Extract and Unify Wipe Tower Estimation

This commit is contained in:
Hanif Koh
2026-09-04 17:15:19 +08:00
committed by HanifKoh
parent 8a291f9d56
commit 8df5e5e738
13 changed files with 589 additions and 227 deletions

View File

@@ -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

View File

@@ -0,0 +1,206 @@
#include <catch2/catch_all.hpp>
#include "libslic3r/GCode/WipeTower.hpp"
#include "libslic3r/GCode/WipeTower2.hpp"
#include "libslic3r/GCode/WipeTowerEstimate.hpp"
#include "libslic3r/PrintConfig.hpp"
#include <cmath>
#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("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<T>.
// 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<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);
// 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));
}