diff --git a/src/libslic3r/GCode/WipeTower.cpp b/src/libslic3r/GCode/WipeTower.cpp index 3d7353eadf..b97e773e63 100644 --- a/src/libslic3r/GCode/WipeTower.cpp +++ b/src/libslic3r/GCode/WipeTower.cpp @@ -617,6 +617,18 @@ Polygon generate_rectange_polygon(const Vec2f &wt_box_min ,const Vec2f & wt_box_ return res; } +const char* flush_planner_queue_command(GCodeFlavor flavor) +{ + return flavor == gcfKlipper ? "M400\n" : "G4 S0\n"; +} + +std::string wait_command(GCodeFlavor flavor, float seconds) +{ + if (flavor == gcfKlipper) + return "G4 P" + std::to_string(std::lround(seconds * 1000.f)) + "\n"; + return "G4 S" + Slic3r::float_to_string_decimal_point(seconds, 3) + "\n"; +} + class WipeTowerWriter { public: @@ -1145,7 +1157,7 @@ public: { if (time==0.f) return *this; - m_gcode += "G4 S" + Slic3r::float_to_string_decimal_point(time, 3) + "\n"; + m_gcode += wait_command(m_gcode_flavor, time); return *this; } @@ -1190,7 +1202,7 @@ public: WipeTowerWriter& flush_planner_queue() { - m_gcode += "G4 S0\n"; + m_gcode += flush_planner_queue_command(m_gcode_flavor); return *this; } @@ -1333,6 +1345,8 @@ public: { std::string buffer; if (wait_for_moves) + // Not flush_planner_queue_command(): this BBL precool path wants M400, which every + // flavor it reaches understands, not the zero dwell the other flavors flush with. buffer += "M400\n"; buffer += "M104"; if (target_extruder != -1) diff --git a/src/libslic3r/GCode/WipeTower.hpp b/src/libslic3r/GCode/WipeTower.hpp index 66e8acf1c4..0819a04f10 100644 --- a/src/libslic3r/GCode/WipeTower.hpp +++ b/src/libslic3r/GCode/WipeTower.hpp @@ -26,6 +26,12 @@ enum GCodeFlavor : unsigned char; Polylines construct_gap_for_skip_points( const Polygon& polygon, const std::vector& skip_points, float wt_width, float gap_length, Polygon& insert_skip_polygon); +// Klipper acts on commands the instant it parses them, and its G4 reads only P (milliseconds), +// so the zero-second and seconds-valued dwells every other flavor uses neither synchronize nor +// pause there. Both defined in WipeTower.cpp, shared by WipeTower and WipeTower2. +const char* flush_planner_queue_command(GCodeFlavor flavor); // finish queued moves, e.g. around M104/M109 +std::string wait_command(GCodeFlavor flavor, float seconds); // pause for `seconds` + class WipeTower { public: diff --git a/src/libslic3r/GCode/WipeTower2.cpp b/src/libslic3r/GCode/WipeTower2.cpp index 51ab155dd9..0837bfa908 100644 --- a/src/libslic3r/GCode/WipeTower2.cpp +++ b/src/libslic3r/GCode/WipeTower2.cpp @@ -386,7 +386,8 @@ public: } WipeTowerWriter2& switch_filament_monitoring(bool enable) { - m_gcode += std::string("G4 S0\n") + "M591 " + (enable ? "R" : "S0") + "\n"; + flush_planner_queue(); + m_gcode += enable ? "M591 R\n" : "M591 S0\n"; return *this; } @@ -625,7 +626,7 @@ public: // Set extruder temperature, don't wait by default. WipeTowerWriter2& set_extruder_temp(int temperature, bool wait = false) { - m_gcode += "G4 S0\n"; // to flush planner queue + flush_planner_queue(); m_gcode += "M" + std::to_string(wait ? 109 : 104) + " S" + std::to_string(temperature) + "\n"; return *this; } @@ -635,7 +636,7 @@ public: { if (time==0.f) return *this; - m_gcode += "G4 S" + Slic3r::float_to_string_decimal_point(time, 3) + "\n"; + m_gcode += wait_command(m_gcode_flavor, time); return *this; } @@ -677,8 +678,8 @@ public: } WipeTowerWriter2& flush_planner_queue() - { - m_gcode += "G4 S0\n"; + { + m_gcode += flush_planner_queue_command(m_gcode_flavor); return *this; } diff --git a/tests/fff_print/CMakeLists.txt b/tests/fff_print/CMakeLists.txt index 70ab639faa..08f86de8a7 100644 --- a/tests/fff_print/CMakeLists.txt +++ b/tests/fff_print/CMakeLists.txt @@ -18,6 +18,7 @@ add_executable(${_TEST_NAME}_tests test_slicing_pipeline_hook.cpp test_support_material.cpp test_trianglemesh.cpp + test_wipe_tower.cpp ) target_link_libraries(${_TEST_NAME}_tests test_common libslic3r Catch2::Catch2WithMain) set_property(TARGET ${_TEST_NAME}_tests PROPERTY FOLDER "tests") diff --git a/tests/fff_print/test_wipe_tower.cpp b/tests/fff_print/test_wipe_tower.cpp new file mode 100644 index 0000000000..bb9e4781d1 --- /dev/null +++ b/tests/fff_print/test_wipe_tower.cpp @@ -0,0 +1,123 @@ +#include + +#include +#include + +#include "libslic3r/GCode/GCodeProcessor.hpp" +#include "libslic3r/GCode/WipeTower.hpp" +#include "libslic3r/PrintConfig.hpp" + +#include "test_helpers.hpp" + +using namespace Slic3r; +using namespace Slic3r::Test; + +// Taken from the config enum map rather than hand-listed, so a flavor added to GCodeFlavor later +// is covered here without editing this file. +static std::vector non_klipper_flavors() +{ + std::vector flavors; + for (const auto &[name, value] : ConfigOptionEnum::get_enum_values()) + if (GCodeFlavor(value) != gcfKlipper) + flavors.push_back(GCodeFlavor(value)); + return flavors; +} + +static std::string flavor_name(GCodeFlavor flavor) +{ + return ConfigOptionEnum::get_enum_names()[int(flavor)]; +} + +TEST_CASE("Klipper flushes the wipe tower planner queue with M400", "[WipeTower]") +{ + CHECK(std::string(flush_planner_queue_command(gcfKlipper)) == "M400\n"); +} + +TEST_CASE("Other flavors flush the wipe tower planner queue with a zero dwell", "[WipeTower]") +{ + const GCodeFlavor flavor = GENERATE(from_range(non_klipper_flavors())); + INFO("gcode flavor: " << flavor_name(flavor)); + CHECK(std::string(flush_planner_queue_command(flavor)) == "G4 S0\n"); +} + +// 1.5s is exactly representable as a float, so neither form can drift when rounded. +TEST_CASE("Klipper waits in the wipe tower with a millisecond dwell", "[WipeTower]") +{ + CHECK(wait_command(gcfKlipper, 1.5f) == "G4 P1500\n"); +} + +TEST_CASE("Other flavors wait in the wipe tower with a seconds dwell", "[WipeTower]") +{ + const GCodeFlavor flavor = GENERATE(from_range(non_klipper_flavors())); + INFO("gcode flavor: " << flavor_name(flavor)); + CHECK(wait_command(flavor, 1.5f) == "G4 S1.500\n"); +} + +// The cases above only exercise the helpers in isolation. The one below slices a real +// two-filament print, so it also covers the binding constraint of both changes: that the +// configured `gcode_flavor` reaches the wipe tower writer and lands in the exported G-code. + +// The G-code inside each WIPE_TOWER_START/WIPE_TOWER_END pair, concatenated, so an M400 emitted +// outside the tower (e.g. GCodeProcessor's pre-heat injector) cannot create a false match. +static std::string wipe_tower_regions(const std::string &gcode) +{ + const std::string &start_tag = GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Wipe_Tower_Start); + const std::string &end_tag = GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Wipe_Tower_End); + std::string regions; + size_t pos = 0; + while (true) { + size_t start = gcode.find(start_tag, pos); + if (start == std::string::npos) + break; + size_t end = gcode.find(end_tag, start); + if (end == std::string::npos) + break; + regions.append(gcode, start, end - start); + pos = end + 1; + } + return regions; +} + +// A per-layer toolchange between the wall and infill filaments, same shape as +// test_multifilament.cpp's "Each feature prints with its assigned filament", so the wipe tower +// runs its toolchange path (and so `flush_planner_queue()`) on every layer. +static DynamicPrintConfig wipe_tower_toolchange_config(const std::string &gcode_flavor) +{ + return multifilament_config(2, { + { "sparse_infill_filament_id", 1 }, + { "internal_solid_filament_id", 1 }, + { "top_surface_filament_id", 1 }, + { "bottom_surface_filament_id", 1 }, + { "outer_wall_filament_id", 2 }, + { "inner_wall_filament_id", 2 }, + { "enable_prime_tower", true }, + { "layer_height", 0.3 }, + { "gcode_flavor", gcode_flavor }, + }); +} + +// Slices a 10mm cube under `config`. Not plain Test::slice: a brand-new Print's first `apply()` +// counts one filament in use, and DynamicPrintConfig::normalize_fdm_2's single-filament rule then +// clears `enable_prime_tower`. A second apply, once init_print's regions have settled, sees both +// filaments and the tower survives. +static std::string slice_with_prime_tower(const DynamicPrintConfig &config) +{ + Print print; + Model model; + init_print({ cube(10) }, print, model, config); + print.apply(model, config); + return gcode(print); +} + +TEST_CASE("The wipe tower's toolchange planner flush follows the gcode flavor", "[WipeTower]") +{ + auto [flavor, expected, unexpected] = GENERATE(table({ + { "klipper", "M400", "G4 S0" }, + { "marlin", "G4 S0", "M400" } })); + DYNAMIC_SECTION(flavor) { + const std::string tower = wipe_tower_regions(slice_with_prime_tower(wipe_tower_toolchange_config(flavor))); + REQUIRE_FALSE(tower.empty()); + CHECK_THAT(tower, Catch::Matchers::ContainsSubstring(expected)); + CHECK_THAT(tower, !Catch::Matchers::ContainsSubstring(unexpected)); + } +}