Browse Source

Add filament override for model-based queue assignment (#486)

When scheduling a print to "any printer" (model-based assignment),
users can now override the 3MF's original filament choices per slot.
The override dropdown shows compatible filaments loaded across all
printers of the selected model, filtered by type and nozzle (H2D).
The scheduler matches against overridden type/color instead of the
original 3MF values, preferring printers with exact color matches.

Backend:
- New GET /printers/available-filaments endpoint (aggregates loaded
  filaments across printers of a model, includes extruder_id for
  dual-nozzle nozzle-aware filtering)
- New filament_overrides JSON column on print_queue table
- Scheduler applies overrides before AMS mapping, clears tray_info_idx
  on overridden slots so color matching takes priority
- Printer selection prefers printers with most override color matches

Frontend:
- New FilamentOverride component with per-slot override dropdowns
- Type-filtered options (PLA slots only show PLA)
- Nozzle-aware filtering (H2D: only shows filaments on correct extruder)
- Integrated into PrintModal for model-based queue mode

Tests:
- 11 backend unit tests (color match counting, override application)
- 12 frontend tests (rendering, type/nozzle filtering, interactions)

i18n: All 6 locales (en, de, fr, it, ja, pt-BR)
maziggy 3 months ago
parent
commit
38af5df050

+ 2 - 0
CHANGELOG.md

@@ -10,6 +10,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **Finish Photo Not Captured When Archive Has No Source 3MF** ([#484](https://github.com/maziggy/bambuddy/issues/484)) — When a print completed but the 3MF source file wasn't downloaded from the printer (e.g. FTP download failure), the archive's `file_path` was null. The finish photo capture silently skipped because it derived the save directory from `file_path`. Now falls back to `archive/{id}/` so the photo is captured regardless.
 
 ### New Features
+- **Filament Override for Model-Based Queue** ([#486](https://github.com/maziggy/bambuddy/issues/486)) — When scheduling a print to "any printer" (model-based assignment), you can now override the 3MF's original filament choices. A new section in the print modal shows the filaments required by the sliced file and lets you swap each slot to any compatible filament loaded across printers of the selected model. The scheduler matches against the overridden type and color instead of the original 3MF values, preferring printers with exact color matches. On dual-nozzle printers (H2D), the override dropdown only shows filaments on the correct extruder for each slot. New `GET /printers/available-filaments` endpoint aggregates loaded filaments across all active printers of a given model. Backend stores overrides as a JSON column on the queue item and applies them at scheduling time by merging into filament requirements before AMS mapping. Translations added for all 6 locales (en, de, fr, it, ja, pt-BR).
 - **SpoolBuddy Integration** — SpoolBuddy is now an optional add-on module within Bambuddy for managing filament spools with NFC scanning and precision weight measurement. Hardware: Raspberry Pi 4B + 7" touchscreen (1024x600) + PN5180 NFC reader + NAU7802 scale. **RPi daemon** (`spoolbuddy/daemon/`): asyncio-based Python service with concurrent NFC polling (300ms, MIFARE Classic with Bambu HKDF-SHA256 key derivation), scale reading (10 SPS, 5-sample moving average, stability detection), and 10s heartbeat — posts events to the Bambuddy backend via REST API with exponential backoff reconnect and 100-event in-memory buffer on failure. **Backend**: new `SpoolBuddyDevice` model for device registration, 10 REST endpoints under `/spoolbuddy/*` (device register/heartbeat, NFC tag-scanned/tag-removed, scale reading/weight-update, calibration tare/set-factor/get), 6 new WebSocket broadcast message types (`spoolbuddy_weight`, `spoolbuddy_tag_matched`, `spoolbuddy_unknown_tag`, `spoolbuddy_tag_removed`, `spoolbuddy_online`, `spoolbuddy_offline`), and a background watchdog task that marks devices offline after 30s without heartbeat. **Frontend**: dedicated kiosk-optimized UI at `/spoolbuddy` routes designed for a fixed 1024x600 pixel display with 48px minimum touch targets — includes Dashboard (50/50 split with live weight display in 72px tabular-nums and state-dependent spool info/actions), AMS Overview (printer selector + slot grid), Inventory (touch-friendly 2-column card grid with search), Printers (status cards with live data), and Settings (scale calibration with live weight, NFC reader status, device info). `useSpoolBuddyState` hook manages a reducer-based state machine driven by WebSocket CustomEvents. Translations added for all 6 locales (en, de, fr, it, ja, pt-BR).
 
 ## [0.2.1b2] - 2026-02-21
@@ -40,6 +41,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **Developer LAN Mode Detection & Warning Banner** — Automatically detects whether connected printers have Developer LAN Mode enabled by parsing the MQTT `fun` field (bit `0x20000000`). When any connected printer lacks developer mode, a persistent orange warning banner appears at the top of the UI with the affected printer name(s) and a link to Bambu Lab's documentation on how to enable it. Without developer mode, MQTT write operations (start/stop/pause prints, AMS control, light/speed/gcode commands) are silently rejected by newer firmware. The `developer_mode` state is included in the support bundle for diagnostics. New `/printers/developer-mode-warnings` endpoint provides a lightweight polling summary. Translations added for all 6 locales (en, de, fr, it, ja, pt-BR).
 
 ### Improved
+- **Filament Override Test Coverage** — Added 11 backend unit tests: 6 for `_count_override_color_matches` (no status, exact match, no match, partial match, color normalization, external spool) and 5 for override application in filament matching (color override, tray_info_idx clearing, type change, partial override, nozzle filtering with override). Added 12 frontend tests for the `FilamentOverride` component: 5 rendering tests (null guards, slot display, dropdown count), 2 type filtering tests (same-type only, all colors), 3 nozzle filtering tests (extruder_id matching, single-nozzle passthrough, null extruder_id inclusion), and 2 interaction tests (select override, reset to original).
 - **P2S Dual-AMS tray_now Test Coverage** — Added 14 integration tests for multi-AMS tray_now disambiguation on single-nozzle printers (resolving AMS-B slots via mapping field, AMS-A passthrough, multi-color mapping, ambiguous/missing mapping fallbacks, last_loaded_tray tracking). Added 9 unit tests for `_resolve_local_slot_from_mapping` (snow decoding, unmapped entry filtering, ambiguity detection, AMS-HT slot matching). All 66 tray_now-related tests pass.
 - **Bulk Spool, Stock & Grouping Test Coverage** — Added 13 backend unit tests covering `SpoolBulkCreate` schema validation (quantity bounds, field preservation, stock vs configured distinction) and bulk endpoint logic (correct spool count, single quantity, identical fields). Added 29 frontend tests: 13 for `SpoolFormModal` covering `validateForm` with `quickAdd` flag (6 tests), quick-add toggle visibility, PA Profile tab hiding, quantity field gating (hidden by default, visible only in quick-add, hidden in edit mode), and brand/subtype optional asterisk removal in quick-add; 16 for inventory grouping logic covering `spoolGroupKey` identity/differentiation (7 tests) and `computeDisplayItems` grouping rules (9 tests for identical/different/used/assigned/single/order/mixed/empty scenarios).
 - **Filament Cost Tracking Test Coverage** — Added 2 backend unit tests for archive cost aggregation (zero-cost guard preserves existing costs, positive-cost updates archive correctly). Added 2 frontend unit tests for spool form cost_per_kg persistence. Fixed missing `archive_id` database migration, SQLAlchemy `is None` → `.is_(None)` in where clauses, duplicate archive cost write, and unconditional zero-cost overwrite.

+ 1 - 0
README.md

@@ -102,6 +102,7 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
 - Print queue with drag-and-drop
 - Multi-printer selection (send to multiple printers at once)
 - 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)
 - Per-printer AMS mapping (individual slot configuration for print farms)
 - Scheduled prints (date/time)

+ 29 - 0
backend/app/api/routes/print_queue.py

@@ -169,6 +169,14 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
         except json.JSONDecodeError:
             required_filament_types_parsed = None
 
+    # Parse filament_overrides from JSON string
+    filament_overrides_parsed = None
+    if item.filament_overrides:
+        try:
+            filament_overrides_parsed = json.loads(item.filament_overrides)
+        except json.JSONDecodeError:
+            filament_overrides_parsed = None
+
     # Create response with parsed ams_mapping
     item_dict = {
         "id": item.id,
@@ -176,6 +184,7 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
         "target_model": item.target_model,
         "target_location": item.target_location,
         "required_filament_types": required_filament_types_parsed,
+        "filament_overrides": filament_overrides_parsed,
         "waiting_reason": item.waiting_reason,
         "archive_id": item.archive_id,
         "library_file_id": item.library_file_id,
@@ -375,6 +384,19 @@ async def add_to_queue(
                 required_filament_types = json.dumps(filament_types)
                 logger.info("Extracted filament types for model-based queue: %s", filament_types)
 
+    # If filament overrides are provided, update required_filament_types to match override types
+    filament_overrides_json = None
+    if data.filament_overrides and target_model_norm:
+        filament_overrides_json = json.dumps(data.filament_overrides)
+        # Update required_filament_types from overrides so scheduler validates against overridden types
+        override_types = sorted({o["type"] for o in data.filament_overrides if "type" in o})
+        if override_types:
+            # Merge with existing types (overrides may only cover some slots)
+            existing_types = set(json.loads(required_filament_types)) if required_filament_types else set()
+            # Replace types for overridden slots, keep others
+            all_types = existing_types | set(override_types)
+            required_filament_types = json.dumps(sorted(all_types))
+
     # Get next position for this printer (or for unassigned/model-based items)
     if data.printer_id is not None:
         result = await db.execute(
@@ -396,6 +418,7 @@ async def add_to_queue(
         target_model=target_model_norm,
         target_location=data.target_location,
         required_filament_types=required_filament_types,
+        filament_overrides=filament_overrides_json,
         archive_id=data.archive_id,
         library_file_id=data.library_file_id,
         scheduled_time=data.scheduled_time,
@@ -613,6 +636,12 @@ async def update_queue_item(
     if "ams_mapping" in update_data:
         update_data["ams_mapping"] = json.dumps(update_data["ams_mapping"]) if update_data["ams_mapping"] else None
 
+    # Serialize filament_overrides to JSON for TEXT column storage
+    if "filament_overrides" in update_data:
+        update_data["filament_overrides"] = (
+            json.dumps(update_data["filament_overrides"]) if update_data["filament_overrides"] else None
+        )
+
     for field, value in update_data.items():
         setattr(item, field, value)
 

+ 95 - 1
backend/app/api/routes/printers.py

@@ -5,7 +5,7 @@ import zipfile
 
 from fastapi import APIRouter, Depends, HTTPException, Query
 from fastapi.responses import Response
-from sqlalchemy import select
+from sqlalchemy import func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 
 from backend.app.core.auth import RequirePermissionIfAuthEnabled
@@ -91,6 +91,100 @@ async def list_usb_cameras(
     return {"cameras": cameras}
 
 
+@router.get("/available-filaments")
+async def get_available_filaments(
+    model: str = Query(..., description="Target printer model"),
+    location: str | None = Query(None, description="Optional location filter"),
+    _=RequirePermissionIfAuthEnabled(Permission.QUEUE_CREATE),
+    db: AsyncSession = Depends(get_db),
+):
+    """Get deduplicated list of filaments loaded across all active printers of a given model.
+
+    Used by the frontend to offer filament override options for model-based queue assignment.
+    """
+    from backend.app.utils.printer_models import normalize_printer_model, normalize_printer_model_id
+
+    # Normalize model name
+    normalized_model = normalize_printer_model(model) or normalize_printer_model_id(model) or model
+
+    query = (
+        select(Printer).where(func.lower(Printer.model) == normalized_model.lower()).where(Printer.is_active == True)  # noqa: E712
+    )
+    if location:
+        query = query.where(Printer.location == location)
+
+    result = await db.execute(query)
+    printers_list = list(result.scalars().all())
+
+    if not printers_list:
+        return []
+
+    # Collect filaments from all matching printers
+    # Dedup key includes extruder_id so same color on different nozzles appears separately
+    seen: set[tuple[str, str, int | None]] = set()  # (type_upper, color_normalized, extruder_id)
+    filaments = []
+
+    for printer in printers_list:
+        status = printer_manager.get_status(printer.id)
+        if not status:
+            continue
+
+        # Get ams_extruder_map for dual-nozzle printers
+        ams_extruder_map = status.raw_data.get("ams_extruder_map", {})
+
+        # AMS trays
+        for ams_unit in status.raw_data.get("ams", []):
+            ams_id = str(ams_unit.get("id", 0))
+            extruder_id = ams_extruder_map.get(ams_id)
+            for tray in ams_unit.get("tray", []):
+                tray_type = tray.get("tray_type")
+                if not tray_type:
+                    continue
+                tray_color = tray.get("tray_color", "")
+                # Normalize color: remove alpha, add hash
+                hex_color = tray_color.replace("#", "")[:6] if tray_color else "808080"
+                color = f"#{hex_color}"
+                tray_info_idx = tray.get("tray_info_idx", "")
+
+                key = (tray_type.upper(), hex_color.lower(), extruder_id)
+                if key not in seen:
+                    seen.add(key)
+                    filaments.append(
+                        {
+                            "type": tray_type,
+                            "color": color,
+                            "tray_info_idx": tray_info_idx,
+                            "extruder_id": extruder_id,
+                        }
+                    )
+
+        # External spools (vt_tray)
+        for vt in status.raw_data.get("vt_tray") or []:
+            vt_type = vt.get("tray_type")
+            if not vt_type:
+                continue
+            vt_color = vt.get("tray_color", "")
+            hex_color = vt_color.replace("#", "")[:6] if vt_color else "808080"
+            color = f"#{hex_color}"
+            tray_info_idx = vt.get("tray_info_idx", "")
+            vt_id = int(vt.get("id", 254))
+            extruder_id = (255 - vt_id) if ams_extruder_map else None
+
+            key = (vt_type.upper(), hex_color.lower(), extruder_id)
+            if key not in seen:
+                seen.add(key)
+                filaments.append(
+                    {
+                        "type": vt_type,
+                        "color": color,
+                        "tray_info_idx": tray_info_idx,
+                        "extruder_id": extruder_id,
+                    }
+                )
+
+    return filaments
+
+
 @router.get("/developer-mode-warnings")
 async def get_developer_mode_warnings(
     _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),

+ 6 - 0
backend/app/core/database.py

@@ -1306,6 +1306,12 @@ async def run_migrations(conn):
     except OperationalError:
         pass  # Table may not exist yet on first run
 
+    # Migration: Add filament_overrides column to print_queue for filament override in model-based assignment
+    try:
+        await conn.execute(text("ALTER TABLE print_queue ADD COLUMN filament_overrides TEXT"))
+    except OperationalError:
+        pass  # Already applied
+
 
 async def seed_notification_templates():
     """Seed default notification templates if they don't exist."""

+ 5 - 0
backend/app/models/print_queue.py

@@ -49,6 +49,11 @@ class PrintQueueItem(Base):
     # Format: "[5, -1, 2, -1]" where position = slot_id-1, value = global tray ID (-1 = unused)
     ams_mapping: Mapped[str | None] = mapped_column(Text, nullable=True)
 
+    # Filament overrides for model-based assignment: JSON array of override objects
+    # Format: '[{"slot_id": 1, "type": "PLA", "color": "#FFFFFF"}]'
+    # Only slots with overrides are included (sparse). null = use original 3MF values.
+    filament_overrides: Mapped[str | None] = mapped_column(Text, nullable=True)
+
     # Plate ID for multi-plate 3MF files (1-indexed, None = auto-detect/plate 1)
     plate_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
 

+ 3 - 0
backend/app/schemas/print_queue.py

@@ -20,6 +20,7 @@ class PrintQueueItemCreate(BaseModel):
     target_model: str | None = None  # Target printer model (mutually exclusive with printer_id)
     target_location: str | None = None  # Target location filter (only used with target_model)
     required_filament_types: list[str] | None = None  # Required filament types for model-based assignment
+    filament_overrides: list[dict] | None = None  # Filament overrides for model-based assignment
     # Either archive_id OR library_file_id must be provided
     archive_id: int | None = None
     library_file_id: int | None = None
@@ -45,6 +46,7 @@ class PrintQueueItemUpdate(BaseModel):
     printer_id: int | None = None
     target_model: str | None = None  # Target printer model (mutually exclusive with printer_id)
     target_location: str | None = None  # Target location filter (only used with target_model)
+    filament_overrides: list[dict] | None = None  # Filament overrides for model-based assignment
     position: int | None = None
     scheduled_time: datetime | None = None
     require_previous_success: bool | None = None
@@ -67,6 +69,7 @@ class PrintQueueItemResponse(BaseModel):
     target_model: str | None = None  # Target printer model for model-based assignment
     target_location: str | None = None  # Target location filter for model-based assignment
     required_filament_types: list[str] | None = None  # Required filament types for model-based assignment
+    filament_overrides: list[dict] | None = None  # Filament overrides for model-based assignment
     waiting_reason: str | None = None  # Why a model-based job hasn't started yet
     archive_id: int | None  # None if library_file_id is set (archive created at print start)
     library_file_id: int | None  # For queue items from library files

+ 91 - 3
backend/app/services/print_scheduler.py

@@ -154,8 +154,29 @@ class PrintScheduler:
                         except json.JSONDecodeError:
                             pass  # Ignore malformed filament types; treat as no constraint
 
+                    # Parse filament overrides if present
+                    filament_overrides = None
+                    if item.filament_overrides:
+                        try:
+                            filament_overrides = json.loads(item.filament_overrides)
+                        except json.JSONDecodeError:
+                            pass
+
+                    # If overrides exist, use override types for validation instead
+                    effective_types = required_types
+                    if filament_overrides:
+                        override_types = sorted({o["type"] for o in filament_overrides if "type" in o})
+                        if override_types:
+                            # Merge: keep original types for non-overridden slots, add override types
+                            effective_types = sorted(set(required_types or []) | set(override_types))
+
                     printer_id, waiting_reason = await self._find_idle_printer_for_model(
-                        db, item.target_model, busy_printers, required_types, item.target_location
+                        db,
+                        item.target_model,
+                        busy_printers,
+                        effective_types,
+                        item.target_location,
+                        filament_overrides=filament_overrides,
                     )
 
                     # Update waiting_reason if changed and send notification when first waiting
@@ -233,6 +254,7 @@ class PrintScheduler:
         exclude_ids: set[int],
         required_filament_types: list[str] | None = None,
         target_location: str | None = None,
+        filament_overrides: list[dict] | None = None,
     ) -> tuple[int | None, str | None]:
         """Find an idle, connected printer matching the model with compatible filaments.
 
@@ -272,6 +294,7 @@ class PrintScheduler:
         printers_busy = []
         printers_offline = []
         printers_missing_filament = []
+        candidates: list[tuple[int, int]] = []  # (printer_id, color_match_count)
 
         for printer in printers:
             if printer.id in exclude_ids:
@@ -297,8 +320,18 @@ class PrintScheduler:
                     logger.debug("Skipping printer %s (%s) - missing filaments: %s", printer.id, printer.name, missing)
                     continue
 
-            # Found a matching printer - clear waiting reason
-            return printer.id, None
+            # If filament overrides with colors, prefer printers with exact color matches
+            if filament_overrides:
+                color_matches = self._count_override_color_matches(printer.id, filament_overrides)
+                candidates.append((printer.id, color_matches))
+            else:
+                # No overrides - take first available (existing behavior)
+                return printer.id, None
+
+        # If we have candidates from 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
 
         # Build waiting reason from what we found
         reasons = []
@@ -353,6 +386,38 @@ class PrintScheduler:
 
         return missing
 
+    def _count_override_color_matches(self, printer_id: int, overrides: list[dict]) -> int:
+        """Count how many filament overrides have an exact color match on the printer.
+
+        Used to prefer printers that already have the desired override colors loaded.
+        """
+        status = printer_manager.get_status(printer_id)
+        if not status:
+            return 0
+
+        # Collect loaded filaments' type+color pairs
+        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))
+
+        matches = 0
+        for o in overrides:
+            o_type = (o.get("type") or "").upper()
+            o_color = (o.get("color") or "").replace("#", "").lower()[:6]
+            if (o_type, o_color) in loaded:
+                matches += 1
+        return matches
+
     async def _compute_ams_mapping_for_printer(
         self, db: AsyncSession, printer_id: int, item: PrintQueueItem
     ) -> list[int] | None:
@@ -381,6 +446,29 @@ class PrintScheduler:
             logger.debug("No filament requirements found for queue item %s", item.id)
             return None
 
+        # Apply filament overrides if present
+        if item.filament_overrides:
+            try:
+                overrides = json.loads(item.filament_overrides)
+                override_map = {o["slot_id"]: o for o in overrides}
+                for req in filament_reqs:
+                    if req["slot_id"] in override_map:
+                        override = override_map[req["slot_id"]]
+                        req["type"] = override["type"]
+                        req["color"] = override["color"]
+                        # Clear tray_info_idx so matching uses type+color instead of
+                        # the original 3MF's tray_info_idx (which would match the old filament)
+                        req["tray_info_idx"] = ""
+                        logger.debug(
+                            "Queue item %s: Override slot %d -> %s %s",
+                            item.id,
+                            req["slot_id"],
+                            override["type"],
+                            override["color"],
+                        )
+            except (json.JSONDecodeError, KeyError, TypeError) as e:
+                logger.warning("Failed to apply filament overrides for queue item %s: %s", item.id, e)
+
         # Build loaded filaments from printer status
         loaded_filaments = self._build_loaded_filaments(status)
         if not loaded_filaments:

+ 215 - 0
backend/tests/unit/test_scheduler_filament_override.py

@@ -0,0 +1,215 @@
+"""Tests for the filament override feature in the print scheduler."""
+
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+from backend.app.services.print_scheduler import PrintScheduler
+
+
+class TestCountOverrideColorMatches:
+    """Test the _count_override_color_matches method."""
+
+    @pytest.fixture
+    def scheduler(self):
+        return PrintScheduler()
+
+    @patch("backend.app.services.print_scheduler.printer_manager")
+    def test_no_status_returns_zero(self, mock_pm, scheduler):
+        """When printer_manager.get_status() returns None, should return 0."""
+        mock_pm.get_status.return_value = None
+
+        result = scheduler._count_override_color_matches(1, [{"type": "PLA", "color": "#FF0000"}])
+        assert result == 0
+
+    @patch("backend.app.services.print_scheduler.printer_manager")
+    def test_exact_match(self, mock_pm, scheduler):
+        """Override with matching type+color on printer returns 1."""
+        mock_pm.get_status.return_value = MagicMock(
+            raw_data={
+                "ams": [{"id": 0, "tray": [{"id": 0, "tray_type": "PLA", "tray_color": "FF0000FF"}]}],
+            }
+        )
+
+        result = scheduler._count_override_color_matches(1, [{"type": "PLA", "color": "#FF0000"}])
+        assert result == 1
+
+    @patch("backend.app.services.print_scheduler.printer_manager")
+    def test_no_match(self, mock_pm, scheduler):
+        """Override with type+color not on printer returns 0."""
+        mock_pm.get_status.return_value = MagicMock(
+            raw_data={
+                "ams": [{"id": 0, "tray": [{"id": 0, "tray_type": "PLA", "tray_color": "FF0000FF"}]}],
+            }
+        )
+
+        result = scheduler._count_override_color_matches(1, [{"type": "PETG", "color": "#00FF00"}])
+        assert result == 0
+
+    @patch("backend.app.services.print_scheduler.printer_manager")
+    def test_multiple_overrides_partial_match(self, mock_pm, scheduler):
+        """2 overrides, only 1 matching = returns 1."""
+        mock_pm.get_status.return_value = MagicMock(
+            raw_data={
+                "ams": [{"id": 0, "tray": [{"id": 0, "tray_type": "PLA", "tray_color": "FF0000FF"}]}],
+            }
+        )
+
+        overrides = [
+            {"type": "PLA", "color": "#FF0000"},  # Matches
+            {"type": "PETG", "color": "#00FF00"},  # Does not match
+        ]
+        result = scheduler._count_override_color_matches(1, overrides)
+        assert result == 1
+
+    @patch("backend.app.services.print_scheduler.printer_manager")
+    def test_color_normalization(self, mock_pm, scheduler):
+        """Override color '#FF0000' matches printer tray_color 'FF0000FF' (with alpha)."""
+        mock_pm.get_status.return_value = MagicMock(
+            raw_data={
+                "ams": [{"id": 0, "tray": [{"id": 0, "tray_type": "PLA", "tray_color": "FF0000FF"}]}],
+            }
+        )
+
+        # Override uses #-prefixed color; printer uses 8-char RGBA without hash
+        result = scheduler._count_override_color_matches(1, [{"type": "PLA", "color": "#FF0000"}])
+        assert result == 1
+
+    @patch("backend.app.services.print_scheduler.printer_manager")
+    def test_external_spool_match(self, mock_pm, scheduler):
+        """Override matches filament in vt_tray."""
+        mock_pm.get_status.return_value = MagicMock(
+            raw_data={
+                "ams": [],
+                "vt_tray": [{"tray_type": "TPU", "tray_color": "0000FFFF"}],
+            }
+        )
+
+        result = scheduler._count_override_color_matches(1, [{"type": "TPU", "color": "#0000FF"}])
+        assert result == 1
+
+
+class TestFilamentOverrideInMatching:
+    """Test that when overrides are applied to filament requirements, the matching uses overridden values."""
+
+    @pytest.fixture
+    def scheduler(self):
+        return PrintScheduler()
+
+    def _apply_overrides(self, filament_reqs, overrides):
+        """Simulate override application as done in _compute_ams_mapping_for_printer."""
+        override_map = {o["slot_id"]: o for o in overrides}
+        for req in filament_reqs:
+            if req["slot_id"] in override_map:
+                override = override_map[req["slot_id"]]
+                req["type"] = override["type"]
+                req["color"] = override["color"]
+                req["tray_info_idx"] = ""  # Clear for override
+        return filament_reqs
+
+    def test_override_changes_color_match(self, scheduler):
+        """Original req has color A, loaded has color B. Override to color B gives exact match."""
+        filament_reqs = [{"slot_id": 1, "type": "PLA", "color": "#000000", "tray_info_idx": ""}]
+        loaded = [
+            {"type": "PLA", "color": "#FF0000", "global_tray_id": 0},
+        ]
+
+        # Without override: type-only match (colors differ)
+        result_without = scheduler._match_filaments_to_slots(filament_reqs, loaded)
+        assert result_without == [0]  # Matches by type only
+
+        # Now apply override changing color to match loaded
+        overrides = [{"slot_id": 1, "type": "PLA", "color": "#FF0000"}]
+        filament_reqs_overridden = [{"slot_id": 1, "type": "PLA", "color": "#000000", "tray_info_idx": ""}]
+        self._apply_overrides(filament_reqs_overridden, overrides)
+
+        result_with = scheduler._match_filaments_to_slots(filament_reqs_overridden, loaded)
+        assert result_with == [0]  # Exact color match now
+        # Verify the override actually changed the color in the requirement
+        assert filament_reqs_overridden[0]["color"] == "#FF0000"
+
+    def test_override_clears_tray_info_idx(self, scheduler):
+        """When tray_info_idx is cleared, matching falls to color-based instead of tray_info_idx-based."""
+        loaded = [
+            {"type": "PLA", "color": "#FF0000", "global_tray_id": 0, "tray_info_idx": "GFA00"},
+            {"type": "PLA", "color": "#00FF00", "global_tray_id": 1, "tray_info_idx": "GFB00"},
+        ]
+
+        # Without override: tray_info_idx "GFA00" matches tray 0 (red)
+        filament_reqs_original = [{"slot_id": 1, "type": "PLA", "color": "#FF0000", "tray_info_idx": "GFA00"}]
+        result_original = scheduler._match_filaments_to_slots(filament_reqs_original, loaded)
+        assert result_original == [0]  # Matched by tray_info_idx
+
+        # With override: tray_info_idx is cleared, color changed to green -> matches tray 1
+        filament_reqs_overridden = [{"slot_id": 1, "type": "PLA", "color": "#FF0000", "tray_info_idx": "GFA00"}]
+        overrides = [{"slot_id": 1, "type": "PLA", "color": "#00FF00"}]
+        self._apply_overrides(filament_reqs_overridden, overrides)
+
+        assert filament_reqs_overridden[0]["tray_info_idx"] == ""  # Cleared
+        result_overridden = scheduler._match_filaments_to_slots(filament_reqs_overridden, loaded)
+        assert result_overridden == [1]  # Now matches tray 1 by color
+
+    def test_override_type_change(self, scheduler):
+        """Override changes type from PLA to PETG, loaded has PETG -> matches."""
+        loaded = [
+            {"type": "PETG", "color": "#FF0000", "global_tray_id": 0},
+        ]
+
+        # Without override: PLA requirement, PETG loaded -> no match
+        filament_reqs_original = [{"slot_id": 1, "type": "PLA", "color": "#FF0000", "tray_info_idx": ""}]
+        result_original = scheduler._match_filaments_to_slots(filament_reqs_original, loaded)
+        assert result_original == [-1]  # Type mismatch
+
+        # With override: type changed to PETG -> matches
+        filament_reqs_overridden = [{"slot_id": 1, "type": "PLA", "color": "#FF0000", "tray_info_idx": ""}]
+        overrides = [{"slot_id": 1, "type": "PETG", "color": "#FF0000"}]
+        self._apply_overrides(filament_reqs_overridden, overrides)
+
+        result_overridden = scheduler._match_filaments_to_slots(filament_reqs_overridden, loaded)
+        assert result_overridden == [0]  # Exact match now
+
+    def test_partial_override(self, scheduler):
+        """2 slots, only slot 1 overridden. Slot 1 uses override, slot 2 uses original."""
+        loaded = [
+            {"type": "PLA", "color": "#FF0000", "global_tray_id": 0},
+            {"type": "PETG", "color": "#00FF00", "global_tray_id": 1},
+        ]
+
+        filament_reqs = [
+            {"slot_id": 1, "type": "PLA", "color": "#000000", "tray_info_idx": "GFA00"},
+            {"slot_id": 2, "type": "PETG", "color": "#00FF00", "tray_info_idx": "GFG02"},
+        ]
+
+        # Override only slot 1: change color to red
+        overrides = [{"slot_id": 1, "type": "PLA", "color": "#FF0000"}]
+        self._apply_overrides(filament_reqs, overrides)
+
+        # Slot 1: overridden to PLA/#FF0000, tray_info_idx cleared -> matches tray 0 by exact color
+        assert filament_reqs[0]["color"] == "#FF0000"
+        assert filament_reqs[0]["tray_info_idx"] == ""
+
+        # Slot 2: NOT overridden, retains original tray_info_idx
+        assert filament_reqs[1]["color"] == "#00FF00"
+        assert filament_reqs[1]["tray_info_idx"] == "GFG02"
+
+        result = scheduler._match_filaments_to_slots(filament_reqs, loaded)
+        assert result == [0, 1]  # Slot 1 -> tray 0 (red PLA), slot 2 -> tray 1 (green PETG)
+
+    def test_nozzle_filtering_with_override(self, scheduler):
+        """Override to a type only available on the wrong nozzle returns -1."""
+        loaded = [
+            # PETG on RIGHT nozzle (extruder 0) only
+            {"type": "PETG", "color": "#FF0000", "global_tray_id": 0, "extruder_id": 0},
+            # PLA on LEFT nozzle (extruder 1) only
+            {"type": "PLA", "color": "#00FF00", "global_tray_id": 4, "extruder_id": 1},
+        ]
+
+        # Override to PETG on LEFT nozzle — but PETG is only on RIGHT
+        filament_reqs = [{"slot_id": 1, "type": "PLA", "color": "#000000", "tray_info_idx": "GFA00", "nozzle_id": 1}]
+        overrides = [{"slot_id": 1, "type": "PETG", "color": "#FF0000"}]
+        self._apply_overrides(filament_reqs, overrides)
+
+        result = scheduler._match_filaments_to_slots(filament_reqs, loaded)
+        # Nozzle filter limits to extruder 1 (LEFT) which only has PLA.
+        # Override changed type to PETG, so no type match on LEFT nozzle -> -1
+        assert result == [-1]

+ 293 - 0
frontend/src/__tests__/components/FilamentOverride.test.tsx

@@ -0,0 +1,293 @@
+/**
+ * Tests for the FilamentOverride component.
+ *
+ * FilamentOverride allows users to override the 3MF's original filament
+ * choices with filaments available across printers of the selected model.
+ */
+
+import { describe, it, expect, vi, afterEach } from 'vitest';
+import { screen, fireEvent, cleanup } from '@testing-library/react';
+import { render } from '../utils';
+import { FilamentOverride } from '../../components/PrintModal/FilamentOverride';
+import type { FilamentReqsData } from '../../components/PrintModal/types';
+
+const defaultFilamentReqs: FilamentReqsData = {
+  filaments: [
+    { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 25, used_meters: 8.5 },
+  ],
+};
+
+const defaultAvailable = [
+  { type: 'PLA', color: '#FF0000', tray_info_idx: 'GFA00', extruder_id: null },
+  { type: 'PLA', color: '#00FF00', tray_info_idx: 'GFA01', extruder_id: null },
+  { type: 'PETG', color: '#0000FF', tray_info_idx: 'GFG00', extruder_id: null },
+];
+
+const mockOnChange = vi.fn();
+
+afterEach(() => {
+  cleanup();
+  vi.clearAllMocks();
+});
+
+describe('FilamentOverride', () => {
+  describe('rendering', () => {
+    it('returns null when filamentReqs is undefined', () => {
+      render(
+        <FilamentOverride
+          filamentReqs={undefined}
+          availableFilaments={defaultAvailable}
+          overrides={{}}
+          onChange={mockOnChange}
+        />
+      );
+
+      expect(screen.queryByText('Filament Override')).not.toBeInTheDocument();
+    });
+
+    it('returns null when filaments array is empty', () => {
+      render(
+        <FilamentOverride
+          filamentReqs={{ filaments: [] }}
+          availableFilaments={defaultAvailable}
+          overrides={{}}
+          onChange={mockOnChange}
+        />
+      );
+
+      expect(screen.queryByText('Filament Override')).not.toBeInTheDocument();
+    });
+
+    it('returns null when availableFilaments is empty', () => {
+      render(
+        <FilamentOverride
+          filamentReqs={defaultFilamentReqs}
+          availableFilaments={[]}
+          overrides={{}}
+          onChange={mockOnChange}
+        />
+      );
+
+      expect(screen.queryByText('Filament Override')).not.toBeInTheDocument();
+    });
+
+    it('renders filament slot with type and grams', () => {
+      render(
+        <FilamentOverride
+          filamentReqs={defaultFilamentReqs}
+          availableFilaments={defaultAvailable}
+          overrides={{}}
+          onChange={mockOnChange}
+        />
+      );
+
+      // The grams text "(25g)" is in a nested span within the type label
+      expect(screen.getByText('(25g)')).toBeInTheDocument();
+      // "Filament Override" heading confirms the section renders
+      expect(screen.getByText('Filament Override')).toBeInTheDocument();
+    });
+
+    it('renders override dropdown for each slot', () => {
+      const twoSlotReqs: FilamentReqsData = {
+        filaments: [
+          { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 25, used_meters: 8.5 },
+          { slot_id: 2, type: 'PLA', color: '#00FF00', used_grams: 10, used_meters: 3.2 },
+        ],
+      };
+
+      render(
+        <FilamentOverride
+          filamentReqs={twoSlotReqs}
+          availableFilaments={defaultAvailable}
+          overrides={{}}
+          onChange={mockOnChange}
+        />
+      );
+
+      const selects = screen.getAllByRole('combobox');
+      expect(selects).toHaveLength(2);
+    });
+  });
+
+  describe('type filtering', () => {
+    it('only shows same-type filaments in dropdown', () => {
+      render(
+        <FilamentOverride
+          filamentReqs={defaultFilamentReqs}
+          availableFilaments={defaultAvailable}
+          overrides={{}}
+          onChange={mockOnChange}
+        />
+      );
+
+      const select = screen.getByRole('combobox');
+      const options = select.querySelectorAll('option');
+
+      // 1 default "Original" option + 2 PLA options (not PETG)
+      expect(options).toHaveLength(3);
+
+      // Verify no PETG option values exist
+      const optionValues = Array.from(options).map((o) => o.getAttribute('value'));
+      expect(optionValues).not.toContain('PETG|#0000FF');
+    });
+
+    it('shows all same-type options regardless of color', () => {
+      const threeColorAvailable = [
+        { type: 'PLA', color: '#FF0000', tray_info_idx: 'GFA00', extruder_id: null },
+        { type: 'PLA', color: '#00FF00', tray_info_idx: 'GFA01', extruder_id: null },
+        { type: 'PLA', color: '#FFFFFF', tray_info_idx: 'GFA02', extruder_id: null },
+      ];
+
+      render(
+        <FilamentOverride
+          filamentReqs={defaultFilamentReqs}
+          availableFilaments={threeColorAvailable}
+          overrides={{}}
+          onChange={mockOnChange}
+        />
+      );
+
+      const select = screen.getByRole('combobox');
+      const options = select.querySelectorAll('option');
+
+      // 1 default "Original" option + 3 PLA color options
+      expect(options).toHaveLength(4);
+    });
+  });
+
+  describe('nozzle filtering', () => {
+    it('filters by extruder_id when nozzle_id is set', () => {
+      const nozzleReqs: FilamentReqsData = {
+        filaments: [
+          { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 25, used_meters: 8.5, nozzle_id: 0 },
+        ],
+      };
+
+      const dualExtruderAvailable = [
+        { type: 'PLA', color: '#FF0000', tray_info_idx: 'GFA00', extruder_id: 0 },
+        { type: 'PLA', color: '#00FF00', tray_info_idx: 'GFA01', extruder_id: 1 },
+      ];
+
+      render(
+        <FilamentOverride
+          filamentReqs={nozzleReqs}
+          availableFilaments={dualExtruderAvailable}
+          overrides={{}}
+          onChange={mockOnChange}
+        />
+      );
+
+      const select = screen.getByRole('combobox');
+      const options = select.querySelectorAll('option');
+
+      // 1 default + 1 PLA with extruder_id=0 (extruder_id=1 is filtered out)
+      expect(options).toHaveLength(2);
+
+      const optionValues = Array.from(options).map((o) => o.getAttribute('value'));
+      expect(optionValues).toContain('PLA|#FF0000');
+      expect(optionValues).not.toContain('PLA|#00FF00');
+    });
+
+    it('shows all filaments when nozzle_id is undefined', () => {
+      const noNozzleReqs: FilamentReqsData = {
+        filaments: [
+          { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 25, used_meters: 8.5 },
+        ],
+      };
+
+      const mixedExtruderAvailable = [
+        { type: 'PLA', color: '#FF0000', tray_info_idx: 'GFA00', extruder_id: 0 },
+        { type: 'PLA', color: '#00FF00', tray_info_idx: 'GFA01', extruder_id: 1 },
+      ];
+
+      render(
+        <FilamentOverride
+          filamentReqs={noNozzleReqs}
+          availableFilaments={mixedExtruderAvailable}
+          overrides={{}}
+          onChange={mockOnChange}
+        />
+      );
+
+      const select = screen.getByRole('combobox');
+      const options = select.querySelectorAll('option');
+
+      // 1 default + 2 PLA options (no nozzle filtering)
+      expect(options).toHaveLength(3);
+    });
+
+    it('includes filaments with null extruder_id', () => {
+      const nozzleReqs: FilamentReqsData = {
+        filaments: [
+          { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 25, used_meters: 8.5, nozzle_id: 0 },
+        ],
+      };
+
+      const mixedAvailable = [
+        { type: 'PLA', color: '#FF0000', tray_info_idx: 'GFA00', extruder_id: 0 },
+        { type: 'PLA', color: '#00FF00', tray_info_idx: 'GFA01', extruder_id: null },
+        { type: 'PLA', color: '#FFFFFF', tray_info_idx: 'GFA02', extruder_id: 1 },
+      ];
+
+      render(
+        <FilamentOverride
+          filamentReqs={nozzleReqs}
+          availableFilaments={mixedAvailable}
+          overrides={{}}
+          onChange={mockOnChange}
+        />
+      );
+
+      const select = screen.getByRole('combobox');
+      const options = select.querySelectorAll('option');
+
+      // 1 default + extruder_id=0 + extruder_id=null (extruder_id=1 filtered out)
+      expect(options).toHaveLength(3);
+
+      const optionValues = Array.from(options).map((o) => o.getAttribute('value'));
+      expect(optionValues).toContain('PLA|#FF0000');
+      expect(optionValues).toContain('PLA|#00FF00');
+      expect(optionValues).not.toContain('PLA|#FFFFFF');
+    });
+  });
+
+  describe('interactions', () => {
+    it('calls onChange when selecting an override', () => {
+      render(
+        <FilamentOverride
+          filamentReqs={defaultFilamentReqs}
+          availableFilaments={defaultAvailable}
+          overrides={{}}
+          onChange={mockOnChange}
+        />
+      );
+
+      const select = screen.getByRole('combobox');
+      fireEvent.change(select, { target: { value: 'PLA|#00FF00' } });
+
+      expect(mockOnChange).toHaveBeenCalledWith({
+        1: { type: 'PLA', color: '#00FF00' },
+      });
+    });
+
+    it('calls onChange to remove override when selecting original', () => {
+      const activeOverrides = {
+        1: { type: 'PLA', color: '#00FF00' },
+      };
+
+      render(
+        <FilamentOverride
+          filamentReqs={defaultFilamentReqs}
+          availableFilaments={defaultAvailable}
+          overrides={activeOverrides}
+          onChange={mockOnChange}
+        />
+      );
+
+      const select = screen.getByRole('combobox');
+      fireEvent.change(select, { target: { value: '' } });
+
+      expect(mockOnChange).toHaveBeenCalledWith({});
+    });
+  });
+});

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

@@ -1222,6 +1222,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
   plate_id: number | null;  // Plate ID for multi-plate 3MF files
   // Print options
   bed_levelling: boolean;
@@ -1251,6 +1252,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
   archive_id?: number | null;
   library_file_id?: number | null;
@@ -1273,6 +1275,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;
   position?: number;
   scheduled_time?: string | null;
   require_previous_success?: boolean;
@@ -2296,6 +2299,11 @@ export const api = {
     ),
   getDeveloperModeWarnings: () =>
     request<{ printer_id: number; name: string }[]>('/printers/developer-mode-warnings'),
+  getAvailableFilaments: (model: string, location?: string) => {
+    const params = new URLSearchParams({ model });
+    if (location) params.set('location', location);
+    return request<Array<{ type: string; color: string; tray_info_idx: string; extruder_id: number | null }>>(`/printers/available-filaments?${params}`);
+  },
   getPrinterStatus: (id: number) =>
     request<PrinterStatus>(`/printers/${id}/status`),
   refreshPrinterStatus: (id: number) =>

+ 134 - 0
frontend/src/components/PrintModal/FilamentOverride.tsx

@@ -0,0 +1,134 @@
+import { useMemo } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Circle, RotateCcw } from 'lucide-react';
+import { getColorName } from '../../utils/colors';
+import type { FilamentReqsData } from './types';
+
+interface FilamentOverrideProps {
+  filamentReqs: FilamentReqsData | undefined;
+  availableFilaments: Array<{ type: string; color: string; tray_info_idx: string; extruder_id: number | null }>;
+  overrides: Record<number, { type: string; color: string }>;
+  onChange: (overrides: Record<number, { type: string; color: string }>) => void;
+}
+
+/**
+ * Filament override UI for model-based queue assignment.
+ * Allows users to override the 3MF's original filament choices with
+ * filaments available across printers of the selected model.
+ */
+export function FilamentOverride({
+  filamentReqs,
+  availableFilaments,
+  overrides,
+  onChange,
+}: FilamentOverrideProps) {
+  const { t } = useTranslation();
+
+  // Index available filaments by type (uppercased) for per-slot filtering
+  const filamentsByType = useMemo(() => {
+    const map: Record<string, Array<{ type: string; color: string; tray_info_idx: string; extruder_id: number | null }>> = {};
+    for (const f of availableFilaments) {
+      const key = f.type.toUpperCase();
+      if (!map[key]) map[key] = [];
+      map[key].push(f);
+    }
+    return map;
+  }, [availableFilaments]);
+
+  const filaments = filamentReqs?.filaments;
+  if (!filaments || filaments.length === 0 || availableFilaments.length === 0) {
+    return null;
+  }
+
+  const handleChange = (slotId: number, value: string) => {
+    if (value === '') {
+      // Reset to original
+      const next = { ...overrides };
+      delete next[slotId];
+      onChange(next);
+    } else {
+      // Parse "TYPE|COLOR" value
+      const [type, color] = value.split('|');
+      onChange({ ...overrides, [slotId]: { type, color } });
+    }
+  };
+
+  return (
+    <div className="mb-4">
+      <div className="flex items-center gap-2 text-sm text-bambu-gray mb-2">
+        <span>{t('printModal.filamentOverride')}</span>
+      </div>
+      <p className="text-xs text-bambu-gray mb-2">{t('printModal.filamentOverrideHint')}</p>
+      <div className="bg-bambu-dark rounded-lg p-3 space-y-2">
+        {filaments.map((req) => {
+          const override = overrides[req.slot_id];
+          const isOverridden = !!override;
+          // Only show filaments of the same type AND compatible nozzle/extruder
+          const sameType = filamentsByType[req.type.toUpperCase()] || [];
+          // On dual-nozzle printers (H2D), filter to filaments on the correct extruder.
+          // nozzle_id from 3MF maps to extruder_id from AMS. If nozzle_id is undefined
+          // (single-nozzle) or extruder_id is null, no nozzle filtering is needed.
+          const compatible = req.nozzle_id != null
+            ? sameType.filter((f) => f.extruder_id == null || f.extruder_id === req.nozzle_id)
+            : 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'
+                }`}
+              >
+                <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}-${idx}`}
+                    value={`${f.type}|${f.color}`}
+                    className="bg-bambu-dark text-white"
+                  >
+                    {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" />
+              )}
+            </div>
+          );
+        })}
+      </div>
+    </div>
+  );
+}

+ 55 - 0
frontend/src/components/PrintModal/index.tsx

@@ -13,6 +13,7 @@ import { toDateTimeLocalValue } from '../../utils/date';
 import { Button } from '../Button';
 import { Card, CardContent } from '../Card';
 import { FilamentMapping } from './FilamentMapping';
+import { FilamentOverride } from './FilamentOverride';
 import { PlateSelector } from './PlateSelector';
 import { PrinterSelector } from './PrinterSelector';
 import { PrintOptionsPanel } from './PrintOptions';
@@ -148,6 +149,18 @@ export function PrintModal({
     return null;
   });
 
+  // Filament overrides for model-based assignment: slot_id -> {type, color}
+  const [filamentOverrides, setFilamentOverrides] = useState<Record<number, { type: string; color: string }>>(() => {
+    if (mode === 'edit-queue-item' && queueItem?.filament_overrides) {
+      const overrides: Record<number, { type: string; color: string }> = {};
+      for (const o of queueItem.filament_overrides) {
+        overrides[o.slot_id] = { type: o.type, color: o.color };
+      }
+      return overrides;
+    }
+    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));
@@ -241,6 +254,13 @@ export function PrintModal({
     return platesData.plates.find((plate) => plate.index === selectedPlate)?.name || undefined;
   }, [platesData, selectedPlate]);
 
+  // Fetch available filaments for model-based assignment (for filament override UI)
+  const { data: availableFilaments } = useQuery({
+    queryKey: ['available-filaments', targetModel, targetLocation],
+    queryFn: () => api.getAvailableFilaments(targetModel!, targetLocation ?? undefined),
+    enabled: assignmentMode === 'model' && !!targetModel,
+  });
+
   // Only fetch printer status when single printer selected (for filament mapping)
   const { data: printerStatus } = useQuery({
     queryKey: ['printer-status', effectivePrinterId],
@@ -295,6 +315,20 @@ export function PrintModal({
     }
   }, [mode, selectedPrinters, selectedPlate, initialPrinterIds, initialPlateId]);
 
+  // Clear filament overrides when target model or plate changes (but not on initial mount for edit mode)
+  const [prevTargetModel, setPrevTargetModel] = useState(targetModel);
+  const [prevPlateForOverrides, setPrevPlateForOverrides] = useState(selectedPlate);
+  useEffect(() => {
+    if (targetModel !== prevTargetModel || selectedPlate !== prevPlateForOverrides) {
+      setPrevTargetModel(targetModel);
+      setPrevPlateForOverrides(selectedPlate);
+      // Don't clear on initial render in edit mode (values are initialized from queueItem)
+      if (mode !== 'edit-queue-item' || prevTargetModel !== null) {
+        setFilamentOverrides({});
+      }
+    }
+  }, [targetModel, selectedPlate, prevTargetModel, prevPlateForOverrides, mode]);
+
   // Auto-expand per-printer mapping when setting is enabled and multiple printers selected
   // Only applies once per printer on initial selection, not when user unchecks
   useEffect(() => {
@@ -394,11 +428,21 @@ 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;
+
     // Common queue data for add-to-queue and edit modes
     const getQueueData = (printerId: number | null): PrintQueueItemCreate => ({
       printer_id: assignmentMode === 'printer' ? printerId : null,
       target_model: assignmentMode === 'model' ? targetModel : null,
       target_location: assignmentMode === 'model' ? targetLocation : null,
+      filament_overrides: assignmentMode === 'model' ? filamentOverridesArray : undefined,
       // Use library_file_id for library files, archive_id for archives
       archive_id: isLibraryFile ? undefined : archiveId,
       library_file_id: isLibraryFile ? libraryFileId : undefined,
@@ -428,6 +472,7 @@ export function PrintModal({
             printer_id: null,
             target_model: targetModel,
             target_location: targetLocation,
+            filament_overrides: filamentOverridesArray || null,
             require_previous_success: scheduleOptions.requirePreviousSuccess,
             auto_off_after: scheduleOptions.autoOffAfter,
             manual_start: scheduleOptions.scheduleType === 'manual',
@@ -669,6 +714,16 @@ export function PrintModal({
               slicedForModel={slicedForModel}
             />
 
+            {/* Filament override - shown in model mode when filament requirements are available */}
+            {assignmentMode === 'model' && targetModel && effectiveFilamentReqs && availableFilaments && availableFilaments.length > 0 && (
+              <FilamentOverride
+                filamentReqs={effectiveFilamentReqs}
+                availableFilaments={availableFilaments}
+                overrides={filamentOverrides}
+                onChange={setFilamentOverrides}
+              />
+            )}
+
             {/* Compatibility warning when sliced model doesn't match selected printer */}
             {slicedForModel && assignmentMode === 'printer' && selectedPrinters.length === 1 && (() => {
               const selectedPrinter = printers?.find(p => p.id === selectedPrinters[0]);

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

@@ -2763,6 +2763,11 @@ export default {
     rightNozzle: 'R',
     leftNozzleTooltip: 'Linke Düse',
     rightNozzleTooltip: 'Rechte Düse',
+    filamentOverride: 'Filament-Überschreibung',
+    filamentOverrideHint: 'Filamente für modellbasierte Zuweisung optional überschreiben. Der Planer wird gegen die ausgewählten Filamente statt der ursprünglichen 3MF-Werte abgleichen.',
+    originalFilament: 'Original',
+    overrideWith: 'Ersetzen mit',
+    resetToOriginal: 'Auf Original zurücksetzen',
   },
 
   // Backup

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

@@ -2767,6 +2767,11 @@ export default {
     rightNozzle: 'R',
     leftNozzleTooltip: 'Left nozzle',
     rightNozzleTooltip: 'Right nozzle',
+    filamentOverride: 'Filament Override',
+    filamentOverrideHint: 'Optionally override filaments for model-based assignment. The scheduler will match against your selected filaments instead of the original 3MF values.',
+    originalFilament: 'Original',
+    overrideWith: 'Override with',
+    resetToOriginal: 'Reset to original',
   },
 
   // Backup

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

@@ -2751,6 +2751,11 @@ export default {
     rightNozzle: 'D',
     leftNozzleTooltip: 'Buse gauche',
     rightNozzleTooltip: 'Buse droite',
+    filamentOverride: 'Remplacement de filament',
+    filamentOverrideHint: 'Remplacez optionnellement les filaments pour l\'affectation par modèle. Le planificateur utilisera vos filaments sélectionnés au lieu des valeurs 3MF d\'origine.',
+    originalFilament: 'Original',
+    overrideWith: 'Remplacer par',
+    resetToOriginal: 'Revenir à l\'original',
   },
 
   // Backup

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

@@ -2473,6 +2473,11 @@ export default {
     rightNozzle: 'R',
     leftNozzleTooltip: 'Ugello sinistro',
     rightNozzleTooltip: 'Ugello destro',
+    filamentOverride: 'Sostituzione filamento',
+    filamentOverrideHint: 'Sostituisci opzionalmente i filamenti per l\'assegnazione basata sul modello. Lo scheduler abbinerà i filamenti selezionati invece dei valori 3MF originali.',
+    originalFilament: 'Originale',
+    overrideWith: 'Sostituisci con',
+    resetToOriginal: 'Ripristina originale',
   },
 
   // Backup

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

@@ -2678,6 +2678,11 @@ export default {
     rightNozzle: 'R',
     leftNozzleTooltip: '左ノズル',
     rightNozzleTooltip: '右ノズル',
+    filamentOverride: 'フィラメントオーバーライド',
+    filamentOverrideHint: 'モデルベースの割り当てに使用するフィラメントをオプションで上書きします。スケジューラは元の3MF値ではなく、選択したフィラメントに基づいてマッチングします。',
+    originalFilament: 'オリジナル',
+    overrideWith: '変更先',
+    resetToOriginal: 'オリジナルに戻す',
   },
   backup: {
     restoreBackup: 'バックアップの復元',

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

@@ -2761,6 +2761,11 @@ export default {
     rightNozzle: 'R',
     leftNozzleTooltip: 'Bico esquerdo',
     rightNozzleTooltip: 'Bico direito',
+    filamentOverride: 'Substituição de Filamento',
+    filamentOverrideHint: 'Substitua opcionalmente os filamentos para atribuição baseada em modelo. O agendador usará os filamentos selecionados em vez dos valores originais do 3MF.',
+    originalFilament: 'Original',
+    overrideWith: 'Substituir por',
+    resetToOriginal: 'Restaurar original',
   },
 
   // Backup

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


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


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-B3SoseiM.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-DmSskpar.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-B00gz1lY.css">
+    <script type="module" crossorigin src="/assets/index-49qrvi2a.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-B3SoseiM.css">
   </head>
   <body>
     <div id="root"></div>

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