mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-21 10:15:04 +02:00
Configure a spool's filament preset and K profile per nozzle
A slicer preset is bound to a printer model: "Bambu PLA Basic @BBL X1C" is not the same preset as "@BBL H2C", and Bambu names a nozzle size in it as well. A spool carried exactly one, which was right until the same spool was used on a second machine -- the AMS slot on the other one was then configured with a preset that machine has no profile for. K profiles had the matching gap from the other side: the tables have always been keyed per hotend, but the picker could not express it. spool_filament_preset and its Spoolman twin store the exceptions, keyed (spool, printer_model, nozzle_diameter). Model rather than printer because the preset is a property of the model -- "@BBL X1C" is the same preset on every X1C, and asking per machine would mean picking the identical value twice. K profiles stay on printer_id, because a K value is measured on one physical hotend and two machines of the same model legitimately differ. Resolution is exact (model, diameter) -> (model, "") -> the spool's own preset, so a spool nobody has configured behaves exactly as it did before. The form writes one row per nozzle size and never the "" row; that level is kept for API clients wanting one value to cover a model. Both halves cover every standard nozzle size rather than the size currently fitted, because a spool is configured once and nozzles get swapped. The PA Profile tab becomes a Printers tab: a model list beside a detail pane holding a preset row per size and a K-profile grid of size by hotend. Each model is offered only the presets that name it, through the same matcher the Configure AMS Slot modal filters with, which moves out of that component into utils/slicerPrinterMatch. Presets whose name identifies no model -- most user-authored and OrcaSlicer ones -- stay offered everywhere, as does whatever is already selected, so a saved override cannot vanish from the control that shows it. Every preset carries an origin badge in the wording and colours that modal already uses. Every path that configures a slot now respects both: manual assign in either inventory mode, RFID auto-assign, the Spoolman tag link, the re-fire when a slot goes empty to loaded, the re-apply after a calibration-table refresh, and the re-selection when a Filament Track Switch moves an AMS to the other nozzle. Which nozzle a slot feeds, and how wide it is, was worked out independently in seven of those places, each reading nozzles[0] for every slot on the machine -- correct on a single-nozzle printer and on a dual-nozzle printer with matching nozzles, wrong the moment two sizes are fitted. That resolution is now services/slot_nozzle. Which array entry belongs to which hotend is no longer inferred. Measured on an H2D fitted with a 0.4 high flow on the left and a 0.6 on the right, nozzles[0] reads the right hotend, so the array is indexed by extruder id and the H2/X2 parser's convention is the one that holds. The legacy parser's opposite convention never governs a real dual-nozzle machine: every model in DUAL_NOZZLE_MODELS reports device.nozzle.info, and left_nozzle_diameter appears in no log or wire capture. Two comments that said otherwise were wrong and are fixed; amsHelpers' code was right all along and only its comment lied. Four defects surfaced while wiring it, all pre-existing except the last. The picker identified a chosen calibration by cali_idx alone, and the printer numbers its calibration table per nozzle -- on a dual-nozzle machine the same index exists on both hotends meaning different things, so saving could persist the other hotend's K value and diameter; SpoolBuddy's write-tag page carried a verbatim copy and gets the same fix. RFID auto-assign chose a K profile with no extruder test at all, so a spool calibrated on both hotends had a coin toss decide which pressure-advance value the slot got, on the path that runs unattended every time a Bambu spool is loaded. The Spoolman tag-link path resolved no preset whatsoever, configuring every linked slot with a generic material id and discarding a preset set in inventory -- the same defect #1713 fixed on the assign path, one function over. And an FTS inlet move re-selected K for nozzle 0 rather than for the nozzle the AMS had just been moved to. The last one is new here: a per-model override can be a cloud USER preset, whose PFUS-prefixed id the slicer rejects, and passing it straight into extrusion_cali_sel would silently lose the K-profile link. Reached the printer only where such an override exists, which is why nothing in the suite caught it. printer_safe_filament_id falls through to the spool's own preset and then the tray's RFID value instead. Reading a printer's calibration table asks for one nozzle size at a time. H2-series firmware answers only the first one or two of a concurrent burst of extrusion_cali_get and silently drops the rest, each dropped request costing a five-second timeout before its retry: measured at 11 and 23 seconds on an H2C and an H2D for four parallel requests, against roughly one second in series. An X1C answers all four at once, which is why this only ever surfaced on dual-diameter printers. Printers themselves are read in parallel -- separate machines are separate connections. The Configure AMS Slot dialog opens on the spool's own configured values, falling back to the slot's last manual configuration and then the tray's RFID data. The spool form is wider for the two-pane layout, colour, weight, cost and location move to their own tab in two columns, and a printer card in expanded view lists every fitted nozzle size rather than the first entry alone.
This commit is contained in:
@@ -3,25 +3,32 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { X, Loader2, Save, Beaker, Palette, Zap, Tag, Unlink } from 'lucide-react';
|
||||
import { api, ApiError } from '../api/client';
|
||||
import type { InventorySpool, SlicerSetting, SpoolCatalogEntry, LocalPreset, BuiltinFilament, SpoolmanBulkCreateResult, SpoolKProfileInput, SpoolmanFilamentEntry } from '../api/client';
|
||||
import type { InventorySpool, SlicerSetting, SpoolCatalogEntry, LocalPreset, BuiltinFilament, SpoolmanBulkCreateResult, SpoolFilamentPresetInput, SpoolKProfileInput, SpoolmanFilamentEntry } from '../api/client';
|
||||
import { Button } from './Button';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
import type { SpoolFormData, PrinterWithCalibrations, ColorPreset, SpoolFormMode } from './spool-form/types';
|
||||
import type {
|
||||
CalibrationProfile,
|
||||
ColorPreset,
|
||||
PresetChoice,
|
||||
PrinterWithCalibrations,
|
||||
SpoolFormData,
|
||||
SpoolFormMode,
|
||||
} from './spool-form/types';
|
||||
import { defaultFormData, validateForm, SPOOLMAN_LINKED_FIELDS } from './spool-form/types';
|
||||
import { buildFilamentOptions, extractBrandsFromPresets, fetchPrinterCalibrations, findPresetOption, loadRecentColors, pairedOptions, parsePresetName, saveRecentColor, withCurrentValue } from './spool-form/utils';
|
||||
import { buildFilamentOptions, extractBrandsFromPresets, fetchPrinterCalibrations, findPresetOption, hotendKey, loadRecentColors, pairedOptions, parsePresetKey, parsePresetName, presetKey, saveRecentColor, withCurrentValue } from './spool-form/utils';
|
||||
import { MATERIALS } from './spool-form/constants';
|
||||
import { FilamentSection } from './spool-form/FilamentSection';
|
||||
import { ColorSection } from './spool-form/ColorSection';
|
||||
import { AdditionalSection } from './spool-form/AdditionalSection';
|
||||
import { SpoolmanFilamentPicker } from './spool-form/SpoolmanFilamentPicker';
|
||||
import { PAProfileSection } from './spool-form/PAProfileSection';
|
||||
import { PrinterProfilesSection } from './spool-form/PrinterProfilesSection';
|
||||
import { SpoolUsageHistory } from './SpoolUsageHistory';
|
||||
import {
|
||||
invalidateInventoryLocations,
|
||||
invalidateSpoolAndLocationQueries,
|
||||
} from '../utils/inventoryQueries';
|
||||
|
||||
type TabId = 'filament' | 'pa-profile';
|
||||
type TabId = 'filament' | 'appearance' | 'printers';
|
||||
|
||||
const CLEAR_TAG_PAYLOAD = { tag_uid: null, tray_uuid: null, tag_type: null, data_origin: null };
|
||||
|
||||
@@ -75,6 +82,7 @@ export function SpoolFormModal({
|
||||
const [cloudAuthenticated, setCloudAuthenticated] = useState(false);
|
||||
const [loadingCloudPresets, setLoadingCloudPresets] = useState(false);
|
||||
const [cloudPresets, setCloudPresets] = useState<SlicerSetting[]>([]);
|
||||
const [orcaSettingIds, setOrcaSettingIds] = useState<Set<string>>(new Set());
|
||||
const [presetInputValue, setPresetInputValue] = useState('');
|
||||
|
||||
// Spool catalog
|
||||
@@ -104,16 +112,31 @@ export function SpoolFormModal({
|
||||
|
||||
// PA Profile state
|
||||
const [fetchedCalibrations, setFetchedCalibrations] = useState<PrinterWithCalibrations[]>([]);
|
||||
const [selectedProfiles, setSelectedProfiles] = useState<Set<string>>(new Set());
|
||||
const [expandedPrinters, setExpandedPrinters] = useState<Set<string>>(new Set());
|
||||
// Whether the calibration fetch above is still running. Without it the
|
||||
// Printers tab renders its "no printers configured" empty state while the
|
||||
// printers are still being asked -- which reads as a wrong answer rather
|
||||
// than as a wait, and the fetch is several round trips per machine.
|
||||
const [loadingCalibrations, setLoadingCalibrations] = useState(false);
|
||||
// One K profile per hotend, keyed `printerId:extruder:diameter`. A Map rather
|
||||
// than a Set of composite keys because the Printers tab presents each hotend
|
||||
// as a single-choice dropdown -- the shape makes "two profiles for one
|
||||
// hotend" unrepresentable instead of relying on eviction logic to prevent it.
|
||||
const [selectedProfiles, setSelectedProfiles] = useState<Map<string, CalibrationProfile>>(new Map());
|
||||
// Per-printer-model preset overrides, keyed by `presetKey(model, diameter)`.
|
||||
// Only the models the user has actually overridden are present; an absent
|
||||
// entry means "inherit this spool's own preset", which is exactly what the
|
||||
// backend cascade does with a missing row.
|
||||
const [modelPresets, setModelPresets] = useState<Map<string, PresetChoice>>(new Map());
|
||||
const [selectedGroupId, setSelectedGroupId] = useState<string>('');
|
||||
|
||||
// Use prop if provided, otherwise use self-fetched data
|
||||
const resolvedCalibrations = printersWithCalibrations.length > 0
|
||||
? printersWithCalibrations
|
||||
: fetchedCalibrations;
|
||||
|
||||
// Count selected PA profiles for tab badge
|
||||
const selectedProfileCount = selectedProfiles.size;
|
||||
// Tab badge: everything the user has configured under Printers, K profiles
|
||||
// and preset overrides alike, since both live on that tab now.
|
||||
const selectedProfileCount = selectedProfiles.size + modelPresets.size;
|
||||
|
||||
// Fetch Spoolman filament catalog when in Spoolman mode
|
||||
// retry:false — Spoolman may be intentionally disabled (400); don't flood the server
|
||||
@@ -169,6 +192,10 @@ export function SpoolFormModal({
|
||||
const orcaPresets = orcaResult.status === 'fulfilled' ? orcaResult.value.presets : [];
|
||||
setCloudAuthenticated(bambuConnected || orcaConnected);
|
||||
setCloudPresets([...bambuPresets, ...orcaPresets]);
|
||||
// The two clouds are merged into one list, so remember which ids came
|
||||
// from Orca -- it is the only way the origin badge can tell them
|
||||
// apart afterwards.
|
||||
setOrcaSettingIds(new Set(orcaPresets.map(p => p.setting_id)));
|
||||
} catch (e) {
|
||||
if (cancelled) return;
|
||||
console.error('Failed to fetch cloud presets:', e);
|
||||
@@ -189,27 +216,40 @@ export function SpoolFormModal({
|
||||
// Fetch printer calibrations if not provided via props
|
||||
if (printersWithCalibrations.length === 0) {
|
||||
(async () => {
|
||||
setLoadingCalibrations(true);
|
||||
try {
|
||||
const printers = await api.getPrinters();
|
||||
const statuses = await Promise.all(
|
||||
printers.map(p => api.getPrinterStatus(p.id).catch(() => null)),
|
||||
);
|
||||
const results: PrinterWithCalibrations[] = [];
|
||||
for (let i = 0; i < printers.length; i++) {
|
||||
const printer = printers[i];
|
||||
const status = statuses[i];
|
||||
const connected = status?.connected ?? false;
|
||||
let calibrations: PrinterWithCalibrations['calibrations'] = [];
|
||||
if (connected) {
|
||||
// Fetch across every installed nozzle so dual-nozzle printers
|
||||
// surface both the 0.4mm and 0.6mm K-profiles, not just 0.4 (#2618).
|
||||
calibrations = await fetchPrinterCalibrations(printer.id, status);
|
||||
}
|
||||
results.push({ printer: { ...printer, connected }, calibrations });
|
||||
}
|
||||
setFetchedCalibrations(results);
|
||||
// Printers in parallel, diameters within a printer in series.
|
||||
// Separate machines are separate MQTT connections and do not
|
||||
// interfere; it is one printer's own firmware that drops a
|
||||
// concurrent burst of calibration requests (see
|
||||
// fetchPrinterCalibrations). Walking the fleet one machine at a
|
||||
// time made the whole tab wait for the sum of every printer.
|
||||
const results = await Promise.all(
|
||||
printers.map(async (printer, i) => {
|
||||
const status = statuses[i];
|
||||
const connected = status?.connected ?? false;
|
||||
let calibrations: PrinterWithCalibrations['calibrations'] = [];
|
||||
if (connected) {
|
||||
// Across every nozzle size, so a profile for a size that is
|
||||
// not currently fitted is still offered (#2618 fetched only
|
||||
// the fitted ones).
|
||||
calibrations = await fetchPrinterCalibrations(printer.id, status);
|
||||
}
|
||||
// Keep the reported nozzle hardware: the Printers tab lists a
|
||||
// model's installed diameters from it. Read as a set of
|
||||
// diameters only -- never indexed by extruder.
|
||||
return { printer: { ...printer, connected }, calibrations, nozzles: status?.nozzles };
|
||||
}),
|
||||
);
|
||||
if (!cancelled) setFetchedCalibrations(results);
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch printer calibrations:', e);
|
||||
} finally {
|
||||
if (!cancelled) setLoadingCalibrations(false);
|
||||
}
|
||||
})();
|
||||
}
|
||||
@@ -228,8 +268,8 @@ export function SpoolFormModal({
|
||||
|
||||
// Build filament options: cloud → local → fallback
|
||||
const filamentOptions = useMemo(
|
||||
() => buildFilamentOptions(cloudPresets, new Set(), localPresets, builtinFilaments),
|
||||
[cloudPresets, localPresets, builtinFilaments],
|
||||
() => buildFilamentOptions(cloudPresets, new Set(), localPresets, builtinFilaments, orcaSettingIds),
|
||||
[cloudPresets, localPresets, builtinFilaments, orcaSettingIds],
|
||||
);
|
||||
|
||||
// Extract brands from presets
|
||||
@@ -374,22 +414,34 @@ export function SpoolFormModal({
|
||||
});
|
||||
setPresetInputValue(spool.slicer_filament_name || spool.slicer_filament || '');
|
||||
|
||||
// Load K-profiles for this spool
|
||||
// Load K-profiles for this spool. The stored row carries everything
|
||||
// the picker needs to show the selection before the printer answers,
|
||||
// so an offline printer still renders what was chosen for it.
|
||||
if (spool.k_profiles && spool.k_profiles.length > 0) {
|
||||
const profileKeys = new Set<string>();
|
||||
const chosen = new Map<string, CalibrationProfile>();
|
||||
for (const p of spool.k_profiles) {
|
||||
if (p.cali_idx !== null && p.cali_idx !== undefined) {
|
||||
profileKeys.add(`${p.printer_id}:${p.cali_idx}:${p.extruder ?? 'null'}`);
|
||||
}
|
||||
if (p.cali_idx === null || p.cali_idx === undefined) continue;
|
||||
const diameter = (p.nozzle_diameter || '').trim() || '0.4';
|
||||
const extruder = p.extruder ?? 0;
|
||||
chosen.set(hotendKey(p.printer_id, extruder, diameter), {
|
||||
cali_idx: p.cali_idx,
|
||||
filament_id: '',
|
||||
setting_id: p.setting_id || '',
|
||||
name: p.name || '',
|
||||
k_value: p.k_value,
|
||||
n_coef: 0,
|
||||
extruder_id: extruder,
|
||||
nozzle_diameter: diameter,
|
||||
});
|
||||
}
|
||||
setSelectedProfiles(profileKeys);
|
||||
setSelectedProfiles(chosen);
|
||||
} else {
|
||||
setSelectedProfiles(new Set());
|
||||
setSelectedProfiles(new Map());
|
||||
}
|
||||
} else {
|
||||
setFormData(defaultFormData);
|
||||
setPresetInputValue('');
|
||||
setSelectedProfiles(new Set());
|
||||
setSelectedProfiles(new Map());
|
||||
}
|
||||
// Reset on every open, not just the create path (#1905). The modal keeps
|
||||
// its state while closed, and the Quick Add toggle only renders in create
|
||||
@@ -400,11 +452,51 @@ export function SpoolFormModal({
|
||||
setQuantity(1);
|
||||
setErrors({});
|
||||
setActiveTab('filament');
|
||||
setSelectedGroupId('');
|
||||
// Cleared on every open, both branches: the modal keeps its state while
|
||||
// closed, so editing spool B after spool A would otherwise show (and
|
||||
// save) A's per-model overrides on B. Refilled by the fetch below.
|
||||
setModelPresets(new Map());
|
||||
setWeightTouched(false);
|
||||
setLocationIdTouched(false);
|
||||
}
|
||||
}, [isOpen, spool, mode, isCopying]);
|
||||
|
||||
// Load this spool's per-printer-model preset overrides. Fetched rather than
|
||||
// read off the spool: they are deliberately not embedded in the spool
|
||||
// response, which the inventory list returns once per spool the user owns.
|
||||
// Copying a spool copies its overrides -- they describe the filament on the
|
||||
// spool, which is what a copy has too.
|
||||
useEffect(() => {
|
||||
if (!isOpen || !spool) return;
|
||||
let cancelled = false;
|
||||
const load = spoolmanMode ? api.getSpoolmanFilamentPresets : api.getSpoolFilamentPresets;
|
||||
load(spool.id)
|
||||
.then(rows => {
|
||||
if (cancelled) return;
|
||||
const next = new Map<string, PresetChoice>();
|
||||
for (const row of rows) {
|
||||
next.set(presetKey(row.printer_model, row.nozzle_diameter || ''), {
|
||||
code: row.slicer_filament || '',
|
||||
name: row.slicer_filament_name || '',
|
||||
});
|
||||
}
|
||||
setModelPresets(next);
|
||||
})
|
||||
.catch(e => {
|
||||
// Non-fatal: the tab still works, it just starts with nothing
|
||||
// overridden. Saving from that state WOULD clear the stored rows, so
|
||||
// say so rather than letting the user save over what they cannot see.
|
||||
if (cancelled) return;
|
||||
console.error('Failed to load filament preset overrides:', e);
|
||||
showToast(t('inventory.filamentPresetsLoadFailed'), 'warning');
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isOpen, spool?.id, spoolmanMode]);
|
||||
|
||||
// Legacy rows may have storage_location text but no location_id yet — link when catalog loads.
|
||||
useEffect(() => {
|
||||
if (!isOpen || !spool || locationIdTouched || formData.location_id != null) return;
|
||||
@@ -416,12 +508,6 @@ export function SpoolFormModal({
|
||||
}
|
||||
}, [isOpen, spool, storageLocations, formData.location_id, locationIdTouched]);
|
||||
|
||||
// Expand all printers in PA profile section when calibrations are available
|
||||
useEffect(() => {
|
||||
if (isOpen && resolvedCalibrations.length > 0) {
|
||||
setExpandedPrinters(new Set(resolvedCalibrations.map(p => String(p.printer.id))));
|
||||
}
|
||||
}, [isOpen, resolvedCalibrations]);
|
||||
|
||||
// Update field helper
|
||||
const updateField = <K extends keyof SpoolFormData>(key: K, value: SpoolFormData[K]) => {
|
||||
@@ -481,7 +567,7 @@ export function SpoolFormModal({
|
||||
: api.createSpool(data as Parameters<typeof api.createSpool>[0]),
|
||||
onSuccess: async (newSpool) => {
|
||||
if (newSpool?.id) {
|
||||
const ok = await saveKProfiles(newSpool.id);
|
||||
const ok = await savePrinterProfiles(newSpool.id);
|
||||
if (!ok) return;
|
||||
}
|
||||
await refreshSpoolQueries();
|
||||
@@ -518,9 +604,12 @@ export function SpoolFormModal({
|
||||
? spoolmanResult.created
|
||||
: (result as InventorySpool[]);
|
||||
|
||||
if (selectedProfiles.size > 0) {
|
||||
// Bulk create: every copy gets the same profiles and overrides. Skipped
|
||||
// entirely when the user configured neither, so a plain bulk add does
|
||||
// not fire two writes per spool.
|
||||
if (selectedProfiles.size > 0 || modelPresets.size > 0) {
|
||||
for (const s of createdSpools) {
|
||||
await saveKProfiles(s.id);
|
||||
await savePrinterProfiles(s.id);
|
||||
}
|
||||
}
|
||||
await refreshSpoolQueries();
|
||||
@@ -554,7 +643,7 @@ export function SpoolFormModal({
|
||||
: api.updateSpool(spool!.id, data as Parameters<typeof api.updateSpool>[1]),
|
||||
onSuccess: async () => {
|
||||
if (spool?.id) {
|
||||
const ok = await saveKProfiles(spool.id);
|
||||
const ok = await savePrinterProfiles(spool.id);
|
||||
if (!ok) return;
|
||||
}
|
||||
await refreshSpoolQueries();
|
||||
@@ -625,6 +714,18 @@ export function SpoolFormModal({
|
||||
queryFn: api.getSettings,
|
||||
enabled: isOpen,
|
||||
});
|
||||
|
||||
// Backend Bambu printer-model registry, so the Printers tab can read the
|
||||
// model out of a preset name and offer each model only its own presets. The
|
||||
// same query key and staleTime the Configure AMS Slot modal uses -- the
|
||||
// registry only changes across backend releases, so this is a cache hit
|
||||
// whenever that modal has been opened.
|
||||
const { data: printerModelsData } = useQuery({
|
||||
queryKey: ['slicerPrinterModels'],
|
||||
queryFn: api.getSlicerPrinterModels,
|
||||
enabled: isOpen,
|
||||
staleTime: Infinity,
|
||||
});
|
||||
const availableCategories = (() => {
|
||||
const set = new Set<string>();
|
||||
for (const s of allSpools ?? []) {
|
||||
@@ -660,65 +761,61 @@ export function SpoolFormModal({
|
||||
},
|
||||
});
|
||||
|
||||
// Save K-profiles for selected calibrations. Returns false if any error occurred.
|
||||
const saveKProfiles = async (spoolId: number): Promise<boolean> => {
|
||||
const saveApi = spoolmanMode ? api.saveSpoolmanKProfiles : api.saveSpoolKProfiles;
|
||||
|
||||
if (selectedProfiles.size === 0) {
|
||||
try {
|
||||
await saveApi(spoolId, []);
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error('Failed to save K-profiles:', e);
|
||||
showToast(t('inventory.kProfileSaveFailed'), 'warning');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// Save everything the Printers tab holds: one K profile per hotend and the
|
||||
// per-printer-model preset overrides. Returns false if either write failed,
|
||||
// which keeps the modal open so the user does not lose what they picked.
|
||||
const savePrinterProfiles = async (spoolId: number): Promise<boolean> => {
|
||||
const saveKApi = spoolmanMode ? api.saveSpoolmanKProfiles : api.saveSpoolKProfiles;
|
||||
const savePresetApi = spoolmanMode ? api.saveSpoolmanFilamentPresets : api.saveSpoolFilamentPresets;
|
||||
|
||||
// The selection Map is keyed by hotend and holds the calibration itself,
|
||||
// so nothing has to be resolved back out of the printer's live list. That
|
||||
// also fixes a real defect in the old key-based lookup: it matched a
|
||||
// calibration by cali_idx alone, and cali_idx is numbered PER NOZZLE --
|
||||
// on a dual-nozzle printer it could resolve the other hotend's entry and
|
||||
// persist that entry's K value and diameter.
|
||||
const profiles: SpoolKProfileInput[] = [];
|
||||
let dropped = 0;
|
||||
for (const key of selectedProfiles) {
|
||||
const [printerIdStr, caliIdxStr, extruderStr] = key.split(':');
|
||||
const printerId = parseInt(printerIdStr);
|
||||
const caliIdx = parseInt(caliIdxStr);
|
||||
const extruder = extruderStr === 'null' ? 0 : parseInt(extruderStr);
|
||||
|
||||
const pc = resolvedCalibrations.find(p => p.printer.id === printerId);
|
||||
if (pc) {
|
||||
const cal = pc.calibrations.find(c => c.cali_idx === caliIdx);
|
||||
if (cal) {
|
||||
profiles.push({
|
||||
printer_id: printerId,
|
||||
extruder,
|
||||
nozzle_diameter: cal.nozzle_diameter || '0.4',
|
||||
k_value: cal.k_value,
|
||||
name: cal.name || null,
|
||||
cali_idx: cal.cali_idx,
|
||||
setting_id: cal.setting_id || null,
|
||||
});
|
||||
} else {
|
||||
dropped++;
|
||||
}
|
||||
} else {
|
||||
dropped++;
|
||||
}
|
||||
for (const [key, cal] of selectedProfiles) {
|
||||
const [printerIdStr, extruderStr, diameter] = key.split(':');
|
||||
profiles.push({
|
||||
printer_id: parseInt(printerIdStr),
|
||||
extruder: parseInt(extruderStr),
|
||||
nozzle_diameter: diameter || '0.4',
|
||||
k_value: cal.k_value,
|
||||
name: cal.name || null,
|
||||
cali_idx: cal.cali_idx,
|
||||
setting_id: cal.setting_id || null,
|
||||
});
|
||||
}
|
||||
|
||||
if (dropped > 0) {
|
||||
console.error(`saveKProfiles: ${dropped} profile key(s) could not be resolved`, Array.from(selectedProfiles));
|
||||
const presets: SpoolFilamentPresetInput[] = [];
|
||||
for (const [key, choice] of modelPresets) {
|
||||
const { model, diameter } = parsePresetKey(key);
|
||||
if (!model) continue;
|
||||
presets.push({
|
||||
printer_model: model,
|
||||
nozzle_diameter: diameter,
|
||||
slicer_filament: choice.code || null,
|
||||
slicer_filament_name: choice.name || null,
|
||||
});
|
||||
}
|
||||
|
||||
// Both are full replacements, so both run even when empty -- that is how
|
||||
// the user clears the last profile or the last override.
|
||||
try {
|
||||
await saveKApi(spoolId, profiles);
|
||||
} catch (e) {
|
||||
console.error('Failed to save K-profiles:', e);
|
||||
showToast(t('inventory.kProfileSaveFailed'), 'warning');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (profiles.length > 0) {
|
||||
try {
|
||||
await saveApi(spoolId, profiles);
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error('Failed to save K-profiles:', e);
|
||||
showToast(t('inventory.kProfileSaveFailed'), 'warning');
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
await savePresetApi(spoolId, presets);
|
||||
} catch (e) {
|
||||
console.error('Failed to save filament preset overrides:', e);
|
||||
showToast(t('inventory.filamentPresetSaveFailed'), 'warning');
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
@@ -800,7 +897,12 @@ export function SpoolFormModal({
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
<div className="relative w-full max-w-xl mx-4 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-xl shadow-2xl max-h-[90vh] flex flex-col">
|
||||
{/* Wider than the old max-w-xl: the Printers tab is a model list beside
|
||||
a detail pane holding a preset row per nozzle size and a hotend-by-
|
||||
size grid, which needs room for both. Held constant across tabs
|
||||
rather than sized per tab -- a modal that resizes as you switch tabs
|
||||
reads as a layout bug. */}
|
||||
<div className="relative w-full max-w-5xl mx-4 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-xl shadow-2xl max-h-[90vh] flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary flex-shrink-0">
|
||||
<h2 className="text-lg font-semibold text-white flex items-baseline gap-2">
|
||||
@@ -857,20 +959,33 @@ export function SpoolFormModal({
|
||||
: 'text-bambu-gray hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<Palette className="w-4 h-4" />
|
||||
<Beaker className="w-4 h-4" />
|
||||
{t('inventory.filamentInfoTab')}
|
||||
</button>
|
||||
{!quickAdd && (
|
||||
<button
|
||||
onClick={() => setActiveTab('pa-profile')}
|
||||
onClick={() => setActiveTab('appearance')}
|
||||
className={`flex-1 px-4 py-2.5 text-sm font-medium flex items-center justify-center gap-2 transition-colors ${
|
||||
activeTab === 'pa-profile'
|
||||
activeTab === 'appearance'
|
||||
? 'text-bambu-green border-b-2 border-bambu-green'
|
||||
: 'text-bambu-gray hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<Beaker className="w-4 h-4" />
|
||||
{t('inventory.paProfileTab')}
|
||||
<Palette className="w-4 h-4" />
|
||||
{t('inventory.colorAndCostTab')}
|
||||
</button>
|
||||
)}
|
||||
{!quickAdd && (
|
||||
<button
|
||||
onClick={() => setActiveTab('printers')}
|
||||
className={`flex-1 px-4 py-2.5 text-sm font-medium flex items-center justify-center gap-2 transition-colors ${
|
||||
activeTab === 'printers'
|
||||
? 'text-bambu-green border-b-2 border-bambu-green'
|
||||
: 'text-bambu-gray hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<Zap className="w-4 h-4" />
|
||||
{t('inventory.printersTab')}
|
||||
{selectedProfileCount > 0 && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded-full bg-bambu-green/20 text-bambu-green">
|
||||
{selectedProfileCount}
|
||||
@@ -926,6 +1041,9 @@ export function SpoolFormModal({
|
||||
/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
) : activeTab === 'appearance' ? (
|
||||
<div className="space-y-6">
|
||||
{/* Color Section */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-bambu-gray uppercase tracking-wide mb-3">
|
||||
@@ -981,14 +1099,18 @@ export function SpoolFormModal({
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<PAProfileSection
|
||||
<PrinterProfilesSection
|
||||
formData={formData}
|
||||
updateField={updateField}
|
||||
printersWithCalibrations={resolvedCalibrations}
|
||||
filamentOptions={filamentOptions}
|
||||
modelPresets={modelPresets}
|
||||
setModelPresets={setModelPresets}
|
||||
selectedProfiles={selectedProfiles}
|
||||
setSelectedProfiles={setSelectedProfiles}
|
||||
expandedPrinters={expandedPrinters}
|
||||
setExpandedPrinters={setExpandedPrinters}
|
||||
selectedGroupId={selectedGroupId}
|
||||
setSelectedGroupId={setSelectedGroupId}
|
||||
printerModels={printerModelsData}
|
||||
isLoading={loadingCalibrations}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user