diff --git a/CHANGELOG.md b/CHANGELOG.md
index ce41498..3e84a9b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,6 +13,20 @@ The format is based on [Keep a Changelog](https://github.com/olivierlacan/keep-a
### Removed
+## [2.0.0] - 2023-12-16
+
+### Added
+
+- Add camera animation for viewer with `animejs`. (https://github.com/yushiang-demo/pano-to-mesh/pull/68)
+
+### Changed
+
+- Change README.md demo links and snapshots. (https://github.com/yushiang-demo/pano-to-mesh/pull/50)
+
+### Fixed
+
+### Removed
+
## [1.4.1] - 2023-11-14
### Added
@@ -171,7 +185,8 @@ Codes without pull requests won't be recorded.
### Removed
-[unreleased]: https://github.com/yushiang-demo/PanoToMesh/compare/v1.4.1...HEAD
+[unreleased]: https://github.com/yushiang-demo/PanoToMesh/compare/v2.0.0...HEAD
+[2.0.0]: https://github.com/yushiang-demo/PanoToMesh/compare/v1.4.1...v2.0.0
[1.4.1]: https://github.com/yushiang-demo/PanoToMesh/compare/v1.4.0...v1.4.1
[1.4.0]: https://github.com/yushiang-demo/PanoToMesh/compare/v1.3.0...v1.4.0
[1.3.0]: https://github.com/yushiang-demo/PanoToMesh/compare/v1.2.0...v1.3.0
diff --git a/Demo.png b/Demo.png
deleted file mode 100644
index 9692967..0000000
Binary files a/Demo.png and /dev/null differ
diff --git a/README.md b/README.md
index 575a22f..8ca45d0 100644
--- a/README.md
+++ b/README.md
@@ -1,50 +1,50 @@
-# Quick start
+# Pano-to-mesh
-## Development with Yarn
+Our tool simplifies 3D panorama creation. You can annotate layouts, preview 3D meshes, export them with textures, arrange your media, share a viewer link for collaboration, and also experience immersive interaction through a first-person view.
+
+## Quick start
+
+### Development
```
yarn
yarn dev
```
-## Production with Docker
+### Production
-### From Dockerfile
+- From Dockerfile
```
docker build . -t pano-to-mesh --no-cache
docker run -p 3000:3000 pano-to-mesh
```
-### From Github Docker Registry
+- From Github Docker Registry
```
docker run -p 3000:3000 ghcr.io/yushiang-demo/pano-to-mesh
```
-# Introduction
-
-## Demo
-
-### Editor
-
-- [Panorama to mesh](https://pano-to-mesh.vercel.app/editors/layout2d#eNpdkE9rwzAMxb-Lz8aWHP-Rcyyjt7WHwqCUENKsSTMyJ6TutjL23ecUtrDp8EPiPZ6EPlnTD8O0ZzlwVp-6vgttGlgmtGac9dVtuEb1wPLDAQTav5VxEIZcBriw4MmoQQM6Mg61mmlmo7fGAukf3o1Wg1WzmhqFuPCueufsvxQLhhTgL4uCs7EKw1S9Vunsc4zjJZeyuqBoYv0cRDhF-abky9hKQEleAklHEgGgXJdIHsgRuXK7mvqsQXT7rh693q3O6_fH28dTe9yYTVMfRUpgy67t1LVdSG8BjmJ-RPH1DUTpVnM)
-- [Mesh preview and download](https://pano-to-mesh.vercel.app/editors/layout3d#eNpdkE9rwzAMxb-Lz8aWHP-Rcyyjt7WHwqCUENKsSTMyJ6TutjL23ecUtrDp8EPiPZ6EPlnTD8O0ZzlwVp-6vgttGlgmtGac9dVtuEb1wPLDAQTav5VxEIZcBriw4MmoQQM6Mg61mmlmo7fGAukf3o1Wg1WzmhqFuPCueufsvxQLhhTgL4uCs7EKw1S9Vunsc4zjJZeyuqBoYv0cRDhF-abky9hKQEleAklHEgGgXJdIHsgRuXK7mvqsQXT7rh693q3O6_fH28dTe9yYTVMfRUpgy67t1LVdSG8BjmJ-RPH1DUTpVnM)
-- [Decoration with media](https://pano-to-mesh.vercel.app/editors/decoration#eNpdkE9rwzAMxb-Lz8aWHP-Rcyyjt7WHwqCUENKsSTMyJ6TutjL23ecUtrDp8EPiPZ6EPlnTD8O0ZzlwVp-6vgttGlgmtGac9dVtuEb1wPLDAQTav5VxEIZcBriw4MmoQQM6Mg61mmlmo7fGAukf3o1Wg1WzmhqFuPCueufsvxQLhhTgL4uCs7EKw1S9Vunsc4zjJZeyuqBoYv0cRDhF-abky9hKQEleAklHEgGgXJdIHsgRuXK7mvqsQXT7rh693q3O6_fH28dTe9yYTVMfRUpgy67t1LVdSG8BjmJ-RPH1DUTpVnM)
+## Features
-![image](./Demo.png)
+- Annotate panorama layouts and preview 3D meshes.
+- Export meshes and textures.
+- Arrange media, including 3D meshes and HTML elements.
+- Experience a first-person view and interact in the viewer.
-### Viewer
+| [Panorama to mesh] | [Mesh preview and download] |
+| :----------------------: | :-------------------------: |
+| ![2d.png](./docs/2d.png) | ![3d.png](./docs/3d.png) |
-- [Viewer](https://pano-to-mesh.vercel.app#eNpdkE9rwzAMxb-Lz8aWHP-Rcyyjt7WHwqCUENKsSTMyJ6TutjL23ecUtrDp8EPiPZ6EPlnTD8O0ZzlwVp-6vgttGlgmtGac9dVtuEb1wPLDAQTav5VxEIZcBriw4MmoQQM6Mg61mmlmo7fGAukf3o1Wg1WzmhqFuPCueufsvxQLhhTgL4uCs7EKw1S9Vunsc4zjJZeyuqBoYv0cRDhF-abky9hKQEleAklHEgGgXJdIHsgRuXK7mvqsQXT7rh693q3O6_fH28dTe9yYTVMfRUpgy67t1LVdSG8BjmJ-RPH1DUTpVnM)
+| [Decoration with media] | [Viewer] |
+| :----------------------------: | :-----------------------------: |
+| ![media.png](./docs/media.png) | ![view.png](./docs//viewer.png) |
> Demo image is provided by:
> https://as1.ftcdn.net/v2/jpg/01/89/08/78/1000_F_189087887_OBrl3f117Yicp94SBhFwMyxVgbN5Nfcb.jpg
-## Features
-
-- Load panorama from url.
-- Annotate layout from a panorama and preview 3D Mesh.
-- Save 3D mesh and texture to local.
-- Share result of viewer and editor with data embedded in URL.
-- Editor of arranging media for viewer.
+[Panorama to mesh]: https://pano-to-mesh.vercel.app/editors/layout2d#eNrNl19vGzcQxL9Kcc-XM_8suaSf89KHokVSFC1co7jYZ1mtpFMlGagR-Lv3t1ITSJGdKIkLRBBki-LdcYczO8O3zbJfjKt-3jfnzdlqWI93q6thffZutPtzOWna95N-XE0n00VzfuFa36XWXbbNzWwcV781565trobpbLqY8KWJXYxcN-vvx7tNeMkVF67LMfoiKcTiqn22NuRC9c6l3We8bJkXa06q72a5GpmYqob913aiz6FKllCSej7zdmLRyvD793Zi1SIhZF-dKJ_2YJel-PfvdEkp8-F6Cg4Xb5vNql-sb8bVvN9MR-p92yzH9XT3_8UL37lSJGsNKhrE81zfFe9zjb5ahTGqtKFLOWjiq69KmUV4xPqqnw3cI3bOB599VldEVEtkvovBxSjM9S5w__Z4Frf4-67fDKvFbi2OUooLPCnzp7joom_dFq-csjKQxIG7XD60zeZ-ybObX6bXw8jmrIb-elzM7pvzm362Htrmut_0Vut6dXXIhi0w3XwpzcND-yl0qENYcpJaYmRZBXAkaSilGjo1ssb2BTvAN6pLxUnVHMsBOjGoyz55cdV5DTtwqnPFx1qSz7k9mvMc0Ly-7a-H1XdfgxAyGGfjqjln11Sp79OQUYpLmjOrrDH5IEq9vgSrqgT4rD4BmQELg6nZJ0215kM-MQAdIaYmJJNAHT4ml0SQhkguvj2edQzadmdAOEUH7QIbFraocQ3Q80sqNedU9lD7ft5Phi-Aa7mYnEAo6RxKK67kADIoxFpPVHQMvFFThGoMOYlVAjpJVdjgcAgOmpRATUg27aCBPIhTUWzNYnQ6nPMswPxHp6_B5wvoZLT3KpVqTIBarOBEj0SQjKM1FegUO2st1rZiSpn-eYCYZtopjbjkLHAow8iybZlwr9Lg6N4RWQNWTakgSAilj0nQR5_o47TxYMJLW8xq9cgxhZxrcjGHfTLdYDQnoHW72SzX52dnm_WwmNzfrW-n_WLSTaab27s33XQ8W4wbgDylYWE4ScU5oekG6kMooQsZr8p0L6mMBlNfxapctvaOVHM6gCvRwLxxoJZcIQYMg2ASksPBog-xGkVj0Wx3K-qpuzwHXD8P_2w-BtbGfj9vXg7z8ffFq3Gcn4IIbuZEos-IyUWhFVlDqhRWIjjhtIyw1iJmawVZ0r2C30PE2bqfoyP_NOuvhttxZjoiRzxd6UllVfHBGmhE--rj1qPFFeyVFMGgU9toUSlAr1GwX9RySl2fu2_PW1dBhSZVnkfgcBY-tFJqwCxrjUFYEIXhBoZxNhQ8qv72C9vuFDGuKFmLnEBhtZCQotHFw9KtMjG6gkk6lUxrTulbr8t1YiEmZUyLeFh2jq-0eCKrVxKS7loOQouKYSdhUb5-43WhL-eNdmJbVWiS1gfxZ6qg7zkXyTPYRsLIqdr6aMBK9qoSnD1hUuR04jqR0WAJ2QxXwYWWmtqjOY9adS2cDOjFmUhVS9lZtSdEizpvtshy_P_UaIKddCqHFJIIIcULj0Zv-y_xtuMQFlrTETFhQat7SHAPnCJTBquF7SKWeGEI8ZCaSG-ixyNHULASOh2wKZ5D3PGGjaVN5aQBRcgEduunBj-8eg-vH4b17WdlmtdgOx1W3WT2xlLNbLrk91d3i-aURCPwF2aH7YHQU4YhauRgvZhRxmA5PtZ4gKBgt4kYnZNdvUWc7BaZD0eLxbb2eNKHEIIC51IsAyAKO0X4cbahoAInYZJy0nGaHx86vvRrIPz1zbg5xG-9GPq__gAqS0yb6dxua3KvzSlyxSCUUwcLTLZWDgd7L-9qSweGwqTaoGonD866ewjTntV7zmRwkBRA-kXv5EMykp31yEIkqvZ41iMQE5oQOZkoVYuoamJlq4LpnIxanBb_xNiHlz6h6fhxTV8-_Avsc5U2
+[Mesh preview and download]: https://pano-to-mesh.vercel.app/editors/layout3d#eNrNl19vGzcQxL9Kcc-XM_8suaSf89KHokVSFC1co7jYZ1mtpFMlGagR-Lv3t1ITSJGdKIkLRBBki-LdcYczO8O3zbJfjKt-3jfnzdlqWI93q6thffZutPtzOWna95N-XE0n00VzfuFa36XWXbbNzWwcV781565trobpbLqY8KWJXYxcN-vvx7tNeMkVF67LMfoiKcTiqn22NuRC9c6l3We8bJkXa06q72a5GpmYqob913aiz6FKllCSej7zdmLRyvD793Zi1SIhZF-dKJ_2YJel-PfvdEkp8-F6Cg4Xb5vNql-sb8bVvN9MR-p92yzH9XT3_8UL37lSJGsNKhrE81zfFe9zjb5ahTGqtKFLOWjiq69KmUV4xPqqnw3cI3bOB599VldEVEtkvovBxSjM9S5w__Z4Frf4-67fDKvFbi2OUooLPCnzp7joom_dFq-csjKQxIG7XD60zeZ-ybObX6bXw8jmrIb-elzM7pvzm362Htrmut_0Vut6dXXIhi0w3XwpzcND-yl0qENYcpJaYmRZBXAkaSilGjo1ssb2BTvAN6pLxUnVHMsBOjGoyz55cdV5DTtwqnPFx1qSz7k9mvMc0Ly-7a-H1XdfgxAyGGfjqjln11Sp79OQUYpLmjOrrDH5IEq9vgSrqgT4rD4BmQELg6nZJ0215kM-MQAdIaYmJJNAHT4ml0SQhkguvj2edQzadmdAOEUH7QIbFraocQ3Q80sqNedU9lD7ft5Phi-Aa7mYnEAo6RxKK67kADIoxFpPVHQMvFFThGoMOYlVAjpJVdjgcAgOmpRATUg27aCBPIhTUWzNYnQ6nPMswPxHp6_B5wvoZLT3KpVqTIBarOBEj0SQjKM1FegUO2st1rZiSpn-eYCYZtopjbjkLHAow8iybZlwr9Lg6N4RWQNWTakgSAilj0nQR5_o47TxYMJLW8xq9cgxhZxrcjGHfTLdYDQnoHW72SzX52dnm_WwmNzfrW-n_WLSTaab27s33XQ8W4wbgDylYWE4ScU5oekG6kMooQsZr8p0L6mMBlNfxapctvaOVHM6gCvRwLxxoJZcIQYMg2ASksPBog-xGkVj0Wx3K-qpuzwHXD8P_2w-BtbGfj9vXg7z8ffFq3Gcn4IIbuZEos-IyUWhFVlDqhRWIjjhtIyw1iJmawVZ0r2C30PE2bqfoyP_NOuvhttxZjoiRzxd6UllVfHBGmhE--rj1qPFFeyVFMGgU9toUSlAr1GwX9RySl2fu2_PW1dBhSZVnkfgcBY-tFJqwCxrjUFYEIXhBoZxNhQ8qv72C9vuFDGuKFmLnEBhtZCQotHFw9KtMjG6gkk6lUxrTulbr8t1YiEmZUyLeFh2jq-0eCKrVxKS7loOQouKYSdhUb5-43WhL-eNdmJbVWiS1gfxZ6qg7zkXyTPYRsLIqdr6aMBK9qoSnD1hUuR04jqR0WAJ2QxXwYWWmtqjOY9adS2cDOjFmUhVS9lZtSdEizpvtshy_P_UaIKddCqHFJIIIcULj0Zv-y_xtuMQFlrTETFhQat7SHAPnCJTBquF7SKWeGEI8ZCaSG-ixyNHULASOh2wKZ5D3PGGjaVN5aQBRcgEduunBj-8eg-vH4b17WdlmtdgOx1W3WT2xlLNbLrk91d3i-aURCPwF2aH7YHQU4YhauRgvZhRxmA5PtZ4gKBgt4kYnZNdvUWc7BaZD0eLxbb2eNKHEIIC51IsAyAKO0X4cbahoAInYZJy0nGaHx86vvRrIPz1zbg5xG-9GPq__gAqS0yb6dxua3KvzSlyxSCUUwcLTLZWDgd7L-9qSweGwqTaoGonD866ewjTntV7zmRwkBRA-kXv5EMykp31yEIkqvZ41iMQE5oQOZkoVYuoamJlq4LpnIxanBb_xNiHlz6h6fhxTV8-_Avsc5U2
+[Decoration with media]: https://pano-to-mesh.vercel.app/editors/decoration#eNrNl19vGzcQxL9Kcc-XM_8suaSf89KHokVSFC1co7jYZ1mtpFMlGagR-Lv3t1ITSJGdKIkLRBBki-LdcYczO8O3zbJfjKt-3jfnzdlqWI93q6thffZutPtzOWna95N-XE0n00VzfuFa36XWXbbNzWwcV781565trobpbLqY8KWJXYxcN-vvx7tNeMkVF67LMfoiKcTiqn22NuRC9c6l3We8bJkXa06q72a5GpmYqob913aiz6FKllCSej7zdmLRyvD793Zi1SIhZF-dKJ_2YJel-PfvdEkp8-F6Cg4Xb5vNql-sb8bVvN9MR-p92yzH9XT3_8UL37lSJGsNKhrE81zfFe9zjb5ahTGqtKFLOWjiq69KmUV4xPqqnw3cI3bOB599VldEVEtkvovBxSjM9S5w__Z4Frf4-67fDKvFbi2OUooLPCnzp7joom_dFq-csjKQxIG7XD60zeZ-ybObX6bXw8jmrIb-elzM7pvzm362Htrmut_0Vut6dXXIhi0w3XwpzcND-yl0qENYcpJaYmRZBXAkaSilGjo1ssb2BTvAN6pLxUnVHMsBOjGoyz55cdV5DTtwqnPFx1qSz7k9mvMc0Ly-7a-H1XdfgxAyGGfjqjln11Sp79OQUYpLmjOrrDH5IEq9vgSrqgT4rD4BmQELg6nZJ0215kM-MQAdIaYmJJNAHT4ml0SQhkguvj2edQzadmdAOEUH7QIbFraocQ3Q80sqNedU9lD7ft5Phi-Aa7mYnEAo6RxKK67kADIoxFpPVHQMvFFThGoMOYlVAjpJVdjgcAgOmpRATUg27aCBPIhTUWzNYnQ6nPMswPxHp6_B5wvoZLT3KpVqTIBarOBEj0SQjKM1FegUO2st1rZiSpn-eYCYZtopjbjkLHAow8iybZlwr9Lg6N4RWQNWTakgSAilj0nQR5_o47TxYMJLW8xq9cgxhZxrcjGHfTLdYDQnoHW72SzX52dnm_WwmNzfrW-n_WLSTaab27s33XQ8W4wbgDylYWE4ScU5oekG6kMooQsZr8p0L6mMBlNfxapctvaOVHM6gCvRwLxxoJZcIQYMg2ASksPBog-xGkVj0Wx3K-qpuzwHXD8P_2w-BtbGfj9vXg7z8ffFq3Gcn4IIbuZEos-IyUWhFVlDqhRWIjjhtIyw1iJmawVZ0r2C30PE2bqfoyP_NOuvhttxZjoiRzxd6UllVfHBGmhE--rj1qPFFeyVFMGgU9toUSlAr1GwX9RySl2fu2_PW1dBhSZVnkfgcBY-tFJqwCxrjUFYEIXhBoZxNhQ8qv72C9vuFDGuKFmLnEBhtZCQotHFw9KtMjG6gkk6lUxrTulbr8t1YiEmZUyLeFh2jq-0eCKrVxKS7loOQouKYSdhUb5-43WhL-eNdmJbVWiS1gfxZ6qg7zkXyTPYRsLIqdr6aMBK9qoSnD1hUuR04jqR0WAJ2QxXwYWWmtqjOY9adS2cDOjFmUhVS9lZtSdEizpvtshy_P_UaIKddCqHFJIIIcULj0Zv-y_xtuMQFlrTETFhQat7SHAPnCJTBquF7SKWeGEI8ZCaSG-ixyNHULASOh2wKZ5D3PGGjaVN5aQBRcgEduunBj-8eg-vH4b17WdlmtdgOx1W3WT2xlLNbLrk91d3i-aURCPwF2aH7YHQU4YhauRgvZhRxmA5PtZ4gKBgt4kYnZNdvUWc7BaZD0eLxbb2eNKHEIIC51IsAyAKO0X4cbahoAInYZJy0nGaHx86vvRrIPz1zbg5xG-9GPq__gAqS0yb6dxua3KvzSlyxSCUUwcLTLZWDgd7L-9qSweGwqTaoGonD866ewjTntV7zmRwkBRA-kXv5EMykp31yEIkqvZ41iMQE5oQOZkoVYuoamJlq4LpnIxanBb_xNiHlz6h6fhxTV8-_Avsc5U2
+[Viewer]: https://pano-to-mesh.vercel.app/#eNrNl19vGzcQxL9Kcc-XM_8suaSf89KHokVSFC1co7jYZ1mtpFMlGagR-Lv3t1ITSJGdKIkLRBBki-LdcYczO8O3zbJfjKt-3jfnzdlqWI93q6thffZutPtzOWna95N-XE0n00VzfuFa36XWXbbNzWwcV781565trobpbLqY8KWJXYxcN-vvx7tNeMkVF67LMfoiKcTiqn22NuRC9c6l3We8bJkXa06q72a5GpmYqob913aiz6FKllCSej7zdmLRyvD793Zi1SIhZF-dKJ_2YJel-PfvdEkp8-F6Cg4Xb5vNql-sb8bVvN9MR-p92yzH9XT3_8UL37lSJGsNKhrE81zfFe9zjb5ahTGqtKFLOWjiq69KmUV4xPqqnw3cI3bOB599VldEVEtkvovBxSjM9S5w__Z4Frf4-67fDKvFbi2OUooLPCnzp7joom_dFq-csjKQxIG7XD60zeZ-ybObX6bXw8jmrIb-elzM7pvzm362Htrmut_0Vut6dXXIhi0w3XwpzcND-yl0qENYcpJaYmRZBXAkaSilGjo1ssb2BTvAN6pLxUnVHMsBOjGoyz55cdV5DTtwqnPFx1qSz7k9mvMc0Ly-7a-H1XdfgxAyGGfjqjln11Sp79OQUYpLmjOrrDH5IEq9vgSrqgT4rD4BmQELg6nZJ0215kM-MQAdIaYmJJNAHT4ml0SQhkguvj2edQzadmdAOEUH7QIbFraocQ3Q80sqNedU9lD7ft5Phi-Aa7mYnEAo6RxKK67kADIoxFpPVHQMvFFThGoMOYlVAjpJVdjgcAgOmpRATUg27aCBPIhTUWzNYnQ6nPMswPxHp6_B5wvoZLT3KpVqTIBarOBEj0SQjKM1FegUO2st1rZiSpn-eYCYZtopjbjkLHAow8iybZlwr9Lg6N4RWQNWTakgSAilj0nQR5_o47TxYMJLW8xq9cgxhZxrcjGHfTLdYDQnoHW72SzX52dnm_WwmNzfrW-n_WLSTaab27s33XQ8W4wbgDylYWE4ScU5oekG6kMooQsZr8p0L6mMBlNfxapctvaOVHM6gCvRwLxxoJZcIQYMg2ASksPBog-xGkVj0Wx3K-qpuzwHXD8P_2w-BtbGfj9vXg7z8ffFq3Gcn4IIbuZEos-IyUWhFVlDqhRWIjjhtIyw1iJmawVZ0r2C30PE2bqfoyP_NOuvhttxZjoiRzxd6UllVfHBGmhE--rj1qPFFeyVFMGgU9toUSlAr1GwX9RySl2fu2_PW1dBhSZVnkfgcBY-tFJqwCxrjUFYEIXhBoZxNhQ8qv72C9vuFDGuKFmLnEBhtZCQotHFw9KtMjG6gkk6lUxrTulbr8t1YiEmZUyLeFh2jq-0eCKrVxKS7loOQouKYSdhUb5-43WhL-eNdmJbVWiS1gfxZ6qg7zkXyTPYRsLIqdr6aMBK9qoSnD1hUuR04jqR0WAJ2QxXwYWWmtqjOY9adS2cDOjFmUhVS9lZtSdEizpvtshy_P_UaIKddCqHFJIIIcULj0Zv-y_xtuMQFlrTETFhQat7SHAPnCJTBquF7SKWeGEI8ZCaSG-ixyNHULASOh2wKZ5D3PGGjaVN5aQBRcgEduunBj-8eg-vH4b17WdlmtdgOx1W3WT2xlLNbLrk91d3i-aURCPwF2aH7YHQU4YhauRgvZhRxmA5PtZ4gKBgt4kYnZNdvUWc7BaZD0eLxbb2eNKHEIIC51IsAyAKO0X4cbahoAInYZJy0nGaHx86vvRrIPz1zbg5xG-9GPq__gAqS0yb6dxua3KvzSlyxSCUUwcLTLZWDgd7L-9qSweGwqTaoGonD866ewjTntV7zmRwkBRA-kXv5EMykp31yEIkqvZ41iMQE5oQOZkoVYuoamJlq4LpnIxanBb_xNiHlz6h6fhxTV8-_Avsc5U2
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/docs/2d.png b/docs/2d.png
new file mode 100644
index 0000000..02b3a3e
Binary files /dev/null and b/docs/2d.png differ
diff --git a/docs/3d.png b/docs/3d.png
new file mode 100644
index 0000000..9a19d8f
Binary files /dev/null and b/docs/3d.png differ
diff --git a/docs/media.png b/docs/media.png
new file mode 100644
index 0000000..ace3f49
Binary files /dev/null and b/docs/media.png differ
diff --git a/docs/viewer.png b/docs/viewer.png
new file mode 100644
index 0000000..0078e51
Binary files /dev/null and b/docs/viewer.png differ
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"