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.
This commit is contained in:
maziggy
2026-08-03 11:33:39 +02:00
parent a9b57ccd3c
commit ef7c1b21f1
29 changed files with 899 additions and 8852 deletions
+1
View File
@@ -16,6 +16,7 @@ All notable changes to Bambuddy will be documented in this file.
- **Git backup now actually writes cloud profiles (#2717, reporter @jmoore-skild)** — Enabling **Cloud Profiles** for a Git backup produced nothing. The collector looked for a `setting` list in the Bambu Cloud response, which is keyed by preset type instead, so the loop never ran once — and it asked for the credential store used when authentication is *disabled*, so on any install with authentication on it found no account to collect from in the first place. Neither failure was visible: `backup_metadata.json` still recorded `cloud_profiles: true`, and the log line read `Collected cloud profiles: 0 filament, 0 printer, 0 process`, which looks like a successful backup of an empty account. Cloud profiles are now collected from **every connected account across both Bambu Cloud and Orca Cloud**, one directory per cloud per account, keyed by user ID so no email address is written into a backup repository. Bambu presets are stored with the payload needed to recreate them rather than just their names, and Bambu's bundled public catalogue is skipped — it is identical for everyone, re-downloadable, and would rewrite the repository on every run. The metadata now records what was actually collected, per cloud and per account, and a run that collects nothing while the category is enabled says so as a warning instead of an INFO line that reads like success. The Cloud Profiles checkbox no longer keys off your own Bambu sign-in — it enables when *any* account is connected and shows how many are in scope, which matters on a multi-user install where the presets being backed up are other people's. A backup also no longer disconnects an Orca Cloud account whose session it can't refresh: Orca reports every rejection with one composite reason, so a genuine revocation is indistinguishable from a lost token-rotation race, and an unattended job should not be the thing that guesses. The account is skipped with a warning, and the dead credentials are cleared the next time you open Orca Cloud Profiles — where you can pair again on the spot. Translated in all locales; wiki updated. Covered by backend tests.
### Added
- **One queue item, several printer models — whichever frees up first (#671, reporter @brainomite; also delivers most of #2570, reporter @NeighborGeek)** — With an H2S and an H2C, a job you don't care which machine runs still had to be queued twice: the two printers need different slices, a queue item held exactly one file, and "any H2S" and "any H2C" were separate jobs competing for the same plastic. Whichever started first, you deleted the other by hand. Select both sliced files in the File Manager and press **Print** and you now get **one** queue item carrying both — the scheduler walks them in the order you arranged and takes the first whose model has an idle printer. The many-to-many never leaves the scheduler's selection loop: the moment a candidate wins, its file, plate and nozzle mapping are folded onto the queue row, so the upload, archive creation, print history and reprint all see an ordinary single-file job and behave exactly as they always have. Order is yours to set, because "both are free right now" has to resolve the same way every time rather than following whichever match the matcher happened to see first. Candidates are otherwise tried least-attempted first, so a printer that accepts the file and never starts hands the job to the other machine on the next lap instead of spending the item's whole retry budget on the one that is wedged. The set is validated as a set: one file per printer model (two slices for the same machine are not alternatives, and picking between them arbitrarily would look like a bug the first time it chose your draft profile), every file gated against the model it is offered as, and at least one model that actually has a printer — grouping the H2C slice before the H2C arrives is fine, queueing a job nothing can ever run is not. A cross-model item deliberately holds no file of its own, so deleting one alternative leaves the job and its sibling intact; deleting or trashing every candidate holds it with an explanation instead of failing deep in the upload. Files can also be grouped permanently with **Group as versions**, after which printing any one of them offers the others without re-selecting — this is the grouping and the print-time file matching asked for in #2570, minus its nested File Manager listing. An existing library arrives with its groups already built, from slice provenance Bambuddy has been recording since the Slice button shipped and had never read back. Translated in all locales; wiki updated. Covered by backend and frontend tests.
- **Uploaded archives can be named after the filename you sent (#2610, contributor @Person2099)** — A 3MF carries a `print_name` inside its metadata, and that is the name an archive gets. It is often the wrong one: whoever originally sliced the file baked their own title into it, and that title survives every rename afterwards, so a file you deliberately named after the job it belongs to still lands in the archive list under a stranger's label. Bambuddy could already prefer the filename — the FTP review flow and virtual-printer dispatch both do, driven by the virtual printer's **Archive name source** setting — but the upload endpoints could not, so anything sent through the API was stuck with the embedded title. `POST /archives/upload` and `POST /archives/upload-bulk` now take an optional `prefer_filename_for_name` query parameter that names the archive after the uploaded file instead; on the bulk route it applies to every file in the batch. It is off by default, so nothing changes for existing callers or for the Web UI's own upload, which does not set it. A per-request parameter rather than a setting, because the caller — typically an integration naming files after its own jobs — is the only party that knows whether the filename it sent is the meaningful one. Both parameters are documented in the OpenAPI schema, so they show up in `/docs`. Covered by backend tests.
- **Keep the AMS slots the slicer picked (#2700, contributor @Striker72rus)** — Bambu Studio and OrcaSlicer resolve which physical AMS tray feeds each filament themselves, right before sending. Bambuddy threw that away: a queue-mode virtual printer worked the mapping out again at dispatch time, from the filament type and colour baked into the 3MF. That is usually the better answer — it is computed against the printer's live trays and it respects **Prefer lowest filament** and the AMS-backup gate that goes with it — but it has nothing to go on when the match isn't unique. Two spools of the same red PLA, and the slot you deliberately chose in the slicer is a coin toss. A new per-virtual-printer **Save AMS mapping** toggle keeps the slicer's pick instead: the print dispatches to exactly those trays, and the mapping is stored on the archive so a reprint can reuse the same physical spools — a **Mapping** button in the print modal selects every slot from it in one click, and the archive card and queue row say so. Because a tray number only means something on the AMS it was resolved against, the mapping records its printer and is only ever offered on that same printer; a model-based ("Any [model]") virtual printer has no fixed printer and is unaffected. Off by default, so nothing changes for existing virtual printers until you turn it on, and **Force color match** still wins for the print being dispatched when both are on. Translated in all locales; wiki updated. Covered by backend and frontend tests.
- **P2S/X2D accessory fans: left auxiliary cooling and chamber exhaust (#2691, contributor @gzimbric, requested in #2660)** — The P2S and X2D have two fans Bambuddy could not show or drive. The **left auxiliary part cooling fan** had no tile and no control at all, because the printer only reports it inside its air-duct data and never in the ordinary fan fields Bambuddy was reading. The **chamber exhaust fan** had the opposite problem: its tile appeared on every P2S whether or not the fan was fitted, so owners of a base machine had a control that did nothing. Both are add-on kits on the P2S and fitted at the factory on the X2D. Both tiles now appear only when the printer itself reports the hardware, so a base P2S looks exactly as it does today and a kitted one gains the fans it actually has. The left auxiliary fan is set from the same speed popover as the others, and the enclosure fan is labelled **Exhaust** on the P2S and X2D — matching the printer's own screen and Bambu Studio — while every other enclosed model keeps **Chamber Fan**. The confirmation message after changing a speed uses the same name as the tile that was clicked. The four tiles are ordered part cooling, left auxiliary, auxiliary, exhaust, so they read left to right in the same order as the physical fans. Both fields are also published through the status endpoint, the WebSocket feed and the MQTT relay, so external automations can read them. Translated in all locales; wiki updated. Covered by backend and frontend tests, including the case where the printer sends a partial fan report — a tile must not disappear mid-print just because one update didn't mention it.
+16
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
@@ -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
View File
@@ -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)
@@ -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
View File
@@ -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}
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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')
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -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>