Bracket a toolchange's own gcode against FanMover splitting its moves (#15904)

FanMover could split a move inside change_filament_gcode to place a delayed fan command. It computed the split point from the last position the G-code set, but on Bambu printers the firmware moves the head during the T command, so the inserted waypoint sent the head to a spot the template never visits (#15789).

Both toolchange paths, set_extruder() and the Type1 append_tcr(), now wrap the template output in ;_FAN_MOVER_SKIP_START/END tags when fan speed-up or kick-start is on. FanMover keeps fan commands inside them where they are and drops the tags from the output. multifilament_config() also sizes the per-filament options that the wipe tower reads by index.
This commit is contained in:
Fernando Marino`
2026-10-12 14:26:20 -05:00
committed by GitHub
parent 3ca182a98e
commit 1494de66de
4 changed files with 96 additions and 2 deletions
+14 -1
View File
@@ -1033,6 +1033,15 @@ static std::vector<Vec2d> get_path_of_change_filament(const Print& print)
return buf;
}
// FanMover consumes these markers (and drops them) to avoid splitting a G1 inside custom gcode.
// Only written when FanMover runs, the same gate as its construction in GCode::process_layers.
static std::string wrap_custom_gcode_for_fan_mover(const FullPrintConfig &config, const std::string &gcode)
{
if (config.fan_speedup_time.value == 0 && config.fan_kickstart.value <= 0)
return gcode;
return ";_FAN_MOVER_SKIP_START\n" + gcode + ";_FAN_MOVER_SKIP_END\n";
}
std::string WipeTowerIntegration::append_tcr(GCode& gcodegen, const WipeTower::ToolChangeResult& tcr, int new_filament_id, double z) const
{
if (new_filament_id != -1 && new_filament_id != tcr.new_tool)
@@ -1384,6 +1393,9 @@ static std::vector<Vec2d> get_path_of_change_filament(const Print& print)
toolchange_gcode_str = gcodegen.placeholder_parser_process("change_filament_gcode", change_filament_gcode, new_filament_id, &config);
check_add_eol(toolchange_gcode_str);
// Type1 wipe tower path (every BBL printer): bracket the same way set_extruder() does,
// so FanMover doesn't split a G1 inside this custom gcode either.
toolchange_gcode_str = wrap_custom_gcode_for_fan_mover(gcodegen.config(), toolchange_gcode_str);
//BBS
{
@@ -10895,7 +10907,8 @@ std::string GCode::set_extruder(unsigned int new_filament_id, double print_z, bo
toolchange_gcode_parsed = placeholder_parser_process("change_filament_gcode", change_filament_gcode, new_filament_id, &dyn_config);
check_add_eol(toolchange_gcode_parsed);
gcode += toolchange_gcode_parsed;
// toolchange_gcode_parsed is still read below (get_last_z_from_gcode, custom_gcode_changes_tool).
gcode += wrap_custom_gcode_for_fan_mover(m_config, toolchange_gcode_parsed);
//BBS
{
+5
View File
@@ -445,6 +445,11 @@ void FanMover::_process_gcode_line(GCodeReader& reader, const GCodeReader::GCode
std::string extrusion_string = line.raw().substr(6, line.raw().size() - 6);
current_role = ExtrusionEntity::string_to_role(extrusion_string);
}
if (line.raw().rfind(";_FAN_MOVER_SKIP_", 0) == 0) {
// Internal markers: read here, then dropped from the output.
m_is_custom_gcode = line.raw().rfind(";_FAN_MOVER_SKIP_START", 0) == 0;
time = -1;
}
if (line.raw().size() > 16) {
if (line.raw().rfind("; custom gcode", 0) != std::string::npos) {
if (line.raw().rfind("; custom gcode end", 0) != std::string::npos)
+4 -1
View File
@@ -223,7 +223,10 @@ DynamicPrintConfig multifilament_config(unsigned int filaments, std::initializer
// These are read by filament id during export but absent from filament_option_keys(),
// so set_num_filaments leaves them size 1; size them too, else out-of-range access.
const auto &defaults = FullPrintConfig::defaults();
for (const char *key : { "filament_type", "filament_vendor", "filament_start_gcode" })
for (const char *key : { "filament_type", "filament_vendor", "filament_start_gcode", "filament_change_length", "filament_change_length_nc",
"filament_ramming_travel_time", "filament_ramming_travel_time_nc",
"filament_ramming_volumetric_speed", "filament_ramming_volumetric_speed_nc",
"nozzle_temperature", "nozzle_temperature_initial_layer" })
static_cast<ConfigOptionVectorBase *>(config.option(key, true))->resize(filaments, defaults.option(key));
// flush_volumes_matrix must be sized filaments*filaments or export rejects it.
+73
View File
@@ -315,6 +315,79 @@ TEST_CASE("Per-object wall filament override is honored", "[MultiFilament]")
CHECK(tools_for_role(gcode, "infill") == std::set<int>{ 0 }); // infill not overridden: stays on F1
}
// FanMover's guard against splitting a G1 inside custom gcode keys off the ";_FAN_MOVER_SKIP_START/END"
// markers GCode::set_extruder() must emit around change_filament_gcode's output. Direct
// toolchanges and Type2's wipe-tower toolchange (append_tcr2() calls set_extruder()) both go
// through it; Type1's wipe-tower toolchange (WipeTowerIntegration::append_tcr(), append_tcr2's only
// sibling caller of change_filament_gcode) needs the same bracketing of its own.
TEST_CASE("Toolchange gcode is bracketed against FanMover splitting its moves", "[MultiFilament]")
{
const std::string custom_gcode = "; fan full\nM106 P1 S255\nM400 S3\nG1 X77 F5000\nG1 X91 F3000\n";
// "direct": no prime tower, GCode::set_extruder() runs the toolchange. By-object sequencing
// with a per-object filament override is what makes one happen at all without a tower.
// "type1": a Type1 prime tower routes the toolchange through WipeTowerIntegration::append_tcr()
// instead (append_tcr2, Type2's equivalent, itself calls set_extruder() - so Type2 needs no
// separate case here).
const std::string path = GENERATE(as<std::string>{}, "direct", "type1");
CAPTURE(path);
std::string gcode_str;
if (path == "direct") {
const std::vector<std::vector<Slic3r::ConfigBase::SetDeserializeItem>> per_object_overrides = {
{}, { { "outer_wall_filament_id", "2" }, { "inner_wall_filament_id", "2" } }
};
DynamicPrintConfig config = multifilament_config(2, {
{ "skirt_loops", 0 },
{ "brim_type", "no_brim" },
{ "print_sequence", "by object" },
{ "change_filament_gcode", custom_gcode },
{ "fan_speedup_time", 0.5 },
{ "fan_kickstart", 0.1 },
});
gcode_str = slice_with_object_overrides({ cube(20), cube(20) }, config, per_object_overrides);
} else {
const std::vector<std::vector<Slic3r::ConfigBase::SetDeserializeItem>> per_object_overrides = {
{ { "extruder", "1" } }, { { "extruder", "2" } }
};
DynamicPrintConfig config = multifilament_config(2, {
{ "change_filament_gcode", custom_gcode },
{ "fan_speedup_time", 0.5 },
{ "fan_kickstart", 0.1 },
{ "enable_prime_tower", true },
{ "wipe_tower_type", "type1" },
{ "prime_tower_width", 35 },
{ "wipe_tower_x", 120 },
{ "wipe_tower_y", 120 },
{ "printable_area", "0x0,200x0,200x200,0x200" },
{ "skirt_loops", 0 },
{ "brim_type", "no_brim" },
});
gcode_str = slice_with_object_overrides({ cube(20), cube(20) }, config, per_object_overrides);
}
// The resolved-settings config footer echoes "change_filament_gcode = ..." with its value
// escaped, so drop it before counting or its own copy of "G1 X77 F5000" is miscounted.
const size_t footer_start = gcode_str.find("; CONFIG_BLOCK_START");
REQUIRE(footer_start != std::string::npos);
gcode_str.erase(footer_start);
auto count_occurrences = [](const std::string &haystack, const std::string &needle) {
size_t count = 0, pos = 0;
while ((pos = haystack.find(needle, pos)) != std::string::npos) {
++count;
pos += needle.size();
}
return count;
};
const size_t total_waypoints = count_occurrences(gcode_str, "G1 X77 F5000");
const size_t bracketed = count_occurrences(gcode_str, "M400 S3\nG1 X77 F5000");
REQUIRE(total_waypoints > 0);
CHECK(bracketed == total_waypoints);
// FanMover drops its own markers; only the start-of-print toolchange, which it never processes, keeps them.
CHECK(count_occurrences(gcode_str, ";_FAN_MOVER_SKIP_START") <= 1);
}
// With wait_for_temp_on_wipe_tower the blocking M109 moves from right after the Tn command to
// a stop point parked beside the wipe tower (heat-up drool falls next to the tower, not onto
// its top): tagged with _WAIT_FOR_TEMP_ON_WIPE_TOWER, after the toolchange and before the