Browse Source

List only online printers and loaded spools in the Slice dialog (#3172)

The person slicing is often away from the printer, and the useful filaments
are the ones already loaded. Two optional filters in the Slice dialog narrow
the printer profiles to the models online now and the filament profiles to
the spools loaded in them, and a Pick button fills a filament row and its
colour from a loaded spool.

A new route returns the connected printers and their slots in one call,
limited to the caller's printers. A spool is matched by the profile saved
for its slot, then that profile's copy for the selected printer, then its
brand text. Both filters are off by default, fall back to the full lists
when they would leave nothing, and never replace a profile the user chose.
maziggy 3 days ago
parent
commit
7f00816f7f
33 changed files with 2031 additions and 21 deletions
  1. 7 0
      CHANGELOG.md
  2. 121 2
      backend/app/api/routes/slicer_presets.py
  3. 55 0
      backend/app/schemas/slicer_presets.py
  4. 191 0
      backend/tests/integration/test_slicer_loaded_spools_3172.py
  5. 8 0
      frontend/src/__tests__/components/SliceModal.test.tsx
  6. 269 0
      frontend/src/__tests__/components/SliceModalLoadedSpools.test.tsx
  7. 219 0
      frontend/src/__tests__/utils/sliceLoadedSpools.test.ts
  8. 39 0
      frontend/src/api/client.ts
  9. 299 15
      frontend/src/components/SliceModal.tsx
  10. 231 0
      frontend/src/components/SliceSpoolPicker.tsx
  11. 21 0
      frontend/src/i18n/locales/de.ts
  12. 21 0
      frontend/src/i18n/locales/en.ts
  13. 21 0
      frontend/src/i18n/locales/es.ts
  14. 21 0
      frontend/src/i18n/locales/fr.ts
  15. 21 0
      frontend/src/i18n/locales/it.ts
  16. 21 0
      frontend/src/i18n/locales/ja.ts
  17. 21 0
      frontend/src/i18n/locales/ko.ts
  18. 21 0
      frontend/src/i18n/locales/nl.ts
  19. 21 0
      frontend/src/i18n/locales/pt-BR.ts
  20. 21 0
      frontend/src/i18n/locales/ru.ts
  21. 21 0
      frontend/src/i18n/locales/sv.ts
  22. 21 0
      frontend/src/i18n/locales/tr.ts
  23. 21 0
      frontend/src/i18n/locales/uk.ts
  24. 21 0
      frontend/src/i18n/locales/zh-CN.ts
  25. 21 0
      frontend/src/i18n/locales/zh-TW.ts
  26. 275 0
      frontend/src/utils/sliceLoadedSpools.ts
  27. 0 0
      static/assets/ImagePreviewModal-CbUXHxG4.js
  28. 0 1
      static/assets/PdfPreviewModal-D84crtKp.js
  29. 0 0
      static/assets/SpreadsheetPreviewModal-BC3ld9qF.js
  30. 0 0
      static/assets/index-Bb4TsUSQ.css
  31. 0 1
      static/assets/index-gzJw0vAb.js
  32. 0 0
      static/assets/pdf-C1rs8xMJ.js
  33. 2 2
      static/index.html

+ 7 - 0
CHANGELOG.md

@@ -5,6 +5,13 @@ All notable changes to Bambuddy will be documented in this file.
 ## [1.2.6b1] - Unreleased
 ## [1.2.6b1] - Unreleased
 
 
 ### Added
 ### Added
+- **The Slice dialog can list only the printers that are online and the spools loaded in them (#3172, requested by @wolfrage76)** — The person slicing is often not at the printer, and the useful filaments are the ones already loaded, because those are what a remote print can start on. The Slice dialog listed every printer profile and every filament profile, so you had to know or walk over to check what was in the AMS. Two checkboxes above the printer profile now narrow the lists. Both are off until you turn them on, and the browser remembers them.
+  - **Only printers that are online:** the printer profiles of the models connected right now, at every nozzle size. A profile Bambuddy picked for an offline model moves to an online one. One you picked yourself stays.
+  - **Only spools that are loaded:** each filament row lists the profiles of the spools in the online printers of the selected model, and its automatic pick is made from them. A profile you picked yourself stays.
+  - **Pick:** a new button on each filament row opens the loaded spools, one section per printer, laid out like the AMS units with empty slots shown. Clicking a spool sets the row's profile and colour. A spool with no profile for the selected printer is greyed out.
+  - **Matching:** a spool uses the profile set for its slot in Bambuddy, or that profile's copy for the selected printer. A spool set up from the slicer or the printer's screen uses its brand text, so Bambu "PLA Basic" finds **Bambu PLA Basic**. Profiles that state another material are never matched.
+  - **Never empty:** hidden profiles stay behind **Show all**, in a **Not online** or **Not loaded** group. With nothing online or nothing matching, the full lists are shown.
+  - **Access:** the checkboxes appear only for users who may see printer status, and list only the printers they have access to.
 - **Browse your Manyfold library in Bambuddy and import its files (#1471, requested by @hibikipr)** — [Manyfold](https://manyfold.app) is a self-hosted library for 3D models. The sidebar entry **MakerWorld** is now **Model Sources**, with a **MakerWorld** tab, which works as before, and a **Manyfold** tab.
 - **Browse your Manyfold library in Bambuddy and import its files (#1471, requested by @hibikipr)** — [Manyfold](https://manyfold.app) is a self-hosted library for 3D models. The sidebar entry **MakerWorld** is now **Model Sources**, with a **MakerWorld** tab, which works as before, and a **Manyfold** tab.
   - **Connecting:** in Manyfold, open Settings → API and create an application with the scopes `public` and `read`. Then enter Manyfold's URL and the application's client ID and secret in the Manyfold tab. **Test connection** shows how many models Bambuddy can see. Bambuddy sees the models the application's owner can see in Manyfold, and never writes to Manyfold.
   - **Connecting:** in Manyfold, open Settings → API and create an application with the scopes `public` and `read`. Then enter Manyfold's URL and the application's client ID and secret in the Manyfold tab. **Test connection** shows how many models Bambuddy can see. Bambuddy sees the models the application's owner can see in Manyfold, and never writes to Manyfold.
   - **Browsing:** search and page through the models with their previews, and open one to see its description, tags, licence and files.
   - **Browsing:** search and page through the models with their previews, and open one to see its description, tags, licence and files.

+ 121 - 2
backend/app/api/routes/slicer_presets.py

@@ -26,14 +26,22 @@ from backend.app.api.routes.orca_cloud import (
     _build_authenticated_service as _build_orca_service,
     _build_authenticated_service as _build_orca_service,
     _load_credentials as _load_orca_credentials,
     _load_credentials as _load_orca_credentials,
 )
 )
-from backend.app.core.auth import RequirePermissionIfAuthEnabled, require_ownership_permission
+from backend.app.core.auth import RequestPrinterScope, RequirePermissionIfAuthEnabled, require_ownership_permission
 from backend.app.core.config import settings as app_settings
 from backend.app.core.config import settings as app_settings
 from backend.app.core.database import get_db
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.core.permissions import Permission
+from backend.app.core.printer_scope import PrinterScope
 from backend.app.models.local_preset import LocalPreset
 from backend.app.models.local_preset import LocalPreset
+from backend.app.models.printer import Printer
+from backend.app.models.slot_preset import SlotPresetMapping
 from backend.app.models.user import User
 from backend.app.models.user import User
 from backend.app.schemas.slicer import PresetRef
 from backend.app.schemas.slicer import PresetRef
 from backend.app.schemas.slicer_presets import (
 from backend.app.schemas.slicer_presets import (
+    LoadedSpoolPreset,
+    LoadedSpoolPrinter,
+    LoadedSpoolsResponse,
+    LoadedSpoolTray,
+    LoadedSpoolUnit,
     UnifiedPreset,
     UnifiedPreset,
     UnifiedPresetsBySlot,
     UnifiedPresetsBySlot,
     UnifiedPresetsResponse,
     UnifiedPresetsResponse,
@@ -49,12 +57,17 @@ from backend.app.services.orca_cloud import (
     OrcaCloudError,
     OrcaCloudError,
 )
 )
 from backend.app.services.preset_resolver import resolve_preset_ref
 from backend.app.services.preset_resolver import resolve_preset_ref
+from backend.app.services.printer_manager import printer_manager
 from backend.app.services.slicer_api import (
 from backend.app.services.slicer_api import (
     SlicerApiError,
     SlicerApiError,
     SlicerApiService,
     SlicerApiService,
     SlicerApiUnavailableError,
     SlicerApiUnavailableError,
 )
 )
-from backend.app.utils.printer_models import PRINTER_MODEL_MAP
+from backend.app.utils.printer_models import (
+    PRINTER_MODEL_MAP,
+    normalize_printer_model,
+    normalize_printer_model_id,
+)
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
@@ -668,6 +681,112 @@ async def list_unified_presets(
     )
     )
 
 
 
 
+def _loaded_spool_tray(raw: dict, ams_id: int, tray_id: int, saved: SlotPresetMapping | None) -> LoadedSpoolTray:
+    exists = raw.get("exists")
+    state = raw.get("state")
+    return LoadedSpoolTray(
+        ams_id=ams_id,
+        tray_id=tray_id,
+        tray_type=raw.get("tray_type") or None,
+        tray_sub_brands=raw.get("tray_sub_brands") or None,
+        tray_color=raw.get("tray_color") or None,
+        tray_info_idx=raw.get("tray_info_idx") or None,
+        exists=exists if isinstance(exists, bool) else None,
+        state=state if isinstance(state, int) else None,
+        saved_preset=(
+            LoadedSpoolPreset(
+                preset_id=saved.preset_id,
+                preset_name=saved.preset_name,
+                preset_source=saved.preset_source,
+                tray_info_idx=saved.tray_info_idx,
+            )
+            if saved is not None
+            else None
+        ),
+    )
+
+
+@router.get("/loaded-spools", response_model=LoadedSpoolsResponse)
+async def list_loaded_spools(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.LIBRARY_UPLOAD, Permission.PRINTERS_READ),
+    printer_scope: PrinterScope = RequestPrinterScope,
+) -> LoadedSpoolsResponse:
+    """What is loaded in each connected printer, for the SliceModal's filters (#3172).
+
+    One call rather than a status request per printer: the dialog needs every
+    connected printer at once, and a farm has a hundred of them. Gated on the
+    slice permission plus printers:read, because it shows what the printer
+    status does, and limited to the caller's printers (#1727).
+
+    Only printers with a live connection are listed: an offline printer can't
+    say what it has loaded, and its last-known trays may be long gone.
+    """
+    query = select(Printer).where(Printer.is_active == True).order_by(Printer.name)  # noqa: E712
+    scope_clause = printer_scope.where_strict(Printer.id)
+    if scope_clause is not None:
+        query = query.where(scope_clause)
+    printers = (await db.execute(query)).scalars().all()
+
+    connected: list[tuple[Printer, dict]] = []
+    for printer in printers:
+        state = printer_manager.get_status(printer.id)
+        if state is None or not state.connected:
+            continue
+        connected.append((printer, state.raw_data or {}))
+    if not connected:
+        return LoadedSpoolsResponse()
+
+    mappings = (
+        (
+            await db.execute(
+                select(SlotPresetMapping).where(SlotPresetMapping.printer_id.in_([p.id for p, _ in connected]))
+            )
+        )
+        .scalars()
+        .all()
+    )
+    saved = {(m.printer_id, m.ams_id, m.tray_id): m for m in mappings}
+
+    out: list[LoadedSpoolPrinter] = []
+    for printer, raw_data in connected:
+        units: list[LoadedSpoolUnit] = []
+        raw_ams = raw_data.get("ams")
+        for ams_data in raw_ams if isinstance(raw_ams, list) else []:
+            if not isinstance(ams_data, dict):
+                continue
+            ams_id = int(ams_data.get("id", 0))
+            raw_trays = [t for t in ams_data.get("tray") or [] if isinstance(t, dict)]
+            trays = [
+                _loaded_spool_tray(t, ams_id, int(t.get("id", 0)), saved.get((printer.id, ams_id, int(t.get("id", 0)))))
+                for t in raw_trays
+            ]
+            # Same rule as the printer status: an AMS-HT reports a single tray.
+            units.append(LoadedSpoolUnit(id=ams_id, is_ams_ht=len(trays) == 1, trays=trays))
+
+        external: list[LoadedSpoolTray] = []
+        holders = [vt for vt in raw_data.get("vt_tray") or [] if isinstance(vt, dict)]
+        for vt in holders:
+            if not vt.get("tray_type"):
+                continue
+            # The holders are trays 254 / 255; slot presets key them as AMS 255,
+            # tray 0 / 1, the way the printer card saves them.
+            tray_id = int(vt.get("id", 254)) - 254
+            external.append(_loaded_spool_tray(vt, 255, tray_id, saved.get((printer.id, 255, tray_id))))
+
+        out.append(
+            LoadedSpoolPrinter(
+                id=printer.id,
+                name=printer.name,
+                model=normalize_printer_model_id(printer.model) or normalize_printer_model(printer.model),
+                ams=units,
+                external=external,
+                external_holders=len(holders),
+            )
+        )
+    return LoadedSpoolsResponse(printers=out)
+
+
 @router.get("/preview-progress/{request_id}")
 @router.get("/preview-progress/{request_id}")
 async def get_preview_slice_progress(
 async def get_preview_slice_progress(
     request_id: str,
     request_id: str,

+ 55 - 0
backend/app/schemas/slicer_presets.py

@@ -80,3 +80,58 @@ class UnifiedPresetsResponse(BaseModel):
     standard: UnifiedPresetsBySlot = UnifiedPresetsBySlot()
     standard: UnifiedPresetsBySlot = UnifiedPresetsBySlot()
     cloud_status: CloudStatus = "ok"
     cloud_status: CloudStatus = "ok"
     orca_cloud_status: CloudStatus = "ok"
     orca_cloud_status: CloudStatus = "ok"
+
+
+class LoadedSpoolPreset(BaseModel):
+    """The filament profile Bambuddy recorded for a slot (``slot_preset_mappings``).
+
+    ``tray_info_idx`` is the filament id the slot was configured with alongside
+    it. The SliceModal compares it with the tray's live id the same way the
+    slot card does, so a row that outlived a spool swap isn't trusted (#3216).
+    """
+
+    preset_id: str
+    preset_name: str
+    preset_source: str
+    tray_info_idx: str | None = None
+
+
+class LoadedSpoolTray(BaseModel):
+    """One AMS slot or external holder, as the printer reports it right now."""
+
+    ams_id: int
+    tray_id: int
+    tray_type: str | None = None
+    tray_sub_brands: str | None = None
+    tray_color: str | None = None
+    tray_info_idx: str | None = None
+    # Firmware's presence bit. A non-RFID spool it can't identify is present
+    # (True) but has no tray_type, which reads as "loaded, unconfigured".
+    exists: bool | None = None
+    state: int | None = None
+    saved_preset: LoadedSpoolPreset | None = None
+
+
+class LoadedSpoolUnit(BaseModel):
+    id: int
+    is_ams_ht: bool
+    trays: list[LoadedSpoolTray]
+
+
+class LoadedSpoolPrinter(BaseModel):
+    id: int
+    name: str
+    # Short model name ("X1C", "H2D") — the form the slicer's @BBL tags use.
+    model: str | None = None
+    ams: list[LoadedSpoolUnit] = []
+    # Only holders that currently have a spool in them.
+    external: list[LoadedSpoolTray] = []
+    # How many holders the printer has, loaded or not: two on a dual-nozzle
+    # printer, whose holders are labelled left and right.
+    external_holders: int = 0
+
+
+class LoadedSpoolsResponse(BaseModel):
+    """Connected printers and what is loaded in them, for the SliceModal (#3172)."""
+
+    printers: list[LoadedSpoolPrinter] = []

+ 191 - 0
backend/tests/integration/test_slicer_loaded_spools_3172.py

@@ -0,0 +1,191 @@
+"""GET /slicer/loaded-spools — what the Slice dialog may filter on (#3172).
+
+The dialog offers to narrow its lists to the printers that are online and the
+spools loaded in them. The endpoint behind it has to:
+
+* list only printers with a live connection, since an offline printer can't
+  say what it has loaded;
+* report every AMS slot as the printer does, empty ones included, with the
+  profile Bambuddy saved for it, and an external holder only while it holds
+  a spool;
+* keep to the caller's printers (#1727) and to callers who may read printer
+  status.
+"""
+
+from __future__ import annotations
+
+from types import SimpleNamespace
+from unittest.mock import patch
+
+import pytest
+from httpx import AsyncClient
+
+from backend.tests.integration.test_printer_scope_1727 import _admin_token, _auth, _group, _team_member, _user
+
+pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
+
+URL = "/api/v1/slicer/loaded-spools"
+
+
+def _status(raw_data: dict, connected: bool = True) -> SimpleNamespace:
+    return SimpleNamespace(connected=connected, raw_data=raw_data)
+
+
+def _ams(ams_id: int, trays: list[dict]) -> dict:
+    return {"id": ams_id, "tray": trays}
+
+
+def _tray(tray_id: int, tray_type: str = "", **kw) -> dict:
+    return {"id": tray_id, "tray_type": tray_type, **kw}
+
+
+def _patched_status(statuses: dict[int, SimpleNamespace | None]):
+    return patch(
+        "backend.app.api.routes.slicer_presets.printer_manager.get_status",
+        side_effect=lambda pid: statuses.get(pid),
+    )
+
+
+class TestLoadedSpools:
+    async def test_lists_only_connected_printers(self, async_client: AsyncClient, printer_factory):
+        online = await printer_factory(name="A online", model="BL-P001")
+        disconnected = await printer_factory(name="B disconnected")
+        never_seen = await printer_factory(name="C never seen")
+        inactive = await printer_factory(name="D inactive", is_active=False)
+
+        statuses = {
+            online.id: _status({"ams": []}),
+            disconnected.id: _status({"ams": [_ams(0, [_tray(0, "PLA")])]}, connected=False),
+            never_seen.id: None,
+            inactive.id: _status({"ams": []}),
+        }
+        with _patched_status(statuses):
+            response = await async_client.get(URL)
+
+        assert response.status_code == 200, response.text
+        printers = response.json()["printers"]
+        assert [p["id"] for p in printers] == [online.id]
+        # The SSDP model code is turned into the short name @BBL tags use.
+        assert printers[0]["model"] == "X1C"
+
+    async def test_no_connected_printer_is_an_empty_list(self, async_client: AsyncClient, printer_factory):
+        printer = await printer_factory()
+        with _patched_status({printer.id: None}):
+            response = await async_client.get(URL)
+        assert response.status_code == 200
+        assert response.json() == {"printers": []}
+
+    async def test_reports_slots_with_their_saved_profile(self, async_client: AsyncClient, printer_factory, db_session):
+        from backend.app.models.slot_preset import SlotPresetMapping
+
+        printer = await printer_factory(model="H2D")
+        db_session.add_all(
+            [
+                SlotPresetMapping(
+                    printer_id=printer.id,
+                    ams_id=0,
+                    tray_id=1,
+                    preset_id="local_7",
+                    preset_name="Overture PLA Matte",
+                    preset_source="local",
+                    tray_info_idx="GFL99",
+                ),
+                SlotPresetMapping(
+                    printer_id=printer.id,
+                    ams_id=255,
+                    tray_id=1,
+                    preset_id="GFSG99",
+                    preset_name="Generic PETG",
+                    preset_source="cloud",
+                ),
+            ]
+        )
+        await db_session.commit()
+
+        raw = {
+            "ams": [
+                _ams(
+                    0,
+                    [
+                        _tray(0, "PLA", tray_color="FF0000FF", tray_info_idx="GFA00", tray_sub_brands="PLA Basic"),
+                        _tray(1, "PLA", tray_color="00FF00FF", tray_info_idx="GFL99"),
+                        _tray(2, exists=True),
+                        _tray(3, state=9),
+                    ],
+                ),
+                _ams(128, [_tray(0, "PETG", tray_color="0000FFFF")]),
+            ],
+            "vt_tray": [_tray(254, ""), _tray(255, "PETG", tray_color="FFFFFFFF")],
+        }
+        with _patched_status({printer.id: _status(raw)}):
+            response = await async_client.get(URL)
+
+        assert response.status_code == 200, response.text
+        [entry] = response.json()["printers"]
+        regular, ht = entry["ams"]
+
+        assert regular["is_ams_ht"] is False
+        assert [t["tray_id"] for t in regular["trays"]] == [0, 1, 2, 3]
+        first, second, unknown, empty = regular["trays"]
+        assert first["tray_sub_brands"] == "PLA Basic"
+        assert first["saved_preset"] is None
+        assert second["saved_preset"] == {
+            "preset_id": "local_7",
+            "preset_name": "Overture PLA Matte",
+            "preset_source": "local",
+            "tray_info_idx": "GFL99",
+        }
+        # Empty slots stay in the list, so the grid matches the unit.
+        assert unknown["tray_type"] is None and unknown["exists"] is True
+        assert empty["tray_type"] is None and empty["state"] == 9
+
+        assert ht["is_ams_ht"] is True and ht["id"] == 128
+
+        # The empty left holder is left out; the right one is AMS 255, tray 1.
+        [ext] = entry["external"]
+        assert (ext["ams_id"], ext["tray_id"]) == (255, 1)
+        assert ext["saved_preset"]["preset_name"] == "Generic PETG"
+        assert entry["external_holders"] == 2
+
+    async def test_tolerates_malformed_status(self, async_client: AsyncClient, printer_factory):
+        printer = await printer_factory()
+        raw = {"ams": ["garbage", {"id": 1, "tray": [None, _tray(0, "ABS")]}], "vt_tray": None}
+        with _patched_status({printer.id: _status(raw)}):
+            response = await async_client.get(URL)
+        assert response.status_code == 200, response.text
+        [entry] = response.json()["printers"]
+        assert [u["id"] for u in entry["ams"]] == [1]
+        assert entry["external"] == []
+
+
+class TestLoadedSpoolsAccess:
+    async def test_limited_user_sees_only_their_printers(self, async_client: AsyncClient, printer_factory):
+        mine = await printer_factory(name="Mine")
+        other = await printer_factory(name="Other")
+        admin = await _admin_token(async_client)
+        jwt, _ = await _team_member(async_client, admin, "slicer3172", [mine.id])
+        # The team permissions don't include slicing; add it in a second group.
+        slicing = await _group(async_client, admin, "slicing3172", permissions=["library:upload"])
+        me = await async_client.get("/api/v1/auth/me", headers=_auth(jwt))
+        groups = [g["id"] for g in me.json()["groups"]] + [slicing]
+        updated = await async_client.patch(
+            f"/api/v1/users/{me.json()['id']}", headers=_auth(admin), json={"group_ids": groups}
+        )
+        assert updated.status_code == 200, updated.text
+
+        statuses = {mine.id: _status({"ams": []}), other.id: _status({"ams": []})}
+        with _patched_status(statuses):
+            response = await async_client.get(URL, headers=_auth(jwt))
+
+        assert response.status_code == 200, response.text
+        assert [p["id"] for p in response.json()["printers"]] == [mine.id]
+
+    async def test_needs_printer_read(self, async_client: AsyncClient, printer_factory):
+        """The slots are printer status; slicing alone doesn't grant it."""
+        await printer_factory()
+        admin = await _admin_token(async_client)
+        group = await _group(async_client, admin, "uploadonly3172", permissions=["library:upload"])
+        jwt, _ = await _user(async_client, admin, "uploadonly3172", [group])
+
+        response = await async_client.get(URL, headers=_auth(jwt))
+        assert response.status_code == 403

+ 8 - 0
frontend/src/__tests__/components/SliceModal.test.tsx

@@ -35,6 +35,7 @@ vi.mock('../../api/client', () => ({
     createSlicerPipeline: vi.fn(),
     createSlicerPipeline: vi.fn(),
     getSlicerPrinterModels: vi.fn(),
     getSlicerPrinterModels: vi.fn(),
     getSlicerPresetValues: vi.fn(),
     getSlicerPresetValues: vi.fn(),
+    getSlicerLoadedSpools: vi.fn(),
   },
   },
 }));
 }));
 
 
@@ -51,6 +52,7 @@ const mockApi = api as unknown as {
   createSlicerPipeline: ReturnType<typeof vi.fn>;
   createSlicerPipeline: ReturnType<typeof vi.fn>;
   getSlicerPrinterModels: ReturnType<typeof vi.fn>;
   getSlicerPrinterModels: ReturnType<typeof vi.fn>;
   getSlicerPresetValues: ReturnType<typeof vi.fn>;
   getSlicerPresetValues: ReturnType<typeof vi.fn>;
+  getSlicerLoadedSpools: ReturnType<typeof vi.fn>;
 };
 };
 
 
 function makeUnified(overrides: Partial<UnifiedPresetsResponse> = {}): UnifiedPresetsResponse {
 function makeUnified(overrides: Partial<UnifiedPresetsResponse> = {}): UnifiedPresetsResponse {
@@ -145,6 +147,8 @@ describe('SliceModal', () => {
     });
     });
     // Default: no saved pipelines. Tests opt in by overriding this.
     // Default: no saved pipelines. Tests opt in by overriding this.
     mockApi.listSlicerPipelines.mockResolvedValue({ pipelines: [] });
     mockApi.listSlicerPipelines.mockResolvedValue({ pipelines: [] });
+    // Default: no printer online (#3172), so the dialog's filters do nothing.
+    mockApi.getSlicerLoadedSpools.mockResolvedValue({ printers: [] });
   });
   });
 
 
   it('auto-selects the highest-priority tier per slot on first load', async () => {
   it('auto-selects the highest-priority tier per slot on first load', async () => {
@@ -2048,6 +2052,7 @@ describe('SliceModal — process settings in "slice as designed" mode', () => {
     mockApi.getSlicerPresets.mockResolvedValue(fullThreeTier);
     mockApi.getSlicerPresets.mockResolvedValue(fullThreeTier);
     mockApi.getSlicerPresetValues.mockResolvedValue({ resolved: true, values: {}, reason: 'ok' });
     mockApi.getSlicerPresetValues.mockResolvedValue({ resolved: true, values: {}, reason: 'ok' });
     mockApi.listSlicerPipelines.mockResolvedValue({ pipelines: [] });
     mockApi.listSlicerPipelines.mockResolvedValue({ pipelines: [] });
+    mockApi.getSlicerLoadedSpools.mockResolvedValue({ printers: [] });
     mockApi.getSlicerPrinterModels.mockResolvedValue({});
     mockApi.getSlicerPrinterModels.mockResolvedValue({});
     mockApi.getLibraryFilePlates.mockResolvedValue({
     mockApi.getLibraryFilePlates.mockResolvedValue({
       file_id: 100,
       file_id: 100,
@@ -2132,6 +2137,7 @@ describe('SliceModal — process settings layout', () => {
     mockApi.getSlicerPresets.mockResolvedValue(fullThreeTier);
     mockApi.getSlicerPresets.mockResolvedValue(fullThreeTier);
     mockApi.getSlicerPresetValues.mockResolvedValue({ resolved: true, values: {}, reason: 'ok' });
     mockApi.getSlicerPresetValues.mockResolvedValue({ resolved: true, values: {}, reason: 'ok' });
     mockApi.listSlicerPipelines.mockResolvedValue({ pipelines: [] });
     mockApi.listSlicerPipelines.mockResolvedValue({ pipelines: [] });
+    mockApi.getSlicerLoadedSpools.mockResolvedValue({ printers: [] });
     mockApi.getLibraryFilePlates.mockResolvedValue({
     mockApi.getLibraryFilePlates.mockResolvedValue({
       file_id: 100,
       file_id: 100,
       filename: 'Cube.stl',
       filename: 'Cube.stl',
@@ -2207,6 +2213,7 @@ describe('SliceModal — presets filtered by the selected printer', () => {
     mockApi.getSlicerPresets.mockResolvedValue(presets);
     mockApi.getSlicerPresets.mockResolvedValue(presets);
     mockApi.getSlicerPrinterModels.mockResolvedValue({ 'Bambu Lab X1 Carbon': 'X1C' });
     mockApi.getSlicerPrinterModels.mockResolvedValue({ 'Bambu Lab X1 Carbon': 'X1C' });
     mockApi.listSlicerPipelines.mockResolvedValue({ pipelines: [] });
     mockApi.listSlicerPipelines.mockResolvedValue({ pipelines: [] });
+    mockApi.getSlicerLoadedSpools.mockResolvedValue({ printers: [] });
     mockApi.getLibraryFilePlates.mockResolvedValue({
     mockApi.getLibraryFilePlates.mockResolvedValue({
       file_id: 100, filename: 'Cube.stl', plates: [], is_multi_plate: false,
       file_id: 100, filename: 'Cube.stl', plates: [], is_multi_plate: false,
     });
     });
@@ -2480,6 +2487,7 @@ describe('SliceModal — material and printer filtering (#2982)', () => {
       'Bambu Lab X1 Carbon': 'X1C',
       'Bambu Lab X1 Carbon': 'X1C',
     });
     });
     mockApi.listSlicerPipelines.mockResolvedValue({ pipelines: [] });
     mockApi.listSlicerPipelines.mockResolvedValue({ pipelines: [] });
+    mockApi.getSlicerLoadedSpools.mockResolvedValue({ printers: [] });
     mockApi.getLibraryFilePlates.mockResolvedValue({ file_id: 100, filename: 'Plate.3mf', plates: [] });
     mockApi.getLibraryFilePlates.mockResolvedValue({ file_id: 100, filename: 'Plate.3mf', plates: [] });
     mockApi.sliceLibraryFile.mockResolvedValue({
     mockApi.sliceLibraryFile.mockResolvedValue({
       job_id: 42,
       job_id: 42,

+ 269 - 0
frontend/src/__tests__/components/SliceModalLoadedSpools.test.tsx

@@ -0,0 +1,269 @@
+/**
+ * The Slice dialog's "online printers" / "loaded spools" filters and the
+ * Pick screen (#3172).
+ */
+
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { fireEvent, screen, waitFor, within } from '@testing-library/react';
+import { render } from '../utils';
+import { SliceModal } from '../../components/SliceModal';
+import { SliceJobTrackerProvider } from '../../contexts/SliceJobTrackerContext';
+import { api, type LoadedSpoolsResponse, type UnifiedPresetsResponse } from '../../api/client';
+
+vi.mock('../../api/client', () => ({
+  api: {
+    getSlicerPresets: vi.fn(),
+    sliceLibraryFile: vi.fn(),
+    sliceArchive: vi.fn(),
+    getSliceJob: vi.fn(),
+    getLibraryFilePlates: vi.fn(),
+    getArchivePlates: vi.fn(),
+    getLibraryFileFilamentRequirements: vi.fn(),
+    getArchiveFilamentRequirements: vi.fn(),
+    getSettings: vi.fn().mockResolvedValue({}),
+    updateSettings: vi.fn().mockResolvedValue({}),
+    listSlicerPipelines: vi.fn(),
+    createSlicerPipeline: vi.fn(),
+    getSlicerPrinterModels: vi.fn(),
+    getSlicerPresetValues: vi.fn(),
+    getSlicerLoadedSpools: vi.fn(),
+  },
+}));
+
+const mockApi = api as unknown as Record<string, ReturnType<typeof vi.fn>>;
+const storage = window.localStorage as unknown as {
+  getItem: ReturnType<typeof vi.fn>;
+  setItem: ReturnType<typeof vi.fn>;
+};
+
+const PRESETS: UnifiedPresetsResponse = {
+  orca_cloud: { printer: [], process: [], filament: [] },
+  cloud: { printer: [], process: [], filament: [] },
+  local: { printer: [], process: [], filament: [] },
+  standard: {
+    printer: [
+      { id: 'Bambu Lab H2D 0.4 nozzle', name: 'Bambu Lab H2D 0.4 nozzle', source: 'standard' },
+      { id: 'Bambu Lab X1 Carbon 0.4 nozzle', name: 'Bambu Lab X1 Carbon 0.4 nozzle', source: 'standard' },
+      { id: 'My farm printer', name: 'My farm printer', source: 'standard' },
+    ],
+    process: [{ id: '0.20mm Standard @BBL H2D', name: '0.20mm Standard @BBL H2D', source: 'standard' }],
+    filament: [
+      // Alphabetically first, so it is what the plain auto-pick lands on.
+      { id: 'Bambu PLA Basic @BBL H2D', name: 'Bambu PLA Basic @BBL H2D', source: 'standard', filament_type: 'PLA' },
+      { id: 'Bambu PLA Matte @BBL H2D', name: 'Bambu PLA Matte @BBL H2D', source: 'standard', filament_type: 'PLA' },
+      { id: 'Generic PETG @BBL H2D', name: 'Generic PETG @BBL H2D', source: 'standard', filament_type: 'PETG' },
+    ],
+  },
+  cloud_status: 'ok',
+  orca_cloud_status: 'ok',
+};
+
+const LOADED: LoadedSpoolsResponse = {
+  printers: [
+    {
+      id: 1,
+      name: 'Farm H2D',
+      model: 'H2D',
+      ams: [
+        {
+          id: 0,
+          is_ams_ht: false,
+          trays: [
+            {
+              ams_id: 0,
+              tray_id: 0,
+              tray_type: 'PLA',
+              tray_sub_brands: 'PLA Matte',
+              tray_color: '00FF00FF',
+              tray_info_idx: 'GFA01',
+              exists: true,
+              state: 11,
+              saved_preset: null,
+            },
+            {
+              ams_id: 0,
+              tray_id: 1,
+              tray_type: null,
+              tray_sub_brands: null,
+              tray_color: null,
+              tray_info_idx: null,
+              exists: false,
+              state: 9,
+              saved_preset: null,
+            },
+            {
+              ams_id: 0,
+              tray_id: 2,
+              tray_type: 'ABS',
+              tray_sub_brands: 'Mystery ABS',
+              tray_color: '000000FF',
+              tray_info_idx: 'P1234567',
+              exists: true,
+              state: 11,
+              saved_preset: null,
+            },
+          ],
+        },
+      ],
+      external: [],
+      external_holders: 2,
+    },
+  ],
+};
+
+function flags(values: Record<string, string>) {
+  storage.getItem.mockImplementation((key: string) => values[key] ?? null);
+}
+
+function renderModal() {
+  return render(
+    <SliceJobTrackerProvider>
+      <SliceModal source={{ kind: 'libraryFile', id: 100, filename: 'Cube.stl' }} onClose={vi.fn()} />
+    </SliceJobTrackerProvider>,
+  );
+}
+
+function selectFor(label: string): HTMLSelectElement {
+  return screen.getByLabelText(label) as HTMLSelectElement;
+}
+
+function optionLabels(select: HTMLSelectElement): string[] {
+  return Array.from(select.options).map((o) => o.textContent ?? '');
+}
+
+describe('SliceModal — online printers and loaded spools (#3172)', () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+    flags({});
+    mockApi.getSlicerPresets.mockResolvedValue(PRESETS);
+    mockApi.getSlicerPresetValues.mockResolvedValue({ resolved: true, values: {}, reason: 'ok' });
+    mockApi.getSlicerPrinterModels.mockResolvedValue({ 'Bambu Lab H2D': 'H2D', 'Bambu Lab X1 Carbon': 'X1C' });
+    mockApi.getLibraryFilePlates.mockResolvedValue({ file_id: 100, filename: 'Cube.stl', plates: [], is_multi_plate: false });
+    mockApi.getLibraryFileFilamentRequirements.mockResolvedValue({
+      file_id: 100,
+      filename: 'Cube.stl',
+      plate_id: 1,
+      filaments: [{ slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 1, used_meters: 1 }],
+    });
+    mockApi.listSlicerPipelines.mockResolvedValue({ pipelines: [] });
+    mockApi.getSlicerLoadedSpools.mockResolvedValue(LOADED);
+  });
+
+  afterEach(() => {
+    storage.getItem.mockReset();
+  });
+
+  it('offers nothing new to a caller who may not read printer status', async () => {
+    mockApi.getSlicerLoadedSpools.mockRejectedValue(new Error('403'));
+    renderModal();
+    await waitFor(() => expect(selectFor('Filament profile').value).not.toBe(''));
+    expect(screen.queryByLabelText(/Only printers that are online/)).toBeNull();
+    expect(screen.queryByRole('button', { name: /Pick/ })).toBeNull();
+  });
+
+  it('starts with both filters off and every profile listed', async () => {
+    renderModal();
+    const online = (await screen.findByLabelText(/Only printers that are online/)) as HTMLInputElement;
+    expect(online.checked).toBe(false);
+    expect((screen.getByLabelText(/Only spools that are loaded/) as HTMLInputElement).checked).toBe(false);
+    await waitFor(() => expect(selectFor('Filament profile').value).toBe('standard:Bambu PLA Basic @BBL H2D'));
+    expect(optionLabels(selectFor('Printer profile'))).toContain('Bambu Lab X1 Carbon 0.4 nozzle');
+  });
+
+  it('remembers a filter in the browser', async () => {
+    renderModal();
+    fireEvent.click(await screen.findByLabelText(/Only spools that are loaded/));
+    expect(storage.setItem).toHaveBeenCalledWith('bambuddy.slice.onlyLoadedSpools', 'true');
+  });
+
+  it('lists only the models that are online, keeping profiles it cannot place', async () => {
+    flags({ 'bambuddy.slice.onlyConnectedModels': 'true' });
+    renderModal();
+    await waitFor(() => expect(selectFor('Printer profile').value).toBe('standard:Bambu Lab H2D 0.4 nozzle'));
+    const labels = optionLabels(selectFor('Printer profile'));
+    expect(labels).toContain('Bambu Lab H2D 0.4 nozzle');
+    expect(labels).toContain('My farm printer');
+    expect(labels).not.toContain('Bambu Lab X1 Carbon 0.4 nozzle');
+
+    // "Show all" brings the offline model back, in a group of its own.
+    fireEvent.click(screen.getAllByRole('button', { name: 'Show all' })[0]);
+    const group = selectFor('Printer profile').querySelector('optgroup[label="Not online"]');
+    expect(group?.textContent).toContain('Bambu Lab X1 Carbon 0.4 nozzle');
+  });
+
+  it('moves an auto-picked printer to an online model, but not a chosen one', async () => {
+    flags({ 'bambuddy.slice.onlyConnectedModels': 'true' });
+    // X1C first, so it is what the plain auto-pick lands on; only an H2D is online.
+    const standard = PRESETS.standard;
+    mockApi.getSlicerPresets.mockResolvedValue({
+      ...PRESETS,
+      standard: {
+        ...standard,
+        printer: [
+          { id: 'Bambu Lab X1 Carbon 0.4 nozzle', name: 'Bambu Lab X1 Carbon 0.4 nozzle', source: 'standard' },
+          { id: 'Bambu Lab H2D 0.6 nozzle', name: 'Bambu Lab H2D 0.6 nozzle', source: 'standard' },
+          { id: 'Bambu Lab H2D 0.4 nozzle', name: 'Bambu Lab H2D 0.4 nozzle', source: 'standard' },
+        ],
+      },
+    });
+    renderModal();
+    await waitFor(() => expect(selectFor('Printer profile').value).toBe('standard:Bambu Lab H2D 0.4 nozzle'));
+
+    fireEvent.click(screen.getAllByRole('button', { name: 'Show all' })[0]);
+    fireEvent.change(selectFor('Printer profile'), { target: { value: 'standard:Bambu Lab X1 Carbon 0.4 nozzle' } });
+    await new Promise((r) => setTimeout(r, 50));
+    expect(selectFor('Printer profile').value).toBe('standard:Bambu Lab X1 Carbon 0.4 nozzle');
+  });
+
+  it('auto-picks from the loaded spools and holds the rest back', async () => {
+    flags({ 'bambuddy.slice.onlyLoadedSpools': 'true' });
+    renderModal();
+    // The plain auto-pick would take PLA Basic; the AMS holds PLA Matte.
+    await waitFor(() => expect(selectFor('Filament profile').value).toBe('standard:Bambu PLA Matte @BBL H2D'));
+    const labels = optionLabels(selectFor('Filament profile'));
+    expect(labels).not.toContain('Bambu PLA Basic @BBL H2D');
+    expect(labels).not.toContain('Generic PETG @BBL H2D');
+  });
+
+  it('never auto-picks a loaded spool of another material', async () => {
+    flags({ 'bambuddy.slice.onlyLoadedSpools': 'true' });
+    mockApi.getLibraryFileFilamentRequirements.mockResolvedValue({
+      file_id: 100,
+      filename: 'Cube.stl',
+      plate_id: 1,
+      filaments: [{ slot_id: 1, type: 'PETG', color: '#FF0000', used_grams: 1, used_meters: 1 }],
+    });
+    renderModal();
+    // Only PLA is loaded; the PETG plate keeps a PETG profile from the full list.
+    await waitFor(() => expect(selectFor('Filament profile').value).toBe('standard:Generic PETG @BBL H2D'));
+  });
+
+  it('keeps every profile when nothing is online', async () => {
+    flags({ 'bambuddy.slice.onlyLoadedSpools': 'true', 'bambuddy.slice.onlyConnectedModels': 'true' });
+    mockApi.getSlicerLoadedSpools.mockResolvedValue({ printers: [] });
+    renderModal();
+    expect(await screen.findByText('No printer is online, so every profile is shown.')).toBeDefined();
+    await waitFor(() => expect(selectFor('Filament profile').value).toBe('standard:Bambu PLA Basic @BBL H2D'));
+    expect(optionLabels(selectFor('Printer profile'))).toContain('Bambu Lab X1 Carbon 0.4 nozzle');
+    expect(screen.queryByRole('button', { name: /Pick/ })).toBeNull();
+  });
+
+  it('fills a row and its colour from a picked slot', async () => {
+    renderModal();
+    await waitFor(() => expect(selectFor('Filament profile').value).toBe('standard:Bambu PLA Basic @BBL H2D'));
+    fireEvent.click(screen.getByRole('button', { name: /Pick/ }));
+
+    const dialog = await screen.findByRole('dialog', { name: 'Loaded spools' });
+    expect(within(dialog).getByText('Farm H2D')).toBeDefined();
+    expect(within(dialog).getByText('Empty')).toBeDefined();
+    // A spool with no profile for this printer is shown but can't be picked.
+    const mystery = within(dialog).getByText('A3').closest('button') as HTMLButtonElement;
+    expect(mystery.disabled).toBe(true);
+
+    fireEvent.click(within(dialog).getByText('A1').closest('button') as HTMLButtonElement);
+
+    await waitFor(() => expect(screen.queryByRole('dialog', { name: 'Loaded spools' })).toBeNull());
+    expect(selectFor('Filament profile').value).toBe('standard:Bambu PLA Matte @BBL H2D');
+    expect((screen.getByLabelText('Filament colour') as HTMLInputElement).value).toBe('#00ff00');
+  });
+});

+ 219 - 0
frontend/src/__tests__/utils/sliceLoadedSpools.test.ts

@@ -0,0 +1,219 @@
+/**
+ * Matching loaded spools against the Slice dialog's profiles (#3172).
+ */
+
+import { describe, it, expect } from 'vitest';
+import type { LoadedSpoolPrinter, LoadedSpoolTray, UnifiedPresetsResponse } from '../../api/client';
+import {
+  buildFilamentNameIndex,
+  isConnectedModelPreset,
+  matchSlotPreset,
+  matchedFilamentRefs,
+  presetBaseName,
+  printerPresetModel,
+  printersOfModel,
+  trayColourHex,
+} from '../../utils/sliceLoadedSpools';
+import { buildCompatibilityIndex } from '../../utils/slicerPrinterMatch';
+
+const MODELS = {
+  'Bambu Lab X1 Carbon': 'X1C',
+  'Bambu Lab H2D': 'H2D',
+  'Bambu Lab A1 Mini': 'A1 Mini',
+  'Bambu Lab A1 mini': 'A1 Mini',
+};
+const compat = buildCompatibilityIndex(MODELS);
+const H2D = 'Bambu Lab H2D 0.4 nozzle';
+
+function presets(overrides: Partial<UnifiedPresetsResponse> = {}): UnifiedPresetsResponse {
+  return {
+    orca_cloud: { printer: [], process: [], filament: [] },
+    cloud: { printer: [], process: [], filament: [] },
+    local: { printer: [], process: [], filament: [] },
+    standard: { printer: [], process: [], filament: [] },
+    cloud_status: 'ok',
+    orca_cloud_status: 'ok',
+    ...overrides,
+  };
+}
+
+const standardFilaments = [
+  { id: 'Bambu PLA Basic @BBL X1C', name: 'Bambu PLA Basic @BBL X1C', source: 'standard' as const, filament_type: 'PLA' },
+  { id: 'Bambu PLA Basic @BBL H2D', name: 'Bambu PLA Basic @BBL H2D', source: 'standard' as const, filament_type: 'PLA' },
+  { id: 'Generic PETG @BBL H2D', name: 'Generic PETG @BBL H2D', source: 'standard' as const, filament_type: 'PETG' },
+  { id: 'Generic PLA @BBL H2D', name: 'Generic PLA @BBL H2D', source: 'standard' as const, filament_type: 'PLA' },
+];
+
+function tray(overrides: Partial<LoadedSpoolTray> = {}): LoadedSpoolTray {
+  return {
+    ams_id: 0,
+    tray_id: 0,
+    tray_type: 'PLA',
+    tray_sub_brands: null,
+    tray_color: 'FF0000FF',
+    tray_info_idx: null,
+    exists: true,
+    state: null,
+    saved_preset: null,
+    ...overrides,
+  };
+}
+
+function match(t: LoadedSpoolTray, data: UnifiedPresetsResponse, printerName: string | null = H2D) {
+  return matchSlotPreset(t, data, buildFilamentNameIndex(data), printerName, compat)?.ref ?? null;
+}
+
+describe('printerPresetModel', () => {
+  it('reads the model from a Bambu printer profile', () => {
+    expect(printerPresetModel('Bambu Lab X1 Carbon 0.4 nozzle', MODELS)).toBe('X1C');
+    expect(printerPresetModel('# Bambu Lab H2D 0.6 nozzle', MODELS)).toBe('H2D');
+    expect(printerPresetModel('Bambu Lab A1 mini 0.2 nozzle', MODELS)).toBe('A1 Mini');
+  });
+
+  it('keeps an unknown Bambu model as written and gives up on other names', () => {
+    expect(printerPresetModel('Bambu Lab Q9 0.4 nozzle', MODELS)).toBe('Q9');
+    expect(printerPresetModel('My farm printer', MODELS)).toBeNull();
+    expect(printerPresetModel(null, MODELS)).toBeNull();
+  });
+});
+
+describe('isConnectedModelPreset', () => {
+  it('keeps profiles of online models at every nozzle size and drops the rest', () => {
+    const online = ['H2D'];
+    expect(isConnectedModelPreset({ name: 'Bambu Lab H2D 0.4 nozzle' }, online, MODELS)).toBe(true);
+    expect(isConnectedModelPreset({ name: 'Bambu Lab H2D 0.8 nozzle' }, online, MODELS)).toBe(true);
+    expect(isConnectedModelPreset({ name: 'Bambu Lab X1 Carbon 0.4 nozzle' }, online, MODELS)).toBe(false);
+  });
+
+  it('never hides a profile whose model it cannot read', () => {
+    expect(isConnectedModelPreset({ name: 'My farm printer' }, ['H2D'], MODELS)).toBe(true);
+  });
+
+  it('understands the short A1 Mini code', () => {
+    expect(isConnectedModelPreset({ name: 'Bambu Lab A1 mini 0.4 nozzle' }, ['A1M'], MODELS)).toBe(true);
+  });
+});
+
+describe('presetBaseName', () => {
+  it('drops the clone prefix and the printer suffix', () => {
+    expect(presetBaseName('# Bambu PLA Basic @BBL X1C')).toBe('bambu pla basic');
+    expect(presetBaseName('SUNLU TPU @Bambu Lab H2D 0.4 nozzle')).toBe('sunlu tpu');
+    expect(presetBaseName('Overture  PLA')).toBe('overture pla');
+  });
+});
+
+describe('matchSlotPreset', () => {
+  it('takes the saved profile by id when it fits the printer', () => {
+    const data = presets({
+      local: { printer: [], process: [], filament: [{ id: '7', name: 'Overture PLA Matte', source: 'local' }] },
+    });
+    const t = tray({
+      saved_preset: { preset_id: 'local_7', preset_name: 'Overture PLA Matte', preset_source: 'local' },
+    });
+    expect(match(t, data)).toEqual({ source: 'local', id: '7' });
+  });
+
+  it("finds the selected printer's copy of a profile saved for another model", () => {
+    const data = presets({
+      cloud: {
+        printer: [],
+        process: [],
+        filament: [{ id: 'GFSA00', name: 'Bambu PLA Basic @BBL X1C', source: 'cloud', filament_type: 'PLA' }],
+      },
+      standard: { printer: [], process: [], filament: standardFilaments },
+    });
+    const t = tray({
+      tray_info_idx: 'GFA00',
+      saved_preset: { preset_id: 'GFSA00', preset_name: 'Bambu PLA Basic', preset_source: 'cloud' },
+    });
+    expect(match(t, data)).toEqual({ source: 'standard', id: 'Bambu PLA Basic @BBL H2D' });
+    // On an X1C the saved profile itself fits.
+    expect(match(t, data, 'Bambu Lab X1 Carbon 0.4 nozzle')).toEqual({ source: 'cloud', id: 'GFSA00' });
+  });
+
+  it("names a Bambu spool nobody configured in Bambuddy from its brand text", () => {
+    const data = presets({ standard: { printer: [], process: [], filament: standardFilaments } });
+    const t = tray({ tray_info_idx: 'GFA00', tray_sub_brands: 'PLA Basic' });
+    expect(match(t, data)).toEqual({ source: 'standard', id: 'Bambu PLA Basic @BBL H2D' });
+  });
+
+  it('does not put "Bambu" in front of a third-party spool', () => {
+    const data = presets({ standard: { printer: [], process: [], filament: standardFilaments } });
+    const t = tray({ tray_info_idx: 'P4d64437', tray_sub_brands: 'PLA Basic' });
+    expect(match(t, data)).toBeNull();
+  });
+
+  it('uses the generic profile for a spool with no brand text', () => {
+    const data = presets({ standard: { printer: [], process: [], filament: standardFilaments } });
+    expect(match(tray({ tray_type: 'PETG', tray_info_idx: 'GFG99' }), data)).toEqual({
+      source: 'standard',
+      id: 'Generic PETG @BBL H2D',
+    });
+  });
+
+  it('ignores a saved profile the slot has been reconfigured away from', () => {
+    const data = presets({
+      local: { printer: [], process: [], filament: [{ id: '7', name: 'Overture PLA Matte', source: 'local' }] },
+      standard: { printer: [], process: [], filament: standardFilaments },
+    });
+    const t = tray({
+      tray_info_idx: 'GFL99',
+      saved_preset: {
+        preset_id: 'local_7',
+        preset_name: 'Overture PLA Matte',
+        preset_source: 'local',
+        tray_info_idx: 'GFL05',
+      },
+    });
+    expect(match(t, data)).toEqual({ source: 'standard', id: 'Generic PLA @BBL H2D' });
+  });
+
+  it('skips a same-named profile that states another material', () => {
+    const data = presets({
+      standard: {
+        printer: [],
+        process: [],
+        filament: [{ id: 'Generic PLA @BBL H2D', name: 'Generic PLA @BBL H2D', source: 'standard', filament_type: 'PETG' }],
+      },
+    });
+    expect(match(tray({ tray_type: 'PLA' }), data)).toBeNull();
+  });
+
+  it('matches nothing for an empty or unidentified slot', () => {
+    const data = presets({ standard: { printer: [], process: [], filament: standardFilaments } });
+    expect(match(tray({ tray_type: null }), data)).toBeNull();
+  });
+});
+
+describe('printersOfModel and matchedFilamentRefs', () => {
+  const printers: LoadedSpoolPrinter[] = [
+    {
+      id: 1,
+      name: 'H2D one',
+      model: 'H2D',
+      ams: [{ id: 0, is_ams_ht: false, trays: [tray({ tray_info_idx: 'GFA00', tray_sub_brands: 'PLA Basic' }), tray({ tray_id: 1, tray_type: null })] }],
+      external: [tray({ ams_id: 255, tray_type: 'PETG', tray_info_idx: 'GFG99' })],
+      external_holders: 2,
+    },
+    { id: 2, name: 'X1C', model: 'X1C', ams: [], external: [], external_holders: 1 },
+  ];
+
+  it('narrows to the selected model, and keeps all when it is unknown', () => {
+    expect(printersOfModel(printers, 'H2D').map((p) => p.id)).toEqual([1]);
+    expect(printersOfModel(printers, null).map((p) => p.id)).toEqual([1, 2]);
+  });
+
+  it('collects the profiles of every loaded spool', () => {
+    const data = presets({ standard: { printer: [], process: [], filament: standardFilaments } });
+    const refs = matchedFilamentRefs(printers, data, buildFilamentNameIndex(data), H2D, compat);
+    expect([...refs].sort()).toEqual(['standard:Bambu PLA Basic @BBL H2D', 'standard:Generic PETG @BBL H2D']);
+  });
+});
+
+describe('trayColourHex', () => {
+  it('drops the alpha byte', () => {
+    expect(trayColourHex({ tray_color: 'ff8800FF' })).toBe('#FF8800');
+    expect(trayColourHex({ tray_color: null })).toBeNull();
+    expect(trayColourHex({ tray_color: 'zz' })).toBeNull();
+  });
+});

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

@@ -1987,6 +1987,43 @@ export interface UnifiedPresetsBySlot {
   process: UnifiedPreset[];
   process: UnifiedPreset[];
   filament: UnifiedPreset[];
   filament: UnifiedPreset[];
 }
 }
+// What is loaded in each connected printer, for the SliceModal's
+// "only connected printers" / "only loaded spools" filters (#3172).
+export interface LoadedSpoolPreset {
+  preset_id: string;
+  preset_name: string;
+  preset_source: string;
+  tray_info_idx?: string | null;
+}
+export interface LoadedSpoolTray {
+  ams_id: number;
+  tray_id: number;
+  tray_type: string | null;
+  tray_sub_brands: string | null;
+  tray_color: string | null;
+  tray_info_idx: string | null;
+  exists: boolean | null;
+  state: number | null;
+  saved_preset: LoadedSpoolPreset | null;
+}
+export interface LoadedSpoolUnit {
+  id: number;
+  is_ams_ht: boolean;
+  trays: LoadedSpoolTray[];
+}
+export interface LoadedSpoolPrinter {
+  id: number;
+  name: string;
+  model: string | null;
+  ams: LoadedSpoolUnit[];
+  // Holders with a spool in them; external_holders counts all of them (two
+  // on a dual-nozzle printer, labelled left and right).
+  external: LoadedSpoolTray[];
+  external_holders: number;
+}
+export interface LoadedSpoolsResponse {
+  printers: LoadedSpoolPrinter[];
+}
 export interface UnifiedPresetsResponse {
 export interface UnifiedPresetsResponse {
   // Priority order: local > orca_cloud > cloud > standard. No cross-tier
   // Priority order: local > orca_cloud > cloud > standard. No cross-tier
   // dedup — every tier surfaces its full list so the user can pick from
   // dedup — every tier surfaces its full list so the user can pick from
@@ -8275,6 +8312,8 @@ export const api = {
   // `@BBL <code>` suffix against the selected printer-preset name (#1325).
   // `@BBL <code>` suffix against the selected printer-preset name (#1325).
   getSlicerPrinterModels: () =>
   getSlicerPrinterModels: () =>
     request<Record<string, string>>('/slicer/printer-models'),
     request<Record<string, string>>('/slicer/printer-models'),
+  getSlicerLoadedSpools: () =>
+    request<LoadedSpoolsResponse>('/slicer/loaded-spools'),
 
 
   /**
   /**
    * Effective values of a process preset, with its `inherits:` chain flattened
    * Effective values of a process preset, with its `inherits:` chain flattened

+ 299 - 15
frontend/src/components/SliceModal.tsx

@@ -1,4 +1,4 @@
-import { Cloud, CloudOff, Cog, Loader2, RefreshCw, X } from 'lucide-react';
+import { Cloud, CloudOff, Cog, Grid2x2, Loader2, RefreshCw, X } from 'lucide-react';
 import { useEffect, useId, useMemo, useRef, useState } from 'react';
 import { useEffect, useId, useMemo, useRef, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
 import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
@@ -7,6 +7,7 @@ import {
   type PresetRef,
   type PresetRef,
   type PresetSource,
   type PresetSource,
   type SliceJobProgress,
   type SliceJobProgress,
+  type LoadedSpoolPrinter,
   type SliceRequest,
   type SliceRequest,
   type SlicerCloudStatus,
   type SlicerCloudStatus,
   type UnifiedPreset,
   type UnifiedPreset,
@@ -17,6 +18,7 @@ import { useSliceJobTracker } from '../contexts/SliceJobTrackerContext';
 import { useToast } from '../contexts/ToastContext';
 import { useToast } from '../contexts/ToastContext';
 import { useIsWideLayout } from '../hooks/useIsWideLayout';
 import { useIsWideLayout } from '../hooks/useIsWideLayout';
 import { PlatePickerModal } from './PlatePickerModal';
 import { PlatePickerModal } from './PlatePickerModal';
+import { SliceSpoolPicker } from './SliceSpoolPicker';
 import SlicerSettingsPanel, { type FilamentChoice } from './SlicerSettingsPanel';
 import SlicerSettingsPanel, { type FilamentChoice } from './SlicerSettingsPanel';
 import type { DesignOverride, PlateFilament } from '../types/plates';
 import type { DesignOverride, PlateFilament } from '../types/plates';
 import type { SettingValue } from '../types/slicerSettings';
 import type { SettingValue } from '../types/slicerSettings';
@@ -35,6 +37,37 @@ import {
   statesDifferentMaterial,
   statesDifferentMaterial,
   type Slot,
   type Slot,
 } from '../utils/slicePresetPicker';
 } from '../utils/slicePresetPicker';
+import {
+  buildFilamentNameIndex,
+  isConnectedModelPreset,
+  matchedFilamentRefs,
+  pickConnectedPrinterPreset,
+  printerPresetModel,
+  printersOfModel,
+} from '../utils/sliceLoadedSpools';
+
+// The Slice dialog's two filters (#3172), remembered per browser. Off unless
+// the user turned them on.
+const ONLY_CONNECTED_MODELS_KEY = 'bambuddy.slice.onlyConnectedModels';
+const ONLY_LOADED_SPOOLS_KEY = 'bambuddy.slice.onlyLoadedSpools';
+
+function readFlag(key: string): boolean {
+  try {
+    return localStorage.getItem(key) === 'true';
+  } catch {
+    return false;
+  }
+}
+
+function writeFlag(key: string, value: boolean): void {
+  try {
+    localStorage.setItem(key, String(value));
+  } catch {
+    // Storage blocked: the choice simply isn't remembered.
+  }
+}
+
+const NO_LOADED_PRINTERS: LoadedSpoolPrinter[] = [];
 
 
 export type SliceSource =
 export type SliceSource =
   | { kind: 'libraryFile'; id: number; filename: string }
   | { kind: 'libraryFile'; id: number; filename: string }
@@ -228,6 +261,9 @@ export function SliceModal({ source, onClose, defaultAutoArrange = false }: Slic
   // PETG on a plate a designer labelled PLA is a thing people do on purpose. A
   // PETG on a plate a designer labelled PLA is a thing people do on purpose. A
   // ref rather than state: this must not itself re-trigger the pre-pick.
   // ref rather than state: this must not itself re-trigger the pre-pick.
   const explicitFilamentSlots = useRef<Set<number>>(new Set());
   const explicitFilamentSlots = useRef<Set<number>>(new Set());
+  // Same for the printer: only an auto-pick is moved to an online model when
+  // "only online printers" is on (#3172).
+  const explicitPrinter = useRef(false);
   // Per-slot colour override, plate-slot-ordered alongside `filamentPresets`.
   // Per-slot colour override, plate-slot-ordered alongside `filamentPresets`.
   // `null` means "not overridden" rather than "no colour": the slot then falls
   // `null` means "not overridden" rather than "no colour": the slot then falls
   // through to the source plate's own colour, and — when the source has none
   // through to the source plate's own colour, and — when the source has none
@@ -455,6 +491,68 @@ export function SliceModal({ source, onClose, defaultAutoArrange = false }: Slic
     [printerModelsQuery.data],
     [printerModelsQuery.data],
   );
   );
 
 
+  // What the connected printers have loaded (#3172), for the two filters and
+  // the Pick screen. A caller who may not read printer status gets an error
+  // here, and the dialog then simply offers none of it.
+  const [onlyConnectedModels, setOnlyConnectedModels] = useState(() => readFlag(ONLY_CONNECTED_MODELS_KEY));
+  const [onlyLoadedSpools, setOnlyLoadedSpools] = useState(() => readFlag(ONLY_LOADED_SPOOLS_KEY));
+  const [pickerRow, setPickerRow] = useState<number | null>(null);
+  const loadedSpoolsQuery = useQuery({
+    queryKey: ['slicerLoadedSpools'],
+    queryFn: api.getSlicerLoadedSpools,
+    staleTime: 15_000,
+    retry: false,
+    enabled: !needsPlatePicker,
+  });
+  const loadedPrinters = loadedSpoolsQuery.data?.printers ?? NO_LOADED_PRINTERS;
+  const printerModels = useMemo(() => printerModelsQuery.data ?? {}, [printerModelsQuery.data]);
+  // Spools only count on printers of the selected profile's model — an A1's
+  // AMS says nothing about what an X1C job can start on. A profile whose
+  // model can't be read keeps every connected printer.
+  const spoolPrinters = useMemo(
+    () => printersOfModel(loadedPrinters, printerPresetModel(selectedPrinterName, printerModels)),
+    [loadedPrinters, selectedPrinterName, printerModels],
+  );
+  const filamentNameIndex = useMemo(
+    () => (presetsQuery.data ? buildFilamentNameIndex(presetsQuery.data) : new Map()),
+    [presetsQuery.data],
+  );
+  const loadedFilamentRefs = useMemo(
+    () =>
+      presetsQuery.data
+        ? matchedFilamentRefs(spoolPrinters, presetsQuery.data, filamentNameIndex, selectedPrinterName, compatIndex)
+        : new Set<string>(),
+    [spoolPrinters, presetsQuery.data, filamentNameIndex, selectedPrinterName, compatIndex],
+  );
+  // Each filter only applies while it has something to go on: with nothing
+  // online, or no loaded spool matching a profile, the full lists stay.
+  const printerFilter = useMemo(() => {
+    if (!onlyConnectedModels || loadedPrinters.length === 0) return null;
+    const models = loadedPrinters.map((p) => p.model);
+    return (p: UnifiedPreset) => isConnectedModelPreset(p, models, printerModels);
+  }, [onlyConnectedModels, loadedPrinters, printerModels]);
+  const filamentFilter = useMemo(() => {
+    if (!onlyLoadedSpools || loadedFilamentRefs.size === 0) return null;
+    return (p: UnifiedPreset) => loadedFilamentRefs.has(`${p.source}:${p.id}`);
+  }, [onlyLoadedSpools, loadedFilamentRefs]);
+  // The preset listing with only the loaded spools' filaments, for the
+  // auto-pick while that filter is on.
+  const loadedOnlyPresets = useMemo<UnifiedPresetsResponse | null>(() => {
+    const data = presetsQuery.data;
+    if (!data || !filamentFilter) return null;
+    const narrow = (by: UnifiedPresetsBySlot): UnifiedPresetsBySlot => ({
+      ...by,
+      filament: by.filament.filter(filamentFilter),
+    });
+    return {
+      ...data,
+      local: narrow(data.local),
+      orca_cloud: narrow(data.orca_cloud),
+      cloud: narrow(data.cloud),
+      standard: narrow(data.standard),
+    };
+  }, [presetsQuery.data, filamentFilter]);
+
   // The picked process preset's effective values, flattened by the sidecar.
   // The picked process preset's effective values, flattened by the sidecar.
   // Without this the settings panel shows OrcaSlicer's compiled-in defaults —
   // Without this the settings panel shows OrcaSlicer's compiled-in defaults —
   // a preset with a 0.42mm line width would read 0, which is the C++ default
   // a preset with a 0.42mm line width would read 0, which is the C++ default
@@ -558,6 +656,24 @@ export function SliceModal({ source, onClose, defaultAutoArrange = false }: Slic
     // eslint-disable-next-line react-hooks/exhaustive-deps
     // eslint-disable-next-line react-hooks/exhaustive-deps
   }, [presetsQuery.data, embeddedPrinter]);
   }, [presetsQuery.data, embeddedPrinter]);
 
 
+  // With "only online printers" on, an auto-picked printer of a model that
+  // isn't online moves to one that is — the file's own printer first, when
+  // that model is online. The status arrives after the presets, so this runs
+  // apart from the pre-pick above. A printer the user chose is left alone.
+  useEffect(() => {
+    const data = presetsQuery.data;
+    // Nor while the file's own settings are in use: that needs the file's printer.
+    if (!data || !printerFilter || explicitPrinter.current || !printerPreset || useEmbedded) return;
+    const current = findPreset(data, printerPreset, 'printer');
+    if (current && printerFilter(current)) return;
+    const embedded = findPresetByName(data, 'printer', embeddedPrinter);
+    const embeddedPreset = findPreset(data, embedded, 'printer');
+    const next = embeddedPreset && printerFilter(embeddedPreset)
+      ? embedded
+      : pickConnectedPrinterPreset(data, loadedPrinters.map((p) => p.model), printerModels);
+    if (next) setPrinterPreset(next);
+  }, [presetsQuery.data, printerFilter, printerPreset, embeddedPrinter, loadedPrinters, printerModels, useEmbedded]);
+
   // Process pre-pick / re-pick (#1325): defaults to a process compatible with
   // Process pre-pick / re-pick (#1325): defaults to a process compatible with
   // the selected printer, and re-defaults when a printer change leaves the
   // the selected printer, and re-defaults when a printer change leaves the
   // current process incompatible. A compatible or unknown manual pick is kept.
   // current process incompatible. A compatible or unknown manual pick is kept.
@@ -598,23 +714,35 @@ export function SliceModal({ source, onClose, defaultAutoArrange = false }: Slic
           const wrongMaterial = p
           const wrongMaterial = p
             && !explicitFilamentSlots.current.has(i)
             && !explicitFilamentSlots.current.has(i)
             && statesDifferentMaterial(p, slot.type);
             && statesDifferentMaterial(p, slot.type);
+          // With "only loaded spools" on, an auto-pick that isn't one of them
+          // is re-picked from them; a choice the user made stays (#3172).
+          const notLoaded = p
+            && filamentFilter !== null
+            && !explicitFilamentSlots.current.has(i)
+            && !filamentFilter(p);
           if (
           if (
             p
             p
             && !wrongMaterial
             && !wrongMaterial
+            && !notLoaded
             && presetCompatibility(p, 'filament', selectedPrinterName, compatIndex) !== 'mismatch'
             && presetCompatibility(p, 'filament', selectedPrinterName, compatIndex) !== 'mismatch'
           ) {
           ) {
             return cur;
             return cur;
           }
           }
         }
         }
-        return pickFilamentForSlot(
-          data,
-          { type: slot.type, color: slot.color },
-          selectedPrinterName,
-          compatIndex,
-        );
+        const required = { type: slot.type, color: slot.color };
+        // From the loaded spools first, but only one of the plate's material:
+        // the picker falls back to a wrong material when that is all it has,
+        // and a loaded PLA is no answer for a PETG slot (#2982).
+        const fromLoaded = loadedOnlyPresets
+          && pickFilamentForSlot(loadedOnlyPresets, required, selectedPrinterName, compatIndex);
+        const loadedPreset = findPreset(data, fromLoaded ?? null, 'filament');
+        if (fromLoaded && loadedPreset && !statesDifferentMaterial(loadedPreset, slot.type)) {
+          return fromLoaded;
+        }
+        return pickFilamentForSlot(data, required, selectedPrinterName, compatIndex);
       });
       });
     });
     });
-  }, [presetsQuery.data, filamentSlots, selectedPrinterName, compatIndex]);
+  }, [presetsQuery.data, filamentSlots, selectedPrinterName, compatIndex, filamentFilter, loadedOnlyPresets]);
 
 
   // Drop colour overrides when the slot count changes. A plate switch renumbers
   // Drop colour overrides when the slot count changes. A plate switch renumbers
   // the slots, so keeping index-keyed overrides would paint slot 2's colour
   // the slots, so keeping index-keyed overrides would paint slot 2's colour
@@ -840,6 +968,7 @@ export function SliceModal({ source, onClose, defaultAutoArrange = false }: Slic
                     // Apply slot state. The filament list is right-padded from
                     // Apply slot state. The filament list is right-padded from
                     // current state so a pipeline with fewer entries than the
                     // current state so a pipeline with fewer entries than the
                     // current source's slot count keeps the existing tail.
                     // current source's slot count keeps the existing tail.
+                    explicitPrinter.current = true;
                     setPrinterPreset(picked.printer_preset);
                     setPrinterPreset(picked.printer_preset);
                     setProcessPreset(picked.process_preset);
                     setProcessPreset(picked.process_preset);
                     setBedType(picked.bed_type);
                     setBedType(picked.bed_type);
@@ -942,12 +1071,69 @@ export function SliceModal({ source, onClose, defaultAutoArrange = false }: Slic
                   </div>
                   </div>
                 )}
                 )}
               </div>
               </div>
+              {/* #3172: narrow the lists to what is online and loaded. Shown
+                  only when the caller may read printer status at all. */}
+              {loadedSpoolsQuery.isSuccess && (
+                <div className="flex flex-col gap-2">
+                  <label className="flex items-start gap-2 text-sm text-bambu-gray cursor-pointer select-none">
+                    <input
+                      type="checkbox"
+                      checked={onlyConnectedModels}
+                      onChange={(e) => {
+                        setOnlyConnectedModels(e.target.checked);
+                        writeFlag(ONLY_CONNECTED_MODELS_KEY, e.target.checked);
+                      }}
+                      disabled={isEnqueuing}
+                      className="mt-0.5 cursor-pointer"
+                    />
+                    <span>
+                      {t('slice.filters.onlyConnectedModels', 'Only printers that are online')}
+                      <span className="block text-xs text-bambu-gray/70">
+                        {t('slice.filters.onlyConnectedModelsHint', 'Printer profiles for the models online right now, at every nozzle size.')}
+                      </span>
+                    </span>
+                  </label>
+                  <label className="flex items-start gap-2 text-sm text-bambu-gray cursor-pointer select-none">
+                    <input
+                      type="checkbox"
+                      checked={onlyLoadedSpools}
+                      onChange={(e) => {
+                        setOnlyLoadedSpools(e.target.checked);
+                        writeFlag(ONLY_LOADED_SPOOLS_KEY, e.target.checked);
+                      }}
+                      disabled={isEnqueuing}
+                      className="mt-0.5 cursor-pointer"
+                    />
+                    <span>
+                      {t('slice.filters.onlyLoadedSpools', 'Only spools that are loaded')}
+                      <span className="block text-xs text-bambu-gray/70">
+                        {t('slice.filters.onlyLoadedSpoolsHint', 'Filament profiles of the spools in the online printers of the selected model. A profile you pick yourself stays.')}
+                      </span>
+                    </span>
+                  </label>
+                  {(onlyConnectedModels || onlyLoadedSpools) && loadedPrinters.length === 0 && (
+                    <p className="text-xs text-bambu-gray/70" role="status">
+                      {t('slice.filters.noneOnline', 'No printer is online, so every profile is shown.')}
+                    </p>
+                  )}
+                  {onlyLoadedSpools && loadedPrinters.length > 0 && loadedFilamentRefs.size === 0 && (
+                    <p className="text-xs text-bambu-gray/70" role="status">
+                      {t('slice.filters.noneLoaded', 'No loaded spool has a profile for this printer, so every filament profile is shown.')}
+                    </p>
+                  )}
+                </div>
+              )}
               <PresetDropdown
               <PresetDropdown
                 label={t('slice.printer')}
                 label={t('slice.printer')}
                 slot="printer"
                 slot="printer"
                 data={presetsQuery.data}
                 data={presetsQuery.data}
                 value={printerPreset}
                 value={printerPreset}
-                onChange={setPrinterPreset}
+                onChange={(ref) => {
+                  explicitPrinter.current = true;
+                  setPrinterPreset(ref);
+                }}
+                allowed={printerFilter}
+                filteredLabel={t('slice.filters.notOnline', 'Not online')}
                 // Locked in embedded mode too: the picked printer is unused on
                 // Locked in embedded mode too: the picked printer is unused on
                 // the embedded-settings path, and changing it away from the
                 // the embedded-settings path, and changing it away from the
                 // design's target would drop canUseEmbedded and yank the toggle
                 // design's target would drop canUseEmbedded and yank the toggle
@@ -1096,6 +1282,9 @@ export function SliceModal({ source, onClose, defaultAutoArrange = false }: Slic
                       }
                       }
                       selectedPrinterName={selectedPrinterName}
                       selectedPrinterName={selectedPrinterName}
                       compatIndex={compatIndex}
                       compatIndex={compatIndex}
+                      allowed={filamentFilter}
+                      filteredLabel={t('slice.filters.notLoaded', 'Not loaded')}
+                      onPick={loadedPrinters.length > 0 ? () => setPickerRow(idx) : undefined}
                     />
                     />
                   );
                   );
                 })
                 })
@@ -1243,6 +1432,45 @@ export function SliceModal({ source, onClose, defaultAutoArrange = false }: Slic
           </button>
           </button>
         </div>
         </div>
       </div>
       </div>
+      {pickerRow !== null && presetsQuery.data && filamentSlots[pickerRow] && (
+        <div onClick={(e) => e.stopPropagation()}>
+          <SliceSpoolPicker
+            printers={spoolPrinters}
+            data={presetsQuery.data}
+            index={filamentNameIndex}
+            selectedPrinterName={selectedPrinterName}
+            compatIndex={compatIndex}
+            slotLabel={
+              filamentSlots.length > 1
+                ? t('slice.filamentSlot', { index: pickerRow + 1, type: filamentSlots[pickerRow].type })
+                : t('slice.filament')
+            }
+            onClose={() => setPickerRow(null)}
+            onPick={(match, colour) => {
+              const idx = pickerRow;
+              explicitFilamentSlots.current.add(idx);
+              setFilamentPresets((current) => {
+                const next = current.length === filamentSlots.length
+                  ? [...current]
+                  : filamentSlots.map((_, i) => current[i] ?? null);
+                next[idx] = match.ref;
+                return next;
+              });
+              // The spool's colour goes with it, so the preview matches it.
+              if (colour) {
+                setFilamentColours((current) => {
+                  const next = current.length === filamentSlots.length
+                    ? [...current]
+                    : filamentSlots.map((_, i) => current[i] ?? null);
+                  next[idx] = colour;
+                  return next;
+                });
+              }
+              setPickerRow(null);
+            }}
+          />
+        </div>
+      )}
     </div>
     </div>
   );
   );
 }
 }
@@ -1383,6 +1611,14 @@ interface PresetDropdownProps {
   // held back behind a "Show all" link instead of padding out the main list.
   // held back behind a "Show all" link instead of padding out the main list.
   selectedPrinterName?: string | null;
   selectedPrinterName?: string | null;
   compatIndex?: PrinterCompatibilityIndex;
   compatIndex?: PrinterCompatibilityIndex;
+  // The Slice dialog's own filters (#3172): presets for which this returns
+  // false are held back behind the same "Show all", in a group of their own
+  // named by `filteredLabel`. Applied after the printer filter above, and
+  // dropped when it would leave nothing to pick.
+  allowed?: ((preset: UnifiedPreset) => boolean) | null;
+  filteredLabel?: string;
+  // Opens the loaded-spools picker for this row.
+  onPick?: () => void;
 }
 }
 
 
 function PresetDropdown({
 function PresetDropdown({
@@ -1397,6 +1633,9 @@ function PresetDropdown({
   swatchColorLabel,
   swatchColorLabel,
   selectedPrinterName,
   selectedPrinterName,
   compatIndex,
   compatIndex,
+  allowed,
+  filteredLabel,
+  onPick,
 }: PresetDropdownProps) {
 }: PresetDropdownProps) {
   const { t } = useTranslation();
   const { t } = useTranslation();
   // Reveals the other-printer group for this slot only. Per-dropdown rather
   // Reveals the other-printer group for this slot only. Per-dropdown rather
@@ -1416,7 +1655,7 @@ function PresetDropdown({
   // resolve to a different printer (#1325). Compatibility-unknown presets
   // resolve to a different printer (#1325). Compatibility-unknown presets
   // stay in their tier, so a custom / untagged preset is never hidden, and
   // stay in their tier, so a custom / untagged preset is never hidden, and
   // empty sections collapse out.
   // empty sections collapse out.
-  const { sections, otherEntries } = useMemo(() => {
+  const { sections, otherEntries, filteredEntries } = useMemo(() => {
     const tiers: { key: keyof UnifiedPresetsResponse; label: string; fallback: string }[] = [
     const tiers: { key: keyof UnifiedPresetsResponse; label: string; fallback: string }[] = [
       { key: 'local', label: 'slice.tier.local', fallback: 'Imported' },
       { key: 'local', label: 'slice.tier.local', fallback: 'Imported' },
       { key: 'orca_cloud', label: 'slice.tier.orcaCloud', fallback: 'Orca Cloud' },
       { key: 'orca_cloud', label: 'slice.tier.orcaCloud', fallback: 'Orca Cloud' },
@@ -1465,10 +1704,28 @@ function PresetDropdown({
     // when the filter empties the list, show it unfiltered — a visible preset
     // when the filter empties the list, show it unfiltered — a visible preset
     // for the wrong printer is recoverable, an empty dropdown is not.
     // for the wrong printer is recoverable, an empty dropdown is not.
     if (compatSections.length === 0 && other.length > 0) {
     if (compatSections.length === 0 && other.length > 0) {
-      return { sections: unfiltered, otherEntries: [] };
+      return { sections: unfiltered, otherEntries: [], filteredEntries: [] };
     }
     }
-    return { sections: compatSections, otherEntries: other };
-  }, [data, slot, t, selectedPrinterName, compatIndex]);
+    // The dialog's own filters (#3172) narrow what the printer filter left.
+    // Leaving nothing is treated the same way as above: the filter is dropped
+    // rather than handing the user an empty dropdown.
+    if (allowed) {
+      const kept: { tierLabel: string; entries: UnifiedPreset[] }[] = [];
+      const filtered: UnifiedPreset[] = [];
+      for (const section of compatSections) {
+        const entries = section.entries.filter((p) => {
+          if (allowed(p)) return true;
+          filtered.push(p);
+          return false;
+        });
+        if (entries.length > 0) kept.push({ tierLabel: section.tierLabel, entries });
+      }
+      if (kept.length > 0) {
+        return { sections: kept, otherEntries: other, filteredEntries: filtered };
+      }
+    }
+    return { sections: compatSections, otherEntries: other, filteredEntries: [] as UnifiedPreset[] };
+  }, [data, slot, t, selectedPrinterName, compatIndex, allowed]);
 
 
   // Other-printer presets are held back by default so the list shows what is
   // Other-printer presets are held back by default so the list shows what is
   // usable on the selected printer. Two things are never hidden: a preset whose
   // usable on the selected printer. Two things are never hidden: a preset whose
@@ -1482,9 +1739,15 @@ function PresetDropdown({
     return otherEntries.filter((p) => `${p.source}:${p.id}` === selectedRefValue);
     return otherEntries.filter((p) => `${p.source}:${p.id}` === selectedRefValue);
   }, [showAll, otherEntries, selectedRefValue]);
   }, [showAll, otherEntries, selectedRefValue]);
 
 
-  const hiddenCount = otherEntries.length - visibleOther.length;
+  const visibleFiltered = useMemo(() => {
+    if (showAll) return filteredEntries;
+    return filteredEntries.filter((p) => `${p.source}:${p.id}` === selectedRefValue);
+  }, [showAll, filteredEntries, selectedRefValue]);
+
+  const hiddenCount =
+    otherEntries.length - visibleOther.length + filteredEntries.length - visibleFiltered.length;
   const totalEntries =
   const totalEntries =
-    sections.reduce((sum, s) => sum + s.entries.length, 0) + visibleOther.length;
+    sections.reduce((sum, s) => sum + s.entries.length, 0) + visibleOther.length + visibleFiltered.length;
 
 
   return (
   return (
     // A plain wrapper rather than a <label> around everything: the "Show all"
     // A plain wrapper rather than a <label> around everything: the "Show all"
@@ -1546,6 +1809,15 @@ function PresetDropdown({
             ))}
             ))}
           </optgroup>
           </optgroup>
         ))}
         ))}
+        {visibleFiltered.length > 0 && (
+          <optgroup label={filteredLabel ?? t('slice.otherPrinters')}>
+            {visibleFiltered.map((p) => (
+              <option key={`${p.source}:${p.id}`} value={`${p.source}:${p.id}`}>
+                {p.name}
+              </option>
+            ))}
+          </optgroup>
+        )}
         {visibleOther.length > 0 && (
         {visibleOther.length > 0 && (
           <optgroup label={t('slice.otherPrinters')}>
           <optgroup label={t('slice.otherPrinters')}>
             {visibleOther.map((p) => (
             {visibleOther.map((p) => (
@@ -1594,6 +1866,18 @@ function PresetDropdown({
           </span>
           </span>
         </label>
         </label>
       )}
       )}
+      {onPick && (
+        <button
+          type="button"
+          onClick={onPick}
+          disabled={disabled}
+          title={t('slice.loadedSpools.pickTitle', 'Pick a spool loaded in a connected printer')}
+          className="flex items-center gap-1.5 px-2.5 rounded-md bg-bambu-dark border border-bambu-dark-tertiary text-sm text-bambu-gray transition-colors enabled:hover:text-white enabled:hover:border-bambu-gray disabled:opacity-50 disabled:cursor-not-allowed"
+        >
+          <Grid2x2 className="w-4 h-4" aria-hidden />
+          {t('slice.loadedSpools.pick', 'Pick')}
+        </button>
+      )}
       </div>
       </div>
     </div>
     </div>
   );
   );

+ 231 - 0
frontend/src/components/SliceSpoolPicker.tsx

@@ -0,0 +1,231 @@
+import { useEffect, useMemo } from 'react';
+import { useTranslation } from 'react-i18next';
+import { X } from 'lucide-react';
+import type { LoadedSpoolPrinter, LoadedSpoolTray, UnifiedPresetsResponse } from '../api/client';
+import { FilamentSwatch } from './FilamentSwatch';
+import { formatSlotLabel, getAmsLabel, getEmptySlotKind } from '../utils/amsHelpers';
+import {
+  matchSlotPreset,
+  presetDisplayName,
+  savedPresetFor,
+  trayColourHex,
+  type FilamentNameIndex,
+  type SlotMatch,
+} from '../utils/sliceLoadedSpools';
+import type { PrinterCompatibilityIndex } from '../utils/slicerPrinterMatch';
+
+interface SliceSpoolPickerProps {
+  /** Connected printers to offer, already narrowed to the selected model. */
+  printers: LoadedSpoolPrinter[];
+  data: UnifiedPresetsResponse;
+  index: FilamentNameIndex;
+  selectedPrinterName: string | null;
+  compatIndex: PrinterCompatibilityIndex;
+  /** The filament row being filled, e.g. "Filament 2 (PLA)". */
+  slotLabel: string;
+  onPick: (match: SlotMatch, colour: string | null) => void;
+  onClose: () => void;
+}
+
+/**
+ * The spools loaded in the connected printers, laid out like the units
+ * themselves, for filling one filament row of the Slice dialog (#3172).
+ *
+ * Regular AMS units keep their four-slot grid, empty slots included, so the
+ * screen matches the machine. AMS-HT units follow, packed four to a row, then
+ * the external holders that have a spool in them. A loaded spool with no
+ * profile for the selected printer stays visible but can't be picked.
+ */
+export function SliceSpoolPicker({
+  printers,
+  data,
+  index,
+  selectedPrinterName,
+  compatIndex,
+  slotLabel,
+  onPick,
+  onClose,
+}: SliceSpoolPickerProps) {
+  const { t } = useTranslation();
+
+  useEffect(() => {
+    const onKey = (e: KeyboardEvent) => {
+      if (e.key === 'Escape') onClose();
+    };
+    window.addEventListener('keydown', onKey);
+    return () => window.removeEventListener('keydown', onKey);
+  }, [onClose]);
+
+  return (
+    <div
+      className="fixed inset-0 z-[60] flex items-center justify-center bg-black/60 p-4"
+      onClick={onClose}
+    >
+      <div
+        role="dialog"
+        aria-modal="true"
+        aria-labelledby="slice-spool-picker-title"
+        className="w-full max-w-3xl max-h-[85vh] flex flex-col rounded-lg bg-bambu-dark-secondary border border-bambu-dark-tertiary/60"
+        onClick={(e) => e.stopPropagation()}
+      >
+        <div className="flex-shrink-0 flex items-start justify-between gap-3 px-4 pt-4 pb-3 border-b border-bambu-dark-tertiary/40">
+          <div className="min-w-0">
+            <h3 id="slice-spool-picker-title" className="text-white font-medium">
+              {t('slice.loadedSpools.title', 'Loaded spools')}
+            </h3>
+            <p className="text-xs text-bambu-gray mt-1 truncate">{slotLabel}</p>
+          </div>
+          <button
+            type="button"
+            onClick={onClose}
+            className="flex-shrink-0 text-bambu-gray hover:text-white transition-colors"
+            aria-label={t('common.close')}
+          >
+            <X className="w-5 h-5" />
+          </button>
+        </div>
+        <div className="flex-1 overflow-y-auto p-4 space-y-5">
+          {printers.length === 0 ? (
+            <p className="text-sm text-bambu-gray">
+              {t('slice.loadedSpools.none', 'No printer of this model is online.')}
+            </p>
+          ) : (
+            printers.map((printer) => (
+              <PrinterSection
+                key={printer.id}
+                printer={printer}
+                matchFor={(tray) => matchSlotPreset(tray, data, index, selectedPrinterName, compatIndex)}
+                onPick={onPick}
+              />
+            ))
+          )}
+        </div>
+      </div>
+    </div>
+  );
+}
+
+function PrinterSection({
+  printer,
+  matchFor,
+  onPick,
+}: {
+  printer: LoadedSpoolPrinter;
+  matchFor: (tray: LoadedSpoolTray) => SlotMatch | null;
+  onPick: (match: SlotMatch, colour: string | null) => void;
+}) {
+  const { t } = useTranslation();
+  const regular = printer.ams.filter((u) => !u.is_ams_ht);
+  const ht = printer.ams.filter((u) => u.is_ams_ht);
+  const hasAny = printer.ams.length > 0 || printer.external.length > 0;
+
+  const tile = (tray: LoadedSpoolTray, label: string) => (
+    <TrayTile key={`${tray.ams_id}-${tray.tray_id}`} tray={tray} label={label} match={matchFor(tray)} onPick={onPick} />
+  );
+
+  return (
+    <section>
+      <h4 className="text-sm text-white font-medium mb-2">
+        {printer.name}
+        {printer.model && <span className="ml-2 text-xs text-bambu-gray font-normal">{printer.model}</span>}
+      </h4>
+      {!hasAny && (
+        <p className="text-xs text-bambu-gray">{t('slice.loadedSpools.nothingLoaded', 'No AMS and no external spool.')}</p>
+      )}
+      <div className="space-y-3">
+        {regular.map((unit) => (
+          <div key={unit.id}>
+            <div className="text-xs text-bambu-gray mb-1">{getAmsLabel(unit.id, unit.trays.length)}</div>
+            <div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
+              {unit.trays.map((tray) => tile(tray, formatSlotLabel(unit.id, tray.tray_id, false, false)))}
+            </div>
+          </div>
+        ))}
+        {ht.length > 0 && (
+          <div>
+            <div className="text-xs text-bambu-gray mb-1">AMS-HT</div>
+            <div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
+              {ht.flatMap((unit) => unit.trays.map((tray) => tile(tray, formatSlotLabel(unit.id, tray.tray_id, true, false))))}
+            </div>
+          </div>
+        )}
+        {printer.external.length > 0 && (
+          <div>
+            <div className="text-xs text-bambu-gray mb-1">{t('printers.external')}</div>
+            <div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
+              {printer.external.map((tray) =>
+                tile(
+                  tray,
+                  printer.external_holders > 1
+                    ? (tray.tray_id === 0 ? t('printers.extL') : t('printers.extR'))
+                    : t('printers.ext'),
+                ),
+              )}
+            </div>
+          </div>
+        )}
+      </div>
+    </section>
+  );
+}
+
+function TrayTile({
+  tray,
+  label,
+  match,
+  onPick,
+}: {
+  tray: LoadedSpoolTray;
+  label: string;
+  match: SlotMatch | null;
+  onPick: (match: SlotMatch, colour: string | null) => void;
+}) {
+  const { t } = useTranslation();
+  const emptyKind = getEmptySlotKind(tray);
+  const colour = trayColourHex(tray);
+
+  const profileName = useMemo(() => {
+    if (match) return presetDisplayName(match.preset.name);
+    const saved = savedPresetFor(tray);
+    if (saved?.preset_name) return presetDisplayName(saved.preset_name);
+    return tray.tray_sub_brands || null;
+  }, [match, tray]);
+
+  if (emptyKind !== null) {
+    return (
+      <div className="rounded-md border border-dashed border-bambu-dark-tertiary px-2 py-2 text-xs text-bambu-gray/70">
+        <div className="font-medium text-bambu-gray">{label}</div>
+        <div>
+          {emptyKind === 'reset'
+            ? t('slice.loadedSpools.unknownSpool', 'Unidentified spool')
+            : t('slice.loadedSpools.empty', 'Empty')}
+        </div>
+      </div>
+    );
+  }
+
+  const disabled = match === null;
+  return (
+    <button
+      type="button"
+      disabled={disabled}
+      onClick={() => match && onPick(match, colour)}
+      title={disabled ? t('slice.loadedSpools.noProfile', 'No profile for the selected printer') : profileName ?? undefined}
+      className="text-left rounded-md border border-bambu-dark-tertiary bg-bambu-dark px-2 py-2 text-xs transition-colors enabled:hover:border-bambu-green disabled:opacity-50 disabled:cursor-not-allowed"
+    >
+      <div className="flex items-center gap-1.5">
+        <FilamentSwatch rgba={tray.tray_color} className="w-3.5 h-3.5 shrink-0" effectSize="table" />
+        <span className="font-medium text-white">{label}</span>
+        <span className="ml-auto text-bambu-gray">{tray.tray_type}</span>
+      </div>
+      <div className="mt-1 truncate text-bambu-gray" title={profileName ?? undefined}>
+        {profileName ?? '—'}
+      </div>
+      {disabled && (
+        <div className="mt-0.5 truncate text-[11px] text-amber-600 dark:text-amber-400">
+          {t('slice.loadedSpools.noProfileShort', 'No profile for this printer')}
+        </div>
+      )}
+    </button>
+  );
+}

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

@@ -4790,6 +4790,27 @@ export default {
       smoothPEI: 'Smooth PEI Plate',
       smoothPEI: 'Smooth PEI Plate',
     },
     },
     // Slicer Pipelines (#1425) — apply a saved bundle or save the current pick.
     // Slicer Pipelines (#1425) — apply a saved bundle or save the current pick.
+    filters: {
+      onlyConnectedModels: 'Nur Drucker, die online sind',
+      onlyConnectedModelsHint: 'Druckerprofile für die gerade verbundenen Modelle, in jeder Düsengröße.',
+      onlyLoadedSpools: 'Nur eingelegte Spulen',
+      onlyLoadedSpoolsHint: 'Filamentprofile der Spulen in den verbundenen Druckern des gewählten Modells. Ein selbst gewähltes Profil bleibt erhalten.',
+      noneOnline: 'Kein Drucker ist online, daher werden alle Profile angezeigt.',
+      noneLoaded: 'Keine eingelegte Spule hat ein Profil für diesen Drucker, daher werden alle Filamentprofile angezeigt.',
+      notOnline: 'Nicht online',
+      notLoaded: 'Nicht eingelegt',
+    },
+    loadedSpools: {
+      title: 'Eingelegte Spulen',
+      pick: 'Wählen',
+      pickTitle: 'Eine Spule aus einem verbundenen Drucker wählen',
+      none: 'Kein Drucker dieses Modells ist online.',
+      nothingLoaded: 'Kein AMS und keine externe Spule.',
+      unknownSpool: 'Unbekannte Spule',
+      empty: 'Leer',
+      noProfile: 'Kein Profil für den gewählten Drucker',
+      noProfileShort: 'Kein Profil für diesen Drucker',
+    },
     pipelines: {
     pipelines: {
       label: 'Pipeline',
       label: 'Pipeline',
       applyAria: 'Pipeline anwenden',
       applyAria: 'Pipeline anwenden',

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

@@ -4827,6 +4827,27 @@ export default {
       smoothPEI: 'Smooth PEI Plate',
       smoothPEI: 'Smooth PEI Plate',
     },
     },
     // Slicer Pipelines (#1425) — apply a saved bundle or save the current pick.
     // Slicer Pipelines (#1425) — apply a saved bundle or save the current pick.
+    filters: {
+      onlyConnectedModels: 'Only printers that are online',
+      onlyConnectedModelsHint: 'Printer profiles for the models online right now, at every nozzle size.',
+      onlyLoadedSpools: 'Only spools that are loaded',
+      onlyLoadedSpoolsHint: 'Filament profiles of the spools in the online printers of the selected model. A profile you pick yourself stays.',
+      noneOnline: 'No printer is online, so every profile is shown.',
+      noneLoaded: 'No loaded spool has a profile for this printer, so every filament profile is shown.',
+      notOnline: 'Not online',
+      notLoaded: 'Not loaded',
+    },
+    loadedSpools: {
+      title: 'Loaded spools',
+      pick: 'Pick',
+      pickTitle: 'Pick a spool loaded in a connected printer',
+      none: 'No printer of this model is online.',
+      nothingLoaded: 'No AMS and no external spool.',
+      unknownSpool: 'Unidentified spool',
+      empty: 'Empty',
+      noProfile: 'No profile for the selected printer',
+      noProfileShort: 'No profile for this printer',
+    },
     pipelines: {
     pipelines: {
       label: 'Pipeline',
       label: 'Pipeline',
       applyAria: 'Apply pipeline',
       applyAria: 'Apply pipeline',

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

@@ -4792,6 +4792,27 @@ export default {
       smoothPEI: 'Smooth PEI Plate',
       smoothPEI: 'Smooth PEI Plate',
     },
     },
     // Slicer Pipelines (#1425) — apply a saved bundle or save the current pick.
     // Slicer Pipelines (#1425) — apply a saved bundle or save the current pick.
+    filters: {
+      onlyConnectedModels: 'Solo impresoras en línea',
+      onlyConnectedModelsHint: 'Perfiles de impresora de los modelos conectados ahora, en todos los tamaños de boquilla.',
+      onlyLoadedSpools: 'Solo bobinas cargadas',
+      onlyLoadedSpoolsHint: 'Perfiles de filamento de las bobinas en las impresoras conectadas del modelo seleccionado. Un perfil que elijas tú se mantiene.',
+      noneOnline: 'Ninguna impresora está en línea, así que se muestran todos los perfiles.',
+      noneLoaded: 'Ninguna bobina cargada tiene un perfil para esta impresora, así que se muestran todos los perfiles de filamento.',
+      notOnline: 'Sin conexión',
+      notLoaded: 'No cargado',
+    },
+    loadedSpools: {
+      title: 'Bobinas cargadas',
+      pick: 'Elegir',
+      pickTitle: 'Elegir una bobina cargada en una impresora conectada',
+      none: 'Ninguna impresora de este modelo está en línea.',
+      nothingLoaded: 'Sin AMS ni bobina externa.',
+      unknownSpool: 'Bobina no identificada',
+      empty: 'Vacío',
+      noProfile: 'Sin perfil para la impresora seleccionada',
+      noProfileShort: 'Sin perfil para esta impresora',
+    },
     pipelines: {
     pipelines: {
       label: 'Pipeline',
       label: 'Pipeline',
       applyAria: 'Aplicar pipeline',
       applyAria: 'Aplicar pipeline',

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

@@ -4778,6 +4778,27 @@ export default {
       smoothPEI: 'Smooth PEI Plate',
       smoothPEI: 'Smooth PEI Plate',
     },
     },
     // Slicer Pipelines (#1425) — apply a saved bundle or save the current pick.
     // Slicer Pipelines (#1425) — apply a saved bundle or save the current pick.
+    filters: {
+      onlyConnectedModels: 'Uniquement les imprimantes en ligne',
+      onlyConnectedModelsHint: 'Profils d\'imprimante des modèles connectés en ce moment, pour toutes les tailles de buse.',
+      onlyLoadedSpools: 'Uniquement les bobines chargées',
+      onlyLoadedSpoolsHint: 'Profils de filament des bobines dans les imprimantes connectées du modèle choisi. Un profil choisi par vous est conservé.',
+      noneOnline: 'Aucune imprimante n\'est en ligne, tous les profils sont donc affichés.',
+      noneLoaded: 'Aucune bobine chargée n\'a de profil pour cette imprimante, tous les profils de filament sont donc affichés.',
+      notOnline: 'Hors ligne',
+      notLoaded: 'Non chargé',
+    },
+    loadedSpools: {
+      title: 'Bobines chargées',
+      pick: 'Choisir',
+      pickTitle: 'Choisir une bobine chargée dans une imprimante connectée',
+      none: 'Aucune imprimante de ce modèle n\'est en ligne.',
+      nothingLoaded: 'Ni AMS ni bobine externe.',
+      unknownSpool: 'Bobine non identifiée',
+      empty: 'Vide',
+      noProfile: 'Aucun profil pour l\'imprimante choisie',
+      noProfileShort: 'Aucun profil pour cette imprimante',
+    },
     pipelines: {
     pipelines: {
       label: 'Pipeline',
       label: 'Pipeline',
       applyAria: 'Appliquer le pipeline',
       applyAria: 'Appliquer le pipeline',

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

@@ -4777,6 +4777,27 @@ export default {
       smoothPEI: 'Smooth PEI Plate',
       smoothPEI: 'Smooth PEI Plate',
     },
     },
     // Slicer Pipelines (#1425) — apply a saved bundle or save the current pick.
     // Slicer Pipelines (#1425) — apply a saved bundle or save the current pick.
+    filters: {
+      onlyConnectedModels: 'Solo stampanti online',
+      onlyConnectedModelsHint: 'Profili stampante dei modelli connessi ora, per ogni diametro dell\'ugello.',
+      onlyLoadedSpools: 'Solo bobine caricate',
+      onlyLoadedSpoolsHint: 'Profili filamento delle bobine nelle stampanti connesse del modello selezionato. Un profilo scelto da te resta.',
+      noneOnline: 'Nessuna stampante è online, quindi sono mostrati tutti i profili.',
+      noneLoaded: 'Nessuna bobina caricata ha un profilo per questa stampante, quindi sono mostrati tutti i profili filamento.',
+      notOnline: 'Non online',
+      notLoaded: 'Non caricato',
+    },
+    loadedSpools: {
+      title: 'Bobine caricate',
+      pick: 'Scegli',
+      pickTitle: 'Scegli una bobina caricata in una stampante connessa',
+      none: 'Nessuna stampante di questo modello è online.',
+      nothingLoaded: 'Nessun AMS e nessuna bobina esterna.',
+      unknownSpool: 'Bobina non identificata',
+      empty: 'Vuoto',
+      noProfile: 'Nessun profilo per la stampante selezionata',
+      noProfileShort: 'Nessun profilo per questa stampante',
+    },
     pipelines: {
     pipelines: {
       label: 'Pipeline',
       label: 'Pipeline',
       applyAria: 'Applica pipeline',
       applyAria: 'Applica pipeline',

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

@@ -4790,6 +4790,27 @@ export default {
       smoothPEI: 'Smooth PEI Plate',
       smoothPEI: 'Smooth PEI Plate',
     },
     },
     // Slicer Pipelines (#1425) — apply a saved bundle or save the current pick.
     // Slicer Pipelines (#1425) — apply a saved bundle or save the current pick.
+    filters: {
+      onlyConnectedModels: 'オンラインのプリンターのみ',
+      onlyConnectedModelsHint: '現在オンラインの機種のプリンタープロファイル(すべてのノズル径)。',
+      onlyLoadedSpools: '装填済みのスプールのみ',
+      onlyLoadedSpoolsHint: '選択した機種のオンラインのプリンターに装填されたスプールのフィラメントプロファイル。自分で選んだプロファイルはそのまま残ります。',
+      noneOnline: 'オンラインのプリンターがないため、すべてのプロファイルを表示しています。',
+      noneLoaded: 'このプリンター用のプロファイルを持つ装填済みスプールがないため、すべてのフィラメントプロファイルを表示しています。',
+      notOnline: 'オフライン',
+      notLoaded: '未装填',
+    },
+    loadedSpools: {
+      title: '装填済みのスプール',
+      pick: '選択',
+      pickTitle: '接続中のプリンターに装填されたスプールを選択',
+      none: 'この機種のプリンターはオンラインではありません。',
+      nothingLoaded: 'AMSも外部スプールもありません。',
+      unknownSpool: '不明なスプール',
+      empty: '空',
+      noProfile: '選択したプリンター用のプロファイルがありません',
+      noProfileShort: 'このプリンター用のプロファイルがありません',
+    },
     pipelines: {
     pipelines: {
       label: 'パイプライン',
       label: 'パイプライン',
       applyAria: 'パイプラインを適用',
       applyAria: 'パイプラインを適用',

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

@@ -4581,6 +4581,27 @@ export default {
       smoothPEI: '매끄러운 PEI 플레이트'
       smoothPEI: '매끄러운 PEI 플레이트'
     },
     },
     // Slicer Pipelines (#1425) — apply a saved bundle or save the current pick.
     // Slicer Pipelines (#1425) — apply a saved bundle or save the current pick.
+    filters: {
+      onlyConnectedModels: '온라인 프린터만',
+      onlyConnectedModelsHint: '지금 온라인인 모델의 프린터 프로필(모든 노즐 크기).',
+      onlyLoadedSpools: '장착된 스풀만',
+      onlyLoadedSpoolsHint: '선택한 모델의 온라인 프린터에 장착된 스풀의 필라멘트 프로필. 직접 고른 프로필은 유지됩니다.',
+      noneOnline: '온라인 프린터가 없어 모든 프로필을 표시합니다.',
+      noneLoaded: '이 프린터용 프로필이 있는 장착 스풀이 없어 모든 필라멘트 프로필을 표시합니다.',
+      notOnline: '오프라인',
+      notLoaded: '미장착',
+    },
+    loadedSpools: {
+      title: '장착된 스풀',
+      pick: '선택',
+      pickTitle: '연결된 프린터에 장착된 스풀 선택',
+      none: '이 모델의 프린터가 온라인이 아닙니다.',
+      nothingLoaded: 'AMS도 외부 스풀도 없습니다.',
+      unknownSpool: '알 수 없는 스풀',
+      empty: '비어 있음',
+      noProfile: '선택한 프린터용 프로필 없음',
+      noProfileShort: '이 프린터용 프로필 없음',
+    },
     pipelines: {
     pipelines: {
       label: '파이프라인',
       label: '파이프라인',
       applyAria: '파이프라인 적용',
       applyAria: '파이프라인 적용',

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

@@ -4825,6 +4825,27 @@ export default {
       smoothPEI: 'Smooth PEI Plate',
       smoothPEI: 'Smooth PEI Plate',
     },
     },
     // Slicer Pipelines (#1425) — apply a saved bundle or save the current pick.
     // Slicer Pipelines (#1425) — apply a saved bundle or save the current pick.
+    filters: {
+      onlyConnectedModels: 'Alleen printers die online zijn',
+      onlyConnectedModelsHint: 'Printerprofielen voor de modellen die nu online zijn, in elke nozzlemaat.',
+      onlyLoadedSpools: 'Alleen geladen spoelen',
+      onlyLoadedSpoolsHint: 'Filamentprofielen van de spoelen in de online printers van het gekozen model. Een profiel dat je zelf kiest, blijft staan.',
+      noneOnline: 'Geen printer is online, dus alle profielen worden getoond.',
+      noneLoaded: 'Geen geladen spoel heeft een profiel voor deze printer, dus alle filamentprofielen worden getoond.',
+      notOnline: 'Niet online',
+      notLoaded: 'Niet geladen',
+    },
+    loadedSpools: {
+      title: 'Geladen spoelen',
+      pick: 'Kiezen',
+      pickTitle: 'Kies een spoel die in een verbonden printer zit',
+      none: 'Geen printer van dit model is online.',
+      nothingLoaded: 'Geen AMS en geen externe spoel.',
+      unknownSpool: 'Onbekende spoel',
+      empty: 'Leeg',
+      noProfile: 'Geen profiel voor de gekozen printer',
+      noProfileShort: 'Geen profiel voor deze printer',
+    },
     pipelines: {
     pipelines: {
       label: 'Pipeline',
       label: 'Pipeline',
       applyAria: 'Pipeline toepassen',
       applyAria: 'Pipeline toepassen',

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

@@ -4777,6 +4777,27 @@ export default {
       smoothPEI: 'Smooth PEI Plate',
       smoothPEI: 'Smooth PEI Plate',
     },
     },
     // Slicer Pipelines (#1425) — apply a saved bundle or save the current pick.
     // Slicer Pipelines (#1425) — apply a saved bundle or save the current pick.
+    filters: {
+      onlyConnectedModels: 'Somente impressoras online',
+      onlyConnectedModelsHint: 'Perfis de impressora dos modelos online agora, em todos os tamanhos de bico.',
+      onlyLoadedSpools: 'Somente carretéis carregados',
+      onlyLoadedSpoolsHint: 'Perfis de filamento dos carretéis nas impressoras online do modelo selecionado. Um perfil escolhido por você é mantido.',
+      noneOnline: 'Nenhuma impressora está online, então todos os perfis são exibidos.',
+      noneLoaded: 'Nenhum carretel carregado tem perfil para esta impressora, então todos os perfis de filamento são exibidos.',
+      notOnline: 'Offline',
+      notLoaded: 'Não carregado',
+    },
+    loadedSpools: {
+      title: 'Carretéis carregados',
+      pick: 'Escolher',
+      pickTitle: 'Escolher um carretel carregado em uma impressora conectada',
+      none: 'Nenhuma impressora deste modelo está online.',
+      nothingLoaded: 'Sem AMS e sem carretel externo.',
+      unknownSpool: 'Carretel não identificado',
+      empty: 'Vazio',
+      noProfile: 'Sem perfil para a impressora selecionada',
+      noProfileShort: 'Sem perfil para esta impressora',
+    },
     pipelines: {
     pipelines: {
       label: 'Pipeline',
       label: 'Pipeline',
       applyAria: 'Aplicar pipeline',
       applyAria: 'Aplicar pipeline',

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

@@ -4572,6 +4572,27 @@ export default {
       texturedPEI: "Текстурированная PEI-пластина",
       texturedPEI: "Текстурированная PEI-пластина",
       smoothPEI: "Гладкая PEI-пластина",
       smoothPEI: "Гладкая PEI-пластина",
     },
     },
+    filters: {
+      onlyConnectedModels: 'Только принтеры в сети',
+      onlyConnectedModelsHint: 'Профили принтеров для моделей, которые сейчас в сети, со всеми диаметрами сопла.',
+      onlyLoadedSpools: 'Только загруженные катушки',
+      onlyLoadedSpoolsHint: 'Профили филамента для катушек в подключённых принтерах выбранной модели. Профиль, выбранный вами вручную, сохраняется.',
+      noneOnline: 'Ни один принтер не в сети, поэтому показаны все профили.',
+      noneLoaded: 'Ни у одной загруженной катушки нет профиля для этого принтера, поэтому показаны все профили филамента.',
+      notOnline: 'Не в сети',
+      notLoaded: 'Не загружено',
+    },
+    loadedSpools: {
+      title: 'Загруженные катушки',
+      pick: 'Выбрать',
+      pickTitle: 'Выбрать катушку, загруженную в подключённый принтер',
+      none: 'Ни один принтер этой модели не в сети.',
+      nothingLoaded: 'Нет AMS и внешней катушки.',
+      unknownSpool: 'Неизвестная катушка',
+      empty: 'Пусто',
+      noProfile: 'Нет профиля для выбранного принтера',
+      noProfileShort: 'Нет профиля для этого принтера',
+    },
     pipelines: {
     pipelines: {
       label: "Конвейер",
       label: "Конвейер",
       applyAria: "Применить конвейер",
       applyAria: "Применить конвейер",

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

@@ -4824,6 +4824,27 @@ errors: {
       smoothPEI: 'Slät PEI-platta',
       smoothPEI: 'Slät PEI-platta',
     },
     },
     // Slicer Pipelines (#1425) — apply a saved bundle or save the current pick.
     // Slicer Pipelines (#1425) — apply a saved bundle or save the current pick.
+    filters: {
+      onlyConnectedModels: 'Endast skrivare som är online',
+      onlyConnectedModelsHint: 'Skrivarprofiler för modellerna som är online just nu, i alla munstycksstorlekar.',
+      onlyLoadedSpools: 'Endast laddade spolar',
+      onlyLoadedSpoolsHint: 'Filamentprofiler för spolarna i de anslutna skrivarna av vald modell. En profil du väljer själv ligger kvar.',
+      noneOnline: 'Ingen skrivare är online, så alla profiler visas.',
+      noneLoaded: 'Ingen laddad spole har en profil för den här skrivaren, så alla filamentprofiler visas.',
+      notOnline: 'Inte online',
+      notLoaded: 'Inte laddad',
+    },
+    loadedSpools: {
+      title: 'Laddade spolar',
+      pick: 'Välj',
+      pickTitle: 'Välj en spole som sitter i en ansluten skrivare',
+      none: 'Ingen skrivare av den här modellen är online.',
+      nothingLoaded: 'Ingen AMS och ingen extern spole.',
+      unknownSpool: 'Okänd spole',
+      empty: 'Tom',
+      noProfile: 'Ingen profil för vald skrivare',
+      noProfileShort: 'Ingen profil för den här skrivaren',
+    },
     pipelines: {
     pipelines: {
       label: 'Pipeline',
       label: 'Pipeline',
       applyAria: 'Tillämpa pipeline',
       applyAria: 'Tillämpa pipeline',

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

@@ -4778,6 +4778,27 @@ export default {
       smoothPEI: 'Düz PEI Plakası',
       smoothPEI: 'Düz PEI Plakası',
     },
     },
     // Slicer Pipelines (#1425) — apply a saved bundle or save the current pick.
     // Slicer Pipelines (#1425) — apply a saved bundle or save the current pick.
+    filters: {
+      onlyConnectedModels: 'Yalnızca çevrimiçi yazıcılar',
+      onlyConnectedModelsHint: 'Şu anda çevrimiçi olan modellerin yazıcı profilleri, tüm nozul boyutlarında.',
+      onlyLoadedSpools: 'Yalnızca takılı makaralar',
+      onlyLoadedSpoolsHint: 'Seçili modelin çevrimiçi yazıcılarındaki makaraların filament profilleri. Kendi seçtiğiniz profil korunur.',
+      noneOnline: 'Çevrimiçi yazıcı yok, bu yüzden tüm profiller gösteriliyor.',
+      noneLoaded: 'Takılı makaraların hiçbirinin bu yazıcı için profili yok, bu yüzden tüm filament profilleri gösteriliyor.',
+      notOnline: 'Çevrimdışı',
+      notLoaded: 'Takılı değil',
+    },
+    loadedSpools: {
+      title: 'Takılı makaralar',
+      pick: 'Seç',
+      pickTitle: 'Bağlı bir yazıcıdaki makarayı seç',
+      none: 'Bu modelden çevrimiçi yazıcı yok.',
+      nothingLoaded: 'AMS ve harici makara yok.',
+      unknownSpool: 'Tanımlanamayan makara',
+      empty: 'Boş',
+      noProfile: 'Seçili yazıcı için profil yok',
+      noProfileShort: 'Bu yazıcı için profil yok',
+    },
     pipelines: {
     pipelines: {
       label: 'Pipeline',
       label: 'Pipeline',
       applyAria: 'Pipeline uygula',
       applyAria: 'Pipeline uygula',

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

@@ -4822,6 +4822,27 @@ export default {
       smoothPEI: "Гладка PEI-пластина",
       smoothPEI: "Гладка PEI-пластина",
     },
     },
     // Slicer Pipelines (#1425) — apply a saved bundle or save the current pick.
     // Slicer Pipelines (#1425) — apply a saved bundle or save the current pick.
+    filters: {
+      onlyConnectedModels: 'Лише принтери в мережі',
+      onlyConnectedModelsHint: 'Профілі принтерів для моделей, що зараз у мережі, з усіма діаметрами сопла.',
+      onlyLoadedSpools: 'Лише завантажені котушки',
+      onlyLoadedSpoolsHint: 'Профілі філаменту для котушок у підключених принтерах вибраної моделі. Профіль, який ви вибрали самі, залишається.',
+      noneOnline: 'Жоден принтер не в мережі, тому показано всі профілі.',
+      noneLoaded: 'Жодна завантажена котушка не має профілю для цього принтера, тому показано всі профілі філаменту.',
+      notOnline: 'Не в мережі',
+      notLoaded: 'Не завантажено',
+    },
+    loadedSpools: {
+      title: 'Завантажені котушки',
+      pick: 'Вибрати',
+      pickTitle: 'Вибрати котушку, завантажену в підключений принтер',
+      none: 'Жоден принтер цієї моделі не в мережі.',
+      nothingLoaded: 'Немає AMS і зовнішньої котушки.',
+      unknownSpool: 'Невідома котушка',
+      empty: 'Порожньо',
+      noProfile: 'Немає профілю для вибраного принтера',
+      noProfileShort: 'Немає профілю для цього принтера',
+    },
     pipelines: {
     pipelines: {
       label: "Конвеєр",
       label: "Конвеєр",
       applyAria: "Застосувати конвеєр",
       applyAria: "Застосувати конвеєр",

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

@@ -4777,6 +4777,27 @@ export default {
       smoothPEI: 'Smooth PEI Plate',
       smoothPEI: 'Smooth PEI Plate',
     },
     },
     // Slicer Pipelines (#1425) — apply a saved bundle or save the current pick.
     // Slicer Pipelines (#1425) — apply a saved bundle or save the current pick.
+    filters: {
+      onlyConnectedModels: '仅在线的打印机',
+      onlyConnectedModelsHint: '当前在线机型的打印机配置(所有喷嘴尺寸)。',
+      onlyLoadedSpools: '仅已装载的耗材盘',
+      onlyLoadedSpoolsHint: '所选机型在线打印机中所装耗材盘的耗材配置。您自己选择的配置会保留。',
+      noneOnline: '没有在线的打印机,因此显示所有配置。',
+      noneLoaded: '没有已装载的耗材盘带有此打印机的配置,因此显示所有耗材配置。',
+      notOnline: '不在线',
+      notLoaded: '未装载',
+    },
+    loadedSpools: {
+      title: '已装载的耗材盘',
+      pick: '选择',
+      pickTitle: '选择已连接打印机中装载的耗材盘',
+      none: '此机型没有在线的打印机。',
+      nothingLoaded: '没有 AMS,也没有外置耗材盘。',
+      unknownSpool: '无法识别的耗材盘',
+      empty: '空',
+      noProfile: '没有适用于所选打印机的配置',
+      noProfileShort: '没有适用于此打印机的配置',
+    },
     pipelines: {
     pipelines: {
       label: '流水线',
       label: '流水线',
       applyAria: '应用流水线',
       applyAria: '应用流水线',

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

@@ -4777,6 +4777,27 @@ export default {
       smoothPEI: 'Smooth PEI Plate',
       smoothPEI: 'Smooth PEI Plate',
     },
     },
     // Slicer Pipelines (#1425) — apply a saved bundle or save the current pick.
     // Slicer Pipelines (#1425) — apply a saved bundle or save the current pick.
+    filters: {
+      onlyConnectedModels: '僅限線上的印表機',
+      onlyConnectedModelsHint: '目前線上機型的印表機設定檔(所有噴嘴尺寸)。',
+      onlyLoadedSpools: '僅限已裝載的線材盤',
+      onlyLoadedSpoolsHint: '所選機型線上印表機中所裝線材盤的線材設定檔。您自己選擇的設定檔會保留。',
+      noneOnline: '沒有線上的印表機,因此顯示所有設定檔。',
+      noneLoaded: '沒有已裝載的線材盤帶有此印表機的設定檔,因此顯示所有線材設定檔。',
+      notOnline: '離線',
+      notLoaded: '未裝載',
+    },
+    loadedSpools: {
+      title: '已裝載的線材盤',
+      pick: '選擇',
+      pickTitle: '選擇已連線印表機中裝載的線材盤',
+      none: '此機型沒有線上的印表機。',
+      nothingLoaded: '沒有 AMS,也沒有外部線材盤。',
+      unknownSpool: '無法識別的線材盤',
+      empty: '空',
+      noProfile: '沒有適用於所選印表機的設定檔',
+      noProfileShort: '沒有適用於此印表機的設定檔',
+    },
     pipelines: {
     pipelines: {
       label: '管線',
       label: '管線',
       applyAria: '套用管線',
       applyAria: '套用管線',

+ 275 - 0
frontend/src/utils/sliceLoadedSpools.ts

@@ -0,0 +1,275 @@
+// Matching what is loaded in the connected printers against the SliceModal's
+// profile lists (#3172).
+//
+// Two questions are answered here:
+//
+//   1. Which printer models are online, and does a printer profile belong to
+//      one of them? A profile whose model can't be read from its name (a
+//      user's own "My farm printer") is never ruled out.
+//   2. Which filament profile does a loaded slot correspond to, for the
+//      printer profile selected in the dialog? Strongest evidence first:
+//
+//      a. the profile Bambuddy saved for the slot, by its id, when it still
+//         describes the slot (#3216) and fits the selected printer;
+//      b. the same profile under another printer's tag, found by base name
+//         (the name before its "@printer" suffix) — a cloud profile saved for
+//         an X1C reaches its H2D copy this way, and bundled profiles, which
+//         are addressed by name, are only reachable this way;
+//      c. for a slot nobody configured through Bambuddy, the spool's own
+//         brand text ("PLA Basic" → "Bambu PLA Basic") or, with none,
+//         "Generic <material>".
+//
+//      A slot with no match is not guessed at: the picker shows it greyed out
+//      rather than turning its bare material into some profile.
+
+import type {
+  LoadedSpoolPreset,
+  LoadedSpoolPrinter,
+  LoadedSpoolTray,
+  PresetRef,
+  PresetSource,
+  UnifiedPreset,
+  UnifiedPresetsResponse,
+} from '../api/client';
+import { slotPresetDescribesTray } from './amsHelpers';
+import { SLICE_MODAL_TIER_ORDER, statesDifferentMaterial } from './slicePresetPicker';
+import {
+  matchesPrinterModelSuffix,
+  presetCompatibility,
+  type PrinterCompatibilityIndex,
+} from './slicerPrinterMatch';
+
+export function refValue(ref: PresetRef): string {
+  return `${ref.source}:${ref.id}`;
+}
+
+/**
+ * The model short name of a printer profile ("Bambu Lab X1 Carbon 0.4
+ * nozzle" → "X1C"), or null when the name isn't a Bambu printer profile.
+ * ``printerModels`` is the backend's long → short registry.
+ */
+export function printerPresetModel(
+  name: string | null | undefined,
+  printerModels: Record<string, string>,
+): string | null {
+  if (!name) return null;
+  const m = name.replace(/^#\s*/, '').trim().match(/^Bambu Lab\s+(.+?)(?:\s+[\d.]+\s*nozzle)?$/i);
+  if (!m) return null;
+  const fragment = m[1].trim();
+  const full = `bambu lab ${fragment}`.toLowerCase();
+  for (const [long, short] of Object.entries(printerModels)) {
+    if (long.toLowerCase() === full) return short;
+  }
+  return fragment;
+}
+
+export function sameModel(a: string | null | undefined, b: string | null | undefined): boolean {
+  if (!a || !b) return false;
+  return matchesPrinterModelSuffix(a.trim(), b.trim());
+}
+
+/** Connected printers of ``model``; every connected printer when it's unknown. */
+export function printersOfModel(
+  printers: LoadedSpoolPrinter[],
+  model: string | null,
+): LoadedSpoolPrinter[] {
+  if (!model) return printers;
+  return printers.filter((p) => sameModel(p.model, model));
+}
+
+/** Whether a printer profile belongs to a connected model, or can't be told. */
+export function isConnectedModelPreset(
+  preset: Pick<UnifiedPreset, 'name'>,
+  connectedModels: (string | null)[],
+  printerModels: Record<string, string>,
+): boolean {
+  const model = printerPresetModel(preset.name, printerModels);
+  if (model === null) return true;
+  return connectedModels.some((c) => sameModel(c, model));
+}
+
+/**
+ * A printer profile for one of the connected models, for the auto-pick while
+ * "only online printers" is on. Prefers the 0.4 nozzle, Bambu's default, as
+ * nothing here says which nozzle is fitted.
+ */
+export function pickConnectedPrinterPreset(
+  data: UnifiedPresetsResponse,
+  connectedModels: (string | null)[],
+  printerModels: Record<string, string>,
+): PresetRef | null {
+  let fallback: PresetRef | null = null;
+  for (const source of SLICE_MODAL_TIER_ORDER) {
+    for (const preset of data[source].printer) {
+      const model = printerPresetModel(preset.name, printerModels);
+      if (model === null || !connectedModels.some((c) => sameModel(c, model))) continue;
+      const ref = { source, id: preset.id };
+      if (/\b0\.4\s*nozzle\b/i.test(preset.name)) return ref;
+      fallback ??= ref;
+    }
+  }
+  return fallback;
+}
+
+/** A profile name without its "# " clone prefix and "@printer" suffix, case-folded. */
+export function presetBaseName(name: string): string {
+  let s = name.replace(/^#\s*/, '');
+  const at = s.lastIndexOf('@');
+  if (at > 0) s = s.slice(0, at);
+  return s.replace(/\s+/g, ' ').trim().toLowerCase();
+}
+
+/** The same, keeping the case, for display. */
+export function presetDisplayName(name: string): string {
+  let s = name.replace(/^#\s*/, '');
+  const at = s.lastIndexOf('@');
+  if (at > 0) s = s.slice(0, at);
+  return s.replace(/\s+/g, ' ').trim();
+}
+
+export function isLoaded(tray: LoadedSpoolTray): boolean {
+  return Boolean(tray.tray_type);
+}
+
+/** The saved profile, unless the slot has been reconfigured since (#3216). */
+export function savedPresetFor(tray: LoadedSpoolTray): LoadedSpoolPreset | null {
+  const saved = tray.saved_preset;
+  if (!saved) return null;
+  return slotPresetDescribesTray(saved.preset_id, tray.tray_info_idx, saved.tray_info_idx) ? saved : null;
+}
+
+function savedRef(saved: LoadedSpoolPreset): PresetRef | null {
+  if (saved.preset_source === 'local') {
+    const m = saved.preset_id.match(/^local_(\d+)$/);
+    return m ? { source: 'local', id: m[1] } : null;
+  }
+  if (saved.preset_source === 'cloud' || saved.preset_source === 'orca_cloud') {
+    return saved.preset_id ? { source: saved.preset_source, id: saved.preset_id } : null;
+  }
+  // "builtin_<filament id>" names a bundled filament by its id, which the
+  // bundled listing doesn't carry; its saved name is matched below instead.
+  return null;
+}
+
+function nameCandidates(tray: LoadedSpoolTray, saved: LoadedSpoolPreset | null): string[] {
+  const out: string[] = [];
+  if (saved?.preset_name) out.push(saved.preset_name);
+  const sub = (tray.tray_sub_brands ?? '').trim();
+  if (sub) {
+    // Bambu's own spools report "PLA Basic"; the profile is "Bambu PLA Basic".
+    // Only for Bambu filament ids, so a third-party "PLA Basic" isn't renamed.
+    if (/^GF/i.test(tray.tray_info_idx ?? '') && !/^bambu\b/i.test(sub)) out.push(`Bambu ${sub}`);
+    out.push(sub);
+  } else if (tray.tray_type) {
+    out.push(`Generic ${tray.tray_type}`);
+  }
+  const seen = new Set<string>();
+  return out.filter((n) => {
+    const key = presetBaseName(n);
+    if (!key || seen.has(key)) return false;
+    seen.add(key);
+    return true;
+  });
+}
+
+interface IndexedPreset {
+  source: PresetSource;
+  preset: UnifiedPreset;
+}
+
+/** Filament profiles by base name, each list in the dialog's tier order. */
+export type FilamentNameIndex = Map<string, IndexedPreset[]>;
+
+export function buildFilamentNameIndex(data: UnifiedPresetsResponse): FilamentNameIndex {
+  const index: FilamentNameIndex = new Map();
+  for (const source of SLICE_MODAL_TIER_ORDER) {
+    for (const preset of data[source].filament) {
+      const key = presetBaseName(preset.name);
+      const list = index.get(key);
+      if (list) list.push({ source, preset });
+      else index.set(key, [{ source, preset }]);
+    }
+  }
+  return index;
+}
+
+export interface SlotMatch {
+  ref: PresetRef;
+  preset: UnifiedPreset;
+}
+
+/**
+ * The filament profile for ``tray`` on the selected printer, or null when
+ * nothing fits. See the header for the order evidence is tried in.
+ */
+export function matchSlotPreset(
+  tray: LoadedSpoolTray,
+  data: UnifiedPresetsResponse,
+  index: FilamentNameIndex,
+  selectedPrinterName: string | null,
+  compatIndex: PrinterCompatibilityIndex,
+): SlotMatch | null {
+  if (!isLoaded(tray)) return null;
+  const saved = savedPresetFor(tray);
+  const fits = (p: UnifiedPreset) => presetCompatibility(p, 'filament', selectedPrinterName, compatIndex);
+
+  if (saved) {
+    const ref = savedRef(saved);
+    const preset = ref ? data[ref.source].filament.find((p) => p.id === ref.id) : undefined;
+    // The user picked this profile for the slot, so its material isn't
+    // second-guessed — only whether it is for the selected printer.
+    if (ref && preset && fits(preset) !== 'mismatch') return { ref, preset };
+  }
+
+  const savedSource = saved ? savedRef(saved)?.source : undefined;
+  for (const name of nameCandidates(tray, saved)) {
+    const entries = index.get(presetBaseName(name)) ?? [];
+    // The tier the saved profile came from goes first, then the usual order.
+    const ordered = savedSource
+      ? [...entries.filter((e) => e.source === savedSource), ...entries.filter((e) => e.source !== savedSource)]
+      : entries;
+    let unknown: SlotMatch | null = null;
+    for (const { source, preset } of ordered) {
+      if (statesDifferentMaterial(preset, tray.tray_type ?? '')) continue;
+      const fit = fits(preset);
+      if (fit === 'match') return { ref: { source, id: preset.id }, preset };
+      if (fit === 'unknown' && !unknown) unknown = { ref: { source, id: preset.id }, preset };
+    }
+    if (unknown) return unknown;
+  }
+  return null;
+}
+
+/** Every loaded tray of ``printers``, AMS units first, then external holders. */
+export function loadedTrays(printers: LoadedSpoolPrinter[]): LoadedSpoolTray[] {
+  const out: LoadedSpoolTray[] = [];
+  for (const printer of printers) {
+    for (const unit of printer.ams) {
+      for (const tray of unit.trays) if (isLoaded(tray)) out.push(tray);
+    }
+    for (const tray of printer.external) if (isLoaded(tray)) out.push(tray);
+  }
+  return out;
+}
+
+/** Ref values of the profiles matched by at least one loaded tray. */
+export function matchedFilamentRefs(
+  printers: LoadedSpoolPrinter[],
+  data: UnifiedPresetsResponse,
+  index: FilamentNameIndex,
+  selectedPrinterName: string | null,
+  compatIndex: PrinterCompatibilityIndex,
+): Set<string> {
+  const out = new Set<string>();
+  for (const tray of loadedTrays(printers)) {
+    const match = matchSlotPreset(tray, data, index, selectedPrinterName, compatIndex);
+    if (match) out.add(refValue(match.ref));
+  }
+  return out;
+}
+
+/** "#RRGGBB" from the printer's RRGGBBAA, or null when it isn't one. */
+export function trayColourHex(tray: Pick<LoadedSpoolTray, 'tray_color'>): string | null {
+  const raw = (tray.tray_color ?? '').replace(/^#/, '');
+  return /^[0-9a-fA-F]{6}/.test(raw) ? `#${raw.slice(0, 6).toUpperCase()}` : null;
+}

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


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


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


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-Bb4TsUSQ.css


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


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


+ 2 - 2
static/index.html

@@ -26,9 +26,9 @@
 
 
     <!-- Splash screens for iOS -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-CSbs3wvg.js"></script>
+    <script type="module" crossorigin src="/assets/index-gzJw0vAb.js"></script>
     <link rel="modulepreload" crossorigin href="/assets/chunk-aKtaBQYM.js">
     <link rel="modulepreload" crossorigin href="/assets/chunk-aKtaBQYM.js">
-    <link rel="stylesheet" crossorigin href="/assets/index-DKHAZk9g.css">
+    <link rel="stylesheet" crossorigin href="/assets/index-Bb4TsUSQ.css">
   </head>
   </head>
   <body>
   <body>
     <div id="root"></div>
     <div id="root"></div>

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