diff --git a/backend/app/api/routes/library.py b/backend/app/api/routes/library.py
index 6f3d7796a..b8987eb3a 100644
--- a/backend/app/api/routes/library.py
+++ b/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
diff --git a/backend/app/schemas/slicer.py b/backend/app/schemas/slicer.py
index 2000f8894..9bc2e6e11 100644
--- a/backend/app/schemas/slicer.py
+++ b/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
diff --git a/backend/app/services/slice_output_check.py b/backend/app/services/slice_output_check.py
index fc030b4ee..750f0c817 100644
--- a/backend/app/services/slice_output_check.py
+++ b/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."
+ )
diff --git a/backend/tests/unit/test_filament_colour_2977.py b/backend/tests/unit/test_filament_colour_2977.py
new file mode 100644
index 000000000..77b27c9c2
--- /dev/null
+++ b/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", "")
+ 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"]
diff --git a/backend/tests/unit/test_slice_unresolved_filament_2977.py b/backend/tests/unit/test_slice_unresolved_filament_2977.py
new file mode 100644
index 000000000..f178320b8
--- /dev/null
+++ b/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")
diff --git a/frontend/src/__tests__/components/SliceModal.test.tsx b/frontend/src/__tests__/components/SliceModal.test.tsx
index 37f430aec..39f583e11 100644
--- a/frontend/src/__tests__/components/SliceModal.test.tsx
+++ b/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