mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-18 03:54:17 +02:00
Pool AMS backup spools in the print dialog's filament check
The dialog weighed each plate against the spool in the slot it mapped to and knew nothing about AMS Filament Backup, so a two-plate job needing 1441 g of ABS was refused against a 1000 g spool while the identical full spool in the next slot went uncounted. The dispatcher has pooled matching spools since #1762 and would have run the print -- "Print anyway" was always the right answer to this warning. The rule for which spools back each other up now lives in one place: build_slot_materials() in filament_deficit, which the dispatcher's pool and the new slot_materials half of GET /printers/{id}/inventory-remain both draw on. The dialog groups on the keys it is handed rather than resolving spools a second time, which is what let the two answers drift apart, and which also gives the check to Spoolman users -- it read the internal inventory only, so in Spoolman mode it approved everything. Where a pool really is short the warning quotes the pooled totals, since the per-slot figure reads as a contradiction next to a full peer spool.
This commit is contained in:
@@ -2,7 +2,7 @@ import { useMutation, useQueries, useQuery, useQueryClient } from '@tanstack/rea
|
||||
import { AlertCircle, AlertTriangle, Loader2, Pencil, Printer, X } from 'lucide-react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { CostCenterSummary, PrinterStatus, PrintQueueItemCreate, PrintQueueItemUpdate, SpoolAssignment } from '../../api/client';
|
||||
import type { CostCenterSummary, PrinterStatus, PrintQueueItemCreate, PrintQueueItemUpdate, SlotMaterial } from '../../api/client';
|
||||
import { api } from '../../api/client';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { Card, CardContent } from '../Card';
|
||||
@@ -21,7 +21,7 @@ import { isGcodeCompatible } from '../../utils/printer';
|
||||
import { getCurrencySymbol } from '../../utils/currency';
|
||||
import { getBedTypeInfo } from '../../utils/bedType';
|
||||
import { toDateTimeLocalValue, parseUTCDate } from '../../utils/date';
|
||||
import { getGlobalTrayId, isPlaceholderDate, effectivePreferLowest } from '../../utils/amsHelpers';
|
||||
import { isPlaceholderDate, effectivePreferLowest } from '../../utils/amsHelpers';
|
||||
import { resolveArchiveSlicerAmsMapping } from './archiveAmsMapping';
|
||||
import { FilamentMapping } from './FilamentMapping';
|
||||
import { FilamentOverride } from './FilamentOverride';
|
||||
@@ -97,6 +97,9 @@ export function PrintModal({
|
||||
slotLabel: string;
|
||||
requiredGrams: number;
|
||||
remainingGrams: number;
|
||||
/** True when AMS Filament Backup pooled more than one spool for this slot;
|
||||
* `requiredGrams` / `remainingGrams` are then the pooled totals. */
|
||||
pooled?: boolean;
|
||||
};
|
||||
|
||||
// Multiple printer selection (used for all modes now)
|
||||
@@ -339,13 +342,6 @@ export function PrintModal({
|
||||
setSelectedCostCenterId(preferredPrivate?.id ?? printableCostCenters[0].id);
|
||||
}, [printableCostCenters, selectedCostCenterId]);
|
||||
|
||||
const { data: spoolAssignments } = useQuery({
|
||||
queryKey: ['spool-assignments'],
|
||||
queryFn: () => api.getAssignments(),
|
||||
staleTime: 30 * 1000,
|
||||
enabled: !isEditing && assignmentMode === 'printer',
|
||||
});
|
||||
|
||||
// Fetch per-printer Map<globalTrayId, gramsRemaining> via the dedicated
|
||||
// backend endpoint (#1766). Server-side mirrors `_build_inventory_remain_overrides`
|
||||
// so internal and Spoolman modes both work uniformly, VT/external slots are
|
||||
@@ -374,6 +370,23 @@ export function PrintModal({
|
||||
return result;
|
||||
}, [selectedPrinters, inventoryRemainQueries]);
|
||||
|
||||
// Same endpoint, the other half of its payload: every inventory-bound slot on
|
||||
// the printer with the backend's material identity and extruder side. The
|
||||
// pre-flight filament check groups on these instead of resolving spools
|
||||
// itself, which is what makes it agree with the dispatcher and work in
|
||||
// Spoolman mode (where the modal has no assignment rows of its own).
|
||||
const slotMaterialsPerPrinter = useMemo(() => {
|
||||
const result = new Map<number, Map<number, SlotMaterial>>();
|
||||
selectedPrinters.forEach((printerId, idx) => {
|
||||
const slots = inventoryRemainQueries[idx]?.data?.slot_materials;
|
||||
if (!slots) return;
|
||||
const printerMap = new Map<number, SlotMaterial>();
|
||||
slots.forEach((slot) => printerMap.set(slot.global_tray_id, slot));
|
||||
result.set(printerId, printerMap);
|
||||
});
|
||||
return result;
|
||||
}, [selectedPrinters, inventoryRemainQueries]);
|
||||
|
||||
// Fetch archive details to get sliced_for_model
|
||||
const { data: archiveDetails } = useQuery({
|
||||
queryKey: ['archive', archiveId],
|
||||
@@ -747,32 +760,26 @@ export function PrintModal({
|
||||
const isMultiPlate = platesData?.is_multi_plate ?? false;
|
||||
const plates = platesData?.plates ?? [];
|
||||
|
||||
const spoolAssignmentsByPrinter = useMemo(() => {
|
||||
const map = new Map<number, Map<number, SpoolAssignment>>();
|
||||
if (!spoolAssignments) return map;
|
||||
spoolAssignments.forEach((assignment) => {
|
||||
const isExternal = assignment.ams_id === 255;
|
||||
const globalTrayId = getGlobalTrayId(
|
||||
assignment.ams_id,
|
||||
assignment.tray_id,
|
||||
isExternal
|
||||
);
|
||||
const printerMap = map.get(assignment.printer_id) ?? new Map();
|
||||
printerMap.set(globalTrayId, assignment);
|
||||
map.set(assignment.printer_id, printerMap);
|
||||
});
|
||||
return map;
|
||||
}, [spoolAssignments]);
|
||||
|
||||
const filamentWarningMessage = useMemo(() => {
|
||||
if (!filamentWarningItems || filamentWarningItems.length === 0) return '';
|
||||
const lines = filamentWarningItems.map((item) =>
|
||||
t('printModal.insufficientFilamentLine', {
|
||||
printer: item.printerName,
|
||||
slot: item.slotLabel,
|
||||
required: Math.round(item.requiredGrams),
|
||||
remaining: Math.round(item.remainingGrams),
|
||||
})
|
||||
// Under AMS Filament Backup the shortfall is against the pooled spools,
|
||||
// not the one slot — quoting that slot's remaining next to a pooled
|
||||
// requirement reads as a contradiction ("needs 1441g, remaining 1000g"
|
||||
// while a second full spool sits next to it).
|
||||
item.pooled
|
||||
? t('printModal.insufficientFilamentLinePooled', {
|
||||
printer: item.printerName,
|
||||
slot: item.slotLabel,
|
||||
required: Math.round(item.requiredGrams),
|
||||
remaining: Math.round(item.remainingGrams),
|
||||
})
|
||||
: t('printModal.insufficientFilamentLine', {
|
||||
printer: item.printerName,
|
||||
slot: item.slotLabel,
|
||||
required: Math.round(item.requiredGrams),
|
||||
remaining: Math.round(item.remainingGrams),
|
||||
})
|
||||
);
|
||||
return [t('printModal.insufficientFilamentMessage'), ...lines].join('\n');
|
||||
}, [filamentWarningItems, t]);
|
||||
@@ -846,13 +853,7 @@ export function PrintModal({
|
||||
? selectedPlateIds.map((plateId) => ({ plateId, reqs: perPlateReqs.get(plateId)?.filaments ?? [] }))
|
||||
: [{ plateId: selectedPlate, reqs: effectiveFilamentReqs?.filaments ?? [] }];
|
||||
|
||||
if (plateJobs.some((job) => job.reqs.length > 0) && spoolAssignmentsByPrinter.size > 0) {
|
||||
const getRemainingWeight = (labelWeight: number, weightUsed: number) => {
|
||||
if (!Number.isFinite(labelWeight) || labelWeight <= 0) return null;
|
||||
if (!Number.isFinite(weightUsed) || weightUsed < 0) return null;
|
||||
return Math.max(0, labelWeight - weightUsed);
|
||||
};
|
||||
|
||||
if (plateJobs.some((job) => job.reqs.length > 0) && slotMaterialsPerPrinter.size > 0) {
|
||||
for (const printerId of selectedPrinters) {
|
||||
const printerStatusForWarning = selectedPrinters.length > 1
|
||||
? multiPrinterMapping.printerResults.find((result) => result.printerId === printerId)?.status
|
||||
@@ -860,10 +861,13 @@ export function PrintModal({
|
||||
|
||||
const loadedFilaments = buildLoadedFilaments(printerStatusForWarning);
|
||||
const slotLabelByTray = new Map(loadedFilaments.map((f) => [f.globalTrayId, f.label]));
|
||||
const assignments = spoolAssignmentsByPrinter.get(printerId);
|
||||
// Slots the backend could price. A slot missing here is one with no
|
||||
// inventory binding, or one whose Spoolman spool it could not read —
|
||||
// both mean "nothing to weigh", never "empty".
|
||||
const slotMaterials = slotMaterialsPerPrinter.get(printerId);
|
||||
const printerName = printers?.find((p) => p.id === printerId)?.name ?? `Printer ${printerId}`;
|
||||
|
||||
if (!assignments) continue;
|
||||
if (!slotMaterials || slotMaterials.size === 0) continue;
|
||||
|
||||
const gramsByTray = new Map<number, number>();
|
||||
for (const job of plateJobs) {
|
||||
@@ -880,19 +884,62 @@ export function PrintModal({
|
||||
});
|
||||
}
|
||||
|
||||
// With AMS Filament Backup ON the firmware switches to any other slot
|
||||
// holding the same material, so the print is only short when the whole
|
||||
// pool is (#1762). The dispatcher has accounted for this since #1762 —
|
||||
// this check did not, and blocked prints the dispatcher would have run.
|
||||
// Dual-extruder printers pool per side: the firmware cannot cross
|
||||
// nozzles even with the backup bit set, which is why `extruder` is part
|
||||
// of the key the backend hands us.
|
||||
const backupOn = printerStatusForWarning?.ams_filament_backup === true;
|
||||
const poolKey = (slot: SlotMaterial) => `${slot.material_key}#${slot.extruder}`;
|
||||
|
||||
const pooledGrams = new Map<string, number>();
|
||||
const pooledSlotCount = new Map<string, number>();
|
||||
const pooledRequired = new Map<string, number>();
|
||||
if (backupOn) {
|
||||
slotMaterials.forEach((slot) => {
|
||||
const key = poolKey(slot);
|
||||
pooledGrams.set(key, (pooledGrams.get(key) ?? 0) + slot.remaining_g);
|
||||
pooledSlotCount.set(key, (pooledSlotCount.get(key) ?? 0) + 1);
|
||||
});
|
||||
for (const [globalTrayId, requiredGrams] of gramsByTray) {
|
||||
const slot = slotMaterials.get(globalTrayId);
|
||||
if (!slot) continue;
|
||||
const key = poolKey(slot);
|
||||
pooledRequired.set(key, (pooledRequired.get(key) ?? 0) + requiredGrams);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [globalTrayId, requiredGrams] of gramsByTray) {
|
||||
const spool = assignments.get(globalTrayId)?.spool;
|
||||
if (!spool) continue;
|
||||
const slot = slotMaterials.get(globalTrayId);
|
||||
if (!slot) continue;
|
||||
|
||||
const remainingGrams = getRemainingWeight(spool.label_weight, spool.weight_used);
|
||||
if (remainingGrams === null) continue;
|
||||
if (remainingGrams >= requiredGrams) continue;
|
||||
const slotLabel = slotLabelByTray.get(globalTrayId) ?? `Tray ${globalTrayId}`;
|
||||
|
||||
if (backupOn) {
|
||||
const key = poolKey(slot);
|
||||
const available = pooledGrams.get(key) ?? 0;
|
||||
const needed = pooledRequired.get(key) ?? 0;
|
||||
if (available >= needed) continue;
|
||||
warningItems.push({
|
||||
printerName,
|
||||
slotLabel,
|
||||
requiredGrams: needed,
|
||||
remainingGrams: available,
|
||||
// A pool of one is just the slot itself — same numbers, so use
|
||||
// the plain wording rather than talk about spools that aren't there.
|
||||
pooled: (pooledSlotCount.get(key) ?? 1) > 1,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (slot.remaining_g >= requiredGrams) continue;
|
||||
warningItems.push({
|
||||
printerName,
|
||||
slotLabel: slotLabelByTray.get(globalTrayId) ?? `Tray ${globalTrayId}`,
|
||||
slotLabel,
|
||||
requiredGrams,
|
||||
remainingGrams,
|
||||
remainingGrams: slot.remaining_g,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user