2026-01-09 18:14:35 +08:00
|
|
|
|
<template>
|
|
|
|
|
|
<div class="model-viewer-page">
|
2026-01-14 14:29:16 +08:00
|
|
|
|
<!-- Animated Background -->
|
|
|
|
|
|
<div class="bg-animation">
|
|
|
|
|
|
<div class="bg-gradient bg-gradient-1"></div>
|
|
|
|
|
|
<div class="bg-gradient bg-gradient-2"></div>
|
|
|
|
|
|
<div class="bg-gradient bg-gradient-3"></div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-01-09 18:14:35 +08:00
|
|
|
|
<!-- Header -->
|
|
|
|
|
|
<div class="viewer-header">
|
|
|
|
|
|
<div class="header-left">
|
2026-01-13 16:41:12 +08:00
|
|
|
|
<a-button type="text" class="back-btn" @click="handleBack">
|
2026-01-09 18:14:35 +08:00
|
|
|
|
<template #icon><ArrowLeftOutlined /></template>
|
|
|
|
|
|
</a-button>
|
|
|
|
|
|
<span class="title">3D 模型预览</span>
|
2026-01-13 16:41:12 +08:00
|
|
|
|
<span class="badge">LIVE</span>
|
2026-01-09 18:14:35 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<div class="header-right">
|
2026-01-13 16:41:12 +08:00
|
|
|
|
<a-button type="text" class="action-btn" @click="resetCamera">
|
2026-01-09 18:14:35 +08:00
|
|
|
|
<template #icon><AimOutlined /></template>
|
|
|
|
|
|
重置视角
|
|
|
|
|
|
</a-button>
|
2026-01-13 16:41:12 +08:00
|
|
|
|
<a-button type="text" class="action-btn" @click="toggleFullscreen">
|
2026-01-14 10:06:08 +08:00
|
|
|
|
<template #icon
|
|
|
|
|
|
><FullscreenOutlined v-if="!isFullscreen" /><FullscreenExitOutlined
|
|
|
|
|
|
v-else
|
|
|
|
|
|
/></template>
|
|
|
|
|
|
{{ isFullscreen ? "退出全屏" : "全屏" }}
|
2026-01-09 18:14:35 +08:00
|
|
|
|
</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">
|
2026-01-13 16:41:12 +08:00
|
|
|
|
<div class="loader">
|
|
|
|
|
|
<div class="loader-ring"></div>
|
|
|
|
|
|
<div class="loader-ring"></div>
|
|
|
|
|
|
<div class="loader-ring"></div>
|
|
|
|
|
|
<span class="loader-text">正在加载模型...</span>
|
|
|
|
|
|
</div>
|
2026-01-09 18:14:35 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Error -->
|
|
|
|
|
|
<div v-if="error" class="error-overlay">
|
2026-01-13 16:41:12 +08:00
|
|
|
|
<div class="error-card">
|
|
|
|
|
|
<div class="error-icon">!</div>
|
|
|
|
|
|
<h3>模型加载失败</h3>
|
|
|
|
|
|
<p>{{ error }}</p>
|
|
|
|
|
|
<div class="error-actions">
|
2026-01-14 10:06:08 +08:00
|
|
|
|
<a-button type="primary" class="gradient-btn" @click="handleRetry"
|
|
|
|
|
|
>重试</a-button
|
|
|
|
|
|
>
|
2026-01-13 16:41:12 +08:00
|
|
|
|
<a-button class="outline-btn" @click="handleBack">返回</a-button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-01-09 18:14:35 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-01-13 16:41:12 +08:00
|
|
|
|
<!-- Model Info (左侧) -->
|
|
|
|
|
|
<div v-if="!loading && !error && modelInfo" class="model-info">
|
|
|
|
|
|
<div class="info-header">
|
|
|
|
|
|
<span class="info-icon"><AppstoreOutlined /></span>
|
|
|
|
|
|
<span class="info-title">模型信息</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="info-body">
|
|
|
|
|
|
<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 highlight">{{ modelInfo.vertices }}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="info-item">
|
|
|
|
|
|
<span class="label">面数</span>
|
|
|
|
|
|
<span class="value highlight">{{ modelInfo.faces }}</span>
|
|
|
|
|
|
</div>
|
2026-01-09 18:14:35 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-01-13 16:41:12 +08:00
|
|
|
|
<!-- Scene Settings Panel (右侧) -->
|
|
|
|
|
|
<div v-if="!loading && !error" class="scene-settings">
|
2026-01-14 10:06:08 +08:00
|
|
|
|
<div
|
|
|
|
|
|
class="settings-header"
|
|
|
|
|
|
@click="settingsPanelOpen = !settingsPanelOpen"
|
|
|
|
|
|
>
|
|
|
|
|
|
<span class="settings-title"> <SettingOutlined /> 场景设置 </span>
|
2026-01-13 16:41:12 +08:00
|
|
|
|
<span class="collapse-icon" :class="{ 'is-open': settingsPanelOpen }">
|
|
|
|
|
|
<RightOutlined />
|
|
|
|
|
|
</span>
|
2026-01-09 18:14:35 +08:00
|
|
|
|
</div>
|
2026-01-13 16:41:12 +08:00
|
|
|
|
|
|
|
|
|
|
<div v-show="settingsPanelOpen" class="settings-body">
|
|
|
|
|
|
<!-- 背景设置 -->
|
|
|
|
|
|
<div class="settings-group">
|
|
|
|
|
|
<div class="group-title">背景</div>
|
|
|
|
|
|
<div class="setting-item">
|
|
|
|
|
|
<span class="setting-label">背景色</span>
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="color"
|
|
|
|
|
|
v-model="sceneSettings.backgroundColor"
|
|
|
|
|
|
@input="updateBackgroundColor"
|
|
|
|
|
|
class="color-picker"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="setting-item">
|
|
|
|
|
|
<span class="setting-label">显示网格</span>
|
|
|
|
|
|
<a-switch
|
|
|
|
|
|
v-model:checked="sceneSettings.showGrid"
|
|
|
|
|
|
size="small"
|
|
|
|
|
|
@change="updateGridVisibility"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 环境光设置 -->
|
|
|
|
|
|
<div class="settings-group">
|
|
|
|
|
|
<div class="group-title">环境光</div>
|
|
|
|
|
|
<div class="setting-item">
|
|
|
|
|
|
<span class="setting-label">强度</span>
|
|
|
|
|
|
<div class="slider-wrapper">
|
|
|
|
|
|
<a-slider
|
|
|
|
|
|
v-model:value="sceneSettings.ambientLight.intensity"
|
2026-01-14 10:06:08 +08:00
|
|
|
|
:min="0"
|
|
|
|
|
|
:max="2"
|
|
|
|
|
|
:step="0.1"
|
2026-01-13 16:41:12 +08:00
|
|
|
|
@change="updateAmbientLight"
|
|
|
|
|
|
/>
|
2026-01-14 10:06:08 +08:00
|
|
|
|
<span class="slider-value">{{
|
|
|
|
|
|
sceneSettings.ambientLight.intensity.toFixed(1)
|
|
|
|
|
|
}}</span>
|
2026-01-13 16:41:12 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="setting-item">
|
|
|
|
|
|
<span class="setting-label">颜色</span>
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="color"
|
|
|
|
|
|
v-model="sceneSettings.ambientLight.color"
|
|
|
|
|
|
@input="updateAmbientLight"
|
|
|
|
|
|
class="color-picker"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 主光源设置 -->
|
|
|
|
|
|
<div class="settings-group">
|
|
|
|
|
|
<div class="group-title">主光源</div>
|
|
|
|
|
|
<div class="setting-item">
|
|
|
|
|
|
<span class="setting-label">强度</span>
|
|
|
|
|
|
<div class="slider-wrapper">
|
|
|
|
|
|
<a-slider
|
|
|
|
|
|
v-model:value="sceneSettings.mainLight.intensity"
|
2026-01-14 10:06:08 +08:00
|
|
|
|
:min="0"
|
|
|
|
|
|
:max="3"
|
|
|
|
|
|
:step="0.1"
|
2026-01-13 16:41:12 +08:00
|
|
|
|
@change="updateMainLight"
|
|
|
|
|
|
/>
|
2026-01-14 10:06:08 +08:00
|
|
|
|
<span class="slider-value">{{
|
|
|
|
|
|
sceneSettings.mainLight.intensity.toFixed(1)
|
|
|
|
|
|
}}</span>
|
2026-01-13 16:41:12 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="setting-item">
|
|
|
|
|
|
<span class="setting-label">颜色</span>
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="color"
|
|
|
|
|
|
v-model="sceneSettings.mainLight.color"
|
|
|
|
|
|
@input="updateMainLight"
|
|
|
|
|
|
class="color-picker"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="setting-item">
|
|
|
|
|
|
<span class="setting-label">水平角度</span>
|
|
|
|
|
|
<div class="slider-wrapper">
|
|
|
|
|
|
<a-slider
|
|
|
|
|
|
v-model:value="sceneSettings.mainLight.horizontalAngle"
|
2026-01-14 10:06:08 +08:00
|
|
|
|
:min="0"
|
|
|
|
|
|
:max="360"
|
|
|
|
|
|
:step="5"
|
2026-01-13 16:41:12 +08:00
|
|
|
|
@change="updateMainLightPosition"
|
|
|
|
|
|
/>
|
2026-01-14 10:06:08 +08:00
|
|
|
|
<span class="slider-value"
|
|
|
|
|
|
>{{ sceneSettings.mainLight.horizontalAngle }}°</span
|
|
|
|
|
|
>
|
2026-01-13 16:41:12 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="setting-item">
|
|
|
|
|
|
<span class="setting-label">垂直角度</span>
|
|
|
|
|
|
<div class="slider-wrapper">
|
|
|
|
|
|
<a-slider
|
|
|
|
|
|
v-model:value="sceneSettings.mainLight.verticalAngle"
|
2026-01-14 10:06:08 +08:00
|
|
|
|
:min="0"
|
|
|
|
|
|
:max="90"
|
|
|
|
|
|
:step="5"
|
2026-01-13 16:41:12 +08:00
|
|
|
|
@change="updateMainLightPosition"
|
|
|
|
|
|
/>
|
2026-01-14 10:06:08 +08:00
|
|
|
|
<span class="slider-value"
|
|
|
|
|
|
>{{ sceneSettings.mainLight.verticalAngle }}°</span
|
|
|
|
|
|
>
|
2026-01-13 16:41:12 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 补光设置 -->
|
|
|
|
|
|
<div class="settings-group">
|
|
|
|
|
|
<div class="group-title">补光</div>
|
|
|
|
|
|
<div class="setting-item">
|
|
|
|
|
|
<span class="setting-label">强度</span>
|
|
|
|
|
|
<div class="slider-wrapper">
|
|
|
|
|
|
<a-slider
|
|
|
|
|
|
v-model:value="sceneSettings.fillLight.intensity"
|
2026-01-14 10:06:08 +08:00
|
|
|
|
:min="0"
|
|
|
|
|
|
:max="2"
|
|
|
|
|
|
:step="0.1"
|
2026-01-13 16:41:12 +08:00
|
|
|
|
@change="updateFillLight"
|
|
|
|
|
|
/>
|
2026-01-14 10:06:08 +08:00
|
|
|
|
<span class="slider-value">{{
|
|
|
|
|
|
sceneSettings.fillLight.intensity.toFixed(1)
|
|
|
|
|
|
}}</span>
|
2026-01-13 16:41:12 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="setting-item">
|
|
|
|
|
|
<span class="setting-label">颜色</span>
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="color"
|
|
|
|
|
|
v-model="sceneSettings.fillLight.color"
|
|
|
|
|
|
@input="updateFillLight"
|
|
|
|
|
|
class="color-picker"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 聚光灯设置 -->
|
|
|
|
|
|
<div class="settings-group">
|
|
|
|
|
|
<div class="group-title">聚光灯</div>
|
|
|
|
|
|
<div class="setting-item">
|
|
|
|
|
|
<span class="setting-label">启用</span>
|
|
|
|
|
|
<a-switch
|
|
|
|
|
|
v-model:checked="sceneSettings.spotLight.enabled"
|
|
|
|
|
|
size="small"
|
|
|
|
|
|
@change="updateSpotLight"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<template v-if="sceneSettings.spotLight.enabled">
|
|
|
|
|
|
<div class="setting-item">
|
|
|
|
|
|
<span class="setting-label">强度</span>
|
|
|
|
|
|
<div class="slider-wrapper">
|
|
|
|
|
|
<a-slider
|
|
|
|
|
|
v-model:value="sceneSettings.spotLight.intensity"
|
2026-01-14 10:06:08 +08:00
|
|
|
|
:min="0"
|
|
|
|
|
|
:max="3"
|
|
|
|
|
|
:step="0.1"
|
2026-01-13 16:41:12 +08:00
|
|
|
|
@change="updateSpotLight"
|
|
|
|
|
|
/>
|
2026-01-14 10:06:08 +08:00
|
|
|
|
<span class="slider-value">{{
|
|
|
|
|
|
sceneSettings.spotLight.intensity.toFixed(1)
|
|
|
|
|
|
}}</span>
|
2026-01-13 16:41:12 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="setting-item">
|
|
|
|
|
|
<span class="setting-label">颜色</span>
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="color"
|
|
|
|
|
|
v-model="sceneSettings.spotLight.color"
|
|
|
|
|
|
@input="updateSpotLight"
|
|
|
|
|
|
class="color-picker"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="setting-item">
|
|
|
|
|
|
<span class="setting-label">光束角度</span>
|
|
|
|
|
|
<div class="slider-wrapper">
|
|
|
|
|
|
<a-slider
|
|
|
|
|
|
v-model:value="sceneSettings.spotLight.angle"
|
2026-01-14 10:06:08 +08:00
|
|
|
|
:min="10"
|
|
|
|
|
|
:max="90"
|
|
|
|
|
|
:step="5"
|
2026-01-13 16:41:12 +08:00
|
|
|
|
@change="updateSpotLight"
|
|
|
|
|
|
/>
|
2026-01-14 10:06:08 +08:00
|
|
|
|
<span class="slider-value"
|
|
|
|
|
|
>{{ sceneSettings.spotLight.angle }}°</span
|
|
|
|
|
|
>
|
2026-01-13 16:41:12 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 渲染设置 -->
|
|
|
|
|
|
<div class="settings-group">
|
|
|
|
|
|
<div class="group-title">渲染</div>
|
|
|
|
|
|
<div class="setting-item">
|
|
|
|
|
|
<span class="setting-label">曝光度</span>
|
|
|
|
|
|
<div class="slider-wrapper">
|
|
|
|
|
|
<a-slider
|
|
|
|
|
|
v-model:value="sceneSettings.render.exposure"
|
2026-01-14 10:06:08 +08:00
|
|
|
|
:min="0.1"
|
|
|
|
|
|
:max="3"
|
|
|
|
|
|
:step="0.1"
|
2026-01-13 16:41:12 +08:00
|
|
|
|
@change="updateRenderSettings"
|
|
|
|
|
|
/>
|
2026-01-14 10:06:08 +08:00
|
|
|
|
<span class="slider-value">{{
|
|
|
|
|
|
sceneSettings.render.exposure.toFixed(1)
|
|
|
|
|
|
}}</span>
|
2026-01-13 16:41:12 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="setting-item">
|
|
|
|
|
|
<span class="setting-label">色调映射</span>
|
|
|
|
|
|
<a-select
|
|
|
|
|
|
v-model:value="sceneSettings.render.toneMapping"
|
|
|
|
|
|
size="small"
|
|
|
|
|
|
style="width: 120px"
|
|
|
|
|
|
@change="updateRenderSettings"
|
|
|
|
|
|
>
|
|
|
|
|
|
<a-select-option value="ACES">ACES</a-select-option>
|
|
|
|
|
|
<a-select-option value="Linear">线性</a-select-option>
|
|
|
|
|
|
<a-select-option value="Reinhard">Reinhard</a-select-option>
|
|
|
|
|
|
<a-select-option value="Cineon">Cineon</a-select-option>
|
|
|
|
|
|
</a-select>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 重置按钮 -->
|
|
|
|
|
|
<div v-show="settingsPanelOpen" class="settings-footer">
|
2026-01-14 10:06:08 +08:00
|
|
|
|
<a-button size="small" class="reset-btn" @click="resetSettings"
|
|
|
|
|
|
>重置默认</a-button
|
|
|
|
|
|
>
|
2026-01-09 18:14:35 +08:00
|
|
|
|
</div>
|
2026-01-13 16:41:12 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Controls Info -->
|
|
|
|
|
|
<div v-if="!loading && !error" class="controls-info">
|
|
|
|
|
|
<div class="control-hint">
|
|
|
|
|
|
<span class="hint-item"><span class="key">左键</span>旋转</span>
|
|
|
|
|
|
<span class="hint-item"><span class="key">右键</span>平移</span>
|
|
|
|
|
|
<span class="hint-item"><span class="key">滚轮</span>缩放</span>
|
2026-01-09 18:14:35 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<script setup lang="ts">
|
2026-01-13 16:41:12 +08:00
|
|
|
|
import { ref, reactive, onMounted, onUnmounted } from "vue"
|
2026-01-09 18:14:35 +08:00
|
|
|
|
import { useRouter, useRoute } from "vue-router"
|
|
|
|
|
|
import {
|
|
|
|
|
|
ArrowLeftOutlined,
|
|
|
|
|
|
AimOutlined,
|
|
|
|
|
|
FullscreenOutlined,
|
|
|
|
|
|
FullscreenExitOutlined,
|
2026-01-13 16:41:12 +08:00
|
|
|
|
SettingOutlined,
|
|
|
|
|
|
RightOutlined,
|
2026-01-14 10:06:08 +08:00
|
|
|
|
AppstoreOutlined,
|
2026-01-09 18:14:35 +08:00
|
|
|
|
} 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)
|
2026-01-14 10:06:08 +08:00
|
|
|
|
const modelInfo = ref<{ size: string; vertices: string; faces: string } | null>(
|
|
|
|
|
|
null
|
|
|
|
|
|
)
|
2026-01-13 16:41:12 +08:00
|
|
|
|
const settingsPanelOpen = ref(true)
|
|
|
|
|
|
|
|
|
|
|
|
// 场景设置默认值
|
|
|
|
|
|
const defaultSettings = {
|
2026-01-14 14:29:16 +08:00
|
|
|
|
backgroundColor: "#f5f5f5",
|
|
|
|
|
|
showGrid: false,
|
2026-01-13 16:41:12 +08:00
|
|
|
|
ambientLight: {
|
|
|
|
|
|
intensity: 0.4,
|
2026-01-14 10:06:08 +08:00
|
|
|
|
color: "#ffffff",
|
2026-01-13 16:41:12 +08:00
|
|
|
|
},
|
|
|
|
|
|
mainLight: {
|
|
|
|
|
|
intensity: 1.5,
|
|
|
|
|
|
color: "#ffffff",
|
|
|
|
|
|
horizontalAngle: 45,
|
2026-01-14 10:06:08 +08:00
|
|
|
|
verticalAngle: 60,
|
2026-01-13 16:41:12 +08:00
|
|
|
|
},
|
|
|
|
|
|
fillLight: {
|
|
|
|
|
|
intensity: 0.8,
|
2026-01-14 10:06:08 +08:00
|
|
|
|
color: "#ffffff",
|
2026-01-13 16:41:12 +08:00
|
|
|
|
},
|
|
|
|
|
|
spotLight: {
|
|
|
|
|
|
enabled: false,
|
|
|
|
|
|
intensity: 1.0,
|
|
|
|
|
|
color: "#ffffff",
|
2026-01-14 10:06:08 +08:00
|
|
|
|
angle: 30,
|
2026-01-13 16:41:12 +08:00
|
|
|
|
},
|
|
|
|
|
|
render: {
|
|
|
|
|
|
exposure: 1.2,
|
2026-01-14 10:06:08 +08:00
|
|
|
|
toneMapping: "ACES",
|
|
|
|
|
|
},
|
2026-01-13 16:41:12 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 场景设置
|
|
|
|
|
|
const sceneSettings = reactive(JSON.parse(JSON.stringify(defaultSettings)))
|
2026-01-09 18:14:35 +08:00
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
2026-01-13 16:41:12 +08:00
|
|
|
|
// 灯光引用
|
|
|
|
|
|
let ambientLight: THREE.AmbientLight | null = null
|
|
|
|
|
|
let mainLight: THREE.DirectionalLight | null = null
|
|
|
|
|
|
let fillLight: THREE.DirectionalLight | null = null
|
|
|
|
|
|
let spotLight: THREE.SpotLight | null = null
|
|
|
|
|
|
let gridHelper: THREE.GridHelper | null = null
|
|
|
|
|
|
|
2026-01-09 18:14:35 +08:00
|
|
|
|
// 获取模型 URL
|
|
|
|
|
|
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-14 14:29:16 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-13 16:41:12 +08:00
|
|
|
|
// 更新背景色
|
|
|
|
|
|
const updateBackgroundColor = () => {
|
|
|
|
|
|
if (scene) {
|
|
|
|
|
|
scene.background = new THREE.Color(sceneSettings.backgroundColor)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 更新网格显示
|
|
|
|
|
|
const updateGridVisibility = () => {
|
|
|
|
|
|
if (gridHelper) {
|
|
|
|
|
|
gridHelper.visible = sceneSettings.showGrid
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 更新环境光
|
|
|
|
|
|
const updateAmbientLight = () => {
|
|
|
|
|
|
if (ambientLight) {
|
|
|
|
|
|
ambientLight.intensity = sceneSettings.ambientLight.intensity
|
|
|
|
|
|
ambientLight.color.set(sceneSettings.ambientLight.color)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 更新主光源
|
|
|
|
|
|
const updateMainLight = () => {
|
|
|
|
|
|
if (mainLight) {
|
|
|
|
|
|
mainLight.intensity = sceneSettings.mainLight.intensity
|
|
|
|
|
|
mainLight.color.set(sceneSettings.mainLight.color)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 更新主光源位置
|
|
|
|
|
|
const updateMainLightPosition = () => {
|
|
|
|
|
|
if (mainLight) {
|
|
|
|
|
|
const distance = 30
|
|
|
|
|
|
const hAngle = (sceneSettings.mainLight.horizontalAngle * Math.PI) / 180
|
|
|
|
|
|
const vAngle = (sceneSettings.mainLight.verticalAngle * Math.PI) / 180
|
|
|
|
|
|
|
|
|
|
|
|
mainLight.position.set(
|
|
|
|
|
|
distance * Math.cos(vAngle) * Math.cos(hAngle),
|
|
|
|
|
|
distance * Math.sin(vAngle),
|
|
|
|
|
|
distance * Math.cos(vAngle) * Math.sin(hAngle)
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 更新补光
|
|
|
|
|
|
const updateFillLight = () => {
|
|
|
|
|
|
if (fillLight) {
|
|
|
|
|
|
fillLight.intensity = sceneSettings.fillLight.intensity
|
|
|
|
|
|
fillLight.color.set(sceneSettings.fillLight.color)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 更新聚光灯
|
|
|
|
|
|
const updateSpotLight = () => {
|
|
|
|
|
|
if (!scene) return
|
|
|
|
|
|
|
|
|
|
|
|
if (sceneSettings.spotLight.enabled) {
|
|
|
|
|
|
if (!spotLight) {
|
|
|
|
|
|
spotLight = new THREE.SpotLight(
|
|
|
|
|
|
sceneSettings.spotLight.color,
|
|
|
|
|
|
sceneSettings.spotLight.intensity
|
|
|
|
|
|
)
|
|
|
|
|
|
spotLight.position.set(0, 30, 0)
|
|
|
|
|
|
spotLight.angle = (sceneSettings.spotLight.angle * Math.PI) / 180
|
|
|
|
|
|
spotLight.penumbra = 0.3
|
|
|
|
|
|
spotLight.castShadow = true
|
|
|
|
|
|
scene.add(spotLight)
|
|
|
|
|
|
|
|
|
|
|
|
// 添加聚光灯目标
|
|
|
|
|
|
spotLight.target.position.set(0, 0, 0)
|
|
|
|
|
|
scene.add(spotLight.target)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
spotLight.intensity = sceneSettings.spotLight.intensity
|
|
|
|
|
|
spotLight.color.set(sceneSettings.spotLight.color)
|
|
|
|
|
|
spotLight.angle = (sceneSettings.spotLight.angle * Math.PI) / 180
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
if (spotLight) {
|
|
|
|
|
|
scene.remove(spotLight)
|
|
|
|
|
|
scene.remove(spotLight.target)
|
|
|
|
|
|
spotLight.dispose()
|
|
|
|
|
|
spotLight = null
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 更新渲染设置
|
|
|
|
|
|
const updateRenderSettings = () => {
|
|
|
|
|
|
if (renderer) {
|
|
|
|
|
|
renderer.toneMappingExposure = sceneSettings.render.exposure
|
|
|
|
|
|
|
|
|
|
|
|
const toneMappingMap: Record<string, THREE.ToneMapping> = {
|
2026-01-14 10:06:08 +08:00
|
|
|
|
ACES: THREE.ACESFilmicToneMapping,
|
|
|
|
|
|
Linear: THREE.LinearToneMapping,
|
|
|
|
|
|
Reinhard: THREE.ReinhardToneMapping,
|
|
|
|
|
|
Cineon: THREE.CineonToneMapping,
|
2026-01-13 16:41:12 +08:00
|
|
|
|
}
|
2026-01-14 10:06:08 +08:00
|
|
|
|
renderer.toneMapping =
|
|
|
|
|
|
toneMappingMap[sceneSettings.render.toneMapping] ||
|
|
|
|
|
|
THREE.ACESFilmicToneMapping
|
2026-01-13 16:41:12 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 重置设置
|
|
|
|
|
|
const resetSettings = () => {
|
|
|
|
|
|
Object.assign(sceneSettings, JSON.parse(JSON.stringify(defaultSettings)))
|
|
|
|
|
|
|
|
|
|
|
|
updateBackgroundColor()
|
|
|
|
|
|
updateGridVisibility()
|
|
|
|
|
|
updateAmbientLight()
|
|
|
|
|
|
updateMainLight()
|
|
|
|
|
|
updateMainLightPosition()
|
|
|
|
|
|
updateFillLight()
|
|
|
|
|
|
updateSpotLight()
|
|
|
|
|
|
updateRenderSettings()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-09 18:14:35 +08:00
|
|
|
|
// 初始化场景
|
|
|
|
|
|
const initScene = () => {
|
|
|
|
|
|
if (!containerRef.value) return
|
|
|
|
|
|
|
|
|
|
|
|
// 创建场景
|
|
|
|
|
|
scene = new THREE.Scene()
|
2026-01-13 16:41:12 +08:00
|
|
|
|
scene.background = new THREE.Color(sceneSettings.backgroundColor)
|
2026-01-09 18:14:35 +08:00
|
|
|
|
|
|
|
|
|
|
// 创建相机
|
|
|
|
|
|
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",
|
2026-01-14 10:06:08 +08:00
|
|
|
|
precision: "highp",
|
2026-01-09 18:14:35 +08:00
|
|
|
|
})
|
|
|
|
|
|
renderer.setSize(width, height)
|
|
|
|
|
|
renderer.setPixelRatio(window.devicePixelRatio)
|
|
|
|
|
|
renderer.shadowMap.enabled = true
|
|
|
|
|
|
renderer.shadowMap.type = THREE.PCFSoftShadowMap
|
|
|
|
|
|
renderer.toneMapping = THREE.ACESFilmicToneMapping
|
2026-01-13 16:41:12 +08:00
|
|
|
|
renderer.toneMappingExposure = sceneSettings.render.exposure
|
2026-01-09 18:14:35 +08:00
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
|
// 添加环境光
|
2026-01-13 16:41:12 +08:00
|
|
|
|
ambientLight = new THREE.AmbientLight(
|
|
|
|
|
|
sceneSettings.ambientLight.color,
|
|
|
|
|
|
sceneSettings.ambientLight.intensity
|
|
|
|
|
|
)
|
2026-01-09 18:14:35 +08:00
|
|
|
|
scene.add(ambientLight)
|
|
|
|
|
|
|
2026-01-13 16:41:12 +08:00
|
|
|
|
// 添加主方向光
|
|
|
|
|
|
mainLight = new THREE.DirectionalLight(
|
|
|
|
|
|
sceneSettings.mainLight.color,
|
|
|
|
|
|
sceneSettings.mainLight.intensity
|
|
|
|
|
|
)
|
|
|
|
|
|
updateMainLightPosition()
|
2026-01-09 18:14:35 +08:00
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
|
// 添加补光 - 从侧面
|
2026-01-13 16:41:12 +08:00
|
|
|
|
fillLight = new THREE.DirectionalLight(
|
|
|
|
|
|
sceneSettings.fillLight.color,
|
|
|
|
|
|
sceneSettings.fillLight.intensity
|
|
|
|
|
|
)
|
2026-01-09 18:14:35 +08:00
|
|
|
|
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)
|
|
|
|
|
|
|
2026-01-14 14:29:16 +08:00
|
|
|
|
// 添加网格 - 使用蓝色调
|
|
|
|
|
|
gridHelper = new THREE.GridHelper(100, 100, 0x0958d9, 0x1677ff)
|
2026-01-13 16:41:12 +08:00
|
|
|
|
gridHelper.visible = sceneSettings.showGrid
|
2026-01-09 18:14:35 +08:00
|
|
|
|
scene.add(gridHelper)
|
|
|
|
|
|
|
|
|
|
|
|
// 开始渲染
|
|
|
|
|
|
animate()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 加载模型
|
|
|
|
|
|
const loadModel = async () => {
|
|
|
|
|
|
if (!scene || !modelUrl.value) {
|
|
|
|
|
|
error.value = "模型 URL 不存在"
|
|
|
|
|
|
loading.value = false
|
2026-01-14 10:06:08 +08:00
|
|
|
|
console.error("模型加载失败: URL为空", {
|
|
|
|
|
|
scene: !!scene,
|
|
|
|
|
|
url: modelUrl.value,
|
|
|
|
|
|
})
|
2026-01-13 11:11:49 +08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-14 10:06:08 +08:00
|
|
|
|
// 检查文件扩展名(支持GLB、GLTF和ZIP格式,ZIP会在后端解压)
|
|
|
|
|
|
// 从URL中提取文件扩展名(忽略查询参数)
|
|
|
|
|
|
const urlWithoutQuery = modelUrl.value.split("?")[0].toLowerCase()
|
|
|
|
|
|
const supportedExtensions = [".glb", ".gltf", ".zip"]
|
|
|
|
|
|
const isSupported = supportedExtensions.some((ext) =>
|
|
|
|
|
|
urlWithoutQuery.endsWith(ext)
|
|
|
|
|
|
)
|
2026-01-13 11:11:49 +08:00
|
|
|
|
if (!isSupported) {
|
2026-01-14 10:06:08 +08:00
|
|
|
|
error.value = `不支持的文件格式,目前仅支持 GLB/GLTF/ZIP 格式`
|
2026-01-13 11:11:49 +08:00
|
|
|
|
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-14 10:06:08 +08:00
|
|
|
|
// 使用后端代理解决CORS问题
|
|
|
|
|
|
// 将原始URL编码后作为查询参数传递给后端代理
|
|
|
|
|
|
const proxyUrl = `/api/ai-3d/proxy-model?url=${encodeURIComponent(modelUrl.value)}`
|
|
|
|
|
|
console.log("使用代理URL:", proxyUrl)
|
2026-01-09 18:14:35 +08:00
|
|
|
|
|
|
|
|
|
|
const loader = new GLTFLoader()
|
|
|
|
|
|
|
|
|
|
|
|
// 配置 DRACO 解码器
|
|
|
|
|
|
if (!dracoLoader) {
|
|
|
|
|
|
dracoLoader = new DRACOLoader()
|
2026-01-14 10:06:08 +08:00
|
|
|
|
dracoLoader.setDecoderPath(
|
|
|
|
|
|
"https://www.gstatic.com/draco/versioned/decoders/1.5.6/"
|
|
|
|
|
|
)
|
2026-01-09 18:14:35 +08:00
|
|
|
|
}
|
|
|
|
|
|
loader.setDRACOLoader(dracoLoader)
|
|
|
|
|
|
|
2026-01-14 10:06:08 +08:00
|
|
|
|
// 使用代理URL加载模型
|
|
|
|
|
|
const gltf = await loader.loadAsync(proxyUrl)
|
2026-01-09 18:14:35 +08:00
|
|
|
|
|
|
|
|
|
|
// 移除旧模型
|
|
|
|
|
|
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(),
|
2026-01-14 10:06:08 +08:00
|
|
|
|
faces: Math.round(faceCount).toLocaleString(),
|
2026-01-09 18:14:35 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 居中模型
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
// 计算需要的距离:确保模型在垂直和水平方向都能完整显示
|
2026-01-14 10:06:08 +08:00
|
|
|
|
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)
|
2026-01-09 18:14:35 +08:00
|
|
|
|
|
2026-01-14 14:29:16 +08:00
|
|
|
|
// 取最大距离,并添加适当的边距(1.8倍,让模型显示更大)
|
2026-01-14 10:06:08 +08:00
|
|
|
|
const baseDistance = Math.max(
|
|
|
|
|
|
distanceForHeight,
|
|
|
|
|
|
distanceForWidth,
|
|
|
|
|
|
distanceForDepth
|
|
|
|
|
|
)
|
2026-01-14 14:29:16 +08:00
|
|
|
|
const cameraDistance = Math.max(baseDistance * 1.8, maxDim * 1.8, 5)
|
2026-01-09 18:14:35 +08:00
|
|
|
|
|
|
|
|
|
|
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)
|
2026-01-14 10:06:08 +08:00
|
|
|
|
|
|
|
|
|
|
// 提取更详细的错误信息
|
|
|
|
|
|
let errorMessage = "无法加载模型文件"
|
|
|
|
|
|
|
|
|
|
|
|
if (err.message) {
|
|
|
|
|
|
errorMessage = err.message
|
|
|
|
|
|
} else if (err.response?.data?.message) {
|
|
|
|
|
|
errorMessage = err.response.data.message
|
|
|
|
|
|
} else if (typeof err === "string") {
|
|
|
|
|
|
errorMessage = err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 如果是网络错误或代理错误,提供更友好的提示
|
|
|
|
|
|
if (errorMessage.includes("代理请求失败") || errorMessage.includes("ZIP")) {
|
|
|
|
|
|
errorMessage = `模型文件处理失败: ${errorMessage}`
|
|
|
|
|
|
} else if (
|
|
|
|
|
|
errorMessage.includes("timeout") ||
|
|
|
|
|
|
errorMessage.includes("超时")
|
|
|
|
|
|
) {
|
|
|
|
|
|
errorMessage = "模型文件下载超时,请检查网络连接或稍后重试"
|
|
|
|
|
|
} else if (
|
|
|
|
|
|
errorMessage.includes("404") ||
|
|
|
|
|
|
errorMessage.includes("不存在")
|
|
|
|
|
|
) {
|
|
|
|
|
|
errorMessage = "模型文件不存在或已被删除"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
error.value = errorMessage
|
2026-01-09 18:14:35 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-13 16:41:12 +08:00
|
|
|
|
if (spotLight && scene) {
|
|
|
|
|
|
scene.remove(spotLight)
|
|
|
|
|
|
scene.remove(spotLight.target)
|
|
|
|
|
|
spotLight.dispose()
|
|
|
|
|
|
spotLight = null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-09 18:14:35 +08:00
|
|
|
|
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
|
2026-01-13 16:41:12 +08:00
|
|
|
|
ambientLight = null
|
|
|
|
|
|
mainLight = null
|
|
|
|
|
|
fillLight = null
|
|
|
|
|
|
gridHelper = null
|
2026-01-09 18:14:35 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 重试
|
|
|
|
|
|
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">
|
2026-01-13 16:41:12 +08:00
|
|
|
|
// ==========================================
|
2026-01-14 14:29:16 +08:00
|
|
|
|
// 蓝色主题色彩方案 - 与系统统一
|
2026-01-13 16:41:12 +08:00
|
|
|
|
// ==========================================
|
2026-01-14 14:29:16 +08:00
|
|
|
|
$primary: #0958d9;
|
|
|
|
|
|
$primary-light: #1677ff;
|
|
|
|
|
|
$primary-dark: #003eb3;
|
|
|
|
|
|
$secondary: #4096ff;
|
|
|
|
|
|
$accent: #1677ff;
|
|
|
|
|
|
$success: #52c41a;
|
|
|
|
|
|
$warning: #faad14;
|
|
|
|
|
|
$error: #ff4d4f;
|
|
|
|
|
|
|
|
|
|
|
|
// 背景色 - 模型查看器保持深色以更好展示3D模型
|
|
|
|
|
|
$background: #f5f5f5;
|
|
|
|
|
|
$surface: #ffffff;
|
|
|
|
|
|
$surface-dark: #1a1a2e;
|
|
|
|
|
|
$text: rgba(0, 0, 0, 0.85);
|
|
|
|
|
|
$text-secondary: rgba(0, 0, 0, 0.65);
|
|
|
|
|
|
$text-muted: rgba(0, 0, 0, 0.45);
|
|
|
|
|
|
$text-light: #ffffff;
|
|
|
|
|
|
|
|
|
|
|
|
// 渐变
|
|
|
|
|
|
$gradient-primary: linear-gradient(135deg, $primary 0%, $primary-light 100%);
|
2026-01-13 16:41:12 +08:00
|
|
|
|
|
2026-01-09 18:14:35 +08:00
|
|
|
|
.model-viewer-page {
|
|
|
|
|
|
position: fixed;
|
|
|
|
|
|
inset: 0;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
2026-01-13 16:41:12 +08:00
|
|
|
|
background: $background;
|
2026-01-09 18:14:35 +08:00
|
|
|
|
z-index: 1000;
|
2026-01-13 16:41:12 +08:00
|
|
|
|
overflow: hidden;
|
2026-01-14 14:29:16 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ==========================================
|
|
|
|
|
|
// Animated Background
|
|
|
|
|
|
// ==========================================
|
|
|
|
|
|
.bg-animation {
|
|
|
|
|
|
position: absolute;
|
|
|
|
|
|
inset: 0;
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
pointer-events: none;
|
|
|
|
|
|
z-index: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.bg-gradient {
|
|
|
|
|
|
position: absolute;
|
|
|
|
|
|
border-radius: 50%;
|
|
|
|
|
|
filter: blur(100px);
|
|
|
|
|
|
opacity: 0.15;
|
|
|
|
|
|
animation: float 30s ease-in-out infinite;
|
|
|
|
|
|
|
|
|
|
|
|
&.bg-gradient-1 {
|
|
|
|
|
|
width: 600px;
|
|
|
|
|
|
height: 600px;
|
|
|
|
|
|
background: $primary;
|
|
|
|
|
|
top: -200px;
|
|
|
|
|
|
left: -100px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
&.bg-gradient-2 {
|
|
|
|
|
|
width: 500px;
|
|
|
|
|
|
height: 500px;
|
|
|
|
|
|
background: $primary-light;
|
|
|
|
|
|
bottom: -150px;
|
|
|
|
|
|
right: -100px;
|
|
|
|
|
|
animation-delay: -10s;
|
|
|
|
|
|
}
|
2026-01-13 16:41:12 +08:00
|
|
|
|
|
2026-01-14 14:29:16 +08:00
|
|
|
|
&.bg-gradient-3 {
|
|
|
|
|
|
width: 400px;
|
|
|
|
|
|
height: 400px;
|
|
|
|
|
|
background: $secondary;
|
|
|
|
|
|
top: 50%;
|
|
|
|
|
|
left: 50%;
|
|
|
|
|
|
transform: translate(-50%, -50%);
|
|
|
|
|
|
animation-delay: -20s;
|
|
|
|
|
|
opacity: 0.1;
|
2026-01-13 16:41:12 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-14 14:29:16 +08:00
|
|
|
|
@keyframes float {
|
2026-01-14 10:06:08 +08:00
|
|
|
|
0%,
|
|
|
|
|
|
100% {
|
2026-01-14 14:29:16 +08:00
|
|
|
|
transform: translate(0, 0) scale(1);
|
2026-01-14 10:06:08 +08:00
|
|
|
|
}
|
2026-01-14 14:29:16 +08:00
|
|
|
|
33% {
|
|
|
|
|
|
transform: translate(30px, -30px) scale(1.05);
|
|
|
|
|
|
}
|
|
|
|
|
|
66% {
|
|
|
|
|
|
transform: translate(-20px, 20px) scale(0.95);
|
2026-01-14 10:06:08 +08:00
|
|
|
|
}
|
2026-01-09 18:14:35 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-13 16:41:12 +08:00
|
|
|
|
// ==========================================
|
|
|
|
|
|
// Header
|
|
|
|
|
|
// ==========================================
|
2026-01-09 18:14:35 +08:00
|
|
|
|
.viewer-header {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
padding: 12px 20px;
|
2026-01-14 14:29:16 +08:00
|
|
|
|
background: rgba($surface, 0.7);
|
2026-01-13 16:41:12 +08:00
|
|
|
|
backdrop-filter: blur(20px);
|
2026-01-14 14:29:16 +08:00
|
|
|
|
border-bottom: 1px solid rgba($primary, 0.1);
|
|
|
|
|
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
2026-01-13 16:41:12 +08:00
|
|
|
|
position: relative;
|
|
|
|
|
|
z-index: 10;
|
2026-01-09 18:14:35 +08:00
|
|
|
|
|
|
|
|
|
|
.header-left {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 16px;
|
|
|
|
|
|
|
|
|
|
|
|
.title {
|
2026-01-13 16:41:12 +08:00
|
|
|
|
color: $text;
|
|
|
|
|
|
font-size: 18px;
|
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
|
background: $gradient-primary;
|
|
|
|
|
|
-webkit-background-clip: text;
|
|
|
|
|
|
-webkit-text-fill-color: transparent;
|
|
|
|
|
|
background-clip: text;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.badge {
|
|
|
|
|
|
padding: 2px 8px;
|
|
|
|
|
|
font-size: 10px;
|
|
|
|
|
|
font-weight: 600;
|
2026-01-09 18:14:35 +08:00
|
|
|
|
color: #fff;
|
2026-01-14 14:29:16 +08:00
|
|
|
|
background: $success;
|
2026-01-13 16:41:12 +08:00
|
|
|
|
border-radius: 4px;
|
|
|
|
|
|
animation: pulse 2s ease-in-out infinite;
|
2026-01-09 18:14:35 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.header-right {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
gap: 8px;
|
|
|
|
|
|
}
|
2026-01-13 16:41:12 +08:00
|
|
|
|
}
|
2026-01-09 18:14:35 +08:00
|
|
|
|
|
2026-01-13 16:41:12 +08:00
|
|
|
|
@keyframes pulse {
|
2026-01-14 10:06:08 +08:00
|
|
|
|
0%,
|
|
|
|
|
|
100% {
|
|
|
|
|
|
opacity: 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
50% {
|
|
|
|
|
|
opacity: 0.7;
|
|
|
|
|
|
}
|
2026-01-13 16:41:12 +08:00
|
|
|
|
}
|
2026-01-09 18:14:35 +08:00
|
|
|
|
|
2026-01-14 14:29:16 +08:00
|
|
|
|
.back-btn {
|
2026-01-13 16:41:12 +08:00
|
|
|
|
color: $text !important;
|
2026-01-14 14:29:16 +08:00
|
|
|
|
width: 40px;
|
|
|
|
|
|
height: 40px;
|
|
|
|
|
|
border-radius: 10px !important;
|
2026-01-13 16:41:12 +08:00
|
|
|
|
border: 1px solid rgba($primary, 0.3) !important;
|
2026-01-14 14:29:16 +08:00
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: center;
|
2026-01-13 16:41:12 +08:00
|
|
|
|
transition: all 0.3s ease !important;
|
2026-01-14 14:29:16 +08:00
|
|
|
|
flex-shrink: 0;
|
2026-01-13 16:41:12 +08:00
|
|
|
|
|
|
|
|
|
|
&:hover {
|
|
|
|
|
|
background: rgba($primary, 0.2) !important;
|
|
|
|
|
|
border-color: $primary !important;
|
|
|
|
|
|
transform: translateY(-1px);
|
2026-01-09 18:14:35 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-14 14:29:16 +08:00
|
|
|
|
.action-btn {
|
|
|
|
|
|
color: $text !important;
|
|
|
|
|
|
border: 1px solid rgba($primary, 0.2) !important;
|
|
|
|
|
|
border-radius: 8px !important;
|
|
|
|
|
|
transition: all 0.3s ease !important;
|
|
|
|
|
|
|
|
|
|
|
|
&:hover {
|
|
|
|
|
|
color: $primary !important;
|
|
|
|
|
|
background: rgba($primary, 0.1) !important;
|
|
|
|
|
|
border-color: $primary !important;
|
|
|
|
|
|
transform: translateY(-1px);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-13 16:41:12 +08:00
|
|
|
|
// ==========================================
|
|
|
|
|
|
// Content Area
|
|
|
|
|
|
// ==========================================
|
2026-01-09 18:14:35 +08:00
|
|
|
|
.viewer-content {
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
position: relative;
|
|
|
|
|
|
overflow: hidden;
|
2026-01-13 16:41:12 +08:00
|
|
|
|
z-index: 1;
|
2026-01-14 14:29:16 +08:00
|
|
|
|
background: rgba($surface, 0.3);
|
|
|
|
|
|
backdrop-filter: blur(10px);
|
2026-01-09 18:14:35 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.model-canvas {
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
height: 100%;
|
|
|
|
|
|
|
|
|
|
|
|
:deep(canvas) {
|
|
|
|
|
|
display: block;
|
|
|
|
|
|
width: 100% !important;
|
|
|
|
|
|
height: 100% !important;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-13 16:41:12 +08:00
|
|
|
|
// ==========================================
|
|
|
|
|
|
// Loading State
|
|
|
|
|
|
// ==========================================
|
|
|
|
|
|
.loading-overlay {
|
2026-01-09 18:14:35 +08:00
|
|
|
|
position: absolute;
|
|
|
|
|
|
inset: 0;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: center;
|
2026-01-14 14:29:16 +08:00
|
|
|
|
background: $background;
|
2026-01-09 18:14:35 +08:00
|
|
|
|
z-index: 10;
|
2026-01-13 16:41:12 +08:00
|
|
|
|
}
|
2026-01-09 18:14:35 +08:00
|
|
|
|
|
2026-01-13 16:41:12 +08:00
|
|
|
|
.loader {
|
|
|
|
|
|
position: relative;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 24px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.loader-ring {
|
|
|
|
|
|
position: absolute;
|
|
|
|
|
|
width: 80px;
|
|
|
|
|
|
height: 80px;
|
|
|
|
|
|
border: 3px solid transparent;
|
|
|
|
|
|
border-radius: 50%;
|
|
|
|
|
|
animation: spin 1.5s linear infinite;
|
|
|
|
|
|
|
|
|
|
|
|
&:nth-child(1) {
|
|
|
|
|
|
border-top-color: $primary;
|
|
|
|
|
|
animation-delay: 0s;
|
2026-01-09 18:14:35 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-13 16:41:12 +08:00
|
|
|
|
&:nth-child(2) {
|
|
|
|
|
|
width: 60px;
|
|
|
|
|
|
height: 60px;
|
2026-01-14 14:29:16 +08:00
|
|
|
|
border-right-color: $primary-light;
|
2026-01-13 16:41:12 +08:00
|
|
|
|
animation-delay: 0.2s;
|
|
|
|
|
|
animation-direction: reverse;
|
2026-01-09 18:14:35 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-13 16:41:12 +08:00
|
|
|
|
&:nth-child(3) {
|
|
|
|
|
|
width: 40px;
|
|
|
|
|
|
height: 40px;
|
2026-01-14 14:29:16 +08:00
|
|
|
|
border-bottom-color: $secondary;
|
2026-01-13 16:41:12 +08:00
|
|
|
|
animation-delay: 0.4s;
|
2026-01-09 18:14:35 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-13 16:41:12 +08:00
|
|
|
|
.loader-text {
|
|
|
|
|
|
margin-top: 100px;
|
|
|
|
|
|
color: $text-muted;
|
|
|
|
|
|
font-size: 14px;
|
2026-01-09 18:14:35 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-13 16:41:12 +08:00
|
|
|
|
@keyframes spin {
|
2026-01-14 10:06:08 +08:00
|
|
|
|
to {
|
|
|
|
|
|
transform: rotate(360deg);
|
|
|
|
|
|
}
|
2026-01-13 16:41:12 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ==========================================
|
|
|
|
|
|
// Error State
|
|
|
|
|
|
// ==========================================
|
|
|
|
|
|
.error-overlay {
|
|
|
|
|
|
position: absolute;
|
|
|
|
|
|
inset: 0;
|
2026-01-09 18:14:35 +08:00
|
|
|
|
display: flex;
|
2026-01-13 16:41:12 +08:00
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: center;
|
2026-01-14 14:29:16 +08:00
|
|
|
|
background: $background;
|
2026-01-13 16:41:12 +08:00
|
|
|
|
z-index: 10;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.error-card {
|
|
|
|
|
|
text-align: center;
|
|
|
|
|
|
padding: 40px;
|
2026-01-14 14:29:16 +08:00
|
|
|
|
background: $surface;
|
|
|
|
|
|
border: 1px solid #e8e8e8;
|
2026-01-13 16:41:12 +08:00
|
|
|
|
border-radius: 16px;
|
2026-01-14 14:29:16 +08:00
|
|
|
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
2026-01-13 16:41:12 +08:00
|
|
|
|
|
|
|
|
|
|
.error-icon {
|
|
|
|
|
|
width: 60px;
|
|
|
|
|
|
height: 60px;
|
|
|
|
|
|
margin: 0 auto 20px;
|
2026-01-09 18:14:35 +08:00
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
2026-01-13 16:41:12 +08:00
|
|
|
|
justify-content: center;
|
|
|
|
|
|
font-size: 32px;
|
|
|
|
|
|
font-weight: bold;
|
|
|
|
|
|
color: #fff;
|
2026-01-14 14:29:16 +08:00
|
|
|
|
background: $error;
|
2026-01-13 16:41:12 +08:00
|
|
|
|
border-radius: 50%;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
h3 {
|
|
|
|
|
|
color: $text;
|
|
|
|
|
|
font-size: 20px;
|
|
|
|
|
|
margin-bottom: 8px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
p {
|
|
|
|
|
|
color: $text-muted;
|
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
|
margin-bottom: 24px;
|
2026-01-09 18:14:35 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-13 16:41:12 +08:00
|
|
|
|
.error-actions {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
gap: 12px;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.gradient-btn {
|
|
|
|
|
|
background: $gradient-primary !important;
|
|
|
|
|
|
border: none !important;
|
|
|
|
|
|
color: #fff !important;
|
|
|
|
|
|
font-weight: 500 !important;
|
|
|
|
|
|
|
|
|
|
|
|
&:hover {
|
|
|
|
|
|
filter: brightness(1.1);
|
|
|
|
|
|
transform: translateY(-1px);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.outline-btn {
|
|
|
|
|
|
background: transparent !important;
|
2026-01-14 14:29:16 +08:00
|
|
|
|
border: 1px solid #e8e8e8 !important;
|
2026-01-13 16:41:12 +08:00
|
|
|
|
color: $text !important;
|
|
|
|
|
|
|
|
|
|
|
|
&:hover {
|
|
|
|
|
|
border-color: $primary !important;
|
|
|
|
|
|
color: $primary-light !important;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ==========================================
|
|
|
|
|
|
// Model Info (Left Side)
|
|
|
|
|
|
// ==========================================
|
2026-01-09 18:14:35 +08:00
|
|
|
|
.model-info {
|
|
|
|
|
|
position: absolute;
|
|
|
|
|
|
top: 20px;
|
2026-01-13 16:41:12 +08:00
|
|
|
|
left: 20px;
|
|
|
|
|
|
background: rgba($surface, 0.8);
|
|
|
|
|
|
backdrop-filter: blur(20px);
|
|
|
|
|
|
border: 1px solid rgba($primary, 0.3);
|
|
|
|
|
|
border-radius: 12px;
|
|
|
|
|
|
overflow: hidden;
|
2026-01-09 18:14:35 +08:00
|
|
|
|
z-index: 5;
|
2026-01-13 16:41:12 +08:00
|
|
|
|
min-width: 200px;
|
|
|
|
|
|
|
|
|
|
|
|
.info-header {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 10px;
|
|
|
|
|
|
padding: 14px 16px;
|
|
|
|
|
|
background: rgba($primary, 0.1);
|
|
|
|
|
|
border-bottom: 1px solid rgba($primary, 0.2);
|
|
|
|
|
|
|
|
|
|
|
|
.info-icon {
|
|
|
|
|
|
color: $primary-light;
|
|
|
|
|
|
font-size: 16px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.info-title {
|
|
|
|
|
|
color: $text;
|
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.info-body {
|
|
|
|
|
|
padding: 12px 16px;
|
|
|
|
|
|
}
|
2026-01-09 18:14:35 +08:00
|
|
|
|
|
|
|
|
|
|
.info-item {
|
|
|
|
|
|
display: flex;
|
2026-01-13 16:41:12 +08:00
|
|
|
|
justify-content: space-between;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
padding: 8px 0;
|
2026-01-09 18:14:35 +08:00
|
|
|
|
|
2026-01-13 16:41:12 +08:00
|
|
|
|
&:not(:last-child) {
|
|
|
|
|
|
border-bottom: 1px solid rgba($primary, 0.1);
|
2026-01-09 18:14:35 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.label {
|
2026-01-13 16:41:12 +08:00
|
|
|
|
color: $text-muted;
|
|
|
|
|
|
font-size: 13px;
|
2026-01-09 18:14:35 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.value {
|
2026-01-13 16:41:12 +08:00
|
|
|
|
color: $text;
|
|
|
|
|
|
font-size: 13px;
|
2026-01-09 18:14:35 +08:00
|
|
|
|
font-weight: 500;
|
2026-01-13 16:41:12 +08:00
|
|
|
|
|
|
|
|
|
|
&.highlight {
|
|
|
|
|
|
color: $secondary;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ==========================================
|
|
|
|
|
|
// Scene Settings (Right Side)
|
|
|
|
|
|
// ==========================================
|
|
|
|
|
|
.scene-settings {
|
|
|
|
|
|
position: absolute;
|
|
|
|
|
|
top: 20px;
|
|
|
|
|
|
right: 20px;
|
|
|
|
|
|
width: 280px;
|
|
|
|
|
|
max-height: calc(100% - 40px);
|
|
|
|
|
|
background: rgba($surface, 0.85);
|
|
|
|
|
|
backdrop-filter: blur(20px);
|
|
|
|
|
|
border: 1px solid rgba($primary, 0.3);
|
|
|
|
|
|
border-radius: 12px;
|
|
|
|
|
|
z-index: 10;
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.settings-header {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
padding: 14px 16px;
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
|
background: rgba($primary, 0.1);
|
|
|
|
|
|
|
|
|
|
|
|
&:hover {
|
|
|
|
|
|
background: rgba($primary, 0.15);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.settings-title {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 10px;
|
|
|
|
|
|
color: $text;
|
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.collapse-icon {
|
|
|
|
|
|
color: $text-muted;
|
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
|
transition: transform 0.3s;
|
|
|
|
|
|
|
|
|
|
|
|
&.is-open {
|
|
|
|
|
|
transform: rotate(90deg);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.settings-body {
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
overflow-y: auto;
|
|
|
|
|
|
padding: 8px 0;
|
|
|
|
|
|
|
|
|
|
|
|
&::-webkit-scrollbar {
|
|
|
|
|
|
width: 4px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
&::-webkit-scrollbar-track {
|
|
|
|
|
|
background: transparent;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
&::-webkit-scrollbar-thumb {
|
|
|
|
|
|
background: rgba($primary, 0.3);
|
|
|
|
|
|
border-radius: 2px;
|
|
|
|
|
|
|
|
|
|
|
|
&:hover {
|
|
|
|
|
|
background: rgba($primary, 0.5);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.settings-group {
|
|
|
|
|
|
padding: 12px 16px;
|
|
|
|
|
|
border-bottom: 1px solid rgba($primary, 0.1);
|
|
|
|
|
|
|
|
|
|
|
|
&:last-of-type {
|
|
|
|
|
|
border-bottom: none;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.group-title {
|
|
|
|
|
|
color: $primary-light;
|
|
|
|
|
|
font-size: 11px;
|
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
|
text-transform: uppercase;
|
|
|
|
|
|
letter-spacing: 1px;
|
|
|
|
|
|
margin-bottom: 12px;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.setting-item {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
|
margin-bottom: 10px;
|
|
|
|
|
|
|
|
|
|
|
|
&:last-child {
|
|
|
|
|
|
margin-bottom: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.setting-label {
|
|
|
|
|
|
color: $text;
|
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.slider-wrapper {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 8px;
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
margin-left: 12px;
|
|
|
|
|
|
|
|
|
|
|
|
:deep(.ant-slider) {
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
margin: 0;
|
|
|
|
|
|
|
|
|
|
|
|
.ant-slider-rail {
|
|
|
|
|
|
background: rgba($primary, 0.2);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.ant-slider-track {
|
|
|
|
|
|
background: $gradient-primary;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.ant-slider-handle {
|
|
|
|
|
|
border-color: $primary;
|
|
|
|
|
|
background: $primary;
|
|
|
|
|
|
|
2026-01-14 10:06:08 +08:00
|
|
|
|
&:hover,
|
|
|
|
|
|
&:focus {
|
2026-01-13 16:41:12 +08:00
|
|
|
|
border-color: $primary-light;
|
|
|
|
|
|
box-shadow: 0 0 0 4px rgba($primary, 0.2);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.slider-value {
|
|
|
|
|
|
color: $text-muted;
|
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
|
min-width: 36px;
|
|
|
|
|
|
text-align: right;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.color-picker {
|
|
|
|
|
|
width: 36px;
|
|
|
|
|
|
height: 28px;
|
|
|
|
|
|
padding: 0;
|
|
|
|
|
|
border: 2px solid rgba($primary, 0.3);
|
|
|
|
|
|
border-radius: 6px;
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
background: transparent;
|
|
|
|
|
|
transition: border-color 0.3s;
|
|
|
|
|
|
|
|
|
|
|
|
&:hover {
|
|
|
|
|
|
border-color: $primary;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
&::-webkit-color-swatch-wrapper {
|
|
|
|
|
|
padding: 2px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
&::-webkit-color-swatch {
|
|
|
|
|
|
border: none;
|
|
|
|
|
|
border-radius: 3px;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.settings-footer {
|
|
|
|
|
|
padding: 12px 16px;
|
|
|
|
|
|
border-top: 1px solid rgba($primary, 0.2);
|
|
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
|
background: rgba($primary, 0.05);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.reset-btn {
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
background: transparent !important;
|
|
|
|
|
|
border: 1px solid rgba($primary, 0.5) !important;
|
|
|
|
|
|
color: $primary-light !important;
|
|
|
|
|
|
font-weight: 500 !important;
|
|
|
|
|
|
transition: all 0.3s !important;
|
|
|
|
|
|
|
|
|
|
|
|
&:hover {
|
|
|
|
|
|
background: rgba($primary, 0.2) !important;
|
|
|
|
|
|
border-color: $primary !important;
|
|
|
|
|
|
color: #fff !important;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Select 样式
|
|
|
|
|
|
:deep(.ant-select) {
|
|
|
|
|
|
.ant-select-selector {
|
|
|
|
|
|
background: rgba($primary, 0.1) !important;
|
|
|
|
|
|
border-color: rgba($primary, 0.3) !important;
|
|
|
|
|
|
color: $text !important;
|
|
|
|
|
|
border-radius: 6px !important;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.ant-select-arrow {
|
|
|
|
|
|
color: $text-muted;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
&:hover .ant-select-selector {
|
|
|
|
|
|
border-color: $primary !important;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
&.ant-select-focused .ant-select-selector {
|
|
|
|
|
|
border-color: $primary !important;
|
|
|
|
|
|
box-shadow: 0 0 0 2px rgba($primary, 0.2) !important;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Switch 样式
|
|
|
|
|
|
:deep(.ant-switch) {
|
|
|
|
|
|
background: rgba($text-muted, 0.3);
|
|
|
|
|
|
|
|
|
|
|
|
&.ant-switch-checked {
|
|
|
|
|
|
background: $gradient-primary;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ==========================================
|
|
|
|
|
|
// Controls Info
|
|
|
|
|
|
// ==========================================
|
|
|
|
|
|
.controls-info {
|
|
|
|
|
|
position: absolute;
|
|
|
|
|
|
bottom: 20px;
|
|
|
|
|
|
left: 50%;
|
|
|
|
|
|
transform: translateX(-50%);
|
|
|
|
|
|
z-index: 5;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.control-hint {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
gap: 20px;
|
|
|
|
|
|
padding: 12px 24px;
|
|
|
|
|
|
background: rgba($surface, 0.8);
|
|
|
|
|
|
backdrop-filter: blur(20px);
|
|
|
|
|
|
border: 1px solid rgba($primary, 0.3);
|
|
|
|
|
|
border-radius: 50px;
|
|
|
|
|
|
|
|
|
|
|
|
.hint-item {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 8px;
|
|
|
|
|
|
color: $text-muted;
|
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
|
|
|
|
|
|
|
.key {
|
|
|
|
|
|
padding: 2px 8px;
|
|
|
|
|
|
background: rgba($primary, 0.2);
|
|
|
|
|
|
border: 1px solid rgba($primary, 0.3);
|
|
|
|
|
|
border-radius: 4px;
|
|
|
|
|
|
color: $primary-light;
|
|
|
|
|
|
font-size: 11px;
|
|
|
|
|
|
font-weight: 600;
|
2026-01-09 18:14:35 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
</style>
|