Add spool inventory: AMS slot assignment, usage tracking, and remaining weight editing

Built-in spool inventory with AMS slot assignment for non-BL spools,
automatic filament usage tracking via 3MF estimates, and remaining
weight editing in the spool form.

Inventory features:
- AMS slot assignment: assign/unassign manual spools to AMS tray slots
- Filter out Bambu Lab spools (RFID-managed) from assignment modal
- Filter out already-assigned spools (one spool per slot)
- Auto-unlink manual assignments when a BL spool is inserted
- Hide assign/unassign UI on BL-occupied slots
- Case-insensitive fingerprint matching for auto-unlink logic

Usage tracking:
- 3MF-based filament consumption tracking for non-BL spools
- Extract per-filament used_g from archived 3MF slice_info
- Map 3MF slot_id to AMS (ams_id, tray_id) positions
- Scale estimates by print progress for failed/aborted prints
- Skip BL spools (tracked via AMS remain% delta, Path 1)
- Prevent double-counting with handled_trays set

Other:
- Add remaining weight field to spool edit form
- Fix hardcoded "Empty" string in AssignSpoolModal (now i18n)
- Add backend unit tests for usage_tracker (11 tests)
- Add frontend tests for AssignSpoolModal filters (5 tests)
- Update CHANGELOG, README, wiki docs, and website
This commit is contained in:
maziggy
2026-02-12 15:42:27 +01:00
parent 1f0931e00c
commit e6e62f2a68
29 changed files with 10404 additions and 9316 deletions
+34 -6
View File
@@ -3,7 +3,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { X, Loader2, Package, Check, Search } from 'lucide-react';
import { api } from '../api/client';
import type { InventorySpool } from '../api/client';
import type { InventorySpool, SpoolAssignment } from '../api/client';
import { Button } from './Button';
import { useToast } from '../contexts/ToastContext';
@@ -33,12 +33,25 @@ export function AssignSpoolModal({ isOpen, onClose, printerId, amsId, trayId, tr
enabled: isOpen,
});
const { data: assignments } = useQuery({
queryKey: ['spool-assignments'],
queryFn: () => api.getAssignments(),
enabled: isOpen,
});
const assignMutation = useMutation({
mutationFn: (spoolId: number) =>
api.assignSpool({ spool_id: spoolId, printer_id: printerId, ams_id: amsId, tray_id: trayId }),
onSuccess: () => {
onSuccess: (newAssignment) => {
// Immediately update cache so UI reflects the new assignment without waiting for refetch
queryClient.setQueryData<SpoolAssignment[]>(['spool-assignments'], (old) => {
const filtered = (old || []).filter(a =>
!(a.printer_id === printerId && a.ams_id === amsId && a.tray_id === trayId)
);
filtered.push(newAssignment);
return filtered;
});
queryClient.invalidateQueries({ queryKey: ['spool-assignments'] });
queryClient.invalidateQueries({ queryKey: ['printer-status'] });
showToast(t('inventory.assignSuccess'), 'success');
onClose();
},
@@ -49,7 +62,18 @@ export function AssignSpoolModal({ isOpen, onClose, printerId, amsId, trayId, tr
if (!isOpen) return null;
const filteredSpools = spools?.filter((spool: InventorySpool) => {
// Filter out Bambu Lab spools (identified by RFID tag_uid or tray_uuid)
// and spools already assigned to other slots
const assignedSpoolIds = new Set(
(assignments || [])
.filter(a => !(a.printer_id === printerId && a.ams_id === amsId && a.tray_id === trayId))
.map(a => a.spool_id)
);
const manualSpools = spools?.filter((spool: InventorySpool) =>
!spool.tag_uid && !spool.tray_uuid && !assignedSpoolIds.has(spool.id)
);
const filteredSpools = manualSpools?.filter((spool: InventorySpool) => {
if (!searchFilter) return true;
const q = searchFilter.toLowerCase();
return (
@@ -101,7 +125,7 @@ export function AssignSpoolModal({ isOpen, onClose, printerId, amsId, trayId, tr
style={{ backgroundColor: `#${trayInfo.color}` }}
/>
)}
<span className="text-white font-medium">{trayInfo.type || 'Empty'}</span>
<span className="text-white font-medium">{trayInfo.type || t('ams.emptySlot')}</span>
<span className="text-bambu-gray">({trayInfo.location})</span>
</div>
</div>
@@ -161,9 +185,13 @@ export function AssignSpoolModal({ isOpen, onClose, printerId, amsId, trayId, tr
</button>
))}
</div>
) : manualSpools && manualSpools.length === 0 ? (
<div className="text-center py-8 text-bambu-gray">
<p>{t('inventory.noManualSpools')}</p>
</div>
) : (
<div className="text-center py-8 text-bambu-gray">
<p>{t('inventory.noSpools')}</p>
<p>{t('inventory.noSpoolsMatch')}</p>
</div>
)}
</div>
@@ -442,9 +442,9 @@ export function ColorCatalogSettings() {
<tr>
<th className="px-3 py-2 text-left text-bambu-gray font-medium w-12"></th>
<th className="px-3 py-2 text-left text-bambu-gray font-medium">{t('settings.colorCatalog.manufacturer')}</th>
<th className="px-3 py-2 text-left text-bambu-gray font-medium">{t('inventory.material')}</th>
<th className="px-3 py-2 text-left text-bambu-gray font-medium">{t('settings.colorCatalog.colorName')}</th>
<th className="px-3 py-2 text-left text-bambu-gray font-medium w-24">{t('settings.colorCatalog.hex')}</th>
<th className="px-3 py-2 text-left text-bambu-gray font-medium">{t('inventory.material')}</th>
<th className="px-3 py-2 w-16"></th>
</tr>
</thead>
@@ -476,6 +476,14 @@ export function ColorCatalogSettings() {
onChange={(e) => setFormManufacturer(e.target.value)}
/>
</td>
<td className="px-3 py-2">
<input
type="text"
className="w-full px-2 py-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-sm focus:border-bambu-green focus:outline-none"
value={formMaterial}
onChange={(e) => setFormMaterial(e.target.value)}
/>
</td>
<td className="px-3 py-2">
<input
type="text"
@@ -492,14 +500,6 @@ export function ColorCatalogSettings() {
onChange={(e) => setFormHexColor(e.target.value)}
/>
</td>
<td className="px-3 py-2">
<input
type="text"
className="w-full px-2 py-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-sm focus:border-bambu-green focus:outline-none"
value={formMaterial}
onChange={(e) => setFormMaterial(e.target.value)}
/>
</td>
<td className="px-3 py-2">
<div className="flex justify-end gap-1">
<button
@@ -525,9 +525,9 @@ export function ColorCatalogSettings() {
/>
</td>
<td className="px-3 py-2 text-white">{entry.manufacturer}</td>
<td className="px-3 py-2 text-bambu-gray">{entry.material || '-'}</td>
<td className="px-3 py-2 text-white">{entry.color_name}</td>
<td className="px-3 py-2 font-mono text-xs text-bambu-gray">{entry.hex_color}</td>
<td className="px-3 py-2 text-bambu-gray">{entry.material || '-'}</td>
<td className="px-3 py-2">
<div className="flex justify-end gap-1">
<button
@@ -329,8 +329,8 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
</div>
)}
{/* Inventory section - assign/unassign for non-Bambu spools */}
{inventory && (data.vendor !== 'Bambu Lab' || !data.trayUuid) && (
{/* Inventory section - only for non-Bambu spools */}
{inventory && data.vendor !== 'Bambu Lab' && (
<div className="pt-2 mt-2 border-t border-bambu-dark-tertiary space-y-2">
{inventory.assignedSpool ? (
<>
+30 -10
View File
@@ -3,7 +3,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { X, Loader2, Save, Beaker, Palette } from 'lucide-react';
import { api } from '../api/client';
import type { InventorySpool, SlicerSetting, SpoolCatalogEntry } from '../api/client';
import type { InventorySpool, SlicerSetting, SpoolCatalogEntry, LocalPreset } from '../api/client';
import { Button } from './Button';
import { useToast } from '../contexts/ToastContext';
import type { SpoolFormData, PrinterWithCalibrations, ColorPreset } from './spool-form/types';
@@ -45,6 +45,12 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration
// Spool catalog
const [spoolCatalog, setSpoolCatalog] = useState<SpoolCatalogEntry[]>([]);
// Local presets (OrcaSlicer imports)
const [localPresets, setLocalPresets] = useState<LocalPreset[]>([]);
// Color catalog
const [colorCatalog, setColorCatalog] = useState<{ manufacturer: string; color_name: string; hex_color: string; material: string | null }[]>([]);
// Color state
const [recentColors, setRecentColors] = useState<ColorPreset[]>([]);
@@ -89,6 +95,8 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration
};
fetchData();
api.getSpoolCatalog().then(setSpoolCatalog).catch(console.error);
api.getColorCatalog().then(setColorCatalog).catch(console.error);
api.getLocalPresets().then(r => setLocalPresets(r.filament)).catch(console.error);
// Fetch printer calibrations if not provided via props
if (printersWithCalibrations.length === 0) {
@@ -130,18 +138,18 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration
})();
}
}
}, [isOpen]);
}, [isOpen, printersWithCalibrations.length]);
// Build filament options from cloud presets
// Build filament options: cloud → local → fallback
const filamentOptions = useMemo(
() => buildFilamentOptions(cloudPresets, new Set()),
[cloudPresets],
() => buildFilamentOptions(cloudPresets, new Set(), localPresets),
[cloudPresets, localPresets],
);
// Extract brands from presets
const availableBrands = useMemo(
() => extractBrandsFromPresets(cloudPresets),
[cloudPresets],
() => extractBrandsFromPresets(cloudPresets, localPresets),
[cloudPresets, localPresets],
);
// Find selected preset option
@@ -162,6 +170,7 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration
rgba: spool.rgba || '808080FF',
label_weight: spool.label_weight || 1000,
core_weight: spool.core_weight || 250,
weight_used: spool.weight_used || 0,
slicer_filament: spool.slicer_filament || '',
note: spool.note || '',
});
@@ -214,11 +223,11 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration
mutationFn: (data: Record<string, unknown>) =>
api.createSpool(data as Parameters<typeof api.createSpool>[0]),
onSuccess: async (newSpool) => {
queryClient.invalidateQueries({ queryKey: ['inventory-spools'] });
// Save K-profiles if any selected
if (selectedProfiles.size > 0 && newSpool?.id) {
await saveKProfiles(newSpool.id);
}
await queryClient.invalidateQueries({ queryKey: ['inventory-spools'] });
showToast(t('inventory.spoolCreated'), 'success');
onClose();
},
@@ -231,11 +240,11 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration
mutationFn: (data: Record<string, unknown>) =>
api.updateSpool(spool!.id, data as Parameters<typeof api.updateSpool>[1]),
onSuccess: async () => {
queryClient.invalidateQueries({ queryKey: ['inventory-spools'] });
// Save K-profiles
if (spool?.id) {
await saveKProfiles(spool.id);
}
await queryClient.invalidateQueries({ queryKey: ['inventory-spools'] });
showToast(t('inventory.spoolUpdated'), 'success');
onClose();
},
@@ -290,6 +299,16 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration
}
};
// Close on Escape key
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isOpen, onClose]);
if (!isOpen) return null;
const handleSubmit = () => {
@@ -314,7 +333,7 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration
rgba: formData.rgba || null,
label_weight: formData.label_weight,
core_weight: formData.core_weight,
weight_used: spool?.weight_used ?? 0,
weight_used: formData.weight_used,
slicer_filament: formData.slicer_filament || null,
slicer_filament_name: presetName,
nozzle_temp_min: null,
@@ -421,6 +440,7 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration
updateField={updateField}
recentColors={recentColors}
onColorUsed={handleColorUsed}
catalogColors={colorCatalog}
/>
</div>
@@ -139,6 +139,28 @@ export function AdditionalSection({
onChange={(weight) => updateField('core_weight', weight)}
/>
{/* Current Weight (remaining filament) */}
<div>
<label className="block text-sm font-medium text-bambu-gray mb-1">{t('inventory.currentWeight')}</label>
<div className="flex items-center gap-2">
<div className="relative flex-1">
<input
type="number"
value={Math.max(0, formData.label_weight - formData.weight_used)}
min={0}
max={formData.label_weight}
onChange={(e) => {
const remaining = parseInt(e.target.value) || 0;
updateField('weight_used', Math.max(0, formData.label_weight - remaining));
}}
className="w-full px-3 py-2 pr-7 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green"
/>
<span className="absolute right-2 top-1/2 -translate-y-1/2 text-xs text-bambu-gray">g</span>
</div>
<span className="text-xs text-bambu-gray shrink-0">/ {formData.label_weight}g</span>
</div>
</div>
{/* Note */}
<div>
<label className="block text-sm font-medium text-bambu-gray mb-1">{t('inventory.note')}</label>
@@ -9,6 +9,7 @@ export function ColorSection({
updateField,
recentColors,
onColorUsed,
catalogColors,
}: ColorSectionProps) {
const { t } = useTranslation();
const [showAllColors, setShowAllColors] = useState(false);
@@ -28,8 +29,87 @@ export function ColorSection({
onColorUsed({ name, hex });
};
// Colors to show based on search/expand state
const filteredColors = useMemo(() => {
// Filter catalog colors by the selected brand + material + subtype
// Brand matching is word-based: "mz - Bambu" matches "Bambu Lab" because both contain "Bambu"
// Material matching: try exact "PETG Basic" first, fall back to base material "PETG" prefix
const matchedCatalogColors = useMemo(() => {
if (catalogColors.length === 0) return [];
const brand = formData.brand?.trim();
const material = formData.material?.toLowerCase().trim();
const subtype = formData.subtype?.toLowerCase().trim();
if (!brand && !material) return [];
// Split brand into words (>= 2 chars) for word-based matching
const brandWords = brand
? brand.toLowerCase().split(/[\s\-_]+/).filter(w => w.length >= 2)
: [];
const brandMatches = (manufacturer: string) => {
if (brandWords.length === 0) return true; // no brand filter
const mfrLower = manufacturer.toLowerCase();
// Any significant brand word found in manufacturer name
return brandWords.some(w => mfrLower.includes(w));
};
// Build the combined material+subtype string to match catalog entries
const fullMaterial = material && subtype ? `${material} ${subtype}` : '';
// First pass: try exact fullMaterial match (e.g. "PETG Basic")
if (fullMaterial) {
const exact = catalogColors.filter(c =>
brandMatches(c.manufacturer) &&
c.material?.toLowerCase() === fullMaterial,
);
if (exact.length > 0) {
return exact.map(c => ({
name: c.color_name,
hex: c.hex_color.replace('#', '').substring(0, 6),
}));
}
// Try without trailing "+" (e.g. "PLA Silk+" -> "PLA Silk")
const normalized = fullMaterial.replace(/\+$/, '');
if (normalized !== fullMaterial) {
const normMatch = catalogColors.filter(c =>
brandMatches(c.manufacturer) &&
c.material?.toLowerCase() === normalized,
);
if (normMatch.length > 0) {
return normMatch.map(c => ({
name: c.color_name,
hex: c.hex_color.replace('#', '').substring(0, 6),
}));
}
}
}
// Second pass: match base material prefix (e.g. "PETG" matches "PETG Basic", "PETG-HF")
if (material) {
const byMaterial = catalogColors.filter(c =>
brandMatches(c.manufacturer) &&
(!c.material || c.material.toLowerCase().startsWith(material)),
);
if (byMaterial.length > 0) {
return byMaterial.map(c => ({
name: c.color_name,
hex: c.hex_color.replace('#', '').substring(0, 6),
}));
}
}
return [];
}, [catalogColors, formData.brand, formData.material, formData.subtype]);
const hasCatalogMatch = matchedCatalogColors.length > 0;
// Search within matched catalog colors
const filteredCatalogColors = useMemo(() => {
if (!colorSearch) return matchedCatalogColors;
const q = colorSearch.toLowerCase();
return matchedCatalogColors.filter(c => c.name.toLowerCase().includes(q));
}, [matchedCatalogColors, colorSearch]);
// Fallback hardcoded colors for search/expand
const filteredFallbackColors = useMemo(() => {
if (colorSearch) {
return ALL_COLORS.filter(c =>
c.name.toLowerCase().includes(colorSearch.toLowerCase()),
@@ -84,48 +164,81 @@ export function ColorSection({
/>
</div>
{/* Color Swatches Grid */}
<div className="space-y-1.5">
<div className="flex items-center justify-between text-xs text-bambu-gray">
<span>{colorSearch ? t('inventory.searchResults') : (showAllColors ? t('inventory.allColors') : t('inventory.commonColors'))}</span>
{!colorSearch && (
<button
type="button"
onClick={() => setShowAllColors(!showAllColors)}
className="flex items-center gap-1 hover:text-white transition-colors"
>
{showAllColors ? (
<>{t('inventory.showLess')} <ChevronUp className="w-3 h-3" /></>
) : (
<>{t('inventory.showAll')} <ChevronDown className="w-3 h-3" /></>
)}
</button>
)}
{/* Color Swatches */}
{hasCatalogMatch ? (
/* Catalog colors matching selected brand/material */
<div className="space-y-1.5">
<span className="text-xs text-bambu-gray">
{colorSearch ? t('inventory.searchResults') : `${formData.brand}${formData.material ? ` ${formData.material}` : ''}`}
</span>
<div className="flex flex-wrap gap-1.5">
{filteredCatalogColors.map(color => (
<button
key={`${color.hex}-${color.name}`}
type="button"
onClick={() => selectColor(color.hex, color.name)}
className={`w-6 h-6 rounded border-2 transition-all hover:scale-110 relative group ${
isSelected(color.hex)
? 'border-bambu-green ring-1 ring-bambu-green/30 scale-110'
: 'border-bambu-dark-tertiary'
}`}
style={{ backgroundColor: `#${color.hex}` }}
title={color.name}
>
<span className="absolute -bottom-7 left-1/2 -translate-x-1/2 px-2 py-0.5 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-xs whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-10 shadow-lg text-white">
{color.name}
</span>
</button>
))}
{filteredCatalogColors.length === 0 && (
<p className="text-sm text-bambu-gray py-1">{t('inventory.noColorsFound')}</p>
)}
</div>
</div>
<div className="flex flex-wrap gap-1.5">
{filteredColors.map(color => (
<button
key={color.hex}
type="button"
onClick={() => selectColor(color.hex, color.name)}
className={`w-6 h-6 rounded border-2 transition-all hover:scale-110 relative group ${
isSelected(color.hex)
? 'border-bambu-green ring-1 ring-bambu-green/30 scale-110'
: 'border-bambu-dark-tertiary'
}`}
style={{ backgroundColor: `#${color.hex}` }}
title={color.name}
>
<span className="absolute -bottom-7 left-1/2 -translate-x-1/2 px-2 py-0.5 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-xs whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-10 shadow-lg text-white">
{color.name}
</span>
</button>
))}
{filteredColors.length === 0 && (
<p className="text-sm text-bambu-gray py-1">{t('inventory.noColorsFound')}</p>
)}
) : (
/* Fallback: hardcoded color palette (no brand/material selected or no catalog matches) */
<div className="space-y-1.5">
<div className="flex items-center justify-between text-xs text-bambu-gray">
<span>{colorSearch ? t('inventory.searchResults') : (showAllColors ? t('inventory.allColors') : t('inventory.commonColors'))}</span>
{!colorSearch && (
<button
type="button"
onClick={() => setShowAllColors(!showAllColors)}
className="flex items-center gap-1 hover:text-white transition-colors"
>
{showAllColors ? (
<>{t('inventory.showLess')} <ChevronUp className="w-3 h-3" /></>
) : (
<>{t('inventory.showAll')} <ChevronDown className="w-3 h-3" /></>
)}
</button>
)}
</div>
<div className="flex flex-wrap gap-1.5">
{filteredFallbackColors.map(color => (
<button
key={color.hex}
type="button"
onClick={() => selectColor(color.hex, color.name)}
className={`w-6 h-6 rounded border-2 transition-all hover:scale-110 relative group ${
isSelected(color.hex)
? 'border-bambu-green ring-1 ring-bambu-green/30 scale-110'
: 'border-bambu-dark-tertiary'
}`}
style={{ backgroundColor: `#${color.hex}` }}
title={color.name}
>
<span className="absolute -bottom-7 left-1/2 -translate-x-1/2 px-2 py-0.5 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-xs whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-10 shadow-lg text-white">
{color.name}
</span>
</button>
))}
{filteredFallbackColors.length === 0 && (
<p className="text-sm text-bambu-gray py-1">{t('inventory.noColorsFound')}</p>
)}
</div>
</div>
</div>
)}
{/* Manual Color Input */}
<div className="grid grid-cols-2 gap-3">
@@ -9,6 +9,7 @@ export interface SpoolFormData {
rgba: string;
label_weight: number;
core_weight: number;
weight_used: number;
slicer_filament: string;
note: string;
}
@@ -21,6 +22,7 @@ export const defaultFormData: SpoolFormData = {
rgba: '808080FF',
label_weight: 1000,
core_weight: 250,
weight_used: 0,
slicer_filament: '',
note: '',
};
@@ -79,6 +81,7 @@ export interface FilamentSectionProps extends SectionProps {
export interface ColorSectionProps extends SectionProps {
recentColors: ColorPreset[];
onColorUsed: (color: ColorPreset) => void;
catalogColors: { manufacturer: string; color_name: string; hex_color: string; material: string | null }[];
}
// Additional section props
+53 -5
View File
@@ -1,4 +1,4 @@
import type { SlicerSetting } from '../../api/client';
import type { SlicerSetting, LocalPreset } from '../../api/client';
import type { ColorPreset, FilamentOption } from './types';
import { KNOWN_VARIANTS, DEFAULT_BRANDS, RECENT_COLORS_KEY, MAX_RECENT_COLORS } from './constants';
@@ -91,8 +91,8 @@ export function parsePresetName(name: string): { brand: string; material: string
return { brand, material, variant };
}
// Extract unique brands from cloud presets
export function extractBrandsFromPresets(presets: SlicerSetting[]): string[] {
// Extract unique brands from cloud presets and local presets
export function extractBrandsFromPresets(presets: SlicerSetting[], localPresets?: LocalPreset[]): string[] {
const brandSet = new Set<string>(DEFAULT_BRANDS);
for (const preset of presets) {
@@ -102,14 +102,56 @@ export function extractBrandsFromPresets(presets: SlicerSetting[]): string[] {
}
}
// Also extract brands from local presets
if (localPresets) {
for (const preset of localPresets) {
if (preset.filament_vendor && preset.filament_vendor.length > 1) {
brandSet.add(preset.filament_vendor);
} else {
const { brand } = parsePresetName(preset.name);
if (brand && brand.length > 1) {
brandSet.add(brand);
}
}
}
}
return Array.from(brandSet).sort((a, b) => a.localeCompare(b));
}
// Build filament options from cloud presets
// Build filament options from local presets (OrcaSlicer imports)
function buildLocalFilamentOptions(localPresets: LocalPreset[]): FilamentOption[] {
const filamentPresets = localPresets.filter(p => p.preset_type === 'filament');
if (filamentPresets.length === 0) return [];
const presetsMap = new Map<string, FilamentOption>();
for (const preset of filamentPresets) {
const baseName = preset.name.replace(/@.*$/, '').trim();
const existing = presetsMap.get(baseName);
if (existing) {
existing.allCodes.push(String(preset.id));
} else {
// Use filament_type as the code if available (e.g. "GFL00"), otherwise use the id
const code = preset.filament_type || String(preset.id);
presetsMap.set(baseName, {
code,
name: baseName,
displayName: baseName,
isCustom: false,
allCodes: [code],
});
}
}
return Array.from(presetsMap.values()).sort((a, b) => a.displayName.localeCompare(b.displayName));
}
// Build filament options: cloud presets → local presets → hardcoded fallback
export function buildFilamentOptions(
cloudPresets: SlicerSetting[],
configuredPrinterModels: Set<string>,
localPresets?: LocalPreset[],
): FilamentOption[] {
// 1. Cloud presets (highest priority)
if (cloudPresets.length > 0) {
const customPresets: FilamentOption[] = [];
const defaultPresetsMap = new Map<string, FilamentOption>();
@@ -155,7 +197,13 @@ export function buildFilamentOptions(
].sort((a, b) => a.displayName.localeCompare(b.displayName));
}
// Fallback to hardcoded defaults
// 2. Local presets (OrcaSlicer imports)
if (localPresets && localPresets.length > 0) {
const localOptions = buildLocalFilamentOptions(localPresets);
if (localOptions.length > 0) return localOptions;
}
// 3. Hardcoded fallback
return FALLBACK_PRESETS;
}