Browse Source

Map each printer against its own AMS, and stop dispatching a job whose filament is not loaded (#2799) (#2803)

Martin Grolmus 1 day ago
parent
commit
0a3e341584
35 changed files with 1931 additions and 35 deletions
  1. 2 0
      CHANGELOG.md
  2. 25 1
      backend/app/api/routes/print_queue.py
  3. 3 0
      backend/app/schemas/print_queue.py
  4. 451 17
      backend/app/services/print_scheduler.py
  5. 75 0
      backend/tests/integration/test_print_queue_api.py
  6. 858 0
      backend/tests/unit/test_ams_mapping_per_printer_2799.py
  7. 3 1
      backend/tests/unit/test_scheduler_busy_reasons_3018.py
  8. 95 0
      frontend/src/__tests__/components/PrintModal.test.tsx
  9. 158 0
      frontend/src/__tests__/hooks/useMultiPrinterFilamentMapping.test.tsx
  10. 47 0
      frontend/src/__tests__/pages/QueuePage.test.tsx
  11. 2 0
      frontend/src/api/client.ts
  12. 90 8
      frontend/src/components/PrintModal/index.tsx
  13. 33 5
      frontend/src/hooks/useMultiPrinterFilamentMapping.ts
  14. 4 0
      frontend/src/i18n/locales/de.ts
  15. 4 0
      frontend/src/i18n/locales/en.ts
  16. 4 0
      frontend/src/i18n/locales/es.ts
  17. 4 0
      frontend/src/i18n/locales/fr.ts
  18. 4 0
      frontend/src/i18n/locales/it.ts
  19. 4 0
      frontend/src/i18n/locales/ja.ts
  20. 4 0
      frontend/src/i18n/locales/ko.ts
  21. 4 0
      frontend/src/i18n/locales/nl.ts
  22. 4 0
      frontend/src/i18n/locales/pt-BR.ts
  23. 4 0
      frontend/src/i18n/locales/ru.ts
  24. 4 0
      frontend/src/i18n/locales/sv.ts
  25. 4 0
      frontend/src/i18n/locales/tr.ts
  26. 4 0
      frontend/src/i18n/locales/uk.ts
  27. 4 0
      frontend/src/i18n/locales/zh-CN.ts
  28. 4 0
      frontend/src/i18n/locales/zh-TW.ts
  29. 27 0
      frontend/src/pages/QueuePage.tsx
  30. 0 0
      static/assets/ImagePreviewModal-DrwoIBiu.js
  31. 0 1
      static/assets/PdfPreviewModal-9v8J8IlR.js
  32. 0 0
      static/assets/SpreadsheetPreviewModal-BSaNvS63.js
  33. 1 1
      static/assets/index-7L3DC4So.js
  34. 0 0
      static/assets/pdf-CCztDciw.js
  35. 1 1
      static/index.html

+ 2 - 0
CHANGELOG.md

@@ -72,6 +72,8 @@ 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
+- **One model queued to several printers could print in the wrong filament (#2799)** — An AMS mapping is a list of one printer's own tray numbers, and Bambuddy stamped the mapping it worked out for the first selected printer onto every other printer in the same dispatch. Where those printers hold the same spools in a different slot order the tray numbers still resolve, so nothing looks wrong, and each copy prints from whatever sits in that tray — in the reported case ASA where the 3MF requires PETG. An explicit mapping also bypasses the printer's own filament-type check, so nothing further down catches it. Every selected printer is now mapped against its own AMS, and a mapping already stored on a queue item is checked against the printer about to run it and recomputed when it does not fit, which covers a spool moved between queueing and dispatch as well. A tray of the right type in the wrong colour is still accepted, as it always was when nothing better is loaded. A slot you put on a tray of another material yourself in the print dialog is kept as you chose it.
+- **A job whose filament is not loaded waits instead of printing (#2799)** — When a slot the plate prints cannot be matched to a tray on the printer it is about to go to, the job is held for a manual start and the queue row says which filament it needs, the same way a job short of filament already waits. This also catches a job that matched nothing at all: it used to be sent anyway and refused by the printer after the whole file had been uploaded, with `0700_8012 Failed to get AMS mapping table` and no explanation on screen. A job queued to a model rather than a named printer gives its printer back when it is held, so pressing start lets the queue choose among all printers of that model again instead of tying the job to the one that could not run it. That choice still goes by filament type only, so when the filament is on the wrong nozzle or the AMS changed after the printer was picked, it can pick the same printer and hold the job there again. A held job waits for someone to press start even once the right spool is loaded, as the filament-shortage hold already does. Start then finds the spool and the job prints, keeping any trays already chosen for its other slots. If the filament is still not loaded, Start says which one is missing and offers Print Anyway, which sends the job the way it went out before this change. The filament named on the queue row and in the notification is in English whatever the interface language.
 - **SpoolBuddy recognises a Bambu Lab spool from either of its two tags, and finds the spool the AMS already created (#984, block 9 read from @Keybored02's #1200, measured by @Thomansky and @Sawtaytoes)** — A Bambu Lab spool has a tag on each side. The two have different tag UIDs, but share one spool ID, the same one the AMS reports. SpoolBuddy read that ID from the wrong part of the tag: the bytes it used hold the filament type, so every "PLA Matte" spool sent the same "ID". The kiosk then didn't save it, so a spool was only known by the UID of the tag that was scanned. Scanning the other side offered to add the spool again, and a spool added on the kiosk was not matched when it went into the AMS, and the other way round.
   - The SpoolBuddy daemon now reads the real spool ID (block 9). **Update the SpoolBuddy daemon** to get the fix: on the kiosk, Settings → Updates.
   - Add to Inventory and Assign Spool on the kiosk save the spool ID with the tag, also when the spool was lifted off the reader while the dialog was open. In Spoolman mode the spool ID is stored as the spool's tag, as the AMS sync already does.

+ 25 - 1
backend/app/api/routes/print_queue.py

@@ -2383,7 +2383,9 @@ async def start_queue_item(
     deficit (#1496) is checked first — if the assigned spool can't satisfy
     a slot's required grams, the route returns ``409`` with the deficit
     payload so the caller can show a confirm dialog and retry with
-    ``skip_filament_check=true``.
+    ``skip_filament_check=true``. The same goes for a filament the printer has
+    no tray for at all (#2799): ``409`` with ``code=unmatched_filament`` and
+    the missing filaments.
     """
     user, can_modify_all = auth_result
 
@@ -2443,6 +2445,28 @@ async def start_queue_item(
                 },
             )
 
+        # A filament the printer has no tray for at all (#2799). Without this,
+        # Start on an item the scheduler held for exactly that would release it
+        # only for the next pass to hold it again, and nothing would ever offer
+        # "Print Anyway".
+        from backend.app.services.print_scheduler import scheduler as _scheduler
+
+        # A convenience, not a gate: the scheduler holds the item again if the
+        # filament is still missing, so a failure here must not break Start.
+        try:
+            missing = await _scheduler.missing_filament_for_start(db, item)
+        except Exception:
+            logger.exception("Queue item %s: filament check before start failed", item_id)
+            missing = None
+        if missing:
+            raise HTTPException(
+                status_code=409,
+                detail={
+                    "code": "unmatched_filament",
+                    "missing": missing,
+                },
+            )
+
     # Print Anyway / no deficit: clear the flags and let the scheduler dispatch.
     item.manual_start = False
     item.filament_short = False

+ 3 - 0
backend/app/schemas/print_queue.py

@@ -147,6 +147,9 @@ class PrintQueueItemUpdate(BaseModel):
     require_previous_success: bool | None = None
     auto_off_after: bool | None = None
     manual_start: bool | None = None
+    # Set by the print dialog when the mapping deliberately puts a slot on a
+    # tray of another material, so the dispatch re-check keeps it (#2799).
+    skip_filament_check: bool | None = None
     ams_mapping: list[int] | None = None
     plate_id: int | None = None
     # Print options

+ 451 - 17
backend/app/services/print_scheduler.py

@@ -588,6 +588,45 @@ def _mapping_is_all_unresolved(mapping: list | None) -> bool:
     return all(t is None or (isinstance(t, int) and t < 0) for t in mapping)
 
 
+def _is_tray_id(value: object) -> bool:
+    """True when ``value`` can be read as a global tray ID.
+
+    ``bool`` is a subclass of ``int``, so a hand-written API payload carrying
+    ``true`` would otherwise be read as tray 1 and judged — or dispatched —
+    against whatever happens to be loaded there.
+    """
+    return isinstance(value, int) and not isinstance(value, bool)
+
+
+# Prefix of the waiting_reason `_block_on_unmatched_filament` writes when it
+# stages an item (#2799). The manual_start branch clears every other reason on
+# a staged item (#3074); this one is the reason it was staged, so it stays.
+_UNMATCHED_HOLD_PREFIX = "Needs "
+
+
+def _is_unmatched_hold_reason(reason: str | None) -> bool:
+    """True for the reason the unmatched-filament hold wrote when it staged the item."""
+    return bool(reason) and reason.startswith(_UNMATCHED_HOLD_PREFIX)
+
+
+def _unresolved_required(required: list[dict], mapping: list) -> list[dict]:
+    """The requirements in ``required`` that ``mapping`` gives no tray (#2799).
+
+    Only slots the plate prints are judged: a ``-1`` anywhere else is padding
+    for a filament this plate does not use. A slot past the end of the mapping
+    is unresolved too.
+    """
+    unresolved = []
+    for req in required:
+        slot_id = req.get("slot_id") or 0
+        if slot_id <= 0:
+            continue
+        tray = mapping[slot_id - 1] if slot_id <= len(mapping) else None
+        if not _is_tray_id(tray) or tray < 0:
+            unresolved.append(req)
+    return unresolved
+
+
 # Global tray ids at or above this are the external spool(s), not an AMS slot:
 # 254 is the deputy feed and 255 the main one. Mirrors the sentinel documented
 # on `_mapping_is_all_unresolved`.
@@ -1231,6 +1270,12 @@ class PrintScheduler:
         # In-memory on purpose: a restart re-arms the grace period, which only
         # delays a recovery that is already the exceptional path (#2829).
         self._terminal_since: dict[int, float] = {}
+        # Per-pass memo for `_get_filament_requirements` (#2799 review). Two
+        # gates parse the same 3MF per dispatch attempt, and a one-file fan-out
+        # across idle printers repeats that for every one of them in a single
+        # pass. Cleared at the top of each pass so a re-sliced file is never
+        # served from a previous tick.
+        self._filament_req_memo: dict[tuple, list[dict] | None] = {}
 
     async def run(self):
         """Main loop - check queue every interval."""
@@ -1390,6 +1435,8 @@ class PrintScheduler:
         Returns True if this pass dispatched at least one item, so the caller
         can loop again quickly instead of sleeping the full interval (#2555).
         """
+        # Scoped to one pass: a file re-sliced between ticks must be re-read.
+        self._filament_req_memo.clear()
         async with async_session() as db:
             # Check if shortest-job-first scheduling is enabled
             sjf_enabled = await self._get_bool_setting(db, "queue_shortest_first")
@@ -1795,7 +1842,12 @@ class PrintScheduler:
                     # this is the last pass that will look at the row: a staged
                     # item never reaches the branches below again, so a reason
                     # left from before it was staged would stand forever (#3074).
-                    await hold_item(item, None)
+                    # The one exception is the unmatched-filament hold, whose
+                    # reason was written at staging and is why the item waits:
+                    # it names the filament to load (#2799). Pressing start
+                    # clears manual_start, and the branches below overwrite it.
+                    keep = item.waiting_reason if _is_unmatched_hold_reason(item.waiting_reason) else None
+                    await hold_item(item, keep)
                     skip_reasons["manual_start"] = skip_reasons.get("manual_start", 0) + 1
                     continue
 
@@ -1962,6 +2014,12 @@ class PrintScheduler:
                         await hold_item(item, None)
                         continue
 
+                    # Unmatched-filament pre-dispatch check (#2799). Hold rather
+                    # than let the printer pick a substitute for a slot the
+                    # matcher could not resolve on this printer.
+                    if await self._block_on_unmatched_filament(db, item):
+                        continue
+
                     # Hold this item back for the next pass rather than racing
                     # another dispatch over the same transient library row. The
                     # printer is still marked busy so a later item does not jump
@@ -2214,6 +2272,15 @@ class PrintScheduler:
                         if await self._block_on_filament_deficit(db, item):
                             continue
 
+                        # Unmatched-filament pre-dispatch check (#2799). Model-based
+                        # selection already filters on filament type, so this is a
+                        # backstop for an AMS that changed between assignment and
+                        # dispatch, and for a type loaded on the wrong nozzle.
+                        # The assignment made above is released with the hold —
+                        # this item asked for a model, not this printer.
+                        if await self._block_on_unmatched_filament(db, item, release_assignment=True):
+                            continue
+
                         _claim_library_row(item)
                         dispatch_ids.append(item.id)
                         claim_printer(printer_id)
@@ -3348,14 +3415,20 @@ class PrintScheduler:
         frontend status-load race can serialize [-1] before the printer's AMS
         trays are known (#2589) — and must not be trusted: downstream it would be
         silently downgraded to external-spool mode and print against an empty
-        feed. A resolved mapping (including manual overrides, or a partially
-        padded one) is left untouched.
+        feed. A resolved mapping is re-checked against the target printer's
+        live trays before it is trusted (#2799) and recomputed when it does not
+        fit. When it does fit, its resolved slots are kept as they are and only
+        the slots the plate prints that are still unresolved are matched again;
+        all of it is left alone when the user has acknowledged it with "Print
+        Anyway".
 
         When recompute cannot resolve it either (no compatible tray loaded), the
         bogus [-1] is cleared to None so it is not later mistaken for an explicit
-        external selection; the print command then keeps use_ams=True and the
-        firmware surfaces a clear AMS-mapping error instead of silently printing
-        to the empty external feed.
+        external selection. On a printer that reported loaded trays,
+        ``_block_on_unmatched_filament`` holds the item rather than let it go out
+        mapping-less; where it cannot judge, the print command keeps use_ams=True
+        and the firmware surfaces a clear AMS-mapping error instead of silently
+        printing to the empty external feed.
 
         Returns an actionable message when that firmware error is the only
         possible outcome — the matcher ran, matched nothing, and the printer has
@@ -3372,10 +3445,44 @@ class PrintScheduler:
             except (json.JSONDecodeError, TypeError):
                 stored_mapping = None
 
-        # Already resolved (present and not all-unresolved) — keep as-is so a
-        # user's manual mapping is never overwritten.
+        # Present and resolved. Global tray IDs only mean something relative to
+        # the printer they were resolved against, so "resolved" is not the same
+        # as "resolved *here*" — check the mapping still fits the printer that
+        # is about to run this item before trusting it (#2799). "Print anyway"
+        # is the user overriding exactly this judgement, so it short-circuits.
         if item.ams_mapping and not _mapping_is_all_unresolved(stored_mapping):
-            return None
+            if item.skip_filament_check:
+                return None
+            conflict = await self._stored_mapping_conflict(db, printer_id, item, stored_mapping)
+            if conflict is None:
+                # It fits, but a slot the plate prints may still be empty: the
+                # spool was missing when it was mapped, and has perhaps been
+                # loaded since. Nothing else would ever look at that slot again,
+                # so a job held for it would be held again on every Start.
+                filled = await self._fill_unresolved_slots(db, printer_id, item, stored_mapping)
+                if filled is not None:
+                    item.ams_mapping = json.dumps(filled)
+                    logger.info(
+                        "Queue item %s: filled unresolved slots of %s on printer %s: %s",
+                        item.id,
+                        stored_mapping,
+                        printer_id,
+                        filled,
+                    )
+                    await db.commit()
+                return None
+            logger.warning(
+                "Queue item %s: stored ams_mapping %s does not fit printer %s (%s) — recomputing",
+                item.id,
+                stored_mapping,
+                printer_id,
+                conflict,
+            )
+            # Drop it before recomputing so a failed recompute cannot fall back
+            # to the mapping we just rejected.
+            item.ams_mapping = None
+            stored_mapping = None
+            await db.commit()
 
         computed_mapping = await self._compute_ams_mapping_for_printer(db, printer_id, item)
         if computed_mapping and not _mapping_is_all_unresolved(computed_mapping):
@@ -3402,6 +3509,198 @@ class PrintScheduler:
 
         return await self._unmappable_without_ams_message(db, printer_id, item, computed_mapping)
 
+    async def _fill_unresolved_slots(
+        self,
+        db: AsyncSession,
+        printer_id: int,
+        item: PrintQueueItem,
+        stored_mapping: list | None,
+    ) -> list | None:
+        """``stored_mapping`` with its unresolved required slots matched on live trays (#2799).
+
+        Every resolved entry is kept, so a tray the user picked by hand stays
+        picked. Only the plate's unresolved slots are matched, and only against
+        trays the mapping does not already use: the matcher never gives two
+        slots one tray, and filling a gap with a tray the user assigned to
+        another slot would print that slot's filament twice.
+
+        Returns None when there is nothing to fill or nothing could be filled.
+        """
+        if not isinstance(stored_mapping, list) or not stored_mapping:
+            return None
+        required = await self._get_filament_requirements(db, item)
+        if not required:
+            return None
+        self._apply_filament_overrides(item, required)
+        gaps = {req["slot_id"] for req in _unresolved_required(required, stored_mapping)}
+        if not gaps:
+            return None
+
+        reserved = {t for t in stored_mapping if _is_tray_id(t) and t >= 0}
+        computed = await self._compute_ams_mapping_for_printer(
+            db, printer_id, item, only_slots=gaps, reserved_trays=reserved
+        )
+        if not computed:
+            return None
+
+        merged = list(stored_mapping) + [-1] * max(0, len(computed) - len(stored_mapping))
+        filled = False
+        for slot_id in gaps:
+            tray = computed[slot_id - 1] if slot_id <= len(computed) else None
+            if _is_tray_id(tray) and tray >= 0:
+                merged[slot_id - 1] = tray
+                filled = True
+        return merged if filled else None
+
+    async def missing_filament_for_start(self, db: AsyncSession, item: PrintQueueItem) -> list[str] | None:
+        """What a staged item would be held for if it were started now (#2799).
+
+        The Start button asks this before releasing an item, so that a job whose
+        filament is still not loaded offers "Print Anyway" instead of being
+        released, held again by the scheduler and leaving no way past the hold.
+        It reaches the same answer the dispatch path would: a stored mapping
+        that fits is kept and only its gaps are matched again, and one that
+        does not fit (or is missing) is replaced by a fresh match.
+
+        Returns the missing filaments, described the way the queue row
+        describes them, or None when nothing is missing or there is not enough
+        evidence to say: no printer yet (a model-based item picks one at
+        dispatch), no status, no trays reported, or no readable 3MF.
+        """
+        if item.skip_filament_check or not item.printer_id:
+            return None
+        status = printer_manager.get_status(item.printer_id)
+        if status is None or not self._build_loaded_filaments(status):
+            return None
+
+        # Called from a request, not from a pass: drop whatever the last pass
+        # memoised so this reads the file as it is now. A pass running at the
+        # same time only loses its cache.
+        self._filament_req_memo.clear()
+
+        mapping: list | None = None
+        if item.ams_mapping:
+            try:
+                mapping = json.loads(item.ams_mapping)
+            except (json.JSONDecodeError, TypeError):
+                mapping = None
+        if not isinstance(mapping, list) or _mapping_is_all_unresolved(mapping):
+            mapping = None
+        if mapping is not None:
+            if await self._stored_mapping_conflict(db, item.printer_id, item, mapping) is None:
+                mapping = await self._fill_unresolved_slots(db, item.printer_id, item, mapping) or mapping
+            else:
+                mapping = None
+        if mapping is None:
+            mapping = await self._compute_ams_mapping_for_printer(db, item.printer_id, item) or []
+
+        required = await self._get_filament_requirements(db, item)
+        if not required:
+            return None
+        self._apply_filament_overrides(item, required)
+        missing = _unresolved_required(required, mapping)
+        return [_describe_filament(req, "nozzle_id") for req in missing] or None
+
+    async def _stored_mapping_conflict(
+        self,
+        db: AsyncSession,
+        printer_id: int,
+        item: PrintQueueItem,
+        stored_mapping: list | None,
+    ) -> str | None:
+        """Describe why ``stored_mapping`` cannot be trusted on ``printer_id`` (#2799).
+
+        A mapping is a list of global tray IDs, and those are only meaningful
+        relative to the printer they were resolved against — the same rule
+        ``print_queue`` documents for tray identity. Two ways a stored mapping
+        arrives at a printer it was not resolved for:
+
+        * the print dialog stamps one mapping onto every selected printer, so a
+          mapping computed against the first printer is dispatched verbatim to
+          the rest, whose AMS slot order differs;
+        * a spool is moved between queueing and dispatch.
+
+        Both end the same way: the slot index still resolves, so nothing looks
+        wrong, and the printer obeys it — an explicit ``ams_mapping`` bypasses
+        the firmware's own type check, so a PETG slot happily prints in ASA.
+
+        Returns a short reason when the mapping names a tray this printer does
+        not have loaded, or points a slot at a tray holding a different filament
+        type. Returns None when the mapping fits, and — deliberately — whenever
+        we lack the evidence to judge, so a recompute only ever follows a
+        positive finding.
+
+        An unresolved (``-1``) required slot is NOT a conflict: it says the
+        matcher had nothing, not that the mapping belongs to another printer,
+        and destroying a partially hand-resolved mapping over it would lose the
+        slots the user did resolve. ``_fill_unresolved_slots`` matches those
+        slots again on their own, and ``_block_on_unmatched_filament`` holds the
+        item when that finds nothing.
+        """
+        if not isinstance(stored_mapping, list) or not stored_mapping:
+            return None
+
+        status = printer_manager.get_status(printer_id)
+        if status is None:
+            return None
+
+        loaded = self._build_loaded_filaments(status)
+        if not loaded:
+            # Nothing reported yet (reconnect, first push pending). Saying
+            # "tray not loaded" here would recompute against an AMS we cannot
+            # see, which is how #2589 produced a bogus all-[-1] in the first
+            # place.
+            return None
+        by_tray = {f["global_tray_id"]: f for f in loaded}
+
+        # Cheap pass first, on live status alone: every tray the mapping names
+        # has to exist here. This catches a foreign mapping without opening the
+        # 3MF, which is worth doing because the parse below is neither cached
+        # nor free.
+        for index, tray in enumerate(stored_mapping):
+            if not _is_tray_id(tray) or tray < 0:
+                continue
+            if tray in by_tray:
+                continue
+            if tray >= 254:
+                # An external feed we have not heard about is absence of
+                # evidence about *that slot* — the rest of the mapping is still
+                # worth judging, so skip it rather than abandoning the pass.
+                continue
+            return f"slot {index + 1} points at tray {tray}, which this printer does not have loaded"
+
+        # Only now is the 3MF worth opening: the type check needs to know what
+        # each slot actually asked for. The external spool is checked the same
+        # way as an AMS tray — `_build_loaded_filaments` reports its type, and
+        # two printers with different filament in the external feed is the same
+        # failure this method exists to catch.
+        required = await self._get_filament_requirements(db, item)
+        if not required:
+            return None
+        self._apply_filament_overrides(item, required)
+
+        for req in required:
+            slot_id = req.get("slot_id") or 0
+            if slot_id <= 0:
+                continue
+            if slot_id > len(stored_mapping):
+                return f"slot {slot_id} is not covered by the mapping"
+
+            tray = stored_mapping[slot_id - 1]
+            if not _is_tray_id(tray) or tray < 0:
+                continue
+
+            loaded_tray = by_tray.get(tray)
+            if loaded_tray is None:
+                continue
+
+            want = canonical_filament_type(req.get("type"))
+            have = canonical_filament_type(loaded_tray.get("type"))
+            if want and have and want != have:
+                return f"slot {slot_id} needs {req.get('type')} but tray {tray} holds {loaded_tray.get('type')}"
+
+        return None
+
     async def _unmappable_without_ams_message(
         self,
         db: AsyncSession,
@@ -3414,12 +3713,14 @@ class PrintScheduler:
         A print dispatched with no mapping goes out as ``use_ams: true`` with no
         ``ams_mapping`` and no ``ams_mapping2``, which the firmware rejects with
         0700_8012 "Failed to get AMS mapping table" — after Bambuddy has already
-        uploaded several megabytes and burned its dispatch retries. With an AMS
-        attached that error is worth reaching: the user can load the right spool
-        and press Resume, so this returns None and today's behaviour stands. With
-        no AMS there is nothing to resume into — the external spool holder is the
-        whole inventory — so the useful answer is to say which filament is
-        missing and stop.
+        uploaded several megabytes and burned its dispatch retries. This method
+        speaks only for the AMS-less case: there is nothing to resume into — the
+        external spool holder is the whole inventory — so the useful answer is to
+        say which filament is missing and stop. With an AMS attached it returns
+        None: where live status reported loaded trays,
+        ``_block_on_unmatched_filament`` holds the item instead, and where it
+        reported none the print goes out and the firmware error is the answer —
+        the user can load the right spool and press Resume.
 
         Fail-safe by construction, mirroring the nozzle-diameter guard (#1899):
         every branch that lacks the evidence to be sure returns None.
@@ -3500,7 +3801,13 @@ class PrintScheduler:
             pass
 
     async def _compute_ams_mapping_for_printer(
-        self, db: AsyncSession, printer_id: int, item: PrintQueueItem
+        self,
+        db: AsyncSession,
+        printer_id: int,
+        item: PrintQueueItem,
+        *,
+        only_slots: set[int] | None = None,
+        reserved_trays: set[int] | None = None,
     ) -> list[int] | None:
         """Compute AMS mapping for a printer based on filament requirements.
 
@@ -3511,6 +3818,10 @@ class PrintScheduler:
             db: Database session
             printer_id: The assigned printer ID
             item: The queue item (contains archive_id or library_file_id)
+            only_slots: Match only these filament slots; every other slot comes
+                back unresolved (-1). Used to fill the gaps of a stored mapping.
+            reserved_trays: Global tray IDs to leave out of the match, because
+                the stored mapping already gives them to another slot.
 
         Returns:
             AMS mapping array or None if no mapping needed/possible
@@ -3552,9 +3863,15 @@ class PrintScheduler:
             return None
 
         self._apply_filament_overrides(item, filament_reqs)
+        if only_slots is not None:
+            filament_reqs = [req for req in filament_reqs if req.get("slot_id") in only_slots]
+            if not filament_reqs:
+                return None
 
         # Build loaded filaments from printer status
         loaded_filaments = self._build_loaded_filaments(status)
+        if reserved_trays:
+            loaded_filaments = [f for f in loaded_filaments if f["global_tray_id"] not in reserved_trays]
         if not loaded_filaments:
             logger.debug("No filaments loaded on printer %s", printer_id)
             return None
@@ -3666,6 +3983,15 @@ class PrintScheduler:
         """
         from backend.app.services.filament_requirements import extract_filament_requirements
 
+        # Callers rewrite these dicts in place via `_apply_filament_overrides`,
+        # so every caller gets its own copy — a shared list would leak one
+        # item's overrides into the next item that happens to print the same
+        # plate.
+        memo_key = (item.archive_id, item.library_file_id, item.plate_id)
+        if memo_key in self._filament_req_memo:
+            cached = self._filament_req_memo[memo_key]
+            return [dict(r) for r in cached] if cached else None
+
         file_path: Path | None = None
         if item.archive_id:
             result = await db.execute(select(PrintArchive).where(PrintArchive.id == item.archive_id))
@@ -3680,10 +4006,12 @@ class PrintScheduler:
                 file_path = lib_path if lib_path.is_absolute() else settings.base_dir / library_file.file_path
 
         if not file_path or not file_path.exists():
+            self._filament_req_memo[memo_key] = None
             return None
 
         filaments = extract_filament_requirements(file_path, plate_id=item.plate_id)
-        return filaments if filaments else None
+        self._filament_req_memo[memo_key] = filaments or None
+        return [dict(r) for r in filaments] if filaments else None
 
     def _build_loaded_filaments(self, status) -> list[dict]:
         """Build list of loaded filaments from printer status.
@@ -7209,6 +7537,112 @@ class PrintScheduler:
             await db.commit()
         return False
 
+    async def _block_on_unmatched_filament(
+        self,
+        db: AsyncSession,
+        item: PrintQueueItem,
+        release_assignment: bool = False,
+    ) -> bool:
+        """Promote to manual_start when a slot the plate prints has no tray (#2799).
+
+        The matcher leaves a requirement it cannot satisfy at ``-1`` and dispatch
+        goes ahead, letting the printer choose — which is how a job prints in the
+        wrong material without anyone being asked. Holding is the same answer the
+        deficit gate already gives for "the spool is too light", so it reuses the
+        same promote-and-notify machinery.
+
+        Keyed off unresolved slots in the *computed* mapping intersected with the
+        plate's own requirement list. Intersecting is what makes ``-1`` safe to
+        read: on its own it also pads slots this plate does not print, so the
+        array alone would hold perfectly good jobs. Reading the mapping rather
+        than the printer's loaded filament types also inherits the matcher's
+        per-nozzle restriction for free — a dual-nozzle printer carrying the
+        filament on the other nozzle's AMS has it "loaded" but unusable, and a
+        type-only scan would wave that through.
+
+        A mapping that resolved *nothing* is the same finding arriving as an
+        absence. ``_ensure_ams_mapping`` clears a rejected mapping its recompute
+        could not replace, and dispatch then goes out as ``use_ams`` with no
+        table at all — several megabytes uploaded for an 0700_8012 rejection.
+        That is held too, but only once live status positively reports loaded
+        trays: #2589's mapping is bogus precisely because the AMS was not known
+        yet, and its empty loaded list is that ignorance rather than a miss.
+
+        ``release_assignment`` is for the model-based path, which commits its
+        printer choice before the gates run. Holding an "any P2S" job would
+        otherwise pin it to the one P2S that could not run it.
+
+        Returns True when this dispatch attempt was blocked.
+        """
+        if item.skip_filament_check or not item.printer_id:
+            return False
+
+        if item.ams_mapping:
+            try:
+                mapping = json.loads(item.ams_mapping)
+            except (json.JSONDecodeError, TypeError):
+                return False
+            if not isinstance(mapping, list):
+                return False
+        else:
+            # Nothing resolved, or nothing survived revalidation. Only a printer
+            # that reported loaded trays makes that a finding — see the
+            # docstring on why an empty list is not one.
+            status = printer_manager.get_status(item.printer_id)
+            if status is None or not self._build_loaded_filaments(status):
+                return False
+            # Every required slot reads unresolved against an empty mapping.
+            mapping = []
+
+        required = await self._get_filament_requirements(db, item)
+        if not required:
+            return False
+        self._apply_filament_overrides(item, required)
+
+        unmatched = _unresolved_required(required, mapping)
+        if not unmatched:
+            # No cleanup needed here: once start is pressed the item is no
+            # longer staged, and whichever exit runs next overwrites the reason
+            # (`hold_item` on the fixed-printer branch, the assignment on the
+            # model-based one), as does dispatch.
+            return False
+
+        wanted = ", ".join(_describe_filament(r, "nozzle_id") for r in unmatched)
+        held_by = item.printer_id
+        item.manual_start = True
+        # Human-readable: this renders on the queue row.
+        item.waiting_reason = f"{_UNMATCHED_HOLD_PREFIX}{wanted}"
+        if release_assignment:
+            # "Any P2S" means any, so the job returns to the pool rather than
+            # waiting on the printer that could not take it. The mapping goes
+            # with the assignment: its tray IDs were resolved against the
+            # printer being released and mean nothing on the next one (#2799),
+            # and keeping them would hold the job again even on a printer that
+            # has the filament, since an unresolved slot is deliberately not a
+            # conflict worth recomputing over.
+            item.printer_id = None
+            item.ams_mapping = None
+        await db.commit()
+
+        job_name = await self._get_job_name(db, item)
+        printer = await self._get_printer(db, held_by)
+        logger.info(
+            "Queue item %s blocked — printer %s has nothing loaded for %s; promoted to manual_start",
+            item.id,
+            held_by,
+            wanted,
+        )
+        try:
+            await notification_service.on_queue_job_waiting(
+                job_name=job_name,
+                target_model=(printer.model if printer else "") or "",
+                waiting_reason=f"needs {wanted}",
+                db=db,
+            )
+        except Exception as e:
+            logger.debug("filament_missing notification failed for item %s: %s", item.id, e)
+        return True
+
     async def _propagate_owner_to_printer_manager(self, db: AsyncSession, item: PrintQueueItem) -> None:
         """Hand the queue item's owner to printer_manager so the
         print-complete callback can credit the user in PrintLogEntry (#1670).

+ 75 - 0
backend/tests/integration/test_print_queue_api.py

@@ -891,6 +891,22 @@ class TestPrintQueueAPI:
         result = response.json()
         assert result["manual_start"] is True
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_queue_item_skip_filament_check(
+        self, async_client: AsyncClient, queue_item_factory, db_session
+    ):
+        """#2799: the print dialog sends it on edit when the mapping deliberately
+        puts a slot on another material."""
+        item = await queue_item_factory()
+        response = await async_client.patch(f"/api/v1/queue/{item.id}", json={"skip_filament_check": True})
+        assert response.status_code == 200
+        assert response.json()["skip_filament_check"] is True
+
+        # Omitted on a later edit, it stays as it was.
+        response = await async_client.patch(f"/api/v1/queue/{item.id}", json={"manual_start": True})
+        assert response.json()["skip_filament_check"] is True
+
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_delete_queue_item(self, async_client: AsyncClient, queue_item_factory, db_session):
@@ -1062,6 +1078,65 @@ class TestQueueStartEndpoint:
         assert result["manual_start"] is False
         assert result["status"] == "pending"
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_start_with_a_filament_not_loaded_offers_print_anyway(
+        self, async_client: AsyncClient, queue_item_factory, db_session
+    ):
+        """#2799: Start on an item whose filament has no tray answers 409 with the
+        missing filaments instead of releasing it to be held again."""
+        from unittest.mock import AsyncMock, patch
+
+        item = await queue_item_factory(manual_start=True)
+        with patch(
+            "backend.app.services.print_scheduler.scheduler.missing_filament_for_start",
+            AsyncMock(return_value=["PETG #2850E0"]),
+        ):
+            response = await async_client.post(f"/api/v1/queue/{item.id}/start")
+
+        assert response.status_code == 409
+        assert response.json()["detail"] == {"code": "unmatched_filament", "missing": ["PETG #2850E0"]}
+        await db_session.refresh(item)
+        assert item.manual_start is True
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_a_failing_filament_check_does_not_break_start(
+        self, async_client: AsyncClient, queue_item_factory, db_session
+    ):
+        """The check is a convenience; the scheduler still holds the item if the
+        filament is missing, so an error in it must not turn Start into a 500."""
+        from unittest.mock import AsyncMock, patch
+
+        item = await queue_item_factory(manual_start=True)
+        with patch(
+            "backend.app.services.print_scheduler.scheduler.missing_filament_for_start",
+            AsyncMock(side_effect=RuntimeError("boom")),
+        ):
+            response = await async_client.post(f"/api/v1/queue/{item.id}/start")
+
+        assert response.status_code == 200
+        assert response.json()["manual_start"] is False
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_print_anyway_past_a_filament_not_loaded(
+        self, async_client: AsyncClient, queue_item_factory, db_session
+    ):
+        """The retry with skip_filament_check is not asked again, and is remembered."""
+        from unittest.mock import AsyncMock, patch
+
+        item = await queue_item_factory(manual_start=True)
+        check = AsyncMock(return_value=["PETG #2850E0"])
+        with patch("backend.app.services.print_scheduler.scheduler.missing_filament_for_start", check):
+            response = await async_client.post(f"/api/v1/queue/{item.id}/start?skip_filament_check=true")
+
+        assert response.status_code == 200
+        check.assert_not_awaited()
+        result = response.json()
+        assert result["manual_start"] is False
+        assert result["skip_filament_check"] is True
+
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_start_non_staged_queue_item(self, async_client: AsyncClient, queue_item_factory, db_session):

+ 858 - 0
backend/tests/unit/test_ams_mapping_per_printer_2799.py

@@ -0,0 +1,858 @@
+"""Regression tests for the cross-printer AMS mapping bug (#2799).
+
+One model queued to three P2S at once produced three queue items carrying a
+byte-identical ``ams_mapping``. That mapping was correct for the printer it was
+computed against and wrong for the other two, whose AMS held the same spools in
+different slots — so two of the three printed the lettering in ASA where the 3MF
+strictly requires PETG. An explicit ``ams_mapping`` bypasses the firmware's own
+type check, so nothing downstream caught it.
+
+The reproduction below is the real incident: the 3MF needs light-blue PETG in
+slot 1 and dark-blue PETG in slot 3; P2S-4 holds them in trays 2 and 1, while
+P2S-5 holds them in trays 0 and 2 with ASA in tray 1. ``[2, -1, 1]`` is right on
+the first and feeds ASA into slot 3 on the second.
+"""
+
+import json
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from backend.app.services.print_scheduler import PrintScheduler
+
+# 3MF requirements: slot 1 light-blue PETG (body), slot 3 dark-blue PETG (text).
+REQUIREMENTS = [
+    {"slot_id": 1, "type": "PETG", "color": "#76D9F4", "tray_info_idx": "GFG99"},
+    {"slot_id": 3, "type": "PETG", "color": "#2850E0", "tray_info_idx": "GFG99"},
+]
+
+# AMS layouts as reported over MQTT, same spools in a different slot order.
+P2S_4_TRAYS = [
+    {"global_tray_id": 0, "type": "ASA", "color": "161616FF"},
+    {"global_tray_id": 1, "type": "PETG", "color": "2850E0FF"},
+    {"global_tray_id": 2, "type": "PETG", "color": "76D9F4FF"},
+    {"global_tray_id": 3, "type": "ASA", "color": "898989FF"},
+]
+P2S_5_TRAYS = [
+    {"global_tray_id": 0, "type": "PETG", "color": "76D9F4FF"},
+    {"global_tray_id": 1, "type": "ASA", "color": "161616FF"},
+    {"global_tray_id": 2, "type": "PETG", "color": "2850E0FF"},
+    {"global_tray_id": 3, "type": "ASA", "color": "898989FF"},
+]
+
+# The mapping the print dialog stamped onto all three items.
+SHARED_MAPPING = [2, -1, 1]
+
+
+def _item(**overrides):
+    item = MagicMock()
+    item.id = 276
+    item.printer_id = 8
+    item.ams_mapping = json.dumps(SHARED_MAPPING)
+    item.skip_filament_check = False
+    item.filament_overrides = None
+    item.required_filament_types = None
+    item.manual_start = False
+    item.filament_short = False
+    for key, value in overrides.items():
+        setattr(item, key, value)
+    return item
+
+
+def _scheduler(trays, requirements=REQUIREMENTS):
+    scheduler = PrintScheduler()
+    scheduler._build_loaded_filaments = MagicMock(return_value=list(trays))
+    scheduler._get_filament_requirements = AsyncMock(
+        return_value=[dict(r) for r in requirements] if requirements else None
+    )
+    return scheduler
+
+
+class TestStoredMappingConflict:
+    """The stored mapping is re-checked against the printer about to run it."""
+
+    @pytest.mark.asyncio
+    async def test_mapping_fits_the_printer_it_was_computed_for(self):
+        scheduler = _scheduler(P2S_4_TRAYS)
+        with patch("backend.app.services.print_scheduler.printer_manager") as pm:
+            pm.get_status.return_value = MagicMock()
+            conflict = await scheduler._stored_mapping_conflict(AsyncMock(), 9, _item(), SHARED_MAPPING)
+        assert conflict is None
+
+    @pytest.mark.asyncio
+    async def test_same_mapping_on_a_differently_loaded_printer_conflicts(self):
+        """The incident: slot 3 would have printed in ASA."""
+        scheduler = _scheduler(P2S_5_TRAYS)
+        with patch("backend.app.services.print_scheduler.printer_manager") as pm:
+            pm.get_status.return_value = MagicMock()
+            conflict = await scheduler._stored_mapping_conflict(AsyncMock(), 8, _item(), SHARED_MAPPING)
+        assert conflict is not None
+        assert "slot 3" in conflict
+        assert "ASA" in conflict
+
+    @pytest.mark.asyncio
+    async def test_tray_not_loaded_conflicts(self):
+        scheduler = _scheduler(P2S_4_TRAYS[:2])  # trays 2 and 3 removed
+        with patch("backend.app.services.print_scheduler.printer_manager") as pm:
+            pm.get_status.return_value = MagicMock()
+            conflict = await scheduler._stored_mapping_conflict(AsyncMock(), 9, _item(), SHARED_MAPPING)
+        assert conflict is not None
+        assert "does not have loaded" in conflict
+
+    @pytest.mark.asyncio
+    async def test_mapping_too_short_for_the_plate_conflicts(self):
+        scheduler = _scheduler(P2S_4_TRAYS)
+        with patch("backend.app.services.print_scheduler.printer_manager") as pm:
+            pm.get_status.return_value = MagicMock()
+            conflict = await scheduler._stored_mapping_conflict(AsyncMock(), 9, _item(), [2])
+        assert conflict is not None
+        assert "slot 3" in conflict
+
+    @pytest.mark.asyncio
+    async def test_unreported_external_feed_is_not_a_conflict(self):
+        """An external spool we have not heard about is absence of evidence."""
+        scheduler = _scheduler(P2S_4_TRAYS)
+        with patch("backend.app.services.print_scheduler.printer_manager") as pm:
+            pm.get_status.return_value = MagicMock()
+            conflict = await scheduler._stored_mapping_conflict(AsyncMock(), 9, _item(), [254, -1, 255])
+        assert conflict is None
+
+    @pytest.mark.asyncio
+    async def test_external_feed_is_type_checked_like_any_tray(self):
+        """Two printers with different filament in the external feed is the same
+        failure this check exists for, so >=254 is not a free pass."""
+        trays = P2S_4_TRAYS + [{"global_tray_id": 254, "type": "ASA", "color": "161616FF"}]
+        scheduler = _scheduler(trays)
+        with patch("backend.app.services.print_scheduler.printer_manager") as pm:
+            pm.get_status.return_value = MagicMock()
+            conflict = await scheduler._stored_mapping_conflict(AsyncMock(), 9, _item(), [254, -1, 1])
+        assert conflict is not None
+        assert "ASA" in conflict
+
+    @pytest.mark.asyncio
+    async def test_an_external_feed_does_not_excuse_the_rest_of_the_mapping(self):
+        """An unrecognised >=254 entry is absence of evidence about that slot
+        only. Bailing out of the whole pass on it let a foreign tray later in
+        the mapping through unexamined."""
+        scheduler = _scheduler(P2S_4_TRAYS)
+        with patch("backend.app.services.print_scheduler.printer_manager") as pm:
+            pm.get_status.return_value = MagicMock()
+            conflict = await scheduler._stored_mapping_conflict(AsyncMock(), 9, _item(), [254, -1, 7])
+        assert conflict is not None
+        assert "slot 3" in conflict
+
+    @pytest.mark.asyncio
+    async def test_unresolved_required_slot_is_not_a_conflict(self):
+        """-1 says the matcher had nothing, not that the mapping is foreign.
+        Recomputing on it would discard the slots the user did resolve by hand;
+        _block_on_unmatched_filament owns this case instead."""
+        scheduler = _scheduler(P2S_4_TRAYS)
+        with patch("backend.app.services.print_scheduler.printer_manager") as pm:
+            pm.get_status.return_value = MagicMock()
+            conflict = await scheduler._stored_mapping_conflict(AsyncMock(), 9, _item(), [2, -1, -1])
+        assert conflict is None
+
+    @pytest.mark.asyncio
+    async def test_foreign_tray_is_caught_without_reading_the_3mf(self):
+        """The cheap pass runs on live status alone, so a foreign mapping is
+        rejected without opening the 3MF."""
+        scheduler = _scheduler(P2S_4_TRAYS)
+        with patch("backend.app.services.print_scheduler.printer_manager") as pm:
+            pm.get_status.return_value = MagicMock()
+            conflict = await scheduler._stored_mapping_conflict(AsyncMock(), 9, _item(), [7, -1, 1])
+        assert conflict is not None
+        assert "does not have loaded" in conflict
+        scheduler._get_filament_requirements.assert_not_awaited()
+
+    @pytest.mark.asyncio
+    async def test_no_status_is_not_a_conflict(self):
+        """Absence of evidence never triggers a recompute."""
+        scheduler = _scheduler(P2S_5_TRAYS)
+        with patch("backend.app.services.print_scheduler.printer_manager") as pm:
+            pm.get_status.return_value = None
+            conflict = await scheduler._stored_mapping_conflict(AsyncMock(), 8, _item(), SHARED_MAPPING)
+        assert conflict is None
+
+    @pytest.mark.asyncio
+    async def test_no_trays_reported_yet_is_not_a_conflict(self):
+        scheduler = _scheduler([])
+        with patch("backend.app.services.print_scheduler.printer_manager") as pm:
+            pm.get_status.return_value = MagicMock()
+            conflict = await scheduler._stored_mapping_conflict(AsyncMock(), 8, _item(), SHARED_MAPPING)
+        assert conflict is None
+
+    @pytest.mark.asyncio
+    async def test_unreadable_requirements_is_not_a_conflict(self):
+        scheduler = _scheduler(P2S_5_TRAYS, requirements=None)
+        with patch("backend.app.services.print_scheduler.printer_manager") as pm:
+            pm.get_status.return_value = MagicMock()
+            conflict = await scheduler._stored_mapping_conflict(AsyncMock(), 8, _item(), SHARED_MAPPING)
+        assert conflict is None
+
+    @pytest.mark.asyncio
+    async def test_a_mapping_that_is_not_a_list_is_not_a_conflict(self):
+        """A corrupted row is not evidence about the printer, and the guard that
+        keeps it from being indexed was the one thing here without a test."""
+        scheduler = _scheduler(P2S_5_TRAYS)
+        with patch("backend.app.services.print_scheduler.printer_manager") as pm:
+            pm.get_status.return_value = MagicMock()
+            conflict = await scheduler._stored_mapping_conflict(AsyncMock(), 8, _item(), {"1": 2})
+        assert conflict is None
+
+    @pytest.mark.asyncio
+    async def test_a_boolean_is_not_read_as_tray_one(self):
+        """`true` is an int in Python, so it would otherwise be judged against
+        whatever tray 1 holds — here ASA, i.e. a conflict reported for a slot
+        nothing is actually known about."""
+        scheduler = _scheduler(P2S_5_TRAYS)
+        with patch("backend.app.services.print_scheduler.printer_manager") as pm:
+            pm.get_status.return_value = MagicMock()
+            conflict = await scheduler._stored_mapping_conflict(AsyncMock(), 8, _item(), [True, -1, 2])
+        assert conflict is None
+
+
+class TestEnsureAmsMappingRevalidates:
+    """``_ensure_ams_mapping`` acts on the conflict verdict."""
+
+    @pytest.mark.asyncio
+    async def test_conflicting_mapping_is_recomputed(self):
+        scheduler = _scheduler(P2S_5_TRAYS)
+        scheduler._compute_ams_mapping_for_printer = AsyncMock(return_value=[0, -1, 2])
+        item = _item()
+        db = AsyncMock()
+
+        with patch("backend.app.services.print_scheduler.printer_manager") as pm:
+            pm.get_status.return_value = MagicMock()
+            await scheduler._ensure_ams_mapping(db, 8, item)
+
+        assert json.loads(item.ams_mapping) == [0, -1, 2]
+        scheduler._compute_ams_mapping_for_printer.assert_awaited_once()
+
+    @pytest.mark.asyncio
+    async def test_fitting_mapping_is_left_untouched(self):
+        scheduler = _scheduler(P2S_4_TRAYS)
+        scheduler._compute_ams_mapping_for_printer = AsyncMock(return_value=[0, -1, 2])
+        item = _item(printer_id=9)
+        db = AsyncMock()
+
+        with patch("backend.app.services.print_scheduler.printer_manager") as pm:
+            pm.get_status.return_value = MagicMock()
+            await scheduler._ensure_ams_mapping(db, 9, item)
+
+        assert json.loads(item.ams_mapping) == SHARED_MAPPING
+        scheduler._compute_ams_mapping_for_printer.assert_not_awaited()
+
+    @pytest.mark.asyncio
+    async def test_print_anyway_keeps_the_users_mapping(self):
+        """skip_filament_check is the user overriding exactly this judgement."""
+        scheduler = _scheduler(P2S_5_TRAYS)
+        scheduler._compute_ams_mapping_for_printer = AsyncMock(return_value=[0, -1, 2])
+        item = _item(skip_filament_check=True)
+        db = AsyncMock()
+
+        with patch("backend.app.services.print_scheduler.printer_manager") as pm:
+            pm.get_status.return_value = MagicMock()
+            await scheduler._ensure_ams_mapping(db, 8, item)
+
+        assert json.loads(item.ams_mapping) == SHARED_MAPPING
+        scheduler._compute_ams_mapping_for_printer.assert_not_awaited()
+
+    @pytest.mark.asyncio
+    async def test_failed_recompute_does_not_fall_back_to_the_bad_mapping(self):
+        scheduler = _scheduler(P2S_5_TRAYS)
+        scheduler._compute_ams_mapping_for_printer = AsyncMock(return_value=None)
+        scheduler._unmappable_without_ams_message = AsyncMock(return_value=None)
+        item = _item()
+        db = AsyncMock()
+
+        with patch("backend.app.services.print_scheduler.printer_manager") as pm:
+            pm.get_status.return_value = MagicMock()
+            await scheduler._ensure_ams_mapping(db, 8, item)
+
+        assert item.ams_mapping is None
+
+
+class TestBlockOnUnmatchedFilament:
+    """A slot the plate prints with no tray on this printer holds the job."""
+
+    @pytest.mark.asyncio
+    async def test_unresolved_required_slot_holds(self):
+        scheduler = _scheduler(P2S_5_TRAYS)
+        scheduler._get_job_name = AsyncMock(return_value="body1")
+        scheduler._get_printer = AsyncMock(return_value=MagicMock(model="P2S"))
+        item = _item(ams_mapping=json.dumps([0, -1, -1]))
+        db = AsyncMock()
+
+        blocked = await scheduler._block_on_unmatched_filament(db, item)
+
+        assert blocked is True
+        assert item.manual_start is True
+        # The reason names the filament, not a machine token, because it renders
+        # on the queue row.
+        assert item.waiting_reason.startswith("Needs ")
+        assert "PETG" in item.waiting_reason
+        assert "#2850E0" in item.waiting_reason
+
+    @pytest.mark.asyncio
+    async def test_padding_for_a_slot_this_plate_skips_does_not_hold(self):
+        """-1 at slot 2, which this plate never prints, is not a missing filament.
+        This is why the hold intersects with the requirement list rather than
+        reading the mapping array on its own."""
+        scheduler = _scheduler(P2S_5_TRAYS)
+        item = _item(ams_mapping=json.dumps([0, -1, 2]))
+
+        blocked = await scheduler._block_on_unmatched_filament(AsyncMock(), item)
+
+        assert blocked is False
+        assert item.manual_start is False
+
+    @pytest.mark.asyncio
+    async def test_mapping_too_short_for_the_plate_holds(self):
+        scheduler = _scheduler(P2S_5_TRAYS)
+        scheduler._get_job_name = AsyncMock(return_value="body1")
+        scheduler._get_printer = AsyncMock(return_value=MagicMock(model="P2S"))
+        item = _item(ams_mapping=json.dumps([0]))
+
+        blocked = await scheduler._block_on_unmatched_filament(AsyncMock(), item)
+
+        assert blocked is True
+
+    @pytest.mark.asyncio
+    async def test_print_anyway_dispatches(self):
+        scheduler = _scheduler(P2S_5_TRAYS)
+        item = _item(ams_mapping=json.dumps([0, -1, -1]), skip_filament_check=True)
+
+        blocked = await scheduler._block_on_unmatched_filament(AsyncMock(), item)
+
+        assert blocked is False
+
+    @pytest.mark.asyncio
+    async def test_a_mapping_that_resolved_nothing_at_all_holds(self):
+        """The complete miss used to fall out of this gate and into the ugly path.
+
+        `_ensure_ams_mapping` clears a rejected mapping the recompute could not
+        replace, and bailing on `not item.ams_mapping` then dispatched the item
+        with no mapping at all: several megabytes uploaded, 0700_8012 from the
+        firmware, retries burned. Live status reporting loaded trays while the
+        matcher resolved none of them is a positive finding, so it holds.
+        """
+        scheduler = _scheduler(P2S_5_TRAYS)
+        scheduler._get_job_name = AsyncMock(return_value="body1")
+        scheduler._get_printer = AsyncMock(return_value=MagicMock(model="P2S"))
+        item = _item(ams_mapping=None)
+
+        with patch("backend.app.services.print_scheduler.printer_manager") as pm:
+            pm.get_status.return_value = MagicMock()
+            blocked = await scheduler._block_on_unmatched_filament(AsyncMock(), item)
+
+        assert blocked is True
+        assert item.manual_start is True
+        assert item.waiting_reason.startswith("Needs ")
+
+    @pytest.mark.asyncio
+    async def test_a_cleared_mapping_on_a_printer_with_no_trays_reported_dispatches(self):
+        """#2589 is the same shape and must stay untouched: there the mapping is
+        bogus because the AMS was not known yet, and an empty loaded list is
+        exactly that ignorance. Holding on it would hold on absence of
+        evidence."""
+        scheduler = _scheduler([])
+        item = _item(ams_mapping=None)
+
+        with patch("backend.app.services.print_scheduler.printer_manager") as pm:
+            pm.get_status.return_value = MagicMock()
+            blocked = await scheduler._block_on_unmatched_filament(AsyncMock(), item)
+
+        assert blocked is False
+        assert item.manual_start is False
+
+    @pytest.mark.asyncio
+    async def test_a_cleared_mapping_with_no_status_dispatches(self):
+        scheduler = _scheduler(P2S_5_TRAYS)
+        item = _item(ams_mapping=None)
+
+        with patch("backend.app.services.print_scheduler.printer_manager") as pm:
+            pm.get_status.return_value = None
+            blocked = await scheduler._block_on_unmatched_filament(AsyncMock(), item)
+
+        assert blocked is False
+        assert item.manual_start is False
+
+    @pytest.mark.asyncio
+    async def test_a_boolean_tray_id_holds_rather_than_reading_as_tray_one(self):
+        """`true` is an int in Python. A hand-written payload carrying it would
+        otherwise be read as tray 1 and dispatched as whatever sits there."""
+        scheduler = _scheduler(P2S_5_TRAYS)
+        scheduler._get_job_name = AsyncMock(return_value="body1")
+        scheduler._get_printer = AsyncMock(return_value=MagicMock(model="P2S"))
+        item = _item(ams_mapping=json.dumps([True, -1, 2]))
+
+        blocked = await scheduler._block_on_unmatched_filament(AsyncMock(), item)
+
+        assert blocked is True
+        assert "#76D9F4" in item.waiting_reason
+
+
+class TestHoldOnTheModelBasedPath:
+    """A held "any P2S" job must not stay pinned to the P2S that held it.
+
+    The model-based path commits `item.printer_id` before the gates run, so a
+    hold would otherwise leave an unpinned job pinned to the one printer that
+    could not run it, and the next press of the start button retries the same
+    dead end. The printer-assigned path keeps its printer: that one was the
+    user's choice, not the scheduler's.
+    """
+
+    @pytest.mark.asyncio
+    async def test_a_model_based_hold_releases_the_printer_and_its_mapping(self):
+        scheduler = _scheduler(P2S_5_TRAYS)
+        scheduler._get_job_name = AsyncMock(return_value="body1")
+        scheduler._get_printer = AsyncMock(return_value=MagicMock(model="P2S"))
+        item = _item(ams_mapping=json.dumps([0, -1, -1]))
+
+        blocked = await scheduler._block_on_unmatched_filament(AsyncMock(), item, release_assignment=True)
+
+        assert blocked is True
+        assert item.printer_id is None
+        # The mapping goes with the assignment: its tray IDs were resolved
+        # against the printer just released, so leaving it would hold the job
+        # again on the next printer even when that one has the filament.
+        assert item.ams_mapping is None
+        assert item.waiting_reason.startswith("Needs ")
+
+    @pytest.mark.asyncio
+    async def test_the_notification_still_names_the_printer_that_held_it(self):
+        scheduler = _scheduler(P2S_5_TRAYS)
+        scheduler._get_job_name = AsyncMock(return_value="body1")
+        scheduler._get_printer = AsyncMock(return_value=MagicMock(model="P2S"))
+        item = _item(ams_mapping=json.dumps([0, -1, -1]))
+
+        await scheduler._block_on_unmatched_filament(AsyncMock(), item, release_assignment=True)
+
+        # Looked up before the release, or it would be looked up as None.
+        assert scheduler._get_printer.await_args.args[1] == 8
+
+    @pytest.mark.asyncio
+    async def test_a_printer_assigned_hold_keeps_both(self):
+        scheduler = _scheduler(P2S_5_TRAYS)
+        scheduler._get_job_name = AsyncMock(return_value="body1")
+        scheduler._get_printer = AsyncMock(return_value=MagicMock(model="P2S"))
+        item = _item(ams_mapping=json.dumps([0, -1, -1]))
+
+        blocked = await scheduler._block_on_unmatched_filament(AsyncMock(), item)
+
+        assert blocked is True
+        assert item.printer_id == 8
+        assert json.loads(item.ams_mapping) == [0, -1, -1]
+
+    @pytest.mark.asyncio
+    async def test_unreadable_requirements_dispatches(self):
+        scheduler = _scheduler(P2S_5_TRAYS, requirements=None)
+        item = _item(ams_mapping=json.dumps([0, -1, -1]))
+
+        blocked = await scheduler._block_on_unmatched_filament(AsyncMock(), item)
+
+        assert blocked is False
+
+    @pytest.mark.asyncio
+    async def test_nozzle_bound_filament_on_the_wrong_side_holds(self):
+        """The H2D case the type-only scan waved through: PETG is loaded, but on
+        the other nozzle's AMS, so the nozzle-aware matcher left the slot at -1.
+        Reading the mapping inherits that restriction for free."""
+        scheduler = _scheduler(P2S_5_TRAYS)
+        scheduler._get_job_name = AsyncMock(return_value="body1")
+        scheduler._get_printer = AsyncMock(return_value=MagicMock(model="H2D"))
+        item = _item(ams_mapping=json.dumps([0, -1, -1]))
+
+        blocked = await scheduler._block_on_unmatched_filament(AsyncMock(), item)
+
+        assert blocked is True
+
+
+class TestModelBasedVirtualPrinterMapping:
+    """A model-based Virtual Printer stamps a mapping nothing can attribute.
+
+    `virtual_printer/manager.py` writes the slicer's own AMS pick onto the queue
+    item it creates, on the line below `printer_id=self.target_printer_id` —
+    which is None for an "Any P2S" VP. So the tray IDs the slicer resolved are
+    carried by an item the scheduler will hand to whichever printer of that
+    model happens to be free, and the row itself records no printer they could
+    be checked against. Same failure as the print dialog's, arriving from a
+    different direction, and reachable whenever the per-VP `save_ams_mapping`
+    opt-in is on without `queue_force_color_match`.
+
+    The item has no `printer_id` until the model-based path assigns one, so
+    these go through `_ensure_ams_mapping`'s `printer_id` argument rather than
+    `item.printer_id` — which is how that path calls it.
+    """
+
+    @pytest.mark.asyncio
+    async def test_slicer_mapping_is_rechecked_against_the_printer_chosen_for_it(self):
+        scheduler = _scheduler(P2S_5_TRAYS)
+        scheduler._compute_ams_mapping_for_printer = AsyncMock(return_value=[0, -1, 2])
+        # Fresh from the VP: no printer_id, mapping resolved by the slicer.
+        item = _item(printer_id=None)
+        db = AsyncMock()
+
+        with patch("backend.app.services.print_scheduler.printer_manager") as pm:
+            pm.get_status.return_value = MagicMock()
+            await scheduler._ensure_ams_mapping(db, 10, item)
+
+        assert json.loads(item.ams_mapping) == [0, -1, 2]
+        # Judged against the printer the scheduler picked, not the row's own
+        # (absent) one.
+        scheduler._compute_ams_mapping_for_printer.assert_awaited_once()
+        assert scheduler._compute_ams_mapping_for_printer.await_args.args[1] == 10
+
+    @pytest.mark.asyncio
+    async def test_slicer_mapping_survives_on_a_printer_it_happens_to_fit(self):
+        """Not every VP mapping is wrong — one that fits the chosen printer is
+        kept, so the slicer's deliberate slot pick is not thrown away."""
+        scheduler = _scheduler(P2S_4_TRAYS)
+        scheduler._compute_ams_mapping_for_printer = AsyncMock(return_value=[0, -1, 2])
+        item = _item(printer_id=None)
+        db = AsyncMock()
+
+        with patch("backend.app.services.print_scheduler.printer_manager") as pm:
+            pm.get_status.return_value = MagicMock()
+            await scheduler._ensure_ams_mapping(db, 9, item)
+
+        assert json.loads(item.ams_mapping) == SHARED_MAPPING
+        scheduler._compute_ams_mapping_for_printer.assert_not_awaited()
+
+
+class TestFilamentRequirementMemo:
+    """The two gates parse the same 3MF per dispatch attempt (#2799 review).
+
+    A one-file fan-out to many idle printers repeats that for every one of them
+    inside a single pass, each time a DB lookup plus a zip open and XML parse.
+    The memo is scoped to the pass so a file re-sliced between ticks is re-read.
+    """
+
+    @staticmethod
+    def _db_returning(archive):
+        result = MagicMock()
+        result.scalar_one_or_none.return_value = archive
+        db = AsyncMock()
+        db.execute = AsyncMock(return_value=result)
+        return db
+
+    @pytest.fixture
+    def parsed(self, tmp_path, monkeypatch):
+        from backend.app.services import print_scheduler as ps
+
+        (tmp_path / "plate.3mf").write_bytes(b"")
+        monkeypatch.setattr(ps.settings, "base_dir", tmp_path)
+
+        calls = []
+
+        def fake_extract(path, plate_id=None):
+            calls.append((path, plate_id))
+            return [{"slot_id": 1, "type": "PETG", "color": "#76D9F4", "tray_info_idx": ""}]
+
+        monkeypatch.setattr(
+            "backend.app.services.filament_requirements.extract_filament_requirements",
+            fake_extract,
+        )
+        return calls
+
+    @pytest.mark.asyncio
+    async def test_the_same_plate_is_parsed_once_per_pass(self, parsed):
+        scheduler = PrintScheduler()
+        db = self._db_returning(MagicMock(file_path="plate.3mf"))
+        item = _item(archive_id=731, library_file_id=None, plate_id=22)
+
+        await scheduler._get_filament_requirements(db, item)
+        await scheduler._get_filament_requirements(db, item)
+
+        assert len(parsed) == 1
+
+    @pytest.mark.asyncio
+    async def test_each_caller_gets_its_own_copy(self, parsed):
+        """`_apply_filament_overrides` rewrites these dicts in place, so handing
+        out the cached list would leak one item's overrides into the next."""
+        scheduler = PrintScheduler()
+        db = self._db_returning(MagicMock(file_path="plate.3mf"))
+        item = _item(archive_id=731, library_file_id=None, plate_id=22)
+
+        first = await scheduler._get_filament_requirements(db, item)
+        first[0]["type"] = "ASA"  # an override applied by one caller
+
+        second = await scheduler._get_filament_requirements(db, item)
+        assert second[0]["type"] == "PETG"
+
+    @pytest.mark.asyncio
+    async def test_a_different_plate_is_parsed_separately(self, parsed):
+        scheduler = PrintScheduler()
+        db = self._db_returning(MagicMock(file_path="plate.3mf"))
+
+        await scheduler._get_filament_requirements(db, _item(archive_id=731, library_file_id=None, plate_id=22))
+        await scheduler._get_filament_requirements(db, _item(archive_id=731, library_file_id=None, plate_id=23))
+
+        assert len(parsed) == 2
+
+    @pytest.mark.asyncio
+    async def test_the_memo_does_not_outlive_the_pass(self, parsed):
+        """check_queue clears it at the top of every pass."""
+        scheduler = PrintScheduler()
+        db = self._db_returning(MagicMock(file_path="plate.3mf"))
+        item = _item(archive_id=731, library_file_id=None, plate_id=22)
+
+        await scheduler._get_filament_requirements(db, item)
+        scheduler._filament_req_memo.clear()
+        await scheduler._get_filament_requirements(db, item)
+
+        assert len(parsed) == 2
+
+    @pytest.mark.asyncio
+    async def test_an_unreadable_file_is_remembered_too(self, parsed):
+        """The miss is worth caching as well — otherwise a missing file costs a
+        DB lookup per gate per attempt for nothing."""
+        scheduler = PrintScheduler()
+        db = self._db_returning(None)
+        item = _item(archive_id=999, library_file_id=None, plate_id=1)
+
+        assert await scheduler._get_filament_requirements(db, item) is None
+        assert await scheduler._get_filament_requirements(db, item) is None
+        assert db.execute.await_count == 1
+
+
+class TestTheHoldReasonSurvivesTheNextPass:
+    """The reason is the hold's whole explanation, so a later pass must keep it.
+
+    Since #3074 the scheduler clears the reason of every staged item on each
+    pass, because a reason left from before staging would otherwise stand
+    forever. The unmatched-filament hold writes its reason at the moment it
+    stages the item, so clearing it would leave a job that waits for a start
+    press with nothing on the row saying which spool to load.
+    """
+
+    @pytest.fixture
+    async def session_maker(self):
+        from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
+
+        import backend.app.models  # noqa: F401 - populate Base.metadata
+        from backend.app.core.database import Base
+
+        engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
+        async with engine.begin() as conn:
+            await conn.run_sync(Base.metadata.create_all)
+        try:
+            yield async_sessionmaker(engine, expire_on_commit=False)
+        finally:
+            await engine.dispose()
+
+    async def _staged_item(self, session_maker, reason):
+        from backend.app.models.print_queue import PrintQueueItem
+        from backend.app.models.printer import Printer
+
+        async with session_maker() as db:
+            printer = Printer(
+                name="P2S-5", serial_number="SERIAL", ip_address="10.0.0.1", access_code="code", model="P2S"
+            )
+            db.add(printer)
+            await db.flush()
+            item = PrintQueueItem(
+                printer_id=printer.id, status="pending", position=0, manual_start=True, waiting_reason=reason
+            )
+            db.add(item)
+            await db.commit()
+            return item.id
+
+    async def _reason_after_a_pass(self, session_maker, item_id):
+        from backend.app.models.print_queue import PrintQueueItem
+
+        scheduler = PrintScheduler()
+        with (
+            patch("backend.app.services.print_scheduler.async_session", session_maker),
+            patch("backend.app.services.print_scheduler.printer_manager.is_connected", MagicMock(return_value=True)),
+            patch("backend.app.services.print_scheduler.printer_manager.get_status", MagicMock(return_value=None)),
+            patch.object(scheduler, "_is_printer_idle", MagicMock(return_value=True)),
+            patch.object(scheduler, "_check_auto_drying", AsyncMock()),
+        ):
+            await scheduler.check_queue()
+        async with session_maker() as db:
+            return (await db.get(PrintQueueItem, item_id)).waiting_reason
+
+    @pytest.mark.asyncio
+    async def test_the_hold_reason_stays_on_a_staged_item(self, session_maker):
+        item_id = await self._staged_item(session_maker, "Needs PETG #2850E0")
+
+        assert await self._reason_after_a_pass(session_maker, item_id) == "Needs PETG #2850E0"
+
+    @pytest.mark.asyncio
+    async def test_any_other_reason_on_a_staged_item_is_still_cleared(self, session_maker):
+        """#3074's rule for everything else is unchanged."""
+        item_id = await self._staged_item(session_maker, "Busy: P2S-5")
+
+        assert await self._reason_after_a_pass(session_maker, item_id) is None
+
+
+def _matching_scheduler(trays):
+    """A scheduler whose real matcher runs against ``trays``."""
+    scheduler = _scheduler(trays)
+    scheduler._get_bool_setting = AsyncMock(return_value=False)
+    scheduler._get_job_name = AsyncMock(return_value="body1")
+    scheduler._get_printer = AsyncMock(return_value=MagicMock(model="P2S"))
+    return scheduler
+
+
+class TestUnresolvedSlotsAreMatchedAgain:
+    """A fitting mapping keeps its resolved slots and has only its gaps matched again.
+
+    The hold keeps a pinned item's mapping, and an unresolved slot is not a
+    conflict, so without this nothing would look at the gap again: loading the
+    missing spool and pressing Start would hold the job again, every time.
+    """
+
+    @pytest.mark.asyncio
+    async def test_a_spool_loaded_after_the_hold_is_found(self):
+        # Held with slot 3 unresolved; dark-blue PETG has since gone into tray 2.
+        scheduler = _matching_scheduler(P2S_5_TRAYS)
+        item = _item(ams_mapping=json.dumps([0, -1, -1]))
+        db = AsyncMock()
+
+        with patch("backend.app.services.print_scheduler.printer_manager") as pm:
+            pm.get_status.return_value = MagicMock()
+            await scheduler._ensure_ams_mapping(db, 8, item)
+            blocked = await scheduler._block_on_unmatched_filament(db, item)
+
+        assert json.loads(item.ams_mapping) == [0, -1, 2]
+        assert blocked is False
+
+    @pytest.mark.asyncio
+    async def test_a_tray_the_user_picked_is_kept_and_not_handed_out_twice(self):
+        """The user put slot 1 on tray 2 by hand. Slot 3's best match is that same
+        tray, but it is taken, so slot 3 gets the other PETG instead of sharing."""
+        scheduler = _matching_scheduler(P2S_5_TRAYS)
+        item = _item(ams_mapping=json.dumps([2, -1, -1]))
+        db = AsyncMock()
+
+        with patch("backend.app.services.print_scheduler.printer_manager") as pm:
+            pm.get_status.return_value = MagicMock()
+            await scheduler._ensure_ams_mapping(db, 8, item)
+
+        assert json.loads(item.ams_mapping) == [2, -1, 0]
+
+    @pytest.mark.asyncio
+    async def test_still_missing_leaves_the_mapping_and_holds(self):
+        trays = [
+            {"global_tray_id": 0, "type": "PETG", "color": "76D9F4FF"},
+            {"global_tray_id": 1, "type": "ASA", "color": "161616FF"},
+        ]
+        scheduler = _matching_scheduler(trays)
+        item = _item(ams_mapping=json.dumps([0, -1, -1]))
+        db = AsyncMock()
+
+        with patch("backend.app.services.print_scheduler.printer_manager") as pm:
+            pm.get_status.return_value = MagicMock()
+            await scheduler._ensure_ams_mapping(db, 8, item)
+            blocked = await scheduler._block_on_unmatched_filament(db, item)
+
+        assert json.loads(item.ams_mapping) == [0, -1, -1]
+        assert blocked is True
+
+    @pytest.mark.asyncio
+    async def test_print_anyway_leaves_the_gap_alone(self):
+        scheduler = _matching_scheduler(P2S_5_TRAYS)
+        item = _item(ams_mapping=json.dumps([0, -1, -1]), skip_filament_check=True)
+        db = AsyncMock()
+
+        with patch("backend.app.services.print_scheduler.printer_manager") as pm:
+            pm.get_status.return_value = MagicMock()
+            await scheduler._ensure_ams_mapping(db, 8, item)
+
+        assert json.loads(item.ams_mapping) == [0, -1, -1]
+
+    @pytest.mark.asyncio
+    async def test_a_complete_mapping_is_not_matched_again(self):
+        scheduler = _matching_scheduler(P2S_5_TRAYS)
+        scheduler._compute_ams_mapping_for_printer = AsyncMock(return_value=[0, -1, 2])
+        item = _item(ams_mapping=json.dumps([0, -1, 2]))
+        db = AsyncMock()
+
+        with patch("backend.app.services.print_scheduler.printer_manager") as pm:
+            pm.get_status.return_value = MagicMock()
+            await scheduler._ensure_ams_mapping(db, 8, item)
+
+        scheduler._compute_ams_mapping_for_printer.assert_not_awaited()
+
+
+class TestMissingFilamentForStart:
+    """The Start button's question: would this item be held again right now?"""
+
+    @pytest.mark.asyncio
+    async def test_names_a_filament_that_is_still_not_loaded(self):
+        trays = [
+            {"global_tray_id": 0, "type": "PETG", "color": "76D9F4FF"},
+            {"global_tray_id": 1, "type": "ASA", "color": "161616FF"},
+        ]
+        scheduler = _matching_scheduler(trays)
+        item = _item(ams_mapping=json.dumps([0, -1, -1]))
+
+        with patch("backend.app.services.print_scheduler.printer_manager") as pm:
+            pm.get_status.return_value = MagicMock()
+            missing = await scheduler.missing_filament_for_start(AsyncMock(), item)
+
+        assert missing is not None and len(missing) == 1
+        assert "PETG" in missing[0] and "#2850E0" in missing[0]
+        # Asking is not acting: the stored mapping is the scheduler's to change.
+        assert json.loads(item.ams_mapping) == [0, -1, -1]
+
+    @pytest.mark.asyncio
+    async def test_nothing_missing_once_the_spool_is_loaded(self):
+        scheduler = _matching_scheduler(P2S_5_TRAYS)
+        item = _item(ams_mapping=json.dumps([0, -1, -1]))
+
+        with patch("backend.app.services.print_scheduler.printer_manager") as pm:
+            pm.get_status.return_value = MagicMock()
+            assert await scheduler.missing_filament_for_start(AsyncMock(), item) is None
+
+    @pytest.mark.asyncio
+    async def test_a_mapping_from_another_printer_is_judged_by_a_fresh_match(self):
+        """The #2799 mapping on P2S-5 conflicts, so Start judges a fresh match,
+        which finds both filaments."""
+        scheduler = _matching_scheduler(P2S_5_TRAYS)
+        item = _item()
+
+        with patch("backend.app.services.print_scheduler.printer_manager") as pm:
+            pm.get_status.return_value = MagicMock()
+            assert await scheduler.missing_filament_for_start(AsyncMock(), item) is None
+
+    @pytest.mark.asyncio
+    async def test_no_mapping_and_nothing_matching_is_missing(self):
+        trays = [{"global_tray_id": 0, "type": "ASA", "color": "161616FF"}]
+        scheduler = _matching_scheduler(trays)
+        item = _item(ams_mapping=None)
+
+        with patch("backend.app.services.print_scheduler.printer_manager") as pm:
+            pm.get_status.return_value = MagicMock()
+            missing = await scheduler.missing_filament_for_start(AsyncMock(), item)
+
+        assert missing is not None and len(missing) == 2
+
+    @pytest.mark.asyncio
+    @pytest.mark.parametrize(
+        "overrides",
+        [
+            {"skip_filament_check": True},
+            # A model-based item picks its printer at dispatch.
+            {"printer_id": None},
+        ],
+    )
+    async def test_does_not_judge_what_dispatch_would_not(self, overrides):
+        trays = [{"global_tray_id": 0, "type": "ASA", "color": "161616FF"}]
+        scheduler = _matching_scheduler(trays)
+        item = _item(ams_mapping=None, **overrides)
+
+        with patch("backend.app.services.print_scheduler.printer_manager") as pm:
+            pm.get_status.return_value = MagicMock()
+            assert await scheduler.missing_filament_for_start(AsyncMock(), item) is None
+
+    @pytest.mark.asyncio
+    async def test_no_status_is_not_a_finding(self):
+        scheduler = _matching_scheduler(P2S_5_TRAYS)
+        item = _item(ams_mapping=None)
+
+        with patch("backend.app.services.print_scheduler.printer_manager") as pm:
+            pm.get_status.return_value = None
+            assert await scheduler.missing_filament_for_start(AsyncMock(), item) is None

+ 3 - 1
backend/tests/unit/test_scheduler_busy_reasons_3018.py

@@ -117,7 +117,9 @@ async def _scheduler(ctx, *, idle: bool):
         patch(
             "backend.app.services.print_scheduler.printer_manager.get_status",
             MagicMock(
-                return_value=SimpleNamespace(state="IDLE" if idle else "RUNNING", subtask_id=None, gcode_file=None)
+                return_value=SimpleNamespace(
+                    state="IDLE" if idle else "RUNNING", subtask_id=None, gcode_file=None, raw_data={}
+                )
             ),
         ),
         patch(

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

@@ -2698,3 +2698,98 @@ describe('PrintModal — override survives "Any model" -> "Specific Printer" (#3
     ]);
   });
 });
+
+describe('PrintModal — a deliberate material substitution is acknowledged (#2799)', () => {
+  // The scheduler re-checks a stored mapping against the printer before
+  // dispatch, and a slot on a tray of another material is what a mapping meant
+  // for a different printer looks like. A user who picks such a tray by hand
+  // means it, so the dialog sends skip_filament_check, which the re-check
+  // honours.
+  const mockOnClose = vi.fn();
+  const PRINTERS = [
+    { id: 1, name: 'Printer 01', model: 'P2S', ip_address: '192.168.1.101', enabled: true, is_active: true },
+  ];
+
+  type Patched = { ams_mapping?: number[] | null; skip_filament_check?: boolean };
+  let patched: Patched[];
+
+  beforeEach(() => {
+    vi.clearAllMocks();
+    patched = [];
+    server.use(
+      http.get('/api/v1/printers/', () => HttpResponse.json(PRINTERS)),
+      http.get('/api/v1/archives/:id/plates', () => HttpResponse.json({ is_multi_plate: false, plates: [] })),
+      http.get('/api/v1/archives/:id/filament-requirements', () =>
+        HttpResponse.json({ filaments: [{ slot_id: 1, type: 'PLA', color: '#8B4513', tray_info_idx: '', used_grams: 50 }] }),
+      ),
+      // Tray 0 holds the PLA the plate asks for, tray 1 a PETG.
+      http.get('/api/v1/printers/:id/status', () =>
+        HttpResponse.json({
+          connected: true,
+          state: 'IDLE',
+          ams: [
+            {
+              id: 0,
+              tray: [
+                { id: 0, tray_type: 'PLA', tray_color: '8B4513FF' },
+                { id: 1, tray_type: 'PETG', tray_color: 'FFFFFFFF' },
+              ],
+            },
+          ],
+          vt_tray: [],
+        }),
+      ),
+      http.get('/api/v1/printers/:id/assignments', () => HttpResponse.json([])),
+      http.patch('/api/v1/queue/:id', async ({ request }) => {
+        patched.push((await request.json()) as Patched);
+        return HttpResponse.json({ id: 1, status: 'pending' });
+      }),
+    );
+  });
+
+  const pinnedItem = () => createMockQueueItem({ printer_id: 1, target_model: null } as Partial<PrintQueueItem>);
+
+  const pickTrayAndSave = async (option: number) => {
+    const user = userEvent.setup();
+    render(<PrintModal mode="edit-queue-item" archiveId={1} archiveName="Job" queueItem={pinnedItem()} onClose={mockOnClose} />);
+
+    await user.click(await screen.findByText(/filament mapping/i));
+    await user.click(await screen.findByRole('combobox', { name: /printer slot for/i }));
+    const listbox = await screen.findByRole('listbox');
+    await user.click(within(listbox).getAllByRole('option')[option]);
+    await user.click(document.querySelector('button[type="submit"]') as HTMLElement);
+
+    await waitFor(() => expect(patched).toHaveLength(1));
+    return patched[0];
+  };
+
+  it('sends skip_filament_check when a slot is put on a tray of another material', async () => {
+    // Option 0 is the empty '-- Select slot --'; 1 is the PLA tray, 2 the PETG.
+    const saved = await pickTrayAndSave(2);
+
+    expect(saved.ams_mapping).toEqual([1]);
+    expect(saved.skip_filament_check).toBe(true);
+  });
+
+  it('does not treat a stored mapping the dialog opened with as a pick made here', async () => {
+    // A job queued before the dispatch re-check existed can carry a mapping
+    // made for another printer. Saving an unrelated edit must not enshrine it.
+    const user = userEvent.setup();
+    const item = createMockQueueItem({ printer_id: 1, target_model: null, ams_mapping: [1] } as Partial<PrintQueueItem>);
+    render(<PrintModal mode="edit-queue-item" archiveId={1} archiveName="Job" queueItem={item} onClose={mockOnClose} />);
+
+    await waitFor(() => expect(screen.getByText(/filament mapping/i)).toBeInTheDocument());
+    await user.click(document.querySelector('button[type="submit"]') as HTMLElement);
+
+    await waitFor(() => expect(patched).toHaveLength(1));
+    expect(patched[0].ams_mapping).toEqual([1]);
+    expect(patched[0].skip_filament_check).toBeUndefined();
+  });
+
+  it('sends nothing extra when the picked tray holds the material asked for', async () => {
+    const saved = await pickTrayAndSave(1);
+
+    expect(saved.ams_mapping).toEqual([0]);
+    expect(saved.skip_filament_check).toBeUndefined();
+  });
+});

+ 158 - 0
frontend/src/__tests__/hooks/useMultiPrinterFilamentMapping.test.tsx

@@ -0,0 +1,158 @@
+/**
+ * Regression tests for the cross-printer AMS mapping bug (#2799).
+ *
+ * A mapping entry is a global tray ID, and those only mean something on the
+ * printer they were resolved against. The shared mapping panel only renders for
+ * a single selected printer, so its edits belong to that printer alone — but
+ * every selected printer used to receive them, and the match badge was computed
+ * from them too. Two failures fall out of that: a printer prints from whatever
+ * sits in the borrowed tray, and the panel reports a match it is not going to
+ * submit.
+ *
+ * The reproduction is the real incident: the file needs light-blue PETG in slot
+ * 1 and dark-blue PETG in slot 3. P2S-4 holds them in trays 2 and 1, P2S-5 in
+ * trays 0 and 2, with ASA in tray 1.
+ */
+
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { renderHook } from '@testing-library/react';
+import type { PrinterStatus } from '../../api/client';
+
+const statusByPrinter: Record<number, PrinterStatus> = {};
+
+vi.mock('@tanstack/react-query', () => ({
+  useQueries: ({ queries }: { queries: { queryKey: unknown[] }[] }) => queries.map((q) => ({
+    data: statusByPrinter[q.queryKey[1] as number],
+    isLoading: false,
+  })),
+}));
+
+vi.mock('../../api/client', () => ({ api: { getPrinterStatus: vi.fn() } }));
+
+const { useMultiPrinterFilamentMapping } = await import('../../hooks/useMultiPrinterFilamentMapping');
+
+const status = (trays: { type: string; color: string }[]): PrinterStatus =>
+  ({
+    ams: [{ id: 0, tray: trays.map((t, id) => ({ id, tray_type: t.type, tray_color: t.color })) }],
+  }) as unknown as PrinterStatus;
+
+// Same spools, different slot order.
+const P2S_4 = status([
+  { type: 'ASA', color: '161616FF' },
+  { type: 'PETG', color: '2850E0FF' },
+  { type: 'PETG', color: '76D9F4FF' },
+]);
+const P2S_5 = status([
+  { type: 'PETG', color: '76D9F4FF' },
+  { type: 'ASA', color: '161616FF' },
+  { type: 'PETG', color: '2850E0FF' },
+]);
+
+const FILAMENT_REQS = {
+  filaments: [
+    { slot_id: 1, type: 'PETG', color: '#76D9F4' },
+    { slot_id: 3, type: 'PETG', color: '#2850E0' },
+  ],
+} as never;
+
+const printers = [
+  { id: 9, name: 'P2S-4' },
+  { id: 10, name: 'P2S-5' },
+] as never;
+
+function render(
+  selected: number[],
+  defaultMappings: Record<number, number>,
+  defaultMappingsPrinterId: number | null,
+) {
+  statusByPrinter[9] = P2S_4;
+  statusByPrinter[10] = P2S_5;
+  return renderHook(() =>
+    useMultiPrinterFilamentMapping(
+      selected,
+      printers,
+      FILAMENT_REQS,
+      defaultMappings,
+      {},
+      vi.fn(),
+      false,
+      undefined,
+      defaultMappingsPrinterId,
+    ),
+  );
+}
+
+describe('useMultiPrinterFilamentMapping — per-printer mapping (#2799)', () => {
+  afterEach(() => {
+    statusByPrinter[9] = P2S_4;
+    statusByPrinter[10] = P2S_5;
+  });
+
+  it('maps each printer against its own trays when nothing was overridden', () => {
+    const { result } = render([9, 10], {}, null);
+
+    expect(result.current.getFinalMapping(9)).toEqual([2, -1, 1]);
+    expect(result.current.getFinalMapping(10)).toEqual([0, -1, 2]);
+  });
+
+  it('does not lend one printer\'s shared mapping to another', () => {
+    // Authored while only P2S-4 was selected: slot 1 -> tray 2, slot 3 -> tray 1.
+    const shared = { 1: 2, 3: 1 };
+    const { result } = render([9, 10], shared, 9);
+
+    // Correct on the printer it was authored against...
+    expect(result.current.getFinalMapping(9)).toEqual([2, -1, 1]);
+    // ...and NOT applied to the other, where tray 1 is ASA.
+    expect(result.current.getFinalMapping(10)).toEqual([0, -1, 2]);
+  });
+
+  it('keeps the shared mapping on the printer it was authored against', () => {
+    // A deliberate choice that auto-matching would not make on its own:
+    // slot 3 pinned to tray 0 (ASA) on P2S-4.
+    const { result } = render([9, 10], { 3: 0 }, 9);
+
+    expect(result.current.getFinalMapping(9)?.[2]).toBe(0);
+    // The other printer is unaffected and still matches on type+colour.
+    expect(result.current.getFinalMapping(10)).toEqual([0, -1, 2]);
+  });
+
+  it('reports a match badge derived from the mapping it will actually submit', () => {
+    // Before the fix the badge for P2S-5 was computed from P2S-4's tray IDs and
+    // claimed a full match while a different mapping went out.
+    const { result } = render([9, 10], { 1: 2, 3: 1 }, 9);
+
+    const p2s5 = result.current.printerResults.find((r) => r.printerId === 10)!;
+    expect(p2s5.matchStatus).toBe('full');
+    expect(p2s5.exactMatches).toBe(2);
+    // The badge's own input agrees with the submitted mapping.
+    expect(result.current.getFinalMapping(10)).toEqual([0, -1, 2]);
+  });
+
+  it('does not hand shared mappings to anyone when their owner is unknown', () => {
+    // Editing a model-based queue item seeds the shared mappings from its
+    // stored ams_mapping while its printer_id is null, so there is no owner.
+    // "Unknown" must not read as "every printer".
+    const { result } = render([9, 10], { 1: 2, 3: 1 }, null);
+
+    expect(result.current.getFinalMapping(9)).toEqual([2, -1, 1]);
+    expect(result.current.getFinalMapping(10)).toEqual([0, -1, 2]);
+  });
+
+  it('flags a printer that cannot satisfy a slot', () => {
+    // Restored in afterEach — the next test to be added here would otherwise
+    // inherit this two-tray P2S-5 and fail for a reason that is not its own.
+    statusByPrinter[10] = status([
+      { type: 'PETG', color: '76D9F4FF' },
+      { type: 'ASA', color: '161616FF' },
+    ]);
+    const { result } = renderHook(() =>
+      useMultiPrinterFilamentMapping([10], printers, FILAMENT_REQS, {}, {}, vi.fn(), false, undefined, null),
+    );
+
+    const p2s5 = result.current.printerResults[0];
+    expect(p2s5.missingTypes).toBe(1);
+    expect(p2s5.matchStatus).toBe('missing');
+    expect(result.current.allPrintersReady).toBe(false);
+    expect(result.current.getFinalMapping(10)?.[2]).toBe(-1);
+  });
+});

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

@@ -1598,6 +1598,53 @@ describe('QueuePage', () => {
     });
   });
 
+  describe('filament-not-loaded ▶ flow (#2799)', () => {
+    /**
+     * The scheduler held the item because a filament it prints has no tray on
+     * the printer. ▶ asks the backend first: still missing is a 409 with the
+     * missing filaments, and "Print Anyway" retries with skip_filament_check.
+     */
+    const heldItem = {
+      ...mockQueueItems[0],
+      manual_start: true,
+      waiting_reason: 'Needs PETG #2850E0',
+    };
+
+    it('shows the missing filament and retries with skip_filament_check', async () => {
+      let secondCallSkippedCheck: boolean | null = null;
+      let attempts = 0;
+      server.use(
+        http.get('/api/v1/queue/', () => HttpResponse.json([heldItem])),
+        http.post('/api/v1/queue/:id/start', ({ request }) => {
+          attempts += 1;
+          if (attempts === 1) {
+            return HttpResponse.json(
+              { detail: { code: 'unmatched_filament', missing: ['PETG #2850E0'] } },
+              { status: 409 },
+            );
+          }
+          secondCallSkippedCheck = new URL(request.url).searchParams.get('skip_filament_check') === 'true';
+          return HttpResponse.json({ ...heldItem, manual_start: false });
+        }),
+      );
+
+      render(<QueuePage />);
+
+      const playButton = await screen.findByTitle(/Start Print|do not have permission to start prints/i);
+      await userEvent.click(playButton);
+
+      await waitFor(() => expect(attempts).toBe(1));
+      await screen.findByText('Filament not loaded');
+      // The dialog's message names what to load; the row says it too.
+      expect(screen.getByText(/no tray with this filament[\s\S]*PETG #2850E0/)).toBeInTheDocument();
+
+      await userEvent.click(screen.getByRole('button', { name: /Print Anyway/i }));
+
+      await waitFor(() => expect(secondCallSkippedCheck).toBe(true));
+      expect(attempts).toBe(2);
+    });
+  });
+
   // #2667: mobile can't drag-reorder the queue, so pending rows get up/down
   // arrows. They persist via the same POST /queue/reorder as drag. The
   // buttons live in the DOM at every width (Tailwind `sm:hidden` is CSS-only),

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

@@ -2851,6 +2851,8 @@ export interface PrintBatchDispatchRequest {
 
 export interface PrintQueueItemUpdate {
   printer_id?: number | null;  // null = unassign
+  // A deliberate filament substitution in the mapping (#2799); see PrintModal.
+  skip_filament_check?: boolean;
   target_model?: string | null;  // Target printer model (mutually exclusive with printer_id)
   target_location?: string | null;  // Target location filter (only used with target_model)
   filament_overrides?: Array<{ slot_id: number; type: string; color: string; color_name?: string; tray_info_idx?: string; force_color_match?: boolean }> | null;

+ 90 - 8
frontend/src/components/PrintModal/index.tsx

@@ -21,7 +21,7 @@ import { isGcodeCompatible, isPrinterCurrentlyDispatchable } from '../../utils/p
 import { getCurrencySymbol } from '../../utils/currency';
 import { getBedTypeInfo } from '../../utils/bedType';
 import { toDateTimeLocalValue, parseUTCDate } from '../../utils/date';
-import { isPlaceholderDate, effectivePreferLowest } from '../../utils/amsHelpers';
+import { isPlaceholderDate, effectivePreferLowest, filamentTypesCompatible } from '../../utils/amsHelpers';
 import { resolveArchiveSlicerAmsMapping } from './archiveAmsMapping';
 import { FilamentMapping } from './FilamentMapping';
 import { FilamentOverride } from './FilamentOverride';
@@ -227,6 +227,14 @@ export function PrintModal({
     return {};
   });
 
+  // The printer `manualMappings` were resolved against (#2799). Its entries are
+  // global tray IDs, which mean nothing on any other machine, so they follow
+  // this one printer instead of every selected one. Seeded when editing an
+  // existing item, whose stored mapping belongs to the printer it is queued on.
+  const [sharedMappingPrinterId, setSharedMappingPrinterId] = useState<number | null>(() =>
+    mode === 'edit-queue-item' ? queueItem?.printer_id ?? null : null,
+  );
+
   // Per-printer override configs (for multi-printer selection)
   const [perPrinterConfigs, setPerPrinterConfigs] = useState<Record<number, PerPrinterConfig>>({});
 
@@ -720,6 +728,7 @@ export function PrintModal({
     setPerPrinterConfigs,
     settings?.prefer_lowest_filament,
     inventoryByTrayIdPerPrinter,
+    sharedMappingPrinterId,
   );
 
   // Auto-select first plate when plates load (single or multi-plate)
@@ -909,16 +918,77 @@ export function PrintModal({
       if (plateId === null || selectedPrinters.length !== 1) return undefined;
       return perPlateAmsMappings.get(plateId);
     }
-    // For multi-printer selection, check if this printer has an override
+    // For multi-printer selection every printer maps against its own AMS.
+    // A mapping is a list of global tray IDs, which only mean something on the
+    // printer they were resolved against, so `amsMapping` — computed against
+    // the first selected printer — cannot be reused on the rest: the slot index
+    // still resolves, so nothing looks wrong, and the job prints from whatever
+    // sits in that tray on the other machine (#2799).
+    //
+    // `getFinalMapping` is the single source of truth here: the hook decides
+    // per printer which overrides legitimately apply (its own, the shared ones
+    // if they were authored against it, otherwise none), and the match badge is
+    // derived from that same decision, so the panel cannot promise one mapping
+    // while another is submitted. Undefined while a printer's status loads —
+    // send none and let the scheduler map it at dispatch, as the multi-plate
+    // path above already does.
     if (selectedPrinters.length > 1) {
-      const printerConfig = perPrinterConfigs[printerId];
-      if (printerConfig && !printerConfig.useDefault) {
-        return multiPrinterMapping.getFinalMapping(printerId);
-      }
+      return multiPrinterMapping.getFinalMapping(printerId);
+    }
+    // The single-printer path says the same thing, explicitly. `amsMapping`
+    // folds in `manualMappings`, so it is this printer's mapping only while this
+    // printer is the one those overrides were authored against. In practice the
+    // effect above clears them whenever the selection changes, but that leaves
+    // the rule enforced in one place and merely implied in the other — a thin
+    // thing to rest on the next time that effect is edited.
+    if (sharedMappingPrinterId !== null && sharedMappingPrinterId !== printerId) {
+      return multiPrinterMapping.getFinalMapping(printerId);
     }
     return amsMapping;
   };
 
+  // Whether `mapping` sends a slot to a tray of another material (#2799). Only
+  // the user can have done that: neither this dialog's matcher nor the
+  // scheduler's maps across types. The scheduler re-checks every stored mapping
+  // against the printer before dispatch, and a slot on a tray of the wrong type
+  // is exactly what a mapping meant for another printer looks like, so it would
+  // replace the user's pick. skip_filament_check is the acknowledgement it
+  // leaves alone. Same type rule as the scheduler, and a tray or requirement
+  // with no type is not judged, as there.
+  //
+  // Editing seeds the picks from the stored mapping, which may itself be a
+  // mapping made for another printer, queued before this check existed. A slot
+  // still on the tray the dialog opened with is not a pick made here, so it is
+  // left to the scheduler to judge.
+  const openedWithTray = (printerId: number, plateId: number | null, slotId: number): number | undefined => {
+    if (mode !== 'edit-queue-item' || !Array.isArray(queueItem?.ams_mapping)) return undefined;
+    if (printerId !== queueItem.printer_id || plateId !== initialPlateId) return undefined;
+    return queueItem.ams_mapping[slotId - 1];
+  };
+  const mappingSubstitutesMaterial = (
+    printerId: number,
+    plateId: number | null,
+    mapping: number[] | undefined,
+  ): boolean => {
+    if (!mapping) return false;
+    const reqs = isMultiPlateSelection
+      ? plateId != null ? mappingPerPlateReqs.get(plateId)?.filaments : undefined
+      : mappingFilamentReqs?.filaments;
+    if (!reqs) return false;
+    const status = selectedPrinters.length > 1
+      ? multiPrinterMapping.printerResults.find((result) => result.printerId === printerId)?.status
+      : printerStatus;
+    const loaded = buildLoadedFilaments(status);
+    return reqs.some((req) => {
+      const slotId = req.slot_id ?? 0;
+      const tray = slotId > 0 ? mapping[slotId - 1] : undefined;
+      if (tray == null || tray < 0) return false;
+      if (openedWithTray(printerId, plateId, slotId) === tray) return false;
+      const filament = loaded.find((f) => f.globalTrayId === tray);
+      return !!filament?.type && !!req.type && !filamentTypesCompatible(filament.type, req.type);
+    });
+  };
+
   const handleSubmit = async (e?: React.FormEvent, options?: { skipFilamentCheck?: boolean }) => {
     e?.preventDefault();
 
@@ -1273,7 +1343,11 @@ export function PrintModal({
       // When the user clicks "Print Anyway" on the frontend deficit warning,
       // persist that acknowledgement so the scheduler doesn't immediately
       // re-flag the item on its first dispatch tick (#1698-followup).
-      skip_filament_check: options?.skipFilamentCheck === true ? true : undefined,
+      skip_filament_check:
+        options?.skipFilamentCheck === true ||
+        (printerId != null && mappingSubstitutesMaterial(printerId, plateId, getMappingForPrinter(printerId, plateId)))
+          ? true
+          : undefined,
       ams_mapping: printerId ? getMappingForPrinter(printerId, plateId) : undefined,
       // Rack positions per filament group (#1784). Only sent in printer mode:
       // in model mode the target printer is not known yet, and the rack it
@@ -1376,6 +1450,9 @@ export function PrintModal({
                 gcode_injection: scheduleOptions.gcodeInjection,
                 manual_start: scheduleOptions.scheduleType === 'queue' && scheduleOptions.requireManualStart,
                 ams_mapping: printerMapping,
+                // Only ever set here, never cleared: an earlier "Print Anyway"
+                // stays acknowledged across an edit, as it does today.
+                skip_filament_check: mappingSubstitutesMaterial(printerId, plateId, printerMapping) ? true : undefined,
                 // null, not undefined: an operator who cleared their picks
                 // means "assign these again", and undefined would leave the
                 // stale ones on the row (#1784).
@@ -1860,7 +1937,12 @@ export function PrintModal({
                 printerId={effectivePrinterId!}
                 filamentReqs={mappingFilamentReqs}
                 manualMappings={manualMappings}
-                onManualMappingChange={setManualMappings}
+                onManualMappingChange={(next) => {
+                  // This panel only renders for a single selected printer, so
+                  // its tray IDs belong to that printer alone (#2799).
+                  setSharedMappingPrinterId(effectivePrinterId!);
+                  setManualMappings(next);
+                }}
                 onEstimatedCostChange={setEstimatedCost}
                 budgetAvailable={billingEnabled ? selectedCostCenter?.budget_available ?? null : null}
                 quantity={effectiveQuantity}

+ 33 - 5
frontend/src/hooks/useMultiPrinterFilamentMapping.ts

@@ -302,6 +302,14 @@ export function useMultiPrinterFilamentMapping(
   setPerPrinterConfigs: React.Dispatch<React.SetStateAction<Record<number, PerPrinterConfig>>>,
   preferLowest?: boolean,
   inventoryByTrayIdPerPrinter?: Map<number, Map<number, number>>,
+  /**
+   * The printer `defaultMappings` were resolved against — the single selected
+   * printer whose shared mapping panel produced them. Tray IDs are
+   * printer-relative, so they are applied only to that printer (#2799).
+   * `null`/`undefined` means no manual editing has happened, and the record is
+   * then empty anyway.
+   */
+  defaultMappingsPrinterId?: number | null,
 ): UseMultiPrinterFilamentMappingResult {
   // Fetch printer status for all selected printers in parallel
   const statusQueries = useQueries({
@@ -332,12 +340,32 @@ export function useMultiPrinterFilamentMapping(
       // Compute auto mapping for this printer
       const autoMapping = computeAmsMapping(filamentReqs, printerStatus, printerPreferLowest, inventoryByTrayId);
 
-      // Determine which mappings to use:
-      // If printer has override (useDefault=false), use its custom mappings
-      // Otherwise use the default mappings
+      // Which manual overrides apply to THIS printer.
+      //
+      // A mapping entry is a global tray ID, and those only mean something on
+      // the printer they were resolved against. The shared panel is only shown
+      // for a single selected printer, so `defaultMappings` belongs to that one
+      // printer and must not leak onto the others once the selection grows —
+      // doing so is the bug in #2799. It is still applied to its own printer,
+      // where the user's edits were correct and dropping them would be a silent
+      // regression.
+      //
+      // Everything downstream — `finalMapping`, the match badge and
+      // `allPrintersReady` — is derived from this one value, so what the panel
+      // reports and what is submitted cannot drift apart.
+      // An explicit match, never a default. `null` means we do not know which
+      // printer the shared mappings were resolved against — which happens when
+      // editing a model-based queue item, whose `printer_id` is null while its
+      // stored `ams_mapping` seeds them. Treating "unknown" as "yours" would
+      // hand those tray IDs to every selected printer, which is the bug this
+      // whole change is about.
+      const ownsDefaultMappings =
+        defaultMappingsPrinterId != null && printerId === defaultMappingsPrinterId;
       const effectiveMappings = !config.useDefault
         ? config.manualMappings
-        : defaultMappings;
+        : ownsDefaultMappings
+          ? defaultMappings
+          : {};
 
       // Compute final mapping with overrides
       const finalMapping = computeMappingWithOverrides(filamentReqs, printerStatus, effectiveMappings, printerPreferLowest, inventoryByTrayId);
@@ -368,7 +396,7 @@ export function useMultiPrinterFilamentMapping(
         inventoryByTrayId,
       };
     });
-  }, [selectedPrinterIds, statusQueries, printers, filamentReqs, perPrinterConfigs, defaultMappings, preferLowest, inventoryByTrayIdPerPrinter]);
+  }, [selectedPrinterIds, statusQueries, printers, filamentReqs, perPrinterConfigs, defaultMappings, preferLowest, inventoryByTrayIdPerPrinter, defaultMappingsPrinterId]);
 
   const isLoading = statusQueries.some((q) => q.isLoading);
 

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

@@ -1339,6 +1339,10 @@ export default {
       unknown: 'unbekannt',
       printAnyway: 'Trotzdem drucken',
     },
+    filamentMissing: {
+      confirmTitle: 'Filament nicht geladen',
+      confirmIntro: 'Der Drucker hat kein Fach mit diesem Filament. Legen Sie es ein und drücken Sie erneut auf Start, oder drucken Sie trotzdem:',
+    },
     slicerAmsMapping: {
       rowBadge: 'AMS-Steckplätze für diesen Drucker gespeichert',
       rowTooltip: 'Dieses Archiv enthält die genauen AMS-Steckplätze, die der Slicer ausgewählt hat, gespeichert für den Drucker dieses Eintrags. Ein erneuter Druck darauf kann diese Fächer wiederverwenden, statt erneut nach Typ und Farbe zuzuordnen.',

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

@@ -1351,6 +1351,10 @@ export default {
       unknown: 'unknown',
       printAnyway: 'Print Anyway',
     },
+    filamentMissing: {
+      confirmTitle: 'Filament not loaded',
+      confirmIntro: 'The printer has no tray with this filament. Load it and press Start again, or print anyway:',
+    },
     slicerAmsMapping: {
       rowBadge: 'AMS slots saved for this printer',
       rowTooltip: 'This archive carries the exact AMS slots the slicer picked, saved for the printer this item targets. A reprint on it can reuse those trays instead of matching again by type and colour.',

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

@@ -1339,6 +1339,10 @@ export default {
       unknown: 'desconocido',
       printAnyway: 'Imprimir de todos modos',
     },
+    filamentMissing: {
+      confirmTitle: 'Filamento no cargado',
+      confirmIntro: 'La impresora no tiene ninguna bandeja con este filamento. Carguelo y pulse Iniciar de nuevo, o imprima de todos modos:',
+    },
     slicerAmsMapping: {
       rowBadge: 'Ranuras AMS guardadas para esta impresora',
       rowTooltip: 'Este archivo conserva las ranuras AMS exactas que eligió el slicer, guardadas para la impresora de este elemento. Una reimpresión en ella puede reutilizar esas bobinas en lugar de volver a emparejar por tipo y color.',

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

@@ -1339,6 +1339,10 @@ export default {
       unknown: 'inconnu',
       printAnyway: 'Imprimer quand meme',
     },
+    filamentMissing: {
+      confirmTitle: 'Filament non charge',
+      confirmIntro: 'L\'imprimante n\'a aucun emplacement avec ce filament. Chargez-le et appuyez de nouveau sur Demarrer, ou imprimez quand meme :',
+    },
     slicerAmsMapping: {
       rowBadge: 'Emplacements AMS enregistrés pour cette imprimante',
       rowTooltip: 'Cette archive conserve les emplacements AMS exacts choisis par le slicer, enregistrés pour l\'imprimante de cet élément. Une réimpression dessus peut réutiliser ces bobines au lieu de refaire la correspondance par type et couleur.',

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

@@ -1339,6 +1339,10 @@ export default {
       unknown: 'sconosciuto',
       printAnyway: 'Stampa comunque',
     },
+    filamentMissing: {
+      confirmTitle: 'Filamento non caricato',
+      confirmIntro: 'La stampante non ha nessuno slot con questo filamento. Caricalo e premi di nuovo Avvia, oppure stampa comunque:',
+    },
     slicerAmsMapping: {
       rowBadge: 'Slot AMS salvati per questa stampante',
       rowTooltip: 'Questo archivio conserva gli slot AMS esatti scelti dallo slicer, salvati per la stampante di questo elemento. Una ristampa su di essa può riutilizzare quelle bobine invece di rifare l\'abbinamento per tipo e colore.',

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

@@ -1338,6 +1338,10 @@ export default {
       unknown: '不明',
       printAnyway: 'それでも印刷',
     },
+    filamentMissing: {
+      confirmTitle: 'フィラメント未装填',
+      confirmIntro: 'このフィラメントが装填されたトレイがプリンターにありません。装填してからもう一度開始を押すか、それでも印刷してください:',
+    },
     slicerAmsMapping: {
       rowBadge: 'このプリンター用に保存されたAMSスロット',
       rowTooltip: 'このアーカイブには、スライサーが選択した正確なAMSスロットが、この項目の対象プリンター用に保存されています。そのプリンターでの再印刷では、タイプと色で照合し直す代わりにそれらのトレイを再利用できます。',

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

@@ -1586,6 +1586,10 @@ export default {
       unknown: '알 수 없음',
       printAnyway: '그냥 인쇄'
     },
+    filamentMissing: {
+      confirmTitle: '필라멘트 미장착',
+      confirmIntro: '프린터에 이 필라멘트가 장착된 트레이가 없습니다. 장착한 뒤 다시 시작을 누르거나, 그냥 인쇄하세요:',
+    },
     slicerAmsMapping: {
       rowBadge: '이 프린터용으로 저장된 AMS 슬롯',
       rowTooltip: '이 아카이브에는 슬라이서가 선택한 정확한 AMS 슬롯이 이 항목의 대상 프린터용으로 저장되어 있습니다. 해당 프린터에서 재인쇄하면 유형과 색상으로 다시 맞추는 대신 그 트레이를 재사용할 수 있습니다.',

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

@@ -1351,6 +1351,10 @@ export default {
       unknown: 'onbekend',
       printAnyway: 'Toch afdrukken',
     },
+    filamentMissing: {
+      confirmTitle: 'Filament niet geladen',
+      confirmIntro: 'De printer heeft geen sleuf met dit filament. Laad het en druk opnieuw op Start, of druk toch af:',
+    },
     slicerAmsMapping: {
       rowBadge: 'AMS-sleuven opgeslagen voor deze printer',
       rowTooltip: 'Dit archief bevat de exacte AMS-sleuven die de slicer heeft gekozen, opgeslagen voor de printer waarop dit item is gericht. Bij opnieuw afdrukken op deze printer kunnen die trays worden hergebruikt in plaats van opnieuw op type en kleur te matchen.',

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

@@ -1339,6 +1339,10 @@ export default {
       unknown: 'desconhecido',
       printAnyway: 'Imprimir mesmo assim',
     },
+    filamentMissing: {
+      confirmTitle: 'Filamento nao carregado',
+      confirmIntro: 'A impressora nao tem nenhuma bandeja com este filamento. Carregue-o e pressione Iniciar novamente, ou imprima mesmo assim:',
+    },
     slicerAmsMapping: {
       rowBadge: 'Slots AMS salvos para esta impressora',
       rowTooltip: 'Este arquivo mantém os slots AMS exatos escolhidos pelo fatiador, salvos para a impressora deste item. Uma reimpressão nela pode reutilizar esses carretéis em vez de casar novamente por tipo e cor.',

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

@@ -1290,6 +1290,10 @@ export default {
       unknown: "неизвестно",
       printAnyway: "Всё равно печатать",
     },
+    filamentMissing: {
+      confirmTitle: "Филамент не загружен",
+      confirmIntro: "На принтере нет лотка с этим филаментом. Загрузите его и снова нажмите «Старт» или всё равно запустите печать:",
+    },
     slicerAmsMapping: {
       rowBadge: "Ячейки AMS сохранены для этого принтера",
       rowTooltip: "У этого архива сохранены точные ячейки AMS, выбранные слайсером, — для принтера, на который нацелено это задание. Повторная печать на нём может использовать те же катушки вместо повторного подбора по типу и цвету.",

+ 4 - 0
frontend/src/i18n/locales/sv.ts

@@ -1351,6 +1351,10 @@ export default {
       unknown: 'okänd',
       printAnyway: 'Skriv ut ändå',
     },
+    filamentMissing: {
+      confirmTitle: 'Filament inte laddat',
+      confirmIntro: 'Skrivaren har inget fack med detta filament. Ladda det och tryck på Start igen, eller skriv ut ändå:',
+    },
     slicerAmsMapping: {
       rowBadge: 'AMS-fack sparade för denna skrivare',
       rowTooltip: 'Detta arkiv innehåller de exakta AMS-fack som slicer-programmet valde, sparade för den skrivare denna post riktar sig till. En omutskrift på den kan återanvända dessa brickor istället för att matcha igen efter typ och färg.',

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

@@ -1341,6 +1341,10 @@ export default {
       unknown: 'bilinmiyor',
       printAnyway: 'Yine de Yazdır',
     },
+    filamentMissing: {
+      confirmTitle: 'Filament yüklü değil',
+      confirmIntro: 'Yazıcıda bu filamente sahip bir yuva yok. Yükleyip yeniden Başlat\'a basın veya yine de yazdırın:',
+    },
     slicerAmsMapping: {
       rowBadge: 'Bu yazıcı için kaydedilen AMS yuvaları',
       rowTooltip: 'Bu arşiv, dilimleyicinin seçtiği tam AMS yuvalarını bu öğenin hedeflediği yazıcı için saklar. O yazıcıda yeniden yazdırma, tür ve renge göre yeniden eşleştirmek yerine bu makaraları yeniden kullanabilir.',

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

@@ -1350,6 +1350,10 @@ export default {
       unknown: "невідомо",
       printAnyway: "Усе одно друкувати",
     },
+    filamentMissing: {
+      confirmTitle: "Філамент не завантажено",
+      confirmIntro: "На принтері немає лотка з цим філаментом. Завантажте його й знову натисніть «Старт» або все одно запустіть друк:",
+    },
     slicerAmsMapping: {
       rowBadge: "Слоти AMS збережено для цього принтера",
       rowTooltip: "Цей запис містить точні слоти AMS, вибрані слайсером, збережені для принтера, на який націлено це завдання. Повторний друк на ньому може використати ті самі котушки замість повторного добору за типом і кольором.",

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

@@ -1339,6 +1339,10 @@ export default {
       unknown: '未知',
       printAnyway: '仍要打印',
     },
+    filamentMissing: {
+      confirmTitle: '耗材未装载',
+      confirmIntro: '打印机上没有装有此耗材的料槽。请装载后再次点击开始,或仍要打印:',
+    },
     slicerAmsMapping: {
       rowBadge: '已为此打印机保存 AMS 槽位',
       rowTooltip: '此存档保留了切片软件选择的确切 AMS 槽位,并为该项目的目标打印机保存。在该打印机上重新打印时,可复用这些料盘,而不必再按类型和颜色重新匹配。',

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

@@ -1339,6 +1339,10 @@ export default {
       unknown: '不明',
       printAnyway: '仍要列印',
     },
+    filamentMissing: {
+      confirmTitle: '線材未裝載',
+      confirmIntro: '印表機上沒有裝有此線材的料槽。請裝載後再次按下開始,或仍要列印:',
+    },
     slicerAmsMapping: {
       rowBadge: '已為此印表機儲存 AMS 槽位',
       rowTooltip: '此封存保留了切片軟體選擇的確切 AMS 槽位,並為此項目的目標印表機儲存。在該印表機上重新列印時,可重複使用這些料盤,而不必再依類型與顏色重新比對。',

+ 27 - 0
frontend/src/pages/QueuePage.tsx

@@ -1882,6 +1882,14 @@ export function QueuePage() {
     }>;
   } | null>(null);
 
+  // A filament the printer has no tray for at all (#2799): 409 with
+  // `code=unmatched_filament` and the missing filaments. Same "Print Anyway"
+  // path as the deficit above.
+  const [filamentMissingConfirm, setFilamentMissingConfirm] = useState<{
+    itemId: number;
+    missing: string[];
+  } | null>(null);
+
   const startMutation = useMutation({
     mutationFn: ({ id, skipFilamentCheck }: { id: number; skipFilamentCheck?: boolean }) =>
       api.startQueueItem(id, { skipFilamentCheck }),
@@ -1889,6 +1897,7 @@ export function QueuePage() {
       queryClient.invalidateQueries({ queryKey: ['queue'] });
       showToast(t('queue.toast.released'));
       setFilamentShortConfirm(null);
+      setFilamentMissingConfirm(null);
     },
     onError: (error: unknown, variables) => {
       if (error instanceof ApiError && error.status === 409 && error.code === 'insufficient_filament') {
@@ -1901,6 +1910,11 @@ export function QueuePage() {
         setFilamentShortConfirm({ itemId: variables.id, deficit: deficitRaw });
         return;
       }
+      if (error instanceof ApiError && error.status === 409 && error.code === 'unmatched_filament') {
+        const missing = (error.detail?.missing ?? []) as string[];
+        setFilamentMissingConfirm({ itemId: variables.id, missing });
+        return;
+      }
       showToast(t('queue.toast.startFailed'), 'error');
     },
   });
@@ -3142,6 +3156,19 @@ export function QueuePage() {
         />
       )}
 
+      {filamentMissingConfirm && (
+        <ConfirmModal
+          title={t('queue.filamentMissing.confirmTitle')}
+          message={t('queue.filamentMissing.confirmIntro') + '\n\n' + filamentMissingConfirm.missing.join('\n')}
+          confirmText={t('queue.filamentShort.printAnyway')}
+          variant="warning"
+          onConfirm={() => {
+            startMutation.mutate({ id: filamentMissingConfirm.itemId, skipFilamentCheck: true });
+          }}
+          onCancel={() => setFilamentMissingConfirm(null)}
+        />
+      )}
+
       {confirmAction && (
         <ConfirmModal
           title={

File diff suppressed because it is too large
+ 0 - 0
static/assets/ImagePreviewModal-DrwoIBiu.js


File diff suppressed because it is too large
+ 0 - 1
static/assets/PdfPreviewModal-9v8J8IlR.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/SpreadsheetPreviewModal-BSaNvS63.js


File diff suppressed because it is too large
+ 1 - 1
static/assets/index-7L3DC4So.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/pdf-CCztDciw.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-DEcweL3K.js"></script>
+    <script type="module" crossorigin src="/assets/index-7L3DC4So.js"></script>
     <link rel="modulepreload" crossorigin href="/assets/chunk-aKtaBQYM.js">
     <link rel="stylesheet" crossorigin href="/assets/index-MteGyJn2.css">
   </head>

Some files were not shown because too many files changed in this diff