From 5a36120c588100e6bff01fa595e18de83ca67af2 Mon Sep 17 00:00:00 2001 From: Gabriel Monteiro Date: Mon, 19 Oct 2026 16:15:59 -0300 Subject: [PATCH] fix(gcode): use Klipper motion planning for time estimates (#15545) Co-authored-by: Ian Bassi Co-authored-by: raistlin7447 --- src/libslic3r/GCode.cpp | 8 - src/libslic3r/GCode/GCodeProcessor.cpp | 393 ++++++++++++++++++------- src/libslic3r/GCode/GCodeProcessor.hpp | 19 +- src/libslic3r/GCode/WipeTower.cpp | 7 +- src/libslic3r/GCodeReader.cpp | 2 +- src/libslic3r/GCodeReader.hpp | 21 +- src/libslic3r/GCodeWriter.cpp | 21 +- src/libslic3r/GCodeWriter.hpp | 4 + src/slic3r/GUI/Plater.cpp | 6 + tests/fff_print/test_gcode_timing.cpp | 200 +++++++++++++ tests/fff_print/test_gcodewriter.cpp | 37 ++- tests/fff_print/test_wipe_tower.cpp | 17 ++ 12 files changed, 602 insertions(+), 133 deletions(-) diff --git a/src/libslic3r/GCode.cpp b/src/libslic3r/GCode.cpp index 1500483f6e..88fef3bb35 100644 --- a/src/libslic3r/GCode.cpp +++ b/src/libslic3r/GCode.cpp @@ -6660,10 +6660,6 @@ LayerResult GCode::process_layer( case CalibMode::Calib_Input_shaping_freq: { if (m_layer_index == 1){ gcode += writer().set_input_shaping('A', print.calib_params().start, 0.f, print.calib_params().shaper_type); - if (m_writer.get_gcode_flavor() == gcfKlipper) { - // Disable minimum cruise ratio to ensure consistent motion for calibration - gcode += "SET_VELOCITY_LIMIT MINIMUM_CRUISE_RATIO=0\n"; - } } else { if (print.calib_params().freqStartX == print.calib_params().freqStartY && print.calib_params().freqEndX == print.calib_params().freqEndY) { gcode += writer().set_input_shaping('A', 0.f, this->interpolate_value_across_layers(print.calib_params().freqStartX, print.calib_params().freqEndX), ""); @@ -6676,10 +6672,6 @@ LayerResult GCode::process_layer( } case CalibMode::Calib_Input_shaping_damp: { if (m_layer_index == 1){ - if (m_writer.get_gcode_flavor() == gcfKlipper) { - // Disable minimum cruise ratio to ensure consistent motion for calibration - gcode += "SET_VELOCITY_LIMIT MINIMUM_CRUISE_RATIO=0\n"; - } gcode += writer().set_input_shaping('X', 0.f, print.calib_params().freqStartX, print.calib_params().shaper_type); gcode += writer().set_input_shaping('Y', 0.f, print.calib_params().freqStartY, print.calib_params().shaper_type); } else { diff --git a/src/libslic3r/GCode/GCodeProcessor.cpp b/src/libslic3r/GCode/GCodeProcessor.cpp index 6346651448..3512df6ab8 100644 --- a/src/libslic3r/GCode/GCodeProcessor.cpp +++ b/src/libslic3r/GCode/GCodeProcessor.cpp @@ -24,6 +24,7 @@ #include #include +#include #include #include #include @@ -378,6 +379,11 @@ void GCodeProcessor::TimeMachine::reset() travel_acceleration = 0.0f; max_travel_acceleration = 0.0f; extrude_factor_override_percentage = 1.0f; + klipper = false; + minimum_cruise_ratio = 0.5f; + requested_accel_to_decel = -1.0f; + klipper_junction_flush = 1.0f; + klipper_queue_priming = true; time = 0.0f; stop_times = std::vector(); curr.reset(); @@ -493,16 +499,87 @@ GCodeProcessor::TimeMachine::AdditionalBuffer GCodeProcessor::TimeMachine::merge return merged; } +// Klipper toolhead.py LookAheadQueue.flush: the pseudo-acceleration pass +// reserves cruising distance across a sequence, not separately in each segment. +size_t GCodeProcessor::TimeMachine::plan_klipper(bool lazy) +{ + klipper_junction_flush = requested_accel_to_decel >= 0.0f ? 0.250f : 0.150f; + klipper_queue_priming = false; + size_t flush_count = blocks.size(); + bool update_flush_count = lazy; + float next_start_v2 = 0.0f; + float next_mcr_start_v2 = 0.0f; + float peak_cruise_v2 = 0.0f; + size_t pending = 0; + for (size_t i = blocks.size(); i-- > 0;) { + TimeBlock& block = blocks[i]; + const float delta_v2 = 2.0f * block.distance * block.acceleration; + const float reachable_start_v2 = next_start_v2 + delta_v2; + const float start_v2 = std::min(sqr(block.max_entry_speed), reachable_start_v2); + const float reachable_mcr_v2 = next_mcr_start_v2 + block.mcr_delta_v2; + const float mcr_start_v2 = std::min(block.max_mcr_entry_speed_sqr, reachable_mcr_v2); + float cruise_v2 = -1.0f; + ++pending; + if (mcr_start_v2 < reachable_mcr_v2) { + if (mcr_start_v2 + block.mcr_delta_v2 > next_mcr_start_v2 || pending > 1) { + // Stop at this block, not past it: the blocks above still have a cruise speed + // that depends on moves not yet seen, so only [0, i) may be committed. + if (update_flush_count && peak_cruise_v2 > 0.0f) { + flush_count = i; + update_flush_count = false; + } + peak_cruise_v2 = std::min(sqr(block.feedrate_profile.cruise), + 0.5f * (mcr_start_v2 + reachable_mcr_v2)); + } + cruise_v2 = std::min({0.5f * (start_v2 + reachable_start_v2), + sqr(block.feedrate_profile.cruise), peak_cruise_v2}); + pending = 0; + } + // Until the forward pass, these fields hold v^2, not speeds; -1 marks + // an unassigned cruise speed. A lazy return leaves them in this state. + block.feedrate_profile.entry = start_v2; + block.feedrate_profile.exit = next_start_v2; + block.trapezoid.cruise_feedrate = cruise_v2; + block.peak_cruise_v2 = peak_cruise_v2; + next_start_v2 = start_v2; + next_mcr_start_v2 = mcr_start_v2; + } + if (update_flush_count) + return 0; + // A run of blocks that cannot accelerate is resolved here, once the peak cruise speed of the + // accelerating block that precedes it is known. Each one takes a running minimum with its own + // entry speed, seeded from that peak rather than from the setter's own clamped cruise speed. + float prev_cruise_v2 = 0.0f; + for (size_t i = 0; i < flush_count; ++i) { + TimeBlock& block = blocks[i]; + float cruise_v2 = block.trapezoid.cruise_feedrate; + if (cruise_v2 < 0.0f) { + cruise_v2 = std::min(prev_cruise_v2, block.feedrate_profile.entry); + prev_cruise_v2 = cruise_v2; + } else + prev_cruise_v2 = block.peak_cruise_v2; + const float start_v2 = std::min(block.feedrate_profile.entry, cruise_v2); + const float end_v2 = std::min(block.feedrate_profile.exit, cruise_v2); + // A zero acceleration would put an infinity in the ramp distances; the Marlin helpers guard + // their divides the same way and fall back to a pure cruise. + const float inv_two_accel = block.acceleration > 0.0f ? 0.5f / block.acceleration : 0.0f; + block.feedrate_profile.entry = std::sqrt(start_v2); + block.feedrate_profile.exit = std::sqrt(end_v2); + block.trapezoid.cruise_feedrate = std::sqrt(cruise_v2); + block.trapezoid.accelerate_until = (cruise_v2 - start_v2) * inv_two_accel; + block.trapezoid.decelerate_after = block.distance - (cruise_v2 - end_v2) * inv_two_accel; + } + return flush_count; +} + void GCodeProcessor::TimeMachine::calculate_time(GCodeProcessorResult& result, PrintEstimatedStatistics::ETimeMode mode, size_t keep_last_n_blocks, float additional_time, EMoveType target_move_type, bool is_final) { if (!enabled) return; - // Orca: on the finalization pass, drain extra time that is still buffered (e.g. a - // trailing filament change) even with fewer than two blocks queued -- no later pass - // exists to attribute it on. Every other pass keeps the original >= 2 requirement, - // and an empty buffer keeps today's early-return unchanged (no behavior change). + // Drain buffered delays on the final pass, even with no motion left. + // A trailing filament change has no later move to receive its time. const bool drain_final = is_final && !m_additional_time_buffer.empty(); - if (blocks.size() < 2 && !drain_final) { + if ((klipper ? blocks.empty() : blocks.size() < 2) && !drain_final) { // Not enough blocks to attribute the extra time to yet; buffer it so it is // applied on a later pass instead of being dropped. if (additional_time > 0.0f) @@ -510,7 +587,7 @@ void GCodeProcessor::TimeMachine::calculate_time(GCodeProcessorResult& result, P return; } - assert(keep_last_n_blocks <= blocks.size()); + assert(klipper || keep_last_n_blocks <= blocks.size()); // Merge any previously buffered extra time with this call's extra time. Each // entry is applied, in order, to the first block matching its target move type @@ -520,23 +597,36 @@ void GCodeProcessor::TimeMachine::calculate_time(GCodeProcessorResult& result, P additional_buffer.emplace_back(target_move_type, additional_time); additional_buffer = merge_adjacent_additional_time_blocks(additional_buffer); - // reverse_pass - for (int i = static_cast(blocks.size()) - 1; i > 0; --i) { - planner_reverse_pass_kernel(blocks[i - 1], blocks[i]); + size_t n_blocks_process; + if (klipper) { + n_blocks_process = plan_klipper(keep_last_n_blocks != 0); + keep_last_n_blocks = blocks.size() - n_blocks_process; + } else { + for (int i = static_cast(blocks.size()) - 1; i > 0; --i) + planner_reverse_pass_kernel(blocks[i - 1], blocks[i]); + for (size_t i = 0; i + 1 < blocks.size(); ++i) + planner_forward_pass_kernel(blocks[i], blocks[i + 1]); + recalculate_trapezoids(blocks); + n_blocks_process = blocks.size() - keep_last_n_blocks; } - - // forward_pass - for (size_t i = 0; i + 1 < blocks.size(); ++i) { - planner_forward_pass_kernel(blocks[i], blocks[i + 1]); - } - - recalculate_trapezoids(blocks); - - const size_t n_blocks_process = blocks.size() - keep_last_n_blocks; size_t additional_buffer_idx = 0; for (size_t i = 0; i < n_blocks_process; ++i) { const TimeBlock& block = blocks[i]; - float block_time = block.time(); + double motion_time; + if (klipper && block.trapezoid.cruise_feedrate > 0.0f) { + // Integrate using the planned speeds. Reconstructing the exit speed + // from rounded deceleration distance loses precision near a stop. + const double cruise = block.trapezoid.cruise_feedrate; + motion_time = block.distance / cruise; + if (block.acceleration > 0.0f) { + const double accel_delta = cruise - block.feedrate_profile.entry; + const double decel_delta = cruise - block.feedrate_profile.exit; + motion_time += (sqr(accel_delta) + sqr(decel_delta)) / (2.0 * block.acceleration * cruise); + } + } else { + motion_time = block.time(); + } + double block_time = motion_time; if (additional_buffer_idx < additional_buffer.size()) { const EMoveType buf_move_type = additional_buffer[additional_buffer_idx].first; if (buf_move_type == EMoveType::Noop || buf_move_type == block.move_type) { @@ -545,13 +635,13 @@ void GCodeProcessor::TimeMachine::calculate_time(GCodeProcessorResult& result, P } } - time += double(block_time); + time += block_time; // Orca: accumulate per-SkipType time spent inside SKIPPABLE regions, folded into this single // calculate_time write site. Fires fleet-wide (the shipping time_lapse_gcode template stamps // blocks stTimelapse), but writes only skippable_part_time, which has no g-code-emitting // reader until the pre-heat injector consumes it, so output is byte-identical. if (block.skippable_type != SkipType::stNone) - result.skippable_part_time[block.skippable_type] += block.time(); + result.skippable_part_time[block.skippable_type] += motion_time; result.moves[block.move_id].time[static_cast(mode)] = block_time; gcode_time.cache += block_time; //BBS @@ -689,14 +779,12 @@ void GCodeProcessor::TimeMachine::calculate_time(GCodeProcessorResult& result, P if (keep_last_n_blocks) { blocks.erase(blocks.begin(), blocks.begin() + n_blocks_process); - // Ensure that the new first block's entry speed will be preserved to prevent discontinuity - // between the erased blocks' exit speed and the new first block's entry speed. - // Otherwise, the first block's entry speed could be recalculated on the next pass without - // considering that there are no more blocks before this first block. This could lead - // to discontinuity between the exit speed (of already processed blocks) and the entry - // speed of the first block. - TimeBlock &first_block = blocks.front(); - first_block.max_entry_speed = first_block.feedrate_profile.entry; + // Preserve the boundary speed in the Marlin planner. Klipper only flushes + // moves whose cruise speed is already independent of future moves. + if (!klipper) { + TimeBlock& first_block = blocks.front(); + first_block.max_entry_speed = first_block.feedrate_profile.entry; + } } else { blocks.clear(); } @@ -3872,9 +3960,9 @@ void GCodeProcessor::finalize(bool post_process) update_slice_warnings(); } -float GCodeProcessor::get_time(PrintEstimatedStatistics::ETimeMode mode) const +double GCodeProcessor::get_time(PrintEstimatedStatistics::ETimeMode mode) const { - return (mode < PrintEstimatedStatistics::ETimeMode::Count) ? float(m_time_processor.machines[static_cast(mode)].time) : 0.0f; + return (mode < PrintEstimatedStatistics::ETimeMode::Count) ? m_time_processor.machines[static_cast(mode)].time : 0.0; } float GCodeProcessor::get_prepare_time(PrintEstimatedStatistics::ETimeMode mode) const @@ -5031,6 +5119,14 @@ void GCodeProcessor::process_G1(const GCodeReader::GCodeLine& line, const std::o if (line.has_e()) g1_axes[E] = (double)line.e(); std::optional g1_feedrate = std::nullopt; if (line.has_f()) g1_feedrate = (double)line.f(); + if (m_flavor == gcfKlipper) { + // Preserve decimal coordinates for short segments without parsing twice. + for (unsigned char axis = X; axis <= E; ++axis) + if (line.has(static_cast(axis))) + g1_axes[axis] = line.precise_value(static_cast(axis)); + if (line.has_f()) + g1_feedrate = line.precise_value(F); + } process_G1(g1_axes, g1_feedrate); } @@ -5228,7 +5324,7 @@ void GCodeProcessor::process_G1(const std::array, 4>& axes //BBS: limite the cruise according to centripetal acceleration //Only need to handle when both prev and curr segment has movement in x-y plane - if ((prev.exit_direction(0) != 0.0f || prev.exit_direction(1) != 0.0f) && + if (m_flavor != gcfKlipper && (prev.exit_direction(0) != 0.0f || prev.exit_direction(1) != 0.0f) && (curr.enter_direction(0) != 0.0f || curr.enter_direction(1) != 0.0f)) { Vec3f v1 = prev.exit_direction; v1(2, 0) = 0.0f; @@ -5252,6 +5348,15 @@ void GCodeProcessor::process_G1(const std::array, 4>& axes } } + if (m_flavor == gcfKlipper && !is_extrusion_only_move(delta_pos)) { + const float max_velocity = get_axis_max_feedrate(static_cast(i), X, m_machine_config_idx); + if (max_velocity > 0.0f) + curr.feedrate = std::min(curr.feedrate, max_velocity); + } + // Klipper applies E limits to retractions and moves without XY motion, + // not to positive extrusion along a printing path. + const bool limit_extruder = m_flavor != gcfKlipper || delta_pos[E] < 0.0 || + (delta_pos[X] == 0.0 && delta_pos[Y] == 0.0); // calculates block cruise feedrate float min_feedrate_factor = 1.0f; for (unsigned char a = X; a <= E; ++a) { @@ -5260,7 +5365,8 @@ void GCodeProcessor::process_G1(const std::array, 4>& axes curr.axis_feedrate[a] *= machine.extrude_factor_override_percentage; curr.abs_axis_feedrate[a] = std::abs(curr.axis_feedrate[a]); - if (curr.abs_axis_feedrate[a] != 0.0f) { + if (curr.abs_axis_feedrate[a] != 0.0f && (a != E || limit_extruder) && + (m_flavor != gcfKlipper || a == Z || a == E)) { float axis_max_feedrate = get_axis_max_feedrate(static_cast(i), static_cast(a), m_machine_config_idx); if (axis_max_feedrate != 0.0f) min_feedrate_factor = std::min(min_feedrate_factor, axis_max_feedrate / curr.abs_axis_feedrate[a]); } @@ -5277,20 +5383,42 @@ void GCodeProcessor::process_G1(const std::array, 4>& axes } // calculates block acceleration - float acceleration = - (type == EMoveType::Travel) ? get_travel_acceleration(static_cast(i)) : - (is_extrusion_only_move(delta_pos) ? - get_retract_acceleration(static_cast(i)) : - get_acceleration(static_cast(i))); + float acceleration; + if (m_flavor == gcfKlipper && is_extrusion_only_move(delta_pos)) { + // Klipper's max_extrude_only_accel. machine_max_acceleration_e is optional and some + // shipped profiles leave it at 0, so keep the non-zero retract fallback. + acceleration = get_axis_max_acceleration(static_cast(i), E, m_machine_config_idx); + if (acceleration <= 0.0f) + acceleration = get_retract_acceleration(static_cast(i)); + } else + acceleration = + (m_flavor == gcfKlipper) ? get_acceleration(static_cast(i)) : + (type == EMoveType::Travel) ? get_travel_acceleration(static_cast(i)) : + (is_extrusion_only_move(delta_pos) ? + get_retract_acceleration(static_cast(i)) : + get_acceleration(static_cast(i))); //BBS for (unsigned char a = X; a <= E; ++a) { + if ((a == E && !limit_extruder) || (m_flavor == gcfKlipper && (a == X || a == Y))) + continue; float axis_max_acceleration = get_axis_max_acceleration(static_cast(i), static_cast(a), m_machine_config_idx); + // An unset (0) limit means "no limit", as in the feedrate loop above. Only the Klipper + // planner divides by the block acceleration, so leave the Marlin path as it was. + if (m_flavor == gcfKlipper && axis_max_acceleration <= 0.0f) + continue; if (acceleration * std::abs(delta_pos[a]) * inv_distance > axis_max_acceleration) acceleration = axis_max_acceleration / (std::abs(delta_pos[a]) * inv_distance); } block.acceleration = acceleration; + if (m_flavor == gcfKlipper) { + block.junction_deviation = get_junction_deviation(static_cast(i), acceleration); + const float cruise_acceleration = machine.requested_accel_to_decel >= 0.0f ? + std::min(machine.acceleration, machine.requested_accel_to_decel) : + machine.acceleration * (1.0f - machine.minimum_cruise_ratio); + block.mcr_delta_v2 = 2.0f * distance * std::min(acceleration, cruise_acceleration); + } static const float PREVIOUS_FEEDRATE_THRESHOLD = 0.0001f; const bool has_prev_move = !blocks.empty() && prev.feedrate > PREVIOUS_FEEDRATE_THRESHOLD; @@ -5401,6 +5529,12 @@ void GCodeProcessor::process_G1(const std::array, 4>& axes block.feedrate_profile.entry = std::min(vmax_junction, v_allowable); block.max_entry_speed = vmax_junction; + if (m_flavor == gcfKlipper && has_prev_move) { + const TimeBlock& previous = blocks.back(); + block.max_entry_speed = std::min(block.max_entry_speed, + std::sqrt(sqr(previous.max_entry_speed) + 2.0f * previous.distance * previous.acceleration)); + block.max_mcr_entry_speed_sqr = std::min(sqr(block.max_entry_speed), previous.max_mcr_entry_speed_sqr + previous.mcr_delta_v2); + } block.flags.nominal_length = (block.feedrate_profile.cruise <= v_allowable); block.flags.recalculate = true; block.safe_feedrate = curr.safe_feedrate; @@ -5412,9 +5546,12 @@ void GCodeProcessor::process_G1(const std::array, 4>& axes prev = curr; blocks.push_back(block); + if (m_flavor == gcfKlipper && blocks.size() > 1) + machine.klipper_junction_flush -= distance / curr.feedrate; } - if (m_time_processor.machines[0].blocks.size() > TimeProcessor::Planner::refresh_threshold) + if (m_flavor == gcfKlipper ? m_time_processor.machines[0].klipper_junction_flush <= 0.0f : + m_time_processor.machines[0].blocks.size() > TimeProcessor::Planner::refresh_threshold) calculate_time(m_result, TimeProcessor::Planner::queue_size); const Vec3f plate_offset = {(float) m_x_offset, (float) m_y_offset, 0.0f}; @@ -6341,6 +6478,8 @@ void GCodeProcessor::process_M108(const GCodeReader::GCodeLine& line) void GCodeProcessor::process_M109(const GCodeReader::GCodeLine& line) { + if (m_flavor == gcfKlipper) + simulate_st_synchronize(); int filament_id = get_filament_id(); float new_temp; if (line.has_value('R', new_temp)) { @@ -6403,6 +6542,8 @@ void GCodeProcessor::process_M140(const GCodeReader::GCodeLine& line) void GCodeProcessor::process_M190(const GCodeReader::GCodeLine& line) { + if (m_flavor == gcfKlipper) + simulate_st_synchronize(); float new_temp; if (line.has_value('S', new_temp)) m_highest_bed_temp = m_highest_bed_temp < (int)new_temp ? (int)new_temp : m_highest_bed_temp; @@ -6480,6 +6621,21 @@ void GCodeProcessor::process_M203(const GCodeReader::GCodeLine& line) void GCodeProcessor::process_M204(const GCodeReader::GCodeLine& line) { float value; + if (m_flavor == gcfKlipper) { + if (!line.has_value('S', value)) { + float travel; + if (!line.has_value('P', value) || !line.has_value('T', travel)) + return; + value = std::min(value, travel); + } + // Klipper replaces its single live max_accel for print and travel; the profile-clamping + // setters would diverge from the firmware when custom G-code raises the limit. + if (value > 0.0f) { + for (auto& machine : m_time_processor.machines) + machine.acceleration = machine.travel_acceleration = value; + } + return; + } for (size_t i = 0; i < static_cast(PrintEstimatedStatistics::ETimeMode::Count); ++i) { if (static_cast(i) == PrintEstimatedStatistics::ETimeMode::Normal || m_time_processor.machine_envelope_processing_enabled) { @@ -6541,50 +6697,50 @@ void GCodeProcessor::process_M205(const GCodeReader::GCodeLine& line) void GCodeProcessor::process_SET_VELOCITY_LIMIT(const GCodeReader::GCodeLine& line) { - // handle SQUARE_CORNER_VELOCITY - static const std::regex square_corner_velocity_pattern("\\sSQUARE_CORNER_VELOCITY\\s*=\\s*([0-9]*\\.*[0-9]*)"); - std::smatch matches; - if (std::regex_search(line.raw(), matches, square_corner_velocity_pattern) && matches.size() == 2) { - float _jerk = 0; - try - { - _jerk = std::stof(matches[1]); + static const std::regex parameter_pattern( + R"(\s(VELOCITY|ACCEL|ACCEL_TO_DECEL|SQUARE_CORNER_VELOCITY|MINIMUM_CRUISE_RATIO)\s*=\s*([+]?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)(?:[eE][+-]?[0-9]+)?)(?=\s|;|$))", + std::regex::icase); + const std::string command = line.raw().substr(0, line.raw().find(';')); + std::vector> parameters; + for (std::sregex_iterator it(command.begin(), command.end(), parameter_pattern), end; it != end; ++it) { + float value; + try { + value = std::stof((*it)[2].str()); + } catch (const std::exception&) { + continue; } - catch (...){} - for (size_t i = 0; i < static_cast(PrintEstimatedStatistics::ETimeMode::Count); ++i) { - set_option_value(m_time_processor.machine_limits.machine_max_jerk_x, i, _jerk); - set_option_value(m_time_processor.machine_limits.machine_max_jerk_y, i, _jerk); + if (std::isfinite(value)) + parameters.emplace_back(boost::to_upper_copy((*it)[1].str()), value); + } + // Klipper 0.13 converts ACCEL_TO_DECEL only when the line has no MINIMUM_CRUISE_RATIO, and later + // releases ignore it, so a line carrying both (as Orca writes) is planned with the cruise ratio. + const bool has_cruise_ratio = std::any_of(parameters.begin(), parameters.end(), + [](const auto& parameter) { return parameter.first == "MINIMUM_CRUISE_RATIO" && parameter.second >= 0.0f && parameter.second < 1.0f; }); + for (const auto& [name, value] : parameters) { + for (size_t i = 0; i < m_time_processor.machines.size(); ++i) { + TimeMachine& machine = m_time_processor.machines[i]; + // Klipper replaces its single live max_accel for print and travel; the profile-clamping + // setters would diverge from the firmware when custom G-code raises the limit. + if (name == "ACCEL" && value > 0.0f) + machine.acceleration = machine.travel_acceleration = value; + else if (name == "MINIMUM_CRUISE_RATIO" && value >= 0.0f && value < 1.0f) { + if (machine.klipper_queue_priming && machine.requested_accel_to_decel >= 0.0f) + machine.klipper_junction_flush -= 1.0f; + machine.minimum_cruise_ratio = value; + machine.requested_accel_to_decel = -1.0f; + } else if (name == "ACCEL_TO_DECEL" && value > 0.0f && !has_cruise_ratio) { + if (machine.klipper_queue_priming && machine.requested_accel_to_decel < 0.0f) + machine.klipper_junction_flush += 1.0f; + machine.requested_accel_to_decel = value; + } else if (name == "SQUARE_CORNER_VELOCITY" && value >= 0.0f) { + set_option_value(m_time_processor.machine_limits.machine_max_jerk_x, i, value); + set_option_value(m_time_processor.machine_limits.machine_max_jerk_y, i, value); + } else if (name == "VELOCITY" && value > 0.0f) { + set_option_value(m_time_processor.machine_limits.machine_max_speed_x, i, value); + set_option_value(m_time_processor.machine_limits.machine_max_speed_y, i, value); + } } } - - static const std::regex accel_pattern("\\sACCEL\\s*=\\s*([0-9]*\\.*[0-9]*)"); - if (std::regex_search(line.raw(), matches, accel_pattern) && matches.size() == 2) { - float _accl = 0; - try - { - _accl = std::stof(matches[1]); - } - catch (...) {} - for (size_t i = 0; i < static_cast(PrintEstimatedStatistics::ETimeMode::Count); ++i) { - set_acceleration(static_cast(i), _accl); - set_travel_acceleration(static_cast(i), _accl); - } - } - - static const std::regex velocity_pattern("\\sVELOCITY\\s*=\\s*([0-9]*\\.*[0-9]*)"); - if (std::regex_search(line.raw(), matches, velocity_pattern) && matches.size() == 2) { - float _speed = 0; - try - { - _speed = std::stof(matches[1]); - } - catch (...) {} - for (size_t i = 0; i < static_cast(PrintEstimatedStatistics::ETimeMode::Count); ++i) { - set_option_value(m_time_processor.machine_limits.machine_max_speed_x, i, _speed); - set_option_value(m_time_processor.machine_limits.machine_max_speed_y, i, _speed); - } - } - } void GCodeProcessor::process_M221(const GCodeReader::GCodeLine& line) @@ -6621,14 +6777,15 @@ void GCodeProcessor::process_M400(const GCodeReader::GCodeLine& line) { float value_s = 0.0; float value_p = 0.0; - if (line.has_value('S', value_s) || line.has_value('P', value_p)) { - value_s += value_p * 0.001; - // Skip post-print end-gcode dwells so they don't inflate the M73 estimate (see - // m_skip_end_gcode_delays). Only omits dwell time — no state is updated here. - if (m_skip_end_gcode_delays) - return; - simulate_st_synchronize(value_s); - } + const bool has_delay = line.has_value('S', value_s) || line.has_value('P', value_p); + if (m_flavor != gcfKlipper && !has_delay) + return; + value_s += value_p * 0.001; + // Skip post-print end-gcode dwells so they don't inflate the M73 estimate (see + // m_skip_end_gcode_delays). Only omits dwell time — no state is updated here. + if (m_skip_end_gcode_delays && has_delay) + return; + simulate_st_synchronize(value_s); } void GCodeProcessor::process_M401(const GCodeReader::GCodeLine& line) @@ -7460,12 +7617,12 @@ float GCodeProcessor::get_junction_deviation(PrintEstimatedStatistics::ETimeMode { const size_t id = static_cast(mode); - // Klipper has no classic jerk: jd = scv^2 * (sqrt(2) - 1) / max_accel - // (toolhead.py::_calc_junction_deviation). Passing the block acceleration back in makes it cancel - // in calc_vmax_junction_deviation(), leaving the identity v == scv at a 90 degree corner. + // Klipper derives junction deviation from the live toolhead acceleration, + // before a move is constrained by Z or extruder limits. if (m_flavor == gcfKlipper) { // machine_max_jerk_x holds the square corner velocity; process_SET_VELOCITY_LIMIT() writes it. const float scv = get_option_value(m_time_processor.machine_limits.machine_max_jerk_x, id); + acceleration = m_time_processor.machines[id].acceleration; if (scv <= 0.0f || acceleration <= 0.0f) return 0.0f; return sqr(scv) * (std::sqrt(2.0f) - 1.0f) / acceleration; @@ -7497,18 +7654,45 @@ float GCodeProcessor::calc_vmax_junction_deviation(const TimeBlock& block, const const TimeMachine::State& curr, bool has_prev_move, PrintEstimatedStatistics::ETimeMode mode) const { + if (m_flavor == gcfKlipper) { + if (!has_prev_move || curr.enter_direction.isZero() || prev.exit_direction.isZero()) + return 0.0f; + const TimeBlock& previous = m_time_processor.machines[static_cast(mode)].blocks.back(); + double limit = std::min(sqr(double(block.feedrate_profile.cruise)), sqr(double(prev.feedrate))); + const double extrude_ratio_change = std::abs(curr.axis_feedrate[E] / curr.feedrate - + prev.axis_feedrate[E] / prev.feedrate); + // Klipper's instantaneous_corner_velocity defaults to 1 mm/s. + // It is independent of machine_max_jerk_e; custom firmware values + // cannot be inferred from the slicer's jerk setting. + if (extrude_ratio_change > 0.0f) + limit = std::min(limit, sqr(1.0 / extrude_ratio_change)); + // Recover directions from the double-precision axis velocities: float + // unit vectors lose the angle between nearly collinear short segments. + const Vec3d prev_direction = Vec3d(prev.axis_feedrate[X], prev.axis_feedrate[Y], prev.axis_feedrate[Z]).normalized(); + const Vec3d curr_direction = Vec3d(curr.axis_feedrate[X], curr.axis_feedrate[Y], curr.axis_feedrate[Z]).normalized(); + const double cosine = std::clamp(-prev_direction.dot(curr_direction), -1.0, 1.0); + const double sin_half = std::sqrt(0.5 * (1.0 - cosine)); + const double cos_half = std::sqrt(0.5 * (1.0 + cosine)); + if (sin_half < 1.0 && cos_half > 0.0) { + const double radius = sin_half / (1.0 - sin_half); + const double half_tan = 0.5 * sin_half / cos_half; + limit = std::min({limit, + radius * block.junction_deviation * block.acceleration, + radius * previous.junction_deviation * previous.acceleration, + half_tan * block.distance * block.acceleration, + half_tan * previous.distance * previous.acceleration}); + } + return std::sqrt(limit); + } const float junction_deviation = get_junction_deviation(mode, block.acceleration); if (junction_deviation <= 0.0f) return -1.0f; // classic jerk machine, the caller keeps its own computation if (!has_prev_move) return 0.0f; // starts from rest, the planner raises this on the reverse pass - // -1 for a straight continuation, +1 for a full reversal. Half angle identity, no acos()/sin(). - // Both vectors are unit length over XYZE, so this really is a cosine: scaling by 1 / distance - // instead, as PrusaSlicer does, leaves an E term that makes extruding corners look straighter - // than they are. Marlin normalizes over XYZE for any extruding move (planner.cpp, esteps > 0) - // and Klipper keeps E out of the cosine entirely (toolhead.py::Move.calc_junction); both agree - // that the corner is planned by its geometry, and normalizing matches them to within 1e-5. + // Unit vectors over XYZE keep this a cosine. Scaling by 1 / XYZ distance + // leaves an E term that makes extruding corners appear straighter. + // Marlin normalizes over XYZE; the Klipper branch above uses XYZ only. float junction_cos_theta = (-prev.jd_unit_vec).dot(curr.jd_unit_vec); if (junction_cos_theta > 0.999999f) return 0.0f; // the path doubles back, the machine has to stop @@ -7524,8 +7708,8 @@ float GCodeProcessor::calc_vmax_junction_deviation(const TimeBlock& block, const float vmax_junction_sqr = (junction_acceleration * junction_deviation * sin_theta_d2) / (1.0f - sin_theta_d2); // Marlin's JD_HANDLE_SMALL_SEGMENTS: a short move through a shallow corner is treated as an arc and - // capped by the centripetal acceleration it needs. Klipper has no equivalent. - if (m_flavor != gcfKlipper && block.distance < 1.0f && junction_cos_theta < -0.7071067812f) { + // capped by the centripetal acceleration it needs. + if (block.distance < 1.0f && junction_cos_theta < -0.7071067812f) { // Fast acos(-t), max. error +-0.033rad. MinMax polynomial by W. Randolph Franklin: // https://wrf.ecse.rpi.edu/Research/Short_Notes/arcsin/onlyelem.html const float neg = junction_cos_theta < 0.0f ? -1.0f : 1.0f; @@ -7700,6 +7884,7 @@ void GCodeProcessor::calculate_time(GCodeProcessorResult& result, size_t keep_la std::vector actual_speed_moves; for (size_t i = 0; i < static_cast(PrintEstimatedStatistics::ETimeMode::Count); ++i) { TimeMachine& machine = m_time_processor.machines[i]; + machine.klipper = m_flavor == gcfKlipper; machine.calculate_time(m_result, static_cast(i), keep_last_n_blocks, additional_time, target_move_type, is_final); if (static_cast(i) == PrintEstimatedStatistics::ETimeMode::Normal) actual_speed_moves = std::move(machine.actual_speed_moves); @@ -7778,6 +7963,14 @@ void GCodeProcessor::calculate_time(GCodeProcessorResult& result, size_t keep_la void GCodeProcessor::simulate_st_synchronize(float additional_time, EMoveType target_move_type) { calculate_time(m_result, 0, additional_time, target_move_type); + if (m_flavor == gcfKlipper) { + // ToolHead overrides the normal look-ahead window with BUFFER_TIME_HIGH + // at startup and after synchronization: 2 s on legacy, 1 s on modern Klipper. + for (TimeMachine& machine : m_time_processor.machines) { + machine.klipper_junction_flush = machine.requested_accel_to_decel >= 0.0f ? 2.0f : 1.0f; + machine.klipper_queue_priming = true; + } + } } void GCodeProcessor::update_estimated_times_stats() @@ -7812,7 +8005,7 @@ double GCodeProcessor::extract_absolute_position_on_axis(Axis axis, const GCodeR is_relative |= (m_e_local_positioning_type == EPositioningType::Relative); const double lengthsScaleFactor = (m_units == EUnits::Inches) ? double(INCHES_TO_MM) : 1.0; - double ret = line.value(Slic3r::Axis(axis)) * lengthsScaleFactor; + double ret = line.precise_value(Slic3r::Axis(axis)) * lengthsScaleFactor; // if (axis == E && m_use_volumetric_e) // ret /= area_filament_cross_section; return is_relative ? m_start_position[axis] + ret : m_origin[axis] + ret; diff --git a/src/libslic3r/GCode/GCodeProcessor.hpp b/src/libslic3r/GCode/GCodeProcessor.hpp index 72fb84bad0..b1fc911104 100644 --- a/src/libslic3r/GCode/GCodeProcessor.hpp +++ b/src/libslic3r/GCode/GCodeProcessor.hpp @@ -91,12 +91,12 @@ inline constexpr float DEFAULT_FILAMENT_DENSITY = 1.245f; struct Mode { - float time; + double time; float prepare_time; std::vector>> custom_gcode_times; void reset() { - time = 0.0f; + time = 0.0; prepare_time = 0.0f; custom_gcode_times.clear(); custom_gcode_times.shrink_to_fit(); @@ -703,6 +703,12 @@ inline constexpr float DEFAULT_FILAMENT_DENSITY = 1.245f; float acceleration{ 0.0f }; // mm/s^2 float max_entry_speed{ 0.0f }; // mm/s float safe_feedrate{ 0.0f }; // mm/s + float junction_deviation{ 0.0f }; + float max_mcr_entry_speed_sqr{ 0.0f }; + float mcr_delta_v2{ 0.0f }; + // Peak cruise speed squared in effect when this block was planned. Only set on a block + // that can accelerate; the delayed blocks after it are seeded from its value. + float peak_cruise_v2{ 0.0f }; Flags flags; FeedrateProfile feedrate_profile; Trapezoid trapezoid; @@ -782,6 +788,11 @@ inline constexpr float DEFAULT_FILAMENT_DENSITY = 1.245f; // hard limit for the travel acceleration, to which the firmware will clamp. float max_travel_acceleration; // mm/s^2 float extrude_factor_override_percentage; + bool klipper{ false }; + float minimum_cruise_ratio{ 0.5f }; + float requested_accel_to_decel{ -1.0f }; + float klipper_junction_flush{ 1.0f }; + bool klipper_queue_priming{ true }; // We accumulate total print time in doubles to reduce the loss of precision // while adding big floating numbers with small float numbers. double time; // s @@ -818,6 +829,8 @@ inline constexpr float DEFAULT_FILAMENT_DENSITY = 1.245f; // Merge adjacent buffer entries that target the same move type. static AdditionalBuffer merge_adjacent_additional_time_blocks(const AdditionalBuffer& buffer); + size_t plan_klipper(bool lazy); + // additional_time is attributed to the first block matching target_move_type // (EMoveType::Noop matches any block, i.e. the first processed block). void calculate_time(GCodeProcessorResult& result, PrintEstimatedStatistics::ETimeMode mode, size_t keep_last_n_blocks = 0, float additional_time = 0.0f, EMoveType target_move_type = EMoveType::Noop, bool is_final = false); @@ -1383,7 +1396,7 @@ inline constexpr float DEFAULT_FILAMENT_DENSITY = 1.245f; void process_buffer(const std::string& buffer); void finalize(bool post_process); - float get_time(PrintEstimatedStatistics::ETimeMode mode) const; + double get_time(PrintEstimatedStatistics::ETimeMode mode) const; float get_prepare_time(PrintEstimatedStatistics::ETimeMode mode) const; std::string get_time_dhm(PrintEstimatedStatistics::ETimeMode mode) const; std::vector>> get_custom_gcode_times(PrintEstimatedStatistics::ETimeMode mode, bool include_remaining) const; diff --git a/src/libslic3r/GCode/WipeTower.cpp b/src/libslic3r/GCode/WipeTower.cpp index b2ddc75a34..e081717ecf 100644 --- a/src/libslic3r/GCode/WipeTower.cpp +++ b/src/libslic3r/GCode/WipeTower.cpp @@ -26,6 +26,7 @@ #include "ClipperUtils.hpp" #include "libslic3r/Line.hpp" #include "LocalesUtils.hpp" +#include "libslic3r/GCodeWriter.hpp" #include "libslic3r/PrintConfig.hpp" #include "libslic3r/Polygon.hpp" #include "libslic3r/Point.hpp" @@ -1528,8 +1529,10 @@ private: gcode << "M204 P" << acceleration; } else if (m_gcode_flavor == gcfKlipper && m_accel_to_decel_enable) { - gcode << "SET_VELOCITY_LIMIT ACCEL_TO_DECEL=" << acceleration * m_accel_to_decel_factor / 100; - gcode << "\nM204 S" << acceleration; + // One line, so Klipper derives the limit from this acceleration rather than the previous one. + std::string limits = "SET_VELOCITY_LIMIT ACCEL=" + std::to_string(acceleration); + GCodeWriter::append_klipper_accel_to_decel(limits, acceleration, m_accel_to_decel_factor); + gcode << limits; } else { // M204: Set default acceleration diff --git a/src/libslic3r/GCodeReader.cpp b/src/libslic3r/GCodeReader.cpp index eff557d817..1b545f86b5 100644 --- a/src/libslic3r/GCodeReader.cpp +++ b/src/libslic3r/GCodeReader.cpp @@ -95,7 +95,7 @@ const char* GCodeReader::parse_line_internal(const char *ptr, const char *end, G if (pend != c && is_end_of_word(*pend)) { // The axis value has been parsed correctly. if (axis != UNKNOWN_AXIS) - gline.m_axis[int(axis)] = float(v); + gline.m_axis[int(axis)] = v; gline.m_mask |= 1 << int(axis); c = pend; } else diff --git a/src/libslic3r/GCodeReader.hpp b/src/libslic3r/GCodeReader.hpp index e65dff193c..97699a99e1 100644 --- a/src/libslic3r/GCodeReader.hpp +++ b/src/libslic3r/GCodeReader.hpp @@ -35,7 +35,8 @@ public: std::string_view axis_pos(char axis) const; void clear() { m_raw.clear(); } bool has(Axis axis) const { return (m_mask & (1 << int(axis))) != 0; } - float value(Axis axis) const { return m_axis[axis]; } + float value(Axis axis) const { return float(m_axis[axis]); } + double precise_value(Axis axis) const { return m_axis[axis]; } bool has(char axis) const; bool has_value(char axis, float &value) const; // Parse value of an axis from raw string starting at axis_pos. @@ -72,15 +73,15 @@ public: bool has_p() const { return this->has(P); } bool has_unknown_axis() const { return this->has(UNKNOWN_AXIS); } - float x() const { return m_axis[X]; } - float y() const { return m_axis[Y]; } - float z() const { return m_axis[Z]; } - float e() const { return m_axis[E]; } - float f() const { return m_axis[F]; } + float x() const { return float(m_axis[X]); } + float y() const { return float(m_axis[Y]); } + float z() const { return float(m_axis[Z]); } + float e() const { return float(m_axis[E]); } + float f() const { return float(m_axis[F]); } // BBS: add I J P axis - float i() const { return m_axis[I]; } - float j() const { return m_axis[J]; } - float p() const { return m_axis[P]; } + float i() const { return float(m_axis[I]); } + float j() const { return float(m_axis[J]); } + float p() const { return float(m_axis[P]); } static bool cmd_is(const std::string &gcode_line, const char *cmd_test) { const char *cmd = GCodeReader::skip_whitespaces(gcode_line.c_str()); @@ -100,7 +101,7 @@ public: } private: std::string m_raw; - float m_axis[NUM_AXES]; + double m_axis[NUM_AXES]; uint32_t m_mask; friend class GCodeReader; }; diff --git a/src/libslic3r/GCodeWriter.cpp b/src/libslic3r/GCodeWriter.cpp index 62382f8214..805e6e2f52 100644 --- a/src/libslic3r/GCodeWriter.cpp +++ b/src/libslic3r/GCodeWriter.cpp @@ -453,12 +453,11 @@ std::string GCodeWriter::set_acceleration_internal(Acceleration type, unsigned i gcode += separate_travel ? "M204 T" : "M204 P"; gcode += value; } else if (FLAVOR_IS(gcfKlipper)) { - gcode.reserve(96); + gcode.reserve(128); gcode += "SET_VELOCITY_LIMIT ACCEL="; gcode += value; if (this->config.accel_to_decel_enable) { - gcode += " ACCEL_TO_DECEL="; - gcode += float_to_string_decimal_point(acceleration * this->config.accel_to_decel_factor / 100); + append_klipper_accel_to_decel(gcode, acceleration, this->config.accel_to_decel_factor.value); if (GCodeWriter::full_gcode_comment) gcode += " ; adjust ACCEL_TO_DECEL"; } @@ -473,6 +472,14 @@ std::string GCodeWriter::set_acceleration_internal(Acceleration type, unsigned i return gcode; } +void GCodeWriter::append_klipper_accel_to_decel(std::string &gcode, unsigned int acceleration, double accel_to_decel_factor) +{ + gcode += " ACCEL_TO_DECEL="; + gcode += float_to_string_decimal_point(acceleration * accel_to_decel_factor / 100); + gcode += " MINIMUM_CRUISE_RATIO="; + gcode += float_to_string_decimal_point(1. - accel_to_decel_factor / 100); +} + std::string GCodeWriter::set_jerk_xy(double jerk) { if (jerk < 0.01 || is_approx(jerk, m_last_jerk)) @@ -548,15 +555,13 @@ std::string GCodeWriter::set_accel_and_jerk(unsigned int acceleration, double je return std::string(); std::string gcode; - gcode.reserve(96); + gcode.reserve(128); gcode += "SET_VELOCITY_LIMIT"; if (set_acceleration) { gcode += " ACCEL="; gcode += std::to_string(acceleration); - if (this->config.accel_to_decel_enable) { - gcode += " ACCEL_TO_DECEL="; - gcode += float_to_string_decimal_point(acceleration * this->config.accel_to_decel_factor / 100); - } + if (this->config.accel_to_decel_enable) + append_klipper_accel_to_decel(gcode, acceleration, this->config.accel_to_decel_factor.value); m_last_acceleration = acceleration; } if (set_jerk) { diff --git a/src/libslic3r/GCodeWriter.hpp b/src/libslic3r/GCodeWriter.hpp index 3c90a4e9e1..fcbd68b282 100644 --- a/src/libslic3r/GCodeWriter.hpp +++ b/src/libslic3r/GCodeWriter.hpp @@ -71,6 +71,10 @@ public: std::string set_jerk_xy(double jerk); // Orca: set acceleration and jerk in one command for Klipper std::string set_accel_and_jerk(unsigned int acceleration, double jerk); + // Orca: Klipper's accel_to_decel limit for `acceleration`, sent as the legacy ACCEL_TO_DECEL and as the + // equivalent MINIMUM_CRUISE_RATIO = 1 - ACCEL_TO_DECEL / ACCEL that replaced it. Klipper before 0.13 and + // vendor forks read only the first, current Klipper only the second, and 0.13 prefers the second. + static void append_klipper_accel_to_decel(std::string &gcode, unsigned int acceleration, double accel_to_decel_factor); std::string set_junction_deviation(double junction_deviation); std::string set_pressure_advance(double pa, int tool = -1) const; std::string set_input_shaping(char axis, float damp, float freq, std::string type) const; diff --git a/src/slic3r/GUI/Plater.cpp b/src/slic3r/GUI/Plater.cpp index 058eae58ba..cd85486891 100644 --- a/src/slic3r/GUI/Plater.cpp +++ b/src/slic3r/GUI/Plater.cpp @@ -17866,6 +17866,9 @@ void Plater::calib_input_shaping_freq(const Calib_Params& params) filament_config->set_key_value("slow_down_for_layer_cooling", new ConfigOptionBools{false}); print_config->set_key_value("layer_height", new ConfigOptionFloat(0.2)); set_config_values(print_config, "enable_overhang_speed", false); + // A 100% factor sends Klipper MINIMUM_CRUISE_RATIO=0 with every acceleration change, for the whole test. + print_config->set_key_value("accel_to_decel_enable", new ConfigOptionBool(true)); + print_config->set_key_value("accel_to_decel_factor", new ConfigOptionPercent(100)); print_config->set_key_value("timelapse_type", new ConfigOptionEnum(tlTraditional)); print_config->set_key_value("wall_loops", new ConfigOptionInt(1)); print_config->set_key_value("top_shell_layers", new ConfigOptionInt(0)); @@ -17930,6 +17933,9 @@ void Plater::calib_input_shaping_damp(const Calib_Params& params) filament_config->set_key_value("slow_down_min_speed", new ConfigOptionFloats { 0.0 }); filament_config->set_key_value("slow_down_for_layer_cooling", new ConfigOptionBools{false}); set_config_values(print_config, "enable_overhang_speed", false); + // A 100% factor sends Klipper MINIMUM_CRUISE_RATIO=0 with every acceleration change, for the whole test. + print_config->set_key_value("accel_to_decel_enable", new ConfigOptionBool(true)); + print_config->set_key_value("accel_to_decel_factor", new ConfigOptionPercent(100)); print_config->set_key_value("timelapse_type", new ConfigOptionEnum(tlTraditional)); print_config->set_key_value("wall_loops", new ConfigOptionInt(1)); print_config->set_key_value("top_shell_layers", new ConfigOptionInt(0)); diff --git a/tests/fff_print/test_gcode_timing.cpp b/tests/fff_print/test_gcode_timing.cpp index b3388340dc..cb1b2c4062 100644 --- a/tests/fff_print/test_gcode_timing.cpp +++ b/tests/fff_print/test_gcode_timing.cpp @@ -2,15 +2,18 @@ #include #include +#include #include #include #include "libslic3r/GCode/GCodeProcessor.hpp" #include "libslic3r/MultiNozzleUtils.hpp" #include "libslic3r/PrintConfig.hpp" +#include "libslic3r/libslic3r.h" #include "libslic3r/Utils.hpp" #include "test_utils.hpp" +#include #include #include #include @@ -30,6 +33,7 @@ namespace Slic3r { enum ExtrusionRole : uint8_t; } using namespace Slic3r; using Catch::Matchers::WithinAbs; +using Catch::Matchers::WithinRel; // Regression coverage for filament/tool-change time being folded into the first // pending motion block (an extrusion move) instead of the tool-change move, and @@ -570,6 +574,33 @@ double planned_corner_speed(GCodeFlavor flavor, double corner_velocity, double j } // namespace +TEST_CASE("Klipper extrusion ratio changes use its own corner velocity, not E jerk", "[GCodeTiming][JunctionDeviation]") +{ + const double jerk_e = GENERATE(0.0, 100.0); + auto config = make_junction_config(gcfKlipper, 5.0, 0.0); + config.machine_max_jerk_e.values = {jerk_e, jerk_e}; + GCodeProcessor processor; + run_processor(processor, config, + "G90\nM83\nSET_VELOCITY_LIMIT ACCEL=1000 MINIMUM_CRUISE_RATIO=0\n" + "G1 X20 Y60 F6000\nM400\n" + "G1 X60 Y60 E0.8 F6000\nG1 X100 Y60 E1.6 F6000\nM400\n"); + // A 0.02 -> 0.04 extrusion ratio change is capped at 1 / 0.02 = 50 mm/s. + REQUIRE_THAT(corner_speed(processor.get_result()), Catch::Matchers::WithinAbs(50.0, 0.01)); +} + +TEST_CASE("Klipper legacy smoothing stays absolute across acceleration changes", "[GCodeTiming][Klipper]") +{ + const bool modern = GENERATE(false, true); + auto config = make_junction_config(gcfKlipper, 5.0, 0.0); + const std::string gcode = std::string("G90\nSET_VELOCITY_LIMIT ACCEL=1000 ") + + (modern ? "MINIMUM_CRUISE_RATIO=0.75\n" : "ACCEL_TO_DECEL=250\n") + + "G1 X40 F30000\nM400\nM204 S2000\nG1 X0\nM400\n"; + GCodeProcessor processor; + run_processor(processor, config, gcode.c_str()); + const double expected = modern ? 0.5 + 0.25 * std::sqrt(2.0) : 0.95; + REQUIRE_THAT(processor.get_time(PrintEstimatedStatistics::ETimeMode::Normal), WithinAbs(expected, 1e-5)); +} + TEST_CASE("Klipper corners are planned with junction deviation derived from the square corner velocity", "[GCodeTiming][JunctionDeviation]") { @@ -669,3 +700,172 @@ TEST_CASE("How fast a corner is taken does not depend on how much is extruded th Catch::Matchers::WithinRel(marlin, 0.02)); } } + +// Expected times are Klipper's own planner math (klippy/toolhead.py). For a move from rest to rest, +// LookAheadQueue.flush() reduces to cruise_v2 = min(vmax^2, d * accel, d * pseudo_accel) with +// pseudo_accel = accel * (1 - minimum_cruise_ratio), and Move.set_junction() to a trapezoid at accel; +// a move too short to reach its speed still spends that ratio of its length cruising. +namespace { + +constexpr double klipper_accel = 1000.0; // make_junction_config's acceleration +constexpr double klipper_cruise_ratio = 0.5; +constexpr double klipper_pseudo_accel = klipper_accel * (1.0 - klipper_cruise_ratio); + +// Time of a move that starts and ends at rest. +double rest_to_rest_time(double distance, double max_speed, double accel, double pseudo_accel) +{ + const double cruise_v2 = std::min(sqr(max_speed), distance * pseudo_accel); + const double cruise = std::sqrt(cruise_v2); + return 2.0 * cruise / accel + (distance - cruise_v2 / accel) / cruise; +} + +// Every run states the cruise ratio rather than relying on the estimator's built-in default. +void run_klipper(GCodeProcessor& proc, const std::string& gcode, + const FullPrintConfig& config = make_junction_config(gcfKlipper, 5.0, 0.0)) +{ + const std::string limits = "SET_VELOCITY_LIMIT MINIMUM_CRUISE_RATIO=" + std::to_string(klipper_cruise_ratio) + "\n"; + run_processor(proc, config, (limits + gcode).c_str()); +} + +double klipper_time(const std::string& gcode, const FullPrintConfig& config = make_junction_config(gcfKlipper, 5.0, 0.0)) +{ + GCodeProcessor proc; + run_klipper(proc, gcode, config); + return proc.get_time(PrintEstimatedStatistics::ETimeMode::Normal); +} + +double time_after_retract(const GCodeProcessorResult& r) +{ + double t = 0.0; + bool after = false; + for (const auto& mv : r.moves) { + if (mv.type == EMoveType::Retract) + after = true; + else if (after) + t += mv.time[NORMAL]; + } + return t; +} + +} // namespace + +TEST_CASE("A straight line split into short segments is planned as one move", "[GCodeTiming][Klipper]") +{ + // 100 mm at 50 mm/s queues 2 s of motion, so the look-ahead flushes lazily part-way along the + // line; at 10 mm/s^2 the ramps span many segments and the cruise ratio caps the speed. + const int segments = GENERATE(1, 100, 1000); + std::ostringstream gcode; + gcode << "SET_VELOCITY_LIMIT ACCEL=10\n" << std::fixed << std::setprecision(3); + for (int i = 1; i <= segments; ++i) + gcode << "G1 X" << 100.0 * i / segments << " F3000\n"; + REQUIRE_THAT(klipper_time(gcode.str()), + WithinRel(rest_to_rest_time(100.0, 50.0, 10.0, 10.0 * (1.0 - klipper_cruise_ratio)), 1e-6)); +} + +TEST_CASE("Where the look-ahead flushes does not change the estimate", "[GCodeTiming][Klipper]") +{ + // A retraction stops the toolhead, so the zig-zag after it is planned on its own; the straight run + // before it only shifts where the queue flushes lazily (1 s of motion after a stop, then every 0.15 s). + auto zigzag_time = [](int run_moves) { + std::string gcode = "M83\nG91\n"; + for (int i = 0; i < run_moves; ++i) + gcode += "G1 X1 F6000\n"; + gcode += "G1 E-1 F2400\n"; + const char* moves[] = {"G1 X2 F3000\n", "G1 Y2 F9000\n", "G1 X2 F1200\n", "G1 Y-2 F9000\n"}; + for (int i = 0; i < 24; ++i) + gcode += moves[i % 4]; + GCodeProcessor proc; + run_klipper(proc, gcode); + return time_after_retract(proc.get_result()); + }; + const double reference = zigzag_time(0); + REQUIRE(reference > 0.0); + for (int run_moves : {30, 80, 105, 130}) { + INFO("run moves = " << run_moves); + REQUIRE_THAT(zigzag_time(run_moves), WithinAbs(reference, 1e-9)); + } +} + +TEST_CASE("A short move into a corner is limited by the centripetal term, not the corner velocity", + "[GCodeTiming][Klipper]") +{ + // A 50 mm/s square corner velocity allows 50 mm/s through the right angle; the 1 mm move into it + // allows sqrt(0.5 * 1 * 1000 * tan(45 deg)) = 22.4 mm/s (Move.calc_junction's move_centripetal_v2). + GCodeProcessor proc; + run_klipper(proc, "G90\nG1 X59 Y60 F6000\nM400\nG1 X60 Y60 F9000\nG1 X60 Y100 F9000\n", + make_junction_config(gcfKlipper, 50.0, 0.0)); + REQUIRE_THAT(corner_speed(proc.get_result()), WithinRel(std::sqrt(500.0), 1e-6)); +} + +TEST_CASE("A corner keeps the square corner velocity when the acceleration changes between its moves", + "[GCodeTiming][Klipper]") +{ + GCodeProcessor proc; + run_klipper(proc, "G90\nG1 X20 Y60 F6000\nM400\nG1 X60 Y60 F9000\nM204 S4000\nG1 X60 Y100 F9000\n"); + REQUIRE_THAT(corner_speed(proc.get_result()), WithinRel(5.0, 1e-6)); +} + +TEST_CASE("A retraction stops the toolhead and runs at the extruder's own limits", "[GCodeTiming][Klipper]") +{ + // An E acceleration of 0 is unset, and the retraction then uses the retract acceleration. + const auto [e_accel, retract_accel] = GENERATE(table({{20000.0, 20000.0}, {0.0, 3000.0}})); + auto config = make_junction_config(gcfKlipper, 5.0, 0.0); + config.machine_max_acceleration_e.values = {e_accel, e_accel}; + config.machine_max_acceleration_retracting.values = {3000.0, 3000.0}; + GCodeProcessor proc; + run_klipper(proc, "G90\nM83\nG1 X60 Y60 F9000\nG1 E-1 F2400\nG1 X100 Y60 F9000\n", config); + REQUIRE_THAT(corner_speed(proc.get_result()), WithinAbs(0.0, 1e-6)); + const double expected = rest_to_rest_time(std::sqrt(2.0) * 60.0, 150.0, klipper_accel, klipper_pseudo_accel) + + rest_to_rest_time(1.0, 40.0, retract_accel, std::min(retract_accel, klipper_pseudo_accel)) + + rest_to_rest_time(40.0, 150.0, klipper_accel, klipper_pseudo_accel); + REQUIRE_THAT(proc.get_time(PrintEstimatedStatistics::ETimeMode::Normal), WithinRel(expected, 1e-6)); +} + +TEST_CASE("Extruder limits apply to retractions, not to extrusion along a path", "[GCodeTiming][Klipper]") +{ + auto config = make_junction_config(gcfKlipper, 5.0, 0.0); + config.machine_max_speed_e.values = {10.0, 10.0}; + config.machine_max_acceleration_e.values = {10.0, 10.0}; + REQUIRE_THAT(klipper_time("M83\nG1 X40 E20 F30000\n", config), + WithinRel(rest_to_rest_time(40.0, 500.0, klipper_accel, klipper_pseudo_accel), 1e-6)); + REQUIRE_THAT(klipper_time("M83\nG1 E-1 F2400\n", config), + WithinRel(rest_to_rest_time(1.0, 10.0, 10.0, 10.0), 1e-6)); +} + +TEST_CASE("A Z move is limited by the Z axis speed and acceleration", "[GCodeTiming][Klipper]") +{ + auto config = make_junction_config(gcfKlipper, 5.0, 0.0); + config.machine_max_speed_z.values = {20.0, 20.0}; + config.machine_max_acceleration_z.values = {100.0, 100.0}; + REQUIRE_THAT(klipper_time("G1 Z10 F1200\n", config), + WithinRel(rest_to_rest_time(10.0, 20.0, 100.0, 100.0), 1e-6)); +} + +TEST_CASE("M204 sets the acceleration from S, or the smaller of P and T", "[GCodeTiming][Klipper]") +{ + const char* m204 = GENERATE("M204 S500", "M204 P500 T2000", "M204 P2000 T500"); + REQUIRE_THAT(klipper_time(std::string(m204) + "\nG1 X100 F30000\n"), + WithinRel(rest_to_rest_time(100.0, 500.0, 500.0, 500.0 * (1.0 - klipper_cruise_ratio)), 1e-6)); +} + +TEST_CASE("SET_VELOCITY_LIMIT applies every limit on the line and ignores the comment", "[GCodeTiming][Klipper]") +{ + REQUIRE_THAT(klipper_time("SET_VELOCITY_LIMIT VELOCITY=80 ACCEL=500 MINIMUM_CRUISE_RATIO=0.2 ; ACCEL=9999\n" + "G1 X20 F30000\n"), + WithinRel(rest_to_rest_time(20.0, 80.0, 500.0, 500.0 * (1.0 - 0.2)), 1e-6)); +} + +TEST_CASE("ACCEL_TO_DECEL is ignored on a line that also sets the minimum cruise ratio", "[GCodeTiming][Klipper]") +{ + // Klipper 0.13 prefers the cruise ratio and later releases no longer read ACCEL_TO_DECEL at all. + // ACCEL_TO_DECEL comes last, so applying the parameters in line order would let it win. + REQUIRE_THAT(klipper_time("SET_VELOCITY_LIMIT MINIMUM_CRUISE_RATIO=" + std::to_string(klipper_cruise_ratio) + + " ACCEL_TO_DECEL=100\nG1 X40 F30000\n"), + WithinRel(rest_to_rest_time(40.0, 500.0, klipper_accel, klipper_pseudo_accel), 1e-6)); +} + +TEST_CASE("M400 without a delay waits for the queue to drain", "[GCodeTiming][Klipper]") +{ + REQUIRE_THAT(klipper_time("G90\nG1 X40 F30000\nM400\nG1 X80\n"), + WithinRel(2.0 * rest_to_rest_time(40.0, 500.0, klipper_accel, klipper_pseudo_accel), 1e-6)); +} diff --git a/tests/fff_print/test_gcodewriter.cpp b/tests/fff_print/test_gcodewriter.cpp index d491527db6..6dad1251f9 100644 --- a/tests/fff_print/test_gcodewriter.cpp +++ b/tests/fff_print/test_gcodewriter.cpp @@ -514,7 +514,7 @@ TEST_CASE("Acceleration and velocity limit commands print their values in genera const Case c = GENERATE(values({ {gcfKlipper, Command::KlipperLimits, 2000000, 25. / 3., false, {"SET_VELOCITY_LIMIT ACCEL=2000000 ", "ACCEL_TO_DECEL=1e+06 ", "SQUARE_CORNER_VELOCITY=8.33333\n"}, {}}, - {gcfKlipper, Command::KlipperLimits, 12345, 0., false, {"ACCEL=12345 ", "ACCEL_TO_DECEL=6172.5\n"}, {"SQUARE_CORNER_VELOCITY"}}, + {gcfKlipper, Command::KlipperLimits, 12345, 0., false, {"ACCEL=12345 ", "ACCEL_TO_DECEL=6172.5 ", "MINIMUM_CRUISE_RATIO=0.5\n"}, {"SQUARE_CORNER_VELOCITY"}}, {gcfKlipper, Command::KlipperLimits, 0, 0.25, true, {"SQUARE_CORNER_VELOCITY=0.25 ", "; adjust VELOCITY_LIMIT"}, {"ACCEL"}}, {gcfKlipper, Command::Print, 3001, 0., true, {"ACCEL=3001 ", "ACCEL_TO_DECEL=1500.5 ", "; adjust ACCEL_TO_DECEL", "; adjust acceleration"}, {}}, {gcfMarlinFirmware, Command::Print, 2500, 0., false, {"M204 P2500\n"}, {}}, @@ -1272,6 +1272,41 @@ TEST_CASE("Custom G-code motion limits are restored before generated moves", "[G REQUIRE(gcode.find("M205 X8 Y8 ; adjust jerk", custom_gcode_pos) != std::string::npos); } +TEST_CASE("Klipper acceleration commands send accel_to_decel both as ACCEL_TO_DECEL and as the cruise ratio", "[GCodeWriter]") +{ + // Klipper 0.13 defines MINIMUM_CRUISE_RATIO = 1 - ACCEL_TO_DECEL / ACCEL; older releases read only the + // first parameter and current ones only the second, so both have to describe the same limit. + GCodeWriter writer; + writer.config.gcode_flavor.value = gcfKlipper; + writer.config.accel_to_decel_enable.value = true; + // The writer appends "; adjust ..." comments by default; keep them out so the line ends at the ratio. + struct CommentGuard + { + bool saved = GCodeWriter::full_gcode_comment; + ~CommentGuard() { GCodeWriter::full_gcode_comment = saved; } + } comment_guard; + GCodeWriter::full_gcode_comment = false; + + SECTION("a 25% factor") { + writer.config.accel_to_decel_factor.value = 25; + REQUIRE_THAT(writer.set_print_acceleration(2000), + Catch::Matchers::StartsWith("SET_VELOCITY_LIMIT ACCEL=2000 ACCEL_TO_DECEL=500 MINIMUM_CRUISE_RATIO=0.75")); + REQUIRE_THAT(writer.set_accel_and_jerk(3000, 0.0), + Catch::Matchers::StartsWith("SET_VELOCITY_LIMIT ACCEL=3000 ACCEL_TO_DECEL=750 MINIMUM_CRUISE_RATIO=0.75")); + } + SECTION("a 100% factor turns the cruise ratio off") { + writer.config.accel_to_decel_factor.value = 100; + REQUIRE_THAT(writer.set_print_acceleration(2000), + Catch::Matchers::StartsWith("SET_VELOCITY_LIMIT ACCEL=2000 ACCEL_TO_DECEL=2000 MINIMUM_CRUISE_RATIO=0\n")); + } + SECTION("neither is sent while the option is off") { + writer.config.accel_to_decel_enable.value = false; + const std::string line = writer.set_print_acceleration(2000); + REQUIRE_THAT(line, Catch::Matchers::StartsWith("SET_VELOCITY_LIMIT ACCEL=2000")); + REQUIRE_THAT(line, !Catch::Matchers::ContainsSubstring("ACCEL_TO_DECEL") && !Catch::Matchers::ContainsSubstring("MINIMUM_CRUISE_RATIO")); + } +} + // Regression test for the belt-printer "illegal gantry move at print start" bug. // // On a belt printer the layer-change z-hop is deferred (lazy_lift) and consumed diff --git a/tests/fff_print/test_wipe_tower.cpp b/tests/fff_print/test_wipe_tower.cpp index c094a656f7..78adb000be 100644 --- a/tests/fff_print/test_wipe_tower.cpp +++ b/tests/fff_print/test_wipe_tower.cpp @@ -202,6 +202,23 @@ TEST_CASE("The wipe tower's toolchange planner flush follows the gcode flavor", } } +TEST_CASE("The wipe tower sets Klipper's acceleration and accel_to_decel on one line", "[WipeTower]") +{ + // On separate lines, current Klipper finds nothing it reads on the ACCEL_TO_DECEL one and 0.13 + // derives the cruise ratio from the acceleration before the change. + DynamicPrintConfig config = wipe_tower_toolchange_config("klipper"); + // Type 2 towers leave the acceleration to the main G-code writer. + config.set_deserialize_strict({ { "wipe_tower_type", "type1" }, + { "accel_to_decel_enable", "1" }, { "accel_to_decel_factor", "50%" }, + { "default_acceleration", "1000" }, { "travel_acceleration", "2000" }, + { "machine_max_acceleration_extruding", "5000" } }); + const std::string tower = wipe_tower_regions(slice_with_prime_tower(config)); + REQUIRE_THAT(tower, Catch::Matchers::ContainsSubstring("SET_VELOCITY_LIMIT ACCEL=")); + CHECK_THAT(tower, Catch::Matchers::ContainsSubstring(" MINIMUM_CRUISE_RATIO=0.5\n")); + CHECK_THAT(tower, !Catch::Matchers::ContainsSubstring("SET_VELOCITY_LIMIT ACCEL_TO_DECEL") && + !Catch::Matchers::ContainsSubstring("M204")); +} + // What Print feeds the shared estimate. The libslic3r WipeTowerEstimate cases cannot see this: // they call the estimator directly. The estimate counts the filaments the print really uses, // so the two-filament shape gives the outer wall the second one.