three-3mf-exporter
Version:
3MF Exporter for Three.js
328 lines (325 loc) • 11.9 kB
JavaScript
import { XMLBuilder } from 'fast-xml-parser';
import JSZip from 'jszip';
import { Color, Vector3 } from 'three';
const defaultPrintConfig = {
printer_name: "Bambu Lab A1",
filament: "Bambu PLA Basic @BBL A1",
printableWidth: 256,
printableDepth: 256,
printableHeight: 256,
printableArea: ["0x0", "256x0", "256x256", "0x256"],
printerSettingsId: "Bambu Lab A1 0.4 nozzle",
printSettingsId: "0.20mm Standard @BBL A1"
};
async function exportTo3MF(object, printJobConfig) {
const objectId = 1;
const zip = new JSZip();
const printConfig = Object.assign({}, defaultPrintConfig, printJobConfig);
const components = [];
const materials = [];
collectComponents(object, components, materials);
const boundingBox = calculateBoundingBox(components);
const modelCenter = calculateModelCenter(boundingBox);
const transform = calculateCenteringTransform(modelCenter, boundingBox, printConfig);
const mainModelXml = createMainModelXML(objectId, components, transform);
const objectModelXml = createObjectModelXML(components);
const modelSettingsXml = createModelSettingsXML(objectId, components);
const projectSettingsConfig = createProjectSettingsConfig(materials, printConfig);
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);
zip.file("[Content_Types].xml", contentTypesXML());
return await zip.generateAsync({ type: "blob", mimeType: "application/vnd.ms-package.3dmanufacturing-3dmodel+xml" });
}
function collectComponents(object, components, materials) {
object.updateMatrixWorld(true);
if (object.type === "Mesh") {
const mesh = object;
const geometry = mesh.geometry;
const positionAttr = geometry.attributes.position;
const indexAttr = geometry.index;
let materialInfo = null;
if (mesh.material) {
const color = new Color();
if ("color" in mesh.material && mesh.material.color) {
color.copy(mesh.material.color);
} else {
color.set(8421504);
}
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;
materialInfo = {
id: materials.length,
color,
name: mesh.name ? `${mesh.name}_material` : `material_${materials.length}`,
extruder
};
materials.push(materialInfo);
}
}
const componentId = components.length;
const component = {
id: componentId,
vertices: [],
triangles: [],
material: materialInfo,
name: mesh.name || `Default-${componentId}`
};
const vertexMap = /* @__PURE__ */ new Map();
const worldMatrix = mesh.matrixWorld;
const processVertex = (vertexIndex) => {
const vertex = new Vector3();
vertex.fromBufferAttribute(positionAttr, vertexIndex);
vertex.applyMatrix4(worldMatrix);
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);
};
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 });
}
} 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 });
}
}
components.push(component);
}
object.children.forEach((child) => {
collectComponents(child, components, materials);
});
}
function calculateBoundingBox(components) {
if (components.length === 0) {
return { min: { x: 0, y: 0, z: 0 }, max: { x: 0, y: 0, z: 0 } };
}
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);
}
}
return { min, max };
}
function calculateModelCenter(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
};
}
function calculateCenteringTransform(modelCenter, boundingBox, printBed) {
const bedCenter = {
x: printBed.printableWidth / 2,
y: printBed.printableDepth / 2,
z: 0
// 通常Z坐标为0,模型放在打印床表面
};
const translation = {
x: bedCenter.x - modelCenter.x,
y: bedCenter.y - modelCenter.y,
z: bedCenter.z - boundingBox.min.z
// 将模型的底部放在打印床上
};
return `1 0 0 0 1 0 0 0 1 ${translation.x.toFixed(4)} ${translation.y.toFixed(4)} ${translation.z.toFixed(4)}`;
}
function createMainModelXML(objectId, components, transform) {
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": [
{ "@_name": "Application", "#text": "BambuStudio-01.07.04.52" },
{ "@_name": "Title", "#text": "Exported 3D Model" },
{ "@_name": "CreationDate", "#text": (/* @__PURE__ */ new Date()).toString() },
{ "@_name": "Copyright", "#text": "Copyright (c) 2023. All rights reserved." }
],
"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 builder = new XMLBuilder({
attributeNamePrefix: "@_",
ignoreAttributes: false,
format: true,
indentBy: " "
});
return `xml version="1.0" encoding="UTF-8"
${builder.build(model)}`;
}
function createObjectModelXML(components) {
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
}))
}
}
}
};
});
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: " "
});
return `xml version="1.0" encoding="UTF-8"
${builder.build(model)}`;
}
function createModelSettingsXML(objectId, components) {
const partsXml = components.map((comp) => {
const extruder = comp.material ? comp.material.extruder : 1;
return ` <part id="${comp.id}" subtype="normal_part">
<metadata key="name" value="${comp.name}"/>
<metadata key="extruder" value="${extruder}"/>
<mesh_stat edges_fixed="0" degenerate_facets="0" facets_removed="0" facets_reversed="0" backwards_edges="0"/>
</part>`;
}).join("\n");
return `xml version="1.0" encoding="UTF-8"
<config>
<object id="${objectId}">
<metadata key="name" value="Exported3DModel.3mf"/>
<metadata key="extruder" value="1"/>
<metadata key="thumbnail_file" value=""/>
${partsXml}
</object>
<plate>
<metadata key="plater_id" value="1"/>
<metadata key="plater_name" value="plate-1"/>
<model_instance>
<metadata key="object_id" value="${objectId}"/>
<metadata key="instance_id" value="0"/>
</model_instance>
</plate>
<assemble>
<assemble_item object_id="${objectId}" instance_id="0" offset="0 0 0"/>
</assemble>
</config>`;
}
function createProjectSettingsConfig(materials, printConfig) {
const colors = materials.map((m) => {
const hex = `#${m.color.getHexString()}`;
return hex;
});
while (colors.length < 2) {
colors.push("#FFFFFF");
}
const projectSettings = {
printable_area: printConfig.printableArea,
printable_height: printConfig.printableHeight.toString(),
bed_exclude_area: [],
filament_colour: colors,
filament_settings_id: Array.from({ length: colors.length }).fill(printConfig.filament),
filament_diameter: Array.from({ length: colors.length }).fill("1.75"),
filament_is_support: Array.from({ length: colors.length }).fill("0"),
printer_model: printConfig.printer_name,
layer_height: "0.2",
wall_loops: "2",
sparse_infill_density: "15%",
printer_settings_id: printConfig.printerSettingsId,
printer_variant: "0.4",
nozzle_diameter: ["0.4"],
enable_support: "0",
support_type: "normal(auto)",
print_settings_id: printConfig.printSettingsId
};
return JSON.stringify(projectSettings);
}
function contentTypesXML() {
return `xml version="1.0" encoding="UTF-8"
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml" />
<Default Extension="model" ContentType="application/vnd.ms-package.3dmanufacturing-3dmodel+xml" />
<Default Extension="png" ContentType="image/png" />
<Default Extension="gcode" ContentType="text/x.gcode"/>
</Types>`;
}
function relationshipsXML(options) {
return `xml version="1.0" encoding="UTF-8"
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rel-${options.id}" Target="${options.target}" Type="http://schemas.microsoft.com/3dmanufacturing/2013/01/3dmodel"/>
</Relationships>`;
}
function generateUUID() {
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
const r = Math.random() * 16 | 0;
const v = c === "x" ? r : r & 3 | 8;
return v.toString(16);
});
}
export { defaultPrintConfig, exportTo3MF };