#include #include "libslic3r/GCodeReader.hpp" #include "test_helpers.hpp" #include #include #include #include #include using namespace Slic3r; using namespace Slic3r::Test; // 0-based tool indices used by extrusions whose role comment contains `role` (needs gcode_comments). static std::set tools_for_role(const std::string& gcode, const std::string& role) { std::set tools; int current_tool = 0; GCodeReader reader; reader.parse_buffer(gcode, [&](GCodeReader& self, const GCodeReader::GCodeLine& line) { const std::string cmd(line.cmd()); if (cmd.size() >= 2 && cmd[0] == 'T' && std::isdigit((unsigned char)cmd[1])) current_tool = std::stoi(cmd.substr(1)); else if (line.extruding(self) && std::string(line.comment()).find(role) != std::string::npos) tools.insert(current_tool); }); return tools; } // Tool index = filament id - 1; brim and skirt follow the wall filament. TEST_CASE("Each feature prints with its assigned filament", "[MultiFilament]") { auto [infill_filament, wall_filament] = GENERATE(table({ {1, 1}, {1, 2}, {2, 1}, {2, 2} })); DYNAMIC_SECTION("infill filament " << infill_filament << ", wall filament " << wall_filament) { const std::string gcode = slice({ cube(20) }, multifilament_config(2, { { "sparse_infill_filament_id", infill_filament }, { "internal_solid_filament_id", infill_filament }, { "top_surface_filament_id", infill_filament }, { "bottom_surface_filament_id", infill_filament }, { "outer_wall_filament_id", wall_filament }, { "inner_wall_filament_id", wall_filament }, { "skirt_loops", 1 }, { "brim_type", "outer_only" }, { "brim_width", 5 }, })); const std::set wall_tool{ wall_filament - 1 }; const std::set infill_tool{ infill_filament - 1 }; CHECK(tools_for_role(gcode, "perimeter") == wall_tool); CHECK(tools_for_role(gcode, "infill") == infill_tool); // sparse + solid + top/bottom CHECK(tools_for_role(gcode, "brim") == wall_tool); CHECK(tools_for_role(gcode, "skirt") == wall_tool); } } TEST_CASE("Each feature prints with its assigned filament (three filaments)", "[MultiFilament]") { const std::string gcode = slice({ cube(20) }, multifilament_config(3, { { "sparse_infill_filament_id", 2 }, { "internal_solid_filament_id", 2 }, { "top_surface_filament_id", 2 }, { "bottom_surface_filament_id", 2 }, { "outer_wall_filament_id", 3 }, { "inner_wall_filament_id", 3 }, { "skirt_loops", 0 }, { "brim_type", "no_brim" }, })); CHECK(tools_for_role(gcode, "perimeter") == std::set{ 2 }); // filament 3 CHECK(tools_for_role(gcode, "infill") == std::set{ 1 }); // filament 2 } // The override must survive tool ordering: object 1's walls print on their filament's // tool, object 0 stays on the first. If dropped, every wall prints on tool 0. TEST_CASE("Per-object wall filament override is honored", "[MultiFilament]") { const std::string gcode = slice_with_object_overrides( { cube(20), cube(20) }, multifilament_config(2, { { "skirt_loops", 0 }, { "brim_type", "no_brim" }, { "print_sequence", "by object" }, }), { {}, { { "outer_wall_filament_id", 2 }, { "inner_wall_filament_id", 2 } } }); CHECK(tools_for_role(gcode, "perimeter") == std::set{ 0, 1 }); CHECK(tools_for_role(gcode, "infill") == std::set{ 0 }); // infill not overridden: stays on F1 } // With wait_for_temp_on_wipe_tower the blocking M109 moves from right after the Tn command to // a stop point parked beside the wipe tower (heat-up drool falls next to the tower, not onto // its top): tagged with _WAIT_FOR_TEMP_ON_WIPE_TOWER, after the toolchange and before the // repositioning move and the first extrusion of the purge, while the post-toolchange restore // demotes to a non-blocking M104. Ordering and the off-tower stop are the contract here. TEST_CASE("Toolchange temperature wait moves to the wipe tower when enabled", "[MultiFilament]") { const bool wait_on_tower = GENERATE(false, true); DYNAMIC_SECTION("wait_for_temp_on_wipe_tower " << (wait_on_tower ? 1 : 0)) { const std::string gcode = slice_with_object_overrides( { cube(20), cube(20) }, multifilament_config(2, { { "nozzle_diameter", "0.4,0.4" }, { "printer_extruder_id", "1,2" }, { "printer_extruder_variant", "Direct Drive Standard,Direct Drive Standard" }, { "extruder_printable_height", "0,0" }, { "single_extruder_multi_material", 0 }, { "enable_prime_tower", 1 }, { "prime_tower_width", 35 }, { "wipe_tower_x", "50" }, { "wipe_tower_y", "50" }, { "ooze_prevention", 1 }, { "standby_temperature_delta", -40 }, { "wait_for_temp_on_wipe_tower", wait_on_tower ? 1 : 0 }, }), // One filament per object -> a toolchange on every layer. Assigned at the object // level: the used-filament count that gates the prime tower is derived from // object/volume configs on the harness's single apply (region filament ids such // as sparse_infill_filament_id are not counted there and the tower would be // silently disabled). { { { "extruder", 1 } }, { { "extruder", 2 } } }); // Split into lines and scan the "; CP TOOLCHANGE START".."; CP TOOLCHANGE END" blocks. std::vector lines; for (size_t pos = 0; pos < gcode.size();) { size_t eol = gcode.find('\n', pos); if (eol == std::string::npos) eol = gcode.size(); lines.emplace_back(gcode.substr(pos, eol - pos)); pos = eol + 1; } const auto is_tool_line = [](const std::string& l) { return l.size() >= 2 && l[0] == 'T' && std::isdigit((unsigned char)l[1]); }; const auto is_m109_line = [](const std::string& l) { return l.rfind("M109", 0) == 0; }; const auto is_tagged_wait = [](const std::string& l) { return l.find("_WAIT_FOR_TEMP_ON_WIPE_TOWER") != std::string::npos; }; const auto is_extruding = [](const std::string& l) { if (l.rfind("G1 ", 0) != 0) return false; const size_t e = l.find(" E"); return e != std::string::npos && l.find_first_of("XY") != std::string::npos && l[e + 2] != '-'; }; int checked_blocks = 0; for (size_t i = 0; i < lines.size(); ++i) { if (lines[i].find("; CP TOOLCHANGE START") == std::string::npos) continue; size_t block_end = i; while (block_end < lines.size() && lines[block_end].find("; CP TOOLCHANGE END") == std::string::npos) ++block_end; size_t tool_line = block_end; for (size_t j = i; j < block_end; ++j) if (is_tool_line(lines[j])) { tool_line = j; break; } if (tool_line == block_end) continue; // final unload block, no toolchange ++checked_blocks; size_t tagged_wait = block_end, untagged_m109 = block_end, first_extrusion = block_end; for (size_t j = tool_line + 1; j < block_end; ++j) { if (is_m109_line(lines[j]) && tagged_wait == block_end && is_tagged_wait(lines[j])) tagged_wait = j; if (is_m109_line(lines[j]) && untagged_m109 == block_end && !is_tagged_wait(lines[j])) untagged_m109 = j; if (first_extrusion == block_end && is_extruding(lines[j])) first_extrusion = j; } INFO("toolchange block at line " << i + 1); if (wait_on_tower) { // The only blocking wait is the tagged one, parked beside the tower before the purge. REQUIRE(tagged_wait < block_end); CHECK(untagged_m109 == block_end); REQUIRE(first_extrusion < block_end); CHECK(tagged_wait < first_extrusion); // The travel preceding the wait parks outside the tower footprint. The tower // auto-sizes, so derive its extent from the purge extrusions of this block. size_t stop_line = block_end; for (size_t j = tagged_wait; j-- > tool_line;) if (lines[j].rfind("G1 ", 0) == 0 && lines[j].find('X') != std::string::npos) { stop_line = j; break; } REQUIRE(stop_line < block_end); const double stop_x = std::stod(lines[stop_line].substr(lines[stop_line].find('X') + 1)); double purge_min_x = std::numeric_limits::max(), purge_max_x = std::numeric_limits::lowest(); for (size_t j = tagged_wait; j < block_end; ++j) { const size_t x_pos = lines[j].find('X'); if (!is_extruding(lines[j]) || x_pos == std::string::npos) continue; const double x = std::stod(lines[j].substr(x_pos + 1)); purge_min_x = std::min(purge_min_x, x); purge_max_x = std::max(purge_max_x, x); } REQUIRE(purge_min_x <= purge_max_x); INFO("stop travel: " << lines[stop_line] << " purge x range: " << purge_min_x << ".." << purge_max_x); const bool beside_tower = stop_x < purge_min_x - 0.5 || stop_x > purge_max_x + 0.5; CHECK(beside_tower); } else { // Stock behavior: the blocking wait follows the toolchange command directly. REQUIRE(untagged_m109 < block_end); CHECK(tagged_wait == block_end); if (first_extrusion < block_end) CHECK(untagged_m109 < first_extrusion); } i = block_end; } REQUIRE(checked_blocks > 0); if (!wait_on_tower) CHECK(gcode.find("_WAIT_FOR_TEMP_ON_WIPE_TOWER") == std::string::npos); } } // max_layer_height can be shorter than the extruder count (normalization sizes it to the // filament count under single_extruder_multi_material). calc_max_layer_height() in ToolOrdering // indexed it per-nozzle and read past the end. Shortened directly here to isolate that read; // the other per-extruder keys stay extruder-length so slicing reaches the code under test. TEST_CASE("Multi-extruder slice stays in bounds with a short max_layer_height", "[MultiFilament]") { DynamicPrintConfig config = multifilament_config(2); config.set_deserialize_strict({ { "nozzle_diameter", "0.4,0.4" }, { "printer_extruder_id", "1,2" }, { "printer_extruder_variant", "Direct Drive Standard,Direct Drive Standard" }, { "extruder_printable_height", "0,0" }, { "max_layer_height", "0.3" }, // deliberately one entry short }); Print print; init_and_process_print({ cube(20) }, print, config); REQUIRE_FALSE(print.objects().front()->layers().empty()); }