第39节:3D打印输出:模型导出准备
第39节:3D打印输出:模型导出准备
概述
3D打印技术正在快速发展,从原型制作到最终产品制造,3D打印的应用范围不断扩大。本节将深入探讨如何将Three.js中创建的3D模型转换为适合3D打印的格式,涵盖几何体拓扑检查、模型修复、格式导出等关键技术环节。

3D打印输出工作流程:
核心原理深度解析
3D打印模型要求
| 要求类型 | 技术标准 | 问题影响 | 解决方案 |
|---|---|---|---|
| 流形几何 | 每个边必须属于两个面 | 非流形几何无法切片 | 边缘修复算法 |
| 面法线 | 所有法线朝外 | 内部表面导致打印错误 | 法线统一化 |
| 壁厚 | 最小厚度 > 喷嘴直径 | 薄壁无法打印 | 壳体检测与加厚 |
| 水密性 | 完全封闭的网格 | 开放网格无法生成填充 | 孔洞检测与填充 |
文件格式对比
| 格式类型 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| STL | 广泛支持、简单 | 无颜色信息、文件大 | FDM打印、快速原型 |
| OBJ | 支持颜色纹理 | 文件结构复杂 | 彩色打印、展示模型 |
| 3MF | 现代格式、多材料 | 支持度有限 | 高级应用、多材料打印 |
| AMF | 支持晶格结构 | 工具链不成熟 | 专业级应用 |
完整代码实现
3D打印准备系统
<template><div class="print-preparation-container"><!-- 主视图区域 --><div class="main-view"><!-- 3D预览画布 --><canvas ref="previewCanvas" class="preview-canvas"></canvas><!-- 模型信息面板 --><div class="model-info-panel"><h3>📊 模型信息</h3><div class="info-grid"><div class="info-item"><span class="label">顶点数:</span><span class="value">{{ vertexCount.toLocaleString() }}</span></div><div class="info-item"><span class="label">面片数:</span><span class="value">{{ faceCount.toLocaleString() }}</span></div><div class="info-item"><span class="label">尺寸:</span><span class="value">{{ modelDimensions }}</span></div><div class="info-item"><span class="label">体积:</span><span class="value">{{ modelVolume }} cm³</span></div><div class="info-item"><span class="label">表面积:</span><span class="value">{{ surfaceArea }} cm²</span></div></div></div></div><!-- 控制面板 --><div class="control-panel"><div class="panel-section"><h3>🛠️ 模型检查</h3><div class="check-list"><div class="check-item" :class="{ passed: checks.manifold }"><span class="check-icon">{{ checks.manifold ? '✅' : '❌' }}</span><span class="check-text">流形几何</span><button v-if="!checks.manifold" @click="fixManifold" class="fix-btn">修复</button></div><div class="check-item" :class="{ passed: checks.normals }"><span class="check-icon">{{ checks.normals ? '✅' : '❌' }}</span><span class="check-text">法线一致性</span><button v-if="!checks.normals" @click="fixNormals" class="fix-btn">修复</button></div><div class="check-item" :class="{ passed: checks.watertight }"><span class="check-icon">{{ checks.watertight ? '✅' : '❌' }}</span><span class="check-text">水密性</span><button v-if="!checks.watertight" @click="fixWatertight" class="fix-btn">修复</button></div><div class="check-item" :class="{ passed: checks.thickness }"><span class="check-icon">{{ checks.thickness ? '✅' : '❌' }}</span><span class="check-text">壁厚检查</span><button v-if="!checks.thickness" @click="fixThickness" class="fix-btn">修复</button></div><div class="check-item" :class="{ passed: checks.overhangs }"><span class="check-icon">{{ checks.overhangs ? '✅' : '⚠️' }}</span><span class="check-text">悬垂角度</span><span class="check-value">{{ overhangAngle }}°</span></div></div><div class="check-actions"><button @click="runAllChecks" class="action-button primary">🔍 运行全部检查</button><button @click="autoFixAll" class="action-button secondary">🛠️ 自动修复所有</button></div></div><div class="panel-section"><h3>📐 尺寸调整</h3><div class="dimension-controls"><div class="dimension-group"><label>缩放比例: {{ scaleFactor }}x</label><input type="range" v-model="scaleFactor" min="0.1" max="5" step="0.1"@input="updateScale"></div><div class="dimension-inputs"><div class="dimension-input"><label>宽度 (mm)</label><input type="number" v-model="targetWidth" @change="updateDimensions"></div><div class="dimension-input"><label>高度 (mm)</label><input type="number" v-model="targetHeight" @change="updateDimensions"></div><div class="dimension-input"><label>深度 (mm)</label><input type="number" v-model="targetDepth" @change="updateDimensions"></div></div><div class="unit-controls"><label>单位:</label><select v-model="currentUnit" @change="updateUnits"><option value="mm">毫米 (mm)</option><option value="cm">厘米 (cm)</option><option value="inch">英寸 (inch)</option></select></div></div></div><div class="panel-section"><h3>🎯 打印设置</h3><div class="print-settings"><div class="setting-group"><label>层高 (mm)</label><input type="number" v-model="layerHeight" min="0.05" max="0.3" step="0.05"></div><div class="setting-group"><label>填充密度</label><input type="range" v-model="infillDensity" min="0" max="100" step="5"><span>{{ infillDensity }}%</span></div><div class="setting-group"><label>支撑结构</label><select v-model="supportType"><option value="none">无支撑</option><option value="touching_buildplate">仅接触平台</option><option value="everywhere">全部支撑</option></select></div><div class="setting-group"><label>打印材料</label><select v-model="materialType"><option value="pla">PLA</option><option value="abs">ABS</option><option value="petg">PETG</option><option value="tpu">TPU(柔性)</option><option value="resin">树脂</option></select></div></div></div><div class="panel-section"><h3>💾 导出选项</h3><div class="export-options"><div class="format-selection"><label class="format-option"><input type="radio" v-model="exportFormat" value="stl"><span class="radio-label">STL格式</span><span class="format-desc">标准3D打印格式</span></label><label class="format-option"><input type="radio" v-model="exportFormat" value="obj"><span class="radio-label">OBJ格式</span><span class="format-desc">包含颜色信息</span></label><label class="format-option"><input type="radio" v-model="exportFormat" value="3mf"><span class="radio-label">3MF格式</span><span class="format-desc">现代多材料格式</span></label></div><div class="export-settings"><div class="export-setting"><label><input type="checkbox" v-model="exportBinary">二进制格式(文件更小)</label></div><div class="export-setting"><label><input type="checkbox" v-model="includeColors">包含颜色信息</label></div><div class="export-setting"><label><input type="checkbox" v-model="exportUnits">包含单位信息</label></div></div><div class="export-actions"><button @click="generatePreview" class="action-button">👁️ 生成预览</button><button @click="exportModel" class="action-button primary" :disabled="!allChecksPassed">💾 导出模型</button></div></div></div><div class="panel-section"><h3>📈 打印估算</h3><div class="print-estimation"><div class="estimation-item"><span class="est-label">打印时间:</span><span class="est-value">{{ estimatedTime }}</span></div><div class="estimation-item"><span class="est-label">材料用量:</span><span class="est-value">{{ materialUsage }}g</span></div><div class="estimation-item"><span class="est-label">打印成本:</span><span class="est-value">¥{{ printCost }}</span></div><div class="estimation-item"><span class="est-label">层数:</span><span class="est-value">{{ layerCount }}</span></div></div></div></div><!-- 预览模态框 --><div v-if="showPreview" class="preview-modal"><div class="preview-content"><div class="preview-header"><h3>导出预览 - {{ exportFormat.toUpperCase() }}</h3><button @click="showPreview = false" class="close-button">×</button></div><div class="preview-body"><div class="preview-stats"><div class="preview-stat"><span>文件大小:</span><span>{{ previewStats.fileSize }}</span></div><div class="preview-stat"><span>三角面片:</span><span>{{ previewStats.triangles }}</span></div><div class="preview-stat"><span>顶点数:</span><span>{{ previewStats.vertices }}</span></div></div><div class="preview-visual"><canvas ref="previewExportCanvas" class="preview-export-canvas"></canvas></div><div class="preview-actions"><button @click="downloadModel" class="action-button primary">⬇️ 下载文件</button><button @click="showPreview = false" class="action-button">取消</button></div></div></div></div><!-- 加载状态 --><div v-if="isProcessing" class="processing-overlay"><div class="processing-spinner"><div class="spinner"></div><h3>{{ processingMessage }}</h3><div class="progress-bar"><div class="progress" :style="{ width: processingProgress + '%' }"></div></div></div></div></div>
</template><script>
import { onMounted, onUnmounted, ref, reactive, computed } from 'vue';
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { STLExporter } from 'three/addons/exporters/STLExporter.js';
import { OBJExporter } from 'three/addons/exporters/OBJExporter.js';// 3D打印准备器
class PrintPreparationSystem {constructor(renderer, scene, camera) {this.renderer = renderer;this.scene = scene;this.camera = camera;this.currentModel = null;this.originalGeometry = null;this.modifiedGeometry = null;this.analysisResults = {};this.repairHistory = [];this.initScene();}// 初始化场景initScene() {// 创建参考网格this.createReferenceGrid();// 创建测量工具this.createMeasurementTools();// 设置灯光this.setupLighting();}// 创建参考网格createReferenceGrid() {const gridHelper = new THREE.GridHelper(100, 10, 0x444444, 0x222222);gridHelper.position.y = -0.01;this.scene.add(gridHelper);// 创建坐标轴const axesHelper = new THREE.AxesHelper(20);this.scene.add(axesHelper);}// 创建测量工具createMeasurementTools() {// 边界框显示this.boundingBox = new THREE.Box3Helper(new THREE.Box3(), 0xffff00);this.boundingBox.visible = false;this.scene.add(this.boundingBox);// 尺寸标注线this.dimensionLines = new THREE.Group();this.scene.add(this.dimensionLines);}// 设置灯光setupLighting() {const ambientLight = new THREE.AmbientLight(0x404040, 0.6);this.scene.add(ambientLight);const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8);directionalLight.position.set(50, 50, 25);directionalLight.castShadow = true;this.scene.add(directionalLight);}// 加载模型loadModel(geometry, material = null) {// 清理旧模型if (this.currentModel) {this.scene.remove(this.currentModel);}this.originalGeometry = geometry.clone();this.modifiedGeometry = geometry.clone();// 创建材质const defaultMaterial = material || new THREE.MeshStandardMaterial({color: 0x4CAF50,transparent: true,opacity: 0.8,side: THREE.DoubleSide});this.currentModel = new THREE.Mesh(this.modifiedGeometry, defaultMaterial);this.currentModel.castShadow = true;this.currentModel.receiveShadow = true;this.scene.add(this.currentModel);// 更新边界框this.updateBoundingBox();// 运行初始检查this.runInitialChecks();return this.currentModel;}// 更新边界框updateBoundingBox() {if (!this.currentModel) return;const box = new THREE.Box3().setFromObject(this.currentModel);this.boundingBox.box = box;this.boundingBox.visible = true;// 更新尺寸标注this.updateDimensionLines(box);}// 更新尺寸标注updateDimensionLines(box) {// 清理旧标注this.dimensionLines.clear();const size = box.getSize(new THREE.Vector3());const min = box.min;const max = box.max;// 创建尺寸线this.createDimensionLine(new THREE.Vector3(min.x, min.y - 5, min.z),new THREE.Vector3(max.x, min.y - 5, min.z),`宽度: ${size.x.toFixed(2)}mm`);this.createDimensionLine(new THREE.Vector3(max.x + 5, min.y, min.z),new THREE.Vector3(max.x + 5, max.y, min.z),`高度: ${size.y.toFixed(2)}mm`);this.createDimensionLine(new THREE.Vector3(min.x, min.y - 10, min.z),new THREE.Vector3(min.x, min.y - 10, max.z),`深度: ${size.z.toFixed(2)}mm`);}// 创建尺寸线createDimensionLine(start, end, label) {const lineGeometry = new THREE.BufferGeometry().setFromPoints([start, end]);const lineMaterial = new THREE.LineBasicMaterial({ color: 0xff0000 });const line = new THREE.Line(lineGeometry, lineMaterial);this.dimensionLines.add(line);// 这里可以添加文字标签(简化实现)console.log(label);}// 运行初始检查async runInitialChecks() {this.analysisResults = {manifold: await this.checkManifold(),normals: await this.checkNormals(),watertight: await this.checkWatertight(),thickness: await this.checkWallThickness(),overhangs: await this.checkOverhangs()};return this.analysisResults;}// 检查流形几何async checkManifold() {if (!this.modifiedGeometry) return false;// 简化实现:检查非流形边const geometry = this.modifiedGeometry;const position = geometry.attributes.position;const index = geometry.index;if (!index) return true; // 无索引几何体默认通过const edgeCount = new Map();// 统计每条边的使用次数for (let i = 0; i < index.count; i += 3) {const a = index.getX(i);const b = index.getX(i + 1);const c = index.getX(i + 2);this.countEdge(edgeCount, a, b);this.countEdge(edgeCount, b, c);this.countEdge(edgeCount, c, a);}// 检查是否有边只被一个面使用for (const count of edgeCount.values()) {if (count === 1) {return false; // 找到非流形边}}return true;}// 统计边使用次数countEdge(edgeCount, i1, i2) {const key = i1 < i2 ? `${i1}-${i2}` : `${i2}-${i1}`;edgeCount.set(key, (edgeCount.get(key) || 0) + 1);}// 检查法线一致性async checkNormals() {if (!this.modifiedGeometry) return false;// 简化实现:检查法线是否存在且长度合理const geometry = this.modifiedGeometry;if (!geometry.attributes.normal) {geometry.computeVertexNormals();}const normals = geometry.attributes.normal;for (let i = 0; i < normals.count; i++) {const nx = normals.getX(i);const ny = normals.getY(i);const nz = normals.getZ(i);const length = Math.sqrt(nx * nx + ny * ny + nz * nz);if (length < 0.9 || length > 1.1) {return false; // 法线长度异常}}return true;}// 检查水密性async checkWatertight() {if (!this.modifiedGeometry) return false;// 简化实现:检查边界边const geometry = this.modifiedGeometry;const position = geometry.attributes.position;const index = geometry.index;if (!index) return false;const edgeCount = new Map();// 统计每条边的使用次数for (let i = 0; i < index.count; i += 3) {const a = index.getX(i);const b = index.getX(i + 1);const c = index.getX(i + 2);this.countEdge(edgeCount, a, b);this.countEdge(edgeCount, b, c);this.countEdge(edgeCount, c, a);}// 查找边界边(只被一个面使用)let boundaryEdges = 0;for (const count of edgeCount.values()) {if (count === 1) {boundaryEdges++;}}return boundaryEdges === 0; // 无边界边即为水密}// 检查壁厚async checkWallThickness() {if (!this.modifiedGeometry) return false;// 简化实现:使用射线投射检查壁厚const geometry = this.modifiedGeometry;const position = geometry.attributes.position;// 这里应该实现更精确的壁厚检测// 简化:假设模型有合理厚度return true;}// 检查悬垂角度async checkOverhangs() {if (!this.modifiedGeometry) return false;const geometry = this.modifiedGeometry;geometry.computeVertexNormals();const normals = geometry.attributes.normal;let maxOverhang = 0;// 计算最大悬垂角度for (let i = 0; i < normals.count; i++) {const ny = normals.getY(i);const angle = Math.acos(Math.abs(ny)) * (180 / Math.PI);maxOverhang = Math.max(maxOverhang, angle);}return {maxAngle: maxOverhang,needsSupport: maxOverhang > 45 // 超过45度需要支撑};}// 修复流形问题async fixManifold() {this.processingMessage = '修复流形几何...';// 简化实现:复制几何体并重新计算this.modifiedGeometry = this.modifiedGeometry.clone();this.modifiedGeometry.computeVertexNormals();// 更新模型this.currentModel.geometry = this.modifiedGeometry;// 重新检查this.analysisResults.manifold = await this.checkManifold();this.addToHistory('修复流形几何');return this.analysisResults.manifold;}// 修复法线问题async fixNormals() {this.processingMessage = '修复法线一致性...';this.modifiedGeometry.computeVertexNormals();// 统一法线方向const normals = this.modifiedGeometry.attributes.normal;for (let i = 0; i < normals.count; i++) {const nx = normals.getX(i);const ny = normals.getY(i);const nz = normals.getZ(i);const length = Math.sqrt(nx * nx + ny * ny + nz * nz);if (length > 0) {normals.setXYZ(i, nx / length, ny / length, nz / length);}}normals.needsUpdate = true;this.currentModel.geometry = this.modifiedGeometry;this.analysisResults.normals = await this.checkNormals();this.addToHistory('修复法线一致性');return this.analysisResults.normals;}// 修复水密性问题async fixWatertight() {this.processingMessage = '修复水密性...';// 简化实现:这里应该实现孔洞检测和填充算法// 实际项目中可以使用专业的网格修复库this.modifiedGeometry = this.modifiedGeometry.clone();this.currentModel.geometry = this.modifiedGeometry;this.analysisResults.watertight = await this.checkWatertight();this.addToHistory('修复水密性');return this.analysisResults.watertight;}// 修复壁厚问题async fixThickness() {this.processingMessage = '修复壁厚...';// 简化实现:应用壳体修改器// 实际项目中应该实现壳体算法this.modifiedGeometry = this.modifiedGeometry.clone();this.currentModel.geometry = this.modifiedGeometry;this.analysisResults.thickness = await this.checkWallThickness();this.addToHistory('修复壁厚');return this.analysisResults.thickness;}// 缩放模型scaleModel(factor) {if (!this.currentModel) return;this.currentModel.scale.set(factor, factor, factor);this.currentModel.updateMatrixWorld(true);this.updateBoundingBox();}// 设置精确尺寸setExactDimensions(width, height, depth) {if (!this.currentModel) return;const box = new THREE.Box3().setFromObject(this.currentModel);const currentSize = box.getSize(new THREE.Vector3());const scaleX = width / currentSize.x;const scaleY = height / currentSize.y;const scaleZ = depth / currentSize.z;this.currentModel.scale.set(scaleX, scaleY, scaleZ);this.currentModel.updateMatrixWorld(true);this.updateBoundingBox();}// 导出为STLexportSTL(binary = true) {if (!this.currentModel) return null;const exporter = new STLExporter();const result = exporter.parse(this.currentModel, { binary });return result;}// 导出为OBJexportOBJ() {if (!this.currentModel) return null;const exporter = new OBJExporter();const result = exporter.parse(this.currentModel);return result;}// 导出为3MFexport3MF() {// 简化实现:Three.js没有内置3MF导出器// 实际项目中可以使用第三方库console.log('3MF导出需要额外库支持');return null;}// 添加到修复历史addToHistory(action) {this.repairHistory.push({action,timestamp: new Date(),geometry: this.modifiedGeometry.clone()});}// 撤销操作undo() {if (this.repairHistory.length > 1) {this.repairHistory.pop(); // 移除当前状态const previous = this.repairHistory[this.repairHistory.length - 1];this.modifiedGeometry = previous.geometry;this.currentModel.geometry = this.modifiedGeometry;this.updateBoundingBox();}}// 获取模型统计信息getModelStats() {if (!this.modifiedGeometry) return null;const geometry = this.modifiedGeometry;const position = geometry.attributes.position;const index = geometry.index;const vertices = position.count;const faces = index ? index.count / 3 : vertices / 3;// 计算边界框const box = new THREE.Box3().setFromObject(this.currentModel);const size = box.getSize(new THREE.Vector3());// 简化体积计算(实际应该使用更精确的方法)const volume = size.x * size.y * size.z;const surfaceArea = this.estimateSurfaceArea(geometry);return {vertices,faces,dimensions: size,volume,surfaceArea};}// 估算表面积estimateSurfaceArea(geometry) {let area = 0;const position = geometry.attributes.position;const index = geometry.index;if (index) {for (let i = 0; i < index.count; i += 3) {const a = index.getX(i);const b = index.getX(i + 1);const c = index.getX(i + 2);const vA = new THREE.Vector3(position.getX(a), position.getY(a), position.getZ(a));const vB = new THREE.Vector3(position.getX(b), position.getY(b), position.getZ(b));const vC = new THREE.Vector3(position.getX(c), position.getY(c), position.getZ(c));const ab = new THREE.Vector3().subVectors(vB, vA);const ac = new THREE.Vector3().subVectors(vC, vA);const cross = new THREE.Vector3().crossVectors(ab, ac);area += cross.length() * 0.5;}}return area;}// 估算打印信息estimatePrintInfo(layerHeight, infillDensity, materialType) {const stats = this.getModelStats();if (!stats) return null;const volume = stats.volume;const materialDensity = this.getMaterialDensity(materialType);// 简化计算const materialWeight = volume * materialDensity * (infillDensity / 100);const printTime = volume * 0.1; // 简化时间估算const layerCount = stats.dimensions.y / layerHeight;return {materialWeight: materialWeight * 1000, // 转换为克printTime: printTime / 60, // 转换为小时layerCount: Math.ceil(layerCount),cost: materialWeight * this.getMaterialCost(materialType)};}// 获取材料密度getMaterialDensity(materialType) {const densities = {pla: 1.24,abs: 1.04,petg: 1.27,tpu: 1.20,resin: 1.10};return densities[materialType] || 1.24;}// 获取材料成本getMaterialCost(materialType) {const costs = {pla: 0.02,abs: 0.025,petg: 0.03,tpu: 0.05,resin: 0.08};return costs[materialType] || 0.02;}
}export default {name: 'PrintPreparation',setup() {const previewCanvas = ref(null);const previewExportCanvas = ref(null);// 状态管理const vertexCount = ref(0);const faceCount = ref(0);const modelDimensions = ref('0 × 0 × 0 mm');const modelVolume = ref(0);const surfaceArea = ref(0);const checks = reactive({manifold: false,normals: false,watertight: false,thickness: false,overhangs: false});const overhangAngle = ref(0);const scaleFactor = ref(1.0);const targetWidth = ref(100);const targetHeight = ref(100);const targetDepth = ref(100);const currentUnit = ref('mm');const layerHeight = ref(0.2);const infillDensity = ref(20);const supportType = ref('none');const materialType = ref('pla');const exportFormat = ref('stl');const exportBinary = ref(true);const includeColors = ref(false);const exportUnits = ref(true);const estimatedTime = ref('0h 0m');const materialUsage = ref(0);const printCost = ref(0);const layerCount = ref(0);const showPreview = ref(false);const previewStats = reactive({fileSize: '0 KB',triangles: 0,vertices: 0});const isProcessing = ref(false);const processingMessage = ref('');const processingProgress = ref(0);let scene, camera, renderer, controls;let preparationSystem;let sampleModel;// 初始化场景const initScene = async () => {// 创建场景scene = new THREE.Scene();scene.background = new THREE.Color(0xf0f0f0);// 创建相机camera = new THREE.PerspectiveCamera(75,previewCanvas.value.clientWidth / previewCanvas.value.clientHeight,0.1,1000);camera.position.set(50, 50, 50);// 创建渲染器renderer = new THREE.WebGLRenderer({canvas: previewCanvas.value,antialias: true});renderer.setSize(previewCanvas.value.clientWidth, previewCanvas.value.clientHeight);renderer.shadowMap.enabled = true;// 添加控制器controls = new OrbitControls(camera, renderer.domElement);controls.enableDamping = true;// 初始化打印准备系统preparationSystem = new PrintPreparationSystem(renderer, scene, camera);// 创建示例模型await createSampleModel();// 启动动画循环animate();};// 创建示例模型const createSampleModel = async () => {// 创建一个有问题的模型用于演示修复const geometry = new THREE.TorusKnotGeometry(10, 3, 100, 16);// 故意制造一些问题const positions = geometry.attributes.position.array;for (let i = 0; i < positions.length; i += 9) {// 随机移除一些面来制造非流形几何if (Math.random() < 0.05) {positions[i] = positions[i + 1] = positions[i + 2] = 0;positions[i + 3] = positions[i + 4] = positions[i + 5] = 0;positions[i + 6] = positions[i + 7] = positions[i + 8] = 0;}}sampleModel = preparationSystem.loadModel(geometry);updateModelStats();};// 更新模型统计const updateModelStats = () => {const stats = preparationSystem.getModelStats();if (!stats) return;vertexCount.value = stats.vertices;faceCount.value = stats.faces;modelDimensions.value = `${stats.dimensions.x.toFixed(1)} × ${stats.dimensions.y.toFixed(1)} × ${stats.dimensions.z.toFixed(1)} ${currentUnit.value}`;modelVolume.value = stats.volume.toFixed(1);surfaceArea.value = stats.surfaceArea.toFixed(1);updatePrintEstimation();};// 更新打印估算const updatePrintEstimation = () => {const printInfo = preparationSystem.estimatePrintInfo(layerHeight.value,infillDensity.value,materialType.value);if (printInfo) {materialUsage.value = printInfo.materialWeight.toFixed(1);printCost.value = printInfo.cost.toFixed(2);layerCount.value = printInfo.layerCount;const hours = Math.floor(printInfo.printTime);const minutes = Math.round((printInfo.printTime - hours) * 60);estimatedTime.value = `${hours}h ${minutes}m`;}};// 运行动画循环const animate = () => {requestAnimationFrame(animate);controls.update();renderer.render(scene, camera);};// 运行全部检查const runAllChecks = async () => {isProcessing.value = true;processingMessage.value = '运行模型检查...';const results = await preparationSystem.runInitialChecks();Object.assign(checks, results);const overhangInfo = await preparationSystem.checkOverhangs();overhangAngle.value = overhangInfo.maxAngle.toFixed(1);checks.overhangs = !overhangInfo.needsSupport;isProcessing.value = false;};// 自动修复所有问题const autoFixAll = async () => {isProcessing.value = true;if (!checks.manifold) {processingMessage.value = '修复流形几何...';await preparationSystem.fixManifold();}if (!checks.normals) {processingMessage.value = '修复法线...';await preparationSystem.fixNormals();}if (!checks.watertight) {processingMessage.value = '修复水密性...';await preparationSystem.fixWatertight();}if (!checks.thickness) {processingMessage.value = '修复壁厚...';await preparationSystem.fixThickness();}await runAllChecks();updateModelStats();isProcessing.value = false;};// 修复方法const fixManifold = async () => {isProcessing.value = true;await preparationSystem.fixManifold();await runAllChecks();updateModelStats();isProcessing.value = false;};const fixNormals = async () => {isProcessing.value = true;await preparationSystem.fixNormals();await runAllChecks();updateModelStats();isProcessing.value = false;};const fixWatertight = async () => {isProcessing.value = true;await preparationSystem.fixWatertight();await runAllChecks();updateModelStats();isProcessing.value = false;};const fixThickness = async () => {isProcessing.value = true;await preparationSystem.fixThickness();await runAllChecks();updateModelStats();isProcessing.value = false;};// 更新缩放const updateScale = () => {preparationSystem.scaleModel(scaleFactor.value);updateModelStats();};// 更新尺寸const updateDimensions = () => {preparationSystem.setExactDimensions(targetWidth.value,targetHeight.value,targetDepth.value);updateModelStats();};// 更新单位const updateUnits = () => {updateModelStats();};// 生成预览const generatePreview = () => {showPreview.value = true;// 计算预览统计const stats = preparationSystem.getModelStats();if (stats) {previewStats.triangles = stats.faces;previewStats.vertices = stats.vertices;// 估算文件大小const estimatedSize = stats.vertices * 50; // 简化估算previewStats.fileSize = formatFileSize(estimatedSize);}};// 导出模型const exportModel = () => {let exportedData;let fileExtension;switch (exportFormat.value) {case 'stl':exportedData = preparationSystem.exportSTL(exportBinary.value);fileExtension = exportBinary.value ? 'stl' : 'stl';break;case 'obj':exportedData = preparationSystem.exportOBJ();fileExtension = 'obj';break;case '3mf':exportedData = preparationSystem.export3MF();fileExtension = '3mf';break;}if (exportedData) {downloadFile(exportedData, `model.${fileExtension}`, exportFormat.value);}};// 下载模型const downloadModel = () => {exportModel();showPreview.value = false;};// 下载文件const downloadFile = (data, filename, format) => {let blob;if (format === 'stl' && exportBinary.value) {blob = new Blob([data], { type: 'application/octet-stream' });} else {blob = new Blob([data], { type: 'text/plain' });}const url = URL.createObjectURL(blob);const link = document.createElement('a');link.href = url;link.download = filename;document.body.appendChild(link);link.click();document.body.removeChild(link);URL.revokeObjectURL(url);};// 格式化文件大小const formatFileSize = (bytes) => {if (bytes === 0) return '0 Bytes';const k = 1024;const sizes = ['Bytes', 'KB', 'MB', 'GB'];const i = Math.floor(Math.log(bytes) / Math.log(k));return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];};// 计算属性const allChecksPassed = computed(() => {return checks.manifold && checks.normals && checks.watertight && checks.thickness;});onMounted(() => {initScene();runAllChecks();window.addEventListener('resize', handleResize);});onUnmounted(() => {if (renderer) {renderer.dispose();}window.removeEventListener('resize', handleResize);});const handleResize = () => {if (!camera || !renderer) return;camera.aspect = previewCanvas.value.clientWidth / previewCanvas.value.clientHeight;camera.updateProjectionMatrix();renderer.setSize(previewCanvas.value.clientWidth, previewCanvas.value.clientHeight);};return {previewCanvas,previewExportCanvas,vertexCount,faceCount,modelDimensions,modelVolume,surfaceArea,checks,overhangAngle,scaleFactor,targetWidth,targetHeight,targetDepth,currentUnit,layerHeight,infillDensity,supportType,materialType,exportFormat,exportBinary,includeColors,exportUnits,estimatedTime,materialUsage,printCost,layerCount,showPreview,previewStats,isProcessing,processingMessage,processingProgress,runAllChecks,autoFixAll,fixManifold,fixNormals,fixWatertight,fixThickness,updateScale,updateDimensions,updateUnits,generatePreview,exportModel,downloadModel,allChecksPassed};}
};
</script><style scoped>
.print-preparation-container {width: 100%;height: 100vh;display: flex;background: #1a1a1a;overflow: hidden;
}.main-view {flex: 1;position: relative;display: flex;flex-direction: column;
}.preview-canvas {width: 100%;height: 100%;display: block;
}.model-info-panel {position: absolute;top: 20px;left: 20px;background: rgba(0, 0, 0, 0.8);padding: 15px;border-radius: 8px;color: white;backdrop-filter: blur(10px);border: 1px solid rgba(255, 255, 255, 0.1);min-width: 200px;
}.model-info-panel h3 {margin: 0 0 15px 0;color: #00ffff;font-size: 16px;
}.info-grid {display: flex;flex-direction: column;gap: 8px;
}.info-item {display: flex;justify-content: space-between;align-items: center;font-size: 14px;
}.info-item .label {color: #ccc;
}.info-item .value {color: #00ff88;font-weight: bold;
}.control-panel {width: 400px;background: #2d2d2d;padding: 20px;overflow-y: auto;border-left: 1px solid #444;
}.panel-section {margin-bottom: 25px;padding-bottom: 20px;border-bottom: 1px solid #444;
}.panel-section:last-child {margin-bottom: 0;border-bottom: none;
}.panel-section h3 {color: #00ffff;margin-bottom: 15px;font-size: 16px;display: flex;align-items: center;gap: 8px;
}.check-list {display: flex;flex-direction: column;gap: 10px;margin-bottom: 15px;
}.check-item {display: flex;align-items: center;gap: 10px;padding: 10px;background: rgba(255, 255, 255, 0.05);border-radius: 6px;transition: all 0.3s ease;
}.check-item.passed {background: rgba(0, 255, 136, 0.1);border: 1px solid rgba(0, 255, 136, 0.3);
}.check-icon {font-size: 16px;
}.check-text {flex: 1;color: #ccc;font-size: 14px;
}.check-value {color: #ffaa00;font-weight: bold;font-size: 14px;
}.fix-btn {padding: 4px 12px;background: #ff6b35;color: white;border: none;border-radius: 4px;cursor: pointer;font-size: 12px;transition: background 0.3s ease;
}.fix-btn:hover {background: #ff8c5a;
}.check-actions {display: flex;gap: 10px;
}.action-button {flex: 1;padding: 12px;border: none;border-radius: 6px;background: #444;color: white;cursor: pointer;font-size: 14px;transition: all 0.3s ease;display: flex;align-items: center;justify-content: center;gap: 8px;
}.action-button.primary {background: linear-gradient(45deg, #667eea, #764ba2);
}.action-button.secondary {background: linear-gradient(45deg, #f093fb, #f5576c);
}.action-button:hover {transform: translateY(-2px);box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3);
}.action-button:disabled {opacity: 0.6;cursor: not-allowed;transform: none;
}.dimension-controls {display: flex;flex-direction: column;gap: 15px;
}.dimension-group {display: flex;flex-direction: column;gap: 8px;
}.dimension-group label {color: #ccc;font-size: 14px;
}.dimension-group input[type="range"] {width: 100%;height: 6px;background: #444;border-radius: 3px;outline: none;
}.dimension-group input[type="range"]::-webkit-slider-thumb {appearance: none;width: 16px;height: 16px;border-radius: 50%;background: #00ffff;cursor: pointer;
}.dimension-inputs {display: grid;grid-template-columns: 1fr 1fr 1fr;gap: 10px;
}.dimension-input {display: flex;flex-direction: column;gap: 5px;
}.dimension-input label {color: #ccc;font-size: 12px;
}.dimension-input input {padding: 8px;background: #444;border: 1px solid #666;border-radius: 4px;color: white;font-size: 14px;
}.unit-controls {display: flex;align-items: center;gap: 10px;
}.unit-controls label {color: #ccc;font-size: 14px;
}.unit-controls select {padding: 8px;background: #444;border: 1px solid #666;border-radius: 4px;color: white;font-size: 14px;
}.print-settings {display: flex;flex-direction: column;gap: 15px;
}.setting-group {display: flex;align-items: center;justify-content: space-between;gap: 10px;
}.setting-group label {color: #ccc;font-size: 14px;min-width: 100px;
}.setting-group input[type="number"],
.setting-group select {padding: 8px;background: #444;border: 1px solid #666;border-radius: 4px;color: white;font-size: 14px;width: 120px;
}.setting-group input[type="range"] {flex: 1;height: 6px;background: #444;border-radius: 3px;outline: none;
}.setting-group input[type="range"]::-webkit-slider-thumb {appearance: none;width: 16px;height: 16px;border-radius: 50%;background: #00ffff;cursor: pointer;
}.export-options {display: flex;flex-direction: column;gap: 15px;
}.format-selection {display: flex;flex-direction: column;gap: 10px;
}.format-option {display: flex;align-items: center;gap: 10px;padding: 10px;background: rgba(255, 255, 255, 0.05);border-radius: 6px;cursor: pointer;transition: background 0.3s ease;
}.format-option:hover {background: rgba(255, 255, 255, 0.1);
}.format-option input[type="radio"] {margin: 0;
}.radio-label {flex: 1;color: #ccc;font-size: 14px;font-weight: bold;
}.format-desc {color: #888;font-size: 12px;
}.export-settings {display: flex;flex-direction: column;gap: 10px;
}.export-setting {display: flex;align-items: center;gap: 8px;
}.export-setting label {color: #ccc;font-size: 14px;display: flex;align-items: center;gap: 8px;cursor: pointer;
}.export-setting input[type="checkbox"] {margin: 0;
}.export-actions {display: flex;gap: 10px;
}.print-estimation {display: flex;flex-direction: column;gap: 10px;
}.estimation-item {display: flex;justify-content: space-between;align-items: center;padding: 8px 0;border-bottom: 1px solid #444;
}.estimation-item:last-child {border-bottom: none;
}.est-label {color: #ccc;font-size: 14px;
}.est-value {color: #00ff88;font-weight: bold;font-size: 14px;
}.preview-modal {position: fixed;top: 0;left: 0;width: 100%;height: 100%;background: rgba(0, 0, 0, 0.8);display: flex;justify-content: center;align-items: center;z-index: 1000;
}.preview-content {background: #2d2d2d;border-radius: 12px;width: 90%;max-width: 600px;max-height: 90vh;overflow: hidden;border: 1px solid #444;
}.preview-header {padding: 20px;background: #1a1a1a;display: flex;justify-content: space-between;align-items: center;border-bottom: 1px solid #444;
}.preview-header h3 {margin: 0;color: #00ffff;
}.close-button {background: none;border: none;color: #ccc;font-size: 24px;cursor: pointer;padding: 0;width: 30px;height: 30px;display: flex;align-items: center;justify-content: center;
}.close-button:hover {color: white;
}.preview-body {padding: 20px;display: flex;flex-direction: column;gap: 20px;
}.preview-stats {display: grid;grid-template-columns: 1fr 1fr 1fr;gap: 15px;
}.preview-stat {display: flex;flex-direction: column;align-items: center;gap: 5px;padding: 15px;background: rgba(255, 255, 255, 0.05);border-radius: 6px;
}.preview-stat span:first-child {color: #ccc;font-size: 12px;
}.preview-stat span:last-child {color: #00ff88;font-weight: bold;font-size: 16px;
}.preview-visual {width: 100%;height: 300px;background: #1a1a1a;border-radius: 6px;overflow: hidden;
}.preview-export-canvas {width: 100%;height: 100%;display: block;
}.preview-actions {display: flex;gap: 10px;justify-content: flex-end;
}.processing-overlay {position: fixed;top: 0;left: 0;width: 100%;height: 100%;background: rgba(0, 0, 0, 0.8);display: flex;justify-content: center;align-items: center;z-index: 1000;
}.processing-spinner {text-align: center;color: white;background: #2d2d2d;padding: 40px;border-radius: 12px;border: 1px solid #444;min-width: 300px;
}.spinner {width: 50px;height: 50px;border: 4px solid #333;border-top: 4px solid #00ffff;border-radius: 50%;animation: spin 1s linear infinite;margin: 0 auto 20px;
}.processing-spinner h3 {margin: 0 0 20px 0;color: #00ffff;
}.progress-bar {width: 100%;height: 6px;background: #444;border-radius: 3px;overflow: hidden;
}.progress {height: 100%;background: linear-gradient(90deg, #00ffff, #0088ff);border-radius: 3px;transition: width 0.3s ease;
}@keyframes spin {0% { transform: rotate(0deg); }100% { transform: rotate(360deg); }
}/* 响应式设计 */
@media (max-width: 1024px) {.print-preparation-container {flex-direction: column;}.control-panel {width: 100%;height: 400px;}.main-view {height: calc(100vh - 400px);}
}@media (max-width: 768px) {.model-info-panel {position: relative;top: auto;left: auto;margin: 10px;}.dimension-inputs {grid-template-columns: 1fr;}.preview-stats {grid-template-columns: 1fr;}
}
</style>
高级3D打印特性
自动网格修复算法
// 高级网格修复系统
class AdvancedMeshRepairSystem {constructor() {this.repairAlgorithms = new Map();this.initAlgorithms();}// 初始化修复算法initAlgorithms() {this.repairAlgorithms.set('hole_filling', this.holeFillingAlgorithm.bind(this));this.repairAlgorithms.set('normal_smoothing', this.normalSmoothingAlgorithm.bind(this));this.repairAlgorithms.set('degenerate_removal', this.degenerateRemovalAlgorithm.bind(this));this.repairAlgorithms.set('self_intersection', this.selfIntersectionAlgorithm.bind(this));}// 孔洞填充算法async holeFillingAlgorithm(geometry) {const position = geometry.attributes.position;const index = geometry.index;if (!index) {console.warn('无索引几何体,无法进行孔洞填充');return geometry;}// 查找边界边const boundaryEdges = this.findBoundaryEdges(geometry);// 对每个边界环进行三角化填充for (const boundary of boundaryEdges) {geometry = await this.triangulateBoundary(geometry, boundary);}return geometry;}// 查找边界边findBoundaryEdges(geometry) {const edgeCount = new Map();const index = geometry.index;// 统计边使用次数for (let i = 0; i < index.count; i += 3) {const a = index.getX(i);const b = index.getX(i + 1);const c = index.getX(i + 2);this.countEdge(edgeCount, a, b);this.countEdge(edgeCount, b, c);this.countEdge(edgeCount, c, a);}// 提取边界边(只被一个面使用)const boundaryEdges = [];const visitedVertices = new Set();for (const [edgeKey, count] of edgeCount.entries()) {if (count === 1) {const [v1, v2] = edgeKey.split('-').map(Number);boundaryEdges.push([v1, v2]);visitedVertices.add(v1);visitedVertices.add(v2);}}// 将边界边连接成环return this.connectBoundaryEdges(boundaryEdges);}// 连接边界边成环connectBoundaryEdges(edges) {const boundaries = [];const edgeMap = new Map();// 构建邻接表for (const [v1, v2] of edges) {if (!edgeMap.has(v1)) edgeMap.set(v1, []);if (!edgeMap.has(v2)) edgeMap.set(v2, []);edgeMap.get(v1).push(v2);edgeMap.get(v2).push(v1);}const visited = new Set();for (const startVertex of edgeMap.keys()) {if (visited.has(startVertex)) continue;const boundary = [];let currentVertex = startVertex;let previousVertex = null;// 遍历边界环do {visited.add(currentVertex);boundary.push(currentVertex);const neighbors = edgeMap.get(currentVertex);const nextVertex = neighbors.find(v => v !== previousVertex);previousVertex = currentVertex;currentVertex = nextVertex;} while (currentVertex !== startVertex && currentVertex !== undefined);if (boundary.length >= 3) {boundaries.push(boundary);}}return boundaries;}// 边界三角化async triangulateBoundary(geometry, boundary) {const position = geometry.attributes.position;const vertices = boundary.map(vIndex => {return new THREE.Vector3(position.getX(vIndex),position.getY(vIndex),position.getZ(vIndex));});// 简单三角化:耳剪法简化实现const triangles = this.earClippingTriangulation(vertices);// 添加新面到几何体const newIndices = [];for (const triangle of triangles) {newIndices.push(boundary[triangle[0]],boundary[triangle[1]], boundary[triangle[2]]);}// 创建新几何体const newGeometry = this.addFacesToGeometry(geometry, newIndices);return newGeometry;}// 耳剪法三角化earClippingTriangulation(vertices) {const triangles = [];const indices = Array.from({ length: vertices.length }, (_, i) => i);while (indices.length > 3) {let earFound = false;for (let i = 0; i < indices.length; i++) {const prev = (i === 0) ? indices.length - 1 : i - 1;const next = (i === indices.length - 1) ? 0 : i + 1;const a = indices[prev];const b = indices[i];const c = indices[next];if (this.isEar(vertices, a, b, c, indices)) {triangles.push([a, b, c]);indices.splice(i, 1);earFound = true;break;}}if (!earFound) {console.warn('无法完成三角化,使用退化三角化');break;}}if (indices.length === 3) {triangles.push(indices);}return triangles;}// 检查是否为耳朵isEar(vertices, a, b, c, polygon) {// 检查凸性const ab = new THREE.Vector3().subVectors(vertices[b], vertices[a]);const bc = new THREE.Vector3().subVectors(vertices[c], vertices[b]);const cross = new THREE.Vector3().crossVectors(ab, bc);if (cross.y <= 0) return false; // 非凸顶点// 检查是否包含其他顶点const triangle = [vertices[a], vertices[b], vertices[c]];for (let i = 0; i < polygon.length; i++) {const vertexIndex = polygon[i];if (vertexIndex !== a && vertexIndex !== b && vertexIndex !== c) {if (this.pointInTriangle(vertices[vertexIndex], triangle)) {return false;}}}return true;}// 点是否在三角形内pointInTriangle(point, triangle) {// 重心坐标法const [a, b, c] = triangle;const v0 = new THREE.Vector3().subVectors(c, a);const v1 = new THREE.Vector3().subVectors(b, a);const v2 = new THREE.Vector3().subVectors(point, a);const dot00 = v0.dot(v0);const dot01 = v0.dot(v1);const dot02 = v0.dot(v2);const dot11 = v1.dot(v1);const dot12 = v1.dot(v2);const invDenom = 1 / (dot00 * dot11 - dot01 * dot01);const u = (dot11 * dot02 - dot01 * dot12) * invDenom;const v = (dot00 * dot12 - dot01 * dot02) * invDenom;return (u >= 0) && (v >= 0) && (u + v < 1);}// 添加面到几何体addFacesToGeometry(geometry, newIndices) {const oldIndex = geometry.index;const oldPosition = geometry.attributes.position;// 创建新索引数组const totalIndices = oldIndex.count + newIndices.length;const newIndexArray = new Uint32Array(totalIndices);// 复制旧索引for (let i = 0; i < oldIndex.count; i++) {newIndexArray[i] = oldIndex.getX(i);}// 添加新索引for (let i = 0; i < newIndices.length; i++) {newIndexArray[oldIndex.count + i] = newIndices[i];}// 创建新几何体const newGeometry = new THREE.BufferGeometry();newGeometry.setAttribute('position', oldPosition);newGeometry.setIndex(new THREE.BufferAttribute(newIndexArray, 1));newGeometry.computeVertexNormals();return newGeometry;}// 法线平滑算法async normalSmoothingAlgorithm(geometry) {geometry.computeVertexNormals();const normals = geometry.attributes.normal;const position = geometry.attributes.position;const index = geometry.index;if (!index) return geometry;// 构建顶点邻接表const adjacency = this.buildVertexAdjacency(geometry);// 平滑法线const newNormals = new Float32Array(normals.array.length);for (let i = 0; i < position.count; i++) {const neighbors = adjacency.get(i) || [];const smoothNormal = new THREE.Vector3();// 平均邻接面法线for (const neighbor of neighbors) {smoothNormal.add(new THREE.Vector3(normals.getX(neighbor),normals.getY(neighbor),normals.getZ(neighbor)));}smoothNormal.normalize();newNormals[i * 3] = smoothNormal.x;newNormals[i * 3 + 1] = smoothNormal.y;newNormals[i * 3 + 2] = smoothNormal.z;}geometry.setAttribute('normal', new THREE.BufferAttribute(newNormals, 3));return geometry;}// 构建顶点邻接表buildVertexAdjacency(geometry) {const adjacency = new Map();const index = geometry.index;for (let i = 0; i < index.count; i += 3) {const a = index.getX(i);const b = index.getX(i + 1);const c = index.getX(i + 2);this.addAdjacency(adjacency, a, b);this.addAdjacency(adjacency, a, c);this.addAdjacency(adjacency, b, a);this.addAdjacency(adjacency, b, c);this.addAdjacency(adjacency, c, a);this.addAdjacency(adjacency, c, b);}return adjacency;}// 添加邻接关系addAdjacency(adjacency, v1, v2) {if (!adjacency.has(v1)) {adjacency.set(v1, new Set());}adjacency.get(v1).add(v2);}// 退化面片移除async degenerateRemovalAlgorithm(geometry) {const index = geometry.index;if (!index) return geometry;const validIndices = [];const position = geometry.attributes.position;for (let i = 0; i < index.count; i += 3) {const a = index.getX(i);const b = index.getX(i + 1);const c = index.getX(i + 2);// 检查面片是否退化(面积过小或共线)const vA = new THREE.Vector3(position.getX(a), position.getY(a), position.getZ(a));const vB = new THREE.Vector3(position.getX(b), position.getY(b), position.getZ(b));const vC = new THREE.Vector3(position.getX(c), position.getY(c), position.getZ(c));const ab = new THREE.Vector3().subVectors(vB, vA);const ac = new THREE.Vector3().subVectors(vC, vA);const cross = new THREE.Vector3().crossVectors(ab, ac);const area = cross.length() * 0.5;// 如果面积大于阈值,保留面片if (area > 0.0001) {validIndices.push(a, b, c);}}const newGeometry = new THREE.BufferGeometry();newGeometry.setAttribute('position', position);newGeometry.setIndex(new THREE.BufferAttribute(new Uint32Array(validIndices), 1));newGeometry.computeVertexNormals();return newGeometry;}// 自相交检测算法async selfIntersectionAlgorithm(geometry) {// 简化实现:使用AABB树进行粗略检测console.log('自相交检测需要更复杂的空间分割算法');return geometry;}// 统计边使用次数countEdge(edgeCount, i1, i2) {const key = i1 < i2 ? `${i1}-${i2}` : `${i2}-${i1}`;edgeCount.set(key, (edgeCount.get(key) || 0) + 1);}// 运行所有修复算法async runAllRepairs(geometry) {let repairedGeometry = geometry;for (const [name, algorithm] of this.repairAlgorithms) {console.log(`运行修复算法: ${name}`);repairedGeometry = await algorithm(repairedGeometry);}return repairedGeometry;}
}
智能支撑结构生成
// 支撑结构生成系统
class SupportStructureGenerator {constructor() {this.supportTypes = {tree: this.generateTreeSupport.bind(this),linear: this.generateLinearSupport.bind(this),lattice: this.generateLatticeSupport.bind(this)};}// 生成支撑结构generateSupport(geometry, overhangInfo, supportType = 'tree') {const generator = this.supportTypes[supportType] || this.supportTypes.tree;return generator(geometry, overhangInfo);}// 生成树状支撑generateTreeSupport(geometry, overhangInfo) {const supportGeometry = new THREE.BufferGeometry();const supportPoints = this.detectOverhangPoints(geometry, overhangInfo);const vertices = [];const indices = [];// 为每个悬垂点生成树状支撑for (const point of supportPoints) {const treeRoot = point.position.clone();const treeTop = point.position.clone();treeTop.y = 0; // 假设打印平台在y=0// 生成树干this.generateTreeTrunk(treeRoot, treeTop, vertices, indices);// 生成树枝this.generateTreeBranches(treeRoot, point.normal, vertices, indices);}supportGeometry.setAttribute('position', new THREE.BufferAttribute(new Float32Array(vertices), 3));supportGeometry.setIndex(new THREE.BufferAttribute(new Uint32Array(indices), 1));return supportGeometry;}// 检测悬垂点detectOverhangPoints(geometry, overhangInfo) {const points = [];const position = geometry.attributes.position;const normal = geometry.attributes.normal;for (let i = 0; i < position.count; i++) {const nx = normal.getX(i);const ny = normal.getY(i);const nz = normal.getZ(i);// 计算法线与垂直方向的夹角const vertical = new THREE.Vector3(0, -1, 0);const normalVec = new THREE.Vector3(nx, ny, nz);const angle = normalVec.angleTo(vertical) * (180 / Math.PI);// 如果角度超过阈值,标记为悬垂点if (angle > overhangInfo.angleThreshold) {points.push({position: new THREE.Vector3(position.getX(i),position.getY(i), position.getZ(i)),normal: normalVec,angle: angle});}}return points;}// 生成树干generateTreeTrunk(root, top, vertices, indices) {const segments = 8;const radius = 0.5;// 生成树干圆柱体for (let i = 0; i <= segments; i++) {const angle = (i / segments) * Math.PI * 2;const x = Math.cos(angle) * radius;const z = Math.sin(angle) * radius;// 底部顶点vertices.push(top.x + x, top.y, top.z + z);// 顶部顶点 vertices.push(root.x + x, root.y, root.z + z);}// 生成侧面索引const baseIndex = vertices.length / 3 - (segments + 1) * 2;for (let i = 0; i < segments; i++) {const current = baseIndex + i * 2;const next = baseIndex + ((i + 1) % segments) * 2;// 两个三角形组成一个四边形indices.push(current, current + 1, next);indices.push(next, current + 1, next + 1);}}// 生成树枝generateTreeBranches(jointPoint, normal, vertices, indices) {const branchCount = 3;const branchLength = 5;for (let i = 0; i < branchCount; i++) {const angle = (i / branchCount) * Math.PI * 2;// 计算分支方向(垂直于法线)const tangent = new THREE.Vector3();tangent.crossVectors(normal, new THREE.Vector3(0, 1, 0));tangent.normalize();const branchDir = new THREE.Vector3();branchDir.x = Math.cos(angle) * tangent.x + Math.sin(angle) * normal.x;branchDir.y = Math.cos(angle) * tangent.y + Math.sin(angle) * normal.y; branchDir.z = Math.cos(angle) * tangent.z + Math.sin(angle) * normal.z;const branchEnd = jointPoint.clone();branchEnd.add(branchDir.multiplyScalar(branchLength));// 简化分支为直线const baseIndex = vertices.length / 3;vertices.push(jointPoint.x, jointPoint.y, jointPoint.z,branchEnd.x, branchEnd.y, branchEnd.z);// 这里应该生成分支的圆柱几何体// 简化实现:只添加线段}}// 生成线性支撑generateLinearSupport(geometry, overhangInfo) {const supportGeometry = new THREE.BufferGeometry();const supportPoints = this.detectOverhangPoints(geometry, overhangInfo);const vertices = [];const indices = [];// 创建支撑柱网格const gridSize = 5;const gridSpacing = 10;for (let x = -gridSize; x <= gridSize; x++) {for (let z = -gridSize; z <= gridSize; z++) {const gridX = x * gridSpacing;const gridZ = z * gridSpacing;// 找到最近的悬垂点const nearestPoint = this.findNearestOverhangPoint(supportPoints, gridX, gridZ);if (nearestPoint && nearestPoint.distance < gridSpacing * 0.7) {this.generateSupportColumn(gridX, 0, gridZ,nearestPoint.position.y,vertices, indices);}}}supportGeometry.setAttribute('position',new THREE.BufferAttribute(new Float32Array(vertices), 3));supportGeometry.setIndex(new THREE.BufferAttribute(new Uint32Array(indices), 1));return supportGeometry;}// 查找最近的悬垂点findNearestOverhangPoint(points, x, z) {let nearest = null;let minDistance = Infinity;for (const point of points) {const distance = Math.sqrt(Math.pow(point.position.x - x, 2) + Math.pow(point.position.z - z, 2));if (distance < minDistance) {minDistance = distance;nearest = { ...point, distance };}}return nearest;}// 生成支撑柱generateSupportColumn(x, bottomY, z, topY, vertices, indices) {const radius = 1;const segments = 6;const baseIndex = vertices.length / 3;// 生成圆柱体顶点for (let i = 0; i <= segments; i++) {const angle = (i / segments) * Math.PI * 2;const cos = Math.cos(angle) * radius;const sin = Math.sin(angle) * radius;// 底部环vertices.push(x + cos, bottomY, z + sin);// 顶部环vertices.push(x + cos, topY, z + sin);}// 生成侧面三角形for (let i = 0; i < segments; i++) {const currentBottom = baseIndex + i * 2;const currentTop = currentBottom + 1;const nextBottom = baseIndex + ((i + 1) % segments) * 2;const nextTop = nextBottom + 1;indices.push(currentBottom, currentTop, nextBottom);indices.push(nextBottom, currentTop, nextTop);}// 生成底部和顶部盖帽this.generateEndCap(baseIndex, segments, bottomY, vertices, indices, false);this.generateEndCap(baseIndex + 1, segments, topY, vertices, indices, true);}// 生成端帽generateEndCap(startIndex, segments, y, vertices, indices, isTop) {const centerIndex = vertices.length / 3;vertices.push(vertices[startIndex * 3],y,vertices[startIndex * 3 + 2]);for (let i = 0; i < segments; i++) {const current = startIndex + i * 2;const next = startIndex + ((i + 1) % segments) * 2;if (isTop) {indices.push(centerIndex, next, current);} else {indices.push(centerIndex, current, next);}}}// 生成晶格支撑generateLatticeSupport(geometry, overhangInfo) {const supportGeometry = new THREE.BufferGeometry();// 创建晶格结构const latticeSize = 20;const cellSize = 2;const strutRadius = 0.3;const vertices = [];const indices = [];// 生成晶格节点for (let x = 0; x <= latticeSize; x++) {for (let y = 0; y <= latticeSize; y++) {for (let z = 0; z <= latticeSize; z++) {if ((x + y + z) % 2 === 0) continue; // 简化晶格密度const nodeX = x * cellSize - latticeSize * cellSize * 0.5;const nodeY = y * cellSize * 0.5; // 压缩Y方向const nodeZ = z * cellSize - latticeSize * cellSize * 0.5;// 检查节点是否在支撑区域内if (this.isInSupportRegion(nodeX, nodeY, nodeZ, geometry)) {this.generateLatticeNode(nodeX, nodeY, nodeZ, strutRadius,vertices, indices);}}}}supportGeometry.setAttribute('position',new THREE.BufferAttribute(new Float32Array(vertices), 3));supportGeometry.setIndex(new THREE.BufferAttribute(new Uint32Array(indices), 1));return supportGeometry;}// 检查是否在支撑区域内isInSupportRegion(x, y, z, geometry) {// 简化实现:检查是否在模型下方const modelBox = new THREE.Box3().setFromObject(new THREE.Mesh(geometry, new THREE.MeshBasicMaterial()));return y < modelBox.max.y && y > 0 &&x >= modelBox.min.x && x <= modelBox.max.x &&z >= modelBox.min.z && z <= modelBox.max.z;}// 生成晶格节点generateLatticeNode(x, y, z, radius, vertices, indices) {const segments = 6;const baseIndex = vertices.length / 3;// 生成小球体作为节点for (let i = 0; i <= segments; i++) {const phi = (i / segments) * Math.PI;for (let j = 0; j <= segments; j++) {const theta = (j / segments) * Math.PI * 2;const px = x + radius * Math.sin(phi) * Math.cos(theta);const py = y + radius * Math.sin(phi) * Math.sin(theta);const pz = z + radius * Math.cos(phi);vertices.push(px, py, pz);}}// 生成球面三角形(简化实现)for (let i = 0; i < segments; i++) {for (let j = 0; j < segments; j++) {const a = baseIndex + i * (segments + 1) + j;const b = a + 1;const c = a + (segments + 1);const d = c + 1;indices.push(a, b, c);indices.push(b, d, c);}}}
}
本节详细介绍了Three.js模型3D打印输出的完整流程,从几何体检查修复到格式导出优化。通过这套系统,开发者可以确保其创建的3D模型符合3D打印要求,大大提高打印成功率。
