Files
OrcaSlicer/tests/fff_print/test_multifilament.cpp
SoftFever 3b445905d1 Fix out-of-bounds reads in the multi-extruder flush-volume matrix
flush_volumes_matrix stores one (filaments x filaments) block per nozzle,
but set_extruder, WipeTower2::extract_wipe_volumes and the PresetBundle
rebuild indexed each block with filament_colour.size() as the row stride.
When the stored matrix does not match that assumption (a legacy project, or
a just-switched printer -- e.g. a single 2x2 block while nozzle_diameter has
2 entries) the index runs past the sliced block and reads out of bounds.

The read is undefined, so its value depends on the C++ std-lib/allocator
layout: identical across Linux architectures but different on macOS/Windows.
That surfaced as the "Toolchange temperature commands are unchanged when the
wipe tower wait is off" regression failing on Linux CI while passing on
macOS/Windows, and crashing hardened (-O0, _GLIBCXX_ASSERTIONS) builds.

- Centralize the block-dimension derivation in get_flush_volumes_matrix_dims
  (sqrt(size / nozzles) with a filaments^2 * nozzles == size check and a
  single-block fallback) and use it wherever the matrix is sliced/indexed;
  bounds-guard the reads as defense in depth. It weighs both options that
  claim to say how many blocks are stored, since either can be stale:
  flush_multiplier, written with the matrix in the project config, and
  nozzle_diameter, which changes the moment a printer is selected.
- PresetBundle::update_multi_material_filament_presets: rebuild the matrix on
  a nozzle-count-only change too (the old per-block gate missed those), and
  seed a brand-new nozzle from the first nozzle's tuned block.
- is_flush_config_modified: stride the stored matrix by its own dimension
  rather than the current filament count, and bound the nozzle loop by the
  printer's extruder count, which CalcFlushingVolumes indexes as well.
- is_flushing_matrix_error: the same derivation, which additionally divided
  by zero on an empty flush_multiplier.
- update_slice_warnings: guard nozzle_hrc_lists, which is sized by the
  nullable nozzle_type option and can be shorter than the extruder count.

With the read fixed the emitted trace is deterministic across builds and
platforms, so regenerate the golden from it and stop comparing the rounded
"time: <n>s" preheat comment (the tolerant lead time already carries that
timing). Add unit tests for the flush-matrix rebuild and dimension logic.
2026-08-07 14:21:26 +08:00

664 lines
35 KiB
C++

#include <catch2/catch_all.hpp>
#include "libslic3r/GCode/GCodeProcessor.hpp"
#include "libslic3r/GCodeReader.hpp"
#include "test_helpers.hpp"
#include "test_utils.hpp"
#include <algorithm>
#include <cctype>
#include <cmath>
#include <cstdio>
#include <cstdlib>
#include <fstream>
#include <limits>
#include <optional>
#include <set>
#include <sstream>
#include <string>
#include <utility>
#include <vector>
using namespace Slic3r;
using namespace Slic3r::Test;
// 0-based tool indices used by extrusions whose role comment contains `role` (needs gcode_comments).
static std::set<int> tools_for_role(const std::string& gcode, const std::string& role)
{
std::set<int> tools;
int current_tool = 0;
GCodeReader reader;
reader.parse_buffer(gcode, [&](GCodeReader& self, const GCodeReader::GCodeLine& line) {
const std::string cmd(line.cmd());
if (cmd.size() >= 2 && cmd[0] == 'T' && std::isdigit((unsigned char)cmd[1]))
current_tool = std::stoi(cmd.substr(1));
else if (line.extruding(self) && std::string(line.comment()).find(role) != std::string::npos)
tools.insert(current_tool);
});
return tools;
}
// X where the nozzle sits while each tagged _WAIT_FOR_TEMP_ON_WIPE_TOWER M109 blocks:
// the nearest preceding G1 carrying an X (the park travel emitted just before the wait).
static std::vector<double> wait_park_xs(const std::string& gcode)
{
std::vector<std::string> lines;
std::istringstream stream(gcode);
for (std::string line; std::getline(stream, line);)
lines.emplace_back(std::move(line));
std::vector<double> xs;
for (size_t i = 0; i < lines.size(); ++i) {
if (lines[i].rfind("M109", 0) != 0 || lines[i].find("_WAIT_FOR_TEMP_ON_WIPE_TOWER") == std::string::npos)
continue;
for (size_t j = i; j-- > 0;) {
if (lines[j].rfind("G1 ", 0) != 0)
continue;
const size_t x_pos = lines[j].find('X');
if (x_pos == std::string::npos)
continue;
xs.push_back(std::stod(lines[j].substr(x_pos + 1)));
break;
}
}
return xs;
}
// Estimated print time at each 1-based line of an exported G-code file, from a second
// GCodeProcessor pass over it. MoveVertex::time is the duration of one move and gcode_id is the
// line it came from (already rebased past the M73 insertions), so the running sum before the first
// move of a line is the elapsed time at that line. The file carries its own config footer, so
// process_file configures the processor -- including the shared s_IsBBLPrinter static that other
// tests in this binary mutate -- from the settings the export itself used.
static std::vector<double> elapsed_time_by_line(const std::string& gcode)
{
ScopedTemporaryFile temp_gcode(".gcode");
{
std::ofstream os(temp_gcode.string());
os << gcode;
}
GCodeProcessor processor;
processor.process_file(temp_gcode.string());
constexpr size_t NORMAL = size_t(PrintEstimatedStatistics::ETimeMode::Normal);
const size_t n_lines = size_t(std::count(gcode.begin(), gcode.end(), '\n')) + 2;
std::vector<double> elapsed(n_lines, 0.);
double running = 0.;
size_t next = 0;
for (const auto& move : processor.get_result().moves) {
const size_t id = std::min<size_t>(move.gcode_id, n_lines - 1);
while (next <= id)
elapsed[next++] = running;
running += move.time[NORMAL];
}
while (next < n_lines)
elapsed[next++] = running;
return elapsed;
}
// The temperature-relevant projection of `gcode`: every M104/M109/Tn line, plus the toolchange and
// priming markers that anchor them, in order. A preheat -- an M104 the GCodeProcessor backtrace
// inserts mid-object, outside any block, naming a tool other than the one currently loaded -- also
// carries "lead <n>s", the estimated time from there to the tool change it heats for, which is the
// property preheat_time controls. No other temperature command gets one: for an M104 retargeting
// the active tool (the first-layer-to-other-layers bump) or one inside a block, the distance to the
// next Tn is a layer time or a handful of moves and says nothing about preheat_time. Everything
// else is dropped, so the trace does not move when travel, tower geometry or line numbering do.
static std::vector<std::string> temperature_trace(const std::string& gcode)
{
std::vector<std::string> lines;
std::istringstream stream(gcode);
for (std::string line; std::getline(stream, line);) {
line.erase(0, line.find_first_not_of(" \t"));
while (!line.empty() && (line.back() == '\r' || line.back() == ' ' || line.back() == '\t'))
line.pop_back();
lines.emplace_back(std::move(line));
}
const std::vector<double> elapsed = elapsed_time_by_line(gcode);
const auto is_tool = [](const std::string& l) { return l.size() >= 2 && l[0] == 'T' && std::isdigit((unsigned char) l[1]); };
const auto is_temp = [](const std::string& l) { return l.rfind("M104", 0) == 0 || l.rfind("M109", 0) == 0; };
const auto marker = [](const std::string& l) -> const char* {
for (const char* m : { "; CP TOOLCHANGE START", "; CP TOOLCHANGE END", "; CP PRIMING START", "; CP PRIMING END" })
if (l.find(m) != std::string::npos)
return m;
return nullptr;
};
// Tool a "T<n>" line, or the "T<n>" argument of an M104, names -- or -1 when it names none.
const auto tool_of = [&is_tool](const std::string& l) -> int {
size_t t = std::string::npos; // index of the 'T'
if (is_tool(l))
t = 0;
else if (l.rfind("M104", 0) == 0 && l.find(" T") != std::string::npos)
t = l.find(" T") + 1;
if (t == std::string::npos || t + 1 >= l.size() || !std::isdigit((unsigned char) l[t + 1]))
return -1;
return std::stoi(l.substr(t + 1));
};
std::vector<std::string> trace;
bool in_block = false;
int current_tool = -1;
for (size_t i = 0; i < lines.size(); ++i) {
if (const char* m = marker(lines[i])) {
in_block = std::string(m).find("START") != std::string::npos;
trace.emplace_back(m); // the marker alone: some carry a trailing tool id, some do not
} else if (is_tool(lines[i]) || is_temp(lines[i])) {
std::string entry = lines[i];
const int named = tool_of(lines[i]);
if (!in_block && lines[i].rfind("M104", 0) == 0 && current_tool != -1 && named != -1 && named != current_tool) {
size_t tn = i;
while (tn < lines.size() && !is_tool(lines[tn]))
++tn;
if (tn < lines.size()) {
char lead[32];
std::snprintf(lead, sizeof(lead), "\tlead %.1fs", elapsed[tn + 1] - elapsed[i + 1]);
entry += lead;
}
}
if (is_tool(lines[i]))
current_tool = named;
trace.emplace_back(std::move(entry));
}
}
return trace;
}
// Splits a trace entry into its command text and the lead time appended after a tab, if any.
static std::pair<std::string, std::optional<double>> split_lead(const std::string& entry)
{
const size_t tab = entry.find('\t');
if (tab == std::string::npos)
return { entry, std::nullopt };
const std::string tail = entry.substr(tab + 1); // "lead 30.2s"
return { entry.substr(0, tab), std::stod(tail.substr(tail.find(' ') + 1)) };
}
// The "time: <n>s" a preheat comment carries is round()'d from the same estimate the lead measures,
// so it sits on a rounding boundary and flips (e.g. 30<->31) across platforms and build optimisation
// levels; drop it from the command text so only the tolerant lead below carries that timing.
static std::string strip_preheat_time(std::string command)
{
const size_t pos = command.find(" time: ");
if (pos != std::string::npos)
command.erase(pos);
return command;
}
// Same command, and a lead time within half a second. The lead is an estimate summed over every
// move before it, so it drifts slightly with unrelated changes to travel or tower geometry; half a
// second is far below the tens of seconds a preheat leaving its backtrace position would shift it.
static bool trace_entries_match(const std::string& a, const std::string& b)
{
const auto x = split_lead(a);
const auto y = split_lead(b);
if (strip_preheat_time(x.first) != strip_preheat_time(y.first))
return false;
if (x.second.has_value() != y.second.has_value())
return false;
return !x.second.has_value() || std::abs(*x.second - *y.second) <= 0.5;
}
// Tool index = filament id - 1; brim and skirt follow the wall filament.
TEST_CASE("Each feature prints with its assigned filament", "[MultiFilament]")
{
auto [infill_filament, wall_filament] = GENERATE(table<int, int>({ {1, 1}, {1, 2}, {2, 1}, {2, 2} }));
DYNAMIC_SECTION("infill filament " << infill_filament << ", wall filament " << wall_filament) {
const std::string gcode = slice({ cube(20) },
multifilament_config(2, {
{ "sparse_infill_filament_id", infill_filament },
{ "internal_solid_filament_id", infill_filament },
{ "top_surface_filament_id", infill_filament },
{ "bottom_surface_filament_id", infill_filament },
{ "outer_wall_filament_id", wall_filament },
{ "inner_wall_filament_id", wall_filament },
{ "skirt_loops", 1 },
{ "brim_type", "outer_only" },
{ "brim_width", 5 },
}));
const std::set<int> wall_tool{ wall_filament - 1 };
const std::set<int> infill_tool{ infill_filament - 1 };
CHECK(tools_for_role(gcode, "perimeter") == wall_tool);
CHECK(tools_for_role(gcode, "infill") == infill_tool); // sparse + solid + top/bottom
CHECK(tools_for_role(gcode, "brim") == wall_tool);
CHECK(tools_for_role(gcode, "skirt") == wall_tool);
}
}
TEST_CASE("Each feature prints with its assigned filament (three filaments)", "[MultiFilament]")
{
const std::string gcode = slice({ cube(20) },
multifilament_config(3, {
{ "sparse_infill_filament_id", 2 },
{ "internal_solid_filament_id", 2 },
{ "top_surface_filament_id", 2 },
{ "bottom_surface_filament_id", 2 },
{ "outer_wall_filament_id", 3 },
{ "inner_wall_filament_id", 3 },
{ "skirt_loops", 0 },
{ "brim_type", "no_brim" },
}));
CHECK(tools_for_role(gcode, "perimeter") == std::set<int>{ 2 }); // filament 3
CHECK(tools_for_role(gcode, "infill") == std::set<int>{ 1 }); // filament 2
}
// The override must survive tool ordering: object 1's walls print on their filament's
// tool, object 0 stays on the first. If dropped, every wall prints on tool 0.
TEST_CASE("Per-object wall filament override is honored", "[MultiFilament]")
{
const std::string gcode = slice_with_object_overrides(
{ cube(20), cube(20) },
multifilament_config(2, {
{ "skirt_loops", 0 },
{ "brim_type", "no_brim" },
{ "print_sequence", "by object" },
}),
{ {}, { { "outer_wall_filament_id", 2 }, { "inner_wall_filament_id", 2 } } });
CHECK(tools_for_role(gcode, "perimeter") == std::set<int>{ 0, 1 });
CHECK(tools_for_role(gcode, "infill") == std::set<int>{ 0 }); // infill not overridden: stays on F1
}
// 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
// repositioning move and the first extrusion of the purge. The restore that used to block there
// demotes to a non-blocking M104 and moves ahead of the Tn, so the incoming tool heats up over
// the change itself. Ordering and the off-tower stop are the contract here.
TEST_CASE("Toolchange temperature wait moves to the wipe tower when enabled", "[MultiFilament]")
{
const bool wait_on_tower = GENERATE(false, true);
DYNAMIC_SECTION("wait_for_temp_on_wipe_tower " << (wait_on_tower ? 1 : 0)) {
const std::string gcode = slice_with_object_overrides(
{ cube(20), cube(20) },
multifilament_config(2, {
{ "nozzle_diameter", "0.4,0.4" },
{ "printer_extruder_id", "1,2" },
{ "printer_extruder_variant", "Direct Drive Standard,Direct Drive Standard" },
{ "extruder_printable_height", "0,0" },
{ "single_extruder_multi_material", 0 },
{ "enable_prime_tower", 1 },
{ "prime_tower_width", 35 },
{ "wipe_tower_x", "50" },
{ "wipe_tower_y", "50" },
{ "ooze_prevention", 1 },
{ "standby_temperature_delta", -40 },
// The post-processor's own preheat pass also inserts an M104 for the incoming
// filament ahead of the Tn; switch it off so the temperature commands under test
// are the only ones in the toolchange block.
{ "preheat_time", 0 },
{ "wait_for_temp_on_wipe_tower", wait_on_tower ? 1 : 0 },
}),
// One filament per object -> a toolchange on every layer. Assigned at the object
// level: the used-filament count that gates the prime tower is derived from
// object/volume configs on the harness's single apply (region filament ids such
// as sparse_infill_filament_id are not counted there and the tower would be
// silently disabled).
{ { { "extruder", 1 } }, { { "extruder", 2 } } });
// Split into lines and scan the "; CP TOOLCHANGE START".."; CP TOOLCHANGE END" blocks.
std::vector<std::string> lines;
std::istringstream gcode_stream(gcode);
for (std::string line; std::getline(gcode_stream, line);)
lines.emplace_back(std::move(line));
const auto is_tool_line = [](const std::string& l) { return l.size() >= 2 && l[0] == 'T' && std::isdigit((unsigned char)l[1]); };
const auto is_m109_line = [](const std::string& l) { return l.rfind("M109", 0) == 0; };
// A non-blocking set-temperature naming one specific tool, e.g. "M104 S255 T1".
const auto is_m104_for_tool = [](const std::string& l, int tool) {
if (l.rfind("M104", 0) != 0)
return false;
const std::string token = " T" + std::to_string(tool);
const size_t at = l.find(token);
return at != std::string::npos && !std::isdigit((unsigned char)l[at + token.size()]);
};
const auto is_tagged_wait = [](const std::string& l) { return l.find("_WAIT_FOR_TEMP_ON_WIPE_TOWER") != std::string::npos; };
const auto is_extruding = [](const std::string& l) {
if (l.rfind("G1 ", 0) != 0)
return false;
const size_t e = l.find(" E");
return e != std::string::npos && l.find_first_of("XY") != std::string::npos && l[e + 2] != '-';
};
int checked_blocks = 0;
for (size_t i = 0; i < lines.size(); ++i) {
if (lines[i].find("; CP TOOLCHANGE START") == std::string::npos)
continue;
size_t block_end = i;
while (block_end < lines.size() && lines[block_end].find("; CP TOOLCHANGE END") == std::string::npos)
++block_end;
size_t tool_line = block_end;
for (size_t j = i; j < block_end; ++j)
if (is_tool_line(lines[j])) { tool_line = j; break; }
if (tool_line == block_end)
continue; // final unload block, no toolchange
++checked_blocks;
// Where the incoming tool's target temperature is raised, relative to its Tn.
const int new_tool = std::stoi(lines[tool_line].substr(1));
size_t preheat = tool_line, restore = block_end;
for (size_t j = i; j < tool_line; ++j)
if (is_m104_for_tool(lines[j], new_tool)) { preheat = j; break; }
for (size_t j = tool_line + 1; j < block_end; ++j)
if (is_m104_for_tool(lines[j], new_tool)) { restore = j; break; }
size_t tagged_wait = block_end, untagged_m109 = block_end, first_extrusion = block_end;
for (size_t j = tool_line + 1; j < block_end; ++j) {
if (is_m109_line(lines[j]) && tagged_wait == block_end && is_tagged_wait(lines[j]))
tagged_wait = j;
if (is_m109_line(lines[j]) && untagged_m109 == block_end && !is_tagged_wait(lines[j]))
untagged_m109 = j;
if (first_extrusion == block_end && is_extruding(lines[j]))
first_extrusion = j;
}
INFO("toolchange block at line " << i + 1);
if (wait_on_tower) {
// The only blocking wait is the tagged one, parked beside the tower before the purge.
REQUIRE(tagged_wait < block_end);
CHECK(untagged_m109 == block_end);
// The target is raised ahead of the toolchange, so the incoming tool heats up
// while it is picked up, and nothing sets it again afterwards.
CHECK(preheat < tool_line);
CHECK(restore == block_end);
REQUIRE(first_extrusion < block_end);
CHECK(tagged_wait < first_extrusion);
// The travel preceding the wait parks outside the tower footprint. The tower
// auto-sizes, so derive its extent from the purge extrusions of this block.
size_t stop_line = block_end;
for (size_t j = tagged_wait; j-- > tool_line;)
if (lines[j].rfind("G1 ", 0) == 0 && lines[j].find('X') != std::string::npos) { stop_line = j; break; }
REQUIRE(stop_line < block_end);
const double stop_x = std::stod(lines[stop_line].substr(lines[stop_line].find('X') + 1));
double purge_min_x = std::numeric_limits<double>::max(), purge_max_x = std::numeric_limits<double>::lowest();
for (size_t j = tagged_wait; j < block_end; ++j) {
const size_t x_pos = lines[j].find('X');
if (!is_extruding(lines[j]) || x_pos == std::string::npos)
continue;
const double x = std::stod(lines[j].substr(x_pos + 1));
purge_min_x = std::min(purge_min_x, x);
purge_max_x = std::max(purge_max_x, x);
}
REQUIRE(purge_min_x <= purge_max_x);
INFO("stop travel: " << lines[stop_line] << " purge x range: " << purge_min_x << ".." << purge_max_x);
const bool beside_tower = stop_x < purge_min_x - 0.5 || stop_x > purge_max_x + 0.5;
CHECK(beside_tower);
} else {
// Stock behavior: the blocking wait follows the toolchange command directly, and
// nothing raises the incoming tool's target before it.
REQUIRE(untagged_m109 < block_end);
CHECK(tagged_wait == block_end);
CHECK(preheat == tool_line);
if (first_extrusion < block_end)
CHECK(untagged_m109 < first_extrusion);
}
i = block_end;
}
REQUIRE(checked_blocks > 0);
if (!wait_on_tower)
CHECK(gcode.find("_WAIT_FOR_TEMP_ON_WIPE_TOWER") == std::string::npos);
}
}
// Priming runs before the first layer is set up, so set_extruder sees no layer at all: its
// on_first_layer() test is false and print_z is the initial layer height rather than 0. The
// tower nonetheless blocks on the first layer temperature there, so the pre-heat raised ahead
// of each priming Tn has to name that same temperature — pre-heating to the "other layers"
// value instead leaves the tagged M109 asking the firmware to cool back down before the
// priming lines are extruded.
TEST_CASE("Wipe tower priming pre-heats to the first layer temperature", "[MultiFilament]")
{
const std::string gcode = slice_with_object_overrides(
{ cube(20), cube(20) },
multifilament_config(2, {
{ "nozzle_diameter", "0.4,0.4" },
{ "printer_extruder_id", "1,2" },
{ "printer_extruder_variant", "Direct Drive Standard,Direct Drive Standard" },
{ "extruder_printable_height", "0,0" },
{ "single_extruder_multi_material", 0 },
{ "single_extruder_multi_material_priming", 1 },
{ "enable_prime_tower", 1 },
{ "prime_tower_width", 35 },
{ "wipe_tower_x", "50" },
{ "wipe_tower_y", "50" },
{ "preheat_time", 0 }, // see the wait test above
// Distinct enough that picking the wrong one is unambiguous.
{ "nozzle_temperature_initial_layer", "215,215" },
{ "nozzle_temperature", "240,240" },
{ "wait_for_temp_on_wipe_tower", 1 },
}),
{ { { "extruder", 1 } }, { { "extruder", 2 } } });
std::vector<std::string> lines;
std::istringstream gcode_stream(gcode);
for (std::string line; std::getline(gcode_stream, line);)
lines.emplace_back(std::move(line));
// Temperature of an M104/M109, or -1 when the line is neither.
const auto temp_of = [](const std::string& l) {
if (l.rfind("M104", 0) != 0 && l.rfind("M109", 0) != 0)
return -1;
const size_t s = l.find('S');
return s == std::string::npos ? -1 : std::stoi(l.substr(s + 1));
};
size_t start = lines.size(), end = lines.size();
for (size_t i = 0; i < lines.size(); ++i) {
if (start == lines.size() && lines[i].find("; CP PRIMING START") != std::string::npos)
start = i;
else if (start < lines.size() && lines[i].find("; CP PRIMING END") != std::string::npos) {
end = i;
break;
}
}
REQUIRE(start < end);
int checked_waits = 0;
for (size_t i = start; i < end; ++i) {
if (lines[i].find("_WAIT_FOR_TEMP_ON_WIPE_TOWER") == std::string::npos)
continue;
++checked_waits;
INFO("priming wait at line " << i + 1 << ": " << lines[i]);
CHECK(temp_of(lines[i]) == 215); // the tower waits on the first layer temperature
// The most recent set-temperature before it is the pre-heat, and must agree with it.
int preheat = -1;
for (size_t j = i; j-- > start;)
if ((preheat = temp_of(lines[j])) != -1)
break;
CHECK(preheat == 215);
}
REQUIRE(checked_waits > 0); // the feature under test is active
}
// The temperature-wait park picks its side of the tower by testing bed containment with the
// tower position at psWipeTower generation time, while WipeTowerIntegration shifts the cached
// moves by the CURRENT position at export. Moving the tower normally invalidates only
// psSkirtBrim (tower gcode is position-independent), but the park makes it bed-relative, so a
// GUI-style move-and-reslice on the same Print must regenerate the tower — otherwise the stale
// park prints outside the bed. Contract: every tagged wait parks inside the printable area.
TEST_CASE("Wipe tower temperature-wait park is regenerated when the tower moves", "[MultiFilament]")
{
// Two objects, one filament each: a toolchange (and a tagged wait) on every layer, like
// the wait test above — but on a single-extruder machine profile: the synthetic
// dual-extruder keys would drag in the extruder-variant expansion, which is not
// idempotent on the default machine profile and would pollute the re-apply diff below.
// Rectangle wall and no brim keep the tower-local footprint inside [0, 35], so the park
// sits at the generator's 2mm side gap: local -2 or 37.
DynamicPrintConfig config = multifilament_config(2, {
{ "single_extruder_multi_material", 0 },
{ "enable_prime_tower", 1 },
{ "prime_tower_width", 35 },
{ "wipe_tower_wall_type", "rectangle" }, // the default rib bulges past the width
{ "prime_tower_brim_width", 0 }, // the default 3 widens the first-layer envelope
{ "printable_area", "0x0,200x0,200x200,0x200" },
{ "wipe_tower_x", "0" },
{ "wipe_tower_y", "50" },
{ "ooze_prevention", 1 },
{ "standby_temperature_delta", -40 },
{ "wait_for_temp_on_wipe_tower", 1 },
});
// init_print force-sets this on its own copy; set it here too so the re-apply below
// diffs in wipe_tower_x ONLY — the exact GUI increment under test.
config.set_key_value("gcode_comments", new ConfigOptionBool(true));
Print print;
Model model;
const std::vector<std::vector<ConfigBase::SetDeserializeItem>> overrides{
{ { "extruder", 1 } }, { { "extruder", 2 } } }; // object-level, see the wait test above
init_print(std::vector<TriangleMesh>{ cube(20), cube(20) }, print, model, config, &overrides);
const std::string at_edge = gcode(print);
const std::vector<double> at_edge_parks = wait_park_xs(at_edge);
REQUIRE(!at_edge_parks.empty()); // the feature under test is active
for (double x : at_edge_parks) {
INFO("wait park X " << x << " with the tower at x=0 on a 200mm bed");
CHECK(x >= -0.05);
CHECK(x <= 200.05);
}
REQUIRE(print.is_step_done(psWipeTower));
// Move the tower to the right bed edge (164 + 35 = 199 keeps the body printable) and
// re-apply on the SAME Print, as the GUI does. Base the re-apply on the print's own
// resolved config so the diff is wipe_tower_x alone — re-applying the caller's config
// would also diff the apply-time extruder normalization write-backs, and those keys
// regenerate the tower for the wrong reason. The cached right-side park would export
// at 164 + 37 = 201, off the bed; regeneration clamps the park against the bed edge.
// Assemble the moved config exactly the way init_print assembled the first one — the
// apply-time normalization is only idempotent when both applies start from the same
// derivation, and any stray diff key would regenerate the tower for the wrong reason.
config.set_deserialize_strict({ { "wipe_tower_x", "164" } });
DynamicPrintConfig moved_config = DynamicPrintConfig::full_print_config();
moved_config.apply(config);
moved_config.set_key_value("gcode_comments", new ConfigOptionBool(true));
print.apply(model, moved_config);
CHECK_FALSE(print.is_step_done(psWipeTower)); // the move must re-generate the tower
const std::string moved = gcode(print);
const std::vector<double> moved_parks = wait_park_xs(moved);
REQUIRE(!moved_parks.empty()); // the waits must survive the re-slice
for (double x : moved_parks) {
INFO("wait park X " << x << " with the tower at x=164 on a 200mm bed");
CHECK(x >= -0.05);
CHECK(x <= 200.05);
}
}
// The flag-off half of the three tests above. Every site wait_for_temp_on_wipe_tower touches is
// guarded -- set_extruder's pre-toolchange preheat block and its post_toolchange skip,
// toolchange_Change's park, the interface-temp guard in WipeTower2::tool_change, and append_tcr2's
// tagged-M109 filter -- so with the option off the feature has to be inert and temperature emission
// has to stay exactly as it was before the option existed. That is pinned against a trace captured
// from main rather than against expectations written from the current code, which would be
// re-derived from the very code they are meant to guard.
//
// Note what main emits here, since it is easy to misread as a missing wait: with preheat_time set,
// the toolchange carries no blocking M109 at all. GCodeProcessor's backtrace moves the heat-up to
// an M104 preheat_time seconds earlier and demotes the in-place command, which is the entire point
// of preheating. The lead times below are what pin that placement.
TEST_CASE("Toolchange temperature commands are unchanged when the wipe tower wait is off", "[MultiFilament][Regression]")
{
// 20x20x5 cubes at the default 0.2mm layer height are 25 layers, one filament each, so there is
// a toolchange -- and a preheat ahead of it -- on every layer.
const std::string gcode = slice_with_object_overrides(
{ make_cube(20., 20., 5.), make_cube(20., 20., 5.) },
multifilament_config(2, {
{ "nozzle_diameter", "0.4,0.4" },
{ "printer_extruder_id", "1,2" },
{ "printer_extruder_variant", "Direct Drive Standard,Direct Drive Standard" },
{ "extruder_printable_height", "0,0" },
{ "single_extruder_multi_material", 0 },
{ "single_extruder_multi_material_priming", 1 }, // reaches toolchange_Change's priming path
{ "enable_prime_tower", 1 },
{ "prime_tower_width", 35 },
{ "wipe_tower_x", "50" },
{ "wipe_tower_y", "50" },
// GCodeProcessor::apply_config enables the preheat backtrace on
// ooze_prevention && preheat_time > 0 && !SEMM && filaments > 1. That is what puts an
// M104 preheat_time seconds ahead of every Tn, and it also gives set_extruder's
// standby/restore pair, which the option demotes and moves when it is on.
{ "ooze_prevention", 1 },
{ "standby_temperature_delta", -40 },
{ "preheat_time", 30 },
{ "preheat_steps", 1 },
// enable_tower_interface_features is deliberately left off: the interface temperature
// is observable only through a change_filament_gcode template that reads
// new_filament_temp, since append_tcr2 strips the tower's own M109 for it, and the
// default template here has none. The option's interface-temp guard is covered by the
// enabled-path tests above instead.
//
// Distinct enough that a wrong pick between the two is unambiguous in the trace.
{ "nozzle_temperature_initial_layer", "215,215" },
{ "nozzle_temperature", "240,240" },
{ "wait_for_temp_on_wipe_tower", 0 },
}),
// Object-level, so the used-filament count that gates the prime tower is derived from it.
{ { { "extruder", 1 } }, { { "extruder", 2 } } });
const std::vector<std::string> trace = temperature_trace(gcode);
REQUIRE(trace.size() > 1);
CHECK(gcode.find("_WAIT_FOR_TEMP_ON_WIPE_TOWER") == std::string::npos);
const std::string golden_path = std::string(TEST_DATA_DIR PATH_SEPARATOR "wipe_tower_temperature_trace_main.txt");
// Regenerate by appending this test and its helpers to the same file on main (dropping the
// wait_for_temp_on_wipe_tower key, which main's config does not know), rebuilding
// fff_print_tests there, running it with ORCA_UPDATE_WIPE_TOWER_TEMP_TRACE=1, copying the file
// it writes back here, and filling in the commit it was captured from.
if (std::getenv("ORCA_UPDATE_WIPE_TOWER_TEMP_TRACE") != nullptr) {
std::ofstream out(golden_path);
REQUIRE(out.good());
out << "# Temperature and tool-change commands of a wait_for_temp_on_wipe_tower-off slice,\n"
"# captured from the main branch at <fill in the commit>. Regeneration is described\n"
"# at the test that reads this file: \"Toolchange temperature commands are unchanged\n"
"# when the wipe tower wait is off\" in tests/fff_print/test_multifilament.cpp.\n";
for (const std::string& entry : trace)
out << entry << "\n";
WARN("Rewrote " << golden_path << " from this run; it no longer reflects main.");
return;
}
std::vector<std::string> golden;
{
std::ifstream in(golden_path);
INFO("reading " << golden_path);
REQUIRE(in.good());
for (std::string line; std::getline(in, line);) {
if (!line.empty() && line.back() == '\r')
line.pop_back();
if (!line.empty() && line[0] != '#')
golden.push_back(std::move(line));
}
}
REQUIRE(!golden.empty());
const size_t common = std::min(trace.size(), golden.size());
for (size_t i = 0; i < common; ++i) {
if (trace_entries_match(trace[i], golden[i]))
continue;
// Report the first difference only: past it the two are misaligned and every later entry
// would be reported as a difference too.
INFO("first difference at trace entry " << i + 1);
INFO(" main: " << golden[i]);
INFO(" branch: " << trace[i]);
FAIL("temperature emission differs from main with wait_for_temp_on_wipe_tower off");
}
CHECK(trace.size() == golden.size());
}
// max_layer_height can be shorter than the extruder count (normalization sizes it to the
// filament count under single_extruder_multi_material). calc_max_layer_height() in ToolOrdering
// indexed it per-nozzle and read past the end. Shortened directly here to isolate that read;
// the other per-extruder keys stay extruder-length so slicing reaches the code under test.
TEST_CASE("Multi-extruder slice stays in bounds with a short max_layer_height", "[MultiFilament]")
{
DynamicPrintConfig config = multifilament_config(2);
config.set_deserialize_strict({
{ "nozzle_diameter", "0.4,0.4" },
{ "printer_extruder_id", "1,2" },
{ "printer_extruder_variant", "Direct Drive Standard,Direct Drive Standard" },
{ "extruder_printable_height", "0,0" },
{ "max_layer_height", "0.3" }, // deliberately one entry short
});
Print print;
init_and_process_print({ cube(20) }, print, config);
REQUIRE_FALSE(print.objects().front()->layers().empty());
}