test_threemf_tools.py 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647
  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_filament_usage_from_3mf,
  11. extract_plate_extruder_set_from_3mf,
  12. extract_project_filaments_from_3mf,
  13. get_cumulative_usage_at_layer,
  14. mm_to_grams,
  15. parse_gcode_layer_filament_usage,
  16. )
  17. def create_mock_3mf(slice_info_content: str) -> io.BytesIO:
  18. """Create a mock 3MF file (ZIP) with slice_info.config content."""
  19. buffer = io.BytesIO()
  20. with zipfile.ZipFile(buffer, "w") as zf:
  21. zf.writestr("Metadata/slice_info.config", slice_info_content)
  22. buffer.seek(0)
  23. return buffer
  24. class TestParseGcodeLayerFilamentUsage:
  25. """Tests for parse_gcode_layer_filament_usage()."""
  26. def test_single_filament_single_layer(self):
  27. """Single filament extruding on one layer."""
  28. gcode = """
  29. M620 S0
  30. G1 X10 Y10 E5.0
  31. G1 X20 Y20 E3.0
  32. """
  33. result = parse_gcode_layer_filament_usage(gcode)
  34. assert result == {0: {0: 8.0}}
  35. def test_multi_layer_single_filament(self):
  36. """Single filament across multiple layers."""
  37. gcode = """
  38. M620 S0
  39. G1 X10 Y10 E10.0
  40. M73 L1
  41. G1 X20 Y20 E5.0
  42. M73 L2
  43. G1 X30 Y30 E7.0
  44. """
  45. result = parse_gcode_layer_filament_usage(gcode)
  46. assert result[0] == {0: 10.0}
  47. assert result[1] == {0: 15.0}
  48. assert result[2] == {0: 22.0}
  49. def test_multi_material(self):
  50. """Multiple filaments switching via M620."""
  51. gcode = """
  52. M620 S0
  53. G1 E10.0
  54. M73 L1
  55. M620 S1
  56. G1 E5.0
  57. M620 S0
  58. G1 E3.0
  59. M73 L2
  60. G1 E2.0
  61. """
  62. result = parse_gcode_layer_filament_usage(gcode)
  63. # Layer 0: filament 0 = 10mm
  64. assert result[0] == {0: 10.0}
  65. # Layer 1: filament 0 = 13mm (10+3), filament 1 = 5mm
  66. assert result[1] == {0: 13.0, 1: 5.0}
  67. # Layer 2: filament 0 = 15mm (13+2)
  68. assert result[2] == {0: 15.0, 1: 5.0}
  69. def test_retractions_ignored(self):
  70. """Negative E values (retractions) should be ignored."""
  71. gcode = """
  72. M620 S0
  73. G1 E10.0
  74. G1 E-2.0
  75. G1 E5.0
  76. """
  77. result = parse_gcode_layer_filament_usage(gcode)
  78. assert result == {0: {0: 15.0}}
  79. def test_m620_s255_unloads(self):
  80. """M620 S255 means unload - extrusion after should be ignored."""
  81. gcode = """
  82. M620 S0
  83. G1 E10.0
  84. M620 S255
  85. G1 E5.0
  86. """
  87. result = parse_gcode_layer_filament_usage(gcode)
  88. assert result == {0: {0: 10.0}}
  89. def test_m620_with_suffix(self):
  90. """M620 S0A format (filament ID with suffix letter)."""
  91. gcode = """
  92. M620 S0A
  93. G1 E10.0
  94. M620 S1A
  95. G1 E5.0
  96. """
  97. result = parse_gcode_layer_filament_usage(gcode)
  98. assert result == {0: {0: 10.0, 1: 5.0}}
  99. def test_comments_ignored(self):
  100. """Comment lines and inline comments are ignored."""
  101. gcode = """
  102. ; This is a comment
  103. M620 S0
  104. G1 X10 E5.0 ; inline comment with E value
  105. G1 E3.0
  106. """
  107. result = parse_gcode_layer_filament_usage(gcode)
  108. assert result == {0: {0: 8.0}}
  109. def test_empty_gcode(self):
  110. """Empty G-code returns empty dict."""
  111. assert parse_gcode_layer_filament_usage("") == {}
  112. assert parse_gcode_layer_filament_usage("\n\n\n") == {}
  113. def test_no_extrusion(self):
  114. """G-code with moves but no extrusion."""
  115. gcode = """
  116. G1 X10 Y10
  117. G1 X20 Y20
  118. """
  119. assert parse_gcode_layer_filament_usage(gcode) == {}
  120. def test_no_active_filament_extrusion_ignored(self):
  121. """Extrusion before any M620 is ignored (no active filament)."""
  122. gcode = """
  123. G1 E10.0
  124. M620 S0
  125. G1 E5.0
  126. """
  127. result = parse_gcode_layer_filament_usage(gcode)
  128. assert result == {0: {0: 5.0}}
  129. def test_g0_g2_g3_extrusion(self):
  130. """G0, G2, G3 with E parameter are also tracked."""
  131. gcode = """
  132. M620 S0
  133. G0 E1.0
  134. G1 E2.0
  135. G2 E3.0
  136. G3 E4.0
  137. """
  138. result = parse_gcode_layer_filament_usage(gcode)
  139. assert result == {0: {0: 10.0}}
  140. def test_cumulative_across_layers(self):
  141. """Values are cumulative, not per-layer."""
  142. gcode = """
  143. M620 S0
  144. G1 E100.0
  145. M73 L1
  146. G1 E100.0
  147. M73 L2
  148. G1 E100.0
  149. """
  150. result = parse_gcode_layer_filament_usage(gcode)
  151. assert result[0] == {0: 100.0}
  152. assert result[1] == {0: 200.0}
  153. assert result[2] == {0: 300.0}
  154. class TestMmToGrams:
  155. """Tests for mm_to_grams()."""
  156. def test_default_pla_175(self):
  157. """Default PLA 1.75mm conversion."""
  158. # 1000mm of 1.75mm PLA at 1.24 g/cm³
  159. # Volume = π × (0.0875cm)² × 100cm = 2.405cm³
  160. # Weight = 2.405 × 1.24 = 2.982g
  161. result = mm_to_grams(1000.0)
  162. expected = math.pi * (0.0875**2) * 100 * 1.24
  163. assert abs(result - expected) < 0.001
  164. def test_zero_length(self):
  165. """Zero length returns zero weight."""
  166. assert mm_to_grams(0.0) == 0.0
  167. def test_custom_diameter(self):
  168. """Custom diameter (2.85mm) changes result."""
  169. result_175 = mm_to_grams(1000.0, diameter_mm=1.75)
  170. result_285 = mm_to_grams(1000.0, diameter_mm=2.85)
  171. # 2.85mm filament has more volume per mm
  172. assert result_285 > result_175
  173. ratio = (2.85 / 1.75) ** 2 # Volume scales with diameter²
  174. assert abs(result_285 / result_175 - ratio) < 0.001
  175. def test_custom_density(self):
  176. """Different density (ABS vs PLA)."""
  177. pla = mm_to_grams(1000.0, density_g_cm3=1.24)
  178. abs_ = mm_to_grams(1000.0, density_g_cm3=1.04)
  179. assert pla > abs_
  180. assert abs(pla / abs_ - 1.24 / 1.04) < 0.001
  181. def test_known_value(self):
  182. """Verify against a known calculation.
  183. 1m (1000mm) of 1.75mm PLA at 1.24 g/cm³:
  184. r = 0.0875 cm, L = 100 cm
  185. V = π × 0.0875² × 100 = 2.4053 cm³
  186. m = 2.4053 × 1.24 = 2.9826 g
  187. """
  188. result = mm_to_grams(1000.0, 1.75, 1.24)
  189. assert abs(result - 2.9826) < 0.01
  190. class TestGetCumulativeUsageAtLayer:
  191. """Tests for get_cumulative_usage_at_layer()."""
  192. def test_exact_layer_match(self):
  193. """Target layer exists exactly in the data."""
  194. data = {0: {0: 100.0}, 5: {0: 500.0}, 10: {0: 1000.0}}
  195. assert get_cumulative_usage_at_layer(data, 5) == {0: 500.0}
  196. def test_between_layers(self):
  197. """Target is between recorded layers - uses the closest lower one."""
  198. data = {0: {0: 100.0}, 5: {0: 500.0}, 10: {0: 1000.0}}
  199. # Layer 7 is between 5 and 10, should return layer 5's data
  200. assert get_cumulative_usage_at_layer(data, 7) == {0: 500.0}
  201. def test_beyond_last_layer(self):
  202. """Target is beyond the last recorded layer."""
  203. data = {0: {0: 100.0}, 5: {0: 500.0}}
  204. assert get_cumulative_usage_at_layer(data, 100) == {0: 500.0}
  205. def test_before_first_layer(self):
  206. """Target is before any recorded data."""
  207. data = {5: {0: 500.0}, 10: {0: 1000.0}}
  208. assert get_cumulative_usage_at_layer(data, 3) == {}
  209. def test_empty_data(self):
  210. """Empty layer_usage returns empty dict."""
  211. assert get_cumulative_usage_at_layer({}, 5) == {}
  212. def test_none_data(self):
  213. """None layer_usage returns empty dict."""
  214. assert get_cumulative_usage_at_layer(None, 5) == {}
  215. def test_multi_filament(self):
  216. """Multi-filament data at target layer."""
  217. data = {
  218. 0: {0: 50.0},
  219. 5: {0: 200.0, 1: 100.0},
  220. 10: {0: 400.0, 1: 250.0, 2: 50.0},
  221. }
  222. result = get_cumulative_usage_at_layer(data, 8)
  223. assert result == {0: 200.0, 1: 100.0}
  224. def test_layer_zero(self):
  225. """Target layer 0."""
  226. data = {0: {0: 10.0}, 1: {0: 20.0}}
  227. assert get_cumulative_usage_at_layer(data, 0) == {0: 10.0}
  228. class TestExtractFilamentUsageFrom3mf:
  229. """Tests for extract_filament_usage_from_3mf function."""
  230. def test_extract_single_filament(self, tmp_path):
  231. """Test extracting a single filament."""
  232. xml_content = """<?xml version="1.0" encoding="UTF-8"?>
  233. <config>
  234. <filament id="1" used_g="50.5" type="PLA" color="#FF0000"/>
  235. </config>
  236. """
  237. mock_3mf = create_mock_3mf(xml_content)
  238. file_path = tmp_path / "test.3mf"
  239. file_path.write_bytes(mock_3mf.read())
  240. result = extract_filament_usage_from_3mf(file_path)
  241. assert len(result) == 1
  242. assert result[0]["slot_id"] == 1
  243. assert result[0]["used_g"] == 50.5
  244. assert result[0]["type"] == "PLA"
  245. assert result[0]["color"] == "#FF0000"
  246. def test_extract_multiple_filaments(self, tmp_path):
  247. """Test extracting multiple filaments."""
  248. xml_content = """<?xml version="1.0" encoding="UTF-8"?>
  249. <config>
  250. <filament id="1" used_g="50.5" type="PLA" color="#FF0000"/>
  251. <filament id="2" used_g="30.2" type="PETG" color="#00FF00"/>
  252. <filament id="3" used_g="10.0" type="ABS" color="#0000FF"/>
  253. </config>
  254. """
  255. mock_3mf = create_mock_3mf(xml_content)
  256. file_path = tmp_path / "test.3mf"
  257. file_path.write_bytes(mock_3mf.read())
  258. result = extract_filament_usage_from_3mf(file_path)
  259. assert len(result) == 3
  260. assert result[0]["slot_id"] == 1
  261. assert result[1]["slot_id"] == 2
  262. assert result[2]["slot_id"] == 3
  263. def test_extract_filament_with_plate_id(self, tmp_path):
  264. """Test extracting filament for a specific plate."""
  265. xml_content = """<?xml version="1.0" encoding="UTF-8"?>
  266. <config>
  267. <plate>
  268. <metadata key="index" value="1"/>
  269. <filament id="1" used_g="25.0" type="PLA" color="#FF0000"/>
  270. </plate>
  271. <plate>
  272. <metadata key="index" value="2"/>
  273. <filament id="1" used_g="75.0" type="PETG" color="#00FF00"/>
  274. </plate>
  275. </config>
  276. """
  277. mock_3mf = create_mock_3mf(xml_content)
  278. file_path = tmp_path / "test.3mf"
  279. file_path.write_bytes(mock_3mf.read())
  280. result = extract_filament_usage_from_3mf(file_path, plate_id=2)
  281. assert len(result) == 1
  282. assert result[0]["used_g"] == 75.0
  283. assert result[0]["type"] == "PETG"
  284. def test_missing_slice_info_returns_empty(self, tmp_path):
  285. """Test that missing slice_info.config returns empty list."""
  286. buffer = io.BytesIO()
  287. with zipfile.ZipFile(buffer, "w") as zf:
  288. zf.writestr("other_file.txt", "content")
  289. buffer.seek(0)
  290. file_path = tmp_path / "test.3mf"
  291. file_path.write_bytes(buffer.read())
  292. result = extract_filament_usage_from_3mf(file_path)
  293. assert result == []
  294. def test_invalid_file_returns_empty(self, tmp_path):
  295. """Test that invalid file returns empty list."""
  296. file_path = tmp_path / "invalid.3mf"
  297. file_path.write_text("not a zip file")
  298. result = extract_filament_usage_from_3mf(file_path)
  299. assert result == []
  300. def test_nonexistent_file_returns_empty(self, tmp_path):
  301. """Test that nonexistent file returns empty list."""
  302. file_path = tmp_path / "nonexistent.3mf"
  303. result = extract_filament_usage_from_3mf(file_path)
  304. assert result == []
  305. def test_filament_without_id_is_skipped(self, tmp_path):
  306. """Test that filament without id is skipped."""
  307. xml_content = """<?xml version="1.0" encoding="UTF-8"?>
  308. <config>
  309. <filament used_g="50.5" type="PLA" color="#FF0000"/>
  310. <filament id="2" used_g="30.0" type="PETG" color="#00FF00"/>
  311. </config>
  312. """
  313. mock_3mf = create_mock_3mf(xml_content)
  314. file_path = tmp_path / "test.3mf"
  315. file_path.write_bytes(mock_3mf.read())
  316. result = extract_filament_usage_from_3mf(file_path)
  317. assert len(result) == 1
  318. assert result[0]["slot_id"] == 2
  319. def test_invalid_used_g_is_skipped(self, tmp_path):
  320. """Test that filament with invalid used_g is skipped."""
  321. xml_content = """<?xml version="1.0" encoding="UTF-8"?>
  322. <config>
  323. <filament id="1" used_g="invalid" type="PLA" color="#FF0000"/>
  324. <filament id="2" used_g="30.0" type="PETG" color="#00FF00"/>
  325. </config>
  326. """
  327. mock_3mf = create_mock_3mf(xml_content)
  328. file_path = tmp_path / "test.3mf"
  329. file_path.write_bytes(mock_3mf.read())
  330. result = extract_filament_usage_from_3mf(file_path)
  331. assert len(result) == 1
  332. assert result[0]["slot_id"] == 2
  333. def test_missing_optional_fields(self, tmp_path):
  334. """Test that missing type and color default to empty string."""
  335. xml_content = """<?xml version="1.0" encoding="UTF-8"?>
  336. <config>
  337. <filament id="1" used_g="50.5"/>
  338. </config>
  339. """
  340. mock_3mf = create_mock_3mf(xml_content)
  341. file_path = tmp_path / "test.3mf"
  342. file_path.write_bytes(mock_3mf.read())
  343. result = extract_filament_usage_from_3mf(file_path)
  344. assert len(result) == 1
  345. assert result[0]["type"] == ""
  346. assert result[0]["color"] == ""
  347. # ---------------------------------------------------------------------------
  348. # Tests for extract_project_filaments_from_3mf — used by the slice modal as
  349. # fallback when the sidecar can't run a preview slice.
  350. # ---------------------------------------------------------------------------
  351. def _make_3mf_with(files: dict[str, bytes | str]) -> zipfile.ZipFile:
  352. buf = io.BytesIO()
  353. with zipfile.ZipFile(buf, "w") as zf:
  354. for name, content in files.items():
  355. zf.writestr(name, content if isinstance(content, (bytes, str)) else str(content))
  356. buf.seek(0)
  357. return zipfile.ZipFile(buf, "r")
  358. class TestExtractProjectFilamentsFrom3mf:
  359. """The helper backfills the slice modal when slice_info.config is empty
  360. (raw project files) and the sidecar is unreachable."""
  361. def test_returns_empty_when_project_settings_missing(self):
  362. with _make_3mf_with({"placeholder.txt": "hi"}) as zf:
  363. assert extract_project_filaments_from_3mf(zf) == []
  364. def test_happy_path_returns_one_entry_per_slot(self):
  365. proj = {
  366. "filament_type": ["PLA", "PETG"],
  367. "filament_colour": ["#000000", "#FFFFFF"],
  368. }
  369. with _make_3mf_with({"Metadata/project_settings.config": json.dumps(proj)}) as zf:
  370. out = extract_project_filaments_from_3mf(zf)
  371. assert [(f["slot_id"], f["type"], f["color"]) for f in out] == [
  372. (1, "PLA", "#000000"),
  373. (2, "PETG", "#FFFFFF"),
  374. ]
  375. def test_mismatched_array_lengths_use_max_with_blanks(self):
  376. proj = {
  377. "filament_type": ["PLA", "PETG", "ABS"],
  378. "filament_colour": ["#000000"],
  379. }
  380. with _make_3mf_with({"Metadata/project_settings.config": json.dumps(proj)}) as zf:
  381. out = extract_project_filaments_from_3mf(zf)
  382. assert len(out) == 3
  383. assert out[0]["color"] == "#000000"
  384. assert out[1]["color"] == ""
  385. assert out[2]["color"] == ""
  386. def test_corrupt_json_returns_empty_no_exception(self):
  387. with _make_3mf_with({"Metadata/project_settings.config": b"{not json"}) as zf:
  388. assert extract_project_filaments_from_3mf(zf) == []
  389. def test_root_is_list_returns_empty(self):
  390. # Defensive: spec says it's a dict, but a file shipping a top-level
  391. # list (or anything non-dict) shouldn't crash the modal.
  392. with _make_3mf_with({"Metadata/project_settings.config": json.dumps([])}) as zf:
  393. assert extract_project_filaments_from_3mf(zf) == []
  394. def test_empty_arrays_returns_empty(self):
  395. proj = {"filament_type": [], "filament_colour": []}
  396. with _make_3mf_with({"Metadata/project_settings.config": json.dumps(proj)}) as zf:
  397. assert extract_project_filaments_from_3mf(zf) == []
  398. # ---------------------------------------------------------------------------
  399. # Tests for extract_plate_extruder_set_from_3mf — three sources unioned:
  400. # object top-level extruder, per-part extruder, painted-face quadtree leaves.
  401. # ---------------------------------------------------------------------------
  402. def _model_settings(plate_id: int, objects: list[dict]) -> str:
  403. """Build a minimal model_settings.config XML for tests. Each object dict
  404. can have: id, extruder (top-level), parts (list of {extruder}).
  405. The plate references all object ids."""
  406. parts_xml = []
  407. for obj in objects:
  408. oid = obj["id"]
  409. ext = obj.get("extruder")
  410. parts = obj.get("parts", [])
  411. ext_meta = f'<metadata key="extruder" value="{ext}"/>' if ext is not None else ""
  412. part_blocks = "".join(
  413. f'<part id="{i}" subtype="normal_part"><metadata key="extruder" value="{p["extruder"]}"/></part>'
  414. for i, p in enumerate(parts)
  415. if p.get("extruder") is not None
  416. )
  417. parts_xml.append(f'<object id="{oid}"><metadata key="name" value="o{oid}"/>{ext_meta}{part_blocks}</object>')
  418. instances = "".join(
  419. f'<model_instance><metadata key="object_id" value="{o["id"]}"/></model_instance>' for o in objects
  420. )
  421. plate = f'<plate><metadata key="plater_id" value="{plate_id}"/>{instances}</plate>'
  422. return f'<?xml version="1.0"?><config>{"".join(parts_xml)}{plate}</config>'
  423. class TestExtractPlateExtruderSetFrom3mf:
  424. def test_returns_empty_set_when_model_settings_missing(self):
  425. with _make_3mf_with({"placeholder.txt": "hi"}) as zf:
  426. assert extract_plate_extruder_set_from_3mf(zf, plate_id=1) == set()
  427. def test_object_top_level_extruder_only(self):
  428. xml = _model_settings(plate_id=1, objects=[{"id": "10", "extruder": 2}])
  429. with _make_3mf_with({"Metadata/model_settings.config": xml}) as zf:
  430. assert extract_plate_extruder_set_from_3mf(zf, plate_id=1) == {2}
  431. def test_per_part_extruder_unions_with_top_level(self):
  432. # Object's default is 1; one of its parts overrides to 3 (multi-color
  433. # via a sub-mesh). Union both — the slicer needs profiles for both.
  434. xml = _model_settings(
  435. plate_id=1,
  436. objects=[{"id": "10", "extruder": 1, "parts": [{"extruder": 3}]}],
  437. )
  438. with _make_3mf_with({"Metadata/model_settings.config": xml}) as zf:
  439. assert extract_plate_extruder_set_from_3mf(zf, plate_id=1) == {1, 3}
  440. def test_unknown_plate_id_returns_empty_set(self):
  441. xml = _model_settings(plate_id=1, objects=[{"id": "10", "extruder": 2}])
  442. with _make_3mf_with({"Metadata/model_settings.config": xml}) as zf:
  443. assert extract_plate_extruder_set_from_3mf(zf, plate_id=99) == set()
  444. def test_corrupt_xml_returns_empty_set_no_exception(self):
  445. with _make_3mf_with({"Metadata/model_settings.config": "<not valid xml"}) as zf:
  446. assert extract_plate_extruder_set_from_3mf(zf, plate_id=1) == set()
  447. def test_zero_extruder_value_ignored(self):
  448. # Bambu's 0 means "use object default" — not a real slot.
  449. xml = _model_settings(plate_id=1, objects=[{"id": "10", "extruder": 0}])
  450. with _make_3mf_with({"Metadata/model_settings.config": xml}) as zf:
  451. assert extract_plate_extruder_set_from_3mf(zf, plate_id=1) == set()
  452. def test_painted_face_above_threshold_kept(self):
  453. # 60/40 split: 60 triangles painted with extruder 1, 40 with ext 2.
  454. # Threshold is 5%; both above. The dominant ones are real colours.
  455. triangles = []
  456. for _ in range(60):
  457. triangles.append('<triangle v1="0" v2="1" v3="2" paint_color="1"/>')
  458. for _ in range(40):
  459. triangles.append('<triangle v1="0" v2="1" v3="2" paint_color="2"/>')
  460. per_obj = (
  461. '<?xml version="1.0"?>'
  462. '<model><resources><object id="100" type="model"><mesh>'
  463. "<triangles>" + "".join(triangles) + "</triangles>"
  464. "</mesh></object></resources><build/></model>"
  465. )
  466. ms = (
  467. '<?xml version="1.0"?><config>'
  468. '<object id="10"><metadata key="name" value="o"/></object>'
  469. '<plate><metadata key="plater_id" value="1"/>'
  470. '<model_instance><metadata key="object_id" value="10"/></model_instance>'
  471. "</plate></config>"
  472. )
  473. threed = (
  474. '<?xml version="1.0"?>'
  475. '<model xmlns="http://schemas.microsoft.com/3dmanufacturing/core/2015/02"'
  476. ' xmlns:p="http://schemas.microsoft.com/3dmanufacturing/production/2015/06">'
  477. "<resources>"
  478. '<object id="10" type="model"><components>'
  479. '<component p:path="/3D/Objects/o100.model" objectid="100"/>'
  480. "</components></object>"
  481. "</resources><build/></model>"
  482. )
  483. with _make_3mf_with(
  484. {
  485. "Metadata/model_settings.config": ms,
  486. "3D/3dmodel.model": threed,
  487. "3D/Objects/o100.model": per_obj,
  488. }
  489. ) as zf:
  490. result = extract_plate_extruder_set_from_3mf(zf, plate_id=1)
  491. # Both real colours kept (60/40 well above 5% threshold); the dropped
  492. # threshold case is the regression that motivates this test.
  493. assert result == {1, 2}
  494. def test_painted_face_below_threshold_dropped_as_noise(self):
  495. # 99 triangles at ext 1, 1 triangle at ext 9 (1% — below 5%
  496. # threshold). The 1% leaf is a single-leaf accident.
  497. triangles = []
  498. for _ in range(99):
  499. triangles.append('<triangle v1="0" v2="1" v3="2" paint_color="1"/>')
  500. triangles.append('<triangle v1="0" v2="1" v3="2" paint_color="9"/>')
  501. per_obj = (
  502. '<?xml version="1.0"?>'
  503. '<model><resources><object id="100" type="model"><mesh>'
  504. "<triangles>" + "".join(triangles) + "</triangles>"
  505. "</mesh></object></resources><build/></model>"
  506. )
  507. ms = (
  508. '<?xml version="1.0"?><config>'
  509. '<object id="10"><metadata key="name" value="o"/></object>'
  510. '<plate><metadata key="plater_id" value="1"/>'
  511. '<model_instance><metadata key="object_id" value="10"/></model_instance>'
  512. "</plate></config>"
  513. )
  514. threed = (
  515. '<?xml version="1.0"?>'
  516. '<model xmlns="http://schemas.microsoft.com/3dmanufacturing/core/2015/02"'
  517. ' xmlns:p="http://schemas.microsoft.com/3dmanufacturing/production/2015/06">'
  518. '<resources><object id="10" type="model"><components>'
  519. '<component p:path="/3D/Objects/o100.model" objectid="100"/>'
  520. "</components></object></resources><build/></model>"
  521. )
  522. with _make_3mf_with(
  523. {
  524. "Metadata/model_settings.config": ms,
  525. "3D/3dmodel.model": threed,
  526. "3D/Objects/o100.model": per_obj,
  527. }
  528. ) as zf:
  529. result = extract_plate_extruder_set_from_3mf(zf, plate_id=1)
  530. # Single-leaf accident at 1% filtered as noise; only the dominant
  531. # extruder survives.
  532. assert result == {1}
  533. def test_missing_per_object_model_file_silently_skipped(self):
  534. ms = (
  535. '<?xml version="1.0"?><config>'
  536. '<object id="10"><metadata key="extruder" value="2"/></object>'
  537. '<plate><metadata key="plater_id" value="1"/>'
  538. '<model_instance><metadata key="object_id" value="10"/></model_instance>'
  539. "</plate></config>"
  540. )
  541. threed = (
  542. '<?xml version="1.0"?>'
  543. '<model xmlns="http://schemas.microsoft.com/3dmanufacturing/core/2015/02"'
  544. ' xmlns:p="http://schemas.microsoft.com/3dmanufacturing/production/2015/06">'
  545. '<resources><object id="10" type="model"><components>'
  546. '<component p:path="/3D/Objects/missing.model" objectid="999"/>'
  547. "</components></object></resources><build/></model>"
  548. )
  549. with _make_3mf_with(
  550. {"Metadata/model_settings.config": ms, "3D/3dmodel.model": threed},
  551. ) as zf:
  552. # Top-level metadata still works; missing component model file
  553. # is silently skipped without crashing.
  554. assert extract_plate_extruder_set_from_3mf(zf, plate_id=1) == {2}