diff --git a/packages/three-3mf-exporter/package.json b/packages/three-3mf-exporter/package.json index 5a1d61c..41b72f7 100644 --- a/packages/three-3mf-exporter/package.json +++ b/packages/three-3mf-exporter/package.json @@ -45,7 +45,8 @@ ], "scripts": { "build": "unbuild", - "stub": "unbuild --stub" + "stub": "unbuild --stub", + "test": "vitest run" }, "peerDependencies": { "three": "*" diff --git a/packages/three-3mf-exporter/src/index.ts b/packages/three-3mf-exporter/src/index.ts index 48f206b..a0ba564 100644 --- a/packages/three-3mf-exporter/src/index.ts +++ b/packages/three-3mf-exporter/src/index.ts @@ -1,21 +1,26 @@ import type { Group, Mesh, MeshPhongMaterial, Object3D } from 'three' -import { XMLBuilder } from 'fast-xml-parser' import JSZip from 'jszip' -import { Color, Vector3 } from 'three' +import { Color, Matrix4, Vector3 } from 'three' /** - * 组件信息接口 + * 组件信息接口 / Component Information Interface + * 已更新以支持程序集(组) / Updated to support Assemblies (Groups) */ interface ComponentInfo { id: number + type: 'mesh' | 'assembly' + // 网格字段 / Mesh fields vertices: { x: number, y: number, z: number }[] triangles: { v1: number, v2: number, v3: number }[] material: MaterialInfo | null + // 程序集字段 / Assembly fields + subComponents: { objectId: number, transform: Matrix4 }[] // 子组件及其变换 / Sub-components and their transforms name: string + uuid: string } /** - * 材质信息接口,用于存储颜色数据 + * 材质信息接口,用于存储颜色数据 / Material Information Interface for storing color data */ interface MaterialInfo { id: number @@ -25,27 +30,32 @@ interface MaterialInfo { } /** - * 打印床配置接口 + * 打印床配置接口 / Print Bed Configuration Interface */ interface PrintConfig { - printer_name: string // 打印机名称 - filament: string // 打印材料 - printableWidth: number // 打印床宽度 (X轴) - printableDepth: number // 打印床深度 (Y轴) - printableHeight: number // 打印高度 (Z轴) - printableArea: [string, string, string, string] // 打印区域坐标 - printerSettingsId: string // 打印机设置ID - printSettingsId: string // 打印设置ID - compression: 'none' | 'standard' // 压缩方式 - - metadata: Partial<{ - Application: string // 应用名称 - Copyright: string // 版权信息 - ApplicationTitle: string - }> & Record + printer_name: string // 打印机名称 / Printer Name + filament: string // 打印材料 / Filament + printableWidth: number // 打印床宽度 (X轴) / Printable Width (X-axis) + printableDepth: number // 打印床深度 (Y轴) / Printable Depth (Y-axis) + printableHeight: number // 打印高度 (Z轴) / Printable Height (Z-axis) + printableArea: readonly [string, string, string, string] // 打印区域坐标 / Printable Area Coordinates + printerSettingsId: string // 打印机设置ID / Printer Settings ID + printSettingsId: string // 打印设置ID / Print Settings ID + compression: 'none' | 'standard' // 压缩方式 / Compression Method + + metadata: Partial<{ Application: string, Copyright: string, ApplicationTitle: string }> } -// 默认的打印配置 (基于 Bambu Lab A1) +/** + * 构建项接口 / Build Item Interface + */ +interface BuildItem { + objectId: number + transformMatrix: { elements: number[] } // 兼容 THREE.Matrix4 / Compatible with THREE.Matrix4 + uuid: string +} + +// 默认的打印配置 (基于 Bambu Lab A1) / Default Print Configuration (based on Bambu Lab A1) export const defaultPrintConfig: PrintConfig = { printer_name: 'Bambu Lab A1', filament: 'Bambu PLA Basic @BBL A1', @@ -67,96 +77,57 @@ const JSZipCompressionMap = { standard: 'DEFLATE' as const, none: 'STORE' as con /** * 将 Three.js 的 Group 或 Mesh 导出为 3MF 文件格式 (BambuStudio 兼容格式) - * @param object Three.js Group 对象或 Mesh 数组 - * @param printJobConfig 打印床配置,可选 - * @returns Blob 数据 + * Export Three.js Group or Mesh to 3MF format (BambuStudio compatible) + * @param object Three.js Group 对象或 Mesh 数组 / Three.js Group or Mesh array + * @param printJobConfig 打印床配置,可选 / Optional print bed configuration + * @returns Blob 数据 / Blob data */ export async function exportTo3MF( object: Group | Object3D, printJobConfig?: Partial, ): Promise { - const objectId = 1 const zip = new JSZip() - - // 合并用户提供的配置与默认配置 + // 合并用户提供的配置与默认配置 / Merge user-provided config with defaults const printConfig = Object.assign({} as (typeof defaultPrintConfig & Partial), defaultPrintConfig, printJobConfig) const compression = JSZipCompressionMap[printConfig.compression] - // 收集所有组件和材质信息 + // 收集所有组件、材质和构建项信息 / Collect all components, materials, and build items const components: ComponentInfo[] = [] const materials: MaterialInfo[] = [] + const buildItems: BuildItem[] = [] - // 递归处理所有网格并计算模型边界和中心位置 - collectComponents(object, components, materials) - const boundingBox = calculateBoundingBox(components) - const modelCenter = calculateModelCenter(boundingBox) - - // 计算将模型放置在打印床中心所需的变换 - const transform = calculateCenteringTransform(modelCenter, boundingBox, printConfig) - - // 创建 3MF 所需的基本文件结构 - const mainModelXml = createMainModelXML(objectId, components, transform, printConfig) - const objectModelXml = createObjectModelXML(components) - const modelSettingsXml = createModelSettingsXML(objectId, components) - const projectSettingsConfig = createProjectSettingsConfig(materials, printConfig) - - // 将文件添加到ZIP中 - zip.file('_rels/.rels', relationshipsXML({ id: `rel-1`, target: '/3D/3dmodel.model' })) - zip.file('3D/3dmodel.model', mainModelXml) - zip.file('3D/_rels/3dmodel.model.rels', relationshipsXML({ id: `rel-${objectId}`, target: `/3D/Objects/object-${objectId}.model` })) - zip.file(`3D/Objects/object-${objectId}.model`, objectModelXml) - zip.file('Metadata/model_settings.config', modelSettingsXml) - zip.file('Metadata/project_settings.config', projectSettingsConfig) - - // 当我把 '[Content_Types].xml' 文件名放在压缩包的开头时,压缩文件将无法解压。 - // 不确定具体原因,但是请不要把它放在压缩包的开头。 - zip.file('[Content_Types].xml', contentTypesXML()) - - // 生成ZIP文件 - return await zip.generateAsync({ type: 'blob', mimeType: 'application/vnd.ms-package.3dmanufacturing-3dmodel+xml', compression }) -} - -/** - * 收集模型中的组件和材质信息 - */ -function collectComponents( - object: Object3D, - components: ComponentInfo[], - materials: MaterialInfo[], -): void { - object.updateMatrixWorld(true) - - if (object.type === 'Mesh') { - const mesh = object as Mesh + // 辅助函数:处理唯一的网格 / Helper: Process Unique Mesh + const processMesh = (mesh: Mesh): number => { + mesh.updateMatrixWorld(true) const geometry = mesh.geometry const positionAttr = geometry.attributes.position const indexAttr = geometry.index - // 处理材质 + // 处理材质 / Process Materials let materialInfo: MaterialInfo | null = null if (mesh.material) { - // 获取材质颜色 const color = new Color() - if ('color' in mesh.material && mesh.material.color) { - color.copy((mesh.material as MeshPhongMaterial).color) - } - else { - // 默认颜色为灰色 + // 处理数组材质 or 单个材质 / Handle array materials or single material + const mat = Array.isArray(mesh.material) ? mesh.material[0] : mesh.material + + if (mat && 'color' in mat && mat.color) { + color.copy((mat as MeshPhongMaterial).color) + } else { + // 默认颜色为灰色 / Default color is gray color.set(0x808080) } - // 检查是否已存在相同颜色的材质 - const existingMaterial = materials.find(m => - m.color.r === color.r && m.color.g === color.g && m.color.b === color.b, + // 检查是否已存在相同颜色的材质 / Check for existing material with the same color + const existingMaterial = materials.find( + m => m.color.r === color.r && m.color.g === color.g && m.color.b === color.b, ) if (existingMaterial) { materialInfo = existingMaterial - } - else { - const extruder = materials.length + 1 // 挤出头编号从1开始 + } else { + const extruder = materials.length + 1 // 挤出头编号从1开始 / Extruder numbering starts from 1 materialInfo = { - id: materials.length, + id: materials.length + 1, color, name: mesh.name ? `${mesh.name}_material` : `material_${materials.length}`, extruder, @@ -165,286 +136,284 @@ function collectComponents( } } - const componentId = components.length + const componentId = components.length + 1 const component: ComponentInfo = { id: componentId, + type: 'mesh', vertices: [], triangles: [], material: materialInfo, - name: mesh.name || `Default-${componentId}`, + name: mesh.name || `Mesh-${componentId}`, + subComponents: [], + uuid: generateUUID() } - // 为当前 mesh 创建独立的顶点映射 + // 顶点去重映射表 / Vertex De-duplication Map const vertexMap = new Map() - const worldMatrix = mesh.matrixWorld - - // 处理当前 mesh 的顶点 + + // 在局部空间(几何空间)处理顶点 / Process Vertices in LOCAL space (Geometry space) const processVertex = (vertexIndex: number) => { const vertex = new Vector3() vertex.fromBufferAttribute(positionAttr, vertexIndex) - vertex.applyMatrix4(worldMatrix) + // 对于模块化组件,不要在这里应用世界矩阵 / Do NOT apply world matrix here for modular components const vertexKey = `${vertex.x},${vertex.y},${vertex.z}` - if (!vertexMap.has(vertexKey)) { vertexMap.set(vertexKey, component.vertices.length) component.vertices.push({ x: vertex.x, y: vertex.y, z: vertex.z }) } - return vertexMap.get(vertexKey)! } - // 处理三角形 + // 处理三角形 / Process Triangles if (indexAttr) { - // 有索引的几何体 for (let i = 0; i < indexAttr.count; i += 3) { - const v1 = processVertex(indexAttr.getX(i)) - const v2 = processVertex(indexAttr.getX(i + 1)) - const v3 = processVertex(indexAttr.getX(i + 2)) - - component.triangles.push({ v1, v2, v3 }) + component.triangles.push({ + v1: processVertex(indexAttr.getX(i)), + v2: processVertex(indexAttr.getX(i + 1)), + v3: processVertex(indexAttr.getX(i + 2)), + }) } - } - else { - // 无索引的几何体 + } else { for (let i = 0; i < positionAttr.count; i += 3) { - const v1 = processVertex(i) - const v2 = processVertex(i + 1) - const v3 = processVertex(i + 2) - - component.triangles.push({ v1, v2, v3 }) + component.triangles.push({ + v1: processVertex(i), + v2: processVertex(i + 1), + v3: processVertex(i + 2), + }) } } components.push(component) + return componentId } - // 递归处理子对象 - object.children.forEach((child) => { - collectComponents(child, components, materials) - }) -} + // 辅助函数:处理节点(网格或组) / Helper: Process Node (Mesh or Group) + const processNode = (node: Object3D): number => { + if (node.type === 'Mesh') { + return processMesh(node as Mesh) + } else if (node.type === 'Group' || node.type === 'Object3D' || node.type === 'Scene') { + const subComponents: { objectId: number, transform: Matrix4 }[] = [] + node.updateMatrixWorld(true) + + node.children.forEach((child) => { + const subId = processNode(child) + if (subId !== -1) { + // 计算相对于当前节点的变换 / Calculate transform relative to the current node + const relMatrix = child.matrixWorld.clone().premultiply(node.matrixWorld.clone().invert()) + subComponents.push({ objectId: subId, transform: relMatrix }) + } + }) + + if (subComponents.length > 0) { + const assemblyId = components.length + 1 + components.push({ + id: assemblyId, + type: 'assembly', + subComponents, + name: node.name || `Group-${assemblyId}`, + vertices: [], triangles: [], material: null, uuid: generateUUID() + }) + return assemblyId + } + } + return -1 + } -interface BoundingBox { min: { x: number, y: number, z: number }, max: { x: number, y: number, z: number } } -interface ModelCenter { x: number, y: number, z: number } + // 遍历与原生分组 / Traversal & Native Grouping + // 如果输入是场景,将其子项作为顶级构建项。如果是组或网格,作为单个顶级构建项。 + // If input is Scene, treat children as top-level build items. If Group or Mesh, treat as single top-level. + const rootChildren = (object.type === 'Scene') ? object.children : [object] + const allVerticesWorld: Vector3[] = [] + + rootChildren.forEach((child) => { + const childId = processNode(child) + if (childId !== -1) { + child.updateMatrix() + const itemMatrix = child.matrix.clone() + + const targetComp = components.find(c => c.id === childId)! + const getVerts = (c: ComponentInfo): Vector3[] => { + if (c.type === 'assembly') { + return c.subComponents.flatMap((sc) => { + const subComp = components.find(x => x.id === sc.objectId)! + return getVerts(subComp).map(v => v.clone().applyMatrix4(sc.transform)) + }) + } + return c.vertices.map(v => new Vector3(v.x, v.y, v.z)) + } -/** - * 计算模型的边界框 - */ -function calculateBoundingBox(components: ComponentInfo[]) { - if (components.length === 0) { - return { min: { x: 0, y: 0, z: 0 }, max: { x: 0, y: 0, z: 0 } } - } + getVerts(targetComp).forEach((vec) => { + vec.applyMatrix4(itemMatrix) + allVerticesWorld.push(vec) + }) - // 初始化边界值为第一个顶点 - const firstVertex = components[0].vertices[0] || { x: 0, y: 0, z: 0 } - const min = { x: firstVertex.x, y: firstVertex.y, z: firstVertex.z } - const max = { x: firstVertex.x, y: firstVertex.y, z: firstVertex.z } - - // 遍历所有组件的所有顶点 - for (const component of components) { - for (const vertex of component.vertices) { - min.x = Math.min(min.x, vertex.x) - min.y = Math.min(min.y, vertex.y) - min.z = Math.min(min.z, vertex.z) - max.x = Math.max(max.x, vertex.x) - max.y = Math.max(max.y, vertex.y) - max.z = Math.max(max.z, vertex.z) + buildItems.push({ + objectId: childId, + transformMatrix: itemMatrix, + uuid: generateUUID() + }) } - } - - return { min, max } -} + }) -/** - * 计算模型中心点 - */ -function calculateModelCenter(boundingBox: BoundingBox) { - return { - x: (boundingBox.min.x + boundingBox.max.x) / 2, - y: (boundingBox.min.y + boundingBox.max.y) / 2, - z: (boundingBox.min.z + boundingBox.max.z) / 2, - } -} + // 居中逻辑 / Centering Logic + let min = { x: Infinity, y: Infinity, z: Infinity } + let max = { x: -Infinity, y: -Infinity, z: -Infinity } + if (allVerticesWorld.length > 0) { + allVerticesWorld.forEach(v => { + min.x = Math.min(min.x, v.x); min.y = Math.min(min.y, v.y); min.z = Math.min(min.z, v.z) + max.x = Math.max(max.x, v.x); max.y = Math.max(max.y, v.y); max.z = Math.max(max.z, v.z) + }) + } else { min = { x: 0, y: 0, z: 0 }; max = { x: 0, y: 0, z: 0 } } + + const modelCenter = { x: (min.x + max.x) / 2, y: (min.y + max.y) / 2, z: (min.z + max.z) / 2 } + const bedCenter = { x: printConfig.printableWidth / 2, y: printConfig.printableDepth / 2, z: 0 } + const shift = { x: bedCenter.x - modelCenter.x, y: bedCenter.y - modelCenter.y, z: bedCenter.z - min.z } + + // 生成 XML / Generate XML + const mainModelXml = createMainModelXML(components, buildItems, shift, printConfig) + const modelSettingsXml = createModelSettingsXML(components, buildItems) + const projectSettingsConfig = createProjectSettingsConfig(materials, printConfig) -/** - * 计算使模型居中的变换矩阵 - */ -function calculateCenteringTransform(modelCenter: ModelCenter, boundingBox: BoundingBox, printBed: PrintConfig) { - // 计算打印床中心 - const bedCenter = { - x: printBed.printableWidth / 2, - y: printBed.printableDepth / 2, - z: 0, // 通常Z坐标为0,模型放在打印床表面 - } + // ZIP 打包 / Zip Packaging + zip.file('_rels/.rels', relationshipsXML()) + zip.file('3D/3dmodel.model', mainModelXml) + zip.file('Metadata/model_settings.config', modelSettingsXml) + zip.file('Metadata/project_settings.config', projectSettingsConfig) - // 计算需要移动的距离 - const translation = { - x: bedCenter.x - modelCenter.x, - y: bedCenter.y - modelCenter.y, - z: bedCenter.z - boundingBox.min.z, // 将模型的底部放在打印床上 - } + // 当我把 '[Content_Types].xml' 文件名放在压缩包的开头时,压缩文件将无法解压。 + // 不确定具体原因,但是请不要把它放在压缩包的开头。 + // When I put '[Content_Types].xml' at the beginning of the zip, it fails to decompress. + // Not sure why, but please do not put it at the start. + zip.file('[Content_Types].xml', contentTypesXML()) - // 生成变换矩阵字符串 (3x4矩阵, 最后三个值是平移) - return `1 0 0 0 1 0 0 0 1 ${translation.x.toFixed(4)} ${translation.y.toFixed(4)} ${translation.z.toFixed(4)}` + // 生成ZIP文件 / Generate ZIP file + return await zip.generateAsync({ type: 'blob', mimeType: 'application/vnd.ms-package.3dmanufacturing-3dmodel+xml', compression }) } /** - * 创建主3dmodel.model文件的XML数据 + * 创建主3dmodel.model文件的XML数据 / Create XML data for the main 3dmodel.model file */ -function createMainModelXML(objectId: number, components: ComponentInfo[], transform: string, printConfig: PrintConfig): string { - const metadata = [] +function createMainModelXML(components: ComponentInfo[], buildItems: BuildItem[], shift: { x: number, y: number, z: number }, printConfig: PrintConfig): string { + const metadata: string[] = [] const metadataConfig = printConfig.metadata - metadata.push({ '@_name': 'CreationDate', '#text': new Date().toString() }) + metadata.push(`${new Date().toISOString()}`) for (const key in metadataConfig) { - metadata.push({ '@_name': key, '#text': metadataConfig[key] }) + metadata.push(`${metadataConfig[key]}`) } - const model = { - model: { - '@_unit': 'millimeter', - '@_xml:lang': 'en-US', - '@_xmlns': 'http://schemas.microsoft.com/3dmanufacturing/core/2015/02', - '@_xmlns:slic3rpe': 'http://schemas.slic3r.org/3mf/2017/06', - '@_xmlns:p': 'http://schemas.microsoft.com/3dmanufacturing/production/2015/06', - '@_requiredextensions': 'p', - metadata, - 'resources': { - object: { - '@_id': `${objectId}`, - '@_p:uuid': generateUUID(), - '@_type': 'model', - 'components': { - component: components.map(comp => ({ - '@_p:path': `/3D/Objects/object-${objectId}.model`, - '@_objectid': comp.id.toString(), - })), - }, - }, - }, - 'build': { - '@_p:uuid': `${generateUUID()}1`, - 'item': { - '@_objectid': `${objectId}`, - '@_p:uuid': `${generateUUID()}2`, - '@_transform': transform, - '@_printable': '1', - }, - }, - }, - } + const resources = components.map((c) => { + if (c.type === 'assembly') { + const comps = c.subComponents.map((sc) => { + const e = sc.transform.elements + const tStr = `${e[0].toFixed(5)} ${e[1].toFixed(5)} ${e[2].toFixed(5)} ${e[4].toFixed(5)} ${e[5].toFixed(5)} ${e[6].toFixed(5)} ${e[8].toFixed(5)} ${e[9].toFixed(5)} ${e[10].toFixed(5)} ${e[12].toFixed(5)} ${e[13].toFixed(5)} ${e[14].toFixed(5)}` + return `` + }).join('') + return `${comps}` + } else { + const vXml = c.vertices.map(v => ``).join(' ') + const tXml = c.triangles.map(t => ``).join(' ') + return `${vXml}${tXml}` + } + }).join('\n') - const builder = new XMLBuilder({ - attributeNamePrefix: '@_', - ignoreAttributes: false, - format: true, - indentBy: ' ', - }) + const build = buildItems.map((item) => { + // 对平移元素应用位移 (12, 13, 14) / Apply shift to translation elements (12, 13, 14) + const e = item.transformMatrix.elements + const tx = e[12] + shift.x + const ty = e[13] + shift.y + const tz = e[14] + shift.z + // 3MF 变换:m00 m01 m02 m10 m11 m12 m20 m21 m22 m30 m31 m32 (仿射 3x4) + // 3MF Transform: m00 m01 m02 m10 m11 m12 m20 m21 m22 m30 m31 m32 (affine 3x4) + const tStr = `${e[0].toFixed(5)} ${e[1].toFixed(5)} ${e[2].toFixed(5)} ${e[4].toFixed(5)} ${e[5].toFixed(5)} ${e[6].toFixed(5)} ${e[8].toFixed(5)} ${e[9].toFixed(5)} ${e[10].toFixed(5)} ${tx.toFixed(5)} ${ty.toFixed(5)} ${tz.toFixed(5)}` + return `` + }).join('\n') - return `\n${builder.build(model)}` + return ` + + ${metadata.join('\n ')} + +${resources} + + +${build} + +` } /** - * 创建对象模型XML数据 + * 创建模型设置XML配置 / Create XML configuration for model settings */ -function createObjectModelXML(components: ComponentInfo[]): string { - const objects = components.map((component) => { - return { - object: { - '@_id': component.id.toString(), - '@_p:uuid': generateUUID(), - '@_type': 'model', - 'mesh': { - vertices: { - vertex: component.vertices.map(v => ({ - '@_x': v.x.toFixed(7), - '@_y': v.y.toFixed(7), - '@_z': v.z.toFixed(7), - })), - }, - triangles: { - triangle: component.triangles.map(t => ({ - '@_v1': t.v1, - '@_v2': t.v2, - '@_v3': t.v3, - })), - }, - }, - }, +function createModelSettingsXML(components: ComponentInfo[], buildItems: BuildItem[]): string { + let objectsXml = "" + let instancesXml = "" + let assembleXml = "" + + buildItems.forEach((item, index) => { + const objId = item.objectId + const comp = components.find(c => c.id === objId)! + + // 展平此对象的部件 / Flatten parts for this object + const parts: ComponentInfo[] = [] + const collect = (c: ComponentInfo) => { + if (c.type === 'mesh') + parts.push(c) + else c.subComponents.forEach(sc => collect(components.find(x => x.id === sc.objectId)!)) } - }) - - const model = { - model: { - '@_unit': 'millimeter', - '@_xml:lang': 'en-US', - '@_xmlns': 'http://schemas.microsoft.com/3dmanufacturing/core/2015/02', - '@_xmlns:p': 'http://schemas.microsoft.com/3dmanufacturing/production/2015/06', - 'resources': objects, - 'build': {}, - }, - } - - const builder = new XMLBuilder({ - attributeNamePrefix: '@_', - ignoreAttributes: false, - format: true, - indentBy: ' ', - }) + collect(comp) - return `\n${builder.build(model)}` -} - -/** - * 创建模型设置XML配置 - */ -function createModelSettingsXML(objectId: number, components: ComponentInfo[]): string { - const partsXml = components.map((comp) => { - const extruder = comp.material ? comp.material.extruder : 1 - return ` - + const partsXml = parts.map(p => { + const extruder = p.material ? p.material.extruder : 1 + return ` + ` - }).join('\n') + }).join('\n') - return ` - - - + objectsXml += ` + ${partsXml} - + \n` + + instancesXml += ` + + + + \n` + + assembleXml += ` \n` + }) + + return ` + +${objectsXml} - - - - +${instancesXml} - +${assembleXml} ` } /** - * 创建项目设置配置文件 + * 创建项目设置配置文件 / Create project settings configuration file */ function createProjectSettingsConfig(materials: MaterialInfo[], printConfig: PrintConfig): string { - // 从材质中提取颜色 + // 从材质中提取颜色 / Extract colors from materials const colors = materials.map((m) => { const hex = `#${m.color.getHexString()}` return hex }) - - // 确保至少有两个颜色(BambuStudio的要求) + // 确保至少有两个颜色(BambuStudio的要求) / Ensure at least two colors (BambuStudio requirement) while (colors.length < 2) { colors.push('#FFFFFF') } - const projectSettings = { printable_area: printConfig.printableArea, printable_height: printConfig.printableHeight.toString(), @@ -464,38 +433,37 @@ function createProjectSettingsConfig(materials: MaterialInfo[], printConfig: Pri support_type: 'normal(auto)', print_settings_id: printConfig.printSettingsId, } - return JSON.stringify(projectSettings) } /** - * 创建 3MF ContentTypes XML + * 创建 3MF Relationships XML / Create 3MF Relationships XML + */ +function relationshipsXML(): string { + return ` + + + + +` +} + +/** + * 创建 3MF ContentTypes XML / Create 3MF ContentTypes XML */ function contentTypesXML(): string { return ` + ` } /** - * 创建 3MF Relationships XML - */ -function relationshipsXML(options: { - id: string - target: string -}): string { - return ` - - -` -} - -/** - * 生成简单的UUID + * 生成简单的UUID / Generate simple UUID */ function generateUUID(): string { return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { diff --git a/packages/three-3mf-exporter/test/exporter.test.ts b/packages/three-3mf-exporter/test/exporter.test.ts new file mode 100644 index 0000000..91546e1 --- /dev/null +++ b/packages/three-3mf-exporter/test/exporter.test.ts @@ -0,0 +1,156 @@ +/** + * @vitest-environment jsdom + */ +import { describe, expect, it } from 'vitest' +import * as THREE from 'three' +import { BoxGeometry, Mesh, MeshStandardMaterial } from 'three' +import JSZip from 'jszip' +import { exportTo3MF } from '../src/index' + +async function getZip(blob: Blob) { + const reader = new FileReader() + const bufferPromise = new Promise((resolve) => { + reader.onload = () => resolve(reader.result as ArrayBuffer) + }) + reader.readAsArrayBuffer(blob) + const buffer = await bufferPromise + return await JSZip.loadAsync(buffer) +} + +describe('three-3mf-exporter', () => { + it('should export a single Mesh as a single object', async () => { + const mesh = new Mesh(new BoxGeometry(1, 1, 1), new MeshStandardMaterial({ color: 0xFF0000 })) + const blob = await exportTo3MF(mesh) + const zip = await getZip(blob) + const modelXml = await zip.file('3D/3dmodel.model')!.async('string') + + // Should have one object with a mesh + expect(modelXml).toContain('') + expect(modelXml).toContain(' { + const group = new THREE.Group() + const mesh1 = new Mesh(new BoxGeometry(1, 1, 1), new MeshStandardMaterial({ color: 0xFF0000 })) + const mesh2 = new Mesh(new BoxGeometry(1, 1, 1), new MeshStandardMaterial({ color: 0x00FF00 })) + mesh1.name = 'Part1' + mesh2.name = 'Part2' + group.add(mesh1) + group.add(mesh2) + + const blob = await exportTo3MF(group) + const zip = await getZip(blob) + const modelXml = await zip.file('3D/3dmodel.model')!.async('string') + + // Mesh 1 -> ID 1, Mesh 2 -> ID 2, Assembly -> ID 3 + expect(modelXml).toContain('') + expect(modelXml).toContain('') + expect(modelXml).toContain('') + expect(modelXml).toContain(' { + const scene = new THREE.Scene() + const groupA = new THREE.Group() + groupA.name = 'GroupA' + const groupB = new THREE.Group() + groupB.name = 'GroupB' + + const meshA1 = new Mesh(new BoxGeometry(1, 1, 1)) + meshA1.name = 'MeshA1' + groupA.add(meshA1) + + const meshB1 = new Mesh(new BoxGeometry(1, 1, 1)) + meshB1.name = 'MeshB1' + groupB.add(meshB1) + + scene.add(groupA) + scene.add(groupB) + + const blob = await exportTo3MF(scene) + const zip = await getZip(blob) + const modelXml = await zip.file('3D/3dmodel.model')!.async('string') + + // MeshA1 -> 1, GroupA -> 2, MeshB1 -> 3, GroupB -> 4 + expect(modelXml).toContain('') + expect(modelXml).toContain('') + + const groupAObj = modelXml.match(/]*>([\s\S]*?)<\/object>/)![1] + expect(groupAObj).toContain('objectid="1"') + + expect(modelXml).toContain(' { + const scene = new THREE.Scene() + const mesh = new Mesh(new BoxGeometry(1, 1, 1)) + mesh.name = 'SceneMesh' + scene.add(mesh) + + const blob = await exportTo3MF(scene) + const zip = await getZip(blob) + const modelXml = await zip.file('3D/3dmodel.model')!.async('string') + + expect(modelXml).toContain('') + expect(modelXml).toContain(' { + const scene = new THREE.Scene() + const group = new THREE.Group() + group.name = 'SceneGroup' + group.add(new Mesh(new BoxGeometry(1, 1, 1))) + scene.add(group) + + const blob = await exportTo3MF(scene) + const zip = await getZip(blob) + const modelXml = await zip.file('3D/3dmodel.model')!.async('string') + + // Mesh -> 1, Group -> 2 + expect(modelXml).toContain('') + expect(modelXml).toContain(' { + const scene = new THREE.Scene() + + // 1. A single mesh + const soloMesh = new Mesh(new BoxGeometry(1, 1, 1)) + soloMesh.name = 'SoloMesh' + scene.add(soloMesh) + + // 2. A group + const group = new THREE.Group() + group.name = 'GroupObj' + group.add(new Mesh(new BoxGeometry(1, 1, 1))) + group.add(new Mesh(new BoxGeometry(1, 1, 1))) + scene.add(group) + + // 3. Another mesh + const secondSoloMesh = new Mesh(new BoxGeometry(1, 1, 1)) + secondSoloMesh.name = 'SecondSolo' + scene.add(secondSoloMesh) + + const blob = await exportTo3MF(scene) + const zip = await getZip(blob) + const modelXml = await zip.file('3D/3dmodel.model')!.async('string') + + // SoloMesh -> 1 + // GroupMesh1 -> 2, GroupMesh2 -> 3, GroupObj -> 4 + // SecondSolo -> 5 + + expect(modelXml).toContain('') + expect(modelXml).toContain('') + expect(modelXml).toContain('') + + expect(modelXml).toContain('