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>
388 lines
20 KiB
C++
388 lines
20 KiB
C++
#include <catch2/catch_all.hpp>
|
|
|
|
#include "libslic3r/Config.hpp"
|
|
#include "libslic3r/PrintConfig.hpp"
|
|
|
|
#include "test_helpers.hpp"
|
|
#include "test_utils.hpp"
|
|
|
|
#include <cstddef>
|
|
#include <string>
|
|
#include <vector>
|
|
|
|
using namespace Slic3r;
|
|
using namespace Slic3r::Test;
|
|
|
|
// The IMEX mode placeholders ({imex_mode}, {imex_mode_index}, {imex_mode_gcode}) and the mode
|
|
// script that consumes them are set up in GCode.cpp's _do_export, immediately before
|
|
// machine_start_gcode is processed. Two contracts live in that ordering:
|
|
// * the three placeholders exist for every G-code script the export runs, IMEX or not;
|
|
// * the mode script is itself run through the placeholder parser, and it runs FIRST, so a
|
|
// {global ...} it declares is in scope by the time machine_start_gcode is processed.
|
|
// Nothing else in the suite exercises them.
|
|
|
|
// Offset of the first line of `gcode` that equals `expected` once trailing whitespace is
|
|
// stripped, or npos. Whole-line matching is deliberate on both counts: prefix matching would
|
|
// let ";IMEX_MODE_INDEX:2" pass for an emitted index of 20, and the config block the exporter
|
|
// appends to every file repeats machine_start_gcode and imex_mode_gcodes verbatim (as
|
|
// "; key = value"), which would make any substring search for the templates below trivially
|
|
// true and every "not emitted" assertion trivially false.
|
|
static std::size_t find_exact_line(const std::string &gcode, const std::string &expected)
|
|
{
|
|
std::size_t pos = 0;
|
|
while (pos <= gcode.size()) {
|
|
const std::size_t eol = gcode.find('\n', pos);
|
|
const std::size_t end = (eol == std::string::npos) ? gcode.size() : eol;
|
|
std::string line = gcode.substr(pos, end - pos);
|
|
while (!line.empty() && (line.back() == '\r' || line.back() == ' ' || line.back() == '\t'))
|
|
line.pop_back();
|
|
if (line == expected)
|
|
return pos;
|
|
if (eol == std::string::npos)
|
|
break;
|
|
pos = eol + 1;
|
|
}
|
|
return std::string::npos;
|
|
}
|
|
|
|
static bool has_line(const std::string &gcode, const std::string &expected)
|
|
{
|
|
return find_exact_line(gcode, expected) != std::string::npos;
|
|
}
|
|
|
|
// Any command line -- not a comment -- containing `token`. Used where the assertion is that a
|
|
// carriage was addressed at all, rather than with a particular value: the config block the
|
|
// exporter appends restates every setting as "; key = value", so the comment lines have to be
|
|
// excluded or a search for a setting's own name matches itself.
|
|
static bool has_command_containing(const std::string &gcode, const std::string &token)
|
|
{
|
|
std::size_t pos = 0;
|
|
while (pos < gcode.size()) {
|
|
const std::size_t eol = gcode.find('\n', pos);
|
|
const std::string line = gcode.substr(pos, (eol == std::string::npos ? gcode.size() : eol) - pos);
|
|
if (!line.empty() && line.front() != ';' && line.find(token) != std::string::npos)
|
|
return true;
|
|
if (eol == std::string::npos)
|
|
break;
|
|
pos = eol + 1;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// Mode scripts. Free of placeholders so that a test can tell "the script was emitted" apart
|
|
// from "the script was expanded"; the expansion case below supplies its own template.
|
|
static const char *kCopyScript = "SET_DUAL_CARRIAGE MODE=COPY";
|
|
static const char *kQuadScript = "SET_QUAD_CARRIAGE MODE=COPY";
|
|
|
|
// Reports all three placeholders on their own lines so find_exact_line can pin each value.
|
|
static const char *kReportStartGcode = ";IMEX_MODE:{imex_mode}\n"
|
|
";IMEX_MODE_INDEX:{imex_mode_index}\n"
|
|
";IMEX_MODE_GCODE:{imex_mode_gcode}\n";
|
|
|
|
// Shared IMEX printer geometry: 7 logical extruders across 4 physical heads, three modes.
|
|
// 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 IMEX quietly
|
|
// stops happening at all. Mirrors imex_7x4_printer() in test_multifilament.cpp; `iq-copy` is
|
|
// carried over from the IQEX case there, which is a mode combination known to slice.
|
|
//
|
|
// imex_mode_gcodes is left to each test: the position a mode's script occupies in this list is
|
|
// exactly what {imex_mode_index} is asserted against.
|
|
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" },
|
|
// Authored 1..n, as every production path does: PresetBundle writes it in
|
|
// full_fff_config(), and config load synthesizes it when absent. The all-1s default is
|
|
// not a neutral placeholder -- get_config_index_base() keys the slot-to-column lookup
|
|
// on it, and with every entry equal each per-filament vector collapses to filament 1's
|
|
// value, leaving a per-slot assertion comparing a value against itself.
|
|
{ "filament_self_index", "1,2,3,4,5,6,7" },
|
|
{ "is_imex", "1" },
|
|
{ "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" },
|
|
{ "skirt_loops", "0" },
|
|
{ "brim_type", "no_brim" },
|
|
// Klipper skips the bed/extruder temperature block that would otherwise be written
|
|
// between the mode script and machine_start_gcode, so the ordering assertion below
|
|
// compares the two scripts and nothing else.
|
|
{ "gcode_flavor", "klipper" },
|
|
});
|
|
size_flush_to_nozzles(config);
|
|
}
|
|
|
|
// Set the mode scripts positionally, one per name in imex_mode_names. Assigning the vector
|
|
// beats set_deserialize_strict here: a coStrings round trip through a ';'-joined string has to
|
|
// quote and escape empty entries and embedded braces, and the empty first entry (primary, which
|
|
// deliberately has no script) is exactly the entry that would be lost.
|
|
static void set_mode_gcodes(DynamicPrintConfig &config, const std::vector<std::string> &gcodes)
|
|
{
|
|
config.option<ConfigOptionStrings>("imex_mode_gcodes", true)->values = gcodes;
|
|
}
|
|
|
|
// 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. Filament 1 is logical slot 0, which pem routes to head 0 -- the head every
|
|
// mode here declares Primary -- so Print::validate() accepts the plate.
|
|
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_mode} is the plate's active mode, {imex_mode_index} its position in imex_mode_names and
|
|
// {imex_mode_gcode} the raw script at that same position. Two modes are exercised because a
|
|
// single one cannot tell a resolved index apart from a constant: `copy` sits at 1 and `iq-copy`
|
|
// at 2, so an index that stopped tracking imex_mode_names fails at least one of them.
|
|
TEST_CASE("IMEX mode placeholders resolve to the active mode's name, index and script",
|
|
"[ImexModeGcode][IMEX]")
|
|
{
|
|
auto [mode, mode_index, mode_script] = GENERATE(table<std::string, int, std::string>({
|
|
{ "copy", 1, kCopyScript },
|
|
{ "iq-copy", 2, kQuadScript },
|
|
}));
|
|
INFO("active mode: " << mode);
|
|
|
|
DynamicPrintConfig config = multifilament_config(7);
|
|
imex_7x4_printer(config);
|
|
all_regions_on_filament(config, 1);
|
|
set_mode_gcodes(config, { "", kCopyScript, kQuadScript });
|
|
config.set_deserialize_strict({
|
|
{ "imex_parallel_mode", mode },
|
|
{ "machine_start_gcode", kReportStartGcode },
|
|
});
|
|
|
|
const std::string gcode = slice({ cube(20) }, config);
|
|
|
|
CHECK(has_line(gcode, ";IMEX_MODE:" + mode));
|
|
CHECK(has_line(gcode, ";IMEX_MODE_INDEX:" + std::to_string(mode_index)));
|
|
CHECK(has_line(gcode, ";IMEX_MODE_GCODE:" + mode_script));
|
|
// The script for the active mode -- and only that one -- reaches the file.
|
|
CHECK(has_line(gcode, mode_script));
|
|
CHECK_FALSE(has_line(gcode, mode == "copy" ? kQuadScript : kCopyScript));
|
|
}
|
|
|
|
// The mode script is a G-code template, not a literal: it goes through
|
|
// placeholder_parser_process like machine_start_gcode does. Because the three IMEX placeholders
|
|
// are set before that call, the script can also read its own mode -- which is what
|
|
// distinguishes "the script is processed" from "the script is processed too early to see them".
|
|
TEST_CASE("A placeholder inside an IMEX mode's G-code script is expanded",
|
|
"[ImexModeGcode][IMEX]")
|
|
{
|
|
const std::string templ = "SET_DUAL_CARRIAGE MODE={imex_mode} INDEX={imex_mode_index}"
|
|
" TEMP={nozzle_temperature_initial_layer[0]}";
|
|
|
|
DynamicPrintConfig config = multifilament_config(7);
|
|
imex_7x4_printer(config);
|
|
all_regions_on_filament(config, 1);
|
|
set_mode_gcodes(config, { "", templ, kQuadScript });
|
|
config.set_deserialize_strict({
|
|
{ "imex_parallel_mode", "copy" },
|
|
// The multi-extruder normalization collapses the per-filament temperature vector to a
|
|
// single value, so [0] is this literal 200 rather than a per-slot default.
|
|
{ "nozzle_temperature_initial_layer", "200" },
|
|
{ "machine_start_gcode", ";START\n" },
|
|
});
|
|
|
|
const std::string gcode = slice({ cube(20) }, config);
|
|
|
|
CHECK(has_line(gcode, "SET_DUAL_CARRIAGE MODE=copy INDEX=1 TEMP=200"));
|
|
// If the script were written straight to the file the braces would survive verbatim.
|
|
CHECK_FALSE(has_line(gcode, templ));
|
|
}
|
|
|
|
// The ordering guarantee, and the reason the placeholder block sits where it does: the mode
|
|
// script is processed BEFORE machine_start_gcode, so a {global} the mode declares is in scope
|
|
// for machine_start_gcode. Reorder the two and machine_start_gcode references an undefined
|
|
// variable, which the placeholder parser raises as an error out of the export -- so a
|
|
// regression here fails this test whether the export throws or merely writes nothing useful.
|
|
TEST_CASE("A global declared in the IMEX mode G-code is visible to machine_start_gcode",
|
|
"[ImexModeGcode][IMEX]")
|
|
{
|
|
DynamicPrintConfig config = multifilament_config(7);
|
|
imex_7x4_printer(config);
|
|
all_regions_on_filament(config, 1);
|
|
set_mode_gcodes(config, { "", std::string("{global imex_carriage_count = 2}") + kCopyScript, kQuadScript });
|
|
config.set_deserialize_strict({
|
|
{ "imex_parallel_mode", "copy" },
|
|
{ "machine_start_gcode", ";IMEX_CARRIAGES:{imex_carriage_count}\n" },
|
|
});
|
|
|
|
const std::string gcode = slice({ cube(20) }, config);
|
|
|
|
// The value is the mode script's, not a default: nothing else in this config defines it.
|
|
const std::size_t start_gcode_at = find_exact_line(gcode, ";IMEX_CARRIAGES:2");
|
|
REQUIRE(start_gcode_at != std::string::npos);
|
|
|
|
// ...and the two land in the file in that same order. Ordering is the contract here, so it
|
|
// is asserted directly rather than left implicit in the global resolving.
|
|
const std::size_t mode_script_at = find_exact_line(gcode, kCopyScript);
|
|
REQUIRE(mode_script_at != std::string::npos);
|
|
CHECK(mode_script_at < start_gcode_at);
|
|
}
|
|
|
|
// Primary is single-carriage printing: it names a real mode (position 0 of imex_mode_names, not
|
|
// the not-found fallback) but carries no script, so the export must add nothing. This is what
|
|
// keeps the mode machinery out of the way of an IMEX printer that is not printing in parallel.
|
|
TEST_CASE("Primary-mode IMEX prints emit no mode G-code", "[ImexModeGcode][IMEX]")
|
|
{
|
|
DynamicPrintConfig config = multifilament_config(7);
|
|
imex_7x4_printer(config);
|
|
all_regions_on_filament(config, 1);
|
|
set_mode_gcodes(config, { "", kCopyScript, kQuadScript });
|
|
config.set_deserialize_strict({
|
|
{ "imex_parallel_mode", "primary" },
|
|
{ "machine_start_gcode", kReportStartGcode },
|
|
});
|
|
|
|
const std::string gcode = slice({ cube(20) }, config);
|
|
|
|
CHECK(has_line(gcode, ";IMEX_MODE:primary"));
|
|
CHECK(has_line(gcode, ";IMEX_MODE_INDEX:0"));
|
|
CHECK(has_line(gcode, ";IMEX_MODE_GCODE:"));
|
|
CHECK_FALSE(has_line(gcode, kCopyScript));
|
|
CHECK_FALSE(has_line(gcode, kQuadScript));
|
|
}
|
|
|
|
// A plate stores its IMEX mode as the mode's *name* and the exporter resolves that name against
|
|
// the printer's imex_mode_names at slice time, so the two drift apart whenever the mode is
|
|
// renamed or deleted after a plate was set to it, or the project is opened against a printer
|
|
// preset that names its modes differently. A name matching no row must be treated as Primary --
|
|
// not as a parallel mode whose every name-keyed lookup happens to come back empty, which is how
|
|
// it used to behave: no mode script (there is none to find), no active-tool roster, and yet the
|
|
// parallel branches taken all the way through the export.
|
|
//
|
|
// Primary is a row like any other, so falling back to it means running its script too; a
|
|
// non-empty script is used here so "fell back to Primary" is distinguishable from "resolved
|
|
// nothing and emitted nothing".
|
|
TEST_CASE("An IMEX mode the printer no longer defines falls back to Primary",
|
|
"[ImexModeGcode][IMEX]")
|
|
{
|
|
static const char *kPrimaryScript = "SET_DUAL_CARRIAGE MODE=PRIMARY";
|
|
|
|
DynamicPrintConfig config = multifilament_config(7);
|
|
imex_7x4_printer(config);
|
|
all_regions_on_filament(config, 1);
|
|
set_mode_gcodes(config, { kPrimaryScript, kCopyScript, kQuadScript });
|
|
config.set_deserialize_strict({
|
|
// A name no row in imex_mode_names carries -- what `copy` becomes once it is renamed.
|
|
{ "imex_parallel_mode", "copy-renamed" },
|
|
{ "machine_start_gcode", kReportStartGcode },
|
|
});
|
|
|
|
const std::string gcode = slice({ cube(20) }, config);
|
|
|
|
// The placeholders report the mode the export actually ran, so the stale name must not
|
|
// survive into them -- a mode script keyed on {imex_mode} would otherwise set the printer
|
|
// up for a mode the slicer did not slice for.
|
|
CHECK(has_line(gcode, ";IMEX_MODE:primary"));
|
|
CHECK(has_line(gcode, ";IMEX_MODE_INDEX:0"));
|
|
CHECK(has_line(gcode, ";IMEX_MODE_GCODE:" + std::string(kPrimaryScript)));
|
|
|
|
// Primary's own script runs; neither parallel mode's does.
|
|
CHECK(has_line(gcode, kPrimaryScript));
|
|
CHECK_FALSE(has_line(gcode, kCopyScript));
|
|
CHECK_FALSE(has_line(gcode, kQuadScript));
|
|
}
|
|
|
|
// The whole block is gated on is_imex. A printer preset can carry a filled-in mode table and
|
|
// still not be an IMEX machine -- and a plate config can still carry a stale imex_parallel_mode
|
|
// -- so with the flag off the placeholders stay empty and no mode script is injected. Run on an
|
|
// ordinary single-extruder printer on purpose: the guard is one boolean, and the multi-head
|
|
// geometry the other cases need would only add ways for this one to fail for another reason.
|
|
TEST_CASE("A printer with IMEX disabled resolves the mode placeholders to nothing",
|
|
"[ImexModeGcode][IMEX]")
|
|
{
|
|
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
|
set_mode_gcodes(config, { "", kCopyScript, kQuadScript });
|
|
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" },
|
|
{ "is_imex", "0" },
|
|
{ "imex_parallel_mode", "copy" },
|
|
{ "skirt_loops", "0" },
|
|
{ "brim_type", "no_brim" },
|
|
{ "gcode_flavor", "klipper" },
|
|
{ "machine_start_gcode", kReportStartGcode },
|
|
});
|
|
|
|
const std::string gcode = slice({ cube(20) }, config);
|
|
|
|
CHECK(has_line(gcode, ";IMEX_MODE:"));
|
|
CHECK(has_line(gcode, ";IMEX_MODE_INDEX:0"));
|
|
CHECK(has_line(gcode, ";IMEX_MODE_GCODE:"));
|
|
CHECK_FALSE(has_line(gcode, kCopyScript));
|
|
CHECK_FALSE(has_line(gcode, kQuadScript));
|
|
}
|
|
|
|
// The placeholders are set unconditionally, ahead of the is_imex check, so they are defined for
|
|
// every printer. A single-extruder preset that mentions {imex_mode} must expand it to an empty
|
|
// string rather than fail the export on an unknown variable.
|
|
TEST_CASE("IMEX mode placeholders are defined on an ordinary single-extruder printer",
|
|
"[ImexModeGcode][IMEX]")
|
|
{
|
|
const std::string gcode = slice({ cube(20) }, {
|
|
{ "skirt_loops", "0" },
|
|
{ "brim_type", "no_brim" },
|
|
{ "gcode_flavor", "klipper" },
|
|
{ "machine_start_gcode", kReportStartGcode },
|
|
});
|
|
|
|
CHECK(has_line(gcode, ";IMEX_MODE:"));
|
|
CHECK(has_line(gcode, ";IMEX_MODE_INDEX:0"));
|
|
CHECK(has_line(gcode, ";IMEX_MODE_GCODE:"));
|
|
}
|
|
|
|
// In a parallel mode no tool changes occur, so every active carriage has to be addressed
|
|
// explicitly: once before the print for pressure advance, and again at the second layer for the
|
|
// drop from the initial-layer temperature. Nothing else in the suite covers either emission, and
|
|
// a carriage that falls out of one of those loops emits nothing at all rather than emitting
|
|
// something wrong -- it silently holds the initial-layer temperature, or whatever pressure
|
|
// advance the firmware was last told, for the whole job. So the property worth pinning is that
|
|
// the set of carriages addressed is exactly the set the mode declares active.
|
|
TEST_CASE("Every carriage a parallel mode declares active is addressed, and no other",
|
|
"[ImexModeGcode][IMEX]")
|
|
{
|
|
DynamicPrintConfig config = multifilament_config(7);
|
|
imex_7x4_printer(config);
|
|
all_regions_on_filament(config, 1);
|
|
set_mode_gcodes(config, { "", kCopyScript, kQuadScript });
|
|
config.set_deserialize_strict({
|
|
{ "imex_parallel_mode", "copy" },
|
|
{ "enable_pressure_advance", "1,1,1,1,1,1,1" },
|
|
// Distinct per slot, so each assertion below names one filament and no other. copy
|
|
// mode's secondary rides physical 1, which physical_extruder_map routes to the fifth
|
|
// slot -- 0.05 and 244, not the primary's 0.01 and 240.
|
|
{ "pressure_advance", "0.010,0.020,0.030,0.040,0.050,0.060,0.070" },
|
|
{ "nozzle_temperature_initial_layer", "235,235,235,235,235,235,235" },
|
|
{ "nozzle_temperature", "240,241,242,243,244,245,246" },
|
|
});
|
|
|
|
const std::string gcode = slice({ cube(20) }, config);
|
|
|
|
// imex_mode_active_tools declares copy as "0:P,1:C", so exactly these two carriages run, and
|
|
// each transitions to the temperature of the filament its own head is routed to. The other
|
|
// two heads this printer has must stay untouched.
|
|
CHECK(has_command_containing(gcode, "M104 S240 T0 ; set IMEX tool temperature"));
|
|
CHECK(has_command_containing(gcode, "M104 S244 T1 ; set IMEX tool temperature"));
|
|
CHECK_FALSE(has_command_containing(gcode, "T2 ; set IMEX tool temperature"));
|
|
CHECK_FALSE(has_command_containing(gcode, "T3 ; set IMEX tool temperature"));
|
|
|
|
// The companion loop, which skips the primary because set_extruder() has already emitted it
|
|
// on the ordinary path. The qualified line is therefore the one that belongs to this loop:
|
|
// Klipper names the first carriage as the unnumbered "extruder" and the rest by index, so
|
|
// dropping the secondary removes `extruder1` and leaves `extruder` in place.
|
|
CHECK(has_command_containing(gcode, "SET_PRESSURE_ADVANCE ADVANCE=0.05 EXTRUDER=extruder1;"));
|
|
CHECK(has_command_containing(gcode, "SET_PRESSURE_ADVANCE ADVANCE=0.01 EXTRUDER=extruder;"));
|
|
CHECK_FALSE(has_command_containing(gcode, "EXTRUDER=extruder2;"));
|
|
}
|