#include #include #include #include #include #include #include #include "libslic3r/GCode/GCodeProcessor.hpp" #include "libslic3r/GCodeReader.hpp" #include "libslic3r/PrintConfig.hpp" #include "libslic3r/Layer.hpp" #include "test_helpers.hpp" using namespace Slic3r; using namespace Slic3r::Test; namespace { DynamicPrintConfig wipe_config(const char *wall_generator, bool wipe_inward, const char *wipe_inward_distance = "50%", const char *seam_gap = "10%", bool wipe_on_loops = false, const char *wall_loops = "2", const char *wall_sequence = "inner wall/outer wall", bool alternate_extra_wall = false, const char *sparse_infill_density = "0%", const char *seam_position = "aligned") { DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); config.set_deserialize_strict({ { "nozzle_diameter", "0.4" }, { "layer_height", "0.2" }, { "initial_layer_print_height", "0.2" }, { "line_width", "0.45" }, { "outer_wall_line_width", "0" }, // Orca: Auto must use the actual path width. { "wall_loops", wall_loops }, { "wall_generator", wall_generator }, { "wall_sequence", wall_sequence }, { "top_shell_layers", "0" }, { "bottom_shell_layers", "0" }, { "sparse_infill_density", sparse_infill_density }, { "seam_position", seam_position }, { "seam_gap", seam_gap }, { "wipe", "1" }, { "wipe_distance", "2" }, { "retraction_length", "0.8" }, { "retract_when_changing_layer", "1" }, { "wipe_inward", wipe_inward ? "1" : "0" }, { "wipe_inward_distance", wipe_inward_distance }, { "wipe_on_loops", wipe_on_loops ? "1" : "0" }, { "alternate_extra_wall", alternate_extra_wall ? "1" : "0" }, { "gcode_comments", "1" }, { "machine_start_gcode", "" }, { "machine_end_gcode", "" }, }); return config; } struct WipeTrajectory { Vec2d start; double z; std::vector destinations; }; std::vector wipe_trajectories(const std::string &gcode) { const std::string &start_tag = GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Wipe_Start); const std::string &end_tag = GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Wipe_End); std::vector trajectories; bool in_wipe = false; GCodeReader parser; parser.parse_buffer(gcode, [&](GCodeReader &self, const GCodeReader::GCodeLine &line) { const std::string_view comment = line.comment(); if (comment.find(start_tag) != std::string_view::npos) { in_wipe = true; trajectories.push_back({Vec2d(self.x(), self.y()), self.z(), {}}); return; } if (comment.find(end_tag) != std::string_view::npos) { in_wipe = false; return; } if (in_wipe && line.dist_XY(self) > EPSILON) trajectories.back().destinations.emplace_back(line.new_X(self), line.new_Y(self)); }); return trajectories; } std::vector wipe_destinations(const std::string &gcode) { std::vector destinations; for (const WipeTrajectory &trajectory : wipe_trajectories(gcode)) destinations.insert(destinations.end(), trajectory.destinations.begin(), trajectory.destinations.end()); return destinations; } bool trajectories_differ(const std::vector &lhs, const std::vector &rhs) { if (lhs.size() != rhs.size()) return true; for (size_t i = 0; i < lhs.size(); ++i) if ((lhs[i] - rhs[i]).norm() > 0.01) return true; return false; } double trajectory_length(const WipeTrajectory &trajectory) { double length = 0.; Vec2d previous = trajectory.start; for (const Vec2d &destination : trajectory.destinations) { length += (destination - previous).norm(); previous = destination; } return length; } } // namespace TEST_CASE("Wipe retraction preserves fractional speed with inward wipe disabled", "[Wipe][Regression]") { const char *retraction_speed = GENERATE("25.25", "25.5", "25.75"); const char *relative_e = GENERATE("0", "1"); INFO("retraction speed: " << retraction_speed); INFO("relative E: " << relative_e); DynamicPrintConfig config = wipe_config("classic", false); config.set_deserialize_strict({ {"gcode_flavor", "marlin2"}, {"use_relative_e_distances", relative_e}, {"retraction_speed", retraction_speed}, {"retraction_length", "0.8"}, {"retract_before_wipe", "0%"}, {"retract_after_wipe", "0%"}, {"role_based_wipe_speed", "0"}, {"wipe_speed", "100"}, {"wipe_distance", "2"}, }); const std::string output = slice({make_cube(10., 10., 1.)}, config); const auto &start_tag = GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Wipe_Start); const auto &end_tag = GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Wipe_End); double before_wipe = 0.; double during_wipe = 0.; bool in_wipe = false; bool complete = false; GCodeReader parser; parser.apply_config(config); parser.parse_buffer(output, [&](GCodeReader &self, const GCodeReader::GCodeLine &line) { if (complete) return; if (line.comment().find(start_tag) != std::string_view::npos) { in_wipe = true; } else if (in_wipe && line.comment().find(end_tag) != std::string_view::npos) { complete = true; } else if (line.retracting(self)) { (in_wipe ? during_wipe : before_wipe) -= line.dist_E(self); } else if (line.extruding(self)) { before_wipe = 0.; } }); REQUIRE(complete); // At 100 mm/s, the 2 mm wipe lasts 0.02 seconds. The remaining part of // the configured 0.8 mm retraction must be emitted before that wipe. const double expected_during = std::stod(retraction_speed) * 2. / 100.; CHECK_THAT(during_wipe, Catch::Matchers::WithinAbs(expected_during, 0.00005)); CHECK_THAT(before_wipe, Catch::Matchers::WithinAbs(0.8 - expected_during, 0.00005)); } TEST_CASE("Inward wipe respects the minimum travel for retraction and Z hop", "[Wipe][Regression]") { const char *wall_generator = GENERATE("classic", "arachne"); const char *relative_e = GENERATE("0", "1"); const char *reduce_crossing_wall = GENERATE("0", "1"); const char *minimum_travel = GENERATE("5", "0"); CAPTURE(wall_generator, relative_e, reduce_crossing_wall, minimum_travel); DynamicPrintConfig config = wipe_config( wall_generator, true, "50%", "10%", false, "3", "inner-outer-inner wall"); config.set_deserialize_strict({ {"gcode_flavor", "marlin2"}, {"use_relative_e_distances", relative_e}, {"reduce_crossing_wall", reduce_crossing_wall}, {"retraction_minimum_travel", minimum_travel}, {"retract_when_changing_layer", "0"}, {"use_firmware_retraction", "0"}, {"retract_before_wipe", "0%"}, {"retract_after_wipe", "0%"}, {"retraction_speed", "25.5"}, {"role_based_wipe_speed", "0"}, {"wipe_speed", "100"}, {"z_hop", "0.4"}, {"retract_lift_above", "0"}, {"retract_lift_below", "0"}, }); config.set_key_value("z_hop_types", new ConfigOptionEnumsGeneric{zhtNormal}); config.set_key_value("retract_lift_enforce", new ConfigOptionEnumsGeneric{rletAllSurfaces}); const std::string output = slice({make_cube(10., 10., 1.)}, config); const auto &role_tag = GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Role); const auto &start_tag = GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Wipe_Start); const auto &end_tag = GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Wipe_End); ExtrusionRole role = erNone; bool after_outer_wall = false; bool in_wipe = false; size_t transitions = 0; size_t same_layer_transitions = 0; size_t inward_wipes = 0; double retraction = 0.; double lift = 0.; double outer_z = 0.; GCodeReader parser; parser.apply_config(config); parser.parse_buffer(output, [&](GCodeReader &self, const GCodeReader::GCodeLine &line) { if (line.comment().find(role_tag) == 0) role = ExtrusionEntity::string_to_role(line.comment().substr(role_tag.size())); if (line.comment().find(start_tag) == 0) { in_wipe = true; if (after_outer_wall) ++inward_wipes; } else if (line.comment().find(end_tag) == 0) { in_wipe = false; } if (line.extruding(self) && line.dist_XY(self) > EPSILON) { if (role == erExternalPerimeter) { after_outer_wall = true; retraction = lift = 0.; outer_z = line.new_Z(self); } else if (after_outer_wall) { REQUIRE(role == erPerimeter); ++transitions; const double layer_rise = std::max(0., double(self.z()) - outer_z); if (layer_rise < EPSILON) ++same_layer_transitions; // A 5 mm threshold suppresses retraction across a few wall widths. // A zero threshold still permits the ordinary retract and lift. const bool retract = std::stod(minimum_travel) == 0.; CHECK_THAT(retraction, Catch::Matchers::WithinAbs(retract ? 0.8 : 0., 0.00005)); // Exclude an ordinary layer change from the accumulated upward motion. CHECK_THAT(lift - layer_rise, Catch::Matchers::WithinAbs(retract ? 0.4 : 0., 0.001)); after_outer_wall = false; } } else if (after_outer_wall) { if (line.retracting(self)) retraction -= line.dist_E(self); lift += std::max(0., double(line.dist_Z(self))); if (in_wipe) CHECK_THAT(line.dist_E(self), Catch::Matchers::WithinAbs(0., 0.00005)); } }); // The 1 mm cube has five 0.2 mm layers: every outer wall must still wipe. REQUIRE(transitions == 5); REQUIRE(same_layer_transitions >= 4); REQUIRE(inward_wipes == transitions); } TEST_CASE("Changing inward wipe settings preserves the sliced geometry", "[Wipe][Regression]") { const char *key = GENERATE("wipe_inward", "wipe_inward_distance"); DynamicPrintConfig config = wipe_config("classic", false); Print print; Model model; init_print({make_cube(10., 10., 1.)}, print, model, config); gcode(print); const PrintObject &object = *print.objects().front(); REQUIRE(object.is_step_done(posPerimeters)); REQUIRE(object.is_step_done(posInfill)); REQUIRE(print.is_step_done(psWipeTower)); REQUIRE(print.is_step_done(psGCodeExport)); DynamicPrintConfig changed = config; changed.set_deserialize_strict({{key, std::string(key) == "wipe_inward" ? "1" : "75%"}}); print.apply(model, changed); CHECK(print.objects().front()->is_step_done(posPerimeters)); CHECK(print.objects().front()->is_step_done(posInfill)); CHECK(print.is_step_done(psWipeTower)); CHECK_FALSE(print.is_step_done(psGCodeExport)); } TEST_CASE("Retraction and pressure advance calibration suppress inward wipe overrides", "[Wipe][Regression]") { const auto mode = GENERATE(CalibMode::Calib_None, CalibMode::Calib_PA_Tower, CalibMode::Calib_Auto_PA_Line, CalibMode::Calib_Retraction_tower, CalibMode::Calib_Flow_Rate); const char *wall_generator = GENERATE("classic", "arachne"); const bool per_object = GENERATE(false, true); INFO("calibration mode: " << int(mode) << ", wall generator: " << wall_generator << ", per-object override: " << per_object); const auto trajectories = [&](bool inward) { DynamicPrintConfig config = wipe_config(wall_generator, inward && !per_object); const std::vector> overrides{ {{"wipe_inward", inward ? "1" : "0"}} }; Print print; Model model; init_print({make_cube(10., 10., 1.)}, print, model, config, per_object ? &overrides : nullptr); Calib_Params params; params.mode = mode; params.start = 0.2; params.end = 0.4; params.step = 0.1; print.set_calib_params(params); return wipe_destinations(gcode(print)); }; const auto regular = trajectories(false); const auto inward = trajectories(true); REQUIRE_FALSE(regular.empty()); REQUIRE_FALSE(inward.empty()); // Other calibration modes and ordinary prints must still honor the option. const bool should_differ = mode == CalibMode::Calib_None || mode == CalibMode::Calib_Flow_Rate; CHECK(trajectories_differ(regular, inward) == should_differ); } TEST_CASE("Inactive inward wipe settings preserve the exported trajectory", "[Wipe][Regression]") { const char *wall_generator = GENERATE("classic", "arachne"); const bool disable_wiping = GENERATE(false, true); DynamicPrintConfig regular = wipe_config(wall_generator, false); DynamicPrintConfig inward = wipe_config(wall_generator, true, disable_wiping ? "50%" : "0"); if (disable_wiping) { regular.set_deserialize_strict({{"wipe", "0"}}); inward.set_deserialize_strict({{"wipe", "0"}}); } const auto regular_paths = wipe_destinations(slice({make_cube(10., 10., 1.)}, regular)); const auto inward_paths = wipe_destinations(slice({make_cube(10., 10., 1.)}, inward)); if (!disable_wiping) REQUIRE_FALSE(regular_paths.empty()); CHECK_FALSE(trajectories_differ(regular_paths, inward_paths)); } TEST_CASE("Inward wipe changes the exported trajectory when outer wall width is Auto", "[Wipe][Regression]") { const char *wall_generator = GENERATE("classic", "arachne"); INFO("wall generator: " << wall_generator); const std::vector regular = wipe_destinations( slice({make_cube(10., 10., 1.)}, wipe_config(wall_generator, false))); const std::vector inward = wipe_destinations( slice({make_cube(10., 10., 1.)}, wipe_config(wall_generator, true))); REQUIRE_FALSE(regular.empty()); REQUIRE_FALSE(inward.empty()); REQUIRE(trajectories_differ(regular, inward)); } TEST_CASE("Inward wipe recognizes an external wall starting on an overhang", "[Wipe][Regression]") { const char *wall_generator = GENERATE("classic", "arachne"); const bool inward = GENERATE(false, true); CAPTURE(wall_generator, inward); const auto config = wipe_config(wall_generator, inward, "50%", "0%", false, "3", "inner-outer-inner wall", false, "0%", "back"); Print print; Model model; init_print({make_cube(10., 10., 1.)}, print, model, config); print.process(); size_t mixed_loops = 0; const auto mark_overhangs = [&](auto &&self, ExtrusionEntity *entity) -> void { if (auto *collection = dynamic_cast(entity)) { for (ExtrusionEntity *child : collection->entities) self(self, child); } else if (auto *loop = dynamic_cast(entity); loop && is_external_perimeter(loop->role())) { // Keep the printed geometry intact and give the back seam overhang // roles. The front edge remains an ordinary external-wall segment. ExtrusionPaths paths; bool has_overhang = false; bool has_external = false; for (const ExtrusionPath &source : loop->paths) { for (size_t i = 1; i < source.polyline.points.size(); ++i) { ExtrusionPath path = source; path.polyline.points = {source.polyline.points[i - 1], source.polyline.points[i]}; const bool overhang = path.polyline.points.front().y() > 0 || path.polyline.points.back().y() > 0; path.set_extrusion_role(overhang ? erOverhangPerimeter : erExternalPerimeter); has_overhang |= overhang; has_external |= !overhang; paths.push_back(std::move(path)); } } REQUIRE(has_overhang); REQUIRE(has_external); loop->paths = std::move(paths); ++mixed_loops; } }; for (const PrintObject *object : print.objects()) for (Layer *layer : object->layers()) for (LayerRegion *region : layer->regions()) mark_overhangs(mark_overhangs, ®ion->perimeters); REQUIRE(mixed_loops > 0); bool has_inward_wipe = false; for (const WipeTrajectory &trajectory : wipe_trajectories(gcode(print))) { if (trajectory.destinations.empty()) continue; const Vec2d move = trajectory.destinations.front() - trajectory.start; if (trajectory.start.x() > 4. && trajectory.start.y() > 4. && move.x() < -0.05 && move.y() < -0.05) has_inward_wipe = true; } CHECK(has_inward_wipe == inward); } TEST_CASE("Inward wipe keeps its offset when seam gap is zero", "[Wipe][Regression]") { const char *wall_generator = GENERATE("classic", "arachne"); INFO("wall generator: " << wall_generator); const std::vector regular = wipe_destinations( slice({make_cube(10., 10., 1.)}, wipe_config(wall_generator, false, "50%", "0%"))); const std::vector inward = wipe_destinations( slice({make_cube(10., 10., 1.)}, wipe_config(wall_generator, true, "50%", "0%"))); REQUIRE_FALSE(regular.empty()); REQUIRE_FALSE(inward.empty()); REQUIRE(trajectories_differ(regular, inward)); } TEST_CASE("Inward wipe is retained across layers with a back seam", "[Wipe][Regression]") { const char *wall_generator = GENERATE("classic", "arachne"); INFO("wall generator: " << wall_generator); const DynamicPrintConfig inward_config = wipe_config( wall_generator, true, "50%", "0%", false, "3", "inner-outer-inner wall", false, "0%", "back"); const std::vector inward = wipe_trajectories(slice({make_cube(27., 27., 1.)}, inward_config)); REQUIRE_FALSE(inward.empty()); std::map inward_wipe_by_layer; for (const WipeTrajectory &trajectory : inward) { bool &has_inward_wipe = inward_wipe_by_layer[trajectory.z]; if (trajectory.destinations.empty()) continue; const Vec2d first_move = trajectory.destinations.front() - trajectory.start; // Orca: a back seam lands on the cube's positive-X/positive-Y corner. // Its inward wipe must move diagonally away from both external faces. has_inward_wipe = has_inward_wipe || (trajectory.start.x() > 13. && trajectory.start.y() > 13. && first_move.x() < -0.05 && first_move.y() < -0.05); } REQUIRE(inward_wipe_by_layer.size() == 5); for (const auto &[z, has_inward_wipe] : inward_wipe_by_layer) { INFO("layer Z: " << z); REQUIRE(has_inward_wipe); } } TEST_CASE("Literal inward wipe distance is clamped to the outer wall width", "[Wipe][Regression]") { const char *wall_generator = GENERATE("classic", "arachne"); INFO("wall generator: " << wall_generator); const std::vector regular = wipe_destinations( slice({make_cube(10., 10., 1.)}, wipe_config(wall_generator, false))); const std::vector full_width = wipe_destinations( slice({make_cube(10., 10., 1.)}, wipe_config(wall_generator, true, "100%"))); const std::vector oversized = wipe_destinations( slice({make_cube(10., 10., 1.)}, wipe_config(wall_generator, true, "2"))); REQUIRE_FALSE(full_width.empty()); REQUIRE(trajectories_differ(regular, full_width)); REQUIRE(oversized.size() == full_width.size()); for (size_t i = 0; i < full_width.size(); ++i) REQUIRE_THAT((oversized[i] - full_width[i]).norm(), Catch::Matchers::WithinAbs(0., 0.01)); } TEST_CASE("Inward wipe is not applied without an adjacent wall", "[Wipe][Regression]") { const char *wall_generator = GENERATE("classic", "arachne"); INFO("wall generator: " << wall_generator); const std::vector regular = wipe_destinations( slice({make_cube(10., 10., 1.)}, wipe_config(wall_generator, false, "50%", "10%", false, "1"))); const std::vector inward = wipe_destinations( slice({make_cube(10., 10., 1.)}, wipe_config(wall_generator, true, "50%", "10%", false, "1"))); REQUIRE_FALSE(regular.empty()); REQUIRE_FALSE(trajectories_differ(regular, inward)); } TEST_CASE("Inward wipe uses an alternate extra wall when the configured wall count is one", "[Wipe][Regression]") { const char *wall_generator = GENERATE("classic", "arachne"); INFO("wall generator: " << wall_generator); const DynamicPrintConfig regular_config = wipe_config( wall_generator, false, "50%", "10%", false, "1", "inner wall/outer wall", true, "15%"); const DynamicPrintConfig inward_config = wipe_config( wall_generator, true, "50%", "10%", false, "1", "inner wall/outer wall", true, "15%"); const std::vector regular = wipe_destinations( slice({make_cube(10., 10., 1.)}, regular_config)); const std::vector inward = wipe_destinations( slice({make_cube(10., 10., 1.)}, inward_config)); REQUIRE_FALSE(regular.empty()); REQUIRE_FALSE(inward.empty()); REQUIRE(trajectories_differ(regular, inward)); } TEST_CASE("Inward wipe is not applied before the adjacent wall is printed", "[Wipe][Regression]") { const char *wall_generator = GENERATE("classic", "arachne"); INFO("wall generator: " << wall_generator); const std::vector regular = wipe_destinations( slice({make_cube(10., 10., 1.)}, wipe_config( wall_generator, false, "50%", "10%", false, "2", "outer wall/inner wall"))); const std::vector inward = wipe_destinations( slice({make_cube(10., 10., 1.)}, wipe_config( wall_generator, true, "50%", "10%", false, "2", "outer wall/inner wall"))); REQUIRE_FALSE(regular.empty()); REQUIRE_FALSE(trajectories_differ(regular, inward)); } TEST_CASE("Wipe on loops preserves the corner move with inward wipe disabled", "[Wipe][Regression]") { const char *wall_generator = GENERATE("classic", "arachne"); const char *nozzle_diameter = GENERATE("0.4", "0.8"); const char *comments = GENERATE("0", "1"); CAPTURE(comments); INFO("wall generator: " << wall_generator << ", nozzle diameter: " << nozzle_diameter); // A closed square gives a 90-degree material-side corner at the seam. DynamicPrintConfig config = wipe_config(wall_generator, false, "50%", "0", true); config.set_deserialize_strict({{"nozzle_diameter", nozzle_diameter}, {"seam_position", "nearest"}, {"gcode_comments", comments}}); const std::string output = slice({make_cube(10., 10., 1.)}, config); const auto &role_tag = GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Role); const auto &wipe_tag = GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Wipe_Start); ExtrusionRole role = erNone; std::vector loop; bool after_extrusion = false; size_t moves = 0; GCodeReader parser; parser.apply_config(config); parser.parse_buffer(output, [&](GCodeReader &self, const GCodeReader::GCodeLine &line) { if (line.comment().find(role_tag) == 0) { role = ExtrusionEntity::string_to_role(line.comment().substr(role_tag.size())); loop.clear(); after_extrusion = false; } if (line.comment().find(wipe_tag) == 0) after_extrusion = false; if (role != erExternalPerimeter) return; if (line.extruding(self) && line.dist_XY(self) > EPSILON) { if (loop.empty()) loop.emplace_back(self.x(), self.y()); loop.emplace_back(line.new_X(self), line.new_Y(self)); after_extrusion = true; return; } // The loop move is the first non-extruding XY move after the external // wall and before the reserved wipe marker, regardless of comment text. if (!after_extrusion || line.dist_XY(self) <= EPSILON) return; after_extrusion = false; ++moves; INFO("layer Z: " << self.z()); REQUIRE(loop.size() >= 4); const Vec2d seam = loop.front(); REQUIRE_THAT((loop.back() - seam).norm(), Catch::Matchers::WithinAbs(0., 0.003)); const Vec2d outgoing = (loop[1] - seam).normalized(); const Vec2d into_corner = (loop[loop.size() - 2] - seam).normalized(); REQUIRE_THAT(outgoing.dot(into_corner), Catch::Matchers::WithinAbs(0., 0.01)); const Vec2d move = Vec2d(line.new_X(self), line.new_Y(self)) - seam; // The legacy corner move is 20% of the nozzle diameter, turned 30 degrees // from the outgoing edge into the square. Check both components independently. const double distance = 0.2 * std::stod(nozzle_diameter); CHECK_THAT(move.dot(outgoing), Catch::Matchers::WithinAbs(distance * std::sqrt(3.) / 2., 0.003)); CHECK_THAT(move.dot(into_corner), Catch::Matchers::WithinAbs(distance / 2., 0.003)); }); REQUIRE(moves == 5); } TEST_CASE("Inward wipe remains valid after wipe on loops moves the nozzle", "[Wipe][Regression]") { const char *wall_generator = GENERATE("classic", "arachne"); const char *comments = GENERATE("0", "1"); CAPTURE(comments); INFO("wall generator: " << wall_generator); DynamicPrintConfig config = wipe_config(wall_generator, false, "50%", "10%", true); config.set_deserialize_strict({{"gcode_comments", comments}}); const std::string loop_move = slice({make_cube(10., 10., 1.)}, config); config.set_deserialize_strict({{"wipe_inward", "1"}}); const std::string combined = slice({make_cube(10., 10., 1.)}, config); config.set_deserialize_strict({{"wipe_on_loops", "0"}}); const std::string inward_only = slice({make_cube(10., 10., 1.)}, config); for (const std::string *output : {&loop_move, &combined}) { INFO("wipe_inward: " << (output == &combined)); std::map> loop_moves_by_layer; const auto &role_tag = GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Role); const auto &wipe_tag = GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Wipe_Start); ExtrusionRole role = erNone; bool after_extrusion = false; GCodeReader parser; parser.apply_config(config); parser.parse_buffer(*output, [&](GCodeReader &self, const GCodeReader::GCodeLine &line) { if (line.comment().find(role_tag) == 0) { role = ExtrusionEntity::string_to_role(line.comment().substr(role_tag.size())); after_extrusion = false; } if (line.comment().find(wipe_tag) == 0) after_extrusion = false; if (role != erExternalPerimeter || line.dist_XY(self) <= EPSILON) return; if (line.extruding(self)) { after_extrusion = true; } else if (after_extrusion) { loop_moves_by_layer[line.new_Z(self)].emplace_back(line.new_X(self), line.new_Y(self)); after_extrusion = false; } }); // The 1 mm cube at 0.2 mm layer height has one external loop on each of five layers. const auto trajectories = wipe_trajectories(*output); REQUIRE(loop_moves_by_layer.size() == 5); for (size_t layer = 1; layer <= 5; ++layer) { const double z = layer * 0.2; const auto moves = std::find_if(loop_moves_by_layer.begin(), loop_moves_by_layer.end(), [z](const auto &entry) { return std::abs(entry.first - z) < 0.001; }); REQUIRE(moves != loop_moves_by_layer.end()); REQUIRE(moves->second.size() == 1); const auto wipe = std::find_if(trajectories.begin(), trajectories.end(), [&](const WipeTrajectory &trajectory) { return std::abs(trajectory.z - z) < 0.001 && (trajectory.start - moves->second.front()).norm() < 0.001; }); REQUIRE(wipe != trajectories.end()); // The configured 2 mm wipe must be measured from the inward move's // endpoint, including when wipe_inward is off (set_last_pos regression). CHECK_THAT(trajectory_length(*wipe), Catch::Matchers::WithinAbs(2., 0.003)); } } const std::vector combined_trajectories = wipe_trajectories(combined); const std::vector inward_trajectories = wipe_trajectories(inward_only); REQUIRE_FALSE(combined_trajectories.empty()); REQUIRE(combined_trajectories.size() == inward_trajectories.size()); REQUIRE(trajectories_differ(wipe_destinations(combined), wipe_destinations(loop_move))); bool start_changed = false; for (size_t i = 0; i < combined_trajectories.size(); ++i) { start_changed = start_changed || (combined_trajectories[i].start - inward_trajectories[i].start).norm() > 0.01; REQUIRE_THAT(trajectory_length(combined_trajectories[i]), Catch::Matchers::WithinAbs(trajectory_length(inward_trajectories[i]), 0.01)); } REQUIRE(start_changed); }