test_filament_colour_2977.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229
  1. """Per-slot filament colour on a slice request (#2977).
  2. Neither Bambu Studio nor OrcaSlicer stores a colour on a filament *preset* --
  3. it is a per-project property their GUIs set from the plate -- so a CLI slice
  4. that supplies no colour records the slicer's compiled-in default for every
  5. slot. That default is ``#00AE42``, which is why every internal-slicer output
  6. was Bambu green whatever filament was picked, why the plate thumbnail was
  7. green, and why the print dialog reported a colour mismatch against the AMS
  8. slot the job had just been correctly mapped to.
  9. ``default_filament_colour`` is not a substitute and these tests do not treat
  10. it as one. Measured against a 02.08.02.61 sidecar, a profile carrying only
  11. ``default_filament_colour: ["#FF00FF"]`` still slices to
  12. ``filament_colour: ["#00AE42"]``: the CLI never reads it, because Bambu Studio
  13. consumes it in the GUI when a project is created. It is read here and
  14. rewritten as ``filament_colour``, which the CLI does honour -- the same
  15. sidecar returns ``filament_colour: ["#E8B00C"]`` for a profile patched this
  16. way.
  17. """
  18. import io
  19. import json
  20. import zipfile
  21. import pytest
  22. from pydantic import ValidationError
  23. from backend.app.api.routes.library import (
  24. _patch_filament_colours,
  25. _preset_default_colour,
  26. _source_plate_colours,
  27. )
  28. from backend.app.schemas.slicer import PresetRef, SliceRequest
  29. pytestmark = pytest.mark.unit
  30. def _filament(name: str, **extra) -> str:
  31. return json.dumps({"name": name, "inherits": name, "from": "system", "type": "filament", **extra})
  32. def _colour_of(profile_json: str) -> list | None:
  33. return json.loads(profile_json).get("filament_colour")
  34. def _project_3mf(types: list[str], colours: list[str]) -> bytes:
  35. buffer = io.BytesIO()
  36. with zipfile.ZipFile(buffer, "w") as archive:
  37. archive.writestr(
  38. "Metadata/project_settings.config",
  39. json.dumps({"filament_type": types, "filament_colour": colours}),
  40. )
  41. return buffer.getvalue()
  42. def _request(**kwargs) -> SliceRequest:
  43. return SliceRequest(
  44. printer_preset=PresetRef(source="standard", id="Bambu Lab A1 mini 0.4 nozzle"),
  45. process_preset=PresetRef(source="standard", id="0.20mm Standard @BBL A1M"),
  46. filament_presets=[PresetRef(source="standard", id="Generic PLA Silk")],
  47. **kwargs,
  48. )
  49. class TestTheRequestField:
  50. def test_absent_by_default_so_older_clients_are_unchanged(self):
  51. assert _request().filament_colours == []
  52. def test_accepts_six_and_eight_digit_hex(self):
  53. # The AMS reports colours with an alpha byte and the slicer writes
  54. # them without one; a request may legitimately carry either.
  55. assert _request(filament_colours=["#00AE42", "#AABBCCDD"]).filament_colours == [
  56. "#00AE42",
  57. "#AABBCCDD",
  58. ]
  59. def test_normalises_case_so_two_equal_slices_do_not_differ_by_a_hex_digit(self):
  60. assert _request(filament_colours=["#e8b00c"]).filament_colours == ["#E8B00C"]
  61. def test_strips_surrounding_whitespace(self):
  62. assert _request(filament_colours=[" #E8B00C "]).filament_colours == ["#E8B00C"]
  63. def test_empty_string_survives_as_a_per_slot_opt_out(self):
  64. # The list is index-aligned with filament_presets, so "no colour for
  65. # slot 2" has to be expressible without shortening the list and
  66. # shifting every slot after it.
  67. assert _request(filament_colours=["#E8B00C", "", "#112233"]).filament_colours == [
  68. "#E8B00C",
  69. "",
  70. "#112233",
  71. ]
  72. @pytest.mark.parametrize("bad", ["red", "00AE42", "#00AE4", "#GGHHII", "#00AE42FFFF", "rgb(0,0,0)"])
  73. def test_rejects_anything_that_is_not_a_hex_colour(self, bad):
  74. # The value is pasted into a profile the slicer parses, so a malformed
  75. # one is refused here rather than passed through to the CLI.
  76. with pytest.raises(ValidationError, match="filament_colours"):
  77. _request(filament_colours=[bad])
  78. def test_the_rejection_names_the_offending_slot(self):
  79. with pytest.raises(ValidationError, match=r"filament_colours\[1\]"):
  80. _request(filament_colours=["#00AE42", "nope"])
  81. class TestThePresetDefaultReader:
  82. def test_reads_the_one_element_array_form(self):
  83. assert _preset_default_colour({"default_filament_colour": ["#123456"]}) == "#123456"
  84. def test_reads_the_bare_string_form(self):
  85. # Hand-written and older profiles store a scalar where the slicers
  86. # store a one-element array.
  87. assert _preset_default_colour({"default_filament_colour": "#123456"}) == "#123456"
  88. @pytest.mark.parametrize(
  89. "profile",
  90. [{}, {"default_filament_colour": []}, {"default_filament_colour": None}, {"default_filament_colour": " "}],
  91. )
  92. def test_absent_or_empty_reads_as_no_colour(self, profile):
  93. assert _preset_default_colour(profile) == ""
  94. class TestTheSourcePlateColours:
  95. def test_reads_the_designed_colours_in_slot_order(self):
  96. assert _source_plate_colours(_project_3mf(["PLA", "PETG"], ["#AA0000", "#00BB00"])) == [
  97. "#AA0000",
  98. "#00BB00",
  99. ]
  100. def test_an_stl_has_none(self):
  101. assert _source_plate_colours(b"solid cube\nendsolid cube\n") == []
  102. def test_a_mesh_only_3mf_has_none(self):
  103. # A CAD or Blender export is a valid 3MF with no project settings.
  104. # This is the case that behaves exactly like an STL, and the reason
  105. # the fix could not stop at "3MFs carry their colours".
  106. buffer = io.BytesIO()
  107. with zipfile.ZipFile(buffer, "w") as archive:
  108. archive.writestr("3D/3dmodel.model", "<model/>")
  109. assert _source_plate_colours(buffer.getvalue()) == []
  110. def test_a_truncated_archive_reads_as_none_rather_than_raising(self):
  111. assert _source_plate_colours(b"PK\x03\x04 truncated") == []
  112. class TestThePriorityChain:
  113. def test_the_requested_colour_is_written_to_filament_colour(self):
  114. patched = _patch_filament_colours([_filament("Generic PLA Silk")], ["#E8B00C"], b"")
  115. assert _colour_of(patched[0]) == ["#E8B00C"]
  116. def test_it_is_written_as_a_one_element_array(self):
  117. # The shape every other per-filament field uses. A bare string parses
  118. # as JSON but not as a slicer config value.
  119. patched = _patch_filament_colours([_filament("Generic PLA Silk")], ["#E8B00C"], b"")
  120. assert isinstance(json.loads(patched[0])["filament_colour"], list)
  121. def test_the_presets_own_default_is_used_when_the_caller_named_none(self):
  122. profile = _filament("Vendor PLA", default_filament_colour=["#123456"])
  123. assert _colour_of(_patch_filament_colours([profile], [], b"")[0]) == ["#123456"]
  124. def test_an_explicit_colour_outranks_the_presets_default(self):
  125. profile = _filament("Vendor PLA", default_filament_colour=["#123456"])
  126. assert _colour_of(_patch_filament_colours([profile], ["#ABCDEF"], b"")[0]) == ["#ABCDEF"]
  127. def test_the_source_plates_colour_is_the_last_resort(self):
  128. source = _project_3mf(["PLA", "PETG"], ["#AA0000", "#00BB00"])
  129. patched = _patch_filament_colours([_filament("A"), _filament("B")], [], source)
  130. assert [_colour_of(p) for p in patched] == [["#AA0000"], ["#00BB00"]]
  131. def test_the_presets_default_outranks_the_source_plate(self):
  132. # The preset is what the user just picked; the source colour is what
  133. # the file happened to be designed with.
  134. source = _project_3mf(["PLA"], ["#AA0000"])
  135. profile = _filament("Vendor PLA", default_filament_colour=["#123456"])
  136. assert _colour_of(_patch_filament_colours([profile], [], source)[0]) == ["#123456"]
  137. def test_an_empty_string_falls_through_to_the_next_source(self):
  138. source = _project_3mf(["PLA"], ["#AA0000"])
  139. assert _colour_of(_patch_filament_colours([_filament("A")], [""], source)[0]) == ["#AA0000"]
  140. def test_a_slot_with_no_colour_anywhere_is_left_untouched(self):
  141. # Not given a guess: the slicer's default is still wrong, but it is
  142. # the same wrong value the file would have had regardless, and an
  143. # invented one would be indistinguishable from a real choice.
  144. patched = _patch_filament_colours([_filament("Generic PLA Silk")], [], b"")
  145. assert "filament_colour" not in json.loads(patched[0])
  146. def test_a_short_colour_list_leaves_the_remaining_slots_to_the_chain(self):
  147. source = _project_3mf(["PLA", "PETG"], ["#AA0000", "#00BB00"])
  148. patched = _patch_filament_colours([_filament("A"), _filament("B")], ["#E8B00C"], source)
  149. assert [_colour_of(p) for p in patched] == [["#E8B00C"], ["#00BB00"]]
  150. def test_more_colours_than_slots_is_not_an_error(self):
  151. patched = _patch_filament_colours([_filament("A")], ["#E8B00C", "#112233"], b"")
  152. assert [_colour_of(p) for p in patched] == [["#E8B00C"]]
  153. def test_slot_order_is_preserved(self):
  154. patched = _patch_filament_colours(
  155. [_filament("A"), _filament("B"), _filament("C")],
  156. ["#110000", "#001100", "#000011"],
  157. b"",
  158. )
  159. assert [_colour_of(p) for p in patched] == [["#110000"], ["#001100"], ["#000011"]]
  160. def test_every_other_field_of_the_profile_survives(self):
  161. profile = _filament("Generic PLA Silk", filament_max_volumetric_speed=["7.5"])
  162. patched = json.loads(_patch_filament_colours([profile], ["#E8B00C"], b"")[0])
  163. assert patched["name"] == "Generic PLA Silk"
  164. assert patched["inherits"] == "Generic PLA Silk"
  165. assert patched["from"] == "system"
  166. assert patched["type"] == "filament"
  167. assert patched["filament_max_volumetric_speed"] == ["7.5"]
  168. def test_an_empty_slot_list_is_a_no_op(self):
  169. assert _patch_filament_colours([], ["#E8B00C"], b"") == []
  170. class TestItNeverFailsASliceThatWouldOtherwiseSucceed:
  171. def test_an_unparseable_profile_is_passed_through(self):
  172. # Same reasoning as the bed-type patch: a colour is not worth losing
  173. # a slice over. The slicer will reject the profile itself if it is
  174. # genuinely broken, with a better message than we could write.
  175. assert _patch_filament_colours(["{not json"], ["#E8B00C"], b"") == ["{not json"]
  176. def test_a_json_profile_that_is_not_an_object_is_passed_through(self):
  177. assert _patch_filament_colours(["[1, 2, 3]"], ["#E8B00C"], b"") == ["[1, 2, 3]"]
  178. def test_an_unreadable_source_still_lets_the_requested_colour_through(self):
  179. patched = _patch_filament_colours([_filament("A")], ["#E8B00C"], b"not a zip")
  180. assert _colour_of(patched[0]) == ["#E8B00C"]