3DModelPreview.vue 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355
  1. <template>
  2. <div class="three-preview" :style="getAreaStyle()">
  3. <SerialNumberPosition v-if="isEnable(data.property.sn_display_mode)" :property="data.property" />
  4. <div ref="three" class="three">
  5. <SvgIcon
  6. :class-name="iconName()"
  7. :icon-class="icon()"
  8. :size="isFullScreen ? '32px' : '16px'"
  9. @click="handleToggleFullScreen"
  10. />
  11. </div>
  12. </div>
  13. </template>
  14. <script>
  15. import PreviewMixin from '../common/PreviewMixin';
  16. import * as THREE from 'three';
  17. import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
  18. import { FBXLoader } from 'three/examples/jsm/loaders/FBXLoader.js';
  19. import { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader.js';
  20. import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
  21. import { get3DModelData } from '@/views/book/courseware/data/3dModel';
  22. import { toggleFullScreen } from '@/utils/common';
  23. import { GetFileStoreInfo } from '@/api/app';
  24. export default {
  25. name: 'ThreeModelPreview',
  26. mixins: [PreviewMixin],
  27. data() {
  28. return {
  29. data: get3DModelData(),
  30. scene: null,
  31. camera: null,
  32. renderer: null,
  33. controls: null,
  34. animationId: null,
  35. isFullScreen: false, // 是否全屏
  36. loaded: false, // 是否加载完成
  37. };
  38. },
  39. watch: {
  40. 'data.model_list': {
  41. handler(val) {
  42. if (val && val.length > 0) {
  43. this.loadModel();
  44. }
  45. },
  46. immediate: true,
  47. deep: true,
  48. },
  49. },
  50. mounted() {
  51. this.initThree();
  52. window.addEventListener('fullscreenchange', this.onWindowResize);
  53. },
  54. beforeDestroy() {
  55. this.cleanup();
  56. window.removeEventListener('fullscreenchange', this.onWindowResize);
  57. },
  58. methods: {
  59. iconName() {
  60. if (this.data.model_list.length === 0) return 'fullscreen-btn';
  61. return this.loaded ? (this.isFullScreen ? 'exit-fullscreen' : 'fullscreen-btn') : 'loading';
  62. },
  63. icon() {
  64. if (this.data.model_list.length === 0) return '';
  65. return this.loaded ? (this.isFullScreen ? 'exit-fullscreen' : 'fullscreen') : 'loading';
  66. },
  67. initThree() {
  68. const container = this.$refs.three;
  69. if (!container) return;
  70. // 创建场景
  71. this.scene = new THREE.Scene();
  72. this.scene.background = new THREE.Color(0xf0f0f0);
  73. // 创建相机
  74. const width = container.clientWidth;
  75. const height = container.clientHeight;
  76. this.camera = new THREE.PerspectiveCamera(75, width / height, 0.1, 1000);
  77. this.camera.position.set(5, 5, 5);
  78. // 创建渲染器
  79. this.renderer = new THREE.WebGLRenderer({ antialias: true });
  80. this.renderer.setSize(width, height);
  81. this.renderer.shadowMap.enabled = true;
  82. this.renderer.shadowMap.type = THREE.PCFSoftShadowMap;
  83. container.appendChild(this.renderer.domElement);
  84. // 添加光源
  85. const ambientLight = new THREE.AmbientLight(0x404040, 0.6);
  86. this.scene.add(ambientLight);
  87. const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8);
  88. directionalLight.position.set(10, 10, 5);
  89. directionalLight.castShadow = true;
  90. this.scene.add(directionalLight);
  91. // 添加控制器
  92. this.controls = new OrbitControls(this.camera, this.renderer.domElement);
  93. this.controls.enableDamping = true;
  94. this.controls.dampingFactor = 0.25;
  95. // 开始渲染循环
  96. this.animate();
  97. // 监听窗口大小变化
  98. window.addEventListener('resize', this.onWindowResize);
  99. },
  100. async loadModel() {
  101. if (!this.data.model_list || this.data.model_list.length === 0) {
  102. return;
  103. }
  104. const fileStore = await GetFileStoreInfo({ file_id: this.data.model_list[0].file_id });
  105. if (!fileStore || fileStore.error) {
  106. console.error('模型文件加载失败:', fileStore);
  107. return;
  108. }
  109. const modelUrl = fileStore.file_url;
  110. // 根据文件扩展名选择合适的加载器
  111. const extension = modelUrl.split('.').pop().toLowerCase();
  112. let loader = null;
  113. switch (extension) {
  114. case 'gltf':
  115. case 'glb':
  116. loader = new GLTFLoader();
  117. this.loadGLTF(loader, modelUrl);
  118. break;
  119. case 'fbx':
  120. loader = new FBXLoader();
  121. this.loadFBX(loader, modelUrl);
  122. break;
  123. case 'obj':
  124. loader = new OBJLoader();
  125. this.loadOBJ(loader, modelUrl);
  126. break;
  127. default:
  128. console.error('不支持的模型格式:', extension);
  129. }
  130. },
  131. loadGLTF(loader, url) {
  132. loader.load(
  133. url,
  134. (gltf) => {
  135. const model = gltf.scene;
  136. this.addModelToScene(model);
  137. // 如果模型有动画,播放第一个动画
  138. if (gltf.animations && gltf.animations.length > 0) {
  139. this.mixer = new THREE.AnimationMixer(model);
  140. const action = this.mixer.clipAction(gltf.animations[0]);
  141. action.play();
  142. }
  143. },
  144. (progress) => {
  145. console.log('加载进度:', `${(progress.loaded / progress.total) * 100}%`);
  146. if (progress.loaded >= progress.total) {
  147. this.loaded = true;
  148. }
  149. },
  150. (error) => {
  151. console.error('GLTF模型加载失败:', error);
  152. },
  153. );
  154. },
  155. loadFBX(loader, url) {
  156. loader.load(
  157. url,
  158. (fbx) => {
  159. this.addModelToScene(fbx);
  160. },
  161. (progress) => {
  162. console.log('加载进度:', `${(progress.loaded / progress.total) * 100}%`);
  163. if (progress.loaded >= progress.total) {
  164. this.loaded = true;
  165. }
  166. },
  167. (error) => {
  168. console.error('FBX模型加载失败:', error);
  169. },
  170. );
  171. },
  172. loadOBJ(loader, url) {
  173. loader.load(
  174. url,
  175. (obj) => {
  176. this.addModelToScene(obj);
  177. },
  178. (progress) => {
  179. console.log('加载进度:', `${(progress.loaded / progress.total) * 100}%`);
  180. if (progress.loaded >= progress.total) {
  181. this.loaded = true;
  182. }
  183. },
  184. (error) => {
  185. console.error('OBJ模型加载失败:', error);
  186. },
  187. );
  188. },
  189. addModelToScene(model) {
  190. // 计算模型的包围盒,用于自动调整相机位置
  191. const box = new THREE.Box3().setFromObject(model);
  192. const center = box.getCenter(new THREE.Vector3());
  193. const size = box.getSize(new THREE.Vector3());
  194. // 将模型移到原点
  195. model.position.sub(center);
  196. this.scene.add(model);
  197. // 自动调整相机位置
  198. const maxDim = Math.max(size.x, size.y, size.z);
  199. const fov = this.camera.fov * (Math.PI / 180);
  200. let cameraZ = Math.abs(maxDim / 2 / Math.tan(fov / 2));
  201. cameraZ *= 1.5; // 稍微远一点以便观察
  202. this.camera.position.set(cameraZ, cameraZ, cameraZ);
  203. this.camera.lookAt(0, 0, 0);
  204. this.controls.target.set(0, 0, 0);
  205. },
  206. animate() {
  207. this.animationId = requestAnimationFrame(this.animate);
  208. // 更新控制器
  209. if (this.controls) {
  210. this.controls.update();
  211. }
  212. // 更新动画混合器
  213. if (this.mixer) {
  214. this.mixer.update(0.016); // 假设60fps
  215. }
  216. // 渲染场景
  217. if (this.renderer && this.scene && this.camera) {
  218. this.renderer.render(this.scene, this.camera);
  219. }
  220. },
  221. onWindowResize() {
  222. const container = this.$refs.three;
  223. this.isFullScreen = document.fullscreenElement !== null;
  224. if (!container || !this.camera || !this.renderer) return;
  225. let height = 0;
  226. let width = 0;
  227. // 检查是否在全屏状态
  228. if (document.fullscreenElement) {
  229. // 全屏状态下使用整个屏幕尺寸
  230. width = window.innerWidth;
  231. height = window.innerHeight;
  232. } else {
  233. // 非全屏状态下使用容器尺寸
  234. width = container.clientWidth;
  235. height = container.clientHeight;
  236. }
  237. // 更新相机宽高比
  238. this.camera.aspect = width / height;
  239. this.camera.updateProjectionMatrix();
  240. // 更新渲染器尺寸
  241. this.renderer.setSize(width, height);
  242. },
  243. cleanup() {
  244. // 停止动画循环
  245. if (this.animationId) {
  246. cancelAnimationFrame(this.animationId);
  247. }
  248. // 移除事件监听器
  249. window.removeEventListener('resize', this.onWindowResize);
  250. // 清理Three.js资源
  251. if (this.renderer) {
  252. this.renderer.dispose();
  253. const container = this.$refs.three;
  254. if (container && container.contains(this.renderer.domElement)) {
  255. container.removeChild(this.renderer.domElement);
  256. }
  257. }
  258. if (this.scene) {
  259. this.scene.clear();
  260. }
  261. },
  262. handleToggleFullScreen() {
  263. if (!this.$refs.three) return;
  264. if (!this.loaded) {
  265. this.$message.warning('请等待模型加载完成后,再切换全屏');
  266. return;
  267. }
  268. toggleFullScreen(this.$refs.three);
  269. // 延迟一帧来确保DOM已经更新
  270. this.$nextTick(() => {
  271. setTimeout(() => {
  272. this.onWindowResize();
  273. }, 100);
  274. });
  275. },
  276. },
  277. };
  278. </script>
  279. <style lang="scss" scoped>
  280. @use '@/styles/mixin.scss' as *;
  281. .three-preview {
  282. @include preview-base;
  283. .three {
  284. position: relative;
  285. min-height: 104px;
  286. overflow: hidden;
  287. .loading {
  288. position: absolute;
  289. top: 10px;
  290. right: 10px;
  291. color: #999;
  292. @include spin;
  293. }
  294. .fullscreen-btn {
  295. position: absolute;
  296. top: 10px;
  297. right: 10px;
  298. z-index: 999;
  299. cursor: pointer;
  300. }
  301. .exit-fullscreen {
  302. position: fixed;
  303. top: 24px;
  304. right: 24px;
  305. z-index: 999;
  306. cursor: pointer;
  307. }
  308. }
  309. }
  310. </style>