ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Vue3集成Three.js实现前端DXF图纸解析与3D可视化

Vue3集成Three.js实现前端DXF图纸解析与3D可视化

1. 项目概述:在Vue3中解析与展示DXF图纸

最近在做一个工业设计类的Web应用,需要在前端直接展示和交互DXF格式的CAD图纸。这需求听起来简单,但实际做起来,从文件解析到3D渲染,再到与Vue3框架的深度集成,每一步都藏着不少门道。网上资料要么是零散的代码片段,要么是纯Three.js的教程,很少有把dxf-parserthree-dxfVue3这三者串起来讲的完整方案。我花了些时间,把整个流程跑通并优化了一遍,踩过的坑、总结的技巧,都在这篇笔记里了。

简单来说,这个方案的核心是:利用dxf-parser这个库将二进制的DXF文件解析成结构化的JSON数据,然后通过three-dxf库将这些数据转换成Three.js能够理解的3D实体(Entities),最后在Vue3组件中,借助@vueuse/core等工具优雅地集成Three.js进行渲染和交互。整个过程完全在浏览器端完成,无需后端介入,非常适合需要快速预览、轻量级查看CAD图纸的Web场景,比如在线设计评审、零件库浏览、简单的图纸标注等。

2. 核心工具链选型与架构设计

2.1 为什么是dxf-parser + three-dxf?

面对DXF文件,前端开发者首先会想到几个问题:怎么读?怎么画?性能如何?市面上有几种思路:

  1. 后端渲染转图片/PDF:这是最传统的方式,服务器用AutoCAD或开源引擎(如LibreDWG)渲染成图片,前端只负责显示。优点是兼容性最好,但失去了前端的交互性(如旋转、缩放、拾取图元),且服务器压力大、有延迟。
  2. 纯前端Canvas/SVG绘制:针对2D DXF,可以自己解析并直接用Canvas 2D或SVG画出来。这对简单图纸可行,但面对复杂的3D实体、图层、线型就力不从心,性能和代码复杂度都是挑战。
  3. WebGL/Three.js方案:用Three.js在WebGL中渲染。这是实现高质量、可交互3D视图的最佳路径。但Three.js本身不认DXF格式,需要一个“翻译”层。

dxf-parserthree-dxf的组合,正是为第三条路径量身定做的。

  • dxf-parser:专注于一件事——解析。它不关心渲染,只负责把DXF文件(ASCII或二进制)的各个段(SECTIONS)、表(TABLES)、块(BLOCKS)、实体(ENTITIES)解析成一个结构清晰的JavaScript对象。这个对象包含了图纸的所有原始信息,比如实体的类型(LINE, CIRCLE, LWPOLYLINE)、图层(LAYER)、颜色索引、线型等。
  • three-dxf:专注于另一件事——转换。它接收dxf-parser输出的解析结果,遍历其中的实体,根据类型调用Three.js的API,创建对应的THREE.LineTHREE.Mesh等对象,并组装成一个Three.js的Group(组)。它相当于一个适配器,弥合了DXF数据模型与Three.js图形模型之间的鸿沟。

这个分工明确的组合,让前端处理DXF变得模块化且可行。你不需要去啃DXF格式的数百种组码(Group Code),也不需要从头用Three.js画各种复杂几何体。

2.2 在Vue3项目中的集成架构

在Vue3项目中引入Three.js和相关库,不能像在传统HTML页面中那样简单粗暴地写脚本。我们需要考虑Vue的响应式、生命周期和组件化思想。我推荐的架构如下:

src/ ├── components/ │ └── DxfViewer.vue // 主要的DXF查看器组件 ├── composables/ │ └── useThreeDxf.ts // 封装解析、渲染逻辑的Composition API ├── utils/ │ └── dxfLoader.ts // 封装文件加载和解析的纯函数 └── views/ // 或其他页面组件 └── DesignReview.vue // 使用DxfViewer的页面

核心思路

  1. 逻辑与视图分离:将Three.js的初始化、场景构建、渲染循环、DXF解析与转换等“脏活累活”封装到Composition Function(如useThreeDxf)或工具类中。Vue组件(DxfViewer.vue)只负责提供DOM容器、绑定用户事件(如文件上传)和调用这些逻辑。
  2. 响应式管理状态:使用Vue的refreactive来管理Three.js场景中的关键状态,例如当前视图的缩放级别、是否显示网格、高亮选中的实体等。这样状态变化能自动触发UI更新。
  3. 善用生命周期:在onMounted钩子中初始化Three.js世界,在onUnmounted中必须进行彻底的资源清理(销毁几何体、材质、移除事件监听器),防止内存泄漏。
  4. 利用现代工具链:使用Vite作为构建工具,它能很好地处理Three.js这类包含大量模块的库。通过@vueuse/core可以方便地使用useRafFn来驱动渲染循环,比手动requestAnimationFrame更优雅。

3. 环境搭建与核心依赖安装

3.1 创建Vue3项目与安装依赖

首先,确保你有一个Vue3项目。如果还没有,用Vite快速创建一个:

npm create vue@latest my-dxf-viewer # 按照提示选择项目配置,这里建议添加TypeScript以获得更好的类型提示。 cd my-dxf-viewer npm install

然后,安装项目运行所需的核心依赖:

npm install three dxf-parser three-dxf npm install @vueuse/core # 提供非常实用的组合式函数,如useRafFn
  • three:3D渲染引擎的核心库。
  • dxf-parser:DXF文件解析器。
  • three-dxf:将解析结果转换为Three.js对象的库。
  • @vueuse/core:Vue3工具函数集合,我们将用它来优化渲染循环和事件处理。

如果你使用TypeScript,可能还需要安装类型定义文件。three有官方类型包@types/three,但dxf-parserthree-dxf可能没有完美的类型定义,需要自己简单声明或使用any临时绕过,但这不影响功能。

3.2 初始化Three.js场景的通用逻辑

在深入DXF之前,我们先搭建一个基础的Three.js场景。这部分逻辑可以抽象到composables/useThreeDxf.ts中。

// composables/useThreeDxf.ts import * as THREE from 'three'; import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; import { ref, onUnmounted, onMounted } from 'vue'; import { useRafFn } from '@vueuse/core'; export function useThreeDxf(containerRef: Ref<HTMLElement | undefined>) { const scene = new THREE.Scene(); const camera = new THREE.PerspectiveCamera(75, 1, 0.1, 1000); // 宽高比先设为1,后续更新 const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); let controls: OrbitControls | null = null; const isLoading = ref(false); const error = ref<string | null>(null); // 初始化函数 const init = () => { if (!containerRef.value) return; const width = containerRef.value.clientWidth; const height = containerRef.value.clientHeight; renderer.setSize(width, height); renderer.setPixelRatio(window.devicePixelRatio); containerRef.value.appendChild(renderer.domElement); camera.aspect = width / height; camera.updateProjectionMatrix(); camera.position.z = 50; // 初始相机位置,根据图纸大小调整 // 添加轨道控制器,允许鼠标拖拽旋转、滚轮缩放 controls = new OrbitControls(camera, renderer.domElement); controls.enableDamping = true; // 启用阻尼,产生更平滑的交互感 controls.dampingFactor = 0.05; // 添加基础光源 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); // 可选:添加辅助坐标系和网格 // const axesHelper = new THREE.AxesHelper(20); // scene.add(axesHelper); // const gridHelper = new THREE.GridHelper(100, 20); // scene.add(gridHelper); }; // 渲染循环 const { pause, resume } = useRafFn(() => { if (controls) controls.update(); // 更新控制器,如果启用了阻尼 renderer.render(scene, camera); }, { immediate: false }); // 先不启动,等初始化完成 // 窗口大小变化时更新相机和渲染器 const handleResize = () => { if (!containerRef.value) return; const width = containerRef.value.clientWidth; const height = containerRef.value.clientHeight; camera.aspect = width / height; camera.updateProjectionMatrix(); renderer.setSize(width, height); }; onMounted(() => { init(); window.addEventListener('resize', handleResize); resume(); // 启动渲染循环 }); onUnmounted(() => { pause(); window.removeEventListener('resize', handleResize); controls?.dispose(); renderer.dispose(); // 这里还需要清理scene中的自定义几何体和材质,后面会讲 }); // 暴露给组件使用的属性和方法 return { scene, camera, renderer, isLoading, error, loadDxfFile, // 这个方法将在下一节实现 clearScene, // 清理场景的方法 }; }

注意OrbitControls需要从three/examples/jsm/controls导入,并且需要额外注意构建工具的配置。Vite通常能处理,如果遇到问题,可能需要检查别名配置或使用three-stdlib包。

4. DXF文件加载、解析与渲染全流程

4.1 文件上传与二进制数据读取

在Vue组件中,我们通过一个<input type="file">元素来让用户选择DXF文件。关键是要读取文件的ArrayBuffer,因为dxf-parser的二进制解析接口需要它。

<!-- components/DxfViewer.vue 部分代码 --> <template> <div class="dxf-viewer"> <div class="toolbar"> <input type="file" accept=".dxf" @change="handleFileUpload" :disabled="isLoading" /> <button @click="fitToView">适应视图</button> <span v-if="isLoading">加载中...</span> <span v-if="error" class="error">{{ error }}</span> </div> <div ref="containerRef" class="viewer-container"></div> </div> </template> <script setup lang="ts"> import { ref } from 'vue'; import { useThreeDxf } from '../composables/useThreeDxf'; const containerRef = ref<HTMLElement>(); const { scene, isLoading, error, loadDxfFile, clearScene } = useThreeDxf(containerRef); const handleFileUpload = async (event: Event) => { const input = event.target as HTMLInputElement; if (!input.files || input.files.length === 0) return; const file = input.files[0]; // 重置错误和场景 error.value = null; clearScene(); isLoading.value = true; try { // 读取文件为ArrayBuffer const arrayBuffer = await file.arrayBuffer(); await loadDxfFile(arrayBuffer, file.name); } catch (err) { error.value = `加载失败: ${err instanceof Error ? err.message : '未知错误'}`; console.error('DXF加载错误:', err); } finally { isLoading.value = false; } }; const fitToView = () => { // 实现视图适应功能,后面会讲 }; </script>

4.2 核心解析与转换逻辑的实现

这是整个流程的心脏部分。我们在useThreeDxf组合函数中实现loadDxfFile方法。

// composables/useThreeDxf.ts (续) import { parseString, parseBinary } from 'dxf-parser'; import * as ThreeDxf from 'three-dxf'; export function useThreeDxf(containerRef: Ref<HTMLElement | undefined>) { // ... 之前的场景初始化代码 ... const loadDxfFile = async (arrayBuffer: ArrayBuffer, fileName: string) => { isLoading.value = true; error.value = null; try { let dxfData; // 尝试判断是二进制还是ASCII格式。简单判断:二进制DXF前几个字节通常是特定值。 // 更稳健的方法是尝试解析二进制,失败再尝试ASCII。 const firstBytes = new Uint8Array(arrayBuffer.slice(0, 6)); const isLikelyBinary = firstBytes[0] === 0x41 && firstBytes[1] === 0x75; // "AutoCAD Binary DXF" 的常见开头 if (isLikelyBinary) { // 二进制解析 dxfData = parseBinary(new Uint8Array(arrayBuffer)); } else { // ASCII解析:需要将ArrayBuffer转为字符串 const decoder = new TextDecoder('utf-8'); const dxfText = decoder.decode(arrayBuffer); dxfData = parseString(dxfText); } if (!dxfData || !dxfData.entities || dxfData.entities.length === 0) { throw new Error('DXF文件解析成功,但未找到任何实体。文件可能为空或格式不支持。'); } // 使用three-dxf进行转换 // 注意:three-dxf的viewer.createScene返回的是一个Object3D (通常是Group) const viewer = new ThreeDxf.Viewer(dxfData, { // 这里可以传入一些可选参数,比如字体文件路径(用于处理TEXT实体) // font: '/fonts/helvetiker_regular.typeface.json', }); const dxfScene = viewer.scene; // 这就是转换后的Three.js Group if (!dxfScene) { throw new Error('无法将DXF数据转换为Three.js场景。'); } // 将转换后的场景添加到我们的主场景中 scene.add(dxfScene); // **关键步骤:自动调整视图,让整个图纸在视野内** fitCameraToObject(dxfScene); } catch (err) { error.value = `处理DXF文件时出错: ${err instanceof Error ? err.message : '未知错误'}`; console.error('DXF处理错误:', err); // 清理可能已添加到场景中的部分对象 clearScene(); } finally { isLoading.value = false; } }; // 调整相机和控制器,使目标物体完整显示在视图中 const fitCameraToObject = (object: THREE.Object3D) => { if (!camera || !controls || !object) return; const boundingBox = new THREE.Box3().setFromObject(object); const center = boundingBox.getCenter(new THREE.Vector3()); const size = boundingBox.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 / Math.sin(fov / 2)); // 加一点余量 cameraZ *= 1.5; camera.position.set(center.x, center.y, center.z + cameraZ); camera.lookAt(center); // 更新轨道控制器的目标点 controls.target.copy(center); controls.update(); // 如果图纸非常大或非常小,可以调整相机的近远裁剪面 const minZ = 0.1; const maxZ = cameraZ * 10; camera.near = minZ; camera.far = maxZ; camera.updateProjectionMatrix(); }; // 清理场景中除灯光、辅助线外的所有自定义对象(即DXF图纸) const clearScene = () => { const objectsToRemove: THREE.Object3D[] = []; scene.children.forEach(child => { // 保留灯光、相机、控制器对象(如果有)、辅助对象(如AxesHelper) if (!(child instanceof THREE.Light) && !(child instanceof THREE.Camera) && child.type !== 'AxesHelper' && child.type !== 'GridHelper') { objectsToRemove.push(child); } }); objectsToRemove.forEach(obj => { // 递归清理几何体和材质,防止内存泄漏 if (obj instanceof THREE.Mesh) { obj.geometry?.dispose(); if (Array.isArray(obj.material)) { obj.material.forEach(m => m.dispose()); } else { obj.material?.dispose(); } } scene.remove(obj); }); }; // ... 返回暴露的属性和方法 ... return { // ... 其他属性 ... loadDxfFile, clearScene, fitCameraToObject, // 也暴露出去,供组件调用 }; }

4.3 视图适配与交互优化

现在,组件中的fitToView函数就可以直接调用了:

// components/DxfViewer.vue (续) const fitToView = () => { // 假设我们将转换后的DXF场景对象保存在一个ref中,或者在useThreeDxf中暴露一个获取方法。 // 这里为了简化,我们假设useThreeDxf内部记录了最后一个加载的dxfScene。 // 实际实现时,你可能需要在useThreeDxf内部维护一个currentDxfObject的ref。 // 例如:在loadDxfFile成功后,将其赋值给一个内部的currentDxfObjectRef。 // 然后fitToView调用 fitCameraToObject(currentDxfObjectRef.value) console.log('适应视图功能需要关联到具体的DXF对象'); };

更完善的实现是在useThreeDxf内部维护一个对当前加载的DXF场景对象的引用:

// composables/useThreeDxf.ts const currentDxfObject = ref<THREE.Object3D | null>(null); const loadDxfFile = async (arrayBuffer: ArrayBuffer, fileName: string) => { // ... 之前的解析和转换代码 ... const dxfScene = viewer.scene; scene.add(dxfScene); currentDxfObject.value = dxfScene; // 保存引用 fitCameraToObject(dxfScene); // ... }; const fitToView = () => { if (currentDxfObject.value) { fitCameraToObject(currentDxfObject.value); } }; return { // ... fitToView, // 暴露这个方法 }

5. 高级特性实现与性能优化

5.1 图层控制与实体拾取

一个实用的DXF查看器,除了显示,还需要能控制图层可见性和选中实体。

图层控制dxf-parser解析出的数据中,每个实体都有一个layer属性。我们可以在转换时,根据图层将实体分组。

// 在loadDxfFile的转换部分之后,可以添加图层处理逻辑 const layerMap = new Map<string, THREE.Object3D>(); dxfScene.traverse((child) => { if (child.userData && child.userData.layer) { const layerName = child.userData.layer; if (!layerMap.has(layerName)) { const layerGroup = new THREE.Group(); layerGroup.name = `layer-${layerName}`; scene.add(layerGroup); layerMap.set(layerName, layerGroup); } const parent = child.parent; if (parent) parent.remove(child); layerMap.get(layerName)!.add(child); } }); // 现在你可以通过控制layerGroup.visible来开关图层 const toggleLayer = (layerName: string, visible: boolean) => { const group = layerMap.get(layerName); if (group) group.visible = visible; };

实体拾取(Raycasting):通过鼠标点击选中场景中的物体。

// 在useThreeDxf中 import { Raycaster, Vector2 } from 'three'; const raycaster = new Raycaster(); const mouse = new Vector2(); const selectedObject = ref<THREE.Object3D | null>(null); const handleCanvasClick = (event: MouseEvent) => { if (!containerRef.value || !camera) return; const rect = containerRef.value.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); // 只检测是Mesh或Line的对象,忽略灯光、辅助线等 const intersectableObjects: THREE.Object3D[] = []; scene.traverse(obj => { if (obj instanceof THREE.Mesh || obj instanceof THREE.Line) { intersectableObjects.push(obj); } }); const intersects = raycaster.intersectObjects(intersectableObjects); if (intersects.length > 0) { const selected = intersects[0].object; selectedObject.value = selected; // 高亮选中的物体:例如改变其材质颜色 // 注意:先保存原始材质,取消选中时恢复 console.log('选中实体:', selected.userData); // userData里可能保存了DXF原始信息 } else { selectedObject.value = null; // 取消所有高亮 } }; // 在初始化时绑定点击事件 onMounted(() => { if (containerRef.value) { containerRef.value.addEventListener('click', handleCanvasClick); } }); onUnmounted(() => { if (containerRef.value) { containerRef.value.removeEventListener('click', handleCanvasClick); } });

5.2 处理复杂实体与性能瓶颈

three-dxf默认支持大部分常见实体,如直线、圆、多段线、文字等。但对于非常复杂的实体(如SPLINE样条曲线)或巨大的图纸(数十万个实体),可能会遇到性能问题或显示不全。

  • 复杂实体支持:检查three-dxf的源码和文档,看是否支持你需要的实体类型。如果不支持,你可能需要修改或扩展它的entityHandlers。这是一个进阶话题,需要你对DXF组码和Three.js几何体有较深理解。
  • 性能优化
    1. 按需渲染与细节层次(LOD):对于超大图纸,可以考虑只渲染当前视图范围内的部分,或者为远离相机的物体使用简化模型。
    2. 合并几何体:将大量材质相同的小线段(如LWPOLYLINE)合并成一个BufferGeometry,可以大幅减少draw call。Three.js的BufferGeometryUtils.mergeBufferGeometries可以帮到你。但要注意,合并后无法单独控制每个实体的颜色或可见性。
    3. Web Worker:将DXF解析和转换这个可能耗时的任务放到Web Worker中,避免阻塞主线程导致页面卡顿。
    4. 虚拟滚动/分块加载:如果是2D平面图且实体按坐标分布,可以借鉴列表虚拟滚动的思想,只渲染视口内的区域。

5.3 样式自定义与导出

默认的three-dxf转换可能颜色和线宽不符合你的需求。你可以通过修改其源码或在其创建材质时传入自定义参数来调整。

// 查看three-dxf源码,找到创建材质的地方(通常在entityHandlers里) // 你可以fork该库,或者在其初始化Viewer时传入自定义的材质生成函数。 // 例如,让所有线框以白色显示: const viewer = new ThreeDxf.Viewer(dxfData, { // 假设three-dxf支持这样的配置(需要查文档或源码) getLineMaterial: (color) => new THREE.LineBasicMaterial({ color: 0xffffff, linewidth: 1 }), });

导出图像:利用Three.js的renderer.domElement可以很方便地导出图片。

const exportImage = (format: 'png' | 'jpeg' = 'png') => { const dataUrl = renderer.domElement.toDataURL(`image/${format}`); const link = document.createElement('a'); link.href = dataUrl; link.download = `dxf-export.${format}`; link.click(); };

6. 常见问题排查与实战心得

6.1 典型错误与解决方案

问题现象可能原因解决方案
页面空白,控制台无报错1. Three.js渲染器DOM未正确添加到容器。
2. 相机位置不对,物体在视野外。
3. 场景中没有光源,物体为黑色。
1. 检查containerRef.value.appendChild(renderer.domElement)是否执行。
2. 调用fitCameraToObject确保物体在视野内。
3. 确保场景中添加了AmbientLightDirectionalLight
加载DXF后报错,如“Cannot read property 'x' of undefined”1. DXF文件格式损坏或不标准。
2.three-dxf不支持该文件中的某些实体类型。
1. 用专业的CAD软件(如AutoCAD, LibreCAD)打开并重新保存该DXF文件,确保格式正确。
2. 尝试在parseStringparseBinary时捕获更详细的错误,或使用try-catch包裹转换过程。可以暂时忽略不支持的实体。
页面卡顿,滚动缩放不流畅1. DXF文件实体数量过多(>10万)。
2. 渲染循环未正确暂停/销毁,存在多个循环。
3. 几何体未合并,draw call过高。
1. 实施性能优化策略,如几何体合并、Web Worker解析。
2. 确保onUnmounted中正确调用了renderer.dispose()controls.dispose(),并停止了渲染循环(pause())。
3. 使用浏览器开发者工具的Performance面板分析瓶颈。
文字(TEXT)显示为方框或乱码three-dxf默认使用的字体文件缺失或不包含所需字符。1. 在Viewer选项中指定一个正确的字体文件路径(需是Three.js支持的typeface.json格式)。
2. 如果不需要显示文字,可以在转换前过滤掉TEXT实体。
线宽(Lineweight)不显示WebGL对LineBasicMateriallinewidth支持有限,且大多数浏览器仅支持宽度为1。1. 接受现实,WebGL中实现完美线宽很困难。
2. 对于需要强调的宽线,可以考虑用拉伸的矩形(Mesh)来模拟。
内存占用持续增长,切换文件后卡死未正确释放前一个文件的几何体和材质资源。确保在加载新文件前或组件卸载时,调用clearScene函数,并遍历所有自定义对象,对其geometrymaterial调用.dispose()方法。

6.2 实操心得与技巧

  1. 从简单文件开始测试:不要一开始就用复杂的机械图纸。找一个只有几条直线和圆的简单DXF文件,确保基础流程跑通。
  2. 善用Three.js的辅助工具:在开发阶段,强烈建议将AxesHelper(坐标轴)和GridHelper(网格)添加到场景中。它们能帮你快速判断物体的位置、大小和方向。
  3. 注意坐标系统:CAD中的坐标(尤其是3D)可能与Three.js的默认坐标系(Y向上)不同。如果发现图纸方向不对,可能需要在加载后对dxfScene应用一个旋转(例如dxfScene.rotation.x = -Math.PI / 2;)。
  4. 类型安全:使用TypeScript虽然初期配置稍麻烦,但在使用Three.js和这些第三方库时,能极大减少因属性名拼写错误或类型不匹配导致的bug。对于没有类型的库,可以在项目根目录创建一个types文件夹,添加.d.ts声明文件。
  5. 异步处理与用户体验:解析大型DXF文件可能需要几秒甚至更长时间。一定要提供加载状态提示(isLoading),并做好错误处理,给用户明确的反馈。
  6. 移动端适配OrbitControls在触摸屏上的体验可能不佳。可以考虑引入TransformControls进行更精细的操控,或者为移动端定制一套手势交互方案。
  7. 版本锁定threedxf-parserthree-dxf的版本兼容性需要注意。在package.json中锁定你测试通过的版本,避免未来升级带来意外问题。

dxf-parserthree-dxf集成到Vue3项目中,核心在于理解数据流的转换(File -> ArrayBuffer -> DXF Data -> Three.js Object)和Vue3的响应式生命周期管理。解决了基础显示问题后,图层控制、实体拾取、性能优化这些高级功能才是让产品变得好用的关键。这个过程会遇到不少细节上的挑战,比如内存管理、性能瓶颈、特定实体支持等,但每解决一个,你对Web端CAD可视化的理解就会更深一层。

返回列表