Просмотр исходного кода

Record the colour a slice is actually printed in (issue #2977)

    Every file the internal slicer produced came back with filament_colour
    = #00AE42 whatever filament was picked: a green plate thumbnail, green
    metadata, and a "Color mismatch" in the Print dialog against the AMS
    slot the job had just been correctly mapped to.

    A colour is not a property of a filament preset in either slicer. It
    belongs to the project, and Bambu Studio and OrcaSlicer set it from the
    plate in their GUIs -- so nothing was attached to the preset Bambuddy
    sends by name, and the CLI fell back to its own compiled-in default,
    which is Bambu green. None of the shipped BBL filament profiles define
    filament_colour; the whole tree has zero occurrences.

    default_filament_colour is not the answer on its own. Measured against
    a 02.08.02.61 sidecar, a profile carrying only that still slices to
    filament_colour ["#00AE42"] -- Bambu Studio consumes it in the GUI when
    a project is created, not in --load-filaments. So it is read and
    rewritten as filament_colour, which the same sidecar does honour: the
    reporter's exact triplet returns ["#E8B00C"] when patched this way, and
    the colour lands in both project_settings.config and slice_info.config,
    which is what the thumbnail and the AMS mapping actually read.

    Each filament row in the slice dialog gets a colour control, and the
    resolved value flows through a chain: the user's pick, then the preset's
    own default_filament_colour, then the colour that slot was designed with
    in the source 3MF. A slot with none of the three is left untouched
    rather than given a guess.

    The designed colour is read from project_settings.config, not
    slice_info.config. The latter records what the file was last sliced
    with, which for a source that never carried a colour is #00AE42 itself
    -- measured -- so using it would have been circular.

    The control is offered on single-filament sources too, because an STL,
    and equally a mesh-only 3MF exported from CAD, has no colour anywhere
    else to inherit. That is the case this exists for, and it took three
    shapes to make it visible: a swatch in the label row was identical to
    the read-only dot multi-colour rows have carried for releases, and
    adding the hex beside it only made it look like a caption. It now sits
    beside the dropdown, styled like it and the same height, with the
    swatch and hex wrapped in one label bound to the input so a click
    anywhere on it opens the picker.

    An untouched slot with no designed colour submits an empty string
    rather than the control's displayed default. A sent colour outranks the
    preset's own, so pinning the placeholder would silently discard the
    real colour of an imported OrcaSlicer profile that carries one.

    Slicer Pipelines pick up the same chain without carrying a colour of
    their own.

    Also adds a warning for a defect found while investigating this: a
    filament preset whose name the sidecar's bundle cannot resolve is not
    rejected. The CLI inherits nothing, falls back to its defaults for
    every field, and returns a well-formed success -- measured, an
    unresolvable name slices as filament_type ["PLA"] at nozzle_temperature
    ["200"] with filament_ids [""] and filament_vendor ["(Undefined)"], so
    a PETG preset a sidecar image predates prints at PLA temperatures with
    no diagnostic anywhere. Both signals are required together, which keeps
    it off the two legitimate lookalikes: a hand-written profile that never
    named a vendor still carries a real filament id, and a user's own cloud
    preset carries a vendor while legitimately having no bundled id. The
    file is kept rather than refused, unlike the missing start G-code of
    whoever can see the temperatures.
MartinNYHC 1 неделя назад
Родитель
Сommit
b2dda1fa5f

+ 121 - 1
backend/app/api/routes/library.py

@@ -73,7 +73,12 @@ from backend.app.services.design_settings import (
 from backend.app.services.filament_requirements import annotate_rack_groups
 from backend.app.services.plate_thumbnail import inject_plate_thumbnails_if_missing
 from backend.app.services.process_overrides import apply_process_overrides
-from backend.app.services.slice_output_check import missing_start_gcode_message, start_gcode_is_missing
+from backend.app.services.slice_output_check import (
+    missing_start_gcode_message,
+    start_gcode_is_missing,
+    unresolved_filament_message,
+    unresolved_filament_slots,
+)
 from backend.app.services.stl_thumbnail import MIN_USABLE_STL_BYTES, generate_stl_thumbnail
 from backend.app.utils.filename import (
     MAX_FILENAME_BYTES,
@@ -3617,6 +3622,101 @@ def _patch_process_bed_type(process_json: str, bed_type: str) -> str:
     return json.dumps(profile)
 
 
+def _source_plate_colours(model_bytes: bytes) -> list[str]:
+    """Per-slot colours the source 3MF was designed with, or ``[]``.
+
+    Read from ``project_settings.config`` rather than ``slice_info.config``:
+    the latter records the colour the file was *last sliced* with, which for a
+    source that never carried one is the slicer's own #00AE42 default — the
+    exact value #2977 is about, so using it as a fallback would be circular.
+    STL and mesh-only 3MF sources have no project settings and yield ``[]``.
+    """
+    from io import BytesIO
+
+    try:
+        with zipfile.ZipFile(BytesIO(model_bytes), "r") as zf:
+            return [str(f.get("color") or "") for f in extract_project_filaments_from_3mf(zf)]
+    except (zipfile.BadZipFile, OSError, ValueError):
+        return []
+
+
+def _preset_default_colour(profile: dict) -> str:
+    """A filament preset's own ``default_filament_colour``, or ``""``.
+
+    OrcaSlicer's third-party vendor profiles carry this; Bambu Studio's
+    bundled BBL filament profiles carry it nowhere (checked across the whole
+    shipped `resources/profiles/BBL/filament/` tree — zero occurrences), which
+    is why it can only ever be one link in the chain and never the whole fix.
+
+    It is read here and rewritten as ``filament_colour`` because the CLI does
+    not read it itself. Measured against a 02.08.02.61 sidecar: a profile
+    carrying only ``default_filament_colour: ["#FF00FF"]`` still slices to
+    ``filament_colour: ["#00AE42"]``. Bambu Studio consumes the default in the
+    GUI when a project is created, not in ``--load-filaments``.
+    """
+    raw = profile.get("default_filament_colour")
+    if isinstance(raw, list):
+        raw = raw[0] if raw else None
+    return raw.strip() if isinstance(raw, str) else ""
+
+
+def _patch_filament_colours(
+    filament_jsons: list[str],
+    requested: list[str],
+    model_bytes: bytes,
+) -> list[str]:
+    """Write ``filament_colour`` onto each resolved filament profile (#2977).
+
+    Neither slicer stores a colour on a filament *preset* — it is a per-project
+    property their GUIs set from the plate — so a CLI slice with no colour
+    supplied records Bambu Studio's compiled-in default for every slot. That
+    default is `#00AE42`, which is why every internal-slicer output was green
+    regardless of the filament picked, and why the print dialog's AMS mapping
+    reported a colour mismatch against whatever was actually loaded.
+
+    Per slot, first non-empty of:
+
+    1. the caller's explicit colour (the SliceModal's per-slot swatch),
+    2. the preset's own ``default_filament_colour``,
+    3. the colour the source 3MF's plate was designed with.
+
+    All three empty means the slot is left untouched rather than being given a
+    guess: the slicer's default is then still wrong, but it is at least the
+    same wrong value the file would have had before this function existed.
+
+    Returns a new list; a profile that isn't parseable JSON is passed through
+    unchanged, on the same reasoning as ``_patch_process_bed_type`` — a colour
+    is not worth failing a slice that would otherwise succeed.
+    """
+    source_colours = _source_plate_colours(model_bytes) if filament_jsons else []
+    patched: list[str] = []
+    for i, raw in enumerate(filament_jsons):
+        try:
+            profile = json.loads(raw)
+        except json.JSONDecodeError:
+            logger.warning("Filament colour skipped for slot %d: profile is not valid JSON", i + 1)
+            patched.append(raw)
+            continue
+        if not isinstance(profile, dict):
+            patched.append(raw)
+            continue
+        colour = (
+            (requested[i].strip() if i < len(requested) and requested[i] else "")
+            or _preset_default_colour(profile)
+            or (source_colours[i].strip() if i < len(source_colours) and source_colours[i] else "")
+        )
+        if not colour:
+            patched.append(raw)
+            continue
+        # One-element array: the same shape the CLI uses for every other
+        # per-filament field (`filament_type`, `filament_vendor`), and the
+        # shape a `--load-filaments` profile is parsed as. A bare string is
+        # accepted by the JSON parser but not by the config deserialiser.
+        profile["filament_colour"] = [colour]
+        patched.append(json.dumps(profile))
+    return patched
+
+
 # Support-related keys we lift from the source 3MF's project_settings.config
 # into the picked process preset before `--load-settings` sees it (#1881).
 # BambuStudio's shipped process presets ("0.20mm Standard @BBL H2D" etc.)
@@ -3843,6 +3943,11 @@ async def _run_slicer_with_fallback(
         assert ref is not None, "schema validator guarantees filament list is non-None"
         filament_jsons.append(await resolve_preset_ref(db, user, ref, "filament"))
 
+    # Give every slot a colour before anything else touches the list, so the
+    # unused-slot substitution below propagates a complete profile rather than
+    # one that still has to be patched afterwards (#2977).
+    filament_jsons = _patch_filament_colours(filament_jsons, request.filament_colours, model_bytes)
+
     # Bed-type override (#1337): patch curr_bed_type onto the resolved
     # process JSON so the slicer's StaticPrintConfig pass picks up the
     # user's pick instead of whatever the process preset defaults to.
@@ -4278,6 +4383,21 @@ async def _run_slicer_with_fallback(
         )
         raise HTTPException(status_code=502, detail=missing_start_gcode_message(request.printer_preset.id))
 
+    # Found while investigating #2977: a filament preset the sidecar's bundle
+    # cannot resolve is not an error there — the CLI inherits nothing and
+    # slices with its own defaults, so a PETG pick comes back as PLA at 200 C.
+    # Warned rather than refused: the file prints, and the user may well have
+    # meant to slice with a profile their sidecar image predates. Skipped on
+    # the embedded-settings path, which sends no filament profiles for the
+    # bundle to resolve in the first place.
+    if not used_embedded_settings:
+        unresolved = unresolved_filament_slots(result.content, export_3mf=bool(request.export_3mf))
+        if unresolved:
+            logger.warning(
+                "%s",
+                unresolved_filament_message(unresolved, [ref.id for ref in request.filament_presets]),
+            )
+
     return result, used_embedded_settings
 
 

+ 51 - 0
backend/app/schemas/slicer.py

@@ -1,9 +1,15 @@
 """Pydantic schemas for slice requests."""
 
+import re
 from typing import Any, Literal
 
 from pydantic import BaseModel, Field, model_validator
 
+# `#RRGGBB` or `#RRGGBBAA`. Bambu Studio writes the 6-digit form into
+# `filament_colour` but accepts and round-trips the 8-digit one, and the AMS
+# reports colours with an alpha byte, so both have to pass.
+_HEX_COLOUR = re.compile(r"#(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{8})")
+
 
 class PresetRef(BaseModel):
     """A source-aware reference to a printer / process / filament preset.
@@ -67,6 +73,34 @@ class SliceRequest(BaseModel):
     # is empty so older clients keep working.
     filament_presets: list[PresetRef] = Field(default_factory=list)
 
+    # Per-slot filament colour, plate-slot-ordered like ``filament_presets``.
+    # Neither Bambu Studio nor OrcaSlicer store a colour on a *filament preset*
+    # — it is a per-project property their GUIs set from the plate — so the CLI
+    # falls back to its compiled-in default (#00AE42, Bambu green) for every
+    # slice unless something supplies one. That default is what #2977 saw: a
+    # green plate thumbnail, `filament_colour = #00AE42` in the output, and a
+    # "Color mismatch" against the AMS slot the print was mapped to.
+    #
+    # `default_filament_colour` is NOT a substitute. Measured against a
+    # 02.08.02.61 sidecar: sending it alone leaves `filament_colour` at
+    # #00AE42, because the CLI never reads it — it is consumed by the GUI when
+    # initialising a project. The colour has to be written to `filament_colour`
+    # itself, which is what this field ends up doing.
+    filament_colours: list[str] = Field(
+        default_factory=list,
+        description=(
+            "Per-slot filament colour as ``#RRGGBB`` / ``#RRGGBBAA``, in the same "
+            "plate-slot order as ``filament_presets``. Written onto each resolved "
+            "filament profile as ``filament_colour`` so the sliced file records the "
+            "colour actually being printed instead of the slicer's built-in default "
+            "(#2977). A shorter list than ``filament_presets`` leaves the remaining "
+            "slots to the fallback chain; an empty string in any position does the "
+            "same for that one slot. An omitted list (older clients) falls back to "
+            "the preset's own ``default_filament_colour``, then to the colour the "
+            "source file's plate was designed with."
+        ),
+    )
+
     plate: int | None = Field(
         default=None,
         ge=0,
@@ -201,6 +235,23 @@ class SliceRequest(BaseModel):
             # Multi-color caller: backfill the singular from the first slot
             # so callers that still read the legacy field see a stable value.
             self.filament_preset = self.filament_presets[0]
+
+        # Colours are pasted straight into a profile the slicer parses, so a
+        # malformed one is rejected here rather than passed through. Empty
+        # strings survive: they are how a caller says "no colour for this
+        # slot" without having to shorten the list and shift every slot after
+        # it. Normalised to upper-case so a slice never differs from another
+        # only by the case of a hex digit.
+        normalised: list[str] = []
+        for i, colour in enumerate(self.filament_colours):
+            value = (colour or "").strip()
+            if not value:
+                normalised.append("")
+                continue
+            if not _HEX_COLOUR.fullmatch(value):
+                raise ValueError(f"filament_colours[{i}] must be '#RRGGBB' or '#RRGGBBAA', got {colour!r}")
+            normalised.append("#" + value[1:].upper())
+        self.filament_colours = normalised
         return self
 
 

+ 82 - 0
backend/app/services/slice_output_check.py

@@ -11,6 +11,18 @@ so the only place to catch it is here, on the bytes the slicer just produced.
 All 56 instantiable presets in the shipped Bambu bundle carry
 ``gcode_claim_action``, which makes its absence a reliable signal rather than
 a heuristic.
+
+``unresolved_filament_slots`` covers a quieter failure found while
+investigating #2977: a filament profile whose name the sidecar's bundle
+cannot resolve is not rejected. The CLI inherits nothing, falls back to its
+compiled-in defaults for every field, and returns a perfectly well-formed
+success. Measured against a 02.08.02.61 sidecar, a profile named for a preset
+that does not exist slices as ``filament_type: ["PLA"]`` at
+``nozzle_temperature: ["200"]`` with ``filament_ids: [""]`` and
+``filament_vendor: ["(Undefined)"]`` — so a PETG preset that fails to resolve
+prints at PLA temperatures. Unlike the missing start G-code this does not make
+the file unprintable, only wrong, so it is reported as a warning and the slice
+is kept.
 """
 
 from __future__ import annotations
@@ -93,3 +105,73 @@ def missing_start_gcode_message(printer_preset_name: str) -> str:
         "companion profile that holds the real start G-code for most Bambu printers. "
         "Update the sidecar and slice again."
     )
+
+
+# What the CLI writes into a filament slot it could not resolve. Bambu Studio
+# uses this literal for a filament whose vendor is unknown, and it is the one
+# field that separates "nothing inherited" from a legitimately vendor-less
+# profile: a resolved preset always carries a real ``filament_ids`` entry
+# (``GFL96`` for Generic PLA Silk, ``GFG99`` for Generic PETG), while an
+# unresolved one carries the empty string.
+_UNDEFINED_VENDOR = "(Undefined)"
+
+
+def unresolved_filament_slots(content: bytes, *, export_3mf: bool) -> list[int]:
+    """1-indexed filament slots the slicer could not resolve a preset for.
+
+    Empty whenever the question cannot be settled — a raw-G-code response (the
+    per-slot config only exists in the 3MF), an unreadable archive, a missing
+    or malformed config. Same principle as ``start_gcode_is_missing``: a check
+    that recognises one specific defect must not report anything it has not
+    actually seen.
+
+    Both signals are required together. ``filament_vendor`` alone would flag a
+    hand-written profile that simply never named a vendor, and ``filament_ids``
+    alone would flag a user's own cloud preset, which legitimately carries no
+    bundled filament id. A slot that has neither inherited a vendor nor been
+    given an id is one where the ``inherits:`` target did not exist.
+    """
+    if not content or not export_3mf:
+        return []
+
+    try:
+        with zipfile.ZipFile(io.BytesIO(content)) as archive:
+            raw = archive.read(_PROJECT_SETTINGS)
+        settings = json.loads(raw)
+    except (KeyError, OSError, zipfile.BadZipFile, UnicodeDecodeError, json.JSONDecodeError) as exc:
+        logger.debug("Filament resolution check skipped: cannot read %s (%s)", _PROJECT_SETTINGS, exc)
+        return []
+
+    if not isinstance(settings, dict):
+        return []
+    vendors = settings.get("filament_vendor")
+    ids = settings.get("filament_ids")
+    if not isinstance(vendors, list) or not isinstance(ids, list):
+        logger.debug("Filament resolution check skipped: no per-slot vendor/id arrays")
+        return []
+
+    unresolved: list[int] = []
+    for slot in range(min(len(vendors), len(ids))):
+        if _as_text(vendors[slot]).strip() == _UNDEFINED_VENDOR and not _as_text(ids[slot]).strip():
+            unresolved.append(slot + 1)
+    return unresolved
+
+
+def unresolved_filament_message(slots: list[int], preset_names: list[str]) -> str:
+    """The warning logged for slots whose filament preset did not resolve.
+
+    Names the presets by the slot they were picked for, because the user picked
+    them per slot and that is the only handle they have on which dropdown to
+    change.
+    """
+    parts: list[str] = []
+    for slot in slots:
+        name = preset_names[slot - 1] if slot - 1 < len(preset_names) else ""
+        parts.append(f"slot {slot} ({name})" if name else f"slot {slot}")
+    return (
+        f"The slicer could not resolve the filament preset for {', '.join(parts)}, so those slots "
+        "were sliced with its built-in defaults (PLA, 200 C) instead of the preset's own settings. "
+        "The file was kept, but check the temperatures before printing. This usually means the "
+        "slicer sidecar's bundled profiles do not contain the preset that was picked - updating "
+        "the sidecar image, or picking a preset from its own bundled list, resolves it."
+    )

+ 229 - 0
backend/tests/unit/test_filament_colour_2977.py

@@ -0,0 +1,229 @@
+"""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"]

+ 153 - 0
backend/tests/unit/test_slice_unresolved_filament_2977.py

@@ -0,0 +1,153 @@
+"""A filament preset the sidecar could not resolve must not pass unnoticed.
+
+Found while investigating #2977. Bambuddy sends a filament profile as a stub
+naming the preset to inherit, and the sidecar's resolver walks that name
+against its bundled profile tree. When the name is not in that tree the CLI
+does not fail: it inherits nothing, falls back to its compiled-in defaults for
+every field, and returns a well-formed success.
+
+Measured against a 02.08.02.61 sidecar, a stub naming a preset that does not
+exist slices as::
+
+    filament_type        ["PLA"]
+    nozzle_temperature   ["200"]
+    filament_ids         [""]
+    filament_vendor      ["(Undefined)"]
+
+-- so a PETG preset whose name that sidecar image predates prints at PLA
+temperatures with no diagnostic anywhere. Unlike the missing start G-code of
+#2838 the file is printable, just wrong, so this is reported and the slice is
+kept rather than refused.
+"""
+
+import io
+import json
+import zipfile
+
+import pytest
+
+from backend.app.services.slice_output_check import (
+    unresolved_filament_message,
+    unresolved_filament_slots,
+)
+
+pytestmark = pytest.mark.unit
+
+
+def _3mf(settings: dict | None, *, valid_zip: bool = True) -> bytes:
+    if not valid_zip:
+        return b"not a zip file at all"
+    buffer = io.BytesIO()
+    with zipfile.ZipFile(buffer, "w") as archive:
+        if settings is not None:
+            archive.writestr("Metadata/project_settings.config", json.dumps(settings))
+        archive.writestr("Metadata/plate_1.gcode", "G1 X0 Y0\n")
+    return buffer.getvalue()
+
+
+# What the sidecar returns for a stub whose `inherits:` target it resolved.
+RESOLVED = {"filament_vendor": ["Generic"], "filament_ids": ["GFL96"]}
+# ... and for one it did not.
+UNRESOLVED = {"filament_vendor": ["(Undefined)"], "filament_ids": [""]}
+
+
+class TestItRecognisesTheDefect:
+    def test_a_resolved_slot_is_not_flagged(self):
+        assert unresolved_filament_slots(_3mf(RESOLVED), export_3mf=True) == []
+
+    def test_an_unresolved_slot_is_flagged_by_its_one_indexed_position(self):
+        assert unresolved_filament_slots(_3mf(UNRESOLVED), export_3mf=True) == [1]
+
+    def test_only_the_unresolved_slots_of_a_multi_colour_slice_are_flagged(self):
+        settings = {
+            "filament_vendor": ["Generic", "(Undefined)", "Bambu"],
+            "filament_ids": ["GFL96", "", "GFA00"],
+        }
+        assert unresolved_filament_slots(_3mf(settings), export_3mf=True) == [2]
+
+    def test_every_slot_can_be_flagged(self):
+        settings = {"filament_vendor": ["(Undefined)"] * 3, "filament_ids": [""] * 3}
+        assert unresolved_filament_slots(_3mf(settings), export_3mf=True) == [1, 2, 3]
+
+
+class TestBothSignalsAreRequiredTogether:
+    def test_a_vendorless_profile_that_still_resolved_is_not_flagged(self):
+        # A hand-written profile may simply never have named a vendor. It
+        # inherited fine, which the real filament id proves.
+        settings = {"filament_vendor": ["(Undefined)"], "filament_ids": ["GFL96"]}
+        assert unresolved_filament_slots(_3mf(settings), export_3mf=True) == []
+
+    def test_a_users_own_cloud_preset_is_not_flagged(self):
+        # Cloud presets legitimately carry no bundled filament id; the vendor
+        # is what separates them from a slot that inherited nothing.
+        settings = {"filament_vendor": ["Bambu"], "filament_ids": [""]}
+        assert unresolved_filament_slots(_3mf(settings), export_3mf=True) == []
+
+    def test_whitespace_does_not_read_as_a_real_filament_id(self):
+        settings = {"filament_vendor": ["(Undefined)"], "filament_ids": ["  "]}
+        assert unresolved_filament_slots(_3mf(settings), export_3mf=True) == [1]
+
+
+class TestItAnswersEmptyWhenItCannotSeeTheAnswer:
+    def test_a_raw_gcode_response_carries_no_per_slot_config(self):
+        assert unresolved_filament_slots(_3mf(UNRESOLVED), export_3mf=False) == []
+
+    def test_empty_content(self):
+        assert unresolved_filament_slots(b"", export_3mf=True) == []
+
+    def test_an_unreadable_archive(self):
+        assert unresolved_filament_slots(_3mf(None, valid_zip=False), export_3mf=True) == []
+
+    def test_a_missing_project_settings(self):
+        assert unresolved_filament_slots(_3mf(None), export_3mf=True) == []
+
+    def test_malformed_json(self):
+        buffer = io.BytesIO()
+        with zipfile.ZipFile(buffer, "w") as archive:
+            archive.writestr("Metadata/project_settings.config", "{not json")
+        assert unresolved_filament_slots(buffer.getvalue(), export_3mf=True) == []
+
+    def test_settings_that_are_not_an_object(self):
+        buffer = io.BytesIO()
+        with zipfile.ZipFile(buffer, "w") as archive:
+            archive.writestr("Metadata/project_settings.config", "[1, 2, 3]")
+        assert unresolved_filament_slots(buffer.getvalue(), export_3mf=True) == []
+
+    def test_missing_vendor_or_id_arrays(self):
+        assert unresolved_filament_slots(_3mf({"filament_ids": [""]}), export_3mf=True) == []
+        assert unresolved_filament_slots(_3mf({"filament_vendor": ["(Undefined)"]}), export_3mf=True) == []
+
+    def test_scalar_rather_than_array_fields(self):
+        settings = {"filament_vendor": "(Undefined)", "filament_ids": ""}
+        assert unresolved_filament_slots(_3mf(settings), export_3mf=True) == []
+
+    def test_mismatched_array_lengths_only_compare_the_overlap(self):
+        settings = {"filament_vendor": ["(Undefined)", "(Undefined)"], "filament_ids": [""]}
+        assert unresolved_filament_slots(_3mf(settings), export_3mf=True) == [1]
+
+
+class TestTheMessage:
+    def test_it_names_the_slot_and_the_preset_that_was_picked(self):
+        message = unresolved_filament_message([2], ["Generic PLA", "Creality PETG DBA"])
+        assert "slot 2 (Creality PETG DBA)" in message
+
+    def test_it_names_every_affected_slot(self):
+        message = unresolved_filament_message([1, 3], ["A", "B", "C"])
+        assert "slot 1 (A)" in message
+        assert "slot 3 (C)" in message
+
+    def test_a_slot_with_no_matching_preset_name_is_still_named(self):
+        # The names come from the request's preset list, which a caller could
+        # send shorter than the slice actually had slots.
+        assert "slot 4" in unresolved_filament_message([4], ["A"])
+
+    def test_it_says_the_file_was_kept(self):
+        assert "kept" in unresolved_filament_message([1], ["A"])
+
+    def test_it_names_the_wrong_defaults_the_user_should_check(self):
+        message = unresolved_filament_message([1], ["A"])
+        assert "PLA" in message
+        assert "200" in message
+
+    def test_it_is_plain_ascii_so_it_survives_every_log_sink(self):
+        unresolved_filament_message([1], ["A"]).encode("ascii")

+ 286 - 1
frontend/src/__tests__/components/SliceModal.test.tsx

@@ -9,7 +9,7 @@
  */
 
 import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
-import { screen, waitFor, within } from '@testing-library/react';
+import { fireEvent, screen, waitFor, within } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
 import { render } from '../utils';
 import { SliceModal } from '../../components/SliceModal';
@@ -259,6 +259,10 @@ describe('SliceModal', () => {
         process_preset: { source: 'local', id: '2' },
         filament_preset: { source: 'local', id: '3' },
         filament_presets: [{ source: 'local', id: '3' }],
+        // An STL has no designed colour and the swatch was not touched, so
+        // the slot is handed back to the backend's fallback chain rather
+        // than being pinned to the picker's displayed default (#2977).
+        filament_colours: [''],
       });
     });
     await waitFor(() => expect(onClose).toHaveBeenCalled());
@@ -1723,6 +1727,287 @@ describe('SliceModal', () => {
     });
   });
 
+  /**
+   * Per-slot filament colour (#2977).
+   *
+   * A filament preset carries no colour in either slicer -- colour is a
+   * per-project property their GUIs set from the plate -- so a slice that
+   * supplies none records the CLI's compiled-in #00AE42 for every slot. That
+   * is what made every internal-slicer thumbnail Bambu green regardless of
+   * the filament picked, and what made the print dialog report a colour
+   * mismatch against the AMS slot it had just correctly mapped to.
+   *
+   * The swatch is the only place the colour can come from for an STL, which
+   * has none anywhere else, so it is offered for single-slot sources too.
+   */
+  describe('filament colour swatch', () => {
+    function colourInputs(): HTMLInputElement[] {
+      return screen
+        .getAllByLabelText('Filament colour')
+        .filter((el): el is HTMLInputElement => el instanceof HTMLInputElement);
+    }
+
+    it('shows the hex beside the swatch so it reads as a control, not a decoration', async () => {
+      // The reason this exists: a bare dot next to a label was taken for the
+      // read-only swatch multi-colour rows already had, so on the STL -- the
+      // one source with no colour to inherit -- nothing said it was settable.
+      renderWithTracker({
+        source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' },
+        onClose: vi.fn(),
+      });
+
+      await waitFor(() => expect(screen.getByText('My Custom X1C')).toBeDefined());
+      expect(screen.getByText('#00AE42')).toBeDefined();
+    });
+
+    it('puts the whole control on the dropdown row, not in the label', async () => {
+      // Sitting beside the <select> and styled like it is what makes it read
+      // as a control. In the label row it read as a caption on the label.
+      renderWithTracker({
+        source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' },
+        onClose: vi.fn(),
+      });
+
+      await waitFor(() => expect(screen.getByText('My Custom X1C')).toBeDefined());
+      const row = colourInputs()[0].closest('div');
+      expect(row?.querySelector('select')).not.toBeNull();
+    });
+
+    it('wraps the swatch and its hex in one label bound to the input', async () => {
+      // So a click anywhere on the pill opens the picker, rather than only a
+      // 16px dot being live.
+      renderWithTracker({
+        source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' },
+        onClose: vi.fn(),
+      });
+
+      await waitFor(() => expect(screen.getByText('My Custom X1C')).toBeDefined());
+      const pill = screen.getByText('#00AE42').closest('label');
+      expect(pill?.getAttribute('for')).toBe(colourInputs()[0].id);
+      expect(pill?.contains(colourInputs()[0])).toBe(true);
+    });
+
+    it('the hex follows the swatch when it is changed', async () => {
+      renderWithTracker({
+        source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' },
+        onClose: vi.fn(),
+      });
+
+      await waitFor(() => expect(screen.getByText('My Custom X1C')).toBeDefined());
+      fireEvent.change(colourInputs()[0], { target: { value: '#e8b00c' } });
+      await waitFor(() => expect(screen.getByText('#E8B00C')).toBeDefined());
+    });
+
+    it('paints the colour on the input itself, not only via the native swatch', async () => {
+      // An engine that does not paint ::-webkit-color-swatch would otherwise
+      // leave an empty ring, which is indistinguishable from no swatch.
+      renderWithTracker({
+        source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' },
+        onClose: vi.fn(),
+      });
+
+      await waitFor(() => expect(screen.getByText('My Custom X1C')).toBeDefined());
+      expect(colourInputs()[0].style.backgroundColor).not.toBe('');
+    });
+
+    it('offers a colour swatch for a single-slot STL, which has no colour of its own', async () => {
+      renderWithTracker({
+        source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' },
+        onClose: vi.fn(),
+      });
+
+      await waitFor(() => expect(screen.getByText('My Custom X1C')).toBeDefined());
+      expect(colourInputs()).toHaveLength(1);
+    });
+
+    it("shows the slicer's own default for a source that carries no colour", async () => {
+      // Not an invented placeholder: #00AE42 is exactly what the slice would
+      // record if nothing were sent, so the swatch tells the truth about the
+      // file that is about to be produced.
+      renderWithTracker({
+        source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' },
+        onClose: vi.fn(),
+      });
+
+      await waitFor(() => expect(screen.getByText('My Custom X1C')).toBeDefined());
+      expect(colourInputs()[0].value).toBe('#00ae42');
+    });
+
+    it("pre-fills each slot from the source plate's designed colour", async () => {
+      mockApi.getLibraryFilePlates.mockResolvedValue(makeMultiColorPlateResponse());
+      mockApi.getLibraryFileFilamentRequirements.mockResolvedValue(makeMultiColorRequirementsResponse());
+      mockApi.getSlicerPresets.mockResolvedValue(makeColorAwarePresets());
+
+      renderWithTracker({
+        source: { kind: 'libraryFile', id: 100, filename: 'TwoColor.3mf' },
+        onClose: vi.fn(),
+      });
+
+      await waitFor(() => expect(screen.getByText('X1C')).toBeDefined());
+      expect(colourInputs().map((i) => i.value)).toEqual(['#000000', '#ffffff']);
+    });
+
+    it("sends the source plate's colours when the swatches are left alone", async () => {
+      mockApi.getLibraryFilePlates.mockResolvedValue(makeMultiColorPlateResponse());
+      mockApi.getLibraryFileFilamentRequirements.mockResolvedValue(makeMultiColorRequirementsResponse());
+      mockApi.getSlicerPresets.mockResolvedValue(makeColorAwarePresets());
+      mockApi.sliceLibraryFile.mockResolvedValue({
+        job_id: 42,
+        status: 'pending',
+        status_url: '/api/v1/slice-jobs/42',
+      });
+
+      renderWithTracker({
+        source: { kind: 'libraryFile', id: 100, filename: 'TwoColor.3mf' },
+        onClose: vi.fn(),
+      });
+
+      await waitFor(() => expect(screen.getByText('X1C')).toBeDefined());
+      await userEvent.setup().click(screen.getByRole('button', { name: /^Slice$/ }));
+
+      await waitFor(() => {
+        expect(mockApi.sliceLibraryFile).toHaveBeenCalledWith(
+          100,
+          expect.objectContaining({ filament_colours: ['#000000', '#FFFFFF'] }),
+        );
+      });
+    });
+
+    it('sends an empty string for a slot with no colour, so the backend fallbacks still run', async () => {
+      // A sent colour outranks the preset's own default_filament_colour, so
+      // pinning the picker's displayed #00AE42 here would silently discard
+      // the real colour of an Orca-imported profile that carries one.
+      mockApi.sliceLibraryFile.mockResolvedValue({
+        job_id: 42,
+        status: 'pending',
+        status_url: '/api/v1/slice-jobs/42',
+      });
+
+      renderWithTracker({
+        source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' },
+        onClose: vi.fn(),
+      });
+
+      await waitFor(() => expect(screen.getByText('My Custom X1C')).toBeDefined());
+      await userEvent.setup().click(screen.getByRole('button', { name: /^Slice$/ }));
+
+      await waitFor(() => {
+        expect(mockApi.sliceLibraryFile).toHaveBeenCalledWith(
+          100,
+          expect.objectContaining({ filament_colours: [''] }),
+        );
+      });
+    });
+
+    it('sends the user\'s pick, upper-cased, once a swatch is changed', async () => {
+      mockApi.sliceLibraryFile.mockResolvedValue({
+        job_id: 42,
+        status: 'pending',
+        status_url: '/api/v1/slice-jobs/42',
+      });
+
+      renderWithTracker({
+        source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' },
+        onClose: vi.fn(),
+      });
+
+      await waitFor(() => expect(screen.getByText('My Custom X1C')).toBeDefined());
+      const user = userEvent.setup();
+      // A colour input has no text entry; fire the change the picker would.
+      fireEvent.change(colourInputs()[0], { target: { value: '#e8b00c' } });
+      await waitFor(() => expect(colourInputs()[0].value).toBe('#e8b00c'));
+
+      await user.click(screen.getByRole('button', { name: /^Slice$/ }));
+      await waitFor(() => {
+        expect(mockApi.sliceLibraryFile).toHaveBeenCalledWith(
+          100,
+          expect.objectContaining({ filament_colours: ['#E8B00C'] }),
+        );
+      });
+    });
+
+    it('only overrides the slot that was changed', async () => {
+      mockApi.getLibraryFilePlates.mockResolvedValue(makeMultiColorPlateResponse());
+      mockApi.getLibraryFileFilamentRequirements.mockResolvedValue(makeMultiColorRequirementsResponse());
+      mockApi.getSlicerPresets.mockResolvedValue(makeColorAwarePresets());
+      mockApi.sliceLibraryFile.mockResolvedValue({
+        job_id: 42,
+        status: 'pending',
+        status_url: '/api/v1/slice-jobs/42',
+      });
+
+      renderWithTracker({
+        source: { kind: 'libraryFile', id: 100, filename: 'TwoColor.3mf' },
+        onClose: vi.fn(),
+      });
+
+      await waitFor(() => expect(screen.getByText('X1C')).toBeDefined());
+      fireEvent.change(colourInputs()[1], { target: { value: '#112233' } });
+
+      await userEvent.setup().click(screen.getByRole('button', { name: /^Slice$/ }));
+      await waitFor(() => {
+        expect(mockApi.sliceLibraryFile).toHaveBeenCalledWith(
+          100,
+          expect.objectContaining({ filament_colours: ['#000000', '#112233'] }),
+        );
+      });
+    });
+
+    it('trims the alpha byte for display but submits the colour whole', async () => {
+      // The AMS reports colours with an alpha byte and a source 3MF can carry
+      // one; <input type="color"> accepts only the 6-digit form.
+      mockApi.getLibraryFileFilamentRequirements.mockResolvedValue({
+        file_id: 100,
+        filename: 'Alpha.3mf',
+        plate_id: 1,
+        filaments: [{ slot_id: 1, type: 'PLA', color: '#E8B00CFF', used_grams: 10, used_meters: 3 }],
+      });
+      mockApi.sliceLibraryFile.mockResolvedValue({
+        job_id: 42,
+        status: 'pending',
+        status_url: '/api/v1/slice-jobs/42',
+      });
+
+      renderWithTracker({
+        source: { kind: 'libraryFile', id: 100, filename: 'Alpha.3mf' },
+        onClose: vi.fn(),
+      });
+
+      await waitFor(() => expect(screen.getByText('My Custom X1C')).toBeDefined());
+      expect(colourInputs()[0].value).toBe('#e8b00c');
+
+      await userEvent.setup().click(screen.getByRole('button', { name: /^Slice$/ }));
+      await waitFor(() => {
+        expect(mockApi.sliceLibraryFile).toHaveBeenCalledWith(
+          100,
+          expect.objectContaining({ filament_colours: ['#E8B00CFF'] }),
+        );
+      });
+    });
+
+    it('is disabled in "slice as designed" mode, which sends no filament profiles', async () => {
+      mockApi.getLibraryFilePlates.mockResolvedValue({
+        file_id: 100,
+        filename: 'Designed.3mf',
+        plates: [],
+        is_multi_plate: false,
+        embedded_printer: 'Imported X1C 0.4',
+        embedded_process: '0.20mm Standard',
+      });
+
+      renderWithTracker({
+        source: { kind: 'libraryFile', id: 100, filename: 'Designed.3mf' },
+        onClose: vi.fn(),
+      });
+
+      await waitFor(() => expect(screen.getByText('My Custom X1C')).toBeDefined());
+      const toggle = screen.getByRole('checkbox', { name: /built-in settings/i });
+      expect(colourInputs()[0].disabled).toBe(false);
+      await userEvent.setup().click(toggle);
+      expect(colourInputs()[0].disabled).toBe(true);
+    });
+  });
+
 });
 
 // Pure-function tests for the filament slot picker. Pinned as a separate

+ 8 - 0
frontend/src/api/client.ts

@@ -1742,6 +1742,14 @@ export interface SliceRequest {
   // backend validator promotes a singular into a one-element list when this
   // is omitted, so legacy single-color clients keep working unchanged.
   filament_presets?: PresetRef[];
+  // Per-slot filament colour as `#RRGGBB` / `#RRGGBBAA`, same plate order as
+  // `filament_presets` (#2977). Neither slicer stores a colour on a filament
+  // *preset* -- it is a per-project property -- so without this every sliced
+  // file records the CLI's built-in #00AE42 and the print dialog reports a
+  // colour mismatch against whatever is loaded in the AMS. An empty string in
+  // any position hands that slot back to the backend's fallback chain (the
+  // preset's own default_filament_colour, then the source plate's colour).
+  filament_colours?: string[];
   plate?: number;
   export_3mf?: boolean;
   // Build-plate override (#1337). When omitted, the slicer uses the process

+ 114 - 4
frontend/src/components/SliceModal.tsx

@@ -189,6 +189,23 @@ function formatElapsed(seconds: number): string {
   return `${h}h ${remM}m`;
 }
 
+// The slicer's own default when nothing supplies a colour — Bambu green. Shown
+// in the picker for a source that carries no colour of its own (an STL, or a
+// mesh-only 3MF) so the swatch tells the truth about what the slice will
+// produce rather than showing an invented placeholder.
+const SLICER_DEFAULT_COLOUR = '#00AE42';
+
+// `<input type="color">` accepts only `#RRGGBB`. Source colours reach us in
+// both that form and the 8-digit `#RRGGBBAA` the AMS reports, so the alpha byte
+// is trimmed for display only — an untouched slot still submits the original
+// string, alpha included.
+function colourInputValue(raw: string | null | undefined): string {
+  const value = (raw || '').trim();
+  return /^#[0-9a-fA-F]{6}([0-9a-fA-F]{2})?$/.test(value)
+    ? value.slice(0, 7).toUpperCase()
+    : SLICER_DEFAULT_COLOUR;
+}
+
 export function SliceModal({ source, onClose }: SliceModalProps) {
   const { t } = useTranslation();
   const { trackJob } = useSliceJobTracker();
@@ -201,6 +218,13 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
   // entry per AMS slot the plate uses. Pre-pick (effect below) initialises
   // each slot from the source plate's required (type, colour).
   const [filamentPresets, setFilamentPresets] = useState<(PresetRef | null)[]>([]);
+  // Per-slot colour override, plate-slot-ordered alongside `filamentPresets`.
+  // `null` means "not overridden" rather than "no colour": the slot then falls
+  // through to the source plate's own colour, and — when the source has none
+  // either — to the backend's remaining fallbacks. Storing an explicit colour
+  // for every slot up front would defeat that chain, because a sent colour
+  // outranks the preset's own `default_filament_colour` (#2977).
+  const [filamentColours, setFilamentColours] = useState<(string | null)[]>([]);
   const [errorMessage, setErrorMessage] = useState<string | null>(null);
   // null = plate not yet picked (or single-plate / non-3MF — picker is skipped
   // and we'll backfill 1 at submit time). Set to a 1-indexed plate number once
@@ -569,6 +593,16 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
     });
   }, [presetsQuery.data, filamentSlots, selectedPrinterName, compatIndex]);
 
+  // Drop colour overrides when the slot count changes. A plate switch renumbers
+  // the slots, so keeping index-keyed overrides would paint slot 2's colour
+  // onto whatever the new plate happens to call slot 2. Same slot count keeps
+  // them: that is a re-pick of presets, not a different plate layout.
+  useEffect(() => {
+    setFilamentColours((current) =>
+      current.length === filamentSlots.length ? current : filamentSlots.map(() => null),
+    );
+  }, [filamentSlots]);
+
   const enqueueMutation = useMutation({
     mutationFn: async (plate: number | null) => {
       const body = buildSliceBody(plate);
@@ -604,6 +638,14 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
       process_preset: processPreset,
       filament_preset: filamentPresets[0] as PresetRef,
       filament_presets: filamentPresets as PresetRef[],
+      // An untouched slot submits the source plate's own colour, and an empty
+      // string when the source has none — which is what lets the backend fall
+      // back to the preset's `default_filament_colour` for the Orca-imported
+      // profiles that carry one. Sending a placeholder here instead would win
+      // that comparison and silently discard the preset's real colour.
+      filament_colours: filamentSlots.map(
+        (slot, i) => filamentColours[i] ?? slot.color ?? '',
+      ),
       ...(plate != null ? { plate } : {}),
       ...(bedType != null ? { bed_type: bedType } : {}),
       // The preset refs above are still sent (the backend validator requires
@@ -1002,7 +1044,20 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
                         })
                       }
                       disabled={isEnqueuing || !isUsed || useEmbedded}
-                      swatchColor={filamentSlots.length > 1 ? slot.color : undefined}
+                      // Shown for every filament slot now that it is editable,
+                      // not just multi-color ones: a single-slot STL is exactly
+                      // the case with no colour anywhere else to inherit.
+                      swatchColor={filamentColours[idx] ?? slot.color}
+                      swatchColorLabel={t('slice.filamentColour')}
+                      onSwatchColorChange={(colour) =>
+                        setFilamentColours((current) => {
+                          const next = current.length === filamentSlots.length
+                            ? [...current]
+                            : filamentSlots.map((_, i) => current[i] ?? null);
+                          next[idx] = colour;
+                          return next;
+                        })
+                      }
                       selectedPrinterName={selectedPrinterName}
                       compatIndex={compatIndex}
                     />
@@ -1255,7 +1310,7 @@ function BedTypeDropdown({
         value={value ?? ''}
         onChange={(e) => onChange(e.target.value === '' ? null : e.target.value)}
         disabled={disabled}
-        className="w-full px-3 py-2 rounded-md bg-bambu-dark border border-bambu-dark-tertiary text-white text-sm focus:outline-none focus:border-bambu-gray disabled:opacity-50"
+        className="flex-1 min-w-0 px-3 py-2 rounded-md bg-bambu-dark border border-bambu-dark-tertiary text-white text-sm focus:outline-none focus:border-bambu-gray disabled:opacity-50"
       >
         <option value="">{t('slice.bedType.auto')}</option>
         {BED_TYPE_OPTIONS.map((opt) => (
@@ -1279,6 +1334,14 @@ interface PresetDropdownProps {
   // filament slots so the user can see at a glance which slot they're
   // configuring against the source 3MF's per-slot colour.
   swatchColor?: string;
+  // When set, the swatch becomes an editable colour input for this slot
+  // (#2977). Filament presets carry no colour of their own, so for an STL —
+  // or any source whose plate was never given one — this is the only place the
+  // colour recorded in the sliced file can come from.
+  onSwatchColorChange?: (colour: string) => void;
+  // Accessible name for that input. Required alongside the handler because the
+  // visible label belongs to the preset <select>, not to the swatch.
+  swatchColorLabel?: string;
   // Selected printer context (#1325). When provided for a process / filament
   // slot, presets that resolve to a different printer (per compatIndex) are
   // held back behind a "Show all" link instead of padding out the main list.
@@ -1294,6 +1357,8 @@ function PresetDropdown({
   onChange,
   disabled,
   swatchColor,
+  onSwatchColorChange,
+  swatchColorLabel,
   selectedPrinterName,
   compatIndex,
 }: PresetDropdownProps) {
@@ -1306,6 +1371,9 @@ function PresetDropdown({
   // nested. Filament slots render several of these, so the id must be unique
   // per instance rather than derived from the slot name.
   const selectId = useId();
+  // The colour input needs its own id so the hex beside it can be a <label>
+  // for it rather than for the preset <select> it sits next to.
+  const colourId = `${selectId}-colour`;
 
   // Tier sections (imported → cloud → standard), plus — for a process /
   // filament slot with a selected printer — a trailing group of presets that
@@ -1375,10 +1443,12 @@ function PresetDropdown({
     // well as being invalid HTML. The label is bound to the select by id.
     <div className="block">
       <div className="flex items-center gap-2 text-xs text-bambu-gray mb-1">
-        {swatchColor && (
+        {/* Read-only dot for slots with no editable colour. Filament rows
+            carry a real control beside the dropdown instead — see below. */}
+        {!onSwatchColorChange && swatchColor && (
           <span
             className="inline-block w-3 h-3 rounded-full border border-bambu-dark-tertiary"
-            style={{ backgroundColor: swatchColor || 'transparent' }}
+            style={{ backgroundColor: swatchColor }}
             aria-hidden
           />
         )}
@@ -1403,6 +1473,7 @@ function PresetDropdown({
           </span>
         )}
       </div>
+      <div className="flex items-stretch gap-2">
       <select
         id={selectId}
         value={toRefValue(value)}
@@ -1434,6 +1505,45 @@ function PresetDropdown({
           </optgroup>
         )}
       </select>
+      {/* The colour control sits beside the dropdown, styled like it and the
+          same height, because that is what makes it read as a control at all.
+          Two earlier shapes did not: a bare swatch in the label row looked
+          exactly like the read-only dot multi-colour rows had carried for
+          releases, and adding the hex beside it only made it look like a
+          caption. On a single-filament STL — the one source with no colour to
+          inherit, and so the case this exists for — neither said anything was
+          settable. Clicking the label opens the picker, so the hex is a hit
+          target and not a caption. */}
+      {onSwatchColorChange && (
+        <label
+          htmlFor={colourId}
+          title={swatchColorLabel}
+          className={`flex items-center gap-2 px-2.5 rounded-md bg-bambu-dark border border-bambu-dark-tertiary text-sm ${
+            disabled
+              ? 'opacity-50 cursor-not-allowed'
+              : 'cursor-pointer hover:border-bambu-gray transition-colors'
+          }`}
+        >
+          <input
+            id={colourId}
+            type="color"
+            value={colourInputValue(swatchColor)}
+            onChange={(e) => onSwatchColorChange(e.target.value.toUpperCase())}
+            disabled={disabled}
+            aria-label={swatchColorLabel}
+            // Painted explicitly as well as through the native swatch: every
+            // engine fills ::-webkit-color-swatch / ::-moz-color-swatch from
+            // the value, but one that did not would leave an empty ring, and
+            // an unlit swatch is indistinguishable from no swatch at all.
+            style={{ backgroundColor: colourInputValue(swatchColor) }}
+            className="slice-colour-swatch w-4 h-4 shrink-0 rounded-full border border-bambu-dark-tertiary p-0 disabled:cursor-not-allowed enabled:cursor-pointer"
+          />
+          <span className="font-mono text-xs text-white tracking-tight">
+            {colourInputValue(swatchColor)}
+          </span>
+        </label>
+      )}
+      </div>
     </div>
   );
 }

+ 1 - 0
frontend/src/i18n/locales/de.ts

@@ -4399,6 +4399,7 @@ export default {
     process: 'Prozess-Profil',
     filament: 'Filament-Profil',
     filamentSlot: 'Filament {{index}} – {{type}}',
+    filamentColour: 'Filamentfarbe',
     selectPreset: '— Profil auswählen —',
     loadingPresets: 'Profile werden geladen…',
     analyzingPlateFilaments: 'Plattenfilamente werden analysiert…',

+ 1 - 0
frontend/src/i18n/locales/en.ts

@@ -4434,6 +4434,7 @@ export default {
     process: 'Process profile',
     filament: 'Filament profile',
     filamentSlot: 'Filament {{index}} ({{type}})',
+    filamentColour: 'Filament colour',
     selectPreset: '— Select a preset —',
     loadingPresets: 'Loading presets…',
     analyzingPlateFilaments: 'Analyzing plate filaments…',

+ 1 - 0
frontend/src/i18n/locales/es.ts

@@ -4401,6 +4401,7 @@ export default {
     process: 'Perfil de proceso',
     filament: 'Perfil de filamento',
     filamentSlot: 'Filamento {{index}} ({{type}})',
+    filamentColour: 'Color del filamento',
     selectPreset: '— Seleccione un preajuste —',
     loadingPresets: 'Cargando preajustes…',
     analyzingPlateFilaments: 'Analizando los filamentos de la cama…',

+ 1 - 0
frontend/src/i18n/locales/fr.ts

@@ -4388,6 +4388,7 @@ export default {
     process: 'Profil de processus',
     filament: 'Profil de filament',
     filamentSlot: 'Filament {{index}} – {{type}}',
+    filamentColour: 'Couleur du filament',
     selectPreset: '— Sélectionner un préréglage —',
     loadingPresets: 'Chargement des préréglages…',
     analyzingPlateFilaments: 'Analyse des filaments de la plaque…',

+ 1 - 0
frontend/src/i18n/locales/it.ts

@@ -4387,6 +4387,7 @@ export default {
     process: 'Profilo processo',
     filament: 'Profilo filamento',
     filamentSlot: 'Filamento {{index}} ({{type}})',
+    filamentColour: 'Colore del filamento',
     selectPreset: '— Seleziona un preset —',
     loadingPresets: 'Caricamento preset…',
     analyzingPlateFilaments: 'Analisi filamenti del piano…',

+ 1 - 0
frontend/src/i18n/locales/ja.ts

@@ -4399,6 +4399,7 @@ export default {
     process: 'プロセスプロファイル',
     filament: 'フィラメントプロファイル',
     filamentSlot: 'フィラメント {{index}}({{type}})',
+    filamentColour: 'フィラメントの色',
     selectPreset: '— プリセットを選択 —',
     loadingPresets: 'プリセットを読み込み中…',
     analyzingPlateFilaments: 'プレートのフィラメントを分析中…',

+ 1 - 0
frontend/src/i18n/locales/ko.ts

@@ -4187,6 +4187,7 @@ export default {
     process: '프로세스 프로필',
     filament: '필라멘트 프로필',
     filamentSlot: '필라멘트 {{index}} ({{type}})',
+    filamentColour: '필라멘트 색상',
     selectPreset: '— 프리셋 선택 —',
     loadingPresets: '프리셋 불러오는 중…',
     analyzingPlateFilaments: '플레이트 필라멘트 분석 중…',

+ 1 - 0
frontend/src/i18n/locales/nl.ts

@@ -4434,6 +4434,7 @@ export default {
     process: 'Procesprofiel',
     filament: 'Filamentprofiel',
     filamentSlot: 'Filament {{index}} ({{type}})',
+    filamentColour: 'Filamentkleur',
     selectPreset: '— Selecteer een preset —',
     loadingPresets: 'Presets laden…',
     analyzingPlateFilaments: 'Filamenten op plaat analyseren…',

+ 1 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -4387,6 +4387,7 @@ export default {
     process: 'Perfil de processo',
     filament: 'Perfil de filamento',
     filamentSlot: 'Filamento {{index}} ({{type}})',
+    filamentColour: 'Cor do filamento',
     selectPreset: '— Selecione uma predefinição —',
     loadingPresets: 'Carregando predefinições…',
     analyzingPlateFilaments: 'Analisando filamentos da mesa…',

+ 1 - 0
frontend/src/i18n/locales/ru.ts

@@ -4182,6 +4182,7 @@ export default {
     process: "Профиль процесса",
     filament: "Профиль филамента",
     filamentSlot: "Филамент {{index}} ({{type}})",
+    filamentColour: "Цвет филамента",
     selectPreset: "— Выберите профиль —",
     loadingPresets: "Загрузка профилей…",
     analyzingPlateFilaments: "Анализ филаментов пластины…",

+ 1 - 0
frontend/src/i18n/locales/tr.ts

@@ -4388,6 +4388,7 @@ export default {
     process: 'İşlem profili',
     filament: 'Filament profili',
     filamentSlot: 'Filament {{index}} ({{type}})',
+    filamentColour: 'Filament rengi',
     selectPreset: '— Bir ön ayar seçin —',
     loadingPresets: 'Ön ayarlar yükleniyor…',
     analyzingPlateFilaments: 'Plaka filamentleri analiz ediliyor…',

+ 1 - 0
frontend/src/i18n/locales/uk.ts

@@ -4432,6 +4432,7 @@ export default {
     process: "Профіль процесу",
     filament: "Профіль філаменту",
     filamentSlot: "Філамент {{index}} ({{type}})",
+    filamentColour: "Колір філаменту",
     selectPreset: "— Виберіть профіль —",
     loadingPresets: "Завантаження профілів…",
     analyzingPlateFilaments: "Аналіз філаментів пластини…",

+ 1 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -4387,6 +4387,7 @@ export default {
     process: '工艺配置',
     filament: '耗材配置',
     filamentSlot: '耗材 {{index}}({{type}})',
+    filamentColour: '耗材颜色',
     selectPreset: '— 选择预设 —',
     loadingPresets: '加载预设中…',
     analyzingPlateFilaments: '分析打印板耗材中…',

+ 1 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -4387,6 +4387,7 @@ export default {
     process: '製程設定檔',
     filament: '耗材設定檔',
     filamentSlot: '耗材 {{index}}({{type}})',
+    filamentColour: '耗材顏色',
     selectPreset: '— 選擇預設 —',
     loadingPresets: '載入預設中…',
     analyzingPlateFilaments: '分析列印板耗材中…',

+ 26 - 0
frontend/src/index.css

@@ -579,3 +579,29 @@ body {
     opacity: 1;
   }
 }
+
+/* Per-slot filament colour swatch in the slice modal (#2977). A native
+   <input type="color"> paints its value inside a fixed inner padding and its
+   own border, so at 14px the colour reads as a dot inside a pale ring rather
+   than as the round swatch it replaced. These reset both so the element is
+   nothing but the colour. */
+.slice-colour-swatch {
+  -webkit-appearance: none;
+  -moz-appearance: none;
+  appearance: none;
+  overflow: hidden;
+}
+
+.slice-colour-swatch::-webkit-color-swatch-wrapper {
+  padding: 0;
+}
+
+.slice-colour-swatch::-webkit-color-swatch {
+  border: none;
+  border-radius: 999px;
+}
+
+.slice-colour-swatch::-moz-color-swatch {
+  border: none;
+  border-radius: 999px;
+}

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-CS9PSj5u.js


Разница между файлами не показана из-за своего большого размера
+ 1 - 0
static/assets/index-CexGkv-b.css


Разница между файлами не показана из-за своего большого размера
+ 0 - 1
static/assets/index-q2IPtdZB.css


+ 2 - 2
static/index.html

@@ -26,8 +26,8 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-4PE719xk.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-q2IPtdZB.css">
+    <script type="module" crossorigin src="/assets/index-CS9PSj5u.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-CexGkv-b.css">
   </head>
   <body>
     <div id="root"></div>

Некоторые файлы не были показаны из-за большого количества измененных файлов