test_threemf_tools.py 36 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943
  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. from backend.app.utils.threemf_tools import (
  10. extract_bed_type_from_3mf,
  11. extract_embedded_presets_from_3mf,
  12. extract_filament_usage_from_3mf,
  13. extract_plate_extruder_set_from_3mf,
  14. extract_print_time_from_3mf,
  15. extract_project_filaments_from_3mf,
  16. get_cumulative_usage_at_layer,
  17. mm_to_grams,
  18. parse_gcode_layer_filament_usage,
  19. )
  20. def create_mock_3mf(slice_info_content: str) -> io.BytesIO:
  21. """Create a mock 3MF file (ZIP) with slice_info.config content."""
  22. buffer = io.BytesIO()
  23. with zipfile.ZipFile(buffer, "w") as zf:
  24. zf.writestr("Metadata/slice_info.config", slice_info_content)
  25. buffer.seek(0)
  26. return buffer
  27. class TestParseGcodeLayerFilamentUsage:
  28. """Tests for parse_gcode_layer_filament_usage()."""
  29. def test_single_filament_single_layer(self):
  30. """Single filament extruding on one layer."""
  31. gcode = """
  32. M620 S0
  33. G1 X10 Y10 E5.0
  34. G1 X20 Y20 E3.0
  35. """
  36. result = parse_gcode_layer_filament_usage(gcode)
  37. assert result == {0: {0: 8.0}}
  38. def test_multi_layer_single_filament(self):
  39. """Single filament across multiple layers."""
  40. gcode = """
  41. M620 S0
  42. G1 X10 Y10 E10.0
  43. M73 L1
  44. G1 X20 Y20 E5.0
  45. M73 L2
  46. G1 X30 Y30 E7.0
  47. """
  48. result = parse_gcode_layer_filament_usage(gcode)
  49. assert result[0] == {0: 10.0}
  50. assert result[1] == {0: 15.0}
  51. assert result[2] == {0: 22.0}
  52. def test_multi_material(self):
  53. """Multiple filaments switching via M620."""
  54. gcode = """
  55. M620 S0
  56. G1 E10.0
  57. M73 L1
  58. M620 S1
  59. G1 E5.0
  60. M620 S0
  61. G1 E3.0
  62. M73 L2
  63. G1 E2.0
  64. """
  65. result = parse_gcode_layer_filament_usage(gcode)
  66. # Layer 0: filament 0 = 10mm
  67. assert result[0] == {0: 10.0}
  68. # Layer 1: filament 0 = 13mm (10+3), filament 1 = 5mm
  69. assert result[1] == {0: 13.0, 1: 5.0}
  70. # Layer 2: filament 0 = 15mm (13+2)
  71. assert result[2] == {0: 15.0, 1: 5.0}
  72. def test_retractions_ignored(self):
  73. """Negative E values (retractions) should be ignored."""
  74. gcode = """
  75. M620 S0
  76. G1 E10.0
  77. G1 E-2.0
  78. G1 E5.0
  79. """
  80. result = parse_gcode_layer_filament_usage(gcode)
  81. assert result == {0: {0: 15.0}}
  82. def test_m620_s255_unloads(self):
  83. """M620 S255 means unload - extrusion after should be ignored."""
  84. gcode = """
  85. M620 S0
  86. G1 E10.0
  87. M620 S255
  88. G1 E5.0
  89. """
  90. result = parse_gcode_layer_filament_usage(gcode)
  91. assert result == {0: {0: 10.0}}
  92. def test_m620_with_suffix(self):
  93. """M620 S0A format (filament ID with suffix letter)."""
  94. gcode = """
  95. M620 S0A
  96. G1 E10.0
  97. M620 S1A
  98. G1 E5.0
  99. """
  100. result = parse_gcode_layer_filament_usage(gcode)
  101. assert result == {0: {0: 10.0, 1: 5.0}}
  102. def test_comments_ignored(self):
  103. """Comment lines and inline comments are ignored."""
  104. gcode = """
  105. ; This is a comment
  106. M620 S0
  107. G1 X10 E5.0 ; inline comment with E value
  108. G1 E3.0
  109. """
  110. result = parse_gcode_layer_filament_usage(gcode)
  111. assert result == {0: {0: 8.0}}
  112. def test_empty_gcode(self):
  113. """Empty G-code returns empty dict."""
  114. assert parse_gcode_layer_filament_usage("") == {}
  115. assert parse_gcode_layer_filament_usage("\n\n\n") == {}
  116. def test_no_extrusion(self):
  117. """G-code with moves but no extrusion."""
  118. gcode = """
  119. G1 X10 Y10
  120. G1 X20 Y20
  121. """
  122. assert parse_gcode_layer_filament_usage(gcode) == {}
  123. def test_no_active_filament_extrusion_ignored(self):
  124. """Extrusion before any M620 is ignored (no active filament)."""
  125. gcode = """
  126. G1 E10.0
  127. M620 S0
  128. G1 E5.0
  129. """
  130. result = parse_gcode_layer_filament_usage(gcode)
  131. assert result == {0: {0: 5.0}}
  132. def test_g0_g2_g3_extrusion(self):
  133. """G0, G2, G3 with E parameter are also tracked."""
  134. gcode = """
  135. M620 S0
  136. G0 E1.0
  137. G1 E2.0
  138. G2 E3.0
  139. G3 E4.0
  140. """
  141. result = parse_gcode_layer_filament_usage(gcode)
  142. assert result == {0: {0: 10.0}}
  143. def test_cumulative_across_layers(self):
  144. """Values are cumulative, not per-layer."""
  145. gcode = """
  146. M620 S0
  147. G1 E100.0
  148. M73 L1
  149. G1 E100.0
  150. M73 L2
  151. G1 E100.0
  152. """
  153. result = parse_gcode_layer_filament_usage(gcode)
  154. assert result[0] == {0: 100.0}
  155. assert result[1] == {0: 200.0}
  156. assert result[2] == {0: 300.0}
  157. class TestMmToGrams:
  158. """Tests for mm_to_grams()."""
  159. def test_default_pla_175(self):
  160. """Default PLA 1.75mm conversion."""
  161. # 1000mm of 1.75mm PLA at 1.24 g/cm³
  162. # Volume = π × (0.0875cm)² × 100cm = 2.405cm³
  163. # Weight = 2.405 × 1.24 = 2.982g
  164. result = mm_to_grams(1000.0)
  165. expected = math.pi * (0.0875**2) * 100 * 1.24
  166. assert abs(result - expected) < 0.001
  167. def test_zero_length(self):
  168. """Zero length returns zero weight."""
  169. assert mm_to_grams(0.0) == 0.0
  170. def test_custom_diameter(self):
  171. """Custom diameter (2.85mm) changes result."""
  172. result_175 = mm_to_grams(1000.0, diameter_mm=1.75)
  173. result_285 = mm_to_grams(1000.0, diameter_mm=2.85)
  174. # 2.85mm filament has more volume per mm
  175. assert result_285 > result_175
  176. ratio = (2.85 / 1.75) ** 2 # Volume scales with diameter²
  177. assert abs(result_285 / result_175 - ratio) < 0.001
  178. def test_custom_density(self):
  179. """Different density (ABS vs PLA)."""
  180. pla = mm_to_grams(1000.0, density_g_cm3=1.24)
  181. abs_ = mm_to_grams(1000.0, density_g_cm3=1.04)
  182. assert pla > abs_
  183. assert abs(pla / abs_ - 1.24 / 1.04) < 0.001
  184. def test_known_value(self):
  185. """Verify against a known calculation.
  186. 1m (1000mm) of 1.75mm PLA at 1.24 g/cm³:
  187. r = 0.0875 cm, L = 100 cm
  188. V = π × 0.0875² × 100 = 2.4053 cm³
  189. m = 2.4053 × 1.24 = 2.9826 g
  190. """
  191. result = mm_to_grams(1000.0, 1.75, 1.24)
  192. assert abs(result - 2.9826) < 0.01
  193. class TestGetCumulativeUsageAtLayer:
  194. """Tests for get_cumulative_usage_at_layer()."""
  195. def test_exact_layer_match(self):
  196. """Target layer exists exactly in the data."""
  197. data = {0: {0: 100.0}, 5: {0: 500.0}, 10: {0: 1000.0}}
  198. assert get_cumulative_usage_at_layer(data, 5) == {0: 500.0}
  199. def test_between_layers(self):
  200. """Target is between recorded layers - uses the closest lower one."""
  201. data = {0: {0: 100.0}, 5: {0: 500.0}, 10: {0: 1000.0}}
  202. # Layer 7 is between 5 and 10, should return layer 5's data
  203. assert get_cumulative_usage_at_layer(data, 7) == {0: 500.0}
  204. def test_beyond_last_layer(self):
  205. """Target is beyond the last recorded layer."""
  206. data = {0: {0: 100.0}, 5: {0: 500.0}}
  207. assert get_cumulative_usage_at_layer(data, 100) == {0: 500.0}
  208. def test_before_first_layer(self):
  209. """Target is before any recorded data."""
  210. data = {5: {0: 500.0}, 10: {0: 1000.0}}
  211. assert get_cumulative_usage_at_layer(data, 3) == {}
  212. def test_empty_data(self):
  213. """Empty layer_usage returns empty dict."""
  214. assert get_cumulative_usage_at_layer({}, 5) == {}
  215. def test_none_data(self):
  216. """None layer_usage returns empty dict."""
  217. assert get_cumulative_usage_at_layer(None, 5) == {}
  218. def test_multi_filament(self):
  219. """Multi-filament data at target layer."""
  220. data = {
  221. 0: {0: 50.0},
  222. 5: {0: 200.0, 1: 100.0},
  223. 10: {0: 400.0, 1: 250.0, 2: 50.0},
  224. }
  225. result = get_cumulative_usage_at_layer(data, 8)
  226. assert result == {0: 200.0, 1: 100.0}
  227. def test_layer_zero(self):
  228. """Target layer 0."""
  229. data = {0: {0: 10.0}, 1: {0: 20.0}}
  230. assert get_cumulative_usage_at_layer(data, 0) == {0: 10.0}
  231. class TestExtractFilamentUsageFrom3mf:
  232. """Tests for extract_filament_usage_from_3mf function."""
  233. def test_extract_single_filament(self, tmp_path):
  234. """Test extracting a single filament."""
  235. xml_content = """<?xml version="1.0" encoding="UTF-8"?>
  236. <config>
  237. <filament id="1" used_g="50.5" type="PLA" color="#FF0000"/>
  238. </config>
  239. """
  240. mock_3mf = create_mock_3mf(xml_content)
  241. file_path = tmp_path / "test.3mf"
  242. file_path.write_bytes(mock_3mf.read())
  243. result = extract_filament_usage_from_3mf(file_path)
  244. assert len(result) == 1
  245. assert result[0]["slot_id"] == 1
  246. assert result[0]["used_g"] == 50.5
  247. assert result[0]["type"] == "PLA"
  248. assert result[0]["color"] == "#FF0000"
  249. def test_extract_multiple_filaments(self, tmp_path):
  250. """Test extracting multiple filaments."""
  251. xml_content = """<?xml version="1.0" encoding="UTF-8"?>
  252. <config>
  253. <filament id="1" used_g="50.5" type="PLA" color="#FF0000"/>
  254. <filament id="2" used_g="30.2" type="PETG" color="#00FF00"/>
  255. <filament id="3" used_g="10.0" type="ABS" color="#0000FF"/>
  256. </config>
  257. """
  258. mock_3mf = create_mock_3mf(xml_content)
  259. file_path = tmp_path / "test.3mf"
  260. file_path.write_bytes(mock_3mf.read())
  261. result = extract_filament_usage_from_3mf(file_path)
  262. assert len(result) == 3
  263. assert result[0]["slot_id"] == 1
  264. assert result[1]["slot_id"] == 2
  265. assert result[2]["slot_id"] == 3
  266. def test_extract_filament_with_plate_id(self, tmp_path):
  267. """Test extracting filament for a specific plate."""
  268. xml_content = """<?xml version="1.0" encoding="UTF-8"?>
  269. <config>
  270. <plate>
  271. <metadata key="index" value="1"/>
  272. <filament id="1" used_g="25.0" type="PLA" color="#FF0000"/>
  273. </plate>
  274. <plate>
  275. <metadata key="index" value="2"/>
  276. <filament id="1" used_g="75.0" type="PETG" color="#00FF00"/>
  277. </plate>
  278. </config>
  279. """
  280. mock_3mf = create_mock_3mf(xml_content)
  281. file_path = tmp_path / "test.3mf"
  282. file_path.write_bytes(mock_3mf.read())
  283. result = extract_filament_usage_from_3mf(file_path, plate_id=2)
  284. assert len(result) == 1
  285. assert result[0]["used_g"] == 75.0
  286. assert result[0]["type"] == "PETG"
  287. def test_missing_slice_info_returns_empty(self, tmp_path):
  288. """Test that missing slice_info.config returns empty list."""
  289. buffer = io.BytesIO()
  290. with zipfile.ZipFile(buffer, "w") as zf:
  291. zf.writestr("other_file.txt", "content")
  292. buffer.seek(0)
  293. file_path = tmp_path / "test.3mf"
  294. file_path.write_bytes(buffer.read())
  295. result = extract_filament_usage_from_3mf(file_path)
  296. assert result == []
  297. def test_invalid_file_returns_empty(self, tmp_path):
  298. """Test that invalid file returns empty list."""
  299. file_path = tmp_path / "invalid.3mf"
  300. file_path.write_text("not a zip file")
  301. result = extract_filament_usage_from_3mf(file_path)
  302. assert result == []
  303. def test_nonexistent_file_returns_empty(self, tmp_path):
  304. """Test that nonexistent file returns empty list."""
  305. file_path = tmp_path / "nonexistent.3mf"
  306. result = extract_filament_usage_from_3mf(file_path)
  307. assert result == []
  308. def test_filament_without_id_is_skipped(self, tmp_path):
  309. """Test that filament without id is skipped."""
  310. xml_content = """<?xml version="1.0" encoding="UTF-8"?>
  311. <config>
  312. <filament used_g="50.5" type="PLA" color="#FF0000"/>
  313. <filament id="2" used_g="30.0" type="PETG" color="#00FF00"/>
  314. </config>
  315. """
  316. mock_3mf = create_mock_3mf(xml_content)
  317. file_path = tmp_path / "test.3mf"
  318. file_path.write_bytes(mock_3mf.read())
  319. result = extract_filament_usage_from_3mf(file_path)
  320. assert len(result) == 1
  321. assert result[0]["slot_id"] == 2
  322. def test_invalid_used_g_is_skipped(self, tmp_path):
  323. """Test that filament with invalid used_g is skipped."""
  324. xml_content = """<?xml version="1.0" encoding="UTF-8"?>
  325. <config>
  326. <filament id="1" used_g="invalid" type="PLA" color="#FF0000"/>
  327. <filament id="2" used_g="30.0" type="PETG" color="#00FF00"/>
  328. </config>
  329. """
  330. mock_3mf = create_mock_3mf(xml_content)
  331. file_path = tmp_path / "test.3mf"
  332. file_path.write_bytes(mock_3mf.read())
  333. result = extract_filament_usage_from_3mf(file_path)
  334. assert len(result) == 1
  335. assert result[0]["slot_id"] == 2
  336. def test_missing_optional_fields(self, tmp_path):
  337. """Test that missing type and color default to empty string."""
  338. xml_content = """<?xml version="1.0" encoding="UTF-8"?>
  339. <config>
  340. <filament id="1" used_g="50.5"/>
  341. </config>
  342. """
  343. mock_3mf = create_mock_3mf(xml_content)
  344. file_path = tmp_path / "test.3mf"
  345. file_path.write_bytes(mock_3mf.read())
  346. result = extract_filament_usage_from_3mf(file_path)
  347. assert len(result) == 1
  348. assert result[0]["type"] == ""
  349. assert result[0]["color"] == ""
  350. # ---------------------------------------------------------------------------
  351. # Tests for extract_project_filaments_from_3mf — used by the slice modal as
  352. # fallback when the sidecar can't run a preview slice.
  353. # ---------------------------------------------------------------------------
  354. def _make_3mf_with(files: dict[str, bytes | str]) -> zipfile.ZipFile:
  355. buf = io.BytesIO()
  356. with zipfile.ZipFile(buf, "w") as zf:
  357. for name, content in files.items():
  358. zf.writestr(name, content if isinstance(content, (bytes, str)) else str(content))
  359. buf.seek(0)
  360. return zipfile.ZipFile(buf, "r")
  361. class TestExtractProjectFilamentsFrom3mf:
  362. """The helper backfills the slice modal when slice_info.config is empty
  363. (raw project files) and the sidecar is unreachable."""
  364. def test_returns_empty_when_project_settings_missing(self):
  365. with _make_3mf_with({"placeholder.txt": "hi"}) as zf:
  366. assert extract_project_filaments_from_3mf(zf) == []
  367. def test_happy_path_returns_one_entry_per_slot(self):
  368. proj = {
  369. "filament_type": ["PLA", "PETG"],
  370. "filament_colour": ["#000000", "#FFFFFF"],
  371. }
  372. with _make_3mf_with({"Metadata/project_settings.config": json.dumps(proj)}) as zf:
  373. out = extract_project_filaments_from_3mf(zf)
  374. assert [(f["slot_id"], f["type"], f["color"]) for f in out] == [
  375. (1, "PLA", "#000000"),
  376. (2, "PETG", "#FFFFFF"),
  377. ]
  378. def test_mismatched_array_lengths_use_max_with_blanks(self):
  379. proj = {
  380. "filament_type": ["PLA", "PETG", "ABS"],
  381. "filament_colour": ["#000000"],
  382. }
  383. with _make_3mf_with({"Metadata/project_settings.config": json.dumps(proj)}) as zf:
  384. out = extract_project_filaments_from_3mf(zf)
  385. assert len(out) == 3
  386. assert out[0]["color"] == "#000000"
  387. assert out[1]["color"] == ""
  388. assert out[2]["color"] == ""
  389. def test_corrupt_json_returns_empty_no_exception(self):
  390. with _make_3mf_with({"Metadata/project_settings.config": b"{not json"}) as zf:
  391. assert extract_project_filaments_from_3mf(zf) == []
  392. def test_root_is_list_returns_empty(self):
  393. # Defensive: spec says it's a dict, but a file shipping a top-level
  394. # list (or anything non-dict) shouldn't crash the modal.
  395. with _make_3mf_with({"Metadata/project_settings.config": json.dumps([])}) as zf:
  396. assert extract_project_filaments_from_3mf(zf) == []
  397. def test_empty_arrays_returns_empty(self):
  398. proj = {"filament_type": [], "filament_colour": []}
  399. with _make_3mf_with({"Metadata/project_settings.config": json.dumps(proj)}) as zf:
  400. assert extract_project_filaments_from_3mf(zf) == []
  401. # ---------------------------------------------------------------------------
  402. # Tests for extract_plate_extruder_set_from_3mf — three sources unioned:
  403. # object top-level extruder, per-part extruder, painted-face quadtree leaves.
  404. # ---------------------------------------------------------------------------
  405. def _model_settings(plate_id: int, objects: list[dict]) -> str:
  406. """Build a minimal model_settings.config XML for tests. Each object dict
  407. can have: id, extruder (top-level), parts (list of {extruder}).
  408. The plate references all object ids."""
  409. parts_xml = []
  410. for obj in objects:
  411. oid = obj["id"]
  412. ext = obj.get("extruder")
  413. parts = obj.get("parts", [])
  414. ext_meta = f'<metadata key="extruder" value="{ext}"/>' if ext is not None else ""
  415. part_blocks = "".join(
  416. f'<part id="{i}" subtype="normal_part"><metadata key="extruder" value="{p["extruder"]}"/></part>'
  417. for i, p in enumerate(parts)
  418. if p.get("extruder") is not None
  419. )
  420. parts_xml.append(f'<object id="{oid}"><metadata key="name" value="o{oid}"/>{ext_meta}{part_blocks}</object>')
  421. instances = "".join(
  422. f'<model_instance><metadata key="object_id" value="{o["id"]}"/></model_instance>' for o in objects
  423. )
  424. plate = f'<plate><metadata key="plater_id" value="{plate_id}"/>{instances}</plate>'
  425. return f'<?xml version="1.0"?><config>{"".join(parts_xml)}{plate}</config>'
  426. class TestExtractPlateExtruderSetFrom3mf:
  427. def test_returns_empty_set_when_model_settings_missing(self):
  428. with _make_3mf_with({"placeholder.txt": "hi"}) as zf:
  429. assert extract_plate_extruder_set_from_3mf(zf, plate_id=1) == set()
  430. def test_object_top_level_extruder_only(self):
  431. xml = _model_settings(plate_id=1, objects=[{"id": "10", "extruder": 2}])
  432. with _make_3mf_with({"Metadata/model_settings.config": xml}) as zf:
  433. assert extract_plate_extruder_set_from_3mf(zf, plate_id=1) == {2}
  434. def test_per_part_extruder_unions_with_top_level(self):
  435. # Object's default is 1; one of its parts overrides to 3 (multi-color
  436. # via a sub-mesh). Union both — the slicer needs profiles for both.
  437. xml = _model_settings(
  438. plate_id=1,
  439. objects=[{"id": "10", "extruder": 1, "parts": [{"extruder": 3}]}],
  440. )
  441. with _make_3mf_with({"Metadata/model_settings.config": xml}) as zf:
  442. assert extract_plate_extruder_set_from_3mf(zf, plate_id=1) == {1, 3}
  443. def test_unknown_plate_id_returns_empty_set(self):
  444. xml = _model_settings(plate_id=1, objects=[{"id": "10", "extruder": 2}])
  445. with _make_3mf_with({"Metadata/model_settings.config": xml}) as zf:
  446. assert extract_plate_extruder_set_from_3mf(zf, plate_id=99) == set()
  447. def test_corrupt_xml_returns_empty_set_no_exception(self):
  448. with _make_3mf_with({"Metadata/model_settings.config": "<not valid xml"}) as zf:
  449. assert extract_plate_extruder_set_from_3mf(zf, plate_id=1) == set()
  450. def test_zero_extruder_value_ignored(self):
  451. # Bambu's 0 means "use object default" — not a real slot.
  452. xml = _model_settings(plate_id=1, objects=[{"id": "10", "extruder": 0}])
  453. with _make_3mf_with({"Metadata/model_settings.config": xml}) as zf:
  454. assert extract_plate_extruder_set_from_3mf(zf, plate_id=1) == set()
  455. def test_painted_face_above_threshold_kept(self):
  456. # 60/40 split: 60 triangles painted with extruder 1, 40 with ext 2.
  457. # Threshold is 5%; both above. The dominant ones are real colours.
  458. triangles = []
  459. for _ in range(60):
  460. triangles.append('<triangle v1="0" v2="1" v3="2" paint_color="1"/>')
  461. for _ in range(40):
  462. triangles.append('<triangle v1="0" v2="1" v3="2" paint_color="2"/>')
  463. per_obj = (
  464. '<?xml version="1.0"?>'
  465. '<model><resources><object id="100" type="model"><mesh>'
  466. "<triangles>" + "".join(triangles) + "</triangles>"
  467. "</mesh></object></resources><build/></model>"
  468. )
  469. ms = (
  470. '<?xml version="1.0"?><config>'
  471. '<object id="10"><metadata key="name" value="o"/></object>'
  472. '<plate><metadata key="plater_id" value="1"/>'
  473. '<model_instance><metadata key="object_id" value="10"/></model_instance>'
  474. "</plate></config>"
  475. )
  476. threed = (
  477. '<?xml version="1.0"?>'
  478. '<model xmlns="http://schemas.microsoft.com/3dmanufacturing/core/2015/02"'
  479. ' xmlns:p="http://schemas.microsoft.com/3dmanufacturing/production/2015/06">'
  480. "<resources>"
  481. '<object id="10" type="model"><components>'
  482. '<component p:path="/3D/Objects/o100.model" objectid="100"/>'
  483. "</components></object>"
  484. "</resources><build/></model>"
  485. )
  486. with _make_3mf_with(
  487. {
  488. "Metadata/model_settings.config": ms,
  489. "3D/3dmodel.model": threed,
  490. "3D/Objects/o100.model": per_obj,
  491. }
  492. ) as zf:
  493. result = extract_plate_extruder_set_from_3mf(zf, plate_id=1)
  494. # Both real colours kept (60/40 well above 5% threshold); the dropped
  495. # threshold case is the regression that motivates this test.
  496. assert result == {1, 2}
  497. def test_painted_face_below_threshold_dropped_as_noise(self):
  498. # 99 triangles at ext 1, 1 triangle at ext 9 (1% — below 5%
  499. # threshold). The 1% leaf is a single-leaf accident.
  500. triangles = []
  501. for _ in range(99):
  502. triangles.append('<triangle v1="0" v2="1" v3="2" paint_color="1"/>')
  503. triangles.append('<triangle v1="0" v2="1" v3="2" paint_color="9"/>')
  504. per_obj = (
  505. '<?xml version="1.0"?>'
  506. '<model><resources><object id="100" type="model"><mesh>'
  507. "<triangles>" + "".join(triangles) + "</triangles>"
  508. "</mesh></object></resources><build/></model>"
  509. )
  510. ms = (
  511. '<?xml version="1.0"?><config>'
  512. '<object id="10"><metadata key="name" value="o"/></object>'
  513. '<plate><metadata key="plater_id" value="1"/>'
  514. '<model_instance><metadata key="object_id" value="10"/></model_instance>'
  515. "</plate></config>"
  516. )
  517. threed = (
  518. '<?xml version="1.0"?>'
  519. '<model xmlns="http://schemas.microsoft.com/3dmanufacturing/core/2015/02"'
  520. ' xmlns:p="http://schemas.microsoft.com/3dmanufacturing/production/2015/06">'
  521. '<resources><object id="10" type="model"><components>'
  522. '<component p:path="/3D/Objects/o100.model" objectid="100"/>'
  523. "</components></object></resources><build/></model>"
  524. )
  525. with _make_3mf_with(
  526. {
  527. "Metadata/model_settings.config": ms,
  528. "3D/3dmodel.model": threed,
  529. "3D/Objects/o100.model": per_obj,
  530. }
  531. ) as zf:
  532. result = extract_plate_extruder_set_from_3mf(zf, plate_id=1)
  533. # Single-leaf accident at 1% filtered as noise; only the dominant
  534. # extruder survives.
  535. assert result == {1}
  536. def test_missing_per_object_model_file_silently_skipped(self):
  537. ms = (
  538. '<?xml version="1.0"?><config>'
  539. '<object id="10"><metadata key="extruder" value="2"/></object>'
  540. '<plate><metadata key="plater_id" value="1"/>'
  541. '<model_instance><metadata key="object_id" value="10"/></model_instance>'
  542. "</plate></config>"
  543. )
  544. threed = (
  545. '<?xml version="1.0"?>'
  546. '<model xmlns="http://schemas.microsoft.com/3dmanufacturing/core/2015/02"'
  547. ' xmlns:p="http://schemas.microsoft.com/3dmanufacturing/production/2015/06">'
  548. '<resources><object id="10" type="model"><components>'
  549. '<component p:path="/3D/Objects/missing.model" objectid="999"/>'
  550. "</components></object></resources><build/></model>"
  551. )
  552. with _make_3mf_with(
  553. {"Metadata/model_settings.config": ms, "3D/3dmodel.model": threed},
  554. ) as zf:
  555. # Top-level metadata still works; missing component model file
  556. # is silently skipped without crashing.
  557. assert extract_plate_extruder_set_from_3mf(zf, plate_id=1) == {2}
  558. class TestExtractEmbeddedPresetsFrom3mf:
  559. """Printer / process preset names read from project_settings.config so the
  560. SliceModal can default its dropdowns to the file's own config (#1325)."""
  561. def test_extracts_printer_and_process(self):
  562. config = json.dumps(
  563. {
  564. "printer_settings_id": "Bambu Lab X1 Carbon 0.4 nozzle",
  565. "print_settings_id": "0.20mm Standard @BBL X1C",
  566. "filament_settings_id": ["Bambu PLA Basic @BBL X1C"],
  567. }
  568. )
  569. with _make_3mf_with({"Metadata/project_settings.config": config}) as zf:
  570. assert extract_embedded_presets_from_3mf(zf) == {
  571. "printer": "Bambu Lab X1 Carbon 0.4 nozzle",
  572. "process": "0.20mm Standard @BBL X1C",
  573. }
  574. def test_settings_id_as_list_takes_first(self):
  575. # Some exports write *_settings_id as a per-extruder list.
  576. config = json.dumps(
  577. {
  578. "printer_settings_id": ["Bambu Lab A1 0.4 nozzle"],
  579. "print_settings_id": ["0.16mm Optimal @BBL A1", "0.20mm @BBL A1"],
  580. }
  581. )
  582. with _make_3mf_with({"Metadata/project_settings.config": config}) as zf:
  583. result = extract_embedded_presets_from_3mf(zf)
  584. assert result["printer"] == "Bambu Lab A1 0.4 nozzle"
  585. assert result["process"] == "0.16mm Optimal @BBL A1"
  586. def test_missing_config_returns_none_values(self):
  587. with _make_3mf_with({"3D/3dmodel.model": "<model/>"}) as zf:
  588. assert extract_embedded_presets_from_3mf(zf) == {
  589. "printer": None,
  590. "process": None,
  591. }
  592. def test_malformed_json_returns_none_values(self):
  593. with _make_3mf_with({"Metadata/project_settings.config": "not json"}) as zf:
  594. assert extract_embedded_presets_from_3mf(zf) == {
  595. "printer": None,
  596. "process": None,
  597. }
  598. def test_blank_and_absent_keys_yield_none(self):
  599. config = json.dumps({"printer_settings_id": " ", "other": "x"})
  600. with _make_3mf_with({"Metadata/project_settings.config": config}) as zf:
  601. assert extract_embedded_presets_from_3mf(zf) == {
  602. "printer": None,
  603. "process": None,
  604. }
  605. class TestExtractBedTypeFrom3mf:
  606. """extract_bed_type_from_3mf reads per-plate `curr_bed_type` from
  607. slice_info.config so the queue / print modal can show the right plate
  608. even on multi-plate 3MFs where different plates target different beds
  609. (#1281). archive.bed_type is one-value-per-archive (first plate's
  610. curr_bed_type — see services/archive.py:235), so for accurate
  611. per-plate surfacing we have to re-read the 3MF."""
  612. def test_single_plate_returns_bed_type(self, tmp_path):
  613. xml_content = """<?xml version="1.0" encoding="UTF-8"?>
  614. <config>
  615. <plate>
  616. <metadata key="index" value="1"/>
  617. <metadata key="curr_bed_type" value="Textured PEI Plate"/>
  618. </plate>
  619. </config>
  620. """
  621. file_path = tmp_path / "test.3mf"
  622. file_path.write_bytes(create_mock_3mf(xml_content).read())
  623. assert extract_bed_type_from_3mf(file_path) == "Textured PEI Plate"
  624. def test_multi_plate_returns_per_plate_value(self, tmp_path):
  625. # Reporter's case: a 3MF mixing PEI + Engineering across plates.
  626. # Looking up by plate_id must return THAT plate's value, not the
  627. # first plate's value the archive happens to cache.
  628. xml_content = """<?xml version="1.0" encoding="UTF-8"?>
  629. <config>
  630. <plate>
  631. <metadata key="index" value="1"/>
  632. <metadata key="curr_bed_type" value="Textured PEI Plate"/>
  633. </plate>
  634. <plate>
  635. <metadata key="index" value="2"/>
  636. <metadata key="curr_bed_type" value="Engineering Plate"/>
  637. </plate>
  638. <plate>
  639. <metadata key="index" value="3"/>
  640. <metadata key="curr_bed_type" value="Cool Plate"/>
  641. </plate>
  642. </config>
  643. """
  644. file_path = tmp_path / "test.3mf"
  645. file_path.write_bytes(create_mock_3mf(xml_content).read())
  646. assert extract_bed_type_from_3mf(file_path, plate_id=1) == "Textured PEI Plate"
  647. assert extract_bed_type_from_3mf(file_path, plate_id=2) == "Engineering Plate"
  648. assert extract_bed_type_from_3mf(file_path, plate_id=3) == "Cool Plate"
  649. def test_no_plate_id_returns_first_plate(self, tmp_path):
  650. # The plate_id=None branch must match the archive-level capture
  651. # convention (first plate wins) so callers that don't care about
  652. # plate selection see the same value the archive table holds.
  653. xml_content = """<?xml version="1.0" encoding="UTF-8"?>
  654. <config>
  655. <plate>
  656. <metadata key="index" value="1"/>
  657. <metadata key="curr_bed_type" value="Cool Plate SuperTack"/>
  658. </plate>
  659. <plate>
  660. <metadata key="index" value="2"/>
  661. <metadata key="curr_bed_type" value="Engineering Plate"/>
  662. </plate>
  663. </config>
  664. """
  665. file_path = tmp_path / "test.3mf"
  666. file_path.write_bytes(create_mock_3mf(xml_content).read())
  667. assert extract_bed_type_from_3mf(file_path) == "Cool Plate SuperTack"
  668. def test_unknown_plate_id_returns_none(self, tmp_path):
  669. xml_content = """<?xml version="1.0" encoding="UTF-8"?>
  670. <config>
  671. <plate>
  672. <metadata key="index" value="1"/>
  673. <metadata key="curr_bed_type" value="Textured PEI Plate"/>
  674. </plate>
  675. </config>
  676. """
  677. file_path = tmp_path / "test.3mf"
  678. file_path.write_bytes(create_mock_3mf(xml_content).read())
  679. assert extract_bed_type_from_3mf(file_path, plate_id=99) is None
  680. def test_plate_without_bed_type_returns_none(self, tmp_path):
  681. # Older slicers may export a plate without curr_bed_type. The
  682. # helper must return None rather than falling through to another
  683. # plate's value (which would silently lie).
  684. xml_content = """<?xml version="1.0" encoding="UTF-8"?>
  685. <config>
  686. <plate>
  687. <metadata key="index" value="1"/>
  688. </plate>
  689. <plate>
  690. <metadata key="index" value="2"/>
  691. <metadata key="curr_bed_type" value="Engineering Plate"/>
  692. </plate>
  693. </config>
  694. """
  695. file_path = tmp_path / "test.3mf"
  696. file_path.write_bytes(create_mock_3mf(xml_content).read())
  697. assert extract_bed_type_from_3mf(file_path, plate_id=1) is None
  698. assert extract_bed_type_from_3mf(file_path, plate_id=2) == "Engineering Plate"
  699. def test_missing_slice_info_returns_none(self, tmp_path):
  700. buffer = io.BytesIO()
  701. with zipfile.ZipFile(buffer, "w") as zf:
  702. zf.writestr("other_file.txt", "content")
  703. buffer.seek(0)
  704. file_path = tmp_path / "test.3mf"
  705. file_path.write_bytes(buffer.read())
  706. assert extract_bed_type_from_3mf(file_path) is None
  707. def test_invalid_file_returns_none(self, tmp_path):
  708. file_path = tmp_path / "invalid.3mf"
  709. file_path.write_text("not a zip file")
  710. assert extract_bed_type_from_3mf(file_path) is None
  711. def test_whitespace_trimmed(self, tmp_path):
  712. # 3MF values sometimes carry surrounding whitespace from manual
  713. # template tweaks; getBedTypeInfo() on the frontend is also
  714. # whitespace-tolerant, but the wire shape should be clean.
  715. xml_content = """<?xml version="1.0" encoding="UTF-8"?>
  716. <config>
  717. <plate>
  718. <metadata key="index" value="1"/>
  719. <metadata key="curr_bed_type" value=" Textured PEI Plate "/>
  720. </plate>
  721. </config>
  722. """
  723. file_path = tmp_path / "test.3mf"
  724. file_path.write_bytes(create_mock_3mf(xml_content).read())
  725. assert extract_bed_type_from_3mf(file_path) == "Textured PEI Plate"
  726. class TestExtractPrintTimeFrom3mf:
  727. """Tests for extract_print_time_from_3mf — the per-plate `prediction` reader
  728. used by the completion notification path to scope the archive-level (summed)
  729. total down to the actually-printed plate (#1785)."""
  730. def test_returns_plate_prediction_when_plate_id_matches(self, tmp_path):
  731. xml_content = """<?xml version="1.0" encoding="UTF-8"?>
  732. <config>
  733. <plate>
  734. <metadata key="index" value="1"/>
  735. <metadata key="prediction" value="3600"/>
  736. </plate>
  737. <plate>
  738. <metadata key="index" value="2"/>
  739. <metadata key="prediction" value="7200"/>
  740. </plate>
  741. <plate>
  742. <metadata key="index" value="3"/>
  743. <metadata key="prediction" value="10800"/>
  744. </plate>
  745. </config>
  746. """
  747. file_path = tmp_path / "test.3mf"
  748. file_path.write_bytes(create_mock_3mf(xml_content).read())
  749. assert extract_print_time_from_3mf(file_path, plate_id=2) == 7200
  750. def test_returns_first_plate_when_no_plate_id(self, tmp_path):
  751. xml_content = """<?xml version="1.0" encoding="UTF-8"?>
  752. <config>
  753. <plate>
  754. <metadata key="index" value="1"/>
  755. <metadata key="prediction" value="900"/>
  756. </plate>
  757. <plate>
  758. <metadata key="index" value="2"/>
  759. <metadata key="prediction" value="1800"/>
  760. </plate>
  761. </config>
  762. """
  763. file_path = tmp_path / "test.3mf"
  764. file_path.write_bytes(create_mock_3mf(xml_content).read())
  765. assert extract_print_time_from_3mf(file_path) == 900
  766. def test_returns_none_when_plate_id_missing(self, tmp_path):
  767. xml_content = """<?xml version="1.0" encoding="UTF-8"?>
  768. <config>
  769. <plate>
  770. <metadata key="index" value="1"/>
  771. <metadata key="prediction" value="3600"/>
  772. </plate>
  773. </config>
  774. """
  775. file_path = tmp_path / "test.3mf"
  776. file_path.write_bytes(create_mock_3mf(xml_content).read())
  777. assert extract_print_time_from_3mf(file_path, plate_id=5) is None
  778. def test_returns_none_when_prediction_unparseable(self, tmp_path):
  779. xml_content = """<?xml version="1.0" encoding="UTF-8"?>
  780. <config>
  781. <plate>
  782. <metadata key="index" value="1"/>
  783. <metadata key="prediction" value="not-a-number"/>
  784. </plate>
  785. </config>
  786. """
  787. file_path = tmp_path / "test.3mf"
  788. file_path.write_bytes(create_mock_3mf(xml_content).read())
  789. assert extract_print_time_from_3mf(file_path, plate_id=1) is None
  790. def test_returns_none_when_slice_info_missing(self, tmp_path):
  791. buffer = io.BytesIO()
  792. with zipfile.ZipFile(buffer, "w") as zf:
  793. zf.writestr("other_file.txt", "content")
  794. buffer.seek(0)
  795. file_path = tmp_path / "test.3mf"
  796. file_path.write_bytes(buffer.read())
  797. assert extract_print_time_from_3mf(file_path) is None
  798. assert extract_print_time_from_3mf(file_path, plate_id=1) is None
  799. def test_returns_none_when_file_invalid(self, tmp_path):
  800. file_path = tmp_path / "invalid.3mf"
  801. file_path.write_text("not a zip file")
  802. assert extract_print_time_from_3mf(file_path) is None
  803. assert extract_print_time_from_3mf(file_path, plate_id=1) is None
  804. def test_returns_none_when_file_missing(self, tmp_path):
  805. file_path = tmp_path / "nonexistent.3mf"
  806. assert extract_print_time_from_3mf(file_path) is None
  807. assert extract_print_time_from_3mf(file_path, plate_id=2) is None