Files
OrcaSlicer/tests/libslic3r/test_wipe_tower_estimate.cpp
T
Hanif Koh e1efec7d6c 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.
2026-09-09 15:45:16 +08:00

220 lines
13 KiB
C++

#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.);
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.).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));
}
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));
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));
}
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));
// 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.).depth, WithinAbs(20., 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));
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));
}
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]") {
// 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).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).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).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.);
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.).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.).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.);
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.);
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.).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]") {
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.).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.).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.).depth,
WithinAbs(estimate_wipe_tower_footprint(defaulted, 3, 0.2, 5.).depth, 1e-9));
}