Merge main

This commit is contained in:
Lam Wei Lun
2026-09-09 19:12:23 +08:00
33 changed files with 1722 additions and 342 deletions
+3
View File
@@ -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;
}
+67 -2
View File
@@ -1,5 +1,7 @@
#include <catch2/catch_all.hpp>
#include <algorithm>
#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<Slic3r::ConfigBase::SetDeserializeItem> 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<Slic3r::ConfigBase::SetDeserializeItem> 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 },
});
}
+147
View File
@@ -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 },
});
@@ -182,3 +184,148 @@ 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. 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(filaments, {
{ "outer_wall_filament_id", filaments == 2 ? "2" : "1" },
{ "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" },
{ "enable_wrapping_detection", "0" },
{ "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<std::vector<ConfigBase::SetDeserializeItem>> 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("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]")
{
// 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", 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));
}
// 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", 1);
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", 1);
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", 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" } });
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"));
}
+3
View File
@@ -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
@@ -39,6 +40,8 @@ add_executable(${_TEST_NAME}_tests
test_utils.cpp
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
@@ -0,0 +1,66 @@
#include <catch2/catch_all.hpp>
#include <algorithm>
#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<Point> lattice()
{
std::vector<Point> 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<Point> sorted_neighbours(const MinimumSpanningTree &mst, const Point &vertex)
{
std::vector<Point> 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<Point> 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<Point> 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<Point> 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<std::vector<Point>> 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<Point> &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));
}
}
}
@@ -8,6 +8,8 @@
#include "libslic3r/AppConfig.hpp"
#include "libslic3r/Model.hpp"
#include "libslic3r/TriangleMesh.hpp"
#include "libslic3r/Utils.hpp"
#include "libslic3r/miniz_extension.hpp"
#include "test_utils.hpp"
@@ -5104,3 +5106,91 @@ TEST_CASE("Published 3MF denylist and mixed-key sets match the import/export con
for (const std::string &key : mixed)
CHECK(structural.count(key) == 0);
}
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<char>(in), std::istreambuf_iterator<char>());
}
void write_zip(const fs::path &zip_file, const std::vector<std::pair<std::string, std::string>> &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<std::string> 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 (<datadir>/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"));
}
}
+93
View File
@@ -0,0 +1,93 @@
#include <catch2/catch_all.hpp>
#include <cmath>
#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<WipeTower::PurgeEstimate> 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<WipeTower::PurgeEstimate> 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));
}
@@ -0,0 +1,351 @@
#include <catch2/catch_all.hpp>
#include "libslic3r/BoundingBox.hpp"
#include "libslic3r/ClipperUtils.hpp"
#include "libslic3r/GCode/WipeTower.hpp"
#include "libslic3r/GCode/WipeTower2.hpp"
#include "libslic3r/GCode/WipeTowerEstimate.hpp"
#include "libslic3r/PrintConfig.hpp"
#include <cmath>
#include <numeric>
#include <string>
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<T>. 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("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));
config.set_key_value("purge_in_prime_tower", new ConfigOptionBool(false));
config.set_key_value("single_extruder_multi_material", new ConfigOptionBool(false));
return config;
}
static std::vector<unsigned int> filaments(size_t count)
{
std::vector<unsigned int> 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(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(printed_brim(3., WipeTowerType::Type2), 1e-6));
// Thinner layers need more depth for the same volume.
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<WipeTower::PurgeEstimate> 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(config, 2, 0.2, 100.).depth, WithinAbs(20., 1e-9));
config.set_key_value("prime_tower_brim_width", new ConfigOptionFloat(-1.));
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(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: the
// Type1 planner's fixed 10 mm, the stability floor otherwise.
config.set_key_value("enable_wrapping_detection", new ConfigOptionBool(true));
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(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");
// 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(WipeTower::get_limit_depth_by_height(5.f), 1e-9));
}
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, 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, 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]") {
// 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(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(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(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(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 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(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(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<T>.
// 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);
preset.set_deserialize_strict("wipe_tower_type", tower_type);
REQUIRE(dynamic_cast<const ConfigOptionEnumGeneric *>(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);
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);
// 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));
// 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(preset, 1, 0.2, 5., type).depth > 0.);
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<T> 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<const ConfigOptionEnumGeneric *>(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");
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]") {
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(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]") {
// 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(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("wipe_tower_extra_spacing");
REQUIRE(partial.option("wipe_tower_extra_spacing") == nullptr);
DynamicPrintConfig defaulted = full;
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));
}