mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-10-13 19:01:21 +00:00
Merge main
This commit is contained in:
@@ -20,7 +20,10 @@
|
||||
#include "libslic3r/Model.hpp"
|
||||
#include "libslic3r/TriangleMesh.hpp"
|
||||
#include "libslic3r/Print.hpp"
|
||||
#include <libslic3r/PrintBase.hpp>
|
||||
#include <libslic3r/CustomGCode.hpp>
|
||||
#include <limits>
|
||||
#include <map>
|
||||
#include <optional>
|
||||
#include <regex>
|
||||
#include <set>
|
||||
@@ -762,6 +765,587 @@ TEST_CASE("Multi-extruder slice stays in bounds with a short max_layer_height",
|
||||
}
|
||||
|
||||
|
||||
// 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.
|
||||
@@ -947,6 +1531,44 @@ TEST_CASE("Each filament sets the pressure advance of its extruder variant on a
|
||||
}
|
||||
}
|
||||
|
||||
// Without a dynamic nozzle map the hotend placeholders carry no hotend index (-1) on a BBL printer: Bambu
|
||||
// firmware reads an explicit index as a request for the Filament Track Switch and rejects the job. Any other
|
||||
// printer gets the extruder index of the filament.
|
||||
TEST_CASE("Hotend placeholders resolve to -1 on a BBL printer and to the extruder index elsewhere", "[MultiFilament]")
|
||||
{
|
||||
// filament 1 prints the walls on extruder 2, filament 2 the infill on extruder 1
|
||||
auto [is_bbl, start, changes] = GENERATE(table<bool, std::string, std::set<std::string>>({
|
||||
{ true, "; hotend start filament 0: -1 -1 -1",
|
||||
{ "; hotend change filament 0: -1 -1", "; hotend change filament 1: -1 -1" } },
|
||||
// the first change loads filament 1 with no filament before it, so there is no outgoing extruder
|
||||
{ false, "; hotend start filament 0: 1 1 1",
|
||||
{ "; hotend change filament -1: -1 1", "; hotend change filament 0: 1 0", "; hotend change filament 1: 0 1" } },
|
||||
}));
|
||||
DynamicPrintConfig config = two_extruder_pressure_advance_config("2,1", "0,0,0,0", 1, 2);
|
||||
config.set_key_value("machine_start_gcode", new ConfigOptionString(
|
||||
"; hotend start filament [initial_no_support_extruder]: [initial_no_support_hotend] [current_hotend] {first_non_support_hotend[0]}"));
|
||||
config.set_key_value("change_filament_gcode", new ConfigOptionString(
|
||||
"; hotend change filament [current_filament_id]: [current_hotend] [next_hotend]"));
|
||||
Print print;
|
||||
print.is_BBL_printer() = is_bbl;
|
||||
Model model;
|
||||
init_print({ cube(20) }, print, model, config);
|
||||
const std::string gcode = Slic3r::Test::gcode(print);
|
||||
|
||||
INFO("BBL printer: " << is_bbl);
|
||||
std::vector<std::string> starts;
|
||||
std::set<std::string> found;
|
||||
std::istringstream stream(gcode);
|
||||
for (std::string line; std::getline(stream, line);) {
|
||||
if (line.rfind("; hotend start ", 0) == 0)
|
||||
starts.push_back(line);
|
||||
else if (line.rfind("; hotend change ", 0) == 0)
|
||||
found.insert(line);
|
||||
}
|
||||
CHECK(starts == std::vector<std::string>{ start });
|
||||
CHECK(found == changes);
|
||||
}
|
||||
|
||||
// Filament 1 prints the walls on extruder 1 (variant index 0), filament 2 the infill on extruder 2 (variant index 3).
|
||||
TEST_CASE("Adaptive pressure advance on one extruder leaves the other extruder's pressure advance alone", "[MultiFilament]")
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user