Browse Source

Fix AMS auto-matching to use tray_info_idx from 3MF files

When multiple AMS trays have the same filament type and color, Bambuddy
now uses the tray_info_idx attribute from the 3MF file to identify the
exact spool selected during slicing. This ensures the correct tray is
used rather than just picking the first match.

Matching priority: tray_info_idx > exact color > similar color > type-only

Closes #245
maziggy 3 months ago
parent
commit
a0d878a231

+ 5 - 0
CHANGELOG.md

@@ -22,6 +22,11 @@ All notable changes to Bambuddy will be documented in this file.
   - Fixed header buttons overflowing outside the screen on iPhone/mobile devices
   - Headers now stack vertically on small screens with proper wrapping
   - Applied consistent responsive pattern from PrintersPage
+- **AMS Auto-Matching Ignores Sliced Spool Selection** (Issue #245):
+  - Fixed AMS slot mapping to use `tray_info_idx` from 3MF files for exact spool matching
+  - When multiple trays have the same filament type/color, the exact spool selected during slicing is now used
+  - Priority: tray_info_idx match > exact color match > similar color match > type-only match
+  - Resolves "extrusion motor overloaded" errors caused by wrong tray selection on H2D Pro and other printers with multiple identical spools
 
 ### Added
 - **Windows Portable Launcher** (contributed by nmori):

+ 6 - 0
backend/app/api/routes/archives.py

@@ -2574,6 +2574,8 @@ async def get_filament_requirements(
                                 used_g = filament_elem.get("used_g", "0")
                                 used_m = filament_elem.get("used_m", "0")
 
+                                tray_info_idx = filament_elem.get("tray_info_idx", "")
+
                                 try:
                                     used_grams = float(used_g)
                                 except (ValueError, TypeError):
@@ -2587,6 +2589,7 @@ async def get_filament_requirements(
                                             "color": filament_color,
                                             "used_grams": round(used_grams, 1),
                                             "used_meters": float(used_m) if used_m else 0,
+                                            "tray_info_idx": tray_info_idx,
                                         }
                                     )
                             break
@@ -2600,6 +2603,8 @@ async def get_filament_requirements(
                         used_g = filament_elem.get("used_g", "0")
                         used_m = filament_elem.get("used_m", "0")
 
+                        tray_info_idx = filament_elem.get("tray_info_idx", "")
+
                         # Only include filaments that are actually used
                         try:
                             used_grams = float(used_g)
@@ -2614,6 +2619,7 @@ async def get_filament_requirements(
                                     "color": filament_color,
                                     "used_grams": round(used_grams, 1),
                                     "used_meters": float(used_m) if used_m else 0,
+                                    "tray_info_idx": tray_info_idx,
                                 }
                             )
 

+ 6 - 0
backend/app/api/routes/library.py

@@ -1634,6 +1634,8 @@ async def get_library_file_filament_requirements(
                                 used_g = filament_elem.get("used_g", "0")
                                 used_m = filament_elem.get("used_m", "0")
 
+                                tray_info_idx = filament_elem.get("tray_info_idx", "")
+
                                 try:
                                     used_grams = float(used_g)
                                 except (ValueError, TypeError):
@@ -1647,6 +1649,7 @@ async def get_library_file_filament_requirements(
                                             "color": filament_color,
                                             "used_grams": round(used_grams, 1),
                                             "used_meters": float(used_m) if used_m else 0,
+                                            "tray_info_idx": tray_info_idx,
                                         }
                                     )
                             break
@@ -1659,6 +1662,8 @@ async def get_library_file_filament_requirements(
                         used_g = filament_elem.get("used_g", "0")
                         used_m = filament_elem.get("used_m", "0")
 
+                        tray_info_idx = filament_elem.get("tray_info_idx", "")
+
                         try:
                             used_grams = float(used_g)
                         except (ValueError, TypeError):
@@ -1672,6 +1677,7 @@ async def get_library_file_filament_requirements(
                                     "color": filament_color,
                                     "used_grams": round(used_grams, 1),
                                     "used_meters": float(used_m) if used_m else 0,
+                                    "tray_info_idx": tray_info_idx,
                                 }
                             )
 

+ 36 - 7
backend/app/services/print_scheduler.py

@@ -446,6 +446,8 @@ class PrintScheduler:
                                 filament_id = filament_elem.get("id")
                                 filament_type = filament_elem.get("type", "")
                                 filament_color = filament_elem.get("color", "")
+                                # tray_info_idx identifies the specific spool selected when slicing
+                                tray_info_idx = filament_elem.get("tray_info_idx", "")
                                 used_g = filament_elem.get("used_g", "0")
                                 try:
                                     used_grams = float(used_g)
@@ -455,6 +457,7 @@ class PrintScheduler:
                                                 "slot_id": int(filament_id),
                                                 "type": filament_type,
                                                 "color": filament_color,
+                                                "tray_info_idx": tray_info_idx,
                                                 "used_grams": round(used_grams, 1),
                                             }
                                         )
@@ -467,6 +470,8 @@ class PrintScheduler:
                         filament_id = filament_elem.get("id")
                         filament_type = filament_elem.get("type", "")
                         filament_color = filament_elem.get("color", "")
+                        # tray_info_idx identifies the specific spool selected when slicing
+                        tray_info_idx = filament_elem.get("tray_info_idx", "")
                         used_g = filament_elem.get("used_g", "0")
                         try:
                             used_grams = float(used_g)
@@ -476,6 +481,7 @@ class PrintScheduler:
                                         "slot_id": int(filament_id),
                                         "type": filament_type,
                                         "color": filament_color,
+                                        "tray_info_idx": tray_info_idx,
                                         "used_grams": round(used_grams, 1),
                                     }
                                 )
@@ -512,6 +518,8 @@ class PrintScheduler:
                 if tray_type:
                     tray_id = tray.get("id", 0)
                     tray_color = tray.get("tray_color", "")
+                    # tray_info_idx identifies the specific spool (e.g., "GFA00", "P4d64437")
+                    tray_info_idx = tray.get("tray_info_idx", "")
                     # Normalize color: remove alpha, add hash
                     color = self._normalize_color(tray_color)
                     # Calculate global tray ID
@@ -521,6 +529,7 @@ class PrintScheduler:
                         {
                             "type": tray_type,
                             "color": color,
+                            "tray_info_idx": tray_info_idx,
                             "ams_id": ams_id,
                             "tray_id": tray_id,
                             "is_ht": is_ht,
@@ -537,6 +546,7 @@ class PrintScheduler:
                 {
                     "type": vt_tray["tray_type"],
                     "color": color,
+                    "tray_info_idx": vt_tray.get("tray_info_idx", ""),
                     "ams_id": -1,
                     "tray_id": 0,
                     "is_ht": False,
@@ -581,11 +591,16 @@ class PrintScheduler:
     def _match_filaments_to_slots(self, required: list[dict], loaded: list[dict]) -> list[int] | None:
         """Match required filaments to loaded filaments and build AMS mapping.
 
-        Priority: exact color match > similar color match > type-only match
+        Priority: tray_info_idx match > exact color match > similar color match > type-only match
+
+        The tray_info_idx is a unique spool identifier stored in the 3MF file when the user
+        slices in Bambu Studio. If the same spool is still loaded in the printer (same
+        tray_info_idx), we use that tray. This ensures the exact spool selected during
+        slicing is used, even if multiple trays have the same type and color.
 
         Args:
-            required: List of required filaments with slot_id, type, color
-            loaded: List of loaded filaments with type, color, global_tray_id
+            required: List of required filaments with slot_id, type, color, tray_info_idx
+            loaded: List of loaded filaments with type, color, tray_info_idx, global_tray_id
 
         Returns:
             AMS mapping array (position = slot_id - 1, value = global_tray_id or -1)
@@ -600,8 +615,10 @@ class PrintScheduler:
         for req in required:
             req_type = (req.get("type") or "").upper()
             req_color = req.get("color", "")
+            req_tray_info_idx = req.get("tray_info_idx", "")
 
-            # Find best match: exact color > similar color > type-only
+            # Find best match: tray_info_idx > exact color > similar color > type-only
+            idx_match = None
             exact_match = None
             similar_match = None
             type_only_match = None
@@ -609,6 +626,18 @@ class PrintScheduler:
             for f in loaded:
                 if f["global_tray_id"] in used_tray_ids:
                     continue
+
+                # First priority: match by tray_info_idx (exact spool identity)
+                # Only match if both have non-empty tray_info_idx
+                f_tray_info_idx = f.get("tray_info_idx", "")
+                if req_tray_info_idx and f_tray_info_idx and req_tray_info_idx == f_tray_info_idx:
+                    idx_match = f
+                    logger.debug(
+                        f"Matched filament slot {req.get('slot_id')} by tray_info_idx={req_tray_info_idx} "
+                        f"-> tray {f['global_tray_id']}"
+                    )
+                    break  # Best possible match - exact spool
+
                 f_type = (f.get("type") or "").upper()
                 if f_type != req_type:
                     continue
@@ -616,15 +645,15 @@ class PrintScheduler:
                 # Type matches - check color
                 f_color = f.get("color", "")
                 if self._normalize_color_for_compare(f_color) == self._normalize_color_for_compare(req_color):
-                    exact_match = f
-                    break  # Best possible match
+                    if not exact_match:
+                        exact_match = f
                 elif self._colors_are_similar(f_color, req_color):
                     if not similar_match:
                         similar_match = f
                 elif not type_only_match:
                     type_only_match = f
 
-            match = exact_match or similar_match or type_only_match
+            match = idx_match or exact_match or similar_match or type_only_match
             if match:
                 used_tray_ids.add(match["global_tray_id"])
                 comparisons.append({"slot_id": req.get("slot_id", 0), "global_tray_id": match["global_tray_id"]})

+ 71 - 21
frontend/src/hooks/useFilamentMapping.ts

@@ -32,6 +32,7 @@ export function buildLoadedFilaments(printerStatus: PrinterStatus | undefined):
           isExternal: false,
           label: formatSlotLabel(amsUnit.id, tray.id, isHt, false),
           globalTrayId: getGlobalTrayId(amsUnit.id, tray.id, false),
+          trayInfoIdx: tray.tray_info_idx || '',
         });
       }
     });
@@ -50,6 +51,7 @@ export function buildLoadedFilaments(printerStatus: PrinterStatus | undefined):
       isExternal: true,
       label: 'External',
       globalTrayId: 254,
+      trayInfoIdx: printerStatus.vt_tray.tray_info_idx || '',
     });
   }
 
@@ -60,6 +62,13 @@ export function buildLoadedFilaments(printerStatus: PrinterStatus | undefined):
  * Compute AMS mapping for a printer given filament requirements and printer status.
  * This is a non-hook version that can be called imperatively (e.g., in a loop for multiple printers).
  *
+ * Priority: tray_info_idx match > exact color match > similar color match > type-only match
+ *
+ * The tray_info_idx is a unique spool identifier stored in the 3MF file when the user
+ * slices in Bambu Studio. If the same spool is still loaded in the printer (same
+ * tray_info_idx), we use that tray. This ensures the exact spool selected during
+ * slicing is used, even if multiple trays have the same type and color.
+ *
  * @param filamentReqs - Required filaments from the 3MF file
  * @param printerStatus - Current printer status with AMS information
  * @returns AMS mapping array or undefined if no mapping needed
@@ -77,15 +86,30 @@ export function computeAmsMapping(
   const usedTrayIds = new Set<number>();
 
   const comparisons = filamentReqs.filaments.map((req) => {
-    // Auto-match: Find a loaded filament that matches by TYPE
-    // Priority: exact color match > similar color match > type-only match
-    const exactMatch = loadedFilaments.find(
-      (f) =>
-        !usedTrayIds.has(f.globalTrayId) &&
-        f.type?.toUpperCase() === req.type?.toUpperCase() &&
-        normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color)
-    );
+    const reqTrayInfoIdx = req.tray_info_idx || '';
+
+    // First priority: match by tray_info_idx (exact spool identity)
+    // Only match if both have non-empty tray_info_idx
+    const idxMatch = reqTrayInfoIdx
+      ? loadedFilaments.find(
+          (f) =>
+            !usedTrayIds.has(f.globalTrayId) &&
+            f.trayInfoIdx &&
+            f.trayInfoIdx === reqTrayInfoIdx
+        )
+      : undefined;
+
+    // Fall back to type+color matching
+    const exactMatch =
+      !idxMatch &&
+      loadedFilaments.find(
+        (f) =>
+          !usedTrayIds.has(f.globalTrayId) &&
+          f.type?.toUpperCase() === req.type?.toUpperCase() &&
+          normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color)
+      );
     const similarMatch =
+      !idxMatch &&
       !exactMatch &&
       loadedFilaments.find(
         (f) =>
@@ -94,13 +118,14 @@ export function computeAmsMapping(
           colorsAreSimilar(f.color, req.color)
       );
     const typeOnlyMatch =
+      !idxMatch &&
       !exactMatch &&
       !similarMatch &&
       loadedFilaments.find(
         (f) =>
           !usedTrayIds.has(f.globalTrayId) && f.type?.toUpperCase() === req.type?.toUpperCase()
       );
-    const loaded = exactMatch || similarMatch || typeOnlyMatch || undefined;
+    const loaded = idxMatch || exactMatch || similarMatch || typeOnlyMatch || undefined;
 
     // Mark this tray as used so it won't be assigned to another slot
     if (loaded) {
@@ -143,6 +168,8 @@ export interface LoadedFilament {
   isExternal: boolean;
   label: string;
   globalTrayId: number;
+  /** Unique spool identifier (e.g., "GFA00", "P4d64437") */
+  trayInfoIdx?: string;
 }
 
 /**
@@ -153,6 +180,8 @@ export interface FilamentRequirement {
   type: string;
   color: string;
   used_grams: number;
+  /** Unique spool identifier from slicing (e.g., "GFA00", "P4d64437") */
+  tray_info_idx?: string;
 }
 
 /**
@@ -215,6 +244,7 @@ export function useLoadedFilaments(
             isExternal: false,
             label: formatSlotLabel(amsUnit.id, tray.id, isHt, false),
             globalTrayId: getGlobalTrayId(amsUnit.id, tray.id, false),
+            trayInfoIdx: tray.tray_info_idx || '',
           });
         }
       });
@@ -233,6 +263,7 @@ export function useLoadedFilaments(
         isExternal: true,
         label: 'External',
         globalTrayId: 254,
+        trayInfoIdx: printerStatus.vt_tray.tray_info_idx || '',
       });
     }
 
@@ -297,16 +328,33 @@ export function useFilamentMapping(
         }
       }
 
-      // Auto-match: Find a loaded filament that matches by TYPE
-      // Priority: exact color match > similar color match > type-only match
+      // Auto-match: Find a loaded filament
+      // Priority: tray_info_idx match > exact color match > similar color match > type-only match
       // IMPORTANT: Exclude trays that are already assigned (manually or auto)
-      const exactMatch = loadedFilaments.find(
-        (f) =>
-          !usedTrayIds.has(f.globalTrayId) &&
-          f.type?.toUpperCase() === req.type?.toUpperCase() &&
-          normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color)
-      );
+      const reqTrayInfoIdx = req.tray_info_idx || '';
+
+      // First priority: match by tray_info_idx (exact spool identity)
+      // This ensures the exact spool selected during slicing is used
+      const idxMatch = reqTrayInfoIdx
+        ? loadedFilaments.find(
+            (f) =>
+              !usedTrayIds.has(f.globalTrayId) &&
+              f.trayInfoIdx &&
+              f.trayInfoIdx === reqTrayInfoIdx
+          )
+        : undefined;
+
+      // Fall back to type+color matching
+      const exactMatch =
+        !idxMatch &&
+        loadedFilaments.find(
+          (f) =>
+            !usedTrayIds.has(f.globalTrayId) &&
+            f.type?.toUpperCase() === req.type?.toUpperCase() &&
+            normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color)
+        );
       const similarMatch =
+        !idxMatch &&
         !exactMatch &&
         loadedFilaments.find(
           (f) =>
@@ -315,13 +363,14 @@ export function useFilamentMapping(
             colorsAreSimilar(f.color, req.color)
         );
       const typeOnlyMatch =
+        !idxMatch &&
         !exactMatch &&
         !similarMatch &&
         loadedFilaments.find(
           (f) =>
             !usedTrayIds.has(f.globalTrayId) && f.type?.toUpperCase() === req.type?.toUpperCase()
         );
-      const loaded = exactMatch || similarMatch || typeOnlyMatch || undefined;
+      const loaded = idxMatch || exactMatch || similarMatch || typeOnlyMatch || undefined;
 
       // Mark this tray as used so it won't be assigned to another slot
       if (loaded) {
@@ -330,11 +379,12 @@ export function useFilamentMapping(
 
       const hasFilament = !!loaded;
       const typeMatch = hasFilament;
-      const colorMatch = !!exactMatch || !!similarMatch;
+      // idxMatch is always considered a color match (same spool = same color)
+      const colorMatch = !!idxMatch || !!exactMatch || !!similarMatch;
 
-      // Status: match (type+color or similar), type_only (type ok, color very different), mismatch (type not found)
+      // Status: match (tray_info_idx, type+color, or similar color), type_only (type ok, color very different), mismatch (type not found)
       let status: FilamentStatus;
-      if (exactMatch || similarMatch) {
+      if (idxMatch || exactMatch || similarMatch) {
         status = 'match';
       } else if (typeOnlyMatch) {
         status = 'type_only';

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-Cqhunm67.js


+ 1 - 1
static/index.html

@@ -23,7 +23,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-BPeSzFt8.js"></script>
+    <script type="module" crossorigin src="/assets/index-Cqhunm67.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-fdAEMOwp.css">
   </head>
   <body>

Some files were not shown because too many files changed in this diff