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
This commit is contained in:
Lam Wei Lun
2026-09-29 14:02:40 +08:00
parent adcc470cff
commit 799fbee86c
11 changed files with 333 additions and 61 deletions
+41 -23
View File
@@ -3708,42 +3708,58 @@ unsigned int PresetBundle::sync_ams_list(std::vector<std::pair<DynamicPrintConfi
ams_array_maps.push_back(temp); ams_array_maps.push_back(temp);
index++; index++;
if (filament_id.empty()) { if (filament_id.empty()) {
if (use_map) { // A tray written by the printer UI carries a material type but no
for (int j = maps.size() - 1; j >= 0; j--) { // OrcaSlicer preset id. Resolve it to the matching Generic preset with
if (maps[j].slot_id == slot_id && maps[j].ams_id == ams_id) { // the tray's own color instead of dropping it (direct sync) or forcing
maps.erase(j); // "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 continue;
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;
} }
if (!filament_changed && this->filament_presets.size() > ams_filament_presets.size()) { 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_presets.push_back(this->filament_presets[ams_filament_presets.size()]);
ams_filament_colors.push_back(filament_color); ams_filament_colors.push_back(filament_color);
ams_filament_color_types.push_back(filament_color_type); ams_filament_color_types.push_back(filament_color_type);
ams_multi_color_filment.push_back(filament_multi_color); ams_multi_color_filment.push_back(filament_multi_color);
ams_infos.back().valid = true;
continue; continue;
} }
bool has_type = false; bool has_type = false;
auto filament_type = ams.opt_string("filament_type", 0u); 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) { auto iter = filaments.end();
has_type |= f.config.opt_string("filament_type", 0u) == filament_type; if (!filament_id.empty()) {
return f.is_compatible && filaments.get_preset_base(f) == &f && f.filament_id == filament_id; }); iter = std::find_if(filaments.begin(), filaments.end(), [this, &filament_id, &has_type, filament_type](auto &f) {
warn_ambiguous_filament_id_match(filaments, iter, filament_id); 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()) { if (iter == filaments.end()) {
BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << boost::format(": filament_id %1% not found or system or compatible") % filament_id; BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << boost::format(": filament_id %1% not found or system or compatible") % filament_id;
if (!filament_type.empty()) { if (!filament_type.empty()) {
@@ -3790,6 +3806,7 @@ unsigned int PresetBundle::sync_ams_list(std::vector<std::pair<DynamicPrintConfi
ams_filament_colors.push_back(filament_color); ams_filament_colors.push_back(filament_color);
ams_filament_color_types.push_back(filament_color_type); ams_filament_color_types.push_back(filament_color_type);
ams_multi_color_filment.push_back(filament_multi_color); ams_multi_color_filment.push_back(filament_multi_color);
ams_infos.back().valid = true;
unknowns.emplace_back(&ams, has_type ? L("The filament may not be compatible with the current machine settings. Generic filament presets will be used.") : unknowns.emplace_back(&ams, has_type ? L("The filament may not be compatible with the current machine settings. Generic filament presets will be used.") :
L("The filament model is unknown. Still using the previous filament preset.")); L("The filament model is unknown. Still using the previous filament preset."));
continue; continue;
@@ -3816,6 +3833,7 @@ unsigned int PresetBundle::sync_ams_list(std::vector<std::pair<DynamicPrintConfi
ams_filament_colors.push_back(filament_color); ams_filament_colors.push_back(filament_color);
ams_filament_color_types.push_back(filament_color_type); ams_filament_color_types.push_back(filament_color_type);
ams_multi_color_filment.push_back(filament_multi_color); ams_multi_color_filment.push_back(filament_multi_color);
ams_infos.back().valid = true;
} }
if (ams_filament_presets.empty()) if (ams_filament_presets.empty())
return 0; return 0;
+9
View File
@@ -37,6 +37,15 @@ inline bool has_engaged_filament_mapping(const std::string& ams_mapping2)
return false; return false;
} }
// A device with no AMS units has a single source: the external spool. OrcaSlicer's
// auto-mapping force-selects it for every filament (DevMapping.cpp), but that is not
// a lane choice: keep it out of the send gate and off print.gcode_file.
inline void drop_forced_external_selection(bool device_has_ams, std::string& ams_mapping2)
{
if (!device_has_ams)
ams_mapping2.clear();
}
// A used filament with no target would be silently dropped from the wire // A used filament with no target would be silently dropped from the wire
// mapping, so the print must be refused rather than run the wrong material. // mapping, so the print must be refused rather than run the wrong material.
// m_ams_mapping_result carries exactly the filaments the slice uses. // m_ams_mapping_result carries exactly the filaments the slice uses.
+9 -6
View File
@@ -3537,14 +3537,17 @@ void SelectMachineDialog::on_send_print()
get_ams_mapping_result(ams_mapping_array,ams_mapping_array2, ams_mapping_info); get_ams_mapping_result(ams_mapping_array,ams_mapping_array2, ams_mapping_info);
// 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. Bambu unchanged.
if (obj_->printer_agent_id == ORCA_PRINTER_AGENT_ID)
drop_forced_external_selection(obj_->HasAms(), ams_mapping_array2);
// OrcaSonar: a mapped print requires the connector to advertise // OrcaSonar: a mapped print requires the connector to advertise
// filament_mapping and the index correlation to be verified. Refuse rather // filament_mapping. Refuse rather than start with the map silently dropped;
// than start with the map silently dropped; Bambu keeps its behavior. // Bambu keeps its behavior.
if (obj_->printer_agent_id == ORCA_PRINTER_AGENT_ID) { if (obj_->printer_agent_id == ORCA_PRINTER_AGENT_ID) {
const bool mapping_available = obj_->is_support_filament_mapping && ORCA_FILAMENT_MAPPING_CORRELATION_VERIFIED; if (!obj_->is_support_filament_mapping && has_engaged_filament_mapping(ams_mapping_array2)) {
if (!mapping_available && has_engaged_filament_mapping(ams_mapping_array2)) { BOOST_LOG_TRIVIAL(warning) << "print_job: connector does not advertise filament_mapping; refusing mapped print";
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";
m_status_bar->set_status_text(_L("AMS filament mapping is not available for this printer. Clear the AMS mapping before printing.")); 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); Enable_Send_Button(true);
return; return;
+6 -4
View File
@@ -744,16 +744,18 @@ void SendMultiMachinePage::on_send(wxCommandEvent& event)
if (!wxGetApp().is_blocking_printing(obj)) { if (!wxGetApp().is_blocking_printing(obj)) {
PrintParams params = request_params(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 // OrcaSonar: a mapped print requires the connector to advertise
// filament_mapping, and a partially mapped print must not silently drop a // 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 // 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 // gate, external slots ({255,0}/{254,0}) included; the extra-spool branch
// rewrites to exactly those. Bambu is unchanged. // rewrites to exactly those. Bambu is unchanged.
if (obj->printer_agent_id == ORCA_PRINTER_AGENT_ID) { if (obj->printer_agent_id == ORCA_PRINTER_AGENT_ID) {
const bool mapping_available = obj->is_support_filament_mapping && ORCA_FILAMENT_MAPPING_CORRELATION_VERIFIED; if (!obj->is_support_filament_mapping && has_engaged_filament_mapping(params.ams_mapping2)) {
if (!mapping_available && has_engaged_filament_mapping(params.ams_mapping2)) { BOOST_LOG_TRIVIAL(warning) << "SendMultiMachinePage: connector does not advertise filament_mapping; refusing mapped print for "
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 "
<< obj->get_dev_id(); << 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); 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(); msg_wingow.ShowModal();
+11 -16
View File
@@ -15,15 +15,7 @@
namespace Slic3r { namespace Slic3r {
static constexpr char ORCA_PRINTER_AGENT_ID[] = "orca"; static constexpr char ORCA_PRINTER_AGENT_ID[] = "orca";
static constexpr char BBL_PRINTER_AGENT_ID[] = "bbl"; 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<filament_id> / 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;
// Factory function type for creating printer agents // Factory function type for creating printer agents
using PrinterAgentFactory = using PrinterAgentFactory =
@@ -32,17 +24,20 @@ using PrinterAgentFactory =
// Information about a registered printer agent // Information about a registered printer agent
struct PrinterAgentInfo struct PrinterAgentInfo
{ {
std::string id; // Registry/config key, e.g. "orca" or a plugin AgentInfo::id 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 display_name; // e.g., "Orca Native", "Bambu Lab"
std::string plugin_identifier; // Empty for built-ins, otherwise <plugin_key>;<uuid>;<capability_name> std::string plugin_identifier; // Empty for built-ins, otherwise <plugin_key>;<uuid>;<capability_name>
PrinterAgentFactory factory; // Function to create the agent PrinterAgentFactory factory; // Function to create the agent
bool is_plugin() const { return !plugin_identifier.empty(); } bool is_plugin() const { return !plugin_identifier.empty(); }
PrinterAgentInfo(const std::string& id_, const std::string& display_name_, PrinterAgentFactory factory_) PrinterAgentInfo(const std::string& id_, const std::string& display_name_, PrinterAgentFactory factory_)
: id(id_), display_name(display_name_), factory(std::move(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_)) : 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 * @param log_dir Directory for log files
* @return Shared pointer to IPrinterAgent, or nullptr if ID not found * @return Shared pointer to IPrinterAgent, or nullptr if ID not found
*/ */
static std::shared_ptr<IPrinterAgent> create_printer_agent_by_id(const std::string& id, static std::shared_ptr<IPrinterAgent> create_printer_agent_by_id(const std::string& id,
std::shared_ptr<ICloudServiceAgent> cloud_agent, std::shared_ptr<ICloudServiceAgent> cloud_agent,
const std::string& log_dir); const std::string& log_dir);
/** /**
* Clear the printer agent cache. * Clear the printer agent cache.
+2 -1
View File
@@ -554,7 +554,6 @@ void OrcaMqttConnection::connect_and_read() {
ws_handshake(*connection, current_config, endpoint); ws_handshake(*connection, current_config, endpoint);
expires_never(*connection);
// Auth precedence: a bearer_provider authenticates the WebSocket upgrade, so the // Auth precedence: a bearer_provider authenticates the WebSocket upgrade, so the
// CONNECT username/password fields are omitted entirely (the cloud form). // CONNECT username/password fields are omitted entirely (the cloud form).
const bool use_bearer = static_cast<bool>(current_config.bearer_provider); const bool use_bearer = static_cast<bool>(current_config.bearer_provider);
@@ -569,6 +568,8 @@ void OrcaMqttConnection::connect_and_read() {
ws_read(*connection, buffer, connack_error); ws_read(*connection, buffer, connack_error);
if (connack_error) if (connack_error)
throw boost::system::system_error(connack_error, "read Orca MQTT CONNACK"); 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()); const std::string connack = boost::beast::buffers_to_string(buffer.data());
// rc: 0 accepted, 1..5 refusal, -1 malformed/not a CONNACK. // rc: 0 accepted, 1..5 refusal, -1 malformed/not a CONNACK.
const int rc = (connack.size() == 4 && static_cast<uint8_t>(connack[0]) == 0x20) const int rc = (connack.size() == 4 && static_cast<uint8_t>(connack[0]) == 0x20)
+6 -11
View File
@@ -45,9 +45,7 @@ namespace fs = boost::filesystem;
// Per-device filament_mapping capability, mirrored from the get_capabilities // Per-device filament_mapping capability, mirrored from the get_capabilities
// reply in merge_capabilities and read by start_sdcard_print so the field is // reply in merge_capabilities and read by start_sdcard_print so the field is
// refused defensively when the connector never advertised it or the // refused defensively when the connector never advertised it.
// index-correlation merge gate (ORCA_FILAMENT_MAPPING_CORRELATION_VERIFIED) is
// not satisfied.
std::mutex g_filament_mapping_mutex; std::mutex g_filament_mapping_mutex;
std::unordered_map<std::string, bool> g_filament_mapping_cache; std::unordered_map<std::string, bool> 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(); 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 // Per-print mapping. A mapped print is refused when the connector did not
// advertise filament_mapping or the index correlation is unverified: the GUI // advertise filament_mapping: the GUI send gates make this visible first, and
// send gates make this visible first, and this is the defensive gate for // this is the defensive gate for callers that bypass them (calibration,
// callers that bypass them (calibration, plugin). Never start a mapped print // plugin). Never start a mapped print with the map silently dropped.
// with the map silently dropped.
const nlohmann::json filament_mapping = build_filament_mapping(params.ams_mapping2); const nlohmann::json filament_mapping = build_filament_mapping(params.ams_mapping2);
if (!filament_mapping.empty()) { if (!filament_mapping.empty()) {
const bool mapping_capable = filament_mapping_advertised(params.dev_id); if (!filament_mapping_advertised(params.dev_id)) {
if (!mapping_capable || !ORCA_FILAMENT_MAPPING_CORRELATION_VERIFIED) { BOOST_LOG_TRIVIAL(warning) << "OrcaPrinterAgent: refusing mapped print, connector does not advertise filament_mapping";
BOOST_LOG_TRIVIAL(warning) << "OrcaPrinterAgent: refusing mapped print (capable=" << mapping_capable
<< ", correlation_verified=" << ORCA_FILAMENT_MAPPING_CORRELATION_VERIFIED << ")";
return ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED; return ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED;
} }
BOOST_LOG_TRIVIAL(info) << "OrcaPrinterAgent: start_sdcard_print emitting filament_mapping entries=" << filament_mapping.size(); BOOST_LOG_TRIVIAL(info) << "OrcaPrinterAgent: start_sdcard_print emitting filament_mapping entries=" << filament_mapping.size();
+37
View File
@@ -40,6 +40,20 @@ static std::set<int> tools_for_role(const std::string& gcode, const std::string&
return tools; return tools;
} }
// Every id named by a Tn command anywhere in `gcode`. A non-BBL single-extruder multi-filament
// setup emits T<filament_id>, and that id is what the Klipper toolchange macro consumes.
static std::set<int> tool_ids(const std::string& gcode)
{
std::set<int> 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: // 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). // the nearest preceding G1 carrying an X (the park travel emitted just before the wait).
static std::vector<double> wait_park_xs(const std::string& gcode) static std::vector<double> 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<int>{ 1 }); // filament 2 CHECK(tools_for_role(gcode, "infill") == std::set<int>{ 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<int>{ 2 }); // filament 3
CHECK(tools_for_role(gcode, "infill") == std::set<int>{ 0 }); // filament 1
CHECK(tool_ids(gcode) == std::set<int>{ 0, 2 });
}
// The override must survive tool ordering: object 1's walls print on their filament's // 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. // 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]") TEST_CASE("Per-object wall filament override is honored", "[MultiFilament]")
+1
View File
@@ -16,6 +16,7 @@ add_executable(${_TEST_NAME}_tests
test_config_variant_expansion.cpp test_config_variant_expansion.cpp
test_toolordering_nozzle_group.cpp test_toolordering_nozzle_group.cpp
test_preset_bundle_loading.cpp test_preset_bundle_loading.cpp
test_preset_bundle_ams_sync.cpp
test_preset_setting_id.cpp test_preset_setting_id.cpp
test_preset_diff.cpp test_preset_diff.cpp
test_vendor_cache.cpp test_vendor_cache.cpp
@@ -0,0 +1,155 @@
#include <catch2/catch_all.hpp>
#include <map>
#include <string>
#include <utility>
#include <vector>
#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<ConfigOptionString>(BBL_JSON_KEY_INHERITS, true)->value = "";
config.option<ConfigOptionStrings>("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 <type> 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<std::pair<DynamicPrintConfig *, std::string>> unknowns;
std::map<int, AMSMapInfo> 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<ConfigOptionStrings>("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<std::pair<DynamicPrintConfig *, std::string>> unknowns;
std::map<int, AMSMapInfo> 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<std::pair<DynamicPrintConfig *, std::string>> unknowns;
std::map<int, AMSMapInfo> 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<ConfigOptionStrings>("filament_colour")->values = {"#000000"};
bundle.project_config.option<ConfigOptionStrings>("filament_colour_type")->values = {"1"};
bundle.filament_ams_list[0] = make_tray("", "PETG", "#898F9B", "0", "1");
std::map<int, AMSMapInfo> maps;
maps[0] = AMSMapInfo{"0", "1"};
std::vector<std::pair<DynamicPrintConfig *, std::string>> 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<ConfigOptionStrings>("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<ConfigOptionStrings>("filament_type") != nullptr);
CHECK(full.option<ConfigOptionStrings>("filament_settings_id")->values == std::vector<std::string>{"Generic PLA @Q2", "Generic PETG @Q2"});
CHECK(full.option<ConfigOptionStrings>("filament_type")->values == std::vector<std::string>{"PLA", "PETG"});
// The AMS sync matches trays by filament_id, so this array must follow the same order.
CHECK(full.option<ConfigOptionStrings>("filament_ids")->values == std::vector<std::string>{"GFL99", "OFYPdQJh"});
}
@@ -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: // An empty mapping must leave the gcode_file payload byte-identical to today:
// exactly command, sequence_id and param, with no filament_mapping key. // 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]") { 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); 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 // 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 // report a non-success result so PrintJob falls back to start_print() rather
// than treating a print that was never sent as successful. // than treating a print that was never sent as successful.