test_threemf_tools.py 40 KB

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