● Add prefer lowest remaining filament in auto-matching (#805)

When multiple AMS spools match the same type/color criteria, an optional
  setting now prefers the spool with the lowest remaining filament. This
  helps consume partial spools before starting new ones. Sorting applies
  to all matching paths: queue scheduler, print modal, and multi-printer
  mapping. Unknown remain values (-1) sort to end.
This commit is contained in:
maziggy
2026-03-31 14:14:15 +02:00
parent 3c887f5166
commit 5a696f9fa3
24 changed files with 476 additions and 155 deletions
@@ -25,9 +25,12 @@ function makeFilament(overrides: Partial<LoadedFilament> & { globalTrayId: numbe
colorName: 'White',
amsId: 0,
trayId: 0,
isHt: false,
isExternal: false,
label: 'AMS1-T1',
trayInfoIdx: '',
extruderId: undefined,
remain: -1,
...overrides,
};
}
@@ -196,3 +199,51 @@ describe('autoMatchFilament', () => {
expect(result!.globalTrayId).toBe(0);
});
});
// -- autoMatchFilament with preferLowest ------------------------------------
describe('autoMatchFilament preferLowest', () => {
it('picks spool with lowest remain when enabled', () => {
const filaments = [
makeFilament({ globalTrayId: 0, type: 'PLA', color: '#FF0000', colorName: 'Red', remain: 80 }),
makeFilament({ globalTrayId: 1, type: 'PLA', color: '#FF0000', colorName: 'Red', remain: 30 }),
];
const req = makeReq({ type: 'PLA', color: '#FF0000' });
const result = autoMatchFilament(req, filaments, new Set(), true);
expect(result).toBeDefined();
expect(result!.globalTrayId).toBe(1); // 30% < 80%
});
it('picks first spool when disabled (default behavior)', () => {
const filaments = [
makeFilament({ globalTrayId: 0, type: 'PLA', color: '#FF0000', colorName: 'Red', remain: 80 }),
makeFilament({ globalTrayId: 1, type: 'PLA', color: '#FF0000', colorName: 'Red', remain: 30 }),
];
const req = makeReq({ type: 'PLA', color: '#FF0000' });
const result = autoMatchFilament(req, filaments, new Set(), false);
expect(result).toBeDefined();
expect(result!.globalTrayId).toBe(0); // First match
});
it('sorts unknown remain (-1) to end', () => {
const filaments = [
makeFilament({ globalTrayId: 0, type: 'PLA', color: '#FF0000', colorName: 'Red', remain: -1 }),
makeFilament({ globalTrayId: 1, type: 'PLA', color: '#FF0000', colorName: 'Red', remain: 50 }),
];
const req = makeReq({ type: 'PLA', color: '#FF0000' });
const result = autoMatchFilament(req, filaments, new Set(), true);
expect(result).toBeDefined();
expect(result!.globalTrayId).toBe(1); // Known 50% over unknown
});
it('still respects nozzle constraint with preferLowest', () => {
const filaments = [
makeFilament({ globalTrayId: 0, type: 'PLA', color: '#FF0000', colorName: 'Red', remain: 10, extruderId: 1 }),
makeFilament({ globalTrayId: 1, type: 'PLA', color: '#FF0000', colorName: 'Red', remain: 80, extruderId: 0 }),
];
const req = makeReq({ type: 'PLA', color: '#FF0000', nozzle_id: 0 });
const result = autoMatchFilament(req, filaments, new Set(), true);
expect(result).toBeDefined();
expect(result!.globalTrayId).toBe(1); // Only tray on correct nozzle
});
});
@@ -873,3 +873,59 @@ describe('X1C model tests (single nozzle, real data)', () => {
});
});
});
describe('computeAmsMapping preferLowest', () => {
it('picks spool with lowest remain when enabled', () => {
const reqs = {
filaments: [{ slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10 }],
};
const status = createPrinterStatus([
{
id: 0,
tray: [
{ id: 0, tray_type: 'PLA', tray_color: 'FF0000', remain: 80 },
{ id: 1, tray_type: 'PLA', tray_color: 'FF0000', remain: 25 },
],
},
]);
const result = computeAmsMapping(reqs, status, true);
expect(result).toEqual([1]); // Tray 1 has 25% remain
});
it('picks first match when disabled', () => {
const reqs = {
filaments: [{ slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10 }],
};
const status = createPrinterStatus([
{
id: 0,
tray: [
{ id: 0, tray_type: 'PLA', tray_color: 'FF0000', remain: 80 },
{ id: 1, tray_type: 'PLA', tray_color: 'FF0000', remain: 25 },
],
},
]);
const result = computeAmsMapping(reqs, status, false);
expect(result).toEqual([0]); // First match (default)
});
it('sorts unknown remain to end', () => {
const reqs = {
filaments: [{ slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10 }],
};
const status = createPrinterStatus([
{
id: 0,
tray: [
{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }, // No remain (defaults to -1)
{ id: 1, tray_type: 'PLA', tray_color: 'FF0000', remain: 60 },
],
},
]);
const result = computeAmsMapping(reqs, status, true);
expect(result).toEqual([1]); // Known 60% over unknown
});
});
+1
View File
@@ -838,6 +838,7 @@ export interface AppSettings {
time_format: 'system' | '12h' | '24h';
// Filament tracking
disable_filament_warnings: boolean; // Disable filament warnings (print insufficiency and assignment mismatch)
prefer_lowest_filament: boolean; // When multiple spools match, prefer lowest remaining filament
// Default printer
default_printer_id: number | null;
// Dark mode theme settings
@@ -107,7 +107,8 @@ function InlineMappingEditor({
isManual = true;
} else {
const usedTrayIds = new Set<number>(Object.values(printerResult.config.manualMappings));
loaded = autoMatchFilament(req, printerResult.loadedFilaments, usedTrayIds) as LoadedFilament | undefined;
const cachedSettings = queryClient.getQueryData<{ prefer_lowest_filament?: boolean }>(['settings']);
loaded = autoMatchFilament(req, printerResult.loadedFilaments, usedTrayIds, cachedSettings?.prefer_lowest_filament) as LoadedFilament | undefined;
}
// Determine status
+3 -2
View File
@@ -324,7 +324,7 @@ export function PrintModal({
});
// Get AMS mapping from hook (only when single printer selected)
const { amsMapping } = useFilamentMapping(effectiveFilamentReqs, printerStatus, manualMappings);
const { amsMapping } = useFilamentMapping(effectiveFilamentReqs, printerStatus, manualMappings, settings?.prefer_lowest_filament);
// Multi-printer filament mapping (for per-printer configuration)
const multiPrinterMapping = useMultiPrinterFilamentMapping(
@@ -333,7 +333,8 @@ export function PrintModal({
effectiveFilamentReqs,
manualMappings,
perPrinterConfigs,
setPerPrinterConfigs
setPerPrinterConfigs,
settings?.prefer_lowest_filament,
);
// Auto-select first plate when plates load (single or multi-plate)
+41 -3
View File
@@ -37,6 +37,7 @@ export function buildLoadedFilaments(printerStatus: PrinterStatus | undefined):
trayInfoIdx: tray.tray_info_idx || '',
traySubBrands: tray.tray_sub_brands || '',
extruderId: amsExtruderMap?.[String(amsUnit.id)],
remain: tray.remain ?? -1,
});
}
});
@@ -61,6 +62,7 @@ export function buildLoadedFilaments(printerStatus: PrinterStatus | undefined):
trayInfoIdx: extTray.tray_info_idx || '',
traySubBrands: extTray.tray_sub_brands || '',
extruderId: hasDualNozzle ? (255 - trayId) : undefined,
remain: extTray.remain ?? -1,
});
}
}
@@ -86,7 +88,8 @@ export function buildLoadedFilaments(printerStatus: PrinterStatus | undefined):
*/
export function computeAmsMapping(
filamentReqs: { filaments: FilamentRequirement[] } | undefined,
printerStatus: PrinterStatus | undefined
printerStatus: PrinterStatus | undefined,
preferLowest?: boolean,
): number[] | undefined {
if (!filamentReqs?.filaments || filamentReqs.filaments.length === 0) return undefined;
@@ -109,6 +112,15 @@ export function computeAmsMapping(
available = available.filter((f) => f.extruderId === req.nozzle_id);
}
// Sort by remaining filament (ascending) so .find() picks the lowest-remain spool first
if (preferLowest) {
available = [...available].sort((a, b) => {
const ra = a.remain >= 0 ? a.remain : 101;
const rb = b.remain >= 0 ? b.remain : 101;
return ra - rb;
});
}
let idxMatch: LoadedFilament | undefined;
let exactMatch: LoadedFilament | undefined;
let similarMatch: LoadedFilament | undefined;
@@ -122,6 +134,13 @@ export function computeAmsMapping(
idxMatch = idxMatches[0];
} else if (idxMatches.length > 1) {
// Multiple trays with same tray_info_idx - use color matching among them
if (preferLowest) {
idxMatches.sort((a, b) => {
const ra = a.remain >= 0 ? a.remain : 101;
const rb = b.remain >= 0 ? b.remain : 101;
return ra - rb;
});
}
exactMatch = idxMatches.find(
(f) =>
f.type?.toUpperCase() === req.type?.toUpperCase() &&
@@ -212,6 +231,8 @@ export interface LoadedFilament {
traySubBrands?: string;
/** Extruder ID for dual-nozzle printers (0=right, 1=left) */
extruderId?: number;
/** Remaining filament percentage (0-100), -1 = unknown */
remain: number;
}
/**
@@ -285,7 +306,8 @@ export function useLoadedFilaments(
export function useFilamentMapping(
filamentReqs: FilamentRequirementsResponse | undefined,
printerStatus: PrinterStatus | undefined,
manualMappings: Record<number, number>
manualMappings: Record<number, number>,
preferLowest?: boolean,
): UseFilamentMappingResult {
const loadedFilaments = useLoadedFilaments(printerStatus);
@@ -345,6 +367,15 @@ export function useFilamentMapping(
available = available.filter((f) => f.extruderId === req.nozzle_id);
}
// Sort by remaining filament (ascending) so .find() picks the lowest-remain spool first
if (preferLowest) {
available = [...available].sort((a, b) => {
const ra = a.remain >= 0 ? a.remain : 101;
const rb = b.remain >= 0 ? b.remain : 101;
return ra - rb;
});
}
let idxMatch: LoadedFilament | undefined;
let exactMatch: LoadedFilament | undefined;
let similarMatch: LoadedFilament | undefined;
@@ -358,6 +389,13 @@ export function useFilamentMapping(
idxMatch = idxMatches[0];
} else if (idxMatches.length > 1) {
// Multiple trays with same tray_info_idx - use color matching among them
if (preferLowest) {
idxMatches.sort((a, b) => {
const ra = a.remain >= 0 ? a.remain : 101;
const rb = b.remain >= 0 ? b.remain : 101;
return ra - rb;
});
}
exactMatch = idxMatches.find(
(f) =>
f.type?.toUpperCase() === req.type?.toUpperCase() &&
@@ -431,7 +469,7 @@ export function useFilamentMapping(
isManual: false,
};
});
}, [filamentReqs, loadedFilaments, manualMappings]);
}, [filamentReqs, loadedFilaments, manualMappings, preferLowest]);
// Build AMS mapping from matched filaments
// Format: array matching 3MF filament slot structure
@@ -88,7 +88,8 @@ export interface UseMultiPrinterFilamentMappingResult {
function computeMatchDetails(
filamentReqs: FilamentRequirement[] | undefined,
loadedFilaments: LoadedFilament[],
manualMappings: Record<number, number>
manualMappings: Record<number, number>,
preferLowest?: boolean,
): { exactMatches: number; typeOnlyMatches: number; missingTypes: number; totalSlots: number; status: PrinterMatchStatus } {
if (!filamentReqs || filamentReqs.length === 0) {
return { exactMatches: 0, typeOnlyMatches: 0, missingTypes: 0, totalSlots: 0, status: 'full' };
@@ -133,6 +134,14 @@ function computeMatchDetails(
}
}
if (preferLowest) {
candidates = [...candidates].sort((a, b) => {
const ra = a.remain >= 0 ? a.remain : 101;
const rb = b.remain >= 0 ? b.remain : 101;
return ra - rb;
});
}
const exactMatch = candidates.find(
(f) =>
f.type?.toUpperCase() === req.type?.toUpperCase() &&
@@ -183,7 +192,8 @@ function computeMatchDetails(
function computeMappingWithOverrides(
filamentReqs: { filaments: FilamentRequirement[] } | undefined,
printerStatus: PrinterStatus | undefined,
manualMappings: Record<number, number>
manualMappings: Record<number, number>,
preferLowest?: boolean,
): number[] | undefined {
if (!filamentReqs?.filaments || filamentReqs.filaments.length === 0) return undefined;
@@ -211,6 +221,14 @@ function computeMappingWithOverrides(
}
}
if (preferLowest) {
candidates = [...candidates].sort((a, b) => {
const ra = a.remain >= 0 ? a.remain : 101;
const rb = b.remain >= 0 ? b.remain : 101;
return ra - rb;
});
}
const exactMatch = candidates.find(
(f) =>
f.type?.toUpperCase() === req.type?.toUpperCase() &&
@@ -270,7 +288,8 @@ export function useMultiPrinterFilamentMapping(
filamentReqs: { filaments: FilamentRequirement[] } | undefined,
defaultMappings: Record<number, number>,
perPrinterConfigs: Record<number, PerPrinterConfig>,
setPerPrinterConfigs: React.Dispatch<React.SetStateAction<Record<number, PerPrinterConfig>>>
setPerPrinterConfigs: React.Dispatch<React.SetStateAction<Record<number, PerPrinterConfig>>>,
preferLowest?: boolean,
): UseMultiPrinterFilamentMappingResult {
// Fetch printer status for all selected printers in parallel
const statusQueries = useQueries({
@@ -294,7 +313,7 @@ export function useMultiPrinterFilamentMapping(
const config = perPrinterConfigs[printerId] || DEFAULT_PRINTER_CONFIG;
// Compute auto mapping for this printer
const autoMapping = computeAmsMapping(filamentReqs, printerStatus);
const autoMapping = computeAmsMapping(filamentReqs, printerStatus, preferLowest);
// Determine which mappings to use:
// If printer has override (useDefault=false), use its custom mappings
@@ -304,13 +323,14 @@ export function useMultiPrinterFilamentMapping(
: defaultMappings;
// Compute final mapping with overrides
const finalMapping = computeMappingWithOverrides(filamentReqs, printerStatus, effectiveMappings);
const finalMapping = computeMappingWithOverrides(filamentReqs, printerStatus, effectiveMappings, preferLowest);
// Compute match details
const matchDetails = computeMatchDetails(
filamentReqs?.filaments,
loadedFilaments,
effectiveMappings
effectiveMappings,
preferLowest,
);
return {
@@ -329,7 +349,7 @@ export function useMultiPrinterFilamentMapping(
config,
};
});
}, [selectedPrinterIds, statusQueries, printers, filamentReqs, perPrinterConfigs, defaultMappings]);
}, [selectedPrinterIds, statusQueries, printers, filamentReqs, perPrinterConfigs, defaultMappings, preferLowest]);
const isLoading = statusQueries.some((q) => q.isLoading);
@@ -350,7 +370,7 @@ export function useMultiPrinterFilamentMapping(
if (!result || !result.status || !filamentReqs?.filaments) return;
// Compute optimal mapping for this printer
const autoMapping = computeAmsMapping(filamentReqs, result.status);
const autoMapping = computeAmsMapping(filamentReqs, result.status, preferLowest);
if (!autoMapping) return;
// Convert autoMapping array to manualMappings record
+2
View File
@@ -1419,6 +1419,8 @@ export default {
filamentChecks: 'Filament-Prüfungen',
disableFilamentWarnings: 'Filament-Warnungen deaktivieren',
disableFilamentWarningsDesc: 'Keine Warnungen über unzureichendes Filament beim Drucken oder Einreihen anzeigen',
preferLowestFilament: 'Niedrigsten Filamentrest bevorzugen',
preferLowestFilamentDesc: 'Bei mehreren passenden Spulen die mit dem geringsten Restfilament verwenden',
trackingModeBuiltIn: 'Integriertes Inventar',
trackingModeBuiltInDesc: 'RFID-Erkennung und Verbrauchserfassung inklusive',
trackingModeSpoolmanDesc: 'Externer Filament-Management-Server',
+2
View File
@@ -1420,6 +1420,8 @@ export default {
filamentChecks: 'Filament checks',
disableFilamentWarnings: 'Disable filament warnings',
disableFilamentWarningsDesc: 'Don\'t show warnings about insufficient filament when printing or queueing',
preferLowestFilament: 'Prefer lowest remaining filament',
preferLowestFilamentDesc: 'When multiple spools match, use the one with the least filament remaining',
trackingModeBuiltIn: 'Built-in Inventory',
trackingModeBuiltInDesc: 'RFID auto-matching and usage tracking included',
trackingModeSpoolmanDesc: 'External filament management server',
+2
View File
@@ -1419,6 +1419,8 @@ export default {
filamentChecks: 'Vérifications du filament',
disableFilamentWarnings: 'Désactiver les avertissements de filament',
disableFilamentWarningsDesc: 'Ne pas afficher les avertissements de filament insuffisant lors de l\'impression ou de la mise en file d\'attente',
preferLowestFilament: 'Préférer le filament le plus bas',
preferLowestFilamentDesc: 'Lorsque plusieurs bobines correspondent, utiliser celle avec le moins de filament restant',
trackingModeBuiltIn: 'Inventaire Intégré',
trackingModeBuiltInDesc: 'Correspondance RFID et suivi de consommation inclus',
trackingModeSpoolmanDesc: 'Serveur de gestion externe',
+2
View File
@@ -1419,6 +1419,8 @@ export default {
filamentChecks: 'Controlli filamento',
disableFilamentWarnings: 'Disabilita avvisi filamento',
disableFilamentWarningsDesc: 'Non mostrare avvisi per filamento insufficiente durante la stampa o l\'accodamento',
preferLowestFilament: 'Preferisci il filamento con meno residuo',
preferLowestFilamentDesc: 'Quando più bobine corrispondono, usa quella con meno filamento rimanente',
trackingModeBuiltIn: 'Inventario integrato',
trackingModeBuiltInDesc: 'Riconoscimento RFID automatico e tracciamento dell\'uso inclusi',
trackingModeSpoolmanDesc: 'Server esterno per la gestione del filamento',
+2
View File
@@ -1418,6 +1418,8 @@ export default {
filamentChecks: 'フィラメントチェック',
disableFilamentWarnings: 'フィラメント警告を無効化',
disableFilamentWarningsDesc: '印刷またはキュー追加時にフィラメント不足の警告を表示しない',
preferLowestFilament: '残量が少ないフィラメントを優先',
preferLowestFilamentDesc: '複数のスプールが一致する場合、残量が最も少ないものを使用します',
trackingModeBuiltIn: '内蔵インベントリ',
trackingModeBuiltInDesc: 'RFID自動検出と使用量追跡を含む',
trackingModeSpoolmanDesc: '外部フィラメント管理サーバー',
+2
View File
@@ -1419,6 +1419,8 @@ export default {
filamentChecks: 'Verificações de filamento',
disableFilamentWarnings: 'Desativar avisos de filamento',
disableFilamentWarningsDesc: 'Não mostrar avisos sobre filamento insuficiente ao imprimir ou adicionar à fila',
preferLowestFilament: 'Preferir filamento com menor resto',
preferLowestFilamentDesc: 'Quando vários carretéis correspondem, usar o com menos filamento restante',
trackingModeBuiltIn: 'Inventário Interno',
trackingModeBuiltInDesc: 'Correspondência automática de RFID e rastreamento de uso incluídos',
trackingModeSpoolmanDesc: 'Servidor de gerenciamento de filamento externo',
+2
View File
@@ -1419,6 +1419,8 @@ export default {
filamentChecks: '耗材检查',
disableFilamentWarnings: '禁用耗材警告',
disableFilamentWarningsDesc: '在打印或加入队列时不显示耗材不足警告',
preferLowestFilament: '优先使用剩余最少的耗材',
preferLowestFilamentDesc: '当多个料盘匹配时,使用剩余耗材最少的那个',
trackingModeBuiltIn: '内置库存',
trackingModeBuiltInDesc: '包含 RFID 自动匹配和用量追踪',
trackingModeSpoolmanDesc: '外部耗材管理服务器',
+19
View File
@@ -741,6 +741,7 @@ export function SettingsPage() {
settings.ams_temp_fair !== localSettings.ams_temp_fair ||
settings.ams_history_retention_days !== localSettings.ams_history_retention_days ||
settings.disable_filament_warnings !== localSettings.disable_filament_warnings ||
settings.prefer_lowest_filament !== localSettings.prefer_lowest_filament ||
(settings.queue_drying_enabled ?? false) !== (localSettings.queue_drying_enabled ?? false) ||
(settings.queue_drying_block ?? false) !== (localSettings.queue_drying_block ?? false) ||
(settings.ambient_drying_enabled ?? false) !== (localSettings.ambient_drying_enabled ?? false) ||
@@ -816,6 +817,7 @@ export function SettingsPage() {
ams_temp_fair: localSettings.ams_temp_fair,
ams_history_retention_days: localSettings.ams_history_retention_days,
disable_filament_warnings: localSettings.disable_filament_warnings,
prefer_lowest_filament: localSettings.prefer_lowest_filament,
queue_drying_enabled: localSettings.queue_drying_enabled,
queue_drying_block: localSettings.queue_drying_block,
ambient_drying_enabled: localSettings.ambient_drying_enabled,
@@ -3610,6 +3612,23 @@ export function SettingsPage() {
<div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
</label>
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-white">{t('settings.preferLowestFilament')}</p>
<p className="text-sm text-bambu-gray">
{t('settings.preferLowestFilamentDesc')}
</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={localSettings.prefer_lowest_filament}
onChange={(e) => updateSetting('prefer_lowest_filament', e.target.checked)}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
</label>
</div>
</CardContent>
</Card>
+11 -2
View File
@@ -203,10 +203,19 @@ export function isPlaceholderDate(scheduledTime: string | null | undefined): boo
*/
export function autoMatchFilament(
req: { type?: string; color?: string; nozzle_id?: number | null },
loadedFilaments: { globalTrayId: number; type?: string; color?: string; extruderId?: number }[],
loadedFilaments: { globalTrayId: number; type?: string; color?: string; extruderId?: number; remain?: number }[],
usedTrayIds: Set<number>,
preferLowest?: boolean,
): typeof loadedFilaments[number] | undefined {
const nozzleFilaments = filterFilamentsByNozzle(loadedFilaments, req.nozzle_id);
let nozzleFilaments = filterFilamentsByNozzle(loadedFilaments, req.nozzle_id);
if (preferLowest) {
nozzleFilaments = [...nozzleFilaments].sort((a, b) => {
const ra = (a.remain ?? -1) >= 0 ? (a.remain ?? -1) : 101;
const rb = (b.remain ?? -1) >= 0 ? (b.remain ?? -1) : 101;
return ra - rb;
});
}
const exactMatch = nozzleFilaments.find(
(f) =>