Share the prime-tower rule with the slicer instead of copying it

imex_wipe_tower_hull() open-coded "does this plate print a tower?" as one
filament and no forcing reason. normalize_fdm_2, which is what actually
clears enable_prime_tower before slicing, has a second arm the copy omitted,
and reads the mixed-filament flag project-wide where the copy read the
plate. Two cases went wrong in opposite directions.

A ByObject plate with several objects prints no tower and the scene draws
none, yet the copy validated one and could refuse the slice with "the prime
tower overlaps an area reserved for IDEX/IQEX parallel printing" - with
nothing on screen to move.

A plate using one plain slot while some other slot in the project is a blend
does print a tower, because normalize_fdm_2 keeps it for any mixed filament
in the project, and the copy skipped validation entirely, so the tower could
be placed in a carriage zone and sliced.

prime_tower_is_printed() in libslic3r states the rule once, and both the
gate and a test use it. The counts are the ones normalize_fdm_2 is handed:
filament slots as authored, so a mixed slot counts once, and distinct
objects rather than instances. filament_is_mixed is a project option, so it
is passed in rather than read from the print preset.

The test drives every combination the rule looks at and compares the verdict
against normalize_fdm_2 itself, so the two cannot drift again without
failing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Clifford Garwood
2026-09-18 01:41:42 -04:00
co-authored by Claude Opus 5
parent 0e44269c5e
commit b6a2c76e5d
4 changed files with 93 additions and 18 deletions
+22
View File
@@ -24,6 +24,28 @@ static const ConfigOption *option_of(const ConfigBase &config, const char *key)
return nullptr;
}
bool prime_tower_is_printed(const ConfigBase &config, int used_filaments, int num_objects, bool has_mixed_filament)
{
const auto *ept = config.option<ConfigOptionBool>("enable_prime_tower");
if (ept == nullptr || !ept->value)
return false;
// normalize_fdm_2 only reconsiders the option when the plate uses a filament; below that it
// leaves the user's choice alone, and there is no tower to reason about either way.
if (used_filaments <= 0)
return true;
const ConfigOption *timelapse = option_of(config, "timelapse_type");
const bool smooth_timelapse = timelapse != nullptr && timelapse->getInt() == int(TimelapseType::tlSmooth);
const auto *wrapping = config.option<ConfigOptionBool>("enable_wrapping_detection");
const bool enable_wrapping = wrapping != nullptr && wrapping->value;
if (smooth_timelapse || enable_wrapping)
return true;
const ConfigOption *sequence = option_of(config, "print_sequence");
const bool by_object = sequence != nullptr && sequence->getInt() == int(PrintSequence::ByObject);
return !((used_filaments == 1 && !has_mixed_filament) || (by_object && num_objects > 1));
}
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
+13
View File
@@ -44,4 +44,17 @@ WipeTowerFootprint estimate_wipe_tower_footprint(const ConfigBase
double layer_height,
double max_object_height);
// Whether a prime tower is PRINTED for a plate. The footprint estimate above answers how big a
// tower is and reports one for a single filament whenever the flush matrix purges, so it cannot
// answer this; the authority is DynamicPrintConfig::normalize_fdm_2(), which clears
// enable_prime_tower before the plate is sliced. This mirrors that rule so pre-slice consumers can
// ask it without mutating a config, and a test pins the two together.
//
// used_filaments: the plate's filament SLOTS as authored - a mixed slot counts once, matching
// Print::extruders(), which is what normalize_fdm_2 is handed.
// num_objects: objects printed on the plate; only ByObject sequencing looks at it.
// has_mixed_filament: filament_is_mixed is a PROJECT option, so it is passed rather than read -
// `config` only has to carry the print preset's own keys.
bool prime_tower_is_printed(const ConfigBase &config, int used_filaments, int num_objects, bool has_mixed_filament);
} // namespace Slic3r
+19 -18
View File
@@ -1118,27 +1118,28 @@ Polygon PartPlate::imex_wipe_tower_hull() const
return Polygon();
const DynamicPrintConfig& print_cfg = preset_bundle->prints.get_edited_preset().config;
const ConfigOptionBool* enable_opt = print_cfg.option<ConfigOptionBool>("enable_prime_tower");
if (!enable_opt || !enable_opt->value)
// Whether a tower is PRINTED is normalize_fdm_2's rule, shared with the slicer through
// prime_tower_is_printed() rather than re-derived here - re-deriving it is what let this
// validate a tower the slicer had already cancelled. Do not substitute the footprint
// estimate: it reports a tower for a single filament whenever the flush matrix purges
// (SEMM + purge_in_prime_tower, the Klipper default), which hard-blocks a plate with
// nothing drawn on screen to move.
//
// Counts are the ones normalize_fdm_2 is handed: filament slots as authored, so a mixed
// slot counts once, and distinct objects rather than instances. filament_is_mixed is a
// project option, hence read separately.
const auto* mixed_opt = preset_bundle->project_config.option<ConfigOptionBools>("filament_is_mixed");
const bool has_mixed = mixed_opt != nullptr && has_any_mixed_filament(mixed_opt->values);
std::set<int> plate_objects;
for (const auto& pr : obj_to_instance_set)
plate_objects.insert(pr.first);
if (!prime_tower_is_printed(print_cfg, (int) get_extruders(true, /*expand_mixed=*/false).size(),
(int) plate_objects.size(), has_mixed))
return Polygon();
// Approximates normalize_fdm_2(), which is what actually decides whether a tower is
// printed: it clears enable_prime_tower for one filament, or ByObject over several
// objects, unless smooth timelapse or wrapping detection forces one.
//
// Do not substitute the footprint estimate's answer. It reports a tower for a single
// filament whenever the flush matrix purges (SEMM + purge_in_prime_tower, the Klipper
// default), which would hard-block a plate whose tower normalize_fdm_2 already cleared -
// with nothing drawn on screen to move. See test_wipe_tower_estimate.cpp.
auto timelapse_type = print_cfg.option<ConfigOptionEnum<TimelapseType>>("timelapse_type");
bool need_wipe_tower = timelapse_type ? (timelapse_type->value == TimelapseType::tlSmooth) : false;
// enable_wrapping_detection is a PRINT option; read from the printer preset it returns
// nullptr and silently skips validation for a tower the user can see and drag.
if (auto wrapping_opt = print_cfg.option<ConfigOptionBool>("enable_wrapping_detection"))
need_wipe_tower |= wrapping_opt->value;
// The estimate's floor counts what is purged, so a mixed slot counts as its components here.
const int plate_extruder_size = (int) get_extruders(true).size();
if (!need_wipe_tower && plate_extruder_size < 2)
return Polygon();
Vec3d wt_pos, wt_size;
// full_config(), not the print preset: wipe_tower_x/y are project options the estimate
@@ -6,6 +6,7 @@
#include "libslic3r/GCode/WipeTower2.hpp"
#include "libslic3r/GCode/WipeTowerEstimate.hpp"
#include "libslic3r/PrintConfig.hpp"
#include "libslic3r/FilamentMixer.hpp"
#include <cmath>
#include <numeric>
@@ -381,3 +382,41 @@ TEST_CASE("A config missing a tower key falls back to that key's default", "[Wip
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));
}
TEST_CASE("prime_tower_is_printed answers exactly what normalize_fdm_2 decides", "[WipeTowerEstimate]") {
// The estimate says how big a tower is; this says whether there is one, and the authority is
// normalize_fdm_2, which clears enable_prime_tower before the plate is sliced. Pre-slice
// consumers cannot call it (it mutates a config), so the rule is mirrored - and mirrored rules
// drift, which is what this pins. Every combination the rule looks at, both verdicts compared.
const int used_filaments = GENERATE(1, 2, 3);
const bool has_mixed = GENERATE(false, true);
const int num_objects = GENERATE(1, 2);
const bool by_object = GENERATE(false, true);
const bool smooth = GENERATE(false, true);
const bool wrapping = GENERATE(false, true);
DynamicPrintConfig config = preset_shaped_defaults();
config.set_key_value("enable_prime_tower", new ConfigOptionBool(true));
config.set_deserialize_strict("print_sequence", by_object ? "by object" : "by layer");
config.set_deserialize_strict("timelapse_type", smooth ? "1" : "0");
config.set_key_value("enable_wrapping_detection", new ConfigOptionBool(wrapping));
config.set_key_value("filament_is_mixed", new ConfigOptionBools(
has_mixed ? std::vector<unsigned char>{0, 1} : std::vector<unsigned char>{0, 0}));
REQUIRE(has_any_mixed_filament(config.option<ConfigOptionBools>("filament_is_mixed")->values) == has_mixed);
DynamicPrintConfig normalized = config;
normalized.normalize_fdm_2(num_objects, used_filaments);
const bool slicer_prints_one = normalized.opt_bool("enable_prime_tower");
CHECK(prime_tower_is_printed(config, used_filaments, num_objects, has_mixed) == slicer_prints_one);
}
TEST_CASE("prime_tower_is_printed follows the option the user set", "[WipeTowerEstimate]") {
DynamicPrintConfig config = preset_shaped_defaults();
config.set_key_value("enable_prime_tower", new ConfigOptionBool(false));
CHECK_FALSE(prime_tower_is_printed(config, 2, 1, false));
// Below one filament normalize_fdm_2 leaves the option alone, so this does too.
config.set_key_value("enable_prime_tower", new ConfigOptionBool(true));
CHECK(prime_tower_is_printed(config, 0, 1, false));
}