Преглед на файлове

fix(slicer): classify filament profiles by their real printer scope, not just their name (#2628 follow-up)

Slicing for a P2S failed with "filament preset Bambu PLA Basic @BBL X1C 0.2
nozzle (slot 1) is not compatible with printer Bambu Lab P2S 0.4 nozzle" —
naming a profile shown nowhere in the dialog. The picked profile was
"Overture PLA Matte @0.2", whose inheritance chain roots in that X1C profile.

The dialog classifies a profile by its compatible_printers list and falls back
to reading the printer out of its name. That name carries no model, and the
list — present on the imported copy — is not shipped by every source: Bambu
Cloud omits it deliberately (rate limits), and Orca Cloud shipped it but
Bambuddy only mined filament type and colour from the same content.

Orca Cloud entries now carry their own compatible_printers, and the existing
same-name enrichment bridge carries the list onto entries that lack one, in
both directions between the cloud tiers. A bare "@<size>" name tag is read as
a nozzle size as a last resort: it can rule a printer out but never rules one
in, and implausible values are ignored rather than guessed at.
maziggy преди 1 месец
родител
ревизия
fdd6ec416f

Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
CHANGELOG.md


+ 5 - 3
backend/app/api/routes/library.py

@@ -3590,9 +3590,11 @@ async def _run_slicer_with_fallback(
     # (e.g. ABS in slot 2 next to a PLA in the used slot 1) makes
     # (e.g. ABS in slot 2 next to a PLA in the used slot 1) makes
     # BambuStudio reject the slice with "the temperature difference of
     # BambuStudio reject the slice with "the temperature difference of
     # the filaments used is too large" (exit 194) even though the G-code
     # the filaments used is too large" (exit 194) even though the G-code
-    # never touches the unused slot. Replace unused-slot entries with the
-    # slot-1 selection before the real slice so the loaded-filament set
-    # is materially homogeneous.
+    # never touches the unused slot; a default scoped to another printer
+    # gets it rejected with "filament preset (slot N) is not compatible
+    # with printer …" (#2628). Replace unused-slot entries with the
+    # plate's lowest used slot before the real slice so the loaded set is
+    # materially homogeneous and printer-correct.
     if is_3mf and request.plate is not None:
     if is_3mf and request.plate is not None:
         from backend.app.services.slicer_3mf_convert import substitute_unused_plate_filaments
         from backend.app.services.slicer_3mf_convert import substitute_unused_plate_filaments
 
 

+ 63 - 8
backend/app/api/routes/slicer_presets.py

@@ -259,15 +259,23 @@ async def _fetch_orca_cloud_presets(
                     filament_colour = fc[0]
                     filament_colour = fc[0]
                 elif isinstance(fc, str):
                 elif isinstance(fc, str):
                     filament_colour = fc
                     filament_colour = fc
-            slots[slot].append(
-                UnifiedPreset(
-                    id=str(preset_id),
-                    name=str(name),
-                    source="orca_cloud",
-                    filament_type=filament_type,
-                    filament_colour=filament_colour,
-                )
+            preset = UnifiedPreset(
+                id=str(preset_id),
+                name=str(name),
+                source="orca_cloud",
+                filament_type=filament_type,
+                filament_colour=filament_colour,
             )
             )
+            if slot in ("process", "filament"):
+                # The profile's own compatible-printer list, straight out of
+                # the content Orca already hands us (#2628). Without it the
+                # SliceModal falls back to reading the printer out of the
+                # profile NAME — and a profile whose name carries no model
+                # ("Overture PLA Matte @0.2") then reads as "can't tell",
+                # which the picker treats as usable and auto-picks for a
+                # printer the profile was never built for.
+                preset.compatible_printers = _content_compatible_printers(content)
+            slots[slot].append(preset)
         _orca_cloud_cache[cache_key] = (now, slots)
         _orca_cloud_cache[cache_key] = (now, slots)
         return slots, "ok"
         return slots, "ok"
     finally:
     finally:
@@ -297,6 +305,25 @@ async def _fetch_local_presets(db: AsyncSession) -> dict[str, list[UnifiedPreset
     return slots
     return slots
 
 
 
 
+def _content_compatible_printers(content: dict) -> list[str] | None:
+    """Pull ``compatible_printers`` out of an inline profile content dict.
+
+    Orca profiles carry it as a list of printer-preset names (the same shape
+    ``orca_profiles.py`` stores on import); a single-printer profile may store
+    a bare string. Returns ``None`` for missing / empty / malformed values so
+    the caller leaves the field unset and the SliceModal falls back to the
+    name-based matcher, rather than treating "no data" as "compatible with
+    nothing".
+    """
+    raw = content.get("compatible_printers")
+    if isinstance(raw, str):
+        raw = [raw]
+    if not isinstance(raw, list):
+        return None
+    names = [s.strip() for s in raw if isinstance(s, str) and s.strip()]
+    return names or None
+
+
 def _parse_compatible_printers(raw: str | None) -> list[str] | None:
 def _parse_compatible_printers(raw: str | None) -> list[str] | None:
     """``LocalPreset.compatible_printers`` stores a JSON array of printer-preset
     """``LocalPreset.compatible_printers`` stores a JSON array of printer-preset
     names. Return the parsed list, or ``None`` on missing / malformed data so
     names. Return the parsed list, or ``None`` on missing / malformed data so
@@ -442,6 +469,16 @@ def _enrich_cloud_metadata(
     in ``local`` / ``orca_cloud`` / ``standard``. This is the only reason
     in ``local`` / ``orca_cloud`` / ``standard``. This is the only reason
     this function exists post-#1712 — without the enrich the Bambu Cloud
     this function exists post-#1712 — without the enrich the Bambu Cloud
     tier can't score in ``pickFilamentForSlot``.
     tier can't score in ``pickFilamentForSlot``.
+
+    Compatibility merge (#2628): the same name bridge carries
+    ``compatible_printers`` onto any process / filament entry that lacks it.
+    Bambu Cloud never ships the list, so a profile whose NAME carries no
+    printer model reads as "compatibility unknown" — which the SliceModal
+    treats as usable and auto-picks for whatever printer is selected. When
+    the very same profile is also present as a local import or an Orca Cloud
+    profile, that copy states the truth; borrowing it turns the auto-pick
+    into a correctly-rejected mismatch. Only ever fills a gap: an entry that
+    carries its own list keeps it.
     """
     """
     # Build a name → metadata lookup from the tiers that carry it (local,
     # Build a name → metadata lookup from the tiers that carry it (local,
     # orca_cloud, standard). Bambu cloud is intentionally skipped — it
     # orca_cloud, standard). Bambu cloud is intentionally skipped — it
@@ -464,6 +501,24 @@ def _enrich_cloud_metadata(
             if p.filament_colour is None and c is not None:
             if p.filament_colour is None and c is not None:
                 p.filament_colour = c
                 p.filament_colour = c
 
 
+    # Compatibility bridge (#2628). Runs over both slots that carry the
+    # list, and in both directions between the cloud tiers — whichever copy
+    # of a profile knows its printers teaches the ones that don't.
+    for slot in ("process", "filament"):
+        compat_by_name: dict[str, list[str]] = {}
+        for tier in (local, orca_cloud, cloud, standard):
+            for p in tier[slot]:
+                if p.compatible_printers and p.name not in compat_by_name:
+                    compat_by_name[p.name] = p.compatible_printers
+        if not compat_by_name:
+            continue
+        for tier in (orca_cloud, cloud):
+            for p in tier[slot]:
+                if not p.compatible_printers:
+                    borrowed = compat_by_name.get(p.name)
+                    if borrowed:
+                        p.compatible_printers = list(borrowed)
+
     return orca_cloud, cloud, local, standard
     return orca_cloud, cloud, local, standard
 
 
 
 

+ 33 - 12
backend/app/services/slicer_3mf_convert.py

@@ -237,20 +237,31 @@ def merge_plate_3mfs(
 
 
 def substitute_unused_plate_filaments(source_3mf_bytes: bytes, plate_id: int | None, items: list[str]) -> list[str]:
 def substitute_unused_plate_filaments(source_3mf_bytes: bytes, plate_id: int | None, items: list[str]) -> list[str]:
     """Replace any filament-list entry whose 1-indexed slot isn't used by
     """Replace any filament-list entry whose 1-indexed slot isn't used by
-    ``plate_id`` with the entry at slot 1 (index 0).
+    ``plate_id`` with the entry from the plate's lowest *used* slot.
 
 
     Why: the slice modal lets the user pick a filament profile per slot,
     Why: the slice modal lets the user pick a filament profile per slot,
     but each plate in a multi-plate project only uses a subset of those
     but each plate in a multi-plate project only uses a subset of those
     slots. The modal labels the unused rows "not used by this plate" yet
     slots. The modal labels the unused rows "not used by this plate" yet
     still submits their dropdown values. BambuStudio then validates every
     still submits their dropdown values. BambuStudio then validates every
-    loaded filament for material compatibility — PLA in a used slot +
-    ABS defaulted into an unused slot trips
-    "the temperature difference of the filaments used is too large"
-    (exit 194), even though the plate's G-code never touches the ABS
-    slot. Substituting unused entries with slot 1's filament keeps the
-    per-filament array length intact (so the source 3MF's per-slot
-    references stay valid) while making the loaded-filament set
-    materially homogeneous, so the validator passes.
+    loaded filament — for material compatibility (PLA in a used slot +
+    ABS defaulted into an unused slot trips "the temperature difference
+    of the filaments used is too large", exit 194) and for printer
+    compatibility ("filament preset X (slot N) is not compatible with
+    printer Y", exit -5) — even though the plate's G-code never touches
+    the unused slot. Substituting unused entries with a used slot's
+    filament keeps the per-filament array length intact (so the source
+    3MF's per-slot references stay valid) while making the loaded set
+    both materially homogeneous and printer-correct, so both validators
+    pass.
+
+    The anchor is the lowest used slot, NOT slot 1 (#2628). Slot 1 is
+    itself unused on plenty of plates, and anchoring there did the two
+    things this function exists to prevent: the substitution became a
+    no-op for the slot that needed it most, and — with more than one
+    unused slot — it propagated slot 1's own preset (in the reported
+    case an ``@Bambu Lab H2D`` profile baked into the source 3MF) into
+    every other unused slot, blocking an A1 slice on slots the plate
+    doesn't even use.
 
 
     The substitution is a no-op when:
     The substitution is a no-op when:
     - ``plate_id`` is None (we can't determine which slots are unused),
     - ``plate_id`` is None (we can't determine which slots are unused),
@@ -288,16 +299,26 @@ def substitute_unused_plate_filaments(source_3mf_bytes: bytes, plate_id: int | N
         # than to silently rewrite them.
         # than to silently rewrite them.
         return items
         return items
     out = list(items)
     out = list(items)
+    # Anchor on the lowest used slot that actually exists in the list. A
+    # plate can reference a slot beyond the submitted list (a truncated or
+    # mismatched pick set) — those can't be an anchor, and if none of the
+    # used slots is in range there is nothing trustworthy to copy from, so
+    # leave the user's picks alone rather than inventing a substitution.
+    in_range_used = sorted(s for s in used if 1 <= s <= len(out))
+    if not in_range_used:
+        return items
+    anchor_slot = in_range_used[0]
     substituted = []
     substituted = []
     for idx in range(len(out)):
     for idx in range(len(out)):
         slot = idx + 1
         slot = idx + 1
         if slot not in used:
         if slot not in used:
             substituted.append(slot)
             substituted.append(slot)
-            out[idx] = out[0]
+            out[idx] = out[anchor_slot - 1]
     if substituted:
     if substituted:
         logger.info(
         logger.info(
-            "Substituted slot-1 filament for unused slot(s) %s on plate %s "
-            "(avoids loaded-filament temp-spread validator)",
+            "Substituted slot-%s filament for unused slot(s) %s on plate %s "
+            "(avoids loaded-filament temp-spread and printer-compatibility validators)",
+            anchor_slot,
             substituted,
             substituted,
             plate_id,
             plate_id,
         )
         )

+ 76 - 4
backend/tests/unit/services/test_slicer_3mf_convert.py

@@ -253,10 +253,10 @@ def p1_project(zip_bytes: bytes) -> bytes:
 
 
 
 
 class TestSubstituteUnusedPlateFilaments:
 class TestSubstituteUnusedPlateFilaments:
-    """Slot 1 carries the used filament; unused-slot entries are
-    overwritten with slot 1 so BambuStudio's filament-temp validator
-    doesn't trip on heterogeneous loaded filaments that the plate's
-    G-code never actually touches."""
+    """Unused-slot entries are overwritten with the plate's lowest *used*
+    slot so BambuStudio's validators don't trip on loaded filaments the
+    plate's G-code never actually touches — neither the filament-temp
+    spread nor the preset-vs-printer compatibility check."""
 
 
     @staticmethod
     @staticmethod
     def _model_settings_xml(per_plate_extruders: list[tuple[int, list[int]]]) -> bytes:
     def _model_settings_xml(per_plate_extruders: list[tuple[int, list[int]]]) -> bytes:
@@ -371,3 +371,75 @@ class TestSubstituteUnusedPlateFilaments:
         items = ["pla.json", "abs_never_used.json"]
         items = ["pla.json", "abs_never_used.json"]
         result = substitute_unused_plate_filaments(zip_bytes, plate_id=1, items=items)
         result = substitute_unused_plate_filaments(zip_bytes, plate_id=1, items=items)
         assert result == ["pla.json", "pla.json"]
         assert result == ["pla.json", "pla.json"]
+
+    # ---- #2628: the anchor is the lowest USED slot, not slot 1 ----------
+
+    def test_substitutes_from_first_used_slot_when_slot_1_is_unused(self):
+        """michaelklos's report: plate 2 of a multi-plate project uses only
+        slot 2, while slot 1 carries an ``@Bambu Lab H2D`` preset baked into
+        the source 3MF. Anchoring on slot 1 made the substitution a no-op for
+        the one slot that needed it, and the CLI rejected the A1 slice with
+        "filament preset (slot 1) is not compatible with printer
+        Bambu Lab A1 0.4 nozzle"."""
+        zip_bytes = _make_3mf({"Metadata/model_settings.config": self._model_settings_xml([(1, [1]), (2, [2])])})
+        items = ["tpu_at_h2d.json", "pla_at_a1.json"]
+
+        result = substitute_unused_plate_filaments(zip_bytes, plate_id=2, items=items)
+
+        assert result == ["pla_at_a1.json", "pla_at_a1.json"]
+
+    def test_unused_slot_1_does_not_poison_the_other_unused_slots(self):
+        """With more than one unused slot, the old anchor propagated slot 1's
+        own (foreign-printer) preset into every other unused slot — the exact
+        poisoning #1851 removed from the picker."""
+        zip_bytes = _make_3mf({"Metadata/model_settings.config": self._model_settings_xml([(1, [1]), (2, [3])])})
+        items = ["tpu_at_h2d.json", "abs.json", "pla_at_a1.json"]
+
+        result = substitute_unused_plate_filaments(zip_bytes, plate_id=2, items=items)
+
+        assert result == ["pla_at_a1.json", "pla_at_a1.json", "pla_at_a1.json"]
+
+    def test_anchor_is_the_lowest_used_slot_not_merely_a_used_one(self):
+        """Deterministic pick so the same project always slices identically."""
+        zip_bytes = _make_3mf({"Metadata/model_settings.config": self._model_settings_xml([(1, [1]), (2, [2, 4])])})
+        items = ["slot1.json", "slot2.json", "slot3.json", "slot4.json"]
+
+        result = substitute_unused_plate_filaments(zip_bytes, plate_id=2, items=items)
+
+        assert result == ["slot2.json", "slot2.json", "slot2.json", "slot4.json"]
+
+    def test_no_op_when_every_used_slot_is_outside_the_submitted_list(self):
+        """A plate referencing only slots beyond the picked list leaves nothing
+        trustworthy to copy from — keep the user's picks rather than invent a
+        substitution from a slot the plate doesn't use."""
+        zip_bytes = _make_3mf({"Metadata/model_settings.config": self._model_settings_xml([(1, [1]), (2, [5])])})
+        items = ["a.json", "b.json"]
+
+        result = substitute_unused_plate_filaments(zip_bytes, plate_id=2, items=items)
+
+        assert result == ["a.json", "b.json"]
+
+    def test_support_slot_can_be_the_anchor(self):
+        """The support-filament union (#1881) feeds the same used-slot set, so
+        a plate whose only geometry slot is 2 with PVA supports in 3 anchors on
+        2 — never on the unused slot 1."""
+        model_settings = self._model_settings_xml([(1, [1]), (2, [2])])
+        project_settings = json.dumps(
+            {
+                "enable_support": "1",
+                "support_filament": "3",
+                "support_interface_filament": "3",
+                "filament_type": ["PLA", "PLA", "PVA"],
+            }
+        ).encode()
+        zip_bytes = _make_3mf(
+            {
+                "Metadata/model_settings.config": model_settings,
+                "Metadata/project_settings.config": project_settings,
+            }
+        )
+        items = ["tpu_at_h2d.json", "pla.json", "pva.json"]
+
+        result = substitute_unused_plate_filaments(zip_bytes, plate_id=2, items=items)
+
+        assert result == ["pla.json", "pla.json", "pva.json"]

+ 147 - 0
backend/tests/unit/test_slicer_presets.py

@@ -155,6 +155,83 @@ class TestEnrichCloudMetadata:
         assert c["filament"][0].filament_colour == "#FFFFFF"
         assert c["filament"][0].filament_colour == "#FFFFFF"
 
 
 
 
+class TestEnrichCompatiblePrinters:
+    """#2628: the same name bridge carries ``compatible_printers`` onto the
+    tiers that don't ship one. Bambu Cloud never does — so a profile whose
+    name carries no printer model reads as "compatibility unknown", which the
+    SliceModal treats as usable and auto-picks for the wrong printer."""
+
+    COMPAT = ["Bambu Lab X1 Carbon 0.2 nozzle", "Bambu Lab P1S 0.2 nozzle"]
+
+    def _tier(self, source: str, slot: str, compat: list[str] | None) -> dict[str, list[UnifiedPreset]]:
+        empty: dict[str, list[UnifiedPreset]] = {"printer": [], "process": [], "filament": []}
+        empty[slot] = [
+            UnifiedPreset(id=f"{source}1", name="Overture PLA Matte @0.2", source=source, compatible_printers=compat)
+        ]
+        return empty
+
+    def test_bambu_cloud_borrows_the_list_from_a_same_named_local_import(self):
+        local = self._tier("local", "filament", self.COMPAT)
+        cloud = self._tier("cloud", "filament", None)
+
+        _oc, c, _l, _s = sp._enrich_cloud_metadata(_slot([]), cloud, local, _slot([]))
+
+        assert c["filament"][0].compatible_printers == self.COMPAT
+
+    def test_bambu_cloud_borrows_from_orca_cloud_too(self):
+        orca = self._tier("orca_cloud", "filament", self.COMPAT)
+        cloud = self._tier("cloud", "filament", None)
+
+        _oc, c, _l, _s = sp._enrich_cloud_metadata(orca, cloud, _slot([]), _slot([]))
+
+        assert c["filament"][0].compatible_printers == self.COMPAT
+
+    def test_orca_cloud_borrows_when_its_own_content_had_no_list(self):
+        orca = self._tier("orca_cloud", "filament", None)
+        local = self._tier("local", "filament", self.COMPAT)
+
+        oc, _c, _l, _s = sp._enrich_cloud_metadata(orca, _slot([]), local, _slot([]))
+
+        assert oc["filament"][0].compatible_printers == self.COMPAT
+
+    def test_process_slot_is_bridged_as_well(self):
+        local = self._tier("local", "process", self.COMPAT)
+        cloud = self._tier("cloud", "process", None)
+
+        _oc, c, _l, _s = sp._enrich_cloud_metadata(_slot([]), cloud, local, _slot([]))
+
+        assert c["process"][0].compatible_printers == self.COMPAT
+
+    def test_never_overwrites_a_list_the_entry_already_has(self):
+        own = ["Bambu Lab P2S 0.4 nozzle"]
+        local = self._tier("local", "filament", self.COMPAT)
+        cloud = self._tier("cloud", "filament", own)
+
+        _oc, c, _l, _s = sp._enrich_cloud_metadata(_slot([]), cloud, local, _slot([]))
+
+        assert c["filament"][0].compatible_printers == own
+
+    def test_no_donor_leaves_the_entry_unclassified(self):
+        """Absent evidence the entry must stay None — the SliceModal then
+        falls back to the name matcher instead of hiding the profile."""
+        cloud = self._tier("cloud", "filament", None)
+
+        _oc, c, _l, _s = sp._enrich_cloud_metadata(_slot([]), cloud, _slot([]), _slot([]))
+
+        assert c["filament"][0].compatible_printers is None
+
+    def test_borrowed_list_is_copied_not_shared(self):
+        """A later mutation of one tier's list must not reach through to the
+        other — these objects are cached per user between requests."""
+        local = self._tier("local", "filament", list(self.COMPAT))
+        cloud = self._tier("cloud", "filament", None)
+
+        _oc, c, l_, _s = sp._enrich_cloud_metadata(_slot([]), cloud, local, _slot([]))
+        l_["filament"][0].compatible_printers.append("Bambu Lab H2D 0.4 nozzle")
+
+        assert c["filament"][0].compatible_printers == self.COMPAT
+
+
 def _user_with_cloud_auth(user_id: int = 1) -> MagicMock:
 def _user_with_cloud_auth(user_id: int = 1) -> MagicMock:
     """Construct a mock User that passes the CLOUD_AUTH permission check.
     """Construct a mock User that passes the CLOUD_AUTH permission check.
 
 
@@ -273,6 +350,76 @@ class TestFetchOrcaCloudPresets:
         assert filament[0].filament_type == "PLA"
         assert filament[0].filament_type == "PLA"
         assert filament[0].filament_colour == "#000000"
         assert filament[0].filament_colour == "#000000"
 
 
+    @pytest.mark.asyncio
+    async def test_extracts_compatible_printers_from_content(self):
+        """#2628: Orca's sync_pull already carries the profile's own
+        compatible-printer list. Surfacing it lets the SliceModal reject a
+        profile built for another printer instead of falling back to reading
+        the model out of the NAME — which fails outright for names that carry
+        no model ("Overture PLA Matte @0.2")."""
+        sp._orca_cloud_cache.clear()
+        compat = ["Bambu Lab X1 Carbon 0.2 nozzle", "Bambu Lab P1S 0.2 nozzle"]
+        svc_mock = MagicMock()
+        svc_mock.list_profiles = AsyncMock(
+            return_value=[
+                {
+                    "id": "f1",
+                    "name": "Overture PLA Matte @0.2",
+                    "content": {
+                        "type": "filament",
+                        "filament_type": ["PLA"],
+                        "compatible_printers": compat,
+                    },
+                },
+                {
+                    "id": "p1",
+                    "name": "Orca 0.20mm",
+                    "content": {"type": "print", "compatible_printers": "Bambu Lab P2S 0.4 nozzle"},
+                },
+                {"id": "m1", "name": "Orca X1C", "content": {"type": "printer"}},
+            ]
+        )
+        svc_mock.close = AsyncMock()
+        user = MagicMock(id=1)
+        user.has_permission = MagicMock(return_value=True)
+        with (
+            patch.object(sp, "_load_orca_credentials", AsyncMock(return_value=self._orca_creds("tok"))),
+            patch.object(sp, "_build_orca_service", AsyncMock(return_value=svc_mock)),
+        ):
+            slots, status = await sp._fetch_orca_cloud_presets(MagicMock(), user)
+
+        assert status == "ok"
+        assert slots["filament"][0].compatible_printers == compat
+        # A single-printer profile may store a bare string — normalised to a list.
+        assert slots["process"][0].compatible_printers == ["Bambu Lab P2S 0.4 nozzle"]
+        # Printer presets have nothing to be compatible with.
+        assert slots["printer"][0].compatible_printers is None
+
+    @pytest.mark.asyncio
+    async def test_missing_or_malformed_compatible_printers_stays_none(self):
+        """No data must read as "unknown", never as "compatible with nothing" —
+        the SliceModal falls back to the name matcher for those."""
+        sp._orca_cloud_cache.clear()
+        svc_mock = MagicMock()
+        svc_mock.list_profiles = AsyncMock(
+            return_value=[
+                {"id": "f1", "name": "No list", "content": {"type": "filament"}},
+                {"id": "f2", "name": "Empty list", "content": {"type": "filament", "compatible_printers": []}},
+                {"id": "f3", "name": "Blanks", "content": {"type": "filament", "compatible_printers": ["", "  "]}},
+                {"id": "f4", "name": "Wrong type", "content": {"type": "filament", "compatible_printers": {"a": 1}}},
+            ]
+        )
+        svc_mock.close = AsyncMock()
+        user = MagicMock(id=1)
+        user.has_permission = MagicMock(return_value=True)
+        with (
+            patch.object(sp, "_load_orca_credentials", AsyncMock(return_value=self._orca_creds("tok"))),
+            patch.object(sp, "_build_orca_service", AsyncMock(return_value=svc_mock)),
+        ):
+            slots, _status = await sp._fetch_orca_cloud_presets(MagicMock(), user)
+
+        assert [p.compatible_printers for p in slots["filament"]] == [None, None, None, None]
+
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     async def test_cache_hit_skips_orca_call(self):
     async def test_cache_hit_skips_orca_call(self):
         """A second call within TTL must reuse the cached slots and NOT
         """A second call within TTL must reuse the cached slots and NOT

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

@@ -1348,3 +1348,83 @@ describe('pickFilamentForSlot — printer-compat contract (#1851)', () => {
     expect(pick).toEqual({ source: 'standard', id: 'Generic PLA @BBL H2C' });
     expect(pick).toEqual({ source: 'standard', id: 'Generic PLA @BBL H2C' });
   });
   });
 });
 });
+
+describe('pickFilamentForSlot — long-form printer tag (#2628)', () => {
+  const index = buildCompatibilityIndex({
+    'Bambu Lab A1': 'A1',
+    'Bambu Lab H2D': 'H2D',
+  });
+
+  it('never auto-picks a user-saved preset scoped to another printer', () => {
+    // michaelklos's registry: a cloud-tier user preset carrying the full
+    // "@Bambu Lab H2D 0.4 nozzle" tag outscores the A1 preset on tier bonus
+    // alone. Until the matcher learned the long form it classified 'unknown'
+    // — indistinguishable from compatible — so it won the slot, landed in a
+    // dropdown the modal disables (slot not used by the plate), and the CLI
+    // rejected the whole slice.
+    const presets = makeUnified({
+      cloud: {
+        printer: [],
+        process: [],
+        filament: [
+          {
+            id: 'sunlu-tpu-h2d',
+            name: 'SUNLU TPU 95A @Bambu Lab H2D 0.4 nozzle',
+            source: 'cloud',
+            filament_type: 'PLA',
+            filament_colour: '#FF0000',
+          },
+        ],
+      },
+      standard: {
+        printer: [],
+        process: [],
+        filament: [
+          {
+            id: 'Bambu PLA Basic @BBL A1',
+            name: 'Bambu PLA Basic @BBL A1',
+            source: 'standard',
+            filament_type: 'PLA',
+            filament_colour: '#FFFFFF',
+          },
+        ],
+      },
+    });
+
+    const pick = pickFilamentForSlot(
+      presets,
+      { type: 'PLA', color: '#FF0000' },
+      'Bambu Lab A1 0.4 nozzle',
+      index,
+    );
+
+    expect(pick).toEqual({ source: 'standard', id: 'Bambu PLA Basic @BBL A1' });
+  });
+
+  it('still picks a long-form preset for its own printer', () => {
+    const presets = makeUnified({
+      cloud: {
+        printer: [],
+        process: [],
+        filament: [
+          {
+            id: 'sunlu-tpu-h2d',
+            name: 'SUNLU TPU 95A @Bambu Lab H2D 0.4 nozzle',
+            source: 'cloud',
+            filament_type: 'TPU',
+            filament_colour: '#FF0000',
+          },
+        ],
+      },
+    });
+
+    const pick = pickFilamentForSlot(
+      presets,
+      { type: 'TPU', color: '#FF0000' },
+      'Bambu Lab H2D 0.4 nozzle',
+      index,
+    );
+
+    expect(pick).toEqual({ source: 'cloud', id: 'sunlu-tpu-h2d' });
+  });
+});

+ 124 - 0
frontend/src/__tests__/utils/slicerPrinterMatch.test.ts

@@ -361,3 +361,127 @@ describe('presetCompatibility with Bambu cloud A1M rename (#1649)', () => {
     ).toBe('mismatch');
     ).toBe('mismatch');
   });
   });
 });
 });
+
+describe('presetCompatibility — long-form @Bambu Lab printer tag (#2628)', () => {
+  const A1 = 'Bambu Lab A1 0.4 nozzle';
+  const A1_MINI = 'Bambu Lab A1 mini 0.4 nozzle';
+  const H2D = 'Bambu Lab H2D 0.4 nozzle';
+  const idx = buildCompatibilityIndex(PRINTER_MODELS);
+
+  it('flags a user-saved H2D filament preset as a mismatch on an A1', () => {
+    // michaelklos's report: this exact preset sat in an unused filament slot
+    // of a multi-plate 3MF. Classified 'unknown' it was treated as usable,
+    // auto-picked, and the CLI rejected the slice with "filament preset
+    // (slot 1) is not compatible with printer Bambu Lab A1 0.4 nozzle".
+    expect(
+      presetCompatibility({ name: 'SUNLU TPU 95A @Bambu Lab H2D 0.4 nozzle' }, 'filament', A1, idx),
+    ).toBe('mismatch');
+  });
+
+  it('matches the same preset against its own printer', () => {
+    expect(
+      presetCompatibility({ name: 'SUNLU TPU 95A @Bambu Lab H2D 0.4 nozzle' }, 'filament', H2D, idx),
+    ).toBe('match');
+  });
+
+  it('resolves a long-form model whose display name differs from its short code', () => {
+    const name = 'My PETG @Bambu Lab X1 Carbon 0.4 nozzle';
+    expect(presetCompatibility({ name }, 'filament', X1C, idx)).toBe('match');
+    expect(presetCompatibility({ name }, 'filament', A1, idx)).toBe('mismatch');
+  });
+
+  it('applies the nozzle filter to the long form too', () => {
+    expect(
+      presetCompatibility({ name: 'My PETG @Bambu Lab A1 0.6 nozzle' }, 'filament', A1, idx),
+    ).toBe('mismatch');
+  });
+
+  it('ignores a trailing "(Custom)" suffix rather than mangling the model token', () => {
+    // The slicer appends this to user-saved presets. Parsed naively the tag
+    // becomes "H2D 0.4 nozzle (Custom)" — which would brand the preset a
+    // mismatch against its own printer and hide it from the dropdown.
+    const name = 'Devil Design PLA Basic @Bambu Lab H2D 0.4 nozzle (Custom)';
+    expect(presetCompatibility({ name }, 'filament', H2D, idx)).toBe('match');
+    expect(presetCompatibility({ name }, 'filament', A1, idx)).toBe('mismatch');
+  });
+
+  it('keeps the A1M alias working through the long form', () => {
+    expect(
+      presetCompatibility({ name: 'My PLA @Bambu Lab A1 mini 0.4 nozzle' }, 'filament', A1_MINI, idx),
+    ).toBe('match');
+    expect(
+      presetCompatibility({ name: 'My PLA @Bambu Lab A1 mini 0.4 nozzle' }, 'filament', A1, idx),
+    ).toBe('mismatch');
+  });
+
+  it('stays unknown for an @-tag that names no recognisable printer', () => {
+    // "@Voron 0.4 nozzle" is not a Bambu printer preset — the matcher must
+    // not guess, so the preset keeps its place in the main dropdown list.
+    expect(
+      presetCompatibility({ name: 'My PLA @Voron 0.4 nozzle' }, 'filament', A1, idx),
+    ).toBe('unknown');
+  });
+
+  it('reads the tag from the last @ so a stray earlier one cannot swallow it', () => {
+    expect(
+      presetCompatibility({ name: 'My @work PLA @Bambu Lab H2D 0.4 nozzle' }, 'filament', A1, idx),
+    ).toBe('mismatch');
+    expect(
+      presetCompatibility({ name: 'My @work PLA @Bambu Lab H2D 0.4 nozzle' }, 'filament', H2D, idx),
+    ).toBe('match');
+  });
+});
+
+describe('presetCompatibility — nozzle-only @<size> tag (#2628 follow-up)', () => {
+  const P2S_04 = 'Bambu Lab P2S 0.4 nozzle';
+  const X1C_02 = 'Bambu Lab X1 Carbon 0.2 nozzle';
+  const idx = buildCompatibilityIndex(PRINTER_MODELS);
+
+  it('rules out a 0.2-nozzle profile on a 0.4-nozzle printer', () => {
+    // The live case: "Overture PLA Matte @0.2" inherits from the X1C 0.2
+    // nozzle system profile, so the slicer rejected a P2S 0.4 slice with
+    // "not compatible with printer Bambu Lab P2S 0.4 nozzle". The name
+    // carries no model, so this size is the only signal available.
+    expect(
+      presetCompatibility({ name: 'Overture PLA Matte @0.2' }, 'filament', P2S_04, idx),
+    ).toBe('mismatch');
+  });
+
+  it('stays unknown when the size agrees — a size is not a model', () => {
+    // The profile might belong to a different printer with the same nozzle;
+    // promoting this to 'match' would claim knowledge we don't have.
+    expect(
+      presetCompatibility({ name: 'Overture PLA Matte @0.2' }, 'filament', X1C_02, idx),
+    ).toBe('unknown');
+  });
+
+  it('accepts the "0.2 nozzle" and "0.2mm" spellings of the same tag', () => {
+    for (const name of ['My PLA @0.2 nozzle', 'My PLA @0.2mm']) {
+      expect(presetCompatibility({ name }, 'filament', P2S_04, idx)).toBe('mismatch');
+    }
+  });
+
+  it('compares sizes numerically so 0.20 and 0.2 are one size', () => {
+    expect(
+      presetCompatibility({ name: 'My PLA @0.20' }, 'filament', X1C_02, idx),
+    ).toBe('unknown');
+  });
+
+  it('ignores a numeric tag that cannot be a nozzle', () => {
+    // "@2026" is a year, not a 2026 mm nozzle — guessing here would brand
+    // the profile incompatible with every printer that exists.
+    expect(presetCompatibility({ name: 'My PLA @2026' }, 'filament', P2S_04, idx)).toBe('unknown');
+    expect(presetCompatibility({ name: 'My PLA @0.05' }, 'filament', P2S_04, idx)).toBe('unknown');
+  });
+
+  it('leaves a model-bearing tag on the model path', () => {
+    // Regression guard: the nozzle-only branch must not swallow the forms
+    // that carry a model — those still resolve to match/mismatch.
+    expect(
+      presetCompatibility({ name: 'Bambu PLA Basic @BBL P2S' }, 'filament', P2S_04, idx),
+    ).toBe('match');
+    expect(
+      presetCompatibility({ name: 'My PLA @Bambu Lab X1 Carbon 0.4 nozzle' }, 'filament', P2S_04, idx),
+    ).toBe('mismatch');
+  });
+});

+ 87 - 10
frontend/src/utils/slicerPrinterMatch.ts

@@ -6,9 +6,11 @@
 //
 //
 //   1. Imported (local-tier) presets carry the slicer's own
 //   1. Imported (local-tier) presets carry the slicer's own
 //      `compatible_printers` list — an exact list of printer-preset names.
 //      `compatible_printers` list — an exact list of printer-preset names.
-//   2. BambuStudio's own `@BBL <model>` naming convention on shipped cloud
-//      / standard presets. The token → printer-fragment table is derived
-//      from the backend's canonical PRINTER_MODEL_MAP (fetched via
+//   2. The `@<printer>` naming convention, in both shapes the slicer
+//      writes: `@BBL <model>` on shipped cloud / standard presets, and
+//      `@Bambu Lab <model> <size> nozzle` on presets a user saved for a
+//      specific printer (#2628). The token → printer-fragment table is
+//      derived from the backend's canonical PRINTER_MODEL_MAP (fetched via
 //      /slicer/printer-models), not duplicated here.
 //      /slicer/printer-models), not duplicated here.
 //
 //
 // The result drives grouping, not hard hiding: a preset no rule covers
 // The result drives grouping, not hard hiding: a preset no rule covers
@@ -136,10 +138,72 @@ function extractPrinterPresetModel(printerPresetName: string): { model: string;
   return stripped ? { model: stripped, nozzle } : null;
   return stripped ? { model: stripped, nozzle } : null;
 }
 }
 
 
+// Trailing parenthetical the slicer appends to user-saved presets —
+// "… @Bambu Lab H2D 0.4 nozzle (Custom)". Dropped before the nozzle suffix
+// is parsed, or the tag would resolve to a nonsense model token and the
+// preset would be branded a mismatch against its OWN printer.
+function stripTrailingParenthetical(s: string): string {
+  return s.replace(/\s*\([^)]*\)\s*$/, '').trim();
+}
+
+// Nozzle sizes Bambu ships run 0.2 – 0.8. The range guard keeps a tag that
+// merely looks numeric ("PLA @2026") from being read as a nozzle and branded
+// incompatible with every printer.
+const MIN_NOZZLE_MM = 0.1;
+const MAX_NOZZLE_MM = 2.0;
+
+// Compare two nozzle strings numerically, so "0.20" and "0.2" are the same
+// size. Unparseable values never match — a size we can't read is not evidence.
+function sameNozzle(a: string, b: string): boolean {
+  const x = Number.parseFloat(a);
+  const y = Number.parseFloat(b);
+  if (Number.isNaN(x) || Number.isNaN(y)) return false;
+  return x === y;
+}
+
+// Pull the model token and nozzle out of a preset name's printer tag.
+// Three shapes exist in the wild (#2628):
+//
+//   "0.20mm Standard @BBL X1C"                    — short code, the form
+//      Bambu ships its own cloud / standard presets under.
+//   "SUNLU TPU 95A @Bambu Lab H2D 0.4 nozzle"     — the full printer-preset
+//      name, the form the slicer writes when a user saves their own preset
+//      for a printer. Handling only the short form left these classified
+//      'unknown', so an H2D-scoped filament was offered (and auto-picked)
+//      for an A1 slice, which the CLI then rejected.
+//   "Overture PLA Matte @0.2"                     — nozzle only, no model.
+//      Returned with a null token: the size can rule a printer OUT, but
+//      says nothing about which models the profile belongs to.
+//
+// The first two shapes are also parsed in ConfigureAmsSlotModal (#1623).
+function extractPrinterTag(presetName: string): { token: string | null; nozzle: string | null } | null {
+  const cleaned = stripTrailingParenthetical(presetName);
+  const bbl = extractBblToken(cleaned);
+  if (bbl) return bbl;
+  // The printer tag is a suffix by convention, so read from the LAST '@' —
+  // a stray earlier one ("My @work PLA @Bambu Lab H2D 0.4 nozzle") must not
+  // swallow it. Anything that doesn't parse as a Bambu printer preset name
+  // falls through to 'unknown', never to a guessed mismatch.
+  const at = cleaned.lastIndexOf('@');
+  if (at < 0) return null;
+  const suffix = cleaned.slice(at + 1).trim();
+  const longForm = extractPrinterPresetModel(suffix);
+  if (longForm) return { token: longForm.model, nozzle: longForm.nozzle };
+  const nozzleOnly = suffix.match(/^([\d.]+)\s*(?:mm)?\s*(?:nozzle)?$/i);
+  if (nozzleOnly) {
+    const size = Number.parseFloat(nozzleOnly[1]);
+    if (!Number.isNaN(size) && size >= MIN_NOZZLE_MM && size <= MAX_NOZZLE_MM) {
+      return { token: null, nozzle: nozzleOnly[1] };
+    }
+  }
+  return null;
+}
+
 /**
 /**
- * Name-based fallback for presets BambuStudio ships with a `@BBL <model>`
- * tag (#1325 follow-up). Used only after `compatible_printers` has returned
- * `'unknown'`.
+ * Name-based fallback for presets carrying a printer tag — BambuStudio's own
+ * `@BBL <model>` (#1325 follow-up), the full `@Bambu Lab <model> <size>
+ * nozzle` form user-saved presets get, or a bare `@<size>` (#2628).
+ * Used only after `compatible_printers` has returned `'unknown'`.
  *
  *
  * Compares BOTH model AND nozzle. The nozzle filter is required because
  * Compares BOTH model AND nozzle. The nozzle filter is required because
  * Bambu ships per-nozzle process / filament variants (0.2 / 0.4 / 0.6 /
  * Bambu ships per-nozzle process / filament variants (0.2 / 0.4 / 0.6 /
@@ -152,8 +216,23 @@ function classifyByBambuName(
   selectedPrinterName: string,
   selectedPrinterName: string,
   bambuModelByShortCode: Record<string, string>,
   bambuModelByShortCode: Record<string, string>,
 ): PrinterCompatibility {
 ): PrinterCompatibility {
-  const parsed = extractBblToken(presetName);
+  const parsed = extractPrinterTag(presetName);
   if (!parsed) return 'unknown';
   if (!parsed) return 'unknown';
+  const selectedParts = extractPrinterPresetModel(selectedPrinterName);
+  if (!selectedParts) return 'unknown';
+  if (parsed.token === null) {
+    // Nozzle-only tag ("Overture PLA Matte @0.2"). The size can rule a
+    // printer OUT, but a matching size proves nothing about the model, so
+    // the best this can ever return is 'unknown' — never 'match'.
+    if (
+      selectedParts.nozzle !== null
+      && parsed.nozzle !== null
+      && !sameNozzle(parsed.nozzle, selectedParts.nozzle)
+    ) {
+      return 'mismatch';
+    }
+    return 'unknown';
+  }
   // If the token isn't in the table (a brand-new Bambu model whose short
   // If the token isn't in the table (a brand-new Bambu model whose short
   // code the backend registry hasn't added yet, or the model map hasn't
   // code the backend registry hasn't added yet, or the model map hasn't
   // loaded yet), fall back to comparing the raw token. That keeps the
   // loaded yet), fall back to comparing the raw token. That keeps the
@@ -162,8 +241,6 @@ function classifyByBambuName(
   // without us having to ship a code update. When they differ in form
   // without us having to ship a code update. When they differ in form
   // (X1C vs "X1 Carbon"), the registry is what makes the match work.
   // (X1C vs "X1 Carbon"), the registry is what makes the match work.
   const inferredModel = bambuModelByShortCode[parsed.token] ?? parsed.token;
   const inferredModel = bambuModelByShortCode[parsed.token] ?? parsed.token;
-  const selectedParts = extractPrinterPresetModel(selectedPrinterName);
-  if (!selectedParts) return 'unknown';
   // The raw inferred model and the printer-preset fragment may differ only by
   // The raw inferred model and the printer-preset fragment may differ only by
   // the Bambu short-code rename (e.g. preset token "A1M" vs printer "A1 Mini").
   // the Bambu short-code rename (e.g. preset token "A1M" vs printer "A1 Mini").
   // ``matchesPrinterModelSuffix`` consults the alias table before declaring a
   // ``matchesPrinterModelSuffix`` consults the alias table before declaring a
@@ -180,7 +257,7 @@ function classifyByBambuName(
   // or non-Bambu printer names that happened to match the model.
   // or non-Bambu printer names that happened to match the model.
   if (selectedParts.nozzle !== null) {
   if (selectedParts.nozzle !== null) {
     const presetNozzle = parsed.nozzle ?? DEFAULT_NOZZLE;
     const presetNozzle = parsed.nozzle ?? DEFAULT_NOZZLE;
-    if (presetNozzle !== selectedParts.nozzle) return 'mismatch';
+    if (!sameNozzle(presetNozzle, selectedParts.nozzle)) return 'mismatch';
   }
   }
   return 'match';
   return 'match';
 }
 }

Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/index-DVKbiORm.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
 
     <!-- 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-D0xVmIdo.js"></script>
+    <script type="module" crossorigin src="/assets/index-DVKbiORm.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-CKAbipPc.css">
     <link rel="stylesheet" crossorigin href="/assets/index-CKAbipPc.css">
   </head>
   </head>
   <body>
   <body>

Някои файлове не бяха показани, защото твърде много файлове са промени