暂存本地
17
.gitignore
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.DS_Store
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.vscode/
|
||||
.idea/
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# 模型文件较大,不提交到git
|
||||
public/models/*.glb
|
||||
263
ANNOTATION_GUIDE.md
Normal file
@@ -0,0 +1,263 @@
|
||||
# 模型标注配置指南
|
||||
|
||||
## 概述
|
||||
|
||||
本指南说明如何将3D模型的部件与检查项目(annotations)精确匹配,实现准确的标注定位。
|
||||
|
||||
## 方法一:使用调试组件查看模型结构
|
||||
|
||||
### 1. 启用调试模式
|
||||
|
||||
在 `ModelDetail.vue` 中使用 `ThreeViewerDebug` 组件并启用调试模式:
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div class="model-detail">
|
||||
<three-viewer-debug
|
||||
:model-path="modelPath"
|
||||
:annotations="modelData.componentCheck"
|
||||
:debug-mode="true"
|
||||
@annotation-click="handleAnnotationClick"
|
||||
@model-loaded="handleModelLoaded"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ThreeViewerDebug from '../components/ThreeViewerDebug.vue'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
ThreeViewerDebug
|
||||
}
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
### 2. 查看模型部件列表
|
||||
|
||||
启用调试模式后,右侧会显示调试面板,包含:
|
||||
- 模型中所有mesh的名称列表
|
||||
- 点击模型部件可查看其名称和坐标
|
||||
|
||||
### 3. 记录部件信息
|
||||
|
||||
1. 打开浏览器控制台(F12)
|
||||
2. 查看控制台输出的"模型部件列表"和"部件映射表"
|
||||
3. 点击模型上的部件,查看其名称和坐标
|
||||
4. 点击"复制坐标"按钮,将坐标复制到剪贴板
|
||||
|
||||
## 方法二:通过命名匹配(推荐)
|
||||
|
||||
### 自动匹配规则
|
||||
|
||||
组件会自动尝试匹配 `componentCheck` 中的 `name` 与模型mesh的名称:
|
||||
|
||||
1. **精确匹配**:mesh名称完全等于annotation名称
|
||||
2. **包含匹配**:mesh名称包含annotation名称,或反之
|
||||
3. **关键词匹配**:拆分annotation名称,匹配关键词
|
||||
|
||||
### 示例
|
||||
|
||||
如果模型中有以下mesh名称:
|
||||
```
|
||||
- 引线_001
|
||||
- 避雷器本体
|
||||
- 铭牌标识
|
||||
- 螺栓组件
|
||||
- 线夹装置
|
||||
- 绝缘罩_上
|
||||
- 绝缘罩_下
|
||||
```
|
||||
|
||||
data.json中的配置:
|
||||
```json
|
||||
{
|
||||
"componentCheck": [
|
||||
{ "name": "引线", "content": [...] }, // 匹配 "引线_001"
|
||||
{ "name": "避雷器与支撑件结构", "content": [...] }, // 匹配 "避雷器本体"
|
||||
{ "name": "铭牌", "content": [...] }, // 匹配 "铭牌标识"
|
||||
{ "name": "螺栓螺母", "content": [...] }, // 匹配 "螺栓组件"
|
||||
{ "name": "线夹", "content": [...] }, // 匹配 "线夹装置"
|
||||
{ "name": "绝缘罩", "content": [...] } // 匹配 "绝缘罩_上" 或 "绝缘罩_下"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## 方法三:手动配置坐标
|
||||
|
||||
如果模型没有命名或命名不规范,可以手动配置坐标。
|
||||
|
||||
### 1. 在data.json中添加position字段
|
||||
|
||||
```json
|
||||
{
|
||||
"componentCheck": [
|
||||
{
|
||||
"name": "避雷器与支撑件结构",
|
||||
"position": { "x": 0, "y": 1.5, "z": 0 },
|
||||
"content": [...]
|
||||
},
|
||||
{
|
||||
"name": "引线",
|
||||
"position": { "x": 0.5, "y": 2.0, "z": 0.3 },
|
||||
"content": [...]
|
||||
},
|
||||
{
|
||||
"name": "铭牌",
|
||||
"position": { "x": -0.3, "y": 0.5, "z": 0.2 },
|
||||
"content": [...]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 修改组件以支持position配置
|
||||
|
||||
在 `createAnnotations()` 方法中添加:
|
||||
|
||||
```javascript
|
||||
createAnnotations() {
|
||||
this.annotations.forEach((annotation, index) => {
|
||||
let position = new THREE.Vector3();
|
||||
|
||||
// 优先使用配置的坐标
|
||||
if (annotation.position) {
|
||||
position.set(
|
||||
annotation.position.x,
|
||||
annotation.position.y,
|
||||
annotation.position.z
|
||||
);
|
||||
} else {
|
||||
// 否则尝试匹配mesh
|
||||
const mesh = this.findMeshByName(annotation.name);
|
||||
if (mesh) {
|
||||
mesh.getWorldPosition(position);
|
||||
// ...
|
||||
}
|
||||
}
|
||||
// ...
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## 获取坐标的步骤
|
||||
|
||||
### 使用调试模式
|
||||
|
||||
1. 启用 `debugMode="true"`
|
||||
2. 运行项目,打开模型详情页
|
||||
3. 点击模型上的部件
|
||||
4. 查看右侧调试面板显示的坐标
|
||||
5. 点击"复制坐标"按钮
|
||||
6. 将坐标粘贴到data.json的对应位置
|
||||
|
||||
### 使用控制台
|
||||
|
||||
1. 打开浏览器控制台(F12)
|
||||
2. 点击模型部件
|
||||
3. 查看控制台输出:
|
||||
```
|
||||
点击的部件: {
|
||||
name: "引线_001",
|
||||
position: { x: 0.523, y: 2.145, z: 0.312 }
|
||||
}
|
||||
```
|
||||
4. 复制坐标到data.json
|
||||
|
||||
## 完整示例
|
||||
|
||||
### data.json配置
|
||||
|
||||
```json
|
||||
{
|
||||
"modelName": "交流避雷器",
|
||||
"modelPath": "1",
|
||||
"componentCheck": [
|
||||
{
|
||||
"name": "避雷器与支撑件结构",
|
||||
"meshName": "避雷器本体",
|
||||
"position": { "x": 0, "y": 1.5, "z": 0 },
|
||||
"content": [
|
||||
"避雷器与支撑件并列结构...",
|
||||
"避雷器在本体失效或损坏后..."
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "引线",
|
||||
"meshName": "引线_001",
|
||||
"position": { "x": 0.5, "y": 2.0, "z": 0.3 },
|
||||
"content": [
|
||||
"不低于 80cm,直径不低于 16mm 的软铜线"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### ModelDetail.vue使用
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div class="model-detail">
|
||||
<!-- 开发阶段使用调试版本 -->
|
||||
<three-viewer-debug
|
||||
v-if="isDevelopment"
|
||||
:model-path="modelPath"
|
||||
:annotations="modelData.componentCheck"
|
||||
:debug-mode="true"
|
||||
@annotation-click="handleAnnotationClick"
|
||||
/>
|
||||
|
||||
<!-- 生产环境使用正式版本 -->
|
||||
<three-viewer
|
||||
v-else
|
||||
:model-path="modelPath"
|
||||
:annotations="modelData.componentCheck"
|
||||
@annotation-click="handleAnnotationClick"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
isDevelopment: process.env.NODE_ENV === 'development'
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **坐标系统**:Three.js使用右手坐标系,Y轴向上
|
||||
2. **模型居中**:组件会自动将模型居中,坐标相对于模型中心
|
||||
3. **标注高度**:标注会自动放置在部件上方约0.3单位处
|
||||
4. **命名规范**:建议在建模时为重要部件命名,便于自动匹配
|
||||
5. **性能考虑**:过多的标注可能影响性能,建议控制在10个以内
|
||||
|
||||
## 故障排查
|
||||
|
||||
### 标注不显示
|
||||
- 检查 `labelRenderer.render()` 是否在动画循环中调用
|
||||
- 检查标注位置是否在相机视野内
|
||||
- 检查CSS样式是否正确
|
||||
|
||||
### 无法匹配部件
|
||||
- 使用调试模式查看模型部件列表
|
||||
- 检查mesh是否有命名
|
||||
- 考虑使用手动配置坐标
|
||||
|
||||
### 高亮不生效
|
||||
- 检查是否正确传递mesh对象
|
||||
- 确认OutlinePass已正确初始化
|
||||
- 查看控制台是否有错误信息
|
||||
|
||||
## 相关文件
|
||||
|
||||
- `src/components/ThreeViewerDebug.vue` - 调试版本组件
|
||||
- `src/components/ThreeViewer.vue` - 生产版本组件
|
||||
- `src/data/data.json` - 模型数据配置
|
||||
- `src/views/ModelDetail.vue` - 详情页面
|
||||
79
QUICKSTART.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# 快速启动指南
|
||||
|
||||
## 第一步:安装依赖
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
## 第二步:准备GLB模型文件
|
||||
|
||||
将您的GLB模型文件放入 `public/models/` 目录,文件名需要与 `src/data/data.json` 中的 `modelName` 对应。
|
||||
|
||||
例如:
|
||||
- `交流避雷器,AC10kV,13kV, 硅橡胶,40kV, 带间隙。外间隙,带支撑件间隙,不兼做绝缘子.glb`
|
||||
- `交流棒形悬式复合绝缘子,FXBW-10/70.glb`
|
||||
- `10kV 三相隔离开关.glb`
|
||||
|
||||
**注意:** 如果暂时没有模型文件,系统会显示加载失败提示,但不影响其他功能测试。
|
||||
|
||||
## 第三步:启动开发服务器
|
||||
|
||||
```bash
|
||||
npm run serve
|
||||
```
|
||||
|
||||
浏览器会自动打开 http://localhost:8080
|
||||
|
||||
## 功能测试
|
||||
|
||||
### 1. 列表页
|
||||
- 查看所有模型列表
|
||||
- 点击任意模型卡片进入详情页
|
||||
|
||||
### 2. 详情页(PC端)
|
||||
- 左侧:3D模型展示区
|
||||
- 右侧:检测内容表格
|
||||
- 点击模型上的标注点,右侧表格会自动高亮对应项
|
||||
|
||||
### 3. 详情页(移动端)
|
||||
- 顶部:模型名称
|
||||
- 中间:3D模型展示区
|
||||
- 底部:检测内容表格
|
||||
- 点击标注点会弹出抽屉显示详细信息
|
||||
|
||||
### 4. 测试响应式
|
||||
- 调整浏览器窗口大小
|
||||
- 宽度 ≤ 768px 时切换为移动端布局
|
||||
- 宽度 > 768px 时切换为PC端布局
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q: 模型加载失败?
|
||||
A: 请确认:
|
||||
1. GLB文件已放入 `public/models/` 目录
|
||||
2. 文件名与 data.json 中的 modelName 完全一致
|
||||
3. 文件格式是 .glb(不是 .gltf)
|
||||
|
||||
### Q: 标注点位置不准确?
|
||||
A: 标注点位置采用圆形分布算法自动计算,可以在 `src/components/ThreeViewer.vue` 的 `createAnnotations` 方法中调整坐标。
|
||||
|
||||
### Q: 如何添加新模型?
|
||||
A:
|
||||
1. 将GLB文件放入 `public/models/`
|
||||
2. 在 `src/data/data.json` 中添加对应的数据配置
|
||||
3. 刷新页面即可看到新模型
|
||||
|
||||
## 生产部署
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
构建完成后,将 `dist` 目录部署到Web服务器即可。
|
||||
|
||||
## 技术支持
|
||||
|
||||
如有问题,请查看:
|
||||
- README.md - 完整文档
|
||||
- public/models/README.md - 模型文件说明
|
||||
133
README.md
Normal file
@@ -0,0 +1,133 @@
|
||||
# Vue2 模型检测系统
|
||||
|
||||
一个基于 Vue2 + Three.js 的3D模型检测系统,支持PC端和移动端自适应。
|
||||
|
||||
## 功能特性
|
||||
|
||||
- 📱 响应式设计,自动适配PC端和移动端
|
||||
- 🎨 Three.js 3D模型渲染
|
||||
- 🏷️ 模型标注打点系统
|
||||
- 📊 检测内容表格展示
|
||||
- 🎯 点击标注高亮显示
|
||||
- 📋 三类检查内容:总体外观、主要参数、关键元器件
|
||||
|
||||
## 技术栈
|
||||
|
||||
- Vue 2.6.14
|
||||
- Vue Router 3.5.3
|
||||
- Three.js 0.150.0
|
||||
- Element UI 2.15.13
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
vue-model-review/
|
||||
├── public/
|
||||
│ ├── index.html
|
||||
│ └── models/ # GLB模型文件目录
|
||||
│ ├── 交流避雷器,AC10kV,13kV, 硅橡胶,40kV, 带间隙。外间隙,带支撑件间隙,不兼做绝缘子.glb
|
||||
│ ├── 交流棒形悬式复合绝缘子,FXBW-10/70.glb
|
||||
│ └── 10kV 三相隔离开关.glb
|
||||
├── src/
|
||||
│ ├── components/
|
||||
│ │ ├── ThreeViewer.vue # 3D模型查看器
|
||||
│ │ └── CheckTable.vue # 检测表格组件
|
||||
│ ├── views/
|
||||
│ │ ├── ModelList.vue # 模型列表页
|
||||
│ │ └── ModelDetail.vue # 模型详情页
|
||||
│ ├── router/
|
||||
│ │ └── index.js # 路由配置
|
||||
│ ├── data/
|
||||
│ │ └── data.json # 模型数据
|
||||
│ ├── App.vue
|
||||
│ └── main.js
|
||||
├── package.json
|
||||
├── vue.config.js
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## 安装依赖
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
## 开发运行
|
||||
|
||||
```bash
|
||||
npm run serve
|
||||
```
|
||||
|
||||
访问 http://localhost:8080
|
||||
|
||||
## 生产构建
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
## 使用说明
|
||||
|
||||
### 1. 准备GLB模型文件
|
||||
|
||||
将GLB模型文件放置在 `public/models/` 目录下,文件名需与 `data.json` 中的 `modelName` 字段对应。
|
||||
|
||||
例如:`交流避雷器,AC10kV,13kV, 硅橡胶,40kV, 带间隙。外间隙,带支撑件间隙,不兼做绝缘子.glb`
|
||||
|
||||
### 2. 配置模型数据
|
||||
|
||||
编辑 `src/data/data.json`,添加或修改模型数据:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"modelName": "模型名称",
|
||||
"appearanceCheck": [...],
|
||||
"mainDataCheck": [...],
|
||||
"componentCheck": [...]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### 3. 页面布局
|
||||
|
||||
#### PC端布局
|
||||
- 顶部:模型名称
|
||||
- 左侧:3D模型展示
|
||||
- 右侧:检测内容表格
|
||||
|
||||
#### 移动端布局
|
||||
- 顶部:模型名称
|
||||
- 中间:3D模型展示
|
||||
- 底部:检测内容表格
|
||||
|
||||
### 4. 交互功能
|
||||
|
||||
- 点击3D模型上的标注点,会高亮对应部件
|
||||
- PC端:右侧表格自动展开对应检查项
|
||||
- 移动端:弹出抽屉显示检查要求
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. GLB模型文件需要放在 `public/models/` 目录下
|
||||
2. 模型文件名必须与 `data.json` 中的 `modelName` 完全一致
|
||||
3. 标注点位置采用圆形分布算法自动计算
|
||||
4. 移动端判断阈值为屏幕宽度 768px
|
||||
|
||||
## 浏览器支持
|
||||
|
||||
- Chrome (推荐)
|
||||
- Firefox
|
||||
- Safari
|
||||
- Edge
|
||||
|
||||
## 系统要求
|
||||
|
||||
- Node.js >= 14.x
|
||||
- npm >= 6.x
|
||||
|
||||
推荐使用 Node.js 16.x 或更高版本以获得最佳兼容性。
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
7864
package-lock.json
generated
Normal file
19
package.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "vue-model-review",
|
||||
"version": "1.0.0",
|
||||
"description": "Vue2 3D Model Review Application",
|
||||
"scripts": {
|
||||
"serve": "vue-cli-service serve",
|
||||
"build": "vue-cli-service build"
|
||||
},
|
||||
"dependencies": {
|
||||
"element-ui": "^2.15.13",
|
||||
"vue": "^2.6.14",
|
||||
"vue-router": "^3.5.3",
|
||||
"three": "^0.124.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vue/cli-service": "^5.0.8",
|
||||
"vue-template-compiler": "^2.6.14"
|
||||
}
|
||||
}
|
||||
12
public/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0,maximum-scale=1.0,user-scalable=no">
|
||||
<title>模型检测系统</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
</body>
|
||||
</html>
|
||||
43
public/models/README.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# 模型文件目录
|
||||
|
||||
请将GLB格式的3D模型文件放置在此目录下。
|
||||
|
||||
## 文件命名规则
|
||||
|
||||
模型文件名必须与 `src/data/data.json` 中的 `modelName` 字段完全一致,并添加 `.glb` 扩展名。
|
||||
|
||||
## 示例
|
||||
|
||||
如果 data.json 中有以下数据:
|
||||
|
||||
```json
|
||||
{
|
||||
"modelName": "交流避雷器,AC10kV,13kV, 硅橡胶,40kV, 带间隙。外间隙,带支撑件间隙,不兼做绝缘子"
|
||||
}
|
||||
```
|
||||
|
||||
则对应的模型文件应命名为:
|
||||
```
|
||||
交流避雷器,AC10kV,13kV, 硅橡胶,40kV, 带间隙。外间隙,带支撑件间隙,不兼做绝缘子.glb
|
||||
```
|
||||
|
||||
## 当前需要的模型文件
|
||||
|
||||
根据 data.json 配置,需要以下模型文件:
|
||||
|
||||
1. `交流避雷器,AC10kV,13kV, 硅橡胶,40kV, 带间隙。外间隙,带支撑件间隙,不兼做绝缘子.glb`
|
||||
2. `交流棒形悬式复合绝缘子,FXBW-10/70.glb`
|
||||
3. `10kV 三相隔离开关.glb`
|
||||
|
||||
## 获取GLB模型
|
||||
|
||||
- 可以使用Blender等3D建模软件导出GLB格式
|
||||
- 可以从3D模型库下载(如Sketchfab)
|
||||
- 可以使用在线转换工具将其他格式转换为GLB
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 文件格式必须是 `.glb`(不是 `.gltf`)
|
||||
- 建议模型大小控制在10MB以内以保证加载速度
|
||||
- 模型应包含适当的材质和纹理
|
||||
- 建议模型中心点位于原点(0,0,0)
|
||||
BIN
public/models/texture/Sk(LuoWen)-DianLan.jpg
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
public/models/texture/Sk(LuoWen)-GangJiaoXian.jpg
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
public/models/texture/Sk(LuoWen)-GangXinLvJiaoXian.jpg
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
public/models/texture/Sk(LuoWen)-LuoShuan.jpg
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
public/models/texture/Sk(LuoWen)-LvBaoGangXinLvJiaoXian.jpg
Normal file
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 1.3 MiB |
BIN
public/models/texture/Sk-DaoLu-4%ShuiNiWenDingSuiShi.jpg
Normal file
|
After Width: | Height: | Size: 741 KiB |
BIN
public/models/texture/Sk-DaoLu-5%ShuiNiWenDingSuiSh.jpg
Normal file
|
After Width: | Height: | Size: 741 KiB |
BIN
public/models/texture/Sk-DaoLu-CuLiShiLiQingHunNingTu.jpg
Normal file
|
After Width: | Height: | Size: 684 KiB |
BIN
public/models/texture/Sk-DaoLu-HuoShaoBan.jpg
Normal file
|
After Width: | Height: | Size: 849 KiB |
BIN
public/models/texture/Sk-DaoLu-JiPeiSuiSh.jpg
Normal file
|
After Width: | Height: | Size: 617 KiB |
BIN
public/models/texture/Sk-DaoLu-LuYuanShi.jpg
Normal file
|
After Width: | Height: | Size: 235 KiB |
BIN
public/models/texture/Sk-DaoLu-RenXingDaoCaiZhuan.jpg
Normal file
|
After Width: | Height: | Size: 143 KiB |
BIN
public/models/texture/Sk-DaoLu-ShuiNiHunNingTu.jpg
Normal file
|
After Width: | Height: | Size: 4.5 MiB |
BIN
public/models/texture/Sk-DaoLu-ShuiNiShaJiang.jpg
Normal file
|
After Width: | Height: | Size: 890 KiB |
BIN
public/models/texture/Sk-DaoLu-XiLiShiLiQingHunNingTu.jpg
Normal file
|
After Width: | Height: | Size: 684 KiB |
BIN
public/models/texture/Sk-DaoLu-ZhongLiShiLiQingHunNingTu.jpg
Normal file
|
After Width: | Height: | Size: 684 KiB |
|
After Width: | Height: | Size: 564 KiB |
BIN
public/models/texture/Sk-FangHuoDuNi.jpg
Normal file
|
After Width: | Height: | Size: 496 KiB |
BIN
public/models/texture/Sk-FangShui-ShuiNiShaJiang.jpg
Normal file
|
After Width: | Height: | Size: 890 KiB |
BIN
public/models/texture/Sk-HunNingTu.jpg
Normal file
|
After Width: | Height: | Size: 4.5 MiB |
BIN
public/models/texture/Sk-JinShuHei.jpg
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
public/models/texture/Sk-JinShuHuiBai.jpg
Normal file
|
After Width: | Height: | Size: 93 KiB |
BIN
public/models/texture/Sk-JinShuLan.jpg
Normal file
|
After Width: | Height: | Size: 118 KiB |
BIN
public/models/texture/Sk-JinShuLv.jpg
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
public/models/texture/Sk-JinShuTong.jpg
Normal file
|
After Width: | Height: | Size: 93 KiB |
BIN
public/models/texture/Sk-JinShuYin.jpg
Normal file
|
After Width: | Height: | Size: 281 KiB |
BIN
public/models/texture/Sk-JueYuanZiBai.jpg
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
public/models/texture/Sk-JueYuanZiHong.jpg
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
public/models/texture/Sk-JueYuanZiYin.jpg
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
public/models/texture/Sk-JueYuanZiZong.jpg
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
public/models/texture/Sk-QiZhuan.jpg
Normal file
|
After Width: | Height: | Size: 7.1 MiB |
BIN
public/models/texture/Sk-SuLiaoBai.jpg
Normal file
|
After Width: | Height: | Size: 93 KiB |
BIN
public/models/texture/Sk-SuLiaoCheng.jpg
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
public/models/texture/Sk-SuLiaoHei.jpg
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
public/models/texture/Sk-SuLiaoHuang.jpg
Normal file
|
After Width: | Height: | Size: 802 B |
BIN
public/models/texture/Sk-SuLiaoLan.jpg
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
public/models/texture/Sk-SuLiaoLv.jpg
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
public/models/texture/Sk-TuZhiCaoDi.jpg
Normal file
|
After Width: | Height: | Size: 615 KiB |
BIN
public/models/texture/Sk-TuZhiSha.jpg
Normal file
|
After Width: | Height: | Size: 433 KiB |
BIN
public/models/texture/Sk-TuZhiTu.jpg
Normal file
|
After Width: | Height: | Size: 686 KiB |
BIN
public/models/texture/Sk-XiangJiaoBai.jpg
Normal file
|
After Width: | Height: | Size: 93 KiB |
BIN
public/models/texture/Sk-XiangJiaoCheng.jpg
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
public/models/texture/Sk-XiangJiaoHei.jpg
Normal file
|
After Width: | Height: | Size: 294 KiB |
BIN
public/models/texture/Sk-XiangJiaoHong.jpg
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
public/models/texture/Sk-XiangJiaoHuang.jpg
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
public/models/texture/Sk-XiangJiaoHui.jpg
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
public/models/texture/Sk-XiangJiaoLan.jpg
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
public/models/texture/Sk-XiangJiaoLv.jpg
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
public/models/texture/Sk-YouQiBai.jpg
Normal file
|
After Width: | Height: | Size: 93 KiB |
BIN
public/models/texture/Sk-YouQiHei.jpg
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
public/models/texture/Sk-YouQiHong.jpg
Normal file
|
After Width: | Height: | Size: 5.9 KiB |
BIN
public/models/texture/Sk-YouQiHuang.jpg
Normal file
|
After Width: | Height: | Size: 802 B |
BIN
public/models/texture/Sk-YouQiLv.jpg
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
public/models/texture/Thumbs.db
Normal file
57
public/models/textureName.json
Normal file
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"SK(螺纹)-电缆": "Sk(LuoWen)-DianLan",
|
||||
"SK(螺纹)-螺栓": "Sk(LuoWen)-LuoShuan",
|
||||
"SK(螺纹)-钢绞线": "Sk(LuoWen)-GangJiaoXian",
|
||||
"SK(螺纹)-钢芯铝绞线": "Sk(LuoWen)-GangXinLvJiaoXian",
|
||||
"SK(螺纹)-铝包钢芯铝绞线": "Sk(LuoWen)-LvBaoGangXinLvJiaoXian",
|
||||
"SK-土质土": "Sk-TuZhiTu",
|
||||
"SK-土质砂": "Sk-TuZhiSha",
|
||||
"SK-土质草地": "Sk-TuZhiCaoDi",
|
||||
"SK-塑料橙": "Sk-SuLiaoCheng",
|
||||
"SK-塑料白": "Sk-SuLiaoBai",
|
||||
"SK-塑料绿": "Sk-SuLiaoLv",
|
||||
"SK-塑料蓝": "Sk-SuLiaoLan",
|
||||
"SK-塑料黄": "Sk-SuLiaoHuang",
|
||||
"SK-塑料黑": "Sk-SuLiaoHei",
|
||||
"SK-橡胶橙": "Sk-XiangJiaoCheng",
|
||||
"SK-橡胶灰": "Sk-XiangJiaoHui",
|
||||
"SK-橡胶白": "Sk-XiangJiaoBai",
|
||||
"SK-橡胶红": "Sk-XiangJiaoHong",
|
||||
"SK-橡胶绿": "Sk-XiangJiaoLv",
|
||||
"SK-橡胶蓝": "Sk-XiangJiaoLan",
|
||||
"SK-橡胶黄": "Sk-XiangJiaoHuang",
|
||||
"SK-橡胶黑": "Sk-XiangJiaoHei",
|
||||
"SK-油漆白": "Sk-YouQiBai",
|
||||
"SK-油漆红": "Sk-YouQiHong",
|
||||
"SK-油漆绿": "Sk-YouQiLv",
|
||||
"SK-油漆黄": "Sk-YouQiHuang",
|
||||
"SK-油漆黑": "Sk-YouQiHei",
|
||||
"SK-混凝土": "Sk-HunNingTu",
|
||||
"SK-砌砖": "Sk-QiZhuan",
|
||||
"SK-绝缘子棕": "Sk-JueYuanZiZong",
|
||||
"SK-绝缘子白": "Sk-JueYuanZiBai",
|
||||
"SK-绝缘子红": "Sk-JueYuanZiHong",
|
||||
"SK-绝缘子银": "Sk-JueYuanZiYin",
|
||||
"SK-道路-1.5LmPC-2改性乳化理清稀浆封层": "Sk-DaoLu-1.5lmpc-2GaiXingRuHuaLiQingXiJiangFengCeng",
|
||||
"SK-道路-4%水泥稳定碎石": "Sk-DaoLu-4%ShuiNiWenDingSuiShi",
|
||||
"SK-道路-5%水泥稳定碎石": "Sk-DaoLu-5%ShuiNiWenDingSuiShi",
|
||||
"SK-道路-SMA-13沥青玛蹄脂碎石混合料": "Sk-DaoLu-sma-13LiQingMaTiZhiSuiShiHunHeLiao",
|
||||
"SK-道路-中粒式沥青混凝土": "Sk-DaoLu-ZhongLiShiLiQingHunNingTu",
|
||||
"SK-道路-人行道彩砖": "Sk-DaoLu-RenXingDaoCaiZhuan",
|
||||
"SK-道路-水泥混凝土": "Sk-DaoLu-ShuiNiHunNingTu",
|
||||
"SK-道路-水泥砂浆": "Sk-DaoLu-ShuiNiShaJiang",
|
||||
"SK-道路-火烧板": "Sk-DaoLu-HuoShaoBan",
|
||||
"SK-道路-粗粒式沥青混凝土": "Sk-DaoLu-CuLiShiLiQingHunNingTu",
|
||||
"SK-道路-级配碎石": "Sk-DaoLu-JiPeiSuiShi",
|
||||
"SK-道路-细粒式沥青混凝土": "Sk-DaoLu-XiLiShiLiQingHunNingTu",
|
||||
"SK-道路-路缘石": "Sk-DaoLu-LuYuanShi",
|
||||
"SK-金属灰白": "Sk-JinShuHuiBai",
|
||||
"SK-金属绿": "Sk-JinShuLv",
|
||||
"SK-金属蓝": "Sk-JinShuLan",
|
||||
"SK-金属铜": "Sk-JinShuTong",
|
||||
"SK-金属银": "Sk-JinShuYin",
|
||||
"SK-金属黑": "Sk-JinShuHei",
|
||||
"SK-防水-水泥砂浆 ": "Sk-FangShui-ShuiNiShaJiang ",
|
||||
"SK-防火堵泥": "Sk-FangHuoDuNi",
|
||||
"Thumbs.db": "Thumbs.db"
|
||||
}
|
||||
30
src/App.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<template>
|
||||
<div id="app">
|
||||
<router-view />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'App'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body, #app {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB',
|
||||
'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
}
|
||||
</style>
|
||||
BIN
src/assets/images/tree/back.png
Normal file
|
After Width: | Height: | Size: 228 B |
BIN
src/assets/images/tree/icon-three1.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
src/assets/images/tree/icon-three2-1.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
src/assets/images/tree/icon-three2.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
src/assets/images/tree/icon-three3.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
src/assets/images/tree/icon-three4.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
src/assets/images/tree/icon-three5.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
src/assets/images/tree/icon-three6.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
src/assets/images/tree/icon-three7.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
src/assets/images/tree/icon-three8.png
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
BIN
src/assets/images/tree/rotateLeft.png
Normal file
|
After Width: | Height: | Size: 662 B |
BIN
src/assets/images/tree/rotateUp.png
Normal file
|
After Width: | Height: | Size: 704 B |
232
src/components/CheckTable.vue
Normal file
@@ -0,0 +1,232 @@
|
||||
<template>
|
||||
<div class="check-table">
|
||||
<el-tabs v-model="activeTab" type="border-card">
|
||||
<!-- 总体外观检查 -->
|
||||
<el-tab-pane label="总体外观检查" name="appearance">
|
||||
<div class="table-content">
|
||||
<div v-for="(item, index) in appearanceCheck" :key="index" class="check-item">
|
||||
<h4>{{ item.name }}</h4>
|
||||
<ul>
|
||||
<li v-for="(content, idx) in item.content" :key="idx">{{ content }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 主要参数检查 -->
|
||||
<el-tab-pane label="主要参数检查" name="mainData">
|
||||
<el-table :data="mainDataCheck" border stripe>
|
||||
<el-table-column prop="name" label="检查项目" min-width="150"></el-table-column>
|
||||
<el-table-column label="参数要求" min-width="200">
|
||||
<template slot-scope="scope">
|
||||
<div v-for="(content, idx) in scope.row.content" :key="idx">
|
||||
{{ content }}
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 关键元器件检查 -->
|
||||
<el-tab-pane label="关键元器件检查" name="component">
|
||||
<div class="component-list">
|
||||
<el-collapse v-model="activeComponents" accordion>
|
||||
<el-collapse-item
|
||||
v-for="(item, index) in componentCheck"
|
||||
:key="index"
|
||||
:name="index"
|
||||
:class="{ 'highlighted': highlightedIndex === index }"
|
||||
>
|
||||
<template slot="title">
|
||||
<span class="component-title">
|
||||
<i class="el-icon-document"></i>
|
||||
{{ index + 1 }}. {{ item.name }}
|
||||
</span>
|
||||
</template>
|
||||
<div class="component-content">
|
||||
<div class="requirement-title">检查要求:</div>
|
||||
<ol>
|
||||
<li v-for="(content, idx) in item.content" :key="idx">
|
||||
{{ content }}
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'CheckTable',
|
||||
props: {
|
||||
appearanceCheck: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
mainDataCheck: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
componentCheck: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
highlightedIndex: {
|
||||
type: Number,
|
||||
default: -1
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
activeTab: 'appearance',
|
||||
activeComponents: null
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
highlightedIndex(newVal) {
|
||||
if (newVal >= 0) {
|
||||
this.activeTab = 'component'
|
||||
this.activeComponents = newVal
|
||||
this.$nextTick(() => {
|
||||
this.scrollToHighlighted()
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
scrollToHighlighted() {
|
||||
const highlightedEl = this.$el.querySelector('.highlighted')
|
||||
if (highlightedEl) {
|
||||
highlightedEl.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.check-table {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.el-tabs {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.el-tabs >>> .el-tabs__content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.table-content {
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.check-item {
|
||||
margin-bottom: 20px;
|
||||
padding: 15px;
|
||||
background: #f9f9f9;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.check-item h4 {
|
||||
margin: 0 0 10px 0;
|
||||
color: #303133;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.check-item ul {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.check-item li {
|
||||
margin: 5px 0;
|
||||
color: #606266;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.component-list {
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.component-title {
|
||||
font-weight: 500;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.component-title i {
|
||||
margin-right: 8px;
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
.component-content {
|
||||
padding: 15px;
|
||||
background: #f9f9f9;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.requirement-title {
|
||||
font-weight: 500;
|
||||
color: #409eff;
|
||||
margin-bottom: 10px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.component-content ol {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.component-content li {
|
||||
margin: 8px 0;
|
||||
color: #606266;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.highlighted {
|
||||
animation: highlight-pulse 1s ease-in-out;
|
||||
}
|
||||
|
||||
.highlighted >>> .el-collapse-item__header {
|
||||
background: #ecf5ff;
|
||||
color: #409eff;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@keyframes highlight-pulse {
|
||||
0%, 100% {
|
||||
background: transparent;
|
||||
}
|
||||
50% {
|
||||
background: #ecf5ff;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.el-tabs >>> .el-tabs__content {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.check-item {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.component-content {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.component-content li {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
544
src/components/ThreeViewer copy.vue
Normal file
@@ -0,0 +1,544 @@
|
||||
<template>
|
||||
<div class="three-viewer">
|
||||
<div ref="container" class="canvas-container"></div>
|
||||
<div class="annotations-container" ref="annotationsContainer"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import * as THREE from 'three';
|
||||
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'; // 导入 GLTFLoader 用于加载 GLTF 模型
|
||||
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; // 导入 OrbitControls 用于控制相机
|
||||
// 高亮
|
||||
import { EffectComposer } from "three/examples/jsm/postprocessing/EffectComposer.js";
|
||||
import { RenderPass } from "three/examples/jsm/postprocessing/RenderPass.js";
|
||||
import { OutlinePass } from "three/examples/jsm/postprocessing/OutlinePass.js";
|
||||
import { UnrealBloomPass } from "three/examples/jsm/postprocessing/UnrealBloomPass.js";
|
||||
import TextureName from '../../public/models/textureName.json'
|
||||
import { RoomEnvironment } from 'three/examples/jsm/environments/RoomEnvironment.js'
|
||||
import { CSS2DRenderer, CSS2DObject } from 'three/examples/jsm/renderers/CSS2DRenderer'
|
||||
|
||||
export default {
|
||||
name: 'ThreeModel',
|
||||
props: {
|
||||
modelPath: {
|
||||
type: [String, Number],
|
||||
default: '',
|
||||
},
|
||||
annotations: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
textureObj() {
|
||||
return this.$store && this.$store.state.textureObj || {}
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
modelPath: {
|
||||
handler(v) {
|
||||
if(v) {
|
||||
if (this.scene) {
|
||||
this.scene.clear();
|
||||
this.scene = null;
|
||||
}
|
||||
// 2. 销毁渲染器
|
||||
if (this.renderer) {
|
||||
this.renderer.dispose();
|
||||
this.renderer.forceContextLoss();
|
||||
this.renderer = null;
|
||||
}
|
||||
// 3. 销毁相机
|
||||
this.camera = null;
|
||||
// 4. 清空画布DOM容器的内容
|
||||
if(this.sceneDomElement){
|
||||
this.sceneDomElement.innerHTML = '';
|
||||
}
|
||||
setTimeout(() => {
|
||||
this.init()
|
||||
}, 300)
|
||||
}
|
||||
},
|
||||
immediate: true
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
scene: null, // Three.js场景
|
||||
camera: null, // 相机
|
||||
renderer: null, // 渲染器
|
||||
controls: null, // 轨道控制器
|
||||
model: null, // 加载的3D模型
|
||||
frameId: null, // 动画帧ID
|
||||
sceneDomElement: null, // 容器DOM元素
|
||||
parent: null, // 容器
|
||||
maxDim: null,
|
||||
highlightColor: null, //高亮颜色
|
||||
composer: null,
|
||||
pmremGenerator: null,
|
||||
labelRenderer: null,
|
||||
annotationObjects: [],
|
||||
selectedPart: null
|
||||
};
|
||||
},
|
||||
mounted () {
|
||||
// this.onWindowResize(); // 初始化后立即调用一次
|
||||
// 添加窗口大小变化监听
|
||||
// window.addEventListener('resize', this.onWindowResize);
|
||||
this.resizeObserver = new ResizeObserver(this.onWindowResize);
|
||||
this.resizeObserver.observe(this.$refs.container);
|
||||
|
||||
window.addEventListener('click', this.onMouseClick, false);
|
||||
this.highlightColor = new THREE.Color(0xff0000); // 高亮颜色为红色
|
||||
},
|
||||
beforeDestroy() {
|
||||
// 移除事件监听
|
||||
// window.removeEventListener('resize', this.onWindowResize);
|
||||
window.removeEventListener('resize', this.onWindowResize);
|
||||
|
||||
// 断开ResizeObserver
|
||||
if (this.resizeObserver) {
|
||||
this.resizeObserver.disconnect();
|
||||
}
|
||||
if (this.labelRenderer) {
|
||||
this.labelRenderer.domElement.remove()
|
||||
}
|
||||
},
|
||||
destroyed() {
|
||||
cancelAnimationFrame(this.frameId);
|
||||
if (this.sceneDomElement && this.renderer) {
|
||||
this.sceneDomElement.removeChild(this.renderer.domElement);
|
||||
}
|
||||
if (this.renderer) {
|
||||
this.renderer.dispose();
|
||||
}
|
||||
if (this.scene) {
|
||||
this.scene.clear();
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
init() {
|
||||
if (this.sceneDomElement && this.renderer) {
|
||||
this.sceneDomElement.removeChild(this.renderer.domElement);
|
||||
}
|
||||
// 获取容器元素
|
||||
this.sceneDomElement = this.$refs.container;
|
||||
// 1. 创建一个新的场景
|
||||
this.scene = new THREE.Scene();
|
||||
|
||||
// 2. 创建相机
|
||||
const width = this.sceneDomElement.clientWidth;
|
||||
const height = this.sceneDomElement.clientHeight;
|
||||
this.camera = new THREE.PerspectiveCamera(55, width / height, 0.1, 1000);
|
||||
|
||||
// 3. 创建渲染器
|
||||
this.renderer = new THREE.WebGLRenderer({
|
||||
antialias: true,
|
||||
alpha: true,
|
||||
});
|
||||
|
||||
// CSS2D渲染器(用于标注)
|
||||
this.labelRenderer = new CSS2DRenderer()
|
||||
this.labelRenderer.setSize(width, height)
|
||||
this.labelRenderer.domElement.style.position = 'absolute'
|
||||
this.labelRenderer.domElement.style.top = '0'
|
||||
this.labelRenderer.domElement.style.pointerEvents = 'none'
|
||||
this.$refs.annotationsContainer.appendChild(this.labelRenderer.domElement)
|
||||
|
||||
// 设置画布分辨率 提高画质
|
||||
this.renderer.setPixelRatio(window.devicePixelRatio);
|
||||
// 渲染器开启阴影效果
|
||||
this.renderer.shadowMap.enabled = true
|
||||
|
||||
this.renderer.setSize(width, height);
|
||||
// this.renderer.setClearColor(0xcccccc);
|
||||
this.renderer.outputEncoding = THREE.sRGBEncoding; // 设置颜色编码
|
||||
this.sceneDomElement.appendChild(this.renderer.domElement);
|
||||
|
||||
//4. 添加光源
|
||||
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6); // 创建环境光
|
||||
this.scene.add(ambientLight); // 将环境光添加到场景中
|
||||
|
||||
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.6); // 平行光
|
||||
directionalLight.position.set(1, 1, 1);
|
||||
this.scene.add(directionalLight);
|
||||
directionalLight.castShadow = true;
|
||||
|
||||
// 环境光
|
||||
this.pmremGenerator = new THREE.PMREMGenerator(this.renderer)
|
||||
this.pmremGenerator.compileEquirectangularShader()
|
||||
const env = this.pmremGenerator.fromScene(new RoomEnvironment(this.renderer), 0.04).texture
|
||||
this.scene.environment = env
|
||||
|
||||
// 3. 添加地面(接收阴影)
|
||||
const groundGeometry = new THREE.PlaneGeometry(8, 8);
|
||||
const groundMaterial = new THREE.MeshStandardMaterial({ color: 0xcccccc, side: THREE.DoubleSide });
|
||||
const ground = new THREE.Mesh(groundGeometry, groundMaterial);
|
||||
ground.rotation.x = -Math.PI / 2;
|
||||
ground.position.y = -1;
|
||||
ground.receiveShadow = true; // ⭐ 关键:地面接收阴影
|
||||
this.scene.add(ground);
|
||||
|
||||
// 5. 添加轨道控制器
|
||||
this.controls = new OrbitControls(this.camera, this.renderer.domElement);
|
||||
this.controls.enableDamping = true; // 启用阻尼效果
|
||||
this.controls.dampingFactor = 0.25; // 阻尼系数
|
||||
|
||||
// 6. 加载3D模型
|
||||
this.loadModel();
|
||||
|
||||
// 7. 开始动画循环
|
||||
this.animate();
|
||||
|
||||
},
|
||||
|
||||
loadModel() {
|
||||
const loader = new GLTFLoader();
|
||||
const textureLoader = new THREE.TextureLoader();
|
||||
console.log(this.modelPath);
|
||||
loader.load(
|
||||
this.modelPath,
|
||||
// 'https://skeps-bucket.oss-cn-shenzhen.aliyuncs.com/2026%2FFile%2F4d3a9525-c415-4ab2-b916-d87b4b86ebcbSK010020001001BLQ_%E4%BA%A4%E6%B5%81%E9%81%BF%E9%9B%B7%E5%99%A8%2C%E5%B8%A6%E6%94%AF%E6%92%91%E4%BB%B6%E5%9B%BA%E5%AE%9A%E9%97%B4%E9%9A%99.glb',
|
||||
(gltf) => {
|
||||
// 移除旧模型
|
||||
if (this.model) {
|
||||
this.scene.remove(this.model);
|
||||
}
|
||||
this.model = gltf.scene;
|
||||
|
||||
this.scene.add(this.model);
|
||||
|
||||
// 遍历模型所有子元素
|
||||
this.model.traverse((child) => {
|
||||
if (child.isMesh) {
|
||||
console.log(7777, child)
|
||||
child.material.emissiveMap = child.material.map;
|
||||
child.castShadow = true; // 让模型产生阴影
|
||||
child.receiveShadow = true; // 让模型接受阴影
|
||||
|
||||
// child.material.emissive = child.material.color;
|
||||
let name = child.material.name.split('.')[0]
|
||||
let path = TextureName[name]
|
||||
|
||||
if(path) {
|
||||
if(this.textureObj[name]) {
|
||||
child.material = this.textureObj[name]
|
||||
child.material.needsUpdate = true;
|
||||
} else {
|
||||
let texture = textureLoader.load(`/models/texture/${encodeURIComponent(path)}.jpg`)
|
||||
texture.wrapS = THREE.RepeatWrapping;
|
||||
texture.wrapT = THREE.RepeatWrapping;
|
||||
texture.repeat.set( 1, 1 );
|
||||
let material = null
|
||||
if(name.includes('金属')) {
|
||||
material = new THREE.MeshStandardMaterial({
|
||||
map: texture,
|
||||
// color: 0x049EF4,
|
||||
// emissive: 0x000000,
|
||||
metalness: 0.55, // 金属感
|
||||
roughness: 0.05 // 粗糙度,0为镜面反射
|
||||
})
|
||||
} else {
|
||||
material = new THREE.MeshPhongMaterial( {
|
||||
// ...child.material,
|
||||
map: texture,
|
||||
emissiveMap: child.material.map,
|
||||
shininess: 0,
|
||||
} );
|
||||
}
|
||||
child.material = material
|
||||
child.material.needsUpdate = true;
|
||||
this.textureObj[name] = material
|
||||
// this.$store.commit('set_textureObj', this.textureObj)
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 计算模型包围盒并居中
|
||||
const box = new THREE.Box3().setFromObject(this.model);
|
||||
const center = box.getCenter(new THREE.Vector3());
|
||||
const size = box.getSize(new THREE.Vector3());
|
||||
|
||||
// 根据模型大小调整相机
|
||||
const maxDim = Math.max(size.x, size.y, size.z);
|
||||
this.maxDim = maxDim
|
||||
this.camera.position.z = this.maxDim * 1;
|
||||
this.camera.near = maxDim / 100;
|
||||
this.camera.far = maxDim * 100;
|
||||
this.camera.updateProjectionMatrix();
|
||||
|
||||
// 创建一个父对象用于旋转
|
||||
this.parent = new THREE.Object3D(); // 创建一个新的 Object3D 作为父对象
|
||||
this.parent.add(this.model); // 将模型添加到父对象
|
||||
|
||||
this.scene.add(this.parent); // 将父对象添加到场景
|
||||
|
||||
// 让控制器聚焦到模型中心 聚焦中心
|
||||
this.controls.target.copy(center);
|
||||
|
||||
this.controls.enableRotate = true;
|
||||
this.controls.minPolarAngle = Math.PI / 2; // 限制向下旋转的角度
|
||||
this.controls.maxPolarAngle = Math.PI / 2; // 限制向上旋转的角度
|
||||
|
||||
this.controls.update();
|
||||
this.createAnnotations()
|
||||
this.$emit('model-loaded')
|
||||
},
|
||||
(progress) => {
|
||||
const percent = (progress.loaded / progress.total) * 100
|
||||
this.$emit('loading-progress', percent)
|
||||
},
|
||||
(error) => {
|
||||
console.error('模型加载失败:', error);
|
||||
this.$emit('model-error', error)
|
||||
}
|
||||
);
|
||||
},
|
||||
|
||||
animate() {
|
||||
this.frameId = requestAnimationFrame(this.animate);
|
||||
if(this.parent && this.isPlay) {
|
||||
this.parent.rotation.y += 0.001; // 沿 Y 轴旋转父对象
|
||||
}
|
||||
// 更新控制器
|
||||
if (this.controls) {
|
||||
this.controls.update();
|
||||
}
|
||||
|
||||
// 渲染场景
|
||||
if (this.renderer && this.scene && this.camera) {
|
||||
this.renderer.render(this.scene, this.camera);
|
||||
}
|
||||
|
||||
if (this.composer) {
|
||||
this.composer.render(this.scene, this.camera);
|
||||
}
|
||||
|
||||
if(this.labelRenderer && this.scene && this.camera) {
|
||||
this.labelRenderer.render(this.scene, this.camera)
|
||||
}
|
||||
},
|
||||
|
||||
// 响应式调整大小
|
||||
onWindowResize() {
|
||||
if (this.camera && this.renderer) {
|
||||
const width = this.sceneDomElement.clientWidth;
|
||||
const height = this.sceneDomElement.clientHeight;
|
||||
this.camera.aspect = width / height;
|
||||
this.camera.updateProjectionMatrix();
|
||||
this.renderer.setSize(width, height);
|
||||
}
|
||||
},
|
||||
// 点击射线
|
||||
onMouseClick(event) {
|
||||
console.log('我点击了')
|
||||
// const raycaster = new THREE.Raycaster();
|
||||
// const mouse = new THREE.Vector2();
|
||||
// // 计算鼠标在屏幕上的位置并转换为Normalized device coordinates (NDC)
|
||||
// console.log(this.sceneDomElement.clientWidth, window.innerWidth)
|
||||
// mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
|
||||
// mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
|
||||
|
||||
// // 更新Raycaster的射线位置和方向
|
||||
// raycaster.setFromCamera(mouse, this.camera);
|
||||
if(!this.sceneDomElement) return
|
||||
let getBoundingClientRect = this.sceneDomElement.getBoundingClientRect()
|
||||
// 屏幕坐标转标准设备坐标
|
||||
let x = ((event.clientX - getBoundingClientRect .left) / this.sceneDomElement.offsetWidth) * 2 - 1;// 标准设备横坐标
|
||||
let y = -((event.clientY - getBoundingClientRect .top) / this.sceneDomElement.offsetHeight) * 2 + 1;// 标准设备纵坐标
|
||||
let standardVector = new THREE.Vector3(x, y, 1);// 标准设备坐标
|
||||
// 标准设备坐标转世界坐标
|
||||
let worldVector = standardVector.unproject(this.camera);
|
||||
// 射线投射方向单位向量(worldVector坐标减相机位置坐标)
|
||||
let ray = worldVector.sub(this.camera.position).normalize();
|
||||
// 创建射线投射器对象
|
||||
let rayCaster = new THREE.Raycaster(this.camera.position, ray);
|
||||
|
||||
// 计算物体和射线的交点
|
||||
const intersects = rayCaster.intersectObjects(this.model.children, true);
|
||||
if (intersects.length !== 0 && intersects[0].object instanceof THREE.Mesh) {
|
||||
// this.controls.target.copy(intersects[0].point);
|
||||
let selectedObject = intersects[0].object;
|
||||
let selectedObjects = [];
|
||||
selectedObjects.push(selectedObject.parent);
|
||||
this.outlineObj(selectedObjects);
|
||||
} else {
|
||||
console.log('没找到', this.composer)
|
||||
this.composer = null
|
||||
}
|
||||
},
|
||||
outlineObj(selectedObjects) {
|
||||
let outlinePass;
|
||||
let renderPass;
|
||||
let unrealBloomPass;
|
||||
|
||||
// 创建一个EffectComposer(效果组合器)对象,然后在该对象上添加后期处理通道。
|
||||
// 用于模型边缘高亮
|
||||
this.composer = new EffectComposer(this.renderer);
|
||||
this.composer.renderTarget1.texture.outputColorSpace = THREE.sRGBEncoding;
|
||||
this.composer.renderTarget2.texture.outputColorSpace = THREE.sRGBEncoding;
|
||||
this.composer.renderTarget1.texture.encoding = THREE.sRGBEncoding;
|
||||
this.composer.renderTarget2.texture.encoding = THREE.sRGBEncoding;
|
||||
|
||||
// 新建一个场景通道 为了覆盖到原来的场景上
|
||||
renderPass = new RenderPass(this.scene, this.camera);
|
||||
this.composer.addPass(renderPass);
|
||||
// 物体边缘发光通道
|
||||
outlinePass = new OutlinePass(
|
||||
new THREE.Vector2(this.sceneDomElement.clientWidth, this.sceneDomElement.clientHeight),
|
||||
this.scene,
|
||||
this.camera,
|
||||
selectedObjects
|
||||
);
|
||||
outlinePass.selectedObjects = selectedObjects;
|
||||
outlinePass.edgeStrength = 10.0; // 边框的亮度
|
||||
outlinePass.edgeGlow = 0.5; // 光晕[0,1]
|
||||
outlinePass.usePatternTexture = false; // 是否使用父级的材质
|
||||
outlinePass.edgeThickness = 1.0; // 边框宽度
|
||||
outlinePass.downSampleRatio = 1; // 边框弯曲度
|
||||
outlinePass.pulsePeriod = 5; // 呼吸闪烁的速度
|
||||
outlinePass.visibleEdgeColor.set(parseInt(0x00ff00)); // 呼吸显示的颜色
|
||||
outlinePass.hiddenEdgeColor = new THREE.Color(0, 0, 0); // 呼吸消失的颜色
|
||||
outlinePass.clear = true;
|
||||
this.composer.addPass(outlinePass);
|
||||
// 自定义的着色器通道 作为参数
|
||||
// effectFXAA = new ShaderPass(FXAAShader);
|
||||
// effectFXAA.uniforms.resolution.value.set(
|
||||
// 1 / window.innerWidth,
|
||||
// 1 / window.innerHeight
|
||||
// );
|
||||
// effectFXAA.renderToScreen = true;
|
||||
// composer.addPass(effectFXAA);
|
||||
// // 抗锯齿
|
||||
// smaaPass = new SMAAPass();
|
||||
// composer.addPass(smaaPass);
|
||||
// // 发光效果
|
||||
unrealBloomPass = new UnrealBloomPass();
|
||||
unrealBloomPass.strength = 0.1;
|
||||
unrealBloomPass.radius = 0;
|
||||
unrealBloomPass.threshold = 1;
|
||||
this.composer.addPass(unrealBloomPass);
|
||||
|
||||
// this.scene.background = new THREE.Color(0x1b1824);
|
||||
},
|
||||
// 创建标签
|
||||
createAnnotations() {
|
||||
// 清除旧标注
|
||||
this.annotationObjects.forEach(obj => {
|
||||
this.scene.remove(obj)
|
||||
})
|
||||
this.annotationObjects = []
|
||||
|
||||
// 创建标注点的预设位置(圆形分布)
|
||||
const radius = 2
|
||||
const angleStep = (Math.PI * 2) / this.annotations.length
|
||||
|
||||
this.annotations.forEach((annotation, index) => {
|
||||
const angle = angleStep * index
|
||||
const x = Math.cos(angle) * radius
|
||||
const z = Math.sin(angle) * radius
|
||||
const y = 1 + Math.sin(index) * 0.5
|
||||
console.log(666, annotation.name, x, y, z)
|
||||
// 创建标注标签
|
||||
const labelDiv = document.createElement('div')
|
||||
labelDiv.className = 'annotation-label'
|
||||
labelDiv.textContent = `${index + 1} ${annotation.name}`
|
||||
labelDiv.style.cssText = `
|
||||
background: rgba(64, 158, 255, 0.9);
|
||||
color: white;
|
||||
padding: 5px 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
pointer-events: auto;
|
||||
`
|
||||
labelDiv.addEventListener('click', () => {
|
||||
this.onAnnotationClick(annotation, index)
|
||||
})
|
||||
|
||||
const label = new CSS2DObject(labelDiv)
|
||||
label.position.set(x/10, y/10, z/10)
|
||||
label.userData = { annotation, index }
|
||||
|
||||
this.scene.add(label)
|
||||
this.annotationObjects.push(label)
|
||||
|
||||
// 创建连接线
|
||||
const points = [
|
||||
new THREE.Vector3(0, 0, 0),
|
||||
new THREE.Vector3(x/10, y/10, z/10)
|
||||
// new THREE.Vector3(0.1, 0.1, 0.1)
|
||||
]
|
||||
const geometry = new THREE.BufferGeometry().setFromPoints(points)
|
||||
const material = new THREE.LineBasicMaterial({
|
||||
color: 0x409eff,
|
||||
transparent: true,
|
||||
opacity: 0.5
|
||||
})
|
||||
const line = new THREE.Line(geometry, material)
|
||||
this.scene.add(line)
|
||||
this.annotationObjects.push(line)
|
||||
})
|
||||
},
|
||||
onAnnotationClick(annotation, index) {
|
||||
this.highlightPart(index)
|
||||
this.$emit('annotation-click', annotation, index)
|
||||
},
|
||||
|
||||
highlightPart(index) {
|
||||
// 重置之前的高亮
|
||||
if (this.selectedPart !== null) {
|
||||
this.annotationObjects.forEach((obj, i) => {
|
||||
if (obj.isLine) {
|
||||
obj.material.opacity = 0.5
|
||||
} else if (obj.element) {
|
||||
obj.element.style.background = 'rgba(64, 158, 255, 0.9)'
|
||||
obj.element.style.transform = 'scale(1)'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 高亮选中的部件
|
||||
this.selectedPart = index
|
||||
this.annotationObjects.forEach((obj, i) => {
|
||||
const objIndex = Math.floor(i / 2)
|
||||
if (objIndex === index) {
|
||||
if (obj.isLine) {
|
||||
obj.material.opacity = 1
|
||||
obj.material.color.setHex(0xff6b00)
|
||||
} else if (obj.element) {
|
||||
obj.element.style.background = 'rgba(255, 107, 0, 0.95)'
|
||||
obj.element.style.transform = 'scale(1.1)'
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.three-viewer {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.canvas-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.annotations-container {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
</style>
|
||||
577
src/components/ThreeViewer.vue
Normal file
@@ -0,0 +1,577 @@
|
||||
<template>
|
||||
<div class="three-viewer">
|
||||
<div ref="container" class="canvas-container"></div>
|
||||
<div class="annotations-container" ref="annotationsContainer"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import * as THREE from 'three';
|
||||
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'; // 导入 GLTFLoader 用于加载 GLTF 模型
|
||||
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; // 导入 OrbitControls 用于控制相机
|
||||
// 高亮
|
||||
import { EffectComposer } from "three/examples/jsm/postprocessing/EffectComposer.js";
|
||||
import { RenderPass } from "three/examples/jsm/postprocessing/RenderPass.js";
|
||||
import { OutlinePass } from "three/examples/jsm/postprocessing/OutlinePass.js";
|
||||
import { UnrealBloomPass } from "three/examples/jsm/postprocessing/UnrealBloomPass.js";
|
||||
import TextureName from '../../public/models/textureName.json'
|
||||
import { RoomEnvironment } from 'three/examples/jsm/environments/RoomEnvironment.js'
|
||||
import { CSS2DRenderer, CSS2DObject } from 'three/examples/jsm/renderers/CSS2DRenderer'
|
||||
|
||||
export default {
|
||||
name: 'ThreeModel',
|
||||
props: {
|
||||
modelPath: {
|
||||
type: [String, Number],
|
||||
default: '',
|
||||
},
|
||||
annotations: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
textureObj() {
|
||||
return this.$store && this.$store.state.textureObj || {}
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
modelPath: {
|
||||
handler(v) {
|
||||
if(v) {
|
||||
if (this.scene) {
|
||||
this.scene.clear();
|
||||
this.scene = null;
|
||||
}
|
||||
// 2. 销毁渲染器
|
||||
if (this.renderer) {
|
||||
this.renderer.dispose();
|
||||
this.renderer.forceContextLoss();
|
||||
this.renderer = null;
|
||||
}
|
||||
// 3. 销毁相机
|
||||
this.camera = null;
|
||||
// 4. 清空画布DOM容器的内容
|
||||
if(this.sceneDomElement){
|
||||
this.sceneDomElement.innerHTML = '';
|
||||
}
|
||||
setTimeout(() => {
|
||||
this.init()
|
||||
}, 300)
|
||||
}
|
||||
},
|
||||
immediate: true
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
scene: null, // Three.js场景
|
||||
camera: null, // 相机
|
||||
renderer: null, // 渲染器
|
||||
controls: null, // 轨道控制器
|
||||
model: null, // 加载的3D模型
|
||||
frameId: null, // 动画帧ID
|
||||
sceneDomElement: null, // 容器DOM元素
|
||||
parent: null, // 容器
|
||||
maxDim: null,
|
||||
highlightColor: null, //高亮颜色
|
||||
composer: null,
|
||||
pmremGenerator: null,
|
||||
labelRenderer: null,
|
||||
annotationObjects: [],
|
||||
selectedPart: null
|
||||
};
|
||||
},
|
||||
mounted () {
|
||||
// this.onWindowResize(); // 初始化后立即调用一次
|
||||
// 添加窗口大小变化监听
|
||||
// window.addEventListener('resize', this.onWindowResize);
|
||||
this.resizeObserver = new ResizeObserver(this.onWindowResize);
|
||||
this.resizeObserver.observe(this.$refs.container);
|
||||
|
||||
// window.addEventListener('click', this.onMouseClick, false);
|
||||
this.highlightColor = new THREE.Color(0xff0000); // 高亮颜色为红色
|
||||
},
|
||||
beforeDestroy() {
|
||||
// 移除事件监听
|
||||
// window.removeEventListener('resize', this.onWindowResize);
|
||||
window.removeEventListener('resize', this.onWindowResize);
|
||||
|
||||
// 断开ResizeObserver
|
||||
if (this.resizeObserver) {
|
||||
this.resizeObserver.disconnect();
|
||||
}
|
||||
if (this.labelRenderer) {
|
||||
this.labelRenderer.domElement.remove()
|
||||
}
|
||||
},
|
||||
destroyed() {
|
||||
cancelAnimationFrame(this.frameId);
|
||||
if (this.sceneDomElement && this.renderer) {
|
||||
this.sceneDomElement.removeChild(this.renderer.domElement);
|
||||
}
|
||||
if (this.renderer) {
|
||||
this.renderer.dispose();
|
||||
}
|
||||
if (this.scene) {
|
||||
this.scene.clear();
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
init() {
|
||||
if (this.sceneDomElement && this.renderer) {
|
||||
this.sceneDomElement.removeChild(this.renderer.domElement);
|
||||
}
|
||||
// 获取容器元素
|
||||
this.sceneDomElement = this.$refs.container;
|
||||
// 1. 创建一个新的场景
|
||||
this.scene = new THREE.Scene();
|
||||
|
||||
// 2. 创建相机
|
||||
const width = this.sceneDomElement.clientWidth;
|
||||
const height = this.sceneDomElement.clientHeight;
|
||||
this.camera = new THREE.PerspectiveCamera(55, width / height, 0.1, 1000);
|
||||
|
||||
// 3. 创建渲染器
|
||||
this.renderer = new THREE.WebGLRenderer({
|
||||
antialias: true,
|
||||
alpha: true,
|
||||
});
|
||||
this.renderer.shadowMap.enabled = true
|
||||
|
||||
// CSS2D渲染器(用于标注)
|
||||
this.labelRenderer = new CSS2DRenderer()
|
||||
this.labelRenderer.setSize(width, height)
|
||||
this.labelRenderer.domElement.style.position = 'absolute'
|
||||
this.labelRenderer.domElement.style.top = '0'
|
||||
this.labelRenderer.domElement.style.pointerEvents = 'none'
|
||||
this.$refs.annotationsContainer.appendChild(this.labelRenderer.domElement)
|
||||
|
||||
// 设置画布分辨率 提高画质
|
||||
this.renderer.setPixelRatio(window.devicePixelRatio);
|
||||
// 渲染器开启阴影效果
|
||||
this.renderer.shadowMap.enabled = true
|
||||
|
||||
this.renderer.setSize(width, height);
|
||||
// this.renderer.setClearColor(0xcccccc);
|
||||
this.renderer.outputEncoding = THREE.sRGBEncoding; // 设置颜色编码
|
||||
this.sceneDomElement.appendChild(this.renderer.domElement);
|
||||
|
||||
//4. 添加光源
|
||||
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6); // 创建环境光
|
||||
this.scene.add(ambientLight); // 将环境光添加到场景中
|
||||
|
||||
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8); // 平行光
|
||||
directionalLight.position.set(5, 10, 7);
|
||||
directionalLight.castShadow = true // 让平行光产生阴影
|
||||
|
||||
// 配置阴影属性 - 提高质量减少锯齿
|
||||
directionalLight.shadow.mapSize.width = 4096
|
||||
directionalLight.shadow.mapSize.height = 4096
|
||||
directionalLight.shadow.camera.near = 0.5
|
||||
directionalLight.shadow.camera.far = 50
|
||||
directionalLight.shadow.camera.left = -10
|
||||
directionalLight.shadow.camera.right = 10
|
||||
directionalLight.shadow.camera.top = 10
|
||||
directionalLight.shadow.camera.bottom = -10
|
||||
directionalLight.shadow.bias = -0.0001 // 减少阴影失真
|
||||
directionalLight.shadow.normalBias = 0.02 // 减少阴影痤疮
|
||||
|
||||
this.scene.add(directionalLight);
|
||||
|
||||
// 环境光
|
||||
this.pmremGenerator = new THREE.PMREMGenerator(this.renderer)
|
||||
this.pmremGenerator.compileEquirectangularShader()
|
||||
const env = this.pmremGenerator.fromScene(new RoomEnvironment(this.renderer), 0.04).texture
|
||||
this.scene.environment = env
|
||||
|
||||
// 添加地面
|
||||
this.createGround()
|
||||
|
||||
// 5. 添加轨道控制器
|
||||
this.controls = new OrbitControls(this.camera, this.renderer.domElement);
|
||||
this.controls.enableDamping = true; // 启用阻尼效果
|
||||
this.controls.dampingFactor = 0.25; // 阻尼系数
|
||||
|
||||
// 6. 加载3D模型
|
||||
this.loadModel();
|
||||
|
||||
// 7. 开始动画循环
|
||||
this.animate();
|
||||
|
||||
},
|
||||
|
||||
loadModel() {
|
||||
const loader = new GLTFLoader();
|
||||
const textureLoader = new THREE.TextureLoader();
|
||||
loader.load(
|
||||
this.modelPath,
|
||||
// 'https://skeps-bucket.oss-cn-shenzhen.aliyuncs.com/2026%2FFile%2F4d3a9525-c415-4ab2-b916-d87b4b86ebcbSK010020001001BLQ_%E4%BA%A4%E6%B5%81%E9%81%BF%E9%9B%B7%E5%99%A8%2C%E5%B8%A6%E6%94%AF%E6%92%91%E4%BB%B6%E5%9B%BA%E5%AE%9A%E9%97%B4%E9%9A%99.glb',
|
||||
(gltf) => {
|
||||
// 移除旧模型
|
||||
if (this.model) {
|
||||
this.scene.remove(this.model);
|
||||
}
|
||||
this.model = gltf.scene;
|
||||
|
||||
this.scene.add(this.model);
|
||||
|
||||
// 遍历模型所有子元素
|
||||
this.model.traverse((child) => {
|
||||
if (child.isMesh) {
|
||||
// console.log(7777)
|
||||
child.material.emissiveMap = child.material.map;
|
||||
child.castShadow = true; // 让模型产生阴影
|
||||
child.receiveShadow = true; // 让模型接受阴影
|
||||
|
||||
// child.material.emissive = child.material.color;
|
||||
let name = child.material.name.split('.')[0]
|
||||
let path = TextureName[name]
|
||||
|
||||
if(path) {
|
||||
if(this.textureObj[name]) {
|
||||
child.material = this.textureObj[name]
|
||||
child.material.needsUpdate = true;
|
||||
} else {
|
||||
let texture = textureLoader.load(`/models/texture/${encodeURIComponent(path)}.jpg`)
|
||||
texture.wrapS = THREE.RepeatWrapping;
|
||||
texture.wrapT = THREE.RepeatWrapping;
|
||||
texture.repeat.set( 1, 1 );
|
||||
let material = null
|
||||
if(name.includes('金属')) {
|
||||
material = new THREE.MeshStandardMaterial({
|
||||
map: texture,
|
||||
// color: 0x049EF4,
|
||||
// emissive: 0x000000,
|
||||
metalness: 0.55, // 金属感
|
||||
roughness: 0.05 // 粗糙度,0为镜面反射
|
||||
})
|
||||
} else {
|
||||
material = new THREE.MeshPhongMaterial( {
|
||||
// ...child.material,
|
||||
map: texture,
|
||||
emissiveMap: child.material.map,
|
||||
shininess: 0,
|
||||
} );
|
||||
}
|
||||
child.material = material
|
||||
child.material.needsUpdate = true;
|
||||
this.textureObj[name] = material
|
||||
// this.$store.commit('set_textureObj', this.textureObj)
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 计算模型包围盒并居中
|
||||
const box = new THREE.Box3().setFromObject(this.model);
|
||||
const center = box.getCenter(new THREE.Vector3());
|
||||
const size = box.getSize(new THREE.Vector3());
|
||||
|
||||
// 根据模型大小调整相机
|
||||
const maxDim = Math.max(size.x, size.y, size.z);
|
||||
this.maxDim = maxDim
|
||||
this.camera.position.z = this.maxDim * 1;
|
||||
this.camera.near = maxDim / 100;
|
||||
this.camera.far = maxDim * 100;
|
||||
this.camera.updateProjectionMatrix();
|
||||
|
||||
// 创建一个父对象用于旋转
|
||||
this.parent = new THREE.Object3D(); // 创建一个新的 Object3D 作为父对象
|
||||
this.parent.add(this.model); // 将模型添加到父对象
|
||||
|
||||
this.scene.add(this.parent); // 将父对象添加到场景
|
||||
|
||||
// 让控制器聚焦到模型中心 聚焦中心
|
||||
this.controls.target.copy(center);
|
||||
|
||||
this.controls.enableRotate = true;
|
||||
// this.controls.minPolarAngle = Math.PI / 2; // 限制向下旋转的角度
|
||||
// this.controls.maxPolarAngle = Math.PI / 2; // 限制向上旋转的角度
|
||||
|
||||
this.controls.update();
|
||||
|
||||
// 调整地面位置到模型底部
|
||||
if (this.ground) {
|
||||
this.ground.position.y = box.min.y - 0.01
|
||||
}
|
||||
|
||||
this.createAnnotations()
|
||||
this.$emit('model-loaded')
|
||||
},
|
||||
(progress) => {
|
||||
const percent = (progress.loaded / progress.total) * 100
|
||||
this.$emit('loading-progress', percent)
|
||||
},
|
||||
(error) => {
|
||||
console.error('模型加载失败:', error);
|
||||
this.$emit('model-error', error)
|
||||
}
|
||||
);
|
||||
},
|
||||
|
||||
animate() {
|
||||
this.frameId = requestAnimationFrame(this.animate);
|
||||
if(this.parent && this.isPlay) {
|
||||
this.parent.rotation.y += 0.001; // 沿 Y 轴旋转父对象
|
||||
}
|
||||
// 更新控制器
|
||||
if (this.controls) {
|
||||
this.controls.update();
|
||||
}
|
||||
|
||||
// 渲染场景
|
||||
if (this.renderer && this.scene && this.camera) {
|
||||
this.renderer.render(this.scene, this.camera);
|
||||
}
|
||||
|
||||
if (this.composer) {
|
||||
this.composer.render(this.scene, this.camera);
|
||||
}
|
||||
|
||||
if(this.labelRenderer && this.scene && this.camera) {
|
||||
this.labelRenderer.render(this.scene, this.camera)
|
||||
}
|
||||
},
|
||||
|
||||
// 响应式调整大小
|
||||
onWindowResize() {
|
||||
if (this.camera && this.renderer) {
|
||||
const width = this.sceneDomElement.clientWidth;
|
||||
const height = this.sceneDomElement.clientHeight;
|
||||
this.camera.aspect = width / height;
|
||||
this.camera.updateProjectionMatrix();
|
||||
this.renderer.setSize(width, height);
|
||||
}
|
||||
},
|
||||
// 点击射线
|
||||
onMouseClick(event) {
|
||||
console.log('我点击了')
|
||||
// const raycaster = new THREE.Raycaster();
|
||||
// const mouse = new THREE.Vector2();
|
||||
// // 计算鼠标在屏幕上的位置并转换为Normalized device coordinates (NDC)
|
||||
// console.log(this.sceneDomElement.clientWidth, window.innerWidth)
|
||||
// mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
|
||||
// mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
|
||||
|
||||
// // 更新Raycaster的射线位置和方向
|
||||
// raycaster.setFromCamera(mouse, this.camera);
|
||||
if(!this.sceneDomElement) return
|
||||
let getBoundingClientRect = this.sceneDomElement.getBoundingClientRect()
|
||||
// 屏幕坐标转标准设备坐标
|
||||
let x = ((event.clientX - getBoundingClientRect .left) / this.sceneDomElement.offsetWidth) * 2 - 1;// 标准设备横坐标
|
||||
let y = -((event.clientY - getBoundingClientRect .top) / this.sceneDomElement.offsetHeight) * 2 + 1;// 标准设备纵坐标
|
||||
let standardVector = new THREE.Vector3(x, y, 1);// 标准设备坐标
|
||||
// 标准设备坐标转世界坐标
|
||||
let worldVector = standardVector.unproject(this.camera);
|
||||
// 射线投射方向单位向量(worldVector坐标减相机位置坐标)
|
||||
let ray = worldVector.sub(this.camera.position).normalize();
|
||||
// 创建射线投射器对象
|
||||
let rayCaster = new THREE.Raycaster(this.camera.position, ray);
|
||||
|
||||
// 计算物体和射线的交点
|
||||
const intersects = rayCaster.intersectObjects(this.model.children, true);
|
||||
if (intersects.length !== 0 && intersects[0].object instanceof THREE.Mesh) {
|
||||
// this.controls.target.copy(intersects[0].point);
|
||||
let selectedObject = intersects[0].object;
|
||||
let selectedObjects = [];
|
||||
selectedObjects.push(selectedObject.parent);
|
||||
this.outlineObj(selectedObjects);
|
||||
} else {
|
||||
console.log('没找到', this.composer)
|
||||
this.composer = null
|
||||
}
|
||||
},
|
||||
outlineObj(selectedObjects) {
|
||||
let outlinePass;
|
||||
let renderPass;
|
||||
let unrealBloomPass;
|
||||
|
||||
// 创建一个EffectComposer(效果组合器)对象,然后在该对象上添加后期处理通道。
|
||||
// 用于模型边缘高亮
|
||||
this.composer = new EffectComposer(this.renderer);
|
||||
this.composer.renderTarget1.texture.outputColorSpace = THREE.sRGBEncoding;
|
||||
this.composer.renderTarget2.texture.outputColorSpace = THREE.sRGBEncoding;
|
||||
this.composer.renderTarget1.texture.encoding = THREE.sRGBEncoding;
|
||||
this.composer.renderTarget2.texture.encoding = THREE.sRGBEncoding;
|
||||
|
||||
// 新建一个场景通道 为了覆盖到原来的场景上
|
||||
renderPass = new RenderPass(this.scene, this.camera);
|
||||
this.composer.addPass(renderPass);
|
||||
// 物体边缘发光通道
|
||||
outlinePass = new OutlinePass(
|
||||
new THREE.Vector2(this.sceneDomElement.clientWidth, this.sceneDomElement.clientHeight),
|
||||
this.scene,
|
||||
this.camera,
|
||||
selectedObjects
|
||||
);
|
||||
outlinePass.selectedObjects = selectedObjects;
|
||||
outlinePass.edgeStrength = 10.0; // 边框的亮度
|
||||
outlinePass.edgeGlow = 0.5; // 光晕[0,1]
|
||||
outlinePass.usePatternTexture = false; // 是否使用父级的材质
|
||||
outlinePass.edgeThickness = 1.0; // 边框宽度
|
||||
outlinePass.downSampleRatio = 1; // 边框弯曲度
|
||||
outlinePass.pulsePeriod = 5; // 呼吸闪烁的速度
|
||||
outlinePass.visibleEdgeColor.set(parseInt(0x00ff00)); // 呼吸显示的颜色
|
||||
outlinePass.hiddenEdgeColor = new THREE.Color(0, 0, 0); // 呼吸消失的颜色
|
||||
outlinePass.clear = true;
|
||||
this.composer.addPass(outlinePass);
|
||||
// 自定义的着色器通道 作为参数
|
||||
// effectFXAA = new ShaderPass(FXAAShader);
|
||||
// effectFXAA.uniforms.resolution.value.set(
|
||||
// 1 / window.innerWidth,
|
||||
// 1 / window.innerHeight
|
||||
// );
|
||||
// effectFXAA.renderToScreen = true;
|
||||
// composer.addPass(effectFXAA);
|
||||
// // 抗锯齿
|
||||
// smaaPass = new SMAAPass();
|
||||
// composer.addPass(smaaPass);
|
||||
// // 发光效果
|
||||
unrealBloomPass = new UnrealBloomPass();
|
||||
unrealBloomPass.strength = 0.1;
|
||||
unrealBloomPass.radius = 0;
|
||||
unrealBloomPass.threshold = 1;
|
||||
this.composer.addPass(unrealBloomPass);
|
||||
|
||||
// this.scene.background = new THREE.Color(0x1b1824);
|
||||
},
|
||||
// 创建标签
|
||||
createAnnotations() {
|
||||
// 清除旧标注
|
||||
this.annotationObjects.forEach(obj => {
|
||||
this.scene.remove(obj)
|
||||
})
|
||||
this.annotationObjects = []
|
||||
|
||||
// 创建标注点的预设位置(圆形分布)
|
||||
const radius = 2
|
||||
const angleStep = (Math.PI * 2) / this.annotations.length
|
||||
|
||||
this.annotations.forEach((annotation, index) => {
|
||||
const angle = angleStep * index
|
||||
const x = Math.cos(angle) * radius
|
||||
const z = Math.sin(angle) * radius
|
||||
const y = 1 + Math.sin(index) * 0.5
|
||||
console.log(666, annotation.name, x, y, z)
|
||||
// 创建标注标签
|
||||
const labelDiv = document.createElement('div')
|
||||
labelDiv.className = 'annotation-label'
|
||||
labelDiv.textContent = `${index + 1} ${annotation.name}`
|
||||
labelDiv.style.cssText = `
|
||||
background: rgba(64, 158, 255, 0.9);
|
||||
color: white;
|
||||
padding: 5px 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
pointer-events: auto;
|
||||
`
|
||||
labelDiv.addEventListener('click', () => {
|
||||
this.onAnnotationClick(annotation, index)
|
||||
})
|
||||
|
||||
const label = new CSS2DObject(labelDiv)
|
||||
label.position.set(0, 0, 0)
|
||||
label.userData = { annotation, index }
|
||||
|
||||
this.scene.add(label)
|
||||
this.annotationObjects.push(label)
|
||||
|
||||
// 创建连接线
|
||||
const points = [
|
||||
new THREE.Vector3(0, 0, 0),
|
||||
new THREE.Vector3(x/10, y/10, z/10)
|
||||
// new THREE.Vector3(0.1, 0.1, 0.1)
|
||||
]
|
||||
const geometry = new THREE.BufferGeometry().setFromPoints(points)
|
||||
const material = new THREE.LineBasicMaterial({
|
||||
color: 0x409eff,
|
||||
transparent: true,
|
||||
opacity: 0.5
|
||||
})
|
||||
const line = new THREE.Line(geometry, material)
|
||||
this.scene.add(line)
|
||||
this.annotationObjects.push(line)
|
||||
})
|
||||
},
|
||||
onAnnotationClick(annotation, index) {
|
||||
this.highlightPart(index)
|
||||
this.$emit('annotation-click', annotation, index)
|
||||
},
|
||||
|
||||
// 创建地面
|
||||
createGround() {
|
||||
// 创建圆形地面几何体,避免菱形格子
|
||||
const groundGeometry = new THREE.CircleGeometry(25, 64)
|
||||
|
||||
// 创建地面材质 - 使用 ShadowMaterial 只显示阴影
|
||||
const groundMaterial = new THREE.ShadowMaterial({
|
||||
opacity: 0.25,
|
||||
color: 0x000000
|
||||
})
|
||||
|
||||
// 创建地面网格
|
||||
this.ground = new THREE.Mesh(groundGeometry, groundMaterial)
|
||||
this.ground.rotation.x = -Math.PI / 2 // 旋转地面使其水平
|
||||
this.ground.position.y = 0
|
||||
this.ground.receiveShadow = true // 地面接收阴影
|
||||
|
||||
this.scene.add(this.ground)
|
||||
},
|
||||
|
||||
highlightPart(index) {
|
||||
// 重置之前的高亮
|
||||
if (this.selectedPart !== null) {
|
||||
this.annotationObjects.forEach((obj, i) => {
|
||||
if (obj.isLine) {
|
||||
obj.material.opacity = 0.5
|
||||
} else if (obj.element) {
|
||||
obj.element.style.background = 'rgba(64, 158, 255, 0.9)'
|
||||
obj.element.style.transform = 'scale(1)'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 高亮选中的部件
|
||||
this.selectedPart = index
|
||||
this.annotationObjects.forEach((obj, i) => {
|
||||
const objIndex = Math.floor(i / 2)
|
||||
if (objIndex === index) {
|
||||
if (obj.isLine) {
|
||||
obj.material.opacity = 1
|
||||
obj.material.color.setHex(0xff6b00)
|
||||
} else if (obj.element) {
|
||||
obj.element.style.background = 'rgba(255, 107, 0, 0.95)'
|
||||
obj.element.style.transform = 'scale(1.1)'
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.three-viewer {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.canvas-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.annotations-container {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
</style>
|
||||
277
src/components/ThreeViewer2.vue
Normal file
@@ -0,0 +1,277 @@
|
||||
<template>
|
||||
<div class="three-viewer">
|
||||
<div ref="container" class="canvas-container"></div>
|
||||
<div class="annotations-container" ref="annotationsContainer"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import * as THREE from 'three'
|
||||
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
|
||||
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader'
|
||||
import { CSS2DRenderer, CSS2DObject } from 'three/examples/jsm/renderers/CSS2DRenderer'
|
||||
|
||||
export default {
|
||||
name: 'ThreeViewer',
|
||||
props: {
|
||||
modelPath: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
annotations: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
scene: null,
|
||||
camera: null,
|
||||
renderer: null,
|
||||
labelRenderer: null,
|
||||
controls: null,
|
||||
model: null,
|
||||
annotationObjects: [],
|
||||
selectedPart: null
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.initThree()
|
||||
this.loadModel()
|
||||
this.animate()
|
||||
window.addEventListener('resize', this.onWindowResize)
|
||||
},
|
||||
beforeDestroy() {
|
||||
window.removeEventListener('resize', this.onWindowResize)
|
||||
if (this.renderer) {
|
||||
this.renderer.dispose()
|
||||
}
|
||||
if (this.labelRenderer) {
|
||||
this.labelRenderer.domElement.remove()
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
initThree() {
|
||||
// 场景
|
||||
this.scene = new THREE.Scene()
|
||||
this.scene.background = new THREE.Color(0xf0f0f0)
|
||||
|
||||
// 相机
|
||||
const container = this.$refs.container
|
||||
const width = container.clientWidth
|
||||
const height = container.clientHeight
|
||||
this.camera = new THREE.PerspectiveCamera(45, 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.setPixelRatio(window.devicePixelRatio)
|
||||
this.renderer.shadowMap.enabled = true
|
||||
container.appendChild(this.renderer.domElement)
|
||||
|
||||
// CSS2D渲染器(用于标注)
|
||||
this.labelRenderer = new CSS2DRenderer()
|
||||
this.labelRenderer.setSize(width, height)
|
||||
this.labelRenderer.domElement.style.position = 'absolute'
|
||||
this.labelRenderer.domElement.style.top = '0'
|
||||
this.labelRenderer.domElement.style.pointerEvents = 'none'
|
||||
this.$refs.annotationsContainer.appendChild(this.labelRenderer.domElement)
|
||||
|
||||
// 控制器
|
||||
this.controls = new OrbitControls(this.camera, this.renderer.domElement)
|
||||
this.controls.enableDamping = true
|
||||
this.controls.dampingFactor = 0.05
|
||||
|
||||
// 光照
|
||||
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6)
|
||||
this.scene.add(ambientLight)
|
||||
|
||||
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8)
|
||||
directionalLight.position.set(5, 10, 5)
|
||||
directionalLight.castShadow = true
|
||||
this.scene.add(directionalLight)
|
||||
|
||||
// 网格地面
|
||||
// const gridHelper = new THREE.GridHelper(10, 10)
|
||||
// this.scene.add(gridHelper)
|
||||
},
|
||||
|
||||
loadModel() {
|
||||
const loader = new GLTFLoader()
|
||||
console.log(this.modelPath)
|
||||
loader.load(
|
||||
this.modelPath,
|
||||
(gltf) => {
|
||||
this.model = gltf.scene
|
||||
|
||||
// 计算模型边界并居中
|
||||
const box = new THREE.Box3().setFromObject(this.model)
|
||||
const center = box.getCenter(new THREE.Vector3())
|
||||
const size = box.getSize(new THREE.Vector3())
|
||||
|
||||
this.model.position.sub(center)
|
||||
|
||||
// 调整相机位置
|
||||
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 *= 2.5
|
||||
|
||||
this.camera.position.set(cameraZ, cameraZ, cameraZ)
|
||||
this.camera.lookAt(0, 0, 0)
|
||||
this.controls.target.set(0, 0, 0)
|
||||
|
||||
this.scene.add(this.model)
|
||||
this.createAnnotations()
|
||||
|
||||
this.$emit('model-loaded')
|
||||
},
|
||||
(progress) => {
|
||||
const percent = (progress.loaded / progress.total) * 100
|
||||
this.$emit('loading-progress', percent)
|
||||
},
|
||||
(error) => {
|
||||
console.error('模型加载失败:', error)
|
||||
this.$emit('model-error', error)
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
createAnnotations() {
|
||||
// 清除旧标注
|
||||
this.annotationObjects.forEach(obj => {
|
||||
this.scene.remove(obj)
|
||||
})
|
||||
this.annotationObjects = []
|
||||
|
||||
// 创建标注点的预设位置(圆形分布)
|
||||
const radius = 2
|
||||
const angleStep = (Math.PI * 2) / this.annotations.length
|
||||
|
||||
this.annotations.forEach((annotation, index) => {
|
||||
const angle = angleStep * index
|
||||
const x = Math.cos(angle) * radius
|
||||
const z = Math.sin(angle) * radius
|
||||
const y = 1 + Math.sin(index) * 0.5
|
||||
|
||||
// 创建标注标签
|
||||
const labelDiv = document.createElement('div')
|
||||
labelDiv.className = 'annotation-label'
|
||||
labelDiv.textContent = `${index + 1} ${annotation.name}`
|
||||
labelDiv.style.cssText = `
|
||||
background: rgba(64, 158, 255, 0.9);
|
||||
color: white;
|
||||
padding: 5px 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
pointer-events: auto;
|
||||
`
|
||||
|
||||
labelDiv.addEventListener('click', () => {
|
||||
this.onAnnotationClick(annotation, index)
|
||||
})
|
||||
|
||||
const label = new CSS2DObject(labelDiv)
|
||||
label.position.set(x, y, z)
|
||||
label.userData = { annotation, index }
|
||||
|
||||
this.scene.add(label)
|
||||
this.annotationObjects.push(label)
|
||||
|
||||
// 创建连接线
|
||||
const points = [
|
||||
new THREE.Vector3(0, 0, 0),
|
||||
new THREE.Vector3(x, y, z)
|
||||
]
|
||||
const geometry = new THREE.BufferGeometry().setFromPoints(points)
|
||||
const material = new THREE.LineBasicMaterial({
|
||||
color: 0x409eff,
|
||||
transparent: true,
|
||||
opacity: 0.5
|
||||
})
|
||||
const line = new THREE.Line(geometry, material)
|
||||
this.scene.add(line)
|
||||
this.annotationObjects.push(line)
|
||||
})
|
||||
},
|
||||
|
||||
onAnnotationClick(annotation, index) {
|
||||
this.highlightPart(index)
|
||||
this.$emit('annotation-click', annotation, index)
|
||||
},
|
||||
|
||||
highlightPart(index) {
|
||||
// 重置之前的高亮
|
||||
if (this.selectedPart !== null) {
|
||||
this.annotationObjects.forEach((obj, i) => {
|
||||
if (obj.isLine) {
|
||||
obj.material.opacity = 0.5
|
||||
} else if (obj.element) {
|
||||
obj.element.style.background = 'rgba(64, 158, 255, 0.9)'
|
||||
obj.element.style.transform = 'scale(1)'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 高亮选中的部件
|
||||
this.selectedPart = index
|
||||
this.annotationObjects.forEach((obj, i) => {
|
||||
const objIndex = Math.floor(i / 2)
|
||||
if (objIndex === index) {
|
||||
if (obj.isLine) {
|
||||
obj.material.opacity = 1
|
||||
obj.material.color.setHex(0xff6b00)
|
||||
} else if (obj.element) {
|
||||
obj.element.style.background = 'rgba(255, 107, 0, 0.95)'
|
||||
obj.element.style.transform = 'scale(1.1)'
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
animate() {
|
||||
requestAnimationFrame(this.animate)
|
||||
this.controls.update()
|
||||
this.renderer.render(this.scene, this.camera)
|
||||
this.labelRenderer.render(this.scene, this.camera)
|
||||
},
|
||||
|
||||
onWindowResize() {
|
||||
const container = this.$refs.container
|
||||
const width = container.clientWidth
|
||||
const height = container.clientHeight
|
||||
|
||||
this.camera.aspect = width / height
|
||||
this.camera.updateProjectionMatrix()
|
||||
|
||||
this.renderer.setSize(width, height)
|
||||
this.labelRenderer.setSize(width, height)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.three-viewer {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.canvas-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.annotations-container {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
597
src/components/ThreeViewerDebug.vue
Normal file
@@ -0,0 +1,597 @@
|
||||
<template>
|
||||
<div class="three-viewer">
|
||||
<div ref="container" class="canvas-container"></div>
|
||||
<div class="annotations-container" ref="annotationsContainer"></div>
|
||||
|
||||
<!-- 调试面板 -->
|
||||
<div v-if="debugMode" class="debug-panel">
|
||||
<h3>调试模式</h3>
|
||||
<div class="debug-info">
|
||||
<p>点击模型查看部件信息</p>
|
||||
<div v-if="selectedMeshInfo">
|
||||
<p><strong>部件名称:</strong> {{ selectedMeshInfo.name || '未命名' }}</p>
|
||||
<p><strong>坐标:</strong></p>
|
||||
<pre>{{ JSON.stringify(selectedMeshInfo.position, null, 2) }}</pre>
|
||||
<button @click="copyPosition">复制坐标</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mesh-list">
|
||||
<h4>模型部件列表:</h4>
|
||||
<ul>
|
||||
<li v-for="(mesh, index) in meshList" :key="index">
|
||||
{{ mesh.name || `未命名_${index}` }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import * as THREE from 'three';
|
||||
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
|
||||
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
|
||||
import { CSS2DRenderer, CSS2DObject } from 'three/examples/jsm/renderers/CSS2DRenderer';
|
||||
import { EffectComposer } from "three/examples/jsm/postprocessing/EffectComposer.js";
|
||||
import { RenderPass } from "three/examples/jsm/postprocessing/RenderPass.js";
|
||||
import { OutlinePass } from "three/examples/jsm/postprocessing/OutlinePass.js";
|
||||
import { RoomEnvironment } from 'three/examples/jsm/environments/RoomEnvironment.js';
|
||||
|
||||
export default {
|
||||
name: 'ThreeViewerDebug',
|
||||
props: {
|
||||
modelPath: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
annotations: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
debugMode: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
scene: null,
|
||||
camera: null,
|
||||
renderer: null,
|
||||
labelRenderer: null,
|
||||
controls: null,
|
||||
model: null,
|
||||
composer: null,
|
||||
outlinePass: null,
|
||||
frameId: null,
|
||||
sceneDomElement: null,
|
||||
annotationObjects: [],
|
||||
selectedPart: null,
|
||||
partMeshMap: {},
|
||||
meshList: [],
|
||||
selectedMeshInfo: null,
|
||||
raycaster: new THREE.Raycaster(),
|
||||
mouse: new THREE.Vector2()
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
modelPath: {
|
||||
handler(v) {
|
||||
if (v) {
|
||||
this.cleanup();
|
||||
setTimeout(() => {
|
||||
this.init();
|
||||
}, 300);
|
||||
}
|
||||
},
|
||||
immediate: true
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.resizeObserver = new ResizeObserver(this.onWindowResize);
|
||||
this.resizeObserver.observe(this.$refs.container);
|
||||
|
||||
// 添加点击事件
|
||||
window.addEventListener('click', this.onMouseClick, false);
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.cleanup();
|
||||
window.removeEventListener('click', this.onMouseClick, false);
|
||||
if (this.resizeObserver) {
|
||||
this.resizeObserver.disconnect();
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
cleanup() {
|
||||
if (this.frameId) {
|
||||
cancelAnimationFrame(this.frameId);
|
||||
}
|
||||
if (this.scene) {
|
||||
this.scene.clear();
|
||||
this.scene = null;
|
||||
}
|
||||
if (this.renderer) {
|
||||
this.renderer.dispose();
|
||||
this.renderer = null;
|
||||
}
|
||||
if (this.labelRenderer) {
|
||||
this.labelRenderer.domElement.remove();
|
||||
this.labelRenderer = null;
|
||||
}
|
||||
if (this.sceneDomElement) {
|
||||
this.sceneDomElement.innerHTML = '';
|
||||
}
|
||||
},
|
||||
|
||||
init() {
|
||||
this.sceneDomElement = this.$refs.container;
|
||||
|
||||
// 创建场景
|
||||
this.scene = new THREE.Scene();
|
||||
this.scene.background = new THREE.Color(0xf0f0f0);
|
||||
|
||||
// 创建相机
|
||||
const width = this.sceneDomElement.clientWidth;
|
||||
const height = this.sceneDomElement.clientHeight;
|
||||
this.camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 1000);
|
||||
this.camera.position.set(5, 5, 5);
|
||||
|
||||
// WebGL渲染器
|
||||
this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
|
||||
this.renderer.setPixelRatio(window.devicePixelRatio);
|
||||
this.renderer.setSize(width, height);
|
||||
this.renderer.shadowMap.enabled = true;
|
||||
this.renderer.outputEncoding = THREE.sRGBEncoding;
|
||||
this.sceneDomElement.appendChild(this.renderer.domElement);
|
||||
|
||||
// CSS2D渲染器
|
||||
this.labelRenderer = new CSS2DRenderer();
|
||||
this.labelRenderer.setSize(width, height);
|
||||
this.labelRenderer.domElement.style.position = 'absolute';
|
||||
this.labelRenderer.domElement.style.top = '0';
|
||||
this.labelRenderer.domElement.style.pointerEvents = 'none';
|
||||
this.$refs.annotationsContainer.appendChild(this.labelRenderer.domElement);
|
||||
|
||||
// 光照
|
||||
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
|
||||
this.scene.add(ambientLight);
|
||||
|
||||
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8);
|
||||
directionalLight.position.set(5, 10, 5);
|
||||
directionalLight.castShadow = true;
|
||||
this.scene.add(directionalLight);
|
||||
|
||||
// 环境光
|
||||
const pmremGenerator = new THREE.PMREMGenerator(this.renderer);
|
||||
const env = pmremGenerator.fromScene(new RoomEnvironment(this.renderer), 0.04).texture;
|
||||
this.scene.environment = env;
|
||||
|
||||
// 控制器
|
||||
this.controls = new OrbitControls(this.camera, this.renderer.domElement);
|
||||
this.controls.enableDamping = true;
|
||||
this.controls.dampingFactor = 0.05;
|
||||
|
||||
// 加载模型
|
||||
this.loadModel();
|
||||
|
||||
// 开始动画
|
||||
this.animate();
|
||||
},
|
||||
|
||||
loadModel() {
|
||||
const loader = new GLTFLoader();
|
||||
|
||||
loader.load(
|
||||
this.modelPath,
|
||||
(gltf) => {
|
||||
this.model = gltf.scene;
|
||||
|
||||
// 收集所有mesh信息
|
||||
this.partMeshMap = {};
|
||||
this.meshList = [];
|
||||
|
||||
this.model.traverse((child) => {
|
||||
if (child.isMesh) {
|
||||
child.castShadow = true;
|
||||
child.receiveShadow = true;
|
||||
|
||||
// 存储mesh引用
|
||||
const meshInfo = {
|
||||
name: child.name,
|
||||
mesh: child,
|
||||
parent: child.parent
|
||||
};
|
||||
|
||||
this.meshList.push(meshInfo);
|
||||
|
||||
if (child.name) {
|
||||
this.partMeshMap[child.name] = child;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
console.log('模型部件列表:', this.meshList.map(m => m.name || '未命名'));
|
||||
console.log('部件映射表:', Object.keys(this.partMeshMap));
|
||||
|
||||
// 计算模型边界并居中
|
||||
const box = new THREE.Box3().setFromObject(this.model);
|
||||
const center = box.getCenter(new THREE.Vector3());
|
||||
const size = box.getSize(new THREE.Vector3());
|
||||
|
||||
this.model.position.sub(center);
|
||||
|
||||
// 调整相机
|
||||
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 *= 2.5;
|
||||
|
||||
this.camera.position.set(cameraZ, cameraZ, cameraZ);
|
||||
this.camera.lookAt(0, 0, 0);
|
||||
this.controls.target.set(0, 0, 0);
|
||||
|
||||
this.scene.add(this.model);
|
||||
|
||||
// 创建标注
|
||||
this.createAnnotations();
|
||||
|
||||
this.$emit('model-loaded', {
|
||||
meshList: this.meshList,
|
||||
partMeshMap: Object.keys(this.partMeshMap)
|
||||
});
|
||||
},
|
||||
(progress) => {
|
||||
const percent = (progress.loaded / progress.total) * 100;
|
||||
this.$emit('loading-progress', percent);
|
||||
},
|
||||
(error) => {
|
||||
console.error('模型加载失败:', error);
|
||||
this.$emit('model-error', error);
|
||||
}
|
||||
);
|
||||
},
|
||||
|
||||
createAnnotations() {
|
||||
// 清除旧标注
|
||||
this.annotationObjects.forEach(obj => {
|
||||
this.scene.remove(obj);
|
||||
});
|
||||
this.annotationObjects = [];
|
||||
|
||||
this.annotations.forEach((annotation, index) => {
|
||||
// 尝试通过名称匹配找到对应的mesh
|
||||
const mesh = this.findMeshByName(annotation.name);
|
||||
|
||||
let position = new THREE.Vector3();
|
||||
|
||||
if (mesh) {
|
||||
// 如果找到对应mesh,使用其位置
|
||||
mesh.getWorldPosition(position);
|
||||
|
||||
// 获取mesh的包围盒,标注放在上方
|
||||
const box = new THREE.Box3().setFromObject(mesh);
|
||||
const size = box.getSize(new THREE.Vector3());
|
||||
position.y += size.y / 2 + 0.3;
|
||||
|
||||
console.log(`找到匹配: ${annotation.name} -> ${mesh.name}`, position);
|
||||
} else {
|
||||
// 如果没找到,使用圆形分布
|
||||
const radius = 2;
|
||||
const angleStep = (Math.PI * 2) / this.annotations.length;
|
||||
const angle = angleStep * index;
|
||||
position.set(
|
||||
Math.cos(angle) * radius,
|
||||
1 + Math.sin(index) * 0.5,
|
||||
Math.sin(angle) * radius
|
||||
);
|
||||
|
||||
console.warn(`未找到匹配: ${annotation.name},使用默认位置`);
|
||||
}
|
||||
|
||||
// 创建标注标签
|
||||
const labelDiv = document.createElement('div');
|
||||
labelDiv.className = 'annotation-label';
|
||||
labelDiv.textContent = `${index + 1} ${annotation.name}`;
|
||||
labelDiv.style.cssText = `
|
||||
background: rgba(64, 158, 255, 0.9);
|
||||
color: white;
|
||||
padding: 5px 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
pointer-events: auto;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
|
||||
`;
|
||||
|
||||
labelDiv.addEventListener('click', () => {
|
||||
this.onAnnotationClick(annotation, index, mesh);
|
||||
});
|
||||
|
||||
const label = new CSS2DObject(labelDiv);
|
||||
label.position.copy(position);
|
||||
label.userData = { annotation, index, mesh };
|
||||
|
||||
this.scene.add(label);
|
||||
this.annotationObjects.push(label);
|
||||
|
||||
// 创建连接线
|
||||
if (mesh) {
|
||||
const meshPos = new THREE.Vector3();
|
||||
mesh.getWorldPosition(meshPos);
|
||||
|
||||
const points = [meshPos, position];
|
||||
const geometry = new THREE.BufferGeometry().setFromPoints(points);
|
||||
const material = new THREE.LineBasicMaterial({
|
||||
color: 0x409eff,
|
||||
transparent: true,
|
||||
opacity: 0.6
|
||||
});
|
||||
const line = new THREE.Line(geometry, material);
|
||||
this.scene.add(line);
|
||||
this.annotationObjects.push(line);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
findMeshByName(annotationName) {
|
||||
// 精确匹配
|
||||
if (this.partMeshMap[annotationName]) {
|
||||
return this.partMeshMap[annotationName];
|
||||
}
|
||||
|
||||
// 模糊匹配
|
||||
for (let meshName in this.partMeshMap) {
|
||||
if (meshName.includes(annotationName) || annotationName.includes(meshName)) {
|
||||
return this.partMeshMap[meshName];
|
||||
}
|
||||
}
|
||||
|
||||
// 尝试匹配关键词
|
||||
const keywords = annotationName.split(/[,、\s]+/);
|
||||
for (let keyword of keywords) {
|
||||
if (keyword.length > 1) {
|
||||
for (let meshName in this.partMeshMap) {
|
||||
if (meshName.includes(keyword)) {
|
||||
return this.partMeshMap[meshName];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
|
||||
onAnnotationClick(annotation, index, mesh) {
|
||||
this.highlightPart(index, mesh);
|
||||
this.$emit('annotation-click', annotation, index);
|
||||
},
|
||||
|
||||
highlightPart(index, mesh) {
|
||||
// 重置之前的高亮
|
||||
this.annotationObjects.forEach((obj) => {
|
||||
if (obj.isLine) {
|
||||
obj.material.opacity = 0.6;
|
||||
obj.material.color.setHex(0x409eff);
|
||||
} else if (obj.element) {
|
||||
obj.element.style.background = 'rgba(64, 158, 255, 0.9)';
|
||||
obj.element.style.transform = 'scale(1)';
|
||||
}
|
||||
});
|
||||
|
||||
// 高亮选中的标注
|
||||
this.selectedPart = index;
|
||||
this.annotationObjects.forEach((obj, i) => {
|
||||
const objIndex = Math.floor(i / 2);
|
||||
if (objIndex === index) {
|
||||
if (obj.isLine) {
|
||||
obj.material.opacity = 1;
|
||||
obj.material.color.setHex(0xff6b00);
|
||||
} else if (obj.element) {
|
||||
obj.element.style.background = 'rgba(255, 107, 0, 0.95)';
|
||||
obj.element.style.transform = 'scale(1.1)';
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 高亮3D部件
|
||||
if (mesh) {
|
||||
this.highlightMesh(mesh);
|
||||
}
|
||||
},
|
||||
|
||||
highlightMesh(mesh) {
|
||||
// 创建高亮效果
|
||||
if (!this.composer) {
|
||||
this.composer = new EffectComposer(this.renderer);
|
||||
const renderPass = new RenderPass(this.scene, this.camera);
|
||||
this.composer.addPass(renderPass);
|
||||
|
||||
this.outlinePass = new OutlinePass(
|
||||
new THREE.Vector2(this.sceneDomElement.clientWidth, this.sceneDomElement.clientHeight),
|
||||
this.scene,
|
||||
this.camera
|
||||
);
|
||||
this.outlinePass.edgeStrength = 5.0;
|
||||
this.outlinePass.edgeGlow = 0.5;
|
||||
this.outlinePass.edgeThickness = 2.0;
|
||||
this.outlinePass.pulsePeriod = 2;
|
||||
this.outlinePass.visibleEdgeColor.set(0xff6b00);
|
||||
this.composer.addPass(this.outlinePass);
|
||||
}
|
||||
|
||||
this.outlinePass.selectedObjects = [mesh.parent || mesh];
|
||||
},
|
||||
|
||||
onMouseClick(event) {
|
||||
if (!this.debugMode || !this.sceneDomElement) return;
|
||||
|
||||
const rect = this.sceneDomElement.getBoundingClientRect();
|
||||
this.mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
|
||||
this.mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
|
||||
|
||||
this.raycaster.setFromCamera(this.mouse, this.camera);
|
||||
const intersects = this.raycaster.intersectObjects(this.model.children, true);
|
||||
|
||||
if (intersects.length > 0) {
|
||||
const mesh = intersects[0].object;
|
||||
const point = intersects[0].point;
|
||||
|
||||
this.selectedMeshInfo = {
|
||||
name: mesh.name,
|
||||
position: {
|
||||
x: parseFloat(point.x.toFixed(3)),
|
||||
y: parseFloat(point.y.toFixed(3)),
|
||||
z: parseFloat(point.z.toFixed(3))
|
||||
}
|
||||
};
|
||||
|
||||
console.log('点击的部件:', this.selectedMeshInfo);
|
||||
}
|
||||
},
|
||||
|
||||
copyPosition() {
|
||||
if (this.selectedMeshInfo) {
|
||||
const text = JSON.stringify(this.selectedMeshInfo.position, null, 2);
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
this.$message.success('坐标已复制到剪贴板');
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
animate() {
|
||||
this.frameId = requestAnimationFrame(this.animate);
|
||||
|
||||
if (this.controls) {
|
||||
this.controls.update();
|
||||
}
|
||||
|
||||
if (this.renderer && this.scene && this.camera) {
|
||||
if (this.composer && this.outlinePass && this.outlinePass.selectedObjects.length > 0) {
|
||||
this.composer.render();
|
||||
} else {
|
||||
this.renderer.render(this.scene, this.camera);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.labelRenderer && this.scene && this.camera) {
|
||||
this.labelRenderer.render(this.scene, this.camera);
|
||||
}
|
||||
},
|
||||
|
||||
onWindowResize() {
|
||||
if (this.camera && this.renderer && this.sceneDomElement) {
|
||||
const width = this.sceneDomElement.clientWidth;
|
||||
const height = this.sceneDomElement.clientHeight;
|
||||
|
||||
this.camera.aspect = width / height;
|
||||
this.camera.updateProjectionMatrix();
|
||||
|
||||
this.renderer.setSize(width, height);
|
||||
this.labelRenderer.setSize(width, height);
|
||||
|
||||
if (this.composer) {
|
||||
this.composer.setSize(width, height);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.three-viewer {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.canvas-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.annotations-container {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.debug-panel {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
max-width: 300px;
|
||||
max-height: 80%;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.debug-panel h3 {
|
||||
margin: 0 0 10px 0;
|
||||
font-size: 16px;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.debug-panel h4 {
|
||||
margin: 10px 0 5px 0;
|
||||
font-size: 14px;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.debug-info {
|
||||
margin-bottom: 15px;
|
||||
padding-bottom: 15px;
|
||||
border-bottom: 1px solid #dcdfe6;
|
||||
}
|
||||
|
||||
.debug-info p {
|
||||
margin: 5px 0;
|
||||
font-size: 13px;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.debug-info pre {
|
||||
background: #f5f7fa;
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
margin: 5px 0;
|
||||
}
|
||||
|
||||
.debug-info button {
|
||||
margin-top: 8px;
|
||||
padding: 5px 12px;
|
||||
background: #409eff;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.debug-info button:hover {
|
||||
background: #66b1ff;
|
||||
}
|
||||
|
||||
.mesh-list ul {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.mesh-list li {
|
||||
font-size: 12px;
|
||||
color: #606266;
|
||||
margin: 3px 0;
|
||||
word-break: break-all;
|
||||
}
|
||||
</style>
|
||||
268
src/data/data.json
Normal file
@@ -0,0 +1,268 @@
|
||||
[
|
||||
{
|
||||
"modelName": "交流避雷器,AC10kV,13kV, 硅橡胶,40kV, 带间隙。外间隙,带支撑件间隙,不兼做绝缘子",
|
||||
"modelPath": "1",
|
||||
"appearanceCheck": [
|
||||
{
|
||||
"name": "交流避雷器,AC10kV,13kV, 硅橡胶,40kV, 带间隙。外间隙 (带支撑件固定间隙,带故障指示)",
|
||||
"content": [
|
||||
"硅橡胶外套",
|
||||
"带支撑件固定间隙",
|
||||
"带故障指示",
|
||||
"不兼做绝缘子"
|
||||
]
|
||||
}
|
||||
],
|
||||
"mainDataCheck": [
|
||||
{
|
||||
"name": "额定电压",
|
||||
"content": [
|
||||
"13kV"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "标称放电电流",
|
||||
"content": [
|
||||
"10kA"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "直流 1mA 参考电压 (不小于)",
|
||||
"content": [
|
||||
"20kV"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "0.75 倍直流 1mA 参考电压下漏电流",
|
||||
"content": [
|
||||
"≤50μA"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "10kA 雷电冲击电流下的最大残压 (峰值,不大于)",
|
||||
"content": [
|
||||
"40kV"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "大电流冲击耐受能力,4/10μs (2 次)",
|
||||
"content": [
|
||||
"电阻片≥100kA; 整只避雷器≥100kA"
|
||||
]
|
||||
}
|
||||
],
|
||||
"componentCheck": [
|
||||
{
|
||||
"name": "避雷器与支撑件结构",
|
||||
"content": [
|
||||
"避雷器与支撑件并列结构,二者互不影响,非一体化结构,避雷器损坏后可方便更换,但是接触应良好可靠。",
|
||||
"避雷器在本体失效或损坏后,应具有明显的故障指示功能,供运维人员在地面清楚可见。",
|
||||
"避雷器的电极应采用热浸镀锌钢(镀锌层厚度应不小于 86µm)或 316L 不锈钢以防腐防锈。"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "引线",
|
||||
"content": [
|
||||
"不低于 80cm,直径不低于 16mm 的软铜线",
|
||||
"一次端引线应采用绝缘铜线,引线绝缘层的工频电压绝缘耐受水平不低于 18kV"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "铭牌",
|
||||
"content": [
|
||||
"铭牌应采用 316L 不锈钢等防腐蚀材料"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "螺栓螺母",
|
||||
"content": [
|
||||
"避雷器的金属材料(连接板、螺栓螺母紧固件等金属部位)均应采用热浸镀锌钢(镀锌层厚度应不小于 86µm)或 316L 不锈钢以防腐防锈"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "线夹",
|
||||
"content": [
|
||||
"配置 JLG 挂钩引流线夹"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "绝缘罩",
|
||||
"content": [
|
||||
"引线两端需要配置绝缘罩,位置 1:避雷器与引线连接位置(支柱绝缘子与引线连接位置)",
|
||||
"位置 2:引线与导线连接位置(JLG 挂钩引流线夹位置)"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"modelName": "交流棒形悬式复合绝缘子,FXBW-10/70",
|
||||
"modelPath": "2",
|
||||
"appearanceCheck": [
|
||||
{
|
||||
"name": "交流棒形悬式复合绝缘子,FXBW-10/70",
|
||||
"content": [
|
||||
"悬式",
|
||||
"复合材质",
|
||||
"表面光滑"
|
||||
]
|
||||
}
|
||||
],
|
||||
"mainDataCheck": [
|
||||
{
|
||||
"name": "公称结构高度",
|
||||
"content": [
|
||||
"390mm"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "公称爬电距离",
|
||||
"content": [
|
||||
"490mm"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "规定机械破坏负荷",
|
||||
"content": [
|
||||
"70kN"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "逐个拉伸试验负荷",
|
||||
"content": [
|
||||
"35kN"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "雷电全波冲击耐受电压",
|
||||
"content": [
|
||||
"75kV"
|
||||
]
|
||||
}
|
||||
],
|
||||
"componentCheck": [
|
||||
{
|
||||
"name": "本体",
|
||||
"content": [
|
||||
"护套与芯棒之间以及伞裙与护套之间的界面应是永久性粘接",
|
||||
"粘接部分应牢固密实,没有气泡和缝隙,防止污秽物和水汽进入"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "锁紧销",
|
||||
"content": [
|
||||
"采用锡青铜、黄铜、奥氏体不锈钢或其他耐锈蚀性材料制作,不采用有防腐蚀表层而本身不耐锈蚀的材料,与绝缘子成套供应",
|
||||
"销腿末端弯曲部分尺寸严格满足标准规定,末端分开到 180° 再扳回原位无裂纹"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "铭牌",
|
||||
"content": [
|
||||
"标记制造厂名或商标、制造日期、额定电压、额定机械负荷、结构高度和产品编号"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"modelName": "10kV 三相隔离开关",
|
||||
"modelPath": "3",
|
||||
"appearanceCheck": [
|
||||
{
|
||||
"name": "10kV 三相隔离开关结构方案",
|
||||
"content": [
|
||||
"瓷绝缘子:实心高强瓷绝缘子,两端外胶装,外层涂抗紫外线灰釉,接线端配绝缘罩",
|
||||
"复合绝缘子:高品质硅橡胶材料复合绝缘子,接线端配绝缘罩"
|
||||
]
|
||||
}
|
||||
],
|
||||
"mainDataCheck": [
|
||||
{
|
||||
"name": "额定电压 Ur",
|
||||
"content": [
|
||||
"12kV"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "额定电流 Ir",
|
||||
"content": [
|
||||
"630A"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "额定频率 fr",
|
||||
"content": [
|
||||
"50Hz"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "短时耐受电流 Ik/tk",
|
||||
"content": [
|
||||
"20kA/4s"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "额定峰值耐受电流 Ip",
|
||||
"content": [
|
||||
"50kA"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "额定工频耐受电压 Ud",
|
||||
"content": [
|
||||
"42kV"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "额定雷电冲击耐受电压 Up",
|
||||
"content": [
|
||||
"75kV"
|
||||
]
|
||||
}
|
||||
],
|
||||
"componentCheck": [
|
||||
{
|
||||
"name": "基座材质",
|
||||
"content": [
|
||||
"不锈钢 (不低于 S304,厚度不小于 5mm) 或热镀锌钢板 (镀层不小于 70μm,厚度不小于 3mm)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "干弧距离",
|
||||
"content": [
|
||||
"不小于 300mm"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "爬电距离",
|
||||
"content": [
|
||||
"不小于 550mm"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "触头导电板镀层",
|
||||
"content": [
|
||||
"接触部位镀银 (厚度不小于 20μm,硬度不小于 120HV)",
|
||||
"其余部位镀银 (厚度不小于 8μm)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "紧固件材质",
|
||||
"content": [
|
||||
"本体螺栓、螺母、垫片全部采用 316L 不锈钢",
|
||||
"瓷瓶上下固定螺栓和弹簧采用 304 不锈钢"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "配套紧固件",
|
||||
"content": [
|
||||
"配套两侧接线板连接紧固件 4 组,含 M12 螺栓、螺帽及垫片,材质 316L 不锈钢"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "铭牌",
|
||||
"content": [
|
||||
"标有产品标准规定必要信息,与招标要求一致"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
13
src/main.js
Normal file
@@ -0,0 +1,13 @@
|
||||
import Vue from 'vue'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import ElementUI from 'element-ui'
|
||||
import 'element-ui/lib/theme-chalk/index.css'
|
||||
|
||||
Vue.use(ElementUI)
|
||||
Vue.config.productionTip = false
|
||||
|
||||
new Vue({
|
||||
router,
|
||||
render: h => h(App)
|
||||
}).$mount('#app')
|
||||
30
src/router/index.js
Normal file
@@ -0,0 +1,30 @@
|
||||
import Vue from 'vue'
|
||||
import VueRouter from 'vue-router'
|
||||
import ModelList from '../views/ModelList.vue'
|
||||
import ModelDetail from '../views/ModelDetail.vue'
|
||||
|
||||
Vue.use(VueRouter)
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/',
|
||||
redirect: '/list'
|
||||
},
|
||||
{
|
||||
path: '/list',
|
||||
name: 'ModelList',
|
||||
component: ModelList
|
||||
},
|
||||
{
|
||||
path: '/detail/:id',
|
||||
name: 'ModelDetail',
|
||||
component: ModelDetail
|
||||
}
|
||||
]
|
||||
|
||||
const router = new VueRouter({
|
||||
mode: 'hash',
|
||||
routes
|
||||
})
|
||||
|
||||
export default router
|
||||
325
src/views/ModelDetail.vue
Normal file
@@ -0,0 +1,325 @@
|
||||
<template>
|
||||
<div class="model-detail" :class="{ 'mobile': isMobile }">
|
||||
<!-- 顶部标题栏 -->
|
||||
<div class="header">
|
||||
<el-button
|
||||
icon="el-icon-arrow-left"
|
||||
circle
|
||||
size="small"
|
||||
@click="goBack"
|
||||
class="back-btn"
|
||||
></el-button>
|
||||
<h2 class="title">{{ modelData.modelName }}</h2>
|
||||
</div>
|
||||
|
||||
<!-- 主内容区 -->
|
||||
<div class="content">
|
||||
<!-- PC端布局:左右分栏 -->
|
||||
<template v-if="!isMobile">
|
||||
<div class="viewer-section">
|
||||
<three-viewer
|
||||
v-if="modelPath"
|
||||
:model-path="modelPath"
|
||||
:annotations="modelData.componentCheck"
|
||||
@annotation-click="handleAnnotationClick"
|
||||
@model-loaded="handleModelLoaded"
|
||||
@model-error="handleModelError"
|
||||
/>
|
||||
<!-- <three-viewer-debug
|
||||
:model-path="modelPath"
|
||||
:annotations="modelData.componentCheck"
|
||||
:debug-mode="true"
|
||||
@annotation-click="handleAnnotationClick"
|
||||
@model-loaded="handleModelLoaded"
|
||||
/> -->
|
||||
<div v-if="loading" class="loading-overlay">
|
||||
<el-progress
|
||||
type="circle"
|
||||
:percentage="loadingProgress"
|
||||
:width="80"
|
||||
></el-progress>
|
||||
<p>加载模型中...</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-section">
|
||||
<check-table
|
||||
:appearance-check="modelData.appearanceCheck"
|
||||
:main-data-check="modelData.mainDataCheck"
|
||||
:component-check="modelData.componentCheck"
|
||||
:highlighted-index="selectedComponentIndex"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 移动端布局:上中下 -->
|
||||
<template v-else>
|
||||
<div class="viewer-section-mobile">
|
||||
<three-viewer
|
||||
v-if="modelPath"
|
||||
:model-path="modelPath"
|
||||
:annotations="modelData.componentCheck"
|
||||
@annotation-click="handleAnnotationClick"
|
||||
@model-loaded="handleModelLoaded"
|
||||
@model-error="handleModelError"
|
||||
/>
|
||||
<div v-if="loading" class="loading-overlay">
|
||||
<el-progress
|
||||
type="circle"
|
||||
:percentage="loadingProgress"
|
||||
:width="60"
|
||||
></el-progress>
|
||||
<p>加载中...</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-section-mobile">
|
||||
<check-table
|
||||
:appearance-check="modelData.appearanceCheck"
|
||||
:main-data-check="modelData.mainDataCheck"
|
||||
:component-check="modelData.componentCheck"
|
||||
:highlighted-index="selectedComponentIndex"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 移动端点击标注后的详情抽屉 -->
|
||||
<el-drawer
|
||||
:visible.sync="drawerVisible"
|
||||
direction="btt"
|
||||
size="60%"
|
||||
:with-header="false"
|
||||
v-if="isMobile && selectedComponent"
|
||||
>
|
||||
<div class="drawer-content">
|
||||
<h3>{{ selectedComponent.name }}</h3>
|
||||
<div class="requirement-title">检查要求:</div>
|
||||
<ol>
|
||||
<li v-for="(content, idx) in selectedComponent.content" :key="idx">
|
||||
{{ content }}
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</el-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ThreeViewer from '../components/ThreeViewer.vue'
|
||||
import ThreeViewerDebug from '../components/ThreeViewerDebug.vue'
|
||||
import CheckTable from '../components/CheckTable.vue'
|
||||
import modelData from '../data/data.json'
|
||||
|
||||
export default {
|
||||
name: 'ModelDetail',
|
||||
components: {
|
||||
ThreeViewer,
|
||||
CheckTable,
|
||||
ThreeViewerDebug
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
modelData: {},
|
||||
modelPath: '',
|
||||
loading: true,
|
||||
loadingProgress: 0,
|
||||
selectedComponentIndex: -1,
|
||||
selectedComponent: null,
|
||||
drawerVisible: false,
|
||||
isMobile: false
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.checkDevice()
|
||||
this.loadModelData()
|
||||
window.addEventListener('resize', this.checkDevice)
|
||||
},
|
||||
beforeDestroy() {
|
||||
window.removeEventListener('resize', this.checkDevice)
|
||||
},
|
||||
methods: {
|
||||
checkDevice() {
|
||||
this.isMobile = window.innerWidth <= 768
|
||||
},
|
||||
|
||||
loadModelData() {
|
||||
const id = parseInt(this.$route.params.id)
|
||||
if (id >= 0 && id < modelData.length) {
|
||||
this.modelData = modelData[id]
|
||||
// 构建模型路径
|
||||
this.modelPath = `/models/${this.modelData.modelPath}.glb`
|
||||
} else {
|
||||
this.$message.error('模型不存在')
|
||||
this.goBack()
|
||||
}
|
||||
},
|
||||
|
||||
handleAnnotationClick(annotation, index) {
|
||||
this.selectedComponentIndex = index
|
||||
this.selectedComponent = annotation
|
||||
|
||||
if (this.isMobile) {
|
||||
this.drawerVisible = true
|
||||
}
|
||||
},
|
||||
|
||||
handleModelLoaded() {
|
||||
this.loading = false
|
||||
this.$message.success('模型加载完成')
|
||||
},
|
||||
|
||||
handleModelError(error) {
|
||||
this.loading = false
|
||||
this.$message.error('模型加载失败,请检查模型文件是否存在')
|
||||
console.error('模型加载错误:', error)
|
||||
},
|
||||
|
||||
goBack() {
|
||||
this.$router.push({ name: 'ModelList' })
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.model-detail {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.header {
|
||||
height: 60px;
|
||||
background: #409eff;
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 20px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border: none;
|
||||
color: white;
|
||||
margin-right: 15px;
|
||||
}
|
||||
|
||||
.back-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* PC端布局 */
|
||||
.viewer-section {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
border-right: 1px solid #e4e7ed;
|
||||
}
|
||||
|
||||
.table-section {
|
||||
width: 450px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 移动端布局 */
|
||||
.mobile .content {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.viewer-section-mobile {
|
||||
height: 40%;
|
||||
position: relative;
|
||||
border-bottom: 1px solid #e4e7ed;
|
||||
}
|
||||
|
||||
.table-section-mobile {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 加载状态 */
|
||||
.loading-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.loading-overlay p {
|
||||
margin-top: 15px;
|
||||
color: #606266;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 抽屉内容 */
|
||||
.drawer-content {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.drawer-content h3 {
|
||||
margin: 0 0 15px 0;
|
||||
color: #303133;
|
||||
font-size: 18px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 2px solid #409eff;
|
||||
}
|
||||
|
||||
.drawer-content .requirement-title {
|
||||
font-weight: 500;
|
||||
color: #409eff;
|
||||
margin: 15px 0 10px 0;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.drawer-content ol {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.drawer-content li {
|
||||
margin: 10px 0;
|
||||
color: #606266;
|
||||
line-height: 1.8;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.header {
|
||||
height: 50px;
|
||||
padding: 0 15px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
margin-right: 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
123
src/views/ModelList.vue
Normal file
@@ -0,0 +1,123 @@
|
||||
<template>
|
||||
<div class="model-list">
|
||||
<div class="header">
|
||||
<h1>模型检测系统</h1>
|
||||
</div>
|
||||
<div class="list-container">
|
||||
<el-card
|
||||
v-for="(item, index) in modelList"
|
||||
:key="index"
|
||||
class="model-card"
|
||||
shadow="hover"
|
||||
@click.native="goToDetail(index)"
|
||||
>
|
||||
<div class="card-content">
|
||||
<i class="el-icon-box"></i>
|
||||
<h3>{{ item.modelName }}</h3>
|
||||
<p>点击查看详情</p>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import modelData from '../data/data.json'
|
||||
|
||||
export default {
|
||||
name: 'ModelList',
|
||||
data() {
|
||||
return {
|
||||
modelList: modelData
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
goToDetail(id) {
|
||||
this.$router.push({ name: 'ModelDetail', params: { id } })
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.model-list {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #f5f7fa;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: #409eff;
|
||||
color: white;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.list-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 20px;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.model-card {
|
||||
cursor: pointer;
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
|
||||
.model-card:hover {
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
|
||||
.card-content {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.card-content i {
|
||||
font-size: 48px;
|
||||
color: #409eff;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.card-content h3 {
|
||||
font-size: 16px;
|
||||
color: #303133;
|
||||
margin: 10px 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.card-content p {
|
||||
font-size: 14px;
|
||||
color: #909399;
|
||||
margin: 10px 0 0;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.header h1 {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.list-container {
|
||||
grid-template-columns: 1fr;
|
||||
padding: 15px;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.card-content h3 {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
17
vue.config.js
Normal file
@@ -0,0 +1,17 @@
|
||||
module.exports = {
|
||||
publicPath: './',
|
||||
outputDir: 'dist',
|
||||
assetsDir: 'static',
|
||||
lintOnSave: false,
|
||||
productionSourceMap: false,
|
||||
devServer: {
|
||||
port: 8080,
|
||||
open: true,
|
||||
client: {
|
||||
overlay: {
|
||||
warnings: false,
|
||||
errors: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||