瀏覽代碼

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 1 天之前
父節點
當前提交
dab7c60a9f
共有 58 個文件被更改,包括 951 次插入 和 91 次删除
  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()

文件差異過大導致無法顯示
+ 0 - 0
static/assets/AdditionalSection-BimvJ0xv.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/AmsUnitCard-LjifBnXb.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/ArchivesPage-CD9bSnXn.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/AssignToAmsModal-BsyuEwE5.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/CamWallPage-1utunR8O.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/CameraPage-C4yooUV8.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/ConfigureAmsSlotModal-CPX90h9N.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/ConfigureAmsSlotModal-DROsw5sr.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/ConfirmOutcomeDialog-C2TBZ334.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/FileManagerPage-C-wPxCh7.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/InventoryPage-Eg1j025Y.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/Layout-DBQNIECX.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/ModelSourcesPage-je7rokjR.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/PhotoGalleryModal-CchfaL7x.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/PrintModal-CZIbcLUP.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/PrintersPage-igaHjmyD.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/ProfilesPage-CFPee9BW.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/ProfilesPage-CJK7CLAE.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/ProjectDetailPage-D8ohs5e6.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/QueuePage-3XtRgZmq.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/RichTextEditor-CB7OdrH0.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/SettingsPage-D-fJUb-Y.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/SliceModal-DYHa-gYe.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/SpoolBuddyAmsPage-K7eJULyB.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/SpoolBuddyDashboard-I6XOQWjB.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/SpoolBuddyInventoryPage-BIL4svSB.js


文件差異過大導致無法顯示
+ 1 - 1
static/assets/SpoolBuddySettingsPage-Da_PmwT6.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/SpoolBuddyWriteTagPage-u9VsCLPe.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/amsHelpers-DDsrDUnz.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/amsHelpers-Dlwij40i.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/colors-BBBvvSKR.js


文件差異過大導致無法顯示
+ 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};

文件差異過大導致無法顯示
+ 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};

文件差異過大導致無法顯示
+ 0 - 0
static/assets/utils-B5TX3uzf.js


文件差異過大導致無法顯示
+ 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>

部分文件因文件數量過多而無法顯示