WIP: add wait_for_temp_on_wipe_tower feature

This commit is contained in:
SoftFever
2026-08-03 14:35:48 +08:00
parent 97a7eb459f
commit 0f48ad4157
12 changed files with 244 additions and 18 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "Custom Printer",
"version": "02.04.00.02",
"version": "02.04.00.03",
"force_update": "0",
"description": "My configurations",
"machine_model_list": [
@@ -6,6 +6,7 @@
"instantiation": "false",
"gcode_flavor": "klipper",
"single_extruder_multi_material": "0",
"wait_for_temp_on_wipe_tower": "1",
"default_filament_profile": [
"Generic PLA @MyToolChanger"
],
+9 -6
View File
@@ -412,10 +412,12 @@ static std::vector<Vec2d> get_path_of_change_filament(const Print& print)
return gcode;
}
std::string OozePrevention::post_toolchange(GCode& gcodegen)
std::string OozePrevention::post_toolchange(GCode& gcodegen, bool wait)
{
// With wait_for_temp_on_wipe_tower the blocking M109 is emitted by the wipe tower
// generator once the head is back over the tower; here only the target is restored.
return (gcodegen.config().standby_temperature_delta.value != 0) ?
gcodegen.writer().set_temperature(this->_get_temp(gcodegen), true, gcodegen.writer().filament()->id()) :
gcodegen.writer().set_temperature(this->_get_temp(gcodegen), wait, gcodegen.writer().filament()->id()) :
std::string();
}
@@ -1554,7 +1556,8 @@ static std::vector<Vec2d> get_path_of_change_filament(const Print& print)
interface_temp = gcodegen.config().nozzle_temperature_range_high.get_at(new_extruder_id);
toolchange_temp_override = interface_temp;
}
toolchange_gcode_str = gcodegen.set_extruder(new_extruder_id, tcr.print_z, false, toolchange_temp_override); // TODO: toolchange_z vs print_z
toolchange_gcode_str = gcodegen.set_extruder(new_extruder_id, tcr.print_z, false, toolchange_temp_override,
gcodegen.config().wait_for_temp_on_wipe_tower.value); // TODO: toolchange_z vs print_z
if (!travel_to_tower_now && !tcr.priming && WipeTower2::use_gap_wall(gcodegen.m_config)) {
// The tool changed in place (multi-tool printer without ramming), so the
// tower entry is the tcr's own positioning move — a straight line across
@@ -1705,7 +1708,7 @@ static std::vector<Vec2d> get_path_of_change_filament(const Print& print)
std::string trimmed = line;
trimmed.erase(0, trimmed.find_first_not_of(" \t"));
bool skip_line = false;
if (boost::starts_with(trimmed, "M109")) {
if (boost::starts_with(trimmed, "M109") && trimmed.find(WipeTower2::wait_for_temp_tag()) == std::string::npos) {
bool matches_extruder = true;
if (trimmed.find('T') != std::string::npos)
matches_extruder = trimmed.find(t_token) != std::string::npos;
@@ -8936,7 +8939,7 @@ void GCode::update_placeholder_parser_with_variant_params()
}
}
std::string GCode::set_extruder(unsigned int new_filament_id, double print_z, bool by_object, int toolchange_temp_override)
std::string GCode::set_extruder(unsigned int new_filament_id, double print_z, bool by_object, int toolchange_temp_override, bool defer_temp_wait)
{
int new_extruder_id = get_extruder_id(new_filament_id);
if (!m_writer.need_toolchange(new_filament_id))
@@ -9348,7 +9351,7 @@ std::string GCode::set_extruder(unsigned int new_filament_id, double print_z, bo
}
// Set the new extruder to the operating temperature.
if (m_ooze_prevention.enable)
gcode += m_ooze_prevention.post_toolchange(*this);
gcode += m_ooze_prevention.post_toolchange(*this, !defer_temp_wait);
if (m_config.enable_pressure_advance.get_at(new_filament_id)) {
gcode += m_writer.set_pressure_advance(m_config.pressure_advance.get_at(new_filament_id));
+2 -2
View File
@@ -50,7 +50,7 @@ public:
OozePrevention() : enable(false) {}
std::string pre_toolchange(GCode &gcodegen);
std::string post_toolchange(GCode &gcodegen);
std::string post_toolchange(GCode &gcodegen, bool wait = true);
private:
int _get_temp(const GCode &gcodegen) const;
@@ -262,7 +262,7 @@ public:
std::string retract(bool toolchange = false, bool is_last_retraction = false, LiftType lift_type = LiftType::NormalLift, bool apply_instantly = false, ExtrusionRole role = erNone);
// extra_retract forwards a PETG pre-extrusion over-extrusion; default 0 -> identical to the plain deretract.
std::string unretract(float extra_retract = 0.f) { return m_writer.unlift() + m_writer.unretract(extra_retract); }
std::string set_extruder(unsigned int extruder_id, double print_z, bool by_object=false, int toolchange_temp_override = -1);
std::string set_extruder(unsigned int extruder_id, double print_z, bool by_object=false, int toolchange_temp_override = -1, bool defer_temp_wait = false);
bool is_BBL_Printer();
WipeTowerType wipe_tower_type();
+89 -7
View File
@@ -412,6 +412,7 @@ public:
const Vec2f& pos() const { return m_current_pos; }
const Vec2f start_pos_rotated() const { return m_start_pos; }
const Vec2f pos_rotated() const { return this->rotate(m_current_pos); }
const Vec2f rotated(const Vec2f &pt) const { return this->rotate(pt); }
float elapsed_time() const { return m_elapsed_time; }
float get_and_reset_used_filament_length() { float temp = m_used_filament_length; m_used_filament_length = 0.f; return temp; }
@@ -623,10 +624,13 @@ public:
}
// Set extruder temperature, don't wait by default.
WipeTowerWriter2& set_extruder_temp(int temperature, bool wait = false)
WipeTowerWriter2& set_extruder_temp(int temperature, bool wait = false, const std::string& comment = std::string())
{
m_gcode += "G4 S0\n"; // to flush planner queue
m_gcode += "M" + std::to_string(wait ? 109 : 104) + " S" + std::to_string(temperature) + "\n";
m_gcode += "M" + std::to_string(wait ? 109 : 104) + " S" + std::to_string(temperature);
if (!comment.empty())
m_gcode += " " + comment;
m_gcode += "\n";
return *this;
}
@@ -1034,7 +1038,8 @@ WipeTower2::WipeTower2(const PrintConfig& config, const PrintRegionConfig& defau
m_wall_type((int)config.wipe_tower_wall_type),
m_use_gap_wall(use_gap_wall(config)),
m_enable_tower_interface_features(config.enable_tower_interface_features.value),
m_enable_tower_interface_cooldown_during_tower(config.enable_tower_interface_cooldown_during_tower.value)
m_enable_tower_interface_cooldown_during_tower(config.enable_tower_interface_cooldown_during_tower.value),
m_wait_for_temp_on_wipe_tower(config.wait_for_temp_on_wipe_tower.value && !config.single_extruder_multi_material.value)
{
// Read absolute value of first layer speed, if given as percentage,
// it is taken over following default. Speeds from config are not
@@ -1066,6 +1071,7 @@ WipeTower2::WipeTower2(const PrintConfig& config, const PrintRegionConfig& defau
// Calculate where the priming lines should be - very naive test not detecting parallelograms etc.
const std::vector<Vec2d>& bed_points = config.printable_area.values;
BoundingBoxf bb(bed_points);
m_bed_bbox = bb;
m_bed_width = float(bb.size().x());
m_bed_shape = (bed_points.size() == 4 ? RectangularBed : CircularBed);
@@ -1235,7 +1241,7 @@ std::vector<WipeTower::ToolChangeResult> WipeTower2::prime(
unsigned int tool = tools[idx_tool];
m_left_to_right = true;
toolchange_Change(writer, tool, m_filpar[tool].material); // Select the tool, set a speed override for soluble and flex materials.
toolchange_Change(writer, tool, m_filpar[tool].material, m_filpar[tool].first_layer_temperature, false); // Select the tool, set a speed override for soluble and flex materials.
toolchange_Load(writer, cleaning_box); // Prime the tool.
if (idx_tool + 1 == tools.size()) {
// Last tool should not be unloaded, but it should be wiped enough to become of a pure color.
@@ -1354,13 +1360,19 @@ WipeTower::ToolChangeResult WipeTower2::tool_change(size_t tool)
toolchange_Unload(writer, cleaning_box, m_filpar[m_current_tool].material,
(is_first_layer() ? m_filpar[m_current_tool].first_layer_temperature : m_filpar[m_current_tool].temperature),
new_tool_temp);
toolchange_Change(writer, tool, m_filpar[tool].material); // Change the tool, set a speed override for soluble and flex materials.
// Wait-at-tower target: the interface temp when an interface boost applies on this layer,
// otherwise the print temp (nozzle_temperature == 0 means "use the first layer temp").
int wait_for_temp = interface_layer && m_filpar[tool].interface_print_temperature > 0 ?
m_filpar[tool].interface_print_temperature :
(is_first_layer() || m_filpar[tool].temperature == 0 ? m_filpar[tool].first_layer_temperature : m_filpar[tool].temperature);
toolchange_Change(writer, tool, m_filpar[tool].material, wait_for_temp, true); // Change the tool, set a speed override for soluble and flex materials.
toolchange_Load(writer, cleaning_box);
writer.travel(writer.x(), writer.y()-m_perimeter_width); // cooling and loading were done a bit down the road
int base_temp = is_first_layer() ? m_filpar[tool].first_layer_temperature : m_filpar[tool].temperature;
if (interface_layer) {
int interface_temp = m_filpar[tool].interface_print_temperature;
if (interface_temp > 0 && interface_temp != base_temp)
// With wait-for-temp-on-wipe-tower the toolchange already blocked for the interface temp.
if (interface_temp > 0 && interface_temp != base_temp && !m_wait_for_temp_on_wipe_tower)
writer.set_extruder_temp(interface_temp, true);
if (m_enable_tower_interface_cooldown_during_tower && interface_temp > 0 && interface_temp != base_temp)
writer.set_extruder_temp(base_temp, false);
@@ -1670,7 +1682,9 @@ void WipeTower2::toolchange_Unload(
void WipeTower2::toolchange_Change(
WipeTowerWriter2 &writer,
const size_t new_tool,
const std::string& new_material)
const std::string& new_material,
const int wait_for_temp,
const bool wait_beside_tower)
{
// Ask the writer about how much of the old filament we consumed:
if (m_current_tool < m_used_filament_length.size())
@@ -1684,6 +1698,70 @@ void WipeTower2::toolchange_Change(
if (m_is_mk4mmu3)
writer.switch_filament_monitoring(true);
const bool wait_for_temp_here = m_wait_for_temp_on_wipe_tower && wait_for_temp > 0;
// The Tn above was issued without a blocking temperature wait (OozePrevention::post_toolchange
// only restores the target); block here, before the deretraction below, which must not extrude
// on a cold nozzle. Like the Bambu H2C, park beside the tower for the heat-up so drool lands
// next to it instead of on its top surface — nearest x side first, the other side as fallback,
// in place if both would leave the bed. Raw pre-rotated moves (see the repositioning move
// below) keep the writer's tracked position at the tower entry. The tag keeps the
// interface-temp deduplication pass in append_tcr2 from stripping the M109.
if (wait_for_temp_here && wait_beside_tower) {
// The rib wall and the stabilization cone bulge past the nominal width rectangle
// (widest near the bottom), and the first-layer brim is printed around the wall later
// in the layer — clear the widest of them, not just the rectangle, so the park point
// and its drool stay off the tower.
float min_x = 0.f, max_x = m_wipe_tower_width;
if (m_wall_type == (int)wtwRib) {
WipeTower::box_coordinates wt_box(Vec2f(0.f, 0.f), m_wipe_tower_width, m_layer_info->depth + m_perimeter_width);
const BoundingBox rib_bbox = get_extents(generate_rib_polygon(wt_box)); // the fillet stays within this bbox
min_x = std::min(min_x, unscaled<float>(rib_bbox.min.x()));
max_x = std::max(max_x, unscaled<float>(rib_bbox.max.x()));
} else if (m_wall_type == (int)wtwCone) {
const double support_scale = get_wipe_tower_cone_base(m_wipe_tower_width, m_wipe_tower_height, m_wipe_tower_depth,
m_wipe_tower_cone_angle).second;
const double z = m_no_sparse_layers ? (m_current_height + m_layer_info->height) : m_layer_info->z;
const double r = std::tan(Geometry::deg2rad(m_wipe_tower_cone_angle / 2.f)) * (m_wipe_tower_height - z);
const double w = m_layer_info->depth + m_perimeter_width;
if (r > 0.5 * w + 0.01) { // same guard as generate_support_cone_wall
const float bulge = float(std::sqrt(r * r - 0.25 * w * w) / support_scale);
min_x = std::min(min_x, m_wipe_tower_width / 2.f - bulge);
max_x = std::max(max_x, m_wipe_tower_width / 2.f + bulge);
}
}
if (is_first_layer()) {
const float brim = m_wipe_tower_brim_width < 0.f ? WipeTower::get_auto_brim_by_height(m_wipe_tower_height) :
m_wipe_tower_brim_width;
min_x -= brim;
max_x += brim;
}
constexpr float gap = 2.f;
const float near_x = writer.x() < m_wipe_tower_width / 2.f ? min_x - gap : max_x + gap;
const float far_x = near_x < m_wipe_tower_width / 2.f ? max_x + gap : min_x - gap;
const float a = float(m_wipe_tower_rotation_angle * M_PI / 180.);
const float c = std::cos(a), s = std::sin(a);
for (float side_x : { near_x, far_x }) {
const Vec2f stop = writer.rotated(Vec2f(side_x, writer.y()));
const Vec2f wt = stop + m_rib_offset;
const Vec2f bed_pt(c * wt.x() - s * wt.y() + m_wipe_tower_pos.x(),
s * wt.x() + c * wt.y() + m_wipe_tower_pos.y());
bool on_bed = false;
if (m_bed_shape == RectangularBed)
on_bed = m_bed_bbox.contains(bed_pt.cast<double>());
else if (m_bed_shape == CircularBed)
on_bed = (bed_pt.cast<double>() - m_bed_bbox.center()).norm() <= m_bed_width / 2.;
if (!on_bed)
continue;
writer.feedrate(m_travel_speed * 60.f)
.append(std::string("G1 X") + Slic3r::float_to_string_decimal_point(stop.x())
+ " Y" + Slic3r::float_to_string_decimal_point(stop.y())
+ never_skip_tag() + "\n");
break;
}
writer.set_extruder_temp(wait_for_temp, true, wait_for_temp_tag());
}
// Travel to where we assume we are. Custom toolchange or some special T code handling (parking extruder etc)
// gcode could have left the extruder somewhere, we cannot just start extruding. We should also inform the
// postprocessor that we absolutely want to have this in the gcode, even if it thought it is the same as before.
@@ -1694,6 +1772,10 @@ void WipeTower2::toolchange_Change(
+ never_skip_tag() + "\n"
);
// Priming has no tower to park beside — wait right at the priming line instead.
if (wait_for_temp_here && !wait_beside_tower)
writer.set_extruder_temp(wait_for_temp, true, wait_for_temp_tag());
writer.append("[deretraction_from_wipe_tower_generator]");
// The toolchange Tn command will be inserted later, only in case that the user does
+8 -1
View File
@@ -22,6 +22,9 @@ class WipeTower2
{
public:
static const std::string never_skip_tag() { return "_GCODE_WIPE_TOWER_NEVER_SKIP_TAG"; }
// Marks the wait-for-temp-on-wipe-tower M109 so the interface-temp deduplication pass
// in WipeTowerIntegration::append_tcr2 does not strip it.
static const std::string wait_for_temp_tag() { return ";_WAIT_FOR_TEMP_ON_WIPE_TOWER"; }
static std::pair<double, double> get_wipe_tower_cone_base(double width, double height, double depth, double angle_deg);
static std::vector<std::vector<float>> extract_wipe_volumes(const PrintConfig& config);
@@ -227,6 +230,7 @@ private:
size_t m_first_layer_idx = size_t(-1);
bool m_enable_tower_interface_features = false;
bool m_enable_tower_interface_cooldown_during_tower = false;
bool m_wait_for_temp_on_wipe_tower = false;
bool m_prev_layer_had_interface = false;
bool m_current_layer_has_interface = false;
@@ -263,6 +267,7 @@ private:
} m_bed_shape;
float m_bed_width; // width of the bed bounding box
Vec2f m_bed_bottom_left; // bottom-left corner coordinates (for rectangular beds)
BoundingBoxf m_bed_bbox; // bounding box of the printable area
float m_perimeter_width = 0.4f * Width_To_Nozzle_Ratio; // Width of an extrusion line, also a perimeter spacing for 100% infill.
float m_extrusion_flow = 0.038f; //0.029f;// Extrusion flow is derived from m_perimeter_width, layer height and filament diameter.
@@ -385,7 +390,9 @@ private:
void toolchange_Change(
WipeTowerWriter2 &writer,
const size_t new_tool,
const std::string& new_material);
const std::string& new_material,
const int wait_for_temp,
const bool wait_beside_tower);
void toolchange_Load(
WipeTowerWriter2 &writer,
+1 -1
View File
@@ -1422,7 +1422,7 @@ static std::vector<std::string> s_Preset_printer_options {
"use_relative_e_distances", "extruder_type", "use_firmware_retraction", "printer_notes",
"grab_length", "support_object_skip_flush", "physical_extruder_map",
"cooling_tube_retraction",
"cooling_tube_length", "high_current_on_filament_swap", "parking_pos_retraction", "extra_loading_move", "wipe_tower_type", "purge_in_prime_tower", "enable_filament_ramming", "tool_change_on_wipe_tower",
"cooling_tube_length", "high_current_on_filament_swap", "parking_pos_retraction", "extra_loading_move", "wipe_tower_type", "purge_in_prime_tower", "enable_filament_ramming", "tool_change_on_wipe_tower", "wait_for_temp_on_wipe_tower",
"z_offset",
"disable_m73", "preferred_orientation", "emit_machine_limits_to_gcode", "pellet_modded_printer", "support_multi_bed_types", "use_3mf", "default_bed_type", "bed_mesh_min","bed_mesh_max","bed_mesh_probe_distance", "adaptive_bed_mesh_margin", "enable_long_retraction_when_cut","long_retractions_when_cut","retraction_distances_when_cut",
"bed_temperature_formula", "nozzle_flush_dataset",
+1
View File
@@ -382,6 +382,7 @@ bool Print::invalidate_state_by_config_options(const ConfigOptionResolver & /* n
|| opt_key == "wiping_volumes_extruders"
|| opt_key == "enable_filament_ramming"
|| opt_key == "tool_change_on_wipe_tower"
|| opt_key == "wait_for_temp_on_wipe_tower"
|| opt_key == "purge_in_prime_tower"
|| opt_key == "z_offset"
|| opt_key == "support_multi_bed_types"
+11
View File
@@ -6542,6 +6542,17 @@ void PrintConfigDef::init_fff_params()
def->mode = comAdvanced;
def->set_default_value(new ConfigOptionBool(false));
def = this->add("wait_for_temp_on_wipe_tower", coBool);
def->label = L("Wait for temperature on wipe tower");
def->tooltip = L("Pick up the new tool without waiting for it to reach printing temperature, travel to the wipe "
"tower, and wait for the temperature there, right before purging. Ooze from the heat-up lands on "
"the tower instead of the model, and the travel overlaps with the heating. "
"Only relevant for multi-extruder (multi-toolhead) printers using a Type 2 wipe tower. "
"The firmware or tool change macro must not wait for the temperature itself. "
"When disabled, the temperature wait is issued right after the tool change command.");
def->mode = comAdvanced;
def->set_default_value(new ConfigOptionBool(false));
def = this->add("wipe_tower_no_sparse_layers", coBool);
def->label = L("No sparse layers (beta)");
+1
View File
@@ -1658,6 +1658,7 @@ PRINT_CONFIG_CLASS_DEFINE(
((ConfigOptionBool, purge_in_prime_tower))
((ConfigOptionBool, enable_filament_ramming))
((ConfigOptionBool, tool_change_on_wipe_tower))
((ConfigOptionBool, wait_for_temp_on_wipe_tower))
((ConfigOptionBool, support_multi_bed_types))
((ConfigOptionBool, use_3mf))
+2
View File
@@ -5582,6 +5582,7 @@ if (is_marlin_flavor)
optgroup->append_single_option_line("purge_in_prime_tower", "printer_multimaterial_wipe_tower#purge-in-prime-tower");
optgroup->append_single_option_line("enable_filament_ramming", "printer_multimaterial_wipe_tower#enable-filament-ramming");
optgroup->append_single_option_line("tool_change_on_wipe_tower", "printer_multimaterial_wipe_tower#tool-change-on-wipe-tower");
optgroup->append_single_option_line("wait_for_temp_on_wipe_tower", "printer_multimaterial_wipe_tower#wait-for-temperature-on-wipe-tower");
optgroup = page->new_optgroup(L("Single extruder multi-material parameters"), "param_settings");
@@ -6095,6 +6096,7 @@ void TabPrinter::toggle_options()
// so the option is irrelevant there.
const size_t extruders_count = m_config->option<ConfigOptionFloats>("nozzle_diameter")->size();
toggle_option("tool_change_on_wipe_tower", !bSEMM && supports_wipe_tower_2 && extruders_count > 1);
toggle_option("wait_for_temp_on_wipe_tower", !bSEMM && supports_wipe_tower_2 && extruders_count > 1);
}
wxString extruder_number;
long val = 1;
+118
View File
@@ -5,8 +5,10 @@
#include "test_helpers.hpp"
#include <cctype>
#include <limits>
#include <set>
#include <string>
#include <vector>
using namespace Slic3r;
using namespace Slic3r::Test;
@@ -86,6 +88,122 @@ TEST_CASE("Per-object wall filament override is honored", "[MultiFilament]")
CHECK(tools_for_role(gcode, "infill") == std::set<int>{ 0 }); // infill not overridden: stays on F1
}
// With wait_for_temp_on_wipe_tower the blocking M109 moves from right after the Tn command to
// a stop point parked beside the wipe tower (heat-up drool falls next to the tower, not onto
// its top): tagged with _WAIT_FOR_TEMP_ON_WIPE_TOWER, after the toolchange and before the
// repositioning move and the first extrusion of the purge, while the post-toolchange restore
// demotes to a non-blocking M104. Ordering and the off-tower stop are the contract here.
TEST_CASE("Toolchange temperature wait moves to the wipe tower when enabled", "[MultiFilament]")
{
const bool wait_on_tower = GENERATE(false, true);
DYNAMIC_SECTION("wait_for_temp_on_wipe_tower " << (wait_on_tower ? 1 : 0)) {
const std::string gcode = slice_with_object_overrides(
{ cube(20), cube(20) },
multifilament_config(2, {
{ "nozzle_diameter", "0.4,0.4" },
{ "printer_extruder_id", "1,2" },
{ "printer_extruder_variant", "Direct Drive Standard,Direct Drive Standard" },
{ "extruder_printable_height", "0,0" },
{ "single_extruder_multi_material", 0 },
{ "enable_prime_tower", 1 },
{ "prime_tower_width", 35 },
{ "wipe_tower_x", "50" },
{ "wipe_tower_y", "50" },
{ "ooze_prevention", 1 },
{ "standby_temperature_delta", -40 },
{ "wait_for_temp_on_wipe_tower", wait_on_tower ? 1 : 0 },
}),
// One filament per object -> a toolchange on every layer. Assigned at the object
// level: the used-filament count that gates the prime tower is derived from
// object/volume configs on the harness's single apply (region filament ids such
// as sparse_infill_filament_id are not counted there and the tower would be
// silently disabled).
{ { { "extruder", 1 } }, { { "extruder", 2 } } });
// Split into lines and scan the "; CP TOOLCHANGE START".."; CP TOOLCHANGE END" blocks.
std::vector<std::string> lines;
for (size_t pos = 0; pos < gcode.size();) {
size_t eol = gcode.find('\n', pos);
if (eol == std::string::npos)
eol = gcode.size();
lines.emplace_back(gcode.substr(pos, eol - pos));
pos = eol + 1;
}
const auto is_tool_line = [](const std::string& l) { return l.size() >= 2 && l[0] == 'T' && std::isdigit((unsigned char)l[1]); };
const auto is_m109_line = [](const std::string& l) { return l.rfind("M109", 0) == 0; };
const auto is_tagged_wait = [](const std::string& l) { return l.find("_WAIT_FOR_TEMP_ON_WIPE_TOWER") != std::string::npos; };
const auto is_extruding = [](const std::string& l) {
if (l.rfind("G1 ", 0) != 0)
return false;
const size_t e = l.find(" E");
return e != std::string::npos && l.find_first_of("XY") != std::string::npos && l[e + 2] != '-';
};
int checked_blocks = 0;
for (size_t i = 0; i < lines.size(); ++i) {
if (lines[i].find("; CP TOOLCHANGE START") == std::string::npos)
continue;
size_t block_end = i;
while (block_end < lines.size() && lines[block_end].find("; CP TOOLCHANGE END") == std::string::npos)
++block_end;
size_t tool_line = block_end;
for (size_t j = i; j < block_end; ++j)
if (is_tool_line(lines[j])) { tool_line = j; break; }
if (tool_line == block_end)
continue; // final unload block, no toolchange
++checked_blocks;
size_t tagged_wait = block_end, untagged_m109 = block_end, first_extrusion = block_end;
for (size_t j = tool_line + 1; j < block_end; ++j) {
if (is_m109_line(lines[j]) && tagged_wait == block_end && is_tagged_wait(lines[j]))
tagged_wait = j;
if (is_m109_line(lines[j]) && untagged_m109 == block_end && !is_tagged_wait(lines[j]))
untagged_m109 = j;
if (first_extrusion == block_end && is_extruding(lines[j]))
first_extrusion = j;
}
INFO("toolchange block at line " << i + 1);
if (wait_on_tower) {
// The only blocking wait is the tagged one, parked beside the tower before the purge.
REQUIRE(tagged_wait < block_end);
CHECK(untagged_m109 == block_end);
REQUIRE(first_extrusion < block_end);
CHECK(tagged_wait < first_extrusion);
// The travel preceding the wait parks outside the tower footprint. The tower
// auto-sizes, so derive its extent from the purge extrusions of this block.
size_t stop_line = block_end;
for (size_t j = tagged_wait; j-- > tool_line;)
if (lines[j].rfind("G1 ", 0) == 0 && lines[j].find('X') != std::string::npos) { stop_line = j; break; }
REQUIRE(stop_line < block_end);
const double stop_x = std::stod(lines[stop_line].substr(lines[stop_line].find('X') + 1));
double purge_min_x = std::numeric_limits<double>::max(), purge_max_x = std::numeric_limits<double>::lowest();
for (size_t j = tagged_wait; j < block_end; ++j) {
const size_t x_pos = lines[j].find('X');
if (!is_extruding(lines[j]) || x_pos == std::string::npos)
continue;
const double x = std::stod(lines[j].substr(x_pos + 1));
purge_min_x = std::min(purge_min_x, x);
purge_max_x = std::max(purge_max_x, x);
}
REQUIRE(purge_min_x <= purge_max_x);
INFO("stop travel: " << lines[stop_line] << " purge x range: " << purge_min_x << ".." << purge_max_x);
const bool beside_tower = stop_x < purge_min_x - 0.5 || stop_x > purge_max_x + 0.5;
CHECK(beside_tower);
} else {
// Stock behavior: the blocking wait follows the toolchange command directly.
REQUIRE(untagged_m109 < block_end);
CHECK(tagged_wait == block_end);
if (first_extrusion < block_end)
CHECK(untagged_m109 < first_extrusion);
}
i = block_end;
}
REQUIRE(checked_blocks > 0);
if (!wait_on_tower)
CHECK(gcode.find("_WAIT_FOR_TEMP_ON_WIPE_TOWER") == std::string::npos);
}
}
// max_layer_height can be shorter than the extruder count (normalization sizes it to the
// filament count under single_extruder_multi_material). calc_max_layer_height() in ToolOrdering
// indexed it per-nozzle and read past the end. Shortened directly here to isolate that read;