From 5779274e5bb226623949829b9d00ee1bd3949846 Mon Sep 17 00:00:00 2001 From: Kris Austin Date: Mon, 7 Sep 2026 16:02:14 -0500 Subject: [PATCH] test: cover support interface generation and tree support (#15575) --- tests/fff_print/CMakeLists.txt | 1 + tests/fff_print/test_support_material.cpp | 361 ++++++++++++++++++++++ tests/fff_print/test_tree_support.cpp | 125 ++++++++ 3 files changed, 487 insertions(+) create mode 100644 tests/fff_print/test_tree_support.cpp diff --git a/tests/fff_print/CMakeLists.txt b/tests/fff_print/CMakeLists.txt index fc46bb8fdc..3247bfda66 100644 --- a/tests/fff_print/CMakeLists.txt +++ b/tests/fff_print/CMakeLists.txt @@ -19,6 +19,7 @@ add_executable(${_TEST_NAME}_tests test_skirt_brim.cpp test_slicing_pipeline_hook.cpp test_support_material.cpp + test_tree_support.cpp test_trianglemesh.cpp test_wipe_tower.cpp ) diff --git a/tests/fff_print/test_support_material.cpp b/tests/fff_print/test_support_material.cpp index f854939c8c..4564c0b034 100644 --- a/tests/fff_print/test_support_material.cpp +++ b/tests/fff_print/test_support_material.cpp @@ -3,11 +3,103 @@ #include "libslic3r/GCodeReader.hpp" #include "libslic3r/Layer.hpp" +#include +#include +#include +#include + #include "test_helpers.hpp" // get access to init_print, etc +// Not self-contained: its inline constructor uses PrintObject, PrintRegion, SlicingParameters and +// Geometry, so it must follow the headers (pulled in via test_helpers.hpp) that define them. +#include "libslic3r/Support/SupportParameters.hpp" + using namespace Slic3r::Test; using namespace Slic3r; +// Distinct layer Z heights carrying support interface extrusion. +static size_t support_interface_layer_count(const std::string &gcode) +{ + return layers_with_role(gcode, "support material interface").size(); +} + +// Distinct layer Z heights carrying support base extrusion. The base G-code label "support material" +// is a substring of "support material interface", so a base line is a support line that is not an +// interface line. +static size_t support_base_layer_count(const std::string &gcode) +{ + std::set layers; + GCodeReader parser; + parser.parse_buffer(gcode, [&layers](GCodeReader &self, const GCodeReader::GCodeLine &line) { + if (! line.extruding(self)) return; + const std::string_view comment = line.comment(); + if (comment.find("support material") != std::string_view::npos && + comment.find("interface") == std::string_view::npos) + layers.insert(self.z()); + }); + return layers.size(); +} + +// Dominant support-interface fill direction per interface layer, in radians [0, pi). Uses the +// length-weighted axial mean (each segment angle doubled so a line and its reverse agree, then +// halved): the parallel infill lines reinforce while the surrounding perimeter cancels. +static std::map interface_fill_angle_by_layer(const std::string &gcode) +{ + std::map> acc; // z -> summed length*(cos2a, sin2a) + GCodeReader parser; + parser.parse_buffer(gcode, [&acc](GCodeReader &self, const GCodeReader::GCodeLine &line) { + if (! line.extruding(self)) return; + if (line.comment().find("support material interface") == std::string_view::npos) return; + const double dx = line.dist_X(self), dy = line.dist_Y(self); + const double len = std::hypot(dx, dy); + if (len < 1e-6) return; + const double a2 = 2.0 * std::atan2(dy, dx); + auto &p = acc[self.z()]; + p.first += len * std::cos(a2); + p.second += len * std::sin(a2); + }); + std::map out; + for (const auto &kv : acc) { + double a = 0.5 * std::atan2(kv.second.second, kv.second.first); + if (a < 0) a += M_PI; + out[kv.first] = a; + } + return out; +} + +// Acute angle (degrees) between two axial fill directions in [0, pi). +static double axial_angle_diff_deg(double a, double b) +{ + const double d = std::fmod(std::fabs(a - b), M_PI); + return std::min(d, M_PI - d) * 180.0 / M_PI; +} + +// Denser interface spacing yields more extruded length. +static double support_interface_extrusion_length(const std::string &gcode) +{ + double len = 0; + GCodeReader parser; + parser.parse_buffer(gcode, [&len](GCodeReader &self, const GCodeReader::GCodeLine &line) { + if (! line.extruding(self)) return; + if (line.comment().find("support material interface") == std::string_view::npos) return; + len += std::hypot(line.dist_X(self), line.dist_Y(self)); + }); + return len; +} + +// A cap slab overhanging a base, joined by a central stem: the cap can only be supported by resting on the +// base, forcing a genuine bottom contact. A horizontal tunnel does not work here -- tree/organic can arch a +// branch in from the opening and avoid the floor entirely. +static TriangleMesh support_capital() +{ + TriangleMesh model = make_cube(40, 40, 2); // base [0,40]x[0,40]x[0,2] + TriangleMesh stem = make_cube(8, 8, 12); stem.translate(16, 16, 1); // stem centered, z 1..13 + TriangleMesh cap = make_cube(40, 40, 2); cap.translate(0, 0, 12); // cap z 12..14 + model.merge(stem); + model.merge(cap); + return model; +} + TEST_CASE("Three raft layers are created", "[SupportMaterial]") { Slic3r::Print print; @@ -104,3 +196,272 @@ TEST_CASE("Support G-code emission survives a second slice in the same process", const std::string second = slice({ TestMesh::overhang }, { { "enable_support", 1 } }); REQUIRE(! layers_with_role(second, "support").empty()); } + +// The contact layer counts toward the configured interface layer count, so N configured top +// interface layers produce exactly N interface layers, not N+1. +TEST_CASE("Support top interface layer count matches the configured value", "[SupportMaterial]") +{ + const int top = GENERATE(1, 2, 3, 4, 6); + const std::string g = slice({ TestMesh::overhang }, { + { "enable_support", 1 }, + { "layer_height", 0.2 }, + { "support_on_build_plate_only", 1 }, + { "support_interface_top_layers", top }, + { "support_interface_bottom_layers", 0 }, + }); + CAPTURE(top); + REQUIRE(support_base_layer_count(g) > 0); // support actually formed + REQUIRE(support_interface_layer_count(g) == size_t(top)); +} + +// A rotated cube-with-hole is a horizontal tunnel whose ceiling and floor both receive support, so top +// and bottom interfaces can be exercised independently (the floor is the bottom contact). +static TriangleMesh support_tunnel() +{ + TriangleMesh tunnel = Slic3r::Test::mesh(TestMesh::cube_with_hole); + tunnel.rotate_x(float(M_PI / 2)); + return tunnel; +} + +static size_t tunnel_interface_layers(const TriangleMesh &tunnel, int top, int bottom) +{ + const std::string g = slice({ tunnel }, { + { "enable_support", 1 }, + { "layer_height", 0.2 }, + { "support_on_build_plate_only", 0 }, + { "support_interface_top_layers", top }, + { "support_interface_bottom_layers", bottom }, + }); + REQUIRE(support_base_layer_count(g) > 0); // support actually formed + return support_interface_layer_count(g); +} + +TEST_CASE("No support interface is generated when neither top nor bottom is configured", "[SupportMaterial]") +{ + REQUIRE(tunnel_interface_layers(support_tunnel(), 0, 0) == 0); +} + +TEST_CASE("Bottom interface layer count matches its setting with top interface off", "[SupportMaterial]") +{ + const int bottom = GENERATE(1, 3, 6); + CAPTURE(bottom); + REQUIRE(tunnel_interface_layers(support_tunnel(), 0, bottom) == size_t(bottom)); +} + +// support_interface_bottom_layers = -1 means "same as top". +TEST_CASE("Support interface bottom layers default to the top layer count", "[SupportMaterial]") +{ + const TriangleMesh tunnel = support_tunnel(); + REQUIRE(tunnel_interface_layers(tunnel, 0, -1) == tunnel_interface_layers(tunnel, 0, 0)); + REQUIRE(tunnel_interface_layers(tunnel, 3, -1) == tunnel_interface_layers(tunnel, 3, 3)); +} + +TEST_CASE("Default support still emits base and interface material", "[SupportMaterial][Regression]") +{ + const std::string g = slice({ TestMesh::overhang }, { { "enable_support", 1 } }); + REQUIRE(support_base_layer_count(g) > 0); + REQUIRE(support_interface_layer_count(g) > 0); +} + +// Organic runs TreeSupport3D + TreeModelVolumes, the others the classic TreeSupport.cpp path. +TEST_CASE("Every tree support style produces base and interface material", "[SupportMaterial]") +{ + const char *style = GENERATE("organic", "tree_slim", "tree_strong", "tree_hybrid"); + INFO("style=" << style); + const std::string g = slice({ TestMesh::overhang }, { + { "enable_support", 1 }, + { "layer_height", 0.2 }, + { "support_type", "tree(auto)" }, + { "support_style", style }, + { "support_interface_top_layers", 3 }, + }); + CHECK(support_base_layer_count(g) > 0); + CHECK(support_interface_layer_count(g) > 0); +} + +TEST_CASE("Raft interface angle alternates by 45 degrees per interface id", "[SupportMaterial]") +{ + Slic3r::Print print; + Slic3r::Test::init_and_process_print({ TestMesh::overhang }, print, { { "enable_support", 1 } }); + SupportParameters sp(*print.objects().front()); + sp.raft_angle_interface = 0.5f; + REQUIRE_THAT(sp.raft_interface_angle(0), Catch::Matchers::WithinAbs(0.5 + M_PI / 4., 1e-6)); + REQUIRE_THAT(sp.raft_interface_angle(1), Catch::Matchers::WithinAbs(0.5 - M_PI / 4., 1e-6)); +} + +// The angle inputs are overwritten directly, so the pattern-to-angle mapping is checked +// independently of the sliced object's configuration. +TEST_CASE("Support interface fill angle follows the configured interface pattern", "[SupportMaterial]") +{ + Slic3r::Print print; + Slic3r::Test::init_and_process_print({ TestMesh::overhang }, print, { { "enable_support", 1 } }); + SupportParameters sp(*print.objects().front()); + sp.interface_angle = 0.3f; + sp.base_angle = 1.1f; + const double tol = 1e-6; + + SECTION("Rectilinear shifts the interface angle by -45deg for snug support") { + sp.support_interface_pattern = smipRectilinear; + sp.support_style = smsSnug; + REQUIRE_THAT(sp.support_interface_angle(0), Catch::Matchers::WithinAbs(sp.interface_angle - M_PI_4, tol)); + REQUIRE_THAT(sp.support_interface_angle(3), Catch::Matchers::WithinAbs(sp.interface_angle - M_PI_4, tol)); + } + SECTION("Rectilinear leaves the interface angle alone for the other styles") { + sp.support_interface_pattern = smipRectilinear; + sp.support_style = smsGrid; + REQUIRE_THAT(sp.support_interface_angle(0), Catch::Matchers::WithinAbs(sp.interface_angle, tol)); + } + SECTION("Rectilinear interlaced alternates -/+45deg by interface id parity") { + sp.support_interface_pattern = smipRectilinearInterlaced; + REQUIRE_THAT(sp.support_interface_angle(0), Catch::Matchers::WithinAbs(sp.interface_angle - M_PI_4, tol)); + REQUIRE_THAT(sp.support_interface_angle(1), Catch::Matchers::WithinAbs(sp.interface_angle + M_PI_4, tol)); + } + SECTION("Grid uses the base angle") { + sp.support_interface_pattern = smipGrid; + REQUIRE_THAT(sp.support_interface_angle(0), Catch::Matchers::WithinAbs(sp.base_angle, tol)); + } + SECTION("Auto and concentric use the interface angle unchanged") { + sp.support_interface_pattern = smipAuto; + REQUIRE_THAT(sp.support_interface_angle(0), Catch::Matchers::WithinAbs(sp.interface_angle, tol)); + sp.support_interface_pattern = smipConcentric; + REQUIRE_THAT(sp.support_interface_angle(0), Catch::Matchers::WithinAbs(sp.interface_angle, tol)); + } +} + +// End-to-end that the pattern reaches the emitted fill, not just support_interface_angle(). +TEST_CASE("Interlaced support interface alternates fill angle while rectilinear does not", "[SupportMaterial]") +{ + auto interface_angles = [](const char *pattern) { + std::vector a; + for (const auto &kv : interface_fill_angle_by_layer(slice({ TestMesh::overhang }, { + { "enable_support", 1 }, + { "layer_height", 0.2 }, + { "support_on_build_plate_only", 1 }, + { "support_interface_top_layers", 6 }, + { "support_interface_pattern", pattern } }))) + a.push_back(kv.second); + return a; + }; + + const std::vector rectilinear = interface_angles("rectilinear"); + const std::vector interlaced = interface_angles("rectilinear_interlaced"); + REQUIRE(rectilinear.size() >= 3); + REQUIRE(interlaced.size() >= 3); + + for (size_t i = 1; i < rectilinear.size(); ++i) + REQUIRE(axial_angle_diff_deg(rectilinear[i], rectilinear[0]) < 15.0); + + for (size_t i = 1; i < interlaced.size(); ++i) + REQUIRE(axial_angle_diff_deg(interlaced[i], interlaced[i - 1]) > 60.0); +} + +// Normal and non-organic tree support share the same interface angle logic: with a rectilinear interface +// pattern both emit their interface fill at the same angle (both go through support_interface_angle()). +TEST_CASE("Normal and tree support use the same interface fill angle", "[SupportMaterial]") +{ + auto mean_interface_angle = [](const char *type, const char *style) { + const auto angles = interface_fill_angle_by_layer(slice({ TestMesh::overhang }, { + { "enable_support", 1 }, { "layer_height", 0.2 }, { "support_on_build_plate_only", 1 }, + { "support_type", type }, { "support_style", style }, + { "support_interface_top_layers", 6 }, { "support_interface_pattern", "rectilinear" } })); + REQUIRE(angles.size() >= 3); + // Axial mean, as in interface_fill_angle_by_layer: a plain mean would split angles either + // side of the [0, pi) wrap. + double x = 0, y = 0; + for (const auto &kv : angles) { + x += std::cos(2.0 * kv.second); + y += std::sin(2.0 * kv.second); + } + double mean = 0.5 * std::atan2(y, x); + if (mean < 0) mean += M_PI; + return mean; + }; + REQUIRE(axial_angle_diff_deg(mean_interface_angle("normal(auto)", "default"), + mean_interface_angle("tree(auto)", "tree_slim")) < 10.0); +} + +// Every style, because the non-organic tree styles once emitted one more top interface layer than the rest. +TEST_CASE("Top interface layer count equals the configured value for every support style", "[SupportMaterial]") +{ + auto [type, style] = GENERATE(table({ + { "normal(auto)", "grid" }, { "normal(auto)", "snug" }, + { "tree(auto)", "organic" }, { "tree(auto)", "tree_slim" }, + { "tree(auto)", "tree_strong" }, { "tree(auto)", "tree_hybrid" }, + })); + CAPTURE(style); + const std::string g = slice({ TestMesh::overhang }, { + { "enable_support", 1 }, + { "layer_height", 0.2 }, + { "support_type", type }, + { "support_style", style }, + { "support_interface_top_layers", 4 }, + }); + REQUIRE(support_interface_layer_count(g) == 4u); +} + +// The bottom interface was dropped in earlier versions when support started on the model rather +// than the plate. +TEST_CASE("Non-organic tree support generates a bottom interface on internal geometry", "[SupportMaterial]") +{ + const std::string g = slice({ support_tunnel() }, { + { "enable_support", 1 }, + { "layer_height", 0.2 }, + { "support_on_build_plate_only", 0 }, + { "support_type", "tree(auto)" }, + { "support_style", "tree_slim" }, + { "support_interface_top_layers", 0 }, + { "support_interface_bottom_layers", 6 }, + }); + REQUIRE(support_base_layer_count(g) > 0); + REQUIRE(support_interface_layer_count(g) > 0); +} + +// The capital forces the model contact; on a horizontal tunnel organic can arch a branch in and make none. +TEST_CASE("A bottom interface is produced for every support style on a forced model contact", "[SupportMaterial]") +{ + auto [type, style] = GENERATE(table({ + { "normal(auto)", "default" }, { "tree(auto)", "tree_slim" }, + { "tree(auto)", "tree_strong" }, { "tree(auto)", "tree_hybrid" }, + { "tree(auto)", "organic" }, + })); + CAPTURE(style); + REQUIRE(support_interface_layer_count(slice({ support_capital() }, { + { "enable_support", 1 }, { "layer_height", 0.2 }, { "support_on_build_plate_only", 0 }, + { "support_type", type }, { "support_style", style }, + { "support_interface_top_layers", 0 }, { "support_interface_bottom_layers", 6 } })) > 0); +} + +TEST_CASE("Bottom interface spacing controls bottom interface density for every support style", "[SupportMaterial]") +{ + auto [type, style] = GENERATE(table({ + { "normal(auto)", "default" }, { "tree(auto)", "tree_slim" }, + { "tree(auto)", "tree_strong" }, { "tree(auto)", "tree_hybrid" }, + { "tree(auto)", "organic" }, + })); + CAPTURE(style); + const TriangleMesh model = support_capital(); + auto len = [&model](const char *support_type, const char *support_style, double spacing) { + return support_interface_extrusion_length(slice({ model }, { + { "enable_support", 1 }, { "layer_height", 0.2 }, { "support_on_build_plate_only", 0 }, + { "support_type", support_type }, { "support_style", support_style }, { "support_interface_top_layers", 0 }, + { "support_interface_bottom_layers", 6 }, { "support_bottom_interface_spacing", spacing } })); + }; + REQUIRE(len(type, style, 0.0) > len(type, style, 4.0) * 1.5); +} + +// Interface and base flows are identical in width and rate unless a separate support-interface +// filament is used, so density is the observable here, not flow. +TEST_CASE("Bottom-only support interface keeps the dense interface density", "[SupportMaterial]") +{ + Slic3r::Print print; + Slic3r::Test::init_and_process_print({ TestMesh::overhang }, print, { + { "enable_support", 1 }, + { "support_interface_top_layers", 0 }, + { "support_interface_bottom_layers", 6 }, + { "support_bottom_interface_spacing", 0.0 }, // solid: density resolves to 1.0 + { "support_base_pattern_spacing", 2.5 }, // sparse: density stays below 1.0 + }); + SupportParameters sp(*print.objects().front()); + REQUIRE(sp.bottom_interface_density > sp.support_density); +} + diff --git a/tests/fff_print/test_tree_support.cpp b/tests/fff_print/test_tree_support.cpp new file mode 100644 index 0000000000..362eb0ca56 --- /dev/null +++ b/tests/fff_print/test_tree_support.cpp @@ -0,0 +1,125 @@ +#include + +#include "libslic3r/Layer.hpp" +#include "libslic3r/TriangleMesh.hpp" + +#include "test_helpers.hpp" + +using namespace Slic3r::Test; +using namespace Slic3r; + +namespace { + +// The upper plate overhangs both the lower plate and open air, so branches land on the model and on +// the bed in the same slice. +TriangleMesh two_tier_mesh() +{ + TriangleMesh lower = make_cube(30, 30, 3); + TriangleMesh column = make_cube(8, 8, 15); + TriangleMesh upper = make_cube(50, 50, 3); + // Each part overlaps the one below rather than resting on it; a coplanar join slices ambiguously. + column.translate(11.f, 11.f, 2.f); + upper.translate(-10.f, -10.f, 16.f); + TriangleMesh mesh = lower; + mesh.merge(column); + mesh.merge(upper); + return mesh; +} + +TriangleMesh scaled(TestMesh id, float scale) +{ + TriangleMesh mesh = Slic3r::Test::mesh(id); + mesh.scale(scale); + return mesh; +} + +void slice_with_tree_support(const TriangleMesh &mesh, Slic3r::Print &print, const char *style, + int threshold_angle = 30, int build_plate_only = 0, int raft_layers = 0) +{ + Slic3r::Test::init_and_process_print({ mesh }, print, { + { "enable_support", 1 }, + { "support_type", "tree(auto)" }, + { "support_style", style }, + { "support_on_build_plate_only", build_plate_only }, + { "support_threshold_angle", threshold_angle }, + { "raft_layers", raft_layers }, + { "layer_height", 0.2 }, + }); +} + +Points support_points(const Slic3r::Print &print) +{ + Points points; + for (const SupportLayer *layer : print.objects().front()->support_layers()) + layer->support_fills.collect_points(points); + return points; +} + +size_t support_point_count(const TriangleMesh &mesh, const char *style, int threshold_angle = 30, + int build_plate_only = 0) +{ + Slic3r::Print print; + slice_with_tree_support(mesh, print, style, threshold_angle, build_plate_only); + return support_points(print).size(); +} + +} // namespace + +TEST_CASE("Tree support is generated for an overhang and not for a plain cube", "[TreeSupport]") +{ + REQUIRE(support_point_count(scaled(TestMesh::overhang, 2.f), "tree_slim") > 1000); + REQUIRE(support_point_count(Slic3r::Test::cube(20), "tree_slim") == 0); +} + +TEST_CASE("Restricting tree support to the build plate changes what is generated", "[TreeSupport]") +{ + const TriangleMesh mesh = two_tier_mesh(); + const size_t anywhere = support_point_count(mesh, "tree_slim", 30, 0); + const size_t plate_only = support_point_count(mesh, "tree_slim", 30, 1); + REQUIRE(anywhere > 1000); + REQUIRE(plate_only > 1000); + // The upper plate overhangs the lower one, so some branches would land on the model. + REQUIRE(plate_only != anywhere); +} + +TEST_CASE("Tree support layers rise monotonically within the layer height limits", "[TreeSupport]") +{ + Slic3r::Print print; + slice_with_tree_support(scaled(TestMesh::overhang, 2.f), print, "tree_slim"); + const double nozzle = print.config().nozzle_diameter.values.front(); + + size_t checked = 0; + double previous = 0; + bool previous_was_adjacent = false; + for (const SupportLayer *layer : print.objects().front()->support_layers()) { + if (layer->print_z <= 0 || layer->height <= 0) { + // Layers with no nodes are left at zero. Skipping one leaves a hole, so the next pair + // spans more than one layer and its gap says nothing about the layer height limit. + previous_was_adjacent = false; + continue; + } + if (previous > 0) { + CAPTURE(previous, layer->print_z); + REQUIRE(layer->print_z > previous); + if (previous_was_adjacent) + REQUIRE(layer->print_z - previous <= nozzle + EPSILON); + } + previous = layer->print_z; + previous_was_adjacent = true; + ++checked; + } + REQUIRE(checked > 10); +} + +TEST_CASE("A raft is still generated under tree support", "[TreeSupport]") +{ + // The mesh supports itself, so a layer count alone passes with no raft at all. + Slic3r::Print rafted, unrafted; + slice_with_tree_support(scaled(TestMesh::overhang, 2.f), rafted, "tree_slim", 30, 0, 3); + slice_with_tree_support(scaled(TestMesh::overhang, 2.f), unrafted, "tree_slim", 30, 0, 0); + const PrintObject *rafted_object = rafted.objects().front(); + const PrintObject *unrafted_object = unrafted.objects().front(); + REQUIRE(rafted_object->support_layers().size() > unrafted_object->support_layers().size()); + // The raft goes under the object. + REQUIRE(rafted_object->layers().front()->print_z > unrafted_object->layers().front()->print_z); +}