Sfoglia il codice sorgente

Configure a spool's filament preset and K profile per nozzle

    A slicer preset is bound to a printer model: "Bambu PLA Basic @BBL X1C" is
    not the same preset as "@BBL H2C", and Bambu names a nozzle size in it as
    well. A spool carried exactly one, which was right until the same spool was
    used on a second machine -- the AMS slot on the other one was then
    configured with a preset that machine has no profile for. K profiles had
    the matching gap from the other side: the tables have always been keyed per
    hotend, but the picker could not express it.

    spool_filament_preset and its Spoolman twin store the exceptions, keyed
    (spool, printer_model, nozzle_diameter). Model rather than printer because
    the preset is a property of the model -- "@BBL X1C" is the same preset on
    every X1C, and asking per machine would mean picking the identical value
    twice. K profiles stay on printer_id, because a K value is measured on one
    physical hotend and two machines of the same model legitimately differ.
    Resolution is exact (model, diameter) -> (model, "") -> the spool's own
    preset, so a spool nobody has configured behaves exactly as it did before.
    The form writes one row per nozzle size and never the "" row; that level is
    kept for API clients wanting one value to cover a model.

    Both halves cover every standard nozzle size rather than the size currently
    fitted, because a spool is configured once and nozzles get swapped. The PA
    Profile tab becomes a Printers tab: a model list beside a detail pane
    holding a preset row per size and a K-profile grid of size by hotend. Each
    model is offered only the presets that name it, through the same matcher
    the Configure AMS Slot modal filters with, which moves out of that
    component into utils/slicerPrinterMatch. Presets whose name identifies no
    model -- most user-authored and OrcaSlicer ones -- stay offered everywhere,
    as does whatever is already selected, so a saved override cannot vanish
    from the control that shows it. Every preset carries an origin badge in the
    wording and colours that modal already uses.

    Every path that configures a slot now respects both: manual assign in
    either inventory mode, RFID auto-assign, the Spoolman tag link, the re-fire
    when a slot goes empty to loaded, the re-apply after a calibration-table
    refresh, and the re-selection when a Filament Track Switch moves an AMS to
    the other nozzle. Which nozzle a slot feeds, and how wide it is, was worked
    out independently in seven of those places, each reading nozzles[0] for
    every slot on the machine -- correct on a single-nozzle printer and on a
    dual-nozzle printer with matching nozzles, wrong the moment two sizes are
    fitted. That resolution is now services/slot_nozzle.

    Which array entry belongs to which hotend is no longer inferred. Measured
    on an H2D fitted with a 0.4 high flow on the left and a 0.6 on the right,
    nozzles[0] reads the right hotend, so the array is indexed by extruder id
    and the H2/X2 parser's convention is the one that holds. The legacy
    parser's opposite convention never governs a real dual-nozzle machine:
    every model in DUAL_NOZZLE_MODELS reports device.nozzle.info, and
    left_nozzle_diameter appears in no log or wire capture. Two comments that
    said otherwise were wrong and are fixed; amsHelpers' code was right all
    along and only its comment lied.

    Four defects surfaced while wiring it, all pre-existing except the last.
    The picker identified a chosen calibration by cali_idx alone, and the
    printer numbers its calibration table per nozzle -- on a dual-nozzle
    machine the same index exists on both hotends meaning different things, so
    saving could persist the other hotend's K value and diameter; SpoolBuddy's
    write-tag page carried a verbatim copy and gets the same fix. RFID
    auto-assign chose a K profile with no extruder test at all, so a spool
    calibrated on both hotends had a coin toss decide which pressure-advance
    value the slot got, on the path that runs unattended every time a Bambu
    spool is loaded. The Spoolman tag-link path resolved no preset whatsoever,
    configuring every linked slot with a generic material id and discarding a
    preset set in inventory -- the same defect #1713 fixed on the assign path,
    one function over. And an FTS inlet move re-selected K for nozzle 0 rather
    than for the nozzle the AMS had just been moved to.

    The last one is new here: a per-model override can be a cloud USER preset,
    whose PFUS-prefixed id the slicer rejects, and passing it straight into
    extrusion_cali_sel would silently lose the K-profile link. Reached the
    printer only where such an override exists, which is why nothing in the
    suite caught it. printer_safe_filament_id falls through to the spool's own
    preset and then the tray's RFID value instead.

    Reading a printer's calibration table asks for one nozzle size at a time.
    H2-series firmware answers only the first one or two of a concurrent burst
    of extrusion_cali_get and silently drops the rest, each dropped request
    costing a five-second timeout before its retry: measured at 11 and 23
    seconds on an H2C and an H2D for four parallel requests, against roughly
    one second in series. An X1C answers all four at once, which is why this
    only ever surfaced on dual-diameter printers. Printers themselves are read
    in parallel -- separate machines are separate connections.

    The Configure AMS Slot dialog opens on the spool's own configured values,
    falling back to the slot's last manual configuration and then the tray's
    RFID data. The spool form is wider for the two-pane layout, colour, weight,
    cost and location move to their own tab in two columns, and a printer card
    in expanded view lists every fitted nozzle size rather than the first entry
    alone.
MartinNYHC 1 settimana fa
parent
commit
4705a3027a
60 ha cambiato i file con 4481 aggiunte e 356 eliminazioni
  1. 96 14
      backend/app/api/routes/inventory.py
  2. 108 9
      backend/app/api/routes/printers.py
  3. 50 23
      backend/app/api/routes/spoolman.py
  4. 117 18
      backend/app/api/routes/spoolman_inventory.py
  5. 1 0
      backend/app/core/database.py
  6. 20 16
      backend/app/main.py
  7. 3 0
      backend/app/models/__init__.py
  8. 8 0
      backend/app/models/spool.py
  9. 90 0
      backend/app/models/spool_filament_preset.py
  10. 25 0
      backend/app/schemas/spool.py
  11. 6 1
      backend/app/services/bambu_mqtt.py
  12. 33 4
      backend/app/services/slot_kprofile.py
  13. 117 0
      backend/app/services/slot_nozzle.py
  14. 127 0
      backend/app/services/spool_filament_preset.py
  15. 37 12
      backend/app/services/spool_tag_matcher.py
  16. 1 0
      backend/tests/conftest.py
  17. 168 0
      backend/tests/integration/test_slot_spool_defaults.py
  18. 234 0
      backend/tests/integration/test_spool_filament_preset_endpoints.py
  19. 179 0
      backend/tests/unit/test_assign_uses_model_preset_override.py
  20. 245 0
      backend/tests/unit/test_rfid_assign_picks_the_right_hotend.py
  21. 120 0
      backend/tests/unit/test_slot_nozzle_resolution.py
  22. 206 0
      backend/tests/unit/test_spool_filament_preset_cascade.py
  23. 1 0
      frontend/scripts/check-i18n-parity.mjs
  24. 632 0
      frontend/src/__tests__/components/PrinterProfilesSection.test.tsx
  25. 10 6
      frontend/src/__tests__/components/SpoolFormBulk.test.tsx
  26. 8 4
      frontend/src/__tests__/components/SpoolFormEditRelaxed.test.tsx
  27. 63 3
      frontend/src/__tests__/components/SpoolFormModal.test.tsx
  28. 118 0
      frontend/src/__tests__/components/spool-form/fetchPrinterCalibrations.test.ts
  29. 55 0
      frontend/src/api/client.ts
  30. 49 81
      frontend/src/components/ConfigureAmsSlotModal.tsx
  31. 226 104
      frontend/src/components/SpoolFormModal.tsx
  32. 30 23
      frontend/src/components/spool-form/AdditionalSection.tsx
  33. 10 2
      frontend/src/components/spool-form/FilamentSection.tsx
  34. 188 0
      frontend/src/components/spool-form/PresetPicker.tsx
  35. 604 0
      frontend/src/components/spool-form/PrinterProfilesSection.tsx
  36. 10 0
      frontend/src/components/spool-form/constants.ts
  37. 48 0
      frontend/src/components/spool-form/types.ts
  38. 82 23
      frontend/src/components/spool-form/utils.ts
  39. 18 0
      frontend/src/i18n/locales/de.ts
  40. 18 0
      frontend/src/i18n/locales/en.ts
  41. 18 0
      frontend/src/i18n/locales/es.ts
  42. 18 0
      frontend/src/i18n/locales/fr.ts
  43. 18 0
      frontend/src/i18n/locales/it.ts
  44. 18 0
      frontend/src/i18n/locales/ja.ts
  45. 18 0
      frontend/src/i18n/locales/ko.ts
  46. 18 0
      frontend/src/i18n/locales/nl.ts
  47. 18 0
      frontend/src/i18n/locales/pt-BR.ts
  48. 18 0
      frontend/src/i18n/locales/ru.ts
  49. 18 0
      frontend/src/i18n/locales/tr.ts
  50. 18 0
      frontend/src/i18n/locales/uk.ts
  51. 18 0
      frontend/src/i18n/locales/zh-CN.ts
  52. 18 0
      frontend/src/i18n/locales/zh-TW.ts
  53. 10 5
      frontend/src/pages/PrintersPage.tsx
  54. 7 1
      frontend/src/pages/spoolbuddy/SpoolBuddyWriteTagPage.tsx
  55. 8 4
      frontend/src/utils/amsHelpers.ts
  56. 76 0
      frontend/src/utils/slicerPrinterMatch.ts
  57. 0 1
      static/assets/index-BzJRM4M1.css
  58. 0 0
      static/assets/index-CU2NGMRH.js
  59. 1 0
      static/assets/index-q2IPtdZB.css
  60. 2 2
      static/index.html

+ 96 - 14
backend/app/api/routes/inventory.py

@@ -26,6 +26,7 @@ from backend.app.models.settings import Settings
 from backend.app.models.spool import Spool
 from backend.app.models.spool_assignment import SpoolAssignment
 from backend.app.models.spool_catalog import SpoolCatalogEntry
+from backend.app.models.spool_filament_preset import SpoolFilamentPreset
 from backend.app.models.spool_k_profile import SpoolKProfile
 from backend.app.models.user import User
 from backend.app.schemas.location import LocationCreate, LocationResponse, LocationUpdate
@@ -34,6 +35,8 @@ from backend.app.schemas.spool import (
     SpoolAssignmentResponse,
     SpoolBulkCreate,
     SpoolCreate,
+    SpoolFilamentPresetBase,
+    SpoolFilamentPresetResponse,
     SpoolKProfileBase,
     SpoolKProfileResponse,
     SpoolResponse,
@@ -53,6 +56,7 @@ from backend.app.services.location_service import (
     rename_location as rename_location_record,
 )
 from backend.app.services.slicer_filament_resolver import resolve_slicer_filament
+from backend.app.services.slot_nozzle import resolve_slot_nozzle
 from backend.app.services.spool_csv import (
     MAX_CSV_IMPORT_BYTES,
     ImportPreview,
@@ -60,6 +64,7 @@ from backend.app.services.spool_csv import (
     parse_and_validate,
     serialize,
 )
+from backend.app.services.spool_filament_preset import resolve_spool_preset
 from backend.app.services.spoolman import SpoolmanClient, get_spoolman_client, init_spoolman_client
 from backend.app.utils.filament_ids import (
     GENERIC_FILAMENT_IDS,
@@ -130,6 +135,27 @@ async def apply_spool_to_slot_via_mqtt(
 
     _generic_id_values = _GENERIC_ID_VALUES
 
+    # Which nozzle this slot feeds, and how wide it is. One resolution shared
+    # with every other path that configures a slot (see services.slot_nozzle),
+    # and used twice below -- for the spool's per-model preset override and for
+    # its K profile -- so the two lookups cannot answer for different nozzles.
+    slot_nozzle = resolve_slot_nozzle(state, ams_id, tray_id, printer_manager.get_model(printer_id))
+    nozzle_diameter = slot_nozzle.diameter
+
+    # A cloud or Orca preset is bound to a printer MODEL ("@BBL X1C"), so the
+    # spool's single slicer_filament stops being right the moment the same
+    # spool is used on a second model. resolve_spool_preset returns the
+    # spool's own value unless the user has set an override for this model,
+    # so a spool nobody has configured behaves exactly as it did before.
+    slot_slicer_filament, slot_slicer_filament_name = await resolve_spool_preset(
+        db,
+        spool_id=spool.id,
+        printer_model=printer_manager.get_model(printer_id),
+        nozzle_diameter=nozzle_diameter,
+        fallback_filament=spool.slicer_filament,
+        fallback_name=spool.slicer_filament_name,
+    )
+
     # slicer_filament → (tray_info_idx, setting_id) resolution is shared with
     # the Spoolman-mode route via this helper (#1713). The helper handles
     # GFS/PFUS/PFCN cloud lookup, GF normalize, integer LocalPreset id,
@@ -139,8 +165,8 @@ async def apply_spool_to_slot_via_mqtt(
     tray_info_idx, setting_id, sub_brand_override, type_override = await resolve_slicer_filament(
         db=db,
         current_user=current_user,
-        slicer_filament=spool.slicer_filament,
-        slicer_filament_name=spool.slicer_filament_name,
+        slicer_filament=slot_slicer_filament,
+        slicer_filament_name=slot_slicer_filament_name,
         material=spool.material,
     )
     if sub_brand_override:
@@ -198,18 +224,7 @@ async def apply_spool_to_slot_via_mqtt(
     if spool.nozzle_temp_max is not None:
         temp_max = spool.nozzle_temp_max
 
-    nozzle_diameter = "0.4"
-    if state and state.nozzles:
-        nd = state.nozzles[0].nozzle_diameter
-        if nd:
-            nozzle_diameter = nd
-
-    slot_extruder = None
-    if state and state.ams_extruder_map:
-        if ams_id == 255:
-            slot_extruder = 1 - tray_id  # ext-L (tray 0) → extruder 1, ext-R (tray 1) → extruder 0
-        else:
-            slot_extruder = state.ams_extruder_map.get(str(ams_id))
+    slot_extruder = slot_nozzle.extruder
 
     # Prefer exact extruder match, fall back to extruder-agnostic kp for the
     # same nozzle. Hard-skipping on mismatch silently drops valid stored
@@ -1667,6 +1682,73 @@ async def replace_k_profiles(
     return new_profiles
 
 
+@router.get("/spools/{spool_id}/filament-presets", response_model=list[SpoolFilamentPresetResponse])
+async def list_filament_presets(
+    spool_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
+):
+    """List per-printer-model preset overrides for a spool.
+
+    A dedicated endpoint rather than a field on ``SpoolResponse``: the
+    inventory list returns every spool the user owns, and only the spool form
+    and the assign path ever need this list, one spool at a time.
+    """
+    result = await db.execute(select(SpoolFilamentPreset).where(SpoolFilamentPreset.spool_id == spool_id))
+    return list(result.scalars().all())
+
+
+@router.put("/spools/{spool_id}/filament-presets", response_model=list[SpoolFilamentPresetResponse])
+async def replace_filament_presets(
+    spool_id: int,
+    presets: list[SpoolFilamentPresetBase],
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+):
+    """Replace all per-printer-model preset overrides for a spool.
+
+    Replace rather than merge, matching the K-profile endpoint next door: the
+    spool form always holds the complete set, and an empty list is how the
+    user clears every override back to the spool's own preset.
+    """
+    result = await db.execute(select(Spool).where(Spool.id == spool_id))
+    if not result.scalar_one_or_none():
+        raise HTTPException(404, "Spool not found")
+
+    # (model, diameter) is UNIQUE, so a payload that names one twice would
+    # fail on flush with an IntegrityError the client cannot act on. Reject it
+    # by name instead -- and reject it BEFORE deleting the existing rows, so a
+    # bad request cannot wipe overrides it then fails to replace.
+    seen: set[tuple[str, str]] = set()
+    for p in presets:
+        key = (p.printer_model, p.nozzle_diameter)
+        if key in seen:
+            raise HTTPException(
+                422,
+                f"Duplicate override for model {p.printer_model!r} nozzle {p.nozzle_diameter or 'any'!r}",
+            )
+        seen.add(key)
+
+    existing = await db.execute(select(SpoolFilamentPreset).where(SpoolFilamentPreset.spool_id == spool_id))
+    for old in existing.scalars().all():
+        await db.delete(old)
+    # Land the deletes before the inserts: within one transaction SQLAlchemy is
+    # free to order the INSERTs first, which trips the UNIQUE constraint
+    # against rows this call is about to remove.
+    await db.flush()
+
+    new_presets = []
+    for p in presets:
+        row = SpoolFilamentPreset(spool_id=spool_id, **p.model_dump())
+        db.add(row)
+        new_presets.append(row)
+
+    await db.commit()
+    for row in new_presets:
+        await db.refresh(row)
+    return new_presets
+
+
 # ── Spool Assignments ────────────────────────────────────────────────────────
 
 

+ 108 - 9
backend/app/api/routes/printers.py

@@ -87,6 +87,7 @@ from backend.app.services.printer_media import (
     remove_printer_files_zip,
     start_printer_files_job,
 )
+from backend.app.services.slot_nozzle import resolve_slot_nozzle
 from backend.app.utils.filament_ids import filament_id_to_setting_id
 from backend.app.utils.filament_types import printer_filament_type
 from backend.app.utils.fts_routing import slot_extruder
@@ -2651,6 +2652,105 @@ async def delete_slot_preset(
     return {"success": True}
 
 
+@router.get("/{printer_id}/slots/{ams_id}/{tray_id}/spool-defaults")
+async def get_slot_spool_defaults(
+    printer_id: int,
+    ams_id: int,
+    tray_id: int,
+    db: AsyncSession = Depends(get_db),
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
+):
+    """What the spool assigned to this slot is configured to use here.
+
+    The Configure AMS Slot dialog opens on a slot that usually already holds an
+    assigned spool, and that spool carries a filament preset per printer model
+    and a K profile per hotend. Without this the dialog offered defaults derived
+    from the slot's last manual configuration or from the tray's RFID data --
+    ignoring the very values the spool was configured with, on the one screen
+    that looks like it exists for them.
+
+    Everything is resolved for the nozzle THIS slot feeds, so a dual-nozzle
+    machine gets the answer for the correct hotend. Returns nulls rather than a
+    404 when the slot holds no known spool: "nothing configured" is an ordinary
+    answer here and the dialog falls back to what it did before.
+    """
+    from backend.app.models.spool import Spool
+    from backend.app.models.spool_assignment import SpoolAssignment
+    from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
+    from backend.app.services.inventory_mode import spoolman_owns_assignments
+    from backend.app.services.slot_kprofile import find_slot_kprofile_for_extruder
+    from backend.app.services.slot_nozzle import resolve_slot_nozzle
+    from backend.app.services.spool_filament_preset import resolve_spool_preset, resolve_spoolman_preset
+
+    state = printer_manager.get_status(printer_id)
+    model = printer_manager.get_model(printer_id)
+    slot_nozzle = resolve_slot_nozzle(state, ams_id, tray_id, model)
+
+    profile = await find_slot_kprofile_for_extruder(
+        db,
+        printer_id,
+        ams_id,
+        tray_id,
+        slot_nozzle.extruder_or_default,
+        slot_nozzle.diameter,
+        model,
+    )
+
+    slicer_filament: str | None = None
+    slicer_filament_name: str | None = None
+    spoolman_mode = await spoolman_owns_assignments(db)
+    if spoolman_mode:
+        sm_assignment = (
+            await db.execute(
+                select(SpoolmanSlotAssignment).where(
+                    SpoolmanSlotAssignment.printer_id == printer_id,
+                    SpoolmanSlotAssignment.ams_id == ams_id,
+                    SpoolmanSlotAssignment.tray_id == tray_id,
+                )
+            )
+        ).scalar_one_or_none()
+        if sm_assignment is not None:
+            slicer_filament, slicer_filament_name = await resolve_spoolman_preset(
+                db,
+                spoolman_spool_id=sm_assignment.spoolman_spool_id,
+                printer_model=model,
+                nozzle_diameter=slot_nozzle.diameter,
+                fallback_filament=None,
+                fallback_name=None,
+            )
+    else:
+        assignment = (
+            await db.execute(
+                select(SpoolAssignment).where(
+                    SpoolAssignment.printer_id == printer_id,
+                    SpoolAssignment.ams_id == ams_id,
+                    SpoolAssignment.tray_id == tray_id,
+                )
+            )
+        ).scalar_one_or_none()
+        if assignment is not None:
+            spool = (await db.execute(select(Spool).where(Spool.id == assignment.spool_id))).scalar_one_or_none()
+            if spool is not None:
+                slicer_filament, slicer_filament_name = await resolve_spool_preset(
+                    db,
+                    spool_id=spool.id,
+                    printer_model=model,
+                    nozzle_diameter=slot_nozzle.diameter,
+                    fallback_filament=spool.slicer_filament,
+                    fallback_name=spool.slicer_filament_name,
+                )
+
+    return {
+        "slicer_filament": slicer_filament,
+        "slicer_filament_name": slicer_filament_name,
+        "cali_idx": profile.cali_idx if profile else None,
+        "k_value": profile.k_value if profile else None,
+        "profile_name": profile.name if profile else None,
+        "extruder": slot_nozzle.extruder,
+        "nozzle_diameter": slot_nozzle.diameter,
+    }
+
+
 @router.post("/{printer_id}/slots/{ams_id}/{tray_id}/configure")
 async def configure_ams_slot(
     printer_id: int,
@@ -4169,13 +4269,12 @@ async def _apply_pa_after_refresh(printer_id: int, ams_id: int, slot_id: int):
             return
 
         # Compute nozzle/extruder once — used by both local and Spoolman lookup.
-        nozzle_diameter = "0.4"
-        if state.nozzles:
-            nd = state.nozzles[0].nozzle_diameter
-            if nd:
-                nozzle_diameter = nd
-
-        resolved_extruder = slot_extruder(ams_id, slot_id, state.ams_extruder_map, state.ams_switch_inlet)
+        # Shared with every other slot-configuring path (services.slot_nozzle),
+        # so the diameter this cascade filters on is the one the slot's own
+        # hotend actually has.
+        slot_nozzle = resolve_slot_nozzle(state, ams_id, slot_id, printer_manager.get_model(printer_id))
+        nozzle_diameter = slot_nozzle.diameter
+        resolved_extruder = slot_nozzle.extruder
 
         # 3-stage K-profile cascade: local SpoolKProfile → Spoolman SpoolmanKProfile
         # → live tray.cali_idx fallback. Pre-Phase-13 only handled the local path
@@ -4214,7 +4313,7 @@ async def _apply_pa_after_refresh(printer_id: int, ams_id: int, slot_id: int):
                     tag_filters.append(Spool.tag_uid == norm_tag)
                 if tag_filters:
                     tag_lookup = await db.execute(
-                        sa_select(Spool).options(selectinload(Spool.k_profiles)).where(or_(*tag_filters)).limit(1)
+                        select(Spool).options(selectinload(Spool.k_profiles)).where(or_(*tag_filters)).limit(1)
                     )
                     spool = tag_lookup.scalar_one_or_none()
                     if spool is not None:
@@ -4256,7 +4355,7 @@ async def _apply_pa_after_refresh(printer_id: int, ams_id: int, slot_id: int):
             # including the tag-based fallback above)
             if matching_cali_idx is None and spool is None:
                 sm_result = await db.execute(
-                    sa_select(SpoolmanSlotAssignment).where(
+                    select(SpoolmanSlotAssignment).where(
                         SpoolmanSlotAssignment.printer_id == printer_id,
                         SpoolmanSlotAssignment.ams_id == ams_id,
                         SpoolmanSlotAssignment.tray_id == slot_id,

+ 50 - 23
backend/app/api/routes/spoolman.py

@@ -22,6 +22,9 @@ from backend.app.models.spoolman_k_profile import SpoolmanKProfile
 from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
 from backend.app.models.user import User
 from backend.app.services.printer_manager import printer_manager
+from backend.app.services.slicer_filament_resolver import resolve_slicer_filament
+from backend.app.services.slot_nozzle import resolve_slot_nozzle
+from backend.app.services.spool_filament_preset import resolve_spoolman_preset
 from backend.app.services.spoolman import (
     SpoolmanClientError,
     SpoolmanNotFoundError,
@@ -32,6 +35,7 @@ from backend.app.services.spoolman import (
 )
 from backend.app.utils.filament_ids import (
     GENERIC_FILAMENT_IDS,
+    filament_id_to_setting_id,
     normalize_slicer_filament,
 )
 from backend.app.utils.filament_types import nozzle_temp_range, printer_filament_type
@@ -956,33 +960,61 @@ async def link_spool(
                 if len(tray_color) == 6:
                     tray_color = tray_color + "FF"
 
+                # Pull printer state via printer_manager (mqtt_client.printer_state
+                # was a non-existent attribute — the hasattr check silently
+                # returned None, defeating every state-based lookup below).
+                state = printer_manager.get_status(p_id)
+                slot_nozzle = resolve_slot_nozzle(state, a_id, t_id, printer_manager.get_model(p_id))
+                nozzle_diameter = slot_nozzle.diameter
+
+                # Resolve the spool's own preset before falling back to a
+                # generic material id. This path used to skip that entirely and
+                # configure every linked slot as generic PLA/PETG, so a spool
+                # with a preset set in inventory lost it the moment it was
+                # linked by tag — the same defect #1713 fixed on the assign
+                # path, in the function next door. The per-model override
+                # cascade applies here for the same reason it does there: the
+                # preset is bound to a printer model.
+                slot_slicer_filament, slot_slicer_filament_name = await resolve_spoolman_preset(
+                    db,
+                    spoolman_spool_id=spool_id,
+                    printer_model=printer_manager.get_model(p_id),
+                    nozzle_diameter=nozzle_diameter,
+                    fallback_filament=mapped.get("slicer_filament"),
+                    fallback_name=mapped.get("slicer_filament_name"),
+                )
+                tray_info_idx, setting_id, sub_brand_override, type_override = await resolve_slicer_filament(
+                    db=db,
+                    current_user=None,
+                    slicer_filament=slot_slicer_filament,
+                    slicer_filament_name=slot_slicer_filament_name,
+                    material=material,
+                )
+                if sub_brand_override:
+                    tray_sub_brands = sub_brand_override
+                if type_override:
+                    tray_type = printer_filament_type(type_override)
+
                 # The spool's own wording is tried first and the reduced type
                 # only as a further fallback, so a material that already
                 # resolves keeps resolving to the same id: "PETG HF" has its
                 # own generic preset (GFG96) that reducing it to "PETG" would
                 # trade away for GFG99.
                 material_upper = material.upper().strip()
-                tray_info_idx = (
-                    GENERIC_FILAMENT_IDS.get(material_upper)
-                    or GENERIC_FILAMENT_IDS.get(material_upper.split("-")[0].split(" ")[0])
-                    or GENERIC_FILAMENT_IDS.get(tray_type.upper())
-                    or ""
-                )
-                setting_id = ""
+                if not tray_info_idx:
+                    tray_info_idx = (
+                        GENERIC_FILAMENT_IDS.get(material_upper)
+                        or GENERIC_FILAMENT_IDS.get(material_upper.split("-")[0].split(" ")[0])
+                        or GENERIC_FILAMENT_IDS.get(tray_type.upper())
+                        or ""
+                    )
+                if tray_info_idx and not setting_id:
+                    setting_id = filament_id_to_setting_id(tray_info_idx)
+
                 temp_defaults = nozzle_temp_range(material, tray_type)
                 temp_min = mapped.get("nozzle_temp_min") or temp_defaults[0]
                 temp_max = temp_defaults[1]
 
-                # Pull printer state via printer_manager (mqtt_client.printer_state
-                # was a non-existent attribute — the hasattr check silently
-                # returned None, defeating every state-based lookup below).
-                state = printer_manager.get_status(p_id)
-                nozzle_diameter = "0.4"
-                if state and state.nozzles:
-                    nd = state.nozzles[0].nozzle_diameter
-                    if nd:
-                        nozzle_diameter = nd
-
                 kp_result = await db.execute(
                     select(SpoolmanKProfile).where(
                         SpoolmanKProfile.spoolman_spool_id == spool_id,
@@ -990,12 +1022,7 @@ async def link_spool(
                     )
                 )
                 kp_rows = kp_result.scalars().all()
-                slot_extruder = None
-                if state and state.ams_extruder_map:
-                    if a_id == 255:
-                        slot_extruder = 1 - t_id
-                    else:
-                        slot_extruder = state.ams_extruder_map.get(str(a_id))
+                slot_extruder = slot_nozzle.extruder
 
                 # Prefer exact extruder match, fall back to extruder-agnostic kp
                 # for the same nozzle. Hard-skip on extruder mismatch silently

+ 117 - 18
backend/app/api/routes/spoolman_inventory.py

@@ -38,10 +38,11 @@ from backend.app.core.websocket import ws_manager
 from backend.app.models.ams_label import AmsLabel
 from backend.app.models.printer import Printer
 from backend.app.models.settings import Settings
+from backend.app.models.spool_filament_preset import SpoolmanFilamentPreset
 from backend.app.models.spoolman_k_profile import SpoolmanKProfile
 from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
 from backend.app.models.user import User
-from backend.app.schemas.spool import SpoolKProfileBase
+from backend.app.schemas.spool import SpoolFilamentPresetBase, SpoolKProfileBase
 from backend.app.schemas.spoolman import SpoolmanFilamentPatch, SpoolmanSlotAssignmentEnriched
 from backend.app.services.location_service import (
     enrich_spool_dicts_with_location_id,
@@ -50,6 +51,8 @@ from backend.app.services.location_service import (
 )
 from backend.app.services.printer_manager import printer_manager
 from backend.app.services.slicer_filament_resolver import resolve_slicer_filament
+from backend.app.services.slot_nozzle import resolve_slot_nozzle
+from backend.app.services.spool_filament_preset import resolve_spoolman_preset
 from backend.app.services.spoolman import (
     SpoolmanClient,
     SpoolmanClientError,
@@ -1507,6 +1510,33 @@ async def assign_spoolman_slot(
             if len(tray_color) == 6:
                 tray_color = tray_color + "FF"
 
+            # Printer state, read here rather than further down because the
+            # per-model preset override below needs the slot's nozzle
+            # diameter and the K-profile cascade further down needs the same
+            # value -- one read, so they cannot disagree. (The previous
+            # `mqtt_client.printer_state` access via hasattr always returned
+            # None -- the attribute is `state`, not `printer_state` -- so the
+            # K-profile cascade silently skipped state.kprofiles, defaulted
+            # nozzle_diameter to 0.4, and left slot_extruder unset.)
+            state = printer_manager.get_status(body.printer_id)
+            slot_nozzle = resolve_slot_nozzle(
+                state, body.ams_id, body.tray_id, printer_manager.get_model(body.printer_id)
+            )
+            nozzle_diameter = slot_nozzle.diameter
+
+            # Per-printer-model preset override, same cascade as internal
+            # mode: a cloud/Orca preset is bound to a model, so one stored
+            # preset per spool is wrong across two models. Returns Spoolman's
+            # own value when no override is set.
+            slot_slicer_filament, slot_slicer_filament_name = await resolve_spoolman_preset(
+                db,
+                spoolman_spool_id=body.spoolman_spool_id,
+                printer_model=printer_manager.get_model(body.printer_id),
+                nozzle_diameter=nozzle_diameter,
+                fallback_filament=mapped.get("slicer_filament"),
+                fallback_name=mapped.get("slicer_filament_name"),
+            )
+
             # #1713: resolve the spool's stored slicer_filament reference
             # (cloud preset, local preset, GF-prefix builtin, or numeric
             # LocalPreset id) to the printer-side tray_info_idx + setting_id.
@@ -1518,8 +1548,8 @@ async def assign_spoolman_slot(
             tray_info_idx, setting_id, sub_brand_override, type_override = await resolve_slicer_filament(
                 db=db,
                 current_user=current_user,
-                slicer_filament=mapped.get("slicer_filament"),
-                slicer_filament_name=mapped.get("slicer_filament_name"),
+                slicer_filament=slot_slicer_filament,
+                slicer_filament_name=slot_slicer_filament_name,
                 material=material,
             )
             if sub_brand_override:
@@ -1563,21 +1593,7 @@ async def assign_spoolman_slot(
             # None (the attribute is `state`, not `printer_state`), so the
             # K-profile cascade silently skipped state.kprofiles, defaulted
             # nozzle_diameter to 0.4, and left slot_extruder unset.
-            state = printer_manager.get_status(body.printer_id)
-            nozzle_diameter = "0.4"
-            if state and state.nozzles:
-                nd = state.nozzles[0].nozzle_diameter
-                if nd:
-                    nozzle_diameter = nd
-
-            slot_extruder = None
-            if state and state.ams_extruder_map:
-                if body.ams_id == 255:
-                    # External slots: ext-L (tray 0) → extruder 1, ext-R (tray 1) → extruder 0
-                    # tray_id 0→1, 1→0
-                    slot_extruder = 1 - body.tray_id
-                else:
-                    slot_extruder = state.ams_extruder_map.get(str(body.ams_id))
+            slot_extruder = slot_nozzle.extruder
 
             # Prefer exact extruder match, fall back to extruder-agnostic kp
             # for the same nozzle. Hard-skipping on mismatch silently dropped
@@ -1820,6 +1836,89 @@ def _k_profile_to_dict(p: SpoolmanKProfile) -> dict:
     }
 
 
+def _filament_preset_to_dict(p: SpoolmanFilamentPreset) -> dict:
+    """Manually map SpoolmanFilamentPreset → SpoolFilamentPresetResponse-compatible dict."""
+    return {
+        "id": p.id,
+        "spool_id": p.spoolman_spool_id,
+        "printer_model": p.printer_model,
+        "nozzle_diameter": p.nozzle_diameter,
+        "slicer_filament": p.slicer_filament,
+        "slicer_filament_name": p.slicer_filament_name,
+        "created_at": p.created_at,
+    }
+
+
+@router.get("/spools/{spool_id}/filament-presets")
+async def get_spoolman_filament_presets(
+    spool_id: int = Path(..., gt=0),
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
+) -> list[dict]:
+    """Return all per-printer-model preset overrides for a Spoolman spool."""
+    await _get_client(db)
+    result = await db.execute(
+        select(SpoolmanFilamentPreset).where(SpoolmanFilamentPreset.spoolman_spool_id == spool_id)
+    )
+    return [_filament_preset_to_dict(p) for p in result.scalars().all()]
+
+
+@router.put("/spools/{spool_id}/filament-presets")
+async def save_spoolman_filament_presets(
+    spool_id: int = Path(..., gt=0),
+    presets: list[SpoolFilamentPresetBase] = Body(...),
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+) -> list[dict]:
+    """Replace all per-printer-model preset overrides for a Spoolman spool."""
+    client = await _get_client(db)
+    async with _translate_spoolman_errors():
+        await client.get_spool(spool_id)
+
+    # Same as the internal route: reject a duplicated (model, diameter) before
+    # touching the stored rows, so a bad payload cannot clear what it fails to
+    # replace.
+    seen: set[tuple[str, str]] = set()
+    for preset in presets:
+        key = (preset.printer_model, preset.nozzle_diameter)
+        if key in seen:
+            raise HTTPException(
+                422,
+                f"Duplicate override for model {preset.printer_model!r} nozzle {preset.nozzle_diameter or 'any'!r}",
+            )
+        seen.add(key)
+
+    saved: list[SpoolmanFilamentPreset] = []
+    try:
+        await db.execute(delete(SpoolmanFilamentPreset).where(SpoolmanFilamentPreset.spoolman_spool_id == spool_id))
+        await db.flush()
+        for preset in presets:
+            obj = SpoolmanFilamentPreset(
+                spoolman_spool_id=spool_id,
+                printer_model=preset.printer_model,
+                nozzle_diameter=preset.nozzle_diameter,
+                slicer_filament=preset.slicer_filament,
+                slicer_filament_name=preset.slicer_filament_name,
+            )
+            db.add(obj)
+            saved.append(obj)
+        await db.commit()
+    except IntegrityError as exc:
+        await db.rollback()
+        raise HTTPException(422, "Duplicate or invalid preset override (check model and nozzle uniqueness)") from exc
+    except HTTPException:
+        raise
+    except Exception as exc:
+        await db.rollback()
+        logger.error("Filament preset save for spool %d failed: %s", spool_id, exc)
+        raise HTTPException(500, "Failed to save filament presets") from exc
+
+    for obj in saved:
+        await db.refresh(obj)
+
+    return [_filament_preset_to_dict(p) for p in saved]
+
+
 def _normalize_filament(raw: dict) -> NormalizedFilament | None:
     """Normalise a raw Spoolman filament dict for the frontend catalog picker.
 

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

@@ -330,6 +330,7 @@ async def init_db():
         spool,
         spool_assignment,
         spool_catalog,
+        spool_filament_preset,
         spool_k_profile,
         spool_usage_history,
         spoolbuddy_device,

+ 20 - 16
backend/app/main.py

@@ -128,10 +128,12 @@ from backend.app.services.printer_manager import (
     resolve_plate_id,
 )
 from backend.app.services.slot_kprofile import find_slot_kprofile_for_extruder
+from backend.app.services.slot_nozzle import nozzle_diameter_for_extruder, resolve_slot_nozzle
 from backend.app.services.smart_plug_manager import smart_plug_manager
 from backend.app.services.spool_assignment_notifications import (
     notify_missing_spool_assignments_on_print_start,
 )
+from backend.app.services.spool_filament_preset import printer_safe_filament_id
 from backend.app.services.spoolman import close_spoolman_client, get_spoolman_client, init_spoolman_client
 from backend.app.services.spoolman_tracking import (
     cleanup_tracking as _cleanup_spoolman_tracking,
@@ -141,7 +143,7 @@ from backend.app.services.spoolman_tracking import (
 from backend.app.services.tasmota import tasmota_service
 from backend.app.utils.ams_drying import is_drying_active, temperature_alarm_suppressed
 from backend.app.utils.filament_types import printer_filament_type
-from backend.app.utils.fts_routing import extruder_for_inlet, slot_extruder as resolve_slot_extruder
+from backend.app.utils.fts_routing import extruder_for_inlet
 from backend.app.utils.local_time import utcnow_naive
 from backend.app.utils.print_jobs import is_internal_printer_job
 
@@ -1892,9 +1894,11 @@ async def on_fts_inlet_change(printer_id: int, ams_id: int, inlet: str):
     if not client or not state or not state.raw_data:
         return
 
-    nozzle_diameter = "0.4"
-    if state.nozzles and state.nozzles[0].nozzle_diameter:
-        nozzle_diameter = state.nozzles[0].nozzle_diameter
+    # The nozzle the AMS now feeds -- the diameter of the TARGET extruder, not
+    # of nozzle 0. On a machine with two sizes fitted, moving the inlet changes
+    # the nozzle width, which changes both the K profile to select and the
+    # preset the slot should carry.
+    nozzle_diameter = nozzle_diameter_for_extruder(state, target_extruder, printer_manager.get_model(printer_id))
 
     ams_raw = state.raw_data.get("ams")
     ams_list = ams_raw.get("ams", []) if isinstance(ams_raw, dict) else ams_raw if isinstance(ams_raw, list) else []
@@ -1911,7 +1915,13 @@ async def on_fts_inlet_change(printer_id: int, ams_id: int, inlet: str):
                 current_idx = tray.get("cali_idx")
 
                 profile = await find_slot_kprofile_for_extruder(
-                    db, printer_id, ams_id, tray_id, target_extruder, nozzle_diameter
+                    db,
+                    printer_id,
+                    ams_id,
+                    tray_id,
+                    target_extruder,
+                    nozzle_diameter,
+                    printer_manager.get_model(printer_id),
                 )
                 if profile is None or profile.cali_idx is None:
                     continue
@@ -1935,7 +1945,7 @@ async def on_fts_inlet_change(printer_id: int, ams_id: int, inlet: str):
                     ams_id=ams_id,
                     tray_id=tray_id,
                     cali_idx=profile.cali_idx,
-                    filament_id=profile.filament_id or tray.get("tray_info_idx", "") or "",
+                    filament_id=printer_safe_filament_id(profile.filament_id, tray.get("tray_info_idx", "")),
                     nozzle_diameter=nozzle_diameter,
                 )
     except Exception as e:
@@ -2364,17 +2374,11 @@ async def on_ams_change(printer_id: int, ams_data: list):
                                     and spool.k_profiles
                                 ):
                                     state = printer_manager.get_status(printer_id)
-                                    nozzle_diameter = "0.4"
-                                    if state and state.nozzles:
-                                        nd = state.nozzles[0].nozzle_diameter
-                                        if nd:
-                                            nozzle_diameter = nd
-                                    slot_extruder = resolve_slot_extruder(
-                                        ams_id,
-                                        tray_id,
-                                        state.ams_extruder_map if state else None,
-                                        state.ams_switch_inlet if state else None,
+                                    slot_nozzle = resolve_slot_nozzle(
+                                        state, ams_id, tray_id, printer_manager.get_model(printer_id)
                                     )
+                                    nozzle_diameter = slot_nozzle.diameter
+                                    slot_extruder = slot_nozzle.extruder
                                     # Prefer exact extruder match, fall back to
                                     # extruder-agnostic kp for the same printer +
                                     # nozzle. Avoids hard-skipping when the AMS is

+ 3 - 0
backend/app/models/__init__.py

@@ -34,6 +34,7 @@ from backend.app.models.sponsor_toast_state import SponsorToastState
 from backend.app.models.spool import Spool
 from backend.app.models.spool_assignment import SpoolAssignment
 from backend.app.models.spool_catalog import SpoolCatalogEntry
+from backend.app.models.spool_filament_preset import SpoolFilamentPreset, SpoolmanFilamentPreset
 from backend.app.models.spool_k_profile import SpoolKProfile
 from backend.app.models.spool_usage_history import SpoolUsageHistory
 from backend.app.models.spoolbuddy_device import SpoolBuddyDevice
@@ -82,7 +83,9 @@ __all__ = [
     "PipelineRun",
     "SlicerPipeline",
     "Spool",
+    "SpoolFilamentPreset",
     "SpoolKProfile",
+    "SpoolmanFilamentPreset",
     "SpoolAssignment",
     "SpoolCatalogEntry",
     "SpoolUsageHistory",

+ 8 - 0
backend/app/models/spool.py

@@ -74,10 +74,18 @@ class Spool(Base):
     updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
 
     k_profiles: Mapped[list["SpoolKProfile"]] = relationship(back_populates="spool", cascade="all, delete-orphan")
+    # Per-printer-model preset overrides. Deliberately NOT embedded in
+    # SpoolResponse the way k_profiles is: the inventory list returns every
+    # spool a user owns, and this list is only ever read by the spool form
+    # and the assign path, both of which fetch it for one spool at a time.
+    filament_presets: Mapped[list["SpoolFilamentPreset"]] = relationship(
+        back_populates="spool", cascade="all, delete-orphan"
+    )
     assignments: Mapped[list["SpoolAssignment"]] = relationship(back_populates="spool", cascade="all, delete-orphan")
     location: Mapped["Location | None"] = relationship(back_populates="spools")
 
 
 from backend.app.models.location import Location  # noqa: E402
 from backend.app.models.spool_assignment import SpoolAssignment  # noqa: E402
+from backend.app.models.spool_filament_preset import SpoolFilamentPreset  # noqa: E402
 from backend.app.models.spool_k_profile import SpoolKProfile  # noqa: E402

+ 90 - 0
backend/app/models/spool_filament_preset.py

@@ -0,0 +1,90 @@
+from datetime import datetime
+
+from sqlalchemy import DateTime, ForeignKey, Integer, String, UniqueConstraint, func
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from backend.app.core.database import Base
+
+
+class SpoolFilamentPreset(Base):
+    """Per-printer-model override of a spool's slicer filament preset.
+
+    ``Spool.slicer_filament`` holds ONE preset, and that is deliberate -- the
+    spool form is printer-agnostic and the user picks the variant they want
+    (see ``spool-form/utils.ts``). It stops being enough as soon as the same
+    spool is used on two different printer models: a cloud or Orca preset is
+    bound to a model (``@BBL X1C``), so the spool that carries an X1C variant
+    configures an AMS slot on an H2C with a preset that machine has no profile
+    for.
+
+    Keyed on the printer MODEL, not the printer: ``@BBL X1C`` is the same
+    preset on every X1C the user owns, and keying per machine would make them
+    pick the identical value once per printer. (K profiles are the opposite --
+    a K value is measured on one individual hotend -- which is why
+    ``spool_k_profile`` keys on ``printer_id`` and this does not.)
+
+    ``nozzle_diameter`` is part of the key because the preset lands on an AMS
+    slot, and a slot feeds exactly one nozzle: on a dual-nozzle machine with
+    two different diameters fitted, one preset per model cannot be right for
+    both hotends, and diameter-specific presets genuinely exist
+    (``Bambu PLA Basic @BBL A1M 0.2 nozzle``). Empty string means "any nozzle
+    of this model". The spool form does not write that row -- it offers one row
+    per nozzle size and nothing above them, because a preset lands on an AMS
+    slot and a slot feeds exactly one nozzle -- but the level is kept in the
+    cascade for API clients that want one value to cover a whole model.
+    Resolution order is
+
+        exact (model, diameter) -> (model, "") -> ``Spool.slicer_filament``
+
+    which is what ``services.spool_filament_preset.resolve_spool_preset``
+    implements. Empty string rather than NULL because NULLs compare distinct
+    in a UNIQUE constraint on both SQLite and PostgreSQL, so a nullable
+    column would happily store the same "any nozzle" row twice.
+    """
+
+    __tablename__ = "spool_filament_preset"
+
+    __table_args__ = (UniqueConstraint("spool_id", "printer_model", "nozzle_diameter"),)
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    spool_id: Mapped[int] = mapped_column(ForeignKey("spool.id", ondelete="CASCADE"), index=True)
+    # Matches ``printers.model`` ("X1C", "H2D", "A1 mini"), not a display name.
+    printer_model: Mapped[str] = mapped_column(String(50))
+    # "" = any nozzle of this model; otherwise the bare decimal the printer
+    # reports ("0.4", "0.2"), the same form ``spool_k_profile`` stores.
+    nozzle_diameter: Mapped[str] = mapped_column(String(10), default="")
+    # Wider than ``Spool.slicer_filament`` (String(50)) on purpose: the same
+    # values reach the Spoolman path, whose write schema already allows 128 /
+    # 255, and a preset id that fits there must not truncate here.
+    slicer_filament: Mapped[str | None] = mapped_column(String(128))
+    slicer_filament_name: Mapped[str | None] = mapped_column(String(255))
+    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
+
+    spool: Mapped["Spool"] = relationship(back_populates="filament_presets")
+
+
+class SpoolmanFilamentPreset(Base):
+    """``SpoolFilamentPreset`` for a Spoolman-managed spool.
+
+    Mirrors ``SpoolmanKProfile``: Spoolman owns the spool, Bambuddy owns this
+    override, so the row is local and keyed by the remote spool id with no
+    foreign key to enforce it. Kept in a Bambuddy table rather than in the
+    spool's Spoolman ``extra`` dict for the same reason the K profiles are --
+    it is Bambu-specific data that no other Spoolman client can use, and the
+    extra dict cannot express a per-model list without hand-rolled JSON.
+    """
+
+    __tablename__ = "spoolman_filament_preset"
+
+    __table_args__ = (UniqueConstraint("spoolman_spool_id", "printer_model", "nozzle_diameter"),)
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    spoolman_spool_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
+    printer_model: Mapped[str] = mapped_column(String(50))
+    nozzle_diameter: Mapped[str] = mapped_column(String(10), default="")
+    slicer_filament: Mapped[str | None] = mapped_column(String(128))
+    slicer_filament_name: Mapped[str | None] = mapped_column(String(255))
+    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
+
+
+from backend.app.models.spool import Spool  # noqa: E402, F401

+ 25 - 0
backend/app/schemas/spool.py

@@ -198,6 +198,31 @@ class SpoolKProfileResponse(SpoolKProfileBase):
         from_attributes = True
 
 
+class SpoolFilamentPresetBase(BaseModel):
+    """One per-printer-model slicer preset override for a spool.
+
+    ``nozzle_diameter`` defaults to "" meaning "any nozzle of this model". The
+    spool form always sends a concrete size; the empty form is for API clients
+    that want one value to cover a model. Lengths match the columns, which are wider than
+    ``Spool.slicer_filament`` so a preset id that fits the Spoolman write
+    schema cannot truncate on the way in.
+    """
+
+    printer_model: str = Field(..., min_length=1, max_length=50)
+    nozzle_diameter: str = Field(default="", max_length=10)
+    slicer_filament: str | None = Field(default=None, max_length=128)
+    slicer_filament_name: str | None = Field(default=None, max_length=255)
+
+
+class SpoolFilamentPresetResponse(SpoolFilamentPresetBase):
+    id: int
+    spool_id: int
+    created_at: datetime
+
+    class Config:
+        from_attributes = True
+
+
 class SpoolResponse(SpoolBase):
     id: int
     # rgba is intentionally unconstrained on the response side: the write paths

+ 6 - 1
backend/app/services/bambu_mqtt.py

@@ -853,7 +853,12 @@ class PrinterState:
     wifi_signal: int | None = None  # WiFi signal strength in dBm
     wired_network: bool = False  # Ethernet connection detected (home_flag bit 18)
     door_open: bool = False  # Enclosure door open (home_flag bit 23; models with a door sensor: X1/X1C/X1E/X2D/P2S/H2*)
-    # Nozzle hardware info (for dual nozzle printers, index 0 = left, 1 = right)
+    # Nozzle hardware info. Indexed by EXTRUDER id: [0] is the RIGHT hotend and
+    # [1] the left, measured 2026-08-27 on an H2D fitted with 0.4 left / 0.6
+    # right. (The legacy parser below writes left -> [0], but it only ever runs
+    # for single-nozzle printers -- every dual-nozzle model reports
+    # device.nozzle.info instead.) Read it through services.slot_nozzle rather
+    # than indexing it directly.
     nozzles: list = field(default_factory=lambda: [NozzleInfo(), NozzleInfo()])
     # AI detection and print options
     print_options: PrintOptions = field(default_factory=PrintOptions)

+ 33 - 4
backend/app/services/slot_kprofile.py

@@ -23,6 +23,7 @@ from backend.app.models.spool_k_profile import SpoolKProfile
 from backend.app.models.spoolman_k_profile import SpoolmanKProfile
 from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
 from backend.app.services.inventory_mode import spoolman_owns_assignments
+from backend.app.services.spool_filament_preset import resolve_spool_preset, resolve_spoolman_preset
 
 
 @dataclass(frozen=True)
@@ -45,6 +46,7 @@ async def find_slot_kprofile_for_extruder(
     tray_id: int,
     extruder: int,
     nozzle_diameter: str,
+    printer_model: str | None = None,
 ) -> SlotKProfile | None:
     """Stored profile for whatever is in this slot, calibrated for ``extruder``.
 
@@ -93,12 +95,27 @@ async def find_slot_kprofile_for_extruder(
         )
         if profile is not None:
             spool = (await db.execute(select(Spool).where(Spool.id == assignment.spool_id))).scalar_one_or_none()
+            # The preset this profile was calibrated under, through the
+            # per-printer-model cascade: a spool can carry a different preset
+            # per model, and extrusion_cali_sel has to name the one the printer
+            # will actually see in the slot. Falls back to the spool's own
+            # value when the caller cannot say which model this is.
+            filament_id = spool.slicer_filament if spool else None
+            if spool is not None and printer_model:
+                filament_id, _ = await resolve_spool_preset(
+                    db,
+                    spool_id=spool.id,
+                    printer_model=printer_model,
+                    nozzle_diameter=nozzle_diameter,
+                    fallback_filament=spool.slicer_filament,
+                    fallback_name=spool.slicer_filament_name,
+                )
             return SlotKProfile(
                 cali_idx=profile.cali_idx,
                 k_value=profile.k_value,
                 name=profile.name,
                 extruder=profile.extruder,
-                filament_id=(spool.slicer_filament if spool else None),
+                filament_id=filament_id,
             )
         # A known spool with no profile for this nozzle is a deliberate stop:
         # falling through to Spoolman would answer for a different spool.
@@ -136,12 +153,24 @@ async def find_slot_kprofile_for_extruder(
     if sm_profile is None:
         return None
 
-    # Spoolman rows carry no slicer preset; the caller falls back to the tray's
-    # own tray_info_idx for filament_id.
+    # A Spoolman K row carries no preset of its own, but the spool can still
+    # have a per-model override stored locally -- that is the same table the
+    # Spoolman assign path writes. Without a model to key on there is nothing
+    # to resolve and the caller falls back to the tray's own tray_info_idx.
+    sm_filament_id = None
+    if printer_model:
+        sm_filament_id, _ = await resolve_spoolman_preset(
+            db,
+            spoolman_spool_id=sm_assignment.spoolman_spool_id,
+            printer_model=printer_model,
+            nozzle_diameter=nozzle_diameter,
+            fallback_filament=None,
+            fallback_name=None,
+        )
     return SlotKProfile(
         cali_idx=sm_profile.cali_idx,
         k_value=sm_profile.k_value,
         name=sm_profile.name,
         extruder=sm_profile.extruder,
-        filament_id=None,
+        filament_id=sm_filament_id,
     )

+ 117 - 0
backend/app/services/slot_nozzle.py

@@ -0,0 +1,117 @@
+"""Which nozzle does this AMS slot feed, and how wide is it?
+
+Every path that configures a slot needs the same two facts: the extruder the
+slot feeds, and that nozzle's diameter. Both the filament preset and the K
+profile are stored per nozzle diameter, so getting the diameter wrong silently
+selects the wrong preset *and* the wrong K value -- and before this module the
+answer was worked out independently in seven places, each with ``nozzles[0]``
+hard-coded as the diameter for every slot on the machine.
+
+``nozzles[0]`` is correct on a single-nozzle printer and correct on a
+dual-nozzle printer with the same size fitted both sides, which is why it has
+survived. It is wrong the moment someone fits a 0.4 and a 0.2, which is exactly
+the machine this feature exists for.
+
+## Which array index belongs to which extruder
+
+``PrinterState.nozzles`` is filled by two different MQTT parsers that use
+opposite conventions, and this module is where that is resolved once:
+
+* The **H2/X2 path** (``bambu_mqtt`` ~5100) writes ``nozzles[nozzle["id"]]``
+  straight from ``device.nozzle.info``, i.e. indexed by physical nozzle id.
+* The **legacy path** (~5013) writes left -> ``nozzles[0]``, right ->
+  ``nozzles[1]``, which is the reverse of the extruder ids (extruder 0 is the
+  RIGHT hotend).
+
+**MEASURED 2026-08-27 on an H2D with 0.4 high flow LEFT and 0.6 high flow
+RIGHT: ``nozzles[0]`` read 0.6 -- the right hotend, which is extruder 0.** So
+the array is indexed by extruder id, and the H2 convention (physical nozzle id N
+sits on extruder N) is the one that holds.
+
+The legacy branch cannot govern a real dual-nozzle machine anyway: every model
+in ``DUAL_NOZZLE_MODELS`` is H2-series or X2D, all of which report
+``device.nozzle.info``, and ``left_nozzle_diameter`` appears nowhere in any
+captured log or wire trace. On a single-nozzle printer both conventions agree
+that index 0 is the only nozzle.
+
+The distinction is invisible on a machine with matching nozzles, since both
+conventions then return the same string -- which is why it went unnoticed for so
+long, and why this is the single place to change if a future model contradicts
+it.
+"""
+
+from __future__ import annotations
+
+import logging
+from dataclasses import dataclass
+
+from backend.app.utils.fts_routing import slot_extruder
+from backend.app.utils.printer_models import is_dual_nozzle_model
+
+logger = logging.getLogger(__name__)
+
+# What a printer that has told us nothing is assumed to have fitted. Matches
+# the default every call site used before this module existed.
+DEFAULT_NOZZLE_DIAMETER = "0.4"
+
+
+@dataclass(frozen=True)
+class SlotNozzle:
+    """The nozzle an AMS slot feeds."""
+
+    # None when the printer has not said which extruder this slot feeds. Callers
+    # that must have a number use ``extruder_or_default``; callers that store a
+    # row keep the None so "unknown" is not written as "the right-hand nozzle".
+    extruder: int | None
+    diameter: str
+
+    @property
+    def extruder_or_default(self) -> int:
+        """0 when unknown -- correct on a single-nozzle machine, a guess on a dual."""
+        return 0 if self.extruder is None else self.extruder
+
+
+def nozzle_diameter_for_extruder(state, extruder: int | None, model: str | None = None) -> str:
+    """The diameter fitted to ``extruder``, or the printer's only nozzle.
+
+    Falls back to index 0, and then to 0.4, whenever the printer has not
+    reported the entry -- an absent nozzle must not make this raise, since it is
+    called on every assign.
+    """
+    nozzles = getattr(state, "nozzles", None) or []
+    if not nozzles:
+        return DEFAULT_NOZZLE_DIAMETER
+
+    index = 0
+    if extruder is not None and extruder > 0 and is_dual_nozzle_model(model):
+        # Physical nozzle id N sits on extruder N -- see the module docstring
+        # for why the legacy left/right convention cannot apply here.
+        index = extruder
+
+    for candidate in (index, 0):
+        if candidate < len(nozzles):
+            diameter = (getattr(nozzles[candidate], "nozzle_diameter", "") or "").strip()
+            if diameter:
+                return diameter
+    return DEFAULT_NOZZLE_DIAMETER
+
+
+def resolve_slot_nozzle(state, ams_id: int, tray_id: int, model: str | None = None) -> SlotNozzle:
+    """The extruder an AMS slot feeds and that nozzle's diameter.
+
+    ``state`` is the live ``PrinterState`` (or None when the printer is not
+    connected, which yields the defaults rather than an error).
+    """
+    if state is None:
+        return SlotNozzle(extruder=None, diameter=DEFAULT_NOZZLE_DIAMETER)
+
+    extruder = slot_extruder(
+        ams_id,
+        tray_id,
+        getattr(state, "ams_extruder_map", None),
+        getattr(state, "ams_switch_inlet", None),
+    )
+    return SlotNozzle(
+        extruder=extruder,
+        diameter=nozzle_diameter_for_extruder(state, extruder, model),
+    )

+ 127 - 0
backend/app/services/spool_filament_preset.py

@@ -0,0 +1,127 @@
+"""Resolve which slicer filament preset a spool should use on a given nozzle.
+
+``Spool.slicer_filament`` is the spool's single, printer-agnostic answer. It is
+right until the same spool is used on two printer models, because a cloud or
+Orca preset is bound to a model (``@BBL X1C``): assigning that spool to an H2C
+writes a slot preset the H2C has no profile for. ``SpoolFilamentPreset`` stores
+the per-model exceptions and this module is the only thing that reads them, so
+the internal-inventory and Spoolman-inventory assign paths cannot drift apart
+the way they did before #1713.
+
+Resolution order, most specific first:
+
+    1. (printer_model, nozzle_diameter)  -- what the spool form writes, one
+                                            row per nozzle size
+    2. (printer_model, "")               -- a whole-model value; the form does
+                                            not write these, but the API accepts
+                                            them and they still resolve
+    3. ``Spool.slicer_filament``         -- what the spool carries today
+
+Every step is a plain equality match on stored strings; nothing is inferred
+from preset names. A model with no row at all resolves to step 3, which is
+exactly the behaviour every install has now, so a spool nobody has configured
+per-model behaves identically before and after this feature.
+"""
+
+from __future__ import annotations
+
+import logging
+
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.models.spool_filament_preset import SpoolFilamentPreset, SpoolmanFilamentPreset
+
+logger = logging.getLogger(__name__)
+
+# What ``resolve_*`` returns: (slicer_filament, slicer_filament_name).
+PresetPair = tuple[str | None, str | None]
+
+
+def _pick(
+    rows: list[SpoolFilamentPreset] | list[SpoolmanFilamentPreset],
+    printer_model: str | None,
+    nozzle_diameter: str | None,
+    fallback: PresetPair,
+) -> PresetPair:
+    """Apply the cascade to rows already fetched for one spool.
+
+    Split out so both spool flavours share it, and so callers that already
+    hold the rows (the spool form's read path) do not re-query.
+    """
+    model = (printer_model or "").strip()
+    if not model:
+        # No model means no way to be more specific than the spool's own value.
+        # This is the normal answer for a printer that has not reported yet.
+        return fallback
+
+    diameter = (nozzle_diameter or "").strip()
+    exact: PresetPair | None = None
+    model_default: PresetPair | None = None
+
+    for row in rows:
+        if row.printer_model != model:
+            continue
+        if diameter and row.nozzle_diameter == diameter:
+            exact = (row.slicer_filament, row.slicer_filament_name)
+        elif row.nozzle_diameter == "":
+            model_default = (row.slicer_filament, row.slicer_filament_name)
+
+    chosen = exact or model_default
+    if chosen is None:
+        return fallback
+    # A row that exists but carries no preset id is a deliberate "use nothing
+    # here", not a hole to fall through: the user picked the blank entry for
+    # this model. Falling back would silently reinstate the value they cleared.
+    return chosen
+
+
+def printer_safe_filament_id(*candidates: str | None) -> str:
+    """First candidate the printer will accept as a filament id, or "".
+
+    ``extrusion_cali_sel`` carries a filament id so the printer can link the
+    calibration index to the slot's filament. A cloud USER preset id
+    (``PFUS``/``PFCN`` prefix) is not one the slicer accepts -- the assign paths
+    have refused those for tray_info_idx since #1713, and the same holds here.
+
+    This matters now that a per-model override can BE such an id: a user picking
+    their own cloud preset for a model stores its ``PFUS...`` id, and passing
+    that straight through would send the printer a value it rejects, silently
+    losing the K-profile link. Falls through to the next candidate instead --
+    normally the spool's own preset, then the tray's RFID value.
+    """
+    for candidate in candidates:
+        value = (candidate or "").strip()
+        if value and not value.startswith(("PFUS", "PFCN")):
+            return value
+    return ""
+
+
+async def resolve_spool_preset(
+    db: AsyncSession,
+    *,
+    spool_id: int,
+    printer_model: str | None,
+    nozzle_diameter: str | None,
+    fallback_filament: str | None,
+    fallback_name: str | None,
+) -> PresetPair:
+    """Cascade for an internal-inventory spool. See the module docstring."""
+    result = await db.execute(select(SpoolFilamentPreset).where(SpoolFilamentPreset.spool_id == spool_id))
+    return _pick(list(result.scalars().all()), printer_model, nozzle_diameter, (fallback_filament, fallback_name))
+
+
+async def resolve_spoolman_preset(
+    db: AsyncSession,
+    *,
+    spoolman_spool_id: int,
+    printer_model: str | None,
+    nozzle_diameter: str | None,
+    fallback_filament: str | None,
+    fallback_name: str | None,
+) -> PresetPair:
+    """Cascade for a Spoolman-managed spool. See the module docstring."""
+    result = await db.execute(
+        select(SpoolmanFilamentPreset).where(SpoolmanFilamentPreset.spoolman_spool_id == spoolman_spool_id)
+    )
+    return _pick(list(result.scalars().all()), printer_model, nozzle_diameter, (fallback_filament, fallback_name))

+ 37 - 12
backend/app/services/spool_tag_matcher.py

@@ -9,6 +9,8 @@ from sqlalchemy.orm import selectinload
 from backend.app.models.spool import Spool
 from backend.app.models.spool_assignment import SpoolAssignment
 from backend.app.schemas.spool import normalize_effect_type
+from backend.app.services.slot_nozzle import resolve_slot_nozzle
+from backend.app.services.spool_filament_preset import printer_safe_filament_id, resolve_spool_preset
 from backend.app.utils.tag_normalization import (
     normalize_tag_uid as _normalize_tag_uid,
     normalize_tray_uuid as _normalize_tray_uuid,
@@ -566,24 +568,47 @@ async def auto_assign_spool(
     try:
         client = printer_manager.get_client(printer_id)
         if client:
-            # Apply K-profile if available
-            nozzle_diameter = "0.4"
-            if state and state.nozzles:
-                nd = state.nozzles[0].nozzle_diameter
-                if nd:
-                    nozzle_diameter = nd
-
-            matching_kp = None
+            # Which nozzle this slot feeds, resolved the same way every other
+            # slot-configuring path resolves it (services.slot_nozzle).
+            slot_nozzle = resolve_slot_nozzle(state, ams_id, tray_id, printer_manager.get_model(printer_id))
+            nozzle_diameter = slot_nozzle.diameter
+
+            # Prefer the profile calibrated for THIS hotend, falling back to one
+            # stored for the same nozzle size on the other. Before this the
+            # first row matching (printer, diameter) won outright with no
+            # extruder test at all -- on a dual-nozzle printer that is a coin
+            # toss between the two hotends, on the path that fires unattended
+            # every time an RFID spool is loaded.
+            exact_kp = None
+            fallback_kp = None
             for kp in spool.k_profiles:
-                if kp.printer_id == printer_id and kp.nozzle_diameter == nozzle_diameter:
-                    matching_kp = kp
+                if kp.printer_id != printer_id or kp.nozzle_diameter != nozzle_diameter:
+                    continue
+                if slot_nozzle.extruder is not None and kp.extruder == slot_nozzle.extruder:
+                    exact_kp = kp
                     break
+                if fallback_kp is None:
+                    fallback_kp = kp
+            matching_kp = exact_kp or fallback_kp
+
+            # The id sent with extrusion_cali_sel has to name the preset the
+            # profile was calibrated under, and that preset can differ per
+            # printer model -- so it comes from the same cascade the assign
+            # paths use rather than straight off the spool.
+            model_filament, _ = await resolve_spool_preset(
+                db,
+                spool_id=spool.id,
+                printer_model=printer_manager.get_model(printer_id),
+                nozzle_diameter=nozzle_diameter,
+                fallback_filament=spool.slicer_filament,
+                fallback_name=spool.slicer_filament_name,
+            )
 
             if matching_kp and matching_kp.cali_idx is not None:
                 # The filament_id in extrusion_cali_sel must match the filament preset
                 # under which the K-profile was calibrated. Use spool.slicer_filament
                 # (the preset assigned in inventory), falling back to tray's RFID value.
-                cali_filament_id = spool.slicer_filament or tray_info_idx or ""
+                cali_filament_id = printer_safe_filament_id(model_filament, spool.slicer_filament, tray_info_idx)
                 client.extrusion_cali_sel(
                     ams_id=ams_id,
                     tray_id=tray_id,
@@ -610,7 +635,7 @@ async def auto_assign_spool(
                 # so the printer keeps its existing calibration selection.
                 live_cali_idx = tray.get("cali_idx")
                 if live_cali_idx is not None and live_cali_idx >= 0:
-                    cali_filament_id = spool.slicer_filament or tray_info_idx or ""
+                    cali_filament_id = printer_safe_filament_id(model_filament, spool.slicer_filament, tray_info_idx)
                     client.extrusion_cali_sel(
                         ams_id=ams_id,
                         tray_id=tray_id,

+ 1 - 0
backend/tests/conftest.py

@@ -244,6 +244,7 @@ async def test_engine():
         spool,
         spool_assignment,
         spool_catalog,
+        spool_filament_preset,
         spool_k_profile,
         spool_usage_history,
         spoolbuddy_device,

+ 168 - 0
backend/tests/integration/test_slot_spool_defaults.py

@@ -0,0 +1,168 @@
+"""GET /printers/{id}/slots/{ams}/{tray}/spool-defaults
+
+What the Configure AMS Slot dialog opens with. The slot usually already holds
+an assigned spool, and that spool carries a filament preset per printer model
+and a K profile per hotend -- the values the user set for exactly this
+situation. Before this endpoint the dialog defaulted to the slot's last manual
+configuration or the tray's RFID data and ignored them.
+
+Everything is resolved for the nozzle THIS slot feeds, so the answer differs
+between the two hotends of a dual-nozzle machine.
+"""
+
+from unittest.mock import MagicMock, patch
+
+import pytest
+from httpx import AsyncClient
+
+URL = "/api/v1/printers/{pid}/slots/{ams}/{tray}/spool-defaults"
+
+RIGHT, LEFT = 0, 1
+
+
+class _Nozzle:
+    def __init__(self, diameter):
+        self.nozzle_diameter = diameter
+
+
+class _State:
+    """Dual-nozzle, 0.4 on the right and 0.2 on the left, AMS 0 -> left."""
+
+    def __init__(self, diameters=("0.4", "0.2")):
+        self.nozzles = [_Nozzle(d) for d in diameters]
+        self.ams_extruder_map = {"0": LEFT, "1": RIGHT}
+        self.ams_switch_inlet = None
+        self.raw_data = {}
+
+
+@pytest.fixture
+def dual_nozzle_printer_state():
+    with patch("backend.app.api.routes.printers.printer_manager") as manager:
+        manager.get_status = MagicMock(return_value=_State())
+        manager.get_model = MagicMock(return_value="H2D")
+        yield manager
+
+
+@pytest.fixture
+async def assigned_spool(db_session, printer_factory):
+    """A spool in AMS 0 tray 0, with a per-model preset and both hotends calibrated."""
+    from backend.app.models.spool import Spool
+    from backend.app.models.spool_assignment import SpoolAssignment
+    from backend.app.models.spool_filament_preset import SpoolFilamentPreset
+    from backend.app.models.spool_k_profile import SpoolKProfile
+
+    printer = await printer_factory(model="H2D")
+    spool = Spool(
+        brand="Bambu",
+        material="PLA",
+        color_name="Black",
+        slicer_filament="GFSA00",
+        slicer_filament_name="Bambu PLA Basic @BBL X1C",
+    )
+    db_session.add(spool)
+    await db_session.commit()
+    await db_session.refresh(spool)
+
+    db_session.add(SpoolAssignment(spool_id=spool.id, printer_id=printer.id, ams_id=0, tray_id=0))
+    db_session.add(
+        SpoolFilamentPreset(
+            spool_id=spool.id,
+            printer_model="H2D",
+            nozzle_diameter="0.2",
+            slicer_filament="GFSA21",
+            slicer_filament_name="Bambu PLA Basic @BBL H2D 0.2 nozzle",
+        )
+    )
+    db_session.add_all(
+        [
+            SpoolKProfile(
+                spool_id=spool.id,
+                printer_id=printer.id,
+                extruder=LEFT,
+                nozzle_diameter="0.2",
+                k_value=0.018,
+                cali_idx=16,
+                name="PLA left",
+            ),
+            SpoolKProfile(
+                spool_id=spool.id,
+                printer_id=printer.id,
+                extruder=RIGHT,
+                nozzle_diameter="0.4",
+                k_value=0.020,
+                cali_idx=15,
+                name="PLA right",
+            ),
+        ]
+    )
+    await db_session.commit()
+    return printer, spool
+
+
+@pytest.mark.integration
+class TestSlotSpoolDefaults:
+    @pytest.mark.asyncio
+    async def test_answers_for_the_hotend_this_slot_feeds(
+        self, async_client: AsyncClient, assigned_spool, dual_nozzle_printer_state
+    ):
+        printer, _ = assigned_spool
+        response = await async_client.get(URL.format(pid=printer.id, ams=0, tray=0))
+        assert response.status_code == 200, response.text
+        body = response.json()
+
+        # AMS 0 feeds the LEFT hotend, which has the 0.2 fitted.
+        assert body["extruder"] == LEFT
+        assert body["nozzle_diameter"] == "0.2"
+        # So the 0.2 preset override, not the spool's own X1C one...
+        assert body["slicer_filament"] == "GFSA21"
+        # ...and the profile calibrated on that hotend, not the other's.
+        assert body["cali_idx"] == 16
+        assert body["k_value"] == pytest.approx(0.018)
+        assert body["profile_name"] == "PLA left"
+
+    @pytest.mark.asyncio
+    async def test_a_slot_with_no_spool_answers_nulls_not_404(
+        self, async_client: AsyncClient, assigned_spool, dual_nozzle_printer_state
+    ):
+        """ "Nothing configured" is an ordinary answer -- the dialog falls back
+        to what it did before rather than treating it as an error."""
+        printer, _ = assigned_spool
+        response = await async_client.get(URL.format(pid=printer.id, ams=1, tray=3))
+        assert response.status_code == 200
+        body = response.json()
+        assert body["slicer_filament"] is None
+        assert body["cali_idx"] is None
+        # The nozzle is still resolved -- AMS 1 is the right hotend.
+        assert body["extruder"] == RIGHT
+        assert body["nozzle_diameter"] == "0.4"
+
+    @pytest.mark.asyncio
+    async def test_falls_back_to_the_spools_own_preset_without_an_override(
+        self, async_client: AsyncClient, assigned_spool, dual_nozzle_printer_state, db_session
+    ):
+        from backend.app.models.spool_assignment import SpoolAssignment
+
+        printer, spool = assigned_spool
+        # Same spool in a slot on the RIGHT hotend, which has no 0.4 override.
+        db_session.add(SpoolAssignment(spool_id=spool.id, printer_id=printer.id, ams_id=1, tray_id=0))
+        await db_session.commit()
+
+        body = (await async_client.get(URL.format(pid=printer.id, ams=1, tray=0))).json()
+
+        assert body["slicer_filament"] == "GFSA00"
+        assert body["cali_idx"] == 15
+
+    @pytest.mark.asyncio
+    async def test_an_offline_printer_still_answers(self, async_client: AsyncClient, assigned_spool):
+        """Opening the dialog on a disconnected printer must not 500 -- it just
+        cannot say which hotend the slot feeds."""
+        printer, _ = assigned_spool
+        with patch("backend.app.api.routes.printers.printer_manager") as manager:
+            manager.get_status = MagicMock(return_value=None)
+            manager.get_model = MagicMock(return_value="H2D")
+            response = await async_client.get(URL.format(pid=printer.id, ams=0, tray=0))
+
+        assert response.status_code == 200
+        body = response.json()
+        assert body["extruder"] is None
+        assert body["nozzle_diameter"] == "0.4"

+ 234 - 0
backend/tests/integration/test_spool_filament_preset_endpoints.py

@@ -0,0 +1,234 @@
+"""Endpoints for the per-printer-model filament preset overrides.
+
+    GET  /api/v1/inventory/spools/{id}/filament-presets
+    PUT  /api/v1/inventory/spools/{id}/filament-presets
+    GET  /api/v1/spoolman/inventory/spools/{id}/filament-presets
+    PUT  /api/v1/spoolman/inventory/spools/{id}/filament-presets
+
+Both PUTs replace the whole set, matching the K-profile endpoints beside them:
+the spool form always holds the complete list, and an empty body is how the
+user clears every override back to the spool's own preset.
+
+The case worth having a test for is the duplicate: (model, diameter) is
+UNIQUE, so a payload naming one twice has to be refused -- and refused
+*before* the existing rows are deleted, or a rejected save takes the user's
+overrides with it.
+"""
+
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from httpx import AsyncClient
+
+SAMPLE_SPOOL = {
+    "id": 7,
+    "filament": {
+        "id": 1,
+        "name": "PLA Basic",
+        "material": "PLA",
+        "weight": 1000,
+        "color_hex": "303030",
+        "vendor": {"id": 1, "name": "Bambu"},
+    },
+    "remaining_weight": 800.0,
+    "used_weight": 200.0,
+    "location": None,
+    "comment": None,
+    "first_used": None,
+    "last_used": None,
+    "registered": "2024-01-01T00:00:00+00:00",
+    "archived": False,
+    "price": None,
+    "extra": {},
+}
+
+INTERNAL = "/api/v1/inventory/spools"
+SPOOLMAN = "/api/v1/spoolman/inventory/spools"
+
+
+@pytest.fixture
+async def spool(db_session):
+    from backend.app.models.spool import Spool
+
+    row = Spool(
+        brand="Bambu",
+        material="PLA",
+        color_name="Charcoal",
+        slicer_filament="GFSA00",
+        slicer_filament_name="Bambu PLA Basic @BBL X1C",
+    )
+    db_session.add(row)
+    await db_session.commit()
+    await db_session.refresh(row)
+    return row
+
+
+@pytest.fixture
+async def spoolman_settings(db_session):
+    from backend.app.models.settings import Settings
+
+    db_session.add(Settings(key="spoolman_enabled", value="true"))
+    db_session.add(Settings(key="spoolman_url", value="http://localhost:7912"))
+    await db_session.commit()
+
+
+@pytest.fixture
+def mock_spoolman_client():
+    client = MagicMock()
+    client.base_url = "http://localhost:7912"
+    client.health_check = AsyncMock(return_value=True)
+    client.get_spool = AsyncMock(return_value=SAMPLE_SPOOL)
+
+    with patch(
+        "backend.app.api.routes.spoolman_inventory._get_client",
+        AsyncMock(return_value=client),
+    ):
+        yield client
+
+
+def _preset(model, diameter="", code="GFSA09", name="Bambu PLA Basic @BBL H2C"):
+    return {
+        "printer_model": model,
+        "nozzle_diameter": diameter,
+        "slicer_filament": code,
+        "slicer_filament_name": name,
+    }
+
+
+@pytest.mark.integration
+class TestInternalInventory:
+    @pytest.mark.asyncio
+    async def test_empty_by_default(self, async_client: AsyncClient, spool):
+        response = await async_client.get(f"{INTERNAL}/{spool.id}/filament-presets")
+        assert response.status_code == 200
+        assert response.json() == []
+
+    @pytest.mark.asyncio
+    async def test_put_then_get_round_trips(self, async_client: AsyncClient, spool):
+        response = await async_client.put(
+            f"{INTERNAL}/{spool.id}/filament-presets",
+            json=[_preset("H2C"), _preset("A1 mini", "0.2", "GFSA21", "PLA @A1M 0.2 nozzle")],
+        )
+        assert response.status_code == 200, response.text
+
+        rows = (await async_client.get(f"{INTERNAL}/{spool.id}/filament-presets")).json()
+        assert len(rows) == 2
+        by_model = {r["printer_model"]: r for r in rows}
+        assert by_model["H2C"]["nozzle_diameter"] == ""
+        assert by_model["H2C"]["slicer_filament"] == "GFSA09"
+        assert by_model["A1 mini"]["nozzle_diameter"] == "0.2"
+        assert by_model["A1 mini"]["slicer_filament"] == "GFSA21"
+        assert all(r["spool_id"] == spool.id for r in rows)
+
+    @pytest.mark.asyncio
+    async def test_put_replaces_rather_than_appends(self, async_client: AsyncClient, spool):
+        await async_client.put(f"{INTERNAL}/{spool.id}/filament-presets", json=[_preset("H2C")])
+        await async_client.put(f"{INTERNAL}/{spool.id}/filament-presets", json=[_preset("X1C")])
+
+        rows = (await async_client.get(f"{INTERNAL}/{spool.id}/filament-presets")).json()
+        assert [r["printer_model"] for r in rows] == ["X1C"]
+
+    @pytest.mark.asyncio
+    async def test_empty_body_clears_every_override(self, async_client: AsyncClient, spool):
+        await async_client.put(f"{INTERNAL}/{spool.id}/filament-presets", json=[_preset("H2C")])
+
+        response = await async_client.put(f"{INTERNAL}/{spool.id}/filament-presets", json=[])
+        assert response.status_code == 200
+        assert (await async_client.get(f"{INTERNAL}/{spool.id}/filament-presets")).json() == []
+
+    @pytest.mark.asyncio
+    async def test_replacing_the_same_key_does_not_trip_the_unique_constraint(self, async_client: AsyncClient, spool):
+        """Deletes and inserts land in one transaction, and SQLAlchemy is free
+        to order the INSERTs first. Re-saving the same (model, diameter) with a
+        new preset is the ordinary case -- the user changed their pick."""
+        await async_client.put(f"{INTERNAL}/{spool.id}/filament-presets", json=[_preset("H2C")])
+
+        response = await async_client.put(
+            f"{INTERNAL}/{spool.id}/filament-presets",
+            json=[_preset("H2C", "", "GFSA11", "Bambu PLA Matte @BBL H2C")],
+        )
+        assert response.status_code == 200, response.text
+
+        rows = (await async_client.get(f"{INTERNAL}/{spool.id}/filament-presets")).json()
+        assert len(rows) == 1
+        assert rows[0]["slicer_filament"] == "GFSA11"
+
+    @pytest.mark.asyncio
+    async def test_duplicate_key_is_rejected_without_losing_the_stored_rows(self, async_client: AsyncClient, spool):
+        await async_client.put(f"{INTERNAL}/{spool.id}/filament-presets", json=[_preset("H2C")])
+
+        response = await async_client.put(
+            f"{INTERNAL}/{spool.id}/filament-presets",
+            json=[_preset("X1C", "", "GFSA01"), _preset("X1C", "", "GFSA02")],
+        )
+        assert response.status_code == 422
+
+        # The rejected save must not have taken the existing override with it.
+        rows = (await async_client.get(f"{INTERNAL}/{spool.id}/filament-presets")).json()
+        assert [r["printer_model"] for r in rows] == ["H2C"]
+
+    @pytest.mark.asyncio
+    async def test_unknown_spool_is_404(self, async_client: AsyncClient):
+        response = await async_client.put(f"{INTERNAL}/999999/filament-presets", json=[_preset("H2C")])
+        assert response.status_code == 404
+
+    @pytest.mark.asyncio
+    async def test_blank_model_is_rejected(self, async_client: AsyncClient, spool):
+        """An empty printer_model would store a row the cascade can never
+        match, since it refuses to resolve without a model."""
+        response = await async_client.put(f"{INTERNAL}/{spool.id}/filament-presets", json=[_preset("")])
+        assert response.status_code == 422
+
+
+@pytest.mark.integration
+class TestSpoolmanInventory:
+    @pytest.mark.asyncio
+    async def test_empty_by_default(self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client):
+        response = await async_client.get(f"{SPOOLMAN}/7/filament-presets")
+        assert response.status_code == 200
+        assert response.json() == []
+
+    @pytest.mark.asyncio
+    async def test_put_then_get_round_trips(self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client):
+        response = await async_client.put(
+            f"{SPOOLMAN}/7/filament-presets",
+            json=[_preset("H2C"), _preset("H2C", "0.2", "GFSA10", "PLA @H2C 0.2 nozzle")],
+        )
+        assert response.status_code == 200, response.text
+
+        rows = (await async_client.get(f"{SPOOLMAN}/7/filament-presets")).json()
+        assert len(rows) == 2
+        assert all(r["spool_id"] == 7 for r in rows)
+        assert {r["nozzle_diameter"] for r in rows} == {"", "0.2"}
+
+    @pytest.mark.asyncio
+    async def test_put_replaces_rather_than_appends(
+        self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
+    ):
+        await async_client.put(f"{SPOOLMAN}/7/filament-presets", json=[_preset("H2C")])
+        await async_client.put(f"{SPOOLMAN}/7/filament-presets", json=[_preset("X1C")])
+
+        rows = (await async_client.get(f"{SPOOLMAN}/7/filament-presets")).json()
+        assert [r["printer_model"] for r in rows] == ["X1C"]
+
+    @pytest.mark.asyncio
+    async def test_duplicate_key_is_rejected_without_losing_the_stored_rows(
+        self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
+    ):
+        await async_client.put(f"{SPOOLMAN}/7/filament-presets", json=[_preset("H2C")])
+
+        response = await async_client.put(
+            f"{SPOOLMAN}/7/filament-presets",
+            json=[_preset("X1C", "", "GFSA01"), _preset("X1C", "", "GFSA02")],
+        )
+        assert response.status_code == 422
+
+        rows = (await async_client.get(f"{SPOOLMAN}/7/filament-presets")).json()
+        assert [r["printer_model"] for r in rows] == ["H2C"]
+
+    @pytest.mark.asyncio
+    async def test_one_spools_overrides_do_not_leak_into_another(
+        self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
+    ):
+        await async_client.put(f"{SPOOLMAN}/7/filament-presets", json=[_preset("H2C")])
+        assert (await async_client.get(f"{SPOOLMAN}/8/filament-presets")).json() == []

+ 179 - 0
backend/tests/unit/test_assign_uses_model_preset_override.py

@@ -0,0 +1,179 @@
+"""The per-model override has to reach the slot, not just the database.
+
+``apply_spool_to_slot_via_mqtt`` is where a spool becomes an AMS slot
+configuration, and the preset it resolves is what the printer (and the slicer
+reading the slot back) ends up with. These drive that function and assert on
+what it handed to ``resolve_slicer_filament``, which is the last point the
+preset is still a stored reference rather than a printer-side id.
+
+The regression they guard is the whole point of the feature: a spool whose
+single ``slicer_filament`` is an ``@BBL X1C`` preset, assigned on an H2C,
+previously configured that H2C slot with the X1C preset.
+"""
+
+from __future__ import annotations
+
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from sqlalchemy import select
+from sqlalchemy.orm import selectinload
+
+from backend.app.models.spool import Spool
+from backend.app.models.spool_filament_preset import SpoolFilamentPreset
+
+pytestmark = pytest.mark.asyncio
+
+SPOOL_DEFAULT = "GFSA00"
+SPOOL_DEFAULT_NAME = "Bambu PLA Basic @BBL X1C"
+
+
+class _Nozzle:
+    def __init__(self, diameter: str):
+        self.nozzle_diameter = diameter
+        self.nozzle_type = "HH01"
+
+
+class _State:
+    """Just enough live printer state for the assign path."""
+
+    def __init__(self, diameter: str = "0.4"):
+        self.nozzles = [_Nozzle(diameter), _Nozzle(diameter)]
+        self.ams_extruder_map = {}
+        self.kprofiles = []
+        self.raw_data = {}
+
+
+async def _spool(db_session) -> Spool:
+    spool = Spool(
+        brand="Bambu",
+        material="PLA",
+        color_name="Charcoal",
+        slicer_filament=SPOOL_DEFAULT,
+        slicer_filament_name=SPOOL_DEFAULT_NAME,
+    )
+    db_session.add(spool)
+    await db_session.commit()
+    # Loaded the way every production caller loads it: k_profiles is a lazy
+    # relationship the assign path walks, and an async session cannot resolve
+    # it mid-call.
+    result = await db_session.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool.id))
+    return result.scalar_one()
+
+
+async def _assign(db_session, spool, printer, *, model: str, diameter: str = "0.4"):
+    """Run the assign path far enough to capture the resolved preset.
+
+    Returns the kwargs ``resolve_slicer_filament`` was called with. Everything
+    downstream of it is stubbed: this asserts which preset was chosen, not how
+    the MQTT payload is built (that is covered elsewhere).
+    """
+    from backend.app.api.routes import inventory as inventory_module
+
+    resolver = AsyncMock(return_value=("GFL99", "GFSL99", "", ""))
+
+    manager = MagicMock()
+    manager.get_client = MagicMock(return_value=MagicMock())
+    manager.get_status = MagicMock(return_value=_State(diameter))
+    manager.get_model = MagicMock(return_value=model)
+
+    with (
+        patch.object(inventory_module, "resolve_slicer_filament", resolver),
+        patch("backend.app.services.printer_manager.printer_manager", manager),
+    ):
+        await inventory_module.apply_spool_to_slot_via_mqtt(
+            db=db_session,
+            current_user=None,
+            spool=spool,
+            printer_id=printer.id,
+            ams_id=0,
+            tray_id=0,
+        )
+
+    assert resolver.await_count == 1, "the assign path did not reach the preset resolver"
+    return resolver.await_args.kwargs
+
+
+class TestWithoutAnOverride:
+    async def test_the_spools_own_preset_is_used(self, db_session, printer_factory):
+        """Every spool in every existing install. Behaviour must be unchanged."""
+        printer = await printer_factory(model="H2C")
+        spool = await _spool(db_session)
+
+        kwargs = await _assign(db_session, spool, printer, model="H2C")
+
+        assert kwargs["slicer_filament"] == SPOOL_DEFAULT
+        assert kwargs["slicer_filament_name"] == SPOOL_DEFAULT_NAME
+
+
+class TestWithAModelOverride:
+    async def test_the_override_replaces_the_spools_preset(self, db_session, printer_factory):
+        printer = await printer_factory(model="H2C")
+        spool = await _spool(db_session)
+        db_session.add(
+            SpoolFilamentPreset(
+                spool_id=spool.id,
+                printer_model="H2C",
+                nozzle_diameter="",
+                slicer_filament="GFSA09",
+                slicer_filament_name="Bambu PLA Basic @BBL H2C",
+            )
+        )
+        await db_session.commit()
+
+        kwargs = await _assign(db_session, spool, printer, model="H2C")
+
+        assert kwargs["slicer_filament"] == "GFSA09"
+        assert kwargs["slicer_filament_name"] == "Bambu PLA Basic @BBL H2C"
+
+    async def test_a_different_model_still_gets_the_spools_preset(self, db_session, printer_factory):
+        """An override for the H2C must not follow the spool onto the X1C."""
+        printer = await printer_factory(model="X1C")
+        spool = await _spool(db_session)
+        db_session.add(
+            SpoolFilamentPreset(
+                spool_id=spool.id,
+                printer_model="H2C",
+                nozzle_diameter="",
+                slicer_filament="GFSA09",
+                slicer_filament_name="Bambu PLA Basic @BBL H2C",
+            )
+        )
+        await db_session.commit()
+
+        kwargs = await _assign(db_session, spool, printer, model="X1C")
+
+        assert kwargs["slicer_filament"] == SPOOL_DEFAULT
+
+
+class TestPerDiameterOverride:
+    async def test_the_slots_nozzle_diameter_selects_the_preset(self, db_session, printer_factory):
+        """The diameter comes from live printer state, so the same spool on
+        the same model resolves differently once a 0.2 nozzle is fitted."""
+        printer = await printer_factory(model="A1 mini")
+        spool = await _spool(db_session)
+        db_session.add_all(
+            [
+                SpoolFilamentPreset(
+                    spool_id=spool.id,
+                    printer_model="A1 mini",
+                    nozzle_diameter="",
+                    slicer_filament="GFSA20",
+                    slicer_filament_name="Bambu PLA Basic @BBL A1M",
+                ),
+                SpoolFilamentPreset(
+                    spool_id=spool.id,
+                    printer_model="A1 mini",
+                    nozzle_diameter="0.2",
+                    slicer_filament="GFSA21",
+                    slicer_filament_name="Bambu PLA Basic @BBL A1M 0.2 nozzle",
+                ),
+            ]
+        )
+        await db_session.commit()
+
+        on_04 = await _assign(db_session, spool, printer, model="A1 mini", diameter="0.4")
+        on_02 = await _assign(db_session, spool, printer, model="A1 mini", diameter="0.2")
+
+        assert on_04["slicer_filament"] == "GFSA20"
+        assert on_02["slicer_filament"] == "GFSA21"

+ 245 - 0
backend/tests/unit/test_rfid_assign_picks_the_right_hotend.py

@@ -0,0 +1,245 @@
+"""RFID auto-assign has to pick the K profile for the hotend the slot feeds.
+
+``auto_assign_spool`` runs unattended every time a Bambu spool is detected in a
+slot. It sends no ``ams_filament_setting`` -- the firmware already has the
+filament from the tag, and overwriting it turns the eye icon into a pen in
+Studio -- but it does select a K profile with ``extrusion_cali_sel``.
+
+It used to take the first stored row matching (printer, nozzle diameter) with
+no extruder test at all. On a dual-nozzle printer a spool calibrated on both
+hotends therefore had a coin toss decide which K value the slot got, and the
+losing side prints with the other nozzle's pressure advance.
+"""
+
+from __future__ import annotations
+
+from unittest.mock import MagicMock
+
+import pytest
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
+from sqlalchemy.orm import selectinload
+
+from backend.app.models.spool import Spool
+from backend.app.models.spool_k_profile import SpoolKProfile
+from backend.app.services.spool_tag_matcher import auto_assign_spool
+
+pytestmark = pytest.mark.asyncio
+
+RIGHT, LEFT = 0, 1
+
+
+class _Nozzle:
+    def __init__(self, diameter):
+        self.nozzle_diameter = diameter
+
+
+class _State:
+    """Dual-nozzle printer, AMS 0 on the left hotend and AMS 1 on the right."""
+
+    def __init__(self, diameters=("0.4", "0.4")):
+        self.nozzles = [_Nozzle(d) for d in diameters]
+        self.ams_extruder_map = {"0": LEFT, "1": RIGHT}
+        self.ams_switch_inlet = None
+        self.raw_data = {}
+
+
+def _manager(state, client):
+    manager = MagicMock()
+    manager.get_status = MagicMock(return_value=state)
+    manager.get_client = MagicMock(return_value=client)
+    manager.get_model = MagicMock(return_value="H2D")
+    return manager
+
+
+async def _spool_with_both_hotends(engine, printer_id, diameters=("0.4", "0.4")):
+    maker = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
+    async with maker() as db:
+        spool = Spool(brand="Bambu", material="PLA", color_name="Black", slicer_filament="GFSA00")
+        db.add(spool)
+        await db.commit()
+        await db.refresh(spool)
+
+        # Same spool, calibrated on both hotends -- the maintainer's H2C reads
+        # 0.018 left and 0.020 right for one black PLA.
+        db.add_all(
+            [
+                SpoolKProfile(
+                    spool_id=spool.id,
+                    printer_id=printer_id,
+                    extruder=RIGHT,
+                    nozzle_diameter=diameters[RIGHT],
+                    k_value=0.020,
+                    cali_idx=15,
+                ),
+                SpoolKProfile(
+                    spool_id=spool.id,
+                    printer_id=printer_id,
+                    extruder=LEFT,
+                    nozzle_diameter=diameters[LEFT],
+                    k_value=0.018,
+                    cali_idx=16,
+                ),
+            ]
+        )
+        await db.commit()
+    return maker, spool.id
+
+
+async def _load(maker, spool_id) -> Spool:
+    async with maker() as db:
+        result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool_id))
+        return result.scalar_one()
+
+
+async def _assign(maker, spool, printer, ams_id, state):
+    client = MagicMock()
+    async with maker() as db:
+        await auto_assign_spool(printer.id, ams_id, 0, spool, _manager(state, client), db, tray_info_idx="GFA00")
+    return client
+
+
+class TestWhichHotendsProfileIsSelected:
+    async def test_a_slot_on_the_left_gets_the_left_profile(self, test_engine, printer_factory):
+        printer = await printer_factory(model="H2D")
+        maker, spool_id = await _spool_with_both_hotends(test_engine, printer.id)
+        spool = await _load(maker, spool_id)
+
+        client = await _assign(maker, spool, printer, ams_id=0, state=_State())
+
+        client.extrusion_cali_sel.assert_called_once()
+        assert client.extrusion_cali_sel.call_args.kwargs["cali_idx"] == 16
+
+    async def test_a_slot_on_the_right_gets_the_right_profile(self, test_engine, printer_factory):
+        printer = await printer_factory(model="H2D")
+        maker, spool_id = await _spool_with_both_hotends(test_engine, printer.id)
+        spool = await _load(maker, spool_id)
+
+        client = await _assign(maker, spool, printer, ams_id=1, state=_State())
+
+        assert client.extrusion_cali_sel.call_args.kwargs["cali_idx"] == 15
+
+    async def test_the_slots_own_nozzle_size_decides_the_diameter(self, test_engine, printer_factory):
+        """0.4 right, 0.2 left: the left slot must look up 0.2 profiles, which
+        is what reading nozzles[0] for every slot got wrong."""
+        printer = await printer_factory(model="H2D")
+        maker, spool_id = await _spool_with_both_hotends(test_engine, printer.id, diameters=("0.4", "0.2"))
+        spool = await _load(maker, spool_id)
+
+        client = await _assign(maker, spool, printer, ams_id=0, state=_State(("0.4", "0.2")))
+
+        kwargs = client.extrusion_cali_sel.call_args.kwargs
+        assert kwargs["nozzle_diameter"] == "0.2"
+        assert kwargs["cali_idx"] == 16
+
+
+class TestTheFilamentIdSentWithTheSelection:
+    """extrusion_cali_sel carries a filament id so the printer can link the
+    calibration index to the slot. A cloud USER preset id (PFUS/PFCN) is not one
+    the slicer accepts -- and a per-model override can now BE such an id, since
+    picking your own cloud preset for a model stores exactly that."""
+
+    async def test_a_cloud_user_preset_override_is_not_sent_to_the_printer(self, test_engine, printer_factory):
+        from backend.app.models.spool_filament_preset import SpoolFilamentPreset
+
+        printer = await printer_factory(model="H2D")
+        maker, spool_id = await _spool_with_both_hotends(test_engine, printer.id)
+        async with maker() as db:
+            db.add(
+                SpoolFilamentPreset(
+                    spool_id=spool_id,
+                    printer_model="H2D",
+                    nozzle_diameter="0.4",
+                    slicer_filament="PFUS279c9bd2c689d5",
+                    slicer_filament_name="# Bambu PETG HF @BBL H2D 0.4 nozzle",
+                )
+            )
+            await db.commit()
+        spool = await _load(maker, spool_id)
+
+        client = await _assign(maker, spool, printer, ams_id=0, state=_State())
+
+        # Falls through to the spool's own preset rather than sending a value
+        # the printer rejects, which would silently lose the K-profile link.
+        assert client.extrusion_cali_sel.call_args.kwargs["filament_id"] == "GFSA00"
+
+    async def test_a_normal_override_is_sent(self, test_engine, printer_factory):
+        from backend.app.models.spool_filament_preset import SpoolFilamentPreset
+
+        printer = await printer_factory(model="H2D")
+        maker, spool_id = await _spool_with_both_hotends(test_engine, printer.id)
+        async with maker() as db:
+            db.add(
+                SpoolFilamentPreset(
+                    spool_id=spool_id,
+                    printer_model="H2D",
+                    nozzle_diameter="0.4",
+                    slicer_filament="GFSG02_15",
+                    slicer_filament_name="Bambu PETG HF @BBL H2D",
+                )
+            )
+            await db.commit()
+        spool = await _load(maker, spool_id)
+
+        client = await _assign(maker, spool, printer, ams_id=0, state=_State())
+
+        assert client.extrusion_cali_sel.call_args.kwargs["filament_id"] == "GFSG02_15"
+
+
+class TestFallbacks:
+    async def test_a_profile_for_the_other_hotend_is_better_than_none(self, test_engine, printer_factory):
+        """An operator who calibrated one side only should still get that
+        profile rather than nothing -- the fallback the old code had by
+        accident, kept deliberately."""
+        printer = await printer_factory(model="H2D")
+        maker = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
+        async with maker() as db:
+            spool = Spool(brand="Bambu", material="PLA", color_name="Black")
+            db.add(spool)
+            await db.commit()
+            await db.refresh(spool)
+            db.add(
+                SpoolKProfile(
+                    spool_id=spool.id,
+                    printer_id=printer.id,
+                    extruder=RIGHT,
+                    nozzle_diameter="0.4",
+                    k_value=0.020,
+                    cali_idx=15,
+                )
+            )
+            await db.commit()
+        loaded = await _load(maker, spool.id)
+
+        client = await _assign(maker, loaded, printer, ams_id=0, state=_State())
+
+        assert client.extrusion_cali_sel.call_args.kwargs["cali_idx"] == 15
+
+    async def test_a_profile_for_a_different_nozzle_size_is_not_used(self, test_engine, printer_factory):
+        """Diameter is not negotiable the way the hotend is: a K value measured
+        on a 0.6 says nothing about a 0.4."""
+        printer = await printer_factory(model="H2D")
+        maker = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
+        async with maker() as db:
+            spool = Spool(brand="Bambu", material="PLA", color_name="Black")
+            db.add(spool)
+            await db.commit()
+            await db.refresh(spool)
+            db.add(
+                SpoolKProfile(
+                    spool_id=spool.id,
+                    printer_id=printer.id,
+                    extruder=LEFT,
+                    nozzle_diameter="0.6",
+                    k_value=0.030,
+                    cali_idx=20,
+                )
+            )
+            await db.commit()
+        loaded = await _load(maker, spool.id)
+
+        client = await _assign(maker, loaded, printer, ams_id=0, state=_State())
+
+        # No stored profile for 0.4 -- nothing is selected from the store.
+        selected = [c for c in client.extrusion_cali_sel.call_args_list if c.kwargs.get("cali_idx") == 20]
+        assert selected == []

+ 120 - 0
backend/tests/unit/test_slot_nozzle_resolution.py

@@ -0,0 +1,120 @@
+"""Which nozzle an AMS slot feeds.
+
+Every path that configures a slot needs the extruder and that nozzle's
+diameter, and both the filament preset and the K profile are stored per
+diameter -- so a wrong answer here silently selects the wrong preset AND the
+wrong K value. Seven call sites used to work it out independently, each reading
+``nozzles[0]`` for every slot on the machine.
+
+``nozzles[0]`` is right on a single-nozzle printer, and right on a dual-nozzle
+printer with matching nozzles, which is why it survived. These pin the case it
+is wrong for: two different sizes fitted, which is the machine the per-nozzle
+feature exists for.
+"""
+
+from __future__ import annotations
+
+from backend.app.services.slot_nozzle import (
+    DEFAULT_NOZZLE_DIAMETER,
+    nozzle_diameter_for_extruder,
+    resolve_slot_nozzle,
+)
+
+
+class _Nozzle:
+    def __init__(self, diameter: str):
+        self.nozzle_diameter = diameter
+
+
+class _State:
+    def __init__(self, diameters, ams_extruder_map=None, ams_switch_inlet=None):
+        self.nozzles = [_Nozzle(d) for d in diameters]
+        self.ams_extruder_map = ams_extruder_map
+        self.ams_switch_inlet = ams_switch_inlet
+
+
+# extruder 0 is the RIGHT hotend, 1 the left.
+MIXED = ["0.4", "0.2"]
+
+
+class TestDualNozzleMixedDiameters:
+    """The case the old shortcut got wrong."""
+
+    def test_each_hotend_reports_its_own_diameter(self):
+        state = _State(MIXED)
+        assert nozzle_diameter_for_extruder(state, 0, "H2C") == "0.4"
+        assert nozzle_diameter_for_extruder(state, 1, "H2C") == "0.2"
+
+    def test_the_slots_extruder_decides_which_one(self):
+        # AMS 0 is bound to the left hotend, AMS 1 to the right.
+        state = _State(MIXED, ams_extruder_map={"0": 1, "1": 0})
+
+        left = resolve_slot_nozzle(state, 0, 0, "H2C")
+        right = resolve_slot_nozzle(state, 1, 0, "H2C")
+        assert (left.extruder, left.diameter) == (1, "0.2")
+        assert (right.extruder, right.diameter) == (0, "0.4")
+
+    def test_external_slots_name_their_side_by_tray_id(self):
+        # Ext-L is tray 0 -> extruder 1, Ext-R is tray 1 -> extruder 0.
+        state = _State(MIXED)
+        assert resolve_slot_nozzle(state, 255, 0, "H2D").diameter == "0.2"
+        assert resolve_slot_nozzle(state, 255, 1, "H2D").diameter == "0.4"
+
+    def test_an_fts_inlet_answers_when_there_is_no_extruder_map(self):
+        state = _State(MIXED, ams_switch_inlet={"0": "A"})
+        resolved = resolve_slot_nozzle(state, 0, 0, "H2C")
+        assert resolved.extruder is not None
+        assert resolved.diameter in {"0.4", "0.2"}
+
+
+class TestSingleNozzle:
+    """Must behave exactly as the old shortcut did."""
+
+    def test_index_zero_whatever_the_map_says(self):
+        # A single-nozzle model has one entry; an extruder id from a stale map
+        # must not index past it.
+        state = _State(["0.6"], ams_extruder_map={"0": 1})
+        assert nozzle_diameter_for_extruder(state, 1, "X1C") == "0.6"
+        assert resolve_slot_nozzle(state, 0, 0, "X1C").diameter == "0.6"
+
+    def test_no_map_means_unknown_extruder_not_zero(self):
+        # "I don't know" and "the right-hand nozzle" are different answers; the
+        # caller's own default of 0 is correct on a single-nozzle machine, but
+        # storing it as a fact is what bound a left K-profile to a right slot.
+        state = _State(["0.4"])
+        resolved = resolve_slot_nozzle(state, 0, 0, "X1C")
+        assert resolved.extruder is None
+        assert resolved.extruder_or_default == 0
+
+
+class TestMissingHardware:
+    """Called on every assign, so it must never raise."""
+
+    def test_no_state_at_all(self):
+        resolved = resolve_slot_nozzle(None, 0, 0, "H2C")
+        assert (resolved.extruder, resolved.diameter) == (None, DEFAULT_NOZZLE_DIAMETER)
+
+    def test_printer_has_reported_no_nozzles(self):
+        assert nozzle_diameter_for_extruder(_State([]), 0, "H2C") == DEFAULT_NOZZLE_DIAMETER
+
+    def test_the_second_hotend_is_absent_from_the_report(self):
+        # H2C parks a nozzle back in its rack and the entry can go missing;
+        # falling back to the other hotend beats returning nothing.
+        assert nozzle_diameter_for_extruder(_State(["0.4"]), 1, "H2C") == "0.4"
+
+    def test_a_blank_diameter_is_not_a_diameter(self):
+        # NozzleInfo starts life with an empty string before MQTT fills it in.
+        # Falling back to the OTHER hotend's size would invent a fact about a
+        # different nozzle, so a blank primary takes the default instead --
+        # which is what every call site did before this module existed.
+        assert nozzle_diameter_for_extruder(_State(["", "0.6"]), 0, "H2C") == DEFAULT_NOZZLE_DIAMETER
+        assert nozzle_diameter_for_extruder(_State(["", ""]), 0, "H2C") == DEFAULT_NOZZLE_DIAMETER
+
+
+class TestMatchingNozzles:
+    """The common fleet: both hotends the same size."""
+
+    def test_both_conventions_agree_so_the_answer_cannot_be_wrong(self):
+        state = _State(["0.4", "0.4"], ams_extruder_map={"0": 1})
+        assert nozzle_diameter_for_extruder(state, 0, "H2C") == "0.4"
+        assert nozzle_diameter_for_extruder(state, 1, "H2C") == "0.4"

+ 206 - 0
backend/tests/unit/test_spool_filament_preset_cascade.py

@@ -0,0 +1,206 @@
+"""The per-printer-model preset cascade.
+
+``Spool.slicer_filament`` holds one preset and is printer-agnostic by design.
+That breaks as soon as a spool is used on two printer models, because a cloud
+or Orca preset is bound to a model (``@BBL X1C``): the AMS slot on an H2C gets
+configured with a preset that machine has no profile for.
+
+``services.spool_filament_preset`` resolves, most specific first:
+
+    (model, diameter) -> (model, "") -> the spool's own slicer_filament
+
+These pin the resolution order itself, including the two cases that are easy
+to get backwards: a spool with no overrides must behave exactly as it did
+before the feature existed, and a stored row whose preset is blank is the
+user clearing the preset for that model, not a hole to fall through.
+"""
+
+from __future__ import annotations
+
+import pytest
+from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
+
+from backend.app.models.spool import Spool
+from backend.app.models.spool_filament_preset import SpoolFilamentPreset, SpoolmanFilamentPreset
+from backend.app.services.spool_filament_preset import resolve_spool_preset, resolve_spoolman_preset
+
+pytestmark = pytest.mark.asyncio
+
+DEFAULT = ("GFSA00", "Bambu PLA Basic @BBL X1C")
+
+
+async def _spool(engine) -> tuple[async_sessionmaker, int]:
+    maker = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
+    async with maker() as db:
+        spool = Spool(
+            brand="Bambu",
+            material="PLA",
+            color_name="Charcoal",
+            slicer_filament=DEFAULT[0],
+            slicer_filament_name=DEFAULT[1],
+        )
+        db.add(spool)
+        await db.commit()
+        await db.refresh(spool)
+        return maker, spool.id
+
+
+async def _resolve(maker, spool_id, model, diameter):
+    async with maker() as db:
+        return await resolve_spool_preset(
+            db,
+            spool_id=spool_id,
+            printer_model=model,
+            nozzle_diameter=diameter,
+            fallback_filament=DEFAULT[0],
+            fallback_name=DEFAULT[1],
+        )
+
+
+async def _add(maker, spool_id, model, diameter, code, name):
+    async with maker() as db:
+        db.add(
+            SpoolFilamentPreset(
+                spool_id=spool_id,
+                printer_model=model,
+                nozzle_diameter=diameter,
+                slicer_filament=code,
+                slicer_filament_name=name,
+            )
+        )
+        await db.commit()
+
+
+class TestNoOverrides:
+    """Every spool in every existing install is this case."""
+
+    async def test_falls_back_to_the_spools_own_preset(self, test_engine):
+        maker, spool_id = await _spool(test_engine)
+        assert await _resolve(maker, spool_id, "H2C", "0.4") == DEFAULT
+
+    async def test_an_unknown_model_falls_back(self, test_engine):
+        maker, spool_id = await _spool(test_engine)
+        await _add(maker, spool_id, "X1C", "", "GFSA01", "PLA @X1C")
+        assert await _resolve(maker, spool_id, "P1S", "0.4") == DEFAULT
+
+    async def test_no_model_at_all_falls_back(self, test_engine):
+        """A printer that has not reported its model yet cannot be more
+        specific than the spool itself -- it must not match some other row."""
+        maker, spool_id = await _spool(test_engine)
+        await _add(maker, spool_id, "X1C", "", "GFSA01", "PLA @X1C")
+        assert await _resolve(maker, spool_id, None, "0.4") == DEFAULT
+        assert await _resolve(maker, spool_id, "", "0.4") == DEFAULT
+
+
+class TestModelDefault:
+    """Diameter "" = any nozzle of the model.
+
+    The spool form writes one row per nozzle size and never this one, but the
+    API accepts it and it has to keep resolving -- these pin that level of the
+    cascade so a client using it does not break silently.
+    """
+
+    async def test_model_row_wins_over_the_spool_default(self, test_engine):
+        maker, spool_id = await _spool(test_engine)
+        await _add(maker, spool_id, "H2C", "", "GFSA09", "Bambu PLA Basic @BBL H2C")
+        assert await _resolve(maker, spool_id, "H2C", "0.4") == ("GFSA09", "Bambu PLA Basic @BBL H2C")
+
+    async def test_it_applies_to_every_nozzle_of_that_model(self, test_engine):
+        maker, spool_id = await _spool(test_engine)
+        await _add(maker, spool_id, "H2C", "", "GFSA09", "Bambu PLA Basic @BBL H2C")
+        for diameter in ("0.2", "0.4", "0.6", "0.8", ""):
+            assert (await _resolve(maker, spool_id, "H2C", diameter))[0] == "GFSA09", diameter
+
+    async def test_other_models_are_untouched(self, test_engine):
+        maker, spool_id = await _spool(test_engine)
+        await _add(maker, spool_id, "H2C", "", "GFSA09", "Bambu PLA Basic @BBL H2C")
+        await _add(maker, spool_id, "A1 mini", "", "GFSA20", "Bambu PLA Basic @BBL A1M")
+        assert (await _resolve(maker, spool_id, "H2C", "0.4"))[0] == "GFSA09"
+        assert (await _resolve(maker, spool_id, "A1 mini", "0.4"))[0] == "GFSA20"
+        assert (await _resolve(maker, spool_id, "X1C", "0.4"))[0] == DEFAULT[0]
+
+
+class TestPerDiameterException:
+    """Why diameter is in the key at all: the preset lands on an AMS slot and
+    a slot feeds exactly one nozzle, so a machine with two diameters fitted
+    needs two answers for one model."""
+
+    async def test_exact_diameter_beats_the_model_default(self, test_engine):
+        maker, spool_id = await _spool(test_engine)
+        await _add(maker, spool_id, "A1 mini", "", "GFSA20", "Bambu PLA Basic @BBL A1M")
+        await _add(maker, spool_id, "A1 mini", "0.2", "GFSA21", "Bambu PLA Basic @BBL A1M 0.2 nozzle")
+
+        assert (await _resolve(maker, spool_id, "A1 mini", "0.2"))[0] == "GFSA21"
+        assert (await _resolve(maker, spool_id, "A1 mini", "0.4"))[0] == "GFSA20"
+
+    async def test_a_diameter_row_alone_still_leaves_other_nozzles_on_the_default(self, test_engine):
+        maker, spool_id = await _spool(test_engine)
+        await _add(maker, spool_id, "A1 mini", "0.2", "GFSA21", "Bambu PLA Basic @BBL A1M 0.2 nozzle")
+
+        assert (await _resolve(maker, spool_id, "A1 mini", "0.2"))[0] == "GFSA21"
+        assert (await _resolve(maker, spool_id, "A1 mini", "0.4"))[0] == DEFAULT[0]
+
+    async def test_the_two_hotends_of_one_machine_resolve_differently(self, test_engine):
+        """The case that put diameter in the key: 0.4 on one hotend, 0.2 on
+        the other, one model, one spool."""
+        maker, spool_id = await _spool(test_engine)
+        await _add(maker, spool_id, "H2C", "0.4", "GFSA09", "Bambu PLA Basic @BBL H2C")
+        await _add(maker, spool_id, "H2C", "0.2", "GFSA10", "Bambu PLA Basic @BBL H2C 0.2 nozzle")
+
+        assert (await _resolve(maker, spool_id, "H2C", "0.4"))[0] == "GFSA09"
+        assert (await _resolve(maker, spool_id, "H2C", "0.2"))[0] == "GFSA10"
+
+
+class TestClearedPreset:
+    async def test_a_blank_row_means_none_not_fall_through(self, test_engine):
+        """The user picking the empty entry for a model is a decision. Falling
+        back to the spool's preset would silently reinstate what they cleared,
+        and it is the spool's preset that is wrong on that model."""
+        maker, spool_id = await _spool(test_engine)
+        await _add(maker, spool_id, "H2C", "", None, None)
+        assert await _resolve(maker, spool_id, "H2C", "0.4") == (None, None)
+
+
+class TestSpoolmanFlavour:
+    """Spoolman spools live in Spoolman; the override is Bambuddy's, keyed by
+    the remote id. Same cascade -- the two inventory modes must not drift."""
+
+    async def test_same_cascade(self, test_engine):
+        maker = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
+        async with maker() as db:
+            db.add(
+                SpoolmanFilamentPreset(
+                    spoolman_spool_id=7,
+                    printer_model="H2C",
+                    nozzle_diameter="",
+                    slicer_filament="GFSA09",
+                    slicer_filament_name="Bambu PLA Basic @BBL H2C",
+                )
+            )
+            db.add(
+                SpoolmanFilamentPreset(
+                    spoolman_spool_id=7,
+                    printer_model="H2C",
+                    nozzle_diameter="0.2",
+                    slicer_filament="GFSA10",
+                    slicer_filament_name="Bambu PLA Basic @BBL H2C 0.2 nozzle",
+                )
+            )
+            await db.commit()
+
+        async def resolve(model, diameter, spool_id=7):
+            async with maker() as db:
+                return await resolve_spoolman_preset(
+                    db,
+                    spoolman_spool_id=spool_id,
+                    printer_model=model,
+                    nozzle_diameter=diameter,
+                    fallback_filament=DEFAULT[0],
+                    fallback_name=DEFAULT[1],
+                )
+
+        assert (await resolve("H2C", "0.4"))[0] == "GFSA09"
+        assert (await resolve("H2C", "0.2"))[0] == "GFSA10"
+        assert (await resolve("X1C", "0.4"))[0] == DEFAULT[0]
+        # Another spool's rows must not leak into this one.
+        assert (await resolve("H2C", "0.4", spool_id=8))[0] == DEFAULT[0]

+ 1 - 0
frontend/scripts/check-i18n-parity.mjs

@@ -421,6 +421,7 @@ const UK_COGNATES = [
 // are used untranslated by Dutch slicer users. Each entry below was
 // checked individually against the Dutch translation in #2891.
 const NL_COGNATES = [
+  '1 printer', '{{n}} printers',
   '1 week', '(25%, 50%, 75%)', 'Accent', 'AMS Filament Backup',
   '{{ams}} Slot {{slot}}', 'Auto', 'Auto Home', 'Bambu Cloud',
   'Batch', 'Batches', 'Branch', 'Budget',

+ 632 - 0
frontend/src/__tests__/components/PrinterProfilesSection.test.tsx

@@ -0,0 +1,632 @@
+/**
+ * The spool form's Printers tab.
+ *
+ * Two things are keyed differently and the UI has to keep them apart: the
+ * filament preset belongs to the printer MODEL (an "@BBL X1C" preset is the
+ * same preset on every X1C), while a K profile is measured on one individual
+ * hotend and stays per printer, per extruder, per nozzle diameter.
+ *
+ * The layout is a model list plus a detail pane so that fleet size stops
+ * mattering -- these drive it with a deliberately mixed fleet: two machines of
+ * one model, a dual-nozzle machine with two different diameters fitted, and an
+ * offline printer.
+ */
+
+import React from 'react';
+import { describe, it, expect, vi } from 'vitest';
+import { screen, fireEvent, within } from '@testing-library/react';
+import { render } from '../utils';
+import { PrinterProfilesSection } from '../../components/spool-form/PrinterProfilesSection';
+import { presetKey, hotendKey } from '../../components/spool-form/utils';
+import { defaultFormData } from '../../components/spool-form/types';
+import type {
+  CalibrationProfile,
+  FilamentOption,
+  PresetChoice,
+  PrinterWithCalibrations,
+} from '../../components/spool-form/types';
+
+const OPTIONS: FilamentOption[] = [
+  { code: 'GFSA00', name: 'Bambu PLA Basic @BBL X1C', displayName: 'Bambu PLA Basic @BBL X1C', isCustom: false, allCodes: ['GFSA00'], source: 'cloud' },
+  { code: 'GFSA09', name: 'Bambu PLA Basic @BBL H2C', displayName: 'Bambu PLA Basic @BBL H2C', isCustom: false, allCodes: ['GFSA09'], source: 'cloud' },
+  { code: 'GFSA21', name: 'Bambu PLA Basic @BBL H2C 0.2 nozzle', displayName: 'Bambu PLA Basic @BBL H2C 0.2 nozzle', isCustom: false, allCodes: ['GFSA21'], source: 'orca_cloud' },
+  // Names no model at all -- the shape most user-authored and Orca presets
+  // have. Must stay offered everywhere: hiding what cannot be classified
+  // would hide most third-party profiles.
+  { code: 'LOCAL1', name: 'eSUN PETG Basic', displayName: 'eSUN PETG Basic (Local)', isCustom: true, allCodes: ['LOCAL1'], source: 'local' },
+];
+
+/** The backend registry shape: "Bambu Lab <long>" -> short code. */
+const PRINTER_MODELS: Record<string, string> = {
+  'Bambu Lab X1 Carbon': 'X1C',
+  'Bambu Lab H2C': 'H2C',
+  'Bambu Lab P1S': 'P1S',
+  'Bambu Lab H2D': 'H2D',
+};
+
+function cal(overrides: Partial<CalibrationProfile>): CalibrationProfile {
+  return {
+    cali_idx: 1,
+    filament_id: 'GFL99',
+    setting_id: 'GFSL99',
+    name: 'PLA Basic',
+    k_value: 0.02,
+    n_coef: 1.0,
+    extruder_id: 0,
+    nozzle_diameter: '0.4',
+    ...overrides,
+  };
+}
+
+function printer(
+  id: number,
+  name: string,
+  model: string | null,
+  opts: {
+    connected?: boolean;
+    nozzleCount?: number;
+    calibrations?: CalibrationProfile[];
+    nozzles?: { nozzle_diameter?: string }[];
+  } = {},
+): PrinterWithCalibrations {
+  return {
+    printer: {
+      id,
+      name,
+      model,
+      connected: opts.connected ?? true,
+      nozzle_count: opts.nozzleCount ?? 1,
+    } as PrinterWithCalibrations['printer'],
+    calibrations: opts.calibrations ?? [cal({})],
+    nozzles: opts.nozzles,
+  };
+}
+
+/** Two X1Cs, one dual-nozzle H2C with 0.4 + 0.2 fitted, one offline P1S. */
+function fleet(): PrinterWithCalibrations[] {
+  return [
+    printer(1, 'X1C-1', 'X1C'),
+    printer(2, 'X1C-2', 'X1C', { calibrations: [cal({ cali_idx: 2, k_value: 0.019 })] }),
+    printer(3, 'H2C-1', 'H2C', {
+      nozzleCount: 2,
+      nozzles: [{ nozzle_diameter: '0.4' }, { nozzle_diameter: '0.2' }],
+      calibrations: [
+        cal({ cali_idx: 3, extruder_id: 0, nozzle_diameter: '0.4', k_value: 0.021 }),
+        cal({ cali_idx: 16, extruder_id: 1, nozzle_diameter: '0.2', k_value: 0.014, name: 'PLA 0.2' }),
+      ],
+    }),
+    printer(4, 'P1S-1', 'P1S', { connected: false }),
+  ];
+}
+
+interface HarnessProps {
+  printers?: PrinterWithCalibrations[];
+  presets?: Map<string, PresetChoice>;
+  profiles?: Map<string, CalibrationProfile>;
+  onPresets?: (next: Map<string, PresetChoice>) => void;
+  onProfiles?: (next: Map<string, CalibrationProfile>) => void;
+  slicerFilament?: string;
+}
+
+/**
+ * Renders the section with real state, so a click is asserted through the
+ * component's own update path rather than against a spy on the setter.
+ */
+function Harness({
+  printers = fleet(),
+  presets = new Map(),
+  profiles = new Map(),
+  onPresets,
+  onProfiles,
+  slicerFilament = 'GFSA00',
+}: HarnessProps) {
+  const [modelPresets, setModelPresets] = React.useState(presets);
+  const [selectedProfiles, setSelectedProfiles] = React.useState(profiles);
+  const [selectedGroupId, setSelectedGroupId] = React.useState('');
+
+  React.useEffect(() => {
+    onPresets?.(modelPresets);
+  }, [modelPresets, onPresets]);
+  React.useEffect(() => {
+    onProfiles?.(selectedProfiles);
+  }, [selectedProfiles, onProfiles]);
+
+  return (
+    <PrinterProfilesSection
+      formData={{ ...defaultFormData, material: 'PLA', brand: 'Bambu', slicer_filament: slicerFilament }}
+      printersWithCalibrations={printers}
+      filamentOptions={OPTIONS}
+      modelPresets={modelPresets}
+      setModelPresets={setModelPresets}
+      selectedProfiles={selectedProfiles}
+      setSelectedProfiles={setSelectedProfiles}
+      selectedGroupId={selectedGroupId}
+      setSelectedGroupId={setSelectedGroupId}
+      printerModels={PRINTER_MODELS}
+    />
+  );
+}
+
+/**
+ * The preset pickers in the detail pane, in render order.
+ *
+ * Each is a button labelled "<model> <size>mm Filament preset" -- not a native
+ * <select>, because every option carries an origin badge (Bambu Cloud / Orca
+ * Cloud / Local / Built-in) and a <select> cannot render one.
+ */
+function presetPickers(): HTMLElement[] {
+  return screen.getAllByRole('button', { name: /Filament preset$/ });
+}
+
+function presetPicker(model: string, diameter: string): HTMLElement {
+  return screen.getByRole('button', { name: `${model} ${diameter}mm Filament preset` });
+}
+
+/** Open one picker and return the options it is offering. */
+function openPicker(picker: HTMLElement): HTMLElement[] {
+  fireEvent.click(picker);
+  return screen.getAllByRole('option');
+}
+
+function optionNamed(pattern: RegExp | string): HTMLElement {
+  return screen.getByRole('option', { name: pattern });
+}
+
+/**
+ * A model's row in the left rail. By role: the model name also appears as the
+ * detail pane's heading, and the pane is full of other buttons.
+ */
+function modelRow(model: string): HTMLElement {
+  return screen.getByRole('tab', { name: new RegExp(`^\\s*${model}\\b`) });
+}
+
+/** The detail pane's heading, i.e. which model is currently open. */
+function openModel(): string {
+  return screen.getByRole('heading').textContent ?? '';
+}
+
+describe('PrinterProfilesSection — spool identity', () => {
+  it('names the spool being configured, colour included', () => {
+    // This tab is the one place you read printer names rather than filament,
+    // and the K lists below are filtered by exactly these fields -- so the line
+    // also explains an empty list.
+    const Named = () => {
+      const [presets, setPresets] = React.useState(new Map<string, PresetChoice>());
+      const [profiles, setProfiles] = React.useState(new Map<string, CalibrationProfile>());
+      const [group, setGroup] = React.useState('');
+      return (
+        <PrinterProfilesSection
+          formData={{
+            ...defaultFormData,
+            brand: 'Bambu',
+            material: 'PLA',
+            subtype: 'Matte',
+            color_name: 'Scarlet Red',
+            rgba: 'DE4343FF',
+          }}
+          printersWithCalibrations={fleet()}
+          filamentOptions={OPTIONS}
+          modelPresets={presets}
+          setModelPresets={setPresets}
+          selectedProfiles={profiles}
+          setSelectedProfiles={setProfiles}
+          selectedGroupId={group}
+          setSelectedGroupId={setGroup}
+        />
+      );
+    };
+    render(<Named />);
+    expect(screen.getByText('Bambu PLA Matte')).toBeInTheDocument();
+    expect(screen.getByText('Scarlet Red')).toBeInTheDocument();
+  });
+
+  it('renders no identity bar at all when the spool has nothing to name yet', () => {
+    const Blank = () => {
+      const [presets, setPresets] = React.useState(new Map<string, PresetChoice>());
+      const [profiles, setProfiles] = React.useState(new Map<string, CalibrationProfile>());
+      const [group, setGroup] = React.useState('');
+      return (
+        <PrinterProfilesSection
+          formData={{ ...defaultFormData, brand: '', material: '', subtype: '', color_name: '' }}
+          printersWithCalibrations={fleet()}
+          filamentOptions={OPTIONS}
+          modelPresets={presets}
+          setModelPresets={setPresets}
+          selectedProfiles={profiles}
+          setSelectedProfiles={setProfiles}
+          selectedGroupId={group}
+          setSelectedGroupId={setGroup}
+        />
+      );
+    };
+    render(<Blank />);
+    // An empty bar, or one repeating the K section's "select a material first",
+    // would be noise -- the K section already says it once.
+    expect(screen.getAllByText(/select a material first/i)).toHaveLength(1);
+  });
+});
+
+describe('PrinterProfilesSection — model list', () => {
+  it('lists each model once, however many machines it has', () => {
+    render(<Harness />);
+    expect(modelRow('X1C')).toBeInTheDocument();
+    expect(modelRow('H2C')).toBeInTheDocument();
+    expect(modelRow('P1S')).toBeInTheDocument();
+    // Two X1Cs, one row -- the rail is per model, not per machine.
+    expect(within(modelRow('X1C')).getByText('2 printers')).toBeInTheDocument();
+  });
+
+  it('shows the first model by default and switches on click', () => {
+    render(<Harness />);
+    // Alphabetical: H2C first. Its machine is named in the detail pane.
+    expect(openModel()).toBe('H2C');
+    // Named twice on purpose: once in the pane subtitle, once on its own card.
+    expect(screen.getAllByText('H2C-1').length).toBeGreaterThan(0);
+
+    fireEvent.click(modelRow('X1C'));
+    expect(openModel()).toBe('X1C');
+    expect(screen.getByText('X1C-1, X1C-2')).toBeInTheDocument();
+  });
+
+  it('counts hotends with no K profile chosen', () => {
+    render(<Harness />);
+    // H2C has two hotends and nothing chosen yet.
+    expect(within(modelRow('H2C')).getByText('2')).toBeInTheDocument();
+  });
+});
+
+describe('PrinterProfilesSection — filament preset', () => {
+  it('starts inherited and stores an override when one is picked', () => {
+    let latest: Map<string, PresetChoice> = new Map();
+    render(<Harness onPresets={next => { latest = next; }} />);
+
+    // One badge per preset row -- one row per nozzle size.
+    expect(screen.getAllByText('inherited')).toHaveLength(presetPickers().length);
+
+    fireEvent.click(presetPicker('H2C', '0.4'));
+    fireEvent.click(optionNamed('Bambu PLA Basic @BBL H2C Bambu Cloud'));
+
+    expect(latest.get(presetKey('H2C', '0.4'))).toEqual({
+      code: 'GFSA09',
+      name: 'Bambu PLA Basic @BBL H2C',
+    });
+    expect(screen.getByText('override')).toBeInTheDocument();
+  });
+
+  it('clearing an override removes the row rather than storing the spool value', () => {
+    // A row repeating the spool's own preset would freeze it: later edits to
+    // the spool preset would stop reaching this model. Absent means inherit.
+    let latest: Map<string, PresetChoice> = new Map();
+    render(
+      <Harness
+        presets={new Map([[presetKey('H2C', '0.4'), { code: 'GFSA09', name: 'Bambu PLA Basic @BBL H2C' }]])}
+        onPresets={next => { latest = next; }}
+      />,
+    );
+
+    fireEvent.click(presetPicker('H2C', '0.4'));
+    fireEvent.click(optionNamed(/use the spool's preset/i));
+
+    expect(latest.has(presetKey('H2C', '0.4'))).toBe(false);
+  });
+
+  it('offers one preset row per nozzle size and nothing above them', () => {
+    render(<Harness />);
+    // Every standard size, not only the ones fitted: a spool is configured
+    // once and nozzles get swapped. No model-wide row -- the preset lands on
+    // an AMS slot and a slot feeds exactly one nozzle.
+    expect(presetPickers()).toHaveLength(4);
+    // (Each size also labels a K-profile grid row, hence getAll.)
+    expect(screen.getAllByText('0.4mm').length).toBeGreaterThan(0);
+    expect(screen.getAllByText('0.2mm').length).toBeGreaterThan(0);
+
+    // Same for a model with a single diameter fitted across both machines.
+    fireEvent.click(modelRow('X1C'));
+    expect(presetPickers()).toHaveLength(4);
+  });
+
+  it('a pick is stored under the size it was made on', () => {
+    let latest: Map<string, PresetChoice> = new Map();
+    render(<Harness onPresets={next => { latest = next; }} />);
+
+    fireEvent.click(presetPicker('H2C', '0.2'));
+    fireEvent.click(optionNamed(/0\.2 nozzle/));
+
+    expect(latest.get(presetKey('H2C', '0.2'))?.code).toBe('GFSA21');
+    // Only that size -- the other rows are untouched.
+    expect(latest.size).toBe(1);
+  });
+
+  it('refuses to offer a preset for a printer whose model is unknown', () => {
+    render(<Harness printers={[printer(9, 'Mystery', null)]} />);
+    expect(screen.getByText(/has not reported its model/i)).toBeInTheDocument();
+  });
+});
+
+describe('PrinterProfilesSection — presets offered per model', () => {
+  function optionNames(picker: HTMLElement): string[] {
+    return openPicker(picker).map(o => o.textContent ?? '');
+  }
+
+  it('offers a model only the presets that name it', () => {
+    render(<Harness />);
+    const names = optionNames(presetPicker('H2C', '0.4'));
+
+    expect(names.some(n => n.includes('Bambu PLA Basic @BBL H2C'))).toBe(true);
+    expect(names.some(n => n.includes('Bambu PLA Basic @BBL X1C'))).toBe(false);
+  });
+
+  it('keeps presets whose model cannot be read from the name', () => {
+    render(<Harness />);
+    const names = optionNames(presetPicker('H2C', '0.4'));
+    expect(names.some(n => n.includes('eSUN PETG Basic (Local)'))).toBe(true);
+  });
+
+  it('switching model switches which presets are offered', () => {
+    render(<Harness />);
+    fireEvent.click(modelRow('X1C'));
+    const names = optionNames(presetPicker('X1C', '0.4'));
+    expect(names.some(n => n.includes('Bambu PLA Basic @BBL X1C'))).toBe(true);
+    expect(names.some(n => n.includes('Bambu PLA Basic @BBL H2C'))).toBe(false);
+  });
+
+  it('never hides an override that is already saved', () => {
+    // The stored value may name another model -- picked before the filter
+    // existed, or by hand. Dropping it from the list would blank the control
+    // and quietly change what gets saved.
+    render(
+      <Harness
+        presets={new Map([[presetKey('X1C', '0.4'), { code: 'GFSA09', name: 'Bambu PLA Basic @BBL H2C' }]])}
+      />,
+    );
+    fireEvent.click(modelRow('X1C'));
+    const picker = presetPicker('X1C', '0.4');
+    expect(picker.textContent).toContain('Bambu PLA Basic @BBL H2C');
+    expect(optionNames(picker).some(n => n.includes('Bambu PLA Basic @BBL H2C'))).toBe(true);
+  });
+
+  it('badges each preset with the source it came from', () => {
+    // The same filament exists as a cloud preset, an imported one and a
+    // built-in, and which one is picked decides what reaches the printer --
+    // so the origin is shown here exactly as the Configure AMS Slot modal
+    // shows it.
+    render(<Harness />);
+    const options = openPicker(presetPicker('H2C', '0.4'));
+
+    const cloud = options.find(o => o.textContent?.includes('@BBL H2C'));
+    expect(cloud?.textContent).toContain('Bambu Cloud');
+    const local = options.find(o => o.textContent?.includes('eSUN PETG Basic'));
+    expect(local?.textContent).toContain('Local');
+  });
+
+  it('filters the list as you type', () => {
+    render(<Harness />);
+    fireEvent.click(presetPicker('H2C', '0.4'));
+    fireEvent.change(screen.getByPlaceholderText(/search filament presets/i), {
+      target: { value: 'esun' },
+    });
+
+    const names = screen.getAllByRole('option').map(o => o.textContent ?? '');
+    expect(names.some(n => n.includes('eSUN'))).toBe(true);
+    expect(names.some(n => n.includes('Bambu PLA Basic'))).toBe(false);
+  });
+});
+
+describe('PrinterProfilesSection — every nozzle size', () => {
+  it('offers a preset row for all four standard sizes, fitted or not', () => {
+    // A spool is configured once and nozzles get swapped. X1C has only a 0.4
+    // fitted, and must still offer 0.2 / 0.6 / 0.8.
+    render(<Harness />);
+    fireEvent.click(modelRow('X1C'));
+
+    expect(presetPickers()).toHaveLength(4);
+    for (const size of ['0.2mm', '0.4mm', '0.6mm', '0.8mm']) {
+      expect(screen.getAllByText(size).length).toBeGreaterThan(0);
+    }
+  });
+
+  it('stores a preset for a size that is not currently fitted', () => {
+    let latest: Map<string, PresetChoice> = new Map();
+    render(<Harness onPresets={next => { latest = next; }} />);
+    fireEvent.click(modelRow('X1C'));
+
+    fireEvent.click(presetPicker('X1C', '0.6'));
+    fireEvent.click(optionNamed('Bambu PLA Basic @BBL X1C Bambu Cloud'));
+
+    expect(latest.get(presetKey('X1C', '0.6'))?.code).toBe('GFSA00');
+  });
+
+  it('offers a K profile for a size the printer has profiles for but has not fitted', () => {
+    // fetchPrinterCalibrations now asks for every standard size, so a 0.6
+    // profile reaches the picker without a 0.6 being screwed in.
+    let latest: Map<string, CalibrationProfile> = new Map();
+    const withSpare = [
+      printer(1, 'X1C-1', 'X1C', {
+        nozzles: [{ nozzle_diameter: '0.4' }],
+        calibrations: [
+          cal({ cali_idx: 1, nozzle_diameter: '0.4' }),
+          cal({ cali_idx: 7, nozzle_diameter: '0.6', k_value: 0.026, name: 'PLA 0.6' }),
+        ],
+      }),
+    ];
+    render(<Harness printers={withSpare} onProfiles={next => { latest = next; }} />);
+
+    fireEvent.change(screen.getByLabelText('X1C-1 Nozzle 0.6mm'), { target: { value: '7' } });
+    expect(latest.get(hotendKey(1, 0, '0.6'))?.cali_idx).toBe(7);
+  });
+});
+
+describe('PrinterProfilesSection — auto-match', () => {
+  it('fills every nozzle size of each model, preferring the variant for that size', () => {
+    let latest: Map<string, PresetChoice> = new Map();
+    render(<Harness onPresets={next => { latest = next; }} />);
+
+    fireEvent.click(screen.getByRole('button', { name: /auto-match/i }));
+
+    // The H2C list holds a plain "@BBL H2C" and an "@BBL H2C 0.2 nozzle". The
+    // 0.2 row takes the sized variant; the rest take the unsized one.
+    expect(latest.get(presetKey('H2C', '0.2'))?.code).toBe('GFSA21');
+    expect(latest.get(presetKey('H2C', '0.4'))?.code).toBe('GFSA09');
+    expect(latest.get(presetKey('H2C', '0.8'))?.code).toBe('GFSA09');
+    // Nothing is written to the model-wide key -- there is no row that shows
+    // it, and a stored value nobody can see is a value nobody can undo.
+    expect(latest.has(presetKey('H2C', ''))).toBe(false);
+  });
+
+  it('leaves a model with no matching variant inherited', () => {
+    let latest: Map<string, PresetChoice> = new Map();
+    render(<Harness onPresets={next => { latest = next; }} />);
+
+    fireEvent.click(screen.getByRole('button', { name: /auto-match/i }));
+
+    // P1S has no preset of this filament in the list, and an approximate one
+    // is worse than falling back to the spool's own.
+    for (const size of ['', '0.2', '0.4', '0.6', '0.8']) {
+      expect(latest.has(presetKey('P1S', size))).toBe(false);
+    }
+  });
+});
+
+describe('PrinterProfilesSection — K profiles', () => {
+  it('keys a chosen profile by printer, extruder and diameter', () => {
+    let latest: Map<string, CalibrationProfile> = new Map();
+    render(<Harness onProfiles={next => { latest = next; }} />);
+
+    // Addressed by cell rather than by DOM order: the grid is size down the
+    // side and hotend across, and only cells the printer has a calibration for
+    // hold a dropdown at all.
+    fireEvent.change(screen.getByLabelText('H2C-1 Left Nozzle 0.2mm'), {
+      target: { value: '16' },
+    });
+
+    // Extruder 1 at 0.2mm -- not the 0.4 hotend, and not keyed by cali_idx,
+    // which is numbered per nozzle and repeats across hotends.
+    expect(latest.get(hotendKey(3, 1, '0.2'))?.cali_idx).toBe(16);
+    expect(latest.has(hotendKey(3, 0, '0.4'))).toBe(false);
+  });
+
+  it('lays the grid out as nozzle size by hotend, with a dash where the printer has nothing', () => {
+    render(<Harness />);
+
+    // The H2C has a 0.2 profile on the left hotend and a 0.4 on the right.
+    expect(screen.getByLabelText('H2C-1 Left Nozzle 0.2mm')).toBeInTheDocument();
+    expect(screen.getByLabelText('H2C-1 Right Nozzle 0.4mm')).toBeInTheDocument();
+    // The other six cells have no calibration to offer, so no control.
+    expect(screen.queryByLabelText('H2C-1 Left Nozzle 0.4mm')).not.toBeInTheDocument();
+    expect(screen.queryByLabelText('H2C-1 Right Nozzle 0.8mm')).not.toBeInTheDocument();
+    // Every size is still listed down the side rather than looking forgotten.
+    for (const size of ['0.2mm', '0.4mm', '0.6mm', '0.8mm']) {
+      expect(screen.getAllByText(size).length).toBeGreaterThan(0);
+    }
+  });
+
+  it('gives a single-nozzle machine one unnamed column', () => {
+    render(<Harness />);
+    fireEvent.click(modelRow('X1C'));
+
+    expect(screen.getByLabelText('X1C-1 Nozzle 0.4mm')).toBeInTheDocument();
+    expect(screen.queryByLabelText(/X1C-1 (Left|Right) Nozzle/)).not.toBeInTheDocument();
+  });
+
+  it('choosing again on one hotend replaces rather than accumulates', () => {
+    let latest: Map<string, CalibrationProfile> = new Map();
+    render(<Harness onProfiles={next => { latest = next; }} />);
+
+    fireEvent.click(modelRow('X1C'));
+    const kSelect = screen.getByLabelText('X1C-1 Nozzle 0.4mm');
+
+    fireEvent.change(kSelect, { target: { value: '1' } });
+    fireEvent.change(kSelect, { target: { value: '' } });
+
+    expect(latest.size).toBe(0);
+  });
+
+  it('says an offline printer cannot be configured instead of showing empty rows', () => {
+    render(<Harness />);
+    fireEvent.click(modelRow('P1S'));
+    expect(screen.getByText(/printer is offline/i)).toBeInTheDocument();
+  });
+
+  it('labels the hotends of a dual-nozzle machine by side', () => {
+    render(<Harness />);
+    expect(screen.getByText('Right Nozzle')).toBeInTheDocument();
+    expect(screen.getByText('Left Nozzle')).toBeInTheDocument();
+
+    // A single-nozzle machine has no side to name.
+    fireEvent.click(modelRow('X1C'));
+    expect(screen.queryByText('Right Nozzle')).not.toBeInTheDocument();
+    expect(screen.getAllByText('Nozzle').length).toBeGreaterThan(0);
+  });
+});
+
+describe('PrinterProfilesSection — empty fleet', () => {
+  it('says so rather than rendering an empty two-pane layout', () => {
+    render(<Harness printers={[]} />);
+    expect(screen.getByText(/no printers configured/i)).toBeInTheDocument();
+  });
+
+  it('waits rather than claiming there are no printers while still loading', () => {
+    // Reading each printer's calibration table is several MQTT round trips, so
+    // this gap is seconds. Saying "no printers configured" during it is a wrong
+    // answer about the user's setup, not a slow one.
+    const Loading = () => {
+      const [presets, setPresets] = React.useState(new Map<string, PresetChoice>());
+      const [profiles, setProfiles] = React.useState(new Map<string, CalibrationProfile>());
+      const [group, setGroup] = React.useState('');
+      return (
+        <PrinterProfilesSection
+          formData={{ ...defaultFormData, material: 'PLA' }}
+          printersWithCalibrations={[]}
+          filamentOptions={OPTIONS}
+          modelPresets={presets}
+          setModelPresets={setPresets}
+          selectedProfiles={profiles}
+          setSelectedProfiles={setProfiles}
+          selectedGroupId={group}
+          setSelectedGroupId={setGroup}
+          isLoading
+        />
+      );
+    };
+    render(<Loading />);
+    expect(screen.queryByText(/no printers configured/i)).not.toBeInTheDocument();
+    expect(screen.getByText(/loading/i)).toBeInTheDocument();
+  });
+});
+
+describe('PrinterProfilesSection — no material yet', () => {
+  it('asks for a material before offering K profiles', () => {
+    const Bare = () => {
+      const [presets, setPresets] = React.useState(new Map<string, PresetChoice>());
+      const [profiles, setProfiles] = React.useState(new Map<string, CalibrationProfile>());
+      const [group, setGroup] = React.useState('');
+      return (
+        <PrinterProfilesSection
+          formData={{ ...defaultFormData, material: '' }}
+          printersWithCalibrations={fleet()}
+          filamentOptions={OPTIONS}
+          modelPresets={presets}
+          setModelPresets={setPresets}
+          selectedProfiles={profiles}
+          setSelectedProfiles={setProfiles}
+          selectedGroupId={group}
+          setSelectedGroupId={setGroup}
+        />
+      );
+    };
+    render(<Bare />);
+    expect(screen.getByText(/select a material first/i)).toBeInTheDocument();
+    // The preset half still works -- it does not depend on the material.
+    expect(screen.getByText('Filament preset')).toBeInTheDocument();
+  });
+});
+
+describe('PrinterProfilesSection — vi sanity', () => {
+  it('does not call any API of its own', () => {
+    // The section is presentational: everything it changes goes through the
+    // props, and SpoolFormModal is what persists it on save.
+    const spy = vi.fn();
+    render(<Harness onPresets={spy} />);
+    expect(spy).toHaveBeenCalled();
+  });
+});

+ 10 - 6
frontend/src/__tests__/components/SpoolFormBulk.test.tsx

@@ -5,7 +5,7 @@
  * - Quick-add toggle appears only in create mode
  * - Quick-add mode shows brand and subtype as optional (no asterisk)
  * - Quick-add mode hides slicer preset field
- * - Quick-add mode hides PA Profile tab
+ * - Quick-add mode hides the Printers tab
  * - Quantity field is only rendered in quick-add mode
  * - Quantity field is hidden in edit mode
  * - Bulk create calls bulkCreateSpools when quantity > 1
@@ -43,6 +43,10 @@ vi.mock('../../api/client', () => ({
     ]),
     updateSpool: vi.fn().mockResolvedValue({ id: 1 }),
     saveSpoolKProfiles: vi.fn().mockResolvedValue([]),
+    getSpoolFilamentPresets: vi.fn().mockResolvedValue([]),
+    saveSpoolFilamentPresets: vi.fn().mockResolvedValue([]),
+    getSpoolmanFilamentPresets: vi.fn().mockResolvedValue([]),
+    saveSpoolmanFilamentPresets: vi.fn().mockResolvedValue([]),
   },
 }));
 
@@ -175,7 +179,7 @@ describe('SpoolFormModal quick-add toggle', () => {
     expect(screen.queryByText('Quick Add (Stock)')).not.toBeInTheDocument();
   });
 
-  it('hides PA Profile tab when quick-add is enabled', async () => {
+  it('hides the Printers tab when quick-add is enabled', async () => {
     render(
       <SpoolFormModal
         isOpen={true}
@@ -189,8 +193,8 @@ describe('SpoolFormModal quick-add toggle', () => {
       expect(screen.getByRole('heading', { name: 'Add Spool' })).toBeInTheDocument();
     });
 
-    // PA Profile tab should be visible initially
-    expect(screen.getByText('PA Profile')).toBeInTheDocument();
+    // Printers tab should be visible initially
+    expect(screen.getByText('Printers')).toBeInTheDocument();
 
     // Toggle quick-add on — the toggle is a button[role="switch"] sibling of the label
     const toggleButtons = screen.getAllByRole('button');
@@ -202,9 +206,9 @@ describe('SpoolFormModal quick-add toggle', () => {
     expect(quickAddToggle).toBeTruthy();
     fireEvent.click(quickAddToggle!);
 
-    // PA Profile tab should be hidden
+    // Printers tab should be hidden
     await waitFor(() => {
-      expect(screen.queryByText('PA Profile')).not.toBeInTheDocument();
+      expect(screen.queryByText('Printers')).not.toBeInTheDocument();
     });
   });
 

+ 8 - 4
frontend/src/__tests__/components/SpoolFormEditRelaxed.test.tsx

@@ -41,6 +41,10 @@ vi.mock('../../api/client', () => ({
     createSpool: vi.fn().mockResolvedValue({ id: 99 }),
     updateSpool: vi.fn().mockResolvedValue({ id: 7 }),
     saveSpoolKProfiles: vi.fn().mockResolvedValue([]),
+    getSpoolFilamentPresets: vi.fn().mockResolvedValue([]),
+    saveSpoolFilamentPresets: vi.fn().mockResolvedValue([]),
+    getSpoolmanFilamentPresets: vi.fn().mockResolvedValue([]),
+    saveSpoolmanFilamentPresets: vi.fn().mockResolvedValue([]),
     getSpoolmanInventoryFilaments: vi.fn().mockResolvedValue([]),
     getAssignments: vi.fn().mockResolvedValue([]),
     unassignSpool: vi.fn().mockResolvedValue({}),
@@ -175,8 +179,8 @@ describe('SpoolFormModal relaxed edit/copy validation (#1905)', () => {
 
     const presetInput = screen.getByPlaceholderText('Search filament presets...');
     fireEvent.focus(presetInput);
-    await waitFor(() => expect(screen.getByRole('button', { name: 'Generic ASA' })).toBeInTheDocument());
-    fireEvent.click(screen.getByRole('button', { name: 'Generic ASA' }));
+    await waitFor(() => expect(screen.getByRole('button', { name: /^Generic ASA\b/ })).toBeInTheDocument());
+    fireEvent.click(screen.getByRole('button', { name: /^Generic ASA\b/ }));
 
     // parsePresetName('Generic ASA') yields brand "Generic" — it must not
     // replace the manufacturer the spool already carries.
@@ -192,8 +196,8 @@ describe('SpoolFormModal relaxed edit/copy validation (#1905)', () => {
 
     const presetInput = screen.getByPlaceholderText('Search filament presets...');
     fireEvent.focus(presetInput);
-    await waitFor(() => expect(screen.getByRole('button', { name: 'Generic ASA' })).toBeInTheDocument());
-    fireEvent.click(screen.getByRole('button', { name: 'Generic ASA' }));
+    await waitFor(() => expect(screen.getByRole('button', { name: /^Generic ASA\b/ })).toBeInTheDocument());
+    fireEvent.click(screen.getByRole('button', { name: /^Generic ASA\b/ }));
 
     expect(screen.getByPlaceholderText('Search brand...')).toHaveValue('Generic');
     expect(screen.getByPlaceholderText('Select material...')).toHaveValue('ASA');

+ 63 - 3
frontend/src/__tests__/components/SpoolFormModal.test.tsx

@@ -31,6 +31,10 @@ vi.mock('../../api/client', () => ({
     createSpoolmanInventorySpool: vi.fn().mockResolvedValue({ id: 88 }),
     updateSpool: vi.fn().mockResolvedValue({ id: 1 }),
     saveSpoolKProfiles: vi.fn().mockResolvedValue([]),
+    getSpoolFilamentPresets: vi.fn().mockResolvedValue([]),
+    saveSpoolFilamentPresets: vi.fn().mockResolvedValue([]),
+    getSpoolmanFilamentPresets: vi.fn().mockResolvedValue([]),
+    saveSpoolmanFilamentPresets: vi.fn().mockResolvedValue([]),
     saveSpoolmanKProfiles: vi.fn().mockResolvedValue([]),
     updateSpoolmanInventorySpool: vi.fn().mockResolvedValue({ id: 42 }),
     bulkCreateSpoolmanInventorySpools: vi.fn().mockResolvedValue({
@@ -75,6 +79,18 @@ vi.mock('../../contexts/ToastContext', async (importOriginal) => {
 
 import { api } from '../../api/client';
 
+/**
+ * Open the spool form's "Color & Cost" tab.
+ *
+ * The form is split across three tabs -- Filament (identity + preset), Color &
+ * Cost (colour, spool weights, price, category, location) and Printers
+ * (per-model preset + per-hotend K profile). Fields that used to sit in one
+ * long scroll under Filament now need their tab opened first.
+ */
+function openColorAndCostTab() {
+  fireEvent.click(screen.getByText('Color & Cost'));
+}
+
 const existingSpool: InventorySpool = {
   id: 1,
   material: 'PLA',
@@ -159,6 +175,8 @@ describe('SpoolFormModal weightTouched', () => {
       expect(screen.getByText('Edit Spool')).toBeInTheDocument();
     });
 
+    openColorAndCostTab();
+
     // The remaining weight is (label_weight - weight_used) = 1000 - 300 = 700.
     // The input is a number input displaying 700. Find it by its displayed value.
     const remainingInput = screen.getByDisplayValue('700');
@@ -236,6 +254,8 @@ describe('SpoolFormModal weightTouched', () => {
       expect(screen.getByText('Edit Spool')).toBeInTheDocument();
     });
 
+    openColorAndCostTab();
+
     // Change the note field (unrelated to catalog ID)
     const noteInputs = screen.getAllByPlaceholderText(/note/i);
     expect(noteInputs.length).toBeGreaterThan(0);
@@ -278,6 +298,8 @@ describe('SpoolFormModal weightTouched', () => {
       expect(screen.getByRole('heading', { name: 'Add Spool' })).toBeInTheDocument();
     });
 
+    openColorAndCostTab();
+
     // Wait for catalog to load
     await waitFor(() => {
       expect(api.getSpoolCatalog).toHaveBeenCalled();
@@ -579,6 +601,8 @@ describe('SpoolFormModal weightTouched', () => {
       expect(screen.getByText('Edit Spool')).toBeInTheDocument();
     });
 
+    openColorAndCostTab();
+
     // Wait for catalog to load
     await waitFor(() => {
       expect(api.getSpoolCatalog).toHaveBeenCalled();
@@ -631,7 +655,7 @@ describe('SpoolFormModal Spoolman K-profile support', () => {
     vi.clearAllMocks();
   });
 
-  it('shows PA Profile tab for Spoolman spools in non-quickAdd mode', async () => {
+  it('shows the Printers tab for Spoolman spools in non-quickAdd mode', async () => {
     render(
       <SpoolFormModal
         isOpen={true}
@@ -647,8 +671,8 @@ describe('SpoolFormModal Spoolman K-profile support', () => {
       expect(screen.getByText('Edit Spool')).toBeInTheDocument();
     });
 
-    // PA Profile tab should be visible in Spoolman mode
-    expect(screen.getByText('PA Profile')).toBeInTheDocument();
+    // Printers tab should be visible in Spoolman mode
+    expect(screen.getByText('Printers')).toBeInTheDocument();
   });
 
   it('calls saveSpoolmanKProfiles (not saveSpoolKProfiles) on update in Spoolman mode', async () => {
@@ -680,6 +704,34 @@ describe('SpoolFormModal Spoolman K-profile support', () => {
     });
     expect(api.saveSpoolKProfiles).not.toHaveBeenCalled();
   });
+
+  it('saves the per-model preset overrides alongside the K profiles', async () => {
+    // Both are full replacements and both are written on every save: that is
+    // how the user clears the last profile or the last override. The Spoolman
+    // pair must be the one called in Spoolman mode -- the two inventory modes
+    // have drifted apart on this path before (#1713).
+    render(
+      <SpoolFormModal
+        isOpen={true}
+        onClose={vi.fn()}
+        spool={spoolmanSpool}
+        mode="edit"
+        currencySymbol="$"
+        spoolmanMode={true}
+      />
+    );
+
+    await waitFor(() => {
+      expect(screen.getByText('Edit Spool')).toBeInTheDocument();
+    });
+
+    fireEvent.click(screen.getByRole('button', { name: /save/i }));
+
+    await waitFor(() => {
+      expect(api.saveSpoolmanFilamentPresets).toHaveBeenCalledWith(42, []);
+    });
+    expect(api.saveSpoolFilamentPresets).not.toHaveBeenCalled();
+  });
 });
 
 // ---------------------------------------------------------------------------
@@ -888,10 +940,16 @@ describe('SpoolFormModal — SpoolmanFilamentPicker integration (T2)', () => {
       expect(screen.getByTestId('picker-selected-id').textContent).toBe('7');
     });
 
+    openColorAndCostTab();
+
     // Manually edit the color_name field (a linked field)
     const colorNameInput = screen.getByPlaceholderText('Jade White, Fire Red...');
     fireEvent.change(colorNameInput, { target: { value: 'Custom Blue' } });
 
+    // Back to the Filament tab: the catalog picker only renders there, so the
+    // link state has to be read where it lives.
+    fireEvent.click(screen.getByText('Filament Info'));
+
     // spoolman_filament_id must be cleared (picker shows 'none')
     await waitFor(() => {
       expect(screen.getByTestId('picker-selected-id').textContent).toBe('none');
@@ -1073,6 +1131,8 @@ describe('SpoolFormModal locationIdTouched', () => {
       expect(screen.getByText('Edit Spool')).toBeInTheDocument();
     });
 
+    openColorAndCostTab();
+
     // Change storage location via the catalog dropdown
     const locationSelect = screen.getByLabelText(/storage location/i);
     fireEvent.change(locationSelect, { target: { value: '2' } });

+ 118 - 0
frontend/src/__tests__/components/spool-form/fetchPrinterCalibrations.test.ts

@@ -0,0 +1,118 @@
+/**
+ * `fetchPrinterCalibrations` asks the printer for its K-profile table.
+ *
+ * Two properties, both measured on real hardware rather than reasoned about:
+ *
+ * 1. It asks for EVERY standard nozzle size, not only the sizes currently
+ *    fitted. A K profile is stored on the printer per diameter and survives a
+ *    nozzle swap, so fetching only what is screwed in right now hides a 0.6
+ *    profile until a 0.6 is fitted, and stops a spool being prepared for a
+ *    nozzle that is about to be changed to.
+ *
+ * 2. It asks for them ONE AT A TIME. H2-series firmware answers only the first
+ *    one or two of a concurrent burst of `extrusion_cali_get` and silently
+ *    drops the rest; each dropped request then costs a 5-second timeout before
+ *    the retry. Measured on an H2C and an H2D: four parallel requests took 11s
+ *    and 23s, against roughly 1s sent in series. An X1C answers all four
+ *    concurrently, which is why this stayed hidden while only dual-diameter
+ *    printers ever sent more than one request.
+ *
+ * The second is the one worth a test: it is invisible in every unit-level
+ * result (the same rows come back either way) and only shows up as a stall on
+ * one brand of hardware.
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+
+const getKProfiles = vi.fn();
+
+vi.mock('../../../api/client', () => ({
+  api: {
+    get getKProfiles() {
+      return getKProfiles;
+    },
+  },
+}));
+
+import { fetchPrinterCalibrations } from '../../../components/spool-form/utils';
+import { STANDARD_NOZZLE_DIAMETERS } from '../../../components/spool-form/constants';
+
+function profile(slotId: number, diameter: string) {
+  return {
+    slot_id: slotId,
+    filament_id: 'GFL99',
+    setting_id: 'GFSL99',
+    name: `PLA ${diameter}`,
+    k_value: '0.020',
+    n_coef: '1.0',
+    extruder_id: 0,
+    nozzle_diameter: diameter,
+  };
+}
+
+describe('fetchPrinterCalibrations', () => {
+  beforeEach(() => {
+    getKProfiles.mockReset();
+  });
+
+  it('asks for every standard nozzle size, not just the fitted one', async () => {
+    getKProfiles.mockResolvedValue({ profiles: [] });
+
+    await fetchPrinterCalibrations(1, { nozzles: [{ nozzle_diameter: '0.4' }] });
+
+    const asked = getKProfiles.mock.calls.map(([, diameter]) => diameter);
+    expect(asked).toEqual(expect.arrayContaining(STANDARD_NOZZLE_DIAMETERS));
+  });
+
+  it('includes an unusual fitted diameter alongside the standard sizes', async () => {
+    getKProfiles.mockResolvedValue({ profiles: [] });
+
+    await fetchPrinterCalibrations(1, { nozzles: [{ nozzle_diameter: '1.0' }] });
+
+    const asked = getKProfiles.mock.calls.map(([, diameter]) => diameter);
+    expect(asked).toContain('1.0');
+    // Asked once each, no duplicate for a size that is both standard and fitted.
+    expect(new Set(asked).size).toBe(asked.length);
+  });
+
+  it('never has two requests in flight at once', async () => {
+    let inFlight = 0;
+    let maxInFlight = 0;
+    getKProfiles.mockImplementation(async () => {
+      inFlight++;
+      maxInFlight = Math.max(maxInFlight, inFlight);
+      await new Promise(resolve => setTimeout(resolve, 0));
+      inFlight--;
+      return { profiles: [] };
+    });
+
+    await fetchPrinterCalibrations(1, { nozzles: [{ nozzle_diameter: '0.4' }] });
+
+    expect(getKProfiles.mock.calls.length).toBeGreaterThan(1);
+    expect(maxInFlight).toBe(1);
+  });
+
+  it('keeps the diameters that answered when one request fails', async () => {
+    getKProfiles.mockImplementation(async (_id: number, diameter: string) => {
+      if (diameter === '0.6') throw new Error('printer said no');
+      return { profiles: [profile(1, diameter)] };
+    });
+
+    const rows = await fetchPrinterCalibrations(1, { nozzles: [{ nozzle_diameter: '0.4' }] });
+
+    const diameters = rows.map(r => r.nozzle_diameter);
+    expect(diameters).toContain('0.4');
+    expect(diameters).not.toContain('0.6');
+  });
+
+  it('flattens every size into one list of calibrations', async () => {
+    getKProfiles.mockImplementation(async (_id: number, diameter: string) => ({
+      profiles: diameter === '0.8' ? [] : [profile(Number(diameter.replace('.', '')), diameter)],
+    }));
+
+    const rows = await fetchPrinterCalibrations(1, { nozzles: [{ nozzle_diameter: '0.4' }] });
+
+    expect(rows.map(r => r.nozzle_diameter).sort()).toEqual(['0.2', '0.4', '0.6']);
+    expect(rows[0].k_value).toBeCloseTo(0.02);
+  });
+});

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

@@ -3628,6 +3628,38 @@ export interface SpoolKProfileInput {
   setting_id?: string | null;
 }
 
+/**
+ * One per-printer-model override of a spool's slicer filament preset.
+ *
+ * A cloud or Orca preset is bound to a printer model ("@BBL X1C"), so the
+ * spool's single `slicer_filament` is wrong as soon as the spool is used on a
+ * second model. `nozzle_diameter` is "" for the model's own default and a bare
+ * decimal ("0.2") for a per-hotend exception; the backend resolves
+ * exact (model, diameter) -> (model, "") -> the spool's own preset.
+ */
+export interface SlotSpoolDefaults {
+  slicer_filament: string | null;
+  slicer_filament_name: string | null;
+  cali_idx: number | null;
+  k_value: number | null;
+  profile_name: string | null;
+  extruder: number | null;
+  nozzle_diameter: string;
+}
+
+export interface SpoolFilamentPresetInput {
+  printer_model: string;
+  nozzle_diameter: string;
+  slicer_filament: string | null;
+  slicer_filament_name: string | null;
+}
+
+export interface SpoolFilamentPreset extends SpoolFilamentPresetInput {
+  id: number;
+  spool_id: number;
+  created_at: string;
+}
+
 /** One inventory-bound AMS slot, as returned by `/printers/{id}/inventory-remain`. */
 export interface SlotMaterial {
   ams_id: number;
@@ -6386,6 +6418,20 @@ export const api = {
       method: 'PUT',
       body: JSON.stringify(profiles),
     }),
+  /**
+   * What the spool assigned to this slot is configured to use *here* -- its
+   * per-printer-model filament preset and the K profile for this slot's hotend.
+   * Nulls when the slot holds no known spool.
+   */
+  getSlotSpoolDefaults: (printerId: number, amsId: number, trayId: number) =>
+    request<SlotSpoolDefaults>(`/printers/${printerId}/slots/${amsId}/${trayId}/spool-defaults`),
+  getSpoolFilamentPresets: (spoolId: number) =>
+    request<SpoolFilamentPreset[]>(`/inventory/spools/${spoolId}/filament-presets`),
+  saveSpoolFilamentPresets: (spoolId: number, presets: SpoolFilamentPresetInput[]) =>
+    request<SpoolFilamentPreset[]>(`/inventory/spools/${spoolId}/filament-presets`, {
+      method: 'PUT',
+      body: JSON.stringify(presets),
+    }),
   getAssignments: (printerId?: number) =>
     request<SpoolAssignment[]>(`/inventory/assignments${printerId ? `?printer_id=${printerId}` : ''}`),
   assignSpool: (data: { spool_id: number; printer_id: number; ams_id: number; tray_id: number }) =>
@@ -6628,6 +6674,15 @@ export const api = {
       body: JSON.stringify(profiles),
     }),
 
+  getSpoolmanFilamentPresets: (spoolId: number) =>
+    request<SpoolFilamentPreset[]>(`/spoolman/inventory/spools/${spoolId}/filament-presets`),
+
+  saveSpoolmanFilamentPresets: (spoolId: number, presets: SpoolFilamentPresetInput[]) =>
+    request<SpoolFilamentPreset[]>(`/spoolman/inventory/spools/${spoolId}/filament-presets`, {
+      method: 'PUT',
+      body: JSON.stringify(presets),
+    }),
+
   // Updates
   getVersion: () => request<VersionInfo>('/updates/version'),
   checkForUpdates: () => request<UpdateCheckResult>('/updates/check'),

+ 49 - 81
frontend/src/components/ConfigureAmsSlotModal.tsx

@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
 import { X, Loader2, Settings2, ChevronDown, CheckCircle2, RotateCcw } from 'lucide-react';
 import { api } from '../api/client';
 import type { KProfile } from '../api/client';
-import { matchesPrinterModelSuffix, presetCompatibility, buildCompatibilityIndex } from '../utils/slicerPrinterMatch';
+import { matchesPrinterModelSuffix, presetCompatibility, buildCompatibilityIndex, extractPresetModel } from '../utils/slicerPrinterMatch';
 import { toFilamentId } from './spool-form/utils';
 import { Button } from './Button';
 import { getAmsLabel } from '../utils/amsHelpers';
@@ -245,82 +245,6 @@ function colorNameToHex(name: string): string | null {
 }
 
 // Escape regex metacharacters and turn whitespace into ``\s+`` so a literal
-// model token compiles to a flexible-whitespace word-boundary regex.
-function _tokenToRegex(token: string): RegExp {
-  const escaped = token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\s+/g, '\\s+');
-  return new RegExp(`\\b${escaped}\\b`, 'i');
-}
-
-// Extract printer model from a preset name → normalized short code
-// (e.g. "X1C", "H2D"). Two strategies in order:
-//
-// (1) ``@`` suffix — the BambuStudio naming convention. Two shapes:
-//   - "@BBL X1C 0.4 nozzle"               → "X1C"  (short-code form,
-//      Bambu Cloud system presets)
-//   - "@Bambu Lab X1 Carbon 0.4 nozzle"   → "X1C"  (long-form, used by
-//      user-renamed Bambu Cloud presets and most Orca Cloud profiles —
-//      reverse-looked-up via the backend printer-model registry)
-//
-// (2) Body scan — many user-authored / Orca Cloud presets put the printer
-// model at the START of the name with no @ suffix at all (the literal
-// shape that surfaced #1623: "X1C eSUN PETG-Basic Filament"). Scan the
-// name for any known model token (every long-name fragment + every short
-// code from the registry) and return the first match. Long-first sort
-// keeps "A1 Mini" / "X1 Carbon" / "H2D Pro" from being eaten by their
-// shorter sibling ("A1" / "X1" / "H2D"). Word-boundary regex prevents
-// false-positives on partial substrings (e.g. "PA1" doesn't match "A1",
-// "X1Box" doesn't match "X1").
-//
-// Returns null when neither strategy resolves; the caller keeps such
-// presets visible (can't filter what we can't classify).
-//
-// ``printerModelsLongToShort`` is the backend's PRINTER_MODEL_MAP shape:
-// keys are "Bambu Lab <long>", values are short codes.
-function extractPresetModel(
-  name: string,
-  printerModelsLongToShort: Record<string, string>,
-): string | null {
-  const atIdx = name.indexOf('@');
-  if (atIdx >= 0) {
-    const suffix = name.slice(atIdx + 1).trim();
-    const bblMatch = suffix.match(/^BBL\s+(.+?)(?:\s+[\d.]+\s*nozzle)?$/i);
-    if (bblMatch) return bblMatch[1].trim();
-    const longMatch = suffix.match(/^Bambu Lab\s+(.+?)(?:\s+[\d.]+\s*nozzle)?$/i);
-    if (longMatch) {
-      const longFragment = longMatch[1].trim();
-      const fullKey = `Bambu Lab ${longFragment}`;
-      if (printerModelsLongToShort[fullKey]) return printerModelsLongToShort[fullKey];
-      const lower = fullKey.toLowerCase();
-      for (const [k, v] of Object.entries(printerModelsLongToShort)) {
-        if (k.toLowerCase() === lower) return v;
-      }
-      return longFragment;
-    }
-  }
-
-  // Body scan — accumulate {token, short} pairs and try long-first.
-  const tokens: Array<{ token: string; short: string }> = [];
-  const seen = new Set<string>();
-  for (const [longName, short] of Object.entries(printerModelsLongToShort)) {
-    const fragment = longName.replace(/^Bambu Lab\s+/, '');
-    const key = fragment.toLowerCase();
-    if (!seen.has(key)) {
-      tokens.push({ token: fragment, short });
-      seen.add(key);
-    }
-    const shortKey = short.toLowerCase();
-    if (!seen.has(shortKey)) {
-      tokens.push({ token: short, short });
-      seen.add(shortKey);
-    }
-  }
-  tokens.sort((a, b) => b.token.length - a.token.length);
-  for (const { token, short } of tokens) {
-    if (_tokenToRegex(token).test(name)) return short;
-  }
-  return null;
-}
-
 export function ConfigureAmsSlotModal({
   isOpen,
   onClose,
@@ -415,6 +339,19 @@ export function ConfigureAmsSlotModal({
     staleTime: Infinity,
   });
 
+  // What the spool in this slot is configured to use here: its filament preset
+  // for this printer's MODEL and its K profile for this slot's hotend. Those
+  // are the values the user set on the spool, so they are the right defaults
+  // for a dialog that configures the slot that spool sits in -- the slot's own
+  // last manual configuration and the tray's RFID data are the fallbacks, not
+  // the other way round.
+  const { data: slotSpoolDefaults } = useQuery({
+    queryKey: ['slot-spool-defaults', printerId, slotInfo.amsId, slotInfo.trayId],
+    queryFn: () => api.getSlotSpoolDefaults(printerId, slotInfo.amsId, slotInfo.trayId),
+    enabled: isOpen,
+    staleTime: 0,
+  });
+
   const compatIndex = useMemo(
     () => buildCompatibilityIndex(printerModelsData ?? {}),
     [printerModelsData],
@@ -1076,8 +1013,10 @@ export function ConfigureAmsSlotModal({
   // Pre-select current profile when modal opens, reset when closes
   useEffect(() => {
     if (isOpen) {
-      // Pre-populate from saved preset mapping (most reliable)
-      if (slotInfo.savedPresetId) {
+      // The spool's own per-model preset first -- see the query above.
+      if (slotSpoolDefaults?.slicer_filament) {
+        setSelectedPresetId(slotSpoolDefaults.slicer_filament);
+      } else if (slotInfo.savedPresetId) {
         setSelectedPresetId(slotInfo.savedPresetId);
       } else if (slotInfo.trayInfoIdx && cloudSettings?.filament) {
         // Fallback: try to match by tray_info_idx in cloud presets
@@ -1120,11 +1059,33 @@ export function ConfigureAmsSlotModal({
       setShowSuccess(false);
       scrolledToRef.current = '';
     }
-  }, [isOpen, slotInfo.savedPresetId, slotInfo.trayInfoIdx, slotInfo.trayColor, cloudSettings?.filament, builtinFilaments]);
+  }, [
+    isOpen,
+    slotSpoolDefaults?.slicer_filament,
+    slotInfo.savedPresetId,
+    slotInfo.trayInfoIdx,
+    slotInfo.trayColor,
+    cloudSettings?.filament,
+    builtinFilaments,
+  ]);
 
   // Auto-select best matching K profile when preset changes
   useEffect(() => {
     if (matchingKProfiles.length > 0) {
+      // The profile the spool is configured with for THIS hotend, if it still
+      // exists on the printer. Ahead of the slot's live cali_idx, which is
+      // whatever was selected last rather than what the spool is set to.
+      if (slotSpoolDefaults?.cali_idx != null) {
+        const configured = findProfileByCaliIdx(
+          matchingKProfiles,
+          slotSpoolDefaults.cali_idx,
+          slotSpoolDefaults.extruder ?? slotInfo.extruderId,
+        );
+        if (configured) {
+          setSelectedKProfile(configured);
+          return;
+        }
+      }
       // Prefer the currently-active K-profile, resolved against this slot's own
       // nozzle — the index alone is ambiguous across hotends.
       if (slotInfo.caliIdx != null && slotInfo.caliIdx > 0) {
@@ -1141,7 +1102,14 @@ export function ConfigureAmsSlotModal({
     } else {
       setSelectedKProfile(null);
     }
-  }, [selectedPresetId, matchingKProfiles, slotInfo.caliIdx, slotInfo.extruderId]);
+  }, [
+    selectedPresetId,
+    matchingKProfiles,
+    slotSpoolDefaults?.cali_idx,
+    slotSpoolDefaults?.extruder,
+    slotInfo.caliIdx,
+    slotInfo.extruderId,
+  ]);
 
   // Escape key handler
   const handleKeyDown = useCallback((e: KeyboardEvent) => {

+ 226 - 104
frontend/src/components/SpoolFormModal.tsx

@@ -3,25 +3,32 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import { useTranslation } from 'react-i18next';
 import { X, Loader2, Save, Beaker, Palette, Zap, Tag, Unlink } from 'lucide-react';
 import { api, ApiError } from '../api/client';
-import type { InventorySpool, SlicerSetting, SpoolCatalogEntry, LocalPreset, BuiltinFilament, SpoolmanBulkCreateResult, SpoolKProfileInput, SpoolmanFilamentEntry } from '../api/client';
+import type { InventorySpool, SlicerSetting, SpoolCatalogEntry, LocalPreset, BuiltinFilament, SpoolmanBulkCreateResult, SpoolFilamentPresetInput, SpoolKProfileInput, SpoolmanFilamentEntry } from '../api/client';
 import { Button } from './Button';
 import { useToast } from '../contexts/ToastContext';
-import type { SpoolFormData, PrinterWithCalibrations, ColorPreset, SpoolFormMode } from './spool-form/types';
+import type {
+  CalibrationProfile,
+  ColorPreset,
+  PresetChoice,
+  PrinterWithCalibrations,
+  SpoolFormData,
+  SpoolFormMode,
+} from './spool-form/types';
 import { defaultFormData, validateForm, SPOOLMAN_LINKED_FIELDS } from './spool-form/types';
-import { buildFilamentOptions, extractBrandsFromPresets, fetchPrinterCalibrations, findPresetOption, loadRecentColors, pairedOptions, parsePresetName, saveRecentColor, withCurrentValue } from './spool-form/utils';
+import { buildFilamentOptions, extractBrandsFromPresets, fetchPrinterCalibrations, findPresetOption, hotendKey, loadRecentColors, pairedOptions, parsePresetKey, parsePresetName, presetKey, saveRecentColor, withCurrentValue } from './spool-form/utils';
 import { MATERIALS } from './spool-form/constants';
 import { FilamentSection } from './spool-form/FilamentSection';
 import { ColorSection } from './spool-form/ColorSection';
 import { AdditionalSection } from './spool-form/AdditionalSection';
 import { SpoolmanFilamentPicker } from './spool-form/SpoolmanFilamentPicker';
-import { PAProfileSection } from './spool-form/PAProfileSection';
+import { PrinterProfilesSection } from './spool-form/PrinterProfilesSection';
 import { SpoolUsageHistory } from './SpoolUsageHistory';
 import {
   invalidateInventoryLocations,
   invalidateSpoolAndLocationQueries,
 } from '../utils/inventoryQueries';
 
-type TabId = 'filament' | 'pa-profile';
+type TabId = 'filament' | 'appearance' | 'printers';
 
 const CLEAR_TAG_PAYLOAD = { tag_uid: null, tray_uuid: null, tag_type: null, data_origin: null };
 
@@ -75,6 +82,7 @@ export function SpoolFormModal({
   const [cloudAuthenticated, setCloudAuthenticated] = useState(false);
   const [loadingCloudPresets, setLoadingCloudPresets] = useState(false);
   const [cloudPresets, setCloudPresets] = useState<SlicerSetting[]>([]);
+  const [orcaSettingIds, setOrcaSettingIds] = useState<Set<string>>(new Set());
   const [presetInputValue, setPresetInputValue] = useState('');
 
   // Spool catalog
@@ -104,16 +112,31 @@ export function SpoolFormModal({
 
   // PA Profile state
   const [fetchedCalibrations, setFetchedCalibrations] = useState<PrinterWithCalibrations[]>([]);
-  const [selectedProfiles, setSelectedProfiles] = useState<Set<string>>(new Set());
-  const [expandedPrinters, setExpandedPrinters] = useState<Set<string>>(new Set());
+  // Whether the calibration fetch above is still running. Without it the
+  // Printers tab renders its "no printers configured" empty state while the
+  // printers are still being asked -- which reads as a wrong answer rather
+  // than as a wait, and the fetch is several round trips per machine.
+  const [loadingCalibrations, setLoadingCalibrations] = useState(false);
+  // One K profile per hotend, keyed `printerId:extruder:diameter`. A Map rather
+  // than a Set of composite keys because the Printers tab presents each hotend
+  // as a single-choice dropdown -- the shape makes "two profiles for one
+  // hotend" unrepresentable instead of relying on eviction logic to prevent it.
+  const [selectedProfiles, setSelectedProfiles] = useState<Map<string, CalibrationProfile>>(new Map());
+  // Per-printer-model preset overrides, keyed by `presetKey(model, diameter)`.
+  // Only the models the user has actually overridden are present; an absent
+  // entry means "inherit this spool's own preset", which is exactly what the
+  // backend cascade does with a missing row.
+  const [modelPresets, setModelPresets] = useState<Map<string, PresetChoice>>(new Map());
+  const [selectedGroupId, setSelectedGroupId] = useState<string>('');
 
   // Use prop if provided, otherwise use self-fetched data
   const resolvedCalibrations = printersWithCalibrations.length > 0
     ? printersWithCalibrations
     : fetchedCalibrations;
 
-  // Count selected PA profiles for tab badge
-  const selectedProfileCount = selectedProfiles.size;
+  // Tab badge: everything the user has configured under Printers, K profiles
+  // and preset overrides alike, since both live on that tab now.
+  const selectedProfileCount = selectedProfiles.size + modelPresets.size;
 
   // Fetch Spoolman filament catalog when in Spoolman mode
   // retry:false — Spoolman may be intentionally disabled (400); don't flood the server
@@ -169,6 +192,10 @@ export function SpoolFormModal({
           const orcaPresets = orcaResult.status === 'fulfilled' ? orcaResult.value.presets : [];
           setCloudAuthenticated(bambuConnected || orcaConnected);
           setCloudPresets([...bambuPresets, ...orcaPresets]);
+          // The two clouds are merged into one list, so remember which ids came
+          // from Orca -- it is the only way the origin badge can tell them
+          // apart afterwards.
+          setOrcaSettingIds(new Set(orcaPresets.map(p => p.setting_id)));
         } catch (e) {
           if (cancelled) return;
           console.error('Failed to fetch cloud presets:', e);
@@ -189,27 +216,40 @@ export function SpoolFormModal({
       // Fetch printer calibrations if not provided via props
       if (printersWithCalibrations.length === 0) {
         (async () => {
+          setLoadingCalibrations(true);
           try {
             const printers = await api.getPrinters();
             const statuses = await Promise.all(
               printers.map(p => api.getPrinterStatus(p.id).catch(() => null)),
             );
-            const results: PrinterWithCalibrations[] = [];
-            for (let i = 0; i < printers.length; i++) {
-              const printer = printers[i];
-              const status = statuses[i];
-              const connected = status?.connected ?? false;
-              let calibrations: PrinterWithCalibrations['calibrations'] = [];
-              if (connected) {
-                // Fetch across every installed nozzle so dual-nozzle printers
-                // surface both the 0.4mm and 0.6mm K-profiles, not just 0.4 (#2618).
-                calibrations = await fetchPrinterCalibrations(printer.id, status);
-              }
-              results.push({ printer: { ...printer, connected }, calibrations });
-            }
-            setFetchedCalibrations(results);
+            // Printers in parallel, diameters within a printer in series.
+            // Separate machines are separate MQTT connections and do not
+            // interfere; it is one printer's own firmware that drops a
+            // concurrent burst of calibration requests (see
+            // fetchPrinterCalibrations). Walking the fleet one machine at a
+            // time made the whole tab wait for the sum of every printer.
+            const results = await Promise.all(
+              printers.map(async (printer, i) => {
+                const status = statuses[i];
+                const connected = status?.connected ?? false;
+                let calibrations: PrinterWithCalibrations['calibrations'] = [];
+                if (connected) {
+                  // Across every nozzle size, so a profile for a size that is
+                  // not currently fitted is still offered (#2618 fetched only
+                  // the fitted ones).
+                  calibrations = await fetchPrinterCalibrations(printer.id, status);
+                }
+                // Keep the reported nozzle hardware: the Printers tab lists a
+                // model's installed diameters from it. Read as a set of
+                // diameters only -- never indexed by extruder.
+                return { printer: { ...printer, connected }, calibrations, nozzles: status?.nozzles };
+              }),
+            );
+            if (!cancelled) setFetchedCalibrations(results);
           } catch (e) {
             console.error('Failed to fetch printer calibrations:', e);
+          } finally {
+            if (!cancelled) setLoadingCalibrations(false);
           }
         })();
       }
@@ -228,8 +268,8 @@ export function SpoolFormModal({
 
   // Build filament options: cloud → local → fallback
   const filamentOptions = useMemo(
-    () => buildFilamentOptions(cloudPresets, new Set(), localPresets, builtinFilaments),
-    [cloudPresets, localPresets, builtinFilaments],
+    () => buildFilamentOptions(cloudPresets, new Set(), localPresets, builtinFilaments, orcaSettingIds),
+    [cloudPresets, localPresets, builtinFilaments, orcaSettingIds],
   );
 
   // Extract brands from presets
@@ -374,22 +414,34 @@ export function SpoolFormModal({
         });
         setPresetInputValue(spool.slicer_filament_name || spool.slicer_filament || '');
 
-        // Load K-profiles for this spool
+        // Load K-profiles for this spool. The stored row carries everything
+        // the picker needs to show the selection before the printer answers,
+        // so an offline printer still renders what was chosen for it.
         if (spool.k_profiles && spool.k_profiles.length > 0) {
-          const profileKeys = new Set<string>();
+          const chosen = new Map<string, CalibrationProfile>();
           for (const p of spool.k_profiles) {
-            if (p.cali_idx !== null && p.cali_idx !== undefined) {
-              profileKeys.add(`${p.printer_id}:${p.cali_idx}:${p.extruder ?? 'null'}`);
-            }
+            if (p.cali_idx === null || p.cali_idx === undefined) continue;
+            const diameter = (p.nozzle_diameter || '').trim() || '0.4';
+            const extruder = p.extruder ?? 0;
+            chosen.set(hotendKey(p.printer_id, extruder, diameter), {
+              cali_idx: p.cali_idx,
+              filament_id: '',
+              setting_id: p.setting_id || '',
+              name: p.name || '',
+              k_value: p.k_value,
+              n_coef: 0,
+              extruder_id: extruder,
+              nozzle_diameter: diameter,
+            });
           }
-          setSelectedProfiles(profileKeys);
+          setSelectedProfiles(chosen);
         } else {
-          setSelectedProfiles(new Set());
+          setSelectedProfiles(new Map());
         }
       } else {
         setFormData(defaultFormData);
         setPresetInputValue('');
-        setSelectedProfiles(new Set());
+        setSelectedProfiles(new Map());
       }
       // Reset on every open, not just the create path (#1905). The modal keeps
       // its state while closed, and the Quick Add toggle only renders in create
@@ -400,11 +452,51 @@ export function SpoolFormModal({
       setQuantity(1);
       setErrors({});
       setActiveTab('filament');
+      setSelectedGroupId('');
+      // Cleared on every open, both branches: the modal keeps its state while
+      // closed, so editing spool B after spool A would otherwise show (and
+      // save) A's per-model overrides on B. Refilled by the fetch below.
+      setModelPresets(new Map());
       setWeightTouched(false);
       setLocationIdTouched(false);
     }
   }, [isOpen, spool, mode, isCopying]);
 
+  // Load this spool's per-printer-model preset overrides. Fetched rather than
+  // read off the spool: they are deliberately not embedded in the spool
+  // response, which the inventory list returns once per spool the user owns.
+  // Copying a spool copies its overrides -- they describe the filament on the
+  // spool, which is what a copy has too.
+  useEffect(() => {
+    if (!isOpen || !spool) return;
+    let cancelled = false;
+    const load = spoolmanMode ? api.getSpoolmanFilamentPresets : api.getSpoolFilamentPresets;
+    load(spool.id)
+      .then(rows => {
+        if (cancelled) return;
+        const next = new Map<string, PresetChoice>();
+        for (const row of rows) {
+          next.set(presetKey(row.printer_model, row.nozzle_diameter || ''), {
+            code: row.slicer_filament || '',
+            name: row.slicer_filament_name || '',
+          });
+        }
+        setModelPresets(next);
+      })
+      .catch(e => {
+        // Non-fatal: the tab still works, it just starts with nothing
+        // overridden. Saving from that state WOULD clear the stored rows, so
+        // say so rather than letting the user save over what they cannot see.
+        if (cancelled) return;
+        console.error('Failed to load filament preset overrides:', e);
+        showToast(t('inventory.filamentPresetsLoadFailed'), 'warning');
+      });
+    return () => {
+      cancelled = true;
+    };
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [isOpen, spool?.id, spoolmanMode]);
+
   // Legacy rows may have storage_location text but no location_id yet — link when catalog loads.
   useEffect(() => {
     if (!isOpen || !spool || locationIdTouched || formData.location_id != null) return;
@@ -416,12 +508,6 @@ export function SpoolFormModal({
     }
   }, [isOpen, spool, storageLocations, formData.location_id, locationIdTouched]);
 
-  // Expand all printers in PA profile section when calibrations are available
-  useEffect(() => {
-    if (isOpen && resolvedCalibrations.length > 0) {
-      setExpandedPrinters(new Set(resolvedCalibrations.map(p => String(p.printer.id))));
-    }
-  }, [isOpen, resolvedCalibrations]);
 
   // Update field helper
   const updateField = <K extends keyof SpoolFormData>(key: K, value: SpoolFormData[K]) => {
@@ -481,7 +567,7 @@ export function SpoolFormModal({
         : api.createSpool(data as Parameters<typeof api.createSpool>[0]),
     onSuccess: async (newSpool) => {
       if (newSpool?.id) {
-        const ok = await saveKProfiles(newSpool.id);
+        const ok = await savePrinterProfiles(newSpool.id);
         if (!ok) return;
       }
       await refreshSpoolQueries();
@@ -518,9 +604,12 @@ export function SpoolFormModal({
         ? spoolmanResult.created
         : (result as InventorySpool[]);
 
-      if (selectedProfiles.size > 0) {
+      // Bulk create: every copy gets the same profiles and overrides. Skipped
+      // entirely when the user configured neither, so a plain bulk add does
+      // not fire two writes per spool.
+      if (selectedProfiles.size > 0 || modelPresets.size > 0) {
         for (const s of createdSpools) {
-          await saveKProfiles(s.id);
+          await savePrinterProfiles(s.id);
         }
       }
       await refreshSpoolQueries();
@@ -554,7 +643,7 @@ export function SpoolFormModal({
         : api.updateSpool(spool!.id, data as Parameters<typeof api.updateSpool>[1]),
     onSuccess: async () => {
       if (spool?.id) {
-        const ok = await saveKProfiles(spool.id);
+        const ok = await savePrinterProfiles(spool.id);
         if (!ok) return;
       }
       await refreshSpoolQueries();
@@ -625,6 +714,18 @@ export function SpoolFormModal({
     queryFn: api.getSettings,
     enabled: isOpen,
   });
+
+  // Backend Bambu printer-model registry, so the Printers tab can read the
+  // model out of a preset name and offer each model only its own presets. The
+  // same query key and staleTime the Configure AMS Slot modal uses -- the
+  // registry only changes across backend releases, so this is a cache hit
+  // whenever that modal has been opened.
+  const { data: printerModelsData } = useQuery({
+    queryKey: ['slicerPrinterModels'],
+    queryFn: api.getSlicerPrinterModels,
+    enabled: isOpen,
+    staleTime: Infinity,
+  });
   const availableCategories = (() => {
     const set = new Set<string>();
     for (const s of allSpools ?? []) {
@@ -660,65 +761,61 @@ export function SpoolFormModal({
     },
   });
 
-  // Save K-profiles for selected calibrations. Returns false if any error occurred.
-  const saveKProfiles = async (spoolId: number): Promise<boolean> => {
-    const saveApi = spoolmanMode ? api.saveSpoolmanKProfiles : api.saveSpoolKProfiles;
-
-    if (selectedProfiles.size === 0) {
-      try {
-        await saveApi(spoolId, []);
-        return true;
-      } catch (e) {
-        console.error('Failed to save K-profiles:', e);
-        showToast(t('inventory.kProfileSaveFailed'), 'warning');
-        return false;
-      }
+  // Save everything the Printers tab holds: one K profile per hotend and the
+  // per-printer-model preset overrides. Returns false if either write failed,
+  // which keeps the modal open so the user does not lose what they picked.
+  const savePrinterProfiles = async (spoolId: number): Promise<boolean> => {
+    const saveKApi = spoolmanMode ? api.saveSpoolmanKProfiles : api.saveSpoolKProfiles;
+    const savePresetApi = spoolmanMode ? api.saveSpoolmanFilamentPresets : api.saveSpoolFilamentPresets;
+
+    // The selection Map is keyed by hotend and holds the calibration itself,
+    // so nothing has to be resolved back out of the printer's live list. That
+    // also fixes a real defect in the old key-based lookup: it matched a
+    // calibration by cali_idx alone, and cali_idx is numbered PER NOZZLE --
+    // on a dual-nozzle printer it could resolve the other hotend's entry and
+    // persist that entry's K value and diameter.
+    const profiles: SpoolKProfileInput[] = [];
+    for (const [key, cal] of selectedProfiles) {
+      const [printerIdStr, extruderStr, diameter] = key.split(':');
+      profiles.push({
+        printer_id: parseInt(printerIdStr),
+        extruder: parseInt(extruderStr),
+        nozzle_diameter: diameter || '0.4',
+        k_value: cal.k_value,
+        name: cal.name || null,
+        cali_idx: cal.cali_idx,
+        setting_id: cal.setting_id || null,
+      });
     }
 
-    const profiles: SpoolKProfileInput[] = [];
-    let dropped = 0;
-    for (const key of selectedProfiles) {
-      const [printerIdStr, caliIdxStr, extruderStr] = key.split(':');
-      const printerId = parseInt(printerIdStr);
-      const caliIdx = parseInt(caliIdxStr);
-      const extruder = extruderStr === 'null' ? 0 : parseInt(extruderStr);
-
-      const pc = resolvedCalibrations.find(p => p.printer.id === printerId);
-      if (pc) {
-        const cal = pc.calibrations.find(c => c.cali_idx === caliIdx);
-        if (cal) {
-          profiles.push({
-            printer_id: printerId,
-            extruder,
-            nozzle_diameter: cal.nozzle_diameter || '0.4',
-            k_value: cal.k_value,
-            name: cal.name || null,
-            cali_idx: cal.cali_idx,
-            setting_id: cal.setting_id || null,
-          });
-        } else {
-          dropped++;
-        }
-      } else {
-        dropped++;
-      }
+    const presets: SpoolFilamentPresetInput[] = [];
+    for (const [key, choice] of modelPresets) {
+      const { model, diameter } = parsePresetKey(key);
+      if (!model) continue;
+      presets.push({
+        printer_model: model,
+        nozzle_diameter: diameter,
+        slicer_filament: choice.code || null,
+        slicer_filament_name: choice.name || null,
+      });
     }
 
-    if (dropped > 0) {
-      console.error(`saveKProfiles: ${dropped} profile key(s) could not be resolved`, Array.from(selectedProfiles));
+    // Both are full replacements, so both run even when empty -- that is how
+    // the user clears the last profile or the last override.
+    try {
+      await saveKApi(spoolId, profiles);
+    } catch (e) {
+      console.error('Failed to save K-profiles:', e);
       showToast(t('inventory.kProfileSaveFailed'), 'warning');
       return false;
     }
 
-    if (profiles.length > 0) {
-      try {
-        await saveApi(spoolId, profiles);
-        return true;
-      } catch (e) {
-        console.error('Failed to save K-profiles:', e);
-        showToast(t('inventory.kProfileSaveFailed'), 'warning');
-        return false;
-      }
+    try {
+      await savePresetApi(spoolId, presets);
+    } catch (e) {
+      console.error('Failed to save filament preset overrides:', e);
+      showToast(t('inventory.filamentPresetSaveFailed'), 'warning');
+      return false;
     }
 
     return true;
@@ -800,7 +897,12 @@ export function SpoolFormModal({
         onClick={onClose}
       />
 
-      <div className="relative w-full max-w-xl mx-4 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-xl shadow-2xl max-h-[90vh] flex flex-col">
+      {/* Wider than the old max-w-xl: the Printers tab is a model list beside
+          a detail pane holding a preset row per nozzle size and a hotend-by-
+          size grid, which needs room for both. Held constant across tabs
+          rather than sized per tab -- a modal that resizes as you switch tabs
+          reads as a layout bug. */}
+      <div className="relative w-full max-w-5xl mx-4 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-xl shadow-2xl max-h-[90vh] flex flex-col">
         {/* Header */}
         <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary flex-shrink-0">
           <h2 className="text-lg font-semibold text-white flex items-baseline gap-2">
@@ -857,20 +959,33 @@ export function SpoolFormModal({
                 : 'text-bambu-gray hover:text-white'
             }`}
           >
-            <Palette className="w-4 h-4" />
+            <Beaker className="w-4 h-4" />
             {t('inventory.filamentInfoTab')}
           </button>
           {!quickAdd && (
             <button
-              onClick={() => setActiveTab('pa-profile')}
+              onClick={() => setActiveTab('appearance')}
+              className={`flex-1 px-4 py-2.5 text-sm font-medium flex items-center justify-center gap-2 transition-colors ${
+                activeTab === 'appearance'
+                  ? 'text-bambu-green border-b-2 border-bambu-green'
+                  : 'text-bambu-gray hover:text-white'
+              }`}
+            >
+              <Palette className="w-4 h-4" />
+              {t('inventory.colorAndCostTab')}
+            </button>
+          )}
+          {!quickAdd && (
+            <button
+              onClick={() => setActiveTab('printers')}
               className={`flex-1 px-4 py-2.5 text-sm font-medium flex items-center justify-center gap-2 transition-colors ${
-                activeTab === 'pa-profile'
+                activeTab === 'printers'
                   ? 'text-bambu-green border-b-2 border-bambu-green'
                   : 'text-bambu-gray hover:text-white'
               }`}
             >
-              <Beaker className="w-4 h-4" />
-              {t('inventory.paProfileTab')}
+              <Zap className="w-4 h-4" />
+              {t('inventory.printersTab')}
               {selectedProfileCount > 0 && (
                 <span className="text-xs px-1.5 py-0.5 rounded-full bg-bambu-green/20 text-bambu-green">
                   {selectedProfileCount}
@@ -926,6 +1041,9 @@ export function SpoolFormModal({
                 />
               </div>
 
+            </div>
+          ) : activeTab === 'appearance' ? (
+            <div className="space-y-6">
               {/* Color Section */}
               <div>
                 <h3 className="text-sm font-semibold text-bambu-gray uppercase tracking-wide mb-3">
@@ -981,14 +1099,18 @@ export function SpoolFormModal({
               )}
             </div>
           ) : (
-            <PAProfileSection
+            <PrinterProfilesSection
               formData={formData}
-              updateField={updateField}
               printersWithCalibrations={resolvedCalibrations}
+              filamentOptions={filamentOptions}
+              modelPresets={modelPresets}
+              setModelPresets={setModelPresets}
               selectedProfiles={selectedProfiles}
               setSelectedProfiles={setSelectedProfiles}
-              expandedPrinters={expandedPrinters}
-              setExpandedPrinters={setExpandedPrinters}
+              selectedGroupId={selectedGroupId}
+              setSelectedGroupId={setSelectedGroupId}
+              printerModels={printerModelsData}
+              isLoading={loadingCalibrations}
             />
           )}
         </div>

+ 30 - 23
frontend/src/components/spool-form/AdditionalSection.tsx

@@ -204,19 +204,26 @@ export function AdditionalSection({
   }, [isRemainingFocused, remainingWeight]);
 
   return (
-    <div className="space-y-4">
+    // Two columns from sm up. These are all short single-value fields, and at
+    // the form's width one per row left most of each row empty and pushed the
+    // rest below the fold. The two that stay full width earn it: the spool
+    // catalogue picker carries a long product name beside its own number
+    // input, and the note is a textarea.
+    <div className="grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-4">
       {/* Empty Spool Weight — hidden in Spoolman mode (managed per filament type in Spoolman) */}
-      {spoolmanMode ? (
-        <p className="text-xs text-bambu-gray px-1">{t('inventory.spoolWeightManagedBySpoolman')}</p>
-      ) : (
-        <SpoolWeightPicker
-          catalog={spoolCatalog}
-          value={formData.core_weight}
-          onChange={(weight) => updateField('core_weight', weight)}
-          catalogId={formData.core_weight_catalog_id}
-          onCatalogIdChange={(id) => updateField('core_weight_catalog_id', id)}
-        />
-      )}
+      <div className="sm:col-span-2">
+        {spoolmanMode ? (
+          <p className="text-xs text-bambu-gray px-1">{t('inventory.spoolWeightManagedBySpoolman')}</p>
+        ) : (
+          <SpoolWeightPicker
+            catalog={spoolCatalog}
+            value={formData.core_weight}
+            onChange={(weight) => updateField('core_weight', weight)}
+            catalogId={formData.core_weight_catalog_id}
+            onCatalogIdChange={(id) => updateField('core_weight_catalog_id', id)}
+          />
+        )}
+      </div>
 
       {/* Current Weight (remaining filament) */}
       <div>
@@ -372,17 +379,6 @@ export function AdditionalSection({
         </p>
       </div>
 
-      {/* Note */}
-      <div>
-        <label className="block text-sm font-medium text-bambu-gray mb-1">{t('inventory.note')}</label>
-        <textarea
-          className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm placeholder:text-bambu-gray/50 focus:outline-none focus:border-bambu-green resize-none min-h-[80px]"
-          placeholder={t('inventory.notePlaceholder')}
-          value={formData.note}
-          onChange={(e) => updateField('note', e.target.value)}
-        />
-      </div>
-
       {/* Storage Location */}
       <div>
         <label className="block text-sm font-medium text-bambu-gray mb-1" htmlFor="spool-storage-location">
@@ -441,6 +437,17 @@ export function AdditionalSection({
           </div>
         )}
       </div>
+
+      {/* Note */}
+      <div className="sm:col-span-2">
+        <label className="block text-sm font-medium text-bambu-gray mb-1">{t('inventory.note')}</label>
+        <textarea
+          className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm placeholder:text-bambu-gray/50 focus:outline-none focus:border-bambu-green resize-none min-h-[80px]"
+          placeholder={t('inventory.notePlaceholder')}
+          value={formData.note}
+          onChange={(e) => updateField('note', e.target.value)}
+        />
+      </div>
     </div>
   );
 }

+ 10 - 2
frontend/src/components/spool-form/FilamentSection.tsx

@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
 import type { FilamentSectionProps, FilamentOption } from './types';
 import { KNOWN_VARIANTS } from './constants';
 import { parsePresetName } from './utils';
+import { PresetSourceBadge } from './PresetPicker';
 
 // The identity fields a slicer preset can auto-fill.
 type PresetFilledField = 'material' | 'brand' | 'subtype';
@@ -268,14 +269,21 @@ export function FilamentSection({
                     <button
                       key={`${option.code}::${option.name}`}
                       type="button"
-                      className={`w-full px-3 py-2 text-left text-sm hover:bg-bambu-dark-tertiary truncate ${
+                      className={`w-full px-3 py-2 text-left text-sm hover:bg-bambu-dark-tertiary flex items-center gap-2 ${
                         selectedPresetOption?.code === option.code
                           ? 'bg-bambu-green/10 text-bambu-green'
                           : 'text-white'
                       }`}
                       onClick={() => handlePresetSelect(option)}
                     >
-                      {option.displayName}
+                      {/* Same origin badge as the Printers tab and the
+                          Configure AMS Slot modal: the same filament exists as
+                          a cloud preset, an imported one and a built-in, and
+                          which is picked decides what reaches the printer. */}
+                      <span className="flex-1 min-w-0 truncate" title={option.displayName}>
+                        {option.displayName}
+                      </span>
+                      <PresetSourceBadge source={option.source} />
                     </button>
                   ))
                 )}

+ 188 - 0
frontend/src/components/spool-form/PresetPicker.tsx

@@ -0,0 +1,188 @@
+import { useEffect, useRef, useState } from 'react';
+import { ChevronDown, Search, X } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
+import type { FilamentOption, FilamentOptionSource } from './types';
+
+/**
+ * Pick one filament preset, showing where each preset came from.
+ *
+ * A native `<select>` cannot carry the origin badge, and the origin is not
+ * decoration: the same filament exists as a Bambu Cloud preset, an Orca Cloud
+ * profile, a locally imported one and a built-in, and which one you pick
+ * decides what actually reaches the printer. The Configure AMS Slot modal has
+ * shown these badges for that reason since #1623; this is the same wording and
+ * the same colours, so the two screens read alike.
+ *
+ * Deliberately not a shared "Select" component: it is a listbox of buttons
+ * because each row is a name plus a badge, and it filters as you type because
+ * a cloud account with a few hundred presets is ordinary.
+ */
+
+const BADGE_CLASSES: Record<FilamentOptionSource, string> = {
+  local: 'bg-green-100 dark:bg-green-500/20 text-green-700 dark:text-green-400',
+  orca_cloud: 'bg-purple-100 dark:bg-purple-500/20 text-purple-700 dark:text-purple-400',
+  cloud: 'bg-bambu-blue/20 text-bambu-blue',
+  builtin: 'bg-amber-100 dark:bg-amber-500/20 text-amber-700 dark:text-amber-400',
+};
+
+export function PresetSourceBadge({ source }: { source: FilamentOptionSource }) {
+  const { t } = useTranslation();
+  // The same four labels the Configure AMS Slot modal uses, by the same keys --
+  // one wording per source across the app rather than a second set to keep in
+  // step.
+  const label = {
+    local: t('profiles.localProfiles.badge'),
+    orca_cloud: t('configureAmsSlot.orcaCloud'),
+    cloud: t('configureAmsSlot.bambuCloud'),
+    builtin: t('configureAmsSlot.builtin'),
+  }[source];
+
+  return (
+    <span className={`text-[10px] px-1.5 py-0.5 rounded whitespace-nowrap ${BADGE_CLASSES[source]}`}>
+      {label}
+    </span>
+  );
+}
+
+interface PresetPickerProps {
+  /** Currently chosen preset code, or '' for "inherit". */
+  value: string;
+  options: FilamentOption[];
+  /** What the empty choice reads as, e.g. "Use the spool's preset". */
+  inheritLabel: string;
+  onChange: (option: FilamentOption | null) => void;
+  disabled?: boolean;
+  ariaLabel: string;
+}
+
+export function PresetPicker({
+  value,
+  options,
+  inheritLabel,
+  onChange,
+  disabled = false,
+  ariaLabel,
+}: PresetPickerProps) {
+  const { t } = useTranslation();
+  const [open, setOpen] = useState(false);
+  const [query, setQuery] = useState('');
+  const rootRef = useRef<HTMLDivElement>(null);
+
+  const selected = options.find(o => o.code === value);
+
+  useEffect(() => {
+    if (!open) return;
+    const onPointerDown = (e: MouseEvent) => {
+      if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false);
+    };
+    // Escape closes this control WITHOUT closing the modal around it, which is
+    // what a bare keydown listener on the document would otherwise let happen.
+    const onKeyDown = (e: KeyboardEvent) => {
+      if (e.key !== 'Escape') return;
+      e.stopPropagation();
+      setOpen(false);
+    };
+    document.addEventListener('mousedown', onPointerDown);
+    document.addEventListener('keydown', onKeyDown, true);
+    return () => {
+      document.removeEventListener('mousedown', onPointerDown);
+      document.removeEventListener('keydown', onKeyDown, true);
+    };
+  }, [open]);
+
+  const needle = query.trim().toLowerCase();
+  const shown = needle
+    ? options.filter(o => o.displayName.toLowerCase().includes(needle))
+    : options;
+
+  const choose = (option: FilamentOption | null) => {
+    onChange(option);
+    setOpen(false);
+    setQuery('');
+  };
+
+  return (
+    <div className="relative" ref={rootRef}>
+      <button
+        type="button"
+        aria-label={ariaLabel}
+        aria-haspopup="listbox"
+        aria-expanded={open}
+        disabled={disabled}
+        onClick={() => setOpen(o => !o)}
+        className="w-full flex items-center gap-2 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-sm text-left focus:outline-none focus:border-bambu-green disabled:opacity-50"
+      >
+        <span className={`flex-1 min-w-0 truncate ${selected ? 'text-white' : 'text-bambu-gray italic'}`}>
+          {selected ? selected.displayName : inheritLabel}
+        </span>
+        {selected && <PresetSourceBadge source={selected.source} />}
+        <ChevronDown className="w-3.5 h-3.5 text-bambu-gray shrink-0" />
+      </button>
+
+      {open && (
+        <div className="absolute z-50 mt-1 w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl">
+          <div className="p-2 border-b border-bambu-dark-tertiary">
+            <div className="relative">
+              <Search className="w-3.5 h-3.5 text-bambu-gray absolute left-2 top-1/2 -translate-y-1/2" />
+              <input
+                autoFocus
+                type="text"
+                value={query}
+                onChange={e => setQuery(e.target.value)}
+                placeholder={t('inventory.searchPresets')}
+                className="w-full pl-7 pr-7 py-1.5 bg-bambu-dark border border-bambu-dark-tertiary rounded-md text-sm text-white placeholder:text-bambu-gray/60 focus:outline-none focus:border-bambu-green"
+              />
+              {query && (
+                <button
+                  type="button"
+                  aria-label={t('common.clear')}
+                  onClick={() => setQuery('')}
+                  className="absolute right-2 top-1/2 -translate-y-1/2 text-bambu-gray hover:text-white"
+                >
+                  <X className="w-3.5 h-3.5" />
+                </button>
+              )}
+            </div>
+          </div>
+
+          <div className="max-h-56 overflow-y-auto p-1" role="listbox" aria-label={ariaLabel}>
+            <button
+              type="button"
+              role="option"
+              aria-selected={!selected}
+              onClick={() => choose(null)}
+              className={`w-full text-left px-2 py-1.5 rounded-md text-sm italic ${
+                selected ? 'text-bambu-gray hover:bg-bambu-dark' : 'bg-bambu-green/15 text-bambu-green'
+              }`}
+            >
+              {inheritLabel}
+            </button>
+            {shown.length === 0 ? (
+              <p className="px-2 py-2 text-sm text-bambu-gray">{t('inventory.noResults')}</p>
+            ) : (
+              shown.map(option => (
+                <button
+                  key={option.code}
+                  type="button"
+                  role="option"
+                  aria-selected={option.code === value}
+                  onClick={() => choose(option)}
+                  className={`w-full flex items-center gap-2 text-left px-2 py-1.5 rounded-md text-sm ${
+                    option.code === value
+                      ? 'bg-bambu-green/15 text-bambu-green'
+                      : 'text-white hover:bg-bambu-dark'
+                  }`}
+                >
+                  <span className="flex-1 min-w-0 truncate" title={option.displayName}>
+                    {option.displayName}
+                  </span>
+                  <PresetSourceBadge source={option.source} />
+                </button>
+              ))
+            )}
+          </div>
+        </div>
+      )}
+    </div>
+  );
+}

+ 604 - 0
frontend/src/components/spool-form/PrinterProfilesSection.tsx

@@ -0,0 +1,604 @@
+import { Fragment, useMemo } from 'react';
+import { Check, Loader2, Printer as PrinterIcon, Sparkles } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
+import type {
+  CalibrationProfile,
+  FilamentOption,
+  PrinterProfilesSectionProps,
+  PrinterWithCalibrations,
+} from './types';
+import { hotendKey, isMatchingCalibration, presetKey } from './utils';
+import { STANDARD_NOZZLE_DIAMETERS } from './constants';
+import { PresetPicker } from './PresetPicker';
+import { extractPresetModel, matchesPrinterModelSuffix } from '../../utils/slicerPrinterMatch';
+
+/**
+ * The spool form's Printers tab: which filament preset this spool uses on each
+ * printer MODEL, and which K profile it uses on each individual hotend.
+ *
+ * The two halves are keyed differently on purpose. A slicer preset is a
+ * property of the model -- "@BBL X1C" is the same preset on every X1C the user
+ * owns -- so asking per machine would make them pick the identical value once
+ * per printer. A K value is measured on one individual hotend, so it stays per
+ * printer, per extruder, per nozzle diameter, which is what both K tables have
+ * always been keyed on.
+ *
+ * Layout is a model list plus a detail pane rather than a stack of cards: the
+ * list is fixed height whatever the fleet size, and the detail pane is bounded
+ * by the largest single model instead of by the total number of printers.
+ *
+ * Nothing here reads `status.nozzles[]` positionally. Which array index belongs
+ * to which extruder is genuinely unsettled in the backend (the H2/X2 and legacy
+ * MQTT parsers disagree), so every nozzle fact on this screen comes from data
+ * that names its own extruder: a calibration profile carries both its
+ * `extruder_id` and its `nozzle_diameter`, and the per-model diameter list is a
+ * deduplicated SET, which no ordering can get wrong.
+ */
+
+interface ModelGroup {
+  /**
+   * Identifies the row. Prefixed so the two kinds cannot collide: `m:` for a
+   * real model, `p:` for a printer that has not reported one. Distinct from
+   * `model` because a model-less printer has no model to be keyed by, and two
+   * of them would otherwise be the same row.
+   */
+  id: string;
+  /** Empty when the printer has not reported a model. */
+  model: string;
+  printers: PrinterWithCalibrations[];
+  /** Distinct nozzle diameters across this model's machines. Order-independent. */
+  diameters: string[];
+}
+
+function distinctDiameters(entry: PrinterWithCalibrations): string[] {
+  const seen = new Set<string>();
+  for (const nozzle of entry.nozzles ?? []) {
+    const raw = (nozzle?.nozzle_diameter ?? '').trim();
+    if (raw && parseFloat(raw) > 0) seen.add(raw);
+  }
+  for (const cal of entry.calibrations) {
+    const raw = (cal.nozzle_diameter ?? '').trim();
+    if (raw && parseFloat(raw) > 0) seen.add(raw);
+  }
+  return Array.from(seen).sort((a, b) => parseFloat(a) - parseFloat(b));
+}
+
+/**
+ * The hotend columns for one printer, in the order they sit on the machine.
+ *
+ * Extruder 0 is the RIGHT hotend and 1 is the left, so a left-to-right table
+ * reads [1, 0]. A single-nozzle machine has one unnamed column -- there is no
+ * side to name.
+ */
+function columnsOf(
+  entry: PrinterWithCalibrations,
+  labels: { left: string; right: string; single: string },
+): Array<{ extruder: number; label: string }> {
+  if ((entry.printer.nozzle_count ?? 1) > 1) {
+    return [
+      { extruder: 1, label: labels.left },
+      { extruder: 0, label: labels.right },
+    ];
+  }
+  return [{ extruder: 0, label: labels.single }];
+}
+
+export function PrinterProfilesSection({
+  formData,
+  printersWithCalibrations,
+  filamentOptions,
+  modelPresets,
+  setModelPresets,
+  selectedProfiles,
+  setSelectedProfiles,
+  selectedGroupId,
+  setSelectedGroupId,
+  printerModels,
+  isLoading = false,
+}: PrinterProfilesSectionProps) {
+  const { t } = useTranslation();
+
+  // Group the fleet by model. A printer whose model the backend has not
+  // reported is grouped under its own name rather than dropped -- it still has
+  // K profiles worth setting, and the preset row is disabled for it below.
+  const groups = useMemo<ModelGroup[]>(() => {
+    const byModel = new Map<string, PrinterWithCalibrations[]>();
+    const modelless: PrinterWithCalibrations[] = [];
+    for (const entry of printersWithCalibrations) {
+      const model = (entry.printer.model || '').trim();
+      if (!model) {
+        modelless.push(entry);
+        continue;
+      }
+      const list = byModel.get(model);
+      if (list) list.push(entry);
+      else byModel.set(model, [entry]);
+    }
+
+    const grouped = Array.from(byModel.entries())
+      .map(([model, printers]) => ({
+        id: `m:${model}`,
+        model,
+        printers,
+        // Every standard size, plus anything unusual this model reports as
+        // fitted. Not just the fitted ones: a spool is configured once and
+        // nozzles get swapped, so the user has to be able to set the preset
+        // for a size they are about to change to.
+        diameters: Array.from(
+          new Set([...STANDARD_NOZZLE_DIAMETERS, ...printers.flatMap(distinctDiameters)]),
+        ).sort((a, b) => parseFloat(a) - parseFloat(b)),
+      }))
+      .sort((a, b) => a.model.localeCompare(b.model));
+
+    // A printer that has not reported its model gets a row of its own, last:
+    // it has K profiles worth setting but cannot share a preset with anything,
+    // and it must not be folded in with other model-less printers.
+    return [
+      ...grouped,
+      ...modelless.map(entry => ({
+        id: `p:${entry.printer.id}`,
+        model: '',
+        printers: [entry],
+        diameters: Array.from(
+          new Set([...STANDARD_NOZZLE_DIAMETERS, ...distinctDiameters(entry)]),
+        ).sort((a, b) => parseFloat(a) - parseFloat(b)),
+      })),
+    ];
+  }, [printersWithCalibrations]);
+
+  const active = groups.find(g => g.id === selectedGroupId) ?? groups[0];
+
+  /**
+   * The presets worth offering for one model.
+   *
+   * A preset name carries the model it belongs to ("@BBL H2C", "@Bambu Lab X1
+   * Carbon", or just "X1C ..." at the front), and offering an X1C preset for an
+   * H2C is offering something that machine has no profile for -- which is the
+   * bug this whole tab exists to fix. Uses the same matcher the Configure AMS
+   * Slot modal filters with, so the two lists agree.
+   *
+   * Two things are deliberately kept: a preset whose model cannot be read at
+   * all (many user-authored and Orca presets name no model), because hiding
+   * what we cannot classify would hide most third-party profiles; and whatever
+   * is currently selected, so an override already saved never silently
+   * disappears from the control that shows it.
+   */
+  const optionsForModel = useMemo(() => {
+    const cache = new Map<string, FilamentOption[]>();
+    return (model: string, selected: string | undefined): FilamentOption[] => {
+      if (!model) return filamentOptions;
+      let list = cache.get(model);
+      if (!list) {
+        list = filamentOptions.filter(option => {
+          const presetModel = extractPresetModel(option.name, printerModels ?? {});
+          return !presetModel || matchesPrinterModelSuffix(presetModel, model);
+        });
+        cache.set(model, list);
+      }
+      if (selected && !list.some(o => o.code === selected)) {
+        const kept = filamentOptions.find(o => o.code === selected);
+        if (kept) return [kept, ...list];
+      }
+      return list;
+    };
+  }, [filamentOptions, printerModels]);
+
+  // "Bambu PLA Matte", from whichever of the three fields are filled in. Blank
+  // parts are skipped rather than padded with "Any brand", which reads as a
+  // filter setting rather than as what the spool is.
+  const identity = [formData.brand, formData.material, formData.subtype]
+    .map(part => part.trim())
+    .filter(Boolean)
+    .join(' ');
+
+  // The spool's own colour. rgba is RRGGBBAA; the alpha is dropped because a
+  // translucent swatch would show the panel behind it rather than the filament.
+  const swatch = /^[0-9A-Fa-f]{6,8}$/.test(formData.rgba)
+    ? `#${formData.rgba.slice(0, 6)}`
+    : 'var(--bambu-gray, #808080)';
+
+  const columns = (entry: PrinterWithCalibrations) =>
+    columnsOf(entry, {
+      left: t('inventory.leftNozzle'),
+      right: t('inventory.rightNozzle'),
+      single: t('inventory.nozzle'),
+    });
+
+  const matchingFor = (entry: PrinterWithCalibrations) =>
+    entry.printer.connected
+      ? entry.calibrations.filter(cal => isMatchingCalibration(cal, formData))
+      : [];
+
+  /**
+   * Grid cells that could hold a K profile but do not -- the left rail's
+   * "unset" badge. Only cells with something to choose are counted: a size the
+   * printer has no calibration for is not an unfinished decision.
+   */
+  const unsetCount = (group: ModelGroup) => {
+    let unset = 0;
+    for (const entry of group.printers) {
+      const matching = matchingFor(entry);
+      for (const column of columns(entry)) {
+        for (const diameter of group.diameters) {
+          const hasCandidate = matching.some(
+            cal =>
+              (cal.extruder_id ?? 0) === column.extruder
+              && ((cal.nozzle_diameter ?? '').trim() || '0.4') === diameter,
+          );
+          if (!hasCandidate) continue;
+          if (!selectedProfiles.get(hotendKey(entry.printer.id, column.extruder, diameter))) unset++;
+        }
+      }
+    }
+    return unset;
+  };
+
+  const setPreset = (model: string, diameter: string, option: FilamentOption | null) => {
+    setModelPresets(prev => {
+      const next = new Map(prev);
+      const key = presetKey(model, diameter);
+      // Removing the entry is what "inherited" means -- the backend cascade
+      // falls through to the spool's own preset when no row exists. Storing a
+      // row that repeats the spool's value would freeze it instead: later
+      // edits to the spool preset would stop reaching this model.
+      if (!option) next.delete(key);
+      else next.set(key, { code: option.code, name: option.name });
+      return next;
+    });
+  };
+
+  const chooseProfile = (
+    printerId: number,
+    extruder: number,
+    diameter: string,
+    cal: CalibrationProfile | null,
+  ) => {
+    setSelectedProfiles(prev => {
+      const next = new Map(prev);
+      const key = hotendKey(printerId, extruder, diameter);
+      if (!cal) next.delete(key);
+      else next.set(key, cal);
+      return next;
+    });
+  };
+
+  /**
+   * Fill each model's preset with the variant of the spool's own preset that
+   * names that model. Preset names are mechanical ("Bambu PLA Basic @BBL X1C"),
+   * so the match is a name comparison, not a guess about filament identity: a
+   * model with no such variant is left inherited rather than given something
+   * approximate.
+   */
+  const autoMatch = () => {
+    const base = filamentOptions.find(o => o.code === formData.slicer_filament);
+    if (!base) return;
+    const stem = base.name.split('@')[0].trim().toLowerCase();
+    if (!stem) return;
+
+    setModelPresets(prev => {
+      const next = new Map(prev);
+      for (const group of groups) {
+        if (!group.model) continue;
+        // Only presets that name this model. An unclassifiable one stays in the
+        // list to be picked by hand but is never assigned for the user.
+        const candidates = optionsForModel(group.model, undefined).filter(
+          option =>
+            option.name.toLowerCase().startsWith(stem)
+            && extractPresetModel(option.name, printerModels ?? {}) !== null,
+        );
+        if (candidates.length === 0) continue;
+
+        for (const diameter of group.diameters) {
+          // Bambu names the size in the preset ("@BBL X1C 0.4 nozzle"), so
+          // prefer the variant for this size and fall back to one that names
+          // the model without a size. A size with neither is left inherited --
+          // an approximate preset is worse than the spool's own.
+          const sized = candidates.find(option =>
+            new RegExp(`\\b${diameter.replace('.', '\\.')}\\s*nozzle\\b`, 'i').test(option.name),
+          );
+          const unsized = candidates.find(option => !/\b[\d.]+\s*nozzle\b/i.test(option.name));
+          const match = sized ?? unsized;
+          if (match) next.set(presetKey(group.model, diameter), { code: match.code, name: match.name });
+        }
+      }
+      return next;
+    });
+  };
+
+  if (printersWithCalibrations.length === 0) {
+    return (
+      <div className="p-6 bg-bambu-dark rounded-lg text-center">
+        {/* "No printers configured" is a claim about the user's setup and must
+            not be made while the printers are still being asked -- reading each
+            one's calibration table is several MQTT round trips. */}
+        <p className="text-bambu-gray flex items-center justify-center gap-2">
+          {isLoading && <Loader2 className="w-4 h-4 animate-spin" />}
+          {isLoading ? t('common.loading') : t('inventory.noPrintersConfigured')}
+        </p>
+      </div>
+    );
+  }
+
+  const renderPresetRow = (model: string, diameter: string, inheritLabel: string) => {
+    const key = presetKey(model, diameter);
+    const chosen = modelPresets.get(key);
+    const options = optionsForModel(model, chosen?.code);
+    return (
+      <div className="flex items-center gap-2">
+        <div className="flex-1 min-w-0">
+          <PresetPicker
+            ariaLabel={`${model} ${diameter}mm ${t('inventory.filamentPreset')}`}
+            value={chosen?.code ?? ''}
+            options={options}
+            inheritLabel={inheritLabel}
+            disabled={!model}
+            onChange={option => setPreset(model, diameter, option)}
+          />
+        </div>
+        <span
+          className={`text-[10px] font-semibold uppercase tracking-wide px-2 py-1 rounded-full shrink-0 ${
+            chosen ? 'bg-bambu-green/20 text-bambu-green' : 'bg-bambu-dark-tertiary text-bambu-gray'
+          }`}
+        >
+          {chosen ? t('inventory.presetOverride') : t('inventory.presetInherited')}
+        </span>
+      </div>
+    );
+  };
+
+  return (
+    <div className="space-y-3">
+      {/* Which spool is being configured. Worth a line of its own here: this
+          tab is the one place you read printer names rather than filament, and
+          the K-profile lists below are filtered by exactly these fields --
+          brand, material and subtype -- so an empty list is explained by what
+          this line says. */}
+      {(identity || formData.color_name) && (
+        <div className="flex items-center gap-2.5 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg">
+          <span
+            className="w-5 h-5 rounded-full border border-white/15 shrink-0"
+            style={{ background: swatch }}
+            aria-hidden="true"
+          />
+          {identity && <span className="text-sm text-white truncate">{identity}</span>}
+          {formData.color_name && (
+            <span className="text-sm text-bambu-gray truncate">{formData.color_name}</span>
+          )}
+        </div>
+      )}
+
+      <div className="flex flex-col md:flex-row gap-4">
+      {/* Model list. Sticky rather than its own scroll region: a second
+          scrollbar inside the modal's own scrolling body means the user has to
+          find which one moves the thing they are looking at. */}
+      <div
+        role="tablist"
+        aria-label={t('inventory.printersTab')}
+        aria-orientation="vertical"
+        className="md:w-52 md:shrink-0 md:self-start md:sticky md:top-0 flex md:flex-col gap-1.5 overflow-x-auto md:overflow-x-visible"
+      >
+        {groups.map(group => {
+          const isActive = group === active;
+          const unset = unsetCount(group);
+          return (
+            <button
+              key={group.id}
+              type="button"
+              role="tab"
+              onClick={() => setSelectedGroupId(group.id)}
+              aria-selected={isActive}
+              className={`flex items-center gap-2 px-3 py-2 rounded-lg border text-left transition-colors shrink-0 md:shrink ${
+                isActive
+                  ? 'bg-bambu-green/10 border-bambu-green/40 text-white'
+                  : 'bg-transparent border-transparent text-bambu-gray hover:bg-bambu-dark hover:text-white'
+              }`}
+            >
+              <div className="min-w-0 flex-1">
+                <div className="text-sm font-semibold truncate">
+                  {group.model || t('inventory.unknownModel')}
+                </div>
+                <div className="text-[11px] text-bambu-gray">
+                  {group.printers.length === 1
+                    ? t('inventory.onePrinter')
+                    : t('inventory.nPrinters', { n: group.printers.length })}
+                </div>
+              </div>
+              {unset > 0 ? (
+                <span className="text-[10px] font-mono px-1.5 py-0.5 rounded-full bg-bambu-dark-tertiary text-bambu-gray shrink-0">
+                  {unset}
+                </span>
+              ) : (
+                <Check className="w-3.5 h-3.5 text-bambu-green shrink-0" />
+              )}
+            </button>
+          );
+        })}
+      </div>
+
+      {/* Detail */}
+      <div className="flex-1 min-w-0">
+        {active && (
+          <div className="space-y-4">
+            <div className="flex items-start justify-between gap-3">
+              <div className="min-w-0">
+                <h4 className="text-base font-semibold text-white truncate">
+                  {active.model || t('inventory.unknownModel')}
+                </h4>
+                <p className="text-xs text-bambu-gray">
+                  {active.printers.map(p => p.printer.name).join(', ')}
+                </p>
+              </div>
+              {formData.slicer_filament && (
+                <button
+                  type="button"
+                  onClick={autoMatch}
+                  title={t('inventory.autoMatchPresetsHint')}
+                  className="flex items-center gap-1.5 px-2 py-1 text-xs bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-bambu-gray hover:text-white hover:border-bambu-green transition-colors shrink-0"
+                >
+                  <Sparkles className="w-3.5 h-3.5" />
+                  {t('inventory.autoMatchPresets')}
+                </button>
+              )}
+            </div>
+
+            {/* Filament preset — model scoped */}
+            <div className="space-y-2">
+              <p className="text-xs font-semibold text-bambu-gray uppercase tracking-wide">
+                {t('inventory.filamentPreset')}
+              </p>
+              {!active.model ? (
+                <p className="text-sm text-bambu-gray italic">{t('inventory.presetNeedsModel')}</p>
+              ) : (
+                /* One row per nozzle size, and no model-wide row above them:
+                   the preset is written to an AMS slot, a slot feeds exactly
+                   one nozzle, and Bambu names its presets per size anyway
+                   ("@BBL X1C 0.4 nozzle"). A size left alone falls straight
+                   back to the spool's own preset. */
+                <div className="space-y-2">
+                  {active.diameters.map(diameter => (
+                    <div key={diameter} className="flex items-center gap-3">
+                      <span className="text-xs font-mono text-bambu-gray w-14 shrink-0">
+                        {diameter}mm
+                      </span>
+                      <div className="flex-1 min-w-0">
+                        {renderPresetRow(
+                          active.model,
+                          diameter,
+                          t('inventory.presetUseSpoolDefault'),
+                        )}
+                      </div>
+                    </div>
+                  ))}
+                </div>
+              )}
+            </div>
+
+            {/* K profiles — machine scoped */}
+            <div className="space-y-2">
+              <p className="text-xs font-semibold text-bambu-gray uppercase tracking-wide">
+                {t('inventory.kProfilesPerPrinter')}
+              </p>
+              {!formData.material ? (
+                <p className="text-sm text-bambu-gray italic">{t('inventory.selectMaterialFirst')}</p>
+              ) : (
+                active.printers.map(entry => {
+                  const matching = matchingFor(entry);
+                  return (
+                    <div
+                      key={entry.printer.id}
+                      className="p-3 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg"
+                    >
+                      <div className="flex items-center gap-2 mb-1">
+                        <PrinterIcon className="w-3.5 h-3.5 text-bambu-gray shrink-0" />
+                        <span className="text-sm font-semibold text-white truncate">
+                          {entry.printer.name}
+                        </span>
+                        <span
+                          className={`text-[10px] font-semibold uppercase tracking-wide px-2 py-0.5 rounded-full shrink-0 ${
+                            entry.printer.connected
+                              ? 'bg-green-500/20 text-green-500'
+                              : 'bg-bambu-dark-tertiary text-bambu-gray'
+                          }`}
+                        >
+                          {entry.printer.connected
+                            ? t('inventory.connected')
+                            : t('inventory.offline')}
+                        </span>
+                      </div>
+
+                      {!entry.printer.connected ? (
+                        <p className="text-sm text-bambu-gray italic py-1">
+                          {t('inventory.printerOffline')}
+                        </p>
+                      ) : matching.length === 0 ? (
+                        <p className="text-sm text-bambu-gray italic py-1">
+                          {t('inventory.noKProfilesMatch')}
+                        </p>
+                      ) : (
+                        /* A grid rather than a list of rows: nozzle size down
+                           the side, hotend across the top. A dual-nozzle
+                           machine has up to eight cells, and stacked rows made
+                           that a scroll where a table is a glance. Columns run
+                           left-then-right to match the machine, which is the
+                           reverse of the extruder ids behind them (extruder 0
+                           is the RIGHT hotend). */
+                        <div
+                          className="grid gap-x-3 gap-y-1.5 items-center"
+                          style={{
+                            gridTemplateColumns: `3.5rem repeat(${columns(entry).length}, minmax(0, 1fr))`,
+                          }}
+                        >
+                          <span />
+                          {columns(entry).map(column => (
+                            <span
+                              key={column.extruder}
+                              className="text-[11px] font-semibold uppercase tracking-wide text-bambu-gray"
+                            >
+                              {column.label}
+                            </span>
+                          ))}
+
+                          {active.diameters.map(diameter => (
+                            <Fragment key={diameter}>
+                              <span className="text-xs font-mono text-bambu-gray">{diameter}mm</span>
+                              {columns(entry).map(column => {
+                                const candidates = matching.filter(
+                                  cal =>
+                                    (cal.extruder_id ?? 0) === column.extruder
+                                    && ((cal.nozzle_diameter ?? '').trim() || '0.4') === diameter,
+                                );
+                                const key = hotendKey(entry.printer.id, column.extruder, diameter);
+                                const chosen = selectedProfiles.get(key);
+                                if (candidates.length === 0) {
+                                  return (
+                                    // The printer has no calibration for this
+                                    // size on this hotend. Shown rather than
+                                    // omitted so the size is visibly accounted
+                                    // for instead of looking forgotten.
+                                    <span
+                                      key={key}
+                                      className="text-xs text-bambu-gray/50 px-2 py-1.5"
+                                      title={t('inventory.noKProfilesMatch')}
+                                    >
+                                      &mdash;
+                                    </span>
+                                  );
+                                }
+                                return (
+                                  <select
+                                    key={key}
+                                    aria-label={`${entry.printer.name} ${column.label} ${diameter}mm`}
+                                    value={chosen ? String(chosen.cali_idx) : ''}
+                                    onChange={e => {
+                                      const cal =
+                                        candidates.find(c => String(c.cali_idx) === e.target.value)
+                                        ?? null;
+                                      chooseProfile(entry.printer.id, column.extruder, diameter, cal);
+                                    }}
+                                    className="min-w-0 px-2 py-1.5 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-sm text-white focus:outline-none focus:border-bambu-green"
+                                  >
+                                    <option value="">{t('inventory.kProfileNotSet')}</option>
+                                    {candidates.map(cal => (
+                                      <option key={cal.cali_idx} value={cal.cali_idx}>
+                                        {`${cal.name || cal.filament_id}  K=${cal.k_value.toFixed(3)}`}
+                                      </option>
+                                    ))}
+                                  </select>
+                                );
+                              })}
+                            </Fragment>
+                          ))}
+                        </div>
+                      )}
+                    </div>
+                  );
+                })
+              )}
+            </div>
+          </div>
+        )}
+      </div>
+      </div>
+    </div>
+  );
+}

+ 10 - 0
frontend/src/components/spool-form/constants.ts

@@ -107,3 +107,13 @@ export const ALL_COLORS: ColorPreset[] = [...QUICK_COLORS, ...EXTENDED_COLORS];
 // Local storage keys
 export const RECENT_COLORS_KEY = 'bambuddy-recent-colors';
 export const MAX_RECENT_COLORS = 8;
+
+/**
+ * The nozzle sizes Bambu sells, smallest first.
+ *
+ * The spool form offers a filament preset and a K profile for every one of
+ * them, not only the size currently screwed into the machine: a spool is
+ * configured once and nozzles get swapped, and the calibration table on the
+ * printer keeps entries per diameter regardless of what is fitted right now.
+ */
+export const STANDARD_NOZZLE_DIAMETERS = ['0.2', '0.4', '0.6', '0.8'];

+ 48 - 0
frontend/src/components/spool-form/types.ts

@@ -72,6 +72,20 @@ export const defaultFormData: SpoolFormData = {
 export interface PrinterWithCalibrations {
   printer: Printer & { connected?: boolean };
   calibrations: CalibrationProfile[];
+  // Nozzle hardware as the printer reports it, kept so the Printers tab can
+  // list a model's installed diameters. Read as a SET of diameters only --
+  // never indexed by extruder, because which array position belongs to which
+  // extruder is unsettled between the two MQTT parsers. Optional: callers that
+  // predate the Printers tab (SpoolBuddy's write-tag page) do not supply it.
+  nozzles?: { nozzle_diameter?: string }[];
+}
+
+// One spool's chosen preset for a printer model, as the Printers tab holds it
+// before it is saved. `name` is kept alongside the code so the row can be
+// rendered without re-searching the preset list.
+export interface PresetChoice {
+  code: string;
+  name: string;
 }
 
 // Calibration profile from printer status
@@ -86,6 +100,39 @@ export interface CalibrationProfile {
   nozzle_diameter?: string;
 }
 
+// Printers tab props. `modelPresets` is keyed by `presetKey(model, diameter)`
+// and holds only the models the user has overridden -- an absent entry is
+// "inherit the spool's own preset", which is exactly what the backend cascade
+// does with a missing row. `selectedProfiles` is keyed by hotend
+// (`printerId:extruder:diameter`), one K profile per hotend by construction.
+export interface PrinterProfilesSectionProps {
+  formData: SpoolFormData;
+  printersWithCalibrations: PrinterWithCalibrations[];
+  filamentOptions: FilamentOption[];
+  modelPresets: Map<string, PresetChoice>;
+  setModelPresets: React.Dispatch<React.SetStateAction<Map<string, PresetChoice>>>;
+  selectedProfiles: Map<string, CalibrationProfile>;
+  setSelectedProfiles: React.Dispatch<React.SetStateAction<Map<string, CalibrationProfile>>>;
+  // Which row of the model list is open. A group id (see ModelGroup), not a
+  // model name: a printer that has not reported its model still gets a row.
+  selectedGroupId: string;
+  setSelectedGroupId: (groupId: string) => void;
+  // Backend printer-model registry ("Bambu Lab X1 Carbon" -> "X1C"), used to
+  // read the model out of a preset name so each model is offered only the
+  // presets that belong to it. Undefined until the query resolves, which just
+  // means no filtering yet rather than an empty list.
+  printerModels?: Record<string, string>;
+  // True while the printers are still being asked for their calibration
+  // tables. Distinguishes "no printers" from "not answered yet": the fetch is
+  // several MQTT round trips per machine, so the gap is seconds, not a frame.
+  isLoading?: boolean;
+}
+
+// Where a filament option came from. Shown as a badge beside the name, using
+// the same wording and colours as the Configure AMS Slot modal, so "which of
+// my four preset sources is this?" reads the same everywhere in the app.
+export type FilamentOptionSource = 'cloud' | 'orca_cloud' | 'local' | 'builtin';
+
 // Filament option from presets
 export interface FilamentOption {
   code: string;
@@ -93,6 +140,7 @@ export interface FilamentOption {
   displayName: string;
   isCustom: boolean;
   allCodes: string[];
+  source: FilamentOptionSource;
 }
 
 // Color preset

+ 82 - 23
frontend/src/components/spool-form/utils.ts

@@ -2,7 +2,7 @@ import { api } from '../../api/client';
 import type { SlicerSetting, LocalPreset, BuiltinFilament } from '../../api/client';
 import { installedNozzleDiameters } from '../../utils/amsHelpers';
 import type { CalibrationProfile, ColorPreset, FilamentOption } from './types';
-import { KNOWN_VARIANTS, DEFAULT_BRANDS, RECENT_COLORS_KEY, MAX_RECENT_COLORS } from './constants';
+import { KNOWN_VARIANTS, DEFAULT_BRANDS, RECENT_COLORS_KEY, MAX_RECENT_COLORS, STANDARD_NOZZLE_DIAMETERS } from './constants';
 
 /**
  * Fetch a printer's K-profiles across every nozzle it actually has installed
@@ -19,11 +19,30 @@ export async function fetchPrinterCalibrations(
   printerId: number,
   status: { nozzles?: { nozzle_diameter?: string }[] } | null | undefined,
 ): Promise<CalibrationProfile[]> {
-  const diameters = installedNozzleDiameters(status);
-  const toFetch = diameters.length > 0 ? diameters : ['0.4'];
-  const responses = await Promise.all(
-    toFetch.map(d => api.getKProfiles(printerId, d).catch(() => null)),
-  );
+  // Every standard size, plus anything unusual the printer reports as fitted --
+  // not just the fitted ones. A K profile is stored on the printer per nozzle
+  // diameter and survives a nozzle swap, so fetching only what is screwed in
+  // right now made a 0.6 profile invisible until a 0.6 was fitted, and left the
+  // user unable to prepare a spool for a nozzle they are about to change to.
+  // A diameter the printer has nothing for answers with an empty list rather
+  // than timing out, so the extra sizes cost a round trip each, not a stall --
+  // provided they are sent one at a time, see below.
+  const installed = installedNozzleDiameters(status);
+  const toFetch = Array.from(new Set([...STANDARD_NOZZLE_DIAMETERS, ...installed]));
+
+  // One diameter at a time, NOT in parallel. H2-series firmware answers only
+  // the first one or two of a concurrent burst of `extrusion_cali_get` and
+  // silently drops the rest, so each dropped request costs a 5s timeout before
+  // it is retried: measured on an H2C and an H2D, four parallel requests took
+  // 11s and 23s respectively, against ~1s when sent one after another. (An X1C
+  // answers all four concurrently, which is why this went unnoticed while only
+  // dual-diameter printers ever sent more than one.) Per-diameter failures are
+  // still swallowed: a printer that doesn't support the endpoint yields no rows
+  // rather than losing the diameters that did answer.
+  const responses = [];
+  for (const diameter of toFetch) {
+    responses.push(await api.getKProfiles(printerId, diameter).catch(() => null));
+  }
   const calibrations: CalibrationProfile[] = [];
   for (const res of responses) {
     if (!res) continue;
@@ -45,23 +64,23 @@ export async function fetchPrinterCalibrations(
 
 // Fallback filament presets when cloud is not available
 const FALLBACK_PRESETS: FilamentOption[] = [
-  { code: 'GFL00', name: 'Bambu PLA Basic', displayName: 'Bambu PLA Basic', isCustom: false, allCodes: ['GFL00'] },
-  { code: 'GFL01', name: 'Bambu PLA Matte', displayName: 'Bambu PLA Matte', isCustom: false, allCodes: ['GFL01'] },
-  { code: 'GFL05', name: 'Generic PLA', displayName: 'Generic PLA', isCustom: false, allCodes: ['GFL05'] },
-  { code: 'GFG00', name: 'Bambu PETG Basic', displayName: 'Bambu PETG Basic', isCustom: false, allCodes: ['GFG00'] },
-  { code: 'GFG05', name: 'Generic PETG', displayName: 'Generic PETG', isCustom: false, allCodes: ['GFG05'] },
-  { code: 'GFB00', name: 'Bambu ABS Basic', displayName: 'Bambu ABS Basic', isCustom: false, allCodes: ['GFB00'] },
-  { code: 'GFB05', name: 'Generic ABS', displayName: 'Generic ABS', isCustom: false, allCodes: ['GFB05'] },
-  { code: 'GFA00', name: 'Bambu ASA Basic', displayName: 'Bambu ASA Basic', isCustom: false, allCodes: ['GFA00'] },
-  { code: 'GFU00', name: 'Bambu TPU 95A', displayName: 'Bambu TPU 95A', isCustom: false, allCodes: ['GFU00'] },
-  { code: 'GFU05', name: 'Generic TPU', displayName: 'Generic TPU', isCustom: false, allCodes: ['GFU05'] },
-  { code: 'GFC00', name: 'Bambu PC Basic', displayName: 'Bambu PC Basic', isCustom: false, allCodes: ['GFC00'] },
-  { code: 'GFN00', name: 'Bambu PA Basic', displayName: 'Bambu PA Basic', isCustom: false, allCodes: ['GFN00'] },
-  { code: 'GFN05', name: 'Generic PA', displayName: 'Generic PA', isCustom: false, allCodes: ['GFN05'] },
-  { code: 'GFS00', name: 'Bambu PLA-CF', displayName: 'Bambu PLA-CF', isCustom: false, allCodes: ['GFS00'] },
-  { code: 'GFT00', name: 'Bambu PETG-CF', displayName: 'Bambu PETG-CF', isCustom: false, allCodes: ['GFT00'] },
-  { code: 'GFNC0', name: 'Bambu PA-CF', displayName: 'Bambu PA-CF', isCustom: false, allCodes: ['GFNC0'] },
-  { code: 'GFV00', name: 'Bambu PVA', displayName: 'Bambu PVA', isCustom: false, allCodes: ['GFV00'] },
+  { code: 'GFL00', name: 'Bambu PLA Basic', displayName: 'Bambu PLA Basic', isCustom: false, allCodes: ['GFL00'], source: 'builtin' },
+  { code: 'GFL01', name: 'Bambu PLA Matte', displayName: 'Bambu PLA Matte', isCustom: false, allCodes: ['GFL01'], source: 'builtin' },
+  { code: 'GFL05', name: 'Generic PLA', displayName: 'Generic PLA', isCustom: false, allCodes: ['GFL05'], source: 'builtin' },
+  { code: 'GFG00', name: 'Bambu PETG Basic', displayName: 'Bambu PETG Basic', isCustom: false, allCodes: ['GFG00'], source: 'builtin' },
+  { code: 'GFG05', name: 'Generic PETG', displayName: 'Generic PETG', isCustom: false, allCodes: ['GFG05'], source: 'builtin' },
+  { code: 'GFB00', name: 'Bambu ABS Basic', displayName: 'Bambu ABS Basic', isCustom: false, allCodes: ['GFB00'], source: 'builtin' },
+  { code: 'GFB05', name: 'Generic ABS', displayName: 'Generic ABS', isCustom: false, allCodes: ['GFB05'], source: 'builtin' },
+  { code: 'GFA00', name: 'Bambu ASA Basic', displayName: 'Bambu ASA Basic', isCustom: false, allCodes: ['GFA00'], source: 'builtin' },
+  { code: 'GFU00', name: 'Bambu TPU 95A', displayName: 'Bambu TPU 95A', isCustom: false, allCodes: ['GFU00'], source: 'builtin' },
+  { code: 'GFU05', name: 'Generic TPU', displayName: 'Generic TPU', isCustom: false, allCodes: ['GFU05'], source: 'builtin' },
+  { code: 'GFC00', name: 'Bambu PC Basic', displayName: 'Bambu PC Basic', isCustom: false, allCodes: ['GFC00'], source: 'builtin' },
+  { code: 'GFN00', name: 'Bambu PA Basic', displayName: 'Bambu PA Basic', isCustom: false, allCodes: ['GFN00'], source: 'builtin' },
+  { code: 'GFN05', name: 'Generic PA', displayName: 'Generic PA', isCustom: false, allCodes: ['GFN05'], source: 'builtin' },
+  { code: 'GFS00', name: 'Bambu PLA-CF', displayName: 'Bambu PLA-CF', isCustom: false, allCodes: ['GFS00'], source: 'builtin' },
+  { code: 'GFT00', name: 'Bambu PETG-CF', displayName: 'Bambu PETG-CF', isCustom: false, allCodes: ['GFT00'], source: 'builtin' },
+  { code: 'GFNC0', name: 'Bambu PA-CF', displayName: 'Bambu PA-CF', isCustom: false, allCodes: ['GFNC0'], source: 'builtin' },
+  { code: 'GFV00', name: 'Bambu PVA', displayName: 'Bambu PVA', isCustom: false, allCodes: ['GFV00'], source: 'builtin' },
 ];
 
 // Parse a slicer preset name to extract brand, material, and variant
@@ -189,6 +208,7 @@ function buildLocalFilamentOptions(localPresets: LocalPreset[]): FilamentOption[
       displayName: preset.name,
       isCustom: false,
       allCodes,
+      source: 'local',
     };
   });
   return options.sort((a, b) => a.displayName.localeCompare(b.displayName));
@@ -204,6 +224,12 @@ export function buildFilamentOptions(
   configuredPrinterModels: Set<string>,
   localPresets?: LocalPreset[],
   builtinFilaments?: BuiltinFilament[],
+  // Which of the cloud presets came from Orca Cloud rather than Bambu Cloud.
+  // The two are merged into one list by the callers (OrcaProfileMeta is
+  // structurally identical to SlicerSetting), so the distinction has to be
+  // carried in rather than derived -- and it is worth carrying, because the
+  // origin badge is the only thing telling a user which cloud a preset is in.
+  orcaSettingIds?: Set<string>,
 ): FilamentOption[] {
   const customPresets: FilamentOption[] = [];
   const defaultPresets: FilamentOption[] = [];
@@ -227,6 +253,7 @@ export function buildFilamentOptions(
           displayName: `${preset.name} (Custom)`,
           isCustom: true,
           allCodes: [preset.setting_id],
+          source: orcaSettingIds?.has(preset.setting_id) ? 'orca_cloud' : 'cloud',
         });
         cloudCodes.add(preset.setting_id);
       }
@@ -237,6 +264,7 @@ export function buildFilamentOptions(
         displayName: preset.name,
         isCustom: false,
         allCodes: [preset.setting_id],
+        source: orcaSettingIds?.has(preset.setting_id) ? 'orca_cloud' : 'cloud',
       });
       cloudCodes.add(preset.setting_id);
     }
@@ -263,6 +291,7 @@ export function buildFilamentOptions(
         displayName: bf.name,
         isCustom: false,
         allCodes: [bf.filament_id, settingId],
+        source: 'builtin',
       });
     }
   }
@@ -423,6 +452,36 @@ export function genericFilamentIdMatchesMaterial(id: string, material: string):
 }
 
 // Check if a calibration matches based on brand, material, and variant
+/**
+ * Key one hotend's K-profile selection: printer, extruder, nozzle diameter.
+ *
+ * All three because that is what both K tables are keyed on -- a K value is
+ * measured on one individual hotend, and cali_idx alone cannot identify one
+ * (the printer numbers its calibration table PER NOZZLE, so index 16 exists on
+ * both hotends of a dual-nozzle machine meaning different things).
+ */
+export function hotendKey(printerId: number, extruder: number, diameter: string): string {
+  return `${printerId}:${extruder}:${diameter}`;
+}
+
+/**
+ * Key one per-printer-model preset override.
+ *
+ * Diameter "" is the model's own default and a bare decimal is a per-hotend
+ * exception, matching the (printer_model, nozzle_diameter) pair the backend
+ * stores. The separator is a NUL because a model name is free text from the
+ * printer ("A1 mini") and must never be able to collide with a diameter.
+ */
+export function presetKey(model: string, diameter: string): string {
+  return `${model}\u0000${diameter}`;
+}
+
+/** Split a `presetKey` back into its model and diameter. */
+export function parsePresetKey(key: string): { model: string; diameter: string } {
+  const [model = '', diameter = ''] = key.split('\u0000');
+  return { model, diameter };
+}
+
 export function isMatchingCalibration(
   cal: { name?: string; filament_id?: string },
   formData: { material: string; brand: string; subtype: string; slicer_filament?: string },

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

@@ -4777,6 +4777,8 @@ export default {
     advancedSettings: 'Erweiterte Einstellungen',
     filamentInfoTab: 'Filament-Info',
     paProfileTab: 'PA-Profil',
+    colorAndCostTab: 'Farbe & Kosten',
+    printersTab: 'Drucker',
     filamentInfo: 'Filament',
     additional: 'Zusätzlich',
     loadingPresets: 'Cloud-Presets werden geladen...',
@@ -4833,6 +4835,22 @@ export default {
     leftNozzle: 'Linke Düse',
     rightNozzle: 'Rechte Düse',
     profilesSelected: 'Kalibrierungsprofil(e) ausgewählt',
+    // Printers tab: per-model filament preset + per-hotend K profile
+    filamentPreset: 'Filament-Voreinstellung',
+    presetUseSpoolDefault: 'Voreinstellung der Spule verwenden',
+    presetInherited: 'geerbt',
+    presetOverride: 'überschrieben',
+    presetNeedsModel: 'Dieser Drucker hat sein Modell noch nicht gemeldet und kann daher keine eigene Voreinstellung haben.',
+    autoMatchPresets: 'Automatisch zuordnen',
+    autoMatchPresetsHint: 'Die Variante der Spulen-Voreinstellung suchen, die das jeweilige Modell nennt',
+    kProfilesPerPrinter: 'K-Profile',
+    kProfileNotSet: 'Nicht gesetzt',
+    nozzle: 'Düse',
+    unknownModel: 'Unbekanntes Modell',
+    onePrinter: '1 Drucker',
+    nPrinters: '{{n}} Drucker',
+    filamentPresetsLoadFailed: 'Die modellspezifischen Voreinstellungen dieser Spule konnten nicht geladen werden',
+    filamentPresetSaveFailed: 'Die modellspezifischen Voreinstellungen konnten nicht gespeichert werden',
     // Stats & enhanced table
     totalInventory: 'Gesamtbestand',
     totalConsumed: 'Gesamtverbrauch',

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

@@ -4819,6 +4819,8 @@ export default {
     // Tabs
     filamentInfoTab: 'Filament Info',
     paProfileTab: 'PA Profile',
+    colorAndCostTab: 'Color & Cost',
+    printersTab: 'Printers',
     filamentInfo: 'Filament',
     additional: 'Additional',
     // Cloud
@@ -4878,6 +4880,22 @@ export default {
     leftNozzle: 'Left Nozzle',
     rightNozzle: 'Right Nozzle',
     profilesSelected: 'calibration profile(s) selected',
+    // Printers tab: per-model filament preset + per-hotend K profile
+    filamentPreset: 'Filament preset',
+    presetUseSpoolDefault: 'Use the spool\'s preset',
+    presetInherited: 'inherited',
+    presetOverride: 'override',
+    presetNeedsModel: 'This printer has not reported its model yet, so it cannot have its own preset.',
+    autoMatchPresets: 'Auto-match',
+    autoMatchPresetsHint: 'Find the variant of this spool\'s preset that names each model',
+    kProfilesPerPrinter: 'K profiles',
+    kProfileNotSet: 'Not set',
+    nozzle: 'Nozzle',
+    unknownModel: 'Unknown model',
+    onePrinter: '1 printer',
+    nPrinters: '{{n}} printers',
+    filamentPresetsLoadFailed: 'Could not load this spool\'s per-model presets',
+    filamentPresetSaveFailed: 'Could not save the per-model presets',
     // Stats & enhanced table
     totalInventory: 'Total Inventory',
     totalConsumed: 'Total Consumed',

+ 18 - 0
frontend/src/i18n/locales/es.ts

@@ -4781,6 +4781,8 @@ export default {
     // Tabs
     filamentInfoTab: 'Información del filamento',
     paProfileTab: 'Perfil PA',
+    colorAndCostTab: 'Color y coste',
+    printersTab: 'Impresoras',
     filamentInfo: 'Filamento',
     additional: 'Adicional',
     // Cloud
@@ -4840,6 +4842,22 @@ export default {
     leftNozzle: 'Boquilla izquierda',
     rightNozzle: 'Boquilla derecha',
     profilesSelected: 'perfil(es) de calibración seleccionado(s)',
+    // Printers tab: per-model filament preset + per-hotend K profile
+    filamentPreset: 'Perfil de filamento',
+    presetUseSpoolDefault: 'Usar el perfil de la bobina',
+    presetInherited: 'heredado',
+    presetOverride: 'personalizado',
+    presetNeedsModel: 'Esta impresora aún no ha indicado su modelo, así que no puede tener su propio perfil.',
+    autoMatchPresets: 'Asignar automáticamente',
+    autoMatchPresetsHint: 'Buscar la variante del perfil de la bobina que nombra cada modelo',
+    kProfilesPerPrinter: 'Perfiles K',
+    kProfileNotSet: 'Sin definir',
+    nozzle: 'Boquilla',
+    unknownModel: 'Modelo desconocido',
+    onePrinter: '1 impresora',
+    nPrinters: '{{n}} impresoras',
+    filamentPresetsLoadFailed: 'No se pudieron cargar los perfiles por modelo de esta bobina',
+    filamentPresetSaveFailed: 'No se pudieron guardar los perfiles por modelo',
     // Stats & enhanced table
     totalInventory: 'Inventario total',
     totalConsumed: 'Total consumido',

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

@@ -4766,6 +4766,8 @@ export default {
     // Tabs
     filamentInfoTab: 'Infos Filament',
     paProfileTab: 'Profil PA',
+    colorAndCostTab: 'Couleur et coût',
+    printersTab: 'Imprimantes',
     filamentInfo: 'Filament',
     additional: 'Additionnel',
     // Cloud
@@ -4822,6 +4824,22 @@ export default {
     leftNozzle: 'Buse Gauche',
     rightNozzle: 'Buse Droite',
     profilesSelected: 'profil(s) de calibration sélectionné(s)',
+    // Printers tab: per-model filament preset + per-hotend K profile
+    filamentPreset: 'Préréglage de filament',
+    presetUseSpoolDefault: 'Utiliser le préréglage de la bobine',
+    presetInherited: 'hérité',
+    presetOverride: 'personnalisé',
+    presetNeedsModel: 'Cette imprimante n\'a pas encore indiqué son modèle, elle ne peut donc pas avoir son propre préréglage.',
+    autoMatchPresets: 'Association automatique',
+    autoMatchPresetsHint: 'Trouver la variante du préréglage de la bobine qui nomme chaque modèle',
+    kProfilesPerPrinter: 'Profils K',
+    kProfileNotSet: 'Non défini',
+    nozzle: 'Buse',
+    unknownModel: 'Modèle inconnu',
+    onePrinter: '1 imprimante',
+    nPrinters: '{{n}} imprimantes',
+    filamentPresetsLoadFailed: 'Impossible de charger les préréglages par modèle de cette bobine',
+    filamentPresetSaveFailed: 'Impossible d\'enregistrer les préréglages par modèle',
     // Stats & enhanced table
     totalInventory: 'Total Inventaire',
     totalConsumed: 'Total Consommé',

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

@@ -4765,6 +4765,8 @@ export default {
     // Tabs
     filamentInfoTab: 'Info filamento',
     paProfileTab: 'Profilo PA',
+    colorAndCostTab: 'Colore e costo',
+    printersTab: 'Stampanti',
     filamentInfo: 'Filamento',
     additional: 'Aggiuntivo',
     // Cloud
@@ -4821,6 +4823,22 @@ export default {
     leftNozzle: 'Ugello sinistro',
     rightNozzle: 'Ugello destro',
     profilesSelected: 'profili di calibrazione selezionati',
+    // Printers tab: per-model filament preset + per-hotend K profile
+    filamentPreset: 'Preset del filamento',
+    presetUseSpoolDefault: 'Usa il preset della bobina',
+    presetInherited: 'ereditato',
+    presetOverride: 'personalizzato',
+    presetNeedsModel: 'Questa stampante non ha ancora comunicato il proprio modello, quindi non può avere un preset dedicato.',
+    autoMatchPresets: 'Abbina automaticamente',
+    autoMatchPresetsHint: 'Trova la variante del preset della bobina che nomina ciascun modello',
+    kProfilesPerPrinter: 'Profili K',
+    kProfileNotSet: 'Non impostato',
+    nozzle: 'Ugello',
+    unknownModel: 'Modello sconosciuto',
+    onePrinter: '1 stampante',
+    nPrinters: '{{n}} stampanti',
+    filamentPresetsLoadFailed: 'Impossibile caricare i preset per modello di questa bobina',
+    filamentPresetSaveFailed: 'Impossibile salvare i preset per modello',
     // Stats & enhanced table
     totalInventory: 'Inventario totale',
     totalConsumed: 'Totale consumato',

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

@@ -4777,6 +4777,8 @@ export default {
     // Tabs
     filamentInfoTab: 'フィラメント情報',
     paProfileTab: 'PAプロファイル',
+    colorAndCostTab: '色とコスト',
+    printersTab: 'プリンター',
     filamentInfo: 'フィラメント',
     additional: '追加情報',
     // Cloud
@@ -4833,6 +4835,22 @@ export default {
     leftNozzle: '左ノズル',
     rightNozzle: '右ノズル',
     profilesSelected: 'キャリブレーションプロファイル選択済み',
+    // Printers tab: per-model filament preset + per-hotend K profile
+    filamentPreset: 'フィラメントプリセット',
+    presetUseSpoolDefault: 'スプールのプリセットを使用',
+    presetInherited: '継承',
+    presetOverride: '上書き',
+    presetNeedsModel: 'このプリンターはまだモデルを報告していないため、専用のプリセットを設定できません。',
+    autoMatchPresets: '自動で割り当て',
+    autoMatchPresetsHint: '各モデル名を含むスプールプリセットのバリアントを探します',
+    kProfilesPerPrinter: 'Kプロファイル',
+    kProfileNotSet: '未設定',
+    nozzle: 'ノズル',
+    unknownModel: '不明なモデル',
+    onePrinter: 'プリンター1台',
+    nPrinters: 'プリンター{{n}}台',
+    filamentPresetsLoadFailed: 'このスプールのモデル別プリセットを読み込めませんでした',
+    filamentPresetSaveFailed: 'モデル別プリセットを保存できませんでした',
     // Stats & enhanced table
     totalInventory: '在庫合計',
     totalConsumed: '総消費量',

+ 18 - 0
frontend/src/i18n/locales/ko.ts

@@ -4561,6 +4561,8 @@ export default {
     advancedSettings: '고급 설정',
     filamentInfoTab: '필라멘트 정보',
     paProfileTab: 'PA 프로필',
+    colorAndCostTab: '색상 및 비용',
+    printersTab: '프린터',
     filamentInfo: '필라멘트',
     additional: '추가',
     loadingPresets: '클라우드 프리셋 불러오는 중...',
@@ -4613,6 +4615,22 @@ export default {
     leftNozzle: '왼쪽 노즐',
     rightNozzle: '오른쪽 노즐',
     profilesSelected: '보정 프로필 선택됨',
+    // Printers tab: per-model filament preset + per-hotend K profile
+    filamentPreset: '필라멘트 프리셋',
+    presetUseSpoolDefault: '스풀 프리셋 사용',
+    presetInherited: '상속됨',
+    presetOverride: '재정의',
+    presetNeedsModel: '이 프린터는 아직 모델을 보고하지 않아 전용 프리셋을 지정할 수 없습니다.',
+    autoMatchPresets: '자동 매칭',
+    autoMatchPresetsHint: '각 모델 이름이 들어간 스풀 프리셋 변형을 찾습니다',
+    kProfilesPerPrinter: 'K 프로파일',
+    kProfileNotSet: '설정 안 됨',
+    nozzle: '노즐',
+    unknownModel: '알 수 없는 모델',
+    onePrinter: '프린터 1대',
+    nPrinters: '프린터 {{n}}대',
+    filamentPresetsLoadFailed: '이 스풀의 모델별 프리셋을 불러오지 못했습니다',
+    filamentPresetSaveFailed: '모델별 프리셋을 저장하지 못했습니다',
     totalInventory: '총 재고',
     totalConsumed: '총 소비량',
     byMaterial: '재료별',

+ 18 - 0
frontend/src/i18n/locales/nl.ts

@@ -4819,6 +4819,8 @@ export default {
     // Tabs
     filamentInfoTab: 'Filamentinformatie',
     paProfileTab: 'PA-profiel',
+    colorAndCostTab: 'Kleur & kosten',
+    printersTab: 'Printers',
     filamentInfo: 'Filament',
     additional: 'Aanvullend',
     // Cloud
@@ -4878,6 +4880,22 @@ export default {
     leftNozzle: 'Linker nozzle',
     rightNozzle: 'Rechter nozzle',
     profilesSelected: 'kalibratieprofiel(en) geselecteerd',
+    // Printers tab: per-model filament preset + per-hotend K profile
+    filamentPreset: 'Filamentvoorinstelling',
+    presetUseSpoolDefault: 'Voorinstelling van de spoel gebruiken',
+    presetInherited: 'overgenomen',
+    presetOverride: 'aangepast',
+    presetNeedsModel: 'Deze printer heeft zijn model nog niet doorgegeven en kan dus geen eigen voorinstelling hebben.',
+    autoMatchPresets: 'Automatisch koppelen',
+    autoMatchPresetsHint: 'Zoek de variant van de spoelvoorinstelling die elk model noemt',
+    kProfilesPerPrinter: 'K-profielen',
+    kProfileNotSet: 'Niet ingesteld',
+    nozzle: 'Nozzle',
+    unknownModel: 'Onbekend model',
+    onePrinter: '1 printer',
+    nPrinters: '{{n}} printers',
+    filamentPresetsLoadFailed: 'De voorinstellingen per model van deze spoel konden niet worden geladen',
+    filamentPresetSaveFailed: 'De voorinstellingen per model konden niet worden opgeslagen',
     // Stats & enhanced table
     totalInventory: 'Totale voorraad',
     totalConsumed: 'Totaal verbruikt',

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

@@ -4765,6 +4765,8 @@ export default {
     // Tabs
     filamentInfoTab: 'Informações do Filamento',
     paProfileTab: 'Perfil PA',
+    colorAndCostTab: 'Cor e custo',
+    printersTab: 'Impressoras',
     filamentInfo: 'Filamento',
     additional: 'Adicional',
     // Cloud
@@ -4821,6 +4823,22 @@ export default {
     leftNozzle: 'Bico Esquerdo',
     rightNozzle: 'Bico Direito',
     profilesSelected: 'perfil(is) de calibração selecionado(s)',
+    // Printers tab: per-model filament preset + per-hotend K profile
+    filamentPreset: 'Predefinição de filamento',
+    presetUseSpoolDefault: 'Usar a predefinição do carretel',
+    presetInherited: 'herdada',
+    presetOverride: 'personalizada',
+    presetNeedsModel: 'Esta impressora ainda não informou o modelo, portanto não pode ter uma predefinição própria.',
+    autoMatchPresets: 'Associar automaticamente',
+    autoMatchPresetsHint: 'Encontrar a variante da predefinição do carretel que nomeia cada modelo',
+    kProfilesPerPrinter: 'Perfis K',
+    kProfileNotSet: 'Não definido',
+    nozzle: 'Bico',
+    unknownModel: 'Modelo desconhecido',
+    onePrinter: '1 impressora',
+    nPrinters: '{{n}} impressoras',
+    filamentPresetsLoadFailed: 'Não foi possível carregar as predefinições por modelo deste carretel',
+    filamentPresetSaveFailed: 'Não foi possível salvar as predefinições por modelo',
     // Stats & enhanced table
     totalInventory: 'Inventário Total',
     totalConsumed: 'Total Consumido',

+ 18 - 0
frontend/src/i18n/locales/ru.ts

@@ -4552,6 +4552,8 @@ export default {
     advancedSettings: "Расширенные настройки",
     filamentInfoTab: "Сведения о филаменте",
     paProfileTab: "Профиль PA",
+    colorAndCostTab: 'Цвет и стоимость',
+    printersTab: 'Принтеры',
     filamentInfo: "Филамент",
     additional: "Дополнительно",
     loadingPresets: "Загрузка облачных пресетов…",
@@ -4604,6 +4606,22 @@ export default {
     leftNozzle: "Левое сопло",
     rightNozzle: "Правое сопло",
     profilesSelected: "выбрано калибровочных профилей",
+    // Printers tab: per-model filament preset + per-hotend K profile
+    filamentPreset: 'Пресет филамента',
+    presetUseSpoolDefault: 'Использовать пресет катушки',
+    presetInherited: 'наследуется',
+    presetOverride: 'переопределён',
+    presetNeedsModel: 'Этот принтер ещё не сообщил свою модель, поэтому у него не может быть собственного пресета.',
+    autoMatchPresets: 'Подобрать автоматически',
+    autoMatchPresetsHint: 'Найти вариант пресета катушки, в названии которого указана каждая модель',
+    kProfilesPerPrinter: 'K-профили',
+    kProfileNotSet: 'Не задан',
+    nozzle: 'Сопло',
+    unknownModel: 'Неизвестная модель',
+    onePrinter: '1 принтер',
+    nPrinters: 'Принтеров: {{n}}',
+    filamentPresetsLoadFailed: 'Не удалось загрузить пресеты по моделям для этой катушки',
+    filamentPresetSaveFailed: 'Не удалось сохранить пресеты по моделям',
     totalInventory: "Всего в учёте",
     totalConsumed: "Израсходовано всего",
     byMaterial: "По материалам",

+ 18 - 0
frontend/src/i18n/locales/tr.ts

@@ -4764,6 +4764,8 @@ export default {
     advancedSettings: 'Gelişmiş Ayarlar',
     filamentInfoTab: 'Filament Bilgisi',
     paProfileTab: 'PA Profili',
+    colorAndCostTab: 'Renk ve maliyet',
+    printersTab: 'Yazıcılar',
     filamentInfo: 'Filament',
     additional: 'Ek',
     loadingPresets: 'Bulut ön ayarları yükleniyor...',
@@ -4816,6 +4818,22 @@ export default {
     leftNozzle: 'Sol Nozul',
     rightNozzle: 'Sağ Nozul',
     profilesSelected: 'kalibrasyon profili seçildi',
+    // Printers tab: per-model filament preset + per-hotend K profile
+    filamentPreset: 'Filament ön ayarı',
+    presetUseSpoolDefault: 'Makaranın ön ayarını kullan',
+    presetInherited: 'devralındı',
+    presetOverride: 'geçersiz kılındı',
+    presetNeedsModel: 'Bu yazıcı modelini henüz bildirmedi, bu yüzden kendi ön ayarı olamaz.',
+    autoMatchPresets: 'Otomatik eşleştir',
+    autoMatchPresetsHint: 'Makara ön ayarının her modeli adıyla anan çeşidini bul',
+    kProfilesPerPrinter: 'K profilleri',
+    kProfileNotSet: 'Ayarlanmadı',
+    nozzle: 'Nozul',
+    unknownModel: 'Bilinmeyen model',
+    onePrinter: '1 yazıcı',
+    nPrinters: '{{n}} yazıcı',
+    filamentPresetsLoadFailed: 'Bu makaranın modele özel ön ayarları yüklenemedi',
+    filamentPresetSaveFailed: 'Modele özel ön ayarlar kaydedilemedi',
     totalInventory: 'Toplam Envanter',
     totalConsumed: 'Toplam Tüketim',
     byMaterial: 'Malzemeye Göre',

+ 18 - 0
frontend/src/i18n/locales/uk.ts

@@ -4816,6 +4816,8 @@ export default {
     // Tabs
     filamentInfoTab: "Інформація про філамент",
     paProfileTab: "Профіль PA",
+    colorAndCostTab: 'Колір і вартість',
+    printersTab: 'Принтери',
     filamentInfo: "Філамент",
     additional: "Додатково",
     // Cloud
@@ -4875,6 +4877,22 @@ export default {
     leftNozzle: "Ліве сопло",
     rightNozzle: "Праве сопло",
     profilesSelected: "вибрані профілі калібрування",
+    // Printers tab: per-model filament preset + per-hotend K profile
+    filamentPreset: 'Пресет філаменту',
+    presetUseSpoolDefault: 'Використовувати пресет котушки',
+    presetInherited: 'успадковано',
+    presetOverride: 'перевизначено',
+    presetNeedsModel: 'Цей принтер ще не повідомив свою модель, тому не може мати власний пресет.',
+    autoMatchPresets: 'Підібрати автоматично',
+    autoMatchPresetsHint: 'Знайти варіант пресета котушки, у назві якого вказано кожну модель',
+    kProfilesPerPrinter: 'K-профілі',
+    kProfileNotSet: 'Не задано',
+    nozzle: 'Сопло',
+    unknownModel: 'Невідома модель',
+    onePrinter: '1 принтер',
+    nPrinters: 'Принтерів: {{n}}',
+    filamentPresetsLoadFailed: 'Не вдалося завантажити пресети за моделями для цієї котушки',
+    filamentPresetSaveFailed: 'Не вдалося зберегти пресети за моделями',
     // Stats & enhanced table
     totalInventory: "Загальний запас",
     totalConsumed: "Всього спожито",

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

@@ -4771,6 +4771,8 @@ export default {
     // Tabs
     filamentInfoTab: '耗材信息',
     paProfileTab: 'PA 配置',
+    colorAndCostTab: '颜色与成本',
+    printersTab: '打印机',
     filamentInfo: '耗材',
     additional: '附加',
     // Cloud
@@ -4827,6 +4829,22 @@ export default {
     leftNozzle: '左喷嘴',
     rightNozzle: '右喷嘴',
     profilesSelected: '个校准配置已选择',
+    // Printers tab: per-model filament preset + per-hotend K profile
+    filamentPreset: '耗材预设',
+    presetUseSpoolDefault: '使用料卷的预设',
+    presetInherited: '继承',
+    presetOverride: '已覆盖',
+    presetNeedsModel: '该打印机尚未上报机型,因此无法设置专属预设。',
+    autoMatchPresets: '自动匹配',
+    autoMatchPresetsHint: '查找料卷预设中标明各机型的对应版本',
+    kProfilesPerPrinter: 'K 值配置',
+    kProfileNotSet: '未设置',
+    nozzle: '喷嘴',
+    unknownModel: '未知机型',
+    onePrinter: '1 台打印机',
+    nPrinters: '{{n}} 台打印机',
+    filamentPresetsLoadFailed: '无法加载该料卷的按机型预设',
+    filamentPresetSaveFailed: '无法保存按机型预设',
     // Stats & enhanced table
     totalInventory: '总库存',
     totalConsumed: '总消耗',

+ 18 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -4771,6 +4771,8 @@ export default {
     // Tabs
     filamentInfoTab: '耗材資訊',
     paProfileTab: 'PA 設定',
+    colorAndCostTab: '顏色與成本',
+    printersTab: '印表機',
     filamentInfo: '耗材',
     additional: '附加',
     // Cloud
@@ -4827,6 +4829,22 @@ export default {
     leftNozzle: '左噴嘴',
     rightNozzle: '右噴嘴',
     profilesSelected: '個校準設定已選擇',
+    // Printers tab: per-model filament preset + per-hotend K profile
+    filamentPreset: '耗材預設',
+    presetUseSpoolDefault: '使用線材捲的預設',
+    presetInherited: '繼承',
+    presetOverride: '已覆寫',
+    presetNeedsModel: '此印表機尚未回報機型,因此無法設定專屬預設。',
+    autoMatchPresets: '自動比對',
+    autoMatchPresetsHint: '尋找線材捲預設中標示各機型的對應版本',
+    kProfilesPerPrinter: 'K 值設定檔',
+    kProfileNotSet: '未設定',
+    nozzle: '噴嘴',
+    unknownModel: '未知機型',
+    onePrinter: '1 台印表機',
+    nPrinters: '{{n}} 台印表機',
+    filamentPresetsLoadFailed: '無法載入此線材捲的各機型預設',
+    filamentPresetSaveFailed: '無法儲存各機型預設',
     // Stats & enhanced table
     totalInventory: '總庫存',
     totalConsumed: '總消耗',

+ 10 - 5
frontend/src/pages/PrintersPage.tsx

@@ -176,7 +176,7 @@ import { FileUploadModal } from '../components/FileUploadModal';
 import { PrintModal } from '../components/PrintModal';
 import { PrinterInfoModal } from '../components/PrinterInfoModal';
 import { FeedDirectionModal } from '../components/FeedDirectionModal';
-import { getAmsLabel, getGlobalTrayId, getFillBarColor, getSpoolmanFillLevel, getFallbackSpoolTag, isBambuLabSpool, resolveSlotNozzleDiameter, resolveSlotExtruder, formatSlotLabel, FTS_INLET_SIDE } from '../utils/amsHelpers';
+import { getAmsLabel, getGlobalTrayId, getFillBarColor, getSpoolmanFillLevel, getFallbackSpoolTag, installedNozzleDiameters, isBambuLabSpool, resolveSlotNozzleDiameter, resolveSlotExtruder, formatSlotLabel, FTS_INLET_SIDE } from '../utils/amsHelpers';
 import { MAX_CHAMBER_TEMP_C, getPrinterImage, getWifiStrength, filterCompatibleQueueItems, isPrinterCurrentlyDispatchable } from '../utils/printer';
 import { FilamentSlotCircle } from '../components/FilamentSlotCircle';
 import { Collapsible } from '../components/Collapsible';
@@ -3929,10 +3929,15 @@ function PrinterCard({
                 </div>
                 <p className="text-sm text-bambu-gray">
                   {printer.model || 'Unknown Model'}
-                  {/* Nozzle Info - only in expanded */}
-                  {viewMode === 'expanded' && status?.nozzles && status.nozzles[0]?.nozzle_diameter && (
-                    <span className="ml-1.5 text-bambu-gray" title={status.nozzles[0].nozzle_type || 'Nozzle'}>
-                      • {status.nozzles[0].nozzle_diameter}mm
+                  {/* Nozzle Info - only in expanded. Every fitted size, not
+                      just nozzles[0]: the array is indexed by extruder, so on a
+                      dual-nozzle machine with two sizes fitted showing the
+                      first entry alone named one hotend and implied it was the
+                      whole printer. Deduplicated, so the usual matching pair
+                      still reads as a single "0.4mm". */}
+                  {viewMode === 'expanded' && installedNozzleDiameters(status).length > 0 && (
+                    <span className="ml-1.5 text-bambu-gray" title={status?.nozzles?.[0]?.nozzle_type || 'Nozzle'}>
+                      • {installedNozzleDiameters(status).join(' / ')}mm
                     </span>
                   )}
                   {viewMode === 'expanded' && maintenanceInfo && maintenanceInfo.total_print_hours > 0 && (

+ 7 - 1
frontend/src/pages/spoolbuddy/SpoolBuddyWriteTagPage.tsx

@@ -669,7 +669,13 @@ function NewSpoolTouchForm({ currencySymbol, onCreated, selectedSpool, spoolmanM
 
       const pc = printersWithCalibrations.find(p => p.printer.id === printerId);
       if (pc) {
-        const cal = pc.calibrations.find(c => c.cali_idx === caliIdx);
+        // Match the extruder too, not cali_idx alone: the printer numbers its
+        // calibration table PER NOZZLE, so on a dual-nozzle machine the same
+        // cali_idx exists on both hotends and means different things. Resolving
+        // by index alone could persist the other hotend's K value and diameter.
+        const cal = pc.calibrations.find(
+          c => c.cali_idx === caliIdx && (c.extruder_id ?? 0) === extruder,
+        );
         if (cal) {
           profiles.push({
             printer_id: printerId,

+ 8 - 4
frontend/src/utils/amsHelpers.ts

@@ -590,10 +590,14 @@ export function installedNozzleDiameters(
  * the machine instead of assuming 0.4mm (#1899).
  *
  * On dual-nozzle printers (H2D) each AMS is bound to one extruder via
- * `ams_extruder_map` (amsId → extruder index, 0=left/primary, 1=right), so we
- * read that nozzle's diameter. Single-nozzle printers have no map entry and
- * fall back to the primary nozzle (index 0). Returns undefined when the printer
- * hasn't reported nozzle hardware yet, letting the caller keep its own default.
+ * `ams_extruder_map` (amsId → extruder index), so we read that nozzle's
+ * diameter. `status.nozzles` is indexed by extruder id -- [0] is the RIGHT
+ * hotend and [1] the left, measured on an H2D fitted with 0.4 left / 0.6 right
+ * -- so indexing it by the extruder is correct. (This comment used to say
+ * "0=left/primary, 1=right", which was backwards; the code was always right.)
+ * Single-nozzle printers have no map entry and fall back to index 0. Returns
+ * undefined when the printer hasn't reported nozzle hardware yet, letting the
+ * caller keep its own default.
  * Diameter is the bare decimal string the status carries, e.g. "0.4" / "0.6".
  */
 export function resolveSlotNozzleDiameter(

+ 76 - 0
frontend/src/utils/slicerPrinterMatch.ts

@@ -319,3 +319,79 @@ export function presetCompatibility(
   // standard presets that don't carry compatible_printers.
   return classifyByBambuName(preset.name, selectedPrinterName, index.bambuModelByShortCode);
 }
+
+// model token compiles to a flexible-whitespace word-boundary regex.
+function _tokenToRegex(token: string): RegExp {
+  const escaped = token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\s+/g, '\\s+');
+  return new RegExp(`\\b${escaped}\\b`, 'i');
+}
+
+// Extract printer model from a preset name → normalized short code
+// (e.g. "X1C", "H2D"). Two strategies in order:
+//
+// (1) ``@`` suffix — the BambuStudio naming convention. Two shapes:
+//   - "@BBL X1C 0.4 nozzle"               → "X1C"  (short-code form,
+//      Bambu Cloud system presets)
+//   - "@Bambu Lab X1 Carbon 0.4 nozzle"   → "X1C"  (long-form, used by
+//      user-renamed Bambu Cloud presets and most Orca Cloud profiles —
+//      reverse-looked-up via the backend printer-model registry)
+//
+// (2) Body scan — many user-authored / Orca Cloud presets put the printer
+// model at the START of the name with no @ suffix at all (the literal
+// shape that surfaced #1623: "X1C eSUN PETG-Basic Filament"). Scan the
+// name for any known model token (every long-name fragment + every short
+// code from the registry) and return the first match. Long-first sort
+// keeps "A1 Mini" / "X1 Carbon" / "H2D Pro" from being eaten by their
+// shorter sibling ("A1" / "X1" / "H2D"). Word-boundary regex prevents
+// false-positives on partial substrings (e.g. "PA1" doesn't match "A1",
+// "X1Box" doesn't match "X1").
+//
+// Returns null when neither strategy resolves; the caller keeps such
+// presets visible (can't filter what we can't classify).
+//
+// ``printerModelsLongToShort`` is the backend's PRINTER_MODEL_MAP shape:
+// keys are "Bambu Lab <long>", values are short codes.
+export function extractPresetModel(
+  name: string,
+  printerModelsLongToShort: Record<string, string>,
+): string | null {
+  const atIdx = name.indexOf('@');
+  if (atIdx >= 0) {
+    const suffix = name.slice(atIdx + 1).trim();
+    const bblMatch = suffix.match(/^BBL\s+(.+?)(?:\s+[\d.]+\s*nozzle)?$/i);
+    if (bblMatch) return bblMatch[1].trim();
+    const longMatch = suffix.match(/^Bambu Lab\s+(.+?)(?:\s+[\d.]+\s*nozzle)?$/i);
+    if (longMatch) {
+      const longFragment = longMatch[1].trim();
+      const fullKey = `Bambu Lab ${longFragment}`;
+      if (printerModelsLongToShort[fullKey]) return printerModelsLongToShort[fullKey];
+      const lower = fullKey.toLowerCase();
+      for (const [k, v] of Object.entries(printerModelsLongToShort)) {
+        if (k.toLowerCase() === lower) return v;
+      }
+      return longFragment;
+    }
+  }
+
+  // Body scan — accumulate {token, short} pairs and try long-first.
+  const tokens: Array<{ token: string; short: string }> = [];
+  const seen = new Set<string>();
+  for (const [longName, short] of Object.entries(printerModelsLongToShort)) {
+    const fragment = longName.replace(/^Bambu Lab\s+/, '');
+    const key = fragment.toLowerCase();
+    if (!seen.has(key)) {
+      tokens.push({ token: fragment, short });
+      seen.add(key);
+    }
+    const shortKey = short.toLowerCase();
+    if (!seen.has(shortKey)) {
+      tokens.push({ token: short, short });
+      seen.add(shortKey);
+    }
+  }
+  tokens.sort((a, b) => b.token.length - a.token.length);
+  for (const { token, short } of tokens) {
+    if (_tokenToRegex(token).test(name)) return short;
+  }
+  return null;
+}

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


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


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


+ 2 - 2
static/index.html

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

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