mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-10-10 17:21:10 +00:00
The IMEX fixtures set seven nozzles but kept multifilament_config's single filaments x filaments flush block. get_flush_volumes_matrix splits that block across the nozzles, leaving each with 7 values, and ToolOrdering::reorder_extruders_for_minimum_flush_volume then reads them as a 7 x 7 matrix, past the end of the buffer. One of the affected tests segfaulted on Windows x64; ASan reproduces the overflow in that test on Linux, where it passed only by luck. The fixtures now repeat the block once per nozzle, as the GUI does, and size flush_multiplier to match, since append_full_config takes the nozzle count from it. The helper is shared in test_helpers. Co-authored-by: HanifKoh <76276251+HanifKoh@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1517 lines
81 KiB
C++
1517 lines
81 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" },
|
|
});
|
|
size_flush_to_nozzles(config);
|
|
}
|
|
|
|
// 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());
|
|
}
|
|
// A filament can define several variants (Standard, High Flow). Each filament prints with its
|
|
// variant of the extruder's variant string, or with its own first variant when it defines none, on a
|
|
// printer listing a single variant as on one listing several.
|
|
TEST_CASE("Each filament prints with its variant of the extruder's variant string", "[MultiFilament]")
|
|
{
|
|
auto [variant_list, nozzle_volume_type, filament, temperature, resolved] = GENERATE(table<std::string, NozzleVolumeType, int, int, std::string>({
|
|
{ "Direct Drive Standard", nvtStandard, 1, 211, "211,223" },
|
|
{ "Direct Drive Standard", nvtStandard, 2, 223, "211,223" },
|
|
{ "Direct Drive High Flow", nvtHighFlow, 1, 239, "239,223" },
|
|
{ "Direct Drive High Flow", nvtHighFlow, 2, 223, "239,223" }, // filament 2 defines no High Flow variant
|
|
{ "Direct Drive Standard,Direct Drive High Flow", nvtHighFlow, 1, 239, "239,223" },
|
|
{ "Direct Drive Standard,Direct Drive High Flow", nvtHighFlow, 2, 223, "239,223" },
|
|
}));
|
|
DYNAMIC_SECTION(variant_list << " printer, " << get_nozzle_volume_type_string(nozzle_volume_type) << " nozzle, filament " << filament) {
|
|
DynamicPrintConfig config = multifilament_config(2, {
|
|
{ "extruder_variant_list", variant_list },
|
|
// filament 1 defines Standard (211) and High Flow (239), filament 2 Standard (223)
|
|
{ "filament_extruder_variant", "Direct Drive Standard;Direct Drive High Flow;Direct Drive Standard" },
|
|
{ "filament_self_index", "1,1,2" },
|
|
{ "nozzle_temperature", "211,239,223" },
|
|
{ "nozzle_temperature_initial_layer", "211,239,223" },
|
|
{ "sparse_infill_filament_id", filament },
|
|
{ "internal_solid_filament_id", filament },
|
|
{ "top_surface_filament_id", filament },
|
|
{ "bottom_surface_filament_id", filament },
|
|
{ "outer_wall_filament_id", filament },
|
|
{ "inner_wall_filament_id", filament },
|
|
{ "enable_prime_tower", 0 },
|
|
{ "skirt_loops", 0 },
|
|
{ "brim_type", "no_brim" },
|
|
// custom G-code indexes the per-filament arrays by filament
|
|
{ "machine_start_gcode", "; start temperature {nozzle_temperature_initial_layer[initial_extruder]}" },
|
|
});
|
|
config.option<ConfigOptionEnumsGeneric>("nozzle_volume_type", true)->values = { nozzle_volume_type };
|
|
const std::string gcode = slice({ cube(20) }, config);
|
|
|
|
std::set<int> temperatures;
|
|
std::istringstream stream(gcode);
|
|
for (std::string line; std::getline(stream, line);) {
|
|
if (line.rfind("M104 ", 0) != 0 && line.rfind("M109 ", 0) != 0)
|
|
continue;
|
|
const size_t s = line.find(" S");
|
|
if (s != std::string::npos && std::stoi(line.substr(s + 2)) > 0)
|
|
temperatures.insert(std::stoi(line.substr(s + 2)));
|
|
}
|
|
CHECK(temperatures == std::set<int>{ temperature });
|
|
CHECK(gcode.find("; start temperature " + std::to_string(temperature) + "\n") != std::string::npos);
|
|
// The config the slice ran with holds one value per filament, as the readers that index
|
|
// it by filament (the wipe tower, the filament compatibility check) expect.
|
|
CHECK(gcode.find("; nozzle_temperature = " + resolved + "\n") != std::string::npos);
|
|
}
|
|
}
|
|
|
|
// An adaptive pressure advance model predicting the same pressure advance at every flow and acceleration.
|
|
static std::string constant_pressure_advance_model(const std::string &pa)
|
|
{
|
|
return pa + ",1,1000\n" + pa + ",500,1000\n" + pa + ",1,100000\n" + pa + ",500,100000";
|
|
}
|
|
|
|
// The pressure advance values a Klipper G-code sets.
|
|
static std::set<std::string> pressure_advance_values(const std::string &gcode)
|
|
{
|
|
const std::string token = "SET_PRESSURE_ADVANCE ADVANCE=";
|
|
std::set<std::string> values;
|
|
std::istringstream stream(gcode);
|
|
for (std::string line; std::getline(stream, line);)
|
|
if (line.rfind(token, 0) == 0)
|
|
values.insert(line.substr(token.size(), line.find(';') - token.size()));
|
|
return values;
|
|
}
|
|
|
|
// Pressure advance, and the adaptive pressure advance model, are tuned per extruder variant like the
|
|
// other filament variant settings.
|
|
TEST_CASE("Each filament sets the pressure advance of its extruder variant", "[MultiFilament]")
|
|
{
|
|
auto [nozzle_volume_type, filament, pressure_advance, adaptive_pressure_advance] = GENERATE(table<NozzleVolumeType, int, std::string, std::string>({
|
|
{ nvtStandard, 1, "0.021", "0.012" },
|
|
{ nvtHighFlow, 1, "0.037", "0.034" },
|
|
{ nvtHighFlow, 2, "0.043", "0.056" }, // filament 2 defines no High Flow variant
|
|
}));
|
|
const bool adaptive = GENERATE(false, true);
|
|
DYNAMIC_SECTION(get_nozzle_volume_type_string(nozzle_volume_type) << " nozzle, filament " << filament << (adaptive ? ", adaptive" : "")) {
|
|
DynamicPrintConfig config = multifilament_config(2, {
|
|
{ "gcode_flavor", "klipper" },
|
|
{ "extruder_variant_list", "Direct Drive Standard,Direct Drive High Flow" },
|
|
// filament 1 defines Standard (0.021) and High Flow (0.037), filament 2 Standard (0.043)
|
|
{ "filament_extruder_variant", "Direct Drive Standard;Direct Drive High Flow;Direct Drive Standard" },
|
|
{ "filament_self_index", "1,1,2" },
|
|
{ "enable_pressure_advance", "1,1,1" },
|
|
{ "pressure_advance", "0.021,0.037,0.043" },
|
|
{ "adaptive_pressure_advance", adaptive ? "1,1,1" : "0,0,0" },
|
|
{ "sparse_infill_filament_id", filament },
|
|
{ "internal_solid_filament_id", filament },
|
|
{ "top_surface_filament_id", filament },
|
|
{ "bottom_surface_filament_id", filament },
|
|
{ "outer_wall_filament_id", filament },
|
|
{ "inner_wall_filament_id", filament },
|
|
{ "enable_prime_tower", 0 },
|
|
{ "skirt_loops", 0 },
|
|
{ "brim_type", "no_brim" },
|
|
// custom G-code indexes the per-filament arrays by filament
|
|
{ "machine_start_gcode", "; start pressure advance {pressure_advance[initial_extruder]}" },
|
|
});
|
|
config.option<ConfigOptionStrings>("adaptive_pressure_advance_model")->values = {
|
|
constant_pressure_advance_model("0.012"), constant_pressure_advance_model("0.034"), constant_pressure_advance_model("0.056") };
|
|
config.option<ConfigOptionEnumsGeneric>("nozzle_volume_type", true)->values = { nozzle_volume_type };
|
|
const std::string gcode = slice({ cube(20) }, config);
|
|
|
|
// The toolchange sets the variant's pressure advance; with adaptive pressure advance, the
|
|
// prediction of the variant's model then replaces it.
|
|
std::set<std::string> expected{ pressure_advance };
|
|
if (adaptive)
|
|
expected.insert(adaptive_pressure_advance);
|
|
CHECK(pressure_advance_values(gcode) == expected);
|
|
CHECK(gcode.find("; start pressure advance " + pressure_advance + "\n") != std::string::npos);
|
|
}
|
|
}
|
|
|
|
// On a printer with two extruders, a filament takes the pressure advance of the variant of the extruder
|
|
// it is mapped to, whichever filament and extruder that is.
|
|
TEST_CASE("Each filament sets the pressure advance of its extruder variant on a two-extruder printer", "[MultiFilament]")
|
|
{
|
|
auto [filament, extruder, pressure_advance, adaptive_pressure_advance] = GENERATE(table<int, int, std::string, std::string>({
|
|
{ 1, 1, "0.021", "0.012" },
|
|
{ 1, 2, "0.037", "0.034" },
|
|
{ 2, 1, "0.043", "0.056" },
|
|
{ 2, 2, "0.049", "0.078" },
|
|
}));
|
|
const bool adaptive = GENERATE(false, true);
|
|
DYNAMIC_SECTION("filament " << filament << " on extruder " << extruder << (adaptive ? ", adaptive" : "")) {
|
|
// the other filament goes on the other extruder
|
|
const std::string filament_map = filament == 1 ? std::to_string(extruder) + "," + std::to_string(3 - extruder) :
|
|
std::to_string(3 - extruder) + "," + std::to_string(extruder);
|
|
DynamicPrintConfig config = multifilament_config(2, {
|
|
{ "gcode_flavor", "klipper" },
|
|
{ "single_extruder_multi_material", 0 },
|
|
{ "nozzle_diameter", "0.4,0.4" },
|
|
{ "extruder_printable_height", "0,0" },
|
|
// extruder 1 has a Standard nozzle, extruder 2 a High Flow one
|
|
{ "printer_extruder_id", "1,2" },
|
|
{ "printer_extruder_variant", "Direct Drive Standard;Direct Drive High Flow" },
|
|
{ "extruder_variant_list", "Direct Drive Standard;Direct Drive High Flow" },
|
|
{ "filament_map", filament_map },
|
|
// both filaments define Standard and High Flow
|
|
{ "filament_extruder_variant", "Direct Drive Standard;Direct Drive High Flow;Direct Drive Standard;Direct Drive High Flow" },
|
|
{ "filament_self_index", "1,1,2,2" },
|
|
{ "enable_pressure_advance", "1,1,1,1" },
|
|
{ "pressure_advance", "0.021,0.037,0.043,0.049" },
|
|
{ "adaptive_pressure_advance", adaptive ? "1,1,1,1" : "0,0,0,0" },
|
|
{ "sparse_infill_filament_id", filament },
|
|
{ "internal_solid_filament_id", filament },
|
|
{ "top_surface_filament_id", filament },
|
|
{ "bottom_surface_filament_id", filament },
|
|
{ "outer_wall_filament_id", filament },
|
|
{ "inner_wall_filament_id", filament },
|
|
{ "enable_prime_tower", 0 },
|
|
{ "skirt_loops", 0 },
|
|
{ "brim_type", "no_brim" },
|
|
// custom G-code indexes the per-filament arrays by filament
|
|
{ "machine_start_gcode", "; start pressure advance {pressure_advance[initial_extruder]}" },
|
|
});
|
|
config.option<ConfigOptionStrings>("adaptive_pressure_advance_model")->values = {
|
|
constant_pressure_advance_model("0.012"), constant_pressure_advance_model("0.034"),
|
|
constant_pressure_advance_model("0.056"), constant_pressure_advance_model("0.078") };
|
|
config.option<ConfigOptionEnumsGeneric>("extruder_type", true)->values = { etDirectDrive, etDirectDrive };
|
|
config.option<ConfigOptionEnumsGeneric>("nozzle_volume_type", true)->values = { nvtStandard, nvtHighFlow };
|
|
// keep the mapping above rather than grouping the filaments automatically
|
|
config.option<ConfigOptionEnum<FilamentMapMode>>("filament_map_mode", true)->value = fmmManual;
|
|
const std::string gcode = slice({ cube(20) }, config);
|
|
|
|
std::set<std::string> expected{ pressure_advance };
|
|
if (adaptive)
|
|
expected.insert(adaptive_pressure_advance);
|
|
CHECK(pressure_advance_values(gcode) == expected);
|
|
CHECK(gcode.find("; start pressure advance " + pressure_advance + "\n") != std::string::npos);
|
|
}
|
|
}
|
|
|
|
// The speeds, in percent, a G-code turns a fan on at: the part cooling fan for `M106 S`, the auxiliary
|
|
// fan for `M106 P2 S`.
|
|
static std::set<int> fan_speeds(const std::string &gcode, const std::string &command)
|
|
{
|
|
std::set<int> speeds;
|
|
std::istringstream stream(gcode);
|
|
for (std::string line; std::getline(stream, line);)
|
|
if (line.rfind(command, 0) == 0)
|
|
if (const int pwm = std::stoi(line.substr(command.size())); pwm > 0)
|
|
speeds.insert(int(std::lround(pwm * 100. / 255.)));
|
|
return speeds;
|
|
}
|
|
|
|
// The fan speeds and the recommended nozzle temperature range are tuned per extruder variant like the
|
|
// other filament variant settings.
|
|
TEST_CASE("Each filament cools with the fan speeds of its extruder variant", "[MultiFilament]")
|
|
{
|
|
auto [nozzle_volume_type, filament, fan_min_speed, fan_max_speed, additional_fan_speed, range_high] = GENERATE(table<NozzleVolumeType, int, int, int, int, int>({
|
|
{ nvtStandard, 1, 15, 25, 10, 240 },
|
|
{ nvtHighFlow, 1, 35, 45, 20, 260 },
|
|
{ nvtHighFlow, 2, 55, 65, 40, 280 }, // filament 2 defines no High Flow variant
|
|
}));
|
|
// Layers printed faster than slow_down_layer_time run the fan at its maximum speed, layers slower than
|
|
// fan_cooling_layer_time at its minimum.
|
|
const bool fast_layers = GENERATE(false, true);
|
|
DYNAMIC_SECTION(get_nozzle_volume_type_string(nozzle_volume_type) << " nozzle, filament " << filament << (fast_layers ? ", fast layers" : ", slow layers")) {
|
|
DynamicPrintConfig config = multifilament_config(2, {
|
|
{ "extruder_variant_list", "Direct Drive Standard,Direct Drive High Flow" },
|
|
// filament 1 defines Standard and High Flow, filament 2 Standard
|
|
{ "filament_extruder_variant", "Direct Drive Standard;Direct Drive High Flow;Direct Drive Standard" },
|
|
{ "filament_self_index", "1,1,2" },
|
|
{ "fan_min_speed", "15,35,55" },
|
|
{ "fan_max_speed", "25,45,65" },
|
|
{ "additional_cooling_fan_speed", "10,20,40" },
|
|
{ "nozzle_temperature_range_high", "240,260,280" },
|
|
{ "auxiliary_fan", 1 },
|
|
{ "reduce_fan_stop_start_freq", "1,1" },
|
|
{ "slow_down_layer_time", fast_layers ? "1000,1000" : "0,0" },
|
|
{ "fan_cooling_layer_time", fast_layers ? "1000,1000" : "0,0" },
|
|
{ "slow_down_for_layer_cooling", "0,0" },
|
|
{ "enable_overhang_bridge_fan", "0,0" },
|
|
{ "sparse_infill_filament_id", filament },
|
|
{ "internal_solid_filament_id", filament },
|
|
{ "top_surface_filament_id", filament },
|
|
{ "bottom_surface_filament_id", filament },
|
|
{ "outer_wall_filament_id", filament },
|
|
{ "inner_wall_filament_id", filament },
|
|
{ "enable_prime_tower", 0 },
|
|
{ "skirt_loops", 0 },
|
|
{ "brim_type", "no_brim" },
|
|
// custom G-code indexes the per-filament arrays by filament
|
|
{ "machine_start_gcode", "; start range high {nozzle_temperature_range_high[initial_extruder]}" },
|
|
});
|
|
config.option<ConfigOptionEnumsGeneric>("nozzle_volume_type", true)->values = { nozzle_volume_type };
|
|
const std::string gcode = slice({ cube(20) }, config);
|
|
|
|
CHECK(fan_speeds(gcode, "M106 S") == std::set<int>{ fast_layers ? fan_max_speed : fan_min_speed });
|
|
CHECK(fan_speeds(gcode, "M106 P2 S") == std::set<int>{ additional_fan_speed });
|
|
CHECK(gcode.find("; start range high " + std::to_string(range_high) + "\n") != std::string::npos);
|
|
}
|
|
}
|