feat(gui): multi-nozzle UI for H2C/A2L

Multi-nozzle sync widget, AMS rack-nozzle mapping popup, calibration rework, send-dialog nozzle mapping and extruder-count UI. Includes the fix to persist the AMS sync badge on filament cards (H2C/A2L and direct-sync printers).
This commit is contained in:
SoftFever
2026-07-09 00:06:27 +08:00
parent a098b483f6
commit 28b7127150
52 changed files with 6136 additions and 250 deletions

View File

@@ -122,6 +122,8 @@
#include "NotificationManager.hpp"
#include "PresetComboBoxes.hpp"
#include "MsgDialog.hpp"
#include "Widgets/MultiNozzleSync.hpp" // NozzleOption, tryPopUpMultiNozzleDialog, setExtruderNozzleCount
#include "DeviceCore/DevNozzleSystem.h" // DevNozzle, GetExtNozzles / GetRackNozzles
#include "ProjectDirtyStateManager.hpp"
#include "Gizmos/GLGizmoSimplify.hpp" // create suggestion notification
#include "Gizmos/GLGizmoSVG.hpp" // Drop SVG file
@@ -584,7 +586,11 @@ struct Sidebar::priv
void jump_to_object(ObjectDataViewModelNode* item);
void can_search();
bool sync_extruder_list(bool &only_external_material);
bool sync_extruder_list(bool &only_external_material, bool is_manual = false);
// Resolve the nozzle option for a multi-nozzle machine. Returns nullopt (and is a no-op) unless
// extruder_count >= 2 && support_multi_nozzle. When is_manual, always pops the MultiNozzleSyncDialog;
// otherwise reuses the app_config-cached option when the machine's nozzle config is unchanged.
std::optional<NozzleOption> get_nozzle_options(MachineObject* obj, int extruder_count, bool support_multi_nozzle, bool is_manual);
bool switch_diameter(bool single);
void update_sync_status(const MachineObject* obj);
@@ -1323,7 +1329,288 @@ static bool is_skip_high_flow_printer(const std::string& printer)
return invalidate_list.count(printer);
};
bool Sidebar::priv::sync_extruder_list(bool &only_external_material)
// ---- Multi-nozzle sync helpers ----------------------------
// Serialize/deserialize the machine nozzle config + chosen NozzleOption for the app_config
// "sync_extruder" reuse cache, and Sidebar::priv::get_nozzle_options (the nozzle picker).
static std::string serialize_nozzle_config(const std::map<int, std::vector<DevNozzle>>& nozzle_cfg_map)
{
std::ostringstream oss;
std::vector<DevNozzle> deputy_nozzles;
auto deputy_it = nozzle_cfg_map.find(1);
if (deputy_it != nozzle_cfg_map.end()) {
deputy_nozzles = deputy_it->second;
}
std::vector<DevNozzle> main_nozzles;
auto main_it = nozzle_cfg_map.find(0);
if (main_it != nozzle_cfg_map.end()) {
main_nozzles = main_it->second;
}
for (size_t i = 0; i < deputy_nozzles.size(); ++i) {
if (i > 0) oss << ";";
oss << std::fixed << std::setprecision(1) << deputy_nozzles[i].GetNozzleDiameter() << ","
<< static_cast<int>(deputy_nozzles[i].GetNozzleFlowType());
}
oss << "|";
for (size_t i = 0; i < main_nozzles.size(); ++i) {
if (i > 0) oss << ";";
oss << std::fixed << std::setprecision(1) << main_nozzles[i].GetNozzleDiameter() << ","
<< static_cast<int>(main_nozzles[i].GetNozzleFlowType());
}
return oss.str();
}
static std::map<int, std::vector<DevNozzle>> deserialize_nozzle_config(const std::string &config_str)
{
std::map<int, std::vector<DevNozzle>> nozzle_cfg_map;
if (config_str.empty()) return nozzle_cfg_map;
std::vector<std::string> extruder_parts;
boost::split(extruder_parts, config_str, boost::is_any_of("|"));
auto get_nozzles_from_string = [](const std::string& part_str) -> std::vector<DevNozzle> {
std::vector<DevNozzle> nozzles;
std::vector<std::string> parts;
boost::split(parts, part_str, boost::is_any_of(";"));
for (const auto &part : parts) {
std::vector<std::string> values;
boost::split(values, part, boost::is_any_of(","));
if (values.size() == 2) {
DevNozzle nozzle;
nozzle.m_diameter = std::stof(values[0]);
nozzle.m_nozzle_flow = static_cast<NozzleFlowType>(std::stoi(values[1]));
nozzles.push_back(nozzle);
}
}
return nozzles;
};
if (extruder_parts.size() != 2) {
auto nozzles = get_nozzles_from_string(config_str);
nozzle_cfg_map[MAIN_EXTRUDER_ID] = nozzles;
nozzle_cfg_map[DEPUTY_EXTRUDER_ID] = { DevNozzle() };
return nozzle_cfg_map;
}
if (!extruder_parts[0].empty()) {
auto nozzles = get_nozzles_from_string(extruder_parts[0]);
nozzle_cfg_map[DEPUTY_EXTRUDER_ID] = nozzles;
}
if (!extruder_parts[1].empty()) {
auto nozzles = get_nozzles_from_string(extruder_parts[1]);
nozzle_cfg_map[MAIN_EXTRUDER_ID] = nozzles;
}
return nozzle_cfg_map;
}
static bool is_same_nozzle_config(const std::map<int, std::vector<DevNozzle>> &config1, const std::map<int, std::vector<DevNozzle>> &config2)
{
if (config1.size() != config2.size()) return false;
for (const auto& [eid, nozzles1] : config1) {
auto it = config2.find(eid);
if (it == config2.end()) return false;
const auto &nozzles2 = it->second;
if (nozzles1.size() != nozzles2.size()) return false;
auto sorted_nozzles1 = nozzles1;
auto sorted_nozzles2 = nozzles2;
auto compare_nozzle = [](const DevNozzle &a, const DevNozzle &b) {
float dia_a = a.GetNozzleDiameter();
float dia_b = b.GetNozzleDiameter();
if (std::abs(dia_a - dia_b) > EPSILON) {
return dia_a < dia_b;
}
return static_cast<int>(a.GetNozzleFlowType()) < static_cast<int>(b.GetNozzleFlowType());
};
std::sort(sorted_nozzles1.begin(), sorted_nozzles1.end(), compare_nozzle);
std::sort(sorted_nozzles2.begin(), sorted_nozzles2.end(), compare_nozzle);
for (size_t i = 0; i < sorted_nozzles1.size(); ++i) {
if (std::abs(sorted_nozzles1[i].GetNozzleDiameter() - sorted_nozzles2[i].GetNozzleDiameter()) > EPSILON ||
sorted_nozzles1[i].GetNozzleFlowType() != sorted_nozzles2[i].GetNozzleFlowType()) {
return false;
}
}
}
return true;
}
static std::string serialize_nozzle_option(const NozzleOption& option) {
std::ostringstream oss;
oss << option.diameter << "|";
bool first = true;
for (const auto& pair : option.extruder_nozzle_stats) {
if (!first) oss << ";";
first = false;
oss << pair.first << ":";
bool first_stat = true;
for (const auto& stat_pair : pair.second) {
if (!first_stat) oss << ",";
first_stat = false;
oss << static_cast<int>(stat_pair.first) << "#" << stat_pair.second;
}
}
return oss.str();
}
static std::optional<NozzleOption> deserialize_nozzle_option(const std::string& option_str) {
if (option_str.empty()) return std::nullopt;
std::vector<std::string> parts;
boost::split(parts, option_str, boost::is_any_of("|"));
if (parts.size() != 2) return std::nullopt;
NozzleOption option;
option.diameter = parts[0];
std::vector<std::string> extruder_parts;
boost::split(extruder_parts, parts[1], boost::is_any_of(";"));
for (const auto& extruder_part : extruder_parts) {
if (extruder_part.empty()) continue;
std::vector<std::string> extruder_data;
boost::split(extruder_data, extruder_part, boost::is_any_of(":"));
if (extruder_data.size() != 2) continue;
int extruder_id = std::stoi(extruder_data[0]);
std::unordered_map<NozzleVolumeType, int> stats;
std::vector<std::string> stat_parts;
boost::split(stat_parts, extruder_data[1], boost::is_any_of(","));
for (const auto& stat_part : stat_parts) {
std::vector<std::string> kv;
boost::split(kv, stat_part, boost::is_any_of("#"));
if (kv.size() == 2) {
NozzleVolumeType type = static_cast<NozzleVolumeType>(std::stoi(kv[0]));
int count = std::stoi(kv[1]);
stats[type] = count;
}
}
option.extruder_nozzle_stats[extruder_id] = stats;
}
return option;
}
std::optional<NozzleOption> Sidebar::priv::get_nozzle_options(MachineObject* obj, int extruder_count, bool support_multi_nozzle, bool is_manual)
{
if (extruder_count < 2 || !support_multi_nozzle) {
return std::nullopt;
}
if (!obj || !obj->GetNozzleSystem()) return std::nullopt;
auto nozzle_system = obj->GetNozzleSystem();
PresetBundle *preset_bundle = wxGetApp().preset_bundle;
if (!preset_bundle) return std::nullopt;
std::string curr_dev_id = obj->get_dev_id();
std::map<int, std::vector<DevNozzle>> curr_nozzle_cfg;
for (const auto& ext_nozzle : nozzle_system->GetExtNozzles()) {
int extruder_id = ext_nozzle.first;
curr_nozzle_cfg[extruder_id].emplace_back(ext_nozzle.second);
}
for (const auto& rack_nozzle : nozzle_system->GetRackNozzles()) {
curr_nozzle_cfg[MAIN_EXTRUDER_ID].emplace_back(rack_nozzle.second);
}
AppConfig *app_config = wxGetApp().app_config;
std::string saved_dev_id = app_config->get("sync_extruder", "dev_id");
std::string saved_nozzle_config_str = app_config->get("sync_extruder", "nozzle_config");
std::string saved_nozzle_option_str = app_config->get("sync_extruder", "nozzle_option");
std::optional<NozzleOption> nozzle_option;
if (is_manual) {
nozzle_option = tryPopUpMultiNozzleDialog(obj);
} else {
bool can_reuse_saved_option = false;
if (!saved_dev_id.empty() && !saved_nozzle_config_str.empty() && !saved_nozzle_option_str.empty() && saved_dev_id == curr_dev_id) {
auto saved_nozzle_config = deserialize_nozzle_config(saved_nozzle_config_str);
if (is_same_nozzle_config(saved_nozzle_config, curr_nozzle_cfg)) {
nozzle_option = deserialize_nozzle_option(saved_nozzle_option_str);
can_reuse_saved_option = nozzle_option.has_value();
}
if (can_reuse_saved_option) {
BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << " Reusing saved nozzle option for dev_id: " << curr_dev_id;
auto &project_config = preset_bundle->project_config;
ConfigOptionEnumsGeneric *nozzle_volume_type_opt = project_config.option<ConfigOptionEnumsGeneric>("nozzle_volume_type");
// Write to preset bundle config
for (int extruder_id = 0; extruder_id < extruder_count; ++extruder_id) {
NozzleVolumeType volume_type;
int nozzle_count;
bool clear_all = true;
if (!nozzle_option->extruder_nozzle_stats.count(extruder_id)) {
nozzle_count = 0;
// Reset the concrete volume types (Standard/High Flow); Hybrid stays a hidden match-any
// sentinel and is left alone. TPU High Flow is a concrete variant shipped on 0.4/0.6
// nozzles, so reset it too, but only there (mirrors the High-Flow-skip-for-0.2 guard).
std::vector<NozzleVolumeType> reset_types{nvtStandard, nvtHighFlow};
if (extruder_supports_tpu_high_flow(preset_bundle, extruder_id))
reset_types.push_back(nvtTPUHighFlow);
for (NozzleVolumeType vt : reset_types) {
volume_type = vt;
setExtruderNozzleCount(preset_bundle, extruder_id, volume_type, nozzle_count, clear_all);
clear_all = false;
}
} else {
for (auto &stat : nozzle_option->extruder_nozzle_stats[extruder_id]) {
volume_type = stat.first;
nozzle_count = stat.second;
setExtruderNozzleCount(preset_bundle, extruder_id, volume_type, nozzle_count, clear_all);
clear_all = false;
}
}
}
// Orca: nozzle stats are persisted directly into the preset's `extruder_nozzle_stats` key;
// there is no machine/user data-source flag on the stats.
}
}
if (!can_reuse_saved_option) {
nozzle_option = tryPopUpMultiNozzleDialog(obj);
}
}
if (nozzle_option) {
std::string current_nozzle_config = serialize_nozzle_config(curr_nozzle_cfg);
std::string current_nozzle_option = serialize_nozzle_option(*nozzle_option);
if (app_config->has_section("sync_extruder")) {
app_config->set("sync_extruder", "dev_id", curr_dev_id);
app_config->set("sync_extruder", "nozzle_config", current_nozzle_config);
app_config->set("sync_extruder", "nozzle_option", current_nozzle_option);
} else {
std::map<std::string, std::string> data;
data["dev_id"] = curr_dev_id;
data["nozzle_config"] = current_nozzle_config;
data["nozzle_option"] = current_nozzle_option;
app_config->set_section("sync_extruder", data);
}
}
return nozzle_option;
}
bool Sidebar::priv::sync_extruder_list(bool &only_external_material, bool is_manual)
{
MachineObject *obj = wxGetApp().getDeviceManager()->get_selected_machine();
auto printer_name = plater->get_selected_printer_name_in_combox();
@@ -1377,13 +1664,51 @@ bool Sidebar::priv::sync_extruder_list(bool &only_external_material)
}
assert(obj->GetExtderSystem()->GetTotalExtderCount() == extruder_nums);
// Multi-nozzle: resolve the nozzle option (pops MultiNozzleSyncDialog when is_manual). No-op for every
// existing printer — support_multi_nozzle is false unless some extruder_max_nozzle_count entry is a real
// value > 1 (nil-guarded, matching the manual/ToolOrdering gates), which no shipping single-nozzle or
// dual-extruder profile sets. When the machine is multi-nozzle but no option resolves (e.g. user cancelled),
// abort the sync.
const ConfigOptionIntsNullable *extruder_max_nozzle_count = cur_preset.config.option<ConfigOptionIntsNullable>("extruder_max_nozzle_count");
bool support_multi_nozzle = extruder_max_nozzle_count != nullptr &&
std::any_of(extruder_max_nozzle_count->values.begin(), extruder_max_nozzle_count->values.end(),
[](int val) { return val > 1 && val != ConfigOptionIntsNullable::nil_value(); });
auto nozzle_option = get_nozzle_options(obj, extruder_nums, support_multi_nozzle, is_manual);
if (!nozzle_option && support_multi_nozzle)
return false;
std::vector<float> nozzle_diameters;
nozzle_diameters.resize(extruder_nums);
std::vector<NozzleVolumeType> target_types(extruder_nums, NozzleVolumeType::nvtStandard);
for (size_t index = 0; index < extruder_nums; ++index) {
int extruder_id = extruder_map[index];
nozzle_diameters[extruder_id] = obj->GetExtderSystem()->GetNozzleDiameter(index);
nozzle_diameters[extruder_id] = nozzle_option ? atof(nozzle_option->diameter.c_str()) : obj->GetExtderSystem()->GetNozzleDiameter(index);
NozzleVolumeType target_type = NozzleVolumeType::nvtStandard;
auto printer_tab = dynamic_cast<TabPrinter *>(wxGetApp().get_tab(Preset::TYPE_PRINTER));
std::optional<NozzleVolumeType> select_type;
if (nozzle_option && nozzle_option->extruder_nozzle_stats.count(index)) {
const auto &stats = nozzle_option->extruder_nozzle_stats[index];
if (stats.size() > 1) {
// Orca: for a mixed extruder, keep Hybrid out of the (still-deferred) nozzle-centric slicing
// pipeline, so collapse to the dominant concrete volume type (tie -> Standard).
// TPU High Flow is shipped on 0.4/0.6 nozzles, so only fold it in there (mirrors the
// High-Flow-skip-for-0.2 guard). Uses the device-reported diameter, same predicate as the preset path.
int std_cnt = 0, hf_cnt = 0, tpu_hf_cnt = 0;
for (const auto &kv : stats) {
if (kv.first == nvtStandard) std_cnt = kv.second;
else if (kv.first == nvtHighFlow) hf_cnt = kv.second;
else if (kv.first == nvtTPUHighFlow) tpu_hf_cnt = kv.second;
}
if (!nozzle_diameter_supports_tpu_high_flow(nozzle_diameters[extruder_id]))
tpu_hf_cnt = 0;
select_type = nvtStandard;
if (hf_cnt > std_cnt && hf_cnt >= tpu_hf_cnt)
select_type = nvtHighFlow;
else if (tpu_hf_cnt > std_cnt && tpu_hf_cnt > hf_cnt)
select_type = nvtTPUHighFlow;
} else {
select_type = stats.begin()->first;
}
}
if (obj->is_nozzle_flow_type_supported()) {
if (obj->GetExtderSystem()->GetNozzleFlowType(index) == NozzleFlowType::NONE_FLOWTYPE) {
MessageDialog dlg(this->plater, _L("There are unset nozzle types. Please set the nozzle types of all extruders before synchronizing."),
@@ -1393,9 +1718,13 @@ bool Sidebar::priv::sync_extruder_list(bool &only_external_material)
}
// hack code, only use standard flow for 0.2
if (std::fabs(nozzle_diameters[extruder_id] - 0.2) > EPSILON)
target_type = NozzleVolumeType(obj->GetExtderSystem()->GetNozzleFlowType(extruder_id) - 1);
// Map device flow->volume via the table, not `flowtype - 1`.
// The arithmetic only aligns for S_FLOW/H_FLOW; U_FLOW(3)-1 would yield nvtHybrid(2), not nvtTPUHighFlow(3).
target_type = DevNozzle::ToNozzleVolumeType(obj->GetExtderSystem()->GetNozzleFlowType(extruder_id));
}
printer_tab->set_extruder_volume_type(index, target_type);
if (select_type)
target_type = *select_type;
target_types[index] = target_type;
}
int deputy_4 = 0, main_4 = 0, deputy_1 = 0, main_1 = 0;
@@ -1439,6 +1768,12 @@ bool Sidebar::priv::sync_extruder_list(bool &only_external_material)
is_switching_diameter = false;
}
// set nozzle volume type after switching prset, so this value can override the old value stored in conf
auto printer_tab = dynamic_cast<TabPrinter *>(wxGetApp().get_tab(Preset::TYPE_PRINTER));
for (size_t idx = 0; idx < target_types.size(); ++idx) {
printer_tab->set_extruder_volume_type(idx, target_types[idx]);
}
BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << __LINE__ << " finish sync_extruder_list";
return true;
}
@@ -3745,15 +4080,13 @@ void Sidebar::sync_ams_list(bool is_from_big_sync_btn)
{ // badge ams filament
clear_combos_filament_badge();
if (sync_result.direct_sync) {
auto& ams_list = wxGetApp().preset_bundle->filament_ams_list;
size_t tray_idx = 0;
for (auto& entry : ams_list) {
if (tray_idx >= p->combos_filament.size()) break;
auto filament_id = entry.second.opt_string("filament_id", 0u);
if (!filament_id.empty()) {
badge_combox_filament(p->combos_filament[tray_idx]);
}
tray_idx++;
// Orca: PresetBundle::sync_ams_list rebuilds combos_filament
// 1:1 from the AMS trays that produce a combo (loaded trays + placeholders; non-placeholder
// empty trays are skipped), so every resulting combo is AMS-sourced and gets a badge. The
// previous per-tray index walked the full filament_ams_list (including the skipped empties),
// so an empty slot before a loaded one dropped the badge for the trailing filaments.
for (auto &c : p->combos_filament) {
badge_combox_filament(c);
}
}
}
@@ -3948,7 +4281,8 @@ bool Sidebar::is_multifilament()
void Sidebar::deal_btn_sync() {
m_begin_sync_printer_status = true;
bool only_external_material;
auto ok = p->sync_extruder_list(only_external_material);
// Manual "sync machine" button: is_manual=true so an H2C pops the MultiNozzleSyncDialog to pick a nozzle option.
auto ok = p->sync_extruder_list(only_external_material, true);
if (ok) {
pop_sync_nozzle_and_ams_dialog();
}
@@ -5641,6 +5975,9 @@ std::map<std::string, std::string> Plater::get_bed_texture_maps()
if (pm->bottom_texture_rect.size() > 0) {
maps["bottom_texture_rect"] = pm->bottom_texture_rect;
}
if (pm->bottom_texture_rect_longer.size() > 0) {
maps["bottom_texture_rect_longer"] = pm->bottom_texture_rect_longer;
}
if (pm->middle_texture_rect.size() > 0) {
maps["middle_texture_rect"] = pm->middle_texture_rect;
}
@@ -11112,8 +11449,9 @@ bool Plater::priv::check_ams_status_impl(bool is_slice_all)
auto nozzle_volumes_values = preset_bundle->project_config.option<ConfigOptionEnumsGeneric>("nozzle_volume_type")->values;
assert(obj->GetExtderSystem()->GetTotalExtderCount() == 2 && nozzle_volumes_values.size() == 2);
if (obj->GetExtderSystem()->GetTotalExtderCount() == 2 && nozzle_volumes_values.size() == 2) {
NozzleVolumeType right_nozzle_type = NozzleVolumeType(obj->GetExtderSystem()->GetNozzleFlowType(0) - 1);
NozzleVolumeType left_nozzle_type = NozzleVolumeType(obj->GetExtderSystem()->GetNozzleFlowType(1) - 1);
// Map device flow->volume via the table, not `flowtype - 1` (which mis-maps U_FLOW to nvtHybrid).
NozzleVolumeType right_nozzle_type = DevNozzle::ToNozzleVolumeType(obj->GetExtderSystem()->GetNozzleFlowType(0));
NozzleVolumeType left_nozzle_type = DevNozzle::ToNozzleVolumeType(obj->GetExtderSystem()->GetNozzleFlowType(1));
NozzleVolumeType preset_left_type = NozzleVolumeType(nozzle_volumes_values[0]);
NozzleVolumeType preset_right_type = NozzleVolumeType(nozzle_volumes_values[1]);
is_same_as_printer = (left_nozzle_type == preset_left_type && right_nozzle_type == preset_right_type);
@@ -17317,6 +17655,9 @@ void Plater::suppress_background_process(const bool stop_background_process)
this->p->suppressed_backround_processing_update = true;
}
// Expose the slicing process to the device GUI.
BackgroundSlicingProcess& Plater::background_process() { return p->background_process; }
void Plater::center_selection() { p->center_selection(); }
void Plater::drop_selection() { p->drop_selection(); }
void Plater::mirror(Axis axis) { p->mirror(axis); }