diff --git a/resources/profiles/Custom.json b/resources/profiles/Custom.json index 0429742c88..7eba013111 100644 --- a/resources/profiles/Custom.json +++ b/resources/profiles/Custom.json @@ -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": [ diff --git a/resources/profiles/Custom/machine/fdm_toolchanger_common.json b/resources/profiles/Custom/machine/fdm_toolchanger_common.json index 7ef8b5207c..55f1b2cadb 100644 --- a/resources/profiles/Custom/machine/fdm_toolchanger_common.json +++ b/resources/profiles/Custom/machine/fdm_toolchanger_common.json @@ -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" ], diff --git a/src/libslic3r/GCode.cpp b/src/libslic3r/GCode.cpp index b898284d89..85903cb779 100644 --- a/src/libslic3r/GCode.cpp +++ b/src/libslic3r/GCode.cpp @@ -1554,7 +1554,8 @@ static std::vector 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, + WipeTower2::wait_for_temp_enabled(gcodegen.m_config)); // 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 +1706,7 @@ static std::vector 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; @@ -8939,7 +8940,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)) @@ -9046,6 +9047,24 @@ std::string GCode::set_extruder(unsigned int new_filament_id, double print_z, bo if (toolchange_temp_override > 0) new_filament_temp = toolchange_temp_override; + // With wait_for_temp_on_wipe_tower the blocking M109 is deferred to the wipe tower, so raise + // the incoming filament's target here — ahead of the tool change rather than after it — and + // let the heat-up overlap the change itself as well as the travel to the tower. The command + // always carries an explicit tool index (the option is off for single extruder MM, so the + // writer emits one), leaving the outgoing filament that pre_toolchange just dropped to its + // standby temperature alone. nozzle_temperature == 0 means "use the first layer temperature". + if (defer_temp_wait) { + // Target what the tower will wait on. It waits on the first layer temperature not only on + // the first layer but also while priming, which runs before any layer is set: there + // on_first_layer() is false and print_z is the initial layer height, so neither test above + // catches it. nozzle_temperature == 0 means "use the first layer temperature" as well. + int preheat_temp = new_filament_temp; + if (toolchange_temp_override <= 0 && (m_layer == nullptr || preheat_temp <= 0)) + preheat_temp = m_config.nozzle_temperature_initial_layer.get_at(new_fi); + if (preheat_temp > 0) + gcode += m_writer.set_temperature(preheat_temp, false, new_filament_id); + } + Vec3d nozzle_pos = m_writer.get_position(); float old_retract_length, old_retract_length_toolchange, wipe_volume; int old_filament_temp, old_filament_e_feedrate; @@ -9349,8 +9368,10 @@ std::string GCode::set_extruder(unsigned int new_filament_id, double print_z, bo } check_add_eol(gcode); } - // Set the new extruder to the operating temperature. - if (m_ooze_prevention.enable) + // Set the new extruder to the operating temperature. With defer_temp_wait the target was + // already raised before the tool change and the blocking wait belongs to the wipe tower + // generator, so there is nothing left to restore here. + if (m_ooze_prevention.enable && !defer_temp_wait) gcode += m_ooze_prevention.post_toolchange(*this); if (m_config.enable_pressure_advance.get_at(new_filament_id)) { diff --git a/src/libslic3r/GCode.hpp b/src/libslic3r/GCode.hpp index 47abf6be85..6346334889 100644 --- a/src/libslic3r/GCode.hpp +++ b/src/libslic3r/GCode.hpp @@ -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(); diff --git a/src/libslic3r/GCode/WipeTower2.cpp b/src/libslic3r/GCode/WipeTower2.cpp index 0837bfa908..34e4b6146f 100644 --- a/src/libslic3r/GCode/WipeTower2.cpp +++ b/src/libslic3r/GCode/WipeTower2.cpp @@ -413,6 +413,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; } @@ -624,10 +625,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()) { 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; } @@ -1006,6 +1010,13 @@ bool WipeTower2::use_gap_wall(const PrintConfig& config) return config.prime_tower_skip_points.value && config.wipe_tower_wall_type.value != wtwCone; } +bool WipeTower2::wait_for_temp_enabled(const PrintConfig& config) +{ + // SEMM runs its own unload/load temperature sequence; the GUI hides the option + // there but a profile may still carry it set. + return config.wait_for_temp_on_wipe_tower.value && !config.single_extruder_multi_material.value; +} + WipeTower2::WipeTower2(const PrintConfig& config, const PrintRegionConfig& default_region_config,int plate_idx, Vec3d plate_origin, const std::vector>& wiping_matrix, size_t initial_tool) : m_semm(config.single_extruder_multi_material.value), m_enable_filament_ramming(config.enable_filament_ramming.value), @@ -1035,7 +1046,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(wait_for_temp_enabled(config)) { // Read absolute value of first layer speed, if given as percentage, // it is taken over following default. Speeds from config are not @@ -1085,6 +1097,7 @@ WipeTower2::WipeTower2(const PrintConfig& config, const PrintRegionConfig& defau m_bed_bottom_left = m_bed_shape == RectangularBed ? Vec2f(bed_points.front().x(), bed_points.front().y()) : Vec2f::Zero(); + m_bed_polygon = Polygon::new_scale(bed_points); } @@ -1236,7 +1249,7 @@ std::vector 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. @@ -1355,13 +1368,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); @@ -1671,7 +1690,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()) @@ -1685,6 +1706,90 @@ 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 (GCode::set_extruder only raises + // the target, ahead of the Tn); 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, then that side clamped + // toward the bed edge, then the far side, in place if all 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(rib_bbox.min.x())); + max_x = std::max(max_x, unscaled(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; + constexpr float min_gap = 0.5f; + const bool on_left = writer.x() < m_wipe_tower_width / 2.f; + const float near_x = on_left ? min_x - gap : max_x + gap; + const float far_x = on_left ? max_x + gap : min_x - gap; + const Eigen::Rotation2Df to_bed(float(Geometry::deg2rad(m_wipe_tower_rotation_angle))); + auto park_pt_on_bed = [this, &writer, to_bed](float side_x) { + const Vec2f bed_pt = to_bed * (writer.rotated(Vec2f(side_x, writer.y())) + m_rib_offset) + m_wipe_tower_pos; + return m_bed_polygon.contains(Point::new_scale(bed_pt.x(), bed_pt.y())); + }; + float park_x = near_x; + bool have_park = park_pt_on_bed(near_x); + if (!have_park) { + // The ideal near point hangs off the bed: pull it back to the bed edge as long + // as that still clears the tower envelope by min_gap (the BBL tower clamps its + // stop_pos against the bed the same way in append_tcr). Bisection, because with + // tower rotation and non-rectangular beds the bed edge is not axis-aligned. + const float limit_x = on_left ? min_x - min_gap : max_x + min_gap; + if (park_pt_on_bed(limit_x)) { + float on = limit_x, off = near_x; + for (int i = 0; i < 8; ++i) { + const float mid = 0.5f * (on + off); + if (park_pt_on_bed(mid)) + on = mid; + else + off = mid; + } + park_x = on; + have_park = true; + } + } + if (!have_park && park_pt_on_bed(far_x)) { + park_x = far_x; + have_park = true; + } + if (have_park) { + const Vec2f stop = writer.rotated(Vec2f(park_x, writer.y())); + 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"); + } + 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. @@ -1695,6 +1800,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 diff --git a/src/libslic3r/GCode/WipeTower2.hpp b/src/libslic3r/GCode/WipeTower2.hpp index 3efa884202..ca9e73bb28 100644 --- a/src/libslic3r/GCode/WipeTower2.hpp +++ b/src/libslic3r/GCode/WipeTower2.hpp @@ -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 get_wipe_tower_cone_base(double width, double height, double depth, double angle_deg); static std::vector> extract_wipe_volumes(const PrintConfig& config); @@ -38,6 +41,11 @@ public: // Shared with the entry routing in GCode.cpp so the router and the tower agree. static bool use_gap_wall(const PrintConfig& config); + // Whether the blocking toolchange temperature wait moves onto the wipe tower. + // Shared with the defer flag in GCode.cpp append_tcr2 so the deferral and the + // tower's tagged M109 can never disagree. + static bool wait_for_temp_enabled(const PrintConfig& config); + // x -- x coordinates of wipe tower in mm ( left bottom corner ) // y -- y coordinates of wipe tower in mm ( left bottom corner ) // width -- width of wipe tower in mm ( default 60 mm - leave as it is ) @@ -227,6 +235,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 +272,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) + Polygon m_bed_polygon; // printable_area contour (scaled) 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 +395,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, diff --git a/src/libslic3r/Preset.cpp b/src/libslic3r/Preset.cpp index 47d448b69b..9ae8b86fd8 100644 --- a/src/libslic3r/Preset.cpp +++ b/src/libslic3r/Preset.cpp @@ -1423,7 +1423,7 @@ static std::vector 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", diff --git a/src/libslic3r/Print.cpp b/src/libslic3r/Print.cpp index 371f1e68ea..61025c89d7 100644 --- a/src/libslic3r/Print.cpp +++ b/src/libslic3r/Print.cpp @@ -282,6 +282,14 @@ bool Print::invalidate_state_by_config_options(const ConfigOptionResolver & /* n || opt_key == "wipe_tower_x" || opt_key == "wipe_tower_y" || opt_key == "wipe_tower_rotation_angle") { + // The tower gcode itself is position-independent (position and rotation are applied + // at export), except that the wait_for_temp_on_wipe_tower park bakes a bed-relative + // side choice into it (WipeTower2::toolchange_Change) — regenerate it when the tower + // moves. Gating on the old config is safe: both inputs of wait_for_temp_enabled + // invalidate psWipeTower themselves when they are part of the same diff. + if ((opt_key == "wipe_tower_x" || opt_key == "wipe_tower_y" || opt_key == "wipe_tower_rotation_angle") + && WipeTower2::wait_for_temp_enabled(m_config)) + steps.emplace_back(psWipeTower); steps.emplace_back(psSkirtBrim); } else if ( opt_key == "slicing_pipeline_plugin" @@ -382,6 +390,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" diff --git a/src/libslic3r/PrintConfig.cpp b/src/libslic3r/PrintConfig.cpp index 849b0754dc..fdb20253d6 100644 --- a/src/libslic3r/PrintConfig.cpp +++ b/src/libslic3r/PrintConfig.cpp @@ -6570,6 +6570,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)"); diff --git a/src/libslic3r/PrintConfig.hpp b/src/libslic3r/PrintConfig.hpp index c1875e0288..6029d5bd88 100644 --- a/src/libslic3r/PrintConfig.hpp +++ b/src/libslic3r/PrintConfig.hpp @@ -1660,6 +1660,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)) diff --git a/src/slic3r/GUI/Tab.cpp b/src/slic3r/GUI/Tab.cpp index f5cb3d045b..64c2b586c8 100644 --- a/src/slic3r/GUI/Tab.cpp +++ b/src/slic3r/GUI/Tab.cpp @@ -5627,6 +5627,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"); @@ -6160,6 +6161,7 @@ void TabPrinter::toggle_options() // so the option is irrelevant there. const size_t extruders_count = m_config->option("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; diff --git a/tests/data/wipe_tower_temperature_trace_main.txt b/tests/data/wipe_tower_temperature_trace_main.txt new file mode 100644 index 0000000000..b7453bdc93 --- /dev/null +++ b/tests/data/wipe_tower_temperature_trace_main.txt @@ -0,0 +1,167 @@ +# Temperature and tool-change commands of a wait_for_temp_on_wipe_tower-off slice, +# captured from the main branch at a10d9e77cf. Regeneration is described +# at the test that reads this file: "Toolchange temperature commands are unchanged +# when the wipe tower wait is off" in tests/fff_print/test_multifilament.cpp. +M104 S215 T0 ; set nozzle temperature +M104 S215 T1 ; set nozzle temperature +; CP PRIMING START +T1 ; change extruder +M109 S215 T1 ; set nozzle temperature and wait for it to be reached +M104 S175 T1 ; set nozzle temperature ;cooldown +T0 ; change extruder +M109 S215 T0 ; set nozzle temperature and wait for it to be reached +; CP PRIMING END +M104 S215 T1 ; preheat T1 time: 30s lead 30.2s +; CP TOOLCHANGE START +M104 S175 T0 ; set nozzle temperature ;cooldown +T1 ; change extruder +M109 S215 T1 ; set nozzle temperature and wait for it to be reached +; CP TOOLCHANGE END +M104 S240 T1 ; set nozzle temperature +M104 S240 T0 ; preheat T0 time: 30s lead 30.0s +; CP TOOLCHANGE START +M104 S200 T1 ; set nozzle temperature ;cooldown +T0 ; change extruder +M109 S240 T0 ; set nozzle temperature and wait for it to be reached +; CP TOOLCHANGE END +M104 S240 T1 ; preheat T1 time: 30s lead 30.4s +; CP TOOLCHANGE START +M104 S200 T0 ; set nozzle temperature ;cooldown +T1 ; change extruder +M109 S240 T1 ; set nozzle temperature and wait for it to be reached +; CP TOOLCHANGE END +M104 S240 T0 ; preheat T0 time: 31s lead 30.9s +; CP TOOLCHANGE START +M104 S200 T1 ; set nozzle temperature ;cooldown +T0 ; change extruder +M109 S240 T0 ; set nozzle temperature and wait for it to be reached +; CP TOOLCHANGE END +M104 S240 T1 ; preheat T1 time: 30s lead 30.2s +; CP TOOLCHANGE START +M104 S200 T0 ; set nozzle temperature ;cooldown +T1 ; change extruder +M109 S240 T1 ; set nozzle temperature and wait for it to be reached +; CP TOOLCHANGE END +M104 S240 T0 ; preheat T0 time: 30s lead 30.2s +; CP TOOLCHANGE START +M104 S200 T1 ; set nozzle temperature ;cooldown +T0 ; change extruder +M109 S240 T0 ; set nozzle temperature and wait for it to be reached +; CP TOOLCHANGE END +M104 S240 T1 ; preheat T1 time: 30s lead 30.2s +; CP TOOLCHANGE START +M104 S200 T0 ; set nozzle temperature ;cooldown +T1 ; change extruder +M109 S240 T1 ; set nozzle temperature and wait for it to be reached +; CP TOOLCHANGE END +M104 S240 T0 ; preheat T0 time: 30s lead 30.2s +; CP TOOLCHANGE START +M104 S200 T1 ; set nozzle temperature ;cooldown +T0 ; change extruder +M109 S240 T0 ; set nozzle temperature and wait for it to be reached +; CP TOOLCHANGE END +M104 S240 T1 ; preheat T1 time: 30s lead 30.2s +; CP TOOLCHANGE START +M104 S200 T0 ; set nozzle temperature ;cooldown +T1 ; change extruder +M109 S240 T1 ; set nozzle temperature and wait for it to be reached +; CP TOOLCHANGE END +M104 S240 T0 ; preheat T0 time: 30s lead 30.2s +; CP TOOLCHANGE START +M104 S200 T1 ; set nozzle temperature ;cooldown +T0 ; change extruder +M109 S240 T0 ; set nozzle temperature and wait for it to be reached +; CP TOOLCHANGE END +M104 S240 T1 ; preheat T1 time: 31s lead 30.7s +; CP TOOLCHANGE START +M104 S200 T0 ; set nozzle temperature ;cooldown +T1 ; change extruder +M109 S240 T1 ; set nozzle temperature and wait for it to be reached +; CP TOOLCHANGE END +M104 S240 T0 ; preheat T0 time: 31s lead 30.6s +; CP TOOLCHANGE START +M104 S200 T1 ; set nozzle temperature ;cooldown +T0 ; change extruder +M109 S240 T0 ; set nozzle temperature and wait for it to be reached +; CP TOOLCHANGE END +M104 S240 T1 ; preheat T1 time: 30s lead 30.3s +; CP TOOLCHANGE START +M104 S200 T0 ; set nozzle temperature ;cooldown +T1 ; change extruder +M109 S240 T1 ; set nozzle temperature and wait for it to be reached +; CP TOOLCHANGE END +M104 S240 T0 ; preheat T0 time: 31s lead 30.6s +; CP TOOLCHANGE START +M104 S200 T1 ; set nozzle temperature ;cooldown +T0 ; change extruder +M109 S240 T0 ; set nozzle temperature and wait for it to be reached +; CP TOOLCHANGE END +M104 S240 T1 ; preheat T1 time: 30s lead 30.0s +; CP TOOLCHANGE START +M104 S200 T0 ; set nozzle temperature ;cooldown +T1 ; change extruder +M109 S240 T1 ; set nozzle temperature and wait for it to be reached +; CP TOOLCHANGE END +M104 S240 T0 ; preheat T0 time: 30s lead 30.2s +; CP TOOLCHANGE START +M104 S200 T1 ; set nozzle temperature ;cooldown +T0 ; change extruder +M109 S240 T0 ; set nozzle temperature and wait for it to be reached +; CP TOOLCHANGE END +M104 S240 T1 ; preheat T1 time: 30s lead 30.2s +; CP TOOLCHANGE START +M104 S200 T0 ; set nozzle temperature ;cooldown +T1 ; change extruder +M109 S240 T1 ; set nozzle temperature and wait for it to be reached +; CP TOOLCHANGE END +M104 S240 T0 ; preheat T0 time: 30s lead 30.2s +; CP TOOLCHANGE START +M104 S200 T1 ; set nozzle temperature ;cooldown +T0 ; change extruder +M109 S240 T0 ; set nozzle temperature and wait for it to be reached +; CP TOOLCHANGE END +M104 S240 T1 ; preheat T1 time: 30s lead 30.2s +; CP TOOLCHANGE START +M104 S200 T0 ; set nozzle temperature ;cooldown +T1 ; change extruder +M109 S240 T1 ; set nozzle temperature and wait for it to be reached +; CP TOOLCHANGE END +M104 S240 T0 ; preheat T0 time: 30s lead 30.2s +; CP TOOLCHANGE START +M104 S200 T1 ; set nozzle temperature ;cooldown +T0 ; change extruder +M109 S240 T0 ; set nozzle temperature and wait for it to be reached +; CP TOOLCHANGE END +M104 S240 T1 ; preheat T1 time: 31s lead 30.7s +; CP TOOLCHANGE START +M104 S200 T0 ; set nozzle temperature ;cooldown +T1 ; change extruder +M109 S240 T1 ; set nozzle temperature and wait for it to be reached +; CP TOOLCHANGE END +M104 S240 T0 ; preheat T0 time: 30s lead 30.4s +; CP TOOLCHANGE START +M104 S200 T1 ; set nozzle temperature ;cooldown +T0 ; change extruder +M109 S240 T0 ; set nozzle temperature and wait for it to be reached +; CP TOOLCHANGE END +M104 S240 T1 ; preheat T1 time: 30s lead 30.4s +; CP TOOLCHANGE START +M104 S200 T0 ; set nozzle temperature ;cooldown +T1 ; change extruder +M109 S240 T1 ; set nozzle temperature and wait for it to be reached +; CP TOOLCHANGE END +M104 S240 T0 ; preheat T0 time: 30s lead 30.0s +; CP TOOLCHANGE START +M104 S200 T1 ; set nozzle temperature ;cooldown +T0 ; change extruder +M109 S240 T0 ; set nozzle temperature and wait for it to be reached +; CP TOOLCHANGE END +M104 S240 T1 ; preheat T1 time: 30s lead 30.0s +; CP TOOLCHANGE START +M104 S200 T0 ; set nozzle temperature ;cooldown +T1 ; change extruder +M109 S240 T1 ; set nozzle temperature and wait for it to be reached +; CP TOOLCHANGE END +; CP TOOLCHANGE START +; CP TOOLCHANGE END +M104 S0 ; turn off temperature diff --git a/tests/fff_print/test_multifilament.cpp b/tests/fff_print/test_multifilament.cpp index f33a8e1e61..081c0fa2ba 100644 --- a/tests/fff_print/test_multifilament.cpp +++ b/tests/fff_print/test_multifilament.cpp @@ -1,12 +1,24 @@ #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 using namespace Slic3r; using namespace Slic3r::Test; @@ -27,6 +39,156 @@ static std::set tools_for_role(const std::string& gcode, const std::string& 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; +} + +// Splits a trace entry into its command text and the lead time appended after a tab, if any. +static std::pair> split_lead(const std::string& entry) +{ + const size_t tab = entry.find('\t'); + if (tab == std::string::npos) + return { entry, std::nullopt }; + const std::string tail = entry.substr(tab + 1); // "lead 30.2s" + return { entry.substr(0, tab), std::stod(tail.substr(tail.find(' ') + 1)) }; +} + +// Same command, and a lead time within half a second. The lead is an estimate summed over every +// move before it, so it drifts slightly with unrelated changes to travel or tower geometry; half a +// second is far below the tens of seconds a preheat leaving its backtrace position would shift it. +static bool trace_entries_match(const std::string& a, const std::string& b) +{ + const auto x = split_lead(a); + const auto y = split_lead(b); + if (x.first != y.first) + return false; + if (x.second.has_value() != y.second.has_value()) + return false; + return !x.second.has_value() || std::abs(*x.second - *y.second) <= 0.5; +} + // Tool index = filament id - 1; brim and skirt follow the wall filament. TEST_CASE("Each feature prints with its assigned filament", "[MultiFilament]") { @@ -86,6 +248,389 @@ TEST_CASE("Per-object wall filament override is honored", "[MultiFilament]") 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()); + + 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; @@ -104,3 +649,4 @@ TEST_CASE("Multi-extruder slice stays in bounds with a short max_layer_height", init_and_process_print({ cube(20) }, print, config); REQUIRE_FALSE(print.objects().front()->layers().empty()); } +