Explorar el Código

Slice dialog: sort preset groups, list stock presets once, add search (#3393)

Paul Traina hace 9 horas
padre
commit
a9ffe5b7cb

+ 0 - 16
.basedpyright/baseline.json

@@ -871,22 +871,6 @@
             }
         ],
         "./backend/app/api/routes/pipeline_runs.py": [
-            {
-                "code": "reportArgumentType",
-                "range": {
-                    "startColumn": 40,
-                    "endColumn": 70,
-                    "lineCount": 1
-                }
-            },
-            {
-                "code": "reportArgumentType",
-                "range": {
-                    "startColumn": 40,
-                    "endColumn": 70,
-                    "lineCount": 1
-                }
-            },
             {
                 "code": "reportArgumentType",
                 "range": {

+ 28 - 5
backend/app/api/routes/pipeline_runs.py

@@ -34,6 +34,7 @@ from sqlalchemy import delete, desc, func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 
 from backend.app.api.routes.cloud import resolve_api_key_cloud_owner
+from backend.app.api.routes.slicer_presets import listed_ref, load_stock_aliases
 from backend.app.core.auth import (
     ApiKeyActor,
     QueueReviewRequired,
@@ -165,13 +166,19 @@ def _make_status_lookup():
     return _lookup
 
 
-def _slice_request_from_pipeline(pipeline: SlicerPipeline) -> SliceRequest:
+def _slice_request_from_pipeline(
+    pipeline: SlicerPipeline, stock_aliases: dict[str, PresetRef] | None = None
+) -> SliceRequest:
+    """``stock_aliases`` is the preset listing's map (``load_stock_aliases``):
+    a ref the listing hides behind its Bambu Cloud twin slices with that twin,
+    as it does from the Slice dialog (#3386)."""
+    aliases = stock_aliases or {}
     try:
         raw_filaments = json.loads(pipeline.filament_presets_json or "[]")
     except (json.JSONDecodeError, TypeError):
         raw_filaments = []
     filament_presets = [
-        PresetRef(source=r["source"], id=r["id"])
+        _stored_ref(aliases, r["source"], r["id"])
         for r in raw_filaments
         if isinstance(r, dict) and "source" in r and "id" in r
     ]
@@ -188,14 +195,30 @@ def _slice_request_from_pipeline(pipeline: SlicerPipeline) -> SliceRequest:
             ),
         ) from None
     return SliceRequest(
-        printer_preset=PresetRef(source=pipeline.printer_preset_source, id=pipeline.printer_preset_id),
-        process_preset=PresetRef(source=pipeline.process_preset_source, id=pipeline.process_preset_id),
+        printer_preset=_stored_ref(aliases, pipeline.printer_preset_source, pipeline.printer_preset_id),
+        process_preset=_stored_ref(aliases, pipeline.process_preset_source, pipeline.process_preset_id),
         filament_presets=filament_presets,
         bed_type=bed_type,
         export_3mf=True,
     )
 
 
+def _stored_ref(aliases: dict[str, PresetRef], source: str, preset_id: str) -> PresetRef:
+    """A ref as the pipeline row stores it, pointed at the twin the listing shows."""
+    return listed_ref(aliases, PresetRef.model_validate({"source": source, "id": preset_id}))
+
+
+async def _pipeline_slice_request(
+    db: AsyncSession, pipeline: SlicerPipeline, cloud_user_id: int | None
+) -> SliceRequest:
+    """The run's slice request, with the aliases of the user who resolves its
+    cloud presets (``current_user_id`` on ``slice_and_persist``), not the
+    run's owner: a key without the cloud scope resolves none, so a hidden
+    Standard ref mapped to its cloud twin would be refused (#3386)."""
+    cloud_user = await db.get(User, cloud_user_id) if cloud_user_id is not None else None
+    return _slice_request_from_pipeline(pipeline, await load_stock_aliases(db, cloud_user))
+
+
 def _compute_job_status(
     persisted: str,
     queue_entry: PrintQueueItem | None,
@@ -566,7 +589,7 @@ def _make_orchestration_callable(
             try:
                 # Inside the try: a row with a bed type no slicer knows fails
                 # the run instead of leaving it in "slicing" (#3359).
-                slice_request = _slice_request_from_pipeline(pipeline)
+                slice_request = await _pipeline_slice_request(session, pipeline, creator_user_id)
                 slice_response = await slice_and_persist(
                     session,
                     model_bytes=model_bytes,

+ 65 - 7
backend/app/api/routes/slicer_presets.py

@@ -183,13 +183,14 @@ async def _fetch_cloud_presets(
             # that share names with private ones get deduped out within the cloud
             # tier itself.
             seen_names: set[str] = set()
-            for entry in type_data.get("private", []) + type_data.get("public", []):
-                name = entry.get("name")
-                setting_id = entry.get("setting_id") or entry.get("id")
-                if not name or not setting_id or name in seen_names:
-                    continue
-                seen_names.add(name)
-                slots[slot].append(UnifiedPreset(id=setting_id, name=name, source="cloud"))
+            for stock, entries in ((False, type_data.get("private", [])), (True, type_data.get("public", []))):
+                for entry in entries:
+                    name = entry.get("name")
+                    setting_id = entry.get("setting_id") or entry.get("id")
+                    if not name or not setting_id or name in seen_names:
+                        continue
+                    seen_names.add(name)
+                    slots[slot].append(UnifiedPreset(id=setting_id, name=name, source="cloud", stock=stock))
 
         # Cloud filament presets carry no metadata in this response on
         # purpose: the per-preset detail endpoint
@@ -752,6 +753,8 @@ async def list_unified_presets(
     standard = await _fetch_bundled_presets(db, refresh=refresh)
 
     orca_cloud, cloud, local, standard = _enrich_cloud_metadata(orca_cloud, cloud, local, standard)
+    stock_aliases = _stock_aliases(cloud, standard)
+    standard = _drop_aliased(standard, stock_aliases)
 
     return UnifiedPresetsResponse(
         orca_cloud=UnifiedPresetsBySlot(**orca_cloud),
@@ -760,9 +763,64 @@ async def list_unified_presets(
         standard=UnifiedPresetsBySlot(**standard),
         cloud_status=cloud_status,
         orca_cloud_status=orca_cloud_status,
+        stock_aliases=stock_aliases,
     )
 
 
+def _stock_aliases(
+    cloud: dict[str, list[UnifiedPreset]],
+    standard: dict[str, list[UnifiedPreset]],
+) -> dict[str, PresetRef]:
+    """Map each Standard preset with a Bambu Cloud stock twin to that twin (#3386).
+
+    Bambu's stock cloud presets are the bundled ones again, so a name is
+    listed once, under Bambu Cloud: Standard is the preferred sidecar's own
+    bundle (OrcaSlicer's re-authored ``@BBL`` profiles when Orca is
+    preferred), and the cloud copy is the one signed-in users already slice
+    with by default. Signed out there is no cloud list, so nothing is
+    aliased. The keys are ``"standard:<id>"``, so a pipeline or AMS slot
+    saved with a hidden Standard copy resolves to the cloud one.
+    """
+    aliases: dict[str, PresetRef] = {}
+    for slot, presets in cloud.items():
+        twins = {p.name: p for p in presets if p.stock}
+        for p in standard.get(slot, []):
+            twin = twins.get(p.name)
+            if twin is not None:
+                aliases[f"standard:{p.id}"] = PresetRef(source="cloud", id=twin.id)
+    return aliases
+
+
+def _drop_aliased(
+    presets: dict[str, list[UnifiedPreset]], aliases: dict[str, PresetRef]
+) -> dict[str, list[UnifiedPreset]]:
+    """``presets`` without those :func:`_stock_aliases` maps to a twin.
+
+    Runs after :func:`_enrich_cloud_metadata`, so the #2628 bridge has
+    already seen both copies.
+    """
+    return {
+        slot: [p for p in slot_presets if f"{p.source}:{p.id}" not in aliases] for slot, slot_presets in presets.items()
+    }
+
+
+async def load_stock_aliases(db: AsyncSession, user: User | None) -> dict[str, PresetRef]:
+    """The listing's ``stock_aliases`` for ``user``, from the cached tiers.
+
+    For callers that slice from stored refs without the Slice dialog, so a
+    pipeline slices with the profile the dialog shows for it. Both fetches
+    degrade to empty rather than raise, which leaves every ref as stored.
+    """
+    cloud, _status = await _fetch_cloud_presets(db, user)
+    standard = await _fetch_bundled_presets(db)
+    return _stock_aliases(cloud, standard)
+
+
+def listed_ref(aliases: dict[str, PresetRef], ref: PresetRef) -> PresetRef:
+    """The ref the listing shows for ``ref``: its cloud twin if hidden, else itself."""
+    return aliases.get(f"{ref.source}:{ref.id}", ref)
+
+
 def _loaded_spool_tray(raw: dict, ams_id: int, tray_id: int, saved: SlotPresetMapping | None) -> LoadedSpoolTray:
     exists = raw.get("exists")
     state = raw.get("state")

+ 15 - 2
backend/app/schemas/slicer_presets.py

@@ -9,7 +9,9 @@ exactly once across the response.
 
 from typing import Literal
 
-from pydantic import BaseModel
+from pydantic import BaseModel, Field
+
+from backend.app.schemas.slicer import PresetRef
 
 CloudStatus = Literal["ok", "not_authenticated", "expired", "unreachable"]
 
@@ -48,6 +50,10 @@ class UnifiedPreset(BaseModel):
     model, so the SliceModal matches against this parent as well (#3250).
     OrcaSlicer Cloud filament presets carry it too: one saved with only the
     keys the user changed takes its material from this parent (#3403).
+
+    ``stock`` marks a Bambu Cloud preset as one of Bambu's own (the cloud's
+    "public" list) rather than the user's. Internal only: the listing drops
+    the Standard presets of the same names (#3386).
     """
 
     id: str
@@ -57,6 +63,7 @@ class UnifiedPreset(BaseModel):
     filament_colour: str | None = None
     compatible_printers: list[str] | None = None
     inherits: str | None = None
+    stock: bool = Field(default=False, exclude=True)
 
 
 class UnifiedPresetsBySlot(BaseModel):
@@ -68,7 +75,8 @@ class UnifiedPresetsBySlot(BaseModel):
 
 
 class UnifiedPresetsResponse(BaseModel):
-    """Every tier carries its full preset list — no cross-tier dedup.
+    """Every tier carries its full preset list — no cross-tier dedup, except
+    that Bambu Cloud's stock presets are listed once, under Bambu Cloud (#3386).
 
     Priority order: ``local > orca_cloud > cloud > standard``. The order
     drives auto-pick (first non-empty tier wins, name-lookup walks tiers
@@ -81,6 +89,10 @@ class UnifiedPresetsResponse(BaseModel):
     explaining why a cloud tier is empty when the user expected to see it
     (signed out / token expired / network down). Each tier has its own
     status because they can fail independently.
+
+    ``stock_aliases`` maps each dropped Standard preset (``"standard:<id>"``)
+    to its Bambu Cloud stock twin, so a pipeline or AMS slot saved with the
+    Standard copy still finds it.
     """
 
     orca_cloud: UnifiedPresetsBySlot = UnifiedPresetsBySlot()
@@ -89,6 +101,7 @@ class UnifiedPresetsResponse(BaseModel):
     standard: UnifiedPresetsBySlot = UnifiedPresetsBySlot()
     cloud_status: CloudStatus = "ok"
     orca_cloud_status: CloudStatus = "ok"
+    stock_aliases: dict[str, PresetRef] = {}
 
 
 class LoadedSpoolPreset(BaseModel):

+ 118 - 0
backend/tests/integration/test_pipeline_stock_aliases_3386.py

@@ -0,0 +1,118 @@
+"""Run with pipeline maps hidden Standard refs for the right user (#3386).
+
+The preset listing hides a Standard preset behind its Bambu Cloud stock twin,
+and a pipeline run slices with that twin, as the Slice dialog does. The twin
+is resolved for the user who resolves the run's cloud presets, which is not
+the run's owner when a key without the cloud scope starts it.
+"""
+
+from pathlib import Path
+from unittest.mock import AsyncMock, patch
+
+import pytest
+from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
+
+from backend.app.models.library import LibraryFile
+from backend.app.models.pipeline_run import PipelineRun
+from backend.app.models.slicer_pipeline import SlicerPipeline
+from backend.app.models.user import User
+from backend.app.schemas.slicer import PresetRef
+
+pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
+
+
+class _Stop(Exception):
+    """Ends the run once the slice request has been captured."""
+
+
+async def _run_pipeline(db_session, test_engine, tmp_path, *, creator_user_id, created_by):
+    from backend.app.api.routes.pipeline_runs import _make_orchestration_callable
+
+    pipeline = SlicerPipeline(
+        name="stock presets",
+        printer_preset_source="standard",
+        printer_preset_id="Bambu Lab X1 Carbon 0.4 nozzle",
+        process_preset_source="standard",
+        process_preset_id="0.20mm Standard @BBL X1C",
+        filament_presets_json='[{"source": "standard", "id": "Bambu PLA Basic @BBL X1C"}]',
+        bed_type="Textured PEI Plate",
+    )
+    source = LibraryFile(filename="cube.stl", file_path="library/cube.stl", file_type="stl", file_size=1)
+    db_session.add_all([pipeline, source])
+    await db_session.commit()
+    run = PipelineRun(pipeline_id=pipeline.id, source_library_file_id=source.id, copies=1, created_by=created_by)
+    db_session.add(run)
+    await db_session.commit()
+    model = tmp_path / "cube.stl"
+    model.write_bytes(b"model")
+
+    seen_users = []
+
+    async def aliases_for(_db, user):
+        # Only a signed-in user has a cloud list, so only they get aliases.
+        seen_users.append(user.id if user is not None else None)
+        if user is None:
+            return {}
+        return {
+            "standard:Bambu Lab X1 Carbon 0.4 nozzle": PresetRef(source="cloud", id="GM001"),
+            "standard:0.20mm Standard @BBL X1C": PresetRef(source="cloud", id="GP004"),
+            "standard:Bambu PLA Basic @BBL X1C": PresetRef(source="cloud", id="GFSA00"),
+        }
+
+    sliced = AsyncMock(side_effect=_Stop())
+    orchestrate = _make_orchestration_callable(
+        run_id=run.id,
+        pipeline_id=pipeline.id,
+        src_kind="library_file",
+        src_id=source.id,
+        src_filename="cube.stl",
+        src_path=Path(model),
+        creator_user_id=creator_user_id,
+        copies=1,
+    )
+    maker = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
+    with (
+        patch("backend.app.api.routes.pipeline_runs.async_session", maker),
+        patch("backend.app.api.routes.pipeline_runs.load_stock_aliases", new=aliases_for),
+        patch("backend.app.api.routes.library.slice_and_persist", new=sliced),
+        patch("backend.app.api.routes.pipeline_runs._publish_run_event", new=AsyncMock()),
+        pytest.raises(_Stop),
+    ):
+        await orchestrate(1)
+
+    request = sliced.call_args.kwargs["request"]
+    return request, seen_users
+
+
+async def _owner(db_session) -> User:
+    owner = User(username="pipeline-owner", password_hash="x", role="user")
+    db_session.add(owner)
+    await db_session.commit()
+    return owner
+
+
+async def test_a_run_without_a_cloud_user_slices_the_stored_standard_refs(db_session, test_engine, tmp_path):
+    """A key without the cloud scope: the owner has a cloud list, but no one
+    resolves cloud presets for this run, so mapping to the cloud twins would
+    get the slice refused."""
+    owner = await _owner(db_session)
+    request, seen_users = await _run_pipeline(
+        db_session, test_engine, tmp_path, creator_user_id=None, created_by=owner.id
+    )
+
+    assert seen_users == [None]
+    assert request.printer_preset == PresetRef(source="standard", id="Bambu Lab X1 Carbon 0.4 nozzle")
+    assert request.process_preset == PresetRef(source="standard", id="0.20mm Standard @BBL X1C")
+    assert request.filament_presets == [PresetRef(source="standard", id="Bambu PLA Basic @BBL X1C")]
+
+
+async def test_a_run_with_a_cloud_user_slices_the_cloud_twins(db_session, test_engine, tmp_path):
+    owner = await _owner(db_session)
+    request, seen_users = await _run_pipeline(
+        db_session, test_engine, tmp_path, creator_user_id=owner.id, created_by=owner.id
+    )
+
+    assert seen_users == [owner.id]
+    assert request.printer_preset == PresetRef(source="cloud", id="GM001")
+    assert request.process_preset == PresetRef(source="cloud", id="GP004")
+    assert request.filament_presets == [PresetRef(source="cloud", id="GFSA00")]

+ 29 - 0
backend/tests/unit/test_slice_request_bed_type.py

@@ -123,6 +123,35 @@ class TestAPipelineSavedWithAnOldName:
     def test_its_run_slices_on_the_supertack_plate(self):
         assert _slice_request_from_pipeline(self._pipeline("Cool Plate (SuperTack)")).bed_type == "Supertack Plate"
 
+
+class TestAPipelineSavedWithAHiddenStandardPreset:
+    """#3386: the listing hides a Standard preset behind its Bambu Cloud stock
+    twin, and Run with pipeline must slice with the twin too, as the Slice
+    dialog does."""
+
+    def test_each_ref_slices_as_the_listing_shows_it(self):
+        pipeline = SlicerPipeline(
+            name="p",
+            printer_preset_source="standard",
+            printer_preset_id="Bambu Lab X1 Carbon 0.4 nozzle",
+            process_preset_source="standard",
+            process_preset_id="0.20mm Standard @BBL X1C",
+            filament_presets_json=(
+                '[{"source": "standard", "id": "Bambu PLA Basic @BBL X1C"}, {"source": "local", "id": "3"}]'
+            ),
+            bed_type=None,
+        )
+        aliases = {
+            "standard:0.20mm Standard @BBL X1C": PresetRef(source="cloud", id="GP004"),
+            "standard:Bambu PLA Basic @BBL X1C": PresetRef(source="cloud", id="GFSA00"),
+        }
+        request = _slice_request_from_pipeline(pipeline, aliases)
+        assert request.printer_preset == PresetRef(source="standard", id="Bambu Lab X1 Carbon 0.4 nozzle")
+        assert request.process_preset == PresetRef(source="cloud", id="GP004")
+        assert request.filament_presets == [PresetRef(source="cloud", id="GFSA00"), PresetRef(source="local", id="3")]
+        # Without a map (signed out) the row slices as stored.
+        assert _slice_request_from_pipeline(pipeline).process_preset.source == "standard"
+
     def test_it_reads_back_with_the_slicers_name(self):
         response = SlicerPipelineResponse(
             id=1,

+ 84 - 1
backend/tests/unit/test_slicer_presets.py

@@ -2,7 +2,8 @@
 
 The endpoint stitches together four preset sources (local / orca_cloud /
 cloud / standard). It does NOT dedup across tiers — every tier surfaces
-its full list so the user can pick any source. Bambu Cloud filament
+its full list so the user can pick any source — except the Standard
+presets that Bambu Cloud's stock list also has (#3386). Bambu Cloud filament
 metadata is enriched from same-named entries in the other tiers so it
 can still score in the SliceModal's auto-pick. These tests pin the
 enrich behaviour, the cloud-status mapping, and the per-user / sidecar
@@ -18,6 +19,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
 import pytest
 
 from backend.app.api.routes import slicer_presets as sp
+from backend.app.schemas.slicer import PresetRef
 from backend.app.schemas.slicer_presets import UnifiedPreset
 
 
@@ -751,6 +753,9 @@ class TestFetchCloudPresets:
         assert status == "ok"
         names = [p.name for p in slots["printer"]]
         assert names == ["My X1C", "Bambu X1C Stock"]
+        # #3386: the public list is Bambu's stock presets — flagged so the
+        # listing can drop the ones Standard also has.
+        assert [p.stock for p in slots["printer"]] == [False, True]
 
     @pytest.mark.asyncio
     async def test_cache_hit_skips_cloud_call(self):
@@ -897,6 +902,84 @@ class TestFetchCloudPresets:
         assert cloud_mock.get_slicer_settings.await_count == 1
 
 
+class TestStockDuplicates:
+    """#3386: a stock Bambu Cloud preset Standard also has is listed once, under Bambu Cloud."""
+
+    @staticmethod
+    def _tiers():
+        cloud = {
+            "printer": [],
+            "process": [],
+            "filament": [
+                UnifiedPreset(id="PFUS1", name="My PLA", source="cloud"),
+                UnifiedPreset(id="GFSA00", name="Bambu PLA Basic @BBL X1C", source="cloud", stock=True),
+                UnifiedPreset(id="GFSA99", name="Bambu PLA Brand New @BBL X1C", source="cloud", stock=True),
+            ],
+        }
+        standard = {
+            "printer": [],
+            "process": [],
+            "filament": [
+                UnifiedPreset(id="std-basic", name="Bambu PLA Basic @BBL X1C", source="standard"),
+                UnifiedPreset(id="std-mine", name="My PLA", source="standard"),
+                UnifiedPreset(id="std-matte", name="Bambu PLA Matte @BBL X1C", source="standard"),
+            ],
+        }
+        return cloud, standard
+
+    def test_hides_the_standard_twin_of_a_stock_cloud_preset(self):
+        cloud, standard = self._tiers()
+        aliases = sp._stock_aliases(cloud, standard)
+        # Only a stock preset hides its twin: the user's own "My PLA" doesn't.
+        assert {k: v.model_dump() for k, v in aliases.items()} == {
+            "standard:std-basic": {"source": "cloud", "id": "GFSA00"}
+        }
+        kept = sp._drop_aliased(standard, aliases)
+        assert [p.id for p in kept["filament"]] == ["std-mine", "std-matte"]
+        # The cloud tier is left whole, the stock copy newer than the bundle included.
+        assert sp._drop_aliased(cloud, aliases) == cloud
+
+    def test_signed_out_nothing_is_hidden(self):
+        _cloud, standard = self._tiers()
+        empty = {"printer": [], "process": [], "filament": []}
+        assert sp._stock_aliases(empty, standard) == {}
+
+    @pytest.mark.asyncio
+    async def test_the_listing_drops_the_standard_twin(self):
+        cloud, standard = self._tiers()
+        empty = {"printer": [], "process": [], "filament": []}
+        with (
+            patch.object(sp, "_fetch_orca_cloud_presets", AsyncMock(return_value=(empty, "not_authenticated"))),
+            patch.object(sp, "_fetch_cloud_presets", AsyncMock(return_value=(cloud, "ok"))),
+            patch.object(sp, "_fetch_local_presets", AsyncMock(return_value=empty)),
+            patch.object(sp, "_fetch_bundled_presets", AsyncMock(return_value=standard)),
+        ):
+            response = await sp.list_unified_presets(
+                db=MagicMock(), current_user=None, api_key_cloud_owner=None, refresh=False
+            )
+        body = response.model_dump()
+        assert [p["id"] for p in body["standard"]["filament"]] == ["std-mine", "std-matte"]
+        assert [p["id"] for p in body["cloud"]["filament"]] == ["PFUS1", "GFSA00", "GFSA99"]
+        assert body["stock_aliases"] == {"standard:std-basic": {"source": "cloud", "id": "GFSA00"}}
+
+    @pytest.mark.asyncio
+    async def test_slicing_from_stored_refs_uses_the_same_map(self):
+        cloud, standard = self._tiers()
+        with (
+            patch.object(sp, "_fetch_cloud_presets", AsyncMock(return_value=(cloud, "ok"))),
+            patch.object(sp, "_fetch_bundled_presets", AsyncMock(return_value=standard)),
+        ):
+            aliases = await sp.load_stock_aliases(MagicMock(), None)
+        hidden = sp.listed_ref(aliases, PresetRef(source="standard", id="std-basic"))
+        assert hidden.model_dump() == {"source": "cloud", "id": "GFSA00"}
+        listed = PresetRef(source="standard", id="std-matte")
+        assert sp.listed_ref(aliases, listed) is listed
+
+    def test_stock_flag_stays_out_of_the_response(self):
+        preset = UnifiedPreset(id="GFSA99", name="x", source="cloud", stock=True)
+        assert "stock" not in preset.model_dump()
+
+
 class TestFetchBundledPresets:
     """Standard tier reaches out to the slicer-api sidecar; tolerate the
     sidecar being absent / unreachable so the modal still works."""

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

@@ -2796,3 +2796,186 @@ describe('SliceModal — material and printer filtering (#2982)', () => {
     expect(labels.some((l) => l?.includes('0.06mm Fine @BBL A1 0.2 nozzle'))).toBe(false);
   });
 });
+
+describe('SliceModal — preset list order, stock duplicates and search (#3386)', () => {
+  const generics = Array.from({ length: 30 }, (_, i) => ({
+    id: `g-${i}`,
+    name: `Generic Filament ${i + 1}`,
+    source: 'standard' as const,
+  }));
+  const presets = makeUnified({
+    cloud: {
+      printer: [],
+      process: [],
+      filament: [
+        { id: 'PFUS-z', name: 'zeta PLA', source: 'cloud' },
+        { id: 'PFUS-10', name: 'alpha PLA 10', source: 'cloud' },
+        { id: 'PFUS-a', name: 'Alpha PLA', source: 'cloud' },
+        { id: 'PFUS-9', name: 'alpha PLA 9', source: 'cloud' },
+        // Stock, but newer than the bundle: the listing keeps it under Bambu Cloud.
+        { id: 'GFSA99', name: 'Bambu PLA Brand New', source: 'cloud' },
+        // Stock, and the listing dropped its Standard twin.
+        { id: 'GFSA00', name: 'Bambu PLA Basic', source: 'cloud' },
+      ],
+    },
+    standard: {
+      printer: [{ id: 'Bambu Lab X1 Carbon 0.4 nozzle', name: 'Bambu Lab X1 Carbon 0.4 nozzle', source: 'standard' }],
+      process: [
+        { id: '0.20mm Standard', name: '0.20mm Standard', source: 'standard' },
+        { id: '0.12mm Fine tree support', name: '0.12mm Fine tree support', source: 'standard' },
+        { id: '0.08mm Extra Fine', name: '0.08mm Extra Fine', source: 'standard' },
+        { id: '0.4mm Draft', name: '0.4mm Draft', source: 'standard' },
+      ],
+      filament: [
+        { id: 'Generic PVA', name: 'Generic PVA', source: 'standard' },
+        { id: 'Bambu Support For PLA', name: 'Bambu Support For PLA', source: 'standard' },
+        { id: 'eSUN PLA+', name: 'eSUN PLA+', source: 'standard' },
+        { id: 'Bambu PLA Matte', name: 'Bambu PLA Matte', source: 'standard' },
+        {
+          id: 'Bambu PETG HF @BBL H2D',
+          name: 'Bambu PETG HF @BBL H2D',
+          source: 'standard',
+          compatible_printers: ['Bambu Lab H2D 0.4 nozzle'],
+        },
+        ...generics,
+      ],
+    },
+    // The listing dropped the Standard copy of "Bambu PLA Basic".
+    stock_aliases: { 'standard:Bambu PLA Basic': { source: 'cloud', id: 'GFSA00' } },
+  });
+
+  const filamentSelect = () => presetSelects()[3];
+  const groupNames = (select: HTMLSelectElement, label: string) =>
+    Array.from(select.querySelectorAll('optgroup'))
+      .filter((g) => g.label === label)
+      .flatMap((g) => Array.from(g.querySelectorAll('option')).map((o) => o.textContent));
+
+  beforeEach(() => {
+    vi.clearAllMocks();
+    mockApi.getSlicerPresets.mockResolvedValue(presets);
+    mockApi.getSlicerPresetValues.mockResolvedValue({ resolved: true, values: {}, reason: 'ok' });
+    mockApi.listSlicerPipelines.mockResolvedValue({ pipelines: [] });
+    mockApi.getSlicerLoadedSpools.mockResolvedValue({ printers: [] });
+    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: [],
+    });
+  });
+
+  const open = async () => {
+    renderWithTracker({ source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' }, onClose: vi.fn() });
+    await waitFor(() => expect(presetSelects()).toHaveLength(4));
+    await waitFor(() => expect(filamentSelect().value).not.toBe(''));
+  };
+
+  it("sorts every group the way the bundled list is ordered", async () => {
+    await open();
+    // Bambu first, then other brands; by name without case, numbers by value.
+    expect(groupNames(filamentSelect(), 'Bambu Cloud')).toEqual([
+      'Bambu PLA Basic',
+      'Bambu PLA Brand New',
+      'Alpha PLA',
+      'alpha PLA 9',
+      'alpha PLA 10',
+      'zeta PLA',
+    ]);
+    // Bambu, Generic, other brands; support materials after every filament.
+    const standard = groupNames(filamentSelect(), 'Standard');
+    expect(standard.slice(0, 4)).toEqual([
+      'Bambu PLA Matte',
+      'Generic Filament 1',
+      'Generic Filament 2',
+      'Generic Filament 3',
+    ]);
+    expect(standard.slice(-3)).toEqual(['eSUN PLA+', 'Bambu Support For PLA', 'Generic PVA']);
+    // Decimals compare by value, and "support" only means a support material
+    // in a filament list.
+    expect(groupNames(presetSelects()[1], 'Standard')).toEqual([
+      '0.08mm Extra Fine',
+      '0.12mm Fine tree support',
+      '0.20mm Standard',
+      '0.4mm Draft',
+    ]);
+  });
+
+  it('applies a pipeline saved with a hidden Standard preset as its Bambu Cloud copy', async () => {
+    const user = userEvent.setup();
+    mockApi.listSlicerPipelines.mockResolvedValue({
+      pipelines: [
+        {
+          id: 7,
+          name: 'Old pipeline',
+          description: null,
+          printer_preset: { source: 'standard', id: 'Bambu Lab X1 Carbon 0.4 nozzle' },
+          process_preset: { source: 'standard', id: '0.20mm Standard' },
+          filament_presets: [{ source: 'standard', id: 'Bambu PLA Basic' }],
+          bed_type: null,
+          target_kind: 'printer_class',
+          target_printer_id: null,
+          target_model_class: null,
+          fanout_strategy: 'max_parallel',
+          created_by: null,
+          created_at: '',
+          updated_at: '',
+        },
+      ],
+    });
+    await open();
+    await user.selectOptions(screen.getByRole('combobox', { name: 'Apply pipeline' }), '7');
+    expect(filamentSelect().value).toBe('cloud:GFSA00');
+  });
+
+  it('offers search on every dropdown, and it never drops the selection', async () => {
+    const user = userEvent.setup();
+    await open();
+    // Printer, process and filament each get one, however short the list.
+    const buttons = screen.getAllByRole('button', { name: /: Search$/ });
+    expect(buttons).toHaveLength(3);
+    expect(screen.queryByRole('searchbox')).toBeNull();
+    await user.click(screen.getByRole('button', { name: /^Filament.*: Search$/ }));
+    const boxes = [screen.getByRole('searchbox')];
+    // The status line is there, empty, from the moment the box opens.
+    const status = screen.getByRole('status');
+    expect(status.textContent).toBe('');
+    // The auto-pick lands on the first Bambu Cloud preset in API order.
+    expect(filamentSelect().selectedOptions[0].textContent).toBe('zeta PLA');
+
+    await user.type(boxes[0], 'pla ALPHA');
+    const names = Array.from(filamentSelect().options)
+      .filter((o) => o.value !== '')
+      .map((o) => o.textContent);
+    // Every word has to match, in any order and any case; the selected preset
+    // stays although it doesn't match, so the choice is never lost.
+    expect(names).toEqual(['Alpha PLA', 'alpha PLA 9', 'alpha PLA 10', 'zeta PLA']);
+    expect(filamentSelect().value).toBe('cloud:PFUS-z');
+
+    expect(screen.queryByText('No matching presets found.')).toBeNull();
+
+    // Only the selection left: say so rather than leave a one-entry list.
+    await user.type(boxes[0], ' nothing');
+    // Same element, new text: screen readers announce a change, not a mount.
+    expect(screen.getByRole('status')).toBe(status);
+    expect(status).toHaveTextContent('No matching presets found.');
+
+    // Escape closes the box, brings the full list back and returns focus to
+    // the button that opened it.
+    await user.keyboard('{Escape}');
+    expect(screen.queryByRole('searchbox')).toBeNull();
+    expect(groupNames(filamentSelect(), 'Standard')).toContain('Generic Filament 30');
+    expect(document.activeElement).toBe(screen.getByRole('button', { name: /^Filament.*: Search$/ }));
+  });
+
+  it('searches the held-back presets too, under their own label', async () => {
+    const user = userEvent.setup();
+    await open();
+    // A preset for another printer is held back behind "Show all".
+    expect(Array.from(filamentSelect().options).map((o) => o.textContent)).not.toContain('Bambu PETG HF @BBL H2D');
+
+    await user.click(screen.getByRole('button', { name: /^Filament.*: Search$/ }));
+    await user.type(screen.getByRole('searchbox'), 'petg hf');
+    expect(groupNames(filamentSelect(), 'Other printers')).toEqual(['Bambu PETG HF @BBL H2D']);
+    expect(screen.queryByText('No matching presets found.')).toBeNull();
+  });
+});

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

@@ -225,6 +225,53 @@ describe('SliceModal — online printers and loaded spools (#3172)', () => {
     expect(labels).not.toContain('Generic PETG @BBL H2D');
   });
 
+  it('matches a slot saved with a hidden Standard profile to its Bambu Cloud twin (#3386)', async () => {
+    flags({ 'bambuddy.slice.onlyLoadedSpools': 'true' });
+    // The listing drops the Standard copy of a stock Bambu Cloud profile; the
+    // slot was saved with the bundled profile, which is stored by filament id
+    // and found by its name, now the cloud twin's.
+    mockApi.getSlicerPresets.mockResolvedValue({
+      ...PRESETS,
+      cloud: {
+        ...PRESETS.cloud,
+        filament: [{ id: 'GFSA01', name: 'Bambu PLA Matte @BBL H2D', source: 'cloud', filament_type: 'PLA' }],
+      },
+      standard: {
+        ...PRESETS.standard,
+        filament: PRESETS.standard.filament.filter((p) => p.id !== 'Bambu PLA Matte @BBL H2D'),
+      },
+      stock_aliases: { 'standard:Bambu PLA Matte @BBL H2D': { source: 'cloud', id: 'GFSA01' } },
+    });
+    const tray = LOADED.printers[0].ams[0].trays[0];
+    mockApi.getSlicerLoadedSpools.mockResolvedValue({
+      printers: [
+        {
+          ...LOADED.printers[0],
+          ams: [
+            {
+              ...LOADED.printers[0].ams[0],
+              trays: [
+                {
+                  ...tray,
+                  tray_sub_brands: null,
+                  saved_preset: {
+                    preset_id: 'builtin_GFA01',
+                    preset_name: 'Bambu PLA Matte @BBL H2D',
+                    preset_source: 'cloud',
+                    tray_info_idx: 'GFA01',
+                  },
+                },
+              ],
+            },
+          ],
+        },
+      ],
+    });
+    renderModal();
+    await waitFor(() => expect(selectFor('Filament profile').value).toBe('cloud:GFSA01'));
+    expect(optionLabels(selectFor('Filament profile'))).not.toContain('Bambu PLA Basic @BBL H2D');
+  });
+
   it('never auto-picks a loaded spool of another material', async () => {
     flags({ 'bambuddy.slice.onlyLoadedSpools': 'true' });
     mockApi.getLibraryFileFilamentRequirements.mockResolvedValue({

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

@@ -2062,14 +2062,17 @@ export interface LoadedSpoolsResponse {
 }
 export interface UnifiedPresetsResponse {
   // Priority order: local > orca_cloud > cloud > standard. No cross-tier
-  // dedup — every tier surfaces its full list so the user can pick from
-  // any source. The order drives auto-pick + visual group rendering only.
+  // dedup (apart from stock_aliases below) — every tier surfaces its full
+  // list so the user can pick from any source. The order drives auto-pick + visual group rendering only.
   orca_cloud: UnifiedPresetsBySlot;
   cloud: UnifiedPresetsBySlot;
   local: UnifiedPresetsBySlot;
   standard: UnifiedPresetsBySlot;
   cloud_status: SlicerCloudStatus;
   orca_cloud_status: SlicerCloudStatus;
+  // Standard presets that Bambu Cloud's stock list also has are left out of
+  // the standard tier (#3386); each "standard:<id>" maps to its cloud twin.
+  stock_aliases?: Record<string, PresetRef>;
 }
 
 // Slicer Pipelines (#1425) — named bundles of preset slots the SliceModal

+ 160 - 35
frontend/src/components/SliceModal.tsx

@@ -1,4 +1,4 @@
-import { Cloud, CloudOff, Cog, Grid2x2, Loader2, RefreshCw, X } from 'lucide-react';
+import { Cloud, CloudOff, Cog, Grid2x2, Loader2, RefreshCw, Search, X } from 'lucide-react';
 import { useEffect, useId, useMemo, useRef, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
@@ -33,7 +33,9 @@ import {
   findPresetByName,
   pickDefault,
   pickFilamentForSlot,
+  listedRef,
   pickProcessDefault,
+  refValue,
   statesDifferentMaterial,
   type Slot,
 } from '../utils/slicePresetPicker';
@@ -84,7 +86,7 @@ interface SliceModalProps {
 function toRefValue(ref: PresetRef | null): string {
   // The HTML `<select>` value space is flat strings; encode source + id so
   // the same preset name can live in multiple tiers without collision.
-  return ref ? `${ref.source}:${ref.id}` : '';
+  return ref ? refValue(ref) : '';
 }
 
 function fromRefValue(raw: string): PresetRef | null {
@@ -97,6 +99,57 @@ function fromRefValue(raw: string): PresetRef | null {
   return { source, id };
 }
 
+// Every preset group is listed in the order the slicer's bundled list already
+// uses (#3386): Bambu, Generic and other brands, each by name, with support
+// filaments after the real ones. The cloud tiers arrive in whatever order the
+// service returns, which reads as no order at all. Names compare without case
+// and with numbers by value, decimals included, so "0.08mm" sorts before
+// "0.2mm" and "PA6" before "PA12".
+const NAME_TEXT_ORDER = new Intl.Collator(undefined, { sensitivity: 'base' });
+const NAME_NUMBER = /(\d+(?:\.\d+)?)/;
+const SUPPORT_MATERIAL = /\b(support|pva|bvoh|hips)\b/i;
+
+// split() with a capture group puts the numbers at the odd indices.
+function compareNameParts(pa: string[], pb: string[]): number {
+  for (let i = 0; i < Math.min(pa.length, pb.length); i++) {
+    const d = i % 2 ? Number(pa[i]) - Number(pb[i]) : NAME_TEXT_ORDER.compare(pa[i], pb[i]);
+    if (d) return d;
+  }
+  return pa.length - pb.length;
+}
+
+function presetRank(name: string, slot: Slot): number {
+  const brand = /^bambu\b/i.test(name) ? 0 : /^generic\b/i.test(name) ? 1 : 2;
+  return (slot === 'filament' && SUPPORT_MATERIAL.test(name) ? 3 : 0) + brand;
+}
+
+function sortPresets(presets: UnifiedPreset[], slot: Slot): UnifiedPreset[] {
+  return presets
+    .map((p) => ({ p, rank: presetRank(p.name, slot), parts: p.name.split(NAME_NUMBER) }))
+    .sort(
+      (a, b) =>
+        a.rank - b.rank || compareNameParts(a.parts, b.parts) || NAME_TEXT_ORDER.compare(a.p.name, b.p.name),
+    )
+    .map(({ p }) => p);
+}
+
+// Every tier sorted once, for the dropdowns. The auto-picks keep the
+// listing's own order, so sorting never changes what is preselected.
+function sortListing(data: UnifiedPresetsResponse): UnifiedPresetsResponse {
+  const sortTier = (tier: UnifiedPresetsBySlot): UnifiedPresetsBySlot => ({
+    printer: sortPresets(tier.printer, 'printer'),
+    process: sortPresets(tier.process, 'process'),
+    filament: sortPresets(tier.filament, 'filament'),
+  });
+  return {
+    ...data,
+    local: sortTier(data.local),
+    orca_cloud: sortTier(data.orca_cloud),
+    cloud: sortTier(data.cloud),
+    standard: sortTier(data.standard),
+  };
+}
+
 // Inline spinner for the filament-requirements query. The backend runs a
 // preview slice on first open of an unsliced project file (cached after);
 // on a complex multi-color model that's a real slice — multi-second to
@@ -469,6 +522,8 @@ export function SliceModal({ source, onClose, defaultAutoArrange = false }: Slic
     }
   };
 
+  const sortedPresets = useMemo(() => presetsQuery.data && sortListing(presetsQuery.data), [presetsQuery.data]);
+
   // Canonical Bambu printer-model registry — drives the @BBL <code> name
   // fallback in slicerPrinterMatch for cloud / standard presets (#1325).
   // Long staleTime: the registry only changes across backend releases.
@@ -546,7 +601,7 @@ export function SliceModal({ source, onClose, defaultAutoArrange = false }: Slic
   }, [onlyConnectedModels, loadedPrinters, printerModels, printerParents]);
   const filamentFilter = useMemo(() => {
     if (!onlyLoadedSpools || loadedFilamentRefs.size === 0) return null;
-    return (p: UnifiedPreset) => loadedFilamentRefs.has(`${p.source}:${p.id}`);
+    return (p: UnifiedPreset) => loadedFilamentRefs.has(toRefValue(p));
   }, [onlyLoadedSpools, loadedFilamentRefs]);
   // The preset listing with only the loaded spools' filaments, for the
   // auto-pick while that filter is on.
@@ -990,15 +1045,18 @@ export function SliceModal({ source, onClose, defaultAutoArrange = false }: Slic
                     // Apply slot state. The filament list is right-padded from
                     // current state so a pipeline with fewer entries than the
                     // current source's slot count keeps the existing tail.
+                    // A pipeline saved with a stock Bambu Cloud preset gets
+                    // the Standard copy the dialog lists instead (#3386).
+                    const listed = (ref: PresetRef) => (presetsQuery.data ? listedRef(presetsQuery.data, ref) : ref);
                     explicitPrinter.current = true;
-                    setPrinterPreset(picked.printer_preset);
-                    setProcessPreset(picked.process_preset);
+                    setPrinterPreset(listed(picked.printer_preset));
+                    setProcessPreset(listed(picked.process_preset));
                     setBedType(picked.bed_type);
                     setFilamentPresets((current) => {
                       const next = current.length > 0 ? [...current] : picked.filament_presets.map(() => null);
                       for (let i = 0; i < next.length; i++) {
                         if (i < picked.filament_presets.length) {
-                          next[i] = picked.filament_presets[i];
+                          next[i] = listed(picked.filament_presets[i]);
                           // Applying a pipeline is as deliberate as picking
                           // from the dropdown, so the slots it fills are
                           // exempt from the material re-pick too.
@@ -1148,7 +1206,7 @@ export function SliceModal({ source, onClose, defaultAutoArrange = false }: Slic
               <PresetDropdown
                 label={t('slice.printer')}
                 slot="printer"
-                data={presetsQuery.data}
+                data={sortedPresets ?? presetsQuery.data}
                 value={printerPreset}
                 onChange={(ref) => {
                   explicitPrinter.current = true;
@@ -1185,7 +1243,7 @@ export function SliceModal({ source, onClose, defaultAutoArrange = false }: Slic
               <PresetDropdown
                 label={t('slice.process')}
                 slot="process"
-                data={presetsQuery.data}
+                data={sortedPresets ?? presetsQuery.data}
                 value={processPreset}
                 onChange={setProcessPreset}
                 disabled={isEnqueuing || useEmbedded}
@@ -1275,7 +1333,7 @@ export function SliceModal({ source, onClose, defaultAutoArrange = false }: Slic
                       key={`filament-${idx}`}
                       label={label}
                       slot="filament"
-                      data={presetsQuery.data}
+                      data={sortedPresets ?? presetsQuery.data}
                       value={filamentPresets[idx] ?? null}
                       onChange={(ref) => {
                         explicitFilamentSlots.current.add(idx);
@@ -1677,6 +1735,10 @@ function PresetDropdown({
   // The colour input needs its own id so the hex beside it can be a <label>
   // for it rather than for the preset <select> it sits next to.
   const colourId = `${selectId}-colour`;
+  const [searching, setSearching] = useState(false);
+  const [query, setQuery] = useState('');
+  const searchButton = useRef<HTMLButtonElement>(null);
+  const selectedRefValue = toRefValue(value);
 
   // Tier sections (imported → cloud → standard), plus — for a process /
   // filament slot with a selected printer — a trailing group of presets that
@@ -1755,28 +1817,55 @@ function PresetDropdown({
     return { sections: compatSections, otherEntries: other, filteredEntries: [] as UnifiedPreset[] };
   }, [data, slot, t, selectedPrinterName, compatIndex, allowed]);
 
+  // Search narrows what is listed, never what is chosen: every word must
+  // appear in the name, and the selected preset always stays (#3386). Every
+  // dropdown has it, so it is always where you look for it; the box opens from
+  // a button so a dialog full of filament rows stays compact. A query looks
+  // through the held-back groups too, which keep their labels.
+  const words = searching ? query.toLowerCase().split(/\s+/).filter(Boolean) : [];
+  const expanded = showAll || words.length > 0;
+
   // 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
   // compatibility is merely *unknown* (it never reaches otherEntries), and the
   // one currently selected — a pipeline or an auto-pick can land on a
   // cross-printer preset, and dropping it from the options would blank the
-  // select and silently discard the choice.
-  const selectedRefValue = toRefValue(value);
+  // select and silently discard the choice. These groups mix tiers, so they
+  // are sorted once they are shown.
   const visibleOther = useMemo(() => {
-    if (showAll) return otherEntries;
-    return otherEntries.filter((p) => `${p.source}:${p.id}` === selectedRefValue);
-  }, [showAll, otherEntries, selectedRefValue]);
+    if (expanded) return sortPresets(otherEntries, slot);
+    return otherEntries.filter((p) => toRefValue(p) === selectedRefValue);
+  }, [expanded, otherEntries, slot, selectedRefValue]);
 
   const visibleFiltered = useMemo(() => {
-    if (showAll) return filteredEntries;
-    return filteredEntries.filter((p) => `${p.source}:${p.id}` === selectedRefValue);
-  }, [showAll, filteredEntries, selectedRefValue]);
+    if (expanded) return sortPresets(filteredEntries, slot);
+    return filteredEntries.filter((p) => toRefValue(p) === selectedRefValue);
+  }, [expanded, filteredEntries, slot, selectedRefValue]);
 
   const hiddenCount =
     otherEntries.length - visibleOther.length + filteredEntries.length - visibleFiltered.length;
   const totalEntries =
     sections.reduce((sum, s) => sum + s.entries.length, 0) + visibleOther.length + visibleFiltered.length;
 
+  const closeSearch = () => {
+    setSearching(false);
+    setQuery('');
+  };
+  const nameMatches = (p: UnifiedPreset) => words.every((w) => p.name.toLowerCase().includes(w));
+  const matches = (p: UnifiedPreset) => nameMatches(p) || toRefValue(p) === selectedRefValue;
+  const noMatches =
+    words.length > 0 &&
+    !sections.some((s) => s.entries.some(nameMatches)) &&
+    !visibleFiltered.some(nameMatches) &&
+    !visibleOther.some(nameMatches);
+  const shownSections = words.length
+    ? sections
+        .map((s) => ({ tierLabel: s.tierLabel, entries: s.entries.filter(matches) }))
+        .filter((s) => s.entries.length > 0)
+    : sections;
+  const shownFiltered = words.length ? visibleFiltered.filter(matches) : visibleFiltered;
+  const shownOther = words.length ? visibleOther.filter(matches) : visibleOther;
+
   return (
     // A plain wrapper rather than a <label> around everything: the "Show all"
     // control is a button, and a button inside a label that also wraps the
@@ -1795,13 +1884,13 @@ function PresetDropdown({
           />
         )}
         <label htmlFor={selectId}>{label}</label>
-        {(hiddenCount > 0 || showAll) && (
-          <span className="ml-auto flex items-center gap-1.5 font-normal">
-            {hiddenCount > 0 && (
-              <span className="text-bambu-gray/60">
-                {t('slice.presetsHidden', '{{count}} hidden', { count: hiddenCount })}
-              </span>
-            )}
+        <span className="ml-auto flex items-center gap-1.5 font-normal">
+          {hiddenCount > 0 && (
+            <span className="text-bambu-gray/60">
+              {t('slice.presetsHidden', '{{count}} hidden', { count: hiddenCount })}
+            </span>
+          )}
+          {(hiddenCount > 0 || showAll) && (
             <button
               type="button"
               onClick={() => setShowAll((v) => !v)}
@@ -1812,13 +1901,49 @@ function PresetDropdown({
                 ? t('slice.showFewerPresets', 'Show fewer')
                 : t('slice.showAllPresets', 'Show all')}
             </button>
-          </span>
-        )}
+          )}
+          <button
+            ref={searchButton}
+            type="button"
+            onClick={() => (searching ? closeSearch() : setSearching(true))}
+            disabled={disabled}
+            aria-label={`${label}: ${searching ? t('slice.closeSearch', 'Close search') : t('common.search', 'Search')}`}
+            title={searching ? t('slice.closeSearch', 'Close search') : t('common.search', 'Search')}
+            className={`hover:text-white disabled:opacity-50 ${searching ? 'text-bambu-green' : ''}`}
+          >
+            <Search className="w-3.5 h-3.5" />
+          </button>
+        </span>
       </div>
+      {searching && (
+        <input
+          type="search"
+          value={query}
+          onChange={(e) => setQuery(e.target.value)}
+          onKeyDown={(e) => {
+            if (e.key !== 'Escape') return;
+            closeSearch();
+            // The input unmounts, which would leave focus on <body>.
+            searchButton.current?.focus();
+          }}
+          autoFocus
+          disabled={disabled}
+          placeholder={t('slice.searchPresets', 'Search presets...')}
+          aria-label={`${label}: ${t('slice.searchPresets', 'Search presets...')}`}
+          className="w-full px-3 py-1.5 mb-1 rounded-md bg-bambu-dark border border-bambu-dark-tertiary text-white text-sm placeholder:text-bambu-gray focus:outline-none focus:border-bambu-gray disabled:opacity-50"
+        />
+      )}
+      {/* Mounted with the search box and only its text changes, so screen
+          readers announce it when a query stops matching. */}
+      {searching && (
+        <p role="status" className={noMatches ? 'text-xs text-bambu-gray mb-1' : 'sr-only'}>
+          {noMatches ? t('slice.noMatchingPresets', 'No matching presets found.') : ''}
+        </p>
+      )}
       <div className="flex items-stretch gap-2">
       <select
         id={selectId}
-        value={toRefValue(value)}
+        value={selectedRefValue}
         onChange={(e) => onChange(fromRefValue(e.target.value))}
         disabled={disabled || totalEntries === 0}
         className="w-full px-3 py-2 rounded-md bg-bambu-dark border border-bambu-dark-tertiary text-white text-sm focus:outline-none focus:border-bambu-gray disabled:opacity-50"
@@ -1828,28 +1953,28 @@ function PresetDropdown({
             ? t('slice.noPresetsForSlot')
             : t('slice.selectPreset')}
         </option>
-        {sections.map((section) => (
+        {shownSections.map((section) => (
           <optgroup key={section.tierLabel} label={section.tierLabel}>
             {section.entries.map((p) => (
-              <option key={`${p.source}:${p.id}`} value={`${p.source}:${p.id}`}>
+              <option key={toRefValue(p)} value={toRefValue(p)}>
                 {p.name}
               </option>
             ))}
           </optgroup>
         ))}
-        {visibleFiltered.length > 0 && (
+        {shownFiltered.length > 0 && (
           <optgroup label={filteredLabel ?? t('slice.otherPrinters')}>
-            {visibleFiltered.map((p) => (
-              <option key={`${p.source}:${p.id}`} value={`${p.source}:${p.id}`}>
+            {shownFiltered.map((p) => (
+              <option key={toRefValue(p)} value={toRefValue(p)}>
                 {p.name}
               </option>
             ))}
           </optgroup>
         )}
-        {visibleOther.length > 0 && (
+        {shownOther.length > 0 && (
           <optgroup label={t('slice.otherPrinters')}>
-            {visibleOther.map((p) => (
-              <option key={`${p.source}:${p.id}`} value={`${p.source}:${p.id}`}>
+            {shownOther.map((p) => (
+              <option key={toRefValue(p)} value={toRefValue(p)}>
                 {p.name}
               </option>
             ))}

+ 3 - 1
frontend/src/components/SlicerPipelinesPanel.tsx

@@ -13,12 +13,14 @@ import {
 } from '../api/client';
 import { Card, CardContent, CardHeader } from './Card';
 import { useToast } from '../contexts/ToastContext';
+import { listedRef } from '../utils/slicePresetPicker';
 
 // Resolve a PresetRef back to its pretty name via the unified-presets listing.
 // Returns null when the ref no longer points at a known preset — render a
 // "deleted" badge in that case so users can see what to fix.
-function resolveName(presets: UnifiedPresetsResponse | undefined, slot: 'printer' | 'process' | 'filament', ref: PresetRef): string | null {
+function resolveName(presets: UnifiedPresetsResponse | undefined, slot: 'printer' | 'process' | 'filament', saved: PresetRef): string | null {
   if (!presets) return null;
+  const ref = listedRef(presets, saved);
   const list = presets[ref.source]?.[slot] ?? [];
   const hit = list.find((p) => p.id === ref.id);
   return hit ? hit.name : null;

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

@@ -4839,6 +4839,9 @@ export default {
     presetsHidden: '{{count}} ausgeblendet',
     showAllPresets: 'Alle anzeigen',
     showFewerPresets: 'Weniger anzeigen',
+    searchPresets: 'Voreinstellungen suchen...',
+    closeSearch: 'Suche schließen',
+    noMatchingPresets: 'Keine passenden Voreinstellungen gefunden.',
     processSettings: 'Prozesseinstellungen',
     processSettingsHint: 'Passen Sie das gewählte Profil für diesen Slice an. Alles, was Sie nicht ändern, bleibt wie im Profil definiert.',
     processSettingsChanged: '{{count}} geändert',

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

@@ -4876,6 +4876,9 @@ export default {
     presetsHidden: '{{count}} hidden',
     showAllPresets: 'Show all',
     showFewerPresets: 'Show fewer',
+    searchPresets: 'Search presets...',
+    closeSearch: 'Close search',
+    noMatchingPresets: 'No matching presets found.',
     processSettings: 'Process settings',
     processSettingsHint: "Adjust the picked preset for this slice. Anything you don't touch stays as the preset defines it.",
     processSettingsChanged: '{{count}} changed',

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

@@ -4841,6 +4841,9 @@ export default {
     presetsHidden: '{{count}} ocultos',
     showAllPresets: 'Mostrar todos',
     showFewerPresets: 'Mostrar menos',
+    searchPresets: 'Buscar preajustes...',
+    closeSearch: 'Cerrar búsqueda',
+    noMatchingPresets: 'No se encontraron preajustes coincidentes.',
     processSettings: 'Ajustes de proceso',
     processSettingsHint: 'Ajusta el perfil seleccionado para este corte. Todo lo que no toques se mantiene como lo define el perfil.',
     processSettingsChanged: '{{count}} cambiados',

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

@@ -4827,6 +4827,9 @@ export default {
     presetsHidden: '{{count}} masqués',
     showAllPresets: 'Tout afficher',
     showFewerPresets: 'Afficher moins',
+    searchPresets: 'Chercher presets...',
+    closeSearch: 'Fermer la recherche',
+    noMatchingPresets: 'Aucun profil trouvé.',
     processSettings: 'Paramètres de process',
     processSettingsHint: 'Ajustez le profil choisi pour ce découpage. Tout ce que vous ne modifiez pas reste tel que défini par le profil.',
     processSettingsChanged: '{{count}} modifiés',

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

@@ -4826,6 +4826,9 @@ export default {
     presetsHidden: '{{count}} nascosti',
     showAllPresets: 'Mostra tutti',
     showFewerPresets: 'Mostra meno',
+    searchPresets: 'Cerca preset...',
+    closeSearch: 'Chiudi ricerca',
+    noMatchingPresets: 'Nessun preset corrispondente trovato.',
     processSettings: 'Impostazioni di processo',
     processSettingsHint: 'Regola il profilo scelto per questo slice. Tutto ciò che non tocchi resta come definito dal profilo.',
     processSettingsChanged: '{{count}} modificate',

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

@@ -4839,6 +4839,9 @@ export default {
     presetsHidden: '{{count}} 件を非表示',
     showAllPresets: 'すべて表示',
     showFewerPresets: '表示を減らす',
+    searchPresets: 'プリセットを検索...',
+    closeSearch: '検索を閉じる',
+    noMatchingPresets: '一致するプリセットが見つかりません。',
     processSettings: 'プロセス設定',
     processSettingsHint: 'このスライス用に選択したプリセットを調整します。変更しない項目はプリセットの定義のままです。',
     processSettingsChanged: '{{count}} 件変更',

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

@@ -4631,6 +4631,9 @@ export default {
     presetsHidden: '{{count}}개 숨김',
     showAllPresets: '모두 표시',
     showFewerPresets: '간략히 표시',
+    searchPresets: '프리셋 검색...',
+    closeSearch: '검색 닫기',
+    noMatchingPresets: '일치하는 프리셋을 찾을 수 없습니다.',
     processSettings: '프로세스 설정',
     processSettingsHint: '이 슬라이스에 사용할 프리셋을 조정합니다. 건드리지 않은 항목은 프리셋 정의를 그대로 따릅니다.',
     processSettingsChanged: '{{count}}개 변경됨',

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

@@ -4874,6 +4874,9 @@ export default {
     presetsHidden: '{{count}} verborgen',
     showAllPresets: 'Alles tonen',
     showFewerPresets: 'Minder tonen',
+    searchPresets: 'Presets zoeken...',
+    closeSearch: 'Zoeken sluiten',
+    noMatchingPresets: 'Geen overeenkomende presets gevonden.',
     processSettings: 'Procesinstellingen',
     processSettingsHint: 'Pas de gekozen preset aan voor deze slice. Alles wat je niet aanraakt blijft zoals de preset het definieert.',
     processSettingsChanged: '{{count}} gewijzigd',

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

@@ -4826,6 +4826,9 @@ export default {
     presetsHidden: '{{count}} ocultos',
     showAllPresets: 'Mostrar todos',
     showFewerPresets: 'Mostrar menos',
+    searchPresets: 'Pesquisar predefinições...',
+    closeSearch: 'Fechar pesquisa',
+    noMatchingPresets: 'Nenhuma predefinição correspondente encontrada.',
     processSettings: 'Configurações de processo',
     processSettingsHint: 'Ajuste o perfil escolhido para este fatiamento. Tudo o que você não alterar permanece como o perfil define.',
     processSettingsChanged: '{{count}} alterados',

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

@@ -4623,6 +4623,9 @@ export default {
     presetsHidden: 'скрыто: {{count}}',
     showAllPresets: 'Показать все',
     showFewerPresets: 'Показать меньше',
+    searchPresets: "Поиск профилей...",
+    closeSearch: "Закрыть поиск",
+    noMatchingPresets: "Подходящие профили не найдены.",
     processSettings: 'Параметры процесса',
     processSettingsHint: 'Настройте выбранный профиль для этой нарезки. Всё, что вы не измените, останется как задано в профиле.',
     processSettingsChanged: 'изменено: {{count}}',

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

@@ -4873,6 +4873,9 @@ errors: {
     presetsHidden: '{{count}} dold',
     showAllPresets: 'Visa alla',
     showFewerPresets: 'Visa färre',
+    searchPresets: 'Sök förinställningar...',
+    closeSearch: 'Stäng sökning',
+    noMatchingPresets: 'Inga matchande förinställningar hittade.',
     processSettings: 'Processinställningar',
     processSettingsHint: "Justera den valda förinställningen för denna slicing. Allt du inte rör förblir som förinställningen definierar det.",
     processSettingsChanged: '{{count}} ändrades',

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

@@ -4828,6 +4828,9 @@ export default {
     presetsHidden: '{{count}} gizli',
     showAllPresets: 'Tümünü göster',
     showFewerPresets: 'Daha az göster',
+    searchPresets: 'Ön ayarlarda ara...',
+    closeSearch: 'Aramayı kapat',
+    noMatchingPresets: 'Eşleşen ön ayar bulunamadı.',
     processSettings: 'İşlem ayarları',
     processSettingsHint: 'Seçilen ön ayarı bu dilimleme için düzenleyin. Dokunmadığınız her şey ön ayardaki gibi kalır.',
     processSettingsChanged: '{{count}} değişti',

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

@@ -4871,6 +4871,9 @@ export default {
     presetsHidden: 'приховано: {{count}}',
     showAllPresets: 'Показати всі',
     showFewerPresets: 'Показати менше',
+    searchPresets: "Шукати профілі...",
+    closeSearch: "Закрити пошук",
+    noMatchingPresets: "Відповідних профілів не знайдено.",
     processSettings: 'Параметри процесу',
     processSettingsHint: 'Налаштуйте вибраний профіль для цього нарізання. Усе, чого ви не змінили, лишається як визначено профілем.',
     processSettingsChanged: 'змінено: {{count}}',

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

@@ -4826,6 +4826,9 @@ export default {
     presetsHidden: '已隐藏 {{count}} 项',
     showAllPresets: '显示全部',
     showFewerPresets: '显示较少',
+    searchPresets: '搜索预设...',
+    closeSearch: '关闭搜索',
+    noMatchingPresets: '未找到匹配的预设。',
     processSettings: '工艺设置',
     processSettingsHint: '为本次切片调整所选预设。未改动的项目仍按预设定义。',
     processSettingsChanged: '已更改 {{count}} 项',

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

@@ -4826,6 +4826,9 @@ export default {
     presetsHidden: '已隱藏 {{count}} 項',
     showAllPresets: '顯示全部',
     showFewerPresets: '顯示較少',
+    searchPresets: '搜尋預設...',
+    closeSearch: '關閉搜尋',
+    noMatchingPresets: '未找到匹配的預設。',
     processSettings: '列印參數',
     processSettingsHint: '為本次切片調整所選預設。未變更的項目仍依預設定義。',
     processSettingsChanged: '已變更 {{count}} 項',

+ 6 - 9
frontend/src/utils/sliceLoadedSpools.ts

@@ -32,17 +32,13 @@ import type {
   UnifiedPresetsResponse,
 } from '../api/client';
 import { slotPresetDescribesTray } from './amsHelpers';
-import { SLICE_MODAL_TIER_ORDER, statesDifferentMaterial } from './slicePresetPicker';
+import { listedRef, refValue, 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.
@@ -155,13 +151,14 @@ export function savedPresetFor(tray: LoadedSpoolTray): LoadedSpoolPreset | null
   return slotPresetDescribesTray(saved.preset_id, tray.tray_info_idx, saved.tray_info_idx) ? saved : null;
 }
 
-function savedRef(saved: LoadedSpoolPreset): PresetRef | null {
+function savedRef(saved: LoadedSpoolPreset, data: UnifiedPresetsResponse): 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;
+    // A ref the listing hides is pointed at the twin it lists (#3386).
+    return saved.preset_id ? listedRef(data, { 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.
@@ -231,14 +228,14 @@ export function matchSlotPreset(
   const fits = (p: UnifiedPreset) => presetCompatibility(p, 'filament', selectedPrinterName, compatIndex);
 
   if (saved) {
-    const ref = savedRef(saved);
+    const ref = savedRef(saved, data);
     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;
+  const savedSource = saved ? savedRef(saved, data)?.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.

+ 11 - 0
frontend/src/utils/slicePresetPicker.ts

@@ -46,6 +46,17 @@ const TIER_BONUS: Record<PresetSource, number> = {
   standard: 0.5,
 };
 
+export function refValue(ref: PresetRef): string {
+  return `${ref.source}:${ref.id}`;
+}
+
+// The listing drops the Standard presets that Bambu Cloud's stock list also
+// has (#3386). A ref saved with one of those — a pipeline, an AMS slot's
+// preset — is pointed at the cloud twin the listing names instead.
+export function listedRef(by: UnifiedPresetsResponse, ref: PresetRef): PresetRef {
+  return by.stock_aliases?.[refValue(ref)] ?? ref;
+}
+
 export function pickDefault(by: UnifiedPresetsResponse, slot: Slot): PresetRef | null {
   for (const tier of SLICE_MODAL_TIER_ORDER) {
     const list = by[tier][slot];