#include #include "libslic3r/GCode/GCodeProcessor.hpp" #include "libslic3r/GCodeReader.hpp" #include "test_helpers.hpp" #include "test_utils.hpp" #include #include #include #include #include #include #include #include #include #include #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; } // X where the nozzle sits while each tagged _WAIT_FOR_TEMP_ON_WIPE_TOWER M109 blocks: // the nearest preceding G1 carrying an X (the park travel emitted just before the wait). static std::vector wait_park_xs(const std::string& gcode) { std::vector lines; std::istringstream stream(gcode); for (std::string line; std::getline(stream, line);) lines.emplace_back(std::move(line)); std::vector xs; for (size_t i = 0; i < lines.size(); ++i) { if (lines[i].rfind("M109", 0) != 0 || lines[i].find("_WAIT_FOR_TEMP_ON_WIPE_TOWER") == std::string::npos) continue; for (size_t j = i; j-- > 0;) { if (lines[j].rfind("G1 ", 0) != 0) continue; const size_t x_pos = lines[j].find('X'); if (x_pos == std::string::npos) continue; xs.push_back(std::stod(lines[j].substr(x_pos + 1))); break; } } return xs; } // Estimated print time at each 1-based line of an exported G-code file, from a second // GCodeProcessor pass over it. MoveVertex::time is the duration of one move and gcode_id is the // line it came from (already rebased past the M73 insertions), so the running sum before the first // move of a line is the elapsed time at that line. The file carries its own config footer, so // process_file configures the processor -- including the shared s_IsBBLPrinter static that other // tests in this binary mutate -- from the settings the export itself used. static std::vector elapsed_time_by_line(const std::string& gcode) { ScopedTemporaryFile temp_gcode(".gcode"); { std::ofstream os(temp_gcode.string()); os << gcode; } GCodeProcessor processor; processor.process_file(temp_gcode.string()); constexpr size_t NORMAL = size_t(PrintEstimatedStatistics::ETimeMode::Normal); const size_t n_lines = size_t(std::count(gcode.begin(), gcode.end(), '\n')) + 2; std::vector elapsed(n_lines, 0.); double running = 0.; size_t next = 0; for (const auto& move : processor.get_result().moves) { const size_t id = std::min(move.gcode_id, n_lines - 1); while (next <= id) elapsed[next++] = running; running += move.time[NORMAL]; } while (next < n_lines) elapsed[next++] = running; return elapsed; } // The temperature-relevant projection of `gcode`: every M104/M109/Tn line, plus the toolchange and // priming markers that anchor them, in order. A preheat -- an M104 the GCodeProcessor backtrace // inserts mid-object, outside any block, naming a tool other than the one currently loaded -- also // carries "lead s", the estimated time from there to the tool change it heats for, which is the // property preheat_time controls. No other temperature command gets one: for an M104 retargeting // the active tool (the first-layer-to-other-layers bump) or one inside a block, the distance to the // next Tn is a layer time or a handful of moves and says nothing about preheat_time. Everything // else is dropped, so the trace does not move when travel, tower geometry or line numbering do. static std::vector temperature_trace(const std::string& gcode) { std::vector lines; std::istringstream stream(gcode); for (std::string line; std::getline(stream, line);) { line.erase(0, line.find_first_not_of(" \t")); while (!line.empty() && (line.back() == '\r' || line.back() == ' ' || line.back() == '\t')) line.pop_back(); lines.emplace_back(std::move(line)); } const std::vector elapsed = elapsed_time_by_line(gcode); const auto is_tool = [](const std::string& l) { return l.size() >= 2 && l[0] == 'T' && std::isdigit((unsigned char) l[1]); }; const auto is_temp = [](const std::string& l) { return l.rfind("M104", 0) == 0 || l.rfind("M109", 0) == 0; }; const auto marker = [](const std::string& l) -> const char* { for (const char* m : { "; CP TOOLCHANGE START", "; CP TOOLCHANGE END", "; CP PRIMING START", "; CP PRIMING END" }) if (l.find(m) != std::string::npos) return m; return nullptr; }; // Tool a "T" line, or the "T" argument of an M104, names -- or -1 when it names none. const auto tool_of = [&is_tool](const std::string& l) -> int { size_t t = std::string::npos; // index of the 'T' if (is_tool(l)) t = 0; else if (l.rfind("M104", 0) == 0 && l.find(" T") != std::string::npos) t = l.find(" T") + 1; if (t == std::string::npos || t + 1 >= l.size() || !std::isdigit((unsigned char) l[t + 1])) return -1; return std::stoi(l.substr(t + 1)); }; std::vector trace; bool in_block = false; int current_tool = -1; for (size_t i = 0; i < lines.size(); ++i) { if (const char* m = marker(lines[i])) { in_block = std::string(m).find("START") != std::string::npos; trace.emplace_back(m); // the marker alone: some carry a trailing tool id, some do not } else if (is_tool(lines[i]) || is_temp(lines[i])) { std::string entry = lines[i]; const int named = tool_of(lines[i]); if (!in_block && lines[i].rfind("M104", 0) == 0 && current_tool != -1 && named != -1 && named != current_tool) { size_t tn = i; while (tn < lines.size() && !is_tool(lines[tn])) ++tn; if (tn < lines.size()) { char lead[32]; std::snprintf(lead, sizeof(lead), "\tlead %.1fs", elapsed[tn + 1] - elapsed[i + 1]); entry += lead; } } if (is_tool(lines[i])) current_tool = named; trace.emplace_back(std::move(entry)); } } return trace; } // "M104 S240 T0 ; preheat T0 time: 31slead 30.9s" carries the same quantity twice, and both // vary by toolchain: the backtrace picks the first line at least preheat_time out, so a sub-tenth // difference in the estimate selects a neighbouring move and "lead" steps by that move's duration. // Tolerate "lead", still far below the tens of seconds a displaced preheat would shift it. Check // "time:" against its own entry's "lead" instead of across runs -- being a rounding of it, that // still catches a change in how it is derived without tracking the absolute estimate. static constexpr double TRACE_TIME_TOLERANCE_S = 1.5; static constexpr double TRACE_ROUNDING_SLACK_S = 0.05; // correct rounding keeps |time - lead| <= 0.5 struct TraceEntry { std::string text; // timing values replaced by a placeholder std::optional time_s; std::optional lead_s; }; static TraceEntry parse_trace_entry(const std::string& entry) { TraceEntry out; std::string text = entry; // Split off the tail only when it really is a "lead s", so an unexpected one still compares. const size_t tab = text.find('\t'); if (tab != std::string::npos) { const std::string tail = text.substr(tab + 1); // "lead 30.2s" const size_t sp = tail.find(' '); if (sp != std::string::npos && sp + 1 < tail.size() && std::isdigit(static_cast(tail[sp + 1]))) { out.lead_s = std::stod(tail.substr(sp + 1)); text.erase(tab); } } static constexpr std::string_view k_time = "time: "; const size_t at = text.find(k_time); // Require a digit first: a dots-only run would otherwise reach std::stod and throw. if (at != std::string::npos && at + k_time.size() < text.size() && std::isdigit(static_cast(text[at + k_time.size()]))) { const size_t first = at + k_time.size(); size_t last = first; while (last < text.size() && (std::isdigit(static_cast(text[last])) || text[last] == '.')) ++last; out.time_s = std::stod(text.substr(first, last - first)); text.replace(first, last - first, ""); // surrounding text, incl. the "s", still compared } out.text = std::move(text); return out; } static bool timings_match(const std::optional& a, const std::optional& b) { if (a.has_value() != b.has_value()) return false; return !a.has_value() || std::abs(*a - *b) <= TRACE_TIME_TOLERANCE_S; } // "time:" must be its own entry's "lead" rounded to a whole second. static bool time_is_rounded_lead(const TraceEntry& e) { if (!e.time_s.has_value() || !e.lead_s.has_value()) return true; // nothing to cross-check return std::abs(*e.time_s - *e.lead_s) <= 0.5 + TRACE_ROUNDING_SLACK_S; } // `a` is the slice under test, `b` the recorded golden. static bool trace_entries_match(const std::string& a, const std::string& b) { const auto x = parse_trace_entry(a); const auto y = parse_trace_entry(b); if (x.text != y.text) return false; // A field appearing or disappearing is a real change even though the values are tolerated. if (x.time_s.has_value() != y.time_s.has_value()) return false; return timings_match(x.lead_s, y.lead_s) && time_is_rounded_lead(x); } // 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. The restore that used to block there // demotes to a non-blocking M104 and moves ahead of the Tn, so the incoming tool heats up over // the change itself. 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 }, // The post-processor's own preheat pass also inserts an M104 for the incoming // filament ahead of the Tn; switch it off so the temperature commands under test // are the only ones in the toolchange block. { "preheat_time", 0 }, { "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; std::istringstream gcode_stream(gcode); for (std::string line; std::getline(gcode_stream, line);) lines.emplace_back(std::move(line)); 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; }; // A non-blocking set-temperature naming one specific tool, e.g. "M104 S255 T1". const auto is_m104_for_tool = [](const std::string& l, int tool) { if (l.rfind("M104", 0) != 0) return false; const std::string token = " T" + std::to_string(tool); const size_t at = l.find(token); return at != std::string::npos && !std::isdigit((unsigned char)l[at + token.size()]); }; 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; // Where the incoming tool's target temperature is raised, relative to its Tn. const int new_tool = std::stoi(lines[tool_line].substr(1)); size_t preheat = tool_line, restore = block_end; for (size_t j = i; j < tool_line; ++j) if (is_m104_for_tool(lines[j], new_tool)) { preheat = j; break; } for (size_t j = tool_line + 1; j < block_end; ++j) if (is_m104_for_tool(lines[j], new_tool)) { restore = j; break; } 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); // The target is raised ahead of the toolchange, so the incoming tool heats up // while it is picked up, and nothing sets it again afterwards. CHECK(preheat < tool_line); CHECK(restore == 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, and // nothing raises the incoming tool's target before it. REQUIRE(untagged_m109 < block_end); CHECK(tagged_wait == block_end); CHECK(preheat == tool_line); 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); } } // Priming runs before the first layer is set up, so set_extruder sees no layer at all: its // on_first_layer() test is false and print_z is the initial layer height rather than 0. The // tower nonetheless blocks on the first layer temperature there, so the pre-heat raised ahead // of each priming Tn has to name that same temperature — pre-heating to the "other layers" // value instead leaves the tagged M109 asking the firmware to cool back down before the // priming lines are extruded. TEST_CASE("Wipe tower priming pre-heats to the first layer temperature", "[MultiFilament]") { 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 }, { "single_extruder_multi_material_priming", 1 }, { "enable_prime_tower", 1 }, { "prime_tower_width", 35 }, { "wipe_tower_x", "50" }, { "wipe_tower_y", "50" }, { "preheat_time", 0 }, // see the wait test above // Distinct enough that picking the wrong one is unambiguous. { "nozzle_temperature_initial_layer", "215,215" }, { "nozzle_temperature", "240,240" }, { "wait_for_temp_on_wipe_tower", 1 }, }), { { { "extruder", 1 } }, { { "extruder", 2 } } }); std::vector lines; std::istringstream gcode_stream(gcode); for (std::string line; std::getline(gcode_stream, line);) lines.emplace_back(std::move(line)); // Temperature of an M104/M109, or -1 when the line is neither. const auto temp_of = [](const std::string& l) { if (l.rfind("M104", 0) != 0 && l.rfind("M109", 0) != 0) return -1; const size_t s = l.find('S'); return s == std::string::npos ? -1 : std::stoi(l.substr(s + 1)); }; size_t start = lines.size(), end = lines.size(); for (size_t i = 0; i < lines.size(); ++i) { if (start == lines.size() && lines[i].find("; CP PRIMING START") != std::string::npos) start = i; else if (start < lines.size() && lines[i].find("; CP PRIMING END") != std::string::npos) { end = i; break; } } REQUIRE(start < end); int checked_waits = 0; for (size_t i = start; i < end; ++i) { if (lines[i].find("_WAIT_FOR_TEMP_ON_WIPE_TOWER") == std::string::npos) continue; ++checked_waits; INFO("priming wait at line " << i + 1 << ": " << lines[i]); CHECK(temp_of(lines[i]) == 215); // the tower waits on the first layer temperature // The most recent set-temperature before it is the pre-heat, and must agree with it. int preheat = -1; for (size_t j = i; j-- > start;) if ((preheat = temp_of(lines[j])) != -1) break; CHECK(preheat == 215); } REQUIRE(checked_waits > 0); // the feature under test is active } // The temperature-wait park picks its side of the tower by testing bed containment with the // tower position at psWipeTower generation time, while WipeTowerIntegration shifts the cached // moves by the CURRENT position at export. Moving the tower normally invalidates only // psSkirtBrim (tower gcode is position-independent), but the park makes it bed-relative, so a // GUI-style move-and-reslice on the same Print must regenerate the tower — otherwise the stale // park prints outside the bed. Contract: every tagged wait parks inside the printable area. TEST_CASE("Wipe tower temperature-wait park is regenerated when the tower moves", "[MultiFilament]") { // Two objects, one filament each: a toolchange (and a tagged wait) on every layer, like // the wait test above — but on a single-extruder machine profile: the synthetic // dual-extruder keys would drag in the extruder-variant expansion, which is not // idempotent on the default machine profile and would pollute the re-apply diff below. // Rectangle wall and no brim keep the tower-local footprint inside [0, 35], so the park // sits at the generator's 2mm side gap: local -2 or 37. DynamicPrintConfig config = multifilament_config(2, { { "single_extruder_multi_material", 0 }, { "enable_prime_tower", 1 }, { "prime_tower_width", 35 }, { "wipe_tower_wall_type", "rectangle" }, // the default rib bulges past the width { "prime_tower_brim_width", 0 }, // the default 3 widens the first-layer envelope { "printable_area", "0x0,200x0,200x200,0x200" }, { "wipe_tower_x", "0" }, { "wipe_tower_y", "50" }, { "ooze_prevention", 1 }, { "standby_temperature_delta", -40 }, { "wait_for_temp_on_wipe_tower", 1 }, }); // init_print force-sets this on its own copy; set it here too so the re-apply below // diffs in wipe_tower_x ONLY — the exact GUI increment under test. config.set_key_value("gcode_comments", new ConfigOptionBool(true)); Print print; Model model; const std::vector> overrides{ { { "extruder", 1 } }, { { "extruder", 2 } } }; // object-level, see the wait test above init_print(std::vector{ cube(20), cube(20) }, print, model, config, &overrides); const std::string at_edge = gcode(print); const std::vector at_edge_parks = wait_park_xs(at_edge); REQUIRE(!at_edge_parks.empty()); // the feature under test is active for (double x : at_edge_parks) { INFO("wait park X " << x << " with the tower at x=0 on a 200mm bed"); CHECK(x >= -0.05); CHECK(x <= 200.05); } REQUIRE(print.is_step_done(psWipeTower)); // Move the tower to the right bed edge (164 + 35 = 199 keeps the body printable) and // re-apply on the SAME Print, as the GUI does. Base the re-apply on the print's own // resolved config so the diff is wipe_tower_x alone — re-applying the caller's config // would also diff the apply-time extruder normalization write-backs, and those keys // regenerate the tower for the wrong reason. The cached right-side park would export // at 164 + 37 = 201, off the bed; regeneration clamps the park against the bed edge. // Assemble the moved config exactly the way init_print assembled the first one — the // apply-time normalization is only idempotent when both applies start from the same // derivation, and any stray diff key would regenerate the tower for the wrong reason. config.set_deserialize_strict({ { "wipe_tower_x", "164" } }); DynamicPrintConfig moved_config = DynamicPrintConfig::full_print_config(); moved_config.apply(config); moved_config.set_key_value("gcode_comments", new ConfigOptionBool(true)); print.apply(model, moved_config); CHECK_FALSE(print.is_step_done(psWipeTower)); // the move must re-generate the tower const std::string moved = gcode(print); const std::vector moved_parks = wait_park_xs(moved); REQUIRE(!moved_parks.empty()); // the waits must survive the re-slice for (double x : moved_parks) { INFO("wait park X " << x << " with the tower at x=164 on a 200mm bed"); CHECK(x >= -0.05); CHECK(x <= 200.05); } } // The flag-off half of the three tests above. Every site wait_for_temp_on_wipe_tower touches is // guarded -- set_extruder's pre-toolchange preheat block and its post_toolchange skip, // toolchange_Change's park, the interface-temp guard in WipeTower2::tool_change, and append_tcr2's // tagged-M109 filter -- so with the option off the feature has to be inert and temperature emission // has to stay exactly as it was before the option existed. That is pinned against a trace captured // from main rather than against expectations written from the current code, which would be // re-derived from the very code they are meant to guard. // // Note what main emits here, since it is easy to misread as a missing wait: with preheat_time set, // the toolchange carries no blocking M109 at all. GCodeProcessor's backtrace moves the heat-up to // an M104 preheat_time seconds earlier and demotes the in-place command, which is the entire point // of preheating. The lead times below are what pin that placement. TEST_CASE("Toolchange temperature commands are unchanged when the wipe tower wait is off", "[MultiFilament][Regression]") { // 20x20x5 cubes at the default 0.2mm layer height are 25 layers, one filament each, so there is // a toolchange -- and a preheat ahead of it -- on every layer. const std::string gcode = slice_with_object_overrides( { make_cube(20., 20., 5.), make_cube(20., 20., 5.) }, 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 }, { "single_extruder_multi_material_priming", 1 }, // reaches toolchange_Change's priming path { "enable_prime_tower", 1 }, { "prime_tower_width", 35 }, { "wipe_tower_x", "50" }, { "wipe_tower_y", "50" }, // GCodeProcessor::apply_config enables the preheat backtrace on // ooze_prevention && preheat_time > 0 && !SEMM && filaments > 1. That is what puts an // M104 preheat_time seconds ahead of every Tn, and it also gives set_extruder's // standby/restore pair, which the option demotes and moves when it is on. { "ooze_prevention", 1 }, { "standby_temperature_delta", -40 }, { "preheat_time", 30 }, { "preheat_steps", 1 }, // enable_tower_interface_features is deliberately left off: the interface temperature // is observable only through a change_filament_gcode template that reads // new_filament_temp, since append_tcr2 strips the tower's own M109 for it, and the // default template here has none. The option's interface-temp guard is covered by the // enabled-path tests above instead. // // Distinct enough that a wrong pick between the two is unambiguous in the trace. { "nozzle_temperature_initial_layer", "215,215" }, { "nozzle_temperature", "240,240" }, { "wait_for_temp_on_wipe_tower", 0 }, }), // Object-level, so the used-filament count that gates the prime tower is derived from it. { { { "extruder", 1 } }, { { "extruder", 2 } } }); const std::vector trace = temperature_trace(gcode); REQUIRE(trace.size() > 1); CHECK(gcode.find("_WAIT_FOR_TEMP_ON_WIPE_TOWER") == std::string::npos); const std::string golden_path = std::string(TEST_DATA_DIR PATH_SEPARATOR "wipe_tower_temperature_trace_main.txt"); // Regenerate by appending this test and its helpers to the same file on main (dropping the // wait_for_temp_on_wipe_tower key, which main's config does not know), rebuilding // fff_print_tests there, running it with ORCA_UPDATE_WIPE_TOWER_TEMP_TRACE=1, copying the file // it writes back here, and filling in the commit it was captured from. if (std::getenv("ORCA_UPDATE_WIPE_TOWER_TEMP_TRACE") != nullptr) { std::ofstream out(golden_path); REQUIRE(out.good()); out << "# Temperature and tool-change commands of a wait_for_temp_on_wipe_tower-off slice,\n" "# captured from the main branch at . Regeneration is described\n" "# at the test that reads this file: \"Toolchange temperature commands are unchanged\n" "# when the wipe tower wait is off\" in tests/fff_print/test_multifilament.cpp.\n"; for (const std::string& entry : trace) out << entry << "\n"; WARN("Rewrote " << golden_path << " from this run; it no longer reflects main."); return; } std::vector golden; { std::ifstream in(golden_path); INFO("reading " << golden_path); REQUIRE(in.good()); for (std::string line; std::getline(in, line);) { if (!line.empty() && line.back() == '\r') line.pop_back(); if (!line.empty() && line[0] != '#') golden.push_back(std::move(line)); } } REQUIRE(!golden.empty()); // Reported separately from the golden comparison below: it is a different failure. for (size_t i = 0; i < trace.size(); ++i) { const auto entry = parse_trace_entry(trace[i]); if (time_is_rounded_lead(entry)) continue; INFO("at trace entry " << i + 1); INFO(" " << trace[i]); FAIL("\"time:\" is not its entry's \"lead\" rounded to a whole second"); } const size_t common = std::min(trace.size(), golden.size()); for (size_t i = 0; i < common; ++i) { if (trace_entries_match(trace[i], golden[i])) continue; // Report the first difference only: past it the two are misaligned and every later entry // would be reported as a difference too. INFO("first difference at trace entry " << i + 1); INFO(" main: " << golden[i]); INFO(" branch: " << trace[i]); FAIL("temperature emission differs from main with wait_for_temp_on_wipe_tower off"); } CHECK(trace.size() == golden.size()); } // 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()); } // Shared IMEX printer geometry: 7 logical extruders across 4 physical heads. // physical_extruder_map is only honoured when its length matches the nozzle count // (PrintApply feeds effective_physical_extruder_map the nozzle_diameter size), so the // nozzle keys must be sized to 7 or the map is silently replaced with the identity and // every logical slot resolves to its own head -- which hides the defects under test. static void imex_7x4_printer(DynamicPrintConfig &config) { config.set_deserialize_strict({ { "nozzle_diameter", "0.4,0.4,0.4,0.4,0.4,0.4,0.4" }, { "printer_extruder_id", "1,2,3,4,5,6,7" }, { "printer_extruder_variant", "Direct Drive Standard,Direct Drive Standard,Direct Drive Standard," "Direct Drive Standard,Direct Drive Standard,Direct Drive Standard," "Direct Drive Standard" }, { "extruder_printable_height", "0,0,0,0,0,0,0" }, { "physical_extruder_map", "0,0,0,0,1,2,3" }, { "is_imex", "1" }, { "imex_mode_names", "primary;copy" }, { "imex_mode_active_tools", "0:P;0:P,1:C" }, { "skirt_loops", "0" }, { "brim_type", "no_brim" }, // Temperature assertions below spell "M104 S T"; RepRapFirmware would emit // "G10 S P" from the same code, so the flavor is pinned rather than defaulted. { "gcode_flavor", "klipper" }, }); } // Route every region to one filament. An unset *_filament_id is not "inherit": // clamp_feature_filament_to_valid rewrites <=0 to 1, which would drag tool 0 into // tool_ordering and mask what these tests assert. PrintObject.cpp's call to that // function is the source of truth for this key list -- a new one has to be added here. static void all_regions_on_filament(DynamicPrintConfig &config, int filament_1based) { for (const char *key : { "outer_wall_filament_id", "inner_wall_filament_id", "sparse_infill_filament_id", "internal_solid_filament_id", "top_surface_filament_id", "bottom_surface_filament_id" }) config.set_deserialize_strict({ { key, std::to_string(filament_1based) } }); } // IMEX parallel modes emit per-carriage temperatures from a branch that is mutually // exclusive with the standard per-extruder path, and that branch skipped the head the // print's own toolpaths run on. That head therefore never received its 1st->2nd layer // transition and held nozzle_temperature_initial_layer for the whole job. TEST_CASE("Parallel-mode IMEX prints transition the printing head to its second-layer temperature", "[MultiFilament][IMEX]") { DynamicPrintConfig config = multifilament_config(7); imex_7x4_printer(config); all_regions_on_filament(config, 1); // filament 1 => logical slot 0 => physical head 0 // The multi-extruder normalization collapses per-filament temperature vectors to a // single value, so heads are told apart by their tool qualifier, not by temperature. config.set_deserialize_strict({ { "imex_parallel_mode", "copy" }, { "nozzle_temperature_initial_layer", "200" }, { "nozzle_temperature", "240" }, }); const std::string gcode = slice({ cube(20) }, config); // Head 0 runs the print's own toolpaths and must step 200 -> 240 at the second layer. CHECK(gcode.find("M104 S240 T0") != std::string::npos); // Head 1 is the copy carriage; it already worked and must keep working. CHECK(gcode.find("M104 S240 T1") != std::string::npos); } // IMEX supplements is_extruder_used for the secondary carriages a parallel mode drives. // `primary` drives exactly one tool, so the supplement must not run: routing every region // to filament 6 puts the initial tool on physical head 2, while the mode's only declared // head is 0, which the unguarded supplement resolved back to filament slot 0. TEST_CASE("Primary-mode IMEX prints mark only the filament slot they print with", "[MultiFilament][IMEX]") { DynamicPrintConfig config = multifilament_config(7); imex_7x4_printer(config); all_regions_on_filament(config, 6); // filament 6 => logical slot 5 => physical head 2 config.set_deserialize_strict({ { "imex_parallel_mode", "primary" }, { "machine_start_gcode", ";USED0:{if is_extruder_used[0]}1{else}0{endif}\n" ";USED5:{if is_extruder_used[5]}1{else}0{endif}\n" }, }); const std::string gcode = slice({ cube(20) }, config); CHECK(gcode.find(";USED5:1") != std::string::npos); CHECK(gcode.find(";USED0:0") != std::string::npos); } // Guard rail for the fix above: the modes the supplement exists for must keep marking their // secondaries. Printed on filament 1 (slot 0), which pem routes to head 0 -- the head `copy` // declares Primary -- so the plate is well-formed and validate() lets it through. Head 0 is // the initial tool's head and is skipped (tool_ordering already marked slot 0); head 1 is the // secondary and resolves to slot 4. // // This case previously printed on filament 6, which routes to head 2 while `copy` declares // head 0 Primary. Print::validate() now refuses that plate outright (no filament on it can // feed the Primary tool), so asserting it slices correctly would contradict // "An IMEX plate whose filament never routes to the primary carriage is blocked" below. // slice() does not surface validate()'s return, so the contradiction would have gone unnoticed. TEST_CASE("Copy-mode IMEX prints still mark every secondary carriage's filament slot", "[MultiFilament][IMEX]") { DynamicPrintConfig config = multifilament_config(7); imex_7x4_printer(config); all_regions_on_filament(config, 1); config.set_deserialize_strict({ { "imex_parallel_mode", "copy" }, { "machine_start_gcode", ";USED0:{if is_extruder_used[0]}1{else}0{endif}\n" ";USED4:{if is_extruder_used[4]}1{else}0{endif}\n" ";USED5:{if is_extruder_used[5]}1{else}0{endif}\n" }, }); const std::string gcode = slice({ cube(20) }, config); CHECK(gcode.find(";USED0:1") != std::string::npos); // the filament actually printed CHECK(gcode.find(";USED4:1") != std::string::npos); // head 1, the secondary carriage CHECK(gcode.find(";USED5:0") != std::string::npos); } // The two IMEX changes are coupled: get_imex_active_tools() now returns an empty roster in // primary mode, so if the temperature branch ever stopped excluding primary it would enter, // emit nothing, skip the standard path, and silently restore the bug the copy-mode case above // covers -- with every other test still green. TEST_CASE("Primary-mode IMEX prints still transition to the second-layer temperature", "[MultiFilament][IMEX]") { DynamicPrintConfig config = multifilament_config(7); imex_7x4_printer(config); all_regions_on_filament(config, 1); config.set_deserialize_strict({ { "imex_parallel_mode", "primary" }, { "nozzle_temperature_initial_layer", "200" }, { "nozzle_temperature", "240" }, }); const std::string gcode = slice({ cube(20) }, config); CHECK(gcode.find("M104 S240") != std::string::npos); } // A plate carries its IMEX mode as a name, matched against the printer's imex_mode_names at // slice time, so a mode renamed or deleted underneath the plate -- or a project opened against a // preset that names its modes differently -- leaves the plate pointing at nothing. That used to // take every "not Primary" branch in the exporter while every name-keyed lookup came back empty, // which is worse than either interpretation on its own: // * the 1st->2nd layer temperature branch is mutually exclusive with the standard one, so an // empty active-tool roster meant NO head was transitioned and every one of them held // nozzle_temperature_initial_layer for the whole print; and // * the initial T was suppressed on the assumption that the mode's setup script would // select the tool, while that script -- resolved by the same name -- did not exist. // Print::validate() does not catch it either: the unresolved name yields an empty tools string, // so there is no declared primary and its IMEX routing guard is skipped. // // Falling back to Primary is what makes the file coherent again. Asserted on the two emissions // that were actually broken rather than on the mode string, which the placeholder test in // test_imex_mode_gcode.cpp covers. TEST_CASE("An IMEX plate set to a mode the printer no longer defines slices as Primary", "[MultiFilament][IMEX]") { DynamicPrintConfig config = multifilament_config(7); imex_7x4_printer(config); all_regions_on_filament(config, 1); // filament 1 => logical slot 0 => physical head 0 config.set_deserialize_strict({ // imex_mode_names is "primary;copy" -- this is `copy` after a rename. { "imex_parallel_mode", "copy-renamed" }, { "nozzle_temperature_initial_layer", "200" }, { "nozzle_temperature", "240" }, }); const std::string gcode = slice({ cube(20) }, config); // The head the toolpaths run on steps 200 -> 240 at the second layer, via the standard // per-extruder path a Primary-mode print uses. CHECK(gcode.find("M104 S240") != std::string::npos); // ...and only that head, addressed the way a single-head print addresses it: nothing drives // a copy carriage here, so the fallback goes through the standard per-extruder path, where // only filament slot 0 prints. GCodeWriter::set_temperature therefore sees // multiple_extruders == false and emits no tool qualifier at all. Excluding the whole // " T" suffix rather than " T1" is what makes that the assertion: a regression that routed // the transition to T2 or T3 instead would satisfy an exclusion of T1, and the unqualified // find above is itself prefix-satisfied by any "M104 S240 T". CHECK(gcode.find("M104 S240 T") == std::string::npos); // The initial tool selection is emitted. Matched as a line-leading token rather than a whole // line so the assertion does not depend on the trailing "; change extruder" comment, which // is switched off by a global unrelated to IMEX. bool selects_initial_tool = false; std::istringstream tool_lines(gcode); std::string line; while (std::getline(tool_lines, line)) if (line.rfind("T0", 0) == 0) { selects_initial_tool = true; break; } CHECK(selects_initial_tool); } // IQEX: when the second gantry is active the mode drives all four carriages, so every one of // them needs its own filament resolved -- for the first layer via is_extruder_used (consumed by // machine_start_gcode) and for the second via the per-tool transition. pem routes filament 1 to // head 0, and heads 1/2/3 to filament slots 4/5/6, so all four slots must appear. TEST_CASE("IQEX modes emit first- and second-layer temperatures for every active carriage", "[MultiFilament][IMEX]") { DynamicPrintConfig config = multifilament_config(7); imex_7x4_printer(config); all_regions_on_filament(config, 1); config.set_deserialize_strict({ { "imex_mode_names", "primary;copy;iq-copy" }, { "imex_mode_active_tools", "0:P;0:P,1:C;0:P,1:C,2:C,3:C" }, { "imex_parallel_mode", "iq-copy" }, { "nozzle_temperature_initial_layer", "200" }, { "nozzle_temperature", "240" }, { "machine_start_gcode", ";USED0:{if is_extruder_used[0]}1{else}0{endif}\n" ";USED4:{if is_extruder_used[4]}1{else}0{endif}\n" ";USED5:{if is_extruder_used[5]}1{else}0{endif}\n" ";USED6:{if is_extruder_used[6]}1{else}0{endif}\n" }, }); const std::string gcode = slice({ cube(20) }, config); // First layer: every active carriage's filament is declared to machine_start_gcode. CHECK(gcode.find(";USED0:1") != std::string::npos); CHECK(gcode.find(";USED4:1") != std::string::npos); CHECK(gcode.find(";USED5:1") != std::string::npos); CHECK(gcode.find(";USED6:1") != std::string::npos); // Second layer: every active carriage gets its own transition. CHECK(gcode.find("M104 S240 T0") != std::string::npos); CHECK(gcode.find("M104 S240 T1") != std::string::npos); CHECK(gcode.find("M104 S240 T2") != std::string::npos); CHECK(gcode.find("M104 S240 T3") != std::string::npos); } // M104/M109 name a physical heater, but every caller of the instance set_temperature overload // addresses filaments by logical id. pem routes filament 5 (logical 4) to head 1, so a // toolchange between filaments 1 and 5 must cool and wait on T1 -- never T4, which on this // machine is an AFC lane index and names no heater at all. // // Regression: the same-physical short-circuit in set_extruder hides this for lane swaps within // one head (filaments 1-4 all map to head 0, so no cool-down is emitted), so only a toolchange // that CROSSES heads reaches the emission. Ooze prevention must be on for pre/post_toolchange // to run at all. TEST_CASE("IMEX heater commands name the physical head, not the logical filament", "[MultiFilament][IMEX][Regression]") { DynamicPrintConfig config = multifilament_config(7); imex_7x4_printer(config); config.set_deserialize_strict({ { "imex_parallel_mode", "primary" }, { "ooze_prevention", "1" }, { "standby_temperature_delta", "-50" }, { "single_extruder_multi_material", "0" }, { "nozzle_temperature_initial_layer", "200,200,200,200,200,200,200" }, { "nozzle_temperature", "240,240,240,240,240,240,240" }, }); // Two objects on filaments 1 and 5: logical 0 -> head 0, logical 4 -> head 1. const std::vector> overrides{ { { "extruder", "1" } }, { { "extruder", "5" } }, }; const std::string gcode = slice_with_object_overrides({ cube(20), cube(20) }, config, overrides); // The bare toolchange stays LOGICAL -- it is an AFC lane selector, not a heater. CHECK(gcode.find("\nT4") != std::string::npos); // Every heater command carrying a tool must name a configured head (0-3 here), never a // logical slot above the head count. Scanning beats a fixed-string check: it fails on any // stray unmapped emission, not just the two sites this test was written for. std::istringstream ss(gcode); std::string line; std::vector offenders; while (std::getline(ss, line)) { if (line.rfind("M104", 0) != 0 && line.rfind("M109", 0) != 0) continue; const size_t t = line.find(" T"); if (t == std::string::npos || t + 2 >= line.size() || !std::isdigit((unsigned char) line[t + 2])) continue; if (std::stoi(line.substr(t + 2)) > 3) offenders.push_back(line); } INFO("heater commands naming a non-existent head: " << offenders.size() << (offenders.empty() ? "" : " e.g. " + offenders.front())); CHECK(offenders.empty()); } // The other half of that translation, and the half nothing else covers: GCodeWriter::set_temperature // passes `this->config.is_imex.value` as the guard, NOT a constant, so a printer that is not an // IMEX printer keeps addressing heaters by logical filament id exactly as upstream does. // // physical_extruder_map is not an IMEX-only key. Shipping dual-nozzle BBL profiles author it -- // fdm_bbl_3dp_002_common ships {1, 0} -- for the inherited BBL reading of the key, and PrintApply // deliberately leaves a non-IMEX printer's map exactly as it arrives (IMEXHelpers.hpp spells out // the two readings). Hardcode `true` at that call site and this whole suite still passes, because // every other case here runs either on an IMEX printer or on one with no authored map -- while // those profiles start sending filament 0's M104/M109 to heater 1 and filament 1's to heater 0. // // The two filaments carry DIFFERENT idle temperatures, so the S value and the T index cross-check // each other: a remap moves both onto the other tool and fails both halves, and no assertion can // be satisfied by a prefix. // // Idle temperature rather than nozzle_temperature, for a reason specific to THIS HARNESS. Keys in // filament_options_with_variant are rewritten at apply time by // update_values_to_printer_extruders_for_multiple_filaments, which sets each filament's value to // `opt->get_at(variant_index[f])` -- it RE-INDEXES per filament by that filament's extruder // variant, it does not flatten. Per-filament nozzle temperature is a real, working feature and // survives that pass on a printer whose filaments resolve to different variant slots. // multifilament_config pins nozzle_diameter to a single 0.4, so every filament here resolves to // the SAME variant slot and therefore ends up with the same value -- which is why the IMEX cases // above tell heads apart by tool qualifier rather than by temperature. idle_temperature is not in // that key set, so "151,173" reaches the emitter intact and the two filaments stay distinguishable. // Ooze prevention is what puts the idle temperatures into the file at all. TEST_CASE("Heater commands keep the logical filament id on a non-IMEX printer", "[MultiFilament][IMEX][Regression]") { DynamicPrintConfig config = 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" }, // The shipping two-nozzle profile: not an IMEX printer, but it authors the swap map. { "is_imex", "0" }, { "physical_extruder_map", "1,0" }, { "single_extruder_multi_material", "0" }, // Ooze prevention drops the outgoing filament to its idle temperature on every tool // change, through the instance set_temperature overload that carries the guard. { "ooze_prevention", "1" }, { "standby_temperature_delta", "-50" }, // unused while idle_temperature is set // Per filament and distinct: these are the values the assertions pair with a heater. { "idle_temperature", "151,173" }, { "nozzle_temperature_initial_layer", "215,215" }, { "nozzle_temperature", "240,240" }, // GCodeProcessor's preheat pass rewrites the tool-change temperature commands and drops // the ";cooldown" M104s outright, applying physical_extruder_map itself as it does. That // pass is not the code under test, so switch it off and let the writer's emissions stand. { "preheat_time", "0" }, { "enable_prime_tower", "0" }, // The assertions spell "M104 S T"; RepRapFirmware emits "G10 S P" from the // same code, so the flavor is pinned rather than defaulted. { "gcode_flavor", "klipper" }, }); // One filament per object, so both heaters are addressed and a tool change happens in both // directions. Assigned at the object level: a region-level filament id would not raise the // used-filament count the tool ordering works from. const std::vector> overrides{ { { "extruder", "1" } }, { { "extruder", "2" } }, }; const std::string gcode = slice_with_object_overrides({ cube(20), cube(20) }, config, overrides); // Every heater command that names a tool, collected as temperature -> the tools it was // addressed to. Scanning beats fixed-string finds: "M104 S151" on its own is prefix-satisfied // by "M104 S151 T1", and excluding just " T1" would let a command routed to some third tool // through. Mirrors the offender scan in the IMEX case above. std::map> tools_by_temp; std::istringstream ss(gcode); for (std::string line; std::getline(ss, line);) { if (line.rfind("M104", 0) != 0 && line.rfind("M109", 0) != 0) continue; const size_t s = line.find('S'); const size_t t = line.find(" T"); if (s == std::string::npos || t == std::string::npos) continue; if (s + 1 >= line.size() || !std::isdigit((unsigned char) line[s + 1])) continue; if (t + 2 >= line.size() || !std::isdigit((unsigned char) line[t + 2])) continue; tools_by_temp[std::stoi(line.substr(s + 1))].insert(std::stoi(line.substr(t + 2))); } std::string seen; for (const auto& [temperature, tools] : tools_by_temp) { seen += " S" + std::to_string(temperature) + "->"; for (int tool : tools) seen += "T" + std::to_string(tool); } INFO("heater commands naming a tool:" << seen); // Both idle temperatures have to be in the file, or the pairing below would prove nothing. REQUIRE(tools_by_temp.count(151) == 1); REQUIRE(tools_by_temp.count(173) == 1); // Filament 0's idle temperature goes to heater 0 and nowhere else, filament 1's to heater 1. // Applying physical_extruder_map {1, 0} here would swap both. CHECK(tools_by_temp[151] == std::set{ 0 }); CHECK(tools_by_temp[173] == std::set{ 1 }); } // The IMEX Primary tool prints the sliced paths directly, so it can only use a filament the // printer's physical_extruder_map routes to it. The ghost filament picker enforces that for // the secondary tools; the primary's filament comes from the ordinary object selector, which // has no IMEX awareness. `copy` declares T0 Primary, but every filament this plate uses -- // filament 6, slot 5 -- routes to head 2, so nothing can feed T0 and validate() must refuse. // // The object's own extruder is pinned too: ModelVolume::get_extruders() reports the volume's // extruder_id (1 by default), which would put slot 0 on the plate. Slot 0 routes to head 0, // the declared primary, so the plate would be well-formed and correctly NOT blocked. TEST_CASE("An IMEX plate whose filament never routes to the Primary tool is blocked", "[MultiFilament][IMEX]") { DynamicPrintConfig config = multifilament_config(7); imex_7x4_printer(config); all_regions_on_filament(config, 6); config.set_deserialize_strict({ { "imex_parallel_mode", "copy" } }); std::vector meshes; meshes.push_back(cube(20)); const std::vector> overrides{ { { "extruder", "6" } } }; Slic3r::Model model; Slic3r::Print print; init_print(std::move(meshes), print, model, config, &overrides, false); std::vector warnings; const StringObjectException err = print.validate(&warnings); REQUIRE_FALSE(err.string.empty()); CHECK(err.string.find("T0") != std::string::npos); // the declared primary CHECK(err.string.find("T2") != std::string::npos); // where the filament actually lives } // A mixed filament is blended at the nozzle by its component toolheads, which a parallel // mode is already using to print copies. Unsupported regardless of where the components // route, so this must refuse even though component filament 1 sits on the declared primary // T0 -- and it must refuse with the mixed message, not the routing one. Mixed slots normally // sit past the end of physical_extruder_map, so the routing rule would call them unrouted. TEST_CASE("An IMEX plate using a mixed filament is blocked", "[MultiFilament][IMEX]") { DynamicPrintConfig config = multifilament_config(8); imex_7x4_printer(config); all_regions_on_filament(config, 8); config.set_deserialize_strict({ { "imex_parallel_mode", "copy" }, { "filament_is_mixed", "0,0,0,0,0,0,0,1" }, { "filament_mixed_components", ";;;;;;;1,5" }, // The mixed arrays run parallel to filament_colour and must be sized to the filament // count (see test_mixed_filament.cpp). validate() returns before the other five are // read, but that is a property of where the rule sits, not something to rely on. { "filament_mixed_sublayer_ratios", ";;;;;;;" }, { "filament_mixed_gradient", "0,0,0,0,0,0,0,0" }, { "filament_mixed_gradient_range", ";;;;;;;" }, { "filament_mixed_gradient_curve", ";;;;;;;" }, { "filament_mixed_gradient_per_part", "0,0,0,0,0,0,0,0" }, }); std::vector meshes; meshes.push_back(cube(20)); const std::vector> overrides{ { { "extruder", "8" } } }; Slic3r::Model model; Slic3r::Print print; init_print(std::move(meshes), print, model, config, &overrides, false); std::vector warnings; const StringObjectException err = print.validate(&warnings); REQUIRE_FALSE(err.string.empty()); CHECK(err.string.find("Mixed filaments") != std::string::npos); } // Filament 8 (slot 7) is the blend, filament 1 (slot 0) is ordinary, so used_filaments is > 1 // and the multi-color rule's gate opens too. This pins that the mixed rule still wins: with the // two the other way round the user is told the mode's active tools all sit on one gantry -- // a lecture about a multi-color print they never asked for -- and never learns the blend is // the problem. // // The second half is what keeps this honest. The multi-color rule only fires here because this // fixture's `copy` mode is degenerate (imex_tools_per_gantry defaults to 2, so "0:P,1:C" puts // both tools on gantry 0). Give the mode a Span tool and it returns nothing, and this test would // pass under EITHER ordering while appearing to guard it. So assert the rule is actually armed. TEST_CASE("A mixed filament outranks the multi-color rule on the same plate", "[MultiFilament][IMEX][Regression]") { // Two cubes, offset: make_cube() is corner-at-origin, so identical meshes would be exactly // coincident. validate() returns from the IMEX block before any geometry check today, but a // future check landing earlier would fail this test for a reason it is not about. const auto build = [](bool blend_slot_8) { DynamicPrintConfig config = multifilament_config(8); imex_7x4_printer(config); all_regions_on_filament(config, 8); config.set_deserialize_strict({ { "imex_parallel_mode", "copy" }, { "filament_is_mixed", "0,0,0,0,0,0,0,1" }, { "filament_mixed_components", ";;;;;;;1,5" }, // The mixed arrays run parallel to filament_colour and must be sized to the filament // count (see test_mixed_filament.cpp). validate() returns before the other five are // read, but that is a property of where the rule sits, not something to rely on. { "filament_mixed_sublayer_ratios", ";;;;;;;" }, { "filament_mixed_gradient", "0,0,0,0,0,0,0,0" }, { "filament_mixed_gradient_range", ";;;;;;;" }, { "filament_mixed_gradient_curve", ";;;;;;;" }, { "filament_mixed_gradient_per_part", "0,0,0,0,0,0,0,0" }, }); if (!blend_slot_8) config.set_deserialize_strict({ { "filament_is_mixed", "0,0,0,0,0,0,0,0" } }); return config; }; const std::vector> overrides{ { { "extruder", "8" } }, { { "extruder", "1" } }, }; const auto validate_plate = [&](const DynamicPrintConfig& config, Slic3r::Print& print, Slic3r::Model& model) { std::vector meshes; meshes.push_back(cube(20)); TriangleMesh second = cube(20); second.translate(30.0, 0.0, 0.0); meshes.push_back(second); init_print(std::move(meshes), print, model, config, &overrides, false); std::vector warnings; return print.validate(&warnings); }; // The multi-color rule IS armed for this plate -- without that, the check below proves nothing. { Slic3r::Model model; Slic3r::Print print; const StringObjectException err = validate_plate(build(false), print, model); REQUIRE_FALSE(err.string.empty()); CHECK(err.string.find("Multi-color") != std::string::npos); } // With the blend present the mixed rule takes precedence over it. { Slic3r::Model model; Slic3r::Print print; const StringObjectException err = validate_plate(build(true), print, model); REQUIRE_FALSE(err.string.empty()); CHECK(err.string.find("not supported in IDEX/IQEX parallel modes") != std::string::npos); CHECK(err.string.find("Multi-color") == std::string::npos); // The two rules differ in more than wording: the mixed path attaches an object (for the // notification's "Jump to" link), the multi-color path returns none. Pins which fired // independently of the message text. CHECK(err.object == print.objects().front()); } } // Guard rail: the block must not fire on a well-formed plate. Filament 1 (slot 0) routes to // head 0, which `copy` declares Primary, so the Primary tool has something to print with. TEST_CASE("An IMEX plate whose filament routes to the Primary tool validates", "[MultiFilament][IMEX]") { DynamicPrintConfig config = multifilament_config(7); imex_7x4_printer(config); all_regions_on_filament(config, 1); config.set_deserialize_strict({ { "imex_parallel_mode", "copy" } }); Slic3r::Model model; Slic3r::Print print; init_print({ cube(20) }, print, model, config); std::vector warnings; const StringObjectException err = print.validate(&warnings); CHECK(err.string.empty()); } // A filament can define several variants (Standard, High Flow). Each filament prints with its // variant of the extruder's variant string, or with its own first variant when it defines none, on a // printer listing a single variant as on one listing several. TEST_CASE("Each filament prints with its variant of the extruder's variant string", "[MultiFilament]") { auto [variant_list, nozzle_volume_type, filament, temperature, resolved] = GENERATE(table({ { "Direct Drive Standard", nvtStandard, 1, 211, "211,223" }, { "Direct Drive Standard", nvtStandard, 2, 223, "211,223" }, { "Direct Drive High Flow", nvtHighFlow, 1, 239, "239,223" }, { "Direct Drive High Flow", nvtHighFlow, 2, 223, "239,223" }, // filament 2 defines no High Flow variant { "Direct Drive Standard,Direct Drive High Flow", nvtHighFlow, 1, 239, "239,223" }, { "Direct Drive Standard,Direct Drive High Flow", nvtHighFlow, 2, 223, "239,223" }, })); DYNAMIC_SECTION(variant_list << " printer, " << get_nozzle_volume_type_string(nozzle_volume_type) << " nozzle, filament " << filament) { DynamicPrintConfig config = multifilament_config(2, { { "extruder_variant_list", variant_list }, // filament 1 defines Standard (211) and High Flow (239), filament 2 Standard (223) { "filament_extruder_variant", "Direct Drive Standard;Direct Drive High Flow;Direct Drive Standard" }, { "filament_self_index", "1,1,2" }, { "nozzle_temperature", "211,239,223" }, { "nozzle_temperature_initial_layer", "211,239,223" }, { "sparse_infill_filament_id", filament }, { "internal_solid_filament_id", filament }, { "top_surface_filament_id", filament }, { "bottom_surface_filament_id", filament }, { "outer_wall_filament_id", filament }, { "inner_wall_filament_id", filament }, { "enable_prime_tower", 0 }, { "skirt_loops", 0 }, { "brim_type", "no_brim" }, // custom G-code indexes the per-filament arrays by filament { "machine_start_gcode", "; start temperature {nozzle_temperature_initial_layer[initial_extruder]}" }, }); config.option("nozzle_volume_type", true)->values = { nozzle_volume_type }; const std::string gcode = slice({ cube(20) }, config); std::set temperatures; std::istringstream stream(gcode); for (std::string line; std::getline(stream, line);) { if (line.rfind("M104 ", 0) != 0 && line.rfind("M109 ", 0) != 0) continue; const size_t s = line.find(" S"); if (s != std::string::npos && std::stoi(line.substr(s + 2)) > 0) temperatures.insert(std::stoi(line.substr(s + 2))); } CHECK(temperatures == std::set{ temperature }); CHECK(gcode.find("; start temperature " + std::to_string(temperature) + "\n") != std::string::npos); // The config the slice ran with holds one value per filament, as the readers that index // it by filament (the wipe tower, the filament compatibility check) expect. CHECK(gcode.find("; nozzle_temperature = " + resolved + "\n") != std::string::npos); } } // An adaptive pressure advance model predicting the same pressure advance at every flow and acceleration. static std::string constant_pressure_advance_model(const std::string &pa) { return pa + ",1,1000\n" + pa + ",500,1000\n" + pa + ",1,100000\n" + pa + ",500,100000"; } // The pressure advance values a Klipper G-code sets. static std::set pressure_advance_values(const std::string &gcode) { const std::string token = "SET_PRESSURE_ADVANCE ADVANCE="; std::set values; std::istringstream stream(gcode); for (std::string line; std::getline(stream, line);) if (line.rfind(token, 0) == 0) values.insert(line.substr(token.size(), line.find(';') - token.size())); return values; } // Pressure advance, and the adaptive pressure advance model, are tuned per extruder variant like the // other filament variant settings. TEST_CASE("Each filament sets the pressure advance of its extruder variant", "[MultiFilament]") { auto [nozzle_volume_type, filament, pressure_advance, adaptive_pressure_advance] = GENERATE(table({ { nvtStandard, 1, "0.021", "0.012" }, { nvtHighFlow, 1, "0.037", "0.034" }, { nvtHighFlow, 2, "0.043", "0.056" }, // filament 2 defines no High Flow variant })); const bool adaptive = GENERATE(false, true); DYNAMIC_SECTION(get_nozzle_volume_type_string(nozzle_volume_type) << " nozzle, filament " << filament << (adaptive ? ", adaptive" : "")) { DynamicPrintConfig config = multifilament_config(2, { { "gcode_flavor", "klipper" }, { "extruder_variant_list", "Direct Drive Standard,Direct Drive High Flow" }, // filament 1 defines Standard (0.021) and High Flow (0.037), filament 2 Standard (0.043) { "filament_extruder_variant", "Direct Drive Standard;Direct Drive High Flow;Direct Drive Standard" }, { "filament_self_index", "1,1,2" }, { "enable_pressure_advance", "1,1,1" }, { "pressure_advance", "0.021,0.037,0.043" }, { "adaptive_pressure_advance", adaptive ? "1,1,1" : "0,0,0" }, { "sparse_infill_filament_id", filament }, { "internal_solid_filament_id", filament }, { "top_surface_filament_id", filament }, { "bottom_surface_filament_id", filament }, { "outer_wall_filament_id", filament }, { "inner_wall_filament_id", filament }, { "enable_prime_tower", 0 }, { "skirt_loops", 0 }, { "brim_type", "no_brim" }, // custom G-code indexes the per-filament arrays by filament { "machine_start_gcode", "; start pressure advance {pressure_advance[initial_extruder]}" }, }); config.option("adaptive_pressure_advance_model")->values = { constant_pressure_advance_model("0.012"), constant_pressure_advance_model("0.034"), constant_pressure_advance_model("0.056") }; config.option("nozzle_volume_type", true)->values = { nozzle_volume_type }; const std::string gcode = slice({ cube(20) }, config); // The toolchange sets the variant's pressure advance; with adaptive pressure advance, the // prediction of the variant's model then replaces it. std::set expected{ pressure_advance }; if (adaptive) expected.insert(adaptive_pressure_advance); CHECK(pressure_advance_values(gcode) == expected); CHECK(gcode.find("; start pressure advance " + pressure_advance + "\n") != std::string::npos); } } // On a printer with two extruders, a filament takes the pressure advance of the variant of the extruder // it is mapped to, whichever filament and extruder that is. TEST_CASE("Each filament sets the pressure advance of its extruder variant on a two-extruder printer", "[MultiFilament]") { auto [filament, extruder, pressure_advance, adaptive_pressure_advance] = GENERATE(table({ { 1, 1, "0.021", "0.012" }, { 1, 2, "0.037", "0.034" }, { 2, 1, "0.043", "0.056" }, { 2, 2, "0.049", "0.078" }, })); const bool adaptive = GENERATE(false, true); DYNAMIC_SECTION("filament " << filament << " on extruder " << extruder << (adaptive ? ", adaptive" : "")) { // the other filament goes on the other extruder const std::string filament_map = filament == 1 ? std::to_string(extruder) + "," + std::to_string(3 - extruder) : std::to_string(3 - extruder) + "," + std::to_string(extruder); DynamicPrintConfig config = multifilament_config(2, { { "gcode_flavor", "klipper" }, { "single_extruder_multi_material", 0 }, { "nozzle_diameter", "0.4,0.4" }, { "extruder_printable_height", "0,0" }, // extruder 1 has a Standard nozzle, extruder 2 a High Flow one { "printer_extruder_id", "1,2" }, { "printer_extruder_variant", "Direct Drive Standard;Direct Drive High Flow" }, { "extruder_variant_list", "Direct Drive Standard;Direct Drive High Flow" }, { "filament_map", filament_map }, // both filaments define Standard and High Flow { "filament_extruder_variant", "Direct Drive Standard;Direct Drive High Flow;Direct Drive Standard;Direct Drive High Flow" }, { "filament_self_index", "1,1,2,2" }, { "enable_pressure_advance", "1,1,1,1" }, { "pressure_advance", "0.021,0.037,0.043,0.049" }, { "adaptive_pressure_advance", adaptive ? "1,1,1,1" : "0,0,0,0" }, { "sparse_infill_filament_id", filament }, { "internal_solid_filament_id", filament }, { "top_surface_filament_id", filament }, { "bottom_surface_filament_id", filament }, { "outer_wall_filament_id", filament }, { "inner_wall_filament_id", filament }, { "enable_prime_tower", 0 }, { "skirt_loops", 0 }, { "brim_type", "no_brim" }, // custom G-code indexes the per-filament arrays by filament { "machine_start_gcode", "; start pressure advance {pressure_advance[initial_extruder]}" }, }); config.option("adaptive_pressure_advance_model")->values = { constant_pressure_advance_model("0.012"), constant_pressure_advance_model("0.034"), constant_pressure_advance_model("0.056"), constant_pressure_advance_model("0.078") }; config.option("extruder_type", true)->values = { etDirectDrive, etDirectDrive }; config.option("nozzle_volume_type", true)->values = { nvtStandard, nvtHighFlow }; // keep the mapping above rather than grouping the filaments automatically config.option>("filament_map_mode", true)->value = fmmManual; const std::string gcode = slice({ cube(20) }, config); std::set expected{ pressure_advance }; if (adaptive) expected.insert(adaptive_pressure_advance); CHECK(pressure_advance_values(gcode) == expected); CHECK(gcode.find("; start pressure advance " + pressure_advance + "\n") != std::string::npos); } }