移动端WebGL实现3D装配体交互:拆解与移动技术方案

移动端WebGL实现3D装配体交互:拆解与移动技术方案

这次我们来看一个在移动端实现装配体拆解与移动的技术方案。对于机械设计、产品展示或教育培训领域的从业者来说,能够在手机、平板等移动设备上直接操作复杂的3D装配体,进行动态拆解和部件移动,是一个极具实用价值的需求。它意味着演示、评审和协作不再受限于桌面工作站,可以随时随地进行。

这个方案的核心,不是要开发一个全新的重型CAD应用,而是探讨如何利用现有的技术栈和工具,在移动端有限的算力和交互条件下,实现流畅的装配体交互体验。重点在于解决几个关键问题:如何在移动端加载和渲染大型装配体模型?如何实现精准的部件选择与高亮?如何设计自然流畅的拆解动画和移动交互逻辑?以及,如何保证不同平台和设备上的兼容性与性能?

本文将带你从技术选型、环境搭建到功能实现,一步步构建一个可在手机浏览器中运行的装配体拆解Demo。我们会重点关注WebGL渲染、轻量化模型处理、交互事件适配以及动画性能优化。无论你是前端工程师、三维应用开发者,还是对移动端三维技术感兴趣的机械工程师,都能从中获得可直接复用的思路和代码。

1. 核心能力速览

能力项说明
核心功能在手机浏览器中加载3D装配体模型,实现部件级的选择、高亮、拆解动画和自由移动。
技术栈前端:Three.js / Babylon.js 等 WebGL 框架;后端:可选的轻量级Node.js/Python服务,用于模型预处理。
模型格式支持 glTF/GLB、OBJ 等Web友好格式。需将原始CAD格式(STEP, IGES, SolidWorks等)进行转换和轻量化处理。
交互方式适配移动端触屏:单指旋转视角、双指缩放、双指平移视角、单击选择部件、长按/拖拽移动部件。
性能门槛重点:依赖手机GPU性能和浏览器WebGL支持。模型面数和部件数量是性能关键,需进行减面、实例化等优化。
部署方式纯前端静态部署(HTML+JS+资源文件)或搭配简易后端服务。可通过二维码在手机浏览器直接访问。
适合场景移动端产品展示、销售演示、维修指导、教育培训、远程设计评审。
不适合场景需要复杂参数化建模、高级仿真分析、精密工程设计的专业CAD工作。

2. 适用场景与使用边界

适用场景:

  1. 销售与市场演示:销售人员使用平板向客户动态展示产品内部结构、核心部件,提升讲解效果。
  2. 现场服务与维修:维修人员在设备现场,通过手机查看设备的爆炸图,按步骤拆解指导进行维护。
  3. 教育培训:学生在手机或平板上学习机械原理,通过交互式拆装加深对零部件关系和装配顺序的理解。
  4. 远程设计评审:设计团队在不同地点,通过共享链接在各自手机/电脑上查看和讨论同一装配体,标注问题。
  5. 数字孪生轻量交互:在物联网应用中,通过移动端简单查看与之对应的物理设备的虚拟模型状态。

使用边界与注意事项:

  1. 非专业设计工具:此方案旨在实现查看、演示与简单交互,无法替代SolidWorks、CATIA、Creo等专业CAD软件的设计、绘图和计算功能。
  2. 模型复杂度限制:手机GPU性能有限,超大型装配体(数万个零件)必须经过严格的轻量化处理(减面、合并、LOD),否则会导致加载缓慢、交互卡顿甚至浏览器崩溃。
  3. 数据安全:涉及企业核心产品模型时,需考虑模型文件的上传、存储和传输安全。建议对glTF等模型文件进行加密或部署在内网环境。
  4. 版权与授权:使用的三维模型必须拥有合法的版权或使用权,确保在公开演示或商业用途中不侵犯知识产权。
  5. 浏览器兼容性:并非所有手机浏览器对WebGL的支持都一致,尤其是较老的浏览器或某些定制化系统浏览器。需进行兼容性测试,并准备降级方案(如显示静态图片)。

3. 环境准备与前置条件

在开始编码前,需要准备好开发和测试环境。

1. 开发环境:

  • 操作系统:Windows, macOS 或 Linux 均可。
  • 代码编辑器:VS Code, WebStorm 等。
  • Node.js:建议安装 LTS 版本(如 v18+),用于运行本地开发服务器和可能的构建工具。
  • Python(可选):如果你计划使用Python脚本进行模型格式转换和预处理(例如使用pygltflib,trimesh库)。

2. 模型预处理环境(关键步骤):这是移动端能否流畅运行的决定性环节。原始CAD模型(.sldasm, .step, .iges等)通常不能直接用于Web。

  • CAD软件:如SolidWorks, Fusion 360, Rhino等,用于将装配体导出为中间格式(如STL, OBJ)。确保导出时保持部件层级和颜色信息。
  • 三维格式转换工具
    • Blender(免费开源):功能强大的三维创作套件,支持多种格式导入/导出,并能进行减面、烘焙纹理等优化操作。是模型预处理的主力工具。
    • CAD Exchanger(商业软件):专门处理CAD格式转换,对特征和装配结构的保持较好。
    • 在线转换器(谨慎使用):对于敏感模型,不建议使用不明在线服务。
  • 模型优化目标:将最终模型转换为glTF 2.0 (.glb)格式。glTF是Web上的“JPEG for 3D”,被Three.js等框架原生高效支持。

3. 测试环境:

  • 本地开发服务器:使用http-serverlive-server在电脑上启动一个本地HTTP服务,方便在局域网内用手机访问测试。
  • 手机设备:准备一台性能中等以上的安卓或iOS手机,用于真机调试。确保手机浏览器(Chrome, Safari)已更新至较新版本。
  • 同一局域网:确保开发电脑和测试手机处于同一Wi-Fi网络下。

4. 技术选型与项目初始化

我们选择Three.js作为WebGL渲染库,因为它生态丰富、文档完善、社区活跃。

1. 创建项目结构:

mkdir mobile-assembly-exploder cd mobile-assembly-exploder npm init -y

2. 安装依赖:

npm install three # 可选,用于轨道控制器(在移动端我们需要自定义) # npm install @types/three three-orbitcontrols # 开发服务器 npm install --save-dev vite

3. 创建基础文件:

  • index.html:主HTML文件。
  • src/main.js:主JavaScript逻辑。
  • src/style.css:样式文件。
  • models/:目录,用于存放优化后的.glb模型文件。
  • public/:Vite的静态资源目录(如果使用Vite)。

4. 基础HTML结构 (index.html):

<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> <title>移动端装配体拆解演示</title> <link rel="stylesheet" href="./src/style.css"> <style> body { margin: 0; overflow: hidden; } #info { position: absolute; top: 10px; left: 10px; color: white; font-family: monospace; background: rgba(0,0,0,0.5); padding: 5px; border-radius: 3px; } #controls { position: absolute; bottom: 20px; width: 100%; text-align: center; } button { margin: 5px; padding: 10px 20px; font-size: 16px; } </style> </head> <body> <div id="container"></div> <div id="info">加载中...</div> <div id="controls"> <button id="resetBtn">复位装配体</button> <button id="explodeBtn">拆解装配体</button> <button id="toggleMove">移动模式</button> </div> <script type="module" src="./src/main.js"></script> </body> </html>

关键点:<meta name="viewport">的设置对移动端适配至关重要,它确保了画布能正确缩放并禁用默认的页面缩放。

5. 核心功能实现:场景、相机、渲染器与模型加载

1. 初始化Three.js场景 (src/main.js):

import * as THREE from 'three'; import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'; // 1. 场景 const scene = new THREE.Scene(); scene.background = new THREE.Color(0xf0f0f0); // 2. 相机 (透视相机,适应移动端屏幕比例) const camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 1000); camera.position.set(10, 10, 10); camera.lookAt(0, 0, 0); // 3. 渲染器 (关键:设置像素比和设备尺寸) const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); renderer.setSize(window.innerWidth, window.innerHeight); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); // 限制像素比以提升性能 renderer.shadowMap.enabled = true; // 可选,根据性能决定 document.getElementById('container').appendChild(renderer.domElement); // 4. 灯光 const ambientLight = new THREE.AmbientLight(0xffffff, 0.6); scene.add(ambientLight); const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8); directionalLight.position.set(10, 20, 15); scene.add(directionalLight); // 5. 辅助工具(开发时使用,发布时可移除) const axesHelper = new THREE.AxesHelper(5); scene.add(axesHelper); const gridHelper = new THREE.GridHelper(20, 20); scene.add(gridHelper); // 变量声明 let model = null; // 加载的模型组 let originalPositions = new Map(); // 存储每个部件的原始位置 { object: THREE.Vector3 } let exploded = false; let isMoveMode = false; let selectedObject = null; const highlightMaterial = new THREE.MeshLambertMaterial({ color: 0xffaa00 }); // 高亮材质 const raycaster = new THREE.Raycaster(); const pointer = new THREE.Vector2(); // 响应式调整 window.addEventListener('resize', onWindowResize); function onWindowResize() { camera.aspect = window.innerWidth / window.innerHeight; camera.updateProjectionMatrix(); renderer.setSize(window.innerWidth, window.innerHeight); }

2. 加载GLB模型并初始化部件信息:

const loader = new GLTFLoader(); const infoDiv = document.getElementById('info'); loader.load( './models/your_assembly.glb', // 替换为你的模型路径 function (gltf) { model = gltf.scene; scene.add(model); // 遍历模型中的所有网格对象,存储原始位置并添加点击事件所需属性 model.traverse((child) => { if (child.isMesh) { originalPositions.set(child, child.position.clone()); // 为每个部件添加一个自定义属性,方便识别 child.userData.isPart = true; // 保存原始材质,用于高亮后恢复 if (Array.isArray(child.material)) { child.userData.originalMaterials = child.material.map(mat => mat.clone()); } else { child.userData.originalMaterial = child.material.clone(); } } }); // 调整相机位置,使模型居中显示 const box = new THREE.Box3().setFromObject(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); const fov = camera.fov * (Math.PI / 180); let cameraZ = Math.abs(maxDim / (2 * Math.tan(fov / 2))); cameraZ *= 1.5; // 稍微拉远一点 camera.position.set(center.x, center.y, cameraZ); camera.lookAt(center); infoDiv.textContent = `模型已加载。零件数: ${originalPositions.size}`; console.log('模型加载完成,原始位置已记录。'); }, function (xhr) { infoDiv.textContent = `加载中... ${((xhr.loaded / xhr.total) * 100).toFixed(2)}%`; }, function (error) { console.error('模型加载失败:', error); infoDiv.textContent = '模型加载失败,请检查控制台。'; } );

6. 移动端交互逻辑实现

移动端的交互逻辑与桌面端(鼠标)有显著不同,需要专门为触屏设计。

1. 触控相机控制(旋转、缩放、平移):我们将实现一个简化的触控控制器来替代传统的OrbitControls,以更好地适应移动端手势。

let touchStartX, touchStartY; let initialPinchDistance = null; let isRotating = false; let isPanning = false; const panSpeed = 0.01; renderer.domElement.addEventListener('touchstart', onTouchStart, { passive: false }); renderer.domElement.addEventListener('touchmove', onTouchMove, { passive: false }); renderer.domElement.addEventListener('touchend', onTouchEnd); function onTouchStart(event) { event.preventDefault(); if (event.touches.length === 1) { // 单指触摸:可能是旋转的开始,也可能是选择部件的开始(稍后判断) touchStartX = event.touches[0].clientX; touchStartY = event.touches[0].clientY; isRotating = true; isPanning = false; } else if (event.touches.length === 2) { // 双指触摸:缩放或平移 isRotating = false; isPanning = true; initialPinchDistance = getPinchDistance(event.touches[0], event.touches[1]); } } function onTouchMove(event) { event.preventDefault(); if (!model) return; if (isRotating && event.touches.length === 1 && !isMoveMode) { // 单指移动:旋转模型 const touchX = event.touches[0].clientX; const touchY = event.touches[0].clientY; const deltaX = touchX - touchStartX; const deltaY = touchY - touchStartY; touchStartX = touchX; touchStartY = touchY; model.rotation.y += deltaX * 0.01; model.rotation.x += deltaY * 0.01; } else if (isPanning && event.touches.length === 2) { // 双指移动:缩放或平移视角 if (initialPinchDistance !== null) { const currentDistance = getPinchDistance(event.touches[0], event.touches[1]); const zoomDelta = (currentDistance - initialPinchDistance) * 0.01; // 缩放相机(或模型) camera.position.z *= (1 - zoomDelta * 0.1); initialPinchDistance = currentDistance; } // 双指平移(Pan)逻辑可以在此添加,通过计算双指中心点的移动来平移相机。 } } function onTouchEnd(event) { isRotating = false; isPanning = false; initialPinchDistance = null; // 如果是单指轻触(点击),则触发部件选择 if (event.changedTouches.length === 1 && event.touches.length === 0) { // 这里有一个简单的延时判断,区分“点击”和“拖动” // 更精确的做法需要记录时间差。此处简化处理。 handleTouchSelect(event.changedTouches[0]); } } function getPinchDistance(touch1, touch2) { const dx = touch1.clientX - touch2.clientX; const dy = touch1.clientY - touch2.clientY; return Math.sqrt(dx * dx + dy * dy); }

2. 部件选择与高亮:

function handleTouchSelect(touch) { // 将触摸位置转换为标准化设备坐标 (-1 到 +1) pointer.x = (touch.clientX / window.innerWidth) * 2 - 1; pointer.y = -(touch.clientY / window.innerHeight) * 2 + 1; // 更新射线投射器 raycaster.setFromCamera(pointer, camera); // 计算与哪些物体相交 const intersects = raycaster.intersectObjects(scene.children, true); // true 表示递归检查子对象 // 清除之前的高亮 if (selectedObject) { restoreMaterial(selectedObject); selectedObject = null; } // 找到第一个被点击的部件 for (let i = 0; i < intersects.length; i++) { const object = intersects[i].object; // 通过我们之前设置的 userData 属性来识别部件 if (object.userData.isPart) { selectedObject = object; highlightObject(selectedObject); infoDiv.textContent = `选中部件: ${object.name || '未命名'}`; break; // 只选中第一个 } } if (!selectedObject) { infoDiv.textContent = '未选中任何部件。'; } } function highlightObject(obj) { if (Array.isArray(obj.material)) { obj.material.forEach(mat => { mat.emissive.set(0x444444); // 使用自发光实现高亮,而非替换材质 mat.needsUpdate = true; }); } else { obj.material.emissive.set(0x444444); obj.material.needsUpdate = true; } } function restoreMaterial(obj) { if (Array.isArray(obj.material)) { obj.material.forEach((mat, idx) => { if (obj.userData.originalMaterials && obj.userData.originalMaterials[idx]) { mat.emissive.copy(obj.userData.originalMaterials[idx].emissive); mat.needsUpdate = true; } }); } else { if (obj.userData.originalMaterial) { obj.material.emissive.copy(obj.userData.originalMaterial.emissive); obj.material.needsUpdate = true; } } }

7. 拆解与移动动画实现

1. 拆解(爆炸图)动画:拆解的本质是计算每个部件沿某个方向(如从装配体中心向外)的偏移向量,然后通过动画移动到目标位置。

const explodeDistance = 2.0; // 拆解距离系数 const explodeDirection = new THREE.Vector3(1, 1, 0.5).normalize(); // 拆解方向,可以自定义 function explodeAssembly() { if (!model || exploded) return; exploded = true; infoDiv.textContent = '拆解中...'; model.traverse((child) => { if (child.isMesh && originalPositions.has(child)) { const originalPos = originalPositions.get(child); // 计算从模型中心到部件原始位置的向量 const offset = child.position.clone().sub(model.position); // 沿拆解方向移动,距离与原始偏移量成比例 const targetPos = originalPos.clone().add(explodeDirection.clone().multiplyScalar(offset.length() * explodeDistance)); // 使用Tween.js或GSAP实现平滑动画。这里用简单线性动画演示。 // 实际项目中建议使用动画库。 child.userData.targetPosition = targetPos; child.userData.animate = true; child.userData.startPosition = child.position.clone(); child.userData.startTime = Date.now(); child.userData.duration = 1000; // 动画时长1秒 } }); } function resetAssembly() { if (!model || !exploded) return; exploded = false; infoDiv.textContent = '复位中...'; model.traverse((child) => { if (child.isMesh && originalPositions.has(child)) { const originalPos = originalPositions.get(child); child.userData.targetPosition = originalPos; child.userData.animate = true; child.userData.startPosition = child.position.clone(); child.userData.startTime = Date.now(); child.userData.duration = 1000; } }); }

2. 部件移动(拖拽)逻辑:进入移动模式后,长按并拖拽选中的部件。

document.getElementById('toggleMove').addEventListener('click', function() { isMoveMode = !isMoveMode; this.textContent = isMoveMode ? '退出移动模式' : '移动模式'; infoDiv.textContent = isMoveMode ? '移动模式已开启,长按并拖拽部件。' : '移动模式已关闭。'; }); // 在 onTouchStart 中补充移动模式的逻辑 function onTouchStart(event) { event.preventDefault(); if (event.touches.length === 1) { touchStartX = event.touches[0].clientX; touchStartY = event.touches[0].clientY; if (isMoveMode && selectedObject) { // 移动模式下,单指触摸直接视为开始拖拽选中的部件 isDraggingPart = true; isRotating = false; } else { isRotating = true; isDraggingPart = false; } isPanning = false; } else if (event.touches.length === 2) { // 移动模式下,双指仍用于相机控制 isRotating = false; isDraggingPart = false; isPanning = true; initialPinchDistance = getPinchDistance(event.touches[0], event.touches[1]); } } // 在 onTouchMove 中补充移动模式的逻辑 function onTouchMove(event) { event.preventDefault(); if (!model) return; if (isDraggingPart && selectedObject && event.touches.length === 1) { // 拖拽选中的部件 const touch = event.touches[0]; // 将屏幕移动转换为三维空间移动是一个复杂问题,通常需要将屏幕增量投影到某个平面上。 // 这里提供一个简化思路:在相机视角平面上移动。 const deltaX = (touch.clientX - touchStartX) * 0.01; const deltaY = (touch.clientY - touchStartY) * 0.01; touchStartX = touch.clientX; touchStartY = touch.clientY; // 根据相机方向计算移动向量(简化版,可能不精确) const moveVector = new THREE.Vector3(); camera.getWorldDirection(moveVector); moveVector.y = 0; // 假设主要在XZ平面移动 moveVector.normalize(); const rightVector = new THREE.Vector3().crossVectors(moveVector, camera.up).normalize(); selectedObject.position.x += rightVector.x * deltaX - moveVector.x * deltaY; selectedObject.position.z += rightVector.z * deltaX - moveVector.z * deltaY; // 注意:此简化逻辑在复杂视角下可能不直观,实际项目需使用射线与平面相交的方法。 } else if (isRotating && event.touches.length === 1 && !isMoveMode) { // ... 原有的旋转逻辑 } else if (isPanning && event.touches.length === 2) { // ... 原有的缩放逻辑 } }

3. 动画循环与更新:

function animate() { requestAnimationFrame(animate); updateAnimations(); // 更新拆解/复位动画 renderer.render(scene, camera); } animate(); function updateAnimations() { const currentTime = Date.now(); model && model.traverse((child) => { if (child.isMesh && child.userData.animate && child.userData.startTime && child.userData.targetPosition) { const elapsed = currentTime - child.userData.startTime; const progress = Math.min(elapsed / child.userData.duration, 1); // 使用缓动函数(这里用简单的线性) child.position.lerpVectors(child.userData.startPosition, child.userData.targetPosition, progress); if (progress >= 1) { child.userData.animate = false; if (!exploded) { // 复位完成,位置精确还原 child.position.copy(originalPositions.get(child)); } } } }); }

8. 性能优化与移动端适配要点

在手机浏览器中运行3D内容,性能优化是重中之重。

  1. 模型轻量化

    • 减面:使用Blender的“Decimate”修改器或专业工具减少三角面数量。目标是将模型面数控制在手机GPU能流畅渲染的范围内(例如,简单装配体数万面,复杂装配体需分块加载或使用LOD)。
    • 合并网格:对于大量相同的小零件(如螺丝、垫圈),如果不需要单独交互,可以合并成一个网格,大幅减少Draw Call。
    • 纹理优化:压缩纹理尺寸,使用.jpg.webp格式,考虑使用纹理图集。
  2. 渲染优化

    • 限制像素比renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))防止在高分屏上渲染过大的画布。
    • 视锥体裁剪:Three.js默认开启。确保相机frustumCulled为true。
    • 细节层次(LOD):为复杂部件创建多个细节程度的模型,根据距离切换。
    • 阴影谨慎使用:实时阴影消耗大。如果非必需,可以关闭或使用烘焙光照贴图。
  3. 交互优化

    • 防抖与节流:对resizetouchmove等频繁触发的事件进行节流处理。
    • 简化射线检测raycaster.intersectObjects传入需要检测的特定对象列表,而不是整个场景。
    • 避免在动画循环中创建对象:将new THREE.Vector3()等操作移到循环外部。
  4. 内存管理

    • 及时销毁不再需要的几何体、材质和纹理。
    • 监听页面visibilitychange事件,当页面隐藏时暂停渲染循环。

9. 部署与真机测试

  1. 构建:如果使用了Vite等构建工具,运行npm run build生成优化的静态文件(位于dist目录)。
  2. 本地服务器:在项目根目录运行一个HTTP服务器。
    npx http-server . -p 8080
  3. 手机访问:确保手机和电脑在同一局域网。在电脑上找到本机IP地址(如192.168.1.100),在手机浏览器中输入http://192.168.1.100:8080
  4. 调试
    • iOS:连接Mac,在Safari的“开发”菜单中可以看到手机设备,可以远程调试Console和Web Inspector。
    • Android:连接USB并开启调试模式,在Chrome的chrome://inspect中调试。
  5. 发布:将dist目录或所有静态文件上传至任何支持静态托管的Web服务器或云存储(如GitHub Pages, Vercel, Netlify)。

10. 常见问题与排查方法

问题现象可能原因排查方式解决方案
页面白屏,控制台无错误WebGL不支持或初始化失败检查浏览器控制台是否有WebGL错误;访问chrome://gpu查看WebGL状态。更新浏览器/显卡驱动;尝试在WebGLRenderer参数中添加{ powerPreference: "low-power" }"high-performance"
模型加载非常慢或卡死模型文件过大或网络慢浏览器Network面板查看模型下载时间;控制台输出模型大小。优化模型(减面、压缩纹理);使用CDN;考虑分块加载。
交互(旋转、缩放)卡顿模型面数过多或JS执行效率低使用浏览器Performance面板记录性能;查看帧率(FPS)。实施第8节的性能优化措施;降低渲染分辨率。
点击无法选中部件射线检测未命中或对象不可选检查raycaster.intersectObjects的参数;确认点击的网格raycast属性为true。确保遍历对象时包含了所有可选的网格;检查模型层级,可能需要scene.children递归。
拆解动画时部件位置错乱原始位置记录不正确或动画逻辑错误打印originalPositions中存储的位置;检查拆解方向向量的计算。确保在模型加载完成、位置稳定后再记录originalPositions;调试拆解目标位置的计算公式。
在手机上无法触摸操作触屏事件被阻止或坐标转换错误检查event.preventDefault()是否被调用;打印pointer坐标看是否在[-1,1]范围。确保监听器正确绑定到renderer.domElement;检查视口(viewport)meta标签。

实现移动端的装配体拆解与移动,技术核心在于“取舍”与“优化”。取WebGL的跨平台之便,舍桌面级CAD的复杂功能;优化模型以适应移动端孱弱的GPU,优化交互以符合触屏直觉。通过本文的步骤,你已经可以搭建一个基础的可交互演示。要将其转化为成熟产品,后续还需要在UI/UX设计、动画流畅度、多模型管理、状态保存、与后端数据同步等方面深入打磨。建议从一个小型、结构清晰的装配体开始实验,逐步增加复杂度,过程中持续进行真机性能测试,这样才能最终打造出用户体验流畅的移动端3D交互应用。