diff --git a/CHANGELOG.md b/CHANGELOG.md
index ce41498..74a6120 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,8 @@ The format is based on [Keep a Changelog](https://github.com/olivierlacan/keep-a
### Added
+- Add camera animation for viewer with `animejs`. (https://github.com/yushiang-demo/pano-to-mesh/pull/68)
+
### Changed
### Fixed
diff --git a/apps/viewer.js b/apps/viewer.js
index 8bf2847..7cad10f 100644
--- a/apps/viewer.js
+++ b/apps/viewer.js
@@ -1,6 +1,7 @@
-import React, { useRef, useMemo } from "react";
-
+import React, { useRef, useMemo, useState, useCallback } from "react";
+import Animator from "@pano-to-mesh/anime";
import {
+ Core,
Loaders,
ThreeCanvas,
PanoramaProjectionMesh,
@@ -10,11 +11,17 @@ import {
import useClick2AddWalls from "../hooks/useClick2AddWalls";
import MediaManager from "../components/MediaManager";
import { MEDIA_2D, MEDIA_3D } from "../components/MediaManager/types";
+import useMouseSkipDrag from "../hooks/useMouseSkipDrag";
+import ToolbarRnd from "../components/ToolbarRnd";
+import Toolbar from "../components/Toolbar";
+import Icons from "../components/Icon";
const dev = process.env.NODE_ENV === "development";
const Viewer = ({ data }) => {
const threeRef = useRef(null);
-
+ const [isTopView, setIsTopView] = useState(true);
+ const [isCameraMoving, setIsCameraMoving] = useState(false);
+ const [baseMesh, setBaseMesh] = useState(null);
const geometryInfo = useMemo(
() => ({
floorY: data.floorY,
@@ -35,22 +42,97 @@ const Viewer = ({ data }) => {
panorama: Loaders.useTexture({ src: data.panorama }),
};
- const onLoad = (mesh) => {
+ const onLoad = useCallback((mesh) => {
threeRef.current.cameraControls.focus(mesh);
- };
+ setBaseMesh(mesh);
+ }, []);
const media = (data.media || []).filter(
(data) =>
![MEDIA_3D.PLACEHOLDER_3D, MEDIA_2D.PLACEHOLDER_2D].includes(data.type)
);
+ const runAnimation = (clips, onfinish) => {
+ if (isCameraMoving) return;
+
+ const timeline = Animator.createTimeline();
+ clips.forEach(timeline.addClip);
+ timeline.play();
+ timeline.finished.then(() => {
+ setIsCameraMoving(false);
+ if (onfinish) onfinish();
+ });
+ setIsCameraMoving(true);
+ };
+
+ const goTop = () => {
+ if (isCameraMoving) return;
+
+ const { cameraControls } = threeRef.current;
+ const { animations } = cameraControls;
+ const clip = animations.moveToTop(baseMesh);
+
+ runAnimation(clip, () => setIsTopView(true));
+ };
+
+ const goDown = () => {
+ if (isCameraMoving) return;
+
+ const { cameraControls } = threeRef.current;
+ const { animations } = cameraControls;
+ const clip = animations.moveFromTop(data.panoramaOrigin);
+
+ runAnimation(clip, () => setIsTopView(false));
+ };
+
+ const eventsHandlers = useMouseSkipDrag(({ normalizedX, normalizedY }) => {
+ const { cameraControls } = threeRef.current;
+ const { animations } = cameraControls;
+
+ const intersections = Core.raycastMeshFromScreen(
+ [normalizedX, normalizedY],
+ cameraControls.getCamera(),
+ baseMesh
+ );
+
+ const firstIntersections = intersections[0];
+
+ if (!firstIntersections) return;
+
+ const { faceNormal, point } = firstIntersections;
+ const cameraHeight = data.panoramaOrigin[1];
+ const target = [
+ point[0] + faceNormal[0] * cameraHeight,
+ cameraHeight,
+ point[2] + faceNormal[2] * cameraHeight,
+ ];
+
+ runAnimation(
+ (isTopView ? animations.moveFromTop : animations.moveTo)(target),
+ () => setIsTopView(false)
+ );
+ });
+
return (
-
-
-
-
-
-
+ <>
+
+
+
+
+
+
+ {!isCameraMoving && (
+
+
+ {isTopView ? (
+
+ ) : (
+
+ )}
+
+
+ )}
+ >
);
};
diff --git a/components/Icon/index.js b/components/Icon/index.js
index 57c9f69..15909ac 100644
--- a/components/Icon/index.js
+++ b/components/Icon/index.js
@@ -38,6 +38,8 @@ const Icon = ({ src, onClick, ...props }) => {
// all svg resources is download from https://www.svgrepo.com/vectors/cursor/
const IconFolder = `/icons`;
const files = {
+ arrowToDown: `${IconFolder}/arrowToDown.svg`,
+ arrowToTop: `${IconFolder}/arrowToTop.svg`,
download: `${IconFolder}/download.svg`,
panorama: `${IconFolder}/panorama.svg`,
camera: `${IconFolder}/camera.svg`,
diff --git a/hooks/useMouseSkipDrag.js b/hooks/useMouseSkipDrag.js
new file mode 100644
index 0000000..e070a02
--- /dev/null
+++ b/hooks/useMouseSkipDrag.js
@@ -0,0 +1,36 @@
+import { useState } from "react";
+
+const MOUSE_UP_THRESHOLD = 5;
+
+const useMouseSkipDrag = (handleMouseUp) => {
+ const [cursorPosition, setCursorPosition] = useState(null);
+ const [cumulativeDelta, setCumulativeDelta] = useState(0);
+
+ const onMouseDown = ({ offsetX, offsetY }) => {
+ setCursorPosition({ offsetX, offsetY });
+ setCumulativeDelta(0);
+ };
+ const onMouseMove = ({ offsetX, offsetY }) => {
+ if (!cursorPosition) return;
+ setCumulativeDelta(
+ (prev) =>
+ prev +
+ Math.abs(offsetX - cursorPosition.offsetX) +
+ Math.abs(offsetY - cursorPosition.offsetY)
+ );
+ setCursorPosition({ offsetX, offsetY });
+ };
+ const onMouseUp = (data) => {
+ setCursorPosition(null);
+ if (MOUSE_UP_THRESHOLD < cumulativeDelta) return;
+ handleMouseUp(data);
+ };
+
+ return {
+ onMouseDown,
+ onMouseMove,
+ onMouseUp,
+ };
+};
+
+export default useMouseSkipDrag;
diff --git a/package.json b/package.json
index 1b9b3b7..4e0b312 100644
--- a/package.json
+++ b/package.json
@@ -15,6 +15,7 @@
"lint": "next lint"
},
"dependencies": {
+ "@pano-to-mesh/anime": "1.0.0",
"@pano-to-mesh/three": "1.0.0",
"@pano-to-mesh/base64": "1.0.0",
"next": "13.2.4",
diff --git a/packages/anime/index.js b/packages/anime/index.js
new file mode 100644
index 0000000..ded2605
--- /dev/null
+++ b/packages/anime/index.js
@@ -0,0 +1,47 @@
+import anime from "animejs";
+
+const Animator = (() => {
+ const createTimeline = () => {
+ const animation = anime.timeline({
+ autoplay: false,
+ });
+
+ const addClip = ({
+ begin,
+ update,
+ complete,
+ duration,
+ easing,
+ timeOffset,
+ }) => {
+ animation.add(
+ {
+ duration: duration || 1e3,
+ easing: easing || "linear",
+ update: (anim) => {
+ update(anim.progress / 1e2);
+ },
+ begin: begin,
+ complete: complete,
+ },
+ timeOffset
+ );
+ };
+
+ const play = () => {
+ animation.play();
+ };
+
+ return {
+ finished: animation.finished,
+ addClip,
+ play,
+ };
+ };
+
+ return {
+ createTimeline,
+ };
+})();
+
+export default Animator;
diff --git a/packages/anime/package.json b/packages/anime/package.json
new file mode 100644
index 0000000..ed56e16
--- /dev/null
+++ b/packages/anime/package.json
@@ -0,0 +1,7 @@
+{
+ "version": "1.0.0",
+ "name": "@pano-to-mesh/anime",
+ "dependencies": {
+ "animejs": "^3.2.2"
+ }
+}
diff --git a/packages/three/components/ThreeCanvas/index.js b/packages/three/components/ThreeCanvas/index.js
index 5c9676a..45fd7bb 100644
--- a/packages/three/components/ThreeCanvas/index.js
+++ b/packages/three/components/ThreeCanvas/index.js
@@ -70,6 +70,7 @@ const ThreeCanvas = (
setThree(publicProps);
const cancelResizeListener = addBeforeRenderEvent(() => {
+ if (!WrapperRef.current) return;
const { clientWidth: width, clientHeight: height } = WrapperRef.current;
setCanvasSize(width, height);
css3DControls.setSize(width, height);
diff --git a/packages/three/core/helpers/CameraControls.js b/packages/three/core/helpers/CameraControls.js
index 08d144c..0435700 100644
--- a/packages/three/core/helpers/CameraControls.js
+++ b/packages/three/core/helpers/CameraControls.js
@@ -1,9 +1,62 @@
import * as THREE from "three";
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
+const MODE = {
+ FIRST_PERSON_VIEW: "FIRST_PERSON_VIEW",
+ TOP_VIEW: "TOP_VIEW",
+};
+
+const FOCUS_VIEW_SCALE = 0.8;
+
+const lerp = (start, end, progress) => {
+ return start * (1 - progress) + end * progress;
+};
+
function CameraControls(camera, domElement) {
const controls = new OrbitControls(camera, domElement);
+ const setEnable = (data) => {
+ controls.enabled = data;
+ };
+
+ let viewport = MODE.TOP_VIEW;
+ const setMode = (mode, object) => {
+ viewport = mode;
+ if (mode === MODE.FIRST_PERSON_VIEW) {
+ const viewDirection = new THREE.Vector3()
+ .subVectors(controls.target, camera.position)
+ .setLength(1e-6);
+ controls.target.addVectors(camera.position, viewDirection);
+ controls.maxDistance = 1e-6;
+ controls.minDistance = 1e-6;
+ controls.maxPolarAngle = Math.PI;
+ controls.minPolarAngle = 0;
+ controls.update();
+ }
+
+ if (mode === MODE.TOP_VIEW && object) {
+ const { distance: minDistance } = getFocusSettings(object, 1.0);
+ const { distance: maxDistance } = getFocusSettings(object, 0.5);
+ controls.maxDistance = maxDistance;
+ controls.minDistance = minDistance;
+ controls.maxPolarAngle = Math.PI / 2;
+ controls.minPolarAngle = 0;
+ controls.maxAzimuthAngle = Infinity;
+ controls.minAzimuthAngle = -Infinity;
+ controls.update();
+ }
+ };
+
+ const setPosition = (target) => {
+ const viewDirection = new THREE.Vector3().subVectors(
+ controls.target,
+ camera.position
+ );
+ camera.position.copy(target);
+ controls.target.addVectors(camera.position, viewDirection);
+ controls.update();
+ };
+
const setTarget = (target) => {
const delta = new THREE.Vector3().subVectors(
camera.position,
@@ -38,14 +91,12 @@ function CameraControls(camera, domElement) {
};
let constraintPanEvent = null;
- const focus = (
- object,
- constraintZoom = true,
- constraintPan = true,
- constraintRotate = true
- ) => {
+ const focus = (object, constraintPan = true) => {
if (!object) return;
- const { origin, distance, boundingBox } = getFocusSettings(object, 0.8);
+ const { origin, distance, boundingBox } = getFocusSettings(
+ object,
+ FOCUS_VIEW_SCALE
+ );
lookAt(...origin.toArray());
controls.maxDistance = distance;
@@ -53,25 +104,13 @@ function CameraControls(camera, domElement) {
controls.update();
controls.maxDistance = Infinity;
controls.minDistance = 0;
+ controls.update();
- if (constraintZoom) {
- const { distance: minDistance } = getFocusSettings(object, 1.0);
- const { distance: maxDistance } = getFocusSettings(object, 0.5);
- controls.maxDistance = maxDistance;
- controls.minDistance = minDistance;
- controls.update();
- }
-
- if (constraintRotate) {
- controls.maxPolarAngle = Math.PI / 2;
- controls.minPolarAngle = 0;
- controls.maxAzimuthAngle = Infinity;
- controls.minAzimuthAngle = -Infinity;
- controls.update();
- }
+ setMode(MODE.TOP_VIEW, object);
if (constraintPan) {
const checkTarget = () => {
+ if (viewport !== MODE.TOP_VIEW) return;
if (
boundingBox.min.length() === Infinity ||
boundingBox.max.length() === Infinity
@@ -107,15 +146,92 @@ function CameraControls(camera, domElement) {
}
};
+ const moveToTop = (object) => {
+ const { distance } = getFocusSettings(object, FOCUS_VIEW_SCALE);
+
+ const startDistance = controls.getDistance();
+ const endDistance = distance;
+
+ const distanceClip = {
+ update: (progress) => {
+ const targetDistance = lerp(startDistance, endDistance, progress);
+ controls.maxDistance = targetDistance;
+ controls.minDistance = targetDistance;
+ controls.update();
+ },
+ };
+
+ const startPolarAngle = controls.getPolarAngle();
+ const endPolarAngle = Math.min(startPolarAngle, Math.PI / 4);
+
+ const polarAngleClip = {
+ update: (progress) => {
+ const targetPolarAngle = lerp(startPolarAngle, endPolarAngle, progress);
+ controls.maxPolarAngle = targetPolarAngle;
+ controls.minPolarAngle = targetPolarAngle;
+ controls.update();
+ },
+ duration: 500,
+ timeOffset: 0,
+ };
+
+ const completeClip = {
+ update: () => {},
+ complete: () => {
+ controls.maxDistance = endDistance;
+ controls.minDistance = endDistance;
+ controls.maxPolarAngle = endPolarAngle;
+ controls.minPolarAngle = endPolarAngle;
+ controls.update();
+ setMode(MODE.TOP_VIEW, object);
+ },
+ };
+
+ const clips = [distanceClip, polarAngleClip, completeClip];
+
+ return clips;
+ };
+
+ const moveTo = (target) => {
+ const start = camera.position;
+ const end = new THREE.Vector3().fromArray(target);
+ const update = (progress) => {
+ const position = new THREE.Vector3().lerpVectors(start, end, progress);
+ setPosition(position);
+ };
+ const complete = () => setPosition(end);
+
+ const clips = [{ update, complete }];
+
+ return clips;
+ };
+
+ const moveFromTop = (target) => {
+ const start = camera.position;
+ const end = new THREE.Vector3().fromArray(target);
+ const update = (progress) => {
+ const position = new THREE.Vector3().lerpVectors(start, end, progress);
+ setPosition(position);
+ };
+ const begin = () => setMode(MODE.FIRST_PERSON_VIEW);
+ const complete = () => setPosition(end);
+
+ const clips = [{ begin, update, complete }];
+ return clips;
+ };
+
return {
domElement,
getCamera: () => controls.object,
- setEnable: (data) => {
- controls.enabled = data;
- },
+ setEnable,
lookAt,
focus,
destroy,
+ animations: {
+ moveFromTop,
+ moveTo,
+ moveToTop,
+ },
};
}
diff --git a/packages/three/core/helpers/Raycaster.js b/packages/three/core/helpers/Raycaster.js
index d7a48a0..b2af40a 100644
--- a/packages/three/core/helpers/Raycaster.js
+++ b/packages/three/core/helpers/Raycaster.js
@@ -26,7 +26,10 @@ export const raycastMeshFromScreen = (
const raycaster = new THREE.Raycaster();
const pointer = new THREE.Vector2(normalizedX * 2 - 1, -normalizedY * 2 + 1);
raycaster.setFromCamera(pointer, camera);
- const intersects = raycaster.intersectObjects(mesh, true);
+ const intersects = raycaster.intersectObjects(
+ Array.isArray(mesh) ? mesh : [mesh],
+ true
+ );
const applyWorldMatrix = (normal, object) => {
const position = new THREE.Vector3();
diff --git a/public/icons/arrowToDown.svg b/public/icons/arrowToDown.svg
new file mode 100644
index 0000000..996ff61
--- /dev/null
+++ b/public/icons/arrowToDown.svg
@@ -0,0 +1,4 @@
+
+
\ No newline at end of file
diff --git a/public/icons/arrowToTop.svg b/public/icons/arrowToTop.svg
new file mode 100644
index 0000000..836f66a
--- /dev/null
+++ b/public/icons/arrowToTop.svg
@@ -0,0 +1,4 @@
+
+
\ No newline at end of file
diff --git a/yarn.lock b/yarn.lock
index 19c1d13..3d7cd1c 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1592,6 +1592,11 @@ ajv@^6.12.4:
json-schema-traverse "^0.4.1"
uri-js "^4.2.2"
+animejs@^3.2.2:
+ version "3.2.2"
+ resolved "https://registry.yarnpkg.com/animejs/-/animejs-3.2.2.tgz#59be98c58834339d5847f4a70ddba74ac75b6afc"
+ integrity sha512-Ao95qWLpDPXXM+WrmwcKbl6uNlC5tjnowlaRYtuVDHHoygjtIPfDUoK9NthrlZsQSKjZXlmji2TrBUAVbiH0LQ==
+
ansi-regex@^5.0.1:
version "5.0.1"
resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304"