Jelajahi Sumber

Add auto-orient and auto-arrange to server-side slicing (#2548)

    Both are per-slice checkboxes, off by default, forwarded as the sidecar's
    orient / arrange form fields. An unticked box is sent by omission: the
    sidecar treats any present value as truthy, so a literal "false" would
    have arranged every slice.

    Arrange unions with the #1493 cross-class decision rather than replacing
    it, and the per-plate slice-all loop is now keyed on the arrange flag
    itself — the project-wide collapse belongs to --arrange, not to the
    cross-class case. The loop also covers the embedded-settings path, whose
    crash-retry is suppressed there since a single --slice 0 retry would
    return one consolidated plate.
maziggy 3 minggu lalu
induk
melakukan
67e8a78cb8
49 mengubah file dengan 4572 tambahan dan 138 penghapusan
  1. 114 41
      backend/app/api/routes/library.py
  2. 425 0
      backend/app/api/routes/library_variants.py
  3. 260 24
      backend/app/api/routes/print_queue.py
  4. 113 0
      backend/app/core/database.py
  5. 2 0
      backend/app/main.py
  6. 2 1
      backend/app/models/__init__.py
  7. 53 0
      backend/app/models/library.py
  8. 76 0
      backend/app/models/print_queue.py
  9. 62 0
      backend/app/schemas/library.py
  10. 39 0
      backend/app/schemas/print_queue.py
  11. 21 0
      backend/app/schemas/slicer.py
  12. 19 0
      backend/app/services/library_trash.py
  13. 284 47
      backend/app/services/print_scheduler.py
  14. 39 6
      backend/app/services/slicer_api.py
  15. 354 0
      backend/tests/integration/test_library_slice_api.py
  16. 246 0
      backend/tests/integration/test_library_variants_api.py
  17. 297 0
      backend/tests/integration/test_queue_variants_api.py
  18. 109 0
      backend/tests/unit/services/test_slicer_api.py
  19. 495 0
      backend/tests/unit/test_scheduler_cross_model_variants.py
  20. 26 0
      backend/tests/unit/test_slice_request_schema.py
  21. 302 0
      backend/tests/unit/test_variant_group_backfill_migration.py
  22. 143 0
      frontend/src/__tests__/components/PrintModalCrossModel.test.tsx
  23. 51 0
      frontend/src/__tests__/components/SliceModal.test.tsx
  24. 122 0
      frontend/src/__tests__/components/VariantCandidates.test.tsx
  25. 95 0
      frontend/src/api/client.ts
  26. 2 1
      frontend/src/components/CompactHistoryRow.tsx
  27. 160 0
      frontend/src/components/PrintModal/VariantCandidates.tsx
  28. 150 7
      frontend/src/components/PrintModal/index.tsx
  29. 14 0
      frontend/src/components/PrintModal/types.ts
  30. 2 1
      frontend/src/components/PrinterQueueWidget.tsx
  31. 49 0
      frontend/src/components/SliceModal.tsx
  32. 22 0
      frontend/src/i18n/locales/de.ts
  33. 22 0
      frontend/src/i18n/locales/en.ts
  34. 22 0
      frontend/src/i18n/locales/es.ts
  35. 22 0
      frontend/src/i18n/locales/fr.ts
  36. 22 0
      frontend/src/i18n/locales/it.ts
  37. 22 0
      frontend/src/i18n/locales/ja.ts
  38. 22 0
      frontend/src/i18n/locales/ko.ts
  39. 22 0
      frontend/src/i18n/locales/pt-BR.ts
  40. 22 0
      frontend/src/i18n/locales/ru.ts
  41. 22 0
      frontend/src/i18n/locales/tr.ts
  42. 22 0
      frontend/src/i18n/locales/uk.ts
  43. 22 0
      frontend/src/i18n/locales/zh-CN.ts
  44. 22 0
      frontend/src/i18n/locales/zh-TW.ts
  45. 91 5
      frontend/src/pages/FileManagerPage.tsx
  46. 24 4
      frontend/src/pages/QueuePage.tsx
  47. 44 0
      frontend/src/utils/queueItemName.ts
  48. 0 0
      static/assets/index-qwJExXvN.js
  49. 1 1
      static/index.html

+ 114 - 41
backend/app/api/routes/library.py

@@ -2023,6 +2023,20 @@ async def list_files(
             )
             hash_counts = {h: c - 1 for h, c in dup_result.all()}  # -1 to exclude self
 
+    # Variant group sizes (#671 / #2570). Counted across the whole group rather
+    # than the rows on screen — members can sit in different folders, so counting
+    # the listing would under-report and the "2 versions" badge would blink in
+    # and out as the user navigated.
+    variant_counts: dict[int, int] = {}
+    group_ids = {f.variant_group_id for f in files if f.variant_group_id}
+    if group_ids:
+        count_result = await db.execute(
+            select(LibraryFile.variant_group_id, func.count(LibraryFile.id))
+            .where(LibraryFile.variant_group_id.in_(group_ids), LibraryFile.deleted_at.is_(None))
+            .group_by(LibraryFile.variant_group_id)
+        )
+        variant_counts = dict(count_result.all())
+
     # Prevent browser caching of file list
     response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
 
@@ -2059,6 +2073,8 @@ async def list_files(
                 filament_used_grams=filament_grams,
                 sliced_for_model=sliced_for_model,
                 tags=[TagSummary(id=t.id, name=t.name) for t in f.tags],
+                variant_group_id=f.variant_group_id,
+                variant_count=variant_counts.get(f.variant_group_id, 0) if f.variant_group_id else 0,
             )
         )
 
@@ -3756,6 +3772,13 @@ async def _run_slicer_with_fallback(
                 target_model,
             )
             cross_class_arrange = True
+
+    # #2548: the user can also ask for either layout pass per-slice. Arrange
+    # is a union with the cross-class decision above — a user opt-out must
+    # not be able to switch off the flag that keeps a class-crossing slice
+    # from crashing — while orient is user-driven only.
+    arrange_flag = cross_class_arrange or request.auto_arrange
+    orient_flag = request.auto_orient
     # When this slice is dispatcher-tracked, generate a request_id so
     # the sidecar publishes progress under it, and wire a callback that
     # forwards each frame onto SliceDispatchService.set_progress for the
@@ -3805,36 +3828,27 @@ async def _run_slicer_with_fallback(
 
         filament_jsons = substitute_unused_plate_filaments(primary_bytes, request.plate or 1, filament_jsons)
 
-    # Cross-class slice-all loop (#1493): when the user asks for
-    # ``plate=0`` (all plates) AND the source's nozzle class differs from
-    # the target's, ``--slice 0 --arrange 1`` consolidates every plate's
-    # objects onto a single target bed (BS's ``--arrange`` is project-
-    # wide) — either packing them all together or rejecting with "Some
-    # objects are located over the boundary of the heated bed" when
-    # nothing fits. Slice each plate independently with ``--arrange 1``
-    # and merge the per-plate outputs into one multi-plate 3MF instead.
-    # Same-class slice-all goes through the regular path below — the
-    # sidecar's native ``--slice 0`` produces the right shape directly.
-    use_cross_class_slice_all = cross_class_arrange and request.plate == 0 and request.export_3mf
+    # Arrange slice-all loop (#1493): when the user asks for ``plate=0``
+    # (all plates) AND arrange is on, ``--slice 0 --arrange 1``
+    # consolidates every plate's objects onto a single target bed (BS's
+    # ``--arrange`` is project-wide) — either packing them all together or
+    # rejecting with "Some objects are located over the boundary of the
+    # heated bed" when nothing fits. Slice each plate independently with
+    # ``--arrange 1`` and merge the per-plate outputs into one multi-plate
+    # 3MF instead. Slice-all without arrange goes through the regular path
+    # below — the sidecar's native ``--slice 0`` produces the right shape
+    # directly.
+    #
+    # Keyed on ``arrange_flag``, not just the cross-class decision: the
+    # project-wide collapse is a property of ``--arrange`` itself, so a
+    # user-requested arrange over all plates (#2548) hits it identically.
+    # Orient doesn't — it rotates objects where they stand and never moves
+    # one between plates — so it isn't part of this condition.
+    use_arrange_slice_all = arrange_flag and request.plate == 0 and request.export_3mf
 
     try:
         try:
-            if embedded_mode:
-                # No --load-settings: feed the CLI the file's own
-                # project_settings.config untouched so the designer's tweaks
-                # (walls, infill, etc.) drive the slice. primary_bytes is
-                # already sentinel-sanitised above, the same bytes the
-                # crash-fallback uses. The resolved presets go unused here.
-                result = await service.slice_without_profiles(
-                    model_bytes=primary_bytes,
-                    model_filename=model_filename,
-                    plate=request.plate,
-                    export_3mf=request.export_3mf,
-                    request_id=progress_request_id,
-                    on_progress=progress_callback,
-                )
-                used_embedded_settings = True
-            elif use_cross_class_slice_all:
+            if use_arrange_slice_all:
                 from backend.app.services.slicer_3mf_convert import (
                     count_plates_in_3mf,
                     merge_plate_3mfs,
@@ -3851,8 +3865,10 @@ async def _run_slicer_with_fallback(
                         ),
                     )
                 logger.info(
-                    "Cross-class slice-all: looping over %d plates with --arrange per plate, then merging",
+                    "Arrange slice-all: looping over %d plates with --arrange per plate, then merging "
+                    "(embedded_settings=%s)",
                     plate_count,
+                    embedded_mode,
                 )
                 from backend.app.services.slicer_api import SliceResult
 
@@ -3881,18 +3897,35 @@ async def _run_slicer_with_fallback(
 
                 for plate_num in range(1, plate_count + 1):
                     plate_cb = _wrap_progress_for_plate(plate_num, plate_count)
-                    per_plate = await service.slice_with_profiles(
-                        model_bytes=primary_bytes,
-                        model_filename=model_filename,
-                        printer_profile_json=presets["printer"],
-                        process_profile_json=presets["process"],
-                        filament_profile_jsons=filament_jsons,
-                        plate=plate_num,
-                        export_3mf=True,
-                        arrange=True,
-                        request_id=progress_request_id,
-                        on_progress=plate_cb,
-                    )
+                    # "Slice as designed" has to take the loop too, not skip
+                    # it: the project-wide collapse is caused by --arrange,
+                    # and which config drives the slice has no bearing on
+                    # that. Same call, minus --load-settings.
+                    if embedded_mode:
+                        per_plate = await service.slice_without_profiles(
+                            model_bytes=primary_bytes,
+                            model_filename=model_filename,
+                            plate=plate_num,
+                            export_3mf=True,
+                            arrange=True,
+                            orient=orient_flag,
+                            request_id=progress_request_id,
+                            on_progress=plate_cb,
+                        )
+                    else:
+                        per_plate = await service.slice_with_profiles(
+                            model_bytes=primary_bytes,
+                            model_filename=model_filename,
+                            printer_profile_json=presets["printer"],
+                            process_profile_json=presets["process"],
+                            filament_profile_jsons=filament_jsons,
+                            plate=plate_num,
+                            export_3mf=True,
+                            arrange=True,
+                            orient=orient_flag,
+                            request_id=progress_request_id,
+                            on_progress=plate_cb,
+                        )
                     per_plate_results.append((plate_num, per_plate))
 
                 # Merge the N single-plate 3MFs into one multi-plate 3MF.
@@ -3913,6 +3946,28 @@ async def _run_slicer_with_fallback(
                     filament_used_g=sum(r.filament_used_g for _, r in per_plate_results),
                     filament_used_mm=sum(r.filament_used_mm for _, r in per_plate_results),
                 )
+                # Report the path honestly: the loop can run either way, and
+                # the UI reads this flag to tell the user whose settings won.
+                used_embedded_settings = embedded_mode
+            elif embedded_mode:
+                # No --load-settings: feed the CLI the file's own
+                # project_settings.config untouched so the designer's tweaks
+                # (walls, infill, etc.) drive the slice. primary_bytes is
+                # already sentinel-sanitised above, the same bytes the
+                # crash-fallback uses. The resolved presets go unused here.
+                # Arrange / orient still apply: they are CLI actions on the
+                # geometry, not settings the embedded config could carry.
+                result = await service.slice_without_profiles(
+                    model_bytes=primary_bytes,
+                    model_filename=model_filename,
+                    plate=request.plate,
+                    export_3mf=request.export_3mf,
+                    arrange=arrange_flag,
+                    orient=orient_flag,
+                    request_id=progress_request_id,
+                    on_progress=progress_callback,
+                )
+                used_embedded_settings = True
             else:
                 result = await service.slice_with_profiles(
                     model_bytes=primary_bytes,
@@ -3922,7 +3977,8 @@ async def _run_slicer_with_fallback(
                     filament_profile_jsons=filament_jsons,
                     plate=request.plate,
                     export_3mf=request.export_3mf,
-                    arrange=cross_class_arrange,
+                    arrange=arrange_flag,
+                    orient=orient_flag,
                     request_id=progress_request_id,
                     on_progress=progress_callback,
                 )
@@ -3942,6 +3998,14 @@ async def _run_slicer_with_fallback(
                 # error (the outer handler turns it into a 502) instead of
                 # re-running the same embedded slice.
                 raise
+            if use_arrange_slice_all:
+                # The fallback is a single ``--slice 0`` call, and with
+                # arrange on that collapses every plate onto one bed — the
+                # exact outcome the per-plate loop above exists to avoid.
+                # Retrying would hand back a one-plate result for a job the
+                # user asked to slice as N, which reads as a Bambuddy bug
+                # rather than a slicer failure. Surface the error instead.
+                raise
             logger.warning(
                 "Slicer CLI failed on the --load-settings path for %s (%s); retrying with embedded settings",
                 model_filename,
@@ -3955,11 +4019,17 @@ async def _run_slicer_with_fallback(
             # there too, so without sanitisation the fallback would die
             # on the same sentinel error (#1201). The SliceModal flags
             # the difference to the user via used_embedded_settings.
+            # Carry the layout flags across too — the retry is meant to
+            # differ from the failed attempt only in where the print
+            # config came from, so dropping them here would silently
+            # produce an un-arranged result the user did ask for.
             result = await service.slice_without_profiles(
                 model_bytes=primary_bytes,
                 model_filename=model_filename,
                 plate=request.plate,
                 export_3mf=request.export_3mf,
+                arrange=arrange_flag,
+                orient=orient_flag,
                 request_id=progress_request_id,
                 on_progress=progress_callback,
             )
@@ -4692,6 +4762,9 @@ async def delete_file(
                 abs_thumb_path.unlink()
             except OSError as e:
                 logger.warning("Failed to delete thumbnail from disk: %s", e)
+        from backend.app.services.library_trash import delete_dependent_variants
+
+        await delete_dependent_variants(db, [file.id])
         await db.delete(file)
         await db.commit()
         return {"status": "success", "message": "File deleted", "trashed": False}

+ 425 - 0
backend/app/api/routes/library_variants.py

@@ -0,0 +1,425 @@
+"""Variant groups — one job, several sliced files (#671 / #2570).
+
+A user with more than one printer model slices the same job once per model. The
+files are unrelated as far as the library is concerned: different names,
+different metadata, often uploaded separately after being sliced in Bambu Studio.
+A variant group is the user telling Bambuddy that they are interchangeable.
+
+Two features consume that statement from opposite ends:
+
+* the print queue picks the printer and needs the matching file (#671)
+* the File Manager's print action has the printer already and needs the same
+  match (#2570)
+
+The group itself stores no model information. Each member's target model comes
+from its own ``sliced_for_model``, parsed out of the 3MF, so a group can never
+disagree with the files in it. A legacy file that declares no model may name one
+explicitly, because there is nothing else to go on.
+
+Invariants enforced here rather than in the database, because they are about
+meaning rather than shape:
+
+* **Two members minimum.** A group of one expresses no choice. Removing members
+  down to one dissolves the group rather than leaving a stub that does nothing.
+* **One member per model.** Two files sliced for the same printer are not
+  alternatives — the resolver would have no basis to prefer one, so an
+  arbitrary pick would look like a bug the first time the wrong quality preset
+  came out.
+* **Members must be sliced and must resolve to a model.** An unsliced .3mf can
+  never be dispatched, so it cannot be a candidate.
+* **A file belongs to at most one group**, which the schema already guarantees;
+  this layer turns the resulting overwrite into an explicit 409.
+
+Permissions follow library_tags.py: mutations need LIBRARY_UPDATE_ALL /
+LIBRARY_UPDATE_OWN, reads need LIBRARY_READ_ALL / LIBRARY_READ_OWN, and an
+``*_OWN`` caller only ever sees or touches files they created.
+"""
+
+from __future__ import annotations
+
+import logging
+
+from fastapi import APIRouter, Depends, HTTPException
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.core.auth import require_ownership_permission
+from backend.app.core.database import get_db
+from backend.app.core.permissions import Permission
+from backend.app.models.library import FileVariantGroup, LibraryFile
+from backend.app.models.user import User
+from backend.app.schemas.library import (
+    VariantGroupCreate,
+    VariantGroupMemberRequest,
+    VariantGroupMemberResponse,
+    VariantGroupResponse,
+    VariantGroupUpdate,
+)
+from backend.app.utils.printer_models import normalize_printer_model, normalize_printer_model_id
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/library/variant-groups", tags=["library-variants"])
+
+# File types that can actually be sent to a printer. A source .3mf or an .stl
+# has no G-code and no sliced_for_model, so it is never a dispatch candidate.
+_PRINTABLE_TYPES = ("gcode.3mf", "gcode")
+
+
+def normalize_model_name(raw: str | None) -> str | None:
+    """Normalize any spelling of a printer model to its short name.
+
+    Internal codes are resolved **first**. ``normalize_printer_model`` returns
+    unknown input unchanged rather than None, so an ``x or y`` chain in the other
+    order never reaches the code map and leaves "O1C" as "O1C" — which then
+    matches no printer row and leaves the job waiting forever. Running the code
+    map first is a no-op for every non-code input.
+    """
+    if not raw:
+        return None
+    return normalize_printer_model(normalize_printer_model_id(raw) or raw) or raw
+
+
+def resolve_variant_model(lib_file: LibraryFile, explicit: str | None = None) -> str | None:
+    """Normalized model a file will be dispatched to, or None if unknowable.
+
+    Precedence: the caller's explicit choice for this request, then the durable
+    override stored on the file, then what the 3MF itself declares. The override
+    exists because a file imported before Bambuddy parsed ``sliced_for_model``
+    declares nothing, and without a way to say so it could never be grouped.
+    It is kept separate from ``file_metadata`` so a user's assertion is never
+    mistaken for something parsed out of the file.
+    """
+    raw = explicit or lib_file.variant_target_model or (lib_file.file_metadata or {}).get("sliced_for_model")
+    return normalize_model_name(raw)
+
+
+async def _load_files(
+    db: AsyncSession,
+    file_ids: list[int],
+    user: User | None,
+    can_access_all: bool,
+) -> dict[int, LibraryFile]:
+    """Fetch the caller's visible, untrashed files by id."""
+    query = LibraryFile.active().where(LibraryFile.id.in_(file_ids))
+    if user is not None and not can_access_all:
+        query = query.where(LibraryFile.created_by_id == user.id)
+    rows = (await db.execute(query)).scalars().all()
+    return {f.id: f for f in rows}
+
+
+def _validate_member(lib_file: LibraryFile, explicit_model: str | None) -> str:
+    """Return the member's model, or raise the reason it cannot be one."""
+    if lib_file.file_type not in _PRINTABLE_TYPES:
+        raise HTTPException(
+            400,
+            f"{lib_file.filename} is not a sliced file — only sliced output can be a print variant",
+        )
+    model = resolve_variant_model(lib_file, explicit_model)
+    if not model:
+        raise HTTPException(
+            400,
+            f"{lib_file.filename} does not say which printer it was sliced for — set its target model explicitly",
+        )
+    if explicit_model:
+        # Persist the user's answer, normalized. The group stores no model data
+        # of its own, so without this the choice would last exactly one request
+        # and the member would read back with no model at all.
+        lib_file.variant_target_model = model
+    return model
+
+
+async def _group_response(db: AsyncSession, group: FileVariantGroup) -> VariantGroupResponse:
+    members = (
+        (
+            await db.execute(
+                LibraryFile.active()
+                .where(LibraryFile.variant_group_id == group.id)
+                .order_by(LibraryFile.variant_position, LibraryFile.id)
+            )
+        )
+        .scalars()
+        .all()
+    )
+    return VariantGroupResponse(
+        id=group.id,
+        name=group.name,
+        members=[
+            VariantGroupMemberResponse(
+                library_file_id=f.id,
+                filename=f.filename,
+                # Members were validated on the way in, but a file whose metadata
+                # was rewritten since then should not blow up a read.
+                target_model=resolve_variant_model(f) or "",
+                position=f.variant_position,
+            )
+            for f in members
+        ],
+    )
+
+
+async def _get_group_or_404(db: AsyncSession, group_id: int) -> FileVariantGroup:
+    group = (await db.execute(select(FileVariantGroup).where(FileVariantGroup.id == group_id))).scalar_one_or_none()
+    if not group:
+        raise HTTPException(404, "Variant group not found")
+    return group
+
+
+async def _dissolve_if_too_small(db: AsyncSession, group: FileVariantGroup) -> bool:
+    """Delete the group when fewer than two members remain.
+
+    A one-member group is not a choice, and leaving one behind would let the
+    queue create a cross-model item with a single candidate that silently
+    behaves like an ordinary job. Returns True when the group was dissolved.
+    """
+    remaining = (await db.execute(select(LibraryFile).where(LibraryFile.variant_group_id == group.id))).scalars().all()
+    if len(remaining) >= 2:
+        return False
+    for lib_file in remaining:
+        lib_file.variant_group_id = None
+        lib_file.variant_position = 0
+    await db.delete(group)
+    return True
+
+
+@router.post("", response_model=VariantGroupResponse, status_code=201)
+@router.post("/", response_model=VariantGroupResponse, status_code=201)
+async def create_variant_group(
+    payload: VariantGroupCreate,
+    db: AsyncSession = Depends(get_db),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.LIBRARY_UPDATE_ALL,
+            Permission.LIBRARY_UPDATE_OWN,
+        )
+    ),
+) -> VariantGroupResponse:
+    """Group files as variants of one job, in priority order."""
+    user, can_update_all = auth_result
+
+    file_ids = [m.library_file_id for m in payload.members]
+    if len(set(file_ids)) != len(file_ids):
+        raise HTTPException(400, "The same file cannot appear twice in a variant group")
+
+    files = await _load_files(db, file_ids, user, can_update_all)
+    missing = [fid for fid in file_ids if fid not in files]
+    if missing:
+        raise HTTPException(404, f"Library file not found: {missing[0]}")
+
+    already_grouped = [files[fid].filename for fid in file_ids if files[fid].variant_group_id is not None]
+    if already_grouped:
+        raise HTTPException(409, f"{already_grouped[0]} already belongs to a variant group")
+
+    models: dict[str, str] = {}
+    for member in payload.members:
+        lib_file = files[member.library_file_id]
+        model = _validate_member(lib_file, member.target_model)
+        if model in models:
+            raise HTTPException(
+                400,
+                f"{lib_file.filename} and {models[model]} are both sliced for {model} — "
+                "variants must target different printers",
+            )
+        models[model] = lib_file.filename
+
+    group = FileVariantGroup(
+        name=payload.name or files[file_ids[0]].filename,
+        created_by_id=user.id if user else None,
+    )
+    db.add(group)
+    await db.flush()
+
+    for position, fid in enumerate(file_ids):
+        files[fid].variant_group_id = group.id
+        files[fid].variant_position = position
+
+    await db.commit()
+    logger.info("Created variant group %s with %d members", group.id, len(file_ids))
+    return await _group_response(db, group)
+
+
+@router.get("/by-file/{file_id}", response_model=VariantGroupResponse)
+async def get_group_for_file(
+    file_id: int,
+    db: AsyncSession = Depends(get_db),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.LIBRARY_READ_ALL,
+            Permission.LIBRARY_READ_OWN,
+        )
+    ),
+) -> VariantGroupResponse:
+    """The group a file belongs to.
+
+    Both consumers start from a file rather than a group id: the print modal
+    knows which file the user clicked, and the queue-create flow knows which
+    file was selected.
+    """
+    user, can_read_all = auth_result
+    files = await _load_files(db, [file_id], user, can_read_all)
+    lib_file = files.get(file_id)
+    if not lib_file:
+        raise HTTPException(404, "Library file not found")
+    if lib_file.variant_group_id is None:
+        raise HTTPException(404, "File is not part of a variant group")
+    return await _group_response(db, await _get_group_or_404(db, lib_file.variant_group_id))
+
+
+@router.get("/{group_id}", response_model=VariantGroupResponse)
+async def get_variant_group(
+    group_id: int,
+    db: AsyncSession = Depends(get_db),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.LIBRARY_READ_ALL,
+            Permission.LIBRARY_READ_OWN,
+        )
+    ),
+) -> VariantGroupResponse:
+    return await _group_response(db, await _get_group_or_404(db, group_id))
+
+
+@router.patch("/{group_id}", response_model=VariantGroupResponse)
+async def update_variant_group(
+    group_id: int,
+    payload: VariantGroupUpdate,
+    db: AsyncSession = Depends(get_db),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.LIBRARY_UPDATE_ALL,
+            Permission.LIBRARY_UPDATE_OWN,
+        )
+    ),
+) -> VariantGroupResponse:
+    """Rename the group, re-order its members, or both.
+
+    Re-ordering is how the user says which printer they would rather have when
+    both are free, so it must be an explicit full ordering — a partial list
+    would leave the rest in an order nobody chose.
+    """
+    user, can_update_all = auth_result
+    group = await _get_group_or_404(db, group_id)
+
+    if payload.name is not None:
+        group.name = payload.name
+
+    if payload.member_file_ids is not None:
+        current = (
+            (await db.execute(select(LibraryFile).where(LibraryFile.variant_group_id == group.id))).scalars().all()
+        )
+        if set(payload.member_file_ids) != {f.id for f in current}:
+            raise HTTPException(400, "member_file_ids must list exactly the group's current members")
+        files = await _load_files(db, payload.member_file_ids, user, can_update_all)
+        if len(files) != len(payload.member_file_ids):
+            raise HTTPException(404, "Library file not found")
+        for position, fid in enumerate(payload.member_file_ids):
+            files[fid].variant_position = position
+
+    await db.commit()
+    return await _group_response(db, group)
+
+
+@router.post("/{group_id}/members", response_model=VariantGroupResponse)
+async def add_variant_group_member(
+    payload: VariantGroupMemberRequest,
+    group_id: int,
+    db: AsyncSession = Depends(get_db),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.LIBRARY_UPDATE_ALL,
+            Permission.LIBRARY_UPDATE_OWN,
+        )
+    ),
+) -> VariantGroupResponse:
+    """Attach another slice to an existing group.
+
+    This is the common real case: the H2S version was queued last week, the H2C
+    version was sliced today.
+    """
+    user, can_update_all = auth_result
+    group = await _get_group_or_404(db, group_id)
+
+    files = await _load_files(db, [payload.library_file_id], user, can_update_all)
+    lib_file = files.get(payload.library_file_id)
+    if not lib_file:
+        raise HTTPException(404, "Library file not found")
+    if lib_file.variant_group_id == group.id:
+        raise HTTPException(409, f"{lib_file.filename} is already in this group")
+    if lib_file.variant_group_id is not None:
+        raise HTTPException(409, f"{lib_file.filename} already belongs to a variant group")
+
+    model = _validate_member(lib_file, payload.target_model)
+
+    existing = (
+        (
+            await db.execute(
+                LibraryFile.active()
+                .where(LibraryFile.variant_group_id == group.id)
+                .order_by(LibraryFile.variant_position, LibraryFile.id)
+            )
+        )
+        .scalars()
+        .all()
+    )
+    for other in existing:
+        if resolve_variant_model(other) == model:
+            raise HTTPException(
+                400,
+                f"{lib_file.filename} and {other.filename} are both sliced for {model} — "
+                "variants must target different printers",
+            )
+
+    lib_file.variant_group_id = group.id
+    lib_file.variant_position = len(existing)
+    await db.commit()
+    return await _group_response(db, group)
+
+
+@router.delete("/{group_id}/members/{file_id}", response_model=None, status_code=204)
+async def remove_variant_group_member(
+    group_id: int,
+    file_id: int,
+    db: AsyncSession = Depends(get_db),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.LIBRARY_UPDATE_ALL,
+            Permission.LIBRARY_UPDATE_OWN,
+        )
+    ),
+) -> None:
+    """Drop one file out of a group; the file itself is untouched."""
+    user, can_update_all = auth_result
+    group = await _get_group_or_404(db, group_id)
+
+    files = await _load_files(db, [file_id], user, can_update_all)
+    lib_file = files.get(file_id)
+    if not lib_file or lib_file.variant_group_id != group.id:
+        raise HTTPException(404, "File is not a member of this group")
+
+    lib_file.variant_group_id = None
+    lib_file.variant_position = 0
+    await db.flush()
+    await _dissolve_if_too_small(db, group)
+    await db.commit()
+
+
+@router.delete("/{group_id}", response_model=None, status_code=204)
+async def delete_variant_group(
+    group_id: int,
+    db: AsyncSession = Depends(get_db),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.LIBRARY_UPDATE_ALL,
+            Permission.LIBRARY_UPDATE_OWN,
+        )
+    ),
+) -> None:
+    """Ungroup the files. The files themselves are kept — every one of them is
+    independently printable, which is the whole reason they were grouped."""
+    group = await _get_group_or_404(db, group_id)
+    members = (await db.execute(select(LibraryFile).where(LibraryFile.variant_group_id == group.id))).scalars().all()
+    for lib_file in members:
+        lib_file.variant_group_id = None
+        lib_file.variant_position = 0
+    await db.delete(group)
+    await db.commit()

+ 260 - 24
backend/app/api/routes/print_queue.py

@@ -8,10 +8,11 @@ from pathlib import Path
 
 import defusedxml.ElementTree as ET
 from fastapi import APIRouter, Depends, HTTPException, Query
-from sqlalchemy import and_, func, or_, select, update
+from sqlalchemy import and_, func, inspect, or_, select, update
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 
+from backend.app.api.routes.library_variants import normalize_model_name, resolve_variant_model
 from backend.app.core.auth import RequirePermissionIfAuthEnabled, require_ownership_permission
 from backend.app.core.config import settings
 from backend.app.core.database import get_db
@@ -19,7 +20,7 @@ from backend.app.core.permissions import Permission
 from backend.app.models.archive import PrintArchive
 from backend.app.models.library import LibraryFile
 from backend.app.models.print_batch import PrintBatch
-from backend.app.models.print_queue import PrintQueueItem
+from backend.app.models.print_queue import PrintQueueItem, PrintQueueVariant
 from backend.app.models.printer import Printer
 from backend.app.models.project import Project
 from backend.app.models.user import User
@@ -33,14 +34,14 @@ from backend.app.schemas.print_queue import (
     PrintQueueItemResponse,
     PrintQueueItemUpdate,
     PrintQueueReorder,
+    QueueVariantCreate,
+    QueueVariantSummary,
 )
 from backend.app.services.filament_deficit import compute_deficit_for_queue_item
 from backend.app.services.filament_requirements import overrides_for_plate
 from backend.app.services.notification_service import notification_service
 from backend.app.utils.printer_models import (
     is_gcode_compatible,
-    normalize_printer_model,
-    normalize_printer_model_id,
 )
 from backend.app.utils.threemf_tools import (
     extract_plate_metadata_from_3mf,
@@ -52,6 +53,27 @@ logger = logging.getLogger(__name__)
 router = APIRouter(prefix="/queue", tags=["queue"])
 
 
+def _variant_summaries(item: PrintQueueItem) -> list[QueueVariantSummary]:
+    """Cross-model candidates for display (#671), or [] if they weren't loaded.
+
+    Every route that builds a queue response eager-loads ``variants``. Reading
+    the attribute unguarded would still be a landmine for the next one that
+    doesn't: a lazy load on an async session raises rather than degrading, so a
+    forgotten ``selectinload`` would turn a card render into a 500.
+    """
+    if "variants" in inspect(item).unloaded:
+        return []
+    return [
+        QueueVariantSummary(
+            library_file_id=v.library_file_id,
+            filename=v.library_file.filename if v.library_file else "",
+            target_model=v.target_model,
+            position=v.position,
+        )
+        for v in item.variants
+    ]
+
+
 def _extract_filament_types_from_3mf(file_path: Path, plate_id: int | None = None) -> list[str]:
     """Extract unique filament types from a 3MF file.
 
@@ -227,6 +249,12 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
         "nozzle_mapping": nozzle_mapping_parsed,
         "nozzles_info": nozzles_info_parsed,
         "cleanup_library_after_dispatch": item.cleanup_library_after_dispatch,
+        # Cross-model alternatives (#671). Guarded rather than read directly:
+        # every route that reaches here eager-loads the relationship, but a
+        # caller that forgets would trigger a lazy load, and a lazy load on an
+        # async session raises rather than degrading. An empty list is the
+        # correct answer for the ordinary item this would most likely be.
+        "variants": _variant_summaries(item),
     }
     response = PrintQueueItemResponse(**item_dict)
     if item.archive:
@@ -338,6 +366,8 @@ async def list_queue(
             selectinload(PrintQueueItem.library_file),
             selectinload(PrintQueueItem.created_by),
             selectinload(PrintQueueItem.batch),
+            # Cross-model candidates (#671) and their files, for the card label.
+            selectinload(PrintQueueItem.variants).selectinload(PrintQueueVariant.library_file),
         )
         .order_by(PrintQueueItem.printer_id.nulls_first(), PrintQueueItem.position)
     )
@@ -382,6 +412,146 @@ async def list_queue(
     return [_enrich_response(item) for item in items]
 
 
+async def _resolve_queue_variants(
+    db: AsyncSession,
+    specs: list[QueueVariantCreate],
+    current_user: User | None,
+) -> list[tuple[QueueVariantCreate, LibraryFile, str]]:
+    """Validate a cross-model candidate set and pair each file with its model (#671).
+
+    Validated as a set, not file by file, because the failure modes are about the
+    set: two candidates for the same printer give the resolver no basis to choose,
+    and a set where nothing can ever run is a job that waits forever.
+
+    At least one candidate must have an active printer — the rest may not, which
+    is deliberate. Grouping the H2C slice before the H2C arrives is a reasonable
+    thing to do, and refusing the whole queue action over it would be worse than
+    letting that candidate simply never match.
+    """
+    file_ids = [s.library_file_id for s in specs]
+    if len(set(file_ids)) != len(file_ids):
+        raise HTTPException(400, "The same file cannot be listed twice as a variant")
+
+    rows = (await db.execute(LibraryFile.active().where(LibraryFile.id.in_(file_ids)))).scalars().all()
+    by_id = {f.id: f for f in rows}
+
+    resolved: list[tuple[QueueVariantCreate, LibraryFile, str]] = []
+    seen_models: dict[str, str] = {}
+    any_active_printer = False
+
+    for spec in specs:
+        library_file = by_id.get(spec.library_file_id)
+        # Same IDOR posture as the single-file path: a file the caller cannot read
+        # is reported as missing rather than forbidden.
+        if not library_file or (
+            current_user
+            and not current_user.has_permission(Permission.LIBRARY_READ_ALL.value)
+            and library_file.created_by_id != current_user.id
+        ):
+            raise HTTPException(404, f"Library file not found: {spec.library_file_id}")
+
+        from backend.app.utils.filename import InvalidFilenameError, validate_print_filename
+
+        try:
+            validate_print_filename(library_file.filename)
+        except InvalidFilenameError as e:
+            raise HTTPException(400, str(e)) from e
+
+        model = resolve_variant_model(library_file, spec.target_model)
+        if not model:
+            raise HTTPException(
+                400,
+                f"{library_file.filename} does not say which printer it was sliced for — "
+                "set its target model explicitly",
+            )
+
+        # Cross-model safety gate (#2578), per candidate. A set is only as safe as
+        # its worst member, and model-based dispatch has no human in the loop.
+        sliced_for = (library_file.file_metadata or {}).get("sliced_for_model")
+        if not is_gcode_compatible(sliced_for, model):
+            raise HTTPException(
+                400,
+                f"{library_file.filename} was sliced for {sliced_for} and cannot be dispatched to {model} printers",
+            )
+
+        if model in seen_models:
+            raise HTTPException(
+                400,
+                f"{library_file.filename} and {seen_models[model]} are both for {model} — "
+                "variants must target different printers",
+            )
+        seen_models[model] = library_file.filename
+
+        has_printer = (
+            (
+                await db.execute(
+                    select(Printer).where(Printer.model == model).where(Printer.is_active == True)  # noqa: E712
+                )
+            )
+            .scalars()
+            .first()
+        )
+        any_active_printer = any_active_printer or bool(has_printer)
+
+        resolved.append((spec, library_file, model))
+
+    if not any_active_printer:
+        raise HTTPException(400, f"No active printers for any of: {', '.join(seen_models)}")
+
+    return resolved
+
+
+def _variant_values(
+    spec: QueueVariantCreate,
+    library_file: LibraryFile,
+    model: str,
+    position: int,
+) -> dict:
+    """Column values for one candidate, extracted from its own 3MF.
+
+    Each candidate is a different slice, so its filament requirements and print
+    time come from its own file rather than being inherited from the item.
+
+    Returns values rather than a row so a quantity>1 batch can build one row per
+    copy without re-opening the 3MF for each.
+    """
+    lib_path = Path(library_file.file_path)
+    file_path = lib_path if lib_path.is_absolute() else settings.base_dir / library_file.file_path
+
+    required_types = None
+    filament_overrides_json = None
+    print_time = (library_file.file_metadata or {}).get("print_time_seconds")
+
+    if file_path.exists():
+        types = _extract_filament_types_from_3mf(file_path, spec.plate_id)
+        if types:
+            required_types = json.dumps(types)
+        if spec.plate_id:
+            plate_time = _extract_print_time_from_3mf(file_path, spec.plate_id)
+            if plate_time is not None:
+                print_time = plate_time
+        if spec.filament_overrides:
+            plate_overrides = overrides_for_plate(spec.filament_overrides, file_path, spec.plate_id)
+            if plate_overrides:
+                filament_overrides_json = json.dumps(plate_overrides)
+                override_types = sorted({o["type"] for o in plate_overrides if "type" in o})
+                if override_types:
+                    existing = set(json.loads(required_types)) if required_types else set()
+                    required_types = json.dumps(sorted(existing | set(override_types)))
+
+    return {
+        "position": position,
+        "library_file_id": library_file.id,
+        "target_model": model,
+        "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,
+        "filament_overrides": filament_overrides_json,
+        "required_filament_types": required_types,
+        "print_time_seconds": print_time,
+    }
+
+
 @router.post("/", response_model=PrintQueueItemResponse)
 async def add_to_queue(
     data: PrintQueueItemCreate,
@@ -389,17 +559,40 @@ async def add_to_queue(
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_CREATE),
 ):
     """Add an item to the print queue."""
-    # Normalize target_model (e.g., "Bambu Lab X1E" / "C13" -> "X1E")
-    target_model_norm = None
-    if data.target_model:
-        target_model_norm = (
-            normalize_printer_model(data.target_model)
-            or normalize_printer_model_id(data.target_model)
-            or data.target_model
-        )
-
-    # Validate that either archive_id or library_file_id is provided
-    if not data.archive_id and not data.library_file_id:
+    # Normalize target_model (e.g., "Bambu Lab X1E" / "C13" -> "X1E").
+    # normalize_model_name resolves internal codes first: the previous
+    # `normalize_printer_model(x) or normalize_printer_model_id(x)` chain never
+    # reached the code map, because the first call returns unknown input
+    # unchanged — so a "C13" target stayed "C13", matched no printer row and
+    # left the item waiting forever. Identical result for every other spelling.
+    target_model_norm = normalize_model_name(data.target_model)
+
+    # Cross-model alternatives (#671): several sliced files, whichever printer
+    # frees up first. The whole candidate set is validated before anything is
+    # written — a half-valid set would produce a job that can only reach some of
+    # the printers the user asked for, with nothing to say which.
+    variant_specs: list[tuple[QueueVariantCreate, LibraryFile, str]] = []
+    if data.variants:
+        if data.printer_id:
+            raise HTTPException(
+                400, "Cannot specify both printer_id and variants — pick a printer or offer alternatives"
+            )
+        if data.archive_id or data.library_file_id:
+            raise HTTPException(
+                400, "Cannot combine variants with archive_id or library_file_id — the variants are the files"
+            )
+        variant_specs = await _resolve_queue_variants(db, data.variants, current_user)
+        # Mirror the first candidate onto the item so the queue listing, the SJF
+        # grouping and the "Any H2S" label have something before a printer is
+        # picked. Resolution overwrites it with whichever candidate actually runs.
+        target_model_norm = variant_specs[0][2]
+
+    # Validate that either archive_id or library_file_id is provided.
+    # A cross-model item deliberately holds neither: its files live on the
+    # variant rows. Pointing library_file_id at one of them would be worse than
+    # useless — that FK is ON DELETE CASCADE, so deleting a single alternative
+    # would take the whole queue item with it.
+    if not data.archive_id and not data.library_file_id and not data.variants:
         raise HTTPException(400, "Either archive_id or library_file_id must be provided")
 
     # Cannot specify both printer_id and target_model
@@ -412,8 +605,10 @@ async def add_to_queue(
         if not result.scalar_one_or_none():
             raise HTTPException(400, "Printer not found")
 
-    # Validate target_model has active printers
-    if target_model_norm:
+    # Validate target_model has active printers. Skipped for cross-model items:
+    # target_model there is just the first candidate, and _resolve_queue_variants
+    # has already required that *some* candidate has a printer.
+    if target_model_norm and not data.variants:
         result = await db.execute(
             select(Printer).where(Printer.model == target_model_norm).where(Printer.is_active == True)  # noqa: E712
         )
@@ -744,6 +939,22 @@ async def add_to_queue(
         db.add(item)
         items.append(item)
 
+    if variant_specs:
+        variant_values = [
+            _variant_values(spec, library_file, model, position)
+            for position, (spec, library_file, model) in enumerate(variant_specs)
+        ]
+        # SJF orders pending items before any printer is known, so the row carries
+        # the shortest candidate's estimate. Resolution replaces it with the one
+        # that actually runs.
+        estimates = [v["print_time_seconds"] for v in variant_values if v["print_time_seconds"]]
+        for item in items:
+            # Each copy in a quantity>1 batch gets its own candidate rows —
+            # attempt counts are per-item, and two copies must be free to land on
+            # different printers.
+            item.variants.extend(PrintQueueVariant(**values) for values in variant_values)
+            item.print_time_seconds = min(estimates) if estimates else None
+
     await db.commit()
 
     # Refresh the first item for the response
@@ -1105,6 +1316,8 @@ async def get_queue_item(
             selectinload(PrintQueueItem.library_file),
             selectinload(PrintQueueItem.created_by),
             selectinload(PrintQueueItem.batch),
+            # Cross-model candidates (#671) and their files, for the card label.
+            selectinload(PrintQueueItem.variants).selectinload(PrintQueueVariant.library_file),
         )
         .where(PrintQueueItem.id == item_id)
     )
@@ -1135,7 +1348,14 @@ async def update_queue_item(
     """Update a queue item."""
     user, can_modify_all = auth_result
 
-    result = await db.execute(select(PrintQueueItem).where(PrintQueueItem.id == item_id))
+    result = await db.execute(
+        select(PrintQueueItem)
+        # Needed by the cross-model guard below, and by the response builder —
+        # without it _variant_summaries falls back to [] and a PATCH would strip
+        # the alternatives out of the payload it echoes back.
+        .options(selectinload(PrintQueueItem.variants).selectinload(PrintQueueVariant.library_file))
+        .where(PrintQueueItem.id == item_id)
+    )
     item = result.scalar_one_or_none()
     if not item:
         raise HTTPException(404, "Queue item not found")
@@ -1158,13 +1378,28 @@ async def update_queue_item(
 
     update_data = data.model_dump(exclude_unset=True)
 
-    # Normalize target_model if being updated
+    # Normalize target_model if being updated (see add_to_queue for why the
+    # code map has to run first).
     if "target_model" in update_data and update_data["target_model"]:
-        update_data["target_model"] = (
-            normalize_printer_model(update_data["target_model"])
-            or normalize_printer_model_id(update_data["target_model"])
-            or update_data["target_model"]
-        )
+        update_data["target_model"] = normalize_model_name(update_data["target_model"])
+
+    # A cross-model item (#671) owns its own printer decision: each candidate
+    # carries its model, and the resolver folds the winner onto the row at
+    # dispatch. Assigning a printer here would leave a row with variants *and* a
+    # printer_id, and the fixed-printer branch of the scheduler wins that race —
+    # so it would dispatch a row whose library_file_id is still null and die in
+    # the upload. Narrowing target_model is refused for the same reason: it
+    # would silently discard every alternative the user queued.
+    #
+    # Compared against the current value rather than merely present, because the
+    # edit dialog re-sends target_model unchanged on every save.
+    if item.variants:
+        for field in ("printer_id", "target_model"):
+            if field in update_data and update_data[field] != getattr(item, field):
+                raise HTTPException(
+                    400,
+                    "This job has printer alternatives — remove them before assigning a printer or model",
+                )
 
     # Cannot specify both printer_id and target_model
     new_printer_id = update_data.get("printer_id", item.printer_id)
@@ -1531,6 +1766,7 @@ async def start_queue_item(
             selectinload(PrintQueueItem.printer),
             selectinload(PrintQueueItem.library_file),
             selectinload(PrintQueueItem.batch),
+            selectinload(PrintQueueItem.variants).selectinload(PrintQueueVariant.library_file),
         )
         .where(PrintQueueItem.id == item_id)
     )

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

@@ -3949,6 +3949,119 @@ async def run_migrations(conn):
             conn, "ALTER TABLE notification_providers ADD COLUMN on_plate_clear_required BOOLEAN DEFAULT false"
         )
 
+    # Migration: variant grouping for library files (#671 / #2570). The
+    # `file_variant_groups` table itself needs no migration — create_all() above
+    # builds it — but the two member-side columns do. INTEGER and the inline
+    # REFERENCES clause are spelled identically on SQLite and Postgres, and
+    # SQLite accepts a REFERENCES on ADD COLUMN (same form as the
+    # pipeline_runs.parent_run_id migration at the top of this function).
+    await _safe_execute(
+        conn,
+        "ALTER TABLE library_files ADD COLUMN variant_group_id INTEGER "
+        "REFERENCES file_variant_groups(id) ON DELETE SET NULL",
+    )
+    await _safe_execute(conn, "ALTER TABLE library_files ADD COLUMN variant_position INTEGER DEFAULT 0")
+    # User-declared target model for a file whose 3MF does not say (#671).
+    # VARCHAR(50) is spelled identically on SQLite and Postgres.
+    await _safe_execute(conn, "ALTER TABLE library_files ADD COLUMN variant_target_model VARCHAR(50)")
+    # The model declares index=True, so fresh installs get this from create_all();
+    # migrated databases need it spelled out. Resolution looks members up by group
+    # on every scheduler pass that touches a grouped item.
+    await _safe_execute(
+        conn,
+        "CREATE INDEX IF NOT EXISTS ix_library_files_variant_group_id ON library_files (variant_group_id)",
+    )
+    await _migrate_backfill_variant_groups(conn)
+
+
+async def _migrate_backfill_variant_groups(conn) -> None:
+    """Build variant groups from the slice provenance already on disk (#671 / #2570).
+
+    ``sliced_from_library_file_id`` has been stamped into ``file_metadata`` by the
+    Slice button (routes/library.py) and the pipeline runner (routes/pipeline_runs.py)
+    since those features shipped, and until now nothing ever read it back — the
+    link existed but was inert. This promotes it to real group membership so an
+    existing library arrives with its slice sets already grouped instead of
+    requiring the user to re-declare by hand what Bambuddy itself recorded.
+
+    Only sources with **two or more** sliced children carrying **distinct**
+    ``sliced_for_model`` values produce a group:
+
+    - Fewer than two candidates is not a choice, and a one-member group would
+      change nothing at print time while creating a row per sliced file in every
+      library on earth.
+    - Two children sliced for the same printer are not alternatives — the
+      resolver has no basis to prefer one, so grouping them would turn a
+      harmless duplicate into an arbitrary pick. Those sources are skipped
+      whole; the user can still group them by hand and choose an order.
+
+    The unsliced source file is deliberately not a member. It has no
+    ``sliced_for_model``, so it can never be a dispatch candidate; showing it
+    alongside its variants is a File Manager listing concern, which is out of
+    scope.
+
+    Idempotent: only files with no group yet are considered, so a re-run after a
+    partial apply resumes rather than duplicating, and a user who has since
+    ungrouped files by hand does not get them silently regrouped.
+    """
+    from sqlalchemy import text
+
+    from backend.app.models.library import FileVariantGroup
+
+    if is_sqlite():
+        source_expr = "json_extract(file_metadata, '$.sliced_from_library_file_id')"
+        model_expr = "json_extract(file_metadata, '$.sliced_for_model')"
+    else:
+        # file_metadata is JSON, not JSONB — cast before using the -> operators,
+        # matching _migrate_drop_library_print_name above.
+        source_expr = "file_metadata::jsonb->>'sliced_from_library_file_id'"
+        model_expr = "file_metadata::jsonb->>'sliced_for_model'"
+
+    async with conn.begin_nested():
+        rows = (
+            await conn.execute(
+                text(
+                    f"SELECT id, {source_expr} AS source_id, {model_expr} AS model "  # noqa: S608 — dialect literals
+                    "FROM library_files "
+                    f"WHERE {source_expr} IS NOT NULL AND {model_expr} IS NOT NULL "
+                    "AND variant_group_id IS NULL AND deleted_at IS NULL "
+                    "ORDER BY id"
+                )
+            )
+        ).fetchall()
+
+        by_source: dict[str, list[tuple[int, str]]] = {}
+        for file_id, source_id, model in rows:
+            by_source.setdefault(str(source_id), []).append((file_id, str(model)))
+
+        for source_id, members in by_source.items():
+            if len(members) < 2:
+                continue
+            models = [m for _, m in members]
+            if len(set(models)) != len(models):
+                # Same printer sliced twice — ambiguous, leave it to the user.
+                continue
+
+            # Name the group after the source file when it is still around; its
+            # filename is what the user recognises. A deleted source leaves the
+            # variants perfectly usable, so fall back rather than skip.
+            name_row = (
+                await conn.execute(
+                    text("SELECT filename FROM library_files WHERE id = :sid"),
+                    {"sid": int(source_id)},
+                )
+            ).fetchone()
+            group_name = name_row[0] if name_row else f"{members[0][1]} + {len(members) - 1} more"
+
+            result = await conn.execute(FileVariantGroup.__table__.insert().values(name=group_name))
+            group_id = result.inserted_primary_key[0]
+
+            for position, (file_id, _model) in enumerate(members):
+                await conn.execute(
+                    text("UPDATE library_files SET variant_group_id = :gid, variant_position = :pos WHERE id = :fid"),
+                    {"gid": group_id, "pos": position, "fid": file_id},
+                )
+
 
 _USER_PRINT_TEMPLATE_RENAMES: tuple[tuple[str, str, str], ...] = (
     ("user_print_start", "User Print Started", "User Print Started Email"),

+ 2 - 0
backend/app/main.py

@@ -39,6 +39,7 @@ from backend.app.api.routes import (
     library,
     library_tags,
     library_trash,
+    library_variants,
     local_backup,
     local_presets,
     maintenance,
@@ -7900,6 +7901,7 @@ app.include_router(projects.router, prefix=app_settings.api_prefix)
 app.include_router(library.router, prefix=app_settings.api_prefix)
 app.include_router(library_tags.router, prefix=app_settings.api_prefix)
 app.include_router(library_trash.router, prefix=app_settings.api_prefix)
+app.include_router(library_variants.router, prefix=app_settings.api_prefix)
 app.include_router(slice_jobs.router, prefix=app_settings.api_prefix)
 app.include_router(slicer_pipelines.router, prefix=app_settings.api_prefix)
 app.include_router(pipeline_runs.pipeline_run_create_router, prefix=app_settings.api_prefix)

+ 2 - 1
backend/app/models/__init__.py

@@ -8,7 +8,7 @@ from backend.app.models.filament import Filament
 from backend.app.models.github_backup import GitHubBackupConfig, GitHubBackupLog
 from backend.app.models.group import Group, user_groups
 from backend.app.models.kprofile_note import KProfileNote
-from backend.app.models.library import LibraryFile, LibraryFolder
+from backend.app.models.library import FileVariantGroup, LibraryFile, LibraryFolder
 from backend.app.models.local_preset import LocalPreset
 from backend.app.models.location import Location
 from backend.app.models.long_lived_token import LongLivedToken
@@ -61,6 +61,7 @@ __all__ = [
     "PrintBatch",
     "LibraryFolder",
     "LibraryFile",
+    "FileVariantGroup",
     "Location",
     "User",
     "Group",

+ 53 - 0
backend/app/models/library.py

@@ -60,6 +60,41 @@ class LibraryFolder(Base):
     archive: Mapped["PrintArchive | None"] = relationship()
 
 
+class FileVariantGroup(Base):
+    """A set of library files that are the same job sliced for different printers.
+
+    Members are peers, not a source/output hierarchy. The group answers one
+    question — "which of these files goes to an H2S, and which to an H2C" — and
+    both open features need that answer from opposite ends: the print queue
+    picks the printer and needs the matching file (#671), the File Manager's
+    print action has the printer already and needs the same match (#2570).
+
+    The group deliberately stores no model information of its own. Each
+    member's target model comes from its own ``file_metadata['sliced_for_model']``,
+    parsed out of the 3MF, so a group can never disagree with the files it
+    contains. It also carries no pointer to an unsliced source file: that is a
+    display concern for the grouped File Manager listing, which is not built.
+
+    Deleting a group ungroups its files rather than deleting them (the member
+    side is ON DELETE SET NULL) — every member is independently printable.
+    """
+
+    __tablename__ = "file_variant_groups"
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    name: Mapped[str] = mapped_column(String(255))
+
+    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
+    updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
+    created_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
+
+    files: Mapped[list["LibraryFile"]] = relationship(
+        back_populates="variant_group",
+        order_by="LibraryFile.variant_position",
+    )
+    created_by: Mapped["User | None"] = relationship()
+
+
 class LibraryFile(Base):
     """File stored in the library."""
 
@@ -98,6 +133,23 @@ class LibraryFile(Base):
     source_type: Mapped[str | None] = mapped_column(String(32), nullable=True)
     source_url: Mapped[str | None] = mapped_column(String(512), nullable=True, index=True)
 
+    # Variant grouping (#671 / #2570). A file belongs to at most one group of
+    # "same job, sliced for a different printer" siblings. SET NULL on group
+    # delete: ungrouping must never take the files with it. ``variant_position``
+    # is the user's priority order within the group — when two printers are idle
+    # at the same scheduler tick, the lowest position wins, so the pick is
+    # reproducible instead of depending on which match the scheduler found first.
+    variant_group_id: Mapped[int | None] = mapped_column(
+        ForeignKey("file_variant_groups.id", ondelete="SET NULL"), nullable=True, index=True
+    )
+    variant_position: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
+    # User's answer to "which printer is this for", for a file that does not say.
+    # Files imported before Bambuddy parsed ``sliced_for_model`` — and raw .gcode —
+    # declare nothing, and without this they could never be grouped. Deliberately
+    # NOT written into ``file_metadata``: that holds what was parsed out of the
+    # file, and a user's assertion must not become indistinguishable from it.
+    variant_target_model: Mapped[str | None] = mapped_column(String(50), nullable=True)
+
     # User tracking (Issue #206)
     created_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
 
@@ -122,6 +174,7 @@ class LibraryFile(Base):
     folder: Mapped["LibraryFolder | None"] = relationship(back_populates="files")
     project: Mapped["Project | None"] = relationship()
     created_by: Mapped["User | None"] = relationship()
+    variant_group: Mapped["FileVariantGroup | None"] = relationship(back_populates="files")
     # Tags (#1268). M2M via library_file_tags. Loaded explicitly via
     # ``selectinload`` in list_files so each row in the listing carries its
     # chip set without N+1 fetches.

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

@@ -165,6 +165,82 @@ class PrintQueueItem(Base):
     project: Mapped["Project | None"] = relationship(back_populates="queue_items")
     batch: Mapped["PrintBatch | None"] = relationship(back_populates="queue_items")
     created_by: Mapped["User | None"] = relationship()
+    variants: Mapped[list["PrintQueueVariant"]] = relationship(
+        back_populates="queue_item",
+        cascade="all, delete-orphan",
+        order_by="PrintQueueVariant.position",
+    )
+
+
+class PrintQueueVariant(Base):
+    """One candidate file for a queue item that may print on several models (#671).
+
+    A user with an H2S and an H2C slices the same job twice and does not care
+    which machine runs it. Each slice becomes a variant; the scheduler walks them
+    in ``position`` order and takes the first whose model has an idle printer.
+
+    **This is a snapshot, not a pointer.** The candidate list is copied from the
+    library's variant group when the item is queued, and every per-file setting
+    the dispatcher needs is copied with it. Two reasons:
+
+    - Editing the library group afterwards must not silently change a job that is
+      already waiting in the queue.
+    - The per-file settings genuinely differ between candidates and are choices
+      the user made for *this* job, not properties of the file. An H2C slice is
+      dual-nozzle and will not have the same slot count, AMS mapping or nozzle
+      mapping as the H2S slice of the same model.
+
+    On a match the winning variant's fields are written onto the queue row before
+    the dispatch commit, so everything downstream — upload, archive creation,
+    print history, reprint — sees an ordinary single-file item and needs no
+    knowledge that variants exist.
+
+    Variants reference library files only. An archive records a print that already
+    happened, of one specific file, so it is never a candidate for "which of these
+    should we run".
+    """
+
+    __tablename__ = "print_queue_variants"
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    queue_item_id: Mapped[int] = mapped_column(
+        ForeignKey("print_queue.id", ondelete="CASCADE"), nullable=False, index=True
+    )
+    # User's priority order. When two printers are idle in the same scheduler
+    # pass, the lowest position wins — so the choice is reproducible instead of
+    # depending on which match the matcher happened to find first.
+    position: Mapped[int] = mapped_column(Integer, default=0)
+
+    # CASCADE: deleting the file drops this candidate but leaves the item and its
+    # other candidates alone. Losing the *last* candidate is handled by the
+    # resolver, which holds the item pending with an explicit waiting_reason
+    # rather than letting it sit there looking dispatchable forever.
+    library_file_id: Mapped[int] = mapped_column(ForeignKey("library_files.id", ondelete="CASCADE"), nullable=False)
+    # Normalized short name ("H2S"), taken from the file's own sliced_for_model
+    # at creation, or picked by the user for a legacy file that declares none.
+    target_model: Mapped[str] = mapped_column(String(50), nullable=False)
+
+    # Per-file dispatch settings, same semantics as the identically named columns
+    # on PrintQueueItem — see there for the formats.
+    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)
+    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)
+
+    # How many times this candidate has been dispatched and bounced back to
+    # pending by the start-watchdog. The resolver tries least-attempted first, so
+    # a printer that accepts the file and never starts (#1678) hands the job to
+    # the other machine on the next lap instead of burning the item's whole
+    # DISPATCH_MAX_ATTEMPTS budget against the same wedged printer — which is the
+    # entire reason the user queued an alternative.
+    attempt_count: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
+
+    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
+
+    queue_item: Mapped["PrintQueueItem"] = relationship(back_populates="variants")
+    library_file: Mapped["LibraryFile"] = relationship()
 
 
 from backend.app.models.archive import PrintArchive  # noqa: E402

+ 62 - 0
backend/app/schemas/library.py

@@ -220,6 +220,13 @@ class FileListResponse(BaseModel):
     # never null, so the FE can iterate without a guard.
     tags: list[TagSummary] = []
 
+    # Variant grouping (#671 / #2570). ``variant_count`` is the size of the whole
+    # group, not of the current listing — members can live in different folders,
+    # so counting the rows on screen would under-report. Projected in the list
+    # query so the badge and the smart-print decision cost no extra request.
+    variant_group_id: int | None = None
+    variant_count: int = 0
+
     class Config:
         from_attributes = True
 
@@ -397,3 +404,58 @@ class BatchThumbnailResponse(BaseModel):
     succeeded: int
     failed: int
     results: list[BatchThumbnailResult]
+
+
+# ============ Variant Group Schemas (#671 / #2570) ============
+
+
+class VariantGroupMemberRequest(BaseModel):
+    """One file joining a variant group.
+
+    ``target_model`` is optional and normally omitted — it is read from the
+    file's own ``sliced_for_model``. Supply it only for a legacy 3MF that
+    declares no model, where there is nothing else to go on.
+    """
+
+    library_file_id: int
+    target_model: str | None = Field(None, max_length=50)
+
+
+class VariantGroupCreate(BaseModel):
+    """Declare that these files are the same job sliced for different printers.
+
+    Order is significant: it is the priority used when more than one printer is
+    idle at the same moment. Two members minimum — a group of one expresses no
+    choice.
+    """
+
+    members: list[VariantGroupMemberRequest] = Field(..., min_length=2)
+    name: str | None = Field(None, max_length=255)
+
+
+class VariantGroupUpdate(BaseModel):
+    """Rename a group and/or re-order its members.
+
+    ``member_file_ids`` must list exactly the group's current members; a partial
+    list is rejected rather than guessing where the omitted ones belong.
+    """
+
+    name: str | None = Field(None, max_length=255)
+    member_file_ids: list[int] | None = None
+
+
+class VariantGroupMemberResponse(BaseModel):
+    """A file within a group, with the model it will be dispatched to."""
+
+    library_file_id: int
+    filename: str
+    target_model: str
+    position: int
+
+
+class VariantGroupResponse(BaseModel):
+    """A variant group and its members, in priority order."""
+
+    id: int
+    name: str
+    members: list[VariantGroupMemberResponse]

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

@@ -44,6 +44,25 @@ def _coerce_tristate(v: object) -> object:
 TriState = Annotated[Literal["off", "on", "auto"], BeforeValidator(_coerce_tristate)]
 
 
+class QueueVariantCreate(BaseModel):
+    """One candidate file for a cross-model queue item (#671).
+
+    Per-file rather than per-item because the settings genuinely differ between
+    candidates: an H2C slice is dual-nozzle and will not share slot count, AMS
+    mapping or nozzle mapping with the H2S slice of the same model.
+
+    ``target_model`` is normally omitted and read from the file's own
+    ``sliced_for_model``; supply it only for a legacy 3MF that declares none.
+    """
+
+    library_file_id: int
+    target_model: str | None = None
+    plate_id: int | None = None
+    ams_mapping: list[int] | None = None
+    nozzle_mapping: list[int] | None = None
+    filament_overrides: list[dict] | None = None
+
+
 class PrintQueueItemCreate(BaseModel):
     printer_id: int | None = None  # None = unassigned, user assigns later
     target_model: str | None = None  # Target printer model (mutually exclusive with printer_id)
@@ -98,6 +117,12 @@ class PrintQueueItemCreate(BaseModel):
     # Direct printer-card uploads are temporary library files. The scheduler
     # deletes them after creating the durable archive copy.
     cleanup_library_after_dispatch: bool = False
+    # Cross-model alternatives (#671): several sliced files, one job, whichever
+    # printer frees up first. Mutually exclusive with printer_id (a specific
+    # printer defeats the purpose) and with archive_id/library_file_id (the
+    # candidates ARE the files). The scheduler resolves one onto the row at
+    # dispatch, after which the item is an ordinary single-file job.
+    variants: list[QueueVariantCreate] | None = None
 
 
 class PrintQueueItemUpdate(BaseModel):
@@ -130,6 +155,15 @@ class PrintQueueItemUpdate(BaseModel):
     nozzle_mapping: list[int] | None = None
 
 
+class QueueVariantSummary(BaseModel):
+    """One candidate on a cross-model queue item, for display (#671)."""
+
+    library_file_id: int
+    filename: str
+    target_model: str
+    position: int
+
+
 class PrintQueueItemResponse(BaseModel):
     id: int
     printer_id: int | None  # None = unassigned
@@ -211,6 +245,11 @@ class PrintQueueItemResponse(BaseModel):
     batch_id: int | None = None
     batch_name: str | None = None
 
+    # Cross-model alternatives (#671), in priority order. Empty for every
+    # ordinary item. Present until dispatch resolves one onto the row, after
+    # which library_file_id / target_model name the candidate that actually ran.
+    variants: list[QueueVariantSummary] = []
+
     # Shortest-job-first scheduling
     been_jumped: bool = False
 

+ 21 - 0
backend/app/schemas/slicer.py

@@ -119,6 +119,27 @@ class SliceRequest(BaseModel):
             "process preset unchanged (#1337)."
         ),
     )
+    auto_orient: bool = Field(
+        default=False,
+        description=(
+            "Let the slicer pick each object's orientation before slicing "
+            "(BambuStudio / OrcaSlicer ``--orient 1``, the GUI's 'Auto orient'). "
+            "Off by default: it rotates geometry, so a model the designer laid "
+            "flat on purpose would silently change. Applies on the embedded-"
+            "settings path too — it is a CLI action, not a profile value (#2548)."
+        ),
+    )
+    auto_arrange: bool = Field(
+        default=False,
+        description=(
+            "Let the slicer lay the objects out on the plate before slicing "
+            "(``--arrange 1``, the GUI's 'Auto arrange'). Off by default: it "
+            "repositions objects, discarding a deliberate layout. Forced on "
+            "regardless for cross-nozzle-class re-slices, where the source's "
+            "coordinates land in the target's dead zone (#1493). Applies on the "
+            "embedded-settings path too (#2548)."
+        ),
+    )
 
     @model_validator(mode="after")
     def normalise_preset_refs(self) -> "SliceRequest":

+ 19 - 0
backend/app/services/library_trash.py

@@ -27,6 +27,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
 from backend.app.core.config import settings as app_settings
 from backend.app.core.database import async_session
 from backend.app.models.library import LibraryFile
+from backend.app.models.print_queue import PrintQueueVariant
 from backend.app.models.settings import Settings
 
 logger = logging.getLogger(__name__)
@@ -351,6 +352,7 @@ class LibraryTrashService:
         for row in rows:
             self._unlink_on_disk(row)
             deleted += 1
+        await delete_dependent_variants(db, [r.id for r in rows])
         # Single DELETE is faster than N await db.delete() round-trips; we
         # still need the Python loop above to unlink bytes on disk.
         await db.execute(delete(LibraryFile).where(LibraryFile.id.in_([r.id for r in rows])))
@@ -383,8 +385,25 @@ class LibraryTrashService:
     async def hard_delete_now(self, db: AsyncSession, file: LibraryFile) -> None:
         """Bypass retention and delete this trashed file + its bytes immediately."""
         self._unlink_on_disk(file)
+        await delete_dependent_variants(db, [file.id])
         await db.delete(file)
         await db.commit()
 
 
+async def delete_dependent_variants(db: AsyncSession, file_ids: list[int]) -> None:
+    """Drop cross-model queue candidates that pointed at these files (#671).
+
+    SQLite ships with ``PRAGMA foreign_keys`` off — verified, not assumed — so
+    the ON DELETE CASCADE on ``print_queue_variants.library_file_id`` never fires
+    on the default deployment and the rows would outlive the file.
+
+    The scheduler already refuses to dispatch a candidate whose file is missing
+    or trashed, so nothing prints wrongly without this. It is here so the table
+    does not fill with rows referencing files that no longer exist.
+    """
+    if not file_ids:
+        return
+    await db.execute(delete(PrintQueueVariant).where(PrintQueueVariant.library_file_id.in_(file_ids)))
+
+
 library_trash_service = LibraryTrashService()

+ 284 - 47
backend/app/services/print_scheduler.py

@@ -4,6 +4,7 @@ import asyncio
 import json
 import logging
 import time
+from dataclasses import dataclass
 from datetime import datetime, timezone
 from pathlib import Path
 
@@ -17,7 +18,7 @@ from backend.app.core.tasks import spawn_background_task
 from backend.app.core.websocket import ws_manager
 from backend.app.models.archive import PrintArchive
 from backend.app.models.library import LibraryFile
-from backend.app.models.print_queue import PrintQueueItem
+from backend.app.models.print_queue import PrintQueueItem, PrintQueueVariant
 from backend.app.models.printer import Printer
 from backend.app.models.settings import Settings
 from backend.app.models.smart_plug import SmartPlug
@@ -156,6 +157,135 @@ def _canonical_filament_type(ftype: str) -> str:
     return _FILAMENT_EQUIV_MAP.get(upper, upper)
 
 
+@dataclass(slots=True)
+class _ModelCandidate:
+    """One (file, printer model) pair the model-based matcher may try.
+
+    Model-based assignment used to have exactly one of these per item, held
+    directly in the item's own columns. Cross-model queue items (#671) have
+    several, held in ``print_queue_variants``. Both shapes are normalised into
+    this so the matching, the cross-model gate and the waiting-reason handling
+    are written once and an item without variants provably takes the same path
+    it took before variants existed.
+
+    ``variant`` is None for the item's own columns and set for a real variant
+    row, which is what :meth:`PrintScheduler._resolve_variant` writes onto the
+    item once that candidate wins.
+    """
+
+    target_model: str | None
+    sliced_for: str | None
+    required_filament_types: str | None
+    filament_overrides: str | None
+    variant: "PrintQueueVariant | None" = None
+
+
+def _sliced_for_model(archive, library_file) -> str | None:
+    """Model a 3MF declares it was sliced for, from whichever source holds it."""
+    if archive is not None:
+        return archive.sliced_for_model
+    if library_file is not None and library_file.file_metadata:
+        return library_file.file_metadata.get("sliced_for_model")
+    return None
+
+
+def _candidates_for(item: PrintQueueItem) -> list[_ModelCandidate]:
+    """Candidate files for ``item``, best first.
+
+    An item with no variant rows yields exactly one candidate built from its own
+    columns — the pre-#671 behaviour, unchanged.
+
+    Variants come back least-attempted first, ties broken by the user's
+    ``position``. On the first pass every count is zero, so this is purely the
+    user's priority order. After a start-watchdog bounce the printer that failed
+    drops behind, so the next lap tries the other machine rather than spending the
+    item's whole retry budget on the one that is wedged. Once every candidate has
+    been tried equally often they cycle again, which keeps the item-level
+    ``DISPATCH_MAX_ATTEMPTS`` bound from #2555 intact — a job with alternatives
+    still gives up, it just does not give up without trying them.
+    """
+    if not item.variants:
+        if not item.archive_id and not item.library_file_id:
+            # Nothing to print at all. Dispatching would fail deep in the upload
+            # on "No archive_id or library_file_id"; the caller holds the item
+            # with an explanation instead.
+            return []
+        return [
+            _ModelCandidate(
+                target_model=item.target_model,
+                sliced_for=_sliced_for_model(item.archive, item.library_file),
+                required_filament_types=item.required_filament_types,
+                filament_overrides=item.filament_overrides,
+            )
+        ]
+
+    # Drop candidates whose file is gone or in the trash. Both are reachable and
+    # neither is covered by the schema: library deletes are soft (the row lives
+    # on with ``deleted_at`` set, which no foreign key can express), and SQLite
+    # ships with ``PRAGMA foreign_keys`` off, so the ON DELETE CASCADE never
+    # fires there and a hard delete leaves the variant row pointing at nothing.
+    usable = [v for v in item.variants if v.library_file is not None and v.library_file.deleted_at is None]
+
+    ordered = sorted(usable, key=lambda v: (v.attempt_count or 0, v.position, v.id))
+    return [
+        _ModelCandidate(
+            target_model=v.target_model,
+            sliced_for=_sliced_for_model(None, v.library_file),
+            required_filament_types=v.required_filament_types,
+            filament_overrides=v.filament_overrides,
+            variant=v,
+        )
+        for v in ordered
+    ]
+
+
+def _collapse_waiting_reasons(per_model: list[tuple[str | None, str]]) -> str | None:
+    """Fold one waiting reason per candidate into a single line for the item.
+
+    A cross-model item produces a reason per candidate, and pasting them
+    together unlabelled reads as gibberish ("No idle printer; PETG not loaded"
+    — on which machine?). Each reason is prefixed with its model, except in the
+    single-candidate case where the item already displays its target model and
+    the prefix would be noise.
+
+    Identical reasons collapse rather than repeat, so three idle-less models
+    read as one clause.
+
+    When *every* candidate is merely busy the parts are joined with the ``" | "``
+    separator :meth:`PrintScheduler._is_busy_only` already parses, and left
+    unprefixed. That case must keep testing busy-only: a fleet that is simply
+    printing needs no user action, and labelling the clauses would turn each pass
+    over a two-model item into a "job waiting" notification.
+    """
+    reasons = [(model, reason) for model, reason in per_model if reason]
+    if not reasons:
+        return None
+    if len(reasons) == 1:
+        return reasons[0][1]
+
+    distinct = list(dict.fromkeys(reason for _model, reason in reasons))
+    if len(distinct) == 1:
+        return distinct[0]
+
+    if all(PrintScheduler._is_busy_only(reason) for _model, reason in reasons):
+        return " | ".join(distinct)
+
+    return "; ".join(f"{model or 'unassigned'}: {reason}" for model, reason in reasons)
+
+
+def _candidate_model_label(candidates: list[_ModelCandidate]) -> str | None:
+    """Human label for the models an item is waiting on ("H2S or H2C").
+
+    Notifications take a single target model. For a cross-model item the item's
+    own ``target_model`` is whichever variant happens to be first, which reads as
+    a lie once it is the H2C that actually runs — so name all of them.
+    """
+    models = list(dict.fromkeys(c.target_model for c in candidates if c.target_model))
+    if not models:
+        return None
+    return " or ".join(models)
+
+
 def _mapping_is_all_unresolved(mapping: list | None) -> bool:
     """True if ``mapping`` is a non-empty list whose every entry is the
     unresolved sentinel (-1 / None) — i.e. no required slot ever matched a tray.
@@ -404,6 +534,10 @@ class PrintScheduler:
                     .options(
                         selectinload(PrintQueueItem.archive),
                         selectinload(PrintQueueItem.library_file),
+                        # Cross-model candidates (#671), plus each candidate's file
+                        # for the same cross-model gate. Lazy-loading either would
+                        # raise in async.
+                        selectinload(PrintQueueItem.variants).selectinload(PrintQueueVariant.library_file),
                     )
                     .order_by(
                         PrintQueueItem.printer_id,
@@ -422,6 +556,10 @@ class PrintScheduler:
                     .options(
                         selectinload(PrintQueueItem.archive),
                         selectinload(PrintQueueItem.library_file),
+                        # Cross-model candidates (#671), plus each candidate's file
+                        # for the same cross-model gate. Lazy-loading either would
+                        # raise in async.
+                        selectinload(PrintQueueItem.variants).selectinload(PrintQueueVariant.library_file),
                     )
                     .order_by(PrintQueueItem.printer_id, PrintQueueItem.position)
                 )
@@ -684,60 +822,92 @@ class PrintScheduler:
                                 other.been_jumped = True
                         await db.commit()
 
-                elif item.target_model:
-                    # Model-based assignment - find any idle printer of matching model
-                    # Parse required filament types if present
-                    required_types = None
-                    if item.required_filament_types:
-                        try:
-                            required_types = json.loads(item.required_filament_types)
-                        except json.JSONDecodeError:
-                            pass  # Ignore malformed filament types; treat as no constraint
+                elif item.target_model or item.variants:
+                    # Model-based assignment - find any idle printer of matching model.
+                    # A plain model-based item has exactly one candidate, built from
+                    # its own columns. A cross-model item (#671) has one per sliced
+                    # variant and takes the first that matches, walking them in the
+                    # user's priority order so the pick is reproducible when more
+                    # than one printer is free in the same pass.
+                    candidates = _candidates_for(item)
+                    printer_id = None
+                    chosen: _ModelCandidate | None = None
+                    per_model_reasons: list[tuple[str | None, str]] = []
+
+                    if not candidates:
+                        # Every candidate file has been deleted or trashed out from
+                        # under this item. Hold it with something the user can act
+                        # on rather than letting it look dispatchable forever.
+                        per_model_reasons.append(
+                            (
+                                item.target_model,
+                                "Every file for this job has been deleted — add a file back or remove the item",
+                            )
+                        )
 
-                    # Parse filament overrides if present
-                    filament_overrides = None
-                    if item.filament_overrides:
-                        try:
-                            filament_overrides = json.loads(item.filament_overrides)
-                        except json.JSONDecodeError:
-                            pass
+                    for candidate in candidates:
+                        # Parse required filament types if present
+                        required_types = None
+                        if candidate.required_filament_types:
+                            try:
+                                required_types = json.loads(candidate.required_filament_types)
+                            except json.JSONDecodeError:
+                                pass  # Ignore malformed filament types; treat as no constraint
+
+                        # Parse filament overrides if present
+                        filament_overrides = None
+                        if candidate.filament_overrides:
+                            try:
+                                filament_overrides = json.loads(candidate.filament_overrides)
+                            except json.JSONDecodeError:
+                                pass
+
+                        # If overrides exist, use override types for validation instead
+                        effective_types = required_types
+                        if filament_overrides:
+                            override_types = sorted({o["type"] for o in filament_overrides if "type" in o})
+                            if override_types:
+                                # Merge: keep original types for non-overridden slots, add override types
+                                effective_types = sorted(set(required_types or []) | set(override_types))
+
+                        # Cross-model safety gate (#2578): never hand a 3MF sliced
+                        # for an incompatible model to a printer, no matter how the
+                        # row got into the DB (old rows, direct API writes). Held
+                        # as pending with an actionable waiting_reason — the user
+                        # fixes it by editing the item's target model.
+                        if not is_gcode_compatible(candidate.sliced_for, candidate.target_model):
+                            per_model_reasons.append(
+                                (
+                                    candidate.target_model,
+                                    f"File was sliced for {candidate.sliced_for}, which is not compatible with "
+                                    f"{candidate.target_model} — edit the item and fix its target model",
+                                )
+                            )
+                            skip_reasons["sliced_model_mismatch"] = skip_reasons.get("sliced_model_mismatch", 0) + 1
+                            continue
 
-                    # If overrides exist, use override types for validation instead
-                    effective_types = required_types
-                    if filament_overrides:
-                        override_types = sorted({o["type"] for o in filament_overrides if "type" in o})
-                        if override_types:
-                            # Merge: keep original types for non-overridden slots, add override types
-                            effective_types = sorted(set(required_types or []) | set(override_types))
-
-                    # Cross-model safety gate (#2578): never hand a 3MF sliced
-                    # for an incompatible model to a printer, no matter how the
-                    # row got into the DB (old rows, direct API writes). Held
-                    # as pending with an actionable waiting_reason — the user
-                    # fixes it by editing the item's target model.
-                    sliced_for = None
-                    if item.archive:
-                        sliced_for = item.archive.sliced_for_model
-                    elif item.library_file and item.library_file.file_metadata:
-                        sliced_for = item.library_file.file_metadata.get("sliced_for_model")
-
-                    if not is_gcode_compatible(sliced_for, item.target_model):
-                        printer_id = None
-                        waiting_reason = (
-                            f"File was sliced for {sliced_for}, which is not compatible with "
-                            f"{item.target_model} — edit the item and fix its target model"
-                        )
-                        skip_reasons["sliced_model_mismatch"] = skip_reasons.get("sliced_model_mismatch", 0) + 1
-                    else:
-                        printer_id, waiting_reason = await self._find_idle_printer_for_model(
+                        match_id, match_reason = await self._find_idle_printer_for_model(
                             db,
-                            item.target_model,
+                            candidate.target_model,
                             busy_printers,
                             effective_types,
                             item.target_location,
                             filament_overrides=filament_overrides,
                             require_plate_clear=require_plate_clear,
                         )
+                        if match_id:
+                            printer_id = match_id
+                            chosen = candidate
+                            break
+                        per_model_reasons.append((candidate.target_model, match_reason or ""))
+
+                    waiting_reason = None if printer_id else _collapse_waiting_reasons(per_model_reasons)
+
+                    # Fold the winning variant's file and settings onto the item
+                    # before anything else looks at them — the guards below and
+                    # every step of the dispatch read the item's own columns.
+                    if chosen is not None:
+                        self._resolve_variant(item, chosen)
 
                     # Update waiting_reason if changed and send notification when first waiting
                     if item.waiting_reason != waiting_reason:
@@ -751,7 +921,7 @@ class PrintScheduler:
                             job_name = await self._get_job_name(db, item)
                             await notification_service.on_queue_job_waiting(
                                 job_name=job_name,
-                                target_model=item.target_model,
+                                target_model=_candidate_model_label(candidates) or item.target_model,
                                 waiting_reason=waiting_reason,
                                 db=db,
                             )
@@ -1387,6 +1557,46 @@ class PrintScheduler:
                 matches += 1
         return matches
 
+    def _resolve_variant(self, item: PrintQueueItem, candidate: _ModelCandidate) -> None:
+        """Fold the winning candidate's file and settings onto the queue row (#671).
+
+        This is the whole trick that keeps cross-model items cheap: the many-to-many
+        never escapes the selection loop. By the time the pass commits, the row
+        looks exactly like an ordinary single-file model-based item, so the upload,
+        archive creation, expected-print registration, print history and reprint
+        paths need no knowledge that variants exist.
+
+        No-ops for a non-variant candidate, which is already the item's own columns.
+
+        Safe to run and re-run: the item's file columns are only ever *read* when it
+        has no variants, so an item that gets resolved and then skipped (library-row
+        conflict, previous-print gate) is simply resolved again on the next pass.
+        """
+        variant = candidate.variant
+        if variant is None:
+            return
+
+        item.library_file_id = variant.library_file_id
+        item.library_file = variant.library_file
+        # The dispatcher checks archive_id first and would print that instead of
+        # the file we just picked. Creation refuses to combine the two, so this
+        # only ever fires on a hand-written row — clear it rather than silently
+        # dispatch something the matcher never considered.
+        item.archive_id = None
+        item.archive = None
+
+        item.target_model = variant.target_model
+        item.plate_id = variant.plate_id
+        item.ams_mapping = variant.ams_mapping
+        item.nozzle_mapping = variant.nozzle_mapping
+        item.filament_overrides = variant.filament_overrides
+        item.required_filament_types = variant.required_filament_types
+        if variant.print_time_seconds is not None:
+            # The row carried the shortest candidate's estimate so SJF could order
+            # it before a printer was known; now that one is chosen, record what is
+            # actually going to run so history and the ETA agree with reality.
+            item.print_time_seconds = variant.print_time_seconds
+
     async def _ensure_ams_mapping(self, db: AsyncSession, printer_id: int, item: PrintQueueItem) -> None:
         """Ensure the queue item carries a usable AMS mapping before dispatch.
 
@@ -2975,6 +3185,22 @@ class PrintScheduler:
             library_file = result.scalar_one_or_none()
             if library_file:
                 return library_file.filename.replace(".gcode.3mf", "").replace(".3mf", "")
+        # A cross-model item (#671) holds no file of its own until a printer is
+        # picked, so name it after its first candidate — otherwise every waiting
+        # notification for one reads "Job #12". Queried rather than read off
+        # item.variants because callers outside the selection loop have not
+        # eager-loaded them, and a lazy load raises in async.
+        first_variant_name = (
+            await db.execute(
+                select(LibraryFile.filename)
+                .join(PrintQueueVariant, PrintQueueVariant.library_file_id == LibraryFile.id)
+                .where(PrintQueueVariant.queue_item_id == item.id)
+                .order_by(PrintQueueVariant.position, PrintQueueVariant.id)
+                .limit(1)
+            )
+        ).scalar_one_or_none()
+        if first_variant_name:
+            return first_variant_name.replace(".gcode.3mf", "").replace(".3mf", "")
         return f"Job #{item.id}"
 
     async def _get_printer(self, db: AsyncSession, printer_id: int) -> Printer | None:
@@ -3996,6 +4222,17 @@ class PrintScheduler:
                 return "already_moved_on"
             item.dispatch_attempts = (item.dispatch_attempts or 0) + 1
             item.started_at = None
+            # Charge the attempt to the candidate that was actually dispatched, so
+            # a cross-model item (#671) reaches for its other file next lap instead
+            # of retrying the printer that just failed to start. Matched by file
+            # because that is what the resolver copied onto the row.
+            if item.library_file_id is not None:
+                await db.execute(
+                    update(PrintQueueVariant)
+                    .where(PrintQueueVariant.queue_item_id == item.id)
+                    .where(PrintQueueVariant.library_file_id == item.library_file_id)
+                    .values(attempt_count=PrintQueueVariant.attempt_count + 1)
+                )
             if command_rejected:
                 # No retry budget for this one: the printer refused to verify the
                 # command, and re-uploading the same 3MF to the same printer will

+ 39 - 6
backend/app/services/slicer_api.py

@@ -471,6 +471,7 @@ class SlicerApiService:
         plate: int | None = None,
         export_3mf: bool = False,
         arrange: bool = False,
+        orient: bool = False,
         request_id: str | None = None,
         on_progress: Callable[[dict], None] | None = None,
     ) -> SliceResult:
@@ -489,7 +490,15 @@ class SlicerApiService:
         the source's X1C-coordinate layout would otherwise drop into an H2D
         dead zone or trigger the multi-extruder geometry pipeline's polygon
         clipping crash. Default off so single-printer slices preserve the
-        user's deliberate layout.
+        user's deliberate layout. Also settable per-slice by the user
+        (#2548).
+
+        ``orient`` forwards ``--orient``, the CLI's auto-orientation pass:
+        the slicer scores candidate rotations (overhang area, contour,
+        unprintability) and rotates each object onto the best one before
+        slicing. User-driven only — nothing in Bambuddy turns it on by
+        itself, since rotating a deliberately-laid-out model is not a
+        change to make silently.
 
         ``request_id``: when supplied, the sidecar wires --pipe to a
         per-request FIFO and publishes structured JSON progress events to
@@ -522,11 +531,7 @@ class SlicerApiService:
             data["plate"] = str(plate)
         if export_3mf:
             data["exportType"] = "3mf"
-        if arrange:
-            # Sidecar reads non-empty truthy strings as True; only send the
-            # field when we want the flag on, so default-off callers exactly
-            # match the previous wire payload.
-            data["arrange"] = "true"
+        _add_layout_flags(data, arrange=arrange, orient=orient)
         if request_id is not None:
             data["requestId"] = request_id
 
@@ -545,6 +550,8 @@ class SlicerApiService:
         model_filename: str,
         plate: int | None = None,
         export_3mf: bool = False,
+        arrange: bool = False,
+        orient: bool = False,
         request_id: str | None = None,
         on_progress: Callable[[dict], None] | None = None,
     ) -> SliceResult:
@@ -563,6 +570,14 @@ class SlicerApiService:
         events to the ProgressStore so the modal's inline spinner +
         toast can show "Generating G-code (75%)" for that preview as
         well.
+
+        ``arrange`` / ``orient`` mean the same as on
+        ``slice_with_profiles``: they are CLI actions applied to the loaded
+        geometry, independent of where the print config came from. Both
+        paths accept them so a user's per-slice choice survives the
+        embedded-settings route and the segfault fallback — the filament-
+        discovery preview leaves them off, since moving objects there
+        would change nothing about which slots the plate consumes.
         """
         files = {
             "file": (model_filename, model_bytes, _guess_model_content_type(model_filename)),
@@ -572,6 +587,7 @@ class SlicerApiService:
             data["plate"] = str(plate)
         if export_3mf:
             data["exportType"] = "3mf"
+        _add_layout_flags(data, arrange=arrange, orient=orient)
         if request_id is not None:
             data["requestId"] = request_id
 
@@ -584,6 +600,23 @@ class SlicerApiService:
         return _handle_slice_response(response, export_3mf=export_3mf)
 
 
+def _add_layout_flags(data: dict[str, str], *, arrange: bool, orient: bool) -> None:
+    """Set the sidecar's ``arrange`` / ``orient`` form fields, but only when on.
+
+    The sidecar branches on ``settings.arrange !== undefined`` and forwards
+    ``--arrange 1`` / ``--arrange 0`` accordingly — but multipart fields
+    arrive as *strings*, and ``"false"`` is truthy in JavaScript. Sending
+    ``"false"`` would therefore turn the flag ON. So an off flag is
+    expressed by omitting the field entirely, which also keeps the wire
+    payload of default-off callers byte-identical to before these
+    parameters existed.
+    """
+    if arrange:
+        data["arrange"] = "true"
+    if orient:
+        data["orient"] = "true"
+
+
 def _safe_int(value: str | None) -> int:
     if not value:
         return 0

+ 354 - 0
backend/tests/integration/test_library_slice_api.py

@@ -334,6 +334,82 @@ class TestSliceLibraryFile:
             "bed_type must stay out of the process JSON when no override is set"
         )
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_auto_orient_and_arrange_reach_the_sidecar(self, async_client: AsyncClient, slice_test_setup):
+        """#2548: the two layout passes are per-slice options, so ticking
+        them in the SliceModal has to come out the other end as the
+        sidecar's ``orient`` / ``arrange`` form fields. Before this the
+        flags existed on the wire but only #1493's cross-class detector
+        could set arrange, and nothing at all could set orient."""
+        captured: dict = {}
+
+        def handler(request: httpx.Request) -> httpx.Response:
+            captured["body"] = bytes(request.content)
+            return httpx.Response(
+                status_code=200,
+                content=_make_3mf_with_settings(),
+                headers={
+                    "x-print-time-seconds": "10",
+                    "x-filament-used-g": "0.1",
+                    "x-filament-used-mm": "1.0",
+                },
+            )
+
+        _install_mock_sidecar(handler)
+        response = await async_client.post(
+            f"/api/v1/library/files/{slice_test_setup['src_file_id']}/slice",
+            json={
+                "printer_preset_id": slice_test_setup["printer_id"],
+                "process_preset_id": slice_test_setup["process_id"],
+                "filament_preset_id": slice_test_setup["filament_id"],
+                "auto_orient": True,
+                "auto_arrange": True,
+            },
+        )
+        assert response.status_code == 202
+        final = await _wait_for_job(async_client, response.json()["job_id"])
+        assert final["status"] == "completed", final
+
+        assert b'name="orient"' in captured["body"]
+        assert b'name="arrange"' in captured["body"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_layout_flags_absent_by_default(self, async_client: AsyncClient, slice_test_setup):
+        """Companion to the above. Both default to off, and off is expressed
+        by omitting the field — the sidecar reads any present value as
+        truthy, so a "false" on the wire would auto-arrange every slice."""
+        captured: dict = {}
+
+        def handler(request: httpx.Request) -> httpx.Response:
+            captured["body"] = bytes(request.content)
+            return httpx.Response(
+                status_code=200,
+                content=_make_3mf_with_settings(),
+                headers={
+                    "x-print-time-seconds": "10",
+                    "x-filament-used-g": "0.1",
+                    "x-filament-used-mm": "1.0",
+                },
+            )
+
+        _install_mock_sidecar(handler)
+        response = await async_client.post(
+            f"/api/v1/library/files/{slice_test_setup['src_file_id']}/slice",
+            json={
+                "printer_preset_id": slice_test_setup["printer_id"],
+                "process_preset_id": slice_test_setup["process_id"],
+                "filament_preset_id": slice_test_setup["filament_id"],
+            },
+        )
+        assert response.status_code == 202
+        final = await _wait_for_job(async_client, response.json()["job_id"])
+        assert final["status"] == "completed", final
+
+        assert b'name="orient"' not in captured["body"]
+        assert b'name="arrange"' not in captured["body"]
+
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_invalid_preset_id_surfaces_as_failed_job_with_status_400(
@@ -864,6 +940,284 @@ class TestCrossClassSliceAllLoop:
         assert new_archive.print_time_seconds == 600 * 3
         assert new_archive.filament_used_grams == pytest.approx(5.0 * 3)
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_user_requested_arrange_also_loops_per_plate(
+        self, async_client: AsyncClient, db_session, slice_test_setup, printer_factory, archive_factory, monkeypatch
+    ):
+        """#2548 inherits #1493's hazard. The per-plate loop exists because
+        ``--arrange`` is project-wide: a single ``--slice 0 --arrange 1``
+        collapses every plate's objects onto one bed. That is a property of
+        the flag, not of the cross-class detour that first needed it — so a
+        user ticking auto-arrange over "all plates" on a SAME-class source
+        must take the same loop. Keying the loop on the cross-class decision
+        alone would send one call and silently return a one-plate result.
+        """
+        from backend.app.models.archive import PrintArchive
+
+        tmp_path = slice_test_setup["tmp_path"]
+        monkeypatch.setattr(app_settings, "archive_dir", tmp_path / "archive")
+
+        src_dir = tmp_path / "archives" / "src_same_class"
+        src_dir.mkdir(parents=True, exist_ok=True)
+        src_3mf = src_dir / "tray.3mf"
+        src_3mf.write_bytes(self._make_multi_plate_x1c_source(plate_count=2))
+        printer = await printer_factory()
+        source = await archive_factory(
+            printer.id,
+            filename="tray.3mf",
+            file_path=str(src_3mf.relative_to(tmp_path)),
+            sliced_for_model="X1C",
+            with_run=False,
+        )
+
+        # X1C target: same nozzle class as the X1C source, so #1493's
+        # detector stays off and only the user's flag is in play.
+        x1c = LocalPreset(
+            name="# Bambu Lab X1 Carbon 0.4 nozzle",
+            preset_type="printer",
+            source="orcaslicer",
+            setting=json.dumps({"name": "Bambu Lab X1 Carbon 0.4 nozzle", "printer_model": "Bambu Lab X1 Carbon"}),
+        )
+        db_session.add(x1c)
+        await db_session.commit()
+        await db_session.refresh(x1c)
+
+        captured_requests: list[dict] = []
+
+        def handler(request: httpx.Request) -> httpx.Response:
+            if not _is_slice_post(request):
+                return httpx.Response(404)
+            body = request.content
+            plate = None
+            marker = b'name="plate"\r\n\r\n'
+            idx = body.find(marker)
+            if idx != -1:
+                start = idx + len(marker)
+                end = body.find(b"\r\n", start)
+                try:
+                    plate = int(body[start:end].decode("utf-8"))
+                except (UnicodeDecodeError, ValueError):
+                    plate = None
+            captured_requests.append(
+                {
+                    "plate": plate,
+                    "arrange": b'name="arrange"' in body,
+                    "orient": b'name="orient"' in body,
+                }
+            )
+            return httpx.Response(
+                status_code=200,
+                content=self._make_single_plate_sliced_output(plate or 1),
+                headers={
+                    "x-print-time-seconds": "300",
+                    "x-filament-used-g": "2.0",
+                    "x-filament-used-mm": "800.0",
+                },
+            )
+
+        _install_mock_sidecar(handler)
+
+        resp = await async_client.post(
+            f"/api/v1/archives/{source.id}/slice",
+            json={
+                "printer_preset": {"source": "local", "id": str(x1c.id)},
+                "process_preset": {"source": "local", "id": str(slice_test_setup["process_id"])},
+                "filament_presets": [{"source": "local", "id": str(slice_test_setup["filament_id"])}],
+                "plate": 0,
+                "auto_arrange": True,
+                "auto_orient": True,
+            },
+        )
+        assert resp.status_code == 202, resp.text
+        final = await _wait_for_job(async_client, resp.json()["job_id"], timeout=15.0)
+        assert final["status"] == "completed", final
+
+        assert [c["plate"] for c in captured_requests] == [1, 2]
+        assert all(c["arrange"] for c in captured_requests)
+        # Orient rides along on every sub-slice too — it is per-object, so
+        # dropping it on the loop path would quietly ignore the user's tick.
+        assert all(c["orient"] for c in captured_requests)
+
+        new_archive = await db_session.get(PrintArchive, final["result"]["archive_id"])
+        with zipfile.ZipFile(tmp_path / new_archive.file_path, "r") as zf:
+            entries = set(zf.namelist())
+        assert "Metadata/plate_1.gcode" in entries
+        assert "Metadata/plate_2.gcode" in entries
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_embedded_settings_slice_all_with_arrange_still_loops(
+        self, async_client: AsyncClient, db_session, slice_test_setup, printer_factory, archive_factory, monkeypatch
+    ):
+        """ "Slice as designed" must not skip the loop. The project-wide
+        collapse comes from ``--arrange``; where the print config came from
+        has no bearing on it. Taking the single-call embedded branch here
+        would return one consolidated plate for a job the user asked to
+        slice as N — and the per-plate calls must still omit the profile
+        triplet, or "as designed" would silently stop meaning that.
+        """
+        from backend.app.models.archive import PrintArchive
+
+        tmp_path = slice_test_setup["tmp_path"]
+        monkeypatch.setattr(app_settings, "archive_dir", tmp_path / "archive")
+
+        src_dir = tmp_path / "archives" / "src_embedded"
+        src_dir.mkdir(parents=True, exist_ok=True)
+        src_3mf = src_dir / "kit.3mf"
+        src_3mf.write_bytes(self._make_multi_plate_x1c_source(plate_count=2))
+        printer = await printer_factory()
+        source = await archive_factory(
+            printer.id,
+            filename="kit.3mf",
+            file_path=str(src_3mf.relative_to(tmp_path)),
+            sliced_for_model="X1C",
+            with_run=False,
+        )
+
+        x1c = LocalPreset(
+            name="# Bambu Lab X1 Carbon 0.4 nozzle",
+            preset_type="printer",
+            source="orcaslicer",
+            setting=json.dumps({"name": "Bambu Lab X1 Carbon 0.4 nozzle", "printer_model": "Bambu Lab X1 Carbon"}),
+        )
+        db_session.add(x1c)
+        await db_session.commit()
+        await db_session.refresh(x1c)
+
+        captured_requests: list[dict] = []
+
+        def handler(request: httpx.Request) -> httpx.Response:
+            if not _is_slice_post(request):
+                return httpx.Response(404)
+            body = request.content
+            plate = None
+            marker = b'name="plate"\r\n\r\n'
+            idx = body.find(marker)
+            if idx != -1:
+                start = idx + len(marker)
+                end = body.find(b"\r\n", start)
+                try:
+                    plate = int(body[start:end].decode("utf-8"))
+                except (UnicodeDecodeError, ValueError):
+                    plate = None
+            captured_requests.append(
+                {
+                    "plate": plate,
+                    "arrange": b'name="arrange"' in body,
+                    "has_profiles": b'name="printerProfile"' in body,
+                }
+            )
+            return httpx.Response(
+                status_code=200,
+                content=self._make_single_plate_sliced_output(plate or 1),
+                headers={
+                    "x-print-time-seconds": "300",
+                    "x-filament-used-g": "2.0",
+                    "x-filament-used-mm": "800.0",
+                },
+            )
+
+        _install_mock_sidecar(handler)
+
+        resp = await async_client.post(
+            f"/api/v1/archives/{source.id}/slice",
+            json={
+                "printer_preset": {"source": "local", "id": str(x1c.id)},
+                "process_preset": {"source": "local", "id": str(slice_test_setup["process_id"])},
+                "filament_presets": [{"source": "local", "id": str(slice_test_setup["filament_id"])}],
+                "plate": 0,
+                "use_embedded_settings": True,
+                "auto_arrange": True,
+            },
+        )
+        assert resp.status_code == 202, resp.text
+        final = await _wait_for_job(async_client, resp.json()["job_id"], timeout=15.0)
+        assert final["status"] == "completed", final
+
+        assert [c["plate"] for c in captured_requests] == [1, 2]
+        assert all(c["arrange"] for c in captured_requests)
+        # No --load-settings on any sub-call: the file's own settings drive
+        # each plate, which is what "slice as designed" promises.
+        assert not any(c["has_profiles"] for c in captured_requests)
+        assert final["result"]["used_embedded_settings"] is True
+
+        new_archive = await db_session.get(PrintArchive, final["result"]["archive_id"])
+        with zipfile.ZipFile(tmp_path / new_archive.file_path, "r") as zf:
+            entries = set(zf.namelist())
+        assert "Metadata/plate_1.gcode" in entries
+        assert "Metadata/plate_2.gcode" in entries
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_cross_class_arrange_survives_user_leaving_the_box_unticked(
+        self, async_client: AsyncClient, db_session, slice_test_setup, printer_factory, archive_factory, monkeypatch
+    ):
+        """The user's per-slice choice is a union with #1493's decision, not
+        a replacement for it. Arrange is what keeps a class-crossing slice
+        from landing in the target's dead zone or segfaulting ZFiller — so
+        the default-false ``auto_arrange`` must not be able to turn it off.
+        """
+        tmp_path = slice_test_setup["tmp_path"]
+        monkeypatch.setattr(app_settings, "archive_dir", tmp_path / "archive")
+
+        src_dir = tmp_path / "archives" / "src_cross_single"
+        src_dir.mkdir(parents=True, exist_ok=True)
+        src_3mf = src_dir / "clip.3mf"
+        src_3mf.write_bytes(self._make_multi_plate_x1c_source(plate_count=1))
+        printer = await printer_factory()
+        source = await archive_factory(
+            printer.id,
+            filename="clip.3mf",
+            file_path=str(src_3mf.relative_to(tmp_path)),
+            sliced_for_model="X1C",
+            with_run=False,
+        )
+
+        h2d = LocalPreset(
+            name="# Bambu Lab H2D 0.4 nozzle",
+            preset_type="printer",
+            source="orcaslicer",
+            setting=json.dumps({"name": "Bambu Lab H2D 0.4 nozzle", "printer_model": "Bambu Lab H2D"}),
+        )
+        db_session.add(h2d)
+        await db_session.commit()
+        await db_session.refresh(h2d)
+
+        captured: dict = {}
+
+        def handler(request: httpx.Request) -> httpx.Response:
+            if not _is_slice_post(request):
+                return httpx.Response(404)
+            captured["body"] = bytes(request.content)
+            return httpx.Response(
+                status_code=200,
+                content=self._make_single_plate_sliced_output(1),
+                headers={
+                    "x-print-time-seconds": "300",
+                    "x-filament-used-g": "2.0",
+                    "x-filament-used-mm": "800.0",
+                },
+            )
+
+        _install_mock_sidecar(handler)
+
+        resp = await async_client.post(
+            f"/api/v1/archives/{source.id}/slice",
+            json={
+                "printer_preset": {"source": "local", "id": str(h2d.id)},
+                "process_preset": {"source": "local", "id": str(slice_test_setup["process_id"])},
+                "filament_presets": [{"source": "local", "id": str(slice_test_setup["filament_id"])}],
+                "plate": 1,
+                "auto_arrange": False,
+            },
+        )
+        assert resp.status_code == 202, resp.text
+        final = await _wait_for_job(async_client, resp.json()["job_id"], timeout=15.0)
+        assert final["status"] == "completed", final
+
+        assert b'name="arrange"' in captured["body"], "cross-class arrange must survive an explicit auto_arrange=false"
+
 
 class TestSliceArchiveResliceModel:
     """Re-slicing an archive for a different printer must stamp the new

+ 246 - 0
backend/tests/integration/test_library_variants_api.py

@@ -0,0 +1,246 @@
+"""Integration tests for variant groups (#671 / #2570).
+
+A variant group is the user declaring that several sliced files are the same
+job for different printers. The endpoints exist to enforce what that statement
+has to mean before the scheduler acts on it without a human in the loop.
+"""
+
+import pytest
+from httpx import AsyncClient
+
+
+@pytest.fixture
+async def sliced_file_factory(db_session):
+    """Create a sliced library file declaring the model it was sliced for."""
+    _counter = [0]
+
+    async def _create(model: str | None = "H2S", **kwargs):
+        from backend.app.models.library import LibraryFile
+
+        _counter[0] += 1
+        defaults = {
+            "filename": f"job_{_counter[0]}.gcode.3mf",
+            "file_path": f"/test/job_{_counter[0]}.gcode.3mf",
+            "file_size": 100,
+            "file_type": "gcode.3mf",
+            "file_metadata": {"sliced_for_model": model} if model else {},
+        }
+        defaults.update(kwargs)
+        f = LibraryFile(**defaults)
+        db_session.add(f)
+        await db_session.commit()
+        await db_session.refresh(f)
+        return f
+
+    return _create
+
+
+async def _create_group(client: AsyncClient, *file_ids: int, name: str | None = None):
+    payload = {"members": [{"library_file_id": fid} for fid in file_ids]}
+    if name:
+        payload["name"] = name
+    return await client.post("/api/v1/library/variant-groups", json=payload)
+
+
+class TestCreateVariantGroup:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_groups_two_slices_in_priority_order(self, async_client, sliced_file_factory):
+        h2s = await sliced_file_factory("H2S")
+        h2c = await sliced_file_factory("H2C")
+
+        r = await _create_group(async_client, h2s.id, h2c.id, name="bracket")
+        assert r.status_code == 201
+        body = r.json()
+        assert body["name"] == "bracket"
+        assert [m["target_model"] for m in body["members"]] == ["H2S", "H2C"]
+        assert [m["position"] for m in body["members"]] == [0, 1]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_model_is_read_from_the_file_not_the_caller(self, async_client, sliced_file_factory):
+        """The group never carries its own model data, so it cannot disagree with
+        the 3MFs. "Bambu Lab H2S" normalizes to the same H2S the scheduler matches."""
+        a = await sliced_file_factory("Bambu Lab H2S")
+        b = await sliced_file_factory("O1C")  # internal code for H2C
+
+        body = (await _create_group(async_client, a.id, b.id)).json()
+        assert [m["target_model"] for m in body["members"]] == ["H2S", "H2C"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_two_slices_for_the_same_printer_are_rejected(self, async_client, sliced_file_factory):
+        """Not alternatives — the resolver would have no basis to prefer one, and
+        the arbitrary pick would look like a bug the first time it chose wrong."""
+        a = await sliced_file_factory("H2S")
+        b = await sliced_file_factory("H2S")
+
+        r = await _create_group(async_client, a.id, b.id)
+        assert r.status_code == 400
+        assert "different printers" in r.json()["detail"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_normalization_catches_the_same_printer_spelled_differently(self, async_client, sliced_file_factory):
+        a = await sliced_file_factory("H2S")
+        b = await sliced_file_factory("Bambu Lab H2S")
+
+        r = await _create_group(async_client, a.id, b.id)
+        assert r.status_code == 400
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_unsliced_file_cannot_be_a_variant(self, async_client, sliced_file_factory):
+        """A source .3mf has no G-code — it can never be dispatched to anything."""
+        sliced = await sliced_file_factory("H2S")
+        source = await sliced_file_factory(None, filename="model.3mf", file_type="3mf")
+
+        r = await _create_group(async_client, sliced.id, source.id)
+        assert r.status_code == 400
+        assert "not a sliced file" in r.json()["detail"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_file_without_a_model_must_name_one(self, async_client, sliced_file_factory):
+        """Legacy 3MFs declare no model. Rather than guess, make the user say."""
+        known = await sliced_file_factory("H2S")
+        legacy = await sliced_file_factory(None)
+
+        r = await _create_group(async_client, known.id, legacy.id)
+        assert r.status_code == 400
+        assert "does not say which printer" in r.json()["detail"]
+
+        r = await async_client.post(
+            "/api/v1/library/variant-groups",
+            json={
+                "members": [
+                    {"library_file_id": known.id},
+                    {"library_file_id": legacy.id, "target_model": "H2C"},
+                ]
+            },
+        )
+        assert r.status_code == 201
+        assert [m["target_model"] for m in r.json()["members"]] == ["H2S", "H2C"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_a_file_belongs_to_one_group_only(self, async_client, sliced_file_factory):
+        a = await sliced_file_factory("H2S")
+        b = await sliced_file_factory("H2C")
+        c = await sliced_file_factory("H2D")
+        assert (await _create_group(async_client, a.id, b.id)).status_code == 201
+
+        r = await _create_group(async_client, a.id, c.id)
+        assert r.status_code == 409
+        assert "already belongs" in r.json()["detail"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_single_member_is_rejected_by_the_schema(self, async_client, sliced_file_factory):
+        only = await sliced_file_factory("H2S")
+        r = await _create_group(async_client, only.id)
+        assert r.status_code == 422, "a group of one expresses no choice"
+
+
+class TestVariantGroupMembership:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_add_version_to_an_existing_group(self, async_client, sliced_file_factory):
+        """The common real case: the H2S version was queued last week, the H2C
+        version was sliced today."""
+        a = await sliced_file_factory("H2S")
+        b = await sliced_file_factory("H2C")
+        c = await sliced_file_factory("H2D")
+        gid = (await _create_group(async_client, a.id, b.id)).json()["id"]
+
+        r = await async_client.post(f"/api/v1/library/variant-groups/{gid}/members", json={"library_file_id": c.id})
+        assert r.status_code == 200
+        assert [m["target_model"] for m in r.json()["members"]] == ["H2S", "H2C", "H2D"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_added_member_cannot_duplicate_a_model(self, async_client, sliced_file_factory):
+        a = await sliced_file_factory("H2S")
+        b = await sliced_file_factory("H2C")
+        dupe = await sliced_file_factory("H2C")
+        gid = (await _create_group(async_client, a.id, b.id)).json()["id"]
+
+        r = await async_client.post(f"/api/v1/library/variant-groups/{gid}/members", json={"library_file_id": dupe.id})
+        assert r.status_code == 400
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_removing_down_to_one_dissolves_the_group(self, async_client, sliced_file_factory):
+        """A leftover one-member group would look like a choice and behave like an
+        ordinary job — worse than no group at all."""
+        a = await sliced_file_factory("H2S")
+        b = await sliced_file_factory("H2C")
+        gid = (await _create_group(async_client, a.id, b.id)).json()["id"]
+
+        r = await async_client.delete(f"/api/v1/library/variant-groups/{gid}/members/{b.id}")
+        assert r.status_code == 204
+        assert (await async_client.get(f"/api/v1/library/variant-groups/{gid}")).status_code == 404
+        # ...and the survivor is still a perfectly good file.
+        assert (await async_client.get(f"/api/v1/library/variant-groups/by-file/{a.id}")).status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_removing_from_a_three_member_group_keeps_it(self, async_client, sliced_file_factory):
+        a = await sliced_file_factory("H2S")
+        b = await sliced_file_factory("H2C")
+        c = await sliced_file_factory("H2D")
+        gid = (await _create_group(async_client, a.id, b.id, c.id)).json()["id"]
+
+        assert (await async_client.delete(f"/api/v1/library/variant-groups/{gid}/members/{c.id}")).status_code == 204
+        body = (await async_client.get(f"/api/v1/library/variant-groups/{gid}")).json()
+        assert [m["library_file_id"] for m in body["members"]] == [a.id, b.id]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_deleting_a_group_keeps_the_files(self, async_client, sliced_file_factory):
+        a = await sliced_file_factory("H2S")
+        b = await sliced_file_factory("H2C")
+        gid = (await _create_group(async_client, a.id, b.id)).json()["id"]
+
+        assert (await async_client.delete(f"/api/v1/library/variant-groups/{gid}")).status_code == 204
+        for f in (a, b):
+            assert (await async_client.get(f"/api/v1/library/files/{f.id}")).status_code == 200
+
+
+class TestVariantGroupOrdering:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_reorder_changes_priority(self, async_client, sliced_file_factory):
+        """Order is the user saying which printer they would rather have when both
+        are free, so it has to be editable."""
+        a = await sliced_file_factory("H2S")
+        b = await sliced_file_factory("H2C")
+        gid = (await _create_group(async_client, a.id, b.id)).json()["id"]
+
+        r = await async_client.patch(f"/api/v1/library/variant-groups/{gid}", json={"member_file_ids": [b.id, a.id]})
+        assert r.status_code == 200
+        assert [m["target_model"] for m in r.json()["members"]] == ["H2C", "H2S"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_partial_reorder_is_rejected(self, async_client, sliced_file_factory):
+        """Listing a subset would leave the rest in an order nobody chose."""
+        a = await sliced_file_factory("H2S")
+        b = await sliced_file_factory("H2C")
+        gid = (await _create_group(async_client, a.id, b.id)).json()["id"]
+
+        r = await async_client.patch(f"/api/v1/library/variant-groups/{gid}", json={"member_file_ids": [a.id]})
+        assert r.status_code == 400
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_lookup_by_file(self, async_client, sliced_file_factory):
+        """Both consumers start from a file: the print modal knows what was
+        clicked, the queue flow knows what was selected."""
+        a = await sliced_file_factory("H2S")
+        b = await sliced_file_factory("H2C")
+        gid = (await _create_group(async_client, a.id, b.id)).json()["id"]
+
+        r = await async_client.get(f"/api/v1/library/variant-groups/by-file/{b.id}")
+        assert r.status_code == 200
+        assert r.json()["id"] == gid

+ 297 - 0
backend/tests/integration/test_queue_variants_api.py

@@ -0,0 +1,297 @@
+"""Queueing a job with cross-model alternatives (#671).
+
+One queue item, several sliced files, whichever printer frees up first. The
+create endpoint's job is to refuse candidate sets that cannot mean what the user
+intends, because after this point the scheduler dispatches to hardware with no
+human in the loop.
+"""
+
+import pytest
+from httpx import AsyncClient
+from sqlalchemy import select
+
+
+@pytest.fixture
+async def sliced_file_factory(db_session):
+    _counter = [0]
+
+    async def _create(model: str | None = "H2S", **kwargs):
+        from backend.app.models.library import LibraryFile
+
+        _counter[0] += 1
+        defaults = {
+            "filename": f"job_{_counter[0]}.gcode.3mf",
+            "file_path": f"/test/job_{_counter[0]}.gcode.3mf",
+            "file_size": 100,
+            "file_type": "gcode.3mf",
+            "file_metadata": {"sliced_for_model": model} if model else {},
+        }
+        defaults.update(kwargs)
+        f = LibraryFile(**defaults)
+        db_session.add(f)
+        await db_session.commit()
+        await db_session.refresh(f)
+        return f
+
+    return _create
+
+
+async def _queue_variants(client: AsyncClient, *file_ids: int, **extra):
+    payload = {"variants": [{"library_file_id": fid} for fid in file_ids]}
+    payload.update(extra)
+    return await client.post("/api/v1/queue/", json=payload)
+
+
+async def _variants_of(db_session, item_id: int):
+    from backend.app.models.print_queue import PrintQueueVariant
+
+    rows = (
+        (
+            await db_session.execute(
+                select(PrintQueueVariant)
+                .where(PrintQueueVariant.queue_item_id == item_id)
+                .order_by(PrintQueueVariant.position)
+            )
+        )
+        .scalars()
+        .all()
+    )
+    return rows
+
+
+class TestQueueWithVariants:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_creates_one_item_with_a_candidate_per_file(
+        self, async_client, db_session, sliced_file_factory, printer_factory
+    ):
+        await printer_factory(model="H2S")
+        await printer_factory(model="H2C")
+        h2s = await sliced_file_factory("H2S")
+        h2c = await sliced_file_factory("H2C")
+
+        r = await _queue_variants(async_client, h2s.id, h2c.id)
+        assert r.status_code == 200
+        item_id = r.json()["id"]
+
+        variants = await _variants_of(db_session, item_id)
+        assert [v.target_model for v in variants] == ["H2S", "H2C"]
+        assert [v.position for v in variants] == [0, 1]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_the_item_holds_no_file_of_its_own(
+        self, async_client, db_session, sliced_file_factory, printer_factory
+    ):
+        """library_file_id is ON DELETE CASCADE. Pointing it at one candidate
+        would mean deleting that single alternative destroys the whole job."""
+        from backend.app.models.print_queue import PrintQueueItem
+
+        await printer_factory(model="H2S")
+        await printer_factory(model="H2C")
+        h2s = await sliced_file_factory("H2S")
+        h2c = await sliced_file_factory("H2C")
+
+        item_id = (await _queue_variants(async_client, h2s.id, h2c.id)).json()["id"]
+        item = (await db_session.execute(select(PrintQueueItem).where(PrintQueueItem.id == item_id))).scalar_one()
+        assert item.library_file_id is None
+        assert item.archive_id is None
+        assert item.target_model == "H2S", "mirrors the first candidate so the card has a label"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_deleting_one_candidate_leaves_the_job_and_its_sibling(
+        self, async_client, db_session, sliced_file_factory, printer_factory
+    ):
+        from backend.app.models.print_queue import PrintQueueItem
+
+        await printer_factory(model="H2S")
+        await printer_factory(model="H2C")
+        h2s = await sliced_file_factory("H2S")
+        h2c = await sliced_file_factory("H2C")
+        item_id = (await _queue_variants(async_client, h2s.id, h2c.id)).json()["id"]
+
+        # Trash, then permanently delete — the only path that actually removes
+        # the row. SQLite has PRAGMA foreign_keys off, so nothing cleans the
+        # candidate up on its own.
+        assert (await async_client.delete(f"/api/v1/library/files/{h2s.id}")).status_code == 200
+        assert (await async_client.delete(f"/api/v1/library/trash/{h2s.id}")).status_code == 200
+
+        item = (
+            await db_session.execute(select(PrintQueueItem).where(PrintQueueItem.id == item_id))
+        ).scalar_one_or_none()
+        assert item is not None, "the job survives losing one alternative"
+        remaining = await _variants_of(db_session, item_id)
+        assert [v.target_model for v in remaining] == ["H2C"], "no row left pointing at a deleted file"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_rejects_a_specific_printer(self, async_client, sliced_file_factory, printer_factory):
+        """Naming a printer defeats the entire purpose of offering alternatives."""
+        printer = await printer_factory(model="H2S")
+        await printer_factory(model="H2C")
+        h2s = await sliced_file_factory("H2S")
+        h2c = await sliced_file_factory("H2C")
+
+        r = await _queue_variants(async_client, h2s.id, h2c.id, printer_id=printer.id)
+        assert r.status_code == 400
+        assert "printer_id" in r.json()["detail"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_rejects_a_file_alongside_the_variants(self, async_client, sliced_file_factory, printer_factory):
+        await printer_factory(model="H2S")
+        await printer_factory(model="H2C")
+        h2s = await sliced_file_factory("H2S")
+        h2c = await sliced_file_factory("H2C")
+        other = await sliced_file_factory("H2D")
+
+        r = await _queue_variants(async_client, h2s.id, h2c.id, library_file_id=other.id)
+        assert r.status_code == 400
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_rejects_two_candidates_for_the_same_printer(
+        self, async_client, sliced_file_factory, printer_factory
+    ):
+        await printer_factory(model="H2S")
+        a = await sliced_file_factory("H2S")
+        b = await sliced_file_factory("H2S")
+
+        r = await _queue_variants(async_client, a.id, b.id)
+        assert r.status_code == 400
+        assert "different printers" in r.json()["detail"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_rejects_the_same_file_twice(self, async_client, sliced_file_factory, printer_factory):
+        await printer_factory(model="H2S")
+        f = await sliced_file_factory("H2S")
+
+        r = await _queue_variants(async_client, f.id, f.id)
+        assert r.status_code == 400
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_cross_model_gate_applies_to_every_candidate(
+        self, async_client, sliced_file_factory, printer_factory
+    ):
+        """A set is only as safe as its worst member."""
+        await printer_factory(model="H2S")
+        await printer_factory(model="H2C")
+        good = await sliced_file_factory("H2S")
+        # Declares X1C but is offered as an H2C candidate.
+        bad = await sliced_file_factory("X1C")
+
+        r = await async_client.post(
+            "/api/v1/queue/",
+            json={
+                "variants": [
+                    {"library_file_id": good.id},
+                    {"library_file_id": bad.id, "target_model": "H2C"},
+                ]
+            },
+        )
+        assert r.status_code == 400
+        assert "sliced for X1C" in r.json()["detail"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_one_candidate_without_a_printer_is_allowed(
+        self, async_client, db_session, sliced_file_factory, printer_factory
+    ):
+        """Slicing for the H2C before the H2C arrives is reasonable. Refusing the
+        whole queue action over it would be worse than that candidate simply
+        never matching."""
+        await printer_factory(model="H2S")
+        h2s = await sliced_file_factory("H2S")
+        h2c = await sliced_file_factory("H2C")
+
+        r = await _queue_variants(async_client, h2s.id, h2c.id)
+        assert r.status_code == 200
+        assert len(await _variants_of(db_session, r.json()["id"])) == 2
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_rejected_when_no_candidate_has_a_printer(self, async_client, sliced_file_factory):
+        """Nothing in the set can ever run — that is a job that waits forever."""
+        h2s = await sliced_file_factory("H2S")
+        h2c = await sliced_file_factory("H2C")
+
+        r = await _queue_variants(async_client, h2s.id, h2c.id)
+        assert r.status_code == 400
+        assert "No active printers" in r.json()["detail"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_assigning_a_printer_is_refused(self, async_client, db_session, sliced_file_factory, printer_factory):
+        """The edit dialog offers a printer picker for every queue item. Taking it
+        would leave a row with variants AND a printer_id — and the fixed-printer
+        branch of the scheduler wins that race, dispatching a row whose
+        library_file_id is still null."""
+        printer = await printer_factory(model="H2S")
+        await printer_factory(model="H2C")
+        h2s = await sliced_file_factory("H2S")
+        h2c = await sliced_file_factory("H2C")
+        item_id = (await _queue_variants(async_client, h2s.id, h2c.id)).json()["id"]
+
+        r = await async_client.patch(f"/api/v1/queue/{item_id}", json={"printer_id": printer.id})
+        assert r.status_code == 400
+        assert "alternatives" in r.json()["detail"]
+
+        assert len(await _variants_of(db_session, item_id)) == 2, "the alternatives survive the refusal"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_narrowing_to_one_model_is_refused(self, async_client, sliced_file_factory, printer_factory):
+        """Saving "Any H2C" over a two-candidate job would silently discard the
+        H2S alternative the user deliberately queued."""
+        await printer_factory(model="H2S")
+        await printer_factory(model="H2C")
+        h2s = await sliced_file_factory("H2S")
+        h2c = await sliced_file_factory("H2C")
+        item_id = (await _queue_variants(async_client, h2s.id, h2c.id)).json()["id"]
+
+        r = await async_client.patch(f"/api/v1/queue/{item_id}", json={"target_model": "H2C"})
+        assert r.status_code == 400
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_resending_the_unchanged_model_is_allowed(self, async_client, sliced_file_factory, printer_factory):
+        """The edit dialog re-sends target_model on every save, so an unchanged
+        value must not block editing the schedule or print options."""
+        await printer_factory(model="H2S")
+        await printer_factory(model="H2C")
+        h2s = await sliced_file_factory("H2S")
+        h2c = await sliced_file_factory("H2C")
+        created = (await _queue_variants(async_client, h2s.id, h2c.id)).json()
+
+        r = await async_client.patch(
+            f"/api/v1/queue/{created['id']}",
+            json={"target_model": created["target_model"], "timelapse": True},
+        )
+        assert r.status_code == 200
+        assert r.json()["timelapse"] is True
+        assert len(r.json()["variants"]) == 2, "the response still carries the alternatives"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_quantity_gives_each_copy_its_own_candidates(
+        self, async_client, db_session, sliced_file_factory, printer_factory
+    ):
+        """Attempt counts are per-item, and two copies must be free to land on
+        different printers."""
+        from backend.app.models.print_queue import PrintQueueItem, PrintQueueVariant
+
+        await printer_factory(model="H2S")
+        await printer_factory(model="H2C")
+        h2s = await sliced_file_factory("H2S")
+        h2c = await sliced_file_factory("H2C")
+
+        r = await _queue_variants(async_client, h2s.id, h2c.id, quantity=3)
+        assert r.status_code == 200
+
+        item_ids = (await db_session.execute(select(PrintQueueItem.id))).scalars().all()
+        assert len(item_ids) == 3
+        total = (await db_session.execute(select(PrintQueueVariant))).scalars().all()
+        assert len(total) == 6

+ 109 - 0
backend/tests/unit/services/test_slicer_api.py

@@ -309,6 +309,115 @@ class TestSliceWithProfiles:
 
         assert b'name="arrange"' not in captured["body"]
 
+    @pytest.mark.asyncio
+    async def test_orient_true_emits_form_field(self):
+        """#2548: user-requested auto-orient reaches the sidecar as its own
+        form field, which it turns into ``--orient 1``."""
+        captured: dict = {}
+
+        def handler(request: httpx.Request) -> httpx.Response:
+            captured["body"] = request.content
+            return httpx.Response(
+                status_code=200,
+                content=b"3MF",
+                headers={"x-print-time-seconds": "0", "x-filament-used-g": "0", "x-filament-used-mm": "0"},
+            )
+
+        service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
+        await service.slice_with_profiles(
+            model_bytes=b"x",
+            model_filename="Cube.3mf",
+            printer_profile_json="{}",
+            process_profile_json="{}",
+            filament_profile_jsons=["{}"],
+            orient=True,
+        )
+
+        assert b'name="orient"' in captured["body"]
+
+    @pytest.mark.asyncio
+    async def test_orient_false_omits_form_field(self):
+        """An off flag must be expressed by ABSENCE, never by sending
+        "false". The sidecar branches on ``settings.orient !== undefined``
+        and multipart fields arrive as strings — and ``"false"`` is truthy
+        in JavaScript, so sending it would switch auto-orient ON for every
+        user who left the box unticked."""
+        captured: dict = {}
+
+        def handler(request: httpx.Request) -> httpx.Response:
+            captured["body"] = request.content
+            return httpx.Response(
+                status_code=200,
+                content=b"3MF",
+                headers={"x-print-time-seconds": "0", "x-filament-used-g": "0", "x-filament-used-mm": "0"},
+            )
+
+        service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
+        await service.slice_with_profiles(
+            model_bytes=b"x",
+            model_filename="Cube.3mf",
+            printer_profile_json="{}",
+            process_profile_json="{}",
+            filament_profile_jsons=["{}"],
+            orient=False,
+        )
+
+        body = captured["body"]
+        assert b'name="orient"' not in body
+        assert b"false" not in body
+
+    @pytest.mark.asyncio
+    async def test_profileless_slice_forwards_both_layout_flags(self):
+        """The embedded-settings path and the segfault fallback both run
+        through ``slice_without_profiles``. Arrange / orient are CLI actions
+        on the geometry rather than profile values, so a user's per-slice
+        choice has to survive those routes too (#2548) — before this they
+        could not be expressed there at all."""
+        captured: dict = {}
+
+        def handler(request: httpx.Request) -> httpx.Response:
+            captured["body"] = request.content
+            return httpx.Response(
+                status_code=200,
+                content=b"3MF",
+                headers={"x-print-time-seconds": "0", "x-filament-used-g": "0", "x-filament-used-mm": "0"},
+            )
+
+        service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
+        await service.slice_without_profiles(
+            model_bytes=b"x",
+            model_filename="Cube.3mf",
+            arrange=True,
+            orient=True,
+        )
+
+        body = captured["body"]
+        assert b'name="arrange"' in body
+        assert b'name="orient"' in body
+
+    @pytest.mark.asyncio
+    async def test_profileless_slice_defaults_omit_layout_flags(self):
+        """The filament-discovery preview also uses this method and passes
+        neither flag — it must keep sending the pre-#2548 payload, since
+        rearranging objects would not change which slots a plate consumes
+        but would burn the arrange pass on every preview."""
+        captured: dict = {}
+
+        def handler(request: httpx.Request) -> httpx.Response:
+            captured["body"] = request.content
+            return httpx.Response(
+                status_code=200,
+                content=b"3MF",
+                headers={"x-print-time-seconds": "0", "x-filament-used-g": "0", "x-filament-used-mm": "0"},
+            )
+
+        service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
+        await service.slice_without_profiles(model_bytes=b"x", model_filename="Cube.3mf")
+
+        body = captured["body"]
+        assert b'name="arrange"' not in body
+        assert b'name="orient"' not in body
+
     @pytest.mark.asyncio
     async def test_multi_filament_sends_one_part_per_profile(self):
         # Multi-color slicing requires N filament profiles, in plate-slot

+ 495 - 0
backend/tests/unit/test_scheduler_cross_model_variants.py

@@ -0,0 +1,495 @@
+"""Cross-model queue items — one job, several sliced files (#671).
+
+The reporter has an H2S and an H2C and does not care which one runs the job.
+He slices it twice; both slices become variants of a single queue item, and the
+scheduler takes the first whose model has an idle printer.
+
+The design constraint that shapes everything here: the many-to-many must never
+escape the selection loop. Once a candidate wins, its file and settings are
+folded onto the queue row, so the upload, archive creation, print history and
+reprint paths keep seeing an ordinary single-file item. These tests assert both
+halves — that the right candidate is picked, and that the row afterwards looks
+like it was queued for that file all along.
+"""
+
+from contextlib import ExitStack
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
+
+import backend.app.models  # noqa: F401 - populate Base.metadata
+from backend.app.core.database import Base
+from backend.app.models.library import LibraryFile
+from backend.app.models.print_queue import PrintQueueItem, PrintQueueVariant
+from backend.app.models.printer import Printer
+from backend.app.services.print_scheduler import (
+    PrintScheduler,
+    _candidate_model_label,
+    _candidates_for,
+    _collapse_waiting_reasons,
+)
+
+# ---------------------------------------------------------------------------
+# Candidate ordering — pure
+# ---------------------------------------------------------------------------
+
+
+def _fake_variant(*, vid, position, model, attempts=0, trashed=False, file_missing=False):
+    return SimpleNamespace(
+        id=vid,
+        position=position,
+        target_model=model,
+        attempt_count=attempts,
+        library_file=None
+        if file_missing
+        else SimpleNamespace(
+            file_metadata={"sliced_for_model": model},
+            deleted_at="2026-01-01" if trashed else None,
+        ),
+        required_filament_types=None,
+        filament_overrides=None,
+    )
+
+
+def _fake_item(variants):
+    return SimpleNamespace(
+        variants=variants,
+        target_model=None,
+        archive=None,
+        archive_id=None,
+        library_file=None,
+        library_file_id=None,
+        required_filament_types=None,
+        filament_overrides=None,
+    )
+
+
+def test_no_variants_yields_the_items_own_columns():
+    """The pre-#671 path must be provably unchanged: one candidate, built from
+    the item itself."""
+    item = SimpleNamespace(
+        variants=[],
+        target_model="H2D",
+        archive=None,
+        archive_id=None,
+        library_file_id=7,
+        library_file=SimpleNamespace(file_metadata={"sliced_for_model": "H2D"}),
+        required_filament_types='["PLA"]',
+        filament_overrides=None,
+    )
+    candidates = _candidates_for(item)
+    assert len(candidates) == 1
+    assert candidates[0].target_model == "H2D"
+    assert candidates[0].sliced_for == "H2D"
+    assert candidates[0].required_filament_types == '["PLA"]'
+    assert candidates[0].variant is None
+
+
+def test_variants_come_back_in_user_priority_order():
+    item = _fake_item(
+        [
+            _fake_variant(vid=2, position=1, model="H2C"),
+            _fake_variant(vid=1, position=0, model="H2S"),
+        ]
+    )
+    assert [c.target_model for c in _candidates_for(item)] == ["H2S", "H2C"]
+
+
+def test_least_attempted_candidate_is_tried_first():
+    """A printer that accepts the file and never starts must not eat the item's
+    whole retry budget — the alternative gets the next lap."""
+    item = _fake_item(
+        [
+            _fake_variant(vid=1, position=0, model="H2S", attempts=1),
+            _fake_variant(vid=2, position=1, model="H2C", attempts=0),
+        ]
+    )
+    assert [c.target_model for c in _candidates_for(item)] == ["H2C", "H2S"]
+
+
+def test_trashed_candidate_is_skipped():
+    """Library deletes are soft: the row survives with deleted_at set, which no
+    foreign key can express. Dispatching a file the user put in the bin would be
+    a genuine surprise."""
+    item = _fake_item(
+        [
+            _fake_variant(vid=1, position=0, model="H2S", trashed=True),
+            _fake_variant(vid=2, position=1, model="H2C"),
+        ]
+    )
+    assert [c.target_model for c in _candidates_for(item)] == ["H2C"]
+
+
+def test_orphaned_candidate_is_skipped():
+    """SQLite runs with PRAGMA foreign_keys off, so a hard delete can leave a
+    candidate row pointing at nothing."""
+    item = _fake_item(
+        [
+            _fake_variant(vid=1, position=0, model="H2S", file_missing=True),
+            _fake_variant(vid=2, position=1, model="H2C"),
+        ]
+    )
+    assert [c.target_model for c in _candidates_for(item)] == ["H2C"]
+
+
+def test_item_with_no_usable_candidates_yields_none():
+    item = _fake_item([_fake_variant(vid=1, position=0, model="H2S", trashed=True)])
+    assert _candidates_for(item) == []
+
+
+def test_equal_attempts_fall_back_to_priority():
+    """Once every candidate has failed equally often they cycle in the user's
+    order, so the item still reaches its DISPATCH_MAX_ATTEMPTS ceiling."""
+    item = _fake_item(
+        [
+            _fake_variant(vid=1, position=0, model="H2S", attempts=2),
+            _fake_variant(vid=2, position=1, model="H2C", attempts=2),
+        ]
+    )
+    assert [c.target_model for c in _candidates_for(item)] == ["H2S", "H2C"]
+
+
+# ---------------------------------------------------------------------------
+# Waiting reasons — pure
+# ---------------------------------------------------------------------------
+
+
+def test_single_candidate_reason_is_unprefixed():
+    """One candidate means the card already shows the model; prefixing it would
+    just be noise."""
+    assert _collapse_waiting_reasons([("H2D", "Busy: H2D-1 (Printing)")]) == "Busy: H2D-1 (Printing)"
+
+
+def test_identical_reasons_collapse_to_one_clause():
+    collapsed = _collapse_waiting_reasons([("H2S", "Busy: shared-1 (Printing)"), ("H2C", "Busy: shared-1 (Printing)")])
+    assert collapsed == "Busy: shared-1 (Printing)"
+
+
+def test_all_busy_stays_busy_only_so_no_notification_fires():
+    """Two models busy on differently-named printers still has to read as
+    busy-only. Labelling the clauses would make every pass over a cross-model
+    item look like it needs the user, when it just needs a printer to finish."""
+    scheduler = PrintScheduler()
+    collapsed = _collapse_waiting_reasons([("H2S", "Busy: H2S-1 (Printing)"), ("H2C", "Busy: H2C-1 (Printing)")])
+    assert collapsed == "Busy: H2S-1 (Printing) | Busy: H2C-1 (Printing)"
+    assert scheduler._is_busy_only(collapsed)
+
+
+def test_differing_reasons_are_labelled_by_model():
+    collapsed = _collapse_waiting_reasons([("H2S", "No PETG loaded"), ("H2C", "Busy: H2C-1 (Printing)")])
+    assert collapsed == "H2S: No PETG loaded; H2C: Busy: H2C-1 (Printing)"
+    assert not PrintScheduler._is_busy_only(collapsed), "a real blocker must still notify"
+
+
+def test_empty_reasons_are_dropped():
+    assert _collapse_waiting_reasons([("H2S", "")]) is None
+    assert _collapse_waiting_reasons([]) is None
+
+
+def test_model_label_names_every_candidate():
+    item = _fake_item(
+        [
+            _fake_variant(vid=1, position=0, model="H2S"),
+            _fake_variant(vid=2, position=1, model="H2C"),
+        ]
+    )
+    assert _candidate_model_label(_candidates_for(item)) == "H2S or H2C"
+
+
+# ---------------------------------------------------------------------------
+# Scheduler behaviour
+# ---------------------------------------------------------------------------
+
+
+@pytest.fixture
+async def queue_db():
+    """In-memory DB with one H2S and one H2C."""
+    engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
+    async with engine.begin() as conn:
+        await conn.run_sync(Base.metadata.create_all)
+    session_maker = async_sessionmaker(engine, expire_on_commit=False)
+
+    async with session_maker() as db:
+        db.add_all(
+            [
+                Printer(
+                    id=1,
+                    name="H2S-1",
+                    serial_number="H2S0001",
+                    ip_address="10.0.0.1",
+                    access_code="x",
+                    model="H2S",
+                    is_active=True,
+                ),
+                Printer(
+                    id=2,
+                    name="H2C-1",
+                    serial_number="H2C0001",
+                    ip_address="10.0.0.2",
+                    access_code="x",
+                    model="H2C",
+                    is_active=True,
+                ),
+            ]
+        )
+        await db.commit()
+
+    try:
+        yield SimpleNamespace(session_maker=session_maker)
+    finally:
+        await engine.dispose()
+
+
+async def _add_variant_item(ctx, specs):
+    """Seed one pending queue item with a variant per (model, overrides) spec."""
+    async with ctx.session_maker() as db:
+        item = PrintQueueItem(
+            status="pending",
+            position=1,
+            target_model=specs[0]["model"],
+        )
+        db.add(item)
+        await db.flush()
+
+        for position, spec in enumerate(specs):
+            lib = LibraryFile(
+                filename=f"job_{spec['model']}.gcode.3mf",
+                file_path=f"/library/job_{spec['model']}.gcode.3mf",
+                file_size=10,
+                file_type="gcode.3mf",
+                file_metadata={"sliced_for_model": spec.get("sliced_for", spec["model"])},
+            )
+            db.add(lib)
+            await db.flush()
+            db.add(
+                PrintQueueVariant(
+                    queue_item_id=item.id,
+                    position=position,
+                    library_file_id=lib.id,
+                    target_model=spec["model"],
+                    plate_id=spec.get("plate_id"),
+                    ams_mapping=spec.get("ams_mapping"),
+                    nozzle_mapping=spec.get("nozzle_mapping"),
+                    print_time_seconds=spec.get("print_time_seconds"),
+                    attempt_count=spec.get("attempts", 0),
+                )
+            )
+        await db.commit()
+        return item.id
+
+
+async def _run_check_queue(ctx, scheduler, finder, waiting_notification=None):
+    patches = [
+        patch("backend.app.services.print_scheduler.async_session", ctx.session_maker),
+        patch("backend.app.core.database.async_session", ctx.session_maker),
+        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=None)),
+        patch(
+            "backend.app.services.notification_service.notification_service.on_queue_job_waiting",
+            waiting_notification or AsyncMock(),
+        ),
+        patch(
+            "backend.app.services.notification_service.notification_service.on_queue_job_assigned",
+            AsyncMock(),
+        ),
+        patch.object(scheduler, "_find_idle_printer_for_model", finder),
+        patch.object(scheduler, "_check_auto_drying", AsyncMock()),
+        # Selection is what's under test — keep AMS recomputation and the
+        # filament-deficit probe out of the way, and never actually dispatch.
+        patch.object(scheduler, "_ensure_ams_mapping", AsyncMock()),
+        patch.object(scheduler, "_block_on_filament_deficit", AsyncMock(return_value=False)),
+        patch.object(scheduler, "_launch_uploads", MagicMock()),
+    ]
+    with ExitStack() as stack:
+        for p in patches:
+            stack.enter_context(p)
+        return await scheduler.check_queue()
+
+
+async def _get_item(ctx, item_id):
+    async with ctx.session_maker() as db:
+        return (await db.execute(select(PrintQueueItem).where(PrintQueueItem.id == item_id))).scalar_one()
+
+
+def _finder_for(available: dict[str, int]):
+    """Matcher that offers a printer only for the listed models."""
+
+    async def _find(db, model, exclude_ids, *args, **kwargs):
+        if model in available:
+            return available[model], None
+        return None, f"No idle {model} printer"
+
+    return AsyncMock(side_effect=_find)
+
+
+@pytest.mark.asyncio
+async def test_first_matching_variant_wins_and_is_folded_onto_the_row(queue_db):
+    """The H2C is free, the H2S is not — the item runs the H2C slice, and every
+    downstream consumer sees a plain single-file item pointing at it."""
+    item_id = await _add_variant_item(
+        queue_db,
+        [
+            {"model": "H2S", "plate_id": 1, "ams_mapping": "[1]", "print_time_seconds": 900},
+            {
+                "model": "H2C",
+                "plate_id": 3,
+                "ams_mapping": "[4, 5]",
+                "nozzle_mapping": "[0, 1]",
+                "print_time_seconds": 1200,
+            },
+        ],
+    )
+    scheduler = PrintScheduler()
+
+    await _run_check_queue(queue_db, scheduler, _finder_for({"H2C": 2}))
+
+    item = await _get_item(queue_db, item_id)
+    assert item.printer_id == 2, "assigned to the H2C"
+    assert item.target_model == "H2C"
+    assert item.plate_id == 3
+    assert item.ams_mapping == "[4, 5]"
+    assert item.nozzle_mapping == "[0, 1]"
+    assert item.print_time_seconds == 1200, "the estimate now describes what will actually run"
+    assert item.waiting_reason is None
+    assert item.archive_id is None
+
+    async with queue_db.session_maker() as db:
+        chosen = (
+            await db.execute(select(PrintQueueVariant).where(PrintQueueVariant.target_model == "H2C"))
+        ).scalar_one()
+        assert item.library_file_id == chosen.library_file_id
+
+
+@pytest.mark.asyncio
+async def test_priority_order_decides_when_both_are_free(queue_db):
+    """Both printers idle in the same pass: the user's first choice runs, so the
+    outcome is reproducible rather than whichever match came back first."""
+    item_id = await _add_variant_item(queue_db, [{"model": "H2S"}, {"model": "H2C"}])
+    scheduler = PrintScheduler()
+
+    await _run_check_queue(queue_db, scheduler, _finder_for({"H2S": 1, "H2C": 2}))
+
+    item = await _get_item(queue_db, item_id)
+    assert item.printer_id == 1
+    assert item.target_model == "H2S"
+
+
+@pytest.mark.asyncio
+async def test_cross_model_gate_is_applied_per_candidate(queue_db):
+    """A variant whose file disagrees with its own model is skipped, and the
+    other one still runs — the gate must not condemn the whole item."""
+    item_id = await _add_variant_item(
+        queue_db,
+        [
+            {"model": "H2S", "sliced_for": "X1C"},
+            {"model": "H2C"},
+        ],
+    )
+    scheduler = PrintScheduler()
+    finder = _finder_for({"H2S": 1, "H2C": 2})
+
+    await _run_check_queue(queue_db, scheduler, finder)
+
+    assert [c.args[1] for c in finder.await_args_list] == ["H2C"], "the mismatched variant never reaches the matcher"
+    item = await _get_item(queue_db, item_id)
+    assert item.printer_id == 2
+    assert item.target_model == "H2C"
+
+
+@pytest.mark.asyncio
+async def test_no_match_reports_every_model_it_tried(queue_db):
+    """Nothing is free: the user must be able to tell which machines were
+    considered, not just that "a printer" was unavailable."""
+    item_id = await _add_variant_item(queue_db, [{"model": "H2S"}, {"model": "H2C"}])
+    scheduler = PrintScheduler()
+    waiting = AsyncMock()
+
+    await _run_check_queue(queue_db, scheduler, _finder_for({}), waiting)
+
+    item = await _get_item(queue_db, item_id)
+    assert item.printer_id is None
+    assert item.status == "pending"
+    assert "H2S: No idle H2S printer" in item.waiting_reason
+    assert "H2C: No idle H2C printer" in item.waiting_reason
+    assert waiting.await_args.kwargs["target_model"] == "H2S or H2C"
+    # The item holds no file of its own yet — the alert still has to name the job.
+    assert waiting.await_args.kwargs["job_name"] == "job_H2S"
+
+
+@pytest.mark.asyncio
+async def test_item_with_no_files_left_is_held_with_an_actionable_reason(queue_db):
+    """Deleting a library file takes its variant with it. An item stripped of
+    every candidate used to sail into dispatch and die there on "No archive_id
+    or library_file_id"; hold it where the user can see why."""
+    async with queue_db.session_maker() as db:
+        db.add(PrintQueueItem(status="pending", position=1, target_model="H2S"))
+        await db.commit()
+    scheduler = PrintScheduler()
+    finder = _finder_for({"H2S": 1})
+
+    await _run_check_queue(queue_db, scheduler, finder)
+
+    finder.assert_not_awaited()
+    async with queue_db.session_maker() as db:
+        item = (await db.execute(select(PrintQueueItem))).scalar_one()
+    assert item.status == "pending"
+    assert item.printer_id is None
+    assert "has been deleted" in item.waiting_reason
+
+
+@pytest.mark.asyncio
+async def test_plain_model_based_item_is_untouched(queue_db):
+    """Regression guard: an item with no variants takes exactly the path it took
+    before variants existed."""
+    async with queue_db.session_maker() as db:
+        lib = LibraryFile(
+            filename="job.gcode.3mf",
+            file_path="/library/job.gcode.3mf",
+            file_size=10,
+            file_type="gcode.3mf",
+            file_metadata={"sliced_for_model": "H2S"},
+        )
+        db.add(lib)
+        await db.flush()
+        db.add(
+            PrintQueueItem(
+                status="pending",
+                position=1,
+                target_model="H2S",
+                library_file_id=lib.id,
+                plate_id=2,
+            )
+        )
+        await db.commit()
+    scheduler = PrintScheduler()
+
+    await _run_check_queue(queue_db, scheduler, _finder_for({"H2S": 1}))
+
+    async with queue_db.session_maker() as db:
+        item = (await db.execute(select(PrintQueueItem))).scalar_one()
+    assert item.printer_id == 1
+    assert item.target_model == "H2S"
+    assert item.plate_id == 2, "nothing overwrote the item's own settings"
+
+
+@pytest.mark.asyncio
+async def test_failed_candidate_steps_aside_for_the_alternative(queue_db):
+    """The H2S burned an attempt on the last lap. Both are free now — the H2C
+    goes first, which is the entire point of queueing an alternative."""
+    item_id = await _add_variant_item(
+        queue_db,
+        [
+            {"model": "H2S", "attempts": 1},
+            {"model": "H2C", "attempts": 0},
+        ],
+    )
+    scheduler = PrintScheduler()
+
+    await _run_check_queue(queue_db, scheduler, _finder_for({"H2S": 1, "H2C": 2}))
+
+    item = await _get_item(queue_db, item_id)
+    assert item.printer_id == 2
+    assert item.target_model == "H2C"

+ 26 - 0
backend/tests/unit/test_slice_request_schema.py

@@ -152,3 +152,29 @@ class TestPresetsRequired:
     def test_empty_request_rejected(self):
         with pytest.raises(ValidationError):
             SliceRequest()
+
+
+class TestLayoutFlags:
+    """#2548: auto-orient / auto-arrange are per-slice options on the
+    request, not stored settings. Both must default to off — they rewrite
+    the object placement the file came with, which is never something to do
+    to a user who did not ask for it."""
+
+    def test_both_default_to_off(self):
+        req = SliceRequest(
+            printer_preset=PresetRef(source="local", id="1"),
+            process_preset=PresetRef(source="local", id="2"),
+            filament_preset=PresetRef(source="local", id="3"),
+        )
+        assert req.auto_orient is False
+        assert req.auto_arrange is False
+
+    def test_flags_are_independent(self):
+        req = SliceRequest(
+            printer_preset=PresetRef(source="local", id="1"),
+            process_preset=PresetRef(source="local", id="2"),
+            filament_preset=PresetRef(source="local", id="3"),
+            auto_orient=True,
+        )
+        assert req.auto_orient is True
+        assert req.auto_arrange is False

+ 302 - 0
backend/tests/unit/test_variant_group_backfill_migration.py

@@ -0,0 +1,302 @@
+"""Tests for the variant-group backfill migration (#671 / #2570).
+
+`sliced_from_library_file_id` has been written into `library_files.file_metadata`
+by the Slice button and the pipeline runner since those features shipped, and
+nothing ever read it back. The migration promotes that inert provenance into
+real `file_variant_groups` membership so an existing library arrives with its
+slice sets already grouped.
+
+The interesting behaviour is all in what it refuses to group: a lone child, two
+children sliced for the same printer, files the user has already grouped by
+hand, and trashed rows.
+"""
+
+from __future__ import annotations
+
+import json
+
+import pytest
+from sqlalchemy import text
+from sqlalchemy.ext.asyncio import create_async_engine
+
+from backend.app.core.database import run_migrations
+
+
+@pytest.fixture(autouse=True)
+def force_sqlite_dialect(monkeypatch):
+    """Force the SQLite branch regardless of test env settings."""
+    from backend.app.core import db_dialect
+
+    monkeypatch.setattr(db_dialect, "is_sqlite", lambda: True)
+    monkeypatch.setattr(db_dialect, "is_postgres", lambda: False)
+    from backend.app.core import database as database_module
+
+    monkeypatch.setattr(database_module, "is_sqlite", lambda: True)
+
+
+def _register_all_models():
+    from backend.app.models import (  # noqa: F401
+        ams_history,
+        ams_label,
+        api_key,
+        archive,
+        color_catalog,
+        external_link,
+        filament,
+        group,
+        kprofile_note,
+        library,
+        maintenance,
+        notification,
+        notification_template,
+        print_log,
+        print_queue,
+        printer,
+        project,
+        project_bom,
+        settings,
+        slot_preset,
+        smart_plug,
+        smart_plug_energy_snapshot,
+        spool,
+        spool_assignment,
+        spool_catalog,
+        spool_k_profile,
+        spool_usage_history,
+        spoolbuddy_device,
+        user,
+        user_email_pref,
+        virtual_printer,
+    )
+
+
+@pytest.fixture
+async def engine():
+    from backend.app.core.database import Base
+
+    _register_all_models()
+
+    eng = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
+    async with eng.begin() as conn:
+        await conn.run_sync(Base.metadata.create_all)
+    yield eng
+    await eng.dispose()
+
+
+async def _insert_file(
+    conn,
+    *,
+    file_id: int,
+    filename: str,
+    metadata: dict | None = None,
+    deleted: bool = False,
+    variant_group_id: int | None = None,
+) -> None:
+    """Insert a minimal LibraryFile row; only the columns the migration reads."""
+    await conn.execute(
+        text(
+            "INSERT INTO library_files "
+            "(id, filename, file_path, file_type, file_size, is_external, print_count, "
+            " file_metadata, deleted_at, variant_group_id, variant_position) "
+            "VALUES (:id, :filename, :path, 'gcode.3mf', 0, 0, 0, :meta, :deleted, :gid, 0)"
+        ),
+        {
+            "id": file_id,
+            "filename": filename,
+            "path": f"/lib/{file_id}",
+            "meta": json.dumps(metadata) if metadata is not None else None,
+            "deleted": "2026-01-01 00:00:00" if deleted else None,
+            "gid": variant_group_id,
+        },
+    )
+
+
+def _variant(source_id: int, model: str) -> dict:
+    return {"sliced_from_library_file_id": source_id, "sliced_for_model": model}
+
+
+async def _members(conn) -> dict[int, tuple[int | None, int]]:
+    rows = (
+        await conn.execute(text("SELECT id, variant_group_id, variant_position FROM library_files ORDER BY id"))
+    ).fetchall()
+    return {r[0]: (r[1], r[2]) for r in rows}
+
+
+async def _group_count(conn) -> int:
+    return (await conn.execute(text("SELECT COUNT(*) FROM file_variant_groups"))).scalar()
+
+
+@pytest.mark.asyncio
+async def test_groups_two_variants_of_the_same_source(engine):
+    """The whole point: an H2S slice and an H2C slice of one model become a group."""
+    async with engine.begin() as conn:
+        await _insert_file(conn, file_id=1, filename="bracket.3mf")
+        await _insert_file(conn, file_id=2, filename="bracket_h2s.gcode.3mf", metadata=_variant(1, "H2S"))
+        await _insert_file(conn, file_id=3, filename="bracket_h2c.gcode.3mf", metadata=_variant(1, "H2C"))
+
+    async with engine.begin() as conn:
+        await run_migrations(conn)
+
+    async with engine.connect() as conn:
+        assert await _group_count(conn) == 1
+        members = await _members(conn)
+        gid = members[2][0]
+        assert gid is not None
+        assert members[3][0] == gid, "both slices land in the same group"
+        assert members[1][0] is None, "the unsliced source is not a dispatch candidate"
+        assert (members[2][1], members[3][1]) == (0, 1), "position follows id order, deterministically"
+
+        name = (await conn.execute(text("SELECT name FROM file_variant_groups"))).scalar()
+        assert name == "bracket.3mf", "the group is named after the source the user recognises"
+
+
+@pytest.mark.asyncio
+async def test_single_variant_produces_no_group(engine):
+    """One candidate is not a choice — grouping it would add a row per sliced
+    file in every library while changing nothing at print time."""
+    async with engine.begin() as conn:
+        await _insert_file(conn, file_id=1, filename="bracket.3mf")
+        await _insert_file(conn, file_id=2, filename="bracket_h2s.gcode.3mf", metadata=_variant(1, "H2S"))
+
+    async with engine.begin() as conn:
+        await run_migrations(conn)
+
+    async with engine.connect() as conn:
+        assert await _group_count(conn) == 0
+        assert (await _members(conn))[2][0] is None
+
+
+@pytest.mark.asyncio
+async def test_duplicate_model_is_skipped_whole(engine):
+    """Two slices for the same printer are not alternatives — the resolver would
+    have no basis to prefer one, so the source is left entirely alone."""
+    async with engine.begin() as conn:
+        await _insert_file(conn, file_id=1, filename="bracket.3mf")
+        await _insert_file(conn, file_id=2, filename="bracket_draft.gcode.3mf", metadata=_variant(1, "H2S"))
+        await _insert_file(conn, file_id=3, filename="bracket_fine.gcode.3mf", metadata=_variant(1, "H2S"))
+
+    async with engine.begin() as conn:
+        await run_migrations(conn)
+
+    async with engine.connect() as conn:
+        assert await _group_count(conn) == 0
+        members = await _members(conn)
+        assert members[2][0] is None and members[3][0] is None
+
+
+@pytest.mark.asyncio
+async def test_variant_without_model_is_not_a_candidate(engine):
+    """A child with no `sliced_for_model` can never be matched to a printer, so
+    it does not count towards the two-candidate threshold."""
+    async with engine.begin() as conn:
+        await _insert_file(conn, file_id=1, filename="bracket.3mf")
+        await _insert_file(conn, file_id=2, filename="bracket_h2s.gcode.3mf", metadata=_variant(1, "H2S"))
+        await _insert_file(
+            conn,
+            file_id=3,
+            filename="bracket_unknown.gcode.3mf",
+            metadata={"sliced_from_library_file_id": 1},
+        )
+
+    async with engine.begin() as conn:
+        await run_migrations(conn)
+
+    async with engine.connect() as conn:
+        assert await _group_count(conn) == 0
+
+
+@pytest.mark.asyncio
+async def test_trashed_variants_are_excluded(engine):
+    """A soft-deleted file is not printable, so it must not make up the second
+    candidate that tips a source into being grouped."""
+    async with engine.begin() as conn:
+        await _insert_file(conn, file_id=1, filename="bracket.3mf")
+        await _insert_file(conn, file_id=2, filename="bracket_h2s.gcode.3mf", metadata=_variant(1, "H2S"))
+        await _insert_file(conn, file_id=3, filename="bracket_h2c.gcode.3mf", metadata=_variant(1, "H2C"), deleted=True)
+
+    async with engine.begin() as conn:
+        await run_migrations(conn)
+
+    async with engine.connect() as conn:
+        assert await _group_count(conn) == 0
+
+
+@pytest.mark.asyncio
+async def test_missing_source_still_groups_with_fallback_name(engine):
+    """Deleting the source model does not make its slices any less usable
+    together, so the group is still built — just named differently."""
+    async with engine.begin() as conn:
+        await _insert_file(conn, file_id=2, filename="bracket_h2s.gcode.3mf", metadata=_variant(99, "H2S"))
+        await _insert_file(conn, file_id=3, filename="bracket_h2c.gcode.3mf", metadata=_variant(99, "H2C"))
+
+    async with engine.begin() as conn:
+        await run_migrations(conn)
+
+    async with engine.connect() as conn:
+        assert await _group_count(conn) == 1
+        name = (await conn.execute(text("SELECT name FROM file_variant_groups"))).scalar()
+        assert name == "H2S + 1 more"
+
+
+@pytest.mark.asyncio
+async def test_separate_sources_get_separate_groups(engine):
+    async with engine.begin() as conn:
+        await _insert_file(conn, file_id=1, filename="bracket.3mf")
+        await _insert_file(conn, file_id=2, filename="bracket_h2s.gcode.3mf", metadata=_variant(1, "H2S"))
+        await _insert_file(conn, file_id=3, filename="bracket_h2c.gcode.3mf", metadata=_variant(1, "H2C"))
+        await _insert_file(conn, file_id=4, filename="clip.3mf")
+        await _insert_file(conn, file_id=5, filename="clip_h2s.gcode.3mf", metadata=_variant(4, "H2S"))
+        await _insert_file(conn, file_id=6, filename="clip_h2c.gcode.3mf", metadata=_variant(4, "H2C"))
+
+    async with engine.begin() as conn:
+        await run_migrations(conn)
+
+    async with engine.connect() as conn:
+        assert await _group_count(conn) == 2
+        members = await _members(conn)
+        assert members[2][0] == members[3][0]
+        assert members[5][0] == members[6][0]
+        assert members[2][0] != members[5][0]
+
+
+@pytest.mark.asyncio
+async def test_backfill_is_idempotent(engine):
+    """Every boot re-runs the migration set; the second pass must not clone the
+    group or renumber its members."""
+    async with engine.begin() as conn:
+        await _insert_file(conn, file_id=1, filename="bracket.3mf")
+        await _insert_file(conn, file_id=2, filename="bracket_h2s.gcode.3mf", metadata=_variant(1, "H2S"))
+        await _insert_file(conn, file_id=3, filename="bracket_h2c.gcode.3mf", metadata=_variant(1, "H2C"))
+
+    async with engine.begin() as conn:
+        await run_migrations(conn)
+    async with engine.connect() as conn:
+        first = await _members(conn)
+
+    async with engine.begin() as conn:
+        await run_migrations(conn)
+    async with engine.connect() as conn:
+        assert await _group_count(conn) == 1
+        assert await _members(conn) == first
+
+
+@pytest.mark.asyncio
+async def test_hand_grouped_files_are_left_alone(engine):
+    """A user who has already grouped (or deliberately ungrouped) files owns that
+    decision — the backfill only ever considers files with no group yet."""
+    async with engine.begin() as conn:
+        await conn.execute(text("INSERT INTO file_variant_groups (id, name) VALUES (7, 'my own grouping')"))
+        await _insert_file(conn, file_id=1, filename="bracket.3mf")
+        await _insert_file(
+            conn, file_id=2, filename="bracket_h2s.gcode.3mf", metadata=_variant(1, "H2S"), variant_group_id=7
+        )
+        await _insert_file(conn, file_id=3, filename="bracket_h2c.gcode.3mf", metadata=_variant(1, "H2C"))
+
+    async with engine.begin() as conn:
+        await run_migrations(conn)
+
+    async with engine.connect() as conn:
+        assert await _group_count(conn) == 1, "no second group is invented"
+        members = await _members(conn)
+        assert members[2][0] == 7, "the user's grouping survives"
+        assert members[3][0] is None, "and the leftover sibling is not force-joined to it"

+ 143 - 0
frontend/src/__tests__/components/PrintModalCrossModel.test.tsx

@@ -0,0 +1,143 @@
+/**
+ * PrintModal in cross-model mode (#671).
+ *
+ * Selecting several sliced files puts the modal in model-based assignment with
+ * no single target model. That combination used to fall through every gate the
+ * override UI depends on, leaving the user with *less* control than the
+ * ordinary "Any X1C" flow — no AMS mapping (correct, there is no printer yet)
+ * and no filament override either (wrong).
+ */
+
+import { describe, it, expect, beforeEach } from 'vitest';
+import { screen, waitFor } from '@testing-library/react';
+import { http, HttpResponse } from 'msw';
+import { render } from '../utils';
+import { server } from '../mocks/server';
+import { PrintModal } from '../../components/PrintModal';
+
+const CANDIDATES = [
+  { id: 11, filename: 'x1c.gcode.3mf', sliced_for_model: 'X1C' },
+  { id: 12, filename: 'h2d.gcode.3mf', sliced_for_model: 'H2D' },
+];
+
+/** Loaded filaments differ per model — the union is what the user may pick.
+ *  The dropdown only ever offers the slot's own material (overriding PLA with
+ *  PETG is not a colour choice), so the spool that proves the union works has
+ *  to be a PLA the X1C does not have. */
+const BY_MODEL: Record<string, Array<Record<string, unknown>>> = {
+  X1C: [{ type: 'PLA', color: '#FFFFFF', tray_info_idx: 'GFA00', tray_sub_brands: 'PLA Basic', extruder_id: null }],
+  H2D: [
+    { type: 'PLA', color: '#FFFFFF', tray_info_idx: 'GFA00', tray_sub_brands: 'PLA Basic', extruder_id: null },
+    { type: 'PLA', color: '#00FF00', tray_info_idx: 'GFA01', tray_sub_brands: 'PLA Matte', extruder_id: null },
+  ],
+};
+
+function mockBackend() {
+  server.use(
+    http.get('/api/v1/printers/', () =>
+      HttpResponse.json([
+        { id: 1, name: 'X1C-1', model: 'X1C', ip_address: '10.0.0.1', is_active: true, enabled: true },
+        { id: 2, name: 'H2D-1', model: 'H2D', ip_address: '10.0.0.2', is_active: true, enabled: true },
+      ]),
+    ),
+    http.get('/api/v1/printers/available-filaments', ({ request }) => {
+      const model = new URL(request.url).searchParams.get('model') ?? '';
+      return HttpResponse.json(BY_MODEL[model] ?? []);
+    }),
+    http.get('/api/v1/library/files/:id', ({ params }) =>
+      HttpResponse.json({
+        id: Number(params.id),
+        filename: 'x1c.gcode.3mf',
+        file_type: 'gcode.3mf',
+        sliced_for_model: 'X1C',
+      }),
+    ),
+    http.get('/api/v1/library/files/:id/plates', ({ params }) =>
+      HttpResponse.json({ file_id: Number(params.id), filename: 'x', plates: [], is_multi_plate: false }),
+    ),
+    http.get('/api/v1/library/files/:id/filament-requirements', () =>
+      HttpResponse.json({
+        filaments: [{ slot_id: 1, type: 'PLA', color: '#FFFFFF', used_grams: 15, used_meters: 5 }],
+      }),
+    ),
+  );
+}
+
+function renderCrossModel() {
+  render(
+    <PrintModal
+      mode="create"
+      libraryFileId={CANDIDATES[0].id}
+      variantFiles={CANDIDATES}
+      archiveName="bracket"
+      onClose={() => {}}
+    />,
+  );
+}
+
+describe('PrintModal cross-model mode', () => {
+  beforeEach(() => mockBackend());
+
+  it('replaces the printer picker with the candidate list', async () => {
+    renderCrossModel();
+    expect(await screen.findByText('x1c.gcode.3mf')).toBeInTheDocument();
+    expect(screen.getByText('h2d.gcode.3mf')).toBeInTheDocument();
+    // Choosing these files already answered "which printer".
+    expect(screen.queryByText('Select Printer')).not.toBeInTheDocument();
+  });
+
+  it('offers filament overrides drawn from every candidate model', async () => {
+    renderCrossModel();
+
+    expect(await screen.findByText('Filament Override')).toBeInTheDocument();
+
+    // PLA Matte is loaded only on the H2D. It has to be offered anyway: the job
+    // can land there, and choosing it simply narrows which candidates match.
+    await waitFor(() => {
+      const options = screen.getAllByRole('option').map((o) => o.textContent ?? '');
+      expect(options.some((o) => o.includes('PLA Matte'))).toBe(true);
+      expect(options.some((o) => o.includes('PLA Basic'))).toBe(true);
+    });
+  });
+
+  it('shows a queued job its alternatives instead of a printer picker', async () => {
+    // Before this, editing a cross-model item showed "Any H2D" with a live
+    // Target Model dropdown and a Specific Printer toggle. Saving that left a
+    // row with variants AND a printer_id, and the fixed-printer branch of the
+    // scheduler wins — dispatching a row whose library_file_id is still null.
+    render(
+      <PrintModal
+        mode="edit-queue-item"
+        libraryFileId={CANDIDATES[0].id}
+        archiveName="bracket"
+        queueItem={
+          {
+            id: 9,
+            printer_id: null,
+            target_model: 'H2D',
+            status: 'pending',
+            variants: [
+              { library_file_id: 12, filename: 'h2d.gcode.3mf', target_model: 'H2D', position: 0 },
+              { library_file_id: 11, filename: 'x1c.gcode.3mf', target_model: 'X1C', position: 1 },
+            ],
+          } as never
+        }
+        onClose={() => {}}
+      />,
+    );
+
+    expect(await screen.findByText('h2d.gcode.3mf')).toBeInTheDocument();
+    expect(screen.getByText('x1c.gcode.3mf')).toBeInTheDocument();
+    expect(screen.queryByText('Target Model')).not.toBeInTheDocument();
+    // Read-only: reordering after queueing would need a variant-level API.
+    expect(screen.queryByLabelText('Move down')).not.toBeInTheDocument();
+  });
+
+  it('shows no AMS slot mapping, because no printer has been chosen yet', async () => {
+    renderCrossModel();
+    await screen.findByText('x1c.gcode.3mf');
+    // The scheduler derives the mapping against whichever printer it picks —
+    // collecting tray numbers here would only be thrown away.
+    expect(screen.queryByText('Filament Mapping')).not.toBeInTheDocument();
+  });
+});

+ 51 - 0
frontend/src/__tests__/components/SliceModal.test.tsx

@@ -523,6 +523,57 @@ describe('SliceModal', () => {
     });
   });
 
+  it('sends the layout flags only for the boxes the user ticked (#2548)', async () => {
+    mockApi.sliceLibraryFile.mockResolvedValue({
+      job_id: 42,
+      status: 'pending',
+      status_url: '/api/v1/slice-jobs/42',
+    });
+
+    renderWithTracker({
+      source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' },
+      onClose: vi.fn(),
+    });
+
+    await waitFor(() => expect(screen.getByText('My Custom X1C')).toBeDefined());
+
+    const user = userEvent.setup();
+    await user.click(screen.getByRole('checkbox', { name: /Auto-orient objects/ }));
+    await user.click(screen.getByRole('button', { name: /^Slice$/ }));
+
+    await waitFor(() => {
+      const [, body] = vi.mocked(mockApi.sliceLibraryFile).mock.calls[0];
+      expect(body).toHaveProperty('auto_orient', true);
+      // The untouched box is omitted, not sent as false. The sidecar reads
+      // any present value as truthy, so a literal false would arrange every
+      // slice — the flag has to travel by absence.
+      expect(body).not.toHaveProperty('auto_arrange');
+    });
+  });
+
+  it('omits both layout flags when neither box is ticked (#2548)', async () => {
+    mockApi.sliceLibraryFile.mockResolvedValue({
+      job_id: 42,
+      status: 'pending',
+      status_url: '/api/v1/slice-jobs/42',
+    });
+
+    renderWithTracker({
+      source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' },
+      onClose: vi.fn(),
+    });
+
+    await waitFor(() => expect(screen.getByText('My Custom X1C')).toBeDefined());
+
+    await userEvent.setup().click(screen.getByRole('button', { name: /^Slice$/ }));
+
+    await waitFor(() => {
+      const [, body] = vi.mocked(mockApi.sliceLibraryFile).mock.calls[0];
+      expect(body).not.toHaveProperty('auto_orient');
+      expect(body).not.toHaveProperty('auto_arrange');
+    });
+  });
+
   it('lets the user override the default and pick a Standard preset', async () => {
     const onClose = vi.fn();
     mockApi.sliceLibraryFile.mockResolvedValue({

+ 122 - 0
frontend/src/__tests__/components/VariantCandidates.test.tsx

@@ -0,0 +1,122 @@
+/**
+ * Cross-model candidate list (#671).
+ *
+ * The list carries the one decision the user makes that the scheduler cannot:
+ * which printer they would rather have when more than one is free. Order is
+ * that decision, so it has to be visible and editable.
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { render } from '../utils';
+import { VariantCandidates, type VariantCandidate } from '../../components/PrintModal/VariantCandidates';
+import { api } from '../../api/client';
+
+vi.mock('../../api/client', async () => {
+  const actual = await vi.importActual<typeof import('../../api/client')>('../../api/client');
+  return {
+    ...actual,
+    api: { ...actual.api, getLibraryFilePlates: vi.fn() },
+  };
+});
+
+const CANDIDATES: VariantCandidate[] = [
+  { id: 1, filename: 'bracket_h2s.gcode.3mf', sliced_for_model: 'H2S' },
+  { id: 2, filename: 'bracket_h2c.gcode.3mf', sliced_for_model: 'H2C' },
+];
+
+function setup(overrides: Partial<React.ComponentProps<typeof VariantCandidates>> = {}) {
+  const onReorder = vi.fn();
+  const onPlateChange = vi.fn();
+  render(
+    <VariantCandidates
+      candidates={CANDIDATES}
+      onReorder={onReorder}
+      plateByFile={{}}
+      onPlateChange={onPlateChange}
+      {...overrides}
+    />,
+  );
+  return { onReorder, onPlateChange };
+}
+
+describe('VariantCandidates', () => {
+  beforeEach(() => {
+    vi.mocked(api.getLibraryFilePlates).mockResolvedValue({
+      file_id: 1,
+      filename: 'x',
+      plates: [],
+      is_multi_plate: false,
+    });
+  });
+
+  it('lists every candidate with the model its file was sliced for', async () => {
+    setup();
+    expect(await screen.findByText('bracket_h2s.gcode.3mf')).toBeInTheDocument();
+    expect(screen.getByText('bracket_h2c.gcode.3mf')).toBeInTheDocument();
+    expect(screen.getByText('H2S')).toBeInTheDocument();
+    expect(screen.getByText('H2C')).toBeInTheDocument();
+  });
+
+  it('moves a candidate down, which is how priority is expressed', async () => {
+    const user = userEvent.setup();
+    const { onReorder } = setup();
+
+    const downButtons = await screen.findAllByLabelText('Move down');
+    await user.click(downButtons[0]);
+
+    expect(onReorder).toHaveBeenCalledWith([CANDIDATES[1], CANDIDATES[0]]);
+  });
+
+  it('cannot move the first candidate up or the last one down', async () => {
+    setup();
+    const up = await screen.findAllByLabelText('Move up');
+    const down = await screen.findAllByLabelText('Move down');
+    expect(up[0]).toBeDisabled();
+    expect(down[down.length - 1]).toBeDisabled();
+  });
+
+  it('offers a plate picker only for the candidates that have several plates', async () => {
+    vi.mocked(api.getLibraryFilePlates).mockImplementation(async (fileId: number) =>
+      fileId === 2
+        ? {
+            file_id: 2,
+            filename: 'bracket_h2c.gcode.3mf',
+            is_multi_plate: true,
+            plates: [
+              { index: 1, name: 'Plate 1', objects: [], has_thumbnail: false, thumbnail_url: null, print_time_seconds: null, filament_used_grams: null, filaments: [] },
+              { index: 2, name: 'Plate 2', objects: [], has_thumbnail: false, thumbnail_url: null, print_time_seconds: null, filament_used_grams: null, filaments: [] },
+            ],
+          }
+        : { file_id: fileId, filename: 'x', is_multi_plate: false, plates: [] },
+    );
+
+    setup();
+
+    // One picker, for the multi-plate file only — a single-plate candidate has
+    // nothing to choose and the control would just be noise.
+    await waitFor(() => expect(screen.getAllByRole('combobox')).toHaveLength(1));
+    expect(screen.getByLabelText('Plate for bracket_h2c.gcode.3mf')).toBeInTheDocument();
+  });
+
+  it('reports the chosen plate against the file it belongs to', async () => {
+    const user = userEvent.setup();
+    vi.mocked(api.getLibraryFilePlates).mockImplementation(async (fileId: number) => ({
+      file_id: fileId,
+      filename: 'x',
+      is_multi_plate: true,
+      plates: [
+        { index: 1, name: 'Plate 1', objects: [], has_thumbnail: false, thumbnail_url: null, print_time_seconds: null, filament_used_grams: null, filaments: [] },
+        { index: 2, name: 'Plate 2', objects: [], has_thumbnail: false, thumbnail_url: null, print_time_seconds: null, filament_used_grams: null, filaments: [] },
+      ],
+    }));
+
+    const { onPlateChange } = setup();
+
+    const pickers = await screen.findAllByRole('combobox');
+    await user.selectOptions(pickers[1], '2');
+
+    expect(onPlateChange).toHaveBeenCalledWith(2, 2);
+  });
+});

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

@@ -1604,6 +1604,12 @@ export interface SliceRequest {
   // instead of the picked profile triplet. The preset refs above are still
   // required by the backend validator but go unused on this path.
   use_embedded_settings?: boolean;
+  // Layout passes the slicer runs before slicing (#2548), both off by
+  // default because they move or rotate the objects the user laid out.
+  // Unlike the fields above these are CLI actions rather than profile
+  // values, so they apply on the embedded-settings path too.
+  auto_orient?: boolean;
+  auto_arrange?: boolean;
 }
 
 // GET /api/v1/slicer/presets — unified listing across cloud / local / standard.
@@ -2203,6 +2209,15 @@ export interface PrintQueueItem {
   target_location: string | null;  // Target location filter for model-based assignment
   required_filament_types: string[] | null;  // Required filament types for model-based assignment
   waiting_reason: string | null;  // Why a model-based job hasn't started yet
+  // Cross-model alternatives (#671), in priority order. Empty for ordinary
+  // items. Present until dispatch resolves one, after which library_file_id and
+  // target_model name the candidate that actually ran.
+  variants?: Array<{
+    library_file_id: number;
+    filename: string;
+    target_model: string;
+    position: number;
+  }>;
   // Either archive_id OR library_file_id must be set (archive created at print start)
   archive_id: number | null;
   library_file_id: number | null;
@@ -2324,6 +2339,22 @@ export interface PrintQueueItemCreate {
   project_id?: number;
   // Delete transient uploaded library file after scheduler creates the archive
   cleanup_library_after_dispatch?: boolean;
+  // Cross-model alternatives (#671): several sliced files, one job, whichever
+  // printer frees up first. Mutually exclusive with printer_id (a named printer
+  // 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[];
+}
+
+/** One candidate file for a cross-model queue item (#671). */
+export interface QueueVariantCreate {
+  library_file_id: number;
+  /** Read from the file's own sliced_for_model unless it declares none. */
+  target_model?: string | null;
+  plate_id?: number | null;
+  ams_mapping?: number[] | null;
+  nozzle_mapping?: number[] | null;
+  filament_overrides?: Array<{ slot_id: number; type: string; color: string; color_name?: string; force_color_match?: boolean }> | null;
 }
 
 export interface PrintBatchCreate {
@@ -6289,6 +6320,50 @@ export const api = {
       method: 'POST',
       body: JSON.stringify({ file_ids: fileIds, tag_ids: tagIds, action }),
     }),
+  // ============ Variant groups (#671 / #2570) ============
+  // "These files are the same job sliced for different printers." Consumed from
+  // both ends: the queue picks a printer and needs the matching file, the File
+  // Manager's print action has the printer and needs the same match.
+  createVariantGroup: (
+    members: { library_file_id: number; target_model?: string }[],
+    name?: string,
+  ) =>
+    request<VariantGroup>('/library/variant-groups', {
+      method: 'POST',
+      body: JSON.stringify({ members, ...(name ? { name } : {}) }),
+    }),
+  getVariantGroup: (groupId: number) =>
+    request<VariantGroup>(`/library/variant-groups/${groupId}`),
+  /** Returns null when the file is not grouped, rather than throwing on the 404. */
+  getVariantGroupForFile: async (fileId: number): Promise<VariantGroup | null> => {
+    try {
+      return await request<VariantGroup>(`/library/variant-groups/by-file/${fileId}`);
+    } catch {
+      return null;
+    }
+  },
+  updateVariantGroup: (groupId: number, body: { name?: string; member_file_ids?: number[] }) =>
+    request<VariantGroup>(`/library/variant-groups/${groupId}`, {
+      method: 'PATCH',
+      body: JSON.stringify(body),
+    }),
+  addVariantGroupMember: (
+    groupId: number,
+    libraryFileId: number,
+    targetModel?: string,
+  ) =>
+    request<VariantGroup>(`/library/variant-groups/${groupId}/members`, {
+      method: 'POST',
+      body: JSON.stringify({
+        library_file_id: libraryFileId,
+        ...(targetModel ? { target_model: targetModel } : {}),
+      }),
+    }),
+  removeVariantGroupMember: (groupId: number, fileId: number) =>
+    request<void>(`/library/variant-groups/${groupId}/members/${fileId}`, { method: 'DELETE' }),
+  deleteVariantGroup: (groupId: number) =>
+    request<void>(`/library/variant-groups/${groupId}`, { method: 'DELETE' }),
+
   getLibraryFile: (id: number) => request<LibraryFile>(`/library/files/${id}`),
   uploadLibraryFile: async (
     file: File,
@@ -6988,6 +7063,26 @@ export interface LibraryFileListItem {
   // legacy code path (or mock) that constructs a LibraryFileListItem without
   // it doesn't crash the renderer. Read sites use `file.tags ?? []`.
   tags?: LibraryTagSummary[];
+  // Variant grouping (#671 / #2570). `variant_count` is the size of the whole
+  // group, which may include files in other folders — never the number of
+  // matching rows on screen. 0 when the file is not grouped.
+  variant_group_id?: number | null;
+  variant_count?: number;
+}
+
+// Variant groups (#671 / #2570): the same job sliced for different printers.
+export interface VariantGroupMember {
+  library_file_id: number;
+  filename: string;
+  target_model: string;
+  position: number;
+}
+
+export interface VariantGroup {
+  id: number;
+  name: string;
+  /** In priority order — index 0 wins when several printers are free at once. */
+  members: VariantGroupMember[];
 }
 
 // Library tag catalog (#1268)

+ 2 - 1
frontend/src/components/CompactHistoryRow.tsx

@@ -16,6 +16,7 @@ import { api } from '../api/client';
 import { type TimeFormat, formatDuration, formatRelativeTime } from '../utils/date';
 import type { PrintQueueItem, Permission } from '../api/client';
 import { Button } from './Button';
+import { queueItemDisplayName } from '../utils/queueItemName';
 
 const STATUS_CONFIG = {
   completed: { icon: CheckCircle, color: 'text-emerald-600 dark:text-emerald-400', border: 'border-l-emerald-500' },
@@ -54,7 +55,7 @@ export function CompactHistoryRow({
 }) {
   const config = STATUS_CONFIG[item.status as keyof typeof STATUS_CONFIG] || STATUS_CONFIG.cancelled;
   const StatusIcon = config.icon;
-  const displayName = item.archive_name || item.library_file_name || `File #${item.archive_id || item.library_file_id}`;
+  const displayName = queueItemDisplayName(item);
 
   const thumbnailUrl = item.archive_thumbnail
     ? api.getArchiveThumbnail(item.archive_id!)

+ 160 - 0
frontend/src/components/PrintModal/VariantCandidates.tsx

@@ -0,0 +1,160 @@
+import { useMemo } from 'react';
+import { useQueries } from '@tanstack/react-query';
+import { ArrowDown, ArrowUp, Layers, Printer as PrinterIcon } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
+import { api } from '../../api/client';
+
+/** One sliced file offered as an alternative for a cross-model job (#671). */
+export interface VariantCandidate {
+  id: number;
+  filename: string;
+  sliced_for_model: string | null;
+}
+
+interface VariantCandidatesProps {
+  candidates: VariantCandidate[];
+  onReorder: (next: VariantCandidate[]) => void;
+  /** file id -> chosen plate, for the multi-plate candidates only. */
+  plateByFile: Record<number, number | null>;
+  onPlateChange: (fileId: number, plateId: number | null) => void;
+  /** Show the set without offering to change it — the edit-queue-item case,
+   *  where reordering would need a variant-level API that doesn't exist. */
+  readOnly?: boolean;
+  /** Replaces the help line under the heading when read-only. */
+  readOnlyNote?: string;
+}
+
+/**
+ * The ordered candidate list for a cross-model print (#671).
+ *
+ * Only two things are configured per candidate: its order, and — when the file
+ * holds more than one plate — which plate to run. Everything else on the modal
+ * (filament overrides, print options, schedule) stays shared, because that is
+ * already how model-based assignment works: the printer is unknown at queue
+ * time, so there is nothing per-machine to configure. The AMS mapping in
+ * particular is deliberately absent — the scheduler computes it against the
+ * printer it actually picks, exactly as it does for a single-model job.
+ *
+ * Order is the answer to "which would you rather have when both are free", so
+ * it is explicit rather than left to whichever printer the matcher saw first.
+ * Move buttons instead of drag: the list is two or three rows, and buttons work
+ * from the keyboard without a drag-and-drop dependency.
+ */
+export function VariantCandidates({
+  candidates,
+  onReorder,
+  plateByFile,
+  onPlateChange,
+  readOnly = false,
+  readOnlyNote,
+}: VariantCandidatesProps) {
+  const { t } = useTranslation();
+
+  const plateQueries = useQueries({
+    // Read-only mode shows a job that is already queued — its plates were
+    // chosen when it was created, so there is nothing to fetch or offer.
+    queries: readOnly
+      ? []
+      : candidates.map((c) => ({
+          queryKey: ['library-file-plates', c.id],
+          queryFn: () => api.getLibraryFilePlates(c.id),
+          staleTime: 60_000,
+        })),
+  });
+
+  const platesByFile = useMemo(() => {
+    const out: Record<number, { index: number; name: string | null }[]> = {};
+    candidates.forEach((c, i) => {
+      const data = plateQueries[i]?.data;
+      if (data?.is_multi_plate && data.plates.length > 1) {
+        out[c.id] = data.plates.map((p) => ({ index: p.index, name: p.name }));
+      }
+    });
+    return out;
+  }, [candidates, plateQueries]);
+
+  const move = (from: number, to: number) => {
+    if (to < 0 || to >= candidates.length) return;
+    const next = [...candidates];
+    const [moved] = next.splice(from, 1);
+    next.splice(to, 0, moved);
+    onReorder(next);
+  };
+
+  return (
+    <div className="mb-4">
+      <div className="flex items-center gap-2 mb-2">
+        <PrinterIcon className="w-4 h-4 text-bambu-gray" />
+        <span className="text-sm text-bambu-gray">{t('printModal.variants.title')}</span>
+      </div>
+      <p className="text-xs text-bambu-gray mb-2">
+        {readOnly ? (readOnlyNote ?? t('printModal.variants.help')) : t('printModal.variants.help')}
+      </p>
+
+      <div className="space-y-2">
+        {candidates.map((candidate, index) => {
+          const plates = platesByFile[candidate.id];
+          // Shown exactly as the 3MF declares it. The backend normalizes when it
+          // resolves the candidate; echoing its own words here avoids a second,
+          // possibly disagreeing, normalizer in the browser.
+          const model = candidate.sliced_for_model;
+          return (
+            <div
+              key={candidate.id}
+              className="flex flex-wrap items-center gap-2 rounded border border-bambu-dark-tertiary p-2"
+            >
+              <span className="text-xs font-mono text-bambu-gray w-5 shrink-0">{index + 1}.</span>
+              <span className="px-2 py-0.5 rounded-full bg-bambu-green/10 text-bambu-green text-xs shrink-0">
+                {model || t('printModal.variants.unknownModel')}
+              </span>
+              <span className="text-sm text-white truncate min-w-0 flex-1" title={candidate.filename}>
+                {candidate.filename}
+              </span>
+
+              {plates && (
+                <label className="flex items-center gap-1 text-xs text-bambu-gray shrink-0">
+                  <Layers className="w-3 h-3" />
+                  <select
+                    className="bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded px-1 py-0.5 text-xs text-white"
+                    value={plateByFile[candidate.id] ?? plates[0].index}
+                    onChange={(e) => onPlateChange(candidate.id, Number(e.target.value))}
+                    aria-label={t('printModal.variants.plateFor', { filename: candidate.filename })}
+                  >
+                    {plates.map((p) => (
+                      <option key={p.index} value={p.index}>
+                        {p.name || t('printModal.plateN', { n: p.index })}
+                      </option>
+                    ))}
+                  </select>
+                </label>
+              )}
+
+              {!readOnly && (
+                <div className="flex items-center gap-1 shrink-0">
+                  <button
+                    type="button"
+                    onClick={() => move(index, index - 1)}
+                    disabled={index === 0}
+                    className="p-1 rounded text-bambu-gray hover:text-white disabled:opacity-30"
+                    aria-label={t('printModal.variants.moveUp')}
+                  >
+                    <ArrowUp className="w-3.5 h-3.5" />
+                  </button>
+                  <button
+                    type="button"
+                    onClick={() => move(index, index + 1)}
+                    disabled={index === candidates.length - 1}
+                    className="p-1 rounded text-bambu-gray hover:text-white disabled:opacity-30"
+                    aria-label={t('printModal.variants.moveDown')}
+                  >
+                    <ArrowDown className="w-3.5 h-3.5" />
+                  </button>
+                </div>
+              )}
+            </div>
+          );
+        })}
+      </div>
+    </div>
+  );
+}

+ 150 - 7
frontend/src/components/PrintModal/index.tsx

@@ -29,6 +29,7 @@ import { PlateSelector } from './PlateSelector';
 import { PrinterSelector } from './PrinterSelector';
 import { PrintOptionsPanel } from './PrintOptions';
 import { ScheduleOptionsPanel } from './ScheduleOptions';
+import { VariantCandidates, type VariantCandidate } from './VariantCandidates';
 import type {
   AssignmentMode,
   FilamentReqsData,
@@ -58,6 +59,7 @@ export function PrintModal({
   onSuccess,
   projectId,
   cleanupLibraryAfterDispatch,
+  variantFiles,
 }: PrintModalProps) {
   const { t } = useTranslation();
   const queryClient = useQueryClient();
@@ -68,6 +70,27 @@ export function PrintModal({
   const isLibraryFile = !!libraryFileId && !archiveId;
   const isEditing = mode === 'edit-queue-item';
 
+  // Cross-model alternatives (#671). One candidate is not a choice, so a
+  // single-entry list behaves exactly like an ordinary print.
+  const isCrossModel = mode === 'create' && (variantFiles?.length ?? 0) > 1;
+  // Editing an already-queued cross-model item. The candidates are shown so the
+  // dialog doesn't misrepresent the job as a plain "Any H2D" — which is what it
+  // did before, offering a printer picker whose Save would have left a row with
+  // both variants and a printer_id. They are not editable here: changing the
+  // set after queueing needs a variant-level API that doesn't exist, and the
+  // backend refuses the printer/model change either way.
+  const editingVariants: VariantCandidate[] =
+    mode === 'edit-queue-item' && (queueItem?.variants?.length ?? 0) > 1
+      ? queueItem!.variants!.map((v) => ({
+          id: v.library_file_id,
+          filename: v.filename,
+          sliced_for_model: v.target_model,
+        }))
+      : [];
+  const hasEditingVariants = editingVariants.length > 0;
+  const [candidates, setCandidates] = useState<VariantCandidate[]>(variantFiles ?? []);
+  const [candidatePlates, setCandidatePlates] = useState<Record<number, number | null>>({});
+
   type FilamentWarningItem = {
     printerName: string;
     slotLabel: string;
@@ -165,6 +188,11 @@ export function PrintModal({
 
   // Assignment mode: 'printer' (specific) or 'model' (any of model)
   const [assignmentMode, setAssignmentMode] = useState<AssignmentMode>(() => {
+    // Cross-model alternatives are model-based by definition — naming one
+    // printer would defeat the point of offering the other file.
+    if (isCrossModel) {
+      return 'model';
+    }
     // Initialize from queue item if editing with target_model
     if (mode === 'edit-queue-item' && queueItem?.target_model) {
       return 'model';
@@ -385,6 +413,42 @@ export function PrintModal({
     enabled: assignmentMode === 'model' && !!targetModel,
   });
 
+  // A cross-model job (#671) has no single target model, so the query above is
+  // disabled and the override UI would silently vanish — leaving less control
+  // than the ordinary "Any X1C" flow offers. Ask each candidate's model instead
+  // and offer the union: the job can land on any of them, so anything loaded on
+  // any of them is a legitimate choice. Picking one only some models have is
+  // allowed and meaningful — it narrows which candidates can match.
+  const candidateModels = useMemo(
+    () => Array.from(new Set(candidates.map((c) => c.sliced_for_model).filter((m): m is string => !!m))),
+    [candidates],
+  );
+  const candidateFilamentQueries = useQueries({
+    queries: isCrossModel
+      ? candidateModels.map((model) => ({
+          queryKey: ['available-filaments', model, targetLocation],
+          queryFn: () => api.getAvailableFilaments(model, targetLocation ?? undefined),
+        }))
+      : [],
+  });
+  const crossModelFilaments = useMemo(() => {
+    const seen = new Set<string>();
+    const merged: NonNullable<typeof availableFilaments> = [];
+    for (const query of candidateFilamentQueries) {
+      for (const filament of query.data ?? []) {
+        // Same type+colour loaded on two models is one choice, not two.
+        const key = `${filament.type}|${filament.color}|${filament.tray_info_idx}`;
+        if (!seen.has(key)) {
+          seen.add(key);
+          merged.push(filament);
+        }
+      }
+    }
+    return merged;
+  }, [candidateFilamentQueries]);
+
+  const effectiveAvailableFilaments = isCrossModel ? crossModelFilaments : availableFilaments;
+
   // Only fetch printer status when single printer selected (for filament mapping)
   const { data: printerStatus, isLoading: printerStatusLoading } = useQuery({
     queryKey: ['printer-status', effectivePrinterId],
@@ -803,17 +867,21 @@ export function PrintModal({
       showToast('Please select at least one printer', 'error');
       return;
     }
-    if (assignmentMode === 'model' && !targetModel) {
+    // A cross-model job has no single target model — each candidate carries its
+    // own, and the backend gates each of them separately. Both checks below are
+    // about the one-model case only.
+    if (!isCrossModel && assignmentMode === 'model' && !targetModel) {
       showToast('Please select a target printer model', 'error');
       return;
     }
     // Cross-model safety gate (#2578) — mirrors the backend's 400 so the user
     // gets inline feedback instead of a failed request.
-    if (assignmentMode === 'model' && !isGcodeCompatible(slicedForModel, targetModel)) {
+    if (!isCrossModel && assignmentMode === 'model' && !isGcodeCompatible(slicedForModel, targetModel)) {
       showToast(`File was sliced for ${slicedForModel} and cannot be dispatched to ${targetModel} printers`, 'error');
       return;
     }
 
+
     setIsSubmitting(true);
     // Calculate total API calls: plates × printers (or 1 for model-based)
     const platesToQueue = selectedPlates.size > 1
@@ -874,6 +942,48 @@ export function PrintModal({
         ? buildFilamentOverridesArray(perPlateReqs.get(plateId))
         : filamentOverridesArray;
 
+    // Cross-model alternatives (#671): ONE item carrying a candidate per file,
+    // in the order the user arranged. This returns before the plate/printer
+    // fan-out below because it deliberately fans out to nothing — the whole
+    // point is that exactly one of these candidates ever runs.
+    //
+    // Filament overrides are shared rather than per-candidate, matching how
+    // single-model assignment already behaves: the printer is unknown at queue
+    // time, so what is expressed here is "this job needs PETG", which is true of
+    // every slice of the same job. The AMS mapping is likewise absent — the
+    // scheduler computes it against the printer it actually picks.
+    if (isCrossModel) {
+      try {
+        await api.addToQueue({
+          variants: candidates.map((c) => ({
+            library_file_id: c.id,
+            plate_id: candidatePlates[c.id] ?? null,
+            filament_overrides: filamentOverridesArray,
+          })),
+          target_location: targetLocation,
+          require_previous_success: scheduleOptions.requirePreviousSuccess,
+          auto_off_after: scheduleOptions.autoOffAfter,
+          gcode_injection: scheduleOptions.gcodeInjection,
+          manual_start: scheduleOptions.scheduleType === 'queue' && scheduleOptions.requireManualStart,
+          scheduled_time: scheduleOptions.scheduleType === 'scheduled' && scheduleOptions.scheduledTime
+            ? new Date(scheduleOptions.scheduledTime).toISOString()
+            : undefined,
+          quantity,
+          ...printOptions,
+          project_id: projectId ?? undefined,
+        });
+        showToast(t('printModal.variants.queued', { count: candidates.length }), 'success');
+        queryClient.invalidateQueries({ queryKey: ['queue'] });
+        onSuccess?.();
+        onClose();
+      } catch (error) {
+        showToast(error instanceof Error ? error.message : String(error), 'error');
+      } finally {
+        setIsSubmitting(false);
+      }
+      return;
+    }
+
     // Multi-plate auto-batch: when the user adds 2+ plates from one source in
     // a single create submission, pre-create a PrintBatch and pass its
     // id to each subsequent addToQueue call so the queue UI groups them as a
@@ -1101,9 +1211,11 @@ export function PrintModal({
 
     // Need valid printer/model selection
     if (assignmentMode === 'printer' && selectedPrinters.length === 0) return false;
-    if (assignmentMode === 'model' && !targetModel) return false;
+    // Both are about the single-model case. A cross-model job has no one target
+    // model, and each candidate is gated against its own by the backend (#671).
+    if (!isCrossModel && assignmentMode === 'model' && !targetModel) return false;
     // Cross-model mismatch cannot be queued (#2578)
-    if (assignmentMode === 'model' && !isGcodeCompatible(slicedForModel, targetModel)) return false;
+    if (!isCrossModel && assignmentMode === 'model' && !isGcodeCompatible(slicedForModel, targetModel)) return false;
 
     // For multi-plate files, need at least one plate selected
     if (isMultiPlate && selectedPlates.size === 0) return false;
@@ -1132,6 +1244,7 @@ export function PrintModal({
     perPlateReqsPending,
     perPlateReqsFailed,
     printerStatusLoading,
+    isCrossModel,
   ]);
 
   // Quantity only applies for single-printer or model-based assignment (not multi-printer)
@@ -1199,8 +1312,13 @@ export function PrintModal({
   // is the filament each slot must be printed in, which the scheduler matches
   // against whatever printer of the model it picks. Needs the model's loaded
   // filaments to offer as alternatives.
+  // Cross-model items have no targetModel by design — their candidates each
+  // carry their own — so gate on having somewhere to source choices from.
   const showFilamentOverride =
-    assignmentMode === 'model' && !!targetModel && !!availableFilaments && availableFilaments.length > 0;
+    assignmentMode === 'model'
+    && (isCrossModel || !!targetModel)
+    && !!effectiveAvailableFilaments
+    && effectiveAvailableFilaments.length > 0;
 
   // Dual-nozzle gate for the Nozzle Offset Calibration toggle (#1682).
   // Mirrors backend `DUAL_NOZZLE_MODELS` so model-based assignment can show
@@ -1295,8 +1413,33 @@ export function PrintModal({
               multiSelect={!isEditing}
             />
 
+            {/* Cross-model alternatives (#671) replace the printer picker entirely:
+                the user already answered "which printer" by choosing these files,
+                and the remaining question is only which they'd rather have. */}
+            {isCrossModel && (
+              <VariantCandidates
+                candidates={candidates}
+                onReorder={setCandidates}
+                plateByFile={candidatePlates}
+                onPlateChange={(fileId, plateId) =>
+                  setCandidatePlates((prev) => ({ ...prev, [fileId]: plateId }))
+                }
+              />
+            )}
+
+            {hasEditingVariants && (
+              <VariantCandidates
+                candidates={editingVariants}
+                readOnly
+                readOnlyNote={t('printModal.variants.editNote')}
+                onReorder={() => {}}
+                plateByFile={{}}
+                onPlateChange={() => {}}
+              />
+            )}
+
             {/* Printer selection with per-printer mapping — hidden when printer is pre-selected via props */}
-            {!initialSelectedPrinterIds?.length && (
+            {!isCrossModel && !hasEditingVariants && !initialSelectedPrinterIds?.length && (
               <PrinterSelector
                 printers={printers || []}
                 selectedPrinterIds={selectedPrinters}
@@ -1328,7 +1471,7 @@ export function PrintModal({
             {showFilamentOverride && !isMultiPlateSelection && effectiveFilamentReqs && (
               <FilamentOverride
                 filamentReqs={effectiveFilamentReqs}
-                availableFilaments={availableFilaments!}
+                availableFilaments={effectiveAvailableFilaments!}
                 overrides={filamentOverrides}
                 onChange={setFilamentOverrides}
                 forceColorMatch={forceColorMatch}

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

@@ -1,6 +1,8 @@
 import type { PrintQueueItem, Printer, CalibrationMode } from '../../api/client';
+import type { VariantCandidate } from './VariantCandidates';
 
 export type { CalibrationMode };
+export type { VariantCandidate };
 
 /**
  * Mode of operation for the PrintModal.
@@ -38,6 +40,18 @@ export interface PrintModalProps {
   /** Delete the LibraryFile after dispatch — used by the Printers-page Direct-Print flow
    *  so transient uploads don't linger in File Manager. Only applies to library-file prints. */
   cleanupLibraryAfterDispatch?: boolean;
+  /**
+   * Cross-model alternatives (#671): the same job sliced for several printers,
+   * to be queued as ONE item that runs on whichever frees up first.
+   *
+   * Supplied by the File Manager when the user multi-selects sliced files, or
+   * when the clicked file belongs to a variant group. Two or more entries put
+   * the modal in cross-model mode: the printer picker is replaced by the ordered
+   * candidate list, and submit posts `variants` instead of a single file.
+   * `libraryFileId` must still be the first candidate — the shared filament and
+   * plate preview reads from it.
+   */
+  variantFiles?: VariantCandidate[];
 }
 
 /**

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

@@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next';
 import { api } from '../api/client';
 import { formatRelativeTime } from '../utils/date';
 import { filterCompatibleQueueItems } from '../utils/printer';
+import { queueItemDisplayName } from '../utils/queueItemName';
 
 interface PrinterQueueWidgetProps {
   printerId: number;
@@ -53,7 +54,7 @@ export function PrinterQueueWidget({ printerId, printerModel, loadedFilamentType
           <div className="min-w-0 flex-1">
             <p className="text-xs text-bambu-gray">{t('queue.nextInQueue')}</p>
             <p className="text-sm text-white truncate">
-              {nextItem?.archive_name || nextItem?.library_file_name || `File #${nextItem?.archive_id || nextItem?.library_file_id}`}
+              {nextItem ? queueItemDisplayName(nextItem) : ''}
             </p>
           </div>
         </div>

+ 49 - 0
frontend/src/components/SliceModal.tsx

@@ -237,6 +237,15 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
   // see canUseEmbedded below.
   const [useEmbedded, setUseEmbedded] = useState(false);
 
+  // Auto-orient / auto-arrange (#2548) — the GUI's two layout buttons,
+  // forwarded as the slicer's --orient / --arrange CLI actions. Per-slice
+  // and off by default: both rewrite the object placement the file came
+  // with, so they are something the user asks for, never a default. Kept
+  // enabled in embedded mode, unlike the process-level options around
+  // them — these act on the geometry, whichever config drives the slice.
+  const [autoOrient, setAutoOrient] = useState(false);
+  const [autoArrange, setAutoArrange] = useState(false);
+
   // #2622: process settings the designer changed away from the stock preset,
   // carried onto the picked process profile so a cross-printer re-slice keeps
   // the model's intended wall count / infill / first layer instead of losing
@@ -544,6 +553,10 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
       // which the embedded-settings path never sends — so they are mutually
       // exclusive by construction (#2622).
       ...(!useEmbedded && designKeys.size > 0 ? { design_overrides: [...designKeys] } : {}),
+      // Sent only when on. The backend defaults both to false, so omitting
+      // them keeps the request identical to what older clients send.
+      ...(autoOrient ? { auto_orient: true } : {}),
+      ...(autoArrange ? { auto_arrange: true } : {}),
     };
   }
 
@@ -888,6 +901,42 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
                 onChange={setBedType}
                 disabled={isEnqueuing || useEmbedded}
               />
+              {/* Layout passes (#2548) — the GUI's "Auto orient" / "Auto
+                  arrange". Not disabled in embedded mode: these are CLI
+                  actions on the geometry, so they work regardless of where
+                  the print config comes from. */}
+              <div className="flex flex-col gap-2">
+                <label className="flex items-start gap-2 text-sm text-bambu-gray cursor-pointer select-none">
+                  <input
+                    type="checkbox"
+                    checked={autoOrient}
+                    onChange={(e) => setAutoOrient(e.target.checked)}
+                    disabled={isEnqueuing}
+                    className="mt-0.5 cursor-pointer"
+                  />
+                  <span>
+                    {t('slice.autoOrient')}
+                    <span className="block text-xs text-bambu-gray/70">
+                      {t('slice.autoOrientHint')}
+                    </span>
+                  </span>
+                </label>
+                <label className="flex items-start gap-2 text-sm text-bambu-gray cursor-pointer select-none">
+                  <input
+                    type="checkbox"
+                    checked={autoArrange}
+                    onChange={(e) => setAutoArrange(e.target.checked)}
+                    disabled={isEnqueuing}
+                    className="mt-0.5 cursor-pointer"
+                  />
+                  <span>
+                    {t('slice.autoArrange')}
+                    <span className="block text-xs text-bambu-gray/70">
+                      {t('slice.autoArrangeHint')}
+                    </span>
+                  </span>
+                </label>
+              </div>
               {/* Filament reqs may need a server-side preview-slice for
                   unsliced project files (single-pass, then cached). Show a
                   scoped spinner so the user sees the printer/process

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

@@ -33,6 +33,7 @@ export default {
 
   // Common
   common: {
+    plusNMore: '+{{count}} weitere',
     save: 'Speichern',
     saving: 'Speichern...',
     cancel: 'Abbrechen',
@@ -3562,6 +3563,13 @@ export default {
 
   // File manager
   fileManager: {
+    variants: {
+      badge: '{{count}} Versionen',
+      groupAction: 'Als Versionen gruppieren',
+      groupTooltip: 'Diese Dateien als denselben Auftrag markieren, gesliced für verschiedene Drucker',
+      grouped: '{{count}} Dateien als Versionen gruppiert',
+      printAlternatives: 'Drucken ({{count}} Alternativen)',
+    },
     title: 'Dateimanager',
     subtitle: 'Organisieren und verwalten Sie Ihre Druckdateien',
     uploadFiles: 'Dateien hochladen',
@@ -4090,6 +4098,10 @@ export default {
     allPresetsRequired: 'Alle Profile müssen ausgewählt sein',
     useEmbedded: 'Eingebettete Einstellungen der Datei verwenden',
     useEmbeddedHint: 'So slicen, wie der Ersteller es angelegt hat (Wände, Füllung, Filament), statt mit den obigen Profilen. Verfügbar, weil dein Drucker zur Datei passt.',
+    autoOrient: 'Objekte automatisch ausrichten',
+    autoOrientHint: 'Der Slicer dreht jedes Objekt zuerst auf die am besten druckbare Seite. Überschreibt die Ausrichtung aus der Datei.',
+    autoArrange: 'Automatisch auf dem Druckbett anordnen',
+    autoArrangeHint: 'Der Slicer verteilt die Objekte so, dass sie sich nicht mehr überlappen. Ersetzt die Anordnung aus der Datei.',
     designSettings: 'Einstellungen des Erstellers behalten',
     designSettingsHint: 'Diese Datei ändert {{count}} Druckeinstellung(en) gegenüber dem Standardprofil.',
     designSettingsSelected: '{{selected}} von {{total}} ausgewählt',
@@ -4628,6 +4640,16 @@ export default {
 
   // Print modal
   printModal: {
+    variants: {
+      editNote: 'Diese Alternativen wurden beim Einreihen festgelegt. Zum Ändern abbrechen und neu einreihen.',
+      title: 'Drucker-Alternativen',
+      help: 'Ein Auftrag, ein Warteschlangenplatz. Der erste passende Drucker, der frei wird, druckt seine Datei.',
+      unknownModel: 'Unbekanntes Modell',
+      plateFor: 'Platte für {{filename}}',
+      moveUp: 'Nach oben',
+      moveDown: 'Nach unten',
+      queued: 'Mit {{count}} Alternativen eingereiht',
+    },
     selectPrinter: 'Drucker auswählen',
     selectPlate: 'Platte auswählen',
     filamentMapping: 'Filamentzuordnung',

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

@@ -33,6 +33,7 @@ export default {
 
   // Common
   common: {
+    plusNMore: '+{{count}} more',
     save: 'Save',
     saving: 'Saving...',
     cancel: 'Cancel',
@@ -3591,6 +3592,13 @@ export default {
 
   // File manager
   fileManager: {
+    variants: {
+      badge: '{{count}} versions',
+      groupAction: 'Group as versions',
+      groupTooltip: 'Mark these files as the same job sliced for different printers',
+      grouped: 'Grouped {{count}} files as versions',
+      printAlternatives: 'Print ({{count}} alternatives)',
+    },
     title: 'File Manager',
     subtitle: 'Organize and manage your print files',
     uploadFiles: 'Upload Files',
@@ -4124,6 +4132,10 @@ export default {
     allPresetsRequired: 'All presets must be selected',
     useEmbedded: "Use the file's built-in settings",
     useEmbeddedHint: "Slice it the way the designer set it up (walls, infill, filament) instead of the profiles above. Offered because your printer matches the file's.",
+    autoOrient: 'Auto-orient objects',
+    autoOrientHint: 'Let the slicer turn each object onto its best printing side first. Overrides the way the model was laid down in the file.',
+    autoArrange: 'Auto-arrange on the plate',
+    autoArrangeHint: 'Let the slicer position the objects so they no longer overlap. Replaces the layout the file came with.',
     designSettings: 'Keep the designer\'s settings',
     designSettingsHint: 'This file changes {{count}} print setting(s) from the stock profile.',
     designSettingsSelected: '{{selected}} of {{total}} selected',
@@ -4671,6 +4683,16 @@ export default {
 
   // Print modal
   printModal: {
+    variants: {
+      editNote: 'These alternatives were set when the job was queued. Cancel and re-queue to change them.',
+      title: 'Printer alternatives',
+      help: 'One job, one queue slot. The first matching printer to free up runs its file.',
+      unknownModel: 'Unknown model',
+      plateFor: 'Plate for {{filename}}',
+      moveUp: 'Move up',
+      moveDown: 'Move down',
+      queued: 'Queued with {{count}} alternatives',
+    },
     selectPrinter: 'Select Printer',
     selectPlate: 'Select Plate',
     filamentMapping: 'Filament Mapping',

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

@@ -33,6 +33,7 @@ export default {
 
   // Common
   common: {
+    plusNMore: '+{{count}} más',
     save: 'Guardar',
     saving: 'Guardando...',
     cancel: 'Cancelar',
@@ -3565,6 +3566,13 @@ export default {
 
   // File manager
   fileManager: {
+    variants: {
+      badge: '{{count}} versiones',
+      groupAction: 'Agrupar como versiones',
+      groupTooltip: 'Marcar estos archivos como el mismo trabajo laminado para distintas impresoras',
+      grouped: '{{count}} archivos agrupados como versiones',
+      printAlternatives: 'Imprimir ({{count}} alternativas)',
+    },
     title: 'Gestor de archivos',
     subtitle: 'Organice y gestione sus archivos de impresión',
     uploadFiles: 'Subir archivos',
@@ -4093,6 +4101,10 @@ export default {
     allPresetsRequired: 'Deben seleccionarse todos los preajustes',
     useEmbedded: 'Usar la configuración incorporada del archivo',
     useEmbeddedHint: 'Laminar tal como lo configuró el diseñador (perímetros, relleno, filamento) en lugar de los perfiles de arriba. Disponible porque tu impresora coincide con la del archivo.',
+    autoOrient: 'Orientar los objetos automáticamente',
+    autoOrientHint: 'El laminador gira cada objeto hacia su mejor cara de impresión antes de laminar. Sustituye la orientación del archivo.',
+    autoArrange: 'Organizar automáticamente en la base',
+    autoArrangeHint: 'El laminador coloca los objetos para que dejen de solaparse. Sustituye la disposición del archivo.',
     designSettings: 'Mantener los ajustes del diseñador',
     designSettingsHint: 'Este archivo cambia {{count}} ajuste(s) de impresión respecto al perfil estándar.',
     designSettingsSelected: '{{selected}} de {{total}} seleccionados',
@@ -4636,6 +4648,16 @@ export default {
 
   // Print modal
   printModal: {
+    variants: {
+      editNote: 'Estas alternativas se fijaron al poner el trabajo en cola. Cancela y vuelve a encolar para cambiarlas.',
+      title: 'Alternativas de impresora',
+      help: 'Un trabajo, un puesto en la cola. La primera impresora compatible que quede libre imprime su archivo.',
+      unknownModel: 'Modelo desconocido',
+      plateFor: 'Placa para {{filename}}',
+      moveUp: 'Subir',
+      moveDown: 'Bajar',
+      queued: 'En cola con {{count}} alternativas',
+    },
     selectPrinter: 'Seleccionar impresora',
     selectPlate: 'Seleccionar cama',
     filamentMapping: 'Mapeo de filamentos',

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

@@ -33,6 +33,7 @@ export default {
 
   // Common
   common: {
+    plusNMore: '+{{count}} autres',
     save: 'Enregistrer',
     saving: 'Enregistrement...',
     cancel: 'Annuler',
@@ -3551,6 +3552,13 @@ export default {
 
   // File manager
   fileManager: {
+    variants: {
+      badge: 'Versions : {{count}}',
+      groupAction: 'Grouper comme versions',
+      groupTooltip: 'Marquer ces fichiers comme le même travail tranché pour différentes imprimantes',
+      grouped: '{{count}} fichiers groupés comme versions',
+      printAlternatives: 'Imprimer ({{count}} alternatives)',
+    },
     title: 'Gestionnaire de fichiers',
     subtitle: 'Organisez vos fichiers d\'impression',
     uploadFiles: 'Téléverser fichiers',
@@ -4079,6 +4087,10 @@ export default {
     allPresetsRequired: 'Tous les préréglages doivent être sélectionnés',
     useEmbedded: 'Utiliser les réglages intégrés du fichier',
     useEmbeddedHint: "Slicer tel que le concepteur l'a configuré (parois, remplissage, filament) au lieu des profils ci-dessus. Proposé car votre imprimante correspond à celle du fichier.",
+    autoOrient: 'Orienter les objets automatiquement',
+    autoOrientHint: "Le trancheur fait pivoter chaque objet sur sa meilleure face d'impression avant de trancher. Remplace l'orientation enregistrée dans le fichier.",
+    autoArrange: 'Disposer automatiquement sur le plateau',
+    autoArrangeHint: "Le trancheur place les objets pour qu'ils ne se chevauchent plus. Remplace la disposition du fichier.",
     designSettings: 'Conserver les réglages du concepteur',
     designSettingsHint: 'Ce fichier modifie {{count}} réglage(s) d\'impression par rapport au profil standard.',
     designSettingsSelected: '{{selected}} sur {{total}} sélectionnés',
@@ -4617,6 +4629,16 @@ export default {
 
   // Print modal
   printModal: {
+    variants: {
+      editNote: 'Ces alternatives ont été définies lors de la mise en file. Annulez et remettez en file pour les modifier.',
+      title: 'Alternatives d\'imprimante',
+      help: 'Un travail, une place dans la file. La première imprimante compatible qui se libère imprime son fichier.',
+      unknownModel: 'Modèle inconnu',
+      plateFor: 'Plateau pour {{filename}}',
+      moveUp: 'Monter',
+      moveDown: 'Descendre',
+      queued: 'Mis en file avec {{count}} alternatives',
+    },
     selectPrinter: 'Choisir l\'imprimante',
     selectPlate: 'Choisir le plateau',
     filamentMapping: 'Mapping Filament',

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

@@ -33,6 +33,7 @@ export default {
 
   // Common
   common: {
+    plusNMore: '+{{count}} altri',
     save: 'Salva',
     saving: 'Salvataggio...',
     cancel: 'Annulla',
@@ -3550,6 +3551,13 @@ export default {
 
   // File manager
   fileManager: {
+    variants: {
+      badge: '{{count}} versioni',
+      groupAction: 'Raggruppa come versioni',
+      groupTooltip: 'Segna questi file come lo stesso lavoro elaborato per stampanti diverse',
+      grouped: '{{count}} file raggruppati come versioni',
+      printAlternatives: 'Stampa ({{count}} alternative)',
+    },
     title: 'Gestore file',
     subtitle: 'Organizza e gestisci i tuoi file di stampa',
     uploadFiles: 'Carica file',
@@ -4078,6 +4086,10 @@ export default {
     allPresetsRequired: 'Tutti i preset devono essere selezionati',
     useEmbedded: 'Usa le impostazioni integrate del file',
     useEmbeddedHint: 'Slicia come impostato dal designer (pareti, riempimento, filamento) invece dei profili sopra. Disponibile perché la tua stampante corrisponde a quella del file.',
+    autoOrient: 'Orienta automaticamente gli oggetti',
+    autoOrientHint: "Lo slicer ruota ogni oggetto sul lato che si stampa meglio prima di affettare. Sostituisce l'orientamento salvato nel file.",
+    autoArrange: 'Disponi automaticamente sul piatto',
+    autoArrangeHint: 'Lo slicer dispone gli oggetti in modo che non si sovrappongano più. Sostituisce la disposizione del file.',
     designSettings: 'Mantieni le impostazioni del progettista',
     designSettingsHint: 'Questo file modifica {{count}} impostazione/i di stampa rispetto al profilo standard.',
     designSettingsSelected: '{{selected}} di {{total}} selezionate',
@@ -4616,6 +4628,16 @@ export default {
 
   // Print modal
   printModal: {
+    variants: {
+      editNote: 'Queste alternative sono state definite al momento dell\'accodamento. Annulla e riaccoda per modificarle.',
+      title: 'Alternative di stampante',
+      help: 'Un lavoro, un posto in coda. La prima stampante compatibile che si libera stampa il suo file.',
+      unknownModel: 'Modello sconosciuto',
+      plateFor: 'Piatto per {{filename}}',
+      moveUp: 'Sposta su',
+      moveDown: 'Sposta giù',
+      queued: 'In coda con {{count}} alternative',
+    },
     selectPrinter: 'Seleziona stampante',
     selectPlate: 'Seleziona piatto',
     filamentMapping: 'Mappatura filamento',

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

@@ -33,6 +33,7 @@ export default {
 
   // Common
   common: {
+    plusNMore: '他{{count}}件',
     save: '保存',
     saving: '保存中...',
     cancel: 'キャンセル',
@@ -3562,6 +3563,13 @@ export default {
 
   // File manager
   fileManager: {
+    variants: {
+      badge: '{{count}}個のバージョン',
+      groupAction: 'バージョンとしてグループ化',
+      groupTooltip: 'これらのファイルを、異なるプリンター向けにスライスした同一ジョブとして扱います',
+      grouped: '{{count}}個のファイルをバージョンとしてグループ化しました',
+      printAlternatives: '印刷({{count}}件の候補)',
+    },
     title: 'ファイル管理',
     subtitle: '印刷ファイルの整理と管理',
     uploadFiles: 'ファイルをアップロード',
@@ -4090,6 +4098,10 @@ export default {
     allPresetsRequired: 'すべてのプリセットを選択する必要があります',
     useEmbedded: 'ファイルに埋め込まれた設定を使用',
     useEmbeddedHint: '上のプロファイルではなく、設計者が設定したとおり(ウォール、インフィル、フィラメント)にスライスします。お使いのプリンターがファイルと一致するため利用できます。',
+    autoOrient: 'オブジェクトの向きを自動で調整',
+    autoOrientHint: 'スライスする前に、各オブジェクトを印刷に適した面へ自動で回転させます。ファイルに保存された向きは上書きされます。',
+    autoArrange: 'プレート上に自動配置',
+    autoArrangeHint: 'オブジェクトが重ならないようにスライサーが並べ直します。ファイルの配置は置き換えられます。',
     designSettings: '設計者の設定を保持',
     designSettingsHint: 'このファイルは標準プロファイルから {{count}} 個の印刷設定を変更しています。',
     designSettingsSelected: '{{total}} 個中 {{selected}} 個を選択',
@@ -4628,6 +4640,16 @@ export default {
 
   // Print modal
   printModal: {
+    variants: {
+      editNote: 'これらの候補はキュー追加時に決まります。変更するにはキャンセルして追加し直してください。',
+      title: 'プリンターの候補',
+      help: '1つのジョブ、キューは1枠。条件に合う最初に空いたプリンターがそのファイルを印刷します。',
+      unknownModel: '不明なモデル',
+      plateFor: '{{filename}} のプレート',
+      moveUp: '上へ',
+      moveDown: '下へ',
+      queued: '{{count}}件の候補付きでキューに追加しました',
+    },
     selectPrinter: 'プリンターを選択',
     selectPlate: 'プレートを選択',
     filamentMapping: 'フィラメントマッピング',

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

@@ -30,6 +30,7 @@ export default {
     installAppSuccess: 'Bambuddy가 설치되었습니다'
   },
   common: {
+    plusNMore: '외 {{count}}개',
     save: '저장',
     saving: '저장 중...',
     cancel: '취소',
@@ -3374,6 +3375,13 @@ export default {
     bundleStepBuild: '지원 번들 ZIP 빌드 중'
   },
   fileManager: {
+    variants: {
+      badge: '버전 {{count}}개',
+      groupAction: '버전으로 그룹화',
+      groupTooltip: '이 파일들을 서로 다른 프린터용으로 슬라이스한 동일 작업으로 표시합니다',
+      grouped: '파일 {{count}}개를 버전으로 그룹화했습니다',
+      printAlternatives: '인쇄 (대안 {{count}}개)',
+    },
     title: '파일 관리자',
     subtitle: '인쇄 파일 정리 및 관리',
     uploadFiles: '파일 업로드',
@@ -3879,6 +3887,10 @@ export default {
     allPresetsRequired: '모든 프리셋을 선택해야 합니다',
     useEmbedded: '파일에 포함된 설정 사용',
     useEmbeddedHint: '위 프로필 대신 디자이너가 설정한 대로(벽, 내부 채움, 필라멘트) 슬라이싱합니다. 프린터가 파일과 일치하여 사용할 수 있습니다.',
+    autoOrient: '개체 방향 자동 조정',
+    autoOrientHint: '슬라이싱하기 전에 각 개체를 출력하기 좋은 면으로 회전시킵니다. 파일에 저장된 방향을 덮어씁니다.',
+    autoArrange: '플레이트에 자동 배치',
+    autoArrangeHint: '개체가 겹치지 않도록 슬라이서가 다시 배치합니다. 파일의 배치를 대체합니다.',
     designSettings: '디자이너 설정 유지',
     designSettingsHint: '이 파일은 기본 프로파일에서 {{count}}개의 출력 설정을 변경합니다.',
     designSettingsSelected: '{{total}}개 중 {{selected}}개 선택됨',
@@ -4400,6 +4412,16 @@ export default {
     emptySlotReset: '필라멘트가 할당되지 않음'
   },
   printModal: {
+    variants: {
+      editNote: '이 대안은 대기열에 추가할 때 정해집니다. 변경하려면 취소 후 다시 추가하세요.',
+      title: '프린터 대안',
+      help: '작업 하나, 대기열 한 자리. 조건이 맞는 프린터 중 먼저 비는 프린터가 해당 파일을 인쇄합니다.',
+      unknownModel: '알 수 없는 모델',
+      plateFor: '{{filename}}의 플레이트',
+      moveUp: '위로',
+      moveDown: '아래로',
+      queued: '대안 {{count}}개와 함께 대기열에 추가했습니다',
+    },
     selectPrinter: '프린터 선택',
     selectPlate: '플레이트 선택',
     filamentMapping: '필라멘트 매핑',

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

@@ -33,6 +33,7 @@ export default {
 
   // Common
   common: {
+    plusNMore: '+{{count}} outros',
     save: 'Salvar',
     saving: 'Salvando...',
     cancel: 'Cancelar',
@@ -3550,6 +3551,13 @@ export default {
 
   // File manager
   fileManager: {
+    variants: {
+      badge: '{{count}} versões',
+      groupAction: 'Agrupar como versões',
+      groupTooltip: 'Marcar estes arquivos como o mesmo trabalho fatiado para impressoras diferentes',
+      grouped: '{{count}} arquivos agrupados como versões',
+      printAlternatives: 'Imprimir ({{count}} alternativas)',
+    },
     title: 'Gerenciador de Arquivos',
     subtitle: 'Organize e gerencie seus arquivos de impressão',
     uploadFiles: 'Enviar Arquivos',
@@ -4078,6 +4086,10 @@ export default {
     allPresetsRequired: 'Todas as predefinições devem ser selecionadas',
     useEmbedded: 'Usar as configurações incorporadas do arquivo',
     useEmbeddedHint: 'Fatiar como o designer configurou (paredes, preenchimento, filamento) em vez dos perfis acima. Disponível porque sua impressora corresponde à do arquivo.',
+    autoOrient: 'Orientar os objetos automaticamente',
+    autoOrientHint: 'O fatiador gira cada objeto para o lado que imprime melhor antes de fatiar. Substitui a orientação salva no arquivo.',
+    autoArrange: 'Organizar automaticamente na mesa',
+    autoArrangeHint: 'O fatiador posiciona os objetos para que não se sobreponham. Substitui a disposição do arquivo.',
     designSettings: 'Manter as configurações do designer',
     designSettingsHint: 'Este arquivo altera {{count}} configuração(ões) de impressão em relação ao perfil padrão.',
     designSettingsSelected: '{{selected}} de {{total}} selecionadas',
@@ -4616,6 +4628,16 @@ export default {
 
   // Print modal
   printModal: {
+    variants: {
+      editNote: 'Estas alternativas foram definidas ao enfileirar o trabalho. Cancele e enfileire de novo para alterá-las.',
+      title: 'Alternativas de impressora',
+      help: 'Um trabalho, uma vaga na fila. A primeira impressora compatível que ficar livre imprime o arquivo dela.',
+      unknownModel: 'Modelo desconhecido',
+      plateFor: 'Mesa para {{filename}}',
+      moveUp: 'Mover para cima',
+      moveDown: 'Mover para baixo',
+      queued: 'Na fila com {{count}} alternativas',
+    },
     selectPrinter: 'Selecionar Impressora',
     selectPlate: 'Selecionar Placa',
     filamentMapping: 'Mapeamento de Filamento',

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

@@ -30,6 +30,7 @@ export default {
     installAppSuccess: "Bambuddy установлен",
   },
   common: {
+    plusNMore: 'ещё {{count}}',
     save: "Сохранить",
     saving: "Сохранение...",
     cancel: "Отмена",
@@ -3366,6 +3367,13 @@ export default {
     bundleStepBuild: "Создание ZIP-пакета для поддержки",
   },
   fileManager: {
+    variants: {
+      badge: 'Версий: {{count}}',
+      groupAction: 'Сгруппировать как версии',
+      groupTooltip: 'Пометить эти файлы как одну задачу, нарезанную для разных принтеров',
+      grouped: 'Файлов сгруппировано как версии: {{count}}',
+      printAlternatives: 'Печать (вариантов: {{count}})',
+    },
     title: "Файловый менеджер",
     subtitle: "Организация и управление файлами для печати",
     uploadFiles: "Загрузить файлы",
@@ -3875,6 +3883,10 @@ export default {
     allPresetsRequired: "Необходимо выбрать все профили",
     useEmbedded: "Использовать встроенные настройки файла",
     useEmbeddedHint: "Нарезать так, как задумал автор модели (стенки, заполнение, филамент), вместо профилей выше. Доступно, потому что ваш принтер совпадает с указанным в файле.",
+    autoOrient: 'Автоматически ориентировать объекты',
+    autoOrientHint: 'Слайсер повернёт каждый объект на сторону, которая печатается лучше всего. Ориентация из файла будет заменена.',
+    autoArrange: 'Автоматически разместить на столе',
+    autoArrangeHint: 'Слайсер расставит объекты так, чтобы они не перекрывались. Расположение из файла будет заменено.',
     designSettings: "Сохранить настройки автора",
     designSettingsHint: "Этот файл меняет {{count}} настроек печати по сравнению со стандартным профилем.",
     designSettingsSelected: "Выбрано {{selected}} из {{total}}",
@@ -4389,6 +4401,16 @@ export default {
     remainingUnit: "осталось",
   },
   printModal: {
+    variants: {
+      editNote: 'Эти варианты заданы при добавлении в очередь. Чтобы изменить их, отмените и добавьте заново.',
+      title: 'Варианты принтера',
+      help: 'Одна задача, одно место в очереди. Первый подходящий освободившийся принтер напечатает свой файл.',
+      unknownModel: 'Неизвестная модель',
+      plateFor: 'Стол для {{filename}}',
+      moveUp: 'Вверх',
+      moveDown: 'Вниз',
+      queued: 'В очереди, вариантов: {{count}}',
+    },
     selectPrinter: "Выберите принтер",
     selectPlate: "Выберите пластину",
     filamentMapping: "Сопоставление филаментов",

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

@@ -33,6 +33,7 @@ export default {
 
   // Ortak
   common: {
+    plusNMore: '+{{count}} tane daha',
     save: 'Kaydet',
     saving: 'Kaydediliyor...',
     cancel: 'İptal',
@@ -3558,6 +3559,13 @@ export default {
 
   // Dosya yöneticisi
   fileManager: {
+    variants: {
+      badge: '{{count}} sürüm',
+      groupAction: 'Sürüm olarak grupla',
+      groupTooltip: 'Bu dosyaları farklı yazıcılar için dilimlenmiş aynı iş olarak işaretle',
+      grouped: '{{count}} dosya sürüm olarak gruplandı',
+      printAlternatives: 'Yazdır ({{count}} alternatif)',
+    },
     title: 'Dosya Yöneticisi',
     subtitle: 'Baskı dosyalarınızı organize edin ve yönetin',
     uploadFiles: 'Dosya Yükle',
@@ -4080,6 +4088,10 @@ export default {
     allPresetsRequired: 'Tüm ön ayarlar seçilmelidir',
     useEmbedded: 'Dosyanın yerleşik ayarlarını kullan',
     useEmbeddedHint: 'Yukarıdaki profiller yerine tasarımcının ayarladığı gibi (duvarlar, dolgu, filament) dilimle. Yazıcınız dosyayla eşleştiği için sunuluyor.',
+    autoOrient: 'Nesneleri otomatik yönlendir',
+    autoOrientHint: 'Dilimleyici, dilimlemeden önce her nesneyi en iyi basılan yüzüne çevirir. Dosyada kayıtlı yönlendirmenin yerini alır.',
+    autoArrange: 'Tablaya otomatik yerleştir',
+    autoArrangeHint: 'Dilimleyici nesneleri üst üste binmeyecek şekilde yerleştirir. Dosyadaki yerleşimin yerini alır.',
     designSettings: 'Tasarımcının ayarlarını koru',
     designSettingsHint: 'Bu dosya standart profile göre {{count}} baskı ayarını değiştiriyor.',
     designSettingsSelected: '{{total}} ayardan {{selected}} tanesi seçili',
@@ -4606,6 +4618,16 @@ export default {
 
   // Baskı modali
   printModal: {
+    variants: {
+      editNote: 'Bu alternatifler iş kuyruğa alınırken belirlendi. Değiştirmek için iptal edip yeniden kuyruğa alın.',
+      title: 'Yazıcı alternatifleri',
+      help: 'Tek iş, tek kuyruk yeri. Uygun olan ilk boşalan yazıcı kendi dosyasını yazdırır.',
+      unknownModel: 'Bilinmeyen model',
+      plateFor: '{{filename}} için tabla',
+      moveUp: 'Yukarı taşı',
+      moveDown: 'Aşağı taşı',
+      queued: '{{count}} alternatifle kuyruğa alındı',
+    },
     selectPrinter: 'Yazıcı Seç',
     selectPlate: 'Plaka Seç',
     filamentMapping: 'Filament Eşlemesi',

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

@@ -33,6 +33,7 @@ export default {
 
   // Common
   common: {
+    plusNMore: 'ще {{count}}',
     save: "Зберегти",
     saving: "Збереження...",
     cancel: "Скасувати",
@@ -3591,6 +3592,13 @@ export default {
 
   // File manager
   fileManager: {
+    variants: {
+      badge: 'Версій: {{count}}',
+      groupAction: 'Згрупувати як версії',
+      groupTooltip: 'Позначити ці файли як одне завдання, нарізане для різних принтерів',
+      grouped: 'Файлів згруповано як версії: {{count}}',
+      printAlternatives: 'Друк (варіантів: {{count}})',
+    },
     title: "Менеджер файлів",
     subtitle: "Упорядковуйте файли друку та керуйте ними",
     uploadFiles: "Вивантажити файли",
@@ -4124,6 +4132,10 @@ export default {
     allPresetsRequired: "Потрібно вибрати всі профілі",
     useEmbedded: "Використати вбудовані налаштування файлу",
     useEmbeddedHint: "Нарізати модель із налаштуваннями автора файлу — стінками, заповненням і філаментом — замість профілів вище. Ця можливість доступна, оскільки модель принтера відповідає файлу.",
+    autoOrient: "Автоматично орієнтувати об'єкти",
+    autoOrientHint: "Слайсер поверне кожен об'єкт на бік, який друкується найкраще. Орієнтацію з файлу буде замінено.",
+    autoArrange: 'Автоматично розмістити на столі',
+    autoArrangeHint: "Слайсер розставить об'єкти так, щоб вони не перекривалися. Розташування з файлу буде замінено.",
     designSettings: "Зберегти налаштування автора",
     designSettingsHint: "Цей файл змінює {{count}} налаштувань друку порівняно зі стандартним профілем.",
     designSettingsSelected: "Вибрано {{selected}} із {{total}}",
@@ -4671,6 +4683,16 @@ export default {
 
   // Print modal
   printModal: {
+    variants: {
+      editNote: 'Ці варіанти задано під час додавання в чергу. Щоб змінити, скасуйте та додайте знову.',
+      title: 'Варіанти принтера',
+      help: 'Одне завдання, одне місце в черзі. Перший відповідний принтер, що звільниться, надрукує свій файл.',
+      unknownModel: 'Невідома модель',
+      plateFor: 'Стіл для {{filename}}',
+      moveUp: 'Вгору',
+      moveDown: 'Вниз',
+      queued: 'У черзі, варіантів: {{count}}',
+    },
     selectPrinter: "Вибрати принтер",
     selectPlate: "Вибрати пластину",
     filamentMapping: "Зіставлення філаментів",

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

@@ -33,6 +33,7 @@ export default {
 
   // Common
   common: {
+    plusNMore: '另 {{count}} 个',
     save: '保存',
     saving: '保存中...',
     cancel: '取消',
@@ -3550,6 +3551,13 @@ export default {
 
   // File manager
   fileManager: {
+    variants: {
+      badge: '{{count}} 个版本',
+      groupAction: '归为版本组',
+      groupTooltip: '将这些文件标记为针对不同打印机切片的同一任务',
+      grouped: '已将 {{count}} 个文件归为版本组',
+      printAlternatives: '打印({{count}} 个备选)',
+    },
     title: '文件管理器',
     subtitle: '组织和管理您的打印文件',
     uploadFiles: '上传文件',
@@ -4078,6 +4086,10 @@ export default {
     allPresetsRequired: '必须选择所有预设',
     useEmbedded: '使用文件的内置设置',
     useEmbeddedHint: '按设计者的设置(壁、填充、耗材)切片,而非上方的配置文件。因您的打印机与文件匹配而可用。',
+    autoOrient: '自动摆正模型',
+    autoOrientHint: '切片前由切片器把每个模型转到最适合打印的一面,会覆盖文件中保存的朝向。',
+    autoArrange: '自动排布在热床上',
+    autoArrangeHint: '由切片器重新摆放模型,使其不再重叠,会替换文件自带的布局。',
     designSettings: '保留设计者的设置',
     designSettingsHint: '此文件相对标准配置修改了 {{count}} 项打印设置。',
     designSettingsSelected: '已选择 {{selected}} / {{total}}',
@@ -4616,6 +4628,16 @@ export default {
 
   // Print modal
   printModal: {
+    variants: {
+      editNote: '这些备选在任务加入队列时确定。如需更改,请取消后重新加入队列。',
+      title: '打印机备选',
+      help: '一个任务,占一个队列位。第一台空闲且匹配的打印机会打印它对应的文件。',
+      unknownModel: '未知型号',
+      plateFor: '{{filename}} 的盘',
+      moveUp: '上移',
+      moveDown: '下移',
+      queued: '已加入队列,含 {{count}} 个备选',
+    },
     selectPrinter: '选择打印机',
     selectPlate: '选择板',
     filamentMapping: '耗材映射',

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

@@ -33,6 +33,7 @@ export default {
 
   // Common
   common: {
+    plusNMore: '另 {{count}} 個',
     save: '儲存',
     saving: '儲存中...',
     cancel: '取消',
@@ -3550,6 +3551,13 @@ export default {
 
   // File manager
   fileManager: {
+    variants: {
+      badge: '{{count}} 個版本',
+      groupAction: '歸為版本群組',
+      groupTooltip: '將這些檔案標記為針對不同印表機切片的同一工作',
+      grouped: '已將 {{count}} 個檔案歸為版本群組',
+      printAlternatives: '列印({{count}} 個備選)',
+    },
     title: '檔案管理器',
     subtitle: '組織和管理您的列印檔案',
     uploadFiles: '上傳檔案',
@@ -4078,6 +4086,10 @@ export default {
     allPresetsRequired: '必須選擇所有預設',
     useEmbedded: '使用檔案的內建設定',
     useEmbeddedHint: '依設計者的設定(外牆、填充、耗材)切片,而非上方的設定檔。因您的印表機與檔案相符而可用。',
+    autoOrient: '自動擺正模型',
+    autoOrientHint: '切片前由切片器將每個模型轉到最適合列印的一面,會覆蓋檔案中儲存的朝向。',
+    autoArrange: '自動排列在熱床上',
+    autoArrangeHint: '由切片器重新擺放模型,使其不再重疊,會取代檔案自帶的版面配置。',
     designSettings: '保留設計者的設定',
     designSettingsHint: '此檔案相對標準設定檔修改了 {{count}} 項列印設定。',
     designSettingsSelected: '已選擇 {{selected}} / {{total}}',
@@ -4616,6 +4628,16 @@ export default {
 
   // Print modal
   printModal: {
+    variants: {
+      editNote: '這些備選在工作加入佇列時確定。如需變更,請取消後重新加入佇列。',
+      title: '印表機備選',
+      help: '一項工作,佔一個佇列位。第一台空閒且相符的印表機會列印它對應的檔案。',
+      unknownModel: '未知型號',
+      plateFor: '{{filename}} 的列印板',
+      moveUp: '上移',
+      moveDown: '下移',
+      queued: '已加入佇列,含 {{count}} 個備選',
+    },
     selectPrinter: '選擇印表機',
     selectPlate: '選擇板',
     filamentMapping: '耗材對應',

+ 91 - 5
frontend/src/pages/FileManagerPage.tsx

@@ -20,6 +20,7 @@ import {
   MoveRight,
   CheckSquare,
   Square,
+  Layers,
   LayoutGrid,
   List,
   Search,
@@ -828,6 +829,14 @@ function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload,
             {file.sliced_for_model}
           </div>
         )}
+        {/* Counts the whole group, including members in other folders (#671 /
+            #2570) — printing this file will offer all of them. */}
+        {(file.variant_count ?? 0) > 1 && (
+          <div className="mt-1 text-xs text-bambu-green flex items-center gap-1">
+            <Layers className="w-3 h-3" />
+            {t('fileManager.variants.badge', { count: file.variant_count })}
+          </div>
+        )}
         {file.print_count > 0 && (
           <div className="mt-1 text-xs text-bambu-green">
             {t('fileManager.printedCount', { count: file.print_count })}
@@ -1410,6 +1419,20 @@ export function FileManagerPage() {
     },
   });
 
+  // "These files are the same job for different printers" (#671 / #2570).
+  // Durable, unlike the ad-hoc selection the Print button uses: once grouped,
+  // printing any member offers the others without re-selecting them.
+  const groupAsVersionsMutation = useMutation({
+    mutationFn: (fileIds: number[]) =>
+      api.createVariantGroup(fileIds.map((id) => ({ library_file_id: id }))),
+    onSuccess: (group) => {
+      queryClient.invalidateQueries({ queryKey: ['library-files'] });
+      showToast(t('fileManager.variants.grouped', { count: group.members.length }), 'success');
+      setSelectedFiles([]);
+    },
+    onError: (error: Error) => showToast(error.message, 'error'),
+  });
+
   const bulkDeleteMutation = useMutation({
     mutationFn: (fileIds: number[]) => api.bulkDeleteLibrary(fileIds, []),
     onSuccess: (_, fileIds) => {
@@ -1543,6 +1566,36 @@ export function FileManagerPage() {
     return files.filter(f => selectedFiles.includes(f.id) && isSlicedFile(f.filename));
   }, [files, selectedFiles, isSlicedFile]);
 
+  // The clicked file's variant group, so printing one member offers the rest
+  // without the user re-selecting them (#2570).
+  const { data: printFileGroup } = useQuery({
+    queryKey: ['variant-group', printFile?.variant_group_id],
+    queryFn: () => api.getVariantGroup(printFile!.variant_group_id!),
+    enabled: !!printFile?.variant_group_id,
+  });
+
+  // Candidates for a cross-model print (#671), or undefined for an ordinary one.
+  // An explicit multi-selection wins over the group: the user just said, in this
+  // action, which files they meant.
+  const printVariantFiles = useMemo(() => {
+    if (!printFile) return undefined;
+    if (selectedSlicedFiles.length > 1) {
+      return selectedSlicedFiles.map(f => ({
+        id: f.id,
+        filename: f.filename,
+        sliced_for_model: f.sliced_for_model,
+      }));
+    }
+    if (printFileGroup && printFileGroup.members.length > 1) {
+      return printFileGroup.members.map(m => ({
+        id: m.library_file_id,
+        filename: m.filename,
+        sliced_for_model: m.target_model,
+      }));
+    }
+    return undefined;
+  }, [printFile, selectedSlicedFiles, printFileGroup]);
+
   // Handlers
   const handleFileSelect = useCallback((id: number) => {
     // Always toggle selection (multi-select by default)
@@ -2218,7 +2271,11 @@ export function FileManagerPage() {
                   </span>
                   <div className="hidden sm:block flex-1" />
                   <div className="w-full sm:w-auto flex flex-wrap items-center gap-2 mt-2 sm:mt-0">
-                    {selectedSlicedFiles.length === 1 && (
+                    {/* Print used to disappear the moment a second sliced file was
+                        selected. Selecting several is now how you say "same job,
+                        different printers" (#671) — one queue item, whichever
+                        machine frees up first. */}
+                    {selectedSlicedFiles.length >= 1 && (
                       <Button
                         variant="primary"
                         size="sm"
@@ -2227,7 +2284,26 @@ export function FileManagerPage() {
                         title={!hasPermission('queue:create') ? t('fileManager.noPermissionAddToQueue') : undefined}
                       >
                         <Printer className="w-4 h-4 sm:mr-1" />
-                        <span className="hidden sm:inline">{t('common.print')}</span>
+                        <span className="hidden sm:inline">
+                          {selectedSlicedFiles.length > 1
+                            ? t('fileManager.variants.printAlternatives', { count: selectedSlicedFiles.length })
+                            : t('common.print')}
+                        </span>
+                      </Button>
+                    )}
+                    {selectedSlicedFiles.length >= 2 && !selectedSlicedFiles.some(f => f.variant_group_id) && (
+                      <Button
+                        variant="secondary"
+                        size="sm"
+                        onClick={() => groupAsVersionsMutation.mutate(selectedSlicedFiles.map(f => f.id))}
+                        disabled={
+                          groupAsVersionsMutation.isPending
+                          || !hasAnyPermission('library:update_own', 'library:update_all')
+                        }
+                        title={t('fileManager.variants.groupTooltip')}
+                      >
+                        <Layers className="w-4 h-4 sm:mr-1" />
+                        <span className="hidden sm:inline">{t('fileManager.variants.groupAction')}</span>
                       </Button>
                     )}
                     <Button
@@ -2732,11 +2808,21 @@ export function FileManagerPage() {
         />
       )}
 
-      {printFile && (
+      {/* Held back until the variant group has loaded. The modal reads its
+          candidate list once, on mount, so opening before the group arrives
+          would show a single-file print for a file that has alternatives. */}
+      {printFile && (!printFile.variant_group_id || printFileGroup !== undefined) && (
         <PrintModal
           mode="create"
-          libraryFileId={printFile.id}
-          archiveName={printFile.print_name || printFile.filename}
+          libraryFileId={printVariantFiles?.[0]?.id ?? printFile.id}
+          variantFiles={printVariantFiles}
+          // Naming a cross-model job after one of its files reads as though the
+          // others aren't part of it.
+          archiveName={
+            printVariantFiles && printVariantFiles.length > 1
+              ? `${printVariantFiles[0].filename} ${t('common.plusNMore', { count: printVariantFiles.length - 1 })}`
+              : printFile.print_name || printFile.filename
+          }
           onClose={() => setPrintFile(null)}
           onSuccess={() => {
             setPrintFile(null);

+ 24 - 4
frontend/src/pages/QueuePage.tsx

@@ -19,6 +19,7 @@ import {
   verticalListSortingStrategy,
 } from '@dnd-kit/sortable';
 import { CSS } from '@dnd-kit/utilities';
+import { queueItemDisplayName } from '../utils/queueItemName';
 import {
   Clock,
   Trash2,
@@ -576,7 +577,7 @@ function SortableQueueItem({
         <div className="flex-1 min-w-0">
           <div className="flex items-center gap-2 mb-1">
             <p className="text-sm sm:text-base text-white font-medium truncate">
-              {item.archive_name || item.library_file_name || `File #${item.archive_id || item.library_file_id}`}
+              {queueItemDisplayName(item, (n) => t('common.plusNMore', { count: n }))}
               {(platesData?.is_multi_plate ?? false) && item.plate_id !== undefined && item.plate_id !== null && ` • ${plates.find(plate => plate.index === item.plate_id)?.name || t('queue.plateNumber', { index: item.plate_id })}`}
             </p>
             {item.archive_id ? (
@@ -607,7 +608,12 @@ function SortableQueueItem({
             <span className={`flex items-center gap-1 sm:gap-1.5 ${item.printer_id === null && !item.target_model ? 'text-orange-700 dark:text-orange-400' : ''} ${item.target_model && !item.printer_id ? 'text-blue-700 dark:text-blue-400' : ''}`}>
               <Printer className="w-3 h-3 sm:w-3.5 sm:h-3.5" />
               <span className="truncate max-w-[120px] sm:max-w-none">
-              {item.target_model && !item.printer_id
+              {/* A cross-model item (#671) is waiting on several models at once.
+                  Showing only target_model would name whichever candidate is
+                  first and read as a lie the moment the other one runs. */}
+              {(item.variants?.length ?? 0) > 1 && !item.printer_id
+                ? `${t('queue.filter.any')} ${item.variants!.map(v => v.target_model).join(' / ')}${item.target_location ? ` @ ${item.target_location}` : ''}`
+                : item.target_model && !item.printer_id
                 ? `${t('queue.filter.any')} ${item.target_model}${item.target_location ? ` @ ${item.target_location}` : ''}${item.required_filament_types?.length ? ` (${item.required_filament_types.join(', ')})` : ''}`
                 : item.printer_id === null
                   ? t('queue.filter.unassigned')
@@ -2190,6 +2196,20 @@ export function QueuePage() {
           isUnassigned: false,
         };
       }
+      // A cross-model item (#671) is waiting on several models. Its own
+      // target_model is just the first candidate mirrored onto the row, so
+      // bucketing on it would file the job under one printer it might never
+      // run on — and the row underneath already says "Any H2D / X1C".
+      if ((item.variants?.length ?? 0) > 1) {
+        const models = item.variants!.map((v) => v.target_model).join(' / ');
+        return {
+          key: `models:${models}`,
+          label: `${t('queue.filter.any')} ${models}`,
+          printerId: null,
+          targetModel: null,
+          isUnassigned: false,
+        };
+      }
       if (item.target_model) {
         return {
           key: `model:${item.target_model}`,
@@ -2787,7 +2807,7 @@ export function QueuePage() {
           mode="edit-queue-item"
           archiveId={editItem.archive_id ?? undefined}
           libraryFileId={editItem.library_file_id ?? undefined}
-          archiveName={editItem.archive_name || editItem.library_file_name || `File #${editItem.archive_id || editItem.library_file_id}`}
+          archiveName={queueItemDisplayName(editItem, (n) => t('common.plusNMore', { count: n }))}
           queueItem={editItem}
           onClose={() => setEditItem(null)}
         />
@@ -2799,7 +2819,7 @@ export function QueuePage() {
           mode="create"
           archiveId={requeueItem.archive_id ?? undefined}
           libraryFileId={requeueItem.library_file_id ?? undefined}
-          archiveName={requeueItem.archive_name || requeueItem.library_file_name || `File #${requeueItem.archive_id || requeueItem.library_file_id}`}
+          archiveName={queueItemDisplayName(requeueItem, (n) => t('common.plusNMore', { count: n }))}
           onClose={() => setRequeueItem(null)}
         />
       )}

+ 44 - 0
frontend/src/utils/queueItemName.ts

@@ -0,0 +1,44 @@
+/**
+ * Display name for a queue item, wherever one is shown.
+ *
+ * Every surface used to inline the same fallback chain, which produced
+ * `File #null` for a cross-model item (#671): those deliberately hold neither
+ * `archive_id` nor `library_file_id` until dispatch resolves a candidate, so
+ * that the ON DELETE CASCADE on `library_file_id` can't destroy the whole job
+ * when one alternative is deleted. Nothing to point at is the design working;
+ * the label just had nowhere to look.
+ */
+
+interface NameableQueueItem {
+  archive_name?: string | null;
+  library_file_name?: string | null;
+  archive_id?: number | null;
+  library_file_id?: number | null;
+  variants?: Array<{ filename: string }>;
+}
+
+/**
+ * @param item      the queue item to name
+ * @param moreLabel formats the "+N more" suffix for a cross-model item; pass
+ *                  the caller's `t` binding so the count stays translated.
+ *                  Omitted in compact surfaces that only have room for a name.
+ */
+export function queueItemDisplayName(
+  item: NameableQueueItem,
+  moreLabel?: (count: number) => string,
+): string {
+  if (item.archive_name) return item.archive_name;
+  if (item.library_file_name) return item.library_file_name;
+
+  // Cross-model item: name it after the candidate the user put first — the one
+  // the scheduler will try first — and say how many others are behind it.
+  const variants = item.variants ?? [];
+  if (variants.length > 0) {
+    const first = variants[0].filename;
+    const others = variants.length - 1;
+    if (others > 0 && moreLabel) return `${first} ${moreLabel(others)}`;
+    return first;
+  }
+
+  return `File #${item.archive_id ?? item.library_file_id}`;
+}

File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/index-qwJExXvN.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-DNwUdClD.js"></script>
+    <script type="module" crossorigin src="/assets/index-qwJExXvN.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-C_6BSgrK.css">
   </head>
   <body>

Beberapa file tidak ditampilkan karena terlalu banyak file yang berubah dalam diff ini