Merge remote-tracking branch 'upstream/main' into haryr/aug25-rebase

# Conflicts:
#	resources/profiles/Custom.json
#	src/libslic3r/Brim.cpp
#	src/libslic3r/GCode.cpp
#	src/libslic3r/GCode.hpp
#	src/libslic3r/Preset.cpp
#	src/slic3r/GUI/3DScene.cpp
#	src/slic3r/GUI/ConfigManipulation.cpp
#	src/slic3r/GUI/GLCanvas3D.cpp
#	src/slic3r/GUI/Plater.cpp
This commit is contained in:
harrierpigeon
2026-08-25 06:50:46 -05:00
861 changed files with 39688 additions and 7012 deletions
+4
View File
@@ -18,7 +18,10 @@ add_executable(${_TEST_NAME}_tests
test_preset_bundle_loading.cpp
test_preset_setting_id.cpp
test_preset_diff.cpp
test_vendor_cache.cpp
test_elephant_foot_compensation.cpp
test_fill_corner_smoothing.cpp
test_fill_plane_path.cpp
test_geometry.cpp
test_multimaterial_segmentation.cpp
test_placeholder_parser.cpp
@@ -29,6 +32,7 @@ add_executable(${_TEST_NAME}_tests
test_stl.cpp
test_meshboolean.cpp
test_marchingsquares.cpp
test_model.cpp
test_utils.cpp
test_timeutils.cpp
test_voronoi.cpp
+12 -85
View File
@@ -155,10 +155,8 @@ SCENARIO("H2C multi-nozzle .3mf round-trip", "[3mf][MultiNozzle]") {
// store_bbs_3mf stages Metadata/project_settings.config through the model's backup path;
// point it at a writable temp dir (the default lives under a read-only root in CI).
std::string backup_dir =
(boost::filesystem::temp_directory_path() / boost::filesystem::unique_path("orca_mn_%%%%%%%%")).string();
boost::filesystem::create_directories(backup_dir);
model.set_backup_path(backup_dir);
ScopedTemporaryDir backup_dir("orca_mn");
model.set_backup_path(backup_dir.string());
// Global (printer) config: give nozzle_volume_type a non-default value so the slice_info
// read-back is a meaningful assertion (High Flow == 1).
@@ -180,7 +178,8 @@ SCENARIO("H2C multi-nozzle .3mf round-trip", "[3mf][MultiNozzle]") {
plate->config.set_key_value("enable_filament_dynamic_map", new ConfigOptionBool(true));
WHEN("stored to and reloaded from a .3mf") {
std::string test_file = std::string(TEST_DATA_DIR) + "/test_3mf/mn_roundtrip.3mf";
ScopedTemporaryFile temp(".3mf");
const std::string test_file = temp.string();
StoreParams store_params;
store_params.path = test_file.c_str();
@@ -202,8 +201,6 @@ SCENARIO("H2C multi-nozzle .3mf round-trip", "[3mf][MultiNozzle]") {
bool loaded = load_bbs_3mf(test_file.c_str(), &dst_config, &ctxt, &dst_model, &dst_plates,
&project_presets, &is_bbl_3mf, &is_orca_3mf, &file_version, nullptr,
LoadStrategy::LoadModel | LoadStrategy::LoadConfig);
boost::filesystem::remove(test_file);
THEN("every multi-nozzle key round-trips as expected") {
REQUIRE(loaded);
REQUIRE(dst_plates.size() >= 1);
@@ -233,7 +230,6 @@ SCENARIO("H2C multi-nozzle .3mf round-trip", "[3mf][MultiNozzle]") {
release_PlateData_list(dst_plates);
}
delete plate; // store_bbs_3mf does not take ownership of the source plate
boost::filesystem::remove_all(backup_dir);
}
}
@@ -250,10 +246,8 @@ SCENARIO("Non-standard nozzle diameter survives .3mf save on a single-nozzle pri
REQUIRE(load_stl(src_file.c_str(), &model));
model.add_default_instances();
std::string backup_dir =
(boost::filesystem::temp_directory_path() / boost::filesystem::unique_path("orca_nd_%%%%%%%%")).string();
boost::filesystem::create_directories(backup_dir);
model.set_backup_path(backup_dir);
ScopedTemporaryDir backup_dir("orca_nd");
model.set_backup_path(backup_dir.string());
// Single extruder with a non-standard 0.5 mm nozzle; extruder_max_nozzle_count stays at its
// default (no nozzle cluster), so the writer must emit the exact config diameter.
@@ -276,7 +270,8 @@ SCENARIO("Non-standard nozzle diameter survives .3mf save on a single-nozzle pri
plate->slice_filaments_info.push_back(fi);
WHEN("stored to and reloaded from a .3mf") {
std::string test_file = std::string(TEST_DATA_DIR) + "/test_3mf/nd_roundtrip.3mf";
ScopedTemporaryFile temp(".3mf");
const std::string test_file = temp.string();
StoreParams store_params;
store_params.path = test_file.c_str();
@@ -296,8 +291,6 @@ SCENARIO("Non-standard nozzle diameter survives .3mf save on a single-nozzle pri
bool loaded = load_bbs_3mf(test_file.c_str(), &dst_config, &ctxt, &dst_model, &dst_plates,
&project_presets, &is_bbl_3mf, &is_orca_3mf, &file_version, nullptr,
LoadStrategy::LoadModel | LoadStrategy::LoadConfig);
boost::filesystem::remove(test_file);
THEN("the saved nozzle diameter is the exact 0.5, not the rounded 0.4") {
REQUIRE(loaded);
REQUIRE(dst_plates.size() >= 1);
@@ -315,7 +308,6 @@ SCENARIO("Non-standard nozzle diameter survives .3mf save on a single-nozzle pri
release_PlateData_list(dst_plates);
}
delete plate; // store_bbs_3mf does not take ownership of the source plate
boost::filesystem::remove_all(backup_dir);
}
}
@@ -436,10 +428,8 @@ SCENARIO("Nozzle-group metadata .3mf round-trip", "[3mf][MultiNozzle]") {
REQUIRE(load_stl(src_file.c_str(), &model));
model.add_default_instances();
std::string backup_dir =
(boost::filesystem::temp_directory_path() / boost::filesystem::unique_path("orca_ng_%%%%%%%%")).string();
boost::filesystem::create_directories(backup_dir);
model.set_backup_path(backup_dir);
ScopedTemporaryDir backup_dir("orca_ng");
model.set_backup_path(backup_dir.string());
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
@@ -459,7 +449,8 @@ SCENARIO("Nozzle-group metadata .3mf round-trip", "[3mf][MultiNozzle]") {
plate->config.set_key_value("filament_map", new ConfigOptionInts({ 1, 2, 1 }));
WHEN("stored to and reloaded from a .3mf") {
std::string test_file = std::string(TEST_DATA_DIR) + "/test_3mf/ng_roundtrip.3mf";
ScopedTemporaryFile temp(".3mf");
const std::string test_file = temp.string();
StoreParams store_params;
store_params.path = test_file.c_str();
@@ -479,8 +470,6 @@ SCENARIO("Nozzle-group metadata .3mf round-trip", "[3mf][MultiNozzle]") {
bool loaded = load_bbs_3mf(test_file.c_str(), &dst_config, &ctxt, &dst_model, &dst_plates,
&project_presets, &is_bbl_3mf, &is_orca_3mf, &file_version, nullptr,
LoadStrategy::LoadModel | LoadStrategy::LoadConfig);
boost::filesystem::remove(test_file);
THEN("the <nozzle> tags round-trip into the loaded plate's nozzles_info") {
REQUIRE(loaded);
REQUIRE(dst_plates.size() >= 1);
@@ -506,67 +495,5 @@ SCENARIO("Nozzle-group metadata .3mf round-trip", "[3mf][MultiNozzle]") {
release_PlateData_list(dst_plates);
}
delete plate;
boost::filesystem::remove_all(backup_dir);
}
}
SCENARIO("2D convex hull of sinking object", "[3mf][.]") {
GIVEN("model") {
// load a model
Model model;
std::string src_file = std::string(TEST_DATA_DIR) + "/test_3mf/Prusa.stl";
REQUIRE(load_stl(src_file.c_str(), &model));
model.add_default_instances();
WHEN("model is rotated, scaled and set as sinking") {
ModelObject* object = model.objects[0];
object->center_around_origin(false);
// This outputs the same exact data as the Prusaslicer test
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];
instance->set_rotation(X, -M_PI / 4.0);
instance->set_offset(Vec3d::Zero());
instance->set_scaling_factor({ 2.0, 2.0, 2.0 });
// calculate 2D convex hull
auto trafo = instance->get_transformation().get_matrix();
// This matrix is the same exact matrix as the Prusaslicer test
CAPTURE(trafo);
Polygon hull_2d = object->convex_hull_2d(trafo);
// But we get different hull_2d.points here (and somehow decimal numbers despite being int64_t values, but that's probabaly printing configuration somewhere -- Prusaslicer's prints out with newlines between the X&Y and not one between coordinates, which is about the worse possible output).
// I think it's something to do with PrusaSlicer ignoring everything under the Z plane, which makes sense from the results.
// See the comments added to ModelObject::convex_hull_2d for more information.
// verify result
Points result = {
{ -91501496, -15914144 },
{ 91501496, -15914144 },
{ 91501496, 4243 },
{ 78229680, 4246883 },
{ 56898100, 4246883 },
{ -85501496, 4242641 },
{ -91501496, 4243 }
};
THEN("2D convex hull should match with reference") {
// Allow 1um error due to floating point rounding.
bool res = hull_2d.points.size() == result.size();
if (res) {
for (size_t i = 0; i < result.size(); ++ i) {
const Point &p1 = result[i];
const Point &p2 = hull_2d.points[i];
CHECK((std::abs(p1.x() - p2.x()) > 1 || std::abs(p1.y() - p2.y()) > 1));
}
}
CAPTURE(hull_2d.points);
REQUIRE(res);
}
}
}
}
+53 -4
View File
@@ -4,6 +4,8 @@
#include "libslic3r/PrintConfigConstants.hpp"
#include "libslic3r/LocalesUtils.hpp"
#include "test_utils.hpp"
#include <cereal/types/polymorphic.hpp>
#include <cereal/types/string.hpp>
#include <cereal/types/vector.hpp>
@@ -233,6 +235,56 @@ SCENARIO("Config ini load/save interface", "[Config]") {
}
}
TEST_CASE("Flush-volume warning predicate respects used filament transitions", "[Config][Regression]")
{
const std::vector<double> multipliers = {1.0};
SECTION("Single used filament does not trigger warning with zero transition entries")
{
const std::vector<double> matrix = {
0.0, 0.0,
0.0, 0.0
};
const std::vector<int> used_filaments = {1};
REQUIRE_FALSE(has_zero_flush_volume_for_used_filaments(matrix, multipliers, used_filaments));
}
SECTION("Two used filaments trigger warning when transition flush entry is zero")
{
const std::vector<double> matrix = {
0.0, 0.0,
0.0, 0.0
};
const std::vector<int> used_filaments = {1, 2};
REQUIRE(has_zero_flush_volume_for_used_filaments(matrix, multipliers, used_filaments));
}
SECTION("Two used filaments do not trigger warning when transitions are non-zero")
{
const std::vector<double> matrix = {
0.0, 280.0,
280.0, 0.0
};
const std::vector<int> used_filaments = {1, 2};
REQUIRE_FALSE(has_zero_flush_volume_for_used_filaments(matrix, multipliers, used_filaments));
}
SECTION("Zero multiplier still triggers warning when multiple filaments are used")
{
const std::vector<double> matrix = {
0.0, 280.0,
280.0, 0.0
};
const std::vector<double> zero_multiplier = {0.0};
const std::vector<int> used_filaments = {1, 2};
REQUIRE(has_zero_flush_volume_for_used_filaments(matrix, zero_multiplier, used_filaments));
}
}
// TODO: https://github.com/SoftFever/OrcaSlicer/issues/11269 - Is this test still relevant? Delete if not.
// It was failing so at least "nozzle_type" and "extruder_printable_area" could not be serialized
// and an exception was thrown, but "nozzle_type" has been around for at least 3 months now.
@@ -407,8 +459,7 @@ SCENARIO("update_diff_values_to_child_config tolerates legacy machine-limit vect
// }
TEST_CASE("save_to_json round-trips plugin capability references as strings", "[Config][plugins]") {
namespace fs = boost::filesystem;
const fs::path tmp = fs::temp_directory_path() / fs::unique_path("orca_plugins_%%%%-%%%%.json");
ScopedTemporaryFile tmp(".json");
const std::vector<std::string> refs = {
"local_plugin;;inset",
"cloud_plugin;550e8400-e29b-41d4-a716-446655440000;inset"
@@ -435,8 +486,6 @@ TEST_CASE("save_to_json round-trips plugin capability references as strings", "[
REQUIRE(reloaded.load_from_json(tmp.string(), substitutions, true, key_values, reason) == 0);
CHECK(reason.empty());
CHECK(reloaded.option<ConfigOptionStrings>("slicing_pipeline_plugin")->values == refs);
fs::remove(tmp);
}
TEST_CASE("plugin capability references survive string-map serialization", "[Config][plugins]") {
@@ -0,0 +1,173 @@
#include <catch2/catch_all.hpp>
#include <algorithm>
#include <cmath>
#include <limits>
#include "libslic3r/Fill/FillCornerSmoothing.hpp"
#include "libslic3r/Polyline.hpp"
#include "libslic3r/libslic3r.h"
using namespace Slic3r;
namespace {
// A right angle turn, with the outgoing leg ten times longer than the incoming one.
Polyline asymmetric_corner()
{
return Polyline{ Point::new_scale(0., 0.), Point::new_scale(10., 0.), Point::new_scale(10., 100.) };
}
double max_turn_cosine(const Polyline &polyline)
{
double sharpest = 1.;
for (size_t i = 1; i + 1 < polyline.size(); ++i) {
const Vec2d incoming = (polyline[i] - polyline[i - 1]).cast<double>().normalized();
const Vec2d outgoing = (polyline[i + 1] - polyline[i]).cast<double>().normalized();
sharpest = std::min(sharpest, incoming.dot(outgoing));
}
return sharpest;
}
bool contains(const Polyline &polyline, const Point &point)
{
return std::find(polyline.points.begin(), polyline.points.end(), point) != polyline.points.end();
}
const double tolerance = scaled<double>(0.0125);
} // namespace
TEST_CASE("Corner smoothing replaces a sharp vertex by a curve", "[FillCornerSmoothing]")
{
const Polyline sharp = asymmetric_corner();
Polyline smooth = sharp;
smooth_polyline_corners(smooth, 1., tolerance);
REQUIRE(smooth.size() > sharp.size());
REQUIRE(smooth.front() == sharp.front());
REQUIRE(smooth.back() == sharp.back());
// The right angle is gone, every remaining turn is a gentle one.
REQUIRE(max_turn_cosine(sharp) < 0.1);
REQUIRE(max_turn_cosine(smooth) > 0.9);
REQUIRE(smooth.length() < sharp.length());
}
TEST_CASE("Corner smoothing keeps the path untouched at a zero factor", "[FillCornerSmoothing]")
{
const Polyline sharp = asymmetric_corner();
Polyline none = sharp;
smooth_polyline_corners(none, 0., tolerance);
REQUIRE(none.points == sharp.points);
Polyline invalid = sharp;
smooth_polyline_corners(invalid, std::numeric_limits<double>::quiet_NaN(), tolerance);
REQUIRE(invalid.points == sharp.points);
}
TEST_CASE("Corner smoothing consumes at most half of the shorter leg", "[FillCornerSmoothing]")
{
// The curve must not reach beyond the middle of either adjoining segment, otherwise the curves of
// two adjacent corners would overlap. The shorter leg is 10mm long, so the corner at (10, 0) is
// left 5mm before it and rejoined 5mm past it, even though the other leg is 100mm long.
Polyline smooth = asymmetric_corner();
smooth_polyline_corners(smooth, 1., tolerance);
REQUIRE(contains(smooth, Point::new_scale(5., 0.)));
REQUIRE(contains(smooth, Point::new_scale(10., 5.)));
// A Bezier curve stays within the convex hull of its control points, so the rounded path stays
// inside the box spanned by the two legs.
for (const Point &point : smooth.points) {
REQUIRE(point.x() >= 0);
REQUIRE(point.y() >= 0);
REQUIRE(point.x() <= Point::new_scale(10., 0.).x());
REQUIRE(point.y() <= Point::new_scale(0., 100.).y());
}
}
TEST_CASE("Corner smoothing scales the curve with the factor", "[FillCornerSmoothing]")
{
Polyline half = asymmetric_corner();
smooth_polyline_corners(half, 0.5, tolerance);
Polyline full = asymmetric_corner();
smooth_polyline_corners(full, 1., tolerance);
// Half of the factor leaves the 10mm leg half as far from the corner.
REQUIRE(contains(half, Point::new_scale(7.5, 0.)));
REQUIRE(contains(full, Point::new_scale(5., 0.)));
// A larger factor rounds a wider portion of the legs, cutting more of the corner off.
REQUIRE(full.length() < half.length());
}
TEST_CASE("Corner smoothing leaves hairpins sharp", "[FillCornerSmoothing]")
{
// Both ends of a curve replacing a nearly reversing turn coincide, which would round the hairpin
// into a degenerate loop instead of a tip.
Polyline hairpin{ Point::new_scale(0., 0.), Point::new_scale(10., 0.), Point::new_scale(0., 0.5) };
const Polyline sharp = hairpin;
smooth_polyline_corners(hairpin, 1., tolerance);
REQUIRE(hairpin == sharp);
}
TEST_CASE("Corner smoothing follows the flattening tolerance", "[FillCornerSmoothing]")
{
Polyline coarse = asymmetric_corner();
smooth_polyline_corners(coarse, 1., scaled<double>(0.2));
Polyline fine = asymmetric_corner();
smooth_polyline_corners(fine, 1., scaled<double>(0.001));
REQUIRE(fine.size() > coarse.size());
REQUIRE(fine.front() == coarse.front());
REQUIRE(fine.back() == coarse.back());
}
TEST_CASE("Corner smoothing emits no zero length segments", "[FillCornerSmoothing]")
{
// Fully smoothed adjacent corners meet at the midpoint of the segment they share.
Polyline zigzag;
for (int i = 0; i < 8; ++i)
zigzag.points.emplace_back(Point::new_scale(i, i % 2 ? 1. : 0.));
smooth_polyline_corners(zigzag, 1., tolerance);
for (size_t i = 1; i < zigzag.size(); ++i)
REQUIRE((zigzag[i] - zigzag[i - 1]).cast<double>().squaredNorm() > 0.);
}
TEST_CASE("Corner smoothing rounds every vertex of a polygon", "[FillCornerSmoothing]")
{
// A polygon closes implicitly, so none of its corners may stay sharp, not even the first one.
const Polygon square{ Point::new_scale(0., 0.), Point::new_scale(10., 0.), Point::new_scale(10., 10.),
Point::new_scale(0., 10.) };
Polygons smooth{ square };
smooth_polygons_corners(smooth, 1., tolerance);
const Polyline rounded = smooth.front().split_at_first_point();
REQUIRE(smooth.front().size() > square.size());
REQUIRE(max_turn_cosine(rounded) > 0.9);
// The turn from the closing segment back into the first one must be gentle as well.
const Vec2d incoming = (rounded[rounded.size() - 1] - rounded[rounded.size() - 2]).cast<double>().normalized();
const Vec2d outgoing = (rounded[1] - rounded[0]).cast<double>().normalized();
REQUIRE(incoming.dot(outgoing) > 0.9);
// None of the corners is cut by more than half of a 10mm side.
for (const Point &point : smooth.front().points) {
REQUIRE(point.x() >= 0);
REQUIRE(point.y() >= 0);
REQUIRE(point.x() <= Point::new_scale(10., 0.).x());
REQUIRE(point.y() <= Point::new_scale(0., 10.).y());
}
}
TEST_CASE("Corner smoothing keeps the ends of a path that returns to its start", "[FillCornerSmoothing][Regression]")
{
// A branch of a lightning tree walks out and retraces its way back, ending where it started. Its
// ends are two free ends that happen to coincide, and joining them would close it into a loop.
Polyline retrace{ Point::new_scale(0., 0.), Point::new_scale(10., 0.), Point::new_scale(10., 10.),
Point::new_scale(5., 10.), Point::new_scale(0., 0.) };
const Polyline sharp = retrace;
smooth_polyline_corners(retrace, 1., tolerance);
REQUIRE(retrace.size() > sharp.size());
REQUIRE(retrace.front() == sharp.front());
REQUIRE(retrace.back() == sharp.back());
}
+218
View File
@@ -0,0 +1,218 @@
#include <catch2/catch_all.hpp>
#include <algorithm>
#include <cmath>
#include <limits>
#include <utility>
#include "libslic3r/Fill/FillPlanePath.hpp"
#include "libslic3r/PrintConfig.hpp"
using namespace Slic3r;
namespace {
constexpr double output_scale = 1'000'000.;
class TestableHilbertCurve : public FillHilbertCurve
{
public:
Points generate_points(double resolution, double smooth_factor = 0., coord_t max_coordinate = 7)
{
InfillPolylineOutput output(output_scale);
FillParams params;
params.smooth_factor = smooth_factor;
FillHilbertCurve::generate(0, 0, max_coordinate, max_coordinate, resolution, params, output);
return std::move(output.result());
}
};
class TestableOctagramSpiral : public FillOctagramSpiral
{
public:
Points generate_points(double resolution, double smooth_factor = 0., coord_t max_coordinate = 7)
{
InfillPolylineOutput output(output_scale);
FillParams params;
params.smooth_factor = smooth_factor;
FillOctagramSpiral::generate(-max_coordinate, -max_coordinate, max_coordinate, max_coordinate, resolution, params, output);
return std::move(output.result());
}
};
// Cosine of the sharpest turn of a path, 1 meaning it has no turn at all.
double sharpest_turn_cosine(const Points &points)
{
double sharpest = 1.;
for (size_t i = 1; i + 1 < points.size(); ++i) {
const Vec2d incoming = (points[i] - points[i - 1]).cast<double>().normalized();
const Vec2d outgoing = (points[i + 1] - points[i]).cast<double>().normalized();
sharpest = std::min(sharpest, incoming.dot(outgoing));
}
return sharpest;
}
double path_length(const Points &points)
{
double length = 0.;
for (size_t i = 1; i < points.size(); ++i)
length += (points[i] - points[i - 1]).cast<double>().norm();
return length;
}
double discrete_curvature_at(const Points &points, const Point &point)
{
const auto point_it = std::find(points.begin(), points.end(), point);
REQUIRE(point_it != points.end());
const size_t point_idx = size_t(std::distance(points.begin(), point_it));
REQUIRE(point_idx > 0);
REQUIRE(point_idx + 1 < points.size());
const Vec2d incoming = (points[point_idx] - points[point_idx - 1]).cast<double>() / output_scale;
const Vec2d outgoing = (points[point_idx + 1] - points[point_idx]).cast<double>() / output_scale;
const Vec2d chord = incoming + outgoing;
const double cross = std::abs(incoming.x() * outgoing.y() - incoming.y() * outgoing.x());
return 2. * cross / (incoming.norm() * outgoing.norm() * chord.norm());
}
} // namespace
TEST_CASE("Hilbert curve exposes a smoothing factor", "[FillPlanePath]")
{
const ConfigOptionDef *factor_def = print_config_def.get("sparse_infill_smooth_factor");
REQUIRE(factor_def != nullptr);
REQUIRE(factor_def->type == coPercent);
REQUIRE_THAT(factor_def->min, Catch::Matchers::WithinAbs(0., 1e-12));
REQUIRE_THAT(factor_def->max, Catch::Matchers::WithinAbs(100., 1e-12));
REQUIRE_THAT(factor_def->get_default_value<ConfigOptionPercent>()->value,
Catch::Matchers::WithinAbs(0., 1e-12));
}
TEST_CASE("Hilbert curve smoothing rounds right angle turns", "[FillPlanePath]")
{
const Points sharp = TestableHilbertCurve().generate_points(0.005);
const Points smooth = TestableHilbertCurve().generate_points(0.005, 1.);
REQUIRE(smooth.front() == sharp.front());
REQUIRE(smooth.back() == sharp.back());
REQUIRE(smooth.size() > sharp.size());
bool has_turn = false;
for (size_t i = 1; i < smooth.size(); ++i) {
const Vec2d segment = (smooth[i] - smooth[i - 1]).cast<double>();
REQUIRE(segment.squaredNorm() > 0.);
}
for (size_t i = 1; i + 1 < smooth.size(); ++i) {
const Vec2d incoming = (smooth[i] - smooth[i - 1]).cast<double>();
const Vec2d outgoing = (smooth[i + 1] - smooth[i]).cast<double>();
const double cross = incoming.x() * outgoing.y() - incoming.y() * outgoing.x();
const double cosine = incoming.dot(outgoing) / (incoming.norm() * outgoing.norm());
has_turn |= std::abs(cross) > 0.;
REQUIRE(cosine > 0.);
}
REQUIRE(has_turn);
const coord_t upper_bound = coord_t(7 * output_scale);
for (const Point &point : smooth) {
REQUIRE(point.x() >= 0);
REQUIRE(point.y() >= 0);
REQUIRE(point.x() <= upper_bound);
REQUIRE(point.y() <= upper_bound);
}
}
TEST_CASE("Smoothed Hilbert curve honors path resolution", "[FillPlanePath]")
{
const Points coarse = TestableHilbertCurve().generate_points(0.1, 1.);
const Points fine = TestableHilbertCurve().generate_points(0.001, 1.);
REQUIRE(fine.size() > coarse.size());
REQUIRE(fine.front() == coarse.front());
REQUIRE(fine.back() == coarse.back());
}
TEST_CASE("Smoothed Hilbert corners use a uniform subdivision depth", "[FillPlanePath]")
{
const Points smooth = TestableHilbertCurve().generate_points(0.0035, 1., 1);
const Point curve_entry(0, coord_t(0.5 * output_scale));
const Point curve_exit(coord_t(0.5 * output_scale), coord_t(output_scale));
const auto entry_it = std::find(smooth.begin(), smooth.end(), curve_entry);
REQUIRE(entry_it != smooth.end());
const auto exit_it = std::find(entry_it, smooth.end(), curve_exit);
REQUIRE(exit_it != smooth.end());
const size_t segment_count = size_t(std::distance(entry_it, exit_it));
REQUIRE(segment_count > 1);
REQUIRE((segment_count & (segment_count - 1)) == 0);
double previous_length = (entry_it[1] - entry_it[0]).cast<double>().norm();
REQUIRE(previous_length > 0.);
double max_length_ratio = 1.;
for (size_t segment = 1; segment < segment_count; ++segment) {
const double current_length = (entry_it[segment + 1] - entry_it[segment]).cast<double>().norm();
REQUIRE(current_length > 0.);
max_length_ratio = std::max(max_length_ratio,
std::max(current_length / previous_length, previous_length / current_length));
previous_length = current_length;
}
REQUIRE(max_length_ratio < 1.5);
}
TEST_CASE("Hilbert smoothing joins straight segments with continuous curvature", "[FillPlanePath]")
{
const Points coarse = TestableHilbertCurve().generate_points(0.005, 0.5, 1);
const Points fine = TestableHilbertCurve().generate_points(0.0001, 0.5, 1);
const Point first_curve_entry(0, coord_t(0.75 * output_scale));
const double coarse_entry_curvature = discrete_curvature_at(coarse, first_curve_entry);
const double fine_entry_curvature = discrete_curvature_at(fine, first_curve_entry);
REQUIRE(coarse_entry_curvature > 0.);
REQUIRE(fine_entry_curvature < 0.25 * coarse_entry_curvature);
}
TEST_CASE("Octagram spiral smoothing rounds the turns of the spiral", "[FillPlanePath]")
{
const Points sharp = TestableOctagramSpiral().generate_points(0.005);
const Points smooth = TestableOctagramSpiral().generate_points(0.005, 1.);
REQUIRE(smooth.size() > sharp.size());
REQUIRE(smooth.front() == sharp.front());
REQUIRE(smooth.back() == sharp.back());
// The spiral alternates between 90 and 135 degree turns; both are rounded into gentle ones.
REQUIRE(sharpest_turn_cosine(sharp) < -0.7);
REQUIRE(sharpest_turn_cosine(smooth) > 0.9);
for (size_t i = 1; i < smooth.size(); ++i)
REQUIRE((smooth[i] - smooth[i - 1]).cast<double>().squaredNorm() > 0.);
}
TEST_CASE("Octagram spiral smooth factor controls corner curvature", "[FillPlanePath]")
{
const Points sharp = TestableOctagramSpiral().generate_points(0.005);
const Points half_smooth = TestableOctagramSpiral().generate_points(0.005, 0.5);
const Points full_smooth = TestableOctagramSpiral().generate_points(0.005, 1.);
const Points invalid_factor = TestableOctagramSpiral().generate_points(
0.005, std::numeric_limits<double>::quiet_NaN());
REQUIRE(path_length(full_smooth) < path_length(half_smooth));
REQUIRE(path_length(half_smooth) < path_length(sharp));
REQUIRE(invalid_factor == sharp);
}
TEST_CASE("Hilbert curve smooth factor controls corner curvature", "[FillPlanePath]")
{
const Points sharp = TestableHilbertCurve().generate_points(0.005);
const Points half_smooth = TestableHilbertCurve().generate_points(0.005, 0.5);
const Points full_smooth = TestableHilbertCurve().generate_points(0.005, 1.);
const Points invalid_factor = TestableHilbertCurve().generate_points(
0.005, std::numeric_limits<double>::quiet_NaN());
REQUIRE(full_smooth.front() == half_smooth.front());
REQUIRE(full_smooth.back() == half_smooth.back());
REQUIRE(path_length(full_smooth) < path_length(half_smooth));
REQUIRE(invalid_factor == sharp);
for (size_t i = 1; i < full_smooth.size(); ++i)
REQUIRE((full_smooth[i] - full_smooth[i - 1]).squaredNorm() > 0);
}
-5
View File
@@ -574,11 +574,6 @@ TEST_CASE("Convex polygon intersection on two squares touching one vertex", "[Ge
Polygon B = A;
B.translate(10 / SCALING_FACTOR, 10 / SCALING_FACTOR);
SVG svg{std::string("one_vertex_touch") + ".svg"};
svg.draw(A, "blue");
svg.draw(B, "green");
svg.Close();
bool is_inters = Geometry::convex_polygons_intersect(A, B);
REQUIRE(is_inters == false);
+40
View File
@@ -0,0 +1,40 @@
#include <catch2/catch_all.hpp>
#include "libslic3r/Model.hpp"
using namespace Slic3r;
// convex_hull_2d does not clip geometry below the bed, so these cases avoid
// sinking transforms.
TEST_CASE("A part's 2D convex hull is its footprint projected onto the bed", "[Model]")
{
Model model;
ModelObject* object = model.add_object();
// Keep the cube's raw coordinates ([0,20] on every axis): the default
// add_volume re-centers the geometry, which would move the footprint.
object->add_volume(make_cube(20, 20, 20), ModelVolumeType::MODEL_PART, false);
SECTION("identity transform yields the 20 mm square") {
const Polygon hull = object->convex_hull_2d(Geometry::Transformation{}.get_matrix());
const BoundingBox bb = hull.bounding_box();
CHECK(hull.size() == 4);
CHECK(bb.min.x() == scaled(0.));
CHECK(bb.min.y() == scaled(0.));
CHECK(bb.max.x() == scaled(20.));
CHECK(bb.max.y() == scaled(20.));
}
SECTION("scaling and offset move and grow the footprint") {
Geometry::Transformation t;
t.set_scaling_factor({2, 2, 2}); // cube now spans [0,40]
t.set_offset({10, 5, 0}); // then shift +10 in X, +5 in Y
const Polygon hull = object->convex_hull_2d(t.get_matrix());
const BoundingBox bb = hull.bounding_box();
CHECK(hull.size() == 4);
CHECK(bb.min.x() == scaled(10.));
CHECK(bb.min.y() == scaled(5.));
CHECK(bb.max.x() == scaled(50.));
CHECK(bb.max.y() == scaled(45.));
}
}
+111 -33
View File
@@ -1,32 +1,19 @@
#include <catch2/catch_all.hpp>
#include <boost/filesystem.hpp>
#include <fstream>
#include "libslic3r/PresetBundle.hpp"
#include "libslic3r/AppConfig.hpp"
#include "test_utils.hpp"
using namespace Slic3r;
namespace {
namespace fs = boost::filesystem;
struct TempPresetDir {
fs::path path;
TempPresetDir()
{
path = fs::temp_directory_path() / fs::unique_path("orcaslicer-preset-%%%%-%%%%-%%%%");
fs::create_directories(path);
}
~TempPresetDir()
{
boost::system::error_code ec;
fs::remove_all(path, ec);
}
};
void write_print_preset(const DynamicPrintConfig &default_config, const fs::path &file, const std::string &name, const std::string &inherits = {})
{
DynamicPrintConfig config(default_config);
@@ -82,17 +69,17 @@ struct RenameTestCollection : public PresetCollection
TEST_CASE("Preset identity is canonicalized from load path", "[Preset][Identity]")
{
TempPresetDir temp_dir;
ScopedTemporaryDir temp_dir;
PresetBundle bundle;
PresetsConfigSubstitutions substitutions;
write_print_preset(bundle.prints.default_preset().config, temp_dir.path / PRESET_PRINT_NAME / "User.json", "User");
write_print_preset(bundle.prints.default_preset().config, temp_dir.path / PRESET_LOCAL_DIR / "bundle-1" / PRESET_PRINT_NAME / "LocalBundle.json", "LocalBundle");
write_print_preset(bundle.prints.default_preset().config, temp_dir.path / PRESET_SUBSCRIBED_DIR / "remote-1" / PRESET_PRINT_NAME / "Subscribed.json", "Subscribed");
write_print_preset(bundle.prints.default_preset().config, temp_dir.path() / PRESET_PRINT_NAME / "User.json", "User");
write_print_preset(bundle.prints.default_preset().config, temp_dir.path() / PRESET_LOCAL_DIR / "bundle-1" / PRESET_PRINT_NAME / "LocalBundle.json", "LocalBundle");
write_print_preset(bundle.prints.default_preset().config, temp_dir.path() / PRESET_SUBSCRIBED_DIR / "remote-1" / PRESET_PRINT_NAME / "Subscribed.json", "Subscribed");
bundle.prints.load_presets(temp_dir.path.string(), PRESET_PRINT_NAME, substitutions, ForwardCompatibilitySubstitutionRule::Disable);
bundle.prints.load_presets((temp_dir.path / PRESET_LOCAL_DIR / "bundle-1").string(), PRESET_PRINT_NAME, substitutions, ForwardCompatibilitySubstitutionRule::Disable);
bundle.prints.load_presets((temp_dir.path / PRESET_SUBSCRIBED_DIR / "remote-1").string(), PRESET_PRINT_NAME, substitutions, ForwardCompatibilitySubstitutionRule::Disable);
bundle.prints.load_presets(temp_dir.path().string(), PRESET_PRINT_NAME, substitutions, ForwardCompatibilitySubstitutionRule::Disable);
bundle.prints.load_presets((temp_dir.path() / PRESET_LOCAL_DIR / "bundle-1").string(), PRESET_PRINT_NAME, substitutions, ForwardCompatibilitySubstitutionRule::Disable);
bundle.prints.load_presets((temp_dir.path() / PRESET_SUBSCRIBED_DIR / "remote-1").string(), PRESET_PRINT_NAME, substitutions, ForwardCompatibilitySubstitutionRule::Disable);
const Preset *root_user = bundle.prints.find_preset("User");
REQUIRE(root_user != nullptr);
@@ -112,14 +99,14 @@ TEST_CASE("Preset identity is canonicalized from load path", "[Preset][Identity]
TEST_CASE("Legacy bundle import without bundle metadata stays in the user preset directory", "[Preset][Identity]")
{
TempPresetDir temp_dir;
ScopedTemporaryDir temp_dir;
PresetBundle bundle;
PresetsConfigSubstitutions substitutions;
std::vector<std::string> result;
int overwrite = 0;
std::string file = (temp_dir.path / "legacy-bundle" / "Imported.json").string();
const fs::path user_root = temp_dir.path / "user";
std::string file = (temp_dir.path() / "legacy-bundle" / "Imported.json").string();
const fs::path user_root = temp_dir.path() / "user";
write_print_preset(bundle.prints.default_preset().config, file, "Imported");
fs::create_directories(user_root);
@@ -146,7 +133,7 @@ TEST_CASE("Current vendor type tolerates missing printer model", "[Preset][Bundl
{
PresetBundle bundle;
VendorProfile orca_vendor("ORCA");
VendorProfile orca_vendor; orca_vendor.id = "ORCA";
VendorProfile::PrinterModel model;
model.name = "Orca Test";
orca_vendor.models.emplace_back(model);
@@ -157,6 +144,31 @@ TEST_CASE("Current vendor type tolerates missing printer model", "[Preset][Bundl
CHECK(bundle.get_current_vendor_type() == VendorType::Unknown);
}
TEST_CASE("A malformed entry in a vendor's preset list is counted, not thrown", "[Preset][Bundle]")
{
ScopedTemporaryDir dir;
// A bare number where the list wants an object. An array element has no key,
// so reporting one as if it did throws nlohmann's invalid_iterator - which is
// not a parse_error, and escapes the catch around the vendor profile parse.
std::ofstream((dir.path() / "Acme.json").string())
<< R"({"version":"1.0.0","name":"Acme","process_list":[123,)"
<< R"({"name":"0.20mm Standard @Acme","sub_path":"process/standard.json"}]})";
fs::create_directories(dir.path() / "Acme" / "process");
std::ofstream((dir.path() / "Acme" / "process" / "standard.json").string())
<< R"({"type":"process","name":"0.20mm Standard @Acme","from":"system",)"
<< R"("instantiation":"true","layer_height":"0.2"})";
PresetBundle bundle;
size_t loaded = 0;
REQUIRE_NOTHROW(loaded = bundle.load_vendor_configs_from_json(
dir.path().string(), "Acme", PresetBundle::LoadSystem,
ForwardCompatibilitySubstitutionRule::EnableSilent).second);
CHECK(bundle.error_count() > 0); // the malformed element was counted
CHECK(loaded == 1); // the well-formed one beside it still loaded
}
TEST_CASE("Printer extruder count tolerates missing nozzle diameter", "[Preset][Bundle]")
{
PresetBundle bundle;
@@ -252,7 +264,7 @@ TEST_CASE("find_preset2 auto-matches removed Generic vendor profiles to the libr
TEST_CASE("Renamed parent is normalized into a loaded preset's inherits", "[Preset][Rename]")
{
TempPresetDir temp_dir;
ScopedTemporaryDir temp_dir;
RenameTestCollection coll;
// Current parent, renamed from "Old Process".
@@ -262,10 +274,10 @@ TEST_CASE("Renamed parent is normalized into a loaded preset's inherits", "[Pres
// A user preset on disk that still inherits the OLD name.
write_preset_with_inherits(coll.default_preset().config,
temp_dir.path / PRESET_PRINT_NAME / "Child.json", "Child", "Old Process");
temp_dir.path() / PRESET_PRINT_NAME / "Child.json", "Child", "Old Process");
PresetsConfigSubstitutions substitutions;
coll.load_presets(temp_dir.path.string(), PRESET_PRINT_NAME, substitutions,
coll.load_presets(temp_dir.path().string(), PRESET_PRINT_NAME, substitutions,
ForwardCompatibilitySubstitutionRule::Disable);
const Preset *child = coll.find_preset("Child");
@@ -279,17 +291,17 @@ TEST_CASE("Renamed parent is normalized into a loaded preset's inherits", "[Pres
TEST_CASE("Removed Generic parent is normalized into a loaded filament's inherits", "[Preset][Rename]")
{
TempPresetDir temp_dir;
ScopedTemporaryDir temp_dir;
PresetBundle bundle;
add_inmemory_preset(bundle.filaments, "Generic PLA @System");
// A user filament that still inherits a removed "<vendor> Generic PLA" profile.
write_preset_with_inherits(bundle.filaments.default_preset().config,
temp_dir.path / PRESET_FILAMENT_NAME / "MyPLA.json", "MyPLA", "Voron Generic PLA");
temp_dir.path() / PRESET_FILAMENT_NAME / "MyPLA.json", "MyPLA", "Voron Generic PLA");
PresetsConfigSubstitutions substitutions;
bundle.filaments.load_presets(temp_dir.path.string(), PRESET_FILAMENT_NAME, substitutions,
bundle.filaments.load_presets(temp_dir.path().string(), PRESET_FILAMENT_NAME, substitutions,
ForwardCompatibilitySubstitutionRule::Disable);
const Preset *child = bundle.filaments.find_preset("MyPLA");
@@ -488,3 +500,69 @@ TEST_CASE("Plugin capability override keys are scoped per preset type", "[Preset
}
}
namespace {
// A standalone filament collection that exposes the protected library masking builder, so the Orca
// Filament Library scenario can be set up without the full system-profile load pipeline.
struct LibraryFilamentTestCollection : public PresetCollection
{
LibraryFilamentTestCollection()
: PresetCollection(Preset::TYPE_FILAMENT, Preset::filament_options(),
static_cast<const PrintRegionConfig &>(FullPrintConfig::defaults()))
{}
using PresetCollection::update_library_profile_excluded_from;
};
} // namespace
// Orca: a filament in the Orca Filament Library that names its compatible printers has to hide the generic
// library filament sharing its alias, the same way a vendor owned filament does. Otherwise both are compatible
// with that printer and the plater combo box lists the shared alias twice.
TEST_CASE("A printer specific filament supersedes the generic library filament with the same alias", "[Preset][Bundle]")
{
LibraryFilamentTestCollection filaments;
PresetCollection printers(Preset::TYPE_PRINTER, Preset::printer_options(),
static_cast<const PrintRegionConfig &>(FullPrintConfig::defaults()));
// The masking keys off the vendor name, which VendorProfile's constructor does not derive from the id.
VendorProfile library(PresetBundle::ORCA_FILAMENT_LIBRARY);
VendorProfile vendor("Vendor");
library.name = PresetBundle::ORCA_FILAMENT_LIBRARY;
vendor.name = "Vendor";
auto add_filament = [&filaments](const VendorProfile &owner, const std::string &name, std::vector<std::string> compatible_printers) {
Preset &preset = add_inmemory_preset(filaments, name);
preset.alias = "Generic ABS";
preset.vendor = &owner;
preset.config.option<ConfigOptionStrings>("compatible_printers", true)->values = std::move(compatible_printers);
};
add_filament(library, "Generic ABS @System", {});
add_filament(library, "Generic ABS @Printer A", { "Printer A" });
add_filament(vendor, "Generic ABS @Printer B", { "Printer B" });
filaments.update_library_profile_excluded_from();
const Preset *generic = filaments.find_preset("Generic ABS @System");
REQUIRE(generic != nullptr);
CHECK(generic->m_excluded_from.count("Printer A") == 1);
CHECK(generic->m_excluded_from.count("Printer B") == 1);
CHECK(generic->m_excluded_from.size() == 2);
// A printer specific profile names printers, so it is never the one being hidden - not even by itself.
const Preset *specific = filaments.find_preset("Generic ABS @Printer A");
REQUIRE(specific != nullptr);
CHECK(specific->m_excluded_from.empty());
// ...and the generic profile really drops out of the compatible set on the printer it is hidden from.
add_inmemory_preset(printers, "Printer A");
add_inmemory_preset(printers, "Printer C");
const Preset *printer_a = printers.find_preset("Printer A");
const Preset *printer_c = printers.find_preset("Printer C");
REQUIRE(printer_a != nullptr);
REQUIRE(printer_c != nullptr);
const PresetWithVendorProfile generic_lib(*generic, &library);
CHECK_FALSE(is_compatible_with_printer(generic_lib, PresetWithVendorProfile(*printer_a, nullptr)));
CHECK(is_compatible_with_printer(generic_lib, PresetWithVendorProfile(*printer_c, nullptr)));
}
@@ -8,6 +8,8 @@
#include "libslic3r/Print.hpp"
#include "libslic3r/TriangleMesh.hpp"
#include "test_utils.hpp"
#include <algorithm>
#include <map>
#include <set>
@@ -708,10 +710,9 @@ TEST_CASE("Sequential selector prints publish a stitched result and cache the pl
REQUIRE(print.config().filament_self_index.values.size() >= print.config().filament_map.values.size());
// Export must consume the cached plans and produce g-code without throwing.
boost::filesystem::path gcode_path = boost::filesystem::temp_directory_path() / "orca_seq_dynamic_publish_test.gcode";
REQUIRE_NOTHROW(print.export_gcode(gcode_path.string(), nullptr, nullptr));
REQUIRE(boost::filesystem::exists(gcode_path));
boost::filesystem::remove(gcode_path);
ScopedTemporaryFile gcode(".gcode");
REQUIRE_NOTHROW(print.export_gcode(gcode.string(), nullptr, nullptr));
REQUIRE(boost::filesystem::exists(gcode.path()));
}
TEST_CASE("Per-variant expansion gives migrating filaments one slot per variant", "[PrintConfig][H2C][Dynamic]")
File diff suppressed because it is too large Load Diff