Compare commits

...

9 Commits

Author SHA1 Message Date
3e0c8a70c8 模型文件 2026-05-12 17:44:15 +08:00
13d25763df 模型更改 2026-04-27 17:36:45 +08:00
49c8171dc8 样式调整 2026-04-27 15:11:11 +08:00
dbb9026bc4 高亮问题 2026-04-27 14:58:33 +08:00
94830006bc three版本更新 2026-04-27 14:48:48 +08:00
f3de05d7dc 样式修改 2026-04-27 11:22:12 +08:00
6e0e447fa3 打点 2026-04-27 11:08:55 +08:00
c2e662cbf4 first commit 2026-04-27 09:58:30 +08:00
4ac99bb417 暂存本地 2026-04-27 09:57:00 +08:00
113 changed files with 12428 additions and 8242 deletions

133
README.md Normal file
View 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

8
package-lock.json generated
View File

@@ -9,7 +9,7 @@
"version": "1.0.0",
"dependencies": {
"element-ui": "^2.15.13",
"three": "^0.124.0",
"three": "^0.184.0",
"vue": "^2.6.14",
"vue-router": "^3.5.3"
},
@@ -6996,9 +6996,9 @@
}
},
"node_modules/three": {
"version": "0.124.0",
"resolved": "https://registry.npmmirror.com/three/-/three-0.124.0.tgz",
"integrity": "sha512-ROXp1Ly7YyF+jC910DQyAWj++Qlw2lQv0qwYLNQwdDbjk4bsOXAfGO92wYTMPNei1GMJUmCxSxc3MjGBTS09Rg=="
"version": "0.184.0",
"resolved": "https://registry.npmmirror.com/three/-/three-0.184.0.tgz",
"integrity": "sha512-wtTRjG92pM5eUg/KuUnHsqSAlPM296brTOcLgMRqEeylYTh/CdtvKUvCyyCQTzFuStieWxvZb8mVTMvdPyUpxg=="
},
"node_modules/throttle-debounce": {
"version": "1.1.0",

View File

@@ -8,9 +8,9 @@
},
"dependencies": {
"element-ui": "^2.15.13",
"three": "^0.184.0",
"vue": "^2.6.14",
"vue-router": "^3.5.3",
"three": "^0.124.0"
"vue-router": "^3.5.3"
},
"devDependencies": {
"@vue/cli-service": "^5.0.8",

12
public/index.html Normal file
View 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
View 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)

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 741 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 741 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 684 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 849 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 617 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 235 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 890 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 684 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 684 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 564 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 496 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 890 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 281 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 802 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 615 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 433 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 686 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 294 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 802 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

View 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"
}

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

30
src/App.vue Normal file
View 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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 662 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 704 B

View File

@@ -0,0 +1,239 @@
<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-tab-pane label="附件" name="file">
</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;
}
.el-tabs >>> .el-tabs__item{
padding: 0 10px;
}
.check-item {
padding: 10px;
}
.component-content {
padding: 10px;
}
.component-content li {
font-size: 13px;
}
}
</style>

View 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>

Some files were not shown because too many files have changed in this diff Show More