Browse Source

Feature: Force Color Match (#625)

* Added "Force Color Match" feature to filament override

* Update settings.py

* Update settings.py

* Label updates for warnings and checkbox case

* Initial plan

* Add noMatchingMaterial i18n translations to all 7 locale files

Co-authored-by: cadtoolbox <12723486+cadtoolbox@users.noreply.github.com>

* Use {{material}} interpolation in noMatchingMaterial; populate from filament_overrides with getColorName

Co-authored-by: cadtoolbox <12723486+cadtoolbox@users.noreply.github.com>

* Updates for PR#625 comments

* Added "Force Color Match" feature to filament override

* Update settings.py

* Update settings.py

* Label updates for warnings and checkbox case

* Initial plan

* Add noMatchingMaterial i18n translations to all 7 locale files

Co-authored-by: cadtoolbox <12723486+cadtoolbox@users.noreply.github.com>

* Use {{material}} interpolation in noMatchingMaterial; populate from filament_overrides with getColorName

Co-authored-by: cadtoolbox <12723486+cadtoolbox@users.noreply.github.com>

* Updates to PrintersPage and QueuePage

* Update QueuePage.tsx

* Rebase to 0.2.2b3

* Fix compile errors with FilamentOverride

* Updates for PR#625 comments

https://github.com/maziggy/bambuddy/pull/625#pullrequestreview-3908738261

* Fix force color match default, loadedFilamentTypes bug, remove dead translations

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: cadtoolbox <12723486+cadtoolbox@users.noreply.github.com>
Co-authored-by: MartinNYHC <mz@v8w.de>
Thomas Rambach 2 months ago
parent
commit
d7401a80df

+ 120 - 11
backend/app/services/print_scheduler.py

@@ -302,6 +302,10 @@ class PrintScheduler:
             required_filament_types: Optional list of filament types needed (e.g., ["PLA", "PETG"])
                                      If provided, only printers with all required types loaded will match.
             target_location: Optional location filter. If provided, only printers in this location are considered.
+            filament_overrides: Optional list of override dicts. Each entry may include
+                                 ``force_color_match: true`` to require an exact type+color match
+                                 on the printer for that slot. Without the flag the existing
+                                 colour-preference logic applies.
 
         Returns:
             Tuple of (printer_id, waiting_reason):
@@ -327,14 +331,27 @@ class PrintScheduler:
         if not printers:
             return None, f"No active {normalized_model} printers{location_suffix} configured"
 
+        # Separate force-matched overrides from preference-only overrides
+        force_overrides = [o for o in (filament_overrides or []) if o.get("force_color_match")]
+        pref_overrides = [o for o in (filament_overrides or []) if not o.get("force_color_match")]
+
         # Track reasons for skipping printers
         printers_busy = []
         printers_offline = []
-        printers_missing_filament = []
+        printers_missing_filament: list[tuple[str, list[str]]] = []
         candidates: list[tuple[int, int]] = []  # (printer_id, color_match_count)
 
         for printer in printers:
             if printer.id in exclude_ids:
+                # Printer is already claimed by another job in this scheduling run.
+                # For force-color jobs, still check if the color would match — if not,
+                # report it as a color mismatch rather than plain "Busy" so the user
+                # knows the job needs a filament change, not just to wait for availability.
+                if force_overrides and not pref_overrides:
+                    missing_colors = self._get_missing_force_color_slots(printer.id, force_overrides)
+                    if missing_colors:
+                        printers_missing_filament.append((printer.name, missing_colors))
+                        continue
                 printers_busy.append(printer.name)
                 continue
 
@@ -346,6 +363,21 @@ class PrintScheduler:
                 continue
 
             if not is_idle:
+                # Printer is currently printing.  For force-color jobs, check whether the
+                # loaded color would satisfy the requirement — if not, surface it as a
+                # color-mismatch reason rather than plain "Busy" so the user understands
+                # that the job is waiting for a filament change, not just printer availability.
+                if force_overrides and not pref_overrides:
+                    missing_colors = self._get_missing_force_color_slots(printer.id, force_overrides)
+                    if missing_colors:
+                        printers_missing_filament.append((printer.name, missing_colors))
+                        logger.debug(
+                            "Printer %s (%s) is busy but also has wrong force-color: %s",
+                            printer.id,
+                            printer.name,
+                            missing_colors,
+                        )
+                        continue
                 printers_busy.append(printer.name)
                 continue
 
@@ -353,25 +385,54 @@ class PrintScheduler:
             if required_filament_types:
                 missing = self._get_missing_filament_types(printer.id, required_filament_types)
                 if missing:
-                    printers_missing_filament.append((printer.name, missing))
+                    # When force_overrides are present, enrich missing entries with color info
+                    # so the "Waiting on" message includes "TYPE (color)" instead of just "TYPE"
+                    if force_overrides:
+                        force_color_map = {
+                            (o.get("type") or "").upper(): o.get("color_name") or o.get("color", "?")
+                            for o in force_overrides
+                        }
+                        missing_enriched = [
+                            f"{t} ({force_color_map[t_upper]})" if (t_upper := t.upper()) in force_color_map else t
+                            for t in missing
+                        ]
+                        printers_missing_filament.append((printer.name, missing_enriched))
+                    else:
+                        printers_missing_filament.append((printer.name, missing))
                     logger.debug("Skipping printer %s (%s) - missing filaments: %s", printer.id, printer.name, missing)
                     continue
 
-            # If filament overrides with colors, only consider printers that have at least one color match
-            if filament_overrides:
-                color_matches = self._count_override_color_matches(printer.id, filament_overrides)
+            # Force color match: ALL flagged slots must have an exact type+color match
+            if force_overrides:
+                missing_colors = self._get_missing_force_color_slots(printer.id, force_overrides)
+                if missing_colors:
+                    printers_missing_filament.append((printer.name, missing_colors))
+                    logger.debug(
+                        "Skipping printer %s (%s) - missing force-matched colors: %s",
+                        printer.id,
+                        printer.name,
+                        missing_colors,
+                    )
+                    continue
+
+            # If preference-only overrides exist, rank by color matches (existing behaviour)
+            if pref_overrides:
+                color_matches = self._count_override_color_matches(printer.id, pref_overrides)
                 if color_matches > 0:
                     candidates.append((printer.id, color_matches))
                 else:
-                    override_colors = [f"{o.get('type', '?')} ({o.get('color', '?')})" for o in filament_overrides]
+                    override_colors = [f"{o.get('type', '?')} ({o.get('color', '?')})" for o in pref_overrides]
                     printers_missing_filament.append((printer.name, override_colors))
                     logger.debug("Skipping printer %s (%s) - no matching override colors", printer.id, printer.name)
                     continue
+            elif force_overrides:
+                # Passed all force checks — immediately eligible (no preference ordering needed)
+                return printer.id, None
             else:
-                # No overrides - take first available (existing behavior)
+                # No overrides at all - take first available (existing behavior)
                 return printer.id, None
 
-        # If we have candidates from override matching, pick the one with most color matches
+        # If we have candidates from preference override matching, pick the one with most color matches
         if candidates:
             candidates.sort(key=lambda c: c[1], reverse=True)
             return candidates[0][0], None
@@ -379,9 +440,19 @@ class PrintScheduler:
         # Build waiting reason from what we found
         reasons = []
         if printers_missing_filament:
-            # Filament mismatch is most actionable - show first
-            names_and_missing = [f"{name} (needs {', '.join(missing)})" for name, missing in printers_missing_filament]
-            reasons.append(f"Waiting for filament: {'; '.join(names_and_missing)}")
+            # Filament/color mismatch is most actionable - show first
+            if force_overrides and not pref_overrides:
+                # All mismatches are force-color failures — use descriptive message only;
+                # but only if there are no busy printers that DO have the matching color.
+                # If a printer has the right color but is busy, surface "Busy" instead so
+                # the user knows the job will start automatically once that printer is free.
+                if not printers_busy:
+                    all_missing = sorted({c for _, cols in printers_missing_filament for c in cols})
+                    return None, f"No matching material/color. Waiting on {', '.join(all_missing)}"
+                # else: fall through — printers_busy will be appended below
+            else:
+                names_and_missing = [f"{name} (needs {', '.join(missing)})" for name, missing in printers_missing_filament]
+                reasons.append(f"Waiting for filament: {'; '.join(names_and_missing)}")
         if printers_busy:
             reasons.append(f"Busy: {', '.join(printers_busy)}")
         if printers_offline:
@@ -389,6 +460,44 @@ class PrintScheduler:
 
         return None, " | ".join(reasons) if reasons else f"No available {model} printers{location_suffix}"
 
+    def _get_missing_force_color_slots(self, printer_id: int, force_overrides: list[dict]) -> list[str]:
+        """Return descriptive strings for force_color_match slots not satisfied by the printer.
+
+        Each entry in ``force_overrides`` must have ``type`` and ``color`` fields and is expected
+        to carry ``force_color_match: True``.  The printer must have **every** such slot loaded
+        with an exact type+color match.
+
+        Returns:
+            List of ``"TYPE (color)"`` strings for unmatched slots (empty list means all match).
+        """
+        status = printer_manager.get_status(printer_id)
+        if not status:
+            return [f"{o.get('type', '?')} ({o.get('color_name') or o.get('color', '?')})" for o in force_overrides]
+
+        # Build set of loaded type+colour pairs from AMS and external spool
+        loaded: set[tuple[str, str]] = set()
+        for ams_unit in status.raw_data.get("ams", []):
+            for tray in ams_unit.get("tray", []):
+                tray_type = tray.get("tray_type")
+                tray_color = tray.get("tray_color", "")
+                if tray_type:
+                    color_norm = tray_color.replace("#", "").lower()[:6]
+                    loaded.add((tray_type.upper(), color_norm))
+        for vt in status.raw_data.get("vt_tray") or []:
+            vt_type = vt.get("tray_type")
+            if vt_type:
+                color_norm = (vt.get("tray_color", "") or "").replace("#", "").lower()[:6]
+                loaded.add((vt_type.upper(), color_norm))
+
+        missing = []
+        for o in force_overrides:
+            o_type = (o.get("type") or "").upper()
+            o_color = (o.get("color") or "").replace("#", "").lower()[:6]
+            if (o_type, o_color) not in loaded:
+                color_label = o.get("color_name") or o.get("color", "?")
+                missing.append(f"{o_type} ({color_label})")
+        return missing
+
     def _get_missing_filament_types(self, printer_id: int, required_types: list[str]) -> list[str]:
         """Get the list of required filament types that are not loaded on the printer.
 

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

@@ -1244,7 +1244,7 @@ export interface PrintQueueItem {
   auto_off_after: boolean;
   manual_start: boolean;  // Requires manual trigger to start (staged)
   ams_mapping: number[] | null;  // AMS slot mapping for multi-color prints
-  filament_overrides: Array<{ slot_id: number; type: string; color: string }> | null;  // Filament overrides for model-based assignment
+  filament_overrides: Array<{ slot_id: number; type: string; color: string; color_name?: string; force_color_match?: boolean }> | null;  // Filament overrides for model-based assignment
   plate_id: number | null;  // Plate ID for multi-plate 3MF files
   // Print options
   bed_levelling: boolean;
@@ -1274,8 +1274,7 @@ export interface PrintQueueItemCreate {
   printer_id?: number | null;  // null = unassigned
   target_model?: string | null;  // Target printer model (mutually exclusive with printer_id)
   target_location?: string | null;  // Target location filter (only used with target_model)
-  filament_overrides?: Array<{ slot_id: number; type: string; color: string }> | null;
-  // Either archive_id OR library_file_id must be provided
+  filament_overrides?: Array<{ slot_id: number; type: string; color: string; color_name?: string; force_color_match?: boolean }> | null;
   archive_id?: number | null;
   library_file_id?: number | null;
   scheduled_time?: string | null;
@@ -1297,7 +1296,7 @@ export interface PrintQueueItemUpdate {
   printer_id?: number | null;  // null = unassign
   target_model?: string | null;  // Target printer model (mutually exclusive with printer_id)
   target_location?: string | null;  // Target location filter (only used with target_model)
-  filament_overrides?: Array<{ slot_id: number; type: string; color: string }> | null;
+  filament_overrides?: Array<{ slot_id: number; type: string; color: string; color_name?: string; force_color_match?: boolean }> | null;
   position?: number;
   scheduled_time?: string | null;
   require_previous_success?: boolean;

+ 69 - 50
frontend/src/components/PrintModal/FilamentOverride.tsx

@@ -1,6 +1,6 @@
 import { useMemo } from 'react';
 import { useTranslation } from 'react-i18next';
-import { Circle, RotateCcw } from 'lucide-react';
+import { Circle, RotateCcw, Palette } from 'lucide-react';
 import { getColorName } from '../../utils/colors';
 import type { FilamentReqsData } from './types';
 
@@ -9,6 +9,11 @@ interface FilamentOverrideProps {
   availableFilaments: Array<{ type: string; color: string; tray_info_idx: string; tray_sub_brands: string; extruder_id: number | null }>;
   overrides: Record<number, { type: string; color: string }>;
   onChange: (overrides: Record<number, { type: string; color: string }>) => void;
+
+  /** Per-slot force color match flags. Defaults to false (opt-in) when not provided. */
+  forceColorMatch?: Record<number, boolean>;
+  /** Called when a slot's force color match checkbox is toggled. */
+  onForceColorMatchChange?: (slotId: number, value: boolean) => void;
 }
 
 /**
@@ -21,6 +26,8 @@ export function FilamentOverride({
   availableFilaments,
   overrides,
   onChange,
+  forceColorMatch,
+  onForceColorMatchChange,
 }: FilamentOverrideProps) {
   const { t } = useTranslation();
 
@@ -73,58 +80,70 @@ export function FilamentOverride({
             : sameType;
 
           return (
-            <div
-              key={req.slot_id}
-              className="grid items-center gap-2 text-xs"
-              style={{ gridTemplateColumns: '16px minmax(70px, 1fr) auto 2fr 20px' }}
-            >
-              {/* Original color swatch */}
-              <span title={`${t('printModal.originalFilament')}: ${req.type} - ${getColorName(req.color)}`}>
-                <Circle className="w-3 h-3" fill={req.color} stroke={req.color} />
-              </span>
-              {/* Original type + grams */}
-              <span className="text-white truncate">
-                {req.type} <span className="text-bambu-gray">({req.used_grams}g)</span>
-              </span>
-              {/* Arrow */}
-              <span className="text-bambu-gray">→</span>
-              {/* Override dropdown — only compatible (same-type) filaments */}
-              <select
-                value={isOverridden ? `${override.type}|${override.color}` : ''}
-                onChange={(e) => handleChange(req.slot_id, e.target.value)}
-                disabled={compatible.length === 0}
-                className={`flex-1 px-2 py-1 rounded border text-xs bg-bambu-dark-secondary focus:outline-none focus:ring-1 focus:ring-bambu-green ${
-                  isOverridden
-                    ? 'border-blue-400/50 text-blue-400'
-                    : 'border-bambu-gray/30 text-bambu-gray'
-                }`}
+            <div key={req.slot_id} className="space-y-1">
+              <div
+                className="grid items-center gap-2 text-xs"
+                style={{ gridTemplateColumns: '16px minmax(70px, 1fr) auto 2fr 20px' }}
               >
-                <option value="" className="bg-bambu-dark text-bambu-gray">
-                  {t('printModal.originalFilament')}: {req.type} ({getColorName(req.color)})
-                </option>
-                {compatible.map((f, idx) => (
-                  <option
+                {/* Original color swatch */}
+                <span title={`${t('printModal.originalFilament')}: ${req.type} - ${getColorName(req.color)}`}>
+                  <Circle className="w-3 h-3" fill={req.color} stroke={req.color} />
+                </span>
+                {/* Original type + grams */}
+                <span className="text-white truncate">
+                  {req.type} <span className="text-bambu-gray">({req.used_grams}g)</span>
+                </span>
+                {/* Arrow */}
+                <span className="text-bambu-gray">→</span>
+                {/* Override dropdown — only compatible (same-type) filaments */}
+                <select
+                  value={isOverridden ? `${override.type}|${override.color}` : ''}
+                  onChange={(e) => handleChange(req.slot_id, e.target.value)}
+                  disabled={compatible.length === 0}
+                  className={`flex-1 px-2 py-1 rounded border text-xs bg-bambu-dark-secondary focus:outline-none focus:ring-1 focus:ring-bambu-green ${
+                    isOverridden
+                      ? 'border-blue-400/50 text-blue-400'
+                      : 'border-bambu-gray/30 text-bambu-gray'
+                  }`}
+                >
+                  <option value="" className="bg-bambu-dark text-bambu-gray">
+                    {t('printModal.originalFilament')}: {req.type} ({getColorName(req.color)})
+                  </option>
+                  {compatible.map((f, idx) => (
+                    <option
                     key={`${f.type}-${f.color}-${f.tray_sub_brands}-${idx}`}
-                    value={`${f.type}|${f.color}`}
-                    className="bg-bambu-dark text-white"
-                  >
+                      value={`${f.type}|${f.color}`}
+                      className="bg-bambu-dark text-white"
+                    >
                     {f.tray_sub_brands || f.type} ({getColorName(f.color)})
-                  </option>
-                ))}
-              </select>
-              {/* Reset button */}
-              {isOverridden ? (
-                <button
-                  type="button"
-                  onClick={() => handleChange(req.slot_id, '')}
-                  className="text-bambu-gray hover:text-white transition-colors"
-                  title={t('printModal.resetToOriginal')}
-                >
-                  <RotateCcw className="w-3 h-3" />
-                </button>
-              ) : (
-                <span className="w-3" />
-              )}
+                    </option>
+                  ))}
+                </select>
+                {/* Reset button */}
+                {isOverridden ? (
+                  <button
+                    type="button"
+                    onClick={() => handleChange(req.slot_id, '')}
+                    className="text-bambu-gray hover:text-white transition-colors"
+                    title={t('printModal.resetToOriginal')}
+                  >
+                    <RotateCcw className="w-3 h-3" />
+                  </button>
+                ) : (
+                  <span className="w-3" />
+                )}
+              </div>
+              {/* Force Color Match checkbox — shown below each filament row */}
+              <label className="flex items-center gap-1.5 text-xs text-bambu-gray cursor-pointer select-none pl-5">
+                <input
+                  type="checkbox"
+                  checked={forceColorMatch?.[req.slot_id] ?? false}
+                  onChange={(e) => onForceColorMatchChange?.(req.slot_id, e.target.checked)}
+                  className="accent-bambu-green w-3 h-3"
+                />
+                <Palette className="w-3 h-3" />
+                {t('printModal.forceColorMatch')}
+              </label>
             </div>
           );
         })}

+ 50 - 8
frontend/src/components/PrintModal/index.tsx

@@ -8,6 +8,7 @@ import { useAuth } from '../../contexts/AuthContext';
 import { useToast } from '../../contexts/ToastContext';
 import { useFilamentMapping } from '../../hooks/useFilamentMapping';
 import { useMultiPrinterFilamentMapping, type PerPrinterConfig } from '../../hooks/useMultiPrinterFilamentMapping';
+import { getColorName } from '../../utils/colors';
 import { isPlaceholderDate } from '../../utils/amsHelpers';
 import { getCurrencySymbol } from '../../utils/currency';
 import { toDateTimeLocalValue, parseUTCDate } from '../../utils/date';
@@ -170,6 +171,18 @@ export function PrintModal({
     return {};
   });
 
+  // Per-slot force color match flags. Default is false (opt-in).
+  const [forceColorMatch, setForceColorMatch] = useState<Record<number, boolean>>(() => {
+    if (mode === 'edit-queue-item' && queueItem?.filament_overrides) {
+      const flags: Record<number, boolean> = {};
+      for (const o of queueItem.filament_overrides) {
+        flags[o.slot_id] = o.force_color_match === true;
+      }
+      return flags;
+    }
+    return {};
+  });
+
   // Track initial values for clearing mappings on change (edit mode only)
   const [initialPrinterIds] = useState(() => (mode === 'edit-queue-item' && queueItem?.printer_id ? [queueItem.printer_id] : []));
   const [initialPlateId] = useState(() => (mode === 'edit-queue-item' && queueItem ? queueItem.plate_id : null));
@@ -334,6 +347,7 @@ export function PrintModal({
       // Don't clear on initial render in edit mode (values are initialized from queueItem)
       if (mode !== 'edit-queue-item' || prevTargetModel !== null) {
         setFilamentOverrides({});
+        setForceColorMatch({});
       }
     }
   }, [targetModel, selectedPlate, prevTargetModel, prevPlateForOverrides, mode]);
@@ -440,14 +454,38 @@ export function PrintModal({
       return amsMapping;
     };
 
-    // Convert filament overrides from Record to array format for API
-    const filamentOverridesArray = Object.keys(filamentOverrides).length > 0
-      ? Object.entries(filamentOverrides).map(([slotId, { type, color }]) => ({
-          slot_id: parseInt(slotId, 10),
-          type,
-          color,
-        }))
-      : undefined;
+    // Convert filament overrides from Record to array format for API.
+    // Include all slots that either have a user override or have force_color_match enabled
+    // (which is the default for model-based assignment).
+    const buildFilamentOverridesArray = () => {
+      const entries: Array<{ slot_id: number; type: string; color: string; color_name: string; force_color_match: boolean }> = [];
+
+      // Process all slots from filament requirements (to capture force_color_match defaults)
+      if (effectiveFilamentReqs?.filaments) {
+        for (const req of effectiveFilamentReqs.filaments) {
+          const userOverride = filamentOverrides[req.slot_id];
+          const isForceColor = forceColorMatch[req.slot_id] ?? false;
+          const effectiveType = userOverride?.type ?? req.type;
+          const effectiveColor = userOverride?.color ?? req.color;
+
+          // Include slot if user changed the filament OR force_color_match is enabled
+          if (userOverride || isForceColor) {
+            entries.push({ slot_id: req.slot_id, type: effectiveType, color: effectiveColor, color_name: getColorName(effectiveColor), force_color_match: isForceColor });
+          }
+        }
+      } else {
+        // Fallback: no filament requirements data — only include explicit user overrides
+        for (const [slotId, { type, color }] of Object.entries(filamentOverrides)) {
+          const id = parseInt(slotId, 10);
+          const isForceColor = forceColorMatch[id] ?? false;
+          entries.push({ slot_id: id, type, color, color_name: getColorName(color), force_color_match: isForceColor });
+        }
+      }
+
+      return entries.length > 0 ? entries : undefined;
+    };
+
+    const filamentOverridesArray = buildFilamentOverridesArray();
 
     // Common queue data for add-to-queue and edit modes
     const getQueueData = (printerId: number | null, plateOverride?: number | null): PrintQueueItemCreate => ({
@@ -759,6 +797,10 @@ export function PrintModal({
                 availableFilaments={availableFilaments}
                 overrides={filamentOverrides}
                 onChange={setFilamentOverrides}
+                forceColorMatch={forceColorMatch}
+                onForceColorMatchChange={(slotId, value) =>
+                  setForceColorMatch((prev) => ({ ...prev, [slotId]: value }))
+                }
               />
             )}
 

+ 2 - 19
frontend/src/components/PrinterQueueWidget.tsx

@@ -6,6 +6,7 @@ import { api } from '../api/client';
 import { useAuth } from '../contexts/AuthContext';
 import { useToast } from '../contexts/ToastContext';
 import { formatRelativeTime } from '../utils/date';
+import { filterCompatibleQueueItems } from '../utils/printer';
 
 interface PrinterQueueWidgetProps {
   printerId: number;
@@ -40,25 +41,7 @@ export function PrinterQueueWidget({ printerId, printerModel, printerState, plat
   });
 
   // Filter queue to items this printer can actually print (filament type + color check)
-  const compatibleQueue = queue?.filter(item => {
-    // Type check: all required filament types must be loaded
-    if (item.required_filament_types?.length && loadedFilamentTypes?.size) {
-      if (!item.required_filament_types.every((t: string) => loadedFilamentTypes.has(t.toUpperCase()))) {
-        return false;
-      }
-    }
-    // Color check: if filament overrides specify colors, at least one must match
-    // Mirrors backend _count_override_color_matches() logic
-    if (item.filament_overrides?.length && loadedFilaments?.size) {
-      const hasColorMatch = item.filament_overrides.some(o => {
-        const oType = (o.type || '').toUpperCase();
-        const oColor = (o.color || '').replace('#', '').toLowerCase().slice(0, 6);
-        return loadedFilaments.has(`${oType}:${oColor}`);
-      });
-      if (!hasColorMatch) return false;
-    }
-    return true;
-  });
+  const compatibleQueue = queue ? filterCompatibleQueueItems(queue, loadedFilamentTypes, loadedFilaments) : undefined;
 
   // Split into auto-dispatchable vs staged (manual_start) items
   const autoDispatchQueue = compatibleQueue?.filter(item => !item.manual_start) ?? [];

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

@@ -3019,6 +3019,7 @@ export default {
     originalFilament: 'Original',
     overrideWith: 'Ersetzen mit',
     resetToOriginal: 'Auf Original zurücksetzen',
+    forceColorMatch: 'Farbe erzwingen',
   },
 
   // Backup

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

@@ -3023,6 +3023,7 @@ export default {
     originalFilament: 'Original',
     overrideWith: 'Override with',
     resetToOriginal: 'Reset to original',
+    forceColorMatch: 'Force color match',
   },
 
   // Backup

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

@@ -3010,6 +3010,7 @@ export default {
     originalFilament: 'Original',
     overrideWith: 'Remplacer par',
     resetToOriginal: 'Revenir à l\'original',
+    forceColorMatch: 'Forcer la correspondance couleur',
   },
 
   // Backup

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

@@ -3009,6 +3009,7 @@ export default {
     originalFilament: 'Originale',
     overrideWith: 'Sostituisci con',
     resetToOriginal: 'Ripristina originale',
+    forceColorMatch: 'Corrispondenza colore forzata',
   },
 
   // Backup

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

@@ -3023,6 +3023,7 @@ export default {
     originalFilament: 'オリジナル',
     overrideWith: '変更先',
     resetToOriginal: 'オリジナルに戻す',
+    forceColorMatch: 'カラーマッチを強制',
   },
 
   // Backup

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

@@ -3009,6 +3009,7 @@ export default {
     originalFilament: 'Original',
     overrideWith: 'Substituir por',
     resetToOriginal: 'Restaurar original',
+    forceColorMatch: 'Forçar correspondência de cor',
   },
 
   // Backup

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

@@ -3009,6 +3009,7 @@ export default {
     originalFilament: '原始',
     overrideWith: '覆盖为',
     resetToOriginal: '恢复为原始',
+    forceColorMatch: '强制颜色匹配',
   },
 
   // Backup

+ 3 - 17
frontend/src/pages/PrintersPage.tsx

@@ -71,7 +71,7 @@ import { FileUploadModal } from '../components/FileUploadModal';
 import { PrintModal } from '../components/PrintModal';
 import { PrinterInfoModal } from '../components/PrinterInfoModal';
 import { getGlobalTrayId } from '../utils/amsHelpers';
-import { getPrinterImage, getWifiStrength } from '../utils/printer';
+import { getPrinterImage, getWifiStrength, filterCompatibleQueueItems } from '../utils/printer';
 import { FilamentSlotCircle } from '../components/FilamentSlotCircle';
 import { hexToColorName, parseFilamentColor, isLightColor } from '../utils/colors';
 
@@ -1781,24 +1781,10 @@ function PrinterCard({
   });
   // Filter queue items by filament compatibility (same logic as PrinterQueueWidget)
   // so the badge only shows on printers that can actually run the queued jobs.
+  // An empty Set means no filaments are loaded — jobs requiring specific types are incompatible.
   const queueCount = useMemo(() => {
     if (!queueItems?.length) return 0;
-    return queueItems.filter(item => {
-      if (item.required_filament_types?.length && loadedFilamentTypes?.size) {
-        if (!item.required_filament_types.every((t: string) => loadedFilamentTypes.has(t.toUpperCase()))) {
-          return false;
-        }
-      }
-      if (item.filament_overrides?.length && loadedFilaments?.size) {
-        const hasColorMatch = item.filament_overrides.some((o: { type?: string; color?: string }) => {
-          const oType = (o.type || '').toUpperCase();
-          const oColor = (o.color || '').replace('#', '').toLowerCase().slice(0, 6);
-          return loadedFilaments.has(`${oType}:${oColor}`);
-        });
-        if (!hasColorMatch) return false;
-      }
-      return true;
-    }).length;
+    return filterCompatibleQueueItems(queueItems, loadedFilamentTypes, loadedFilaments).length;
   }, [queueItems, loadedFilamentTypes, loadedFilaments]);
 
   // Fetch currently printing queue item to show who started it (Issue #206)

+ 55 - 0
frontend/src/utils/printer.ts

@@ -23,3 +23,58 @@ export function getWifiStrength(rssi: number): { labelKey: string; color: string
   if (rssi >= -80) return { labelKey: 'printers.wifiSignal.weak', color: 'text-orange-400', bars: 1 };
   return { labelKey: 'printers.wifiSignal.veryWeak', color: 'text-red-400', bars: 1 };
 }
+
+import type { PrintQueueItem } from '../api/client';
+
+/**
+ * Filters queue items based on printer compatibility (filament types and colors).
+ * Mirrors backend _find_idle_printer_for_model() logic.
+ * @param items - Array of queue items to filter
+ * @param loadedFilamentTypes - Set of loaded filament types (e.g., "PLA", "PETG")
+ * @param loadedFilaments - Set of loaded filament type+color pairs (e.g., "PLA:ffffff", "PETG:ff0000")
+ * @returns Array of compatible queue items
+ */
+export function filterCompatibleQueueItems(
+  items: PrintQueueItem[],
+  loadedFilamentTypes?: Set<string>,
+  loadedFilaments?: Set<string>
+): PrintQueueItem[] {
+  return items.filter(item => {
+    // Type check: all required filament types must be loaded
+    if (item.required_filament_types && item.required_filament_types.length > 0 && loadedFilamentTypes !== undefined) {
+      if (!item.required_filament_types.every((t: string) => loadedFilamentTypes.has(t.toUpperCase()))) {
+        return false;
+      }
+    }
+
+    // Color check: evaluate force_color_match per slot
+    // Only apply when loadedFilaments is provided (not undefined).
+    // An empty Set means no filaments are loaded — force-matched slots cannot match.
+    if (item.filament_overrides && item.filament_overrides.length > 0 && loadedFilaments !== undefined) {
+      const forceOverrides = item.filament_overrides.filter(o => o.force_color_match === true);
+      const prefOverrides = item.filament_overrides.filter(o => o.force_color_match !== true);
+
+      // All force-matched slots must have exact type+color on this printer
+      if (forceOverrides.length > 0) {
+        const allForceMatch = forceOverrides.every(o => {
+          const oType = (o.type || '').toUpperCase();
+          const oColor = (o.color || '').replace('#', '').toLowerCase().slice(0, 6);
+          return loadedFilaments.has(`${oType}:${oColor}`);
+        });
+        if (!allForceMatch) return false;
+      }
+
+      // Preference-only overrides: at least one color must match (existing behaviour)
+      if (prefOverrides.length > 0 && forceOverrides.length === 0) {
+        const hasColorMatch = prefOverrides.some(o => {
+          const oType = (o.type || '').toUpperCase();
+          const oColor = (o.color || '').replace('#', '').toLowerCase().slice(0, 6);
+          return loadedFilaments.has(`${oType}:${oColor}`);
+        });
+        if (!hasColorMatch) return false;
+      }
+    }
+
+    return true;
+  });
+}

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


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


+ 2 - 2
static/index.html

@@ -23,8 +23,8 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-C37gQ0lV.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-DfcIVNpM.css">
+    <script type="module" crossorigin src="/assets/index-BHfcmN6A.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-BoLtXYT2.css">
   </head>
   <body>
     <div id="root"></div>

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