#include #include "libslic3r/ExtrusionEntity.hpp" #include "libslic3r/PresetBundle.hpp" #include "libslic3r/Print.hpp" #include "libslic3r/GCode/ToolOrdering.hpp" #include #include using namespace Slic3r; namespace { static std::vector split_rows(const std::string &serialized) { std::vector rows; std::stringstream ss(serialized); std::string row; while (std::getline(ss, row, ';')) { if (!row.empty()) rows.push_back(row); } return rows; } static std::string join_rows(const std::vector &rows) { std::ostringstream ss; for (size_t i = 0; i < rows.size(); ++i) { if (i != 0) ss << ';'; ss << rows[i]; } return ss.str(); } static unsigned int virtual_id_for_stable_id(const std::vector &mixed, size_t num_physical, uint64_t stable_id) { unsigned int next_virtual_id = unsigned(num_physical + 1); for (const MixedFilament &mf : mixed) { if (!mf.enabled || mf.deleted) continue; if (mf.stable_id == stable_id) return next_virtual_id; ++next_virtual_id; } return 0; } struct MixedAutoGenerateGuard { explicit MixedAutoGenerateGuard(bool enabled) : previous(MixedFilamentManager::auto_generate_enabled()) { MixedFilamentManager::set_auto_generate_enabled(enabled); } ~MixedAutoGenerateGuard() { MixedFilamentManager::set_auto_generate_enabled(previous); } bool previous = true; }; } // namespace TEST_CASE("Mixed filament remap follows stable row ids when same-pair rows reorder", "[MixedFilament]") { PresetBundle bundle; bundle.filament_presets = {"Default Filament", "Default Filament"}; bundle.project_config.option("filament_colour")->values = {"#FF0000", "#0000FF"}; bundle.update_multi_material_filament_presets(); auto &mgr = bundle.mixed_filaments; auto &mixed = mgr.mixed_filaments(); REQUIRE(mixed.size() == 1); mixed[0].deleted = true; mixed[0].enabled = false; const auto colors = bundle.project_config.option("filament_colour")->values; mgr.add_custom_filament(1, 2, 25, colors); mgr.add_custom_filament(1, 2, 75, colors); auto &old_mixed = mgr.mixed_filaments(); REQUIRE(old_mixed.size() == 3); REQUIRE(old_mixed[1].enabled); REQUIRE(old_mixed[2].enabled); const uint64_t first_custom_id = old_mixed[1].stable_id; const uint64_t second_custom_id = old_mixed[2].stable_id; std::vector rows = split_rows(mgr.serialize_custom_entries()); REQUIRE(rows.size() == 3); std::swap(rows[1], rows[2]); auto *definitions = bundle.project_config.option("mixed_filament_definitions"); REQUIRE(definitions != nullptr); definitions->value = join_rows(rows); bundle.filament_presets.push_back(bundle.filament_presets.back()); bundle.project_config.option("filament_colour")->values.push_back("#00FF00"); bundle.update_multi_material_filament_presets(size_t(-1), 2); const std::vector remap = bundle.consume_last_filament_id_remap(); REQUIRE(remap.size() >= 5); const auto &rebuilt = bundle.mixed_filaments.mixed_filaments(); const unsigned int new_first_custom_virtual_id = virtual_id_for_stable_id(rebuilt, 3, first_custom_id); const unsigned int new_second_custom_virtual_id = virtual_id_for_stable_id(rebuilt, 3, second_custom_id); REQUIRE(new_first_custom_virtual_id != 0); REQUIRE(new_second_custom_virtual_id != 0); CHECK(remap[3] == new_first_custom_virtual_id); CHECK(remap[4] == new_second_custom_virtual_id); } TEST_CASE("Mixed filament remap keeps later painted colors stable when an earlier mixed row is deleted", "[MixedFilament]") { PresetBundle bundle; bundle.filament_presets = {"Default Filament", "Default Filament", "Default Filament", "Default Filament"}; bundle.project_config.option("filament_colour")->values = {"#FF0000", "#00FF00", "#0000FF", "#FFFF00"}; bundle.update_multi_material_filament_presets(); auto &mixed = bundle.mixed_filaments.mixed_filaments(); REQUIRE(mixed.size() >= 6); const uint64_t stable_id_6 = mixed[1].stable_id; const uint64_t stable_id_7 = mixed[2].stable_id; const uint64_t stable_id_8 = mixed[3].stable_id; const std::vector old_mixed = mixed; mixed[0].enabled = false; mixed[0].deleted = true; bundle.update_mixed_filament_id_remap(old_mixed, 4, 4); const std::vector remap = bundle.consume_last_filament_id_remap(); REQUIRE(remap.size() >= 11); CHECK(remap[6] == virtual_id_for_stable_id(mixed, 4, stable_id_6)); CHECK(remap[7] == virtual_id_for_stable_id(mixed, 4, stable_id_7)); CHECK(remap[8] == virtual_id_for_stable_id(mixed, 4, stable_id_8)); } TEST_CASE("Mixed filament grouped manual patterns normalize and round-trip", "[MixedFilament]") { const std::vector colors = {"#FF0000", "#0000FF"}; 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("1/1/1/1/1/1/1/2, 1/1/1/2/1/1/1/1"); REQUIRE(row.manual_pattern == "11111112,11121111"); const std::string serialized = mgr.serialize_custom_entries(); MixedFilamentManager loaded; loaded.load_custom_entries(serialized, colors); REQUIRE(loaded.mixed_filaments().size() == 1); CHECK(loaded.mixed_filaments().front().manual_pattern == "11111112,11121111"); CHECK(loaded.mixed_filaments().front().mix_b_percent == 13); } TEST_CASE("Mixed filament component surface offsets round-trip and bias the second layer component", "[MixedFilament]") { const std::vector colors = {"#FF0000", "#FFFF00"}; MixedFilamentManager mgr; mgr.add_custom_filament(1, 2, 50, colors); REQUIRE(mgr.mixed_filaments().size() == 1); MixedFilament &row = mgr.mixed_filaments().front(); row.ratio_a = 1; row.ratio_b = 1; row.component_a_surface_offset = 0.02f; row.component_b_surface_offset = -0.01f; const std::string serialized = mgr.serialize_custom_entries(); CHECK(serialized.find("xa0.02") != std::string::npos); CHECK(serialized.find("xb-0.01") != std::string::npos); MixedFilamentManager loaded; loaded.load_custom_entries(serialized, colors); REQUIRE(loaded.mixed_filaments().size() == 1); const MixedFilament &loaded_row = loaded.mixed_filaments().front(); CHECK(loaded_row.component_a_surface_offset == Approx(0.02f)); CHECK(loaded_row.component_b_surface_offset == Approx(-0.01f)); CHECK(loaded.component_surface_offset(3, 2, 0) == Approx(0.01f)); CHECK(loaded.component_surface_offset(3, 2, 1) == Approx(0.0f)); } TEST_CASE("Mixed filament apparent mix percent follows the signed bias target", "[MixedFilament]") { CHECK(MixedFilamentManager::apparent_mix_b_percent(50, 0.00f, 0.00f, 0.4f) == 50); CHECK(MixedFilamentManager::apparent_mix_b_percent(50, 0.00f, 0.02f, 0.4f) == 45); CHECK(MixedFilamentManager::apparent_mix_b_percent(50, 0.02f, 0.00f, 0.4f) == 55); CHECK(MixedFilamentManager::apparent_mix_b_percent(50, -0.02f, 0.00f, 0.4f) == 45); CHECK(MixedFilamentManager::apparent_mix_b_percent(50, 0.00f, -0.02f, 0.4f) == 55); } TEST_CASE("Mixed filament bias helper maps signed bias to a one-sided safe offset pair", "[MixedFilament]") { const auto [offset_a, offset_b] = MixedFilamentManager::surface_offset_pair_from_signed_bias(0.06f, 0.4f); CHECK(offset_a == Approx(0.0f)); CHECK(offset_b == Approx(0.06f)); CHECK(MixedFilamentManager::bias_ui_value_from_surface_offsets(offset_a, offset_b, 0.4f) == Approx(0.06f)); CHECK(MixedFilamentManager::bias_ui_value_from_surface_offsets(0.02f, 0.0f, 0.4f) == Approx(-0.02f)); CHECK(MixedFilamentManager::bias_ui_value_from_surface_offsets(-0.02f, 0.0f, 0.4f) == Approx(0.02f)); const auto [negative_a, negative_b] = MixedFilamentManager::surface_offset_pair_from_signed_bias(-0.06f, 0.4f); CHECK(negative_a == Approx(0.06f)); CHECK(negative_b == Approx(0.0f)); const auto [unclamped_a, unclamped_b] = MixedFilamentManager::surface_offset_pair_from_signed_bias(0.30f, 0.4f); CHECK(unclamped_a == Approx(0.0f)); CHECK(unclamped_b == Approx(0.30f)); const auto [unclamped_negative_a, unclamped_negative_b] = MixedFilamentManager::surface_offset_pair_from_signed_bias(-0.30f, 0.4f); CHECK(unclamped_negative_a == Approx(0.30f)); CHECK(unclamped_negative_b == Approx(0.0f)); const auto [clamped_a, clamped_b] = MixedFilamentManager::surface_offset_pair_from_signed_bias(0.40f, 0.4f); CHECK(clamped_a == Approx(0.0f)); CHECK(clamped_b == Approx(0.35f)); const auto [clamped_negative_a, clamped_negative_b] = MixedFilamentManager::surface_offset_pair_from_signed_bias(-0.40f, 0.4f); CHECK(clamped_negative_a == Approx(0.35f)); CHECK(clamped_negative_b == Approx(0.0f)); } TEST_CASE("Mixed filament component surface offsets follow the signed bias target across alternating layers", "[MixedFilament]") { const std::vector colors = {"#FF0000", "#FFFF00"}; 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.clear(); row.distribution_mode = int(MixedFilament::Simple); row.ratio_a = 1; row.ratio_b = 1; { const auto [offset_a, offset_b] = MixedFilamentManager::surface_offset_pair_from_signed_bias(0.05f, 0.4f); row.component_a_surface_offset = offset_a; row.component_b_surface_offset = offset_b; CHECK(mgr.component_surface_offset(3, 2, 0) == Approx(0.0f)); CHECK(mgr.component_surface_offset(3, 2, 1) == Approx(0.05f)); CHECK(mgr.component_surface_offset(3, 2, 2) == Approx(0.0f)); CHECK(mgr.component_surface_offset(3, 2, 3) == Approx(0.05f)); } { row.component_a_surface_offset = 0.05f; row.component_b_surface_offset = 0.0f; CHECK(mgr.component_surface_offset(3, 2, 0) == Approx(0.05f)); CHECK(mgr.component_surface_offset(3, 2, 1) == Approx(0.0f)); CHECK(mgr.component_surface_offset(3, 2, 2) == Approx(0.05f)); CHECK(mgr.component_surface_offset(3, 2, 3) == Approx(0.0f)); } { const auto [offset_a, offset_b] = MixedFilamentManager::surface_offset_pair_from_signed_bias(-0.05f, 0.4f); row.component_a_surface_offset = offset_a; row.component_b_surface_offset = offset_b; CHECK(mgr.component_surface_offset(3, 2, 0) == Approx(0.05f)); CHECK(mgr.component_surface_offset(3, 2, 1) == Approx(0.0f)); CHECK(mgr.component_surface_offset(3, 2, 2) == Approx(0.05f)); CHECK(mgr.component_surface_offset(3, 2, 3) == Approx(0.0f)); } } TEST_CASE("Mixed filament auto generation can be disabled without dropping custom rows", "[MixedFilament]") { const std::vector colors = {"#FF0000", "#00FF00", "#0000FF"}; MixedFilamentManager enabled_mgr; enabled_mgr.auto_generate(colors); REQUIRE(enabled_mgr.mixed_filaments().size() == 3); const std::string serialized_auto_rows = enabled_mgr.serialize_custom_entries(); MixedAutoGenerateGuard guard(false); MixedFilamentManager mgr; mgr.add_custom_filament(1, 2, 50, colors); REQUIRE(mgr.mixed_filaments().size() == 1); mgr.auto_generate(colors); REQUIRE(mgr.mixed_filaments().size() == 1); CHECK(mgr.mixed_filaments().front().custom); CHECK(mgr.mixed_filaments().front().component_a == 1); CHECK(mgr.mixed_filaments().front().component_b == 2); MixedFilamentManager loaded; loaded.load_custom_entries(serialized_auto_rows, colors); CHECK(loaded.mixed_filaments().empty()); } TEST_CASE("Mixed filament perimeter resolver uses grouped manual patterns by inset", "[MixedFilament]") { const std::vector colors = {"#00FFFF", "#FF00FF"}; 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 unsigned int mixed_filament_id = 3; CHECK(mgr.resolve(mixed_filament_id, 2, 0) == 1); CHECK(mgr.resolve(mixed_filament_id, 2, 1) == 2); CHECK(mgr.resolve_perimeter(mixed_filament_id, 2, 0, 0) == 1); CHECK(mgr.resolve_perimeter(mixed_filament_id, 2, 1, 0) == 2); CHECK(mgr.resolve_perimeter(mixed_filament_id, 2, 0, 1) == 2); CHECK(mgr.resolve_perimeter(mixed_filament_id, 2, 1, 1) == 1); CHECK(mgr.resolve_perimeter(mixed_filament_id, 2, 0, 3) == 2); CHECK(mgr.resolve_perimeter(mixed_filament_id, 2, 1, 3) == 1); const std::vector ordered_layer0 = mgr.ordered_perimeter_extruders(mixed_filament_id, 2, 0); const std::vector ordered_layer1 = mgr.ordered_perimeter_extruders(mixed_filament_id, 2, 1); REQUIRE(ordered_layer0.size() == 2); REQUIRE(ordered_layer1.size() == 2); CHECK(ordered_layer0[0] == 1); CHECK(ordered_layer0[1] == 2); CHECK(ordered_layer1[0] == 2); CHECK(ordered_layer1[1] == 1); } TEST_CASE("Grouped manual perimeter patterns keep grouped resolution on collapsed single-tool layers", "[MixedFilament]") { const std::vector colors = {"#00FFFF", "#FF00FF"}; 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("2,12"); REQUIRE(row.manual_pattern == "2,12"); const unsigned int mixed_filament_id = 3; // The flattened row cadence resolves this layer to component A, but both // perimeter groups collapse onto physical filament 2. G-code generation // and tool ordering must keep using the grouped perimeter result here. CHECK(mgr.resolve(mixed_filament_id, 2, 1) == 1); const std::vector ordered_layer1 = mgr.ordered_perimeter_extruders(mixed_filament_id, 2, 1); REQUIRE(ordered_layer1.size() == 1); CHECK(ordered_layer1.front() == 2); CHECK(mgr.resolve_perimeter(mixed_filament_id, 2, 1, 0) == 2); CHECK(mgr.resolve_perimeter(mixed_filament_id, 2, 1, 1) == 2); CHECK(mgr.resolve_perimeter(mixed_filament_id, 2, 1, 2) == 2); } TEST_CASE("Grouped manual perimeter patterns resolve overlapping singleton inner groups", "[MixedFilament]") { const std::vector colors = {"#00FFFF", "#FF00FF"}; 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,1"); REQUIRE(row.manual_pattern == "12,1"); const unsigned int mixed_filament_id = 3; const std::vector ordered_layer0 = mgr.ordered_perimeter_extruders(mixed_filament_id, 2, 0); const std::vector ordered_layer1 = mgr.ordered_perimeter_extruders(mixed_filament_id, 2, 1); REQUIRE(ordered_layer0.size() == 1); CHECK(ordered_layer0.front() == 1); REQUIRE(ordered_layer1.size() == 2); CHECK(ordered_layer1[0] == 2); CHECK(ordered_layer1[1] == 1); CHECK(mgr.resolve_perimeter(mixed_filament_id, 2, 0, 0) == 1); CHECK(mgr.resolve_perimeter(mixed_filament_id, 2, 0, 1) == 1); CHECK(mgr.resolve_perimeter(mixed_filament_id, 2, 1, 0) == 2); CHECK(mgr.resolve_perimeter(mixed_filament_id, 2, 1, 1) == 1); CHECK(mgr.resolve_perimeter(mixed_filament_id, 2, 2, 0) == 1); CHECK(mgr.resolve_perimeter(mixed_filament_id, 2, 2, 1) == 1); } TEST_CASE("Grouped manual wall patterns make infill follow the innermost perimeter tool", "[MixedFilament]") { const std::vector colors = {"#00FFFF", "#FF00FF"}; 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,1"); REQUIRE(row.manual_pattern == "12,1"); PrintRegionConfig region_config = static_cast(FullPrintConfig::defaults()); region_config.wall_filament.value = 3; region_config.wall_loops.value = 2; region_config.enable_infill_filament_override.value = false; region_config.sparse_infill_density.value = 15.; region_config.sparse_infill_filament.value = 2; region_config.solid_infill_filament.value = 3; PrintRegion region(region_config); LayerTools layer0(0.2); layer0.layer_index = 0; layer0.object_layer_count = 6; layer0.layer_height = 0.2; layer0.mixed_mgr = &mgr; layer0.num_physical = 2; LayerTools layer1(0.4); layer1.layer_index = 1; layer1.object_layer_count = 6; layer1.layer_height = 0.2; layer1.mixed_mgr = &mgr; layer1.num_physical = 2; CHECK(layer0.wall_filament(region) == 0); CHECK(layer1.wall_filament(region) == 1); CHECK(layer0.sparse_infill_filament(region) == 0); CHECK(layer1.sparse_infill_filament(region) == 0); CHECK(layer0.solid_infill_filament(region) == 0); CHECK(layer1.solid_infill_filament(region) == 0); region_config.enable_infill_filament_override.value = true; region_config.sparse_infill_filament.value = 2; region_config.solid_infill_filament.value = 2; PrintRegion overridden_region(region_config); CHECK(layer0.sparse_infill_filament(overridden_region) == 1); CHECK(layer1.sparse_infill_filament(overridden_region) == 1); CHECK(layer0.solid_infill_filament(overridden_region) == 1); CHECK(layer1.solid_infill_filament(overridden_region) == 1); } TEST_CASE("Mixed filament painted-region resolver collapses ordinary mixed rows to the active physical extruder", "[MixedFilament]") { const std::vector colors = {"#FF0000", "#00FF00"}; MixedFilamentManager mgr; mgr.add_custom_filament(1, 2, 50, colors); REQUIRE(mgr.mixed_filaments().size() == 1); MixedFilament &row = mgr.mixed_filaments().front(); row.ratio_a = 1; row.ratio_b = 1; row.manual_pattern.clear(); row.distribution_mode = int(MixedFilament::Simple); CHECK(mgr.effective_painted_region_filament_id(3, 2, 0) == 1); CHECK(mgr.effective_painted_region_filament_id(3, 2, 1) == 2); } TEST_CASE("Mixed filament painted-region resolver preserves virtual channels for grouped and same-layer modes", "[MixedFilament]") { const std::vector colors = {"#00FFFF", "#FF00FF"}; 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"); CHECK(mgr.effective_painted_region_filament_id(3, 2, 0) == 3); row.component_a_surface_offset = 0.02f; row.component_b_surface_offset = -0.02f; CHECK(mgr.component_surface_offset(3, 2, 0) == Approx(0.0f)); row.manual_pattern.clear(); row.distribution_mode = int(MixedFilament::SameLayerPointillisme); CHECK(mgr.effective_painted_region_filament_id(3, 2, 0) == 3); CHECK(mgr.component_surface_offset(3, 2, 0) == Approx(0.0f)); } TEST_CASE("ExtrusionPath copies preserve inset index", "[MixedFilament]") { ExtrusionPath src(erPerimeter); src.inset_idx = 3; ExtrusionPath copied(src); CHECK(copied.inset_idx == 3); ExtrusionPath assigned(erExternalPerimeter); assigned.inset_idx = 0; assigned = src; CHECK(assigned.inset_idx == 3); } TEST_CASE("Extrusion loop and multipath entities preserve inset index", "[MixedFilament]") { ExtrusionPath src(erPerimeter); src.inset_idx = 2; ExtrusionMultiPath multi_from_path(src); CHECK(multi_from_path.inset_idx == 2); ExtrusionMultiPath multi_copy(multi_from_path); CHECK(multi_copy.inset_idx == 2); ExtrusionMultiPath multi_assigned; multi_assigned.inset_idx = 0; multi_assigned = multi_from_path; CHECK(multi_assigned.inset_idx == 2); ExtrusionLoop loop_from_path(src); CHECK(loop_from_path.inset_idx == 2); ExtrusionLoop loop_copy(loop_from_path); CHECK(loop_copy.inset_idx == 2); }