diff --git a/src/libslic3r/PresetBundle.cpp b/src/libslic3r/PresetBundle.cpp index 9cc325cff5..776c5c58a1 100644 --- a/src/libslic3r/PresetBundle.cpp +++ b/src/libslic3r/PresetBundle.cpp @@ -695,6 +695,135 @@ std::optional PresetBundle::get_filament_by_filament_id(const return std::nullopt; } +// GitHub #11937 helper: populate FilamentBaseInfo from a preset. Mirrors the field extraction in +// get_filament_by_filament_id() so both lookups report identical data for the same preset. +static FilamentBaseInfo make_filament_base_info(const Preset& filament_preset) +{ + const auto& config = filament_preset.config; + FilamentBaseInfo info; + info.filament_id = filament_preset.filament_id; + info.is_system = filament_preset.is_system; + info.filament_name = filament_preset.alias; + info.setting_id = filament_preset.setting_id; + if (config.has("filament_is_support")) + info.is_support = config.option("filament_is_support")->values[0]; + if (config.has("filament_type")) + info.filament_type = config.option("filament_type")->values[0]; + if (config.has("filament_vendor")) + info.vendor = config.option("filament_vendor")->values[0]; + if (config.has("nozzle_temperature_range_high")) + info.nozzle_temp_range_high = config.option("nozzle_temperature_range_high")->values[0]; + if (config.has("nozzle_temperature_range_low")) + info.nozzle_temp_range_low = config.option("nozzle_temperature_range_low")->values[0]; + if (config.has("temperature_vitrification")) + info.temperature_vitrification = config.option("temperature_vitrification")->values[0]; + if (config.has("filament_printable")) + info.filament_printable = config.option("filament_printable")->values[0]; + if (config.has("filament_extruder_compatibility")) + info.set_filament_extruder_compatibility(config.option("filament_extruder_compatibility")->values[0]); + return info; +} + +std::optional PresetBundle::resolve_filament_for_spool(const std::string& stored_id, + const std::string& vendor, + const std::string& material_type, + bool* exact_match) const +{ + if (exact_match) + *exact_match = false; + + // Step 1 — the normal case: the spool stored a real Preset::filament_id. + if (!stored_id.empty()) { + if (auto info = get_filament_by_filament_id(stored_id)) { + if (exact_match) + *exact_match = true; + return info; + } + } + + // Step 2 — the spool stored a setting_id (cloud user-settings id) instead of a filament_id. + // Prefer a preset that actually carries a filament_id so downstream AMS commands have + // something meaningful to send. + if (!stored_id.empty()) { + const Preset* setting_hit = nullptr; + for (auto iter = filaments.begin(); iter != filaments.end(); ++iter) { + const Preset& preset = *iter; + if (preset.setting_id != stored_id) + continue; + if (!preset.filament_id.empty()) { + setting_hit = &preset; + break; + } + if (!setting_hit) + setting_hit = &preset; + } + if (setting_hit) { + if (exact_match) + *exact_match = true; + return make_filament_base_info(*setting_hit); + } + } + + // Steps 3 and 4 need a material type to work with. + if (material_type.empty()) + return std::nullopt; + + auto preset_type_of = [](const Preset& preset) -> std::string { + if (!preset.config.has("filament_type")) + return std::string(); + const auto& values = preset.config.option("filament_type")->values; + return values.empty() ? std::string() : values.front(); + }; + auto preset_vendor_of = [](const Preset& preset) -> std::string { + if (!preset.config.has("filament_vendor")) + return std::string(); + const auto& values = preset.config.option("filament_vendor")->values; + return values.empty() ? std::string() : values.front(); + }; + + // Step 3 — same vendor and material type. Prefer a system preset, but accept a user preset + // (which inherits its base's filament_id) when that is all the user has. + if (!vendor.empty()) { + const Preset* fallback = nullptr; + for (auto iter = filaments.begin(); iter != filaments.end(); ++iter) { + const Preset& preset = *iter; + if (preset.filament_id.empty()) + continue; + if (preset_vendor_of(preset) != vendor || preset_type_of(preset) != material_type) + continue; + if (preset.is_system) + return make_filament_base_info(preset); + if (!fallback) + fallback = &preset; + } + if (fallback) + return make_filament_base_info(*fallback); + } + + // Step 4 — last resort: the shipped "Generic " system preset. This is what makes a + // hand-typed third-party filament usable in the AMS at all: the printer only needs sane + // temperature/type parameters for the slot, which the Generic profile provides. + const std::string generic_name = "Generic " + material_type; + const Preset* generic_any = nullptr; + for (auto iter = filaments.begin(); iter != filaments.end(); ++iter) { + const Preset& preset = *iter; + if (preset.filament_id.empty()) + continue; + if (preset_type_of(preset) != material_type) + continue; + const bool name_is_generic = boost::istarts_with(preset.name, generic_name) || + boost::istarts_with(preset.alias, generic_name); + if (name_is_generic && preset.is_system) + return make_filament_base_info(preset); + if (name_is_generic && !generic_any) + generic_any = &preset; + } + if (generic_any) + return make_filament_base_info(*generic_any); + + return std::nullopt; +} + //BBS: load project embedded presets PresetsConfigSubstitutions PresetBundle::load_project_embedded_presets(std::vector project_presets, ForwardCompatibilitySubstitutionRule substitution_rule) { diff --git a/src/libslic3r/PresetBundle.hpp b/src/libslic3r/PresetBundle.hpp index c782ae6014..826ff0a164 100644 --- a/src/libslic3r/PresetBundle.hpp +++ b/src/libslic3r/PresetBundle.hpp @@ -217,6 +217,29 @@ class PresetBundle std::optional get_filament_by_filament_id(const std::string& filament_id, const std::string& printer_name = std::string(), bool only_system = false) const; + // GitHub #11937: Filament Manager spools store an id in FilamentSpool::setting_id that is + // compared against Preset::filament_id everywhere. Spools created through the web + // "Add filament" dialog may instead carry a cloud user-settings id, or nothing at all when + // the user typed a third-party brand by hand. Those spools used to fail + // get_filament_by_filament_id() outright and were rendered as unselectable + // "Unsupported Filaments". + // + // resolve_filament_for_spool() is a tolerant lookup for exactly that situation. Resolution + // order, first hit wins: + // 1. exact Preset::filament_id match (the normal, correct case) + // 2. Preset::setting_id match (spool stored the wrong kind of id) + // 3. vendor + filament_type match on a base preset + // 4. "Generic " system preset + // `exact_match` reports whether step 1 or 2 succeeded, so callers can flag an approximate + // resolution to the user without blocking the operation. + // + // Purely local: never consults the cloud catalogue or RFID data, so it behaves identically + // in LAN mode. + std::optional resolve_filament_for_spool(const std::string& stored_id, + const std::string& vendor, + const std::string& material_type, + bool* exact_match = nullptr) const; + // Load support recommended params from JSON file void load_support_recommended_params(); // Get support recommended params by (support_material, model_material) diff --git a/src/slic3r/GUI/AMSMaterialsSetting.cpp b/src/slic3r/GUI/AMSMaterialsSetting.cpp index 9d918c510f..7f6823e0bf 100644 --- a/src/slic3r/GUI/AMSMaterialsSetting.cpp +++ b/src/slic3r/GUI/AMSMaterialsSetting.cpp @@ -769,7 +769,17 @@ void AMSMaterialsSetting::on_select_ok(wxCommandEvent& event) auto* store = wxGetApp().fila_manager_store(); const FilamentSpool* sp = store ? store->get_spool(m_selected_spool_id) : nullptr; if (sp) { - filament_item.filament_id = sp->setting_id; + // GitHub #11937: resolve through filament_id > setting_id > + // vendor+type > "Generic " instead of assuming + // sp->setting_id is already a valid Preset::filament_id, so a + // spool with a cloud user-settings id or a free-typed brand can + // still be confirmed into the AMS slot. + std::string resolved_filament_id = sp->setting_id; + if (auto* bundle = wxGetApp().preset_bundle) { + if (auto info = bundle->resolve_filament_for_spool(sp->setting_id, sp->brand, sp->material_type)) + resolved_filament_id = info->filament_id; + } + filament_item.filament_id = resolved_filament_id; filament_item.setting_id = sp->setting_id; filament_item.spool_id = sp->spool_id; } @@ -1554,8 +1564,14 @@ static void _populate_filament_combobox_grouped( for (const auto& spool_id : store->all_spool_ids()) { const Slic3r::GUI::FilamentSpool* sp = store->get_spool(spool_id); if (!sp) continue; + // GitHub #11937: a manually-added spool may carry a cloud + // user-settings id (or nothing, for a free-typed third-party + // brand) in setting_id instead of a real Preset::filament_id. + // Fall back through vendor+type and "Generic " so those + // spools become selectable instead of being bucketed as + // "Unsupported Filaments" forever. bool has_preset = bundle && - bundle->get_filament_by_filament_id(sp->setting_id).has_value(); + bundle->resolve_filament_for_spool(sp->setting_id, sp->brand, sp->material_type).has_value(); if (has_preset) { wxString brand = sp->brand.empty() ? other_bucket : wxString::FromUTF8(sp->brand); lib_brand_to_spools[brand].push_back(*sp); @@ -2457,7 +2473,10 @@ void AMSMaterialsSetting::on_select_filament(wxCommandEvent &evt) auto* store = wxGetApp().fila_manager_store(); const FilamentSpool* sp = store ? store->get_spool(m_selected_spool_id) : nullptr; if (sp && preset_bundle) { - auto fila_info = preset_bundle->get_filament_by_filament_id(sp->setting_id); + // GitHub #11937: same tolerant resolution as on_select_ok() — + // sp->setting_id may be a cloud user-settings id or empty + // rather than a real Preset::filament_id. + auto fila_info = preset_bundle->resolve_filament_for_spool(sp->setting_id, sp->brand, sp->material_type); if (fila_info.has_value()) { ams_filament_id = fila_info->filament_id; ams_setting_id = fila_info->setting_id; diff --git a/src/slic3r/GUI/DeviceWeb/device_page/src/features/filament-manager/AddEditDialog.tsx b/src/slic3r/GUI/DeviceWeb/device_page/src/features/filament-manager/AddEditDialog.tsx index 2569234e1a..a2a5c62275 100644 --- a/src/slic3r/GUI/DeviceWeb/device_page/src/features/filament-manager/AddEditDialog.tsx +++ b/src/slic3r/GUI/DeviceWeb/device_page/src/features/filament-manager/AddEditDialog.tsx @@ -191,9 +191,9 @@ export function AddEditDialog({ // path. Both fields mirror FilamentSpool.colors / .color_type (swagger // semantics: 0=gradient / 1=multicolor / 2=single). They stay in sync with // colorCode: picking a candidate from FilamentColorCodeQuery seeds all - // four (color_code, colors[], color_type, color_name); picking a plain - // custom hex via the "+" picker resets colors=[] and color_type=2 so the - // outgoing spool is unambiguously single-colour. + // four (color_code, colors[], color_type, color_name); the "+" picker can + // either commit a plain custom hex as single-colour or assemble a custom + // multicolour palette and persist it into colors[] / color_type. const [colors, setColors] = useState([]); const [colorType, setColorType] = useState<0 | 1 | 2>(2); // STUDIO-17977 F1.3: BBL 官方耗材代码(如 "Q01B00" / "13903"),来自 @@ -298,6 +298,8 @@ export function AddEditDialog({ setAmsLockedFields({ brand: false, material: false, color: false, weight: false }); setAmsData(null); setAmsError(''); + setColorPickerOpen(false); + setCustomPaletteDraft([]); // STUDIO-17977 F1.3: forget the previous dialog session's fila_id so // the alignment effect treats this open as a fresh first observation // and does not snap colours that the parent just seeded via initSpool @@ -518,20 +520,50 @@ export function AddEditDialog({ return null; }, [presets, brand, materialType, series]); + // GitHub #11937: when nothing above resolves a real Preset::filament_id + // (free-typed third-party brand, or a brand/series combination with no + // matching preset item at all), fall back to the shipped + // "Generic " system preset. This is what makes a hand-typed + // third-party filament usable in the AMS: without it, setting_id is + // persisted empty, which is indistinguishable on the C++ side from "no + // filament_id at all" and renders the spool as unselectable "Unsupported + // Filaments" in the AMS slot dialog. + const genericFallbackFilamentId = useMemo(() => { + if (!materialType) return ''; + const genericName = `generic ${materialType}`.toLowerCase(); + for (const vendor of presets) { + for (const tp of vendor.types) { + if (tp.name !== materialType) continue; + for (const item of tp.items || []) { + if (item.is_user) continue; + const name = (item.name || '').toLowerCase(); + if (name.startsWith(genericName) && item.filament_id) return item.filament_id; + } + } + } + return ''; + }, [presets, materialType]); + // STUDIO-17977 / Task 10: drive the colour palette from // `filament.colors.query_for_id`. The same fila_id resolution as - // handleSubmit (cloud filamentId > preset setting_id > preset filament_id) - // is reused here so the candidate query keys off whatever the spool will - // actually serialise as `setting_id` on save. + // handleSubmit (cloud filamentId > preset filament_id > preset setting_id + // > Generic fallback) is reused here so the candidate query keys + // off whatever the spool will actually serialise as `setting_id` on save. + // + // GitHub #11937: filament_id now takes priority over setting_id — a + // preset's setting_id is a cloud user-settings id that never matches + // Preset::filament_id on the C++ side, whereas filament_id is exactly + // what get_filament_by_filament_id() / the AMS gate compare against. const filaId = useMemo( () => ( matchedCloudFilamentId - || matchedPresetItem?.setting_id || matchedPresetItem?.filament_id + || matchedPresetItem?.setting_id || initSpool?.setting_id + || genericFallbackFilamentId || '' ), - [matchedCloudFilamentId, matchedPresetItem, initSpool?.setting_id], + [matchedCloudFilamentId, matchedPresetItem, initSpool?.setting_id, genericFallbackFilamentId], ); const candidatesByFilaId = useStore((s) => s.filament.candidatesByFilaId); const setColorCandidates = useStore((s) => s.filament.setColorCandidates); @@ -973,45 +1005,92 @@ export function AddEditDialog({ // which the native picker fires while the user is still dragging the hue // slider. That overwrote the form's color_code (and dirtied customColors) // long before the user actually decided on a color. - // Now the popover holds a local `draftColor`; only OK calls - // commitCustomColorSelection() to write it back to the form. Cancel and - // ESC discard the draft. Outside clicks intentionally keep the popover open - // so users do not lose a selected custom color by clicking blank space. + // Now the popover holds a local `draftColor`; OK still commits a plain + // single colour, while "Add to palette" / "Done" can assemble a custom + // multicolour set. Cancel and ESC discard the draft. Outside clicks + // intentionally keep the popover open so users do not lose a selected + // custom color by clicking blank space. const [colorPickerOpen, setColorPickerOpen] = useState(false); const [draftColor, setDraftColor] = useState('#000000'); + const [customPaletteDraft, setCustomPaletteDraft] = useState([]); const colorPickerRef = useRef(null); const nativeColorInputRef = useRef(null); + const applySingleCustomColor = useCallback((value: string) => { + const next = commitCustomColorSelection(value, customColors, BAMBU_COLORS); + setColorCode(next.colorCode); + setCustomColors(next.customColors); + // Single-colour commit intentionally clears any prior gradient / + // multicolor payload so the saved spool matches the swatch the user + // just confirmed. + setColors([]); + setColorType(2); + // A freshly hand-picked colour has no preset name or BBL official code; + // clear both so the preview row reflects the free-picker selection. + setColorName(''); + setFilaColorCode(''); + userTouchedColorRef.current = true; + setCustomPaletteDraft([]); + setColorPickerOpen(false); + }, [customColors]); + + const addDraftColorToPalette = useCallback(() => { + const next = commitCustomColorSelection(draftColor, customColors, BAMBU_COLORS); + if (!next.colorCode) return; + setCustomColors(next.customColors); + setCustomPaletteDraft((prev) => ( + prev.some((c) => c.toUpperCase() === next.colorCode.toUpperCase()) + ? prev + : [...prev, next.colorCode] + )); + }, [draftColor, customColors]); + + const removeDraftPaletteColor = useCallback((hex: string) => { + setCustomPaletteDraft((prev) => prev.filter((c) => c.toUpperCase() !== hex.toUpperCase())); + }, []); + + const commitCustomPaletteDraft = useCallback(() => { + if (customPaletteDraft.length === 0) return; + if (customPaletteDraft.length === 1) { + applySingleCustomColor(customPaletteDraft[0]); + return; + } + const nextCustomColors = customPaletteDraft.reduce( + (acc, hex) => commitCustomColorSelection(hex, acc, BAMBU_COLORS).customColors, + customColors, + ); + setColorCode(customPaletteDraft[0]); + setCustomColors(nextCustomColors); + setColors([...customPaletteDraft]); + setColorType(1); + setColorName(''); + setFilaColorCode(''); + userTouchedColorRef.current = true; + setCustomPaletteDraft([]); + setColorPickerOpen(false); + }, [customPaletteDraft, customColors, applySingleCustomColor]); + + const draftPaletteContainsColor = customPaletteDraft.some( + (c) => c.toUpperCase() === (draftColor || '').toUpperCase(), + ); + // The trigger button is rendered with `disabled={lockColor}` so a locked // AMS color cannot start a draft session in the first place; we therefore // don't need to re-check lockColor here. const openColorPicker = useCallback(() => { - setDraftColor(colorCode || '#000000'); + setDraftColor(colorCode || colors[0] || '#000000'); + setCustomPaletteDraft(colors.length > 1 ? [...colors] : []); setColorPickerOpen(true); - }, [colorCode]); + }, [colorCode, colors]); const cancelCustomColor = useCallback(() => { + setCustomPaletteDraft([]); setColorPickerOpen(false); }, []); const confirmCustomColor = useCallback(() => { - const next = commitCustomColorSelection(draftColor, customColors, BAMBU_COLORS); - setColorCode(next.colorCode); - setCustomColors(next.customColors); - // Custom-picker is single-colour only: drop any prior gradient / - // multicolor selection so the saved spool matches the swatch the user - // just confirmed. - setColors([]); - setColorType(2); - // STUDIO-17977 F1.3: a freshly hand-picked colour has no preset name - // or BBL official code; clear both so the preview row reflects the - // free-picker selection. Resolver effect upgrades them back if the - // hex happens to coincide with a single-colour candidate. - setColorName(''); - setFilaColorCode(''); - userTouchedColorRef.current = true; - setColorPickerOpen(false); - }, [draftColor, customColors]); + applySingleCustomColor(draftColor); + }, [draftColor, applySingleCustomColor]); // Esc key is treated as Cancel; outside clicks do not dismiss the picker // because that made the chosen color disappear before users could confirm. @@ -1089,11 +1168,11 @@ export function AddEditDialog({ net_weight: currentNetWeight, remain_percent: remainPct, note, - setting_id: matchedCloudFilamentId - || matchedPresetItem?.setting_id - || matchedPresetItem?.filament_id - || initSpool?.setting_id - || '', + // GitHub #11937: reuse the same resolution order as `filaId` above + // (cloud filamentId > preset filament_id > preset setting_id > + // Generic fallback) so setting_id is never persisted empty for + // a hand-typed third-party brand. + setting_id: filaId, }; // STUDIO-18340: emit `colors: []` for single-colour edits so an AMS-read @@ -2268,7 +2347,7 @@ export function AddEditDialog({
{/* STUDIO-18114: Custom-color picker — click "+" to open a draft popover; the form's color is only updated after - the user explicitly confirms with OK. */} + the user explicitly confirms with OK / Done. */}
-
+ {customPaletteDraft.length > 0 && ( +
+ + {t('Palette')} + +
+ {customPaletteDraft.map((hex) => ( +
+ + + {hex.toUpperCase()} + + +
+ ))} +
+
+ )} +
+ + {customPaletteDraft.length > 0 && ( + + )}
)} diff --git a/src/slic3r/GUI/GUI_App.cpp b/src/slic3r/GUI/GUI_App.cpp index a87793f5c2..abdff61cc6 100644 --- a/src/slic3r/GUI/GUI_App.cpp +++ b/src/slic3r/GUI/GUI_App.cpp @@ -2283,20 +2283,26 @@ void GUI_App::init_networking_callbacks() if (MachineObject* obj = this->m_device_manager->get_user_machine(dev_id)) { auto sel = this->m_device_manager->get_selected_machine(); - if (sel && sel->get_dev_id() == dev_id) { - obj->parse_json("cloud", msg); + const bool is_selected = sel && sel->get_dev_id() == dev_id; + obj->parse_json("cloud", msg, !is_selected); + if (is_selected) { GUI::wxGetApp().sidebar().load_ams_list(obj); - // STUDIO-18155: AMS 状态变化驱动耗材同步(本地 store + 节流后云端) - // 仅在在位字段实际变化时才推 spool list,避免每条 MQTT 都整体重渲。 - bool fila_mount_changed = false; - if (auto* sync = wxGetApp().fila_manager_sync()) - fila_mount_changed = sync->on_device_update(obj); - if (!m_disable_fila_manager && mainframe && mainframe->web_device()) { - if (fila_mount_changed) - mainframe->web_device()->NotifyFilamentSessionState(); - } - } else { - obj->parse_json("cloud", msg, true); + } + // GitHub #11937: fila sync (and, in particular, the print-FINISH + // consumption deduction it drives) must run for *every* device + // update, not only the currently-selected machine — otherwise a + // print finishing on a printer the user isn't currently viewing + // never gets its filament weight deducted, since the FINISH + // transition is only observed while on_device_update() is + // actually called for that dev_id. + // STUDIO-18155: AMS 状态变化驱动耗材同步(本地 store + 节流后云端) + // 仅在在位字段实际变化时才推 spool list,避免每条 MQTT 都整体重渲。 + bool fila_mount_changed = false; + if (auto* sync = wxGetApp().fila_manager_sync()) + fila_mount_changed = sync->on_device_update(obj); + if (!m_disable_fila_manager && mainframe && mainframe->web_device()) { + if (fila_mount_changed) + mainframe->web_device()->NotifyFilamentSessionState(); } } @@ -2343,17 +2349,21 @@ void GUI_App::init_networking_callbacks() if (MachineObject* obj = m_device_manager->get_my_machine(dev_id)) { obj->parse_json("lan", msg); - if (this->m_device_manager->get_selected_machine() == obj) { + const bool is_selected = this->m_device_manager->get_selected_machine() == obj; + if (is_selected) { GUI::wxGetApp().sidebar().load_ams_list(obj); - // STUDIO-18155: AMS 状态变化驱动耗材同步(本地 store + 节流后云端) - // 仅在在位字段实际变化时才推 spool list,避免每条 MQTT 都整体重渲。 - bool fila_mount_changed = false; - if (auto* sync = wxGetApp().fila_manager_sync()) - fila_mount_changed = sync->on_device_update(obj); - if (!m_disable_fila_manager && mainframe && mainframe->web_device()) { - if (fila_mount_changed) - mainframe->web_device()->NotifyFilamentSessionState(); - } + } + // GitHub #11937: same reasoning as the cloud-message handler + // above — fila sync must run for every device, not only the + // one currently selected in the UI. + // STUDIO-18155: AMS 状态变化驱动耗材同步(本地 store + 节流后云端) + // 仅在在位字段实际变化时才推 spool list,避免每条 MQTT 都整体重渲。 + bool fila_mount_changed = false; + if (auto* sync = wxGetApp().fila_manager_sync()) + fila_mount_changed = sync->on_device_update(obj); + if (!m_disable_fila_manager && mainframe && mainframe->web_device()) { + if (fila_mount_changed) + mainframe->web_device()->NotifyFilamentSessionState(); } } @@ -3497,6 +3507,13 @@ bool GUI_App::on_init_inner() m_fila_manager_cloud_client); BOOST_LOG_TRIVIAL(info) << "Filament Manager cloud dispatcher initialized"; } + + // Populate the spool store on startup so print weight tracking works + // even if the user never opens the Filament Manager page this run. + // The dispatcher skips the pull silently when no login token is + // available (e.g. LAN-only mode); FM page open and fresh login also + // trigger pulls, and duplicate pulls are deduped by the queue. + m_fila_manager_cloud_disp->enqueue_pull(); } BOOST_LOG_TRIVIAL(info) << "create the main window"; diff --git a/src/slic3r/GUI/SelectMachine.cpp b/src/slic3r/GUI/SelectMachine.cpp index edc22f90d1..ff517413a8 100644 --- a/src/slic3r/GUI/SelectMachine.cpp +++ b/src/slic3r/GUI/SelectMachine.cpp @@ -50,6 +50,7 @@ #include #include #include +#include #include "Plater.hpp" #include "Notebook.hpp" #include "BitmapCache.hpp" @@ -129,6 +130,21 @@ static int to_ams_time_index(DevAmsType type) } } +static std::string make_fila_manager_job_key(const MachineObject* obj) +{ + static uint64_t s_local_print_seq = 0; + + if (!obj) return {}; + + std::string anchor = "local"; + if (!obj->subtask_id_.empty() && obj->subtask_id_ != "0") + anchor = obj->subtask_id_; + else if (!obj->job_id_.empty() && obj->job_id_ != "0") + anchor = obj->job_id_; + + return obj->get_dev_id() + ":" + anchor + ":" + std::to_string(++s_local_print_seq); +} + static std::vector build_actual_ams_type_per_filament(const std::vector& mapping, MachineObject* obj) { int filament_count = 0; @@ -3375,6 +3391,26 @@ void SelectMachineDialog::on_send_print() BOOST_LOG_TRIVIAL(info) << "print_job: timelapse_option = " << timelapse_option; BOOST_LOG_TRIVIAL(info) << "print_job: use_ams = " << m_print_job->task_use_ams; + if (m_print_type == PrintFromType::FROM_NORMAL) { + std::map, double> used_by_slot; + if (build_slot_consumption_map(used_by_slot)) { + if (!used_by_slot.empty()) { + if (auto* store = wxGetApp().fila_manager_store()) { + const std::string job_key = make_fila_manager_job_key(obj_); + store->set_pending_consumption(obj_->get_dev_id(), used_by_slot, job_key); + BOOST_LOG_TRIVIAL(info) + << "[FilaManager] recorded pending consumption dev_id=" << obj_->get_dev_id() + << " slots=" << used_by_slot.size() + << " job_key=" << job_key; + } + } + } else { + BOOST_LOG_TRIVIAL(warning) + << "[FilaManager] failed to record pending print consumption for dev_id=" + << obj_->get_dev_id(); + } + } + m_print_job->on_success([this]() { finish_mode(); }); m_print_job->on_check_ip_address_fail([this]() { @@ -6394,6 +6430,46 @@ bool SelectMachineDialog::IsAllAmsSupportAccurateRemain(MachineObject* obj_) con return true; } +bool SelectMachineDialog::build_slot_consumption_map( + std::map, double>& used_by_slot) const +{ + used_by_slot.clear(); + + if (!m_plater) return true; + + GCodeProcessorResult* gcode_result = m_plater->background_process().get_current_gcode_result(); + if (!gcode_result) { + // GitHub #11937: this is a known, silent gap — reprints / cloud-only + // resends without a fresh local slice have no gcode result to derive + // grams-used from, so no pending consumption gets recorded for this + // send. Log at info level so this is diagnosable instead of the + // Filament Manager weight just silently never updating. + BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << " no current gcode result available, skipping consumption recording"; + return true; + } + + auto full_config = wxGetApp().preset_bundle->full_config(); + auto filament_densities = full_config.option("filament_density"); + if (!filament_densities) return true; + + const auto& densities = filament_densities->values; + const auto& volumes_map = gcode_result->print_statistics.total_volumes_per_extruder; + + for (const auto& fila : m_ams_mapping_result) { + auto vol_it = volumes_map.find(fila.id); + if (vol_it == volumes_map.end() || fila.id < 0 || static_cast(fila.id) >= densities.size()) { + BOOST_LOG_TRIVIAL(error) << __FUNCTION__ << " the list of filament densities can't find " << fila.id; + used_by_slot.clear(); + return false; + } + + std::pair key{fila.ams_id, fila.slot_id}; + used_by_slot[key] += densities[fila.id] * (vol_it->second / 1000.0); // mm^3 -> cm^3 + } + + return true; +} + // return true don't warning bool SelectMachineDialog::CheckWarningFilamentRemain(MachineObject* obj_) { @@ -6405,9 +6481,6 @@ bool SelectMachineDialog::CheckWarningFilamentRemain(MachineObject* obj_) if (!IsAllAmsSupportAccurateRemain(obj_)) return true; - auto full_config = wxGetApp().preset_bundle->full_config(); - auto filament_densities = full_config.option("filament_density"); - // key: ams_id slot_id value: remain std::map, double> fila_remain_map; //collect fila remain info std::map, FilamentInfo> fila_used_map; @@ -6429,31 +6502,23 @@ bool SelectMachineDialog::CheckWarningFilamentRemain(MachineObject* obj_) } } - // collect used filament weight + std::map, double> used_by_slot; + if (!build_slot_consumption_map(used_by_slot)) + return true; + for (const auto& fila : m_ams_mapping_result) { - if (GCodeProcessorResult* gcode_result = m_plater->background_process().get_current_gcode_result()) { - if (filament_densities) { - auto densities = filament_densities->values; - auto volumes_map = gcode_result->print_statistics.total_volumes_per_extruder; - if (volumes_map.find(fila.id) != volumes_map.end() && fila.id >= 0 && fila.id < densities.size()) { - std::pair key{fila.ams_id, fila.slot_id}; - double used_g = densities[fila.id] * (volumes_map[fila.id] / 1000); // mm^3 -> cm^3 - auto used_it = fila_used_map.find(key); - if (used_it == fila_used_map.end()) { - FilamentInfo info; - info.id = fila.id; - info.used_g = used_g; - fila_used_map[key] = info; - } else { - used_it->second.used_g += used_g; - } - fila_ids_in_slot[key].push_back(fila.id); - } else { - BOOST_LOG_TRIVIAL(error) << __FUNCTION__ << "the list of filament densities can't find "<< fila.id; - return true; - } - } - } + std::pair key{fila.ams_id, fila.slot_id}; + auto used_it = used_by_slot.find(key); + if (used_it == used_by_slot.end()) continue; + + auto fila_it = fila_used_map.find(key); + if (fila_it == fila_used_map.end()) { + FilamentInfo info; + info.id = fila.id; + info.used_g = used_it->second; + fila_used_map[key] = info; + } + fila_ids_in_slot[key].push_back(fila.id); } { diff --git a/src/slic3r/GUI/SelectMachine.hpp b/src/slic3r/GUI/SelectMachine.hpp index 494c6bb8f9..eaa43f2375 100644 --- a/src/slic3r/GUI/SelectMachine.hpp +++ b/src/slic3r/GUI/SelectMachine.hpp @@ -48,6 +48,8 @@ #include #include +#include + #define PRINT_OPT_BG_GRAY 0xF8F8F8 #define PRINT_OPT_ITEM_BG_GRAY 0xEEEEEE @@ -654,6 +656,7 @@ class SelectMachineDialog : public DPIDialog // enbale or disable external change assist bool is_enable_external_change_assist(std::vector& ams_mapping_result); + bool build_slot_consumption_map(std::map, double>& used_by_slot) const; // update time diff --git a/src/slic3r/GUI/fila_manager/wgtFilaManagerCloudDispatcher.cpp b/src/slic3r/GUI/fila_manager/wgtFilaManagerCloudDispatcher.cpp index 395374a4fc..73b7d9252f 100644 --- a/src/slic3r/GUI/fila_manager/wgtFilaManagerCloudDispatcher.cpp +++ b/src/slic3r/GUI/fila_manager/wgtFilaManagerCloudDispatcher.cpp @@ -364,6 +364,10 @@ void wgtFilaManagerCloudDispatcher::run_push_update_op(const std::string& spool_ if (auto* store = wxGetApp().fila_manager_store()) { store->apply_patch(spool_id, local_patch); store->mark_synced(spool_id, true); + // A weight push landing means the cloud now holds the + // locally deducted value — pulls may overwrite again. + if (local_patch.contains("net_weight")) + store->clear_weight_push_pending(spool_id); } update_last_synced_now(); wxGetApp().emit_fila_debug_log("data", "info", "Dispatcher push_update finished", @@ -373,18 +377,23 @@ void wgtFilaManagerCloudDispatcher::run_push_update_op(const std::string& spool_ on_op_done(); }); }, - [this, spool_id, create_body](int code, const std::string& err) { + [this, spool_id, create_body, local_patch](int code, const std::string& err) { if (code == 404) { // Fallback to create for the common "cloud has no record of // this local spool yet" case (e.g. the local row was created // while offline and the first update is effectively a create). BOOST_LOG_TRIVIAL(info) << "[CloudDispatcher] push_update 404, fallback create " << spool_id; m_client->create_spool(create_body, - [this, spool_id](const nlohmann::json&) { - wxTheApp->CallAfter([this, spool_id]() { + [this, spool_id, local_patch](const nlohmann::json&) { + wxTheApp->CallAfter([this, spool_id, local_patch]() { BOOST_LOG_TRIVIAL(info) << "[CloudDispatcher] push_update fallback create ok " << spool_id; if (auto* store = wxGetApp().fila_manager_store()) { store->mark_synced(spool_id, true); + // The full create body carries the current + // (deducted) netWeight — same as a direct + // weight push succeeding. + if (local_patch.contains("net_weight")) + store->clear_weight_push_pending(spool_id); } update_last_synced_now(); wxGetApp().emit_fila_debug_log("data", "info", "Dispatcher push_update fallback create finished", diff --git a/src/slic3r/GUI/fila_manager/wgtFilaManagerCloudSync.cpp b/src/slic3r/GUI/fila_manager/wgtFilaManagerCloudSync.cpp index 65d08cc22c..d2540066d2 100644 --- a/src/slic3r/GUI/fila_manager/wgtFilaManagerCloudSync.cpp +++ b/src/slic3r/GUI/fila_manager/wgtFilaManagerCloudSync.cpp @@ -13,7 +13,10 @@ #include #include #include +#include +#include #include +#include namespace Slic3r { namespace GUI { @@ -52,6 +55,12 @@ inline std::string err_body_tail(const std::string& err) #endif } +bool filament_exists_by_filament_id(const std::string& filament_id) +{ + return wxGetApp().preset_bundle + && wxGetApp().preset_bundle->get_filament_by_filament_id(filament_id).has_value(); +} + bool filament_is_support_by_setting_id(const std::string& setting_id) { bool is_support = false; @@ -102,6 +111,56 @@ std::string tray_id_name_by_filament_color(const FilamentSpool& s) return color_code.empty() ? std::string{} : s.setting_id.substr(2) + "-" + color_code; } +struct SettingIdMigrationResult { + bool attempted = false; + bool repaired = false; + std::string original_setting_id; +}; + +SettingIdMigrationResult migrate_manual_spool_setting_id(FilamentSpool& s) +{ + SettingIdMigrationResult result; + // GitHub #11937: entry_method is a comparatively recent field + // ("manual" | "ams_sync" | "rfid"). Cloud records created before it + // existed (or pulled through an older client) have it empty, and those + // are exactly the legacy manual entries most likely to carry a broken + // setting_id. Treat empty the same as "manual" so legacy libraries get + // repaired too; only genuinely non-manual sources (ams_sync/rfid) are + // skipped, since those ids come from the printer/RFID and shouldn't be + // rewritten. + if (s.entry_method != "manual" && !s.entry_method.empty()) + return result; + if (s.setting_id_migration_version >= kFilamentSpoolSettingIdMigrationVersion) + return result; + + auto* bundle = wxGetApp().preset_bundle; + if (!bundle) + return result; + if (filament_exists_by_filament_id(s.setting_id)) + return result; + + result.attempted = true; + result.original_setting_id = s.setting_id; + + bool exact_match = false; + auto resolved = bundle->resolve_filament_for_spool(s.setting_id, + s.brand, + s.material_type, + &exact_match); + s.setting_id_migration_version = kFilamentSpoolSettingIdMigrationVersion; + if (!resolved.has_value() || resolved->filament_id.empty()) + return result; + + s.setting_id = resolved->filament_id; + result.repaired = true; + BOOST_LOG_TRIVIAL(info) + << "[FilaCloudSync] repaired manual spool setting_id spool_id=" << s.spool_id + << " old_setting_id=" << result.original_setting_id + << " new_setting_id=" << s.setting_id + << " exact_match=" << (exact_match ? 1 : 0); + return result; +} + } // namespace wgtFilaManagerCloudSync::wgtFilaManagerCloudSync( @@ -365,91 +424,40 @@ void wgtFilaManagerCloudSync::pull_from_cloud() "Starting pull_from_cloud merge", nlohmann::json::object()); - m_client->list_spools({}, - [this](const nlohmann::json& data) { - wxTheApp->CallAfter([this, data]() { - try { - // Cloud ListFilamentV2Resp returns { total, hits: [...] } - // at the root; tolerate a few alternative shapes for - // forward/backward compatibility. - nlohmann::json list = extract_cloud_list(data); - - // Cloud is the source of truth: collect every cloud id we - // are about to keep, then rewrite the local store to match - // exactly that set. Local-only entries (e.g. pushes that - // never succeeded) are dropped on purpose so a pull always - // leaves the local list in sync with the latest cloud - // snapshot. - std::set cloud_ids; - int dropped_local_only = 0; - - // 判断某台机器当前是否在线(有实时 MQTT 数据)。 - // 用 get_my_machine 而非 get_user_machine,避免跨账号误判。 - auto machine_is_online = [](const std::string& dev_id) -> bool { - if (dev_id.empty()) return false; - auto* mgr = wxGetApp().getDeviceManager(); - if (!mgr) return false; - MachineObject* obj = mgr->get_my_machine(dev_id); - return obj && obj->is_online(); - }; - - for (const auto& item : list) { - FilamentSpool cloud_spool = cloud_json_to_spool(item); - if (cloud_spool.spool_id.empty()) continue; - cloud_spool.cloud_synced = true; - cloud_ids.insert(cloud_spool.spool_id); - - if (const FilamentSpool* existing = m_store->get_spool(cloud_spool.spool_id)) { - // 判断本地是否有来自该机器的实时 MQTT 数据。 - // 条件:existing->dev_id 对应的机器当前在线。 - // 不单独判断 in_printer==true,因为断连后该值仍可能为 true。 - const bool local_is_live = machine_is_online(existing->dev_id); - if (local_is_live) { - // 机器在线时以本地为准,保留本地在位字段,不用云端值覆盖。 - // 不在 pull 里反向 push 修正——下次 MQTT 到来时 - // notify_ams_synced 会把最新在位字段推上云端。 - cloud_spool.in_printer = existing->in_printer; - cloud_spool.dev_id = existing->dev_id; - cloud_spool.ams_sn = existing->ams_sn; - cloud_spool.ams_id = existing->ams_id; - cloud_spool.ams_type = existing->ams_type; - cloud_spool.slot_id = existing->slot_id; - cloud_spool.device_name = existing->device_name; - } - // local_is_live==false:云端在位字段直接作为历史数据落地 - m_store->update_spool(cloud_spool); - } else { - m_store->add_spool(cloud_spool); - } - } + fetch_all_spool_pages(0, std::make_shared(nlohmann::json::array())); +} - for (const auto& existing : m_store->spools_to_json()) { - const std::string existing_id = existing.value("spool_id", ""); - if (existing_id.empty()) continue; - if (cloud_ids.count(existing_id) == 0) { - m_store->remove_spool(existing_id); - ++dropped_local_only; - } - } +// GitHub #11937: list_spools() defaults to limit=20 per page +// (wgtFilaManagerCloudClient::list_spools). The old single-shot call here +// silently truncated any account with more than 20 spools to just the first +// page, which also meant the setting_id migration in merge_pulled_spools() +// never ran on spool #21 onward. Walk every page (offset += kPageSize) until +// the cloud returns a short/empty page, then merge the full accumulated list +// in one shot so the "drop anything not in cloud_ids" cleanup below still +// only sees a complete picture. +void wgtFilaManagerCloudSync::fetch_all_spool_pages(int offset, std::shared_ptr accumulated) +{ + constexpr int kPageSize = 200; + const std::map query{ + {"offset", std::to_string(offset)}, + {"limit", std::to_string(kPageSize)}, + }; - m_last_pull_succeeded = true; - BOOST_LOG_TRIVIAL(info) << "[FilaCloudSync] pull_from_cloud completed, " - << list.size() << " items kept, " - << dropped_local_only << " local-only entries dropped"; - wxGetApp().emit_fila_debug_log("data", "info", "Cloud pull merged", - "Cloud pull overwrote local store", - {{"count", static_cast(list.size())}, - {"dropped_local_only", dropped_local_only}}); - } catch (const std::exception& e) { - m_last_pull_succeeded = false; - m_last_pull_error_code = -1; - m_last_pull_error_message = e.what(); - BOOST_LOG_TRIVIAL(error) << "[FilaCloudSync] pull_from_cloud merge error: " << e.what(); - wxGetApp().emit_fila_debug_log("data", "error", "Cloud pull merge error", - "Merging cloud pull result into local store failed", - {{"error", e.what()}}); + m_client->list_spools(query, + [this, offset, accumulated, kPageSize](const nlohmann::json& data) { + wxTheApp->CallAfter([this, offset, accumulated, data, kPageSize]() { + nlohmann::json page = extract_cloud_list(data); + const size_t page_size = page.size(); + for (auto& item : page) + accumulated->push_back(std::move(item)); + + // A short page (fewer items than requested) means this was + // the last page. An empty first page also stops immediately. + if (page_size < static_cast(kPageSize)) { + merge_pulled_spools(*accumulated); + return; } - m_syncing = false; + fetch_all_spool_pages(offset + kPageSize, accumulated); }); }, [this](int code, const std::string& err) { @@ -466,6 +474,128 @@ void wgtFilaManagerCloudSync::pull_from_cloud() }); } +void wgtFilaManagerCloudSync::merge_pulled_spools(const nlohmann::json& list) +{ + try { + // Cloud is the source of truth: collect every cloud id we + // are about to keep, then rewrite the local store to match + // exactly that set. Local-only entries (e.g. pushes that + // never succeeded) are dropped on purpose so a pull always + // leaves the local list in sync with the latest cloud + // snapshot. + std::set cloud_ids; + int dropped_local_only = 0; + + // 判断某台机器当前是否在线(有实时 MQTT 数据)。 + // 用 get_my_machine 而非 get_user_machine,避免跨账号误判。 + auto machine_is_online = [](const std::string& dev_id) -> bool { + if (dev_id.empty()) return false; + auto* mgr = wxGetApp().getDeviceManager(); + if (!mgr) return false; + MachineObject* obj = mgr->get_my_machine(dev_id); + return obj && obj->is_online(); + }; + + auto* dispatcher = wxGetApp().fila_manager_cloud_disp(); + for (const auto& item : list) { + FilamentSpool cloud_spool = cloud_json_to_spool(item); + if (cloud_spool.spool_id.empty()) continue; + const FilamentSpool* existing = m_store->get_spool(cloud_spool.spool_id); + // setting_id_migration_version is local-only bookkeeping. + // While a repaired cloud PUT is still queued/in flight, + // keep the already-attempted local state instead of + // reintroducing the raw broken cloud id on the next pull. + const bool keep_existing_migration_state = existing + && (existing->entry_method == "manual" || existing->entry_method.empty()) + && existing->setting_id_migration_version >= kFilamentSpoolSettingIdMigrationVersion + && !filament_exists_by_filament_id(cloud_spool.setting_id) + && (filament_exists_by_filament_id(existing->setting_id) + || (existing->setting_id == cloud_spool.setting_id + && existing->brand == cloud_spool.brand + && existing->material_type == cloud_spool.material_type)); + if (keep_existing_migration_state) { + cloud_spool.setting_id = existing->setting_id; + cloud_spool.setting_id_migration_version = existing->setting_id_migration_version; + } + const SettingIdMigrationResult migration = migrate_manual_spool_setting_id(cloud_spool); + cloud_spool.cloud_synced = true; + cloud_ids.insert(cloud_spool.spool_id); + + if (existing) { + // 判断本地是否有来自该机器的实时 MQTT 数据。 + // 条件:existing->dev_id 对应的机器当前在线。 + // 不单独判断 in_printer==true,因为断连后该值仍可能为 true。 + const bool local_is_live = machine_is_online(existing->dev_id); + if (local_is_live) { + // 机器在线时以本地为准,保留本地在位字段,不用云端值覆盖。 + // 不在 pull 里反向 push 修正——下次 MQTT 到来时 + // notify_ams_synced 会把最新在位字段推上云端。 + cloud_spool.in_printer = existing->in_printer; + cloud_spool.dev_id = existing->dev_id; + cloud_spool.ams_sn = existing->ams_sn; + cloud_spool.ams_id = existing->ams_id; + cloud_spool.ams_type = existing->ams_type; + cloud_spool.slot_id = existing->slot_id; + cloud_spool.device_name = existing->device_name; + } + // local_is_live==false:云端在位字段直接作为历史数据落地 + // GitHub #11937 weight tracking: a local print-FINISH + // deduction that hasn't been pushed yet must not be reverted + // by the stale cloud net_weight (this was the "weight never + // updates" symptom: deduct → pull 12s later → overwrite). + if (existing->weight_push_pending) { + if (existing->net_weight != cloud_spool.net_weight) { + BOOST_LOG_TRIVIAL(info) + << "[FilaCloudSync] keeping locally deducted weight over cloud spool_id=" + << existing->spool_id + << " local_net_weight=" << existing->net_weight + << " cloud_net_weight=" << cloud_spool.net_weight; + } + cloud_spool.net_weight = existing->net_weight; + cloud_spool.remain_percent = existing->remain_percent; + cloud_spool.status = existing->status; + } + m_store->update_spool(cloud_spool); + } else { + m_store->add_spool(cloud_spool); + } + + if (migration.repaired && dispatcher) { + dispatcher->enqueue_push_update( + cloud_spool.spool_id, + nlohmann::json{{"setting_id", cloud_spool.setting_id}}); + } + } + + for (const auto& existing : m_store->spools_to_json()) { + const std::string existing_id = existing.value("spool_id", ""); + if (existing_id.empty()) continue; + if (cloud_ids.count(existing_id) == 0) { + m_store->remove_spool(existing_id); + ++dropped_local_only; + } + } + + m_last_pull_succeeded = true; + BOOST_LOG_TRIVIAL(info) << "[FilaCloudSync] pull_from_cloud completed, " + << list.size() << " items kept, " + << dropped_local_only << " local-only entries dropped"; + wxGetApp().emit_fila_debug_log("data", "info", "Cloud pull merged", + "Cloud pull overwrote local store", + {{"count", static_cast(list.size())}, + {"dropped_local_only", dropped_local_only}}); + } catch (const std::exception& e) { + m_last_pull_succeeded = false; + m_last_pull_error_code = -1; + m_last_pull_error_message = e.what(); + BOOST_LOG_TRIVIAL(error) << "[FilaCloudSync] pull_from_cloud merge error: " << e.what(); + wxGetApp().emit_fila_debug_log("data", "error", "Cloud pull merge error", + "Merging cloud pull result into local store failed", + {{"error", e.what()}}); + } + m_syncing = false; +} + // --------------------------------------------------------------------------- // Push: local → cloud // --------------------------------------------------------------------------- diff --git a/src/slic3r/GUI/fila_manager/wgtFilaManagerCloudSync.h b/src/slic3r/GUI/fila_manager/wgtFilaManagerCloudSync.h index 9a58503a9c..adf5f9ea8e 100644 --- a/src/slic3r/GUI/fila_manager/wgtFilaManagerCloudSync.h +++ b/src/slic3r/GUI/fila_manager/wgtFilaManagerCloudSync.h @@ -3,6 +3,7 @@ #include #include +#include #include #include #include "nlohmann/json.hpp" @@ -94,6 +95,16 @@ class wgtFilaManagerCloudSync { static FilamentSpool cloud_json_to_spool(const nlohmann::json& j); private: + // GitHub #11937: list_spools() is paginated by the cloud API (default + // limit=20 — see wgtFilaManagerCloudClient::list_spools). A single + // unpaginated call silently truncated any library with more than 20 + // spools, which also meant the setting_id migration below it never saw + // spool #21 onward. fetch_all_spool_pages() walks every page (accumulating + // into `accumulated`) before handing the full merged list to + // merge_pulled_spools(). + void fetch_all_spool_pages(int offset, std::shared_ptr accumulated); + void merge_pulled_spools(const nlohmann::json& list); + wgtFilaManagerStore* m_store; wgtFilaManagerCloudClient* m_client; bool m_syncing = false; diff --git a/src/slic3r/GUI/fila_manager/wgtFilaManagerStore.cpp b/src/slic3r/GUI/fila_manager/wgtFilaManagerStore.cpp index 43abdd4b5f..0c7576edc9 100644 --- a/src/slic3r/GUI/fila_manager/wgtFilaManagerStore.cpp +++ b/src/slic3r/GUI/fila_manager/wgtFilaManagerStore.cpp @@ -9,7 +9,9 @@ #include #include +#include #include +#include #include #include #include @@ -68,7 +70,9 @@ nlohmann::json FilamentSpool::to_json() const {"note", note}, {"favorite", favorite}, {"net_weight", net_weight}, + {"last_deducted_job_key", last_deducted_job_key}, {"cloud_synced", cloud_synced}, + {"setting_id_migration_version", setting_id_migration_version}, {"in_printer", in_printer}, {"dev_id", dev_id}, {"ams_sn", ams_sn}, @@ -121,7 +125,9 @@ FilamentSpool FilamentSpool::from_json(const nlohmann::json& j) get("note", s.note); get("favorite", s.favorite); get("net_weight", s.net_weight); + get("last_deducted_job_key", s.last_deducted_job_key); get("cloud_synced", s.cloud_synced); + get("setting_id_migration_version", s.setting_id_migration_version); get("in_printer", s.in_printer); get("dev_id", s.dev_id); get("ams_sn", s.ams_sn); @@ -184,6 +190,11 @@ void wgtFilaManagerStore::update_spool(const FilamentSpool& spool) auto it = m_spools.find(spool.spool_id); if (it == m_spools.end()) return; FilamentSpool s = spool; + if (s.setting_id_migration_version < it->second.setting_id_migration_version) + s.setting_id_migration_version = it->second.setting_id_migration_version; + // weight_push_pending is local-only bookkeeping; cloud data carries no + // such concept, so it must survive a wholesale pull-merge overwrite. + s.weight_push_pending = it->second.weight_push_pending; s.updated_at = now_iso8601(); it->second = std::move(s); m_dirty = true; @@ -203,7 +214,8 @@ bool wgtFilaManagerStore::update_spool_if_changed(const FilamentSpool& sp) FilamentSpool& cur = it->second; // 仅比较"sync 关心字段"。identity 字段(spool_id / tag_uid / color_code / - // setting_id / entry_method / created_at / cloud_synced)和元字段 + // setting_id / entry_method / created_at / cloud_synced / + // setting_id_migration_version)和元字段 // (brand / material_type / series / color_name / diameter / // initial_weight / spool_weight / total_net_weight / note / favorite) // 由 sync 完全不动,比较时直接忽略输入 sp 的对应字段。 @@ -235,8 +247,9 @@ bool wgtFilaManagerStore::apply_patch(const std::string& spool_id, const nlohman FilamentSpool& s = it->second; // 仅合并用户可编辑字段;不接受 null(表示"未提供")。系统字段 spool_id / - // tag_uid / entry_method / created_at / bound_* / cloud_synced 故意不在此处 - // 合并,避免前端 patch 清掉它们(见 STUDIO-17964 Problem A)。 + // tag_uid / entry_method / created_at / bound_* / cloud_synced / + // setting_id_migration_version 故意不在此处合并,避免前端 patch 清掉它们 + // (见 STUDIO-17964 Problem A)。 auto get_if = [&](const char* key, auto& dst) { if (!patch.contains(key)) return; const auto& v = patch.at(key); @@ -308,6 +321,83 @@ std::vector wgtFilaManagerStore::all_spool_ids() const return ids; } +bool wgtFilaManagerStore::deduct_consumption(const std::string& spool_id, + double used_g, + const std::string& job_key) +{ + if (spool_id.empty() || job_key.empty() || used_g <= 0.0) return false; + + auto it = m_spools.find(spool_id); + if (it == m_spools.end()) return false; + + FilamentSpool& s = it->second; + if (s.last_deducted_job_key == job_key) { + BOOST_LOG_TRIVIAL(info) + << "[FilaManager] skip duplicate deduction spool_id=" << spool_id + << " job_key=" << job_key; + return false; + } + + const double prev_net_weight = s.net_weight; + const int prev_remain = s.remain_percent; + const std::string prev_status = s.status; + + s.net_weight = std::max(0.0, s.net_weight - used_g); + + const double total_nw = s.effective_total_net_weight(); + if (total_nw > 0.0) { + const double pct = std::max(0.0, std::min(100.0, s.net_weight / total_nw * 100.0)); + s.remain_percent = static_cast(std::round(pct)); + } + + if (s.status != "archived") { + if (s.net_weight <= 0.0) + s.status = "empty"; + else if (s.remain_percent < 20) + s.status = "low"; + else + s.status = "active"; + } + + s.last_deducted_job_key = job_key; + s.updated_at = now_iso8601(); + // Weight changed locally but is not on the cloud yet — mark it so the + // next pull_from_cloud() merge keeps this value instead of reverting to + // the stale cloud net_weight, until the dispatcher confirms the push. + s.weight_push_pending = true; + m_dirty = true; + + return prev_net_weight != s.net_weight + || prev_remain != s.remain_percent + || prev_status != s.status; +} + +void wgtFilaManagerStore::clear_weight_push_pending(const std::string& spool_id) +{ + auto it = m_spools.find(spool_id); + if (it == m_spools.end()) return; + it->second.weight_push_pending = false; +} + +void wgtFilaManagerStore::set_pending_consumption( + const std::string& dev_id, + const std::map, double>& per_slot_used_g, + const std::string& job_key) +{ + if (dev_id.empty() || job_key.empty() || per_slot_used_g.empty()) return; + m_pending_consumption[dev_id] = PendingConsumption{job_key, per_slot_used_g}; +} + +std::optional wgtFilaManagerStore::take_pending_consumption(const std::string& dev_id) +{ + auto it = m_pending_consumption.find(dev_id); + if (it == m_pending_consumption.end()) return std::nullopt; + + PendingConsumption pending = std::move(it->second); + m_pending_consumption.erase(it); + return pending; +} + const FilamentSpool* wgtFilaManagerStore::find_by_setting_and_color( const std::string& setting_id, const std::string& color) const { diff --git a/src/slic3r/GUI/fila_manager/wgtFilaManagerStore.h b/src/slic3r/GUI/fila_manager/wgtFilaManagerStore.h index ee71e106ea..49a17c0a4e 100644 --- a/src/slic3r/GUI/fila_manager/wgtFilaManagerStore.h +++ b/src/slic3r/GUI/fila_manager/wgtFilaManagerStore.h @@ -2,7 +2,9 @@ #define slic3r_wgtFilaManagerStore_h_ #include +#include #include +#include #include #include "nlohmann/json.hpp" @@ -25,6 +27,13 @@ struct EjectedSlotSnapshot { std::string slot_id; }; +struct PendingConsumption { + std::string job_key; + std::map, double> per_slot_used_g; +}; + +inline constexpr int kFilamentSpoolSettingIdMigrationVersion = 1; + struct FilamentSpool { std::string spool_id; std::string setting_id; @@ -80,10 +89,21 @@ struct FilamentSpool { bool favorite = false; double net_weight = 0; + std::string last_deducted_job_key; + + // In-memory only (never serialized): set by deduct_consumption() when a + // local print-FINISH deduction changed net_weight, cleared by the cloud + // dispatcher once the new weight has been pushed successfully. While set, + // pull_from_cloud() must NOT overwrite net_weight / remain_percent / + // status with stale cloud values — otherwise a pull landing between the + // deduction and its push silently reverts the tracked weight. + bool weight_push_pending = false; // Cloud synchronization marker. Cloud is the source of truth: this flag // is true iff the spool was present in the latest cloud pull. bool cloud_synced = false; + // Internal bookkeeping for GitHub #11937 manual-spool setting_id repair. + int setting_id_migration_version = 0; nlohmann::json to_json() const; // to_json_with_runtime: 持久化字段 + 运行时在位快照,供 spools_to_json() 推送前端。 @@ -123,7 +143,8 @@ class wgtFilaManagerStore { // // 为防御 sync 路径污染 identity 字段(设计 Q5 + STUDIO-18117 教训), // 该方法**强制**用 store 既有 spool 的 identity 字段(spool_id / tag_uid / - // color_code / colors / color_type / setting_id / entry_method / created_at / cloud_synced) + // color_code / colors / color_type / setting_id / entry_method / created_at / + // cloud_synced / setting_id_migration_version) // 覆盖输入 sp 中的对应字段,再做比较与写入。即便 sync 误塞 identity, // store 也不会被改写。 // @@ -132,8 +153,9 @@ class wgtFilaManagerStore { bool update_spool_if_changed(const FilamentSpool& sp); // Selectively merge user-editable fields from `patch` into the existing // spool without touching system-managed metadata (spool_id / tag_uid / - // entry_method / created_at / bound_* / cloud_synced). Returns true if an - // existing spool was updated. + // entry_method / created_at / bound_* / cloud_synced / + // setting_id_migration_version). Returns true if an existing spool was + // updated. bool apply_patch(const std::string& spool_id, const nlohmann::json& patch); void remove_spool(const std::string& spool_id); const FilamentSpool* get_spool(const std::string& spool_id) const; @@ -160,6 +182,23 @@ class wgtFilaManagerStore { // 配合 get_spool 遍历做 push_all_now / 全量同步等批量操作。 std::vector all_spool_ids() const; + // 本地打印完成后的扣减入口。与 AMS 自动同步分离,避免复用 + // update_spool_if_changed() 误碰 identity 防线(STUDIO-18117)。 + bool deduct_consumption(const std::string& spool_id, + double used_g, + const std::string& job_key); + + // Clear the weight_push_pending flag once the deducted weight has been + // pushed to the cloud successfully (see FilamentSpool::weight_push_pending). + void clear_weight_push_pending(const std::string& spool_id); + + // 待扣减账本仅保存在内存:覆盖同 dev_id 的旧任务,默认假设单机同一时刻只有 + // 一个待完成打印。跨进程重启中的在途任务不做恢复。 + void set_pending_consumption(const std::string& dev_id, + const std::map, double>& per_slot_used_g, + const std::string& job_key); + std::optional take_pending_consumption(const std::string& dev_id); + bool is_dirty() const { return m_dirty; } void set_dirty() { m_dirty = true; } void clear_dirty() { m_dirty = false; } @@ -197,6 +236,7 @@ class wgtFilaManagerStore { std::string get_storage_path() const; std::map m_spools; + std::map m_pending_consumption; bool m_dirty = false; }; diff --git a/src/slic3r/GUI/fila_manager/wgtFilaManagerSync.cpp b/src/slic3r/GUI/fila_manager/wgtFilaManagerSync.cpp index 9fc43d45ff..c7d2e96da6 100644 --- a/src/slic3r/GUI/fila_manager/wgtFilaManagerSync.cpp +++ b/src/slic3r/GUI/fila_manager/wgtFilaManagerSync.cpp @@ -53,7 +53,9 @@ bool wgtFilaManagerSync::on_device_update(MachineObject* obj) if (!obj || !m_store) return false; if (!obj->is_online()) return false; // 离线不处理,保留在位字段 check_new_filament_hint(obj); - return sync_all_trays(obj); + const bool sync_changed = sync_all_trays(obj); + const bool deduct_changed = check_print_finished_and_deduct(obj); + return sync_changed || deduct_changed; } bool wgtFilaManagerSync::on_device_disconnect(const std::string& dev_id, @@ -69,6 +71,7 @@ bool wgtFilaManagerSync::on_device_disconnect(const std::string& dev_id, else ++it; } + m_prev_print_status.erase(dev_id); // 空 present_now → was_our_hold 的 spool 全部清字段 const std::map empty; return m_store->apply_mount_diff(dev_id, dev_name, empty); @@ -308,6 +311,67 @@ bool wgtFilaManagerSync::sync_all_trays(MachineObject* obj) return mount_changed; } +bool wgtFilaManagerSync::check_print_finished_and_deduct(MachineObject* obj) +{ + if (!obj || !m_store) return false; + + const std::string dev_id = obj->get_dev_id(); + const std::string print_status = obj->print_status; + const std::string prev_status = m_prev_print_status[dev_id]; + m_prev_print_status[dev_id] = print_status; + + if (print_status != "FINISH" || prev_status == "FINISH") + return false; + + auto pending = m_store->take_pending_consumption(dev_id); + if (!pending.has_value()) + return false; + + bool any_changed = false; + for (const auto& [slot_key, used_g] : pending->per_slot_used_g) { + if (used_g <= 0.0) continue; + + const FilamentSpool* matched = m_store->find_by_slot(dev_id, slot_key.first, slot_key.second); + if (!matched) { + BOOST_LOG_TRIVIAL(warning) + << "[FilaManager] finish deduction skip: no spool bound to dev=" + << dev_id << " ams_id=" << slot_key.first + << " slot_id=" << slot_key.second + << " job_key=" << pending->job_key; + continue; + } + + const std::string spool_id = matched->spool_id; + if (!m_store->deduct_consumption(spool_id, used_g, pending->job_key)) + continue; + + any_changed = true; + BOOST_LOG_TRIVIAL(info) + << "[FilaManager] deducted used_g=" << used_g + << " spool_id=" << spool_id + << " dev_id=" << dev_id + << " job_key=" << pending->job_key; + + // The deduction is local-only until pushed — without this push the + // next pull_from_cloud() reverts net_weight to the stale cloud value. + // Enqueue immediately so the dispatcher FIFO lands the new weight + // before any subsequently queued pull. Safe when logged out: the op + // no-ops and the weight_push_pending flag keeps pulls from + // overwriting the local deduction in the meantime. + if (auto* disp = wxGetApp().fila_manager_cloud_disp()) { + disp->enqueue_push_update(spool_id, nlohmann::json{ + {"net_weight", matched->net_weight}, + {"total_net_weight", matched->effective_total_net_weight()}, + }); + BOOST_LOG_TRIVIAL(info) + << "[FilaManager] enqueued weight push spool_id=" << spool_id + << " net_weight=" << matched->net_weight; + } + } + + return any_changed; +} + const FilamentSpool* wgtFilaManagerSync::match_tray(const DevAmsTray& tray, const std::string& dev_id, const std::string& ams_id) diff --git a/src/slic3r/GUI/fila_manager/wgtFilaManagerSync.h b/src/slic3r/GUI/fila_manager/wgtFilaManagerSync.h index 4896f78a6d..27e043f5cb 100644 --- a/src/slic3r/GUI/fila_manager/wgtFilaManagerSync.h +++ b/src/slic3r/GUI/fila_manager/wgtFilaManagerSync.h @@ -20,7 +20,7 @@ class wgtFilaManagerSync { explicit wgtFilaManagerSync(wgtFilaManagerStore* store); ~wgtFilaManagerSync() = default; - // 返回 true 表示在位字段发生变化(调用方据此决定是否刷 UI)。 + // 返回 true 表示耗材会话状态发生变化(在位 / 余量本地扣减),调用方据此刷 UI。 bool on_device_update(MachineObject* obj); bool sync_all_trays(MachineObject* obj); @@ -51,6 +51,7 @@ class wgtFilaManagerSync { void notify_new_filament_hint(const std::string& ams_id, const std::string& slot_id, bool show); + bool check_print_finished_and_deduct(MachineObject* obj); wgtFilaManagerStore* m_store; @@ -66,6 +67,7 @@ class wgtFilaManagerSync { std::set m_skipped_uuids; // key 格式与 m_prev_tray_exists 相同;value 为该槽位当前被 skip 的 uuid。 std::map m_slot_skipped_uuid; + std::map m_prev_print_status; };