From 4deadc9dcea073b44f70232cdaf15d49931170b1 Mon Sep 17 00:00:00 2001 From: HanifKoh <76276251+HanifKoh@users.noreply.github.com> Date: Wed, 9 Sep 2026 12:33:42 +0800 Subject: [PATCH 01/14] Make Tree-Support Deterministic (#15565) * Make tree support deterministic without giving up its parallelism * Break equal-distance ties in the tree support MST by coordinates * test: cover the determinism this PR fixes The MST unit tests here cover the tie-break, but the drop_nodes rework has no test. Adds two cases to the tree support suite. The thread-scheduling one slices five configs twice each and compares the support point sequence, which is what the node ordering moves. The MST tie one pins the branch diameter and line width that carry Prim's equal-distance ties into the toolpaths. slice_with_tree_support takes an optional config list so the second case can add the tree parameters it needs, and the double-slice comparison is shared rather than written twice. Both fail on main without this PR. The first passes from 60d1ceb580, the second from e148865dd6. --------- Co-authored-by: raistlin7447 --- src/libslic3r/MinimumSpanningTree.cpp | 7 +- src/libslic3r/Support/TreeSupport.cpp | 102 ++++++++++++++---- src/libslic3r/Support/TreeSupport3D.cpp | 11 +- tests/fff_print/test_tree_support.cpp | 69 +++++++++++- tests/libslic3r/CMakeLists.txt | 1 + .../libslic3r/test_minimum_spanning_tree.cpp | 66 ++++++++++++ 6 files changed, 223 insertions(+), 33 deletions(-) create mode 100644 tests/libslic3r/test_minimum_spanning_tree.cpp diff --git a/src/libslic3r/MinimumSpanningTree.cpp b/src/libslic3r/MinimumSpanningTree.cpp index ff8fe6e5dd..88555e70ee 100644 --- a/src/libslic3r/MinimumSpanningTree.cpp +++ b/src/libslic3r/MinimumSpanningTree.cpp @@ -60,10 +60,15 @@ auto MinimumSpanningTree::prim(std::vector vertices) const -> AdjacencyGr //This search is O(V) right now, which can be made down to O(log(V)). This reduces the overall time complexity from O(V*V) to O(V*log(E)). //However that requires an implementation of a heap that supports the decreaseKey operation, which is not in the std library. //TODO: Implement this? + // Break equal-distance ties on coordinates: the map is keyed by address, so its + // iteration order (and therefore the first minimum) would otherwise depend on where + // the vertices were allocated. using MapValue = std::pair; const auto closest = std::min_element(smallest_distance.begin(), smallest_distance.end(), [](const MapValue& a, const MapValue& b) { - return a.second < b.second; + if (a.second != b.second) + return a.second < b.second; + return *a.first < *b.first; }); //Add this point to the graph and remove it from the candidates. diff --git a/src/libslic3r/Support/TreeSupport.cpp b/src/libslic3r/Support/TreeSupport.cpp index 2b06f11244..519b6e826e 100644 --- a/src/libslic3r/Support/TreeSupport.cpp +++ b/src/libslic3r/Support/TreeSupport.cpp @@ -2846,7 +2846,9 @@ void TreeSupport::drop_nodes() const MinimumSpanningTree& mst = spanning_trees[group_index]; //In the first pass, merge all nodes that are close together. std::vector> nodes_vec(nodes_this_part.begin(), nodes_this_part.end()); - tbb::parallel_for_each(nodes_vec.begin(), nodes_vec.end(), [&](const std::pair& entry) { + // Sequential: nodes merge into and invalidate each other in place, so parallel execution + // makes the merge order (and thus the result) depend on thread scheduling. + std::for_each(nodes_vec.begin(), nodes_vec.end(), [&](const std::pair& entry) { SupportNode* p_node = entry.second; SupportNode& node = *p_node; if (!p_node->valid) @@ -2934,7 +2936,32 @@ void TreeSupport::drop_nodes() ); //In the second pass, move all middle nodes. - tbb::parallel_for_each(nodes_vec.begin(), nodes_vec.end(), [&](const std::pair& entry) { + // Still parallel: this pass only reads other nodes. Side effects (invalidation, new + // nodes, contact_nodes/unsupported_branch_leaves updates) are recorded per node and + // applied afterwards in node order. Node creation must be deferred too, since + // SupportNode's constructor writes `parent->child = this` on other nodes. + struct PendingNode { + Point position; + int distance_to_top = 0; + int support_roof_layers_below = 0; + bool to_buildplate = false; + SupportNode *parent = nullptr; + bool zero_max_move = false; + bool has_overhang = false; + ExPolygon overhang; + bool clamp_radius = false; + coordf_t parent_radius = 0; + double dist_to_outer = 0; + }; + struct PassTwoResult { + bool invalidate = false; + bool unsupported_leaf = false; + std::vector pending; + }; + std::vector pass2_results(nodes_vec.size()); + auto pass2_body = [&](size_t node_idx) { + const std::pair& entry = nodes_vec[node_idx]; + PassTwoResult& pass2_out = pass2_results[node_idx]; SupportNode* p_node = entry.second; const SupportNode& node = *p_node; @@ -2949,14 +2976,16 @@ void TreeSupport::drop_nodes() ExPolygons overhangs_next = diff_clipped({ node.overhang }, get_collision(0, obj_layer_nr_next)); for(auto& overhang:overhangs_next) { Point next_pt = overhang.contour.centroid(); - SupportNode *next_node = m_ts_data->create_node(next_pt, p_node->distance_to_top + 1, obj_layer_nr_next, - p_node->support_roof_layers_below - (p_node->distance_to_top >= 0 ? 1 : 0), - to_buildplate, p_node, print_z_next, height_next); - next_node->max_move_dist = 0; - next_node->overhang = std::move(overhang); - m_ts_data->m_mutex.lock(); - contact_nodes[layer_nr_next].emplace_back(next_node); - m_ts_data->m_mutex.unlock(); + PendingNode pending; + pending.position = next_pt; + pending.distance_to_top = p_node->distance_to_top + 1; + pending.support_roof_layers_below = p_node->support_roof_layers_below - (p_node->distance_to_top >= 0 ? 1 : 0); + pending.to_buildplate = to_buildplate; + pending.parent = p_node; + pending.zero_max_move = true; + pending.has_overhang = true; + pending.overhang = std::move(overhang); + pass2_out.pending.emplace_back(std::move(pending)); } return; @@ -2973,17 +3002,17 @@ void TreeSupport::drop_nodes() { if (support_on_buildplate_only) { - unsupported_branch_leaves.push_front({ layer_nr, p_node }); + pass2_out.unsupported_leaf = true; } else { - p_node->valid = false; + pass2_out.invalidate = true; } return; } // if the link between parent and current is cut by contours, mark current as bottom contact node if (p_node->parent && intersection_ln({p_node->position, p_node->parent->position}, layer_contours).empty()==false) { - p_node->valid = false; + pass2_out.invalidate = true; return; } } @@ -3096,20 +3125,47 @@ void TreeSupport::drop_nodes() } auto next_collision = get_collision(0, obj_layer_nr_next); const bool to_buildplate = !is_inside_ex(m_ts_data->m_layer_outlines[obj_layer_nr_next], next_layer_vertex); - SupportNode * next_node = m_ts_data->create_node(next_layer_vertex, node.distance_to_top + 1, obj_layer_nr_next, - node.support_roof_layers_below - (node.distance_to_top >= 0 ? 1 : 0), - to_buildplate, p_node, print_z_next, height_next); // don't increase radius if next node will collide partially with the object (STUDIO-7883) - to_outside = projection_onto(next_collision, next_node->position); + to_outside = projection_onto(next_collision, next_layer_vertex); direction_to_outer = to_outside - node.position; double dist_to_outer = unscale_(direction_to_outer.cast().norm()); - next_node->radius = std::max(node.radius, std::min(next_node->radius, dist_to_outer)); - get_max_move_dist(next_node); - m_ts_data->m_mutex.lock(); - contact_nodes[layer_nr_next].push_back(next_node); - m_ts_data->m_mutex.unlock(); + PendingNode pending; + pending.position = next_layer_vertex; + pending.distance_to_top = node.distance_to_top + 1; + pending.support_roof_layers_below = node.support_roof_layers_below - (node.distance_to_top >= 0 ? 1 : 0); + pending.to_buildplate = to_buildplate; + pending.parent = p_node; + pending.clamp_radius = true; + pending.parent_radius = node.radius; + pending.dist_to_outer = dist_to_outer; + pass2_out.pending.emplace_back(std::move(pending)); + }; + tbb::parallel_for(tbb::blocked_range(0, nodes_vec.size()), + [&pass2_body](const tbb::blocked_range& node_range) { + for (size_t node_idx = node_range.begin(); node_idx < node_range.end(); ++ node_idx) + pass2_body(node_idx); + }); + // Apply the recorded side effects in node order. + for (size_t node_idx = 0; node_idx < nodes_vec.size(); ++ node_idx) { + PassTwoResult& pass2_out = pass2_results[node_idx]; + for (PendingNode& pending : pass2_out.pending) { + SupportNode* next_node = m_ts_data->create_node(pending.position, pending.distance_to_top, obj_layer_nr_next, + pending.support_roof_layers_below, pending.to_buildplate, pending.parent, print_z_next, height_next); + if (pending.zero_max_move) + next_node->max_move_dist = 0; + if (pending.has_overhang) + next_node->overhang = std::move(pending.overhang); + if (pending.clamp_radius) { + next_node->radius = std::max(pending.parent_radius, std::min(next_node->radius, pending.dist_to_outer)); + get_max_move_dist(next_node); + } + contact_nodes[layer_nr_next].push_back(next_node); + } + if (pass2_out.unsupported_leaf) + unsupported_branch_leaves.push_front({ layer_nr, nodes_vec[node_idx].second }); + if (pass2_out.invalidate) + nodes_vec[node_idx].second->valid = false; } - ); } #ifdef SUPPORT_TREE_DEBUG_TO_SVG diff --git a/src/libslic3r/Support/TreeSupport3D.cpp b/src/libslic3r/Support/TreeSupport3D.cpp index 29131502d2..3cd3f4110d 100644 --- a/src/libslic3r/Support/TreeSupport3D.cpp +++ b/src/libslic3r/Support/TreeSupport3D.cpp @@ -2382,13 +2382,10 @@ static void merge_influence_areas( size_t num_buckets_initial; { // How many buckets per first merge iteration? - const size_t num_threads = tbb::this_task_arena::max_concurrency(); - // 4 buckets per thread if possible, - const size_t num_buckets_min = (input_size + 2) / 4; - // 2 buckets per thread otherwise. - const size_t num_buckets_max = input_size / 2; - num_buckets_initial = num_buckets_min >= num_threads ? num_buckets_min : num_buckets_max; - const size_t bucket_size = num_buckets_min >= num_threads ? 4 : 2; + // Fixed at 4: merging is not associative, so sizing buckets off max_concurrency() made + // results depend on the core count of the slicing machine. + const size_t bucket_size = 4; + num_buckets_initial = (input_size + 2) / 4; // Fill in the buckets. SupportElementMerging *it = influence_areas.data(); // Reserve one more bucket to keep a single influence area which will not be merged in the first iteration. diff --git a/tests/fff_print/test_tree_support.cpp b/tests/fff_print/test_tree_support.cpp index 362eb0ca56..fbb40bb8f3 100644 --- a/tests/fff_print/test_tree_support.cpp +++ b/tests/fff_print/test_tree_support.cpp @@ -1,5 +1,7 @@ #include +#include + #include "libslic3r/Layer.hpp" #include "libslic3r/TriangleMesh.hpp" @@ -33,10 +35,13 @@ TriangleMesh scaled(TestMesh id, float scale) return mesh; } +// `extra` is applied last, so a caller can add or override any key. void slice_with_tree_support(const TriangleMesh &mesh, Slic3r::Print &print, const char *style, - int threshold_angle = 30, int build_plate_only = 0, int raft_layers = 0) + int threshold_angle = 30, int build_plate_only = 0, int raft_layers = 0, + std::initializer_list extra = {}) { - Slic3r::Test::init_and_process_print({ mesh }, print, { + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + config.set_deserialize_strict({ { "enable_support", 1 }, { "support_type", "tree(auto)" }, { "support_style", style }, @@ -45,6 +50,8 @@ void slice_with_tree_support(const TriangleMesh &mesh, Slic3r::Print &print, con { "raft_layers", raft_layers }, { "layer_height", 0.2 }, }); + config.set_deserialize_strict(extra); + Slic3r::Test::init_and_process_print({ mesh }, print, config); } Points support_points(const Slic3r::Print &print) @@ -63,6 +70,32 @@ size_t support_point_count(const TriangleMesh &mesh, const char *style, int thre return support_points(print).size(); } +// Index of the first differing point, or the common length when they match. An index keeps a +// failure readable; comparing the vectors themselves dumps thousands of points. +size_t first_difference(const Points &a, const Points &b) +{ + const size_t common = std::min(a.size(), b.size()); + for (size_t i = 0; i < common; ++i) + if (a[i] != b[i]) + return i; + return common; +} + +// Slice `mesh` twice and require an identical support point sequence. Point counts and total +// length are order insensitive, so the sequence is what a reordering shows up in. +void sliced_twice_matches(const TriangleMesh &mesh, int build_plate_only, const char *style = "tree_slim", + std::initializer_list extra = {}) +{ + Slic3r::Print first_print, second_print; + slice_with_tree_support(mesh, first_print, style, 30, build_plate_only, 0, extra); + slice_with_tree_support(mesh, second_print, style, 30, build_plate_only, 0, extra); + const Points first = support_points(first_print); + const Points second = support_points(second_print); + REQUIRE(first.size() > 1000); // without support the comparison below passes vacuously + REQUIRE(second.size() == first.size()); + REQUIRE(first_difference(first, second) == first.size()); +} + } // namespace TEST_CASE("Tree support is generated for an overhang and not for a plain cube", "[TreeSupport]") @@ -123,3 +156,35 @@ TEST_CASE("A raft is still generated under tree support", "[TreeSupport]") // The raft goes under the object. REQUIRE(rafted_object->layers().front()->print_z > unrafted_object->layers().front()->print_z); } + +// drop_nodes() decides the node merges and spawns the next layer's nodes in parallel. Every one of +// those decisions has to be applied in a fixed order, or the same model gives different branches on +// each slice. +TEST_CASE("Tree support toolpaths do not depend on thread scheduling", "[TreeSupport][Regression]") +{ + // Scaled up so that a layer holds enough nodes for the parallel range to be split. At stock + // size it stays in one chunk and the order never varies. + SECTION("overhang") { sliced_twice_matches(scaled(TestMesh::overhang, 2.f), 0); } + SECTION("bridge with hole") { sliced_twice_matches(scaled(TestMesh::bridge_with_hole, 3.f), 0); } + // Dropping every branch that cannot reach the bed leaves the survivors dense enough that the + // neighbour merge fires in bulk. + SECTION("on the build plate") { sliced_twice_matches(scaled(TestMesh::overhang, 4.f), 1); } + // Branches resting on the model are what put nodes in a part group other than 0, which is the + // only way to reach the prune in the second pass. tree_hybrid additionally builds polygon + // nodes, so it is the only style that exercises the overhang merge. + SECTION("resting on the model") { sliced_twice_matches(two_tier_mesh(), 0); } + SECTION("hybrid on the model") { sliced_twice_matches(two_tier_mesh(), 0, "tree_hybrid"); } +} + +// Prim breaks equal-distance ties by heap address. A 1 mm branch diameter puts neighbours close +// enough to tie, and an explicit line width pins max_move_dist, so the moved tie winner reaches +// the support toolpaths. +TEST_CASE("Tree support toolpaths do not depend on the MST tie order", "[TreeSupport][Regression]") +{ + sliced_twice_matches(two_tier_mesh(), 0, "tree_hybrid", { + { "tree_support_branch_diameter", 1.0 }, + { "tree_support_branch_distance", 5.0 }, + { "tree_support_branch_angle", 40 }, + { "support_line_width", 0.4 }, + }); +} diff --git a/tests/libslic3r/CMakeLists.txt b/tests/libslic3r/CMakeLists.txt index 5d3e301ea7..0d29ea11ae 100644 --- a/tests/libslic3r/CMakeLists.txt +++ b/tests/libslic3r/CMakeLists.txt @@ -29,6 +29,7 @@ add_executable(${_TEST_NAME}_tests test_polygon.cpp test_mutable_polygon.cpp test_mutable_priority_queue.cpp + test_minimum_spanning_tree.cpp test_nozzle_volume_type.cpp test_step.cpp test_stl.cpp diff --git a/tests/libslic3r/test_minimum_spanning_tree.cpp b/tests/libslic3r/test_minimum_spanning_tree.cpp new file mode 100644 index 0000000000..5f9998171c --- /dev/null +++ b/tests/libslic3r/test_minimum_spanning_tree.cpp @@ -0,0 +1,66 @@ +#include + +#include + +#include "libslic3r/MinimumSpanningTree.hpp" +#include "libslic3r/Point.hpp" + +using namespace Slic3r; + +// A 5x5 lattice: at every step of Prim's algorithm several candidates sit at the same +// distance from the tree, so the tie-break decides the tree's shape. +static std::vector lattice() +{ + std::vector vertices; + for (int y = 0; y < 5; ++y) + for (int x = 0; x < 5; ++x) + vertices.emplace_back(Point::new_scale(x, y)); + return vertices; +} + +static std::vector sorted_neighbours(const MinimumSpanningTree &mst, const Point &vertex) +{ + std::vector neighbours = mst.adjacent_nodes(vertex); + std::sort(neighbours.begin(), neighbours.end()); + return neighbours; +} + +TEST_CASE("Minimum spanning tree connects every vertex", "[MinimumSpanningTree]") +{ + const std::vector vertices = lattice(); + const MinimumSpanningTree mst(vertices); + + REQUIRE(mst.vertices().size() == vertices.size()); + size_t adjacency_entries = 0; + for (const Point &vertex : vertices) { + const std::vector neighbours = mst.adjacent_nodes(vertex); + REQUIRE(! neighbours.empty()); + adjacency_entries += neighbours.size(); + } + // A tree on n vertices has n - 1 edges, each listed from both ends. + REQUIRE(adjacency_entries == 2 * (vertices.size() - 1)); +} + +TEST_CASE("Minimum spanning tree does not depend on the order of the non-root vertices", "[MinimumSpanningTree][Regression]") +{ + const std::vector vertices = lattice(); + const MinimumSpanningTree reference(vertices); + + // The root stays first: Prim's tree legitimately depends on where it starts. + // Every other order of the remaining vertices must give the same tree. + std::vector> orders; + orders.emplace_back(vertices); + std::reverse(orders.back().begin() + 1, orders.back().end()); + for (size_t shift = 1; shift + 1 < vertices.size(); ++shift) { + orders.emplace_back(vertices); + std::rotate(orders.back().begin() + 1, orders.back().begin() + 1 + shift, orders.back().end()); + } + + for (const std::vector &order : orders) { + const MinimumSpanningTree mst(order); + for (const Point &vertex : vertices) { + INFO("vertex " << vertex.x() << "," << vertex.y()); + REQUIRE(sorted_neighbours(mst, vertex) == sorted_neighbours(reference, vertex)); + } + } +} From 8a291f9d561d1ce718867da02eb2fe8e576d0c68 Mon Sep 17 00:00:00 2001 From: HanifKoh <76276251+HanifKoh@users.noreply.github.com> Date: Wed, 9 Sep 2026 15:35:21 +0800 Subject: [PATCH 02/14] Confine config import to the preset directory (#15608) import_presets reduced each zip entry to a basename by stripping only '/', so on Windows an entry named with '\' separators kept its directory components and was extracted wherever they pointed. Strip both separators, and reject any entry whose name still escapes the extraction folder. The preset name from the JSON and the bundle id from bundle_structure.json were joined onto the preset directory unchecked as well, which let either of them write outside it on every platform. Both are now validated before anything is written. The check is the is_path_within_root helper the 3MF importer already had, moved to Utils so both importers share it. It treats '/' and '\' as separators on every platform, so a bundle that would escape on one OS is rejected on all of them. --- src/libslic3r/Format/bbs_3mf.cpp | 39 -------- src/libslic3r/PresetBundle.cpp | 16 +++- src/libslic3r/Utils.hpp | 4 + src/libslic3r/utils.cpp | 24 +++++ .../libslic3r/test_preset_bundle_loading.cpp | 90 +++++++++++++++++++ 5 files changed, 133 insertions(+), 40 deletions(-) diff --git a/src/libslic3r/Format/bbs_3mf.cpp b/src/libslic3r/Format/bbs_3mf.cpp index b0cbb1fd50..b4f6dc1d45 100644 --- a/src/libslic3r/Format/bbs_3mf.cpp +++ b/src/libslic3r/Format/bbs_3mf.cpp @@ -102,45 +102,6 @@ struct ZipUnicodePathExtraField } }; -// Validate that a relative file path does not escape the root directory via path traversal. -static bool is_path_within_root(const std::string& file_path, const boost::filesystem::path& root) -{ - if (file_path.empty()) - return false; - - boost::filesystem::path p(file_path); - if (p.is_absolute()) - return false; - - // Reject any path component that is ".." - for (const auto& component : p) { - if (component == "..") - return false; - } - - // Resolve the full path and verify it starts with the canonical root (also catches symlink escapes) - try { - boost::filesystem::path full_path = root / p; - boost::filesystem::path canonical_root = boost::filesystem::weakly_canonical(root); - boost::filesystem::path canonical_full = boost::filesystem::weakly_canonical(full_path); - - auto root_str = canonical_root.string(); - auto full_str = canonical_full.string(); - if (full_str.length() < root_str.length()) - return false; - if (full_str.compare(0, root_str.length(), root_str) != 0) - return false; - // Ensure it's a proper prefix (not just a substring of a longer directory name) - if (full_str.length() > root_str.length() && - full_str[root_str.length()] != boost::filesystem::path::preferred_separator) - return false; - } catch (const boost::filesystem::filesystem_error&) { - return false; - } - - return true; -} - // VERSION NUMBERS // 0 : .3mf, files saved by older slic3r or other applications. No version definition in them. // 1 : Introduction of 3mf versioning. No other change in data saved into 3mf files. diff --git a/src/libslic3r/PresetBundle.cpp b/src/libslic3r/PresetBundle.cpp index 6d4e77837a..f17fd5c768 100644 --- a/src/libslic3r/PresetBundle.cpp +++ b/src/libslic3r/PresetBundle.cpp @@ -1614,6 +1614,12 @@ PresetsConfigSubstitutions PresetBundle::import_presets(std::vector metadata.id = to_string(uuid); BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << " bundle_id was empty, so generating a UUID: " << metadata.id; } + if (has_bundle_structure && !is_path_within_root(metadata.id, user_folder / user_id / PRESET_LOCAL_DIR)) { + BOOST_LOG_TRIVIAL(error) << __FUNCTION__ << " bundle id escapes the bundle directory, not importing: " << metadata.id; + fclose(zipFile); + fs::remove_all(temp_folder, ec); + continue; + } // Build bundle directory path based on whether bundle_structure.json was present fs::path bundle_base_dir; @@ -1636,11 +1642,15 @@ PresetsConfigSubstitutions PresetBundle::import_presets(std::vector if (status) { std::string file_name = file_stat.m_filename; BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << " From zip file: " << file << ". Read file name: " << file_stat.m_filename; - size_t index = file_name.find_last_of('/'); + size_t index = file_name.find_last_of("/\\"); if (std::string::npos != index) { file_name = file_name.substr(index + 1); } if (BUNDLE_STRUCTURE_JSON_NAME == file_name) continue; + if (!is_path_within_root(file_name, temp_folder)) { + BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << " zip entry escapes the temp directory, skipping: " << file_stat.m_filename; + continue; + } // create target file path std::string target_file_path = boost::filesystem::path(temp_folder / file_name).make_preferred().string(); @@ -1729,6 +1739,10 @@ bool PresetBundle::import_json_presets(PresetsConfigSubstitutions & s BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << " Preset type is unknown, not loading: " << name; return false; } + if (!is_path_within_root(name, fs::path(collection->m_dir_path))) { + BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << " Preset name escapes the preset directory, not loading: " << name; + return false; + } const PresetOrigin load_origin = detect_origin_from_path(boost::filesystem::path(bundle_dir)); const std::string preset_name = get_preset_canonical_name(name, load_origin); diff --git a/src/libslic3r/Utils.hpp b/src/libslic3r/Utils.hpp index 55d9b716cf..797894442a 100644 --- a/src/libslic3r/Utils.hpp +++ b/src/libslic3r/Utils.hpp @@ -255,6 +255,10 @@ extern bool is_gallery_file(const std::string& path, char const* type); extern bool is_shapes_dir(const std::string& dir); //BBS: add json support extern bool is_json_file(const std::string& path); +// True if rel_path is relative, has no ".." component and, joined to root, still resolves inside it. +// Both '/' and '\\' are treated as separators on every platform, so an archive rejected on one OS +// is rejected on all of them. +extern bool is_path_within_root(const std::string &rel_path, const boost::filesystem::path &root); // Orca: custom protocal support utils inline bool is_orca_open(const std::string& url) { return boost::starts_with(url, "orcaslicer://open"); } diff --git a/src/libslic3r/utils.cpp b/src/libslic3r/utils.cpp index 5f4baac951..875c90f6ab 100644 --- a/src/libslic3r/utils.cpp +++ b/src/libslic3r/utils.cpp @@ -1088,6 +1088,30 @@ bool is_json_file(const std::string& path) return boost::iends_with(path, ".json"); } +bool is_path_within_root(const std::string &rel_path, const boost::filesystem::path &root) +{ + auto is_separator = [](char c) { return c == '/' || c == '\\'; }; + if (rel_path.empty() || is_separator(rel_path.front()) || (rel_path.size() > 1 && rel_path[1] == ':')) + return false; + for (size_t start = 0; start <= rel_path.size();) { + size_t end = start; + while (end < rel_path.size() && !is_separator(rel_path[end])) + ++end; + if (rel_path.compare(start, end - start, "..") == 0) + return false; + start = end + 1; + } + // Resolve against the canonical root so a symlink inside it cannot lead back out. + try { + const std::string root_str = boost::filesystem::weakly_canonical(root).string(); + const std::string full_str = boost::filesystem::weakly_canonical(root / rel_path).string(); + return full_str.compare(0, root_str.size(), root_str) == 0 && + (full_str.size() == root_str.size() || full_str[root_str.size()] == boost::filesystem::path::preferred_separator); + } catch (const boost::filesystem::filesystem_error &) { + return false; + } +} + bool is_img_file(const std::string &path) { return boost::iends_with(path, ".png") || boost::iends_with(path, ".svg"); diff --git a/tests/libslic3r/test_preset_bundle_loading.cpp b/tests/libslic3r/test_preset_bundle_loading.cpp index 26cbf387ec..d01711000a 100644 --- a/tests/libslic3r/test_preset_bundle_loading.cpp +++ b/tests/libslic3r/test_preset_bundle_loading.cpp @@ -6,6 +6,8 @@ #include "libslic3r/PresetBundle.hpp" #include "libslic3r/AppConfig.hpp" +#include "libslic3r/Utils.hpp" +#include "libslic3r/miniz_extension.hpp" #include "test_utils.hpp" @@ -1406,3 +1408,91 @@ TEST_CASE("Sizing down to the nozzle count plus mixes is what eats the mixed tai CHECK(bundle.project_config.option("filament_mixed_components")->values[5] == "1,2"); } } + +namespace { + +// data_dir() is a process-wide global that import_presets extracts into; scope it to the test. +struct ScopedDataDir +{ + std::string previous = data_dir(); + explicit ScopedDataDir(const fs::path &dir) { set_data_dir(dir.string()); } + ~ScopedDataDir() { set_data_dir(previous); } +}; + +std::string read_file(const fs::path &file) +{ + std::ifstream in(file.string(), std::ios::binary); + return std::string(std::istreambuf_iterator(in), std::istreambuf_iterator()); +} + +void write_zip(const fs::path &zip_file, const std::vector> &entries) +{ + mz_zip_archive zip; + mz_zip_zero_struct(&zip); + REQUIRE(open_zip_writer(&zip, zip_file.string())); + for (const auto &[name, content] : entries) + REQUIRE(mz_zip_writer_add_mem(&zip, name.c_str(), content.data(), content.size(), MZ_DEFAULT_COMPRESSION)); + REQUIRE(mz_zip_writer_finalize_archive(&zip)); + REQUIRE(close_zip_writer(&zip)); +} + +bool any_filename_contains(const fs::path &root, const std::string &needle) +{ + for (fs::recursive_directory_iterator it(root), end; it != end; ++it) + if (it->path().filename().string().find(needle) != std::string::npos) + return true; + return false; +} + +} // namespace + +TEST_CASE("Config import confines zip entries, preset names and bundle ids to the preset directory", "[Preset][Bundle][Regression]") +{ + ScopedTemporaryDir temp_dir; + const fs::path data_root = temp_dir.path() / "datadir"; + const fs::path src_dir = temp_dir.path() / "src"; + fs::create_directories(src_dir); + ScopedDataDir scoped_data_dir(data_root); + + PresetBundle bundle; + AppConfig app_config; + const auto confirm = [](std::string const &) { return 1; }; + const auto import = [&](const fs::path &file) { + std::vector files{file.string()}; + bundle.import_presets(files, confirm, ForwardCompatibilitySubstitutionRule::Disable, app_config); + return files; + }; + + const fs::path good_file = src_dir / "Good.json"; + write_print_preset(bundle.prints.default_preset().config, good_file, "Good"); + const std::string good_json = read_file(good_file); + + // Four levels up from where import_presets writes (/user/default/temp) is temp_dir + // itself, so anything that escapes lands where the scan below can see it. + const std::string up = "../../../../"; + const std::string up_win = "..\\..\\..\\..\\"; + + SECTION("zip entry names with either separator are reduced to a basename") { + const fs::path zip = src_dir / "bundle.zip"; + write_zip(zip, {{up + "zip-escape.json", "{}"}, {up_win + "zip-escape.json", "{}"}, {"presets/Good.json", good_json}}); + import(zip); + CHECK(bundle.prints.find_preset("Good") != nullptr); + CHECK_FALSE(any_filename_contains(temp_dir.path(), "zip-escape")); + } + + SECTION("a preset name that walks out of the preset directory is rejected") { + for (const std::string &name : {up + "name-escape", up_win + "name-escape"}) { + const fs::path file = src_dir / "escape.json"; + write_print_preset(bundle.prints.default_preset().config, file, name); + CHECK(import(file).empty()); + CHECK_FALSE(any_filename_contains(temp_dir.path(), "name-escape")); + } + } + + SECTION("a bundle id that walks out of the bundle directory is rejected") { + const fs::path zip = src_dir / "bundle.zip"; + write_zip(zip, {{BUNDLE_STRUCTURE_JSON_NAME, "{\"id\": \"" + up + "bundle-escape\"}"}, {"Good.json", good_json}}); + CHECK(import(zip).empty()); + CHECK_FALSE(any_filename_contains(temp_dir.path(), "bundle-escape")); + } +} From 8df5e5e738aae791c2fac9cfa8b0534ed7639e5c Mon Sep 17 00:00:00 2001 From: Hanif Koh Date: Fri, 4 Sep 2026 17:15:19 +0800 Subject: [PATCH 03/14] Extract and Unify Wipe Tower Estimation --- src/OrcaSlicer.cpp | 43 ++-- src/libslic3r/CMakeLists.txt | 2 + src/libslic3r/GCode/WipeTowerEstimate.cpp | 109 ++++++++++ src/libslic3r/GCode/WipeTowerEstimate.hpp | 29 +++ src/libslic3r/Print.cpp | 100 +++------ src/libslic3r/Print.hpp | 4 + src/slic3r/GUI/GLCanvas3D.cpp | 12 +- src/slic3r/GUI/Jobs/ArrangeJob.cpp | 3 +- src/slic3r/GUI/PartPlate.cpp | 214 +++++++++---------- src/slic3r/GUI/PartPlate.hpp | 10 +- tests/fff_print/test_wipe_tower.cpp | 83 +++++++ tests/libslic3r/CMakeLists.txt | 1 + tests/libslic3r/test_wipe_tower_estimate.cpp | 206 ++++++++++++++++++ 13 files changed, 589 insertions(+), 227 deletions(-) create mode 100644 src/libslic3r/GCode/WipeTowerEstimate.cpp create mode 100644 src/libslic3r/GCode/WipeTowerEstimate.hpp create mode 100644 tests/libslic3r/test_wipe_tower_estimate.cpp diff --git a/src/OrcaSlicer.cpp b/src/OrcaSlicer.cpp index 31f39921f4..5b1d2da1bf 100644 --- a/src/OrcaSlicer.cpp +++ b/src/OrcaSlicer.cpp @@ -4015,7 +4015,7 @@ int CLI::run(int argc, char **argv) } }; - auto check_plate_wipe_tower = [get_print_sequence, is_smooth_timelapse, new_extruder_count](Slic3r::GUI::PartPlate* plate, int plate_index, DynamicPrintConfig& print_config, plate_obj_size_info_t &plate_obj_size_info) { + auto check_plate_wipe_tower = [get_print_sequence, is_smooth_timelapse](Slic3r::GUI::PartPlate* plate, int plate_index, DynamicPrintConfig& print_config, plate_obj_size_info_t &plate_obj_size_info) { plate_obj_size_info.obj_bbox= plate->get_objects_bounding_box(); BOOST_LOG_TRIVIAL(info) << boost::format("plate %1%, object bbox: min {%2%, %3%, %4%} - max {%5%, %6%, %7%}") %(plate_index+1) %plate_obj_size_info.obj_bbox.min.x() % plate_obj_size_info.obj_bbox.min.y() % plate_obj_size_info.obj_bbox.min.z() %plate_obj_size_info.obj_bbox.max.x() % plate_obj_size_info.obj_bbox.max.y() % plate_obj_size_info.obj_bbox.max.z(); @@ -4059,22 +4059,13 @@ int CLI::run(int argc, char **argv) plate_obj_size_info.wipe_x = wipe_x_option->get_at(plate_index); plate_obj_size_info.wipe_y = wipe_y_option->get_at(plate_index); - ConfigOptionFloat* width_option = print_config.option("prime_tower_width", true); - plate_obj_size_info.wipe_width = width_option->value; + // Body and brim from one estimate: resolving an auto (-1) brim against a different + // height would size the two halves of the same tower from two different objects. + const WipeTowerFootprint footprint = plate->estimate_wipe_tower_footprint(print_config, filaments_cnt); + float brim_width = float(footprint.brim_width); - ConfigOptionFloat* brim_width_option = print_config.option("prime_tower_brim_width", true); - float brim_width = brim_width_option->value; - if (brim_width < 0) brim_width = WipeTower::get_auto_brim_by_height((float)plate_obj_size_info.obj_bbox.max.z()); - - ConfigOptionFloat* volume_option = print_config.option("prime_volume", true); - float wipe_volume = volume_option->value; - - const ConfigOptionBool * wrapping_detection = print_config.option("enable_wrapping_detection"); - bool enable_wrapping = (wrapping_detection != nullptr) && wrapping_detection->value; - - Vec3d wipe_tower_size = plate->estimate_wipe_tower_size(print_config, plate_obj_size_info.wipe_width, wipe_volume, new_extruder_count, filaments_cnt, false, enable_wrapping); - plate_obj_size_info.wipe_width = wipe_tower_size(0); - plate_obj_size_info.wipe_depth = wipe_tower_size(1); + plate_obj_size_info.wipe_width = footprint.width; + plate_obj_size_info.wipe_depth = footprint.depth; Vec3d origin = plate->get_origin(); Vec3d start(origin(0) + plate_obj_size_info.wipe_x - brim_width, origin(1) + plate_obj_size_info.wipe_y, 0.f); @@ -4875,7 +4866,7 @@ int CLI::run(int argc, char **argv) wipe_y_option->set_at(&wt_y_opt, i, 0); Vec3d wipe_tower_size, wipe_tower_pos; - ArrangePolygon wipe_tower_ap = cur_plate->estimate_wipe_tower_polygon(m_print_config, i, wipe_tower_pos, wipe_tower_size, new_extruder_count, assemble_plate.filaments_count, true); + ArrangePolygon wipe_tower_ap = cur_plate->estimate_wipe_tower_polygon(m_print_config, i, wipe_tower_pos, wipe_tower_size, assemble_plate.filaments_count, true); //update the new wp position wt_x_opt.value = wipe_tower_pos(0); @@ -5175,7 +5166,7 @@ int CLI::run(int argc, char **argv) } Vec3d wipe_tower_size, wipe_tower_pos; - ArrangePolygon wipe_tower_ap = partplate_list.get_plate(plate_index_valid)->estimate_wipe_tower_polygon(m_print_config, plate_index_valid, wipe_tower_pos, wipe_tower_size, new_extruder_count, extruder_size, true); + ArrangePolygon wipe_tower_ap = partplate_list.get_plate(plate_index_valid)->estimate_wipe_tower_polygon(m_print_config, plate_index_valid, wipe_tower_pos, wipe_tower_size, extruder_size, true); //update the new wp position if (bedid < plate_count) { @@ -5276,22 +5267,16 @@ int CLI::run(int argc, char **argv) //float depth = v * (filaments_cnt - 1) / (layer_height * w); - const ConfigOptionBool *wrapping_detection = m_print_config.option("enable_wrapping_detection"); - bool enable_wrapping = (wrapping_detection != nullptr) && wrapping_detection->value; - - Vec3d wipe_tower_size = cur_plate->estimate_wipe_tower_size(m_print_config, w, v, new_extruder_count, filaments_cnt, false, enable_wrapping); + const WipeTowerFootprint footprint = cur_plate->estimate_wipe_tower_footprint(m_print_config, filaments_cnt); + Vec3d wipe_tower_size(footprint.width, footprint.depth, footprint.height); Vec3d plate_origin = cur_plate->get_origin(); int plate_width, plate_depth; double plate_height; partplate_list.get_plate_size(plate_width, plate_depth, plate_height); float depth = wipe_tower_size(1); - float margin = 15.f, wp_brim_width = 0.f; - ConfigOption *wipe_tower_brim_width_opt = m_print_config.option("prime_tower_brim_width"); - if (wipe_tower_brim_width_opt ) { - wp_brim_width = wipe_tower_brim_width_opt->getFloat(); - if (wp_brim_width < 0) wp_brim_width = WipeTower::get_auto_brim_by_height((float) wipe_tower_size.z()); - BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << boost::format("arrange wipe_tower: wp_brim_width %1%")%wp_brim_width; - } + // Brim already resolved against the height the body was sized from. + float margin = 15.f, wp_brim_width = float(footprint.brim_width); + BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << boost::format("arrange wipe_tower: wp_brim_width %1%")%wp_brim_width; w = wipe_tower_size(0); BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << boost::format("arrange wipe_tower: x=%1%, y=%2%, width=%3%, depth=%4%, angle=%5%, prime_volume=%6%, filaments_cnt=%7%, layer_height=%8%, plate_width=%9%, plate_depth=%10%") diff --git a/src/libslic3r/CMakeLists.txt b/src/libslic3r/CMakeLists.txt index d07c42d8f6..23b6decb2c 100644 --- a/src/libslic3r/CMakeLists.txt +++ b/src/libslic3r/CMakeLists.txt @@ -272,6 +272,8 @@ set(lisbslic3r_sources GCode/WipeTower2.hpp GCode/WipeTower.cpp GCode/WipeTower.hpp + GCode/WipeTowerEstimate.cpp + GCode/WipeTowerEstimate.hpp GCodeWriter.cpp GCodeWriter.hpp Geometry/ArcWelder.hpp diff --git a/src/libslic3r/GCode/WipeTowerEstimate.cpp b/src/libslic3r/GCode/WipeTowerEstimate.cpp new file mode 100644 index 0000000000..9e9bb7de4b --- /dev/null +++ b/src/libslic3r/GCode/WipeTowerEstimate.cpp @@ -0,0 +1,109 @@ +#include "WipeTowerEstimate.hpp" + +#include "WipeTower.hpp" +#include "WipeTower2.hpp" +#include "../Config.hpp" +#include "../PrintConfig.hpp" +#include "../libslic3r.h" + +#include +#include + +namespace Slic3r { + +WipeTowerFootprint estimate_wipe_tower_footprint(const ConfigBase &config, size_t filaments_cnt, double layer_height, double max_object_height, bool any_raft) +{ + WipeTowerFootprint footprint; + footprint.height = max_object_height; + 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); + return opt != nullptr ? opt->getFloat() : 0.; + }; + auto opt_bool = [&option_of](const char *key) { + const ConfigOption *opt = option_of(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); + return opt != nullptr ? opt->getInt() : fallback; + }; + auto max_of = [&option_of](const char *key, double fallback) { + const auto *opt = dynamic_cast(option_of(key)); + return (opt != nullptr && !opt->values.empty()) ? *std::max_element(opt->values.begin(), opt->values.end()) : fallback; + }; + + 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"); + const double extra_rib_length = opt_float("wipe_tower_extra_rib_length"); + const auto *nozzle_opt = dynamic_cast(option_of("nozzle_diameter")); + 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); + // Reasons a tower is printed with no tool change to purge for. + const bool need_wipe_tower = smooth_timelapse || opt_bool("enable_wrapping_detection") || any_raft; + + // No tool change, nothing to purge; smooth timelapse still primes once. + size_t purge_count = 0; + if (filaments_cnt > 1) + purge_count = dual_nozzle ? filaments_cnt : filaments_cnt - 1; + else if (smooth_timelapse) + purge_count = 1; + + double volume = prime_volume * double(purge_count); + if (dual_nozzle) { + // Dual-nozzle printers also purge the filament change length on the tower. + const double length = max_of("filament_change_length", 0.); + const double diameter = max_of("filament_diameter", 1.75); + volume += length * PI * diameter * diameter / 4. * double(filaments_cnt / 2); + } + // Single-extruder multi-material purges the flush matrix instead of the prime volume. + const bool semm_flush = opt_bool("purge_in_prime_tower") && opt_bool("single_extruder_multi_material"); + 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. + if (volume < EPSILON && !need_wipe_tower) + return footprint; + + const double min_depth = WipeTower::get_limit_depth_by_height(float(max_object_height)); + 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; + } else { + double depth = volume / (layer_height * width); + // The flush volumes already hold the spacing between wipes. + if (!semm_flush) + depth *= extra_spacing; + footprint.width = width; + footprint.depth = std::max(min_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)); + return footprint; +} + +} // namespace Slic3r diff --git a/src/libslic3r/GCode/WipeTowerEstimate.hpp b/src/libslic3r/GCode/WipeTowerEstimate.hpp new file mode 100644 index 0000000000..911ca9560c --- /dev/null +++ b/src/libslic3r/GCode/WipeTowerEstimate.hpp @@ -0,0 +1,29 @@ +#pragma once + +#include + +namespace Slic3r { + +class ConfigBase; + +// 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 +// not, so a change to how one caller derives them has to be mirrored in the others. +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 +}; + +// 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 +// (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. +// any_raft: any object on the plate prints a raft, which puts the tower on every layer +// below it. Caller-resolved: raft_layers is a PrintObjectConfig key, absent +// from Print's config and overridable per object. +WipeTowerFootprint estimate_wipe_tower_footprint(const ConfigBase &config, size_t filaments_cnt, double layer_height, double max_object_height, bool any_raft); + +} // namespace Slic3r diff --git a/src/libslic3r/Print.cpp b/src/libslic3r/Print.cpp index aee50db534..7d362537ea 100644 --- a/src/libslic3r/Print.cpp +++ b/src/libslic3r/Print.cpp @@ -20,6 +20,7 @@ #include "GCode.hpp" #include "GCode/WipeTower.hpp" #include "GCode/WipeTower2.hpp" +#include "GCode/WipeTowerEstimate.hpp" #include "Utils.hpp" #include "PrintConfig.hpp" #include "MaterialType.hpp" @@ -1031,20 +1032,20 @@ static StringObjectException layered_print_cleareance_valid(const Print &print, //BBS: add the wipe tower check logic const PrintConfig & config = print.config(); - int filaments_count = print.extruders().size(); + // Custom G-code tool changes (MultiAsSingle) build a real tower on a plate whose objects + // all use one filament, so they have to be counted or the hull below collapses to a point. + int filaments_count = print.extruders(true).size(); int plate_index = print.get_plate_index(); const Vec3d plate_origin = print.get_plate_origin(); float x = config.wipe_tower_x.get_at(plate_index) + plate_origin(0); float y = config.wipe_tower_y.get_at(plate_index) + plate_origin(1); - float width = config.prime_tower_width.value; float a = config.wipe_tower_rotation_angle.value; //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; - - if (config.wipe_tower_wall_type.value == WipeTowerWallType::wtwRib) - width = depth; + // The estimate resolves the effective width (a rib wall squares the tower). + const WipeTowerData &wipe_tower_estimate = print.wipe_tower_data(filaments_count); + float width = wipe_tower_estimate.width; + float depth = wipe_tower_estimate.depth; Polygons convex_hulls_temp; if (print.has_wipe_tower()) { @@ -3997,74 +3998,27 @@ bool Print::has_wipe_tower() const const WipeTowerData &Print::wipe_tower_data(size_t filaments_cnt) const { - // If the wipe tower wasn't created yet, make sure the depth and brim_width members are set to default. - double max_height = 0; - for (size_t obj_idx = 0; obj_idx < m_objects.size(); obj_idx++) { - double object_z = (double) m_objects[obj_idx]->size().z(); - max_height = std::max(unscale_(object_z), max_height); + // Until the tower is generated, size it with the estimate the GUI/CLI placement uses, so + // validation cannot reject a position the clamp just accepted. + if (is_step_done(psWipeTower) || filaments_cnt == 0) + return m_wipe_tower_data; + + double max_height = 0.; + double layer_height = std::numeric_limits::max(); + bool any_raft = false; + for (const PrintObject *object : m_objects) { + max_height = std::max(max_height, unscale_(double(object->size().z()))); + layer_height = std::min(layer_height, object->config().layer_height.value); + any_raft = any_raft || object->config().raft_layers.value > 0; } - if (max_height < EPSILON) return m_wipe_tower_data; + if (max_height < EPSILON) + return m_wipe_tower_data; - double layer_height = 0.08f; // hard code layer height - layer_height = m_objects.front()->config().layer_height.value; - - auto timelapse_type = config().option>("timelapse_type"); - bool need_wipe_tower = (timelapse_type ? (timelapse_type->value == TimelapseType::tlSmooth) : false) | (m_config.wipe_tower_wall_type.value == WipeTowerWallType::wtwRib); - double extra_spacing = config().option("prime_tower_infill_gap")->getFloat() / 100.; - double rib_width = config().option("wipe_tower_rib_width")->getFloat(); - - double filament_change_volume = 0.; - { - std::vector filament_change_lengths; - auto filament_change_lengths_opt = config().option("filament_change_length"); - if (filament_change_lengths_opt) filament_change_lengths = filament_change_lengths_opt->values; - double length = filament_change_lengths.empty() ? 0 : *std::max_element(filament_change_lengths.begin(), filament_change_lengths.end()); - double diameter = 1.75; - std::vector diameters; - auto filament_diameter_opt = config().option("filament_diameter"); - if (filament_diameter_opt) diameters = filament_diameter_opt->values; - diameter = diameters.empty() ? diameter : *std::max_element(diameters.begin(), diameters.end()); - filament_change_volume = length * PI * diameter * diameter / 4.; - } - - - if (! is_step_done(psWipeTower) && filaments_cnt !=0) { - double wipe_volume = m_config.prime_volume; - int filament_depth_count = m_config.nozzle_diameter.values.size() == 2 ? filaments_cnt : filaments_cnt - 1; - if (filaments_cnt == 1 && enable_timelapse_print()) filament_depth_count = 1; - double volume = wipe_volume * filament_depth_count; - if (m_config.nozzle_diameter.values.size() == 2) volume += filament_change_volume * (int) (filaments_cnt / 2); - - // Sizing 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. - const bool semm_flush = m_config.purge_in_prime_tower && m_config.single_extruder_multi_material; - if (semm_flush) volume = WipeTower2::estimate_semm_flush_volume(m_config, filaments_cnt); - - if (m_config.wipe_tower_wall_type.value == WipeTowerWallType::wtwRib) { - 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); - depth = std::max((double) min_wipe_tower_depth, depth); - depth += rib_width / std::sqrt(2) + config().wipe_tower_extra_rib_length.value; - const_cast(this)->m_wipe_tower_data.depth = depth; - const_cast(this)->m_wipe_tower_data.brim_width = m_config.prime_tower_brim_width; - } - } - else { - double width = m_config.prime_tower_width; - double depth = volume / (layer_height * width); - // The flush volumes already hold the spacing between wipes. - if (!semm_flush) depth *= extra_spacing; - if (need_wipe_tower || depth > EPSILON) { - float min_wipe_tower_depth = WipeTower::get_limit_depth_by_height(max_height); - depth = std::max((double) min_wipe_tower_depth, depth); - } - const_cast(this)->m_wipe_tower_data.depth = depth; - const_cast(this)->m_wipe_tower_data.brim_width = m_config.prime_tower_brim_width; - } - if (m_config.prime_tower_brim_width < 0) const_cast(this)->m_wipe_tower_data.brim_width = WipeTower::get_auto_brim_by_height(max_height); - } + const WipeTowerFootprint footprint = estimate_wipe_tower_footprint(m_config, filaments_cnt, layer_height, max_height, any_raft); + WipeTowerData &data = const_cast(this)->m_wipe_tower_data; + data.depth = float(footprint.depth); + data.width = float(footprint.width); + data.brim_width = float(footprint.brim_width); return m_wipe_tower_data; } diff --git a/src/libslic3r/Print.hpp b/src/libslic3r/Print.hpp index 9e20061501..964deb7a60 100644 --- a/src/libslic3r/Print.hpp +++ b/src/libslic3r/Print.hpp @@ -782,6 +782,9 @@ struct WipeTowerData // Depth of the wipe tower to pass to GLCanvas3D for exact bounding box: float depth; + // Effective width (a rib wall squares the tower). Pre-generation estimate only; once the + // tower exists its mesh is exact. + float width; std::vector> z_and_depth_pairs; float brim_width; float height; @@ -795,6 +798,7 @@ struct WipeTowerData used_filament.clear(); number_of_toolchanges = -1; depth = 0.f; + width = 0.f; brim_width = 0.f; height = 0.f; rib_offset = Vec2f::Zero(); diff --git a/src/slic3r/GUI/GLCanvas3D.cpp b/src/slic3r/GUI/GLCanvas3D.cpp index 6cdc27ed65..e1f47061c1 100644 --- a/src/slic3r/GUI/GLCanvas3D.cpp +++ b/src/slic3r/GUI/GLCanvas3D.cpp @@ -2891,20 +2891,18 @@ void GLCanvas3D::reload_scene(bool refresh_immediately, bool force_full_scene_re DynamicPrintConfig& proj_cfg = wxGetApp().preset_bundle->project_config; float x = dynamic_cast(proj_cfg.option("wipe_tower_x"))->get_at(plate_id); float y = dynamic_cast(proj_cfg.option("wipe_tower_y"))->get_at(plate_id); - float w = dynamic_cast(m_config->option("prime_tower_width"))->value; float a = dynamic_cast(m_config->option("wipe_tower_rotation_angle"))->value; - // BBS - float v = dynamic_cast(m_config->option("prime_volume"))->value; Vec3d plate_origin = ppl.get_plate(plate_id)->get_origin(); - const Print* print = m_process->fff_print(); const Print* current_print = part_plate->fff_print(); if (!need_wipe_tower && part_plate->get_extruders(true).size() < 2) continue; if (part_plate->get_objects_on_this_plate().empty()) continue; - float brim_width = print->wipe_tower_data(filaments_count).brim_width; - int nozzle_nums = wxGetApp().preset_bundle->get_printer_extruder_count(); - Vec3d wipe_tower_size = ppl.get_plate(plate_id)->estimate_wipe_tower_size(full_config, w, v, nozzle_nums, 0, false, dynamic_cast(dconfig.option("enable_wrapping_detection"))->value); + // Body and brim from this plate's own estimate: m_process->fff_print() is the + // selected plate's, so an auto brim drew every tower with that plate's brim. + const WipeTowerFootprint footprint = part_plate->estimate_wipe_tower_footprint(full_config); + float brim_width = float(footprint.brim_width); + Vec3d wipe_tower_size(footprint.width, footprint.depth, footprint.height); // The stored position is already clamped onto the bed, by // set_default_wipe_tower_pos_for_plate and again on every drag. diff --git a/src/slic3r/GUI/Jobs/ArrangeJob.cpp b/src/slic3r/GUI/Jobs/ArrangeJob.cpp index 6b74b71af1..c0cc7bdb5a 100644 --- a/src/slic3r/GUI/Jobs/ArrangeJob.cpp +++ b/src/slic3r/GUI/Jobs/ArrangeJob.cpp @@ -265,8 +265,7 @@ arrangement::ArrangePolygon estimate_wipe_tower_info(int plate_index, std::setget_printer_extruder_count(); - auto arrange_poly = ppl.get_plate(plate_index_valid)->estimate_wipe_tower_polygon(full_config, plate_index, wipe_tower_pos, wipe_tower_size, nozzle_nums, extruder_size); + auto arrange_poly = ppl.get_plate(plate_index_valid)->estimate_wipe_tower_polygon(full_config, plate_index, wipe_tower_pos, wipe_tower_size, extruder_size); arrange_poly.bed_idx = plate_index; return arrange_poly; } diff --git a/src/slic3r/GUI/PartPlate.cpp b/src/slic3r/GUI/PartPlate.cpp index 849aa4e31a..3f56e9a8de 100644 --- a/src/slic3r/GUI/PartPlate.cpp +++ b/src/slic3r/GUI/PartPlate.cpp @@ -20,6 +20,7 @@ #include "libslic3r/libslic3r.h" #include "libslic3r/Polygon.hpp" +#include "libslic3r/GCode/WipeTowerEstimate.hpp" #include "libslic3r/ClipperUtils.hpp" #include "libslic3r/BoundingBox.hpp" #include "libslic3r/Geometry.hpp" @@ -1531,8 +1532,15 @@ std::vector PartPlate::get_extruders(bool conside_custom_gcode) const if (check_objects_empty_and_gcode3mf(plate_extruders)) { return plate_extruders; } - // if 3mf file - const DynamicPrintConfig& glb_config = wxGetApp().preset_bundle->prints.get_edited_preset().config; + return get_extruders(conside_custom_gcode, wxGetApp().preset_bundle->prints.get_edited_preset().config, wxGetApp().preset_bundle->project_config); +} + +// The plate's filaments, with the global keys read from the given configs rather than the +// application's presets: the wipe tower estimate is also called under the CLI, which has no +// application object. get_extruders(bool) passes the edited presets; a full config serves both. +std::vector PartPlate::get_extruders(bool conside_custom_gcode, const DynamicPrintConfig& glb_config, const DynamicPrintConfig& project_config) const +{ + std::vector plate_extruders; int glb_support_intf_extr = glb_config.opt_int("support_interface_filament"); int glb_support_extr = glb_config.opt_int("support_filament"); int glb_outer_wall_extr = glb_config.opt_int("outer_wall_filament_id"); @@ -1549,7 +1557,9 @@ std::vector PartPlate::get_extruders(bool conside_custom_gcode) const glb_support |= glb_config.opt_int("raft_layers") > 0; for (int obj_idx = 0; obj_idx < m_model->objects.size(); obj_idx++) { - if (!contain_instance_totally(obj_idx, 0)) + // Any instance on the plate counts, as PrintApply does: after an arrange, instance 0 + // can sit on a different plate. + if (!contain_any_instance_totally(obj_idx)) continue; ModelObject* mo = m_model->objects[obj_idx]; @@ -1662,7 +1672,7 @@ std::vector PartPlate::get_extruders(bool conside_custom_gcode) const if (conside_custom_gcode) { //BBS int nums_extruders = 0; - if (const ConfigOptionStrings *color_option = dynamic_cast(wxGetApp().preset_bundle->project_config.option("filament_colour"))) { + if (const ConfigOptionStrings *color_option = dynamic_cast(project_config.option("filament_colour"))) { nums_extruders = color_option->values.size(); if (m_model->plates_custom_gcodes.find(m_plate_index) != m_model->plates_custom_gcodes.end()) { for (auto item : m_model->plates_custom_gcodes.at(m_plate_index).gcodes) { @@ -1681,9 +1691,8 @@ std::vector PartPlate::get_extruders(bool conside_custom_gcode) const // is never loaded into a tray, so callers (AMS mapping, filament checks) must see the // physical filaments it resolves to instead. { - auto& project_config = wxGetApp().preset_bundle->project_config; - auto* is_mixed_opt = project_config.option("filament_is_mixed"); - auto* comp_strs_opt = project_config.option("filament_mixed_components"); + const auto* is_mixed_opt = project_config.option("filament_is_mixed"); + const auto* comp_strs_opt = project_config.option("filament_mixed_components"); if (is_mixed_opt && comp_strs_opt && has_any_mixed_filament(is_mixed_opt->values)) { std::vector ext_0based; for (int e : plate_extruders) @@ -2311,113 +2320,86 @@ bool PartPlate::check_compatible_of_nozzle_and_filament(const DynamicPrintConfig return wipe_tower_size; }*/ -Vec3d PartPlate::estimate_wipe_tower_size(const DynamicPrintConfig & config, const double w, const double wipe_volume, int extruder_count, int plate_extruder_size, bool use_global_objects, bool enable_wrapping_detection) const +WipeTowerFootprint PartPlate::estimate_wipe_tower_footprint(const DynamicPrintConfig &config, int plate_extruder_size, bool use_global_objects) const { - Vec3d wipe_tower_size; - double layer_height = 0.08f; // hard code layer height - double max_height = 0.f; - wipe_tower_size.setZero(); - - const ConfigOption* layer_height_opt = config.option("layer_height"); - if (layer_height_opt) - layer_height = layer_height_opt->getFloat(); - - // empty plate - if (plate_extruder_size == 0) - { - std::vector plate_extruders = get_extruders(true); - plate_extruder_size = plate_extruders.size(); + // 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()); + } + // 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. + 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) - return wipe_tower_size; + return WipeTowerFootprint(); - for (int obj_idx = 0; obj_idx < m_model->objects.size(); obj_idx++) { - if (!use_global_objects && !contain_instance_totally(obj_idx, 0)) + // Tallest object on this plate and the thinnest layer it is sliced at, resolved per object + // as PrintObject resolves them (override, else preset) and over this plate's objects only - + // seeding from the global value, or folding in an off-plate override, diverges from Print. + const ConfigOption *layer_height_opt = config.option("layer_height"); + const double global_layer_height = layer_height_opt != nullptr ? layer_height_opt->getFloat() : 0.08; + const ConfigOption *raft_layers_opt = config.option("raft_layers"); + const int global_raft_layers = raft_layers_opt != nullptr ? raft_layers_opt->getInt() : 0; + double max_height = 0.; + double layer_height = std::numeric_limits::max(); + bool any_raft = false; + for (int obj_idx = 0; obj_idx < int(m_model->objects.size()); ++obj_idx) { + const ModelObject *object = m_model->objects[obj_idx]; + if (!use_global_objects && !contain_any_instance_totally(obj_idx)) continue; - - BoundingBoxf3 bbox = m_model->objects[obj_idx]->bounding_box_exact(); - max_height = std::max(bbox.size().z(), max_height); - } - wipe_tower_size(2) = max_height; - //const DynamicPrintConfig &dconfig = wxGetApp().preset_bundle->prints.get_edited_preset().config; - auto timelapse_type = config.option>("timelapse_type"); - bool need_wipe_tower = (timelapse_type ? (timelapse_type->value == TimelapseType::tlSmooth) : false) | enable_wrapping_detection; - double extra_spacing = config.option("prime_tower_infill_gap")->getFloat() / 100.; - const ConfigOptionEnum* use_rib_wall_opt = config.option>("wipe_tower_wall_type"); - bool use_rib_wall = use_rib_wall_opt ? use_rib_wall_opt->value == WipeTowerWallType::wtwRib: false; - double rib_width = config.option("wipe_tower_rib_width")->getFloat(); - double depth; - double filament_change_volume=0.; - { - std::vector filament_change_lengths; - auto filament_change_lengths_opt = m_print->config().option("filament_change_length"); - if (filament_change_lengths_opt) filament_change_lengths = filament_change_lengths_opt->values; - double length = filament_change_lengths.empty() ? 0 : *std::max_element(filament_change_lengths.begin(), filament_change_lengths.end()); - double diameter = 1.75; - std::vector diameters; - auto filament_diameter_opt = m_print->config().option("filament_diameter"); - if (filament_diameter_opt) diameters = filament_diameter_opt->values; - diameter = diameters.empty() ? diameter : *std::max_element(diameters.begin(), diameters.end()); - filament_change_volume = length * PI * diameter * diameter / 4.; - } - 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); - // 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"); - const bool semm_flush = purge_opt && purge_opt->value && semm_opt && semm_opt->value; - if (semm_flush) volume = WipeTower2::estimate_semm_flush_volume(config, plate_extruder_size); - if (use_rib_wall) { - 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); - double volume_depth = depth; - depth = std::max((double) min_wipe_tower_depth, depth); - rib_width = std::min(rib_width, depth / 2); - depth = rib_width / std::sqrt(2) + std::max(depth + m_print->config().wipe_tower_extra_rib_length.value, volume_depth); - wipe_tower_size(0) = wipe_tower_size(1) = depth; + // Per instance, to match PrintObject::size(); the union over instances differs once + // they are rotated apart. + for (int inst_idx = 0; inst_idx < int(object->instances.size()); ++inst_idx) { + if (!use_global_objects && !contain_instance_totally(obj_idx, inst_idx)) + continue; + max_height = std::max(max_height, object->instance_bounding_box(inst_idx, true).size().z()); } + const ConfigOption *object_layer_height = object->config.option("layer_height"); + layer_height = std::min(layer_height, object_layer_height != nullptr ? object_layer_height->getFloat() : global_layer_height); + const ConfigOption *object_raft_layers = object->config.option("raft_layers"); + any_raft = any_raft || (object_raft_layers != nullptr ? object_raft_layers->getInt() : global_raft_layers) > 0; } - else { - depth = volume / (layer_height * w); - // The flush volumes already hold the spacing between wipes. - if (!semm_flush) depth *= extra_spacing; - if (need_wipe_tower || depth > EPSILON) { - float min_wipe_tower_depth = WipeTower::get_limit_depth_by_height(max_height); - depth = std::max((double)min_wipe_tower_depth, depth); - } - wipe_tower_size(0) = w; - wipe_tower_size(1) = depth; - } + if (layer_height == std::numeric_limits::max()) + layer_height = global_layer_height; - return wipe_tower_size; + return Slic3r::estimate_wipe_tower_footprint(config, size_t(plate_extruder_size), layer_height, max_height, any_raft); } -arrangement::ArrangePolygon PartPlate::estimate_wipe_tower_polygon(const DynamicPrintConfig& config, int plate_index, Vec3d& wt_pos, Vec3d& wt_size, int extruder_count, int plate_extruder_size, bool use_global_objects) const +Vec3d PartPlate::estimate_wipe_tower_size(const DynamicPrintConfig &config, int plate_extruder_size, bool use_global_objects) const +{ + const WipeTowerFootprint footprint = estimate_wipe_tower_footprint(config, plate_extruder_size, use_global_objects); + return Vec3d(footprint.width, footprint.depth, footprint.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 { float x = dynamic_cast(config.option("wipe_tower_x"))->get_at(plate_index); float y = dynamic_cast(config.option("wipe_tower_y"))->get_at(plate_index); - float w = dynamic_cast(config.option("prime_tower_width"))->value; //float a = dynamic_cast(config.option("wipe_tower_rotation_angle"))->value; - float v = dynamic_cast(config.option("prime_volume"))->value; - float tower_brim_width = dynamic_cast(config.option("prime_tower_brim_width"))->value; - const ConfigOptionBool * wrapping_opt = dynamic_cast(config.option("enable_wrapping_detection")); - bool enable_wrapping = (wrapping_opt != nullptr) && wrapping_opt->value; - wt_size = estimate_wipe_tower_size(config, w, v, extruder_count, plate_extruder_size, use_global_objects, enable_wrapping); + const WipeTowerFootprint footprint = estimate_wipe_tower_footprint(config, plate_extruder_size, use_global_objects); + wt_size = Vec3d(footprint.width, footprint.depth, footprint.height); int plate_width=m_width, plate_depth=m_depth; - w = wt_size(0); // effective width; differs from prime_tower_width when the rib wall squares the tower + float w = wt_size(0); // effective width; differs from prime_tower_width when the rib wall squares the tower float depth = wt_size(1); - float margin = WIPE_TOWER_MARGIN + tower_brim_width, wp_brim_width = 0.f; - const ConfigOption* wipe_tower_brim_width_opt = config.option("prime_tower_brim_width"); - if (wipe_tower_brim_width_opt) { - wp_brim_width = wipe_tower_brim_width_opt->getFloat(); - if (wp_brim_width < 0) wp_brim_width = WipeTower::get_auto_brim_by_height((float) wt_size.z()); - BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << boost::format("arrange wipe_tower: wp_brim_width %1%") % wp_brim_width; - } + // 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; + BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << boost::format("arrange wipe_tower: wp_brim_width %1%") % wp_brim_width; - x = std::clamp(x, margin, (float)plate_width - w - margin - wp_brim_width); - y = std::clamp(y, margin, (float)plate_depth - depth - margin - wp_brim_width); + // A tower too deep for the plate leaves no valid position: clamping with hi < lo is UB and + // in release silently returns the negative hi. + x = std::clamp(x, margin, std::max(margin, (float)plate_width - w - margin)); + y = std::clamp(y, margin, std::max(margin, (float)plate_depth - depth - margin)); wt_pos(0) = x; wt_pos(1) = y; wt_pos(2) = 0.f; @@ -2755,6 +2737,20 @@ bool PartPlate::contain_instance_totally(int obj_id, int instance_id) const return result; } +//judge whether any of the object's instances is totally included in plate or not +bool PartPlate::contain_any_instance_totally(int obj_id) const +{ + if (obj_id < 0 || obj_id >= int(m_model->objects.size())) + return false; + + const ModelObject *object = m_model->objects[obj_id]; + for (int instance_id = 0; instance_id < int(object->instances.size()); ++instance_id) + if (contain_instance_totally(obj_id, instance_id)) + return true; + + return false; +} + //check whether instance is outside the plate or not bool PartPlate::check_outside(int obj_id, int instance_id, BoundingBoxf3* bounding_box) { @@ -4488,26 +4484,16 @@ void PartPlateList::set_default_wipe_tower_pos_for_plate(int plate_idx, bool ini f_volume_maps = wxGetApp().preset_bundle->get_default_nozzle_volume_types_for_filaments(filament_maps); } DynamicPrintConfig full_config = wxGetApp().preset_bundle->full_config(false, filament_maps, f_volume_maps); - float w = dynamic_cast(full_config.option("prime_tower_width"))->value; - float v = dynamic_cast(full_config.option("prime_volume"))->value; - bool enable_wrapping = false; - const ConfigOptionBool *wrapping_opt = dynamic_cast(full_config.option("enable_wrapping_detection")); - if (wrapping_opt) enable_wrapping = wrapping_opt->value; - int nozzle_nums = wxGetApp().preset_bundle->get_printer_extruder_count(); - Vec3d wipe_tower_size = part_plate->estimate_wipe_tower_size(full_config, w, v, nozzle_nums, init_pos ? 2 : 0, false, enable_wrapping); + WipeTowerFootprint footprint = part_plate->estimate_wipe_tower_footprint(full_config, init_pos ? 2 : 0); - if (!init_pos && (is_approx(wipe_tower_size(0), 0.0) || is_approx(wipe_tower_size(1), 0.0))) { - wipe_tower_size = part_plate->estimate_wipe_tower_size(full_config, w, v, nozzle_nums, 2, false, enable_wrapping); + if (!init_pos && (is_approx(footprint.width, 0.0) || is_approx(footprint.depth, 0.0))) { + footprint = part_plate->estimate_wipe_tower_footprint(full_config, 2); } + Vec3d wipe_tower_size(footprint.width, footprint.depth, footprint.height); - // Compute brim-aware margin: brim extends outward from tower position - float brim_width = 0.f; - const ConfigOptionFloat *brim_opt = full_config.option("prime_tower_brim_width"); - if (brim_opt) { - brim_width = brim_opt->value; - if (brim_width < 0) brim_width = WipeTower::get_auto_brim_by_height((float) wipe_tower_size.z()); - } - const float margin = WIPE_TOWER_MARGIN + brim_width; + // Brim-aware margin: the brim extends outward from the tower position. + const float brim_width = float(footprint.brim_width); + const float margin = WIPE_TOWER_MARGIN + brim_width; // clamp wipe tower position within plate boundaries { diff --git a/src/slic3r/GUI/PartPlate.hpp b/src/slic3r/GUI/PartPlate.hpp index 8ad2f4a7d1..1cd45f77fd 100644 --- a/src/slic3r/GUI/PartPlate.hpp +++ b/src/slic3r/GUI/PartPlate.hpp @@ -11,6 +11,7 @@ #include "libslic3r/GCode/GCodeProcessor.hpp" #include "libslic3r/Format/bbs_3mf.hpp" #include "libslic3r/Slicing.hpp" +#include "libslic3r/GCode/WipeTowerEstimate.hpp" #include "libslic3r/Arrange.hpp" #include "Plater.hpp" #include "libslic3r/Model.hpp" @@ -339,11 +340,14 @@ 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; - Vec3d estimate_wipe_tower_size(const DynamicPrintConfig & config, const double w, const double wipe_volume, int extruder_count = 1, int plate_extruder_size = 0, bool use_global_objects = false, bool enable_wrapping_detection = false) const; - arrangement::ArrangePolygon estimate_wipe_tower_polygon(const DynamicPrintConfig & config, int plate_index, Vec3d& wt_pos, Vec3d& wt_size, int extruder_count = 1, int plate_extruder_size = 0, bool use_global_objects = false) const; + // plate_extruder_size: filaments purged on the plate; 0 derives it from the plate's objects. + WipeTowerFootprint estimate_wipe_tower_footprint(const DynamicPrintConfig & config, int plate_extruder_size = 0, bool use_global_objects = false) const; + Vec3d estimate_wipe_tower_size(const DynamicPrintConfig & config, int plate_extruder_size = 0, bool use_global_objects = false) const; + arrangement::ArrangePolygon estimate_wipe_tower_polygon(const DynamicPrintConfig & config, int plate_index, Vec3d& wt_pos, Vec3d& wt_size, int plate_extruder_size = 0, bool use_global_objects = false) const; bool check_objects_empty_and_gcode3mf(std::vector &result) const; // get used filaments from config, 1 based idx std::vector get_extruders(bool conside_custom_gcode = false) const; + std::vector get_extruders(bool conside_custom_gcode, const DynamicPrintConfig& glb_config, const DynamicPrintConfig& project_config) const; std::vector get_extruders_under_cli(bool conside_custom_gcode, DynamicPrintConfig& full_config) const; std::vector get_extruders_without_support(bool conside_custom_gcode = false) const; // get used filaments from gcode result, 1 based idx @@ -366,6 +370,8 @@ public: bool contain_instance_totally(ModelObject* object, int instance_id) const; //judge whether instance is totally included in plate or not bool contain_instance_totally(int obj_id, int instance_id) const; + //judge whether any of the object's instances is totally included in plate or not + bool contain_any_instance_totally(int obj_id) const; //judge whether the plate's origin is at the left of instance or not bool is_left_top_of(int obj_id, int instance_id); diff --git a/tests/fff_print/test_wipe_tower.cpp b/tests/fff_print/test_wipe_tower.cpp index 2bd7ac4189..d5f56d3f6c 100644 --- a/tests/fff_print/test_wipe_tower.cpp +++ b/tests/fff_print/test_wipe_tower.cpp @@ -182,3 +182,86 @@ TEST_CASE("The wipe tower's toolchange planner flush follows the gcode flavor", CHECK_THAT(tower, !Catch::Matchers::ContainsSubstring(unexpected)); } } + +// 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) +{ + // 100 mm3 per purge on a 50 mm wide tower: one purge is 100/(layer_height * 50) of depth. + return multifilament_config(2, { + { "enable_prime_tower", "1" }, + { "wipe_tower_wall_type", wall_type }, + { "prime_tower_width", "50" }, + { "prime_volume", "100" }, + { "prime_tower_infill_gap", "100%" }, + { "prime_tower_brim_width", "3" }, + { "purge_in_prime_tower", "0" }, + { "single_extruder_multi_material", "0" }, + { "timelapse_type", "0" }, + { "layer_height", "0.2" }, + { "raft_layers", "0" } }); +} + +TEST_CASE("The tower is sized for the thinnest layer any object on the plate is sliced at", "[WipeTower]") +{ + // The tower has to survive its thinnest layer, so an override finer than the preset drives + // the estimate even on the second object. Two 20 mm cubes, the second at 0.1 mm. + const DynamicPrintConfig config = tower_estimate_config("rectangle"); + const std::vector> overrides = { + {}, { { "layer_height", "0.1" } } }; + + Print print; + Model model; + init_print({ cube(20), cube(20) }, print, model, config, &overrides); + + // One purge at 0.1 mm: 100 / (0.1 * 50) = 20 mm, above the 20 mm-tall tower's stability + // floor. At the preset's 0.2 mm it would be half that, so the two are easy to tell apart. + const float floor_20mm = WipeTower::get_limit_depth_by_height(20.f); + REQUIRE(floor_20mm < 10.f); + CHECK_THAT(print.wipe_tower_data(2).depth, Catch::Matchers::WithinAbs(20., 1e-4)); +} + +TEST_CASE("Validation is given the tower's effective width, not the configured one", "[WipeTower]") +{ + // A rib wall squares the tower, so its width is its depth. Validation reads this rather + // than re-deriving the rule from the wall type. + Print print; + Model model; + + SECTION("a rectangle wall keeps the configured width") { + const DynamicPrintConfig config = tower_estimate_config("rectangle"); + init_print({ cube(20) }, print, model, config); + const WipeTowerData &data = print.wipe_tower_data(2); + CHECK_THAT(data.width, Catch::Matchers::WithinAbs(50., 1e-4)); + CHECK(data.depth < data.width); + } + + SECTION("a rib wall reports the squared footprint") { + const DynamicPrintConfig config = tower_estimate_config("rib"); + init_print({ cube(20) }, print, model, config); + const WipeTowerData &data = print.wipe_tower_data(2); + CHECK_THAT(data.width, Catch::Matchers::WithinAbs(data.depth, 1e-4)); + CHECK(data.width > 0.f); + } +} + +TEST_CASE("A single-filament plate reserves a tower only when one is actually printed", "[WipeTower]") +{ + // Reporting no tower for one that is built collapses the validation hull to a point, so + // the config-visible reasons for a single-filament tower have to be honoured. + Print print; + Model model; + + SECTION("no tool change and nothing else that prints one") { + const DynamicPrintConfig config = tower_estimate_config("rib"); + init_print({ cube(20) }, print, model, config); + CHECK_THAT(print.wipe_tower_data(1).depth, Catch::Matchers::WithinAbs(0., 1e-6)); + } + + SECTION("a raft puts the tower on every layer below the object") { + DynamicPrintConfig config = tower_estimate_config("rib"); + config.set_deserialize_strict({ { "raft_layers", "3" } }); + init_print({ cube(20) }, print, model, config); + CHECK(print.wipe_tower_data(1).depth > 0.f); + } +} diff --git a/tests/libslic3r/CMakeLists.txt b/tests/libslic3r/CMakeLists.txt index 0d29ea11ae..dc8508743e 100644 --- a/tests/libslic3r/CMakeLists.txt +++ b/tests/libslic3r/CMakeLists.txt @@ -40,6 +40,7 @@ add_executable(${_TEST_NAME}_tests test_utils.cpp test_timeutils.cpp test_voronoi.cpp + test_wipe_tower_estimate.cpp test_optimizers.cpp test_ordering_strategies.cpp # test_png_io.cpp diff --git a/tests/libslic3r/test_wipe_tower_estimate.cpp b/tests/libslic3r/test_wipe_tower_estimate.cpp new file mode 100644 index 0000000000..753d872f29 --- /dev/null +++ b/tests/libslic3r/test_wipe_tower_estimate.cpp @@ -0,0 +1,206 @@ +#include + +#include "libslic3r/GCode/WipeTower.hpp" +#include "libslic3r/GCode/WipeTower2.hpp" +#include "libslic3r/GCode/WipeTowerEstimate.hpp" +#include "libslic3r/PrintConfig.hpp" + +#include +#include + +using namespace Slic3r; +using Catch::Matchers::WithinAbs; + +// Rectangle wall, one nozzle, 100 mm3 prime volume on a 50 mm wide tower at 0.2 mm layers: one +// purge is 10 mm of depth. The flush matrix is off here; the shipped-default case covers it. +// Built as PresetBundle::full_config builds the GUI's: apply() creates each enum as a +// ConfigOptionEnumGeneric, where full_print_config() would clone the static defaults' +// ConfigOptionEnum. The estimate has to read either. +static DynamicPrintConfig preset_shaped_defaults() +{ + DynamicPrintConfig config; + config.apply(FullPrintConfig::defaults()); + return config; +} + +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("prime_tower_infill_gap", 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_deserialize_strict("timelapse_type", "0"); + config.set_key_value("enable_wrapping_detection", new ConfigOptionBool(false)); + config.set_key_value("raft_layers", new ConfigOptionInt(0)); + config.set_key_value("purge_in_prime_tower", new ConfigOptionBool(false)); + config.set_key_value("single_extruder_multi_material", new ConfigOptionBool(false)); + return config; +} + +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., false); + 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)); + // Thinner layers need more depth for the same volume. + CHECK_THAT(estimate_wipe_tower_footprint(config, 3, 0.1, 5., false).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., false).depth, WithinAbs(30., 1e-9)); +} + +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., false).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., false).brim_width, WithinAbs(WipeTower::get_auto_brim_by_height(50.f), 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., false).depth, WithinAbs(0., 1e-9)); + CHECK_THAT(estimate_wipe_tower_footprint(config, 0, 0.2, 100., false).width, WithinAbs(0., 1e-9)); + + // Wrapping detection prints a tower on the first layers whatever the filament count. + config.set_key_value("enable_wrapping_detection", new ConfigOptionBool(true)); + CHECK_THAT(estimate_wipe_tower_footprint(config, 1, 0.2, 100., false).depth, WithinAbs(20., 1e-9)); + config.set_key_value("enable_wrapping_detection", new ConfigOptionBool(false)); + + // So does a raft. raft_layers is a per-object key, so it arrives as a resolved flag and + // is deliberately not read off the config. + CHECK_THAT(estimate_wipe_tower_footprint(config, 1, 0.2, 100., true).depth, WithinAbs(20., 1e-9)); + config.set_key_value("raft_layers", new ConfigOptionInt(3)); + CHECK_THAT(estimate_wipe_tower_footprint(config, 1, 0.2, 100., false).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., false).depth, WithinAbs(20., 1e-9)); + CHECK_THAT(estimate_wipe_tower_footprint(config, 1, 0.2, 5., false).depth, WithinAbs(10., 1e-9)); +} + +TEST_CASE("Both wall types agree on whether there is a tower at all", "[WipeTowerEstimate]") { + // A wall type may only change the shape of the tower, never whether one is reserved: + // reporting no tower for one that is built collapses the validation hull to a point. + const double height = GENERATE(5., 100.); + DynamicPrintConfig rect = make_config(); + 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, false).depth, WithinAbs(0., 1e-9)); + CHECK_THAT(estimate_wipe_tower_footprint(rib, 1, 0.2, height, false).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, false).depth, WithinAbs(0., 1e-9)); + CHECK_THAT(estimate_wipe_tower_footprint(rib, 1, 0.2, height, false).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, false).depth >= WipeTower::get_limit_depth_by_height(float(height))); + CHECK(estimate_wipe_tower_footprint(rib, 2, 0.2, height, false).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., false); + CHECK_THAT(fp.depth, WithinAbs(8. / std::sqrt(2.) + body, 1e-9)); + CHECK_THAT(fp.width, WithinAbs(fp.depth, 1e-9)); + // The extra rib length grows the footprint. + config.set_key_value("wipe_tower_extra_rib_length", new ConfigOptionFloat(4.)); + CHECK_THAT(estimate_wipe_tower_footprint(config, 3, 0.2, 5., false).depth, WithinAbs(8. / std::sqrt(2.) + body + 4., 1e-9)); + // 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., false).depth, WithinAbs(2.5 / std::sqrt(2.) + 5., 1e-9)); +} + +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. + const char *wall_type = GENERATE("rectangle", "cone", "rib"); + const char *tower_type = GENERATE("type1", "type2"); + DynamicPrintConfig preset = make_config(wall_type); + preset.set_deserialize_strict("wipe_tower_type", tower_type); + REQUIRE(dynamic_cast(preset.option("wipe_tower_wall_type")) != nullptr); + + FullPrintConfig static_config; + static_config.apply(preset, true); + 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., false); + 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 WipeTowerFootprint from_static = estimate_wipe_tower_footprint(static_config, 3, 0.2, 5., false); + 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)); + + // Smooth timelapse is the other enum the estimate reads: a lone filament gets a tower + // 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., false).depth > 0.); + CHECK(estimate_wipe_tower_footprint(static_config, 1, 0.2, 5., false).depth > 0.); +} + +TEST_CASE("A dual nozzle purges every filament plus the filament change", "[WipeTowerEstimate]") { + 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})); + // 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., false).depth, WithinAbs((200. + change_volume) / 10., 1e-9)); +} + +TEST_CASE("The shipped defaults size the tower from the flush matrix", "[WipeTowerEstimate]") { + // Both keys default to true, so the shipped configuration purges the flush volumes rather + // than the prime volume, with no infill gap on top - the flush volumes already hold it. + DynamicPrintConfig config = preset_shaped_defaults(); + REQUIRE(config.opt_bool("purge_in_prime_tower")); + REQUIRE(config.opt_bool("single_extruder_multi_material")); + config.set_key_value("prime_tower_width", new ConfigOptionFloat(50.)); + config.set_deserialize_strict("wipe_tower_wall_type", "rectangle"); + config.set_key_value("nozzle_diameter", new ConfigOptionFloats({0.4})); + + 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., false).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); + + 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., false).depth, + WithinAbs(estimate_wipe_tower_footprint(defaulted, 3, 0.2, 5., false).depth, 1e-9)); +} From e1efec7d6ce537c2408fd6ada5aefac7a909a685 Mon Sep 17 00:00:00 2001 From: Hanif Koh Date: Tue, 8 Sep 2026 14:43:23 +0800 Subject: [PATCH 04/14] Fix review findings in the shared wipe tower estimate A raft is not a reason to reserve a tower. Print::apply runs normalize_fdm_2, which clears enable_prime_tower for a plate that purges one filament unless smooth timelapse or wrapping detection is on, so a single-filament plate with a raft prints no tower at all and the estimate was reserving bed area for one. Drop the input; need_wipe_tower is now exactly the two exceptions normalize_fdm_2 honours, named there so the next reason added has to be checked against it. The GUI preview and the validation containment check each re-derived "is a tower printed here" from the filament count instead of reading the estimate, so both missed the towers printed with no tool change to purge for. They now take the answer from the footprint, which is the drift this shared estimate exists to remove. A tower that is not printed estimates to zero, so its hull is degenerate and every check on it passes trivially - the containment check needs no gate of its own. WipeTowerData::width was written only by the pre-generation estimate and left at zero for the whole post-generation life of the Print, while its neighbour depth held the real value. Set it from the generator in both branches. The plate's height scan transformed every model part's full mesh per instance on each scene reload, discarding all but the z extent. The cached convex hull has the same z extent. A plate loaded from a sliced .gcode.3mf holds no objects and its filaments live in slice_filaments_info; the config-taking get_extruders overload returned an empty list for it, which sized the tower for a placeholder two filaments. It now answers the way the wx overload does, without reaching the plater. Also drop estimate_wipe_tower_size, which has no callers. --- src/libslic3r/GCode/WipeTowerEstimate.cpp | 11 +-- src/libslic3r/GCode/WipeTowerEstimate.hpp | 8 +-- src/libslic3r/Print.cpp | 29 ++++---- src/libslic3r/Print.hpp | 4 +- src/slic3r/GUI/GLCanvas3D.cpp | 4 +- src/slic3r/GUI/PartPlate.cpp | 27 ++++--- src/slic3r/GUI/PartPlate.hpp | 5 +- tests/fff_print/test_wipe_tower.cpp | 67 ++++++++++++++++- tests/libslic3r/test_wipe_tower_estimate.cpp | 75 ++++++++++++-------- 9 files changed, 154 insertions(+), 76 deletions(-) diff --git a/src/libslic3r/GCode/WipeTowerEstimate.cpp b/src/libslic3r/GCode/WipeTowerEstimate.cpp index 9e9bb7de4b..a8aeda28ef 100644 --- a/src/libslic3r/GCode/WipeTowerEstimate.cpp +++ b/src/libslic3r/GCode/WipeTowerEstimate.cpp @@ -11,7 +11,7 @@ namespace Slic3r { -WipeTowerFootprint estimate_wipe_tower_footprint(const ConfigBase &config, size_t filaments_cnt, double layer_height, double max_object_height, bool any_raft) +WipeTowerFootprint estimate_wipe_tower_footprint(const ConfigBase &config, size_t filaments_cnt, double layer_height, double max_object_height) { WipeTowerFootprint footprint; footprint.height = max_object_height; @@ -56,8 +56,9 @@ WipeTowerFootprint estimate_wipe_tower_footprint(const ConfigBase &config, size_ 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); - // Reasons a tower is printed with no tool change to purge for. - const bool need_wipe_tower = smooth_timelapse || opt_bool("enable_wrapping_detection") || any_raft; + // 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"); // No tool change, nothing to purge; smooth timelapse still primes once. size_t purge_count = 0; @@ -80,7 +81,9 @@ WipeTowerFootprint estimate_wipe_tower_footprint(const ConfigBase &config, size_ // 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. - if (volume < EPSILON && !need_wipe_tower) + // 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) return footprint; const double min_depth = WipeTower::get_limit_depth_by_height(float(max_object_height)); diff --git a/src/libslic3r/GCode/WipeTowerEstimate.hpp b/src/libslic3r/GCode/WipeTowerEstimate.hpp index 911ca9560c..fe5c0b519c 100644 --- a/src/libslic3r/GCode/WipeTowerEstimate.hpp +++ b/src/libslic3r/GCode/WipeTowerEstimate.hpp @@ -21,9 +21,9 @@ struct WipeTowerFootprint // changes, so a count 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. -// any_raft: any object on the plate prints a raft, which puts the tower on every layer -// below it. Caller-resolved: raft_layers is a PrintObjectConfig key, absent -// from Print's config and overridable per object. -WipeTowerFootprint estimate_wipe_tower_footprint(const ConfigBase &config, size_t filaments_cnt, double layer_height, double max_object_height, bool any_raft); +// +// 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); } // namespace Slic3r diff --git a/src/libslic3r/Print.cpp b/src/libslic3r/Print.cpp index 7d362537ea..39410a8cef 100644 --- a/src/libslic3r/Print.cpp +++ b/src/libslic3r/Print.cpp @@ -1081,22 +1081,21 @@ 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")}; } - // 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). + // No gate on "is there a tower": one that is not printed estimates to zero, so the hull + // is degenerate and every check passes. Re-deriving it here missed the wrapping-detection + // tower on a single-filament plate. // 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 (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(); - const Point plate_shift(scale_(plate_origin.x()), scale_(plate_origin.y())); - for (Polygon &p : printable_polys) - p.translate(plate_shift); - 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")}; - } + // 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(); + const Point plate_shift(scale_(plate_origin.x()), scale_(plate_origin.y())); + for (Polygon &p : printable_polys) + p.translate(plate_shift); + 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 {}; } @@ -4005,16 +4004,14 @@ const WipeTowerData &Print::wipe_tower_data(size_t filaments_cnt) const double max_height = 0.; double layer_height = std::numeric_limits::max(); - bool any_raft = false; for (const PrintObject *object : m_objects) { max_height = std::max(max_height, unscale_(double(object->size().z()))); layer_height = std::min(layer_height, object->config().layer_height.value); - any_raft = any_raft || object->config().raft_layers.value > 0; } if (max_height < EPSILON) return m_wipe_tower_data; - const WipeTowerFootprint footprint = estimate_wipe_tower_footprint(m_config, filaments_cnt, layer_height, max_height, any_raft); + const WipeTowerFootprint footprint = estimate_wipe_tower_footprint(m_config, filaments_cnt, layer_height, max_height); WipeTowerData &data = const_cast(this)->m_wipe_tower_data; data.depth = float(footprint.depth); data.width = float(footprint.width); @@ -4244,6 +4241,7 @@ void Print::_make_wipe_tower() m_wipe_tower_data.tool_changes.reserve(m_wipe_tower_data.tool_ordering.layer_tools().size()); wipe_tower.generate_new(m_wipe_tower_data.tool_changes); m_wipe_tower_data.depth = wipe_tower.get_depth(); + m_wipe_tower_data.width = wipe_tower.width(); m_wipe_tower_data.brim_width = wipe_tower.get_brim_width(); m_wipe_tower_data.bbx = wipe_tower.get_bbx(); m_wipe_tower_data.rib_offset = wipe_tower.get_rib_offset(); @@ -4357,6 +4355,7 @@ void Print::_make_wipe_tower() m_wipe_tower_data.tool_changes.reserve(m_wipe_tower_data.tool_ordering.layer_tools().size()); wipe_tower.generate(m_wipe_tower_data.tool_changes); m_wipe_tower_data.depth = wipe_tower.get_depth(); + m_wipe_tower_data.width = wipe_tower.width(); m_wipe_tower_data.z_and_depth_pairs = wipe_tower.get_z_and_depth_pairs(); m_wipe_tower_data.brim_width = wipe_tower.get_brim_width(); m_wipe_tower_data.height = wipe_tower.get_wipe_tower_height(); diff --git a/src/libslic3r/Print.hpp b/src/libslic3r/Print.hpp index 964deb7a60..af1dc3af40 100644 --- a/src/libslic3r/Print.hpp +++ b/src/libslic3r/Print.hpp @@ -782,8 +782,8 @@ struct WipeTowerData // Depth of the wipe tower to pass to GLCanvas3D for exact bounding box: float depth; - // Effective width (a rib wall squares the tower). Pre-generation estimate only; once the - // tower exists its mesh is exact. + // Effective width (a rib wall squares the tower): the estimate until generation, then the + // generated width, so it never disagrees with depth. float width; std::vector> z_and_depth_pairs; float brim_width; diff --git a/src/slic3r/GUI/GLCanvas3D.cpp b/src/slic3r/GUI/GLCanvas3D.cpp index e1f47061c1..b0675360b8 100644 --- a/src/slic3r/GUI/GLCanvas3D.cpp +++ b/src/slic3r/GUI/GLCanvas3D.cpp @@ -2895,12 +2895,14 @@ void GLCanvas3D::reload_scene(bool refresh_immediately, bool force_full_scene_re Vec3d plate_origin = ppl.get_plate(plate_id)->get_origin(); const Print* current_print = part_plate->fff_print(); - if (!need_wipe_tower && part_plate->get_extruders(true).size() < 2) continue; if (part_plate->get_objects_on_this_plate().empty()) continue; // Body and brim from this plate's own estimate: m_process->fff_print() is the // selected plate's, so an auto brim drew every tower with that plate's brim. const WipeTowerFootprint footprint = part_plate->estimate_wipe_tower_footprint(full_config); + // The estimate is also the answer to whether this plate prints a tower; + // deciding it here as well only gave the two room to drift. + if (footprint.depth <= 0.) continue; float brim_width = float(footprint.brim_width); Vec3d wipe_tower_size(footprint.width, footprint.depth, footprint.height); diff --git a/src/slic3r/GUI/PartPlate.cpp b/src/slic3r/GUI/PartPlate.cpp index 3f56e9a8de..09b8ff3068 100644 --- a/src/slic3r/GUI/PartPlate.cpp +++ b/src/slic3r/GUI/PartPlate.cpp @@ -1,5 +1,6 @@ #include #include +#include #include #include #include @@ -1541,6 +1542,14 @@ std::vector PartPlate::get_extruders(bool conside_custom_gcode) const std::vector PartPlate::get_extruders(bool conside_custom_gcode, const DynamicPrintConfig& glb_config, const DynamicPrintConfig& project_config) const { std::vector plate_extruders; + // A plate from a sliced .gcode.3mf holds no objects, so report the filaments the G-code + // used. check_objects_empty_and_gcode3mf does this for get_extruders(bool), but reaches + // the plater, which the CLI has none of; slice_filaments_info is only filled for such a plate. + if (m_model->objects.empty()) { + for (const FilamentInfo &info : slice_filaments_info) + plate_extruders.push_back(info.id + 1); + return plate_extruders; + } int glb_support_intf_extr = glb_config.opt_int("support_interface_filament"); int glb_support_extr = glb_config.opt_int("support_filament"); int glb_outer_wall_extr = glb_config.opt_int("outer_wall_filament_id"); @@ -2347,37 +2356,27 @@ WipeTowerFootprint PartPlate::estimate_wipe_tower_footprint(const DynamicPrintCo // seeding from the global value, or folding in an off-plate override, diverges from Print. const ConfigOption *layer_height_opt = config.option("layer_height"); const double global_layer_height = layer_height_opt != nullptr ? layer_height_opt->getFloat() : 0.08; - const ConfigOption *raft_layers_opt = config.option("raft_layers"); - const int global_raft_layers = raft_layers_opt != nullptr ? raft_layers_opt->getInt() : 0; double max_height = 0.; double layer_height = std::numeric_limits::max(); - bool any_raft = false; for (int obj_idx = 0; obj_idx < int(m_model->objects.size()); ++obj_idx) { const ModelObject *object = m_model->objects[obj_idx]; if (!use_global_objects && !contain_any_instance_totally(obj_idx)) continue; // Per instance, to match PrintObject::size(); the union over instances differs once - // they are rotated apart. + // they are rotated apart. The cached convex hull has the mesh's z extent and is cheap + // enough for every scene reload. for (int inst_idx = 0; inst_idx < int(object->instances.size()); ++inst_idx) { if (!use_global_objects && !contain_instance_totally(obj_idx, inst_idx)) continue; - max_height = std::max(max_height, object->instance_bounding_box(inst_idx, true).size().z()); + max_height = std::max(max_height, object->instance_convex_hull_bounding_box(inst_idx, true).size().z()); } const ConfigOption *object_layer_height = object->config.option("layer_height"); layer_height = std::min(layer_height, object_layer_height != nullptr ? object_layer_height->getFloat() : global_layer_height); - const ConfigOption *object_raft_layers = object->config.option("raft_layers"); - any_raft = any_raft || (object_raft_layers != nullptr ? object_raft_layers->getInt() : global_raft_layers) > 0; } 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, any_raft); -} - -Vec3d PartPlate::estimate_wipe_tower_size(const DynamicPrintConfig &config, int plate_extruder_size, bool use_global_objects) const -{ - const WipeTowerFootprint footprint = estimate_wipe_tower_footprint(config, plate_extruder_size, use_global_objects); - return Vec3d(footprint.width, footprint.depth, footprint.height); + return Slic3r::estimate_wipe_tower_footprint(config, size_t(plate_extruder_size), 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 diff --git a/src/slic3r/GUI/PartPlate.hpp b/src/slic3r/GUI/PartPlate.hpp index 1cd45f77fd..829d417e04 100644 --- a/src/slic3r/GUI/PartPlate.hpp +++ b/src/slic3r/GUI/PartPlate.hpp @@ -340,9 +340,10 @@ 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 it from the plate's objects. + // plate_extruder_size: filaments purged on the plate; 0 derives them from its objects. + // 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; - Vec3d estimate_wipe_tower_size(const DynamicPrintConfig & config, int plate_extruder_size = 0, bool use_global_objects = false) const; arrangement::ArrangePolygon estimate_wipe_tower_polygon(const DynamicPrintConfig & config, int plate_index, Vec3d& wt_pos, Vec3d& wt_size, int plate_extruder_size = 0, bool use_global_objects = false) const; bool check_objects_empty_and_gcode3mf(std::vector &result) const; // get used filaments from config, 1 based idx diff --git a/tests/fff_print/test_wipe_tower.cpp b/tests/fff_print/test_wipe_tower.cpp index d5f56d3f6c..9a6c5aa686 100644 --- a/tests/fff_print/test_wipe_tower.cpp +++ b/tests/fff_print/test_wipe_tower.cpp @@ -199,6 +199,7 @@ static DynamicPrintConfig tower_estimate_config(const char *wall_type) { "single_extruder_multi_material", "0" }, { "timelapse_type", "0" }, { "layer_height", "0.2" }, + { "enable_wrapping_detection", "0" }, { "raft_layers", "0" } }); } @@ -245,23 +246,83 @@ TEST_CASE("Validation is given the tower's effective width, not the configured o } } +TEST_CASE("Generating the tower keeps its reported width current", "[WipeTower]") +{ + // width is handed out after the slice, so leaving it at the estimate reports a zero-width + // tower to every post-generation consumer. + const DynamicPrintConfig config = wipe_tower_toolchange_config("marlin"); + Print print; + Model model; + init_print({ cube(10) }, print, model, config); + print.apply(model, config); + REQUIRE(print.wipe_tower_data(2).width > 0.f); + + print.process(); + REQUIRE(print.is_step_done(psWipeTower)); + const WipeTowerData &data = print.wipe_tower_data(); + // A width the generator never wrote reads as zero. A rib wall squares the tower, so the + // generated width is the body square: under the configured 50 mm, and inside the depth. + CHECK(data.width > 0.f); + CHECK(data.width < 50.f); + CHECK(data.width <= data.depth + EPSILON); +} + TEST_CASE("A single-filament plate reserves a tower only when one is actually printed", "[WipeTower]") { - // Reporting no tower for one that is built collapses the validation hull to a point, so - // the config-visible reasons for a single-filament tower have to be honoured. + // The estimate has to answer this the way Print::apply does: reporting no tower for one + // that is built collapses the validation hull to a point, and reporting one for a tower + // that is not built takes that bed area away from the arranger and draws a preview box + // over nothing. Print print; Model model; SECTION("no tool change and nothing else that prints one") { const DynamicPrintConfig config = tower_estimate_config("rib"); 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)); } - SECTION("a raft puts the tower on every layer below the object") { + // A raft puts the tower on every layer below the object, but only where there is a tower: + // 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"); config.set_deserialize_strict({ { "raft_layers", "3" } }); init_print({ cube(20) }, print, model, config); + REQUIRE_FALSE(print.config().enable_prime_tower.value); + REQUIRE_FALSE(print.has_wipe_tower()); + CHECK_THAT(print.wipe_tower_data(1).depth, Catch::Matchers::WithinAbs(0., 1e-6)); + } + + SECTION("smooth timelapse prints one, and keeps enable_prime_tower on") { + DynamicPrintConfig config = tower_estimate_config("rib"); + config.set_deserialize_strict({ { "timelapse_type", "1" } }); + init_print({ cube(20) }, print, model, config); + REQUIRE(print.has_wipe_tower()); CHECK(print.wipe_tower_data(1).depth > 0.f); } } + +TEST_CASE("A tower printed without a tool change is still validated against the bed", "[WipeTower]") +{ + // Wrapping detection prints a tower on a plate that purges one filament. Neither the old + // estimate (which read the wall type and smooth timelapse) nor the old containment gate (the + // filament count or smooth timelapse) knew about it, so between them that tower was never + // checked against the bed. + Print print; + Model model; + DynamicPrintConfig config = tower_estimate_config("rectangle"); + // 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" } }); + + init_print({ cube(20) }, print, model, config); + REQUIRE(print.extruders(true).size() == 1); + REQUIRE(print.has_wipe_tower()); + CHECK(print.wipe_tower_data(1).depth > 0.f); + CHECK_THAT(print.validate().string, Catch::Matchers::ContainsSubstring("printable area")); +} diff --git a/tests/libslic3r/test_wipe_tower_estimate.cpp b/tests/libslic3r/test_wipe_tower_estimate.cpp index 753d872f29..00235bb2ff 100644 --- a/tests/libslic3r/test_wipe_tower_estimate.cpp +++ b/tests/libslic3r/test_wipe_tower_estimate.cpp @@ -45,48 +45,61 @@ static DynamicPrintConfig make_config(const char *wall_type = "rectangle") 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., false); + const WipeTowerFootprint fp = estimate_wipe_tower_footprint(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)); // Thinner layers need more depth for the same volume. - CHECK_THAT(estimate_wipe_tower_footprint(config, 3, 0.1, 5., false).depth, WithinAbs(40., 1e-9)); + 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., false).depth, WithinAbs(30., 1e-9)); + CHECK_THAT(estimate_wipe_tower_footprint(spaced, 3, 0.2, 5.).depth, WithinAbs(30., 1e-9)); } 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., false).depth, WithinAbs(20., 1e-9)); + CHECK_THAT(estimate_wipe_tower_footprint(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., false).brim_width, WithinAbs(WipeTower::get_auto_brim_by_height(50.f), 1e-6)); + CHECK_THAT(estimate_wipe_tower_footprint(config, 2, 0.2, 50.).brim_width, WithinAbs(WipeTower::get_auto_brim_by_height(50.f), 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., false).depth, WithinAbs(0., 1e-9)); - CHECK_THAT(estimate_wipe_tower_footprint(config, 0, 0.2, 100., false).width, WithinAbs(0., 1e-9)); + 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)); // Wrapping detection prints a tower on the first layers whatever the filament count. config.set_key_value("enable_wrapping_detection", new ConfigOptionBool(true)); - CHECK_THAT(estimate_wipe_tower_footprint(config, 1, 0.2, 100., false).depth, WithinAbs(20., 1e-9)); + CHECK_THAT(estimate_wipe_tower_footprint(config, 1, 0.2, 100.).depth, WithinAbs(20., 1e-9)); config.set_key_value("enable_wrapping_detection", new ConfigOptionBool(false)); - // So does a raft. raft_layers is a per-object key, so it arrives as a resolved flag and - // is deliberately not read off the config. - CHECK_THAT(estimate_wipe_tower_footprint(config, 1, 0.2, 100., true).depth, WithinAbs(20., 1e-9)); + // 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., false).depth, WithinAbs(0., 1e-9)); + CHECK_THAT(estimate_wipe_tower_footprint(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., false).depth, WithinAbs(20., 1e-9)); - CHECK_THAT(estimate_wipe_tower_footprint(config, 1, 0.2, 5., false).depth, WithinAbs(10., 1e-9)); + 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)); +} + +TEST_CASE("A tool change reserves the stability floor even with nothing to purge", "[WipeTowerEstimate]") { + // The purge volumes are configurable down to zero, but the tool changes are still printed on + // the tower and the generator still floors it, so the estimate has to floor it too. + const double height = GENERATE(5., 100.); + const float floor = WipeTower::get_limit_depth_by_height(float(height)); + 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); + // 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)); } TEST_CASE("Both wall types agree on whether there is a tower at all", "[WipeTowerEstimate]") { @@ -97,34 +110,34 @@ 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, false).depth, WithinAbs(0., 1e-9)); - CHECK_THAT(estimate_wipe_tower_footprint(rib, 1, 0.2, height, false).depth, WithinAbs(0., 1e-9)); + 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)); // 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, false).depth, WithinAbs(0., 1e-9)); - CHECK_THAT(estimate_wipe_tower_footprint(rib, 1, 0.2, height, false).depth, WithinAbs(0., 1e-9)); + 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)); // With a tool change both reserve one, and both respect the stability floor. - CHECK(estimate_wipe_tower_footprint(rect, 2, 0.2, height, false).depth >= WipeTower::get_limit_depth_by_height(float(height))); - CHECK(estimate_wipe_tower_footprint(rib, 2, 0.2, height, false).depth >= WipeTower::get_limit_depth_by_height(float(height))); + 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))); } 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., false); + WipeTowerFootprint fp = estimate_wipe_tower_footprint(config, 3, 0.2, 5.); CHECK_THAT(fp.depth, WithinAbs(8. / std::sqrt(2.) + body, 1e-9)); CHECK_THAT(fp.width, WithinAbs(fp.depth, 1e-9)); // The extra rib length grows the footprint. config.set_key_value("wipe_tower_extra_rib_length", new ConfigOptionFloat(4.)); - CHECK_THAT(estimate_wipe_tower_footprint(config, 3, 0.2, 5., false).depth, WithinAbs(8. / std::sqrt(2.) + body + 4., 1e-9)); + CHECK_THAT(estimate_wipe_tower_footprint(config, 3, 0.2, 5.).depth, WithinAbs(8. / std::sqrt(2.) + body + 4., 1e-9)); // 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., false).depth, WithinAbs(2.5 / std::sqrt(2.) + 5., 1e-9)); + CHECK_THAT(estimate_wipe_tower_footprint(config, 2, 0.2, 5.).depth, WithinAbs(2.5 / std::sqrt(2.) + 5., 1e-9)); } TEST_CASE("Every wall and tower type is read the same from a preset and a static config", "[WipeTowerEstimate]") { @@ -144,7 +157,7 @@ TEST_CASE("Every wall and tower type is read the same from a preset and a static 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., false); + 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)); @@ -153,7 +166,7 @@ TEST_CASE("Every wall and tower type is read the same from a preset and a static CHECK_THAT(fp.depth, WithinAbs(20., 1e-9)); } - const WipeTowerFootprint from_static = estimate_wipe_tower_footprint(static_config, 3, 0.2, 5., false); + const WipeTowerFootprint from_static = estimate_wipe_tower_footprint(static_config, 3, 0.2, 5.); 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)); @@ -162,8 +175,8 @@ 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., false).depth > 0.); - CHECK(estimate_wipe_tower_footprint(static_config, 1, 0.2, 5., false).depth > 0.); + 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.); } TEST_CASE("A dual nozzle purges every filament plus the filament change", "[WipeTowerEstimate]") { @@ -173,7 +186,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., false).depth, WithinAbs((200. + change_volume) / 10., 1e-9)); + CHECK_THAT(estimate_wipe_tower_footprint(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]") { @@ -188,7 +201,7 @@ 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., false).depth, WithinAbs(expected, 1e-6)); + CHECK_THAT(estimate_wipe_tower_footprint(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]") { @@ -201,6 +214,6 @@ TEST_CASE("A config missing a tower key falls back to that key's default", "[Wip 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., false).depth, - WithinAbs(estimate_wipe_tower_footprint(defaulted, 3, 0.2, 5., false).depth, 1e-9)); + 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)); } From 98acd687f7c4792f105083b9287d514b1c272726 Mon Sep 17 00:00:00 2001 From: Hanif Koh Date: Tue, 1 Sep 2026 20:30:56 +0800 Subject: [PATCH 05/14] Fixes for Wipe Tower Position Clamping Validation grows the estimated body by the brim before the tower is generated, so a tower whose brim leaves the bed is rejected up front instead of at export. The scene reload re-clamps the stored position, since set_default_wipe_tower_pos_for_plate does not rerun when painting changes the filament count. The rectangle-wall footprint polygon gets its two missing brim corners (it was a skewed quad), so the post-generation check covers the whole brim. --- src/libslic3r/Print.cpp | 17 ++++++++--------- src/slic3r/GUI/GLCanvas3D.cpp | 16 ++++++++++++++-- 2 files changed, 22 insertions(+), 11 deletions(-) diff --git a/src/libslic3r/Print.cpp b/src/libslic3r/Print.cpp index 39410a8cef..b014b0f299 100644 --- a/src/libslic3r/Print.cpp +++ b/src/libslic3r/Print.cpp @@ -1046,6 +1046,7 @@ static StringObjectException layered_print_cleareance_valid(const Print &print, const WipeTowerData &wipe_tower_estimate = print.wipe_tower_data(filaments_count); float width = wipe_tower_estimate.width; float depth = wipe_tower_estimate.depth; + float brim_width = wipe_tower_estimate.brim_width; Polygons convex_hulls_temp; if (print.has_wipe_tower()) { @@ -1084,17 +1085,15 @@ static StringObjectException layered_print_cleareance_valid(const Print &print, // No gate on "is there a tower": one that is not printed estimates to zero, so the hull // is degenerate and every check passes. Re-deriving it here missed the wrapping-detection // tower on a single-filament plate. - // 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. - // The shared printable polygon is plate-local, while the tower polygons above are - // already shifted by the plate origin. + // Pre-generation, grow the body by the brim to match what the generator draws; + // post-generation the mesh already includes it. Polygons printable_polys = print.get_extruder_shared_printable_polygon(); const Point plate_shift(scale_(plate_origin.x()), scale_(plate_origin.y())); for (Polygon &p : printable_polys) p.translate(plate_shift); - if (!diff(convex_hulls_temp, printable_polys).empty()) + Polygons tower_polys_with_brim = print.is_step_done(psWipeTower) ? + convex_hulls_temp : offset(convex_hulls_temp, float(scale_(brim_width))); + if (!diff(tower_polys_with_brim, printable_polys).empty()) return {L("Prime Tower") + L(" is partially outside the printable area, and it cannot be printed.\n")}; return {}; } @@ -5961,8 +5960,8 @@ void WipeTowerData::construct_mesh(float width, float depth, float height, float wipe_tower_mesh_data->real_wipe_tower_mesh = make_cube(width, depth, height); wipe_tower_mesh_data->real_brim_mesh = make_cube(width + 2 * brim_width, depth + 2 * brim_width, first_layer_height); wipe_tower_mesh_data->real_brim_mesh.translate({-brim_width, -brim_width, 0}); - wipe_tower_mesh_data->bottom = {scaled(Vec2f{-brim_width, -brim_width}), scaled(Vec2f{width + brim_width, 0}), scaled(Vec2f{width + brim_width, depth + brim_width}), - scaled(Vec2f{0, depth})}; + wipe_tower_mesh_data->bottom = {scaled(Vec2f{-brim_width, -brim_width}), scaled(Vec2f{width + brim_width, -brim_width}), + scaled(Vec2f{width + brim_width, depth + brim_width}), scaled(Vec2f{-brim_width, depth + brim_width})}; } else { wipe_tower_mesh_data->real_wipe_tower_mesh = WipeTower::its_make_rib_tower(width, depth, height, rib_length, rib_width, fillet_wall); wipe_tower_mesh_data->bottom = WipeTower::rib_section(width, depth, rib_length, rib_width, fillet_wall); diff --git a/src/slic3r/GUI/GLCanvas3D.cpp b/src/slic3r/GUI/GLCanvas3D.cpp index b0675360b8..5ad6151c68 100644 --- a/src/slic3r/GUI/GLCanvas3D.cpp +++ b/src/slic3r/GUI/GLCanvas3D.cpp @@ -2906,8 +2906,20 @@ void GLCanvas3D::reload_scene(bool refresh_immediately, bool force_full_scene_re float brim_width = float(footprint.brim_width); Vec3d wipe_tower_size(footprint.width, footprint.depth, footprint.height); - // The stored position is already clamped onto the bed, by - // set_default_wipe_tower_pos_for_plate and again on every drag. + // set_default_wipe_tower_pos_for_plate doesn't rerun when painting changes the + // filament count, so redo its clamp here on every reload. + { + Vec3d clamped_pos, clamped_size; + part_plate->estimate_wipe_tower_polygon(full_config, plate_id, clamped_pos, clamped_size); + if (std::abs(x - (float) clamped_pos(0)) > EPSILON || std::abs(y - (float) clamped_pos(1)) > EPSILON) { + x = (float) clamped_pos(0); + y = (float) clamped_pos(1); + ConfigOptionFloat wt_x_opt(x), wt_y_opt(y); + dynamic_cast(proj_cfg.option("wipe_tower_x"))->set_at(&wt_x_opt, plate_id, 0); + dynamic_cast(proj_cfg.option("wipe_tower_y"))->set_at(&wt_y_opt, plate_id, 0); + } + } + if (!current_print->is_step_done(psWipeTower) || !current_print->wipe_tower_data().wipe_tower_mesh_data) { // update for wipe tower position int volume_idx_wipe_tower_new = m_volumes.load_wipe_tower_preview(1000 + plate_id, x + plate_origin(0), y + plate_origin(1), From 99627c8e935b4bd0a167f6728949840d49a024a1 Mon Sep 17 00:00:00 2001 From: Hanif Koh Date: Thu, 3 Sep 2026 14:52:01 +0800 Subject: [PATCH 06/14] 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)); } From e17965be53168549985551da55439d54cfe5272d Mon Sep 17 00:00:00 2001 From: Hanif Koh Date: Thu, 3 Sep 2026 14:52:26 +0800 Subject: [PATCH 07/14] Brim and Cone Aware Preview --- src/slic3r/GUI/3DScene.cpp | 41 ++++++++++++++++++++++++++++++++--- src/slic3r/GUI/GLCanvas3D.cpp | 4 +++- src/slic3r/GUI/Selection.cpp | 7 +++--- 3 files changed, 45 insertions(+), 7 deletions(-) diff --git a/src/slic3r/GUI/3DScene.cpp b/src/slic3r/GUI/3DScene.cpp index f65c0e3532..a21deaf209 100644 --- a/src/slic3r/GUI/3DScene.cpp +++ b/src/slic3r/GUI/3DScene.cpp @@ -20,6 +20,9 @@ #include "libslic3r/AppConfig.hpp" #include "libslic3r/PresetBundle.hpp" #include "libslic3r/ClipperUtils.hpp" +#include "libslic3r/GCode/WipeTower.hpp" +#include "libslic3r/GCode/WipeTower2.hpp" +#include "libslic3r/GCode/WipeTowerEstimate.hpp" #include "libslic3r/Tesselate.hpp" #include "libslic3r/PrintConfig.hpp" @@ -919,6 +922,33 @@ int GLVolumeCollection::load_wipe_tower_preview( GUI::PartPlateList& ppl = GUI::wxGetApp().plater()->get_partplate_list(); std::vector plate_extruders = ppl.get_plate(plate_idx)->get_extruders(true); TriangleMesh wipe_tower_shell = make_cube(width, depth, height); + // The brim is part of the printed footprint: draw it and fold it into the shell so the + // outside-bed shader and the drag clamp react to the true first-layer extent. + const bool show_brim = brim_width > 0.f; + const float brim_height = 0.2f; // one first layer, visual only + TriangleMesh brim_slab; + if (show_brim) { + // A Type2 cone-wall tower's base bulges past the body box — follow the real base + // outline instead of the rectangle. Type1 ignores the cone option. + Polygon cone_base; + { + // Preset enums are ConfigOptionEnumGeneric, so read them by value; the planner is + // resolved as the estimate resolves it, off the printer preset. + const DynamicPrintConfig &print_cfg = GUI::wxGetApp().preset_bundle->prints.get_edited_preset().config; + const DynamicPrintConfig &printer_cfg = GUI::wxGetApp().preset_bundle->printers.get_edited_preset().config; + const ConfigOption *wall_opt = print_cfg.option("wipe_tower_wall_type"); + if (wall_opt != nullptr && wall_opt->getInt() == int(WipeTowerWallType::wtwCone) && resolve_wipe_tower_type(printer_cfg) == WipeTowerType::Type2) + cone_base = WipeTower2::cone_base_polygon(width, depth, height, print_cfg.opt_float("wipe_tower_cone_angle")); + } + if (!cone_base.empty()) { + Polygons brim_outline = offset(cone_base, scaled(brim_width)); + brim_slab = WipeTower::its_make_rib_brim(brim_outline.empty() ? cone_base : brim_outline.front(), brim_height); + } else { + brim_slab = make_cube(width + 2.f * brim_width, depth + 2.f * brim_width, brim_height); + brim_slab.translate({-brim_width, -brim_width, 0.f}); + } + wipe_tower_shell.merge(brim_slab); + } for (int extruder_id : plate_extruders) { if (extruder_id <= extruder_colors.size()) colors.push_back(extruder_colors[extruder_id - 1]); @@ -929,14 +959,19 @@ int GLVolumeCollection::load_wipe_tower_preview( // Orca: make it transparent for(auto& color : colors) color.a(0.66f); + const size_t slab_count = colors.size(); // per-filament body slabs; the brim part comes after + if (show_brim && !colors.empty()) + colors.push_back(colors.front()); volumes.emplace_back(new GLWipeTowerVolume(colors)); GLWipeTowerVolume& v = *dynamic_cast(volumes.back()); v.model_per_colors.resize(colors.size()); - for (int i = 0; i < colors.size(); i++) { - TriangleMesh color_part = make_cube(width, depth / colors.size(), height); - color_part.translate({ 0.f, depth * i / colors.size(), 0. }); + for (size_t i = 0; i < slab_count; i++) { + TriangleMesh color_part = make_cube(width, depth / slab_count, height); + color_part.translate({ 0.f, depth * i / slab_count, 0. }); v.model_per_colors[i].init_from(color_part); } + if (show_brim && !colors.empty()) + v.model_per_colors[slab_count].init_from(brim_slab); v.model.init_from(wipe_tower_shell); v.mesh_raycaster = std::make_unique(std::make_shared(wipe_tower_shell)); v.set_convex_hull(wipe_tower_shell); diff --git a/src/slic3r/GUI/GLCanvas3D.cpp b/src/slic3r/GUI/GLCanvas3D.cpp index 5ad6151c68..8d72405cdd 100644 --- a/src/slic3r/GUI/GLCanvas3D.cpp +++ b/src/slic3r/GUI/GLCanvas3D.cpp @@ -2907,7 +2907,9 @@ void GLCanvas3D::reload_scene(bool refresh_immediately, bool force_full_scene_re Vec3d wipe_tower_size(footprint.width, footprint.depth, footprint.height); // set_default_wipe_tower_pos_for_plate doesn't rerun when painting changes the - // filament count, so redo its clamp here on every reload. + // filament count, so redo its clamp here on every reload — unconditionally: a + // paint-triggered reload can arrive before the background process invalidates + // psWipeTower, so gating on it would skip the clamp exactly when it is needed. { Vec3d clamped_pos, clamped_size; part_plate->estimate_wipe_tower_polygon(full_config, plate_id, clamped_pos, clamped_size); diff --git a/src/slic3r/GUI/Selection.cpp b/src/slic3r/GUI/Selection.cpp index b6d7abde21..e74cc832a8 100644 --- a/src/slic3r/GUI/Selection.cpp +++ b/src/slic3r/GUI/Selection.cpp @@ -1274,9 +1274,10 @@ void Selection::translate(const Vec3d &displacement, TransformationType transfor Vec3d tower_origin = m_cache.volumes_data[i].get_volume_position(); Vec3d actual_displacement = displacement; bool show_read_wipe_tower = wxGetApp().plater()->get_partplate_list().get_plate(plate_idx)->fff_print()->is_step_done(psWipeTower); - float brim_width = wxGetApp().preset_bundle->prints.get_edited_preset().config.opt_float("prime_tower_brim_width"); - - const double margin = show_read_wipe_tower ? WIPE_TOWER_MARGIN : brim_width + 0.5; // 0.5 is the line width of wipe tower + // Both preview volumes carry the brim in their bounding box (the estimate + // preview merges a brim slab, the sliced preview the real brim mesh), so the + // drag clamp only pads by the wipe tower line width. + const double margin = show_read_wipe_tower ? WIPE_TOWER_MARGIN : 0.5; // 0.5 is the line width of wipe tower actual_displacement = (m_cache.volumes_data[i].get_instance_rotation_matrix() * m_cache.volumes_data[i].get_instance_scale_matrix() * m_cache.volumes_data[i].get_instance_mirror_matrix()) From 4c583212f58519fff74ff92ba767fb03e42e8e4b Mon Sep 17 00:00:00 2001 From: Hanif Koh Date: Thu, 3 Sep 2026 15:51:31 +0800 Subject: [PATCH 08/14] Match Drag Margin to Release Clamp --- src/slic3r/GUI/Selection.cpp | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/slic3r/GUI/Selection.cpp b/src/slic3r/GUI/Selection.cpp index e74cc832a8..84f4a0b836 100644 --- a/src/slic3r/GUI/Selection.cpp +++ b/src/slic3r/GUI/Selection.cpp @@ -1273,11 +1273,9 @@ void Selection::translate(const Vec3d &displacement, TransformationType transfor const Polygons bed_polys{wxGetApp().plater()->get_partplate_list().get_plate(plate_idx)->get_shared_printable_polygon()}; Vec3d tower_origin = m_cache.volumes_data[i].get_volume_position(); Vec3d actual_displacement = displacement; - bool show_read_wipe_tower = wxGetApp().plater()->get_partplate_list().get_plate(plate_idx)->fff_print()->is_step_done(psWipeTower); - // Both preview volumes carry the brim in their bounding box (the estimate - // preview merges a brim slab, the sliced preview the real brim mesh), so the - // drag clamp only pads by the wipe tower line width. - const double margin = show_read_wipe_tower ? WIPE_TOWER_MARGIN : 0.5; // 0.5 is the line width of wipe tower + // Both preview volumes carry the brim in their bounding box, and the release + // clamp holds it WIPE_TOWER_MARGIN inside — same margin, so drops don't snap. + const double margin = WIPE_TOWER_MARGIN; actual_displacement = (m_cache.volumes_data[i].get_instance_rotation_matrix() * m_cache.volumes_data[i].get_instance_scale_matrix() * m_cache.volumes_data[i].get_instance_mirror_matrix()) From 869805132ec1ec80f29a9a83f6bc9030b4873600 Mon Sep 17 00:00:00 2001 From: Hanif Koh Date: Thu, 3 Sep 2026 17:07:48 +0800 Subject: [PATCH 09/14] Add Separate Comfort Margin for Auto Placement --- src/libslic3r/libslic3r.h | 3 +++ src/slic3r/GUI/PartPlate.cpp | 25 +++++++++++++++++++------ 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/src/libslic3r/libslic3r.h b/src/libslic3r/libslic3r.h index 6584566f40..c339da566a 100644 --- a/src/libslic3r/libslic3r.h +++ b/src/libslic3r/libslic3r.h @@ -93,6 +93,9 @@ static constexpr double INSET_OVERLAP_TOLERANCE = 0.4; static constexpr double EXTERNAL_INFILL_MARGIN = 3; static constexpr double BRIDGE_INFILL_MARGIN = 1; static constexpr double WIPE_TOWER_MARGIN = 1.; +// Margin for system placement of the wipe tower (defaults, re-placement, CLI). Positions +// within WIPE_TOWER_MARGIN stay valid: a user drag down to that limit is respected. +static constexpr double WIPE_TOWER_AUTO_MARGIN = 15.; //FIXME Better to use an inline function with an explicit return type. //inline coord_t scale_(coordf_t v) { return coord_t(floor(v / SCALING_FACTOR + 0.5f)); } #define scale_(val) ((val) / SCALING_FACTOR) diff --git a/src/slic3r/GUI/PartPlate.cpp b/src/slic3r/GUI/PartPlate.cpp index beb244a39c..bf511ec657 100644 --- a/src/slic3r/GUI/PartPlate.cpp +++ b/src/slic3r/GUI/PartPlate.cpp @@ -2404,13 +2404,26 @@ arrangement::ArrangePolygon PartPlate::estimate_wipe_tower_polygon(const Dynamic 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())})); } + // A position valid by WIPE_TOWER_MARGIN is the user's choice and stays untouched; an + // invalid one is re-placed with the comfort margin (falling back to the validity bounds + // on cramped plates). std::clamp is UB if lo > hi, so keep every hi >= lo. 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 - // in release silently returns the negative hi. - x = std::clamp(x, margin, std::max(margin, (float)plate_width - w - margin)); - y = std::clamp(y, margin, std::max(margin, (float)plate_depth - depth - margin)); + const float x_hi = std::max(margin, (float) plate_width - w - margin); + const float y_hi = std::max(margin, (float) plate_depth - depth - margin); + const float margin_c = (float) WIPE_TOWER_AUTO_MARGIN + wp_brim_width; + float x_lo_c = margin_c, x_hi_c = (float) plate_width - w - margin_c; + if (x_lo_c > x_hi_c) { x_lo_c = margin; x_hi_c = x_hi; } + float y_lo_c = margin_c, y_hi_c = (float) plate_depth - depth - margin_c; + if (y_lo_c > y_hi_c) { y_lo_c = margin; y_hi_c = y_hi; } + // Drag clamps reach this limit through the volume's bounding box (post-slice: the real + // mesh, a couple of mm inside this reserved estimate), so a drop can land slightly out + // of bounds — snap it onto the bound; only far-out positions get the comfort re-place. + const float tol = 5.f; + if (x < margin - tol || x > x_hi + tol) x = std::clamp(x, x_lo_c, x_hi_c); + else x = std::clamp(x, margin, x_hi); + if (y < margin - tol || y > y_hi + tol) y = std::clamp(y, y_lo_c, y_hi_c); + else y = std::clamp(y, margin, y_hi); wt_pos(0) = x; wt_pos(1) = y; wt_pos(2) = 0.f; @@ -4504,7 +4517,7 @@ void PartPlateList::set_default_wipe_tower_pos_for_plate(int plate_idx, bool ini // Brim-aware margin: the brim extends outward from the tower position. const float brim_width = float(footprint.brim_width); - const float margin = WIPE_TOWER_MARGIN + brim_width; + const float margin = WIPE_TOWER_AUTO_MARGIN + brim_width; // clamp wipe tower position within plate boundaries { From 2fdc16f9f28cdaf5951e0f2e29282dc1f749adeb Mon Sep 17 00:00:00 2001 From: Hanif Koh Date: Tue, 8 Sep 2026 14:43:34 +0800 Subject: [PATCH 10/14] Size a no-purge tower at the planners' idle depth Smooth timelapse no longer charges a prime volume it does not purge. A tower printed with no tool change is exactly the idle depth: the stability minimum for Type2, the wrapping detection depth for Type1. Charging a full prime_volume on top made the previewed and arranged tower deeper than the one that is printed. The Type2 half of "a tool change reserves a tower whatever the purge volumes resolve to" arrives with the base commit; here it only has to survive the planner split, since Type1 already reserves per filament. The wipe tower filament only joins the tool ordering when there is a tower to join, which is the has_wipe_tower() half of the guard Print::extruders applies. --- src/libslic3r/GCode/WipeTowerEstimate.cpp | 9 +++---- src/slic3r/GUI/PartPlate.cpp | 6 +++-- tests/libslic3r/test_wipe_tower_estimate.cpp | 26 ++++++++++++-------- 3 files changed, 23 insertions(+), 18 deletions(-) diff --git a/src/libslic3r/GCode/WipeTowerEstimate.cpp b/src/libslic3r/GCode/WipeTowerEstimate.cpp index 6fee774e9c..f40f2899fd 100644 --- a/src/libslic3r/GCode/WipeTowerEstimate.cpp +++ b/src/libslic3r/GCode/WipeTowerEstimate.cpp @@ -96,12 +96,9 @@ WipeTowerFootprint estimate_wipe_tower_footprint(const ConfigBase &config, WipeT // normalize_fdm_2 clearing enable_prime_tower. Its mixed-filament case is not modelled. const bool need_wipe_tower = smooth_timelapse || wrapping; - // No tool change, nothing to purge; smooth timelapse still primes once. - size_t purge_count = 0; - if (filaments_cnt > 1) - purge_count = dual_nozzle ? filaments_cnt : filaments_cnt - 1; - else if (smooth_timelapse) - purge_count = 1; + // A tower printed for one of the reasons above has no tool change to purge for; both + // planners give it the idle depth below and nothing more. + const size_t purge_count = filaments_cnt > 1 ? (dual_nozzle ? filaments_cnt : filaments_cnt - 1) : 0; // Type2 purges one volume per tool change. Type1 plans per filament below; here the volume // only decides whether a tower exists. diff --git a/src/slic3r/GUI/PartPlate.cpp b/src/slic3r/GUI/PartPlate.cpp index bf511ec657..e73d43b782 100644 --- a/src/slic3r/GUI/PartPlate.cpp +++ b/src/slic3r/GUI/PartPlate.cpp @@ -2341,10 +2341,12 @@ WipeTowerFootprint PartPlate::estimate_wipe_tower_footprint(const DynamicPrintCo 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. + // validation counts it - but only where there is a tower to join, which is the + // has_wipe_tower() half of that guard. const ConfigOption *wipe_tower_filament_opt = config.option("wipe_tower_filament"); + const ConfigOption *enable_prime_tower_opt = config.option("enable_prime_tower"); const int wipe_tower_filament = wipe_tower_filament_opt != nullptr ? wipe_tower_filament_opt->getInt() : 0; - if (plate_extruders.size() > 1 && wipe_tower_filament > 0 && + if (enable_prime_tower_opt != nullptr && enable_prime_tower_opt->getBool() && 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()) diff --git a/tests/libslic3r/test_wipe_tower_estimate.cpp b/tests/libslic3r/test_wipe_tower_estimate.cpp index ae0a92f40a..fee5c1f152 100644 --- a/tests/libslic3r/test_wipe_tower_estimate.cpp +++ b/tests/libslic3r/test_wipe_tower_estimate.cpp @@ -171,22 +171,28 @@ TEST_CASE("A single filament only gets a tower when one is printed anyway", "[Wi 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. + // A tower printed with no tool change is exactly the planner's idle depth: there is + // nothing to purge, and WipeTower2 sizes it at the stability floor. 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)); + CHECK_THAT(estimate(config, 1, 0.2, 5.).depth, WithinAbs(WipeTower::get_limit_depth_by_height(5.f), 1e-9)); } -TEST_CASE("A tool change reserves the stability floor even with nothing to purge", "[WipeTowerEstimate]") { - // The purge volumes are configurable down to zero, but the tool changes are still printed on - // the tower and the generator still floors it, so the estimate has to floor it too. - const double height = GENERATE(5., 100.); - const float floor = WipeTower::get_limit_depth_by_height(float(height)); - DynamicPrintConfig config = make_config(GENERATE("rectangle", "rib")); +TEST_CASE("A tool change reserves a tower even with nothing to purge", "[WipeTowerEstimate]") { + // The purge volumes are configurable down to zero, but the tool changes are still printed + // on the tower and both planners still floor it - so the estimate has to floor it too. + // Type1 plans per filament and already reserves one; Type2 has only the volume to go on. + const double height = GENERATE(5., 100.); + const float floor = WipeTower::get_limit_depth_by_height(float(height)); + const char *wall = GENERATE("rectangle", "rib"); + DynamicPrintConfig config = make_config(wall); config.set_key_value("prime_volume", new ConfigOptionFloat(0.)); + config.set_key_value("filament_prime_volume", new ConfigOptionFloats({0.})); - CHECK(estimate(config, 3, 0.2, height).depth >= floor); + CHECK(estimate(config, 3, 0.2, height, WipeTowerType::Type2).depth >= floor); + CHECK(estimate(config, 3, 0.2, height, WipeTowerType::Type1).depth >= floor); // Still nothing for a lone filament with no other reason. - CHECK_THAT(estimate(config, 1, 0.2, height).depth, WithinAbs(0., 1e-9)); + CHECK_THAT(estimate(config, 1, 0.2, height, WipeTowerType::Type2).depth, WithinAbs(0., 1e-9)); + CHECK_THAT(estimate(config, 1, 0.2, height, WipeTowerType::Type1).depth, WithinAbs(0., 1e-9)); } TEST_CASE("Both wall types agree on whether there is a tower at all", "[WipeTowerEstimate]") { From 81357695c518c090cf32d18777978dc880d89556 Mon Sep 17 00:00:00 2001 From: Hanif Koh Date: Thu, 3 Sep 2026 14:52:12 +0800 Subject: [PATCH 11/14] Verify WipeTower Footprint at Point of Generation The clamps and validation work from estimates. Once the tower is generated, _make_wipe_tower re-tests the exact first-layer footprint, brim and cone base included, against the printable area and the exclusion zone, so an off-plate tower fails with a clear error instead of exporting unprintable G-code. The rectangle-wall mesh footprint learns the Type2 cone base so that check and the post-generation validation see the real outline. Pre-generation, validation hard-checks the body plus an explicit brim and warns on the estimated auto brim and cone base with the existing "may collide" strings, so the user hears about a marginal position on the first slice rather than only at generation time. Two fff_print fixtures that print a tower at the default position move it onto the 200 mm test bed, as the multifilament fixtures already do: the shipped default y of 220 is off that bed, and the backstop now says so instead of exporting the tower. --- src/libslic3r/Print.cpp | 87 +++++++++++++++++++++++----- src/libslic3r/Print.hpp | 2 +- tests/fff_print/test_gcodewriter.cpp | 3 + tests/fff_print/test_wipe_tower.cpp | 2 + 4 files changed, 78 insertions(+), 16 deletions(-) diff --git a/src/libslic3r/Print.cpp b/src/libslic3r/Print.cpp index 271e410933..1b54a527ea 100644 --- a/src/libslic3r/Print.cpp +++ b/src/libslic3r/Print.cpp @@ -1068,33 +1068,57 @@ static StringObjectException layered_print_cleareance_valid(const Print &print, convex_hulls_temp.push_back(wipe_tower_polygon); } } + // Post-generation the mesh bottom already carries the brim. Pre-generation the body grows + // by the brim only when its width is explicit; the auto brim and a Type2 cone base depend on + // the tower height, exact only once generated, so they only warn here - the exact footprint + // is re-checked in _make_wipe_tower. + const bool exact_footprint = print.is_step_done(psWipeTower); + Polygons tower_polys_checked = (!exact_footprint && config.prime_tower_brim_width.value >= 0) ? + offset(convex_hulls_temp, float(scale_(brim_width))) : + convex_hulls_temp; + Polygons tower_polys_estimated; + if (!exact_footprint && !convex_hulls_temp.empty()) { + Polygon base = convex_hulls_temp.front(); + if (config.wipe_tower_wall_type.value == WipeTowerWallType::wtwCone && print.wipe_tower_type() == WipeTowerType::Type2) { + double max_height = 0.; + for (const PrintObject *object : print.objects()) + max_height = std::max(max_height, unscale_(object->size().z())); + base = WipeTower2::cone_base_polygon(width, depth, max_height, config.wipe_tower_cone_angle.value); + base.rotate(Geometry::deg2rad(a)); + base.translate(Point(scale_(x), scale_(y))); + } + tower_polys_estimated = offset(base, float(scale_(brim_width))); + } + // Object proximity stays a body-only warning: brim near-misses would newly warn on + // many setups that print fine. if (!intersection(convex_hulls_other, convex_hulls_temp).empty()) { if (warning) { warning->string += L("Prime Tower") + L(" is too close to others, and collisions may be caused.\n"); } } - if (!intersection(exclude_polys, convex_hulls_temp).empty()) { - /*if (warning) { - warning->string += L("Prime Tower is too close to exclusion area, there may be collisions when printing.\n"); - }*/ + if (!intersection(exclude_polys, tower_polys_checked).empty()) { return {L("Prime Tower") + L(" is too close to an exclusion area, and collisions will be caused.\n")}; } - if (print_config.enable_wrapping_detection.value && !intersection({wrapping_poly}, convex_hulls_temp).empty()) { + if (print_config.enable_wrapping_detection.value && !intersection({wrapping_poly}, tower_polys_checked).empty()) { return {L("Prime Tower") + L(" is too close to clumping detection area, and collisions will be caused.\n")}; } - // No gate on "is there a tower": one that is not printed estimates to zero, so the hull - // is degenerate and every check passes. Re-deriving it here missed the wrapping-detection + if (warning && !intersection(exclude_polys, tower_polys_estimated).empty()) { + warning->string += L("Prime Tower") + L(" is too close to exclusion area, there may be collisions when printing.") + "\n"; + } + if (warning && print_config.enable_wrapping_detection.value && !intersection({wrapping_poly}, tower_polys_estimated).empty()) { + warning->string += L("Prime Tower") + L(" is too close to clumping detection area, there may be collisions when printing.") + "\n"; + } + // No gate on "is there a tower": one that is not printed estimates to zero, so the hulls + // are degenerate and every check passes. Re-deriving it here missed the wrapping-detection // tower on a single-filament plate. - // Pre-generation, grow the body by the brim to match what the generator draws; - // post-generation the mesh already includes it. Polygons printable_polys = print.get_extruder_shared_printable_polygon(); const Point plate_shift(scale_(plate_origin.x()), scale_(plate_origin.y())); for (Polygon &p : printable_polys) p.translate(plate_shift); - Polygons tower_polys_with_brim = print.is_step_done(psWipeTower) ? - convex_hulls_temp : offset(convex_hulls_temp, float(scale_(brim_width))); - if (!diff(tower_polys_with_brim, printable_polys).empty()) + if (!diff(tower_polys_checked, printable_polys).empty()) return {L("Prime Tower") + L(" is partially outside the printable area, and it cannot be printed.\n")}; + if (warning && !diff(tower_polys_estimated, printable_polys).empty()) + warning->string += L("Prime Tower") + L(" is partially outside the printable area, and it cannot be printed.\n"); return {}; } @@ -4390,7 +4414,9 @@ void Print::_make_wipe_tower() wipe_tower.get_wipe_tower_height(), wipe_tower.get_brim_width(), config().wipe_tower_wall_type.value == WipeTowerWallType::wtwRib, wipe_tower.get_rib_width(), wipe_tower.get_rib_length(), - config().wipe_tower_fillet_wall.value); + config().wipe_tower_fillet_wall.value, + config().wipe_tower_wall_type.value == WipeTowerWallType::wtwCone ? + (float) config().wipe_tower_cone_angle.value : 0.f); const Vec3d origin = Vec3d::Zero(); // FakeWipeTower::pos is a bed-frame translation applied after rotation // (getFakeExtrusionPathsFromWipeTower2 rotates about the local origin), so the @@ -4403,6 +4429,28 @@ void Print::_make_wipe_tower() config().wipe_tower_rotation_angle, config().wipe_tower_cone_angle, {scale_(origin.x()), scale_(origin.y())}); } + + // The clamps and checks above work from estimates; re-test the exact generated footprint + // so an off-plate tower fails with a clear error instead of exporting unprintable G-code + // (validate() only sees the mesh on its next run). + if (m_wipe_tower_data.wipe_tower_mesh_data) { + Polygon footprint = m_wipe_tower_data.wipe_tower_mesh_data->bottom; // includes brim and rib offset + footprint.rotate(Geometry::deg2rad(m_config.wipe_tower_rotation_angle.value)); + footprint.translate(Point(scale_(m_config.wipe_tower_x.get_at(m_plate_index)), + scale_(m_config.wipe_tower_y.get_at(m_plate_index)))); + const Polygons printable_polys = this->get_extruder_shared_printable_polygon(); + if (!printable_polys.empty() && !diff(Polygons{footprint}, printable_polys).empty()) { + const BoundingBox fp = get_extents(footprint); + const BoundingBox pr = get_extents(printable_polys); + BOOST_LOG_TRIVIAL(error) << boost::format("wipe tower footprint [%1%,%2%]-[%3%,%4%] leaves printable [%5%,%6%]-[%7%,%8%]") % + unscaled(fp.min.x()) % unscaled(fp.min.y()) % unscaled(fp.max.x()) % unscaled(fp.max.y()) % + unscaled(pr.min.x()) % unscaled(pr.min.y()) % unscaled(pr.max.x()) % unscaled(pr.max.y()); + throw Slic3r::SlicingError(L("Prime Tower") + L(" is partially outside the printable area, and it cannot be printed.\n")); + } + // The cutter/purge corner is a physical obstacle — the brim must stay out like the body. + if (!intersection(get_bed_excluded_area(m_config), Polygons{footprint}).empty()) + throw Slic3r::SlicingError(L("Prime Tower") + L(" is too close to an exclusion area, and collisions will be caused.\n")); + } } // Generate a recommended G-code output file name based on the format template, default extension, and template parameters @@ -5951,12 +5999,21 @@ ExtrusionLayers FakeWipeTower::getTrueExtrusionLayersFromWipeTower() const } return wtels; } -void WipeTowerData::construct_mesh(float width, float depth, float height, float brim_width, bool is_rib_wipe_tower, float rib_width, float rib_length,bool fillet_wall) +void WipeTowerData::construct_mesh(float width, float depth, float height, float brim_width, bool is_rib_wipe_tower, float rib_width, float rib_length,bool fillet_wall, float cone_angle) { wipe_tower_mesh_data = WipeTowerMeshData{}; float first_layer_height=0.08; //brim height if (width < EPSILON || depth < EPSILON || height < EPSILON) return; - if (!is_rib_wipe_tower || rib_length < EPSILON) { + if (cone_angle > EPSILON && (!is_rib_wipe_tower || rib_length < EPSILON)) { + // Cone tower: the base bulges past the body box; this bottom polygon feeds the + // containment checks, so it must carry the bulge and the brim (cone not lofted). + wipe_tower_mesh_data->real_wipe_tower_mesh = make_cube(width, depth, height); + wipe_tower_mesh_data->bottom = WipeTower2::cone_base_polygon(width, depth, height, cone_angle); + auto brim_bottom = offset(wipe_tower_mesh_data->bottom, scaled(brim_width)); + if (!brim_bottom.empty()) + wipe_tower_mesh_data->bottom = brim_bottom.front(); + wipe_tower_mesh_data->real_brim_mesh = WipeTower::its_make_rib_brim(wipe_tower_mesh_data->bottom, first_layer_height); + } else if (!is_rib_wipe_tower || rib_length < EPSILON) { wipe_tower_mesh_data->real_wipe_tower_mesh = make_cube(width, depth, height); wipe_tower_mesh_data->real_brim_mesh = make_cube(width + 2 * brim_width, depth + 2 * brim_width, first_layer_height); wipe_tower_mesh_data->real_brim_mesh.translate({-brim_width, -brim_width, 0}); diff --git a/src/libslic3r/Print.hpp b/src/libslic3r/Print.hpp index af1dc3af40..9822c5520c 100644 --- a/src/libslic3r/Print.hpp +++ b/src/libslic3r/Print.hpp @@ -804,7 +804,7 @@ struct WipeTowerData rib_offset = Vec2f::Zero(); wipe_tower_mesh_data = std::nullopt; } - void construct_mesh(float width, float depth, float height, float brim_width, bool is_rib_wipe_tower, float rib_width, float rib_length, bool fillet_wall); + void construct_mesh(float width, float depth, float height, float brim_width, bool is_rib_wipe_tower, float rib_width, float rib_length, bool fillet_wall, float cone_angle = 0.f); private: // Only allow the WipeTowerData to be instantiated internally by Print, diff --git a/tests/fff_print/test_gcodewriter.cpp b/tests/fff_print/test_gcodewriter.cpp index b09b38e794..c0c9e794b9 100644 --- a/tests/fff_print/test_gcodewriter.cpp +++ b/tests/fff_print/test_gcodewriter.cpp @@ -574,6 +574,9 @@ static DynamicPrintConfig dual_extruder_toolchange_config() config.set_key_value("nozzle_temperature_range_high", new ConfigOptionInts({240, 240})); config.set_key_value("flush_multiplier", new ConfigOptionFloats({1})); config.set_key_value("flush_volumes_matrix", new ConfigOptionFloats({0, 140, 140, 0})); + // Inside the 200x200 test bed; the default y, 220, is not, and generation rejects that. + config.set_key_value("wipe_tower_x", new ConfigOptionFloats({50.})); + config.set_key_value("wipe_tower_y", new ConfigOptionFloats({50.})); return config; } diff --git a/tests/fff_print/test_wipe_tower.cpp b/tests/fff_print/test_wipe_tower.cpp index 5a248075e4..24d50d6e66 100644 --- a/tests/fff_print/test_wipe_tower.cpp +++ b/tests/fff_print/test_wipe_tower.cpp @@ -152,6 +152,8 @@ static DynamicPrintConfig wipe_tower_toolchange_config(const std::string &gcode_ { "outer_wall_filament_id", 2 }, { "inner_wall_filament_id", 2 }, { "enable_prime_tower", true }, + { "wipe_tower_x", 50 }, // inside the 200x200 test bed + { "wipe_tower_y", 50 }, // (the default y, 220, is not) { "layer_height", 0.3 }, { "gcode_flavor", gcode_flavor }, }); From fae77be3db53af3f87a8fdf561f77450f9ace0a6 Mon Sep 17 00:00:00 2001 From: Hanif Koh Date: Mon, 7 Sep 2026 21:04:15 +0800 Subject: [PATCH 12/14] Place the Wipe Tower in the Profile Validator The validator forces a two-filament print with the prime tower on and slices it at the config default position (x 15, y 220), which lies off any bed shallower than the tower. It calls validate() but slices regardless, so the off-plate tower was exported silently; with the generation-time footprint check it is rejected instead, and 522 of the 1013 printer presets failed the slice check. The validator now positions the tower the way the GUI and CLI do before slicing: beside the centred cube, clear of the edge exclusion strips some beds carry, then pulled inside the printable outline by the tower's own estimated footprint. --- .../OrcaSlicer_profile_validator.cpp | 36 ++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/src/dev-utils/OrcaSlicer_profile_validator.cpp b/src/dev-utils/OrcaSlicer_profile_validator.cpp index 67d0ba444d..56acbaa732 100644 --- a/src/dev-utils/OrcaSlicer_profile_validator.cpp +++ b/src/dev-utils/OrcaSlicer_profile_validator.cpp @@ -8,7 +8,11 @@ #define NANOSVGRAST_IMPLEMENTATION #include "nanosvg/nanosvgrast.h" +#include "libslic3r/BoundingBox.hpp" #include "libslic3r/GCode.hpp" +#include "libslic3r/GCode/WipeTower.hpp" +#include "libslic3r/GCode/WipeTowerEstimate.hpp" +#include "libslic3r/Geometry.hpp" #include "libslic3r/Preset.hpp" #include "libslic3r/Config.hpp" #include "libslic3r/PresetBundle.hpp" @@ -116,15 +120,45 @@ Vec2d printable_area_center(const DynamicPrintConfig &cfg) return 0.5 * (lo + hi); } +// Put the prime tower where the GUI and CLI would before slicing. The config default (x 15, y 220) +// lies off any bed shallower than the tower, and generation rejects an off-plate tower instead of +// exporting it. Beside the centred cube, clear of the edge exclusion strips some beds carry, then +// pulled inside the printable outline by the tower's own estimated footprint, with a few mm of +// clearance so the conflict checker never sees the two touch. +void place_wipe_tower(DynamicPrintConfig &cfg, const Vec2d ¢er) +{ + const auto *area = cfg.option("printable_area"); + if (area == nullptr || area->values.size() < 3) + return; + const WipeTowerFootprint footprint = estimate_wipe_tower_footprint(cfg, resolve_wipe_tower_type(cfg), {0, 1}, cfg.opt_float("layer_height"), 10.); + if (footprint.depth < EPSILON) + return; + const double margin = WIPE_TOWER_MARGIN + footprint.brim_width; + // The position is the tower's own origin; a rotated tower extends from it in another + // direction, so place the rotated box's extents rather than the origin. + Slic3r::Polygon box({Point::new_scale(0., 0.), Point::new_scale(footprint.width, 0.), Point::new_scale(footprint.width, footprint.depth), Point::new_scale(0., footprint.depth)}); + box.rotate(Geometry::deg2rad(cfg.opt_float("wipe_tower_rotation_angle"))); + const BoundingBox local = get_extents(box); + const Vec2d lo = unscale(local.min); + const Vec2d size = unscale(local.max) - lo; + Vec2d pos(center.x() + 5. + margin + 5. - lo.x(), center.y() - size.y() / 2. - lo.y()); + box.translate(Point::new_scale(pos.x(), pos.y())); + const Vec2f move = WipeTower::move_box_inside_polygon(get_extents(box), Polygons{Polygon::new_scale(area->values)}, scaled(margin)); + pos += move.cast(); + cfg.option("wipe_tower_x", true)->values = {pos.x()}; + cfg.option("wipe_tower_y", true)->values = {pos.y()}; +} + // Slice one centered cube that switches from filament 1 to filament 2 partway up, so exactly one // filament change fires, then export. The change drives the printer's own change_filament_gcode: on a // single-nozzle machine it rides the AMS prime tower (append_tcr), on a multi-nozzle machine it routes // through the nozzle swap (set_extruder / append_tcr2) - the engine picks the path from the printer's // topology, so one model covers both. An undefined placeholder in any shipped custom g-code throws // Slic3r::PlaceholderParserError from export. -std::string slice_two_color_cube_and_export(const DynamicPrintConfig &cfg, bool is_bbl) +std::string slice_two_color_cube_and_export(DynamicPrintConfig cfg, bool is_bbl) { const Vec2d center = printable_area_center(cfg); + place_wipe_tower(cfg, center); TriangleMesh m = make_cube(10, 10, 10); m.translate(float(center.x() - 5.), float(center.y() - 5.), 0.f); From 2f2a6bc3b5039603d3b2b78fcd97104e7474a196 Mon Sep 17 00:00:00 2001 From: Hanif Koh Date: Mon, 7 Sep 2026 19:41:01 +0800 Subject: [PATCH 13/14] Share the Estimated First-Layer Outline of the Wipe Tower The preview brim, the placement margin and the pre-generation validation warning each decided on their own whether the tower has a Type2 cone base, reading the wall type and cone angle three different ways. The preview's read cast the preset's enum to ConfigOptionEnum, which a preset-shaped config never holds, so the cone base was never previewed. estimate_wipe_tower_first_layer_outline now answers that question once, beside the footprint estimate, from the config and the resolved planner; all three sites take the outline from it. The libslic3r case reads the outline off a preset-shaped config, where the old cast came back empty. --- src/libslic3r/GCode/WipeTowerEstimate.cpp | 11 ++++++++ src/libslic3r/GCode/WipeTowerEstimate.hpp | 8 ++++++ src/libslic3r/Print.cpp | 15 ++++------ src/slic3r/GUI/3DScene.cpp | 27 +++++------------- src/slic3r/GUI/PartPlate.cpp | 12 ++------ tests/libslic3r/test_wipe_tower_estimate.cpp | 29 ++++++++++++++++++++ 6 files changed, 64 insertions(+), 38 deletions(-) diff --git a/src/libslic3r/GCode/WipeTowerEstimate.cpp b/src/libslic3r/GCode/WipeTowerEstimate.cpp index f40f2899fd..d8cfe74527 100644 --- a/src/libslic3r/GCode/WipeTowerEstimate.cpp +++ b/src/libslic3r/GCode/WipeTowerEstimate.cpp @@ -37,6 +37,17 @@ WipeTowerType resolve_wipe_tower_type(const ConfigBase &config) return type != nullptr ? WipeTowerType(type->getInt()) : WipeTowerType::Type2; } +Polygon estimate_wipe_tower_first_layer_outline(const ConfigBase &config, WipeTowerType tower_type, double width, double depth, double height) +{ + // Type1 ignores the cone option. The wall type is read by value: a preset-shaped config + // holds it as ConfigOptionEnumGeneric, which a cast to ConfigOptionEnum cannot see. + const ConfigOption *wall_type = option_of(config, "wipe_tower_wall_type"); + const ConfigOption *cone_angle = option_of(config, "wipe_tower_cone_angle"); + const bool cone = tower_type == WipeTowerType::Type2 && wall_type != nullptr && + wall_type->getInt() == int(WipeTowerWallType::wtwCone) && cone_angle != nullptr; + return WipeTower2::cone_base_polygon(width, depth, height, cone ? cone_angle->getFloat() : 0.); +} + 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; diff --git a/src/libslic3r/GCode/WipeTowerEstimate.hpp b/src/libslic3r/GCode/WipeTowerEstimate.hpp index 5b333005e8..387028649b 100644 --- a/src/libslic3r/GCode/WipeTowerEstimate.hpp +++ b/src/libslic3r/GCode/WipeTowerEstimate.hpp @@ -2,6 +2,8 @@ #include +#include "../Polygon.hpp" + namespace Slic3r { class ConfigBase; @@ -23,6 +25,12 @@ struct WipeTowerFootprint // the GUI and CLI placement can resolve it without a Print. WipeTowerType resolve_wipe_tower_type(const ConfigBase &config); +// First-layer outline of an estimated tower in tower-local scaled coordinates, brim excluded: +// the body box, or for a Type2 cone wall the box unioned with the cone's base. The preview, +// the placement margin and validation all take the outline from here so they cannot disagree +// about whether a cone exists. +Polygon estimate_wipe_tower_first_layer_outline(const ConfigBase &config, WipeTowerType tower_type, double width, double depth, double height); + // 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. diff --git a/src/libslic3r/Print.cpp b/src/libslic3r/Print.cpp index 1b54a527ea..60f747ce04 100644 --- a/src/libslic3r/Print.cpp +++ b/src/libslic3r/Print.cpp @@ -1078,15 +1078,12 @@ static StringObjectException layered_print_cleareance_valid(const Print &print, convex_hulls_temp; Polygons tower_polys_estimated; if (!exact_footprint && !convex_hulls_temp.empty()) { - Polygon base = convex_hulls_temp.front(); - if (config.wipe_tower_wall_type.value == WipeTowerWallType::wtwCone && print.wipe_tower_type() == WipeTowerType::Type2) { - double max_height = 0.; - for (const PrintObject *object : print.objects()) - max_height = std::max(max_height, unscale_(object->size().z())); - base = WipeTower2::cone_base_polygon(width, depth, max_height, config.wipe_tower_cone_angle.value); - base.rotate(Geometry::deg2rad(a)); - base.translate(Point(scale_(x), scale_(y))); - } + double max_height = 0.; + for (const PrintObject *object : print.objects()) + max_height = std::max(max_height, unscale_(object->size().z())); + Polygon base = estimate_wipe_tower_first_layer_outline(config, print.wipe_tower_type(), width, depth, max_height); + base.rotate(Geometry::deg2rad(a)); + base.translate(Point(scale_(x), scale_(y))); tower_polys_estimated = offset(base, float(scale_(brim_width))); } // Object proximity stays a body-only warning: brim near-misses would newly warn on diff --git a/src/slic3r/GUI/3DScene.cpp b/src/slic3r/GUI/3DScene.cpp index a21deaf209..6dbea1272a 100644 --- a/src/slic3r/GUI/3DScene.cpp +++ b/src/slic3r/GUI/3DScene.cpp @@ -21,7 +21,6 @@ #include "libslic3r/PresetBundle.hpp" #include "libslic3r/ClipperUtils.hpp" #include "libslic3r/GCode/WipeTower.hpp" -#include "libslic3r/GCode/WipeTower2.hpp" #include "libslic3r/GCode/WipeTowerEstimate.hpp" #include "libslic3r/Tesselate.hpp" #include "libslic3r/PrintConfig.hpp" @@ -928,25 +927,13 @@ int GLVolumeCollection::load_wipe_tower_preview( const float brim_height = 0.2f; // one first layer, visual only TriangleMesh brim_slab; if (show_brim) { - // A Type2 cone-wall tower's base bulges past the body box — follow the real base - // outline instead of the rectangle. Type1 ignores the cone option. - Polygon cone_base; - { - // Preset enums are ConfigOptionEnumGeneric, so read them by value; the planner is - // resolved as the estimate resolves it, off the printer preset. - const DynamicPrintConfig &print_cfg = GUI::wxGetApp().preset_bundle->prints.get_edited_preset().config; - const DynamicPrintConfig &printer_cfg = GUI::wxGetApp().preset_bundle->printers.get_edited_preset().config; - const ConfigOption *wall_opt = print_cfg.option("wipe_tower_wall_type"); - if (wall_opt != nullptr && wall_opt->getInt() == int(WipeTowerWallType::wtwCone) && resolve_wipe_tower_type(printer_cfg) == WipeTowerType::Type2) - cone_base = WipeTower2::cone_base_polygon(width, depth, height, print_cfg.opt_float("wipe_tower_cone_angle")); - } - if (!cone_base.empty()) { - Polygons brim_outline = offset(cone_base, scaled(brim_width)); - brim_slab = WipeTower::its_make_rib_brim(brim_outline.empty() ? cone_base : brim_outline.front(), brim_height); - } else { - brim_slab = make_cube(width + 2.f * brim_width, depth + 2.f * brim_width, brim_height); - brim_slab.translate({-brim_width, -brim_width, 0.f}); - } + // The brim follows the real first-layer outline: a Type2 cone-wall tower's base bulges + // past the body box. The wall type and angle are print settings, the planner a printer one. + const DynamicPrintConfig &print_cfg = GUI::wxGetApp().preset_bundle->prints.get_edited_preset().config; + const DynamicPrintConfig &printer_cfg = GUI::wxGetApp().preset_bundle->printers.get_edited_preset().config; + const Polygon outline = estimate_wipe_tower_first_layer_outline(print_cfg, resolve_wipe_tower_type(printer_cfg), width, depth, height); + const Polygons brim_outline = offset(outline, scaled(brim_width)); + brim_slab = WipeTower::its_make_rib_brim(brim_outline.empty() ? outline : brim_outline.front(), brim_height); wipe_tower_shell.merge(brim_slab); } for (int extruder_id : plate_extruders) { diff --git a/src/slic3r/GUI/PartPlate.cpp b/src/slic3r/GUI/PartPlate.cpp index e73d43b782..90e2c96ab6 100644 --- a/src/slic3r/GUI/PartPlate.cpp +++ b/src/slic3r/GUI/PartPlate.cpp @@ -22,7 +22,6 @@ #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" @@ -2398,14 +2397,9 @@ arrangement::ArrangePolygon PartPlate::estimate_wipe_tower_polygon(const Dynamic // clamp put the brim off the bed. Matches set_default_wipe_tower_pos_for_plate. 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())})); - } + // bulge into the same margin. + const BoundingBox outline = get_extents(estimate_wipe_tower_first_layer_outline(config, resolve_wipe_tower_type(config), w, depth, wt_size.z())); + wp_brim_width += float(std::max({0., unscaled(outline.max.x()) - w, unscaled(outline.max.y()) - depth, -unscaled(outline.min.x()), -unscaled(outline.min.y())})); // A position valid by WIPE_TOWER_MARGIN is the user's choice and stays untouched; an // invalid one is re-placed with the comfort margin (falling back to the validity bounds // on cramped plates). std::clamp is UB if lo > hi, so keep every hi >= lo. diff --git a/tests/libslic3r/test_wipe_tower_estimate.cpp b/tests/libslic3r/test_wipe_tower_estimate.cpp index fee5c1f152..20644200f3 100644 --- a/tests/libslic3r/test_wipe_tower_estimate.cpp +++ b/tests/libslic3r/test_wipe_tower_estimate.cpp @@ -1,5 +1,7 @@ #include +#include "libslic3r/BoundingBox.hpp" +#include "libslic3r/ClipperUtils.hpp" #include "libslic3r/GCode/WipeTower.hpp" #include "libslic3r/GCode/WipeTower2.hpp" #include "libslic3r/GCode/WipeTowerEstimate.hpp" @@ -272,6 +274,33 @@ TEST_CASE("Every wall and tower type is read the same from a preset and a static CHECK(estimate(static_config, 1, 0.2, 5., type).depth > 0.); } +TEST_CASE("The first-layer outline bulges only for a Type2 cone wall", "[WipeTowerEstimate]") { + // Read off a preset-shaped config, whose enums are ConfigOptionEnumGeneric: a cast to + // ConfigOptionEnum sees no wall type there and would never find the cone. + DynamicPrintConfig config = make_config("cone"); + config.set_key_value("wipe_tower_cone_angle", new ConfigOptionFloat(25.)); + REQUIRE(dynamic_cast(config.option("wipe_tower_wall_type")) != nullptr); + const Polygon box = Polygon::new_scale({{0., 0.}, {35., 0.}, {35., 20.}, {0., 20.}}); + auto is_box = [&box](const Polygon &outline) { return diff(Polygons{outline}, Polygons{box}).empty(); }; + + // A 25-degree cone on a 100 mm tower has a 22 mm base radius, past the 10 mm half-depth. + const Polygon cone = estimate_wipe_tower_first_layer_outline(config, WipeTowerType::Type2, 35., 20., 100.); + CHECK(unscaled(get_extents(cone).max.y()) > 20. + 1.); + CHECK(diff(Polygons{box}, Polygons{cone}).empty()); + // Type1 ignores the cone option, and the other wall types have no cone. + CHECK(is_box(estimate_wipe_tower_first_layer_outline(config, WipeTowerType::Type1, 35., 20., 100.))); + for (const char *wall_type : {"rectangle", "rib"}) { + config.set_deserialize_strict("wipe_tower_wall_type", wall_type); + CHECK(is_box(estimate_wipe_tower_first_layer_outline(config, WipeTowerType::Type2, 35., 20., 100.))); + } + // The static config Print holds gives the same outline. + config.set_deserialize_strict("wipe_tower_wall_type", "cone"); + FullPrintConfig static_config; + static_config.apply(config, true); + const Polygon from_static = estimate_wipe_tower_first_layer_outline(static_config, WipeTowerType::Type2, 35., 20., 100.); + CHECK(from_static.points == cone.points); +} + TEST_CASE("A Bambu Lab printer always gets the Type1 planner", "[WipeTowerEstimate]") { DynamicPrintConfig config = make_config(); config.set_deserialize_strict("wipe_tower_type", "type2"); From 284539d76245243837848e18fafed595284177ef Mon Sep 17 00:00:00 2001 From: Hanif Koh Date: Thu, 3 Sep 2026 14:52:26 +0800 Subject: [PATCH 14/14] [CLI]: Place Wipe Tower before Slicing A plain CLI slice ran none of the placement sites, so a stored or default tower position that no longer fits the tower the plate needs went straight to the generation-time error. The slice loop now applies the same clamp the GUI applies on reload to every plate it is about to slice, skipping only plates that print no tower: by-object plates with more than one instance, and plates whose footprint estimate is empty (which covers single-filament plates without smooth timelapse, wrapping detection or a raft). The plate's filaments come from the same config-driven derivation the estimate uses everywhere else. The two arrange sites read the brim width from the right option when padding the default position; an auto brim uses its 8 mm cap there, since the object heights are unknown before the estimate runs. --- src/OrcaSlicer.cpp | 38 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 36 insertions(+), 2 deletions(-) diff --git a/src/OrcaSlicer.cpp b/src/OrcaSlicer.cpp index 5b1d2da1bf..1f85cf7358 100644 --- a/src/OrcaSlicer.cpp +++ b/src/OrcaSlicer.cpp @@ -4832,7 +4832,10 @@ int CLI::run(int argc, char **argv) int plate_count = partplate_list.get_plate_count(); auto printer_structure_opt = m_print_config.option>("printer_structure"); - const float tower_brim_width = m_print_config.option("prime_tower_width", true)->value; + // This margin only pre-adjusts the default away from the near edges; + // estimate_wipe_tower_polygon below computes the real clamped position. + float tower_brim_width = m_print_config.option("prime_tower_brim_width", true)->value; + if (tower_brim_width < 0.f) tower_brim_width = 8.f; // auto: object heights unknown here, 8 mm is the auto cap const float tower_margin = WIPE_TOWER_MARGIN + tower_brim_width; // set the default position, the same with print config(left top) @@ -5129,7 +5132,10 @@ int CLI::run(int argc, char **argv) int extruder_size = used_filament_set.size(); auto printer_structure_opt = m_print_config.option>("printer_structure"); - const float tower_brim_width = m_print_config.option("prime_tower_width", true)->value; + // This margin only pre-adjusts the default away from the near edges; + // estimate_wipe_tower_polygon below computes the real clamped position. + float tower_brim_width = m_print_config.option("prime_tower_brim_width", true)->value; + if (tower_brim_width < 0.f) tower_brim_width = 8.f; // auto: object heights unknown here, 8 mm is the auto cap const float tower_margin = WIPE_TOWER_MARGIN + tower_brim_width; // set the default position, the same with print config(left top) float x = WIPE_TOWER_DEFAULT_X_POS; @@ -5792,6 +5798,34 @@ int CLI::run(int argc, char **argv) //Print fff_print; std::vector plate_triangle_counts(partplate_list.get_plate_count(), 0); + // The stored (or default) tower position may not fit the tower these plates + // need, and no CLI placement site runs on a plain slice - mirror the GUI's + // reload clamp and fit every plate's tower into the printable area first. + if (m_print_config.option("enable_prime_tower", true)->value) { + for (int index = 0; index < partplate_list.get_plate_count(); index++) { + if ((plate_to_slice != 0) && (plate_to_slice != (index + 1))) + continue; + Slic3r::GUI::PartPlate *plate = partplate_list.get_plate(index); + // Printing by object disables the tower only with more than one instance. + bool is_seq_print = false; + get_print_sequence(plate, m_print_config, is_seq_print); + if (is_seq_print && plate->printable_instance_size() > 1) + continue; + // An empty estimate is a plate that prints no tower (one filament and + // neither smooth timelapse, wrapping detection nor a raft). + Vec3d wt_pos, wt_size; + plate->estimate_wipe_tower_polygon(m_print_config, index, wt_pos, wt_size); + if (wt_size(0) < EPSILON || wt_size(1) < EPSILON) + continue; + ConfigOptionFloat wt_x_opt((float) wt_pos(0)); + ConfigOptionFloat wt_y_opt((float) wt_pos(1)); + m_print_config.option("wipe_tower_x", true)->set_at(&wt_x_opt, index, 0); + m_print_config.option("wipe_tower_y", true)->set_at(&wt_y_opt, index, 0); + BOOST_LOG_TRIVIAL(info) << boost::format("plate %1%: wipe tower clamped to {%2%, %3%}, size {%4%, %5%}") + % (index + 1) % wt_pos(0) % wt_pos(1) % wt_size(0) % wt_size(1); + } + } + while(!finished) { //BBS: slice every partplate one by one