Przeglądaj źródła

Slice dialog: material for H2C filaments with OrcaSlicer preferred

- Bambu Cloud lists filament profiles without their material, which
  was only taken from a profile of the same name. The OrcaSlicer bundle
  has no H2C profiles, so every "@BBL H2C" filament stayed untyped and
  a PETG-CF slot could be preselected as Generic PETG
- A stock "<filament> @BBL <printer>" profile now takes the material of
  the same filament's bundled "@BBL" profiles for other printers, when
  they all agree; a user's copy ("... - Copy") is left alone
- An Orca Cloud filament saved without a material takes it from the
  profile it was saved from; Orca's own "@System" generics, which state
  a coarser material, are no source
- Only fills a missing material, never the colour
maziggy 1 dzień temu
rodzic
commit
9ac3f0f201

+ 1 - 0
CHANGELOG.md

@@ -199,6 +199,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 
 ### Fixed
+- **The Slice dialog picks the right material for H2C filaments with OrcaSlicer preferred (#3403, reported by @pleasantone)** — Bambu Cloud lists its filament profiles without their material, which Bambuddy takes from the bundled profile of the same name. The OrcaSlicer bundle has no H2C profiles, so every "@BBL H2C" filament stayed without one, and a PETG-CF slot could be preselected as Generic PETG, also with "Only loaded spools" on. Such a profile now takes the material of the same filament's bundled profiles for other printers ("Generic PETG-CF @BBL X1C" for "Generic PETG-CF @BBL H2C 0.4 nozzle"), when they all agree. An OrcaSlicer Cloud filament saved without a material of its own takes it from the profile it was saved from.
 - **SpoolBuddy (PN5180): NTAG tags with 7-byte UIDs are read through every cascade level (#3258, contributed by @Thomansky in #3346)** - They are no longer reported as 88 + three bytes. Spools linked with the short form are still found, in the built-in inventory and in Spoolman, and their link is upgraded on the next scan.
 - **The stock forecast names a SKU's colour once (#3400, reported by @ojimpo)** — In Spoolman mode, a filament with no colour name takes its subtype as one, so a filament named "Black" with material "PLA Basic" read "Bambu Lab PLA Basic Black Black" in the forecast list, the chart legend, the alerts, the add-to-list dialog and the shopping list. A colour that is the same word as the subtype is now left out of the name. The SKU itself, its settings and the shopping list entries are unchanged.
 - **The stock forecast counts used-up spools towards the consumption rate (#3399, reported by @ojimpo)** — Archived spools were left out of the forecast entirely, so once used-up spools were archived, a SKU's rate counted only what its remaining spools had used. A SKU in weekly use could show 0.2 g/day and run out in decades, and the Reorder and Stock Break alerts came late or not at all.

+ 69 - 1
backend/app/api/routes/slicer_presets.py

@@ -14,6 +14,7 @@ from __future__ import annotations
 import hashlib
 import json
 import logging
+import re
 import time
 
 from fastapi import APIRouter, Depends, HTTPException, Query
@@ -304,7 +305,9 @@ async def _fetch_orca_cloud_presets(
                 # 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)
-            elif slot == "printer":
+            if slot in ("printer", "filament"):
+                # A filament's parent gives it the material its own content
+                # leaves out (#3403); see _enrich_cloud_metadata.
                 preset.inherits = _content_inherits(content)
             slots[slot].append(preset)
         _orca_cloud_cache[cache_key] = (now, slots)
@@ -528,6 +531,19 @@ def _enrich_cloud_metadata(
     this function exists post-#1712 — without the enrich the Bambu Cloud
     tier can't score in ``pickFilamentForSlot``.
 
+    A filament with no same-named twin still gets a material when one can be
+    told for sure (#3403), for both cloud tiers:
+      - an Orca Cloud profile saved with only the keys the user changed takes
+        it from the profile it inherits from;
+      - a stock ``<filament> @BBL <printer>`` profile takes it from the bundled
+        profiles of the same filament for other printers, when they all agree.
+        With OrcaSlicer preferred the bundle has no H2C profiles at all, so
+        without this every Bambu Cloud "@BBL H2C" filament is untyped and a
+        PETG-CF slot can pick Generic PETG. Only ``@BBL`` names count: Orca's
+        "Generic PETG-CF @System" states PETG.
+    Only ever fills a gap, and only the material: the colour of another
+    printer's profile is not this one's.
+
     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
@@ -559,6 +575,42 @@ def _enrich_cloud_metadata(
             if p.filament_colour is None and c is not None:
                 p.filament_colour = c
 
+    # Material of the bundled "<filament> @BBL <printer>" profiles, per
+    # filament, kept only where every printer's profile states the same one.
+    bundled_types: dict[str, set[str]] = {}
+    for p in standard["filament"]:
+        base = _bbl_filament_base(p.name)
+        if base and p.filament_type:
+            bundled_types.setdefault(base, set()).add(p.filament_type)
+    type_by_base = {base: next(iter(types)) for base, types in bundled_types.items() if len(types) == 1}
+    # The parents an Orca Cloud profile can name. Of the bundle, only "@BBL"
+    # profiles: Orca's own "Generic PETG-CF @System" states PETG, and a
+    # user's PETG-CF saved from it would then be left out of PETG-CF slots.
+    type_by_name = {
+        p.name: p.filament_type
+        for tier in (local, orca_cloud, standard)
+        for p in tier["filament"]
+        if p.filament_type and (tier is not standard or _bbl_filament_base(p.name))
+    }
+    orca_parent = {p.name: p.inherits for p in orca_cloud["filament"] if p.inherits}
+
+    def _material_for(name: str) -> str | None:
+        seen: set[str] = set()
+        while name and name not in seen:
+            seen.add(name)
+            if name in type_by_name:
+                return type_by_name[name]
+            base = _bbl_filament_base(name)
+            if base in type_by_base:
+                return type_by_base[base]
+            name = orca_parent.get(name, "")
+        return None
+
+    for tier in (orca_cloud, cloud):
+        for p in tier["filament"]:
+            if p.filament_type is None:
+                p.filament_type = _material_for(p.name)
+
     # 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.
@@ -580,6 +632,22 @@ def _enrich_cloud_metadata(
     return orca_cloud, cloud, local, standard
 
 
+# What follows "@BBL " in a stock filament name: the printer, and maybe the
+# nozzle ("H2C", "H2C 0.4 nozzle"). True of every name in both bundles.
+_BBL_PRINTER_TAIL = re.compile(r"[A-Z0-9]+( \d\.\d nozzle)?")
+
+
+def _bbl_filament_base(name: str) -> str | None:
+    """The filament part of a stock ``<filament> @BBL <printer>`` name
+    ("Generic PETG-CF @BBL H2C 0.4 nozzle" -> "Generic PETG-CF"), else None.
+    A name with more after the printer is a user's copy ("... @BBL H2C -
+    Copy"), whose material may have been changed: None."""
+    base, sep, printer = name.partition(" @BBL ")
+    if not sep or not _BBL_PRINTER_TAIL.fullmatch(printer):
+        return None
+    return base.strip() or None
+
+
 @router.get("/printer-models")
 def list_printer_models() -> dict[str, str]:
     """Canonical Bambu printer-model registry, surfaced for the SliceModal.

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

@@ -46,6 +46,8 @@ class UnifiedPreset(BaseModel):
     ("Bambu Lab H2D 0.4 nozzle - Skew") is listed in no process's
     ``compatible_printers`` and its name says nothing reliable about the
     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).
     """
 
     id: str

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

@@ -155,6 +155,116 @@ class TestEnrichCloudMetadata:
         assert c["filament"][0].filament_colour == "#FFFFFF"
 
 
+def _filaments(source: str, *items: tuple[str, str | None]) -> dict[str, list[UnifiedPreset]]:
+    """A tier with only filament presets, from (name, filament_type) pairs."""
+    return {
+        "printer": [],
+        "process": [],
+        "filament": [UnifiedPreset(id=n, name=n, source=source, filament_type=t) for n, t in items],
+    }
+
+
+class TestEnrichMaterialWithoutTwin:
+    """#3403: with OrcaSlicer preferred, the bundle has no H2C profiles, so a
+    Bambu Cloud "@BBL H2C" filament has no same-named twin and used to stay
+    untyped -- a PETG-CF slot then auto-picked Generic PETG."""
+
+    def _bundled(self) -> dict[str, list[UnifiedPreset]]:
+        # The OrcaSlicer 2.4.2 bundle as it lists these: every printer's
+        # profile but the H2C, and Orca's own "@System" generics.
+        return _filaments(
+            "standard",
+            ("Generic PETG-CF @BBL X1C", "PETG-CF"),
+            ("Generic PETG-CF @BBL H2D", "PETG-CF"),
+            ("Generic PETG-CF @System", "PETG"),
+            ("Generic PETG @BBL X1C", "PETG"),
+            ("Generic PE-CF @BBL X1C", "PE-CF"),
+            ("Generic PE-CF @System", "PE"),
+        )
+
+    def test_bambu_cloud_stock_profile_takes_the_material_of_its_other_printers(self):
+        cloud = _filaments(
+            "cloud",
+            ("Generic PETG @BBL H2C", None),
+            ("Generic PETG-CF @BBL H2C", None),
+            ("Generic PETG-CF @BBL H2C 0.4 nozzle", None),
+            ("Generic PE-CF @BBL H2C", None),
+        )
+
+        _oc, c, _l, _s = sp._enrich_cloud_metadata(
+            _filaments("orca_cloud"), cloud, _filaments("local"), self._bundled()
+        )
+
+        assert [p.filament_type for p in c["filament"]] == ["PETG", "PETG-CF", "PETG-CF", "PE-CF"]
+        # Material only: the colour of another printer's profile isn't this one's.
+        assert all(p.filament_colour is None for p in c["filament"])
+
+    def test_disagreeing_bundled_materials_leave_it_unknown(self):
+        bundled = _filaments("standard", ("Odd PLA @BBL X1C", "PLA"), ("Odd PLA @BBL A1", "PLA-CF"))
+        cloud = _filaments("cloud", ("Odd PLA @BBL H2C", None))
+
+        _oc, c, _l, _s = sp._enrich_cloud_metadata(_filaments("orca_cloud"), cloud, _filaments("local"), bundled)
+
+        assert c["filament"][0].filament_type is None
+
+    def test_only_bundled_bbl_names_count(self):
+        """A name without "@BBL", or a user's own "@BBL"-named profile, is no
+        source of another profile's material."""
+        local = _filaments("local", ("Generic PLA @BBL X1C", "PETG"))
+        cloud = _filaments("cloud", ("Generic PETG-CF", None), ("Generic PLA @BBL H2C", None))
+
+        _oc, c, _l, _s = sp._enrich_cloud_metadata(_filaments("orca_cloud"), cloud, local, self._bundled())
+
+        assert [p.filament_type for p in c["filament"]] == [None, None]
+
+    def test_a_users_copy_of_a_stock_profile_is_left_unknown(self):
+        """Its material may be the one the user changed it to."""
+        cloud = _filaments("cloud", ("Generic PETG @BBL H2C - Copy", None), ("Generic PETG @BBL H2C 0.4 nozzle", None))
+
+        _oc, c, _l, _s = sp._enrich_cloud_metadata(
+            _filaments("orca_cloud"), cloud, _filaments("local"), self._bundled()
+        )
+
+        assert [p.filament_type for p in c["filament"]] == [None, "PETG"]
+
+    def test_a_stated_material_is_kept(self):
+        cloud = _filaments("cloud", ("Generic PETG-CF @BBL H2C", "PETG"))
+
+        _oc, c, _l, _s = sp._enrich_cloud_metadata(
+            _filaments("orca_cloud"), cloud, _filaments("local"), self._bundled()
+        )
+
+        assert c["filament"][0].filament_type == "PETG"
+
+    def test_orca_cloud_profile_takes_the_material_of_its_parent(self):
+        orca = _filaments(
+            "orca_cloud",
+            ("My PETG-CF", None),
+            ("My PETG-CF fast", None),
+            ("My H2C PETG-CF", None),
+            ("Loop A", None),
+            ("Loop B", None),
+            ("Typed", "PLA"),
+            ("From System", None),
+        )
+        parents = {
+            "My PETG-CF": "Generic PETG-CF @BBL X1C",  # bundled, same name
+            "My PETG-CF fast": "My PETG-CF",  # through another user profile
+            "My H2C PETG-CF": "Generic PETG-CF @BBL H2C",  # not bundled: by filament
+            "Loop A": "Loop B",
+            "Loop B": "Loop A",
+            "Typed": "Generic PETG @BBL X1C",
+            # Orca's own generic states the coarser PETG: no source.
+            "From System": "Generic PETG-CF @System",
+        }
+        for p in orca["filament"]:
+            p.inherits = parents[p.name]
+
+        oc, _c, _l, _s = sp._enrich_cloud_metadata(orca, _filaments("cloud"), _filaments("local"), self._bundled())
+
+        assert [p.filament_type for p in oc["filament"]] == ["PETG-CF", "PETG-CF", "PETG-CF", None, None, "PLA", None]
+
+
 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
@@ -423,6 +533,39 @@ class TestFetchOrcaCloudPresets:
 
         assert [p.inherits for p in slots["printer"]] == ["Bambu Lab H2D 0.4 nozzle", None, None]
 
+    @pytest.mark.asyncio
+    async def test_filament_preset_carries_the_preset_it_was_saved_from(self):
+        """#3403: a filament saved with only the keys the user changed states no
+        material of its own; the enrich pass reads it off this parent."""
+        sp._orca_cloud_cache.clear()
+        svc_mock = MagicMock()
+        svc_mock.list_profiles = AsyncMock(
+            return_value=[
+                {
+                    "id": "f1",
+                    "name": "My PETG-CF",
+                    "content": {"type": "filament", "inherits": "Generic PETG-CF @BBL X1C"},
+                },
+                {
+                    "id": "p1",
+                    "name": "Orca 0.20mm",
+                    "content": {"type": "print", "inherits": "0.20mm Standard @BBL X1C"},
+                },
+            ]
+        )
+        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, _ = await sp._fetch_orca_cloud_presets(MagicMock(), user)
+
+        assert slots["filament"][0].inherits == "Generic PETG-CF @BBL X1C"
+        assert slots["filament"][0].filament_type is None
+        assert slots["process"][0].inherits 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" —