mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-20 00:38:38 +02:00
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:
@@ -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]);
|
||||
|
||||
Reference in New Issue
Block a user