Ver Fonte

fix(queue): an override counts an equivalent filament type in its colour; preheat and keep-warm heat the bed for the plate the job prints; queue colour names and the colour name saved with an override follow the filament's product line

maziggy há 3 dias atrás
pai
commit
a6a4b7648c

+ 4 - 0
CHANGELOG.md

@@ -164,6 +164,10 @@ All notable changes to Bambuddy will be documented in this file.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 
 ### Fixed
+- **Filament matching, preheat temperatures and colour names follow the plate's own filaments** — Three cases:
+  - **An "Any model" job with a filament override** waited forever when the printers held an equivalent filament under another name (PA-CF loaded for a PA12-CF override). The type check already counted it as the same filament, but the colour check that picks the printer did not.
+  - **Preheating a plate of a multi-filament project** heated the bed for the hottest filament of the whole project: a PLA plate in a project that also holds PETG was preheated, and kept warm between jobs, at the PETG temperature. Preheat and keep-warm now heat the bed for the filaments of the plate the job prints.
+  - **Colour names on the queue page** came from the plain filament type, so a colour named differently in a product line (the same black is "Charcoal" in PLA Matte) showed the generic name. The queue page, and the colour name the print dialog saves with a filament override, now name it within the filament's product line, as the print dialog already showed it. Jobs queued before keep the name they were saved with.
 - **Stopping and cancelling leave nothing running or reserved** — Four cases:
   - **Cancelling a pipeline run** set its queued jobs to cancelled, but a job already preheating went on heating the printer (up to its soak time), and its share of the cost centre's budget stayed reserved. Both now happen as when the job is cancelled from the queue.
   - **Stopping a job while its printer is offline** left its share of the cost centre's budget reserved until Bambuddy restarted; it is now released at once.

+ 2 - 0
backend/app/api/routes/archives.py

@@ -4758,6 +4758,8 @@ async def get_archive_plates(
                                     "slot_id": int(filament_id),
                                     "type": filament_type,
                                     "color": filament_color,
+                                    # The preset the slicer assigned (GFA01, ...): names the product line
+                                    "tray_info_idx": filament_elem.get("tray_info_idx", "") or "",
                                     "used_grams": round(used_grams, 1),
                                     "used_meters": float(used_m) if used_m else 0,
                                 }

+ 2 - 0
backend/app/api/routes/library.py

@@ -3593,6 +3593,8 @@ async def get_library_file_plates(
                                     "slot_id": int(filament_id),
                                     "type": filament_type,
                                     "color": filament_color,
+                                    # The preset the slicer assigned (GFA01, ...): names the product line
+                                    "tray_info_idx": filament_elem.get("tray_info_idx", "") or "",
                                     "used_grams": round(used_grams, 1),
                                     "used_meters": float(used_m) if used_m else 0,
                                 }

+ 2 - 0
backend/app/api/routes/printers.py

@@ -1933,6 +1933,8 @@ async def get_printer_file_plates(
                                     "slot_id": int(filament_id),
                                     "type": filament_type,
                                     "color": filament_color,
+                                    # The preset the slicer assigned (GFA01, ...): names the product line
+                                    "tray_info_idx": filament_elem.get("tray_info_idx", "") or "",
                                     "used_grams": round(used_grams, 1),
                                     "used_meters": float(used_m) if used_m else 0,
                                 }

+ 3 - 33
backend/app/services/archive.py

@@ -26,7 +26,7 @@ from backend.app.utils.archive_paths import archive_dir as resolve_archive_dir
 from backend.app.utils.ffmpeg_output import NO_FFMPEG_OUTPUT, summarize_ffmpeg_stderr
 from backend.app.utils.filename import clean_display_name
 from backend.app.utils.safe_path import PathTraversalError, assert_under, safe_join_under
-from backend.app.utils.threemf_tools import bed_temperature_from_config
+from backend.app.utils.threemf_tools import bed_temperature_from_config, used_filament_indexes
 
 logger = logging.getLogger(__name__)
 
@@ -277,36 +277,6 @@ class ThreeMFParser:
             )
         return self.metadata
 
-    def _first_used_filament_index(self, plates: list) -> int:
-        """Position, in the project's per-filament settings, of the first
-        filament the printed plate uses: 0 when that can't be told.
-
-        ``project_settings`` lists one value per filament of the whole
-        project, so the printed plate's own filament picks which applies
-        (a plate printing only filament 3 has filament 3's nozzle
-        temperature). Without a plate number, the only plate decides.
-        """
-        if self.plate_number:
-            printed = [
-                plate
-                for plate in plates
-                if any(
-                    meta.get("key") == "index" and meta.get("value") == str(self.plate_number)
-                    for meta in plate.findall("metadata")
-                )
-            ]
-        else:
-            printed = plates if len(plates) == 1 else []
-        used: list[int] = []
-        for plate in printed[:1]:
-            for filament in plate.findall("filament"):
-                try:
-                    if float(filament.get("used_g", "0")) > 0:
-                        used.append(int(filament.get("id", "")))
-                except (TypeError, ValueError):
-                    continue
-        return min(used) - 1 if used and min(used) > 0 else 0
-
     def _parse_slice_info(self, zf: zipfile.ZipFile):
         """Parse slice_info.config for print settings and printable objects."""
         try:
@@ -395,7 +365,7 @@ class ThreeMFParser:
                         if printable_objects:
                             self.metadata["printable_objects"] = printable_objects
 
-                self._plate_filament_index = self._first_used_filament_index(plates)
+                self._plate_filament_indexes = used_filament_indexes(plates, self.plate_number)
 
                 if any_time_seen:
                     self.metadata["print_time_seconds"] = summed_time
@@ -626,7 +596,7 @@ class ThreeMFParser:
                 if key in data:
                     val = data[key]
                     if isinstance(val, list) and val:
-                        index = getattr(self, "_plate_filament_index", 0)
+                        index = (getattr(self, "_plate_filament_indexes", None) or [0])[0]
                         self.metadata["nozzle_temperature"] = int(float(val[index if index < len(val) else 0]))
                     elif isinstance(val, (int, float, str)):
                         self.metadata["nozzle_temperature"] = int(float(val))

+ 24 - 5
backend/app/services/print_scheduler.py

@@ -89,6 +89,7 @@ from backend.app.utils.threemf_tools import (
     default_plate_number,
     extract_rack_plan_from_3mf,
     extract_slot_extruders_from_3mf,
+    plate_bed_temperature,
     select_plate_gcode_name,
 )
 
@@ -1053,6 +1054,22 @@ def _unmatched_filament_message(required: list[dict], loaded: list[dict]) -> str
     )
 
 
+async def _queued_plate_bed_temperature(item: PrintQueueItem, archive: PrintArchive | None) -> int:
+    """Bed temperature for the plate a queued job prints, 0 when unknown.
+
+    The archive stores the highest bed temperature of any filament in the
+    project, while the job may print another plate than the one archived: a
+    PLA plate of a project that also holds PETG is heated for PLA. Read from
+    the archive's file for the job's own plate, falling back to the stored
+    value when the file can't be read.
+    """
+    stored = int(archive.bed_temperature) if archive and archive.bed_temperature else 0
+    if archive is None or not archive.file_path:
+        return stored
+    temperature = await asyncio.to_thread(plate_bed_temperature, settings.base_dir / archive.file_path, item.plate_id)
+    return temperature or stored
+
+
 def _effective_plate_id(explicit_plate_id: int | None, file_path: Path) -> int:
     """The plate to dispatch, resolved once in ``_start_print`` and reused at
     every call site below it: G-code injection, usage registration, rack-plan
@@ -3585,16 +3602,18 @@ class PrintScheduler:
                 tray_color = tray.get("tray_color") or ""
                 if tray_type:
                     color_norm = tray_color.replace("#", "").lower()[:6]
-                    loaded.add((tray_type.upper(), color_norm))
+                    loaded.add((canonical_filament_type(tray_type), color_norm))
         for vt in raw_data.get("vt_tray") or []:
             vt_type = vt.get("tray_type")
             if vt_type:
                 color_norm = (vt.get("tray_color", "") or "").replace("#", "").lower()[:6]
-                loaded.add((vt_type.upper(), color_norm))
+                loaded.add((canonical_filament_type(vt_type), color_norm))
 
+        # Types compared as the type check compares them: a PA12-CF override
+        # is met by PA-CF in its colour.
         matches = 0
         for o in overrides:
-            o_type = (o.get("type") or "").upper()
+            o_type = canonical_filament_type(o.get("type") or "")
             o_color = (o.get("color") or "").replace("#", "").lower()[:6]
             if (o_type, o_color) in loaded:
                 matches += 1
@@ -7018,7 +7037,7 @@ class PrintScheduler:
             # missing bed_temperature (Orca gcode.3mf exports parse without
             # one) does NOT skip the hold — chamber need gates the feature.
             archive = next_item.archive
-            item_bed = int(archive.bed_temperature) if archive and archive.bed_temperature else 0
+            item_bed = await _queued_plate_bed_temperature(next_item, archive)
             bed_target = max(item_bed, hold_temp)
             if bed_target <= 0:
                 continue
@@ -7392,7 +7411,7 @@ class PrintScheduler:
             self._preheat_flap_to_cooling(item.id, printer)
             return True
 
-        bed_target = int(archive.bed_temperature) if archive and archive.bed_temperature else 0
+        bed_target = await _queued_plate_bed_temperature(item, archive)
         if bed_target <= 0:
             # No bed temperature in the slicer metadata. When the print needs a
             # hot chamber the bed is simply how we heat it, so fall back to the

+ 74 - 3
backend/app/utils/threemf_tools.py

@@ -1050,7 +1050,7 @@ _BED_TEMP_KEYS: dict[str, tuple[str, str]] = {
 _GENERIC_BED_TEMP_KEYS = ("bed_temperature_initial_layer", "bed_temperature")
 
 
-def _plate_temperature(val) -> int | None:
+def _plate_temperature(val, filament_indexes: list[int] | None = None) -> int | None:
     """Bed temperature from one plate-temperature entry, or None.
 
     The plate arrays carry one entry per filament in the project, and a 0 means
@@ -1058,8 +1058,15 @@ def _plate_temperature(val) -> int | None:
     so the print runs at the highest its filaments ask for -- taking entry 0 the
     way the neighbouring scalar settings do would store a 0 for any project
     whose first filament is not one this plate is heated for.
+
+    ``filament_indexes`` (0-based) are the filaments the printed plate uses:
+    only theirs count, so a PLA plate of a project that also holds PETG is not
+    heated to the PETG temperature. Without them every filament counts.
     """
     values = val if isinstance(val, list) else [val]
+    if filament_indexes and isinstance(val, list):
+        picked = [values[i] for i in filament_indexes if 0 <= i < len(values)]
+        values = picked or values
     temps = []
     for entry in values:
         if isinstance(entry, bool) or not isinstance(entry, (int, float, str)):
@@ -1071,18 +1078,19 @@ def _plate_temperature(val) -> int | None:
     return max(temps) if temps else None
 
 
-def bed_temperature_from_config(data: dict) -> int | None:
+def bed_temperature_from_config(data: dict, filament_indexes: list[int] | None = None) -> int | None:
     """Bed temperature for the plate *data* is sliced for, or None (#2989).
 
     *data* is a parsed ``Metadata/project_settings.config``. Lives here rather
     than beside the archive parser so the ingest path and the one-shot backfill
     that repairs archives written before the fix read it exactly the same way.
+    ``filament_indexes``: the printed plate's filaments, see _plate_temperature.
     """
     bed_type = str(data.get("curr_bed_type") or "").strip()
     for key in (*_BED_TEMP_KEYS.get(bed_type, ()), *_GENERIC_BED_TEMP_KEYS):
         if key not in data:
             continue
-        temperature = _plate_temperature(data[key])
+        temperature = _plate_temperature(data[key], filament_indexes)
         # A plate array of all zeros means no filament in the project prints on
         # this plate, which is not a bed temperature -- keep looking rather than
         # recording a 0 that reads as "cold bed".
@@ -1175,6 +1183,69 @@ def _plate_number_of(name: str) -> int | None:
         return None
 
 
+def used_filament_indexes(plates: list, plate_number: int | None) -> list[int]:
+    """Positions, in the project's per-filament settings, of the filaments the
+    printed plate uses, sorted: empty when that can't be told.
+
+    *plates* are the ``<plate>`` elements of ``slice_info.config``, whose
+    filament ids are 1-based. ``project_settings`` lists one value per filament
+    of the whole project, so the printed plate's own filaments pick which apply.
+    Without a plate number, the only plate decides.
+    """
+    if plate_number:
+        printed = [
+            plate
+            for plate in plates
+            if any(
+                meta.get("key") == "index" and meta.get("value") == str(plate_number)
+                for meta in plate.findall("metadata")
+            )
+        ]
+    else:
+        printed = plates if len(plates) == 1 else []
+    used: list[int] = []
+    for plate in printed[:1]:
+        for filament in plate.findall("filament"):
+            try:
+                if float(filament.get("used_g", "0")) > 0:
+                    used.append(int(filament.get("id", "")))
+            except (TypeError, ValueError):
+                continue
+    return sorted(fid - 1 for fid in set(used) if fid > 0)
+
+
+def plate_bed_temperature(file_path: Path, plate_id: int | None) -> int | None:
+    """Bed temperature for the plate of *file_path* that prints, or None.
+
+    An archive stores one bed temperature, the highest any filament of the
+    project asks for, while a queued job may print any plate of it: a PLA plate
+    of a project that also holds PETG is heated for PLA. *plate_id* as a queue
+    job holds it; none (or 0) means the file's default plate, the one dispatched.
+    None when the file can't be read or names no bed temperature, so the caller
+    falls back to the stored value.
+
+    Every failure is None, as in extract_bed_temperature_from_3mf: preheat and
+    keep-warm run this for every job, and a damaged file raises well beyond the
+    expected few (zlib.error from corrupt deflate data, OverflowError from an
+    "inf" temperature). Escaping here would fail the dispatch every round, or
+    end keep-warm for every printer.
+    """
+    try:
+        with zipfile.ZipFile(file_path, "r") as zf:
+            names = zf.namelist()
+            if "Metadata/project_settings.config" not in names:
+                return None
+            data = json.loads(zf.read("Metadata/project_settings.config").decode())
+            slice_info = zf.read("Metadata/slice_info.config") if "Metadata/slice_info.config" in names else None
+        if not isinstance(data, dict):
+            return None
+        plates = ET.fromstring(slice_info).findall("plate") if slice_info else []
+        plate_number = plate_id if plate_id is not None and plate_id > 0 else default_plate_number(names)
+        return bed_temperature_from_config(data, used_filament_indexes(plates, plate_number))
+    except Exception:
+        return None
+
+
 def select_plate_gcode_name(names: list[str], plate_id: int | None) -> str | None:
     """The ``.gcode`` member for exactly ``plate_id``, or None if it isn't there.
 

+ 78 - 0
backend/tests/integration/test_plate_filament_preset.py

@@ -0,0 +1,78 @@
+"""Each plate filament carries the slicer preset it was sliced with (SN-C9).
+
+The plate's type is only "PLA"; the colour catalogue is keyed by product line
+("PLA Matte"), where the same hex has another name. The queue page names a
+colour from these plate listings, so each needs the preset id (tray_info_idx)
+that names the line. All three listings are copies of one parser.
+"""
+
+import io
+import zipfile
+from pathlib import Path
+from unittest.mock import AsyncMock, patch
+
+import pytest
+
+pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
+
+
+def _sliced_3mf() -> bytes:
+    buf = io.BytesIO()
+    with zipfile.ZipFile(buf, "w") as zf:
+        zf.writestr(
+            "Metadata/slice_info.config",
+            "<?xml version='1.0'?><config><plate><metadata key='index' value='1'/>"
+            "<filament id='1' tray_info_idx='GFA01' type='PLA' color='#000000' used_g='5' used_m='1.6'/>"
+            "<filament id='2' type='PLA' color='#FFFFFF' used_g='3' used_m='1'/>"
+            "</plate></config>",
+        )
+        zf.writestr("Metadata/plate_1.gcode", "; plate 1")
+    return buf.getvalue()
+
+
+def _presets(response) -> list[str]:
+    assert response.status_code == 200, response.text
+    return [f["tray_info_idx"] for f in response.json()["plates"][0]["filaments"]]
+
+
+@pytest.fixture
+def base_dir(monkeypatch, tmp_path) -> Path:
+    from backend.app.core.config import settings
+
+    monkeypatch.setattr(settings, "base_dir", tmp_path)
+    return tmp_path
+
+
+async def test_archive_plates(async_client, archive_factory, printer_factory, base_dir):
+    (base_dir / "matte.3mf").write_bytes(_sliced_3mf())
+    printer = await printer_factory()
+    archive = await archive_factory(printer.id, filename="matte.3mf", file_path="matte.3mf")
+
+    response = await async_client.get(f"/api/v1/archives/{archive.id}/plates")
+
+    assert _presets(response) == ["GFA01", ""]
+
+
+async def test_library_file_plates(async_client, db_session, base_dir):
+    from backend.app.models.library import LibraryFile
+
+    path = base_dir / "matte.3mf"
+    path.write_bytes(_sliced_3mf())
+    lib_file = LibraryFile(filename="matte.3mf", file_path=str(path), file_type="3mf", file_size=1)
+    db_session.add(lib_file)
+    await db_session.commit()
+
+    response = await async_client.get(f"/api/v1/library/files/{lib_file.id}/plates")
+
+    assert _presets(response) == ["GFA01", ""]
+
+
+async def test_printer_file_plates(async_client, printer_factory):
+    printer = await printer_factory()
+
+    with patch("backend.app.api.routes.printers._download_printer_file_bytes", AsyncMock(return_value=_sliced_3mf())):
+        response = await async_client.get(
+            f"/api/v1/printers/{printer.id}/files/plates", params={"path": "/cache/matte.3mf"}
+        )
+
+    assert _presets(response) == ["GFA01", ""]

+ 35 - 0
backend/tests/unit/test_override_color_match_equivalent_types.py

@@ -0,0 +1,35 @@
+"""An override's colour matches a tray of an equivalent filament type (SN-C1).
+
+The type check already treats PA12-CF and PA-CF as the same filament, but the
+colour check that ranks printers for an "Any model" job compared the names as
+written. With an override for PA12-CF in a colour, a printer holding PA-CF in
+that colour counted no match and was skipped, so the job waited forever.
+"""
+
+import pytest
+
+from backend.app.services.print_scheduler import PrintScheduler
+
+
+def _raw(tray_type: str, color: str = "FF0000FF") -> dict:
+    return {"ams": [{"id": 0, "tray": [{"id": 0, "tray_type": tray_type, "tray_color": color}]}]}
+
+
+@pytest.mark.parametrize(("wanted", "loaded"), [("PA12-CF", "PA-CF"), ("PA-CF", "PA12-CF")])
+def test_an_equivalent_type_in_the_colour_counts(wanted, loaded):
+    overrides = [{"type": wanted, "color": "#FF0000"}]
+
+    assert PrintScheduler()._count_override_color_matches(1, overrides, _raw(loaded)) == 1
+
+
+def test_another_type_in_the_colour_does_not():
+    overrides = [{"type": "PETG", "color": "#FF0000"}]
+
+    assert PrintScheduler()._count_override_color_matches(1, overrides, _raw("PLA")) == 0
+
+
+def test_an_external_spool_of_an_equivalent_type_counts():
+    overrides = [{"type": "PA-CF", "color": "#FF0000"}]
+    raw = {"ams": [], "vt_tray": [{"id": 254, "tray_type": "PA12-CF", "tray_color": "FF0000FF"}]}
+
+    assert PrintScheduler()._count_override_color_matches(1, overrides, raw) == 1

+ 171 - 0
backend/tests/unit/test_queued_plate_bed_temperature.py

@@ -0,0 +1,171 @@
+"""Preheat heats the bed for the plate the queued job prints (PL-C7).
+
+The archive stores one bed temperature, the highest any filament of the
+project asks for, and preheat and keep-warm used it for every job: a PLA plate
+of a project that also holds PETG was preheated to the PETG temperature. They
+now read the job's own plate from the archive's file.
+"""
+
+import json
+import zipfile
+from pathlib import Path
+from types import SimpleNamespace
+
+import pytest
+
+from backend.app.services.archive import ThreeMFParser
+from backend.app.services.print_scheduler import _queued_plate_bed_temperature
+from backend.app.utils.threemf_tools import plate_bed_temperature
+
+PLA, PETG_PLATE_FILAMENTS = [1], [2, 3]
+
+
+def _3mf(path: Path, plates: dict[int, list[int]], gcode: tuple[int, ...] = ()) -> Path:
+    """Plate N prints the filaments listed for it (1-based ids), PLA at 55,
+    PLA at 60 and PETG at 70; ``gcode`` are the plates sliced."""
+    plate_xml = "".join(
+        f"<plate><metadata key='index' value='{index}'/>"
+        + "".join(f"<filament id='{fid}' type='PLA' color='#FFFFFF' used_g='5'/>" for fid in filaments)
+        + "</plate>"
+        for index, filaments in plates.items()
+    )
+    settings = {"curr_bed_type": "Textured PEI Plate", "textured_plate_temp": ["55", "60", "70"]}
+    with zipfile.ZipFile(path, "w") as zf:
+        zf.writestr("Metadata/slice_info.config", f"<?xml version='1.0'?><config>{plate_xml}</config>")
+        zf.writestr("Metadata/project_settings.config", json.dumps(settings))
+        zf.writestr("3D/3dmodel.model", "<model/>")
+        for index in gcode:
+            zf.writestr(f"Metadata/plate_{index}.gcode", "; plate")
+    return path
+
+
+def test_each_plate_has_its_own_filaments_temperature(tmp_path):
+    path = _3mf(tmp_path / "a.3mf", {1: PLA, 2: PETG_PLATE_FILAMENTS})
+
+    assert plate_bed_temperature(path, 1) == 55
+    assert plate_bed_temperature(path, 2) == 70
+
+
+def test_without_a_plate_the_one_dispatched_decides(tmp_path):
+    """A job without a plate prints the file's default (lowest sliced) plate."""
+    path = _3mf(tmp_path / "a.3mf", {1: PLA, 2: PETG_PLATE_FILAMENTS}, gcode=(1, 2))
+
+    assert plate_bed_temperature(path, None) == 55
+    assert plate_bed_temperature(path, 0) == 55
+
+
+def test_a_plate_whose_filaments_are_unknown_takes_the_highest(tmp_path):
+    assert plate_bed_temperature(_3mf(tmp_path / "a.3mf", {1: [], 2: []}), None) == 70
+
+
+def test_an_unreadable_file_is_none(tmp_path):
+    (tmp_path / "broken.3mf").write_bytes(b"not a zip")
+
+    assert plate_bed_temperature(tmp_path / "broken.3mf", 1) is None
+    assert plate_bed_temperature(tmp_path / "missing.3mf", 1) is None
+
+
+def test_a_damaged_member_is_none(tmp_path):
+    """Corrupt deflate data raises zlib.error, which is no OSError: preheat
+    runs this on every dispatch, so nothing may escape it."""
+    path = tmp_path / "a.3mf"
+    with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as zf:
+        zf.writestr("Metadata/project_settings.config", json.dumps({"bed_temperature": ["60"] * 200}))
+    raw = bytearray(path.read_bytes())
+    start = raw.index(b"PK\x03\x04") + 30 + len("Metadata/project_settings.config")
+    raw[start : start + 8] = b"\xff" * 8
+    path.write_bytes(bytes(raw))
+
+    assert plate_bed_temperature(path, 1) is None
+
+
+def test_an_infinite_temperature_is_none(tmp_path):
+    path = tmp_path / "a.3mf"
+    with zipfile.ZipFile(path, "w") as zf:
+        zf.writestr("Metadata/project_settings.config", json.dumps({"bed_temperature": ["inf"]}))
+
+    assert plate_bed_temperature(path, 1) is None
+
+
+def test_the_archive_keeps_the_projects_highest(tmp_path):
+    """The stored value stays the safe upper bound for any plate of it."""
+    path = _3mf(tmp_path / "a.3mf", {1: PLA, 2: PETG_PLATE_FILAMENTS})
+
+    assert ThreeMFParser(path, plate_number=1).parse().get("bed_temperature") == 70
+
+
+@pytest.fixture
+def base_dir(monkeypatch, tmp_path) -> Path:
+    from backend.app.core.config import settings
+
+    monkeypatch.setattr(settings, "base_dir", tmp_path)
+    return tmp_path
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(("plate_id", "expected"), [(1, 55), (2, 70)])
+async def test_a_job_is_heated_for_its_plate(base_dir, plate_id, expected):
+    _3mf(base_dir / "a.3mf", {1: PLA, 2: PETG_PLATE_FILAMENTS})
+    archive = SimpleNamespace(file_path="a.3mf", bed_temperature=70)
+
+    assert await _queued_plate_bed_temperature(SimpleNamespace(plate_id=plate_id), archive) == expected
+
+
+@pytest.mark.asyncio
+async def test_an_unreadable_file_falls_back_to_the_stored_value(base_dir):
+    archive = SimpleNamespace(file_path="gone.3mf", bed_temperature=65)
+
+    assert await _queued_plate_bed_temperature(SimpleNamespace(plate_id=2), archive) == 65
+
+
+@pytest.mark.asyncio
+async def test_no_archive_is_zero():
+    assert await _queued_plate_bed_temperature(SimpleNamespace(plate_id=None), None) == 0
+
+
+@pytest.mark.asyncio
+async def test_preheat_heats_for_the_job_s_plate(base_dir):
+    from unittest.mock import AsyncMock, patch
+
+    from backend.app.services.print_scheduler import PrintScheduler
+    from backend.tests.unit.test_scheduler_preheat import _ints, _make_client, _make_printer, _make_state
+
+    _3mf(base_dir / "a.3mf", {1: PLA, 2: PETG_PLATE_FILAMENTS})
+    scheduler = PrintScheduler()
+    item = SimpleNamespace(id=42, plate_id=1, preheat_override="on", preheat_chamber_target_override=0)
+    client = _make_client()
+
+    with (
+        patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=False)),
+        patch.object(scheduler, "_get_int_setting", _ints(preheat_soak_seconds=0)),
+        patch("backend.app.services.print_scheduler.printer_manager") as pm,
+        patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
+    ):
+        pm.get_client.return_value = client
+        pm.get_status.return_value = _make_state(55.0, 0.0)
+        await scheduler._preheat_and_soak(
+            AsyncMock(), item, _make_printer("H2D"), SimpleNamespace(file_path="a.3mf", bed_temperature=70)
+        )
+
+    client.set_bed_temperature.assert_called_once_with(55)
+
+
+@pytest.mark.asyncio
+async def test_keep_warm_holds_for_the_next_job_s_plate(base_dir):
+    from unittest.mock import patch
+
+    from backend.app.services.print_scheduler import PrintScheduler
+    from backend.tests.unit.test_scheduler_keep_warm import _make_client, _make_item, _make_state, _run
+
+    _3mf(base_dir / "a.3mf", {1: PLA, 2: PETG_PLATE_FILAMENTS})
+    item = _make_item()
+    item.plate_id = 2
+    item.archive = SimpleNamespace(file_path="a.3mf", bed_temperature=110)
+    client = _make_client()
+
+    with patch("backend.app.services.print_scheduler.printer_manager") as pm:
+        pm.get_status.return_value = _make_state()
+        pm.get_client.return_value = client
+        await _run(PrintScheduler(), items=[item], hold_temp=40)
+
+    client.set_bed_temperature.assert_called_once_with(70)

+ 1 - 1
backend/tests/unit/test_scheduler_keep_warm.py

@@ -57,7 +57,7 @@ def _make_item(
     needed check pass without any AMS-derivation mocking. Set to ``None`` in
     tests that specifically want to exercise the derivation branch.
     """
-    archive = SimpleNamespace(bed_temperature=bed_temperature)
+    archive = SimpleNamespace(file_path=None, bed_temperature=bed_temperature)
     return SimpleNamespace(
         id=item_id,
         printer_id=printer_id,

+ 3 - 3
backend/tests/unit/test_scheduler_preheat.py

@@ -36,7 +36,7 @@ def item():
 
 @pytest.fixture
 def archive():
-    return SimpleNamespace(bed_temperature=60)
+    return SimpleNamespace(file_path=None, bed_temperature=60)
 
 
 def _make_printer(model: str, printer_id: int = 7):
@@ -417,7 +417,7 @@ async def test_no_bed_temperature_but_chamber_needed_heats_bed_to_configured_tem
     """
     db = AsyncMock()
     client = _make_client()
-    bare_archive = SimpleNamespace(bed_temperature=None)
+    bare_archive = SimpleNamespace(file_path=None, bed_temperature=None)
 
     with (
         patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
@@ -449,7 +449,7 @@ async def test_no_bed_temperature_and_no_chamber_target_still_skips(scheduler, i
     """
     db = AsyncMock()
     client = _make_client()
-    bare_archive = SimpleNamespace(bed_temperature=None)
+    bare_archive = SimpleNamespace(file_path=None, bed_temperature=None)
 
     with (
         patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),

+ 2 - 2
backend/tests/unit/test_scheduler_preheat_ams_mapping_2886.py

@@ -153,7 +153,7 @@ async def test_end_to_end_the_pla_job_skips_preheat_entirely(scheduler):
     """
     db = AsyncMock()
     client = _make_client()
-    archive = SimpleNamespace(bed_temperature=None)
+    archive = SimpleNamespace(file_path=None, bed_temperature=None)
 
     with (
         patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
@@ -184,7 +184,7 @@ async def test_end_to_end_a_mapped_asa_job_still_heats_the_bed_to_drive_the_cham
     chamber-heating bed temperature."""
     db = AsyncMock()
     client = _make_client()
-    archive = SimpleNamespace(bed_temperature=None)
+    archive = SimpleNamespace(file_path=None, bed_temperature=None)
     mapping = json.dumps([-1, -1, -1, ASA_TRAY])
 
     with (

+ 4 - 4
backend/tests/unit/test_scheduler_preheat_fast_path.py

@@ -45,7 +45,7 @@ def item():
 
 @pytest.fixture
 def archive():
-    return SimpleNamespace(bed_temperature=60)
+    return SimpleNamespace(file_path=None, bed_temperature=60)
 
 
 def _make_printer(model: str, printer_id: int = PRINTER_ID):
@@ -441,7 +441,7 @@ async def test_preheat_falls_back_to_configured_bed_temp_when_metadata_missing(s
     """
     db = AsyncMock()
     client = _make_client()
-    archive_no_bed = SimpleNamespace(bed_temperature=None)
+    archive_no_bed = SimpleNamespace(file_path=None, bed_temperature=None)
 
     with (
         patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
@@ -469,7 +469,7 @@ async def test_preheat_fallback_honours_configured_temp(scheduler, item):
     """The fallback reads `queue_keep_warm_bed_temp`; it is not hard-coded."""
     db = AsyncMock()
     client = _make_client()
-    archive_no_bed = SimpleNamespace(bed_temperature=None)
+    archive_no_bed = SimpleNamespace(file_path=None, bed_temperature=None)
 
     with (
         patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
@@ -500,7 +500,7 @@ async def test_preheat_still_skips_when_no_bed_temp_and_no_chamber_target(schedu
     db = AsyncMock()
     client = _make_client()
     pla_item = SimpleNamespace(id=43, preheat_override="inherit", preheat_chamber_target_override=0)
-    archive_no_bed = SimpleNamespace(bed_temperature=None)
+    archive_no_bed = SimpleNamespace(file_path=None, bed_temperature=None)
 
     with (
         patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),

+ 1 - 1
backend/tests/unit/test_scheduler_release_conn_before_ftp_2572.py

@@ -131,7 +131,7 @@ async def test_connection_released_before_preheat_soak(dispatch_case):
 
     item = SimpleNamespace(id=1, preheat_override="on", preheat_chamber_target_override=None)
     printer = SimpleNamespace(id=7, model="P1S")  # no chamber sensor → straight to soak
-    archive = SimpleNamespace(bed_temperature=60)
+    archive = SimpleNamespace(file_path=None, bed_temperature=60)
 
     async with dispatch_case.session_maker() as db:
         # Simulate the caller's earlier reads holding an open transaction.

+ 131 - 0
frontend/src/__tests__/components/PrintModal.test.tsx

@@ -20,6 +20,7 @@ import { ToastProvider } from '../../contexts/ToastContext';
 import { http, HttpResponse } from 'msw';
 import { setAuthToken } from '../../api/client';
 import { server } from '../mocks/server';
+import { setColorCatalog, __resetColorCatalogForTests } from '../../utils/colors';
 import type { PrintQueueItem } from '../../api/client';
 
 const mockPrinters = [
@@ -2381,6 +2382,92 @@ describe('PrintModal — per-plate filament override in model mode (#2552)', ()
       expect.objectContaining({ slot_id: 2, force_color_match: true }),
     ]);
   });
+
+  // The queue page shows the stored colour name. The same blue is "Marine
+  // Blue" in PLA Matte; the plain type "PLA" only reaches the generic name (SN-C9).
+  it("stores a forced slot's colour name within its sliced product line", async () => {
+    setColorCatalog({ '0000ff': 'Blue' }, { 'PLA Matte|0000ff': 'Marine Blue' });
+    type Queued = { plate_id: number; filament_overrides?: Array<{ slot_id: number; color_name?: string }> | null };
+    const queued: Queued[] = [];
+    server.use(
+      http.get('/api/v1/archives/:id/filament-requirements', () =>
+        HttpResponse.json({ filaments: [{ ...SLOT_2_BLUE, tray_info_idx: 'GFA01' }] }),
+      ),
+      http.get('/api/v1/cloud/builtin-filaments', () =>
+        HttpResponse.json([{ filament_id: 'GFA01', name: 'Bambu PLA Matte' }]),
+      ),
+      http.get('/api/v1/cloud/filament-id-map', () => HttpResponse.json({})),
+      http.post('/api/v1/queue/', async ({ request }) => {
+        queued.push((await request.json()) as Queued);
+        return HttpResponse.json({ id: queued.length, status: 'pending' });
+      }),
+    );
+
+    try {
+      const user = userEvent.setup();
+      renderWithCache(
+        <PrintModal mode="create" archiveId={1} archiveName="Two.gcode.3mf" onClose={mockOnClose} />,
+        (qc) => qc.setQueryData(['archive-plates', 1], PLATES),
+      );
+      await selectAnyX1CAndBothPlates(user);
+      await waitFor(() => expect(screen.getByText(/Filament Override — Plate 2/)).toBeInTheDocument());
+      // Wait for the preset names, which the stored colour name is derived from.
+      await waitFor(() => expect(screen.getAllByText(/PLA Matte/).length).toBeGreaterThan(0));
+
+      await user.click(screen.getAllByRole('checkbox', { name: /force color match/i })[1]);
+      await user.click(document.querySelector('button[type="submit"]') as HTMLElement);
+
+      await waitFor(() => expect(queued.length).toBe(2));
+      expect(queued.find((q) => q.plate_id === 2)?.filament_overrides).toEqual([
+        expect.objectContaining({ slot_id: 2, color_name: 'Marine Blue' }),
+      ]);
+    } finally {
+      __resetColorCatalogForTests();
+    }
+  });
+
+  it("does not name a swapped slot's colour by the preset it was sliced with", async () => {
+    setColorCatalog({ '00ff00': 'Green' }, { 'PLA Matte|00ff00': 'Matte Green' });
+    type Queued = { plate_id: number; filament_overrides?: Array<{ slot_id: number; color_name?: string }> | null };
+    const queued: Queued[] = [];
+    server.use(
+      http.get('/api/v1/archives/:id/filament-requirements', () =>
+        HttpResponse.json({ filaments: [{ ...SLOT_2_BLUE, tray_info_idx: 'GFA01' }] }),
+      ),
+      http.get('/api/v1/cloud/builtin-filaments', () =>
+        HttpResponse.json([{ filament_id: 'GFA01', name: 'Bambu PLA Matte' }]),
+      ),
+      http.get('/api/v1/cloud/filament-id-map', () => HttpResponse.json({})),
+      http.post('/api/v1/queue/', async ({ request }) => {
+        queued.push((await request.json()) as Queued);
+        return HttpResponse.json({ id: queued.length, status: 'pending' });
+      }),
+    );
+
+    try {
+      const user = userEvent.setup();
+      renderWithCache(
+        <PrintModal mode="create" archiveId={1} archiveName="Two.gcode.3mf" onClose={mockOnClose} />,
+        (qc) => qc.setQueryData(['archive-plates', 1], PLATES),
+      );
+      await selectAnyX1CAndBothPlates(user);
+      await waitFor(() => expect(screen.getByText(/Filament Override — Plate 2/)).toBeInTheDocument());
+      await waitFor(() => expect(screen.getAllByText(/PLA Matte/).length).toBeGreaterThan(0));
+
+      // Swap the PLA Matte blue for the loaded PLA Basic green.
+      const pickers = screen.getAllByRole('combobox', { name: /filament override for/i });
+      await user.click(pickers[pickers.length - 1]);
+      await user.click(within(await screen.findByRole('listbox')).getByText(/Green/));
+      await user.click(document.querySelector('button[type="submit"]') as HTMLElement);
+
+      await waitFor(() => expect(queued.length).toBe(2));
+      expect(queued.find((q) => q.plate_id === 2)?.filament_overrides).toEqual([
+        expect.objectContaining({ slot_id: 2, color: '#00FF00', color_name: 'Green' }),
+      ]);
+    } finally {
+      __resetColorCatalogForTests();
+    }
+  });
 });
 
 describe('PrintModal — per-plate quantity (#342)', () => {
@@ -2785,6 +2872,50 @@ describe('PrintModal — override survives "Any model" -> "Specific Printer" (#3
     ]);
   });
 
+  it("names a kept pin's colour within its product line when the file's filaments can't be read", async () => {
+    const hex = BROWN.replace('#', '').toLowerCase();
+    setColorCatalog({ [hex]: 'Brown' }, { [`PLA Matte|${hex}`]: 'Matte Brown' });
+    server.use(
+      http.get('/api/v1/archives/:id/filament-requirements', () => HttpResponse.json({ detail: 'gone' }, { status: 500 })),
+    );
+    const vpItem = createMockQueueItem({
+      printer_id: null,
+      target_model: 'P2S',
+      filament_overrides: [
+        { slot_id: 1, type: 'PLA', color: BROWN, tray_info_idx: 'GFA01', force_color_match: true },
+      ],
+    } as Partial<PrintQueueItem>);
+    // The preset names are known from the first render, so the save can't race them.
+    const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
+    queryClient.setQueryData(['builtin-filaments'], [{ filament_id: 'GFA01', name: 'Bambu PLA Matte' }]);
+    queryClient.setQueryData(['filament-id-map'], {});
+
+    try {
+      const user = userEvent.setup();
+      rtlRender(
+        <QueryClientProvider client={queryClient}>
+          <BrowserRouter>
+            <AuthProvider>
+              <ThemeProvider>
+                <ToastProvider>
+                  <PrintModal mode="edit-queue-item" archiveId={1} archiveName="Job" queueItem={vpItem} onClose={mockOnClose} />
+                </ToastProvider>
+              </ThemeProvider>
+            </AuthProvider>
+          </BrowserRouter>
+        </QueryClientProvider>,
+      );
+      await submit(user);
+
+      await waitFor(() => expect(patched).toHaveLength(1));
+      expect(patched[0].filament_overrides).toEqual([
+        expect.objectContaining({ slot_id: 1, tray_info_idx: 'GFA01', color_name: 'Matte Brown' }),
+      ]);
+    } finally {
+      __resetColorCatalogForTests();
+    }
+  });
+
   it('carries an override picked in model mode into a specific-printer job when creating', async () => {
     const user = userEvent.setup();
     render(<PrintModal mode="create" archiveId={1} archiveName="Job" onClose={mockOnClose} />);

+ 94 - 0
frontend/src/__tests__/pages/QueuePage.test.tsx

@@ -10,6 +10,7 @@ import { QueuePage } from '../../pages/QueuePage';
 import { http, HttpResponse } from 'msw';
 import { server } from '../mocks/server';
 import { setAuthToken } from '../../api/client';
+import { setColorCatalog, __resetColorCatalogForTests } from '../../utils/colors';
 
 // Mock queue data
 const mockQueueItems = [
@@ -634,6 +635,99 @@ describe('QueuePage', () => {
       });
     });
 
+    // The colour catalogue is keyed by product line: the same black is
+    // "Charcoal" in PLA Matte and "Black" in PLA Basic. The plate's type is
+    // only "PLA"; its slicer preset (tray_info_idx) names the line (SN-C9).
+    describe('colour names by product line', () => {
+      const plateWith = (filament: Record<string, unknown>) =>
+        HttpResponse.json({
+          file_id: 16,
+          filename: 'matte.3mf',
+          plates: [
+            {
+              index: 1,
+              name: 'Plate 1',
+              objects: ['Part'],
+              has_thumbnail: false,
+              thumbnail_url: null,
+              print_time_seconds: 3600,
+              filament_used_grams: 20,
+              filaments: [{ slot_id: 1, type: 'PLA', color: '#000000', used_grams: 20, used_meters: 6.7, ...filament }],
+            },
+          ],
+          is_multi_plate: false,
+        });
+
+      const show = async (plateFilament: Record<string, unknown> | null, overrides: unknown = null) => {
+        const item = {
+          ...mockQueueItems[0],
+          id: 85,
+          printer_id: null,
+          target_model: 'P2S',
+          archive_id: null,
+          library_file_id: 16,
+          archive_name: null,
+          library_file_name: 'Product line test',
+          plate_id: 1,
+          filament_overrides: overrides,
+        };
+        server.use(
+          http.get('/api/v1/queue/', () => HttpResponse.json([item])),
+          http.get('/api/v1/library/files/16/plates', () =>
+            plateFilament ? plateWith(plateFilament) : HttpResponse.json({ detail: 'Not found' }, { status: 404 }),
+          ),
+          http.get('/api/v1/cloud/builtin-filaments', () =>
+            HttpResponse.json([
+              { filament_id: 'GFA01', name: 'Bambu PLA Matte' },
+              { filament_id: 'GFA00', name: 'Bambu PLA Basic' },
+            ]),
+          ),
+          http.get('/api/v1/cloud/filament-id-map', () => HttpResponse.json({})),
+        );
+        render(<QueuePage />);
+        await screen.findByText('Product line test');
+        return screen.getByText('Product line test').closest('.group') as HTMLElement;
+      };
+
+      beforeEach(() => {
+        setColorCatalog({ '000000': 'Black' }, { 'PLA Matte|000000': 'Charcoal' });
+      });
+      afterEach(() => {
+        __resetColorCatalogForTests();
+      });
+
+      it("names the plate colour within the preset's product line", async () => {
+        const row = await show({ tray_info_idx: 'GFA01' });
+
+        expect(await within(row).findByText('Charcoal')).toBeInTheDocument();
+      });
+
+      it("names an override within the override's own preset", async () => {
+        const row = await show({ tray_info_idx: 'GFA00' }, [
+          { slot_id: 1, type: 'PLA', color: '#000000', tray_info_idx: 'GFA01', force_color_match: false },
+        ]);
+
+        expect(await within(row).findByText('Charcoal')).toBeInTheDocument();
+      });
+
+      it('names an override within its preset when the file has no plate listing', async () => {
+        const row = await show(null, [
+          { slot_id: 1, type: 'PLA', color: '#000000', tray_info_idx: 'GFA01', force_color_match: false },
+        ]);
+
+        expect(await within(row).findByText('Charcoal')).toBeInTheDocument();
+      });
+
+      it("does not lend the plate's preset to an override of another filament", async () => {
+        const row = await show({ tray_info_idx: 'GFA01' }, [
+          { slot_id: 1, type: 'PETG', color: '#000000', force_color_match: false },
+        ]);
+
+        expect(await within(row).findByText('Black')).toBeInTheDocument();
+        expect(within(row).queryByText('Charcoal')).toBeNull();
+      });
+    });
+
     it('keeps an eight-colour job compact and exposes details in the tooltip (#3132)', async () => {
       const colours = [
         '#FF0000',

+ 11 - 2
frontend/src/components/PrintModal/index.tsx

@@ -17,6 +17,7 @@ import {
 } from '../../hooks/useFilamentMapping';
 import { useMultiPrinterFilamentMapping, type PerPrinterConfig } from '../../hooks/useMultiPrinterFilamentMapping';
 import { getColorName } from '../../utils/colors';
+import { productLineOf, useFilamentNameByIdx } from './useFilamentLabels';
 import { isGcodeCompatible, isPrinterCurrentlyDispatchable } from '../../utils/printer';
 import { getCurrencySymbol } from '../../utils/currency';
 import { getBedTypeInfo } from '../../utils/bedType';
@@ -521,6 +522,8 @@ export function PrintModal({
   );
 
   // Fetch available filaments for model-based assignment (for filament override UI)
+  // Preset id -> name, to store a colour name within its product line (SN-C9)
+  const filamentNameByIdx = useFilamentNameByIdx();
   const { data: availableFilaments } = useQuery({
     queryKey: ['available-filaments', targetModel, targetLocation],
     queryFn: () => api.getAvailableFilaments(targetModel!, targetLocation ?? undefined),
@@ -1224,7 +1227,11 @@ export function PrintModal({
 
           // Include slot if user changed the filament OR force_color_match is enabled
           if (userOverride || isForceColor) {
-            entries.push({ slot_id: req.slot_id, type: effectiveType, color: effectiveColor, color_name: getColorName(effectiveColor), ...storedVariantFor(req.slot_id, effectiveType, effectiveColor), force_color_match: isForceColor });
+            const variant = storedVariantFor(req.slot_id, effectiveType, effectiveColor);
+            // A slot left as sliced keeps its own preset; a swapped one only a stored matching one.
+            const preset = variant.tray_info_idx ?? (userOverride ? undefined : req.tray_info_idx);
+            const colorName = getColorName(effectiveColor, productLineOf(preset, effectiveType, filamentNameByIdx));
+            entries.push({ slot_id: req.slot_id, type: effectiveType, color: effectiveColor, color_name: colorName, ...variant, force_color_match: isForceColor });
           }
         }
       } else {
@@ -1232,7 +1239,9 @@ export function PrintModal({
         for (const [slotId, { type, color }] of Object.entries(filamentOverrides)) {
           const id = parseInt(slotId, 10);
           const isForceColor = forceColorMatch[id] ?? false;
-          entries.push({ slot_id: id, type, color, color_name: getColorName(color), ...storedVariantFor(id, type, color), force_color_match: isForceColor });
+          const variant = storedVariantFor(id, type, color);
+          const colorName = getColorName(color, productLineOf(variant.tray_info_idx, type, filamentNameByIdx));
+          entries.push({ slot_id: id, type, color, color_name: colorName, ...variant, force_color_match: isForceColor });
         }
       }
 

+ 41 - 21
frontend/src/components/PrintModal/useFilamentLabels.ts

@@ -38,27 +38,12 @@ interface FilamentReqLike {
 }
 
 /**
- * Resolve per-slot human-readable labels for the schedule modal's filament
- * panels (#1718). Both the model-mode ``FilamentOverride`` and the printer-
- * mode ``FilamentMapping`` consume this so the two panels render the same
- * sub-brand + disambiguated color for the same sliced 3MF. Extracted from
- * the inline implementation in ``FilamentOverride`` so the two callers can't
- * drift.
- *
- * Three queries back the resolution:
- *   - ``/cloud/builtin-filaments`` → Bambu factory SKU → name map (GFA01 →
- *     "Bambu PLA Matte" etc.).
- *   - ``/cloud/filament-id-map``   → user custom cloud preset SKU → name
- *     map (P-prefix). Wins over the builtin entry for the same id.
- *   - ``/inventory/colors/by-material`` (one ``useQuery`` per slot via
- *     ``useQueries``, keyed on hex + material hint) → catalog color name
- *     disambiguated by material context.
- *
- * Output is positional — ``labels[i]`` corresponds to ``reqs[i]``. Returns
- * an empty array when ``reqs`` is undefined / empty so callers can safely
- * index without a length check.
+ * Slicer preset id (``tray_info_idx``: GFA01, P-prefixed user presets) to its
+ * name ("Bambu PLA Matte"). The cloud user-preset map wins over the builtin
+ * entry for the same id. Shared by the print dialog and the queue page so both
+ * name a colour within the same product line.
  */
-export function useFilamentLabels(reqs: readonly FilamentReqLike[] | undefined): FilamentLabel[] {
+export function useFilamentNameByIdx(): Record<string, string> {
   const { data: builtinFilaments } = useQuery({
     queryKey: ['builtin-filaments'],
     queryFn: () => api.getBuiltinFilaments(),
@@ -70,7 +55,7 @@ export function useFilamentLabels(reqs: readonly FilamentReqLike[] | undefined):
     staleTime: 5 * 60 * 1000,
   });
 
-  const filamentNameByIdx = useMemo(() => {
+  return useMemo(() => {
     const map: Record<string, string> = {};
     for (const f of builtinFilaments || []) {
       if (f.filament_id) map[f.filament_id] = f.name;
@@ -82,6 +67,41 @@ export function useFilamentLabels(reqs: readonly FilamentReqLike[] | undefined):
     }
     return map;
   }, [builtinFilaments, cloudFilamentIdMap]);
+}
+
+/** The colour catalogue's material for a filament: the product line its
+ *  preset names ("PLA Matte"), else its bare type ("PLA"). */
+export function productLineOf(
+  trayInfoIdx: string | null | undefined,
+  type: string,
+  filamentNameByIdx: Record<string, string>,
+): string {
+  return extractMaterialHint((trayInfoIdx && filamentNameByIdx[trayInfoIdx]) || type);
+}
+
+/**
+ * Resolve per-slot human-readable labels for the schedule modal's filament
+ * panels (#1718). Both the model-mode ``FilamentOverride`` and the printer-
+ * mode ``FilamentMapping`` consume this so the two panels render the same
+ * sub-brand + disambiguated color for the same sliced 3MF. Extracted from
+ * the inline implementation in ``FilamentOverride`` so the two callers can't
+ * drift.
+ *
+ * Three queries back the resolution:
+ *   - ``/cloud/builtin-filaments`` → Bambu factory SKU → name map (GFA01 →
+ *     "Bambu PLA Matte" etc.).
+ *   - ``/cloud/filament-id-map``   → user custom cloud preset SKU → name
+ *     map (P-prefix). Wins over the builtin entry for the same id.
+ *   - ``/inventory/colors/by-material`` (one ``useQuery`` per slot via
+ *     ``useQueries``, keyed on hex + material hint) → catalog color name
+ *     disambiguated by material context.
+ *
+ * Output is positional — ``labels[i]`` corresponds to ``reqs[i]``. Returns
+ * an empty array when ``reqs`` is undefined / empty so callers can safely
+ * index without a length check.
+ */
+export function useFilamentLabels(reqs: readonly FilamentReqLike[] | undefined): FilamentLabel[] {
+  const filamentNameByIdx = useFilamentNameByIdx();
 
   // Compute the per-slot (resolvedName, materialHint) pairs up-front so the
   // ``useQueries`` call below has a stable shape and the render path below

+ 17 - 3
frontend/src/pages/QueuePage.tsx

@@ -70,6 +70,7 @@ import { PipelineRunsView } from './PipelineRunsPage';
 import { type TimeFormat, formatETA, formatDuration, formatRelativeTime, parseUTCDate } from '../utils/date';
 import { getBedTypeInfo } from '../utils/bedType';
 import { getColorName } from '../utils/colors';
+import { productLineOf, useFilamentNameByIdx } from '../components/PrintModal/useFilamentLabels';
 import type { PrintQueueItem, PrintQueueBulkUpdate, Permission, CalibrationMode, PrinterStatus, SlotSpoolIdentity } from '../api/client';
 import { formatSlotLabel, getEmptySlotKind } from '../utils/amsHelpers';
 import type { PlateMetadata } from '../types/plates';
@@ -180,6 +181,7 @@ function resolveQueueFilaments(
   loadedFilaments: LoadedFilament[] = [],
   status?: PrinterStatus,
   emptyLabel = 'Empty',
+  filamentNameByIdx: Record<string, string> = {},
 ): QueueFilamentDisplay[] {
   // A stored AMS mapping names the physical tray that will actually feed a 3MF
   // slot. Once its live tray data is available, that is more specific than
@@ -231,12 +233,16 @@ function resolveQueueFilaments(
         const override = overrides.get(filament.slot_id);
         const color = override?.color ?? filament.color;
         const type = override?.type ?? filament.type;
+        // An override is another filament: the plate's preset is not its own.
+        const productLine = override
+          ? productLineOf(override.tray_info_idx, override.type, filamentNameByIdx)
+          : productLineOf(filament.tray_info_idx, filament.type, filamentNameByIdx);
 
         return resolveSlot({
           slotId: filament.slot_id,
           type,
           color,
-          colorName: override?.color_name?.trim() || getColorName(color, type),
+          colorName: override?.color_name?.trim() || getColorName(color, productLine),
         });
       });
   }
@@ -252,7 +258,7 @@ function resolveQueueFilaments(
         color: override.color,
         colorName:
           override.color_name?.trim() ||
-          getColorName(override.color, override.type),
+          getColorName(override.color, productLineOf(override.tray_info_idx, override.type, filamentNameByIdx)),
       }),
     );
   }
@@ -658,7 +664,15 @@ function SortableQueueItem({
   // Combine plates data from either source
   const platesData = isLibraryFile ? libraryPlatesData : archivePlatesData;
   const plates = platesData?.plates ?? [];
-  const queueFilaments = resolveQueueFilaments(item, plates, loadedFilaments, status, t('ams.empty'));
+  const filamentNameByIdx = useFilamentNameByIdx();
+  const queueFilaments = resolveQueueFilaments(
+    item,
+    plates,
+    loadedFilaments,
+    status,
+    t('ams.empty'),
+    filamentNameByIdx,
+  );
 
   const canReorder = hasPermission('queue:reorder');
   const {

+ 2 - 0
frontend/src/types/plates.ts

@@ -2,6 +2,8 @@ export interface PlateFilament {
   slot_id: number;
   type: string;
   color: string;
+  // The slicer preset (GFA01, ...); names the product line for colour names
+  tray_info_idx?: string;
   used_grams: number;
   used_meters: number;
   // True when this AMS slot is consumed by the picked plate. False