Merge upstream main: cyclic print ordering, brim overlap fix, perimeter and support fixes

This commit is contained in:
Clifford Garwood
2026-07-31 01:42:48 -04:00
131 changed files with 54916 additions and 27918 deletions
+1
View File
@@ -11,6 +11,7 @@ add_executable(${_TEST_NAME}_tests
test_gcodewriter.cpp
test_model.cpp
test_multifilament.cpp
test_perimeters.cpp
test_print.cpp
test_printobject.cpp
test_skirt_brim.cpp
-11
View File
@@ -491,17 +491,6 @@ SCENARIO("init_print functionality", "[test_helpers]") {
THEN("Export gcode functions outputs text.") {
REQUIRE(! Slic3r::Test::gcode(print).empty());
}
#if 0
THEN("Embedded meshes exported") {
std::string path = "C:\\data\\temp\\embedded_meshes\\";
for (auto kvp : Slic3r::Test::mesh_names) {
Slic3r::TriangleMesh m = mesh(kvp.first);
std::string name = kvp.second;
REQUIRE(Slic3r::store_stl((path + name + ".stl").c_str(), &m, true) == true);
REQUIRE(Slic3r::store_obj((path + name + ".obj").c_str(), &m) == true);
}
}
#endif
}
}
}
+257
View File
@@ -0,0 +1,257 @@
#include <catch2/catch_all.hpp>
#include "libslic3r/ExtrusionEntity.hpp"
#include "libslic3r/ExtrusionEntityCollection.hpp"
#include "libslic3r/Layer.hpp"
#include "libslic3r/Print.hpp"
#include <algorithm>
#include <cmath>
#include <vector>
#include "test_helpers.hpp"
using namespace Slic3r;
using namespace Slic3r::Test;
namespace {
// The layer at this Z is the last one of the base, so its top surface is the ledge.
const double ledge_z = 5.0;
// The first layer, at initial_layer_print_height.
const double first_layer_z = 0.2;
// TestMesh::step scaled 3x in X/Y: a 60x60x5 base carrying a 54x54 column up to z=10, leaving a 3mm
// top ledge around a feature that keeps rising. That is the geometry both only_one_wall_top and the
// top surface expansion act on. The ledge has to stay wider than the wall band plus two top-infill
// lines, or the expansion discards it as a sliver and the tests below assert nothing.
TriangleMesh step_with_ledge()
{
TriangleMesh m = Slic3r::Test::mesh(TestMesh::step);
m.scale(Vec3f(3.f, 3.f, 1.f));
return m;
}
// Every setting the assertions depend on, so none of them rests on a default.
DynamicPrintConfig base_config(const char *wall_generator)
{
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
config.set_deserialize_strict({
{ "wall_generator", wall_generator },
{ "layer_height", 0.2 }, // puts a layer boundary exactly on ledge_z
{ "initial_layer_print_height", 0.2 },
{ "wall_loops", 3 },
{ "sparse_infill_density", "15%" },
{ "top_shell_layers", 3 },
{ "bottom_shell_layers", 3 },
{ "top_surface_density", "100%" },
{ "top_surface_expansion", 0.0 },
{ "only_one_wall_top", false },
{ "only_one_wall_first_layer", false },
// Do not let the one-wall threshold discard the 3mm ledge before the feature sees it.
{ "min_width_top_surface", 0.0 },
});
return config;
}
double collection_length(const ExtrusionEntityCollection &coll)
{
double len = 0.;
for (const ExtrusionEntity *entity : coll.flatten().entities)
if (! entity->is_collection())
len += entity->length();
return len;
}
// Extruded length per layer. Two slices are compared through this rather than through their G-code,
// because the G-code carries a config block that differs whenever any setting differs.
struct SliceLengths {
std::vector<double> perimeters;
std::vector<double> fills;
};
SliceLengths slice_lengths(const Print &print)
{
SliceLengths out;
for (const Layer *layer : print.objects().front()->layers()) {
double perimeters = 0., fills = 0.;
for (const LayerRegion *region : layer->regions()) {
perimeters += collection_length(region->perimeters);
fills += collection_length(region->fills);
}
out.perimeters.push_back(perimeters);
out.fills.push_back(fills);
}
return out;
}
double perimeter_length_at(const Print &print, double print_z)
{
for (const Layer *layer : print.objects().front()->layers())
if (std::abs(layer->print_z - print_z) < 1e-4) {
double len = 0.;
for (const LayerRegion *region : layer->regions())
len += collection_length(region->perimeters);
return len;
}
return 0.;
}
// Largest per-layer difference between two series; a negative result means they are not comparable.
double max_difference(const std::vector<double> &a, const std::vector<double> &b)
{
if (a.size() != b.size() || a.empty())
return -1.;
double worst = 0.;
for (size_t i = 0; i < a.size(); ++ i)
worst = std::max(worst, std::abs(a[i] - b[i]));
return worst;
}
} // namespace
// The expansion only retypes area as top solid infill, so it can do nothing where there is no top
// fill to begin with: zero top shell layers retypes the top surfaces as internal, and a top surface
// density of 0% leaves the top layer with walls only. The last section is the control - the same
// expansion on the same model does change the slice once a top fill exists - without which the two
// equality checks above it would hold for an unrelated reason.
TEST_CASE("Top surface expansion only acts where there is a top fill", "[Perimeters]")
{
const char *wall_generator = GENERATE("classic", "arachne");
CAPTURE(wall_generator);
auto lengths_for = [wall_generator](int top_shell_layers, const char *top_surface_density, double expansion) {
DynamicPrintConfig config = base_config(wall_generator);
config.set_deserialize_strict({
{ "top_shell_layers", top_shell_layers },
{ "top_surface_density", top_surface_density },
{ "top_surface_expansion", expansion },
});
Print print;
init_and_process_print({ step_with_ledge() }, print, config);
REQUIRE_FALSE(print.objects().empty());
return slice_lengths(print);
};
SECTION("no top shell layers") {
const SliceLengths off = lengths_for(0, "100%", 0.0);
const SliceLengths on = lengths_for(0, "100%", 2.0);
REQUIRE(off.perimeters.size() == on.perimeters.size());
CHECK_THAT(max_difference(off.perimeters, on.perimeters), Catch::Matchers::WithinAbs(0., 1.0));
CHECK_THAT(max_difference(off.fills, on.fills), Catch::Matchers::WithinAbs(0., 1.0));
}
SECTION("zero top surface density") {
const SliceLengths off = lengths_for(3, "0%", 0.0);
const SliceLengths on = lengths_for(3, "0%", 2.0);
REQUIRE(off.perimeters.size() == on.perimeters.size());
CHECK_THAT(max_difference(off.perimeters, on.perimeters), Catch::Matchers::WithinAbs(0., 1.0));
CHECK_THAT(max_difference(off.fills, on.fills), Catch::Matchers::WithinAbs(0., 1.0));
}
SECTION("with a top fill the same expansion does change the slice") {
const SliceLengths off = lengths_for(3, "100%", 0.0);
const SliceLengths on = lengths_for(3, "100%", 2.0);
REQUIRE(off.fills.size() == on.fills.size());
CHECK(max_difference(off.fills, on.fills) > scale_(0.5));
}
}
// With no top shell the top surfaces are retyped as internal, so the top surface density has nothing
// left to control: there is no top fill, and only_one_wall_top - the one route from the density to the
// perimeters - is itself switched off for want of a top surface to act on.
TEST_CASE("Top surface density does not affect a slice without a top shell", "[Perimeters]")
{
const char *wall_generator = GENERATE("classic", "arachne");
CAPTURE(wall_generator);
auto lengths_for = [wall_generator](const char *top_surface_density) {
DynamicPrintConfig config = base_config(wall_generator);
config.set_deserialize_strict({
{ "top_shell_layers", 0 },
{ "only_one_wall_top", true },
{ "top_surface_density", top_surface_density },
});
Print print;
init_and_process_print({ step_with_ledge() }, print, config);
REQUIRE_FALSE(print.objects().empty());
return slice_lengths(print);
};
const SliceLengths solid = lengths_for("100%");
const SliceLengths none = lengths_for("0%");
REQUIRE(solid.perimeters.size() == none.perimeters.size());
CHECK_THAT(max_difference(solid.perimeters, none.perimeters), Catch::Matchers::WithinAbs(0., 1.0));
CHECK_THAT(max_difference(solid.fills, none.fills), Catch::Matchers::WithinAbs(0., 1.0));
}
// On the ledge layer the inner walls are given up to the top fill, so that layer loses wall length.
// The handover needs a top fill that reaches the freed space: at a top surface density of 0% there is
// no top fill at all, and without top_surface_expansion the fill never grows over the walls. Either
// way the feature still runs, through the original generation, which keeps the inner walls up to the
// top boundary - putting that layer back between the plain and the one-wall slice.
TEST_CASE("Only one wall on top surfaces drops inner walls only where a top fill replaces them", "[Perimeters]")
{
const char *wall_generator = GENERATE("classic", "arachne");
CAPTURE(wall_generator);
auto ledge_perimeters_for = [wall_generator](bool only_one_wall_top, const char *top_surface_density, double expansion) {
DynamicPrintConfig config = base_config(wall_generator);
config.set_deserialize_strict({
{ "only_one_wall_top", only_one_wall_top },
{ "top_surface_density", top_surface_density },
{ "top_surface_expansion", expansion },
});
Print print;
init_and_process_print({ step_with_ledge() }, print, config);
REQUIRE_FALSE(print.objects().empty());
return perimeter_length_at(print, ledge_z);
};
const double plain = ledge_perimeters_for(false, "100%", 2.0);
const double one_wall = ledge_perimeters_for(true, "100%", 2.0);
const double one_wall_no_fill = ledge_perimeters_for(true, "0%", 2.0);
const double one_wall_no_expand = ledge_perimeters_for(true, "100%", 0.0);
REQUIRE(plain > 0.);
CHECK(one_wall < plain);
// Both fall back to the original generation, which cuts the walls back to the top boundary but not past it.
CHECK(one_wall_no_fill > one_wall);
CHECK(one_wall_no_fill < plain);
CHECK(one_wall_no_expand > one_wall);
CHECK(one_wall_no_expand < plain);
}
// The bottom counterpart: the first layer is thinned to a single wall only where a bottom shell fills the
// space behind it. With no bottom shell layers the bottom surfaces are retyped as internal, so that wall
// would ring sparse infill on the bed - the option is switched off instead, and the GUI hides it in that
// state so a profile that left it enabled cannot act behind a hidden checkbox.
TEST_CASE("Only one wall on the first layer needs a bottom shell", "[Perimeters]")
{
const char *wall_generator = GENERATE("classic", "arachne");
CAPTURE(wall_generator);
auto first_layer_perimeters_for = [wall_generator](bool only_one_wall_first_layer, int bottom_shell_layers) {
DynamicPrintConfig config = base_config(wall_generator);
config.set_deserialize_strict({
{ "only_one_wall_first_layer", only_one_wall_first_layer },
{ "bottom_shell_layers", bottom_shell_layers },
});
Print print;
init_and_process_print({ step_with_ledge() }, print, config);
REQUIRE_FALSE(print.objects().empty());
return perimeter_length_at(print, first_layer_z);
};
const double plain = first_layer_perimeters_for(false, 3);
const double one_wall = first_layer_perimeters_for(true, 3);
// Both at zero bottom shell layers, so everything else that setting changes cancels out between them.
const double plain_no_shell = first_layer_perimeters_for(false, 0);
const double one_wall_no_shell = first_layer_perimeters_for(true, 0);
REQUIRE(plain > 0.);
CHECK(one_wall < plain);
// No bottom shell: the option is inert, down to the same walls an unchecked box gives.
CHECK_THAT(one_wall_no_shell, Catch::Matchers::WithinAbs(plain_no_shell, 1.0));
}
+18
View File
@@ -153,6 +153,24 @@ TEST_CASE("Object brims are generated per instance", "[SkirtBrim]")
}
}
TEST_CASE("Uncombined neighboring brims precede their respective objects", "[SkirtBrim]")
{
Print print;
Model model;
place_two_cubes_apart(0, {
{ "skirt_loops", 0 },
{ "brim_type", "outer_only" },
{ "brim_width", 5 },
{ "combine_brims", 0 },
}, print, model);
print.process();
REQUIRE(print.skirt_brim_groups().size() == 1);
REQUIRE(print.skirt_brim_groups().front().brims.size() == 2);
CHECK(role_sequence(gcode(print), { "brim", "perimeter" }) ==
std::vector<std::string>{ "brim", "perimeter", "brim", "perimeter" });
}
TEST_CASE("Combine brims merges neighboring object instances", "[SkirtBrim]")
{
Print print;
+1
View File
@@ -33,6 +33,7 @@ add_executable(${_TEST_NAME}_tests
test_timeutils.cpp
test_voronoi.cpp
test_optimizers.cpp
test_ordering_strategies.cpp
# test_png_io.cpp
test_indexed_triangle_set.cpp
../libnest2d/printer_parts.cpp
+5 -4
View File
@@ -10,6 +10,8 @@
#include "libslic3r/MultiNozzleUtils.hpp"
#include "libslic3r/ProjectTask.hpp"
#include "test_utils.hpp"
#include <boost/filesystem/operations.hpp>
#include <catch2/catch_tostring.hpp>
@@ -110,8 +112,8 @@ SCENARIO("Export+Import geometry to/from 3mf file cycle", "[3mf]") {
src_object->instances.front()->set_transformation(src_instance_transform);
WHEN("model is saved+loaded to/from 3mf file") {
// save the model to 3mf file
std::string test_file = std::string(TEST_DATA_DIR) + "/test_3mf/prusa.3mf";
ScopedTemporaryFile temp(".3mf");
const std::string test_file = temp.string();
store_3mf(test_file.c_str(), &src_model, nullptr, false);
// load back the model from the 3mf file
@@ -121,7 +123,6 @@ SCENARIO("Export+Import geometry to/from 3mf file cycle", "[3mf]") {
ConfigSubstitutionContext ctxt{ ForwardCompatibilitySubstitutionRule::Disable };
load_3mf(test_file.c_str(), dst_config, ctxt, &dst_model, false);
}
boost::filesystem::remove(test_file);
// compare meshes
TriangleMesh src_mesh = src_model.mesh();
@@ -738,7 +739,7 @@ SCENARIO("2D convex hull of sinking object", "[3mf][.]") {
object->center_around_origin(false);
// This outputs the same exact data as the Prusaslicer test
object->volumes[0]->mesh().write_ascii("/tmp/orca.ascii");
write_debug_stl("3mf/orca.ascii", object->volumes[0]->mesh());
// set instance's attitude so that it is rotated, scaled (and sinking? how is it sinking? the rotation? does it matter if it's sinking?)
ModelInstance* instance = object->instances[0];
+3 -1
View File
@@ -4,6 +4,8 @@
#include "libslic3r/SLA/Hollowing.hpp"
#include "test_utils.hpp"
TEST_CASE("Hollow two overlapping spheres") {
using namespace Slic3r;
@@ -16,6 +18,6 @@ TEST_CASE("Hollow two overlapping spheres") {
sla::hollow_mesh(sphere1, sla::HollowingConfig{}, sla::HollowingFlags::hfRemoveInsideTriangles);
sphere1.WriteOBJFile("twospheres.obj");
write_debug_obj("hollowing/twospheres.obj", sphere1);
}
@@ -5,6 +5,8 @@
#include "libslic3r/TriangleMesh.hpp"
#include "test_utils.hpp"
using namespace Slic3r;
TEST_CASE("Split empty mesh", "[its_split][its]") {
@@ -29,13 +31,15 @@ TEST_CASE("Split simple mesh consisting of one part", "[its_split][its]") {
REQUIRE(res.front().vertices.size() == cube.vertices.size());
}
// Dump each split part as its own OBJ for eyeballing; no-op in release.
void debug_write_obj(const std::vector<indexed_triangle_set> &res, const std::string &name)
{
#ifndef NDEBUG
size_t part_idx = 0;
for (auto &part_its : res) {
its_write_obj(part_its, (name + std::to_string(part_idx++) + ".obj").c_str());
}
for (const auto &part_its : res)
write_debug_obj("indexed_triangle_set/" + name + std::to_string(part_idx++) + ".obj", part_its);
#else
(void) res; (void) name;
#endif
}
@@ -260,7 +264,6 @@ TEST_CASE("Reduce one edge by Quadric Edge Collapse", "[its]")
CHECK(is_similar(its_, its, cfg));
}
#include "test_utils.hpp"
TEST_CASE("Simplify mesh by Quadric edge collapse to 5%", "[its]")
{
TriangleMesh mesh = load_model("frog_legs.obj");
+21 -29
View File
@@ -191,22 +191,21 @@ static void test_expolys(Rst&& rst, const ExPolygons& ref, Vec2i32 window, const
for (const ExPolygon& expoly : ref)
rst.draw(expoly);
std::fstream out(name + ".png", std::ios::out);
out << rst.encode(sla::PNGRasterEncoder{});
out.close();
write_debug_stream("marchingsquares/" + name + ".png",
[&] { return rst.encode(sla::PNGRasterEncoder{}); });
const ExPolygons bmp = rstGetPolys(rst);
const ExPolygons ext = sla::raster_to_polygons(rst, window);
SVG svg(name + ".svg", raster_bb);
svg.draw(bmp, "green");
if (pixel_size.x() >= scale_(0.5))
svg.draw_grid(raster_bb, "grey", scale_(0.05), pixel_size.x());
if (window_size.x() >= scale_(1.0))
svg.draw_grid(raster_bb, "grey", scale_(0.10), window_size.x());
svg.draw_outline(ref, "red", "red", scale_(0.3));
svg.draw_outline(ext, "blue", "blue");
svg.Close();
write_debug_svg("marchingsquares/" + name + ".svg", raster_bb, [&](SVG &svg) {
svg.draw(bmp, "green");
if (pixel_size.x() >= scale_(0.5))
svg.draw_grid(raster_bb, "grey", scale_(0.05), pixel_size.x());
if (window_size.x() >= scale_(1.0))
svg.draw_grid(raster_bb, "grey", scale_(0.10), window_size.x());
svg.draw_outline(ref, "red", "red", scale_(0.3));
svg.draw_outline(ext, "blue", "blue");
});
// Note all these areas are unscaled back to mm^2.
double raster_area = unscaled(unscaled(area(bmp)));
@@ -432,9 +431,7 @@ static void recreate_object_from_rasters(const std::string& objname, float lh)
double disp_w = 120.96;
double disp_h = 68.04;
#ifndef NDEBUG
size_t cntr = 0;
#endif
for (ExPolygons& layer : layers) {
auto rst = create_raster(res, disp_w, disp_h);
@@ -442,11 +439,8 @@ static void recreate_object_from_rasters(const std::string& objname, float lh)
rst.draw(island);
}
#ifndef NDEBUG
std::fstream out(objname + std::to_string(cntr) + ".png", std::ios::out);
out << rst.encode(sla::PNGRasterEncoder{});
out.close();
#endif
write_debug_stream("marchingsquares/" + objname + std::to_string(cntr) + ".png",
[&] { return rst.encode(sla::PNGRasterEncoder{}); });
ExPolygons layer_ = sla::raster_to_polygons(rst);
// float delta = scaled(std::min(rst.pixel_dimensions().h_mm,
@@ -454,21 +448,19 @@ static void recreate_object_from_rasters(const std::string& objname, float lh)
// layer_ = expolygons_simplify(layer_, delta);
#ifndef NDEBUG
SVG svg(objname + std::to_string(cntr) + ".svg", rstBBox(rst));
svg.draw(layer_);
svg.draw(layer, "green");
svg.Close();
#endif
write_debug_svg("marchingsquares/" + objname + std::to_string(cntr) + ".svg", rstBBox(rst),
[&](SVG &svg) {
svg.draw(layer_);
svg.draw(layer, "green");
});
double layera = 0., layera_ = 0.;
for (auto& p : layer)
layera += p.area();
for (auto& p : layer_)
layera_ += p.area();
#ifndef NDEBUG
std::cout << cntr++ << std::endl;
#endif
++cntr;
double diff = std::abs(layera_ - layera);
REQUIRE((diff <= 0.1 * layera || diff < scaled<double>(1.) * scaled<double>(1.)));
@@ -477,7 +469,7 @@ static void recreate_object_from_rasters(const std::string& objname, float lh)
indexed_triangle_set out = slices_to_mesh(layers, bb.min.z(), double(lh), double(lh));
its_write_obj(out, "out_from_rasters.obj");
write_debug_obj("marchingsquares/out_from_rasters.obj", out);
}
TEST_CASE("Recreate object from rasters", "[SL1Import]") { recreate_object_from_rasters("frog_legs.obj", 0.05f); }
@@ -0,0 +1,297 @@
#include <catch2/catch_all.hpp>
#define SLIC3R_TEST_HARNESS
#include "libslic3r/Point.hpp"
#include "libslic3r/GCode/OrderingStrategies.hpp"
#include "libslic3r/Geometry.hpp"
#include <algorithm>
#include <unordered_set>
using namespace Slic3r;
// --- Helpers ---
static double euclidean_path_length(const std::vector<size_t>& path, const Points& centers)
{
return tsp_cycle_path_length(path, centers);
}
static bool has_crossings(const std::vector<size_t>& path, const Points& centers)
{
size_t pn = path.size();
if (pn < 4) return false;
for (size_t i = 0; i < pn; ++i) {
size_t i_next = (i + 1) % pn;
for (size_t j = i + 2; j < pn; ++j) {
if (j == i_next) continue;
if (j == (pn - 1) && i == 0) continue;
size_t j_next = (j + 1) % pn;
if (Geometry::segments_intersect(
centers[path[i]], centers[path[i_next]],
centers[path[j]], centers[path[j_next]])) {
return true;
}
}
}
return false;
}
static bool is_permutation(const std::vector<size_t>& path, size_t n)
{
if (path.size() != n) return false;
std::unordered_set<size_t> seen(path.begin(), path.end());
for (size_t i = 0; i < n; ++i) {
if (seen.count(i) != 1) return false;
}
return true;
}
// --- Test fixtures ---
static Points make_grid_4x4()
{
Points pts;
for (int row = 0; row < 4; ++row)
for (int col = 0; col < 4; ++col)
pts.emplace_back(100000 * col, 100000 * row);
return pts;
}
static Points make_linear_5()
{
Points pts;
for (int i = 0; i < 5; ++i)
pts.emplace_back(100000 * i, 0);
return pts;
}
static Points make_ring_8()
{
Points pts;
constexpr double R = 100000.0;
for (int i = 0; i < 8; ++i) {
double angle = 2.0 * M_PI * i / 8.0;
pts.emplace_back(static_cast<coord_t>(R * std::cos(angle)),
static_cast<coord_t>(R * std::sin(angle)));
}
return pts;
}
static Points make_random_16()
{
// Deterministic "random" points via simple hash.
Points pts;
for (int i = 0; i < 16; ++i) {
uint32_t h = static_cast<uint32_t>(i * 2654435761u);
coord_t x = static_cast<coord_t>((h >> 16) & 0xFFFF) * 10;
coord_t y = static_cast<coord_t>(h & 0xFFFF) * 10;
pts.emplace_back(x, y);
}
return pts;
}
// --- TSP Post-Processing Tests ---
TEST_CASE("tsp_2opt_improve reduces path length", "[TSPPostProcessing]") {
Points centers = make_random_16();
std::vector<size_t> path(centers.size());
// Reverse half the path to create a deliberately bad ordering.
for (size_t i = 0; i < path.size(); ++i) path[i] = i;
std::reverse(path.begin(), path.end() - path.size() / 2);
double before = euclidean_path_length(path, centers);
tsp_2opt_improve(path, centers);
double after = euclidean_path_length(path, centers);
REQUIRE(is_permutation(path, centers.size()));
CHECK(after <= before);
}
TEST_CASE("tsp_remove_crossings eliminates crossings", "[TSPPostProcessing]") {
Points centers = make_random_16();
std::vector<size_t> path(centers.size());
for (size_t i = 0; i < path.size(); ++i) path[i] = i;
// Create a crossing by reversing a middle segment.
if (path.size() >= 4) {
std::reverse(path.begin() + 1, path.end() - 1);
}
tsp_remove_crossings(path, centers);
CHECK(!has_crossings(path, centers));
REQUIRE(is_permutation(path, centers.size()));
}
TEST_CASE("tsp_rotate_minimize_closing shortens closing edge", "[TSPPostProcessing]") {
Points centers = make_random_16();
std::vector<size_t> path(centers.size());
for (size_t i = 0; i < path.size(); ++i) path[i] = i;
// Compute all possible closing edge lengths.
size_t pn = path.size();
double min_closing2 = std::numeric_limits<double>::max();
for (size_t start = 0; start < pn; ++start) {
size_t last = (start + pn - 1) % pn;
double d2 = (centers[path[start]].cast<double>() - centers[path[last]].cast<double>()).squaredNorm();
if (d2 < min_closing2) min_closing2 = d2;
}
tsp_rotate_minimize_closing(path, centers);
// Closing edge should be the minimum possible.
double actual_closing2 = (centers[path.front()].cast<double>() - centers[path.back()].cast<double>()).squaredNorm();
CHECK(actual_closing2 == min_closing2);
REQUIRE(is_permutation(path, centers.size()));
}
TEST_CASE("tsp_cycle_path_length is correct for triangle", "[TSPPostProcessing]") {
Points pts;
pts.emplace_back(0, 0);
pts.emplace_back(100000, 0);
pts.emplace_back(50000, 86602); // equilateral ~100mm sides
std::vector<size_t> path = {0, 1, 2};
double len = tsp_cycle_path_length(path, pts);
// Perimeter of equilateral triangle with side ~100000.
REQUIRE(len > 290000);
REQUIRE(len < 310000);
}
TEST_CASE("tsp_max_edge_length finds longest edge", "[TSPPostProcessing]") {
Points pts;
pts.emplace_back(0, 0);
pts.emplace_back(100000, 0);
pts.emplace_back(50000, 0);
std::vector<size_t> path = {0, 1, 2};
double mx = tsp_max_edge_length(path, pts);
// Longest edge is 0->1 = 100000.
CHECK(mx == Catch::Approx(100000).margin(1));
}
// --- Core Strategy Tests: Empty / Small Inputs ---
TEST_CASE("snake_core handles empty input", "[Snake]") {
Points centers;
auto path = snake_core(centers);
REQUIRE(path.empty());
}
TEST_CASE("snake_core handles single point", "[Snake]") {
Points pts{{100, 200}};
CHECK(snake_core(pts) == std::vector<size_t>{0});
}
TEST_CASE("snake_core handles two points", "[Snake]") {
Points pts{{100, 200}, {300, 400}};
auto p2 = snake_core(pts);
REQUIRE(is_permutation(p2, 2));
}
// --- Core Strategy Tests: Grid Layout ---
TEST_CASE("snake produces good path on grid", "[Snake]") {
Points centers = make_grid_4x4();
auto path = snake_core(centers);
REQUIRE(is_permutation(path, centers.size()));
CHECK(!has_crossings(path, centers));
}
// --- Core Strategy Tests: Variable Row Spacing ---
TEST_CASE("snake handles variable Y spacing", "[Snake]") {
// Rows at Y = 0, 50, 100, 1000 (large gap between last two rows).
// The adaptive row detection should identify the tight cluster (0, 50, 100)
// and the isolated row (1000) without splitting them incorrectly.
Points pts;
pts.emplace_back(0, 0); pts.emplace_back(100000, 0);
pts.emplace_back(0, 50000); pts.emplace_back(100000, 50000);
pts.emplace_back(0, 100000); pts.emplace_back(100000, 100000);
pts.emplace_back(0, 1000000); pts.emplace_back(100000, 1000000);
auto path = snake_core(pts);
REQUIRE(is_permutation(path, pts.size()));
CHECK(!has_crossings(path, pts));
}
// --- Core Strategy Tests: All Points Same Y ---
TEST_CASE("snake handles all points on same Y", "[Snake]") {
// All points share the same Y coordinate. This exercises the
// division-by-zero guard (ys.size() == 1).
Points pts;
for (int i = 0; i < 6; ++i)
pts.emplace_back(100000 * i, 50000);
auto path = snake_core(pts);
REQUIRE(is_permutation(path, pts.size()));
}
// --- Core Strategy Tests: Collinear Points ---
TEST_CASE("snake_core handles collinear points", "[Snake]") {
Points centers = make_linear_5();
auto p2 = snake_core(centers);
REQUIRE(is_permutation(p2, centers.size()));
}
// --- Core Strategy Tests: Ring Layout ---
TEST_CASE("snake_core produces valid paths on ring", "[Snake]") {
Points centers = make_ring_8();
auto p2 = snake_core(centers);
REQUIRE(is_permutation(p2, centers.size()));
}
// --- Core Strategy Tests: Random Layout ---
TEST_CASE("snake_core produces valid paths on random input", "[Snake]") {
Points centers = make_random_16();
auto p2 = snake_core(centers);
REQUIRE(is_permutation(p2, centers.size()));
}
// --- Quality Comparison Tests ---
TEST_CASE("snake has no crossings on random input", "[Snake]") {
Points centers = make_random_16();
auto path = snake_core(centers);
REQUIRE(is_permutation(path, centers.size()));
CHECK(!has_crossings(path, centers));
}
// --- Edge Cases ---
TEST_CASE("snake_core handles duplicate points", "[Snake]") {
Points pts;
pts.emplace_back(100, 200);
pts.emplace_back(100, 200); // duplicate
pts.emplace_back(300, 400);
auto p2 = snake_core(pts);
REQUIRE(p2.size() == pts.size());
}
TEST_CASE("snake_core handles three points", "[Snake]") {
Points pts;
pts.emplace_back(0, 0);
pts.emplace_back(100000, 0);
pts.emplace_back(50000, 86602);
auto p2 = snake_core(pts);
REQUIRE(is_permutation(p2, 3));
}
+1 -1
View File
@@ -229,7 +229,7 @@ TEST_CASE("halfcone test", "[halfcone]") {
indexed_triangle_set m = sla::get_mesh(br, 45);
its_merge_vertices(m);
its_write_obj(m, "Halfcone.obj");
write_debug_obj("sla_print/Halfcone.obj", m);
}
TEST_CASE("Test concurrency")
+5 -5
View File
@@ -13,7 +13,7 @@ TEST_CASE("Overhanging point should be supported", "[SupGen]") {
// Pyramid with 45 deg slope
TriangleMesh mesh = make_pyramid(10.f, 10.f);
mesh.rotate_y(float(PI));
mesh.WriteOBJFile("Pyramid.obj");
write_debug_obj("sla_supptgen/Pyramid.obj", mesh);
sla::SupportPoints pts = calc_support_pts(mesh);
@@ -55,7 +55,7 @@ TEST_CASE("Overhanging horizontal surface should be supported", "[SupGen]") {
TriangleMesh mesh = make_cube(width, depth, height);
mesh.translate(0., 0., 5.); // lift up
mesh.WriteOBJFile("Cuboid.obj");
write_debug_obj("sla_supptgen/Cuboid.obj", mesh);
sla::SupportPointGenerator::Config cfg;
sla::SupportPoints pts = calc_support_pts(mesh, cfg);
@@ -81,7 +81,7 @@ TEST_CASE("Overhanging edge should be supported", "[SupGen]") {
TriangleMesh mesh = make_prism(width, depth, height);
mesh.rotate_y(float(PI)); // rotate on its back
mesh.translate(0., 0., height);
mesh.WriteOBJFile("Prism.obj");
write_debug_obj("sla_supptgen/Prism.obj", mesh);
sla::SupportPointGenerator::Config cfg;
sla::SupportPoints pts = calc_support_pts(mesh, cfg);
@@ -106,7 +106,7 @@ TEST_CASE("Hollowed cube should be supported from the inside", "[SupGen][Hollowe
hollow_mesh(mesh, HollowingConfig{});
mesh.WriteOBJFile("cube_hollowed.obj");
write_debug_obj("sla_supptgen/cube_hollowed.obj", mesh);
auto bb = mesh.bounding_box();
auto h = float(bb.max.z() - bb.min.z());
@@ -129,7 +129,7 @@ TEST_CASE("Two parallel plates should be supported", "[SupGen][Hollowed]")
mesh_high.translate(0., 0., 10.); // lift up
mesh.merge(mesh_high);
mesh.WriteOBJFile("parallel_plates.obj");
write_debug_obj("sla_supptgen/parallel_plates.obj", mesh);
sla::SupportPointGenerator::Config cfg;
sla::SupportPoints pts = calc_support_pts(mesh, cfg);
+15 -16
View File
@@ -47,8 +47,9 @@ void test_support_model_collision(const std::string &obj_filename,
notouch = notouch && area(intersections) < PI * pinhead_r * pinhead_r;
}
/*if (!notouch) */export_failed_case(support_slices, byproducts);
if (!notouch)
export_failed_case(support_slices, byproducts);
REQUIRE(notouch);
}
@@ -62,11 +63,11 @@ void export_failed_case(const std::vector<ExPolygons> &support_slices, const Sup
std::stringstream ss;
if (!intersections.empty()) {
ss << byproducts.obj_fname << std::setprecision(4) << n << ".svg";
SVG svg(ss.str());
svg.draw(sup_slice, "green");
svg.draw(mod_slice, "blue");
svg.draw(intersections, "red");
svg.Close();
write_debug_svg("sla/" + ss.str(), [&](SVG &svg) {
svg.draw(sup_slice, "green");
svg.draw(mod_slice, "blue");
svg.draw(intersections, "red");
});
}
}
@@ -74,8 +75,8 @@ void export_failed_case(const std::vector<ExPolygons> &support_slices, const Sup
byproducts.supporttree.retrieve_full_mesh(its);
TriangleMesh m{its};
m.merge(byproducts.input_mesh);
m.WriteOBJFile((Catch::getResultCapture().getCurrentTestName() + "_" +
byproducts.obj_fname).c_str());
write_debug_obj("sla/" + Catch::getResultCapture().getCurrentTestName() +
"_" + byproducts.obj_fname, m);
}
void test_supports(const std::string &obj_filename,
@@ -350,13 +351,11 @@ void check_raster_transformations(sla::RasterBase::Orientation o, sla::RasterBas
REQUIRE((w < res.width_px && h < res.height_px));
auto px = raster.read_pixel(w, h);
if (px != FullWhite) {
std::fstream outf("out.png", std::ios::out);
outf << raster.encode(sla::PNGRasterEncoder());
}
if (px != FullWhite)
write_debug_stream("sla/raster_transform_mismatch.png",
[&] { return raster.encode(sla::PNGRasterEncoder()); });
REQUIRE(px == FullWhite);
}
+53 -5
View File
@@ -85,7 +85,8 @@ TEST_CASE_METHOD(PluginFolderFixture, "Managed builds fold into the series; cust
{
add_plugin("02.08.01.55"); // managed, same series -> folded into the 02.08.01 row
add_plugin("02.09.00.10"); // managed, unknown series -> not listed
add_plugin("02.03.00.62"); // managed, series no longer whitelisted -> not listed
add_plugin("02.03.00.62"); // managed, older whitelisted series -> folded into 02.03.00
add_plugin("02.01.01.52"); // managed, series with no ABI in this build -> not listed
add_plugin("02.08.01_custom"); // custom, whitelisted series -> listed under it
add_plugin("02.08.01.52-dev"); // custom (dash-suffixed), whitelisted series -> listed
@@ -96,17 +97,24 @@ TEST_CASE_METHOD(PluginFolderFixture, "Managed builds fold into the series; cust
REQUIRE(count_version(versions, "02.08.01") == 1);
REQUIRE(count_version(versions, "02.09.00.10") == 0);
REQUIRE(count_version(versions, "02.03.00.62") == 0);
REQUIRE(count_version(versions, "02.03.00") == 1);
REQUIRE(count_version(versions, "02.01.01.52") == 0);
// Custom-named builds are distinct files kept under their own name.
REQUIRE(count_version(versions, "02.08.01_custom") == 1);
REQUIRE(count_version(versions, "02.08.01.52-dev") == 1);
// Newest series first, its customs nested under it (suffix sort: "" < ".52-dev" < "_custom"),
// legacy last.
// then older series, legacy last.
REQUIRE(versions[0].version == "02.08.01");
REQUIRE(versions[1].version == "02.08.01.52-dev");
REQUIRE(versions[2].version == "02.08.01_custom");
REQUIRE(versions[3].version == "02.03.00");
REQUIRE(versions.back().version == BAMBU_NETWORK_AGENT_VERSION_LEGACY);
// An older whitelisted series is a flat row of its own, and never holds "(Latest)".
REQUIRE(versions[3].suffix.empty());
REQUIRE_FALSE(versions[3].is_latest);
// Customs sort/render nested under their series (non-empty suffix, base = the series).
REQUIRE(versions[1].base_version == "02.08.01");
REQUIRE_FALSE(versions[1].suffix.empty());
@@ -137,6 +145,16 @@ TEST_CASE_METHOD(PluginFolderFixture, "Only the loaded series is marked installe
REQUIRE(marked == 1);
}
// An older series is marked the same way, and never bleeds onto the latest row.
{
add_plugin("02.03.00.62");
auto versions = get_all_available_versions("02.03.00.62");
int marked = 0;
for (const auto& info : versions)
if (info.is_loaded) { ++marked; REQUIRE(info.version == "02.03.00"); }
REQUIRE(marked == 1);
}
// A loaded custom build matches its own row, never the bare series.
{
auto versions = get_all_available_versions("02.08.01_custom");
@@ -153,19 +171,25 @@ TEST_CASE_METHOD(PluginFolderFixture, "Only the loaded series is marked installe
TEST_CASE("Only whitelisted series pass the load gate", "[NetworkVersions]")
{
// The whitelisted series, its builds, and custom-named builds of that series.
// Each whitelisted series, its builds, and custom-named builds of that series.
REQUIRE(is_supported_network_version("02.08.01"));
REQUIRE(is_supported_network_version("02.08.01.52"));
REQUIRE(is_supported_network_version("02.08.01.55"));
REQUIRE(is_supported_network_version("02.08.01_custom"));
REQUIRE(is_supported_network_version("02.08.01.52-dev"));
REQUIRE(is_supported_network_version("02.03.00"));
REQUIRE(is_supported_network_version("02.03.00.62"));
REQUIRE(is_supported_network_version("02.03.00.70"));
REQUIRE(is_supported_network_version("02.03.00_custom"));
REQUIRE(is_supported_network_version(BAMBU_NETWORK_AGENT_VERSION_LEGACY));
// Series whitelisted by previous Orca releases - their ABI no longer matches.
REQUIRE_FALSE(is_supported_network_version("02.03.00.62"));
// Series whitelisted by previous Orca releases that no generation here can call.
REQUIRE_FALSE(is_supported_network_version("02.01.01.52"));
REQUIRE_FALSE(is_supported_network_version("02.00.02.50"));
// A neighbouring series of a whitelisted one is still its own ABI.
REQUIRE_FALSE(is_supported_network_version("02.03.01.51"));
// Unknown series, legacy siblings, and malformed values.
REQUIRE_FALSE(is_supported_network_version("02.09.00.10"));
std::string legacy = BAMBU_NETWORK_AGENT_VERSION_LEGACY;
@@ -175,6 +199,30 @@ TEST_CASE("Only whitelisted series pass the load gate", "[NetworkVersions]")
REQUIRE_FALSE(is_supported_network_version("02.08"));
}
TEST_CASE("Each version resolves to the ABI generation that can call it", "[NetworkVersions]")
{
// The generation is keyed on the series, so every build of a series - including the
// custom-named ones - resolves to the same one.
CHECK(network_plugin_abi("02.08.01") == NetworkAbi::Current);
CHECK(network_plugin_abi("02.08.01.55") == NetworkAbi::Current);
CHECK(network_plugin_abi("02.08.01.52-dev") == NetworkAbi::Current);
CHECK(network_plugin_abi("02.03.00") == NetworkAbi::V0203);
CHECK(network_plugin_abi("02.03.00.62") == NetworkAbi::V0203);
CHECK(network_plugin_abi("02.03.00_custom") == NetworkAbi::V0203);
CHECK(network_plugin_abi(BAMBU_NETWORK_AGENT_VERSION_LEGACY) == NetworkAbi::Legacy);
// Anything the load gate rejects must dispatch through nothing at all, rather than
// defaulting to a layout it does not share.
CHECK(network_plugin_abi("02.01.01.52") == NetworkAbi::Unsupported);
CHECK(network_plugin_abi("02.00.02.50") == NetworkAbi::Unsupported);
CHECK(network_plugin_abi("02.09.00.10") == NetworkAbi::Unsupported);
CHECK(network_plugin_abi("") == NetworkAbi::Unsupported);
// A series may only be offered once the dispatch layer implements its generation.
for (size_t i = 0; i < AVAILABLE_NETWORK_VERSIONS_COUNT; ++i)
CHECK(AVAILABLE_NETWORK_VERSIONS[i].abi != NetworkAbi::Unsupported);
}
TEST_CASE_METHOD(PluginFolderFixture, "Legacy series never adopts discovered builds", "[NetworkVersions]")
{
// A different build of the legacy series must not be surfaced: is_legacy_version()
+99
View File
@@ -3,9 +3,14 @@
#include <libslic3r/TriangleMesh.hpp>
#include <libslic3r/Format/OBJ.hpp>
#include <libslic3r/SVG.hpp>
#include <boost/filesystem.hpp>
#include <cstdio>
#include <fstream>
#include <string>
#if defined(WIN32) || defined(_WIN32)
#define PATH_SEPARATOR R"(\)"
#else
@@ -44,4 +49,98 @@ private:
boost::filesystem::path m_path;
};
// ---------------------------------------------------------------------------
// Debug-only test artifacts
//
// Files a test dumps for inspection: a mesh, an SVG, or any streamable blob such
// as a PNG. In debug builds each run writes to a fresh temp folder (path printed
// once); the name may include a subfolder (e.g. "marchingsquares/foo.svg").
// ---------------------------------------------------------------------------
// Maps name to a path under the run's temp folder, creating any parent dirs
// (forward slashes work on Windows). Not gated, so only call it from a
// write_debug_* helper or inside an #ifndef NDEBUG block.
inline std::string debug_artifact_path(const std::string &name)
{
static const boost::filesystem::path root = [] {
boost::filesystem::path dir = boost::filesystem::temp_directory_path()
/ boost::filesystem::unique_path("orca-test-artifacts-%%%%-%%%%");
boost::filesystem::create_directories(dir);
std::printf("Debug test artifacts will be written to %s\n", dir.string().c_str());
return dir;
}();
boost::filesystem::path full = root / name;
boost::filesystem::create_directories(full.parent_path());
return full.string();
}
// Dump a mesh as OBJ.
inline void write_debug_obj(const std::string &name, const Slic3r::TriangleMesh &mesh)
{
#ifndef NDEBUG
mesh.WriteOBJFile(debug_artifact_path(name).c_str());
#else
(void) name; (void) mesh;
#endif
}
inline void write_debug_obj(const std::string &name, const indexed_triangle_set &its)
{
#ifndef NDEBUG
its_write_obj(its, debug_artifact_path(name).c_str());
#else
(void) name; (void) its;
#endif
}
// Dump a mesh as ASCII STL.
inline void write_debug_stl(const std::string &name, const Slic3r::TriangleMesh &mesh)
{
#ifndef NDEBUG
mesh.write_ascii(debug_artifact_path(name).c_str());
#else
(void) name; (void) mesh;
#endif
}
// Draw an SVG artifact through a callback that receives the open SVG. Second
// overload takes a BoundingBox when the drawing needs one.
template<class Draw>
inline void write_debug_svg(const std::string &name, Draw &&draw)
{
#ifndef NDEBUG
Slic3r::SVG svg(debug_artifact_path(name));
draw(svg);
svg.Close();
#else
(void) name; (void) draw;
#endif
}
template<class Draw>
inline void write_debug_svg(const std::string &name, const Slic3r::BoundingBox &bbox, Draw &&draw)
{
#ifndef NDEBUG
Slic3r::SVG svg(debug_artifact_path(name), bbox);
draw(svg);
svg.Close();
#else
(void) name; (void) bbox; (void) draw;
#endif
}
// Write a callback's result (e.g. raster.encode(sla::PNGRasterEncoder{})) to an
// artifact. operator<< is resolved by ADL at the call site, so this header needn't
// include the producer's headers.
template<class Produce>
inline void write_debug_stream(const std::string &name, Produce &&produce)
{
#ifndef NDEBUG
std::ofstream out(debug_artifact_path(name), std::ios::out | std::ios::binary);
out << produce();
#else
(void) name; (void) produce;
#endif
}
#endif // SLIC3R_TEST_UTILS