diff --git a/src/slic3r/GUI/DeviceCore/DevFilaSystem.cpp b/src/slic3r/GUI/DeviceCore/DevFilaSystem.cpp index 0aa273c53c..254ff241cd 100644 --- a/src/slic3r/GUI/DeviceCore/DevFilaSystem.cpp +++ b/src/slic3r/GUI/DeviceCore/DevFilaSystem.cpp @@ -947,6 +947,21 @@ void DevFilaSystemParser::ParseV1_0(const json& jj, MachineObject* obj, DevFilaS } } } + + // Give every live device extruder an external virtual tray slot (non-Bambu) so a plugin agent + // that reaches the model only through this parser also gets the shared external-slot topology. + if (obj) + obj->ensure_external_virtual_tray_slots(); + + // Diagnostic: summarize the AMS state this production parse produced, so the agent boundary + // (submit_ams_payload) box/tray counts can be correlated with what actually landed in the model. + if (obj && system) { + size_t parsed_trays = 0; + for (const auto& kv : system->amsList) + parsed_trays += kv.second->GetTrays().size(); + BOOST_LOG_TRIVIAL(info) << "DevFilaSystemParser::ParseV1_0: parsed units " << system->amsList.size() + << " trays " << parsed_trays; + } } static DevAms::AmsType ams_type_from_string(const std::string& s) @@ -957,6 +972,23 @@ static DevAms::AmsType ams_type_from_string(const std::string& s) return DevAms::AMS; // default } +void grow_virtual_tray_slots(std::vector& slots, std::size_t tray_id) +{ + // Address a slot by its virtual tray id, never by a raw vector index: the slot for `tray_id` + // lives at index VIRTUAL_TRAY_MAIN_ID - tray_id and placeholder entries carry + // id = VIRTUAL_TRAY_MAIN_ID - index. Append-only, so a slot that already holds reported + // metadata keeps it and a sparse/out-of-order report cannot shift the id <-> extruder mapping. + if (tray_id > static_cast(VIRTUAL_TRAY_MAIN_ID)) + return; + const std::size_t slot_index = static_cast(VIRTUAL_TRAY_MAIN_ID) - tray_id; + while (slots.size() <= slot_index) { + slots.emplace_back(std::to_string(VIRTUAL_TRAY_MAIN_ID - static_cast(slots.size()))); + // A filler only makes the slot addressable; it is topology, not a reported spool. Callers + // overwrite the target entry with reported metadata, which clears this marker. + slots.back().is_slot_placeholder = true; + } +} + void DevFilaSystemParser::ParseAgentFilament(const json& data, MachineObject* obj, DevFilaSystem* system) { if (!system || !data.is_object()) @@ -1084,7 +1116,6 @@ void DevFilaSystemParser::ParseAgentFilament(const json& data, MachineObject* ob if (obj && data.contains("external") && data["external"].is_array()) { std::map external_slots; - obj->vt_slot.clear(); for (const auto& e : data["external"]) { if (!e.is_object()) @@ -1105,9 +1136,23 @@ void DevFilaSystemParser::ParseAgentFilament(const json& data, MachineObject* ob external_slots.insert_or_assign(ext, std::move(tray)); } - for (auto& entry : external_slots) - obj->vt_slot.push_back(std::move(entry.second)); + // Place each reported spool at its own index (VIRTUAL_TRAY_MAIN_ID - extruder_id) instead of + // appending in report order: a sparse or out-of-order report must not compact the vector and + // shift the slot -> extruder association. + obj->vt_slot.clear(); + for (const auto& entry : external_slots) + { + // entry.first is the extruder id; its virtual tray id is VIRTUAL_TRAY_MAIN_ID - extruder_id + // and the slot lives at vector index extruder_id (the id <-> index mapping grow applies). + grow_virtual_tray_slots(obj->vt_slot, static_cast(VIRTUAL_TRAY_MAIN_ID - entry.first)); + obj->vt_slot[entry.first] = entry.second; + } } + + // Fill any extruder still missing a slot so the model always has one external slot per live + // extruder, even when the agent reported no external data at all. Preserves reported metadata. + if (obj) + obj->ensure_external_virtual_tray_slots(); } } diff --git a/src/slic3r/GUI/DeviceCore/DevFilaSystem.h b/src/slic3r/GUI/DeviceCore/DevFilaSystem.h index f2439a5a1b..ea9837de03 100644 --- a/src/slic3r/GUI/DeviceCore/DevFilaSystem.h +++ b/src/slic3r/GUI/DeviceCore/DevFilaSystem.h @@ -7,6 +7,7 @@ #include "DevFilaSwitch.h" #include "DevUtil.h" +#include #include #include #include @@ -82,7 +83,7 @@ public: wxColour wx_color; bool is_bbl; bool is_exists = false; - bool is_slot_placeholder = false; // Orca: True for empty tray slots from pull-mode agents + bool is_slot_placeholder = false; // Orca: True for synthetic topology slots or empty reported trays int hold_count = 0; int remain = 0; // filament remain: 0 ~ 100 @@ -114,6 +115,16 @@ public: std::optional get_ams_drying_preset() const; }; +/** + * Ensure the virtual-tray vector can address the slot for a virtual tray id. The slot's index is + * `VIRTUAL_TRAY_MAIN_ID - tray_id` and placeholder entries carry id = VIRTUAL_TRAY_MAIN_ID - index. + * Existing entries are left untouched, so a slot already populated with reported filament data + * keeps it; callers then assign the reported tray at index `VIRTUAL_TRAY_MAIN_ID - tray_id`. + * Appended placeholders are marked `is_slot_placeholder`, so consumers can tell a synthetic + * topology filler from a spool the printer actually reported (overwriting the entry clears it). + */ +void grow_virtual_tray_slots(std::vector& slots, std::size_t tray_id); + /** * DevAms - Represents a single AMS (Automatic Material System) unit. * diff --git a/src/slic3r/GUI/DeviceCore/DevMapping.cpp b/src/slic3r/GUI/DeviceCore/DevMapping.cpp index 906ac2345b..356574564a 100644 --- a/src/slic3r/GUI/DeviceCore/DevMapping.cpp +++ b/src/slic3r/GUI/DeviceCore/DevMapping.cpp @@ -18,14 +18,80 @@ // TODO: remove this include #include "slic3r/GUI/DeviceManager.hpp" +#include "slic3r/GUI/GUI_App.hpp" #include "slic3r/GUI/GuiColor.hpp" namespace Slic3r { + // Bambu keeps its fixed external pair (255 main/right, 254 deputy/left) and its two + // left/right flags. Every other printer addresses one external virtual tray per live + // extruder, so ids 255, 254, 253, ... are extruders 0, 1, 2, ... . + static bool _is_bbl_vendor_printer() + { + return wxTheApp != nullptr && GUI::wxGetApp().preset_bundle != nullptr && + GUI::wxGetApp().preset_bundle->is_bbl_vendor(); + } + + // Extruder a virtual (external spool) tray id feeds: VIRTUAL_TRAY_MAIN_ID - tray_id. + // Returns -1 for an empty, non-numeric or out-of-range id. + static int _virtual_tray_extruder_id(const std::string& tray_id) + { + if (tray_id.empty()) + return -1; + for (char c : tray_id) + { + if (c < '0' || c > '9') + return -1; + } + try + { + const int vt_id = std::stoi(tray_id); + if (vt_id < 0 || vt_id > VIRTUAL_TRAY_MAIN_ID) + return -1; + return VIRTUAL_TRAY_MAIN_ID - vt_id; + } + catch (...) + { + return -1; + } + } + + // True when obj->vt_slot actually carries this virtual tray id as a reported spool. A synthetic + // topology filler (is_slot_placeholder) only makes the slot addressable and does not count. + static bool _has_virtual_tray_slot(const MachineObject* obj, const std::string& tray_id) + { + if (obj == nullptr || tray_id.empty()) + return false; + for (const auto& tray : obj->vt_slot) + { + if (!tray.is_slot_placeholder && tray.id == tray_id) + return true; + } + return false; + } + + // A generic printer may expose one AMS unit / external slot per extruder. When both legacy + // flags of a category are enabled (the sidebar sync call uses both AMS and both external flags) + // every extruder participates; when only one side is enabled (SelectMachine's left/right split) + // keep to that side's legacy extruder (right = main = 0, left = deputy = 1), so the N slots do + // not leak across sides. + static bool _generic_extruder_in_selected_side(int extruder_id, bool use_right, bool use_left) + { + if (use_right && use_left) + return true; + if (use_right) + return extruder_id == MAIN_EXTRUDER_ID; + if (use_left) + return extruder_id == DEPUTY_EXTRUDER_ID; + return false; + } + bool DevMappingUtil::is_valid_mapping_result(const MachineObject* obj, std::vector& result, bool check_empty_slot) { if (result.empty()) return false; + const bool is_bbl_vendor = _is_bbl_vendor_printer(); + for (int i = 0; i < result.size(); i++) { // invalid mapping result @@ -42,8 +108,13 @@ namespace Slic3r auto ams_item = obj->GetFilaSystem()->GetAmsById(result[i].ams_id); if (ams_item == nullptr) { - if ((result[i].ams_id != std::to_string(VIRTUAL_TRAY_MAIN_ID)) && - (result[i].ams_id != std::to_string(VIRTUAL_TRAY_DEPUTY_ID))) + // A virtual (external spool) tray is not an AMS unit. Bambu keeps its fixed pair; + // a generic printer accepts whichever per-extruder slots it actually reported. + const bool is_virtual_tray = is_bbl_vendor + ? (result[i].ams_id == std::to_string(VIRTUAL_TRAY_MAIN_ID) || + result[i].ams_id == std::to_string(VIRTUAL_TRAY_DEPUTY_ID)) + : _has_virtual_tray_slot(obj, result[i].ams_id); + if (!is_virtual_tray) { result[i].tray_id = -1; return false; @@ -94,8 +165,11 @@ namespace Slic3r result.ams_id = std::to_string(ams_id); result.slot_id = std::to_string(slot_id); - if (ams_id == VIRTUAL_TRAY_MAIN_ID || ams_id == VIRTUAL_TRAY_DEPUTY_ID) + if (type == DevAms::EXT_SPOOL || ams_id == VIRTUAL_TRAY_MAIN_ID || ams_id == VIRTUAL_TRAY_DEPUTY_ID) { + // An external spool is addressed by its virtual tray id (255 -> extruder 0, 254 -> 1, + // ...); the id is the tray's identity, so it must not fall through to the AMS + // ams_id * 4 + slot_id indexing. result.tray_id = atoi(tray.id.c_str()); result.id = atoi(tray.id.c_str()); } @@ -126,11 +200,25 @@ namespace Slic3r std::map tray_filaments; // tray_index : tray_color bool left_nozzle_has_ams = false, right_nozzle_has_ams = false; + const bool is_bbl_vendor = _is_bbl_vendor_printer(); + // Extruders whose AMS filament participates in this mapping. On a generic printer this + // carries the per-extruder "nozzle already has AMS" state that the legacy right/left flags + // express for Bambu, so the matching external spool can be suppressed. + std::set generic_extruders_with_ams; + const auto& ams_list = obj->GetFilaSystem()->GetAmsList(); for (auto ams = ams_list.begin(); ams != ams_list.end(); ams++) { std::string ams_id = ams->second->GetAmsId(); auto ams_type = ams->second->GetAmsType(); + const int ams_id_int = atoi(ams->first.c_str()); + int generic_tray_base = 0; + if (!is_bbl_vendor) { + for (const auto& other : ams_list) { + if (atoi(other.first.c_str()) < ams_id_int) + generic_tray_base += static_cast(other.second->GetTrays().size()); + } + } // GetAmsType() maps mixed -> AMS_LITE; recover the mixed type so N9 trays index at 24+slot. if (ams_type == DevAms::AMS_LITE && ams->second->IsAmsLiteMixed()) { @@ -138,10 +226,16 @@ namespace Slic3r } for (auto tray = ams->second->GetTrays().begin(); tray != ams->second->GetTrays().end(); tray++) { - int ams_id = atoi(ams->first.c_str()); + int ams_id = ams_id_int; int tray_id = atoi(tray->first.c_str()); int tray_index = 0; - if (ams_type == DevAms::AMS || ams_type == DevAms::AMS_LITE || ams_type == DevAms::N3F) + if (!is_bbl_vendor) + { + // Generic units can have different lane counts; index all lanes cumulatively, + // matching the shared tray-index map and sidebar list builder. + tray_index = generic_tray_base + tray_id; + } + else if (ams_type == DevAms::AMS || ams_type == DevAms::AMS_LITE || ams_type == DevAms::N3F) { tray_index = ams_id * 4 + tray_id; } @@ -169,14 +263,18 @@ namespace Slic3r if (tray->second->is_tray_info_ready()) { _parse_tray_info(ams_id, tray_id, ams_type, *(tray->second), info); + // Generic unit lanes use one cumulative index across variable-width units; + // ams_id * 4 + slot_id is only valid for Bambu's fixed four-slot units. + if (!is_bbl_vendor) + info.id = tray_index; } - //first: left,nozzle=1,map=1 second: right,nozzle=0,map=2 - bool right_ams_valid = (ams->second->GetBindedExtruderSet().count(MAIN_EXTRUDER_ID) != 0) && map_opt[MappingOption::USE_RIGHT_AMS]; - bool left_ams_valid = (ams->second->GetBindedExtruderSet().count(DEPUTY_EXTRUDER_ID) != 0) && map_opt[MappingOption::USE_LEFT_AMS]; - if (right_ams_valid || left_ams_valid) + bool ams_valid = false; + if (is_bbl_vendor) { - tray_filaments.emplace(std::make_pair(tray_index, info)); + //first: left,nozzle=1,map=1 second: right,nozzle=0,map=2 + bool right_ams_valid = (ams->second->GetBindedExtruderSet().count(MAIN_EXTRUDER_ID) != 0) && map_opt[MappingOption::USE_RIGHT_AMS]; + bool left_ams_valid = (ams->second->GetBindedExtruderSet().count(DEPUTY_EXTRUDER_ID) != 0) && map_opt[MappingOption::USE_LEFT_AMS]; if (right_ams_valid) { right_nozzle_has_ams = true; @@ -185,6 +283,25 @@ namespace Slic3r { left_nozzle_has_ams = true; } + ams_valid = right_ams_valid || left_ams_valid; + } + else + { + // A generic unit is bound to its own extruder, so it takes part whenever the + // request covers that extruder. + for (int extruder_id : ams->second->GetBindedExtruderSet()) + { + if (_generic_extruder_in_selected_side(extruder_id, map_opt[MappingOption::USE_RIGHT_AMS], map_opt[MappingOption::USE_LEFT_AMS])) + { + ams_valid = true; + generic_extruders_with_ams.insert(extruder_id); + } + } + } + + if (ams_valid) + { + tray_filaments.emplace(std::make_pair(tray_index, info)); } } } @@ -193,10 +310,21 @@ namespace Slic3r { for (auto tray : obj->vt_slot) { - bool right_ext_valid = (tray.id == std::to_string(VIRTUAL_TRAY_MAIN_ID) && map_opt[MappingOption::USE_RIGHT_EXT]); - bool left_ext_valid = (tray.id == std::to_string(VIRTUAL_TRAY_DEPUTY_ID) && map_opt[MappingOption::USE_LEFT_EXT]); - if (right_ext_valid || left_ext_valid) + // A synthetic topology filler is not a reported external spool, so it is never a + // mapping candidate. Bambu keeps its fixed reported pair. + if (!is_bbl_vendor && tray.is_slot_placeholder) { + continue; + } + if (is_bbl_vendor) + { + // Bambu keeps its fixed pair and the two left/right external flags. + const bool right_ext_valid = (tray.id == std::to_string(VIRTUAL_TRAY_MAIN_ID) && map_opt[MappingOption::USE_RIGHT_EXT]); + const bool left_ext_valid = (tray.id == std::to_string(VIRTUAL_TRAY_DEPUTY_ID) && map_opt[MappingOption::USE_LEFT_EXT]); + if (!right_ext_valid && !left_ext_valid) + { + continue; + } if (nozzle_has_ams_then_ignore_ext) { if (right_ext_valid && right_nozzle_has_ams) @@ -208,10 +336,31 @@ namespace Slic3r continue; } } - FilamentInfo info; - _parse_tray_info(atoi(tray.id.c_str()), 0, DevAms::EXT_SPOOL, tray, info); - tray_filaments.emplace(std::make_pair(info.tray_id, info)); } + else + { + // Generic: every present virtual tray is the external spool of the extruder it + // addresses (VIRTUAL_TRAY_MAIN_ID - tray.id): 255 -> 0, 254 -> 1, ... . Keep to + // the selected side when only one legacy flag is enabled, and drop the spool when + // its extruder already has AMS filament in this mapping and suppression is on. + const int extruder_id = _virtual_tray_extruder_id(tray.id); + if (extruder_id < 0) + { + continue; + } + if (!_generic_extruder_in_selected_side(extruder_id, map_opt[MappingOption::USE_RIGHT_EXT], map_opt[MappingOption::USE_LEFT_EXT])) + { + continue; + } + if (nozzle_has_ams_then_ignore_ext && generic_extruders_with_ams.count(extruder_id) != 0) + { + continue; + } + } + + FilamentInfo info; + _parse_tray_info(atoi(tray.id.c_str()), 0, DevAms::EXT_SPOOL, tray, info); + tray_filaments.emplace(std::make_pair(info.tray_id, info)); } } @@ -412,4 +561,4 @@ namespace Slic3r return 0; } -} \ No newline at end of file +} diff --git a/src/slic3r/GUI/DeviceManager.cpp b/src/slic3r/GUI/DeviceManager.cpp index 991b33e888..734dfb0044 100644 --- a/src/slic3r/GUI/DeviceManager.cpp +++ b/src/slic3r/GUI/DeviceManager.cpp @@ -4134,11 +4134,8 @@ int MachineObject::parse_json(std::string tunnel, std::string payload, bool key_ continue; auto vslot = parse_vt_tray(tray_json); + grow_virtual_tray_slots(vt_slot, static_cast(tray_id)); const auto index = static_cast::size_type>(VIRTUAL_TRAY_MAIN_ID - tray_id); - while (vt_slot.size() <= index) { - const int placeholder_id = VIRTUAL_TRAY_MAIN_ID - static_cast(vt_slot.size()); - vt_slot.emplace_back(std::to_string(placeholder_id)); - } vt_slot[index] = vslot; } catch (...) { @@ -4173,6 +4170,13 @@ int MachineObject::parse_json(std::string tunnel, std::string payload, bool key_ try { parse_new_info(jj); } catch (...) {} + + // Give every live device extruder an external virtual tray even when the payload + // carried no vir_slot/vt_tray data (non-Bambu), so the UI always has a slot per + // extruder. Runs after parse_new_info(), which refreshes the extruder count, and + // after the vir_slot/vt_tray handling above, which may have cleared the support + // flag and would otherwise hide these synthetic slots. + ensure_external_virtual_tray_slots(); #pragma endregion } else if (jj["command"].get() == "gcode_line") { //ack of gcode_line @@ -5278,6 +5282,37 @@ DevAmsTray MachineObject::parse_vt_tray(json vtray) return vt_tray; } +void MachineObject::ensure_external_virtual_tray_slots() +{ + // Without a preset bundle the vendor layout is unknown, and Bambu owns its own fixed pair + // (255, 254) reported through vir_slot/vt_tray; in both cases leave vt_slot untouched. + if (wxTheApp == nullptr || GUI::wxGetApp().preset_bundle == nullptr || + GUI::wxGetApp().preset_bundle->is_bbl_vendor()) { + return; + } + + const int extruder_count = m_extder_system != nullptr ? m_extder_system->GetTotalExtderCount() : 0; + if (extruder_count <= 0) { + return; + } + + // One slot per live extruder, addressed by VIRTUAL_TRAY_MAIN_ID - extruder_id. Grow with + // placeholders and only fill slots that do not exist yet, so a slot already populated from a + // reported tray keeps its metadata and the slot -> extruder mapping never shifts. + for (int extruder_id = 0; extruder_id < extruder_count; ++extruder_id) { + const int tray_id = VIRTUAL_TRAY_MAIN_ID - extruder_id; + grow_virtual_tray_slots(vt_slot, static_cast::size_type>(tray_id)); + auto& tray = vt_slot[extruder_id]; + // The constructor seeds id 255 without a placeholder flag. Mark any empty generic slot as + // synthetic, but leave a slot carrying reported filament data eligible for sync/mapping. + tray.is_slot_placeholder = !tray.is_exists && tray.setting_id.empty() && tray.m_fila_type.empty(); + } + + // A printer with an external-slot layout supports virtual trays even when the payload carried + // no vir_slot/vt_tray data, which would otherwise clear this flag and hide the synthetic slots. + ams_support_virtual_tray = true; +} + bool MachineObject::contains_tray(const std::string &ams_id, const std::string &tray_id) const { const bool is_bbl_vendor = wxTheApp != nullptr && GUI::wxGetApp().preset_bundle != nullptr && GUI::wxGetApp().preset_bundle->is_bbl_vendor(); diff --git a/src/slic3r/GUI/DeviceManager.hpp b/src/slic3r/GUI/DeviceManager.hpp index 75bc545f64..c36abfc9f4 100644 --- a/src/slic3r/GUI/DeviceManager.hpp +++ b/src/slic3r/GUI/DeviceManager.hpp @@ -950,6 +950,16 @@ public: std::vector vt_slot; DevAmsTray parse_vt_tray(nlohmann::json vtray); + /** + * Give every live device extruder an external virtual tray slot (non-Bambu printers only). + * + * Slots are addressed by VIRTUAL_TRAY_MAIN_ID - extruder_id, so a slot already reported for an + * extruder keeps its tray metadata and sparse/out-of-order reports never shift the slot -> + * extruder association. Only missing topology slots are added, so the call is idempotent across + * refreshes. Bambu keeps its existing fixed pair reported through vir_slot/vt_tray. + */ + void ensure_external_virtual_tray_slots(); + /*get ams slot info*/ bool contains_tray(const std::string &ams_id, const std::string &tray_id) const; DevAmsTray get_tray(const std::string &ams_id, const std::string &tray_id) const;/*use contains_tray() check first*/ diff --git a/src/slic3r/Utils/MoonrakerPrinterAgent.cpp b/src/slic3r/Utils/MoonrakerPrinterAgent.cpp index ebd2378987..a9444d0773 100644 --- a/src/slic3r/Utils/MoonrakerPrinterAgent.cpp +++ b/src/slic3r/Utils/MoonrakerPrinterAgent.cpp @@ -10,6 +10,7 @@ #include "slic3r/GUI/DeviceManager.hpp" #include "slic3r/GUI/DeviceCore/DevFilaSystem.h" #include "slic3r/GUI/DeviceCore/DevManager.h" +#include "slic3r/GUI/DeviceCore/DevDefs.h" #include "../GUI/DeviceCore/DevStorage.h" #include "../GUI/DeviceCore/DevFirmware.h" #include "nlohmann/json.hpp" @@ -583,8 +584,20 @@ void MoonrakerPrinterAgent::build_ams_payload_grouped(const std::vector 0) box_trays << ", "; + box_trays << "box" << ams_array[i].value("id", std::string()) << ":" + << ((ams_array[i].contains("tray") && ams_array[i]["tray"].is_array()) ? ams_array[i]["tray"].size() : 0); + } + BOOST_LOG_TRIVIAL(info) << "MoonrakerPrinterAgent::submit_ams_payload: boundary boxes " << ams_array.size() + << " trays [" << box_trays.str() << "]"; + // Call the parser to populate DevFilaSystem DevFilaSystemParser::ParseV1_0(print_json, obj, obj->GetFilaSystem().get(), false); + // ParseV1_0 handles only the AMS boxes, so grow the external-spool slots directly here too: + // pull mode bypasses MachineObject::parse_json(), the only place the push-status vir_slot is + // applied. Each Moonraker extruder gets one empty native virtual tray (id = 255 - extruder_id), + // kept at its id-derived index so a non-contiguous extruder set does not shift slots. Slots that + // already carry reported spool metadata are left untouched (only missing slots are added). + const auto extruder_status = collect_extruder_status(); + for (const auto& entry : extruder_status) { + const int extruder_id = entry.first; + if (extruder_id < 0 || extruder_id > VIRTUAL_TRAY_MAIN_ID) { + continue; + } + // Address the slot by its virtual tray id (VIRTUAL_TRAY_MAIN_ID - extruder_id), which places + // it at vector index extruder_id. Append-only, so a reported spool and the extruder + // association survive refreshes. + grow_virtual_tray_slots(obj->vt_slot, static_cast(VIRTUAL_TRAY_MAIN_ID - extruder_id)); + auto& tray = obj->vt_slot[extruder_id]; + tray.is_slot_placeholder = !tray.is_exists && tray.setting_id.empty() && tray.m_fila_type.empty(); + } + if (!extruder_status.empty()) { + // A native virtual tray implies external-spool support, matching parse_vt_tray(). + obj->ams_support_virtual_tray = true; + } + // Set printer_type so update_sync_status() can match it against the preset's printer type. // Without this, the comparison fails and all sync badges are cleared. obj->printer_type = device_info.model_id; @@ -924,14 +971,30 @@ bool MoonrakerPrinterAgent::fetch_moonraker_filament_data(std::vector toolhead binding from the // AFC printer objects so lanes on different toolheads end up in different AMS boxes. std::map lane_extruder; fetch_afc_lane_extruders(lane_extruder); + // Happy Hare also writes lane_data, where the lane number is the gate index. Its mmu_machine + // status maps gates to units, and each unit's config maps to an extruder. Probe that mapping + // only when a lane has no AFC/extruder field, preserving the existing AFC/generic behavior. + std::map hh_gate_extruder; + bool hh_probed = false; + auto hh_extruder_for_lane = [&](int lane) { + if (!hh_probed) { + hh_probed = true; + fetch_hh_gate_extruders(hh_gate_extruder); + } + const auto it = hh_gate_extruder.find(lane); + return it != hh_gate_extruder.end() ? it->second : 0; + }; + for (const auto& [lane_key, lane_obj] : value.items()) { if (!lane_obj.is_object()) { + ++skipped_records; continue; } @@ -943,6 +1006,7 @@ bool MoonrakerPrinterAgent::fetch_moonraker_filament_data(std::vectorsecond : 0; + tray.extruder_id = it != lane_extruder.end() ? it->second : hh_extruder_for_lane(lane_index); } tray.tray_color = safe_json_string(lane_obj, "color"); tray.tray_type = safe_json_string(lane_obj, "material"); @@ -970,8 +1037,18 @@ bool MoonrakerPrinterAgent::fetch_moonraker_filament_data(std::vector& tra } // Parse gate data. Every gate becomes a lane so empty/unknown gates surface - // as empty AMS slots rather than vanishing. HH is single-extruder, so all - // lanes belong to feed group 0. + // as empty AMS slots rather than vanishing. Each gate is grouped by the toolhead its + // mmu_machine unit feeds; when the unit layout is unavailable every gate keeps the previous + // fallback of feed group 0. + std::map gate_extruder; + fetch_hh_gate_extruders(gate_extruder); + trays.clear(); for (int gate_idx = 0; gate_idx < num_gates; ++gate_idx) { @@ -1076,7 +1157,8 @@ bool MoonrakerPrinterAgent::fetch_hh_filament_info(std::vector& tra AmsTrayData tray; tray.slot_index = gate_idx; - tray.extruder_id = 0; + const auto extruder_it = gate_extruder.find(gate_idx); + tray.extruder_id = extruder_it != gate_extruder.end() ? extruder_it->second : 0; if (status > 0 && !material.empty()) { tray.tray_type = material; @@ -1101,6 +1183,30 @@ bool MoonrakerPrinterAgent::fetch_hh_filament_info(std::vector& tra return true; } +// GET a Moonraker path (relative to the device base URL) with the device's API key and parse the +// response body. Returns false on a non-200 status or a body that is not valid JSON. +bool MoonrakerPrinterAgent::http_get_json(const std::string& path, nlohmann::json& out) const +{ + auto http = Http::get(join_url(device_info.base_url, path)); + if (!device_info.api_key.empty()) { + http.header("X-Api-Key", device_info.api_key); + } + std::string body; + unsigned status_code = 0; + http.timeout_connect(5) + .timeout_max(10) + .on_complete([&](std::string response, unsigned status) { + body = std::move(response); + status_code = status; + }) + .perform_sync(); + if (status_code != 200) { + return false; + } + out = nlohmann::json::parse(body, nullptr, false, true); + return !out.is_discarded(); +} + // AFC writes its filament data into the lane_data database namespace, and the records of the // fixture/agent write path carry no extruder field, so the lane -> toolhead binding has to be read // from the printer objects: each AFC_lane .extruder names the toolhead its lane feeds, and @@ -1108,29 +1214,8 @@ bool MoonrakerPrinterAgent::fetch_hh_filament_info(std::vector& tra // of the main extruder. bool MoonrakerPrinterAgent::fetch_afc_lane_extruders(std::map& lane_extruder) { - auto query_objects = [this](const std::string& query_string, nlohmann::json& out) { - auto http = Http::get(join_url(device_info.base_url, "/printer/objects/query?" + query_string)); - if (!device_info.api_key.empty()) { - http.header("X-Api-Key", device_info.api_key); - } - std::string body; - unsigned status_code = 0; - http.timeout_connect(5) - .timeout_max(10) - .on_complete([&](std::string response, unsigned status) { - body = std::move(response); - status_code = status; - }) - .perform_sync(); - if (status_code != 200) { - return false; - } - out = nlohmann::json::parse(body, nullptr, false, true); - return !out.is_discarded(); - }; - nlohmann::json afc_json; - if (!query_objects("AFC", afc_json) || !afc_json.contains("result") || + if (!http_get_json("/printer/objects/query?AFC", afc_json) || !afc_json.contains("result") || !afc_json["result"].contains("status") || !afc_json["result"]["status"].contains("AFC")) { return false; } @@ -1154,7 +1239,7 @@ bool MoonrakerPrinterAgent::fetch_afc_lane_extruders(std::map& } nlohmann::json lanes_json; - if (!query_objects(query_string, lanes_json) || !lanes_json.contains("result") || + if (!http_get_json("/printer/objects/query?" + query_string, lanes_json) || !lanes_json.contains("result") || !lanes_json["result"].contains("status")) { return false; } @@ -1177,6 +1262,111 @@ bool MoonrakerPrinterAgent::fetch_afc_lane_extruders(std::map& return !lane_extruder.empty(); } +// Happy Hare maps its gates to toolheads through mmu_machine units: each unit entry carries +// {name, first_gate, num_gates}, and the matching [mmu_unit ] config section names the +// extruder the unit is attached to. Only Happy Hare publishes mmu_machine, so a generic +// lane_data / AFC printer is never reassigned from here. Best effort: returns false and leaves the +// map empty when Happy Hare or its unit data is not available. +bool MoonrakerPrinterAgent::fetch_hh_gate_extruders(std::map& gate_extruder) +{ + gate_extruder.clear(); + + nlohmann::json machine_json; + if (!http_get_json("/printer/objects/query?mmu_machine", machine_json) || + !machine_json.contains("result") || !machine_json["result"].contains("status") || + !machine_json["result"]["status"].contains("mmu_machine") || + !machine_json["result"]["status"]["mmu_machine"].is_object()) { + return false; + } + + const auto& machine = machine_json["result"]["status"]["mmu_machine"]; + if (machine.empty()) { + return false; + } + + // Happy Hare publishes its config through the configfile object; read every unit's section from + // one fetch. An absent or unreadable configfile leaves every unit at the main extruder. + nlohmann::json sections; + nlohmann::json config_json; + if (http_get_json("/printer/objects/query?configfile", config_json) && + config_json.contains("result") && config_json["result"].contains("status") && + config_json["result"]["status"].contains("configfile") && + config_json["result"]["status"]["configfile"].is_object()) { + const auto& configfile = config_json["result"]["status"]["configfile"]; + if (configfile.contains("config") && configfile["config"].is_object()) { + sections = configfile["config"]; + } + } + + // One gate range -> extruder resolution per unit, reused for every gate the unit carries. + std::map unit_extruder; + + for (const auto& [unit_key, unit_obj] : machine.items()) { + // A unit entry is the one carrying a gate range; skip scalars such as num_units. + if (!unit_obj.is_object() || !unit_obj.contains("first_gate") || !unit_obj.contains("num_gates")) { + continue; + } + const int first_gate = safe_json_int_flexible(unit_obj, "first_gate", -1); + const int num_gates = safe_json_int_flexible(unit_obj, "num_gates", -1); + if (first_gate < 0 || num_gates <= 0) { + continue; + } + + // If status omits the unit name, use the unit key as a best-effort config section suffix. + std::string unit_name = safe_json_string(unit_obj, "name"); + if (unit_name.empty() && unit_key.rfind("unit_", 0) == 0) { + unit_name = unit_key; + } + if (unit_name.empty()) { + continue; + } + + auto it = unit_extruder.find(unit_name); + if (it == unit_extruder.end()) { + it = unit_extruder.emplace(unit_name, hh_unit_extruder_from_config(sections, unit_name)).first; + } + for (int gate = first_gate; gate < first_gate + num_gates; ++gate) { + gate_extruder[gate] = it->second; + } + } + + if (gate_extruder.empty()) { + return false; + } + + BOOST_LOG_TRIVIAL(debug) << "MoonrakerPrinterAgent::fetch_hh_gate_extruders: Mapped " + << gate_extruder.size() << " Happy Hare gate(s) across " + << unit_extruder.size() << " unit(s)"; + return true; +} + +// Read the [mmu_unit ] section of a configfile.config map and map its extruder_name to a +// toolhead index ("extruder" -> 0, "extruderN" -> N). Happy Hare v4 defaults a missing +// extruder_name to the main extruder, so an absent setting and an absent section both fall back to +// 0 rather than dropping the unit. +int MoonrakerPrinterAgent::hh_unit_extruder_from_config(const nlohmann::json& sections, const std::string& unit_name) +{ + constexpr int default_extruder = 0; + + if (!sections.is_object()) { + return default_extruder; + } + + // Happy Hare names its unit sections "mmu_unit " (e.g. "mmu_unit unit0"). + const auto it = sections.find("mmu_unit " + unit_name); + if (it == sections.end() || !it->is_object()) { + return default_extruder; + } + + const std::string extruder_name = safe_json_string(*it, "extruder_name"); + if (extruder_name.empty()) { + return default_extruder; + } + + const int extruder_index = extruder_index_from_name(extruder_name); + return extruder_index >= 0 ? extruder_index : default_extruder; +} + int MoonrakerPrinterAgent::handle_request(const std::string& dev_id, const std::string& json_str) { auto json = nlohmann::json::parse(json_str, nullptr, false); @@ -2017,6 +2207,31 @@ void MoonrakerPrinterAgent::update_status_cache(const nlohmann::json& updates) } } +// Snapshot Moonraker toolhead status objects ("extruder", "extruder1", ...) keyed by extruder id. +// An id is the suffix of the object name, so a non-contiguous set keeps its real ids. +std::map MoonrakerPrinterAgent::collect_extruder_status() const +{ + std::lock_guard lock(payload_mutex); + std::map extruder_status; + for (const auto& item : status_cache.items()) { + int extruder_id = -1; + if (item.key() == "extruder") { + extruder_id = 0; + } else if (item.key().rfind("extruder", 0) == 0 && item.key().size() > 8 && + std::all_of(item.key().begin() + 8, item.key().end(), [](unsigned char c) { return std::isdigit(c) != 0; })) { + try { + extruder_id = std::stoi(item.key().substr(8)); + } catch (...) { + extruder_id = -1; + } + } + if (extruder_id >= 0 && item.value().is_object()) { + extruder_status.emplace(extruder_id, item.value()); + } + } + return extruder_status; +} + nlohmann::json MoonrakerPrinterAgent::build_print_payload_locked() const { nlohmann::json payload; @@ -2102,23 +2317,7 @@ nlohmann::json MoonrakerPrinterAgent::build_print_payload_locked() const // MachineObject::parse_new_info() only parses device.extruder when the four // legacy new-protocol fields are present. Moonraker has no equivalents, so // empty values act as compatibility markers without enabling any features. - std::map extruder_status; - for (const auto& item : status_cache.items()) { - int extruder_id = -1; - if (item.key() == "extruder") { - extruder_id = 0; - } else if (item.key().rfind("extruder", 0) == 0 && item.key().size() > 8 && - std::all_of(item.key().begin() + 8, item.key().end(), [](unsigned char c) { return std::isdigit(c) != 0; })) { - try { - extruder_id = std::stoi(item.key().substr(8)); - } catch (...) { - extruder_id = -1; - } - } - if (extruder_id >= 0 && item.value().is_object()) { - extruder_status.emplace(extruder_id, &item.value()); - } - } + const auto extruder_status = collect_extruder_status(); if (!extruder_status.empty()) { auto& device_extruder = payload["print"]["device"]["extruder"]; @@ -2153,8 +2352,8 @@ nlohmann::json MoonrakerPrinterAgent::build_print_payload_locked() const }; for (const auto& [extruder_id, status] : extruder_status) { - const int current_temperature = encoded_temperature(*status, "temperature"); - const int target_temperature = encoded_temperature(*status, "target"); + const int current_temperature = encoded_temperature(status, "temperature"); + const int target_temperature = encoded_temperature(status, "target"); device_extruder["info"].push_back({ {"id", extruder_id}, {"filam_bak", nlohmann::json::array()}, @@ -2172,6 +2371,21 @@ nlohmann::json MoonrakerPrinterAgent::build_print_payload_locked() const payload["print"]["fun"] = ""; payload["print"]["aux"] = ""; payload["print"]["stat"] = ""; + + // External-spool placeholders. Moonraker reports the extruders but no external-spool + // filament, so emit one empty native virtual tray per extruder (id = 255 - extruder_id). + // Emitting vir_slot makes the parser grow vt_slot and set ams_support_virtual_tray, giving + // every extruder a load slot instead of only the main one. Ids come from the extruder_status + // map, so a non-contiguous extruder set keeps its id mapping (the parser fills the holes + // with placeholders) rather than shifting every slot down. + auto& vir_slot = payload["print"]["vir_slot"]; + vir_slot = nlohmann::json::array(); + for (const auto& entry : extruder_status) { + if (entry.first < 0 || entry.first > VIRTUAL_TRAY_MAIN_ID) { + continue; + } + vir_slot.push_back({{"id", std::to_string(VIRTUAL_TRAY_MAIN_ID - entry.first)}}); + } } if (status_cache.contains("heater_bed") && status_cache["heater_bed"].is_object()) { diff --git a/src/slic3r/Utils/MoonrakerPrinterAgent.hpp b/src/slic3r/Utils/MoonrakerPrinterAgent.hpp index 7773ac247c..a47badc558 100644 --- a/src/slic3r/Utils/MoonrakerPrinterAgent.hpp +++ b/src/slic3r/Utils/MoonrakerPrinterAgent.hpp @@ -145,6 +145,8 @@ private: void handle_ws_message(const std::string& dev_id, const std::string& payload); void update_status_cache(const nlohmann::json& updates); nlohmann::json build_print_payload_locked() const; + // Snapshot Moonraker "extruder"/"extruderN" status objects from the cache, keyed by extruder id. + std::map collect_extruder_status() const; // Print control helpers int pause_print(const std::string& dev_id); @@ -172,6 +174,15 @@ private: // Lane name -> toolhead index, read from the AFC printer objects (the lane_data database has no // extruder field). Returns false when AFC is not present or the objects cannot be read. bool fetch_afc_lane_extruders(std::map& lane_extruder); + // Gate -> toolhead index, read from the Happy Hare mmu_machine units (gate ranges) and each + // unit's configured extruder_name. Returns false when Happy Hare is not present. + bool fetch_hh_gate_extruders(std::map& gate_extruder); + // Read the extruder_name of the [mmu_unit ] section from a configfile.config map and map + // it to a toolhead index. Missing section/setting falls back to the main extruder (Happy Hare's + // own default). + static int hh_unit_extruder_from_config(const nlohmann::json& sections, const std::string& unit_name); + // GET a Moonraker path with the device's API key and parse the JSON body. + bool http_get_json(const std::string& path, nlohmann::json& out) const; // Build one BBL tray JSON object (placeholder when the tray has no filament). static nlohmann::json make_ams_tray_json(const std::string& tray_id, const AmsTrayData& tray); diff --git a/tests/slic3rutils/test_dev_mapping.cpp b/tests/slic3rutils/test_dev_mapping.cpp index 5f7007e947..e46b00128f 100644 --- a/tests/slic3rutils/test_dev_mapping.cpp +++ b/tests/slic3rutils/test_dev_mapping.cpp @@ -13,6 +13,7 @@ #include #endif +#include #include #include #include @@ -141,6 +142,53 @@ TEST_CASE("Agent filament slots preserve every extruder and AMS binding", "[DevF CHECK(tray_index_map.at(252).second == 0); } +TEST_CASE("External virtual tray slots cover each extruder without shifting existing data", "[DevFilaSystem]") +{ + std::vector slots; + slots.emplace_back("255"); + slots[0].setting_id = "main-spool"; + + // Ensure slots for three extruders, preserving the main spool's reported metadata. + for (int extruder_id = 0; extruder_id < 3; ++extruder_id) { + grow_virtual_tray_slots(slots, static_cast(VIRTUAL_TRAY_MAIN_ID - extruder_id)); + } + + REQUIRE(slots.size() == 3); + CHECK(slots[0].id == "255"); + CHECK(slots[1].id == "254"); + CHECK(slots[2].id == "253"); + CHECK(slots[0].setting_id == "main-spool"); + + // Repeating the topology update is idempotent and leaves existing tray data intact. + slots[2].setting_id = "third-spool"; + for (int extruder_id = 0; extruder_id < 3; ++extruder_id) { + grow_virtual_tray_slots(slots, static_cast(VIRTUAL_TRAY_MAIN_ID - extruder_id)); + } + REQUIRE(slots.size() == 3); + CHECK(slots[2].setting_id == "third-spool"); +} + +TEST_CASE("Sparse external spool reports retain their extruder slot IDs", "[DevFilaSystem]") +{ + MachineObject obj(nullptr, nullptr, "test", "test_dev", "127.0.0.1"); + const json filament = { + {"units", json::array()}, + {"external", json::array({ + {{"extruder", 2}, {"loaded", true}, {"material", "PETG"}, {"preset_id", "third-spool"}}, + {{"extruder", 0}, {"loaded", true}, {"material", "PLA"}, {"preset_id", "main-spool"}} + })} + }; + + DevFilaSystemParser::ParseAgentFilament(filament, &obj, obj.GetFilaSystem().get()); + + REQUIRE(obj.vt_slot.size() == 3); + CHECK(obj.vt_slot[0].id == "255"); + CHECK(obj.vt_slot[0].setting_id == "main-spool"); + CHECK(obj.vt_slot[1].id == "254"); + CHECK(obj.vt_slot[2].id == "253"); + CHECK(obj.vt_slot[2].setting_id == "third-spool"); +} + TEST_CASE("Generic AMS tray index map uses cumulative lane counts", "[DevMapping]") { MachineObject obj(nullptr, nullptr, "test", "test_dev", "127.0.0.1"); @@ -392,3 +440,171 @@ TEST_CASE("Switch-bound AMS with an invalid track is excluded from mapping", "[D DevFilaSystemParser::ParseV1_0(print_push, &obj_no_switch, obj_no_switch.GetFilaSystem().get(), false); REQUIRE(obj_no_switch.GetFilaSystem()->GetAmsList().count("0") == 0); } + +TEST_CASE("Generic external spools map by their virtual tray id", "[DevMapping]") +{ + MachineObject obj(nullptr, nullptr, "test", "test_dev", "127.0.0.1"); + + // One external spool per extruder: 255 -> extruder 0, 254 -> 1, 253 -> 2, 252 -> 3. + const json filament = { + {"units", json::array()}, + {"external", json::array({ + {{"extruder", 0}, {"loaded", true}, {"material", "PLA"}, {"color", "FF5733FF"}, {"preset_id", "P0"}}, + {{"extruder", 1}, {"loaded", true}, {"material", "ASA"}, {"color", "33C1FFFF"}, {"preset_id", "P1"}}, + {{"extruder", 2}, {"loaded", true}, {"material", "ABS"}, {"color", "8D33FFFF"}, {"preset_id", "P2"}}, + {{"extruder", 3}, {"loaded", true}, {"material", "PETG"}, {"color", "44AA22FF"}, {"preset_id", "P3"}} + })} + }; + DevFilaSystemParser::ParseAgentFilament(filament, &obj, obj.GetFilaSystem().get()); + REQUIRE(obj.vt_slot.size() == 4); + REQUIRE(obj.vt_slot[2].id == "253"); + + // Matches only extruder 2's external spool (ABS / 8D33FFFF). + FilamentInfo fila; + fila.id = 0; + fila.type = "ABS"; + fila.color = "8D33FFFF"; + + std::vector result; + std::vector map_opt(4, false); + map_opt[MappingOption::USE_LEFT_EXT] = true; + map_opt[MappingOption::USE_RIGHT_EXT] = true; + + DevMappingUtil::ams_filament_mapping(&obj, {fila}, result, map_opt, {}, false); + + REQUIRE(result.size() == 1); + // The external spool keeps its virtual tray id (253), never the AMS ams_id * 4 + slot_id form. + CHECK(result[0].tray_id == 253); + CHECK(result[0].ams_id == "253"); +} + +TEST_CASE("Mapping validation accepts only reported generic virtual slots", "[DevMapping]") +{ + MachineObject obj(nullptr, nullptr, "test", "test_dev", "127.0.0.1"); + + const json filament = { + {"units", json::array()}, + {"external", json::array({ + {{"extruder", 0}, {"loaded", true}, {"material", "PLA"}, {"preset_id", "P0"}}, + {{"extruder", 1}, {"loaded", true}, {"material", "ASA"}, {"preset_id", "P1"}}, + {{"extruder", 2}, {"loaded", true}, {"material", "ABS"}, {"preset_id", "P2"}} + })} + }; + DevFilaSystemParser::ParseAgentFilament(filament, &obj, obj.GetFilaSystem().get()); + REQUIRE(obj.vt_slot.size() == 3); + + FilamentInfo reported; + reported.id = 0; + reported.tray_id = 253; + reported.ams_id = "253"; // extruder 2's external slot, present in vt_slot + reported.slot_id = "0"; + { + std::vector result{reported}; + CHECK(DevMappingUtil::is_valid_mapping_result(&obj, result, false)); + } + + // A virtual id the printer never reported is rejected and reset. + FilamentInfo missing; + missing.id = 1; + missing.tray_id = 252; + missing.ams_id = "252"; + missing.slot_id = "0"; + { + std::vector result{missing}; + CHECK_FALSE(DevMappingUtil::is_valid_mapping_result(&obj, result, false)); + CHECK(result[0].tray_id == -1); + } +} + +TEST_CASE("Generic AMS units on every extruder join the mapping", "[DevMapping]") +{ + MachineObject obj(nullptr, nullptr, "test", "test_dev", "127.0.0.1"); + + const std::vector materials = {"PLA", "ASA", "ABS", "PETG", "TPU"}; + const std::vector colors = {"FF5733FF", "33C1FFFF", "8D33FFFF", "44AA22FF", "FFAA00FF"}; + + json filament = { {"units", json::array()}, {"external", json::array()} }; + for (int extruder = 0; extruder < 5; ++extruder) { + json unit = { {"id", std::to_string(extruder)}, {"extruder", extruder}, {"slots", json::array()} }; + unit["slots"].push_back({{"index", 0}, {"loaded", true}, {"material", materials[extruder]}, {"color", colors[extruder]}}); + filament["units"].push_back(std::move(unit)); + } + DevFilaSystemParser::ParseAgentFilament(filament, &obj, obj.GetFilaSystem().get()); + REQUIRE(obj.GetFilaSystem()->GetAmsCount() == 5); + + // PETG / 44AA22FF is extruder 3's AMS slot, with cumulative generic tray index 3. + FilamentInfo fila; + fila.id = 0; + fila.type = "PETG"; + fila.color = "44AA22FF"; + + // The sidebar sync enables both legacy AMS flags, so every extruder participates. + std::vector result; + std::vector map_opt(4, false); + map_opt[MappingOption::USE_LEFT_AMS] = true; + map_opt[MappingOption::USE_RIGHT_AMS] = true; + DevMappingUtil::ams_filament_mapping(&obj, {fila}, result, map_opt, {}, false); + REQUIRE(result.size() == 1); + CHECK(result[0].ams_id == "3"); + CHECK(result[0].tray_id == 3); + + // SelectMachine's right-side split passes only the right flag: extruder 3's unit stays out and + // nothing in extruder 0's unit matches PETG, so the filament is left unmapped. + std::vector split_result; + map_opt[MappingOption::USE_LEFT_AMS] = false; + DevMappingUtil::ams_filament_mapping(&obj, {fila}, split_result, map_opt, {}, false); + REQUIRE(split_result.size() == 1); + CHECK(split_result[0].tray_id == -1); +} + +TEST_CASE("Generic external spool is suppressed when its extruder has AMS filament", "[DevMapping]") +{ + MachineObject obj(nullptr, nullptr, "test", "test_dev", "127.0.0.1"); + + // One AMS unit on extruder 2 (red ABS) plus one external spool per extruder; extruder 2's + // external spool is green ABS, which is what the sliced filament matches exactly. + const json filament = { + {"units", json::array({ + {{"id", "2"}, {"extruder", 2}, {"slots", json::array({ + {{"index", 0}, {"loaded", true}, {"material", "ABS"}, {"color", "FF0000FF"}} + })}} + })}, + {"external", json::array({ + {{"extruder", 0}, {"loaded", true}, {"material", "PLA"}, {"color", "FF5733FF"}}, + {{"extruder", 1}, {"loaded", true}, {"material", "ASA"}, {"color", "33C1FFFF"}}, + {{"extruder", 2}, {"loaded", true}, {"material", "ABS"}, {"color", "00FF00FF"}}, + {{"extruder", 3}, {"loaded", true}, {"material", "PETG"}, {"color", "44AA22FF"}}, + {{"extruder", 4}, {"loaded", true}, {"material", "TPU"}, {"color", "FFAA00FF"}} + })} + }; + DevFilaSystemParser::ParseAgentFilament(filament, &obj, obj.GetFilaSystem().get()); + REQUIRE(obj.vt_slot.size() == 5); + REQUIRE(obj.vt_slot[2].id == "253"); + + FilamentInfo fila; + fila.id = 0; + fila.type = "ABS"; + fila.color = "00FF00FF"; + + std::vector map_opt(4, false); + map_opt[MappingOption::USE_LEFT_AMS] = true; + map_opt[MappingOption::USE_RIGHT_AMS] = true; + map_opt[MappingOption::USE_LEFT_EXT] = true; + map_opt[MappingOption::USE_RIGHT_EXT] = true; + + // With suppression off the exact-match external spool (253) wins over the red AMS tray. + std::vector result; + DevMappingUtil::ams_filament_mapping(&obj, {fila}, result, map_opt, {}, false); + REQUIRE(result.size() == 1); + CHECK(result[0].tray_id == 253); + CHECK(result[0].ams_id == "253"); + + // With suppression on, extruder 2 already has AMS filament in this mapping, so its external + // spool is dropped and the AMS tray is the only candidate left. This fixture has only that + // single generic unit, so its cumulative tray index is 0. + std::vector suppressed; + DevMappingUtil::ams_filament_mapping(&obj, {fila}, suppressed, map_opt, {}, true); + REQUIRE(suppressed.size() == 1); + CHECK(suppressed[0].ams_id == "2"); + CHECK(suppressed[0].tray_id == 0); +}