|
@@ -31,6 +31,16 @@ import { PrinterSelector } from './PrinterSelector';
|
|
|
import { PrintOptionsPanel } from './PrintOptions';
|
|
import { PrintOptionsPanel } from './PrintOptions';
|
|
|
import { ScheduleOptionsPanel } from './ScheduleOptions';
|
|
import { ScheduleOptionsPanel } from './ScheduleOptions';
|
|
|
import { VariantCandidates, type VariantCandidate } from './VariantCandidates';
|
|
import { VariantCandidates, type VariantCandidate } from './VariantCandidates';
|
|
|
|
|
+import { MultiFileModeToggle } from './MultiFileModeToggle';
|
|
|
|
|
+import {
|
|
|
|
|
+ commonModel,
|
|
|
|
|
+ defaultMultiFileMode,
|
|
|
|
|
+ duplicateModel,
|
|
|
|
|
+ filesIncompatibleWith,
|
|
|
|
|
+ filesNotSlicedFor,
|
|
|
|
|
+ type MultiFileMode,
|
|
|
|
|
+} from './multiFile';
|
|
|
|
|
+import { fileHasQueuedPair, queueSeparateJobs, runAsapTail } from './separateJobs';
|
|
|
import { CostCenterSelect } from './CostCenterSelect';
|
|
import { CostCenterSelect } from './CostCenterSelect';
|
|
|
import type {
|
|
import type {
|
|
|
AssignmentMode,
|
|
AssignmentMode,
|
|
@@ -98,6 +108,9 @@ export function PrintModal({
|
|
|
projectId,
|
|
projectId,
|
|
|
cleanupLibraryAfterDispatch,
|
|
cleanupLibraryAfterDispatch,
|
|
|
variantFiles,
|
|
variantFiles,
|
|
|
|
|
+ separateJobsAllowed,
|
|
|
|
|
+ resumeSeparateJobs,
|
|
|
|
|
+ onSeparateJobsProgress,
|
|
|
}: PrintModalProps) {
|
|
}: PrintModalProps) {
|
|
|
const { t } = useTranslation();
|
|
const { t } = useTranslation();
|
|
|
const queryClient = useQueryClient();
|
|
const queryClient = useQueryClient();
|
|
@@ -111,9 +124,47 @@ export function PrintModal({
|
|
|
const isLibraryFile = !!libraryFileId && !archiveId;
|
|
const isLibraryFile = !!libraryFileId && !archiveId;
|
|
|
const isEditing = mode === 'edit-queue-item';
|
|
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;
|
|
|
|
|
|
|
+ // The rest of a separate-jobs run closed after a refusal (#3471 review).
|
|
|
|
|
+ const resumed = mode === 'create' && !!separateJobsAllowed && !!resumeSeparateJobs && (variantFiles?.length ?? 0) > 0;
|
|
|
|
|
+ // What that run was submitted with: the reopened dialog starts from it, so
|
|
|
|
|
+ // a quick Print finishes the run the way it began (#3471 review).
|
|
|
|
|
+ const resumedSettings = resumed ? resumeSeparateJobs!.settings : undefined;
|
|
|
|
|
+ // Several sliced files at once. One file is not a choice, so a single-entry
|
|
|
|
|
+ // list behaves exactly like an ordinary print, unless it is the last file of
|
|
|
|
|
+ // a resumed run: one printer may already have it, and only the separate-jobs
|
|
|
|
|
+ // path knows to skip that pair.
|
|
|
|
|
+ const isMultiFile = mode === 'create' && ((variantFiles?.length ?? 0) > 1 || resumed);
|
|
|
|
|
+ // What those files become (#3462): one job with a candidate per file (#671),
|
|
|
|
|
+ // or one queue item per file. Only a selection made in the File Manager may
|
|
|
|
|
+ // be split into separate jobs; a version group is the same job sliced for
|
|
|
|
|
+ // different printers, so without `separateJobsAllowed` it stays alternatives
|
|
|
|
|
+ // and the dialog behaves exactly as before.
|
|
|
|
|
+ const [multiFileMode, setMultiFileMode] = useState<MultiFileMode>(() =>
|
|
|
|
|
+ resumed ? 'separate' : separateJobsAllowed && isMultiFile ? defaultMultiFileMode(variantFiles!) : 'alternatives',
|
|
|
|
|
+ );
|
|
|
|
|
+ // Cross-model alternatives (#671).
|
|
|
|
|
+ const isCrossModel = isMultiFile && multiFileMode === 'alternatives';
|
|
|
|
|
+ const isSeparateJobs = isMultiFile && multiFileMode === 'separate';
|
|
|
|
|
+ // Set once separate jobs were partly queued and the rest is still listed.
|
|
|
|
|
+ // The rest is the remainder of those jobs, so the mode is no longer offered:
|
|
|
|
|
+ // as alternatives it could be a job of one candidate, or one whose filament
|
|
|
|
|
+ // panel describes a file that has already been queued and left the list.
|
|
|
|
|
+ const [partlyQueued, setPartlyQueued] = useState(resumed);
|
|
|
|
|
+ // File and printer pairs earlier submits of a partly queued run got into the
|
|
|
|
|
+ // queue. The next submit skips them, so a file one printer took and another
|
|
|
|
|
+ // refused is not queued on the first printer again.
|
|
|
|
|
+ const [queuedPairs, setQueuedPairs] = useState<ReadonlySet<string>>(
|
|
|
|
|
+ () => new Set(resumed ? resumeSeparateJobs!.queuedPairs : []),
|
|
|
|
|
+ );
|
|
|
|
|
+ // ASAP positions the run has taken so far. The backend moves everything at
|
|
|
|
|
+ // or below an inserted position down, so a second submit that restarted at 1
|
|
|
|
|
+ // would put the rest ahead of what is already queued.
|
|
|
|
|
+ const [asapPositionsTaken, setAsapPositionsTaken] = useState(resumed ? resumeSeparateJobs!.asapInserted : 0);
|
|
|
|
|
+ // The run's own queue items and batches, to find where its ASAP items end
|
|
|
|
|
+ // in the queue as it is at the next submit.
|
|
|
|
|
+ const [runItemIds, setRunItemIds] = useState<number[]>(() => (resumed ? resumeSeparateJobs!.queuedItemIds ?? [] : []));
|
|
|
|
|
+ const [runBatchIds, setRunBatchIds] = useState<number[]>(() => (resumed ? resumeSeparateJobs!.queuedBatchIds ?? [] : []));
|
|
|
|
|
+ const showModeToggle = isMultiFile && !!separateJobsAllowed && !partlyQueued;
|
|
|
// Editing an already-queued cross-model item. The candidates are shown so the
|
|
// Editing an already-queued cross-model item. The candidates are shown so the
|
|
|
// dialog doesn't misrepresent the job as a plain "Any H2D" — which is what it
|
|
// dialog doesn't misrepresent the job as a plain "Any H2D" — which is what it
|
|
|
// did before, offering a printer picker whose Save would have left a row with
|
|
// did before, offering a printer picker whose Save would have left a row with
|
|
@@ -130,7 +181,13 @@ export function PrintModal({
|
|
|
: [];
|
|
: [];
|
|
|
const hasEditingVariants = editingVariants.length > 0;
|
|
const hasEditingVariants = editingVariants.length > 0;
|
|
|
const [candidates, setCandidates] = useState<VariantCandidate[]>(variantFiles ?? []);
|
|
const [candidates, setCandidates] = useState<VariantCandidate[]>(variantFiles ?? []);
|
|
|
- const [candidatePlates, setCandidatePlates] = useState<Record<number, number | null>>({});
|
|
|
|
|
|
|
+ const [candidatePlates, setCandidatePlates] = useState<Record<number, number | null>>(
|
|
|
|
|
+ () => resumedSettings?.plateByFile ?? {},
|
|
|
|
|
+ );
|
|
|
|
|
+ // Whether every listed file's plates are known and chosen (#3471 review).
|
|
|
|
|
+ // Before that a quick Print would send plate_id null: the whole file's time
|
|
|
|
|
+ // and cost, and plate 1 printing.
|
|
|
|
|
+ const [candidatePlatesReady, setCandidatePlatesReady] = useState(false);
|
|
|
|
|
|
|
|
type FilamentWarningItem = {
|
|
type FilamentWarningItem = {
|
|
|
printerId: number;
|
|
printerId: number;
|
|
@@ -152,6 +209,7 @@ export function PrintModal({
|
|
|
if (initialSelectedPrinterIds?.length) {
|
|
if (initialSelectedPrinterIds?.length) {
|
|
|
return initialSelectedPrinterIds;
|
|
return initialSelectedPrinterIds;
|
|
|
}
|
|
}
|
|
|
|
|
+ if (resumedSettings) return resumedSettings.printerIds;
|
|
|
return [];
|
|
return [];
|
|
|
});
|
|
});
|
|
|
|
|
|
|
@@ -160,7 +218,8 @@ export function PrintModal({
|
|
|
if (mode === 'edit-queue-item' && queueItem?.plate_id != null) {
|
|
if (mode === 'edit-queue-item' && queueItem?.plate_id != null) {
|
|
|
return new Set([queueItem.plate_id]);
|
|
return new Set([queueItem.plate_id]);
|
|
|
}
|
|
}
|
|
|
- if (mode === 'create' && !isCrossModel && preselectPlateId != null) {
|
|
|
|
|
|
|
+ // Several files choose their plates in the candidate list, in either mode.
|
|
|
|
|
+ if (mode === 'create' && !isMultiFile && preselectPlateId != null) {
|
|
|
return new Set([preselectPlateId]);
|
|
return new Set([preselectPlateId]);
|
|
|
}
|
|
}
|
|
|
return new Set();
|
|
return new Set();
|
|
@@ -170,7 +229,7 @@ export function PrintModal({
|
|
|
const selectedPlate = selectedPlates.size === 1 ? [...selectedPlates][0] : null;
|
|
const selectedPlate = selectedPlates.size === 1 ? [...selectedPlates][0] : null;
|
|
|
|
|
|
|
|
// Quantity — number of copies (creates a batch if > 1)
|
|
// Quantity — number of copies (creates a batch if > 1)
|
|
|
- const [quantity, setQuantity] = useState(1);
|
|
|
|
|
|
|
+ const [quantity, setQuantity] = useState(() => resumedSettings?.quantity ?? 1);
|
|
|
|
|
|
|
|
// Per-plate quantities for multi-plate files (#342). Keyed by plate index;
|
|
// Per-plate quantities for multi-plate files (#342). Keyed by plate index;
|
|
|
// a plate with no entry means one run. Only used in create mode on a
|
|
// a plate with no entry means one run. Only used in create mode on a
|
|
@@ -191,7 +250,7 @@ export function PrintModal({
|
|
|
preheat_chamber_target_override: queueItem.preheat_chamber_target_override ?? DEFAULT_PRINT_OPTIONS.preheat_chamber_target_override,
|
|
preheat_chamber_target_override: queueItem.preheat_chamber_target_override ?? DEFAULT_PRINT_OPTIONS.preheat_chamber_target_override,
|
|
|
};
|
|
};
|
|
|
}
|
|
}
|
|
|
- return DEFAULT_PRINT_OPTIONS;
|
|
|
|
|
|
|
+ return resumedSettings?.printOptions ?? DEFAULT_PRINT_OPTIONS;
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
const [scheduleOptions, setScheduleOptions] = useState<ScheduleOptions>(() => {
|
|
const [scheduleOptions, setScheduleOptions] = useState<ScheduleOptions>(() => {
|
|
@@ -220,7 +279,7 @@ export function PrintModal({
|
|
|
staggerIntervalMinutes: DEFAULT_SCHEDULE_OPTIONS.staggerIntervalMinutes,
|
|
staggerIntervalMinutes: DEFAULT_SCHEDULE_OPTIONS.staggerIntervalMinutes,
|
|
|
};
|
|
};
|
|
|
}
|
|
}
|
|
|
- return DEFAULT_SCHEDULE_OPTIONS;
|
|
|
|
|
|
|
+ return resumedSettings?.scheduleOptions ?? DEFAULT_SCHEDULE_OPTIONS;
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
// Manual slot overrides: slot_id (1-indexed) -> globalTrayId (default mapping for single printer or all printers)
|
|
// Manual slot overrides: slot_id (1-indexed) -> globalTrayId (default mapping for single printer or all printers)
|
|
@@ -259,7 +318,7 @@ export function PrintModal({
|
|
|
if (mode === 'edit-queue-item' && queueItem?.target_model) {
|
|
if (mode === 'edit-queue-item' && queueItem?.target_model) {
|
|
|
return 'model';
|
|
return 'model';
|
|
|
}
|
|
}
|
|
|
- return 'printer';
|
|
|
|
|
|
|
+ return resumedSettings?.assignmentMode ?? 'printer';
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
// Target model for model-based assignment
|
|
// Target model for model-based assignment
|
|
@@ -267,7 +326,7 @@ export function PrintModal({
|
|
|
if (mode === 'edit-queue-item' && queueItem?.target_model) {
|
|
if (mode === 'edit-queue-item' && queueItem?.target_model) {
|
|
|
return queueItem.target_model;
|
|
return queueItem.target_model;
|
|
|
}
|
|
}
|
|
|
- return null;
|
|
|
|
|
|
|
+ return resumedSettings?.targetModel ?? null;
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
// Target location for model-based assignment (optional filter)
|
|
// Target location for model-based assignment (optional filter)
|
|
@@ -275,11 +334,11 @@ export function PrintModal({
|
|
|
if (mode === 'edit-queue-item' && queueItem?.target_location) {
|
|
if (mode === 'edit-queue-item' && queueItem?.target_location) {
|
|
|
return queueItem.target_location;
|
|
return queueItem.target_location;
|
|
|
}
|
|
}
|
|
|
- return null;
|
|
|
|
|
|
|
+ return resumedSettings?.targetLocation ?? null;
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
const [selectedCostCenterId, setSelectedCostCenterId] = useState<number | null>(() =>
|
|
const [selectedCostCenterId, setSelectedCostCenterId] = useState<number | null>(() =>
|
|
|
- mode === 'edit-queue-item' ? queueItem?.cost_center_id ?? null : null
|
|
|
|
|
|
|
+ mode === 'edit-queue-item' ? queueItem?.cost_center_id ?? null : resumedSettings?.costCenterId ?? null
|
|
|
);
|
|
);
|
|
|
const [estimatedCost, setEstimatedCost] = useState<number | null>(queueItem?.estimated_cost ?? null);
|
|
const [estimatedCost, setEstimatedCost] = useState<number | null>(queueItem?.estimated_cost ?? null);
|
|
|
const [estimatedCostsByPlate, setEstimatedCostsByPlate] = useState<Record<number, number | null>>({});
|
|
const [estimatedCostsByPlate, setEstimatedCostsByPlate] = useState<Record<number, number | null>>({});
|
|
@@ -333,7 +392,8 @@ export function PrintModal({
|
|
|
// Sync print option defaults from settings once available
|
|
// Sync print option defaults from settings once available
|
|
|
const printDefaultsApplied = useRef(false);
|
|
const printDefaultsApplied = useRef(false);
|
|
|
useEffect(() => {
|
|
useEffect(() => {
|
|
|
- if (!settings || printDefaultsApplied.current || mode === 'edit-queue-item') return;
|
|
|
|
|
|
|
+ // A resumed run keeps the options it was submitted with.
|
|
|
|
|
+ if (!settings || printDefaultsApplied.current || mode === 'edit-queue-item' || resumedSettings) return;
|
|
|
printDefaultsApplied.current = true;
|
|
printDefaultsApplied.current = true;
|
|
|
setPrintOptions({
|
|
setPrintOptions({
|
|
|
bed_levelling: settings.default_bed_levelling ?? DEFAULT_PRINT_OPTIONS.bed_levelling,
|
|
bed_levelling: settings.default_bed_levelling ?? DEFAULT_PRINT_OPTIONS.bed_levelling,
|
|
@@ -346,19 +406,19 @@ export function PrintModal({
|
|
|
preheat_override: DEFAULT_PRINT_OPTIONS.preheat_override,
|
|
preheat_override: DEFAULT_PRINT_OPTIONS.preheat_override,
|
|
|
preheat_chamber_target_override: DEFAULT_PRINT_OPTIONS.preheat_chamber_target_override,
|
|
preheat_chamber_target_override: DEFAULT_PRINT_OPTIONS.preheat_chamber_target_override,
|
|
|
});
|
|
});
|
|
|
- }, [settings, mode]);
|
|
|
|
|
|
|
+ }, [settings, mode, resumedSettings]);
|
|
|
|
|
|
|
|
// Sync stagger defaults from settings once available
|
|
// Sync stagger defaults from settings once available
|
|
|
const staggerDefaultsApplied = useRef(false);
|
|
const staggerDefaultsApplied = useRef(false);
|
|
|
useEffect(() => {
|
|
useEffect(() => {
|
|
|
- if (!settings || staggerDefaultsApplied.current || mode === 'edit-queue-item') return;
|
|
|
|
|
|
|
+ if (!settings || staggerDefaultsApplied.current || mode === 'edit-queue-item' || resumedSettings) return;
|
|
|
staggerDefaultsApplied.current = true;
|
|
staggerDefaultsApplied.current = true;
|
|
|
setScheduleOptions((prev) => ({
|
|
setScheduleOptions((prev) => ({
|
|
|
...prev,
|
|
...prev,
|
|
|
staggerGroupSize: settings.stagger_group_size ?? prev.staggerGroupSize,
|
|
staggerGroupSize: settings.stagger_group_size ?? prev.staggerGroupSize,
|
|
|
staggerIntervalMinutes: settings.stagger_interval_minutes ?? prev.staggerIntervalMinutes,
|
|
staggerIntervalMinutes: settings.stagger_interval_minutes ?? prev.staggerIntervalMinutes,
|
|
|
}));
|
|
}));
|
|
|
- }, [settings, mode]);
|
|
|
|
|
|
|
+ }, [settings, mode, resumedSettings]);
|
|
|
|
|
|
|
|
const currencySymbol = getCurrencySymbol(settings?.currency || 'USD');
|
|
const currencySymbol = getCurrencySymbol(settings?.currency || 'USD');
|
|
|
const defaultCostPerKg = settings?.default_filament_cost ?? 0;
|
|
const defaultCostPerKg = settings?.default_filament_cost ?? 0;
|
|
@@ -462,6 +522,39 @@ export function PrintModal({
|
|
|
const sourceDetailsPending =
|
|
const sourceDetailsPending =
|
|
|
(!!archiveId && !isLibraryFile && !archiveDetails && !archiveDetailsFailed) ||
|
|
(!!archiveId && !isLibraryFile && !archiveDetails && !archiveDetailsFailed) ||
|
|
|
(isLibraryFile && !libraryFileDetails && !libraryFileDetailsFailed);
|
|
(isLibraryFile && !libraryFileDetails && !libraryFileDetailsFailed);
|
|
|
|
|
+ // The model the printer picker filters on, pre-selects and offers as "Any X".
|
|
|
|
|
+ // Separate jobs print every listed file, not just the first one the dialog
|
|
|
|
|
+ // was opened with (which may already be queued and gone from the list after
|
|
|
|
|
+ // a refusal), so they offer the model all of them share, or none.
|
|
|
|
|
+ const selectorModel = isSeparateJobs ? commonModel(candidates) : slicedForModel;
|
|
|
|
|
+ // The model gate (#2578) per file: each separate job runs its own file, and
|
|
|
|
|
+ // the backend refuses each one sliced for an incompatible model. A file that
|
|
|
|
|
+ // names no model passes, as a single file does.
|
|
|
|
|
+ const separateJobsIncompatible = useMemo(
|
|
|
|
|
+ () => (isSeparateJobs && assignmentMode === 'model' ? filesIncompatibleWith(candidates, targetModel) : []),
|
|
|
|
|
+ [isSeparateJobs, assignmentMode, candidates, targetModel],
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ // Each mode starts from the assignment it is built on, as when the dialog
|
|
|
|
|
+ // opens in it: the alternatives job is model-based with no printer chosen
|
|
|
|
|
+ // (#671), separate jobs start at the printer picker. Nothing picked for one
|
|
|
|
|
+ // mode rides along unseen into the other; the alternatives request always
|
|
|
|
|
+ // carries target_location, and no control in that mode shows it.
|
|
|
|
|
+ const handleMultiFileModeChange = (next: MultiFileMode) => {
|
|
|
|
|
+ if (next === multiFileMode) return;
|
|
|
|
|
+ // Not while the files are being queued: a refusal mid-run hides the
|
|
|
|
|
+ // toggle, and the rest would then be queued in a mode nobody chose for it.
|
|
|
|
|
+ // The toggle is disabled meanwhile; this keeps the mode in step with it.
|
|
|
|
|
+ if (isSubmitting) return;
|
|
|
|
|
+ // Two files for one model can't be alternatives; the toggle disables the
|
|
|
|
|
+ // choice, and this keeps the mode from ever disagreeing with it.
|
|
|
|
|
+ if (next === 'alternatives' && duplicateModel(candidates) != null) return;
|
|
|
|
|
+ setMultiFileMode(next);
|
|
|
|
|
+ setAssignmentMode(next === 'alternatives' ? 'model' : 'printer');
|
|
|
|
|
+ setTargetModel(null);
|
|
|
|
|
+ setTargetLocation(null);
|
|
|
|
|
+ if (next === 'alternatives') setSelectedPrinters([]);
|
|
|
|
|
+ };
|
|
|
|
|
|
|
|
// The archive's own saved AMS-slot pick from the slicer (see the "Save AMS
|
|
// The archive's own saved AMS-slot pick from the slicer (see the "Save AMS
|
|
|
// mapping" virtual-printer setting) — undefined for library files or
|
|
// mapping" virtual-printer setting) — undefined for library files or
|
|
@@ -605,6 +698,18 @@ export function PrintModal({
|
|
|
}
|
|
}
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
|
|
+ /** The success toast of a create submit that queued `count` items. */
|
|
|
|
|
+ const showQueuedToast = async (count: number) => {
|
|
|
|
|
+ const waitForIdleToast = await asapToastShouldPromiseLaterStart();
|
|
|
|
|
+ if (waitForIdleToast) {
|
|
|
|
|
+ showToast(t('queue.printQueuedWillStartWhenIdle'));
|
|
|
|
|
+ } else if (count === 1) {
|
|
|
|
|
+ showToast(assignmentMode === 'model' ? `Queued for any ${targetModel}` : t('queue.printQueued'));
|
|
|
|
|
+ } else {
|
|
|
|
|
+ showToast(t('queue.itemsQueued', { count }));
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
// Get AMS mapping from hook (only when single printer selected)
|
|
// Get AMS mapping from hook (only when single printer selected)
|
|
|
const { amsMapping } = useFilamentMapping(
|
|
const { amsMapping } = useFilamentMapping(
|
|
|
mappingFilamentReqs,
|
|
mappingFilamentReqs,
|
|
@@ -789,7 +894,7 @@ export function PrintModal({
|
|
|
const defaultId = settings?.default_printer_id;
|
|
const defaultId = settings?.default_printer_id;
|
|
|
const preferred =
|
|
const preferred =
|
|
|
defaultId != null && !isCrossModel && assignmentMode === 'printer' && !sourceDetailsPending
|
|
defaultId != null && !isCrossModel && assignmentMode === 'printer' && !sourceDetailsPending
|
|
|
- ? activePrinters.find(p => p.id === defaultId && (!slicedForModel || p.model === slicedForModel))
|
|
|
|
|
|
|
+ ? activePrinters.find(p => p.id === defaultId && (!selectorModel || p.model === selectorModel))
|
|
|
: undefined;
|
|
: undefined;
|
|
|
if (preferred && !defaultPrinterOffered.current) {
|
|
if (preferred && !defaultPrinterOffered.current) {
|
|
|
defaultPrinterOffered.current = true;
|
|
defaultPrinterOffered.current = true;
|
|
@@ -797,7 +902,7 @@ export function PrintModal({
|
|
|
} else if (activePrinters.length === 1) {
|
|
} else if (activePrinters.length === 1) {
|
|
|
setSelectedPrinters([activePrinters[0].id]);
|
|
setSelectedPrinters([activePrinters[0].id]);
|
|
|
}
|
|
}
|
|
|
- }, [mode, printers, selectedPrinters.length, settings?.default_printer_id, isCrossModel, assignmentMode, slicedForModel, sourceDetailsPending]);
|
|
|
|
|
|
|
+ }, [mode, printers, selectedPrinters.length, settings?.default_printer_id, isCrossModel, assignmentMode, selectorModel, sourceDetailsPending]);
|
|
|
|
|
|
|
|
// Clear manual mappings and per-printer configs when printer or plate changes.
|
|
// Clear manual mappings and per-printer configs when printer or plate changes.
|
|
|
// The per-plate mappings go with them: a manual override holds a global tray id,
|
|
// The per-plate mappings go with them: a manual override holds a global tray id,
|
|
@@ -854,11 +959,11 @@ export function PrintModal({
|
|
|
// to another model, #2578) — fill it with the sliced-for model once known,
|
|
// to another model, #2578) — fill it with the sliced-for model once known,
|
|
|
// provided an active printer of that model exists.
|
|
// provided an active printer of that model exists.
|
|
|
useEffect(() => {
|
|
useEffect(() => {
|
|
|
- if (assignmentMode !== 'model' || targetModel || !slicedForModel) return;
|
|
|
|
|
- if (printers?.some((p) => p.is_active && p.model === slicedForModel)) {
|
|
|
|
|
- setTargetModel(slicedForModel);
|
|
|
|
|
|
|
+ if (assignmentMode !== 'model' || targetModel || !selectorModel) return;
|
|
|
|
|
+ if (printers?.some((p) => p.is_active && p.model === selectorModel)) {
|
|
|
|
|
+ setTargetModel(selectorModel);
|
|
|
}
|
|
}
|
|
|
- }, [assignmentMode, targetModel, slicedForModel, printers]);
|
|
|
|
|
|
|
+ }, [assignmentMode, targetModel, selectorModel, printers]);
|
|
|
|
|
|
|
|
// Auto-expand per-printer mapping when setting is enabled and multiple printers selected
|
|
// Auto-expand per-printer mapping when setting is enabled and multiple printers selected
|
|
|
// Only applies once per printer on initial selection, not when user unchecks
|
|
// Only applies once per printer on initial selection, not when user unchecks
|
|
@@ -904,9 +1009,10 @@ export function PrintModal({
|
|
|
const isMultiPlate = platesData?.is_multi_plate ?? false;
|
|
const isMultiPlate = platesData?.is_multi_plate ?? false;
|
|
|
const plates = platesData?.plates ?? [];
|
|
const plates = platesData?.plates ?? [];
|
|
|
// The plates that will print, beside the buttons: in a long plate list the
|
|
// The plates that will print, beside the buttons: in a long plate list the
|
|
|
- // selected card can be far out of view (#3395).
|
|
|
|
|
|
|
+ // selected card can be far out of view (#3395). Several files choose theirs
|
|
|
|
|
+ // in the candidate list, where they are in view.
|
|
|
const selectedPlatesSummary =
|
|
const selectedPlatesSummary =
|
|
|
- !isCrossModel && isMultiPlate && plates.length > 1
|
|
|
|
|
|
|
+ !isMultiFile && isMultiPlate && plates.length > 1
|
|
|
? plates
|
|
? plates
|
|
|
.filter((p) => selectedPlates.has(p.index))
|
|
.filter((p) => selectedPlates.has(p.index))
|
|
|
.map((p) => {
|
|
.map((p) => {
|
|
@@ -1064,10 +1170,14 @@ export function PrintModal({
|
|
|
return;
|
|
return;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ // Separate jobs skip it: it weighs the filaments of the dialog's one file,
|
|
|
|
|
+ // and each job prints its own. The scheduler's check at dispatch (#1496)
|
|
|
|
|
+ // still holds any of them back that is short of filament.
|
|
|
if (
|
|
if (
|
|
|
!options?.skipFilamentCheck &&
|
|
!options?.skipFilamentCheck &&
|
|
|
!settings?.disable_filament_warnings &&
|
|
!settings?.disable_filament_warnings &&
|
|
|
!isEditing &&
|
|
!isEditing &&
|
|
|
|
|
+ !isSeparateJobs &&
|
|
|
assignmentMode === 'printer'
|
|
assignmentMode === 'printer'
|
|
|
) {
|
|
) {
|
|
|
const warningItems: FilamentWarningItem[] = [];
|
|
const warningItems: FilamentWarningItem[] = [];
|
|
@@ -1194,11 +1304,22 @@ export function PrintModal({
|
|
|
return;
|
|
return;
|
|
|
}
|
|
}
|
|
|
// Cross-model safety gate (#2578) — mirrors the backend's 400 so the user
|
|
// Cross-model safety gate (#2578) — mirrors the backend's 400 so the user
|
|
|
- // gets inline feedback instead of a failed request.
|
|
|
|
|
- if (!isCrossModel && assignmentMode === 'model' && !isGcodeCompatible(slicedForModel, targetModel)) {
|
|
|
|
|
|
|
+ // gets inline feedback instead of a failed request. Separate jobs are
|
|
|
|
|
+ // gated per file below; the dialog's own file may no longer be listed.
|
|
|
|
|
+ if (!isMultiFile && assignmentMode === 'model' && !isGcodeCompatible(slicedForModel, targetModel)) {
|
|
|
showToast(`File was sliced for ${slicedForModel} and cannot be dispatched to ${targetModel} printers`, 'error');
|
|
showToast(`File was sliced for ${slicedForModel} and cannot be dispatched to ${targetModel} printers`, 'error');
|
|
|
return;
|
|
return;
|
|
|
}
|
|
}
|
|
|
|
|
+ if (separateJobsIncompatible.length > 0) {
|
|
|
|
|
+ showToast(
|
|
|
|
|
+ t('printModal.multiFile.incompatible', {
|
|
|
|
|
+ model: targetModel,
|
|
|
|
|
+ files: separateJobsIncompatible.map((f) => f.filename).join(', '),
|
|
|
|
|
+ }),
|
|
|
|
|
+ 'error',
|
|
|
|
|
+ );
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
|
|
|
|
|
setIsSubmitting(true);
|
|
setIsSubmitting(true);
|
|
@@ -1377,6 +1498,170 @@ export function PrintModal({
|
|
|
return;
|
|
return;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ // Separate jobs (#3462): one ordinary queue item per listed file, files in
|
|
|
|
|
+ // list order and the selected printers inside each, through the same
|
|
|
|
|
+ // POST /queue/ the multi-plate fan-out below uses. Returns before that
|
|
|
|
|
+ // fan-out, which is built around the dialog's one file (its batch, its
|
|
|
|
|
+ // per-plate quantities and mappings).
|
|
|
|
|
+ //
|
|
|
|
|
+ // Each item carries only what is true of every file: printer or model,
|
|
|
|
|
+ // schedule, options, cost centre. No ams_mapping and no filament_overrides:
|
|
|
|
|
+ // the mapping and override panels describe the first file's filaments,
|
|
|
|
|
+ // which are not the others'. Without a mapping the scheduler resolves one at
|
|
|
|
|
+ // dispatch from the item's own 3MF and the printer's loaded trays
|
|
|
|
|
+ // (`_ensure_ams_mapping`), as it already does for model-based items. Also
|
|
|
|
|
+ // left out: estimated_cost (a display hint, the server prices each item
|
|
|
|
|
+ // itself), skip_filament_check (the spool warning was never shown),
|
|
|
|
|
+ // nozzle_rack_choice (picked against the first file's filament groups) and
|
|
|
|
|
+ // batch_id (a batch belongs to one source file; quantity > 1 still makes
|
|
|
|
|
+ // the server batch each file on its own).
|
|
|
|
|
+ //
|
|
|
|
|
+ // The first refused file stops the run: queueing the rest would put them
|
|
|
|
|
+ // ahead of it, and stopping lets a resubmit carry on from that file
|
|
|
|
|
+ // without queueing anything twice. What did go in leaves the list, so the
|
|
|
|
|
+ // list is always exactly what is still to be queued.
|
|
|
|
|
+ if (isSeparateJobs) {
|
|
|
|
|
+ const printerIds = assignmentMode === 'printer' ? selectedPrinters : [null];
|
|
|
|
|
+ // The stagger of the printer fan-out below, per file.
|
|
|
|
|
+ const useStagger = assignmentMode === 'printer' && scheduleOptions.staggerEnabled && selectedPrinters.length > 1;
|
|
|
|
|
+ const staggerBaseTime = useStagger
|
|
|
|
|
+ ? (scheduleOptions.scheduleType === 'scheduled' && scheduleOptions.scheduledTime
|
|
|
|
|
+ ? new Date(scheduleOptions.scheduledTime).getTime()
|
|
|
|
|
+ : Date.now())
|
|
|
|
|
+ : 0;
|
|
|
|
|
+ const asap = scheduleOptions.scheduleType === 'asap';
|
|
|
|
|
+ setSubmitProgress({ current: 0, total: candidates.length * printerIds.length });
|
|
|
|
|
+ try {
|
|
|
|
|
+ // The rest of a partly queued run goes in right behind the run's own
|
|
|
|
|
+ // ASAP items as the queue stands now: since the refusal, items may have
|
|
|
|
|
+ // been added, moved or started, and the count of positions taken would
|
|
|
|
|
+ // then point somewhere else (#3471 review). The count is the fallback
|
|
|
|
|
+ // when the queue can't be read.
|
|
|
|
|
+ let asapInsertedBefore = asapPositionsTaken;
|
|
|
|
|
+ if (asap && (runItemIds.length > 0 || runBatchIds.length > 0)) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const pending = await api.getQueue(undefined, 'pending');
|
|
|
|
|
+ asapInsertedBefore = runAsapTail(pending, runItemIds, runBatchIds);
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ // Keep the count.
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ const outcome = await queueSeparateJobs({
|
|
|
|
|
+ files: candidates,
|
|
|
|
|
+ plateByFile: candidatePlates,
|
|
|
|
|
+ printerIds,
|
|
|
|
|
+ alreadyQueued: queuedPairs,
|
|
|
|
|
+ asapInsertedBefore,
|
|
|
|
|
+ quantity: effectiveQuantity,
|
|
|
|
|
+ asap,
|
|
|
|
|
+ buildItem: (file, plateId, printerId, printerIndex) => {
|
|
|
|
|
+ const item: PrintQueueItemCreate = {
|
|
|
|
|
+ library_file_id: file.id,
|
|
|
|
|
+ plate_id: plateId,
|
|
|
|
|
+ printer_id: assignmentMode === 'printer' ? printerId : null,
|
|
|
|
|
+ target_model: assignmentMode === 'model' ? targetModel : null,
|
|
|
|
|
+ target_location: assignmentMode === 'model' ? targetLocation : null,
|
|
|
|
|
+ require_previous_success: scheduleOptions.requirePreviousSuccess,
|
|
|
|
|
+ auto_off_after: scheduleOptions.autoOffAfter,
|
|
|
|
|
+ gcode_injection: scheduleOptions.gcodeInjection,
|
|
|
|
|
+ manual_start: needsReview || (scheduleOptions.scheduleType === 'queue' && scheduleOptions.requireManualStart),
|
|
|
|
|
+ scheduled_time: scheduleOptions.scheduleType === 'scheduled' && scheduleOptions.scheduledTime
|
|
|
|
|
+ ? new Date(scheduleOptions.scheduledTime).toISOString()
|
|
|
|
|
+ : undefined,
|
|
|
|
|
+ ...printOptions,
|
|
|
|
|
+ project_id: projectId ?? undefined,
|
|
|
|
|
+ cost_center_id: billingEnabled ? selectedCostCenterId : undefined,
|
|
|
|
|
+ cleanup_library_after_dispatch: cleanupLibraryAfterDispatch,
|
|
|
|
|
+ };
|
|
|
|
|
+ if (useStagger) {
|
|
|
|
|
+ const groupIndex = Math.floor(printerIndex / scheduleOptions.staggerGroupSize);
|
|
|
|
|
+ if (groupIndex > 0) {
|
|
|
|
|
+ const offsetMs = groupIndex * scheduleOptions.staggerIntervalMinutes * 60_000;
|
|
|
|
|
+ item.scheduled_time = new Date(staggerBaseTime + offsetMs).toISOString();
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return item;
|
|
|
|
|
+ },
|
|
|
|
|
+ addToQueue: (data) => addToQueueMutation.mutateAsync(data),
|
|
|
|
|
+ onProgress: (current, total) => setSubmitProgress({ current, total }),
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ setAsapPositionsTaken(outcome.asapInserted);
|
|
|
|
|
+ const allItemIds = [...runItemIds, ...outcome.queuedItemIds];
|
|
|
|
|
+ const allBatchIds = [...runBatchIds, ...outcome.queuedBatchIds];
|
|
|
|
|
+ setRunItemIds(allItemIds);
|
|
|
|
|
+ setRunBatchIds(allBatchIds);
|
|
|
|
|
+ if (outcome.failure) {
|
|
|
|
|
+ const allQueued = new Set([...queuedPairs, ...outcome.queuedPairs]);
|
|
|
|
|
+ setQueuedPairs(allQueued);
|
|
|
|
|
+ // What is in the queue: the files queued on every printer, and the
|
|
|
|
|
+ // refused file itself when a printer took it, in this submit or an
|
|
|
|
|
+ // earlier one, also on a printer that has been deselected since.
|
|
|
|
|
+ const refusedId = outcome.failure.file.id;
|
|
|
|
|
+ const refusedFileQueued = fileHasQueuedPair(allQueued, refusedId);
|
|
|
|
|
+ const queuedIds = refusedFileQueued
|
|
|
|
|
+ ? [...outcome.queuedFileIds, refusedId]
|
|
|
|
|
+ : outcome.queuedFileIds;
|
|
|
|
|
+ const queued = candidates.filter((c) => queuedIds.includes(c.id)).map((c) => c.filename);
|
|
|
|
|
+ showToast(
|
|
|
|
|
+ t('printModal.multiFile.failed', {
|
|
|
|
|
+ filename: outcome.failure.file.filename,
|
|
|
|
|
+ message: outcome.failure.message,
|
|
|
|
|
+ queued: queued.length > 0 ? queued.join(', ') : t('common.none'),
|
|
|
|
|
+ }),
|
|
|
|
|
+ 'error',
|
|
|
|
|
+ );
|
|
|
|
|
+ // Only files queued on every printer leave the list; the refused one
|
|
|
|
|
+ // stays, even if a printer before the refusal took it.
|
|
|
|
|
+ const remaining = candidates.filter((c) => !outcome.queuedFileIds.includes(c.id));
|
|
|
|
|
+ setCandidates(remaining);
|
|
|
|
|
+ // Any item in the queue makes this run partly queued, also one of
|
|
|
|
|
+ // the refused file on an earlier printer.
|
|
|
|
|
+ if (outcome.itemsCreated > 0) setPartlyQueued(true);
|
|
|
|
|
+ // The opener keeps this if the dialog is closed now: without it the
|
|
|
|
|
+ // same selection would open empty-handed and queue them again.
|
|
|
|
|
+ if (allQueued.size > 0) {
|
|
|
|
|
+ const remainingIds = remaining.map((c) => c.id);
|
|
|
|
|
+ onSeparateJobsProgress?.({
|
|
|
|
|
+ queuedPairs: [...allQueued],
|
|
|
|
|
+ asapInserted: outcome.asapInserted,
|
|
|
|
|
+ queuedItemIds: allItemIds,
|
|
|
|
|
+ queuedBatchIds: allBatchIds,
|
|
|
|
|
+ settings: {
|
|
|
|
|
+ plateByFile: candidatePlates,
|
|
|
|
|
+ assignmentMode,
|
|
|
|
|
+ printerIds: selectedPrinters,
|
|
|
|
|
+ targetModel,
|
|
|
|
|
+ targetLocation,
|
|
|
|
|
+ quantity,
|
|
|
|
|
+ printOptions,
|
|
|
|
|
+ scheduleOptions,
|
|
|
|
|
+ costCenterId: selectedCostCenterId,
|
|
|
|
|
+ },
|
|
|
|
|
+ queuedFileIds: (variantFiles ?? []).map((f) => f.id).filter((id) => !remainingIds.includes(id)),
|
|
|
|
|
+ remainingFileIds: remainingIds,
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+ queryClient.invalidateQueries({ queryKey: ['queue'] });
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Nothing posted means every remaining pair was already queued (a
|
|
|
|
|
+ // refusing printer deselected since): a success, but "0 items queued"
|
|
|
|
|
+ // would say otherwise.
|
|
|
|
|
+ if (outcome.itemsCreated > 0) await showQueuedToast(outcome.itemsCreated);
|
|
|
|
|
+ queryClient.invalidateQueries({ queryKey: ['queue'] });
|
|
|
|
|
+ onSuccess?.();
|
|
|
|
|
+ onClose();
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ // queueSeparateJobs reports refusals itself; this is anything else.
|
|
|
|
|
+ showToast(error instanceof Error ? error.message : String(error), 'error');
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ setIsSubmitting(false);
|
|
|
|
|
+ }
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
// Batch order (#342): a create submission that produces more than one run
|
|
// Batch order (#342): a create submission that produces more than one run
|
|
|
// from one source is pre-created as a batch carrying per-plate targets,
|
|
// from one source is pre-created as a batch carrying per-plate targets,
|
|
|
// and its id is passed to each subsequent addToQueue call. The targets are
|
|
// and its id is passed to each subsequent addToQueue call. The targets are
|
|
@@ -1628,22 +1913,8 @@ export function PrintModal({
|
|
|
if (mode === 'edit-queue-item') {
|
|
if (mode === 'edit-queue-item') {
|
|
|
showToast('Queue item updated');
|
|
showToast('Queue item updated');
|
|
|
}
|
|
}
|
|
|
- } else if (results.success === 1) {
|
|
|
|
|
- const waitForIdleToast = await asapToastShouldPromiseLaterStart();
|
|
|
|
|
- showToast(
|
|
|
|
|
- waitForIdleToast
|
|
|
|
|
- ? t('queue.printQueuedWillStartWhenIdle')
|
|
|
|
|
- : assignmentMode === 'model'
|
|
|
|
|
- ? `Queued for any ${targetModel}`
|
|
|
|
|
- : t('queue.printQueued'),
|
|
|
|
|
- );
|
|
|
|
|
} else {
|
|
} else {
|
|
|
- const waitForIdleToast = await asapToastShouldPromiseLaterStart();
|
|
|
|
|
- showToast(
|
|
|
|
|
- waitForIdleToast
|
|
|
|
|
- ? t('queue.printQueuedWillStartWhenIdle')
|
|
|
|
|
- : t('queue.itemsQueued', { count: results.success }),
|
|
|
|
|
- );
|
|
|
|
|
|
|
+ await showQueuedToast(results.success);
|
|
|
}
|
|
}
|
|
|
queryClient.invalidateQueries({ queryKey: ['queue'] });
|
|
queryClient.invalidateQueries({ queryKey: ['queue'] });
|
|
|
onSuccess?.();
|
|
onSuccess?.();
|
|
@@ -1658,6 +1929,15 @@ export function PrintModal({
|
|
|
|
|
|
|
|
const isPending = isSubmitting || updateQueueMutation.isPending;
|
|
const isPending = isSubmitting || updateQueueMutation.isPending;
|
|
|
|
|
|
|
|
|
|
+ // A single-printer AMS job must wait for the printer's live status before it
|
|
|
|
|
+ // can resolve the filament mapping. Submitting mid-load matched against zero
|
|
|
|
|
+ // known trays and serialized an all-[-1] mapping, which dispatched the print
|
|
|
|
|
+ // to the empty external feed (#2589). Separate jobs send no mapping at all
|
|
|
|
|
+ // (the scheduler maps each against its own file at dispatch), so they have
|
|
|
|
|
+ // nothing to wait for.
|
|
|
|
|
+ const waitingForAmsStatus =
|
|
|
|
|
+ !isSeparateJobs && assignmentMode === 'printer' && selectedPrinters.length === 1 && printerStatusLoading;
|
|
|
|
|
+
|
|
|
const canSubmit = useMemo(() => {
|
|
const canSubmit = useMemo(() => {
|
|
|
if (isPending) return false;
|
|
if (isPending) return false;
|
|
|
|
|
|
|
@@ -1671,11 +1951,15 @@ export function PrintModal({
|
|
|
// Both are about the single-model case. A cross-model job has no one target
|
|
// 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).
|
|
// model, and each candidate is gated against its own by the backend (#671).
|
|
|
if (!isCrossModel && assignmentMode === 'model' && !targetModel) return false;
|
|
if (!isCrossModel && assignmentMode === 'model' && !targetModel) return false;
|
|
|
- // Cross-model mismatch cannot be queued (#2578)
|
|
|
|
|
- if (!isCrossModel && assignmentMode === 'model' && !isGcodeCompatible(slicedForModel, targetModel)) return false;
|
|
|
|
|
|
|
+ // Cross-model mismatch cannot be queued (#2578); separate jobs per file.
|
|
|
|
|
+ if (!isMultiFile && assignmentMode === 'model' && !isGcodeCompatible(slicedForModel, targetModel)) return false;
|
|
|
|
|
+ if (separateJobsIncompatible.length > 0) return false;
|
|
|
|
|
+ // Each listed file's plate comes from the list, in either mode.
|
|
|
|
|
+ if (isMultiFile && !candidatePlatesReady) return false;
|
|
|
|
|
|
|
|
- // For multi-plate files, need at least one plate selected
|
|
|
|
|
- if (isMultiPlate && selectedPlates.size === 0) return false;
|
|
|
|
|
|
|
+ // For multi-plate files, need at least one plate selected. Separate jobs
|
|
|
|
|
+ // take each file's plate from the candidate list, not from this selection.
|
|
|
|
|
+ if (!isSeparateJobs && isMultiPlate && selectedPlates.size === 0) return false;
|
|
|
|
|
|
|
|
// Every selected plate has to have answered before we can queue it: a plate
|
|
// Every selected plate has to have answered before we can queue it: a plate
|
|
|
// still in flight would be sent with no mapping and no overrides, and one that
|
|
// still in flight would be sent with no mapping and no overrides, and one that
|
|
@@ -1683,11 +1967,8 @@ export function PrintModal({
|
|
|
// the rest — the banner above says which state we are in.
|
|
// the rest — the banner above says which state we are in.
|
|
|
if (perPlateReqsPending || perPlateReqsFailed) return false;
|
|
if (perPlateReqsPending || perPlateReqsFailed) return false;
|
|
|
|
|
|
|
|
- // A single-printer AMS job must wait for the printer's live status before it
|
|
|
|
|
- // can resolve the filament mapping. Submitting mid-load matched against zero
|
|
|
|
|
- // known trays and serialized an all-[-1] mapping, which dispatched the print
|
|
|
|
|
- // to the empty external feed (#2589).
|
|
|
|
|
- if (assignmentMode === 'printer' && selectedPrinters.length === 1 && printerStatusLoading) return false;
|
|
|
|
|
|
|
+ // The printer's live status first (#2589, see waitingForAmsStatus).
|
|
|
|
|
+ if (waitingForAmsStatus) return false;
|
|
|
|
|
|
|
|
return true;
|
|
return true;
|
|
|
}, [
|
|
}, [
|
|
@@ -1700,8 +1981,12 @@ export function PrintModal({
|
|
|
isPending,
|
|
isPending,
|
|
|
perPlateReqsPending,
|
|
perPlateReqsPending,
|
|
|
perPlateReqsFailed,
|
|
perPlateReqsFailed,
|
|
|
- printerStatusLoading,
|
|
|
|
|
|
|
+ waitingForAmsStatus,
|
|
|
isCrossModel,
|
|
isCrossModel,
|
|
|
|
|
+ isMultiFile,
|
|
|
|
|
+ isSeparateJobs,
|
|
|
|
|
+ separateJobsIncompatible,
|
|
|
|
|
+ candidatePlatesReady,
|
|
|
billingEnabled,
|
|
billingEnabled,
|
|
|
loadingCostCenters,
|
|
loadingCostCenters,
|
|
|
selectedCostCenter,
|
|
selectedCostCenter,
|
|
@@ -1714,11 +1999,12 @@ export function PrintModal({
|
|
|
// global field is hidden (#342) — the reporter's case is "plate 1 once,
|
|
// global field is hidden (#342) — the reporter's case is "plate 1 once,
|
|
|
// plate 2 twice", which one shared number cannot express. Single-plate
|
|
// plate 2 twice", which one shared number cannot express. Single-plate
|
|
|
// files, and edit mode, keep the single field exactly as before.
|
|
// files, and edit mode, keep the single field exactly as before.
|
|
|
- // Cross-model is excluded: its plate choice is per candidate and lives in
|
|
|
|
|
- // VariantCandidates, so there are no per-plate steppers to own the number
|
|
|
|
|
- // and the global Quantity field below is the only one there is (#3101).
|
|
|
|
|
|
|
+ // Several files are excluded, alternatives and separate jobs alike: their
|
|
|
|
|
+ // plate choice is per candidate and lives in VariantCandidates, so there are
|
|
|
|
|
+ // no per-plate steppers to own the number and the global Quantity field
|
|
|
|
|
+ // below is the only one there is (#3101).
|
|
|
const usePerPlateQuantities =
|
|
const usePerPlateQuantities =
|
|
|
- mode === 'create' && !isCrossModel && isMultiPlate && plates.length > 1;
|
|
|
|
|
|
|
+ mode === 'create' && !isMultiFile && isMultiPlate && plates.length > 1;
|
|
|
|
|
|
|
|
/** Runs to queue for one plate. `null` = the single-plate / whole-file case. */
|
|
/** Runs to queue for one plate. `null` = the single-plate / whole-file case. */
|
|
|
const quantityForPlate = (plateIndex: number | null): number => {
|
|
const quantityForPlate = (plateIndex: number | null): number => {
|
|
@@ -1776,7 +2062,9 @@ export function PrintModal({
|
|
|
// - Single printer selected
|
|
// - Single printer selected
|
|
|
// - For archives: plate is selected (for multi-plate) or not required (single-plate)
|
|
// - For archives: plate is selected (for multi-plate) or not required (single-plate)
|
|
|
// - For library files: always show (no plate selection)
|
|
// - For library files: always show (no plate selection)
|
|
|
- const showFilamentMapping = effectivePrinterId && selectedPlates.size <= 1 && (
|
|
|
|
|
|
|
+ // Never for separate jobs: the panel maps the first file's filaments, and
|
|
|
|
|
+ // each job is mapped against its own file at dispatch.
|
|
|
|
|
+ const showFilamentMapping = !isSeparateJobs && effectivePrinterId && selectedPlates.size <= 1 && (
|
|
|
isLibraryFile || (isMultiPlate ? selectedPlate !== null : true)
|
|
isLibraryFile || (isMultiPlate ? selectedPlate !== null : true)
|
|
|
);
|
|
);
|
|
|
|
|
|
|
@@ -1791,7 +2079,7 @@ export function PrintModal({
|
|
|
// plate *per printer*, so those items ship without a mapping and the scheduler
|
|
// plate *per printer*, so those items ship without a mapping and the scheduler
|
|
|
// computes one per plate when it picks the printer.
|
|
// computes one per plate when it picks the printer.
|
|
|
const showPerPlateFilamentMapping =
|
|
const showPerPlateFilamentMapping =
|
|
|
- !!effectivePrinterId && isMultiPlateSelection && selectedPrinters.length === 1;
|
|
|
|
|
|
|
+ !isSeparateJobs && !!effectivePrinterId && isMultiPlateSelection && selectedPrinters.length === 1;
|
|
|
|
|
|
|
|
// Model mode has no printer and so no trays to map onto; what it offers instead
|
|
// Model mode has no printer and so no trays to map onto; what it offers instead
|
|
|
// is the filament each slot must be printed in, which the scheduler matches
|
|
// is the filament each slot must be printed in, which the scheduler matches
|
|
@@ -1799,8 +2087,10 @@ export function PrintModal({
|
|
|
// filaments to offer as alternatives.
|
|
// filaments to offer as alternatives.
|
|
|
// Cross-model items have no targetModel by design — their candidates each
|
|
// Cross-model items have no targetModel by design — their candidates each
|
|
|
// carry their own — so gate on having somewhere to source choices from.
|
|
// carry their own — so gate on having somewhere to source choices from.
|
|
|
|
|
+ // Separate jobs send no overrides: the slots listed are the first file's.
|
|
|
const showFilamentOverride =
|
|
const showFilamentOverride =
|
|
|
assignmentMode === 'model'
|
|
assignmentMode === 'model'
|
|
|
|
|
+ && !isSeparateJobs
|
|
|
&& (isCrossModel || !!targetModel)
|
|
&& (isCrossModel || !!targetModel)
|
|
|
&& !!effectiveAvailableFilaments
|
|
&& !!effectiveAvailableFilaments
|
|
|
&& effectiveAvailableFilaments.length > 0;
|
|
&& effectiveAvailableFilaments.length > 0;
|
|
@@ -1860,9 +2150,10 @@ export function PrintModal({
|
|
|
early so the user knows which plate to mount before scheduling
|
|
early so the user knows which plate to mount before scheduling
|
|
|
(#1281). PlateSelector renders its own per-plate badges for
|
|
(#1281). PlateSelector renders its own per-plate badges for
|
|
|
multi-plate files; this badge covers the single-plate case and
|
|
multi-plate files; this badge covers the single-plate case and
|
|
|
- the multi-plate case where exactly one plate is selected. */}
|
|
|
|
|
|
|
+ the multi-plate case where exactly one plate is selected.
|
|
|
|
|
+ Not for separate jobs: it would name the first file's plate. */}
|
|
|
{(() => {
|
|
{(() => {
|
|
|
- if (!plates.length) return null;
|
|
|
|
|
|
|
+ if (isSeparateJobs || !plates.length) return null;
|
|
|
const target = selectedPlate != null
|
|
const target = selectedPlate != null
|
|
|
? plates.find(p => p.index === selectedPlate)
|
|
? plates.find(p => p.index === selectedPlate)
|
|
|
: plates[0];
|
|
: plates[0];
|
|
@@ -1880,8 +2171,8 @@ export function PrintModal({
|
|
|
selecting printers. Cross-model has no use for it: the plate is
|
|
selecting printers. Cross-model has no use for it: the plate is
|
|
|
chosen per candidate in the list below, and this selector's own
|
|
chosen per candidate in the list below, and this selector's own
|
|
|
choice never reached the request — it only decided which plate
|
|
choice never reached the request — it only decided which plate
|
|
|
- the filament panel described (#3101). */}
|
|
|
|
|
- {!isCrossModel && (
|
|
|
|
|
|
|
+ the filament panel described (#3101). Separate jobs likewise. */}
|
|
|
|
|
+ {!isMultiFile && (
|
|
|
<PlateSelector
|
|
<PlateSelector
|
|
|
plates={plates}
|
|
plates={plates}
|
|
|
isMultiPlate={isMultiPlate}
|
|
isMultiPlate={isMultiPlate}
|
|
@@ -1914,10 +2205,26 @@ export function PrintModal({
|
|
|
/>
|
|
/>
|
|
|
)}
|
|
)}
|
|
|
|
|
|
|
|
|
|
+ {/* What the marked files become (#3462). Only for a selection made
|
|
|
|
|
+ in the File Manager: a version group is one job sliced for
|
|
|
|
|
+ different printers, for which only alternatives make sense.
|
|
|
|
|
+ Its hint, or why Alternatives is blocked, is the one
|
|
|
|
|
+ explanation of the mode, so the list below shows none. */}
|
|
|
|
|
+ {showModeToggle && (
|
|
|
|
|
+ <MultiFileModeToggle
|
|
|
|
|
+ value={multiFileMode}
|
|
|
|
|
+ onChange={handleMultiFileModeChange}
|
|
|
|
|
+ blockedModel={duplicateModel(candidates)}
|
|
|
|
|
+ disabled={isPending}
|
|
|
|
|
+ />
|
|
|
|
|
+ )}
|
|
|
|
|
+
|
|
|
{/* Cross-model alternatives (#671) replace the printer picker entirely:
|
|
{/* Cross-model alternatives (#671) replace the printer picker entirely:
|
|
|
the user already answered "which printer" by choosing these files,
|
|
the user already answered "which printer" by choosing these files,
|
|
|
- and the remaining question is only which they'd rather have. */}
|
|
|
|
|
- {isCrossModel && (
|
|
|
|
|
|
|
+ and the remaining question is only which they'd rather have.
|
|
|
|
|
+ Separate jobs use the same list for their queue order and keep
|
|
|
|
|
+ the printer picker below. */}
|
|
|
|
|
+ {isMultiFile && (
|
|
|
<VariantCandidates
|
|
<VariantCandidates
|
|
|
candidates={candidates}
|
|
candidates={candidates}
|
|
|
onReorder={setCandidates}
|
|
onReorder={setCandidates}
|
|
@@ -1925,6 +2232,9 @@ export function PrintModal({
|
|
|
onPlateChange={(fileId, plateId) =>
|
|
onPlateChange={(fileId, plateId) =>
|
|
|
setCandidatePlates((prev) => ({ ...prev, [fileId]: plateId }))
|
|
setCandidatePlates((prev) => ({ ...prev, [fileId]: plateId }))
|
|
|
}
|
|
}
|
|
|
|
|
+ onPlatesReadyChange={setCandidatePlatesReady}
|
|
|
|
|
+ title={isSeparateJobs ? t('printModal.multiFile.orderTitle') : undefined}
|
|
|
|
|
+ help={showModeToggle ? '' : isSeparateJobs ? t('printModal.multiFile.separateHint') : undefined}
|
|
|
/>
|
|
/>
|
|
|
)}
|
|
)}
|
|
|
|
|
|
|
@@ -1955,7 +2265,9 @@ export function PrintModal({
|
|
|
// fan-out across printers ships no mapping at all (the scheduler maps
|
|
// fan-out across printers ships no mapping at all (the scheduler maps
|
|
|
// each plate against the printer it picks), so the editor would be
|
|
// each plate against the printer it picks), so the editor would be
|
|
|
// collecting tray choices it then throws away. Withhold its input.
|
|
// collecting tray choices it then throws away. Withhold its input.
|
|
|
- filamentReqs={isMultiPlateSelection ? undefined : mappingFilamentReqs}
|
|
|
|
|
|
|
+ // Separate jobs alike: those are the first file's filaments, and
|
|
|
|
|
+ // every job ships without a mapping.
|
|
|
|
|
+ filamentReqs={isMultiPlateSelection || isSeparateJobs ? undefined : mappingFilamentReqs}
|
|
|
onAutoConfigurePrinter={multiPrinterMapping.autoConfigurePrinter}
|
|
onAutoConfigurePrinter={multiPrinterMapping.autoConfigurePrinter}
|
|
|
onUpdatePrinterConfig={multiPrinterMapping.updatePrinterConfig}
|
|
onUpdatePrinterConfig={multiPrinterMapping.updatePrinterConfig}
|
|
|
assignmentMode={assignmentMode}
|
|
assignmentMode={assignmentMode}
|
|
@@ -1964,10 +2276,24 @@ export function PrintModal({
|
|
|
onTargetModelChange={setTargetModel}
|
|
onTargetModelChange={setTargetModel}
|
|
|
targetLocation={targetLocation}
|
|
targetLocation={targetLocation}
|
|
|
onTargetLocationChange={setTargetLocation}
|
|
onTargetLocationChange={setTargetLocation}
|
|
|
- slicedForModel={slicedForModel}
|
|
|
|
|
|
|
+ slicedForModel={selectorModel}
|
|
|
/>
|
|
/>
|
|
|
)}
|
|
)}
|
|
|
|
|
|
|
|
|
|
+ {/* "Any X" for separate jobs: each file is gated on its own (#2578),
|
|
|
|
|
+ and the backend would refuse these one by one. */}
|
|
|
|
|
+ {separateJobsIncompatible.length > 0 && (
|
|
|
|
|
+ <div className="p-3 mb-2 bg-red-50 dark:bg-red-500/10 border border-red-300 dark:border-red-500/30 rounded-lg flex items-center gap-2">
|
|
|
|
|
+ <AlertCircle className="w-4 h-4 text-red-600 dark:text-red-400 flex-shrink-0" />
|
|
|
|
|
+ <span className="text-sm text-red-700 dark:text-red-400">
|
|
|
|
|
+ {t('printModal.multiFile.incompatible', {
|
|
|
|
|
+ model: targetModel,
|
|
|
|
|
+ files: separateJobsIncompatible.map((f) => f.filename).join(', '),
|
|
|
|
|
+ })}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+
|
|
|
{/* Filament override - shown in model mode when filament requirements are available */}
|
|
{/* Filament override - shown in model mode when filament requirements are available */}
|
|
|
{showFilamentOverride && !isMultiPlateSelection && effectiveFilamentReqs && (
|
|
{showFilamentOverride && !isMultiPlateSelection && effectiveFilamentReqs && (
|
|
|
<FilamentOverride
|
|
<FilamentOverride
|
|
@@ -2011,7 +2337,7 @@ export function PrintModal({
|
|
|
})}
|
|
})}
|
|
|
|
|
|
|
|
{/* Compatibility warning when sliced model doesn't match selected printer */}
|
|
{/* Compatibility warning when sliced model doesn't match selected printer */}
|
|
|
- {slicedForModel && assignmentMode === 'printer' && selectedPrinters.length === 1 && (() => {
|
|
|
|
|
|
|
+ {!isSeparateJobs && slicedForModel && assignmentMode === 'printer' && selectedPrinters.length === 1 && (() => {
|
|
|
const selectedPrinter = printers?.find(p => p.id === selectedPrinters[0]);
|
|
const selectedPrinter = printers?.find(p => p.id === selectedPrinters[0]);
|
|
|
if (selectedPrinter && selectedPrinter.model && slicedForModel !== selectedPrinter.model) {
|
|
if (selectedPrinter && selectedPrinter.model && slicedForModel !== selectedPrinter.model) {
|
|
|
return (
|
|
return (
|
|
@@ -2026,6 +2352,37 @@ export function PrintModal({
|
|
|
return null;
|
|
return null;
|
|
|
})()}
|
|
})()}
|
|
|
|
|
|
|
|
|
|
+ {/* The same warning for separate jobs, naming each file that was
|
|
|
|
|
+ sliced for another model. Every selected printer gets every
|
|
|
|
|
+ file, so there is one line per model among them; with mixed
|
|
|
|
|
+ files the picker shows all printers unfiltered. A warning, not
|
|
|
|
|
+ a block, as for one file. */}
|
|
|
|
|
+ {isSeparateJobs && assignmentMode === 'printer' && (() => {
|
|
|
|
|
+ const printerModels = new Set(
|
|
|
|
|
+ selectedPrinters
|
|
|
|
|
+ .map((id) => printers?.find((p) => p.id === id)?.model)
|
|
|
|
|
+ .filter((model): model is string => !!model),
|
|
|
|
|
+ );
|
|
|
|
|
+ return [...printerModels].map((printerModel) => {
|
|
|
|
|
+ const mismatched = filesNotSlicedFor(candidates, printerModel);
|
|
|
|
|
+ if (mismatched.length === 0) return null;
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div
|
|
|
|
|
+ key={printerModel}
|
|
|
|
|
+ className="p-3 mb-2 bg-yellow-50 dark:bg-yellow-500/10 border border-yellow-300 dark:border-yellow-500/30 rounded-lg flex items-center gap-2"
|
|
|
|
|
+ >
|
|
|
|
|
+ <AlertTriangle className="w-4 h-4 text-yellow-600 dark:text-yellow-400 flex-shrink-0" />
|
|
|
|
|
+ <span className="text-sm text-yellow-700 dark:text-yellow-400">
|
|
|
|
|
+ {t('printModal.multiFile.notSlicedFor', {
|
|
|
|
|
+ model: printerModel,
|
|
|
|
|
+ files: mismatched.map((f) => f.filename).join(', '),
|
|
|
|
|
+ })}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ );
|
|
|
|
|
+ });
|
|
|
|
|
+ })()}
|
|
|
|
|
+
|
|
|
{/* Warning when archive data couldn't be loaded */}
|
|
{/* Warning when archive data couldn't be loaded */}
|
|
|
{archiveDataMissing && (
|
|
{archiveDataMissing && (
|
|
|
<div className="flex items-start gap-2 p-3 mb-2 bg-orange-50 dark:bg-orange-500/10 border border-orange-300 dark:border-orange-500/30 rounded-lg text-sm">
|
|
<div className="flex items-start gap-2 p-3 mb-2 bg-orange-50 dark:bg-orange-500/10 border border-orange-300 dark:border-orange-500/30 rounded-lg text-sm">
|
|
@@ -2163,7 +2520,10 @@ export function PrintModal({
|
|
|
/>
|
|
/>
|
|
|
{quantity > 1 && (
|
|
{quantity > 1 && (
|
|
|
<span className="text-xs text-bambu-gray">
|
|
<span className="text-xs text-bambu-gray">
|
|
|
- {t('queue.quantityHint', 'Creates {{count}} queue items', { count: quantity })}
|
|
|
|
|
|
|
+ {/* Separate jobs post the quantity once per listed file. */}
|
|
|
|
|
+ {t('queue.quantityHint', 'Creates {{count}} queue items', {
|
|
|
|
|
+ count: isSeparateJobs ? quantity * candidates.length : quantity,
|
|
|
|
|
+ })}
|
|
|
</span>
|
|
</span>
|
|
|
)}
|
|
)}
|
|
|
</div>
|
|
</div>
|
|
@@ -2209,7 +2569,7 @@ export function PrintModal({
|
|
|
|
|
|
|
|
{/* Waiting for the printer's AMS status: submitting now would map
|
|
{/* Waiting for the printer's AMS status: submitting now would map
|
|
|
against zero known trays and dispatch to the empty external feed (#2589). */}
|
|
against zero known trays and dispatch to the empty external feed (#2589). */}
|
|
|
- {assignmentMode === 'printer' && selectedPrinters.length === 1 && printerStatusLoading && (
|
|
|
|
|
|
|
+ {waitingForAmsStatus && (
|
|
|
<div className="mb-4 p-3 bg-blue-100 dark:bg-blue-500/20 border border-blue-500/50 rounded-lg text-sm text-blue-700 dark:text-blue-400 flex items-center gap-2">
|
|
<div className="mb-4 p-3 bg-blue-100 dark:bg-blue-500/20 border border-blue-500/50 rounded-lg text-sm text-blue-700 dark:text-blue-400 flex items-center gap-2">
|
|
|
<Loader2 className="w-4 h-4 animate-spin" />
|
|
<Loader2 className="w-4 h-4 animate-spin" />
|
|
|
{t('printModal.waitingForAmsStatus', {
|
|
{t('printModal.waitingForAmsStatus', {
|