Cura/plugins/3MFWriter/BambuLabVariant.py
Erwan MATHIEU d2e625edb3 Export project settings into BambuLab 3MF
CURA-12101
The printer parses the machine_start_gcode to allow selecting the filaments mapping at start time, without it the user has to set the filaments in fixed order. This is probably a security to ensure the proper filament is loaded at start.
2025-04-29 11:28:56 +02:00

176 lines
8.5 KiB
Python

# Copyright (c) 2025 UltiMaker
# Cura is released under the terms of the LGPLv3 or higher.
import hashlib
import json
from io import StringIO
import xml.etree.ElementTree as ET
import zipfile
from PyQt6.QtCore import Qt, QBuffer
from PyQt6.QtGui import QImage
from UM.Application import Application
from UM.Logger import Logger
from UM.Mesh.MeshWriter import MeshWriter
from UM.PluginRegistry import PluginRegistry
from typing import cast
from cura.CuraApplication import CuraApplication
from .ThreeMFVariant import ThreeMFVariant
from UM.i18n import i18nCatalog
catalog = i18nCatalog("cura")
# Path constants
METADATA_PATH = "Metadata"
THUMBNAIL_PATH_MULTIPLATE = f"{METADATA_PATH}/plate_1.png"
THUMBNAIL_PATH_MULTIPLATE_SMALL = f"{METADATA_PATH}/plate_1_small.png"
GCODE_PATH = f"{METADATA_PATH}/plate_1.gcode"
GCODE_MD5_PATH = f"{GCODE_PATH}.md5"
MODEL_SETTINGS_PATH = f"{METADATA_PATH}/model_settings.config"
PLATE_DESC_PATH = f"{METADATA_PATH}/plate_1.json"
SLICE_INFO_PATH = f"{METADATA_PATH}/slice_info.config"
PROJECT_SETTINGS_PATH = f"{METADATA_PATH}/project_settings.config"
class BambuLabVariant(ThreeMFVariant):
"""BambuLab specific implementation of the 3MF format."""
@property
def mime_type(self) -> str:
return "application/vnd.bambulab-package.3dmanufacturing-3dmodel+xml"
def process_thumbnail(self, snapshot: QImage, thumbnail_buffer: QBuffer,
archive: zipfile.ZipFile, relations_element: ET.Element) -> None:
"""Process the thumbnail for BambuLab variant."""
# Write thumbnail
archive.writestr(zipfile.ZipInfo(THUMBNAIL_PATH_MULTIPLATE), thumbnail_buffer.data())
# Add relations elements for thumbnails
ET.SubElement(relations_element, "Relationship",
Target="/" + THUMBNAIL_PATH_MULTIPLATE, Id="rel-2",
pe="http://schemas.openxmlformats.org/package/2006/relationships/metadata/thumbnail")
ET.SubElement(relations_element, "Relationship",
Target="/" + THUMBNAIL_PATH_MULTIPLATE, Id="rel-4",
Type="http://schemas.bambulab.com/package/2021/cover-thumbnail-middle")
# Create and save small thumbnail
small_snapshot = snapshot.scaled(128, 128, transformMode=Qt.TransformationMode.SmoothTransformation)
small_thumbnail_buffer = QBuffer()
small_thumbnail_buffer.open(QBuffer.OpenModeFlag.ReadWrite)
small_snapshot.save(small_thumbnail_buffer, "PNG")
# Write small thumbnail
archive.writestr(zipfile.ZipInfo(THUMBNAIL_PATH_MULTIPLATE_SMALL), small_thumbnail_buffer.data())
# Add relation for small thumbnail
ET.SubElement(relations_element, "Relationship",
Target="/" + THUMBNAIL_PATH_MULTIPLATE_SMALL, Id="rel-5",
Type="http://schemas.bambulab.com/package/2021/cover-thumbnail-small")
def add_extra_files(self, archive: zipfile.ZipFile, metadata_relations_element: ET.Element) -> None:
"""Add BambuLab specific files to the archive."""
self._storeGCode(archive, metadata_relations_element)
self._storeModelSettings(archive)
self._storePlateDesc(archive)
self._storeSliceInfo(archive)
self._storeProjectSettings(archive)
def _storeGCode(self, archive: zipfile.ZipFile, metadata_relations_element: ET.Element):
"""Store GCode data in the archive."""
gcode_textio = StringIO()
gcode_writer = cast(MeshWriter, PluginRegistry.getInstance().getPluginObject("GCodeWriter"))
success = gcode_writer.write(gcode_textio, None)
if not success:
error_msg = catalog.i18nc("@info:error", "Can't write GCode to 3MF file")
self._writer.setInformation(error_msg)
Logger.error(error_msg)
raise Exception(error_msg)
gcode_data = gcode_textio.getvalue().encode("UTF-8")
archive.writestr(zipfile.ZipInfo(GCODE_PATH), gcode_data)
gcode_relation_element = ET.SubElement(metadata_relations_element, "Relationship",
Target=f"/{GCODE_PATH}", Id="rel-1",
Type="http://schemas.bambulab.com/package/2021/gcode")
# Calculate and store the MD5 sum of the gcode data
md5_hash = hashlib.md5(gcode_data).hexdigest()
archive.writestr(zipfile.ZipInfo(GCODE_MD5_PATH), md5_hash.encode("UTF-8"))
def _storeModelSettings(self, archive: zipfile.ZipFile):
"""Store model settings in the archive."""
config = ET.Element("config")
plate = ET.SubElement(config, "plate")
ET.SubElement(plate, "metadata", key="plater_id", value="1")
ET.SubElement(plate, "metadata", key="plater_name", value="")
ET.SubElement(plate, "metadata", key="locked", value="false")
ET.SubElement(plate, "metadata", key="filament_map_mode", value="Auto For Flush")
extruders_count = len(CuraApplication.getInstance().getExtruderManager().extruderIds)
ET.SubElement(plate, "metadata", key="filament_maps", value=" ".join("1" for _ in range(extruders_count)))
ET.SubElement(plate, "metadata", key="gcode_file", value=GCODE_PATH)
ET.SubElement(plate, "metadata", key="thumbnail_file", value=THUMBNAIL_PATH_MULTIPLATE)
ET.SubElement(plate, "metadata", key="pattern_bbox_file", value=PLATE_DESC_PATH)
self._writer._storeElementTree(archive, MODEL_SETTINGS_PATH, config)
def _storePlateDesc(self, archive: zipfile.ZipFile):
"""Store plate description in the archive."""
plate_desc = {}
filament_ids = []
filament_colors = []
for extruder in CuraApplication.getInstance().getExtruderManager().getUsedExtruderStacks():
filament_ids.append(extruder.getValue("extruder_nr"))
filament_colors.append(self._writer._getMaterialColor(extruder))
plate_desc["filament_ids"] = filament_ids
plate_desc["filament_colors"] = filament_colors
plate_desc["first_extruder"] = CuraApplication.getInstance().getExtruderManager().getInitialExtruderNr()
plate_desc["is_seq_print"] = Application.getInstance().getGlobalContainerStack().getValue("print_sequence") == "one_at_a_time"
plate_desc["nozzle_diameter"] = CuraApplication.getInstance().getExtruderManager().getActiveExtruderStack().getValue("machine_nozzle_size")
plate_desc["version"] = 2
file = zipfile.ZipInfo(PLATE_DESC_PATH)
file.compress_type = zipfile.ZIP_DEFLATED
archive.writestr(file, json.dumps(plate_desc).encode("UTF-8"))
def _storeSliceInfo(self, archive: zipfile.ZipFile):
"""Store slice information in the archive."""
config = ET.Element("config")
header = ET.SubElement(config, "header")
ET.SubElement(header, "header_item", key="X-BBL-Client-Type", value="slicer")
ET.SubElement(header, "header_item", key="X-BBL-Client-Version", value="02.00.01.50")
plate = ET.SubElement(config, "plate")
ET.SubElement(plate, "metadata", key="index", value="1")
ET.SubElement(plate,
"metadata",
key="nozzle_diameters",
value=str(CuraApplication.getInstance().getExtruderManager().getActiveExtruderStack().getValue("machine_nozzle_size")))
print_information = CuraApplication.getInstance().getPrintInformation()
for index, extruder in enumerate(Application.getInstance().getGlobalContainerStack().extruderList):
used_m = print_information.materialLengths[index]
used_g = print_information.materialWeights[index]
if used_m > 0.0 and used_g > 0.0:
ET.SubElement(plate,
"filament",
id=str(extruder.getValue("extruder_nr") + 1),
tray_info_idx="GFA00",
type=extruder.material.getMetaDataEntry("material", ""),
color=self._writer._getMaterialColor(extruder),
used_m=str(used_m),
used_g=str(used_g))
self._writer._storeElementTree(archive, SLICE_INFO_PATH, config)
def _storeProjectSettings(self, archive: zipfile.ZipFile):
api = CuraApplication.getInstance().getCuraAPI()
file = zipfile.ZipInfo(PROJECT_SETTINGS_PATH)
json_string = json.dumps(api.interface.settings.getAllGlobalSettings(), separators=(", ", ": "), indent=4)
archive.writestr(file, json_string.encode("UTF-8"))