Parcourir la source

Choose which rack nozzle each filament prints from on an H2C (#1784)

    The Vortek rack holds six hotends, and a multi-colour plate is sliced to
    use a different one per colour so it can skip the purge. Which of the six
    each colour takes is not in the 3MF. The same plate, sliced and sent twice
    from Bambu Studio with a different choice each time, produces two files
    that differ only in rounding in the last digit of a few extrusion figures
    -- the filament grouping, the toolchange stream, the 120 nozzle-change
    markers and project_settings.config are all identical. The choice travels
    only in the dispatched nozzle_mapping.

    Bambuddy had no way to state it, so those plates went out with no nozzle
    assignment at all and the printer chose for itself. That is what levelled
    on one hotend and printed with another, millimetres above the plate.

    Every rack-bound filament now carries a position picker beside its AMS
    slot dropdown, listing all six with the nozzle each holds. An empty
    position, or one holding the wrong diameter or flow type, is shown greyed
    out with the reason rather than hidden, so someone looking for position 4
    finds it. The choice is per filament *group* rather than per slot, because
    a group is one hotend: two filaments the slicer grouped together share it
    and cannot point at different positions.

    Nothing has to be picked. Positions are assigned automatically, preferring
    one already loaded with that colour, which on the plate this was built
    against reproduces Bambu Studio's own pick exactly.

    A nozzle currently picked up onto the carriage is offered too. The
    firmware drops its rack position from the report entirely rather than
    sending a placeholder (#943), and refusing it would rule out the position
    most likely to be wanted -- the one the last print left mounted. Only
    recoverable when exactly one position is missing; two gaps are genuinely
    ambiguous and stay unavailable.

    Positions are re-checked at dispatch, not just when queued, because the
    rack can be re-loaded in between. The two failure modes differ on purpose:
    an explicitly chosen position that no longer fits stops the print, names
    what the position now holds, and deletes the uploaded file from the SD
    card so it cannot be started by hand either -- an operator who named a
    hotend must not silently get a different one. An automatic assignment that
    cannot be made instead falls back to letting the firmware choose, which is
    what happened before any of this existed.

    The pick is stored as {group: position} rather than as the expanded
    nozzle_mapping, though that is what goes on the wire. That column means
    "Bambu Studio decided, forward verbatim", and only the group-and-position
    form can be re-checked against what is actually mounted at dispatch.

    The existing multi-rack refusal in extract_nozzle_mapping_from_3mf stays.
    It still guards the #2800 fallback, which can only ever name one rack id.

    Measured on the maintainer's H2C: rack position n is physical nozzle id
    15 + n, confirmed by cross-referencing two captured dispatches against
    Bambu Studio's own dialog. extruder_max_nozzle_count names which carriage
    is the rack straight from the file, and is read rather than assumed -- a
    fourth independent confirmation of the carriage indices fixed in 45dc139.

    The print dialog is also wider, on every printer. Its filament rows carry
    the most horizontal content in it and adding a picker truncated names to
    "Bamb...". The column widths themselves only change on a rack machine.

    Tests: 44 unit covering the plan, the resolver, the mounted-nozzle
    recovery and every refusal; 9 dispatch integration asserting the two real
    captures end to end; 7 API round-trip; 33 frontend. The API ones exist
    because two integration bugs got through a green suite that tested the
    pieces and not the seams -- the group data reached only one of the three
    filament-requirements routes, and the field was declared on every schema
    except the create one, where Pydantic dropped it in silence.
maziggy il y a 3 semaines
Parent
commit
8e553289db
38 fichiers modifiés avec 2324 ajouts et 12 suppressions
  1. 6 0
      backend/app/api/routes/archives.py
  2. 6 0
      backend/app/api/routes/library.py
  3. 21 0
      backend/app/api/routes/print_queue.py
  4. 10 0
      backend/app/core/database.py
  5. 12 0
      backend/app/models/print_queue.py
  6. 16 0
      backend/app/schemas/print_queue.py
  7. 204 0
      backend/app/services/bambu_mqtt.py
  8. 40 1
      backend/app/services/filament_requirements.py
  9. 2 0
      backend/app/services/print_batch.py
  10. 115 4
      backend/app/services/print_scheduler.py
  11. 174 0
      backend/app/utils/threemf_tools.py
  12. 172 0
      backend/tests/integration/test_queue_nozzle_rack_choice_api_1784.py
  13. 253 0
      backend/tests/integration/test_scheduler_nozzle_rack_dispatch_1784.py
  14. 483 0
      backend/tests/unit/test_nozzle_rack_positions_1784.py
  15. 230 0
      frontend/src/__tests__/components/FilamentMappingRackPicker.test.tsx
  16. 167 0
      frontend/src/__tests__/utils/nozzleRack.test.ts
  17. 12 0
      frontend/src/api/client.ts
  18. 82 4
      frontend/src/components/PrintModal/FilamentMapping.tsx
  19. 48 1
      frontend/src/components/PrintModal/index.tsx
  20. 36 0
      frontend/src/components/PrintModal/types.ts
  21. 8 0
      frontend/src/hooks/useFilamentMapping.ts
  22. 4 0
      frontend/src/i18n/locales/de.ts
  23. 4 0
      frontend/src/i18n/locales/en.ts
  24. 4 0
      frontend/src/i18n/locales/es.ts
  25. 4 0
      frontend/src/i18n/locales/fr.ts
  26. 4 0
      frontend/src/i18n/locales/it.ts
  27. 4 0
      frontend/src/i18n/locales/ja.ts
  28. 4 0
      frontend/src/i18n/locales/ko.ts
  29. 4 0
      frontend/src/i18n/locales/pt-BR.ts
  30. 4 0
      frontend/src/i18n/locales/ru.ts
  31. 4 0
      frontend/src/i18n/locales/tr.ts
  32. 4 0
      frontend/src/i18n/locales/uk.ts
  33. 4 0
      frontend/src/i18n/locales/zh-CN.ts
  34. 4 0
      frontend/src/i18n/locales/zh-TW.ts
  35. 173 0
      frontend/src/utils/nozzleRack.ts
  36. 0 0
      static/assets/index-1Ya6fAmN.css
  37. 0 0
      static/assets/index-CUMnY0g5.js
  38. 2 2
      static/index.html

+ 6 - 0
backend/app/api/routes/archives.py

@@ -30,6 +30,7 @@ from backend.app.schemas.print_log import PrintLogResponse
 from backend.app.schemas.slicer import SliceRequest
 from backend.app.services.archive import ArchiveService
 from backend.app.services.design_settings import overrides_from_config
+from backend.app.services.filament_requirements import annotate_rack_groups
 from backend.app.utils.http import build_content_disposition
 from backend.app.utils.safe_path import safe_join_under
 from backend.app.utils.threemf_tools import (
@@ -4126,6 +4127,11 @@ async def get_filament_requirements(
                 for filament in filaments:
                     filament["nozzle_id"] = nozzle_mapping.get(filament["slot_id"])
 
+            # Nozzle-rack machines (#1784): the print dialog offers a rack
+            # position per filament group, which needs the group table as well
+            # as the carriage above.
+            annotate_rack_groups(filaments, file_path, plate_id)
+
     except Exception as e:
         logger.warning("Failed to parse filament requirements from archive %s: %s", archive_id, e)
 

+ 6 - 0
backend/app/api/routes/library.py

@@ -69,6 +69,7 @@ from backend.app.services.design_settings import (
     extract_design_process_overrides,
     overrides_from_config,
 )
+from backend.app.services.filament_requirements import annotate_rack_groups
 from backend.app.services.plate_thumbnail import inject_plate_thumbnails_if_missing
 from backend.app.services.process_overrides import apply_process_overrides
 from backend.app.services.stl_thumbnail import MIN_USABLE_STL_BYTES, generate_stl_thumbnail
@@ -3434,6 +3435,11 @@ async def get_library_file_filament_requirements(
                 for filament in filaments:
                     filament["nozzle_id"] = nozzle_mapping.get(filament["slot_id"])
 
+            # Nozzle-rack machines (#1784): the print dialog offers a rack
+            # position per filament group, which needs the group table as well
+            # as the carriage above.
+            annotate_rack_groups(filaments, file_path, plate_id)
+
     except Exception as e:
         logger.warning("Failed to parse filament requirements from library file %s: %s", file_id, e)
 

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

@@ -366,6 +366,15 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
         except json.JSONDecodeError:
             nozzle_mapping_parsed = None
 
+    # The operator's rack-position pick (#1784), keyed by filament group. Sent
+    # parsed so the print dialog can show which hotend each group will use.
+    nozzle_rack_choice_parsed = None
+    if item.nozzle_rack_choice:
+        try:
+            nozzle_rack_choice_parsed = json.loads(item.nozzle_rack_choice)
+        except json.JSONDecodeError:
+            nozzle_rack_choice_parsed = None
+
     nozzles_info_parsed = None
     if item.nozzles_info:
         try:
@@ -421,6 +430,7 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
         "gcode_injection": item.gcode_injection,
         # H2C rack-swap nozzle pick (#1780)
         "nozzle_mapping": nozzle_mapping_parsed,
+        "nozzle_rack_choice": nozzle_rack_choice_parsed,
         "nozzles_info": nozzles_info_parsed,
         "cleanup_library_after_dispatch": item.cleanup_library_after_dispatch,
         # Cross-model alternatives (#671). Guarded rather than read directly:
@@ -720,6 +730,7 @@ def _variant_values(
         "plate_id": spec.plate_id,
         "ams_mapping": json.dumps(spec.ams_mapping) if spec.ams_mapping else None,
         "nozzle_mapping": json.dumps(spec.nozzle_mapping) if spec.nozzle_mapping else None,
+        "nozzle_rack_choice": json.dumps(spec.nozzle_rack_choice) if spec.nozzle_rack_choice else None,
         "filament_overrides": filament_overrides_json,
         "required_filament_types": required_types,
         "print_time_seconds": print_time,
@@ -1033,6 +1044,8 @@ async def add_to_queue(
     )
 
     ams_mapping_json = json.dumps(data.ams_mapping) if data.ams_mapping else None
+    # Same Text-as-JSON convention for the rack-position pick (#1784).
+    nozzle_rack_choice_json = json.dumps(data.nozzle_rack_choice) if data.nozzle_rack_choice else None
     # Reprint fallback: the caller didn't specify an explicit ams_mapping (no
     # per-slot filament-mapping edit was made), but the archive carries the
     # slicer's own live-resolved AMS-slot pick from the original print (see
@@ -1096,6 +1109,7 @@ async def add_to_queue(
             manual_start=data.manual_start,
             skip_filament_check=data.skip_filament_check,
             ams_mapping=ams_mapping_json,
+            nozzle_rack_choice=nozzle_rack_choice_json,
             plate_id=data.plate_id,
             bed_levelling=data.bed_levelling,
             flow_cali=data.flow_cali,
@@ -1923,6 +1937,13 @@ async def update_queue_item(
             json.dumps(update_data["nozzle_mapping"]) if update_data["nozzle_mapping"] else None
         )
 
+    # Same Text-as-JSON convention for the rack-position pick (#1784). An empty
+    # object clears it, which is how the UI says "assign these for me again".
+    if "nozzle_rack_choice" in update_data:
+        update_data["nozzle_rack_choice"] = (
+            json.dumps(update_data["nozzle_rack_choice"]) if update_data["nozzle_rack_choice"] else None
+        )
+
     trusted_estimated_cost = await _trusted_item_estimated_cost(
         db,
         item,

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

@@ -1681,6 +1681,16 @@ async def run_migrations(conn):
     await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN nozzle_mapping TEXT")
     await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN nozzles_info TEXT")
 
+    # Migration: nozzle_rack_choice (#1784). Which rack position each filament
+    # group prints from, as JSON {group_id: 1-based position}. Kept separate
+    # from nozzle_mapping above because that one is BambuStudio's own expanded
+    # answer and rides to the printer verbatim, while this is the operator's
+    # pick and has to survive being re-checked against a rack that may have
+    # been re-loaded since. Also on the variants table so a batch clone does
+    # not silently lose it. Nullable TEXT, no Postgres / SQLite divergence.
+    await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN nozzle_rack_choice TEXT")
+    await _safe_execute(conn, "ALTER TABLE print_queue_variants ADD COLUMN nozzle_rack_choice TEXT")
+
     # Migration: Add target_parts_count column to projects for tracking total parts needed
     await _safe_execute(conn, "ALTER TABLE projects ADD COLUMN target_parts_count INTEGER")
 

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

@@ -92,6 +92,17 @@ class PrintQueueItem(Base):
     nozzle_mapping: Mapped[str | None] = mapped_column(Text, nullable=True)
     nozzles_info: Mapped[str | None] = mapped_column(Text, nullable=True)
 
+    # Which rack position each filament group prints from, on a nozzle-rack
+    # machine (#1784). JSON object keyed by the 3MF's group id, valued with a
+    # 1-based rack position as the operator counts them.
+    #
+    # Deliberately not the expanded `nozzle_mapping` above, though that is what
+    # goes on the wire: the rack can be re-loaded between queueing and
+    # dispatch, and only the position-and-group form can be re-checked against
+    # what is actually mounted at the moment the job runs. NULL means nothing
+    # was picked, and the dispatcher assigns positions itself.
+    nozzle_rack_choice: Mapped[str | None] = mapped_column(Text, nullable=True)
+
     # Printer-card direct uploads create transient library rows. When this is
     # true, the scheduler deletes the source row/files after archiving a copy.
     cleanup_library_after_dispatch: Mapped[bool] = mapped_column(Boolean, default=False)
@@ -233,6 +244,7 @@ class PrintQueueVariant(Base):
     plate_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
     ams_mapping: Mapped[str | None] = mapped_column(Text, nullable=True)
     nozzle_mapping: Mapped[str | None] = mapped_column(Text, nullable=True)
+    nozzle_rack_choice: Mapped[str | None] = mapped_column(Text, nullable=True)
     filament_overrides: Mapped[str | None] = mapped_column(Text, nullable=True)
     required_filament_types: Mapped[str | None] = mapped_column(Text, nullable=True)
     print_time_seconds: Mapped[int | None] = mapped_column(Integer, nullable=True)

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

@@ -60,6 +60,10 @@ class QueueVariantCreate(BaseModel):
     plate_id: int | None = None
     ams_mapping: list[int] | None = None
     nozzle_mapping: list[int] | None = None
+    # Which rack position each filament group prints from (#1784), as
+    # {group_id: 1-based position}. The operator's pick, re-checked against the
+    # live rack at dispatch; null means "assign them for me".
+    nozzle_rack_choice: dict[int, int] | None = None
     filament_overrides: list[dict] | None = None
 
 
@@ -116,6 +120,10 @@ class PrintQueueItemCreate(BaseModel):
     project_id: int | None = None
     cost_center_id: int | None = None
     estimated_cost: float | None = None
+    # Which rack position each filament group prints from (#1784), as
+    # {group_id: 1-based position}. The operator's pick, re-checked against the
+    # live rack at dispatch; null means "assign them for me".
+    nozzle_rack_choice: dict[int, int] | None = None
     # Direct printer-card uploads are temporary library files. The scheduler
     # deletes them after creating the durable archive copy.
     cleanup_library_after_dispatch: bool = False
@@ -157,6 +165,10 @@ class PrintQueueItemUpdate(BaseModel):
     # physical nozzle position IDs from BambuStudio's project_file MQTT
     # body; sent back to the printer verbatim on dispatch.
     nozzle_mapping: list[int] | None = None
+    # Which rack position each filament group prints from (#1784), as
+    # {group_id: 1-based position}. The operator's pick, re-checked against the
+    # live rack at dispatch; null means "assign them for me".
+    nozzle_rack_choice: dict[int, int] | None = None
 
 
 class QueueVariantSummary(BaseModel):
@@ -267,6 +279,10 @@ class PrintQueueItemResponse(BaseModel):
     # "edit print → choose nozzle" UI; null on every model except O1C2
     # uploads from BambuStudio.
     nozzle_mapping: list[int] | None = None
+    # Which rack position each filament group prints from (#1784), as
+    # {group_id: 1-based position}. The operator's pick, re-checked against the
+    # live rack at dispatch; null means "assign them for me".
+    nozzle_rack_choice: dict[int, int] | None = None
 
     class Config:
         from_attributes = True

+ 204 - 0
backend/app/services/bambu_mqtt.py

@@ -408,6 +408,210 @@ def resolve_rack_nozzle_mapping(
     return wire
 
 
+# A rack position as the operator counts it (and as the printer card and
+# BambuStudio both label it) is 1-based; the physical nozzle id is 15 higher.
+# Measured 2026-08-14: a plate dispatched with the operator picking R1 and R2
+# sent 16 and 17, and the same plate picking R1 and R3 sent 16 and 18.
+_RACK_POSITION_BASE = 15
+RACK_POSITIONS = tuple(range(1, len(_RACK_NOZZLE_IDS) + 1))
+
+
+def rack_position_to_nozzle_id(position: int) -> int | None:
+    """Physical nozzle id for a 1-based rack position, or None if out of range."""
+    if not isinstance(position, int) or isinstance(position, bool):
+        return None
+    if position not in RACK_POSITIONS:
+        return None
+    return _RACK_POSITION_BASE + position
+
+
+def _rack_slot_is_eligible(slot: dict, diameter: str, volume_type: str) -> bool:
+    """Whether a live rack slot can print a group wanting this nozzle.
+
+    Mirrors the filter BambuStudio applies in its own picker: the position has
+    to hold a nozzle at all, and that nozzle has to match the slice's diameter
+    and flow type. A mismatch here is not cosmetic -- it is the printer being
+    asked to lay down a 0.4 extrusion through a 0.2 orifice.
+    """
+    if not isinstance(slot, dict):
+        return False
+    slot_diameter = str(slot.get("diameter") or "").strip()
+    slot_type = str(slot.get("type") or "").strip()
+    if not slot_diameter and not slot_type:
+        return False  # empty position
+
+    # "0.40" and "0.4" are the same nozzle spelled two ways -- the 3MF pads,
+    # the printer does not.
+    try:
+        if round(float(slot_diameter), 2) != round(float(diameter), 2):
+            return False
+    except (TypeError, ValueError):
+        return False
+
+    # Flow type: the printer reports a code ("HS", "HH01"), the slice reports a
+    # name ("Standard", "High Flow"). Compared only when both are stated, so a
+    # printer that omits the code is not thereby ruled ineligible.
+    wanted = volume_type.strip().lower()
+    if wanted and slot_type:
+        is_high_flow = slot_type.upper().startswith("HH")
+        if wanted.startswith("high flow") != is_high_flow:
+            return False
+    return True
+
+
+# The nozzle currently picked up onto the rack carriage. Physical id 1 is the
+# fixed hotend (``_FIXED_NOZZLE_ID``), so the other carriage entry is 0.
+_RACK_CARRIAGE_NOZZLE_ID = 0
+
+
+def _rack_by_position(rack_slots: list[dict]) -> dict[int, dict]:
+    """Live rack contents keyed by 1-based position, mounted nozzle included.
+
+    The firmware omits a rack id entirely while that nozzle is picked up onto
+    the carriage (#943) -- it does not send an empty placeholder. Taking the
+    omission at face value would rule the nozzle ineligible for the very print
+    that wants it, and it is the single most likely position to be picked,
+    because it is the one the last print left mounted.
+
+    The absent id is recoverable only when exactly one is missing: rack ids are
+    fixed at 16..21, so a single gap alongside a loaded carriage is that
+    carriage's nozzle. Two or more gaps are genuinely ambiguous -- an operator
+    with four nozzles in six positions looks the same -- so those stay absent
+    and the caller treats them as empty.
+
+    Measured 2026-08-14 09:02 on the maintainer's H2C: ``IDs: [16, 1, 21, 19,
+    18, 0, 20]`` -- both carriages present, rack id 17 the lone gap.
+    """
+    by_position: dict[int, dict] = {}
+    carriage: dict | None = None
+    for slot in rack_slots or []:
+        if not isinstance(slot, dict) or not isinstance(slot.get("id"), int):
+            continue
+        if slot["id"] == _RACK_CARRIAGE_NOZZLE_ID:
+            carriage = slot
+            continue
+        position = slot["id"] - _RACK_POSITION_BASE
+        if position in RACK_POSITIONS:
+            by_position[position] = slot
+
+    missing = [position for position in RACK_POSITIONS if position not in by_position]
+    if len(missing) == 1 and carriage is not None and (carriage.get("diameter") or carriage.get("type")):
+        by_position[missing[0]] = carriage
+    return by_position
+
+
+def resolve_rack_plan_mapping(
+    slot_groups: list[int],
+    groups: dict[int, dict],
+    choice: dict[int, int],
+    rack_slots: list[dict],
+) -> tuple[list[int] | None, str | None]:
+    """Build a physical ``nozzle_mapping`` from a rack plan and a position pick.
+
+    This is the multi-hotend counterpart to :func:`resolve_rack_nozzle_mapping`.
+    That one can only name the single live rack position, so a plate wanting a
+    different hotend per group is unresolvable to it. Here each group carries
+    its own position, which is the operator's choice (#1784) -- the 3MF states
+    it nowhere, proven by dispatching one plate twice with different picks and
+    diffing the two files down to float noise.
+
+    ``choice`` may be partial or empty; groups it does not name are assigned
+    from the live rack, preferring a position already loaded with the group's
+    own filament colour and otherwise taking the lowest eligible one.
+
+    Returns ``(wire, None)`` on success, or ``(None, reason)`` where *reason*
+    is a sentence naming what could not be satisfied. The caller decides what
+    to do with a failure, and the two cases differ: a stale *explicit* pick
+    should stop the print, while a failed auto-assignment should degrade to
+    letting the firmware choose, exactly as before this existed.
+    """
+    if not isinstance(slot_groups, list) or not slot_groups:
+        return None, "the plate lists no filament slots"
+    if len(slot_groups) > _RACK_WIRE_SLOTS:
+        return None, f"the plate needs {len(slot_groups)} filament slots and the printer takes {_RACK_WIRE_SLOTS}"
+
+    by_position = _rack_by_position(rack_slots)
+
+    # Assign every rack-bound group a position before building the wire, so a
+    # group can never be handed one an earlier group already took. Explicit
+    # picks are placed first: an auto-assignment must yield to them rather than
+    # claim a position the operator asked for.
+    assigned: dict[int, int] = {}
+    rack_group_ids = sorted(gid for gid, g in groups.items() if g.get("on_rack"))
+
+    for group_id in rack_group_ids:
+        position = choice.get(group_id)
+        if position is None:
+            continue
+        group = groups[group_id]
+        if rack_position_to_nozzle_id(position) is None:
+            return None, f"rack position {position} does not exist"
+        if position in assigned.values():
+            return None, f"rack position {position} is picked for more than one filament group"
+        slot = by_position.get(position)
+        if slot is None:
+            return None, f"the printer reports nothing at rack position {position}"
+        if not _rack_slot_is_eligible(slot, group.get("nozzle_diameter", ""), group.get("volume_type", "")):
+            return None, (
+                f"rack position {position} holds a "
+                f"{slot.get('diameter') or 'missing'} {slot.get('type') or ''} nozzle, "
+                f"and the plate needs {group.get('nozzle_diameter')} {group.get('volume_type')}".replace("  ", " ")
+            )
+        assigned[group_id] = position
+
+    for group_id in rack_group_ids:
+        if group_id in assigned:
+            continue
+        group = groups[group_id]
+        eligible = [
+            position
+            for position in RACK_POSITIONS
+            if position not in assigned.values()
+            and position in by_position
+            and _rack_slot_is_eligible(
+                by_position[position], group.get("nozzle_diameter", ""), group.get("volume_type", "")
+            )
+        ]
+        if not eligible:
+            return None, (
+                f"no free rack position holds a {group.get('nozzle_diameter')} "
+                f"{group.get('volume_type')} nozzle for filament group {group_id}"
+            )
+        # Prefer a position already carrying this group's colour: picking it
+        # means the operator does not have to move filament to make the print
+        # match what they asked for.
+        wanted_colour = str(group.get("filament_color") or "").strip().lstrip("#").upper()[:6]
+        assigned[group_id] = next(
+            (
+                position
+                for position in eligible
+                if wanted_colour
+                and str(by_position[position].get("filament_color") or "").strip().lstrip("#").upper()[:6]
+                == wanted_colour
+            ),
+            eligible[0],
+        )
+
+    wire = [-1] * _RACK_WIRE_SLOTS
+    for index, group_id in enumerate(slot_groups):
+        if not isinstance(group_id, int) or isinstance(group_id, bool) or group_id < 0:
+            continue  # slot this plate does not print
+        group = groups.get(group_id)
+        if group is None:
+            return None, f"filament slot {index + 1} names group {group_id}, which the plate does not describe"
+        if not group.get("on_rack"):
+            wire[index] = _FIXED_NOZZLE_ID
+            continue
+        nozzle_id = rack_position_to_nozzle_id(assigned[group_id])
+        if nozzle_id is None:  # pragma: no cover - assigned only ever holds valid positions
+            return None, f"filament group {group_id} resolved to no rack position"
+        wire[index] = nozzle_id
+
+    if all(value == -1 for value in wire):
+        return None, "the plate assigns no filament to a nozzle"
+    return wire, None
+
+
 @dataclass
 class MQTTLogEntry:
     """Log entry for MQTT message debugging."""

+ 40 - 1
backend/app/services/filament_requirements.py

@@ -20,7 +20,10 @@ import xml.etree.ElementTree as ET
 import zipfile
 from pathlib import Path
 
-from backend.app.utils.threemf_tools import extract_nozzle_mapping_from_3mf
+from backend.app.utils.threemf_tools import (
+    extract_nozzle_mapping_from_3mf,
+    extract_rack_plan_from_3mf,
+)
 
 logger = logging.getLogger(__name__)
 
@@ -99,6 +102,8 @@ def extract_filament_requirements(file_path: Path, plate_id: int | None = None)
             if nozzle_mapping:
                 for filament in filaments:
                     filament["nozzle_id"] = nozzle_mapping.get(filament["slot_id"])
+
+            annotate_rack_groups(filaments, file_path, plate_id)
     except Exception as e:
         logger.warning("Failed to parse filament requirements from %s: %s", file_path, e)
         return []
@@ -106,6 +111,40 @@ def extract_filament_requirements(file_path: Path, plate_id: int | None = None)
     return filaments
 
 
+def annotate_rack_groups(filaments: list[dict], file_path: Path, plate_id: int | None) -> None:
+    """Tag each filament with its group and that group's hotend needs (#1784).
+
+    `nozzle_id` says which *carriage*, which is all a two-hotend printer needs.
+    An H2C's rack carriage hosts six, so the print dialog also needs the
+    filament *group* — the slicer's logical nozzle — to offer a rack position
+    for it. Groups are the unit of choice, not slots: two slots in one group
+    share a hotend and cannot be pointed at different positions.
+
+    Annotated whenever the file describes a rack, independently of the nozzle
+    mapping, which is deliberately withheld for exactly the multi-rack plates
+    this is most needed for.
+
+    Mutates ``filaments`` in place and returns nothing, so every caller lands
+    on one implementation: the three filament-requirements paths (archive,
+    library and this module's own parser) each build their filament list
+    differently and would otherwise drift.
+    """
+    rack_plan = extract_rack_plan_from_3mf(file_path, plate_id=plate_id)
+    if rack_plan is None:
+        return
+
+    group_dicts = rack_plan.group_dicts()
+    for filament in filaments:
+        index = filament.get("slot_id", 0) - 1
+        if not 0 <= index < len(rack_plan.slot_groups):
+            continue
+        group_id = rack_plan.slot_groups[index]
+        if group_id < 0:
+            continue
+        filament["group_id"] = group_id
+        filament["group"] = group_dicts.get(group_id)
+
+
 def overrides_for_plate(
     overrides: list[dict],
     file_path: Path | None,

+ 2 - 0
backend/app/services/print_batch.py

@@ -61,6 +61,7 @@ CLONED_SETTING_COLUMNS = (
     "print_time_seconds",
     "gcode_injection",
     "nozzle_mapping",
+    "nozzle_rack_choice",
     "require_previous_success",
     "auto_off_after",
     "manual_start",
@@ -83,6 +84,7 @@ CLONED_VARIANT_COLUMNS = (
     "plate_id",
     "ams_mapping",
     "nozzle_mapping",
+    "nozzle_rack_choice",
     "filament_overrides",
     "required_filament_types",
     "print_time_seconds",

+ 115 - 4
backend/app/services/print_scheduler.py

@@ -36,7 +36,7 @@ from backend.app.services.bambu_ftp import (
     upload_file_async,
     with_ftp_retry,
 )
-from backend.app.services.bambu_mqtt import HMS_MQTT_VERIFY_FAILED
+from backend.app.services.bambu_mqtt import HMS_MQTT_VERIFY_FAILED, resolve_rack_plan_mapping
 from backend.app.services.filament_deficit import compute_deficit_for_queue_item
 from backend.app.services.finance_budget import (
     create_budget_reservation,
@@ -63,7 +63,10 @@ from backend.app.utils.printer_models import (
     is_nozzle_rack_model,
     normalize_printer_model,
 )
-from backend.app.utils.threemf_tools import extract_slot_extruders_from_3mf
+from backend.app.utils.threemf_tools import (
+    extract_rack_plan_from_3mf,
+    extract_slot_extruders_from_3mf,
+)
 
 logger = logging.getLogger(__name__)
 
@@ -2252,6 +2255,7 @@ class PrintScheduler:
         item.plate_id = variant.plate_id
         item.ams_mapping = variant.ams_mapping
         item.nozzle_mapping = variant.nozzle_mapping
+        item.nozzle_rack_choice = variant.nozzle_rack_choice
         item.filament_overrides = variant.filament_overrides
         item.required_filament_types = variant.required_filament_types
         if variant.print_time_seconds is not None:
@@ -5759,8 +5763,114 @@ class PrintScheduler:
         # Skipped when the item already carries a Bambu Studio capture: that
         # one wins downstream anyway, so reading the 3MF again would be work
         # thrown away on every dispatch.
-        nozzle_slot_extruders = None
+        # Rack position resolution (#1784), tried before the #2800 fallback
+        # below because it can express what that one cannot: a plate wanting a
+        # *different* hotend off the rack per filament group. The position per
+        # group is the operator's pick — the 3MF states it nowhere, proven by
+        # sending one plate twice with different picks and finding the two
+        # files identical bar float noise — so it is resolved here against the
+        # rack as it stands right now, after the upload, not at queue time.
+        resolved_nozzle_mapping = None
         if not item.nozzle_mapping and file_path is not None and is_nozzle_rack_model(printer.model):
+            rack_plan = extract_rack_plan_from_3mf(file_path, plate_id=item.plate_id or 1)
+            if rack_plan is not None:
+                try:
+                    stored_choice = json.loads(item.nozzle_rack_choice) if item.nozzle_rack_choice else {}
+                except (json.JSONDecodeError, TypeError):
+                    stored_choice = {}
+                    logger.warning(
+                        "Queue item %s: unreadable nozzle_rack_choice %r, assigning rack positions instead",
+                        item.id,
+                        item.nozzle_rack_choice,
+                    )
+                # JSON object keys are strings; the groups are ints.
+                choice: dict[int, int] = {}
+                for key, value in (stored_choice or {}).items():
+                    try:
+                        choice[int(key)] = int(value)
+                    except (TypeError, ValueError):
+                        continue
+
+                live_rack = getattr(printer_manager.get_status(item.printer_id), "nozzle_rack", None) or []
+                resolved_nozzle_mapping, rack_error = resolve_rack_plan_mapping(
+                    rack_plan.slot_groups, rack_plan.group_dicts(), choice, live_rack
+                )
+                if resolved_nozzle_mapping is None and choice:
+                    # An explicit pick that no longer holds stops the print. The
+                    # operator named a hotend; printing from a different one is
+                    # how a plate gets levelled on one nozzle and drawn with
+                    # another, millimetres above the bed. Nothing has been sent
+                    # to the printer yet, so failing here costs only the upload.
+                    item.status = "failed"
+                    item.error_message = (
+                        f"Nozzle rack pick no longer fits the printer: {rack_error}. "
+                        "Edit the item to choose another position."
+                    )
+                    item.completed_at = datetime.now(timezone.utc)
+                    await db.commit()
+                    logger.warning(
+                        "Queue item %s: refusing to dispatch to %s — %s (chose %s, rack %s)",
+                        item.id,
+                        printer.name,
+                        rack_error,
+                        choice,
+                        [slot.get("id") for slot in live_rack],
+                    )
+                    await notification_service.on_queue_job_failed(
+                        job_name=filename.replace(".gcode.3mf", "").replace(".3mf", ""),
+                        printer_id=printer.id,
+                        printer_name=printer.name,
+                        reason=item.error_message,
+                        db=db,
+                    )
+                    try:
+                        await ws_manager.send_queue_item_failed(
+                            user_id=toast_uid,
+                            queue_item_id=item.id,
+                            printer_id=item.printer_id,
+                            reason="nozzle_rack_pick_stale",
+                        )
+                    except Exception:
+                        pass  # Best-effort — don't fail the error handler
+                    # The file is already on the SD card by this point, and a
+                    # 3MF left there is a phantom print waiting to be started
+                    # from the touchscreen. Same cleanup the start_print
+                    # failure path below does, for the same reason.
+                    try:
+                        await delete_file_async(
+                            printer.ip_address,
+                            printer.access_code,
+                            remote_path,
+                            printer_model=printer.model,
+                        )
+                    except Exception:
+                        pass  # Best-effort — don't fail the error handler
+                    return
+                if resolved_nozzle_mapping is None:
+                    # Nothing was picked and nothing could be assigned. Falls
+                    # through to the #2800 path, which is what ran before this
+                    # existed — strictly not worse than today.
+                    logger.info(
+                        "Queue item %s: no rack positions assignable (%s); falling back",
+                        item.id,
+                        rack_error,
+                    )
+                else:
+                    logger.info(
+                        "Queue item %s: rack mapping %s (groups %s, chosen %s)",
+                        item.id,
+                        resolved_nozzle_mapping,
+                        rack_plan.slot_groups,
+                        choice or "auto",
+                    )
+
+        nozzle_slot_extruders = None
+        if (
+            not item.nozzle_mapping
+            and resolved_nozzle_mapping is None
+            and file_path is not None
+            and is_nozzle_rack_model(printer.model)
+        ):
             slot_extruders = extract_slot_extruders_from_3mf(file_path, plate_id=item.plate_id or 1)
             if slot_extruders:
                 nozzle_slot_extruders = json.dumps(slot_extruders)
@@ -5783,7 +5893,8 @@ class PrintScheduler:
             timelapse=effective_timelapse,
             use_ams=item.use_ams,
             nozzle_offset_cali=item.nozzle_offset_cali,
-            nozzle_mapping=item.nozzle_mapping,
+            nozzle_mapping=item.nozzle_mapping
+            or (json.dumps(resolved_nozzle_mapping) if resolved_nozzle_mapping else None),
             nozzle_slot_extruders=nozzle_slot_extruders,
         )
 

+ 174 - 0
backend/app/utils/threemf_tools.py

@@ -380,6 +380,180 @@ def extract_slot_extruders_from_3mf(file_path: Path, plate_id: int | None = None
     return [by_slot.get(slot, -1) for slot in range(1, highest_slot + 1)]
 
 
+@dataclass(frozen=True)
+class RackGroup:
+    """One filament group on a nozzle-rack plate, and what hotend it needs.
+
+    A group is the slicer's *logical* nozzle. On an H2C the rack carriage hosts
+    six of them, so several groups share one extruder index -- which is exactly
+    the case ``extract_nozzle_mapping_from_3mf`` refuses to answer, because the
+    physical rack position per group is the operator's choice and is stated
+    nowhere in the file.
+    """
+
+    group_id: int
+    on_rack: bool
+    nozzle_diameter: str
+    volume_type: str
+    # Only a hint, for preferring a rack position already loaded with this
+    # colour. Excluded from equality on purpose: two filaments may share a
+    # group and differ in colour without the group being contradictory, and
+    # the agreement check below must not reject that file.
+    filament_color: str = field(default="", compare=False)
+
+
+@dataclass(frozen=True)
+class RackPlan:
+    """Everything a rack dispatch needs from the 3MF, short of the choice itself.
+
+    ``slot_groups`` is dense: index 0 is filament slot 1, and a slot the plate
+    does not print is ``-1``, matching :func:`extract_slot_extruders_from_3mf`.
+    ``groups`` is keyed by group id.
+    """
+
+    slot_groups: list[int]
+    groups: dict[int, RackGroup]
+
+    @property
+    def rack_group_ids(self) -> list[int]:
+        """Groups needing a rack position, lowest first, for stable assignment."""
+        return sorted(gid for gid, group in self.groups.items() if group.on_rack)
+
+    def group_dicts(self) -> dict[int, dict]:
+        """The groups as plain dicts, the form the resolver and the API take.
+
+        Keeps one definition of the shape rather than two that can drift: the
+        dispatcher resolves against it and the print dialog renders from it.
+        """
+        return {
+            gid: {
+                "on_rack": group.on_rack,
+                "nozzle_diameter": group.nozzle_diameter,
+                "volume_type": group.volume_type,
+                "filament_color": group.filament_color,
+            }
+            for gid, group in self.groups.items()
+        }
+
+
+def extract_rack_plan_from_3mf(file_path: Path, plate_id: int | None = None) -> RackPlan | None:
+    """What a nozzle-rack plate needs per group, or None (#1784).
+
+    :func:`extract_nozzle_mapping_from_3mf` answers "which carriage" and
+    withholds the whole mapping when a plate needs several hotends off one
+    rack. This answers the question underneath it -- which groups exist, which
+    of them are rack-bound, and what nozzle each one wants -- so the caller can
+    pair it with a chosen rack position and build a mapping the other function
+    cannot.
+
+    Measured basis (maintainer's H2C, 2026-08-14): the same plate was sent
+    twice with different rack picks and every member of the two 3MFs was
+    identical bar float noise -- ``group_id`` values, the toolchange stream and
+    the ``NOZZLE_CHANGE`` markers included. The pick lives only in the
+    dispatched ``nozzle_mapping``, so nothing here can or should derive it.
+
+    Returns None whenever the plate cannot be described completely: a partial
+    plan would place some slots and leave others at "not printed", which is the
+    contradiction the firmware rejects as HMS 0500-4047.
+
+    Takes a path rather than an open archive for the same reason
+    :func:`extract_slot_extruders_from_3mf` does -- the dispatcher is holding
+    the file, and a broken one must not take the print down.
+    """
+    try:
+        with zipfile.ZipFile(file_path) as zf:
+            return _rack_plan(zf, plate_id=plate_id)
+    except (zipfile.BadZipFile, OSError) as exc:
+        logger.warning("Failed to read rack plan from %s: %s", file_path, exc)
+        return None
+    except Exception:
+        logger.exception("Unreadable rack plan in %s", file_path)
+        return None
+
+
+def _rack_plan(zf: zipfile.ZipFile, plate_id: int | None) -> RackPlan | None:
+    """Body of :func:`extract_rack_plan_from_3mf`, on an already-open archive."""
+    names = zf.namelist()
+    if "Metadata/project_settings.config" not in names:
+        return None
+    if "Metadata/slice_info.config" not in names:
+        return None
+
+    data = json.loads(zf.read("Metadata/project_settings.config").decode())
+    physical_extruder_map = data.get("physical_extruder_map")
+    if not physical_extruder_map or len(physical_extruder_map) <= 1:
+        return None
+
+    # Which extruder index is the rack, taken from the file rather than a
+    # constant: the rack is the carriage that can address more than one nozzle.
+    # `extruder_max_nozzle_count` is ['1', '6'] on an H2C, and reading it here
+    # means a future rack of a different size needs no change.
+    rack_indices: set[int] = set()
+    for index, count in enumerate(data.get("extruder_max_nozzle_count") or []):
+        try:
+            if int(count) > 1:
+                rack_indices.add(index)
+        except (TypeError, ValueError):
+            return None
+    if not rack_indices:
+        return None
+
+    si_root = ET.fromstring(zf.read("Metadata/slice_info.config").decode())
+    plates = _plates_in_scope(si_root, plate_id)
+    group_extruders = _group_extruder_indices(plates)
+    if not group_extruders:
+        return None
+
+    filament_elems = [elem for plate in plates for elem in plate.findall(".//filament")]
+    if not filament_elems:
+        return None
+
+    slot_groups: dict[int, int] = {}
+    groups: dict[int, RackGroup] = {}
+    for elem in filament_elems:
+        group_id_str = elem.get("group_id")
+        slot_id_str = elem.get("id")
+        if group_id_str is None or not slot_id_str:
+            # One ungrouped filament makes the plan partial, and a partial plan
+            # dispatches the ungrouped slot as unprinted.
+            return None
+        try:
+            group_id = int(group_id_str)
+            slot_id = int(slot_id_str)
+        except (TypeError, ValueError):
+            return None
+
+        extruder_index = group_extruders.get(group_id)
+        if extruder_index is None or not 0 <= extruder_index < len(physical_extruder_map):
+            return None
+
+        # Two plates in scope may name the same slot; they must agree, or the
+        # dispatched plate is ambiguous.
+        if slot_groups.setdefault(slot_id, group_id) != group_id:
+            return None
+
+        group = RackGroup(
+            group_id=group_id,
+            on_rack=extruder_index in rack_indices,
+            nozzle_diameter=(elem.get("nozzle_diameter") or "").strip(),
+            volume_type=(elem.get("volume_type") or "").strip(),
+            filament_color=(elem.get("color") or "").strip(),
+        )
+        # Filaments sharing a group must want the same hotend, or "the group is
+        # one nozzle" is not true and no single position can serve them.
+        if groups.setdefault(group_id, group) != group:
+            return None
+
+    highest_slot = max(slot_groups)
+    if highest_slot < 1 or highest_slot > _MAX_DENSE_FILAMENT_SLOTS:
+        return None
+
+    return RackPlan(
+        slot_groups=[slot_groups.get(slot, -1) for slot in range(1, highest_slot + 1)],
+        groups=groups,
+    )
+
+
 def _plates_in_scope(si_root: XmlElement, plate_id: int | None) -> list[XmlElement]:
     """The ``<plate>`` elements a lookup should read, narrowed to one if asked.
 

+ 172 - 0
backend/tests/integration/test_queue_nozzle_rack_choice_api_1784.py

@@ -0,0 +1,172 @@
+"""The rack-position pick has to survive the round trip (#1784).
+
+It did not, first time out: the field was declared on ``PrintQueueItemUpdate``
+and the response model but not on ``PrintQueueItemCreate``, so Pydantic dropped
+it from every POST without complaint. The queued item then carried no pick, the
+dispatcher assigned positions itself, and the print ran from hotends the
+operator had not chosen -- with nothing in the logs but ``chosen auto``.
+
+A silently-dropped field is invisible at every layer above it, so it is pinned
+here at the layer it crosses: HTTP in, database out.
+"""
+
+import pytest
+from httpx import AsyncClient
+
+pytestmark = pytest.mark.integration
+
+
+@pytest.fixture
+async def printer(db_session):
+    from backend.app.models.printer import Printer
+
+    printer = Printer(
+        name="H2C-1",
+        ip_address="192.168.1.210",
+        serial_number="RACKCHOICE0001",
+        access_code="12345678",
+        model="H2C",
+    )
+    db_session.add(printer)
+    await db_session.commit()
+    await db_session.refresh(printer)
+    return printer
+
+
+@pytest.fixture
+async def archive(db_session, printer):
+    from backend.app.models.archive import PrintArchive
+
+    archive = PrintArchive(
+        printer_id=printer.id,
+        filename="benchy.gcode.3mf",
+        file_path="archives/benchy.gcode.3mf",
+        file_size=1024,
+        status="completed",
+    )
+    db_session.add(archive)
+    await db_session.commit()
+    await db_session.refresh(archive)
+    return archive
+
+
+async def _stored_choice(db_session, item_id):
+    """What actually landed in the column, not what the response echoed."""
+    from backend.app.models.print_queue import PrintQueueItem
+
+    db_session.expire_all()
+    item = await db_session.get(PrintQueueItem, item_id)
+    return item.nozzle_rack_choice
+
+
+@pytest.mark.asyncio
+class TestCreate:
+    async def test_a_pick_posted_on_create_reaches_the_column(
+        self, async_client: AsyncClient, printer, archive, db_session
+    ):
+        response = await async_client.post(
+            "/api/v1/queue/",
+            json={
+                "printer_id": printer.id,
+                "archive_id": archive.id,
+                # Group 2 to rack position 1, group 1 to position 3 -- the pick
+                # BambuStudio dispatched as [16, 1, 18] on 2026-08-13.
+                "nozzle_rack_choice": {"2": 1, "1": 3},
+            },
+        )
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["nozzle_rack_choice"] == {"2": 1, "1": 3}
+        assert await _stored_choice(db_session, result["id"]) is not None
+
+    async def test_creating_without_one_leaves_the_column_null(
+        self, async_client: AsyncClient, printer, archive, db_session
+    ):
+        """Null is the signal to assign positions at dispatch."""
+        response = await async_client.post("/api/v1/queue/", json={"printer_id": printer.id, "archive_id": archive.id})
+
+        assert response.status_code == 200
+        assert response.json()["nozzle_rack_choice"] is None
+        assert await _stored_choice(db_session, response.json()["id"]) is None
+
+
+@pytest.mark.asyncio
+class TestUpdate:
+    async def test_editing_an_item_replaces_its_pick(self, async_client: AsyncClient, printer, archive, db_session):
+        created = await async_client.post(
+            "/api/v1/queue/",
+            json={
+                "printer_id": printer.id,
+                "archive_id": archive.id,
+                "nozzle_rack_choice": {"2": 1, "1": 3},
+            },
+        )
+        item_id = created.json()["id"]
+
+        response = await async_client.patch(f"/api/v1/queue/{item_id}", json={"nozzle_rack_choice": {"2": 1, "1": 2}})
+
+        assert response.status_code == 200
+        assert response.json()["nozzle_rack_choice"] == {"2": 1, "1": 2}
+
+    async def test_clearing_the_pick_hands_the_choice_back_to_the_dispatcher(
+        self, async_client: AsyncClient, printer, archive, db_session
+    ):
+        created = await async_client.post(
+            "/api/v1/queue/",
+            json={
+                "printer_id": printer.id,
+                "archive_id": archive.id,
+                "nozzle_rack_choice": {"2": 1, "1": 3},
+            },
+        )
+        item_id = created.json()["id"]
+
+        response = await async_client.patch(f"/api/v1/queue/{item_id}", json={"nozzle_rack_choice": None})
+
+        assert response.status_code == 200
+        assert response.json()["nozzle_rack_choice"] is None
+        assert await _stored_choice(db_session, item_id) is None
+
+    async def test_an_unrelated_edit_does_not_disturb_the_pick(
+        self, async_client: AsyncClient, printer, archive, db_session
+    ):
+        created = await async_client.post(
+            "/api/v1/queue/",
+            json={
+                "printer_id": printer.id,
+                "archive_id": archive.id,
+                "nozzle_rack_choice": {"2": 1, "1": 3},
+            },
+        )
+        item_id = created.json()["id"]
+
+        response = await async_client.patch(f"/api/v1/queue/{item_id}", json={"manual_start": True})
+
+        assert response.status_code == 200
+        assert response.json()["nozzle_rack_choice"] == {"2": 1, "1": 3}
+
+
+class TestSchemaCoverage:
+    def test_create_update_and_response_all_declare_the_field(self):
+        """The original bug in one assertion: it was on two of the three.
+
+        A field missing from a request schema is dropped in silence, so there is
+        no error anywhere to catch it -- only a print that runs from the wrong
+        hotend.
+        """
+        from backend.app.schemas.print_queue import (
+            PrintQueueItemCreate,
+            PrintQueueItemResponse,
+            PrintQueueItemUpdate,
+            QueueVariantCreate,
+        )
+
+        for schema in (PrintQueueItemCreate, PrintQueueItemUpdate, PrintQueueItemResponse, QueueVariantCreate):
+            assert "nozzle_rack_choice" in schema.model_fields, schema.__name__
+
+    def test_the_create_schema_actually_keeps_a_posted_pick(self):
+        from backend.app.schemas.print_queue import PrintQueueItemCreate
+
+        parsed = PrintQueueItemCreate(printer_id=1, archive_id=1, nozzle_rack_choice={"2": 1, "1": 3})
+        assert parsed.nozzle_rack_choice == {2: 1, 1: 3}

+ 253 - 0
backend/tests/integration/test_scheduler_nozzle_rack_dispatch_1784.py

@@ -0,0 +1,253 @@
+"""What the dispatcher does with a rack-position pick (#1784).
+
+The resolution itself is covered in
+``backend/tests/unit/test_nozzle_rack_positions_1784.py``. This covers the glue
+around it, where the two failure modes deliberately differ:
+
+- an **explicit** pick that no longer fits the rack stops the print, because the
+  operator named a hotend and printing from a different one is how a plate gets
+  levelled on one nozzle and drawn with another, millimetres above the bed;
+- an **assignment** that cannot be made falls through to the pre-existing #2800
+  path, which is strictly not worse than the behaviour before any of this.
+
+And a non-rack printer must be untouched by all of it.
+"""
+
+from __future__ import annotations
+
+import json
+import zipfile
+from contextlib import ExitStack
+from pathlib import Path
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
+
+import backend.app.models  # noqa: F401 - populate Base.metadata
+import backend.app.services.print_scheduler as scheduler_module
+from backend.app.core.database import Base
+from backend.app.models.archive import PrintArchive
+from backend.app.models.print_queue import PrintQueueItem
+from backend.app.models.printer import Printer
+from backend.app.models.settings import Settings  # noqa: F401 - registers the table
+from backend.app.services.print_scheduler import PrintScheduler
+
+pytestmark = pytest.mark.integration
+
+# Three filaments in groups 2/0/1, groups 1 and 2 both on the rack carriage --
+# the maintainer's own plate, the one that printed in mid-air.
+_FILAMENTS = (
+    '<filament id="1" group_id="2" color="#DE4343" nozzle_diameter="0.40" volume_type="High Flow"/>'
+    '<filament id="2" group_id="0" color="#F4EE2A" nozzle_diameter="0.40" volume_type="High Flow"/>'
+    '<filament id="3" group_id="1" color="#0078BF" nozzle_diameter="0.40" volume_type="High Flow"/>'
+)
+_NOZZLES = '<nozzle id="0" extruder_id="1"/><nozzle id="1" extruder_id="2"/><nozzle id="2" extruder_id="2"/>'
+
+
+def _write_3mf(path: Path) -> None:
+    path.parent.mkdir(parents=True, exist_ok=True)
+    with zipfile.ZipFile(path, "w") as zf:
+        zf.writestr(
+            "Metadata/project_settings.config",
+            json.dumps(
+                {
+                    "physical_extruder_map": ["1", "0"],
+                    "extruder_max_nozzle_count": ["1", "6"],
+                    "extruder_nozzle_stats": ["High Flow#1", "High Flow#6"],
+                }
+            ),
+        )
+        zf.writestr(
+            "Metadata/slice_info.config",
+            f'<config><plate><metadata key="index" value="1"/>{_FILAMENTS}{_NOZZLES}</plate></config>',
+        )
+
+
+def _rack(present=(1, 2, 3, 4, 5, 6)):
+    """Live rack telemetry, plus the always-reported fixed carriage."""
+    return [{"id": 15 + p, "diameter": "0.4", "type": "HH01", "filament_color": ""} for p in present] + [
+        {"id": 1, "diameter": "0.4", "type": "HH01", "filament_color": ""}
+    ]
+
+
+@pytest.fixture
+async def rack_case(tmp_path):
+    engine = create_async_engine("sqlite+aiosqlite:///:memory:")
+    async with engine.begin() as conn:
+        await conn.run_sync(Base.metadata.create_all)
+    session_maker = async_sessionmaker(engine, expire_on_commit=False)
+
+    base_dir = tmp_path / "rack-dispatch"
+    archive_rel = Path("archives") / "benchy.gcode.3mf"
+    _write_3mf(base_dir / archive_rel)
+
+    async def _build(model: str, choice: dict | None):
+        async with session_maker() as db:
+            printer = Printer(
+                name="H2C-1",
+                serial_number="RACK-SERIAL",
+                ip_address="127.0.0.1",
+                access_code="access-code",
+                model=model,
+            )
+            db.add(printer)
+            await db.flush()
+            archive = PrintArchive(
+                printer_id=printer.id,
+                filename="benchy.gcode.3mf",
+                file_path=str(archive_rel),
+                file_size=(base_dir / archive_rel).stat().st_size,
+                status="completed",
+            )
+            db.add(archive)
+            await db.flush()
+            item = PrintQueueItem(
+                printer_id=printer.id,
+                archive_id=archive.id,
+                plate_id=1,
+                status="pending",
+                nozzle_rack_choice=json.dumps(choice) if choice else None,
+            )
+            db.add(item)
+            await db.commit()
+            return SimpleNamespace(item_id=item.id, printer_id=printer.id)
+
+    try:
+        yield SimpleNamespace(session_maker=session_maker, base_dir=base_dir, build=_build)
+    finally:
+        await engine.dispose()
+
+
+async def _dispatch(ctx, ids, rack_slots):
+    """Run one dispatch, returning the mocked ``start_print`` and the delete."""
+    scheduler = PrintScheduler()
+    start_print = MagicMock(return_value=True)
+    delete_file = AsyncMock(return_value=True)
+    status = SimpleNamespace(state="IDLE", nozzle_rack=rack_slots)
+
+    with ExitStack() as stack:
+        for patcher in (
+            patch.object(scheduler_module, "async_session", ctx.session_maker),
+            patch.object(scheduler_module.settings, "base_dir", ctx.base_dir),
+            patch("backend.app.services.print_scheduler.printer_manager.is_connected", MagicMock(return_value=True)),
+            patch("backend.app.services.print_scheduler.printer_manager.get_status", MagicMock(return_value=status)),
+            patch("backend.app.services.print_scheduler.printer_manager.start_print", start_print),
+            patch("backend.app.services.print_scheduler.printer_manager.set_awaiting_plate_clear", MagicMock()),
+            patch("backend.app.services.print_scheduler.delete_file_async", delete_file),
+            patch("backend.app.services.print_scheduler.upload_file_async", AsyncMock(return_value=True)),
+            # Reads settings through its own session on the real app database,
+            # which the in-memory engine here does not have.
+            patch(
+                "backend.app.services.print_scheduler.get_ftp_retry_settings",
+                AsyncMock(return_value=(False, 3, 2.0, 30.0)),
+            ),
+            patch("backend.app.services.print_scheduler.cache_3mf_download", MagicMock()),
+            patch("backend.app.services.print_scheduler.spawn_background_task", MagicMock()),
+            patch("backend.app.services.notification_service.notification_service.on_queue_job_started", AsyncMock()),
+            patch("backend.app.services.notification_service.notification_service.on_queue_job_failed", AsyncMock()),
+            patch("backend.app.services.mqtt_relay.mqtt_relay.on_queue_job_started", AsyncMock()),
+            patch.object(scheduler, "_propagate_owner_to_printer_manager", AsyncMock()),
+            patch.object(scheduler, "_power_off_if_needed", AsyncMock()),
+            patch.object(scheduler, "_preheat_and_soak", AsyncMock()),
+        ):
+            stack.enter_context(patcher)
+        await scheduler._dispatch_one(ids.item_id)
+
+    async with ctx.session_maker() as db:
+        item = await db.get(PrintQueueItem, ids.item_id)
+    return start_print, delete_file, item
+
+
+def _sent_mapping(start_print):
+    assert start_print.call_count == 1, "the print command was never sent"
+    return json.loads(start_print.call_args.kwargs["nozzle_mapping"])
+
+
+class TestAPickThatStillFits:
+    async def test_the_chosen_positions_reach_the_printer(self, rack_case):
+        """Picking R1 for group 2 and R2 for group 1 is BambuStudio's own
+        dispatch of this plate on 2026-08-14: nozzle_mapping [16, 1, 17].
+        """
+        ids = await rack_case.build("H2C", {"2": 1, "1": 2})
+        start_print, _, item = await _dispatch(rack_case, ids, _rack())
+
+        assert _sent_mapping(start_print)[:3] == [16, 1, 17]
+        assert item.status == "printing"
+
+    async def test_a_different_pick_of_the_same_plate_sends_a_different_mapping(self, rack_case):
+        """The 2026-08-13 dispatch of the identical file: [16, 1, 18]."""
+        ids = await rack_case.build("H2C", {"2": 1, "1": 3})
+        start_print, _, _ = await _dispatch(rack_case, ids, _rack())
+
+        assert _sent_mapping(start_print)[:3] == [16, 1, 18]
+
+
+class TestNoPickAtAll:
+    async def test_positions_are_assigned_rather_than_left_to_the_firmware(self, rack_case):
+        """The plate that used to dispatch with no mapping now gets one."""
+        ids = await rack_case.build("H2C", None)
+        start_print, _, item = await _dispatch(rack_case, ids, _rack())
+
+        assert _sent_mapping(start_print)[:3] == [17, 1, 16]
+        assert item.status == "printing"
+
+    async def test_an_unassignable_plate_falls_back_instead_of_failing(self, rack_case):
+        """Nothing was promised, so nothing is broken by letting the firmware
+        pick -- exactly what happened before this feature existed.
+        """
+        ids = await rack_case.build("H2C", None)
+        start_print, _, item = await _dispatch(rack_case, ids, _rack(present=()))
+
+        assert start_print.call_count == 1
+        assert start_print.call_args.kwargs["nozzle_mapping"] is None
+        assert item.status == "printing"
+
+
+class TestAPickThatNoLongerFits:
+    """Someone re-loaded the rack between queueing and dispatch."""
+
+    async def test_the_print_is_refused_rather_than_sent_to_another_hotend(self, rack_case):
+        ids = await rack_case.build("H2C", {"2": 1, "1": 3})
+        start_print, _, item = await _dispatch(rack_case, ids, _rack(present=(1, 2)))
+
+        start_print.assert_not_called()
+        assert item.status == "failed"
+
+    async def test_the_error_names_the_position_and_says_how_to_fix_it(self, rack_case):
+        ids = await rack_case.build("H2C", {"2": 1, "1": 3})
+        _, _, item = await _dispatch(rack_case, ids, _rack(present=(1, 2)))
+
+        assert "rack position 3" in item.error_message
+        assert "Edit the item" in item.error_message
+
+    async def test_the_uploaded_file_is_removed_from_the_sd_card(self, rack_case):
+        """It is already uploaded by this point, and a 3MF left there is a
+        phantom print waiting to be started from the touchscreen.
+        """
+        ids = await rack_case.build("H2C", {"2": 1, "1": 3})
+        _, delete_file, _ = await _dispatch(rack_case, ids, _rack(present=(1, 2)))
+
+        delete_file.assert_awaited()
+
+
+class TestOtherModels:
+    async def test_a_non_rack_printer_is_left_entirely_alone(self, rack_case):
+        """No rack means no resolution, no refusal, and no mapping invented."""
+        ids = await rack_case.build("X1C", None)
+        start_print, _, item = await _dispatch(rack_case, ids, [])
+
+        assert start_print.call_count == 1
+        assert start_print.call_args.kwargs["nozzle_mapping"] is None
+        assert item.status == "printing"
+
+    async def test_a_stale_pick_on_a_non_rack_printer_does_not_stop_the_print(self, rack_case):
+        """The column can survive a reassignment to another model; it must not
+        then block a printer the pick never applied to.
+        """
+        ids = await rack_case.build("X1C", {"2": 1, "1": 3})
+        start_print, _, item = await _dispatch(rack_case, ids, [])
+
+        assert start_print.call_count == 1
+        assert item.status == "printing"

+ 483 - 0
backend/tests/unit/test_nozzle_rack_positions_1784.py

@@ -0,0 +1,483 @@
+"""Choosing which rack position each filament group prints from (#1784).
+
+An H2C's rack carriage hosts six hotends. Which one a filament group takes is
+the operator's choice and is stated nowhere in the 3MF -- established by
+dispatching one plate twice from BambuStudio with different picks and diffing
+the two files: `group_id` values, the toolchange stream, the `NOZZLE_CHANGE`
+markers and `project_settings.config` were all identical, and only extrusion
+floats differed in the last digit. The pick lives solely in the dispatched
+`nozzle_mapping`.
+
+The numbers pinned here are measured on the maintainer's own H2C
+(`31B8BP610600650`), not invented:
+
+- 2026-08-14 09:32, picking R1 and R2 -> `nozzle_mapping [16, 1, 17, -1 x29]`
+- 2026-08-13 17:20, same plate picking R1 and R3 -> `[16, 1, 18, -1 x29]`
+- 2026-08-14 09:02 rack telemetry -> `IDs: [16, 1, 21, 19, 18, 0, 20]`, i.e.
+  both carriages present and rack id 17 the lone gap, because its nozzle was
+  mounted at the time.
+"""
+
+import json
+import zipfile
+
+import pytest
+
+from backend.app.services.bambu_mqtt import (
+    RACK_POSITIONS,
+    _rack_by_position,
+    rack_position_to_nozzle_id,
+    resolve_rack_plan_mapping,
+)
+from backend.app.utils.threemf_tools import extract_rack_plan_from_3mf
+
+# The plate this whole feature was built against: three PLA filaments, groups
+# 2/0/1, with groups 1 and 2 both on the rack carriage (slicer extruder 2).
+BENCHY_FILAMENTS = {
+    1: {"group": 2, "color": "#DE4343"},
+    2: {"group": 0, "color": "#F4EE2A"},
+    3: {"group": 1, "color": "#0078BF"},
+}
+BENCHY_NOZZLES = {0: 1, 1: 2, 2: 2}
+
+# The machine half of the file, for the tests that hand-write slice_info.
+_RACK_SETTINGS = json.dumps(
+    {
+        "physical_extruder_map": ["1", "0"],
+        "extruder_max_nozzle_count": ["1", "6"],
+        "extruder_nozzle_stats": ["High Flow#1", "High Flow#6"],
+    }
+)
+
+
+def _write_rack_3mf(path, filaments, nozzles, *, plate_index=1, max_nozzles=("1", "6"), diameter="0.40"):
+    """A 3MF in the shape BambuStudio writes for a nozzle-rack machine."""
+    elems = "".join(
+        f'<filament id="{slot}" group_id="{f["group"]}" color="{f["color"]}" '
+        f'nozzle_diameter="{f.get("diameter", diameter)}" '
+        f'volume_type="{f.get("volume_type", "High Flow")}"/>'
+        for slot, f in filaments.items()
+    )
+    elems += "".join(f'<nozzle id="{group}" extruder_id="{ext}"/>' for group, ext in nozzles.items())
+    body = f'<plate><metadata key="index" value="{plate_index}"/>{elems}</plate>'
+    with zipfile.ZipFile(path, "w") as zf:
+        zf.writestr(
+            "Metadata/project_settings.config",
+            json.dumps(
+                {
+                    "physical_extruder_map": ["1", "0"],
+                    "extruder_max_nozzle_count": list(max_nozzles),
+                    "extruder_nozzle_stats": ["High Flow#1", "High Flow#6"],
+                }
+            ),
+        )
+        zf.writestr("Metadata/slice_info.config", f"<config>{body}</config>")
+    return path
+
+
+def _rack(present=(1, 2, 3, 4, 5, 6), *, diameters=None, types=None, colors=None, carriage=None):
+    """Live rack telemetry: a nozzle at each named 1-based position.
+
+    Positions left out are absent from the payload entirely, which is how the
+    firmware reports both an empty position and one whose nozzle is currently
+    mounted (#943).
+    """
+    diameters = diameters or {}
+    types = types or {}
+    colors = colors or {}
+    slots = [
+        {
+            "id": 15 + position,
+            "diameter": diameters.get(position, "0.4"),
+            "type": types.get(position, "HH01"),
+            "filament_color": colors.get(position, ""),
+        }
+        for position in present
+    ]
+    # The fixed carriage is always reported; the rack carriage only when it
+    # actually holds a nozzle.
+    slots.append({"id": 1, "diameter": "0.4", "type": "HH01", "filament_color": ""})
+    if carriage:
+        slots.append({"id": 0, **carriage})
+    return slots
+
+
+@pytest.fixture
+def benchy(tmp_path):
+    return _write_rack_3mf(tmp_path / "benchy.3mf", BENCHY_FILAMENTS, BENCHY_NOZZLES)
+
+
+class TestRackPositionNumbering:
+    """Rack position n is physical nozzle id 15 + n."""
+
+    def test_the_six_positions_map_to_the_ids_the_printer_uses(self):
+        assert [rack_position_to_nozzle_id(p) for p in RACK_POSITIONS] == [16, 17, 18, 19, 20, 21]
+
+    @pytest.mark.parametrize("position", [0, -1, 7, 16])
+    def test_a_position_outside_the_rack_names_no_nozzle(self, position):
+        assert rack_position_to_nozzle_id(position) is None
+
+    def test_a_bool_is_not_a_position(self):
+        """`True` is an int in Python and would otherwise resolve to R1."""
+        assert rack_position_to_nozzle_id(True) is None
+
+
+class TestReadingThePlan:
+    def test_the_maintainers_plate_reads_as_two_rack_groups_and_one_fixed(self, benchy):
+        plan = extract_rack_plan_from_3mf(benchy, plate_id=1)
+
+        assert plan.slot_groups == [2, 0, 1]
+        assert plan.rack_group_ids == [1, 2]
+        assert plan.groups[0].on_rack is False
+        assert plan.groups[1].on_rack is True
+        assert plan.groups[2].nozzle_diameter == "0.40"
+        assert plan.groups[2].volume_type == "High Flow"
+
+    def test_the_rack_carriage_is_read_from_the_file_not_assumed(self, tmp_path):
+        """`extruder_max_nozzle_count` names the rack: the one addressing >1.
+
+        A fourth independent confirmation of which extruder index is the rack,
+        and the only one that comes from the file itself rather than telemetry.
+        """
+        source = _write_rack_3mf(tmp_path / "flipped.3mf", BENCHY_FILAMENTS, BENCHY_NOZZLES, max_nozzles=("6", "1"))
+        plan = extract_rack_plan_from_3mf(source, plate_id=1)
+
+        # Groups 1 and 2 sit on slicer extruder 2 -> index 1, which is now the
+        # single-nozzle carriage; group 0 is on index 0, which is now the rack.
+        # Exactly inverted from the same file read with ('1', '6').
+        assert plan.rack_group_ids == [0]
+        assert plan.groups[1].on_rack is False
+        assert plan.groups[2].on_rack is False
+
+    def test_a_machine_with_no_multi_nozzle_carriage_has_no_plan(self, tmp_path):
+        source = _write_rack_3mf(tmp_path / "h2d.3mf", BENCHY_FILAMENTS, BENCHY_NOZZLES, max_nozzles=("1", "1"))
+        assert extract_rack_plan_from_3mf(source, plate_id=1) is None
+
+    def test_an_ungrouped_filament_makes_the_plan_partial_so_there_is_none(self, tmp_path):
+        """A partial plan dispatches the ungrouped slot as unprinted.
+
+        Which contradicts an ams_mapping that does name a tray for it, and the
+        firmware rejects the contradiction outright as HMS 0500-4047.
+        """
+        path = _write_rack_3mf(tmp_path / "partial.3mf", BENCHY_FILAMENTS, BENCHY_NOZZLES)
+        with zipfile.ZipFile(path, "w") as zf:
+            zf.writestr("Metadata/project_settings.config", _RACK_SETTINGS)
+            zf.writestr(
+                "Metadata/slice_info.config",
+                '<config><plate><metadata key="index" value="1"/>'
+                '<filament id="1" group_id="2" nozzle_diameter="0.40" volume_type="High Flow"/>'
+                '<filament id="2"/>'
+                '<nozzle id="2" extruder_id="2"/></plate></config>',
+            )
+        assert extract_rack_plan_from_3mf(path, plate_id=1) is None
+
+    def test_two_filaments_may_share_a_group_and_differ_in_colour(self, tmp_path):
+        """Colour is a hint for auto-assignment, not part of the group identity.
+
+        Rejecting the file over it would be wrong: a group is one hotend, and a
+        hotend can print more than one colour in sequence.
+        """
+        source = _write_rack_3mf(
+            tmp_path / "shared.3mf",
+            {1: {"group": 1, "color": "#FF0000"}, 2: {"group": 1, "color": "#00FF00"}},
+            {1: 2},
+        )
+        plan = extract_rack_plan_from_3mf(source, plate_id=1)
+
+        assert plan.slot_groups == [1, 1]
+        assert plan.rack_group_ids == [1]
+
+    def test_filaments_in_one_group_wanting_different_nozzles_is_unresolvable(self, tmp_path):
+        """One group is one hotend, so no single position can serve both."""
+        source = _write_rack_3mf(
+            tmp_path / "contradiction.3mf",
+            {
+                1: {"group": 1, "color": "#FF0000"},
+                2: {"group": 1, "color": "#00FF00", "diameter": "0.60"},
+            },
+            {1: 2},
+        )
+        assert extract_rack_plan_from_3mf(source, plate_id=1) is None
+
+    def test_an_unreadable_file_yields_no_plan_rather_than_raising(self, tmp_path):
+        broken = tmp_path / "broken.3mf"
+        broken.write_bytes(b"not a zip")
+        assert extract_rack_plan_from_3mf(broken, plate_id=1) is None
+
+    def test_a_missing_file_yields_no_plan(self, tmp_path):
+        assert extract_rack_plan_from_3mf(tmp_path / "absent.3mf", plate_id=1) is None
+
+
+class TestResolvingAgainstTheLiveRack:
+    """The measured dispatches, reproduced from a plan plus a pick."""
+
+    def test_picking_r1_and_r2_reproduces_the_dispatch_of_2026_08_14(self, benchy):
+        plan = extract_rack_plan_from_3mf(benchy, plate_id=1)
+        wire, error = resolve_rack_plan_mapping(plan.slot_groups, plan.group_dicts(), {2: 1, 1: 2}, _rack())
+
+        assert error is None
+        assert wire[:3] == [16, 1, 17]
+        assert wire[3:] == [-1] * 29
+        assert len(wire) == 32
+
+    def test_picking_r1_and_r3_reproduces_the_dispatch_of_2026_08_13(self, benchy):
+        plan = extract_rack_plan_from_3mf(benchy, plate_id=1)
+        wire, error = resolve_rack_plan_mapping(plan.slot_groups, plan.group_dicts(), {2: 1, 1: 3}, _rack())
+
+        assert error is None
+        assert wire[:3] == [16, 1, 18]
+
+    def test_the_fixed_group_always_lands_on_physical_nozzle_1(self, benchy):
+        """Whichever slot it occupies -- confirmed in both captures."""
+        plan = extract_rack_plan_from_3mf(benchy, plate_id=1)
+        wire, _ = resolve_rack_plan_mapping(plan.slot_groups, plan.group_dicts(), {2: 4, 1: 5}, _rack())
+
+        # Slot 2 is group 0, the fixed carriage.
+        assert wire[1] == 1
+
+
+class TestAutoAssignment:
+    def test_an_unpicked_plate_is_assigned_by_colour(self, benchy):
+        """Preferring the position already loaded with the group's own colour
+        means the operator does not have to move filament to get what they
+        asked for. Here that reproduces BambuStudio's own pick exactly.
+        """
+        plan = extract_rack_plan_from_3mf(benchy, plate_id=1)
+        rack = _rack(colors={1: "DE4343FF", 2: "0078BFFF", 4: "FFFFFFFF"})
+        wire, error = resolve_rack_plan_mapping(plan.slot_groups, plan.group_dicts(), {}, rack)
+
+        assert error is None
+        assert wire[:3] == [16, 1, 17]
+
+    def test_without_a_colour_match_it_takes_the_lowest_free_position(self, benchy):
+        plan = extract_rack_plan_from_3mf(benchy, plate_id=1)
+        wire, error = resolve_rack_plan_mapping(plan.slot_groups, plan.group_dicts(), {}, _rack())
+
+        assert error is None
+        # Groups are assigned lowest-id first: group 1 takes R1, group 2 takes R2.
+        assert wire[:3] == [17, 1, 16]
+
+    def test_an_explicit_pick_is_never_stolen_by_an_auto_assignment(self, benchy):
+        """The half-picked case: one group named, the other filled in around it."""
+        plan = extract_rack_plan_from_3mf(benchy, plate_id=1)
+        wire, error = resolve_rack_plan_mapping(plan.slot_groups, plan.group_dicts(), {1: 1}, _rack())
+
+        assert error is None
+        assert wire[2] == 16  # group 1, as picked
+        assert wire[0] == 17  # group 2, assigned around it
+
+    def test_only_eligible_positions_are_assigned(self, benchy):
+        """A 0.2 nozzle cannot lay down a 0.4 extrusion."""
+        plan = extract_rack_plan_from_3mf(benchy, plate_id=1)
+        rack = _rack(present=(1, 2, 3), diameters={1: "0.2", 2: "0.6"})
+        wire, error = resolve_rack_plan_mapping(plan.slot_groups, plan.group_dicts(), {}, rack)
+
+        assert wire is None
+        assert "no free rack position" in error
+
+    def test_flow_type_is_matched_as_well_as_diameter(self, benchy):
+        plan = extract_rack_plan_from_3mf(benchy, plate_id=1)
+        rack = _rack(present=(1, 2), types={1: "HS", 2: "HS"})
+        _, error = resolve_rack_plan_mapping(plan.slot_groups, plan.group_dicts(), {}, rack)
+
+        assert "no free rack position" in error
+
+    def test_a_printer_reporting_no_nozzle_type_is_not_ruled_out(self, benchy):
+        """Compared only when both sides state it, so a terse firmware still works."""
+        plan = extract_rack_plan_from_3mf(benchy, plate_id=1)
+        rack = _rack(present=(1, 2), types={1: "", 2: ""})
+        wire, error = resolve_rack_plan_mapping(plan.slot_groups, plan.group_dicts(), {}, rack)
+
+        assert error is None
+        assert wire[:3] == [17, 1, 16]
+
+    def test_a_padded_diameter_matches_an_unpadded_one(self, benchy):
+        """The 3MF writes "0.40" and the printer reports "0.4"."""
+        plan = extract_rack_plan_from_3mf(benchy, plate_id=1)
+        wire, error = resolve_rack_plan_mapping(
+            plan.slot_groups, plan.group_dicts(), {}, _rack(diameters={1: "0.400", 2: "0.4"})
+        )
+
+        assert error is None
+        assert wire is not None
+
+
+class TestTheMountedNozzle:
+    """#943: a mounted rack nozzle is omitted from telemetry, not blanked."""
+
+    def test_the_lone_gap_is_recovered_from_the_carriage(self):
+        """Measured 09:02: IDs [16, 1, 21, 19, 18, 0, 20] -- id 17 the only gap."""
+        slots = _rack(present=(1, 3, 4, 5, 6), carriage={"diameter": "0.4", "type": "HH01", "filament_color": ""})
+        by_position = _rack_by_position(slots)
+
+        assert set(by_position) == {1, 2, 3, 4, 5, 6}
+        assert by_position[2]["id"] == 0  # the carriage's nozzle, standing in for R2
+
+    def test_the_mounted_nozzle_can_be_picked(self, benchy):
+        """It is the likeliest pick of all -- the last print left it there."""
+        plan = extract_rack_plan_from_3mf(benchy, plate_id=1)
+        slots = _rack(present=(1, 3, 4, 5, 6), carriage={"diameter": "0.4", "type": "HH01", "filament_color": ""})
+        wire, error = resolve_rack_plan_mapping(plan.slot_groups, plan.group_dicts(), {2: 1, 1: 2}, slots)
+
+        assert error is None
+        assert wire[:3] == [16, 1, 17]
+
+    def test_two_gaps_are_ambiguous_and_stay_absent(self):
+        """Four nozzles in six positions looks identical to two mounted ones,
+        and only one can be mounted at a time. Guessing would offer a position
+        that holds nothing.
+        """
+        slots = _rack(present=(1, 4, 5, 6), carriage={"diameter": "0.4", "type": "HH01"})
+        by_position = _rack_by_position(slots)
+
+        assert set(by_position) == {1, 4, 5, 6}
+
+    def test_an_empty_carriage_fills_no_gap(self):
+        slots = _rack(present=(1, 3, 4, 5, 6), carriage={"diameter": "", "type": ""})
+        assert set(_rack_by_position(slots)) == {1, 3, 4, 5, 6}
+
+
+class TestRefusals:
+    """Every one of these stops a print rather than guessing a hotend.
+
+    A wrong physical id levels with one nozzle and prints with another, several
+    millimetres off the bed -- the failure this whole area exists to prevent.
+    """
+
+    def test_a_position_holding_nothing_is_refused_with_a_reason(self, benchy):
+        plan = extract_rack_plan_from_3mf(benchy, plate_id=1)
+        _, error = resolve_rack_plan_mapping(plan.slot_groups, plan.group_dicts(), {2: 1, 1: 3}, _rack(present=(1, 2)))
+
+        assert error == "the printer reports nothing at rack position 3"
+
+    def test_the_wrong_nozzle_names_what_it_holds_and_what_is_needed(self, benchy):
+        plan = extract_rack_plan_from_3mf(benchy, plate_id=1)
+        _, error = resolve_rack_plan_mapping(
+            plan.slot_groups, plan.group_dicts(), {2: 1, 1: 5}, _rack(diameters={5: "0.6"})
+        )
+
+        assert "rack position 5 holds a 0.6" in error
+        assert "needs 0.40 High Flow" in error
+
+    def test_two_groups_cannot_share_one_position(self, benchy):
+        """They are different hotends by definition -- that is what a group is."""
+        plan = extract_rack_plan_from_3mf(benchy, plate_id=1)
+        _, error = resolve_rack_plan_mapping(plan.slot_groups, plan.group_dicts(), {2: 1, 1: 1}, _rack())
+
+        assert error == "rack position 1 is picked for more than one filament group"
+
+    def test_a_position_beyond_the_rack_is_refused(self, benchy):
+        plan = extract_rack_plan_from_3mf(benchy, plate_id=1)
+        _, error = resolve_rack_plan_mapping(plan.slot_groups, plan.group_dicts(), {2: 1, 1: 9}, _rack())
+
+        assert error == "rack position 9 does not exist"
+
+    def test_a_plate_with_more_slots_than_the_wire_carries_is_refused(self):
+        groups = {0: {"on_rack": False, "nozzle_diameter": "0.4", "volume_type": "High Flow"}}
+        _, error = resolve_rack_plan_mapping([0] * 33, groups, {}, _rack())
+
+        assert "33 filament slots" in error
+
+    def test_an_empty_plate_is_refused(self):
+        _, error = resolve_rack_plan_mapping([], {}, {}, _rack())
+        assert error == "the plate lists no filament slots"
+
+    def test_a_slot_naming_an_undescribed_group_is_refused(self):
+        groups = {0: {"on_rack": False, "nozzle_diameter": "0.4", "volume_type": "High Flow"}}
+        _, error = resolve_rack_plan_mapping([0, 7], groups, {}, _rack())
+
+        assert error == "filament slot 2 names group 7, which the plate does not describe"
+
+    def test_a_plate_assigning_nothing_is_refused(self):
+        """All -1 would tell the printer to print with no nozzle at all."""
+        _, error = resolve_rack_plan_mapping([-1, -1], {}, {}, _rack())
+        assert error == "the plate assigns no filament to a nozzle"
+
+
+class TestFixedOnlyPlates:
+    def test_a_plate_using_only_the_fixed_hotend_needs_no_rack_position(self, tmp_path):
+        source = _write_rack_3mf(tmp_path / "fixed.3mf", {1: {"group": 0, "color": "#FFFFFF"}}, {0: 1})
+        plan = extract_rack_plan_from_3mf(source, plate_id=1)
+        wire, error = resolve_rack_plan_mapping(plan.slot_groups, plan.group_dicts(), {}, _rack())
+
+        assert error is None
+        assert wire[0] == 1
+        assert wire[1:] == [-1] * 31
+
+    def test_it_resolves_even_with_an_entirely_empty_rack(self, tmp_path):
+        """Nothing is being asked of the rack, so its contents cannot matter."""
+        source = _write_rack_3mf(tmp_path / "fixed.3mf", {1: {"group": 0, "color": "#FFFFFF"}}, {0: 1})
+        plan = extract_rack_plan_from_3mf(source, plate_id=1)
+        wire, error = resolve_rack_plan_mapping(plan.slot_groups, plan.group_dicts(), {}, _rack(present=()))
+
+        assert error is None
+        assert wire[0] == 1
+
+
+class TestPlateScoping:
+    def test_the_dispatched_plate_is_the_one_read(self, tmp_path):
+        """A multi-plate file may assign the same slot differently per plate."""
+        path = tmp_path / "multi.3mf"
+        with zipfile.ZipFile(path, "w") as zf:
+            zf.writestr("Metadata/project_settings.config", _RACK_SETTINGS)
+            zf.writestr(
+                "Metadata/slice_info.config",
+                "<config>"
+                '<plate><metadata key="index" value="1"/>'
+                '<filament id="1" group_id="0" nozzle_diameter="0.40" volume_type="High Flow"/>'
+                '<nozzle id="0" extruder_id="1"/></plate>'
+                '<plate><metadata key="index" value="2"/>'
+                '<filament id="1" group_id="1" nozzle_diameter="0.40" volume_type="High Flow"/>'
+                '<nozzle id="1" extruder_id="2"/></plate>'
+                "</config>",
+            )
+
+        assert extract_rack_plan_from_3mf(path, plate_id=1).rack_group_ids == []
+        assert extract_rack_plan_from_3mf(path, plate_id=2).rack_group_ids == [1]
+
+
+class TestEveryRequirementsPathIsAnnotated:
+    """The three filament-requirements paths must all carry the group data.
+
+    They each build their filament list differently — the archive route and the
+    library route parse `slice_info.config` themselves rather than calling
+    `extract_filament_requirements` — so annotating only one of them shipped a
+    picker that never appeared in the print dialog. `annotate_rack_groups` is
+    the single implementation; these pin that all three reach it.
+    """
+
+    def test_the_shared_annotator_tags_a_route_built_filament_list(self, benchy):
+        from backend.app.services.filament_requirements import annotate_rack_groups
+
+        # The shape the archive/library routes build, with no group keys.
+        filaments = [{"slot_id": 1}, {"slot_id": 2}, {"slot_id": 3}]
+        annotate_rack_groups(filaments, benchy, 1)
+
+        assert [f["group_id"] for f in filaments] == [2, 0, 1]
+        assert filaments[0]["group"]["on_rack"] is True
+        assert filaments[1]["group"]["on_rack"] is False
+
+    def test_a_slot_the_plate_does_not_print_is_left_untagged(self, benchy):
+        from backend.app.services.filament_requirements import annotate_rack_groups
+
+        filaments = [{"slot_id": 9}]
+        annotate_rack_groups(filaments, benchy, 1)
+
+        assert "group_id" not in filaments[0]
+
+    def test_a_non_rack_file_leaves_every_filament_untouched(self, tmp_path):
+        from backend.app.services.filament_requirements import annotate_rack_groups
+
+        source = _write_rack_3mf(tmp_path / "h2d.3mf", BENCHY_FILAMENTS, BENCHY_NOZZLES, max_nozzles=("1", "1"))
+        filaments = [{"slot_id": 1}]
+        annotate_rack_groups(filaments, source, 1)
+
+        assert filaments == [{"slot_id": 1}]
+
+    @pytest.mark.parametrize("module", ["archives", "library"])
+    def test_both_routes_import_the_annotator(self, module):
+        """A guard against the two hand-rolled routes drifting out again."""
+        import importlib
+
+        route = importlib.import_module(f"backend.app.api.routes.{module}")
+        assert hasattr(route, "annotate_rack_groups")

+ 230 - 0
frontend/src/__tests__/components/FilamentMappingRackPicker.test.tsx

@@ -0,0 +1,230 @@
+/**
+ * The per-group rack position picker in the print dialog (#1784).
+ *
+ * On an H2C the rack carriage hosts six hotends and the 3MF does not say which
+ * one a filament group should use — proven by dispatching one plate twice from
+ * BambuStudio with different picks and finding the two files identical bar
+ * float noise. So the dialog has to ask, and the answer travels to the printer
+ * as `nozzle_mapping`.
+ *
+ * The picker is per *group*, not per slot: two slots sharing a group share one
+ * hotend and cannot point at different positions.
+ */
+
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import { screen, waitFor, cleanup, fireEvent } from '@testing-library/react';
+import { http, HttpResponse } from 'msw';
+import { render } from '../utils';
+import { server } from '../mocks/server';
+import { FilamentMapping } from '../../components/PrintModal/FilamentMapping';
+import type { PrinterStatus } from '../../api/client';
+
+// The maintainer's own plate: three PLA filaments in groups 2/0/1, with groups
+// 1 and 2 on the rack and group 0 on the fixed hotend.
+const group = (over = {}) => ({
+  on_rack: true,
+  nozzle_diameter: '0.40',
+  volume_type: 'High Flow',
+  filament_color: '',
+  ...over,
+});
+
+const benchyReqs = {
+  filaments: [
+    { slot_id: 1, type: 'PLA', color: '#DE4343', used_grams: 7, used_meters: 2.1,
+      group_id: 2, group: group({ filament_color: '#DE4343' }) },
+    { slot_id: 2, type: 'PLA', color: '#F4EE2A', used_grams: 7, used_meters: 2.2,
+      group_id: 0, group: group({ on_rack: false, filament_color: '#F4EE2A' }) },
+    { slot_id: 3, type: 'PLA', color: '#0078BF', used_grams: 10, used_meters: 3.3,
+      group_id: 1, group: group({ filament_color: '#0078BF' }) },
+  ],
+};
+
+const rackSlot = (id: number, over = {}) => ({
+  id,
+  nozzle_type: 'HH01',
+  nozzle_diameter: '0.4',
+  wear: null,
+  stat: null,
+  max_temp: 300,
+  serial_number: '',
+  filament_color: '',
+  filament_id: '',
+  filament_type: '',
+  ...over,
+});
+
+function createStatus(nozzleRack: unknown[]): PrinterStatus {
+  return {
+    id: 1,
+    name: 'H2C-1',
+    connected: true,
+    state: 'IDLE',
+    ams: [
+      {
+        id: 0,
+        tray: [
+          { id: 0, tray_type: 'PLA', tray_color: 'DE4343', tray_info_idx: 'GFA01' },
+          { id: 1, tray_type: 'PLA', tray_color: 'F4EE2A', tray_info_idx: 'GFA00' },
+          { id: 2, tray_type: 'PLA', tray_color: '0078BF', tray_info_idx: 'GFA01' },
+        ],
+      },
+    ],
+    vt_tray: [],
+    ams_extruder_map: {},
+    nozzle_rack: nozzleRack,
+    ...{},
+  } as unknown as PrinterStatus;
+}
+
+/** Full rack, plus the fixed carriage the printer always reports. */
+const fullRack = [1, 2, 3, 4, 5, 6].map((p) => rackSlot(15 + p)).concat(rackSlot(1));
+
+function mount(props: Record<string, unknown> = {}, rack = fullRack) {
+  server.use(
+    http.get('/api/v1/printers/:id/status', () => HttpResponse.json(createStatus(rack))),
+    http.get('/api/v1/printers/:id/spool-assignments', () => HttpResponse.json([])),
+  );
+  return render(
+    <FilamentMapping
+      printerId={1}
+      filamentReqs={benchyReqs}
+      manualMappings={{}}
+      onManualMappingChange={() => {}}
+      currencySymbol="$"
+      defaultCostPerKg={20}
+      defaultExpanded
+      {...props}
+    />,
+  );
+}
+
+/** The rack pickers, in slot order. */
+async function pickers() {
+  return await waitFor(async () => {
+    const found = await screen.findAllByLabelText('Rack position');
+    expect(found.length).toBeGreaterThan(0);
+    return found as HTMLSelectElement[];
+  });
+}
+
+afterEach(() => {
+  cleanup();
+  vi.clearAllMocks();
+});
+
+describe('FilamentMapping — nozzle rack position picker', () => {
+  beforeEach(() => {
+    server.use(http.get('/api/v1/printers/:id/spool-assignments', () => HttpResponse.json([])));
+  });
+
+  it('offers a picker for each rack-bound group and none for the fixed one', async () => {
+    mount();
+    const selects = await pickers();
+
+    // Groups 2 and 1 are on the rack; group 0 is the fixed hotend and gets the
+    // plain L badge instead.
+    expect(selects).toHaveLength(2);
+    expect(screen.getByText('L')).toBeInTheDocument();
+  });
+
+  it('offers all six positions so a missing one is greyed out, not absent', async () => {
+    mount({}, [rackSlot(16), rackSlot(17), rackSlot(1)]);
+    const [first] = await pickers();
+
+    expect(first.querySelectorAll('option')).toHaveLength(6);
+    const disabled = [...first.querySelectorAll('option')].filter((o) => (o as HTMLOptionElement).disabled);
+    expect(disabled.map((o) => (o as HTMLOptionElement).value)).toEqual(['3', '4', '5', '6']);
+  });
+
+  it('disables a position holding the wrong nozzle', async () => {
+    mount({}, [rackSlot(16), rackSlot(17, { nozzle_diameter: '0.6' }), rackSlot(1)]);
+    const [first] = await pickers();
+
+    const option = [...first.querySelectorAll('option')].find(
+      (o) => (o as HTMLOptionElement).value === '2',
+    ) as HTMLOptionElement;
+    expect(option.disabled).toBe(true);
+    expect(option.title).toBe('Holds a 0.6 HH01 nozzle; this filament needs 0.40 High Flow');
+  });
+
+  it('pre-selects the position already loaded with the group colour', async () => {
+    // Reproduces BambuStudio's own pick for this plate — dispatched [16, 1, 17]
+    // on 2026-08-14: red group to R1, blue group to R2.
+    const coloured = [
+      rackSlot(16, { filament_color: 'DE4343FF' }),
+      rackSlot(17, { filament_color: '0078BFFF' }),
+      ...[3, 4, 5, 6].map((p) => rackSlot(15 + p)),
+      rackSlot(1),
+    ];
+    mount({}, coloured);
+    const [red, blue] = await pickers();
+
+    expect(red.value).toBe('1');
+    expect(blue.value).toBe('2');
+  });
+
+  it('reports every group when one is changed, not just the edited one', async () => {
+    // Sending only the edited group would let the dispatcher re-assign the
+    // others around it and silently move a hotend the operator had accepted.
+    const onChange = vi.fn();
+    mount({ onNozzleRackChoiceChange: onChange });
+    const [red] = await pickers();
+
+    fireEvent.change(red, { target: { value: '4' } });
+
+    expect(onChange).toHaveBeenCalledTimes(1);
+    expect(onChange.mock.calls[0][0]).toEqual({ 1: 1, 2: 4 });
+  });
+
+  it('shows a saved pick rather than re-deriving one', async () => {
+    mount({ nozzleRackChoice: { 2: 5, 1: 6 }, onNozzleRackChoiceChange: vi.fn() });
+    const [red, blue] = await pickers();
+
+    expect(red.value).toBe('5');
+    expect(blue.value).toBe('6');
+  });
+
+  it('offers the mounted nozzle, which telemetry omits entirely', async () => {
+    // #943: the firmware drops a rack id while that nozzle is on the carriage.
+    // It is the likeliest pick of all — the last print left it there.
+    const mounted = [
+      ...[1, 3, 4, 5, 6].map((p) => rackSlot(15 + p)),
+      rackSlot(1),
+      rackSlot(0), // the rack carriage, holding position 2's nozzle
+    ];
+    mount({}, mounted);
+    const [first] = await pickers();
+
+    const option = [...first.querySelectorAll('option')].find(
+      (o) => (o as HTMLOptionElement).value === '2',
+    ) as HTMLOptionElement;
+    expect(option.disabled).toBe(false);
+  });
+
+  it('renders no picker on a printer with no rack', async () => {
+    // Two carriages and nothing at 16..21 -- an H2D, or an H2C mid-report.
+    // The panel still maps filaments; it just offers no rack position, and
+    // no L/R badge either, since this plate carries no nozzle_id.
+    mount({}, [rackSlot(0), rackSlot(1)]);
+
+    await waitFor(() => expect(screen.getAllByText(/PLA/).length).toBeGreaterThan(0));
+    expect(screen.queryAllByLabelText('Rack position')).toHaveLength(0);
+    expect(screen.queryByText('R')).not.toBeInTheDocument();
+  });
+
+  it('leaves a dual-nozzle plate on its L/R badges', async () => {
+    // No group data at all — an H2D file. The pre-existing badge must survive.
+    const h2dReqs = {
+      filaments: [
+        { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 5, used_meters: 2, nozzle_id: 1 },
+        { slot_id: 2, type: 'PLA', color: '#00FF00', used_grams: 5, used_meters: 2, nozzle_id: 0 },
+      ],
+    };
+    mount({ filamentReqs: h2dReqs });
+
+    await screen.findByText('L');
+    expect(screen.getByText('R')).toBeInTheDocument();
+    expect(screen.queryAllByLabelText('Rack position')).toHaveLength(0);
+  });
+});

+ 167 - 0
frontend/src/__tests__/utils/nozzleRack.test.ts

@@ -0,0 +1,167 @@
+import { describe, it, expect } from 'vitest';
+import type { NozzleRackSlot } from '../../api/client';
+import type { RackGroupInfo } from '../../components/PrintModal/types';
+import {
+  RACK_POSITIONS,
+  autoAssignRackPositions,
+  isRackSlotEligible,
+  rackByPosition,
+  rackOptionsForGroup,
+  rackPositionToNozzleId,
+} from '../../utils/nozzleRack';
+
+/**
+ * Mirrors backend/tests/unit/test_nozzle_rack_positions_1784.py. The two
+ * implementations have to agree or the picker greys out an option the
+ * dispatcher would have accepted, or worse offers one it will refuse.
+ */
+
+const slot = (id: number, over: Partial<NozzleRackSlot> = {}): NozzleRackSlot => ({
+  id,
+  nozzle_type: 'HH01',
+  nozzle_diameter: '0.4',
+  wear: null,
+  stat: null,
+  max_temp: 300,
+  serial_number: '',
+  filament_color: '',
+  filament_id: '',
+  filament_type: '',
+  ...over,
+});
+
+/** Live rack with a nozzle at each named 1-based position, plus the carriages. */
+const rack = (present: number[] = [1, 2, 3, 4, 5, 6], over: Record<number, Partial<NozzleRackSlot>> = {}) => [
+  slot(1),
+  ...present.map((p) => slot(15 + p, over[p] ?? {})),
+];
+
+const group = (over: Partial<RackGroupInfo> = {}): RackGroupInfo => ({
+  on_rack: true,
+  nozzle_diameter: '0.40',
+  volume_type: 'High Flow',
+  filament_color: '',
+  ...over,
+});
+
+const t = (key: string) => key;
+
+describe('rack position numbering', () => {
+  it('maps position n to physical nozzle id 15 + n', () => {
+    expect(RACK_POSITIONS.map(rackPositionToNozzleId)).toEqual([16, 17, 18, 19, 20, 21]);
+  });
+
+  it.each([0, -1, 7, 1.5])('rejects %s as a position', (position) => {
+    expect(rackPositionToNozzleId(position)).toBeNull();
+  });
+});
+
+describe('reading the live rack', () => {
+  it('recovers the mounted nozzle from the lone gap', () => {
+    // Measured 2026-08-14 09:02: IDs [16, 1, 21, 19, 18, 0, 20] — id 17 absent
+    // because that nozzle was picked up onto the carriage (#943).
+    const slots = [...rack([1, 3, 4, 5, 6]), slot(0)];
+    const byPosition = rackByPosition(slots);
+
+    expect([...byPosition.keys()].sort((a, b) => a - b)).toEqual([1, 2, 3, 4, 5, 6]);
+    expect(byPosition.get(2)!.id).toBe(0);
+  });
+
+  it('leaves two gaps absent, because which one is mounted is unknowable', () => {
+    const byPosition = rackByPosition([...rack([1, 4, 5, 6]), slot(0)]);
+    expect([...byPosition.keys()].sort((a, b) => a - b)).toEqual([1, 4, 5, 6]);
+  });
+
+  it('fills no gap from an empty carriage', () => {
+    const empty = slot(0, { nozzle_diameter: '', nozzle_type: '' });
+    expect(rackByPosition([...rack([1, 3, 4, 5, 6]), empty]).has(2)).toBe(false);
+  });
+
+  it('ignores the fixed carriage entirely', () => {
+    expect(rackByPosition([slot(1)]).size).toBe(0);
+  });
+});
+
+describe('eligibility', () => {
+  it('matches a padded slice diameter against an unpadded printer one', () => {
+    expect(isRackSlotEligible(slot(16, { nozzle_diameter: '0.4' }), group())).toBe(true);
+  });
+
+  it('rejects the wrong diameter', () => {
+    expect(isRackSlotEligible(slot(16, { nozzle_diameter: '0.6' }), group())).toBe(false);
+  });
+
+  it('rejects the wrong flow type', () => {
+    expect(isRackSlotEligible(slot(16, { nozzle_type: 'HS' }), group())).toBe(false);
+  });
+
+  it('does not rule out a printer that reports no flow type', () => {
+    expect(isRackSlotEligible(slot(16, { nozzle_type: '' }), group())).toBe(true);
+  });
+
+  it('rejects an empty position', () => {
+    expect(isRackSlotEligible(slot(16, { nozzle_diameter: '', nozzle_type: '' }), group())).toBe(false);
+  });
+
+  it('rejects a position that is not there at all', () => {
+    expect(isRackSlotEligible(undefined, group())).toBe(false);
+  });
+});
+
+describe('the options offered for a group', () => {
+  it('always offers all six, so a position is greyed out rather than missing', () => {
+    const options = rackOptionsForGroup(rack([1, 2]), group(), t);
+
+    expect(options).toHaveLength(6);
+    expect(options.filter((o) => o.eligible).map((o) => o.position)).toEqual([1, 2]);
+  });
+
+  it('says why an empty position cannot be used', () => {
+    const options = rackOptionsForGroup(rack([1]), group(), t);
+    expect(options[1].reason).toBe('printModal.rackEmptyPosition');
+  });
+
+  it('says why a wrong nozzle cannot be used', () => {
+    const options = rackOptionsForGroup(rack([1, 2], { 2: { nozzle_diameter: '0.6' } }), group(), t);
+    expect(options[1].reason).toBe('printModal.rackWrongNozzle');
+  });
+});
+
+describe('auto-assignment', () => {
+  const groups = new Map<number, RackGroupInfo>([
+    [0, group({ on_rack: false, filament_color: '#F4EE2A' })],
+    [1, group({ filament_color: '#0078BF' })],
+    [2, group({ filament_color: '#DE4343' })],
+  ]);
+
+  it('prefers the position already loaded with the group colour', () => {
+    // Reproduces BambuStudio's own pick for this plate: group 2 (red) to R1,
+    // group 1 (blue) to R2 — dispatched as [16, 1, 17] on 2026-08-14.
+    const loaded = rack([1, 2, 3, 4, 5, 6], {
+      1: { filament_color: 'DE4343FF' },
+      2: { filament_color: '0078BFFF' },
+    });
+    expect(autoAssignRackPositions(loaded, groups)).toEqual({ 1: 2, 2: 1 });
+  });
+
+  it('falls back to the lowest free position when no colour matches', () => {
+    expect(autoAssignRackPositions(rack(), groups)).toEqual({ 1: 1, 2: 2 });
+  });
+
+  it('never reassigns a position the operator pinned', () => {
+    expect(autoAssignRackPositions(rack(), groups, { 1: 4 })).toEqual({ 1: 4, 2: 1 });
+  });
+
+  it('gives up rather than half-assigning when a group cannot be placed', () => {
+    expect(autoAssignRackPositions(rack([1]), groups)).toBeNull();
+  });
+
+  it('gives up when a pinned position is no longer eligible', () => {
+    expect(autoAssignRackPositions(rack([1, 2]), groups, { 1: 5 })).toBeNull();
+  });
+
+  it('assigns nothing, successfully, when no group needs the rack', () => {
+    const fixedOnly = new Map<number, RackGroupInfo>([[0, group({ on_rack: false })]]);
+    expect(autoAssignRackPositions(rack([]), fixedOnly)).toEqual({});
+  });
+});

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

@@ -2429,6 +2429,10 @@ export interface PrintQueueItem {
   // Auto-print G-code injection
   gcode_injection?: boolean;
   cleanup_library_after_dispatch?: boolean;
+  /** Which rack position each filament group prints from, on a nozzle-rack
+   *  machine (#1784): `{ [group_id]: 1-based position }`. Re-checked against
+   *  the live rack at dispatch; omit to have the dispatcher assign them. */
+  nozzle_rack_choice?: Record<number, number> | null;
 }
 
 export interface PrintBatchPlateTarget {
@@ -2534,6 +2538,10 @@ export interface PrintQueueItemCreate {
   // defeats the point) and with archive_id/library_file_id (these ARE the files).
   // Order is priority — index 0 wins when several printers are idle at once.
   variants?: QueueVariantCreate[];
+  /** Which rack position each filament group prints from, on a nozzle-rack
+   *  machine (#1784): `{ [group_id]: 1-based position }`. Re-checked against
+   *  the live rack at dispatch; omit to have the dispatcher assign them. */
+  nozzle_rack_choice?: Record<number, number> | null;
 }
 
 /** One candidate file for a cross-model queue item (#671). */
@@ -2605,6 +2613,10 @@ export interface PrintQueueItemUpdate {
   gcode_injection?: boolean;
   cost_center_id?: number | null;
   estimated_cost?: number | null;
+  /** Which rack position each filament group prints from, on a nozzle-rack
+   *  machine (#1784): `{ [group_id]: 1-based position }`. Re-checked against
+   *  the live rack at dispatch; omit to have the dispatcher assign them. */
+  nozzle_rack_choice?: Record<number, number> | null;
 }
 
 export interface PrintQueueBulkUpdate {

+ 82 - 4
frontend/src/components/PrintModal/FilamentMapping.tsx

@@ -7,7 +7,8 @@ import { useFilamentMapping } from '../../hooks/useFilamentMapping';
 import { getGlobalTrayId, effectivePreferLowest } from '../../utils/amsHelpers';
 import { getColorName } from '../../utils/colors';
 import { useFilamentLabels } from './useFilamentLabels';
-import type { FilamentMappingProps } from './types';
+import { autoAssignRackPositions, rackOptionsForGroup } from '../../utils/nozzleRack';
+import type { FilamentMappingProps, RackGroupInfo } from './types';
 
 /**
  * Filament mapping UI for comparing required filaments with loaded AMS slots.
@@ -28,6 +29,8 @@ export function FilamentMapping({
   onForceColorMatchChange,
   plateLabel,
   archiveAmsMapping,
+  nozzleRackChoice,
+  onNozzleRackChoiceChange,
 }: FilamentMappingProps & { defaultExpanded?: boolean }) {
   const { t } = useTranslation();
   const queryClient = useQueryClient();
@@ -194,6 +197,41 @@ export function FilamentMapping({
   const hasFilamentReqs = filamentReqs?.filaments && filamentReqs.filaments.length > 0;
   const isDualNozzle = filamentReqs?.filaments?.some((f) => f.nozzle_id != null) ?? false;
 
+  // Nozzle rack (#1784). The 3MF names a filament *group* per slot and says
+  // which groups need a hotend off the rack; which of the six positions each
+  // takes is the operator's to choose and is stated nowhere in the file. A
+  // group, not a slot, is the unit of choice — two slots sharing a group share
+  // one hotend and cannot point at different positions.
+  const rackGroups = useMemo(() => {
+    const groups = new Map<number, RackGroupInfo>();
+    for (const f of filamentReqs?.filaments ?? []) {
+      if (f.group_id != null && f.group) groups.set(f.group_id, f.group);
+    }
+    return groups;
+  }, [filamentReqs]);
+  const hasRack = (printerStatus?.nozzle_rack?.some((n) => n.id >= 16) ?? false)
+    && [...rackGroups.values()].some((g) => g.on_rack);
+
+  // What the dispatcher would assign if nothing were picked, shown as the
+  // pre-selection so the dialog states what will happen rather than leaving
+  // every picker blank. Explicit picks are pinned and the rest fill in around
+  // them, exactly as the backend does it.
+  const effectiveRackChoice = useMemo(() => {
+    if (!hasRack) return {};
+    return (
+      autoAssignRackPositions(printerStatus?.nozzle_rack, rackGroups, nozzleRackChoice ?? {})
+      ?? (nozzleRackChoice ?? {})
+    );
+  }, [hasRack, printerStatus?.nozzle_rack, rackGroups, nozzleRackChoice]);
+
+  const pickRackPosition = (groupId: number, position: number) => {
+    if (!onNozzleRackChoiceChange) return;
+    // Every group is written back, not just the edited one: leaving the others
+    // implicit would let the dispatcher re-assign them around the new pick and
+    // silently move a hotend the operator had already seen and accepted.
+    onNozzleRackChoiceChange({ ...effectiveRackChoice, [groupId]: position });
+  };
+
   // Filament Track Switch: when installed, AMS-to-extruder mapping is dynamic
   // (any slot can be routed to either extruder), so the per-nozzle dropdown
   // filter is suppressed. fila_switch.in_slots[track] = currently fed slot,
@@ -322,7 +360,17 @@ export function FilamentMapping({
             <div key={idx} className="space-y-1">
               <div
                 className="grid items-center gap-2 text-xs"
-                style={{ gridTemplateColumns: '16px minmax(70px, 1fr) auto 2fr 16px' }}
+                style={{
+                  // The rack picker sits inside the required-filament cell, so
+                  // on a rack machine that cell has to carry the name *and* an
+                  // ~85px dropdown. Raising only the floor (not the fraction)
+                  // keeps every other printer's layout exactly as it was, and
+                  // keeps the AMS dropdown — which names type, colour and
+                  // remaining weight — the widest column.
+                  gridTemplateColumns: hasRack
+                    ? '16px minmax(210px, 1.4fr) auto 2fr 16px'
+                    : '16px minmax(70px, 1fr) auto 2fr 16px',
+                }}
               >
                 {/* Required color */}
                 <span title={`Required: ${resolvedName} - ${colorLabel}`}>
@@ -332,14 +380,44 @@ export function FilamentMapping({
                     truncates; the gram usage is pinned (shrink-0) so it never
                     clips on narrow/mobile widths (#2669). */}
                 <span className="text-white flex items-center gap-1 min-w-0">
-                  {isDualNozzle && item.nozzle_id != null && (
+                  {hasRack && item.group_id != null && item.group ? (
+                    item.group.on_rack ? (
+                      <select
+                        value={effectiveRackChoice[item.group_id] ?? ''}
+                        onChange={(e) => pickRackPosition(item.group_id!, Number(e.target.value))}
+                        disabled={!onNozzleRackChoiceChange}
+                        title={t('printModal.rackPositionTooltip')}
+                        aria-label={t('printModal.rackPosition')}
+                        className="shrink-0 bg-bambu-dark-tertiary text-white text-[10px] font-bold rounded px-1 py-0.5 border border-bambu-dark-tertiary focus:border-bambu-green outline-none disabled:opacity-60"
+                      >
+                        {rackOptionsForGroup(printerStatus?.nozzle_rack, item.group, t).map((option) => (
+                          <option
+                            key={option.position}
+                            value={option.position}
+                            disabled={!option.eligible}
+                            title={option.reason}
+                          >
+                            R{option.position}
+                            {option.diameter ? ` · ${option.diameter}` : ''}
+                          </option>
+                        ))}
+                      </select>
+                    ) : (
+                      <span
+                        className="inline-flex items-center justify-center w-3.5 h-3.5 rounded text-[9px] font-bold leading-none bg-bambu-gray/20 text-bambu-gray shrink-0"
+                        title={t('printModal.leftNozzleTooltip')}
+                      >
+                        {t('printModal.leftNozzle')}
+                      </span>
+                    )
+                  ) : isDualNozzle && item.nozzle_id != null ? (
                     <span
                       className="inline-flex items-center justify-center w-3.5 h-3.5 rounded text-[9px] font-bold leading-none bg-bambu-gray/20 text-bambu-gray shrink-0"
                       title={item.nozzle_id === 1 ? t('printModal.leftNozzleTooltip') : t('printModal.rightNozzleTooltip')}
                     >
                       {item.nozzle_id === 1 ? t('printModal.leftNozzle') : t('printModal.rightNozzle')}
                     </span>
-                  )}
+                  ) : null}
                   <span className="truncate min-w-0" title={resolvedName}>{resolvedName}</span>
                   <span className="text-bambu-gray shrink-0 whitespace-nowrap">({item.used_grams}g)</span>
                 </span>

+ 48 - 1
frontend/src/components/PrintModal/index.tsx

@@ -603,6 +603,23 @@ export function PrintModal({
   // Manual slot overrides are per plate: slot 3 of plate 1 and slot 3 of plate 2
   // are different prints and may want different trays.
   const [manualMappingsByPlate, setManualMappingsByPlate] = useState<Record<number, Record<number, number>>>({});
+  // Rack position per filament group (#1784), and one set per plate for the
+  // per-plate panels — each plate has its own groups.
+  const [nozzleRackChoice, setNozzleRackChoice] = useState<Record<number, number>>(() => {
+    // Re-opening an item shows the positions it was queued with, so editing
+    // one filament does not silently drop the rest.
+    if (mode === 'edit-queue-item' && queueItem?.nozzle_rack_choice) {
+      const seeded: Record<number, number> = {};
+      for (const [groupId, position] of Object.entries(queueItem.nozzle_rack_choice)) {
+        const group = Number(groupId);
+        if (Number.isInteger(group) && Number.isInteger(position)) seeded[group] = position;
+      }
+      return seeded;
+    }
+    return {};
+  });
+  const [nozzleRackChoiceByPlate, setNozzleRackChoiceByPlate] =
+    useState<Record<number, Record<number, number>>>({});
 
   // Only ever computed for a single target printer: a tray id means nothing on a
   // different printer, so a fan-out across printers must not reuse these.
@@ -1132,6 +1149,15 @@ export function PrintModal({
     };
 
     // Common queue data for create and edit modes
+    // One panel per plate when several are selected, one shared panel
+    // otherwise -- the same split the AMS mappings use above.
+    const rackChoiceForPlate = (plateId: number | null): Record<number, number> | undefined => {
+      const choice = plateId != null && isMultiPlateSelection
+        ? nozzleRackChoiceByPlate[plateId]
+        : nozzleRackChoice;
+      return choice && Object.keys(choice).length > 0 ? choice : undefined;
+    };
+
     const getQueueData = (printerId: number | null, plateOverride?: number | null): PrintQueueItemCreate => {
       const plateId = plateOverride !== undefined ? plateOverride : selectedPlate;
       const plateEstimatedCost =
@@ -1153,6 +1179,11 @@ export function PrintModal({
       // re-flag the item on its first dispatch tick (#1698-followup).
       skip_filament_check: options?.skipFilamentCheck === true ? true : undefined,
       ams_mapping: printerId ? getMappingForPrinter(printerId, plateId) : undefined,
+      // Rack positions per filament group (#1784). Only sent in printer mode:
+      // in model mode the target printer is not known yet, and the rack it
+      // will be dispatched to cannot be validated against here. The dispatcher
+      // assigns them itself in that case.
+      nozzle_rack_choice: printerId ? rackChoiceForPlate(plateId) : undefined,
       plate_id: plateId,
       scheduled_time: scheduleOptions.scheduleType === 'scheduled' && scheduleOptions.scheduledTime
         ? new Date(scheduleOptions.scheduledTime).toISOString()
@@ -1245,6 +1276,10 @@ export function PrintModal({
                 gcode_injection: scheduleOptions.gcodeInjection,
                 manual_start: scheduleOptions.scheduleType === 'queue' && scheduleOptions.requireManualStart,
                 ams_mapping: printerMapping,
+                // null, not undefined: an operator who cleared their picks
+                // means "assign these again", and undefined would leave the
+                // stale ones on the row (#1784).
+                nozzle_rack_choice: rackChoiceForPlate(plateId) ?? null,
                 plate_id: plateId,
                 scheduled_time: scheduleOptions.scheduleType === 'scheduled' && scheduleOptions.scheduledTime
                   ? new Date(scheduleOptions.scheduledTime).toISOString()
@@ -1491,8 +1526,14 @@ export function PrintModal({
       className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4"
       onClick={isSubmitting ? undefined : onClose}
     >
+      {/* 4xl rather than the 2xl this was: the filament rows carry the most
+          horizontal content in the dialog — a required name, a nozzle picker on
+          rack machines, and an AMS slot dropdown naming type, colour and
+          remaining weight — and anything narrower truncated the name to
+          "Bamb..." (#1784). 4xl is 896px, so it still fits a 1024-wide laptop
+          with the surrounding padding, and `w-full` keeps it fluid below that. */}
       <Card
-        className="w-full max-w-2xl max-h-[90vh] overflow-y-auto"
+        className="w-full max-w-4xl max-h-[90vh] overflow-y-auto"
         onClick={(e) => e.stopPropagation()}
       >
         <CardContent className="p-0">
@@ -1721,6 +1762,8 @@ export function PrintModal({
                   setForceColorMatch((prev) => ({ ...prev, [slotId]: value }))
                 }
                 archiveAmsMapping={archiveSlicerAmsMapping}
+                nozzleRackChoice={nozzleRackChoice}
+                onNozzleRackChoiceChange={setNozzleRackChoice}
               />
             )}
 
@@ -1753,6 +1796,10 @@ export function PrintModal({
                     setForceColorMatch((prev) => ({ ...prev, [slotId]: value }))
                   }
                   archiveAmsMapping={archiveSlicerAmsMapping}
+                  nozzleRackChoice={nozzleRackChoiceByPlate[plateId] ?? {}}
+                  onNozzleRackChoiceChange={(choice) =>
+                    setNozzleRackChoiceByPlate((prev) => ({ ...prev, [plateId]: choice }))
+                  }
                 />
               );
             })}

+ 36 - 0
frontend/src/components/PrintModal/types.ts

@@ -222,9 +222,38 @@ export interface FilamentReqsData {
      *  = user custom). Used to resolve the "original" filament label in
      *  FilamentOverride against the builtin + cloud user-preset maps. #1718. */
     tray_info_idx?: string;
+    /** Which filament group this slot prints in, on a nozzle-rack machine
+     *  (#1784). The group is the slicer's logical nozzle, so it — not the slot
+     *  — is what a rack position is chosen for. Absent on every other model. */
+    group_id?: number;
+    /** What that group needs of a hotend. Only groups with `on_rack` get a
+     *  position picker; the rest are on the fixed carriage and have no choice
+     *  to make. */
+    group?: RackGroupInfo;
   }>;
 }
 
+/** A filament group's hotend requirements, from the 3MF (#1784). */
+export interface RackGroupInfo {
+  on_rack: boolean;
+  nozzle_diameter: string;
+  volume_type: string;
+  filament_color: string;
+}
+
+/** One position on the H2C's six-slot nozzle rack, as offered to the user. */
+export interface RackPositionOption {
+  /** 1-based, the way the printer card, BambuStudio and the operator count. */
+  position: number;
+  diameter: string;
+  nozzleType: string;
+  filamentColor: string;
+  /** False when the position is empty or holds the wrong nozzle for the group. */
+  eligible: boolean;
+  /** Why not, when `eligible` is false — shown as the option's title. */
+  reason?: string;
+}
+
 /**
  * Props for the FilamentMapping component.
  */
@@ -257,6 +286,13 @@ export interface FilamentMappingProps {
    *  auto-match. Undefined/omitted when the archive has no saved mapping —
    *  the toggle is hidden and behaviour is unchanged. */
   archiveAmsMapping?: number[];
+  /** The operator's rack-position pick per filament group (#1784), keyed by
+   *  group id. Only meaningful on a nozzle-rack model; omit elsewhere and no
+   *  picker is rendered. A group absent from the object is assigned a position
+   *  by the dispatcher against the rack as it stands at dispatch. */
+  nozzleRackChoice?: Record<number, number>;
+  /** Called when a rack position is picked for a group. */
+  onNozzleRackChoiceChange?: (choice: Record<number, number>) => void;
 }
 
 /**

+ 8 - 0
frontend/src/hooks/useFilamentMapping.ts

@@ -1,5 +1,6 @@
 import { useMemo } from 'react';
 import { getColorName } from '../utils/colors';
+import type { RackGroupInfo } from '../components/PrintModal/types';
 import {
   normalizeColor,
   normalizeColorForCompare,
@@ -157,6 +158,13 @@ export interface FilamentRequirement {
   tray_info_idx?: string;
   /** Target nozzle for dual-nozzle printers (0=right, 1=left) */
   nozzle_id?: number;
+  /** Filament group this slot prints in, on a nozzle-rack machine (#1784).
+   *  The group is the slicer's logical nozzle, so it is what a rack position
+   *  gets chosen for. Absent on every other model. */
+  group_id?: number;
+  /** What that group needs of a hotend, for filtering the rack positions it
+   *  can be sent to. */
+  group?: RackGroupInfo;
 }
 
 /**

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

@@ -4925,6 +4925,10 @@ export default {
     rightNozzle: 'R',
     leftNozzleTooltip: 'Linke Düse',
     rightNozzleTooltip: 'Rechte Düse',
+    rackPosition: 'Wechslerposition',
+    rackPositionTooltip: 'Welche Düse im Wechsler dieses Filament druckt. Die Positionen sind wie am Drucker nummeriert.',
+    rackEmptyPosition: 'Diese Wechslerposition ist leer',
+    rackWrongNozzle: 'Enthält eine {{has}}-Düse; dieses Filament benötigt {{needs}}',
     filamentOverride: 'Filament-Überschreibung',
     filamentOverrideHint: 'Filamente für modellbasierte Zuweisung optional überschreiben. Der Planer wird gegen die ausgewählten Filamente statt der ursprünglichen 3MF-Werte abgleichen.',
     originalFilament: 'Original',

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

@@ -4968,6 +4968,10 @@ export default {
     rightNozzle: 'R',
     leftNozzleTooltip: 'Left nozzle',
     rightNozzleTooltip: 'Right nozzle',
+    rackPosition: 'Rack position',
+    rackPositionTooltip: 'Which nozzle on the rack prints this filament. Positions are numbered as on the printer.',
+    rackEmptyPosition: 'This rack position is empty',
+    rackWrongNozzle: 'Holds a {{has}} nozzle; this filament needs {{needs}}',
     filamentOverride: 'Filament Override',
     filamentOverrideHint: 'Optionally override filaments for model-based assignment. The scheduler will match against your selected filaments instead of the original 3MF values.',
     originalFilament: 'Original',

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

@@ -4932,6 +4932,10 @@ export default {
     rightNozzle: 'D',
     leftNozzleTooltip: 'Boquilla izquierda',
     rightNozzleTooltip: 'Boquilla derecha',
+    rackPosition: 'Posición del carro',
+    rackPositionTooltip: 'Qué boquilla del carro imprime este filamento. Las posiciones se numeran como en la impresora.',
+    rackEmptyPosition: 'Esta posición del carro está vacía',
+    rackWrongNozzle: 'Tiene una boquilla {{has}}; este filamento necesita {{needs}}',
     filamentOverride: 'Anulación de filamento',
     filamentOverrideHint: 'Anule opcionalmente los filamentos para la asignación basada en el modelo. El planificador comparará con los filamentos que ha seleccionado en lugar de los valores originales del 3MF.',
     originalFilament: 'Original',

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

@@ -4914,6 +4914,10 @@ export default {
     rightNozzle: 'D',
     leftNozzleTooltip: 'Buse gauche',
     rightNozzleTooltip: 'Buse droite',
+    rackPosition: 'Position du rack',
+    rackPositionTooltip: "Quelle buse du rack imprime ce filament. Les positions sont numérotées comme sur l'imprimante.",
+    rackEmptyPosition: 'Cette position du rack est vide',
+    rackWrongNozzle: 'Contient une buse {{has}} ; ce filament nécessite {{needs}}',
     filamentOverride: 'Remplacement de filament',
     filamentOverrideHint: 'Remplacez optionnellement les filaments pour l\'affectation par modèle. Le planificateur utilisera vos filaments sélectionnés au lieu des valeurs 3MF d\'origine.',
     originalFilament: 'Original',

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

@@ -4913,6 +4913,10 @@ export default {
     rightNozzle: 'R',
     leftNozzleTooltip: 'Ugello sinistro',
     rightNozzleTooltip: 'Ugello destro',
+    rackPosition: 'Posizione nel rack',
+    rackPositionTooltip: 'Quale ugello del rack stampa questo filamento. Le posizioni sono numerate come sulla stampante.',
+    rackEmptyPosition: 'Questa posizione del rack è vuota',
+    rackWrongNozzle: 'Contiene un ugello {{has}}; questo filamento richiede {{needs}}',
     filamentOverride: 'Sostituzione filamento',
     filamentOverrideHint: 'Sostituisci opzionalmente i filamenti per l\'assegnazione basata sul modello. Lo scheduler abbinerà i filamenti selezionati invece dei valori 3MF originali.',
     originalFilament: 'Originale',

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

@@ -4925,6 +4925,10 @@ export default {
     rightNozzle: 'R',
     leftNozzleTooltip: '左ノズル',
     rightNozzleTooltip: '右ノズル',
+    rackPosition: 'ラック位置',
+    rackPositionTooltip: 'このフィラメントを印刷するラック上のノズル。位置番号はプリンター本体と同じです。',
+    rackEmptyPosition: 'このラック位置は空です',
+    rackWrongNozzle: '{{has}} ノズルが装着されています。このフィラメントには {{needs}} が必要です',
     filamentOverride: 'フィラメントオーバーライド',
     filamentOverrideHint: 'モデルベースの割り当てに使用するフィラメントをオプションで上書きします。スケジューラは元の3MF値ではなく、選択したフィラメントに基づいてマッチングします。',
     originalFilament: 'オリジナル',

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

@@ -4695,6 +4695,10 @@ export default {
     rightNozzle: 'R',
     leftNozzleTooltip: '왼쪽 노즐',
     rightNozzleTooltip: '오른쪽 노즐',
+    rackPosition: '랙 위치',
+    rackPositionTooltip: '이 필라멘트를 출력할 랙의 노즐입니다. 위치 번호는 프린터와 동일합니다.',
+    rackEmptyPosition: '이 랙 위치는 비어 있습니다',
+    rackWrongNozzle: '{{has}} 노즐이 장착되어 있습니다. 이 필라멘트에는 {{needs}}이(가) 필요합니다',
     filamentOverride: '필라멘트 재정의',
     filamentOverrideHint: '선택적으로 모델 기반 할당을 위해 필라멘트를 재정의합니다. 스케줄러는 원래 3MF 값 대신 선택한 필라멘트와 일치시킵니다.',
     originalFilament: '원래',

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

@@ -4913,6 +4913,10 @@ export default {
     rightNozzle: 'R',
     leftNozzleTooltip: 'Bico esquerdo',
     rightNozzleTooltip: 'Bico direito',
+    rackPosition: 'Posição no rack',
+    rackPositionTooltip: 'Qual bico do rack imprime este filamento. As posições são numeradas como na impressora.',
+    rackEmptyPosition: 'Esta posição do rack está vazia',
+    rackWrongNozzle: 'Tem um bico {{has}}; este filamento precisa de {{needs}}',
     filamentOverride: 'Substituição de Filamento',
     filamentOverrideHint: 'Substitua opcionalmente os filamentos para atribuição baseada em modelo. O agendador usará os filamentos selecionados em vez dos valores originais do 3MF.',
     originalFilament: 'Original',

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

@@ -4684,6 +4684,10 @@ export default {
     rightNozzle: "П",
     leftNozzleTooltip: "Левое сопло",
     rightNozzleTooltip: "Правое сопло",
+    rackPosition: "Позиция в стойке",
+    rackPositionTooltip: "Каким соплом из стойки печатать этот филамент. Позиции нумеруются так же, как на принтере.",
+    rackEmptyPosition: "Эта позиция в стойке пуста",
+    rackWrongNozzle: "Установлено сопло {{has}}; этому филаменту нужно {{needs}}",
     filamentOverride: "Переопределение филаментов",
     filamentOverrideHint: "При необходимости замените филаменты для назначения по модели. Планировщик будет сопоставлять выбранные филаменты вместо исходных значений из 3MF.",
     originalFilament: "Исходный",

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

@@ -4902,6 +4902,10 @@ export default {
     rightNozzle: 'R',
     leftNozzleTooltip: 'Sol nozul',
     rightNozzleTooltip: 'Sağ nozul',
+    rackPosition: 'Rack konumu',
+    rackPositionTooltip: 'Bu filamenti rack üzerindeki hangi nozulun basacağı. Konumlar yazıcıdaki gibi numaralandırılmıştır.',
+    rackEmptyPosition: 'Bu rack konumu boş',
+    rackWrongNozzle: '{{has}} nozul takılı; bu filament {{needs}} gerektiriyor',
     filamentOverride: 'Filament Geçersiz Kılma',
     filamentOverrideHint: 'Model tabanlı atama için isteğe bağlı olarak filamentleri geçersiz kıl. Planlayıcı, orijinal 3MF değerleri yerine seçtiğiniz filamentlere göre eşleşecek.',
     originalFilament: 'Orijinal',

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

@@ -4967,6 +4967,10 @@ export default {
     rightNozzle: "Р",
     leftNozzleTooltip: "Ліве сопло",
     rightNozzleTooltip: "Праве сопло",
+    rackPosition: "Позиція у стійці",
+    rackPositionTooltip: "Яким соплом зі стійки друкувати цей філамент. Позиції нумеруються так само, як на принтері.",
+    rackEmptyPosition: "Ця позиція у стійці порожня",
+    rackWrongNozzle: "Встановлено сопло {{has}}; цьому філаменту потрібно {{needs}}",
     filamentOverride: "Перевизначення філаменту",
     filamentOverrideHint: "Необов’язково замініть філаменти для призначення на основі моделі. Планувальник зіставлятиметься з вибраними філаментами замість вихідних значень 3MF.",
     originalFilament: "Оригінал",

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

@@ -4913,6 +4913,10 @@ export default {
     rightNozzle: '右',
     leftNozzleTooltip: '左喷嘴',
     rightNozzleTooltip: '右喷嘴',
+    rackPosition: '刀架位置',
+    rackPositionTooltip: '由刀架上的哪个喷嘴打印此耗材。位置编号与打印机上一致。',
+    rackEmptyPosition: '此刀架位置为空',
+    rackWrongNozzle: '装有 {{has}} 喷嘴;此耗材需要 {{needs}}',
     filamentOverride: '耗材覆盖',
     filamentOverrideHint: '可选覆盖用于基于模型的耗材分配。调度器将使用您选择的耗材而不是原始 3MF 值进行匹配。',
     originalFilament: '原始',

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

@@ -4913,6 +4913,10 @@ export default {
     rightNozzle: '右',
     leftNozzleTooltip: '左噴嘴',
     rightNozzleTooltip: '右噴嘴',
+    rackPosition: '刀架位置',
+    rackPositionTooltip: '由刀架上的哪個噴嘴列印此耗材。位置編號與印表機上一致。',
+    rackEmptyPosition: '此刀架位置為空',
+    rackWrongNozzle: '裝有 {{has}} 噴嘴;此耗材需要 {{needs}}',
     filamentOverride: '耗材覆蓋',
     filamentOverrideHint: '可選覆蓋用於基於模型的耗材分配。排程器將使用您選擇的耗材而不是原始 3MF 值進行匹配。',
     originalFilament: '原始',

+ 173 - 0
frontend/src/utils/nozzleRack.ts

@@ -0,0 +1,173 @@
+import type { NozzleRackSlot } from '../api/client';
+import type { RackGroupInfo, RackPositionOption } from '../components/PrintModal/types';
+
+/**
+ * H2C nozzle-rack position helpers (#1784).
+ *
+ * A rack position as everyone counts it — the printer card, BambuStudio, the
+ * operator — is 1-based, and the physical nozzle ID the printer is sent is 15
+ * higher. Measured 2026-08-14: the same plate dispatched with R1+R2 picked sent
+ * `[16, 1, 17]`, and with R1+R3 picked sent `[16, 1, 18]`.
+ *
+ * The eligibility rule here mirrors `_rack_slot_is_eligible` in
+ * `backend/app/services/bambu_mqtt.py`. It is duplicated rather than fetched
+ * because the picker has to grey out an option as the user looks at it, but the
+ * backend re-checks the same rule at dispatch and refuses the print if it no
+ * longer holds — so this copy being briefly stale can only mislead, never
+ * misprint.
+ */
+
+/** Physical nozzle IDs of the six rack positions. */
+export const RACK_POSITION_BASE = 15;
+export const RACK_SIZE = 6;
+export const RACK_POSITIONS = Array.from({ length: RACK_SIZE }, (_, i) => i + 1);
+
+/** The two carriage entries the printer reports alongside the rack itself. */
+const RACK_CARRIAGE_NOZZLE_ID = 0;
+
+export function rackPositionToNozzleId(position: number): number | null {
+  if (!Number.isInteger(position) || position < 1 || position > RACK_SIZE) return null;
+  return RACK_POSITION_BASE + position;
+}
+
+/**
+ * Live rack contents by 1-based position, including the mounted nozzle.
+ *
+ * The firmware omits a rack ID entirely while that nozzle is picked up onto the
+ * carriage (#943) rather than sending an empty placeholder, so taking the gap at
+ * face value would grey out the nozzle most likely to be wanted — the one the
+ * last print left mounted. A single gap alongside a loaded carriage is that
+ * carriage's nozzle; two or more gaps are genuinely ambiguous (four nozzles in
+ * six positions looks identical) and stay absent.
+ */
+export function rackByPosition(slots: NozzleRackSlot[] | undefined): Map<number, NozzleRackSlot> {
+  const byPosition = new Map<number, NozzleRackSlot>();
+  let carriage: NozzleRackSlot | undefined;
+
+  for (const slot of slots ?? []) {
+    if (slot.id === RACK_CARRIAGE_NOZZLE_ID) {
+      carriage = slot;
+      continue;
+    }
+    const position = slot.id - RACK_POSITION_BASE;
+    if (position >= 1 && position <= RACK_SIZE) byPosition.set(position, slot);
+  }
+
+  const missing = RACK_POSITIONS.filter((p) => !byPosition.has(p));
+  if (missing.length === 1 && carriage && (carriage.nozzle_diameter || carriage.nozzle_type)) {
+    byPosition.set(missing[0], carriage);
+  }
+  return byPosition;
+}
+
+/** Whether a live rack slot can print a group wanting this nozzle. */
+export function isRackSlotEligible(
+  slot: NozzleRackSlot | undefined,
+  group: Pick<RackGroupInfo, 'nozzle_diameter' | 'volume_type'>,
+): boolean {
+  if (!slot) return false;
+  if (!slot.nozzle_diameter && !slot.nozzle_type) return false;
+
+  // "0.40" and "0.4" name the same nozzle — the 3MF pads, the printer does not.
+  const slotDiameter = Number.parseFloat(slot.nozzle_diameter);
+  const wantedDiameter = Number.parseFloat(group.nozzle_diameter);
+  if (!Number.isFinite(slotDiameter) || !Number.isFinite(wantedDiameter)) return false;
+  if (Math.abs(slotDiameter - wantedDiameter) > 0.005) return false;
+
+  // Flow type: the printer reports a code ("HS", "HH01"), the slice a name
+  // ("High Flow"). Compared only when both are stated, so a printer that omits
+  // the code is not thereby ruled out.
+  const wanted = (group.volume_type || '').trim().toLowerCase();
+  if (wanted && slot.nozzle_type) {
+    const isHighFlow = slot.nozzle_type.toUpperCase().startsWith('HH');
+    if (wanted.startsWith('high flow') !== isHighFlow) return false;
+  }
+  return true;
+}
+
+/**
+ * The six positions as pickable options for one group, each with the reason it
+ * cannot be used when it cannot. Always returns all six: an operator looking for
+ * position 4 should find it greyed out with an explanation, not missing.
+ */
+export function rackOptionsForGroup(
+  slots: NozzleRackSlot[] | undefined,
+  group: RackGroupInfo,
+  translate: (key: string, opts?: Record<string, unknown>) => string,
+): RackPositionOption[] {
+  const byPosition = rackByPosition(slots);
+
+  return RACK_POSITIONS.map((position) => {
+    const slot = byPosition.get(position);
+    const eligible = isRackSlotEligible(slot, group);
+    let reason: string | undefined;
+    if (!eligible) {
+      reason = slot
+        ? translate('printModal.rackWrongNozzle', {
+            has: `${slot.nozzle_diameter || '?'} ${slot.nozzle_type || ''}`.trim(),
+            needs: `${group.nozzle_diameter} ${group.volume_type}`.trim(),
+          })
+        : translate('printModal.rackEmptyPosition');
+    }
+    return {
+      position,
+      diameter: slot?.nozzle_diameter ?? '',
+      nozzleType: slot?.nozzle_type ?? '',
+      filamentColor: slot?.filament_color ?? '',
+      eligible,
+      reason,
+    };
+  });
+}
+
+/**
+ * A position for every rack-bound group, preferring one already loaded with the
+ * group's own colour. Mirrors the dispatcher's assignment so the dialog shows
+ * what will actually happen rather than leaving every picker blank.
+ *
+ * Returns null when some group cannot be placed — the caller then leaves the
+ * pickers empty and lets the backend fall back, rather than showing a partial
+ * assignment that reads as a decision.
+ */
+export function autoAssignRackPositions(
+  slots: NozzleRackSlot[] | undefined,
+  groups: Map<number, RackGroupInfo>,
+  pinned: Record<number, number> = {},
+): Record<number, number> | null {
+  const byPosition = rackByPosition(slots);
+  const assigned: Record<number, number> = {};
+  const taken = new Set<number>();
+
+  const rackGroupIds = [...groups.keys()].filter((id) => groups.get(id)!.on_rack).sort((a, b) => a - b);
+
+  // Explicit picks are placed first so an auto-assignment yields to them
+  // instead of claiming a position the operator asked for.
+  for (const groupId of rackGroupIds) {
+    const position = pinned[groupId];
+    if (position == null) continue;
+    if (taken.has(position) || !isRackSlotEligible(byPosition.get(position), groups.get(groupId)!)) return null;
+    assigned[groupId] = position;
+    taken.add(position);
+  }
+
+  for (const groupId of rackGroupIds) {
+    if (assigned[groupId] != null) continue;
+    const group = groups.get(groupId)!;
+    const eligible = RACK_POSITIONS.filter((p) => !taken.has(p) && isRackSlotEligible(byPosition.get(p), group));
+    if (eligible.length === 0) return null;
+
+    const wanted = normaliseColor(group.filament_color);
+    const preferred = wanted
+      ? eligible.find((p) => normaliseColor(byPosition.get(p)?.filament_color) === wanted)
+      : undefined;
+    const chosen = preferred ?? eligible[0];
+    assigned[groupId] = chosen;
+    taken.add(chosen);
+  }
+  return assigned;
+}
+
+/** Hex colours arrive as `#RRGGBB` from the 3MF and `RRGGBBAA` from the printer. */
+function normaliseColor(value: string | undefined): string {
+  return (value ?? '').trim().replace(/^#/, '').slice(0, 6).toUpperCase();
+}

Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/index-1Ya6fAmN.css


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/index-CUMnY0g5.js


+ 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-BKSFEuQA.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-n0K-Bu3y.css">
+    <script type="module" crossorigin src="/assets/index-CUMnY0g5.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-1Ya6fAmN.css">
   </head>
   <body>
     <div id="root"></div>

Certains fichiers n'ont pas été affichés car il y a eu trop de fichiers modifiés dans ce diff