mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-10-10 17:21:10 +00:00
A tool change added from the layer slider switches heads mid-print like a painted color, but the parallel-mode checks only looked at the filaments of the plate's objects, support and prime tower. A one-filament plate with a slider change to a copying head's filament passed them. They now include those tool changes, as the plate's warning badge already did. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1540 lines
82 KiB
C++
1540 lines
82 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());
|
|
}
|
|
}
|
|
|
|
// A tool change added from the layer slider switches heads mid-print just as a painted color
|
|
// does, so a parallel mode has to judge it with the plate's other filaments. Before it counted,
|
|
// a one-filament plate with a slider change to a copying head's filament passed validate().
|
|
TEST_CASE("A layer slider tool change counts as a color in a parallel mode", "[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_parallel_mode", "copy" } });
|
|
|
|
Slic3r::Model model;
|
|
Slic3r::Print print;
|
|
init_print({ cube(20) }, print, model, config);
|
|
std::vector<StringObjectException> warnings;
|
|
REQUIRE(print.validate(&warnings).string.empty());
|
|
|
|
// Filament 5 routes to head 1, which copies the primary in this mode.
|
|
model.plates_custom_gcodes[model.curr_plate_index].gcodes.push_back(
|
|
{ 5.0, CustomGCode::Type::ToolChange, 5, "", "" });
|
|
print.apply(model, print.full_print_config());
|
|
REQUIRE_FALSE(print.validate(&warnings).string.empty());
|
|
}
|
|
|
|
// 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);
|
|
}
|
|
}
|