Procházet zdrojové kódy

Fix the filament id put in an AMS slot for SUNLU and some P1/X1 presets

- The slot's filament id came from a Bambu preset's setting id with the
  "S" after "GF" dropped. SUNLU's do not follow that ("GFSNLS03_04" is
  "GFSNL03"), and about thirty P1/X1 presets carry another filament's
  id, decided by the full setting id.
- /cloud/settings passes through the filament id from Bambu's list;
  Configure AMS Slot and the K-profile picker use it.
- One conversion per side (utils/filament_ids.py, utils/filamentIds.ts)
  with a SUNLU rule and a generated table of exceptions
  (scripts/generate_filament_id_tables.py). Bare ids stay on the rule
  unless the preset was picked as itself: Bambuddy builds bare ids from
  filament ids, and Bambu's bare GFSA05 is PLA Matte, not Silk.
- Signed in to Bambu Cloud, spool assignment read the preset without its
  suffix and put a Bambu PLA Silk spool in as PLA Matte (also TPU 95A
  HF, PA, PA-CF, EVA, PETG-CF, Support); it now uses the full id.
- Orca Cloud: SUNLU and seven newer Bambu filaments added to the builtin
  catalogue, and OrcaSlicer's own "OF..." ids translated with Orca's
  table instead of being sent to the printer.
maziggy před 12 hodinami
rodič
revize
dab7c60a9f
58 změnil soubory, kde provedl 951 přidání a 91 odebrání
  1. 5 0
      CHANGELOG.md
  2. 22 0
      backend/app/api/routes/cloud.py
  3. 4 0
      backend/app/schemas/cloud.py
  4. 39 7
      backend/app/services/slicer_filament_resolver.py
  5. 137 0
      backend/app/utils/filament_id_tables.py
  6. 68 17
      backend/app/utils/filament_ids.py
  7. 3 1
      backend/tests/unit/services/test_slicer_filament_resolver.py
  8. 227 0
      backend/tests/unit/test_filament_ids_3387.py
  9. 90 0
      frontend/src/__tests__/components/ConfigureAmsSlotModal.test.tsx
  10. 73 0
      frontend/src/__tests__/utils/filamentIds.test.ts
  11. 17 0
      frontend/src/__tests__/utils/filamentPresets.test.ts
  12. 3 0
      frontend/src/api/client.ts
  13. 23 33
      frontend/src/components/ConfigureAmsSlotModal.tsx
  14. 8 15
      frontend/src/components/spool-form/utils.ts
  15. 4 2
      frontend/src/utils/amsHelpers.ts
  16. 30 0
      frontend/src/utils/filamentIdTables.ts
  17. 72 0
      frontend/src/utils/filamentIds.ts
  18. 10 6
      frontend/src/utils/filamentPresets.ts
  19. 106 0
      scripts/generate_filament_id_tables.py
  20. 0 0
      static/assets/AdditionalSection-BimvJ0xv.js
  21. 0 0
      static/assets/AmsUnitCard-LjifBnXb.js
  22. 0 0
      static/assets/ArchivesPage-CD9bSnXn.js
  23. 0 0
      static/assets/AssignToAmsModal-BsyuEwE5.js
  24. 0 0
      static/assets/CamWallPage-1utunR8O.js
  25. 0 0
      static/assets/CameraPage-C4yooUV8.js
  26. 0 0
      static/assets/ConfigureAmsSlotModal-CPX90h9N.js
  27. 0 0
      static/assets/ConfigureAmsSlotModal-DROsw5sr.js
  28. 0 0
      static/assets/ConfirmOutcomeDialog-C2TBZ334.js
  29. 0 0
      static/assets/FileManagerPage-C-wPxCh7.js
  30. 0 0
      static/assets/InventoryPage-Eg1j025Y.js
  31. 0 0
      static/assets/Layout-DBQNIECX.js
  32. 0 0
      static/assets/ModelSourcesPage-je7rokjR.js
  33. 0 0
      static/assets/PhotoGalleryModal-CchfaL7x.js
  34. 0 0
      static/assets/PrintModal-CZIbcLUP.js
  35. 0 0
      static/assets/PrintersPage-igaHjmyD.js
  36. 0 0
      static/assets/ProfilesPage-CFPee9BW.js
  37. 0 0
      static/assets/ProfilesPage-CJK7CLAE.js
  38. 0 0
      static/assets/ProjectDetailPage-D8ohs5e6.js
  39. 0 0
      static/assets/QueuePage-3XtRgZmq.js
  40. 0 0
      static/assets/RichTextEditor-CB7OdrH0.js
  41. 0 0
      static/assets/SettingsPage-D-fJUb-Y.js
  42. 0 0
      static/assets/SliceModal-DYHa-gYe.js
  43. 0 0
      static/assets/SpoolBuddyAmsPage-K7eJULyB.js
  44. 0 0
      static/assets/SpoolBuddyDashboard-I6XOQWjB.js
  45. 0 0
      static/assets/SpoolBuddyInventoryPage-BIL4svSB.js
  46. 1 1
      static/assets/SpoolBuddySettingsPage-Da_PmwT6.js
  47. 0 0
      static/assets/SpoolBuddyWriteTagPage-u9VsCLPe.js
  48. 0 0
      static/assets/amsHelpers-DDsrDUnz.js
  49. 0 0
      static/assets/amsHelpers-Dlwij40i.js
  50. 0 0
      static/assets/colors-BBBvvSKR.js
  51. 0 0
      static/assets/index-CnjrYt4Y.js
  52. 1 1
      static/assets/inventorySearch-BT7neCFO.js
  53. 0 0
      static/assets/locationSensorDefaults-BkEDE4Rl.js
  54. 0 1
      static/assets/useColorCatalogVersion-DPkJwK8A.js
  55. 1 0
      static/assets/useColorCatalogVersion-me0iWO3m.js
  56. 0 0
      static/assets/utils-B5TX3uzf.js
  57. 0 0
      static/assets/utils-CRm_p6_K.js
  58. 7 7
      static/index.html

+ 5 - 0
CHANGELOG.md

@@ -192,6 +192,11 @@ 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
+- **SUNLU and some P1/X1 presets put the right filament in an AMS slot (#3387, reported by @Spegeli)** — Bambuddy worked out a slot's filament id from a Bambu preset's setting id by dropping the "S" after "GF". For SUNLU that gives an id no profile has: SUNLU PLA+ (`GFSNLS03_04`) went into the slot as `GFNLS03` instead of `GFSNL03`. About thirty P1/X1-series presets carry another filament's id altogether, for example Generic TPU @BBL P1P is `GFU99`, not Generic EVA's `GFR99`.
+  - **Configure AMS Slot** now takes the filament id from Bambu's own preset list, which carries it for every preset.
+  - **Spool assignment, in both inventory modes, and K-profile matching** know SUNLU's ids and look up the P1/X1 exceptions by full setting id.
+  - **Signed in to Bambu Cloud, a Bambu PLA Silk spool went into the slot as Bambu PLA Matte**: assignment read the preset without its variant suffix, and Bambu's bare `GFSA05` is PLA Matte @BBL X1. The same swapped TPU 95A HF, PA and PA-CF, Generic EVA, PETG-CF and Bambu Support For PLA. The filament now comes from the spool's full setting id.
+  - **Orca Cloud profiles based on SUNLU** went into the slot as Generic PLA, because SUNLU and seven newer Bambu filaments (PLA Tough+, Translucent, Lite, Pure, PETG Matte, TPU 90A and 85A) were missing from Bambuddy's catalogue. They are now listed, also under the built-in presets. An Orca Cloud profile carrying one of OrcaSlicer's own filament ids is translated to Bambu's id the way OrcaSlicer does it, instead of being sent to the printer as is.
 - **Mail from Bambuddy is no longer quarantined for missing headers (#3376, reported by @BenjaminCs)** — Bambuddy's mail had no `Date` and no `Message-ID` header, both of which RFC 5322 requires. A strict mail server (e.g. amavis with its default header check) accepted the message and then quarantined it, so Bambuddy logged a success and nothing arrived. Elsewhere a missing `Date` counts as a spam signal. Every mail now carries both: the SMTP test, welcome, password-reset and two-factor mails, and the email notification provider. The `Message-ID` uses the sender address's domain. A sender name with accented letters or a comma also broke the `From` header so that no address was left in it; the name is now encoded on its own and the address stays readable. The log now says "Email accepted by SMTP server", since Bambuddy cannot see delivery.
 - **Bambu PLA Silk+ spools added by RFID get their colour name** — An RFID roll is named from the colour catalogue by its hex and the product line the AMS reports. A single-colour Silk+ roll reports "PLA Silk+", but the catalogue had the Silk+ colours (Silver, Titan Gray, Gold and the rest) filed under "PLA Silk", the line the AMS reports for the Dual/Tri Color silk, so every Silk+ roll arrived without a name, in both inventory modes. The seeded colours are now filed under "PLA Silk+". On an existing install the update moves the seeded Bambu Lab rows that are still as seeded (rows you edited or added stay as they are), and names the RFID-added Bambu spools that came in without a colour name; a name already set is never changed.
 - **The X2D's auxiliary fans are named the right way round (#3373, reported by @Spegeli)** — On the X2D, the printer card's **Left Auxiliary Fan** drove the fan on the right of the chamber, and **Auxiliary Fan** the one on the left. The names came from the P2S, which numbers these two fans the other way round. On an X2D the card now shows **Left Auxiliary Fan** then **Right Auxiliary Fan**, still left to right, and the confirmation after setting a speed names the same side. The P2S is unchanged. The API keeps its field and fan names (`left_aux_fan_speed`, `aux`, `aux2`): on an X2D, `aux2` and `left_aux_fan_speed` are the right fan. Wiki updated.

+ 22 - 0
backend/app/api/routes/cloud.py

@@ -621,6 +621,7 @@ async def get_slicer_settings(
                         user_id=s.get("user_id"),
                         updated_time=s.get("updated_time"),
                         is_custom=True,
+                        filament_id=_listed_filament_id(s),
                     )
                 )
             # Public (default) presets
@@ -634,6 +635,7 @@ async def get_slicer_settings(
                         user_id=s.get("user_id"),
                         updated_time=s.get("updated_time"),
                         is_custom=False,
+                        filament_id=_listed_filament_id(s),
                     )
                 )
             setattr(result, our_type, parsed)
@@ -800,11 +802,15 @@ _BUILTIN_FILAMENT_NAMES: dict[str, str] = {
     "GFA07": "Bambu PLA Marble",
     "GFA08": "Bambu PLA Sparkle",
     "GFA09": "Bambu PLA Tough",
+    "GFA10": "Bambu PLA Tough+",
     "GFA11": "Bambu PLA Aero",
     "GFA12": "Bambu PLA Glow",
     "GFA13": "Bambu PLA Dynamic",
     "GFA15": "Bambu PLA Galaxy",
     "GFA16": "Bambu PLA Wood",
+    "GFA17": "Bambu PLA Translucent",
+    "GFA18": "Bambu PLA Lite",
+    "GFA19": "Bambu PLA Pure",
     "GFA50": "Bambu PLA-CF",
     "GFB00": "Bambu ABS",
     "GFB01": "Bambu ASA",
@@ -821,6 +827,7 @@ _BUILTIN_FILAMENT_NAMES: dict[str, str] = {
     "GFG00": "Bambu PETG Basic",
     "GFG01": "Bambu PETG Translucent",
     "GFG02": "Bambu PETG HF",
+    "GFG03": "Bambu PETG Matte",
     "GFG50": "Bambu PETG-CF",
     "GFG60": "PolyLite PETG",
     "GFG96": "Generic PETG HF",
@@ -869,6 +876,13 @@ _BUILTIN_FILAMENT_NAMES: dict[str, str] = {
     "GFS97": "Generic BVOH",
     "GFS98": "Generic HIPS",
     "GFS99": "Generic PVA",
+    "GFSNL02": "SUNLU PLA Matte",
+    "GFSNL03": "SUNLU PLA+",
+    "GFSNL04": "SUNLU PLA+ 2.0",
+    "GFSNL05": "SUNLU Silk PLA+",
+    "GFSNL06": "SUNLU PLA Marble",
+    "GFSNL07": "SUNLU Wood PLA",
+    "GFSNL08": "SUNLU PETG",
     "GFT01": "Bambu PET-CF",
     "GFT02": "Bambu PPS-CF",
     "GFT97": "Generic PPS",
@@ -876,6 +890,8 @@ _BUILTIN_FILAMENT_NAMES: dict[str, str] = {
     "GFU00": "Bambu TPU 95A HF",
     "GFU01": "Bambu TPU 95A",
     "GFU02": "Bambu TPU for AMS",
+    "GFU03": "Bambu TPU 90A",
+    "GFU04": "Bambu TPU 85A",
     "GFU98": "Generic TPU for AMS",
     "GFU99": "Generic TPU",
 }
@@ -960,6 +976,12 @@ async def _enrich_from_local_presets(
     return result
 
 
+def _listed_filament_id(entry: dict) -> str | None:
+    """The filament_id an entry of Bambu's preset list carries, if it is one."""
+    raw = entry.get("filament_id")
+    return (raw.strip() or None) if isinstance(raw, str) else None
+
+
 # _filament_id_to_setting_id is now imported from backend.app.utils.filament_ids
 _filament_id_to_setting_id = filament_id_to_setting_id
 

+ 4 - 0
backend/app/schemas/cloud.py

@@ -67,6 +67,10 @@ class SlicerSetting(BaseModel):
     user_id: str | None = None
     updated_time: str | None = None
     is_custom: bool = False
+    # The id Bambu Studio puts in an AMS slot for this preset. It cannot be
+    # derived from setting_id for every preset (SUNLU's "GFSNLS03_04" is
+    # "GFSNL03", #3387), so it is passed through from Bambu's list as is.
+    filament_id: str | None = None
 
 
 class SlicerSettingsResponse(BaseModel):

+ 39 - 7
backend/app/services/slicer_filament_resolver.py

@@ -47,6 +47,8 @@ from backend.app.utils.filament_ids import (
     GENERIC_FILAMENT_IDS,
     filament_id_to_setting_id,
     normalize_slicer_filament,
+    orca_to_bambu_filament_id,
+    setting_id_to_filament_id,
 )
 from backend.app.utils.filament_types import is_material_name
 
@@ -82,10 +84,16 @@ _MAX_INHERITS_DEPTH = 5
 
 
 def _content_filament_id(content: dict) -> str:
+    """A profile's filament_id as the printer knows it.
+
+    One copied from an OrcaSlicer system preset is Orca's own id ("OFMUWNkp"),
+    which the printer cannot resolve; it is translated with Orca's own table,
+    and one the table does not know reads as none, so the lookup goes on to
+    the parents (#3387)."""
     raw = content.get("filament_id")
     if isinstance(raw, list):
         raw = raw[0] if raw else None
-    return raw.strip() if isinstance(raw, str) else ""
+    return orca_to_bambu_filament_id(raw.strip()) if isinstance(raw, str) else ""
 
 
 def _catalog_filament_id(preset_name: str) -> str:
@@ -363,17 +371,41 @@ async def resolve_slicer_filament(
                     own_filament_id = detail.get("filament_id") or (
                         cloud_setting.get("filament_id") if isinstance(cloud_setting, dict) else None
                     )
-                    if own_filament_id:
+                    # An official preset's id comes from its full setting_id
+                    # (#3387). The detail read above is the suffix-free base's,
+                    # and for some bases that is another filament: "GFSA05" is
+                    # Bambu PLA Matte @BBL X1, while "GFSA05_07" -- and the
+                    # bare id Bambuddy builds from GFA05 -- is Bambu PLA Silk.
+                    # Its type and name only count when it is the same filament.
+                    # A spool named after that very preset is the exception: it
+                    # was picked as Bambu's legacy bare preset ("Bambu TPU 95A
+                    # @BBL X1C", "GFSU00" -> GFU01), not built by Bambuddy, and
+                    # Bambu's own id is then the right one.
+                    official = setting_id_to_filament_id(sf) if base_sf.startswith("GFS") else ""
+                    detail_name = detail.get("name")
+                    if (
+                        official
+                        and own_filament_id
+                        and isinstance(detail_name, str)
+                        and detail_name.strip()
+                        and detail_name.strip() == (slicer_filament_name or "").strip()
+                    ):
+                        official = own_filament_id
+                    if official:
+                        tray_info_idx = official
+                        if own_filament_id and own_filament_id != official:
+                            type_override = None
+                        else:
+                            cloud_name = detail.get("name", "")
+                            if cloud_name:
+                                sub_brand_override = cloud_name.split("@")[0].strip()
+                    elif own_filament_id:
                         tray_info_idx = own_filament_id
                         cloud_name = detail.get("name", "")
                         if cloud_name:
                             sub_brand_override = cloud_name.replace(r"@.*$", "").split("@")[0].strip()
                     elif detail.get("base_id"):
-                        bid = detail["base_id"].split("_")[0]
-                        if bid.startswith("GFS") and len(bid) >= 5:
-                            tray_info_idx = f"GF{bid[3:]}"
-                        else:
-                            tray_info_idx = bid
+                        tray_info_idx = setting_id_to_filament_id(detail["base_id"])
                 finally:
                     await cloud.close()
             elif cloud is not None:

+ 137 - 0
backend/app/utils/filament_id_tables.py

@@ -0,0 +1,137 @@
+"""Generated by scripts/generate_filament_id_tables.py -- do not edit by hand.
+
+1928 public Bambu filament presets read.
+"""
+
+# Bambu setting_id -> filament_id, where the naming rule gives another id.
+SETTING_ID_FILAMENT_IDS: dict[str, str] = {
+    "GFSA04": "GFA00",
+    "GFSA04_10": "GFA00",
+    "GFSA04_11": "GFA00",
+    "GFSA05_10": "GFA01",
+    "GFSA05_28": "GFA01",
+    "GFSG50_10": "GFG98",
+    "GFSL18": "GFL01",
+    "GFSL19": "GFL00",
+    "GFSL22": "GFL01",
+    "GFSL22_10": "GFL01",
+    "GFSL23": "GFL00",
+    "GFSL23_10": "GFL00",
+    "GFSL24": "GFL01",
+    "GFSL24_10": "GFL01",
+    "GFSL25": "GFL00",
+    "GFSL25_10": "GFL00",
+    "GFSL99_01": "GFL96",
+    "GFSL99_12": "GFL96",
+    "GFSN00": "GFN03",
+    "GFSN00_10": "GFN03",
+    "GFSN98_10": "GFN99",
+    "GFSN99_10": "GFN98",
+    "GFSP97_03": "GFP95",
+    "GFSR99_10": "GFU99",
+    "GFSS02_10": "GFS00",
+}
+
+# OrcaSlicer filament_id -> Bambu filament_id (Orca's table, Bambu Studio 66e405477).
+ORCA_FILAMENT_IDS: dict[str, str] = {
+    "OF0UJcb6": "GFG60",
+    "OF0wBGNx": "GFB51",
+    "OF1UNk9P": "GFP97",
+    "OF2GCW1l": "GFSNL08",
+    "OF2VFe4J": "GFN04",
+    "OF342jVN": "GFA10",
+    "OF4RvVuU": "GFT02",
+    "OF54B0S0": "GFN06",
+    "OF5CgdDq": "GFL00",
+    "OF6rdQ6M": "GFT98",
+    "OF74KeQR": "GFR98",
+    "OF7lOgYF": "GFG96",
+    "OF8Tlg47": "GFN96",
+    "OF8tPByX": "GFN97",
+    "OF9OlTWH": "GFA07",
+    "OFAnyRUI": "GFS02",
+    "OFBSW57R": "GFA11",
+    "OFBw6eEG": "GFL05",
+    "OFCD8qiU": "GFR99",
+    "OFDGigEI": "GFA13",
+    "OFDSrzZ8": "GFL99",
+    "OFDvXujf": "GFS99",
+    "OFDxfPgH": "GFA08",
+    "OFEGNJD4": "GFA05",
+    "OFEkPBwx": "GFA12",
+    "OFEswT5W": "GFA50",
+    "OFFNYwWR": "GFN03",
+    "OFFbSnCD": "GFA17",
+    "OFFvzqcd": "GFG00",
+    "OFHWSM21": "GFL54",
+    "OFHa48An": "GFT01",
+    "OFHmPYRy": "GFSNL04",
+    "OFIBbYO5": "GFU02",
+    "OFIfnzxC": "GFS05",
+    "OFKhMPeX": "GFC00",
+    "OFLJ8S6I": "GFSNL07",
+    "OFLPAxz3": "GFB98",
+    "OFLakOUI": "GFC99",
+    "OFMTK0UC": "GFS03",
+    "OFMUWNkp": "GFSNL03",
+    "OFMjtqTC": "GFA16",
+    "OFNk8bxk": "GFN08",
+    "OFOvv91M": "GFB60",
+    "OFPklMI1": "GFP99",
+    "OFQ3e56w": "GFA15",
+    "OFQHiNJs": "GFS01",
+    "OFQLcbps": "GFN98",
+    "OFS2tn6G": "GFL53",
+    "OFTHDCqA": "GFA19",
+    "OFTRZ8Y4": "GFG50",
+    "OFUanySo": "GFA06",
+    "OFVCkX5w": "GFU01",
+    "OFW29a9R": "GFL01",
+    "OFWbdGsC": "GFL98",
+    "OFX0ycRQ": "GFL52",
+    "OFXIbw5D": "GFA01",
+    "OFXkm8q1": "GFP96",
+    "OFXzQ4yL": "GFB02",
+    "OFY9muEs": "GFB99",
+    "OFYPdQJh": "GFG99",
+    "OFaQMgRH": "GFA09",
+    "OFc3xdm9": "GFL04",
+    "OFcytyoA": "GFL06",
+    "OFd0Fv0k": "GFP98",
+    "OFdyfQvU": "GFG03",
+    "OFesA6rF": "GFL96",
+    "OFf6mfQO": "GFS06",
+    "OFfBpSRI": "GFB01",
+    "OFg57Nmc": "GFC01",
+    "OFg8ndtj": "GFN99",
+    "OFgbpcy9": "GFU99",
+    "OFhuaUQB": "GFB00",
+    "OFk8t9mz": "GFL55",
+    "OFkOviHk": "GFL50",
+    "OFknl9Iz": "GFB50",
+    "OFlfuj2k": "GFU04",
+    "OFmN2lvw": "GFU98",
+    "OFmpMwxS": "GFL95",
+    "OFnfxTvi": "GFA18",
+    "OFniMuTN": "GFN05",
+    "OFo2UF2C": "GFS97",
+    "OFoYSJKi": "GFG98",
+    "OFoiVqVM": "GFA00",
+    "OFovEIbw": "GFG02",
+    "OFpPGSKG": "GFSNL05",
+    "OFpW4gdi": "GFSNL02",
+    "OFq9svOz": "GFT97",
+    "OFqINlYj": "GFL03",
+    "OFrKLeE3": "GFA02",
+    "OFsFon5l": "GFS98",
+    "OFsHSVZc": "GFS00",
+    "OFsijjtH": "GFP95",
+    "OFtkLO6q": "GFU03",
+    "OFu1evlr": "GFG97",
+    "OFvKUnLh": "GFU00",
+    "OFvrXuV7": "GFB61",
+    "OFwPrlCM": "GFG01",
+    "OFwaYjL6": "GFL51",
+    "OFxUwUeW": "GFSNL06",
+    "OFzyIxba": "GFS04",
+}

+ 68 - 17
backend/app/utils/filament_ids.py

@@ -6,10 +6,28 @@ Bambu printers use two ID formats for filament presets:
   - **setting_id**: e.g. "GFSL05", "GFSG02", "GFSA00"
     Used by BambuStudio / Bambu Cloud API to resolve presets.
 
-The only difference for official Bambu filaments is an "S" inserted after "GF".
+For most official Bambu filaments the only difference is an "S" inserted after
+"GF". Two kinds do not follow that rule (#3387):
+  - SUNLU: setting_id "GFSNLS03_04", filament_id "GFSNL03" -- a filament_id
+    that itself starts with "GFS" plus a letter.
+  - About thirty P1/X1-series presets whose filament_id is another one
+    altogether ("GFSA05_10", Bambu PLA Matte @BBL P1P, is "GFA01", while every
+    other "GFSA05" is Bambu PLA Silk, "GFA05"). Those are listed by full
+    setting_id in ``filament_id_tables.SETTING_ID_FILAMENT_IDS``.
 User presets (starting with "P") use the same ID in both contexts.
 """
 
+import re
+
+from backend.app.utils.filament_id_tables import ORCA_FILAMENT_IDS, SETTING_ID_FILAMENT_IDS
+
+# SUNLU's ids in Bambu's catalogue: filament_id "GFSNL03", setting_id "GFSNLS03".
+_SUNLU_FILAMENT_ID = re.compile(r"GFSNL(\d+)")
+_SUNLU_SETTING_ID = re.compile(r"GFSNLS(\d+)")
+# OrcaSlicer's own filament ids ("OFMUWNkp"); Orca maps them to Bambu's at the
+# printer boundary with the table ORCA_FILAMENT_IDS is generated from.
+_ORCA_FILAMENT_ID = re.compile(r"OF[0-9A-Za-z]{6}")
+
 MATERIAL_TEMPS: dict[str, tuple[int, int]] = {
     "PLA": (190, 230),
     "PETG": (220, 260),
@@ -76,8 +94,9 @@ GENERIC_IDS_REPLACED_ON_REUSE: frozenset[str] = frozenset(
 def filament_id_to_setting_id(filament_id: str) -> str:
     """Convert filament_id → setting_id (e.g. "GFL05" → "GFSL05").
 
-    - Already a setting_id ("GFSL05", "GFSS99") → returned unchanged; a
-      support filament_id ("GFS99") still converts.
+    - Already a setting_id ("GFSL05", "GFSS99", "GFSNLS03") → returned
+      unchanged; a support filament_id ("GFS99") still converts, and so does
+      SUNLU's ("GFSNL03" → "GFSNLS03").
     - User presets ("P…") → returned unchanged.
     - Empty / unknown → returned unchanged.
     """
@@ -88,6 +107,10 @@ def filament_id_to_setting_id(filament_id: str) -> str:
     if filament_id.startswith("P"):
         return filament_id
 
+    sunlu = _SUNLU_FILAMENT_ID.fullmatch(filament_id)
+    if sunlu:
+        return f"GFSNLS{sunlu.group(1)}"
+
     # Official Bambu presets: GFx## -> GFSx##
     if filament_id.startswith("GF") and len(filament_id) >= 4:
         # Already a setting_id: "GFS" then the family letter ("GFSL05").
@@ -102,26 +125,53 @@ def filament_id_to_setting_id(filament_id: str) -> str:
 
 
 def setting_id_to_filament_id(setting_id: str) -> str:
-    """Convert setting_id → filament_id (e.g. "GFSL05" → "GFL05").
-
-    - Already a filament_id ("GFL05", or a support one like "GFS99") →
-      returned unchanged.
-    - User presets ("P…") → returned unchanged.
-    - Empty / unknown → returned unchanged.
+    """Convert setting_id → filament_id (e.g. "GFSL05_07" → "GFL05"), with
+    any version suffix stripped.
+
+    - A setting_id Bambu gives a filament_id the rule does not lead to → that
+      filament_id, looked up by the full id, suffix included (#3387).
+    - Already a filament_id ("GFL05", a support one like "GFS99", or SUNLU's
+      "GFSNL03") → returned unchanged.
+    - User presets ("P…") → returned without the suffix.
+    - Empty / unknown → returned without the suffix.
     """
     if not setting_id:
         return setting_id
 
-    # User presets start with "P" - leave unchanged
-    if setting_id.startswith("P"):
-        return setting_id
+    listed = SETTING_ID_FILAMENT_IDS.get(setting_id)
+    if listed:
+        return listed
+    return derived_filament_id(setting_id.split("_")[0])
+
+
+def derived_filament_id(base: str) -> str:
+    """The filament_id the naming rule gives for a suffix-free setting_id,
+    without the table of exceptions. ``scripts/generate_filament_id_tables.py``
+    lists exactly the presets where this differs from Bambu's own id."""
+    sunlu = _SUNLU_SETTING_ID.fullmatch(base)
+    if sunlu:
+        return f"GFSNL{sunlu.group(1)}"
+    if _SUNLU_FILAMENT_ID.fullmatch(base):
+        return base
 
     # Setting_id format: GFSx## -> GFx##  (remove the "S"). A digit after
     # "GFS" means a support filament_id, not a setting_id (see above).
-    if setting_id.startswith("GFS") and len(setting_id) >= 5 and setting_id[3].isalpha():
-        return f"GF{setting_id[3:]}"
+    if base.startswith("GFS") and len(base) >= 5 and base[3].isalpha():
+        return f"GF{base[3:]}"
+
+    return base
 
-    return setting_id
+
+def orca_to_bambu_filament_id(filament_id: str) -> str:
+    """The Bambu filament_id for an OrcaSlicer one ("OFMUWNkp" → "GFSNL03").
+
+    OrcaSlicer's system presets carry ids of their own; the printer only knows
+    Bambu's. An Orca id the table does not know returns ``""`` -- sent to the
+    printer it would resolve nowhere. Any other id is returned unchanged.
+    """
+    if _ORCA_FILAMENT_ID.fullmatch(filament_id or ""):
+        return ORCA_FILAMENT_IDS.get(filament_id, "")
+    return filament_id
 
 
 def normalize_slicer_filament(slicer_filament: str | None) -> tuple[str, str]:
@@ -137,10 +187,11 @@ def normalize_slicer_filament(slicer_filament: str | None) -> tuple[str, str]:
     if not raw:
         return ("", "")
 
-    # Strip version suffix (e.g. "GFSL05_07" → "GFSL05")
+    # Strip version suffix (e.g. "GFSL05_07" → "GFSL05"); the filament_id is
+    # read from the full id, since the suffix can decide it (#3387).
     base = raw.split("_")[0] if "_" in raw else raw
 
-    tray_info_idx = setting_id_to_filament_id(base)
+    tray_info_idx = setting_id_to_filament_id(raw)
     sid = filament_id_to_setting_id(base)
 
     return (tray_info_idx, sid)

+ 3 - 1
backend/tests/unit/services/test_slicer_filament_resolver.py

@@ -357,7 +357,9 @@ class TestACustomPresetsOwnFilamentId:
                 slicer_filament_name=None,
                 material="PLA",
             )
-        assert idx == "GFNLS03"
+        # SUNLU PLA+'s own id: its setting_id does not follow the "drop the S"
+        # rule, which gave "GFNLS03", an id no profile has (#3387).
+        assert idx == "GFSNL03"
         assert sid == "PFUSfb87cd50b76616"
 
 

+ 227 - 0
backend/tests/unit/test_filament_ids_3387.py

@@ -0,0 +1,227 @@
+"""Filament ids that the "drop the S after GF" rule gets wrong (#3387).
+
+Bambu's public preset list carries each preset's setting_id next to the
+filament_id Bambu Studio sends to the printer. SUNLU's do not follow the rule
+("GFSNLS03_04" is "GFSNL03"), and a few P1/X1 presets carry another
+filament's id altogether, decided by the full setting_id.
+"""
+
+import re
+from pathlib import Path
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from backend.app.api.routes.cloud import _BUILTIN_FILAMENT_NAMES, get_slicer_settings
+from backend.app.utils.filament_id_tables import ORCA_FILAMENT_IDS, SETTING_ID_FILAMENT_IDS
+from backend.app.utils.filament_ids import (
+    derived_filament_id,
+    filament_id_to_setting_id,
+    normalize_slicer_filament,
+    orca_to_bambu_filament_id,
+    setting_id_to_filament_id,
+)
+
+REPO_ROOT = Path(__file__).resolve().parents[3]
+
+
+class TestSettingIdToFilamentId:
+    @pytest.mark.parametrize(
+        ("setting_id", "filament_id"),
+        [
+            ("GFSNLS03_04", "GFSNL03"),  # SUNLU PLA+ @BBL A1, the reported one
+            ("GFSNLS08", "GFSNL08"),  # SUNLU PETG @BBL X1C, no suffix
+            ("GFSA00_04", "GFA00"),  # Bambu PLA Basic @BBL A1, the plain rule
+            ("GFSA05_10", "GFA01"),  # Bambu PLA Matte @BBL P1P
+            ("GFSA05_02", "GFA05"),  # Bambu PLA Silk @BBL X1: same base, own id
+            ("GFSR99_10", "GFU99"),  # Generic TPU @BBL P1P
+            ("GFSR99_00", "GFR99"),  # Generic EVA @BBL X1C
+            ("GFSS02_10", "GFS00"),  # Bambu Support W @BBL P1P
+            ("GFSL23", "GFL00"),  # PolyLite PLA @BBL X1; "GFL23" is no filament
+        ],
+    )
+    def test_bambus_own_id(self, setting_id, filament_id):
+        assert setting_id_to_filament_id(setting_id) == filament_id
+
+    @pytest.mark.parametrize("filament_id", ["GFSNL03", "GFS99", "GFL05", "PFUS990b6e19965353", "P8a85d5a", ""])
+    def test_a_filament_id_or_user_preset_is_left_alone(self, filament_id):
+        assert setting_id_to_filament_id(filament_id) == filament_id
+
+    def test_a_bare_id_bambuddy_builds_keeps_the_rule(self):
+        """Bambuddy turns a filament id into a bare setting_id itself
+        ("GFA05" -> "GFSA05"). Bambu also has a legacy preset of that bare id
+        (Bambu PLA Matte @BBL X1, "GFA01"); following it would turn every such
+        Bambu PLA Silk spool into Matte."""
+        assert setting_id_to_filament_id(filament_id_to_setting_id("GFA05")) == "GFA05"
+        assert setting_id_to_filament_id("GFSR99") == "GFR99"
+
+
+class TestFilamentIdToSettingId:
+    @pytest.mark.parametrize(
+        ("filament_id", "setting_id"),
+        [("GFSNL03", "GFSNLS03"), ("GFSNLS03", "GFSNLS03"), ("GFA00", "GFSA00"), ("GFS99", "GFSS99")],
+    )
+    def test_converts(self, filament_id, setting_id):
+        assert filament_id_to_setting_id(filament_id) == setting_id
+
+    def test_normalize_reads_the_filament_id_from_the_full_id(self):
+        assert normalize_slicer_filament("GFSA05_10") == ("GFA01", "GFSA05")
+        assert normalize_slicer_filament("GFSNLS03_04") == ("GFSNL03", "GFSNLS03")
+        assert normalize_slicer_filament("GFSL05_07") == ("GFL05", "GFSL05")
+
+    def test_normalize_still_strips_the_suffix_of_anything_else(self):
+        """Free text that starts with "P" must keep reducing to its material,
+        which the resolver's guard then refuses, rather than going to the
+        printer as a 9-character id."""
+        assert normalize_slicer_filament("PLA_Basic") == ("PLA", "PLA")
+        assert normalize_slicer_filament("PFUSabc_01") == ("PFUSabc", "PFUSabc")
+
+
+class TestOrcaIds:
+    @pytest.mark.parametrize(
+        ("value", "expected"),
+        [("OFMUWNkp", "GFSNL03"), ("OFzzzzzz", ""), ("GFL99", "GFL99"), ("Pfc74047", "Pfc74047"), ("", "")],
+    )
+    def test_translates_orca_ids_only(self, value, expected):
+        assert orca_to_bambu_filament_id(value) == expected
+
+    def test_every_filament_orca_knows_has_a_builtin_name(self):
+        """The Orca lookup finds a system parent's id by name in the builtin
+        table; SUNLU missing from it sent those profiles out as Generic PLA."""
+        assert set(ORCA_FILAMENT_IDS.values()) <= set(_BUILTIN_FILAMENT_NAMES)
+
+
+class TestTheGeneratedTables:
+    def test_only_exceptions_are_listed(self):
+        for setting_id, filament_id in SETTING_ID_FILAMENT_IDS.items():
+            assert derived_filament_id(setting_id.split("_")[0]) != filament_id, setting_id
+
+    def test_no_bare_entry_shadows_a_real_filament(self):
+        """See test_a_bare_id_bambuddy_builds_keeps_the_rule."""
+        for setting_id in SETTING_ID_FILAMENT_IDS:
+            if "_" not in setting_id:
+                assert derived_filament_id(setting_id) not in _BUILTIN_FILAMENT_NAMES, setting_id
+
+    def test_the_frontend_copy_matches(self):
+        ts = (REPO_ROOT / "frontend" / "src" / "utils" / "filamentIdTables.ts").read_text()
+        assert dict(re.findall(r"^\s+(\w+): '(\w+)',$", ts, re.M)) == SETTING_ID_FILAMENT_IDS
+
+
+class TestTheCloudListCarriesTheId:
+    @pytest.mark.asyncio
+    async def test_filament_id_is_passed_through(self):
+        cloud = MagicMock()
+        cloud.is_authenticated = True
+        cloud.close = AsyncMock()
+        cloud.get_slicer_settings = AsyncMock(
+            return_value={
+                "filament": {
+                    "public": [
+                        {"setting_id": "GFSNLS03_04", "name": "SUNLU PLA+ @BBL A1", "filament_id": "GFSNL03"},
+                        {"setting_id": "GFSA00_04", "name": "Bambu PLA Basic @BBL A1", "filament_id": 7},
+                    ],
+                    "private": [{"setting_id": "PFUSfb87cd50b76616", "name": "Mine"}],
+                }
+            }
+        )
+        with patch("backend.app.api.routes.cloud.build_authenticated_cloud", AsyncMock(return_value=cloud)):
+            result = await get_slicer_settings(version="2.0.0.0", db=MagicMock(), current_user=None)
+
+        assert {s.setting_id: s.filament_id for s in result.filament} == {
+            "PFUSfb87cd50b76616": None,
+            "GFSNLS03_04": "GFSNL03",
+            "GFSA00_04": None,  # not a string: dropped, not a failed list
+        }
+
+
+class TestOrcaProfiles:
+    ORCA_ID = "34d8f588-860b-5be1-bcbe-c0d46d96324b"
+
+    async def _lookup(self, profile, others=()):
+        from backend.app.services.slicer_filament_resolver import lookup_orca_filament_id
+
+        svc = MagicMock()
+        svc.get_profile = AsyncMock(return_value=profile)
+        svc.list_profiles = AsyncMock(return_value=[profile, *others])
+        svc.close = AsyncMock()
+        with patch(
+            "backend.app.api.routes.orca_cloud._build_authenticated_service",
+            AsyncMock(return_value=svc),
+        ):
+            return await lookup_orca_filament_id(MagicMock(), None, self.ORCA_ID)
+
+    @pytest.mark.asyncio
+    async def test_inheriting_a_sunlu_system_preset(self):
+        child = {"id": self.ORCA_ID, "name": "My SUNLU", "content": {"inherits": "SUNLU PLA+ @BBL A1"}}
+        found = await self._lookup(child)
+        assert (found.filament_id, found.source) == ("GFSNL03", "inherited")
+        assert filament_id_to_setting_id(found.filament_id) == "GFSNLS03"
+
+    @pytest.mark.asyncio
+    async def test_an_orca_id_of_its_own_is_translated(self):
+        profile = {"id": self.ORCA_ID, "name": "Mine", "content": {"filament_id": "OFMUWNkp"}}
+        found = await self._lookup(profile)
+        assert (found.filament_id, found.source) == ("GFSNL03", "own")
+
+    @pytest.mark.asyncio
+    async def test_an_unknown_orca_id_never_reaches_the_printer(self):
+        profile = {"id": self.ORCA_ID, "name": "Mine", "content": {"filament_id": "OFzzzzzz"}}
+        found = await self._lookup(profile)
+        assert (found.filament_id, found.reason) == ("", "no_filament_id")
+
+    @pytest.mark.asyncio
+    async def test_an_unknown_orca_id_still_lets_a_parent_answer(self):
+        child = {"id": self.ORCA_ID, "name": "C", "content": {"filament_id": "OFzzzzzz", "inherits": "P"}}
+        parent = {"id": "p", "name": "P", "content": {"filament_id": "OF2GCW1l"}}
+        found = await self._lookup(child, [parent])
+        assert (found.filament_id, found.source) == ("GFSNL08", "inherited")
+
+
+class TestSpoolAssignmentSignedIn:
+    """With Bambu Cloud signed in, the resolver reads the detail of the
+    suffix-free base, which for "GFSA05" is Bambu PLA Matte @BBL X1. The
+    official id comes from the spool's full setting_id instead (#3387)."""
+
+    MATTE_X1 = {"name": "Bambu PLA Matte @BBL X1", "filament_id": "GFA01", "setting": {"filament_type": ["PLA"]}}
+
+    async def _resolve(self, slicer_filament, detail, name=None):
+        from backend.app.services.slicer_filament_resolver import resolve_slicer_filament
+
+        cloud = MagicMock()
+        cloud.is_authenticated = True
+        cloud.get_setting_detail = AsyncMock(return_value=detail)
+        cloud.close = AsyncMock()
+        with patch("backend.app.api.routes.cloud.build_authenticated_cloud", AsyncMock(return_value=cloud)):
+            return await resolve_slicer_filament(
+                db=MagicMock(),
+                current_user=None,
+                slicer_filament=slicer_filament,
+                slicer_filament_name=name,
+                material="PLA",
+            )
+
+    @pytest.mark.asyncio
+    @pytest.mark.parametrize("slicer_filament", ["GFSA05_07", "GFSA05"])
+    async def test_a_silk_spool_stays_silk(self, slicer_filament):
+        idx, sid, brand, ftype = await self._resolve(slicer_filament, self.MATTE_X1)
+        assert (idx, sid) == ("GFA05", "GFSA05")
+        # Matte's name and type are another filament's.
+        assert (brand, ftype) == (None, None)
+
+    @pytest.mark.asyncio
+    async def test_the_p1p_matte_spool_gets_matte(self):
+        idx, _sid, brand, ftype = await self._resolve("GFSA05_10", self.MATTE_X1)
+        assert (idx, brand, ftype) == ("GFA01", "Bambu PLA Matte", "PLA")
+
+    @pytest.mark.asyncio
+    async def test_a_spool_picked_as_the_legacy_bare_preset_gets_bambus_id(self):
+        """Picked in the spool form, the spool carries the preset's own name;
+        the bare id is then Bambu's preset, not one Bambuddy built."""
+        detail = {"name": "Bambu TPU 95A @BBL X1C", "filament_id": "GFU01", "setting": {"filament_type": ["TPU"]}}
+        idx, _sid, brand, ftype = await self._resolve("GFSU00", detail, name="Bambu TPU 95A @BBL X1C")
+        assert (idx, brand, ftype) == ("GFU01", "Bambu TPU 95A", "TPU")
+
+    @pytest.mark.asyncio
+    async def test_a_silk_spool_named_silk_stays_silk(self):
+        idx, *_ = await self._resolve("GFSA05", self.MATTE_X1, name="Bambu PLA Silk")
+        assert idx == "GFA05"

+ 90 - 0
frontend/src/__tests__/components/ConfigureAmsSlotModal.test.tsx

@@ -435,6 +435,96 @@ describe('ConfigureAmsSlotModal', () => {
     expect(api.getCloudSettingDetail).not.toHaveBeenCalled();
   });
 
+  it.each([
+    // Bambu's list carries the preset's own id; a new preset needs no rule.
+    ['from Bambu\'s list', 'GFSX01_02', 'New Filament @BBL A1', 'GFX07', 'GFX07'],
+    // An older backend sends no filament_id; the rule knows SUNLU's shape and
+    // the presets that carry another filament's id.
+    ['without one in the list', 'GFSNLS03_04', 'SUNLU PLA+ @BBL A1', null, 'GFSNL03'],
+    ['without one in the list', 'GFSA05_10', 'Bambu PLA Matte @BBL P1P', undefined, 'GFA01'],
+  ])('sends a GFS* preset\'s own filament_id %s (#3387)', async (_how, settingId, name, listed, expected) => {
+    (api.getCloudSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
+      filament: [{ setting_id: settingId, name, filament_id: listed }],
+    });
+    render(<ConfigureAmsSlotModal {...defaultProps} slotInfo={{ ...defaultProps.slotInfo, savedPresetId: settingId }} />);
+
+    await waitFor(() => {
+      expect(screen.getByText(name)).toBeInTheDocument();
+    });
+    fireEvent.click(screen.getByRole('button', { name: /Configure Slot/i }));
+    await waitFor(() => {
+      expect(api.configureAmsSlot).toHaveBeenCalled();
+    });
+
+    const payload = (api.configureAmsSlot as ReturnType<typeof vi.fn>).mock.calls[0][3];
+    expect(payload.tray_info_idx).toBe(expected);
+    expect(payload.setting_id).toBe(settingId);
+  });
+
+  // "GFSR99" is Generic TPU (GFU99) in Bambu's list, and the id Bambuddy
+  // itself saves for a Generic EVA (GFR99) slot; "GFSA05" likewise is Bambu
+  // PLA Matte @BBL X1 (GFA01) and Bambuddy's id for Bambu PLA Silk (GFA05).
+  const BARE = [
+    { setting_id: 'GFSR99', name: 'Generic TPU', filament_id: 'GFU99' },
+    { setting_id: 'GFSA05', name: 'Bambu PLA Matte @BBL X1', filament_id: 'GFA01' },
+  ];
+
+  it('sends the list\'s filament_id for a bare preset picked from the list (#3387)', async () => {
+    (api.getCloudSettings as ReturnType<typeof vi.fn>).mockResolvedValue({ filament: BARE });
+    render(<ConfigureAmsSlotModal {...defaultProps} />);
+
+    await waitFor(() => {
+      expect(screen.getByText('Generic TPU')).toBeInTheDocument();
+    });
+    fireEvent.click(screen.getByText('Generic TPU'));
+    await waitFor(() => {
+      expect(screen.getByRole('button', { name: /Configure Slot/i })).not.toBeDisabled();
+    });
+    fireEvent.click(screen.getByRole('button', { name: /Configure Slot/i }));
+    await waitFor(() => {
+      expect(api.configureAmsSlot).toHaveBeenCalled();
+    });
+    expect((api.configureAmsSlot as ReturnType<typeof vi.fn>).mock.calls[0][3].tray_info_idx).toBe('GFU99');
+  });
+
+  it.each([
+    ['a Silk slot Bambuddy saved', 'GFSA05', 'GFA05', 'GFA05'],
+    ['a Generic TPU slot picked from the list', 'GFSR99', 'GFU99', 'GFU99'],
+    ['a slot reporting neither', 'GFSR99', undefined, 'GFR99'],
+  ])('keeps the filament of %s when it is saved again (#3387)', async (_what, saved, tray, expected) => {
+    (api.getCloudSettings as ReturnType<typeof vi.fn>).mockResolvedValue({ filament: BARE });
+    render(<ConfigureAmsSlotModal {...defaultProps} slotInfo={{ ...defaultProps.slotInfo, savedPresetId: saved, trayInfoIdx: tray }} />);
+
+    await waitFor(() => {
+      expect(screen.getByText(BARE.find(p => p.setting_id === saved)!.name)).toBeInTheDocument();
+      expect(screen.getByRole('button', { name: /Configure Slot/i })).not.toBeDisabled();
+    });
+    fireEvent.click(screen.getByRole('button', { name: /Configure Slot/i }));
+    await waitFor(() => {
+      expect(api.configureAmsSlot).toHaveBeenCalled();
+    });
+    expect((api.configureAmsSlot as ReturnType<typeof vi.fn>).mock.calls[0][3].tray_info_idx).toBe(expected);
+  });
+
+  it('pre-selects a SUNLU preset from the id the slot reports (#3387)', async () => {
+    (api.getCloudSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
+      filament: [{ setting_id: 'GFSNLS03_04', name: 'SUNLU PLA+ @BBL A1', filament_id: 'GFSNL03' }],
+    });
+    render(<ConfigureAmsSlotModal {...defaultProps} slotInfo={{ ...defaultProps.slotInfo, trayInfoIdx: 'GFSNL03' }} />);
+
+    await waitFor(() => {
+      expect(screen.getByText('SUNLU PLA+ @BBL A1')).toBeInTheDocument();
+    });
+    await waitFor(() => {
+      expect(screen.getByRole('button', { name: /Configure Slot/i })).not.toBeDisabled();
+    });
+    fireEvent.click(screen.getByRole('button', { name: /Configure Slot/i }));
+    await waitFor(() => {
+      expect(api.configureAmsSlot).toHaveBeenCalled();
+    });
+    expect((api.configureAmsSlot as ReturnType<typeof vi.fn>).mock.calls[0][3].setting_id).toBe('GFSNLS03_04');
+  });
+
   it('sends no tray_info_idx when the cloud detail fetch fails (#3003)', async () => {
     // Network/5xx from /cloud/settings/{id} must not abort the configure flow.
     // It leaves no filament_id to send, and the PFUS default is not a usable

+ 73 - 0
frontend/src/__tests__/utils/filamentIds.test.ts

@@ -0,0 +1,73 @@
+import { describe, expect, it } from 'vitest';
+import { cloudPresetFilamentId, cloudPresetFilamentIds, filamentIdToSettingId, settingIdToFilamentId } from '../../utils/filamentIds';
+import { toFilamentId } from '../../components/spool-form/utils';
+import { slotPresetDescribesTray } from '../../utils/amsHelpers';
+
+// #3387: the "drop the S after GF" rule is wrong for SUNLU and for a few
+// P1/X1 presets that carry another filament's id. Mirrors
+// backend/tests/unit/test_filament_ids_3387.py.
+describe('settingIdToFilamentId', () => {
+  it.each([
+    ['GFSNLS03_04', 'GFSNL03'], // SUNLU PLA+ @BBL A1
+    ['GFSA00_04', 'GFA00'], // Bambu PLA Basic @BBL A1, the plain rule
+    ['GFSA05_10', 'GFA01'], // Bambu PLA Matte @BBL P1P
+    ['GFSA05_02', 'GFA05'], // Bambu PLA Silk @BBL X1
+    ['GFSA05', 'GFA05'], // the bare id Bambuddy builds from GFA05 keeps the rule
+    ['GFSR99_10', 'GFU99'], // Generic TPU @BBL P1P
+    ['GFSNL03', 'GFSNL03'], // already SUNLU's filament_id
+    ['GFS99', 'GFS99'], // a support filament_id
+    ['PFUScd84f663d2c2ef', 'PFUScd84f663d2c2ef'],
+  ])('%s -> %s', (id, expected) => {
+    expect(settingIdToFilamentId(id)).toBe(expected);
+  });
+});
+
+describe('filamentIdToSettingId', () => {
+  it.each([
+    ['GFSNL03', 'GFSNLS03'],
+    ['GFA00', 'GFSA00'],
+    ['GFS99', 'GFSS99'],
+    ['GFSL05', 'GFSL05'],
+    ['P8a85d5a', 'P8a85d5a'],
+  ])('%s -> %s', (id, expected) => {
+    expect(filamentIdToSettingId(id)).toBe(expected);
+  });
+});
+
+describe('toFilamentId', () => {
+  it('matches a SUNLU spool to its K-profile', () => {
+    expect(toFilamentId('GFSNLS03_04')).toBe(toFilamentId('GFSNL03'));
+  });
+
+  it('leaves a support filament_id whole', () => {
+    expect(toFilamentId('GFS99')).toBe('GFS99');
+  });
+});
+
+describe('slotPresetDescribesTray', () => {
+  it('knows the slot still holds a preset with an unusual id', () => {
+    expect(slotPresetDescribesTray('GFSNLS03_04', 'GFSNL03')).toBe(true);
+    expect(slotPresetDescribesTray('GFSA05_10', 'GFA01')).toBe(true);
+    expect(slotPresetDescribesTray('GFSA05_10', 'GFA05')).toBe(false);
+  });
+});
+
+describe('cloudPresetFilamentId', () => {
+  const silkOrMatte = { setting_id: 'GFSA05', filament_id: 'GFA01' };
+
+  it('takes the list\'s id for a suffixed preset', () => {
+    expect(cloudPresetFilamentId({ setting_id: 'GFSX01_02', filament_id: 'GFX07' })).toBe('GFX07');
+  });
+
+  it('reads an ambiguous bare id by the rule, unless it was picked from the list', () => {
+    expect(cloudPresetFilamentId(silkOrMatte)).toBe('GFA05');
+    expect(cloudPresetFilamentId(silkOrMatte, { pickedFromList: true })).toBe('GFA01');
+    expect(cloudPresetFilamentId(silkOrMatte, { slotTrayInfoIdx: 'GFA01' })).toBe('GFA01');
+    expect(cloudPresetFilamentId(silkOrMatte, { slotTrayInfoIdx: 'GFL99' })).toBe('GFA05');
+  });
+
+  it('matches a slot holding either filament', () => {
+    expect(cloudPresetFilamentIds(silkOrMatte)).toEqual(['GFA01', 'GFA05']);
+    expect(cloudPresetFilamentIds({ setting_id: 'PFUSab', filament_id: 'Pab12345' })).toEqual(['Pab12345']);
+  });
+});

+ 17 - 0
frontend/src/__tests__/utils/filamentPresets.test.ts

@@ -115,6 +115,23 @@ describe('buildFilamentPresetOptions', () => {
     expect(option.filamentId).toBe('GFG98');
   });
 
+  it("takes a cloud preset's filament id from Bambu's list when it carries one (#3387)", () => {
+    const [listed] = buildFilamentPresetOptions({
+      cloudSettings: [cloudSetting({ setting_id: 'GFSX01_02', name: 'New Filament', filament_id: 'GFX07' })],
+    });
+    expect(listed.filamentId).toBe('GFX07');
+    // A bare id that could name two filaments is the one picked by its name:
+    // "Generic PA" is GFN99, not the rule's GFN98 (Generic PA-CF).
+    const [bare] = buildFilamentPresetOptions({
+      cloudSettings: [cloudSetting({ setting_id: 'GFSN98', name: 'Generic PA', filament_id: 'GFN99' })],
+    });
+    expect(bare.filamentId).toBe('GFN99');
+    const [derived] = buildFilamentPresetOptions({
+      cloudSettings: [cloudSetting({ setting_id: 'GFSNLS03_04', name: 'SUNLU PLA+ @BBL A1' })],
+    });
+    expect(derived.filamentId).toBe('GFSNL03');
+  });
+
   it('leaves a cloud user preset unresolved for the detail lookup', () => {
     // PFUS ids are setting ids, not filament ids — the printer rejects them,
     // so guessing one here would file the calibration under nothing.

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

@@ -1831,6 +1831,9 @@ export interface SlicerSetting {
   user_id: string | null;
   updated_time: string | null;
   is_custom: boolean;
+  // The id Bambu Studio puts in an AMS slot for the preset; not derivable
+  // from setting_id for every preset (#3387). Absent from older backends.
+  filament_id?: string | null;
 }
 
 export interface SpoolCatalogEntry {

+ 23 - 33
frontend/src/components/ConfigureAmsSlotModal.tsx

@@ -6,6 +6,7 @@ import { api } from '../api/client';
 import type { KProfile } from '../api/client';
 import { matchesPrinterModelSuffix, presetCompatibility, buildCompatibilityIndex, extractPresetModel } from '../utils/slicerPrinterMatch';
 import { toFilamentId } from './spool-form/utils';
+import { cloudPresetFilamentId, cloudPresetFilamentIds, filamentIdToSettingId } from '../utils/filamentIds';
 import { Button } from './Button';
 import { ConfirmModal } from './ConfirmModal';
 import { getAmsLabel } from '../utils/amsHelpers';
@@ -27,26 +28,6 @@ interface SlotInfo {
   savedPresetId?: string;
 }
 
-// Convert setting_id to tray_info_idx (filament_id format)
-// Bambu format: setting_id "GFSL05" → tray_info_idx "GFL05"
-function convertToTrayInfoIdx(settingId: string): string {
-  // Strip version suffix if present (e.g., GFSL05_07 -> GFSL05)
-  const baseId = settingId.includes('_') ? settingId.split('_')[0] : settingId;
-
-  // Bambu presets start with "GFS" - remove the 'S' to get filament_id
-  if (baseId.startsWith('GFS')) {
-    return 'GF' + baseId.slice(3);
-  }
-
-  // User presets (PFUS*, PFSP*) - use the base setting_id (without version suffix)
-  // This follows the pattern that filament_id and setting_id share the same base ID
-  if (baseId.startsWith('PFUS') || baseId.startsWith('PFSP')) {
-    return baseId;  // Use base ID without version suffix
-  }
-
-  // For other formats, use as-is
-  return baseId;
-}
 
 interface ConfigureAmsSlotModalProps {
   isOpen: boolean;
@@ -296,6 +277,15 @@ export function ConfigureAmsSlotModal({
   // on. Lets the K-profile list tell "reopened the slot" from "picked another
   // filament" (#3216).
   const [openedPresetId, setOpenedPresetId] = useState<string>('');
+  // How a cloud preset's filament id is read where its bare id could name two
+  // filaments (#3387): the preset the slot was opened with by what the slot
+  // reports, any other as the preset picked from Bambu's list.
+  const slotPresetFilament = useCallback(
+    (presetId: string) => (presetId === openedPresetId
+      ? { slotTrayInfoIdx: slotInfo.trayInfoIdx }
+      : { pickedFromList: true }),
+    [openedPresetId, slotInfo.trayInfoIdx],
+  );
   const [selectedKProfile, setSelectedKProfile] = useState<KProfile | null>(null);
   // The same value, readable at mutation-execute time rather than at
   // closure-capture time. useMutation hands its options to the observer from an
@@ -523,7 +513,10 @@ export function ConfigureAmsSlotModal({
         trayInfoIdx = builtinFilamentId!;
         settingId = '';
       } else {
-        trayInfoIdx = convertToTrayInfoIdx(selectedPresetId);
+        // The filament id from Bambu's list (#3387). The preset the slot was
+        // opened with keeps the filament the slot reports where its bare id
+        // could name two.
+        trayInfoIdx = cloudPresetFilamentId(selectedPreset!, slotPresetFilament(selectedPresetId));
         settingId = selectedPresetId;
 
         // User cloud presets may carry a distinct filament_id in the cloud detail
@@ -743,7 +736,7 @@ export function ConfigureAmsSlotModal({
         // Keep preset if it matches the slot's saved mapping or current tray_info_idx
         const isSavedPreset = savedId === cp.setting_id;
         const isCurrentPreset = isSavedPreset
-          || (trayIdx && (cp.setting_id === trayIdx || convertToTrayInfoIdx(cp.setting_id) === trayIdx));
+          || (trayIdx && (cp.setting_id === trayIdx || cloudPresetFilamentIds(cp).includes(trayIdx)));
         // Search filter applies to ALL presets (including saved) — no bypass
         if (query && !cp.name.toLowerCase().includes(query)) continue;
         // Filter by printer model if set (skip for current preset). Uses the
@@ -799,9 +792,7 @@ export function ConfigureAmsSlotModal({
         if (coveredIds.has(bf.filament_id)) continue;
         // Convert filament_id to setting_id format for cloud compatibility
         // e.g. "GFA00" → cloud setting_id would be "GFSA00" (insert S after GF)
-        const settingId = bf.filament_id.startsWith('GF')
-          ? 'GFS' + bf.filament_id.slice(2)
-          : bf.filament_id;
+        const settingId = filamentIdToSettingId(bf.filament_id);
         if (coveredIds.has(settingId)) continue;
         if (!query || bf.name.toLowerCase().includes(query)) {
           items.push({ id: `builtin_${bf.filament_id}`, name: bf.name, source: 'builtin', isUser: false });
@@ -854,9 +845,8 @@ export function ConfigureAmsSlotModal({
         const cp = cloudSettings.filament.find(p => p.setting_id === selectedPresetId);
         presetName = cp?.name || null;
         if (cp) {
-          // SlicerSetting only carries setting_id ("GFSG98_09"); toFilamentId
-          // drops the variant suffix and the "S" infix to yield "GFG98".
-          filamentId = toFilamentId(cp.setting_id);
+          // The same filament id the slot is sent (#3387).
+          filamentId = toFilamentId(cloudPresetFilamentId(cp, slotPresetFilament(cp.setting_id)));
         }
       }
     }
@@ -878,7 +868,7 @@ export function ConfigureAmsSlotModal({
       brand: parsed.brand,
       filamentId,
     };
-  }, [selectedPresetId, cloudSettings?.filament, localPresets?.filament, builtinFilaments, orcaCloudList?.filament]);
+  }, [selectedPresetId, cloudSettings?.filament, localPresets?.filament, builtinFilaments, orcaCloudList?.filament, slotPresetFilament]);
 
   // For backwards compatibility with the label
   const selectedMaterial = selectedPresetInfo?.fullName || '';
@@ -1135,11 +1125,11 @@ export function ConfigureAmsSlotModal({
         let currentPreset = cloudSettings.filament.find(
           p => p.setting_id === slotInfo.trayInfoIdx
         );
-        // Then try matching by converting setting_id → filament_id format
+        // Then by filament_id: the one Bambu's list gives first, then one the
+        // rule reads from a bare id (#3387).
         if (!currentPreset) {
-          currentPreset = cloudSettings.filament.find(
-            p => convertToTrayInfoIdx(p.setting_id) === slotInfo.trayInfoIdx
-          );
+          currentPreset = cloudSettings.filament.find(p => p.filament_id === slotInfo.trayInfoIdx)
+            || cloudSettings.filament.find(p => cloudPresetFilamentIds(p).includes(slotInfo.trayInfoIdx!));
         }
         if (currentPreset) {
           setSelectedPresetId(currentPreset.setting_id);

+ 8 - 15
frontend/src/components/spool-form/utils.ts

@@ -1,6 +1,7 @@
 import { api } from '../../api/client';
 import type { SlicerSetting, LocalPreset, BuiltinFilament, SpoolKProfileInput } from '../../api/client';
 import { installedNozzleDiameters } from '../../utils/amsHelpers';
+import { filamentIdToSettingId, settingIdToFilamentId } from '../../utils/filamentIds';
 import type { CalibrationProfile, ColorPreset, FilamentOption } from './types';
 import { KNOWN_VARIANTS, DEFAULT_BRANDS, RECENT_COLORS_KEY, MAX_RECENT_COLORS, STANDARD_NOZZLE_DIAMETERS } from './constants';
 
@@ -282,9 +283,7 @@ export function buildFilamentOptions(
   const builtinOptions: FilamentOption[] = [];
   if (builtinFilaments && builtinFilaments.length > 0) {
     for (const bf of builtinFilaments) {
-      const settingId = bf.filament_id.startsWith('GF')
-        ? 'GFS' + bf.filament_id.slice(2)
-        : bf.filament_id;
+      const settingId = filamentIdToSettingId(bf.filament_id);
       if (cloudCodes.has(bf.filament_id) || cloudCodes.has(settingId)) continue;
       builtinOptions.push({
         code: bf.filament_id,
@@ -391,20 +390,14 @@ export function saveRecentColor(color: ColorPreset, currentRecent: ColorPreset[]
 // Spools store ``slicer_filament`` as a setting_id like "GFSG98_09" (the "_NN"
 // suffix is the variant, the "S" infix marks it as a setting_id); printer
 // K-profiles store ``filament_id`` as "GFG98" (bare). Both shapes need
-// normalising before comparison.
+// normalising before comparison. The rule and its exceptions (SUNLU, a few
+// P1/X1 presets, #3387) live in ``utils/filamentIds``.
 //
-// This is the inverse of the filament_id→setting_id mapping at
-// ``buildFilamentOptions`` ("GFS" + filament_id.slice(2)), so a round-trip
-// stays consistent. Non-Bambu IDs (numeric local-preset IDs, Orca UUIDs)
-// are returned unchanged uppercase — they won't match any K-profile's
-// filament_id and the caller falls through to name-based matching.
+// Non-Bambu IDs (numeric local-preset IDs, Orca UUIDs) are returned unchanged
+// uppercase — they won't match any K-profile's filament_id and the caller
+// falls through to name-based matching.
 export function toFilamentId(id: string | null | undefined): string {
-  if (!id) return '';
-  // Drop "_NN" variant suffix.
-  let s = id.split('_')[0];
-  // Strip the "S" infix in "GFS..." so "GFSG98" → "GFG98".
-  if (/^GFS/i.test(s)) s = s.slice(0, 2) + s.slice(3);
-  return s.toUpperCase();
+  return settingIdToFilamentId((id || '').toUpperCase());
 }
 
 // "GFx99" identifiers (GFL99, GFG99, GFB99, ...) are Bambu's *generic* filament

+ 4 - 2
frontend/src/utils/amsHelpers.ts

@@ -4,6 +4,7 @@
  * for AMS, AMS-HT, and external spool configurations.
  */
 import { parseUTCDate } from './date';
+import { settingIdToFilamentId } from './filamentIds';
 
 /**
  * Normalize color format from various sources for CSS rendering.
@@ -719,8 +720,9 @@ export function slotPresetDescribesTray(
   const tray = (trayInfoIdx || '').split('_')[0].toUpperCase();
   const recorded = (presetTrayInfoIdx || '').split('_')[0].toUpperCase();
   if (recorded && tray) return recorded === tray;
-  if (!preset.startsWith('GFS') || !tray.startsWith('GF') || tray.startsWith('GFS')) return true;
-  return `GF${preset.slice(3)}` === tray;
+  if (!preset.startsWith('GFS') || !tray.startsWith('GF')) return true;
+  // The full id, suffix included, decides some presets' filament_id (#3387).
+  return settingIdToFilamentId((presetId || '').toUpperCase()) === tray;
 }
 
 export interface AmsTrayLike {

+ 30 - 0
frontend/src/utils/filamentIdTables.ts

@@ -0,0 +1,30 @@
+// Generated by scripts/generate_filament_id_tables.py -- do not edit by hand.
+// Bambu setting_id -> filament_id, where the naming rule gives another id.
+
+export const SETTING_ID_FILAMENT_IDS: Readonly<Record<string, string>> = {
+  GFSA04: 'GFA00',
+  GFSA04_10: 'GFA00',
+  GFSA04_11: 'GFA00',
+  GFSA05_10: 'GFA01',
+  GFSA05_28: 'GFA01',
+  GFSG50_10: 'GFG98',
+  GFSL18: 'GFL01',
+  GFSL19: 'GFL00',
+  GFSL22: 'GFL01',
+  GFSL22_10: 'GFL01',
+  GFSL23: 'GFL00',
+  GFSL23_10: 'GFL00',
+  GFSL24: 'GFL01',
+  GFSL24_10: 'GFL01',
+  GFSL25: 'GFL00',
+  GFSL25_10: 'GFL00',
+  GFSL99_01: 'GFL96',
+  GFSL99_12: 'GFL96',
+  GFSN00: 'GFN03',
+  GFSN00_10: 'GFN03',
+  GFSN98_10: 'GFN99',
+  GFSN99_10: 'GFN98',
+  GFSP97_03: 'GFP95',
+  GFSR99_10: 'GFU99',
+  GFSS02_10: 'GFS00',
+};

+ 72 - 0
frontend/src/utils/filamentIds.ts

@@ -0,0 +1,72 @@
+// Bambu filament ids: the printer-side filament_id ("GFL05", reported as an AMS
+// slot's tray_info_idx) and the cloud preset's setting_id ("GFSL05_07").
+// Mirrors backend/app/utils/filament_ids.py.
+//
+// For most official presets the setting_id is the filament_id with an "S"
+// after "GF" plus a variant suffix. Two kinds are not (#3387): SUNLU's
+// setting_id "GFSNLS03_04" is filament_id "GFSNL03", and about thirty P1/X1
+// presets carry another filament's id altogether, listed by full setting_id
+// in SETTING_ID_FILAMENT_IDS. When Bambu's list hands over the preset's own
+// filament_id, use that; these are for ids that come without one.
+import { SETTING_ID_FILAMENT_IDS } from './filamentIdTables';
+
+const SUNLU_SETTING_ID = /^GFSNLS(\d+)$/;
+const SUNLU_FILAMENT_ID = /^GFSNL\d+$/;
+
+/** The filament_id for a setting_id; any other id comes back without its
+ *  variant suffix. Case is kept: user preset ids carry lowercase hex. */
+export function settingIdToFilamentId(id: string | null | undefined): string {
+  if (!id) return '';
+  const listed = SETTING_ID_FILAMENT_IDS[id];
+  if (listed) return listed;
+  const base = id.split('_')[0];
+  const sunlu = SUNLU_SETTING_ID.exec(base);
+  if (sunlu) return `GFSNL${sunlu[1]}`;
+  if (SUNLU_FILAMENT_ID.test(base)) return base;
+  // "GFS" then the family letter is a setting_id. A digit after "GFS" is a
+  // support filament_id ("GFS99", Generic PVA), already in its final form.
+  if (/^GFS[A-Z]/.test(base)) return 'GF' + base.slice(3);
+  return base;
+}
+
+/** The base setting_id for a filament_id ("GFA00" → "GFSA00", "GFSNL03" →
+ *  "GFSNLS03"); anything that is not a Bambu filament_id is returned as is. */
+export function filamentIdToSettingId(id: string): string {
+  if (SUNLU_FILAMENT_ID.test(id)) return 'GFSNLS' + id.slice(5);
+  if (/^GFS[A-Z]/.test(id)) return id;
+  return id.startsWith('GF') ? 'GFS' + id.slice(2) : id;
+}
+
+interface ListedPreset {
+  setting_id: string;
+  filament_id?: string | null;
+}
+
+/** A bare official setting_id for which Bambu's list and the rule name two
+ *  filaments: "GFSA05" is Bambu PLA Matte @BBL X1 (GFA01) in Bambu's list, and
+ *  the id Bambuddy itself builds for Bambu PLA Silk (GFA05). */
+function ambiguous(preset: ListedPreset, derived: string): boolean {
+  return !!preset.filament_id && preset.filament_id !== derived
+    && preset.setting_id.startsWith('GFS') && !preset.setting_id.includes('_');
+}
+
+/** A cloud preset's filament_id. Bambu's list carries it, but for an
+ *  ambiguous bare id (see above) it says only which filament a preset picked
+ *  from the list is; where Bambuddy may have built the id itself, the rule
+ *  reads it, as the backend does. ``slotTrayInfoIdx``, for the preset a slot
+ *  already holds, settles it by what the slot reports. */
+export function cloudPresetFilamentId(
+  preset: ListedPreset,
+  { pickedFromList = false, slotTrayInfoIdx }: { pickedFromList?: boolean; slotTrayInfoIdx?: string } = {},
+): string {
+  const derived = settingIdToFilamentId(preset.setting_id);
+  if (!ambiguous(preset, derived)) return preset.filament_id || derived;
+  if (slotTrayInfoIdx && (slotTrayInfoIdx === preset.filament_id || slotTrayInfoIdx === derived)) return slotTrayInfoIdx;
+  return pickedFromList ? preset.filament_id! : derived;
+}
+
+/** Every filament_id a cloud preset may stand for, to match a slot against. */
+export function cloudPresetFilamentIds(preset: ListedPreset): string[] {
+  const derived = settingIdToFilamentId(preset.setting_id);
+  return ambiguous(preset, derived) ? [preset.filament_id!, derived] : [preset.filament_id || derived];
+}

+ 10 - 6
frontend/src/utils/filamentPresets.ts

@@ -11,7 +11,8 @@
 // and the only one that works with no cloud account and nothing imported.
 
 import type { BuiltinFilament, LocalPreset, OrcaProfileMeta, SlicerSetting } from '../api/client';
-import { parsePresetName, toFilamentId } from '../components/spool-form/utils';
+import { parsePresetName } from '../components/spool-form/utils';
+import { cloudPresetFilamentId } from './filamentIds';
 
 export type FilamentPresetSource = 'local' | 'orca_cloud' | 'cloud' | 'builtin';
 
@@ -173,16 +174,19 @@ export function buildFilamentPresetOptions(sources: FilamentPresetSources): Fila
     });
   }
 
-  // 3. Bambu Cloud. Official presets (GFS…) carry their filament id in the
-  // setting_id itself; user presets (PFUS… / PFCN…) do not, and toFilamentId
-  // would hand back the raw cloud id, which the printer rejects. Leave those
-  // empty here and let resolveFilamentId fetch the detail on selection.
+  // 3. Bambu Cloud. Bambu's list carries each preset's filament id; without
+  // it, official presets (GFS…) give theirs by their setting_id. User presets
+  // (PFUS… / PFCN…) do not, and the rule would hand back the raw cloud id,
+  // which the printer rejects. Leave those empty here and let
+  // resolveFilamentId fetch the detail on selection. These options are picked
+  // by Bambu's name, so a bare official id that could name two filaments is
+  // the one Bambu's list says (see cloudPresetFilamentId, #3387).
   for (const cp of cloudSettings ?? []) {
     const name = presetDisplayName(cp.name);
     // Cloud setting_ids carry a variant suffix ("GFSA00_01"); claim the bare
     // filament id as well, or the built-in tier won't recognise the filament
     // as covered and will list it again under its own heading.
-    const filamentId = cp.setting_id.startsWith('GFS') ? toFilamentId(cp.setting_id) : '';
+    const filamentId = cp.setting_id.startsWith('GFS') ? cloudPresetFilamentId(cp, { pickedFromList: true }) : '';
     if (!take('cloud', name, cp.setting_id, filamentId || undefined)) continue;
     options.push({
       id: cp.setting_id,

+ 106 - 0
scripts/generate_filament_id_tables.py

@@ -0,0 +1,106 @@
+"""Regenerate the filament-id tables from Bambu's and OrcaSlicer's own data (#3387).
+
+Usage:
+    python scripts/generate_filament_id_tables.py [BAMBU_PUBLIC_JSON] [ORCA_IDS_JSON]
+
+With no arguments both sources are downloaded:
+
+  Bambu Cloud's public preset list (no login needed). Every filament entry
+  carries its setting_id and the filament_id Bambu Studio sends to the printer
+  for it. For most, dropping the "S" after "GF" turns one into the other; the
+  presets where it does not (beyond SUNLU, which ``derived_filament_id``
+  handles by rule) go into SETTING_ID_FILAMENT_IDS, by full setting_id --
+  the suffix can decide it ("GFSA05_10" is Bambu PLA Matte, every other
+  "GFSA05_nn" Bambu PLA Silk).
+
+  A bare setting_id is listed only when the rule gives an id no preset has.
+  Bambuddy itself builds bare ids from filament ids ("GFA05" -> "GFSA05"), so
+  where the rule leads to a real filament ("GFSA05" -> Bambu PLA Silk), it
+  wins over Bambu's legacy preset of that bare id ("GFSA05" is also Bambu PLA
+  Matte @BBL X1): listing it would turn every such Silk spool into Matte.
+
+  OrcaSlicer's resources/printers/bambu_filament_ids.json, the table Orca uses
+  to translate its own filament ids ("OFMUWNkp") to Bambu's at the printer.
+  It becomes ORCA_FILAMENT_IDS.
+
+Writes backend/app/utils/filament_id_tables.py and
+frontend/src/utils/filamentIdTables.ts (the frontend needs only the first
+table). Run it by hand when Bambu or Orca add filaments, and review the diff.
+"""
+
+import json
+import sys
+import urllib.request
+from pathlib import Path
+
+REPO_ROOT = Path(__file__).resolve().parent.parent
+sys.path.insert(0, str(REPO_ROOT))
+
+from backend.app.utils.filament_ids import derived_filament_id  # noqa: E402
+
+BAMBU_URL = "https://api.bambulab.com/v1/iot-service/api/slicer/setting?version=2.0.0.0&public=true"
+ORCA_URL = "https://raw.githubusercontent.com/OrcaSlicer/OrcaSlicer/main/resources/printers/bambu_filament_ids.json"
+BACKEND_OUT = REPO_ROOT / "backend" / "app" / "utils" / "filament_id_tables.py"
+FRONTEND_OUT = REPO_ROOT / "frontend" / "src" / "utils" / "filamentIdTables.ts"
+
+
+def _load(arg: str | None, url: str) -> dict:
+    if arg:
+        return json.loads(Path(arg).read_text())
+    request = urllib.request.Request(url, headers={"User-Agent": "bambuddy-filament-id-tables"})
+    with urllib.request.urlopen(request, timeout=30) as response:
+        return json.load(response)
+
+
+def main() -> None:
+    args = sys.argv[1:]
+    bambu = _load(args[0] if args else None, BAMBU_URL)
+    orca = _load(args[1] if len(args) > 1 else None, ORCA_URL)
+
+    presets = (bambu.get("filament") or {}).get("public") or []
+    if not presets:
+        sys.exit("Bambu's public list has no filament presets")
+    real_ids = {preset.get("filament_id") for preset in presets}
+    setting_ids: dict[str, str] = {}
+    for preset in presets:
+        setting_id, filament_id = preset.get("setting_id"), preset.get("filament_id")
+        if not setting_id or not filament_id:
+            continue
+        derived = derived_filament_id(setting_id.split("_")[0])
+        if derived == filament_id or ("_" not in setting_id and derived in real_ids):
+            continue
+        setting_ids[setting_id] = filament_id
+
+    orca_ids = {
+        orca_id: entry["bambu_id"]
+        for orca_id, entry in (orca.get("filaments") or {}).items()
+        if isinstance(entry, dict) and entry.get("bambu_id")
+    }
+    if not orca_ids:
+        sys.exit("Orca's table has no filaments")
+
+    header = "Generated by scripts/generate_filament_id_tables.py -- do not edit by hand."
+    lines = [f'"""{header}', "", f"{len(presets)} public Bambu filament presets read.", '"""', ""]
+    lines.append("# Bambu setting_id -> filament_id, where the naming rule gives another id.")
+    lines.append("SETTING_ID_FILAMENT_IDS: dict[str, str] = {")
+    lines += [f'    "{k}": "{v}",' for k, v in sorted(setting_ids.items())]
+    lines.append("}")
+    lines.append("")
+    commit = orca.get("bambustudio_commit", "unknown")
+    lines.append(f"# OrcaSlicer filament_id -> Bambu filament_id (Orca's table, Bambu Studio {commit}).")
+    lines.append("ORCA_FILAMENT_IDS: dict[str, str] = {")
+    lines += [f'    "{k}": "{v}",' for k, v in sorted(orca_ids.items())]
+    lines.append("}")
+    BACKEND_OUT.write_text("\n".join(lines) + "\n")
+
+    ts = [f"// {header}", "// Bambu setting_id -> filament_id, where the naming rule gives another id.", ""]
+    ts.append("export const SETTING_ID_FILAMENT_IDS: Readonly<Record<string, string>> = {")
+    ts += [f"  {k}: '{v}'," for k, v in sorted(setting_ids.items())]
+    ts.append("};")
+    FRONTEND_OUT.write_text("\n".join(ts) + "\n")
+
+    print(f"{len(setting_ids)} setting_id exceptions, {len(orca_ids)} Orca ids")
+
+
+if __name__ == "__main__":
+    main()

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/AdditionalSection-BimvJ0xv.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/AmsUnitCard-LjifBnXb.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/ArchivesPage-CD9bSnXn.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/AssignToAmsModal-BsyuEwE5.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/CamWallPage-1utunR8O.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/CameraPage-C4yooUV8.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/ConfigureAmsSlotModal-CPX90h9N.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/ConfigureAmsSlotModal-DROsw5sr.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/ConfirmOutcomeDialog-C2TBZ334.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/FileManagerPage-C-wPxCh7.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/InventoryPage-Eg1j025Y.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/Layout-DBQNIECX.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/ModelSourcesPage-je7rokjR.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/PhotoGalleryModal-CchfaL7x.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/PrintModal-CZIbcLUP.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/PrintersPage-igaHjmyD.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/ProfilesPage-CFPee9BW.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/ProfilesPage-CJK7CLAE.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/ProjectDetailPage-D8ohs5e6.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/QueuePage-3XtRgZmq.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/RichTextEditor-CB7OdrH0.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/SettingsPage-D-fJUb-Y.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/SliceModal-DYHa-gYe.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/SpoolBuddyAmsPage-K7eJULyB.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/SpoolBuddyDashboard-I6XOQWjB.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/SpoolBuddyInventoryPage-BIL4svSB.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 1 - 1
static/assets/SpoolBuddySettingsPage-Da_PmwT6.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/SpoolBuddyWriteTagPage-u9VsCLPe.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/amsHelpers-DDsrDUnz.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/amsHelpers-Dlwij40i.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/colors-BBBvvSKR.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/index-CnjrYt4Y.js


+ 1 - 1
static/assets/inventorySearch-CV2XH-nr.js → static/assets/inventorySearch-BT7neCFO.js

@@ -1 +1 @@
-import{c as e}from"./colors-BF4_h-XA.js";function t(e){let t=e.trim().match(/^#(\d+)$/);return t?Number(t[1]):null}function n(n,r){let i=r.trim().toLowerCase();if(!i)return!0;let a=t(i);return a===null?String(n.id).includes(i)||n.material.toLowerCase().includes(i)||(n.brand?.toLowerCase().includes(i)??!1)||(n.color_name?.toLowerCase().includes(i)??!1)||(e(n.color_name,n.rgba,n.color_name_is_synthesized)?.toLowerCase().includes(i)??!1)||(n.subtype?.toLowerCase().includes(i)??!1)||(n.note?.toLowerCase().includes(i)??!1)||(n.slicer_filament_name?.toLowerCase().includes(i)??!1)||(n.storage_location?.toLowerCase().includes(i)??!1)||(n.material_number?.toLowerCase().includes(i)??!1)||(n.suppliers?.some(e=>e.supplier_name.toLowerCase().includes(i)||(e.supplier_article_number?.toLowerCase().includes(i)??!1))??!1):n.id===a}function r(e,t){return t?e.filter(e=>n(e,t)):e}export{r as n,t};
+import{c as e}from"./colors-BBBvvSKR.js";function t(e){let t=e.trim().match(/^#(\d+)$/);return t?Number(t[1]):null}function n(n,r){let i=r.trim().toLowerCase();if(!i)return!0;let a=t(i);return a===null?String(n.id).includes(i)||n.material.toLowerCase().includes(i)||(n.brand?.toLowerCase().includes(i)??!1)||(n.color_name?.toLowerCase().includes(i)??!1)||(e(n.color_name,n.rgba,n.color_name_is_synthesized)?.toLowerCase().includes(i)??!1)||(n.subtype?.toLowerCase().includes(i)??!1)||(n.note?.toLowerCase().includes(i)??!1)||(n.slicer_filament_name?.toLowerCase().includes(i)??!1)||(n.storage_location?.toLowerCase().includes(i)??!1)||(n.material_number?.toLowerCase().includes(i)??!1)||(n.suppliers?.some(e=>e.supplier_name.toLowerCase().includes(i)||(e.supplier_article_number?.toLowerCase().includes(i)??!1))??!1):n.id===a}function r(e,t){return t?e.filter(e=>n(e,t)):e}export{r as n,t};

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/locationSensorDefaults-BkEDE4Rl.js


+ 0 - 1
static/assets/useColorCatalogVersion-DPkJwK8A.js

@@ -1 +0,0 @@
-import{i as e}from"./rolldown-runtime-aKtaBQYM.js";import{k as t}from"./client-Wixdakxn.js";import{d as n,r}from"./colors-BF4_h-XA.js";var i=e(t(),1);function a(){return(0,i.useSyncExternalStore)(n,r,r)}export{a as t};

+ 1 - 0
static/assets/useColorCatalogVersion-me0iWO3m.js

@@ -0,0 +1 @@
+import{i as e}from"./rolldown-runtime-aKtaBQYM.js";import{k as t}from"./client-Wixdakxn.js";import{d as n,r}from"./colors-BBBvvSKR.js";var i=e(t(),1);function a(){return(0,i.useSyncExternalStore)(n,r,r)}export{a as t};

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/utils-B5TX3uzf.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/utils-CRm_p6_K.js


+ 7 - 7
static/index.html

@@ -26,7 +26,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-C7RCsWDW.js"></script>
+    <script type="module" crossorigin src="/assets/index-CnjrYt4Y.js"></script>
     <link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-aKtaBQYM.js">
     <link rel="modulepreload" crossorigin href="/assets/client-Wixdakxn.js">
     <link rel="modulepreload" crossorigin href="/assets/react-dom-9hW1swN1.js">
@@ -91,15 +91,15 @@
     <link rel="modulepreload" crossorigin href="/assets/Button-Bj3LhBE2.js">
     <link rel="modulepreload" crossorigin href="/assets/ConfirmModal-Cswi0gVe.js">
     <link rel="modulepreload" crossorigin href="/assets/date-G9KUauqz.js">
-    <link rel="modulepreload" crossorigin href="/assets/amsHelpers-Dlwij40i.js">
-    <link rel="modulepreload" crossorigin href="/assets/colors-BF4_h-XA.js">
+    <link rel="modulepreload" crossorigin href="/assets/amsHelpers-DDsrDUnz.js">
+    <link rel="modulepreload" crossorigin href="/assets/colors-BBBvvSKR.js">
     <link rel="modulepreload" crossorigin href="/assets/currency-R7oUEamO.js">
     <link rel="modulepreload" crossorigin href="/assets/useUiSettings-BC4O5ZgX.js">
     <link rel="modulepreload" crossorigin href="/assets/NumberInput-VlZYDJ-L.js">
     <link rel="modulepreload" crossorigin href="/assets/printer-DfB6SVGc.js">
     <link rel="modulepreload" crossorigin href="/assets/filamentSwatchHelpers-C3r5m07g.js">
     <link rel="modulepreload" crossorigin href="/assets/FilamentSwatch-CAdwRRk_.js">
-    <link rel="modulepreload" crossorigin href="/assets/PrintModal-Didc4a40.js">
+    <link rel="modulepreload" crossorigin href="/assets/PrintModal-CZIbcLUP.js">
     <link rel="modulepreload" crossorigin href="/assets/download-CIYQYgRy.js">
     <link rel="modulepreload" crossorigin href="/assets/eye-DotmdMYm.js">
     <link rel="modulepreload" crossorigin href="/assets/folder-kanban-CmkoZHYI.js">
@@ -110,7 +110,7 @@
     <link rel="modulepreload" crossorigin href="/assets/trash-2-BuReB2M2.js">
     <link rel="modulepreload" crossorigin href="/assets/weight-vy0UgZLK.js">
     <link rel="modulepreload" crossorigin href="/assets/projectTree-C5LrJHZP.js">
-    <link rel="modulepreload" crossorigin href="/assets/ConfirmOutcomeDialog-WjHNMaSV.js">
+    <link rel="modulepreload" crossorigin href="/assets/ConfirmOutcomeDialog-C2TBZ334.js">
     <link rel="modulepreload" crossorigin href="/assets/key-DP9lXZjm.js">
     <link rel="modulepreload" crossorigin href="/assets/log-out-DEHZfwc9.js">
     <link rel="modulepreload" crossorigin href="/assets/shield-alert-DhDK-8wS.js">
@@ -119,10 +119,10 @@
     <link rel="modulepreload" crossorigin href="/assets/wifi-off-EtDnmBfi.js">
     <link rel="modulepreload" crossorigin href="/assets/ThemeContext-DGpnVTMH.js">
     <link rel="modulepreload" crossorigin href="/assets/useIsSidebarCompact-Ck-Ma4uk.js">
-    <link rel="modulepreload" crossorigin href="/assets/useColorCatalogVersion-DPkJwK8A.js">
+    <link rel="modulepreload" crossorigin href="/assets/useColorCatalogVersion-me0iWO3m.js">
     <link rel="modulepreload" crossorigin href="/assets/circle-minus-BWJkPGS-.js">
     <link rel="modulepreload" crossorigin href="/assets/ConnectionDiagnostic-DSIiTSOB.js">
-    <link rel="modulepreload" crossorigin href="/assets/Layout-B4aNzgjw.js">
+    <link rel="modulepreload" crossorigin href="/assets/Layout-DBQNIECX.js">
     <link rel="stylesheet" crossorigin href="/assets/index-DAazpymq.css">
   </head>
   <body>

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů