From 99627c8e935b4bd0a167f6728949840d49a024a1 Mon Sep 17 00:00:00 2001 From: Hanif Koh Date: Thu, 3 Sep 2026 14:52:01 +0800 Subject: [PATCH] Size the Footprint Estimate from the Planners The shared estimate reserved every tower with one volume-per-purge rule and the stability floor. Both planners do more: WipeTower (Type1) wipes each filament's own prime volume in whole lines, one block per adhesiveness category sized by its worst layer, rams the leaving filament at every nozzle change, and squares a rib tower from the planned depth; WipeTower2 (Type2) spaces its lines by wipe_tower_extra_spacing, not the Type1-only infill gap, and its extra flow cancels out of the depth. Both extend the ribs rather than the body below the stability minimum, size every layer including a thinner first one, and lay the brim in whole loops, WipeTower reporting half a spacing of line width on top. All of that now lives in estimate_wipe_tower_footprint, fed the planner (resolve_wipe_tower_type mirrors Print::wipe_tower_type and the CLI's Bambu Lab detection) and the filament ids rather than a count. Print passes its own tool set; the PartPlate adapter derives the plate's ids from the passed config and treats an explicit count as a floor, so the CLI's count-only callers size per filament too. The placement clamp also reserves a Type2 cone's base bulge, which the body box does not cover. The planner-mirroring helpers sit beside the planners in WipeTower and WipeTower2 so the two stay in sync; the libslic3r cases pin them to footprints measured from generated G-code. --- src/libslic3r/GCode/WipeTower.cpp | 88 ++++++++ src/libslic3r/GCode/WipeTower.hpp | 27 +++ src/libslic3r/GCode/WipeTower2.cpp | 17 ++ src/libslic3r/GCode/WipeTower2.hpp | 4 + src/libslic3r/GCode/WipeTowerEstimate.cpp | 166 ++++++++++++---- src/libslic3r/GCode/WipeTowerEstimate.hpp | 22 +- src/libslic3r/Print.cpp | 2 +- src/slic3r/GUI/PartPlate.cpp | 44 ++-- src/slic3r/GUI/PartPlate.hpp | 3 +- tests/fff_print/test_wipe_tower.cpp | 19 +- tests/libslic3r/CMakeLists.txt | 1 + tests/libslic3r/test_wipe_tower.cpp | 93 +++++++++ tests/libslic3r/test_wipe_tower_estimate.cpp | 199 ++++++++++++++----- 13 files changed, 559 insertions(+), 126 deletions(-) create mode 100644 tests/libslic3r/test_wipe_tower.cpp diff --git a/src/libslic3r/GCode/WipeTower.cpp b/src/libslic3r/GCode/WipeTower.cpp index 8aff5f4a3f..bef3803c55 100644 --- a/src/libslic3r/GCode/WipeTower.cpp +++ b/src/libslic3r/GCode/WipeTower.cpp @@ -1630,6 +1630,94 @@ float WipeTower::get_auto_brim_by_height(float max_height) { return 8.f; } +float WipeTower::estimate_brim_real_width(float brim_width, float nozzle_diameter, float first_layer_height, bool type2) +{ + if (brim_width <= 0.f) + return brim_width; + const float spacing = nozzle_diameter * 1.25f - first_layer_height * float(1. - M_PI_4); // Width_To_Nozzle_Ratio + if (spacing <= EPSILON) + return brim_width; + const int loops_num = int((brim_width + spacing / 2.f) / spacing); + return loops_num * spacing + (type2 ? 0.f : spacing / 2.f); +} + +float WipeTower::get_wrapping_detection_depth() +{ + return float(wrapping_wipe_tower_depth); +} + +float WipeTower::nozzle_change_perimeter_width(float nozzle_diameter) +{ + auto it = nozzle_diameter_to_nozzle_change_width.find(nozzle_diameter); + return it != nozzle_diameter_to_nozzle_change_width.end() ? it->second : 2.f * nozzle_diameter * 1.25f; +} + +float WipeTower::estimate_tower_blocks_depth(const std::vector &purges, float width, float layer_height, float nozzle_diameter, float extra_spacing) +{ + if (purges.empty() || layer_height < EPSILON || nozzle_diameter < EPSILON) + return 0.f; + const float pw = nozzle_diameter * 1.25f; // Width_To_Nozzle_Ratio + const float ncpw = nozzle_change_perimeter_width(nozzle_diameter); + const float line_width = width - 2.f * pw; + if (line_width <= EPSILON) + return 0.f; + // Line cross-section as volume_to_length() sees it; the infill gap stretches the perimeter + // width by the configured ratio and nozzle-change lines keep their own width + // (calc_block_infill_gap). + auto line_area = [layer_height](float w) { return layer_height * (w - layer_height * float(1. - M_PI_4)); }; + const float extra_width = (extra_spacing - 1.f) * pw; + const float gap = pw + extra_width; + const float nc_gap = ncpw + extra_width; + // A layer purges into at most (filaments - 1) targets, so a category holding every filament + // never sees its smallest purge (the layer's first filament) in its worst layer. + struct Block { float depth = 0.f; float min_purge = 0.f; size_t filaments = 0; }; + std::map blocks; + for (const PurgeEstimate &purge : purges) { + Block &block = blocks[purge.category]; + const float purge_depth = std::ceil(purge.prime_volume / line_area(pw) / line_width) * gap; + block.min_purge = block.filaments == 0 ? purge_depth : std::min(block.min_purge, purge_depth); + block.depth += purge_depth; + ++block.filaments; + if (purge.filament_change_length > EPSILON) { + // The leaving filament is rammed over the nozzle-change flow, again in whole lines. + const float filament_area = float(M_PI) * purge.filament_diameter * purge.filament_diameter / 4.f; + const float nc_length = purge.filament_change_length * filament_area / line_area(ncpw); + block.depth += std::ceil(nc_length / (width - ncpw - pw)) * nc_gap; + } + } + float depth = pw; // plan_tower_new starts the first block one perimeter width in + for (const auto &[category, block] : blocks) + depth += block.filaments == purges.size() ? block.depth - block.min_purge : block.depth; + return depth; +} + +float WipeTower::rib_footprint_side(float width, float depth, float rib_width, float extra_rib_length, float max_height) +{ + if (width < EPSILON || depth < EPSILON) + return 0.f; + // Ribs run the diagonal; below the height-based minimum they are extended rather than the + // body, then by the extra length, never ending up shorter than the diagonal. + const float diagonal = std::sqrt(width * width + depth * depth); + float rib_length = diagonal; + if (depth + EPSILON < get_limit_depth_by_height(max_height)) + rib_length = std::max(rib_length, get_limit_depth_by_height(max_height) * float(std::sqrt(2.))); + rib_length = std::max(diagonal, rib_length + extra_rib_length); + // Half the extension at each end of the diagonal plus half the rib width, projected onto the axes. + const float rib_w = std::min(rib_width, std::min(width, depth) / 2.f); + const float per_side = ((rib_length - diagonal) / 2.f + rib_w / 2.f) / float(std::sqrt(2.)); + return std::max(width, depth) + 2.f * per_side; +} + +float WipeTower::estimate_rib_tower_bbox_side(const std::vector &purges, float width, float layer_height, float nozzle_diameter, float extra_spacing, float rib_width, float extra_rib_length, float max_height) +{ + if (purges.empty() || width < EPSILON || layer_height < EPSILON || nozzle_diameter < EPSILON) + return 0.f; + const float pw = nozzle_diameter * 1.25f; // Width_To_Nozzle_Ratio + const float square = align_ceil(std::sqrt(estimate_tower_blocks_depth(purges, width, layer_height, nozzle_diameter, extra_spacing) * width), pw); + const float depth = estimate_tower_blocks_depth(purges, square, layer_height, nozzle_diameter, extra_spacing); + return rib_footprint_side(square, depth, rib_width, extra_rib_length, max_height); +} + Vec2f WipeTower::move_box_inside_polygon(const BoundingBox &box, const Polygons &polygons, coord_t offset) { if (polygons.empty()) return Vec2f{0.f, 0.f}; diff --git a/src/libslic3r/GCode/WipeTower.hpp b/src/libslic3r/GCode/WipeTower.hpp index 045c82cbf3..9303f09691 100644 --- a/src/libslic3r/GCode/WipeTower.hpp +++ b/src/libslic3r/GCode/WipeTower.hpp @@ -42,9 +42,36 @@ public: static const std::map min_depth_per_height; static float get_limit_depth_by_height(float max_height); static float get_auto_brim_by_height(float max_height); + // Both generators lay the brim in whole loops one line spacing apart, so the printed width + // differs from the configured one. WipeTower reports it with half a spacing of line width + // added, WipeTower2 reports the loops alone; an estimate has to round like the generator + // whose G-code it stands in for. + static float estimate_brim_real_width(float brim_width, float nozzle_diameter, float first_layer_height, bool type2); + // Depth a Type1 tower reserves once nothing but wrapping detection asks for one. + static float get_wrapping_detection_depth(); + // Line width of the nozzle-change purge lines at this nozzle diameter. + static float nozzle_change_perimeter_width(float nozzle_diameter); static TriangleMesh its_make_rib_tower(float width, float depth, float height, float rib_length, float rib_width, bool fillet_wall); static TriangleMesh its_make_rib_brim(const Polygon& brim, float layer_height); static Polygon rib_section(float width, float depth, float rib_length, float rib_width, bool fillet_wall); + // One filament's share of a Type1 tower layer, as plan_tower_new() reserves it. + struct PurgeEstimate + { + float prime_volume = 0.f; // mm3 wiped after changing to this filament + int category = 0; // filament_adhesiveness_category; one purge block per category + float filament_change_length = 0.f; // mm of filament rammed when it leaves its nozzle; 0 when no nozzle change is planned + float filament_diameter = 1.75f; + }; + // Depth of the Type1 purge stack at the given width (also the rectangle-wall depth): each + // purge is whole lines at the block infill gap, one block per adhesiveness category sized by + // its worst layer, stacked behind one perimeter width. + static float estimate_tower_blocks_depth(const std::vector &purges, float width, float layer_height, float nozzle_diameter, float extra_spacing); + // Side of the square bounding a rib-wall tower's first layer, brim excluded: the body plus the + // rib bulge, with the ribs extended to the height-based minimum as both generators do. + static float rib_footprint_side(float width, float depth, float rib_width, float extra_rib_length, float max_height); + // Type1 rib tower: plan_tower_new() squares the tower from the depth at the configured width, + // then re-plans the depth at the squared width. + static float estimate_rib_tower_bbox_side(const std::vector &purges, float width, float layer_height, float nozzle_diameter, float extra_spacing, float rib_width, float extra_rib_length, float max_height); // Translation that brings a footprint inside the printable outline, padded by offset. The prime // tower is validated against the real outline (see layered_print_cleareance_valid), so clamping // against the bounding box alone would leave it off a delta or hexagonal bed. box and polygons diff --git a/src/libslic3r/GCode/WipeTower2.cpp b/src/libslic3r/GCode/WipeTower2.cpp index ee0f9c375a..4e752bd772 100644 --- a/src/libslic3r/GCode/WipeTower2.cpp +++ b/src/libslic3r/GCode/WipeTower2.cpp @@ -2129,6 +2129,23 @@ std::pair WipeTower2::get_wipe_tower_cone_base(double width, dou return std::make_pair(R, support_scale); } +Polygon WipeTower2::cone_base_polygon(double width, double depth, double height, double angle_deg) +{ + Polygon box({Point::new_scale(Vec2d(0., 0.)), Point::new_scale(Vec2d(width, 0.)), + Point::new_scale(Vec2d(width, depth)), Point::new_scale(Vec2d(0., depth))}); + if (angle_deg <= EPSILON || height <= EPSILON || width <= EPSILON || depth <= EPSILON) + return box; + const auto [R, x_scale] = get_wipe_tower_cone_base(width, height, depth, angle_deg); + if (R <= EPSILON) + return box; + const Vec2d center(width / 2., depth / 2.); + Polygon ellipse; + for (double alpha = 0.; alpha < 2. * M_PI; alpha += M_PI / 20.) + ellipse.points.push_back(Point::new_scale(center + R * Vec2d(std::cos(alpha) / x_scale, std::sin(alpha)))); + Polygons u = union_({box, ellipse}); + return u.empty() ? box : u.front(); +} + // Static method to extract wipe_volumes[from][to] from the configuration. // 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. diff --git a/src/libslic3r/GCode/WipeTower2.hpp b/src/libslic3r/GCode/WipeTower2.hpp index 5b1a474b5d..232cad1a6b 100644 --- a/src/libslic3r/GCode/WipeTower2.hpp +++ b/src/libslic3r/GCode/WipeTower2.hpp @@ -27,6 +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); + // First-layer outline of a cone-wall tower in tower-local (scaled) coordinates: body box + // unioned with the cone's base ellipse — the model first_layer_wipe_tower_corners uses, + // and generate_support_cone_wall stays within it. Brim not included. + static Polygon cone_base_polygon(double width, double depth, double height, double angle_deg); 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. diff --git a/src/libslic3r/GCode/WipeTowerEstimate.cpp b/src/libslic3r/GCode/WipeTowerEstimate.cpp index a8aeda28ef..6fee774e9c 100644 --- a/src/libslic3r/GCode/WipeTowerEstimate.cpp +++ b/src/libslic3r/GCode/WipeTowerEstimate.cpp @@ -8,57 +8,93 @@ #include #include +#include namespace Slic3r { -WipeTowerFootprint estimate_wipe_tower_footprint(const ConfigBase &config, size_t filaments_cnt, double layer_height, double max_object_height) +// Every caller today declares all these keys, but the signature accepts any ConfigBase: fall +// back to the key's declared default, never to a hand-copied constant. +static const ConfigOption *option_of(const ConfigBase &config, const char *key) +{ + if (const ConfigOption *opt = config.option(key); opt != nullptr) + return opt; + if (const ConfigDef *def = config.def(); def != nullptr) + if (const ConfigOptionDef *opt_def = def->get(key); opt_def != nullptr) + return opt_def->default_value.get(); + return nullptr; +} + +WipeTowerType resolve_wipe_tower_type(const ConfigBase &config) +{ + // printer_model is what the CLI keys its Bambu Lab detection on; the GUI's vendor flag + // agrees for every shipped profile. + if (const auto *model = dynamic_cast(config.option("printer_model")); + model != nullptr && model->value.compare(0, 9, "Bambu Lab") == 0) + return WipeTowerType::Type1; + // By value, not by concrete type: a static PrintConfig holds ConfigOptionEnum, a + // DynamicConfig built from presets holds ConfigOptionEnumGeneric, and both answer getInt(). + const ConfigOption *type = option_of(config, "wipe_tower_type"); + return type != nullptr ? WipeTowerType(type->getInt()) : WipeTowerType::Type2; +} + +WipeTowerFootprint estimate_wipe_tower_footprint(const ConfigBase &config, WipeTowerType tower_type, const std::vector &filament_ids, double layer_height, double max_object_height) { WipeTowerFootprint footprint; footprint.height = max_object_height; + const size_t filaments_cnt = filament_ids.size(); if (filaments_cnt == 0 || layer_height < EPSILON) return footprint; - // Every caller today declares all these keys, but the signature accepts any ConfigBase: - // fall back to the key's declared default, never to a hand-copied constant. - auto option_of = [&config](const char *key) -> const ConfigOption * { - if (const ConfigOption *opt = config.option(key); opt != nullptr) - return opt; - if (const ConfigDef *def = config.def(); def != nullptr) - if (const ConfigOptionDef *opt_def = def->get(key); opt_def != nullptr) - return opt_def->default_value.get(); - return nullptr; - }; - auto opt_float = [&option_of](const char *key) { - const ConfigOption *opt = option_of(key); + auto opt_float = [&config](const char *key) { + const ConfigOption *opt = option_of(config, key); return opt != nullptr ? opt->getFloat() : 0.; }; - auto opt_bool = [&option_of](const char *key) { - const ConfigOption *opt = option_of(key); + auto opt_bool = [&config](const char *key) { + const ConfigOption *opt = option_of(config, key); return opt != nullptr && opt->getBool(); }; - // By value, not by concrete type: a static PrintConfig holds ConfigOptionEnum, a - // DynamicConfig built from presets holds ConfigOptionEnumGeneric, and both answer getInt(). - auto opt_enum = [&option_of](const char *key, int fallback) { - const ConfigOption *opt = option_of(key); + auto opt_enum = [&config](const char *key, int fallback) { + const ConfigOption *opt = option_of(config, key); return opt != nullptr ? opt->getInt() : fallback; }; - auto max_of = [&option_of](const char *key, double fallback) { - const auto *opt = dynamic_cast(option_of(key)); + auto floats_of = [&config](const char *key) { return dynamic_cast(option_of(config, key)); }; + auto max_of = [&floats_of](const char *key, double fallback) { + const auto *opt = floats_of(key); return (opt != nullptr && !opt->values.empty()) ? *std::max_element(opt->values.begin(), opt->values.end()) : fallback; }; + auto float_at = [&floats_of](const char *key, unsigned int id, double fallback) { + const auto *opt = floats_of(key); + return (opt != nullptr && !opt->values.empty()) ? opt->get_at(id) : fallback; + }; + auto int_at = [&config](const char *key, unsigned int id, int fallback) { + const auto *opt = dynamic_cast(option_of(config, key)); + return (opt != nullptr && !opt->values.empty()) ? opt->get_at(id) : fallback; + }; + // Both planners size every layer, so the tower has to fit its thinnest one: the first layer + // when it is printed thinner than the rest. + const double first_layer_height = opt_float("initial_layer_print_height"); + if (first_layer_height > EPSILON) + layer_height = std::min(layer_height, first_layer_height); + + const bool type1 = tower_type == WipeTowerType::Type1; const double width = opt_float("prime_tower_width"); const double prime_volume = opt_float("prime_volume"); - const double extra_spacing = opt_float("prime_tower_infill_gap") / 100.; - double rib_width = opt_float("wipe_tower_rib_width"); + // Type1 spaces its purge lines by prime_tower_infill_gap, Type2 by wipe_tower_extra_spacing. + // Type2's extra flow cancels out of the depth: the line length is divided by it and the row + // pitch multiplied by it (WipeTower2::get_wipe_depth). + const double extra_spacing = opt_float(type1 ? "prime_tower_infill_gap" : "wipe_tower_extra_spacing") / 100.; + const double rib_width = opt_float("wipe_tower_rib_width"); const double extra_rib_length = opt_float("wipe_tower_extra_rib_length"); - const auto *nozzle_opt = dynamic_cast(option_of("nozzle_diameter")); + const auto *nozzle_opt = floats_of("nozzle_diameter"); + const double nozzle_diameter = (nozzle_opt != nullptr && !nozzle_opt->values.empty()) ? nozzle_opt->values.front() : 0.4; const bool dual_nozzle = nozzle_opt != nullptr && nozzle_opt->values.size() == 2; const bool rib_wall = opt_enum("wipe_tower_wall_type", int(WipeTowerWallType::wtwRectangle)) == int(WipeTowerWallType::wtwRib); const bool smooth_timelapse = opt_enum("timelapse_type", int(TimelapseType::tlTraditional)) == int(TimelapseType::tlSmooth); + const bool wrapping = opt_bool("enable_wrapping_detection"); // Reasons a tower is printed with no tool change to purge for: the ones that stop // normalize_fdm_2 clearing enable_prime_tower. Its mixed-filament case is not modelled. - const bool need_wipe_tower = smooth_timelapse || opt_bool("enable_wrapping_detection"); + const bool need_wipe_tower = smooth_timelapse || wrapping; // No tool change, nothing to purge; smooth timelapse still primes once. size_t purge_count = 0; @@ -67,6 +103,8 @@ WipeTowerFootprint estimate_wipe_tower_footprint(const ConfigBase &config, size_ else if (smooth_timelapse) purge_count = 1; + // Type2 purges one volume per tool change. Type1 plans per filament below; here the volume + // only decides whether a tower exists. double volume = prime_volume * double(purge_count); if (dual_nozzle) { // Dual-nozzle printers also purge the filament change length on the tower. @@ -79,33 +117,77 @@ WipeTowerFootprint estimate_wipe_tower_footprint(const ConfigBase &config, size_ if (semm_flush) volume = WipeTower2::estimate_semm_flush_volume(config, filaments_cnt); - // Both wall types decide this together: over-reserving only wastes bed area, but - // reporting no tower for one that is built collapses the validation hull to a point. - // A tool change is a reason on its own: the generator floors the tower whatever the - // purge volumes resolve to. - if (volume < EPSILON && filaments_cnt < 2 && !need_wipe_tower) + // The Type1 planner wipes each filament's own prime volume after changing to it, in a block + // per adhesiveness category. On a two-nozzle printer the leaving filament is also rammed at + // every nozzle change; the tool order groups filaments by nozzle, so a layer crosses + // (nozzles used - 1) times, charged here to the longest ramming. + std::vector purges; + if (type1 && filaments_cnt > 1) { + const bool saving_mode = opt_enum("prime_volume_mode", int(PrimeVolumeMode::pvmDefault)) == int(PrimeVolumeMode::pvmSaving); + std::set nozzles; + size_t longest_ramming = 0; + for (size_t i = 0; i < filaments_cnt; ++i) { + const unsigned int id = filament_ids[i]; + WipeTower::PurgeEstimate purge; + purge.prime_volume = saving_mode ? 15.f : float(float_at("filament_prime_volume", id, prime_volume)); + purge.category = int_at("filament_adhesiveness_category", id, 0); + purge.filament_diameter = float(float_at("filament_diameter", id, 1.75)); + purges.push_back(purge); + if (dual_nozzle) { + nozzles.insert(int_at("filament_map", id, 1)); + if (float_at("filament_change_length", id, 0.) > float_at("filament_change_length", filament_ids[longest_ramming], 0.)) + longest_ramming = i; + } + } + if (nozzles.size() > 1) + purges[longest_ramming].filament_change_length = float(float_at("filament_change_length", filament_ids[longest_ramming], 0.) * double(nozzles.size() - 1)); + } + + // Both wall types decide this together: over-reserving only wastes bed area, but reporting + // no tower for one that is built collapses the validation hull to a point. + // A tool change is a reason on its own (see the base commit); Type1 already reserves + // per filament, Type2 has only the volume, which can resolve to zero. + const bool has_purge = type1 ? !purges.empty() : volume > EPSILON; + if (!has_purge && filaments_cnt < 2 && !need_wipe_tower) return footprint; - const double min_depth = WipeTower::get_limit_depth_by_height(float(max_object_height)); + const double min_depth = WipeTower::get_limit_depth_by_height(float(max_object_height)); + const float perimeter_width = float(nozzle_diameter) * 1.25f; // Width_To_Nozzle_Ratio + // With nothing to purge, plan_tower_new sizes the tower for wrapping detection or the + // stability minimum; WipeTower2 only knows the latter. + const double idle_depth = (type1 && wrapping && !smooth_timelapse) ? WipeTower::get_wrapping_detection_depth() : min_depth; if (rib_wall) { - // A rib wall squares the tower; the ribs run the diagonal and bulge past the body. - const double volume_depth = std::sqrt(volume / layer_height * extra_spacing); - double depth = std::max(min_depth, volume_depth); - rib_width = std::min(rib_width, depth / 2.); - depth = rib_width / std::sqrt(2.) + std::max(depth + extra_rib_length, volume_depth); - footprint.width = footprint.depth = depth; + // Both planners square the tower to the purge area and extend the ribs, not the body, + // below the stability minimum. + double side; + if (!purges.empty()) + side = WipeTower::estimate_rib_tower_bbox_side(purges, float(width), float(layer_height), float(nozzle_diameter), float(extra_spacing), float(rib_width), float(extra_rib_length), float(max_object_height)); + else { + const double square = has_purge ? std::sqrt(volume / layer_height * extra_spacing) : idle_depth; + side = WipeTower::rib_footprint_side(float(square), float(square), float(rib_width), float(extra_rib_length), float(max_object_height)); + } + footprint.width = footprint.depth = side; } else { - double depth = volume / (layer_height * width); - // The flush volumes already hold the spacing between wipes. - if (!semm_flush) - depth *= extra_spacing; + double depth; + if (type1) { + // plan_tower_new stretches a short purge stack to the stability minimum behind its + // leading perimeter width. + depth = purges.empty() ? idle_depth : std::max(min_depth + perimeter_width, double(WipeTower::estimate_tower_blocks_depth(purges, float(width), float(layer_height), float(nozzle_diameter), float(extra_spacing)))); + } else { + depth = volume / (layer_height * width); + // The flush volumes already hold the spacing between wipes. + if (!semm_flush) + depth *= extra_spacing; + depth = std::max(min_depth, depth); + } footprint.width = width; - footprint.depth = std::max(min_depth, depth); + footprint.depth = depth; } footprint.brim_width = opt_float("prime_tower_brim_width"); if (footprint.brim_width < 0) footprint.brim_width = WipeTower::get_auto_brim_by_height(float(max_object_height)); + footprint.brim_width = WipeTower::estimate_brim_real_width(float(footprint.brim_width), float(nozzle_diameter), float(first_layer_height > EPSILON ? first_layer_height : layer_height), !type1); return footprint; } diff --git a/src/libslic3r/GCode/WipeTowerEstimate.hpp b/src/libslic3r/GCode/WipeTowerEstimate.hpp index fe5c0b519c..5b333005e8 100644 --- a/src/libslic3r/GCode/WipeTowerEstimate.hpp +++ b/src/libslic3r/GCode/WipeTowerEstimate.hpp @@ -1,10 +1,11 @@ #pragma once -#include +#include namespace Slic3r { class ConfigBase; +enum class WipeTowerType; // Pre-slice footprint of the wipe tower, shared by validation (Print), the GUI's placement // clamp/preview/arrange and the CLI placement. The arithmetic is shared; the inputs below are @@ -14,16 +15,25 @@ struct WipeTowerFootprint double width = 0.; // effective width: equals depth for a rib wall, which squares the tower double depth = 0.; // 0 when these inputs imply no tower double height = 0.; // tallest object; drives the stability floor and the auto brim - double brim_width = 0.; // configured width, auto (-1) resolved by height + double brim_width = 0.; // printed width: auto (-1) resolved by height, laid in whole loops }; -// filaments_cnt: filaments purged on the plate. The config cannot see custom G-code tool -// changes, so a count derived from the model must include them +// Which planner builds the tower: Bambu Lab printers always get Type1, the rest follow +// wipe_tower_type. The rule Print::wipe_tower_type() and the CLI apply, read off the config so +// the GUI and CLI placement can resolve it without a Print. +WipeTowerType resolve_wipe_tower_type(const ConfigBase &config); + +// filament_ids: 0-based filaments purged on the plate. The config cannot see custom G-code tool +// changes, so ids derived from the model must include them // (Print::extruders(true)) or a real tower is sized as if it were never built. -// layer_height: thinnest layer the tower will be planned at. +// layer_height: thinnest layer the objects are sliced at. The first layer is folded in here. // // A raft is deliberately not a reason: normalize_fdm_2 clears enable_prime_tower for a plate // purging one filament unless smooth timelapse or wrapping detection is on. -WipeTowerFootprint estimate_wipe_tower_footprint(const ConfigBase &config, size_t filaments_cnt, double layer_height, double max_object_height); +WipeTowerFootprint estimate_wipe_tower_footprint(const ConfigBase &config, + WipeTowerType tower_type, + const std::vector &filament_ids, + double layer_height, + double max_object_height); } // namespace Slic3r diff --git a/src/libslic3r/Print.cpp b/src/libslic3r/Print.cpp index b014b0f299..271e410933 100644 --- a/src/libslic3r/Print.cpp +++ b/src/libslic3r/Print.cpp @@ -4010,7 +4010,7 @@ const WipeTowerData &Print::wipe_tower_data(size_t filaments_cnt) const if (max_height < EPSILON) return m_wipe_tower_data; - const WipeTowerFootprint footprint = estimate_wipe_tower_footprint(m_config, filaments_cnt, layer_height, max_height); + const WipeTowerFootprint footprint = estimate_wipe_tower_footprint(m_config, this->wipe_tower_type(), this->extruders(true), layer_height, max_height); WipeTowerData &data = const_cast(this)->m_wipe_tower_data; data.depth = float(footprint.depth); data.width = float(footprint.width); diff --git a/src/slic3r/GUI/PartPlate.cpp b/src/slic3r/GUI/PartPlate.cpp index 09b8ff3068..beb244a39c 100644 --- a/src/slic3r/GUI/PartPlate.cpp +++ b/src/slic3r/GUI/PartPlate.cpp @@ -22,6 +22,7 @@ #include "libslic3r/libslic3r.h" #include "libslic3r/Polygon.hpp" #include "libslic3r/GCode/WipeTowerEstimate.hpp" +#include "libslic3r/GCode/WipeTower2.hpp" #include "libslic3r/ClipperUtils.hpp" #include "libslic3r/BoundingBox.hpp" #include "libslic3r/Geometry.hpp" @@ -2333,22 +2334,20 @@ WipeTowerFootprint PartPlate::estimate_wipe_tower_footprint(const DynamicPrintCo { // The CLI calls this too, so the plate's filaments are derived from the passed config: // get_extruders(bool) reads the same keys off wxGetApp()'s presets, which the CLI has none of. - std::vector plate_extruders; - if (plate_extruder_size == 0) { - plate_extruders = get_extruders(true, config, config); - plate_extruder_size = int(plate_extruders.size()); - } + // An explicit count is a floor: init-time and arrange estimates size an empty plate for that + // many generic filaments, the lowest ids not already on the plate. + std::vector plate_extruders = get_extruders(true, config, config); + for (int id = 1; int(plate_extruders.size()) < plate_extruder_size; ++id) + if (std::find(plate_extruders.begin(), plate_extruders.end(), id) == plate_extruders.end()) + plate_extruders.push_back(id); // The wipe tower filament joins the tool ordering even when unused (Print::extruders), so - // validation counts it. An explicit count is the plate's painted filaments, which never do. + // validation counts it. const ConfigOption *wipe_tower_filament_opt = config.option("wipe_tower_filament"); const int wipe_tower_filament = wipe_tower_filament_opt != nullptr ? wipe_tower_filament_opt->getInt() : 0; - if (plate_extruder_size > 1 && wipe_tower_filament > 0) { - if (plate_extruders.empty()) - plate_extruders = get_extruders(true, config, config); - if (std::find(plate_extruders.begin(), plate_extruders.end(), wipe_tower_filament) == plate_extruders.end()) - ++plate_extruder_size; - } - if (plate_extruder_size == 0) + if (plate_extruders.size() > 1 && wipe_tower_filament > 0 && + std::find(plate_extruders.begin(), plate_extruders.end(), wipe_tower_filament) == plate_extruders.end()) + plate_extruders.push_back(wipe_tower_filament); + if (plate_extruders.empty()) return WipeTowerFootprint(); // Tallest object on this plate and the thinnest layer it is sliced at, resolved per object @@ -2376,7 +2375,11 @@ WipeTowerFootprint PartPlate::estimate_wipe_tower_footprint(const DynamicPrintCo if (layer_height == std::numeric_limits::max()) layer_height = global_layer_height; - return Slic3r::estimate_wipe_tower_footprint(config, size_t(plate_extruder_size), layer_height, max_height); + std::vector filament_ids; + for (int id : plate_extruders) + if (id > 0) + filament_ids.push_back(static_cast(id - 1)); + return Slic3r::estimate_wipe_tower_footprint(config, resolve_wipe_tower_type(config), filament_ids, layer_height, max_height); } arrangement::ArrangePolygon PartPlate::estimate_wipe_tower_polygon(const DynamicPrintConfig& config, int plate_index, Vec3d& wt_pos, Vec3d& wt_size, int plate_extruder_size, bool use_global_objects) const @@ -2391,8 +2394,17 @@ arrangement::ArrangePolygon PartPlate::estimate_wipe_tower_polygon(const Dynamic float depth = wt_size(1); // Resolved brim, not the raw option: "Auto" (-1) would yield a margin of 0 and let the // clamp put the brim off the bed. Matches set_default_wipe_tower_pos_for_plate. - const float wp_brim_width = float(footprint.brim_width); - const float margin = WIPE_TOWER_MARGIN + wp_brim_width; + float wp_brim_width = float(footprint.brim_width); + // A Type2 stabilization cone bulges past the body box like a brim does - fold its worst-axis + // bulge into the same margin (Type1 ignores the cone option). + const auto *cone_wall_opt = config.option("wipe_tower_wall_type"); + const auto *cone_angle_opt = config.option("wipe_tower_cone_angle"); + if (cone_wall_opt != nullptr && cone_wall_opt->getInt() == int(WipeTowerWallType::wtwCone) && cone_angle_opt != nullptr && + cone_angle_opt->getFloat() > EPSILON && resolve_wipe_tower_type(config) == WipeTowerType::Type2) { + const BoundingBox cb = get_extents(WipeTower2::cone_base_polygon(w, depth, wt_size.z(), cone_angle_opt->getFloat())); + wp_brim_width += float(std::max({0., unscaled(cb.max.x()) - w, unscaled(cb.max.y()) - depth, -unscaled(cb.min.x()), -unscaled(cb.min.y())})); + } + const float margin = WIPE_TOWER_MARGIN + wp_brim_width; BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << boost::format("arrange wipe_tower: wp_brim_width %1%") % wp_brim_width; // A tower too deep for the plate leaves no valid position: clamping with hi < lo is UB and diff --git a/src/slic3r/GUI/PartPlate.hpp b/src/slic3r/GUI/PartPlate.hpp index 829d417e04..6d7eb18beb 100644 --- a/src/slic3r/GUI/PartPlate.hpp +++ b/src/slic3r/GUI/PartPlate.hpp @@ -340,7 +340,8 @@ public: Vec3d get_origin() { return m_origin; } //Vec3d calculate_wipe_tower_size(const DynamicPrintConfig &config, const double w, const double wipe_volume, int plate_extruder_size = 0, bool use_global_objects = false) const; - // plate_extruder_size: filaments purged on the plate; 0 derives them from its objects. + // plate_extruder_size: a floor on the filaments purged on the plate; its own are always + // counted, so 0 sizes for exactly those. // use_global_objects skips the containment test, which the CLI needs before objects are // assigned to plates - the layer height is then the project's thinnest, which over-reserves. WipeTowerFootprint estimate_wipe_tower_footprint(const DynamicPrintConfig & config, int plate_extruder_size = 0, bool use_global_objects = false) const; diff --git a/tests/fff_print/test_wipe_tower.cpp b/tests/fff_print/test_wipe_tower.cpp index 9a6c5aa686..5a248075e4 100644 --- a/tests/fff_print/test_wipe_tower.cpp +++ b/tests/fff_print/test_wipe_tower.cpp @@ -184,11 +184,13 @@ TEST_CASE("The wipe tower's toolchange planner flush follows the gcode flavor", } // What Print feeds the shared estimate. The libslic3r WipeTowerEstimate cases cannot see this: -// they call the estimator directly. -static DynamicPrintConfig tower_estimate_config(const char *wall_type) +// they call the estimator directly. The estimate counts the filaments the print really uses, +// so the two-filament shape gives the outer wall the second one. +static DynamicPrintConfig tower_estimate_config(const char *wall_type, unsigned int filaments = 2) { // 100 mm3 per purge on a 50 mm wide tower: one purge is 100/(layer_height * 50) of depth. - return multifilament_config(2, { + return multifilament_config(filaments, { + { "outer_wall_filament_id", filaments == 2 ? "2" : "1" }, { "enable_prime_tower", "1" }, { "wipe_tower_wall_type", wall_type }, { "prime_tower_width", "50" }, @@ -277,7 +279,7 @@ TEST_CASE("A single-filament plate reserves a tower only when one is actually pr Model model; SECTION("no tool change and nothing else that prints one") { - const DynamicPrintConfig config = tower_estimate_config("rib"); + const DynamicPrintConfig config = tower_estimate_config("rib", 1); init_print({ cube(20) }, print, model, config); REQUIRE_FALSE(print.has_wipe_tower()); CHECK_THAT(print.wipe_tower_data(1).depth, Catch::Matchers::WithinAbs(0., 1e-6)); @@ -287,7 +289,7 @@ TEST_CASE("A single-filament plate reserves a tower only when one is actually pr // Print::apply runs normalize_fdm_2, which clears enable_prime_tower for a plate that // purges one filament and has neither smooth timelapse nor wrapping detection on. SECTION("a raft alone does not print one") { - DynamicPrintConfig config = tower_estimate_config("rib"); + DynamicPrintConfig config = tower_estimate_config("rib", 1); config.set_deserialize_strict({ { "raft_layers", "3" } }); init_print({ cube(20) }, print, model, config); REQUIRE_FALSE(print.config().enable_prime_tower.value); @@ -296,7 +298,7 @@ TEST_CASE("A single-filament plate reserves a tower only when one is actually pr } SECTION("smooth timelapse prints one, and keeps enable_prime_tower on") { - DynamicPrintConfig config = tower_estimate_config("rib"); + DynamicPrintConfig config = tower_estimate_config("rib", 1); config.set_deserialize_strict({ { "timelapse_type", "1" } }); init_print({ cube(20) }, print, model, config); REQUIRE(print.has_wipe_tower()); @@ -312,13 +314,12 @@ TEST_CASE("A tower printed without a tool change is still validated against the // checked against the bed. Print print; Model model; - DynamicPrintConfig config = tower_estimate_config("rectangle"); + DynamicPrintConfig config = tower_estimate_config("rectangle", 1); // Relative E without a per-layer G92 is rejected before the tower is ever looked at, and // has_wipe_tower() wants a real exclusion polygon before it honours wrapping detection. config.set_deserialize_strict({ { "enable_wrapping_detection", "1" }, { "wrapping_exclude_area", "180x180,190x180,190x190,180x190" }, - { "wipe_tower_x", "500" }, { "wipe_tower_y", "500" }, - { "use_relative_e_distances", "0" } }); + { "wipe_tower_x", "500" }, { "wipe_tower_y", "500" }, { "use_relative_e_distances", "0" } }); init_print({ cube(20) }, print, model, config); REQUIRE(print.extruders(true).size() == 1); diff --git a/tests/libslic3r/CMakeLists.txt b/tests/libslic3r/CMakeLists.txt index dc8508743e..5c10ab1496 100644 --- a/tests/libslic3r/CMakeLists.txt +++ b/tests/libslic3r/CMakeLists.txt @@ -41,6 +41,7 @@ add_executable(${_TEST_NAME}_tests test_timeutils.cpp test_voronoi.cpp test_wipe_tower_estimate.cpp + test_wipe_tower.cpp test_optimizers.cpp test_ordering_strategies.cpp # test_png_io.cpp diff --git a/tests/libslic3r/test_wipe_tower.cpp b/tests/libslic3r/test_wipe_tower.cpp new file mode 100644 index 0000000000..2987dce9da --- /dev/null +++ b/tests/libslic3r/test_wipe_tower.cpp @@ -0,0 +1,93 @@ +#include + +#include + +#include "libslic3r/BoundingBox.hpp" +#include "libslic3r/ClipperUtils.hpp" +#include "libslic3r/GCode/WipeTower.hpp" +#include "libslic3r/GCode/WipeTower2.hpp" + +using namespace Slic3r; +using Catch::Matchers::WithinAbs; + +// A Bambu P1S project that reproduced the off-plate brim: two PLAs priming 30 and 45 mm3 in +// separate adhesiveness categories on a 35 mm tower, 0.21 mm layers, 0.4 nozzle (0.5 mm lines), +// 150 % infill gap (0.75 mm line pitch), rib width 8, 16 mm tall. +static std::vector cube_purges(int first_category = 100) +{ + return {{30.f, first_category}, {45.f, 0}}; +} + +TEST_CASE("Cone base polygon bulges past the body box", "[WipeTower]") { + // Zero angle: plain body box. + const Polygon box = WipeTower2::cone_base_polygon(35., 20., 100., 0.); + CHECK(box.points.size() == 4); + CHECK(get_extents(box).size() == Point::new_scale(Vec2d(35., 20.))); + // A 25-degree cone on a 100 mm tower: base radius R = tan(12.5deg)*100 = 22.2 mm, + // which exceeds the body half-depth, so the footprint bulges to center +- R in y + // (support_scale keeps the x extent compressed near the body). + const Polygon base = WipeTower2::cone_base_polygon(35., 20., 100., 25.); + const BoundingBox bb = get_extents(base); + const double R = std::tan(25. / 2. * M_PI / 180.) * 100.; + CHECK_THAT(unscaled(bb.min.y()), WithinAbs(10. - R, 0.1)); + CHECK_THAT(unscaled(bb.max.y()), WithinAbs(10. + R, 0.1)); + // The footprint always contains the body box. + CHECK(diff(Polygons{box}, Polygons{base}).empty()); +} + +TEST_CASE("Type1 block-stack depth quantizes each purge to whole lines", "[WipeTower]") { + // A 0.5 mm line at 0.21 mm carries 0.0955 mm3 per mm, so across the 34 mm between the + // perimeters 30 mm3 is 10 lines and 45 mm3 is 14: 7.5 + 10.5 at the 0.75 mm pitch behind + // one perimeter width. The generated mesh of the project measured exactly this. + CHECK_THAT(WipeTower::estimate_tower_blocks_depth(cube_purges(), 35.f, 0.21f, 0.4f, 1.5f), WithinAbs(18.5f, 0.01f)); + // Sharing one category, a layer can never purge into every filament (one of them starts + // the layer), so the block is sized by its worst layer and the 10-line purge drops out. + CHECK_THAT(WipeTower::estimate_tower_blocks_depth(cube_purges(0), 35.f, 0.21f, 0.4f, 1.5f), WithinAbs(11.0f, 0.01f)); + CHECK_THAT(WipeTower::estimate_tower_blocks_depth({}, 35.f, 0.2f, 0.4f, 1.f), WithinAbs(0.f, 1e-6f)); + // A width narrower than two perimeter widths cannot hold purge lines. + CHECK_THAT(WipeTower::estimate_tower_blocks_depth({{45.f, 0}}, 0.9f, 0.2f, 0.4f, 1.f), WithinAbs(0.f, 1e-6f)); +} + +TEST_CASE("A nozzle change adds its ramming lines to the block", "[WipeTower]") { + // 10 mm of 1.75 mm filament (24.05 mm3) laid as 1.0 mm nozzle-change lines at 0.2 mm + // (0.1914 mm2 each) is 125.7 mm; across the 48.5 mm available that is 3 lines of 1.0 mm. + std::vector purges{{100.f, 0}, {100.f, 0}}; + const float without_change = WipeTower::estimate_tower_blocks_depth(purges, 50.f, 0.2f, 0.4f, 1.f); + purges.front().filament_change_length = 10.f; + CHECK_THAT(WipeTower::estimate_tower_blocks_depth(purges, 50.f, 0.2f, 0.4f, 1.f) - without_change, WithinAbs(3.f, 1e-4f)); +} + +TEST_CASE("Rib tower footprint estimate covers the generated footprint", "[WipeTower]") { + // The generated first-layer wall bbox of the project measured 29.56 mm from the sliced + // G-code; the volume-only estimate said 23.585 mm. + const float side = WipeTower::estimate_rib_tower_bbox_side(cube_purges(), 35.f, 0.21f, 0.4f, 1.5f, 8.f, 0.f, 16.f); + CHECK(side >= 29.56f); + CHECK(side <= 29.56f + 4.f); // without grossly over-reserving plate space + // Separate categories stack their blocks, so the footprint must not shrink when they differ. + CHECK(side >= WipeTower::estimate_rib_tower_bbox_side(cube_purges(0), 35.f, 0.21f, 0.4f, 1.5f, 8.f, 0.f, 16.f)); + CHECK_THAT(WipeTower::estimate_rib_tower_bbox_side({}, 35.f, 0.2f, 0.4f, 1.f, 8.f, 0.f, 16.f), WithinAbs(0.f, 1e-6f)); +} + +TEST_CASE("Rib footprint extends the ribs, not the body, below the stability minimum", "[WipeTower]") { + // A 10 mm body under a 90 mm print: the ribs stretch to the minimum depth's diagonal, and + // the rib width is capped at half the body, so the square grows to minimum + 5 / sqrt(2). + const float min_depth = WipeTower::get_limit_depth_by_height(90.f); + REQUIRE(min_depth > 10.f); + CHECK_THAT(WipeTower::rib_footprint_side(10.f, 10.f, 8.f, 0.f, 90.f), WithinAbs(min_depth + 5.f / std::sqrt(2.f), 1e-4f)); + // The extra rib length runs along the diagonal, so it shows as its projection on each axis. + const float plain = WipeTower::rib_footprint_side(30.f, 30.f, 8.f, 0.f, 5.f); + CHECK_THAT(plain, WithinAbs(30.f + 8.f / std::sqrt(2.f), 1e-4f)); + CHECK_THAT(WipeTower::rib_footprint_side(30.f, 30.f, 8.f, 4.f, 5.f) - plain, WithinAbs(4.f / std::sqrt(2.f), 1e-4f)); + // A negative extra length cannot pull the ribs inside the diagonal. + CHECK_THAT(WipeTower::rib_footprint_side(30.f, 30.f, 8.f, -4.f, 5.f), WithinAbs(plain, 1e-4f)); + CHECK_THAT(WipeTower::rib_footprint_side(0.f, 30.f, 8.f, 0.f, 5.f), WithinAbs(0.f, 1e-6f)); +} + +TEST_CASE("Brim width estimate matches each generator's loop quantization", "[WipeTower]") { + // 3 mm configured, 0.4 nozzle, 0.2 first layer: 0.4571 mm spacing, 7 loops. WipeTower2 + // prints and reports the 7 loops; WipeTower reports half a spacing of line width on top. + const float spacing = 0.5f - 0.2f * float(1. - M_PI_4); + CHECK_THAT(WipeTower::estimate_brim_real_width(3.f, 0.4f, 0.2f, true), WithinAbs(7.f * spacing, 1e-4f)); + CHECK_THAT(WipeTower::estimate_brim_real_width(3.f, 0.4f, 0.2f, false), WithinAbs(7.5f * spacing, 1e-4f)); + CHECK_THAT(WipeTower::estimate_brim_real_width(0.f, 0.4f, 0.2f, true), WithinAbs(0.f, 1e-6f)); +} diff --git a/tests/libslic3r/test_wipe_tower_estimate.cpp b/tests/libslic3r/test_wipe_tower_estimate.cpp index 00235bb2ff..ae0a92f40a 100644 --- a/tests/libslic3r/test_wipe_tower_estimate.cpp +++ b/tests/libslic3r/test_wipe_tower_estimate.cpp @@ -6,6 +6,7 @@ #include "libslic3r/PrintConfig.hpp" #include +#include #include using namespace Slic3r; @@ -28,12 +29,16 @@ static DynamicPrintConfig make_config(const char *wall_type = "rectangle") DynamicPrintConfig config = preset_shaped_defaults(); config.set_key_value("prime_tower_width", new ConfigOptionFloat(50.)); config.set_key_value("prime_volume", new ConfigOptionFloat(100.)); + config.set_key_value("filament_prime_volume", new ConfigOptionFloats({100.})); + config.set_key_value("filament_adhesiveness_category", new ConfigOptionInts({0})); config.set_key_value("prime_tower_infill_gap", new ConfigOptionPercent(100.)); + config.set_key_value("wipe_tower_extra_spacing", new ConfigOptionPercent(100.)); config.set_key_value("prime_tower_brim_width", new ConfigOptionFloat(3.)); config.set_deserialize_strict("wipe_tower_wall_type", wall_type); config.set_key_value("wipe_tower_rib_width", new ConfigOptionFloat(8.)); config.set_key_value("wipe_tower_extra_rib_length", new ConfigOptionFloat(0.)); config.set_key_value("nozzle_diameter", new ConfigOptionFloats({0.4})); + config.set_key_value("initial_layer_print_height", new ConfigOptionFloat(0.2)); config.set_deserialize_strict("timelapse_type", "0"); config.set_key_value("enable_wrapping_detection", new ConfigOptionBool(false)); config.set_key_value("raft_layers", new ConfigOptionInt(0)); @@ -42,51 +47,133 @@ static DynamicPrintConfig make_config(const char *wall_type = "rectangle") return config; } +static std::vector filaments(size_t count) +{ + std::vector ids(count); + std::iota(ids.begin(), ids.end(), 0u); + return ids; +} + +// The first `count` filaments on the given planner; Type2 unless a case says otherwise. +static WipeTowerFootprint estimate(const ConfigBase &config, size_t count, double layer_height, double height, WipeTowerType type = WipeTowerType::Type2) +{ + return estimate_wipe_tower_footprint(config, type, filaments(count), layer_height, height); +} + +// What both planners print for a 3 mm brim at 0.4 nozzle and 0.2 first layer (0.4571 mm loops). +static double printed_brim(double configured, WipeTowerType type) +{ + return WipeTower::estimate_brim_real_width(float(configured), 0.4f, 0.2f, type == WipeTowerType::Type2); +} + TEST_CASE("A rectangle wall tower is sized by the purge volume", "[WipeTowerEstimate]") { const DynamicPrintConfig config = make_config(); // Three filaments purge twice per layer; a 5 mm object keeps the stability floor at 5 mm. - const WipeTowerFootprint fp = estimate_wipe_tower_footprint(config, 3, 0.2, 5.); + const WipeTowerFootprint fp = estimate(config, 3, 0.2, 5.); CHECK_THAT(fp.width, WithinAbs(50., 1e-9)); CHECK_THAT(fp.depth, WithinAbs(20., 1e-9)); CHECK_THAT(fp.height, WithinAbs(5., 1e-9)); - CHECK_THAT(fp.brim_width, WithinAbs(3., 1e-9)); + CHECK_THAT(fp.brim_width, WithinAbs(printed_brim(3., WipeTowerType::Type2), 1e-6)); // Thinner layers need more depth for the same volume. - CHECK_THAT(estimate_wipe_tower_footprint(config, 3, 0.1, 5.).depth, WithinAbs(40., 1e-9)); - // The infill gap spaces the purge lines. - DynamicPrintConfig spaced = config; - spaced.set_key_value("prime_tower_infill_gap", new ConfigOptionPercent(150.)); - CHECK_THAT(estimate_wipe_tower_footprint(spaced, 3, 0.2, 5.).depth, WithinAbs(30., 1e-9)); + CHECK_THAT(estimate(config, 3, 0.1, 5.).depth, WithinAbs(40., 1e-9)); +} + +TEST_CASE("Each planner spaces its purge lines by its own option", "[WipeTowerEstimate]") { + // Type2 reads wipe_tower_extra_spacing and Type1 prime_tower_infill_gap; neither sees the + // other's key. Type2's extra flow cancels out of its depth. + DynamicPrintConfig config = make_config(); + config.set_key_value("wipe_tower_extra_flow", new ConfigOptionPercent(250.)); + CHECK_THAT(estimate(config, 3, 0.2, 5.).depth, WithinAbs(20., 1e-9)); + config.set_key_value("wipe_tower_extra_spacing", new ConfigOptionPercent(150.)); + CHECK_THAT(estimate(config, 3, 0.2, 5.).depth, WithinAbs(30., 1e-9)); + const double type1_spaced = estimate(config, 3, 0.2, 5., WipeTowerType::Type1).depth; + config.set_key_value("prime_tower_infill_gap", new ConfigOptionPercent(150.)); + CHECK_THAT(estimate(config, 3, 0.2, 5.).depth, WithinAbs(30., 1e-9)); + // Type1 stacks whole lines behind one 0.5 mm perimeter width, so only the stack scales. + CHECK_THAT(estimate(config, 3, 0.2, 5., WipeTowerType::Type1).depth - 0.5, WithinAbs(1.5 * (type1_spaced - 0.5), 1e-6)); +} + +TEST_CASE("Type1 sizes the tower from each filament's own prime volume", "[WipeTowerEstimate]") { + // The Bambu P1S project of the WipeTower cases: 30 and 45 mm3 in two categories on a 35 mm + // tower at 0.21 mm, 150 % gap, is 18.5 mm of stacked blocks (11 mm sharing one category). + DynamicPrintConfig config = make_config(); + config.set_key_value("prime_tower_width", new ConfigOptionFloat(35.)); + config.set_key_value("prime_tower_infill_gap", new ConfigOptionPercent(150.)); + config.set_key_value("initial_layer_print_height", new ConfigOptionFloat(0.21)); + config.set_key_value("filament_prime_volume", new ConfigOptionFloats({30., 45.})); + config.set_key_value("filament_adhesiveness_category", new ConfigOptionInts({100, 0})); + const std::vector purges{{30.f, 100}, {45.f, 0}}; + const double blocks = WipeTower::estimate_tower_blocks_depth(purges, 35.f, 0.21f, 0.4f, 1.5f); + REQUIRE_THAT(blocks, WithinAbs(18.5, 0.01)); + CHECK_THAT(estimate(config, 2, 0.21, 5., WipeTowerType::Type1).depth, WithinAbs(blocks, 1e-4)); + // The ids pick the volumes, so their order does not matter and a lone filament has no purge. + CHECK_THAT(estimate_wipe_tower_footprint(config, WipeTowerType::Type1, {1, 0}, 0.21, 5.).depth, WithinAbs(blocks, 1e-4)); + CHECK_THAT(estimate(config, 1, 0.21, 5., WipeTowerType::Type1).depth, WithinAbs(0., 1e-9)); + config.set_key_value("filament_adhesiveness_category", new ConfigOptionInts({0, 0})); + CHECK_THAT(estimate(config, 2, 0.21, 5., WipeTowerType::Type1).depth, WithinAbs(11., 0.01)); + // A rib wall squares the same stack. + config.set_deserialize_strict("wipe_tower_wall_type", "rib"); + const WipeTowerFootprint rib = estimate(config, 2, 0.21, 5., WipeTowerType::Type1); + CHECK_THAT(rib.width, WithinAbs(rib.depth, 1e-9)); + CHECK_THAT(rib.depth, WithinAbs(WipeTower::estimate_rib_tower_bbox_side({{30.f, 0}, {45.f, 0}}, 35.f, 0.21f, 0.4f, 1.5f, 8.f, 0.f, 5.f), 1e-4)); +} + +TEST_CASE("A second nozzle adds the ramming of one nozzle change per layer", "[WipeTowerEstimate]") { + // Two filaments on two nozzles: the tool order crosses once per layer, and Type1 rams 10 mm + // of filament as three 1.0 mm nozzle-change lines (see the WipeTower case). + DynamicPrintConfig config = make_config(); + config.set_key_value("nozzle_diameter", new ConfigOptionFloats({0.4, 0.4})); + config.set_key_value("filament_change_length", new ConfigOptionFloats({10., 10.})); + config.set_key_value("filament_diameter", new ConfigOptionFloats({1.75, 1.75})); + config.set_key_value("filament_map", new ConfigOptionInts({1, 1})); + const double same_nozzle = estimate(config, 2, 0.2, 5., WipeTowerType::Type1).depth; + config.set_key_value("filament_map", new ConfigOptionInts({1, 2})); + CHECK_THAT(estimate(config, 2, 0.2, 5., WipeTowerType::Type1).depth - same_nozzle, WithinAbs(3., 1e-4)); +} + +TEST_CASE("The tower is sized for the first layer when it is the thinnest", "[WipeTowerEstimate]") { + // Both planners reserve the worst layer: a 0.28 mm print with a 0.2 mm first layer needs + // the 0.2 mm depth, while a thicker first layer changes nothing. + DynamicPrintConfig config = make_config(); + const double at_thinnest = estimate(config, 3, 0.2, 5.).depth; + CHECK_THAT(estimate(config, 3, 0.28, 5.).depth, WithinAbs(at_thinnest, 1e-9)); + config.set_key_value("initial_layer_print_height", new ConfigOptionFloat(0.3)); + CHECK(estimate(config, 3, 0.28, 5.).depth < at_thinnest); } TEST_CASE("Object height sets the stability floor and the auto brim", "[WipeTowerEstimate]") { DynamicPrintConfig config = make_config(); // Two filaments purge once: 10 mm, lifted to the 20 mm floor of a 100 mm tower. - CHECK_THAT(estimate_wipe_tower_footprint(config, 2, 0.2, 100.).depth, WithinAbs(20., 1e-9)); + CHECK_THAT(estimate(config, 2, 0.2, 100.).depth, WithinAbs(20., 1e-9)); config.set_key_value("prime_tower_brim_width", new ConfigOptionFloat(-1.)); - CHECK_THAT(estimate_wipe_tower_footprint(config, 2, 0.2, 50.).brim_width, WithinAbs(WipeTower::get_auto_brim_by_height(50.f), 1e-6)); + const double auto_brim = WipeTower::get_auto_brim_by_height(50.f); + CHECK_THAT(estimate(config, 2, 0.2, 50.).brim_width, WithinAbs(printed_brim(auto_brim, WipeTowerType::Type2), 1e-6)); + CHECK_THAT(estimate(config, 2, 0.2, 50., WipeTowerType::Type1).brim_width, WithinAbs(printed_brim(auto_brim, WipeTowerType::Type1), 1e-6)); } TEST_CASE("A single filament only gets a tower when one is printed anyway", "[WipeTowerEstimate]") { DynamicPrintConfig config = make_config(); - CHECK_THAT(estimate_wipe_tower_footprint(config, 1, 0.2, 100.).depth, WithinAbs(0., 1e-9)); - CHECK_THAT(estimate_wipe_tower_footprint(config, 0, 0.2, 100.).width, WithinAbs(0., 1e-9)); + CHECK_THAT(estimate(config, 1, 0.2, 100.).depth, WithinAbs(0., 1e-9)); + CHECK_THAT(estimate(config, 0, 0.2, 100.).width, WithinAbs(0., 1e-9)); - // Wrapping detection prints a tower on the first layers whatever the filament count. + // Wrapping detection prints a tower on the first layers whatever the filament count: the + // Type1 planner's fixed 10 mm, the stability floor otherwise. config.set_key_value("enable_wrapping_detection", new ConfigOptionBool(true)); - CHECK_THAT(estimate_wipe_tower_footprint(config, 1, 0.2, 100.).depth, WithinAbs(20., 1e-9)); + CHECK_THAT(estimate(config, 1, 0.2, 100.).depth, WithinAbs(20., 1e-9)); + CHECK_THAT(estimate(config, 1, 0.2, 100., WipeTowerType::Type1).depth, WithinAbs(WipeTower::get_wrapping_detection_depth(), 1e-9)); config.set_key_value("enable_wrapping_detection", new ConfigOptionBool(false)); // A raft is not one of them: normalize_fdm_2 clears enable_prime_tower for a plate that // purges one filament unless smooth timelapse or wrapping detection is on, so a raft // alone leaves no tower to reserve for. config.set_key_value("raft_layers", new ConfigOptionInt(3)); - CHECK_THAT(estimate_wipe_tower_footprint(config, 1, 0.2, 100.).depth, WithinAbs(0., 1e-9)); + CHECK_THAT(estimate(config, 1, 0.2, 100.).depth, WithinAbs(0., 1e-9)); config.set_key_value("raft_layers", new ConfigOptionInt(0)); config.set_deserialize_strict("timelapse_type", "1"); // Smooth timelapse primes the single filament once: 10 mm, lifted to the floor. - CHECK_THAT(estimate_wipe_tower_footprint(config, 1, 0.2, 100.).depth, WithinAbs(20., 1e-9)); - CHECK_THAT(estimate_wipe_tower_footprint(config, 1, 0.2, 5.).depth, WithinAbs(10., 1e-9)); + CHECK_THAT(estimate(config, 1, 0.2, 100.).depth, WithinAbs(20., 1e-9)); + CHECK_THAT(estimate(config, 1, 0.2, 5.).depth, WithinAbs(10., 1e-9)); } TEST_CASE("A tool change reserves the stability floor even with nothing to purge", "[WipeTowerEstimate]") { @@ -97,9 +184,9 @@ TEST_CASE("A tool change reserves the stability floor even with nothing to purge DynamicPrintConfig config = make_config(GENERATE("rectangle", "rib")); config.set_key_value("prime_volume", new ConfigOptionFloat(0.)); - CHECK(estimate_wipe_tower_footprint(config, 3, 0.2, height).depth >= floor); + CHECK(estimate(config, 3, 0.2, height).depth >= floor); // Still nothing for a lone filament with no other reason. - CHECK_THAT(estimate_wipe_tower_footprint(config, 1, 0.2, height).depth, WithinAbs(0., 1e-9)); + CHECK_THAT(estimate(config, 1, 0.2, height).depth, WithinAbs(0., 1e-9)); } TEST_CASE("Both wall types agree on whether there is a tower at all", "[WipeTowerEstimate]") { @@ -110,41 +197,40 @@ TEST_CASE("Both wall types agree on whether there is a tower at all", "[WipeTowe DynamicPrintConfig rib = make_config("rib"); // No tool change and nothing else that prints a tower - neither wall type reserves one. - CHECK_THAT(estimate_wipe_tower_footprint(rect, 1, 0.2, height).depth, WithinAbs(0., 1e-9)); - CHECK_THAT(estimate_wipe_tower_footprint(rib, 1, 0.2, height).depth, WithinAbs(0., 1e-9)); + CHECK_THAT(estimate(rect, 1, 0.2, height).depth, WithinAbs(0., 1e-9)); + CHECK_THAT(estimate(rib, 1, 0.2, height).depth, WithinAbs(0., 1e-9)); // Not even on a dual-nozzle printer, where a lone filament still needs no purge. rect.set_key_value("nozzle_diameter", new ConfigOptionFloats({0.4, 0.4})); rib.set_key_value("nozzle_diameter", new ConfigOptionFloats({0.4, 0.4})); - CHECK_THAT(estimate_wipe_tower_footprint(rect, 1, 0.2, height).depth, WithinAbs(0., 1e-9)); - CHECK_THAT(estimate_wipe_tower_footprint(rib, 1, 0.2, height).depth, WithinAbs(0., 1e-9)); + CHECK_THAT(estimate(rect, 1, 0.2, height).depth, WithinAbs(0., 1e-9)); + CHECK_THAT(estimate(rib, 1, 0.2, height).depth, WithinAbs(0., 1e-9)); // With a tool change both reserve one, and both respect the stability floor. - CHECK(estimate_wipe_tower_footprint(rect, 2, 0.2, height).depth >= WipeTower::get_limit_depth_by_height(float(height))); - CHECK(estimate_wipe_tower_footprint(rib, 2, 0.2, height).depth >= WipeTower::get_limit_depth_by_height(float(height))); + CHECK(estimate(rect, 2, 0.2, height).depth >= WipeTower::get_limit_depth_by_height(float(height))); + CHECK(estimate(rib, 2, 0.2, height).depth >= WipeTower::get_limit_depth_by_height(float(height))); } TEST_CASE("A rib wall squares the tower and caps the rib width", "[WipeTowerEstimate]") { DynamicPrintConfig config = make_config("rib"); // sqrt(200 / 0.2) = 31.62 mm square, plus the 8 mm rib bulge along the diagonal. const double body = std::sqrt(1000.); - WipeTowerFootprint fp = estimate_wipe_tower_footprint(config, 3, 0.2, 5.); - CHECK_THAT(fp.depth, WithinAbs(8. / std::sqrt(2.) + body, 1e-9)); + WipeTowerFootprint fp = estimate(config, 3, 0.2, 5.); + CHECK_THAT(fp.depth, WithinAbs(8. / std::sqrt(2.) + body, 1e-5)); CHECK_THAT(fp.width, WithinAbs(fp.depth, 1e-9)); - // The extra rib length grows the footprint. + // The extra rib length runs along the diagonal and grows the footprint by its projection. config.set_key_value("wipe_tower_extra_rib_length", new ConfigOptionFloat(4.)); - CHECK_THAT(estimate_wipe_tower_footprint(config, 3, 0.2, 5.).depth, WithinAbs(8. / std::sqrt(2.) + body + 4., 1e-9)); + CHECK_THAT(estimate(config, 3, 0.2, 5.).depth, WithinAbs((8. + 4.) / std::sqrt(2.) + body, 1e-5)); // A tiny tower caps the rib width at half its depth: 5 mm body, 2.5 mm rib. config.set_key_value("wipe_tower_extra_rib_length", new ConfigOptionFloat(0.)); config.set_key_value("prime_volume", new ConfigOptionFloat(5.)); - CHECK_THAT(estimate_wipe_tower_footprint(config, 2, 0.2, 5.).depth, WithinAbs(2.5 / std::sqrt(2.) + 5., 1e-9)); + CHECK_THAT(estimate(config, 2, 0.2, 5.).depth, WithinAbs(2.5 / std::sqrt(2.) + 5., 1e-5)); } TEST_CASE("Every wall and tower type is read the same from a preset and a static config", "[WipeTowerEstimate]") { // The GUI, arrange and the CLI pass a DynamicPrintConfig whose enums are // ConfigOptionEnumGeneric; Print passes a static config whose enums are ConfigOptionEnum. - // The wall type is read by value, so both give the same shape, and the wipe tower - // implementation is not an input to the footprint at all. + // Both the wall type and the planner selection are read by value, so both give the same shape. const char *wall_type = GENERATE("rectangle", "cone", "rib"); const char *tower_type = GENERATE("type1", "type2"); DynamicPrintConfig preset = make_config(wall_type); @@ -156,17 +242,18 @@ TEST_CASE("Every wall and tower type is read the same from a preset and a static REQUIRE(static_config.wipe_tower_wall_type.serialize() == wall_type); REQUIRE(static_config.wipe_tower_type.serialize() == tower_type); - // Three filaments purge twice per layer on a 5 mm object: a 50 x 20 rectangle, or a square. - const WipeTowerFootprint fp = estimate_wipe_tower_footprint(preset, 3, 0.2, 5.); - if (std::string(wall_type) == "rib") { - CHECK_THAT(fp.width, WithinAbs(fp.depth, 1e-9)); - CHECK_THAT(fp.depth, WithinAbs(8. / std::sqrt(2.) + std::sqrt(1000.), 1e-9)); - } else { - CHECK_THAT(fp.width, WithinAbs(50., 1e-9)); - CHECK_THAT(fp.depth, WithinAbs(20., 1e-9)); - } + const WipeTowerType type = resolve_wipe_tower_type(preset); + CHECK(type == (std::string(tower_type) == "type1" ? WipeTowerType::Type1 : WipeTowerType::Type2)); + CHECK(resolve_wipe_tower_type(static_config) == type); - const WipeTowerFootprint from_static = estimate_wipe_tower_footprint(static_config, 3, 0.2, 5.); + // Three filaments purge twice per layer on a 5 mm object. + const WipeTowerFootprint fp = estimate(preset, 3, 0.2, 5., type); + const WipeTowerFootprint from_static = estimate(static_config, 3, 0.2, 5., type); + CHECK(fp.depth > 0.); + if (std::string(wall_type) == "rib") + CHECK_THAT(fp.width, WithinAbs(fp.depth, 1e-9)); + else + CHECK_THAT(fp.width, WithinAbs(50., 1e-9)); CHECK_THAT(from_static.width, WithinAbs(fp.width, 1e-9)); CHECK_THAT(from_static.depth, WithinAbs(fp.depth, 1e-9)); CHECK_THAT(from_static.brim_width, WithinAbs(fp.brim_width, 1e-9)); @@ -175,8 +262,19 @@ TEST_CASE("Every wall and tower type is read the same from a preset and a static // through both storages too. preset.set_deserialize_strict("timelapse_type", "1"); static_config.apply(preset, true); - CHECK(estimate_wipe_tower_footprint(preset, 1, 0.2, 5.).depth > 0.); - CHECK(estimate_wipe_tower_footprint(static_config, 1, 0.2, 5.).depth > 0.); + CHECK(estimate(preset, 1, 0.2, 5., type).depth > 0.); + CHECK(estimate(static_config, 1, 0.2, 5., type).depth > 0.); +} + +TEST_CASE("A Bambu Lab printer always gets the Type1 planner", "[WipeTowerEstimate]") { + DynamicPrintConfig config = make_config(); + config.set_deserialize_strict("wipe_tower_type", "type2"); + config.set_key_value("printer_model", new ConfigOptionString("Bambu Lab X1 Carbon")); + CHECK(resolve_wipe_tower_type(config) == WipeTowerType::Type1); + config.set_key_value("printer_model", new ConfigOptionString("Voron 2.4")); + CHECK(resolve_wipe_tower_type(config) == WipeTowerType::Type2); + config.erase("wipe_tower_type"); + CHECK(resolve_wipe_tower_type(config) == WipeTowerType::Type2); } TEST_CASE("A dual nozzle purges every filament plus the filament change", "[WipeTowerEstimate]") { @@ -186,7 +284,7 @@ TEST_CASE("A dual nozzle purges every filament plus the filament change", "[Wipe config.set_key_value("filament_diameter", new ConfigOptionFloats({1.75, 1.75})); // Two purges of 100 mm3 plus one 10 mm filament change: (200 + 10 * pi * 1.75^2 / 4) / (0.2 * 50). const double change_volume = 10. * PI * 1.75 * 1.75 / 4.; - CHECK_THAT(estimate_wipe_tower_footprint(config, 2, 0.2, 5.).depth, WithinAbs((200. + change_volume) / 10., 1e-9)); + CHECK_THAT(estimate(config, 2, 0.2, 5.).depth, WithinAbs((200. + change_volume) / 10., 1e-9)); } TEST_CASE("The shipped defaults size the tower from the flush matrix", "[WipeTowerEstimate]") { @@ -201,19 +299,18 @@ TEST_CASE("The shipped defaults size the tower from the flush matrix", "[WipeTow const double flush_volume = WipeTower2::estimate_semm_flush_volume(config, 2); const double expected = std::max(double(WipeTower::get_limit_depth_by_height(5.f)), flush_volume / (0.2 * 50.)); - CHECK_THAT(estimate_wipe_tower_footprint(config, 2, 0.2, 5.).depth, WithinAbs(expected, 1e-6)); + CHECK_THAT(estimate(config, 2, 0.2, 5.).depth, WithinAbs(expected, 1e-6)); } TEST_CASE("A config missing a tower key falls back to that key's default", "[WipeTowerEstimate]") { // The signature takes any ConfigBase: an absent key must read as its declared default. const DynamicPrintConfig full = make_config(); DynamicPrintConfig partial = full; - partial.erase("prime_tower_infill_gap"); - REQUIRE(partial.option("prime_tower_infill_gap") == nullptr); + partial.erase("wipe_tower_extra_spacing"); + REQUIRE(partial.option("wipe_tower_extra_spacing") == nullptr); DynamicPrintConfig defaulted = full; - defaulted.set_key_value("prime_tower_infill_gap", - print_config_def.get("prime_tower_infill_gap")->default_value->clone()); - CHECK_THAT(estimate_wipe_tower_footprint(partial, 3, 0.2, 5.).depth, - WithinAbs(estimate_wipe_tower_footprint(defaulted, 3, 0.2, 5.).depth, 1e-9)); + defaulted.set_key_value("wipe_tower_extra_spacing", + print_config_def.get("wipe_tower_extra_spacing")->default_value->clone()); + CHECK_THAT(estimate(partial, 3, 0.2, 5.).depth, WithinAbs(estimate(defaulted, 3, 0.2, 5.).depth, 1e-9)); }