diff --git a/src/OrcaSlicer.cpp b/src/OrcaSlicer.cpp index 9db1aea5f8..e0a15209a3 100644 --- a/src/OrcaSlicer.cpp +++ b/src/OrcaSlicer.cpp @@ -24,6 +24,8 @@ #include #include #include +#include +#include #if defined(__linux__) || defined(__LINUX__) #include @@ -7608,6 +7610,9 @@ LONG WINAPI VectoredExceptionHandler(PEXCEPTION_POINTERS pExceptionInfo) }*/ #if defined(_MSC_VER) || defined(__MINGW32__) +// Guards against a failed allocation inside the dump re-entering the new-handler. +static std::atomic g_dump_in_progress{false}; + extern "C" { __declspec(dllexport) int __stdcall orcaslicer_main(int argc, wchar_t **argv) { @@ -7626,10 +7631,22 @@ extern "C" { //AddVectoredExceptionHandler(1, CBaseException::UnhandledExceptionFilter); SET_DEFULTER_HANDLER(); #endif + // Dump before unwinding, while the stack still names what asked for the memory. Throwing + // std::bad_alloc is standard-permitted here and is what reaches generic_exception_handle(). std::set_new_handler([]() { - int *a = nullptr; - *a = 0; - }); + if (!g_dump_in_progress.exchange(true)) { + try { + // A null EXCEPTION_POINTERS walks the calling thread as it stands. + CBaseException base(GetCurrentProcess(), GetCurrentProcessId(), NULL, nullptr); + base.ShowCallstack(); + } catch (...) { + // A failed dump must not displace the std::bad_alloc owed to the caller. + } + // ObjParser recovers from std::bad_alloc, so let a later one dump again. + g_dump_in_progress = false; + } + throw std::bad_alloc(); + }); // Call the UTF8 main. return CLI().run(argc, argv_ptrs.data()); } diff --git a/src/libslic3r/GCode/GCodeProcessor.cpp b/src/libslic3r/GCode/GCodeProcessor.cpp index 4691af7e0e..cc53cd0e51 100644 --- a/src/libslic3r/GCode/GCodeProcessor.cpp +++ b/src/libslic3r/GCode/GCodeProcessor.cpp @@ -614,6 +614,8 @@ void GCodeProcessor::TimeMachine::calculate_time(GCodeProcessorResult& result, P float leftover = 0.0f; for (size_t i = additional_buffer_idx; i < additional_buffer.size(); ++i) leftover += additional_buffer[i].second; + BOOST_LOG_TRIVIAL(debug) << "calculate_time(is_final): leftover=" << leftover + << "s from " << (additional_buffer.size() - additional_buffer_idx) << " items"; time += double(leftover); gcode_time.cache += leftover; } else { @@ -3492,6 +3494,7 @@ void GCodeProcessor::reset() m_extruder_blocks.clear(); m_machine_start_gcode_end_line_id = (unsigned int) (-1); m_machine_end_gcode_start_line_id = (unsigned int) (-1); + m_skip_end_gcode_delays = false; m_remaining_volume = std::vector(MAXIMUM_EXTRUDER_NUMBER, 0.f); m_line_id = 0; @@ -4219,6 +4222,14 @@ void GCodeProcessor::process_tags(const std::string_view comment, bool producers return; } + // End gcode marker: skip post-print M400 S/P dwells after this point so the M73 estimate reports + // print-completion time, not post-print filtration/cooldown. BBS drops the same remainder in + // calculate_time(is_final). + if (comment == Machine_End_GCode_Start_Tag) { + m_skip_end_gcode_delays = true; + return; + } + // Orca: Integrate filament consumption for purging performed to an external device and controlled via macros // (eg. Happy Hare) in the filament consumption stats. if (boost::starts_with(comment, GCodeProcessor::External_Purge_Tag)) { @@ -6401,6 +6412,10 @@ void GCodeProcessor::process_M400(const GCodeReader::GCodeLine& line) 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); } } diff --git a/src/libslic3r/GCode/GCodeProcessor.hpp b/src/libslic3r/GCode/GCodeProcessor.hpp index 8a66cdc9e4..f5bec9e826 100644 --- a/src/libslic3r/GCode/GCodeProcessor.hpp +++ b/src/libslic3r/GCode/GCodeProcessor.hpp @@ -1109,6 +1109,10 @@ class Print; std::vector m_extruder_blocks; unsigned int m_machine_start_gcode_end_line_id{ (unsigned int) (-1) }; unsigned int m_machine_end_gcode_start_line_id{ (unsigned int) (-1) }; + // Set when the MACHINE_END_GCODE_START tag is seen during the streaming parse; tells + // process_M400 to skip post-print end-gcode dwells (air purification, timelapse, sound) + // so they don't inflate the M73 estimate. BBS excludes them in calculate_time(is_final). + bool m_skip_end_gcode_delays{ false }; // Tracks, during the stream, which filament sits in each physical nozzle and which nozzle each // 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 diff --git a/src/libslic3r/GCode/ToolOrderUtils.cpp b/src/libslic3r/GCode/ToolOrderUtils.cpp index c8b8ac3fdc..4e2934d967 100644 --- a/src/libslic3r/GCode/ToolOrderUtils.cpp +++ b/src/libslic3r/GCode/ToolOrderUtils.cpp @@ -1091,6 +1091,16 @@ namespace Slic3r if (layer + 1 < layer_filaments.size()) next_lf = layer_filaments[layer + 1]; std::vector filament_used_next_layer = collect_filaments_in_groups(filament_sets, next_lf); + // Enable inter-layer forecast: when choosing filament ordering for current layer, + // also consider next layer's filament set to minimize inter-layer transition flush. + // solve_extruder_order_with_forcast() tries all permutations of curr+next layer + // and picks the ordering that minimizes total flush across both layers. + // This avoids expensive inter-layer transitions (e.g. ending layer with F2 when + // next layer starts with F3, costing flush[F2→F3], instead of ending with F3 + // which gives flush[F3→F3]=0). Limited to ≤5 filaments due to O(N!×M!) complexity. + // The per-nozzle base reorder does not use the inter-layer forecast. This function drives + // BBL multi-extruder grouping cost and H2C ordering, so keeping it false avoids perturbing + // existing H2D/H2C output. bool use_forcast = false; float tmp_cost = 0; std::vector sequence; diff --git a/src/libslic3r/GCode/WipeTower.cpp b/src/libslic3r/GCode/WipeTower.cpp index 58df527da5..06bca292d4 100644 --- a/src/libslic3r/GCode/WipeTower.cpp +++ b/src/libslic3r/GCode/WipeTower.cpp @@ -1609,6 +1609,64 @@ void WipeTower::set_extruder(size_t idx, const PrintConfig& config) if (max_vol_speed!= 0.f) m_filpar[idx].max_e_speed = (max_vol_speed / filament_area()); + // Vortek H2C: carousel-specific ramming, precool, and reverse travel parameters + { + // Ramming speed: .first = extruder change, .second = nozzle change (carousel) + // Use the dedicated ramming volumetric speed, falling back to max_vol_speed only when + // the setting is nil/-1. + float ramming_vol_speed = float(config.filament_ramming_volumetric_speed.get_at(idx)); + if (config.filament_ramming_volumetric_speed.is_nil(idx) || is_approx(config.filament_ramming_volumetric_speed.get_at(idx), -1.)) + ramming_vol_speed = max_vol_speed; + m_filpar[idx].max_e_ramming_speed.first = (ramming_vol_speed / filament_area()); + + float ramming_vol_speed_nc = float(config.filament_ramming_volumetric_speed_nc.get_at(idx)); + if (config.filament_ramming_volumetric_speed_nc.is_nil(idx) || is_approx(config.filament_ramming_volumetric_speed_nc.get_at(idx), -1.)) + ramming_vol_speed_nc = max_vol_speed; + m_filpar[idx].max_e_ramming_speed.second = (ramming_vol_speed_nc / filament_area()); + } + { + // Precool target temp: .first = extruder change, .second = nozzle change (carousel) + // Precool is only active when enable_pre_heating is on; otherwise no precool temp/timing is + // applied and the downstream precool_t stays 0, matching printers with pre-heating disabled. + m_filpar[idx].precool_target_temp = {0, 0}; + if (config.enable_pre_heating.value) { + if (!config.filament_pre_cooling_temperature.is_nil(idx) && config.filament_pre_cooling_temperature.get_at(idx) != 0) + m_filpar[idx].precool_target_temp.first = config.filament_pre_cooling_temperature.get_at(idx); + if (!config.filament_pre_cooling_temperature_nc.is_nil(idx) && config.filament_pre_cooling_temperature_nc.get_at(idx) != 0) + m_filpar[idx].precool_target_temp.second = config.filament_pre_cooling_temperature_nc.get_at(idx); + } + } + { + // Precool timing: (nozzle_temp - precool_temp) / hotend_cooling_rate + int extruder_count = m_is_multi_extruder ? 2 : 1; // H2C = 2 extruders + float nozzle_temp = float(config.nozzle_temperature.is_nil(idx) ? 0 : config.nozzle_temperature.get_at(idx)); + float nozzle_temp_fl = float(config.nozzle_temperature_initial_layer.is_nil(idx) ? nozzle_temp : config.nozzle_temperature_initial_layer.get_at(idx)); + m_filpar[idx].precool_t.first.resize(extruder_count, 0.f); + m_filpar[idx].precool_t.second.resize(extruder_count, 0.f); + m_filpar[idx].precool_t_first_layer.first.resize(extruder_count, 0.f); + m_filpar[idx].precool_t_first_layer.second.resize(extruder_count, 0.f); + std::vector cooling_rates = config.hotend_cooling_rate.values; + for (int i = 0; i < extruder_count && i < (int)cooling_rates.size(); i++) { + if (cooling_rates[i] < EPSILON) continue; + if (m_filpar[idx].precool_target_temp.first != 0) { + m_filpar[idx].precool_t.first[i] = std::max(0.f, nozzle_temp - float(m_filpar[idx].precool_target_temp.first)) / float(cooling_rates[i]); + m_filpar[idx].precool_t_first_layer.first[i] = std::max(0.f, nozzle_temp_fl - float(m_filpar[idx].precool_target_temp.first)) / float(cooling_rates[i]); + } + if (m_filpar[idx].precool_target_temp.second != 0) { + m_filpar[idx].precool_t.second[i] = std::max(0.f, nozzle_temp - float(m_filpar[idx].precool_target_temp.second)) / float(cooling_rates[i]); + m_filpar[idx].precool_t_first_layer.second[i] = std::max(0.f, nozzle_temp_fl - float(m_filpar[idx].precool_target_temp.second)) / float(cooling_rates[i]); + } + } + } + { + // Ramming travel time: .first = extruder change, .second = nozzle change (carousel) + m_filpar[idx].ramming_travel_time = {0.f, 0.f}; + if (!config.filament_ramming_travel_time.is_nil(idx)) + m_filpar[idx].ramming_travel_time.first = float(config.filament_ramming_travel_time.get_at(idx)); + if (!config.filament_ramming_travel_time_nc.is_nil(idx)) + m_filpar[idx].ramming_travel_time.second = float(config.filament_ramming_travel_time_nc.get_at(idx)); + } + m_perimeter_width = nozzle_diameter * Width_To_Nozzle_Ratio; // all extruders are now assumed to have the same diameter m_nozzle_change_perimeter_width = 2*m_perimeter_width; // BBS: remove useless config @@ -1893,7 +1951,7 @@ WipeTower::NozzleChangeResult WipeTower::nozzle_change(int old_filament_id, int .set_initial_tool(m_current_tool) .set_extrusion_flow(m_extrusion_flow) .set_y_shift(m_y_shift + (new_filament_id != (unsigned int) (-1) && (m_current_shape == SHAPE_REVERSED) ? m_layer_info->depth - m_layer_info->toolchanges_depth() : 0.f)) - .append("; Nozzle change start\n"); + .append(format_nozzle_change_tag(true, old_filament_id, new_filament_id)); box_coordinates cleaning_box(Vec2f(m_perimeter_width, m_perimeter_width), m_wipe_tower_width - 2 * m_perimeter_width, (new_filament_id != (unsigned int) (-1) ? wipe_depth + m_depth_traversed - m_perimeter_width : m_wipe_tower_depth - m_perimeter_width)); @@ -1969,7 +2027,7 @@ WipeTower::NozzleChangeResult WipeTower::nozzle_change(int old_filament_id, int } } - writer.append("; Nozzle change end\n"); + writer.append(format_nozzle_change_tag(false, old_filament_id, new_filament_id)); result.start_pos = writer.start_pos_rotated(); result.end_pos = writer.pos(); @@ -2546,6 +2604,19 @@ void WipeTower::plan_toolchange(float z_par, float layer_height_par, unsigned in nozzle_change_depth = nozzle_change_line_count * m_nozzle_change_perimeter_width; depth += nozzle_change_depth; } + if (nozzle_change_depth == 0 + && !m_filament_nozzle_map.empty() + && old_tool < m_filament_nozzle_map.size() && new_tool < m_filament_nozzle_map.size() + && m_filament_nozzle_map[old_tool] != m_filament_nozzle_map[new_tool]) { + double e_flow = nozzle_change_extrusion_flow(layer_height_par); + double length = m_filaments_change_length[old_tool] / e_flow; + int nozzle_change_line_count = length / (m_wipe_tower_width - 2*m_nozzle_change_perimeter_width) + 1; + if (has_tpu_filament()) + nozzle_change_depth = m_tpu_fixed_spacing * nozzle_change_line_count * m_nozzle_change_perimeter_width; + else + nozzle_change_depth = nozzle_change_line_count * m_nozzle_change_perimeter_width; + depth += nozzle_change_depth; + } WipeTowerInfo::ToolChange tool_change = WipeTowerInfo::ToolChange(old_tool, new_tool, depth, 0.f, 0.f, wipe_volume, length_to_extrude, purge_volume); tool_change.nozzle_change_depth = nozzle_change_depth; m_plan.back().tool_changes.push_back(tool_change); @@ -2695,6 +2766,13 @@ bool WipeTower::is_petg_filament(int filament_id) const return m_filpar[filament_id].material == "PETG"; } +bool WipeTower::is_need_reverse_travel(int filament_id, bool extruder_change) const +{ + if (extruder_change) + return m_filpar[filament_id].ramming_travel_time.first > EPSILON; + return m_filpar[filament_id].ramming_travel_time.second > EPSILON; +} + // BBS: consider both soluable and support properties // Return index of first toolchange that switches to non-soluble and non-support extruder // ot -1 if there is no such toolchange. @@ -2819,6 +2897,13 @@ WipeTower::ToolChangeResult WipeTower::tool_change_new(size_t new_tool, bool sol && is_valid_last_layer(m_current_tool, m_cur_layer_id, m_z_pos)) { m_nozzle_change_result = nozzle_change_new(m_current_tool, new_tool, solid_nozzlechange); } + if (m_nozzle_change_result.gcode.empty() + && !m_filament_nozzle_map.empty() + && m_current_tool < m_filament_nozzle_map.size() && new_tool < m_filament_nozzle_map.size() + && m_filament_nozzle_map[m_current_tool] != m_filament_nozzle_map[new_tool] + && is_valid_last_layer(m_current_tool, m_cur_layer_id, m_z_pos)) { + m_nozzle_change_result = nozzle_change_new(m_current_tool, new_tool, solid_nozzlechange); + } size_t old_tool = m_current_tool; float wipe_depth = 0.f; @@ -2983,20 +3068,40 @@ WipeTower::NozzleChangeResult WipeTower::nozzle_change_new(int old_filament_id, } float nz_extrusion_flow = nozzle_change_extrusion_flow(m_layer_height); - float nozzle_change_speed = 60.0f * m_filpar[m_current_tool].max_e_speed / nz_extrusion_flow; - nozzle_change_speed = solid_infill ? 40.f * 60.f : nozzle_change_speed;//If the contact layers belong to different categories, then reduce the speed. + bool extruder_change = !is_in_same_extruder(old_filament_id, new_filament_id); + float max_e_ramming = extruder_change + ? m_filpar[m_current_tool].max_e_ramming_speed.first + : m_filpar[m_current_tool].max_e_ramming_speed.second; + if (max_e_ramming < EPSILON) max_e_ramming = m_filpar[m_current_tool].max_e_speed; // fallback + float nozzle_change_speed = 60.0f * max_e_ramming / nz_extrusion_flow; + nozzle_change_speed = solid_infill ? 40.f * 60.f : nozzle_change_speed; if (is_tpu_filament(m_current_tool)) { nozzle_change_speed *= 0.25; } - float bridge_speed = std::min(60.0f * m_filpar[m_current_tool].max_e_speed / nozzle_change_extrusion_flow(0.2), nozzle_change_speed); // limit the bridge speed by add flow + float bridge_speed = std::min(60.0f * max_e_ramming / nozzle_change_extrusion_flow(0.2), nozzle_change_speed); WipeTowerWriter writer(m_layer_height, m_nozzle_change_perimeter_width, m_gcode_flavor, m_filpar); writer.set_extrusion_flow(nz_extrusion_flow) .set_z(m_z_pos) .set_initial_tool(m_current_tool) .set_y_shift(m_y_shift + (new_filament_id != (unsigned int) (-1) && (m_current_shape == SHAPE_REVERSED) ? m_layer_info->depth - m_layer_info->toolchanges_depth() : 0.f)) - .append("; Nozzle change start\n"); + .append(format_nozzle_change_tag(true, old_filament_id, new_filament_id)); + + if (!extruder_change && m_is_multiple_nozzle) { + writer.append("M632 S" + std::to_string(new_filament_id) + " M N\n"); + // Use m_physical_extruder_map for heater index (matches format_line_M104 in add_M104_by_requirement) + if (m_filpar[m_current_tool].precool_target_temp.second != 0) { + int logical_ext = m_filament_map.empty() ? 0 : m_filament_map[m_current_tool] - 1; + int phys_ext = (logical_ext >= 0 && logical_ext < (int)m_physical_extruder_map.size()) + ? m_physical_extruder_map[logical_ext] : logical_ext; + writer.append("M400\n"); + writer.append("M104 T" + std::to_string(phys_ext) + " S" + + std::to_string(m_filpar[m_current_tool].precool_target_temp.second) + " N0\n"); + writer.append("M106 S255\n"); + } + writer.append("M633\n"); + } WipeTowerBlock* block = get_block_by_category(m_filpar[old_filament_id].category, false); if (!block) { @@ -3021,6 +3126,23 @@ WipeTower::NozzleChangeResult WipeTower::nozzle_change_new(int old_filament_id, dy = solid_infill ? m_nozzle_change_perimeter_width : dy; nozzle_change_line_count = solid_infill ? std::numeric_limits::max() : nozzle_change_line_count; m_left_to_right = true; + + if (extruder_change) { + float ramming_length = nozzle_change_line_count * (xr - xl); + int extruder_id = m_filament_map.empty() ? 0 : m_filament_map[m_current_tool] - 1; + float precool_t = (extruder_id >= 0 && extruder_id < (int)m_filpar[m_current_tool].precool_t.first.size()) + ? m_filpar[m_current_tool].precool_t.first[extruder_id] : 0.f; + float precool_t_fl = (extruder_id >= 0 && extruder_id < (int)m_filpar[m_current_tool].precool_t_first_layer.first.size()) + ? m_filpar[m_current_tool].precool_t_first_layer.first[extruder_id] : 0.f; + float per_cooling_max_speed = nozzle_change_speed; + if (is_first_layer() && precool_t_fl > EPSILON) + per_cooling_max_speed = ramming_length / precool_t_fl * 60.f; + else if (precool_t > EPSILON) + per_cooling_max_speed = ramming_length / precool_t * 60.f; + if (nozzle_change_speed > per_cooling_max_speed) nozzle_change_speed = per_cooling_max_speed; + if (bridge_speed > per_cooling_max_speed) bridge_speed = per_cooling_max_speed; + } + int real_nozzle_change_line_count = 0; bool need_change_flow = false; for (int i = 0; true; ++i) { @@ -3053,9 +3175,40 @@ WipeTower::NozzleChangeResult WipeTower::nozzle_change_new(int old_filament_id, block->last_nozzle_change_id = old_filament_id; NozzleChangeResult result; - if (is_tpu_filament(m_current_tool)) { + if (!extruder_change && m_is_multiple_nozzle) { + writer.append("M632 S" + std::to_string(new_filament_id) + " M N\n"); + } + + if (is_need_reverse_travel(m_current_tool, extruder_change)) { bool left_to_right = !m_left_to_right; - int tpu_line_count = (real_nozzle_change_line_count + 2 - 1) / 2; // nozzle_change_line_count / 2 round up + int tpu_line_count = real_nozzle_change_line_count; + float reverse_speed = nozzle_change_speed * 2; // reverse travel runs at double the nozzle-change speed + float rt_time = extruder_change ? m_filpar[m_current_tool].ramming_travel_time.first + : m_filpar[m_current_tool].ramming_travel_time.second; + float need_reverse_travel_dis = rt_time * reverse_speed / 60.f; + float real_travel_dis = tpu_line_count * (xr - xl - 2 * m_perimeter_width); + if (real_travel_dis < need_reverse_travel_dis) + reverse_speed *= real_travel_dis / need_reverse_travel_dis; + writer.travel(writer.x(), writer.y() + dy/2); + + for (int i = 0; true; ++i) { + need_reverse_travel_dis -= (xr - xl - 2 * m_perimeter_width); + float offset_dis = 0.f; + if (need_reverse_travel_dis < 0) + offset_dis = -need_reverse_travel_dis; + if (left_to_right) + writer.travel(xr - m_perimeter_width - offset_dis, writer.y(), reverse_speed); + else + writer.travel(xl + m_perimeter_width + offset_dis, writer.y(), reverse_speed); + if (need_reverse_travel_dis < EPSILON) break; + if (i == tpu_line_count - 1) + break; + writer.travel(writer.x(), writer.y() - dy); + left_to_right = !left_to_right; + } + } else if (is_tpu_filament(m_current_tool)) { + bool left_to_right = !m_left_to_right; + int tpu_line_count = (real_nozzle_change_line_count + 2 - 1) / 2; nozzle_change_speed *= 2; writer.travel(writer.x(), writer.y() - m_nozzle_change_perimeter_width); @@ -3080,12 +3233,15 @@ WipeTower::NozzleChangeResult WipeTower::nozzle_change_new(int old_filament_id, } } - writer.append("; Nozzle change end\n"); + if (!extruder_change && m_is_multiple_nozzle) writer.append("M633\n"); + + writer.append(format_nozzle_change_tag(false, old_filament_id, new_filament_id)); result.start_pos = writer.start_pos_rotated(); result.origin_start_pos = initial_position; result.end_pos = writer.pos_rotated(); result.gcode = writer.gcode(); + result.is_extruder_change = extruder_change; return result; } @@ -3506,29 +3662,26 @@ void WipeTower::toolchange_wipe_new(WipeTowerWriter &writer, const box_coordinat // Emit the arriving-hotend pre-heat inside the M632/M633 nozzle-change barrier. `M632 S[ H] // M N` opens the barrier (M = firmware nozzle-change flag, N = slicer generated), the M104 sets the // arriving hotend temp, and `M633` closes it. H2C's grouping is static (no dynamic nozzle map), so the - // H field is omitted (a dynamic nozzle map would supply a real nozzle id, a static map -1 => no + // H field is omitted (a dynamic nozzle map would supply a real nozzle id, a static map -1 =>no // H). The counterproductive fan-on (M106 S255) used for departing-tool cooldown is intentionally // omitted, since this is a pre-HEAT of the arriving tool. The whole helper is only ever called from // add_M104_by_requirement, which is gated on m_is_multiple_nozzle (extruder_max_nozzle_count>1) => H2C - // only; every other printer's wipe tower is untouched. The M632 M-flag is itself a firmware barrier, so - // a preceding M400 wait is subsumed. + // only; every other printer's wipe tower is untouched. + // BBS: extruder change preheat uses M400 + M104 WITHOUT M632/M633 barrier. + // M632 barriers are only for carousel nozzle changes (emitted in nozzle_change_new/ramming). auto format_line_M104 = [this](int target_temp, int target_extruder = -1, bool wait_for_moves = true, const std::string &comment = "") { std::string buffer; - buffer += "M632 S" + std::to_string(m_current_tool) + " M N\n"; + if (wait_for_moves) + buffer += "M400\n"; buffer += "M104"; if (target_extruder != -1 && target_extruder < (int) m_physical_extruder_map.size()) buffer += (" T" + std::to_string(m_physical_extruder_map[target_extruder])); buffer += " S" + std::to_string(target_temp) + " N0"; // N0 means the gcode is generated by the slicer if (!comment.empty()) buffer += " ;" + comment; buffer += '\n'; - buffer += "M633\n"; - (void) wait_for_moves; // the M632 M-flag barrier replaces the former M400 wait return buffer; }; - // Suppress the pre-heat M104 on the first layer and on solid (contact) toolchanges (should_heating). - // m_is_multiple_nozzle folds in the H2C gate so single-nozzle output is untouched. - // Orca: the arriving extruder id is resolved as m_filament_map[tool]-1 (layer-static) because Orca's - // wipe tower is extruder-level rather than tracking a per-layer nozzle map. + // m_is_multiple_nozzle gate needed because Orca calls toolchange_wipe_new for ALL printers (BBS has it H2C-only). bool should_heating = m_is_multiple_nozzle && m_filpar[m_current_tool].filament_cooling_before_tower > EPSILON && !solid_tool_toolchange && !is_first_layer(); auto add_M104_by_requirement = [&writer, &format_line_M104, &should_heating, this]() { @@ -3710,6 +3863,18 @@ bool WipeTower::is_in_same_extruder(int filament_id_1, int filament_id_2) return m_filament_map[filament_id_1] == m_filament_map[filament_id_2]; } +std::string WipeTower::format_nozzle_change_tag(bool start, int old_filament_id, int new_filament_id) const +{ + const std::string &tag = start ? GCodeProcessor::Nozzle_Change_Start_Tag : GCodeProcessor::Nozzle_Change_End_Tag; + int old_nozzle = (old_filament_id >= 0 && old_filament_id < (int)m_filament_nozzle_map.size()) + ? m_filament_nozzle_map[old_filament_id] : -1; + int new_nozzle = (new_filament_id >= 0 && new_filament_id < (int)m_filament_nozzle_map.size()) + ? m_filament_nozzle_map[new_filament_id] : -1; + char buff[96]; + snprintf(buff, sizeof(buff), ";%s OF%d NF%d ON%d NN%d\n", tag.c_str(), old_filament_id, new_filament_id, old_nozzle, new_nozzle); + return std::string(buff); +} + // Per-extruder printable-height clamp: is an extruder still allowed to print on this wipe-tower layer, // or is it its final layer above the extruder's printable height? // Orca: the arriving extruder id is resolved as m_filament_map[tool]-1 (1-based map, layer-static), @@ -3908,6 +4073,19 @@ void WipeTower::plan_tower_new() nozzle_change_depth = nozzle_change_line_count * m_nozzle_change_perimeter_width; depth += nozzle_change_depth; } + if (nozzle_change_depth == 0 + && !m_filament_nozzle_map.empty() + && toolchange.old_tool < (int)m_filament_nozzle_map.size() && toolchange.new_tool < (int)m_filament_nozzle_map.size() + && m_filament_nozzle_map[toolchange.old_tool] != m_filament_nozzle_map[toolchange.new_tool]) { + double e_flow = nozzle_change_extrusion_flow(m_plan[idx].height); + double length = m_filaments_change_length[toolchange.old_tool] / e_flow; + int nozzle_change_line_count = length / (m_wipe_tower_width - 2*m_nozzle_change_perimeter_width) + 1; + if (has_tpu_filament()) + nozzle_change_depth = m_tpu_fixed_spacing * nozzle_change_line_count * m_nozzle_change_perimeter_width; + else + nozzle_change_depth = nozzle_change_line_count * m_nozzle_change_perimeter_width; + depth += nozzle_change_depth; + } toolchange.nozzle_change_depth = nozzle_change_depth; toolchange.required_depth = depth; } diff --git a/src/libslic3r/GCode/WipeTower.hpp b/src/libslic3r/GCode/WipeTower.hpp index 4430a3218e..508765824e 100644 --- a/src/libslic3r/GCode/WipeTower.hpp +++ b/src/libslic3r/GCode/WipeTower.hpp @@ -58,6 +58,7 @@ public: Vec2f origin_start_pos; // not rotated std::vector wipe_path; + bool is_extruder_change{true}; }; struct ToolChangeResult @@ -309,6 +310,8 @@ public: int get_number_of_toolchanges() const { return m_num_tool_changes; } void set_filament_map(const std::vector &filament_map) { m_filament_map = filament_map; } + // Vortek H2C: filament_id → physical nozzle_id for carousel rotation detection + void set_filament_nozzle_map(const std::vector &nozzle_map) { m_filament_nozzle_map = nozzle_map; } void set_has_tpu_filament(bool has_tpu) { m_has_tpu_filament = has_tpu; } bool has_tpu_filament() const { return m_has_tpu_filament; } @@ -356,6 +359,12 @@ public: // Distance (in mm of filament) that a hotend is allowed to pre-cool before the // tower is reached; drives the prime-tower heating-during-wipe model (multi-nozzle only). float filament_cooling_before_tower = 0.f; + // .first = extruder change, .second = nozzle change (carousel) + std::pair max_e_ramming_speed{0.f, 0.f}; + std::pair ramming_travel_time{0.f, 0.f}; + std::pair precool_target_temp{0, 0}; + std::pair,std::vector> precool_t; + std::pair,std::vector> precool_t_first_layer; }; @@ -395,6 +404,8 @@ public: void add_depth_to_block(int filament_id, int filament_adhesiveness_category, float depth, bool is_nozzle_change = false); int get_filament_category(int filament_id); bool is_in_same_extruder(int filament_id_1, int filament_id_2); + // Vortek H2C: format BBS-compatible NOZZLE_CHANGE_START/END tag with OF/NF/ON/NN payload + std::string format_nozzle_change_tag(bool start, int old_filament_id, int new_filament_id) const; void reset_block_status(); int get_wall_filament_for_all_layer(); // for generate new wipe tower @@ -453,6 +464,7 @@ private: size_t m_cur_layer_id; NozzleChangeResult m_nozzle_change_result; std::vector m_filament_map; + std::vector m_filament_nozzle_map; // Vortek H2C: filament_id → physical nozzle_id bool m_has_tpu_filament{false}; bool m_is_multi_extruder{false}; bool m_use_gap_wall{false}; @@ -555,6 +567,7 @@ private: bool is_tpu_filament(int filament_id) const; bool is_petg_filament(int filament_id) const; + bool is_need_reverse_travel(int filament_id, bool extruder_change) const; // BBS box_coordinates align_perimeter(const box_coordinates& perimeter_box); diff --git a/src/libslic3r/GCodeWriter.cpp b/src/libslic3r/GCodeWriter.cpp index 26db1c61e5..e3d1c30362 100644 --- a/src/libslic3r/GCodeWriter.cpp +++ b/src/libslic3r/GCodeWriter.cpp @@ -2,6 +2,8 @@ #include "CustomGCode.hpp" #include "I18N.hpp" #include "PrintConfig.hpp" +#include "ClipperUtils.hpp" +#include "Line.hpp" #include #include #include @@ -99,9 +101,87 @@ void GCodeWriter::apply_print_config(const PrintConfig &print_config) m_max_jerk_z = LIMITS(machine_max_jerk_z); m_max_jerk_e = LIMITS(machine_max_jerk_e); m_resolution = print_config.resolution.value; - #undef LIMITS #undef LIMITS_UINT + // Orca: capture the printable area(s) so a spiral lift can be skipped when its + // circle would leave the boundary and collide with the print limits. Full polygons + // are stored (not a bounding box) so the check stays correct for non-rectangular + // beds, and per-extruder areas are kept so printers with different boundaries per + // extruder use the right limit for whichever extruder is active. + auto to_scaled_polygon = [](const Pointfs &pts) { + Polygon poly; + poly.points.reserve(pts.size()); + for (const Vec2d &p : pts) + poly.points.emplace_back(coord_t(scale_(p.x())), coord_t(scale_(p.y()))); + poly.make_counter_clockwise(); + return poly; + }; + + m_bed_printable_area.points.clear(); + m_extruder_printable_areas.clear(); + + if (print_config.printable_area.values.size() >= 3) + m_bed_printable_area = to_scaled_polygon(print_config.printable_area.values); + + const std::vector &extruder_areas = print_config.extruder_printable_area.values; + if (!extruder_areas.empty()) { + m_extruder_printable_areas.resize(extruder_areas.size()); + for (size_t i = 0; i < extruder_areas.size(); ++i) { + if (extruder_areas[i].size() < 3) { + // No dedicated area for this extruder: it can reach the whole bed. + m_extruder_printable_areas[i] = m_bed_printable_area; + continue; + } + Polygon extruder_poly = to_scaled_polygon(extruder_areas[i]); + if (m_bed_printable_area.points.size() < 3) { + m_extruder_printable_areas[i] = std::move(extruder_poly); + continue; + } + // The reachable area is the extruder area clipped to the bed. Bed shapes are + // convex in practice, so keep the largest resulting contour. + Polygons clipped = intersection(extruder_poly, m_bed_printable_area); + const Polygon *largest = nullptr; + double best_area = 0.; + for (const Polygon &p : clipped) { + double a = std::abs(p.area()); + if (a > best_area) { best_area = a; largest = &p; } + } + m_extruder_printable_areas[i] = largest ? *largest : std::move(extruder_poly); + } + } +} + +const Polygon *GCodeWriter::active_printable_area() const +{ + if (const Extruder *e = this->filament()) { + size_t id = e->extruder_id(); + if (id < m_extruder_printable_areas.size() && m_extruder_printable_areas[id].points.size() >= 3) + return &m_extruder_printable_areas[id]; + } + if (m_bed_printable_area.points.size() >= 3) + return &m_bed_printable_area; + return nullptr; +} + +bool GCodeWriter::spiral_lift_fits_printable_area(const Vec2d ¢er, double radius) const +{ + const Polygon *area = this->active_printable_area(); + if (area == nullptr) + return true; // Boundary unknown: don't restrict (preserve previous behavior). + + const Point c = Point::new_scale(center.x(), center.y()); + const double r_scaled = scale_(radius); + const double r2 = r_scaled * r_scaled; + + // The spiral traces a full circle of `radius` around `center`, so the center must lie + // inside the printable area and every edge must be at least `radius` away from it. + if (!area->contains(c)) + return false; + const Points &pts = area->points; + for (size_t i = 0, n = pts.size(); i < n; ++i) + if (Line::distance_to_squared(c, pts[i], pts[(i + 1) % n]) < r2) + return false; + return true; } void GCodeWriter::set_extruders(std::vector extruder_ids) @@ -731,14 +811,19 @@ std::string GCodeWriter::eager_lift(const LiftType type) { } // BBS: spiral lift only safe with known position - // TODO: check the arc will move within bed area if (type == LiftType::SpiralLift && this->is_current_position_clear()) { double radius = target_lift / (2 * PI * atan(filament()->travel_slope())); // static spiral alignment when no move in x,y plane. - // spiral centra is a radius distance to the right (y=0) + // spiral centra is a radius distance to the right (y=0) Vec2d ij_offset = { radius, 0 }; - if (target_lift > 0) { + // Orca: keep the spiral inside the active extruder's printable area, otherwise + // fall back to a normal lift to avoid colliding with the print boundary. m_pos + // includes the plate offset, so remove it to match the printable area coordinates. + const Vec2d spiral_center = { m_pos.x() - m_x_offset + ij_offset.x(), m_pos.y() - m_y_offset + ij_offset.y() }; + if (target_lift > 0 && this->spiral_lift_fits_printable_area(spiral_center, radius)) { lift_move = this->_spiral_travel_to_z(m_pos(2) + target_lift, ij_offset, "spiral lift Z"); + } else if (target_lift > 0) { + lift_move = _travel_to_z(m_pos(2) + target_lift, "normal lift Z"); } } //BBS: if position is unknown use normal lift @@ -793,7 +878,15 @@ std::string GCodeWriter::travel_to_xyz(const Vec3d &point, const std::string &co double radius = delta(2) / (2 * PI * atan(this->filament()->travel_slope())); Vec2d ij_offset = radius * delta_no_z.normalized(); ij_offset = { -ij_offset(1), ij_offset(0) }; - slop_move = this->_spiral_travel_to_z(target(2), ij_offset, "spiral lift Z"); + // Orca: only perform the spiral lift if its full circle stays inside the + // printable area of the active extruder, otherwise fall back to a normal + // lift to avoid colliding with the print boundary. `source` is already in + // bed coordinates (plate offset removed), matching the printable area. + const Vec2d spiral_center = { source.x() + ij_offset.x(), source.y() + ij_offset.y() }; + if (this->spiral_lift_fits_printable_area(spiral_center, radius)) + slop_move = this->_spiral_travel_to_z(target(2), ij_offset, "spiral lift Z"); + else + slop_move = _travel_to_z(target.z(), "normal lift Z"); } //BBS: SlopeLift else if (m_to_lift_type == LiftType::SlopeLift && diff --git a/src/libslic3r/GCodeWriter.hpp b/src/libslic3r/GCodeWriter.hpp index 2e7ea2176c..e904f3f35b 100644 --- a/src/libslic3r/GCodeWriter.hpp +++ b/src/libslic3r/GCodeWriter.hpp @@ -6,6 +6,7 @@ #include #include "Extruder.hpp" #include "Point.hpp" +#include "Polygon.hpp" #include "PrintConfig.hpp" #include "GCode/CoolingBuffer.hpp" @@ -181,6 +182,14 @@ public: // Orca: slicing resolution in mm double m_resolution = 0.01; + // Orca: printable area polygons (scaled, bed coordinates) used to keep spiral lifts + // from colliding with the print boundary. m_extruder_printable_areas holds the + // per-extruder reachable area (intersected with the bed) when a printer defines + // different boundaries per extruder; m_bed_printable_area is the global fallback. + // Storing full polygons (rather than a bounding box) keeps the check correct for + // non-rectangular beds such as delta/circular printers. + Polygon m_bed_printable_area; + std::vector m_extruder_printable_areas; std::string m_gcode_label_objects_start; std::string m_gcode_label_objects_end; @@ -197,6 +206,10 @@ public: std::string _travel_to_z(double z, const std::string &comment); std::string _spiral_travel_to_z(double z, const Vec2d &ij_offset, const std::string &comment); + // Orca: printable area of the active extruder (per-extruder when configured, otherwise the bed). Null when unknown. + const Polygon *active_printable_area() const; + // Orca: true if a full spiral-lift circle (center in bed coordinates, mm) fits inside the active printable area. + bool spiral_lift_fits_printable_area(const Vec2d ¢er, double radius) const; std::string _retract(double length, double restart_extra, const std::string &comment); std::string set_acceleration_internal(Acceleration type, unsigned int acceleration); diff --git a/src/libslic3r/Preset.cpp b/src/libslic3r/Preset.cpp index 0e9dd58593..ed712ef6a9 100644 --- a/src/libslic3r/Preset.cpp +++ b/src/libslic3r/Preset.cpp @@ -1376,7 +1376,7 @@ static std::vector s_Preset_filament_options {/*"filament_colour", "filament_ramming_travel_time", "filament_ramming_travel_time_nc", "filament_pre_cooling_temperature", "filament_pre_cooling_temperature_nc", "filament_preheat_temperature_delta", "filament_retract_length_nc", - "filament_change_length_nc", "filament_prime_volume_nc", + "filament_change_length_nc", "filament_prime_volume", "filament_prime_volume_nc", "long_retractions_when_ec", "retraction_distances_when_ec", "plugin_config_overrides", //ams chamber diff --git a/src/libslic3r/Print.cpp b/src/libslic3r/Print.cpp index 7c6290634b..808bc590be 100644 --- a/src/libslic3r/Print.cpp +++ b/src/libslic3r/Print.cpp @@ -3605,7 +3605,8 @@ std::vector> Print::get_physical_unprintable_filaments(const std:: return physical_unprintables; auto get_unprintable_extruder_id = [&](unsigned int filament_idx) -> int { - int status = m_config.filament_printable.values[filament_idx]; + // filament_printable may be shorter than the filament count; get_at() clamps. + int status = m_config.filament_printable.get_at(filament_idx); for (int i = 0; i < extruder_num; ++i) { if (!(status >> i & 1)) { return i; @@ -4017,6 +4018,8 @@ void Print::_make_wipe_tower() m_wipe_tower_data.tool_ordering.empty() ? 0.f : m_wipe_tower_data.tool_ordering.back().print_z, m_wipe_tower_data.tool_ordering.all_extruders()); wipe_tower.set_has_tpu_filament(this->has_tpu_filament()); wipe_tower.set_filament_map(this->get_filament_maps()); + // Vortek H2C: pass nozzle-level map for carousel rotation detection in tool_change_new() + wipe_tower.set_filament_nozzle_map(this->get_filament_nozzle_maps()); // Feed the has_filament_switcher device flag (develop-only dynamic key, read defensively from // the full config — no shipping profile sets it) and the shared printable bed used by the PETG // pre-extrusion offset clamp. Both are inert unless has_filament_switcher is set. @@ -4052,14 +4055,32 @@ void Print::_make_wipe_tower() multi_extruder_flush.emplace_back(wipe_volumes); } - std::vectorfilament_maps = get_filament_maps(); + // Use NozzleStatusRecorder for per-carousel-slot tracking (BBS pattern). + // The original Orca code tracked per-extruder (2 slots), which collapsed all + // carousel filaments into one slot and caused massive redundant AMS flushing. + auto group_result = get_layered_nozzle_group_result(); + MultiNozzleUtils::NozzleStatusRecorder nozzle_recorder; + // Fallback (group_result == null) per-physical-nozzle tracking, matching the original + // pre-port behavior: remembers the last filament loaded in each physical nozzle slot. + std::vector nozzle_cur_filament_ids(nozzle_nums, (unsigned int) -1); + + std::vectorfilament_maps = get_filament_maps(); + int layer_idx = -1; - std::vector nozzle_cur_filament_ids(nozzle_nums, -1); unsigned int current_filament_id = m_wipe_tower_data.tool_ordering.first_extruder(); - size_t cur_nozzle_id = filament_maps[current_filament_id] - 1; - nozzle_cur_filament_ids[cur_nozzle_id] = current_filament_id; + // Initialize NozzleStatusRecorder with the first filament's carousel slot + if (group_result) { + auto nozzle = group_result->get_nozzle_for_filament(current_filament_id, layer_idx); + if (nozzle) + nozzle_recorder.set_nozzle_status(nozzle->group_id, current_filament_id, nozzle->extruder_id); + } else { + size_t cur_nozzle_id = filament_maps[current_filament_id] - 1; + nozzle_cur_filament_ids[cur_nozzle_id] = current_filament_id; + } for (auto& layer_tools : m_wipe_tower_data.tool_ordering.layer_tools()) { // for all layers + ++layer_idx; + if (!layer_tools.has_wipe_tower) continue; bool first_layer = &layer_tools == &m_wipe_tower_data.tool_ordering.front(); wipe_tower.plan_toolchange((float)layer_tools.print_z, (float)layer_tools.wipe_tower_layer_height, current_filament_id, current_filament_id); @@ -4070,30 +4091,76 @@ void Print::_make_wipe_tower() if (filament_id == current_filament_id) continue; - int nozzle_id = filament_maps[filament_id] - 1; - unsigned int pre_filament_id = nozzle_cur_filament_ids[nozzle_id]; - float volume_to_purge = 0; - if (pre_filament_id != (unsigned int)(-1) && pre_filament_id != filament_id) { - volume_to_purge = multi_extruder_flush[nozzle_id][pre_filament_id][filament_id]; - // Fast purge mode uses flush_multiplier_fast; Default is inert. - float flush_multiplier = (m_config.prime_volume_mode == PrimeVolumeMode::pvmFast) ? m_config.flush_multiplier_fast.get_at(nozzle_id) - : m_config.flush_multiplier.get_at(nozzle_id); - volume_to_purge *= flush_multiplier; - volume_to_purge = pre_filament_id == -1 ? 0 : - layer_tools.wiping_extrusions().mark_wiping_extrusions(*this, current_filament_id, filament_id, volume_to_purge); + + // Per-carousel-slot purge tracking via NozzleStatusRecorder + if (group_result) { + auto nozzle_info = group_result->get_nozzle_for_filament(filament_id, layer_idx); + if (nozzle_info) { + int extruder_id = nozzle_info->extruder_id; + int nozzle_id = nozzle_info->group_id; + int prev_nozzle_filament = nozzle_recorder.get_filament_in_nozzle(nozzle_id); + + if (!nozzle_recorder.is_nozzle_empty(nozzle_id) && + static_cast(filament_id) != prev_nozzle_filament) { + volume_to_purge = multi_extruder_flush[extruder_id][prev_nozzle_filament][filament_id]; + // Fast purge mode uses flush_multiplier_fast; Default is inert. + float flush_multiplier = (m_config.prime_volume_mode == PrimeVolumeMode::pvmFast) + ? m_config.flush_multiplier_fast.get_at(extruder_id) + : m_config.flush_multiplier.get_at(extruder_id); + volume_to_purge *= flush_multiplier; + volume_to_purge = layer_tools.wiping_extrusions().mark_wiping_extrusions( + *this, current_filament_id, filament_id, volume_to_purge); + } + nozzle_recorder.set_nozzle_status(nozzle_id, filament_id, extruder_id); + } + } else { + // Fallback: original Orca per-physical-nozzle path (non-carousel printers). + // Flush source is the last filament that occupied THIS nozzle, guarded so the + // first use of a nozzle incurs no flush. + int nozzle_id = filament_maps[filament_id] - 1; + unsigned int pre_filament_id = nozzle_cur_filament_ids[nozzle_id]; + if (pre_filament_id != (unsigned int) -1 && pre_filament_id != filament_id) { + volume_to_purge = multi_extruder_flush[nozzle_id][pre_filament_id][filament_id]; + float flush_multiplier = (m_config.prime_volume_mode == PrimeVolumeMode::pvmFast) + ? m_config.flush_multiplier_fast.get_at(nozzle_id) + : m_config.flush_multiplier.get_at(nozzle_id); + volume_to_purge *= flush_multiplier; + volume_to_purge = layer_tools.wiping_extrusions().mark_wiping_extrusions( + *this, current_filament_id, filament_id, volume_to_purge); + } + nozzle_cur_filament_ids[nozzle_id] = filament_id; } //During the filament change, the extruder will extrude an extra length of grab_length for the corresponding detection, so the purge can reduce this length. - float grab_purge_volume = m_config.grab_length.get_at(nozzle_id) * 2.4; //(diameter/2)^2*PI=2.4 + int grab_extruder_id = filament_maps[filament_id] - 1; + float grab_purge_volume = m_config.grab_length.get_at(grab_extruder_id) * 2.4; //(diameter/2)^2*PI=2.4 volume_to_purge = std::max(0.f, volume_to_purge - grab_purge_volume); - // Saving mode reduces the prime volume to 15 mm3; Default is inert. - float prime_volume = (m_config.prime_volume_mode == PrimeVolumeMode::pvmSaving) ? 15.f : (float) m_config.prime_volume; + // Select prime volume per-filament: nozzle change (carousel rotation) uses + // filament_prime_volume_nc, filament change (same nozzle slot) uses filament_prime_volume. + float wipe_volume_ec = filament_id < m_config.filament_prime_volume.values.size() + ? m_config.filament_prime_volume.values[filament_id] + : (float) m_config.prime_volume; + float wipe_volume_nc = filament_id < m_config.filament_prime_volume_nc.values.size() + ? m_config.filament_prime_volume_nc.values[filament_id] + : (float) m_config.prime_volume; + + float prime_volume = wipe_volume_ec; + if (group_result) { + bool is_nozzle_change = group_result->are_filaments_same_extruder(current_filament_id, filament_id, layer_idx) && + !group_result->are_filaments_same_nozzle(current_filament_id, filament_id, layer_idx); + if (is_nozzle_change) { + prime_volume = wipe_volume_nc; + } + } + if (m_config.prime_volume_mode == PrimeVolumeMode::pvmSaving) { + prime_volume = 15.f; + } + wipe_tower.plan_toolchange((float)layer_tools.print_z, (float)layer_tools.wipe_tower_layer_height, current_filament_id, filament_id, prime_volume, volume_to_purge); current_filament_id = filament_id; - nozzle_cur_filament_ids[nozzle_id] = filament_id; } layer_tools.wiping_extrusions().ensure_perimeters_infills_order(*this); diff --git a/src/libslic3r/PrintConfig.cpp b/src/libslic3r/PrintConfig.cpp index 2dcbb5b259..a9860cbbed 100644 --- a/src/libslic3r/PrintConfig.cpp +++ b/src/libslic3r/PrintConfig.cpp @@ -7384,7 +7384,7 @@ void PrintConfigDef::init_fff_params() def->tooltip = L("The flush multiplier used in fast purge mode."); def->set_default_value(new ConfigOptionFloats{1.2}); - // BBS + // Orca: used by the generic (Type2) wipe tower; also the fallback for filament_prime_volume on Type1. def = this->add("prime_volume", coFloat); def->label = L("Prime volume"); def->tooltip = L("This is the volume of material to prime the extruder with on the tower."); @@ -8030,6 +8030,16 @@ void PrintConfigDef::init_fff_params() def->mode = comDevelop; def->set_default_value(new ConfigOptionBool(false)); + // Used by the Type1 wipe tower: filament_prime_volume on a filament change, + // filament_prime_volume_nc on a hotend/nozzle change. Type2 uses prime_volume instead. + def = this->add("filament_prime_volume", coFloats); + def->label = L("Filament change"); + def->tooltip = L("The volume of material required to prime the extruder on the tower, excluding a hotend change."); + def->sidetext = L("mm³"); + def->min = 1.0; + def->mode = comSimple; + def->set_default_value(new ConfigOptionFloats{45.}); + def = this->add("filament_prime_volume_nc", coFloats); def->label = L("Hotend change"); def->tooltip = L("The volume of material required to prime the extruder for a hotend change on the tower."); @@ -9050,7 +9060,7 @@ void PrintConfigDef::handle_legacy(t_config_option_key &opt_key, std::string &va "retraction_distance_when_cut", "internal_bridge_support_thickness", "top_area_threshold", "reduce_wall_solid_infill","filament_load_time","filament_unload_time", "smooth_coefficient", "overhang_totally_speed", "silent_mode", - "overhang_speed_classic", "filament_prime_volume", + "overhang_speed_classic", "anisotropic_surfaces", // superseded by top_surface_fill_order / bottom_surface_fill_order }; diff --git a/src/libslic3r/PrintConfig.hpp b/src/libslic3r/PrintConfig.hpp index c830158700..73811e3ecb 100644 --- a/src/libslic3r/PrintConfig.hpp +++ b/src/libslic3r/PrintConfig.hpp @@ -1841,6 +1841,7 @@ PRINT_CONFIG_CLASS_DERIVED_DEFINE( // BBS: wipe tower is only used for priming ((ConfigOptionFloat, prime_volume)) // Nozzle-change (nc) prime volume + pre-heat delta + ((ConfigOptionFloats, filament_prime_volume)) ((ConfigOptionFloats, filament_prime_volume_nc)) ((ConfigOptionFloatsNullable, filament_preheat_temperature_delta)) ((ConfigOptionFloats, flush_multiplier)) diff --git a/src/libslic3r/calib.cpp b/src/libslic3r/calib.cpp index f5490138ed..10fa9653eb 100644 --- a/src/libslic3r/calib.cpp +++ b/src/libslic3r/calib.cpp @@ -771,6 +771,7 @@ CustomGCode::Info CalibPressureAdvancePattern::generate_custom_gcodes(const Dyna } } + gcode << m_writer.reset_e(); gcode << m_writer.set_pressure_advance(m_params.start); gcode << "; end pressure advance pattern for layer\n"; diff --git a/src/libslic3r/calib.hpp b/src/libslic3r/calib.hpp index db147fdeb4..15ac7303c9 100644 --- a/src/libslic3r/calib.hpp +++ b/src/libslic3r/calib.hpp @@ -35,10 +35,10 @@ struct Calib_Params { Calib_Params() : mode(CalibMode::Calib_None){}; int extruder_id = 0; - double start, end, step; - bool print_numbers; - double freqStartX, freqEndX, freqStartY, freqEndY; - int test_model; + double start = 0.0, end = 1.0, step = 0.1; + bool print_numbers = false; + double freqStartX = 0.0, freqEndX = 1.0, freqStartY = 0.0, freqEndY = 1.0; + int test_model = 0; std::string shaper_type; std::vector accelerations; std::vector speeds; diff --git a/src/slic3r/GUI/DeviceCore/DevManager.cpp b/src/slic3r/GUI/DeviceCore/DevManager.cpp index 320d68f727..0b9e67adbd 100644 --- a/src/slic3r/GUI/DeviceCore/DevManager.cpp +++ b/src/slic3r/GUI/DeviceCore/DevManager.cpp @@ -558,6 +558,7 @@ namespace Slic3r } else { + Slic3r::GUI::wxGetApp().reset_unsigned_plugin_warning(); if (m_agent) { if (it->second->connection_type() != "lan" || it->second->connection_type().empty()) diff --git a/src/slic3r/GUI/Field.cpp b/src/slic3r/GUI/Field.cpp index 9701fa7a6d..bcbc381eff 100644 --- a/src/slic3r/GUI/Field.cpp +++ b/src/slic3r/GUI/Field.cpp @@ -1043,14 +1043,14 @@ void TextCtrl::propagate_value() void TextCtrl::set_value(const boost::any& value, bool change_event/* = false*/) { m_disable_change_event = !change_event; + if (m_opt.nullable) { const bool m_is_na_val = value.empty() || (boost::any_cast(value) == _(L("N/A"))); if (!m_is_na_val) m_last_meaningful_value = value; - text_ctrl()->SetValue(boost::any_cast(value)); // BBS } - else - text_ctrl()->SetValue(value.empty() ? "" : boost::any_cast(value)); // BBS // BBS: null value + + text_ctrl()->SetValue(value.empty() ? wxString() : boost::any_cast(value)); m_disable_change_event = false; if (!change_event) { @@ -1187,18 +1187,24 @@ void CheckBox::set_value(const boost::any& value, bool change_event) m_disable_change_event = !change_event; if (m_opt.nullable) { const bool is_value_unsigned_char = value.type() == typeid(unsigned char); + bool bool_value = false; + m_is_na_val = value.empty() || (is_value_unsigned_char && boost::any_cast(value) == ConfigOptionBoolsNullable::nil_value()); - if (!m_is_na_val) - m_last_meaningful_value = is_value_unsigned_char ? value : static_cast(boost::any_cast(value)); - const auto bool_value = is_value_unsigned_char ? - boost::any_cast(value) != 0 : - boost::any_cast(value); - dynamic_cast<::CheckBox*>(window)->SetValue(m_is_na_val ? false : bool_value); // BBS + if (!m_is_na_val) { + bool_value = is_value_unsigned_char ? + boost::any_cast(value) != 0 : + boost::any_cast(value); + m_last_meaningful_value = is_value_unsigned_char ? value : static_cast(bool_value); + } + + dynamic_cast<::CheckBox*>(window)->SetValue(bool_value); } - else if (!value.empty()) // BBS: null value + else if (!value.empty()){ // BBS: null value dynamic_cast<::CheckBox*>(window)->SetValue(boost::any_cast(value)); // BBS + } + dynamic_cast<::CheckBox*>(window)->SetHalfChecked(value.empty()); m_disable_change_event = false; } diff --git a/src/slic3r/GUI/GUI_App.cpp b/src/slic3r/GUI/GUI_App.cpp index 312c4f6731..9a846b1aaa 100644 --- a/src/slic3r/GUI/GUI_App.cpp +++ b/src/slic3r/GUI/GUI_App.cpp @@ -6127,18 +6127,23 @@ bool GUI_App::process_network_msg(std::string dev_id, std::string msg) } else if (msg == "unsigned_studio") { BOOST_LOG_TRIVIAL(info) << "process_network_msg, unsigned_studio"; - MessageDialog - msg_dlg(nullptr, - _L("To use OrcaSlicer with Bambu Lab printers, you need to enable LAN mode and Developer mode on your printer.\n\n" - "Please go to your printer's settings and:\n" - "1. Turn on LAN mode\n" - "2. Enable Developer mode\n\n" - "Developer mode allows the printer to work exclusively through local network access, " - "enabling full functionality with OrcaSlicer."), - _L("Network Plug-in Restriction"), wxAPPLY | wxOK); - m_show_error_msgdlg = true; - msg_dlg.ShowModal(); - m_show_error_msgdlg = false; + // Plugin re-emits this on every subscribe retry; latch it so it shows + // once per connection episode. + if (!m_show_error_msgdlg && !m_unsigned_plugin_warning_shown) { + m_unsigned_plugin_warning_shown = true; + MessageDialog + msg_dlg(nullptr, + _L("To use OrcaSlicer with Bambu Lab printers, you need to enable LAN mode and Developer mode on your printer.\n\n" + "Please go to your printer's settings and:\n" + "1. Turn on LAN mode\n" + "2. Enable Developer mode\n\n" + "Developer mode allows the printer to work exclusively through local network access, " + "enabling full functionality with OrcaSlicer."), + _L("Network Plug-in Restriction"), wxAPPLY | wxOK); + m_show_error_msgdlg = true; + msg_dlg.ShowModal(); + m_show_error_msgdlg = false; + } return true; } } diff --git a/src/slic3r/GUI/GUI_App.hpp b/src/slic3r/GUI/GUI_App.hpp index c90e58887e..bda27d40ec 100644 --- a/src/slic3r/GUI/GUI_App.hpp +++ b/src/slic3r/GUI/GUI_App.hpp @@ -343,6 +343,7 @@ private: public: //try again when subscription fails void on_start_subscribe_again(std::string dev_id); + void reset_unsigned_plugin_warning() { m_unsigned_plugin_warning_shown = false; } std::string get_local_models_path(); bool OnInit() override; int OnExit() override; diff --git a/src/slic3r/GUI/Plater.cpp b/src/slic3r/GUI/Plater.cpp index 3af7340c28..feecdfa163 100644 --- a/src/slic3r/GUI/Plater.cpp +++ b/src/slic3r/GUI/Plater.cpp @@ -11988,12 +11988,53 @@ bool Plater::priv::check_ams_status_impl(bool is_slice_all) auto nozzle_volumes_values = preset_bundle->project_config.option("nozzle_volume_type")->values; assert(obj->GetExtderSystem()->GetTotalExtderCount() == 2 && nozzle_volumes_values.size() == 2); if (obj->GetExtderSystem()->GetTotalExtderCount() == 2 && nozzle_volumes_values.size() == 2) { - // Map device flow->volume via the table, not `flowtype - 1` (which mis-maps U_FLOW to nvtHybrid). - NozzleVolumeType right_nozzle_type = DevNozzle::ToNozzleVolumeType(obj->GetExtderSystem()->GetNozzleFlowType(0)); - NozzleVolumeType left_nozzle_type = DevNozzle::ToNozzleVolumeType(obj->GetExtderSystem()->GetNozzleFlowType(1)); - NozzleVolumeType preset_left_type = NozzleVolumeType(nozzle_volumes_values[0]); - NozzleVolumeType preset_right_type = NozzleVolumeType(nozzle_volumes_values[1]); - is_same_as_printer = (left_nozzle_type == preset_left_type && right_nozzle_type == preset_right_type); + // [Vortek] H2C: Use BBS-style NozzleGroupInfo comparison instead of direct nozzle type match. + // This correctly handles Hybrid presets (which expand into per-type counts) and detects + // never-synced state (nozzle_count==0) so the first sync dialog appears. + // After device sync, extruder_nozzle_stats matches printer → dialog suppressed. + // Reference to BBS: BambuStudio/src/slic3r/GUI/Plater.cpp is_extruder_stat_synced() + using namespace MultiNozzleUtils; + auto nozzle_diameter_values = preset_bundle->printers.get_edited_preset().config.option("nozzle_diameter")->values; + + // Build preset nozzle groups from extruder_nozzle_stats config + std::vector> preset_nozzle_infos(nozzle_diameter_values.size()); + for (size_t extruder_id = 0; extruder_id < nozzle_diameter_values.size(); ++extruder_id) { + NozzleVolumeType preset_volume_type = NozzleVolumeType(nozzle_volumes_values[extruder_id]); + std::string preset_diameter = format_diameter_to_str(nozzle_diameter_values[extruder_id]); + + if (preset_volume_type == nvtHybrid) { + // Hybrid: expand into separate groups for each nozzle type from stats + int std_count = getExtruderNozzleCount(preset_bundle, extruder_id, nvtStandard); + int hf_count = getExtruderNozzleCount(preset_bundle, extruder_id, nvtHighFlow); + if (std_count > 0) + preset_nozzle_infos[extruder_id].emplace_back(preset_diameter, nvtStandard, extruder_id, std_count); + if (hf_count > 0) + preset_nozzle_infos[extruder_id].emplace_back(preset_diameter, nvtHighFlow, extruder_id, hf_count); + // If both are 0 → never synced → empty group → will mismatch + } else { + int count = getExtruderNozzleCount(preset_bundle, extruder_id, preset_volume_type); + preset_nozzle_infos[extruder_id].emplace_back(preset_diameter, preset_volume_type, extruder_id, count); + } + } + + // Compare with printer nozzle groups + auto printer_groups = obj->GetNozzleSystem()->GetNozzleGroups(); + for (const auto& preset_groups : preset_nozzle_infos) { + for (const auto& preset_group : preset_groups) { + if (preset_group.nozzle_count == 0) { + // Never synced: if printer has nozzles of this type → needs sync + if (std::find_if(printer_groups.begin(), printer_groups.end(), + [&preset_group](const NozzleGroupInfo& elem) { return preset_group.is_same_type(elem); }) + != printer_groups.end()) { + is_same_as_printer = false; + break; + } + } else if (std::find(printer_groups.begin(), printer_groups.end(), preset_group) == printer_groups.end()) { + is_same_as_printer = false; + break; + } + } + } } std::vector> ams_count_info; diff --git a/src/slic3r/GUI/Tab.cpp b/src/slic3r/GUI/Tab.cpp index e2eb67a5fb..6bb47059e2 100644 --- a/src/slic3r/GUI/Tab.cpp +++ b/src/slic3r/GUI/Tab.cpp @@ -3299,7 +3299,19 @@ static std::vector intersect(std::vector const& l, std static std::vector concat(std::vector const& l, std::vector const& r) { std::vector t; - std::set_union(l.begin(), l.end(), r.begin(), r.end(), std::back_inserter(t)); + bool l_is_sorted = std::is_sorted(l.begin(), l.end()); + bool r_is_sorted = std::is_sorted(r.begin(), r.end()); + + if (l_is_sorted && r_is_sorted) { + std::set_union(l.begin(), l.end(), r.begin(), r.end(), std::back_inserter(t)); + return t; + } + + std::vector l_sorted = l; + std::vector r_sorted = r; + std::sort(l_sorted.begin(), l_sorted.end()); + std::sort(r_sorted.begin(), r_sorted.end()); + std::set_union(l_sorted.begin(), l_sorted.end(), r_sorted.begin(), r_sorted.end(), std::back_inserter(t)); return t; } diff --git a/src/slic3r/GUI/TroubleshootDialog.cpp b/src/slic3r/GUI/TroubleshootDialog.cpp index 44f35e2335..2ddb136a1c 100644 --- a/src/slic3r/GUI/TroubleshootDialog.cpp +++ b/src/slic3r/GUI/TroubleshootDialog.cpp @@ -186,7 +186,7 @@ TroubleshootDialog::TroubleshootDialog() Fit(); }); - auto link_wiki = new HyperLink(this, _L("Wiki Guide")); + auto link_wiki = new HyperLink(this, _L("Wiki Guide"), "https://www.orcaslicer.com/wiki/troubleshoot_center"); // RIGHT SIZER ////////////////////// diff --git a/tests/compare_analyzer/README.md b/tests/compare_analyzer/README.md new file mode 100644 index 0000000000..bc08fe5121 --- /dev/null +++ b/tests/compare_analyzer/README.md @@ -0,0 +1,88 @@ +# Compare Analyzer — G-code Slicing Comparison Tools + +Tools for deep comparison and analysis of `.3mf` slicing project files, designed for +verifying multi-nozzle (H2C carousel) and multi-extruder slicing correctness. + +## Tools + +### `compare_slices.py` — Slice Comparison Analyzer + +Deep comparison of two `.3mf` files (OrcaSlicer, BambuStudio, or any compatible slicer). +Generates a comprehensive Markdown report covering: + +- **Filament usage** — per-filament weight/length with color mapping +- **Nozzle/extruder mapping** — Vortek carousel slot assignments +- **Tool change sequences** — T-code ordering and count +- **Prime tower analysis** — tower entries, G-code line count +- **Temperature timeline** — pre-heat lead times, target temperatures per tool change +- **Retract parameters** — M620.11 analysis during nozzle switches +- **Filament change G-code blocks** — line-by-line diff of change_filament_gcode +- **Control command diff** — timeline of M/G-code differences +- **Critical discrepancy detection** — automatic flagging of weight/time anomalies + +#### Usage + +```bash +# Compare two slice files +python3 compare_slices.py file1.3mf file2.3mf + +# With custom labels +python3 compare_slices.py file1.3mf file2.3mf --labels "Upstream" "Fixed" +``` + +#### Output +Markdown report saved to `mp_reports/compare_report_YYYYMMDD_HHMMSS.md` + +#### Example: Detecting H2C purge regression +``` +⚠️ CRITICAL DISCREPANCY: Huge difference in part weight: + OrcaSlicer 60.90 g vs BambuStudio 17.47 g (difference 43.43 g or 71.3%). + The reason is incorrect nozzle mapping, causing huge AMS flushing. +``` + +--- + +### `show_temp_plot.py` — Temperature Timeline Plotter + +Generates interactive HTML temperature plots for analyzing thermal profiles during +multi-nozzle prints. Visualizes heater temperature commands (M104/M109) per tool change, +showing pre-heat timing and temperature convergence. + +#### Architecture +- H2C dual-extruder layout with Vortek carousel nozzles +- Physical heaters mapped dynamically: + - Heater 0: Extruder 2 (right nozzle slot, T0/T2/T3/T4) + - Heater 1: Extruder 1 (left nozzle slot, T1) +- Active heater mapping derived from G-code temperature signals + +#### Usage + +```bash +# Single file analysis +python3 show_temp_plot.py file.3mf + +# Side-by-side comparison of two files +python3 show_temp_plot.py file1.3mf file2.3mf +``` + +#### Output +Interactive HTML report saved to Desktop as `temp_plot_v3.html` + +--- + +## Requirements + +- **Python 3.8+** +- **No external dependencies** — uses only Python standard library + (`json`, `zipfile`, `xml.etree.ElementTree`, `difflib`, `webbrowser`) + +## Use Cases + +1. **Regression testing** — compare slices before/after code changes to verify + no unintended differences in purge volumes, tool ordering, or temperature timing +2. **BBS compatibility verification** — compare OrcaSlicer output against BambuStudio + reference slices to ensure behavioral parity +3. **H2C carousel validation** — verify per-slot nozzle tracking produces correct + purge volumes (not collapsed per-extruder) +4. **Temperature protocol analysis** — verify pre-heat lead times and cooling + temperatures during nozzle changes match expected profiles diff --git a/tests/compare_analyzer/compare_slices.py b/tests/compare_analyzer/compare_slices.py new file mode 100755 index 0000000000..cf321c7b7d --- /dev/null +++ b/tests/compare_analyzer/compare_slices.py @@ -0,0 +1,1282 @@ +""" +Slicing G-code Comparison Tool: OrcaSlicer vs BambuStudio (BBL) +==================================================================== + +The script performs a deep comparison of two `.3mf` slicing project files +(the first one from OrcaSlicer, the second from Bambu Studio) for debugging the Vortek H2C nozzle changer. + +Default behavior (file paths): +------------------------------------ +If only filenames are passed (e.g. `54orca.3mf` and `bbl.3mf`), the script searches by priority: +1. Looks for the file on the user's Desktop (~/Desktop/). +2. Looks for the file relative to the current working directory. +3. Supports absolute paths. + +What the script analyzes: +---------------------- +1. Summary metadata (slice_info.config): + - Print time, part weight, first layer time, slicer versions, printer model ID. + - filament_maps parameters, dynamic mapping, and filament switcher settings. + - Summary nozzle settings: extruder_nozzle_stats, filament_nozzle_map, filament_volume_map. +2. Preheat and Standby Cooldown Analysis: + - Matches M104/M109 heating commands of the target nozzle before toolchange (T). + - Calculates lead lines (how many G-code lines before physical T the heating started). + - Extracts standby cooldown temperatures of the inactive nozzle. +3. Vortek Nozzles comparison: + - Analyzes assigned nozzle IDs, extruder IDs, and diameters. +4. Differences in slicing settings (project_settings.config): + - Generates a comparison table of configuration discrepancies between slicers. +5. Nozzle changes, prime tower, and retracts: + - Total number of toolchanges and sequence. + - Prime Tower G-code volume (lines and blocks). + - Retract parameters during nozzle switch (M620.11 combinations of E, R, F). +6. Change filament G-code blocks (change_filament_gcode): + - Runs a unified diff for each toolchange G-code block. +7. Timeline of G-code control commands: + - Builds a differential track of toolchanges, nozzle switches, and heating events. +8. Critical discrepancies analysis: + - Signals invalid nozzle indices (out of H2C carousel limits). + - Detects nozzle collisions (duplicate slots causing AMS flush instead of physical swap). + - Pinpoints the Standard#7 inventory bug, weight discrepancies due to flushes, etc. + +Results are saved to: +/Users/denn/Develop/3dprint/dehancer lab/H2C_v2/mp_reports/compare_report_.md + +AGENTS NOTE: +When a slicing report is requested, do not print raw setting diffs or full timelines in chat. +Provide only the final analytical report of KEY and CRITICAL discrepancies (mapping errors, preheat differences, Vortek anomalies). +""" + +import os +import sys +import zipfile +import json +import xml.etree.ElementTree as ET +import difflib +from datetime import datetime + +DESKTOP_DIR = os.path.expanduser("~/Desktop") +REPORTS_DIR = "/Users/denn/Develop/3dprint/dehancer lab/H2C_v2/mp_reports" + +def escape_markdown_table(val): + if val is None: + return "None" + # Replace pipe with unicode vertical bar to avoid breaking markdown table columns + return str(val).replace("|", " ⎮ ") + +def find_file(filename): + if os.path.isabs(filename): + return filename + desktop_path = os.path.join(DESKTOP_DIR, filename) + if os.path.exists(desktop_path): + return desktop_path + if os.path.exists(filename): + return os.path.abspath(filename) + return None + +def parse_metadata(zip_file): + metadata = { + "version_info": {}, + "plate_meta": {}, + "filaments": [], + "nozzles": [] + } + try: + xml_data = zip_file.read("Metadata/slice_info.config") + root = ET.fromstring(xml_data) + + header = root.find("header") + if header is not None: + for item in header.findall("header_item"): + metadata["version_info"][item.attrib.get("key")] = item.attrib.get("value") + + plate = root.find("plate") + if plate is not None: + for item in plate.findall("metadata"): + metadata["plate_meta"][item.attrib.get("key")] = item.attrib.get("value") + + for fil in plate.findall("filament"): + metadata["filaments"].append({ + "id": fil.attrib.get("id"), + "type": fil.attrib.get("type"), + "color": fil.attrib.get("color"), + "used_g": fil.attrib.get("used_g"), + "used_m": fil.attrib.get("used_m"), + "nozzle_diameter": fil.attrib.get("nozzle_diameter"), + "volume_type": fil.attrib.get("volume_type") + }) + + for noz in plate.findall("nozzle"): + metadata["nozzles"].append({ + "id": noz.attrib.get("id"), + "extruder_id": noz.attrib.get("extruder_id"), + "nozzle_diameter": noz.attrib.get("nozzle_diameter") + }) + except KeyError: + pass + except Exception as e: + print(f"Error parsing metadata: {e}") + return metadata + +def parse_project_settings(zip_file): + try: + content = zip_file.read("Metadata/project_settings.config").decode('utf-8') + return json.loads(content) + except KeyError: + return {} + except Exception as e: + print(f"Error parsing project_settings: {e}") + return {} + +def parse_critical_gcode(zip_file, filament_maps_str=None): + critical_events = [] + total_lines = 0 + file_size = 0 + + # Try to parse filament_maps from Metadata/slice_info.config if not provided + if not filament_maps_str: + try: + xml_data = zip_file.read("Metadata/slice_info.config") + import xml.etree.ElementTree as ET + root = ET.fromstring(xml_data) + plate = root.find("plate") + if plate is not None: + for meta in plate.findall("metadata"): + if meta.attrib.get("key") == "filament_maps": + filament_maps_str = meta.attrib.get("value", "").strip() + break + except Exception: + pass + + if not filament_maps_str: + filament_maps_str = "1 1 1 1 1 2" + + extruder_map = {} + for i, v in enumerate(filament_maps_str.split()): + try: + extruder_map[i] = int(v) + except ValueError: + pass + + # For H2C, Extruder 1 (Left) maps to Heater 1, Extruder 2 (Right) maps to Heater 0 + heater_to_ext = {1: 1, 2: 0} + nozzle_map = {} + for fid, ext_id in extruder_map.items(): + nozzle_map[fid] = heater_to_ext.get(ext_id, 1) + + toolchange_count = 0 + toolchange_sequence = [] + prime_tower_blocks = 0 + prime_tower_lines = 0 + in_prime_tower = False + + m620_11_retracts = [] + temp_events = [] + + active_extruder = 0 + next_filament = 0 + temp_T0 = 0 + temp_T1 = 0 + temp_track = [] + + toolchange_blocks = [] + current_tc_block = [] + in_tc_block = False + in_m620_block = False + printing_started = False + m73_points = [] + try: + gcode_names = [name for name in zip_file.namelist() if name.endswith('.gcode')] + if not gcode_names: + raise KeyError("No .gcode file found in 3mf") + gcode_name = gcode_names[0] + info = zip_file.getinfo(gcode_name) + file_size = info.file_size + + with zip_file.open(gcode_name) as f: + for line_bytes in f: + total_lines += 1 + line = line_bytes.decode("utf-8", errors="ignore").strip() + + if "; CHANGE_LAYER" in line: + printing_started = True + + # Special check for H2C virtual temperature commands (e.g. ;VM104, ;VM109) + is_virtual_temp = False + if line.startswith(";VM104") or line.startswith(";VM109"): + is_virtual_temp = True + cmd_part = line[1:].split(";")[0].strip() + else: + cmd_part = line.split(";")[0].strip() + + if ";======== H2C filament_change ========" in line: + in_tc_block = True + current_tc_block = [f"Line {total_lines}: {line}"] + elif in_tc_block: + current_tc_block.append(f"Line {total_lines}: {line}") + if "M1002 gcode_claim_action : 0" in cmd_part: + in_tc_block = False + toolchange_blocks.append("\n".join(current_tc_block)) + + if not cmd_part: + continue + + words = cmd_part.split() + first_word = words[0] + + if first_word == "M628" and "S1" in cmd_part: + in_prime_tower = True + prime_tower_blocks += 1 + elif first_word == "M628" and "S0" in cmd_part: + in_prime_tower = False + + if in_prime_tower: + prime_tower_lines += 1 + + if first_word == "M73" and not first_word.startswith("M73.2"): + parts = cmd_part.split() + r_val = next((p for p in parts if p.startswith("R")), None) + p_val = next((p for p in parts if p.startswith("P")), None) + if r_val and p_val: + try: + r_min = int(r_val[1:]) + m73_points.append((total_lines, r_min)) + except ValueError: + pass + + is_critical = False + + if first_word.startswith("T") and first_word[1:].isdigit(): + is_critical = True + toolchange_count += 1 + toolchange_sequence.append(first_word) + t_val = int(first_word[1:]) + if t_val < 60000: + active_extruder = t_val + next_filament = t_val + temp_track.append((total_lines, active_extruder, temp_T0, temp_T1, f"T{t_val} (Active Filament: T{t_val})", in_m620_block, printing_started)) + + elif any(first_word.startswith(prefix) for prefix in ["M620", "M621", "M622", "M623", "M628", "M629", "M1002", "G29"]): + is_critical = True + if first_word == "M620": + in_m620_block = True + parts = cmd_part.split() + s_val = next((p for p in parts if p.startswith("S")), None) + if s_val and s_val.endswith("A") and len(s_val) > 2: + try: + next_filament = int(s_val[1:-1]) + except ValueError: + pass + elif first_word == "M621": + in_m620_block = False + elif first_word == "M620.11": + parts = cmd_part.split() + e_val = next((p for p in parts if p.startswith("E")), "E?") + r_val = next((p for p in parts if p.startswith("R")), "R?") + f_val = next((p for p in parts if p.startswith("F")), "F?") + m620_11_retracts.append(f"{e_val} {r_val} {f_val}") + elif first_word == "M620.15": + parts = cmd_part.split() + p_val = next((p for p in parts if p.startswith("P")), None) + c_val = next((p for p in parts if p.startswith("C")), None) + desc_parts = [] + # M620.15 is a firmware command that sets target temp of the INCOMING nozzle + # during the switch to prevent oozing / prepare for print. + target_heater = 1 if next_filament == 1 else 0 + if p_val: + desc_parts.append(f"Pre-cool P{p_val[1:]}°C") + temp_events.append((total_lines, f"M620.15 Pre-cool P{p_val[1:]}°C")) + try: + val = int(p_val[1:]) + if target_heater == 0: + temp_T0 = val + else: + temp_T1 = val + except ValueError: + pass + if c_val: + desc_parts.append(f"Target-cool C{c_val[1:]}°C") + temp_events.append((total_lines, f"M620.15 Target-cool C{c_val[1:]}°C")) + try: + val = int(c_val[1:]) + if target_heater == 0: + temp_T0 = val + else: + temp_T1 = val + except ValueError: + pass + desc = " & ".join(desc_parts) + temp_track.append((total_lines, active_extruder, temp_T0, temp_T1, f"M620.15 {desc}", in_m620_block, printing_started)) + + elif any(first_word.startswith(prefix) for prefix in ["M104", "M109", "VM104", "VM109"]): + is_critical = True + parts = cmd_part.split() + s_val = next((p for p in parts if p.startswith("S")), None) + t_val = next((p for p in parts if p.startswith("T")), "") + if s_val: + s_temp = int(s_val[1:]) + t_desc = f" T{t_val[1:]}" if t_val else "" + temp_events.append((total_lines, f"{first_word}{t_desc} S{s_val[1:]}°C")) + + # Determine target heater + if t_val: + # Explicit T parameter: T0 = heater 0, T1 = heater 1 + if t_val == "T0": + targeted_heater = 0 + elif t_val == "T1": + targeted_heater = 1 + else: + targeted_heater = 0 + else: + # No T param: applies to active heater + # H2C mapping: use nozzle_map + targeted_heater = nozzle_map.get(active_extruder, 1) + + if targeted_heater == 0: + temp_T0 = s_temp + else: + temp_T1 = s_temp + + temp_track.append((total_lines, active_extruder, temp_T0, temp_T1, f"{first_word}{t_desc} S{s_temp}°C", in_m620_block, printing_started)) + + if is_critical: + critical_events.append(f"Line {total_lines}: {cmd_part}") + + # Insert regular temperature samples every 100 lines to prevent linear interpolation artifacts + if total_lines % 100 == 0: + temp_track.append((total_lines, active_extruder, temp_T0, temp_T1, "Regular Sample", in_m620_block, printing_started)) + except KeyError: + pass + except Exception as e: + print(f"Error parsing G-code: {e}") + + stats = { + "toolchange_count": toolchange_count, + "toolchange_sequence": toolchange_sequence, + "prime_tower_blocks": prime_tower_blocks, + "prime_tower_lines": prime_tower_lines, + "m620_11_retracts": sorted(list(set(m620_11_retracts))), + "temp_events": temp_events, + "temp_track": temp_track, + "toolchange_blocks": toolchange_blocks, + "m73_points": m73_points + } + return critical_events, total_lines, file_size, stats + +def get_slicer_name(meta): + version_info = meta.get("version_info", {}) + if "OrcaSlicer-Version" in version_info: + return "OrcaSlicer" + elif "X-BBL-Client-Version" in version_info: + return "BambuStudio" + for k in version_info.keys(): + if "orcaslicer" in k.lower(): + return "OrcaSlicer" + if "bambu" in k.lower() or "bbl" in k.lower(): + return "BambuStudio" + return "UnknownSlicer" + +def analyze_critical_discrepancies(f1_meta, f2_meta, f1_settings, f2_settings, f1_stats, f2_stats): + discrepancies = [] + slicer1 = get_slicer_name(f1_meta) + slicer2 = get_slicer_name(f2_meta) + + # 1. Nozzle map index errors + def clean_map(m): + if not m: + return [] + if isinstance(m, str): + m = m.replace("[", "").replace("]", "").replace("'", "").replace(",", " ") + return [int(x) for x in m.split()] + elif isinstance(m, list): + return [int(x) for x in m] + return [] + + fnm1 = clean_map(f1_settings.get("filament_nozzle_map")) + fnm2 = clean_map(f2_settings.get("filament_nozzle_map")) + + if fnm1: + # Compute max valid nozzle slot from extruder_nozzle_stats + # Format: ['Standard#1', 'Standard#4|High Flow#2'] → total = 1+4+2 = 7, max_slot = 6 + max_nozzle_slot = 5 # default fallback + ens = f1_settings.get("extruder_nozzle_stats") or f2_settings.get("extruder_nozzle_stats") + if ens: + if isinstance(ens, str): + ens = ens.split("','") + total_nozzles = 0 + for entry in ens: + entry = entry.strip().strip("'\"[] ") + for part in entry.split("|"): + part = part.strip() + if "#" in part: + try: + total_nozzles += int(part.split("#")[1]) + except (ValueError, IndexError): + pass + if total_nozzles > 0: + max_nozzle_slot = total_nozzles - 1 + invalid = [x for x in fnm1 if x < 0 or x > max_nozzle_slot] + if invalid: + discrepancies.append({ + "level": "CRITICAL ERROR", + "message": f"{slicer1} `filament_nozzle_map` contains invalid nozzle slots {invalid} (out of H2C nozzle limits 0..{max_nozzle_slot}). This breaks physical switching." + }) + + # 2. Nozzle group collisions (multiple active filaments on one slot) + if fnm1: + active_filaments = [i for i, fil in enumerate(f1_meta["filaments"]) if fil.get("used_g") and float(fil["used_g"]) > 0] + used_slots = {} + for f_idx in active_filaments: + if f_idx < len(fnm1): + slot = fnm1[f_idx] + if slot != 0: + if slot in used_slots: + used_slots[slot].append(f_idx + 1) + else: + used_slots[slot] = [f_idx + 1] + for slot, fils in used_slots.items(): + if len(fils) > 1: + discrepancies.append({ + "level": "CRITICAL DISCREPANCY", + "message": f"{slicer1}: different active filament colors {fils} share the same carousel slot {slot} (duplicate nozzle). Because of this, filament changes cause AMS flushing instead of physical nozzle change!" + }) + + # 3. Toolchange counts comparison + tc1 = f1_stats["toolchange_count"] + tc2 = f2_stats["toolchange_count"] + if tc1 != tc2: + discrepancies.append({ + "level": "CRITICAL ERROR", + "message": f"Filament change count mismatch: {slicer1} has {tc1} changes, {slicer2} has {tc2} changes." + }) + + # 4. Filament maps differences + fmap1 = f1_meta["plate_meta"].get("filament_maps") + fmap2 = f2_meta["plate_meta"].get("filament_maps") + if fmap1 != fmap2: + discrepancies.append({ + "level": "WARNING", + "message": f"Filament maps differ: {slicer1} `{fmap1}` vs {slicer2} `{fmap2}`." + }) + + # 5. Weight discrepancy + w1 = float(f1_meta["plate_meta"].get("weight", 0)) + w2 = float(f2_meta["plate_meta"].get("weight", 0)) + if w1 > 0 and w2 > 0: + ratio = abs(w1 - w2) / max(w1, w2) + if ratio > 0.2: + discrepancies.append({ + "level": "CRITICAL DISCREPANCY", + "message": f"Huge difference in part weight: {slicer1} {w1:.2f} g vs {slicer2} {w2:.2f} g (difference {abs(w1-w2):.2f} g or {ratio*100:.1f}%). The reason is incorrect nozzle mapping in {slicer1}, causing huge AMS flushing." + }) + + # 6. Prediction time discrepancy + pred1 = float(f1_meta["plate_meta"].get("prediction", 0)) + pred2 = float(f2_meta["plate_meta"].get("prediction", 0)) + if pred1 > 0 and pred2 > 0: + ratio = abs(pred1 - pred2) / max(pred1, pred2) + if ratio > 0.2: + discrepancies.append({ + "level": "CRITICAL DISCREPANCY", + "message": f"Print time difference exceeds 20%: {slicer1} {int(pred1/60)} min vs {slicer2} {int(pred2/60)} min (difference {int((pred1-pred2)/60)} min or {ratio*100:.1f}%)." + }) + + # 7. Physical extruder map + pem1 = f1_settings.get("physical_extruder_map") + pem2 = f2_settings.get("physical_extruder_map") + if pem1 != pem2: + discrepancies.append({ + "level": "WARNING", + "message": f"Physical extruder map `physical_extruder_map` differs: {slicer1} {pem1} vs {slicer2} {pem2}." + }) + + # 8. Nozzle statistics (extruder_nozzle_stats) + ens1 = f1_settings.get("extruder_nozzle_stats") + ens2 = f2_settings.get("extruder_nozzle_stats") + if ens1 != ens2: + discrepancies.append({ + "level": "WARNING", + "message": f"Nozzle inventories `extruder_nozzle_stats` differ: {slicer1} `{ens1}` vs {slicer2} `{ens2}`." + }) + if ens1: + ens1_str = str(ens1) + if "Standard#7" in ens1_str: + discrepancies.append({ + "level": "CRITICAL ERROR", + "message": f"H2C nozzle changer bug detected in {slicer1}: `extruder_nozzle_stats` is set to `Standard#7` for both extruders. This causes incorrect linear nozzle list construction and mapping crash!" + }) + + return discrepancies + +def interpret_temp_action(desc, active_extruder): + try: + desc_clean = desc.replace("°C", "") + words = desc_clean.split() + if not words: + return "Control Command" + cmd = words[0] + + if cmd.startswith("T") and "Active Extruder" in desc: + return "Active Extruder Switch" + + if "Pre-cool" in desc: + p_temp = desc.split("P")[-1].replace("°C", "").strip() + return f"Virtual Preheat (Pre-cool) before change to {p_temp}°C" + + if "Target-cool" in desc: + c_temp = desc.split("C")[-1].replace("°C", "").strip() + return f"Set Target Active Nozzle Temp to {c_temp}°C" + + if cmd in ["M104", "M109", "VM104", "VM109"]: + s_val = None + t_val = None + for w in words: + if w.startswith("S"): + try: + s_val = int(w[1:]) + except ValueError: + pass + elif w.startswith("T") and not "Active" in desc: + t_val = w + + heater = active_extruder + if t_val == "T0": + heater = 0 + elif t_val == "T1": + heater = 1 + + heater_name = "Left (T0)" if heater == 0 else "Right (T1)" + + if s_val is not None: + if s_val <= 50: + action_type = "Cooling" if s_val > 0 else "Power Off" + target_desc = "standby (cooldown)" if s_val > 0 else "heater" + return f"{action_type} {heater_name} to {target_desc} ({s_val}°C)" + elif 150 <= s_val <= 215: + if heater != active_extruder: + return f"Standby Preheat {heater_name} to {s_val}°C" + else: + return f"Heating {heater_name} to intermediate temp {s_val}°C" + else: + action = "Stabilizing Temperature" if cmd == "M109" else "Heating" + return f"{action} {heater_name} to active print temp ({s_val}°C)" + except Exception as e: + return f"Temperature Command ({e})" + return "Control Command" + +def format_side_by_side_temp_track(f1_track, f2_track, f1_name, f2_name): + # 1. Find toolchanges for File 1 (Orca) + tc1 = [] + for idx, item in enumerate(f1_track): + desc = item[4] + line = item[0] + if desc.startswith("T") and "Active Extruder" in desc: + first_part = desc.split()[0] + t_num = first_part[1:] + if t_num.isdigit() and int(t_num) < 1000: + tc1.append((idx, line, int(t_num))) + + # 2. Find toolchanges for File 2 (BBL) + tc2 = [] + for idx, item in enumerate(f2_track): + desc = item[4] + line = item[0] + if desc.startswith("T") and "Active Extruder" in desc: + first_part = desc.split()[0] + t_num = first_part[1:] + if t_num.isdigit() and int(t_num) < 1000: + tc2.append((idx, line, int(t_num))) + + if not tc1 and not tc2: + res = ["##### Start Warmup Comparison (first 30 events):\n"] + res.append("| Orca: Line | Orca: Command | T0/T1 | BBL: Line | BBL: Command | T0/T1 |\n") + res.append("| :---: | :--- | :---: | :---: | :--- | :---: |\n") + limit = min(30, max(len(f1_track), len(f2_track))) + for i in range(limit): + item1 = f1_track[i] if i < len(f1_track) else None + item2 = f2_track[i] if i < len(f2_track) else None + + o_line = str(item1[0]) if item1 else "" + o_desc = item1[4] if item1 else "" + o_temps = f"{item1[2]}/{item1[3]}°C" if item1 else "" + + b_line = str(item2[0]) if item2 else "" + b_desc = item2[4] if item2 else "" + b_temps = f"{item2[2]}/{item2[3]}°C" if item2 else "" + + res.append(f"| {o_line} | {o_desc} | {o_temps} | {b_line} | {b_desc} | {b_temps} |\n") + return "".join(res) + + # 3. Create ranges around toolchanges for Orca + ranges1 = [] + for idx, line, t_num in tc1: + start_idx = max(0, idx - 10) + end_idx = min(len(f1_track) - 1, idx + 8) + ranges1.append((start_idx, end_idx, f"Change to T{t_num} (line {line})", line)) + + m_ranges1 = [] + if ranges1: + curr_start, curr_end, curr_label, curr_last_line = ranges1[0] + for start, end, label, line in ranges1[1:]: + if line - curr_last_line <= 1000: + curr_end = max(curr_end, end) + curr_label += f" -> T{label.split(' to T')[-1].split()[0]}" + curr_last_line = line + else: + m_ranges1.append((curr_start, curr_end, curr_label)) + curr_start, curr_end, curr_label, curr_last_line = start, end, label, line + m_ranges1.append((curr_start, curr_end, curr_label)) + + # Ranges for BBL + ranges2 = [] + for idx, line, t_num in tc2: + start_idx = max(0, idx - 10) + end_idx = min(len(f2_track) - 1, idx + 8) + ranges2.append((start_idx, end_idx, f"Change to T{t_num} (line {line})", line)) + + m_ranges2 = [] + if ranges2: + curr_start, curr_end, curr_label, curr_last_line = ranges2[0] + for start, end, label, line in ranges2[1:]: + if line - curr_last_line <= 1000: + curr_end = max(curr_end, end) + curr_label += f" -> T{label.split(' to T')[-1].split()[0]}" + curr_last_line = line + else: + m_ranges2.append((curr_start, curr_end, curr_label)) + curr_start, curr_end, curr_label, curr_last_line = start, end, label, line + m_ranges2.append((curr_start, curr_end, curr_label)) + + res = [] + + # Render Start Warmup + res.append("#### Start Warmup Before Printing:\n") + res.append("| Orca: Line | Orca: Event (Phase) | Orca T0/T1 | BBL: Line | BBL: Event (Phase) | BBL T0/T1 |\n") + res.append("| :---: | :--- | :---: | :---: | :--- | :---: |\n") + + first_start1 = m_ranges1[0][0] if m_ranges1 else len(f1_track) + first_start2 = m_ranges2[0][0] if m_ranges2 else len(f2_track) + + limit = max(first_start1, first_start2) + for i in range(limit): + item1 = f1_track[i] if i < first_start1 else None + item2 = f2_track[i] if i < first_start2 else None + + o_line, o_desc, o_temps = "", "", "" + if item1: + o_line = str(item1[0]) + act1 = interpret_temp_action(item1[4], item1[1]) + o_desc = f"{item1[4]} ({act1})" + o_temps = f"{item1[2]}/{item1[3]}°C" + + b_line, b_desc, b_temps = "", "", "" + if item2: + b_line = str(item2[0]) + act2 = interpret_temp_action(item2[4], item2[1]) + b_desc = f"{item2[4]} ({act2})" + b_temps = f"{item2[2]}/{item2[3]}°C" + + res.append(f"| {o_line} | {o_desc} | {o_temps} | {b_line} | {b_desc} | {b_temps} |\n") + res.append("\n") + + # Render transitions + num_transitions = max(len(m_ranges1), len(m_ranges2)) + for t_idx in range(num_transitions): + r1 = m_ranges1[t_idx] if t_idx < len(m_ranges1) else None + r2 = m_ranges2[t_idx] if t_idx < len(m_ranges2) else None + + label1 = r1[2] if r1 else "No transition" + label2 = r2[2] if r2 else "No transition" + + res.append(f"#### Transition #{t_idx + 1}:\n") + res.append(f"**Orca:** {label1} | **BBL:** {label2}\n\n") + res.append("| Orca: Line | Orca: Event (Phase) | Orca T0/T1 | BBL: Line | BBL: Event (Phase) | BBL T0/T1 |\n") + res.append("| :---: | :--- | :---: | :---: | :--- | :---: |\n") + + events1 = f1_track[r1[0]:r1[1]+1] if r1 else [] + events2 = f2_track[r2[0]:r2[1]+1] if r2 else [] + + max_ev = max(len(events1), len(events2)) + for i in range(max_ev): + item1 = events1[i] if i < len(events1) else None + item2 = events2[i] if i < len(events2) else None + + o_line, o_desc, o_temps = "", "", "" + if item1: + o_line = str(item1[0]) + act1 = interpret_temp_action(item1[4], item1[1]) + o_desc = f"{item1[4]} ({act1})" + o_temps = f"{item1[2]}/{item1[3]}°C" + if item1[4].startswith("T") and "Active Extruder" in item1[4] and not "T100" in item1[4]: + o_line = f"**{o_line}**" + o_desc = f"**{o_desc}**" + o_temps = f"**{o_temps}**" + + b_line, b_desc, b_temps = "", "", "" + if item2: + b_line = str(item2[0]) + act2 = interpret_temp_action(item2[4], item2[1]) + b_desc = f"{item2[4]} ({act2})" + b_temps = f"{item2[2]}/{item2[3]}°C" + if item2[4].startswith("T") and "Active Extruder" in item2[4] and not "T100" in item2[4]: + b_line = f"**{b_line}**" + b_desc = f"**{b_desc}**" + b_temps = f"**{b_temps}**" + + res.append(f"| {o_line} | {o_desc} | {o_temps} | {b_line} | {b_desc} | {b_temps} |\n") + res.append("\n") + + return "".join(res) + + +def get_nozzle_map(filament_maps_str, track): + if not filament_maps_str: + filament_maps_str = "1 1 1 1 1 2" + + extruder_map = {} + for i, v in enumerate(filament_maps_str.split()): + try: + extruder_map[i] = int(v) + except ValueError: + pass + + # H2C physical mapping: Left (Extruder 1) -> Heater 1, Right (Extruder 2) -> Heater 0 + heater_to_ext = {1: 1, 2: 0} + nozzle_map = {} + for fid, ext_id in extruder_map.items(): + nozzle_map[fid] = heater_to_ext.get(ext_id, 1) + + return nozzle_map + + +def analyze_preheat_cooldown_events(track, nozzle_map=None): + if nozzle_map is None: + nozzle_map = {1: 1} # Fallback H2C: T1 -> heater 1, others -> heater 0 + + events = [] + tc_indices = [] + + prev_nozzle = None + for idx, item in enumerate(track): + desc = item[4] + if desc.startswith("T") and "Active Filament" in desc: + first_part = desc.split()[0] + t_num_str = first_part[1:] + if t_num_str.isdigit(): + t_num = int(t_num_str) + if t_num >= 60000: + continue + + norm_nozzle = t_num + if t_num == 1000: + norm_nozzle = 0 + elif t_num == 1001: + norm_nozzle = 1 + + if prev_nozzle is not None and norm_nozzle != prev_nozzle: + target_ext = nozzle_map.get(norm_nozzle, 0) + source_ext = nozzle_map.get(prev_nozzle, 0) + if target_ext != source_ext: + tc_indices.append((idx, item[0], target_ext, source_ext, norm_nozzle)) + prev_nozzle = norm_nozzle + + for i, (tc_idx, tc_line, target_ext, source_ext, nozzle_num) in enumerate(tc_indices): + # 1. Look for preheat command for target_ext before the toolchange + preheat_line = None + preheat_temp = None + + prev_tc_idx = tc_indices[i - 1][0] if i > 0 else 0 + for k in range(tc_idx - 1, prev_tc_idx - 1, -1): + + desc_k = track[k][4] + active_ext_k = track[k][1] + + # Check M104 / M109 + if "M104" in desc_k or "M109" in desc_k: + words = desc_k.split() + s_temp_str = next((w[1:].replace("°C", "") for w in words if w.startswith("S")), None) + t_val = next((w for w in words if w.startswith("T")), None) + + targeted = None + if t_val == "T0": + targeted = 0 + elif t_val == "T1": + targeted = 1 + elif t_val is None: + targeted = nozzle_map.get(active_ext_k, 0) + + if targeted is not None and targeted == target_ext and s_temp_str and s_temp_str.isdigit(): + temp_val = int(s_temp_str) + if temp_val >= 150: # Real preheat threshold + preheat_line = track[k][0] + preheat_temp = temp_val + break # Stop at first (closest to toolchange) + + # Check M620.15 (Vortek pre-cooling/pre-heating commands) + elif "M620.15" in desc_k and target_ext == 1: + words = desc_k.split() + p_temp_str = next((w[1:].replace("°C", "") for w in words if w.startswith("P") and len(w) > 1 and w[1].isdigit()), None) + c_temp_str = next((w[1:].replace("°C", "") for w in words if w.startswith("C") and len(w) > 1 and w[1].isdigit()), None) + + temp_val = None + if p_temp_str and p_temp_str.isdigit(): + temp_val = int(p_temp_str) + elif c_temp_str and c_temp_str.isdigit(): + temp_val = int(c_temp_str) + + if temp_val and temp_val >= 150: + preheat_line = track[k][0] + preheat_temp = temp_val + + cooldown_line = None + cooldown_temp = None + start_look = max(0, tc_idx - 15) + end_look = min(len(track), tc_idx + 40) + + for k in range(start_look, end_look): + desc_k = track[k][4] + active_ext_k = track[k][1] + + if "M104" in desc_k or "M109" in desc_k: + words = desc_k.split() + s_temp_str = next((w[1:].replace("°C", "") for w in words if w.startswith("S")), None) + t_val = next((w for w in words if w.startswith("T")), None) + + targeted = None + if t_val == "T0": + targeted = 0 + elif t_val == "T1": + targeted = 1 + elif t_val is None: + targeted = nozzle_map.get(active_ext_k, 0) + + if targeted == source_ext and s_temp_str and s_temp_str.isdigit(): + temp_val = int(s_temp_str) + if temp_val < 200: + cooldown_line = track[k][0] + cooldown_temp = temp_val + break + + elif "M620.15" in desc_k and source_ext == 1: + words = desc_k.split() + p_temp_str = next((w[1:].replace("°C", "") for w in words if w.startswith("P") and len(w) > 1 and w[1].isdigit()), None) + c_temp_str = next((w[1:].replace("°C", "") for w in words if w.startswith("C") and len(w) > 1 and w[1].isdigit()), None) + + temp_val = None + if p_temp_str and p_temp_str.isdigit(): + temp_val = int(p_temp_str) + elif c_temp_str and c_temp_str.isdigit(): + temp_val = int(c_temp_str) + + if temp_val and temp_val < 200: + cooldown_line = track[k][0] + cooldown_temp = temp_val + break + + events.append({ + "tc_line": tc_line, + "target_ext": target_ext, + "nozzle_num": nozzle_num, + "preheat_line": preheat_line, + "preheat_temp": preheat_temp, + "cooldown_line": cooldown_line, + "cooldown_temp": cooldown_temp + }) + return events + + +def build_comparison_report(f1_name, f1_meta, f1_settings, f1_events, f1_lines, f1_size, f1_stats, + f2_name, f2_meta, f2_settings, f2_events, f2_lines, f2_size, f2_stats): + + now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + slicer1 = get_slicer_name(f1_meta) + slicer2 = get_slicer_name(f2_meta) + + hdr1 = f"File 1 ({slicer1})" + hdr2 = f"File 2 ({slicer2})" + + v1_val = f1_meta["version_info"].get("OrcaSlicer-Version") or f1_meta["version_info"].get("X-BBL-Client-Version") or "Unknown" + v2_val = f2_meta["version_info"].get("OrcaSlicer-Version") or f2_meta["version_info"].get("X-BBL-Client-Version") or "Unknown" + + report = [] + report.append(f"# Slicing G-code Comparison (3MF): {f1_name} vs {f2_name}\n") + report.append(f"**Report Generation Date:** {now_str}\n") + + # 1. Summary table + report.append("## 1. Summary File Statistics\n") + report.append(f"| Metric | {hdr1} | {hdr2} | Difference |\n") + report.append("| :--- | :---: | :---: | :---: |\n") + report.append(f"| **File Name** | `{f1_name}` | `{f2_name}` | — |\n") + report.append(f"| **G-code Size** | {f1_size / (1024*1024):.2f} MB | {f2_size / (1024*1024):.2f} MB | { (f1_size - f2_size) / (1024*1024):+.2f} MB |\n") + report.append(f"| **Total G-code Lines** | {f1_lines:,} | {f2_lines:,} | {f1_lines - f2_lines:+,} |\n") + report.append(f"| **Critical Events** | {len(f1_events):,} | {len(f2_events):,} | {len(f1_events) - len(f2_events):+,} |\n") + + pred1 = float(f1_meta["plate_meta"].get("prediction", 0)) + pred2 = float(f2_meta["plate_meta"].get("prediction", 0)) + report.append(f"| **Print Time** | {int(pred1/60)} min | {int(pred2/60)} min | {int((pred1 - pred2)/60):+} min |\n") + + w1 = float(f1_meta["plate_meta"].get("weight", 0)) + w2 = float(f2_meta["plate_meta"].get("weight", 0)) + report.append(f"| **Total Part Weight** | {w1:.2f} g | {w2:.2f} g | {w1 - w2:+.2f} g |\n") + + fl1 = float(f1_meta["plate_meta"].get("first_layer_time", 0)) + fl2 = float(f2_meta["plate_meta"].get("first_layer_time", 0)) + report.append(f"| **First Layer Time** | {fl1/60:.2f} min | {fl2/60:.2f} min | {(fl1 - fl2)/60:+.2f} min |\n") + + report.append(f"| **Slicer Version** | {slicer1} `{v1_val}` | {slicer2} `{v2_val}` | — |\n") + + m1 = f1_meta["plate_meta"].get("printer_model_id", "Unknown") + m2 = f2_meta["plate_meta"].get("printer_model_id", "Unknown") + report.append(f"| **Printer Model ID** | `{m1}` | `{m2}` | — |\n") + + fmap1 = f1_meta["plate_meta"].get("filament_maps", "None") + fmap2 = f2_meta["plate_meta"].get("filament_maps", "None") + report.append(f"| **filament_maps Map** | `{fmap1}` | `{fmap2}` | — |\n") + + dynamic1 = f1_meta["plate_meta"].get("enable_filament_dynamic_map", "Unknown") + dynamic2 = f2_meta["plate_meta"].get("enable_filament_dynamic_map", "Unknown") + report.append(f"| **Dynamic Mapping** | `{dynamic1}` | `{dynamic2}` | — |\n") + + switcher1 = f1_meta["plate_meta"].get("has_filament_switcher", "Unknown") + switcher2 = f2_meta["plate_meta"].get("has_filament_switcher", "Unknown") + report.append(f"| **Nozzle Switcher** | `{switcher1}` | `{switcher2}` | — |\n") + + ens1 = f1_settings.get("extruder_nozzle_stats", "None") + ens2 = f2_settings.get("extruder_nozzle_stats", "None") + report.append(f"| **Nozzle Stats (extruder_nozzle_stats)** | `{escape_markdown_table(ens1)}` | `{escape_markdown_table(ens2)}` | — |\n") + + fnm1 = f1_settings.get("filament_nozzle_map", "None") + fnm2 = f2_settings.get("filament_nozzle_map", "None") + report.append(f"| **Nozzle Map (filament_nozzle_map)** | `{escape_markdown_table(fnm1)}` | `{escape_markdown_table(fnm2)}` | — |\n") + + fvm1 = f1_settings.get("filament_volume_map", "None") + fvm2 = f2_settings.get("filament_volume_map", "None") + report.append(f"| **Volume Maps (filament_volume_map)** | `{escape_markdown_table(fvm1)}` | `{escape_markdown_table(fvm2)}` | — |\n") + report.append("\n") + + # 2. Preheat and Standby analysis table + report.append("## 2. Preheat and Standby Cooldown Analysis\n") + report.append("The table matches preheating events of the target nozzle before change with the cooldown of the inactive nozzle.\n") + report.append("Lead (lines) shows how many G-code lines before physical toolchange `T` the printer sends `M104/M109` heating command.\n\n") + report.append(f"| Change # | Target Extruder | {slicer1}: Preheat | {slicer1}: Cooldown | {slicer2}: Preheat | {slicer2}: Cooldown |\n") + report.append("| :---: | :---: | :--- | :--- | :--- | :--- |\n") + + f1_nozzle_map = get_nozzle_map(f1_meta["plate_meta"].get("filament_maps"), f1_stats["temp_track"]) + f2_nozzle_map = get_nozzle_map(f2_meta["plate_meta"].get("filament_maps"), f2_stats["temp_track"]) + f1_preheat = analyze_preheat_cooldown_events(f1_stats["temp_track"], f1_nozzle_map) + f2_preheat = analyze_preheat_cooldown_events(f2_stats["temp_track"], f2_nozzle_map) + + max_preheat = max(len(f1_preheat), len(f2_preheat)) + for idx in range(max_preheat): + p1 = f1_preheat[idx] if idx < len(f1_preheat) else None + p2 = f2_preheat[idx] if idx < len(f2_preheat) else None + + target_str = f"T{p1['nozzle_num']}" if p1 else (f"T{p2['nozzle_num']}" if p2 else "") + + o_preheat_str = "—" + if p1 and p1["preheat_temp"] is not None: + lead = p1["tc_line"] - p1["preheat_line"] + o_preheat_str = f"{p1['preheat_temp']}°C ({lead} lines lead)" + + o_cooldown_str = "—" + if p1 and p1["cooldown_temp"] is not None: + o_cooldown_str = f"{p1['cooldown_temp']}°C" + + b_preheat_str = "—" + if p2 and p2["preheat_temp"] is not None: + lead = p2["tc_line"] - p2["preheat_line"] + b_preheat_str = f"{p2['preheat_temp']}°C ({lead} lines lead)" + + b_cooldown_str = "—" + if p2 and p2["cooldown_temp"] is not None: + b_cooldown_str = f"{p2['cooldown_temp']}°C" + + report.append(f"| {idx+1} | `{target_str}` | {o_preheat_str} | {o_cooldown_str} | {b_preheat_str} | {b_cooldown_str} |\n") + report.append("\n") + + # 3. Nozzle to Extruder mappings + report.append("## 3. Nozzle and Extruder Mapping (Vortek Nozzles)\n") + report.append(f"**{slicer1} `extruder_nozzle_stats`:** `{ens1}`\n\n") + report.append(f"**{slicer2} `extruder_nozzle_stats`:** `{ens2}`\n\n") + + report.append(f"### {hdr1}:\n") + if f1_meta["nozzles"]: + report.append("| Nozzle ID | Extruder ID | Diameter |\n| :---: | :---: | :---: |\n") + for noz in f1_meta["nozzles"]: + report.append(f"| {noz['id']} | {noz['extruder_id']} | {noz['nozzle_diameter']} |\n") + else: + report.append("*Nozzle mapping is not present in metadata*\n") + report.append("\n") + + report.append(f"### {hdr2}:\n") + if f2_meta["nozzles"]: + report.append("| Nozzle ID | Extruder ID | Diameter |\n| :---: | :---: | :---: |\n") + for noz in f2_meta["nozzles"]: + report.append(f"| {noz['id']} | {noz['extruder_id']} | {noz['nozzle_diameter']} |\n") + else: + report.append("*Nozzle mapping is not present in metadata*\n") + report.append("\n") + + # 4. Filaments used + report.append("## 4. Filaments Used\n") + report.append(f"### {hdr1}:\n") + report.append("| ID | Type | Color | Weight (g) | Length (m) | Nozzle Dia |\n| :---: | :--- | :---: | :---: | :---: | :---: |\n") + for fil in f1_meta["filaments"]: + c = fil["color"] + used_m = f"{float(fil.get('used_m', 0)):.2f} m" if fil.get("used_m") else "—" + report.append(f"| {fil['id']} | {fil['type']} | `{c}` | {fil['used_g']} g | {used_m} | {fil['nozzle_diameter']} |\n") + report.append("\n") + + report.append(f"### {hdr2}:\n") + report.append("| ID | Type | Color | Weight (g) | Length (m) | Nozzle Dia |\n| :---: | :--- | :---: | :---: | :---: | :---: |\n") + for fil in f2_meta["filaments"]: + c = fil["color"] + used_m = f"{float(fil.get('used_m', 0)):.2f} m" if fil.get("used_m") else "—" + report.append(f"| {fil['id']} | {fil['type']} | `{c}` | {fil['used_g']} g | {used_m} | {fil['nozzle_diameter']} |\n") + report.append("\n") + + # 5. Settings differences + report.append("## 5. Differences in Key Slicing Settings (project_settings.config)\n") + + interesting_keys = { + "extruder_nozzle_stats", + "filament_nozzle_map", + "filament_volume_map", + "physical_extruder_map", + "master_extruder_id", + "extruder_max_nozzle_count", + "has_filament_switcher", + "enable_filament_dynamic_map", + "filament_settings_id", + "filament_type", + "machine_load_filament_time", + "machine_unload_filament_time", + "enable_prime_tower", + "prime_tower_width", + "prime_tower_brim_width", + "wipe_tower_size", + "wipe_tower_width", + "filament_pre_cooling_temperature", + "filament_pre_cooling_temperature_nc", + "filament_retract_length_nc", + "filament_retract_speed_nc", + "filament_deretract_speed_nc", + "layer_height", + "initial_layer_print_height", + "wall_loops", + "sparse_infill_density", + "sparse_infill_pattern", + "bridge_flow_ratio", + "flush_volumes_matrix", + "flush_multiplier", + } + + diff_settings = {} + for key in sorted(interesting_keys): + v1 = f1_settings.get(key) + v2 = f2_settings.get(key) + if v1 != v2: + diff_settings[key] = (v1, v2) + + if diff_settings: + report.append(f"| Setting Key | Value in {slicer1} | Value in {slicer2} |\n") + report.append("| :--- | :--- | :--- |\n") + for key, (v_orca, v_bbl) in diff_settings.items(): + report.append(f"| `{key}` | `{escape_markdown_table(v_orca)}` | `{escape_markdown_table(v_bbl)}` |\n") + else: + report.append("*No significant differences in key slicing settings found.*\n") + report.append("\n") + + # 6. Deep Vortek & Print Logic Analysis + report.append("## 6. Detailed Vortek Logic and Print Parameters Analysis\n") + report.append("### A. Nozzle Changes and Tool Change Operations\n") + report.append(f"| Parameter | {hdr1} | {hdr2} |\n") + report.append("| :--- | :---: | :---: |\n") + report.append(f"| **Total nozzle/extruder changes (T)** | {f1_stats['toolchange_count']} | {f2_stats['toolchange_count']} |\n") + seq1 = " -> ".join(f1_stats['toolchange_sequence'][:12]) + ("..." if len(f1_stats['toolchange_sequence']) > 12 else "") + seq2 = " -> ".join(f2_stats['toolchange_sequence'][:12]) + ("..." if len(f2_stats['toolchange_sequence']) > 12 else "") + report.append(f"| **Change Sequence (first 12)** | `{seq1}` | `{seq2}` |\n") + report.append("\n") + report.append("### B. Prime Tower Comparison\n") + report.append(f"| Parameter | {hdr1} | {hdr2} |\n") + report.append("| :--- | :--- | :---: |\n") + report.append(f"| **Total Prime Tower Entries (M628 S1)** | {f1_stats['prime_tower_blocks']} | {f2_stats['prime_tower_blocks']} |\n") + report.append(f"| **Total G-code Lines Inside Tower** | {f1_stats['prime_tower_lines']:,} | {f2_stats['prime_tower_lines']:,} |\n") + report.append("\n") + report.append("### C. Retract Parameters During Nozzle Switch (M620.11)\n") + report.append(f"| {hdr1} | {hdr2} |\n") + report.append("| :--- | :--- |\n") + max_len = max(len(f1_stats['m620_11_retracts']), len(f2_stats['m620_11_retracts'])) + for i in range(max_len): + r1 = f1_stats['m620_11_retracts'][i] if i < len(f1_stats['m620_11_retracts']) else "" + r2 = f2_stats['m620_11_retracts'][i] if i < len(f2_stats['m620_11_retracts']) else "" + report.append(f"| `{r1}` | `{r2}` |\n") + report.append("\n") + + # Toolchange G-code Blocks Diff + report.append("### D. Change Filament G-code Blocks Analysis (change_filament_gcode)\n") + max_tc_blocks = max(len(f1_stats['toolchange_blocks']), len(f2_stats['toolchange_blocks'])) + diffs_count = 0 + diff_summary = [] + + def clean_gcode_lines(lines_list): + res = [] + for line in lines_list: + if line.startswith("Line "): + parts = line.split(": ", 1) + if len(parts) == 2: + res.append(parts[1]) + else: + res.append(line) + else: + res.append(line) + return res + + for b_idx in range(max_tc_blocks): + b1 = f1_stats['toolchange_blocks'][b_idx].splitlines() if b_idx < len(f1_stats['toolchange_blocks']) else [] + b2 = f2_stats['toolchange_blocks'][b_idx].splitlines() if b_idx < len(f2_stats['toolchange_blocks']) else [] + + b1_clean = clean_gcode_lines(b1) + b2_clean = clean_gcode_lines(b2) + + tc_diff = list(difflib.unified_diff(b1_clean, b2_clean, lineterm="")) + if tc_diff: + diffs_count += 1 + changed_lines = [l for l in tc_diff if l.startswith('+') or l.startswith('-')] + changed_lines = [l for l in changed_lines if not l.startswith('+++') and not l.startswith('---')] + commands_changed = set() + for cl in changed_lines: + cmd = cl[1:].strip().split()[0] if cl[1:].strip() else "" + if cmd: + commands_changed.add(cmd) + cmds_str = ", ".join(sorted(list(commands_changed))) + diff_summary.append(f"- **Change #{b_idx + 1}**: differences found ({len(changed_lines)} modified lines, commands: `{cmds_str}`)\n") + + if diffs_count == 0: + report.append("> [!NOTE]\n> All change filament G-code blocks (`change_filament_gcode`) are identical!\n\n") + else: + report.append(f"Filament change G-code blocks difference summary (total changes: {max_tc_blocks}, differing: {diffs_count}, identical: {max_tc_blocks - diffs_count}):\n") + limit = 10 + for item in diff_summary[:limit]: + report.append(item) + if len(diff_summary) > limit: + report.append(f"- ... and {len(diff_summary) - limit} more differing changes ...\n") + report.append("\n") + + # G-code critical events diff + report.append("## 6. Differences in G-code Control Commands (Toolchange / Nozzle Changer / Temp)\n") + + def clean_events(events_list): + res = [] + for ev in events_list: + if ev.startswith("Line "): + parts = ev.split(": ", 1) + if len(parts) == 2: + res.append(parts[1]) + else: + res.append(ev) + else: + res.append(ev) + return res + + events1_clean = clean_events(f1_events) + events2_clean = clean_events(f2_events) + + diff = difflib.unified_diff( + events1_clean, + events2_clean, + fromfile=slicer1, + tofile=slicer2, + n=2, + lineterm="" + ) + + diff_lines = list(diff) + if diff_lines: + diff_lines_clean = [dl for dl in diff_lines if not dl.startswith('---') and not dl.startswith('+++') and not dl.startswith('@@')] + report.append(f"Detected {len(diff_lines_clean)} lines of differences in control commands timeline.\n") + report.append("Showing key differences (max 12 lines):\n") + report.append("```diff\n") + for dline in diff_lines_clean[:12]: + report.append(f"{dline}\n") + if len(diff_lines_clean) > 12: + report.append(f"... and {len(diff_lines_clean) - 12} more difference lines ...\n") + report.append("```\n") + else: + report.append("> [!NOTE]\n") + report.append("> G-code control commands timeline is identical!\n") + + # Critical Discrepancies Analyzer + report.append("\n## 7. Critical Discrepancies and Errors Analysis (Analytics)\n") + discrepancies = analyze_critical_discrepancies(f1_meta, f2_meta, f1_settings, f2_settings, f1_stats, f2_stats) + if discrepancies: + report.append("| Status | Problem Description |\n") + report.append("| :--- | :--- |\n") + for item in discrepancies: + level_str = f"**{item['level']}**" + if "ERROR" in item["level"]: + level_str = f"❌ {item['level']}" + elif "DISCREPANCY" in item["level"]: + level_str = f"⚠️ {item['level']}" + else: + level_str = f"ℹ️ {item['level']}" + report.append(f"| {level_str} | {item['message']} |\n") + else: + report.append("> [!NOTE]\n> No critical discrepancies or slicing logic errors found.\n") + return "".join(report) + +def main(): + if len(sys.argv) < 3: + print("Usage: python3 compare_slices.py ") + sys.exit(1) + + f1_input = sys.argv[1] + f2_input = sys.argv[2] + + f1_path = find_file(f1_input) + f2_path = find_file(f2_input) + + if not f1_path: + print(f"Error: File '{f1_input}' not found on Desktop or local path.") + sys.exit(1) + if not f2_path: + print(f"Error: File '{f2_input}' not found on Desktop or local path.") + sys.exit(1) + + print(f"Analyzing File 1: {f1_path}") + with zipfile.ZipFile(f1_path, "r") as z1: + f1_meta = parse_metadata(z1) + f1_settings = parse_project_settings(z1) + f1_maps_str = f1_meta["plate_meta"].get("filament_maps") + f1_events, f1_lines, f1_size, f1_stats = parse_critical_gcode(z1, f1_maps_str) + + print(f"Analyzing File 2: {f2_path}") + with zipfile.ZipFile(f2_path, "r") as z2: + f2_meta = parse_metadata(z2) + f2_settings = parse_project_settings(z2) + f2_maps_str = f2_meta["plate_meta"].get("filament_maps") + f2_events, f2_lines, f2_size, f2_stats = parse_critical_gcode(z2, f2_maps_str) + + f1_basename = os.path.basename(f1_path) + f2_basename = os.path.basename(f2_path) + + report_content = build_comparison_report( + f1_basename, f1_meta, f1_settings, f1_events, f1_lines, f1_size, f1_stats, + f2_basename, f2_meta, f2_settings, f2_events, f2_lines, f2_size, f2_stats + ) + + os.makedirs(REPORTS_DIR, exist_ok=True) + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + report_name = f"compare_report_{timestamp}.md" + report_path = os.path.join(REPORTS_DIR, report_name) + + with open(report_path, "w", encoding="utf-8") as f: + f.write(report_content) + + print("\n" + "="*50) + print(f"Comparison completed successfully!") + print(f"Report saved to: {report_path}") + print("="*50) + +if __name__ == "__main__": + main() diff --git a/tests/compare_analyzer/show_temp_plot.py b/tests/compare_analyzer/show_temp_plot.py new file mode 100755 index 0000000000..f70167b361 --- /dev/null +++ b/tests/compare_analyzer/show_temp_plot.py @@ -0,0 +1,1545 @@ +#!/usr/bin/env python3 +""" +H2C Temperature Timeline Comparison Tool. +Generates interactive HTML with temperature plots for analyzing and comparing OrcaSlicer vs BambuStudio G-code. + +================================================================================ +ARCHITECTURE & MAPPING LOGIC: +================================================================================ +- H2C configuration utilizes a dual-extruder layout with 4 Vortek nozzles. +- Physical heaters are mapped as: + - Heater 0: Extruder 2 (physical RIGHT nozzle slot, T0/T2/T3/T4) + - Heater 1: Extruder 1 (physical LEFT nozzle slot, T1) +- The active heater mapping is derived dynamically based on active G-code temperature signals: + - When filament X is active and heater N is heated to printing temperature (>200°C), + we associate filament X with heater N. + +================================================================================ +EXECUTION & RUN RULES: +================================================================================ +1. Single File Analysis: + Generates a temperature profile layout for a single 3MF file. + Usage: + python3 show_temp_plot.py + +2. Comparison Mode (Two Files): + Renders a side-by-side alignment of temperature panels for both files (e.g. OrcaSlicer vs BambuStudio). + Usage: + python3 show_temp_plot.py + +Output: +- The script automatically outputs the compiled interactive HTML report to: + ~/Desktop/temp_plot.html +- Opens the HTML report in the system's default web browser immediately. +""" +import sys +import os +import zipfile +import json +import xml.etree.ElementTree as ET +import webbrowser + +sys.path.append(os.path.dirname(os.path.abspath(__file__))) +try: + import compare_slices +except ImportError: + sys.path.append("/Users/denn/Develop/3dprint/dehancer lab/H2C_v2/scripts") + import compare_slices + + +def parse_filaments_and_colors(zip_file): + """Parse filament colors and types from Metadata/slice_info.config inside 3MF.""" + filaments = {} + try: + xml_data = zip_file.read("Metadata/slice_info.config") + root = ET.fromstring(xml_data) + plate = root.find("plate") + if plate is not None: + for fil in plate.findall("filament"): + fid = int(fil.attrib.get("id")) # 1-based in XML + color = fil.attrib.get("color") + ftype = fil.attrib.get("type", "PLA") + filaments[fid - 1] = {"color": color, "type": ftype} + except Exception as e: + print(f"Error parsing filaments: {e}") + return filaments + + +def parse_nozzle_groups(zip_file): + """ + Parse filament-to-extruder mapping from filament_maps in slice_info.config. + + filament_maps = "2 1 2 2 2" → {0: 2, 1: 1, 2: 2, 3: 2, 4: 2} + + Returns: + extruder_map: {filament_id_0based: extruder_id} + extruder_groups: {extruder_id: [list of filament IDs 0-based]} + """ + extruder_map = {} + + try: + xml_data = zip_file.read("Metadata/slice_info.config").decode("utf-8", errors="replace") + root = ET.fromstring(xml_data) + plate = root.find("plate") + if plate is not None: + for meta in plate.findall("metadata"): + if meta.attrib.get("key") == "filament_maps": + raw = meta.attrib.get("value", "").strip() + if raw: + for i, v in enumerate(raw.split()): + extruder_map[i] = int(v) + break + except Exception as e: + print(f" Warning: could not parse filament_maps: {e}") + + extruder_groups = {} + for fid, eid in extruder_map.items(): + extruder_groups.setdefault(eid, []).append(fid) + + return extruder_map, extruder_groups + + +def determine_heater_to_extruder(track, extruder_map): + # H2C physical mapping: Left (Extruder 1) -> Heater 1, Right (Extruder 2) -> Heater 0 + return {1: 1, 2: 0} + + +def build_timeline_and_interpolate(track, tool_changes, m73_points, total_lines, m400_weights=None, + gcode_lines=None): + """Build a line→time mapping from physical G1 motion time estimation. + + When *gcode_lines* is provided (list of raw gcode strings, 0-indexed), + the timeline is computed from actual feedrates + M400 delays, giving a + physically accurate time axis independent of M73 granularity. + Falls back to M73 interpolation when gcode_lines is not available. + """ + import re as _re + import math as _math + + if m400_weights is None: + m400_weights = {} + + # ── Physical timeline from G1 moves ────────────────────────────────── + if gcode_lines is not None: + cumulative = [0.0] * (len(gcode_lines) + 2) # 1-indexed + cur_x, cur_y, cur_z = 0.0, 0.0, 0.0 + cur_f = 1800.0 # mm/min default + t = 0.0 + + for i, raw in enumerate(gcode_lines): + line_num = i + 1 + gl = raw.strip() + + # M400 S/P delays + ms = _re.match(r'^M400\s+S([\d.]+)', gl) + mp = _re.match(r'^M400\s+P([\d.]+)', gl) + if ms: + t += float(ms.group(1)) + elif mp: + t += float(mp.group(1)) / 1000.0 + + # G1 moves + if gl.startswith('G1 '): + fm = _re.search(r'F([\d.]+)', gl) + if fm: + cur_f = float(fm.group(1)) + + xm = _re.search(r'X([-\d.]+)', gl) + ym = _re.search(r'Y([-\d.]+)', gl) + zm = _re.search(r'Z([-\d.]+)', gl) + + nx = float(xm.group(1)) if xm else cur_x + ny = float(ym.group(1)) if ym else cur_y + nz = float(zm.group(1)) if zm else cur_z + + dist = _math.sqrt((nx - cur_x)**2 + (ny - cur_y)**2 + (nz - cur_z)**2) + if dist > 0.001 and cur_f > 0: + t += dist / (cur_f / 60.0) # F is mm/min → mm/s + + cur_x, cur_y, cur_z = nx, ny, nz + + if line_num < len(cumulative): + cumulative[line_num] = t + + # Fill any remaining slots + for j in range(line_num + 1, len(cumulative)): + cumulative[j] = t + + total_duration = t + + # ── Scale physical timeline to match M73 trapezoid estimate ────── + # Physical dist/speed ignores acceleration/deceleration, giving an + # underestimated total (e.g. 29 min vs real 48 min). M73 from the + # trapezoid planner accounts for accel/decel and is closer to reality. + # We scale only the G1 motion component; M400 delays are physical + # waits and must not be inflated. + if m73_points and total_duration > 0: + m73_total_sec = max(p[1] for p in m73_points) * 60 + + # Compute total M400 delay time + m400_total = 0.0 + for raw in gcode_lines: + gl = raw.strip() + ms = _re.match(r'^M400\s+S([\d.]+)', gl) + mp = _re.match(r'^M400\s+P([\d.]+)', gl) + if ms: + m400_total += float(ms.group(1)) + elif mp: + m400_total += float(mp.group(1)) / 1000.0 + + g1_time = total_duration - m400_total + m73_g1_time = m73_total_sec - m400_total + + if g1_time > 0 and m73_g1_time > g1_time: + scale = m73_g1_time / g1_time + # Re-scale: for each line, separate M400-contributed time + # from G1-contributed time, scale only G1 part. + # Since M400 delays are sparse and cumulative is monotonic, + # we rebuild by scaling the G1 increments. + m400_lines = set() + for i, raw in enumerate(gcode_lines): + gl = raw.strip() + if _re.match(r'^M400\s+[SP]', gl): + m400_lines.add(i + 1) # 1-indexed + + prev = 0.0 + new_t = 0.0 + for j in range(1, len(cumulative)): + delta = cumulative[j] - prev + prev = cumulative[j] + if j in m400_lines: + new_t += delta # M400: no scale + else: + new_t += delta * scale # G1: scale + cumulative[j] = new_t + + total_duration = cumulative[-1] + + def get_time(line): + idx = max(1, min(int(round(line)), len(cumulative) - 1)) + return cumulative[idx] + + for tr in track: + tr["time"] = get_time(tr["line"]) + for tc in tool_changes: + tc["time"] = get_time(tc["line"]) + + return total_duration, get_time + + # ── Fallback: M73-based interpolation (original logic) ─────────────── + m73_points = sorted(list(set([(p[0], p[1]) for p in m73_points]))) + filtered_m73 = [] + seen_times = set() + for line, r_val in m73_points: + if r_val not in seen_times: + filtered_m73.append((line, r_val)) + seen_times.add(r_val) + m73_points = filtered_m73 + timeline = [] + if m73_points: + max_R = m73_points[0][1] + for _, r in m73_points[:5]: + if r > max_R: + max_R = r + for line, r in m73_points: + elapsed_sec = (max_R - r) * 60 + timeline.append((line, elapsed_sec)) + if timeline[0][0] > 1: + timeline.insert(0, (1, 0)) + if timeline[-1][0] < total_lines: + timeline.append((total_lines, max_R * 60)) + else: + timeline = [(1, 0), (total_lines, total_lines * 0.02)] + + line_to_time = {} + # Build continuous M620 ranges from sparse track samples. + m620_ranges = [] + m620_start = None + for t in track: + if t.get("in_m620", False): + if m620_start is None: + m620_start = t["line"] + else: + if m620_start is not None: + m620_ranges.append((m620_start, t["line"])) + m620_start = None + if m620_start is not None: + m620_ranges.append((m620_start, track[-1]["line"] if track else m620_start)) + + def is_in_m620(l): + for rs, re_ in m620_ranges: + if rs <= l <= re_: + return True + return False + + for k in range(len(timeline) - 1): + line1, time1 = timeline[k] + line2, time2 = timeline[k + 1] + + dT = time2 - time1 + dL = line2 - line1 + if dL <= 0: + continue + + weights = [] + total_w = 0.0 + for l in range(line1, line2 + 1): + is_tc = is_in_m620(l) + w = 500.0 if is_tc else 1.0 + if l in m400_weights: + w += m400_weights[l] * 50.0 + weights.append((l, w)) + total_w += w + + current_t = time1 + if total_w == 0: + total_w = 1.0 + + for l, w in weights: + line_to_time[l] = current_t + current_t += (w / total_w) * dT + + def get_time(line): + l_round = int(round(line)) + if l_round in line_to_time: + return line_to_time[l_round] + + if line <= timeline[0][0]: + return timeline[0][1] + if line >= timeline[-1][0]: + return timeline[-1][1] + for k in range(len(timeline) - 1): + pt1 = timeline[k] + pt2 = timeline[k + 1] + if pt1[0] <= line <= pt2[0]: + if pt2[0] == pt1[0]: + return pt1[1] + return pt1[1] + (line - pt1[0]) / (pt2[0] - pt1[0]) * (pt2[1] - pt1[1]) + return 0 + + for t in track: + t["time"] = get_time(t["line"]) + for tc in tool_changes: + tc["time"] = get_time(tc["line"]) + + total_duration = timeline[-1][1] + return total_duration, get_time + + +def parse_file_data(filepath): + if not filepath or not os.path.exists(filepath): + return None + try: + with zipfile.ZipFile(filepath) as z: + filaments = parse_filaments_and_colors(z) + extruder_map, extruder_groups = parse_nozzle_groups(z) + _, total_lines, _, stats = compare_slices.parse_critical_gcode(z) + track_raw = stats["temp_track"] + m73_points = stats.get("m73_points", []) + + # Find the start of machine end gcode to cut off non-printing trailing commands (e.g. air filtration wait) + end_gcode_line = total_lines + raw_gcode_lines = None + for name in z.namelist(): + if name.endswith('.gcode'): + gcode_text = z.read(name).decode('utf-8', errors='replace') + gcode_lines = gcode_text.split('\n') + for i in range(len(gcode_lines) - 1, -1, -1): + gl = gcode_lines[i] + line_num = i + 1 + if ';' in gl and '=' not in gl: + if 'MACHINE_END_GCODE_START' in gl or 'filament end gcode' in gl or 'machine: H2C end' in gl: + end_gcode_line = line_num + break + raw_gcode_lines = gcode_lines + break + + # Keep end_gcode_line but do not trim data arrays to preserve full time duration + pass + + # Parse TC, Wipe Tower, Toolchange zones and M400 weights from raw gcode + tc_zones_raw = [] # list of (start_line, end_line) + wipe_zones_raw = [] # list of (start_line, end_line) + toolchange_zones_raw = [] # list of (start_line, end_line) + m400_weights = {} + for name in z.namelist(): + if name.endswith('.gcode'): + gcode_text = z.read(name).decode('utf-8', errors='replace') + gcode_lines = gcode_text.split('\n') + nc_start = None + wipe_start = None + tc_block_start = None + for i, gl in enumerate(gcode_lines): + line_num = i + 1 + # Parse M400 delay commands + if 'M400' in gl: + import re + m_s = re.search(r'M400\s+S(\d+)', gl) + if m_s: + m400_weights[line_num] = float(m_s.group(1)) + else: + m_p = re.search(r'M400\s+P(\d+)', gl) + if m_p: + m400_weights[line_num] = float(m_p.group(1)) * 0.001 + # Match both Orca and BBS toolchange start comments + if 'CP TOOLCHANGE START' in gl: + tc_block_start = line_num + elif 'CP TOOLCHANGE END' in gl: + if tc_block_start is not None: + toolchange_zones_raw.append((tc_block_start, line_num)) + tc_block_start = None + + # Match BBS (; NOZZLE_CHANGE_START) and Orca (; Nozzle change start/end) + if 'NOZZLE_CHANGE_START' in gl or 'Nozzle change start' in gl: + nc_start = line_num + elif 'NOZZLE_CHANGE_END' in gl or 'Nozzle change end' in gl: + if nc_start is not None: + tc_zones_raw.append((nc_start, line_num)) + nc_start = None + + # Fallback: detect M632 M N / M633 as carousel nozzle change zones + # (Orca doesn't always emit ; Nozzle change start/end around M632/M633) + if gl.startswith('M632') and ' M ' in gl and nc_start is None: + nc_start = line_num + elif gl.startswith('M633') and nc_start is not None: + tc_zones_raw.append((nc_start, line_num)) + nc_start = None + # Match both Orca (; CP TOOLCHANGE WIPE) and BBS (; CP_TOOLCHANGE_WIPE) + elif 'CP TOOLCHANGE WIPE' in gl or 'CP_TOOLCHANGE_WIPE' in gl: + wipe_start = line_num + elif '; CP TOOLCHANGE END' in gl: + if wipe_start is not None: + wipe_zones_raw.append((wipe_start, line_num)) + wipe_start = None + break + + # Extract preheat events in raw G-code lines format using dynamic nozzle_map + filament_maps_str = " ".join([str(extruder_map[i]) for i in sorted(extruder_map.keys())]) + nozzle_map_for_preheat = compare_slices.get_nozzle_map(filament_maps_str, track_raw) + raw_preheats = compare_slices.analyze_preheat_cooldown_events(track_raw, nozzle_map_for_preheat) + + track = [] + tool_changes = [] + current_tool = 0 + for line, active_ext, t0, t1, desc, in_m620_block, printing_started in track_raw: + if desc.startswith("T") and "Active Filament" in desc: + parts = desc.split() + t_name = parts[0] + if t_name[1:].isdigit(): + t_val = int(t_name[1:]) + if t_val < 60000: + current_tool = t_val + + track.append({ + "line": line, + "active": current_tool, + "t0": t0, + "t1": t1, + "desc": desc, + "in_m620": in_m620_block, + "printing_started": printing_started + }) + if desc.startswith("T") and "Active Filament" in desc: + parts = desc.split() + t_name = parts[0] + t_idx = -1 + if t_name[1:].isdigit(): + t_idx = int(t_name[1:]) + tool_changes.append({ + "line": line, + "name": t_name, + "idx": t_idx, + "desc": desc, + }) + + total_duration, get_time = build_timeline_and_interpolate(track, tool_changes, m73_points, total_lines, m400_weights, + gcode_lines=raw_gcode_lines) + end_gcode_time = get_time(end_gcode_line) + + # Convert preheat lines to time (seconds) + preheats = [] + for ev in raw_preheats: + if ev["preheat_line"] is not None and ev["tc_line"] is not None: + start_t = get_time(ev["preheat_line"]) + end_t = get_time(ev["tc_line"]) + preheats.append({ + "start_time": start_t, + "end_time": end_t, + "heater": ev["target_ext"], # 0 or 1 + "target_temp": ev["preheat_temp"], + "nozzle_num": ev["nozzle_num"] + }) + + # Convert precool (cooldown) lines to time (seconds) + # Precool = M104 S on the DEPARTING nozzle before tool change + # Filter: skip S0 (heater off), negative durations, and very short zones (<2s) + precools = [] + for ev in raw_preheats: + if ev.get("cooldown_line") is not None and ev["tc_line"] is not None and ev.get("cooldown_temp") is not None: + if ev["cooldown_temp"] <= 0: # S0 = heater off, not a real precool + continue + start_t = get_time(ev["cooldown_line"]) + end_t = get_time(ev["tc_line"]) + dur = end_t - start_t + if dur < 2.0: # Too short or negative — not a visible precool zone + continue + # source_ext = the extruder that is LEAVING (opposite of target_ext) + source_heater = 1 - ev["target_ext"] if ev["target_ext"] in (0, 1) else 0 + precools.append({ + "start_time": start_t, + "end_time": end_t, + "heater": source_heater, + "target_temp": ev["cooldown_temp"], + "nozzle_num": ev.get("nozzle_num", -1) + }) + + heater_to_ext = determine_heater_to_extruder(track, extruder_map) + + nozzle_map = {} + for fid, ext_id in extruder_map.items(): + nozzle_map[fid] = heater_to_ext.get(ext_id, 0) + + # Convert TC/Wipe zones to time coordinates. + # M73 has 1-minute resolution, so TC zones (which last ~20-30s) often map + # to identical times. When that happens, estimate width from line count ratio. + def zone_to_time(start_line, end_line, min_dur=8.0): + t_start = get_time(start_line) + t_end = get_time(end_line) + actual_dur = t_end - t_start + if actual_dur < min_dur: + # M73 has 1-minute resolution and BBS TC is only ~7 lines, + # so interpolated duration can be <1s. Use line-based estimate. + estimated_dur = max(min_dur, (end_line - start_line) * 0.15) + t_end = t_start + estimated_dur + return {"start_time": t_start, "end_time": t_end} + + # Sequence nozzle changes (tc_zones) and wipe tower blocks (wipe_zones) sequentially + # to prevent visual overlaps caused by artificial duration extension. + all_sub_zones = [] + for s, e in tc_zones_raw: + all_sub_zones.append({"type": "nc", "start_line": s, "end_line": e}) + for s, e in wipe_zones_raw: + all_sub_zones.append({"type": "wipe", "start_line": s, "end_line": e}) + + all_sub_zones.sort(key=lambda z: z["start_line"]) + + tc_zones = [] + wipe_zones = [] + prev_end_time = -1.0 + min_dur = 8.0 + + for zone in all_sub_zones: + s_line = zone["start_line"] + e_line = zone["end_line"] + t_start = get_time(s_line) + t_end = get_time(e_line) + + actual_dur = t_end - t_start + dur = actual_dur + if actual_dur < min_dur: + dur = max(min_dur, (e_line - s_line) * 0.15) + + if t_start < prev_end_time: + t_start = prev_end_time + + t_end = t_start + dur + prev_end_time = t_end + + formatted_zone = {"start_time": t_start, "end_time": t_end} + if zone["type"] == "nc": + tc_zones.append(formatted_zone) + else: + wipe_zones.append(formatted_zone) + + toolchange_zones = [zone_to_time(s, e) for s, e in toolchange_zones_raw] + + + slicer_name = "OrcaSlicer" if "orca" in os.path.basename(filepath).lower() else ( + "BambuStudio" if "bbl" in os.path.basename(filepath).lower() else "Slicer" + ) + + print(f" Extruder groups: {extruder_groups}") + print(f" Heater→Extruder: {heater_to_ext}") + for fid, heater in sorted(nozzle_map.items()): + ext_id = extruder_map.get(fid, "?") + fil_info = filaments.get(fid, {}) + print(f" T{fid} ({fil_info.get('type','?')} {fil_info.get('color','?')}) → Extruder {ext_id} → Heater {heater}") + + cooldown_count = sum(1 for ev in raw_preheats if ev.get("cooldown_temp") is not None) + + return { + "filename": os.path.basename(filepath), + "slicer": slicer_name, + "total_lines": total_lines, + "total_duration": total_duration, + "end_gcode_time": end_gcode_time, + "filaments": filaments, + "nozzle_map": nozzle_map, + "extruder_groups": extruder_groups, + "heater_to_ext": heater_to_ext, + "tool_changes": tool_changes, + "preheats": preheats, + "precools": precools, + "preheat_count": len(preheats), + "cooldown_count": cooldown_count, + "tc_zones": tc_zones, + "wipe_zones": wipe_zones, + "toolchange_zones": toolchange_zones, + "track": track, + } + except Exception as e: + import traceback + print(f"Error parsing {filepath}: {e}") + traceback.print_exc() + return None + + +def find_desktop_3mf_files(): + desktop = os.path.expanduser("~/Desktop") + files = [os.path.join(desktop, f) for f in os.listdir(desktop) if f.endswith(".3mf")] + files = sorted(files, key=os.path.getmtime, reverse=True) + return files + + +def main(): + file1_path = None + file2_path = None + + if len(sys.argv) > 1: + file1_path = sys.argv[1] + if len(sys.argv) > 2: + file2_path = sys.argv[2] + + if not file1_path: + desktop_files = find_desktop_3mf_files() + if len(desktop_files) >= 2: + orcas = [f for f in desktop_files if "orca" in os.path.basename(f).lower()] + bbls = [f for f in desktop_files if "bbl" in os.path.basename(f).lower() or "bambu" in os.path.basename(f).lower()] + if orcas and bbls: + file1_path = orcas[0] + file2_path = bbls[0] + else: + file1_path = desktop_files[0] + file2_path = desktop_files[1] + elif len(desktop_files) == 1: + file1_path = desktop_files[0] + + if not file1_path: + print("Usage: python3 show_temp_plot.py [file2.3mf]") + sys.exit(1) + + print(f"Loading File 1: {file1_path}") + f1_data = parse_file_data(file1_path) + + f2_data = None + if file2_path: + print(f"Loading File 2: {file2_path}") + f2_data = parse_file_data(file2_path) + + if not f1_data: + print("Error: Failed to parse first file.") + sys.exit(1) + + js_data = { + "is_comparison": f2_data is not None, + "file1": f1_data, + "file2": f2_data, + "total_duration": max(f1_data["total_duration"], f2_data["total_duration"]) if f2_data else f1_data["total_duration"], + } + + html_content = HTML_TEMPLATE.replace("%DATA_JSON%", json.dumps(js_data)) + + output_html = os.path.join(os.path.dirname(file1_path), "temp_plot_v3.html") + with open(output_html, "w", encoding="utf-8") as f: + f.write(html_content) + + print(f"Interactive HTML report: {output_html}") + webbrowser.open("file://" + os.path.abspath(output_html)) + + +# H2C hardware fact: heater 0 = RIGHT nozzle, heater 1 = LEFT nozzle +# Panel layout: top panels show heater 0 (RIGHT), bottom panels show heater 1 (LEFT) +HTML_TEMPLATE = r""" + + + + H2C Temperature Timeline + + + + +
+ + + +""" + +if __name__ == "__main__": + main() diff --git a/tests/libslic3r/test_calib.cpp b/tests/libslic3r/test_calib.cpp index e72eb73e0d..0092a7dedd 100644 --- a/tests/libslic3r/test_calib.cpp +++ b/tests/libslic3r/test_calib.cpp @@ -1,5 +1,9 @@ #include +#include +#include +#include + #include "libslic3r/calib.hpp" #include "libslic3r/Model.hpp" #include "libslic3r/TriangleMesh.hpp" @@ -38,3 +42,69 @@ TEST_CASE("Zero calibration line width resolves to a positive default", "[Calib] REQUIRE(pattern.line_width() > 0.); REQUIRE(pattern.line_width_first_layer() > 0.); } + +namespace { + +struct EndState { double final_e; double max_e; }; + +EndState simulate_absolute_e(const std::string &gcode) +{ + double final_e = 0.; + double max_e = 0.; + + std::istringstream lines(gcode); + std::string line; + while (std::getline(lines, line)) { + std::istringstream words(line); + std::string op; + if (!(words >> op)) + continue; + if (op != "G1" && op != "G0" && op != "G92") + continue; + + std::string word; + while (words >> word) { + if (word.size() >= 2 && word[0] == 'E') { + final_e = std::stod(word.substr(1)); + max_e = std::max(max_e, final_e); + break; + } + } + } + + return {final_e, max_e}; +} + +} // namespace + +TEST_CASE("PA pattern resets the extruder after the final layer in absolute E mode", "[Calib][Regression]") +{ + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + config.set_deserialize_strict({ + {"use_relative_e_distances", "0"}, + {"line_width", "0.45"}, + {"initial_layer_line_width", "0.45"}, + }); + + Model model; + model.add_object("cube", "", make_cube(20, 20, 20))->add_instance(); + + Calib_Params params; + params.mode = CalibMode::Calib_PA_Pattern; + params.start = 0.; + params.end = 0.08; + params.step = 0.002; + + CalibPressureAdvancePattern pattern(params, config, /* is_bbl_machine */ false, *model.objects.front(), Vec3d(0, 0, 0)); + const CustomGCode::Info info = pattern.generate_custom_gcodes(config, /* is_bbl_machine */ false, *model.objects.front(), + Vec3d(0, 0, 0)); + + std::string gcode; + for (const CustomGCode::Item &item : info.gcodes) + gcode += item.extra; + + const EndState state = simulate_absolute_e(gcode); + + REQUIRE(state.max_e > 1.); + REQUIRE_THAT(state.final_e, Catch::Matchers::WithinAbs(0., 1e-9)); +}