From 3ebd68d9599ed771371f40381bb1e20fe235da8d Mon Sep 17 00:00:00 2001 From: SoftFever Date: Sun, 12 Jul 2026 14:48:45 +0800 Subject: [PATCH] feat(engine): per-nozzle-variant machine limits in the time estimator The time estimator's speed/acceleration limits were indexed by time mode only, reading slot 0 of the per-(extruder x volume-type) arrays the multi-extruder profiles already carry (H2C 0.4: 8 entries, H2D 0.4: 10). Every move was therefore modelled with the first machine slot's limits regardless of which nozzle variant was printing - estimation fidelity only, since emitted feedrates/accelerations are decided on the slicing side. Now the estimator resolves the machine slot of the nozzle currently mounted in the active extruder: the nozzle grouping context is handed to the processor BEFORE the streaming replay (new member + setter - deliberately separate from the post-stream result-field handover that gates the richer change-time model, whose timing is unchanged), the occupancy recorder is populated on every filament change (bookkeeping decoupled from the gated time model; recorder writes have no time effect), and get_machine_config_idx maps (volume type x extruder type x extruder) to the slot via the printer's variant layout, newly carried on the processor result. The feedrate/acceleration getters gain a slot parameter indexing [slot*2 + mode]; jerk and the print/travel/retract accelerations stay mode-only. Reloaded sliced projects re-estimate with the result's saved grouping context; imported bare g-code degrades to slot 0 - the historical read. M201/M203 write the parsed value into EVERY slot's mode entry (a firmware envelope change is global), which keeps per-slot reads in lockstep with the mode-only reads they replace: the fleet emits envelope lines before any motion, so estimates - hence the estimated time header, M73 lines, and every other byte - are unchanged (20/20 pinned-slice byte gate bit-identical, incl. the sequential repro sliced twice). Fidelity improves where envelope emission is off or a migrating per-layer plan moves filaments across variants. Tests: a stub-driven processor case proving the slot follows the active nozzle through the exact production path (T..H.. commands, fallback recorder bookkeeping, 4x time ratio on the slow variant), that emitted M201/M203 reach every slot, and that a missing context degrades to slot 0. Suites green (libslic3r 48998/169, fff_print 667/62). --- src/libslic3r/GCode.cpp | 10 +- src/libslic3r/GCode/GCodeProcessor.cpp | 145 ++++++++++++++++++------- src/libslic3r/GCode/GCodeProcessor.hpp | 42 ++++++- src/libslic3r/Print.cpp | 8 +- tests/fff_print/test_gcode_timing.cpp | 95 ++++++++++++++++ 5 files changed, 253 insertions(+), 47 deletions(-) diff --git a/src/libslic3r/GCode.cpp b/src/libslic3r/GCode.cpp index 0eacc024c8..78bb80dbf9 100644 --- a/src/libslic3r/GCode.cpp +++ b/src/libslic3r/GCode.cpp @@ -2453,11 +2453,16 @@ void GCode::do_export(Print* print, const char* path, GCodeProcessorResult* resu // free functions called by GCode::_do_export() namespace DoExport { - static void init_gcode_processor(const PrintConfig& config, GCodeProcessor& processor, bool& silent_time_estimator_enabled) + static void init_gcode_processor(const PrintConfig& config, GCodeProcessor& processor, bool& silent_time_estimator_enabled, + const std::shared_ptr& nozzle_group_result = nullptr) { silent_time_estimator_enabled = (config.gcode_flavor == gcfMarlinLegacy || config.gcode_flavor == gcfMarlinFirmware) && config.silent_mode; processor.reset(); + // Slot-resolution context for the streaming replay (reset() just cleared it). This is NOT + // the post-stream result-field handover at the end of do_export, which gates the richer + // change-time model and must stay after the stream. + processor.initialize_from_context(nozzle_group_result); processor.initialize_result_moves(); processor.apply_config(config); processor.enable_stealth_time_estimator(silent_time_estimator_enabled); @@ -2714,7 +2719,8 @@ void GCode::_do_export(Print& print, GCodeOutputStream &file, ThumbnailsGenerato update_layer_related_config(0); // modifies m_silent_time_estimator_enabled - DoExport::init_gcode_processor(print.config(), m_processor, m_silent_time_estimator_enabled); + DoExport::init_gcode_processor(print.config(), m_processor, m_silent_time_estimator_enabled, + print.get_layered_nozzle_group_result()); const bool is_bbl_printers = print.is_BBL_printer(); const WipeTowerType wipe_tower_type = print.wipe_tower_type(); m_calib_config.clear(); diff --git a/src/libslic3r/GCode/GCodeProcessor.cpp b/src/libslic3r/GCode/GCodeProcessor.cpp index 262be94e59..e76aa6a276 100644 --- a/src/libslic3r/GCode/GCodeProcessor.cpp +++ b/src/libslic3r/GCode/GCodeProcessor.cpp @@ -2548,6 +2548,9 @@ void GCodeProcessorResult::reset() { nozzle_group_result.reset(); // per-extruder hotend types (pre-heat injector input); repopulated by apply_config. extruder_types.clear(); + // machine-slot layout of the per-variant printer arrays; repopulated by apply_config. + printer_extruder_variant.clear(); + printer_extruder_id.clear(); // SKIPPABLE per-type accumulated time. skippable_part_time.clear(); @@ -2945,6 +2948,8 @@ void GCodeProcessor::apply_config(const PrintConfig& config) m_result.extruder_types.resize(config.extruder_type.values.size()); for (size_t idx = 0; idx < config.extruder_type.values.size(); ++idx) m_result.extruder_types[idx] = static_cast(config.extruder_type.values[idx]); + m_result.printer_extruder_variant = config.printer_extruder_variant.values; + m_result.printer_extruder_id = config.printer_extruder_id.values; m_extruder_offsets.resize(filament_count); m_extruder_colors.resize(filament_count); @@ -3130,6 +3135,11 @@ void GCodeProcessor::apply_config(const DynamicPrintConfig& config) m_result.extruder_types[idx] = static_cast(extruder_type->values[idx]); } + if (const ConfigOptionStrings* pe_variant = config.option("printer_extruder_variant")) + m_result.printer_extruder_variant = pe_variant->values; + if (const ConfigOptionInts* pe_id = config.option("printer_extruder_id")) + m_result.printer_extruder_id = pe_id->values; + const ConfigOptionEnumsGenericNullable* nozzle_type = config.option("nozzle_type"); if (nozzle_type != nullptr) { m_result.nozzle_type.resize(nozzle_type->size()); @@ -3497,6 +3507,9 @@ void GCodeProcessor::reset() // clear the multi-nozzle occupancy tracker between slices (the richer hotend-change model's only // mutable state). Inert for the single-nozzle fleet (never populated). m_nozzle_status_recorder = MultiNozzleUtils::NozzleStatusRecorder{}; + // drop the slot-resolution context and its cached slot; re-seeded per export. + m_nozzle_group_result.reset(); + m_machine_config_idx = 0; m_extruder_colors.resize(MIN_EXTRUDERS_COUNT); for (size_t i = 0; i < MIN_EXTRUDERS_COUNT; ++i) { m_extruder_colors[i] = static_cast(i); @@ -5056,7 +5069,7 @@ void GCodeProcessor::process_G1(const std::array, 4>& axes curr.abs_axis_feedrate[a] = std::abs(curr.axis_feedrate[a]); if (curr.abs_axis_feedrate[a] != 0.0f) { - float axis_max_feedrate = get_axis_max_feedrate(static_cast(i), static_cast(a)); + 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]); } } @@ -5080,7 +5093,7 @@ void GCodeProcessor::process_G1(const std::array, 4>& axes //BBS for (unsigned char a = X; a <= E; ++a) { - float axis_max_acceleration = get_axis_max_acceleration(static_cast(i), static_cast(a)); + float axis_max_acceleration = get_axis_max_acceleration(static_cast(i), static_cast(a), m_machine_config_idx); if (acceleration * std::abs(delta_pos[a]) * inv_distance > axis_max_acceleration) acceleration = axis_max_acceleration / (std::abs(delta_pos[a]) * inv_distance); } @@ -5418,7 +5431,7 @@ void GCodeProcessor::process_VG1(const GCodeReader::GCodeLine& line) curr.abs_axis_feedrate[a] = std::abs(curr.axis_feedrate[a]); if (curr.abs_axis_feedrate[a] != 0.0f) { - float axis_max_feedrate = get_axis_max_feedrate(static_cast(i), static_cast(a)); + 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]); } } @@ -5442,7 +5455,7 @@ void GCodeProcessor::process_VG1(const GCodeReader::GCodeLine& line) //BBS for (unsigned char a = X; a <= E; ++a) { - float axis_max_acceleration = get_axis_max_acceleration(static_cast(i), static_cast(a)); + float axis_max_acceleration = get_axis_max_acceleration(static_cast(i), static_cast(a), m_machine_config_idx); if (acceleration * std::abs(delta_pos[a]) * inv_distance > axis_max_acceleration) acceleration = axis_max_acceleration / (std::abs(delta_pos[a]) * inv_distance); } @@ -6181,16 +6194,23 @@ void GCodeProcessor::process_M201(const GCodeReader::GCodeLine& line) // see http://reprap.org/wiki/G-code#M201:_Set_max_printing_acceleration float factor = ((m_flavor != gcfRepRapSprinter && m_flavor != gcfRepRapFirmware) && m_units == EUnits::Inches) ? INCHES_TO_MM : 1.0f; - // Write to index i (0=Normal, 1=Stealth) — matches get_axis_max_acceleration's read pattern. + // The arrays are slot-major ([slot*2 + mode]); a firmware M201 changes the machine's live + // limits globally, so write the value into EVERY slot's mode entry. Orca: covering every slot + // (not a partial range) keeps the per-slot reads in lockstep with the mode-only reads they + // replaced. Per-mode gating unchanged: Stealth entries only once envelope processing is on. + auto set_all_slots = [](ConfigOptionFloats &option, size_t mode, float value) { + for (size_t slot_base = 0; slot_base < option.size(); slot_base += 2) + set_option_value(option, slot_base + mode, value); + }; 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) { - if (line.has_x()) set_option_value(m_time_processor.machine_limits.machine_max_acceleration_x, i, line.x() * factor); + if (line.has_x()) set_all_slots(m_time_processor.machine_limits.machine_max_acceleration_x, i, line.x() * factor); - if (line.has_y()) set_option_value(m_time_processor.machine_limits.machine_max_acceleration_y, i, line.y() * factor); + if (line.has_y()) set_all_slots(m_time_processor.machine_limits.machine_max_acceleration_y, i, line.y() * factor); - if (line.has_z()) set_option_value(m_time_processor.machine_limits.machine_max_acceleration_z, i, line.z() * factor); + if (line.has_z()) set_all_slots(m_time_processor.machine_limits.machine_max_acceleration_z, i, line.z() * factor); - if (line.has_e()) set_option_value(m_time_processor.machine_limits.machine_max_acceleration_e, i, line.e() * factor); + if (line.has_e()) set_all_slots(m_time_processor.machine_limits.machine_max_acceleration_e, i, line.e() * factor); } } } @@ -6205,20 +6225,25 @@ void GCodeProcessor::process_M203(const GCodeReader::GCodeLine& line) // http://smoothieware.org/supported-g-codes float factor = (m_flavor == gcfMarlinLegacy || m_flavor == gcfMarlinFirmware || m_flavor == gcfSmoothie || m_flavor == gcfKlipper) ? 1.0f : MMMIN_TO_MMSEC; - // Write to index i (0=Normal, 1=Stealth) — matches get_axis_max_feedrate's read pattern. + // Slot-major arrays; a firmware M203 changes the live limits globally — write every slot's + // mode entry (see process_M201). Per-mode gating unchanged. + auto set_all_slots = [](ConfigOptionFloats &option, size_t mode, float value) { + for (size_t slot_base = 0; slot_base < option.size(); slot_base += 2) + set_option_value(option, slot_base + mode, value); + }; 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) { if (line.has_x()) - set_option_value(m_time_processor.machine_limits.machine_max_speed_x, i, line.x() * factor); + set_all_slots(m_time_processor.machine_limits.machine_max_speed_x, i, line.x() * factor); if (line.has_y()) - set_option_value(m_time_processor.machine_limits.machine_max_speed_y, i, line.y() * factor); + set_all_slots(m_time_processor.machine_limits.machine_max_speed_y, i, line.y() * factor); if (line.has_z()) - set_option_value(m_time_processor.machine_limits.machine_max_speed_z, i, line.z() * factor); + set_all_slots(m_time_processor.machine_limits.machine_max_speed_z, i, line.z() * factor); if (line.has_e()) - set_option_value(m_time_processor.machine_limits.machine_max_speed_e, i, line.e() * factor); + set_all_slots(m_time_processor.machine_limits.machine_max_speed_e, i, line.e() * factor); } } } @@ -6582,11 +6607,33 @@ bool GCodeProcessor::use_multi_nozzle_change_time_model() const // - Beyond total_(flush_)filament_changes, the richer counters (total_extruder_changes / load / // unload / tool_change time) are maintained in the matching branches so the multi-nozzle // GCodeViewer stats do not regress to zero. These are UI-only (not written to g-code). +std::optional GCodeProcessor::resolve_target_nozzle( + const MultiNozzleUtils::NozzleGroupResultBase &group, int id, int nozzle_id) const +{ + std::optional info; + if (nozzle_id != -1) + info = group.get_nozzle_from_id(nozzle_id); + if (!info) { + auto used_nozzles = group.get_nozzles_for_filament(id); + if (!used_nozzles.empty()) + info = used_nozzles.front(); + } + return info; +} + void GCodeProcessor::process_filament_change(int id, int nozzle_id) { // Gate: outside the multi-nozzle context, or when the nozzle-grouping result is not available // (e.g. re-importing a bare g-code file), run the existing single-arg model byte-for-byte. if (!use_multi_nozzle_change_time_model() || !m_result.nozzle_group_result) { + // Orca: occupancy bookkeeping is deliberately decoupled from the gated change-time model: + // the per-slot machine-limit resolution needs the recorder during the streaming pass, + // where the richer time model stays byte-frozen behind the result-field gate above. + // Recorder writes have no time effect. + if (m_nozzle_group_result) { + if (auto info = resolve_target_nozzle(*m_nozzle_group_result, id, nozzle_id)) + m_nozzle_status_recorder.set_nozzle_status(info->group_id, id, info->extruder_id); + } process_filament_change(id); return; } @@ -6604,15 +6651,7 @@ void GCodeProcessor::process_filament_change(int id, int nozzle_id) m_last_filament_id[prev_extruder_id] = static_cast(prev_filament_id); // Resolve the destination nozzle: by explicit H id first, else the filament's first nozzle. - std::optional target_nozzle_info; - if (nozzle_id != -1) - target_nozzle_info = m_result.nozzle_group_result->get_nozzle_from_id(nozzle_id); - if (!target_nozzle_info) { - auto used_nozzles = m_result.nozzle_group_result->get_nozzles_for_filament(id); - if (used_nozzles.empty()) - return; - target_nozzle_info = used_nozzles.front(); - } + std::optional target_nozzle_info = resolve_target_nozzle(*m_result.nozzle_group_result, id, nozzle_id); if (!target_nozzle_info) return; @@ -6690,6 +6729,8 @@ void GCodeProcessor::process_filament_change(int id, int nozzle_id) } simulate_st_synchronize(extra_time, EMoveType::Tool_change); + + m_machine_config_idx = get_machine_config_idx(); } void GCodeProcessor::process_filament_change(int id) @@ -6812,6 +6853,8 @@ void GCodeProcessor::process_filament_change(int id) } simulate_st_synchronize(extra_time, EMoveType::Tool_change); + + m_machine_config_idx = get_machine_config_idx(); } void GCodeProcessor::store_move_vertex(EMoveType type, EMovePathType path_type, bool internal_only) @@ -6941,33 +6984,59 @@ float GCodeProcessor::minimum_travel_feedrate(PrintEstimatedStatistics::ETimeMod return std::max(feedrate, get_option_value(m_time_processor.machine_limits.machine_min_travel_rate, static_cast(mode))); } -// Machine limit arrays hold 2 values: [0]=Normal, [1]=Stealth. Index by mode only. -// Orca: per-(extruder x volume-type) machine limits are deliberately not resolved here even -// though the slicing side now materializes filament_map_2 and the per-filament volume map -// (an extruder_id*2+mode style offset would need filament_map_2 in the processor-side config -// plus a re-audit of every get_option_value(..., mode) call). This only affects -// time-estimation fidelity: limits are mode-indexed for ALL multi-extruder printers alike, so -// a Hybrid extruder degrades no further than existing dual-extruder machines. Follow-up. +// Machine slot of the nozzle currently mounted in the active extruder. Slot 0 (the historical +// single-slot read) whenever there is no grouping context (bare g-code import), no active +// extruder yet, or the nozzle/extruder is unknown to the recorder. +int GCodeProcessor::get_machine_config_idx() const +{ + const int extruder_id = get_extruder_id(false); + if (!m_nozzle_group_result || extruder_id < 0) + return 0; + const int nozzle_id = m_nozzle_status_recorder.get_nozzle_in_extruder(extruder_id); + auto nozzle_info = m_nozzle_group_result->get_nozzle_from_id(nozzle_id); + // Orca: bounds guard — a stale grouping context after a printer swap must not index OOB. + if (!nozzle_info || extruder_id >= (int) m_result.extruder_types.size()) + return 0; + return std::max(0, get_config_index_base(nozzle_info->volume_type, m_result.extruder_types[extruder_id], + extruder_id + 1, m_result.printer_extruder_variant, + m_result.printer_extruder_id)); +} + +// Speed/acceleration limit arrays are slot-major with two mode entries per machine slot: +// [slot*2 + mode]. Single-variant printers have one slot, so the 2-arg forms (slot 0) read +// exactly the historical [mode] entry. float GCodeProcessor::get_axis_max_feedrate(PrintEstimatedStatistics::ETimeMode mode, Axis axis) const { + return get_axis_max_feedrate(mode, axis, 0); +} + +float GCodeProcessor::get_axis_max_feedrate(PrintEstimatedStatistics::ETimeMode mode, Axis axis, int machine_idx) const +{ + const size_t pos = static_cast(machine_idx) * 2 + static_cast(mode); switch (axis) { - case X: { return get_option_value(m_time_processor.machine_limits.machine_max_speed_x, static_cast(mode)); } - case Y: { return get_option_value(m_time_processor.machine_limits.machine_max_speed_y, static_cast(mode)); } - case Z: { return get_option_value(m_time_processor.machine_limits.machine_max_speed_z, static_cast(mode)); } - case E: { return get_option_value(m_time_processor.machine_limits.machine_max_speed_e, static_cast(mode)); } + case X: { return get_option_value(m_time_processor.machine_limits.machine_max_speed_x, pos); } + case Y: { return get_option_value(m_time_processor.machine_limits.machine_max_speed_y, pos); } + case Z: { return get_option_value(m_time_processor.machine_limits.machine_max_speed_z, pos); } + case E: { return get_option_value(m_time_processor.machine_limits.machine_max_speed_e, pos); } default: { return 0.0f; } } } float GCodeProcessor::get_axis_max_acceleration(PrintEstimatedStatistics::ETimeMode mode, Axis axis) const { + return get_axis_max_acceleration(mode, axis, 0); +} + +float GCodeProcessor::get_axis_max_acceleration(PrintEstimatedStatistics::ETimeMode mode, Axis axis, int machine_idx) const +{ + const size_t pos = static_cast(machine_idx) * 2 + static_cast(mode); switch (axis) { - case X: { return get_option_value(m_time_processor.machine_limits.machine_max_acceleration_x, static_cast(mode)); } - case Y: { return get_option_value(m_time_processor.machine_limits.machine_max_acceleration_y, static_cast(mode)); } - case Z: { return get_option_value(m_time_processor.machine_limits.machine_max_acceleration_z, static_cast(mode)); } - case E: { return get_option_value(m_time_processor.machine_limits.machine_max_acceleration_e, static_cast(mode)); } + case X: { return get_option_value(m_time_processor.machine_limits.machine_max_acceleration_x, pos); } + case Y: { return get_option_value(m_time_processor.machine_limits.machine_max_acceleration_y, pos); } + case Z: { return get_option_value(m_time_processor.machine_limits.machine_max_acceleration_z, pos); } + case E: { return get_option_value(m_time_processor.machine_limits.machine_max_acceleration_e, pos); } default: { return 0.0f; } } } diff --git a/src/libslic3r/GCode/GCodeProcessor.hpp b/src/libslic3r/GCode/GCodeProcessor.hpp index 633695b03e..f6d434960a 100644 --- a/src/libslic3r/GCode/GCodeProcessor.hpp +++ b/src/libslic3r/GCode/GCodeProcessor.hpp @@ -298,6 +298,10 @@ class Print; // (mixed-type X2D workaround). Populated in apply_config; unused until the injector side-pass // consumes it. std::vector extruder_types; + // Machine-slot layout of the per-variant printer arrays (one entry per (extruder x + // volume-type) slot). Populated in apply_config; keys the per-slot machine-limit lookup. + std::vector printer_extruder_variant; + std::vector printer_extruder_id; // first key stores filaments, second keys stores the layer ranges(enclosed) that use the filaments std::unordered_map, std::vector>,FilamentSequenceHash> layer_filaments; std::vector nozzle_change_sequence; @@ -349,6 +353,8 @@ class Print; nozzle_group_result = other.nozzle_group_result; // Keep the per-extruder hotend types on a copied result (injector input). extruder_types = other.extruder_types; + printer_extruder_variant = other.printer_extruder_variant; + printer_extruder_id = other.printer_extruder_id; layer_filaments = other.layer_filaments; filament_change_sequence = other.filament_change_sequence; nozzle_change_sequence = other.nozzle_change_sequence; @@ -1104,9 +1110,15 @@ class Print; unsigned int m_machine_start_gcode_end_line_id{ (unsigned int) (-1) }; unsigned int m_machine_end_gcode_start_line_id{ (unsigned int) (-1) }; // Tracks, during the stream, which filament sits in each physical nozzle and which nozzle each - // extruder currently carries. Consumed ONLY by the richer two-arg process_filament_change - // model, which single-nozzle printers (X1/P1/A1/H2S/A2L) never enter. + // extruder currently carries. Written by both branches of the two-arg process_filament_change + // (the fallback branch does occupancy bookkeeping only); read by the richer change-time model + // and by the per-slot machine-limit resolution. Single-nozzle printers never populate it. MultiNozzleUtils::NozzleStatusRecorder m_nozzle_status_recorder; + // Nozzle grouping context for slot resolution during the streaming pass. Set before the + // replay begins (see initialize_from_context); deliberately separate from + // m_result.nozzle_group_result, which is handed over only after the stream for the + // pre-heat injector's second pass and gates the richer change-time model. + std::shared_ptr m_nozzle_group_result; bool m_manual_filament_change; //BBS: x, y offset for gcode generated @@ -1131,6 +1143,9 @@ class Print; std::vector m_last_filament_id; std::vector m_filament_id; unsigned char m_extruder_id; + // Cached get_machine_config_idx() value; its inputs (active extruder + recorder occupancy) + // change only on filament-change events, where it is recomputed. + int m_machine_config_idx{0}; ExtruderColors m_extruder_colors; ExtruderTemps m_extruder_temps; bool m_is_XL_printer = false; @@ -1193,6 +1208,11 @@ class Print; const std::vector>& unprintable_filament_types ); void apply_config(const PrintConfig& config); void set_print(Print* print) { m_print = print; } + // Hand the nozzle grouping context to the estimator BEFORE the streaming replay, so the + // per-slot machine-limit resolution can follow the active nozzle. Null is fine (slot 0). + void initialize_from_context(const std::shared_ptr& nozzle_group_result) { + m_nozzle_group_result = nozzle_group_result; + } DynamicConfig export_config_for_render() const; @@ -1412,6 +1432,15 @@ class Print; // filament-in-nozzle change. Self-gated: for single-nozzle printers it delegates to // process_filament_change(int) so their time estimate — hence exported g-code — is unchanged. void process_filament_change(int id, int nozzle_id); + // Destination nozzle of a filament change: the explicit H id when given, else the + // filament's first nozzle in the grouping. Shared by the change-time model and the + // fallback-path occupancy bookkeeping. + std::optional resolve_target_nozzle( + const MultiNozzleUtils::NozzleGroupResultBase &group, int id, int nozzle_id) const; + // Machine slot of the nozzle currently mounted in the active extruder (0 when no grouping + // context / unknown extruder — the single-slot layout). Cached in m_machine_config_idx, + // recomputed on filament-change events. + int get_machine_config_idx() const; // True only for multi-nozzle-capable printers (H2C cluster, or a dual/multi-extruder machine // like H2D/X2D): the gate that admits the richer two-arg hotend-change time model. False for // every single-extruder single-nozzle printer (X1/P1/A1/H2S/A2L). @@ -1440,11 +1469,14 @@ class Print; float minimum_feedrate(PrintEstimatedStatistics::ETimeMode mode, float feedrate) const; float minimum_travel_feedrate(PrintEstimatedStatistics::ETimeMode mode, float feedrate) const; - // Machine limit arrays are indexed by time mode only: [0]=Normal, [1]=Stealth. - // Do NOT add an extruder_id parameter — OrcaSlicer does not use BambuStudio's - // per-nozzle machine limits (filament_map_2 / get_config_idx_for_filament). + // Speed/acceleration limit arrays are slot-major with two mode entries per machine slot: + // [slot*2 + mode], slot from get_machine_config_idx() (0 = the only slot on single-variant + // printers, whose arrays hold just [Normal, Stealth]). The 2-arg forms read slot 0 and stay + // exactly the historical mode-only lookup; jerk and the accelerations below are mode-only. float get_axis_max_feedrate(PrintEstimatedStatistics::ETimeMode mode, Axis axis) const; + float get_axis_max_feedrate(PrintEstimatedStatistics::ETimeMode mode, Axis axis, int machine_idx) const; float get_axis_max_acceleration(PrintEstimatedStatistics::ETimeMode mode, Axis axis) const; + float get_axis_max_acceleration(PrintEstimatedStatistics::ETimeMode mode, Axis axis, int machine_idx) const; float get_axis_max_jerk_with_jd(PrintEstimatedStatistics::ETimeMode mode, Axis axis, float acceleration) const; float get_axis_max_jerk_with_jd(PrintEstimatedStatistics::ETimeMode mode, Axis axis) const; float get_axis_max_jerk(PrintEstimatedStatistics::ETimeMode mode, Axis axis) const; diff --git a/src/libslic3r/Print.cpp b/src/libslic3r/Print.cpp index 6820ea8804..0a9ee60728 100644 --- a/src/libslic3r/Print.cpp +++ b/src/libslic3r/Print.cpp @@ -3453,8 +3453,8 @@ void Print::update_to_config_by_nozzle_group_result(const MultiNozzleUtils::Laye // Note: filament_map_2 keeps its apply-time (static) derivation here; the per-slot machine // indices below key the override merge instead, so nothing on this path reads it. Its other // consumers are the three-map write-back (which recomputes it) and the diagnostic copy in - // the g-code header; per-(extruder x volume-type) machine limits in the g-code processor - // remain a follow-up (see GCodeProcessor::get_axis_max_feedrate). + // the g-code header; the time estimator resolves per-(extruder x volume-type) machine limits + // from the live nozzle occupancy instead (see GCodeProcessor::get_machine_config_idx). m_full_print_config = m_ori_full_print_config; std::set filament_keys = filament_options_with_variant; filament_keys.insert("filament_self_index"); @@ -4257,6 +4257,10 @@ void Print::export_gcode_from_previous_file(const std::string& file, GCodeProces GCodeProcessor::s_IsBBLPrinter = is_BBL_printer(); const Vec3d origin = this->get_plate_origin(); processor.set_xy_offset(origin(0), origin(1)); + // Reloaded sliced projects re-estimate with the same nozzle-grouping slot context as the + // original export; harmless when the result carries none (slot 0). + if (result != nullptr && result->nozzle_group_result) + processor.initialize_from_context(result->nozzle_group_result); //processor.enable_producers(true); processor.process_file(file); diff --git a/tests/fff_print/test_gcode_timing.cpp b/tests/fff_print/test_gcode_timing.cpp index 646ab1bbbe..4570f253bb 100644 --- a/tests/fff_print/test_gcode_timing.cpp +++ b/tests/fff_print/test_gcode_timing.cpp @@ -2,12 +2,14 @@ #include "libslic3r/libslic3r.h" #include "libslic3r/GCode/GCodeProcessor.hpp" +#include "libslic3r/MultiNozzleUtils.hpp" #include "libslic3r/PrintConfig.hpp" #include "test_utils.hpp" #include #include +#include using namespace Slic3r; using Catch::Matchers::WithinAbs; @@ -323,3 +325,96 @@ TEST_CASE("Carried-forward tool-change delay reaches the total without polluting REQUIRE_THAT(rd.at(role), WithinAbs(zero_time, 1e-2)); } } + +TEST_CASE("Per-slot machine limits follow the active nozzle", "[GCodeTiming][MultiNozzle]") +{ + // Single physical extruder carrying two nozzle variants: machine slot 0 (Standard) caps X/Y + // speed at 200 mm/s, slot 1 (High Flow) at 50 mm/s. The estimator must clamp each move by the + // slot of the nozzle the active filament occupies -- resolved from the grouping context handed + // over before the replay plus the occupancy recorder, i.e. the exact in-slicer streaming path. + FullPrintConfig config = make_config(0.0, 0.0, 0.0); + config.extruder_type.values = {static_cast(etDirectDrive)}; + config.printer_extruder_id.values = {1, 1}; + config.printer_extruder_variant.values = {"Direct Drive Standard", "Direct Drive High Flow"}; + // Slot-major layout: [slot0-Normal, slot0-Stealth, slot1-Normal, slot1-Stealth]. + config.machine_max_speed_x.values = {200., 200., 50., 50.}; + config.machine_max_speed_y.values = {200., 200., 50., 50.}; + config.machine_max_speed_z.values = {200., 200., 50., 50.}; + config.machine_max_speed_e.values = {200., 200., 50., 50.}; + // Keep acceleration and jerk far from limiting so move times are speed-dominated. + for (auto *accel : {&config.machine_max_acceleration_x, &config.machine_max_acceleration_y, + &config.machine_max_acceleration_z, &config.machine_max_acceleration_e}) + accel->values = {100000., 100000., 100000., 100000.}; + config.machine_max_acceleration_travel.values = {100000., 100000.}; + config.machine_max_acceleration_extruding.values = {100000., 100000.}; + config.machine_max_jerk_x.values = {10000., 10000.}; + config.machine_max_jerk_y.values = {10000., 10000.}; + config.machine_max_jerk_z.values = {10000., 10000.}; + config.machine_max_jerk_e.values = {10000., 10000.}; + + // Grouping stub: filament 0 lives on the Standard nozzle (slot 0), filament 1 on the + // High Flow nozzle (slot 1), both mounted on extruder 0. + std::vector nozzles; + { + MultiNozzleUtils::NozzleInfo n; + n.diameter = "0.4"; + n.volume_type = nvtStandard; n.extruder_id = 0; n.group_id = 0; nozzles.push_back(n); + n.volume_type = nvtHighFlow; n.extruder_id = 0; n.group_id = 1; nozzles.push_back(n); + } + std::vector filament_nozzle_map = {0, 1}; + std::vector used_filaments = {0, 1}; + auto group = MultiNozzleUtils::LayeredNozzleGroupResult::create(filament_nozzle_map, nozzles, used_filaments); + REQUIRE(group.has_value()); + auto context = std::make_shared(*group); + + // Two identical 100 mm X travels, one per filament; T..H.. carries the target nozzle id. + // The trailing 1 mm move keeps two blocks queued at finalize, so the measured move's time is + // flushed (a lone final block is never attributed); it adds 1 mm to the second bucket. + const char* gcode = + "M83\n" + "T0 H0\n" + "G1 X100 F30000\n" + "T1 H1\n" + "G1 X0 F30000\n" + "G1 X1 F30000\n"; + + // Travel time accumulated after each tool-change move (bucket 0 = before any T). + auto travel_times_by_tool = [](const GCodeProcessorResult& r) { + std::vector out(1, 0.0); + for (const auto& mv : r.moves) { + if (mv.type == EMoveType::Tool_change) + out.push_back(0.0); + else if (mv.type == EMoveType::Travel) + out.back() += mv.time[NORMAL]; + } + return out; + }; + + SECTION("the move on the High Flow nozzle is clamped by its own slot") { + GCodeProcessor proc; + proc.initialize_from_context(context); + run_processor(proc, config, gcode); + auto times = travel_times_by_tool(proc.get_result()); + REQUIRE(times.size() == 3); + REQUIRE_THAT(times[1], Catch::Matchers::WithinRel(100.0 / 200.0, 0.10)); + REQUIRE_THAT(times[2], Catch::Matchers::WithinRel(101.0 / 50.0, 0.10)); + } + SECTION("an emitted envelope line reaches every slot") { + const std::string enveloped = std::string("M201 X20000\nM203 X80\n") + gcode; + GCodeProcessor proc; + proc.initialize_from_context(context); + run_processor(proc, config, enveloped.c_str()); + auto times = travel_times_by_tool(proc.get_result()); + REQUIRE(times.size() == 3); + REQUIRE_THAT(times[1], Catch::Matchers::WithinRel(100.0 / 80.0, 0.10)); + REQUIRE_THAT(times[2], Catch::Matchers::WithinRel(101.0 / 80.0, 0.10)); + } + SECTION("no grouping context degrades to slot 0") { + GCodeProcessor proc; + run_processor(proc, config, gcode); + auto times = travel_times_by_tool(proc.get_result()); + REQUIRE(times.size() == 3); + REQUIRE_THAT(times[1], Catch::Matchers::WithinRel(100.0 / 200.0, 0.10)); + REQUIRE_THAT(times[2], Catch::Matchers::WithinRel(101.0 / 200.0, 0.10)); + } +}