Browse Source

Add per-printer AMS mapping for multi-printer selection
- New useMultiPrinterFilamentMapping hook for parallel printer status fetching
- Per-printer custom slot configuration with "Custom mapping" checkbox
- Auto-configure using RFID data to match filaments by type/color
- Match status indicator (exact/partial/missing) under each printer
- Re-read button to refresh loaded filaments
- New setting: "Expand custom mapping by default" in Settings → Filament
- FilamentMapping component now accepts defaultExpanded prop

Frontend:
- PrinterSelector: Inline mapping editor for each selected printer
- PrintModal: Fetches settings, auto-expands mapping when setting enabled
- SettingsPage: Toggle for per_printer_mapping_expanded

Backend:
- Added per_printer_mapping_expanded to AppSettings schema
- Added boolean parsing in settings routes
- Added integration tests for new setting

Closes #104

maziggy 4 months ago
parent
commit
e1224316a7

+ 15 - 15
CHANGELOG.md

@@ -23,9 +23,15 @@ All notable changes to Bambuddy will be documented in this file.
 - **Multi-Printer Selection** - Send prints or queue items to multiple printers at once:
   - Checkbox selection for multiple printers in reprint and add-to-queue modes
   - "Select all" / "Clear" buttons for quick selection
-  - Same filament slot mapping applies to all selected printers
   - Progress indicator during multi-printer submission
   - Ideal for print farms with identical filament configurations
+- **Per-Printer AMS Mapping** - Configure filament slot mapping individually for each printer:
+  - Enable "Custom mapping" checkbox under each selected printer
+  - Auto-configure uses RFID data to match filaments automatically
+  - Manual override for specific slot assignments
+  - Match status indicator shows exact/partial/missing matches
+  - Re-read button to refresh printer's loaded filaments
+  - New setting in Settings → Filament to expand custom mapping by default
 - **Enhanced Add-to-Queue** - Now includes plate selection and print options:
   - Configure all print settings upfront instead of editing afterward
   - Filament mapping with manual override capability
@@ -38,20 +44,6 @@ All notable changes to Bambuddy will be documented in this file.
   - Archives are created automatically when prints start
   - Reduces clutter in Archives from unprinted queued files
   - Queue displays library file name, thumbnail, and print time
-
-### Changed
-- **Edit Queue Item modal** - Single printer selection only (reassigns item, doesn't duplicate)
-- **Edit Queue Item button** - Changed from "Print to X Printers" to "Save"
-
-### Fixed
-- **File Manager folder navigation** - Fixed bug where opening a folder would briefly show files then jump back to root:
-  - Removed `selectedFolderId` from useEffect dependency array that was causing a reset loop
-  - Folder navigation now works correctly without resetting
-- **Queue items with library files** - Fixed 500 errors when listing/updating queue items from File Manager
-
-## [0.1.6b10] - 2026-01-20
-
-### New Features
 - **Expandable Color Picker** - Configure AMS Slot modal now has an expandable color palette:
   - 8 basic colors shown by default (White, Black, Red, Blue, Green, Yellow, Orange, Gray)
   - Click "+" to expand 24 additional colors (Cyan, Magenta, Purple, Pink, Brown, Beige, Navy, Teal, Lime, Gold, Silver, Maroon, Olive, Coral, Salmon, Turquoise, Violet, Indigo, Chocolate, Tan, Slate, Charcoal, Ivory, Cream)
@@ -67,7 +59,15 @@ All notable changes to Bambuddy will be documented in this file.
   - Embedded viewer is draggable and resizable with persistent position/size
   - Configure in Settings → General → Camera section
 
+### Changed
+- **Edit Queue Item modal** - Single printer selection only (reassigns item, doesn't duplicate)
+- **Edit Queue Item button** - Changed from "Print to X Printers" to "Save"
+
 ### Fixed
+- **File Manager folder navigation** - Fixed bug where opening a folder would briefly show files then jump back to root:
+  - Removed `selectedFolderId` from useEffect dependency array that was causing a reset loop
+  - Folder navigation now works correctly without resetting
+- **Queue items with library files** - Fixed 500 errors when listing/updating queue items from File Manager
 - **User preset AMS configuration** - Fixed user presets (inheriting from Bambu presets) showing empty fields in Bambu Studio after configuration:
   - Now correctly derives `tray_info_idx` from the preset's `base_id` when `filament_id` is null
   - User presets that inherit from Bambu presets (e.g., "# Overture Matte PLA @BBL H2D") now work correctly

+ 1 - 0
README.md

@@ -71,6 +71,7 @@
 ### ⏰ Scheduling & Automation
 - Print queue with drag-and-drop
 - Multi-printer selection (send to multiple printers at once)
+- Per-printer AMS mapping (individual slot configuration for print farms)
 - Scheduled prints (date/time)
 - Queue Only mode (stage without auto-start)
 - Smart plug integration (Tasmota, Home Assistant)

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

@@ -78,6 +78,7 @@ async def get_settings(db: AsyncSession = Depends(get_db)):
                 "mqtt_enabled",
                 "mqtt_use_tls",
                 "ha_enabled",
+                "per_printer_mapping_expanded",
             ]:
                 settings_dict[setting.key] = setting.value.lower() == "true"
             elif setting.key in [

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

@@ -41,6 +41,11 @@ class AppSettings(BaseModel):
     )
     ams_history_retention_days: int = Field(default=30, description="Number of days to keep AMS sensor history data")
 
+    # Print modal settings
+    per_printer_mapping_expanded: bool = Field(
+        default=False, description="Expand custom filament mapping by default in print modal"
+    )
+
     # Date/time display format
     date_format: str = Field(default="system", description="Date format: system, us, eu, iso")
     time_format: str = Field(default="system", description="Time format: system, 12h, 24h")
@@ -127,6 +132,7 @@ class AppSettingsUpdate(BaseModel):
     ams_temp_good: float | None = None
     ams_temp_fair: float | None = None
     ams_history_retention_days: int | None = None
+    per_printer_mapping_expanded: bool | None = None
     date_format: str | None = None
     time_format: str | None = None
     default_printer_id: int | None = None

+ 42 - 0
backend/tests/integration/test_settings_api.py

@@ -328,3 +328,45 @@ class TestSettingsAPI:
         assert "camera_view_mode" in result
         # Default is 'window' as defined in schema
         assert result["camera_view_mode"] in ["window", "embedded"]
+
+    # ========================================================================
+    # Per-printer mapping settings tests
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_per_printer_mapping_expanded(self, async_client: AsyncClient):
+        """Verify per_printer_mapping_expanded can be updated."""
+        response = await async_client.put("/api/v1/settings/", json={"per_printer_mapping_expanded": True})
+
+        assert response.status_code == 200
+        assert response.json()["per_printer_mapping_expanded"] is True
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_per_printer_mapping_expanded_persists(self, async_client: AsyncClient):
+        """CRITICAL: Verify per_printer_mapping_expanded persists after update."""
+        # Update to True
+        await async_client.put("/api/v1/settings/", json={"per_printer_mapping_expanded": True})
+
+        # Verify persistence in new request
+        response = await async_client.get("/api/v1/settings/")
+        assert response.json()["per_printer_mapping_expanded"] is True
+
+        # Update back to False
+        await async_client.put("/api/v1/settings/", json={"per_printer_mapping_expanded": False})
+
+        # Verify persistence
+        response = await async_client.get("/api/v1/settings/")
+        assert response.json()["per_printer_mapping_expanded"] is False
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_per_printer_mapping_expanded_default(self, async_client: AsyncClient):
+        """Verify per_printer_mapping_expanded has correct default value."""
+        response = await async_client.get("/api/v1/settings/")
+        result = response.json()
+
+        assert "per_printer_mapping_expanded" in result
+        # Default is False as defined in schema
+        assert isinstance(result["per_printer_mapping_expanded"], bool)

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

@@ -558,6 +558,8 @@ export interface AppSettings {
   ams_temp_good: number;      // <= this is green/blue
   ams_temp_fair: number;      // <= this is orange, > is red
   ams_history_retention_days: number;  // days to keep AMS sensor history
+  // Print modal settings
+  per_printer_mapping_expanded: boolean;  // Whether custom mapping is expanded by default in print modal
   // Date/time format settings
   date_format: 'system' | 'us' | 'eu' | 'iso';
   time_format: 'system' | '12h' | '24h';

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

@@ -15,10 +15,11 @@ export function FilamentMapping({
   filamentReqs,
   manualMappings,
   onManualMappingChange,
-}: FilamentMappingProps) {
+  defaultExpanded = false,
+}: FilamentMappingProps & { defaultExpanded?: boolean }) {
   const queryClient = useQueryClient();
   const [isRefreshing, setIsRefreshing] = useState(false);
-  const [isExpanded, setIsExpanded] = useState(false);
+  const [isExpanded, setIsExpanded] = useState(defaultExpanded);
 
   // Fetch printer status
   const { data: printerStatus } = useQuery({

+ 341 - 49
frontend/src/components/PrintModal/PrinterSelector.tsx

@@ -1,9 +1,205 @@
-import { Printer as PrinterIcon, Loader2, AlertCircle, Check } from 'lucide-react';
+import { useState } from 'react';
+import { useQueryClient } from '@tanstack/react-query';
+import {
+  Printer as PrinterIcon,
+  Loader2,
+  AlertCircle,
+  AlertTriangle,
+  Check,
+  Circle,
+  RefreshCw,
+  Wand2,
+} from 'lucide-react';
+import { api } from '../../api/client';
+import { getColorName } from '../../utils/colors';
+import {
+  normalizeColorForCompare,
+  colorsAreSimilar,
+} from '../../utils/amsHelpers';
 import type { PrinterSelectorProps } from './types';
+import type { PrinterMappingResult, PerPrinterConfig } from '../../hooks/useMultiPrinterFilamentMapping';
+import type { FilamentRequirement, LoadedFilament } from '../../hooks/useFilamentMapping';
+
+interface PrinterSelectorWithMappingProps extends PrinterSelectorProps {
+  /** Per-printer mapping results (only used when multiple printers selected) */
+  printerMappingResults?: PrinterMappingResult[];
+  /** Filament requirements for the print */
+  filamentReqs?: { filaments: FilamentRequirement[] };
+  /** Callback to auto-configure a printer */
+  onAutoConfigurePrinter?: (printerId: number) => void;
+  /** Callback to update printer config */
+  onUpdatePrinterConfig?: (printerId: number, config: Partial<PerPrinterConfig>) => void;
+}
+
+/**
+ * Inline AMS mapping editor for a single printer.
+ */
+function InlineMappingEditor({
+  printerResult,
+  filamentReqs,
+  onUpdateConfig,
+}: {
+  printerResult: PrinterMappingResult;
+  filamentReqs: FilamentRequirement[];
+  onUpdateConfig: (config: Partial<PerPrinterConfig>) => void;
+}) {
+  const queryClient = useQueryClient();
+  const [isRefreshing, setIsRefreshing] = useState(false);
+
+  const handleSlotChange = (slotId: number, value: string) => {
+    if (slotId <= 0) return;
+
+    const newMappings = { ...printerResult.config.manualMappings };
+    if (value === '') {
+      delete newMappings[slotId];
+    } else {
+      newMappings[slotId] = parseInt(value, 10);
+    }
+
+    onUpdateConfig({
+      useDefault: false,
+      manualMappings: newMappings,
+      autoConfigured: false,
+    });
+  };
+
+  const handleRefresh = async () => {
+    setIsRefreshing(true);
+    try {
+      await api.refreshPrinterStatus(printerResult.printerId);
+      await new Promise((r) => setTimeout(r, 500));
+      await queryClient.refetchQueries({ queryKey: ['printer-status', printerResult.printerId] });
+    } finally {
+      setIsRefreshing(false);
+    }
+  };
+
+  // Compute current slot assignments
+  const slotAssignments = filamentReqs.map((req) => {
+    const slotId = req.slot_id || 0;
+    const currentMapping = printerResult.config.manualMappings[slotId];
+
+    let loaded: LoadedFilament | undefined;
+    let isManual = false;
+
+    if (currentMapping !== undefined) {
+      loaded = printerResult.loadedFilaments.find((f) => f.globalTrayId === currentMapping);
+      isManual = true;
+    } else {
+      // Auto-match logic
+      const usedTrayIds = new Set<number>(Object.values(printerResult.config.manualMappings));
+
+      const exactMatch = printerResult.loadedFilaments.find(
+        (f) =>
+          !usedTrayIds.has(f.globalTrayId) &&
+          f.type?.toUpperCase() === req.type?.toUpperCase() &&
+          normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color)
+      );
+      const similarMatch = exactMatch
+        ? undefined
+        : printerResult.loadedFilaments.find(
+            (f) =>
+              !usedTrayIds.has(f.globalTrayId) &&
+              f.type?.toUpperCase() === req.type?.toUpperCase() &&
+              colorsAreSimilar(f.color, req.color)
+          );
+      const typeOnlyMatch =
+        exactMatch || similarMatch
+          ? undefined
+          : printerResult.loadedFilaments.find(
+              (f) => !usedTrayIds.has(f.globalTrayId) && f.type?.toUpperCase() === req.type?.toUpperCase()
+            );
+      loaded = exactMatch ?? similarMatch ?? typeOnlyMatch;
+    }
+
+    // Determine status
+    let status: 'match' | 'type_only' | 'mismatch' = 'mismatch';
+    if (loaded) {
+      const typeMatch = loaded.type?.toUpperCase() === req.type?.toUpperCase();
+      const colorMatch =
+        normalizeColorForCompare(loaded.color) === normalizeColorForCompare(req.color) ||
+        colorsAreSimilar(loaded.color, req.color);
+
+      if (typeMatch && colorMatch) {
+        status = 'match';
+      } else if (typeMatch) {
+        status = 'type_only';
+      }
+    }
+
+    return { req, loaded, status, isManual };
+  });
+
+  return (
+    <div className="mt-2 bg-bambu-dark rounded-lg p-3 space-y-2">
+      <div className="flex items-center justify-between mb-2">
+        <span className="text-xs text-bambu-gray">Custom slot mapping</span>
+        <button
+          type="button"
+          onClick={handleRefresh}
+          className="flex items-center gap-1 px-2 py-0.5 text-xs rounded border border-bambu-gray/30 hover:border-bambu-gray hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray hover:text-white"
+          disabled={isRefreshing}
+        >
+          <RefreshCw className={`w-3 h-3 ${isRefreshing ? 'animate-spin' : ''}`} />
+          <span>Re-read</span>
+        </button>
+      </div>
+
+      {slotAssignments.map(({ req, loaded, status, isManual }, idx) => (
+        <div
+          key={idx}
+          className="grid items-center gap-2 text-xs"
+          style={{ gridTemplateColumns: '16px minmax(70px, 1fr) auto 2fr 16px' }}
+        >
+          <span title={`Required: ${req.type} - ${getColorName(req.color)}`}>
+            <Circle className="w-3 h-3" fill={req.color} stroke={req.color} />
+          </span>
+          <span className="text-white truncate">
+            {req.type} <span className="text-bambu-gray">({req.used_grams}g)</span>
+          </span>
+          <span className="text-bambu-gray">→</span>
+          <select
+            value={loaded?.globalTrayId ?? ''}
+            onChange={(e) => handleSlotChange(req.slot_id || 0, e.target.value)}
+            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 ${
+              status === 'match'
+                ? 'border-bambu-green/50 text-bambu-green'
+                : status === 'type_only'
+                ? 'border-yellow-400/50 text-yellow-400'
+                : 'border-orange-400/50 text-orange-400'
+            } ${isManual ? 'ring-1 ring-blue-400/50' : ''}`}
+            title={isManual ? 'Manually selected' : 'Auto-matched'}
+          >
+            <option value="" className="bg-bambu-dark text-bambu-gray">
+              -- Select slot --
+            </option>
+            {printerResult.loadedFilaments.map((f) => (
+              <option key={f.globalTrayId} value={f.globalTrayId} className="bg-bambu-dark text-white">
+                {f.label}: {f.type} ({f.colorName})
+              </option>
+            ))}
+          </select>
+          {status === 'match' ? (
+            <Check className="w-3 h-3 text-bambu-green" />
+          ) : status === 'type_only' ? (
+            <span title="Same type, different color">
+              <AlertTriangle className="w-3 h-3 text-yellow-400" />
+            </span>
+          ) : (
+            <span title="Filament type not loaded">
+              <AlertTriangle className="w-3 h-3 text-orange-400" />
+            </span>
+          )}
+        </div>
+      ))}
+    </div>
+  );
+}
 
 /**
  * Printer selection component with grid-based UI.
  * Supports single or multi-select modes.
+ * When multiple printers are selected, shows per-printer mapping overrides.
  */
 export function PrinterSelector({
   printers,
@@ -12,10 +208,22 @@ export function PrinterSelector({
   isLoading = false,
   allowMultiple = false,
   showInactive = false,
-}: PrinterSelectorProps) {
+  printerMappingResults,
+  filamentReqs,
+  onAutoConfigurePrinter,
+  onUpdatePrinterConfig,
+}: PrinterSelectorWithMappingProps) {
   // Filter printers based on showInactive flag
   const displayPrinters = showInactive ? printers : printers.filter((p) => p.is_active);
 
+  const showMappingOptions = allowMultiple &&
+    selectedPrinterIds.length > 1 &&
+    printerMappingResults &&
+    filamentReqs?.filaments &&
+    filamentReqs.filaments.length > 0 &&
+    onAutoConfigurePrinter &&
+    onUpdatePrinterConfig;
+
   if (isLoading) {
     return (
       <div className="flex justify-center py-8">
@@ -35,14 +243,12 @@ export function PrinterSelector({
 
   const handlePrinterClick = (printerId: number) => {
     if (allowMultiple) {
-      // Multi-select mode: toggle printer in selection
       if (selectedPrinterIds.includes(printerId)) {
         onMultiSelect(selectedPrinterIds.filter((id) => id !== printerId));
       } else {
         onMultiSelect([...selectedPrinterIds, printerId]);
       }
     } else {
-      // Single-select mode: replace selection
       onMultiSelect([printerId]);
     }
   };
@@ -55,10 +261,28 @@ export function PrinterSelector({
     onMultiSelect([]);
   };
 
-  const isSelected = (printerId: number) => selectedPrinterIds.includes(printerId);
+  const handleOverrideToggle = (printerId: number, enabled: boolean, e: React.MouseEvent) => {
+    e.stopPropagation();
+    if (!onAutoConfigurePrinter || !onUpdatePrinterConfig) return;
+
+    if (enabled) {
+      onAutoConfigurePrinter(printerId);
+    } else {
+      onUpdatePrinterConfig(printerId, {
+        useDefault: true,
+        manualMappings: {},
+        autoConfigured: false,
+      });
+    }
+  };
 
+  const isSelected = (printerId: number) => selectedPrinterIds.includes(printerId);
   const selectedCount = selectedPrinterIds.length;
 
+  const getPrinterMappingResult = (printerId: number) => {
+    return printerMappingResults?.find((r) => r.printerId === printerId);
+  };
+
   return (
     <div className="space-y-2 mb-6">
       {/* Multi-select header */}
@@ -92,51 +316,119 @@ export function PrinterSelector({
         </div>
       )}
 
-      {displayPrinters.map((printer) => (
-        <button
-          key={printer.id}
-          type="button"
-          onClick={() => handlePrinterClick(printer.id)}
-          className={`w-full flex items-center gap-3 p-3 rounded-lg border transition-colors ${
-            isSelected(printer.id)
-              ? 'border-bambu-green bg-bambu-green/10'
-              : 'border-bambu-dark-tertiary bg-bambu-dark hover:border-bambu-gray'
-          } ${!printer.is_active ? 'opacity-60' : ''}`}
-        >
-          <div
-            className={`p-2 rounded-lg ${
-              isSelected(printer.id) ? 'bg-bambu-green/20' : 'bg-bambu-dark-tertiary'
-            }`}
-          >
-            <PrinterIcon
-              className={`w-5 h-5 ${
-                isSelected(printer.id) ? 'text-bambu-green' : 'text-bambu-gray'
-              }`}
-            />
-          </div>
-          <div className="text-left flex-1">
-            <p className="text-white font-medium">
-              {printer.name}
-              {!printer.is_active && <span className="text-bambu-gray text-xs ml-2">(inactive)</span>}
-            </p>
-            <p className="text-xs text-bambu-gray">
-              {printer.model || 'Unknown model'} • {printer.ip_address}
-            </p>
-          </div>
-          {/* Checkbox indicator for multi-select */}
-          {allowMultiple && (
-            <div
-              className={`w-5 h-5 rounded border-2 flex items-center justify-center transition-colors ${
-                isSelected(printer.id)
-                  ? 'bg-bambu-green border-bambu-green'
-                  : 'border-bambu-gray/50'
-              }`}
+      {displayPrinters.map((printer) => {
+        const selected = isSelected(printer.id);
+        const mappingResult = getPrinterMappingResult(printer.id);
+        const hasOverride = mappingResult && !mappingResult.config.useDefault;
+
+        return (
+          <div key={printer.id}>
+            {/* Printer selection button */}
+            <button
+              type="button"
+              onClick={() => handlePrinterClick(printer.id)}
+              className={`w-full flex items-center gap-3 p-3 rounded-lg border transition-colors ${
+                selected
+                  ? 'border-bambu-green bg-bambu-green/10'
+                  : 'border-bambu-dark-tertiary bg-bambu-dark hover:border-bambu-gray'
+              } ${!printer.is_active ? 'opacity-60' : ''}`}
             >
-              {isSelected(printer.id) && <Check className="w-3 h-3 text-white" />}
-            </div>
-          )}
-        </button>
-      ))}
+              <div
+                className={`p-2 rounded-lg ${
+                  selected ? 'bg-bambu-green/20' : 'bg-bambu-dark-tertiary'
+                }`}
+              >
+                <PrinterIcon
+                  className={`w-5 h-5 ${
+                    selected ? 'text-bambu-green' : 'text-bambu-gray'
+                  }`}
+                />
+              </div>
+              <div className="text-left flex-1">
+                <p className="text-white font-medium">
+                  {printer.name}
+                  {!printer.is_active && <span className="text-bambu-gray text-xs ml-2">(inactive)</span>}
+                </p>
+                <p className="text-xs text-bambu-gray">
+                  {printer.model || 'Unknown model'} • {printer.ip_address}
+                </p>
+              </div>
+              {allowMultiple && (
+                <div
+                  className={`w-5 h-5 rounded border-2 flex items-center justify-center transition-colors ${
+                    selected
+                      ? 'bg-bambu-green border-bambu-green'
+                      : 'border-bambu-gray/50'
+                  }`}
+                >
+                  {selected && <Check className="w-3 h-3 text-white" />}
+                </div>
+              )}
+            </button>
+
+            {/* Per-printer override checkbox + mapping (only when selected and multi-printer) */}
+            {selected && showMappingOptions && mappingResult && (
+              <div className="ml-4 mt-2 mb-3">
+                {/* Override checkbox row */}
+                <div className="flex items-center gap-2">
+                  <label
+                    className="flex items-center gap-2 cursor-pointer"
+                    onClick={(e) => e.stopPropagation()}
+                  >
+                    <input
+                      type="checkbox"
+                      checked={hasOverride}
+                      onChange={(e) => handleOverrideToggle(printer.id, e.target.checked, e as unknown as React.MouseEvent)}
+                      className="w-3.5 h-3.5 rounded border-bambu-gray/30 bg-bambu-dark-secondary text-bambu-green focus:ring-bambu-green focus:ring-offset-0"
+                    />
+                    <span className="text-xs text-bambu-gray">Custom mapping</span>
+                  </label>
+
+                  {/* Match status indicator */}
+                  <span className={`text-xs ml-2 ${
+                    mappingResult.matchStatus === 'full'
+                      ? 'text-bambu-green'
+                      : mappingResult.matchStatus === 'partial'
+                      ? 'text-yellow-400'
+                      : 'text-orange-400'
+                  }`}>
+                    ({mappingResult.exactMatches}/{mappingResult.totalSlots} matched)
+                  </span>
+
+                  {/* Loading indicator */}
+                  {mappingResult.isLoading && (
+                    <RefreshCw className="w-3 h-3 text-bambu-gray animate-spin" />
+                  )}
+
+                  {/* Auto-configure button (when override is enabled) */}
+                  {hasOverride && (
+                    <button
+                      type="button"
+                      onClick={(e) => {
+                        e.stopPropagation();
+                        onAutoConfigurePrinter!(printer.id);
+                      }}
+                      className="ml-auto flex items-center gap-1 px-2 py-0.5 text-xs rounded border border-bambu-gray/30 hover:border-bambu-gray hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray hover:text-white"
+                    >
+                      <Wand2 className="w-3 h-3" />
+                      Auto
+                    </button>
+                  )}
+                </div>
+
+                {/* Inline mapping editor (shown when override is checked) */}
+                {hasOverride && (
+                  <InlineMappingEditor
+                    printerResult={mappingResult}
+                    filamentReqs={filamentReqs!.filaments}
+                    onUpdateConfig={(config) => onUpdatePrinterConfig!(printer.id, config)}
+                  />
+                )}
+              </div>
+            )}
+          </div>
+        );
+      })}
 
       {/* Warning when no printer selected */}
       {selectedCount === 0 && (

+ 67 - 19
frontend/src/components/PrintModal/index.tsx

@@ -7,6 +7,7 @@ import { Card, CardContent } from '../Card';
 import { Button } from '../Button';
 import { useToast } from '../../contexts/ToastContext';
 import { useFilamentMapping } from '../../hooks/useFilamentMapping';
+import { useMultiPrinterFilamentMapping, type PerPrinterConfig } from '../../hooks/useMultiPrinterFilamentMapping';
 import { isPlaceholderDate } from '../../utils/amsHelpers';
 import { PrinterSelector } from './PrinterSelector';
 import { PlateSelector } from './PlateSelector';
@@ -99,7 +100,7 @@ export function PrintModal({
     return DEFAULT_SCHEDULE_OPTIONS;
   });
 
-  // Manual slot overrides: slot_id (1-indexed) -> globalTrayId
+  // Manual slot overrides: slot_id (1-indexed) -> globalTrayId (default mapping for single printer or all printers)
   const [manualMappings, setManualMappings] = useState<Record<number, number>>(() => {
     if (mode === 'edit-queue-item' && queueItem?.ams_mapping && Array.isArray(queueItem.ams_mapping)) {
       const mappings: Record<number, number> = {};
@@ -113,6 +114,9 @@ export function PrintModal({
     return {};
   });
 
+  // Per-printer override configs (for multi-printer selection)
+  const [perPrinterConfigs, setPerPrinterConfigs] = useState<Record<number, PerPrinterConfig>>({});
+
   // 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));
@@ -127,6 +131,11 @@ export function PrintModal({
   const effectivePrinterId = selectedPrinters.length > 0 ? selectedPrinters[0] : null;
 
   // Queries
+  const { data: settings } = useQuery({
+    queryKey: ['settings'],
+    queryFn: api.getSettings,
+  });
+
   const { data: printers, isLoading: loadingPrinters } = useQuery({
     queryKey: ['printers'],
     queryFn: api.getPrinters,
@@ -181,6 +190,16 @@ export function PrintModal({
   // Get AMS mapping from hook (only when single printer selected)
   const { amsMapping } = useFilamentMapping(effectiveFilamentReqs, printerStatus, manualMappings);
 
+  // Multi-printer filament mapping (for per-printer configuration)
+  const multiPrinterMapping = useMultiPrinterFilamentMapping(
+    selectedPrinters,
+    printers,
+    effectiveFilamentReqs,
+    manualMappings,
+    perPrinterConfigs,
+    setPerPrinterConfigs
+  );
+
   // Auto-select first plate for single-plate files
   useEffect(() => {
     if (platesData?.plates?.length === 1 && !selectedPlate) {
@@ -198,19 +217,39 @@ export function PrintModal({
     }
   }, [mode, printers, selectedPrinters.length]);
 
-  // Clear manual mappings when printer or plate changes
+  // Clear manual mappings and per-printer configs when printer or plate changes
   useEffect(() => {
     if (mode === 'edit-queue-item') {
       // For edit mode, clear mappings if printer selection or plate changed from initial
       const printersChanged = JSON.stringify(selectedPrinters.sort()) !== JSON.stringify(initialPrinterIds.sort());
       if (printersChanged || selectedPlate !== initialPlateId) {
         setManualMappings({});
+        setPerPrinterConfigs({});
       }
     } else {
       setManualMappings({});
+      setPerPrinterConfigs({});
     }
   }, [mode, selectedPrinters, selectedPlate, initialPrinterIds, initialPlateId]);
 
+  // Auto-expand per-printer mapping when setting is enabled and multiple printers selected
+  useEffect(() => {
+    if (!settings?.per_printer_mapping_expanded) return;
+    if (selectedPrinters.length <= 1) return;
+
+    // Check if any printer needs to be auto-configured
+    const printersNeedingConfig = selectedPrinters.filter(
+      printerId => !perPrinterConfigs[printerId] || perPrinterConfigs[printerId].useDefault
+    );
+
+    if (printersNeedingConfig.length > 0) {
+      // Auto-configure printers that don't have custom config
+      printersNeedingConfig.forEach(printerId => {
+        multiPrinterMapping.autoConfigurePrinter(printerId);
+      });
+    }
+  }, [settings?.per_printer_mapping_expanded, selectedPrinters, perPrinterConfigs, multiPrinterMapping]);
+
   // Close on Escape key
   useEffect(() => {
     const handleKeyDown = (e: KeyboardEvent) => {
@@ -260,6 +299,18 @@ export function PrintModal({
       errors: [],
     };
 
+    // Get mapping for a specific printer (per-printer override or default)
+    const getMappingForPrinter = (printerId: number): number[] | undefined => {
+      // For multi-printer selection, check if this printer has an override
+      if (selectedPrinters.length > 1) {
+        const printerConfig = perPrinterConfigs[printerId];
+        if (printerConfig && !printerConfig.useDefault) {
+          return multiPrinterMapping.getFinalMapping(printerId);
+        }
+      }
+      return amsMapping;
+    };
+
     // Common queue data for add-to-queue and edit modes
     const getQueueData = (printerId: number): PrintQueueItemCreate => ({
       printer_id: printerId,
@@ -269,7 +320,7 @@ export function PrintModal({
       require_previous_success: scheduleOptions.requirePreviousSuccess,
       auto_off_after: scheduleOptions.autoOffAfter,
       manual_start: scheduleOptions.scheduleType === 'manual',
-      ams_mapping: amsMapping,
+      ams_mapping: getMappingForPrinter(printerId),
       plate_id: selectedPlate,
       scheduled_time: scheduleOptions.scheduleType === 'scheduled' && scheduleOptions.scheduledTime
         ? new Date(scheduleOptions.scheduledTime).toISOString()
@@ -284,26 +335,28 @@ export function PrintModal({
       try {
         if (mode === 'reprint') {
           // Reprint mode - start print immediately
+          const printerMapping = getMappingForPrinter(printerId);
           if (isLibraryFile) {
             await api.printLibraryFile(libraryFileId!, printerId, {
-              ams_mapping: amsMapping,
+              ams_mapping: printerMapping,
               ...printOptions,
             });
           } else {
             await api.reprintArchive(archiveId!, printerId, {
               plate_id: selectedPlate ?? undefined,
-              ams_mapping: amsMapping,
+              ams_mapping: printerMapping,
               ...printOptions,
             });
           }
         } else if (mode === 'edit-queue-item' && i === 0) {
           // Edit mode - update the original queue item for the first printer
+          const printerMapping = getMappingForPrinter(printerId);
           const updateData: PrintQueueItemUpdate = {
             printer_id: printerId,
             require_previous_success: scheduleOptions.requirePreviousSuccess,
             auto_off_after: scheduleOptions.autoOffAfter,
             manual_start: scheduleOptions.scheduleType === 'manual',
-            ams_mapping: amsMapping,
+            ams_mapping: printerMapping,
             plate_id: selectedPlate,
             scheduled_time: scheduleOptions.scheduleType === 'scheduled' && scheduleOptions.scheduledTime
               ? new Date(scheduleOptions.scheduledTime).toISOString()
@@ -448,7 +501,7 @@ export function PrintModal({
               )}
             </p>
 
-            {/* Printer selection */}
+            {/* Printer selection with per-printer mapping */}
             <PrinterSelector
               printers={printers || []}
               selectedPrinterIds={selectedPrinters}
@@ -456,18 +509,12 @@ export function PrintModal({
               isLoading={loadingPrinters}
               allowMultiple={true}
               showInactive={mode === 'edit-queue-item'}
+              printerMappingResults={multiPrinterMapping.printerResults}
+              filamentReqs={effectiveFilamentReqs}
+              onAutoConfigurePrinter={multiPrinterMapping.autoConfigurePrinter}
+              onUpdatePrinterConfig={multiPrinterMapping.updatePrinterConfig}
             />
 
-            {/* Multi-printer filament mapping note */}
-            {selectedPrinters.length > 1 && (
-              <div className="flex items-start gap-2 p-3 mb-2 bg-blue-500/10 border border-blue-500/30 rounded-lg text-sm">
-                <AlertCircle className="w-4 h-4 text-blue-400 mt-0.5 flex-shrink-0" />
-                <p className="text-blue-400">
-                  Slot mapping below applies to all {selectedPrinters.length} printers. Ensure they have matching filament configurations.
-                </p>
-              </div>
-            )}
-
             {/* Plate selection */}
             <PlateSelector
               plates={plates}
@@ -486,13 +533,14 @@ export function PrintModal({
               </div>
             )}
 
-            {/* Filament mapping - show when printer selected and filament requirements available */}
-            {showFilamentMapping && !archiveDataMissing && (
+            {/* Filament mapping - only show when single printer selected */}
+            {showFilamentMapping && !archiveDataMissing && selectedPrinters.length === 1 && (
               <FilamentMapping
                 printerId={effectivePrinterId!}
                 filamentReqs={effectiveFilamentReqs}
                 manualMappings={manualMappings}
                 onManualMappingChange={setManualMappings}
+                defaultExpanded={settings?.per_printer_mapping_expanded ?? false}
               />
             )}
 

+ 385 - 0
frontend/src/hooks/useMultiPrinterFilamentMapping.ts

@@ -0,0 +1,385 @@
+import { useMemo } from 'react';
+import { useQueries } from '@tanstack/react-query';
+import { api } from '../api/client';
+import type { PrinterStatus, Printer } from '../api/client';
+import {
+  buildLoadedFilaments,
+  computeAmsMapping,
+  type LoadedFilament,
+  type FilamentRequirement,
+} from './useFilamentMapping';
+import {
+  normalizeColorForCompare,
+  colorsAreSimilar,
+} from '../utils/amsHelpers';
+
+/**
+ * Match status for a single printer's filament configuration.
+ */
+export type PrinterMatchStatus = 'full' | 'partial' | 'missing';
+
+/**
+ * Per-printer configuration for AMS mapping.
+ */
+export interface PerPrinterConfig {
+  /** Whether this printer uses the default mapping or has custom config */
+  useDefault: boolean;
+  /** Manual slot overrides for this printer (slot_id -> globalTrayId) */
+  manualMappings: Record<number, number>;
+  /** Whether this mapping was auto-configured */
+  autoConfigured: boolean;
+}
+
+/**
+ * Result of filament mapping for a single printer.
+ */
+export interface PrinterMappingResult {
+  printerId: number;
+  printerName: string;
+  /** Printer status data */
+  status: PrinterStatus | undefined;
+  /** Whether status is still loading */
+  isLoading: boolean;
+  /** List of loaded filaments in this printer */
+  loadedFilaments: LoadedFilament[];
+  /** Auto-computed AMS mapping for this printer */
+  autoMapping: number[] | undefined;
+  /** Final AMS mapping (considering manual overrides) */
+  finalMapping: number[] | undefined;
+  /** Match status: full (all exact), partial (some mismatches), missing (type not found) */
+  matchStatus: PrinterMatchStatus;
+  /** Number of slots with exact match (type + color) */
+  exactMatches: number;
+  /** Number of slots with type-only match */
+  typeOnlyMatches: number;
+  /** Number of slots with missing type */
+  missingTypes: number;
+  /** Total required slots */
+  totalSlots: number;
+  /** Per-printer config */
+  config: PerPrinterConfig;
+}
+
+/**
+ * Result of the useMultiPrinterFilamentMapping hook.
+ */
+export interface UseMultiPrinterFilamentMappingResult {
+  /** Results for each selected printer */
+  printerResults: PrinterMappingResult[];
+  /** Whether any printer data is still loading */
+  isLoading: boolean;
+  /** Per-printer configurations */
+  perPrinterConfigs: Record<number, PerPrinterConfig>;
+  /** Update config for a specific printer */
+  updatePrinterConfig: (printerId: number, config: Partial<PerPrinterConfig>) => void;
+  /** Auto-configure all printers based on their loaded filaments */
+  autoConfigureAll: () => void;
+  /** Auto-configure a specific printer */
+  autoConfigurePrinter: (printerId: number) => void;
+  /** Get final mapping for a specific printer (for submission) */
+  getFinalMapping: (printerId: number) => number[] | undefined;
+  /** Check if all printers have acceptable mappings */
+  allPrintersReady: boolean;
+}
+
+/**
+ * Compute match details for a printer given filament requirements and loaded filaments.
+ */
+function computeMatchDetails(
+  filamentReqs: FilamentRequirement[] | undefined,
+  loadedFilaments: LoadedFilament[],
+  manualMappings: Record<number, number>
+): { 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' };
+  }
+
+  let exactMatches = 0;
+  let typeOnlyMatches = 0;
+  let missingTypes = 0;
+  const usedTrayIds = new Set<number>(Object.values(manualMappings));
+
+  for (const req of filamentReqs) {
+    const slotId = req.slot_id || 0;
+
+    // Check manual override first
+    if (slotId > 0 && manualMappings[slotId] !== undefined) {
+      const manualTrayId = manualMappings[slotId];
+      const manualLoaded = loadedFilaments.find((f) => f.globalTrayId === manualTrayId);
+
+      if (manualLoaded) {
+        const typeMatch = manualLoaded.type?.toUpperCase() === req.type?.toUpperCase();
+        const colorMatch =
+          normalizeColorForCompare(manualLoaded.color) === normalizeColorForCompare(req.color) ||
+          colorsAreSimilar(manualLoaded.color, req.color);
+
+        if (typeMatch && colorMatch) {
+          exactMatches++;
+        } else if (typeMatch) {
+          typeOnlyMatches++;
+        } else {
+          missingTypes++;
+        }
+        continue;
+      }
+    }
+
+    // Auto-match
+    const exactMatch = loadedFilaments.find(
+      (f) =>
+        !usedTrayIds.has(f.globalTrayId) &&
+        f.type?.toUpperCase() === req.type?.toUpperCase() &&
+        normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color)
+    );
+    const similarMatch = exactMatch
+      ? undefined
+      : loadedFilaments.find(
+          (f) =>
+            !usedTrayIds.has(f.globalTrayId) &&
+            f.type?.toUpperCase() === req.type?.toUpperCase() &&
+            colorsAreSimilar(f.color, req.color)
+        );
+    const typeOnlyMatch =
+      exactMatch || similarMatch
+        ? undefined
+        : loadedFilaments.find(
+            (f) => !usedTrayIds.has(f.globalTrayId) && f.type?.toUpperCase() === req.type?.toUpperCase()
+          );
+    const loaded = exactMatch ?? similarMatch ?? typeOnlyMatch;
+
+    if (loaded) {
+      usedTrayIds.add(loaded.globalTrayId);
+    }
+
+    if (exactMatch || similarMatch) {
+      exactMatches++;
+    } else if (typeOnlyMatch) {
+      typeOnlyMatches++;
+    } else {
+      missingTypes++;
+    }
+  }
+
+  const totalSlots = filamentReqs.length;
+  let status: PrinterMatchStatus = 'full';
+  if (missingTypes > 0) {
+    status = 'missing';
+  } else if (typeOnlyMatches > 0) {
+    status = 'partial';
+  }
+
+  return { exactMatches, typeOnlyMatches, missingTypes, totalSlots, status };
+}
+
+/**
+ * Compute AMS mapping with manual overrides applied.
+ */
+function computeMappingWithOverrides(
+  filamentReqs: { filaments: FilamentRequirement[] } | undefined,
+  printerStatus: PrinterStatus | undefined,
+  manualMappings: Record<number, number>
+): number[] | undefined {
+  if (!filamentReqs?.filaments || filamentReqs.filaments.length === 0) return undefined;
+
+  const loadedFilaments = buildLoadedFilaments(printerStatus);
+  if (loadedFilaments.length === 0) return undefined;
+
+  const usedTrayIds = new Set<number>(Object.values(manualMappings));
+  const comparisons: { slot_id: number; globalTrayId: number }[] = [];
+
+  for (const req of filamentReqs.filaments) {
+    const slotId = req.slot_id || 0;
+
+    // Check manual override first
+    if (slotId > 0 && manualMappings[slotId] !== undefined) {
+      comparisons.push({ slot_id: slotId, globalTrayId: manualMappings[slotId] });
+      continue;
+    }
+
+    // Auto-match
+    const exactMatch = loadedFilaments.find(
+      (f) =>
+        !usedTrayIds.has(f.globalTrayId) &&
+        f.type?.toUpperCase() === req.type?.toUpperCase() &&
+        normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color)
+    );
+    const similarMatch = exactMatch
+      ? undefined
+      : loadedFilaments.find(
+          (f) =>
+            !usedTrayIds.has(f.globalTrayId) &&
+            f.type?.toUpperCase() === req.type?.toUpperCase() &&
+            colorsAreSimilar(f.color, req.color)
+        );
+    const typeOnlyMatch =
+      exactMatch || similarMatch
+        ? undefined
+        : loadedFilaments.find(
+            (f) => !usedTrayIds.has(f.globalTrayId) && f.type?.toUpperCase() === req.type?.toUpperCase()
+          );
+    const loaded = exactMatch ?? similarMatch ?? typeOnlyMatch;
+
+    if (loaded) {
+      usedTrayIds.add(loaded.globalTrayId);
+    }
+
+    comparisons.push({ slot_id: slotId, globalTrayId: loaded?.globalTrayId ?? -1 });
+  }
+
+  const maxSlotId = Math.max(...comparisons.map((f) => f.slot_id || 0));
+  if (maxSlotId <= 0) return undefined;
+
+  const mapping = new Array(maxSlotId).fill(-1);
+  comparisons.forEach((f) => {
+    if (f.slot_id && f.slot_id > 0) {
+      mapping[f.slot_id - 1] = f.globalTrayId;
+    }
+  });
+
+  return mapping;
+}
+
+/**
+ * Default per-printer config (use default mapping).
+ */
+const DEFAULT_PRINTER_CONFIG: PerPrinterConfig = {
+  useDefault: true,
+  manualMappings: {},
+  autoConfigured: false,
+};
+
+/**
+ * Hook to manage filament mapping for multiple printers.
+ * Fetches printer status for all selected printers and computes per-printer mappings.
+ */
+export function useMultiPrinterFilamentMapping(
+  selectedPrinterIds: number[],
+  printers: Printer[] | undefined,
+  filamentReqs: { filaments: FilamentRequirement[] } | undefined,
+  defaultMappings: Record<number, number>,
+  perPrinterConfigs: Record<number, PerPrinterConfig>,
+  setPerPrinterConfigs: React.Dispatch<React.SetStateAction<Record<number, PerPrinterConfig>>>
+): UseMultiPrinterFilamentMappingResult {
+  // Fetch printer status for all selected printers in parallel
+  const statusQueries = useQueries({
+    queries: selectedPrinterIds.map((printerId) => ({
+      queryKey: ['printer-status', printerId],
+      queryFn: () => api.getPrinterStatus(printerId),
+      enabled: selectedPrinterIds.length > 0,
+      staleTime: 5000, // Consider data fresh for 5 seconds
+    })),
+  });
+
+  // Build results for each printer
+  const printerResults = useMemo((): PrinterMappingResult[] => {
+    return selectedPrinterIds.map((printerId, index) => {
+      const query = statusQueries[index];
+      const printerStatus = query?.data;
+      const printer = printers?.find((p) => p.id === printerId);
+      const printerName = printer?.name || `Printer ${printerId}`;
+
+      const loadedFilaments = buildLoadedFilaments(printerStatus);
+      const config = perPrinterConfigs[printerId] || DEFAULT_PRINTER_CONFIG;
+
+      // Compute auto mapping for this printer
+      const autoMapping = computeAmsMapping(filamentReqs, printerStatus);
+
+      // Determine which mappings to use:
+      // If printer has override (useDefault=false), use its custom mappings
+      // Otherwise use the default mappings
+      const effectiveMappings = !config.useDefault
+        ? config.manualMappings
+        : defaultMappings;
+
+      // Compute final mapping with overrides
+      const finalMapping = computeMappingWithOverrides(filamentReqs, printerStatus, effectiveMappings);
+
+      // Compute match details
+      const matchDetails = computeMatchDetails(
+        filamentReqs?.filaments,
+        loadedFilaments,
+        effectiveMappings
+      );
+
+      return {
+        printerId,
+        printerName,
+        status: printerStatus,
+        isLoading: query?.isLoading ?? false,
+        loadedFilaments,
+        autoMapping,
+        finalMapping,
+        matchStatus: matchDetails.status,
+        exactMatches: matchDetails.exactMatches,
+        typeOnlyMatches: matchDetails.typeOnlyMatches,
+        missingTypes: matchDetails.missingTypes,
+        totalSlots: matchDetails.totalSlots,
+        config,
+      };
+    });
+  }, [selectedPrinterIds, statusQueries, printers, filamentReqs, perPrinterConfigs, defaultMappings]);
+
+  const isLoading = statusQueries.some((q) => q.isLoading);
+
+  // Update config for a specific printer
+  const updatePrinterConfig = (printerId: number, updates: Partial<PerPrinterConfig>) => {
+    setPerPrinterConfigs((prev) => ({
+      ...prev,
+      [printerId]: {
+        ...(prev[printerId] || DEFAULT_PRINTER_CONFIG),
+        ...updates,
+      },
+    }));
+  };
+
+  // Auto-configure a specific printer based on its loaded filaments
+  const autoConfigurePrinter = (printerId: number) => {
+    const result = printerResults.find((r) => r.printerId === printerId);
+    if (!result || !result.status || !filamentReqs?.filaments) return;
+
+    // Compute optimal mapping for this printer
+    const autoMapping = computeAmsMapping(filamentReqs, result.status);
+    if (!autoMapping) return;
+
+    // Convert autoMapping array to manualMappings record
+    const manualMappings: Record<number, number> = {};
+    autoMapping.forEach((globalTrayId, index) => {
+      if (globalTrayId !== -1) {
+        manualMappings[index + 1] = globalTrayId;
+      }
+    });
+
+    updatePrinterConfig(printerId, {
+      useDefault: false,
+      manualMappings,
+      autoConfigured: true,
+    });
+  };
+
+  // Auto-configure all printers
+  const autoConfigureAll = () => {
+    for (const printerId of selectedPrinterIds) {
+      autoConfigurePrinter(printerId);
+    }
+  };
+
+  // Get final mapping for a specific printer (for submission)
+  const getFinalMapping = (printerId: number): number[] | undefined => {
+    const result = printerResults.find((r) => r.printerId === printerId);
+    return result?.finalMapping;
+  };
+
+  // Check if all printers have acceptable mappings (no missing types)
+  const allPrintersReady = printerResults.every((r) => r.matchStatus !== 'missing');
+
+  return {
+    printerResults,
+    isLoading,
+    perPrinterConfigs,
+    updatePrinterConfig,
+    autoConfigureAll,
+    autoConfigurePrinter,
+    getFinalMapping,
+    allPrintersReady,
+  };
+}

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

@@ -361,6 +361,7 @@ export function SettingsPage() {
       settings.ams_temp_good !== localSettings.ams_temp_good ||
       settings.ams_temp_fair !== localSettings.ams_temp_fair ||
       settings.ams_history_retention_days !== localSettings.ams_history_retention_days ||
+      settings.per_printer_mapping_expanded !== localSettings.per_printer_mapping_expanded ||
       settings.date_format !== localSettings.date_format ||
       settings.time_format !== localSettings.time_format ||
       settings.default_printer_id !== localSettings.default_printer_id ||
@@ -420,6 +421,7 @@ export function SettingsPage() {
         ams_temp_good: localSettings.ams_temp_good,
         ams_temp_fair: localSettings.ams_temp_fair,
         ams_history_retention_days: localSettings.ams_history_retention_days,
+        per_printer_mapping_expanded: localSettings.per_printer_mapping_expanded,
         date_format: localSettings.date_format,
         time_format: localSettings.time_format,
         default_printer_id: localSettings.default_printer_id,
@@ -2519,6 +2521,33 @@ export function SettingsPage() {
                     Older humidity and temperature data will be automatically deleted
                   </p>
                 </div>
+
+                {/* Per-Printer Mapping Default */}
+                <div className="space-y-3 pt-4 border-t border-bambu-dark-tertiary">
+                  <div className="flex items-center gap-2 text-white">
+                    <Printer className="w-4 h-4 text-bambu-green" />
+                    <span className="font-medium">Print Modal</span>
+                  </div>
+                  <div className="flex items-center justify-between">
+                    <div>
+                      <label className="block text-sm text-white">
+                        Expand custom mapping by default
+                      </label>
+                      <p className="text-xs text-bambu-gray mt-0.5">
+                        When printing to multiple printers, show per-printer AMS mapping expanded
+                      </p>
+                    </div>
+                    <label className="relative inline-flex items-center cursor-pointer">
+                      <input
+                        type="checkbox"
+                        checked={localSettings.per_printer_mapping_expanded ?? false}
+                        onChange={(e) => updateSetting('per_printer_mapping_expanded', 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>
+                </div>
               </CardContent>
             </Card>
           </div>

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


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

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