tests: cover belt-brim first-contact emission, tool selection, inner/leading-edge, and predicate (E)

Deterministic tests for: coincident brim at first belt contact not dropped
(C), single- and multi-extruder brim tool selection with no doubling (B),
multi-object apron ordering, inner-only+leading-only not rejecting prime
tower/spiral (D), and inner/holed + leading-edge-only geometry.
This commit is contained in:
harrierpigeon
2026-08-06 15:38:31 -05:00
parent 1bd3404c03
commit a453cb1eba
2 changed files with 371 additions and 0 deletions

View File

@@ -9,7 +9,12 @@
#include <boost/algorithm/string.hpp>
#include <cctype>
#include <cmath>
#include <limits>
#include <map>
#include <set>
#include <string>
#include "test_helpers.hpp" // get access to init_print, etc
@@ -473,6 +478,298 @@ static DynamicPrintConfig belt_brim_config()
return config;
}
// Same belt as belt_brim_config(), but with `filaments` distinct filaments so the brim's
// tool selection can be observed. Kept separate from belt_brim_config() so the existing
// single-filament belt tests are untouched.
static DynamicPrintConfig belt_brim_multifilament_config(unsigned int filaments,
std::initializer_list<Slic3r::ConfigBase::SetDeserializeItem> extra = {})
{
DynamicPrintConfig config = multifilament_config(filaments);
config.set_deserialize_strict({
{ "belt_printer", 1 },
{ "belt_slice_rotation", "x" },
{ "belt_slice_rotation_angle", 45 },
{ "belt_slice_rotation_global", 1 },
{ "gcode_remap_x", "rev_x" },
{ "gcode_remap_y", "pos_z" },
{ "gcode_remap_z", "pos_y" },
{ "layer_height", 0.2 },
{ "initial_layer_print_height", 0.2 },
{ "skirt_loops", 0 },
{ "top_shell_layers", 0 },
{ "bottom_shell_layers", 1 },
{ "machine_start_gcode", "T[initial_tool]\n" },
});
if (extra.size() > 0)
config.set_deserialize_strict(extra);
return config;
}
// 0-based tool indices used by extrusions whose role comment contains `role` (needs
// gcode_comments). Mirrors tools_for_role in test_multifilament.cpp; statics do not cross
// translation units, so it is repeated here.
static std::set<int> belt_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;
}
// Machine Z of the first extruding move whose role comment contains `role`, in file order;
// numeric_limits<double>::max() when the role never extrudes.
static double first_role_z(const std::string &gcode, const std::string &role)
{
double z = std::numeric_limits<double>::max();
GCodeReader parser;
parser.parse_buffer(gcode, [&z, &role](GCodeReader &self, const GCodeReader::GCodeLine &line) {
if (line.extruding(self) && line.comment().find(role) != std::string_view::npos) {
z = self.z();
self.quit_parsing();
}
});
return z;
}
// Number of object layers that carry a belt brim band. Each such band is emitted as one
// contiguous brim pass, so for a single object whose first-contact layer carries a band
// (the apron prologue folds into that layer's pass) this equals role_passes(gcode, "brim").
static int nonempty_belt_brim_layers(const PrintObject &object)
{
int n = 0;
for (const ExtrusionEntityCollection &band : object.belt_brim_by_layer())
if (! band.empty())
++ n;
return n;
}
// For each active tool, the ordinal (1-based, over extruding moves) of the FIRST move whose
// role comment contains `role`. Lets a per-object ordering check key off the object's
// unique wall filament.
static std::map<int, long> first_move_by_tool(const std::string &gcode, const std::string &role)
{
std::map<int, long> first;
int tool = 0;
long idx = 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])) {
tool = std::stoi(cmd.substr(1));
return;
}
if (! line.extruding(self))
return;
++ idx;
if (std::string(line.comment()).find(role) != std::string::npos && ! first.count(tool))
first[tool] = idx;
});
return first;
}
// C - the band coincident with the object's FIRST contact with the belt must not be dropped:
// a belt brim has to appear at or below the object's first perimeter. On the unfixed feature
// the first-contact band is dropped and the first brim then appears only at a later (higher)
// layer. Machine Z is meaningful and shared between roles under the belt remap, so the first
// brim's Z must not exceed the first perimeter's. Both with and without support.
TEST_CASE("Belt brim is laid at the object's first belt contact", "[SkirtBrim][belt]")
{
const bool support = GENERATE(false, true);
DYNAMIC_SECTION("enable_support=" << support) {
DynamicPrintConfig config = belt_brim_config();
config.set_deserialize_strict({
{ "brim_type", "outer_only" },
{ "brim_width", 4 },
{ "leading_brim_length", 0 },
{ "extra_brim_width", 0 },
{ "brim_object_gap", 0 },
{ "enable_support", support ? 1 : 0 },
});
const std::string gcode = slice({ cube(20) }, config);
const double brim_z = first_role_z(gcode, "brim");
const double peri_z = first_role_z(gcode, "perimeter");
REQUIRE(brim_z < std::numeric_limits<double>::max());
REQUIRE(peri_z < std::numeric_limits<double>::max());
CHECK(brim_z <= peri_z + EPSILON);
}
}
// C control - when the band's own object layer has extrusion (any interior layer of a solid
// cube), the band takes the ordinary process_layer() path and must be drawn immediately
// before that layer's perimeters, and exactly once: never dropped, never double-emitted.
TEST_CASE("Belt brim on an object layer precedes its perimeters, once", "[SkirtBrim][belt]")
{
DynamicPrintConfig config = belt_brim_config();
config.set_deserialize_strict({
{ "brim_type", "outer_only" },
{ "brim_width", 4 },
{ "brim_object_gap", 0 },
});
Print print;
Model model;
init_print({ cube(20) }, print, model, config);
const std::string gc = gcode(print);
// Ordering: the first thing extruded is brim, then perimeter.
const std::vector<std::string> seq = role_sequence(gc, { "brim", "perimeter" });
REQUIRE(seq.size() >= 2);
CHECK(seq[0] == "brim");
CHECK(seq[1] == "perimeter");
// Exactly once: every band is one contiguous pass (the apron prologue folds into the
// first layer's), so the pass count equals the number of layers carrying a band - not
// twice it, which double-emission would give, nor fewer, which a dropped band would.
const int bands = nonempty_belt_brim_layers(*print.objects().front());
REQUIRE(bands > 0);
CHECK(role_passes(gc, "brim") == bands);
}
// B - single extruder (filament id 1). Every band must survive the 1-based -> 0-based
// filament-id conversion the apron path performs: a wrong conversion drops all single-extruder
// bands, so the pass count would collapse. The expected count is derived from the sliced
// layers, not a ratio.
TEST_CASE("Belt brim on a single extruder emits every band once", "[SkirtBrim][belt]")
{
DynamicPrintConfig config = belt_brim_config();
config.set_deserialize_strict({
{ "brim_type", "outer_only" },
{ "brim_width", 4 },
{ "brim_object_gap", 0 },
});
Print print;
Model model;
init_print({ cube(20) }, print, model, config);
const std::string gc = gcode(print);
const int expected = nonempty_belt_brim_layers(*print.objects().front());
REQUIRE(expected > 0);
CHECK(role_passes(gc, "brim") == expected);
CHECK(belt_tools_for_role(gc, "brim") == std::set<int>{ 0 }); // filament 1 -> tool 0
}
// B - multi extruder (wall filament id 2). Every belt-brim line must print on the object's
// wall filament (index 2 -> tool 1), and the total number of passes must equal the
// single-extruder baseline: no per-filament doubling.
TEST_CASE("Belt brim on a multi-extruder object uses the wall filament, no doubling", "[SkirtBrim][belt]")
{
// Single-extruder baseline built the same way (same nozzle/flow), so the band geometry -
// and thus the band count - is identical and only the filament assignment differs.
DynamicPrintConfig base = belt_brim_multifilament_config(1, {
{ "brim_type", "outer_only" },
{ "brim_width", 4 },
{ "brim_object_gap", 0 },
});
const int baseline = role_passes(slice({ cube(20) }, base), "brim");
REQUIRE(baseline > 0);
DynamicPrintConfig config = belt_brim_multifilament_config(2, {
{ "brim_type", "outer_only" },
{ "brim_width", 4 },
{ "brim_object_gap", 0 },
{ "outer_wall_filament_id", 2 },
{ "inner_wall_filament_id", 2 },
});
const std::string gc = slice({ cube(20) }, config);
CHECK(belt_tools_for_role(gc, "brim") == std::set<int>{ 1 }); // filament 2 -> tool 1
CHECK(role_passes(gc, "brim") == baseline);
}
// B - two objects offset ALONG the belt (Y, since the tilt is about X), each with its own
// wall filament. Each object's brim/apron must print on that object's filament AND before
// that object's own perimeters. The object is identified by its unique tool.
TEST_CASE("Belt brim of each object precedes its perimeters on its own filament", "[SkirtBrim][belt]")
{
DynamicPrintConfig config = belt_brim_multifilament_config(2, {
{ "brim_type", "outer_only" },
{ "brim_width", 4 },
{ "leading_brim_length", 6 },
{ "brim_object_gap", 0 },
});
std::vector<TriangleMesh> meshes;
meshes.emplace_back(cube(20));
TriangleMesh second = cube(20);
second.translate(0.f, 40.f, 0.f); // offset along the belt so it lands well after the first
meshes.emplace_back(std::move(second));
const std::vector<std::vector<Slic3r::ConfigBase::SetDeserializeItem>> overrides {
{ { "outer_wall_filament_id", 1 }, { "inner_wall_filament_id", 1 } },
{ { "outer_wall_filament_id", 2 }, { "inner_wall_filament_id", 2 } },
};
Print print;
Model model;
init_print(std::move(meshes), print, model, config, &overrides, /*arrange=*/false);
print.process();
const std::string gc = gcode(print);
// Both brims appear, each on its object's wall filament (1 -> T0, 2 -> T1).
CHECK(belt_tools_for_role(gc, "brim") == std::set<int>{ 0, 1 });
const std::map<int, long> brim_first = first_move_by_tool(gc, "brim");
const std::map<int, long> peri_first = first_move_by_tool(gc, "perimeter");
for (int tool : { 0, 1 }) {
REQUIRE(brim_first.count(tool) == 1);
REQUIRE(peri_first.count(tool) == 1);
CHECK(brim_first.at(tool) < peri_first.at(tool));
}
}
// D - the belt-brim predicate must not fire on a request that produces no belt brim.
// leading_brim_length / extra_brim_width only feed the OUTER ring, so inner_only with zero
// brim_width yields nothing and must not claim the layers the prime tower / spiral vase need.
TEST_CASE("Belt inner-only leading brim does not reject the prime tower or spiral vase", "[SkirtBrim][belt]")
{
auto inner_leading = [](std::initializer_list<Slic3r::ConfigBase::SetDeserializeItem> extra) {
DynamicPrintConfig config = belt_brim_config();
config.set_deserialize_strict({
{ "brim_type", "inner_only" },
{ "brim_width", 0 },
{ "leading_brim_length", 6 },
{ "brim_object_gap", 0 },
});
config.set_deserialize_strict(extra);
return config;
};
SECTION("prime tower is left alone") {
Print print;
Model model;
init_print({ cube(20) }, print, model, inner_leading({ { "enable_prime_tower", 1 } }));
CHECK_FALSE(print.objects().front()->has_belt_brim());
CHECK(print.validate().string.empty());
}
SECTION("spiral vase is left alone") {
Print print;
Model model;
init_print({ cube(20) }, print, model, inner_leading({ { "spiral_mode", 1 } }));
CHECK_FALSE(print.objects().front()->has_belt_brim());
CHECK(print.validate().string.empty());
}
SECTION("a real inner brim still rejects the prime tower") {
DynamicPrintConfig config = belt_brim_config();
config.set_deserialize_strict({
{ "brim_type", "inner_only" },
{ "brim_width", 4 },
{ "brim_object_gap", 0 },
{ "enable_prime_tower", 1 },
});
Print print;
Model model;
init_print({ cube(20) }, print, model, config);
CHECK(print.objects().front()->has_belt_brim());
CHECK_FALSE(print.validate().string.empty());
}
}
TEST_CASE("Belt brim spans many layers instead of one", "[SkirtBrim][belt]")
{
DynamicPrintConfig config = belt_brim_config();

View File

@@ -275,6 +275,80 @@ SCENARIO("belt_brim_region reduces to the plate brim without an apron", "[BeltBr
}
}
SCENARIO("belt_brim_region builds an inner ring inside a hole", "[BeltBrim]") {
// Holed prisms (a washer) are the only footprints an inner brim has anything to grab.
// The inner path offsets the hole boundary inward and keeps the ring between the two
// offsets, clipped back inside the hole - it must be non-empty and live in the hole,
// never spill out onto the plate. No apron is applied to the inner ring.
const coord_t mm = scale_(1.);
const BeltBrimFrame frame { 1.0, 1 };
const coord_t width = 3 * mm;
const coord_t gap = 1 * mm;
GIVEN("a 40x40 mm washer with a 20 mm square hole") {
ExPolygon washer = make_box(0, 0, 40 * mm, 40 * mm);
add_hole(washer, 10 * mm, 10 * mm, 30 * mm, 30 * mm);
const ExPolygons footprint { washer };
const BoundingBox hole_bb = get_extents(washer.holes.front());
WHEN("an inner-only brim is requested") {
const ExPolygons region = belt_brim_region(footprint, false, true, width, gap, 0, 0, frame);
THEN("a non-empty ring is produced strictly inside the hole") {
REQUIRE(! region.empty());
CHECK(area(region) > 0);
const BoundingBox rb = get_extents(region);
CHECK(rb.min.x() >= hole_bb.min.x());
CHECK(rb.min.y() >= hole_bb.min.y());
CHECK(rb.max.x() <= hole_bb.max.x());
CHECK(rb.max.y() <= hole_bb.max.y());
}
}
WHEN("no inner brim is requested") {
THEN("the hole contributes nothing") {
CHECK(belt_brim_region(footprint, false, false, width, gap, 0, 0, frame).empty());
}
}
}
}
SCENARIO("Leading-edge-only retains the downhill half of the brim region", "[BeltBrim]") {
// BeltBrim.cpp ~445-458 clips the region to the object's first-contact band and keeps
// only what lies at or downhill of it. That clip is built with band_box(), which is
// file-static, so the rectangular half-band is reconstructed here with the SAME sign
// rule the code uses (low_side = shear > 0, i.e. downhill is -u) to pin the convention
// for both tilt signs. downhill_sign() is the exported accessor the flag mirrors.
const coord_t mm = scale_(1.);
const double shear = GENERATE(1.0, -1.0);
DYNAMIC_SECTION("shear " << shear) {
const BeltBrimFrame frame { shear, 1 }; // from_axis 1 => u is Y
CHECK((frame.downhill_sign() < 0) == (frame.shear > 0.));
const ExPolygons region { make_box(0, 0, 20 * mm, 20 * mm) }; // straddles the cut
const coord_t u_cut = 8 * mm;
const BoundingBox bb = get_extents(region);
const bool low_side = frame.shear > 0.;
const coord_t lo = low_side ? bb.min.y() : u_cut;
const coord_t hi = low_side ? u_cut : bb.max.y();
Polygon keep;
keep.points = { Point(bb.min.x(), lo), Point(bb.max.x(), lo),
Point(bb.max.x(), hi), Point(bb.min.x(), hi) };
const ExPolygons kept = intersection_ex(region, Polygons{ keep });
REQUIRE(! kept.empty());
const BoundingBox kb = get_extents(kept);
if (frame.shear > 0.) {
// downhill is -u: nothing above the cut survives.
CHECK(kb.max.y() <= u_cut + 2);
CHECK(kb.min.y() < u_cut);
} else {
// downhill is +u: nothing below the cut survives.
CHECK(kb.min.y() >= u_cut - 2);
CHECK(kb.max.y() > u_cut);
}
}
}
SCENARIO("The apron follows the sign of the shear", "[BeltBrim]") {
// Guards the one sign convention that is easiest to get backwards: which way
// is downhill, i.e. which way the belt carries the part.