2026-01-09 18:14:35 +08:00
|
|
|
|
<template>
|
|
|
|
|
|
<div class="model-viewer-page">
|
|
|
|
|
|
<!-- Header -->
|
|
|
|
|
|
<div class="viewer-header">
|
|
|
|
|
|
<div class="header-left">
|
|
|
|
|
|
<a-button type="text" @click="handleBack">
|
|
|
|
|
|
<template #icon><ArrowLeftOutlined /></template>
|
|
|
|
|
|
返回
|
|
|
|
|
|
</a-button>
|
|
|
|
|
|
<span class="title">3D 模型预览</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="header-right">
|
|
|
|
|
|
<a-button type="text" @click="resetCamera">
|
|
|
|
|
|
<template #icon><AimOutlined /></template>
|
|
|
|
|
|
重置视角
|
|
|
|
|
|
</a-button>
|
|
|
|
|
|
<a-button type="text" @click="toggleFullscreen">
|
|
|
|
|
|
<template #icon><FullscreenOutlined v-if="!isFullscreen" /><FullscreenExitOutlined v-else /></template>
|
|
|
|
|
|
{{ isFullscreen ? '退出全屏' : '全屏' }}
|
|
|
|
|
|
</a-button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 3D Viewer -->
|
|
|
|
|
|
<div class="viewer-content">
|
|
|
|
|
|
<div ref="containerRef" class="model-canvas"></div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Loading -->
|
|
|
|
|
|
<div v-if="loading" class="loading-overlay">
|
|
|
|
|
|
<a-spin size="large" tip="正在加载模型..." />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Error -->
|
|
|
|
|
|
<div v-if="error" class="error-overlay">
|
|
|
|
|
|
<a-result status="error" title="模型加载失败" :sub-title="error">
|
|
|
|
|
|
<template #extra>
|
|
|
|
|
|
<a-button type="primary" @click="handleRetry">重试</a-button>
|
|
|
|
|
|
<a-button @click="handleBack">返回</a-button>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</a-result>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Controls Info -->
|
|
|
|
|
|
<div v-if="!loading && !error" class="controls-info">
|
|
|
|
|
|
<div class="control-hint">
|
|
|
|
|
|
<span><DragOutlined /> 左键:旋转</span>
|
|
|
|
|
|
<span><DragOutlined /> 右键:平移</span>
|
|
|
|
|
|
<span>滚轮:缩放</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Model Info -->
|
|
|
|
|
|
<div v-if="!loading && !error && modelInfo" class="model-info">
|
|
|
|
|
|
<div class="info-item">
|
|
|
|
|
|
<span class="label">尺寸:</span>
|
|
|
|
|
|
<span class="value">{{ modelInfo.size }}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="info-item">
|
|
|
|
|
|
<span class="label">顶点数:</span>
|
|
|
|
|
|
<span class="value">{{ modelInfo.vertices }}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="info-item">
|
|
|
|
|
|
<span class="label">面数:</span>
|
|
|
|
|
|
<span class="value">{{ modelInfo.faces }}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<script setup lang="ts">
|
|
|
|
|
|
import { ref, onMounted, onUnmounted } from "vue"
|
|
|
|
|
|
import { useRouter, useRoute } from "vue-router"
|
|
|
|
|
|
import {
|
|
|
|
|
|
ArrowLeftOutlined,
|
|
|
|
|
|
AimOutlined,
|
|
|
|
|
|
FullscreenOutlined,
|
|
|
|
|
|
FullscreenExitOutlined,
|
|
|
|
|
|
DragOutlined
|
|
|
|
|
|
} from "@ant-design/icons-vue"
|
|
|
|
|
|
|
|
|
|
|
|
// @ts-ignore
|
|
|
|
|
|
import * as THREE from "three"
|
|
|
|
|
|
// @ts-ignore
|
|
|
|
|
|
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js"
|
|
|
|
|
|
// @ts-ignore
|
|
|
|
|
|
import { DRACOLoader } from "three/examples/jsm/loaders/DRACOLoader.js"
|
|
|
|
|
|
// @ts-ignore
|
|
|
|
|
|
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"
|
|
|
|
|
|
|
|
|
|
|
|
const router = useRouter()
|
|
|
|
|
|
const route = useRoute()
|
|
|
|
|
|
|
|
|
|
|
|
const containerRef = ref<HTMLDivElement | null>(null)
|
|
|
|
|
|
const loading = ref(true)
|
|
|
|
|
|
const error = ref<string | null>(null)
|
|
|
|
|
|
const isFullscreen = ref(false)
|
|
|
|
|
|
const modelInfo = ref<{ size: string; vertices: string; faces: string } | null>(null)
|
|
|
|
|
|
|
|
|
|
|
|
let scene: THREE.Scene | null = null
|
|
|
|
|
|
let camera: THREE.PerspectiveCamera | null = null
|
|
|
|
|
|
let renderer: THREE.WebGLRenderer | null = null
|
|
|
|
|
|
let controls: OrbitControls | null = null
|
|
|
|
|
|
let model: THREE.Group | null = null
|
|
|
|
|
|
let animationId: number | null = null
|
|
|
|
|
|
let dracoLoader: DRACOLoader | null = null
|
|
|
|
|
|
let initialCameraPosition: THREE.Vector3 | null = null
|
|
|
|
|
|
|
|
|
|
|
|
// 获取模型 URL
|
2026-01-13 11:11:49 +08:00
|
|
|
|
// Vue Router 会自动解码 query 参数,所以直接使用即可
|
2026-01-09 18:14:35 +08:00
|
|
|
|
const modelUrl = ref((route.query.url as string) || "")
|
2026-01-13 11:11:49 +08:00
|
|
|
|
console.log("模型查看器 - URL:", modelUrl.value)
|
2026-01-09 18:14:35 +08:00
|
|
|
|
|
|
|
|
|
|
// 返回上一页
|
|
|
|
|
|
const handleBack = () => {
|
2026-01-13 11:11:49 +08:00
|
|
|
|
// 如果是新标签页打开(没有历史记录),则关闭窗口
|
|
|
|
|
|
if (window.history.length <= 1) {
|
|
|
|
|
|
window.close()
|
|
|
|
|
|
} else {
|
|
|
|
|
|
router.back()
|
|
|
|
|
|
}
|
2026-01-09 18:14:35 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 重置相机视角
|
|
|
|
|
|
const resetCamera = () => {
|
|
|
|
|
|
if (camera && initialCameraPosition && controls) {
|
|
|
|
|
|
camera.position.copy(initialCameraPosition)
|
|
|
|
|
|
controls.target.set(0, 0, 0)
|
|
|
|
|
|
controls.update()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 切换全屏
|
|
|
|
|
|
const toggleFullscreen = () => {
|
|
|
|
|
|
if (!document.fullscreenElement) {
|
|
|
|
|
|
document.documentElement.requestFullscreen()
|
|
|
|
|
|
isFullscreen.value = true
|
|
|
|
|
|
} else {
|
|
|
|
|
|
document.exitFullscreen()
|
|
|
|
|
|
isFullscreen.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 初始化场景
|
|
|
|
|
|
const initScene = () => {
|
|
|
|
|
|
if (!containerRef.value) return
|
|
|
|
|
|
|
|
|
|
|
|
// 创建场景
|
|
|
|
|
|
scene = new THREE.Scene()
|
|
|
|
|
|
scene.background = new THREE.Color(0x1a1a2e)
|
|
|
|
|
|
|
|
|
|
|
|
// 创建相机
|
|
|
|
|
|
const width = containerRef.value.clientWidth
|
|
|
|
|
|
const height = containerRef.value.clientHeight
|
|
|
|
|
|
camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 10000)
|
|
|
|
|
|
camera.position.set(10, 10, 10)
|
|
|
|
|
|
camera.lookAt(0, 0, 0)
|
|
|
|
|
|
|
|
|
|
|
|
// 创建渲染器 - 高质量设置
|
|
|
|
|
|
renderer = new THREE.WebGLRenderer({
|
|
|
|
|
|
antialias: true,
|
|
|
|
|
|
alpha: true,
|
|
|
|
|
|
powerPreference: "high-performance",
|
|
|
|
|
|
precision: "highp"
|
|
|
|
|
|
})
|
|
|
|
|
|
renderer.setSize(width, height)
|
|
|
|
|
|
// 使用设备完整像素比,最高质量
|
|
|
|
|
|
renderer.setPixelRatio(window.devicePixelRatio)
|
|
|
|
|
|
renderer.shadowMap.enabled = true
|
|
|
|
|
|
renderer.shadowMap.type = THREE.PCFSoftShadowMap
|
|
|
|
|
|
renderer.toneMapping = THREE.ACESFilmicToneMapping
|
|
|
|
|
|
renderer.toneMappingExposure = 1.2
|
|
|
|
|
|
renderer.outputColorSpace = THREE.SRGBColorSpace
|
|
|
|
|
|
containerRef.value.appendChild(renderer.domElement)
|
|
|
|
|
|
|
|
|
|
|
|
// 创建控制器
|
|
|
|
|
|
controls = new OrbitControls(camera, renderer.domElement)
|
|
|
|
|
|
controls.enableDamping = true
|
|
|
|
|
|
controls.dampingFactor = 0.05
|
|
|
|
|
|
controls.minDistance = 0.1
|
|
|
|
|
|
controls.maxDistance = 1000
|
|
|
|
|
|
controls.enablePan = true
|
|
|
|
|
|
controls.panSpeed = 1
|
|
|
|
|
|
controls.rotateSpeed = 1
|
|
|
|
|
|
|
|
|
|
|
|
// 添加半球光 - 更自然的环境光
|
|
|
|
|
|
const hemiLight = new THREE.HemisphereLight(0xffffff, 0x444444, 0.6)
|
|
|
|
|
|
hemiLight.position.set(0, 100, 0)
|
|
|
|
|
|
scene.add(hemiLight)
|
|
|
|
|
|
|
|
|
|
|
|
// 添加环境光
|
|
|
|
|
|
const ambientLight = new THREE.AmbientLight(0xffffff, 0.4)
|
|
|
|
|
|
scene.add(ambientLight)
|
|
|
|
|
|
|
|
|
|
|
|
// 添加主方向光 - 增强亮度
|
|
|
|
|
|
const mainLight = new THREE.DirectionalLight(0xffffff, 1.5)
|
|
|
|
|
|
mainLight.position.set(10, 20, 10)
|
|
|
|
|
|
mainLight.castShadow = true
|
|
|
|
|
|
mainLight.shadow.mapSize.width = 4096
|
|
|
|
|
|
mainLight.shadow.mapSize.height = 4096
|
|
|
|
|
|
mainLight.shadow.camera.near = 0.1
|
|
|
|
|
|
mainLight.shadow.camera.far = 500
|
|
|
|
|
|
mainLight.shadow.bias = -0.0001
|
|
|
|
|
|
scene.add(mainLight)
|
|
|
|
|
|
|
|
|
|
|
|
// 添加补光 - 从侧面
|
|
|
|
|
|
const fillLight = new THREE.DirectionalLight(0xffffff, 0.8)
|
|
|
|
|
|
fillLight.position.set(-10, 10, -10)
|
|
|
|
|
|
scene.add(fillLight)
|
|
|
|
|
|
|
|
|
|
|
|
// 添加背光 - 增加立体感
|
|
|
|
|
|
const backLight = new THREE.DirectionalLight(0xffffff, 0.5)
|
|
|
|
|
|
backLight.position.set(0, 10, -20)
|
|
|
|
|
|
scene.add(backLight)
|
|
|
|
|
|
|
|
|
|
|
|
// 添加底部补光
|
|
|
|
|
|
const bottomLight = new THREE.DirectionalLight(0xffffff, 0.2)
|
|
|
|
|
|
bottomLight.position.set(0, -10, 0)
|
|
|
|
|
|
scene.add(bottomLight)
|
|
|
|
|
|
|
|
|
|
|
|
// 添加网格
|
|
|
|
|
|
const gridHelper = new THREE.GridHelper(100, 100, 0x444444, 0x333333)
|
|
|
|
|
|
scene.add(gridHelper)
|
|
|
|
|
|
|
|
|
|
|
|
// 开始渲染
|
|
|
|
|
|
animate()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 加载模型
|
|
|
|
|
|
const loadModel = async () => {
|
|
|
|
|
|
if (!scene || !modelUrl.value) {
|
|
|
|
|
|
error.value = "模型 URL 不存在"
|
|
|
|
|
|
loading.value = false
|
2026-01-13 11:11:49 +08:00
|
|
|
|
console.error("模型加载失败: URL为空", { scene: !!scene, url: modelUrl.value })
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 检查文件扩展名
|
|
|
|
|
|
const supportedExtensions = ['.glb', '.gltf']
|
|
|
|
|
|
const urlLower = modelUrl.value.toLowerCase()
|
|
|
|
|
|
const isSupported = supportedExtensions.some(ext => urlLower.includes(ext))
|
|
|
|
|
|
if (!isSupported) {
|
|
|
|
|
|
error.value = `不支持的文件格式,目前仅支持 GLB/GLTF 格式`
|
|
|
|
|
|
loading.value = false
|
|
|
|
|
|
console.error("不支持的文件格式:", modelUrl.value)
|
2026-01-09 18:14:35 +08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
loading.value = true
|
|
|
|
|
|
error.value = null
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
console.log("开始加载模型,URL:", modelUrl.value)
|
|
|
|
|
|
|
2026-01-13 11:11:49 +08:00
|
|
|
|
// 验证 URL 是否可访问
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await fetch(modelUrl.value, { method: "HEAD" })
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
|
throw new Error(`文件不存在或无法访问 (HTTP ${response.status})`)
|
|
|
|
|
|
}
|
|
|
|
|
|
console.log("文件验证通过,开始加载...")
|
|
|
|
|
|
} catch (fetchErr: any) {
|
|
|
|
|
|
console.error("文件访问验证失败:", fetchErr)
|
|
|
|
|
|
throw new Error(`无法访问文件: ${fetchErr.message}`)
|
2026-01-09 18:14:35 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const loader = new GLTFLoader()
|
|
|
|
|
|
|
|
|
|
|
|
// 配置 DRACO 解码器
|
|
|
|
|
|
if (!dracoLoader) {
|
|
|
|
|
|
dracoLoader = new DRACOLoader()
|
|
|
|
|
|
dracoLoader.setDecoderPath("https://www.gstatic.com/draco/versioned/decoders/1.5.6/")
|
|
|
|
|
|
}
|
|
|
|
|
|
loader.setDRACOLoader(dracoLoader)
|
|
|
|
|
|
|
|
|
|
|
|
const gltf = await loader.loadAsync(modelUrl.value)
|
|
|
|
|
|
|
|
|
|
|
|
// 移除旧模型
|
|
|
|
|
|
if (model && scene) {
|
|
|
|
|
|
scene.remove(model)
|
|
|
|
|
|
disposeModel(model)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
model = gltf.scene
|
|
|
|
|
|
|
|
|
|
|
|
// 计算模型信息
|
|
|
|
|
|
let vertexCount = 0
|
|
|
|
|
|
let faceCount = 0
|
|
|
|
|
|
model.traverse((child: THREE.Object3D) => {
|
|
|
|
|
|
if (child instanceof THREE.Mesh) {
|
|
|
|
|
|
const geometry = child.geometry
|
|
|
|
|
|
if (geometry.attributes.position) {
|
|
|
|
|
|
vertexCount += geometry.attributes.position.count
|
|
|
|
|
|
}
|
|
|
|
|
|
if (geometry.index) {
|
|
|
|
|
|
faceCount += geometry.index.count / 3
|
|
|
|
|
|
} else if (geometry.attributes.position) {
|
|
|
|
|
|
faceCount += geometry.attributes.position.count / 3
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// 计算边界框
|
|
|
|
|
|
const box = new THREE.Box3().setFromObject(model)
|
|
|
|
|
|
const center = box.getCenter(new THREE.Vector3())
|
|
|
|
|
|
const size = box.getSize(new THREE.Vector3())
|
|
|
|
|
|
|
|
|
|
|
|
// 设置模型信息
|
|
|
|
|
|
modelInfo.value = {
|
|
|
|
|
|
size: `${size.x.toFixed(2)} x ${size.y.toFixed(2)} x ${size.z.toFixed(2)}`,
|
|
|
|
|
|
vertices: vertexCount.toLocaleString(),
|
|
|
|
|
|
faces: Math.round(faceCount).toLocaleString()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 居中模型
|
|
|
|
|
|
model.position.sub(center)
|
|
|
|
|
|
|
|
|
|
|
|
// 计算相机距离,确保模型完整显示
|
|
|
|
|
|
if (camera && containerRef.value) {
|
|
|
|
|
|
const maxDim = Math.max(size.x, size.y, size.z)
|
|
|
|
|
|
|
|
|
|
|
|
// 根据容器宽高比调整
|
|
|
|
|
|
const containerWidth = containerRef.value.clientWidth
|
|
|
|
|
|
const containerHeight = containerRef.value.clientHeight
|
|
|
|
|
|
const aspect = containerWidth / containerHeight
|
|
|
|
|
|
|
|
|
|
|
|
// 使用 FOV 计算理想距离
|
|
|
|
|
|
const fovRad = (camera.fov * Math.PI) / 180
|
|
|
|
|
|
|
|
|
|
|
|
// 计算需要的距离:确保模型在垂直和水平方向都能完整显示
|
|
|
|
|
|
const distanceForHeight = (size.y / 2) / Math.tan(fovRad / 2)
|
|
|
|
|
|
const distanceForWidth = (size.x / 2) / Math.tan(fovRad / 2) / aspect
|
|
|
|
|
|
const distanceForDepth = (size.z / 2) / Math.tan(fovRad / 2)
|
|
|
|
|
|
|
|
|
|
|
|
// 取最大距离,并添加足够的边距(3倍)
|
|
|
|
|
|
const baseDistance = Math.max(distanceForHeight, distanceForWidth, distanceForDepth)
|
|
|
|
|
|
const cameraDistance = Math.max(baseDistance * 3, maxDim * 3, 10)
|
|
|
|
|
|
|
|
|
|
|
|
console.log("模型尺寸:", size)
|
|
|
|
|
|
console.log("计算的相机距离:", cameraDistance)
|
|
|
|
|
|
|
|
|
|
|
|
// 设置相机位置(从斜上方45度角观看)
|
|
|
|
|
|
const angle = Math.PI / 4
|
|
|
|
|
|
camera.position.set(
|
|
|
|
|
|
cameraDistance * Math.cos(angle),
|
|
|
|
|
|
cameraDistance * 0.5,
|
|
|
|
|
|
cameraDistance * Math.sin(angle)
|
|
|
|
|
|
)
|
|
|
|
|
|
camera.lookAt(0, 0, 0)
|
|
|
|
|
|
|
|
|
|
|
|
// 保存初始位置
|
|
|
|
|
|
initialCameraPosition = camera.position.clone()
|
|
|
|
|
|
|
|
|
|
|
|
// 更新控制器
|
|
|
|
|
|
if (controls) {
|
|
|
|
|
|
controls.target.set(0, 0, 0)
|
|
|
|
|
|
controls.update()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
scene.add(model)
|
|
|
|
|
|
loading.value = false
|
|
|
|
|
|
console.log("模型加载成功")
|
|
|
|
|
|
} catch (err: any) {
|
|
|
|
|
|
console.error("模型加载失败:", err)
|
|
|
|
|
|
error.value = err.message || "无法加载模型文件"
|
|
|
|
|
|
loading.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 释放模型资源
|
|
|
|
|
|
const disposeModel = (object: THREE.Object3D) => {
|
|
|
|
|
|
object.traverse((child: THREE.Object3D) => {
|
|
|
|
|
|
if (child instanceof THREE.Mesh) {
|
|
|
|
|
|
child.geometry.dispose()
|
|
|
|
|
|
if (Array.isArray(child.material)) {
|
|
|
|
|
|
child.material.forEach((mat: THREE.Material) => mat.dispose())
|
|
|
|
|
|
} else {
|
|
|
|
|
|
child.material.dispose()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 动画循环
|
|
|
|
|
|
const animate = () => {
|
|
|
|
|
|
const render = () => {
|
|
|
|
|
|
if (controls && camera && renderer && scene) {
|
|
|
|
|
|
controls.update()
|
|
|
|
|
|
renderer.render(scene, camera)
|
|
|
|
|
|
animationId = requestAnimationFrame(render)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
render()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 窗口大小变化
|
|
|
|
|
|
const handleResize = () => {
|
|
|
|
|
|
if (!containerRef.value || !camera || !renderer) return
|
|
|
|
|
|
|
|
|
|
|
|
const width = containerRef.value.clientWidth
|
|
|
|
|
|
const height = containerRef.value.clientHeight
|
|
|
|
|
|
|
|
|
|
|
|
camera.aspect = width / height
|
|
|
|
|
|
camera.updateProjectionMatrix()
|
|
|
|
|
|
renderer.setSize(width, height)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 全屏变化
|
|
|
|
|
|
const handleFullscreenChange = () => {
|
|
|
|
|
|
isFullscreen.value = !!document.fullscreenElement
|
|
|
|
|
|
setTimeout(handleResize, 100)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 释放场景
|
|
|
|
|
|
const disposeScene = () => {
|
|
|
|
|
|
if (animationId) {
|
|
|
|
|
|
cancelAnimationFrame(animationId)
|
|
|
|
|
|
animationId = null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (model && scene) {
|
|
|
|
|
|
disposeModel(model)
|
|
|
|
|
|
scene.remove(model)
|
|
|
|
|
|
model = null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (controls) {
|
|
|
|
|
|
controls.dispose()
|
|
|
|
|
|
controls = null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (renderer && containerRef.value) {
|
|
|
|
|
|
renderer.dispose()
|
|
|
|
|
|
if (renderer.domElement.parentNode) {
|
|
|
|
|
|
containerRef.value.removeChild(renderer.domElement)
|
|
|
|
|
|
}
|
|
|
|
|
|
renderer = null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (dracoLoader) {
|
|
|
|
|
|
dracoLoader.dispose()
|
|
|
|
|
|
dracoLoader = null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
scene = null
|
|
|
|
|
|
camera = null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 重试
|
|
|
|
|
|
const handleRetry = () => {
|
|
|
|
|
|
error.value = null
|
|
|
|
|
|
loadModel()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
onMounted(() => {
|
|
|
|
|
|
window.addEventListener("resize", handleResize)
|
|
|
|
|
|
document.addEventListener("fullscreenchange", handleFullscreenChange)
|
|
|
|
|
|
|
|
|
|
|
|
initScene()
|
|
|
|
|
|
loadModel()
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
onUnmounted(() => {
|
|
|
|
|
|
window.removeEventListener("resize", handleResize)
|
|
|
|
|
|
document.removeEventListener("fullscreenchange", handleFullscreenChange)
|
|
|
|
|
|
disposeScene()
|
|
|
|
|
|
})
|
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<style scoped lang="scss">
|
|
|
|
|
|
.model-viewer-page {
|
|
|
|
|
|
position: fixed;
|
|
|
|
|
|
inset: 0;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
background: #1a1a2e;
|
|
|
|
|
|
z-index: 1000;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.viewer-header {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
padding: 12px 20px;
|
|
|
|
|
|
background: rgba(0, 0, 0, 0.5);
|
|
|
|
|
|
backdrop-filter: blur(10px);
|
|
|
|
|
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
|
|
|
|
|
|
|
|
|
|
|
.header-left {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 16px;
|
|
|
|
|
|
|
|
|
|
|
|
.title {
|
|
|
|
|
|
color: #fff;
|
|
|
|
|
|
font-size: 16px;
|
|
|
|
|
|
font-weight: 500;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.header-right {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
gap: 8px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
:deep(.ant-btn-text) {
|
|
|
|
|
|
color: rgba(255, 255, 255, 0.85);
|
|
|
|
|
|
|
|
|
|
|
|
&:hover {
|
|
|
|
|
|
color: #fff;
|
|
|
|
|
|
background: rgba(255, 255, 255, 0.1);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.viewer-content {
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
position: relative;
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.model-canvas {
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
height: 100%;
|
|
|
|
|
|
|
|
|
|
|
|
:deep(canvas) {
|
|
|
|
|
|
display: block;
|
|
|
|
|
|
width: 100% !important;
|
|
|
|
|
|
height: 100% !important;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.loading-overlay,
|
|
|
|
|
|
.error-overlay {
|
|
|
|
|
|
position: absolute;
|
|
|
|
|
|
inset: 0;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
background: rgba(26, 26, 46, 0.95);
|
|
|
|
|
|
z-index: 10;
|
|
|
|
|
|
|
|
|
|
|
|
:deep(.ant-spin-text) {
|
|
|
|
|
|
color: #fff;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
:deep(.ant-result-title) {
|
|
|
|
|
|
color: #fff;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
:deep(.ant-result-subtitle) {
|
|
|
|
|
|
color: rgba(255, 255, 255, 0.65);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.controls-info {
|
|
|
|
|
|
position: absolute;
|
|
|
|
|
|
bottom: 20px;
|
|
|
|
|
|
left: 50%;
|
|
|
|
|
|
transform: translateX(-50%);
|
|
|
|
|
|
z-index: 5;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.control-hint {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
gap: 24px;
|
|
|
|
|
|
padding: 10px 20px;
|
|
|
|
|
|
background: rgba(0, 0, 0, 0.6);
|
|
|
|
|
|
backdrop-filter: blur(10px);
|
|
|
|
|
|
border-radius: 8px;
|
|
|
|
|
|
color: rgba(255, 255, 255, 0.85);
|
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
|
|
|
|
|
|
|
span {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 6px;
|
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.model-info {
|
|
|
|
|
|
position: absolute;
|
|
|
|
|
|
top: 20px;
|
|
|
|
|
|
right: 20px;
|
|
|
|
|
|
padding: 16px;
|
|
|
|
|
|
background: rgba(0, 0, 0, 0.6);
|
|
|
|
|
|
backdrop-filter: blur(10px);
|
|
|
|
|
|
border-radius: 8px;
|
|
|
|
|
|
z-index: 5;
|
|
|
|
|
|
|
|
|
|
|
|
.info-item {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
gap: 8px;
|
|
|
|
|
|
margin-bottom: 8px;
|
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
|
|
|
|
|
|
|
&:last-child {
|
|
|
|
|
|
margin-bottom: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.label {
|
|
|
|
|
|
color: rgba(255, 255, 255, 0.65);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.value {
|
|
|
|
|
|
color: #fff;
|
|
|
|
|
|
font-weight: 500;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
</style>
|