From 9e90f7f3051128009fc377cb431c04a613dc662d Mon Sep 17 00:00:00 2001 From: zhangzhendong Date: Sun, 24 May 2026 15:09:33 +0800 Subject: [PATCH] fix: mixed filament dependency resolution priority and delete/merge remap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - manual_pattern now participates in dependency resolution with priority: manual_pattern → gradient → pair - Add errno-based overflow detection for strtoul calls - Add bounds checking for symbolic tokens "1"/"2" in physical_filament_from_token - remove_physical_filament now detects and removes entries that depend on the deleted physical filament via manual_pattern tokens - Deleted mixed filament virtual IDs are correctly remapped to 0 (NONE), preventing paint from being reassigned to a surviving mixed filament - build_filament_id_remap accepts deleted_mixed_idx parameter; merge path correctly handles dependent mixed entries via remap injection - UI: properly show/hide delete button and enable/disable add button when physical filament count changes - Add 50+ regression tests covering serialization, resolve, delete, merge, gradient, bracket notation, and group boundary scenarios --- src/libslic3r/MixedFilament.cpp | 163 +++- src/libslic3r/PresetBundle.cpp | 69 +- src/libslic3r/PresetBundle.hpp | 6 +- src/slic3r/GUI/Plater.cpp | 96 +- tests/libslic3r/test_mixed_filament.cpp | 1146 +++++++++++++++++++++++ 5 files changed, 1416 insertions(+), 64 deletions(-) diff --git a/src/libslic3r/MixedFilament.cpp b/src/libslic3r/MixedFilament.cpp index a0a556099a8..aec1527febb 100644 --- a/src/libslic3r/MixedFilament.cpp +++ b/src/libslic3r/MixedFilament.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #include #include @@ -693,14 +694,19 @@ std::vector MixedFilamentManager::split_pattern_group_to_tokens(con unsigned int MixedFilamentManager::physical_filament_from_token(const std::string &token, const MixedFilament &mf, size_t num_physical) { + // Cycle-mode invariant: component_a≡1, component_b≡2 always + // (enforced by UI and MixedFilamentDialog::MODE_CYCLE). + // Under this invariant the symbolic tokens "1"/"2" are identity + // mappings — no ambiguity with direct physical IDs 1 and 2. if (token == "1") - return mf.component_a; + return (mf.component_a >= 1 && mf.component_a <= num_physical) ? mf.component_a : 0; if (token == "2") - return mf.component_b; + return (mf.component_b >= 1 && mf.component_b <= num_physical) ? mf.component_b : 0; char *end = nullptr; + errno = 0; unsigned long id = std::strtoul(token.c_str(), &end, 10); - if (end && *end == '\0' && id >= 1 && id <= num_physical) + if (errno != ERANGE && *end == '\0' && id >= 1 && id <= num_physical) return unsigned(id); return 0; @@ -1005,8 +1011,9 @@ static unsigned int decode_manual_pattern_preview_token(const std::string &token return (component_b >= 1 && component_b <= num_physical) ? component_b : 0; char *end = nullptr; + errno = 0; unsigned long id = std::strtoul(token.c_str(), &end, 10); - if (end && *end == '\0' && id >= 1 && id <= num_physical) + if (errno != ERANGE && *end == '\0' && id >= 1 && id <= num_physical) return unsigned(id); return 0; @@ -1702,33 +1709,118 @@ void MixedFilamentManager::remove_physical_filament(unsigned int deleted_filamen if (deleted_filament_id == 0 || m_mixed.empty()) return; + // Check and adjust filaments following resolve() order: + // 1. manual_pattern (cycle mode tokens) + // 2. gradient_component_ids + // 3. component_a / component_b (pair) + std::vector filtered; filtered.reserve(m_mixed.size()); for (MixedFilament mf : m_mixed) { - // Check pair components - if (mf.component_a == deleted_filament_id || mf.component_b == deleted_filament_id) + + // ---- 1. manual_pattern ---- + bool uses_deleted_in_pattern = false; + const std::string norm = normalize_manual_pattern(mf.manual_pattern); + if (!norm.empty()) { + const auto groups = split_pattern_groups(norm); + for (const std::string &group : groups) { + const auto tokens = tokenize_pattern_group(group); + for (const std::string &token : tokens) { + if (physical_filament_from_token(token, mf, kMaxPhysicalFilaments) == deleted_filament_id) { + uses_deleted_in_pattern = true; + break; + } + } + if (uses_deleted_in_pattern) break; + } + } + if (uses_deleted_in_pattern) continue; - // Check gradient components - bool uses_deleted_in_gradient = false; - for (unsigned int comp_id : decode_gradient_component_ids(mf.gradient_component_ids, 0)) { - if (comp_id == deleted_filament_id) { - uses_deleted_in_gradient = true; - break; + // ---- 2. gradient components ---- + // Only check when there is no manual_pattern; a pattern already resolves + // every token, so the gradient check would be a false positive at worst. + if (norm.empty()) { + bool uses_deleted_in_gradient = false; + for (unsigned int comp_id : decode_gradient_component_ids(mf.gradient_component_ids, 0)) { + if (comp_id == deleted_filament_id) { + uses_deleted_in_gradient = true; + break; + } } + if (uses_deleted_in_gradient) + continue; } - if (uses_deleted_in_gradient) + + // ---- 3. pair components ---- + // Only check when there is no manual_pattern; a pattern already resolves + // every token through physical_filament_from_token (symbolic "1"/"2" or + // literal numeric), so the pair check would be redundant at best and a + // false positive at worst (component_a/b may hold unrelated default values). + if (norm.empty() && (mf.component_a == deleted_filament_id || mf.component_b == deleted_filament_id)) continue; - // Adjust component IDs for remaining filaments - if (mf.component_a > deleted_filament_id) - --mf.component_a; - if (mf.component_b > deleted_filament_id) - --mf.component_b; + // ---- Adjust IDs for the surviving mixed filament ---- + + // Adjust manual_pattern + if (!norm.empty()) { + const auto groups = split_pattern_groups(norm); + std::string adjusted; + for (size_t gi = 0; gi < groups.size(); ++gi) { + if (gi > 0) adjusted += ','; + const auto tokens = tokenize_pattern_group(groups[gi]); + for (const std::string &token : tokens) { + // All tokens are treated as literal physical-filament IDs + // during adjustment. In cycle mode (component_a≡1, component_b≡2) + // the "1"/"2" identity mapping means decrementing them produces + // the correct result; for non-cycle patterns without "1"/"2", + // component_a/b are irrelevant (pair adjustment is guarded by + // norm.empty()). + char *end = nullptr; + errno = 0; + unsigned long id = std::strtoul(token.c_str(), &end, 10); + if (errno != ERANGE && *end == '\0' && id > deleted_filament_id) { + --id; + if (id >= 10) { + adjusted += '['; + adjusted += std::to_string(id); + adjusted += ']'; + } else { + adjusted += std::to_string(id); + } + } else { + if (token.size() > 1) { + adjusted += '['; + adjusted += token; + adjusted += ']'; + } else { + adjusted += token; + } + } + } + } + mf.manual_pattern = adjusted; + } + + // Adjust pair components (only when no pattern — same rationale as Step 3) + if (norm.empty()) { + if (mf.component_a > deleted_filament_id) + --mf.component_a; + if (mf.component_b > deleted_filament_id) + --mf.component_b; + } // Adjust gradient component IDs { auto decoded = decode_gradient_component_ids(mf.gradient_component_ids, 0); + if (!norm.empty()) { + // When manual_pattern is the active resolution source the + // gradient deletion check was skipped — remove stale IDs + // that reference the now-deleted physical filament. + decoded.erase( + std::remove(decoded.begin(), decoded.end(), deleted_filament_id), + decoded.end()); + } for (unsigned int &id : decoded) if (id > deleted_filament_id) --id; @@ -1825,6 +1917,9 @@ std::string MixedFilamentManager::normalize_manual_pattern(const std::string &pa if (num_str == "0") return {}; + // Compressing [1]→1 and [2]→2 is safe under the cycle-mode + // invariant (component_a≡1, component_b≡2) — the symbolic + // tokens are identity mappings, so no information is lost. if (num_str.size() == 1) { normalized.push_back(num_str[0]); } else { @@ -2381,14 +2476,25 @@ std::vector MixedFilamentManager::mixed_filaments_using_physical(unsigne if (mf.deleted || !mf.enabled) continue; bool depends_on_physical = false; - - // Check pair components - if (mf.component_a == physical_filament_1based || mf.component_b == physical_filament_1based) { - depends_on_physical = true; + + // Check manual_pattern (cycle mode tokens — resolve order #1) + const std::string norm = normalize_manual_pattern(mf.manual_pattern); + if (!norm.empty()) { + const auto groups = split_pattern_groups(norm); + for (const std::string &group : groups) { + const auto tokens = tokenize_pattern_group(group); + for (const std::string &token : tokens) { + if (physical_filament_from_token(token, mf, kMaxPhysicalFilaments) == physical_filament_1based) { + depends_on_physical = true; + break; + } + } + if (depends_on_physical) break; + } } - - // Check gradient components - if (!depends_on_physical) { + + // Check gradient components (resolve order #2) + if (!depends_on_physical && norm.empty()) { for (unsigned int comp_id : decode_gradient_component_ids(mf.gradient_component_ids, 0)) { if (comp_id == physical_filament_1based) { depends_on_physical = true; @@ -2396,6 +2502,13 @@ std::vector MixedFilamentManager::mixed_filaments_using_physical(unsigne } } } + + // Check pair components (resolve order #3) + if (!depends_on_physical && norm.empty()) { + if (mf.component_a == physical_filament_1based || mf.component_b == physical_filament_1based) { + depends_on_physical = true; + } + } if (depends_on_physical) { result.push_back(j); diff --git a/src/libslic3r/PresetBundle.cpp b/src/libslic3r/PresetBundle.cpp index b88aefce56b..b7a07927a6d 100644 --- a/src/libslic3r/PresetBundle.cpp +++ b/src/libslic3r/PresetBundle.cpp @@ -3611,16 +3611,50 @@ void PresetBundle::update_multi_material_filament_presets(size_t to_delete_filam void PresetBundle::update_mixed_filament_id_remap(const std::vector &old_mixed, size_t old_num_filaments, - size_t new_num_filaments) + size_t new_num_filaments, + size_t deleted_mixed_idx) { - build_filament_id_remap(old_mixed, old_num_filaments, new_num_filaments, false, 0u); + build_filament_id_remap(old_mixed, old_num_filaments, new_num_filaments, false, 0u, deleted_mixed_idx); +} + +// Checks manual_pattern and gradient dependency. +// When norm is non-empty, the pair check is skipped (pattern tokens already +// cover dependency detection via physical_filament_from_token). +// When norm is empty, the caller checks pair first, then calls this as supplement. +static bool mixed_filament_depends_on_physical(const MixedFilament& mf, unsigned int physical_1based) +{ + // ---- 1. manual_pattern ---- + const std::string norm = MixedFilamentManager::normalize_manual_pattern(mf.manual_pattern); + if (!norm.empty()) { + const auto groups = MixedFilamentManager::split_pattern_groups(norm); + for (const std::string& group : groups) { + const auto tokens = MixedFilamentManager::split_pattern_group_to_tokens(group, 0); + for (const std::string& token : tokens) { + if (MixedFilamentManager::physical_filament_from_token(token, mf, MixedFilamentManager::kMaxPhysicalFilaments) == physical_1based) + return true; + } + } + } + + // ---- 2. gradient components ---- + // Only check when there is no manual_pattern; a pattern already resolves + // every token, so gradient IDs would be a false positive at worst. + if (norm.empty()) { + for (unsigned int comp_id : MixedFilamentManager::decode_gradient_component_ids(mf.gradient_component_ids, 0)) { + if (comp_id == physical_1based) + return true; + } + } + + return false; } void PresetBundle::build_filament_id_remap(const std::vector &old_mixed, size_t old_num_filaments, size_t new_num_filaments, bool deleting_filament, - unsigned int deleted_1based) + unsigned int deleted_1based, + size_t deleted_mixed_idx) { size_t old_enabled_mixed = 0; for (const auto &mf : old_mixed) @@ -3634,10 +3668,10 @@ void PresetBundle::build_filament_id_remap(const std::vector &old unsigned int mapped = 0; if (deleting_filament && old_id == deleted_1based) { mapped = 0; + } else if (deleting_filament && old_id > deleted_1based) { + mapped = old_id - 1; } else if (old_id <= unsigned(new_num_filaments)) { mapped = old_id; - if (deleting_filament && old_id > deleted_1based) - --mapped; } m_last_filament_id_remap[old_id] = mapped; } @@ -3661,14 +3695,32 @@ void PresetBundle::build_filament_id_remap(const std::vector &old size_t stable_id_hits = 0; size_t fallback_pair_hits = 0; size_t missing_hits = 0; + size_t deleted_mixed_skips = 0; unsigned int old_virtual_id = unsigned(old_num_filaments + 1); - for (const auto &mf : old_mixed) { + for (size_t midx = 0; midx < old_mixed.size(); ++midx) { + const auto &mf = old_mixed[midx]; if (!mf.enabled) continue; + // When a mixed filament is explicitly deleted, leave its old virtual ID + // mapped to 0 (NONE) so paint on the deleted filament is removed, rather + // than being reassigned to a surviving mixed via pair-based fallback. + if (midx == deleted_mixed_idx) { + ++old_virtual_id; + ++deleted_mixed_skips; + continue; + } + + const std::string norm = MixedFilamentManager::normalize_manual_pattern(mf.manual_pattern); unsigned int a = mf.component_a; unsigned int b = mf.component_b; - if (a == deleted_1based || b == deleted_1based) { + if (norm.empty() && (a == deleted_1based || b == deleted_1based)) { + m_last_filament_id_remap[old_virtual_id] = 0; + ++missing_hits; + } else if (deleting_filament && mixed_filament_depends_on_physical(mf, deleted_1based)) { + // The mixed filament was removed by remove_physical_filament because + // its manual_pattern or gradient references the deleted physical, + // even though component_a/component_b do not. m_last_filament_id_remap[old_virtual_id] = 0; ++missing_hits; } else { @@ -3682,7 +3734,7 @@ void PresetBundle::build_filament_id_remap(const std::vector &old } } if (!mapped_by_stable_id) { - if (deleting_filament) { + if (deleting_filament && norm.empty()) { if (a > deleted_1based) --a; if (b > deleted_1based) @@ -3727,6 +3779,7 @@ void PresetBundle::build_filament_id_remap(const std::vector &old << " new_physical=" << new_num_filaments << " deleting=" << (deleting_filament ? 1 : 0) << " deleted_id=" << deleted_1based + << " deleted_mixed_skips=" << deleted_mixed_skips << " old_mixed_enabled=" << old_enabled_mixed << " new_mixed_enabled=" << this->mixed_filaments.enabled_count() << " stable_id_hits=" << stable_id_hits diff --git a/src/libslic3r/PresetBundle.hpp b/src/libslic3r/PresetBundle.hpp index 97536b28030..0ae15163455 100644 --- a/src/libslic3r/PresetBundle.hpp +++ b/src/libslic3r/PresetBundle.hpp @@ -268,7 +268,8 @@ class PresetBundle // changes when the physical filament count itself did not change. void update_mixed_filament_id_remap(const std::vector &old_mixed, size_t old_num_filaments, - size_t new_num_filaments); + size_t new_num_filaments, + size_t deleted_mixed_idx = size_t(-1)); // Mapping generated during the latest filament count change. // Index is old 1-based filament ID, value is new 1-based filament ID (0 = removed). const std::vector& last_filament_id_remap() const { return m_last_filament_id_remap; } @@ -428,7 +429,8 @@ class PresetBundle size_t old_num_filaments, size_t new_num_filaments, bool deleting_filament, - unsigned int deleted_1based); + unsigned int deleted_1based, + size_t deleted_mixed_idx = size_t(-1)); // Update renamed_from and alias maps of system profiles. void update_system_maps(); diff --git a/src/slic3r/GUI/Plater.cpp b/src/slic3r/GUI/Plater.cpp index c2abb64dd4a..a940dbe4448 100644 --- a/src/slic3r/GUI/Plater.cpp +++ b/src/slic3r/GUI/Plater.cpp @@ -2181,23 +2181,13 @@ Sidebar::Sidebar(Plater *parent) h_physical_title->Add(physical_label, 0, wxALIGN_CENTER_VERTICAL); h_physical_title->AddStretchSpacer(); - // Delete filament button + // Delete filament button — delegates to delete_filament for consistent remap behavior ScalableButton* del_btn = new ScalableButton(p->m_panel_physical_filaments_title, wxID_ANY, "delete_filament"); del_btn->SetToolTip(_L("Remove last filament")); del_btn->Bind(wxEVT_BUTTON, [this](wxCommandEvent &e) { if (p->combos_filament.size() <= 1) return; - size_t filament_count = p->combos_filament.size() - 1; - if (wxGetApp().preset_bundle->is_the_only_edited_filament(filament_count) || (filament_count == 1)) { - wxGetApp().get_tab(Preset::TYPE_FILAMENT)->select_preset(wxGetApp().preset_bundle->filament_presets[0], false, "", true); - } - if (p->editing_filament >= filament_count) { - p->editing_filament = -1; - } - wxGetApp().preset_bundle->set_num_filaments(filament_count); - wxGetApp().plater()->on_filaments_change(filament_count); - wxGetApp().get_tab(Preset::TYPE_PRINT)->update(); - wxGetApp().preset_bundle->export_selections(*wxGetApp().app_config); + delete_filament(size_t(-1), -1); }); p->m_bpButton_del_filament = del_btn; @@ -6041,14 +6031,16 @@ void Sidebar::update_color_mix_panel() menu.AppendSubMenu(merge_submenu, _L("Merge with")); menu.Append(del_id, _L("Delete")); - menu.Bind(wxEVT_MENU, [this, i](wxCommandEvent&) { + menu.Bind(wxEVT_MENU, [this, i, num_physical](wxCommandEvent&) { auto& mgr2 = wxGetApp().preset_bundle->mixed_filaments; auto& mfs2 = mgr2.mixed_filaments(); - if (i < mfs2.size()) mfs2[i].deleted = true; + const std::vector old_mixed = mfs2; + if (i < mfs2.size()) { mfs2[i].deleted = true; mfs2[i].enabled = false; } if (auto* opt = wxGetApp().preset_bundle->project_config.option("mixed_filament_definitions")) opt->value = mgr2.serialize_custom_entries(); + wxGetApp().preset_bundle->update_mixed_filament_id_remap(old_mixed, num_physical, num_physical, i); wxGetApp().plater()->post_slice_state_change_update(); - wxGetApp().plater()->on_filaments_change(p->combos_filament.size()); + wxGetApp().plater()->on_filaments_change(num_physical); wxWeakRef weak_this(this); wxTheApp->CallAfter([weak_this]() { Sidebar* sidebar = weak_this.get(); @@ -6899,7 +6891,7 @@ void Sidebar::update_mixed_filament_panel(bool sync_manager) } p->m_expanded_mixed_filament_rows.clear(); set_mixed_string("mixed_filament_definitions", mgr.serialize_custom_entries()); - wxGetApp().preset_bundle->update_mixed_filament_id_remap(old_mixed, num_physical, num_physical); + wxGetApp().preset_bundle->update_mixed_filament_id_remap(old_mixed, num_physical, num_physical, mixed_id); notify_mixed_change(); if (wxGetApp().plater()) wxGetApp().plater()->update_project_dirty_from_presets(); @@ -7203,10 +7195,12 @@ void Sidebar::on_filaments_delete(size_t filament_id) { auto& choices = combos_filament(); + p->m_skip_mixed_filament_sync_once = false; + if (filament_id >= choices.size()) return; - if (choices.size() == 1) + if (choices.size() <= 2) choices[0]->GetDropDown().Invalidate(); wxWindowUpdateLocker noUpdates_scrolled_panel(this); @@ -7244,6 +7238,16 @@ void Sidebar::on_filaments_delete(size_t filament_id) sizer->Hide(p->m_flushing_volume_btn); } + if (p->m_bpButton_del_filament != nullptr && p->m_panel_physical_filaments_title != nullptr) { + auto* inner_sizer = p->m_panel_physical_filaments_title->GetSizer(); + if (inner_sizer) { + if (p->combos_filament.size() > 1) + inner_sizer->Show(p->m_bpButton_del_filament); + else + inner_sizer->Hide(p->m_bpButton_del_filament); + } + } + for (size_t idx = filament_id; idx < p->combos_filament.size(); ++idx) { p->combos_filament[idx]->update(); } @@ -7272,8 +7276,17 @@ void Sidebar::on_filaments_delete(size_t filament_id) }); p->m_panel_filament_title->Refresh(); update_ui_from_settings(); - dynamic_filament_list.update(); + update_dynamic_filament_list(); update_mixed_filament_panel(); + update_color_mix_panel(); + + if (PresetBundle *pb = wxGetApp().preset_bundle) { + const bool can_add = pb->mixed_filaments.total_filaments(p->combos_filament.size()) < MAXIMUM_FILAMENT_NUMBER; + if (p->m_bpButton_add_filament) + p->m_bpButton_add_filament->Enable(can_add); + if (p->m_btn_add_color_mix) + p->m_btn_add_color_mix->Enable(can_add); + } } void Sidebar::edit_filament() { @@ -7288,14 +7301,24 @@ static bool mixed_filament_uses_physical(const MixedFilament* target_mf, unsigne { if (!target_mf) return false; - - // Check if target mixed filament uses source physical filament as component - if (target_mf->component_a == source_physical_1based || target_mf->component_b == source_physical_1based) { - return true; + + // Check manual_pattern tokens (resolve order #1) + const std::string norm = MixedFilamentManager::normalize_manual_pattern(target_mf->manual_pattern); + if (!norm.empty()) { + const auto groups = MixedFilamentManager::split_pattern_groups(norm); + for (const std::string &group : groups) { + const auto tokens = MixedFilamentManager::split_pattern_group_to_tokens(group, 0); + for (const std::string &token : tokens) { + if (MixedFilamentManager::physical_filament_from_token(token, *target_mf, MixedFilamentManager::kMaxPhysicalFilaments) == source_physical_1based) + return true; + } + } } - - // Also check gradient components (delegates to centralized decode) - { + + // Check gradient components (resolve order #2). + // Only check when there is no manual_pattern; a pattern already resolves + // every token, so gradient IDs would be a false positive at worst. + if (norm.empty()) { const std::vector ids = MixedFilamentManager::decode_gradient_component_ids(target_mf->gradient_component_ids, 0); for (unsigned int id : ids) { if (id == source_physical_1based) @@ -7303,6 +7326,13 @@ static bool mixed_filament_uses_physical(const MixedFilament* target_mf, unsigne } } + // Check if target mixed filament uses source physical filament as component + // (resolve order #3). Only reached when the mixed filament has no manual_pattern, + // because in cycle mode pattern tokens "1"/"2" already cover component_a/b. + if (norm.empty() && (target_mf->component_a == source_physical_1based || target_mf->component_b == source_physical_1based)) { + return true; + } + return false; } @@ -19779,13 +19809,21 @@ void Plater::on_filaments_delete(size_t num_filaments, size_t filament_id, int r id_remap = preset_bundle->consume_last_filament_id_remap(); // Build state map for remap if available. - // When replace_filament_id >= 0 the caller handles paint transfer via - // update_extruder_count_when_delete_filament — skip the generic remap - // (which maps deleted→0) so paint is moved to the target, not lost. + // Use the remap for both pure-delete and merge paths so that mixed + // filaments deleted by remove_physical_filament are correctly mapped + // to NONE instead of being shifted onto wrong IDs. EnforcerBlockerStateMap state_map; bool should_remap_states = false; - if (!id_remap.empty() && replace_filament_id < 0) { + if (!id_remap.empty()) { should_remap_states = true; + if (replace_filament_id >= 0) { + // Merge: inject the merge target into the remap so the deleted + // physical filament maps to the target instead of 0. + size_t old_1based = filament_id + 1; + size_t new_1based = replace_filament_id + 1; + if (old_1based < id_remap.size()) + id_remap[old_1based] = (unsigned int)new_1based; + } for (size_t i = 0; i < state_map.size(); ++i) state_map[i] = EnforcerBlockerType(i); for (size_t i = 1; i < state_map.size(); ++i) { diff --git a/tests/libslic3r/test_mixed_filament.cpp b/tests/libslic3r/test_mixed_filament.cpp index 99b0f8aa3b9..a8b7d10c1e4 100644 --- a/tests/libslic3r/test_mixed_filament.cpp +++ b/tests/libslic3r/test_mixed_filament.cpp @@ -930,6 +930,46 @@ TEST_CASE("Mixed filament accessors total_filaments display_colors is_mixed", "[ CHECK(display.size() == 1); } +TEST_CASE("LIFE-REGRESS-01: deleted entry not returned by mixed_filaments_using_physical", "[MixedFilament][Lifecycle]") +{ + const std::vector colors = {"#FF0000", "#00FF00", "#0000FF"}; + MixedFilamentManager mgr; + mgr.auto_generate(colors); + REQUIRE(mgr.mixed_filaments().size() == 3); + + // Mark first entry as deleted and disabled + mgr.mixed_filaments()[0].deleted = true; + mgr.mixed_filaments()[0].enabled = false; + + const unsigned int phys_a = mgr.mixed_filaments()[0].component_a; + const auto deps = mgr.mixed_filaments_using_physical(phys_a); + + // The deleted entry (index 0) should NOT appear in the results + for (size_t idx : deps) + CHECK(idx != 0); +} + +TEST_CASE("LIFE-REGRESS-02: empty manager state after all entries cleared", "[MixedFilament][Lifecycle]") +{ + const std::vector colors = {"#FF0000", "#00FF00", "#0000FF"}; + MixedFilamentManager mgr; + mgr.auto_generate(colors); + REQUIRE(mgr.mixed_filaments().size() == 3); + + // Mark ALL entries as deleted and disabled to clear everything + for (auto &mf : mgr.mixed_filaments()) { + mf.deleted = true; + mf.enabled = false; + } + + const size_t num_physical = 3; + const unsigned int first_mixed_id = 4; + + CHECK(mgr.enabled_count() == 0); + CHECK(mgr.total_filaments(num_physical) == num_physical); + CHECK(mgr.is_mixed(first_mixed_id, num_physical) == false); +} + // ============================================================================ // [MixedFilament][Serialization] // ============================================================================ @@ -1043,6 +1083,212 @@ TEST_CASE("Mixed filament duplicate stable_id dedup on load", "[MixedFilament][S CHECK(loaded.mixed_filaments()[0].stable_id != loaded.mixed_filaments()[1].stable_id); } +TEST_CASE("SER-REGRESS-01: manual_pattern preserved through serialize-load cycle", "[MixedFilament][Serialization]") +{ + const std::vector colors = {"#FF0000", "#00FF00", "#0000FF", "#FFFF00"}; + + MixedFilamentManager mgr; + mgr.auto_generate(colors); + mgr.add_custom_filament(1, 2, 50, colors); + + // Find the custom entry and set manual_pattern + MixedFilament *custom_entry = nullptr; + for (auto &mf : mgr.mixed_filaments()) { + if (mf.custom && mf.component_a == 1 && mf.component_b == 2) { + custom_entry = &mf; + break; + } + } + REQUIRE(custom_entry != nullptr); + custom_entry->manual_pattern = MixedFilamentManager::normalize_manual_pattern("1266666"); + REQUIRE(custom_entry->manual_pattern == "1266666"); + + const std::string serialized = mgr.serialize_custom_entries(); + + MixedFilamentManager loaded; + loaded.auto_generate(colors); + loaded.load_custom_entries(serialized, colors); + + bool found = false; + for (const auto &mf : loaded.mixed_filaments()) { + if (mf.custom && mf.component_a == 1 && mf.component_b == 2) { + CHECK(mf.manual_pattern == "1266666"); + found = true; + } + } + CHECK(found); +} + +TEST_CASE("SER-REGRESS-02: duplicate custom entries survive load_custom_entries", "[MixedFilament][Serialization]") +{ + const std::vector colors = {"#FF0000", "#00FF00", "#0000FF"}; + + MixedFilamentManager mgr; + mgr.auto_generate(colors); + + // Add two custom entries with same pair but different manual_patterns + mgr.add_custom_filament(1, 2, 30, colors); + mgr.add_custom_filament(1, 2, 70, colors); + + // Set distinct manual_patterns and stable_ids + auto &mixed = mgr.mixed_filaments(); + std::vector customs; + for (auto &mf : mixed) { + if (mf.custom && mf.component_a == 1 && mf.component_b == 2) + customs.push_back(&mf); + } + REQUIRE(customs.size() == 2); + + customs[0]->manual_pattern = MixedFilamentManager::normalize_manual_pattern("1212"); + customs[0]->stable_id = 1001; + customs[1]->manual_pattern = MixedFilamentManager::normalize_manual_pattern("2121"); + customs[1]->stable_id = 1002; + + const std::string serialized = mgr.serialize_custom_entries(); + + MixedFilamentManager loaded; + loaded.auto_generate(colors); + loaded.load_custom_entries(serialized, colors); + + // Custom entries do NOT get pair-based dedup like auto entries + int custom_count = 0; + for (const auto &mf : loaded.mixed_filaments()) { + if (mf.custom && mf.component_a == 1 && mf.component_b == 2) + ++custom_count; + } + CHECK(custom_count == 2); +} + +TEST_CASE("SER-REGRESS-03: deleted flag round-trips through serialize-load", "[MixedFilament][Serialization]") +{ + const std::vector colors = {"#FF0000", "#00FF00"}; + MixedFilamentManager mgr; + mgr.add_custom_filament(1, 2, 50, colors); + mgr.mixed_filaments().front().deleted = true; + mgr.mixed_filaments().front().enabled = false; + + const std::string serialized = mgr.serialize_custom_entries(); + CHECK(serialized.find("d1") != std::string::npos); + + MixedFilamentManager loaded; + loaded.load_custom_entries(serialized, colors); + REQUIRE(loaded.mixed_filaments().size() >= 1); + CHECK(loaded.mixed_filaments().front().deleted); + CHECK_FALSE(loaded.mixed_filaments().front().enabled); +} + +TEST_CASE("SER-REGRESS-04: gradient r1 token round-trip", "[MixedFilament][Serialization]") +{ + const std::vector colors = {"#FF0000", "#00FF00"}; + MixedFilamentManager mgr; + mgr.add_custom_filament(1, 2, 50, colors); + + auto &mf = mgr.mixed_filaments().front(); + mf.gradient_enabled = true; + mf.gradient_start = 0.8f; + mf.gradient_end = 0.2f; + + const std::string serialized = mgr.serialize_custom_entries(); + CHECK(serialized.find("r1/0.8000/0.2000") != std::string::npos); + + MixedFilamentManager loaded; + loaded.load_custom_entries(serialized, colors); + REQUIRE(loaded.mixed_filaments().size() >= 1); + + const auto &loaded_mf = loaded.mixed_filaments().front(); + CHECK(loaded_mf.gradient_enabled); + using Catch::Matchers::WithinAbs; + CHECK_THAT(double(loaded_mf.gradient_start), WithinAbs(0.8, 0.0001)); + CHECK_THAT(double(loaded_mf.gradient_end), WithinAbs(0.2, 0.0001)); +} + +TEST_CASE("SER-REGRESS-05: ui_mode persistence through serialization", "[MixedFilament][Serialization]") +{ + const std::vector colors = {"#FF0000", "#00FF00"}; + MixedFilamentManager mgr; + mgr.add_custom_filament(1, 2, 50, colors); + + auto &mf = mgr.mixed_filaments().front(); + mf.ui_mode = 1; + mf.manual_pattern = MixedFilamentManager::normalize_manual_pattern("1212"); + REQUIRE(mf.manual_pattern == "1212"); + + const std::string serialized = mgr.serialize_custom_entries(); + + MixedFilamentManager loaded; + loaded.load_custom_entries(serialized, colors); + REQUIRE(loaded.mixed_filaments().size() >= 1); + + const auto &loaded_mf = loaded.mixed_filaments().front(); + CHECK(loaded_mf.ui_mode == 1); +} + +TEST_CASE("SER-REGRESS-06: surface offset serialization round-trip xa/xb tokens", "[MixedFilament][Serialization]") +{ + const std::vector colors = {"#FF0000", "#00FF00", "#0000FF"}; + MixedFilamentManager mgr; + mgr.auto_generate(colors); + mgr.add_custom_filament(1, 2, 50, colors); + + auto &mf = mgr.mixed_filaments().back(); + mf.component_a_surface_offset = 0.15f; + mf.component_b_surface_offset = -0.10f; + + const std::string serialized = mgr.serialize_custom_entries(); + // format_surface_offset_token strips trailing zeros after setprecision(4): + // 0.15 -> "xa0.15", -0.10 -> "xb-0.1" + CHECK(serialized.find("xa0.15") != std::string::npos); + CHECK(serialized.find("xb-0.1") != std::string::npos); + + MixedFilamentManager loaded; + loaded.auto_generate(colors); + loaded.load_custom_entries(serialized, colors); + + // Find the loaded custom entry by component pair + const MixedFilament *loaded_mf = nullptr; + for (const auto &row : loaded.mixed_filaments()) { + if (row.custom && row.component_a == 1 && row.component_b == 2) { + loaded_mf = &row; + break; + } + } + REQUIRE(loaded_mf != nullptr); + + using Catch::Matchers::WithinAbs; + CHECK_THAT(double(loaded_mf->component_a_surface_offset), WithinAbs(0.15, 0.001)); + CHECK_THAT(double(loaded_mf->component_b_surface_offset), WithinAbs(-0.10, 0.001)); +} + +TEST_CASE("SER-REGRESS-07: origin_auto flag serialization round-trip", "[MixedFilament][Serialization]") +{ + const std::vector colors = {"#FF0000", "#00FF00", "#0000FF"}; + MixedFilamentManager mgr; + mgr.auto_generate(colors); + mgr.add_custom_filament(1, 2, 50, colors); + + auto &mf = mgr.mixed_filaments().back(); + mf.origin_auto = true; + mf.custom = false; + + const std::string serialized = mgr.serialize_custom_entries(); + CHECK(serialized.find("o1") != std::string::npos); + + MixedFilamentManager loaded; + loaded.auto_generate(colors); + loaded.load_custom_entries(serialized, colors); + + // Find the loaded custom entry by component pair + const MixedFilament *loaded_mf = nullptr; + for (const auto &row : loaded.mixed_filaments()) { + if (row.custom == false && row.component_a == 1 && row.component_b == 2) { + loaded_mf = &row; + break; + } + } + REQUIRE(loaded_mf != nullptr); + CHECK(loaded_mf->origin_auto == true); +} + // ============================================================================ // [MixedFilament][Gradient] // ============================================================================ @@ -1593,3 +1839,903 @@ TEST_CASE("Mixed filament apparent pair percentages bias on vs off", "[MixedFila // With positive B offset (0.05mm), B's apparent percent should decrease CHECK(pct_b_on < pct_b_off); } + +// ============================================================================ +// [MixedFilament][Resolve] — RES-REGRESS regression tests +// ============================================================================ + +TEST_CASE("RES-REGRESS-01: resolve handles deleted mixed filament", "[MixedFilament][Resolve]") +{ + const std::vector colors = {"#FF0000", "#00FF00", "#0000FF"}; + MixedFilamentManager mgr; + mgr.auto_generate(colors); + // 3 physical -> 3 auto-generated pairs: (1,2), (1,3), (2,3) + // Virtual IDs: 4, 5, 6 + REQUIRE(mgr.mixed_filaments().size() == 3); + + const size_t num_physical = 3; + const unsigned int virt_4 = 4; + const unsigned int virt_5 = 5; + const unsigned int virt_6 = 6; + + // Verify is_mixed for two of the auto-generated pairs + CHECK(mgr.is_mixed(virt_4, num_physical)); + CHECK(mgr.is_mixed(virt_5, num_physical)); + + // All 3 auto pairs are enabled, total = 3 physical + 3 mixed = 6 + const size_t total_before = mgr.total_filaments(num_physical); + CHECK(total_before == 6); + + // Find the entry to delete by component pair (1,3) using component-based lookup + // instead of a hardcoded index, so the test is robust against changes in + // auto_generate ordering. + for (auto &mf : mgr.mixed_filaments()) { + if (mf.component_a == 1 && mf.component_b == 3) { + mf.deleted = true; + mf.enabled = false; + break; + } + } + + // total_filaments decreases: 3 physical + 2 enabled mixed = 5 + const size_t total_after = mgr.total_filaments(num_physical); + CHECK(total_after == 5); + + // The highest old virtual ID (6) is now unmapped because virtual IDs are + // dynamically re-enumerated over enabled entries. Only 2 enabled mixed remain. + CHECK(mgr.mixed_index_from_filament_id(virt_6, num_physical) == -1); + + // resolve returns filament_id unchanged when mixed_index returns -1 (passthrough) + CHECK(mgr.resolve(virt_6, num_physical, 0) == virt_6); + + // The surviving mixed entries still resolve correctly (virt_4 -> index 0, virt_5 -> index 1) + CHECK(mgr.mixed_index_from_filament_id(virt_4, num_physical) >= 0); + CHECK(mgr.mixed_index_from_filament_id(virt_5, num_physical) >= 0); +} + +TEST_CASE("RES-REGRESS-02: resolve with gradient multi-color after component removal", "[MixedFilament][Resolve]") +{ + const std::vector colors = { + "#FF0000", "#00FF00", "#0000FF", "#FFFF00", "#FF00FF", "#00FFFF" + }; + MixedFilamentManager mgr; + + // Add a custom mixed filament whose gradient references physical filament #6 + mgr.add_custom_filament(1, 2, 50, colors); + REQUIRE(mgr.mixed_filaments().size() == 1); + + MixedFilament &mf = mgr.mixed_filaments().front(); + mf.gradient_enabled = true; + mf.gradient_component_ids = "6"; // References physical filament #6 + + const size_t count_before = mgr.mixed_filaments().size(); + CHECK(count_before == 1); + + // Delete physical #6 — the gradient-dependent mixed should be removed + mgr.remove_physical_filament(6); + + const size_t count_after = mgr.mixed_filaments().size(); + CHECK(count_after == 0); +} + +TEST_CASE("RES-REGRESS-03: effective_painted_region_filament_id with Grouped mode", "[MixedFilament][Resolve]") +{ + const std::vector colors = {"#00FFFF", "#FF00FF", "#FF0000"}; + MixedFilamentManager mgr; + mgr.add_custom_filament(1, 2, 50, colors); + REQUIRE(mgr.mixed_filaments().size() == 1); + + MixedFilament &row = mgr.mixed_filaments().front(); + row.manual_pattern = MixedFilamentManager::normalize_manual_pattern("12,21"); + REQUIRE(row.manual_pattern == "12,21"); + + const size_t num_physical = 3; + const unsigned int virtual_id = 4; + + // Grouped pattern (contains comma) preserves virtual ID for paint identity + unsigned int eff = mgr.effective_painted_region_filament_id(virtual_id, num_physical, 0); + CHECK(eff == virtual_id); +} + +// ============================================================================ +// [MixedFilament][Gradient] — GRAD-REGRESS regression tests +// ============================================================================ + +TEST_CASE("GRAD-REGRESS-01: encode/decode gradient IDs >= 10", "[MixedFilament][Gradient]") +{ + // Multi-ID with values >= 10 uses extended slash-separated format + const std::vector ids_1_2_12 = {1, 2, 12}; + std::string encoded = MixedFilamentManager::encode_gradient_component_ids(ids_1_2_12); + CHECK(encoded == "1/2/12"); + + // Round-trip: decode back to original IDs + std::vector decoded = MixedFilamentManager::decode_gradient_component_ids(encoded, 0); + REQUIRE(decoded.size() == 3); + CHECK(decoded[0] == 1); + CHECK(decoded[1] == 2); + CHECK(decoded[2] == 12); + + // Single-ID >= 10 uses leading slash for disambiguation from legacy + const std::vector single_id_12 = {12}; + std::string encoded_single = MixedFilamentManager::encode_gradient_component_ids(single_id_12); + CHECK(encoded_single == "/12"); + + // Round-trip single + std::vector decoded_single = + MixedFilamentManager::decode_gradient_component_ids(encoded_single, 0); + REQUIRE(decoded_single.size() == 1); + CHECK(decoded_single[0] == 12); + + // All legacy IDs (<= 9) still use compact single-char format + const std::vector legacy_ids = {1, 2, 3}; + std::string legacy_encoded = MixedFilamentManager::encode_gradient_component_ids(legacy_ids); + CHECK(legacy_encoded == "123"); + + std::vector legacy_decoded = + MixedFilamentManager::decode_gradient_component_ids(legacy_encoded, 0); + REQUIRE(legacy_decoded.size() == 3); + CHECK(legacy_decoded[0] == 1); + CHECK(legacy_decoded[1] == 2); + CHECK(legacy_decoded[2] == 3); + + // Mixed extended: some IDs < 10, some >= 10 + const std::vector mixed_ids = {3, 12, 5}; + std::string mixed_encoded = MixedFilamentManager::encode_gradient_component_ids(mixed_ids); + CHECK(mixed_encoded == "3/12/5"); + + std::vector mixed_decoded = + MixedFilamentManager::decode_gradient_component_ids(mixed_encoded, 0); + REQUIRE(mixed_decoded.size() == 3); + CHECK(mixed_decoded[0] == 3); + CHECK(mixed_decoded[1] == 12); + CHECK(mixed_decoded[2] == 5); +} + +TEST_CASE("GRAD-REGRESS-02: is_simple_gradient false with manual_pattern", "[MixedFilament][Gradient]") +{ + MixedFilament mf; + mf.component_a = 1; + mf.component_b = 2; + mf.gradient_enabled = true; + mf.manual_pattern = "1212"; + + // Non-empty manual_pattern -> is_simple_gradient returns false + CHECK_FALSE(is_simple_gradient(mf)); + + // Clear manual_pattern, set 2 gradient components -> is_simple_gradient true + mf.manual_pattern.clear(); + mf.gradient_component_ids = "12"; + CHECK(is_simple_gradient(mf)); + + // Empty gradient_component_ids with valid pair also satisfies the check + mf.gradient_component_ids.clear(); + CHECK(is_simple_gradient(mf)); +} + +// ============================================================================ +// [MixedFilament][Delete] +// ============================================================================ + +TEST_CASE("remove_physical_filament detects manual_pattern dependency and removes dependents", "[MixedFilament][Delete]") +{ + const std::vector colors(6, "#FF0000"); + MixedFilamentManager mgr; + mgr.auto_generate(colors); // C(6,2)=15 auto entries (all custom=false) + + // Add 4 custom entries as per the regression scenario: + // A: no pattern, component_a=1, component_b=2 (no dependency on #6) + mgr.add_custom_filament(1, 2, 50, colors); + + // B: manual_pattern="1266666" — token "6" maps to physical #6 + mgr.add_custom_filament(1, 2, 50, colors); + mgr.mixed_filaments().back().manual_pattern = "1266666"; + uint64_t idB = mgr.mixed_filaments().back().stable_id; + + // C: no pattern, component_a=2, component_b=5 (no dependency on #6) + mgr.add_custom_filament(2, 5, 50, colors); + + // D: manual_pattern="1221412465" — token "6" maps to physical #6 + mgr.add_custom_filament(1, 2, 50, colors); + mgr.mixed_filaments().back().manual_pattern = "1221412465"; + uint64_t idD = mgr.mixed_filaments().back().stable_id; + + // Delete physical filament #6 (1-based) + mgr.remove_physical_filament(6); + + // Check: Entry B and D should be REMOVED (they depend on physical #6 via pattern) + bool foundB = false, foundD = false; + bool foundA = false, foundC = false; + for (const auto &mf : mgr.mixed_filaments()) { + if (mf.stable_id == idB) foundB = true; + if (mf.stable_id == idD) foundD = true; + if (mf.component_a == 1 && mf.component_b == 2 && mf.manual_pattern.empty()) foundA = true; + if (mf.component_a == 2 && mf.component_b == 5) foundC = true; + } + CHECK_FALSE(foundB); + CHECK_FALSE(foundD); + CHECK(foundA); + CHECK(foundC); + + // Surviving entries should have component IDs adjusted (but #6 was the max, so no shift needed) +} + +TEST_CASE("build_filament_id_remap zeros dependents via mixed_filament_depends_on_physical", "[MixedFilament][Delete]") +{ + PresetBundle bundle; + bundle.filament_presets = { + "PLA Red", "PLA Green", "PLA Blue", "PLA Yellow", "PLA White", "PLA Black" + }; + bundle.project_config.option("filament_colour")->values = { + "#FF0000", "#00FF00", "#0000FF", "#FFFF00", "#FFFFFF", "#000000" + }; + bundle.update_multi_material_filament_presets(); + + auto &mgr = bundle.mixed_filaments; + const auto &colors = bundle.project_config.option("filament_colour")->values; + + // Add B: manual_pattern="1266666" — token "6" maps to physical #6 + mgr.add_custom_filament(1, 2, 50, colors); + mgr.mixed_filaments().back().manual_pattern = MixedFilamentManager::normalize_manual_pattern("1266666"); + const uint64_t idB = mgr.mixed_filaments().back().stable_id; + + // Add D: manual_pattern="1221412465" — token "6" maps to physical #6 + mgr.add_custom_filament(1, 2, 50, colors); + mgr.mixed_filaments().back().manual_pattern = MixedFilamentManager::normalize_manual_pattern("1221412465"); + const uint64_t idD = mgr.mixed_filaments().back().stable_id; + + // Record old virtual IDs for B and D before the deletion call + const unsigned int old_virtual_id_B = virtual_id_for_stable_id(mgr.mixed_filaments(), 6, idB); + const unsigned int old_virtual_id_D = virtual_id_for_stable_id(mgr.mixed_filaments(), 6, idD); + REQUIRE(old_virtual_id_B > 6); + REQUIRE(old_virtual_id_D > 6); + + // Delete physical #6 (0-based index = 5) + const size_t physical_before = 6; + bundle.filament_presets.pop_back(); + bundle.project_config.option("filament_colour")->values.pop_back(); + bundle.update_multi_material_filament_presets(5, physical_before); + + const std::vector remap = bundle.consume_last_filament_id_remap(); + REQUIRE(remap.size() > old_virtual_id_D); + REQUIRE(remap.size() > old_virtual_id_B); + + // Pattern-dependent entries B and D should map to 0 (deleted) in the remap + CHECK(remap[old_virtual_id_B] == 0); + CHECK(remap[old_virtual_id_D] == 0); + + // Verify B and D are NOT in the post-deletion mixed filaments + bool foundB = false, foundD = false; + for (const auto &mf : mgr.mixed_filaments()) { + if (mf.stable_id == idB) foundB = true; + if (mf.stable_id == idD) foundD = true; + } + CHECK_FALSE(foundB); + CHECK_FALSE(foundD); +} + +TEST_CASE("mixed_filament_depends_on_physical indirectly exercised via deletion remap", "[MixedFilament][Delete]") +{ + // mixed_filament_depends_on_physical is a static function in PresetBundle.cpp + // and is exercised indirectly through update_multi_material_filament_presets. + // This test validates that the remap correctly marks pattern-dependents as + // removed (mapped to 0) even when component_a/component_b do not directly + // reference the deleted physical filament. + + PresetBundle bundle; + bundle.filament_presets = {"Filament 1", "Filament 2", "Filament 3"}; + bundle.project_config.option("filament_colour")->values = { + "#FF0000", "#00FF00", "#0000FF" + }; + bundle.update_multi_material_filament_presets(); + + auto &mgr = bundle.mixed_filaments; + const auto &colors = bundle.project_config.option("filament_colour")->values; + + // Add a custom entry whose manual_pattern references physical #3 + // but whose component_a/component_b are 1 and 2 (not 3). + mgr.add_custom_filament(1, 2, 50, colors); + mgr.mixed_filaments().back().manual_pattern = MixedFilamentManager::normalize_manual_pattern("123"); + const uint64_t sid = mgr.mixed_filaments().back().stable_id; + + const unsigned int old_virtual_id = virtual_id_for_stable_id(mgr.mixed_filaments(), 3, sid); + REQUIRE(old_virtual_id > 3); + + // Delete physical #3 (0-based index = 2) + const size_t physical_before = 3; + bundle.filament_presets.pop_back(); + bundle.project_config.option("filament_colour")->values.pop_back(); + bundle.update_multi_material_filament_presets(2, physical_before); + + const std::vector remap = bundle.consume_last_filament_id_remap(); + REQUIRE(remap.size() > old_virtual_id); + + // The pattern-dependent entry should be mapped to 0 (deleted) + CHECK(remap[old_virtual_id] == 0); + + // Verify the entry is gone from post-deletion state + bool found = false; + for (const auto &mf : mgr.mixed_filaments()) { + if (mf.stable_id == sid) found = true; + } + CHECK_FALSE(found); +} + +TEST_CASE("remove_physical_filament adjusts manual_pattern tokens above deleted ID", "[MixedFilament][Delete]") +{ + const std::vector colors(6, "#FF0000"); + MixedFilamentManager mgr; + mgr.auto_generate(colors); + + // Add a custom entry with pattern "15353" + // "1" maps to component_a, "3" and "5" are numeric physical IDs + mgr.add_custom_filament(1, 2, 50, colors); + MixedFilament &row = mgr.mixed_filaments().back(); + row.manual_pattern = MixedFilamentManager::normalize_manual_pattern("15353"); + const uint64_t sid = row.stable_id; + + // Delete physical filament #4 (1-based) + mgr.remove_physical_filament(4); + + // The entry should survive (no token resolves to physical #4) + // but tokens > 4 should be decremented: "5" → "4" + bool found = false; + for (const auto &cur : mgr.mixed_filaments()) { + if (cur.stable_id == sid) { + found = true; + // "1" stays (<=4), "5"→"4", "3" stays (<=4), "5"→"4", "3" stays (<=4) + CHECK(cur.manual_pattern == "14343"); + break; + } + } + CHECK(found); +} + +TEST_CASE("mixed_filaments_using_physical finds multiple pattern dependents for same physical", "[MixedFilament][Delete]") +{ + const std::vector colors(6, "#FF0000"); + MixedFilamentManager mgr; + mgr.auto_generate(colors); + + // Add B: manual_pattern="1266666" — tokens include numeric "6" + mgr.add_custom_filament(1, 2, 50, colors); + mgr.mixed_filaments().back().manual_pattern = MixedFilamentManager::normalize_manual_pattern("1266666"); + const uint64_t idB = mgr.mixed_filaments().back().stable_id; + + // Add D: manual_pattern="1221412465" — tokens include numeric "6" + mgr.add_custom_filament(1, 2, 50, colors); + mgr.mixed_filaments().back().manual_pattern = MixedFilamentManager::normalize_manual_pattern("1221412465"); + const uint64_t idD = mgr.mixed_filaments().back().stable_id; + + // mixed_filaments_using_physical(6) should return BOTH entries + const auto deps = mgr.mixed_filaments_using_physical(6); + CHECK(deps.size() >= 2); + + // Verify both stable_ids are in the returned indices + bool foundB_in_deps = false, foundD_in_deps = false; + for (size_t idx : deps) { + if (mgr.mixed_filaments()[idx].stable_id == idB) foundB_in_deps = true; + if (mgr.mixed_filaments()[idx].stable_id == idD) foundD_in_deps = true; + } + CHECK(foundB_in_deps); + CHECK(foundD_in_deps); + + // After remove_physical_filament(6), BOTH should be removed + mgr.remove_physical_filament(6); + + bool foundB = false, foundD = false; + for (const auto &mf : mgr.mixed_filaments()) { + if (mf.stable_id == idB) foundB = true; + if (mf.stable_id == idD) foundD = true; + } + CHECK_FALSE(foundB); + CHECK_FALSE(foundD); +} + +// ============================================================================ +// [MixedFilament][Merge] — MERGE-REGRESS regression tests +// ============================================================================ + +TEST_CASE("MERGE-REGRESS-01: build_merge_filament_remap 3-arg merges mixed into physical", "[MixedFilament][Merge]") +{ + PresetBundle bundle; + bundle.filament_presets = {"PLA Red", "PLA Green", "PLA Blue", "PLA Yellow"}; + bundle.project_config.option("filament_colour")->values = { + "#FF0000", "#00FF00", "#0000FF", "#FFFF00" + }; + bundle.update_multi_material_filament_presets(); + + auto &mgr = bundle.mixed_filaments; + // C(4,2) = 6 auto entries with auto_generate enabled + REQUIRE(mgr.mixed_filaments().size() == 6); + + // Capture old_mixed state for reference + const std::vector old_mixed = mgr.mixed_filaments(); + const size_t total_filaments = 10; // 4 physical + 6 mixed + + // Merge mixed at 0-based ID 5 (first auto mixed in 1-based: physical 1..4, mixed 5..10) + // into physical at 0-based ID 0 (physical #1) + bundle.build_merge_filament_remap(/*from_id=*/5, /*to_id=*/0, total_filaments); + + const std::vector remap = bundle.consume_last_filament_id_remap(); + REQUIRE(remap.size() >= 11); // total_filaments + 1 + + // from_id(5) > to_id(0) → else branch: remap[from_id+1] = to_id+1 = 1 + CHECK(remap[6] == 1); + + // IDs before from_id+1 (i < 6) stay the same + CHECK(remap[0] == 0); + CHECK(remap[1] == 1); + CHECK(remap[2] == 2); + CHECK(remap[3] == 3); + CHECK(remap[4] == 4); + CHECK(remap[5] == 5); + + // IDs after from_id+1 (i > 6) shift down by 1 + CHECK(remap[7] == 6); + CHECK(remap[8] == 7); + CHECK(remap[9] == 8); + CHECK(remap[10] == 9); +} + +TEST_CASE("MERGE-REGRESS-02: build_merge_filament_remap 4-arg handles dependent mixed", "[MixedFilament][Merge]") +{ + // Disable auto_generate so we control exactly which entries exist + MixedAutoGenerateGuard guard(false); + + PresetBundle bundle; + bundle.filament_presets = {"PLA Red", "PLA Green", "PLA Blue", "PLA Yellow"}; + bundle.project_config.option("filament_colour")->values = { + "#FF0000", "#00FF00", "#0000FF", "#FFFF00" + }; + bundle.update_multi_material_filament_presets(); + + auto &mgr = bundle.mixed_filaments; + const auto &colors = bundle.project_config.option("filament_colour")->values; + // No auto entries (auto_generate disabled), total = 4 physical + + // Add 3 custom entries: + // Entry 0 (0-based virtual=4): component_a=2, component_b=3 — NOT dependent on physical #1 + mgr.add_custom_filament(2, 3, 50, colors); + // Entry 1 (0-based virtual=5): component_a=1, component_b=2 — DEPENDENT on physical #1 + mgr.add_custom_filament(1, 2, 50, colors); + mgr.mixed_filaments().back().manual_pattern = MixedFilamentManager::normalize_manual_pattern("1212"); + const uint64_t dependent_sid = mgr.mixed_filaments().back().stable_id; + // Entry 2 (0-based virtual=6): component_a=3, component_b=4 — NOT dependent on physical #1 + mgr.add_custom_filament(3, 4, 50, colors); + + REQUIRE(mgr.mixed_filaments().size() == 3); + + const size_t total_filaments = 7; // 4 physical + 3 mixed + const size_t num_physical = 4; + + // Merge physical #1 (from_id=0) into first mixed entry (to_id=4, 0-based virtual ID) + // The dependent entry at old 1-based virtual ID 6 should be zeroed out in the remap. + bundle.build_merge_filament_remap(/*from_id=*/0, /*to_id=*/4, total_filaments, num_physical); + + const std::vector remap = bundle.consume_last_filament_id_remap(); + REQUIRE(remap.size() >= 8); // total_filaments + 1 + + // Source physical #1 (1-based=1) maps to target mixed's recalculated 1-based virtual ID. + // target_old_virtual_id=4, target_old_mixed_idx=0. No deleted mixed before it. + // new_num_physical=3, target_new_virtual_id=3 → 1-based = 4. + CHECK(remap[1] == 4); + + // Physical #2..#4 shift down by 1 + CHECK(remap[2] == 1); + CHECK(remap[3] == 2); + CHECK(remap[4] == 3); + + // First mixed (old 1-based=5, i=5): NOT dependent, survives. + // old_virtual_id=4, old_mixed_idx=0, deleted_before=0 + // new_virtual_id = 3 + 0 - 0 = 3 → 1-based = 4 + CHECK(remap[5] == 4); + + // Second mixed (old 1-based=6, i=6): DEPENDENT → mapped to 0. + const unsigned int old_virtual_id_dep = num_physical + 1; // 4 + 1 = 5, i=6 + CHECK(remap[6] == 0); + + // Third mixed (old 1-based=7, i=7): NOT dependent, survives. + // old_virtual_id=6, old_mixed_idx=2, deleted_before=1 (dependent at vid 5) + // new_virtual_id = 3 + 2 - 1 = 4 → 1-based = 5 + CHECK(remap[7] == 5); +} + +TEST_CASE("MERGE-REGRESS-03: merge remap handles multiple dependents on same physical", "[MixedFilament][Merge]") +{ + // Disable auto_generate so we control the exact entry set + MixedAutoGenerateGuard guard(false); + + PresetBundle bundle; + bundle.filament_presets = { + "PLA 1", "PLA 2", "PLA 3", "PLA 4", "PLA 5", "PLA 6" + }; + bundle.project_config.option("filament_colour")->values = { + "#FF0000", "#00FF00", "#0000FF", "#FFFF00", "#FF00FF", "#00FFFF" + }; + bundle.update_multi_material_filament_presets(); + + auto &mgr = bundle.mixed_filaments; + const auto &colors = bundle.project_config.option("filament_colour")->values; + // No auto entries, 6 physical + + // Add 4 custom entries: + // Entry 0 (0-based virt=6): comp_a=2, comp_b=3 — NOT dependent on physical #1 + mgr.add_custom_filament(2, 3, 50, colors); + + // Entry 1 (0-based virt=7): comp_a=1, comp_b=2 — DEPENDENT on physical #1 + mgr.add_custom_filament(1, 2, 50, colors); + mgr.mixed_filaments().back().manual_pattern = MixedFilamentManager::normalize_manual_pattern("1266666"); + const uint64_t dep1_sid = mgr.mixed_filaments().back().stable_id; + + // Entry 2 (0-based virt=8): comp_a=4, comp_b=5 — NOT dependent on physical #1 + mgr.add_custom_filament(4, 5, 50, colors); + + // Entry 3 (0-based virt=9): comp_a=1, comp_b=3 — DEPENDENT on physical #1 + mgr.add_custom_filament(1, 3, 50, colors); + mgr.mixed_filaments().back().manual_pattern = MixedFilamentManager::normalize_manual_pattern("1221412465"); + const uint64_t dep2_sid = mgr.mixed_filaments().back().stable_id; + + REQUIRE(mgr.mixed_filaments().size() == 4); + + const size_t total_filaments = 10; // 6 physical + 4 mixed + const size_t num_physical = 6; + + // Merge physical #1 (from_id=0) into first mixed entry (to_id=6, 0-based). + // The first mixed entry (comp_a=2, comp_b=3) does NOT depend on physical #1. + bundle.build_merge_filament_remap(/*from_id=*/0, /*to_id=*/6, total_filaments, num_physical); + + const std::vector remap = bundle.consume_last_filament_id_remap(); + REQUIRE(remap.size() >= 11); // total_filaments + 1 + + // Source physical #1 (1-based=1) maps to target mixed's recalculated 1-based virtual ID. + // target_old_virtual_id=6, target_old_mixed_idx=0, deleted_before=0 + // new_num_physical=5, target_new_virtual_id=5 → 1-based = 6 + CHECK(remap[1] == 6); + + // Physical #2..#6 shift down by 1 + CHECK(remap[2] == 1); + CHECK(remap[3] == 2); + CHECK(remap[4] == 3); + CHECK(remap[5] == 4); + CHECK(remap[6] == 5); + + // First mixed (old 1-based=7, i=7): NOT dependent, survives. + // old_virtual_id=6, old_mixed_idx=0, deleted_before=0 + // new_virtual_id = 5 + 0 - 0 = 5 → 1-based = 6 + CHECK(remap[7] == 6); + + // Second mixed (old 1-based=8, i=8): DEPENDENT → 0 + CHECK(remap[8] == 0); + + // Third mixed (old 1-based=9, i=9): NOT dependent, survives. + // old_virtual_id=8, old_mixed_idx=2, deleted_before=1 (dependent at vid 7) + // new_virtual_id = 5 + 2 - 1 = 6 → 1-based = 7 + CHECK(remap[9] == 7); + + // Fourth mixed (old 1-based=10, i=10): DEPENDENT → 0 + CHECK(remap[10] == 0); + + // Verify the surviving non-dependent entry at i=9 has correct deleted_before_target adjustment + // deleted_before for vid 8 = 1 (one dependent, vid 7, appears before it) + // new_virtual_id = 5 + 2 - 1 = 6, so 1-based = 7. This accounts for one deleted entry + // that was before it in the virtual ID space. + CHECK(remap[9] == 7); +} + +TEST_CASE("MERGE-REGRESS-04: merge_mixed_filament marks source deleted and serializes d1", "[MixedFilament][Merge]") +{ + PresetBundle bundle; + bundle.filament_presets = {"PLA Red", "PLA Green", "PLA Blue"}; + bundle.project_config.option("filament_colour")->values = { + "#FF0000", "#00FF00", "#0000FF" + }; + bundle.update_multi_material_filament_presets(); + + auto &mgr = bundle.mixed_filaments; + // C(3,2) = 3 auto entries + REQUIRE(mgr.mixed_filaments().size() >= 3); + + // Simulate merge_mixed_filament logic: mark an auto entry as deleted + auto &source_mf = mgr.mixed_filaments()[0]; + const uint64_t source_sid = source_mf.stable_id; + source_mf.deleted = true; + source_mf.enabled = false; + + // Assert source flags + CHECK(source_mf.deleted); + CHECK_FALSE(source_mf.enabled); + + // Serialize and check for "d1" token + const std::string serialized = mgr.serialize_custom_entries(); + CHECK(serialized.find("d1") != std::string::npos); + + // Round-trip: load into a new manager and verify deleted state + MixedFilamentManager loaded; + loaded.auto_generate(bundle.project_config.option("filament_colour")->values); + loaded.clear_custom_entries(); + loaded.load_custom_entries(serialized, + bundle.project_config.option("filament_colour")->values); + + // Find the entry by stable_id in the loaded list + bool found_in_loaded = false; + for (const auto &mf : loaded.mixed_filaments()) { + if (mf.stable_id == source_sid) { + found_in_loaded = true; + CHECK(mf.deleted); + CHECK_FALSE(mf.enabled); + break; + } + } + CHECK(found_in_loaded); +} + +// ============================================================================ +// [MixedFilament][Delete] — DELETE-PRIORITY resolution order tests +// ============================================================================ + +TEST_CASE("DELETE-PRIORITY-01: resolve-order — manual_pattern blocks pair/gradient check", "[MixedFilament][Delete]") +{ + // Verify that when a mixed filament has a non-empty manual_pattern, the + // component_a/component_b pair check and gradient check are skipped (both + // guarded by norm.empty()), so an entry can survive even if its component_a + // or component_b references the deleted physical filament. + // + // Setup: pattern "12" where both tokens are non-symbolic direct physical IDs + // (they do not map to component_a/b). Delete physical #5 which is not + // referenced by the pattern but IS referenced by component_a. + // Result: manual_pattern check passes (no token resolves to #5), gradient + // and pair checks are skipped (norm is non-empty), so the entry SURVIVES. + + constexpr int NUM_PHYSICAL = 6; + const std::vector colors(NUM_PHYSICAL, "#FF0000"); + MixedFilamentManager mgr; + mgr.auto_generate(colors); + + // Add a custom entry. We use component_a=5, component_b=2 so that + // component_a references the soon-to-be-deleted physical #5. Then we + // override the manual_pattern to tokens that do NOT resolve to #5. + mgr.add_custom_filament(5, 2, 50, colors); + MixedFilament &row = mgr.mixed_filaments().back(); + // Use a pattern whose tokens are all direct physical IDs ("3", "4") that + // do not include "5". Because the norm is non-empty the pair check + // (which would flag component_a=5) is never reached. + row.manual_pattern = MixedFilamentManager::normalize_manual_pattern("34"); + const uint64_t sid = row.stable_id; + + mgr.remove_physical_filament(5); + + bool found = false; + for (const auto &cur : mgr.mixed_filaments()) { + if (cur.stable_id == sid) { + found = true; + break; + } + } + CHECK(found); +} + +TEST_CASE("DELETE-PRIORITY-02: bracket notation token adjustment after deletion", "[MixedFilament][Delete]") +{ + // Setup: 6 physical. Create a custom entry with manual_pattern="1[12]3" + // containing a bracket-wrapped two‑digit token [12]. Delete physical #5. + // The entry survives (no token resolves to #5), and the [12] token is + // decremented to [11] because 12 > 5. + // + // Normalization preserves multi‑digit bracket tokens: "1[12]3" → "1[12]3". + + const std::vector colors(6, "#FF0000"); + MixedFilamentManager mgr; + mgr.auto_generate(colors); + + mgr.add_custom_filament(1, 2, 50, colors); + MixedFilament &row = mgr.mixed_filaments().back(); + row.manual_pattern = MixedFilamentManager::normalize_manual_pattern("1[12]3"); + const uint64_t sid = row.stable_id; + + mgr.remove_physical_filament(5); + + bool found = false; + for (const auto &cur : mgr.mixed_filaments()) { + if (cur.stable_id == sid) { + found = true; + // Token "1" stays (1 <= 5, not decremented) + // Token "[12]" → 12 > 5 → decremented to 11, re‑encoded as "[11]" + // Token "3" stays (3 <= 5, not decremented) + CHECK(cur.manual_pattern == "1[11]3"); + break; + } + } + CHECK(found); +} + +TEST_CASE("DELETE-PRIORITY-03: deleting physical #1 when manual_pattern uses symbolic 1", "[MixedFilament][Delete]") +{ + // Setup: 3 physical. Create a custom entry with manual_pattern="121". + // The "1" tokens are symbolic — they map to component_a via + // physical_filament_from_token. Delete physical #1 which matches + // component_a, so every "1" token flags uses_deleted_in_pattern. + // The entry IS removed because it depends on physical #1 via component_a. + + const std::vector colors(3, "#FF0000"); + MixedFilamentManager mgr; + mgr.auto_generate(colors); + + mgr.add_custom_filament(1, 2, 50, colors); + MixedFilament &row = mgr.mixed_filaments().back(); + row.manual_pattern = MixedFilamentManager::normalize_manual_pattern("121"); + const uint64_t sid = row.stable_id; + + mgr.remove_physical_filament(1); + + // Entry removed: token "1" → component_a=1 == deleted(1) + bool found = false; + for (const auto &cur : mgr.mixed_filaments()) { + if (cur.stable_id == sid) { + found = true; + break; + } + } + CHECK_FALSE(found); +} + +TEST_CASE("DELETE-BRACKET-01: bracket token crosses single-digit boundary after adjustment", "[MixedFilament][Delete]") +{ + // Setup: 6 physical. Create a custom entry with manual_pattern="1[10]3". + // Delete physical #1. Token "1" resolves to component_a=1 == deleted → entry removed. + // Instead use a setup where the bracket token crosses the boundary safely: + // Pattern "3[10]4", delete physical #2. None resolves to deleted. + // Token [10] (literal 10) > 2 → decremented to 9, which is < 10 → bare "9". + + const std::vector colors(6, "#FF0000"); + MixedFilamentManager mgr; + mgr.auto_generate(colors); + + mgr.add_custom_filament(1, 2, 50, colors); + MixedFilament &row = mgr.mixed_filaments().back(); + row.manual_pattern = MixedFilamentManager::normalize_manual_pattern("3[10]4"); + const uint64_t sid = row.stable_id; + + mgr.remove_physical_filament(2); + + bool found = false; + for (const auto &cur : mgr.mixed_filaments()) { + if (cur.stable_id == sid) { + found = true; + // 3 > 2 → 2, 10 > 2 → 9 → bare "9" (< 10), 4 > 2 → 3 + CHECK(cur.manual_pattern == "293"); + break; + } + } + CHECK(found); +} + +TEST_CASE("DELETE-GROUP-01: comma-separated group adjustment after deletion", "[MixedFilament][Delete]") +{ + // Setup: 6 physical. Create a custom entry with multi-group pattern "34,78". + // Delete physical #2. Neither group resolves to deleted. + // Group 0: "34" → 3>2→2, 4>2→3 = "23" + // Group 1: "78" → 7>2→6, 8>2→7 = "67" + // Result: "23,67" with comma preserved. + + const std::vector colors(6, "#FF0000"); + MixedFilamentManager mgr; + mgr.auto_generate(colors); + + mgr.add_custom_filament(1, 2, 50, colors); + MixedFilament &row = mgr.mixed_filaments().back(); + row.manual_pattern = MixedFilamentManager::normalize_manual_pattern("34,78"); + const uint64_t sid = row.stable_id; + + mgr.remove_physical_filament(2); + + bool found = false; + for (const auto &cur : mgr.mixed_filaments()) { + if (cur.stable_id == sid) { + found = true; + CHECK(cur.manual_pattern == "23,67"); + break; + } + } + CHECK(found); +} + +// ============================================================================ +// [MixedFilament][Gradient] — GRAD-DEL regression tests +// ============================================================================ + +TEST_CASE("GRAD-DEL-01: gradient with partial component survival — matching ID removes entry", "[MixedFilament][Gradient]") +{ + // Setup: 6 physical. Create a custom entry with gradient_component_ids="123" + // (IDs 1, 2, 3). Delete physical #2. The gradient check (step 2 in + // remove_physical_filament) finds that ID 2 matches the deleted physical, + // so the entire mixed filament is removed — even though IDs 1 and 3 survive. + + const std::vector colors(6, "#FF0000"); + MixedFilamentManager mgr; + mgr.auto_generate(colors); + + mgr.add_custom_filament(1, 2, 50, colors); + MixedFilament &row = mgr.mixed_filaments().back(); + row.gradient_enabled = true; + row.gradient_component_ids = "123"; + const uint64_t sid = row.stable_id; + + mgr.remove_physical_filament(2); + + // The gradient entry should be gone + bool found = false; + for (const auto &cur : mgr.mixed_filaments()) { + if (cur.stable_id == sid) found = true; + } + CHECK_FALSE(found); + + // No surviving entry should have gradient_component_ids set + int gradient_entries = 0; + for (const auto &cur : mgr.mixed_filaments()) { + if (!cur.gradient_component_ids.empty()) ++gradient_entries; + } + CHECK(gradient_entries == 0); +} + +TEST_CASE("GRAD-DEL-02: gradient partial survival — no matching component, IDs adjusted", "[MixedFilament][Gradient]") +{ + // Setup: 6 physical. Create a custom entry with gradient_component_ids="345" + // (IDs 3, 4, 5). Delete physical #2. None of the gradient IDs match #2, + // so the entry SURVIVES and all IDs > 2 are decremented: "345" → "234". + + const std::vector colors(6, "#FF0000"); + MixedFilamentManager mgr; + mgr.auto_generate(colors); + + mgr.add_custom_filament(3, 4, 50, colors); + MixedFilament &row = mgr.mixed_filaments().back(); + row.gradient_enabled = true; + row.gradient_component_ids = "345"; + const uint64_t sid = row.stable_id; + + mgr.remove_physical_filament(2); + + // Entry survives — none of the gradient IDs (3, 4, 5) match deleted #2 + bool found = false; + for (const auto &cur : mgr.mixed_filaments()) { + if (cur.stable_id == sid) { + found = true; + // 3 > 2 → 2, 4 > 2 → 3, 5 > 2 → 4 (all single‑digit → compact "234") + CHECK(cur.gradient_component_ids == "234"); + break; + } + } + CHECK(found); +} + +TEST_CASE("GRAD-DEL-03: stale gradient ID removed when manual_pattern survives deletion", "[MixedFilament][Gradient]") +{ + // When a mixed filament has both manual_pattern and gradient_component_ids, + // and the pattern does not reference the deleted physical but the gradient + // does, the entry survives (pattern is the active resolution source) but + // the stale gradient ID must be removed from gradient_component_ids. + + const std::vector colors(6, "#FF0000"); + MixedFilamentManager mgr; + mgr.auto_generate(colors); + + mgr.add_custom_filament(1, 2, 50, colors); + MixedFilament &row = mgr.mixed_filaments().back(); + row.manual_pattern = MixedFilamentManager::normalize_manual_pattern("34"); + row.gradient_component_ids = "5"; + const uint64_t sid = row.stable_id; + + // Delete physical #5. Pattern "34" does not reference #5, so entry survives. + // Gradient "5" references #5, so it must be erased (stale reference). + mgr.remove_physical_filament(5); + + bool found = false; + for (const auto &cur : mgr.mixed_filaments()) { + if (cur.stable_id == sid) { + found = true; + CHECK(cur.gradient_component_ids.empty()); + break; + } + } + CHECK(found); +}