MakerbotWriter.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380
  1. # Copyright (c) 2024 UltiMaker
  2. # Cura is released under the terms of the LGPLv3 or higher.
  3. from io import StringIO, BufferedIOBase
  4. import json
  5. from typing import cast, List, Optional, Dict, Tuple
  6. from zipfile import BadZipFile, ZipFile, ZIP_DEFLATED
  7. import pyDulcificum as du
  8. from PyQt6.QtCore import QBuffer
  9. from UM.Logger import Logger
  10. from UM.Math.AxisAlignedBox import AxisAlignedBox
  11. from UM.Mesh.MeshWriter import MeshWriter
  12. from UM.MimeTypeDatabase import MimeTypeDatabase, MimeType
  13. from UM.PluginRegistry import PluginRegistry
  14. from UM.Scene.SceneNode import SceneNode
  15. from UM.Scene.Iterator.DepthFirstIterator import DepthFirstIterator
  16. from UM.i18n import i18nCatalog
  17. from cura.CuraApplication import CuraApplication
  18. from cura.PrinterOutput.FormatMaps import FormatMaps
  19. from cura.Snapshot import Snapshot
  20. from cura.Utils.Threading import call_on_qt_thread
  21. from cura.CuraVersion import ConanInstalls
  22. catalog = i18nCatalog("cura")
  23. class MakerbotWriter(MeshWriter):
  24. """A file writer that writes '.makerbot' files."""
  25. def __init__(self) -> None:
  26. super().__init__(add_to_recent_files=False)
  27. Logger.info(f"Using PyDulcificum: {du.__version__}")
  28. MimeTypeDatabase.addMimeType(
  29. MimeType(
  30. name="application/x-makerbot",
  31. comment="Makerbot Toolpath Package",
  32. suffixes=["makerbot"]
  33. )
  34. )
  35. MimeTypeDatabase.addMimeType(
  36. MimeType(
  37. name="application/x-makerbot-sketch",
  38. comment="Makerbot Toolpath Package",
  39. suffixes=["makerbot"]
  40. )
  41. )
  42. MimeTypeDatabase.addMimeType(
  43. MimeType(
  44. name="application/x-makerbot-replicator_plus",
  45. comment="Makerbot Toolpath Package",
  46. suffixes=["makerbot"]
  47. )
  48. )
  49. _PNG_FORMAT = [
  50. {"prefix": "isometric_thumbnail", "width": 120, "height": 120},
  51. {"prefix": "isometric_thumbnail", "width": 320, "height": 320},
  52. {"prefix": "isometric_thumbnail", "width": 640, "height": 640},
  53. {"prefix": "thumbnail", "width": 90, "height": 90},
  54. ]
  55. _PNG_FORMAT_METHOD = [
  56. {"prefix": "thumbnail", "width": 140, "height": 106},
  57. {"prefix": "thumbnail", "width": 212, "height": 300},
  58. {"prefix": "thumbnail", "width": 960, "height": 1460},
  59. ]
  60. _META_VERSION = "3.0.0"
  61. # must be called from the main thread because of OpenGL
  62. @staticmethod
  63. @call_on_qt_thread
  64. def _createThumbnail(width: int, height: int) -> Optional[QBuffer]:
  65. if not CuraApplication.getInstance().isVisible:
  66. Logger.warning("Can't create snapshot when renderer not initialized.")
  67. return
  68. try:
  69. snapshot = Snapshot.isometricSnapshot(width, height)
  70. thumbnail_buffer = QBuffer()
  71. thumbnail_buffer.open(QBuffer.OpenModeFlag.WriteOnly)
  72. snapshot.save(thumbnail_buffer, "PNG")
  73. return thumbnail_buffer
  74. except:
  75. Logger.logException("w", "Failed to create snapshot image")
  76. return None
  77. def write(self, stream: BufferedIOBase, nodes: List[SceneNode], mode=MeshWriter.OutputMode.BinaryMode) -> bool:
  78. metadata, file_format = self._getMeta(nodes)
  79. if mode != MeshWriter.OutputMode.BinaryMode:
  80. Logger.log("e", "MakerbotWriter does not support text mode.")
  81. self.setInformation(catalog.i18nc("@error:not supported", "MakerbotWriter does not support text mode."))
  82. return False
  83. # The GCodeWriter plugin is always available since it is in the "required" list of plugins.
  84. gcode_writer = PluginRegistry.getInstance().getPluginObject("GCodeWriter")
  85. if gcode_writer is None:
  86. Logger.log("e", "Could not find the GCodeWriter plugin, is it disabled?.")
  87. self.setInformation(
  88. catalog.i18nc("@error:load", "Could not load GCodeWriter plugin. Try to re-enable the plugin."))
  89. return False
  90. gcode_writer = cast(MeshWriter, gcode_writer)
  91. gcode_text_io = StringIO()
  92. success = gcode_writer.write(gcode_text_io, None)
  93. filename, filedata = "", ""
  94. # Writing the g-code failed. Then I can also not write the gzipped g-code.
  95. if not success:
  96. self.setInformation(gcode_writer.getInformation())
  97. return False
  98. match file_format:
  99. case "application/x-makerbot-sketch":
  100. filename, filedata = "print.gcode", gcode_text_io.getvalue()
  101. case "application/x-makerbot":
  102. filename, filedata = "print.jsontoolpath", du.gcode_2_miracle_jtp(gcode_text_io.getvalue())
  103. case "application/x-makerbot-replicator_plus":
  104. filename, filedata = "print.jsontoolpath", du.gcode_2_miracle_jtp(gcode_text_io.getvalue(), nb_extruders=1)
  105. case _:
  106. raise Exception("Unsupported Mime type")
  107. png_files = []
  108. for png_format in (self._PNG_FORMAT + self._PNG_FORMAT_METHOD):
  109. width, height, prefix = png_format["width"], png_format["height"], png_format["prefix"]
  110. thumbnail_buffer = self._createThumbnail(width, height)
  111. if thumbnail_buffer is None:
  112. Logger.warning(f"Could not create thumbnail of size {width}x{height}.")
  113. continue
  114. png_files.append({
  115. "file": f"{prefix}_{width}x{height}.png",
  116. "data": thumbnail_buffer.data(),
  117. })
  118. try:
  119. with ZipFile(stream, "w", compression=ZIP_DEFLATED) as zip_stream:
  120. zip_stream.writestr("meta.json", json.dumps(metadata, indent=4))
  121. zip_stream.writestr(filename, filedata)
  122. for png_file in png_files:
  123. file, data = png_file["file"], png_file["data"]
  124. zip_stream.writestr(file, data)
  125. api = CuraApplication.getInstance().getCuraAPI()
  126. metadata_json = api.interface.settings.getSliceMetadata()
  127. # All the mapping stuff we have to do:
  128. product_to_id_map = FormatMaps.getProductIdMap()
  129. printer_name_map = FormatMaps.getInversePrinterNameMap()
  130. extruder_type_map = FormatMaps.getInverseExtruderTypeMap()
  131. material_map = FormatMaps.getInverseMaterialMap()
  132. for key, value in metadata_json.items():
  133. if "all_settings" in value:
  134. if "machine_name" in value["all_settings"]:
  135. machine_name = value["all_settings"]["machine_name"]
  136. if machine_name in product_to_id_map:
  137. machine_name = product_to_id_map[machine_name][0]
  138. value["all_settings"]["machine_name"] = printer_name_map.get(machine_name, machine_name)
  139. if "machine_nozzle_id" in value["all_settings"]:
  140. extruder_type = value["all_settings"]["machine_nozzle_id"]
  141. value["all_settings"]["machine_nozzle_id"] = extruder_type_map.get(extruder_type, extruder_type)
  142. if "material_type" in value["all_settings"]:
  143. material_type = value["all_settings"]["material_type"]
  144. value["all_settings"]["material_type"] = material_map.get(material_type, material_type)
  145. slice_metadata = json.dumps(metadata_json, separators=(", ", ": "), indent=4)
  146. zip_stream.writestr("slicemetadata.json", slice_metadata)
  147. except (IOError, OSError, BadZipFile) as ex:
  148. Logger.log("e", f"Could not write to (.makerbot) file because: '{ex}'.")
  149. self.setInformation(catalog.i18nc("@error", "MakerbotWriter could not save to the designated path."))
  150. return False
  151. return True
  152. def _getMeta(self, root_nodes: List[SceneNode]) -> Tuple[Dict[str, any], str]:
  153. application = CuraApplication.getInstance()
  154. machine_manager = application.getMachineManager()
  155. global_stack = machine_manager.activeMachine
  156. extruders = global_stack.extruderList
  157. nodes = []
  158. for root_node in root_nodes:
  159. for node in DepthFirstIterator(root_node):
  160. if not getattr(node, "_outside_buildarea", False):
  161. if node.callDecoration(
  162. "isSliceable") and node.getMeshData() and node.isVisible() and not node.callDecoration(
  163. "isNonThumbnailVisibleMesh"):
  164. nodes.append(node)
  165. meta = dict()
  166. # This is a bit of a "hack", the mime type should be passed through with the export writer but
  167. # since this is not the case we get the mime type from the global stack instead
  168. file_format = global_stack.definition.getMetaDataEntry("file_formats")
  169. meta["bot_type"] = global_stack.definition.getMetaDataEntry("reference_machine_id")
  170. bounds: Optional[AxisAlignedBox] = None
  171. for node in nodes:
  172. node_bounds = node.getBoundingBox()
  173. if node_bounds is None:
  174. continue
  175. if bounds is None:
  176. bounds = node_bounds
  177. else:
  178. bounds = bounds + node_bounds
  179. if file_format == "application/x-makerbot-sketch":
  180. bounds = None
  181. if bounds is not None:
  182. meta["bounding_box"] = {
  183. "x_min": bounds.left,
  184. "x_max": bounds.right,
  185. "y_min": bounds.back,
  186. "y_max": bounds.front,
  187. "z_min": bounds.bottom,
  188. "z_max": bounds.top,
  189. }
  190. material_bed_temperature = global_stack.getProperty("material_bed_temperature", "value")
  191. meta["platform_temperature"] = material_bed_temperature
  192. build_volume_temperature = global_stack.getProperty("build_volume_temperature", "value")
  193. meta["build_plane_temperature"] = build_volume_temperature
  194. print_information = application.getPrintInformation()
  195. meta["commanded_duration_s"] = int(print_information.currentPrintTime)
  196. meta["duration_s"] = int(print_information.currentPrintTime)
  197. material_lengths = list(map(meterToMillimeter, print_information.materialLengths))
  198. meta["extrusion_distance_mm"] = material_lengths[0]
  199. meta["extrusion_distances_mm"] = material_lengths
  200. meta["extrusion_mass_g"] = print_information.materialWeights[0]
  201. meta["extrusion_masses_g"] = print_information.materialWeights
  202. meta["uuid"] = print_information.slice_uuid
  203. materials = [extruder.material.getMetaData().get("reference_material_id") for extruder in extruders]
  204. meta["material"] = materials[0]
  205. meta["materials"] = materials
  206. materials_temps = [extruder.getProperty("default_material_print_temperature", "value") for extruder in
  207. extruders]
  208. meta["extruder_temperature"] = materials_temps[0]
  209. meta["extruder_temperatures"] = materials_temps
  210. meta["model_counts"] = [{"count": len(nodes), "name": "instance0"}]
  211. tool_types = [extruder.variant.getMetaDataEntry("reference_extruder_id") for extruder in extruders]
  212. meta["tool_type"] = tool_types[0]
  213. meta["tool_types"] = tool_types
  214. meta["version"] = MakerbotWriter._META_VERSION
  215. meta["preferences"] = dict()
  216. bounds = application.getBuildVolume().getBoundingBox()
  217. intent = CuraApplication.getInstance().getIntentManager().currentIntentCategory
  218. meta["preferences"]["instance0"] = {
  219. "machineBounds": [bounds.right, bounds.front, bounds.left, bounds.back] if bounds is not None else None,
  220. "printMode": intent
  221. }
  222. if file_format == "application/x-makerbot":
  223. accel_overrides = meta["accel_overrides"] = {}
  224. if intent in ['highspeed', 'highspeedsolid']:
  225. accel_overrides['do_input_shaping'] = True
  226. accel_overrides['do_corner_rounding'] = True
  227. bead_mode_overrides = accel_overrides["bead_mode"] = {}
  228. accel_enabled = global_stack.getProperty('acceleration_enabled', 'value')
  229. if accel_enabled:
  230. global_accel_setting = global_stack.getProperty('acceleration_print', 'value')
  231. accel_overrides["rate_mm_per_s_sq"] = {
  232. "x": global_accel_setting,
  233. "y": global_accel_setting
  234. }
  235. if global_stack.getProperty('acceleration_travel_enabled', 'value'):
  236. travel_accel_setting = global_stack.getProperty('acceleration_travel', 'value')
  237. bead_mode_overrides['Travel Move'] = {
  238. "rate_mm_per_s_sq": {
  239. "x": travel_accel_setting,
  240. "y": travel_accel_setting
  241. }
  242. }
  243. jerk_enabled = global_stack.getProperty('jerk_enabled', 'value')
  244. if jerk_enabled:
  245. global_jerk_setting = global_stack.getProperty('jerk_print', 'value')
  246. accel_overrides["max_speed_change_mm_per_s"] = {
  247. "x": global_jerk_setting,
  248. "y": global_jerk_setting
  249. }
  250. if global_stack.getProperty('jerk_travel_enabled', 'value'):
  251. travel_jerk_setting = global_stack.getProperty('jerk_travel', 'value')
  252. if 'Travel Move' not in bead_mode_overrides:
  253. bead_mode_overrides['Travel Move' ] = {}
  254. bead_mode_overrides['Travel Move'].update({
  255. "max_speed_change_mm_per_s": {
  256. "x": travel_jerk_setting,
  257. "y": travel_jerk_setting
  258. }
  259. })
  260. # Get bead mode settings per extruder
  261. available_bead_modes = {
  262. "infill": "FILL",
  263. "prime_tower": "PRIME_TOWER",
  264. "roofing": "TOP_SURFACE",
  265. "support_infill": "SUPPORT",
  266. "support_interface": "SUPPORT_INTERFACE",
  267. "wall_0": "WALL_OUTER",
  268. "wall_x": "WALL_INNER",
  269. "skirt_brim": "SKIRT"
  270. }
  271. for idx, extruder in enumerate(extruders):
  272. for bead_mode_setting, bead_mode_tag in available_bead_modes.items():
  273. ext_specific_tag = "%s_%s" % (bead_mode_tag, idx)
  274. if accel_enabled or jerk_enabled:
  275. bead_mode_overrides[ext_specific_tag] = {}
  276. if accel_enabled:
  277. accel_val = extruder.getProperty('acceleration_%s' % bead_mode_setting, 'value')
  278. bead_mode_overrides[ext_specific_tag]["rate_mm_per_s_sq"] = {
  279. "x": accel_val,
  280. "y": accel_val
  281. }
  282. if jerk_enabled:
  283. jerk_val = extruder.getProperty('jerk_%s' % bead_mode_setting, 'value')
  284. bead_mode_overrides[ext_specific_tag][ "max_speed_change_mm_per_s"] = {
  285. "x": jerk_val,
  286. "y": jerk_val
  287. }
  288. meta["miracle_config"] = {"gaggles": {"instance0": {}}}
  289. version_info = dict()
  290. cura_engine_info = ConanInstalls.get("curaengine", {"version": "unknown", "revision": "unknown"})
  291. version_info["curaengine_version"] = cura_engine_info["version"]
  292. version_info["curaengine_commit_hash"] = cura_engine_info["revision"]
  293. dulcificum_info = ConanInstalls.get("dulcificum", {"version": "unknown", "revision": "unknown"})
  294. version_info["dulcificum_version"] = dulcificum_info["version"]
  295. version_info["dulcificum_commit_hash"] = dulcificum_info["revision"]
  296. version_info["makerbot_writer_version"] = self.getVersion()
  297. version_info["pyDulcificum_version"] = du.__version__
  298. # Add engine plugin information to the metadata
  299. for name, package_info in ConanInstalls.items():
  300. if not name.startswith("curaengine_"):
  301. continue
  302. version_info[f"{name}_version"] = package_info["version"]
  303. version_info[f"{name}_commit_hash"] = package_info["revision"]
  304. # Add version info to the main metadata, but also to "miracle_config"
  305. # so that it shows up in analytics
  306. meta["miracle_config"].update(version_info)
  307. meta.update(version_info)
  308. # TODO add the following instructions
  309. # num_tool_changes
  310. # num_z_layers
  311. # num_z_transitions
  312. # platform_temperature
  313. # total_commands
  314. return meta, file_format
  315. def meterToMillimeter(value: float) -> float:
  316. """Converts a value in meters to millimeters."""
  317. return value * 1000.0