From bbb73c2c130368c051ca6982dff0a43d55fee477 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 18 Oct 2026 22:26:40 +0000 Subject: [PATCH 1/7] Read Happy Hare and AFC slots in the Moonraker device probe Port the parsing from upstream MoonrakerPrinterAgent (mmu gates/materials/colors and lane_data lanes) into PrintHostDeviceStatus so the send-dialog mapping table can show those slots. Extra GETs run only when print_task_config named nothing, and the first objects/query string is left unchanged for PRs #610/#611. Co-authored-by: aceRage --- src/slic3r/Utils/PrintHostDeviceStatus.cpp | 247 +++++++++++++++++- src/slic3r/Utils/PrintHostDeviceStatus.hpp | 15 +- tests/slic3rutils/printhost_devices_tests.cpp | 97 +++++++ 3 files changed, 352 insertions(+), 7 deletions(-) diff --git a/src/slic3r/Utils/PrintHostDeviceStatus.cpp b/src/slic3r/Utils/PrintHostDeviceStatus.cpp index 3c03280ab0a0..268df73b9f62 100644 --- a/src/slic3r/Utils/PrintHostDeviceStatus.cpp +++ b/src/slic3r/Utils/PrintHostDeviceStatus.cpp @@ -49,14 +49,18 @@ static std::string moonraker_base(const std::string& host) return "http://" + h; } -static bool moonraker_get(const std::string& url, std::string& body, std::string& error, int timeout_s) +static bool moonraker_get(const std::string& url, std::string& body, std::string& error, int timeout_s, + const std::string& apikey = {}) { bool ok = false; - Http::get(url).tls_policy(Http::TlsPolicy::PrintHost) // printer: keep accepting self-signed certificates + auto http = Http::get(url); + http.tls_policy(Http::TlsPolicy::PrintHost) // printer: keep accepting self-signed certificates .timeout_connect(timeout_s) .timeout_max(timeout_s) - .size_limit(512 * 1024) - .on_error([&](std::string reply, std::string err, unsigned status) { + .size_limit(512 * 1024); + if (!apikey.empty()) + http.header("X-Api-Key", apikey); + http.on_error([&](std::string reply, std::string err, unsigned status) { error = err.empty() ? ("HTTP " + std::to_string(status)) : err; if (!reply.empty()) body = reply; @@ -90,6 +94,43 @@ static std::string colour_of(const std::string& raw) return "#" + s.substr(0, 6); } +// Happy Hare / AFC also send "0xRRGGBB" and "#None". +static std::string colour_of_hex(const std::string& raw) +{ + std::string s = raw; + if (s.size() >= 2 && s[0] == '0' && (s[1] == 'x' || s[1] == 'X')) + s = s.substr(2); + return colour_of(s); +} + +static int arr_int(const json& a, size_t i, int fallback = 0) +{ + if (i >= a.size()) + return fallback; + if (a[i].is_number_integer()) + return a[i].get(); + if (a[i].is_number()) + return static_cast(a[i].get()); + return fallback; +} + +static int json_int(const json& j, const char* key, int fallback = 0) +{ + if (!j.is_object() || !j.contains(key)) + return fallback; + const json& v = j[key]; + if (v.is_number_integer()) + return v.get(); + if (v.is_number()) + return static_cast(v.get()); + if (v.is_string()) { + try { + return std::stoi(v.get()); + } catch (...) {} + } + return fallback; +} + static const json& array_at(const json& j, const char* key) { static const json empty = json::array(); @@ -168,6 +209,139 @@ static void slots_from_print_task_config(const json& status, std::vector& } } +// Happy Hare `mmu` (https://github.com/moggieuk/Happy-Hare): num_gates, gate_status +// (-1 unknown, 0 empty, 1/2 loaded), gate_material, gate_color. Ported from upstream +// OrcaSlicer MoonrakerPrinterAgent::fetch_hh_filament_info; empty gates stay in the list +// so the dialog can show the unit, but they do not count as named filaments. +static constexpr int k_max_mmu_gates = 32; + +static json mmu_object_from(const json& root) +{ + if (!root.is_object()) + return json::object(); + if (root.contains("num_gates")) + return root; + if (root.contains("mmu") && root["mmu"].is_object()) + return root["mmu"]; + const auto result = root.find("result"); + if (result != root.end() && result->is_object()) { + const auto status = result->find("status"); + if (status != result->end() && status->is_object() && status->contains("mmu") && (*status)["mmu"].is_object()) + return (*status)["mmu"]; + } + return json::object(); +} + +static void slots_from_happy_hare(const json& mmu, std::vector& out, bool& have_filaments) +{ + if (!mmu.is_object() || mmu.empty()) + return; + const int n = json_int(mmu, "num_gates", 0); + if (n <= 0 || n > k_max_mmu_gates) + return; + const json& gate_status = array_at(mmu, "gate_status"); + const json& gate_material = array_at(mmu, "gate_material"); + const json& gate_color = array_at(mmu, "gate_color"); + for (int i = 0; i < n; ++i) { + Slot s; + s.index = i; + // Upstream skips status <= 0 and empty material. Keep the row so the unit is visible; + // only a loaded gate with a type/colour counts as a named filament. + const int status = arr_int(gate_status, static_cast(i), 0); + const bool loaded = status > 0; + if (loaded) { + s.type = arr_str(gate_material, static_cast(i)); + s.color = colour_of_hex(arr_str(gate_color, static_cast(i))); + s.loaded = !s.type.empty(); + } + if (!s.type.empty() || !s.color.empty()) + have_filaments = true; + out.push_back(std::move(s)); + } +} + +// AFC / BoxTurtle Moonraker database namespace `lane_data`: +// { "result": { "value": { "lane1": { "lane": "0", "material": "PLA", "color": "#FF0000" }, ... } } } +// Ported from upstream MoonrakerPrinterAgent::fetch_moonraker_filament_data. Also accepts a +// `lanes` array if a firmware writes that instead of per-key objects. +static int afc_lane_index(const json& lane_obj) +{ + if (!lane_obj.is_object() || !lane_obj.contains("lane")) + return -1; + const json& v = lane_obj["lane"]; + if (v.is_number_integer()) + return v.get(); + if (v.is_number()) + return static_cast(v.get()); + if (v.is_string()) { + try { + return std::stoi(v.get_ref()); + } catch (...) {} + } + return -1; +} + +static json afc_value_from(const json& root) +{ + if (!root.is_object()) + return json::object(); + const auto result = root.find("result"); + if (result != root.end() && result->is_object()) { + const auto value = result->find("value"); + if (value != result->end() && (value->is_object() || value->is_array())) + return *value; + } + return root; +} + +static void add_afc_lane(const json& lane_obj, std::vector& tmp, bool& have_filaments) +{ + const int idx = afc_lane_index(lane_obj); + if (idx < 0) + return; + Slot s; + s.index = idx; + s.type = str_at(lane_obj, "material"); + s.color = colour_of_hex(str_at(lane_obj, "color")); + s.loaded = !s.type.empty(); + if (!s.loaded) + s.color.clear(); + if (!s.type.empty() || !s.color.empty()) + have_filaments = true; + tmp.push_back(std::move(s)); +} + +static void slots_from_afc_value(const json& value, std::vector& out, bool& have_filaments) +{ + std::vector tmp; + if (value.is_array()) { + for (const json& child : value) + add_afc_lane(child, tmp, have_filaments); + } else if (value.is_object()) { + if (value.contains("lanes") && value["lanes"].is_array()) { + for (const json& child : value["lanes"]) + add_afc_lane(child, tmp, have_filaments); + } else { + for (auto it = value.begin(); it != value.end(); ++it) { + if (it->is_object()) + add_afc_lane(*it, tmp, have_filaments); + } + } + } + std::sort(tmp.begin(), tmp.end(), [](const Slot& a, const Slot& b) { return a.index < b.index; }); + for (Slot& s : tmp) + out.push_back(std::move(s)); +} + +static void adopt_named_slots(Status& st, std::vector slots, bool have_filaments) +{ + if (!have_filaments || slots.empty()) + return; + st.slots = std::move(slots); + st.slots_have_filaments = true; + st.note.clear(); +} + // No print_task_config: the extruder objects still say how many tools there are. A slot list with // no materials in it - the caller shows it as tools, not as filaments. static void slots_from_extruders(const json& status, std::vector& out) @@ -199,6 +373,11 @@ static Status fill_from_status(const json& status, const std::string& host_type_ if (status.contains("print_stats") && status["print_stats"].is_object()) st.state = str_at(status["print_stats"], "state"); slots_from_print_task_config(status, st.slots, st.slots_have_filaments); + // Happy Hare lives under status.mmu when the objects/query asked for it. Left as a separate + // GET in probe() so this first query string stays as it is (open PRs #610/#611 add QIDI's + // multi_color_controller there). Tests pass `mmu` in the status object. + if (st.slots.empty()) + slots_from_happy_hare(mmu_object_from(status), st.slots, st.slots_have_filaments); if (st.slots.empty()) slots_from_extruders(status, st.slots); if (st.slots.empty() || !st.slots_have_filaments) @@ -252,9 +431,11 @@ Status probe(const Device& d, int timeout_s) } // One call, read-only, never a command - the same objects RemoteControl::describe_hosts asks // for plus print_task_config, which is where the loaded filaments live when there are any. + // Happy Hare `mmu` and AFC `lane_data` are extra GETs below, not added to this query string: + // open PRs #610/#611 append QIDI's multi_color_controller here. std::string body; if (!moonraker_get(base + "/printer/objects/query?print_stats&print_task_config&extruder&extruder1&extruder2&extruder3", - body, st.error, timeout_s)) { + body, st.error, timeout_s, d.apikey)) { st.note = "this printer did not answer" + (st.error.empty() ? std::string() : (": " + st.error)); return st; } @@ -270,6 +451,30 @@ Status probe(const Device& d, int timeout_s) Status parsed = fill_from_status(status, d.host_type); parsed.probed = true; parsed.error = st.error; + + // Optional add-ons: a 404/timeout here must not fail a probe that already got print_stats. + if (!parsed.slots_have_filaments) { + std::string mmu_body, mmu_err; + if (moonraker_get(base + "/printer/objects/query?mmu", mmu_body, mmu_err, timeout_s, d.apikey)) { + bool have = false; + std::vector slots; + json mmu_json = json::parse(mmu_body, nullptr, false); + if (mmu_json.is_object()) + slots_from_happy_hare(mmu_object_from(mmu_json), slots, have); + adopt_named_slots(parsed, std::move(slots), have); + } + } + if (!parsed.slots_have_filaments) { + std::string lane_body, lane_err; + if (moonraker_get(base + "/server/database/item?namespace=lane_data", lane_body, lane_err, timeout_s, d.apikey)) { + bool have = false; + std::vector slots; + json lane_json = json::parse(lane_body, nullptr, false); + if (lane_json.is_object() || lane_json.is_array()) + slots_from_afc_value(afc_value_from(lane_json), slots, have); + adopt_named_slots(parsed, std::move(slots), have); + } + } return parsed; } @@ -286,5 +491,37 @@ Status parse_moonraker_status(const std::string& status_json, const std::string& return st; } +Status parse_happy_hare_mmu(const std::string& json_text, const std::string& host_type_key) +{ + json root = json::parse(json_text, nullptr, false); + Status st; + st.probed = can_probe(host_type_key); + if (!root.is_object()) { + st.note = no_filament_note(host_type_key); + return st; + } + st.online = true; + slots_from_happy_hare(mmu_object_from(root), st.slots, st.slots_have_filaments); + if (st.slots.empty() || !st.slots_have_filaments) + st.note = no_filament_note(host_type_key); + return st; +} + +Status parse_afc_lane_data(const std::string& json_text, const std::string& host_type_key) +{ + json root = json::parse(json_text, nullptr, false); + Status st; + st.probed = can_probe(host_type_key); + if (!(root.is_object() || root.is_array())) { + st.note = no_filament_note(host_type_key); + return st; + } + st.online = true; + slots_from_afc_value(afc_value_from(root), st.slots, st.slots_have_filaments); + if (st.slots.empty() || !st.slots_have_filaments) + st.note = no_filament_note(host_type_key); + return st; +} + } // namespace PrintHostDevices } // namespace Slic3r diff --git a/src/slic3r/Utils/PrintHostDeviceStatus.hpp b/src/slic3r/Utils/PrintHostDeviceStatus.hpp index 076715676824..80a929eaa461 100644 --- a/src/slic3r/Utils/PrintHostDeviceStatus.hpp +++ b/src/slic3r/Utils/PrintHostDeviceStatus.hpp @@ -23,8 +23,12 @@ // Snapmaker-flavoured Moonraker also carries `print_task_config`, whose parallel arrays are the // loaded filaments per toolhead (the same object SnapmakerLan::toolheads_of reads, // SnapmakerLan.cpp:456, and the same one SSWCP's machine filament info comes from). Where there -// is no `print_task_config`, the `extruder`/`extruder1`... objects still say how many tools the -// machine has, which is a slot list without materials in it. +// is no `print_task_config`, a Happy Hare `mmu` object (gates/materials/colors) or an AFC / +// BoxTurtle `lane_data` namespace can still name what is loaded; those are read-only for the +// send-dialog mapping table. Upstream's MoonrakerPrinterAgent start_print is a stub and does +// not send a gate/lane mapping at start, so neither does this probe. If none of those objects +// are present, the `extruder`/`extruder1`... objects still say how many tools the machine has, +// which is a slot list without materials in it. // * A PrusaLink / PrusaConnect printer (a Buddy board on an MK4/MK4S/MINI/CORE One, or the // PrusaLink daemon in front of an MK3S) answers its own REST API: `/api/v1/status` gives the // state and both heaters, `/api/v1/job` the file it is printing. See PrusaLinkStatus.hpp. It @@ -74,6 +78,13 @@ Status probe(const Device& d, int timeout_s = 3); // only by a printer on somebody's desk. `status_json` is the JSON text of that object. Status parse_moonraker_status(const std::string& status_json, const std::string& host_type_key); +// Happy Hare: GET /printer/objects/query?mmu (full body), a status object that contains `mmu`, +// or the `mmu` object itself. Network-free, for Catch2. +Status parse_happy_hare_mmu(const std::string& json_text, const std::string& host_type_key); +// AFC / BoxTurtle: GET /server/database/item?namespace=lane_data (full body), or the `value` +// object of lane keys. Network-free, for Catch2. +Status parse_afc_lane_data(const std::string& json_text, const std::string& host_type_key); + // What can be said before asking: false for every host type this fork has no status client for. bool can_probe(const std::string& host_type_key); // The sentence the dialog shows when `probe()` came back with nothing to map. diff --git a/tests/slic3rutils/printhost_devices_tests.cpp b/tests/slic3rutils/printhost_devices_tests.cpp index 0971b7fde131..257f444ed599 100644 --- a/tests/slic3rutils/printhost_devices_tests.cpp +++ b/tests/slic3rutils/printhost_devices_tests.cpp @@ -392,6 +392,103 @@ TEST_CASE("PrintHostDevices: what a device says it has loaded", "[PrintHostDevic CHECK_FALSE(st.note.empty()); } + SECTION("Happy Hare names its gates and what is loaded in them") { + // Shape of GET /printer/objects/query?mmu, as MoonrakerPrinterAgent::fetch_hh_filament_info reads it. + const std::string query = + R"({"result":{"status":{"mmu":{ + "num_gates":4, + "gate_status":[1,2,0,-1], + "gate_material":["PLA","PETG","ABS","TPU"], + "gate_color":["#FF0000","0x00FF00","#0000FF","#FFFFFF"], + "gate_temperature":[210,240,0,0] + }}}})"; + const Status st = parse_happy_hare_mmu(query, "octoprint"); + CHECK(st.online); + CHECK(st.slots_have_filaments); + REQUIRE(st.slots.size() == 4); + CHECK(st.slots[0].index == 0); + CHECK(st.slots[0].type == "PLA"); + CHECK(st.slots[0].color == "#FF0000"); + CHECK(st.slots[0].loaded); + CHECK(st.slots[1].type == "PETG"); + CHECK(st.slots[1].color == "#00FF00"); // 0x prefix + CHECK(st.slots[1].loaded); + CHECK_FALSE(st.slots[2].loaded); // gate_status 0 + CHECK(st.slots[2].type.empty()); + CHECK_FALSE(st.slots[3].loaded); // gate_status -1 + CHECK(st.slots[0].label() == "T1 PLA"); + CHECK(st.note.empty()); + + // The same object inside a status snapshot is enough for parse_moonraker_status. + const std::string status = + R"({"print_stats":{"state":"standby"}, + "mmu":{"num_gates":2,"gate_status":[1,0],"gate_material":["ASA",""],"gate_color":["202020",""]}})"; + const Status from_status = parse_moonraker_status(status, "octoprint"); + CHECK(from_status.state == "standby"); + REQUIRE(from_status.slots.size() == 2); + CHECK(from_status.slots_have_filaments); + CHECK(from_status.slots[0].type == "ASA"); + CHECK(from_status.slots[0].color == "#202020"); + CHECK_FALSE(from_status.slots[1].loaded); + } + + SECTION("an empty Happy Hare mmu is not a mapping table") { + CHECK_FALSE(parse_happy_hare_mmu(R"({"result":{"status":{"mmu":{}}}})", "octoprint").slots_have_filaments); + CHECK_FALSE(parse_happy_hare_mmu(R"({"mmu":{"num_gates":0}})", "octoprint").slots_have_filaments); + const Status empty_gates = parse_happy_hare_mmu( + R"({"num_gates":2,"gate_status":[0,0],"gate_material":["",""],"gate_color":["",""]})", "octoprint"); + REQUIRE(empty_gates.slots.size() == 2); + CHECK_FALSE(empty_gates.slots_have_filaments); + CHECK_FALSE(empty_gates.note.empty()); + } + + SECTION("Snapmaker print_task_config wins over a Happy Hare mmu in the same status") { + const std::string status = + R"({"print_task_config":{"filament_type":["PLA"],"filament_color_rgba":["FF0000FF"],"filament_exist":[true]}, + "mmu":{"num_gates":1,"gate_status":[1],"gate_material":["PETG"],"gate_color":["00FF00"]}})"; + const Status st = parse_moonraker_status(status, "octoprint"); + REQUIRE(st.slots.size() == 1); + CHECK(st.slots[0].type == "PLA"); + } + + SECTION("AFC / BoxTurtle lane_data names its lanes") { + // Shape of GET /server/database/item?namespace=lane_data, as + // MoonrakerPrinterAgent::fetch_moonraker_filament_data and scripts/test_moonraker_lane_data.py. + const std::string body = + R"({"result":{"namespace":"lane_data","value":{ + "lane2":{"lane":"1","material":"PETG","color":"#00FF00","nozzle_temp":240,"bed_temp":80}, + "lane1":{"lane":"0","material":"PLA","color":"0xFF0000","nozzle_temp":210,"bed_temp":60}, + "lane3":{"lane":"2","material":"","color":"#None"} + }}})"; + const Status st = parse_afc_lane_data(body, "octoprint"); + CHECK(st.online); + CHECK(st.slots_have_filaments); + REQUIRE(st.slots.size() == 3); // sorted by the `lane` tool number, not the JSON key + CHECK(st.slots[0].index == 0); + CHECK(st.slots[0].type == "PLA"); + CHECK(st.slots[0].color == "#FF0000"); + CHECK(st.slots[0].loaded); + CHECK(st.slots[1].index == 1); + CHECK(st.slots[1].type == "PETG"); + CHECK_FALSE(st.slots[2].loaded); + CHECK(st.slots[2].type.empty()); + CHECK(st.slots[2].color.empty()); + CHECK(st.note.empty()); + + const std::string lanes_array = + R"({"lanes":[{"lane":0,"material":"ASA","color":"#202020"},{"lane":"1","material":"TPU","color":"#111111"}]})"; + const Status from_array = parse_afc_lane_data(lanes_array, "octoprint"); + REQUIRE(from_array.slots.size() == 2); + CHECK(from_array.slots[0].type == "ASA"); + CHECK(from_array.slots[1].type == "TPU"); + } + + SECTION("AFC lane_data with no materials is not a mapping table") { + CHECK_FALSE(parse_afc_lane_data("{}", "octoprint").slots_have_filaments); + CHECK_FALSE(parse_afc_lane_data(R"({"result":{"value":{}}})", "octoprint").slots_have_filaments); + CHECK_FALSE(parse_afc_lane_data("not json", "octoprint").online); + } + SECTION("something that is not a Moonraker printer") { const Status st = parse_moonraker_status("{}", "octoprint"); CHECK_FALSE(st.online); From 07cb4a00f3883b410c67b9ed2eacc868b2afbcf0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 18 Oct 2026 22:37:50 +0000 Subject: [PATCH 2/7] HH/AFC probe: 1s extras, QIDI guard, clamp, named colours Add-on mmu/lane_data GETs use a 1 s timeout because probe() runs on the UI thread. Skip them when multi_color_controller reports a QIDI Box so those indices cannot become SAVE_VARIABLE value_tN once #610 lands. Clamp num_gates above 32, cap/dedupe AFC lanes, map CSS colour names, and cover wrong-type fields and huge arrays in Catch2. Co-authored-by: aceRage --- src/slic3r/Utils/PrintHostDeviceStatus.cpp | 100 ++++++++++++++---- src/slic3r/Utils/PrintHostDeviceStatus.hpp | 7 +- tests/slic3rutils/printhost_devices_tests.cpp | 81 ++++++++++++++ 3 files changed, 163 insertions(+), 25 deletions(-) diff --git a/src/slic3r/Utils/PrintHostDeviceStatus.cpp b/src/slic3r/Utils/PrintHostDeviceStatus.cpp index 268df73b9f62..64bd57b4894c 100644 --- a/src/slic3r/Utils/PrintHostDeviceStatus.cpp +++ b/src/slic3r/Utils/PrintHostDeviceStatus.cpp @@ -6,8 +6,12 @@ #include #include +#include #include +#include +#include +#include namespace Slic3r { namespace PrintHostDevices { @@ -94,13 +98,38 @@ static std::string colour_of(const std::string& raw) return "#" + s.substr(0, 6); } -// Happy Hare / AFC also send "0xRRGGBB" and "#None". +// Happy Hare / AFC also send "0xRRGGBB", CSS names ("red"), and "#None". static std::string colour_of_hex(const std::string& raw) { std::string s = raw; + boost::trim(s); if (s.size() >= 2 && s[0] == '0' && (s[1] == 'x' || s[1] == 'X')) s = s.substr(2); - return colour_of(s); + const std::string hex = colour_of(s); + if (!hex.empty()) + return hex; + std::string key = s; + if (!key.empty() && key[0] == '#') + key = key.substr(1); + boost::algorithm::to_lower(key); + if (key.empty() || key == "none") + return {}; + static const std::map named = { + {"black", "#000000"}, {"white", "#FFFFFF"}, {"red", "#FF0000"}, {"green", "#008000"}, + {"blue", "#0000FF"}, {"yellow", "#FFFF00"}, {"orange", "#FFA500"}, {"purple", "#800080"}, + {"pink", "#FFC0CB"}, {"cyan", "#00FFFF"}, {"magenta", "#FF00FF"}, {"brown", "#A52A2A"}, + {"grey", "#808080"}, {"gray", "#808080"}, {"teal", "#008080"}, {"lime", "#00FF00"}, + {"navy", "#000080"}, {"maroon", "#800000"}, {"olive", "#808000"}, {"silver", "#C0C0C0"}, + {"gold", "#FFD700"}, {"violet", "#EE82EE"}, {"indigo", "#4B0082"}, {"aqua", "#00FFFF"}, + {"fuchsia", "#FF00FF"}, {"coral", "#FF7F50"}, {"salmon", "#FA8072"}, {"khaki", "#F0E68C"}, + {"ivory", "#FFFFF0"}, {"beige", "#F5F5DC"}, {"tan", "#D2B48C"}, {"turquoise", "#40E0D0"}, + {"lightblue", "#ADD8E6"}, {"light_blue", "#ADD8E6"}, {"darkblue", "#00008B"}, {"dark_blue", "#00008B"}, + }; + const auto it = named.find(key); + if (it != named.end()) + return it->second; + // Unmapped name: keep it readable rather than dropping to "". + return s; } static int arr_int(const json& a, size_t i, int fallback = 0) @@ -236,9 +265,11 @@ static void slots_from_happy_hare(const json& mmu, std::vector& out, bool& { if (!mmu.is_object() || mmu.empty()) return; - const int n = json_int(mmu, "num_gates", 0); - if (n <= 0 || n > k_max_mmu_gates) + int n = json_int(mmu, "num_gates", 0); + if (n <= 0) return; + if (n > k_max_mmu_gates) + n = k_max_mmu_gates; const json& gate_status = array_at(mmu, "gate_status"); const json& gate_material = array_at(mmu, "gate_material"); const json& gate_color = array_at(mmu, "gate_color"); @@ -294,11 +325,16 @@ static json afc_value_from(const json& root) return root; } -static void add_afc_lane(const json& lane_obj, std::vector& tmp, bool& have_filaments) +static void add_afc_lane(const json& lane_obj, std::vector& tmp, std::vector& seen, bool& have_filaments) { const int idx = afc_lane_index(lane_obj); - if (idx < 0) + if (idx < 0 || idx >= k_max_mmu_gates) return; + if (seen[static_cast(idx)]) + return; + if (static_cast(tmp.size()) >= k_max_mmu_gates) + return; + seen[static_cast(idx)] = 1; Slot s; s.index = idx; s.type = str_at(lane_obj, "material"); @@ -314,17 +350,18 @@ static void add_afc_lane(const json& lane_obj, std::vector& tmp, bool& hav static void slots_from_afc_value(const json& value, std::vector& out, bool& have_filaments) { std::vector tmp; + std::vector seen(static_cast(k_max_mmu_gates), 0); if (value.is_array()) { for (const json& child : value) - add_afc_lane(child, tmp, have_filaments); + add_afc_lane(child, tmp, seen, have_filaments); } else if (value.is_object()) { if (value.contains("lanes") && value["lanes"].is_array()) { for (const json& child : value["lanes"]) - add_afc_lane(child, tmp, have_filaments); + add_afc_lane(child, tmp, seen, have_filaments); } else { for (auto it = value.begin(); it != value.end(); ++it) { if (it->is_object()) - add_afc_lane(*it, tmp, have_filaments); + add_afc_lane(*it, tmp, seen, have_filaments); } } } @@ -333,6 +370,21 @@ static void slots_from_afc_value(const json& value, std::vector& out, bool out.push_back(std::move(s)); } +// A QIDI Box (X-Max 4 / X-Plus 4 / Q2): `multi_color_controller` with box_count > 0. +// #610 parses that object into slots and sends SAVE_VARIABLE value_tN. Happy Hare / AFC +// indices must never be adopted in that case — they are a different numbering. +// Detectable on main once the object is in the first query; when Status::qidi_box lands +// with #610, skip extras if that flag is set as well. +static bool qidi_box_present(const json& status) +{ + if (!status.is_object() || !status.contains("multi_color_controller") || !status["multi_color_controller"].is_object()) + return false; + const json& mcc = status["multi_color_controller"]; + const json& boxes = mcc.contains("box") && mcc["box"].is_object() ? mcc["box"] : mcc; + const int count = json_int(mcc, "box_count", json_int(boxes, "box_count", 0)); + return count > 0; +} + static void adopt_named_slots(Status& st, std::vector slots, bool have_filaments) { if (!have_filaments || slots.empty()) @@ -373,10 +425,10 @@ static Status fill_from_status(const json& status, const std::string& host_type_ if (status.contains("print_stats") && status["print_stats"].is_object()) st.state = str_at(status["print_stats"], "state"); slots_from_print_task_config(status, st.slots, st.slots_have_filaments); - // Happy Hare lives under status.mmu when the objects/query asked for it. Left as a separate - // GET in probe() so this first query string stays as it is (open PRs #610/#611 add QIDI's - // multi_color_controller there). Tests pass `mmu` in the status object. - if (st.slots.empty()) + // Happy Hare lives under status.mmu when the objects/query asked for it. Skip it when a + // QIDI Box is present so those indices cannot be sent as value_tN (#610). Tests pass `mmu` + // in the status object. + if (st.slots.empty() && !qidi_box_present(status)) slots_from_happy_hare(mmu_object_from(status), st.slots, st.slots_have_filaments); if (st.slots.empty()) slots_from_extruders(status, st.slots); @@ -429,12 +481,11 @@ Status probe(const Device& d, int timeout_s) st.note = no_filament_note(d.host_type); return st; } - // One call, read-only, never a command - the same objects RemoteControl::describe_hosts asks - // for plus print_task_config, which is where the loaded filaments live when there are any. - // Happy Hare `mmu` and AFC `lane_data` are extra GETs below, not added to this query string: - // open PRs #610/#611 append QIDI's multi_color_controller here. + // One call, read-only, never a command. multi_color_controller is the QIDI Box object #610 + // also asks for; asking here lets qidi_box_present() skip Happy Hare / AFC extras so their + // indices cannot go out as SAVE_VARIABLE value_tN once that PR lands. std::string body; - if (!moonraker_get(base + "/printer/objects/query?print_stats&print_task_config&extruder&extruder1&extruder2&extruder3", + if (!moonraker_get(base + "/printer/objects/query?print_stats&print_task_config&multi_color_controller&extruder&extruder1&extruder2&extruder3", body, st.error, timeout_s, d.apikey)) { st.note = "this printer did not answer" + (st.error.empty() ? std::string() : (": " + st.error)); return st; @@ -452,10 +503,13 @@ Status probe(const Device& d, int timeout_s) parsed.probed = true; parsed.error = st.error; - // Optional add-ons: a 404/timeout here must not fail a probe that already got print_stats. - if (!parsed.slots_have_filaments) { + // Optional add-ons: the send dialog runs probe() on the UI thread, so these stay at ~1 s. + // A 404/timeout must not fail a probe that already got print_stats. Skip entirely when a + // QIDI Box is present (merge point for Status::qidi_box in #610). + const int addon_timeout_s = 1; + if (!parsed.slots_have_filaments && !qidi_box_present(status)) { std::string mmu_body, mmu_err; - if (moonraker_get(base + "/printer/objects/query?mmu", mmu_body, mmu_err, timeout_s, d.apikey)) { + if (moonraker_get(base + "/printer/objects/query?mmu", mmu_body, mmu_err, addon_timeout_s, d.apikey)) { bool have = false; std::vector slots; json mmu_json = json::parse(mmu_body, nullptr, false); @@ -464,9 +518,9 @@ Status probe(const Device& d, int timeout_s) adopt_named_slots(parsed, std::move(slots), have); } } - if (!parsed.slots_have_filaments) { + if (!parsed.slots_have_filaments && !qidi_box_present(status)) { std::string lane_body, lane_err; - if (moonraker_get(base + "/server/database/item?namespace=lane_data", lane_body, lane_err, timeout_s, d.apikey)) { + if (moonraker_get(base + "/server/database/item?namespace=lane_data", lane_body, lane_err, addon_timeout_s, d.apikey)) { bool have = false; std::vector slots; json lane_json = json::parse(lane_body, nullptr, false); diff --git a/src/slic3r/Utils/PrintHostDeviceStatus.hpp b/src/slic3r/Utils/PrintHostDeviceStatus.hpp index 80a929eaa461..ef1990a85d43 100644 --- a/src/slic3r/Utils/PrintHostDeviceStatus.hpp +++ b/src/slic3r/Utils/PrintHostDeviceStatus.hpp @@ -37,7 +37,9 @@ // * Everything else (Duet, Repetier, SimplyPrint, ...) is not probed: this fork has no client for // their status APIs, and guessing would cost a timeout per address. // -// Blocking. Never call it from the GUI thread. +// Blocking. PrintHostSendDialog::refresh_device_status currently runs this on the UI thread +// with a busy cursor (PrintHostDialogs.cpp), so add-on Happy Hare / AFC GETs use a ~1 s +// timeout rather than stacking another 3+3 s onto the first query. namespace Slic3r { namespace PrintHostDevices { @@ -70,7 +72,8 @@ struct Status bool slots_have_filaments { false }; }; -// Blocking, off the GUI thread. `timeout_s` is per HTTP call. +// Blocking. `timeout_s` is the first Moonraker/PrusaLink call; Happy Hare / AFC extras +// use a 1 s timeout of their own (the send dialog calls this on the UI thread). Status probe(const Device& d, int timeout_s = 3); // The half of probe() that has no network in it: a Moonraker `result.status` object turned diff --git a/tests/slic3rutils/printhost_devices_tests.cpp b/tests/slic3rutils/printhost_devices_tests.cpp index 257f444ed599..8f5006ecb1cc 100644 --- a/tests/slic3rutils/printhost_devices_tests.cpp +++ b/tests/slic3rutils/printhost_devices_tests.cpp @@ -489,6 +489,87 @@ TEST_CASE("PrintHostDevices: what a device says it has loaded", "[PrintHostDevic CHECK_FALSE(parse_afc_lane_data("not json", "octoprint").online); } + SECTION("Happy Hare named colours map to hex, or stay readable") { + const Status st = parse_happy_hare_mmu( + R"({"num_gates":4,"gate_status":[1,1,1,1], + "gate_material":["PLA","PLA","PLA","PLA"], + "gate_color":["red","Blue","chartreuse","#None"]})", + "octoprint"); + REQUIRE(st.slots.size() == 4); + CHECK(st.slots[0].color == "#FF0000"); + CHECK(st.slots[1].color == "#0000FF"); + CHECK(st.slots[2].color == "chartreuse"); // unmapped CSS name, not dropped + CHECK(st.slots[3].color.empty()); // #None + } + + SECTION("num_gates above 32 is clamped, not dropped") { + const Status st = parse_happy_hare_mmu( + R"({"num_gates":40,"gate_status":[1],"gate_material":["PLA"],"gate_color":["red"]})", + "octoprint"); + REQUIRE(st.slots.size() == 32); + CHECK(st.slots[0].type == "PLA"); + CHECK(st.slots[0].color == "#FF0000"); + CHECK(st.slots[0].loaded); + CHECK_FALSE(st.slots[31].loaded); + } + + SECTION("wrong-type Happy Hare fields do not throw") { + CHECK(parse_happy_hare_mmu(R"({"num_gates":[],"gate_status":1,"gate_material":{"0":"PLA"}})", "octoprint") + .slots.empty()); + CHECK(parse_happy_hare_mmu(R"({"num_gates":"nope"})", "octoprint").slots.empty()); + const Status st = parse_happy_hare_mmu( + R"({"num_gates":"2","gate_status":[1,1],"gate_material":["PLA",1],"gate_color":[true,"green"]})", + "octoprint"); + REQUIRE(st.slots.size() == 2); + CHECK(st.slots[0].type == "PLA"); + CHECK(st.slots[1].type.empty()); // material was a number + CHECK(st.slots[1].color == "#008000"); + } + + SECTION("a huge gate_status array is truncated to num_gates") { + std::string body = R"({"num_gates":2,"gate_status":[1)"; + for (int i = 0; i < 200; ++i) + body += ",0"; + body += R"(],"gate_material":["PLA","PETG"],"gate_color":["red","blue"]})"; + const Status st = parse_happy_hare_mmu(body, "octoprint"); + REQUIRE(st.slots.size() == 2); + CHECK(st.slots[0].type == "PLA"); + CHECK(st.slots[1].type == "PETG"); + } + + SECTION("a QIDI Box in the same status is not replaced by Happy Hare gates") { + const std::string status = + R"({"print_stats":{"state":"standby"}, + "multi_color_controller":{"box_count":1,"boxes":[]}, + "mmu":{"num_gates":1,"gate_status":[1],"gate_material":["PLA"],"gate_color":["red"]}})"; + const Status st = parse_moonraker_status(status, "octoprint"); + CHECK_FALSE(st.slots_have_filaments); + CHECK(st.slots.empty()); // no QIDI parser on this branch; HH must not fill in + } + + SECTION("AFC lanes cap at 32, de-duplicate, and reject out-of-range indices") { + const Status dup = parse_afc_lane_data( + R"({"lane1":{"lane":0,"material":"PLA","color":"red"}, + "lane2":{"lane":0,"material":"PETG","color":"blue"}, + "lane3":{"lane":99,"material":"ABS","color":"yellow"}, + "lane4":{"lane":-3,"material":"TPU","color":"white"}})", + "octoprint"); + REQUIRE(dup.slots.size() == 1); + CHECK(dup.slots[0].index == 0); + CHECK(dup.slots[0].type == "PLA"); + + std::string many = "{\"lanes\":["; + for (int i = 0; i < 40; ++i) { + if (i) + many += ","; + many += "{\"lane\":" + std::to_string(i) + ",\"material\":\"PLA\",\"color\":\"red\"}"; + } + many += "]}"; + const Status capped = parse_afc_lane_data(many, "octoprint"); + REQUIRE(capped.slots.size() == 32); + CHECK(capped.slots.back().index == 31); + } + SECTION("something that is not a Moonraker printer") { const Status st = parse_moonraker_status("{}", "octoprint"); CHECK_FALSE(st.online); From 60a58270a294e238a08591f794f802e57573d03a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 18 Oct 2026 22:47:46 +0000 Subject: [PATCH 3/7] Return empty slot colour for unparsable CSS names Slot.color stays '#RRGGBB' or empty so auto-match cannot treat a raw name as a colour. Known names still map to hex; unknown ones are kept in Slot.color_name and the dropdown label. Co-authored-by: aceRage --- src/slic3r/Utils/PrintHostDeviceStatus.cpp | 63 ++++++++++++++----- src/slic3r/Utils/PrintHostDeviceStatus.hpp | 7 ++- tests/slic3rutils/printhost_devices_tests.cpp | 23 ++++++- 3 files changed, 74 insertions(+), 19 deletions(-) diff --git a/src/slic3r/Utils/PrintHostDeviceStatus.cpp b/src/slic3r/Utils/PrintHostDeviceStatus.cpp index 64bd57b4894c..2728def9edcb 100644 --- a/src/slic3r/Utils/PrintHostDeviceStatus.cpp +++ b/src/slic3r/Utils/PrintHostDeviceStatus.cpp @@ -27,6 +27,10 @@ std::string Slot::label() const out += " " + type; if (!sub_type.empty() && sub_type != type) out += " " + sub_type; + if (!color_name.empty()) + out += " " + color_name; + } else if (!color_name.empty()) { + out += " " + color_name; } else if (!loaded) { out += " (empty)"; } @@ -99,21 +103,29 @@ static std::string colour_of(const std::string& raw) } // Happy Hare / AFC also send "0xRRGGBB", CSS names ("red"), and "#None". -static std::string colour_of_hex(const std::string& raw) +// Slot.color is "#RRGGBB" or empty: an unknown name must not leak into auto-match as a colour. +struct ParsedSlotColour { - std::string s = raw; + std::string hex; + std::string name; +}; + +static ParsedSlotColour parse_slot_colour(const std::string& raw) +{ + ParsedSlotColour out; + std::string s = raw; boost::trim(s); if (s.size() >= 2 && s[0] == '0' && (s[1] == 'x' || s[1] == 'X')) s = s.substr(2); - const std::string hex = colour_of(s); - if (!hex.empty()) - return hex; + out.hex = colour_of(s); + if (!out.hex.empty()) + return out; std::string key = s; if (!key.empty() && key[0] == '#') key = key.substr(1); boost::algorithm::to_lower(key); if (key.empty() || key == "none") - return {}; + return out; static const std::map named = { {"black", "#000000"}, {"white", "#FFFFFF"}, {"red", "#FF0000"}, {"green", "#008000"}, {"blue", "#0000FF"}, {"yellow", "#FFFF00"}, {"orange", "#FFA500"}, {"purple", "#800080"}, @@ -126,10 +138,29 @@ static std::string colour_of_hex(const std::string& raw) {"lightblue", "#ADD8E6"}, {"light_blue", "#ADD8E6"}, {"darkblue", "#00008B"}, {"dark_blue", "#00008B"}, }; const auto it = named.find(key); - if (it != named.end()) - return it->second; - // Unmapped name: keep it readable rather than dropping to "". - return s; + if (it != named.end()) { + out.hex = it->second; + return out; + } + out.name = s; + return out; +} + +std::string print_host_slot_colour(const std::string& raw) +{ + return parse_slot_colour(raw).hex; +} + +static void apply_slot_colour(Slot& s, const std::string& raw) +{ + const ParsedSlotColour c = parse_slot_colour(raw); + s.color = c.hex; + s.color_name = c.name; +} + +static bool slot_names_filament(const Slot& s) +{ + return !s.type.empty() || !s.color.empty() || !s.color_name.empty(); } static int arr_int(const json& a, size_t i, int fallback = 0) @@ -282,10 +313,10 @@ static void slots_from_happy_hare(const json& mmu, std::vector& out, bool& const bool loaded = status > 0; if (loaded) { s.type = arr_str(gate_material, static_cast(i)); - s.color = colour_of_hex(arr_str(gate_color, static_cast(i))); + apply_slot_colour(s, arr_str(gate_color, static_cast(i))); s.loaded = !s.type.empty(); } - if (!s.type.empty() || !s.color.empty()) + if (slot_names_filament(s)) have_filaments = true; out.push_back(std::move(s)); } @@ -338,11 +369,13 @@ static void add_afc_lane(const json& lane_obj, std::vector& tmp, std::vect Slot s; s.index = idx; s.type = str_at(lane_obj, "material"); - s.color = colour_of_hex(str_at(lane_obj, "color")); + apply_slot_colour(s, str_at(lane_obj, "color")); s.loaded = !s.type.empty(); - if (!s.loaded) + if (!s.loaded) { s.color.clear(); - if (!s.type.empty() || !s.color.empty()) + s.color_name.clear(); + } + if (slot_names_filament(s)) have_filaments = true; tmp.push_back(std::move(s)); } diff --git a/src/slic3r/Utils/PrintHostDeviceStatus.hpp b/src/slic3r/Utils/PrintHostDeviceStatus.hpp index ef1990a85d43..cc505e2f205a 100644 --- a/src/slic3r/Utils/PrintHostDeviceStatus.hpp +++ b/src/slic3r/Utils/PrintHostDeviceStatus.hpp @@ -51,7 +51,8 @@ struct Slot std::string type; // "PLA", "" = the printer did not say std::string sub_type; std::string vendor; - std::string color; // "#RRGGBB", "" = the printer did not say + std::string color; // "#RRGGBB", "" = the printer did not say / unparsable name + std::string color_name; // original CSS name when `color` is empty bool loaded { false }; double nozzle { 0.0 }; @@ -88,6 +89,10 @@ Status parse_happy_hare_mmu(const std::string& json_text, const std::string& hos // object of lane keys. Network-free, for Catch2. Status parse_afc_lane_data(const std::string& json_text, const std::string& host_type_key); +// Slot.color contract: "#RRGGBB" or empty. Hex, 0x, and known CSS names map to hex; "#None" +// and unparsable names are empty (the readable name is Slot::color_name, not this helper). +std::string print_host_slot_colour(const std::string& raw); + // What can be said before asking: false for every host type this fork has no status client for. bool can_probe(const std::string& host_type_key); // The sentence the dialog shows when `probe()` came back with nothing to map. diff --git a/tests/slic3rutils/printhost_devices_tests.cpp b/tests/slic3rutils/printhost_devices_tests.cpp index 8f5006ecb1cc..1a2e0767e193 100644 --- a/tests/slic3rutils/printhost_devices_tests.cpp +++ b/tests/slic3rutils/printhost_devices_tests.cpp @@ -489,7 +489,13 @@ TEST_CASE("PrintHostDevices: what a device says it has loaded", "[PrintHostDevic CHECK_FALSE(parse_afc_lane_data("not json", "octoprint").online); } - SECTION("Happy Hare named colours map to hex, or stay readable") { + SECTION("Happy Hare named colours map to hex; unparsable names stay empty") { + CHECK(print_host_slot_colour("red") == "#FF0000"); + CHECK(print_host_slot_colour("0x00FF00") == "#00FF00"); + CHECK(print_host_slot_colour("#None").empty()); + CHECK(print_host_slot_colour("chartreuse").empty()); + CHECK(print_host_slot_colour("").empty()); + const Status st = parse_happy_hare_mmu( R"({"num_gates":4,"gate_status":[1,1,1,1], "gate_material":["PLA","PLA","PLA","PLA"], @@ -497,9 +503,20 @@ TEST_CASE("PrintHostDevices: what a device says it has loaded", "[PrintHostDevic "octoprint"); REQUIRE(st.slots.size() == 4); CHECK(st.slots[0].color == "#FF0000"); + CHECK(st.slots[0].color_name.empty()); CHECK(st.slots[1].color == "#0000FF"); - CHECK(st.slots[2].color == "chartreuse"); // unmapped CSS name, not dropped - CHECK(st.slots[3].color.empty()); // #None + CHECK(st.slots[2].color.empty()); // unmapped CSS name: not '#RRGGBB', so not a colour + CHECK(st.slots[2].color_name == "chartreuse"); + CHECK(st.slots[2].label().find("chartreuse") != std::string::npos); + CHECK(st.slots[3].color.empty()); // #None + CHECK(st.slots[3].color_name.empty()); + + const Status afc = parse_afc_lane_data( + R"({"lane1":{"lane":0,"material":"PLA","color":"chartreuse"}})", "octoprint"); + REQUIRE(afc.slots.size() == 1); + CHECK(afc.slots[0].color.empty()); + CHECK(afc.slots[0].color_name == "chartreuse"); + CHECK(afc.slots[0].loaded); } SECTION("num_gates above 32 is clamped, not dropped") { From df9a75d9e54eae087c106eab13066ec07901ca73 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 19 Oct 2026 13:11:22 +0000 Subject: [PATCH 4/7] Add a shared Bambu-style filament map for third-party send dialogs. Elegoo CC upload now uses MaterialItem colour cards and a MappingItem slot popup instead of a text combo, while the start-print slot_map is unchanged. Other hosts keep the existing rows; Bambu and Snapmaker are not switched over. Co-authored-by: aceRage --- src/slic3r/CMakeLists.txt | 5 + src/slic3r/GUI/AmsMappingPopup.cpp | 43 +- src/slic3r/GUI/AmsMappingPopup.hpp | 5 + src/slic3r/GUI/Plater.cpp | 4 + src/slic3r/GUI/PrintHostDialogs.cpp | 86 +++- src/slic3r/GUI/PrintHostDialogs.hpp | 14 +- src/slic3r/GUI/ThirdPartyFilamentMap.cpp | 217 +++++++++ src/slic3r/GUI/ThirdPartyFilamentMap.hpp | 104 +++++ src/slic3r/GUI/ThirdPartyFilamentMapPanel.cpp | 424 ++++++++++++++++++ src/slic3r/GUI/ThirdPartyFilamentMapPanel.hpp | 82 ++++ .../elegoo_canvas_mapping_tests.cpp | 125 ++++++ 11 files changed, 1094 insertions(+), 15 deletions(-) create mode 100644 src/slic3r/GUI/ThirdPartyFilamentMap.cpp create mode 100644 src/slic3r/GUI/ThirdPartyFilamentMap.hpp create mode 100644 src/slic3r/GUI/ThirdPartyFilamentMapPanel.cpp create mode 100644 src/slic3r/GUI/ThirdPartyFilamentMapPanel.hpp diff --git a/src/slic3r/CMakeLists.txt b/src/slic3r/CMakeLists.txt index 4ce4b7f8a8d1..254e0da97c86 100644 --- a/src/slic3r/CMakeLists.txt +++ b/src/slic3r/CMakeLists.txt @@ -1111,6 +1111,10 @@ set(SLIC3R_GUI_SOURCES # Last on purpose: a new source in the middle of the list reshuffles the unity batches (HomeVaults.cpp # and HomeVendors.cpp share file-local names and must not land in one batch). GUI/FavoriteFilamentsUI.cpp + GUI/ThirdPartyFilamentMap.cpp + GUI/ThirdPartyFilamentMap.hpp + GUI/ThirdPartyFilamentMapPanel.cpp + GUI/ThirdPartyFilamentMapPanel.hpp ) add_subdirectory(GUI/DeviceTab) @@ -1281,6 +1285,7 @@ slic3r_unity_build(libslic3r_gui SKIP GUI/Tab.cpp # ::CheckBox vs GUI::CheckBox (Field.hpp) GUI/AMSMaterialsSetting.cpp # ::CheckBox members vs GUI::CheckBox (Field.hpp) GUI/AmsMappingPopup.cpp # ::CheckBox members vs GUI::CheckBox (Field.hpp) + GUI/ThirdPartyFilamentMapPanel.cpp # includes AmsMappingPopup.hpp (::CheckBox vs GUI::CheckBox) GUI/CalibrationWizardPage.cpp # ::CheckBox members vs GUI::CheckBox (Field.hpp) GUI/ExtrusionCalibration.cpp # ::CheckBox members vs GUI::CheckBox (Field.hpp) ) diff --git a/src/slic3r/GUI/AmsMappingPopup.cpp b/src/slic3r/GUI/AmsMappingPopup.cpp index bbb3cfc7e608..796e0fc5c786 100644 --- a/src/slic3r/GUI/AmsMappingPopup.cpp +++ b/src/slic3r/GUI/AmsMappingPopup.cpp @@ -69,6 +69,22 @@ void MaterialItem::set_ams_info(wxColour col, wxString txt, int ctype, std::vect if (need_refresh) { Refresh();} } +void MaterialItem::set_dropdown_visible(bool visible) +{ + if (m_dropdown_visible == visible) + return; + m_dropdown_visible = visible; + Refresh(); +} + +void MaterialItem::set_nozzle_badge(const wxString &badge) +{ + if (m_nozzle_badge == badge) + return; + m_nozzle_badge = badge; + Refresh(); +} + void MaterialItem::disable() { if (IsEnabled()) { @@ -179,6 +195,19 @@ void MaterialItem::render(wxDC &dc) auto mapping_txt_size = dc.GetTextExtent(mapping_txt); dc.DrawText(mapping_txt, wxPoint((MATERIAL_ITEM_SIZE.x - mapping_txt_size.x) / 2, FromDIP(30) + (FromDIP(16) - mapping_txt_size.y) / 2)); + + if (!m_nozzle_badge.empty()) { + const int d = FromDIP(16); + const wxPoint o(FromDIP(2), FromDIP(2)); + dc.SetPen(*wxTRANSPARENT_PEN); + dc.SetBrush(wxBrush(wxColour(0x26, 0x2E, 0x30))); + dc.DrawEllipse(o.x, o.y, d, d); + dc.SetFont(::Label::Body_10); + dc.SetTextForeground(*wxWHITE); + dc.SetBackgroundMode(wxBRUSHSTYLE_TRANSPARENT); + const wxSize ts = dc.GetTextExtent(m_nozzle_badge); + dc.DrawText(m_nozzle_badge, o.x + (d - ts.x) / 2, o.y + (d - ts.y) / 2); + } } void MaterialItem::doRender(wxDC &dc) @@ -273,12 +302,14 @@ void MaterialItem::doRender(wxDC &dc) } #endif //arrow - if ( (acolor.Red() > 160 && acolor.Green() > 160 && acolor.Blue() > 160) && - (acolor.Red() < 180 && acolor.Green() < 180 && acolor.Blue() < 180)) { - dc.DrawBitmap(m_arraw_bitmap_white.bmp(), size.x - m_arraw_bitmap_white.GetBmpSize().x - FromDIP(7), size.y - m_arraw_bitmap_white.GetBmpSize().y); - } - else { - dc.DrawBitmap(m_arraw_bitmap_gray.bmp(), size.x - m_arraw_bitmap_gray.GetBmpSize().x - FromDIP(7), size.y - m_arraw_bitmap_gray.GetBmpSize().y); + if (m_dropdown_visible) { + if ( (acolor.Red() > 160 && acolor.Green() > 160 && acolor.Blue() > 160) && + (acolor.Red() < 180 && acolor.Green() < 180 && acolor.Blue() < 180)) { + dc.DrawBitmap(m_arraw_bitmap_white.bmp(), size.x - m_arraw_bitmap_white.GetBmpSize().x - FromDIP(7), size.y - m_arraw_bitmap_white.GetBmpSize().y); + } + else { + dc.DrawBitmap(m_arraw_bitmap_gray.bmp(), size.x - m_arraw_bitmap_gray.GetBmpSize().x - FromDIP(7), size.y - m_arraw_bitmap_gray.GetBmpSize().y); + } } diff --git a/src/slic3r/GUI/AmsMappingPopup.hpp b/src/slic3r/GUI/AmsMappingPopup.hpp index ece65604c0c2..1796716aec50 100644 --- a/src/slic3r/GUI/AmsMappingPopup.hpp +++ b/src/slic3r/GUI/AmsMappingPopup.hpp @@ -86,9 +86,14 @@ class MaterialItem: public wxPanel bool m_selected {false}; bool m_warning{false}; + // Optional extras for ThirdPartyFilamentMapPanel. Defaults keep the Bambu send dialog unchanged. + bool m_dropdown_visible {true}; + wxString m_nozzle_badge; void msw_rescale(); void set_ams_info(wxColour col, wxString txt, int ctype=0, std::vector cols= std::vector()); + void set_dropdown_visible(bool visible); + void set_nozzle_badge(const wxString &badge); void disable(); void enable(); diff --git a/src/slic3r/GUI/Plater.cpp b/src/slic3r/GUI/Plater.cpp index 4dd074d2170e..64b829a0ccbb 100644 --- a/src/slic3r/GUI/Plater.cpp +++ b/src/slic3r/GUI/Plater.cpp @@ -24464,6 +24464,7 @@ static std::vector plate_filaments_for_send(int plate_idx) const ConfigOptionStrings* colours = bundle->project_config.option("filament_colour"); const ConfigOptionStrings* types = full.option("filament_type"); const ConfigOptionInts* temps = full.option("nozzle_temperature"); + const ConfigOptionInts* fil_map = bundle->project_config.option("filament_map"); std::map volumes; if (plate && plate->is_slice_result_valid() && plate->get_slice_result()) for (const auto& kv : plate->get_slice_result()->print_statistics.total_volumes_per_extruder) @@ -24474,6 +24475,9 @@ static std::vector plate_filaments_for_send(int plate_idx) f.colour = (colours && i < colours->values.size()) ? colours->values[i] : std::string(); f.type = (types && i < types->values.size()) ? types->values[i] : std::string(); f.nozzle_temp = (temps && i < temps->values.size()) ? temps->values[i] : 0; + // filament_map is 1-based (SelectMachine / Bambu dual-nozzle). -1 when the plate is single-nozzle. + if (fil_map && i < fil_map->values.size() && fil_map->values[i] > 0) + f.nozzle_id = fil_map->values[i] - 1; // The remembered CANVAS mapping keys on the preset as people know it: its alias, else its name. if (const Preset* fp = bundle->filaments.find_preset(bundle->filament_presets[i])) f.alias = fp->alias.empty() ? fp->name : fp->alias; diff --git a/src/slic3r/GUI/PrintHostDialogs.cpp b/src/slic3r/GUI/PrintHostDialogs.cpp index 2e9312e38f26..a4d3374eca48 100644 --- a/src/slic3r/GUI/PrintHostDialogs.cpp +++ b/src/slic3r/GUI/PrintHostDialogs.cpp @@ -492,6 +492,8 @@ std::string PrintHostSendDialog::filament_mapping() const // The QIDI Box switched off: the print runs from the extruder's own spool, nothing is mapped. if (m_qidi_use_box && !m_qidi_use_box->GetValue()) return {}; + if (m_filament_map) + return m_filament_map->filament_mapping(); std::string out; for (size_t i = 0; i < m_mapping_choices.size() && i < m_mapping_filament.size(); ++i) { const int sel = m_mapping_choices[i]->GetSelection(); @@ -580,8 +582,7 @@ void PrintHostSendDialog::build_device_ui() content_sizer->Add(m_device_note, 0, wxBOTTOM, VERT_SPACING); m_mapping_panel = new wxPanel(this, wxID_ANY); - m_mapping_sizer = new wxFlexGridSizer(3, FromDIP(4), FromDIP(8)); - m_mapping_sizer->AddGrowableCol(2, 1); + m_mapping_sizer = new wxBoxSizer(wxVERTICAL); m_mapping_panel->SetSizer(m_mapping_sizer); content_sizer->Add(m_mapping_panel, 0, wxEXPAND | wxBOTTOM, VERT_SPACING); m_mapping_note = new wxStaticText(this, wxID_ANY, wxEmptyString); @@ -670,6 +671,7 @@ void PrintHostSendDialog::rebuild_mapping_rows() { if (!m_mapping_panel || !m_mapping_sizer) return; + m_filament_map = nullptr; m_mapping_choices.clear(); m_mapping_filament.clear(); m_mapping_sizer->Clear(true); @@ -682,6 +684,15 @@ void PrintHostSendDialog::rebuild_mapping_rows() return; } + if (use_third_party_map_panel()) { + rebuild_third_party_map_rows(); + return; + } + + auto* grid = new wxFlexGridSizer(3, FromDIP(4), FromDIP(8)); + grid->AddGrowableCol(2, 1); + m_mapping_sizer->Add(grid, 0, wxEXPAND); + wxArrayString slot_labels; slot_labels.Add(_L("As sliced")); for (const PrintHostDevices::Slot& s : m_device_status.slots) { @@ -696,12 +707,12 @@ void PrintHostSendDialog::rebuild_mapping_rows() continue; auto* swatch = new wxPanel(m_mapping_panel, wxID_ANY, wxDefaultPosition, wxSize(FromDIP(16), FromDIP(16))); swatch->SetBackgroundColour(swatch_colour(f.colour)); - m_mapping_sizer->Add(swatch, 0, wxALIGN_CENTER_VERTICAL); + grid->Add(swatch, 0, wxALIGN_CENTER_VERTICAL); wxString name = format_wxstr(_L("Filament %1%"), f.index + 1); if (!f.type.empty()) name += " " + from_u8(f.type); - m_mapping_sizer->Add(new wxStaticText(m_mapping_panel, wxID_ANY, name), 0, wxALIGN_CENTER_VERTICAL); + grid->Add(new wxStaticText(m_mapping_panel, wxID_ANY, name), 0, wxALIGN_CENTER_VERTICAL); auto* choice = new wxChoice(m_mapping_panel, wxID_ANY, wxDefaultPosition, wxDefaultSize, slot_labels); // Auto-match: the dialog's own choice when it has one (the Elegoo dialog's remembered @@ -715,7 +726,7 @@ void PrintHostSendDialog::rebuild_mapping_rows() m_device_status.qidi_box); choice->SetSelection(best); choice->Bind(wxEVT_CHOICE, [this](wxCommandEvent&) { update_mapping_note(); }); - m_mapping_sizer->Add(choice, 1, wxEXPAND); + grid->Add(choice, 1, wxEXPAND); m_mapping_choices.push_back(choice); m_mapping_filament.push_back(f.index); } @@ -725,8 +736,59 @@ void PrintHostSendDialog::rebuild_mapping_rows() update_mapping_note(); } +void PrintHostSendDialog::rebuild_third_party_map_rows() +{ + ThirdPartyFilamentMapModel model; + for (const SendPlateFilament& f : m_plate_filaments) { + if (!f.used) + continue; + ThirdPartySlicerFilament sf; + sf.index = f.index; + sf.colour = f.colour; + sf.type = f.type; + sf.name = f.alias; + sf.nozzle_id = f.nozzle_id; + model.filaments.push_back(std::move(sf)); + m_mapping_filament.push_back(f.index); + } + for (const PrintHostDevices::Slot& s : m_device_status.slots) { + ThirdPartyDeviceSlot ds; + ds.unit_id = s.unit; + ds.slot_id = s.tray >= 0 ? s.tray : s.index; + ds.label = s.unit >= 0 ? bambu_style_slot_label(s.unit, ds.slot_id) : bambu_style_slot_label(-1, s.index); + ds.colour = s.color; + ds.type = s.type; + ds.name = s.name; + ds.empty = !s.loaded; + ds.index = s.index; + model.slots.push_back(std::move(ds)); + } + model.read_only = third_party_map_read_only(); + model.mapping.resize(model.filaments.size(), -1); + for (size_t i = 0; i < model.filaments.size(); ++i) { + const SendPlateFilament* f = plate_filament(model.filaments[i].index); + if (!f) + continue; + const int preset_pos = preselect_slot(*f); + const bool preset_ok = preset_pos >= 0 && preset_pos < (int) m_device_status.slots.size(); + const int best = preset_ok ? preset_pos + 1 : + PrintHostDevices::auto_match_slot(m_device_status.slots, f->colour, f->type, + m_device_status.qidi_box); + model.mapping[i] = best - 1; + } + + m_filament_map = new ThirdPartyFilamentMapPanel(m_mapping_panel); + m_filament_map->set_model(std::move(model), [this]() { update_mapping_note(); }); + m_mapping_sizer->Add(m_filament_map, 0, wxEXPAND); + m_mapping_panel->Show(!m_mapping_filament.empty()); + m_mapping_panel->Layout(); + update_mapping_note(); +} + std::vector PrintHostSendDialog::mapping_choice_positions() const { + if (m_filament_map) + return m_filament_map->choice_positions(); std::vector out; for (const wxChoice* c : m_mapping_choices) out.push_back(c ? c->GetSelection() - 1 : -1); // 0 is "As sliced" @@ -741,11 +803,17 @@ const SendPlateFilament* PrintHostSendDialog::plate_filament(int index) const return nullptr; } +wxString PrintHostSendDialog::mapping_warning() const +{ + return m_filament_map ? m_filament_map->mismatch_warning() : wxString(); +} + void PrintHostSendDialog::update_mapping_note() { if (!m_mapping_note) return; - const wxString text = m_mapping_choices.empty() ? wxString() : mapping_warning(); + const bool has_rows = m_filament_map || !m_mapping_choices.empty(); + const wxString text = has_rows ? mapping_warning() : wxString(); const bool was = m_mapping_note->IsShown(); m_mapping_note->SetLabel(text); m_mapping_note->Wrap(CONTENT_WIDTH * wxGetApp().em_unit()); @@ -812,6 +880,8 @@ void PrintHostSendDialog::rebuild_qidi_options() void PrintHostSendDialog::update_mapping_enabled() { const bool box_on = !m_qidi_use_box || m_qidi_use_box->GetValue(); + if (m_filament_map) + m_filament_map->set_mapping_enabled(box_on); for (wxChoice* choice : m_mapping_choices) choice->Enable(box_on); } @@ -2333,7 +2403,7 @@ int ElegooPrintHostSendDialog::preselect_slot(const SendPlateFilament& f) const wxString ElegooPrintHostSendDialog::mapping_warning() const { if (!m_device_status.canvas) - return wxString(); + return PrintHostSendDialog::mapping_warning(); const auto used = used_filaments(); const auto warnings = ElegooCanvasMapping::warnings_for(used, mapping_choice_positions(), m_device_status.slots); wxString text; @@ -2358,6 +2428,8 @@ wxString ElegooPrintHostSendDialog::mapping_warning() const break; } } + if (const wxString extra = PrintHostSendDialog::mapping_warning(); !extra.IsEmpty()) + add(extra); return text; } diff --git a/src/slic3r/GUI/PrintHostDialogs.hpp b/src/slic3r/GUI/PrintHostDialogs.hpp index 3219d024d472..e1eac9e461b1 100644 --- a/src/slic3r/GUI/PrintHostDialogs.hpp +++ b/src/slic3r/GUI/PrintHostDialogs.hpp @@ -15,6 +15,7 @@ #include "../Utils/PrintHostDeviceStatus.hpp" #include "../Utils/ElegooCanvasMapping.hpp" #include "../Utils/Flashforge.hpp" +#include "ThirdPartyFilamentMapPanel.hpp" #include "libslic3r/PrintConfig.hpp" #include "libslic3r/ProjectTask.hpp" class wxButton; @@ -41,6 +42,7 @@ struct SendPlateFilament bool used { false }; // this plate's slice result actually used it std::string alias; // its filament preset's alias (or name), for remembered mappings int nozzle_temp { 0 }; // its nozzle temperature, 0 = unknown + int nozzle_id { -1 }; // 0-based nozzle / toolhead; -1 = single-nozzle / unknown }; class PrintHostSendDialog : public GUI::MsgDialog @@ -102,6 +104,11 @@ class PrintHostSendDialog : public GUI::MsgDialog // cursor and a short timeout - the same shape as the devices dialog's Test button. void refresh_device_status(); void rebuild_mapping_rows(); + // Elegoo CC (and later CFS / MMU / ACE): Bambu-style MaterialItem rows instead of a text combo. + virtual bool use_third_party_map_panel() const { return false; } + // Creality K3 holders: show the two colours, no popup. + virtual bool third_party_map_read_only() const { return false; } + void rebuild_third_party_map_rows(); // The picked QIDI printer said it is printing or paused: Upload only, no start. bool print_blocked() const; // "Upload and Print" follows print_blocked(). Safe before the buttons exist. @@ -114,7 +121,8 @@ class PrintHostSendDialog : public GUI::MsgDialog // closest-colour rule pick. The Elegoo dialog answers with the remembered mapping. virtual int preselect_slot(const SendPlateFilament& f) const { return -1; } // One or more lines under the mapping table, refreshed whenever a choice changes; "" = none. - virtual wxString mapping_warning() const { return wxString(); } + // The base implementation is the third-party panel's type/colour mismatch text. + virtual wxString mapping_warning() const; void update_mapping_note(); // The rows' choices as positions in m_device_status.slots, -1 = "As sliced"; and the plate // filament each row is for. @@ -134,7 +142,8 @@ class PrintHostSendDialog : public GUI::MsgDialog wxComboBox* m_combo_devices { nullptr }; wxStaticText* m_device_note { nullptr }; wxWindow* m_mapping_panel { nullptr }; - wxFlexGridSizer* m_mapping_sizer { nullptr }; + wxBoxSizer* m_mapping_sizer { nullptr }; + ThirdPartyFilamentMapPanel* m_filament_map { nullptr }; // Elegoo CC; null = wxChoice rows std::vector m_mapping_choices; // one per used plate filament std::vector m_mapping_filament; // its filament index // QIDI printers: QIDIStudio's print options, null where the device does not have them. @@ -268,6 +277,7 @@ class ElegooPrintHostSendDialog : public PrintHostSendDialog virtual std::map extendedInfo() const override; protected: + bool use_third_party_map_panel() const override { return true; } int preselect_slot(const SendPlateFilament& f) const override; wxString mapping_warning() const override; diff --git a/src/slic3r/GUI/ThirdPartyFilamentMap.cpp b/src/slic3r/GUI/ThirdPartyFilamentMap.cpp new file mode 100644 index 000000000000..c8b14c7fb18b --- /dev/null +++ b/src/slic3r/GUI/ThirdPartyFilamentMap.cpp @@ -0,0 +1,217 @@ +#include "ThirdPartyFilamentMap.hpp" + +#include +#include +#include + +namespace Slic3r { +namespace GUI { + +namespace { + +char hex_digit(unsigned v) +{ + return v < 10 ? static_cast('0' + v) : static_cast('A' + (v - 10)); +} + +bool hex_nibble(char c, int& out) +{ + if (c >= '0' && c <= '9') { + out = c - '0'; + return true; + } + if (c >= 'a' && c <= 'f') { + out = c - 'a' + 10; + return true; + } + if (c >= 'A' && c <= 'F') { + out = c - 'A' + 10; + return true; + } + return false; +} + +bool rgb_of(const std::string& colour, int& r, int& g, int& b) +{ + const std::string hex = normalize_map_colour(colour); + if (hex.size() != 7) + return false; + int v[6]; + for (int i = 0; i < 6; ++i) { + if (!hex_nibble(hex[i + 1], v[i])) + return false; + } + r = v[0] * 16 + v[1]; + g = v[2] * 16 + v[3]; + b = v[4] * 16 + v[5]; + return true; +} + +std::string lower_copy(std::string s) +{ + for (char& c : s) + c = static_cast(std::tolower(static_cast(c))); + return s; +} + +} // namespace + +std::string normalize_map_colour(const std::string& colour) +{ + std::string s = colour; + if (!s.empty() && s[0] == '#') + s = s.substr(1); + if (s.size() < 6) + return {}; + int dummy; + for (int i = 0; i < 6; ++i) { + if (!hex_nibble(s[i], dummy)) + return {}; + } + std::string out = "#"; + out.resize(7); + out[0] = '#'; + for (int i = 0; i < 6; ++i) { + int n = 0; + hex_nibble(s[i], n); + out[i + 1] = hex_digit(static_cast(n)); + } + return out; +} + +std::string bambu_style_slot_label(int unit_id, int slot_id) +{ + const int slot = slot_id < 0 ? 0 : slot_id; + const int letter_i = slot % 4; + const char letter = static_cast('A' + letter_i); + if (unit_id < 0) + return std::string(1, letter); + return std::to_string(unit_id + 1) + letter; +} + +std::string slot_display_label(const ThirdPartyDeviceSlot& slot) +{ + if (!slot.label.empty()) + return slot.label; + return bambu_style_slot_label(slot.unit_id, slot.slot_id >= 0 ? slot.slot_id : slot.index); +} + +bool types_match(const std::string& a, const std::string& b) +{ + if (a.empty() || b.empty()) + return true; + return lower_copy(a) == lower_copy(b); +} + +double map_colour_distance(const std::string& a, const std::string& b) +{ + int ar, ag, ab, br, bg, bb; + if (!rgb_of(a, ar, ag, ab) || !rgb_of(b, br, bg, bb)) + return 0; + const double rm = (ar + br) / 2.0; + const double dr = ar - br, dg = ag - bg, db = ab - bb; + return (2 + rm / 256) * dr * dr + 4 * dg * dg + (2 + (255 - rm) / 256) * db * db; +} + +bool colours_mismatch(const std::string& a, const std::string& b) +{ + if (normalize_map_colour(a).empty() || normalize_map_colour(b).empty()) + return false; + // Red vs a nearby red is a few thousand; red vs blue is tens of thousands. 8000 is + // "noticeably different" without flagging two spools of the same shade. + return map_colour_distance(a, b) > 8000.0; +} + +ThirdPartyMapMismatch mismatch_for(const ThirdPartySlicerFilament& filament, const ThirdPartyDeviceSlot& slot) +{ + const bool type_bad = !slot.empty && !types_match(filament.type, slot.type); + const bool colour_bad = !slot.empty && colours_mismatch(filament.colour, slot.colour); + if (type_bad && colour_bad) + return ThirdPartyMapMismatch::TypeAndColour; + if (type_bad) + return ThirdPartyMapMismatch::Type; + if (colour_bad) + return ThirdPartyMapMismatch::Colour; + return ThirdPartyMapMismatch::None; +} + +ThirdPartyMapMismatch mismatch_at(const ThirdPartyFilamentMapModel& model, size_t filament_row) +{ + if (filament_row >= model.filaments.size() || filament_row >= model.mapping.size()) + return ThirdPartyMapMismatch::None; + const int pos = model.mapping[filament_row]; + if (pos < 0 || pos >= (int) model.slots.size()) + return ThirdPartyMapMismatch::None; + return mismatch_for(model.filaments[filament_row], model.slots[pos]); +} + +std::vector distinct_nozzle_ids(const ThirdPartyFilamentMapModel& model) +{ + std::set ids; + for (const ThirdPartySlicerFilament& f : model.filaments) + if (f.nozzle_id >= 0) + ids.insert(f.nozzle_id); + for (const ThirdPartyDeviceSlot& s : model.slots) + if (s.nozzle_id >= 0) + ids.insert(s.nozzle_id); + return std::vector(ids.begin(), ids.end()); +} + +bool has_multiple_nozzles(const ThirdPartyFilamentMapModel& model) +{ + return distinct_nozzle_ids(model).size() > 1; +} + +std::string filament_badge_text(const ThirdPartySlicerFilament& filament, const ThirdPartyFilamentMapModel& model) +{ + const std::vector nozzles = distinct_nozzle_ids(model); + if (filament.nozzle_id >= 0 && nozzles.size() > 1) { + if (nozzles.size() == 2 && nozzles[0] == 0 && nozzles[1] == 1) { + // Bambu H2D: physical 0 is the right nozzle, 1 is the left. + if (filament.nozzle_id == 0) + return "R"; + if (filament.nozzle_id == 1) + return "L"; + } + return std::to_string(filament.nozzle_id + 1); + } + return std::to_string(filament.index + 1); +} + +std::vector group_slots(const std::vector& slots) +{ + std::vector groups; + for (size_t i = 0; i < slots.size(); ++i) { + const ThirdPartyDeviceSlot& s = slots[i]; + auto it = std::find_if(groups.begin(), groups.end(), [&](const ThirdPartySlotGroup& g) { + return g.unit_id == s.unit_id && g.nozzle_id == s.nozzle_id; + }); + if (it == groups.end()) { + ThirdPartySlotGroup g; + g.unit_id = s.unit_id; + g.nozzle_id = s.nozzle_id; + g.slot_positions.push_back((int) i); + groups.push_back(std::move(g)); + } else { + it->slot_positions.push_back((int) i); + } + } + return groups; +} + +std::string encode_filament_mapping(const ThirdPartyFilamentMapModel& model) +{ + std::string out; + for (size_t i = 0; i < model.filaments.size() && i < model.mapping.size(); ++i) { + const int pos = model.mapping[i]; + if (pos < 0 || pos >= (int) model.slots.size()) + continue; + if (!out.empty()) + out += ","; + out += std::to_string(model.filaments[i].index) + ":" + std::to_string(model.slots[pos].index); + } + return out; +} + +} // namespace GUI +} // namespace Slic3r diff --git a/src/slic3r/GUI/ThirdPartyFilamentMap.hpp b/src/slic3r/GUI/ThirdPartyFilamentMap.hpp new file mode 100644 index 000000000000..073e69ddea9a --- /dev/null +++ b/src/slic3r/GUI/ThirdPartyFilamentMap.hpp @@ -0,0 +1,104 @@ +#ifndef slic3r_GUI_ThirdPartyFilamentMap_hpp_ +#define slic3r_GUI_ThirdPartyFilamentMap_hpp_ + +// Vendor-neutral filament → device-slot mapping for third-party send/upload dialogs +// (Elegoo CANVAS, Creality CFS, K3 holders, Happy Hare / AFC / QIDI Box, Anycubic ACE). +// +// wx-free and printer-protocol-free: the dialog fills this model, the panel shows it, and +// encode_filament_mapping() returns the same "filament:slot" string PrintHostSendDialog already +// sends. Bambu's SelectMachineDialog is unchanged. + +#include +#include + +namespace Slic3r { +namespace GUI { + +// One filament of the plate being sent. +struct ThirdPartySlicerFilament +{ + int index { 0 }; // 0-based slicer filament / T-number + std::string colour; // "#RRGGBB" or "#RRGGBBAA"; empty = unknown + std::string type; // "PLA" + std::string name; // preset alias or display name + int nozzle_id { -1 }; // 0-based nozzle / toolhead; -1 = none / single-nozzle +}; + +// One place on the device a filament can be fed from (AMS / CANVAS / MMU / CFS / ACE / box tray). +struct ThirdPartyDeviceSlot +{ + int unit_id { -1 }; // 0-based unit (AMS 1, CANVAS 1, CFS box 1); -1 = ungrouped + int slot_id { -1 }; // 0-based slot inside the unit (A=0) + std::string label; // shown on the tile; empty = bambu_style_slot_label() + std::string colour; // "#RRGGBB"; empty = unknown + std::string type; // "PLA"; empty = the device did not say + std::string name; // spool / material name + int nozzle_id { -1 }; // which nozzle this unit feeds; -1 = unspecified + bool empty { false }; + int index { 0 }; // protocol index (PrintHostDevices::Slot::index) +}; + +struct ThirdPartyFilamentMapModel +{ + std::vector filaments; // one row each, already filtered to used + std::vector slots; + // Parallel to filaments: position in `slots`, or -1 = "As sliced". + std::vector mapping; + bool read_only { false }; +}; + +struct ThirdPartySlotGroup +{ + int unit_id { -1 }; + int nozzle_id { -1 }; + std::vector slot_positions; // indices into model.slots +}; + +enum class ThirdPartyMapMismatch { + None = 0, + Type = 1, + Colour = 2, + TypeAndColour = 3, +}; + +// "#RRGGBB" upper case from "#RRGGBB", "#RRGGBBAA", "RRGGBB"; empty for anything else. +std::string normalize_map_colour(const std::string& colour); + +// Bambu-style unit+slot: unit 0 slot 0 → "1A", unit 0 slot 3 → "1D", unit 1 slot 0 → "2A". +// unit_id < 0: slot 0 → "A", slot 1 → "B", ... +std::string bambu_style_slot_label(int unit_id, int slot_id); + +// The label a tile should show: slot.label if set, else bambu_style_slot_label(). +std::string slot_display_label(const ThirdPartyDeviceSlot& slot); + +bool types_match(const std::string& a, const std::string& b); + +// Redmean, same formula PrintHostDevices::auto_match_slot uses. 0 when either colour is empty. +double map_colour_distance(const std::string& a, const std::string& b); + +// Noticeably different colours (redmean). False when either side has no colour. +bool colours_mismatch(const std::string& a, const std::string& b); + +ThirdPartyMapMismatch mismatch_for(const ThirdPartySlicerFilament& filament, const ThirdPartyDeviceSlot& slot); +ThirdPartyMapMismatch mismatch_at(const ThirdPartyFilamentMapModel& model, size_t filament_row); + +// Distinct nozzle ids on filaments or slots; more than one means a multi-nozzle / multi-toolhead printer. +std::vector distinct_nozzle_ids(const ThirdPartyFilamentMapModel& model); +bool has_multiple_nozzles(const ThirdPartyFilamentMapModel& model); + +// Badge on the slicer-filament colour box: "L"/"R" when the printer has exactly two nozzles +// numbered 0/1 (Bambu H2D: 0 = right, 1 = left), else the 1-based nozzle number, else the +// 1-based filament index on a single nozzle. +std::string filament_badge_text(const ThirdPartySlicerFilament& filament, const ThirdPartyFilamentMapModel& model); + +// Slots grouped by nozzle (when several) then unit, preserving first-seen order. +std::vector group_slots(const std::vector& slots); + +// "0:1,1:2" — filament index to Slot::index. Skips "As sliced" (-1) rows. Same shape as +// PrintHostSendDialog::filament_mapping(). +std::string encode_filament_mapping(const ThirdPartyFilamentMapModel& model); + +} // namespace GUI +} // namespace Slic3r + +#endif diff --git a/src/slic3r/GUI/ThirdPartyFilamentMapPanel.cpp b/src/slic3r/GUI/ThirdPartyFilamentMapPanel.cpp new file mode 100644 index 000000000000..c1e90d96b04e --- /dev/null +++ b/src/slic3r/GUI/ThirdPartyFilamentMapPanel.cpp @@ -0,0 +1,424 @@ +#include "ThirdPartyFilamentMapPanel.hpp" + +#include "AmsMappingPopup.hpp" +#include "GUI_App.hpp" +#include "I18N.hpp" +#include "Widgets/Label.hpp" +#include "Widgets/PopupWindow.hpp" +#include "format.hpp" + +#include +#include +#include +#include + +#include "Widgets/StateColor.hpp" + +#include + +namespace Slic3r { +namespace GUI { + +namespace { + +wxColour parse_slot_colour(const std::string& raw) +{ + std::string s = normalize_map_colour(raw); + if (s.empty()) + return wxColour(0xCE, 0xCE, 0xCE); + wxColour c; + if (c.Set(wxString::FromUTF8(s.c_str()))) + return c; + return wxColour(0xCE, 0xCE, 0xCE); +} + +wxString from_utf8(const std::string& s) +{ + return wxString::FromUTF8(s.c_str()); +} + +wxString group_title(const ThirdPartySlotGroup& g, bool multiple_nozzles) +{ + wxString title; + if (multiple_nozzles && g.nozzle_id >= 0) { + // Bambu AmsMapingPopup: physical 0 is the right nozzle, 1 is the left. + if (g.nozzle_id == 0) + title = _L("Right"); + else if (g.nozzle_id == 1) + title = _L("Left"); + else + title = format_wxstr(_L("Nozzle %1%"), g.nozzle_id + 1); + } + if (g.unit_id >= 0) { + const wxString unit = format_wxstr(_L("Unit %1%"), g.unit_id + 1); + title = title.empty() ? unit : title + " · " + unit; + } + return title; +} + +} // namespace + +class ThirdPartySlotPopup : public PopupWindow +{ +public: + ThirdPartySlotPopup(wxWindow* parent) + : PopupWindow(parent, wxBORDER_NONE) + { + SetBackgroundColour(StateColor::darkModeColorFor(*wxWHITE)); + Bind(wxEVT_PAINT, &ThirdPartySlotPopup::on_paint, this); + + m_sizer = new wxBoxSizer(wxVERTICAL); + + auto* title_panel = new wxPanel(this, wxID_ANY); + title_panel->SetBackgroundColour(StateColor::darkModeColorFor(wxColour(0xF8, 0xF8, 0xF8))); + title_panel->SetMinSize(wxSize(-1, FromDIP(30))); + auto* title_sizer = new wxBoxSizer(wxVERTICAL); + auto* title = new wxStaticText(title_panel, wxID_ANY, _L("Device slots")); + title->SetForegroundColour(StateColor::darkModeColorFor(wxColour(0x32, 0x3A, 0x3D))); + title->SetFont(::Label::Head_13); + title_sizer->Add(title, 0, wxALIGN_CENTER | wxALL, FromDIP(6)); + title_panel->SetSizer(title_sizer); + + m_body = new wxBoxSizer(wxVERTICAL); + + m_as_sliced = new MappingItem(this); + m_as_sliced->SetSize(wxSize(FromDIP(48), FromDIP(60))); + m_as_sliced->SetMinSize(m_as_sliced->GetSize()); + m_as_sliced->SetMaxSize(m_as_sliced->GetSize()); + TrayData sliced; + sliced.type = TrayType::EMPTY; + sliced.colour = wxColour(0xCE, 0xCE, 0xCE); + m_as_sliced->set_tray_index("-"); + m_as_sliced->set_data(wxColour(0xCE, 0xCE, 0xCE), _L("As sliced"), sliced); + m_as_sliced->Bind(wxEVT_LEFT_DOWN, [this](wxMouseEvent&) { + if (m_panel) + m_panel->pick_slot(m_filament_row, -1); + Dismiss(); + }); + + m_warning = new wxStaticText(this, wxID_ANY, wxEmptyString); + m_warning->SetForegroundColour(wxColour(0xFF, 0x6F, 0x00)); + m_warning->SetFont(::Label::Body_12); + m_warning->Hide(); + + m_sizer->Add(title_panel, 0, wxEXPAND | wxALL, FromDIP(2)); + m_sizer->Add(m_body, 0, wxEXPAND | wxALL, FromDIP(4)); + auto* sliced_row = new wxBoxSizer(wxHORIZONTAL); + sliced_row->Add(m_as_sliced, 0, wxALIGN_CENTER | wxALL, FromDIP(4)); + m_sizer->Add(sliced_row, 0, wxALIGN_CENTER); + m_sizer->Add(m_warning, 0, wxEXPAND | wxALL, FromDIP(6)); + SetSizer(m_sizer); + } + + void set_panel(ThirdPartyFilamentMapPanel* panel) { m_panel = panel; } + + void update(const ThirdPartyFilamentMapModel& model, size_t filament_row) + { + m_filament_row = filament_row; + m_body->Clear(true); + m_tiles.clear(); + + const bool multi = has_multiple_nozzles(model); + const std::string tag_type = filament_row < model.filaments.size() ? model.filaments[filament_row].type : std::string(); + + bool any_type_mismatch = false; + const auto groups = group_slots(model.slots); + for (const ThirdPartySlotGroup& g : groups) { + const wxString title = group_title(g, multi); + if (!title.empty()) { + auto* t = new wxStaticText(this, wxID_ANY, title); + t->SetFont(::Label::Head_13); + t->SetForegroundColour(StateColor::darkModeColorFor(wxColour(0x32, 0x3A, 0x3D))); + m_body->Add(t, 0, wxALIGN_CENTER | wxTOP, FromDIP(4)); + } + + for (size_t chunk = 0; chunk < g.slot_positions.size(); chunk += 4) { + auto* row = new wxBoxSizer(wxHORIZONTAL); + const size_t n = std::min(4, g.slot_positions.size() - chunk); + auto* container = new MappingContainer(this); + auto* inner = new wxBoxSizer(wxHORIZONTAL); + container->SetSizer(inner); + inner->Add(0, 0, 0, wxLEFT, FromDIP(6)); + for (size_t k = 0; k < n; ++k) { + const int pos = g.slot_positions[chunk + k]; + const ThirdPartyDeviceSlot& slot = model.slots[pos]; + MappingItem* item = new MappingItem(container); + item->SetSize(wxSize(FromDIP(48), FromDIP(60))); + item->SetMinSize(item->GetSize()); + item->SetMaxSize(item->GetSize()); + item->m_ams_id = slot.unit_id; + item->m_slot_id = slot.slot_id; + + TrayData td; + td.id = pos; + td.ams_id = slot.unit_id; + td.slot_id = slot.slot_id; + td.filament_type = slot.type; + td.name = slot.type.empty() ? slot.name : slot.type; + td.colour = parse_slot_colour(slot.colour); + if (slot.empty) + td.type = TrayType::EMPTY; + else if (slot.type.empty() && slot.colour.empty()) + td.type = TrayType::THIRD; + else + td.type = TrayType::NORMAL; + + const bool type_unmatch = !slot.empty && !tag_type.empty() && !types_match(tag_type, slot.type); + if (type_unmatch) + any_type_mismatch = true; + + const wxString idx = from_utf8(slot_display_label(slot)); + item->set_tray_index(idx); + if (td.type == TrayType::EMPTY) + item->set_data(wxColour(0xCE, 0xCE, 0xCE), "-", td); + else if (td.type == TrayType::THIRD) + item->set_data(wxColour(0xCE, 0xCE, 0xCE), "?", td); + else if (type_unmatch) + item->set_data(wxColour(0xEE, 0xEE, 0xEE), from_utf8(td.name), td, true); + else + item->set_data(td.colour, from_utf8(td.name), td); + + item->Bind(wxEVT_LEFT_DOWN, [this, pos](wxMouseEvent&) { + if (m_panel) + m_panel->pick_slot(m_filament_row, pos); + Dismiss(); + }); + inner->Add(item, 0, wxTOP, FromDIP(1)); + inner->Add(0, 0, 0, wxRIGHT, FromDIP(6)); + m_tiles.push_back(item); + } + container->Show(); + row->Add(container, 0, wxALIGN_CENTER); + m_body->Add(row, 0, wxALIGN_CENTER | wxALL, FromDIP(2)); + } + } + + if (any_type_mismatch) { + m_warning->SetLabel(_L("Note: slots of a different type are greyed. You can still pick them.")); + m_warning->Wrap(FromDIP(248)); + m_warning->Show(); + } else { + m_warning->Hide(); + } + + Layout(); + Fit(); + } + + void OnDismiss() override + { + if (m_panel) + m_panel->restore_after_popup(); + PopupWindow::OnDismiss(); + } + +private: + void on_paint(wxPaintEvent&) + { + wxPaintDC dc(this); + dc.SetPen(wxColour(0xAC, 0xAC, 0xAC)); + dc.SetBrush(*wxTRANSPARENT_BRUSH); + dc.DrawRoundedRectangle(0, 0, GetSize().x, GetSize().y, 0); + } + + ThirdPartyFilamentMapPanel* m_panel { nullptr }; + size_t m_filament_row { 0 }; + wxBoxSizer* m_sizer { nullptr }; + wxBoxSizer* m_body { nullptr }; + MappingItem* m_as_sliced { nullptr }; + wxStaticText* m_warning { nullptr }; + std::vector m_tiles; +}; + +ThirdPartyFilamentMapPanel::ThirdPartyFilamentMapPanel(wxWindow* parent) + : wxPanel(parent, wxID_ANY) +{ + SetBackgroundColour(StateColor::darkModeColorFor(*wxWHITE)); + m_wrap = new wxWrapSizer(wxHORIZONTAL); + auto* root = new wxBoxSizer(wxVERTICAL); + root->Add(m_wrap, 0, wxEXPAND); + SetSizer(root); + + m_popup = new ThirdPartySlotPopup(this); + m_popup->set_panel(this); +} + +ThirdPartyFilamentMapPanel::~ThirdPartyFilamentMapPanel() +{ + m_popup = nullptr; +} + +void ThirdPartyFilamentMapPanel::set_model(ThirdPartyFilamentMapModel model, ChangedFn on_changed) +{ + if (model.mapping.size() != model.filaments.size()) + model.mapping.resize(model.filaments.size(), -1); + m_model = std::move(model); + m_on_changed = std::move(on_changed); + rebuild_rows(); +} + +void ThirdPartyFilamentMapPanel::set_read_only(bool read_only) +{ + if (m_model.read_only == read_only) + return; + m_model.read_only = read_only; + rebuild_rows(); +} + +void ThirdPartyFilamentMapPanel::set_mapping_enabled(bool enabled) +{ + if (m_mapping_enabled == enabled) + return; + m_mapping_enabled = enabled; + for (MaterialItem* item : m_items) { + if (!item) + continue; + if (enabled) + item->enable(); + else + item->disable(); + } +} + +std::string ThirdPartyFilamentMapPanel::filament_mapping() const +{ + return encode_filament_mapping(m_model); +} + +wxString ThirdPartyFilamentMapPanel::mismatch_warning() const +{ + wxString text; + auto add = [&text](const wxString& line) { + if (!text.IsEmpty()) + text += "\n"; + text += line; + }; + for (size_t i = 0; i < m_model.filaments.size(); ++i) { + const ThirdPartyMapMismatch kind = mismatch_at(m_model, i); + if (kind == ThirdPartyMapMismatch::None) + continue; + const int n = m_model.filaments[i].index + 1; + if (kind == ThirdPartyMapMismatch::Type || kind == ThirdPartyMapMismatch::TypeAndColour) { + std::string slot_type; + const int pos = i < m_model.mapping.size() ? m_model.mapping[i] : -1; + if (pos >= 0 && pos < (int) m_model.slots.size()) + slot_type = m_model.slots[pos].type; + add(format_wxstr(_L("Filament %1%: its type (%2%) does not match the mapped slot (%3%)."), n, + m_model.filaments[i].type, slot_type)); + } + if (kind == ThirdPartyMapMismatch::Colour || kind == ThirdPartyMapMismatch::TypeAndColour) + add(format_wxstr(_L("Filament %1%: its colour does not match the mapped slot."), n)); + } + return text; +} + +void ThirdPartyFilamentMapPanel::msw_rescale() +{ + for (MaterialItem* item : m_items) + if (item) + item->msw_rescale(); + Layout(); +} + +void ThirdPartyFilamentMapPanel::sys_color_changed() +{ + SetBackgroundColour(StateColor::darkModeColorFor(*wxWHITE)); + wxGetApp().UpdateDarkUI(this); + Refresh(); +} + +void ThirdPartyFilamentMapPanel::rebuild_rows() +{ + m_wrap->Clear(true); + m_items.clear(); + m_items.resize(m_model.filaments.size(), nullptr); + + for (size_t i = 0; i < m_model.filaments.size(); ++i) { + const ThirdPartySlicerFilament& f = m_model.filaments[i]; + auto* item = new MaterialItem(this, colour_of(f.colour), material_label(f)); + item->set_dropdown_visible(!m_model.read_only); + item->set_nozzle_badge(from_utf8(filament_badge_text(f, m_model))); + if (!m_mapping_enabled) + item->disable(); + m_items[i] = item; + apply_slot_to_item(i); + const size_t row = i; + item->Bind(wxEVT_LEFT_DOWN, [this, row](wxMouseEvent& e) { + if (m_model.read_only || !m_mapping_enabled) + return; + open_popup(row); + e.Skip(); + }); + m_wrap->Add(item, 0, wxALL, FromDIP(5)); + } + Layout(); +} + +void ThirdPartyFilamentMapPanel::apply_slot_to_item(size_t row) +{ + if (row >= m_items.size() || !m_items[row]) + return; + MaterialItem* item = m_items[row]; + const int pos = row < m_model.mapping.size() ? m_model.mapping[row] : -1; + if (pos < 0 || pos >= (int) m_model.slots.size()) { + item->set_ams_info(wxColour(0xCE, 0xCE, 0xCE), "-"); + item->on_normal(); + return; + } + const ThirdPartyDeviceSlot& slot = m_model.slots[pos]; + const wxColour col = slot.empty ? wxColour(0xCE, 0xCE, 0xCE) : colour_of(slot.colour); + item->set_ams_info(col, from_utf8(slot_display_label(slot))); + item->on_normal(); + if (mismatch_at(m_model, row) != ThirdPartyMapMismatch::None) + item->on_warning(); +} + +void ThirdPartyFilamentMapPanel::open_popup(size_t row) +{ + if (!m_popup || row >= m_items.size() || !m_items[row]) + return; + MaterialItem* item = m_items[row]; + for (MaterialItem* other : m_items) + if (other) + other->on_normal(); + item->on_selected(); + + m_popup->update(m_model, row); + wxPoint pos = item->ClientToScreen(wxPoint(0, 0)); + pos.y += item->GetRect().height; + m_popup->Move(pos); + m_popup->Popup(); +} + +void ThirdPartyFilamentMapPanel::pick_slot(size_t row, int slot_pos) +{ + if (row >= m_model.mapping.size()) + return; + m_model.mapping[row] = slot_pos; + restore_after_popup(); + if (m_on_changed) + m_on_changed(); +} + +void ThirdPartyFilamentMapPanel::restore_after_popup() +{ + for (size_t i = 0; i < m_items.size(); ++i) + apply_slot_to_item(i); +} + +wxColour ThirdPartyFilamentMapPanel::colour_of(const std::string& raw) const +{ + return parse_slot_colour(raw); +} + +wxString ThirdPartyFilamentMapPanel::material_label(const ThirdPartySlicerFilament& f) const +{ + if (!f.type.empty()) + return from_utf8(f.type); + if (!f.name.empty()) + return from_utf8(f.name); + return format_wxstr("F%1%", f.index + 1); +} + +} // namespace GUI +} // namespace Slic3r diff --git a/src/slic3r/GUI/ThirdPartyFilamentMapPanel.hpp b/src/slic3r/GUI/ThirdPartyFilamentMapPanel.hpp new file mode 100644 index 000000000000..f1df24f928f9 --- /dev/null +++ b/src/slic3r/GUI/ThirdPartyFilamentMapPanel.hpp @@ -0,0 +1,82 @@ +#ifndef slic3r_GUI_ThirdPartyFilamentMapPanel_hpp_ +#define slic3r_GUI_ThirdPartyFilamentMapPanel_hpp_ + +#include "ThirdPartyFilamentMap.hpp" + +#include +#include +#include +#include + +class wxBoxSizer; +class wxWrapSizer; + +namespace Slic3r { +namespace GUI { + +class MaterialItem; +class ThirdPartySlotPopup; + +// Bambu-style mapping rows for a third-party send dialog: MaterialItem colour cards (slicer +// filament on top, device slot underneath) and a MappingItem tile popup grouped by unit +// (1A–1D). The Bambu SelectMachineDialog is not used and is not changed. +// +// ThirdPartyFilamentMapPanel* panel = new ThirdPartyFilamentMapPanel(parent); +// panel->set_model(model, [this]() { update_mapping_note(); }); +// std::string sent = panel->filament_mapping(); // "0:1,1:2" +// +// Read-only mode (Creality K3 holders): set_model() with model.read_only, or set_read_only(true) +// — no popup, no dropdown arrow, just the two colours. +class ThirdPartyFilamentMapPanel : public wxPanel +{ +public: + using ChangedFn = std::function; + + explicit ThirdPartyFilamentMapPanel(wxWindow* parent); + ~ThirdPartyFilamentMapPanel() override; + + // Replaces the rows. `on_changed` is called whenever a slot is picked (not in read-only). + void set_model(ThirdPartyFilamentMapModel model, ChangedFn on_changed = {}); + const ThirdPartyFilamentMapModel& model() const { return m_model; } + + void set_read_only(bool read_only); + bool is_read_only() const { return m_model.read_only; } + + // Grey out the cards and skip the popup (QIDI Box off / Use CFS off). + void set_mapping_enabled(bool enabled); + bool mapping_enabled() const { return m_mapping_enabled; } + + // Positions in model.slots, -1 = "As sliced". Same shape as PrintHostSendDialog::mapping_choice_positions(). + std::vector choice_positions() const { return m_model.mapping; } + // "0:1,1:2" — filament index to Slot::index. Empty when every row is "As sliced". + std::string filament_mapping() const; + + // Type / colour mismatch sentences for the dialog's mapping note; "" = none. Translated. + wxString mismatch_warning() const; + + void msw_rescale(); + void sys_color_changed(); + +private: + friend class ThirdPartySlotPopup; + + void rebuild_rows(); + void apply_slot_to_item(size_t row); + void open_popup(size_t row); + void pick_slot(size_t row, int slot_pos); // -1 = as sliced + void restore_after_popup(); + wxColour colour_of(const std::string& raw) const; + wxString material_label(const ThirdPartySlicerFilament& f) const; + + ThirdPartyFilamentMapModel m_model; + ChangedFn m_on_changed; + bool m_mapping_enabled { true }; + wxWrapSizer* m_wrap { nullptr }; + std::vector m_items; + ThirdPartySlotPopup* m_popup { nullptr }; +}; + +} // namespace GUI +} // namespace Slic3r + +#endif diff --git a/tests/slic3rutils/elegoo_canvas_mapping_tests.cpp b/tests/slic3rutils/elegoo_canvas_mapping_tests.cpp index c013e48ed188..fff1ab1664df 100644 --- a/tests/slic3rutils/elegoo_canvas_mapping_tests.cpp +++ b/tests/slic3rutils/elegoo_canvas_mapping_tests.cpp @@ -21,6 +21,7 @@ #include "slic3r/Utils/ElegooCanvasMapping.hpp" #include "slic3r/Utils/ElegooStartPrint.hpp" #include "slic3r/Utils/PrintHostDeviceStatus.hpp" +#include "slic3r/GUI/ThirdPartyFilamentMap.hpp" using namespace Slic3r; using ElegooStartPrint::SlotMapEntry; @@ -155,6 +156,8 @@ TEST_CASE("A CANVAS read becomes the send dialog's slots", "[ElegooCanvas]") CHECK(st.slots[0].min_temp == 190); CHECK(st.slots[0].max_temp == 230); CHECK(st.slots[0].label() == "Tray 1 PLA"); + CHECK(Slic3r::GUI::bambu_style_slot_label(st.slots[0].unit, st.slots[0].tray) == "1A"); + CHECK(Slic3r::GUI::bambu_style_slot_label(st.slots[3].unit, st.slots[3].tray) == "1D"); CHECK(! st.slots[2].loaded); CHECK(st.slots[2].label() == "Tray 3 (empty)"); CHECK(st.slots[3].name == "PLA"); @@ -320,3 +323,125 @@ TEST_CASE("Remembered CANVAS mappings follow the spool, not the tray number", "[ CHECK(ElegooCanvasMapping::load().empty()); } } + +TEST_CASE("Third-party filament map: Bambu-style slot labels and grouping", "[ThirdPartyFilamentMap]") +{ + using namespace Slic3r::GUI; + CHECK(bambu_style_slot_label(0, 0) == "1A"); + CHECK(bambu_style_slot_label(0, 1) == "1B"); + CHECK(bambu_style_slot_label(0, 3) == "1D"); + CHECK(bambu_style_slot_label(1, 0) == "2A"); + CHECK(bambu_style_slot_label(-1, 0) == "A"); + CHECK(bambu_style_slot_label(-1, 2) == "C"); + + ThirdPartyDeviceSlot a; + a.unit_id = 0; + a.slot_id = 0; + a.label = "custom"; + CHECK(slot_display_label(a) == "custom"); + a.label.clear(); + CHECK(slot_display_label(a) == "1A"); + + std::vector slots(6); + slots[0].unit_id = 0; + slots[0].slot_id = 0; + slots[1].unit_id = 0; + slots[1].slot_id = 1; + slots[2].unit_id = 1; + slots[2].slot_id = 0; + slots[2].nozzle_id = 1; + slots[3].unit_id = 1; + slots[3].slot_id = 1; + slots[3].nozzle_id = 1; + slots[4].unit_id = 0; + slots[4].slot_id = 2; + slots[5].unit_id = 0; + slots[5].slot_id = 3; + const auto groups = group_slots(slots); + REQUIRE(groups.size() == 2); + CHECK(groups[0].unit_id == 0); + CHECK(groups[0].slot_positions == std::vector{ 0, 1, 4, 5 }); + CHECK(groups[1].unit_id == 1); + CHECK(groups[1].nozzle_id == 1); + CHECK(groups[1].slot_positions == std::vector{ 2, 3 }); +} + +TEST_CASE("Third-party filament map: encode matches the send dialog's filament:slot string", "[ThirdPartyFilamentMap]") +{ + using namespace Slic3r::GUI; + ThirdPartyFilamentMapModel model; + ThirdPartySlicerFilament f0; + f0.index = 0; + ThirdPartySlicerFilament f1; + f1.index = 2; + model.filaments = { f0, f1 }; + ThirdPartyDeviceSlot s0; + s0.index = 5; + ThirdPartyDeviceSlot s1; + s1.index = 7; + model.slots = { s0, s1 }; + model.mapping = { 1, -1 }; + CHECK(encode_filament_mapping(model) == "0:7"); + model.mapping = { 0, 1 }; + CHECK(encode_filament_mapping(model) == "0:5,2:7"); + model.mapping = { -1, -1 }; + CHECK(encode_filament_mapping(model).empty()); +} + +TEST_CASE("Third-party filament map: type and colour mismatch, badges, colour parse", "[ThirdPartyFilamentMap]") +{ + using namespace Slic3r::GUI; + CHECK(normalize_map_colour("#ff00aa") == "#FF00AA"); + CHECK(normalize_map_colour("00FF00BB") == "#00FF00"); + CHECK(normalize_map_colour("not-a-colour").empty()); + CHECK(types_match("PLA", "pla")); + CHECK(types_match("PLA", "")); + CHECK_FALSE(types_match("PLA", "PETG")); + CHECK_FALSE(colours_mismatch("#FF0000", "#EE0000")); + CHECK(colours_mismatch("#FF0000", "#0000FF")); + CHECK_FALSE(colours_mismatch("#FF0000", "")); + + ThirdPartySlicerFilament f; + f.index = 0; + f.type = "PLA"; + f.colour = "#FF0000"; + ThirdPartyDeviceSlot s; + s.type = "PETG"; + s.colour = "#0000FF"; + CHECK(mismatch_for(f, s) == ThirdPartyMapMismatch::TypeAndColour); + s.type = "PLA"; + CHECK(mismatch_for(f, s) == ThirdPartyMapMismatch::Colour); + s.colour = "#FF0000"; + CHECK(mismatch_for(f, s) == ThirdPartyMapMismatch::None); + s.empty = true; + s.type = "PETG"; + CHECK(mismatch_for(f, s) == ThirdPartyMapMismatch::None); + + ThirdPartyFilamentMapModel model; + ThirdPartySlicerFilament a; + a.index = 0; + a.nozzle_id = -1; + model.filaments = { a }; + CHECK(filament_badge_text(a, model) == "1"); + a.index = 3; + model.filaments = { a }; + CHECK(filament_badge_text(a, model) == "4"); + + ThirdPartySlicerFilament left; + left.nozzle_id = 1; + ThirdPartySlicerFilament right; + right.nozzle_id = 0; + model.filaments = { right, left }; + CHECK(has_multiple_nozzles(model)); + CHECK(filament_badge_text(right, model) == "R"); + CHECK(filament_badge_text(left, model) == "L"); + + ThirdPartySlicerFilament third; + third.nozzle_id = 2; + model.filaments = { right, left, third }; + CHECK(filament_badge_text(right, model) == "1"); + CHECK(filament_badge_text(third, model) == "3"); + + model.filaments = { right }; + CHECK_FALSE(has_multiple_nozzles(model)); +} From 59c1a0570ede6b8e45f28ede740d07ba9b957536 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 19 Oct 2026 13:26:32 +0000 Subject: [PATCH 5/7] Fix Russ review 1/3 on the third-party filament map. Call the panel's DPI/theme handlers from the send dialog, use dark-mode colours only on the new badge and panel paths, add the panel to gettext, label Elegoo warnings with the same 1A-1D tiles, shrink the badge so long types still fit, number unit-less slots 1..N, and keep read-only auto-match visual while encoding an empty mapping. Co-authored-by: aceRage --- localization/i18n/list.txt | 1 + src/slic3r/GUI/AmsMappingPopup.cpp | 27 +++++-- src/slic3r/GUI/PrintHostDialogs.cpp | 81 ++++++++++++++----- src/slic3r/GUI/PrintHostDialogs.hpp | 8 +- src/slic3r/GUI/ThirdPartyFilamentMap.cpp | 8 +- src/slic3r/GUI/ThirdPartyFilamentMap.hpp | 5 +- src/slic3r/GUI/ThirdPartyFilamentMapPanel.cpp | 67 ++++++++++++--- src/slic3r/GUI/ThirdPartyFilamentMapPanel.hpp | 3 +- .../elegoo_canvas_mapping_tests.cpp | 18 ++++- 9 files changed, 170 insertions(+), 48 deletions(-) diff --git a/localization/i18n/list.txt b/localization/i18n/list.txt index e240d8fc8a33..dc76bf9aaa5e 100644 --- a/localization/i18n/list.txt +++ b/localization/i18n/list.txt @@ -177,6 +177,7 @@ src/slic3r/GUI/CaliHistoryDialog.cpp src/slic3r/GUI/BonjourDialog.cpp src/slic3r/GUI/calib_dlg.cpp src/slic3r/GUI/PrintHostDialogs.cpp +src/slic3r/GUI/ThirdPartyFilamentMapPanel.cpp src/slic3r/GUI/Gizmos/GLGizmoMeshBoolean.cpp src/slic3r/GUI/NetworkTestDialog.cpp src/slic3r/GUI/CreatePresetsDialog.cpp diff --git a/src/slic3r/GUI/AmsMappingPopup.cpp b/src/slic3r/GUI/AmsMappingPopup.cpp index 796e0fc5c786..cd7f8322d73c 100644 --- a/src/slic3r/GUI/AmsMappingPopup.cpp +++ b/src/slic3r/GUI/AmsMappingPopup.cpp @@ -12,9 +12,11 @@ #include "Widgets/ProgressDialog.hpp" #include "Widgets/RoundedRectangle.hpp" #include "Widgets/StaticBox.hpp" +#include "Widgets/StateColor.hpp" #include #include +#include #include #include #include @@ -176,8 +178,20 @@ void MaterialItem::render(wxDC &dc) dc.SetFont(::Label::Body_10); } - auto material_txt_size = dc.GetTextExtent(m_material_name); - dc.DrawText(m_material_name, wxPoint((MATERIAL_ITEM_SIZE.x - material_txt_size.x) / 2, (FromDIP(28) - material_txt_size.y) / 2)); + wxString material_txt = m_material_name; + auto material_txt_size = dc.GetTextExtent(material_txt); + int name_x = (MATERIAL_ITEM_SIZE.x - material_txt_size.x) / 2; + if (!m_nozzle_badge.empty()) { + // 12 DIP badge at top-left: long types (PETG-CF) on a 64-DIP tile must not sit under it. + const int name_x_pad = FromDIP(12) + FromDIP(4); + const int name_max_w = GetSize().x - name_x_pad - FromDIP(4); + if (material_txt_size.x > name_max_w) { + material_txt = wxControl::Ellipsize(material_txt, dc, wxELLIPSIZE_END, name_max_w); + material_txt_size = dc.GetTextExtent(material_txt); + } + name_x = std::max(name_x, name_x_pad); + } + dc.DrawText(material_txt, wxPoint(name_x, (FromDIP(28) - material_txt_size.y) / 2)); // mapping num dc.SetFont(::Label::Body_10); @@ -197,13 +211,14 @@ void MaterialItem::render(wxDC &dc) dc.DrawText(mapping_txt, wxPoint((MATERIAL_ITEM_SIZE.x - mapping_txt_size.x) / 2, FromDIP(30) + (FromDIP(16) - mapping_txt_size.y) / 2)); if (!m_nozzle_badge.empty()) { - const int d = FromDIP(16); - const wxPoint o(FromDIP(2), FromDIP(2)); + const int d = FromDIP(12); + const wxPoint o(FromDIP(2), FromDIP(2)); + const wxColour badge_bg = StateColor::darkModeColorFor(wxColour(0x26, 0x2E, 0x30)); dc.SetPen(*wxTRANSPARENT_PEN); - dc.SetBrush(wxBrush(wxColour(0x26, 0x2E, 0x30))); + dc.SetBrush(wxBrush(badge_bg)); dc.DrawEllipse(o.x, o.y, d, d); dc.SetFont(::Label::Body_10); - dc.SetTextForeground(*wxWHITE); + dc.SetTextForeground(badge_bg.GetLuminance() < 0.6 ? *wxWHITE : wxColour(0x26, 0x2E, 0x30)); dc.SetBackgroundMode(wxBRUSHSTYLE_TRANSPARENT); const wxSize ts = dc.GetTextExtent(m_nozzle_badge); dc.DrawText(m_nozzle_badge, o.x + (d - ts.x) / 2, o.y + (d - ts.y) / 2); diff --git a/src/slic3r/GUI/PrintHostDialogs.cpp b/src/slic3r/GUI/PrintHostDialogs.cpp index a4d3374eca48..59f4541ca131 100644 --- a/src/slic3r/GUI/PrintHostDialogs.cpp +++ b/src/slic3r/GUI/PrintHostDialogs.cpp @@ -36,6 +36,7 @@ #include "NotificationManager.hpp" #include "ExtraRenderers.hpp" #include "format.hpp" +#include "Widgets/StateColor.hpp" #include "WebPreprintDialog.hpp" #include "../Utils/PrintHostDevices.hpp" #include "../Utils/PrintHostDeviceStatus.hpp" @@ -440,6 +441,24 @@ static FlashforgeMaterialMapWidget* as_ff_map_widget(wxWindow* window) } // namespace +static ThirdPartyDeviceSlot third_party_slot_from(const PrintHostDevices::Slot& s) +{ + ThirdPartyDeviceSlot ds; + ds.unit_id = s.unit; + ds.slot_id = s.tray >= 0 ? s.tray : s.index; + ds.colour = s.color; + ds.type = s.type; + ds.name = s.name; + ds.empty = !s.loaded; + ds.index = s.index; + return ds; +} + +static wxString third_party_slot_tile_label(const PrintHostDevices::Slot& s) +{ + return from_u8(slot_display_label(third_party_slot_from(s))); +} + static const char *CONFIG_KEY_PATH = "printhost_path"; static const char *CONFIG_KEY_GROUP = "printhost_group"; static const char* CONFIG_KEY_STORAGE = "printhost_storage"; @@ -751,20 +770,12 @@ void PrintHostSendDialog::rebuild_third_party_map_rows() model.filaments.push_back(std::move(sf)); m_mapping_filament.push_back(f.index); } - for (const PrintHostDevices::Slot& s : m_device_status.slots) { - ThirdPartyDeviceSlot ds; - ds.unit_id = s.unit; - ds.slot_id = s.tray >= 0 ? s.tray : s.index; - ds.label = s.unit >= 0 ? bambu_style_slot_label(s.unit, ds.slot_id) : bambu_style_slot_label(-1, s.index); - ds.colour = s.color; - ds.type = s.type; - ds.name = s.name; - ds.empty = !s.loaded; - ds.index = s.index; - model.slots.push_back(std::move(ds)); - } + for (const PrintHostDevices::Slot& s : m_device_status.slots) + model.slots.push_back(third_party_slot_from(s)); model.read_only = third_party_map_read_only(); model.mapping.resize(model.filaments.size(), -1); + // Always fill the visual auto-match, including read-only (K3): the cards show the match, but + // encode_filament_mapping() / filament_mapping() return empty when read_only. for (size_t i = 0; i < model.filaments.size(); ++i) { const SendPlateFilament* f = plate_filament(model.filaments[i].index); if (!f) @@ -803,17 +814,39 @@ const SendPlateFilament* PrintHostSendDialog::plate_filament(int index) const return nullptr; } -wxString PrintHostSendDialog::mapping_warning() const +wxString PrintHostSendDialog::third_party_mismatch_warning() const { return m_filament_map ? m_filament_map->mismatch_warning() : wxString(); } +void PrintHostSendDialog::on_dpi_changed(const wxRect& suggested_rect) +{ + MsgDialog::on_dpi_changed(suggested_rect); + if (m_filament_map) + m_filament_map->msw_rescale(); + Layout(); + Fit(); + Refresh(); +} + +void PrintHostSendDialog::on_sys_color_changed() +{ + if (m_filament_map) + m_filament_map->sys_color_changed(); + if (m_mapping_note) + m_mapping_note->SetForegroundColour(StateColor::darkModeColorFor(wxColour("#E8A33D"))); +#ifdef _WIN32 + wxGetApp().UpdateDlgDarkUI(this); +#endif + Refresh(); +} + void PrintHostSendDialog::update_mapping_note() { if (!m_mapping_note) return; const bool has_rows = m_filament_map || !m_mapping_choices.empty(); - const wxString text = has_rows ? mapping_warning() : wxString(); + const wxString text = has_rows ? third_party_mismatch_warning() : wxString(); const bool was = m_mapping_note->IsShown(); m_mapping_note->SetLabel(text); m_mapping_note->Wrap(CONTENT_WIDTH * wxGetApp().em_unit()); @@ -2400,10 +2433,10 @@ int ElegooPrintHostSendDialog::preselect_slot(const SendPlateFilament& f) const return ElegooCanvasMapping::recall(m_memory, u, m_device_status.slots); } -wxString ElegooPrintHostSendDialog::mapping_warning() const +wxString ElegooPrintHostSendDialog::third_party_mismatch_warning() const { if (!m_device_status.canvas) - return PrintHostSendDialog::mapping_warning(); + return PrintHostSendDialog::third_party_mismatch_warning(); const auto used = used_filaments(); const auto warnings = ElegooCanvasMapping::warnings_for(used, mapping_choice_positions(), m_device_status.slots); wxString text; @@ -2412,23 +2445,31 @@ wxString ElegooPrintHostSendDialog::mapping_warning() const text += "\n"; text += line; }; + auto slot_lbl = [this](int protocol_index) { + for (const PrintHostDevices::Slot& s : m_device_status.slots) + if (s.index == protocol_index) + return third_party_slot_tile_label(s); + return from_u8(bambu_style_slot_label(-1, protocol_index)); + }; for (const ElegooCanvasMapping::Warning& w : warnings) { switch (w.kind) { case ElegooCanvasMapping::WarningKind::MoreFilamentsThanTrays: add(_L("This plate uses more filaments than the CANVAS has loaded trays.")); break; case ElegooCanvasMapping::WarningKind::SharedTray: - add(format_wxstr(_L("Filament %1% prints from the same tray as another filament."), w.filament + 1)); + add(format_wxstr(_L("Filament %1% prints from the same slot (%2%) as another filament."), w.filament + 1, + slot_lbl(w.slot))); break; case ElegooCanvasMapping::WarningKind::TemperatureOutOfRange: - add(format_wxstr(_L("Filament %1%: its nozzle temperature is outside the range of tray %2%."), w.filament + 1, w.slot + 1)); + add(format_wxstr(_L("Filament %1%: its nozzle temperature is outside the range of %2%."), w.filament + 1, + slot_lbl(w.slot))); break; case ElegooCanvasMapping::WarningKind::EmptyTray: - add(format_wxstr(_L("Filament %1% is mapped to tray %2%, which is empty."), w.filament + 1, w.slot + 1)); + add(format_wxstr(_L("Filament %1% is mapped to %2%, which is empty."), w.filament + 1, slot_lbl(w.slot))); break; } } - if (const wxString extra = PrintHostSendDialog::mapping_warning(); !extra.IsEmpty()) + if (const wxString extra = PrintHostSendDialog::third_party_mismatch_warning(); !extra.IsEmpty()) add(extra); return text; } diff --git a/src/slic3r/GUI/PrintHostDialogs.hpp b/src/slic3r/GUI/PrintHostDialogs.hpp index e1eac9e461b1..5e8d675592a5 100644 --- a/src/slic3r/GUI/PrintHostDialogs.hpp +++ b/src/slic3r/GUI/PrintHostDialogs.hpp @@ -121,8 +121,10 @@ class PrintHostSendDialog : public GUI::MsgDialog // closest-colour rule pick. The Elegoo dialog answers with the remembered mapping. virtual int preselect_slot(const SendPlateFilament& f) const { return -1; } // One or more lines under the mapping table, refreshed whenever a choice changes; "" = none. - // The base implementation is the third-party panel's type/colour mismatch text. - virtual wxString mapping_warning() const; + // Named so vendor PRs (#622) can add their own mapping_warning() without colliding. + virtual wxString third_party_mismatch_warning() const; + void on_dpi_changed(const wxRect &suggested_rect) override; + void on_sys_color_changed() override; void update_mapping_note(); // The rows' choices as positions in m_device_status.slots, -1 = "As sliced"; and the plate // filament each row is for. @@ -279,7 +281,7 @@ class ElegooPrintHostSendDialog : public PrintHostSendDialog protected: bool use_third_party_map_panel() const override { return true; } int preselect_slot(const SendPlateFilament& f) const override; - wxString mapping_warning() const override; + wxString third_party_mismatch_warning() const override; private: // The plate's used filaments in the shape the CANVAS mapping code takes. diff --git a/src/slic3r/GUI/ThirdPartyFilamentMap.cpp b/src/slic3r/GUI/ThirdPartyFilamentMap.cpp index c8b14c7fb18b..3bfa9134003d 100644 --- a/src/slic3r/GUI/ThirdPartyFilamentMap.cpp +++ b/src/slic3r/GUI/ThirdPartyFilamentMap.cpp @@ -82,10 +82,10 @@ std::string normalize_map_colour(const std::string& colour) std::string bambu_style_slot_label(int unit_id, int slot_id) { const int slot = slot_id < 0 ? 0 : slot_id; - const int letter_i = slot % 4; - const char letter = static_cast('A' + letter_i); if (unit_id < 0) - return std::string(1, letter); + return std::to_string(slot + 1); + const int letter_i = slot % 4; + const char letter = static_cast('A' + letter_i); return std::to_string(unit_id + 1) + letter; } @@ -201,6 +201,8 @@ std::vector group_slots(const std::vector group_slots(const std::vector& slots); -// "0:1,1:2" — filament index to Slot::index. Skips "As sliced" (-1) rows. Same shape as +// "0:1,1:2" — filament index to Slot::index. Empty when model.read_only (the cards may still +// show an auto-match) or when every row is "As sliced". Same shape as // PrintHostSendDialog::filament_mapping(). std::string encode_filament_mapping(const ThirdPartyFilamentMapModel& model); diff --git a/src/slic3r/GUI/ThirdPartyFilamentMapPanel.cpp b/src/slic3r/GUI/ThirdPartyFilamentMapPanel.cpp index c1e90d96b04e..115b9443dfc1 100644 --- a/src/slic3r/GUI/ThirdPartyFilamentMapPanel.cpp +++ b/src/slic3r/GUI/ThirdPartyFilamentMapPanel.cpp @@ -21,15 +21,30 @@ namespace GUI { namespace { +wxColour unknown_slot_colour() +{ + return StateColor::darkModeColorFor(wxColour(0xCE, 0xCE, 0xCE)); +} + +wxColour warning_text_colour() +{ + return StateColor::darkModeColorFor(wxColour(0xFF, 0x6F, 0x00)); +} + +wxColour popup_border_colour() +{ + return StateColor::darkModeColorFor(wxColour(0xAC, 0xAC, 0xAC)); +} + wxColour parse_slot_colour(const std::string& raw) { std::string s = normalize_map_colour(raw); if (s.empty()) - return wxColour(0xCE, 0xCE, 0xCE); + return unknown_slot_colour(); wxColour c; if (c.Set(wxString::FromUTF8(s.c_str()))) return c; - return wxColour(0xCE, 0xCE, 0xCE); + return unknown_slot_colour(); } wxString from_utf8(const std::string& s) @@ -87,9 +102,9 @@ class ThirdPartySlotPopup : public PopupWindow m_as_sliced->SetMaxSize(m_as_sliced->GetSize()); TrayData sliced; sliced.type = TrayType::EMPTY; - sliced.colour = wxColour(0xCE, 0xCE, 0xCE); + sliced.colour = unknown_slot_colour(); m_as_sliced->set_tray_index("-"); - m_as_sliced->set_data(wxColour(0xCE, 0xCE, 0xCE), _L("As sliced"), sliced); + m_as_sliced->set_data(unknown_slot_colour(), _L("As sliced"), sliced); m_as_sliced->Bind(wxEVT_LEFT_DOWN, [this](wxMouseEvent&) { if (m_panel) m_panel->pick_slot(m_filament_row, -1); @@ -97,7 +112,7 @@ class ThirdPartySlotPopup : public PopupWindow }); m_warning = new wxStaticText(this, wxID_ANY, wxEmptyString); - m_warning->SetForegroundColour(wxColour(0xFF, 0x6F, 0x00)); + m_warning->SetForegroundColour(warning_text_colour()); m_warning->SetFont(::Label::Body_12); m_warning->Hide(); @@ -170,9 +185,9 @@ class ThirdPartySlotPopup : public PopupWindow const wxString idx = from_utf8(slot_display_label(slot)); item->set_tray_index(idx); if (td.type == TrayType::EMPTY) - item->set_data(wxColour(0xCE, 0xCE, 0xCE), "-", td); + item->set_data(unknown_slot_colour(), "-", td); else if (td.type == TrayType::THIRD) - item->set_data(wxColour(0xCE, 0xCE, 0xCE), "?", td); + item->set_data(unknown_slot_colour(), "?", td); else if (type_unmatch) item->set_data(wxColour(0xEE, 0xEE, 0xEE), from_utf8(td.name), td, true); else @@ -212,11 +227,35 @@ class ThirdPartySlotPopup : public PopupWindow PopupWindow::OnDismiss(); } + void msw_rescale() + { + for (MappingItem* t : m_tiles) + if (t) + t->msw_rescale(); + if (m_as_sliced) + m_as_sliced->msw_rescale(); + Layout(); + } + + void sys_color_changed() + { + SetBackgroundColour(StateColor::darkModeColorFor(*wxWHITE)); + if (m_warning) + m_warning->SetForegroundColour(warning_text_colour()); + if (m_as_sliced) { + TrayData sliced; + sliced.type = TrayType::EMPTY; + sliced.colour = unknown_slot_colour(); + m_as_sliced->set_data(unknown_slot_colour(), _L("As sliced"), sliced); + } + Refresh(); + } + private: void on_paint(wxPaintEvent&) { wxPaintDC dc(this); - dc.SetPen(wxColour(0xAC, 0xAC, 0xAC)); + dc.SetPen(popup_border_colour()); dc.SetBrush(*wxTRANSPARENT_BRUSH); dc.DrawRoundedRectangle(0, 0, GetSize().x, GetSize().y, 0); } @@ -317,6 +356,8 @@ void ThirdPartyFilamentMapPanel::msw_rescale() for (MaterialItem* item : m_items) if (item) item->msw_rescale(); + if (m_popup) + m_popup->msw_rescale(); Layout(); } @@ -324,6 +365,10 @@ void ThirdPartyFilamentMapPanel::sys_color_changed() { SetBackgroundColour(StateColor::darkModeColorFor(*wxWHITE)); wxGetApp().UpdateDarkUI(this); + for (size_t i = 0; i < m_items.size(); ++i) + apply_slot_to_item(i); + if (m_popup) + m_popup->sys_color_changed(); Refresh(); } @@ -361,12 +406,12 @@ void ThirdPartyFilamentMapPanel::apply_slot_to_item(size_t row) MaterialItem* item = m_items[row]; const int pos = row < m_model.mapping.size() ? m_model.mapping[row] : -1; if (pos < 0 || pos >= (int) m_model.slots.size()) { - item->set_ams_info(wxColour(0xCE, 0xCE, 0xCE), "-"); + item->set_ams_info(unknown_slot_colour(), "-"); item->on_normal(); return; } const ThirdPartyDeviceSlot& slot = m_model.slots[pos]; - const wxColour col = slot.empty ? wxColour(0xCE, 0xCE, 0xCE) : colour_of(slot.colour); + const wxColour col = slot.empty ? unknown_slot_colour() : colour_of(slot.colour); item->set_ams_info(col, from_utf8(slot_display_label(slot))); item->on_normal(); if (mismatch_at(m_model, row) != ThirdPartyMapMismatch::None) @@ -417,7 +462,7 @@ wxString ThirdPartyFilamentMapPanel::material_label(const ThirdPartySlicerFilame return from_utf8(f.type); if (!f.name.empty()) return from_utf8(f.name); - return format_wxstr("F%1%", f.index + 1); + return format_wxstr(_L("F%1%"), f.index + 1); } } // namespace GUI diff --git a/src/slic3r/GUI/ThirdPartyFilamentMapPanel.hpp b/src/slic3r/GUI/ThirdPartyFilamentMapPanel.hpp index f1df24f928f9..c8e05b562a05 100644 --- a/src/slic3r/GUI/ThirdPartyFilamentMapPanel.hpp +++ b/src/slic3r/GUI/ThirdPartyFilamentMapPanel.hpp @@ -47,8 +47,9 @@ class ThirdPartyFilamentMapPanel : public wxPanel bool mapping_enabled() const { return m_mapping_enabled; } // Positions in model.slots, -1 = "As sliced". Same shape as PrintHostSendDialog::mapping_choice_positions(). + // Still filled in read-only (the visual auto-match); filament_mapping() is empty then. std::vector choice_positions() const { return m_model.mapping; } - // "0:1,1:2" — filament index to Slot::index. Empty when every row is "As sliced". + // "0:1,1:2" — filament index to Slot::index. Empty when read_only or every row is "As sliced". std::string filament_mapping() const; // Type / colour mismatch sentences for the dialog's mapping note; "" = none. Translated. diff --git a/tests/slic3rutils/elegoo_canvas_mapping_tests.cpp b/tests/slic3rutils/elegoo_canvas_mapping_tests.cpp index fff1ab1664df..9cdfe806dbe8 100644 --- a/tests/slic3rutils/elegoo_canvas_mapping_tests.cpp +++ b/tests/slic3rutils/elegoo_canvas_mapping_tests.cpp @@ -331,8 +331,17 @@ TEST_CASE("Third-party filament map: Bambu-style slot labels and grouping", "[Th CHECK(bambu_style_slot_label(0, 1) == "1B"); CHECK(bambu_style_slot_label(0, 3) == "1D"); CHECK(bambu_style_slot_label(1, 0) == "2A"); - CHECK(bambu_style_slot_label(-1, 0) == "A"); - CHECK(bambu_style_slot_label(-1, 2) == "C"); + CHECK(bambu_style_slot_label(-1, 0) == "1"); + CHECK(bambu_style_slot_label(-1, 2) == "3"); + CHECK(bambu_style_slot_label(-1, 3) == "4"); + CHECK(bambu_style_slot_label(-1, 4) == "5"); + CHECK(bambu_style_slot_label(-1, 7) == "8"); + + ThirdPartyDeviceSlot unitless; + unitless.unit_id = -1; + unitless.slot_id = -1; + unitless.index = 5; + CHECK(slot_display_label(unitless) == "6"); ThirdPartyDeviceSlot a; a.unit_id = 0; @@ -386,6 +395,11 @@ TEST_CASE("Third-party filament map: encode matches the send dialog's filament:s CHECK(encode_filament_mapping(model) == "0:5,2:7"); model.mapping = { -1, -1 }; CHECK(encode_filament_mapping(model).empty()); + model.mapping = { 0, 1 }; + model.read_only = true; + CHECK(encode_filament_mapping(model).empty()); + model.read_only = false; + CHECK(encode_filament_mapping(model) == "0:5,2:7"); } TEST_CASE("Third-party filament map: type and colour mismatch, badges, colour parse", "[ThirdPartyFilamentMap]") From 884d042d776bde70af63c0d848d6f593ffbdd348 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 19 Oct 2026 13:33:09 +0000 Subject: [PATCH 6/7] Keep mapping_warning() for vendor PRs; size the badge to its text. update_mapping_note combines mapping_warning() and third_party_mismatch_warning(). Read-only mismatch compares T-number identity, not the visual auto-match. The nozzle badge pill follows Body_10 extent so labels like 10 fit. Co-authored-by: aceRage --- src/slic3r/GUI/AmsMappingPopup.cpp | 34 ++++++++++---- src/slic3r/GUI/PrintHostDialogs.cpp | 46 ++++++++++++++++--- src/slic3r/GUI/PrintHostDialogs.hpp | 7 ++- src/slic3r/GUI/ThirdPartyFilamentMap.cpp | 20 ++++++-- src/slic3r/GUI/ThirdPartyFilamentMap.hpp | 4 ++ src/slic3r/GUI/ThirdPartyFilamentMapPanel.cpp | 4 +- src/slic3r/GUI/ThirdPartyFilamentMapPanel.hpp | 5 +- .../elegoo_canvas_mapping_tests.cpp | 31 +++++++++++++ 8 files changed, 128 insertions(+), 23 deletions(-) diff --git a/src/slic3r/GUI/AmsMappingPopup.cpp b/src/slic3r/GUI/AmsMappingPopup.cpp index cd7f8322d73c..4a814874a37a 100644 --- a/src/slic3r/GUI/AmsMappingPopup.cpp +++ b/src/slic3r/GUI/AmsMappingPopup.cpp @@ -181,15 +181,27 @@ void MaterialItem::render(wxDC &dc) wxString material_txt = m_material_name; auto material_txt_size = dc.GetTextExtent(material_txt); int name_x = (MATERIAL_ITEM_SIZE.x - material_txt_size.x) / 2; + wxSize badge_ts(0, 0); + int badge_w = 0; + int badge_h = 0; if (!m_nozzle_badge.empty()) { - // 12 DIP badge at top-left: long types (PETG-CF) on a 64-DIP tile must not sit under it. - const int name_x_pad = FromDIP(12) + FromDIP(4); + // Pill sized to Body_10 text: a fixed 12 DIP circle clips "10" and is shorter than the glyph. + dc.SetFont(::Label::Body_10); + badge_ts = dc.GetTextExtent(m_nozzle_badge); + const int pad = FromDIP(2); + badge_w = badge_ts.x + 2 * pad; + badge_h = badge_ts.y + 2 * pad; + const int name_x_pad = badge_w + FromDIP(4); const int name_max_w = GetSize().x - name_x_pad - FromDIP(4); - if (material_txt_size.x > name_max_w) { + dc.SetFont(::Label::Body_13); + if (dc.GetTextExtent(material_txt).x > GetSize().x - 10) + dc.SetFont(::Label::Body_10); + if (dc.GetTextExtent(material_txt).x > name_max_w) { + dc.SetFont(::Label::Body_10); material_txt = wxControl::Ellipsize(material_txt, dc, wxELLIPSIZE_END, name_max_w); - material_txt_size = dc.GetTextExtent(material_txt); } - name_x = std::max(name_x, name_x_pad); + material_txt_size = dc.GetTextExtent(material_txt); + name_x = std::max((MATERIAL_ITEM_SIZE.x - material_txt_size.x) / 2, name_x_pad); } dc.DrawText(material_txt, wxPoint(name_x, (FromDIP(28) - material_txt_size.y) / 2)); @@ -211,17 +223,21 @@ void MaterialItem::render(wxDC &dc) dc.DrawText(mapping_txt, wxPoint((MATERIAL_ITEM_SIZE.x - mapping_txt_size.x) / 2, FromDIP(30) + (FromDIP(16) - mapping_txt_size.y) / 2)); if (!m_nozzle_badge.empty()) { - const int d = FromDIP(12); const wxPoint o(FromDIP(2), FromDIP(2)); const wxColour badge_bg = StateColor::darkModeColorFor(wxColour(0x26, 0x2E, 0x30)); dc.SetPen(*wxTRANSPARENT_PEN); dc.SetBrush(wxBrush(badge_bg)); - dc.DrawEllipse(o.x, o.y, d, d); dc.SetFont(::Label::Body_10); + if (badge_w <= 0 || badge_h <= 0) { + badge_ts = dc.GetTextExtent(m_nozzle_badge); + const int pad = FromDIP(2); + badge_w = badge_ts.x + 2 * pad; + badge_h = badge_ts.y + 2 * pad; + } + dc.DrawRoundedRectangle(o.x, o.y, badge_w, badge_h, std::min(badge_w, badge_h) / 2); dc.SetTextForeground(badge_bg.GetLuminance() < 0.6 ? *wxWHITE : wxColour(0x26, 0x2E, 0x30)); dc.SetBackgroundMode(wxBRUSHSTYLE_TRANSPARENT); - const wxSize ts = dc.GetTextExtent(m_nozzle_badge); - dc.DrawText(m_nozzle_badge, o.x + (d - ts.x) / 2, o.y + (d - ts.y) / 2); + dc.DrawText(m_nozzle_badge, o.x + (badge_w - badge_ts.x) / 2, o.y + (badge_h - badge_ts.y) / 2); } } diff --git a/src/slic3r/GUI/PrintHostDialogs.cpp b/src/slic3r/GUI/PrintHostDialogs.cpp index 59f4541ca131..4048f0336026 100644 --- a/src/slic3r/GUI/PrintHostDialogs.cpp +++ b/src/slic3r/GUI/PrintHostDialogs.cpp @@ -459,6 +459,36 @@ static wxString third_party_slot_tile_label(const PrintHostDevices::Slot& s) return from_u8(slot_display_label(third_party_slot_from(s))); } +static void append_unique_mapping_lines(wxString& out, const wxString& block) +{ + size_t start = 0; + while (start <= block.size()) { + const size_t nl = block.find('\n', start); + const wxString line = (nl == wxString::npos) ? block.Mid(start) : block.Mid(start, nl - start); + if (!line.IsEmpty()) { + bool seen = false; + size_t ostart = 0; + while (!seen && ostart <= out.size()) { + const size_t onl = out.find('\n', ostart); + const wxString existing = (onl == wxString::npos) ? out.Mid(ostart) : out.Mid(ostart, onl - ostart); + if (existing == line) + seen = true; + if (onl == wxString::npos) + break; + ostart = onl + 1; + } + if (!seen) { + if (!out.IsEmpty()) + out += "\n"; + out += line; + } + } + if (nl == wxString::npos) + break; + start = nl + 1; + } +} + static const char *CONFIG_KEY_PATH = "printhost_path"; static const char *CONFIG_KEY_GROUP = "printhost_group"; static const char* CONFIG_KEY_STORAGE = "printhost_storage"; @@ -814,6 +844,11 @@ const SendPlateFilament* PrintHostSendDialog::plate_filament(int index) const return nullptr; } +wxString PrintHostSendDialog::mapping_warning() const +{ + return wxString(); +} + wxString PrintHostSendDialog::third_party_mismatch_warning() const { return m_filament_map ? m_filament_map->mismatch_warning() : wxString(); @@ -845,8 +880,9 @@ void PrintHostSendDialog::update_mapping_note() { if (!m_mapping_note) return; - const bool has_rows = m_filament_map || !m_mapping_choices.empty(); - const wxString text = has_rows ? third_party_mismatch_warning() : wxString(); + wxString text; + append_unique_mapping_lines(text, mapping_warning()); + append_unique_mapping_lines(text, third_party_mismatch_warning()); const bool was = m_mapping_note->IsShown(); m_mapping_note->SetLabel(text); m_mapping_note->Wrap(CONTENT_WIDTH * wxGetApp().em_unit()); @@ -2433,10 +2469,10 @@ int ElegooPrintHostSendDialog::preselect_slot(const SendPlateFilament& f) const return ElegooCanvasMapping::recall(m_memory, u, m_device_status.slots); } -wxString ElegooPrintHostSendDialog::third_party_mismatch_warning() const +wxString ElegooPrintHostSendDialog::mapping_warning() const { if (!m_device_status.canvas) - return PrintHostSendDialog::third_party_mismatch_warning(); + return PrintHostSendDialog::mapping_warning(); const auto used = used_filaments(); const auto warnings = ElegooCanvasMapping::warnings_for(used, mapping_choice_positions(), m_device_status.slots); wxString text; @@ -2469,8 +2505,6 @@ wxString ElegooPrintHostSendDialog::third_party_mismatch_warning() const break; } } - if (const wxString extra = PrintHostSendDialog::third_party_mismatch_warning(); !extra.IsEmpty()) - add(extra); return text; } diff --git a/src/slic3r/GUI/PrintHostDialogs.hpp b/src/slic3r/GUI/PrintHostDialogs.hpp index 5e8d675592a5..52e251329dd5 100644 --- a/src/slic3r/GUI/PrintHostDialogs.hpp +++ b/src/slic3r/GUI/PrintHostDialogs.hpp @@ -121,7 +121,10 @@ class PrintHostSendDialog : public GUI::MsgDialog // closest-colour rule pick. The Elegoo dialog answers with the remembered mapping. virtual int preselect_slot(const SendPlateFilament& f) const { return -1; } // One or more lines under the mapping table, refreshed whenever a choice changes; "" = none. - // Named so vendor PRs (#622) can add their own mapping_warning() without colliding. + // Vendor PRs (#616/#619/#620/#622) override this; keep the name and signature. + virtual wxString mapping_warning() const; + // Panel type/colour mismatch (identity slots when read-only). Combined with mapping_warning() + // in update_mapping_note(); vendor overrides of mapping_warning() still compile after rebase. virtual wxString third_party_mismatch_warning() const; void on_dpi_changed(const wxRect &suggested_rect) override; void on_sys_color_changed() override; @@ -281,7 +284,7 @@ class ElegooPrintHostSendDialog : public PrintHostSendDialog protected: bool use_third_party_map_panel() const override { return true; } int preselect_slot(const SendPlateFilament& f) const override; - wxString third_party_mismatch_warning() const override; + wxString mapping_warning() const override; private: // The plate's used filaments in the shape the CANVAS mapping code takes. diff --git a/src/slic3r/GUI/ThirdPartyFilamentMap.cpp b/src/slic3r/GUI/ThirdPartyFilamentMap.cpp index 3bfa9134003d..2aa6d44fce6e 100644 --- a/src/slic3r/GUI/ThirdPartyFilamentMap.cpp +++ b/src/slic3r/GUI/ThirdPartyFilamentMap.cpp @@ -135,11 +135,25 @@ ThirdPartyMapMismatch mismatch_for(const ThirdPartySlicerFilament& filament, con return ThirdPartyMapMismatch::None; } +int warning_slot_position(const ThirdPartyFilamentMapModel& model, size_t filament_row) +{ + if (filament_row >= model.filaments.size()) + return -1; + if (model.read_only) { + const int t = model.filaments[filament_row].index; + for (size_t i = 0; i < model.slots.size(); ++i) + if (model.slots[i].index == t) + return (int) i; + return -1; + } + if (filament_row >= model.mapping.size()) + return -1; + return model.mapping[filament_row]; +} + ThirdPartyMapMismatch mismatch_at(const ThirdPartyFilamentMapModel& model, size_t filament_row) { - if (filament_row >= model.filaments.size() || filament_row >= model.mapping.size()) - return ThirdPartyMapMismatch::None; - const int pos = model.mapping[filament_row]; + const int pos = warning_slot_position(model, filament_row); if (pos < 0 || pos >= (int) model.slots.size()) return ThirdPartyMapMismatch::None; return mismatch_for(model.filaments[filament_row], model.slots[pos]); diff --git a/src/slic3r/GUI/ThirdPartyFilamentMap.hpp b/src/slic3r/GUI/ThirdPartyFilamentMap.hpp index f45780298c4a..80364f02b477 100644 --- a/src/slic3r/GUI/ThirdPartyFilamentMap.hpp +++ b/src/slic3r/GUI/ThirdPartyFilamentMap.hpp @@ -80,6 +80,10 @@ double map_colour_distance(const std::string& a, const std::string& b); bool colours_mismatch(const std::string& a, const std::string& b); ThirdPartyMapMismatch mismatch_for(const ThirdPartySlicerFilament& filament, const ThirdPartyDeviceSlot& slot); +// Slot compared for a warning that describes what will actually print. Interactive: mapping[row]. +// Read-only: the slot whose protocol index equals the filament T-number (identity); mapping[] may +// still hold a visual auto-match and is not sent. +int warning_slot_position(const ThirdPartyFilamentMapModel& model, size_t filament_row); ThirdPartyMapMismatch mismatch_at(const ThirdPartyFilamentMapModel& model, size_t filament_row); // Distinct nozzle ids on filaments or slots; more than one means a multi-nozzle / multi-toolhead printer. diff --git a/src/slic3r/GUI/ThirdPartyFilamentMapPanel.cpp b/src/slic3r/GUI/ThirdPartyFilamentMapPanel.cpp index 115b9443dfc1..bc222bcc2f56 100644 --- a/src/slic3r/GUI/ThirdPartyFilamentMapPanel.cpp +++ b/src/slic3r/GUI/ThirdPartyFilamentMapPanel.cpp @@ -339,7 +339,7 @@ wxString ThirdPartyFilamentMapPanel::mismatch_warning() const const int n = m_model.filaments[i].index + 1; if (kind == ThirdPartyMapMismatch::Type || kind == ThirdPartyMapMismatch::TypeAndColour) { std::string slot_type; - const int pos = i < m_model.mapping.size() ? m_model.mapping[i] : -1; + const int pos = warning_slot_position(m_model, i); if (pos >= 0 && pos < (int) m_model.slots.size()) slot_type = m_model.slots[pos].type; add(format_wxstr(_L("Filament %1%: its type (%2%) does not match the mapped slot (%3%)."), n, @@ -414,7 +414,7 @@ void ThirdPartyFilamentMapPanel::apply_slot_to_item(size_t row) const wxColour col = slot.empty ? unknown_slot_colour() : colour_of(slot.colour); item->set_ams_info(col, from_utf8(slot_display_label(slot))); item->on_normal(); - if (mismatch_at(m_model, row) != ThirdPartyMapMismatch::None) + if (mismatch_for(m_model.filaments[row], slot) != ThirdPartyMapMismatch::None) item->on_warning(); } diff --git a/src/slic3r/GUI/ThirdPartyFilamentMapPanel.hpp b/src/slic3r/GUI/ThirdPartyFilamentMapPanel.hpp index c8e05b562a05..2d21279d5903 100644 --- a/src/slic3r/GUI/ThirdPartyFilamentMapPanel.hpp +++ b/src/slic3r/GUI/ThirdPartyFilamentMapPanel.hpp @@ -47,12 +47,15 @@ class ThirdPartyFilamentMapPanel : public wxPanel bool mapping_enabled() const { return m_mapping_enabled; } // Positions in model.slots, -1 = "As sliced". Same shape as PrintHostSendDialog::mapping_choice_positions(). - // Still filled in read-only (the visual auto-match); filament_mapping() is empty then. + // In read-only this is the visual auto-match only. The map that is sent is filament_mapping() + // (empty); mismatch_warning() compares each filament with its T-number slot (identity). std::vector choice_positions() const { return m_model.mapping; } // "0:1,1:2" — filament index to Slot::index. Empty when read_only or every row is "As sliced". + // Read-only consumers must take the sent map from here, not from choice_positions(). std::string filament_mapping() const; // Type / colour mismatch sentences for the dialog's mapping note; "" = none. Translated. + // Read-only: identity (T-number slot), not the visual auto-match in choice_positions(). wxString mismatch_warning() const; void msw_rescale(); diff --git a/tests/slic3rutils/elegoo_canvas_mapping_tests.cpp b/tests/slic3rutils/elegoo_canvas_mapping_tests.cpp index 9cdfe806dbe8..0b69b2982a73 100644 --- a/tests/slic3rutils/elegoo_canvas_mapping_tests.cpp +++ b/tests/slic3rutils/elegoo_canvas_mapping_tests.cpp @@ -402,6 +402,37 @@ TEST_CASE("Third-party filament map: encode matches the send dialog's filament:s CHECK(encode_filament_mapping(model) == "0:5,2:7"); } +TEST_CASE("Third-party filament map: read-only mismatch uses T-number identity, not auto-match", "[ThirdPartyFilamentMap]") +{ + using namespace Slic3r::GUI; + ThirdPartyFilamentMapModel model; + ThirdPartySlicerFilament f; + f.index = 0; + f.type = "PLA"; + f.colour = "#FF0000"; + model.filaments = { f }; + + ThirdPartyDeviceSlot t0; + t0.index = 0; + t0.type = "PETG"; + t0.colour = "#0000FF"; + ThirdPartyDeviceSlot t1; + t1.index = 1; + t1.type = "PLA"; + t1.colour = "#FF0000"; + model.slots = { t0, t1 }; + model.mapping = { 1 }; // visual auto-match to the matching holder + + CHECK(mismatch_at(model, 0) == ThirdPartyMapMismatch::None); + CHECK(warning_slot_position(model, 0) == 1); + + model.read_only = true; + CHECK(encode_filament_mapping(model).empty()); + CHECK(warning_slot_position(model, 0) == 0); + CHECK(mismatch_at(model, 0) == ThirdPartyMapMismatch::TypeAndColour); + CHECK(model.mapping == std::vector{ 1 }); +} + TEST_CASE("Third-party filament map: type and colour mismatch, badges, colour parse", "[ThirdPartyFilamentMap]") { using namespace Slic3r::GUI; From ec59f58ed8e2e4756121f4dcae9b9685bfca7509 Mon Sep 17 00:00:00 2001 From: aceRage Date: Mon, 19 Oct 2026 19:35:42 +0000 Subject: [PATCH 7/7] Keep Happy Hare materials when extra gate_status values are ignored The truncation case built gate_status as [1] plus two hundred zeros, so gate 1 was empty and leftover names stayed off the slot. Pad after two loaded gates so extra statuses cannot drop gate_material at the same index. Deft --- src/slic3r/Utils/PrintHostDeviceStatus.cpp | 2 ++ tests/slic3rutils/printhost_devices_tests.cpp | 4 +++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/slic3r/Utils/PrintHostDeviceStatus.cpp b/src/slic3r/Utils/PrintHostDeviceStatus.cpp index 2d81db75ce1c..e6048fac6f4e 100644 --- a/src/slic3r/Utils/PrintHostDeviceStatus.cpp +++ b/src/slic3r/Utils/PrintHostDeviceStatus.cpp @@ -316,6 +316,8 @@ static void slots_from_happy_hare(const json& mmu, std::vector& out, bool& const json& gate_status = array_at(mmu, "gate_status"); const json& gate_material = array_at(mmu, "gate_material"); const json& gate_color = array_at(mmu, "gate_color"); + // Zip the first n entries of each array by index. Extra gate_status values + // are ignored; they must not skip gate_material[i] / gate_color[i]. for (int i = 0; i < n; ++i) { Slot s; s.index = i; diff --git a/tests/slic3rutils/printhost_devices_tests.cpp b/tests/slic3rutils/printhost_devices_tests.cpp index bae3bbe69c70..f53b2f027d53 100644 --- a/tests/slic3rutils/printhost_devices_tests.cpp +++ b/tests/slic3rutils/printhost_devices_tests.cpp @@ -782,7 +782,9 @@ TEST_CASE("PrintHostDevices: what a device says it has loaded", "[PrintHostDevic } SECTION("a huge gate_status array is truncated to num_gates") { - std::string body = R"({"num_gates":2,"gate_status":[1)"; + // Two loaded gates, then padding. Starting from [1] plus zeros made gate 1 + // empty, so the parser (which skips leftover names on status <= 0) left type blank. + std::string body = R"({"num_gates":2,"gate_status":[1,1)"; for (int i = 0; i < 200; ++i) body += ",0"; body += R"(],"gate_material":["PLA","PETG"],"gate_color":["red","blue"]})";