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