From 799fbee86c57ab47226cab743a9e3a214b742f16 Mon Sep 17 00:00:00 2001 From: Lam Wei Lun Date: Tue, 29 Sep 2026 14:02:40 +0800 Subject: [PATCH] feat: enable filament mapping and resolve printer-set AMS trays by material type Drop the index-correlation gate so mapped prints are gated only on the filament_mapping capability Resolve AMS trays set on the printer UI to the matching Generic preset using their material type and color Skip the forced external-spool selection on printers without an AMS and add coverage for the mapping gates --- src/libslic3r/PresetBundle.cpp | 64 +++++--- src/slic3r/GUI/FilamentMappingUtils.hpp | 9 + src/slic3r/GUI/SelectMachine.cpp | 15 +- src/slic3r/GUI/SendMultiMachinePage.cpp | 10 +- src/slic3r/Utils/NetworkAgentFactory.hpp | 27 ++- src/slic3r/Utils/OrcaMqttConnection.cpp | 3 +- src/slic3r/Utils/OrcaPrinterAgent.cpp | 17 +- tests/fff_print/test_multifilament.cpp | 37 +++++ tests/libslic3r/CMakeLists.txt | 1 + .../libslic3r/test_preset_bundle_ams_sync.cpp | 155 ++++++++++++++++++ tests/slic3rutils/test_orca_printer_agent.cpp | 56 +++++++ 11 files changed, 333 insertions(+), 61 deletions(-) create mode 100644 tests/libslic3r/test_preset_bundle_ams_sync.cpp diff --git a/src/libslic3r/PresetBundle.cpp b/src/libslic3r/PresetBundle.cpp index ac83f5cb1b..be2fce983a 100644 --- a/src/libslic3r/PresetBundle.cpp +++ b/src/libslic3r/PresetBundle.cpp @@ -3708,42 +3708,58 @@ unsigned int PresetBundle::sync_ams_list(std::vector= 0; j--) { - if (maps[j].slot_id == slot_id && maps[j].ams_id == ams_id) { - maps.erase(j); + // A tray written by the printer UI carries a material type but no + // OrcaSlicer preset id. Resolve it to the matching Generic preset with + // the tray's own color instead of dropping it (direct sync) or forcing + // "Generic PLA" (mapping sync). Placeholders and typeless trays keep + // the previous behavior. + const auto tray_type = ams.opt_string("filament_type", 0u); + if (is_placeholder || tray_type.empty()) { + if (use_map) { + for (int j = maps.size() - 1; j >= 0; j--) { + if (maps[j].slot_id == slot_id && maps[j].ams_id == ams_id) { + maps.erase(j); + } } + ams_filament_presets.push_back("Generic PLA");//for unknow matieral + auto default_unknown_color = "#CECECE"; + ams_filament_colors.push_back(default_unknown_color); + ams_filament_color_types.push_back("1"); + if (filament_multi_color.size() == 0) { + filament_multi_color.push_back(default_unknown_color); + } + ams_multi_color_filment.push_back(filament_multi_color); + } else if (is_placeholder) { + // Orca: push placeholders to keep index alignment with ams_infos + ams_filament_presets.push_back(""); + ams_filament_colors.push_back(""); + ams_filament_color_types.push_back(""); + ams_multi_color_filment.push_back({}); } - ams_filament_presets.push_back("Generic PLA");//for unknow matieral - auto default_unknown_color = "#CECECE"; - ams_filament_colors.push_back(default_unknown_color); - ams_filament_color_types.push_back("1"); - if (filament_multi_color.size() == 0) { - filament_multi_color.push_back(default_unknown_color); - } - ams_multi_color_filment.push_back(filament_multi_color); - } else if (is_placeholder) { - // Orca: push placeholders to keep index alignment with ams_infos - ams_filament_presets.push_back(""); - ams_filament_colors.push_back(""); - ams_filament_color_types.push_back(""); - ams_multi_color_filment.push_back({}); + continue; } - continue; } if (!filament_changed && this->filament_presets.size() > ams_filament_presets.size()) { ams_filament_presets.push_back(this->filament_presets[ams_filament_presets.size()]); ams_filament_colors.push_back(filament_color); ams_filament_color_types.push_back(filament_color_type); ams_multi_color_filment.push_back(filament_multi_color); + ams_infos.back().valid = true; continue; } bool has_type = false; auto filament_type = ams.opt_string("filament_type", 0u); - auto iter = std::find_if(filaments.begin(), filaments.end(), [this, &filament_id, &has_type, filament_type](auto &f) { - has_type |= f.config.opt_string("filament_type", 0u) == filament_type; - return f.is_compatible && filaments.get_preset_base(f) == &f && f.filament_id == filament_id; }); - warn_ambiguous_filament_id_match(filaments, iter, filament_id); + auto iter = filaments.end(); + if (!filament_id.empty()) { + iter = std::find_if(filaments.begin(), filaments.end(), [this, &filament_id, &has_type, filament_type](auto &f) { + has_type |= f.config.opt_string("filament_type", 0u) == filament_type; + return f.is_compatible && filaments.get_preset_base(f) == &f && f.filament_id == filament_id; }); + warn_ambiguous_filament_id_match(filaments, iter, filament_id); + } else { + // The material type is the only identity a printer-set tray carries. + has_type = std::any_of(filaments.begin(), filaments.end(), [&filament_type](auto &f) { + return f.is_compatible && f.config.opt_string("filament_type", 0u) == filament_type; }); + } if (iter == filaments.end()) { BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << boost::format(": filament_id %1% not found or system or compatible") % filament_id; if (!filament_type.empty()) { @@ -3790,6 +3806,7 @@ unsigned int PresetBundle::sync_ams_list(std::vectorprinter_agent_id == ORCA_PRINTER_AGENT_ID) + drop_forced_external_selection(obj_->HasAms(), ams_mapping_array2); + // OrcaSonar: a mapped print requires the connector to advertise - // filament_mapping and the index correlation to be verified. Refuse rather - // than start with the map silently dropped; Bambu keeps its behavior. + // filament_mapping. Refuse rather than start with the map silently dropped; + // Bambu keeps its behavior. if (obj_->printer_agent_id == ORCA_PRINTER_AGENT_ID) { - const bool mapping_available = obj_->is_support_filament_mapping && ORCA_FILAMENT_MAPPING_CORRELATION_VERIFIED; - if (!mapping_available && has_engaged_filament_mapping(ams_mapping_array2)) { - BOOST_LOG_TRIVIAL(warning) << "print_job: filament mapping unavailable (capability=" << obj_->is_support_filament_mapping - << ", correlation_verified=" << ORCA_FILAMENT_MAPPING_CORRELATION_VERIFIED << "); refusing mapped print"; + if (!obj_->is_support_filament_mapping && has_engaged_filament_mapping(ams_mapping_array2)) { + BOOST_LOG_TRIVIAL(warning) << "print_job: connector does not advertise filament_mapping; refusing mapped print"; m_status_bar->set_status_text(_L("AMS filament mapping is not available for this printer. Clear the AMS mapping before printing.")); Enable_Send_Button(true); return; diff --git a/src/slic3r/GUI/SendMultiMachinePage.cpp b/src/slic3r/GUI/SendMultiMachinePage.cpp index c95b2b54bf..d9ee74f4ce 100644 --- a/src/slic3r/GUI/SendMultiMachinePage.cpp +++ b/src/slic3r/GUI/SendMultiMachinePage.cpp @@ -744,16 +744,18 @@ void SendMultiMachinePage::on_send(wxCommandEvent& event) if (!wxGetApp().is_blocking_printing(obj)) { PrintParams params = request_params(obj); + // A device with no AMS unit has one source, the external spool: OrcaSlicer's + // auto-mapping force-selects it, which is not a lane choice. + if (obj->printer_agent_id == ORCA_PRINTER_AGENT_ID) + drop_forced_external_selection(obj->HasAms(), params.ams_mapping2); // OrcaSonar: a mapped print requires the connector to advertise // filament_mapping, and a partially mapped print must not silently drop a // used filament. Any entry the serializer would put on the wire engages the // gate, external slots ({255,0}/{254,0}) included; the extra-spool branch // rewrites to exactly those. Bambu is unchanged. if (obj->printer_agent_id == ORCA_PRINTER_AGENT_ID) { - const bool mapping_available = obj->is_support_filament_mapping && ORCA_FILAMENT_MAPPING_CORRELATION_VERIFIED; - if (!mapping_available && has_engaged_filament_mapping(params.ams_mapping2)) { - BOOST_LOG_TRIVIAL(warning) << "SendMultiMachinePage: filament mapping unavailable (capability=" << obj->is_support_filament_mapping - << ", correlation_verified=" << ORCA_FILAMENT_MAPPING_CORRELATION_VERIFIED << "); refusing mapped print for " + if (!obj->is_support_filament_mapping && has_engaged_filament_mapping(params.ams_mapping2)) { + BOOST_LOG_TRIVIAL(warning) << "SendMultiMachinePage: connector does not advertise filament_mapping; refusing mapped print for " << obj->get_dev_id(); MessageDialog msg_wingow(nullptr, _L("AMS filament mapping is not available for this printer. Clear the AMS mapping before printing."), "", wxICON_WARNING | wxOK); msg_wingow.ShowModal(); diff --git a/src/slic3r/Utils/NetworkAgentFactory.hpp b/src/slic3r/Utils/NetworkAgentFactory.hpp index 764a41edb5..f4a30b877e 100644 --- a/src/slic3r/Utils/NetworkAgentFactory.hpp +++ b/src/slic3r/Utils/NetworkAgentFactory.hpp @@ -15,15 +15,7 @@ namespace Slic3r { static constexpr char ORCA_PRINTER_AGENT_ID[] = "orca"; -static constexpr char BBL_PRINTER_AGENT_ID[] = "bbl"; - -// Index-correlation merge gate (plan PR 3). The per-print `filament_mapping` -// serializer is shipped disabled until a slice-level test proves that the -// `ams_mapping2` array position equals the toolchange identifier the generated -// G-code emits (T / next_filament_id). Both the GUI send gates and -// OrcaPrinterAgent::start_sdcard_print consult this, so mapping is refused -// visibly rather than silently dropped. Flip to true only with that test. -static constexpr bool ORCA_FILAMENT_MAPPING_CORRELATION_VERIFIED = false; +static constexpr char BBL_PRINTER_AGENT_ID[] = "bbl"; // Factory function type for creating printer agents using PrinterAgentFactory = @@ -32,17 +24,20 @@ using PrinterAgentFactory = // Information about a registered printer agent struct PrinterAgentInfo { - std::string id; // Registry/config key, e.g. "orca" or a plugin AgentInfo::id - std::string display_name; // e.g., "Orca Native", "Bambu Lab" - std::string plugin_identifier; // Empty for built-ins, otherwise ;; - PrinterAgentFactory factory; // Function to create the agent + std::string id; // Registry/config key, e.g. "orca" or a plugin AgentInfo::id + std::string display_name; // e.g., "Orca Native", "Bambu Lab" + std::string plugin_identifier; // Empty for built-ins, otherwise ;; + PrinterAgentFactory factory; // Function to create the agent bool is_plugin() const { return !plugin_identifier.empty(); } PrinterAgentInfo(const std::string& id_, const std::string& display_name_, PrinterAgentFactory factory_) : id(id_), display_name(display_name_), factory(std::move(factory_)) {} - PrinterAgentInfo(const std::string& id_, const std::string& display_name_, const std::string& plugin_identifier, PrinterAgentFactory factory_) + PrinterAgentInfo(const std::string& id_, + const std::string& display_name_, + const std::string& plugin_identifier, + PrinterAgentFactory factory_) : id(id_), display_name(display_name_), plugin_identifier(plugin_identifier), factory(std::move(factory_)) {} }; @@ -124,9 +119,9 @@ public: * @param log_dir Directory for log files * @return Shared pointer to IPrinterAgent, or nullptr if ID not found */ - static std::shared_ptr create_printer_agent_by_id(const std::string& id, + static std::shared_ptr create_printer_agent_by_id(const std::string& id, std::shared_ptr cloud_agent, - const std::string& log_dir); + const std::string& log_dir); /** * Clear the printer agent cache. diff --git a/src/slic3r/Utils/OrcaMqttConnection.cpp b/src/slic3r/Utils/OrcaMqttConnection.cpp index d33e4182c6..5ff4f8c430 100644 --- a/src/slic3r/Utils/OrcaMqttConnection.cpp +++ b/src/slic3r/Utils/OrcaMqttConnection.cpp @@ -554,7 +554,6 @@ void OrcaMqttConnection::connect_and_read() { ws_handshake(*connection, current_config, endpoint); - expires_never(*connection); // Auth precedence: a bearer_provider authenticates the WebSocket upgrade, so the // CONNECT username/password fields are omitted entirely (the cloud form). const bool use_bearer = static_cast(current_config.bearer_provider); @@ -569,6 +568,8 @@ void OrcaMqttConnection::connect_and_read() { ws_read(*connection, buffer, connack_error); if (connack_error) throw boost::system::system_error(connack_error, "read Orca MQTT CONNACK"); + + expires_never(*connection); const std::string connack = boost::beast::buffers_to_string(buffer.data()); // rc: 0 accepted, 1..5 refusal, -1 malformed/not a CONNACK. const int rc = (connack.size() == 4 && static_cast(connack[0]) == 0x20) diff --git a/src/slic3r/Utils/OrcaPrinterAgent.cpp b/src/slic3r/Utils/OrcaPrinterAgent.cpp index 41ffcf8b2e..42e52b5510 100644 --- a/src/slic3r/Utils/OrcaPrinterAgent.cpp +++ b/src/slic3r/Utils/OrcaPrinterAgent.cpp @@ -45,9 +45,7 @@ namespace fs = boost::filesystem; // Per-device filament_mapping capability, mirrored from the get_capabilities // reply in merge_capabilities and read by start_sdcard_print so the field is -// refused defensively when the connector never advertised it or the -// index-correlation merge gate (ORCA_FILAMENT_MAPPING_CORRELATION_VERIFIED) is -// not satisfied. +// refused defensively when the connector never advertised it. std::mutex g_filament_mapping_mutex; std::unordered_map g_filament_mapping_cache; @@ -1708,16 +1706,13 @@ int OrcaPrinterAgent::start_sdcard_print(PrintParams params, OnUpdateStatusFn up const std::string target = params.dst_file.empty() ? remote_gcode_name(params) : fs::path(params.dst_file).filename().string(); // Per-print mapping. A mapped print is refused when the connector did not - // advertise filament_mapping or the index correlation is unverified: the GUI - // send gates make this visible first, and this is the defensive gate for - // callers that bypass them (calibration, plugin). Never start a mapped print - // with the map silently dropped. + // advertise filament_mapping: the GUI send gates make this visible first, and + // this is the defensive gate for callers that bypass them (calibration, + // plugin). Never start a mapped print with the map silently dropped. const nlohmann::json filament_mapping = build_filament_mapping(params.ams_mapping2); if (!filament_mapping.empty()) { - const bool mapping_capable = filament_mapping_advertised(params.dev_id); - if (!mapping_capable || !ORCA_FILAMENT_MAPPING_CORRELATION_VERIFIED) { - BOOST_LOG_TRIVIAL(warning) << "OrcaPrinterAgent: refusing mapped print (capable=" << mapping_capable - << ", correlation_verified=" << ORCA_FILAMENT_MAPPING_CORRELATION_VERIFIED << ")"; + if (!filament_mapping_advertised(params.dev_id)) { + BOOST_LOG_TRIVIAL(warning) << "OrcaPrinterAgent: refusing mapped print, connector does not advertise filament_mapping"; return ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED; } BOOST_LOG_TRIVIAL(info) << "OrcaPrinterAgent: start_sdcard_print emitting filament_mapping entries=" << filament_mapping.size(); diff --git a/tests/fff_print/test_multifilament.cpp b/tests/fff_print/test_multifilament.cpp index 484b7fb68a..dca18352a6 100644 --- a/tests/fff_print/test_multifilament.cpp +++ b/tests/fff_print/test_multifilament.cpp @@ -40,6 +40,20 @@ static std::set tools_for_role(const std::string& gcode, const std::string& return tools; } +// Every id named by a Tn command anywhere in `gcode`. A non-BBL single-extruder multi-filament +// setup emits T, and that id is what the Klipper toolchange macro consumes. +static std::set tool_ids(const std::string& gcode) +{ + std::set tools; + GCodeReader reader; + reader.parse_buffer(gcode, [&tools](GCodeReader&, const GCodeReader::GCodeLine& line) { + const std::string cmd(line.cmd()); + if (cmd.size() >= 2 && cmd[0] == 'T' && std::isdigit((unsigned char)cmd[1])) + tools.insert(std::stoi(cmd.substr(1))); + }); + return tools; +} + // X where the nozzle sits while each tagged _WAIT_FOR_TEMP_ON_WIPE_TOWER M109 blocks: // the nearest preceding G1 carrying an X (the park travel emitted just before the wait). static std::vector wait_park_xs(const std::string& gcode) @@ -287,6 +301,29 @@ TEST_CASE("Each feature prints with its assigned filament (three filaments)", "[ CHECK(tools_for_role(gcode, "infill") == std::set{ 1 }); // filament 2 } +// The per-print wire mapping keys each entry by the filament's index in the config filament +// arrays (the ams_mapping2 position). Pin that the id the toolchange emits is that index even +// when the used filaments skip a middle one: filament 2 (index 1) is configured but unused, so +// a renumbering to the used set would emit {0, 1} instead of {0, 2} and aim the map at the +// wrong lane. +TEST_CASE("Toolchange ids keep the config index of the used filament when a middle filament is unused", "[MultiFilament]") +{ + const std::string gcode = slice({ cube(20) }, + multifilament_config(3, { + { "sparse_infill_filament_id", 1 }, + { "internal_solid_filament_id", 1 }, + { "top_surface_filament_id", 1 }, + { "bottom_surface_filament_id", 1 }, + { "outer_wall_filament_id", 3 }, + { "inner_wall_filament_id", 3 }, + { "skirt_loops", 0 }, + { "brim_type", "no_brim" }, + })); + CHECK(tools_for_role(gcode, "perimeter") == std::set{ 2 }); // filament 3 + CHECK(tools_for_role(gcode, "infill") == std::set{ 0 }); // filament 1 + CHECK(tool_ids(gcode) == std::set{ 0, 2 }); +} + // The override must survive tool ordering: object 1's walls print on their filament's // tool, object 0 stays on the first. If dropped, every wall prints on tool 0. TEST_CASE("Per-object wall filament override is honored", "[MultiFilament]") diff --git a/tests/libslic3r/CMakeLists.txt b/tests/libslic3r/CMakeLists.txt index 185dce37da..86db60930e 100644 --- a/tests/libslic3r/CMakeLists.txt +++ b/tests/libslic3r/CMakeLists.txt @@ -16,6 +16,7 @@ add_executable(${_TEST_NAME}_tests test_config_variant_expansion.cpp test_toolordering_nozzle_group.cpp test_preset_bundle_loading.cpp + test_preset_bundle_ams_sync.cpp test_preset_setting_id.cpp test_preset_diff.cpp test_vendor_cache.cpp diff --git a/tests/libslic3r/test_preset_bundle_ams_sync.cpp b/tests/libslic3r/test_preset_bundle_ams_sync.cpp new file mode 100644 index 0000000000..ed57381a51 --- /dev/null +++ b/tests/libslic3r/test_preset_bundle_ams_sync.cpp @@ -0,0 +1,155 @@ +#include + +#include +#include +#include +#include + +#include "libslic3r/Preset.hpp" +#include "libslic3r/PresetBundle.hpp" + +using namespace Slic3r; + +namespace { + +// In-memory system filament preset. Mirrors the helper in test_preset_bundle_loading.cpp. +Preset &add_system_filament(PresetBundle &bundle, const std::string &name, const std::string &filament_id, const std::string &filament_type) +{ + DynamicPrintConfig config(bundle.filaments.default_preset().config); + config.option(BBL_JSON_KEY_INHERITS, true)->value = ""; + config.option("filament_type", true)->values = {filament_type}; + Preset &preset = bundle.filaments.load_preset(std::string(), name, config, /*select=*/false); + preset.is_system = true; + preset.filament_id = filament_id; + return preset; +} + +// One AMS tray entry as build_filament_ams_list produces it. +DynamicPrintConfig make_tray(const std::string &filament_id, const std::string &filament_type, const std::string &color, const std::string &ams_id, + const std::string &slot_id) +{ + DynamicPrintConfig tray; + tray.set_key_value("filament_id", new ConfigOptionStrings{filament_id}); + tray.set_key_value("filament_type", new ConfigOptionStrings{filament_type}); + tray.set_key_value("filament_colour", new ConfigOptionStrings{color}); + tray.set_key_value("filament_colour_type", new ConfigOptionStrings{"1"}); + tray.set_key_value("filament_multi_colour", new ConfigOptionStrings{}); + tray.set_key_value("ams_id", new ConfigOptionStrings{ams_id}); + tray.set_key_value("slot_id", new ConfigOptionStrings{slot_id}); + return tray; +} + +} // namespace + +// A tray the printer UI wrote carries a material type but no OrcaSlicer preset id. It must still +// sync, resolved to Generic with the tray's own color. +TEST_CASE("AMS sync resolves a printer-set tray to Generic by material type", "[Preset][AMS]") +{ + PresetBundle bundle; + add_system_filament(bundle, "Generic PETG @Q2", "OFYPdQJh", "PETG"); + add_system_filament(bundle, "Generic PLA @Q2", "GFL99", "PLA"); + + bundle.filament_ams_list[0] = make_tray("OFYPdQJh", "PETG", "#FE717A", "0", "0"); // preset-backed + bundle.filament_ams_list[1] = make_tray("", "PETG", "#898F9B", "0", "1"); // printer-set, no id + bundle.filament_ams_list[2] = make_tray("", "PLA", "#FAFAFA", "0", "2"); // printer-set, no id + bundle.filament_ams_list[3] = make_tray("", "", "#000000", "0", "3"); // empty slot + + std::vector> unknowns; + std::map maps; + MergeFilamentInfo merge; + + const unsigned int count = bundle.sync_ams_list(unknowns, /*use_map=*/false, maps, /*enable_append=*/false, merge); + + CHECK(count == 3); + REQUIRE(bundle.filament_presets.size() == 3); + CHECK(bundle.filament_presets[0] == "Generic PETG @Q2"); + CHECK(bundle.filament_presets[1] == "Generic PETG @Q2"); + CHECK(bundle.filament_presets[2] == "Generic PLA @Q2"); + + const ConfigOptionStrings *colors = bundle.project_config.option("filament_colour"); + REQUIRE(colors != nullptr); + REQUIRE(colors->values.size() == 3); + CHECK(colors->values[0] == "#FE717A"); + CHECK(colors->values[1] == "#898F9B"); + CHECK(colors->values[2] == "#FAFAFA"); +} + +// Without a preset id and without a material type there is nothing to resolve; the tray stays +// skipped in a direct sync. +TEST_CASE("AMS sync skips a tray without a preset id or a material type", "[Preset][AMS]") +{ + PresetBundle bundle; + add_system_filament(bundle, "Generic PETG @Q2", "OFYPdQJh", "PETG"); + bundle.filament_ams_list[0] = make_tray("", "", "#000000", "0", "0"); + + std::vector> unknowns; + std::map maps; + MergeFilamentInfo merge; + + CHECK(bundle.sync_ams_list(unknowns, /*use_map=*/false, maps, /*enable_append=*/false, merge) == 0); + CHECK(bundle.filament_presets.empty()); +} + +// Guard the pre-existing behavior: a non-empty id that matches no preset still resolves by type. +TEST_CASE("AMS sync still resolves an unmatched preset id by material type", "[Preset][AMS]") +{ + PresetBundle bundle; + add_system_filament(bundle, "Generic PETG @Q2", "OFYPdQJh", "PETG"); + bundle.filament_ams_list[0] = make_tray("REMOVED_ID", "PETG", "#123456", "0", "0"); + + std::vector> unknowns; + std::map maps; + MergeFilamentInfo merge; + + CHECK(bundle.sync_ams_list(unknowns, /*use_map=*/false, maps, /*enable_append=*/false, merge) == 1); + REQUIRE(bundle.filament_presets.size() == 1); + CHECK(bundle.filament_presets[0] == "Generic PETG @Q2"); +} + +// Mapping-mode sync used to force "Generic PLA" for an id-less tray; it must use the type instead. +TEST_CASE("AMS sync in mapping mode resolves a printer-set tray to Generic, not Generic PLA", "[Preset][AMS]") +{ + PresetBundle bundle; + add_system_filament(bundle, "Generic PETG @Q2", "OFYPdQJh", "PETG"); + add_system_filament(bundle, "Generic PLA @Q2", "GFL99", "PLA"); + + bundle.filament_presets = {"Generic PLA @Q2"}; + bundle.project_config.option("filament_colour")->values = {"#000000"}; + bundle.project_config.option("filament_colour_type")->values = {"1"}; + + bundle.filament_ams_list[0] = make_tray("", "PETG", "#898F9B", "0", "1"); + + std::map maps; + maps[0] = AMSMapInfo{"0", "1"}; + + std::vector> unknowns; + MergeFilamentInfo merge; + + const unsigned int count = bundle.sync_ams_list(unknowns, /*use_map=*/true, maps, /*enable_append=*/false, merge); + + CHECK(count == 1); + REQUIRE(bundle.filament_presets.size() == 1); + CHECK(bundle.filament_presets[0] == "Generic PETG @Q2"); + CHECK(bundle.project_config.option("filament_colour")->values[0] == "#898F9B"); +} + +// The dialog builds ams_mapping2 one entry per filament_presets index, while the generated +// G-code's toolchange names the filament's index in the slice's filament config arrays. Those +// are the same index only if full_config() collects the filament arrays in filament_presets +// order; if a refactor reorders one side, every mapped print aims at the wrong lane. +TEST_CASE("Full config filament arrays follow the selected filament preset order", "[Preset][AMS]") +{ + PresetBundle bundle; + add_system_filament(bundle, "Generic PETG @Q2", "OFYPdQJh", "PETG"); + add_system_filament(bundle, "Generic PLA @Q2", "GFL99", "PLA"); + + // Deliberately not the load order: the arrays must follow this selection. + bundle.filament_presets = {"Generic PLA @Q2", "Generic PETG @Q2"}; + + const DynamicPrintConfig full = bundle.full_config(/*apply_extruder=*/false); + REQUIRE(full.option("filament_type") != nullptr); + CHECK(full.option("filament_settings_id")->values == std::vector{"Generic PLA @Q2", "Generic PETG @Q2"}); + CHECK(full.option("filament_type")->values == std::vector{"PLA", "PETG"}); + // The AMS sync matches trays by filament_id, so this array must follow the same order. + CHECK(full.option("filament_ids")->values == std::vector{"GFL99", "OFYPdQJh"}); +} diff --git a/tests/slic3rutils/test_orca_printer_agent.cpp b/tests/slic3rutils/test_orca_printer_agent.cpp index 5e2a73f7ad..6fe7b96c8d 100644 --- a/tests/slic3rutils/test_orca_printer_agent.cpp +++ b/tests/slic3rutils/test_orca_printer_agent.cpp @@ -154,6 +154,45 @@ TEST_CASE("the GUI mapping gate engages exactly the entries the serializer sends } } +// The used-unmapped refusal reads m_ams_mapping_result: an entry with no target carries +// empty ams_id/slot_id, while an external-spool assignment (ams_id 255/254) is a real +// target. A wholly unmapped print reports no target and falls to the existing send flow. +TEST_CASE("used-filament targets split mapped from unmapped", "[OrcaPrinterAgent]") { + using Slic3r::GUI::has_any_mapped_target; + using Slic3r::GUI::has_used_filament_without_target; + + Slic3r::FilamentInfo box; box.ams_id = "0"; box.slot_id = "1"; // box slot + Slic3r::FilamentInfo external; external.ams_id = "255"; external.slot_id = "0"; // external spool + Slic3r::FilamentInfo unmapped; // no target + + CHECK_FALSE(has_used_filament_without_target({box, external})); + CHECK(has_used_filament_without_target({box, unmapped})); + CHECK(has_used_filament_without_target({external, unmapped})); + CHECK(has_used_filament_without_target({unmapped})); // wholly unmapped: all-invalid flow, not this refusal + CHECK(has_any_mapped_target({box, unmapped})); + CHECK(has_any_mapped_target({external, unmapped})); + CHECK_FALSE(has_any_mapped_target({unmapped})); +} + +// A device with no AMS units has one source, the external spool. OrcaSlicer's +// auto-mapping force-selects it for every filament; that is not a lane choice, so it +// must not gate the print or reach print.gcode_file. +TEST_CASE("a no-AMS device drops the forced external-spool selection", "[OrcaPrinterAgent]") { + using Slic3r::GUI::drop_forced_external_selection; + using Slic3r::GUI::has_engaged_filament_mapping; + + std::string external_only = R"([{"ams_id":255,"slot_id":0}])"; + drop_forced_external_selection(/*device_has_ams=*/false, external_only); + CHECK(external_only.empty()); + CHECK_FALSE(has_engaged_filament_mapping(external_only)); + CHECK(Probe::build_filament_mapping(external_only).empty()); + + std::string box_mapping = R"([{"ams_id":0,"slot_id":2}])"; + drop_forced_external_selection(/*device_has_ams=*/true, box_mapping); + CHECK(box_mapping == R"([{"ams_id":0,"slot_id":2}])"); + CHECK(has_engaged_filament_mapping(box_mapping)); +} + // An empty mapping must leave the gcode_file payload byte-identical to today: // exactly command, sequence_id and param, with no filament_mapping key. TEST_CASE("gcode_file payload omits filament_mapping when the map is empty", "[OrcaPrinterAgent]") { @@ -172,6 +211,23 @@ TEST_CASE("gcode_file payload omits filament_mapping when the map is empty", "[O CHECK(with["print"]["filament_mapping"][0]["filament_index"] == 0); } +// The defensive gate: a mapped print is refused before anything is published when the +// connector never advertised filament_mapping. The GUI send gates make this visible first; +// this covers callers that bypass them (calibration, plugin). A sentinel-only mapping does +// not engage the gate and falls through to the normal publish path. +TEST_CASE("an engaged mapping is refused when the connector never advertised filament_mapping", "[OrcaPrinterAgent]") { + OrcaPrinterAgent agent("/tmp"); + Slic3r::PrintParams params; + params.dev_id = "dev-no-mapping-cap"; + params.dst_file = "/tmp/job.gcode"; + params.ams_mapping2 = R"([{"ams_id":0,"slot_id":2}])"; + + CHECK(agent.start_sdcard_print(params, {}, {}) == ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED); + + params.ams_mapping2 = R"([{"ams_id":255,"slot_id":255}])"; + CHECK(agent.start_sdcard_print(params, {}, {}) == BAMBU_NETWORK_ERR_PRINT_LP_PUBLISH_MSG_FAILED); +} + // The FTP "send with record" transport does not exist on OrcaSonar. It must // report a non-success result so PrintJob falls back to start_print() rather // than treating a print that was never sent as successful.