Browse Source

● Add prefer lowest remaining filament in auto-matching (#805)

  When multiple AMS spools match the same type/color criteria, an optional
  setting now prefers the spool with the lowest remaining filament. This
  helps consume partial spools before starting new ones. Sorting applies
  to all matching paths: queue scheduler, print modal, and multi-printer
  mapping. Unknown remain values (-1) sort to end.
maziggy 1 month ago
parent
commit
5a696f9fa3

+ 1 - 0
CHANGELOG.md

@@ -11,6 +11,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **Settings Queue Tab** — New dedicated Queue tab in Settings consolidates queue-related settings: staggered start defaults and auto-drying configuration (moved from the Filament tab).
 - **Per-User Statistics Filtering** ([#730](https://github.com/maziggy/bambuddy/issues/730)) — Admins can now filter the Statistics page by user. A user dropdown appears in the stats header for users with the new `stats:filter_by_user` permission (Administrators only by default). Filter by a specific user to see their prints, filament usage, and costs, or select "No User (System)" to view prints without user attribution (e.g. slicer-initiated or pre-auth prints). The filter applies to all stats widgets and exports. Requested by @3823u44238.
 - **Bulk Printer Actions** ([#825](https://github.com/maziggy/bambuddy/issues/825)) — Select multiple printer cards and apply bulk actions from a floating toolbar. Toggle selection mode from the header, then click cards to select. Use "Select All", "Select by State" (printing, paused, finished, idle, error, offline), or "Select by Location" to quickly pick printers. Available actions: Stop, Pause, Resume, Clear Notifications, and Clear Bed — each button is smart-enabled based on the selected printers' current states. Confirmation modals for destructive actions (Stop, Pause, Clear Bed). The status summary bar now shows all printer states (printing, paused, finished, idle, error, offline). Requested by @therevoman.
+- **Prefer Lowest Remaining Filament** ([#805](https://github.com/maziggy/bambuddy/issues/805)) — New optional setting in Settings → Filament that prefers AMS spools with the lowest remaining filament during auto-matching. When multiple spools match the same type and color, the one with the least filament remaining is selected first. Helps consume partial spools before starting new ones. Applies to queue scheduling, print modal, and multi-printer mapping. Unknown remain values (e.g. external spools without sensors) are treated as full. Disabled by default. Requested by @Mofoss.
 
 ### Improved
 - **Queue Page Visual Refresh** — Compact stats bar replaces the five summary cards (saves vertical space), color-coded left borders on all queue items for instant status scanning, collapsible history section (collapsed by default), and condensed single-line rows for history items showing more prints at a glance.

+ 1 - 0
README.md

@@ -112,6 +112,7 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
 - Model-based queue assignment (send to "any X1C" for load balancing) with location filtering
 - Filament override for model-based queue (swap filament colors/types before scheduling)
 - Filament validation (only assign to printers with required filaments)
+- Prefer lowest remaining filament (consume partial spools first when multiple match)
 - Per-printer AMS mapping (individual slot configuration for print farms)
 - Scheduled prints (date/time)
 - Queue Only mode (stage without auto-start)

+ 1 - 0
backend/app/api/routes/settings.py

@@ -88,6 +88,7 @@ async def get_settings(
                 "spoolman_disable_weight_sync",
                 "spoolman_report_partial_usage",
                 "disable_filament_warnings",
+                "prefer_lowest_filament",
                 "check_updates",
                 "check_printer_firmware",
                 "include_beta_updates",

+ 5 - 0
backend/app/schemas/settings.py

@@ -37,6 +37,10 @@ class AppSettings(BaseModel):
         default=False,
         description="Disable insufficient filament warnings when printing or queueing prints",
     )
+    prefer_lowest_filament: bool = Field(
+        default=False,
+        description="When multiple AMS spools match, prefer the one with lowest remaining filament",
+    )
 
     # Updates
     check_updates: bool = Field(default=True, description="Automatically check for updates on startup")
@@ -223,6 +227,7 @@ class AppSettingsUpdate(BaseModel):
     spoolman_disable_weight_sync: bool | None = None
     spoolman_report_partial_usage: bool | None = None
     disable_filament_warnings: bool | None = None
+    prefer_lowest_filament: bool | None = None
     check_updates: bool | None = None
     check_printer_firmware: bool | None = None
     include_beta_updates: bool | None = None

+ 15 - 2
backend/app/services/print_scheduler.py

@@ -703,8 +703,11 @@ class PrintScheduler:
             logger.debug("No filaments loaded on printer %s", printer_id)
             return None
 
+        # Check if user prefers lowest remaining filament when multiple spools match
+        prefer_lowest = await self._get_bool_setting(db, "prefer_lowest_filament")
+
         # Compute mapping: match required filaments to available slots
-        return self._match_filaments_to_slots(filament_reqs, loaded_filaments)
+        return self._match_filaments_to_slots(filament_reqs, loaded_filaments, prefer_lowest)
 
     async def _get_filament_requirements(self, db: AsyncSession, item: PrintQueueItem) -> list[dict] | None:
         """Extract filament requirements from the source 3MF file.
@@ -856,6 +859,7 @@ class PrintScheduler:
                             "is_external": False,
                             "global_tray_id": global_tray_id,
                             "extruder_id": ams_extruder_map.get(str(ams_id)),
+                            "remain": tray.get("remain", -1),
                         }
                     )
 
@@ -875,6 +879,7 @@ class PrintScheduler:
                         "is_external": True,
                         "global_tray_id": tray_id,
                         "extruder_id": (255 - tray_id) if ams_extruder_map else None,
+                        "remain": vt.get("remain", -1),
                     }
                 )
 
@@ -911,7 +916,9 @@ class PrintScheduler:
         except ValueError:
             return False
 
-    def _match_filaments_to_slots(self, required: list[dict], loaded: list[dict]) -> list[int] | None:
+    def _match_filaments_to_slots(
+        self, required: list[dict], loaded: list[dict], prefer_lowest: bool = False
+    ) -> list[int] | None:
         """Match required filaments to loaded filaments and build AMS mapping.
 
         Priority: unique tray_info_idx match > exact color match > similar color match > type-only match
@@ -957,6 +964,10 @@ class PrintScheduler:
             if req_nozzle_id is not None:
                 available = [f for f in available if f.get("extruder_id") == req_nozzle_id]
 
+            # Sort by remaining filament (ascending) so lowest-remain spool wins .find()
+            if prefer_lowest:
+                available.sort(key=lambda f: f.get("remain", -1) if f.get("remain", -1) >= 0 else 101)
+
             # Check if tray_info_idx is unique among available trays
             if req_tray_info_idx:
                 idx_matches = [f for f in available if f.get("tray_info_idx") == req_tray_info_idx]
@@ -973,6 +984,8 @@ class PrintScheduler:
                         f"Non-unique tray_info_idx={req_tray_info_idx} found in {len(idx_matches)} trays, "
                         f"using color matching among trays: {[f['global_tray_id'] for f in idx_matches]}"
                     )
+                    if prefer_lowest:
+                        idx_matches.sort(key=lambda f: f.get("remain", -1) if f.get("remain", -1) >= 0 else 101)
                     # Use color matching within this subset
                     for f in idx_matches:
                         f_color = f.get("color", "")

+ 90 - 0
backend/tests/unit/test_scheduler_ams_mapping.py

@@ -415,6 +415,96 @@ class TestMatchFilamentsToSlots:
         assert result == [-1, 3]
 
 
+class TestPreferLowestFilament:
+    """Test prefer_lowest_filament sorting in _match_filaments_to_slots."""
+
+    @pytest.fixture
+    def scheduler(self):
+        return PrintScheduler()
+
+    def test_prefer_lowest_picks_lower_remain(self, scheduler):
+        """When enabled, should pick the spool with lower remaining filament."""
+        required = [{"slot_id": 1, "type": "PLA", "color": "#FF0000"}]
+        loaded = [
+            {"type": "PLA", "color": "#FF0000", "global_tray_id": 0, "remain": 80},
+            {"type": "PLA", "color": "#FF0000", "global_tray_id": 1, "remain": 30},
+        ]
+
+        result = scheduler._match_filaments_to_slots(required, loaded, prefer_lowest=True)
+        assert result == [1]  # Should pick tray 1 (30% remaining)
+
+    def test_prefer_lowest_disabled_picks_first(self, scheduler):
+        """When disabled, should pick the first matching spool (default behavior)."""
+        required = [{"slot_id": 1, "type": "PLA", "color": "#FF0000"}]
+        loaded = [
+            {"type": "PLA", "color": "#FF0000", "global_tray_id": 0, "remain": 80},
+            {"type": "PLA", "color": "#FF0000", "global_tray_id": 1, "remain": 30},
+        ]
+
+        result = scheduler._match_filaments_to_slots(required, loaded, prefer_lowest=False)
+        assert result == [0]  # Should pick tray 0 (first match)
+
+    def test_prefer_lowest_unknown_remain_sorted_last(self, scheduler):
+        """Spools with remain=-1 (unknown) should be sorted to end."""
+        required = [{"slot_id": 1, "type": "PLA", "color": "#FF0000"}]
+        loaded = [
+            {"type": "PLA", "color": "#FF0000", "global_tray_id": 0, "remain": -1},
+            {"type": "PLA", "color": "#FF0000", "global_tray_id": 1, "remain": 50},
+        ]
+
+        result = scheduler._match_filaments_to_slots(required, loaded, prefer_lowest=True)
+        assert result == [1]  # Should pick tray 1 (known 50%) over unknown
+
+    def test_prefer_lowest_missing_remain_sorted_last(self, scheduler):
+        """Spools without remain field should be sorted to end."""
+        required = [{"slot_id": 1, "type": "PLA", "color": "#FF0000"}]
+        loaded = [
+            {"type": "PLA", "color": "#FF0000", "global_tray_id": 0},  # No remain field
+            {"type": "PLA", "color": "#FF0000", "global_tray_id": 1, "remain": 50},
+        ]
+
+        result = scheduler._match_filaments_to_slots(required, loaded, prefer_lowest=True)
+        assert result == [1]  # Should pick tray 1 (known 50%) over missing
+
+    def test_prefer_lowest_multiple_slots(self, scheduler):
+        """Should pick lowest remain for each slot independently."""
+        required = [
+            {"slot_id": 1, "type": "PLA", "color": "#FF0000"},
+            {"slot_id": 2, "type": "PLA", "color": "#FF0000"},
+        ]
+        loaded = [
+            {"type": "PLA", "color": "#FF0000", "global_tray_id": 0, "remain": 80},
+            {"type": "PLA", "color": "#FF0000", "global_tray_id": 1, "remain": 30},
+            {"type": "PLA", "color": "#FF0000", "global_tray_id": 2, "remain": 60},
+        ]
+
+        result = scheduler._match_filaments_to_slots(required, loaded, prefer_lowest=True)
+        # Slot 1 gets tray 1 (30%), slot 2 gets tray 2 (60%) — tray 0 (80%) unused
+        assert result == [1, 2]
+
+    def test_prefer_lowest_with_tray_info_idx(self, scheduler):
+        """Should sort within tray_info_idx subset too."""
+        required = [{"slot_id": 1, "type": "PLA", "color": "#FFFFFF", "tray_info_idx": "GFA00"}]
+        loaded = [
+            {"type": "PLA", "color": "#FFFFFF", "global_tray_id": 0, "tray_info_idx": "GFA00", "remain": 80},
+            {"type": "PLA", "color": "#FFFFFF", "global_tray_id": 1, "tray_info_idx": "GFA00", "remain": 20},
+        ]
+
+        result = scheduler._match_filaments_to_slots(required, loaded, prefer_lowest=True)
+        assert result == [1]  # Should pick tray 1 (20%) within idx subset
+
+    def test_prefer_lowest_external_spool(self, scheduler):
+        """External spool with low remain should be preferred over AMS spool."""
+        required = [{"slot_id": 1, "type": "PLA", "color": "#FF0000"}]
+        loaded = [
+            {"type": "PLA", "color": "#FF0000", "global_tray_id": 0, "remain": 80, "is_external": False},
+            {"type": "PLA", "color": "#FF0000", "global_tray_id": 254, "remain": 10, "is_external": True},
+        ]
+
+        result = scheduler._match_filaments_to_slots(required, loaded, prefer_lowest=True)
+        assert result == [254]  # Should pick external spool (10%) over AMS (80%)
+
+
 class TestBuildLoadedFilamentsTrayInfoIdx:
     """Test tray_info_idx extraction in _build_loaded_filaments."""
 

+ 51 - 0
frontend/src/__tests__/components/PrinterSelector.test.ts

@@ -25,9 +25,12 @@ function makeFilament(overrides: Partial<LoadedFilament> & { globalTrayId: numbe
     colorName: 'White',
     amsId: 0,
     trayId: 0,
+    isHt: false,
+    isExternal: false,
     label: 'AMS1-T1',
     trayInfoIdx: '',
     extruderId: undefined,
+    remain: -1,
     ...overrides,
   };
 }
@@ -196,3 +199,51 @@ describe('autoMatchFilament', () => {
     expect(result!.globalTrayId).toBe(0);
   });
 });
+
+// -- autoMatchFilament with preferLowest ------------------------------------
+
+describe('autoMatchFilament preferLowest', () => {
+  it('picks spool with lowest remain when enabled', () => {
+    const filaments = [
+      makeFilament({ globalTrayId: 0, type: 'PLA', color: '#FF0000', colorName: 'Red', remain: 80 }),
+      makeFilament({ globalTrayId: 1, type: 'PLA', color: '#FF0000', colorName: 'Red', remain: 30 }),
+    ];
+    const req = makeReq({ type: 'PLA', color: '#FF0000' });
+    const result = autoMatchFilament(req, filaments, new Set(), true);
+    expect(result).toBeDefined();
+    expect(result!.globalTrayId).toBe(1); // 30% < 80%
+  });
+
+  it('picks first spool when disabled (default behavior)', () => {
+    const filaments = [
+      makeFilament({ globalTrayId: 0, type: 'PLA', color: '#FF0000', colorName: 'Red', remain: 80 }),
+      makeFilament({ globalTrayId: 1, type: 'PLA', color: '#FF0000', colorName: 'Red', remain: 30 }),
+    ];
+    const req = makeReq({ type: 'PLA', color: '#FF0000' });
+    const result = autoMatchFilament(req, filaments, new Set(), false);
+    expect(result).toBeDefined();
+    expect(result!.globalTrayId).toBe(0); // First match
+  });
+
+  it('sorts unknown remain (-1) to end', () => {
+    const filaments = [
+      makeFilament({ globalTrayId: 0, type: 'PLA', color: '#FF0000', colorName: 'Red', remain: -1 }),
+      makeFilament({ globalTrayId: 1, type: 'PLA', color: '#FF0000', colorName: 'Red', remain: 50 }),
+    ];
+    const req = makeReq({ type: 'PLA', color: '#FF0000' });
+    const result = autoMatchFilament(req, filaments, new Set(), true);
+    expect(result).toBeDefined();
+    expect(result!.globalTrayId).toBe(1); // Known 50% over unknown
+  });
+
+  it('still respects nozzle constraint with preferLowest', () => {
+    const filaments = [
+      makeFilament({ globalTrayId: 0, type: 'PLA', color: '#FF0000', colorName: 'Red', remain: 10, extruderId: 1 }),
+      makeFilament({ globalTrayId: 1, type: 'PLA', color: '#FF0000', colorName: 'Red', remain: 80, extruderId: 0 }),
+    ];
+    const req = makeReq({ type: 'PLA', color: '#FF0000', nozzle_id: 0 });
+    const result = autoMatchFilament(req, filaments, new Set(), true);
+    expect(result).toBeDefined();
+    expect(result!.globalTrayId).toBe(1); // Only tray on correct nozzle
+  });
+});

+ 56 - 0
frontend/src/__tests__/hooks/useFilamentMapping.test.ts

@@ -873,3 +873,59 @@ describe('X1C model tests (single nozzle, real data)', () => {
     });
   });
 });
+
+describe('computeAmsMapping preferLowest', () => {
+  it('picks spool with lowest remain when enabled', () => {
+    const reqs = {
+      filaments: [{ slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10 }],
+    };
+    const status = createPrinterStatus([
+      {
+        id: 0,
+        tray: [
+          { id: 0, tray_type: 'PLA', tray_color: 'FF0000', remain: 80 },
+          { id: 1, tray_type: 'PLA', tray_color: 'FF0000', remain: 25 },
+        ],
+      },
+    ]);
+
+    const result = computeAmsMapping(reqs, status, true);
+    expect(result).toEqual([1]); // Tray 1 has 25% remain
+  });
+
+  it('picks first match when disabled', () => {
+    const reqs = {
+      filaments: [{ slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10 }],
+    };
+    const status = createPrinterStatus([
+      {
+        id: 0,
+        tray: [
+          { id: 0, tray_type: 'PLA', tray_color: 'FF0000', remain: 80 },
+          { id: 1, tray_type: 'PLA', tray_color: 'FF0000', remain: 25 },
+        ],
+      },
+    ]);
+
+    const result = computeAmsMapping(reqs, status, false);
+    expect(result).toEqual([0]); // First match (default)
+  });
+
+  it('sorts unknown remain to end', () => {
+    const reqs = {
+      filaments: [{ slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10 }],
+    };
+    const status = createPrinterStatus([
+      {
+        id: 0,
+        tray: [
+          { id: 0, tray_type: 'PLA', tray_color: 'FF0000' },  // No remain (defaults to -1)
+          { id: 1, tray_type: 'PLA', tray_color: 'FF0000', remain: 60 },
+        ],
+      },
+    ]);
+
+    const result = computeAmsMapping(reqs, status, true);
+    expect(result).toEqual([1]); // Known 60% over unknown
+  });
+});

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

@@ -838,6 +838,7 @@ export interface AppSettings {
   time_format: 'system' | '12h' | '24h';
   // Filament tracking
   disable_filament_warnings: boolean;  // Disable filament warnings (print insufficiency and assignment mismatch)
+  prefer_lowest_filament: boolean;  // When multiple spools match, prefer lowest remaining filament
   // Default printer
   default_printer_id: number | null;
   // Dark mode theme settings

+ 2 - 1
frontend/src/components/PrintModal/PrinterSelector.tsx

@@ -107,7 +107,8 @@ function InlineMappingEditor({
       isManual = true;
     } else {
       const usedTrayIds = new Set<number>(Object.values(printerResult.config.manualMappings));
-      loaded = autoMatchFilament(req, printerResult.loadedFilaments, usedTrayIds) as LoadedFilament | undefined;
+      const cachedSettings = queryClient.getQueryData<{ prefer_lowest_filament?: boolean }>(['settings']);
+      loaded = autoMatchFilament(req, printerResult.loadedFilaments, usedTrayIds, cachedSettings?.prefer_lowest_filament) as LoadedFilament | undefined;
     }
 
     // Determine status

+ 3 - 2
frontend/src/components/PrintModal/index.tsx

@@ -324,7 +324,7 @@ export function PrintModal({
   });
 
   // Get AMS mapping from hook (only when single printer selected)
-  const { amsMapping } = useFilamentMapping(effectiveFilamentReqs, printerStatus, manualMappings);
+  const { amsMapping } = useFilamentMapping(effectiveFilamentReqs, printerStatus, manualMappings, settings?.prefer_lowest_filament);
 
   // Multi-printer filament mapping (for per-printer configuration)
   const multiPrinterMapping = useMultiPrinterFilamentMapping(
@@ -333,7 +333,8 @@ export function PrintModal({
     effectiveFilamentReqs,
     manualMappings,
     perPrinterConfigs,
-    setPerPrinterConfigs
+    setPerPrinterConfigs,
+    settings?.prefer_lowest_filament,
   );
 
   // Auto-select first plate when plates load (single or multi-plate)

+ 41 - 3
frontend/src/hooks/useFilamentMapping.ts

@@ -37,6 +37,7 @@ export function buildLoadedFilaments(printerStatus: PrinterStatus | undefined):
           trayInfoIdx: tray.tray_info_idx || '',
           traySubBrands: tray.tray_sub_brands || '',
           extruderId: amsExtruderMap?.[String(amsUnit.id)],
+          remain: tray.remain ?? -1,
         });
       }
     });
@@ -61,6 +62,7 @@ export function buildLoadedFilaments(printerStatus: PrinterStatus | undefined):
         trayInfoIdx: extTray.tray_info_idx || '',
         traySubBrands: extTray.tray_sub_brands || '',
         extruderId: hasDualNozzle ? (255 - trayId) : undefined,
+        remain: extTray.remain ?? -1,
       });
     }
   }
@@ -86,7 +88,8 @@ export function buildLoadedFilaments(printerStatus: PrinterStatus | undefined):
  */
 export function computeAmsMapping(
   filamentReqs: { filaments: FilamentRequirement[] } | undefined,
-  printerStatus: PrinterStatus | undefined
+  printerStatus: PrinterStatus | undefined,
+  preferLowest?: boolean,
 ): number[] | undefined {
   if (!filamentReqs?.filaments || filamentReqs.filaments.length === 0) return undefined;
 
@@ -109,6 +112,15 @@ export function computeAmsMapping(
       available = available.filter((f) => f.extruderId === req.nozzle_id);
     }
 
+    // Sort by remaining filament (ascending) so .find() picks the lowest-remain spool first
+    if (preferLowest) {
+      available = [...available].sort((a, b) => {
+        const ra = a.remain >= 0 ? a.remain : 101;
+        const rb = b.remain >= 0 ? b.remain : 101;
+        return ra - rb;
+      });
+    }
+
     let idxMatch: LoadedFilament | undefined;
     let exactMatch: LoadedFilament | undefined;
     let similarMatch: LoadedFilament | undefined;
@@ -122,6 +134,13 @@ export function computeAmsMapping(
         idxMatch = idxMatches[0];
       } else if (idxMatches.length > 1) {
         // Multiple trays with same tray_info_idx - use color matching among them
+        if (preferLowest) {
+          idxMatches.sort((a, b) => {
+            const ra = a.remain >= 0 ? a.remain : 101;
+            const rb = b.remain >= 0 ? b.remain : 101;
+            return ra - rb;
+          });
+        }
         exactMatch = idxMatches.find(
           (f) =>
             f.type?.toUpperCase() === req.type?.toUpperCase() &&
@@ -212,6 +231,8 @@ export interface LoadedFilament {
   traySubBrands?: string;
   /** Extruder ID for dual-nozzle printers (0=right, 1=left) */
   extruderId?: number;
+  /** Remaining filament percentage (0-100), -1 = unknown */
+  remain: number;
 }
 
 /**
@@ -285,7 +306,8 @@ export function useLoadedFilaments(
 export function useFilamentMapping(
   filamentReqs: FilamentRequirementsResponse | undefined,
   printerStatus: PrinterStatus | undefined,
-  manualMappings: Record<number, number>
+  manualMappings: Record<number, number>,
+  preferLowest?: boolean,
 ): UseFilamentMappingResult {
   const loadedFilaments = useLoadedFilaments(printerStatus);
 
@@ -345,6 +367,15 @@ export function useFilamentMapping(
         available = available.filter((f) => f.extruderId === req.nozzle_id);
       }
 
+      // Sort by remaining filament (ascending) so .find() picks the lowest-remain spool first
+      if (preferLowest) {
+        available = [...available].sort((a, b) => {
+          const ra = a.remain >= 0 ? a.remain : 101;
+          const rb = b.remain >= 0 ? b.remain : 101;
+          return ra - rb;
+        });
+      }
+
       let idxMatch: LoadedFilament | undefined;
       let exactMatch: LoadedFilament | undefined;
       let similarMatch: LoadedFilament | undefined;
@@ -358,6 +389,13 @@ export function useFilamentMapping(
           idxMatch = idxMatches[0];
         } else if (idxMatches.length > 1) {
           // Multiple trays with same tray_info_idx - use color matching among them
+          if (preferLowest) {
+            idxMatches.sort((a, b) => {
+              const ra = a.remain >= 0 ? a.remain : 101;
+              const rb = b.remain >= 0 ? b.remain : 101;
+              return ra - rb;
+            });
+          }
           exactMatch = idxMatches.find(
             (f) =>
               f.type?.toUpperCase() === req.type?.toUpperCase() &&
@@ -431,7 +469,7 @@ export function useFilamentMapping(
         isManual: false,
       };
     });
-  }, [filamentReqs, loadedFilaments, manualMappings]);
+  }, [filamentReqs, loadedFilaments, manualMappings, preferLowest]);
 
   // Build AMS mapping from matched filaments
   // Format: array matching 3MF filament slot structure

+ 28 - 8
frontend/src/hooks/useMultiPrinterFilamentMapping.ts

@@ -88,7 +88,8 @@ export interface UseMultiPrinterFilamentMappingResult {
 function computeMatchDetails(
   filamentReqs: FilamentRequirement[] | undefined,
   loadedFilaments: LoadedFilament[],
-  manualMappings: Record<number, number>
+  manualMappings: Record<number, number>,
+  preferLowest?: boolean,
 ): { exactMatches: number; typeOnlyMatches: number; missingTypes: number; totalSlots: number; status: PrinterMatchStatus } {
   if (!filamentReqs || filamentReqs.length === 0) {
     return { exactMatches: 0, typeOnlyMatches: 0, missingTypes: 0, totalSlots: 0, status: 'full' };
@@ -133,6 +134,14 @@ function computeMatchDetails(
       }
     }
 
+    if (preferLowest) {
+      candidates = [...candidates].sort((a, b) => {
+        const ra = a.remain >= 0 ? a.remain : 101;
+        const rb = b.remain >= 0 ? b.remain : 101;
+        return ra - rb;
+      });
+    }
+
     const exactMatch = candidates.find(
       (f) =>
         f.type?.toUpperCase() === req.type?.toUpperCase() &&
@@ -183,7 +192,8 @@ function computeMatchDetails(
 function computeMappingWithOverrides(
   filamentReqs: { filaments: FilamentRequirement[] } | undefined,
   printerStatus: PrinterStatus | undefined,
-  manualMappings: Record<number, number>
+  manualMappings: Record<number, number>,
+  preferLowest?: boolean,
 ): number[] | undefined {
   if (!filamentReqs?.filaments || filamentReqs.filaments.length === 0) return undefined;
 
@@ -211,6 +221,14 @@ function computeMappingWithOverrides(
       }
     }
 
+    if (preferLowest) {
+      candidates = [...candidates].sort((a, b) => {
+        const ra = a.remain >= 0 ? a.remain : 101;
+        const rb = b.remain >= 0 ? b.remain : 101;
+        return ra - rb;
+      });
+    }
+
     const exactMatch = candidates.find(
       (f) =>
         f.type?.toUpperCase() === req.type?.toUpperCase() &&
@@ -270,7 +288,8 @@ export function useMultiPrinterFilamentMapping(
   filamentReqs: { filaments: FilamentRequirement[] } | undefined,
   defaultMappings: Record<number, number>,
   perPrinterConfigs: Record<number, PerPrinterConfig>,
-  setPerPrinterConfigs: React.Dispatch<React.SetStateAction<Record<number, PerPrinterConfig>>>
+  setPerPrinterConfigs: React.Dispatch<React.SetStateAction<Record<number, PerPrinterConfig>>>,
+  preferLowest?: boolean,
 ): UseMultiPrinterFilamentMappingResult {
   // Fetch printer status for all selected printers in parallel
   const statusQueries = useQueries({
@@ -294,7 +313,7 @@ export function useMultiPrinterFilamentMapping(
       const config = perPrinterConfigs[printerId] || DEFAULT_PRINTER_CONFIG;
 
       // Compute auto mapping for this printer
-      const autoMapping = computeAmsMapping(filamentReqs, printerStatus);
+      const autoMapping = computeAmsMapping(filamentReqs, printerStatus, preferLowest);
 
       // Determine which mappings to use:
       // If printer has override (useDefault=false), use its custom mappings
@@ -304,13 +323,14 @@ export function useMultiPrinterFilamentMapping(
         : defaultMappings;
 
       // Compute final mapping with overrides
-      const finalMapping = computeMappingWithOverrides(filamentReqs, printerStatus, effectiveMappings);
+      const finalMapping = computeMappingWithOverrides(filamentReqs, printerStatus, effectiveMappings, preferLowest);
 
       // Compute match details
       const matchDetails = computeMatchDetails(
         filamentReqs?.filaments,
         loadedFilaments,
-        effectiveMappings
+        effectiveMappings,
+        preferLowest,
       );
 
       return {
@@ -329,7 +349,7 @@ export function useMultiPrinterFilamentMapping(
         config,
       };
     });
-  }, [selectedPrinterIds, statusQueries, printers, filamentReqs, perPrinterConfigs, defaultMappings]);
+  }, [selectedPrinterIds, statusQueries, printers, filamentReqs, perPrinterConfigs, defaultMappings, preferLowest]);
 
   const isLoading = statusQueries.some((q) => q.isLoading);
 
@@ -350,7 +370,7 @@ export function useMultiPrinterFilamentMapping(
     if (!result || !result.status || !filamentReqs?.filaments) return;
 
     // Compute optimal mapping for this printer
-    const autoMapping = computeAmsMapping(filamentReqs, result.status);
+    const autoMapping = computeAmsMapping(filamentReqs, result.status, preferLowest);
     if (!autoMapping) return;
 
     // Convert autoMapping array to manualMappings record

+ 2 - 0
frontend/src/i18n/locales/de.ts

@@ -1419,6 +1419,8 @@ export default {
     filamentChecks: 'Filament-Prüfungen',
     disableFilamentWarnings: 'Filament-Warnungen deaktivieren',
     disableFilamentWarningsDesc: 'Keine Warnungen über unzureichendes Filament beim Drucken oder Einreihen anzeigen',
+    preferLowestFilament: 'Niedrigsten Filamentrest bevorzugen',
+    preferLowestFilamentDesc: 'Bei mehreren passenden Spulen die mit dem geringsten Restfilament verwenden',
     trackingModeBuiltIn: 'Integriertes Inventar',
     trackingModeBuiltInDesc: 'RFID-Erkennung und Verbrauchserfassung inklusive',
     trackingModeSpoolmanDesc: 'Externer Filament-Management-Server',

+ 2 - 0
frontend/src/i18n/locales/en.ts

@@ -1420,6 +1420,8 @@ export default {
     filamentChecks: 'Filament checks',
     disableFilamentWarnings: 'Disable filament warnings',
     disableFilamentWarningsDesc: 'Don\'t show warnings about insufficient filament when printing or queueing',
+    preferLowestFilament: 'Prefer lowest remaining filament',
+    preferLowestFilamentDesc: 'When multiple spools match, use the one with the least filament remaining',
     trackingModeBuiltIn: 'Built-in Inventory',
     trackingModeBuiltInDesc: 'RFID auto-matching and usage tracking included',
     trackingModeSpoolmanDesc: 'External filament management server',

+ 2 - 0
frontend/src/i18n/locales/fr.ts

@@ -1419,6 +1419,8 @@ export default {
     filamentChecks: 'Vérifications du filament',
     disableFilamentWarnings: 'Désactiver les avertissements de filament',
     disableFilamentWarningsDesc: 'Ne pas afficher les avertissements de filament insuffisant lors de l\'impression ou de la mise en file d\'attente',
+    preferLowestFilament: 'Préférer le filament le plus bas',
+    preferLowestFilamentDesc: 'Lorsque plusieurs bobines correspondent, utiliser celle avec le moins de filament restant',
     trackingModeBuiltIn: 'Inventaire Intégré',
     trackingModeBuiltInDesc: 'Correspondance RFID et suivi de consommation inclus',
     trackingModeSpoolmanDesc: 'Serveur de gestion externe',

+ 2 - 0
frontend/src/i18n/locales/it.ts

@@ -1419,6 +1419,8 @@ export default {
     filamentChecks: 'Controlli filamento',
     disableFilamentWarnings: 'Disabilita avvisi filamento',
     disableFilamentWarningsDesc: 'Non mostrare avvisi per filamento insufficiente durante la stampa o l\'accodamento',
+    preferLowestFilament: 'Preferisci il filamento con meno residuo',
+    preferLowestFilamentDesc: 'Quando più bobine corrispondono, usa quella con meno filamento rimanente',
     trackingModeBuiltIn: 'Inventario integrato',
     trackingModeBuiltInDesc: 'Riconoscimento RFID automatico e tracciamento dell\'uso inclusi',
     trackingModeSpoolmanDesc: 'Server esterno per la gestione del filamento',

+ 2 - 0
frontend/src/i18n/locales/ja.ts

@@ -1418,6 +1418,8 @@ export default {
     filamentChecks: 'フィラメントチェック',
     disableFilamentWarnings: 'フィラメント警告を無効化',
     disableFilamentWarningsDesc: '印刷またはキュー追加時にフィラメント不足の警告を表示しない',
+    preferLowestFilament: '残量が少ないフィラメントを優先',
+    preferLowestFilamentDesc: '複数のスプールが一致する場合、残量が最も少ないものを使用します',
     trackingModeBuiltIn: '内蔵インベントリ',
     trackingModeBuiltInDesc: 'RFID自動検出と使用量追跡を含む',
     trackingModeSpoolmanDesc: '外部フィラメント管理サーバー',

+ 2 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -1419,6 +1419,8 @@ export default {
     filamentChecks: 'Verificações de filamento',
     disableFilamentWarnings: 'Desativar avisos de filamento',
     disableFilamentWarningsDesc: 'Não mostrar avisos sobre filamento insuficiente ao imprimir ou adicionar à fila',
+    preferLowestFilament: 'Preferir filamento com menor resto',
+    preferLowestFilamentDesc: 'Quando vários carretéis correspondem, usar o com menos filamento restante',
     trackingModeBuiltIn: 'Inventário Interno',
     trackingModeBuiltInDesc: 'Correspondência automática de RFID e rastreamento de uso incluídos',
     trackingModeSpoolmanDesc: 'Servidor de gerenciamento de filamento externo',

+ 2 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -1419,6 +1419,8 @@ export default {
     filamentChecks: '耗材检查',
     disableFilamentWarnings: '禁用耗材警告',
     disableFilamentWarningsDesc: '在打印或加入队列时不显示耗材不足警告',
+    preferLowestFilament: '优先使用剩余最少的耗材',
+    preferLowestFilamentDesc: '当多个料盘匹配时,使用剩余耗材最少的那个',
     trackingModeBuiltIn: '内置库存',
     trackingModeBuiltInDesc: '包含 RFID 自动匹配和用量追踪',
     trackingModeSpoolmanDesc: '外部耗材管理服务器',

+ 19 - 0
frontend/src/pages/SettingsPage.tsx

@@ -741,6 +741,7 @@ export function SettingsPage() {
       settings.ams_temp_fair !== localSettings.ams_temp_fair ||
       settings.ams_history_retention_days !== localSettings.ams_history_retention_days ||
       settings.disable_filament_warnings !== localSettings.disable_filament_warnings ||
+      settings.prefer_lowest_filament !== localSettings.prefer_lowest_filament ||
       (settings.queue_drying_enabled ?? false) !== (localSettings.queue_drying_enabled ?? false) ||
       (settings.queue_drying_block ?? false) !== (localSettings.queue_drying_block ?? false) ||
       (settings.ambient_drying_enabled ?? false) !== (localSettings.ambient_drying_enabled ?? false) ||
@@ -816,6 +817,7 @@ export function SettingsPage() {
         ams_temp_fair: localSettings.ams_temp_fair,
         ams_history_retention_days: localSettings.ams_history_retention_days,
         disable_filament_warnings: localSettings.disable_filament_warnings,
+        prefer_lowest_filament: localSettings.prefer_lowest_filament,
         queue_drying_enabled: localSettings.queue_drying_enabled,
         queue_drying_block: localSettings.queue_drying_block,
         ambient_drying_enabled: localSettings.ambient_drying_enabled,
@@ -3610,6 +3612,23 @@ export function SettingsPage() {
                     <div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
                   </label>
                 </div>
+                <div className="flex items-center justify-between">
+                  <div>
+                    <p className="text-white">{t('settings.preferLowestFilament')}</p>
+                    <p className="text-sm text-bambu-gray">
+                      {t('settings.preferLowestFilamentDesc')}
+                    </p>
+                  </div>
+                  <label className="relative inline-flex items-center cursor-pointer">
+                    <input
+                      type="checkbox"
+                      checked={localSettings.prefer_lowest_filament}
+                      onChange={(e) => updateSetting('prefer_lowest_filament', e.target.checked)}
+                      className="sr-only peer"
+                    />
+                    <div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
+                  </label>
+                </div>
               </CardContent>
             </Card>
 

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

@@ -203,10 +203,19 @@ export function isPlaceholderDate(scheduledTime: string | null | undefined): boo
  */
 export function autoMatchFilament(
   req: { type?: string; color?: string; nozzle_id?: number | null },
-  loadedFilaments: { globalTrayId: number; type?: string; color?: string; extruderId?: number }[],
+  loadedFilaments: { globalTrayId: number; type?: string; color?: string; extruderId?: number; remain?: number }[],
   usedTrayIds: Set<number>,
+  preferLowest?: boolean,
 ): typeof loadedFilaments[number] | undefined {
-  const nozzleFilaments = filterFilamentsByNozzle(loadedFilaments, req.nozzle_id);
+  let nozzleFilaments = filterFilamentsByNozzle(loadedFilaments, req.nozzle_id);
+
+  if (preferLowest) {
+    nozzleFilaments = [...nozzleFilaments].sort((a, b) => {
+      const ra = (a.remain ?? -1) >= 0 ? (a.remain ?? -1) : 101;
+      const rb = (b.remain ?? -1) >= 0 ? (b.remain ?? -1) : 101;
+      return ra - rb;
+    });
+  }
 
   const exactMatch = nozzleFilaments.find(
     (f) =>

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-CPo_HX6d.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-D7sc-Plx.js"></script>
+    <script type="module" crossorigin src="/assets/index-CPo_HX6d.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-B4zcncds.css">
   </head>
   <body>

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