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.
This commit is contained in:
Hanif Koh
2026-09-09 13:13:09 +08:00
parent e2645db983
commit c5f6e3559c
9 changed files with 154 additions and 76 deletions
+64 -3
View File
@@ -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"));
}
+44 -31
View File
@@ -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));
}