Files
OrcaSlicer/tests/fff_print/test_multifilament.cpp
T
Clifford GarwoodandClaude Opus 5 5eac300d91 Use IDEX/IQEX in the user-facing strings
Closes review comment 10.

`461c69c83e` settled this in April — IMEX internally, IDEX/IQEX as the user-facing label — but the
UI strings were never converted. Every translated string naming the feature now reads IDEX/IQEX:
41 occurrences across the printer and process option labels and tooltips, the modes editor, the
plate mode indicator, the pre-slice warnings, the placement refusals and the slicing errors. The
reviewer listed eight; the rest were in the same class.

Nothing else moves. The config keys keep the `imex_` spelling — `is_imex`, `imex_mode_names`,
`imex_parallel_mode` and the rest are on-disk format in existing printer presets and 3MF projects,
so renaming them would break every profile and project already saved. C++ identifiers, filenames,
comments and test names keep IMEX as well: it stays the internal name of the subsystem, which is
what covers the topology space (one gantry with 2-4 tools, 2x1 and 2x2 grids) that neither acronym
names on its own. Where a tooltip quotes a key, the key spelling is preserved and only the feature
word around it changed.

The `is_imex` tooltip is reworded rather than substituted: it already named the hardware families
parenthetically, so a literal replacement would have said IDEX/IQEX twice in one sentence.

No translation impact — no IMEX string had reached OrcaSlicer.pot or any catalogue, so there is
nothing to migrate. One test asserted on the old error text and now matches the new one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 10:05:37 -04:00

1277 lines
66 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 <map>
#include <optional>
#include <set>
#include <sstream>
#include <string>
#include <string_view>
#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;
}
// "M104 S240 T0 ; preheat T0 time: 31s<TAB>lead 30.9s" carries the same quantity twice, and both
// vary by toolchain: the backtrace picks the first line at least preheat_time out, so a sub-tenth
// difference in the estimate selects a neighbouring move and "lead" steps by that move's duration.
// Tolerate "lead", still far below the tens of seconds a displaced preheat would shift it. Check
// "time:" against its own entry's "lead" instead of across runs -- being a rounding of it, that
// still catches a change in how it is derived without tracking the absolute estimate.
static constexpr double TRACE_TIME_TOLERANCE_S = 1.5;
static constexpr double TRACE_ROUNDING_SLACK_S = 0.05; // correct rounding keeps |time - lead| <= 0.5
struct TraceEntry
{
std::string text; // timing values replaced by a placeholder
std::optional<double> time_s;
std::optional<double> lead_s;
};
static TraceEntry parse_trace_entry(const std::string& entry)
{
TraceEntry out;
std::string text = entry;
// Split off the tail only when it really is a "lead <n>s", so an unexpected one still compares.
const size_t tab = text.find('\t');
if (tab != std::string::npos) {
const std::string tail = text.substr(tab + 1); // "lead 30.2s"
const size_t sp = tail.find(' ');
if (sp != std::string::npos && sp + 1 < tail.size()
&& std::isdigit(static_cast<unsigned char>(tail[sp + 1]))) {
out.lead_s = std::stod(tail.substr(sp + 1));
text.erase(tab);
}
}
static constexpr std::string_view k_time = "time: ";
const size_t at = text.find(k_time);
// Require a digit first: a dots-only run would otherwise reach std::stod and throw.
if (at != std::string::npos && at + k_time.size() < text.size()
&& std::isdigit(static_cast<unsigned char>(text[at + k_time.size()]))) {
const size_t first = at + k_time.size();
size_t last = first;
while (last < text.size() && (std::isdigit(static_cast<unsigned char>(text[last])) || text[last] == '.'))
++last;
out.time_s = std::stod(text.substr(first, last - first));
text.replace(first, last - first, "<n>"); // surrounding text, incl. the "s", still compared
}
out.text = std::move(text);
return out;
}
static bool timings_match(const std::optional<double>& a, const std::optional<double>& b)
{
if (a.has_value() != b.has_value())
return false;
return !a.has_value() || std::abs(*a - *b) <= TRACE_TIME_TOLERANCE_S;
}
// "time:" must be its own entry's "lead" rounded to a whole second.
static bool time_is_rounded_lead(const TraceEntry& e)
{
if (!e.time_s.has_value() || !e.lead_s.has_value())
return true; // nothing to cross-check
return std::abs(*e.time_s - *e.lead_s) <= 0.5 + TRACE_ROUNDING_SLACK_S;
}
// `a` is the slice under test, `b` the recorded golden.
static bool trace_entries_match(const std::string& a, const std::string& b)
{
const auto x = parse_trace_entry(a);
const auto y = parse_trace_entry(b);
if (x.text != y.text)
return false;
// A field appearing or disappearing is a real change even though the values are tolerated.
if (x.time_s.has_value() != y.time_s.has_value())
return false;
return timings_match(x.lead_s, y.lead_s) && time_is_rounded_lead(x);
}
// 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());
// Reported separately from the golden comparison below: it is a different failure.
for (size_t i = 0; i < trace.size(); ++i) {
const auto entry = parse_trace_entry(trace[i]);
if (time_is_rounded_lead(entry))
continue;
INFO("at trace entry " << i + 1);
INFO(" " << trace[i]);
FAIL("\"time:\" is not its entry's \"lead\" rounded to a whole second");
}
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());
}
// Shared IMEX printer geometry: 7 logical extruders across 4 physical heads.
// physical_extruder_map is only honoured when its length matches the nozzle count
// (PrintApply feeds effective_physical_extruder_map the nozzle_diameter size), so the
// nozzle keys must be sized to 7 or the map is silently replaced with the identity and
// every logical slot resolves to its own head -- which hides the defects under test.
static void imex_7x4_printer(DynamicPrintConfig &config)
{
config.set_deserialize_strict({
{ "nozzle_diameter", "0.4,0.4,0.4,0.4,0.4,0.4,0.4" },
{ "printer_extruder_id", "1,2,3,4,5,6,7" },
{ "printer_extruder_variant", "Direct Drive Standard,Direct Drive Standard,Direct Drive Standard,"
"Direct Drive Standard,Direct Drive Standard,Direct Drive Standard,"
"Direct Drive Standard" },
{ "extruder_printable_height", "0,0,0,0,0,0,0" },
{ "physical_extruder_map", "0,0,0,0,1,2,3" },
{ "is_imex", "1" },
{ "imex_mode_names", "primary;copy" },
{ "imex_mode_active_tools", "0:P;0:P,1:C" },
{ "skirt_loops", "0" },
{ "brim_type", "no_brim" },
// Temperature assertions below spell "M104 S<t> T<n>"; RepRapFirmware would emit
// "G10 S<t> P<n>" from the same code, so the flavor is pinned rather than defaulted.
{ "gcode_flavor", "klipper" },
});
}
// Route every region to one filament. An unset *_filament_id is not "inherit":
// clamp_feature_filament_to_valid rewrites <=0 to 1, which would drag tool 0 into
// tool_ordering and mask what these tests assert. PrintObject.cpp's call to that
// function is the source of truth for this key list -- a new one has to be added here.
static void all_regions_on_filament(DynamicPrintConfig &config, int filament_1based)
{
for (const char *key : { "outer_wall_filament_id", "inner_wall_filament_id",
"sparse_infill_filament_id", "internal_solid_filament_id",
"top_surface_filament_id", "bottom_surface_filament_id" })
config.set_deserialize_strict({ { key, std::to_string(filament_1based) } });
}
// IMEX parallel modes emit per-carriage temperatures from a branch that is mutually
// exclusive with the standard per-extruder path, and that branch skipped the head the
// print's own toolpaths run on. That head therefore never received its 1st->2nd layer
// transition and held nozzle_temperature_initial_layer for the whole job.
TEST_CASE("Parallel-mode IMEX prints transition the printing head to its second-layer temperature",
"[MultiFilament][IMEX]")
{
DynamicPrintConfig config = multifilament_config(7);
imex_7x4_printer(config);
all_regions_on_filament(config, 1); // filament 1 => logical slot 0 => physical head 0
// The multi-extruder normalization collapses per-filament temperature vectors to a
// single value, so heads are told apart by their tool qualifier, not by temperature.
config.set_deserialize_strict({
{ "imex_parallel_mode", "copy" },
{ "nozzle_temperature_initial_layer", "200" },
{ "nozzle_temperature", "240" },
});
const std::string gcode = slice({ cube(20) }, config);
// Head 0 runs the print's own toolpaths and must step 200 -> 240 at the second layer.
CHECK(gcode.find("M104 S240 T0") != std::string::npos);
// Head 1 is the copy carriage; it already worked and must keep working.
CHECK(gcode.find("M104 S240 T1") != std::string::npos);
}
// IMEX supplements is_extruder_used for the secondary carriages a parallel mode drives.
// `primary` drives exactly one tool, so the supplement must not run: routing every region
// to filament 6 puts the initial tool on physical head 2, while the mode's only declared
// head is 0, which the unguarded supplement resolved back to filament slot 0.
TEST_CASE("Primary-mode IMEX prints mark only the filament slot they print with",
"[MultiFilament][IMEX]")
{
DynamicPrintConfig config = multifilament_config(7);
imex_7x4_printer(config);
all_regions_on_filament(config, 6); // filament 6 => logical slot 5 => physical head 2
config.set_deserialize_strict({
{ "imex_parallel_mode", "primary" },
{ "machine_start_gcode",
";USED0:{if is_extruder_used[0]}1{else}0{endif}\n"
";USED5:{if is_extruder_used[5]}1{else}0{endif}\n" },
});
const std::string gcode = slice({ cube(20) }, config);
CHECK(gcode.find(";USED5:1") != std::string::npos);
CHECK(gcode.find(";USED0:0") != std::string::npos);
}
// Guard rail for the fix above: the modes the supplement exists for must keep marking their
// secondaries. Printed on filament 1 (slot 0), which pem routes to head 0 -- the head `copy`
// declares Primary -- so the plate is well-formed and validate() lets it through. Head 0 is
// the initial tool's head and is skipped (tool_ordering already marked slot 0); head 1 is the
// secondary and resolves to slot 4.
//
// This case previously printed on filament 6, which routes to head 2 while `copy` declares
// head 0 Primary. Print::validate() now refuses that plate outright (no filament on it can
// feed the Primary tool), so asserting it slices correctly would contradict
// "An IMEX plate whose filament never routes to the primary carriage is blocked" below.
// slice() does not surface validate()'s return, so the contradiction would have gone unnoticed.
TEST_CASE("Copy-mode IMEX prints still mark every secondary carriage's filament slot",
"[MultiFilament][IMEX]")
{
DynamicPrintConfig config = multifilament_config(7);
imex_7x4_printer(config);
all_regions_on_filament(config, 1);
config.set_deserialize_strict({
{ "imex_parallel_mode", "copy" },
{ "machine_start_gcode",
";USED0:{if is_extruder_used[0]}1{else}0{endif}\n"
";USED4:{if is_extruder_used[4]}1{else}0{endif}\n"
";USED5:{if is_extruder_used[5]}1{else}0{endif}\n" },
});
const std::string gcode = slice({ cube(20) }, config);
CHECK(gcode.find(";USED0:1") != std::string::npos); // the filament actually printed
CHECK(gcode.find(";USED4:1") != std::string::npos); // head 1, the secondary carriage
CHECK(gcode.find(";USED5:0") != std::string::npos);
}
// The two IMEX changes are coupled: get_imex_active_tools() now returns an empty roster in
// primary mode, so if the temperature branch ever stopped excluding primary it would enter,
// emit nothing, skip the standard path, and silently restore the bug the copy-mode case above
// covers -- with every other test still green.
TEST_CASE("Primary-mode IMEX prints still transition to the second-layer temperature",
"[MultiFilament][IMEX]")
{
DynamicPrintConfig config = multifilament_config(7);
imex_7x4_printer(config);
all_regions_on_filament(config, 1);
config.set_deserialize_strict({
{ "imex_parallel_mode", "primary" },
{ "nozzle_temperature_initial_layer", "200" },
{ "nozzle_temperature", "240" },
});
const std::string gcode = slice({ cube(20) }, config);
CHECK(gcode.find("M104 S240") != std::string::npos);
}
// A plate carries its IMEX mode as a name, matched against the printer's imex_mode_names at
// slice time, so a mode renamed or deleted underneath the plate -- or a project opened against a
// preset that names its modes differently -- leaves the plate pointing at nothing. That used to
// take every "not Primary" branch in the exporter while every name-keyed lookup came back empty,
// which is worse than either interpretation on its own:
// * the 1st->2nd layer temperature branch is mutually exclusive with the standard one, so an
// empty active-tool roster meant NO head was transitioned and every one of them held
// nozzle_temperature_initial_layer for the whole print; and
// * the initial T<n> was suppressed on the assumption that the mode's setup script would
// select the tool, while that script -- resolved by the same name -- did not exist.
// Print::validate() does not catch it either: the unresolved name yields an empty tools string,
// so there is no declared primary and its IMEX routing guard is skipped.
//
// Falling back to Primary is what makes the file coherent again. Asserted on the two emissions
// that were actually broken rather than on the mode string, which the placeholder test in
// test_imex_mode_gcode.cpp covers.
TEST_CASE("An IMEX plate set to a mode the printer no longer defines slices as Primary",
"[MultiFilament][IMEX]")
{
DynamicPrintConfig config = multifilament_config(7);
imex_7x4_printer(config);
all_regions_on_filament(config, 1); // filament 1 => logical slot 0 => physical head 0
config.set_deserialize_strict({
// imex_mode_names is "primary;copy" -- this is `copy` after a rename.
{ "imex_parallel_mode", "copy-renamed" },
{ "nozzle_temperature_initial_layer", "200" },
{ "nozzle_temperature", "240" },
});
const std::string gcode = slice({ cube(20) }, config);
// The head the toolpaths run on steps 200 -> 240 at the second layer, via the standard
// per-extruder path a Primary-mode print uses.
CHECK(gcode.find("M104 S240") != std::string::npos);
// ...and only that head, addressed the way a single-head print addresses it: nothing drives
// a copy carriage here, so the fallback goes through the standard per-extruder path, where
// only filament slot 0 prints. GCodeWriter::set_temperature therefore sees
// multiple_extruders == false and emits no tool qualifier at all. Excluding the whole
// " T" suffix rather than " T1" is what makes that the assertion: a regression that routed
// the transition to T2 or T3 instead would satisfy an exclusion of T1, and the unqualified
// find above is itself prefix-satisfied by any "M104 S240 T<n>".
CHECK(gcode.find("M104 S240 T") == std::string::npos);
// The initial tool selection is emitted. Matched as a line-leading token rather than a whole
// line so the assertion does not depend on the trailing "; change extruder" comment, which
// is switched off by a global unrelated to IMEX.
bool selects_initial_tool = false;
std::istringstream tool_lines(gcode);
std::string line;
while (std::getline(tool_lines, line))
if (line.rfind("T0", 0) == 0) {
selects_initial_tool = true;
break;
}
CHECK(selects_initial_tool);
}
// IQEX: when the second gantry is active the mode drives all four carriages, so every one of
// them needs its own filament resolved -- for the first layer via is_extruder_used (consumed by
// machine_start_gcode) and for the second via the per-tool transition. pem routes filament 1 to
// head 0, and heads 1/2/3 to filament slots 4/5/6, so all four slots must appear.
TEST_CASE("IQEX modes emit first- and second-layer temperatures for every active carriage",
"[MultiFilament][IMEX]")
{
DynamicPrintConfig config = multifilament_config(7);
imex_7x4_printer(config);
all_regions_on_filament(config, 1);
config.set_deserialize_strict({
{ "imex_mode_names", "primary;copy;iq-copy" },
{ "imex_mode_active_tools", "0:P;0:P,1:C;0:P,1:C,2:C,3:C" },
{ "imex_parallel_mode", "iq-copy" },
{ "nozzle_temperature_initial_layer", "200" },
{ "nozzle_temperature", "240" },
{ "machine_start_gcode",
";USED0:{if is_extruder_used[0]}1{else}0{endif}\n"
";USED4:{if is_extruder_used[4]}1{else}0{endif}\n"
";USED5:{if is_extruder_used[5]}1{else}0{endif}\n"
";USED6:{if is_extruder_used[6]}1{else}0{endif}\n" },
});
const std::string gcode = slice({ cube(20) }, config);
// First layer: every active carriage's filament is declared to machine_start_gcode.
CHECK(gcode.find(";USED0:1") != std::string::npos);
CHECK(gcode.find(";USED4:1") != std::string::npos);
CHECK(gcode.find(";USED5:1") != std::string::npos);
CHECK(gcode.find(";USED6:1") != std::string::npos);
// Second layer: every active carriage gets its own transition.
CHECK(gcode.find("M104 S240 T0") != std::string::npos);
CHECK(gcode.find("M104 S240 T1") != std::string::npos);
CHECK(gcode.find("M104 S240 T2") != std::string::npos);
CHECK(gcode.find("M104 S240 T3") != std::string::npos);
}
// M104/M109 name a physical heater, but every caller of the instance set_temperature overload
// addresses filaments by logical id. pem routes filament 5 (logical 4) to head 1, so a
// toolchange between filaments 1 and 5 must cool and wait on T1 -- never T4, which on this
// machine is an AFC lane index and names no heater at all.
//
// Regression: the same-physical short-circuit in set_extruder hides this for lane swaps within
// one head (filaments 1-4 all map to head 0, so no cool-down is emitted), so only a toolchange
// that CROSSES heads reaches the emission. Ooze prevention must be on for pre/post_toolchange
// to run at all.
TEST_CASE("IMEX heater commands name the physical head, not the logical filament",
"[MultiFilament][IMEX][Regression]")
{
DynamicPrintConfig config = multifilament_config(7);
imex_7x4_printer(config);
config.set_deserialize_strict({
{ "imex_parallel_mode", "primary" },
{ "ooze_prevention", "1" },
{ "standby_temperature_delta", "-50" },
{ "single_extruder_multi_material", "0" },
{ "nozzle_temperature_initial_layer", "200,200,200,200,200,200,200" },
{ "nozzle_temperature", "240,240,240,240,240,240,240" },
});
// Two objects on filaments 1 and 5: logical 0 -> head 0, logical 4 -> head 1.
const std::vector<std::vector<ConfigBase::SetDeserializeItem>> overrides{
{ { "extruder", "1" } },
{ { "extruder", "5" } },
};
const std::string gcode = slice_with_object_overrides({ cube(20), cube(20) }, config, overrides);
// The bare toolchange stays LOGICAL -- it is an AFC lane selector, not a heater.
CHECK(gcode.find("\nT4") != std::string::npos);
// Every heater command carrying a tool must name a configured head (0-3 here), never a
// logical slot above the head count. Scanning beats a fixed-string check: it fails on any
// stray unmapped emission, not just the two sites this test was written for.
std::istringstream ss(gcode);
std::string line;
std::vector<std::string> offenders;
while (std::getline(ss, line)) {
if (line.rfind("M104", 0) != 0 && line.rfind("M109", 0) != 0)
continue;
const size_t t = line.find(" T");
if (t == std::string::npos || t + 2 >= line.size() || !std::isdigit((unsigned char) line[t + 2]))
continue;
if (std::stoi(line.substr(t + 2)) > 3)
offenders.push_back(line);
}
INFO("heater commands naming a non-existent head: " << offenders.size()
<< (offenders.empty() ? "" : " e.g. " + offenders.front()));
CHECK(offenders.empty());
}
// The other half of that translation, and the half nothing else covers: GCodeWriter::set_temperature
// passes `this->config.is_imex.value` as the guard, NOT a constant, so a printer that is not an
// IMEX printer keeps addressing heaters by logical filament id exactly as upstream does.
//
// physical_extruder_map is not an IMEX-only key. Shipping dual-nozzle BBL profiles author it --
// fdm_bbl_3dp_002_common ships {1, 0} -- for the inherited BBL reading of the key, and PrintApply
// deliberately leaves a non-IMEX printer's map exactly as it arrives (IMEXHelpers.hpp spells out
// the two readings). Hardcode `true` at that call site and this whole suite still passes, because
// every other case here runs either on an IMEX printer or on one with no authored map -- while
// those profiles start sending filament 0's M104/M109 to heater 1 and filament 1's to heater 0.
//
// The two filaments carry DIFFERENT idle temperatures, so the S value and the T index cross-check
// each other: a remap moves both onto the other tool and fails both halves, and no assertion can
// be satisfied by a prefix.
//
// Idle temperature rather than nozzle_temperature, for a reason specific to THIS HARNESS. Keys in
// filament_options_with_variant are rewritten at apply time by
// update_values_to_printer_extruders_for_multiple_filaments, which sets each filament's value to
// `opt->get_at(variant_index[f])` -- it RE-INDEXES per filament by that filament's extruder
// variant, it does not flatten. Per-filament nozzle temperature is a real, working feature and
// survives that pass on a printer whose filaments resolve to different variant slots.
// multifilament_config pins nozzle_diameter to a single 0.4, so every filament here resolves to
// the SAME variant slot and therefore ends up with the same value -- which is why the IMEX cases
// above tell heads apart by tool qualifier rather than by temperature. idle_temperature is not in
// that key set, so "151,173" reaches the emitter intact and the two filaments stay distinguishable.
// Ooze prevention is what puts the idle temperatures into the file at all.
TEST_CASE("Heater commands keep the logical filament id on a non-IMEX printer",
"[MultiFilament][IMEX][Regression]")
{
DynamicPrintConfig config = 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" },
// The shipping two-nozzle profile: not an IMEX printer, but it authors the swap map.
{ "is_imex", "0" },
{ "physical_extruder_map", "1,0" },
{ "single_extruder_multi_material", "0" },
// Ooze prevention drops the outgoing filament to its idle temperature on every tool
// change, through the instance set_temperature overload that carries the guard.
{ "ooze_prevention", "1" },
{ "standby_temperature_delta", "-50" }, // unused while idle_temperature is set
// Per filament and distinct: these are the values the assertions pair with a heater.
{ "idle_temperature", "151,173" },
{ "nozzle_temperature_initial_layer", "215,215" },
{ "nozzle_temperature", "240,240" },
// GCodeProcessor's preheat pass rewrites the tool-change temperature commands and drops
// the ";cooldown" M104s outright, applying physical_extruder_map itself as it does. That
// pass is not the code under test, so switch it off and let the writer's emissions stand.
{ "preheat_time", "0" },
{ "enable_prime_tower", "0" },
// The assertions spell "M104 S<t> T<n>"; RepRapFirmware emits "G10 S<t> P<n>" from the
// same code, so the flavor is pinned rather than defaulted.
{ "gcode_flavor", "klipper" },
});
// One filament per object, so both heaters are addressed and a tool change happens in both
// directions. Assigned at the object level: a region-level filament id would not raise the
// used-filament count the tool ordering works from.
const std::vector<std::vector<ConfigBase::SetDeserializeItem>> overrides{
{ { "extruder", "1" } },
{ { "extruder", "2" } },
};
const std::string gcode = slice_with_object_overrides({ cube(20), cube(20) }, config, overrides);
// Every heater command that names a tool, collected as temperature -> the tools it was
// addressed to. Scanning beats fixed-string finds: "M104 S151" on its own is prefix-satisfied
// by "M104 S151 T1", and excluding just " T1" would let a command routed to some third tool
// through. Mirrors the offender scan in the IMEX case above.
std::map<int, std::set<int>> tools_by_temp;
std::istringstream ss(gcode);
for (std::string line; std::getline(ss, line);) {
if (line.rfind("M104", 0) != 0 && line.rfind("M109", 0) != 0)
continue;
const size_t s = line.find('S');
const size_t t = line.find(" T");
if (s == std::string::npos || t == std::string::npos)
continue;
if (s + 1 >= line.size() || !std::isdigit((unsigned char) line[s + 1]))
continue;
if (t + 2 >= line.size() || !std::isdigit((unsigned char) line[t + 2]))
continue;
tools_by_temp[std::stoi(line.substr(s + 1))].insert(std::stoi(line.substr(t + 2)));
}
std::string seen;
for (const auto& [temperature, tools] : tools_by_temp) {
seen += " S" + std::to_string(temperature) + "->";
for (int tool : tools)
seen += "T" + std::to_string(tool);
}
INFO("heater commands naming a tool:" << seen);
// Both idle temperatures have to be in the file, or the pairing below would prove nothing.
REQUIRE(tools_by_temp.count(151) == 1);
REQUIRE(tools_by_temp.count(173) == 1);
// Filament 0's idle temperature goes to heater 0 and nowhere else, filament 1's to heater 1.
// Applying physical_extruder_map {1, 0} here would swap both.
CHECK(tools_by_temp[151] == std::set<int>{ 0 });
CHECK(tools_by_temp[173] == std::set<int>{ 1 });
}
// The IMEX Primary tool prints the sliced paths directly, so it can only use a filament the
// printer's physical_extruder_map routes to it. The ghost filament picker enforces that for
// the secondary tools; the primary's filament comes from the ordinary object selector, which
// has no IMEX awareness. `copy` declares T0 Primary, but every filament this plate uses --
// filament 6, slot 5 -- routes to head 2, so nothing can feed T0 and validate() must refuse.
//
// The object's own extruder is pinned too: ModelVolume::get_extruders() reports the volume's
// extruder_id (1 by default), which would put slot 0 on the plate. Slot 0 routes to head 0,
// the declared primary, so the plate would be well-formed and correctly NOT blocked.
TEST_CASE("An IMEX plate whose filament never routes to the Primary tool is blocked",
"[MultiFilament][IMEX]")
{
DynamicPrintConfig config = multifilament_config(7);
imex_7x4_printer(config);
all_regions_on_filament(config, 6);
config.set_deserialize_strict({ { "imex_parallel_mode", "copy" } });
std::vector<TriangleMesh> meshes;
meshes.push_back(cube(20));
const std::vector<std::vector<ConfigBase::SetDeserializeItem>> overrides{ { { "extruder", "6" } } };
Slic3r::Model model;
Slic3r::Print print;
init_print(std::move(meshes), print, model, config, &overrides, false);
std::vector<StringObjectException> warnings;
const StringObjectException err = print.validate(&warnings);
REQUIRE_FALSE(err.string.empty());
CHECK(err.string.find("T0") != std::string::npos); // the declared primary
CHECK(err.string.find("T2") != std::string::npos); // where the filament actually lives
}
// A mixed filament is blended at the nozzle by its component toolheads, which a parallel
// mode is already using to print copies. Unsupported regardless of where the components
// route, so this must refuse even though component filament 1 sits on the declared primary
// T0 -- and it must refuse with the mixed message, not the routing one. Mixed slots normally
// sit past the end of physical_extruder_map, so the routing rule would call them unrouted.
TEST_CASE("An IMEX plate using a mixed filament is blocked", "[MultiFilament][IMEX]")
{
DynamicPrintConfig config = multifilament_config(8);
imex_7x4_printer(config);
all_regions_on_filament(config, 8);
config.set_deserialize_strict({
{ "imex_parallel_mode", "copy" },
{ "filament_is_mixed", "0,0,0,0,0,0,0,1" },
{ "filament_mixed_components", ";;;;;;;1,5" },
// The mixed arrays run parallel to filament_colour and must be sized to the filament
// count (see test_mixed_filament.cpp). validate() returns before the other five are
// read, but that is a property of where the rule sits, not something to rely on.
{ "filament_mixed_sublayer_ratios", ";;;;;;;" },
{ "filament_mixed_gradient", "0,0,0,0,0,0,0,0" },
{ "filament_mixed_gradient_range", ";;;;;;;" },
{ "filament_mixed_gradient_curve", ";;;;;;;" },
{ "filament_mixed_gradient_per_part", "0,0,0,0,0,0,0,0" },
});
std::vector<TriangleMesh> meshes;
meshes.push_back(cube(20));
const std::vector<std::vector<ConfigBase::SetDeserializeItem>> overrides{ { { "extruder", "8" } } };
Slic3r::Model model;
Slic3r::Print print;
init_print(std::move(meshes), print, model, config, &overrides, false);
std::vector<StringObjectException> warnings;
const StringObjectException err = print.validate(&warnings);
REQUIRE_FALSE(err.string.empty());
CHECK(err.string.find("Mixed filaments") != std::string::npos);
}
// Filament 8 (slot 7) is the blend, filament 1 (slot 0) is ordinary, so used_filaments is > 1
// and the multi-color rule's gate opens too. This pins that the mixed rule still wins: with the
// two the other way round the user is told the mode's active tools all sit on one gantry --
// a lecture about a multi-color print they never asked for -- and never learns the blend is
// the problem.
//
// The second half is what keeps this honest. The multi-color rule only fires here because this
// fixture's `copy` mode is degenerate (imex_tools_per_gantry defaults to 2, so "0:P,1:C" puts
// both tools on gantry 0). Give the mode a Span tool and it returns nothing, and this test would
// pass under EITHER ordering while appearing to guard it. So assert the rule is actually armed.
TEST_CASE("A mixed filament outranks the multi-color rule on the same plate",
"[MultiFilament][IMEX][Regression]")
{
// Two cubes, offset: make_cube() is corner-at-origin, so identical meshes would be exactly
// coincident. validate() returns from the IMEX block before any geometry check today, but a
// future check landing earlier would fail this test for a reason it is not about.
const auto build = [](bool blend_slot_8) {
DynamicPrintConfig config = multifilament_config(8);
imex_7x4_printer(config);
all_regions_on_filament(config, 8);
config.set_deserialize_strict({
{ "imex_parallel_mode", "copy" },
{ "filament_is_mixed", "0,0,0,0,0,0,0,1" },
{ "filament_mixed_components", ";;;;;;;1,5" },
// The mixed arrays run parallel to filament_colour and must be sized to the filament
// count (see test_mixed_filament.cpp). validate() returns before the other five are
// read, but that is a property of where the rule sits, not something to rely on.
{ "filament_mixed_sublayer_ratios", ";;;;;;;" },
{ "filament_mixed_gradient", "0,0,0,0,0,0,0,0" },
{ "filament_mixed_gradient_range", ";;;;;;;" },
{ "filament_mixed_gradient_curve", ";;;;;;;" },
{ "filament_mixed_gradient_per_part", "0,0,0,0,0,0,0,0" },
});
if (!blend_slot_8)
config.set_deserialize_strict({ { "filament_is_mixed", "0,0,0,0,0,0,0,0" } });
return config;
};
const std::vector<std::vector<ConfigBase::SetDeserializeItem>> overrides{
{ { "extruder", "8" } }, { { "extruder", "1" } },
};
const auto validate_plate = [&](const DynamicPrintConfig& config, Slic3r::Print& print,
Slic3r::Model& model) {
std::vector<TriangleMesh> meshes;
meshes.push_back(cube(20));
TriangleMesh second = cube(20);
second.translate(30.0, 0.0, 0.0);
meshes.push_back(second);
init_print(std::move(meshes), print, model, config, &overrides, false);
std::vector<StringObjectException> warnings;
return print.validate(&warnings);
};
// The multi-color rule IS armed for this plate -- without that, the check below proves nothing.
{
Slic3r::Model model;
Slic3r::Print print;
const StringObjectException err = validate_plate(build(false), print, model);
REQUIRE_FALSE(err.string.empty());
CHECK(err.string.find("Multi-color") != std::string::npos);
}
// With the blend present the mixed rule takes precedence over it.
{
Slic3r::Model model;
Slic3r::Print print;
const StringObjectException err = validate_plate(build(true), print, model);
REQUIRE_FALSE(err.string.empty());
CHECK(err.string.find("not supported in IDEX/IQEX parallel modes") != std::string::npos);
CHECK(err.string.find("Multi-color") == std::string::npos);
// The two rules differ in more than wording: the mixed path attaches an object (for the
// notification's "Jump to" link), the multi-color path returns none. Pins which fired
// independently of the message text.
CHECK(err.object == print.objects().front());
}
}
// Guard rail: the block must not fire on a well-formed plate. Filament 1 (slot 0) routes to
// head 0, which `copy` declares Primary, so the Primary tool has something to print with.
TEST_CASE("An IMEX plate whose filament routes to the Primary tool validates",
"[MultiFilament][IMEX]")
{
DynamicPrintConfig config = multifilament_config(7);
imex_7x4_printer(config);
all_regions_on_filament(config, 1);
config.set_deserialize_strict({ { "imex_parallel_mode", "copy" } });
Slic3r::Model model;
Slic3r::Print print;
init_print({ cube(20) }, print, model, config);
std::vector<StringObjectException> warnings;
const StringObjectException err = print.validate(&warnings);
CHECK(err.string.empty());
}