mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-08-07 01:57:38 +00:00
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:
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user