Переглянути джерело

Add cross-model print alternatives to the File Manager and print modal (#671, #2570)

Selecting several sliced files and pressing Print now creates one queue
item carrying all of them, instead of hiding the Print button the moment
a second file is selected. The printer picker is replaced by the ordered
candidate list, since choosing these files is already the answer to
"which printer" and the only question left is which is preferred.

Per-candidate configuration is the plate only. Model-based assignment
sends no AMS mapping — the printer is unknown until dispatch, where the
scheduler derives it — so a per-candidate mapping editor would collect
choices it then discards. Filament overrides stay shared: "this job
needs PETG" holds for every slice of the same job.

Adds Group as versions for durable grouping, a versions badge counting
the whole group rather than the rows on screen, and a queue card label
naming every model a pending item is waiting on.
maziggy 1 місяць тому
батько
коміт
ef7c1b21f1

Різницю між файлами не показано, бо вона завелика
+ 0 - 0
CHANGELOG.md


+ 16 - 0
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,
             )
         )
 

+ 34 - 1
backend/app/api/routes/print_queue.py

@@ -8,7 +8,7 @@ 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
 
@@ -35,6 +35,7 @@ from backend.app.schemas.print_queue import (
     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
@@ -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)
     )
@@ -1286,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)
     )
@@ -1709,6 +1741,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)
     )

+ 7 - 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
 

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

@@ -155,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
@@ -236,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
 

+ 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);
+  });
+});

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

@@ -2203,6 +2203,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 +2333,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 +6314,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 +7057,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)

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

@@ -0,0 +1,145 @@
+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;
+}
+
+/**
+ * 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,
+}: VariantCandidatesProps) {
+  const { t } = useTranslation();
+
+  const plateQueries = useQueries({
+    queries: 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">{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>
+              )}
+
+              <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>
+  );
+}

+ 81 - 5
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,12 @@ 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;
+  const [candidates, setCandidates] = useState<VariantCandidate[]>(variantFiles ?? []);
+  const [candidatePlates, setCandidatePlates] = useState<Record<number, number | null>>({});
+
   type FilamentWarningItem = {
     printerName: string;
     slotLabel: string;
@@ -165,6 +173,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';
@@ -803,17 +816,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 +891,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 +1160,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 +1193,7 @@ export function PrintModal({
     perPlateReqsPending,
     perPlateReqsFailed,
     printerStatusLoading,
+    isCrossModel,
   ]);
 
   // Quantity only applies for single-printer or model-based assignment (not multi-printer)
@@ -1295,8 +1357,22 @@ 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 }))
+                }
+              />
+            )}
+
             {/* Printer selection with per-printer mapping — hidden when printer is pre-selected via props */}
-            {!initialSelectedPrinterIds?.length && (
+            {!isCrossModel && !initialSelectedPrinterIds?.length && (
               <PrinterSelector
                 printers={printers || []}
                 selectedPrinterIds={selectedPrinters}

+ 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[];
 }
 
 /**

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

@@ -3562,6 +3562,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',
@@ -4628,6 +4635,15 @@ export default {
 
   // Print modal
   printModal: {
+    variants: {
+      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',

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

@@ -3591,6 +3591,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',
@@ -4671,6 +4678,15 @@ export default {
 
   // Print modal
   printModal: {
+    variants: {
+      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',

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

@@ -3565,6 +3565,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',
@@ -4636,6 +4643,15 @@ export default {
 
   // Print modal
   printModal: {
+    variants: {
+      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',

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

@@ -3551,6 +3551,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',
@@ -4617,6 +4624,15 @@ export default {
 
   // Print modal
   printModal: {
+    variants: {
+      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',

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

@@ -3550,6 +3550,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',
@@ -4616,6 +4623,15 @@ export default {
 
   // Print modal
   printModal: {
+    variants: {
+      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',

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

@@ -3562,6 +3562,13 @@ export default {
 
   // File manager
   fileManager: {
+    variants: {
+      badge: '{{count}}個のバージョン',
+      groupAction: 'バージョンとしてグループ化',
+      groupTooltip: 'これらのファイルを、異なるプリンター向けにスライスした同一ジョブとして扱います',
+      grouped: '{{count}}個のファイルをバージョンとしてグループ化しました',
+      printAlternatives: '印刷({{count}}件の候補)',
+    },
     title: 'ファイル管理',
     subtitle: '印刷ファイルの整理と管理',
     uploadFiles: 'ファイルをアップロード',
@@ -4628,6 +4635,15 @@ export default {
 
   // Print modal
   printModal: {
+    variants: {
+      title: 'プリンターの候補',
+      help: '1つのジョブ、キューは1枠。条件に合う最初に空いたプリンターがそのファイルを印刷します。',
+      unknownModel: '不明なモデル',
+      plateFor: '{{filename}} のプレート',
+      moveUp: '上へ',
+      moveDown: '下へ',
+      queued: '{{count}}件の候補付きでキューに追加しました',
+    },
     selectPrinter: 'プリンターを選択',
     selectPlate: 'プレートを選択',
     filamentMapping: 'フィラメントマッピング',

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

@@ -3374,6 +3374,13 @@ export default {
     bundleStepBuild: '지원 번들 ZIP 빌드 중'
   },
   fileManager: {
+    variants: {
+      badge: '버전 {{count}}개',
+      groupAction: '버전으로 그룹화',
+      groupTooltip: '이 파일들을 서로 다른 프린터용으로 슬라이스한 동일 작업으로 표시합니다',
+      grouped: '파일 {{count}}개를 버전으로 그룹화했습니다',
+      printAlternatives: '인쇄 (대안 {{count}}개)',
+    },
     title: '파일 관리자',
     subtitle: '인쇄 파일 정리 및 관리',
     uploadFiles: '파일 업로드',
@@ -4400,6 +4407,15 @@ export default {
     emptySlotReset: '필라멘트가 할당되지 않음'
   },
   printModal: {
+    variants: {
+      title: '프린터 대안',
+      help: '작업 하나, 대기열 한 자리. 조건이 맞는 프린터 중 먼저 비는 프린터가 해당 파일을 인쇄합니다.',
+      unknownModel: '알 수 없는 모델',
+      plateFor: '{{filename}}의 플레이트',
+      moveUp: '위로',
+      moveDown: '아래로',
+      queued: '대안 {{count}}개와 함께 대기열에 추가했습니다',
+    },
     selectPrinter: '프린터 선택',
     selectPlate: '플레이트 선택',
     filamentMapping: '필라멘트 매핑',

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

@@ -3550,6 +3550,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',
@@ -4616,6 +4623,15 @@ export default {
 
   // Print modal
   printModal: {
+    variants: {
+      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',

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

@@ -3366,6 +3366,13 @@ export default {
     bundleStepBuild: "Создание ZIP-пакета для поддержки",
   },
   fileManager: {
+    variants: {
+      badge: 'Версий: {{count}}',
+      groupAction: 'Сгруппировать как версии',
+      groupTooltip: 'Пометить эти файлы как одну задачу, нарезанную для разных принтеров',
+      grouped: 'Файлов сгруппировано как версии: {{count}}',
+      printAlternatives: 'Печать (вариантов: {{count}})',
+    },
     title: "Файловый менеджер",
     subtitle: "Организация и управление файлами для печати",
     uploadFiles: "Загрузить файлы",
@@ -4389,6 +4396,15 @@ export default {
     remainingUnit: "осталось",
   },
   printModal: {
+    variants: {
+      title: 'Варианты принтера',
+      help: 'Одна задача, одно место в очереди. Первый подходящий освободившийся принтер напечатает свой файл.',
+      unknownModel: 'Неизвестная модель',
+      plateFor: 'Стол для {{filename}}',
+      moveUp: 'Вверх',
+      moveDown: 'Вниз',
+      queued: 'В очереди, вариантов: {{count}}',
+    },
     selectPrinter: "Выберите принтер",
     selectPlate: "Выберите пластину",
     filamentMapping: "Сопоставление филаментов",

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

@@ -3558,6 +3558,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',
@@ -4606,6 +4613,15 @@ export default {
 
   // Baskı modali
   printModal: {
+    variants: {
+      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',

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

@@ -3591,6 +3591,13 @@ export default {
 
   // File manager
   fileManager: {
+    variants: {
+      badge: 'Версій: {{count}}',
+      groupAction: 'Згрупувати як версії',
+      groupTooltip: 'Позначити ці файли як одне завдання, нарізане для різних принтерів',
+      grouped: 'Файлів згруповано як версії: {{count}}',
+      printAlternatives: 'Друк (варіантів: {{count}})',
+    },
     title: "Менеджер файлів",
     subtitle: "Упорядковуйте файли друку та керуйте ними",
     uploadFiles: "Вивантажити файли",
@@ -4671,6 +4678,15 @@ export default {
 
   // Print modal
   printModal: {
+    variants: {
+      title: 'Варіанти принтера',
+      help: 'Одне завдання, одне місце в черзі. Перший відповідний принтер, що звільниться, надрукує свій файл.',
+      unknownModel: 'Невідома модель',
+      plateFor: 'Стіл для {{filename}}',
+      moveUp: 'Вгору',
+      moveDown: 'Вниз',
+      queued: 'У черзі, варіантів: {{count}}',
+    },
     selectPrinter: "Вибрати принтер",
     selectPlate: "Вибрати пластину",
     filamentMapping: "Зіставлення філаментів",

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

@@ -3550,6 +3550,13 @@ export default {
 
   // File manager
   fileManager: {
+    variants: {
+      badge: '{{count}} 个版本',
+      groupAction: '归为版本组',
+      groupTooltip: '将这些文件标记为针对不同打印机切片的同一任务',
+      grouped: '已将 {{count}} 个文件归为版本组',
+      printAlternatives: '打印({{count}} 个备选)',
+    },
     title: '文件管理器',
     subtitle: '组织和管理您的打印文件',
     uploadFiles: '上传文件',
@@ -4616,6 +4623,15 @@ export default {
 
   // Print modal
   printModal: {
+    variants: {
+      title: '打印机备选',
+      help: '一个任务,占一个队列位。第一台空闲且匹配的打印机会打印它对应的文件。',
+      unknownModel: '未知型号',
+      plateFor: '{{filename}} 的盘',
+      moveUp: '上移',
+      moveDown: '下移',
+      queued: '已加入队列,含 {{count}} 个备选',
+    },
     selectPrinter: '选择打印机',
     selectPlate: '选择板',
     filamentMapping: '耗材映射',

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

@@ -3550,6 +3550,13 @@ export default {
 
   // File manager
   fileManager: {
+    variants: {
+      badge: '{{count}} 個版本',
+      groupAction: '歸為版本群組',
+      groupTooltip: '將這些檔案標記為針對不同印表機切片的同一工作',
+      grouped: '已將 {{count}} 個檔案歸為版本群組',
+      printAlternatives: '列印({{count}} 個備選)',
+    },
     title: '檔案管理器',
     subtitle: '組織和管理您的列印檔案',
     uploadFiles: '上傳檔案',
@@ -4616,6 +4623,15 @@ export default {
 
   // Print modal
   printModal: {
+    variants: {
+      title: '印表機備選',
+      help: '一項工作,佔一個佇列位。第一台空閒且相符的印表機會列印它對應的檔案。',
+      unknownModel: '未知型號',
+      plateFor: '{{filename}} 的列印板',
+      moveUp: '上移',
+      moveDown: '下移',
+      queued: '已加入佇列,含 {{count}} 個備選',
+    },
     selectPrinter: '選擇印表機',
     selectPlate: '選擇板',
     filamentMapping: '耗材對應',

+ 84 - 4
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,10 +2808,14 @@ 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}
+          libraryFileId={printVariantFiles?.[0]?.id ?? printFile.id}
+          variantFiles={printVariantFiles}
           archiveName={printFile.print_name || printFile.filename}
           onClose={() => setPrintFile(null)}
           onSuccess={() => {

+ 6 - 1
frontend/src/pages/QueuePage.tsx

@@ -607,7 +607,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')

Різницю між файлами не показано, бо вона завелика
+ 0 - 1
static/assets/index-3tpP1N3E.css


Різницю між файлами не показано, бо вона завелика
+ 0 - 0
static/assets/index-DDecIyHp.js


Різницю між файлами не показано, бо вона завелика
+ 0 - 0
static/assets/index-p7qlPsqM.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-p7qlPsqM.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-C_6BSgrK.css">
   </head>
   <body>

Деякі файли не було показано, через те що забагато файлів було змінено