test_threemf_tools.py 60 KB

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