| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229 |
- """Per-slot filament colour on a slice request (#2977).
- Neither Bambu Studio nor OrcaSlicer stores a colour on a filament *preset* --
- it is a per-project property their GUIs set from the plate -- so a CLI slice
- that supplies no colour records the slicer's compiled-in default for every
- slot. That default is ``#00AE42``, which is why every internal-slicer output
- was Bambu green whatever filament was picked, why the plate thumbnail was
- green, and why the print dialog reported a colour mismatch against the AMS
- slot the job had just been correctly mapped to.
- ``default_filament_colour`` is not a substitute and these tests do not treat
- it as one. Measured against a 02.08.02.61 sidecar, a profile carrying only
- ``default_filament_colour: ["#FF00FF"]`` still slices to
- ``filament_colour: ["#00AE42"]``: the CLI never reads it, because Bambu Studio
- consumes it in the GUI when a project is created. It is read here and
- rewritten as ``filament_colour``, which the CLI does honour -- the same
- sidecar returns ``filament_colour: ["#E8B00C"]`` for a profile patched this
- way.
- """
- import io
- import json
- import zipfile
- import pytest
- from pydantic import ValidationError
- from backend.app.api.routes.library import (
- _patch_filament_colours,
- _preset_default_colour,
- _source_plate_colours,
- )
- from backend.app.schemas.slicer import PresetRef, SliceRequest
- pytestmark = pytest.mark.unit
- def _filament(name: str, **extra) -> str:
- return json.dumps({"name": name, "inherits": name, "from": "system", "type": "filament", **extra})
- def _colour_of(profile_json: str) -> list | None:
- return json.loads(profile_json).get("filament_colour")
- def _project_3mf(types: list[str], colours: list[str]) -> bytes:
- buffer = io.BytesIO()
- with zipfile.ZipFile(buffer, "w") as archive:
- archive.writestr(
- "Metadata/project_settings.config",
- json.dumps({"filament_type": types, "filament_colour": colours}),
- )
- return buffer.getvalue()
- def _request(**kwargs) -> SliceRequest:
- return SliceRequest(
- printer_preset=PresetRef(source="standard", id="Bambu Lab A1 mini 0.4 nozzle"),
- process_preset=PresetRef(source="standard", id="0.20mm Standard @BBL A1M"),
- filament_presets=[PresetRef(source="standard", id="Generic PLA Silk")],
- **kwargs,
- )
- class TestTheRequestField:
- def test_absent_by_default_so_older_clients_are_unchanged(self):
- assert _request().filament_colours == []
- def test_accepts_six_and_eight_digit_hex(self):
- # The AMS reports colours with an alpha byte and the slicer writes
- # them without one; a request may legitimately carry either.
- assert _request(filament_colours=["#00AE42", "#AABBCCDD"]).filament_colours == [
- "#00AE42",
- "#AABBCCDD",
- ]
- def test_normalises_case_so_two_equal_slices_do_not_differ_by_a_hex_digit(self):
- assert _request(filament_colours=["#e8b00c"]).filament_colours == ["#E8B00C"]
- def test_strips_surrounding_whitespace(self):
- assert _request(filament_colours=[" #E8B00C "]).filament_colours == ["#E8B00C"]
- def test_empty_string_survives_as_a_per_slot_opt_out(self):
- # The list is index-aligned with filament_presets, so "no colour for
- # slot 2" has to be expressible without shortening the list and
- # shifting every slot after it.
- assert _request(filament_colours=["#E8B00C", "", "#112233"]).filament_colours == [
- "#E8B00C",
- "",
- "#112233",
- ]
- @pytest.mark.parametrize("bad", ["red", "00AE42", "#00AE4", "#GGHHII", "#00AE42FFFF", "rgb(0,0,0)"])
- def test_rejects_anything_that_is_not_a_hex_colour(self, bad):
- # The value is pasted into a profile the slicer parses, so a malformed
- # one is refused here rather than passed through to the CLI.
- with pytest.raises(ValidationError, match="filament_colours"):
- _request(filament_colours=[bad])
- def test_the_rejection_names_the_offending_slot(self):
- with pytest.raises(ValidationError, match=r"filament_colours\[1\]"):
- _request(filament_colours=["#00AE42", "nope"])
- class TestThePresetDefaultReader:
- def test_reads_the_one_element_array_form(self):
- assert _preset_default_colour({"default_filament_colour": ["#123456"]}) == "#123456"
- def test_reads_the_bare_string_form(self):
- # Hand-written and older profiles store a scalar where the slicers
- # store a one-element array.
- assert _preset_default_colour({"default_filament_colour": "#123456"}) == "#123456"
- @pytest.mark.parametrize(
- "profile",
- [{}, {"default_filament_colour": []}, {"default_filament_colour": None}, {"default_filament_colour": " "}],
- )
- def test_absent_or_empty_reads_as_no_colour(self, profile):
- assert _preset_default_colour(profile) == ""
- class TestTheSourcePlateColours:
- def test_reads_the_designed_colours_in_slot_order(self):
- assert _source_plate_colours(_project_3mf(["PLA", "PETG"], ["#AA0000", "#00BB00"])) == [
- "#AA0000",
- "#00BB00",
- ]
- def test_an_stl_has_none(self):
- assert _source_plate_colours(b"solid cube\nendsolid cube\n") == []
- def test_a_mesh_only_3mf_has_none(self):
- # A CAD or Blender export is a valid 3MF with no project settings.
- # This is the case that behaves exactly like an STL, and the reason
- # the fix could not stop at "3MFs carry their colours".
- buffer = io.BytesIO()
- with zipfile.ZipFile(buffer, "w") as archive:
- archive.writestr("3D/3dmodel.model", "<model/>")
- assert _source_plate_colours(buffer.getvalue()) == []
- def test_a_truncated_archive_reads_as_none_rather_than_raising(self):
- assert _source_plate_colours(b"PK\x03\x04 truncated") == []
- class TestThePriorityChain:
- def test_the_requested_colour_is_written_to_filament_colour(self):
- patched = _patch_filament_colours([_filament("Generic PLA Silk")], ["#E8B00C"], b"")
- assert _colour_of(patched[0]) == ["#E8B00C"]
- def test_it_is_written_as_a_one_element_array(self):
- # The shape every other per-filament field uses. A bare string parses
- # as JSON but not as a slicer config value.
- patched = _patch_filament_colours([_filament("Generic PLA Silk")], ["#E8B00C"], b"")
- assert isinstance(json.loads(patched[0])["filament_colour"], list)
- def test_the_presets_own_default_is_used_when_the_caller_named_none(self):
- profile = _filament("Vendor PLA", default_filament_colour=["#123456"])
- assert _colour_of(_patch_filament_colours([profile], [], b"")[0]) == ["#123456"]
- def test_an_explicit_colour_outranks_the_presets_default(self):
- profile = _filament("Vendor PLA", default_filament_colour=["#123456"])
- assert _colour_of(_patch_filament_colours([profile], ["#ABCDEF"], b"")[0]) == ["#ABCDEF"]
- def test_the_source_plates_colour_is_the_last_resort(self):
- source = _project_3mf(["PLA", "PETG"], ["#AA0000", "#00BB00"])
- patched = _patch_filament_colours([_filament("A"), _filament("B")], [], source)
- assert [_colour_of(p) for p in patched] == [["#AA0000"], ["#00BB00"]]
- def test_the_presets_default_outranks_the_source_plate(self):
- # The preset is what the user just picked; the source colour is what
- # the file happened to be designed with.
- source = _project_3mf(["PLA"], ["#AA0000"])
- profile = _filament("Vendor PLA", default_filament_colour=["#123456"])
- assert _colour_of(_patch_filament_colours([profile], [], source)[0]) == ["#123456"]
- def test_an_empty_string_falls_through_to_the_next_source(self):
- source = _project_3mf(["PLA"], ["#AA0000"])
- assert _colour_of(_patch_filament_colours([_filament("A")], [""], source)[0]) == ["#AA0000"]
- def test_a_slot_with_no_colour_anywhere_is_left_untouched(self):
- # Not given a guess: the slicer's default is still wrong, but it is
- # the same wrong value the file would have had regardless, and an
- # invented one would be indistinguishable from a real choice.
- patched = _patch_filament_colours([_filament("Generic PLA Silk")], [], b"")
- assert "filament_colour" not in json.loads(patched[0])
- def test_a_short_colour_list_leaves_the_remaining_slots_to_the_chain(self):
- source = _project_3mf(["PLA", "PETG"], ["#AA0000", "#00BB00"])
- patched = _patch_filament_colours([_filament("A"), _filament("B")], ["#E8B00C"], source)
- assert [_colour_of(p) for p in patched] == [["#E8B00C"], ["#00BB00"]]
- def test_more_colours_than_slots_is_not_an_error(self):
- patched = _patch_filament_colours([_filament("A")], ["#E8B00C", "#112233"], b"")
- assert [_colour_of(p) for p in patched] == [["#E8B00C"]]
- def test_slot_order_is_preserved(self):
- patched = _patch_filament_colours(
- [_filament("A"), _filament("B"), _filament("C")],
- ["#110000", "#001100", "#000011"],
- b"",
- )
- assert [_colour_of(p) for p in patched] == [["#110000"], ["#001100"], ["#000011"]]
- def test_every_other_field_of_the_profile_survives(self):
- profile = _filament("Generic PLA Silk", filament_max_volumetric_speed=["7.5"])
- patched = json.loads(_patch_filament_colours([profile], ["#E8B00C"], b"")[0])
- assert patched["name"] == "Generic PLA Silk"
- assert patched["inherits"] == "Generic PLA Silk"
- assert patched["from"] == "system"
- assert patched["type"] == "filament"
- assert patched["filament_max_volumetric_speed"] == ["7.5"]
- def test_an_empty_slot_list_is_a_no_op(self):
- assert _patch_filament_colours([], ["#E8B00C"], b"") == []
- class TestItNeverFailsASliceThatWouldOtherwiseSucceed:
- def test_an_unparseable_profile_is_passed_through(self):
- # Same reasoning as the bed-type patch: a colour is not worth losing
- # a slice over. The slicer will reject the profile itself if it is
- # genuinely broken, with a better message than we could write.
- assert _patch_filament_colours(["{not json"], ["#E8B00C"], b"") == ["{not json"]
- def test_a_json_profile_that_is_not_an_object_is_passed_through(self):
- assert _patch_filament_colours(["[1, 2, 3]"], ["#E8B00C"], b"") == ["[1, 2, 3]"]
- def test_an_unreadable_source_still_lets_the_requested_colour_through(self):
- patched = _patch_filament_colours([_filament("A")], ["#E8B00C"], b"not a zip")
- assert _colour_of(patched[0]) == ["#E8B00C"]
|