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