diff --git a/src/libslic3r/GCode.cpp b/src/libslic3r/GCode.cpp index 205c758640..4c135cebf3 100644 --- a/src/libslic3r/GCode.cpp +++ b/src/libslic3r/GCode.cpp @@ -779,11 +779,26 @@ static std::vector get_path_of_change_filament(const Print& print) // start_pos refers to the last position before the wipe_tower. // end_pos refers to the wipe tower's start_pos. // using the print coordinate system - Polyline WipeTowerIntegration::generate_path_to_wipe_tower(const Point& start_pos,const Point &end_pos , const BoundingBox& avoid_polygon , const Polygons& bed_polygons) const + Polyline WipeTowerIntegration::generate_path_to_wipe_tower(const Point& start_pos,const Point &end_pos , const BoundingBox& avoid_polygon , const Polygons& bed_polygons, bool clamp_avoid_to_bed) const { Polyline res; coord_t alpha = scaled(wipe_tower_routing_clearance); // offset distance BoundingBox avoid_polygon_inner = avoid_polygon; + if (clamp_avoid_to_bed) { + // The inflated corners must stay on the bed for a route to be generated at all + // (tested below): clamp the box against the bed shrunk by the clearance so a + // tower parked near the bed edge is still routed along the clamped side instead + // of always travelling straight across the tower. + BoundingBox clamp_bbx = get_extents(bed_polygons); + clamp_bbx.offset(-(alpha + SCALED_EPSILON)); + avoid_polygon_inner.min = avoid_polygon_inner.min.cwiseMax(clamp_bbx.min); + avoid_polygon_inner.max = avoid_polygon_inner.max.cwiseMin(clamp_bbx.max); + if (avoid_polygon_inner.min.x() >= avoid_polygon_inner.max.x() || + avoid_polygon_inner.min.y() >= avoid_polygon_inner.max.y()) { + res.points.push_back(end_pos); + return res; + } + } avoid_polygon_inner.offset(alpha); coord_t width = avoid_polygon_inner.max[0] - avoid_polygon_inner.min[0]; Vec2f v(1, 0); // the first print direction of end_pos. @@ -792,20 +807,9 @@ static std::vector get_path_of_change_filament(const Print& print) // outline is tested (not its bounding box), so on circular/custom beds corners // hanging off the bed are rejected. // If so, do nothing and just go directly to the end_pos. - bool is_bbx_in_bed = true; Points avoid_points = avoid_polygon_inner.polygon().points; - for (auto &wipe_tower_bbx_p : avoid_points) { - bool on_bed = false; - for (const Polygon &bed_polygon : bed_polygons) - if (ClipperLib::PointInPolygon(wipe_tower_bbx_p, bed_polygon.points) == 1) { - on_bed = true; - break; - } - if (!on_bed) { - is_bbx_in_bed = false; - break; - } - } + const bool is_bbx_in_bed = std::all_of(avoid_points.begin(), avoid_points.end(), + [&bed_polygons](const Point &pt) { return contains(bed_polygons, pt, /*border_result=*/false); }); if (!is_bbx_in_bed) { res.points.push_back(end_pos); return res; @@ -964,20 +968,12 @@ static std::vector get_path_of_change_filament(const Print& print) Polygon body_points = scaled(BoundingBoxf(Vec2d(0., 0.), Vec2d(body_width, m_wipe_tower_depth))).polygon(); for (auto& p : body_points.points) p = wipe_tower_point_to_object_point(gcodegen, transform_wt2_pt(unscale(p).cast()) + plate_origin_2d); - if (BoundingBox(body_points.points).contains(route_start)) + // Test the rotated polygon itself, not its bounding box — at rotation angles off + // the axes the box's corner triangles cover most of the brim ring. + if (body_points.contains(route_start)) return {}; - const Polygons bed_polygons = printer_travel_polygons(gcodegen); - // The router inflates the avoid box by wipe_tower_routing_clearance and refuses - // to route once any inflated corner leaves the bed: clamp the box against the bed - // shrunk by that clearance so a tower parked near the bed edge is still routed - // along the clamped side instead of always travelling straight across the tower. - BoundingBox clamp_bbx = get_extents(bed_polygons); - clamp_bbx.offset(-(scaled(wipe_tower_routing_clearance) + SCALED_EPSILON)); - avoid_bbx.min = avoid_bbx.min.cwiseMax(clamp_bbx.min); - avoid_bbx.max = avoid_bbx.max.cwiseMin(clamp_bbx.max); - if (avoid_bbx.min.x() >= avoid_bbx.max.x() || avoid_bbx.min.y() >= avoid_bbx.max.y()) - return {}; - Polyline travel_polyline = generate_path_to_wipe_tower(route_start, start_wipe_pos, avoid_bbx, bed_polygons); + Polyline travel_polyline = generate_path_to_wipe_tower(route_start, start_wipe_pos, avoid_bbx, + printer_travel_polygons(gcodegen), /*clamp_avoid_to_bed=*/true); std::string gcode; // The polyline's last point is start_wipe_pos itself — emitted by the caller. for (size_t i = 0; i + 1 < travel_polyline.points.size(); ++i) @@ -9391,12 +9387,9 @@ std::string GCode::set_extruder(unsigned int new_filament_id, double print_z, bo // The blocking M109 is emitted by the wipe tower generator; with no temperature // command here the nozzle would only start heating once the head arrives there. // Heat non-blocking now so the heat-up overlaps the travel, targeting what the - // tower will wait on (OozePrevention::_get_temp logic plus the interface override). - int temp = toolchange_temp_override > 0 ? - toolchange_temp_override : - (m_layer == nullptr || m_layer->id() == 0 || m_config.nozzle_temperature.get_at(new_fi) == 0 ? - m_config.nozzle_temperature_initial_layer.get_at(new_fi) : - m_config.nozzle_temperature.get_at(new_fi)); + // tower will wait on (the writer already holds the new filament, so _get_temp + // resolves its column) plus the interface override. + int temp = toolchange_temp_override > 0 ? toolchange_temp_override : m_ooze_prevention._get_temp(*this); if (temp > 0) restore_temp_gcode = m_writer.set_temperature(temp, false, new_filament_id); } diff --git a/src/libslic3r/GCode.hpp b/src/libslic3r/GCode.hpp index 081c6ea4b1..8c1550104d 100644 --- a/src/libslic3r/GCode.hpp +++ b/src/libslic3r/GCode.hpp @@ -51,8 +51,9 @@ public: OozePrevention() : enable(false) {} std::string pre_toolchange(GCode &gcodegen); std::string post_toolchange(GCode &gcodegen, bool wait = true); - -private: + // The operating temperature for the writer's current filament; public so the + // wait_for_temp_on_wipe_tower pre-heat in GCode::set_extruder targets the same + // temperature the tower's blocking M109 will wait on. int _get_temp(const GCode &gcodegen) const; }; @@ -130,7 +131,7 @@ public: private: WipeTowerIntegration& operator=(const WipeTowerIntegration&); std::string append_tcr(GCode &gcodegen, const WipeTower::ToolChangeResult &tcr, int new_extruder_id, double z = -1.) const; - Polyline generate_path_to_wipe_tower(const Point &start_pos, const Point &end_pos, const BoundingBox &avoid_polygon, const Polygons &bed_polygons) const; + Polyline generate_path_to_wipe_tower(const Point &start_pos, const Point &end_pos, const BoundingBox &avoid_polygon, const Polygons &bed_polygons, bool clamp_avoid_to_bed = false) const; std::string append_tcr2(GCode &gcodegen, const WipeTower::ToolChangeResult &tcr, int new_extruder_id, double z = -1.) const; std::string travel_to_tower_gap(GCode &gcodegen, const Point &route_start, const Point &start_wipe_pos) const; Vec2f transform_wt2_pt(const Vec2f &pt) const; diff --git a/src/libslic3r/GCode/WipeTower2.cpp b/src/libslic3r/GCode/WipeTower2.cpp index c5a5dae387..9ffc36275b 100644 --- a/src/libslic3r/GCode/WipeTower2.cpp +++ b/src/libslic3r/GCode/WipeTower2.cpp @@ -746,8 +746,8 @@ public: if (n <= 0) return; while (n--) { - const Vec2f lap_max{std::min(box_max.x(), clamp_max.x()), std::min(box_max.y(), clamp_max.y())}; - const Vec2f lap_min{std::max(box_min.x(), clamp_min.x()), std::max(box_min.y(), clamp_min.y())}; + const Vec2f lap_max = box_max.cwiseMin(clamp_max); + const Vec2f lap_min = box_min.cwiseMax(clamp_min); travel(lap_max.x(), m_current_pos.y(), feedrate); travel(m_current_pos.x(), lap_max.y(), feedrate); travel(lap_min.x(), m_current_pos.y(), feedrate); @@ -1087,7 +1087,6 @@ WipeTower2::WipeTower2(const PrintConfig& config, const PrintRegionConfig& defau // Calculate where the priming lines should be - very naive test not detecting parallelograms etc. const std::vector& bed_points = config.printable_area.values; BoundingBoxf bb(bed_points); - m_bed_bbox = bb; m_bed_width = float(bb.size().x()); m_bed_shape = (bed_points.size() == 4 ? RectangularBed : CircularBed); @@ -1759,16 +1758,9 @@ void WipeTower2::toolchange_Change( const bool on_left = writer.x() < m_wipe_tower_width / 2.f; const float near_x = on_left ? min_x - gap : max_x + gap; const float far_x = on_left ? max_x + gap : min_x - gap; - const float a = float(m_wipe_tower_rotation_angle * M_PI / 180.); - const float c = std::cos(a), s = std::sin(a); - auto park_pt_on_bed = [this, &writer, c, s](float side_x) { - const Vec2f wt = writer.rotated(Vec2f(side_x, writer.y())) + m_rib_offset; - const Vec2f bed_pt(c * wt.x() - s * wt.y() + m_wipe_tower_pos.x(), - s * wt.x() + c * wt.y() + m_wipe_tower_pos.y()); - if (m_bed_shape == RectangularBed) - return m_bed_bbox.contains(bed_pt.cast()); - if (m_bed_shape == CircularBed) - return (bed_pt.cast() - m_bed_bbox.center()).norm() <= m_bed_width / 2.; + const Eigen::Rotation2Df to_bed(float(Geometry::deg2rad(m_wipe_tower_rotation_angle))); + auto park_pt_on_bed = [this, &writer, to_bed](float side_x) { + const Vec2f bed_pt = to_bed * (writer.rotated(Vec2f(side_x, writer.y())) + m_rib_offset) + m_wipe_tower_pos; return m_bed_polygon.contains(Point::new_scale(bed_pt.x(), bed_pt.y())); }; float park_x = near_x; @@ -2149,15 +2141,17 @@ std::pair WipeTower2::get_wipe_tower_cone_base(double width, dou } // Static method to extract wipe_volumes[from][to] from the configuration. -std::vector> WipeTower2::extract_wipe_volumes(const PrintConfig& config) +// Takes a ConfigBase so the GUI's wipe tower size estimate can pass the plate's +// DynamicPrintConfig directly instead of materializing a full PrintConfig per call. +std::vector> WipeTower2::extract_wipe_volumes(const ConfigBase& config) { // Get wiping matrix to get number of extruders and convert vector to vector: - std::vector wiping_matrix(cast(config.flush_volumes_matrix.values)); - auto scale = config.flush_multiplier.get_at(0); + std::vector wiping_matrix(cast(config.option("flush_volumes_matrix")->values)); + auto scale = config.option("flush_multiplier")->get_at(0); // The values shall only be used when SEMM is enabled. The purging for other printers // is determined by filament_minimal_purge_on_wipe_tower. - if (! config.purge_in_prime_tower.value || ! config.single_extruder_multi_material.value) + if (! config.option("purge_in_prime_tower")->value || ! config.option("single_extruder_multi_material")->value) std::fill(wiping_matrix.begin(), wiping_matrix.end(), 0.f); // Extract purging volumes for each extruder pair: @@ -2167,13 +2161,28 @@ std::vector> WipeTower2::extract_wipe_volumes(const PrintConf wipe_volumes.push_back(std::vector(wiping_matrix.begin()+i*number_of_extruders, wiping_matrix.begin()+(i+1)*number_of_extruders)); // Also include filament_minimal_purge_on_wipe_tower. This is needed for the preview. + const auto *minimal_purge = config.option("filament_minimal_purge_on_wipe_tower"); for (unsigned int i = 0; i(wipe_volumes[i][j] * scale, config.filament_minimal_purge_on_wipe_tower.get_at(j)); + wipe_volumes[i][j] = std::max(wipe_volumes[i][j] * scale, minimal_purge->get_at(j)); return wipe_volumes; } +float WipeTower2::estimate_semm_flush_volume(const ConfigBase& config, size_t filaments_cnt) +{ + std::vector> wipe_volumes = extract_wipe_volumes(config); + std::vector max_wipe_volumes; + for (const std::vector &v : wipe_volumes) + max_wipe_volumes.emplace_back(*std::max_element(v.begin(), v.end())); + float maximum = std::accumulate(max_wipe_volumes.begin(), max_wipe_volumes.end(), 0.f); + maximum = maximum * filaments_cnt / max_wipe_volumes.size(); + + // Orca: it's overshooting a bit, so let's reduce it a bit + maximum *= 0.6; + return maximum; +} + static float get_wipe_depth(float volume, float layer_height, float perimeter_width, float extra_flow, float extra_spacing, float width) { float length_to_extrude = (volume_to_length(volume, perimeter_width, layer_height)) / extra_flow; diff --git a/src/libslic3r/GCode/WipeTower2.hpp b/src/libslic3r/GCode/WipeTower2.hpp index f536f26cb8..5b1a474b5d 100644 --- a/src/libslic3r/GCode/WipeTower2.hpp +++ b/src/libslic3r/GCode/WipeTower2.hpp @@ -17,6 +17,7 @@ namespace Slic3r class WipeTowerWriter2; class PrintRegionConfig; +class ConfigBase; class WipeTower2 { @@ -26,7 +27,10 @@ public: // in WipeTowerIntegration::append_tcr2 does not strip it. static const std::string wait_for_temp_tag() { return ";_WAIT_FOR_TEMP_ON_WIPE_TOWER"; } static std::pair get_wipe_tower_cone_base(double width, double height, double depth, double angle_deg); - static std::vector> extract_wipe_volumes(const PrintConfig& config); + static std::vector> extract_wipe_volumes(const ConfigBase& config); + // Estimated total flush volume of a SEMM print with the given number of filaments, + // used to reserve wipe tower space before the tower is generated. + static float estimate_semm_flush_volume(const ConfigBase& config, size_t filaments_cnt); // Construct ToolChangeResult from current state of WipeTower2 and WipeTowerWriter2. @@ -272,8 +276,7 @@ private: } m_bed_shape; float m_bed_width; // width of the bed bounding box Vec2f m_bed_bottom_left; // bottom-left corner coordinates (for rectangular beds) - BoundingBoxf m_bed_bbox; // bounding box of the printable area - Polygon m_bed_polygon; // printable_area contour (scaled), for beds neither rectangular nor circular + Polygon m_bed_polygon; // printable_area contour (scaled) float m_perimeter_width = 0.4f * Width_To_Nozzle_Ratio; // Width of an extrusion line, also a perimeter spacing for 100% infill. float m_extrusion_flow = 0.038f; //0.029f;// Extrusion flow is derived from m_perimeter_width, layer height and filament diameter. diff --git a/src/libslic3r/Print.cpp b/src/libslic3r/Print.cpp index f4eb090b27..b3b61d03b5 100644 --- a/src/libslic3r/Print.cpp +++ b/src/libslic3r/Print.cpp @@ -282,6 +282,14 @@ bool Print::invalidate_state_by_config_options(const ConfigOptionResolver & /* n || opt_key == "wipe_tower_x" || opt_key == "wipe_tower_y" || opt_key == "wipe_tower_rotation_angle") { + // The tower gcode itself is position-independent (position and rotation are applied + // at export), except that the wait_for_temp_on_wipe_tower park bakes a bed-relative + // side choice into it (WipeTower2::toolchange_Change) — regenerate it when the tower + // moves. Gating on the old config is safe: both inputs of wait_for_temp_enabled + // invalidate psWipeTower themselves when they are part of the same diff. + if ((opt_key == "wipe_tower_x" || opt_key == "wipe_tower_y" || opt_key == "wipe_tower_rotation_angle") + && WipeTower2::wait_for_temp_enabled(m_config)) + steps.emplace_back(psWipeTower); steps.emplace_back(psSkirtBrim); } else if ( opt_key == "slicing_pipeline_plugin" @@ -1027,7 +1035,7 @@ static StringObjectException layered_print_cleareance_valid(const Print &print, //float v = config.wiping_volume.value; float depth = print.wipe_tower_data(filaments_count).depth; - float brim_width = print.wipe_tower_data(filaments_count).brim_width; + //float brim_width = print.wipe_tower_data(filaments_count).brim_width; if (config.wipe_tower_wall_type.value == WipeTowerWallType::wtwRib) width = depth; @@ -1066,14 +1074,20 @@ static StringObjectException layered_print_cleareance_valid(const Print &print, if (print_config.enable_wrapping_detection.value && !intersection({wrapping_poly}, convex_hulls_temp).empty()) { return {L("Prime Tower") + L(" is too close to clumping detection area, and collisions will be caused.\n")}; } - if (!convex_hulls_temp.empty()) { + // Skip the containment check for towers that will never be printed (single-filament + // prints without smooth timelapse keep the config's tower position but emit nothing). + // Pre-generation only the body square is tested — the auto-brim estimate can overshoot + // the generated brim by several mm and must not hard-fail a print that physically fits. + // Post-generation the mesh bottom already includes the real brim, so the exact + // footprint is tested. + if (!convex_hulls_temp.empty() && (filaments_count > 1 || print.enable_timelapse_print())) { // The shared printable polygon is plate-local, while the tower polygons above are // already shifted by the plate origin. Polygons printable_polys = print.get_extruder_shared_printable_polygon(); std::for_each(printable_polys.begin(), printable_polys.end(), [&plate_origin](Polygon& p) { p.translate(scale_(plate_origin.x()), scale_(plate_origin.y())); }); - if (!diff(offset(convex_hulls_temp, float(scale_(brim_width))), printable_polys).empty()) - return {L("Prime Tower") + L(" (including the brim) is partially outside the printable area, and it cannot be printed.\n")}; + if (!diff(convex_hulls_temp, printable_polys).empty()) + return {L("Prime Tower") + L(" is partially outside the printable area, and it cannot be printed.\n")}; } return {}; } @@ -3930,6 +3944,8 @@ const WipeTowerData &Print::wipe_tower_data(size_t filaments_cnt) const if (m_config.nozzle_diameter.values.size() == 2) volume += filament_change_volume * (int) (filaments_cnt / 2); if (m_config.wipe_tower_wall_type.value == WipeTowerWallType::wtwRib) { + if (m_config.purge_in_prime_tower && m_config.single_extruder_multi_material) + volume = WipeTower2::estimate_semm_flush_volume(m_config, filaments_cnt); double depth = std::sqrt(volume / layer_height * extra_spacing); if (need_wipe_tower || filaments_cnt > 1) { float min_wipe_tower_depth = WipeTower::get_limit_depth_by_height(max_height); @@ -3945,15 +3961,7 @@ const WipeTowerData &Print::wipe_tower_data(size_t filaments_cnt) const // Calculating depth should take into account currently set wiping volumes. // For a long time, the initial preview would just use 900/width per toolchange (15mm on a 60mm wide tower) // and it worked well enough. Let's try to do slightly better by accounting for the purging volumes. - std::vector> wipe_volumes = WipeTower2::extract_wipe_volumes(m_config); - std::vector max_wipe_volumes; - for (const std::vector &v : wipe_volumes) - max_wipe_volumes.emplace_back(*std::max_element(v.begin(), v.end())); - float maximum = std::accumulate(max_wipe_volumes.begin(), max_wipe_volumes.end(), 0.f); - maximum = maximum * filaments_cnt / max_wipe_volumes.size(); - - // Orca: it's overshooting a bit, so let's reduce it a bit - maximum *= 0.6; + float maximum = WipeTower2::estimate_semm_flush_volume(m_config, filaments_cnt); const_cast(this)->m_wipe_tower_data.depth = maximum / (layer_height * width); } else { double depth = volume / (layer_height * width) * extra_spacing; diff --git a/src/slic3r/GUI/PartPlate.cpp b/src/slic3r/GUI/PartPlate.cpp index 4f57c2577d..d3c12ce66c 100644 --- a/src/slic3r/GUI/PartPlate.cpp +++ b/src/slic3r/GUI/PartPlate.cpp @@ -2263,6 +2263,12 @@ Vec3d PartPlate::estimate_wipe_tower_size(const DynamicPrintConfig & config, con double volume = wipe_volume * (extruder_count == 2 ? plate_extruder_size : (plate_extruder_size - 1)); if (extruder_count == 2) volume += filament_change_volume * (int) (plate_extruder_size / 2); if (use_rib_wall) { + // Read from the passed plate config — m_print may not have been applied yet + // (fresh plates, CLI), in which case its PrintConfig still holds defaults. + const auto *purge_opt = config.option("purge_in_prime_tower"); + const auto *semm_opt = config.option("single_extruder_multi_material"); + if (purge_opt && purge_opt->value && semm_opt && semm_opt->value) + volume = WipeTower2::estimate_semm_flush_volume(config, plate_extruder_size); depth = std::sqrt(volume / layer_height * extra_spacing); if (need_wipe_tower || plate_extruder_size > 1) { float min_wipe_tower_depth = WipeTower::get_limit_depth_by_height(max_height); diff --git a/tests/fff_print/test_multifilament.cpp b/tests/fff_print/test_multifilament.cpp index 694a00097f..32bb32630c 100644 --- a/tests/fff_print/test_multifilament.cpp +++ b/tests/fff_print/test_multifilament.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include @@ -29,6 +30,31 @@ static std::set tools_for_role(const std::string& gcode, const std::string& return tools; } +// X where the nozzle sits while each tagged _WAIT_FOR_TEMP_ON_WIPE_TOWER M109 blocks: +// the nearest preceding G1 carrying an X (the park travel emitted just before the wait). +static std::vector wait_park_xs(const std::string& gcode) +{ + std::vector lines; + std::istringstream stream(gcode); + for (std::string line; std::getline(stream, line);) + lines.emplace_back(std::move(line)); + std::vector xs; + for (size_t i = 0; i < lines.size(); ++i) { + if (lines[i].rfind("M109", 0) != 0 || lines[i].find("_WAIT_FOR_TEMP_ON_WIPE_TOWER") == std::string::npos) + continue; + for (size_t j = i; j-- > 0;) { + if (lines[j].rfind("G1 ", 0) != 0) + continue; + const size_t x_pos = lines[j].find('X'); + if (x_pos == std::string::npos) + continue; + xs.push_back(std::stod(lines[j].substr(x_pos + 1))); + break; + } + } + return xs; +} + // Tool index = filament id - 1; brim and skirt follow the wall filament. TEST_CASE("Each feature prints with its assigned filament", "[MultiFilament]") { @@ -122,13 +148,9 @@ TEST_CASE("Toolchange temperature wait moves to the wipe tower when enabled", "[ // Split into lines and scan the "; CP TOOLCHANGE START".."; CP TOOLCHANGE END" blocks. std::vector lines; - for (size_t pos = 0; pos < gcode.size();) { - size_t eol = gcode.find('\n', pos); - if (eol == std::string::npos) - eol = gcode.size(); - lines.emplace_back(gcode.substr(pos, eol - pos)); - pos = eol + 1; - } + std::istringstream gcode_stream(gcode); + for (std::string line; std::getline(gcode_stream, line);) + lines.emplace_back(std::move(line)); const auto is_tool_line = [](const std::string& l) { return l.size() >= 2 && l[0] == 'T' && std::isdigit((unsigned char)l[1]); }; const auto is_m109_line = [](const std::string& l) { return l.rfind("M109", 0) == 0; }; const auto is_tagged_wait = [](const std::string& l) { return l.find("_WAIT_FOR_TEMP_ON_WIPE_TOWER") != std::string::npos; }; @@ -204,6 +226,79 @@ TEST_CASE("Toolchange temperature wait moves to the wipe tower when enabled", "[ } } +// The temperature-wait park picks its side of the tower by testing bed containment with the +// tower position at psWipeTower generation time, while WipeTowerIntegration shifts the cached +// moves by the CURRENT position at export. Moving the tower normally invalidates only +// psSkirtBrim (tower gcode is position-independent), but the park makes it bed-relative, so a +// GUI-style move-and-reslice on the same Print must regenerate the tower — otherwise the stale +// park prints outside the bed. Contract: every tagged wait parks inside the printable area. +TEST_CASE("Wipe tower temperature-wait park is regenerated when the tower moves", "[MultiFilament]") +{ + // Two objects, one filament each: a toolchange (and a tagged wait) on every layer, like + // the wait test above — but on a single-extruder machine profile: the synthetic + // dual-extruder keys would drag in the extruder-variant expansion, which is not + // idempotent on the default machine profile and would pollute the re-apply diff below. + // Rectangle wall and no brim keep the tower-local footprint inside [0, 35], so the park + // sits at the generator's 2mm side gap: local -2 or 37. + DynamicPrintConfig config = multifilament_config(2, { + { "single_extruder_multi_material", 0 }, + { "enable_prime_tower", 1 }, + { "prime_tower_width", 35 }, + { "wipe_tower_wall_type", "rectangle" }, // the default rib bulges past the width + { "prime_tower_brim_width", 0 }, // the default 3 widens the first-layer envelope + { "printable_area", "0x0,200x0,200x200,0x200" }, + { "wipe_tower_x", "0" }, + { "wipe_tower_y", "50" }, + { "ooze_prevention", 1 }, + { "standby_temperature_delta", -40 }, + { "wait_for_temp_on_wipe_tower", 1 }, + }); + // init_print force-sets this on its own copy; set it here too so the re-apply below + // diffs in wipe_tower_x ONLY — the exact GUI increment under test. + config.set_key_value("gcode_comments", new ConfigOptionBool(true)); + + Print print; + Model model; + const std::vector> overrides{ + { { "extruder", 1 } }, { { "extruder", 2 } } }; // object-level, see the wait test above + init_print(std::vector{ cube(20), cube(20) }, print, model, config, &overrides); + + const std::string at_edge = gcode(print); + const std::vector at_edge_parks = wait_park_xs(at_edge); + REQUIRE(!at_edge_parks.empty()); // the feature under test is active + for (double x : at_edge_parks) { + INFO("wait park X " << x << " with the tower at x=0 on a 200mm bed"); + CHECK(x >= -0.05); + CHECK(x <= 200.05); + } + REQUIRE(print.is_step_done(psWipeTower)); + + // Move the tower to the right bed edge (164 + 35 = 199 keeps the body printable) and + // re-apply on the SAME Print, as the GUI does. Base the re-apply on the print's own + // resolved config so the diff is wipe_tower_x alone — re-applying the caller's config + // would also diff the apply-time extruder normalization write-backs, and those keys + // regenerate the tower for the wrong reason. The cached right-side park would export + // at 164 + 37 = 201, off the bed; regeneration clamps the park against the bed edge. + // Assemble the moved config exactly the way init_print assembled the first one — the + // apply-time normalization is only idempotent when both applies start from the same + // derivation, and any stray diff key would regenerate the tower for the wrong reason. + config.set_deserialize_strict({ { "wipe_tower_x", "164" } }); + DynamicPrintConfig moved_config = DynamicPrintConfig::full_print_config(); + moved_config.apply(config); + moved_config.set_key_value("gcode_comments", new ConfigOptionBool(true)); + print.apply(model, moved_config); + CHECK_FALSE(print.is_step_done(psWipeTower)); // the move must re-generate the tower + + const std::string moved = gcode(print); + const std::vector moved_parks = wait_park_xs(moved); + REQUIRE(!moved_parks.empty()); // the waits must survive the re-slice + for (double x : moved_parks) { + INFO("wait park X " << x << " with the tower at x=164 on a 200mm bed"); + CHECK(x >= -0.05); + CHECK(x <= 200.05); + } +} + // max_layer_height can be shorter than the extruder count (normalization sizes it to the // filament count under single_extruder_multi_material). calc_max_layer_height() in ToolOrdering // indexed it per-nozzle and read past the end. Shortened directly here to isolate that read;