fix: harden generic N-extruder device model and status/AMS UI, add N-toolhead ExtruderImage sizing, tests, and HLSD

This commit is contained in:
Ian Chua
2026-10-08 17:39:44 +08:00
parent 7515fb480f
commit e5e30ee0e8
9 changed files with 224 additions and 39 deletions
+10 -6
View File
@@ -144,10 +144,10 @@ The device-control widgets follow the same device-reported counts.
`N3F_AMS`, `N3S_AMS`. A plain `AMS` maps to `GENERIC_AMS`.
- On non-Bambu printers `use_generic_ams_layout()` routes `GENERIC_AMS` units through a variable-lane
rendering that draws one lane per reported tray; Bambu keeps its fixed four-slot rendering.
- `AMSControl` picks its layout by vendor: Bambu keeps the left/right presentation
(`CreateAmsSingleNozzle` for one toolhead, `CreateAmsDoubleNozzle` for two), while non-Bambu
printers use `CreateAmsMultiNozzle`, which builds one preview and one AMS simplebook per toolhead
for any count.
- `AMSControl` picks its layout by toolhead count, not vendor: `CreateAmsSingleNozzle` for one
toolhead, `CreateAmsDoubleNozzle` for two, and `CreateAmsMultiNozzle` (one preview and one AMS
simplebook per toolhead) for three or more. A two-toolhead generic printer therefore keeps the
legacy left/right presentation; that is deliberate.
- The status panel's `ExtruderImage` renders one per-toolhead state per nozzle and is resized to
`GetTotalExtderCount()`. Non-Bambu printers choose the active toolhead through
`m_generic_nozzle_selector` (shown when more than one toolhead exists); Bambu keeps its left/right
@@ -162,10 +162,14 @@ The device-control widgets follow the same device-reported counts.
- **Extruder dialect invariants.** `device.extruder.state`'s count field must equal the length of
`info[]`, and `info[]` must be ordered by extruder id with `id` equal to the array index: the
parser places toolheads by array position while `GetExtderById()` indexes by id.
- **One filament road per toolhead.** Each toolhead renders a single AMS filament road rather than an
independent per-toolhead road state. A generic scaffold that allowed the latter was removed as
unused; the one-extruder-per-toolhead road is the intended model.
- **Preset/device agreement.** The generic layout and virtual-slot range derive their count from the
selected printer preset. A printer preset must therefore declare the same number of
`nozzle_diameter` entries as the device has toolheads; a mismatch is surfaced because the sidebar
and extruder logic would otherwise disagree.
`nozzle_diameter` entries as the device has toolheads. A mismatch is surfaced only as a log warning
(`StatusPanel::check_extruder_count_mismatch()`), deliberately log-only, because the sidebar and
extruder logic would otherwise silently disagree.
- **Bambu compatibility.** Bambu-specific presentation (left/right pair, fixed 4-slot units, fixed
virtual ids, physical/logical inversion) is preserved behind the vendor check. Generic paths do
not alter it.
+17 -5
View File
@@ -37,6 +37,9 @@ namespace Slic3r {
bool devPrinterUtil::IsVirtualSlot(int ams_id)
{
// When the app or preset bundle is unavailable we cannot know the vendor's
// extruder count, so fall back deterministically to the fixed Bambu pair
// {255, 254} rather than guessing from an unknown generic layout.
if (wxTheApp == nullptr || GUI::wxGetApp().preset_bundle == nullptr || GUI::wxGetApp().preset_bundle->is_bbl_vendor())
return ams_id == VIRTUAL_TRAY_MAIN_ID || ams_id == VIRTUAL_TRAY_DEPUTY_ID;
@@ -46,6 +49,14 @@ bool devPrinterUtil::IsVirtualSlot(int ams_id)
bool devPrinterUtil::IsVirtualSlot(const std::string& ams_id)
{
// Strict: only a fully numeric string is a valid slot id. std::stoi alone
// would accept trailing garbage (e.g. "255abc" -> 255).
if (ams_id.empty())
return false;
for (char c : ams_id) {
if (c < '0' || c > '9')
return false;
}
try {
return IsVirtualSlot(std::stoi(ams_id));
} catch (...) {
@@ -220,6 +231,11 @@ wxString DevAms::GetDisplayName() const
int DevAms::GetSlotCount() const
{
// N3S is a 1-slot Bambu contract, so report 1 regardless of whether the
// preset bundle is momentarily unavailable (m_trays.size() can be 0).
if (GetAmsType() == N3S)
return 1;
if (wxTheApp != nullptr && GUI::wxGetApp().preset_bundle != nullptr &&
GUI::wxGetApp().preset_bundle->is_bbl_vendor()) {
// GetAmsType() maps AMS_LITE_MIXED -> AMS_LITE, so N9 reports 4 slots like AMS-Lite.
@@ -228,10 +244,6 @@ int DevAms::GetSlotCount() const
{
return 4;
}
else if (ams_type == N3S)
{
return 1;
}
}
return static_cast<int>(m_trays.size());
@@ -1057,7 +1069,7 @@ void DevFilaSystemParser::ParseAgentFilament(const json& data, MachineObject* ob
if (!e.is_object())
continue;
const int ext = e.value("extruder", MAIN_EXTRUDER_ID);
if (ext < MAIN_EXTRUDER_ID || ext > VIRTUAL_TRAY_MAIN_ID)
if (ext < MAIN_EXTRUDER_ID || ext >= VIRTUAL_TRAY_MAIN_ID)
continue;
const int vt_id = VIRTUAL_TRAY_MAIN_ID - ext;
+10 -9
View File
@@ -808,8 +808,12 @@ bool MachineObject::is_extrusion_cali_finished()
DevAmsTray *MachineObject::get_curr_tray()
{
const std::string& cur_ams_id = m_extder_system->GetCurrentAmsId();
if (cur_ams_id.compare(std::to_string(VIRTUAL_TRAY_MAIN_ID)) == 0) {
return &vt_slot[0];
if (devPrinterUtil::IsVirtualSlot(cur_ams_id)) {
for (auto& tray : vt_slot) {
if (tray.id == cur_ams_id)
return &tray;
}
return nullptr;
}
DevAms* curr_ams = get_curr_Ams();
@@ -2634,13 +2638,10 @@ void MachineObject::reset()
json empty_j;
print_json.diff2all_base_reset(empty_j);
for (auto i = 0; i < vt_slot.size(); i++) {
vt_slot[i].reset();
if (i == 1) {
vt_slot.erase(vt_slot.begin() + 1);
}
}
for (auto& tray : vt_slot)
tray.reset();
if (vt_slot.size() > 1)
vt_slot.erase(vt_slot.begin() + 1, vt_slot.end());
// why: reset reuses MachineObject, so release its lazy subtask
// before dropping the pointer to prevent reconnect leaks.
if (subtask_) {
+90 -5
View File
@@ -226,6 +226,8 @@ ExtruderImage::ExtruderImage(wxWindow* parent, wxWindowID id, int nozzle_num, co
wxWindow::Create(parent, id, pos, wxSize(FromDIP(45), FromDIP(112)));
SetBackgroundColour(*wxWHITE);
m_nozzle_num = nozzle_num;
// Legacy Bambu single/dual page size. The generic (non-Bambu) grid grows
// past this when it holds more than two toolheads; see updateGenericSize().
SetSize(wxSize(FromDIP(45), FromDIP(112)));
SetMinSize(wxSize(FromDIP(45), FromDIP(112)));
SetMaxSize(wxSize(FromDIP(45), FromDIP(112)));
@@ -298,6 +300,7 @@ void ExtruderImage::setExtruderCount(int nozzle_num)
m_nozzle_num = normalized_count;
m_multi_extruder_states.resize(static_cast<size_t>(m_nozzle_num), ExtruderState::EMPTY_LOAD);
updateGenericSize();
Refresh();
}
@@ -307,9 +310,37 @@ void ExtruderImage::setGenericNozzleDisplay(bool enabled)
return;
m_generic_nozzle_display = enabled;
updateGenericSize();
Refresh();
}
void ExtruderImage::updateGenericSize()
{
// The generic (non-Bambu) grid must stay legible for any toolhead count, so
// give it whole natural-size icons (24x62 DIP) and wrap into extra rows
// instead of squeezing everything into the fixed 45x112 Bambu page. doRender()
// balances the columns/rows inside the area we hand it, so sizing the page to
// a grid of natural-size cells keeps every icon at its full size.
// Bambu <=2 toolheads and generic <=2 toolheads keep the legacy page size.
wxSize size(FromDIP(45), FromDIP(112));
if (m_generic_nozzle_display && m_nozzle_num > 2) {
const wxSize icon = m_left_extruder_active_filled->GetBmpSize();
if (icon.x > 0 && icon.y > 0) {
// Fit as many columns as the fixed-width extruder panel (143 DIP) allows.
const int columns = std::max(1, std::min(m_nozzle_num, FromDIP(143) / icon.x));
const int rows = (m_nozzle_num + columns - 1) / columns;
size = wxSize(columns * icon.x, rows * icon.y);
}
}
if (GetMinSize() == size && GetMaxSize() == size)
return;
SetMinSize(size);
SetMaxSize(size);
SetSize(size);
}
void ExtruderImage::setExtruderUsed(std::string loc)
{
//current_nozzle_idx = nozzle_id;
@@ -352,6 +383,7 @@ void ExtruderImage::update(ExtruderState state, int idx) {
return;
m_multi_extruder_states[idx] = state;
Refresh();
}
void ExtruderImage::paintEvent(wxPaintEvent& evt)
@@ -1954,6 +1986,8 @@ void StatusBasePanel::ensure_nozzle_temp_controls(size_t count)
if (!m_temp_nozzle_parent || !m_temp_nozzle_sizer)
return;
// Widgets beyond the current toolhead count are intentionally retained
// (hidden, never destroyed) so a later count increase reuses them.
bool changed = count != m_temp_nozzle_active_count;
while (m_tempCtrl_nozzles.size() < count) {
TempInput* temp_ctrl = create_nozzle_temp_control(m_temp_nozzle_parent, m_nozzle_temp_control_id);
@@ -3715,6 +3749,9 @@ void StatusPanel::update_misc_ctrl(MachineObject *obj)
ExtruderImage* image = m_extruderImage.front();
image->setGenericNozzleDisplay(true);
image->setExtruderCount(m_nozzle_num);
// Re-apply every update so a DPI change (which the setters above skip on
// steady state) still resizes the grid.
image->updateGenericSize();
const int current_nozzle_id = extder_system->GetCurrentExtderId();
const int selected_nozzle_id = current_nozzle_id >= 0 && current_nozzle_id < m_nozzle_num ? current_nozzle_id : -1;
@@ -3740,7 +3777,31 @@ void StatusPanel::update_misc_ctrl(MachineObject *obj)
m_generic_nozzle_selector->Enable();
}
if (bbl_selector_was_shown != m_nozzle_btn_panel->IsShown() ||
// The generic >2 toolhead page is larger than the legacy 45x112 Bambu page,
// so drive the book's size from the page that is shown. The book is only
// given an explicit size while a >2 generic grid is shown (or to clear a
// previous one); a plain Bambu session leaves the book's natural 45x112
// best size untouched.
bool book_size_changed = false;
{
const bool generic_grid = !is_bbl_vendor && m_nozzle_num > 2;
const bool book_has_explicit_size =
m_extruder_book->GetMinSize() != wxDefaultSize ||
m_extruder_book->GetMaxSize() != wxDefaultSize;
if (generic_grid || book_has_explicit_size) {
const wxSize legacy_size(FromDIP(45), FromDIP(112));
const wxSize wanted_size = generic_grid ? m_extruderImage.front()->GetMinSize() : legacy_size;
if (m_extruder_book->GetMinSize() != wanted_size ||
m_extruder_book->GetMaxSize() != wanted_size) {
m_extruder_book->SetMinSize(wanted_size);
m_extruder_book->SetMaxSize(wanted_size);
book_size_changed = true;
}
}
}
if (book_size_changed ||
bbl_selector_was_shown != m_nozzle_btn_panel->IsShown() ||
generic_selector_was_shown != m_generic_nozzle_selector->IsShown())
m_nozzle_btn_panel->GetParent()->Layout();
@@ -3870,6 +3931,16 @@ void StatusPanel::update_ams(MachineObject *obj)
AMSinfo info;
info.ams_id = ams->first;
if (ams->second->IsExist() && info.parse_ams_info(obj, ams->second, obj->GetFilaSystem()->IsDetectRemainEnabled(), obj->is_support_ams_humidity)) {
// A non-Bambu unit reported as AMS_LITE but not matching the fixed
// four-lane AMS_LITE grid (e.g. a generic unit advertising a
// different slot count) has no fixed layout to render into and would
// show an empty card. Treat it as GENERIC_AMS so it uses the same
// variable-lane rendering. Bambu's fixed rendering is untouched.
if (info.ams_type == AMSModel::AMS_LITE &&
info.cans.size() != GENERIC_AMS_SLOT_NUM &&
(!wxGetApp().preset_bundle || !wxGetApp().preset_bundle->is_bbl_vendor())) {
info.ams_type = AMSModel::GENERIC_AMS;
}
ams_info.push_back(info);
}
}
@@ -3889,6 +3960,7 @@ void StatusPanel::update_ams(MachineObject *obj)
const bool show_generic_nozzle_switch =
obj->GetExtderSystem()->GetTotalExtderCount() > 2 &&
!wxGetApp().preset_bundle->is_bbl_vendor();
const bool ams_nozzle_switch_was_shown = m_ams_nozzle_switch->IsShown();
if (show_generic_nozzle_switch) {
std::vector<wxString> nozzle_options;
const int nozzle_count = obj->GetExtderSystem()->GetTotalExtderCount();
@@ -3903,7 +3975,8 @@ void StatusPanel::update_ams(MachineObject *obj)
} else {
m_ams_nozzle_switch->Hide();
}
m_ams_nozzle_switch->GetParent()->Layout();
if (ams_nozzle_switch_was_shown != m_ams_nozzle_switch->IsShown())
m_ams_nozzle_switch->GetParent()->Layout();
m_ams_control->UpdateAmsDryControl(obj);
last_tray_exist_bits = obj->tray_exist_bits;
@@ -5607,6 +5680,12 @@ void StatusPanel::on_nozzle_selected(wxCommandEvent &event)
m_generic_nozzle_selector->Disable();
if (obj->GetCtrl()->command_select_extruder(nozzle_id) == 0)
{
if (!is_bbl_vendor) {
// The command did not start; restore the previous selection and
// re-enable the selector so the user is not stuck.
m_generic_nozzle_selector->SetSelection(current_selection);
m_generic_nozzle_selector->Enable();
}
return;
}
}
@@ -5735,11 +5814,16 @@ void StatusPanel::set_default()
m_switch_cham_fan_timeout = 0;
m_show_ams_group = false;
m_show_filament_group = false;
m_generic_nozzle_selector->Hide();
if (m_generic_nozzle_selector)
m_generic_nozzle_selector->Hide();
m_nozzle_btn_panel->Hide();
m_generic_nozzle_selector->Clear();
if (m_generic_nozzle_selector)
m_generic_nozzle_selector->Clear();
m_generic_nozzle_selector_count = 0;
m_extruder_book->SetSelection(0);
// Clear any generic >2 toolhead grid growth from a previous device.
m_extruder_book->SetMinSize(wxSize(FromDIP(45), FromDIP(112)));
m_extruder_book->SetMaxSize(wxSize(FromDIP(45), FromDIP(112)));
m_nozzle_btn_panel->GetParent()->Layout();
reset_printing_values();
@@ -5765,7 +5849,8 @@ void StatusPanel::set_default()
m_ams_control->Reset();
m_ams_rack_switch->updateState("left");
m_ams_rack_switch->Hide();
m_ams_nozzle_switch->Hide();
if (m_ams_nozzle_switch)
m_ams_nozzle_switch->Hide();
m_panel_nozzle_rack->Hide();
m_scale_panel->Hide();
m_filament_load_box->Hide();
+1
View File
@@ -168,6 +168,7 @@ public:
void update(ExtruderState state, int idx);
void msw_rescale();
void updateGenericSize();
void setExtruderCount(int nozzle_num);
void setGenericNozzleDisplay(bool enabled);
void setExtruderUsed(std::string loc);
+16 -7
View File
@@ -638,6 +638,7 @@ void AMSControl::ClearAms() {
for (auto &pane : m_nozzle_panes) {
if (pane.preview_panel) pane.preview_panel->Destroy();
if (pane.ams_book) pane.ams_book->Destroy();
if (pane.page) pane.page->Destroy();
}
}
m_item_ids.assign(std::max(m_total_ext_count, 2), {});
@@ -1329,17 +1330,25 @@ void AMSControl::createAms(wxSimplebook* parent, int& idx, AMSinfo info, AMSPane
// Above eight lanes, AmsItem is a fixed-minimum viewport over a wider
// lane strip. Expand the viewport through its page's sizers rather
// than propagating the strip's virtual width into the card/book.
// Reset back to the centered fixed layout for eight or fewer lanes so a
// later small unit is not left in expand mode.
const bool generic_lane_layout = !wxGetApp().preset_bundle || !wxGetApp().preset_bundle->is_bbl_vendor();
if (generic_lane_layout && info.cans.size() > 8) {
if (generic_lane_layout) {
if (auto *page = parent->GetParent()) {
auto pane = std::find_if(m_nozzle_panes.begin(), m_nozzle_panes.end(),
[page](const NozzleAmsPane &candidate) { return candidate.page == page; });
if (pane != m_nozzle_panes.end()) {
pane->ams_area->GetItem(pane->ams_book)->SetProportion(1);
pane->ams_area->GetItem(pane->ams_book)->SetFlag(wxEXPAND);
pane->page_sizer->GetItem(pane->ams_area)->SetFlag(wxEXPAND | wxTOP);
m_sizer_ams_items->GetItem(m_nozzle_book)->SetFlag(wxEXPAND);
m_sizer_body->GetItem(m_sizer_ams_items)->SetFlag(wxEXPAND);
if (pane != m_nozzle_panes.end() && m_nozzle_book) {
const bool expand_viewport = info.cans.size() > 8;
if (auto *book_item = pane->ams_area->GetItem(pane->ams_book)) {
book_item->SetProportion(expand_viewport ? 1 : 0);
book_item->SetFlag(expand_viewport ? wxEXPAND : wxALIGN_CENTER);
}
if (auto *area_item = pane->page_sizer->GetItem(pane->ams_area))
area_item->SetFlag(expand_viewport ? (wxEXPAND | wxTOP) : (wxALIGN_CENTER | wxTOP));
if (auto *nozzle_book_item = m_sizer_ams_items->GetItem(m_nozzle_book))
nozzle_book_item->SetFlag(expand_viewport ? wxEXPAND : wxALIGN_CENTER);
if (auto *items_item = m_sizer_body->GetItem(m_sizer_ams_items))
items_item->SetFlag(expand_viewport ? wxEXPAND : wxALIGN_CENTER);
m_amswin->Layout();
}
}
+1 -1
View File
@@ -877,7 +877,7 @@ void MultiNozzleStatusTable::UpdateRackInfo(std::weak_ptr<DevNozzleRack> rack)
auto& nozzle = elem.second;
int extruder_id{};
if (wxGetApp().preset_bundle->is_bbl_vendor()) {
if (wxGetApp().preset_bundle && wxGetApp().preset_bundle->is_bbl_vendor()) {
extruder_id = nozzle.AtLeftExtruder() ? 0 : 1;
if (nozzle.AtRightExtruder())
has_right = true;
+74 -2
View File
@@ -3,7 +3,6 @@
// makes std::byte a competing candidate (otherwise the Windows COM headers pulled in via
// DeviceManager.hpp error with an ambiguous `byte`). wx/timer.h must precede DeviceManager.hpp,
// which includes DeviceErrorDialog.hpp (uses wxTimerEvent) before its own wx/timer.h include.
#include "catch2/catch_message.hpp"
#include <string>
#include <utility>
#include <vector>
@@ -17,7 +16,6 @@
#include <Windows.h>
#endif
#include <catch2/catch_test_macros.hpp>
#include <catch2/catch_all.hpp>
#include <wx/timer.h>
@@ -84,3 +82,77 @@ TEST_CASE("Extruder state decodes a generic toolhead set", "[DevExtderSystem]")
CHECK(ext->GetNozzleId() == id);
}
}
TEST_CASE("Legacy single-toolhead parser fills only the main extruder", "[DevExtderSystem]")
{
MachineObject obj(nullptr, nullptr, "test", "test_dev", "127.0.0.1");
REQUIRE(obj.GetExtderSystem()->GetTotalExtderCount() == 1);
// ParseV1_0 is the legacy single/dual fallback. It returns unless the system reports exactly
// one toolhead, so it only ever writes MAIN_EXTRUDER_ID's fields.
const json legacy = {
{"nozzle_temper", 210},
{"nozzle_target_temper", 245},
{"ams", {{"tray_now", "2"}, {"tray_tar", "3"}}}
};
ExtderSystemParser::ParseV1_0(legacy, obj.GetExtderSystem());
const auto ext = obj.GetExtderSystem()->GetExtderById(MAIN_EXTRUDER_ID);
REQUIRE(ext.has_value());
CHECK(ext->GetCurrentTemp() == 210);
CHECK(ext->GetTargetTemp() == 245);
// Below 0x80 the tray pointer encodes ams_id = value / 4, slot_id = value % 4.
CHECK(ext->GetSlotNow().ams_id == "0");
CHECK(ext->GetSlotNow().slot_id == "2");
CHECK(ext->GetSlotTarget().ams_id == "0");
CHECK(ext->GetSlotTarget().slot_id == "3");
CHECK(obj.GetExtderSystem()->IsBusyLoading());
CHECK(obj.GetExtderSystem()->GetLoadingExtderId() == MAIN_EXTRUDER_ID);
}
// ParseV2_0 does not enforce the documented extruder dialect invariants: the count in `state` and
// the id ordering of `info[]` are trusted, not checked. This test pins the resulting behavior.
TEST_CASE("Malformed extruder payload is stored by info array position", "[DevExtderSystem]")
{
const auto extder_info = [](int id) {
// Every key ParseV2_0 reads must be present.
return json{ {"id", id}, {"filam_bak", json::array()}, {"info", 0}, {"temp", 0},
{"spre", 0}, {"snow", 0}, {"star", 0}, {"stat", 0}, {"hnow", 0} };
};
SECTION("state count disagrees with info length") {
MachineObject obj(nullptr, nullptr, "test", "test_dev", "127.0.0.1");
// `state` reports 2 toolheads but info[] carries 3.
json info = json::array();
for (int id = 0; id < 3; ++id)
info.push_back(extder_info(id));
ExtderSystemParser::ParseV2_0(json{ {"state", 2}, {"info", info} }, obj.GetExtderSystem());
// The info array wins: three toolheads are stored. m_total_extder_count still holds 2, so
// GetTotalExtderCount() would trip its debug assert; only the stored vector is inspected.
CHECK(obj.GetExtderSystem()->GetTotalExtderSize() == 3);
for (int id = 0; id < 3; ++id) {
const auto ext = obj.GetExtderSystem()->GetExtderById(id);
REQUIRE(ext.has_value());
CHECK(ext->GetExtId() == id);
}
}
SECTION("info id differs from the array index") {
MachineObject obj(nullptr, nullptr, "test", "test_dev", "127.0.0.1");
// ids 1 and 2 sit at array positions 0 and 1.
json info = json::array({extder_info(1), extder_info(2)});
ExtderSystemParser::ParseV2_0(json{ {"state", 2}, {"info", info} }, obj.GetExtderSystem());
// Toolheads are stored by array position; the payload id is kept but is not the lookup key.
const auto first = obj.GetExtderSystem()->GetExtderById(0);
REQUIRE(first.has_value());
CHECK(first->GetExtId() == 1);
const auto second = obj.GetExtderSystem()->GetExtderById(1);
REQUIRE(second.has_value());
CHECK(second->GetExtId() == 2);
}
}
+5 -4
View File
@@ -3,8 +3,6 @@
// makes std::byte a competing candidate (otherwise the Windows COM headers pulled in via
// DeviceManager.hpp error with an ambiguous `byte`). wx/timer.h must precede DeviceManager.hpp,
// which includes DeviceErrorDialog.hpp (uses wxTimerEvent) before its own wx/timer.h include.
#include <string>
#include <utility>
#ifdef WIN32
#ifndef WIN32_LEAN_AND_MEAN
#define WIN32_LEAN_AND_MEAN
@@ -15,11 +13,14 @@
#include <Windows.h>
#endif
#include <set>
#include <string>
#include <utility>
#include <vector>
#include <catch2/catch_test_macros.hpp>
#include "slic3r/GUI/DeviceCore/DevDefs.h"
#include "libslic3r/ProjectTask.hpp"
#include <vector>
#include <set>
#include <catch2/catch_message.hpp>
#include <catch2/catch_all.hpp>