Feature: Force Color Match (#625)

* Added "Force Color Match" feature to filament override

* Update settings.py

* Update settings.py

* Label updates for warnings and checkbox case

* Initial plan

* Add noMatchingMaterial i18n translations to all 7 locale files

Co-authored-by: cadtoolbox <12723486+cadtoolbox@users.noreply.github.com>

* Use {{material}} interpolation in noMatchingMaterial; populate from filament_overrides with getColorName

Co-authored-by: cadtoolbox <12723486+cadtoolbox@users.noreply.github.com>

* Updates for PR#625 comments

* Added "Force Color Match" feature to filament override

* Update settings.py

* Update settings.py

* Label updates for warnings and checkbox case

* Initial plan

* Add noMatchingMaterial i18n translations to all 7 locale files

Co-authored-by: cadtoolbox <12723486+cadtoolbox@users.noreply.github.com>

* Use {{material}} interpolation in noMatchingMaterial; populate from filament_overrides with getColorName

Co-authored-by: cadtoolbox <12723486+cadtoolbox@users.noreply.github.com>

* Updates to PrintersPage and QueuePage

* Update QueuePage.tsx

* Rebase to 0.2.2b3

* Fix compile errors with FilamentOverride

* Updates for PR#625 comments

https://github.com/maziggy/bambuddy/pull/625#pullrequestreview-3908738261

* Fix force color match default, loadedFilamentTypes bug, remove dead translations

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: cadtoolbox <12723486+cadtoolbox@users.noreply.github.com>
Co-authored-by: MartinNYHC <mz@v8w.de>
This commit is contained in:
Thomas Rambach
2026-03-09 11:54:02 +01:00
committed by GitHub
co-authored by cadtoolbox copilot-swe-agent[bot] MartinNYHC
parent 7b74605ae4
commit d7401a80df
17 changed files with 747 additions and 547 deletions
@@ -1,6 +1,6 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Circle, RotateCcw } from 'lucide-react';
import { Circle, RotateCcw, Palette } from 'lucide-react';
import { getColorName } from '../../utils/colors';
import type { FilamentReqsData } from './types';
@@ -9,6 +9,11 @@ interface FilamentOverrideProps {
availableFilaments: Array<{ type: string; color: string; tray_info_idx: string; tray_sub_brands: string; extruder_id: number | null }>;
overrides: Record<number, { type: string; color: string }>;
onChange: (overrides: Record<number, { type: string; color: string }>) => void;
/** Per-slot force color match flags. Defaults to false (opt-in) when not provided. */
forceColorMatch?: Record<number, boolean>;
/** Called when a slot's force color match checkbox is toggled. */
onForceColorMatchChange?: (slotId: number, value: boolean) => void;
}
/**
@@ -21,6 +26,8 @@ export function FilamentOverride({
availableFilaments,
overrides,
onChange,
forceColorMatch,
onForceColorMatchChange,
}: FilamentOverrideProps) {
const { t } = useTranslation();
@@ -73,58 +80,70 @@ export function FilamentOverride({
: 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'
}`}
<div key={req.slot_id} className="space-y-1">
<div
className="grid items-center gap-2 text-xs"
style={{ gridTemplateColumns: '16px minmax(70px, 1fr) auto 2fr 20px' }}
>
<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}-${f.tray_sub_brands}-${idx}`}
value={`${f.type}|${f.color}`}
className="bg-bambu-dark text-white"
>
{f.tray_sub_brands || 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')}
{/* 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'
}`}
>
<RotateCcw className="w-3 h-3" />
</button>
) : (
<span className="w-3" />
)}
<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}-${f.tray_sub_brands}-${idx}`}
value={`${f.type}|${f.color}`}
className="bg-bambu-dark text-white"
>
{f.tray_sub_brands || 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>
{/* Force Color Match checkbox — shown below each filament row */}
<label className="flex items-center gap-1.5 text-xs text-bambu-gray cursor-pointer select-none pl-5">
<input
type="checkbox"
checked={forceColorMatch?.[req.slot_id] ?? false}
onChange={(e) => onForceColorMatchChange?.(req.slot_id, e.target.checked)}
className="accent-bambu-green w-3 h-3"
/>
<Palette className="w-3 h-3" />
{t('printModal.forceColorMatch')}
</label>
</div>
);
})}
+50 -8
View File
@@ -8,6 +8,7 @@ import { useAuth } from '../../contexts/AuthContext';
import { useToast } from '../../contexts/ToastContext';
import { useFilamentMapping } from '../../hooks/useFilamentMapping';
import { useMultiPrinterFilamentMapping, type PerPrinterConfig } from '../../hooks/useMultiPrinterFilamentMapping';
import { getColorName } from '../../utils/colors';
import { isPlaceholderDate } from '../../utils/amsHelpers';
import { getCurrencySymbol } from '../../utils/currency';
import { toDateTimeLocalValue, parseUTCDate } from '../../utils/date';
@@ -170,6 +171,18 @@ export function PrintModal({
return {};
});
// Per-slot force color match flags. Default is false (opt-in).
const [forceColorMatch, setForceColorMatch] = useState<Record<number, boolean>>(() => {
if (mode === 'edit-queue-item' && queueItem?.filament_overrides) {
const flags: Record<number, boolean> = {};
for (const o of queueItem.filament_overrides) {
flags[o.slot_id] = o.force_color_match === true;
}
return flags;
}
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));
@@ -334,6 +347,7 @@ export function PrintModal({
// Don't clear on initial render in edit mode (values are initialized from queueItem)
if (mode !== 'edit-queue-item' || prevTargetModel !== null) {
setFilamentOverrides({});
setForceColorMatch({});
}
}
}, [targetModel, selectedPlate, prevTargetModel, prevPlateForOverrides, mode]);
@@ -440,14 +454,38 @@ 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;
// Convert filament overrides from Record to array format for API.
// Include all slots that either have a user override or have force_color_match enabled
// (which is the default for model-based assignment).
const buildFilamentOverridesArray = () => {
const entries: Array<{ slot_id: number; type: string; color: string; color_name: string; force_color_match: boolean }> = [];
// Process all slots from filament requirements (to capture force_color_match defaults)
if (effectiveFilamentReqs?.filaments) {
for (const req of effectiveFilamentReqs.filaments) {
const userOverride = filamentOverrides[req.slot_id];
const isForceColor = forceColorMatch[req.slot_id] ?? false;
const effectiveType = userOverride?.type ?? req.type;
const effectiveColor = userOverride?.color ?? req.color;
// Include slot if user changed the filament OR force_color_match is enabled
if (userOverride || isForceColor) {
entries.push({ slot_id: req.slot_id, type: effectiveType, color: effectiveColor, color_name: getColorName(effectiveColor), force_color_match: isForceColor });
}
}
} else {
// Fallback: no filament requirements data — only include explicit user overrides
for (const [slotId, { type, color }] of Object.entries(filamentOverrides)) {
const id = parseInt(slotId, 10);
const isForceColor = forceColorMatch[id] ?? false;
entries.push({ slot_id: id, type, color, color_name: getColorName(color), force_color_match: isForceColor });
}
}
return entries.length > 0 ? entries : undefined;
};
const filamentOverridesArray = buildFilamentOverridesArray();
// Common queue data for add-to-queue and edit modes
const getQueueData = (printerId: number | null, plateOverride?: number | null): PrintQueueItemCreate => ({
@@ -759,6 +797,10 @@ export function PrintModal({
availableFilaments={availableFilaments}
overrides={filamentOverrides}
onChange={setFilamentOverrides}
forceColorMatch={forceColorMatch}
onForceColorMatchChange={(slotId, value) =>
setForceColorMatch((prev) => ({ ...prev, [slotId]: value }))
}
/>
)}