threemf_tools.py 43 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040
  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 hashlib
  7. import json
  8. import logging
  9. import math
  10. import re
  11. import zipfile
  12. from pathlib import Path
  13. import defusedxml.ElementTree as ET
  14. logger = logging.getLogger(__name__)
  15. # Default filament properties
  16. DEFAULT_FILAMENT_DIAMETER = 1.75 # mm
  17. DEFAULT_FILAMENT_DENSITY = 1.24 # g/cm³ (PLA)
  18. def parse_gcode_layer_filament_usage(gcode_content: str) -> dict[int, dict[int, float]]:
  19. """Parse G-code to extract per-layer, per-filament cumulative extrusion in mm.
  20. This function tracks filament extrusion across layers and tool changes,
  21. building a cumulative usage map that can be used to calculate partial
  22. usage at any layer.
  23. Args:
  24. gcode_content: The raw G-code content as a string
  25. Returns:
  26. A nested dictionary mapping layer numbers to filament usage:
  27. {layer: {filament_id: cumulative_mm}, ...}
  28. Example:
  29. {0: {0: 125.5}, 1: {0: 250.0, 1: 50.0}, 2: {0: 375.0, 1: 150.0}}
  30. This shows:
  31. - Layer 0: filament 0 used 125.5mm cumulative
  32. - Layer 1: filament 0 used 250mm cumulative, filament 1 used 50mm
  33. - Layer 2: filament 0 used 375mm cumulative, filament 1 used 150mm
  34. G-code commands parsed:
  35. - M73 L<layer>: Layer change marker
  36. - M620 S<filament>: Filament/tool change (S255 = unload)
  37. - G0/G1/G2/G3 E<amount>: Extrusion moves
  38. """
  39. layer_filaments: dict[int, dict[int, float]] = {}
  40. current_layer = 0
  41. active_filament: int | None = None
  42. cumulative_extrusion: dict[int, float] = {} # filament_id -> total mm
  43. for line in gcode_content.splitlines():
  44. line = line.strip()
  45. if not line:
  46. continue
  47. # Handle comments - skip but check for layer markers
  48. if line.startswith(";"):
  49. # Some slicers use comment-based layer markers
  50. # e.g., "; CHANGE_LAYER" or ";LAYER_CHANGE"
  51. continue
  52. # Split line into command and inline comment
  53. if ";" in line:
  54. line = line.split(";")[0].strip()
  55. # Extract command and parameters
  56. parts = line.split()
  57. if not parts:
  58. continue
  59. cmd = parts[0].upper()
  60. # Layer change: M73 L<layer>
  61. # Bambu printers use M73 with L parameter for layer indication
  62. if cmd == "M73":
  63. for part in parts[1:]:
  64. part_upper = part.upper()
  65. if part_upper.startswith("L"):
  66. try:
  67. new_layer = int(part[1:])
  68. # Save current state before layer change
  69. if cumulative_extrusion:
  70. layer_filaments[current_layer] = cumulative_extrusion.copy()
  71. current_layer = new_layer
  72. except ValueError:
  73. pass # Skip G-code lines with unparseable layer numbers
  74. # Filament change: M620 S<filament>
  75. # Bambu uses M620 for AMS filament switching
  76. # S255 means full unload (no active filament)
  77. elif cmd == "M620":
  78. for part in parts[1:]:
  79. part_upper = part.upper()
  80. if part_upper.startswith("S"):
  81. filament_str = part[1:]
  82. if filament_str == "255":
  83. # Full unload - no active filament
  84. active_filament = None
  85. else:
  86. try:
  87. # Extract digits (e.g., "0A" -> 0, "1" -> 1)
  88. match = re.match(r"(\d+)", filament_str)
  89. if match:
  90. active_filament = int(match.group(1))
  91. except (ValueError, AttributeError):
  92. pass # Skip unparseable filament switch commands
  93. # Extrusion moves: G0/G1/G2/G3 with E parameter
  94. # Only G1 typically has extrusion, but check all for safety
  95. elif cmd in ("G0", "G1", "G2", "G3"):
  96. if active_filament is None:
  97. continue
  98. for part in parts[1:]:
  99. part_upper = part.upper()
  100. if part_upper.startswith("E"):
  101. try:
  102. extrusion = float(part[1:])
  103. # Only count positive extrusion (not retractions)
  104. if extrusion > 0:
  105. current = cumulative_extrusion.get(active_filament, 0)
  106. cumulative_extrusion[active_filament] = current + extrusion
  107. except ValueError:
  108. pass # Skip G-code lines with unparseable extrusion values
  109. # Save final layer state
  110. if cumulative_extrusion:
  111. layer_filaments[current_layer] = cumulative_extrusion.copy()
  112. return layer_filaments
  113. def mm_to_grams(
  114. length_mm: float,
  115. diameter_mm: float = DEFAULT_FILAMENT_DIAMETER,
  116. density_g_cm3: float = DEFAULT_FILAMENT_DENSITY,
  117. ) -> float:
  118. """Convert filament length in mm to weight in grams.
  119. Uses the formula: mass = volume × density
  120. where volume = π × r² × length
  121. Args:
  122. length_mm: Length of filament in millimeters
  123. diameter_mm: Filament diameter in millimeters (default: 1.75)
  124. density_g_cm3: Material density in g/cm³ (default: 1.24 for PLA)
  125. Returns:
  126. Weight in grams
  127. """
  128. radius_cm = (diameter_mm / 2) / 10 # Convert mm to cm
  129. length_cm = length_mm / 10 # Convert mm to cm
  130. volume_cm3 = math.pi * radius_cm * radius_cm * length_cm
  131. return volume_cm3 * density_g_cm3
  132. def extract_layer_filament_usage_from_3mf(file_path: Path) -> dict[int, dict[int, float]] | None:
  133. """Extract per-layer filament usage from a 3MF file's embedded G-code.
  134. Args:
  135. file_path: Path to the 3MF file
  136. Returns:
  137. Dictionary mapping layers to filament usage, or None if parsing fails.
  138. Format: {layer: {filament_id: cumulative_mm}, ...}
  139. """
  140. try:
  141. with zipfile.ZipFile(file_path, "r") as zf:
  142. # Find G-code file(s) - usually plate_1.gcode or Metadata/plate_1.gcode
  143. gcode_files = [f for f in zf.namelist() if f.endswith(".gcode")]
  144. if not gcode_files:
  145. return None
  146. # Use the first G-code file (typically only one per 3MF export)
  147. gcode_path = gcode_files[0]
  148. gcode_content = zf.read(gcode_path).decode("utf-8", errors="ignore")
  149. return parse_gcode_layer_filament_usage(gcode_content)
  150. except Exception:
  151. return None
  152. def get_cumulative_usage_at_layer(
  153. layer_usage: dict[int, dict[int, float]],
  154. target_layer: int,
  155. ) -> dict[int, float]:
  156. """Get cumulative filament usage (in mm) up to and including target_layer.
  157. Args:
  158. layer_usage: The output from parse_gcode_layer_filament_usage()
  159. target_layer: The layer number to get usage for
  160. Returns:
  161. Dictionary of {filament_id: cumulative_mm} for each filament used
  162. up to target_layer. Returns empty dict if no data available.
  163. """
  164. if not layer_usage:
  165. return {}
  166. # Find the highest recorded layer <= target_layer
  167. # (we store snapshots at layer changes, so we need the closest one)
  168. relevant_layers = [layer for layer in layer_usage if layer <= target_layer]
  169. if not relevant_layers:
  170. return {}
  171. max_layer = max(relevant_layers)
  172. return layer_usage.get(max_layer, {})
  173. def extract_filament_properties_from_3mf(file_path: Path) -> dict[int, dict]:
  174. """Extract filament properties (density, diameter, type) from 3MF metadata.
  175. Args:
  176. file_path: Path to the 3MF file
  177. Returns:
  178. Dictionary mapping filament IDs to their properties:
  179. {filament_id: {"diameter": 1.75, "density": 1.24, "type": "PLA"}, ...}
  180. Note: filament_id is 1-based (matches slot_id in slice_info.config)
  181. """
  182. properties: dict[int, dict] = {}
  183. try:
  184. with zipfile.ZipFile(file_path, "r") as zf:
  185. # Try slice_info.config first for filament types
  186. if "Metadata/slice_info.config" in zf.namelist():
  187. content = zf.read("Metadata/slice_info.config").decode()
  188. root = ET.fromstring(content)
  189. for f in root.findall(".//filament"):
  190. try:
  191. # id is 1-based in slice_info.config
  192. fid = int(f.get("id", 0))
  193. properties[fid] = {
  194. "type": f.get("type", "PLA"),
  195. "diameter": DEFAULT_FILAMENT_DIAMETER,
  196. "density": DEFAULT_FILAMENT_DENSITY,
  197. }
  198. except ValueError:
  199. pass # Skip filament entries with unparseable IDs
  200. # Try project_settings.config for density values
  201. if "Metadata/project_settings.config" in zf.namelist():
  202. content = zf.read("Metadata/project_settings.config").decode()
  203. try:
  204. data = json.loads(content)
  205. densities = data.get("filament_density", [])
  206. for i, density in enumerate(densities):
  207. # project_settings uses 0-based indexing, convert to 1-based
  208. fid = i + 1
  209. if fid not in properties:
  210. properties[fid] = {
  211. "type": "",
  212. "diameter": DEFAULT_FILAMENT_DIAMETER,
  213. }
  214. try:
  215. properties[fid]["density"] = float(density)
  216. except (ValueError, TypeError):
  217. properties[fid]["density"] = DEFAULT_FILAMENT_DENSITY
  218. except json.JSONDecodeError:
  219. pass # Skip malformed project_settings.config JSON
  220. except Exception:
  221. pass # Return whatever properties were collected before the error
  222. return properties
  223. def _first_settings_id(value: object) -> str | None:
  224. """A ``*_settings_id`` value is usually a string, occasionally a list (one
  225. entry per extruder). Return the first non-empty string, else None."""
  226. if isinstance(value, str):
  227. return value.strip() or None
  228. if isinstance(value, list):
  229. for item in value:
  230. if isinstance(item, str) and item.strip():
  231. return item.strip()
  232. return None
  233. def extract_embedded_presets_from_3mf(zf: zipfile.ZipFile) -> dict[str, str | None]:
  234. """Read the printer / process preset names a 3MF project was prepared with.
  235. BambuStudio / OrcaSlicer write the chosen preset names into
  236. ``Metadata/project_settings.config`` (``printer_settings_id`` and
  237. ``print_settings_id``). The SliceModal uses them to default its printer
  238. and process dropdowns to what the file was sliced for (#1325) instead of
  239. blindly taking the first listed preset.
  240. Returns ``{"printer": <name|None>, "process": <name|None>}``. Every failure
  241. mode (missing config, malformed JSON, unexpected shape) yields ``None``
  242. values so the modal falls back to its own defaults.
  243. """
  244. result: dict[str, str | None] = {"printer": None, "process": None}
  245. try:
  246. if "Metadata/project_settings.config" not in zf.namelist():
  247. return result
  248. data = json.loads(zf.read("Metadata/project_settings.config").decode())
  249. except (KeyError, ValueError, OSError):
  250. return result
  251. if not isinstance(data, dict):
  252. return result
  253. result["printer"] = _first_settings_id(data.get("printer_settings_id"))
  254. result["process"] = _first_settings_id(data.get("print_settings_id"))
  255. return result
  256. def extract_nozzle_mapping_from_3mf(zf: zipfile.ZipFile) -> dict[int, int] | None:
  257. """Extract per-slot nozzle/extruder mapping from a 3MF file.
  258. On dual-nozzle printers (H2D, H2D Pro), each filament slot is assigned to a
  259. specific nozzle. The slicer may override user preferences when using "Auto For
  260. Flush" mode, so the actual assignment comes from slice_info.config group_id
  261. attributes, not from the user's filament_nozzle_map preference.
  262. Priority:
  263. 1. group_id on <filament> elements in slice_info.config (actual assignment)
  264. 2. filament_nozzle_map in project_settings.config (user preference fallback)
  265. Both are mapped through physical_extruder_map to get MQTT extruder IDs (0=right, 1=left).
  266. Args:
  267. zf: An open ZipFile of the 3MF archive
  268. Returns:
  269. Dictionary mapping {slot_id: extruder_id} for dual-nozzle files,
  270. or None if single-nozzle, missing data, or parse error.
  271. """
  272. try:
  273. if "Metadata/project_settings.config" not in zf.namelist():
  274. return None
  275. content = zf.read("Metadata/project_settings.config").decode()
  276. data = json.loads(content)
  277. physical_extruder_map = data.get("physical_extruder_map")
  278. if not physical_extruder_map or len(physical_extruder_map) <= 1:
  279. return None # Single-nozzle printer
  280. # Check if only one extruder is active.
  281. # If so, we can skip the mapping and just assign all slots to that extruder.
  282. # extruder_nozzle_stats format: ["Standard#0|High Flow#0", "Standard#1"]
  283. # Each entry = one extruder. Format: <NozzleVolumeType>#<count>[|...]
  284. # #N is the count of physical nozzles of that type (0 = none installed).
  285. # Types: Standard, High Flow, Hybrid, TPU High Flow
  286. active_extruders = []
  287. for stats_str in data.get("extruder_nozzle_stats") or []:
  288. nozzle_counts = [n.partition("#")[2] for n in stats_str.split("|")]
  289. active_extruders.append(1 if any(c not in ("0", "") for c in nozzle_counts) else 0)
  290. # Parse slice_info once: needed by both the single-active shortcut
  291. # (to verify the slice is actually single-group, #1825) and Priority 1.
  292. si_root: ET.Element | None = None
  293. distinct_group_ids: set[int] = set()
  294. if "Metadata/slice_info.config" in zf.namelist():
  295. si_content = zf.read("Metadata/slice_info.config").decode()
  296. si_root = ET.fromstring(si_content)
  297. for filament_elem in si_root.findall(".//filament"):
  298. gid = filament_elem.get("group_id")
  299. if gid is not None:
  300. try:
  301. distinct_group_ids.add(int(gid))
  302. except (ValueError, TypeError):
  303. pass
  304. # Single-active shortcut: only safe when the slice actually uses one
  305. # group. extruder_nozzle_stats can under-report a second installed
  306. # nozzle when its volume-type differs from the profile's enumerated
  307. # types (HT-AMS / High-Flow asymmetry on H2D, #1825); without this
  308. # guard the shortcut collapses a real multi-extruder slice onto one
  309. # nozzle and the group_id mapping below is skipped.
  310. if sum(active_extruders) == 1 and len(distinct_group_ids) <= 1:
  311. nozzle_mapping: dict[int, int] = {}
  312. active_idx = active_extruders.index(1)
  313. target_extruder = int(physical_extruder_map[active_idx])
  314. if si_root is not None:
  315. for filament_elem in si_root.findall(".//filament"):
  316. try:
  317. nozzle_mapping[int(filament_elem.get("id"))] = target_extruder
  318. except (ValueError, TypeError):
  319. pass
  320. return nozzle_mapping or None
  321. # Priority 1: Use group_id from slice_info filament elements.
  322. # This reflects the actual slicer assignment (respects "Auto For Flush").
  323. nozzle_mapping: dict[int, int] = {}
  324. if si_root is not None:
  325. for filament_elem in si_root.findall(".//filament"):
  326. group_id_str = filament_elem.get("group_id")
  327. filament_id_str = filament_elem.get("id")
  328. if group_id_str is not None and filament_id_str:
  329. try:
  330. group_id = int(group_id_str)
  331. slot_id = int(filament_id_str)
  332. if group_id < len(physical_extruder_map):
  333. nozzle_mapping[slot_id] = int(physical_extruder_map[group_id])
  334. except (ValueError, TypeError, IndexError):
  335. pass
  336. if nozzle_mapping:
  337. return nozzle_mapping
  338. # Priority 2: Fall back to filament_nozzle_map (user preference).
  339. # This is correct when the user manually assigned nozzles, but may be
  340. # wrong when the slicer overrides via "Auto For Flush".
  341. filament_nozzle_map = data.get("filament_nozzle_map")
  342. if not filament_nozzle_map:
  343. return None
  344. for i, slicer_ext_str in enumerate(filament_nozzle_map):
  345. slot_id = i + 1
  346. try:
  347. slicer_ext = int(slicer_ext_str)
  348. if slicer_ext < len(physical_extruder_map):
  349. nozzle_mapping[slot_id] = int(physical_extruder_map[slicer_ext])
  350. except (ValueError, TypeError, IndexError):
  351. pass
  352. return nozzle_mapping if nozzle_mapping else None
  353. except Exception:
  354. return None
  355. def extract_filament_usage_from_3mf(file_path: Path, plate_id: int | None = None) -> list[dict]:
  356. """Extract per-filament total usage from 3MF slice_info.config.
  357. This extracts the slicer-estimated total usage per filament slot,
  358. not the per-layer breakdown.
  359. Args:
  360. file_path: Path to the 3MF file
  361. plate_id: Optional plate index to filter for (for multi-plate files)
  362. Returns:
  363. List of filament usage dictionaries:
  364. [{"slot_id": 1, "used_g": 50.5, "type": "PLA", "color": "#FF0000"}, ...]
  365. """
  366. filament_usage = []
  367. try:
  368. with zipfile.ZipFile(file_path, "r") as zf:
  369. if "Metadata/slice_info.config" not in zf.namelist():
  370. return []
  371. content = zf.read("Metadata/slice_info.config").decode()
  372. root = ET.fromstring(content)
  373. if plate_id is not None:
  374. # Find the plate element with matching index
  375. for plate_elem in root.findall(".//plate"):
  376. plate_index = None
  377. for meta in plate_elem.findall("metadata"):
  378. if meta.get("key") == "index":
  379. try:
  380. plate_index = int(meta.get("value", "0"))
  381. except ValueError:
  382. pass
  383. break
  384. if plate_index == plate_id:
  385. for f in plate_elem.findall("filament"):
  386. filament_id = f.get("id")
  387. used_g = f.get("used_g", "0")
  388. try:
  389. used_amount = float(used_g)
  390. if filament_id:
  391. filament_usage.append(
  392. {
  393. "slot_id": int(filament_id),
  394. "used_g": used_amount,
  395. "type": f.get("type", ""),
  396. "color": f.get("color", ""),
  397. }
  398. )
  399. except (ValueError, TypeError):
  400. pass
  401. break
  402. else:
  403. # No plate_id specified - extract all filaments
  404. for f in root.findall(".//filament"):
  405. filament_id = f.get("id")
  406. used_g = f.get("used_g", "0")
  407. try:
  408. used_amount = float(used_g)
  409. if filament_id:
  410. filament_usage.append(
  411. {
  412. "slot_id": int(filament_id),
  413. "used_g": used_amount,
  414. "type": f.get("type", ""),
  415. "color": f.get("color", ""),
  416. }
  417. )
  418. except (ValueError, TypeError):
  419. pass # Skip filament entries with unparseable usage values
  420. except Exception:
  421. pass # Return whatever usage data was collected before the error
  422. return filament_usage
  423. def extract_print_time_from_3mf(file_path: Path, plate_id: int | None = None) -> int | None:
  424. """Extract the slicer's predicted print time from a 3MF's slice_info.config.
  425. Multi-plate 3MFs carry one ``<plate><metadata key="prediction" .../></plate>``
  426. per plate. The archive-level `print_time_seconds` is the sum across all plates
  427. (see services/archive.py:200-264, #1593). For per-plate UI / notifications,
  428. callers re-read the 3MF and request the specific plate's value via this helper.
  429. Args:
  430. file_path: Path to the 3MF file
  431. plate_id: Plate index to filter for; if None, returns the first plate's
  432. ``prediction`` (matches the legacy single-plate read).
  433. Returns:
  434. Predicted print time in seconds, or None if not found / unparseable.
  435. """
  436. try:
  437. with zipfile.ZipFile(file_path, "r") as zf:
  438. if "Metadata/slice_info.config" not in zf.namelist():
  439. return None
  440. content = zf.read("Metadata/slice_info.config").decode()
  441. root = ET.fromstring(content)
  442. if plate_id is not None:
  443. for plate_elem in root.findall(".//plate"):
  444. plate_index = None
  445. for meta in plate_elem.findall("metadata"):
  446. if meta.get("key") == "index":
  447. try:
  448. plate_index = int(meta.get("value", "0"))
  449. except ValueError:
  450. pass # Skip plate with unparseable index
  451. break
  452. if plate_index == plate_id:
  453. for meta in plate_elem.findall("metadata"):
  454. if meta.get("key") == "prediction":
  455. try:
  456. return int(meta.get("value", "0"))
  457. except ValueError:
  458. return None
  459. break
  460. else:
  461. plate_elem = root.find(".//plate")
  462. if plate_elem is not None:
  463. for meta in plate_elem.findall("metadata"):
  464. if meta.get("key") == "prediction":
  465. try:
  466. return int(meta.get("value", "0"))
  467. except ValueError:
  468. return None
  469. except Exception as e:
  470. logger.warning("Failed to extract print time from %s: %s", file_path, e)
  471. return None
  472. def extract_bed_type_from_3mf(file_path: Path, plate_id: int | None = None) -> str | None:
  473. """Extract the build plate type (`curr_bed_type`) for a specific plate (#1281).
  474. ``archive.bed_type`` is captured at ingest time but is one value per archive
  475. (the first plate's `curr_bed_type` — see services/archive.py:235). For a
  476. multi-plate 3MF where different plates target different beds (e.g. a 40-plate
  477. file mixing PEI + Engineering), the archive-level value lies. When a queue
  478. item or print modal targets a specific plate, this re-reads the 3MF and
  479. returns that plate's actual bed type.
  480. Args:
  481. file_path: Path to the 3MF file
  482. plate_id: Plate index to filter for; if None, returns the first plate's
  483. ``curr_bed_type`` (matches the archive-level capture).
  484. Returns:
  485. Bed type string (e.g. "Textured PEI Plate"), or None if not found.
  486. """
  487. try:
  488. with zipfile.ZipFile(file_path, "r") as zf:
  489. if "Metadata/slice_info.config" not in zf.namelist():
  490. return None
  491. content = zf.read("Metadata/slice_info.config").decode()
  492. root = ET.fromstring(content)
  493. for plate_elem in root.findall(".//plate"):
  494. plate_index = None
  495. bed_value: str | None = None
  496. for meta in plate_elem.findall("metadata"):
  497. key = meta.get("key")
  498. if key == "index":
  499. try:
  500. plate_index = int(meta.get("value", "0"))
  501. except ValueError:
  502. pass # Skip plate with unparseable index
  503. elif key == "curr_bed_type" and meta.get("value"):
  504. bed_value = (meta.get("value") or "").strip()
  505. if plate_id is None:
  506. # First plate wins when no plate_id is requested.
  507. return bed_value
  508. if plate_index == plate_id:
  509. return bed_value
  510. except Exception:
  511. pass # Return None on any failure rather than raising — caller decides
  512. return None
  513. # Header values exposed as `{placeholder}` substitutions inside snippets.
  514. # Aliases let users write Prusa-style names (`{max_layer_z}`) that map onto
  515. # Bambu/Orca header keys (`max_z_height`).
  516. _HEADER_PLACEHOLDER_ALIASES = {
  517. "max_layer_z": "max_z_height",
  518. "max_print_height": "max_z_height",
  519. "total_layers": "total_layer_number",
  520. }
  521. _HEADER_KEY_RE = re.compile(r"^;\s*([^:]+?)\s*:\s*(.+?)\s*$")
  522. _PLACEHOLDER_RE = re.compile(r"\{([a-zA-Z_][a-zA-Z0-9_]*)\}")
  523. _START_GCODE_END_MARKER = "; MACHINE_START_GCODE_END"
  524. _EXECUTABLE_BLOCK_END_MARKER = "; EXECUTABLE_BLOCK_END"
  525. def _parse_3mf_gcode_header(content: str) -> dict[str, str]:
  526. """Parse the `; HEADER_BLOCK_START..END` block into a normalised dict.
  527. Keys are lowercased, ` [units]` suffixes stripped, and spaces converted
  528. to underscores so callers can look up `total_layer_number` regardless of
  529. whether the source line is `; total layer number: 80` or
  530. `; total filament length [mm] : 12155.34`.
  531. """
  532. header: dict[str, str] = {}
  533. in_header = False
  534. for raw_line in content.splitlines():
  535. line = raw_line.strip()
  536. if line == "; HEADER_BLOCK_START":
  537. in_header = True
  538. continue
  539. if line == "; HEADER_BLOCK_END":
  540. break
  541. if not in_header:
  542. continue
  543. m = _HEADER_KEY_RE.match(line)
  544. if not m:
  545. continue
  546. key, value = m.group(1), m.group(2)
  547. key = re.sub(r"\s*\[[^\]]*\]\s*$", "", key)
  548. key = key.strip().lower().replace(" ", "_")
  549. header[key] = value
  550. return header
  551. def _substitute_placeholders(snippet: str, header: dict[str, str]) -> str:
  552. """Replace `{var}` placeholders with header values, leaving unknowns intact."""
  553. def repl(m: re.Match) -> str:
  554. name = m.group(1)
  555. value = header.get(name)
  556. if value is None:
  557. alias = _HEADER_PLACEHOLDER_ALIASES.get(name)
  558. if alias is not None:
  559. value = header.get(alias)
  560. if value is None:
  561. logger.warning(
  562. "G-code injection: placeholder {%s} not found in 3MF header; leaving as-is",
  563. name,
  564. )
  565. return m.group(0)
  566. return value
  567. return _PLACEHOLDER_RE.sub(repl, snippet)
  568. def _inject_start_at_marker(content: str, snippet: str) -> str:
  569. """Insert snippet immediately before `; MACHINE_START_GCODE_END`.
  570. The marker sits at the bottom of the printer's startup block — bed heat,
  571. homing, and nozzle prime are already done, so injected snippets land in
  572. the same place a slicer-side custom-start-gcode would. Falls back to
  573. prepending if the marker isn't present (older files / non-Bambu slicers).
  574. """
  575. marker_idx = content.find(_START_GCODE_END_MARKER)
  576. if marker_idx == -1:
  577. logger.warning(
  578. "G-code injection: '%s' not found, prepending start snippet to whole file",
  579. _START_GCODE_END_MARKER,
  580. )
  581. return snippet.rstrip("\n") + "\n" + content
  582. line_start = content.rfind("\n", 0, marker_idx)
  583. line_start = 0 if line_start == -1 else line_start + 1
  584. return content[:line_start] + snippet.rstrip("\n") + "\n" + content[line_start:]
  585. def _inject_end_before_marker(content: str, snippet: str) -> str:
  586. """Insert snippet immediately before `; EXECUTABLE_BLOCK_END`.
  587. The end snippet must run *inside* the executable block. Bambu firmware
  588. (verified on a P1S) does not execute G-code that sits after
  589. `; EXECUTABLE_BLOCK_END`, so appending to the file end silently drops the
  590. snippet — auto-eject / plate-clear moves never fire. Inserting before the
  591. marker places the snippet after the printer's own machine-end sequence but
  592. still within the executed block. Falls back to appending at the file end if
  593. the marker isn't present.
  594. """
  595. marker_idx = content.find(_EXECUTABLE_BLOCK_END_MARKER)
  596. if marker_idx == -1:
  597. logger.warning(
  598. "G-code injection: '%s' not found, appending end snippet to file end",
  599. _EXECUTABLE_BLOCK_END_MARKER,
  600. )
  601. return content.rstrip("\n") + "\n" + snippet.rstrip("\n") + "\n"
  602. line_start = content.rfind("\n", 0, marker_idx)
  603. line_start = 0 if line_start == -1 else line_start + 1
  604. return content[:line_start] + snippet.rstrip("\n") + "\n" + content[line_start:]
  605. def inject_gcode_into_3mf(
  606. source_path: Path,
  607. plate_id: int,
  608. start_gcode: str | None,
  609. end_gcode: str | None,
  610. ):
  611. """Create a temp copy of a 3MF with G-code injected at start/end.
  612. Snippets support `{placeholder}` substitution against values parsed from
  613. the 3MF G-code header block (e.g. `{max_layer_z}` → `16.00`). Start
  614. snippets are anchored to the `; MACHINE_START_GCODE_END` marker so they
  615. run after the printer's own startup (#422). End snippets are inserted just
  616. before `; EXECUTABLE_BLOCK_END` so they run inside the executable block —
  617. Bambu firmware (P1S) ignores g-code placed after that marker.
  618. The plate's `.gcode.md5` sidecar is recomputed so firmware that validates
  619. it against the gcode (e.g. P1S) still accepts the modified file.
  620. Args:
  621. source_path: Path to the original 3MF file.
  622. plate_id: Plate number (1-indexed) to inject into.
  623. start_gcode: G-code to insert after printer startup, or None.
  624. end_gcode: G-code to append, or None.
  625. Returns:
  626. Path to temp file with injected G-code, or None if injection failed.
  627. Caller is responsible for cleaning up the temp file.
  628. """
  629. import tempfile
  630. if not start_gcode and not end_gcode:
  631. return None
  632. try:
  633. # Find the target gcode file inside the 3MF
  634. with zipfile.ZipFile(source_path, "r") as zf:
  635. all_gcode = [f for f in zf.namelist() if f.endswith(".gcode")]
  636. if not all_gcode:
  637. return None
  638. # Try plate-specific gcode file first
  639. target_gcode = None
  640. plate_pattern = f"plate_{plate_id}.gcode"
  641. for f in all_gcode:
  642. if f.endswith(plate_pattern):
  643. target_gcode = f
  644. break
  645. # Fall back to first gcode file
  646. if target_gcode is None:
  647. target_gcode = all_gcode[0]
  648. # Read and modify gcode content
  649. gcode_content = zf.read(target_gcode).decode("utf-8", errors="ignore")
  650. header = _parse_3mf_gcode_header(gcode_content)
  651. if start_gcode:
  652. resolved = _substitute_placeholders(start_gcode, header)
  653. # Log the post-substitution snippet so the actually-injected G-code
  654. # (placeholders like {max_layer_z} already resolved) is visible at DEBUG.
  655. logger.debug("G-code injection [%s]: resolved START snippet:\n%s", target_gcode, resolved)
  656. gcode_content = _inject_start_at_marker(gcode_content, resolved)
  657. if end_gcode:
  658. resolved = _substitute_placeholders(end_gcode, header)
  659. logger.debug("G-code injection [%s]: resolved END snippet:\n%s", target_gcode, resolved)
  660. gcode_content = _inject_end_before_marker(gcode_content, resolved)
  661. # The printer validates the plate gcode against an embedded
  662. # `<plate>.gcode.md5` sidecar (uppercase hex, no trailing newline).
  663. # Rewriting the gcode without refreshing this hash makes firmware
  664. # reject the file at load (P1S: HMS 0500-4003 "unable to parse"),
  665. # so recompute it from the exact bytes we're about to write.
  666. gcode_bytes = gcode_content.encode("utf-8")
  667. md5_name = target_gcode + ".md5"
  668. # Not a security hash — this reproduces Bambu's `.gcode.md5` sidecar
  669. # format, so flag it as non-security for the linters (ruff S324 / bandit B324).
  670. md5_value = hashlib.md5(gcode_bytes, usedforsecurity=False).hexdigest().upper().encode("ascii")
  671. # Write modified 3MF to temp file
  672. with tempfile.NamedTemporaryFile(delete=False, suffix=".3mf") as tmp:
  673. tmp_path = Path(tmp.name)
  674. with zipfile.ZipFile(tmp_path, "w", zipfile.ZIP_DEFLATED) as zf_write:
  675. for item in zf.namelist():
  676. info = zf.getinfo(item)
  677. if item == target_gcode:
  678. zf_write.writestr(info, gcode_bytes)
  679. elif item == md5_name:
  680. zf_write.writestr(info, md5_value)
  681. else:
  682. zf_write.writestr(info, zf.read(item))
  683. return tmp_path
  684. except Exception:
  685. # Clean up temp file on error
  686. if "tmp_path" in locals() and tmp_path.exists():
  687. tmp_path.unlink(missing_ok=True)
  688. return None
  689. def extract_project_filaments_from_3mf(zf: zipfile.ZipFile) -> list[dict]:
  690. """Project-wide AMS slot config from ``Metadata/project_settings.config``.
  691. Returns one dict per configured AMS slot in slot order (1-indexed), with
  692. ``type`` and ``color`` populated from the project's ``filament_type`` and
  693. ``filament_colour`` arrays. ``used_grams`` / ``used_meters`` are 0 because
  694. project_settings carries the configuration, not per-print usage — the
  695. fields exist for shape compatibility with the slice_info-derived list.
  696. The SliceModal needs this on **unsliced** project files: slice_info.config
  697. is empty until Bambu Studio has actually sliced the project, but the user
  698. can still pick filament profiles for a slice we're about to perform.
  699. """
  700. if "Metadata/project_settings.config" not in zf.namelist():
  701. return []
  702. try:
  703. proj = json.loads(zf.read("Metadata/project_settings.config").decode())
  704. except (ValueError, OSError):
  705. return []
  706. if not isinstance(proj, dict):
  707. return []
  708. types_arr = proj.get("filament_type") or []
  709. colors_arr = proj.get("filament_colour") or []
  710. slot_count = max(
  711. len(types_arr) if isinstance(types_arr, list) else 0, len(colors_arr) if isinstance(colors_arr, list) else 0
  712. )
  713. out: list[dict] = []
  714. for i in range(slot_count):
  715. out.append(
  716. {
  717. "slot_id": i + 1,
  718. "type": types_arr[i] if i < len(types_arr) and isinstance(types_arr[i], str) else "",
  719. "color": colors_arr[i] if i < len(colors_arr) and isinstance(colors_arr[i], str) else "",
  720. "used_grams": 0,
  721. "used_meters": 0,
  722. }
  723. )
  724. return out
  725. _PAINT_COLOR_ATTR_RE = re.compile(rb'paint_color="([0-9A-Fa-f]+)"')
  726. # Painted-face quadtree leaves include both real filament assignments and
  727. # tiny edit artifacts (single-leaf accidents from "tried a colour, undid,
  728. # repainted with a different one"). The threshold's only job is dropping
  729. # accidents — anything the user spent meaningful effort on must survive.
  730. # 5% of an object's painted triangles is well below any 60/40 / 70/30 /
  731. # 33/33/33 split a real two- or three-colour print would hit, so all
  732. # intentional colours are kept; one-off single-leaf paints (typically
  733. # 0.1-1.5% in observed projects) are filtered. Note that this fallback
  734. # path runs ONLY when the preview-slice path can't reach the sidecar; in
  735. # the normal flow the slicer's own pruning produces the canonical list and
  736. # this threshold isn't reached.
  737. _PAINT_NOISE_THRESHOLD = 0.05
  738. def extract_plate_extruder_set_from_3mf(zf: zipfile.ZipFile, plate_id: int) -> set[int]:
  739. """Extruder/AMS slot indices (1-indexed) used by objects on ``plate_id``.
  740. Three sources are unioned because Bambu Studio splits per-object extruder
  741. info across THREE places depending on how the user assigned colours:
  742. 1. ``model_settings.config`` — top-level ``<metadata key="extruder">``
  743. on each ``<object>`` (the "default extruder" for the whole object).
  744. 2. ``model_settings.config`` — per-``<part>`` ``<metadata key="extruder">``
  745. overrides (used when the user split an object into multiple parts
  746. with distinct filaments).
  747. 3. ``3D/Objects/object_*.model`` — ``paint_color`` attributes on
  748. individual ``<triangle>`` elements (used when the user "painted" a
  749. face with a different filament). The encoding is a hex string where
  750. each nibble is a TriangleSelector tree node: ``0`` = unpainted leaf,
  751. ``F`` = branch (4 children follow), ``1``..``E`` = leaf painted with
  752. extruder N. We don't decode the tree — every leaf-paint nibble in
  753. the string IS the extruder number, so a flat scan over hex chars
  754. yields the correct set without recursive parsing.
  755. Without (3) the painted-face data is invisible: model_settings says
  756. every object on a multi-color plate uses extruder 1 by default but the
  757. actual print uses 3, 4, 12 etc. via face paint, so the SliceModal would
  758. render only one filament dropdown for what's clearly a multi-colour
  759. print (#1150 follow-up).
  760. """
  761. if "Metadata/model_settings.config" not in zf.namelist():
  762. return set()
  763. try:
  764. root = ET.fromstring(zf.read("Metadata/model_settings.config").decode())
  765. except (ET.ParseError, OSError):
  766. return set()
  767. # Pass 1: object → set of extruders from XML metadata (sources 1 + 2)
  768. # plus the per-object .model file path so we can later scan source 3.
  769. object_extruders: dict[str, set[int]] = {}
  770. object_model_paths: dict[str, list[str]] = {}
  771. for obj_elem in root.findall(".//object"):
  772. obj_id = obj_elem.get("id")
  773. if not obj_id:
  774. continue
  775. extruders: set[int] = set()
  776. top = obj_elem.find("metadata[@key='extruder']")
  777. if top is not None:
  778. try:
  779. v = int(top.get("value", "0"))
  780. if v > 0:
  781. extruders.add(v)
  782. except (ValueError, TypeError):
  783. pass
  784. for part_elem in obj_elem.findall(".//part"):
  785. part_ext = part_elem.find("metadata[@key='extruder']")
  786. if part_ext is None:
  787. continue
  788. try:
  789. v = int(part_ext.get("value", "0"))
  790. if v > 0:
  791. extruders.add(v)
  792. except (ValueError, TypeError):
  793. pass
  794. object_extruders[obj_id] = extruders
  795. # Pass 2: 3dmodel.model maps each <object id="N"> to its component
  796. # .model file path(s). Bambu wraps object IDs that match
  797. # model_settings.config IDs around <components><component
  798. # path="/3D/Objects/object_K.model" objectid="..." /></components>.
  799. # Strip xmlns prefixes on attributes so ElementTree can find them
  800. # without namespace gymnastics — `p:path` becomes `path` etc.
  801. if "3D/3dmodel.model" in zf.namelist():
  802. try:
  803. raw = zf.read("3D/3dmodel.model").decode()
  804. stripped = re.sub(r'xmlns:?\w*="[^"]*"', "", raw)
  805. stripped = re.sub(r"<(/?)\w+:", r"<\1", stripped)
  806. stripped = re.sub(r" \w+:(\w+=)", r" \1", stripped)
  807. model_root = ET.fromstring(stripped)
  808. for obj_elem in model_root.findall(".//object"):
  809. oid = obj_elem.get("id")
  810. if not oid:
  811. continue
  812. comps = obj_elem.find("components")
  813. if comps is None:
  814. continue
  815. paths = []
  816. for c in comps.findall("component"):
  817. p = c.get("path")
  818. if p:
  819. paths.append(p.lstrip("/"))
  820. if paths:
  821. object_model_paths[oid] = paths
  822. except (ET.ParseError, OSError):
  823. pass # No 3dmodel — paint scan just won't apply
  824. # Pass 3: scan paint_color attrs in each per-object .model file. Cache
  825. # by file path because two objects often share the same component tree.
  826. paint_cache: dict[str, set[int]] = {}
  827. def _scan_paint(path: str) -> set[int]:
  828. if path in paint_cache:
  829. return paint_cache[path]
  830. out: set[int] = set()
  831. if path not in zf.namelist():
  832. paint_cache[path] = out
  833. return out
  834. try:
  835. data = zf.read(path)
  836. except OSError:
  837. paint_cache[path] = out
  838. return out
  839. # Per-extruder triangle coverage. Each painted triangle may have
  840. # multiple leaf nibbles (the quadtree subdivides the face into
  841. # painted regions); we count one triangle per unique extruder per
  842. # match so the resulting fraction is "what share of painted
  843. # triangles include at least one leaf with extruder N". Noise from
  844. # one-off edit artifacts is filtered out at the threshold below.
  845. extruder_triangles: dict[int, int] = {}
  846. total_painted = 0
  847. for match in _PAINT_COLOR_ATTR_RE.finditer(data):
  848. total_painted += 1
  849. seen: set[int] = set()
  850. for ch in match.group(1):
  851. # Hex digit → 4-bit value. 0 = unpainted leaf, F = branch
  852. # (decoded recursively but children are encoded inline, so
  853. # we'll see them on later iterations). 1-E = leaf painted
  854. # with extruder N.
  855. if ch in b"123456789":
  856. seen.add(ch - 0x30)
  857. elif ch in b"ABCDEabcde":
  858. seen.add((ch & 0x4F) - 0x37)
  859. for e in seen:
  860. extruder_triangles[e] = extruder_triangles.get(e, 0) + 1
  861. if total_painted > 0:
  862. cutoff = max(1, int(total_painted * _PAINT_NOISE_THRESHOLD))
  863. for ext, count in extruder_triangles.items():
  864. if count >= cutoff:
  865. out.add(ext)
  866. paint_cache[path] = out
  867. return out
  868. # Walk plates — collect extruders for objects on the requested plate.
  869. used: set[int] = set()
  870. for plate_elem in root.findall(".//plate"):
  871. plater_id = None
  872. for meta in plate_elem.findall("metadata"):
  873. if meta.get("key") == "plater_id":
  874. try:
  875. plater_id = int(meta.get("value", ""))
  876. except (ValueError, TypeError):
  877. pass
  878. break
  879. if plater_id != plate_id:
  880. continue
  881. for inst in plate_elem.findall("model_instance"):
  882. for inst_meta in inst.findall("metadata"):
  883. if inst_meta.get("key") != "object_id":
  884. continue
  885. obj_id = inst_meta.get("value")
  886. if not obj_id:
  887. continue
  888. used.update(object_extruders.get(obj_id, set()))
  889. for path in object_model_paths.get(obj_id, []):
  890. used.update(_scan_paint(path))
  891. break
  892. return used