这是一个基于Vue.js和Three.js的三维装配可视化系统,用于展示机械零部件的装配和拆解过程。系统支持模型加载、拆解/装配路径生成、动画展示和工艺流程图生成等功能。
npm create vite@latest
创建Vue3项目npm install three @types/three
系统采用前端单页应用架构,使用Vue3作为框架,Three.js作为3D渲染引擎。数据流向如下:
系统分为以下几个主要模块:
src/
├── assets/ # 静态资源
├── components/ # 组件
│ ├── ModelViewer/ # 3D模型查看器
│ │ └── ModelViewer.vue # 核心3D渲染组件
│ ├── ProcessChart/# 工艺流程图
│ │ └── ProcessChart.vue # 流程图组件
│ ├── StepList/ # 工艺步骤列表
│ │ └── StepList.vue # 步骤列表组件
│ └── ToolBar/ # 工具栏
│ └── ToolBar.vue # 工具栏组件
├── router/ # 路由配置
│ └── index.js # 路由定义
├── services/ # 服务
│ └── assemblyService.js # 装配相关服务,包含路径计算等
├── stores/ # 状态管理
│ ├── modelStore.js # 模型状态,存储模型和部件信息
│ └── assemblyStore.js # 装配状态,存储装配步骤和播放状态
└── views/ # 页面视图
├── AssemblyDesignView.vue # 装配设计页面
├── ProcessDesignView.vue # 工艺设计页面
└── StepDesignView.vue # 工步设计页面
模型加载使用Three.js的GLTFLoader和OBJLoader实现。加载后会提取模型的部件信息,并存储在modelStore中。
// 加载GLTF模型
const loadGLTF = (url) => {
const loader = new GLTFLoader()
loader.load(
url,
(gltf) => {
// 清除现有模型
clearScene()
// 添加新模型到场景
scene.add(gltf.scene)
// 调整相机位置以适应模型
fitCameraToObject(gltf.scene)
// 提取部件信息
const parts = extractParts(gltf.scene)
modelStore.setParts(parts)
// 设置动画混合器
if (gltf.animations && gltf.animations.length > 0) {
animationMixer = new THREE.AnimationMixer(gltf.scene)
gltf.animations.forEach((clip) => {
animationMixer.clipAction(clip).play()
})
}
},
// ...错误处理
)
}
模型加载中遇到的主要问题是bin文件路径问题。GLTF文件通常引用外部的bin文件,需要确保这些文件在正确的相对路径上。我们通过将所有模型文件放在public目录下解决了这个问题。
部件提取是从加载的3D模型中识别和分离各个组件的过程。我们使用以下数据结构来表示部件:
// 部件数据结构
{
id: String, // 部件唯一标识符
name: String, // 部件名称
mesh: THREE.Mesh, // 部件的3D网格对象
parentId: String // 父部件ID,用于构建层次结构
}
提取过程中,遍历模型的所有网格对象,为每个网格创建一个部件对象:
// 从模型中提取部件信息
const extractParts = (object) => {
const parts = []
object.traverse((child) => {
if (child.isMesh) {
// 为每个网格创建一个唯一ID
const id = `part_${parts.length}`
// 获取部件名称
const name = child.name || `部件 ${parts.length + 1}`
// 确定父部件ID
let parentId = null
if (child.parent && child.parent !== object) {
parentId = child.parent.uuid
}
// 添加到部件列表
parts.push({
id,
name,
mesh: child,
parentId
})
// 存储原始位置
child.userData.originalPosition = child.position.clone()
child.userData.originalRotation = child.rotation.clone()
// 添加点击事件
child.userData.partId = id
}
})
return parts
}
实现了基于射线检测的部件选择和拖拽功能。当用户拖动部件时,会记录拆解步骤。
// 鼠标按下事件处理
const onMouseDown = (event) => {
// 计算鼠标位置
const rect = renderer.domElement.getBoundingClientRect()
mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1
mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1
// 设置射线
raycaster.setFromCamera(mouse, camera)
// 获取与射线相交的对象
const intersects = raycaster.intersectObjects(scene.children, true)
if (intersects.length > 0) {
// 找到第一个有partId的对象
const intersectedObject = intersects.find(intersect =>
intersect.object.userData && intersect.object.userData.partId
)
if (intersectedObject) {
// 禁用轨道控制器
controls.enabled = false
// 设置拖拽状态
isDragging = true
// 获取选中的部件
const partId = intersectedObject.object.userData.partId
selectedPart = modelStore.parts.find(part => part.id === partId)
// 记录起始位置
dragStartPosition.copy(selectedPart.mesh.position)
// 设置拖拽平面
planeNormal.copy(camera.position).sub(controls.target).normalize()
planePoint.copy(selectedPart.mesh.position)
plane.setFromNormalAndCoplanarPoint(planeNormal, planePoint)
}
}
}
拖拽过程中,使用射线与平面的交点来确定部件的新位置:
// 鼠标移动事件处理
const onMouseMove = (event) => {
if (!isDragging || !selectedPart) return
// 计算鼠标位置
const rect = renderer.domElement.getBoundingClientRect()
mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1
mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1
// 设置射线
raycaster.setFromCamera(mouse, camera)
// 计算射线与平面的交点
const ray = raycaster.ray
if (ray.intersectPlane(plane, intersectionPoint)) {
// 移动部件
selectedPart.mesh.position.copy(intersectionPoint)
// 更新当前位置
dragCurrentPosition.copy(intersectionPoint)
}
}
当用户拖动部件完成拆解操作时,系统会记录这个步骤。步骤数据结构如下:
// 步骤数据结构
{
partId: String, // 部件ID
action: String, // 动作类型(拆解/装配)
path: Array // 移动路径,包含一系列位置点
}
步骤记录过程:
// 鼠标释放事件处理
const onMouseUp = () => {
if (!isDragging || !selectedPart) return
// 启用轨道控制器
controls.enabled = true
// 计算移动距离
const distance = dragStartPosition.distanceTo(dragCurrentPosition)
// 如果移动距离足够大,则记录拆解步骤
if (distance > 0.5) {
// 计算移动路径
const path = calculateLinearPath(dragStartPosition, dragCurrentPosition, 20)
// 记录拆解步骤
assemblyStore.addStep({
partId: selectedPart.id,
action: '拆解',
path: path
})
} else {
// 如果移动距离不够,则恢复原位
selectedPart.mesh.position.copy(dragStartPosition)
}
// 重置拖拽状态
isDragging = false
selectedPart = null
}
实现了正视图、俯视图和侧视图的切换功能。关键是设置相机位置和上方向向量:
// 改变视角
const changeView = (viewType) => {
// 获取模型的边界框
const box = new THREE.Box3().setFromObject(scene)
const size = box.getSize(new THREE.Vector3())
const center = box.getCenter(new THREE.Vector3())
// 计算合适的距离
const maxDim = Math.max(size.x, size.y, size.z)
const distance = maxDim * 1.2
// 根据视角类型设置相机位置
switch (viewType) {
case 'front':
camera.position.set(center.x, center.y, center.z + distance)
camera.up.set(0, 1, 0) // Y轴向上
break
case 'top':
camera.position.set(center.x, center.y + distance, center.z)
camera.up.set(0, 0, -1) // Z轴向下
break
case 'side':
camera.position.set(center.x + distance, center.y, center.z)
camera.up.set(0, 1, 0) // Y轴向上
break
}
// 更新相机
camera.lookAt(center)
camera.updateProjectionMatrix()
// 更新控制器
controls.update()
}
工艺流程图基于记录的拆解步骤生成,使用简单的节点和连线表示装配关系:
// 生成工艺流程图
const generateProcessChart = () => {
const steps = assemblyStore.steps
if (steps.length === 0) return
// 清除现有图表
chartContainer.innerHTML = ''
// 创建SVG元素
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
svg.setAttribute('width', '100%')
svg.setAttribute('height', '100%')
// 为每个步骤创建节点
steps.forEach((step, index) => {
const part = modelStore.parts.find(p => p.id === step.partId)
if (!part) return
// 创建节点
const node = document.createElementNS('http://www.w3.org/2000/svg', 'circle')
node.setAttribute('cx', 50 + index * 100)
node.setAttribute('cy', 50)
node.setAttribute('r', 20)
node.setAttribute('fill', '#42b883')
// 创建标签
const text = document.createElementNS('http://www.w3.org/2000/svg', 'text')
text.setAttribute('x', 50 + index * 100)
text.setAttribute('y', 90)
text.setAttribute('text-anchor', 'middle')
text.textContent = part.name
// 添加到SVG
svg.appendChild(node)
svg.appendChild(text)
// 添加连线
if (index > 0) {
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line')
line.setAttribute('x1', 50 + (index - 1) * 100)
line.setAttribute('y1', 50)
line.setAttribute('x2', 50 + index * 100)
line.setAttribute('y2', 50)
line.setAttribute('stroke', '#666')
line.setAttribute('stroke-width', 2)
svg.appendChild(line)
}
})
// 添加到容器
chartContainer.appendChild(svg)
}