ThreeMFWriter.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476
  1. # Copyright (c) 2015-2022 Ultimaker B.V.
  2. # Cura is released under the terms of the LGPLv3 or higher.
  3. import json
  4. import re
  5. import threading
  6. from typing import Optional, cast, List, Dict, Pattern, Set
  7. from UM.Mesh.MeshWriter import MeshWriter
  8. from UM.Math.Vector import Vector
  9. from UM.Logger import Logger
  10. from UM.Math.Matrix import Matrix
  11. from UM.Application import Application
  12. from UM.OutputDevice import OutputDeviceError
  13. from UM.Message import Message
  14. from UM.Resources import Resources
  15. from UM.Scene.SceneNode import SceneNode
  16. from UM.Settings.ContainerRegistry import ContainerRegistry
  17. from cura.CuraApplication import CuraApplication
  18. from cura.CuraPackageManager import CuraPackageManager
  19. from cura.Settings import CuraContainerStack
  20. from cura.Utils.Threading import call_on_qt_thread
  21. from cura.Scene.CuraSceneNode import CuraSceneNode
  22. from cura.Snapshot import Snapshot
  23. from PyQt6.QtCore import Qt, QBuffer
  24. from PyQt6.QtGui import QImage, QPainter
  25. import pySavitar as Savitar
  26. from .UCPDialog import UCPDialog
  27. import numpy
  28. import datetime
  29. MYPY = False
  30. try:
  31. if not MYPY:
  32. import xml.etree.cElementTree as ET
  33. except ImportError:
  34. Logger.log("w", "Unable to load cElementTree, switching to slower version")
  35. import xml.etree.ElementTree as ET
  36. import zipfile
  37. import UM.Application
  38. from .SettingsExportModel import SettingsExportModel
  39. from .SettingsExportGroup import SettingsExportGroup
  40. from UM.i18n import i18nCatalog
  41. catalog = i18nCatalog("cura")
  42. THUMBNAIL_PATH = "Metadata/thumbnail.png"
  43. MODEL_PATH = "3D/3dmodel.model"
  44. PACKAGE_METADATA_PATH = "Cura/packages.json"
  45. class ThreeMFWriter(MeshWriter):
  46. def __init__(self):
  47. super().__init__()
  48. self._namespaces = {
  49. "3mf": "http://schemas.microsoft.com/3dmanufacturing/core/2015/02",
  50. "content-types": "http://schemas.openxmlformats.org/package/2006/content-types",
  51. "relationships": "http://schemas.openxmlformats.org/package/2006/relationships",
  52. "cura": "http://software.ultimaker.com/xml/cura/3mf/2015/10"
  53. }
  54. self._unit_matrix_string = ThreeMFWriter._convertMatrixToString(Matrix())
  55. self._archive: Optional[zipfile.ZipFile] = None
  56. self._store_archive = False
  57. self._lock = threading.Lock()
  58. @staticmethod
  59. def _convertMatrixToString(matrix):
  60. result = ""
  61. result += str(matrix._data[0, 0]) + " "
  62. result += str(matrix._data[1, 0]) + " "
  63. result += str(matrix._data[2, 0]) + " "
  64. result += str(matrix._data[0, 1]) + " "
  65. result += str(matrix._data[1, 1]) + " "
  66. result += str(matrix._data[2, 1]) + " "
  67. result += str(matrix._data[0, 2]) + " "
  68. result += str(matrix._data[1, 2]) + " "
  69. result += str(matrix._data[2, 2]) + " "
  70. result += str(matrix._data[0, 3]) + " "
  71. result += str(matrix._data[1, 3]) + " "
  72. result += str(matrix._data[2, 3])
  73. return result
  74. def setStoreArchive(self, store_archive):
  75. """Should we store the archive
  76. Note that if this is true, the archive will not be closed.
  77. The object that set this parameter is then responsible for closing it correctly!
  78. """
  79. self._store_archive = store_archive
  80. @staticmethod
  81. def _convertUMNodeToSavitarNode(um_node,
  82. transformation = Matrix(),
  83. exported_settings: Optional[Dict[str, Set[str]]] = None,
  84. center_mesh = False):
  85. """Convenience function that converts an Uranium SceneNode object to a SavitarSceneNode
  86. :returns: Uranium Scene node.
  87. """
  88. if not isinstance(um_node, SceneNode):
  89. return None
  90. active_build_plate_nr = CuraApplication.getInstance().getMultiBuildPlateModel().activeBuildPlate
  91. if um_node.callDecoration("getBuildPlateNumber") != active_build_plate_nr:
  92. return
  93. savitar_node = Savitar.SceneNode()
  94. savitar_node.setName(um_node.getName())
  95. mesh_data = um_node.getMeshData()
  96. if center_mesh:
  97. node_matrix = Matrix()
  98. # compensate for original center position, if object(s) is/are not around its zero position
  99. if mesh_data is not None:
  100. extents = mesh_data.getExtents()
  101. if extents is not None:
  102. # We use a different coordinate space while writing, so flip Z and Y
  103. center_vector = Vector(extents.center.x, extents.center.y, extents.center.z)
  104. node_matrix.setByTranslation(center_vector)
  105. node_matrix.multiply(um_node.getLocalTransformation())
  106. else:
  107. node_matrix = um_node.getLocalTransformation()
  108. matrix_string = ThreeMFWriter._convertMatrixToString(node_matrix.preMultiply(transformation))
  109. savitar_node.setTransformation(matrix_string)
  110. if mesh_data is not None:
  111. savitar_node.getMeshData().setVerticesFromBytes(mesh_data.getVerticesAsByteArray())
  112. indices_array = mesh_data.getIndicesAsByteArray()
  113. if indices_array is not None:
  114. savitar_node.getMeshData().setFacesFromBytes(indices_array)
  115. else:
  116. savitar_node.getMeshData().setFacesFromBytes(numpy.arange(mesh_data.getVertices().size / 3, dtype=numpy.int32).tostring())
  117. # Handle per object settings (if any)
  118. stack = um_node.callDecoration("getStack")
  119. if stack is not None:
  120. changed_setting_keys = stack.getTop().getAllKeys()
  121. if exported_settings is None:
  122. # Ensure that we save the extruder used for this object in a multi-extrusion setup
  123. if stack.getProperty("machine_extruder_count", "value") > 1:
  124. changed_setting_keys.add("extruder_nr")
  125. # Get values for all changed settings & save them.
  126. for key in changed_setting_keys:
  127. savitar_node.setSetting("cura:" + key, str(stack.getProperty(key, "value")))
  128. else:
  129. # We want to export only the specified settings
  130. if um_node.getName() in exported_settings:
  131. model_exported_settings = exported_settings[um_node.getName()]
  132. # Get values for all exported settings & save them.
  133. for key in model_exported_settings:
  134. savitar_node.setSetting("cura:" + key, str(stack.getProperty(key, "value")))
  135. if isinstance(um_node, CuraSceneNode):
  136. savitar_node.setSetting("cura:print_order", str(um_node.printOrder))
  137. savitar_node.setSetting("cura:drop_to_buildplate", str(um_node.isDropDownEnabled))
  138. # Store the metadata.
  139. for key, value in um_node.metadata.items():
  140. savitar_node.setSetting(key, value)
  141. for child_node in um_node.getChildren():
  142. # only save the nodes on the active build plate
  143. if child_node.callDecoration("getBuildPlateNumber") != active_build_plate_nr:
  144. continue
  145. savitar_child_node = ThreeMFWriter._convertUMNodeToSavitarNode(child_node,
  146. exported_settings = exported_settings)
  147. if savitar_child_node is not None:
  148. savitar_node.addChild(savitar_child_node)
  149. return savitar_node
  150. def getArchive(self):
  151. return self._archive
  152. def _addLogoToThumbnail(self, primary_image, logo_name):
  153. # Load the icon png image
  154. icon_image = QImage(Resources.getPath(Resources.Images, logo_name))
  155. # Resize icon_image to be 1/4 of primary_image size
  156. new_width = int(primary_image.width() / 4)
  157. new_height = int(primary_image.height() / 4)
  158. icon_image = icon_image.scaled(new_width, new_height, Qt.AspectRatioMode.KeepAspectRatio)
  159. # Create a QPainter to draw on the image
  160. painter = QPainter(primary_image)
  161. # Draw the icon in the top-left corner (adjust coordinates as needed)
  162. icon_position = (10, 10)
  163. painter.drawImage(icon_position[0], icon_position[1], icon_image)
  164. painter.end()
  165. def write(self, stream, nodes, mode = MeshWriter.OutputMode.BinaryMode, export_settings_model = None) -> bool:
  166. self._archive = None # Reset archive
  167. archive = zipfile.ZipFile(stream, "w", compression = zipfile.ZIP_DEFLATED)
  168. try:
  169. model_file = zipfile.ZipInfo(MODEL_PATH)
  170. # Because zipfile is stupid and ignores archive-level compression settings when writing with ZipInfo.
  171. model_file.compress_type = zipfile.ZIP_DEFLATED
  172. # Create content types file
  173. content_types_file = zipfile.ZipInfo("[Content_Types].xml")
  174. content_types_file.compress_type = zipfile.ZIP_DEFLATED
  175. content_types = ET.Element("Types", xmlns = self._namespaces["content-types"])
  176. rels_type = ET.SubElement(content_types, "Default", Extension = "rels", ContentType = "application/vnd.openxmlformats-package.relationships+xml")
  177. model_type = ET.SubElement(content_types, "Default", Extension = "model", ContentType = "application/vnd.ms-package.3dmanufacturing-3dmodel+xml")
  178. # Create _rels/.rels file
  179. relations_file = zipfile.ZipInfo("_rels/.rels")
  180. relations_file.compress_type = zipfile.ZIP_DEFLATED
  181. relations_element = ET.Element("Relationships", xmlns = self._namespaces["relationships"])
  182. model_relation_element = ET.SubElement(relations_element, "Relationship", Target = "/" + MODEL_PATH, Id = "rel0", Type = "http://schemas.microsoft.com/3dmanufacturing/2013/01/3dmodel")
  183. # Attempt to add a thumbnail
  184. snapshot = self._createSnapshot()
  185. if snapshot:
  186. if export_settings_model != None:
  187. self._addLogoToThumbnail(snapshot, "cura-share.png")
  188. elif export_settings_model == None and self._store_archive:
  189. self._addLogoToThumbnail(snapshot, "cura-icon.png")
  190. thumbnail_buffer = QBuffer()
  191. thumbnail_buffer.open(QBuffer.OpenModeFlag.ReadWrite)
  192. snapshot.save(thumbnail_buffer, "PNG")
  193. thumbnail_file = zipfile.ZipInfo(THUMBNAIL_PATH)
  194. # Don't try to compress snapshot file, because the PNG is pretty much as compact as it will get
  195. archive.writestr(thumbnail_file, thumbnail_buffer.data())
  196. # Add PNG to content types file
  197. thumbnail_type = ET.SubElement(content_types, "Default", Extension="png", ContentType="image/png")
  198. # Add thumbnail relation to _rels/.rels file
  199. thumbnail_relation_element = ET.SubElement(relations_element, "Relationship",
  200. Target="/" + THUMBNAIL_PATH, Id="rel1",
  201. Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/thumbnail")
  202. # Write material metadata
  203. packages_metadata = self._getMaterialPackageMetadata() + self._getPluginPackageMetadata()
  204. self._storeMetadataJson({"packages": packages_metadata}, archive, PACKAGE_METADATA_PATH)
  205. savitar_scene = Savitar.Scene()
  206. scene_metadata = CuraApplication.getInstance().getController().getScene().getMetaData()
  207. for key, value in scene_metadata.items():
  208. savitar_scene.setMetaDataEntry(key, value)
  209. current_time_string = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
  210. if "Application" not in scene_metadata:
  211. # This might sound a bit strange, but this field should store the original application that created
  212. # the 3mf. So if it was already set, leave it to whatever it was.
  213. savitar_scene.setMetaDataEntry("Application", CuraApplication.getInstance().getApplicationDisplayName())
  214. if "CreationDate" not in scene_metadata:
  215. savitar_scene.setMetaDataEntry("CreationDate", current_time_string)
  216. savitar_scene.setMetaDataEntry("ModificationDate", current_time_string)
  217. transformation_matrix = Matrix()
  218. transformation_matrix._data[1, 1] = 0
  219. transformation_matrix._data[1, 2] = -1
  220. transformation_matrix._data[2, 1] = 1
  221. transformation_matrix._data[2, 2] = 0
  222. global_container_stack = Application.getInstance().getGlobalContainerStack()
  223. # Second step: 3MF defines the left corner of the machine as center, whereas cura uses the center of the
  224. # build volume.
  225. if global_container_stack:
  226. translation_vector = Vector(x=global_container_stack.getProperty("machine_width", "value") / 2,
  227. y=global_container_stack.getProperty("machine_depth", "value") / 2,
  228. z=0)
  229. translation_matrix = Matrix()
  230. translation_matrix.setByTranslation(translation_vector)
  231. transformation_matrix.preMultiply(translation_matrix)
  232. root_node = UM.Application.Application.getInstance().getController().getScene().getRoot()
  233. exported_model_settings = ThreeMFWriter._extractModelExportedSettings(export_settings_model) if export_settings_model != None else None
  234. for node in nodes:
  235. if node == root_node:
  236. for root_child in node.getChildren():
  237. savitar_node = ThreeMFWriter._convertUMNodeToSavitarNode(root_child,
  238. transformation_matrix,
  239. exported_model_settings,
  240. center_mesh = True)
  241. if savitar_node:
  242. savitar_scene.addSceneNode(savitar_node)
  243. else:
  244. savitar_node = self._convertUMNodeToSavitarNode(node,
  245. transformation_matrix,
  246. exported_model_settings)
  247. if savitar_node:
  248. savitar_scene.addSceneNode(savitar_node)
  249. parser = Savitar.ThreeMFParser()
  250. scene_string = parser.sceneToString(savitar_scene)
  251. archive.writestr(model_file, scene_string)
  252. archive.writestr(content_types_file, b'<?xml version="1.0" encoding="UTF-8"?> \n' + ET.tostring(content_types))
  253. archive.writestr(relations_file, b'<?xml version="1.0" encoding="UTF-8"?> \n' + ET.tostring(relations_element))
  254. except Exception as error:
  255. Logger.logException("e", "Error writing zip file")
  256. self.setInformation(str(error))
  257. return False
  258. finally:
  259. if not self._store_archive:
  260. archive.close()
  261. else:
  262. self._archive = archive
  263. return True
  264. @staticmethod
  265. def _storeMetadataJson(metadata: Dict[str, List[Dict[str, str]]], archive: zipfile.ZipFile, path: str) -> None:
  266. """Stores metadata inside archive path as json file"""
  267. metadata_file = zipfile.ZipInfo(path)
  268. # We have to set the compress type of each file as well (it doesn't keep the type of the entire archive)
  269. metadata_file.compress_type = zipfile.ZIP_DEFLATED
  270. archive.writestr(metadata_file,
  271. json.dumps(metadata, separators=(", ", ": "), indent=4, skipkeys=True, ensure_ascii=False))
  272. @staticmethod
  273. def _getPluginPackageMetadata() -> List[Dict[str, str]]:
  274. """Get metadata for all backend plugins that are used in the project.
  275. :return: List of material metadata dictionaries.
  276. """
  277. backend_plugin_enum_value_regex = re.compile(
  278. r"PLUGIN::(?P<plugin_id>\w+)@(?P<version>\d+.\d+.\d+)::(?P<value>\w+)")
  279. # This regex parses enum values to find if they contain custom
  280. # backend engine values. These custom enum values are in the format
  281. # PLUGIN::<plugin_id>@<version>::<value>
  282. # where
  283. # - plugin_id is the id of the plugin
  284. # - version is in the semver format
  285. # - value is the value of the enum
  286. plugin_ids = set()
  287. def addPluginIdsInStack(stack: CuraContainerStack) -> None:
  288. for key in stack.getAllKeys():
  289. value = str(stack.getProperty(key, "value"))
  290. for plugin_id, _version, _value in backend_plugin_enum_value_regex.findall(value):
  291. plugin_ids.add(plugin_id)
  292. # Go through all stacks and find all the plugin id contained in the project
  293. global_stack = CuraApplication.getInstance().getMachineManager().activeMachine
  294. addPluginIdsInStack(global_stack)
  295. for container in global_stack.getContainers():
  296. addPluginIdsInStack(container)
  297. for extruder_stack in global_stack.extruderList:
  298. addPluginIdsInStack(extruder_stack)
  299. for container in extruder_stack.getContainers():
  300. addPluginIdsInStack(container)
  301. metadata = {}
  302. package_manager = cast(CuraPackageManager, CuraApplication.getInstance().getPackageManager())
  303. for plugin_id in plugin_ids:
  304. package_data = package_manager.getInstalledPackageInfo(plugin_id)
  305. metadata[plugin_id] = {
  306. "id": plugin_id,
  307. "display_name": package_data.get("display_name") if package_data.get("display_name") else "",
  308. "package_version": package_data.get("package_version") if package_data.get("package_version") else "",
  309. "sdk_version_semver": package_data.get("sdk_version_semver") if package_data.get(
  310. "sdk_version_semver") else "",
  311. "type": "plugin",
  312. }
  313. # Storing in a dict and fetching values to avoid duplicates
  314. return list(metadata.values())
  315. @staticmethod
  316. def _getMaterialPackageMetadata() -> List[Dict[str, str]]:
  317. """Get metadata for installed materials in active extruder stack, this does not include bundled materials.
  318. :return: List of material metadata dictionaries.
  319. """
  320. metadata = {}
  321. package_manager = cast(CuraPackageManager, CuraApplication.getInstance().getPackageManager())
  322. for extruder in CuraApplication.getInstance().getExtruderManager().getActiveExtruderStacks():
  323. if not extruder.isEnabled:
  324. # Don't export materials not in use
  325. continue
  326. if isinstance(extruder.material, type(ContainerRegistry.getInstance().getEmptyInstanceContainer())):
  327. # This is an empty material container, no material to export
  328. continue
  329. if package_manager.isMaterialBundled(extruder.material.getFileName(), extruder.material.getMetaDataEntry("GUID")):
  330. # Don't export bundled materials
  331. continue
  332. package_id = package_manager.getMaterialFilePackageId(extruder.material.getFileName(),
  333. extruder.material.getMetaDataEntry("GUID"))
  334. package_data = package_manager.getInstalledPackageInfo(package_id)
  335. # We failed to find the package for this material
  336. if not package_data:
  337. Logger.info(f"Could not find package for material in extruder {extruder.id}, skipping.")
  338. continue
  339. material_metadata = {
  340. "id": package_id,
  341. "display_name": package_data.get("display_name") if package_data.get("display_name") else "",
  342. "package_version": package_data.get("package_version") if package_data.get("package_version") else "",
  343. "sdk_version_semver": package_data.get("sdk_version_semver") if package_data.get(
  344. "sdk_version_semver") else "",
  345. "type": "material",
  346. }
  347. metadata[package_id] = material_metadata
  348. # Storing in a dict and fetching values to avoid duplicates
  349. return list(metadata.values())
  350. @call_on_qt_thread # must be called from the main thread because of OpenGL
  351. def _createSnapshot(self):
  352. Logger.log("d", "Creating thumbnail image...")
  353. self._lock.acquire()
  354. if not CuraApplication.getInstance().isVisible:
  355. Logger.log("w", "Can't create snapshot when renderer not initialized.")
  356. return None
  357. try:
  358. snapshot = Snapshot.snapshot(width=300, height=300)
  359. except:
  360. Logger.logException("w", "Failed to create snapshot image")
  361. return None
  362. finally: self._lock.release()
  363. return snapshot
  364. @staticmethod
  365. def sceneNodesToString(scene_nodes: [SceneNode]) -> str:
  366. savitar_scene = Savitar.Scene()
  367. for scene_node in scene_nodes:
  368. savitar_node = ThreeMFWriter._convertUMNodeToSavitarNode(scene_node, center_mesh = True)
  369. savitar_scene.addSceneNode(savitar_node)
  370. parser = Savitar.ThreeMFParser()
  371. scene_string = parser.sceneToString(savitar_scene)
  372. return scene_string
  373. @staticmethod
  374. def _extractModelExportedSettings(model: Optional[SettingsExportModel]) -> Dict[str, Set[str]]:
  375. extra_settings = {}
  376. if model is not None:
  377. for group in model.settingsGroups:
  378. if group.category == SettingsExportGroup.Category.Model:
  379. exported_model_settings = set()
  380. for exported_setting in group.settings:
  381. if exported_setting.selected:
  382. exported_model_settings.add(exported_setting.id)
  383. extra_settings[group.category_details] = exported_model_settings
  384. return extra_settings
  385. def exportUcp(self):
  386. self._config_dialog = UCPDialog()
  387. self._config_dialog.show()