diff --git a/tests/fff_print/test_skirt_brim.cpp b/tests/fff_print/test_skirt_brim.cpp index b5d315db2a..486be88f40 100644 --- a/tests/fff_print/test_skirt_brim.cpp +++ b/tests/fff_print/test_skirt_brim.cpp @@ -9,7 +9,12 @@ #include +#include #include +#include +#include +#include +#include #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 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 belt_tools_for_role(const std::string &gcode, const std::string &role) +{ + std::set 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::max() when the role never extrudes. +static double first_role_z(const std::string &gcode, const std::string &role) +{ + double z = std::numeric_limits::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 first_move_by_tool(const std::string &gcode, const std::string &role) +{ + std::map 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::max()); + REQUIRE(peri_z < std::numeric_limits::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 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{ 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{ 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 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> 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{ 0, 1 }); + + const std::map brim_first = first_move_by_tool(gc, "brim"); + const std::map 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 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(); diff --git a/tests/libslic3r/test_belt_brim.cpp b/tests/libslic3r/test_belt_brim.cpp index 9f645817e9..d5e99dafaa 100644 --- a/tests/libslic3r/test_belt_brim.cpp +++ b/tests/libslic3r/test_belt_brim.cpp @@ -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.