threemf_tools.py 56 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340
  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 collections import OrderedDict
  13. from dataclasses import dataclass, field
  14. from pathlib import Path
  15. from threading import Lock
  16. import defusedxml.ElementTree as ET
  17. logger = logging.getLogger(__name__)
  18. # Default filament properties
  19. DEFAULT_FILAMENT_DIAMETER = 1.75 # mm
  20. DEFAULT_FILAMENT_DENSITY = 1.24 # g/cm³ (PLA)
  21. def parse_gcode_layer_filament_usage(gcode_content: str) -> dict[int, dict[int, float]]:
  22. """Parse G-code to extract per-layer, per-filament cumulative extrusion in mm.
  23. This function tracks filament extrusion across layers and tool changes,
  24. building a cumulative usage map that can be used to calculate partial
  25. usage at any layer.
  26. Args:
  27. gcode_content: The raw G-code content as a string
  28. Returns:
  29. A nested dictionary mapping layer numbers to filament usage:
  30. {layer: {filament_id: cumulative_mm}, ...}
  31. Example:
  32. {0: {0: 125.5}, 1: {0: 250.0, 1: 50.0}, 2: {0: 375.0, 1: 150.0}}
  33. This shows:
  34. - Layer 0: filament 0 used 125.5mm cumulative
  35. - Layer 1: filament 0 used 250mm cumulative, filament 1 used 50mm
  36. - Layer 2: filament 0 used 375mm cumulative, filament 1 used 150mm
  37. G-code commands parsed:
  38. - M73 L<layer>: Layer change marker
  39. - M620 S<filament>: Filament/tool change (S255 = unload)
  40. - G0/G1/G2/G3 E<amount>: Extrusion moves
  41. """
  42. layer_filaments: dict[int, dict[int, float]] = {}
  43. current_layer = 0
  44. active_filament: int | None = None
  45. cumulative_extrusion: dict[int, float] = {} # filament_id -> total mm
  46. for line in gcode_content.splitlines():
  47. line = line.strip()
  48. if not line:
  49. continue
  50. # Handle comments - skip but check for layer markers
  51. if line.startswith(";"):
  52. # Some slicers use comment-based layer markers
  53. # e.g., "; CHANGE_LAYER" or ";LAYER_CHANGE"
  54. continue
  55. # Split line into command and inline comment
  56. if ";" in line:
  57. line = line.split(";")[0].strip()
  58. # Extract command and parameters
  59. parts = line.split()
  60. if not parts:
  61. continue
  62. cmd = parts[0].upper()
  63. # Layer change: M73 L<layer>
  64. # Bambu printers use M73 with L parameter for layer indication
  65. if cmd == "M73":
  66. for part in parts[1:]:
  67. part_upper = part.upper()
  68. if part_upper.startswith("L"):
  69. try:
  70. new_layer = int(part[1:])
  71. # Save current state before layer change
  72. if cumulative_extrusion:
  73. layer_filaments[current_layer] = cumulative_extrusion.copy()
  74. current_layer = new_layer
  75. except ValueError:
  76. pass # Skip G-code lines with unparseable layer numbers
  77. # Filament change: M620 S<filament>
  78. # Bambu uses M620 for AMS filament switching
  79. # S255 means full unload (no active filament)
  80. elif cmd == "M620":
  81. for part in parts[1:]:
  82. part_upper = part.upper()
  83. if part_upper.startswith("S"):
  84. filament_str = part[1:]
  85. if filament_str == "255":
  86. # Full unload - no active filament
  87. active_filament = None
  88. else:
  89. try:
  90. # Extract digits (e.g., "0A" -> 0, "1" -> 1)
  91. match = re.match(r"(\d+)", filament_str)
  92. if match:
  93. active_filament = int(match.group(1))
  94. except (ValueError, AttributeError):
  95. pass # Skip unparseable filament switch commands
  96. # Extrusion moves: G0/G1/G2/G3 with E parameter
  97. # Only G1 typically has extrusion, but check all for safety
  98. elif cmd in ("G0", "G1", "G2", "G3"):
  99. if active_filament is None:
  100. continue
  101. for part in parts[1:]:
  102. part_upper = part.upper()
  103. if part_upper.startswith("E"):
  104. try:
  105. extrusion = float(part[1:])
  106. # Only count positive extrusion (not retractions)
  107. if extrusion > 0:
  108. current = cumulative_extrusion.get(active_filament, 0)
  109. cumulative_extrusion[active_filament] = current + extrusion
  110. except ValueError:
  111. pass # Skip G-code lines with unparseable extrusion values
  112. # Save final layer state
  113. if cumulative_extrusion:
  114. layer_filaments[current_layer] = cumulative_extrusion.copy()
  115. return layer_filaments
  116. def mm_to_grams(
  117. length_mm: float,
  118. diameter_mm: float = DEFAULT_FILAMENT_DIAMETER,
  119. density_g_cm3: float = DEFAULT_FILAMENT_DENSITY,
  120. ) -> float:
  121. """Convert filament length in mm to weight in grams.
  122. Uses the formula: mass = volume × density
  123. where volume = π × r² × length
  124. Args:
  125. length_mm: Length of filament in millimeters
  126. diameter_mm: Filament diameter in millimeters (default: 1.75)
  127. density_g_cm3: Material density in g/cm³ (default: 1.24 for PLA)
  128. Returns:
  129. Weight in grams
  130. """
  131. radius_cm = (diameter_mm / 2) / 10 # Convert mm to cm
  132. length_cm = length_mm / 10 # Convert mm to cm
  133. volume_cm3 = math.pi * radius_cm * radius_cm * length_cm
  134. return volume_cm3 * density_g_cm3
  135. def extract_layer_filament_usage_from_3mf(
  136. file_path: Path, plate_id: int | None = None
  137. ) -> dict[int, dict[int, float]] | None:
  138. """Extract per-layer filament usage from a 3MF file's embedded G-code.
  139. Args:
  140. file_path: Path to the 3MF file
  141. plate_id: Plate to read. Required for multi-plate files — zip member
  142. order is whatever the slicer wrote, and Bambu Studio stores
  143. ``plate_2.gcode`` ahead of ``plate_1.gcode``, so the old
  144. "first member" behaviour read a different plate's layers than
  145. the one that printed. Returns None rather than silently falling
  146. back to another plate when the requested plate isn't in the
  147. file; callers degrade to linear scaling, which is bounded.
  148. Returns:
  149. Dictionary mapping layers to filament usage, or None if parsing fails.
  150. Format: {layer: {filament_id: cumulative_mm}, ...}
  151. """
  152. try:
  153. with zipfile.ZipFile(file_path, "r") as zf:
  154. names = zf.namelist()
  155. gcode_path = select_plate_gcode_name(names, plate_id)
  156. if gcode_path is None:
  157. # No plate asked for, or a file whose single G-code member
  158. # doesn't follow the plate_N naming convention (non-Bambu
  159. # slicers) — the lone member is unambiguous either way.
  160. gcode_files = [f for f in names if f.endswith(".gcode")]
  161. if plate_id is None or len(gcode_files) == 1:
  162. gcode_path = default_plate_gcode_name(names)
  163. if gcode_path is None:
  164. return None
  165. gcode_content = zf.read(gcode_path).decode("utf-8", errors="ignore")
  166. return parse_gcode_layer_filament_usage(gcode_content)
  167. except Exception:
  168. return None
  169. def get_cumulative_usage_at_layer(
  170. layer_usage: dict[int, dict[int, float]],
  171. target_layer: int,
  172. ) -> dict[int, float]:
  173. """Get cumulative filament usage (in mm) up to and including target_layer.
  174. Args:
  175. layer_usage: The output from parse_gcode_layer_filament_usage()
  176. target_layer: The layer number to get usage for
  177. Returns:
  178. Dictionary of {filament_id: cumulative_mm} for each filament used
  179. up to target_layer. Returns empty dict if no data available.
  180. """
  181. if not layer_usage:
  182. return {}
  183. # Find the highest recorded layer <= target_layer
  184. # (we store snapshots at layer changes, so we need the closest one)
  185. relevant_layers = [layer for layer in layer_usage if layer <= target_layer]
  186. if not relevant_layers:
  187. return {}
  188. max_layer = max(relevant_layers)
  189. return layer_usage.get(max_layer, {})
  190. def extract_filament_properties_from_3mf(file_path: Path) -> dict[int, dict]:
  191. """Extract filament properties (density, diameter, type) from 3MF metadata.
  192. Args:
  193. file_path: Path to the 3MF file
  194. Returns:
  195. Dictionary mapping filament IDs to their properties:
  196. {filament_id: {"diameter": 1.75, "density": 1.24, "type": "PLA"}, ...}
  197. Note: filament_id is 1-based (matches slot_id in slice_info.config)
  198. """
  199. properties: dict[int, dict] = {}
  200. try:
  201. with zipfile.ZipFile(file_path, "r") as zf:
  202. # Try slice_info.config first for filament types
  203. if "Metadata/slice_info.config" in zf.namelist():
  204. content = zf.read("Metadata/slice_info.config").decode()
  205. root = ET.fromstring(content)
  206. for f in root.findall(".//filament"):
  207. try:
  208. # id is 1-based in slice_info.config
  209. fid = int(f.get("id", 0))
  210. properties[fid] = {
  211. "type": f.get("type", "PLA"),
  212. "diameter": DEFAULT_FILAMENT_DIAMETER,
  213. "density": DEFAULT_FILAMENT_DENSITY,
  214. }
  215. except ValueError:
  216. pass # Skip filament entries with unparseable IDs
  217. # Try project_settings.config for density values
  218. if "Metadata/project_settings.config" in zf.namelist():
  219. content = zf.read("Metadata/project_settings.config").decode()
  220. try:
  221. data = json.loads(content)
  222. densities = data.get("filament_density", [])
  223. for i, density in enumerate(densities):
  224. # project_settings uses 0-based indexing, convert to 1-based
  225. fid = i + 1
  226. if fid not in properties:
  227. properties[fid] = {
  228. "type": "",
  229. "diameter": DEFAULT_FILAMENT_DIAMETER,
  230. }
  231. try:
  232. properties[fid]["density"] = float(density)
  233. except (ValueError, TypeError):
  234. properties[fid]["density"] = DEFAULT_FILAMENT_DENSITY
  235. except json.JSONDecodeError:
  236. pass # Skip malformed project_settings.config JSON
  237. except Exception:
  238. pass # Return whatever properties were collected before the error
  239. return properties
  240. def _first_settings_id(value: object) -> str | None:
  241. """A ``*_settings_id`` value is usually a string, occasionally a list (one
  242. entry per extruder). Return the first non-empty string, else None."""
  243. if isinstance(value, str):
  244. return value.strip() or None
  245. if isinstance(value, list):
  246. for item in value:
  247. if isinstance(item, str) and item.strip():
  248. return item.strip()
  249. return None
  250. def extract_embedded_presets_from_3mf(zf: zipfile.ZipFile) -> dict[str, str | None]:
  251. """Read the printer / process preset names a 3MF project was prepared with.
  252. BambuStudio / OrcaSlicer write the chosen preset names into
  253. ``Metadata/project_settings.config`` (``printer_settings_id`` and
  254. ``print_settings_id``). The SliceModal uses them to default its printer
  255. and process dropdowns to what the file was sliced for (#1325) instead of
  256. blindly taking the first listed preset.
  257. Returns ``{"printer": <name|None>, "process": <name|None>}``. Every failure
  258. mode (missing config, malformed JSON, unexpected shape) yields ``None``
  259. values so the modal falls back to its own defaults.
  260. """
  261. result: dict[str, str | None] = {"printer": None, "process": None}
  262. try:
  263. if "Metadata/project_settings.config" not in zf.namelist():
  264. return result
  265. data = json.loads(zf.read("Metadata/project_settings.config").decode())
  266. except (KeyError, ValueError, OSError):
  267. return result
  268. if not isinstance(data, dict):
  269. return result
  270. result["printer"] = _first_settings_id(data.get("printer_settings_id"))
  271. result["process"] = _first_settings_id(data.get("print_settings_id"))
  272. return result
  273. # Ceiling on the dense per-slot form below. Deliberately larger than the 32
  274. # entries a print command carries, so a legitimate file is never silently
  275. # truncated at the limit -- it is either usable or rejected outright.
  276. _MAX_DENSE_FILAMENT_SLOTS = 64
  277. def extract_slot_extruders_from_3mf(file_path: Path) -> list[int] | None:
  278. """Per-slot extruder assignment as a dense list, or None (#2800).
  279. Same data as :func:`extract_nozzle_mapping_from_3mf`, reshaped for the
  280. dispatcher: index 0 is filament slot 1, and a slot this file does not
  281. print is ``-1``. Nozzle-rack printers (H2C) need it to build the physical
  282. ``nozzle_mapping`` the firmware expects — without one they fall back to
  283. picking a nozzle themselves, which can level with one hotend and print
  284. with another, several millimetres off the bed.
  285. Takes a path rather than an open archive because the dispatcher is
  286. handling the file, not the zip, and a broken file there must not take the
  287. print down: an unreadable or non-3MF path returns None, and the caller
  288. dispatches exactly as it did before this existed.
  289. """
  290. try:
  291. with zipfile.ZipFile(file_path) as zf:
  292. by_slot = extract_nozzle_mapping_from_3mf(zf)
  293. except (zipfile.BadZipFile, OSError) as exc:
  294. logger.warning("Failed to read nozzle mapping from %s: %s", file_path, exc)
  295. return None
  296. if not by_slot:
  297. return None
  298. # The slot IDs are whatever the file says, so the dense form has to be
  299. # bounded before it is built: a corrupt or hostile 3MF declaring
  300. # `filament id="50000000"` would otherwise allocate a fifty-million-entry
  301. # list here, on the dispatch path. Nothing above 32 is usable anyway --
  302. # that is the length of the array the printer is sent.
  303. highest_slot = max(by_slot)
  304. if highest_slot < 1 or highest_slot > _MAX_DENSE_FILAMENT_SLOTS:
  305. logger.warning(
  306. "Ignoring nozzle mapping from %s: highest filament slot %s is out of range",
  307. file_path,
  308. highest_slot,
  309. )
  310. return None
  311. return [by_slot.get(slot, -1) for slot in range(1, highest_slot + 1)]
  312. def extract_nozzle_mapping_from_3mf(zf: zipfile.ZipFile) -> dict[int, int] | None:
  313. """Extract per-slot nozzle/extruder mapping from a 3MF file.
  314. On dual-nozzle printers (H2D, H2D Pro), each filament slot is assigned to a
  315. specific nozzle. The slicer may override user preferences when using "Auto For
  316. Flush" mode, so the actual assignment comes from slice_info.config group_id
  317. attributes, not from the user's filament_nozzle_map preference.
  318. Priority:
  319. 1. group_id on <filament> elements in slice_info.config (actual assignment)
  320. 2. filament_nozzle_map in project_settings.config (user preference fallback)
  321. Both are mapped through physical_extruder_map to get MQTT extruder IDs (0=right, 1=left).
  322. Args:
  323. zf: An open ZipFile of the 3MF archive
  324. Returns:
  325. Dictionary mapping {slot_id: extruder_id} for dual-nozzle files,
  326. or None if single-nozzle, missing data, or parse error.
  327. """
  328. try:
  329. if "Metadata/project_settings.config" not in zf.namelist():
  330. return None
  331. content = zf.read("Metadata/project_settings.config").decode()
  332. data = json.loads(content)
  333. physical_extruder_map = data.get("physical_extruder_map")
  334. if not physical_extruder_map or len(physical_extruder_map) <= 1:
  335. return None # Single-nozzle printer
  336. # Check if only one extruder is active.
  337. # If so, we can skip the mapping and just assign all slots to that extruder.
  338. # extruder_nozzle_stats format: ["Standard#0|High Flow#0", "Standard#1"]
  339. # Each entry = one extruder. Format: <NozzleVolumeType>#<count>[|...]
  340. # #N is the count of physical nozzles of that type (0 = none installed).
  341. # Types: Standard, High Flow, Hybrid, TPU High Flow
  342. active_extruders = []
  343. for stats_str in data.get("extruder_nozzle_stats") or []:
  344. nozzle_counts = [n.partition("#")[2] for n in stats_str.split("|")]
  345. active_extruders.append(1 if any(c not in ("0", "") for c in nozzle_counts) else 0)
  346. # Parse slice_info once: needed by both the single-active shortcut
  347. # (to verify the slice is actually single-group, #1825) and Priority 1.
  348. si_root: ET.Element | None = None
  349. distinct_group_ids: set[int] = set()
  350. if "Metadata/slice_info.config" in zf.namelist():
  351. si_content = zf.read("Metadata/slice_info.config").decode()
  352. si_root = ET.fromstring(si_content)
  353. for filament_elem in si_root.findall(".//filament"):
  354. gid = filament_elem.get("group_id")
  355. if gid is not None:
  356. try:
  357. distinct_group_ids.add(int(gid))
  358. except (ValueError, TypeError):
  359. pass
  360. # Single-active shortcut: only safe when the slice actually uses one
  361. # group. extruder_nozzle_stats can under-report a second installed
  362. # nozzle when its volume-type differs from the profile's enumerated
  363. # types (HT-AMS / High-Flow asymmetry on H2D, #1825); without this
  364. # guard the shortcut collapses a real multi-extruder slice onto one
  365. # nozzle and the group_id mapping below is skipped.
  366. if sum(active_extruders) == 1 and len(distinct_group_ids) <= 1:
  367. nozzle_mapping: dict[int, int] = {}
  368. active_idx = active_extruders.index(1)
  369. target_extruder = int(physical_extruder_map[active_idx])
  370. if si_root is not None:
  371. for filament_elem in si_root.findall(".//filament"):
  372. try:
  373. nozzle_mapping[int(filament_elem.get("id"))] = target_extruder
  374. except (ValueError, TypeError):
  375. pass
  376. return nozzle_mapping or None
  377. # Priority 1: Use group_id from slice_info filament elements.
  378. # This reflects the actual slicer assignment (respects "Auto For Flush").
  379. nozzle_mapping: dict[int, int] = {}
  380. if si_root is not None:
  381. for filament_elem in si_root.findall(".//filament"):
  382. group_id_str = filament_elem.get("group_id")
  383. filament_id_str = filament_elem.get("id")
  384. if group_id_str is not None and filament_id_str:
  385. try:
  386. group_id = int(group_id_str)
  387. slot_id = int(filament_id_str)
  388. if group_id < len(physical_extruder_map):
  389. nozzle_mapping[slot_id] = int(physical_extruder_map[group_id])
  390. except (ValueError, TypeError, IndexError):
  391. pass
  392. if nozzle_mapping:
  393. return nozzle_mapping
  394. # Priority 2: Fall back to filament_nozzle_map (user preference).
  395. # This is correct when the user manually assigned nozzles, but may be
  396. # wrong when the slicer overrides via "Auto For Flush".
  397. filament_nozzle_map = data.get("filament_nozzle_map")
  398. if not filament_nozzle_map:
  399. return None
  400. for i, slicer_ext_str in enumerate(filament_nozzle_map):
  401. slot_id = i + 1
  402. try:
  403. slicer_ext = int(slicer_ext_str)
  404. if slicer_ext < len(physical_extruder_map):
  405. nozzle_mapping[slot_id] = int(physical_extruder_map[slicer_ext])
  406. except (ValueError, TypeError, IndexError):
  407. pass
  408. return nozzle_mapping if nozzle_mapping else None
  409. except Exception:
  410. return None
  411. @dataclass(frozen=True)
  412. class PlateMetadata:
  413. """Combined per-plate slice_info.config values from a single 3MF parse.
  414. Bundles the three fields the queue listing needs so a queue poll opens and
  415. parses each 3MF once instead of three times (#2573). ``filament_usage`` is
  416. the full per-filament list (other callers — usage tracking, Spoolman — need
  417. it); ``filament_used_grams`` is its ``used_g`` sum, precomputed here so the
  418. queue path doesn't re-sum on every hit.
  419. """
  420. print_time_seconds: int | None = None
  421. filament_usage: list[dict] = field(default_factory=list)
  422. bed_type: str | None = None
  423. filament_used_grams: float = 0.0
  424. _EMPTY_PLATE_METADATA = PlateMetadata()
  425. # Revision-keyed cache for parsed per-plate metadata. Queue polling re-lists the
  426. # same unchanged 3MFs every few seconds per connected client (#2573); without a
  427. # cache each row costs a ZIP open + XML parse. The key includes the file's
  428. # mtime_ns and size so a replaced or edited file transparently gets a fresh
  429. # entry — no manual invalidation needed. Bounded LRU + lock so it stays small
  430. # and is safe to touch from worker threads.
  431. _PLATE_METADATA_CACHE: "OrderedDict[tuple, PlateMetadata]" = OrderedDict()
  432. _PLATE_METADATA_CACHE_LOCK = Lock()
  433. _PLATE_METADATA_CACHE_MAX = 512
  434. def clear_plate_metadata_cache() -> None:
  435. """Drop all cached per-plate metadata (used by tests)."""
  436. with _PLATE_METADATA_CACHE_LOCK:
  437. _PLATE_METADATA_CACHE.clear()
  438. def _parse_plate_metadata_uncached(file_path: Path, plate_id: int | None) -> PlateMetadata:
  439. """Open the 3MF once and pull print time, filament usage and bed type.
  440. Replicates the per-field ``plate_id=None`` behaviour of the three legacy
  441. helpers exactly: usage collects every ``<filament>`` in the file, while
  442. print time and bed type come from the first ``<plate>``.
  443. """
  444. try:
  445. with zipfile.ZipFile(file_path, "r") as zf:
  446. if "Metadata/slice_info.config" not in zf.namelist():
  447. return _EMPTY_PLATE_METADATA
  448. content = zf.read("Metadata/slice_info.config").decode()
  449. root = ET.fromstring(content)
  450. except Exception as e:
  451. logger.warning("Failed to read plate metadata from %s: %s", file_path, e)
  452. return _EMPTY_PLATE_METADATA
  453. def _plate_index(plate_elem) -> int | None:
  454. for meta in plate_elem.findall("metadata"):
  455. if meta.get("key") == "index":
  456. try:
  457. return int(meta.get("value", "0"))
  458. except ValueError:
  459. return None
  460. return None
  461. def _collect_filaments(plate_elem) -> list[dict]:
  462. out: list[dict] = []
  463. for f in plate_elem.findall("filament"):
  464. filament_id = f.get("id")
  465. # Both the used_g float() and the id int() must stay inside the guard:
  466. # a non-numeric id or used_g is silently skipped (matches the legacy
  467. # helpers, which tolerated garbage rows rather than raising — a raise
  468. # here would 500 the whole queue listing).
  469. try:
  470. used_amount = float(f.get("used_g", "0"))
  471. if filament_id:
  472. out.append(
  473. {
  474. "slot_id": int(filament_id),
  475. "used_g": used_amount,
  476. "type": f.get("type", ""),
  477. "color": f.get("color", ""),
  478. }
  479. )
  480. except (ValueError, TypeError):
  481. continue
  482. return out
  483. print_time: int | None = None
  484. bed_type: str | None = None
  485. filament_usage: list[dict] = []
  486. matched_plate = None
  487. if plate_id is not None:
  488. for plate_elem in root.findall(".//plate"):
  489. if _plate_index(plate_elem) == plate_id:
  490. matched_plate = plate_elem
  491. break
  492. else:
  493. matched_plate = root.find(".//plate")
  494. if matched_plate is not None:
  495. for meta in matched_plate.findall("metadata"):
  496. key = meta.get("key")
  497. if key == "prediction" and print_time is None:
  498. try:
  499. print_time = int(meta.get("value", "0"))
  500. except ValueError:
  501. print_time = None
  502. elif key == "curr_bed_type" and meta.get("value"):
  503. bed_type = (meta.get("value") or "").strip()
  504. if plate_id is not None:
  505. if matched_plate is not None:
  506. filament_usage = _collect_filaments(matched_plate)
  507. else:
  508. # Legacy plate_id=None usage: every filament in the file, not just plate 1.
  509. for f in root.findall(".//filament"):
  510. filament_id = f.get("id")
  511. # int()/float() both guarded — a garbage id/used_g row is skipped, not raised.
  512. try:
  513. used_amount = float(f.get("used_g", "0"))
  514. if filament_id:
  515. filament_usage.append(
  516. {
  517. "slot_id": int(filament_id),
  518. "used_g": used_amount,
  519. "type": f.get("type", ""),
  520. "color": f.get("color", ""),
  521. }
  522. )
  523. except (ValueError, TypeError):
  524. continue
  525. return PlateMetadata(
  526. print_time_seconds=print_time,
  527. filament_usage=filament_usage,
  528. bed_type=bed_type,
  529. filament_used_grams=sum(f["used_g"] for f in filament_usage),
  530. )
  531. def extract_plate_metadata_from_3mf(file_path: Path, plate_id: int | None = None) -> PlateMetadata:
  532. """Return combined per-plate metadata, cached by file revision (#2573).
  533. The result is keyed by ``(path, plate_id, mtime_ns, size)`` so an unchanged
  534. file is parsed at most once; a replaced/edited file re-parses automatically.
  535. The returned ``PlateMetadata`` is shared and MUST be treated as read-only —
  536. callers that need a mutable filament list get a copy from the wrappers below.
  537. """
  538. file_path = Path(file_path)
  539. try:
  540. stat = file_path.stat()
  541. except OSError:
  542. # File missing/unreadable: parse (which will return empty) but don't
  543. # cache — the file may appear later and we don't want a sticky miss.
  544. return _parse_plate_metadata_uncached(file_path, plate_id)
  545. key = (str(file_path), plate_id, stat.st_mtime_ns, stat.st_size)
  546. with _PLATE_METADATA_CACHE_LOCK:
  547. cached = _PLATE_METADATA_CACHE.get(key)
  548. if cached is not None:
  549. _PLATE_METADATA_CACHE.move_to_end(key)
  550. return cached
  551. metadata = _parse_plate_metadata_uncached(file_path, plate_id)
  552. with _PLATE_METADATA_CACHE_LOCK:
  553. _PLATE_METADATA_CACHE[key] = metadata
  554. _PLATE_METADATA_CACHE.move_to_end(key)
  555. while len(_PLATE_METADATA_CACHE) > _PLATE_METADATA_CACHE_MAX:
  556. _PLATE_METADATA_CACHE.popitem(last=False)
  557. return metadata
  558. def extract_filament_usage_from_3mf(file_path: Path, plate_id: int | None = None) -> list[dict]:
  559. """Extract per-filament total usage from 3MF slice_info.config.
  560. This extracts the slicer-estimated total usage per filament slot,
  561. not the per-layer breakdown.
  562. Args:
  563. file_path: Path to the 3MF file
  564. plate_id: Optional plate index to filter for (for multi-plate files)
  565. Returns:
  566. List of filament usage dictionaries:
  567. [{"slot_id": 1, "used_g": 50.5, "type": "PLA", "color": "#FF0000"}, ...]
  568. """
  569. # Delegate to the cached combined parse (#2573). Return fresh dicts so callers
  570. # that mutate the list don't corrupt the shared cached PlateMetadata.
  571. return [dict(f) for f in extract_plate_metadata_from_3mf(file_path, plate_id).filament_usage]
  572. def extract_print_time_from_3mf(file_path: Path, plate_id: int | None = None) -> int | None:
  573. """Extract the slicer's predicted print time from a 3MF's slice_info.config.
  574. Multi-plate 3MFs carry one ``<plate><metadata key="prediction" .../></plate>``
  575. per plate. The archive-level `print_time_seconds` is the sum across all plates
  576. (see services/archive.py:200-264, #1593). For per-plate UI / notifications,
  577. callers re-read the 3MF and request the specific plate's value via this helper.
  578. Args:
  579. file_path: Path to the 3MF file
  580. plate_id: Plate index to filter for; if None, returns the first plate's
  581. ``prediction`` (matches the legacy single-plate read).
  582. Returns:
  583. Predicted print time in seconds, or None if not found / unparseable.
  584. """
  585. return extract_plate_metadata_from_3mf(file_path, plate_id).print_time_seconds
  586. def extract_bed_type_from_3mf(file_path: Path, plate_id: int | None = None) -> str | None:
  587. """Extract the build plate type (`curr_bed_type`) for a specific plate (#1281).
  588. ``archive.bed_type`` is captured at ingest time but is one value per archive
  589. (the first plate's `curr_bed_type` — see services/archive.py:235). For a
  590. multi-plate 3MF where different plates target different beds (e.g. a 40-plate
  591. file mixing PEI + Engineering), the archive-level value lies. When a queue
  592. item or print modal targets a specific plate, this re-reads the 3MF and
  593. returns that plate's actual bed type.
  594. Args:
  595. file_path: Path to the 3MF file
  596. plate_id: Plate index to filter for; if None, returns the first plate's
  597. ``curr_bed_type`` (matches the archive-level capture).
  598. Returns:
  599. Bed type string (e.g. "Textured PEI Plate"), or None if not found.
  600. """
  601. return extract_plate_metadata_from_3mf(file_path, plate_id).bed_type
  602. # Header values exposed as `{placeholder}` substitutions inside snippets.
  603. # Aliases let users write Prusa-style names (`{max_layer_z}`) that map onto
  604. # Bambu/Orca header keys (`max_z_height`).
  605. _HEADER_PLACEHOLDER_ALIASES = {
  606. "max_layer_z": "max_z_height",
  607. "max_print_height": "max_z_height",
  608. "total_layers": "total_layer_number",
  609. }
  610. _HEADER_KEY_RE = re.compile(r"^;\s*([^:]+?)\s*:\s*(.+?)\s*$")
  611. _PLACEHOLDER_RE = re.compile(r"\{([a-zA-Z_][a-zA-Z0-9_]*)\}")
  612. _START_GCODE_END_MARKER = "; MACHINE_START_GCODE_END"
  613. _EXECUTABLE_BLOCK_END_MARKER = "; EXECUTABLE_BLOCK_END"
  614. def _parse_3mf_gcode_header(content: str) -> dict[str, str]:
  615. """Parse the `; HEADER_BLOCK_START..END` block into a normalised dict.
  616. Keys are lowercased, ` [units]` suffixes stripped, and spaces converted
  617. to underscores so callers can look up `total_layer_number` regardless of
  618. whether the source line is `; total layer number: 80` or
  619. `; total filament length [mm] : 12155.34`.
  620. """
  621. header: dict[str, str] = {}
  622. in_header = False
  623. for raw_line in content.splitlines():
  624. line = raw_line.strip()
  625. if line == "; HEADER_BLOCK_START":
  626. in_header = True
  627. continue
  628. if line == "; HEADER_BLOCK_END":
  629. break
  630. if not in_header:
  631. continue
  632. m = _HEADER_KEY_RE.match(line)
  633. if not m:
  634. continue
  635. key, value = m.group(1), m.group(2)
  636. key = re.sub(r"\s*\[[^\]]*\]\s*$", "", key)
  637. key = key.strip().lower().replace(" ", "_")
  638. header[key] = value
  639. return header
  640. def _plate_number_of(name: str) -> int | None:
  641. """Plate index encoded in a ``…/plate_<n>.gcode`` member, or None.
  642. Parsed as an int rather than string-matched so a zero-padded
  643. ``plate_01.gcode`` resolves to the same 1 the plates endpoint reports.
  644. """
  645. marker = "plate_"
  646. idx = name.rfind(marker)
  647. if idx < 0 or not name.endswith(".gcode"):
  648. return None
  649. try:
  650. return int(name[idx + len(marker) : -len(".gcode")])
  651. except ValueError:
  652. return None
  653. def select_plate_gcode_name(names: list[str], plate_id: int | None) -> str | None:
  654. """The ``.gcode`` member for exactly ``plate_id``, or None if it isn't there.
  655. Returns None for a ``plate_id`` the file doesn't hold — callers that want a
  656. fallback compose this with ``default_plate_gcode_name``; callers serving a
  657. user's explicit plate choice want the None so they can 404 instead of
  658. quietly rendering a different plate.
  659. """
  660. if plate_id is None:
  661. return None
  662. for name in names:
  663. if name.endswith(".gcode") and _plate_number_of(name) == plate_id:
  664. return name
  665. return None
  666. def default_plate_gcode_name(names: list[str]) -> str | None:
  667. """The ``.gcode`` member to show when no plate was asked for.
  668. The lowest plate number, NOT the first member in the archive: zip order is
  669. whatever the slicer happened to write, and Bambu Studio does not write
  670. plates in order — a two-plate file measured here stores ``plate_2.gcode``
  671. ahead of ``plate_1.gcode``, so taking the first member opened plate 2. Files
  672. from slicers that don't use the plate naming convention keep the old
  673. first-member behaviour, since there is no numbering to sort by.
  674. """
  675. gcodes = [n for n in names if n.endswith(".gcode")]
  676. if not gcodes:
  677. return None
  678. numbered = [(num, n) for n in gcodes if (num := _plate_number_of(n)) is not None]
  679. if numbered:
  680. return min(numbered)[1]
  681. return gcodes[0]
  682. # The header block sits at the very top of the plate G-code. Read only that
  683. # much: a sliced plate is routinely tens of megabytes and `ZipFile.read()`
  684. # would inflate all of it to reach ~40 lines.
  685. _HEADER_READ_LIMIT_BYTES = 64 * 1024
  686. def extract_max_z_height_from_3mf(file_path: Path, plate_id: int | None = None) -> float | None:
  687. """Return the plate's ``max_z_height`` in mm, or None if not knowable.
  688. This is the Z the toolhead sat at for the final layer — the same value
  689. Bambu's own end G-code adds its bed-drop offset to (``G1 Z{max_layer_z +
  690. 100}``). #2547 uses it to put the plate back into camera framing before the
  691. finish photo, which is only safe because it is a height the printer was
  692. physically at seconds earlier.
  693. None means "don't know" and callers must treat it as such rather than
  694. substituting a default: the file may be unreadable, carry no plate G-code,
  695. or come from a slicer that writes no ``max_z_height`` header. Guessing a
  696. height here would command a Z move to somewhere the nozzle has never been.
  697. """
  698. try:
  699. with zipfile.ZipFile(file_path, "r") as zf:
  700. names = zf.namelist()
  701. target = select_plate_gcode_name(names, plate_id) or default_plate_gcode_name(names)
  702. if target is None:
  703. return None
  704. with zf.open(target, "r") as fh:
  705. head = fh.read(_HEADER_READ_LIMIT_BYTES)
  706. except (OSError, zipfile.BadZipFile, KeyError) as e:
  707. logger.debug("max_z_height: cannot read %s: %s", file_path, e)
  708. return None
  709. raw = _parse_3mf_gcode_header(head.decode("utf-8", errors="ignore")).get("max_z_height")
  710. if raw is None:
  711. return None
  712. try:
  713. value = float(raw)
  714. except ValueError:
  715. logger.debug("max_z_height: unusable value %r in %s", raw, file_path)
  716. return None
  717. # Zero or negative means the header key is present but meaningless. Passed
  718. # on as a height it would become a move *toward* the bed, so drop it.
  719. return value if value > 0 else None
  720. def _substitute_placeholders(snippet: str, header: dict[str, str]) -> str:
  721. """Replace `{var}` placeholders with header values, leaving unknowns intact."""
  722. def repl(m: re.Match) -> str:
  723. name = m.group(1)
  724. value = header.get(name)
  725. if value is None:
  726. alias = _HEADER_PLACEHOLDER_ALIASES.get(name)
  727. if alias is not None:
  728. value = header.get(alias)
  729. if value is None:
  730. logger.warning(
  731. "G-code injection: placeholder {%s} not found in 3MF header; leaving as-is",
  732. name,
  733. )
  734. return m.group(0)
  735. return value
  736. return _PLACEHOLDER_RE.sub(repl, snippet)
  737. def _inject_start_at_marker(content: str, snippet: str) -> str:
  738. """Insert snippet immediately before `; MACHINE_START_GCODE_END`.
  739. The marker sits at the bottom of the printer's startup block — bed heat,
  740. homing, and nozzle prime are already done, so injected snippets land in
  741. the same place a slicer-side custom-start-gcode would. Falls back to
  742. prepending if the marker isn't present (older files / non-Bambu slicers).
  743. """
  744. marker_idx = content.find(_START_GCODE_END_MARKER)
  745. if marker_idx == -1:
  746. logger.warning(
  747. "G-code injection: '%s' not found, prepending start snippet to whole file",
  748. _START_GCODE_END_MARKER,
  749. )
  750. return snippet.rstrip("\n") + "\n" + content
  751. line_start = content.rfind("\n", 0, marker_idx)
  752. line_start = 0 if line_start == -1 else line_start + 1
  753. return content[:line_start] + snippet.rstrip("\n") + "\n" + content[line_start:]
  754. def _inject_end_before_marker(content: str, snippet: str) -> str:
  755. """Insert snippet immediately before `; EXECUTABLE_BLOCK_END`.
  756. The end snippet must run *inside* the executable block. Bambu firmware
  757. (verified on a P1S) does not execute G-code that sits after
  758. `; EXECUTABLE_BLOCK_END`, so appending to the file end silently drops the
  759. snippet — auto-eject / plate-clear moves never fire. Inserting before the
  760. marker places the snippet after the printer's own machine-end sequence but
  761. still within the executed block. Falls back to appending at the file end if
  762. the marker isn't present.
  763. """
  764. marker_idx = content.find(_EXECUTABLE_BLOCK_END_MARKER)
  765. if marker_idx == -1:
  766. logger.warning(
  767. "G-code injection: '%s' not found, appending end snippet to file end",
  768. _EXECUTABLE_BLOCK_END_MARKER,
  769. )
  770. return content.rstrip("\n") + "\n" + snippet.rstrip("\n") + "\n"
  771. line_start = content.rfind("\n", 0, marker_idx)
  772. line_start = 0 if line_start == -1 else line_start + 1
  773. return content[:line_start] + snippet.rstrip("\n") + "\n" + content[line_start:]
  774. def inject_gcode_into_3mf(
  775. source_path: Path,
  776. plate_id: int,
  777. start_gcode: str | None,
  778. end_gcode: str | None,
  779. ):
  780. """Create a temp copy of a 3MF with G-code injected at start/end.
  781. Snippets support `{placeholder}` substitution against values parsed from
  782. the 3MF G-code header block (e.g. `{max_layer_z}` → `16.00`). Start
  783. snippets are anchored to the `; MACHINE_START_GCODE_END` marker so they
  784. run after the printer's own startup (#422). End snippets are inserted just
  785. before `; EXECUTABLE_BLOCK_END` so they run inside the executable block —
  786. Bambu firmware (P1S) ignores g-code placed after that marker.
  787. The plate's `.gcode.md5` sidecar is recomputed so firmware that validates
  788. it against the gcode (e.g. P1S) still accepts the modified file.
  789. Args:
  790. source_path: Path to the original 3MF file.
  791. plate_id: Plate number (1-indexed) to inject into.
  792. start_gcode: G-code to insert after printer startup, or None.
  793. end_gcode: G-code to append, or None.
  794. Returns:
  795. Path to temp file with injected G-code, or None if injection failed.
  796. Caller is responsible for cleaning up the temp file.
  797. """
  798. import tempfile
  799. if not start_gcode and not end_gcode:
  800. return None
  801. try:
  802. # Find the target gcode file inside the 3MF
  803. with zipfile.ZipFile(source_path, "r") as zf:
  804. # Plate-specific gcode first, else the lowest-numbered plate.
  805. names = zf.namelist()
  806. target_gcode = select_plate_gcode_name(names, plate_id) or default_plate_gcode_name(names)
  807. if target_gcode is None:
  808. return None
  809. # Read and modify gcode content
  810. gcode_content = zf.read(target_gcode).decode("utf-8", errors="ignore")
  811. header = _parse_3mf_gcode_header(gcode_content)
  812. if start_gcode:
  813. resolved = _substitute_placeholders(start_gcode, header)
  814. # Log the post-substitution snippet so the actually-injected G-code
  815. # (placeholders like {max_layer_z} already resolved) is visible at DEBUG.
  816. logger.debug("G-code injection [%s]: resolved START snippet:\n%s", target_gcode, resolved)
  817. gcode_content = _inject_start_at_marker(gcode_content, resolved)
  818. if end_gcode:
  819. resolved = _substitute_placeholders(end_gcode, header)
  820. logger.debug("G-code injection [%s]: resolved END snippet:\n%s", target_gcode, resolved)
  821. gcode_content = _inject_end_before_marker(gcode_content, resolved)
  822. # The printer validates the plate gcode against an embedded
  823. # `<plate>.gcode.md5` sidecar (uppercase hex, no trailing newline).
  824. # Rewriting the gcode without refreshing this hash makes firmware
  825. # reject the file at load (P1S: HMS 0500-4003 "unable to parse"),
  826. # so recompute it from the exact bytes we're about to write.
  827. gcode_bytes = gcode_content.encode("utf-8")
  828. md5_name = target_gcode + ".md5"
  829. # Not a security hash — this reproduces Bambu's `.gcode.md5` sidecar
  830. # format, so flag it as non-security for the linters (ruff S324 / bandit B324).
  831. md5_value = hashlib.md5(gcode_bytes, usedforsecurity=False).hexdigest().upper().encode("ascii")
  832. # Write modified 3MF to temp file
  833. with tempfile.NamedTemporaryFile(delete=False, suffix=".3mf") as tmp:
  834. tmp_path = Path(tmp.name)
  835. with zipfile.ZipFile(tmp_path, "w", zipfile.ZIP_DEFLATED) as zf_write:
  836. for item in zf.namelist():
  837. info = zf.getinfo(item)
  838. if item == target_gcode:
  839. zf_write.writestr(info, gcode_bytes)
  840. elif item == md5_name:
  841. zf_write.writestr(info, md5_value)
  842. else:
  843. zf_write.writestr(info, zf.read(item))
  844. return tmp_path
  845. except Exception:
  846. # Clean up temp file on error
  847. if "tmp_path" in locals() and tmp_path.exists():
  848. tmp_path.unlink(missing_ok=True)
  849. return None
  850. def extract_project_filaments_from_3mf(zf: zipfile.ZipFile) -> list[dict]:
  851. """Project-wide AMS slot config from ``Metadata/project_settings.config``.
  852. Returns one dict per configured AMS slot in slot order (1-indexed), with
  853. ``type`` and ``color`` populated from the project's ``filament_type`` and
  854. ``filament_colour`` arrays. ``used_grams`` / ``used_meters`` are 0 because
  855. project_settings carries the configuration, not per-print usage — the
  856. fields exist for shape compatibility with the slice_info-derived list.
  857. The SliceModal needs this on **unsliced** project files: slice_info.config
  858. is empty until Bambu Studio has actually sliced the project, but the user
  859. can still pick filament profiles for a slice we're about to perform.
  860. """
  861. if "Metadata/project_settings.config" not in zf.namelist():
  862. return []
  863. try:
  864. proj = json.loads(zf.read("Metadata/project_settings.config").decode())
  865. except (ValueError, OSError):
  866. return []
  867. if not isinstance(proj, dict):
  868. return []
  869. types_arr = proj.get("filament_type") or []
  870. colors_arr = proj.get("filament_colour") or []
  871. slot_count = max(
  872. len(types_arr) if isinstance(types_arr, list) else 0, len(colors_arr) if isinstance(colors_arr, list) else 0
  873. )
  874. out: list[dict] = []
  875. for i in range(slot_count):
  876. out.append(
  877. {
  878. "slot_id": i + 1,
  879. "type": types_arr[i] if i < len(types_arr) and isinstance(types_arr[i], str) else "",
  880. "color": colors_arr[i] if i < len(colors_arr) and isinstance(colors_arr[i], str) else "",
  881. "used_grams": 0,
  882. "used_meters": 0,
  883. }
  884. )
  885. return out
  886. def expand_to_project_slots(zf: zipfile.ZipFile, used: list[dict]) -> list[dict]:
  887. """Widen a used-only filament list to one entry per project slot.
  888. ``used`` is the slice_info-derived list: only the slots whose G-code
  889. actually consumed filament, each carrying real usage figures. That is the
  890. right answer for print-time AMS matching, and the wrong one for the slice
  891. modal, because the list the modal builds is **positional** — index 0 is
  892. slot 1 all the way down to the ``filament_N.json`` parts handed to the CLI.
  893. A source whose only used slot is 4 therefore produced a single dropdown
  894. whose pick the CLI bound to slot 1, leaving slot 4 — the one the model
  895. prints with — on whatever the source had baked in (#2712).
  896. Returns the project's slots in slot order, each flagged ``used_in_plate``.
  897. Rows present in ``used`` are kept whole, so their usage figures, resolved
  898. type/colour and ``tray_info_idx`` survive; the rest come from the project
  899. configuration with zero usage. A used slot beyond the project's slot count
  900. is appended rather than dropped — the caller asked for a superset, and
  901. silently losing the one slot that prints would be the original bug again.
  902. ``used`` is returned unchanged when the file carries no project settings
  903. to widen against: a narrower-than-ideal list still prints correctly, an
  904. invented one might not.
  905. """
  906. project = extract_project_filaments_from_3mf(zf)
  907. if not project:
  908. return used
  909. by_slot = {f["slot_id"]: f for f in used}
  910. out: list[dict] = []
  911. for slot in project:
  912. known = by_slot.pop(slot["slot_id"], None)
  913. if known is not None:
  914. known["used_in_plate"] = True
  915. out.append(known)
  916. else:
  917. slot["used_in_plate"] = False
  918. out.append(slot)
  919. # Anything slice_info reported that the project doesn't declare.
  920. for leftover in by_slot.values():
  921. leftover["used_in_plate"] = True
  922. out.append(leftover)
  923. out.sort(key=lambda f: f["slot_id"])
  924. return out
  925. # BambuStudio serialises bool config options as string "1"/"0" in
  926. # project_settings.config, but forks / older versions occasionally write real
  927. # booleans or ints — accept anything that isn't unambiguously falsy. A missing
  928. # key counts as off: a 3MF that never declares `enable_support` gives us no
  929. # support intent to act on.
  930. _SUPPORTS_DISABLED_VALUES = (False, 0, "0", "false", "False", "", None)
  931. def supports_enabled_in_config(cfg: dict[str, object]) -> bool:
  932. """Whether a 3MF's ``project_settings.config`` has supports switched on.
  933. Shared by the callers that read support intent out of a source file so
  934. they agree on what "on" means: the slot extractor below and the slice
  935. route's process-preset support carry-over (#1881 / #2820).
  936. """
  937. return cfg.get("enable_support") not in _SUPPORTS_DISABLED_VALUES
  938. def extract_support_filament_slots_from_3mf(zf: zipfile.ZipFile) -> set[int]:
  939. """Slots referenced by the process settings for support material.
  940. Supports aren't attached to object geometry — they're generated by
  941. the slicer's process pass — so :func:`extract_plate_extruder_set_from_3mf`,
  942. which walks per-object extruder metadata + paint_color triangles,
  943. doesn't see them. Callers that need the complete set of slots a
  944. plate print will exercise (e.g. the SliceModal's filament-
  945. substitution logic) must union this in — otherwise a support-only
  946. slot (typical PLA-model + PVA-support setup) looks "unused" and its
  947. user-picked profile gets silently overwritten with slot 1's,
  948. producing a single-material print (#1881).
  949. Returns the empty set when supports are disabled, ``support_filament``
  950. / ``support_interface_filament`` are 0 (== "same as model"), the
  951. project has no embedded settings, or the file isn't a valid 3MF.
  952. """
  953. if "Metadata/project_settings.config" not in zf.namelist():
  954. return set()
  955. try:
  956. cfg = json.loads(zf.read("Metadata/project_settings.config").decode("utf-8"))
  957. except (json.JSONDecodeError, UnicodeDecodeError, OSError):
  958. return set()
  959. if not isinstance(cfg, dict):
  960. return set()
  961. if not supports_enabled_in_config(cfg):
  962. return set()
  963. out: set[int] = set()
  964. for key in ("support_filament", "support_interface_filament"):
  965. raw = cfg.get(key)
  966. if raw is None:
  967. continue
  968. try:
  969. slot = int(raw)
  970. except (ValueError, TypeError):
  971. continue
  972. # Slot 0 means "same as model" — no dedicated slot to preserve.
  973. if slot > 0:
  974. out.add(slot)
  975. return out
  976. _PAINT_COLOR_ATTR_RE = re.compile(rb'paint_color="([0-9A-Fa-f]+)"')
  977. # Painted-face quadtree leaves include both real filament assignments and
  978. # tiny edit artifacts (single-leaf accidents from "tried a colour, undid,
  979. # repainted with a different one"). The threshold's only job is dropping
  980. # accidents — anything the user spent meaningful effort on must survive.
  981. # 5% of an object's painted triangles is well below any 60/40 / 70/30 /
  982. # 33/33/33 split a real two- or three-colour print would hit, so all
  983. # intentional colours are kept; one-off single-leaf paints (typically
  984. # 0.1-1.5% in observed projects) are filtered. Note that this fallback
  985. # path runs ONLY when the preview-slice path can't reach the sidecar; in
  986. # the normal flow the slicer's own pruning produces the canonical list and
  987. # this threshold isn't reached.
  988. _PAINT_NOISE_THRESHOLD = 0.05
  989. def extract_plate_extruder_set_from_3mf(zf: zipfile.ZipFile, plate_id: int) -> set[int]:
  990. """Extruder/AMS slot indices (1-indexed) used by objects on ``plate_id``.
  991. Three sources are unioned because Bambu Studio splits per-object extruder
  992. info across THREE places depending on how the user assigned colours:
  993. 1. ``model_settings.config`` — top-level ``<metadata key="extruder">``
  994. on each ``<object>`` (the "default extruder" for the whole object).
  995. 2. ``model_settings.config`` — per-``<part>`` ``<metadata key="extruder">``
  996. overrides (used when the user split an object into multiple parts
  997. with distinct filaments).
  998. 3. ``3D/Objects/object_*.model`` — ``paint_color`` attributes on
  999. individual ``<triangle>`` elements (used when the user "painted" a
  1000. face with a different filament). The encoding is a hex string where
  1001. each nibble is a TriangleSelector tree node: ``0`` = unpainted leaf,
  1002. ``F`` = branch (4 children follow), ``1``..``E`` = leaf painted with
  1003. extruder N. We don't decode the tree — every leaf-paint nibble in
  1004. the string IS the extruder number, so a flat scan over hex chars
  1005. yields the correct set without recursive parsing.
  1006. Without (3) the painted-face data is invisible: model_settings says
  1007. every object on a multi-color plate uses extruder 1 by default but the
  1008. actual print uses 3, 4, 12 etc. via face paint, so the SliceModal would
  1009. render only one filament dropdown for what's clearly a multi-colour
  1010. print (#1150 follow-up).
  1011. """
  1012. if "Metadata/model_settings.config" not in zf.namelist():
  1013. return set()
  1014. try:
  1015. root = ET.fromstring(zf.read("Metadata/model_settings.config").decode())
  1016. except (ET.ParseError, OSError):
  1017. return set()
  1018. # Pass 1: object → set of extruders from XML metadata (sources 1 + 2)
  1019. # plus the per-object .model file path so we can later scan source 3.
  1020. object_extruders: dict[str, set[int]] = {}
  1021. object_model_paths: dict[str, list[str]] = {}
  1022. for obj_elem in root.findall(".//object"):
  1023. obj_id = obj_elem.get("id")
  1024. if not obj_id:
  1025. continue
  1026. extruders: set[int] = set()
  1027. top = obj_elem.find("metadata[@key='extruder']")
  1028. if top is not None:
  1029. try:
  1030. v = int(top.get("value", "0"))
  1031. if v > 0:
  1032. extruders.add(v)
  1033. except (ValueError, TypeError):
  1034. pass
  1035. for part_elem in obj_elem.findall(".//part"):
  1036. part_ext = part_elem.find("metadata[@key='extruder']")
  1037. if part_ext is None:
  1038. continue
  1039. try:
  1040. v = int(part_ext.get("value", "0"))
  1041. if v > 0:
  1042. extruders.add(v)
  1043. except (ValueError, TypeError):
  1044. pass
  1045. object_extruders[obj_id] = extruders
  1046. # Pass 2: 3dmodel.model maps each <object id="N"> to its component
  1047. # .model file path(s). Bambu wraps object IDs that match
  1048. # model_settings.config IDs around <components><component
  1049. # path="/3D/Objects/object_K.model" objectid="..." /></components>.
  1050. # Strip xmlns prefixes on attributes so ElementTree can find them
  1051. # without namespace gymnastics — `p:path` becomes `path` etc.
  1052. if "3D/3dmodel.model" in zf.namelist():
  1053. try:
  1054. raw = zf.read("3D/3dmodel.model").decode()
  1055. stripped = re.sub(r'xmlns:?\w*="[^"]*"', "", raw)
  1056. stripped = re.sub(r"<(/?)\w+:", r"<\1", stripped)
  1057. stripped = re.sub(r" \w+:(\w+=)", r" \1", stripped)
  1058. model_root = ET.fromstring(stripped)
  1059. for obj_elem in model_root.findall(".//object"):
  1060. oid = obj_elem.get("id")
  1061. if not oid:
  1062. continue
  1063. comps = obj_elem.find("components")
  1064. if comps is None:
  1065. continue
  1066. paths = []
  1067. for c in comps.findall("component"):
  1068. p = c.get("path")
  1069. if p:
  1070. paths.append(p.lstrip("/"))
  1071. if paths:
  1072. object_model_paths[oid] = paths
  1073. except (ET.ParseError, OSError):
  1074. pass # No 3dmodel — paint scan just won't apply
  1075. # Pass 3: scan paint_color attrs in each per-object .model file. Cache
  1076. # by file path because two objects often share the same component tree.
  1077. paint_cache: dict[str, set[int]] = {}
  1078. def _scan_paint(path: str) -> set[int]:
  1079. if path in paint_cache:
  1080. return paint_cache[path]
  1081. out: set[int] = set()
  1082. if path not in zf.namelist():
  1083. paint_cache[path] = out
  1084. return out
  1085. try:
  1086. data = zf.read(path)
  1087. except OSError:
  1088. paint_cache[path] = out
  1089. return out
  1090. # Per-extruder triangle coverage. Each painted triangle may have
  1091. # multiple leaf nibbles (the quadtree subdivides the face into
  1092. # painted regions); we count one triangle per unique extruder per
  1093. # match so the resulting fraction is "what share of painted
  1094. # triangles include at least one leaf with extruder N". Noise from
  1095. # one-off edit artifacts is filtered out at the threshold below.
  1096. extruder_triangles: dict[int, int] = {}
  1097. total_painted = 0
  1098. for match in _PAINT_COLOR_ATTR_RE.finditer(data):
  1099. total_painted += 1
  1100. seen: set[int] = set()
  1101. for ch in match.group(1):
  1102. # Hex digit → 4-bit value. 0 = unpainted leaf, F = branch
  1103. # (decoded recursively but children are encoded inline, so
  1104. # we'll see them on later iterations). 1-E = leaf painted
  1105. # with extruder N.
  1106. if ch in b"123456789":
  1107. seen.add(ch - 0x30)
  1108. elif ch in b"ABCDEabcde":
  1109. seen.add((ch & 0x4F) - 0x37)
  1110. for e in seen:
  1111. extruder_triangles[e] = extruder_triangles.get(e, 0) + 1
  1112. if total_painted > 0:
  1113. cutoff = max(1, int(total_painted * _PAINT_NOISE_THRESHOLD))
  1114. for ext, count in extruder_triangles.items():
  1115. if count >= cutoff:
  1116. out.add(ext)
  1117. paint_cache[path] = out
  1118. return out
  1119. # Walk plates — collect extruders for objects on the requested plate.
  1120. used: set[int] = set()
  1121. for plate_elem in root.findall(".//plate"):
  1122. plater_id = None
  1123. for meta in plate_elem.findall("metadata"):
  1124. if meta.get("key") == "plater_id":
  1125. try:
  1126. plater_id = int(meta.get("value", ""))
  1127. except (ValueError, TypeError):
  1128. pass
  1129. break
  1130. if plater_id != plate_id:
  1131. continue
  1132. for inst in plate_elem.findall("model_instance"):
  1133. for inst_meta in inst.findall("metadata"):
  1134. if inst_meta.get("key") != "object_id":
  1135. continue
  1136. obj_id = inst_meta.get("value")
  1137. if not obj_id:
  1138. continue
  1139. used.update(object_extruders.get(obj_id, set()))
  1140. for path in object_model_paths.get(obj_id, []):
  1141. used.update(_scan_paint(path))
  1142. break
  1143. return used