mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-07-31 23:02:06 +00:00
fff_print: a maintainable testing framework (proposal + coverage) (#14426)
* fix: initialize Print::m_isBBLPrinter
Built outside the GUI/CLI (headless tests, embedded use) the member was read
uninitialized: is_BBL_printer()/wipe_tower_type() feed it into ToolOrdering,
which then non-deterministically dropped per-feature filament assignments.
Default it to false, the value the GUI and CLI already assign for non-Bambu
printers.
* docs(test): add the fff_print testing contract
tests/fff_print/README.md codifies how the suite is organized: one file per
subsystem (each owning both in-memory and emitted-G-code assertions), flat
behavioral test names with a single [Subsystem] tag, a robust-tests guide,
the shared helpers, and an add-a-test checklist. Linked from tests/CLAUDE.md.
* test(fff_print): reorganize the suite to the contract and add coverage
Bring every subsystem into one file per the README: rename the test_data
harness to test_helpers; consolidate skirt/brim; split multi-filament and
cooling into their own files; disperse the test_printgcode grab-bag and the
end-to-end smoke scenario into focused tests; fold test_gcode into
test_gcodewriter. Standardize names and tags, align cube tests on the cube()
helper, and de-qualify the flagship files.
New coverage: multi-filament per-feature and per-object routing; a skirt/brim
behavior matrix (the #14333 rework, including brim ears, with regression
coverage for #14319 and #14366); resolved extrusion-width and config
comments; custom-G-code placeholders; fan control and speed-marker
consumption.
Re-enable three slice tests previously tagged [NotWorking]: the clipper
"Coordinate outside allowed range" error that disabled them was specific to a
past CI runner environment and no longer reproduces.
* test(fff_print): tag arm64-flaky skirt/brim tests NotWorking
Four skirt/brim slice tests intermittently throw ClipperLib's "Coordinate
outside allowed range" on the macOS and Windows arm64 CI toolchains (an FP
divergence, not a slicing bug; see PR #14207). Linux x86_64 and aarch64 are
unaffected. Tag them [NotWorking] so ctest -LE NotWorking skips them.
* test(fff_print): re-enable the arm64 skirt/brim tests
These were tagged [NotWorking] as a stopgap when myfork's daily-driver build
combined them with the cross-platform CI on a base that predated upstream's
m_origin fix (99dea01cc3). With upstream merged in, Print::m_origin is
initialized and the "Coordinate outside allowed range" throw is gone, so the
tests pass on macOS/Windows arm64. Drop the tags.
This commit is contained in:
@@ -9,190 +9,276 @@
|
||||
|
||||
#include <cmath>
|
||||
|
||||
#include "test_data.hpp" // get access to init_print, etc
|
||||
#include "test_helpers.hpp" // get access to init_print, etc
|
||||
|
||||
using namespace Slic3r::Test;
|
||||
using namespace Slic3r;
|
||||
|
||||
/// Helper method to find the tool used for the brim (always the first extrusion).
|
||||
[[maybe_unused]] static int get_brim_tool(const std::string &gcode)
|
||||
// Distinct brim regions (combine_brims merges touching brims into one covering >1 object).
|
||||
static int brim_count(const Print &print)
|
||||
{
|
||||
int brim_tool = -1;
|
||||
int tool = -1;
|
||||
GCodeReader parser;
|
||||
parser.parse_buffer(gcode, [&tool, &brim_tool] (Slic3r::GCodeReader &self, const Slic3r::GCodeReader::GCodeLine &line)
|
||||
{
|
||||
// if the command is a T command, set the current tool
|
||||
if (boost::starts_with(line.cmd(), "T")) {
|
||||
tool = atoi(line.cmd().data() + 1);
|
||||
} else if (line.cmd() == "G1" && line.extruding(self) && line.dist_XY(self) > 0 && brim_tool < 0) {
|
||||
brim_tool = tool;
|
||||
int n = 0;
|
||||
for (const auto &group : print.skirt_brim_groups())
|
||||
n += (int) group.brims.size();
|
||||
return n;
|
||||
}
|
||||
|
||||
// Total brim loops across all objects.
|
||||
static size_t brim_loop_count(Print &print)
|
||||
{
|
||||
size_t n = 0;
|
||||
for (const auto &kv : print.get_brimMap())
|
||||
n += kv.second.items_count();
|
||||
return n;
|
||||
}
|
||||
|
||||
// The span is skirt_height layers, or every layer when a draft shield is on (forced even at
|
||||
// height 0); per-object skirts are rejected in By object printing (no room between objects).
|
||||
TEST_CASE("Skirt is emitted once per layer it spans", "[SkirtBrim]")
|
||||
{
|
||||
const int object_layers = 100; // 20mm cube at 0.2mm layers
|
||||
const char *skirt_type = GENERATE("combined", "perobject");
|
||||
const char *print_seq = GENERATE("by layer", "by object");
|
||||
const char *draft_shield = GENERATE("disabled", "enabled");
|
||||
const int skirt_height = GENERATE(0, 1, 3);
|
||||
|
||||
DYNAMIC_SECTION(skirt_type << " | " << print_seq << " | draft=" << draft_shield << " | height=" << skirt_height) {
|
||||
auto do_slice = [&] {
|
||||
return slice_two_cubes_arranged({
|
||||
{ "skirt_loops", 1 },
|
||||
{ "skirt_height", skirt_height },
|
||||
{ "skirt_distance", 3 },
|
||||
{ "skirt_type", skirt_type },
|
||||
{ "draft_shield", draft_shield },
|
||||
{ "print_sequence", print_seq },
|
||||
{ "layer_height", 0.2 },
|
||||
});
|
||||
};
|
||||
const bool draft = std::string(draft_shield) == "enabled";
|
||||
const bool has_skirt = draft || skirt_height > 0;
|
||||
const bool unsafe_by_object = std::string(skirt_type) == "perobject"
|
||||
&& std::string(print_seq) == "by object" && has_skirt;
|
||||
|
||||
if (unsafe_by_object) {
|
||||
REQUIRE_THROWS(do_slice());
|
||||
} else {
|
||||
const int expected_layers = draft ? object_layers : skirt_height;
|
||||
CHECK(role_passes(do_slice(), "skirt") == expected_layers);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Each per-object skirt prints right before its own object, so distant objects yield two
|
||||
// non-contiguous skirt passes; close objects group into a single skirt.
|
||||
TEST_CASE("Per-object skirts group when objects are close", "[SkirtBrim]")
|
||||
{
|
||||
auto [gap, expected_skirts] = GENERATE(table<double, int>({ { 5.0, 1 }, { 60.0, 2 } }));
|
||||
DYNAMIC_SECTION("gap=" << gap) {
|
||||
const std::string gcode = slice_two_cubes_apart(gap, {
|
||||
{ "skirt_loops", 1 },
|
||||
{ "skirt_height", 1 },
|
||||
{ "skirt_distance", 3 },
|
||||
{ "skirt_type", "perobject" },
|
||||
{ "print_sequence", "by layer" },
|
||||
{ "layer_height", 0.2 },
|
||||
});
|
||||
CHECK(role_passes(gcode, "skirt") == expected_skirts);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("Combine brims merges touching brims", "[SkirtBrim]")
|
||||
{
|
||||
auto [gap, combine, expected_brims] = GENERATE(table<double, int, int>({
|
||||
{ 5.0, 1, 1 }, // touching + combine -> one merged brim
|
||||
{ 5.0, 0, 2 }, // touching, no combine -> separate
|
||||
{ 60.0, 1, 2 }, // far apart -> nothing to merge
|
||||
}));
|
||||
DYNAMIC_SECTION("gap=" << gap << " combine_brims=" << combine) {
|
||||
Print print;
|
||||
Model model;
|
||||
place_two_cubes_apart(gap, {
|
||||
{ "skirt_loops", 1 },
|
||||
{ "skirt_height", 1 },
|
||||
{ "skirt_distance", 3 },
|
||||
{ "skirt_type", "perobject" },
|
||||
{ "print_sequence", "by layer" },
|
||||
{ "brim_type", "outer_only" },
|
||||
{ "brim_width", 5 },
|
||||
{ "combine_brims", combine },
|
||||
{ "layer_height", 0.2 },
|
||||
}, print, model);
|
||||
print.process();
|
||||
CHECK(brim_count(print) == expected_brims);
|
||||
}
|
||||
}
|
||||
|
||||
// Each object's skirt and brim come right before that object, not all skirts then all brims first.
|
||||
TEST_CASE("By-layer per-object skirt and brim precede each object", "[SkirtBrim]")
|
||||
{
|
||||
const std::string gcode = slice_two_cubes_apart(60, { // far apart: a skirt+brim per object
|
||||
{ "skirt_loops", 1 },
|
||||
{ "skirt_height", 1 },
|
||||
{ "skirt_distance", 3 },
|
||||
{ "skirt_type", "perobject" },
|
||||
{ "print_sequence", "by layer" },
|
||||
{ "brim_type", "outer_only" },
|
||||
{ "brim_width", 5 },
|
||||
{ "layer_height", 0.2 },
|
||||
});
|
||||
return brim_tool;
|
||||
const std::vector<std::string> expected{ "skirt", "brim", "perimeter", "skirt", "brim", "perimeter" };
|
||||
CHECK(role_sequence(gcode, { "skirt", "brim", "perimeter" }) == expected);
|
||||
}
|
||||
|
||||
// A square's corners are 90 degrees, so they get ears only when brim_ears_max_angle is above 90.
|
||||
TEST_CASE("Brim ears appear only at corners within the max angle", "[SkirtBrim]")
|
||||
{
|
||||
auto [max_angle, expect_ears] = GENERATE(table<int, bool>({ { 91, true }, { 90, false }, { 89, false } }));
|
||||
DYNAMIC_SECTION("brim_ears_max_angle=" << max_angle) {
|
||||
Print print;
|
||||
init_and_process_print({ cube(20) }, print, {
|
||||
{ "skirt_loops", 0 },
|
||||
{ "brim_type", "brim_ears" },
|
||||
{ "brim_width", 1 },
|
||||
{ "brim_ears_max_angle", max_angle },
|
||||
{ "initial_layer_line_width", 0.5 },
|
||||
});
|
||||
if (expect_ears) CHECK(brim_loop_count(print) > 0);
|
||||
else CHECK(brim_loop_count(print) == 0);
|
||||
}
|
||||
}
|
||||
|
||||
SCENARIO("Skirt has the configured number of loops", "[SkirtBrim]") {
|
||||
GIVEN("20mm cube and default config") {
|
||||
WHEN("skirt_loops is set to 2") {
|
||||
Print print;
|
||||
init_and_process_print({cube(20)}, print, {
|
||||
{ "skirt_height", 1 },
|
||||
{ "skirt_distance", 1 },
|
||||
{ "skirt_loops", 2 }
|
||||
});
|
||||
THEN("Skirt Extrusion collection has 2 loops in it") {
|
||||
REQUIRE(print.skirt().items_count() == 2);
|
||||
REQUIRE(print.skirt().flatten().entities.size() == 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SCENARIO("Brim has the configured number of loops", "[SkirtBrim]") {
|
||||
GIVEN("20mm cube and default config, 1mm first layer width") {
|
||||
WHEN("Brim is set to 6mm") {
|
||||
Print print;
|
||||
init_and_process_print({cube(20)}, print, {
|
||||
{ "brim_type", "outer_only" },
|
||||
{ "initial_layer_line_width", 1 },
|
||||
{ "brim_width", 6 }
|
||||
});
|
||||
THEN("Brim Extrusion collection has 6 loops in it") {
|
||||
REQUIRE(brim_loop_count(print) == 6);
|
||||
}
|
||||
}
|
||||
WHEN("Brim is set to 6mm, extrusion width 0.5mm") {
|
||||
Print print;
|
||||
init_and_process_print({cube(20)}, print, {
|
||||
{ "brim_type", "outer_only" },
|
||||
{ "brim_width", 6 },
|
||||
{ "initial_layer_line_width", 0.5 }
|
||||
});
|
||||
THEN("Brim Extrusion collection has 12 loops in it") {
|
||||
REQUIRE(brim_loop_count(print) == 12);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("Skirt height is honored", "[SkirtBrim]") {
|
||||
DynamicPrintConfig config = Slic3r::DynamicPrintConfig::full_print_config();
|
||||
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
||||
config.set_deserialize_strict({
|
||||
{ "skirt_loops", 1 },
|
||||
{ "skirt_height", 5 },
|
||||
{ "wall_loops", 0 },
|
||||
{ "gcode_comments", true }
|
||||
{ "skirt_loops", 1 },
|
||||
{ "skirt_height", 5 },
|
||||
{ "wall_loops", 0 },
|
||||
});
|
||||
|
||||
std::string gcode;
|
||||
std::string gcode;
|
||||
SECTION("printing a single object") {
|
||||
gcode = Slic3r::Test::slice({TestMesh::cube_20x20x20}, config);
|
||||
gcode = slice({ cube(20) }, config);
|
||||
}
|
||||
SECTION("printing multiple objects") {
|
||||
gcode = Slic3r::Test::slice({TestMesh::cube_20x20x20, TestMesh::cube_20x20x20}, config);
|
||||
gcode = slice({ cube(20), cube(20) }, config);
|
||||
}
|
||||
|
||||
REQUIRE(layers_with_role(gcode, "skirt").size() == (size_t)config.opt_int("skirt_height"));
|
||||
REQUIRE(layers_with_role(gcode, "skirt").size() == (size_t) config.opt_int("skirt_height"));
|
||||
}
|
||||
|
||||
SCENARIO("Skirt and brim generation", "[SkirtBrim]") {
|
||||
GIVEN("A default configuration") {
|
||||
DynamicPrintConfig config = Slic3r::DynamicPrintConfig::full_print_config();
|
||||
config.set_num_extruders(4);
|
||||
config.set_deserialize_strict({
|
||||
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
||||
config.set_num_extruders(4);
|
||||
config.set_deserialize_strict({
|
||||
{ "initial_layer_print_height", 0.3 },
|
||||
{ "gcode_comments", true },
|
||||
// avoid altering speeds unexpectedly
|
||||
// avoid altering speeds unexpectedly
|
||||
{ "slow_down_for_layer_cooling", false },
|
||||
{ "initial_layer_speed", "100%" },
|
||||
// remove noise from top/solid layers
|
||||
// remove noise from top/solid layers
|
||||
{ "top_shell_layers", 0 },
|
||||
{ "bottom_shell_layers", 1 },
|
||||
{ "machine_start_gcode", "T[initial_tool]\n" }
|
||||
{ "machine_start_gcode", "T[initial_tool]\n" },
|
||||
});
|
||||
|
||||
WHEN("Brim width is set to 5") {
|
||||
config.set_deserialize_strict({
|
||||
config.set_deserialize_strict({
|
||||
{ "wall_loops", 0 },
|
||||
{ "skirt_loops", 0 },
|
||||
{ "brim_type", "outer_only" },
|
||||
{ "brim_width", 5 }
|
||||
});
|
||||
THEN("Brim is generated") {
|
||||
std::string gcode = Slic3r::Test::slice({TestMesh::cube_20x20x20}, config);
|
||||
{ "brim_width", 5 },
|
||||
});
|
||||
THEN("Brim is generated") {
|
||||
std::string gcode = slice({ cube(20) }, config);
|
||||
REQUIRE(! layers_with_role(gcode, "brim").empty());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#if 0
|
||||
// This is a real error! One shall print the brim with the external perimeter extruder!
|
||||
WHEN("Perimeter extruder = 2 and support extruders = 3") {
|
||||
THEN("Brim is printed with the extruder used for the perimeters of first object") {
|
||||
config.set_deserialize_strict({
|
||||
{ "skirts", 0 },
|
||||
{ "brim_width", 5 },
|
||||
{ "perimeter_extruder", 2 },
|
||||
{ "support_material_extruder", 3 },
|
||||
{ "infill_extruder", 4 }
|
||||
});
|
||||
std::string gcode = Slic3r::Test::slice({TestMesh::cube_20x20x20}, config);
|
||||
int tool = get_brim_tool(gcode);
|
||||
REQUIRE(tool == config.opt_int("perimeter_extruder") - 1);
|
||||
}
|
||||
}
|
||||
WHEN("Perimeter extruder = 2, support extruders = 3, raft is enabled") {
|
||||
THEN("brim is printed with same extruder as skirt") {
|
||||
config.set_deserialize_strict({
|
||||
{ "skirts", 0 },
|
||||
{ "brim_width", 5 },
|
||||
{ "perimeter_extruder", 2 },
|
||||
{ "support_material_extruder", 3 },
|
||||
{ "infill_extruder", 4 },
|
||||
{ "raft_layers", 1 }
|
||||
});
|
||||
std::string gcode = Slic3r::Test::slice({TestMesh::cube_20x20x20}, config);
|
||||
int tool = get_brim_tool(gcode);
|
||||
REQUIRE(tool == config.opt_int("support_material_extruder") - 1);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
WHEN("brim width to 1 with layer_width of 0.5") {
|
||||
config.set_deserialize_strict({
|
||||
config.set_deserialize_strict({
|
||||
{ "skirt_loops", 0 },
|
||||
{ "initial_layer_line_width", 0.5 },
|
||||
{ "brim_type", "outer_only" },
|
||||
{ "brim_width", 1 }
|
||||
});
|
||||
{ "brim_width", 1 },
|
||||
});
|
||||
THEN("2 brim lines") {
|
||||
Slic3r::Print print;
|
||||
Slic3r::Test::init_and_process_print({TestMesh::cube_20x20x20}, print, config);
|
||||
size_t total_entities = 0;
|
||||
for (const auto& pair : print.get_brimMap()) {
|
||||
total_entities += pair.second.entities.size();
|
||||
}
|
||||
REQUIRE(total_entities == 2);
|
||||
Print print;
|
||||
init_and_process_print({ cube(20) }, print, config);
|
||||
REQUIRE(brim_loop_count(print) == 2);
|
||||
}
|
||||
}
|
||||
|
||||
#if 0
|
||||
WHEN("brim ears on a square") {
|
||||
config.set_deserialize_strict({
|
||||
{ "skirts", 0 },
|
||||
{ "first_layer_extrusion_width", 0.5 },
|
||||
{ "brim_width", 1 },
|
||||
{ "brim_ears", 1 },
|
||||
{ "brim_ears_max_angle", 91 }
|
||||
});
|
||||
Slic3r::Print print;
|
||||
Slic3r::Test::init_and_process_print({TestMesh::cube_20x20x20}, print, config);
|
||||
THEN("Four brim ears") {
|
||||
REQUIRE(print.brim().entities.size() == 4);
|
||||
}
|
||||
}
|
||||
|
||||
WHEN("brim ears on a square but with a too small max angle") {
|
||||
config.set_deserialize_strict({
|
||||
{ "skirts", 0 },
|
||||
{ "first_layer_extrusion_width", 0.5 },
|
||||
{ "brim_width", 1 },
|
||||
{ "brim_ears", 1 },
|
||||
{ "brim_ears_max_angle", 89 }
|
||||
});
|
||||
THEN("no brim") {
|
||||
Slic3r::Print print;
|
||||
Slic3r::Test::init_and_process_print({ TestMesh::cube_20x20x20 }, print, config);
|
||||
REQUIRE(print.brim().entities.size() == 0);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
WHEN("Object is plated with overhang support and a brim") {
|
||||
config.set_deserialize_strict({
|
||||
config.set_deserialize_strict({
|
||||
{ "layer_height", 0.4 },
|
||||
{ "initial_layer_print_height", 0.4 },
|
||||
{ "skirt_loops", 1 },
|
||||
{ "skirt_distance", 0 },
|
||||
{ "enable_support", 1 },
|
||||
{ "brim_type", "outer_only" },
|
||||
{ "brim_width", 5 }
|
||||
});
|
||||
|
||||
{ "brim_width", 5 },
|
||||
});
|
||||
THEN("Support and brim are both emitted") {
|
||||
std::string gcode = Slic3r::Test::slice({TestMesh::overhang}, config);
|
||||
std::string gcode = slice({ TestMesh::overhang }, config);
|
||||
REQUIRE(! layers_with_role(gcode, "support").empty());
|
||||
REQUIRE(! layers_with_role(gcode, "brim").empty());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
WHEN("an object with support is surrounded by a skirt") {
|
||||
config.set_deserialize_strict({
|
||||
{ "enable_support", 1 },
|
||||
{ "skirt_loops", 1 },
|
||||
{ "skirt_distance", 2 },
|
||||
{ "brim_type", "no_brim" },
|
||||
{ "z_hop", 0 }
|
||||
{ "z_hop", 0 },
|
||||
});
|
||||
THEN("the skirt is long enough to enclose the object and its support") {
|
||||
std::string gcode = Slic3r::Test::slice({TestMesh::overhang}, config);
|
||||
std::string gcode = slice({ TestMesh::overhang }, config);
|
||||
const double first_layer_z = config.opt_float("initial_layer_print_height");
|
||||
|
||||
// On the first layer, accumulate the skirt loop length and collect the
|
||||
@@ -200,7 +286,7 @@ SCENARIO("Skirt and brim generation", "[SkirtBrim]") {
|
||||
double skirt_length = 0.0;
|
||||
Points footprint;
|
||||
GCodeReader parser;
|
||||
parser.parse_buffer(gcode, [&] (GCodeReader& self, const GCodeReader::GCodeLine& line) {
|
||||
parser.parse_buffer(gcode, [&](GCodeReader &self, const GCodeReader::GCodeLine &line) {
|
||||
if (! line.extruding(self) || line.dist_XY(self) <= 0 || std::abs(self.z() - first_layer_z) > 0.01)
|
||||
return;
|
||||
if (line.comment().find("skirt") != std::string_view::npos)
|
||||
@@ -214,17 +300,18 @@ SCENARIO("Skirt and brim generation", "[SkirtBrim]") {
|
||||
REQUIRE(skirt_length > hull_perimeter);
|
||||
}
|
||||
}
|
||||
|
||||
WHEN("Large minimum skirt length is used.") {
|
||||
// One skirt loop around a 20mm cube is ~88mm, so 500mm forces extra loops.
|
||||
config.set_deserialize_strict({
|
||||
{ "skirt_loops", 1 },
|
||||
{ "min_skirt_length", 500 }
|
||||
{ "min_skirt_length", 500 },
|
||||
});
|
||||
THEN("The skirt is extended to at least the minimum length") {
|
||||
std::string gcode = Slic3r::Test::slice({TestMesh::cube_20x20x20}, config);
|
||||
std::string gcode = slice({ cube(20) }, config);
|
||||
double skirt_length = 0.0;
|
||||
GCodeReader parser;
|
||||
parser.parse_buffer(gcode, [&skirt_length] (GCodeReader& self, const GCodeReader::GCodeLine& line) {
|
||||
parser.parse_buffer(gcode, [&skirt_length](GCodeReader &self, const GCodeReader::GCodeLine &line) {
|
||||
if (line.extruding(self) && line.comment().find("skirt") != std::string_view::npos)
|
||||
skirt_length += line.dist_XY(self);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user