Add filament override for model-based queue assignment (#486)

When scheduling a print to "any printer" (model-based assignment),
users can now override the 3MF's original filament choices per slot.
The override dropdown shows compatible filaments loaded across all
printers of the selected model, filtered by type and nozzle (H2D).
The scheduler matches against overridden type/color instead of the
original 3MF values, preferring printers with exact color matches.

Backend:
- New GET /printers/available-filaments endpoint (aggregates loaded
  filaments across printers of a model, includes extruder_id for
  dual-nozzle nozzle-aware filtering)
- New filament_overrides JSON column on print_queue table
- Scheduler applies overrides before AMS mapping, clears tray_info_idx
  on overridden slots so color matching takes priority
- Printer selection prefers printers with most override color matches

Frontend:
- New FilamentOverride component with per-slot override dropdowns
- Type-filtered options (PLA slots only show PLA)
- Nozzle-aware filtering (H2D: only shows filaments on correct extruder)
- Integrated into PrintModal for model-based queue mode

Tests:
- 11 backend unit tests (color match counting, override application)
- 12 frontend tests (rendering, type/nozzle filtering, interactions)

i18n: All 6 locales (en, de, fr, it, ja, pt-BR)
This commit is contained in:
maziggy
2026-02-22 16:43:16 +01:00
parent 7a1b22cff0
commit 38af5df050
23 changed files with 1077 additions and 114 deletions
@@ -0,0 +1,134 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Circle, RotateCcw } from 'lucide-react';
import { getColorName } from '../../utils/colors';
import type { FilamentReqsData } from './types';
interface FilamentOverrideProps {
filamentReqs: FilamentReqsData | undefined;
availableFilaments: Array<{ type: string; color: string; tray_info_idx: string; extruder_id: number | null }>;
overrides: Record<number, { type: string; color: string }>;
onChange: (overrides: Record<number, { type: string; color: string }>) => void;
}
/**
* Filament override UI for model-based queue assignment.
* Allows users to override the 3MF's original filament choices with
* filaments available across printers of the selected model.
*/
export function FilamentOverride({
filamentReqs,
availableFilaments,
overrides,
onChange,
}: FilamentOverrideProps) {
const { t } = useTranslation();
// Index available filaments by type (uppercased) for per-slot filtering
const filamentsByType = useMemo(() => {
const map: Record<string, Array<{ type: string; color: string; tray_info_idx: string; extruder_id: number | null }>> = {};
for (const f of availableFilaments) {
const key = f.type.toUpperCase();
if (!map[key]) map[key] = [];
map[key].push(f);
}
return map;
}, [availableFilaments]);
const filaments = filamentReqs?.filaments;
if (!filaments || filaments.length === 0 || availableFilaments.length === 0) {
return null;
}
const handleChange = (slotId: number, value: string) => {
if (value === '') {
// Reset to original
const next = { ...overrides };
delete next[slotId];
onChange(next);
} else {
// Parse "TYPE|COLOR" value
const [type, color] = value.split('|');
onChange({ ...overrides, [slotId]: { type, color } });
}
};
return (
<div className="mb-4">
<div className="flex items-center gap-2 text-sm text-bambu-gray mb-2">
<span>{t('printModal.filamentOverride')}</span>
</div>
<p className="text-xs text-bambu-gray mb-2">{t('printModal.filamentOverrideHint')}</p>
<div className="bg-bambu-dark rounded-lg p-3 space-y-2">
{filaments.map((req) => {
const override = overrides[req.slot_id];
const isOverridden = !!override;
// Only show filaments of the same type AND compatible nozzle/extruder
const sameType = filamentsByType[req.type.toUpperCase()] || [];
// On dual-nozzle printers (H2D), filter to filaments on the correct extruder.
// nozzle_id from 3MF maps to extruder_id from AMS. If nozzle_id is undefined
// (single-nozzle) or extruder_id is null, no nozzle filtering is needed.
const compatible = req.nozzle_id != null
? sameType.filter((f) => f.extruder_id == null || f.extruder_id === req.nozzle_id)
: sameType;
return (
<div
key={req.slot_id}
className="grid items-center gap-2 text-xs"
style={{ gridTemplateColumns: '16px minmax(70px, 1fr) auto 2fr 20px' }}
>
{/* Original color swatch */}
<span title={`${t('printModal.originalFilament')}: ${req.type} - ${getColorName(req.color)}`}>
<Circle className="w-3 h-3" fill={req.color} stroke={req.color} />
</span>
{/* Original type + grams */}
<span className="text-white truncate">
{req.type} <span className="text-bambu-gray">({req.used_grams}g)</span>
</span>
{/* Arrow */}
<span className="text-bambu-gray">→</span>
{/* Override dropdown — only compatible (same-type) filaments */}
<select
value={isOverridden ? `${override.type}|${override.color}` : ''}
onChange={(e) => handleChange(req.slot_id, e.target.value)}
disabled={compatible.length === 0}
className={`flex-1 px-2 py-1 rounded border text-xs bg-bambu-dark-secondary focus:outline-none focus:ring-1 focus:ring-bambu-green ${
isOverridden
? 'border-blue-400/50 text-blue-400'
: 'border-bambu-gray/30 text-bambu-gray'
}`}
>
<option value="" className="bg-bambu-dark text-bambu-gray">
{t('printModal.originalFilament')}: {req.type} ({getColorName(req.color)})
</option>
{compatible.map((f, idx) => (
<option
key={`${f.type}-${f.color}-${idx}`}
value={`${f.type}|${f.color}`}
className="bg-bambu-dark text-white"
>
{f.type} ({getColorName(f.color)})
</option>
))}
</select>
{/* Reset button */}
{isOverridden ? (
<button
type="button"
onClick={() => handleChange(req.slot_id, '')}
className="text-bambu-gray hover:text-white transition-colors"
title={t('printModal.resetToOriginal')}
>
<RotateCcw className="w-3 h-3" />
</button>
) : (
<span className="w-3" />
)}
</div>
);
})}
</div>
</div>
);
}
@@ -13,6 +13,7 @@ import { toDateTimeLocalValue } from '../../utils/date';
import { Button } from '../Button';
import { Card, CardContent } from '../Card';
import { FilamentMapping } from './FilamentMapping';
import { FilamentOverride } from './FilamentOverride';
import { PlateSelector } from './PlateSelector';
import { PrinterSelector } from './PrinterSelector';
import { PrintOptionsPanel } from './PrintOptions';
@@ -148,6 +149,18 @@ export function PrintModal({
return null;
});
// Filament overrides for model-based assignment: slot_id -> {type, color}
const [filamentOverrides, setFilamentOverrides] = useState<Record<number, { type: string; color: string }>>(() => {
if (mode === 'edit-queue-item' && queueItem?.filament_overrides) {
const overrides: Record<number, { type: string; color: string }> = {};
for (const o of queueItem.filament_overrides) {
overrides[o.slot_id] = { type: o.type, color: o.color };
}
return overrides;
}
return {};
});
// Track initial values for clearing mappings on change (edit mode only)
const [initialPrinterIds] = useState(() => (mode === 'edit-queue-item' && queueItem?.printer_id ? [queueItem.printer_id] : []));
const [initialPlateId] = useState(() => (mode === 'edit-queue-item' && queueItem ? queueItem.plate_id : null));
@@ -241,6 +254,13 @@ export function PrintModal({
return platesData.plates.find((plate) => plate.index === selectedPlate)?.name || undefined;
}, [platesData, selectedPlate]);
// Fetch available filaments for model-based assignment (for filament override UI)
const { data: availableFilaments } = useQuery({
queryKey: ['available-filaments', targetModel, targetLocation],
queryFn: () => api.getAvailableFilaments(targetModel!, targetLocation ?? undefined),
enabled: assignmentMode === 'model' && !!targetModel,
});
// Only fetch printer status when single printer selected (for filament mapping)
const { data: printerStatus } = useQuery({
queryKey: ['printer-status', effectivePrinterId],
@@ -295,6 +315,20 @@ export function PrintModal({
}
}, [mode, selectedPrinters, selectedPlate, initialPrinterIds, initialPlateId]);
// Clear filament overrides when target model or plate changes (but not on initial mount for edit mode)
const [prevTargetModel, setPrevTargetModel] = useState(targetModel);
const [prevPlateForOverrides, setPrevPlateForOverrides] = useState(selectedPlate);
useEffect(() => {
if (targetModel !== prevTargetModel || selectedPlate !== prevPlateForOverrides) {
setPrevTargetModel(targetModel);
setPrevPlateForOverrides(selectedPlate);
// Don't clear on initial render in edit mode (values are initialized from queueItem)
if (mode !== 'edit-queue-item' || prevTargetModel !== null) {
setFilamentOverrides({});
}
}
}, [targetModel, selectedPlate, prevTargetModel, prevPlateForOverrides, mode]);
// 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
useEffect(() => {
@@ -394,11 +428,21 @@ export function PrintModal({
return amsMapping;
};
// Convert filament overrides from Record to array format for API
const filamentOverridesArray = Object.keys(filamentOverrides).length > 0
? Object.entries(filamentOverrides).map(([slotId, { type, color }]) => ({
slot_id: parseInt(slotId, 10),
type,
color,
}))
: undefined;
// Common queue data for add-to-queue and edit modes
const getQueueData = (printerId: number | null): PrintQueueItemCreate => ({
printer_id: assignmentMode === 'printer' ? printerId : null,
target_model: assignmentMode === 'model' ? targetModel : null,
target_location: assignmentMode === 'model' ? targetLocation : null,
filament_overrides: assignmentMode === 'model' ? filamentOverridesArray : undefined,
// Use library_file_id for library files, archive_id for archives
archive_id: isLibraryFile ? undefined : archiveId,
library_file_id: isLibraryFile ? libraryFileId : undefined,
@@ -428,6 +472,7 @@ export function PrintModal({
printer_id: null,
target_model: targetModel,
target_location: targetLocation,
filament_overrides: filamentOverridesArray || null,
require_previous_success: scheduleOptions.requirePreviousSuccess,
auto_off_after: scheduleOptions.autoOffAfter,
manual_start: scheduleOptions.scheduleType === 'manual',
@@ -669,6 +714,16 @@ export function PrintModal({
slicedForModel={slicedForModel}
/>
{/* Filament override - shown in model mode when filament requirements are available */}
{assignmentMode === 'model' && targetModel && effectiveFilamentReqs && availableFilaments && availableFilaments.length > 0 && (
<FilamentOverride
filamentReqs={effectiveFilamentReqs}
availableFilaments={availableFilaments}
overrides={filamentOverrides}
onChange={setFilamentOverrides}
/>
)}
{/* Compatibility warning when sliced model doesn't match selected printer */}
{slicedForModel && assignmentMode === 'printer' && selectedPrinters.length === 1 && (() => {
const selectedPrinter = printers?.find(p => p.id === selectedPrinters[0]);