threemf_tools.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309
  1. """3MF file parsing utilities for filament tracking.
  2. This module provides functions to parse Bambu Lab 3MF files and extract
  3. per-layer filament usage data from the embedded G-code. This enables
  4. accurate partial usage reporting for multi-material prints.
  5. """
  6. import json
  7. import math
  8. import re
  9. import zipfile
  10. from pathlib import Path
  11. import defusedxml.ElementTree as ET
  12. from defusedxml.ElementTree import ParseError as XMLParseError
  13. # Default filament properties
  14. DEFAULT_FILAMENT_DIAMETER = 1.75 # mm
  15. DEFAULT_FILAMENT_DENSITY = 1.24 # g/cm³ (PLA)
  16. def parse_gcode_layer_filament_usage(gcode_content: str) -> dict[int, dict[int, float]]:
  17. """Parse G-code to extract per-layer, per-filament cumulative extrusion in mm.
  18. This function tracks filament extrusion across layers and tool changes,
  19. building a cumulative usage map that can be used to calculate partial
  20. usage at any layer.
  21. Args:
  22. gcode_content: The raw G-code content as a string
  23. Returns:
  24. A nested dictionary mapping layer numbers to filament usage:
  25. {layer: {filament_id: cumulative_mm}, ...}
  26. Example:
  27. {0: {0: 125.5}, 1: {0: 250.0, 1: 50.0}, 2: {0: 375.0, 1: 150.0}}
  28. This shows:
  29. - Layer 0: filament 0 used 125.5mm cumulative
  30. - Layer 1: filament 0 used 250mm cumulative, filament 1 used 50mm
  31. - Layer 2: filament 0 used 375mm cumulative, filament 1 used 150mm
  32. G-code commands parsed:
  33. - M73 L<layer>: Layer change marker
  34. - M620 S<filament>: Filament/tool change (S255 = unload)
  35. - G0/G1/G2/G3 E<amount>: Extrusion moves
  36. """
  37. layer_filaments: dict[int, dict[int, float]] = {}
  38. current_layer = 0
  39. active_filament: int | None = None
  40. cumulative_extrusion: dict[int, float] = {} # filament_id -> total mm
  41. for line in gcode_content.splitlines():
  42. line = line.strip()
  43. if not line:
  44. continue
  45. # Handle comments - skip but check for layer markers
  46. if line.startswith(";"):
  47. # Some slicers use comment-based layer markers
  48. # e.g., "; CHANGE_LAYER" or ";LAYER_CHANGE"
  49. continue
  50. # Split line into command and inline comment
  51. if ";" in line:
  52. line = line.split(";")[0].strip()
  53. # Extract command and parameters
  54. parts = line.split()
  55. if not parts:
  56. continue
  57. cmd = parts[0].upper()
  58. # Layer change: M73 L<layer>
  59. # Bambu printers use M73 with L parameter for layer indication
  60. if cmd == "M73":
  61. for part in parts[1:]:
  62. part_upper = part.upper()
  63. if part_upper.startswith("L"):
  64. try:
  65. new_layer = int(part[1:])
  66. # Save current state before layer change
  67. if cumulative_extrusion:
  68. layer_filaments[current_layer] = cumulative_extrusion.copy()
  69. current_layer = new_layer
  70. except ValueError:
  71. pass # Skip G-code lines with unparseable layer numbers
  72. # Filament change: M620 S<filament>
  73. # Bambu uses M620 for AMS filament switching
  74. # S255 means full unload (no active filament)
  75. elif cmd == "M620":
  76. for part in parts[1:]:
  77. part_upper = part.upper()
  78. if part_upper.startswith("S"):
  79. filament_str = part[1:]
  80. if filament_str == "255":
  81. # Full unload - no active filament
  82. active_filament = None
  83. else:
  84. try:
  85. # Extract digits (e.g., "0A" -> 0, "1" -> 1)
  86. match = re.match(r"(\d+)", filament_str)
  87. if match:
  88. active_filament = int(match.group(1))
  89. except (ValueError, AttributeError):
  90. pass # Skip unparseable filament switch commands
  91. # Extrusion moves: G0/G1/G2/G3 with E parameter
  92. # Only G1 typically has extrusion, but check all for safety
  93. elif cmd in ("G0", "G1", "G2", "G3"):
  94. if active_filament is None:
  95. continue
  96. for part in parts[1:]:
  97. part_upper = part.upper()
  98. if part_upper.startswith("E"):
  99. try:
  100. extrusion = float(part[1:])
  101. # Only count positive extrusion (not retractions)
  102. if extrusion > 0:
  103. current = cumulative_extrusion.get(active_filament, 0)
  104. cumulative_extrusion[active_filament] = current + extrusion
  105. except ValueError:
  106. pass # Skip G-code lines with unparseable extrusion values
  107. # Save final layer state
  108. if cumulative_extrusion:
  109. layer_filaments[current_layer] = cumulative_extrusion.copy()
  110. return layer_filaments
  111. def mm_to_grams(
  112. length_mm: float,
  113. diameter_mm: float = DEFAULT_FILAMENT_DIAMETER,
  114. density_g_cm3: float = DEFAULT_FILAMENT_DENSITY,
  115. ) -> float:
  116. """Convert filament length in mm to weight in grams.
  117. Uses the formula: mass = volume × density
  118. where volume = π × r² × length
  119. Args:
  120. length_mm: Length of filament in millimeters
  121. diameter_mm: Filament diameter in millimeters (default: 1.75)
  122. density_g_cm3: Material density in g/cm³ (default: 1.24 for PLA)
  123. Returns:
  124. Weight in grams
  125. """
  126. radius_cm = (diameter_mm / 2) / 10 # Convert mm to cm
  127. length_cm = length_mm / 10 # Convert mm to cm
  128. volume_cm3 = math.pi * radius_cm * radius_cm * length_cm
  129. return volume_cm3 * density_g_cm3
  130. def extract_layer_filament_usage_from_3mf(file_path: Path) -> dict[int, dict[int, float]] | None:
  131. """Extract per-layer filament usage from a 3MF file's embedded G-code.
  132. Args:
  133. file_path: Path to the 3MF file
  134. Returns:
  135. Dictionary mapping layers to filament usage, or None if parsing fails.
  136. Format: {layer: {filament_id: cumulative_mm}, ...}
  137. """
  138. try:
  139. with zipfile.ZipFile(file_path, "r") as zf:
  140. # Find G-code file(s) - usually plate_1.gcode or Metadata/plate_1.gcode
  141. gcode_files = [f for f in zf.namelist() if f.endswith(".gcode")]
  142. if not gcode_files:
  143. return None
  144. # Use the first G-code file (typically only one per 3MF export)
  145. gcode_path = gcode_files[0]
  146. gcode_content = zf.read(gcode_path).decode("utf-8", errors="ignore")
  147. return parse_gcode_layer_filament_usage(gcode_content)
  148. except (zipfile.BadZipFile, OSError, UnicodeDecodeError):
  149. return None
  150. def get_cumulative_usage_at_layer(
  151. layer_usage: dict[int, dict[int, float]],
  152. target_layer: int,
  153. ) -> dict[int, float]:
  154. """Get cumulative filament usage (in mm) up to and including target_layer.
  155. Args:
  156. layer_usage: The output from parse_gcode_layer_filament_usage()
  157. target_layer: The layer number to get usage for
  158. Returns:
  159. Dictionary of {filament_id: cumulative_mm} for each filament used
  160. up to target_layer. Returns empty dict if no data available.
  161. """
  162. if not layer_usage:
  163. return {}
  164. # Find the highest recorded layer <= target_layer
  165. # (we store snapshots at layer changes, so we need the closest one)
  166. relevant_layers = [layer for layer in layer_usage if layer <= target_layer]
  167. if not relevant_layers:
  168. return {}
  169. max_layer = max(relevant_layers)
  170. return layer_usage.get(max_layer, {})
  171. def extract_filament_properties_from_3mf(file_path: Path) -> dict[int, dict]:
  172. """Extract filament properties (density, diameter, type) from 3MF metadata.
  173. Args:
  174. file_path: Path to the 3MF file
  175. Returns:
  176. Dictionary mapping filament IDs to their properties:
  177. {filament_id: {"diameter": 1.75, "density": 1.24, "type": "PLA"}, ...}
  178. Note: filament_id is 1-based (matches slot_id in slice_info.config)
  179. """
  180. properties: dict[int, dict] = {}
  181. try:
  182. with zipfile.ZipFile(file_path, "r") as zf:
  183. # Try slice_info.config first for filament types
  184. if "Metadata/slice_info.config" in zf.namelist():
  185. content = zf.read("Metadata/slice_info.config").decode()
  186. root = ET.fromstring(content)
  187. for f in root.findall(".//filament"):
  188. try:
  189. # id is 1-based in slice_info.config
  190. fid = int(f.get("id", 0))
  191. properties[fid] = {
  192. "type": f.get("type", "PLA"),
  193. "diameter": DEFAULT_FILAMENT_DIAMETER,
  194. "density": DEFAULT_FILAMENT_DENSITY,
  195. }
  196. except ValueError:
  197. pass # Skip filament entries with unparseable IDs
  198. # Try project_settings.config for density values
  199. if "Metadata/project_settings.config" in zf.namelist():
  200. content = zf.read("Metadata/project_settings.config").decode()
  201. try:
  202. data = json.loads(content)
  203. densities = data.get("filament_density", [])
  204. for i, density in enumerate(densities):
  205. # project_settings uses 0-based indexing, convert to 1-based
  206. fid = i + 1
  207. if fid not in properties:
  208. properties[fid] = {
  209. "type": "",
  210. "diameter": DEFAULT_FILAMENT_DIAMETER,
  211. }
  212. try:
  213. properties[fid]["density"] = float(density)
  214. except (ValueError, TypeError):
  215. properties[fid]["density"] = DEFAULT_FILAMENT_DENSITY
  216. except json.JSONDecodeError:
  217. pass # Skip malformed project_settings.config JSON
  218. except (zipfile.BadZipFile, OSError, KeyError, ValueError, XMLParseError, UnicodeDecodeError):
  219. pass # Return whatever properties were collected before the error
  220. return properties
  221. def extract_filament_usage_from_3mf(file_path: Path) -> list[dict]:
  222. """Extract per-filament total usage from 3MF slice_info.config.
  223. This extracts the slicer-estimated total usage per filament slot,
  224. not the per-layer breakdown.
  225. Args:
  226. file_path: Path to the 3MF file
  227. Returns:
  228. List of filament usage dictionaries:
  229. [{"slot_id": 1, "used_g": 50.5, "type": "PLA", "color": "#FF0000"}, ...]
  230. """
  231. filament_usage = []
  232. try:
  233. with zipfile.ZipFile(file_path, "r") as zf:
  234. if "Metadata/slice_info.config" not in zf.namelist():
  235. return []
  236. content = zf.read("Metadata/slice_info.config").decode()
  237. root = ET.fromstring(content)
  238. for f in root.findall(".//filament"):
  239. filament_id = f.get("id")
  240. used_g = f.get("used_g", "0")
  241. try:
  242. used_amount = float(used_g)
  243. if filament_id:
  244. filament_usage.append(
  245. {
  246. "slot_id": int(filament_id),
  247. "used_g": used_amount,
  248. "type": f.get("type", ""),
  249. "color": f.get("color", ""),
  250. }
  251. )
  252. except (ValueError, TypeError):
  253. pass # Skip filament entries with unparseable usage values
  254. except (zipfile.BadZipFile, OSError, KeyError, ValueError, XMLParseError, UnicodeDecodeError):
  255. pass # Return whatever usage data was collected before the error
  256. return filament_usage