test_threemf_tools.py 59 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481
  1. """Unit tests for 3MF parsing utilities (threemf_tools.py).
  2. Tests G-code parsing, filament length-to-weight conversion,
  3. and cumulative layer usage lookup.
  4. """
  5. import io
  6. import json
  7. import math
  8. import zipfile
  9. import pytest
  10. from backend.app.utils.threemf_tools import (
  11. expand_to_project_slots,
  12. extract_bed_type_from_3mf,
  13. extract_embedded_presets_from_3mf,
  14. extract_filament_usage_from_3mf,
  15. extract_layer_filament_usage_from_3mf,
  16. extract_max_z_height_from_3mf,
  17. extract_plate_extruder_set_from_3mf,
  18. extract_print_time_from_3mf,
  19. extract_project_filaments_from_3mf,
  20. extract_support_filament_slots_from_3mf,
  21. get_cumulative_usage_at_layer,
  22. mm_to_grams,
  23. parse_gcode_layer_filament_usage,
  24. )
  25. def create_mock_3mf(slice_info_content: str) -> io.BytesIO:
  26. """Create a mock 3MF file (ZIP) with slice_info.config content."""
  27. buffer = io.BytesIO()
  28. with zipfile.ZipFile(buffer, "w") as zf:
  29. zf.writestr("Metadata/slice_info.config", slice_info_content)
  30. buffer.seek(0)
  31. return buffer
  32. class TestParseGcodeLayerFilamentUsage:
  33. """Tests for parse_gcode_layer_filament_usage()."""
  34. def test_single_filament_single_layer(self):
  35. """Single filament extruding on one layer."""
  36. gcode = """
  37. M620 S0
  38. G1 X10 Y10 E5.0
  39. G1 X20 Y20 E3.0
  40. """
  41. result = parse_gcode_layer_filament_usage(gcode)
  42. assert result == {0: {0: 8.0}}
  43. def test_multi_layer_single_filament(self):
  44. """Single filament across multiple layers."""
  45. gcode = """
  46. M620 S0
  47. G1 X10 Y10 E10.0
  48. M73 L1
  49. G1 X20 Y20 E5.0
  50. M73 L2
  51. G1 X30 Y30 E7.0
  52. """
  53. result = parse_gcode_layer_filament_usage(gcode)
  54. assert result[0] == {0: 10.0}
  55. assert result[1] == {0: 15.0}
  56. assert result[2] == {0: 22.0}
  57. def test_multi_material(self):
  58. """Multiple filaments switching via M620."""
  59. gcode = """
  60. M620 S0
  61. G1 E10.0
  62. M73 L1
  63. M620 S1
  64. G1 E5.0
  65. M620 S0
  66. G1 E3.0
  67. M73 L2
  68. G1 E2.0
  69. """
  70. result = parse_gcode_layer_filament_usage(gcode)
  71. # Layer 0: filament 0 = 10mm
  72. assert result[0] == {0: 10.0}
  73. # Layer 1: filament 0 = 13mm (10+3), filament 1 = 5mm
  74. assert result[1] == {0: 13.0, 1: 5.0}
  75. # Layer 2: filament 0 = 15mm (13+2)
  76. assert result[2] == {0: 15.0, 1: 5.0}
  77. def test_retractions_ignored(self):
  78. """Negative E values (retractions) should be ignored."""
  79. gcode = """
  80. M620 S0
  81. G1 E10.0
  82. G1 E-2.0
  83. G1 E5.0
  84. """
  85. result = parse_gcode_layer_filament_usage(gcode)
  86. assert result == {0: {0: 15.0}}
  87. def test_m620_s255_unloads(self):
  88. """M620 S255 means unload - extrusion after should be ignored."""
  89. gcode = """
  90. M620 S0
  91. G1 E10.0
  92. M620 S255
  93. G1 E5.0
  94. """
  95. result = parse_gcode_layer_filament_usage(gcode)
  96. assert result == {0: {0: 10.0}}
  97. def test_m620_with_suffix(self):
  98. """M620 S0A format (filament ID with suffix letter)."""
  99. gcode = """
  100. M620 S0A
  101. G1 E10.0
  102. M620 S1A
  103. G1 E5.0
  104. """
  105. result = parse_gcode_layer_filament_usage(gcode)
  106. assert result == {0: {0: 10.0, 1: 5.0}}
  107. def test_comments_ignored(self):
  108. """Comment lines and inline comments are ignored."""
  109. gcode = """
  110. ; This is a comment
  111. M620 S0
  112. G1 X10 E5.0 ; inline comment with E value
  113. G1 E3.0
  114. """
  115. result = parse_gcode_layer_filament_usage(gcode)
  116. assert result == {0: {0: 8.0}}
  117. def test_empty_gcode(self):
  118. """Empty G-code returns empty dict."""
  119. assert parse_gcode_layer_filament_usage("") == {}
  120. assert parse_gcode_layer_filament_usage("\n\n\n") == {}
  121. def test_no_extrusion(self):
  122. """G-code with moves but no extrusion."""
  123. gcode = """
  124. G1 X10 Y10
  125. G1 X20 Y20
  126. """
  127. assert parse_gcode_layer_filament_usage(gcode) == {}
  128. def test_no_active_filament_extrusion_ignored(self):
  129. """Extrusion before any M620 is ignored (no active filament)."""
  130. gcode = """
  131. G1 E10.0
  132. M620 S0
  133. G1 E5.0
  134. """
  135. result = parse_gcode_layer_filament_usage(gcode)
  136. assert result == {0: {0: 5.0}}
  137. def test_g0_g2_g3_extrusion(self):
  138. """G0, G2, G3 with E parameter are also tracked."""
  139. gcode = """
  140. M620 S0
  141. G0 E1.0
  142. G1 E2.0
  143. G2 E3.0
  144. G3 E4.0
  145. """
  146. result = parse_gcode_layer_filament_usage(gcode)
  147. assert result == {0: {0: 10.0}}
  148. def test_cumulative_across_layers(self):
  149. """Values are cumulative, not per-layer."""
  150. gcode = """
  151. M620 S0
  152. G1 E100.0
  153. M73 L1
  154. G1 E100.0
  155. M73 L2
  156. G1 E100.0
  157. """
  158. result = parse_gcode_layer_filament_usage(gcode)
  159. assert result[0] == {0: 100.0}
  160. assert result[1] == {0: 200.0}
  161. assert result[2] == {0: 300.0}
  162. class TestMmToGrams:
  163. """Tests for mm_to_grams()."""
  164. def test_default_pla_175(self):
  165. """Default PLA 1.75mm conversion."""
  166. # 1000mm of 1.75mm PLA at 1.24 g/cm³
  167. # Volume = π × (0.0875cm)² × 100cm = 2.405cm³
  168. # Weight = 2.405 × 1.24 = 2.982g
  169. result = mm_to_grams(1000.0)
  170. expected = math.pi * (0.0875**2) * 100 * 1.24
  171. assert abs(result - expected) < 0.001
  172. def test_zero_length(self):
  173. """Zero length returns zero weight."""
  174. assert mm_to_grams(0.0) == 0.0
  175. def test_custom_diameter(self):
  176. """Custom diameter (2.85mm) changes result."""
  177. result_175 = mm_to_grams(1000.0, diameter_mm=1.75)
  178. result_285 = mm_to_grams(1000.0, diameter_mm=2.85)
  179. # 2.85mm filament has more volume per mm
  180. assert result_285 > result_175
  181. ratio = (2.85 / 1.75) ** 2 # Volume scales with diameter²
  182. assert abs(result_285 / result_175 - ratio) < 0.001
  183. def test_custom_density(self):
  184. """Different density (ABS vs PLA)."""
  185. pla = mm_to_grams(1000.0, density_g_cm3=1.24)
  186. abs_ = mm_to_grams(1000.0, density_g_cm3=1.04)
  187. assert pla > abs_
  188. assert abs(pla / abs_ - 1.24 / 1.04) < 0.001
  189. def test_known_value(self):
  190. """Verify against a known calculation.
  191. 1m (1000mm) of 1.75mm PLA at 1.24 g/cm³:
  192. r = 0.0875 cm, L = 100 cm
  193. V = π × 0.0875² × 100 = 2.4053 cm³
  194. m = 2.4053 × 1.24 = 2.9826 g
  195. """
  196. result = mm_to_grams(1000.0, 1.75, 1.24)
  197. assert abs(result - 2.9826) < 0.01
  198. class TestGetCumulativeUsageAtLayer:
  199. """Tests for get_cumulative_usage_at_layer()."""
  200. def test_exact_layer_match(self):
  201. """Target layer exists exactly in the data."""
  202. data = {0: {0: 100.0}, 5: {0: 500.0}, 10: {0: 1000.0}}
  203. assert get_cumulative_usage_at_layer(data, 5) == {0: 500.0}
  204. def test_between_layers(self):
  205. """Target is between recorded layers - uses the closest lower one."""
  206. data = {0: {0: 100.0}, 5: {0: 500.0}, 10: {0: 1000.0}}
  207. # Layer 7 is between 5 and 10, should return layer 5's data
  208. assert get_cumulative_usage_at_layer(data, 7) == {0: 500.0}
  209. def test_beyond_last_layer(self):
  210. """Target is beyond the last recorded layer."""
  211. data = {0: {0: 100.0}, 5: {0: 500.0}}
  212. assert get_cumulative_usage_at_layer(data, 100) == {0: 500.0}
  213. def test_before_first_layer(self):
  214. """Target is before any recorded data."""
  215. data = {5: {0: 500.0}, 10: {0: 1000.0}}
  216. assert get_cumulative_usage_at_layer(data, 3) == {}
  217. def test_empty_data(self):
  218. """Empty layer_usage returns empty dict."""
  219. assert get_cumulative_usage_at_layer({}, 5) == {}
  220. def test_none_data(self):
  221. """None layer_usage returns empty dict."""
  222. assert get_cumulative_usage_at_layer(None, 5) == {}
  223. def test_multi_filament(self):
  224. """Multi-filament data at target layer."""
  225. data = {
  226. 0: {0: 50.0},
  227. 5: {0: 200.0, 1: 100.0},
  228. 10: {0: 400.0, 1: 250.0, 2: 50.0},
  229. }
  230. result = get_cumulative_usage_at_layer(data, 8)
  231. assert result == {0: 200.0, 1: 100.0}
  232. def test_layer_zero(self):
  233. """Target layer 0."""
  234. data = {0: {0: 10.0}, 1: {0: 20.0}}
  235. assert get_cumulative_usage_at_layer(data, 0) == {0: 10.0}
  236. class TestExtractFilamentUsageFrom3mf:
  237. """Tests for extract_filament_usage_from_3mf function."""
  238. def test_extract_single_filament(self, tmp_path):
  239. """Test extracting a single filament."""
  240. xml_content = """<?xml version="1.0" encoding="UTF-8"?>
  241. <config>
  242. <filament id="1" used_g="50.5" type="PLA" color="#FF0000"/>
  243. </config>
  244. """
  245. mock_3mf = create_mock_3mf(xml_content)
  246. file_path = tmp_path / "test.3mf"
  247. file_path.write_bytes(mock_3mf.read())
  248. result = extract_filament_usage_from_3mf(file_path)
  249. assert len(result) == 1
  250. assert result[0]["slot_id"] == 1
  251. assert result[0]["used_g"] == 50.5
  252. assert result[0]["type"] == "PLA"
  253. assert result[0]["color"] == "#FF0000"
  254. def test_extract_multiple_filaments(self, tmp_path):
  255. """Test extracting multiple filaments."""
  256. xml_content = """<?xml version="1.0" encoding="UTF-8"?>
  257. <config>
  258. <filament id="1" used_g="50.5" type="PLA" color="#FF0000"/>
  259. <filament id="2" used_g="30.2" type="PETG" color="#00FF00"/>
  260. <filament id="3" used_g="10.0" type="ABS" color="#0000FF"/>
  261. </config>
  262. """
  263. mock_3mf = create_mock_3mf(xml_content)
  264. file_path = tmp_path / "test.3mf"
  265. file_path.write_bytes(mock_3mf.read())
  266. result = extract_filament_usage_from_3mf(file_path)
  267. assert len(result) == 3
  268. assert result[0]["slot_id"] == 1
  269. assert result[1]["slot_id"] == 2
  270. assert result[2]["slot_id"] == 3
  271. def test_extract_filament_with_plate_id(self, tmp_path):
  272. """Test extracting filament for a specific plate."""
  273. xml_content = """<?xml version="1.0" encoding="UTF-8"?>
  274. <config>
  275. <plate>
  276. <metadata key="index" value="1"/>
  277. <filament id="1" used_g="25.0" type="PLA" color="#FF0000"/>
  278. </plate>
  279. <plate>
  280. <metadata key="index" value="2"/>
  281. <filament id="1" used_g="75.0" type="PETG" color="#00FF00"/>
  282. </plate>
  283. </config>
  284. """
  285. mock_3mf = create_mock_3mf(xml_content)
  286. file_path = tmp_path / "test.3mf"
  287. file_path.write_bytes(mock_3mf.read())
  288. result = extract_filament_usage_from_3mf(file_path, plate_id=2)
  289. assert len(result) == 1
  290. assert result[0]["used_g"] == 75.0
  291. assert result[0]["type"] == "PETG"
  292. def test_missing_slice_info_returns_empty(self, tmp_path):
  293. """Test that missing slice_info.config returns empty list."""
  294. buffer = io.BytesIO()
  295. with zipfile.ZipFile(buffer, "w") as zf:
  296. zf.writestr("other_file.txt", "content")
  297. buffer.seek(0)
  298. file_path = tmp_path / "test.3mf"
  299. file_path.write_bytes(buffer.read())
  300. result = extract_filament_usage_from_3mf(file_path)
  301. assert result == []
  302. def test_invalid_file_returns_empty(self, tmp_path):
  303. """Test that invalid file returns empty list."""
  304. file_path = tmp_path / "invalid.3mf"
  305. file_path.write_text("not a zip file")
  306. result = extract_filament_usage_from_3mf(file_path)
  307. assert result == []
  308. def test_nonexistent_file_returns_empty(self, tmp_path):
  309. """Test that nonexistent file returns empty list."""
  310. file_path = tmp_path / "nonexistent.3mf"
  311. result = extract_filament_usage_from_3mf(file_path)
  312. assert result == []
  313. def test_filament_without_id_is_skipped(self, tmp_path):
  314. """Test that filament without id is skipped."""
  315. xml_content = """<?xml version="1.0" encoding="UTF-8"?>
  316. <config>
  317. <filament used_g="50.5" type="PLA" color="#FF0000"/>
  318. <filament id="2" used_g="30.0" type="PETG" color="#00FF00"/>
  319. </config>
  320. """
  321. mock_3mf = create_mock_3mf(xml_content)
  322. file_path = tmp_path / "test.3mf"
  323. file_path.write_bytes(mock_3mf.read())
  324. result = extract_filament_usage_from_3mf(file_path)
  325. assert len(result) == 1
  326. assert result[0]["slot_id"] == 2
  327. def test_invalid_used_g_is_skipped(self, tmp_path):
  328. """Test that filament with invalid used_g is skipped."""
  329. xml_content = """<?xml version="1.0" encoding="UTF-8"?>
  330. <config>
  331. <filament id="1" used_g="invalid" type="PLA" color="#FF0000"/>
  332. <filament id="2" used_g="30.0" type="PETG" color="#00FF00"/>
  333. </config>
  334. """
  335. mock_3mf = create_mock_3mf(xml_content)
  336. file_path = tmp_path / "test.3mf"
  337. file_path.write_bytes(mock_3mf.read())
  338. result = extract_filament_usage_from_3mf(file_path)
  339. assert len(result) == 1
  340. assert result[0]["slot_id"] == 2
  341. def test_missing_optional_fields(self, tmp_path):
  342. """Test that missing type and color default to empty string."""
  343. xml_content = """<?xml version="1.0" encoding="UTF-8"?>
  344. <config>
  345. <filament id="1" used_g="50.5"/>
  346. </config>
  347. """
  348. mock_3mf = create_mock_3mf(xml_content)
  349. file_path = tmp_path / "test.3mf"
  350. file_path.write_bytes(mock_3mf.read())
  351. result = extract_filament_usage_from_3mf(file_path)
  352. assert len(result) == 1
  353. assert result[0]["type"] == ""
  354. assert result[0]["color"] == ""
  355. # ---------------------------------------------------------------------------
  356. # Tests for extract_project_filaments_from_3mf — used by the slice modal as
  357. # fallback when the sidecar can't run a preview slice.
  358. # ---------------------------------------------------------------------------
  359. def _make_3mf_with(files: dict[str, bytes | str]) -> zipfile.ZipFile:
  360. buf = io.BytesIO()
  361. with zipfile.ZipFile(buf, "w") as zf:
  362. for name, content in files.items():
  363. zf.writestr(name, content if isinstance(content, (bytes, str)) else str(content))
  364. buf.seek(0)
  365. return zipfile.ZipFile(buf, "r")
  366. class TestExtractProjectFilamentsFrom3mf:
  367. """The helper backfills the slice modal when slice_info.config is empty
  368. (raw project files) and the sidecar is unreachable."""
  369. def test_returns_empty_when_project_settings_missing(self):
  370. with _make_3mf_with({"placeholder.txt": "hi"}) as zf:
  371. assert extract_project_filaments_from_3mf(zf) == []
  372. def test_happy_path_returns_one_entry_per_slot(self):
  373. proj = {
  374. "filament_type": ["PLA", "PETG"],
  375. "filament_colour": ["#000000", "#FFFFFF"],
  376. }
  377. with _make_3mf_with({"Metadata/project_settings.config": json.dumps(proj)}) as zf:
  378. out = extract_project_filaments_from_3mf(zf)
  379. assert [(f["slot_id"], f["type"], f["color"]) for f in out] == [
  380. (1, "PLA", "#000000"),
  381. (2, "PETG", "#FFFFFF"),
  382. ]
  383. def test_mismatched_array_lengths_use_max_with_blanks(self):
  384. proj = {
  385. "filament_type": ["PLA", "PETG", "ABS"],
  386. "filament_colour": ["#000000"],
  387. }
  388. with _make_3mf_with({"Metadata/project_settings.config": json.dumps(proj)}) as zf:
  389. out = extract_project_filaments_from_3mf(zf)
  390. assert len(out) == 3
  391. assert out[0]["color"] == "#000000"
  392. assert out[1]["color"] == ""
  393. assert out[2]["color"] == ""
  394. def test_corrupt_json_returns_empty_no_exception(self):
  395. with _make_3mf_with({"Metadata/project_settings.config": b"{not json"}) as zf:
  396. assert extract_project_filaments_from_3mf(zf) == []
  397. def test_root_is_list_returns_empty(self):
  398. # Defensive: spec says it's a dict, but a file shipping a top-level
  399. # list (or anything non-dict) shouldn't crash the modal.
  400. with _make_3mf_with({"Metadata/project_settings.config": json.dumps([])}) as zf:
  401. assert extract_project_filaments_from_3mf(zf) == []
  402. def test_empty_arrays_returns_empty(self):
  403. proj = {"filament_type": [], "filament_colour": []}
  404. with _make_3mf_with({"Metadata/project_settings.config": json.dumps(proj)}) as zf:
  405. assert extract_project_filaments_from_3mf(zf) == []
  406. # ---------------------------------------------------------------------------
  407. # Tests for expand_to_project_slots — #2712
  408. # ---------------------------------------------------------------------------
  409. class TestExpandToProjectSlots:
  410. """The slice modal's filament list is positional: index 0 is slot 1, all
  411. the way through to the ``filament_N.json`` parts handed to the slicer.
  412. A MakerWorld source that carries slice_info but paints with slot 4 alone
  413. used to yield a one-row list, so the user's single pick was bound to slot
  414. 1 and slot 4 — the slot that actually prints — kept the source's embedded
  415. default. Picking PETG produced a PLA print.
  416. """
  417. PROJECT = json.dumps(
  418. {
  419. "filament_type": ["PLA", "PLA", "PLA", "PLA"],
  420. "filament_colour": ["#38CC0A", "#161616", "#898989", "#898989"],
  421. }
  422. )
  423. def test_a_single_used_slot_still_produces_a_full_positional_list(self):
  424. """The reported file: four project slots, only slot 4 printed."""
  425. used = [
  426. {
  427. "slot_id": 4,
  428. "type": "PLA",
  429. "color": "#898989",
  430. "used_grams": 105.9,
  431. "used_meters": 35.51,
  432. "tray_info_idx": "GFL99",
  433. "used_in_plate": True,
  434. }
  435. ]
  436. with _make_3mf_with({"Metadata/project_settings.config": self.PROJECT}) as zf:
  437. out = expand_to_project_slots(zf, used)
  438. assert [f["slot_id"] for f in out] == [1, 2, 3, 4]
  439. assert [f["used_in_plate"] for f in out] == [False, False, False, True]
  440. def test_the_used_row_keeps_its_usage_figures(self):
  441. """The modal shows the real weight and colour, and the print path
  442. downstream reads ``tray_info_idx`` — none of it may be flattened into
  443. a zeroed project row."""
  444. used = [
  445. {
  446. "slot_id": 4,
  447. "type": "PETG",
  448. "color": "#FF0000",
  449. "used_grams": 105.9,
  450. "used_meters": 35.51,
  451. "tray_info_idx": "GFL99",
  452. "used_in_plate": True,
  453. }
  454. ]
  455. with _make_3mf_with({"Metadata/project_settings.config": self.PROJECT}) as zf:
  456. out = expand_to_project_slots(zf, used)
  457. slot4 = out[3]
  458. assert slot4["used_grams"] == 105.9
  459. assert slot4["tray_info_idx"] == "GFL99"
  460. # Resolved from the slice, not the project's stale PLA/#898989.
  461. assert (slot4["type"], slot4["color"]) == ("PETG", "#FF0000")
  462. def test_padding_rows_carry_the_project_type_and_colour(self):
  463. """They drive the modal's pre-pick for the disabled rows."""
  464. used = [{"slot_id": 4, "type": "PLA", "color": "#898989", "used_grams": 1.0, "used_meters": 1.0}]
  465. with _make_3mf_with({"Metadata/project_settings.config": self.PROJECT}) as zf:
  466. out = expand_to_project_slots(zf, used)
  467. assert (out[0]["type"], out[0]["color"]) == ("PLA", "#38CC0A")
  468. assert out[0]["used_grams"] == 0
  469. def test_every_slot_used_is_a_shape_change_only(self):
  470. used = [
  471. {"slot_id": i, "type": "PLA", "color": "", "used_grams": 5.0, "used_meters": 1.0, "used_in_plate": True}
  472. for i in (1, 2, 3, 4)
  473. ]
  474. with _make_3mf_with({"Metadata/project_settings.config": self.PROJECT}) as zf:
  475. out = expand_to_project_slots(zf, used)
  476. assert len(out) == 4
  477. assert all(f["used_in_plate"] for f in out)
  478. assert all(f["used_grams"] == 5.0 for f in out)
  479. def test_a_used_slot_beyond_the_project_list_is_kept(self):
  480. """Dropping it would recreate the original bug on a file whose
  481. project settings and slice_info disagree — the one slot that prints
  482. would vanish from the list entirely."""
  483. used = [{"slot_id": 9, "type": "PLA", "color": "", "used_grams": 5.0, "used_meters": 1.0}]
  484. with _make_3mf_with({"Metadata/project_settings.config": self.PROJECT}) as zf:
  485. out = expand_to_project_slots(zf, used)
  486. assert [f["slot_id"] for f in out] == [1, 2, 3, 4, 9]
  487. assert out[-1]["used_in_plate"] is True
  488. def test_returns_the_input_unchanged_without_project_settings(self):
  489. """Nothing to widen against — a narrow list still prints correctly,
  490. an invented one might not."""
  491. used = [{"slot_id": 4, "type": "PLA", "color": "", "used_grams": 5.0, "used_meters": 1.0}]
  492. with _make_3mf_with({"placeholder.txt": "hi"}) as zf:
  493. assert expand_to_project_slots(zf, used) == used
  494. # ---------------------------------------------------------------------------
  495. # Tests for extract_plate_extruder_set_from_3mf — three sources unioned:
  496. # object top-level extruder, per-part extruder, painted-face quadtree leaves.
  497. # ---------------------------------------------------------------------------
  498. def _model_settings(plate_id: int, objects: list[dict]) -> str:
  499. """Build a minimal model_settings.config XML for tests. Each object dict
  500. can have: id, extruder (top-level), parts (list of {extruder}).
  501. The plate references all object ids."""
  502. parts_xml = []
  503. for obj in objects:
  504. oid = obj["id"]
  505. ext = obj.get("extruder")
  506. parts = obj.get("parts", [])
  507. ext_meta = f'<metadata key="extruder" value="{ext}"/>' if ext is not None else ""
  508. part_blocks = "".join(
  509. f'<part id="{i}" subtype="normal_part"><metadata key="extruder" value="{p["extruder"]}"/></part>'
  510. for i, p in enumerate(parts)
  511. if p.get("extruder") is not None
  512. )
  513. parts_xml.append(f'<object id="{oid}"><metadata key="name" value="o{oid}"/>{ext_meta}{part_blocks}</object>')
  514. instances = "".join(
  515. f'<model_instance><metadata key="object_id" value="{o["id"]}"/></model_instance>' for o in objects
  516. )
  517. plate = f'<plate><metadata key="plater_id" value="{plate_id}"/>{instances}</plate>'
  518. return f'<?xml version="1.0"?><config>{"".join(parts_xml)}{plate}</config>'
  519. class TestExtractPlateExtruderSetFrom3mf:
  520. def test_returns_empty_set_when_model_settings_missing(self):
  521. with _make_3mf_with({"placeholder.txt": "hi"}) as zf:
  522. assert extract_plate_extruder_set_from_3mf(zf, plate_id=1) == set()
  523. def test_object_top_level_extruder_only(self):
  524. xml = _model_settings(plate_id=1, objects=[{"id": "10", "extruder": 2}])
  525. with _make_3mf_with({"Metadata/model_settings.config": xml}) as zf:
  526. assert extract_plate_extruder_set_from_3mf(zf, plate_id=1) == {2}
  527. def test_per_part_extruder_unions_with_top_level(self):
  528. # Object's default is 1; one of its parts overrides to 3 (multi-color
  529. # via a sub-mesh). Union both — the slicer needs profiles for both.
  530. xml = _model_settings(
  531. plate_id=1,
  532. objects=[{"id": "10", "extruder": 1, "parts": [{"extruder": 3}]}],
  533. )
  534. with _make_3mf_with({"Metadata/model_settings.config": xml}) as zf:
  535. assert extract_plate_extruder_set_from_3mf(zf, plate_id=1) == {1, 3}
  536. def test_unknown_plate_id_returns_empty_set(self):
  537. xml = _model_settings(plate_id=1, objects=[{"id": "10", "extruder": 2}])
  538. with _make_3mf_with({"Metadata/model_settings.config": xml}) as zf:
  539. assert extract_plate_extruder_set_from_3mf(zf, plate_id=99) == set()
  540. def test_corrupt_xml_returns_empty_set_no_exception(self):
  541. with _make_3mf_with({"Metadata/model_settings.config": "<not valid xml"}) as zf:
  542. assert extract_plate_extruder_set_from_3mf(zf, plate_id=1) == set()
  543. def test_zero_extruder_value_ignored(self):
  544. # Bambu's 0 means "use object default" — not a real slot.
  545. xml = _model_settings(plate_id=1, objects=[{"id": "10", "extruder": 0}])
  546. with _make_3mf_with({"Metadata/model_settings.config": xml}) as zf:
  547. assert extract_plate_extruder_set_from_3mf(zf, plate_id=1) == set()
  548. def test_painted_face_above_threshold_kept(self):
  549. # 60/40 split: 60 triangles painted with extruder 1, 40 with ext 2.
  550. # Threshold is 5%; both above. The dominant ones are real colours.
  551. triangles = []
  552. for _ in range(60):
  553. triangles.append('<triangle v1="0" v2="1" v3="2" paint_color="1"/>')
  554. for _ in range(40):
  555. triangles.append('<triangle v1="0" v2="1" v3="2" paint_color="2"/>')
  556. per_obj = (
  557. '<?xml version="1.0"?>'
  558. '<model><resources><object id="100" type="model"><mesh>'
  559. "<triangles>" + "".join(triangles) + "</triangles>"
  560. "</mesh></object></resources><build/></model>"
  561. )
  562. ms = (
  563. '<?xml version="1.0"?><config>'
  564. '<object id="10"><metadata key="name" value="o"/></object>'
  565. '<plate><metadata key="plater_id" value="1"/>'
  566. '<model_instance><metadata key="object_id" value="10"/></model_instance>'
  567. "</plate></config>"
  568. )
  569. threed = (
  570. '<?xml version="1.0"?>'
  571. '<model xmlns="http://schemas.microsoft.com/3dmanufacturing/core/2015/02"'
  572. ' xmlns:p="http://schemas.microsoft.com/3dmanufacturing/production/2015/06">'
  573. "<resources>"
  574. '<object id="10" type="model"><components>'
  575. '<component p:path="/3D/Objects/o100.model" objectid="100"/>'
  576. "</components></object>"
  577. "</resources><build/></model>"
  578. )
  579. with _make_3mf_with(
  580. {
  581. "Metadata/model_settings.config": ms,
  582. "3D/3dmodel.model": threed,
  583. "3D/Objects/o100.model": per_obj,
  584. }
  585. ) as zf:
  586. result = extract_plate_extruder_set_from_3mf(zf, plate_id=1)
  587. # Both real colours kept (60/40 well above 5% threshold); the dropped
  588. # threshold case is the regression that motivates this test.
  589. assert result == {1, 2}
  590. def test_painted_face_below_threshold_dropped_as_noise(self):
  591. # 99 triangles at ext 1, 1 triangle at ext 9 (1% — below 5%
  592. # threshold). The 1% leaf is a single-leaf accident.
  593. triangles = []
  594. for _ in range(99):
  595. triangles.append('<triangle v1="0" v2="1" v3="2" paint_color="1"/>')
  596. triangles.append('<triangle v1="0" v2="1" v3="2" paint_color="9"/>')
  597. per_obj = (
  598. '<?xml version="1.0"?>'
  599. '<model><resources><object id="100" type="model"><mesh>'
  600. "<triangles>" + "".join(triangles) + "</triangles>"
  601. "</mesh></object></resources><build/></model>"
  602. )
  603. ms = (
  604. '<?xml version="1.0"?><config>'
  605. '<object id="10"><metadata key="name" value="o"/></object>'
  606. '<plate><metadata key="plater_id" value="1"/>'
  607. '<model_instance><metadata key="object_id" value="10"/></model_instance>'
  608. "</plate></config>"
  609. )
  610. threed = (
  611. '<?xml version="1.0"?>'
  612. '<model xmlns="http://schemas.microsoft.com/3dmanufacturing/core/2015/02"'
  613. ' xmlns:p="http://schemas.microsoft.com/3dmanufacturing/production/2015/06">'
  614. '<resources><object id="10" type="model"><components>'
  615. '<component p:path="/3D/Objects/o100.model" objectid="100"/>'
  616. "</components></object></resources><build/></model>"
  617. )
  618. with _make_3mf_with(
  619. {
  620. "Metadata/model_settings.config": ms,
  621. "3D/3dmodel.model": threed,
  622. "3D/Objects/o100.model": per_obj,
  623. }
  624. ) as zf:
  625. result = extract_plate_extruder_set_from_3mf(zf, plate_id=1)
  626. # Single-leaf accident at 1% filtered as noise; only the dominant
  627. # extruder survives.
  628. assert result == {1}
  629. def test_missing_per_object_model_file_silently_skipped(self):
  630. ms = (
  631. '<?xml version="1.0"?><config>'
  632. '<object id="10"><metadata key="extruder" value="2"/></object>'
  633. '<plate><metadata key="plater_id" value="1"/>'
  634. '<model_instance><metadata key="object_id" value="10"/></model_instance>'
  635. "</plate></config>"
  636. )
  637. threed = (
  638. '<?xml version="1.0"?>'
  639. '<model xmlns="http://schemas.microsoft.com/3dmanufacturing/core/2015/02"'
  640. ' xmlns:p="http://schemas.microsoft.com/3dmanufacturing/production/2015/06">'
  641. '<resources><object id="10" type="model"><components>'
  642. '<component p:path="/3D/Objects/missing.model" objectid="999"/>'
  643. "</components></object></resources><build/></model>"
  644. )
  645. with _make_3mf_with(
  646. {"Metadata/model_settings.config": ms, "3D/3dmodel.model": threed},
  647. ) as zf:
  648. # Top-level metadata still works; missing component model file
  649. # is silently skipped without crashing.
  650. assert extract_plate_extruder_set_from_3mf(zf, plate_id=1) == {2}
  651. class TestExtractEmbeddedPresetsFrom3mf:
  652. """Printer / process preset names read from project_settings.config so the
  653. SliceModal can default its dropdowns to the file's own config (#1325)."""
  654. def test_extracts_printer_and_process(self):
  655. config = json.dumps(
  656. {
  657. "printer_settings_id": "Bambu Lab X1 Carbon 0.4 nozzle",
  658. "print_settings_id": "0.20mm Standard @BBL X1C",
  659. "filament_settings_id": ["Bambu PLA Basic @BBL X1C"],
  660. }
  661. )
  662. with _make_3mf_with({"Metadata/project_settings.config": config}) as zf:
  663. assert extract_embedded_presets_from_3mf(zf) == {
  664. "printer": "Bambu Lab X1 Carbon 0.4 nozzle",
  665. "process": "0.20mm Standard @BBL X1C",
  666. }
  667. def test_settings_id_as_list_takes_first(self):
  668. # Some exports write *_settings_id as a per-extruder list.
  669. config = json.dumps(
  670. {
  671. "printer_settings_id": ["Bambu Lab A1 0.4 nozzle"],
  672. "print_settings_id": ["0.16mm Optimal @BBL A1", "0.20mm @BBL A1"],
  673. }
  674. )
  675. with _make_3mf_with({"Metadata/project_settings.config": config}) as zf:
  676. result = extract_embedded_presets_from_3mf(zf)
  677. assert result["printer"] == "Bambu Lab A1 0.4 nozzle"
  678. assert result["process"] == "0.16mm Optimal @BBL A1"
  679. def test_missing_config_returns_none_values(self):
  680. with _make_3mf_with({"3D/3dmodel.model": "<model/>"}) as zf:
  681. assert extract_embedded_presets_from_3mf(zf) == {
  682. "printer": None,
  683. "process": None,
  684. }
  685. def test_malformed_json_returns_none_values(self):
  686. with _make_3mf_with({"Metadata/project_settings.config": "not json"}) as zf:
  687. assert extract_embedded_presets_from_3mf(zf) == {
  688. "printer": None,
  689. "process": None,
  690. }
  691. def test_blank_and_absent_keys_yield_none(self):
  692. config = json.dumps({"printer_settings_id": " ", "other": "x"})
  693. with _make_3mf_with({"Metadata/project_settings.config": config}) as zf:
  694. assert extract_embedded_presets_from_3mf(zf) == {
  695. "printer": None,
  696. "process": None,
  697. }
  698. class TestExtractBedTypeFrom3mf:
  699. """extract_bed_type_from_3mf reads per-plate `curr_bed_type` from
  700. slice_info.config so the queue / print modal can show the right plate
  701. even on multi-plate 3MFs where different plates target different beds
  702. (#1281). archive.bed_type is one-value-per-archive (first plate's
  703. curr_bed_type — see services/archive.py:235), so for accurate
  704. per-plate surfacing we have to re-read the 3MF."""
  705. def test_single_plate_returns_bed_type(self, tmp_path):
  706. xml_content = """<?xml version="1.0" encoding="UTF-8"?>
  707. <config>
  708. <plate>
  709. <metadata key="index" value="1"/>
  710. <metadata key="curr_bed_type" value="Textured PEI Plate"/>
  711. </plate>
  712. </config>
  713. """
  714. file_path = tmp_path / "test.3mf"
  715. file_path.write_bytes(create_mock_3mf(xml_content).read())
  716. assert extract_bed_type_from_3mf(file_path) == "Textured PEI Plate"
  717. def test_multi_plate_returns_per_plate_value(self, tmp_path):
  718. # Reporter's case: a 3MF mixing PEI + Engineering across plates.
  719. # Looking up by plate_id must return THAT plate's value, not the
  720. # first plate's value the archive happens to cache.
  721. xml_content = """<?xml version="1.0" encoding="UTF-8"?>
  722. <config>
  723. <plate>
  724. <metadata key="index" value="1"/>
  725. <metadata key="curr_bed_type" value="Textured PEI Plate"/>
  726. </plate>
  727. <plate>
  728. <metadata key="index" value="2"/>
  729. <metadata key="curr_bed_type" value="Engineering Plate"/>
  730. </plate>
  731. <plate>
  732. <metadata key="index" value="3"/>
  733. <metadata key="curr_bed_type" value="Cool Plate"/>
  734. </plate>
  735. </config>
  736. """
  737. file_path = tmp_path / "test.3mf"
  738. file_path.write_bytes(create_mock_3mf(xml_content).read())
  739. assert extract_bed_type_from_3mf(file_path, plate_id=1) == "Textured PEI Plate"
  740. assert extract_bed_type_from_3mf(file_path, plate_id=2) == "Engineering Plate"
  741. assert extract_bed_type_from_3mf(file_path, plate_id=3) == "Cool Plate"
  742. def test_no_plate_id_returns_first_plate(self, tmp_path):
  743. # The plate_id=None branch must match the archive-level capture
  744. # convention (first plate wins) so callers that don't care about
  745. # plate selection see the same value the archive table holds.
  746. xml_content = """<?xml version="1.0" encoding="UTF-8"?>
  747. <config>
  748. <plate>
  749. <metadata key="index" value="1"/>
  750. <metadata key="curr_bed_type" value="Cool Plate SuperTack"/>
  751. </plate>
  752. <plate>
  753. <metadata key="index" value="2"/>
  754. <metadata key="curr_bed_type" value="Engineering Plate"/>
  755. </plate>
  756. </config>
  757. """
  758. file_path = tmp_path / "test.3mf"
  759. file_path.write_bytes(create_mock_3mf(xml_content).read())
  760. assert extract_bed_type_from_3mf(file_path) == "Cool Plate SuperTack"
  761. def test_unknown_plate_id_returns_none(self, tmp_path):
  762. xml_content = """<?xml version="1.0" encoding="UTF-8"?>
  763. <config>
  764. <plate>
  765. <metadata key="index" value="1"/>
  766. <metadata key="curr_bed_type" value="Textured PEI Plate"/>
  767. </plate>
  768. </config>
  769. """
  770. file_path = tmp_path / "test.3mf"
  771. file_path.write_bytes(create_mock_3mf(xml_content).read())
  772. assert extract_bed_type_from_3mf(file_path, plate_id=99) is None
  773. def test_plate_without_bed_type_returns_none(self, tmp_path):
  774. # Older slicers may export a plate without curr_bed_type. The
  775. # helper must return None rather than falling through to another
  776. # plate's value (which would silently lie).
  777. xml_content = """<?xml version="1.0" encoding="UTF-8"?>
  778. <config>
  779. <plate>
  780. <metadata key="index" value="1"/>
  781. </plate>
  782. <plate>
  783. <metadata key="index" value="2"/>
  784. <metadata key="curr_bed_type" value="Engineering Plate"/>
  785. </plate>
  786. </config>
  787. """
  788. file_path = tmp_path / "test.3mf"
  789. file_path.write_bytes(create_mock_3mf(xml_content).read())
  790. assert extract_bed_type_from_3mf(file_path, plate_id=1) is None
  791. assert extract_bed_type_from_3mf(file_path, plate_id=2) == "Engineering Plate"
  792. def test_missing_slice_info_returns_none(self, tmp_path):
  793. buffer = io.BytesIO()
  794. with zipfile.ZipFile(buffer, "w") as zf:
  795. zf.writestr("other_file.txt", "content")
  796. buffer.seek(0)
  797. file_path = tmp_path / "test.3mf"
  798. file_path.write_bytes(buffer.read())
  799. assert extract_bed_type_from_3mf(file_path) is None
  800. def test_invalid_file_returns_none(self, tmp_path):
  801. file_path = tmp_path / "invalid.3mf"
  802. file_path.write_text("not a zip file")
  803. assert extract_bed_type_from_3mf(file_path) is None
  804. def test_whitespace_trimmed(self, tmp_path):
  805. # 3MF values sometimes carry surrounding whitespace from manual
  806. # template tweaks; getBedTypeInfo() on the frontend is also
  807. # whitespace-tolerant, but the wire shape should be clean.
  808. xml_content = """<?xml version="1.0" encoding="UTF-8"?>
  809. <config>
  810. <plate>
  811. <metadata key="index" value="1"/>
  812. <metadata key="curr_bed_type" value=" Textured PEI Plate "/>
  813. </plate>
  814. </config>
  815. """
  816. file_path = tmp_path / "test.3mf"
  817. file_path.write_bytes(create_mock_3mf(xml_content).read())
  818. assert extract_bed_type_from_3mf(file_path) == "Textured PEI Plate"
  819. class TestExtractPrintTimeFrom3mf:
  820. """Tests for extract_print_time_from_3mf — the per-plate `prediction` reader
  821. used by the completion notification path to scope the archive-level (summed)
  822. total down to the actually-printed plate (#1785)."""
  823. def test_returns_plate_prediction_when_plate_id_matches(self, tmp_path):
  824. xml_content = """<?xml version="1.0" encoding="UTF-8"?>
  825. <config>
  826. <plate>
  827. <metadata key="index" value="1"/>
  828. <metadata key="prediction" value="3600"/>
  829. </plate>
  830. <plate>
  831. <metadata key="index" value="2"/>
  832. <metadata key="prediction" value="7200"/>
  833. </plate>
  834. <plate>
  835. <metadata key="index" value="3"/>
  836. <metadata key="prediction" value="10800"/>
  837. </plate>
  838. </config>
  839. """
  840. file_path = tmp_path / "test.3mf"
  841. file_path.write_bytes(create_mock_3mf(xml_content).read())
  842. assert extract_print_time_from_3mf(file_path, plate_id=2) == 7200
  843. def test_returns_first_plate_when_no_plate_id(self, tmp_path):
  844. xml_content = """<?xml version="1.0" encoding="UTF-8"?>
  845. <config>
  846. <plate>
  847. <metadata key="index" value="1"/>
  848. <metadata key="prediction" value="900"/>
  849. </plate>
  850. <plate>
  851. <metadata key="index" value="2"/>
  852. <metadata key="prediction" value="1800"/>
  853. </plate>
  854. </config>
  855. """
  856. file_path = tmp_path / "test.3mf"
  857. file_path.write_bytes(create_mock_3mf(xml_content).read())
  858. assert extract_print_time_from_3mf(file_path) == 900
  859. def test_returns_none_when_plate_id_missing(self, tmp_path):
  860. xml_content = """<?xml version="1.0" encoding="UTF-8"?>
  861. <config>
  862. <plate>
  863. <metadata key="index" value="1"/>
  864. <metadata key="prediction" value="3600"/>
  865. </plate>
  866. </config>
  867. """
  868. file_path = tmp_path / "test.3mf"
  869. file_path.write_bytes(create_mock_3mf(xml_content).read())
  870. assert extract_print_time_from_3mf(file_path, plate_id=5) is None
  871. def test_returns_none_when_prediction_unparseable(self, tmp_path):
  872. xml_content = """<?xml version="1.0" encoding="UTF-8"?>
  873. <config>
  874. <plate>
  875. <metadata key="index" value="1"/>
  876. <metadata key="prediction" value="not-a-number"/>
  877. </plate>
  878. </config>
  879. """
  880. file_path = tmp_path / "test.3mf"
  881. file_path.write_bytes(create_mock_3mf(xml_content).read())
  882. assert extract_print_time_from_3mf(file_path, plate_id=1) is None
  883. def test_returns_none_when_slice_info_missing(self, tmp_path):
  884. buffer = io.BytesIO()
  885. with zipfile.ZipFile(buffer, "w") as zf:
  886. zf.writestr("other_file.txt", "content")
  887. buffer.seek(0)
  888. file_path = tmp_path / "test.3mf"
  889. file_path.write_bytes(buffer.read())
  890. assert extract_print_time_from_3mf(file_path) is None
  891. assert extract_print_time_from_3mf(file_path, plate_id=1) is None
  892. def test_returns_none_when_file_invalid(self, tmp_path):
  893. file_path = tmp_path / "invalid.3mf"
  894. file_path.write_text("not a zip file")
  895. assert extract_print_time_from_3mf(file_path) is None
  896. assert extract_print_time_from_3mf(file_path, plate_id=1) is None
  897. def test_returns_none_when_file_missing(self, tmp_path):
  898. file_path = tmp_path / "nonexistent.3mf"
  899. assert extract_print_time_from_3mf(file_path) is None
  900. assert extract_print_time_from_3mf(file_path, plate_id=2) is None
  901. # ---------------------------------------------------------------------------
  902. # Tests for extract_support_filament_slots_from_3mf — #1881: a plate that uses
  903. # PVA (or any material) exclusively for supports doesn't reference the support
  904. # slot from object geometry, so without this helper substitute_unused_plate_
  905. # filaments overwrites the user's support-material profile with slot 1's.
  906. # ---------------------------------------------------------------------------
  907. class TestExtractSupportFilamentSlotsFrom3mf:
  908. def test_pla_object_plus_pva_support_returns_support_slot(self):
  909. # The reporter's exact scenario (#1881): slot 1 = PLA (model),
  910. # slot 2 = PVA (support). enable_support on. Without this the
  911. # substitute logic replaces slot 2's PVA profile with PLA and
  912. # the printed supports come out in PLA.
  913. cfg = json.dumps(
  914. {
  915. "enable_support": "1",
  916. "support_filament": "2",
  917. "support_interface_filament": "2",
  918. "filament_type": ["PLA", "PVA"],
  919. }
  920. )
  921. with _make_3mf_with({"Metadata/project_settings.config": cfg}) as zf:
  922. assert extract_support_filament_slots_from_3mf(zf) == {2}
  923. def test_distinct_support_body_and_interface_slots(self):
  924. cfg = json.dumps(
  925. {
  926. "enable_support": "1",
  927. "support_filament": "2",
  928. "support_interface_filament": "3",
  929. }
  930. )
  931. with _make_3mf_with({"Metadata/project_settings.config": cfg}) as zf:
  932. assert extract_support_filament_slots_from_3mf(zf) == {2, 3}
  933. def test_supports_disabled_returns_empty(self):
  934. # enable_support off — supports won't be printed even if a slot is
  935. # configured. Don't force it into the "used" set; substitution
  936. # should still homogenise the loaded-filament array.
  937. cfg = json.dumps(
  938. {
  939. "enable_support": "0",
  940. "support_filament": "2",
  941. "support_interface_filament": "2",
  942. }
  943. )
  944. with _make_3mf_with({"Metadata/project_settings.config": cfg}) as zf:
  945. assert extract_support_filament_slots_from_3mf(zf) == set()
  946. def test_slot_zero_treated_as_same_as_model(self):
  947. # BambuStudio's `0` for support_filament means "same as model" —
  948. # no dedicated slot to preserve.
  949. cfg = json.dumps(
  950. {
  951. "enable_support": "1",
  952. "support_filament": "0",
  953. "support_interface_filament": "0",
  954. }
  955. )
  956. with _make_3mf_with({"Metadata/project_settings.config": cfg}) as zf:
  957. assert extract_support_filament_slots_from_3mf(zf) == set()
  958. def test_boolean_enable_support_accepted(self):
  959. # Some forks / older versions write a real JSON bool instead of "1"/"0".
  960. cfg = json.dumps({"enable_support": True, "support_filament": "2"})
  961. with _make_3mf_with({"Metadata/project_settings.config": cfg}) as zf:
  962. assert extract_support_filament_slots_from_3mf(zf) == {2}
  963. def test_integer_slot_value_accepted(self):
  964. cfg = json.dumps({"enable_support": "1", "support_filament": 3})
  965. with _make_3mf_with({"Metadata/project_settings.config": cfg}) as zf:
  966. assert extract_support_filament_slots_from_3mf(zf) == {3}
  967. def test_missing_project_settings_returns_empty(self):
  968. with _make_3mf_with({"placeholder.txt": "hi"}) as zf:
  969. assert extract_support_filament_slots_from_3mf(zf) == set()
  970. def test_malformed_json_returns_empty(self):
  971. with _make_3mf_with({"Metadata/project_settings.config": b"{not json"}) as zf:
  972. assert extract_support_filament_slots_from_3mf(zf) == set()
  973. def test_root_is_list_returns_empty(self):
  974. with _make_3mf_with({"Metadata/project_settings.config": json.dumps([])}) as zf:
  975. assert extract_support_filament_slots_from_3mf(zf) == set()
  976. def test_non_numeric_slot_value_skipped(self):
  977. cfg = json.dumps({"enable_support": "1", "support_filament": "not-a-number"})
  978. with _make_3mf_with({"Metadata/project_settings.config": cfg}) as zf:
  979. assert extract_support_filament_slots_from_3mf(zf) == set()
  980. class TestExtractPlateMetadataFrom3mf:
  981. """The combined per-plate helper parses slice_info.config once and caches
  982. the result by file revision so queue polling doesn't re-open the same 3MF
  983. three times per row on every poll (#2573)."""
  984. _MULTI_PLATE = """<?xml version="1.0" encoding="UTF-8"?>
  985. <config>
  986. <plate>
  987. <metadata key="index" value="1"/>
  988. <metadata key="prediction" value="3600"/>
  989. <metadata key="curr_bed_type" value="Textured PEI Plate"/>
  990. <filament id="1" used_g="50.0" type="PLA" color="#FF0000"/>
  991. </plate>
  992. <plate>
  993. <metadata key="index" value="2"/>
  994. <metadata key="prediction" value="7200"/>
  995. <metadata key="curr_bed_type" value="Engineering Plate"/>
  996. <filament id="1" used_g="12.5" type="ABS" color="#00FF00"/>
  997. <filament id="2" used_g="7.5" type="ABS" color="#0000FF"/>
  998. </plate>
  999. </config>
  1000. """
  1001. def _write(self, tmp_path, xml, name="test.3mf"):
  1002. from backend.app.utils.threemf_tools import clear_plate_metadata_cache
  1003. clear_plate_metadata_cache()
  1004. file_path = tmp_path / name
  1005. file_path.write_bytes(create_mock_3mf(xml).read())
  1006. return file_path
  1007. def test_combines_all_three_fields_for_plate(self, tmp_path):
  1008. from backend.app.utils.threemf_tools import extract_plate_metadata_from_3mf
  1009. file_path = self._write(tmp_path, self._MULTI_PLATE)
  1010. meta = extract_plate_metadata_from_3mf(file_path, plate_id=2)
  1011. assert meta.print_time_seconds == 7200
  1012. assert meta.bed_type == "Engineering Plate"
  1013. assert meta.filament_used_grams == 20.0
  1014. assert {f["slot_id"] for f in meta.filament_usage} == {1, 2}
  1015. def test_plate_id_none_matches_legacy_behaviour(self, tmp_path):
  1016. # Legacy None behaviour: time+bed from the first plate, but usage
  1017. # collects EVERY filament in the file (not just plate 1).
  1018. from backend.app.utils.threemf_tools import extract_plate_metadata_from_3mf
  1019. file_path = self._write(tmp_path, self._MULTI_PLATE)
  1020. meta = extract_plate_metadata_from_3mf(file_path, plate_id=None)
  1021. assert meta.print_time_seconds == 3600
  1022. assert meta.bed_type == "Textured PEI Plate"
  1023. assert len(meta.filament_usage) == 3 # 1 from plate 1 + 2 from plate 2
  1024. def test_second_call_hits_cache_without_reparsing(self, tmp_path):
  1025. from unittest.mock import patch
  1026. import backend.app.utils.threemf_tools as tools
  1027. file_path = self._write(tmp_path, self._MULTI_PLATE)
  1028. with patch.object(tools, "_parse_plate_metadata_uncached", wraps=tools._parse_plate_metadata_uncached) as spy:
  1029. first = tools.extract_plate_metadata_from_3mf(file_path, plate_id=2)
  1030. second = tools.extract_plate_metadata_from_3mf(file_path, plate_id=2)
  1031. assert spy.call_count == 1 # parsed once, served from cache the second time
  1032. assert first is second
  1033. assert second.print_time_seconds == 7200
  1034. def test_changed_file_reparses(self, tmp_path):
  1035. from unittest.mock import patch
  1036. import backend.app.utils.threemf_tools as tools
  1037. file_path = self._write(tmp_path, self._MULTI_PLATE)
  1038. with patch.object(tools, "_parse_plate_metadata_uncached", wraps=tools._parse_plate_metadata_uncached) as spy:
  1039. tools.extract_plate_metadata_from_3mf(file_path, plate_id=1)
  1040. # Replace the file with different content (and a different size, so the
  1041. # revision key changes even if mtime resolution is coarse).
  1042. new_xml = """<?xml version="1.0" encoding="UTF-8"?>
  1043. <config>
  1044. <plate>
  1045. <metadata key="index" value="1"/>
  1046. <metadata key="prediction" value="999"/>
  1047. <metadata key="curr_bed_type" value="Cool Plate"/>
  1048. <filament id="1" used_g="1.0" type="PLA" color="#FFFFFF"/>
  1049. </plate>
  1050. </config>
  1051. """
  1052. file_path.write_bytes(create_mock_3mf(new_xml).read())
  1053. fresh = tools.extract_plate_metadata_from_3mf(file_path, plate_id=1)
  1054. assert spy.call_count == 2 # revision changed -> re-parsed
  1055. assert fresh.print_time_seconds == 999
  1056. assert fresh.bed_type == "Cool Plate"
  1057. def test_wrapper_returns_mutable_copy(self, tmp_path):
  1058. # extract_filament_usage_from_3mf callers mutate the list; that must not
  1059. # corrupt the shared cached PlateMetadata.
  1060. from backend.app.utils.threemf_tools import (
  1061. extract_filament_usage_from_3mf,
  1062. extract_plate_metadata_from_3mf,
  1063. )
  1064. file_path = self._write(tmp_path, self._MULTI_PLATE)
  1065. usage = extract_filament_usage_from_3mf(file_path, plate_id=2)
  1066. usage.append({"slot_id": 99, "used_g": 0.0, "type": "", "color": ""})
  1067. usage[0]["used_g"] = -1.0
  1068. cached = extract_plate_metadata_from_3mf(file_path, plate_id=2)
  1069. assert len(cached.filament_usage) == 2
  1070. assert all(f["used_g"] > 0 for f in cached.filament_usage)
  1071. def test_non_numeric_filament_id_is_skipped_not_raised(self, tmp_path):
  1072. # A garbage filament id (or used_g) must be silently skipped, exactly as
  1073. # the legacy helpers did — a raise here would 500 the queue listing that
  1074. # calls this per row. Guards both the plate-specific and plate_id=None paths.
  1075. from backend.app.utils.threemf_tools import (
  1076. extract_filament_usage_from_3mf,
  1077. extract_plate_metadata_from_3mf,
  1078. )
  1079. xml_content = """<?xml version="1.0" encoding="UTF-8"?>
  1080. <config>
  1081. <plate>
  1082. <metadata key="index" value="1"/>
  1083. <metadata key="prediction" value="3600"/>
  1084. <filament id="abc" used_g="5.0" type="PLA" color="#FFFFFF"/>
  1085. <filament id="1" used_g="10.0" type="PLA" color="#FF0000"/>
  1086. <filament id="2" used_g="bad" type="PLA" color="#00FF00"/>
  1087. </plate>
  1088. </config>
  1089. """
  1090. file_path = self._write(tmp_path, xml_content)
  1091. meta = extract_plate_metadata_from_3mf(file_path, plate_id=1)
  1092. assert [f["slot_id"] for f in meta.filament_usage] == [1]
  1093. assert meta.filament_used_grams == 10.0
  1094. assert meta.print_time_seconds == 3600
  1095. # plate_id=None path (collects all filaments in the file) must skip too.
  1096. none_result = extract_filament_usage_from_3mf(file_path, plate_id=None)
  1097. assert [f["slot_id"] for f in none_result] == [1]
  1098. def test_missing_file_returns_empty_and_is_not_cached(self, tmp_path):
  1099. from unittest.mock import patch
  1100. import backend.app.utils.threemf_tools as tools
  1101. tools.clear_plate_metadata_cache()
  1102. missing = tmp_path / "nope.3mf"
  1103. with patch.object(tools, "_parse_plate_metadata_uncached", wraps=tools._parse_plate_metadata_uncached) as spy:
  1104. meta = tools.extract_plate_metadata_from_3mf(missing, plate_id=1)
  1105. tools.extract_plate_metadata_from_3mf(missing, plate_id=1)
  1106. assert meta.print_time_seconds is None
  1107. assert meta.filament_usage == []
  1108. # Missing file must not create a sticky cache entry (it may appear later).
  1109. assert spy.call_count == 2
  1110. def _make_plate_3mf(tmp_path, gcode_by_name: dict[str, str], name: str = "print.3mf"):
  1111. """Write a 3MF containing the given plate G-code members."""
  1112. buffer = io.BytesIO()
  1113. with zipfile.ZipFile(buffer, "w") as zf:
  1114. for member, content in gcode_by_name.items():
  1115. zf.writestr(member, content)
  1116. buffer.seek(0)
  1117. path = tmp_path / name
  1118. path.write_bytes(buffer.read())
  1119. return path
  1120. def _header(**values: str) -> str:
  1121. lines = ["; HEADER_BLOCK_START"]
  1122. lines += [f"; {key.replace('_', ' ')}: {value}" for key, value in values.items()]
  1123. lines.append("; HEADER_BLOCK_END")
  1124. lines.append("G1 X0 Y0")
  1125. return "\n".join(lines)
  1126. class TestExtractMaxZHeightFrom3mf:
  1127. """#2547: the print's top Z, used to command the plate back into camera
  1128. framing before the finish photo.
  1129. This value becomes the target of a real Z move, so "don't know" has to be
  1130. reported as None rather than defaulted — a wrong height would drive the
  1131. nozzle into the part.
  1132. """
  1133. def test_reads_max_z_height_from_the_plate_header(self, tmp_path):
  1134. path = _make_plate_3mf(
  1135. tmp_path,
  1136. {"Metadata/plate_1.gcode": _header(max_z_height="16.00", total_layer_number="80")},
  1137. )
  1138. assert extract_max_z_height_from_3mf(path, 1) == 16.0
  1139. def test_picks_the_requested_plate(self, tmp_path):
  1140. path = _make_plate_3mf(
  1141. tmp_path,
  1142. {
  1143. "Metadata/plate_1.gcode": _header(max_z_height="16.00"),
  1144. "Metadata/plate_2.gcode": _header(max_z_height="42.50"),
  1145. },
  1146. )
  1147. assert extract_max_z_height_from_3mf(path, 2) == 42.5
  1148. def test_falls_back_to_the_only_gcode_when_the_plate_name_does_not_match(self, tmp_path):
  1149. """Files from slicers that don't use Bambu's plate naming still resolve."""
  1150. path = _make_plate_3mf(tmp_path, {"whatever.gcode": _header(max_z_height="7.25")})
  1151. assert extract_max_z_height_from_3mf(path, 3) == 7.25
  1152. def test_missing_header_key_returns_none(self, tmp_path):
  1153. path = _make_plate_3mf(tmp_path, {"Metadata/plate_1.gcode": _header(total_layer_number="80")})
  1154. assert extract_max_z_height_from_3mf(path, 1) is None
  1155. def test_non_numeric_value_returns_none(self, tmp_path):
  1156. path = _make_plate_3mf(tmp_path, {"Metadata/plate_1.gcode": _header(max_z_height="tall")})
  1157. assert extract_max_z_height_from_3mf(path, 1) is None
  1158. def test_zero_and_negative_are_treated_as_unknown(self, tmp_path):
  1159. """Passed through, either would become a Z move toward the bed."""
  1160. zero = _make_plate_3mf(tmp_path, {"Metadata/plate_1.gcode": _header(max_z_height="0")}, "z.3mf")
  1161. negative = _make_plate_3mf(tmp_path, {"Metadata/plate_1.gcode": _header(max_z_height="-3")}, "n.3mf")
  1162. assert extract_max_z_height_from_3mf(zero, 1) is None
  1163. assert extract_max_z_height_from_3mf(negative, 1) is None
  1164. def test_no_gcode_member_returns_none(self, tmp_path):
  1165. path = _make_plate_3mf(tmp_path, {"Metadata/slice_info.config": "<config/>"})
  1166. assert extract_max_z_height_from_3mf(path, 1) is None
  1167. def test_unreadable_file_returns_none(self, tmp_path):
  1168. path = tmp_path / "broken.3mf"
  1169. path.write_text("not a zip")
  1170. assert extract_max_z_height_from_3mf(path, 1) is None
  1171. def test_missing_file_returns_none(self, tmp_path):
  1172. assert extract_max_z_height_from_3mf(tmp_path / "nope.3mf", 1) is None
  1173. def test_only_the_header_is_inflated(self, tmp_path):
  1174. """A sliced plate is routinely tens of MB; reading it whole to reach ~40
  1175. header lines would stall the finish-photo path. The header is read from
  1176. a bounded prefix, so a huge body must not change the answer."""
  1177. gcode = _header(max_z_height="99.9") + "\n" + ("G1 X1 Y1 E0.1\n" * 400_000)
  1178. path = _make_plate_3mf(tmp_path, {"Metadata/plate_1.gcode": gcode})
  1179. assert extract_max_z_height_from_3mf(path, 1) == 99.9
  1180. def _layer_gcode(per_layer_mm: float, layers: int) -> str:
  1181. """G-code extruding a fixed amount on filament 0 for each of ``layers``."""
  1182. lines = ["M620 S0"]
  1183. for layer in range(1, layers + 1):
  1184. lines.append(f"M73 L{layer}")
  1185. lines.append(f"G1 X1 Y1 E{per_layer_mm}")
  1186. return "\n".join(lines)
  1187. class TestExtractLayerFilamentUsagePlateSelection:
  1188. """The per-layer extract feeds the mid-print tray split and partial-print
  1189. scaling, so reading a different plate than the one that printed silently
  1190. misattributes filament.
  1191. Bambu Studio writes ``plate_2.gcode`` ahead of ``plate_1.gcode`` in the
  1192. zip, so "first member" is not "first plate".
  1193. """
  1194. def test_reads_the_requested_plate_not_the_first_member(self, tmp_path):
  1195. path = _make_plate_3mf(
  1196. tmp_path,
  1197. {
  1198. "Metadata/plate_2.gcode": _layer_gcode(1.0, 5),
  1199. "Metadata/plate_1.gcode": _layer_gcode(10.0, 8),
  1200. },
  1201. )
  1202. usage = extract_layer_filament_usage_from_3mf(path, 1)
  1203. assert usage is not None
  1204. assert get_cumulative_usage_at_layer(usage, 8)[0] == pytest.approx(80.0)
  1205. def test_plate_two_reads_plate_two(self, tmp_path):
  1206. path = _make_plate_3mf(
  1207. tmp_path,
  1208. {
  1209. "Metadata/plate_2.gcode": _layer_gcode(1.0, 5),
  1210. "Metadata/plate_1.gcode": _layer_gcode(10.0, 8),
  1211. },
  1212. )
  1213. usage = extract_layer_filament_usage_from_3mf(path, 2)
  1214. assert usage is not None
  1215. assert get_cumulative_usage_at_layer(usage, 5)[0] == pytest.approx(5.0)
  1216. def test_no_plate_asked_for_takes_the_lowest_numbered_plate(self, tmp_path):
  1217. path = _make_plate_3mf(
  1218. tmp_path,
  1219. {
  1220. "Metadata/plate_2.gcode": _layer_gcode(1.0, 5),
  1221. "Metadata/plate_1.gcode": _layer_gcode(10.0, 8),
  1222. },
  1223. )
  1224. usage = extract_layer_filament_usage_from_3mf(path)
  1225. assert usage is not None
  1226. assert get_cumulative_usage_at_layer(usage, 8)[0] == pytest.approx(80.0)
  1227. def test_requested_plate_missing_returns_none_rather_than_another_plate(self, tmp_path):
  1228. """Callers degrade to linear scaling, which is bounded. Silently
  1229. reading a different plate's layers is not."""
  1230. path = _make_plate_3mf(
  1231. tmp_path,
  1232. {
  1233. "Metadata/plate_1.gcode": _layer_gcode(10.0, 8),
  1234. "Metadata/plate_2.gcode": _layer_gcode(1.0, 5),
  1235. },
  1236. )
  1237. assert extract_layer_filament_usage_from_3mf(path, 3) is None
  1238. def test_single_unnumbered_gcode_is_used_for_any_plate(self, tmp_path):
  1239. """Slicers outside Bambu's plate_N convention export one G-code member;
  1240. it is unambiguous whatever plate the queue recorded."""
  1241. path = _make_plate_3mf(tmp_path, {"whatever.gcode": _layer_gcode(2.0, 4)})
  1242. usage = extract_layer_filament_usage_from_3mf(path, 1)
  1243. assert usage is not None
  1244. assert get_cumulative_usage_at_layer(usage, 4)[0] == pytest.approx(8.0)
  1245. def test_no_gcode_member_returns_none(self, tmp_path):
  1246. path = _make_plate_3mf(tmp_path, {"Metadata/slice_info.config": "<config/>"})
  1247. assert extract_layer_filament_usage_from_3mf(path, 1) is None
  1248. def test_unreadable_file_returns_none(self, tmp_path):
  1249. path = tmp_path / "broken.3mf"
  1250. path.write_text("not a zip")
  1251. assert extract_layer_filament_usage_from_3mf(path, 1) is None