threemf_tools.py 90 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102
  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 io
  8. import json
  9. import logging
  10. import math
  11. import re
  12. import zipfile
  13. from collections import OrderedDict
  14. from collections.abc import Iterable
  15. from dataclasses import dataclass, field
  16. from pathlib import Path
  17. from threading import Lock
  18. # Parsing goes through defusedxml; the element type it hands back is the stdlib
  19. # one, and defusedxml does not re-export it, so annotations name it directly.
  20. from xml.etree.ElementTree import Element as XmlElement
  21. import defusedxml.ElementTree as ET
  22. logger = logging.getLogger(__name__)
  23. # Default filament properties
  24. DEFAULT_FILAMENT_DIAMETER = 1.75 # mm
  25. DEFAULT_FILAMENT_DENSITY = 1.24 # g/cm³ (PLA)
  26. def parse_gcode_layer_filament_usage(gcode_content: str) -> dict[int, dict[int, float]]:
  27. """Parse G-code to extract per-layer, per-filament cumulative extrusion in mm.
  28. This function tracks filament extrusion across layers and tool changes,
  29. building a cumulative usage map that can be used to calculate partial
  30. usage at any layer.
  31. Args:
  32. gcode_content: The raw G-code content as a string
  33. Returns:
  34. A nested dictionary mapping layer numbers to filament usage:
  35. {layer: {filament_id: cumulative_mm}, ...}
  36. Example:
  37. {0: {0: 125.5}, 1: {0: 250.0, 1: 50.0}, 2: {0: 375.0, 1: 150.0}}
  38. This shows:
  39. - Layer 0: filament 0 used 125.5mm cumulative
  40. - Layer 1: filament 0 used 250mm cumulative, filament 1 used 50mm
  41. - Layer 2: filament 0 used 375mm cumulative, filament 1 used 150mm
  42. G-code commands parsed:
  43. - M73 L<layer>: Layer change marker
  44. - M620 S<filament>: Filament/tool change (S255 = unload)
  45. - G0/G1/G2/G3 E<amount>: Extrusion moves (relative E; retractions
  46. are subtracted, so each layer holds the net length fed)
  47. """
  48. layer_filaments: dict[int, dict[int, float]] = {}
  49. current_layer = 0
  50. active_filament: int | None = None
  51. cumulative_extrusion: dict[int, float] = {} # filament_id -> total mm
  52. for line in gcode_content.splitlines():
  53. line = line.strip()
  54. if not line:
  55. continue
  56. # Handle comments - skip but check for layer markers
  57. if line.startswith(";"):
  58. # Some slicers use comment-based layer markers
  59. # e.g., "; CHANGE_LAYER" or ";LAYER_CHANGE"
  60. continue
  61. # Split line into command and inline comment
  62. if ";" in line:
  63. line = line.split(";")[0].strip()
  64. # Extract command and parameters
  65. parts = line.split()
  66. if not parts:
  67. continue
  68. cmd = parts[0].upper()
  69. # Layer change: M73 L<layer>
  70. # Bambu printers use M73 with L parameter for layer indication
  71. if cmd == "M73":
  72. for part in parts[1:]:
  73. part_upper = part.upper()
  74. if part_upper.startswith("L"):
  75. try:
  76. new_layer = int(part[1:])
  77. # Save current state before layer change
  78. if cumulative_extrusion:
  79. layer_filaments[current_layer] = _snapshot(cumulative_extrusion)
  80. current_layer = new_layer
  81. except ValueError:
  82. pass # Skip G-code lines with unparseable layer numbers
  83. # Filament change: M620 S<filament>
  84. # Bambu uses M620 for AMS filament switching
  85. # S255 means full unload (no active filament)
  86. elif cmd == "M620":
  87. for part in parts[1:]:
  88. part_upper = part.upper()
  89. if part_upper.startswith("S"):
  90. filament_str = part[1:]
  91. if filament_str == "255":
  92. # Full unload - no active filament
  93. active_filament = None
  94. else:
  95. try:
  96. # Extract digits (e.g., "0A" -> 0, "1" -> 1)
  97. match = re.match(r"(\d+)", filament_str)
  98. if match:
  99. active_filament = int(match.group(1))
  100. except (ValueError, AttributeError):
  101. pass # Skip unparseable filament switch commands
  102. # Extrusion moves: G0/G1/G2/G3 with E parameter
  103. # Only G1 typically has extrusion, but check all for safety
  104. elif cmd in ("G0", "G1", "G2", "G3"):
  105. if active_filament is None:
  106. continue
  107. for part in parts[1:]:
  108. part_upper = part.upper()
  109. if part_upper.startswith("E"):
  110. try:
  111. extrusion = float(part[1:])
  112. # Net sum, retractions included (#3317). Bambu G-code is
  113. # relative (M83): every retract is followed by an
  114. # un-retract of the same length, so counting only the
  115. # positive half added each pair to the total and put a
  116. # print 1.1-1.7x above the slicer's weight.
  117. current = cumulative_extrusion.get(active_filament, 0)
  118. cumulative_extrusion[active_filament] = current + extrusion
  119. except ValueError:
  120. pass # Skip G-code lines with unparseable extrusion values
  121. # Save final layer state
  122. if cumulative_extrusion:
  123. layer_filaments[current_layer] = _snapshot(cumulative_extrusion)
  124. return layer_filaments
  125. def _snapshot(cumulative_extrusion: dict[int, float]) -> dict[int, float]:
  126. """Copy the running totals for one layer, never below zero.
  127. A filament retracted before it has extruded anything would otherwise read
  128. as negative usage at that layer.
  129. """
  130. return {filament: max(mm, 0.0) for filament, mm in cumulative_extrusion.items()}
  131. def mm_to_grams(
  132. length_mm: float,
  133. diameter_mm: float = DEFAULT_FILAMENT_DIAMETER,
  134. density_g_cm3: float = DEFAULT_FILAMENT_DENSITY,
  135. ) -> float:
  136. """Convert filament length in mm to weight in grams.
  137. Uses the formula: mass = volume × density
  138. where volume = π × r² × length
  139. Args:
  140. length_mm: Length of filament in millimeters
  141. diameter_mm: Filament diameter in millimeters (default: 1.75)
  142. density_g_cm3: Material density in g/cm³ (default: 1.24 for PLA)
  143. Returns:
  144. Weight in grams
  145. """
  146. radius_cm = (diameter_mm / 2) / 10 # Convert mm to cm
  147. length_cm = length_mm / 10 # Convert mm to cm
  148. volume_cm3 = math.pi * radius_cm * radius_cm * length_cm
  149. return volume_cm3 * density_g_cm3
  150. def extract_layer_filament_usage_from_3mf(
  151. file_path: Path, plate_id: int | None = None
  152. ) -> dict[int, dict[int, float]] | None:
  153. """Extract per-layer filament usage from a 3MF file's embedded G-code.
  154. Args:
  155. file_path: Path to the 3MF file
  156. plate_id: Plate to read. Required for multi-plate files — zip member
  157. order is whatever the slicer wrote, and Bambu Studio stores
  158. ``plate_2.gcode`` ahead of ``plate_1.gcode``, so the old
  159. "first member" behaviour read a different plate's layers than
  160. the one that printed. Returns None rather than silently falling
  161. back to another plate when the requested plate isn't in the
  162. file; callers degrade to linear scaling, which is bounded.
  163. Returns:
  164. Dictionary mapping layers to filament usage, or None if parsing fails.
  165. Format: {layer: {filament_id: cumulative_mm}, ...}
  166. """
  167. try:
  168. with zipfile.ZipFile(file_path, "r") as zf:
  169. gcode_path = _printed_plate_gcode_name(zf.namelist(), plate_id)
  170. if gcode_path is None:
  171. return None
  172. gcode_content = zf.read(gcode_path).decode("utf-8", errors="ignore")
  173. return parse_gcode_layer_filament_usage(gcode_content)
  174. except Exception:
  175. return None
  176. def _printed_plate_gcode_name(names: list[str], plate_id: int | None) -> str | None:
  177. """The G-code member of the plate that printed, or None.
  178. Exactly ``plate_id`` when it is in the file. With no plate asked for, or a
  179. file whose single G-code member doesn't follow the plate_N naming
  180. convention (non-Bambu slicers), the lone or lowest member: unambiguous
  181. either way. Never another plate of a multi-plate file.
  182. """
  183. gcode_path = select_plate_gcode_name(names, plate_id)
  184. if gcode_path is None:
  185. gcode_files = [f for f in names if f.endswith(".gcode")]
  186. if plate_id is None or len(gcode_files) == 1:
  187. gcode_path = default_plate_gcode_name(names)
  188. return gcode_path
  189. @dataclass(frozen=True)
  190. class PausePoint:
  191. """A pause the slicer put into the print (#2984).
  192. ``layer`` is the layer the printer reports while it waits: Bambu Studio and
  193. OrcaSlicer write the pause after the layer-change G-code (``M73 L<n>``),
  194. at the start of that layer. ``remaining_minutes`` is the slicer's time left
  195. at that point, from the last ``M73 P<pct> R<min>`` before it, or None when
  196. the G-code carries no such line.
  197. """
  198. layer: int
  199. remaining_minutes: int | None
  200. def parse_gcode_pause_points(gcode_content: str) -> list[PausePoint]:
  201. """The pauses in a plate's G-code, in print order.
  202. A pause is ``M400 U1``: the pause G-code of every Bambu printer profile,
  203. written by both slicers after their ``; PAUSE_PRINTING`` tag, and by hand in
  204. a custom layer G-code. Bambu's start and end G-code never use it. One per
  205. layer: a second pause on a layer the printer already stopped at adds no
  206. new point to warn about.
  207. """
  208. return _pause_points(gcode_content.splitlines())
  209. def _pause_points(lines: Iterable[str]) -> list[PausePoint]:
  210. pauses: list[PausePoint] = []
  211. layer = 0
  212. remaining: int | None = None
  213. for raw in lines:
  214. line = raw.lstrip()
  215. if not line.startswith(("M73", "M400", "m73", "m400")):
  216. continue
  217. parts = line.split(";", 1)[0].split()
  218. cmd = parts[0].upper() if parts else ""
  219. params = [p.upper() for p in parts[1:]]
  220. if cmd == "M73":
  221. for param in params:
  222. try:
  223. if param.startswith("L"):
  224. layer = int(param[1:])
  225. elif param.startswith("R"):
  226. remaining = int(param[1:])
  227. except ValueError:
  228. pass # A malformed value leaves the last good one
  229. elif cmd == "M400" and "U1" in params:
  230. if not pauses or pauses[-1].layer != layer:
  231. pauses.append(PausePoint(layer=layer, remaining_minutes=remaining))
  232. return pauses
  233. def extract_pause_points_from_3mf(file_path: Path, plate_id: int | None = None) -> list[PausePoint]:
  234. """The pauses of the plate that is printing, or none when unreadable.
  235. Line by line: a plate's G-code can run to 100 MB, and only its M73 and
  236. M400 lines are wanted.
  237. """
  238. try:
  239. with zipfile.ZipFile(file_path, "r") as zf:
  240. gcode_path = _printed_plate_gcode_name(zf.namelist(), plate_id)
  241. if gcode_path is None:
  242. return []
  243. with zf.open(gcode_path) as member:
  244. return _pause_points(io.TextIOWrapper(member, encoding="utf-8", errors="ignore"))
  245. except Exception:
  246. logger.debug("Could not read pauses from %s", file_path, exc_info=True)
  247. return []
  248. def get_cumulative_usage_at_layer(
  249. layer_usage: dict[int, dict[int, float]],
  250. target_layer: int,
  251. ) -> dict[int, float]:
  252. """Get cumulative filament usage (in mm) up to and including target_layer.
  253. Args:
  254. layer_usage: The output from parse_gcode_layer_filament_usage()
  255. target_layer: The layer number to get usage for
  256. Returns:
  257. Dictionary of {filament_id: cumulative_mm} for each filament used
  258. up to target_layer. Returns empty dict if no data available.
  259. """
  260. if not layer_usage:
  261. return {}
  262. # Find the highest recorded layer <= target_layer
  263. # (we store snapshots at layer changes, so we need the closest one)
  264. relevant_layers = [layer for layer in layer_usage if layer <= target_layer]
  265. if not relevant_layers:
  266. return {}
  267. max_layer = max(relevant_layers)
  268. return layer_usage.get(max_layer, {})
  269. def slicer_grams_at_layer(
  270. layer_usage: dict[int, dict[int, float]],
  271. target_layer: int,
  272. filament_usage: list[dict],
  273. ) -> dict[int, float]:
  274. """Grams per 1-based slot for a print stopped at ``target_layer`` (#3317).
  275. The slicer's weight for each filament, times the share of that filament's
  276. whole-print feed the G-code has done by ``target_layer``. A finished print
  277. is charged the slicer's weight, and the G-code feeds more than the slicer
  278. counts (the start sequence, the purge at a filament change) and ahead of
  279. the layers it serves, so its grams on their own charged a print cancelled
  280. near the end more than the same print finished.
  281. Only slots the slicer weighed and the G-code fed by then; the caller
  282. converts the others from millimetres.
  283. """
  284. if not layer_usage:
  285. return {}
  286. final = layer_usage[max(layer_usage)]
  287. at_layer = get_cumulative_usage_at_layer(layer_usage, target_layer)
  288. slicer_grams: dict[int, float] = {}
  289. for usage in filament_usage:
  290. slot_id, used_g = usage.get("slot_id"), usage.get("used_g") or 0
  291. if isinstance(slot_id, int) and used_g > 0:
  292. slicer_grams.setdefault(slot_id, used_g)
  293. grams: dict[int, float] = {}
  294. for filament_id, mm in at_layer.items():
  295. slot_id = filament_id + 1
  296. total_mm = final.get(filament_id, 0)
  297. if slot_id in slicer_grams and total_mm > 0:
  298. # The end sequence can retract a little after the last snapshot.
  299. grams[slot_id] = slicer_grams[slot_id] * min(mm / total_mm, 1.0)
  300. return grams
  301. def extract_filament_properties_from_3mf(file_path: Path) -> dict[int, dict]:
  302. """Extract filament properties (density, diameter, type) from 3MF metadata.
  303. Args:
  304. file_path: Path to the 3MF file
  305. Returns:
  306. Dictionary mapping filament IDs to their properties:
  307. {filament_id: {"diameter": 1.75, "density": 1.24, "type": "PLA"}, ...}
  308. Note: filament_id is 1-based (matches slot_id in slice_info.config)
  309. """
  310. properties: dict[int, dict] = {}
  311. try:
  312. with zipfile.ZipFile(file_path, "r") as zf:
  313. # Try slice_info.config first for filament types
  314. if "Metadata/slice_info.config" in zf.namelist():
  315. content = zf.read("Metadata/slice_info.config").decode()
  316. root = ET.fromstring(content)
  317. for f in root.findall(".//filament"):
  318. try:
  319. # id is 1-based in slice_info.config
  320. fid = int(f.get("id", 0))
  321. properties[fid] = {
  322. "type": f.get("type", "PLA"),
  323. "diameter": DEFAULT_FILAMENT_DIAMETER,
  324. "density": DEFAULT_FILAMENT_DENSITY,
  325. }
  326. except ValueError:
  327. pass # Skip filament entries with unparseable IDs
  328. # Try project_settings.config for density values
  329. if "Metadata/project_settings.config" in zf.namelist():
  330. content = zf.read("Metadata/project_settings.config").decode()
  331. try:
  332. data = json.loads(content)
  333. densities = data.get("filament_density", [])
  334. for i, density in enumerate(densities):
  335. # project_settings uses 0-based indexing, convert to 1-based
  336. fid = i + 1
  337. if fid not in properties:
  338. properties[fid] = {
  339. "type": "",
  340. "diameter": DEFAULT_FILAMENT_DIAMETER,
  341. }
  342. try:
  343. properties[fid]["density"] = float(density)
  344. except (ValueError, TypeError):
  345. properties[fid]["density"] = DEFAULT_FILAMENT_DENSITY
  346. except json.JSONDecodeError:
  347. pass # Skip malformed project_settings.config JSON
  348. except Exception:
  349. pass # Return whatever properties were collected before the error
  350. return properties
  351. def _first_settings_id(value: object) -> str | None:
  352. """A ``*_settings_id`` value is usually a string, occasionally a list (one
  353. entry per extruder). Return the first non-empty string, else None."""
  354. if isinstance(value, str):
  355. return value.strip() or None
  356. if isinstance(value, list):
  357. for item in value:
  358. if isinstance(item, str) and item.strip():
  359. return item.strip()
  360. return None
  361. def extract_embedded_presets_from_3mf(zf: zipfile.ZipFile) -> dict[str, str | None]:
  362. """Read the printer / process preset names a 3MF project was prepared with.
  363. BambuStudio / OrcaSlicer write the chosen preset names into
  364. ``Metadata/project_settings.config`` (``printer_settings_id`` and
  365. ``print_settings_id``). The SliceModal uses them to default its printer
  366. and process dropdowns to what the file was sliced for (#1325) instead of
  367. blindly taking the first listed preset.
  368. Returns ``{"printer": <name|None>, "process": <name|None>}``. Every failure
  369. mode (missing config, malformed JSON, unexpected shape) yields ``None``
  370. values so the modal falls back to its own defaults.
  371. """
  372. result: dict[str, str | None] = {"printer": None, "process": None}
  373. try:
  374. if "Metadata/project_settings.config" not in zf.namelist():
  375. return result
  376. data = json.loads(zf.read("Metadata/project_settings.config").decode())
  377. except (KeyError, ValueError, OSError):
  378. return result
  379. if not isinstance(data, dict):
  380. return result
  381. result["printer"] = _first_settings_id(data.get("printer_settings_id"))
  382. result["process"] = _first_settings_id(data.get("print_settings_id"))
  383. return result
  384. # Ceiling on the dense per-slot form below. Deliberately larger than the 32
  385. # entries a print command carries, so a legitimate file is never silently
  386. # truncated at the limit -- it is either usable or rejected outright.
  387. _MAX_DENSE_FILAMENT_SLOTS = 64
  388. def extract_slot_extruders_from_3mf(file_path: Path, plate_id: int | None = None) -> list[int] | None:
  389. """Per-slot extruder assignment as a dense list, or None (#2800).
  390. Same data as :func:`extract_nozzle_mapping_from_3mf`, reshaped for the
  391. dispatcher: index 0 is filament slot 1, and a slot this file does not
  392. print is ``-1``. Nozzle-rack printers (H2C) need it to build the physical
  393. ``nozzle_mapping`` the firmware expects — without one they fall back to
  394. picking a nozzle themselves, which can level with one hotend and print
  395. with another, several millimetres off the bed.
  396. ``plate_id`` scopes the answer to the plate actually being dispatched. A
  397. multi-plate 3MF carries one filament list per plate and they need not
  398. agree, so without it a slot can take its extruder from a plate this print
  399. is not going to run.
  400. Takes a path rather than an open archive because the dispatcher is
  401. handling the file, not the zip, and a broken file there must not take the
  402. print down: an unreadable or non-3MF path returns None, and the caller
  403. dispatches exactly as it did before this existed.
  404. """
  405. try:
  406. with zipfile.ZipFile(file_path) as zf:
  407. by_slot = extract_nozzle_mapping_from_3mf(zf, plate_id=plate_id)
  408. except (zipfile.BadZipFile, OSError) as exc:
  409. logger.warning("Failed to read nozzle mapping from %s: %s", file_path, exc)
  410. return None
  411. if not by_slot:
  412. return None
  413. # The slot IDs are whatever the file says, so the dense form has to be
  414. # bounded before it is built: a corrupt or hostile 3MF declaring
  415. # `filament id="50000000"` would otherwise allocate a fifty-million-entry
  416. # list here, on the dispatch path. Nothing above 32 is usable anyway --
  417. # that is the length of the array the printer is sent.
  418. highest_slot = max(by_slot)
  419. if highest_slot < 1 or highest_slot > _MAX_DENSE_FILAMENT_SLOTS:
  420. logger.warning(
  421. "Ignoring nozzle mapping from %s: highest filament slot %s is out of range",
  422. file_path,
  423. highest_slot,
  424. )
  425. return None
  426. return [by_slot.get(slot, -1) for slot in range(1, highest_slot + 1)]
  427. @dataclass(frozen=True)
  428. class RackGroup:
  429. """One filament group on a nozzle-rack plate, and what hotend it needs.
  430. A group is the slicer's *logical* nozzle. On an H2C the rack carriage hosts
  431. six of them, so several groups share one extruder index -- which is exactly
  432. the case ``extract_nozzle_mapping_from_3mf`` refuses to answer, because the
  433. physical rack position per group is the operator's choice and is stated
  434. nowhere in the file.
  435. """
  436. group_id: int
  437. on_rack: bool
  438. nozzle_diameter: str
  439. volume_type: str
  440. # Only a hint, for preferring a rack position already loaded with this
  441. # colour. Excluded from equality on purpose: two filaments may share a
  442. # group and differ in colour without the group being contradictory, and
  443. # the agreement check below must not reject that file.
  444. filament_color: str = field(default="", compare=False)
  445. @dataclass(frozen=True)
  446. class RackPlan:
  447. """Everything a rack dispatch needs from the 3MF, short of the choice itself.
  448. ``slot_groups`` is dense: index 0 is filament slot 1, and a slot the plate
  449. does not print is ``-1``, matching :func:`extract_slot_extruders_from_3mf`.
  450. ``groups`` is keyed by group id.
  451. """
  452. slot_groups: list[int]
  453. groups: dict[int, RackGroup]
  454. @property
  455. def rack_group_ids(self) -> list[int]:
  456. """Groups needing a rack position, lowest first, for stable assignment."""
  457. return sorted(gid for gid, group in self.groups.items() if group.on_rack)
  458. def group_dicts(self) -> dict[int, dict]:
  459. """The groups as plain dicts, the form the resolver and the API take.
  460. Keeps one definition of the shape rather than two that can drift: the
  461. dispatcher resolves against it and the print dialog renders from it.
  462. """
  463. return {
  464. gid: {
  465. "on_rack": group.on_rack,
  466. "nozzle_diameter": group.nozzle_diameter,
  467. "volume_type": group.volume_type,
  468. "filament_color": group.filament_color,
  469. }
  470. for gid, group in self.groups.items()
  471. }
  472. def extract_rack_plan_from_3mf(file_path: Path, plate_id: int | None = None) -> RackPlan | None:
  473. """What a nozzle-rack plate needs per group, or None (#1784).
  474. :func:`extract_nozzle_mapping_from_3mf` answers "which carriage" and
  475. withholds the whole mapping when a plate needs several hotends off one
  476. rack. This answers the question underneath it -- which groups exist, which
  477. of them are rack-bound, and what nozzle each one wants -- so the caller can
  478. pair it with a chosen rack position and build a mapping the other function
  479. cannot.
  480. Measured basis (maintainer's H2C, 2026-08-14): the same plate was sent
  481. twice with different rack picks and every member of the two 3MFs was
  482. identical bar float noise -- ``group_id`` values, the toolchange stream and
  483. the ``NOZZLE_CHANGE`` markers included. The pick lives only in the
  484. dispatched ``nozzle_mapping``, so nothing here can or should derive it.
  485. Returns None whenever the plate cannot be described completely: a partial
  486. plan would place some slots and leave others at "not printed", which is the
  487. contradiction the firmware rejects as HMS 0500-4047.
  488. Takes a path rather than an open archive for the same reason
  489. :func:`extract_slot_extruders_from_3mf` does -- the dispatcher is holding
  490. the file, and a broken one must not take the print down.
  491. """
  492. try:
  493. with zipfile.ZipFile(file_path) as zf:
  494. return _rack_plan(zf, plate_id=plate_id)
  495. except (zipfile.BadZipFile, OSError) as exc:
  496. logger.warning("Failed to read rack plan from %s: %s", file_path, exc)
  497. return None
  498. except Exception:
  499. logger.exception("Unreadable rack plan in %s", file_path)
  500. return None
  501. def _rack_plan(zf: zipfile.ZipFile, plate_id: int | None) -> RackPlan | None:
  502. """Body of :func:`extract_rack_plan_from_3mf`, on an already-open archive."""
  503. names = zf.namelist()
  504. if "Metadata/project_settings.config" not in names:
  505. return None
  506. if "Metadata/slice_info.config" not in names:
  507. return None
  508. data = json.loads(zf.read("Metadata/project_settings.config").decode())
  509. physical_extruder_map = data.get("physical_extruder_map")
  510. if not physical_extruder_map or len(physical_extruder_map) <= 1:
  511. return None
  512. # Which extruder index is the rack, taken from the file rather than a
  513. # constant: the rack is the carriage that can address more than one nozzle.
  514. # `extruder_max_nozzle_count` is ['1', '6'] on an H2C, and reading it here
  515. # means a future rack of a different size needs no change.
  516. rack_indices: set[int] = set()
  517. for index, count in enumerate(data.get("extruder_max_nozzle_count") or []):
  518. try:
  519. if int(count) > 1:
  520. rack_indices.add(index)
  521. except (TypeError, ValueError):
  522. return None
  523. if not rack_indices:
  524. return None
  525. si_root = ET.fromstring(zf.read("Metadata/slice_info.config").decode())
  526. plates = _plates_in_scope(si_root, plate_id)
  527. group_extruders = _group_extruder_indices(plates)
  528. if not group_extruders:
  529. return None
  530. filament_elems = [elem for plate in plates for elem in plate.findall(".//filament")]
  531. if not filament_elems:
  532. return None
  533. slot_groups: dict[int, int] = {}
  534. groups: dict[int, RackGroup] = {}
  535. for elem in filament_elems:
  536. group_id_str = elem.get("group_id")
  537. slot_id_str = elem.get("id")
  538. if group_id_str is None or not slot_id_str:
  539. # One ungrouped filament makes the plan partial, and a partial plan
  540. # dispatches the ungrouped slot as unprinted.
  541. return None
  542. try:
  543. group_id = int(group_id_str)
  544. slot_id = int(slot_id_str)
  545. except (TypeError, ValueError):
  546. return None
  547. extruder_index = group_extruders.get(group_id)
  548. if extruder_index is None or not 0 <= extruder_index < len(physical_extruder_map):
  549. return None
  550. # Two plates in scope may name the same slot; they must agree, or the
  551. # dispatched plate is ambiguous.
  552. if slot_groups.setdefault(slot_id, group_id) != group_id:
  553. return None
  554. group = RackGroup(
  555. group_id=group_id,
  556. on_rack=extruder_index in rack_indices,
  557. nozzle_diameter=(elem.get("nozzle_diameter") or "").strip(),
  558. volume_type=(elem.get("volume_type") or "").strip(),
  559. filament_color=(elem.get("color") or "").strip(),
  560. )
  561. # Filaments sharing a group must want the same hotend, or "the group is
  562. # one nozzle" is not true and no single position can serve them.
  563. if groups.setdefault(group_id, group) != group:
  564. return None
  565. highest_slot = max(slot_groups)
  566. if highest_slot < 1 or highest_slot > _MAX_DENSE_FILAMENT_SLOTS:
  567. return None
  568. return RackPlan(
  569. slot_groups=[slot_groups.get(slot, -1) for slot in range(1, highest_slot + 1)],
  570. groups=groups,
  571. )
  572. def _plates_in_scope(si_root: XmlElement, plate_id: int | None) -> list[XmlElement]:
  573. """The ``<plate>`` elements a lookup should read, narrowed to one if asked.
  574. A 3MF holds every plate in the project, each with its own filament list, and
  575. two plates may assign the same slot to different extruders. Falls back to
  576. every plate when no id is given or none matches, which is what this module
  577. did before plates were distinguished at all.
  578. """
  579. plates = si_root.findall(".//plate")
  580. if not plates:
  581. return [si_root]
  582. if plate_id is None:
  583. return plates
  584. for plate in plates:
  585. for metadata in plate.findall("metadata"):
  586. if metadata.get("key") != "index":
  587. continue
  588. try:
  589. if int(metadata.get("value") or "") == plate_id:
  590. return [plate]
  591. except (TypeError, ValueError):
  592. pass
  593. return plates
  594. def _group_extruder_indices(plates: list[XmlElement]) -> dict[int, int] | None:
  595. """Map each filament group to the slicer extruder index it prints on.
  596. ``slice_info.config`` states this directly, as ``<nozzle id="<group>"
  597. extruder_id="<1-based extruder>"/>``. Reading it matters on nozzle-rack
  598. printers, where the group id is *not* an extruder index: the H2C's rack
  599. carriage can host six hotends (``extruder_max_nozzle_count`` is ``['1',
  600. '6']``), so the slicer emits more groups than the machine has extruders and
  601. several groups share one carriage. A plate of the reporter's carried groups
  602. 0, 1 and 2 against a two-entry ``physical_extruder_map``.
  603. Returns None when the file states no table, or when two plates in scope
  604. disagree about a group — in which case the caller keeps treating the group
  605. id as the extruder index, which is what every H2D file in practice wants
  606. and what this module has always done.
  607. """
  608. table: dict[int, int] = {}
  609. for plate in plates:
  610. for nozzle in plate.findall(".//nozzle"):
  611. try:
  612. group_id = int(nozzle.get("id") or "")
  613. extruder_index = int(nozzle.get("extruder_id") or "") - 1
  614. except (TypeError, ValueError):
  615. return None
  616. if extruder_index < 0:
  617. return None
  618. if table.setdefault(group_id, extruder_index) != extruder_index:
  619. return None
  620. return table or None
  621. def extract_nozzle_mapping_from_3mf(zf: zipfile.ZipFile, plate_id: int | None = None) -> dict[int, int] | None:
  622. """Extract per-slot nozzle/extruder mapping from a 3MF file.
  623. On dual-nozzle printers (H2D, H2D Pro), each filament slot is assigned to a
  624. specific nozzle. The slicer may override user preferences when using "Auto For
  625. Flush" mode, so the actual assignment comes from slice_info.config group_id
  626. attributes, not from the user's filament_nozzle_map preference.
  627. Priority:
  628. 1. group_id on <filament> elements in slice_info.config (actual assignment),
  629. resolved through the file's own group-to-extruder table
  630. 2. filament_nozzle_map in project_settings.config (user preference fallback)
  631. Both are mapped through physical_extruder_map to get MQTT extruder IDs (0=right, 1=left).
  632. Returns None rather than a partial answer whenever a filament the plate
  633. prints cannot be placed. The gap does not stay a gap downstream: the dense
  634. form fills it with -1, which already means "slot not printed", and
  635. dispatching that against an ams_mapping that *does* name a tray for the slot
  636. is a contradiction the firmware rejects outright with HMS 0500-4047, "the
  637. available hotend quantity or model does not match the sliced file". Giving
  638. no mapping at all costs only the firmware's own nozzle pick.
  639. Args:
  640. zf: An open ZipFile of the 3MF archive
  641. plate_id: 1-based plate to read, or None for every plate in the file
  642. Returns:
  643. Dictionary mapping {slot_id: extruder_id} for dual-nozzle files,
  644. or None if single-nozzle, missing data, unplaceable, or parse error.
  645. """
  646. try:
  647. if "Metadata/project_settings.config" not in zf.namelist():
  648. return None
  649. content = zf.read("Metadata/project_settings.config").decode()
  650. data = json.loads(content)
  651. physical_extruder_map = data.get("physical_extruder_map")
  652. if not physical_extruder_map or len(physical_extruder_map) <= 1:
  653. return None # Single-nozzle printer
  654. # Check if only one extruder is active.
  655. # If so, we can skip the mapping and just assign all slots to that extruder.
  656. # extruder_nozzle_stats format: ["Standard#0|High Flow#0", "Standard#1"]
  657. # Each entry = one extruder. Format: <NozzleVolumeType>#<count>[|...]
  658. # #N is the count of physical nozzles of that type (0 = none installed).
  659. # Types: Standard, High Flow, Hybrid, TPU High Flow
  660. active_extruders = []
  661. for stats_str in data.get("extruder_nozzle_stats") or []:
  662. nozzle_counts = [n.partition("#")[2] for n in stats_str.split("|")]
  663. active_extruders.append(1 if any(c not in ("0", "") for c in nozzle_counts) else 0)
  664. # Parse slice_info once: needed by both the single-active shortcut
  665. # (to verify the slice is actually single-group, #1825) and Priority 1.
  666. si_root: XmlElement | None = None
  667. filament_elems: list[XmlElement] = []
  668. group_extruders: dict[int, int] | None = None
  669. distinct_group_ids: set[int] = set()
  670. if "Metadata/slice_info.config" in zf.namelist():
  671. si_content = zf.read("Metadata/slice_info.config").decode()
  672. si_root = ET.fromstring(si_content)
  673. plates = _plates_in_scope(si_root, plate_id)
  674. group_extruders = _group_extruder_indices(plates)
  675. filament_elems = [elem for plate in plates for elem in plate.findall(".//filament")]
  676. for filament_elem in filament_elems:
  677. gid = filament_elem.get("group_id")
  678. if gid is not None:
  679. try:
  680. distinct_group_ids.add(int(gid))
  681. except (ValueError, TypeError):
  682. pass
  683. # Two groups on one extruder means that extruder is a nozzle rack,
  684. # and the plate wants a *different* hotend from it per group. Which
  685. # physical rack slot each group takes is the slicer's own choice
  686. # against the rack's live contents and is stated nowhere in the
  687. # file -- on the plate that prompted this, both rack groups carry
  688. # identical nozzle_diameter and volume_type, and BambuStudio still
  689. # dispatched them to positions 16 and 18 (captured 2026-08-13
  690. # 17:20; that print completed). Nothing here can reproduce that
  691. # choice, and answering anyway is what printed in mid-air, so the
  692. # whole mapping is withheld and the firmware picks for itself.
  693. if group_extruders and len(set(group_extruders.values())) < len(group_extruders):
  694. logger.warning(
  695. "Ignoring nozzle mapping: groups %s share extruders %s, so the plate "
  696. "needs more than one nozzle from a rack and the physical positions "
  697. "are not derivable from the file",
  698. sorted(group_extruders),
  699. sorted(set(group_extruders.values())),
  700. )
  701. return None
  702. # Single-active shortcut: only safe when the slice actually uses one
  703. # group. extruder_nozzle_stats can under-report a second installed
  704. # nozzle when its volume-type differs from the profile's enumerated
  705. # types (HT-AMS / High-Flow asymmetry on H2D, #1825); without this
  706. # guard the shortcut collapses a real multi-extruder slice onto one
  707. # nozzle and the group_id mapping below is skipped.
  708. if sum(active_extruders) == 1 and len(distinct_group_ids) <= 1:
  709. nozzle_mapping: dict[int, int] = {}
  710. active_idx = active_extruders.index(1)
  711. target_extruder = int(physical_extruder_map[active_idx])
  712. for filament_elem in filament_elems:
  713. try:
  714. nozzle_mapping[int(filament_elem.get("id"))] = target_extruder
  715. except (ValueError, TypeError):
  716. pass
  717. return nozzle_mapping or None
  718. # Priority 1: Use group_id from slice_info filament elements.
  719. # This reflects the actual slicer assignment (respects "Auto For Flush").
  720. nozzle_mapping: dict[int, int] = {}
  721. ungrouped = 0
  722. for filament_elem in filament_elems:
  723. group_id_str = filament_elem.get("group_id")
  724. filament_id_str = filament_elem.get("id")
  725. if not filament_id_str:
  726. continue
  727. if group_id_str is None:
  728. # Counted rather than returned on: a file where *no* filament
  729. # carries a group falls through to Priority 2 as it always has.
  730. # Only a file that groups some and not others is unplaceable.
  731. ungrouped += 1
  732. continue
  733. try:
  734. group_id = int(group_id_str)
  735. slot_id = int(filament_id_str)
  736. except (ValueError, TypeError):
  737. logger.warning(
  738. "Ignoring nozzle mapping: unreadable filament id=%r group_id=%r",
  739. filament_id_str,
  740. group_id_str,
  741. )
  742. return None
  743. # The group id is an extruder index only where the file states no
  744. # table of its own — true of every H2D slice, not of an H2C one.
  745. extruder_index = group_id if group_extruders is None else group_extruders.get(group_id)
  746. if extruder_index is None or not 0 <= extruder_index < len(physical_extruder_map):
  747. logger.warning(
  748. "Ignoring nozzle mapping: filament slot %s is in group %s, which "
  749. "resolves to extruder %r outside physical_extruder_map %r",
  750. slot_id,
  751. group_id,
  752. extruder_index,
  753. physical_extruder_map,
  754. )
  755. return None
  756. nozzle_mapping[slot_id] = int(physical_extruder_map[extruder_index])
  757. if nozzle_mapping and ungrouped:
  758. logger.warning(
  759. "Ignoring nozzle mapping: %d filament(s) carry no group_id while %d do, "
  760. "so the ungrouped slots would dispatch as unprinted",
  761. ungrouped,
  762. len(nozzle_mapping),
  763. )
  764. return None
  765. if nozzle_mapping:
  766. return nozzle_mapping
  767. # Priority 2: Fall back to filament_nozzle_map (user preference).
  768. # This is correct when the user manually assigned nozzles, but may be
  769. # wrong when the slicer overrides via "Auto For Flush".
  770. filament_nozzle_map = data.get("filament_nozzle_map")
  771. if not filament_nozzle_map:
  772. return None
  773. for i, slicer_ext_str in enumerate(filament_nozzle_map):
  774. slot_id = i + 1
  775. try:
  776. slicer_ext = int(slicer_ext_str)
  777. if slicer_ext < len(physical_extruder_map):
  778. nozzle_mapping[slot_id] = int(physical_extruder_map[slicer_ext])
  779. except (ValueError, TypeError, IndexError):
  780. pass
  781. return nozzle_mapping if nozzle_mapping else None
  782. except Exception:
  783. return None
  784. @dataclass(frozen=True)
  785. class PlateMetadata:
  786. """Combined per-plate slice_info.config values from a single 3MF parse.
  787. Bundles the three fields the queue listing needs so a queue poll opens and
  788. parses each 3MF once instead of three times (#2573). ``filament_usage`` is
  789. the full per-filament list (other callers — usage tracking, Spoolman — need
  790. it); ``filament_used_grams`` is its ``used_g`` sum, precomputed here so the
  791. queue path doesn't re-sum on every hit.
  792. """
  793. print_time_seconds: int | None = None
  794. filament_usage: list[dict] = field(default_factory=list)
  795. bed_type: str | None = None
  796. filament_used_grams: float = 0.0
  797. _EMPTY_PLATE_METADATA = PlateMetadata()
  798. # Revision-keyed cache for parsed per-plate metadata. Queue polling re-lists the
  799. # same unchanged 3MFs every few seconds per connected client (#2573); without a
  800. # cache each row costs a ZIP open + XML parse. The key includes the file's
  801. # mtime_ns and size so a replaced or edited file transparently gets a fresh
  802. # entry — no manual invalidation needed. Bounded LRU + lock so it stays small
  803. # and is safe to touch from worker threads.
  804. _PLATE_METADATA_CACHE: "OrderedDict[tuple, PlateMetadata]" = OrderedDict()
  805. _PLATE_METADATA_CACHE_LOCK = Lock()
  806. _PLATE_METADATA_CACHE_MAX = 512
  807. def clear_plate_metadata_cache() -> None:
  808. """Drop all cached per-plate metadata (used by tests)."""
  809. with _PLATE_METADATA_CACHE_LOCK:
  810. _PLATE_METADATA_CACHE.clear()
  811. def _parse_plate_metadata_uncached(file_path: Path, plate_id: int | None) -> PlateMetadata:
  812. """Open the 3MF once and pull print time, filament usage and bed type.
  813. Replicates the per-field ``plate_id=None`` behaviour of the three legacy
  814. helpers exactly: usage collects every ``<filament>`` in the file, while
  815. print time and bed type come from the first ``<plate>``.
  816. """
  817. try:
  818. with zipfile.ZipFile(file_path, "r") as zf:
  819. if "Metadata/slice_info.config" not in zf.namelist():
  820. return _EMPTY_PLATE_METADATA
  821. content = zf.read("Metadata/slice_info.config").decode()
  822. root = ET.fromstring(content)
  823. except Exception as e:
  824. logger.warning("Failed to read plate metadata from %s: %s", file_path, e)
  825. return _EMPTY_PLATE_METADATA
  826. def _plate_index(plate_elem) -> int | None:
  827. for meta in plate_elem.findall("metadata"):
  828. if meta.get("key") == "index":
  829. try:
  830. return int(meta.get("value", "0"))
  831. except ValueError:
  832. return None
  833. return None
  834. def _collect_filaments(plate_elem) -> list[dict]:
  835. out: list[dict] = []
  836. for f in plate_elem.findall("filament"):
  837. filament_id = f.get("id")
  838. # Both the used_g float() and the id int() must stay inside the guard:
  839. # a non-numeric id or used_g is silently skipped (matches the legacy
  840. # helpers, which tolerated garbage rows rather than raising — a raise
  841. # here would 500 the whole queue listing).
  842. try:
  843. used_amount = float(f.get("used_g", "0"))
  844. if filament_id:
  845. out.append(
  846. {
  847. "slot_id": int(filament_id),
  848. "used_g": used_amount,
  849. "type": f.get("type", ""),
  850. "color": f.get("color", ""),
  851. }
  852. )
  853. except (ValueError, TypeError):
  854. continue
  855. return out
  856. print_time: int | None = None
  857. bed_type: str | None = None
  858. filament_usage: list[dict] = []
  859. matched_plate = None
  860. if plate_id is not None:
  861. for plate_elem in root.findall(".//plate"):
  862. if _plate_index(plate_elem) == plate_id:
  863. matched_plate = plate_elem
  864. break
  865. else:
  866. matched_plate = root.find(".//plate")
  867. if matched_plate is not None:
  868. for meta in matched_plate.findall("metadata"):
  869. key = meta.get("key")
  870. if key == "prediction" and print_time is None:
  871. try:
  872. print_time = int(meta.get("value", "0"))
  873. except ValueError:
  874. print_time = None
  875. elif key == "curr_bed_type" and meta.get("value"):
  876. bed_type = (meta.get("value") or "").strip()
  877. if plate_id is not None:
  878. if matched_plate is not None:
  879. filament_usage = _collect_filaments(matched_plate)
  880. else:
  881. # Legacy plate_id=None usage: every filament in the file, not just plate 1.
  882. for f in root.findall(".//filament"):
  883. filament_id = f.get("id")
  884. # int()/float() both guarded — a garbage id/used_g row is skipped, not raised.
  885. try:
  886. used_amount = float(f.get("used_g", "0"))
  887. if filament_id:
  888. filament_usage.append(
  889. {
  890. "slot_id": int(filament_id),
  891. "used_g": used_amount,
  892. "type": f.get("type", ""),
  893. "color": f.get("color", ""),
  894. }
  895. )
  896. except (ValueError, TypeError):
  897. continue
  898. return PlateMetadata(
  899. print_time_seconds=print_time,
  900. filament_usage=filament_usage,
  901. bed_type=bed_type,
  902. filament_used_grams=sum(f["used_g"] for f in filament_usage),
  903. )
  904. def extract_plate_metadata_from_3mf(file_path: Path, plate_id: int | None = None) -> PlateMetadata:
  905. """Return combined per-plate metadata, cached by file revision (#2573).
  906. The result is keyed by ``(path, plate_id, mtime_ns, size)`` so an unchanged
  907. file is parsed at most once; a replaced/edited file re-parses automatically.
  908. The returned ``PlateMetadata`` is shared and MUST be treated as read-only —
  909. callers that need a mutable filament list get a copy from the wrappers below.
  910. """
  911. file_path = Path(file_path)
  912. try:
  913. stat = file_path.stat()
  914. except OSError:
  915. # File missing/unreadable: parse (which will return empty) but don't
  916. # cache — the file may appear later and we don't want a sticky miss.
  917. return _parse_plate_metadata_uncached(file_path, plate_id)
  918. key = (str(file_path), plate_id, stat.st_mtime_ns, stat.st_size)
  919. with _PLATE_METADATA_CACHE_LOCK:
  920. cached = _PLATE_METADATA_CACHE.get(key)
  921. if cached is not None:
  922. _PLATE_METADATA_CACHE.move_to_end(key)
  923. return cached
  924. metadata = _parse_plate_metadata_uncached(file_path, plate_id)
  925. with _PLATE_METADATA_CACHE_LOCK:
  926. _PLATE_METADATA_CACHE[key] = metadata
  927. _PLATE_METADATA_CACHE.move_to_end(key)
  928. while len(_PLATE_METADATA_CACHE) > _PLATE_METADATA_CACHE_MAX:
  929. _PLATE_METADATA_CACHE.popitem(last=False)
  930. return metadata
  931. def extract_filament_usage_from_3mf(file_path: Path, plate_id: int | None = None) -> list[dict]:
  932. """Extract per-filament total usage from 3MF slice_info.config.
  933. This extracts the slicer-estimated total usage per filament slot,
  934. not the per-layer breakdown.
  935. Args:
  936. file_path: Path to the 3MF file
  937. plate_id: Optional plate index to filter for (for multi-plate files)
  938. Returns:
  939. List of filament usage dictionaries:
  940. [{"slot_id": 1, "used_g": 50.5, "type": "PLA", "color": "#FF0000"}, ...]
  941. """
  942. # Delegate to the cached combined parse (#2573). Return fresh dicts so callers
  943. # that mutate the list don't corrupt the shared cached PlateMetadata.
  944. return [dict(f) for f in extract_plate_metadata_from_3mf(file_path, plate_id).filament_usage]
  945. def extract_print_time_from_3mf(file_path: Path, plate_id: int | None = None) -> int | None:
  946. """Extract the slicer's predicted print time from a 3MF's slice_info.config.
  947. Multi-plate 3MFs carry one ``<plate><metadata key="prediction" .../></plate>``
  948. per plate. The archive-level `print_time_seconds` is the sum across all plates
  949. (see services/archive.py:200-264, #1593). For per-plate UI / notifications,
  950. callers re-read the 3MF and request the specific plate's value via this helper.
  951. Args:
  952. file_path: Path to the 3MF file
  953. plate_id: Plate index to filter for; if None, returns the first plate's
  954. ``prediction`` (matches the legacy single-plate read).
  955. Returns:
  956. Predicted print time in seconds, or None if not found / unparseable.
  957. """
  958. return extract_plate_metadata_from_3mf(file_path, plate_id).print_time_seconds
  959. # Bed temperature is not one key in a BambuStudio project. Every plate type has
  960. # its own per-filament array, and the plate actually fitted is named separately
  961. # in ``curr_bed_type`` -- so reading a bed temperature means picking the array
  962. # the plate points at. Keys and mapping are BambuStudio's own
  963. # ``get_bed_temp_1st_layer_key`` / ``get_bed_temp_key`` (PrintConfig.hpp), and
  964. # the plate names are the ``curr_bed_type`` enum values (PrintConfig.cpp).
  965. # First-layer temperature first: that is what the printer heats to before the
  966. # print starts, which is what preheat is trying to reach.
  967. #
  968. # ``Default Plate`` is deliberately absent -- BambuStudio maps it to no key at
  969. # all, so there is nothing to read and guessing a plate would invent a bed
  970. # temperature the slice never specified.
  971. _BED_TEMP_KEYS: dict[str, tuple[str, str]] = {
  972. "Cool Plate": ("cool_plate_temp_initial_layer", "cool_plate_temp"),
  973. "Engineering Plate": ("eng_plate_temp_initial_layer", "eng_plate_temp"),
  974. "High Temp Plate": ("hot_plate_temp_initial_layer", "hot_plate_temp"),
  975. "Textured PEI Plate": ("textured_plate_temp_initial_layer", "textured_plate_temp"),
  976. "Supertack Plate": ("supertack_plate_temp_initial_layer", "supertack_plate_temp"),
  977. # OrcaSlicer only (PrintConfig.hpp get_bed_temp_key).
  978. "Textured Cool Plate": ("textured_cool_plate_temp_initial_layer", "textured_cool_plate_temp"),
  979. }
  980. # Fallback for a config that names no plate: the Orca/PrusaSlicer spelling,
  981. # which is a single value rather than a per-plate array.
  982. _GENERIC_BED_TEMP_KEYS = ("bed_temperature_initial_layer", "bed_temperature")
  983. def _plate_temperature(val, filament_indexes: list[int] | None = None) -> int | None:
  984. """Bed temperature from one plate-temperature entry, or None.
  985. The plate arrays carry one entry per filament in the project, and a 0 means
  986. that filament cannot print on this plate. The bed only has one temperature,
  987. so the print runs at the highest its filaments ask for -- taking entry 0 the
  988. way the neighbouring scalar settings do would store a 0 for any project
  989. whose first filament is not one this plate is heated for.
  990. ``filament_indexes`` (0-based) are the filaments the printed plate uses:
  991. only theirs count, so a PLA plate of a project that also holds PETG is not
  992. heated to the PETG temperature. Without them every filament counts.
  993. """
  994. values = val if isinstance(val, list) else [val]
  995. if filament_indexes and isinstance(val, list):
  996. picked = [values[i] for i in filament_indexes if 0 <= i < len(values)]
  997. values = picked or values
  998. temps = []
  999. for entry in values:
  1000. if isinstance(entry, bool) or not isinstance(entry, (int, float, str)):
  1001. continue
  1002. try:
  1003. temps.append(int(float(entry)))
  1004. except (TypeError, ValueError):
  1005. continue
  1006. return max(temps) if temps else None
  1007. def bed_temperature_from_config(data: dict, filament_indexes: list[int] | None = None) -> int | None:
  1008. """Bed temperature for the plate *data* is sliced for, or None (#2989).
  1009. *data* is a parsed ``Metadata/project_settings.config``. Lives here rather
  1010. than beside the archive parser so the ingest path and the one-shot backfill
  1011. that repairs archives written before the fix read it exactly the same way.
  1012. ``filament_indexes``: the printed plate's filaments, see _plate_temperature.
  1013. """
  1014. bed_type = str(data.get("curr_bed_type") or "").strip()
  1015. for key in (*_BED_TEMP_KEYS.get(bed_type, ()), *_GENERIC_BED_TEMP_KEYS):
  1016. if key not in data:
  1017. continue
  1018. temperature = _plate_temperature(data[key], filament_indexes)
  1019. # A plate array of all zeros means no filament in the project prints on
  1020. # this plate, which is not a bed temperature -- keep looking rather than
  1021. # recording a 0 that reads as "cold bed".
  1022. if temperature:
  1023. return temperature
  1024. return None
  1025. def extract_bed_temperature_from_3mf(file_path: Path) -> int | None:
  1026. """Read a 3MF's bed temperature straight off disk, or None.
  1027. For the backfill, which has a ``file_path`` and nothing else. Opens only
  1028. ``Metadata/project_settings.config`` -- the archive parser reads thumbnails,
  1029. the model and the slice info as well, and none of that is wanted here.
  1030. Every failure is None. Deliberately broader than the handful of exceptions a
  1031. malformed zip is expected to raise: the caller runs inside the startup
  1032. migration, which has no handler above it, so anything unlisted escaping here
  1033. does not skip one archive -- it stops Bambuddy from booting, and keeps
  1034. stopping it, because the one-shot flag is written in the same transaction
  1035. that just rolled back. A bed temperature is not worth that.
  1036. """
  1037. try:
  1038. with zipfile.ZipFile(file_path, "r") as zf:
  1039. if "Metadata/project_settings.config" not in zf.namelist():
  1040. return None
  1041. data = json.loads(zf.read("Metadata/project_settings.config").decode())
  1042. return bed_temperature_from_config(data) if isinstance(data, dict) else None
  1043. except Exception:
  1044. return None
  1045. # Header values exposed as `{placeholder}` substitutions inside snippets.
  1046. # Aliases let users write Prusa-style names (`{max_layer_z}`) that map onto
  1047. # Bambu/Orca header keys (`max_z_height`).
  1048. _HEADER_PLACEHOLDER_ALIASES = {
  1049. "max_layer_z": "max_z_height",
  1050. "max_print_height": "max_z_height",
  1051. "total_layers": "total_layer_number",
  1052. }
  1053. _HEADER_KEY_RE = re.compile(r"^;\s*([^:]+?)\s*:\s*(.+?)\s*$")
  1054. _PLACEHOLDER_RE = re.compile(r"\{([a-zA-Z_][a-zA-Z0-9_]*)\}")
  1055. _START_GCODE_END_MARKER = "; MACHINE_START_GCODE_END"
  1056. _EXECUTABLE_BLOCK_END_MARKER = "; EXECUTABLE_BLOCK_END"
  1057. def _parse_3mf_gcode_header(content: str) -> dict[str, str]:
  1058. """Parse the `; HEADER_BLOCK_START..END` block into a normalised dict.
  1059. Keys are lowercased, ` [units]` suffixes stripped, and spaces converted
  1060. to underscores so callers can look up `total_layer_number` regardless of
  1061. whether the source line is `; total layer number: 80` or
  1062. `; total filament length [mm] : 12155.34`.
  1063. """
  1064. header: dict[str, str] = {}
  1065. in_header = False
  1066. for raw_line in content.splitlines():
  1067. line = raw_line.strip()
  1068. if line == "; HEADER_BLOCK_START":
  1069. in_header = True
  1070. continue
  1071. if line == "; HEADER_BLOCK_END":
  1072. break
  1073. if not in_header:
  1074. continue
  1075. m = _HEADER_KEY_RE.match(line)
  1076. if not m:
  1077. continue
  1078. key, value = m.group(1), m.group(2)
  1079. key = re.sub(r"\s*\[[^\]]*\]\s*$", "", key)
  1080. key = key.strip().lower().replace(" ", "_")
  1081. header[key] = value
  1082. return header
  1083. def _plate_number_of(name: str) -> int | None:
  1084. """Plate index encoded in a ``…/plate_<n>.gcode`` member, or None.
  1085. Parsed as an int rather than string-matched so a zero-padded
  1086. ``plate_01.gcode`` resolves to the same 1 the plates endpoint reports.
  1087. """
  1088. marker = "plate_"
  1089. idx = name.rfind(marker)
  1090. if idx < 0 or not name.endswith(".gcode"):
  1091. return None
  1092. try:
  1093. return int(name[idx + len(marker) : -len(".gcode")])
  1094. except ValueError:
  1095. return None
  1096. def used_filament_indexes(plates: list, plate_number: int | None) -> list[int]:
  1097. """Positions, in the project's per-filament settings, of the filaments the
  1098. printed plate uses, sorted: empty when that can't be told.
  1099. *plates* are the ``<plate>`` elements of ``slice_info.config``, whose
  1100. filament ids are 1-based. ``project_settings`` lists one value per filament
  1101. of the whole project, so the printed plate's own filaments pick which apply.
  1102. Without a plate number, the only plate decides.
  1103. """
  1104. if plate_number:
  1105. printed = [
  1106. plate
  1107. for plate in plates
  1108. if any(
  1109. meta.get("key") == "index" and meta.get("value") == str(plate_number)
  1110. for meta in plate.findall("metadata")
  1111. )
  1112. ]
  1113. else:
  1114. printed = plates if len(plates) == 1 else []
  1115. used: list[int] = []
  1116. for plate in printed[:1]:
  1117. for filament in plate.findall("filament"):
  1118. try:
  1119. if float(filament.get("used_g", "0")) > 0:
  1120. used.append(int(filament.get("id", "")))
  1121. except (TypeError, ValueError):
  1122. continue
  1123. return sorted(fid - 1 for fid in set(used) if fid > 0)
  1124. def plate_bed_temperature(file_path: Path, plate_id: int | None) -> int | None:
  1125. """Bed temperature for the plate of *file_path* that prints, or None.
  1126. An archive stores one bed temperature, the highest any filament of the
  1127. project asks for, while a queued job may print any plate of it: a PLA plate
  1128. of a project that also holds PETG is heated for PLA. *plate_id* as a queue
  1129. job holds it; none (or 0) means the file's default plate, the one dispatched.
  1130. None when the file can't be read or names no bed temperature, so the caller
  1131. falls back to the stored value.
  1132. Every failure is None, as in extract_bed_temperature_from_3mf: preheat and
  1133. keep-warm run this for every job, and a damaged file raises well beyond the
  1134. expected few (zlib.error from corrupt deflate data, OverflowError from an
  1135. "inf" temperature). Escaping here would fail the dispatch every round, or
  1136. end keep-warm for every printer.
  1137. """
  1138. try:
  1139. with zipfile.ZipFile(file_path, "r") as zf:
  1140. names = zf.namelist()
  1141. if "Metadata/project_settings.config" not in names:
  1142. return None
  1143. data = json.loads(zf.read("Metadata/project_settings.config").decode())
  1144. slice_info = zf.read("Metadata/slice_info.config") if "Metadata/slice_info.config" in names else None
  1145. if not isinstance(data, dict):
  1146. return None
  1147. plates = ET.fromstring(slice_info).findall("plate") if slice_info else []
  1148. plate_number = plate_id if plate_id is not None and plate_id > 0 else default_plate_number(names)
  1149. return bed_temperature_from_config(data, used_filament_indexes(plates, plate_number))
  1150. except Exception:
  1151. return None
  1152. def select_plate_gcode_name(names: list[str], plate_id: int | None) -> str | None:
  1153. """The ``.gcode`` member for exactly ``plate_id``, or None if it isn't there.
  1154. Returns None for a ``plate_id`` the file doesn't hold — callers that want a
  1155. fallback compose this with ``default_plate_gcode_name``; callers serving a
  1156. user's explicit plate choice want the None so they can 404 instead of
  1157. quietly rendering a different plate.
  1158. """
  1159. if plate_id is None:
  1160. return None
  1161. for name in names:
  1162. if name.endswith(".gcode") and _plate_number_of(name) == plate_id:
  1163. return name
  1164. return None
  1165. def default_plate_gcode_name(names: list[str]) -> str | None:
  1166. """The ``.gcode`` member to show when no plate was asked for.
  1167. The lowest plate number, NOT the first member in the archive: zip order is
  1168. whatever the slicer happened to write, and Bambu Studio does not write
  1169. plates in order — a two-plate file measured here stores ``plate_2.gcode``
  1170. ahead of ``plate_1.gcode``, so taking the first member opened plate 2. Files
  1171. from slicers that don't use the plate naming convention keep the old
  1172. first-member behaviour, since there is no numbering to sort by.
  1173. """
  1174. gcodes = [n for n in names if n.endswith(".gcode")]
  1175. if not gcodes:
  1176. return None
  1177. numbered = [(num, n) for n in gcodes if (num := _plate_number_of(n)) is not None]
  1178. if numbered:
  1179. return min(numbered)[1]
  1180. return gcodes[0]
  1181. def names_carry_gcode(names: list[str]) -> bool:
  1182. """Is this 3MF a sliced file — does it carry printer-executable G-code?
  1183. One definition, because several of them is the bug (#2993). The archive
  1184. side judged a file by what it holds -- the card's GCODE badge reads the
  1185. layer count and print time parsed out of the plate G-code, and
  1186. ``/archives/{id}/capabilities`` scanned the zip -- while the library judged
  1187. it by its filename. So a sliced 3MF stored as ``Foo.3mf`` rather than
  1188. ``Foo.gcode.3mf`` carried the badge and still re-imported as a source-only
  1189. project. This is the answer for anything asking the zip directly.
  1190. Defers to ``default_plate_gcode_name`` rather than testing for
  1191. ``Metadata/plate_<n>.gcode``, so a slicer that lays its output out some
  1192. other way is judged by the same rule everywhere.
  1193. """
  1194. return default_plate_gcode_name(names) is not None
  1195. def carries_gcode(file_path: Path | str) -> bool:
  1196. """``names_carry_gcode`` for a file on disk. False for anything unreadable.
  1197. Only the zip's central directory is read — no member is decompressed — so
  1198. this is cheap enough to run on every ingested file.
  1199. """
  1200. try:
  1201. with zipfile.ZipFile(file_path, "r") as zf:
  1202. return names_carry_gcode(zf.namelist())
  1203. except (OSError, zipfile.BadZipFile):
  1204. # Not a zip, gone, or unreadable. Callers treat that as "no G-code
  1205. # visible", which is what they did before this check existed.
  1206. return False
  1207. def default_plate_number(names: list[str]) -> int | None:
  1208. """The plate index to dispatch when the caller didn't ask for one.
  1209. Composes :func:`default_plate_gcode_name` with the numeric index its
  1210. chosen member encodes, so a caller that needs the integer ``plate_id`` a
  1211. print command takes gets the same "lowest plate, not first zip member"
  1212. answer the plate viewer already relies on.
  1213. None when the archive holds no G-code at all, or its default member
  1214. doesn't follow the ``plate_N`` naming convention ``default_plate_gcode_name``
  1215. falls back to a bare first member for — callers dispatching a real print
  1216. command should fall back to plate 1 themselves in that case rather than
  1217. treat None as a plate number.
  1218. A single-plate export cut out of a larger project keeps its ORIGINAL
  1219. plate number, so a caller that instead hardcodes a plate-1 fallback sends
  1220. a print command for a plate the archive may not contain. A real printer
  1221. accepts that command, can't find the G-code, and wedges (#2947).
  1222. """
  1223. selected = default_plate_gcode_name(names)
  1224. return _plate_number_of(selected) if selected is not None else None
  1225. # The header block sits at the very top of the plate G-code. Read only that
  1226. # much: a sliced plate is routinely tens of megabytes and `ZipFile.read()`
  1227. # would inflate all of it to reach ~40 lines.
  1228. _HEADER_READ_LIMIT_BYTES = 64 * 1024
  1229. def extract_max_z_height_from_3mf(file_path: Path, plate_id: int | None = None) -> float | None:
  1230. """Return the plate's ``max_z_height`` in mm, or None if not knowable.
  1231. This is the Z the toolhead sat at for the final layer — the same value
  1232. Bambu's own end G-code adds its bed-drop offset to (``G1 Z{max_layer_z +
  1233. 100}``). #2547 uses it to put the plate back into camera framing before the
  1234. finish photo, which is only safe because it is a height the printer was
  1235. physically at seconds earlier.
  1236. None means "don't know" and callers must treat it as such rather than
  1237. substituting a default: the file may be unreadable, carry no plate G-code,
  1238. or come from a slicer that writes no ``max_z_height`` header. Guessing a
  1239. height here would command a Z move to somewhere the nozzle has never been.
  1240. """
  1241. try:
  1242. with zipfile.ZipFile(file_path, "r") as zf:
  1243. names = zf.namelist()
  1244. target = select_plate_gcode_name(names, plate_id) or default_plate_gcode_name(names)
  1245. if target is None:
  1246. return None
  1247. with zf.open(target, "r") as fh:
  1248. head = fh.read(_HEADER_READ_LIMIT_BYTES)
  1249. except (OSError, zipfile.BadZipFile, KeyError) as e:
  1250. logger.debug("max_z_height: cannot read %s: %s", file_path, e)
  1251. return None
  1252. raw = _parse_3mf_gcode_header(head.decode("utf-8", errors="ignore")).get("max_z_height")
  1253. if raw is None:
  1254. return None
  1255. try:
  1256. value = float(raw)
  1257. except ValueError:
  1258. logger.debug("max_z_height: unusable value %r in %s", raw, file_path)
  1259. return None
  1260. # Zero or negative means the header key is present but meaningless. Passed
  1261. # on as a height it would become a move *toward* the bed, so drop it.
  1262. return value if value > 0 else None
  1263. def _substitute_placeholders(snippet: str, header: dict[str, str]) -> str:
  1264. """Replace `{var}` placeholders with header values, leaving unknowns intact."""
  1265. def repl(m: re.Match) -> str:
  1266. name = m.group(1)
  1267. value = header.get(name)
  1268. if value is None:
  1269. alias = _HEADER_PLACEHOLDER_ALIASES.get(name)
  1270. if alias is not None:
  1271. value = header.get(alias)
  1272. if value is None:
  1273. logger.warning(
  1274. "G-code injection: placeholder {%s} not found in 3MF header; leaving as-is",
  1275. name,
  1276. )
  1277. return m.group(0)
  1278. return value
  1279. return _PLACEHOLDER_RE.sub(repl, snippet)
  1280. def _inject_start_at_marker(content: str, snippet: str) -> str:
  1281. """Insert snippet immediately before `; MACHINE_START_GCODE_END`.
  1282. The marker sits at the bottom of the printer's startup block — bed heat,
  1283. homing, and nozzle prime are already done, so injected snippets land in
  1284. the same place a slicer-side custom-start-gcode would. Falls back to
  1285. prepending if the marker isn't present (older files / non-Bambu slicers).
  1286. """
  1287. marker_idx = content.find(_START_GCODE_END_MARKER)
  1288. if marker_idx == -1:
  1289. logger.warning(
  1290. "G-code injection: '%s' not found, prepending start snippet to whole file",
  1291. _START_GCODE_END_MARKER,
  1292. )
  1293. return snippet.rstrip("\n") + "\n" + content
  1294. line_start = content.rfind("\n", 0, marker_idx)
  1295. line_start = 0 if line_start == -1 else line_start + 1
  1296. return content[:line_start] + snippet.rstrip("\n") + "\n" + content[line_start:]
  1297. def _inject_end_before_marker(content: str, snippet: str) -> str:
  1298. """Insert snippet immediately before `; EXECUTABLE_BLOCK_END`.
  1299. The end snippet must run *inside* the executable block. Bambu firmware
  1300. (verified on a P1S) does not execute G-code that sits after
  1301. `; EXECUTABLE_BLOCK_END`, so appending to the file end silently drops the
  1302. snippet — auto-eject / plate-clear moves never fire. Inserting before the
  1303. marker places the snippet after the printer's own machine-end sequence but
  1304. still within the executed block. Falls back to appending at the file end if
  1305. the marker isn't present.
  1306. """
  1307. marker_idx = content.find(_EXECUTABLE_BLOCK_END_MARKER)
  1308. if marker_idx == -1:
  1309. logger.warning(
  1310. "G-code injection: '%s' not found, appending end snippet to file end",
  1311. _EXECUTABLE_BLOCK_END_MARKER,
  1312. )
  1313. return content.rstrip("\n") + "\n" + snippet.rstrip("\n") + "\n"
  1314. line_start = content.rfind("\n", 0, marker_idx)
  1315. line_start = 0 if line_start == -1 else line_start + 1
  1316. return content[:line_start] + snippet.rstrip("\n") + "\n" + content[line_start:]
  1317. def inject_gcode_into_3mf(
  1318. source_path: Path,
  1319. plate_id: int,
  1320. start_gcode: str | None,
  1321. end_gcode: str | None,
  1322. ):
  1323. """Create a temp copy of a 3MF with G-code injected at start/end.
  1324. Snippets support `{placeholder}` substitution against values parsed from
  1325. the 3MF G-code header block (e.g. `{max_layer_z}` → `16.00`). Start
  1326. snippets are anchored to the `; MACHINE_START_GCODE_END` marker so they
  1327. run after the printer's own startup (#422). End snippets are inserted just
  1328. before `; EXECUTABLE_BLOCK_END` so they run inside the executable block —
  1329. Bambu firmware (P1S) ignores g-code placed after that marker.
  1330. The plate's `.gcode.md5` sidecar is recomputed so firmware that validates
  1331. it against the gcode (e.g. P1S) still accepts the modified file.
  1332. Args:
  1333. source_path: Path to the original 3MF file.
  1334. plate_id: Plate number (1-indexed) to inject into.
  1335. start_gcode: G-code to insert after printer startup, or None.
  1336. end_gcode: G-code to append, or None.
  1337. Returns:
  1338. Path to temp file with injected G-code, or None if injection failed.
  1339. Caller is responsible for cleaning up the temp file.
  1340. """
  1341. import tempfile
  1342. if not start_gcode and not end_gcode:
  1343. return None
  1344. try:
  1345. # Find the target gcode file inside the 3MF
  1346. with zipfile.ZipFile(source_path, "r") as zf:
  1347. # Plate-specific gcode first, else the lowest-numbered plate.
  1348. names = zf.namelist()
  1349. target_gcode = select_plate_gcode_name(names, plate_id) or default_plate_gcode_name(names)
  1350. if target_gcode is None:
  1351. return None
  1352. # Read and modify gcode content
  1353. gcode_content = zf.read(target_gcode).decode("utf-8", errors="ignore")
  1354. header = _parse_3mf_gcode_header(gcode_content)
  1355. if start_gcode:
  1356. resolved = _substitute_placeholders(start_gcode, header)
  1357. # Log the post-substitution snippet so the actually-injected G-code
  1358. # (placeholders like {max_layer_z} already resolved) is visible at DEBUG.
  1359. logger.debug("G-code injection [%s]: resolved START snippet:\n%s", target_gcode, resolved)
  1360. gcode_content = _inject_start_at_marker(gcode_content, resolved)
  1361. if end_gcode:
  1362. resolved = _substitute_placeholders(end_gcode, header)
  1363. logger.debug("G-code injection [%s]: resolved END snippet:\n%s", target_gcode, resolved)
  1364. gcode_content = _inject_end_before_marker(gcode_content, resolved)
  1365. # The printer validates the plate gcode against an embedded
  1366. # `<plate>.gcode.md5` sidecar (uppercase hex, no trailing newline).
  1367. # Rewriting the gcode without refreshing this hash makes firmware
  1368. # reject the file at load (P1S: HMS 0500-4003 "unable to parse"),
  1369. # so recompute it from the exact bytes we're about to write.
  1370. gcode_bytes = gcode_content.encode("utf-8")
  1371. md5_name = target_gcode + ".md5"
  1372. # Not a security hash — this reproduces Bambu's `.gcode.md5` sidecar
  1373. # format, so flag it as non-security for the linters (ruff S324 / bandit B324).
  1374. md5_value = hashlib.md5(gcode_bytes, usedforsecurity=False).hexdigest().upper().encode("ascii")
  1375. # Write modified 3MF to temp file
  1376. with tempfile.NamedTemporaryFile(delete=False, suffix=".3mf") as tmp:
  1377. tmp_path = Path(tmp.name)
  1378. with zipfile.ZipFile(tmp_path, "w", zipfile.ZIP_DEFLATED) as zf_write:
  1379. for item in zf.namelist():
  1380. info = zf.getinfo(item)
  1381. if item == target_gcode:
  1382. zf_write.writestr(info, gcode_bytes)
  1383. elif item == md5_name:
  1384. zf_write.writestr(info, md5_value)
  1385. else:
  1386. zf_write.writestr(info, zf.read(item))
  1387. return tmp_path
  1388. except Exception:
  1389. # Clean up temp file on error
  1390. if "tmp_path" in locals() and tmp_path.exists():
  1391. tmp_path.unlink(missing_ok=True)
  1392. return None
  1393. def extract_project_filaments_from_3mf(zf: zipfile.ZipFile) -> list[dict]:
  1394. """Project-wide AMS slot config from ``Metadata/project_settings.config``.
  1395. Returns one dict per configured AMS slot in slot order (1-indexed), with
  1396. ``type`` and ``color`` populated from the project's ``filament_type`` and
  1397. ``filament_colour`` arrays. ``used_grams`` / ``used_meters`` are 0 because
  1398. project_settings carries the configuration, not per-print usage — the
  1399. fields exist for shape compatibility with the slice_info-derived list.
  1400. The SliceModal needs this on **unsliced** project files: slice_info.config
  1401. is empty until Bambu Studio has actually sliced the project, but the user
  1402. can still pick filament profiles for a slice we're about to perform.
  1403. """
  1404. if "Metadata/project_settings.config" not in zf.namelist():
  1405. return []
  1406. try:
  1407. proj = json.loads(zf.read("Metadata/project_settings.config").decode())
  1408. except (ValueError, OSError):
  1409. return []
  1410. if not isinstance(proj, dict):
  1411. return []
  1412. types_arr = proj.get("filament_type") or []
  1413. colors_arr = proj.get("filament_colour") or []
  1414. slot_count = max(
  1415. len(types_arr) if isinstance(types_arr, list) else 0, len(colors_arr) if isinstance(colors_arr, list) else 0
  1416. )
  1417. out: list[dict] = []
  1418. for i in range(slot_count):
  1419. out.append(
  1420. {
  1421. "slot_id": i + 1,
  1422. "type": types_arr[i] if i < len(types_arr) and isinstance(types_arr[i], str) else "",
  1423. "color": colors_arr[i] if i < len(colors_arr) and isinstance(colors_arr[i], str) else "",
  1424. "used_grams": 0,
  1425. "used_meters": 0,
  1426. }
  1427. )
  1428. return out
  1429. def expand_to_project_slots(zf: zipfile.ZipFile, used: list[dict]) -> list[dict]:
  1430. """Widen a used-only filament list to one entry per project slot.
  1431. ``used`` is the slice_info-derived list: only the slots whose G-code
  1432. actually consumed filament, each carrying real usage figures. That is the
  1433. right answer for print-time AMS matching, and the wrong one for the slice
  1434. modal, because the list the modal builds is **positional** — index 0 is
  1435. slot 1 all the way down to the ``filament_N.json`` parts handed to the CLI.
  1436. A source whose only used slot is 4 therefore produced a single dropdown
  1437. whose pick the CLI bound to slot 1, leaving slot 4 — the one the model
  1438. prints with — on whatever the source had baked in (#2712).
  1439. Returns the project's slots in slot order, each flagged ``used_in_plate``.
  1440. Rows present in ``used`` are kept whole, so their usage figures, resolved
  1441. type/colour and ``tray_info_idx`` survive; the rest come from the project
  1442. configuration with zero usage. A used slot beyond the project's slot count
  1443. is appended rather than dropped — the caller asked for a superset, and
  1444. silently losing the one slot that prints would be the original bug again.
  1445. ``used`` is returned unchanged when the file carries no project settings
  1446. to widen against: a narrower-than-ideal list still prints correctly, an
  1447. invented one might not.
  1448. """
  1449. project = extract_project_filaments_from_3mf(zf)
  1450. if not project:
  1451. return used
  1452. by_slot = {f["slot_id"]: f for f in used}
  1453. out: list[dict] = []
  1454. for slot in project:
  1455. known = by_slot.pop(slot["slot_id"], None)
  1456. if known is not None:
  1457. known["used_in_plate"] = True
  1458. out.append(known)
  1459. else:
  1460. slot["used_in_plate"] = False
  1461. out.append(slot)
  1462. # Anything slice_info reported that the project doesn't declare.
  1463. for leftover in by_slot.values():
  1464. leftover["used_in_plate"] = True
  1465. out.append(leftover)
  1466. out.sort(key=lambda f: f["slot_id"])
  1467. return out
  1468. # BambuStudio serialises bool config options as string "1"/"0" in
  1469. # project_settings.config, but forks / older versions occasionally write real
  1470. # booleans or ints — accept anything that isn't unambiguously falsy. A missing
  1471. # key counts as off: a 3MF that never declares `enable_support` gives us no
  1472. # support intent to act on.
  1473. _SUPPORTS_DISABLED_VALUES = (False, 0, "0", "false", "False", "", None)
  1474. def supports_enabled_in_config(cfg: dict[str, object]) -> bool:
  1475. """Whether a 3MF's ``project_settings.config`` has supports switched on.
  1476. Shared by the callers that read support intent out of a source file so
  1477. they agree on what "on" means: the slot extractor below and the slice
  1478. route's process-preset support carry-over (#1881 / #2820).
  1479. """
  1480. return cfg.get("enable_support") not in _SUPPORTS_DISABLED_VALUES
  1481. def extract_support_filament_slots_from_3mf(zf: zipfile.ZipFile) -> set[int]:
  1482. """Slots referenced by the process settings for support material.
  1483. Supports aren't attached to object geometry — they're generated by
  1484. the slicer's process pass — so :func:`extract_plate_extruder_set_from_3mf`,
  1485. which walks per-object extruder metadata + paint_color triangles,
  1486. doesn't see them. Callers that need the complete set of slots a
  1487. plate print will exercise (e.g. the SliceModal's filament-
  1488. substitution logic) must union this in — otherwise a support-only
  1489. slot (typical PLA-model + PVA-support setup) looks "unused" and its
  1490. user-picked profile gets silently overwritten with slot 1's,
  1491. producing a single-material print (#1881).
  1492. Returns the empty set when supports are disabled, ``support_filament``
  1493. / ``support_interface_filament`` are 0 (== "same as model"), the
  1494. project has no embedded settings, or the file isn't a valid 3MF.
  1495. """
  1496. if "Metadata/project_settings.config" not in zf.namelist():
  1497. return set()
  1498. try:
  1499. cfg = json.loads(zf.read("Metadata/project_settings.config").decode("utf-8"))
  1500. except (json.JSONDecodeError, UnicodeDecodeError, OSError):
  1501. return set()
  1502. if not isinstance(cfg, dict):
  1503. return set()
  1504. if not supports_enabled_in_config(cfg):
  1505. return set()
  1506. out: set[int] = set()
  1507. for key in ("support_filament", "support_interface_filament"):
  1508. raw = cfg.get(key)
  1509. if raw is None:
  1510. continue
  1511. try:
  1512. slot = int(raw)
  1513. except (ValueError, TypeError):
  1514. continue
  1515. # Slot 0 means "same as model" — no dedicated slot to preserve.
  1516. if slot > 0:
  1517. out.add(slot)
  1518. return out
  1519. _PAINT_COLOR_ATTR_RE = re.compile(rb'paint_color="([0-9A-Fa-f]+)"')
  1520. def _painted_states(paint_color: bytes) -> set[int]:
  1521. """Filament numbers a triangle's ``paint_color`` paints with (#3355).
  1522. The string is the slicer's TriangleSelector tree, one hex digit per
  1523. 4-bit node, written last node first. Each node: bits 0-1 are the
  1524. number of split sides; a split node (1-3) keeps the special side in bits
  1525. 2-3 and is followed by split + 1 children. A leaf keeps its state in bits
  1526. 2-3 (0 = unpainted, 1 or 2 = that filament); ``0b11`` there means the next
  1527. node holds ``state - 3``, and a ``0xF`` in that one means one more holds
  1528. ``state - 18``. Unpainted leaves print with the object's or part's own
  1529. filament, which the caller already counts. Reading each digit as a
  1530. filament number instead (extruder 1 is written ``4``, 2 ``8``, 3 ``0C``)
  1531. left real colours out, and the slice then overwrote them.
  1532. A string that ends early is malformed: what was read is kept.
  1533. """
  1534. nibbles = [int(chr(c), 16) for c in reversed(paint_color)]
  1535. states: set[int] = set()
  1536. pos = 0
  1537. pending = 1 # nodes still to read
  1538. try:
  1539. while pending:
  1540. pending -= 1
  1541. code = nibbles[pos]
  1542. pos += 1
  1543. split_sides = code & 0b11
  1544. if split_sides:
  1545. pending += split_sides + 1
  1546. continue
  1547. if code & 0b1100 == 0b1100:
  1548. low = nibbles[pos]
  1549. pos += 1
  1550. if low == 0b1111:
  1551. state = nibbles[pos] + 18
  1552. pos += 1
  1553. else:
  1554. state = low + 3
  1555. else:
  1556. state = code >> 2
  1557. if state:
  1558. states.add(state)
  1559. except IndexError:
  1560. pass
  1561. return states
  1562. def extract_plate_extruder_set_from_3mf(zf: zipfile.ZipFile, plate_id: int) -> set[int]:
  1563. """Extruder/AMS slot indices (1-indexed) used by objects on ``plate_id``.
  1564. Three sources are unioned because Bambu Studio splits per-object extruder
  1565. info across THREE places depending on how the user assigned colours:
  1566. 1. ``model_settings.config`` — top-level ``<metadata key="extruder">``
  1567. on each ``<object>`` (the "default extruder" for the whole object).
  1568. 2. ``model_settings.config`` — per-``<part>`` ``<metadata key="extruder">``
  1569. overrides (used when the user split an object into multiple parts
  1570. with distinct filaments).
  1571. 3. ``3D/Objects/object_*.model`` — ``paint_color`` attributes on
  1572. individual ``<triangle>`` elements (used when the user "painted" a
  1573. face with a different filament), decoded by :func:`_painted_states`.
  1574. Every painted leaf counts, however small: the slicer prints it with
  1575. that filament, so the slot is used.
  1576. Without (3) the painted-face data is invisible: model_settings says
  1577. every object on a multi-color plate uses extruder 1 by default but the
  1578. actual print uses 3, 4, 12 etc. via face paint, so the SliceModal would
  1579. render only one filament dropdown for what's clearly a multi-colour
  1580. print (#1150 follow-up).
  1581. """
  1582. if "Metadata/model_settings.config" not in zf.namelist():
  1583. return set()
  1584. try:
  1585. root = ET.fromstring(zf.read("Metadata/model_settings.config").decode())
  1586. except (ET.ParseError, OSError):
  1587. return set()
  1588. # Pass 1: object → set of extruders from XML metadata (sources 1 + 2)
  1589. # plus the per-object .model file path so we can later scan source 3.
  1590. object_extruders: dict[str, set[int]] = {}
  1591. object_model_paths: dict[str, list[str]] = {}
  1592. for obj_elem in root.findall(".//object"):
  1593. obj_id = obj_elem.get("id")
  1594. if not obj_id:
  1595. continue
  1596. extruders: set[int] = set()
  1597. top = obj_elem.find("metadata[@key='extruder']")
  1598. if top is not None:
  1599. try:
  1600. v = int(top.get("value", "0"))
  1601. if v > 0:
  1602. extruders.add(v)
  1603. except (ValueError, TypeError):
  1604. pass
  1605. for part_elem in obj_elem.findall(".//part"):
  1606. part_ext = part_elem.find("metadata[@key='extruder']")
  1607. if part_ext is None:
  1608. continue
  1609. try:
  1610. v = int(part_ext.get("value", "0"))
  1611. if v > 0:
  1612. extruders.add(v)
  1613. except (ValueError, TypeError):
  1614. pass
  1615. object_extruders[obj_id] = extruders
  1616. # Pass 2: 3dmodel.model maps each <object id="N"> to its component
  1617. # .model file path(s). Bambu wraps object IDs that match
  1618. # model_settings.config IDs around <components><component
  1619. # path="/3D/Objects/object_K.model" objectid="..." /></components>.
  1620. # Strip xmlns prefixes on attributes so ElementTree can find them
  1621. # without namespace gymnastics — `p:path` becomes `path` etc.
  1622. if "3D/3dmodel.model" in zf.namelist():
  1623. try:
  1624. raw = zf.read("3D/3dmodel.model").decode()
  1625. stripped = re.sub(r'xmlns:?\w*="[^"]*"', "", raw)
  1626. stripped = re.sub(r"<(/?)\w+:", r"<\1", stripped)
  1627. stripped = re.sub(r" \w+:(\w+=)", r" \1", stripped)
  1628. model_root = ET.fromstring(stripped)
  1629. for obj_elem in model_root.findall(".//object"):
  1630. oid = obj_elem.get("id")
  1631. if not oid:
  1632. continue
  1633. comps = obj_elem.find("components")
  1634. if comps is None:
  1635. continue
  1636. paths = []
  1637. for c in comps.findall("component"):
  1638. p = c.get("path")
  1639. if p:
  1640. paths.append(p.lstrip("/"))
  1641. if paths:
  1642. object_model_paths[oid] = paths
  1643. except (ET.ParseError, OSError):
  1644. pass # No 3dmodel — paint scan just won't apply
  1645. # Pass 3: scan paint_color attrs in each per-object .model file. Cache
  1646. # by file path because two objects often share the same component tree.
  1647. paint_cache: dict[str, set[int]] = {}
  1648. def _scan_paint(path: str) -> set[int]:
  1649. if path in paint_cache:
  1650. return paint_cache[path]
  1651. out: set[int] = set()
  1652. if path not in zf.namelist():
  1653. paint_cache[path] = out
  1654. return out
  1655. try:
  1656. data = zf.read(path)
  1657. except OSError:
  1658. paint_cache[path] = out
  1659. return out
  1660. # Whole-triangle paints repeat ("4", "8", ...): decode each string once.
  1661. for paint in {match.group(1) for match in _PAINT_COLOR_ATTR_RE.finditer(data)}:
  1662. out |= _painted_states(paint)
  1663. paint_cache[path] = out
  1664. return out
  1665. # Walk plates — collect extruders for objects on the requested plate.
  1666. used: set[int] = set()
  1667. for plate_elem in root.findall(".//plate"):
  1668. plater_id = None
  1669. for meta in plate_elem.findall("metadata"):
  1670. if meta.get("key") == "plater_id":
  1671. try:
  1672. plater_id = int(meta.get("value", ""))
  1673. except (ValueError, TypeError):
  1674. pass
  1675. break
  1676. if plater_id != plate_id:
  1677. continue
  1678. for inst in plate_elem.findall("model_instance"):
  1679. for inst_meta in inst.findall("metadata"):
  1680. if inst_meta.get("key") != "object_id":
  1681. continue
  1682. obj_id = inst_meta.get("value")
  1683. if not obj_id:
  1684. continue
  1685. used.update(object_extruders.get(obj_id, set()))
  1686. for path in object_model_paths.get(obj_id, []):
  1687. used.update(_scan_paint(path))
  1688. break
  1689. return used
  1690. # Keys in ``Metadata/project_settings.config`` that Bambu Studio writes an
  1691. # "inherit / unset" marker into, mapped to the marker it uses for that key.
  1692. # The slicer CLI's ``StaticPrintConfig`` validator runs against the embedded
  1693. # settings *before* ``--load-settings`` overrides apply, so a marker the CLI's
  1694. # own range check rejects makes it exit non-zero before our profile triplet is
  1695. # ever consulted.
  1696. #
  1697. # There are two markers because there are two conventions, and which one a
  1698. # given CLI rejects depends on the build:
  1699. #
  1700. # "-1" -- inherit from the parent process preset (#1201, MakerWorld P2S
  1701. # 3MFs). ``raft_first_layer_expansion`` and ``tree_support_wall_count`` are
  1702. # min 0 in every OrcaSlicer to date, so those still fail on the current
  1703. # sidecar; ``prime_tower_brim_width`` gained min -1 in Orca 2.4.2 and now
  1704. # passes there, but not on older builds.
  1705. #
  1706. # "0" -- "use the active object/part filament", the default Bambu Studio
  1707. # writes for the three feature-filament indices (#3030). Bambu Studio and
  1708. # OrcaSlicer 2.4.0+ both define these min 0, so 0 is legal there; OrcaSlicer
  1709. # 2.3.x and earlier still used the 1-based scheme (min 1, default 1) and
  1710. # reject it with ``0 not in range [1.000000,...]``. Sidecar images are
  1711. # version-tagged, so an install can be pinned to one of those.
  1712. #
  1713. # Removing the key rather than rewriting it is what makes this safe on every
  1714. # build: the CLI then falls back to its own compiled default, which is 0 on
  1715. # the builds where 0 was legal (so nothing changes) and 1 on the older ones,
  1716. # which is what "the active filament" means under that scheme.
  1717. #
  1718. # Allowlisted (rather than "strip every marker-shaped value") because some
  1719. # fields legitimately take the marker value -- z_offset, translations, and any
  1720. # feature index a user really did set to a first filament -- and a blanket
  1721. # strip would silently corrupt those.
  1722. #
  1723. # Add new entries as reports surface: the slicer names the offending field
  1724. # directly, e.g. ``<field>: <value> not in range [...]``.
  1725. PROJECT_SETTINGS_SENTINELS: dict[str, str] = {
  1726. # Reported in #1201 (MakerWorld P2S 3MFs).
  1727. "raft_first_layer_expansion": "-1",
  1728. "tree_support_wall_count": "-1",
  1729. # Known sentinel case from earlier reports, cited in #1201.
  1730. "prime_tower_brim_width": "-1",
  1731. # Reported in #3030 (MakerWorld 3MF, OrcaSlicer sidecar).
  1732. "wall_filament": "0",
  1733. "sparse_infill_filament": "0",
  1734. "solid_infill_filament": "0",
  1735. }
  1736. PROJECT_SETTINGS_PATH = "Metadata/project_settings.config"
  1737. def _is_sentinel(value: object, sentinel: str) -> bool:
  1738. """Does ``value`` carry ``sentinel``, whether stored as text or a number?
  1739. Bambu Studio writes every ``project_settings.config`` value as a string,
  1740. but a 3MF that has been round-tripped through another tool can carry the
  1741. same field as a JSON number. ``bool`` is excluded explicitly: it is an
  1742. ``int`` subclass in Python, and ``str(False)`` would otherwise never match
  1743. anyway -- the exclusion is there so a future numeric sentinel like ``0``
  1744. cannot be matched by ``False``.
  1745. """
  1746. if isinstance(value, bool):
  1747. return False
  1748. if isinstance(value, (str, int)):
  1749. return str(value) == sentinel
  1750. return False
  1751. def sanitize_project_settings_sentinels(zip_bytes: bytes) -> bytes:
  1752. """Strip inherit/unset sentinels from a 3MF's ``project_settings.config``
  1753. so the slicer CLI's range validator accepts the file (#1201, #3030).
  1754. Removes only allowlisted keys (see ``PROJECT_SETTINGS_SENTINELS``) and only
  1755. when the value is exactly that key's sentinel. The rest of the config --
  1756. and every other entry in the zip -- is preserved byte-for-byte. Unlike a
  1757. whole-file strip this leaves ``StaticPrintConfig`` initialisation intact:
  1758. the file is still present, still parses, and the slicer falls back to the
  1759. supplied ``--load-settings`` value, or to its own default, for the removed
  1760. key.
  1761. Returns the original bytes unchanged when no sanitisation is needed (input
  1762. isn't a valid zip, no ``project_settings.config``, no allowlisted sentinels
  1763. present, or any other parse failure) so the caller can pass the result on
  1764. without further checks.
  1765. """
  1766. from io import BytesIO
  1767. try:
  1768. with zipfile.ZipFile(BytesIO(zip_bytes), "r") as zin:
  1769. if PROJECT_SETTINGS_PATH not in zin.namelist():
  1770. return zip_bytes
  1771. try:
  1772. config = json.loads(zin.read(PROJECT_SETTINGS_PATH).decode("utf-8"))
  1773. except (json.JSONDecodeError, UnicodeDecodeError):
  1774. return zip_bytes
  1775. if not isinstance(config, dict):
  1776. return zip_bytes
  1777. removed = {
  1778. key: sentinel
  1779. for key, sentinel in PROJECT_SETTINGS_SENTINELS.items()
  1780. if _is_sentinel(config.get(key), sentinel)
  1781. }
  1782. if not removed:
  1783. return zip_bytes
  1784. for key in removed:
  1785. config.pop(key, None)
  1786. patched = json.dumps(config)
  1787. logger.info(
  1788. "3MF sanitiser: removed inherit sentinels %s - slicer will use its defaults for those keys",
  1789. sorted(f"{key}={sentinel}" for key, sentinel in removed.items()),
  1790. )
  1791. dst = BytesIO()
  1792. with zipfile.ZipFile(dst, "w", zipfile.ZIP_DEFLATED) as zout:
  1793. for item in zin.infolist():
  1794. if item.filename == PROJECT_SETTINGS_PATH:
  1795. zout.writestr(item, patched)
  1796. else:
  1797. zout.writestr(item, zin.read(item.filename))
  1798. return dst.getvalue()
  1799. except (zipfile.BadZipFile, OSError):
  1800. return zip_bytes