|
@@ -0,0 +1,352 @@
|
|
|
+<template>
|
|
|
+ <div class="three-preview" :style="getAreaStyle()">
|
|
|
+ <SerialNumberPosition v-if="isEnable(data.property.sn_display_mode)" :property="data.property" />
|
|
|
+
|
|
|
+ <div ref="three" class="three">
|
|
|
+ <SvgIcon
|
|
|
+ :class-name="iconName()"
|
|
|
+ :icon-class="icon()"
|
|
|
+ :size="isFullScreen ? '32px' : '16px'"
|
|
|
+ @click="handleToggleFullScreen"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script>
|
|
|
+import PreviewMixin from '../common/PreviewMixin';
|
|
|
+
|
|
|
+import * as THREE from 'three';
|
|
|
+import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
|
|
|
+import { FBXLoader } from 'three/examples/jsm/loaders/FBXLoader.js';
|
|
|
+import { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader.js';
|
|
|
+import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
|
|
|
+
|
|
|
+import { get3DModelData } from '@/views/book/courseware/data/3dModel';
|
|
|
+import { toggleFullScreen } from '@/utils/common';
|
|
|
+import { GetFileStoreInfo } from '@/api/app';
|
|
|
+
|
|
|
+export default {
|
|
|
+ name: 'ThreeModelPreview',
|
|
|
+ mixins: [PreviewMixin],
|
|
|
+ data() {
|
|
|
+ return {
|
|
|
+ data: get3DModelData(),
|
|
|
+ scene: null,
|
|
|
+ camera: null,
|
|
|
+ renderer: null,
|
|
|
+ controls: null,
|
|
|
+ animationId: null,
|
|
|
+ isFullScreen: false, // 是否全屏
|
|
|
+ loaded: false, // 是否加载完成
|
|
|
+ };
|
|
|
+ },
|
|
|
+ watch: {
|
|
|
+ 'data.model_list': {
|
|
|
+ handler(val) {
|
|
|
+ if (val && val.length > 0) {
|
|
|
+ this.loadModel();
|
|
|
+ }
|
|
|
+ },
|
|
|
+ immediate: true,
|
|
|
+ deep: true,
|
|
|
+ },
|
|
|
+ },
|
|
|
+ mounted() {
|
|
|
+ this.initThree();
|
|
|
+ window.addEventListener('fullscreenchange', this.onWindowResize);
|
|
|
+ },
|
|
|
+ beforeDestroy() {
|
|
|
+ this.cleanup();
|
|
|
+ window.removeEventListener('fullscreenchange', this.onWindowResize);
|
|
|
+ },
|
|
|
+ methods: {
|
|
|
+ iconName() {
|
|
|
+ return this.loaded ? (this.isFullScreen ? 'exit-fullscreen' : 'fullscreen-btn') : 'loading';
|
|
|
+ },
|
|
|
+ icon() {
|
|
|
+ return this.loaded ? (this.isFullScreen ? 'exit-fullscreen' : 'fullscreen') : 'loading';
|
|
|
+ },
|
|
|
+ initThree() {
|
|
|
+ const container = this.$refs.three;
|
|
|
+ if (!container) return;
|
|
|
+
|
|
|
+ // 创建场景
|
|
|
+ this.scene = new THREE.Scene();
|
|
|
+ this.scene.background = new THREE.Color(0xf0f0f0);
|
|
|
+
|
|
|
+ // 创建相机
|
|
|
+ const width = container.clientWidth;
|
|
|
+ const height = container.clientHeight;
|
|
|
+ this.camera = new THREE.PerspectiveCamera(75, width / height, 0.1, 1000);
|
|
|
+ this.camera.position.set(5, 5, 5);
|
|
|
+
|
|
|
+ // 创建渲染器
|
|
|
+ this.renderer = new THREE.WebGLRenderer({ antialias: true });
|
|
|
+ this.renderer.setSize(width, height);
|
|
|
+ this.renderer.shadowMap.enabled = true;
|
|
|
+ this.renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
|
|
+ container.appendChild(this.renderer.domElement);
|
|
|
+
|
|
|
+ // 添加光源
|
|
|
+ const ambientLight = new THREE.AmbientLight(0x404040, 0.6);
|
|
|
+ this.scene.add(ambientLight);
|
|
|
+
|
|
|
+ const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8);
|
|
|
+ directionalLight.position.set(10, 10, 5);
|
|
|
+ directionalLight.castShadow = true;
|
|
|
+ this.scene.add(directionalLight);
|
|
|
+
|
|
|
+ // 添加控制器
|
|
|
+ this.controls = new OrbitControls(this.camera, this.renderer.domElement);
|
|
|
+ this.controls.enableDamping = true;
|
|
|
+ this.controls.dampingFactor = 0.25;
|
|
|
+
|
|
|
+ // 开始渲染循环
|
|
|
+ this.animate();
|
|
|
+
|
|
|
+ // 监听窗口大小变化
|
|
|
+ window.addEventListener('resize', this.onWindowResize);
|
|
|
+ },
|
|
|
+
|
|
|
+ async loadModel() {
|
|
|
+ if (!this.data.model_list || this.data.model_list.length === 0) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ const fileStore = await GetFileStoreInfo({ file_id: this.data.model_list[0].file_id });
|
|
|
+ if (!fileStore || fileStore.error) {
|
|
|
+ console.error('模型文件加载失败:', fileStore);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const modelUrl = fileStore.file_url;
|
|
|
+ // 根据文件扩展名选择合适的加载器
|
|
|
+ const extension = modelUrl.split('.').pop().toLowerCase();
|
|
|
+ let loader = null;
|
|
|
+
|
|
|
+ switch (extension) {
|
|
|
+ case 'gltf':
|
|
|
+ case 'glb':
|
|
|
+ loader = new GLTFLoader();
|
|
|
+ this.loadGLTF(loader, modelUrl);
|
|
|
+ break;
|
|
|
+ case 'fbx':
|
|
|
+ loader = new FBXLoader();
|
|
|
+ this.loadFBX(loader, modelUrl);
|
|
|
+ break;
|
|
|
+ case 'obj':
|
|
|
+ loader = new OBJLoader();
|
|
|
+ this.loadOBJ(loader, modelUrl);
|
|
|
+ break;
|
|
|
+ default:
|
|
|
+ console.error('不支持的模型格式:', extension);
|
|
|
+ }
|
|
|
+ },
|
|
|
+
|
|
|
+ loadGLTF(loader, url) {
|
|
|
+ loader.load(
|
|
|
+ url,
|
|
|
+ (gltf) => {
|
|
|
+ const model = gltf.scene;
|
|
|
+ this.addModelToScene(model);
|
|
|
+
|
|
|
+ // 如果模型有动画,播放第一个动画
|
|
|
+ if (gltf.animations && gltf.animations.length > 0) {
|
|
|
+ this.mixer = new THREE.AnimationMixer(model);
|
|
|
+ const action = this.mixer.clipAction(gltf.animations[0]);
|
|
|
+ action.play();
|
|
|
+ }
|
|
|
+ },
|
|
|
+ (progress) => {
|
|
|
+ console.log('加载进度:', `${(progress.loaded / progress.total) * 100}%`);
|
|
|
+ if (progress.loaded >= progress.total) {
|
|
|
+ this.loaded = true;
|
|
|
+ }
|
|
|
+ },
|
|
|
+ (error) => {
|
|
|
+ console.error('GLTF模型加载失败:', error);
|
|
|
+ },
|
|
|
+ );
|
|
|
+ },
|
|
|
+
|
|
|
+ loadFBX(loader, url) {
|
|
|
+ loader.load(
|
|
|
+ url,
|
|
|
+ (fbx) => {
|
|
|
+ this.addModelToScene(fbx);
|
|
|
+ },
|
|
|
+ (progress) => {
|
|
|
+ console.log('加载进度:', `${(progress.loaded / progress.total) * 100}%`);
|
|
|
+ if (progress.loaded >= progress.total) {
|
|
|
+ this.loaded = true;
|
|
|
+ }
|
|
|
+ },
|
|
|
+ (error) => {
|
|
|
+ console.error('FBX模型加载失败:', error);
|
|
|
+ },
|
|
|
+ );
|
|
|
+ },
|
|
|
+
|
|
|
+ loadOBJ(loader, url) {
|
|
|
+ loader.load(
|
|
|
+ url,
|
|
|
+ (obj) => {
|
|
|
+ this.addModelToScene(obj);
|
|
|
+ },
|
|
|
+ (progress) => {
|
|
|
+ console.log('加载进度:', `${(progress.loaded / progress.total) * 100}%`);
|
|
|
+ if (progress.loaded >= progress.total) {
|
|
|
+ this.loaded = true;
|
|
|
+ }
|
|
|
+ },
|
|
|
+ (error) => {
|
|
|
+ console.error('OBJ模型加载失败:', error);
|
|
|
+ },
|
|
|
+ );
|
|
|
+ },
|
|
|
+
|
|
|
+ addModelToScene(model) {
|
|
|
+ // 计算模型的包围盒,用于自动调整相机位置
|
|
|
+ const box = new THREE.Box3().setFromObject(model);
|
|
|
+ const center = box.getCenter(new THREE.Vector3());
|
|
|
+ const size = box.getSize(new THREE.Vector3());
|
|
|
+
|
|
|
+ // 将模型移到原点
|
|
|
+ model.position.sub(center);
|
|
|
+ this.scene.add(model);
|
|
|
+
|
|
|
+ // 自动调整相机位置
|
|
|
+ const maxDim = Math.max(size.x, size.y, size.z);
|
|
|
+ const fov = this.camera.fov * (Math.PI / 180);
|
|
|
+ let cameraZ = Math.abs(maxDim / 2 / Math.tan(fov / 2));
|
|
|
+ cameraZ *= 1.5; // 稍微远一点以便观察
|
|
|
+
|
|
|
+ this.camera.position.set(cameraZ, cameraZ, cameraZ);
|
|
|
+ this.camera.lookAt(0, 0, 0);
|
|
|
+ this.controls.target.set(0, 0, 0);
|
|
|
+ },
|
|
|
+
|
|
|
+ animate() {
|
|
|
+ this.animationId = requestAnimationFrame(this.animate);
|
|
|
+
|
|
|
+ // 更新控制器
|
|
|
+ if (this.controls) {
|
|
|
+ this.controls.update();
|
|
|
+ }
|
|
|
+
|
|
|
+ // 更新动画混合器
|
|
|
+ if (this.mixer) {
|
|
|
+ this.mixer.update(0.016); // 假设60fps
|
|
|
+ }
|
|
|
+
|
|
|
+ // 渲染场景
|
|
|
+ if (this.renderer && this.scene && this.camera) {
|
|
|
+ this.renderer.render(this.scene, this.camera);
|
|
|
+ }
|
|
|
+ },
|
|
|
+
|
|
|
+ onWindowResize() {
|
|
|
+ const container = this.$refs.three;
|
|
|
+ this.isFullScreen = document.fullscreenElement !== null;
|
|
|
+ if (!container || !this.camera || !this.renderer) return;
|
|
|
+
|
|
|
+ let height = 0;
|
|
|
+ let width = 0;
|
|
|
+
|
|
|
+ // 检查是否在全屏状态
|
|
|
+ if (document.fullscreenElement) {
|
|
|
+ // 全屏状态下使用整个屏幕尺寸
|
|
|
+ width = window.innerWidth;
|
|
|
+ height = window.innerHeight;
|
|
|
+ } else {
|
|
|
+ // 非全屏状态下使用容器尺寸
|
|
|
+ width = container.clientWidth;
|
|
|
+ height = container.clientHeight;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 更新相机宽高比
|
|
|
+ this.camera.aspect = width / height;
|
|
|
+ this.camera.updateProjectionMatrix();
|
|
|
+
|
|
|
+ // 更新渲染器尺寸
|
|
|
+ this.renderer.setSize(width, height);
|
|
|
+ },
|
|
|
+
|
|
|
+ cleanup() {
|
|
|
+ // 停止动画循环
|
|
|
+ if (this.animationId) {
|
|
|
+ cancelAnimationFrame(this.animationId);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 移除事件监听器
|
|
|
+ window.removeEventListener('resize', this.onWindowResize);
|
|
|
+
|
|
|
+ // 清理Three.js资源
|
|
|
+ if (this.renderer) {
|
|
|
+ this.renderer.dispose();
|
|
|
+ const container = this.$refs.three;
|
|
|
+ if (container && container.contains(this.renderer.domElement)) {
|
|
|
+ container.removeChild(this.renderer.domElement);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (this.scene) {
|
|
|
+ this.scene.clear();
|
|
|
+ }
|
|
|
+ },
|
|
|
+
|
|
|
+ handleToggleFullScreen() {
|
|
|
+ if (!this.$refs.three) return;
|
|
|
+ if (!this.loaded) {
|
|
|
+ this.$message.warning('请等待模型加载完成后,再切换全屏');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ toggleFullScreen(this.$refs.three);
|
|
|
+ // 延迟一帧来确保DOM已经更新
|
|
|
+ this.$nextTick(() => {
|
|
|
+ setTimeout(() => {
|
|
|
+ this.onWindowResize();
|
|
|
+ }, 100);
|
|
|
+ });
|
|
|
+ },
|
|
|
+ },
|
|
|
+};
|
|
|
+</script>
|
|
|
+
|
|
|
+<style lang="scss" scoped>
|
|
|
+@use '@/styles/mixin.scss' as *;
|
|
|
+
|
|
|
+.three-preview {
|
|
|
+ @include preview-base;
|
|
|
+
|
|
|
+ .three {
|
|
|
+ position: relative;
|
|
|
+ overflow: hidden;
|
|
|
+
|
|
|
+ .loading {
|
|
|
+ position: absolute;
|
|
|
+ top: 10px;
|
|
|
+ right: 10px;
|
|
|
+ color: #999;
|
|
|
+
|
|
|
+ @include spin;
|
|
|
+ }
|
|
|
+
|
|
|
+ .fullscreen-btn {
|
|
|
+ position: absolute;
|
|
|
+ top: 10px;
|
|
|
+ right: 10px;
|
|
|
+ z-index: 999;
|
|
|
+ cursor: pointer;
|
|
|
+ }
|
|
|
+
|
|
|
+ .exit-fullscreen {
|
|
|
+ position: fixed;
|
|
|
+ top: 24px;
|
|
|
+ right: 24px;
|
|
|
+ z-index: 999;
|
|
|
+ cursor: pointer;
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+</style>
|