test_threemf_tools.py 66 KB

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