Merge upstream/main into iXex PR branch (#13086)

Catches the iXex/IDEX parallel-printing branch up to upstream main
(102 commits). Two content conflicts resolved:

- src/libslic3r/Preset.cpp: s_Preset_printer_options — kept upstream's
  new "use_3mf" key and our iMEX printer-capability/mode keys.
- tests/fff_print/test_gcodewriter.cpp: upstream revived the disabled
  suite (#14196), dropping the obsolete [.]-tagged lift() test and its
  config_lift_unlift.ini; kept their set_speed + z_hop tests and appended
  our 10 per-firmware set_pressure_advance/set_temperature scenarios.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Clifford Garwood
2026-06-26 23:29:35 -04:00
co-authored by Claude Opus 4.8
888 changed files with 103247 additions and 195853 deletions
+22 -24
View File
@@ -98,9 +98,9 @@ REQUIRE_THAT(calculated_value, WithinULP(expected, 4)); // 4 ULPs apart
## Overview of OrcaSlicer's Testing Framework
OrcaSlicer uses **Catch2 v2** as its primary testing framework. The test suite is organized into several modules that mirror the project's architectural components:
OrcaSlicer uses **Catch2 v3** (currently v3.11.0, vendored in `tests/catch2/`) as its primary testing framework. The test suite is organized into several modules that mirror the project's architectural components:
> **Note**: OrcaSlicer currently uses Catch2 v2 (based on `#include <catch2/catch.hpp>` includes). Some features mentioned in this guide are only available in v3 and marked accordingly.
> **Note**: Test files include the framework via `#include <catch2/catch_all.hpp>` (the v3 single-header convenience include). All v3 features described in this guide are available.
### Test Structure
```
@@ -149,13 +149,13 @@ Stereolithography specific tests:
### File Organization
1. **Naming Convention**: `test_<feature>.cpp` (e.g., `test_geometry.cpp`)
2. **Header Structure**: Include `<catch2/catch.hpp>` first, then relevant headers
2. **Header Structure**: Include `<catch2/catch_all.hpp>` first, then relevant headers
3. **Namespace Usage**: Use `using namespace Slic3r;` for convenience
4. **File Placement**: Add to appropriate test directory and update CMakeLists.txt
### Test Naming and Structure
```cpp
#include <catch2/catch.hpp>
#include <catch2/catch_all.hpp>
#include "libslic3r/Point.hpp"
using namespace Slic3r;
@@ -216,7 +216,7 @@ REQUIRE_THROWS_MATCHES(function_call(), SpecificException,
// String matchers
using Catch::Matchers::StartsWith;
using Catch::Matchers::EndsWith;
using Catch::Matchers::ContainsSubstring; // Note: v2 uses "Contains"
using Catch::Matchers::ContainsSubstring;
using Catch::Matchers::Equals;
using Catch::Matchers::Matches; // Regex matching
@@ -330,7 +330,7 @@ TEST_CASE_METHOD(GeometryFixture, "Point operations", "[Geometry]") {
REQUIRE(origin.distance_to(unit_x) == 1.0);
}
// Persistent fixture - single instance for entire test case (v2.12.0+)
// Persistent fixture - single instance for entire test case (v3.2.0+)
TEST_CASE_PERSISTENT_FIXTURE(GeometryFixture, "Persistent operations", "[Geometry]") {
static int call_count = 0;
++call_count;
@@ -384,8 +384,7 @@ TEST_CASE("Explicit test control", "[Control]") {
WARN("This warns but doesn't fail the test");
if (precondition_not_met) {
// SKIP("Reason"); // v3.3.0+ only, not available in v2
SUCCEED("Test cannot run due to precondition"); // v2 alternative
SKIP("Reason"); // Marks the test as skipped (v3.3.0+, available)
return;
}
@@ -506,7 +505,7 @@ TEST_CASE("Algorithm performance", "[Performance][Algorithm]") {
// Large test data
std::vector<Point> points = generate_large_point_set(10000);
// Time the operation (manual timing for Catch2 v2)
// Time the operation (manual timing example; the BENCHMARK macro is also available)
auto start = std::chrono::high_resolution_clock::now();
auto result = convex_hull(points);
auto end = std::chrono::high_resolution_clock::now();
@@ -746,7 +745,7 @@ REQUIRE_THROWS_AS(risky_function(), SpecificException);
⚠️ **CRITICAL**: Catch2 assertions are **NOT thread-safe** by default!
> **Note**: Catch2 v3.9.0+ has opt-in thread-safe assertions via `CATCH_CONFIG_EXPERIMENTAL_THREAD_SAFE_ASSERTIONS`, but OrcaSlicer uses v2
> **Note**: Catch2 v3.9.0+ has opt-in thread-safe assertions via `CATCH_CONFIG_EXPERIMENTAL_THREAD_SAFE_ASSERTIONS`. OrcaSlicer is on v3.11.0 but does not enable this flag, so assertions remain non-thread-safe by default.
❌ **Incorrect**: Will cause undefined behavior or crashes
```cpp
@@ -812,7 +811,7 @@ TEST_CASE("Resource management", "[Memory]") {
### Runtime Performance
```cpp
TEST_CASE("Performance-sensitive test", "[Performance]") {
// Manual timing for Catch2 v2 (v3 has built-in benchmarking)
// Manual timing example (Catch2's built-in BENCHMARK macro is also available)
auto start = std::chrono::high_resolution_clock::now();
auto result = expensive_operation();
@@ -921,21 +920,20 @@ std::foo_function(); // Always call qualified
// NOT: #include <foo.h> and foo_function();
```
### Catch2 Version-Specific Limitations
### Catch2 v3 Features Available
```cpp
// OrcaSlicer uses Catch2 v2 - these features are NOT available:
// SKIP() macro - Available in v3.3.0+
// Thread-safe assertions - Available in v3.9.0+
// BENCHMARK improvements - Many in v3.x
// testCasePartial events - Available in v3.0.1+
// Multiple reporters - Available in v3.0.1+
// STATIC_CHECK macro - Available in v3.0.1+
// OrcaSlicer is on Catch2 v3.11.0 - all of these ARE available:
// SKIP() macro - v3.3.0+
// Opt-in thread-safe assertions - v3.9.0+ (NOT enabled here; see Thread Safety)
// Built-in BENCHMARK / BENCHMARK_ADVANCED - v3.x
// testCasePartial events - v3.0.1+
// Multiple reporters simultaneously - v3.0.1+
// STATIC_CHECK macro - v3.0.1+
// Built-in test sharding (--shard-*) - v3.x
// v2 Limitations to remember:
// - Sections can be re-run if last section fails
// - String matcher is "Contains" not "ContainsSubstring"
// - Limited benchmarking support compared to v3
// - No test sharding built-in
// v3 notes to remember:
// - String matcher is "ContainsSubstring" (v2's "Contains" no longer exists)
// - Sections can still be re-run if a later section fails (unchanged from v2)
```
### Test Organization Best Practices
+5
View File
@@ -47,6 +47,11 @@ function(orcaslicer_copy_test_dlls)
endforeach()
endfunction()
# Register Catch2 tags as CTest labels so `ctest -L`/`-LE` can filter by tag.
function(orcaslicer_discover_tests TARGET)
catch_discover_tests(${TARGET} ADD_TAGS_AS_LABELS)
endfunction()
add_subdirectory(libnest2d)
add_subdirectory(libslic3r)
add_subdirectory(slic3rutils)
@@ -1,30 +0,0 @@
before_layer_gcode =
between_objects_gcode =
end_filament_gcode = "; Filament-specific end gcode \n;END gcode for filament\n"
end_gcode = M104 S0 ; turn off temperature\nG28 X0 ; home X axis\nM84 ; disable motors\n
extrusion_axis = E
extrusion_multiplier = 1
filament_cost = 0
filament_density = 0
filament_diameter = 3
filament_max_volumetric_speed = 0
gcode_comments = 0
gcode_flavor = reprap
layer_gcode =
max_print_speed = 80
max_volumetric_speed = 0
retract_length = 2
retract_length_toolchange = 10
retract_lift = 1.5
retract_lift_above = 0
retract_lift_below = 0
retract_restart_extra = 0
retract_restart_extra_toolchange = 0
retract_speed = 40
start_filament_gcode = "; Filament gcode\n"
start_gcode = G28 ; home all axes\nG1 Z5 F5000 ; lift nozzle\n
toolchange_gcode =
travel_speed = 130
use_firmware_retraction = 0
use_relative_e_distances = 0
use_volumetric_e = 0
+1 -1
View File
@@ -21,4 +21,4 @@ set_property(TARGET ${_TEST_NAME}_tests PROPERTY FOLDER "tests")
orcaslicer_copy_test_dlls()
catch_discover_tests(${_TEST_NAME}_tests)
orcaslicer_discover_tests(${_TEST_NAME}_tests)
+25 -4
View File
@@ -10,10 +10,11 @@
#include <cstdlib>
#include <string>
#include <boost/nowide/cstdio.hpp>
#include <boost/filesystem.hpp>
#include <libslic3r/ModelArrange.hpp>
#include "test_utils.hpp"
using namespace std;
namespace Slic3r { namespace Test {
@@ -282,16 +283,36 @@ void init_and_process_print(std::initializer_list<TriangleMesh> meshes, Slic3r::
std::string gcode(Print & print)
{
boost::filesystem::path temp = boost::filesystem::unique_path();
ScopedTemporaryFile temp(".gcode");
print.set_status_silent();
print.process();
print.export_gcode(temp.string(), nullptr, nullptr);
std::ifstream t(temp.string());
std::string str((std::istreambuf_iterator<char>(t)), std::istreambuf_iterator<char>());
boost::nowide::remove(temp.string().c_str());
return str;
}
std::set<double> layers_with_role(const std::string &gcode, const std::string &role)
{
std::set<double> layers;
GCodeReader parser;
parser.parse_buffer(gcode, [&layers, &role](GCodeReader &self, const GCodeReader::GCodeLine &line) {
if (line.extruding(self) && line.comment().find(role) != std::string_view::npos)
layers.insert(self.z());
});
return layers;
}
double max_z(const std::string &gcode)
{
double z = 0.0;
GCodeReader parser;
parser.parse_buffer(gcode, [&z](GCodeReader &self, const GCodeReader::GCodeLine &) {
z = std::max(z, static_cast<double>(self.z()));
});
return z;
}
Slic3r::Model model(const std::string &model_name, TriangleMesh &&_mesh)
{
Slic3r::Model result;
@@ -338,7 +359,7 @@ std::string slice(std::initializer_list<TriangleMesh> meshes, std::initializer_l
#include <catch2/catch_all.hpp>
SCENARIO("init_print functionality", "[test_data][.]") {
SCENARIO("init_print functionality", "[test_data]") {
GIVEN("A default config") {
Slic3r::DynamicPrintConfig config = Slic3r::DynamicPrintConfig::full_print_config();
WHEN("init_print is called with a single mesh.") {
+9
View File
@@ -8,6 +8,8 @@
#include "libslic3r/Print.hpp"
#include "libslic3r/TriangleMesh.hpp"
#include <set>
#include <string>
#include <unordered_map>
namespace Slic3r { namespace Test {
@@ -80,6 +82,13 @@ std::string slice(std::initializer_list<TriangleMesh> meshes, const DynamicPrint
std::string slice(std::initializer_list<TestMesh> meshes, std::initializer_list<Slic3r::ConfigBase::SetDeserializeItem> config_items, bool comments = false);
std::string slice(std::initializer_list<TriangleMesh> meshes, std::initializer_list<Slic3r::ConfigBase::SetDeserializeItem> config_items, bool comments = false);
// Distinct layer Z heights that carry an extrusion tagged with the given role
// comment (requires gcode_comments), e.g. "skirt", "brim", "support".
std::set<double> layers_with_role(const std::string &gcode, const std::string &role);
// Highest Z reached by any move in the gcode.
double max_z(const std::string &gcode);
} } // namespace Slic3r::Test
+1 -76
View File
@@ -15,82 +15,7 @@
using namespace Slic3r::Test;
using namespace Slic3r;
SCENARIO("Extrusion width specifics", "[Flow][.]") {
GIVEN("A config with a skirt, brim, some fill density, 3 perimeters, and 1 bottom solid layer and a 20mm cube mesh") {
// this is a sharedptr
DynamicPrintConfig config = Slic3r::DynamicPrintConfig::full_print_config();
config.set_deserialize_strict({
{ "brim_width", 2 },
{ "skirts", 1 },
{ "perimeters", 3 },
{ "fill_density", "40%" },
{ "first_layer_height", 0.3 }
});
WHEN("first layer width set to 2mm") {
Slic3r::Model model;
config.set("first_layer_extrusion_width", 2);
Slic3r::Print print;
Slic3r::Test::init_print({TestMesh::cube_20x20x20}, print, model, config);
std::vector<double> E_per_mm_bottom;
std::string gcode = Test::gcode(print);
Slic3r::GCodeReader parser;
const double layer_height = config.opt_float("layer_height");
parser.parse_buffer(gcode, [&E_per_mm_bottom, layer_height] (Slic3r::GCodeReader& self, const Slic3r::GCodeReader::GCodeLine& line)
{
if (self.z() == Catch::Approx(layer_height).margin(0.01)) { // only consider first layer
if (line.extruding(self) && line.dist_XY(self) > 0) {
E_per_mm_bottom.emplace_back(line.dist_E(self) / line.dist_XY(self));
}
}
});
THEN(" First layer width applies to everything on first layer.") {
bool pass = false;
double avg_E = std::accumulate(E_per_mm_bottom.cbegin(), E_per_mm_bottom.cend(), 0.0) / static_cast<double>(E_per_mm_bottom.size());
pass = (std::count_if(E_per_mm_bottom.cbegin(), E_per_mm_bottom.cend(), [avg_E] (const double& v) { return v == Catch::Approx(avg_E); }) == 0);
REQUIRE(pass == true);
REQUIRE(E_per_mm_bottom.size() > 0); // make sure it actually passed because of extrusion
}
THEN(" First layer width does not apply to upper layer.") {
}
}
}
}
// needs gcode export
SCENARIO(" Bridge flow specifics.", "[Flow]") {
GIVEN("A default config with no cooling and a fixed bridge speed, flow ratio and an overhang mesh.") {
WHEN("bridge_flow_ratio is set to 1.0") {
THEN("Output flow is as expected.") {
}
}
WHEN("bridge_flow_ratio is set to 0.5") {
THEN("Output flow is as expected.") {
}
}
WHEN("bridge_flow_ratio is set to 2.0") {
THEN("Output flow is as expected.") {
}
}
}
GIVEN("A default config with no cooling and a fixed bridge speed, flow ratio, fixed extrusion width of 0.4mm and an overhang mesh.") {
WHEN("bridge_flow_ratio is set to 1.0") {
THEN("Output flow is as expected.") {
}
}
WHEN("bridge_flow_ratio is set to 0.5") {
THEN("Output flow is as expected.") {
}
}
WHEN("bridge_flow_ratio is set to 2.0") {
THEN("Output flow is as expected.") {
}
}
}
}
/// Test the expected behavior for auto-width,
/// Test the expected behavior for auto-width,
/// spacing, etc
SCENARIO("Flow: Flow math for non-bridges", "[Flow]") {
GIVEN("Nozzle Diameter of 0.4, a desired width of 1mm and layer height of 0.5") {
+41 -78
View File
@@ -6,65 +6,55 @@
using namespace Slic3r;
SCENARIO("lift() is not ignored after unlift() at normal values of Z", "[GCodeWriter][.]") {
GIVEN("A config from a file and a single extruder.") {
GCodeWriter writer;
GCodeConfig &config = writer.config;
config.load(std::string(TEST_DATA_DIR) + "/fff_print_tests/test_gcodewriter/config_lift_unlift.ini", ForwardCompatibilitySubstitutionRule::Disable);
SCENARIO("set_speed emits values with fixed-point output.", "[GCodeWriter]") {
std::vector<unsigned int> extruder_ids {0};
GIVEN("GCodeWriter instance") {
GCodeWriter writer;
WHEN("set_speed is called to set speed to 99999.123") {
THEN("Output string is G1 F99999.123") {
REQUIRE_THAT(writer.set_speed(99999.123), Catch::Matchers::Equals("G1 F99999.123\n"));
}
}
WHEN("set_speed is called to set speed to 1") {
THEN("Output string is G1 F1") {
REQUIRE_THAT(writer.set_speed(1.0), Catch::Matchers::Equals("G1 F1\n"));
}
}
WHEN("set_speed is called to set speed to 203.200022") {
THEN("Output string is G1 F203.2") {
REQUIRE_THAT(writer.set_speed(203.200022), Catch::Matchers::Equals("G1 F203.2\n"));
}
}
WHEN("set_speed is called to set speed to 203.200522") {
THEN("Output string is G1 F203.201") {
REQUIRE_THAT(writer.set_speed(203.200522), Catch::Matchers::Equals("G1 F203.201\n"));
}
}
}
}
SCENARIO("z_hop lifts the nozzle when a lift is requested", "[GCodeWriter]") {
GIVEN("A writer with the nozzle parked at Z = 10") {
GCodeWriter writer;
std::vector<unsigned int> extruder_ids { 0 };
writer.set_extruders(extruder_ids);
writer.set_extruder(0);
writer.travel_to_z(10.0);
WHEN("Z is set to 203") {
double trouble_Z = 203;
writer.travel_to_z(trouble_Z);
AND_WHEN("GcodeWriter::Lift() is called") {
REQUIRE(writer.lazy_lift().size() > 0);
AND_WHEN("Z is moved post-lift to the same delta as the config Z lift") {
REQUIRE(writer.travel_to_z(trouble_Z + config.z_hop.values[0]).size() == 0);
AND_WHEN("GCodeWriter::Unlift() is called") {
REQUIRE(writer.unlift().size() == 0); // we're the same height so no additional move happens.
THEN("GCodeWriter::Lift() emits gcode.") {
REQUIRE(writer.lazy_lift().size() > 0);
}
}
}
WHEN("z_hop is 1 and an eager lift is requested") {
writer.config.z_hop.values = { 1.0 };
std::string gcode = writer.eager_lift(LiftType::NormalLift);
THEN("a Z move up by z_hop is emitted") {
REQUIRE_THAT(gcode, Catch::Matchers::ContainsSubstring("Z11"));
}
}
WHEN("Z is set to 500003") {
double trouble_Z = 500003;
writer.travel_to_z(trouble_Z);
AND_WHEN("GcodeWriter::Lift() is called") {
REQUIRE(writer.lazy_lift().size() > 0);
AND_WHEN("Z is moved post-lift to the same delta as the config Z lift") {
REQUIRE(writer.travel_to_z(trouble_Z + config.z_hop.values[0]).size() == 0);
AND_WHEN("GCodeWriter::Unlift() is called") {
REQUIRE(writer.unlift().size() == 0); // we're the same height so no additional move happens.
THEN("GCodeWriter::Lift() emits gcode.") {
REQUIRE(writer.lazy_lift().size() > 0);
}
}
}
WHEN("z_hop is 0") {
writer.config.z_hop.values = { 0.0 };
std::string gcode = writer.eager_lift(LiftType::NormalLift);
THEN("no lift is emitted") {
REQUIRE(gcode.empty());
}
}
WHEN("Z is set to 10.3") {
double trouble_Z = 10.3;
writer.travel_to_z(trouble_Z);
AND_WHEN("GcodeWriter::Lift() is called") {
REQUIRE(writer.lazy_lift().size() > 0);
AND_WHEN("Z is moved post-lift to the same delta as the config Z lift") {
REQUIRE(writer.travel_to_z(trouble_Z + config.z_hop.values[0]).size() == 0);
AND_WHEN("GCodeWriter::Unlift() is called") {
REQUIRE(writer.unlift().size() == 0); // we're the same height so no additional move happens.
THEN("GCodeWriter::Lift() emits gcode.") {
REQUIRE(writer.lazy_lift().size() > 0);
}
}
}
}
}
// The test above will fail for trouble_Z == 9007199254740992, where trouble_Z + 1.5 will be rounded to trouble_Z + 2.0 due to double mantisa overflow.
}
}
@@ -331,30 +321,3 @@ SCENARIO("set_pressure_advance emits BBL M900 L1000 M10 regardless of tool index
}
}
}
SCENARIO("set_speed emits values with fixed-point output.", "[GCodeWriter]") {
GIVEN("GCodeWriter instance") {
GCodeWriter writer;
WHEN("set_speed is called to set speed to 99999.123") {
THEN("Output string is G1 F99999.123") {
REQUIRE_THAT(writer.set_speed(99999.123), Catch::Matchers::Equals("G1 F99999.123\n"));
}
}
WHEN("set_speed is called to set speed to 1") {
THEN("Output string is G1 F1") {
REQUIRE_THAT(writer.set_speed(1.0), Catch::Matchers::Equals("G1 F1\n"));
}
}
WHEN("set_speed is called to set speed to 203.200022") {
THEN("Output string is G1 F203.2") {
REQUIRE_THAT(writer.set_speed(203.200022), Catch::Matchers::Equals("G1 F203.2\n"));
}
}
WHEN("set_speed is called to set speed to 203.200522") {
THEN("Output string is G1 F203.201") {
REQUIRE_THAT(writer.set_speed(203.200522), Catch::Matchers::Equals("G1 F203.201\n"));
}
}
}
}
+6 -7
View File
@@ -4,15 +4,15 @@
#include "libslic3r/Model.hpp"
#include "libslic3r/ModelArrange.hpp"
#include <boost/nowide/cstdio.hpp>
#include <boost/filesystem.hpp>
#include "test_data.hpp"
#include "test_utils.hpp"
using namespace Slic3r;
using namespace Slic3r::Test;
SCENARIO("Model construction", "[Model][.]") {
SCENARIO("Model construction", "[Model]") {
GIVEN("A Slic3r Model") {
Slic3r::Model model;
Slic3r::TriangleMesh sample_mesh = Slic3r::make_cube(20,20,20);
@@ -49,12 +49,11 @@ SCENARIO("Model construction", "[Model][.]") {
print.set_status_silent();
print.apply(model, config);
print.process();
boost::filesystem::path temp = boost::filesystem::unique_path();
ScopedTemporaryFile temp(".gcode");
print.export_gcode(temp.string(), nullptr, nullptr);
REQUIRE(boost::filesystem::exists(temp));
REQUIRE(boost::filesystem::is_regular_file(temp));
REQUIRE(boost::filesystem::file_size(temp) > 0);
boost::nowide::remove(temp.string().c_str());
REQUIRE(boost::filesystem::exists(temp.path()));
REQUIRE(boost::filesystem::is_regular_file(temp.path()));
REQUIRE(boost::filesystem::file_size(temp.path()) > 0);
}
}
}
+188 -57
View File
@@ -3,41 +3,23 @@
#include "libslic3r/libslic3r.h"
#include "libslic3r/Print.hpp"
#include "libslic3r/Layer.hpp"
#include "libslic3r/Model.hpp"
#include "test_data.hpp"
#include <algorithm>
using namespace Slic3r;
using namespace Slic3r::Test;
SCENARIO("PrintObject: Perimeter generation", "[PrintObject][.]") {
SCENARIO("Print: Skirt generation", "[Print]") {
GIVEN("20mm cube and default config") {
WHEN("make_perimeters() is called") {
Slic3r::Print print;
Slic3r::Test::init_and_process_print({TestMesh::cube_20x20x20}, print, { { "fill_density", 0 } });
const PrintObject &object = *print.objects().front();
THEN("67 layers exist in the model") {
REQUIRE(object.layers().size() == 66);
}
THEN("Every layer in region 0 has 1 island of perimeters") {
for (const Layer *layer : object.layers())
REQUIRE(layer->regions().front()->perimeters.entities.size() == 1);
}
THEN("Every layer in region 0 has 3 paths in its perimeters list.") {
for (const Layer *layer : object.layers())
REQUIRE(layer->regions().front()->perimeters.items_count() == 3);
}
}
}
}
SCENARIO("Print: Skirt generation", "[Print][.]") {
GIVEN("20mm cube and default config") {
WHEN("Skirts is set to 2 loops") {
WHEN("skirt_loops is set to 2") {
Slic3r::Print print;
Slic3r::Test::init_and_process_print({TestMesh::cube_20x20x20}, print, {
{ "skirt_height", 1 },
{ "skirt_distance", 1 },
{ "skirts", 2 }
{ "skirt_height", 1 },
{ "skirt_distance", 1 },
{ "skirt_loops", 2 }
});
THEN("Skirt Extrusion collection has 2 loops in it") {
REQUIRE(print.skirt().items_count() == 2);
@@ -47,19 +29,19 @@ SCENARIO("Print: Skirt generation", "[Print][.]") {
}
}
SCENARIO("Print: Changing number of solid surfaces does not cause all surfaces to become internal.", "[Print][.]") {
GIVEN("sliced 20mm cube and config with top_solid_surfaces = 2 and bottom_solid_surfaces = 1") {
SCENARIO("Print: Changing number of solid shell layers does not cause all surfaces to become internal.", "[Print]") {
GIVEN("sliced 20mm cube and config with top_shell_layers = 2 and bottom_shell_layers = 1") {
Slic3r::DynamicPrintConfig config = Slic3r::DynamicPrintConfig::full_print_config();
config.set_deserialize_strict({
{ "top_solid_layers", 2 },
{ "bottom_solid_layers", 1 },
{ "layer_height", 0.25 }, // get a known number of layers
{ "first_layer_height", 0.25 }
{ "top_shell_layers", 2 },
{ "bottom_shell_layers", 1 },
{ "layer_height", 0.25 }, // get a known number of layers
{ "initial_layer_print_height", 0.25 }
});
Slic3r::Print print;
Slic3r::Model model;
Slic3r::Test::init_print({TestMesh::cube_20x20x20}, print, model, config);
// Precondition: Ensure that the model has 2 solid top layers (39, 38)
// Precondition: Ensure that the model has 2 solid top layers (79, 78)
// and one solid bottom layer (0).
auto test_is_solid_infill = [&print](size_t obj_id, size_t layer_id) {
const Layer &layer = *(print.objects().at(obj_id)->get_layer((int)layer_id));
@@ -74,8 +56,8 @@ SCENARIO("Print: Changing number of solid surfaces does not cause all surfaces t
test_is_solid_infill(0, 0); // should be solid
test_is_solid_infill(0, 79); // should be solid
test_is_solid_infill(0, 78); // should be solid
WHEN("Model is re-sliced with top_solid_layers == 3") {
config.set("top_solid_layers", 3);
WHEN("Model is re-sliced with top_shell_layers == 3") {
config.set("top_shell_layers", 3);
print.apply(model, config);
print.process();
THEN("Print object does not have 0 solid bottom layers.") {
@@ -90,27 +72,14 @@ SCENARIO("Print: Changing number of solid surfaces does not cause all surfaces t
}
}
SCENARIO("Print: Brim generation", "[Print][.]") {
SCENARIO("Print: Brim generation", "[Print]") {
GIVEN("20mm cube and default config, 1mm first layer width") {
WHEN("Brim is set to 3mm") {
Slic3r::Print print;
Slic3r::Test::init_and_process_print({TestMesh::cube_20x20x20}, print, {
{ "first_layer_extrusion_width", 1 },
{ "brim_width", 3 }
});
THEN("Brim Extrusion collection has 3 loops in it") {
size_t total_items = 0;
for (const auto& pair : print.get_brimMap()) {
total_items += pair.second.items_count();
}
REQUIRE(total_items == 3);
}
}
WHEN("Brim is set to 6mm") {
Slic3r::Print print;
Slic3r::Test::init_and_process_print({TestMesh::cube_20x20x20}, print, {
{ "first_layer_extrusion_width", 1 },
{ "brim_width", 6 }
{ "brim_type", "outer_only" },
{ "initial_layer_line_width", 1 },
{ "brim_width", 6 }
});
THEN("Brim Extrusion collection has 6 loops in it") {
size_t total_items = 0;
@@ -123,18 +92,180 @@ SCENARIO("Print: Brim generation", "[Print][.]") {
WHEN("Brim is set to 6mm, extrusion width 0.5mm") {
Slic3r::Print print;
Slic3r::Test::init_and_process_print({TestMesh::cube_20x20x20}, print, {
{ "first_layer_extrusion_width", 1 },
{ "brim_width", 6 },
{ "first_layer_extrusion_width", 0.5 }
{ "brim_type", "outer_only" },
{ "brim_width", 6 },
{ "initial_layer_line_width", 0.5 }
});
print.process();
THEN("Brim Extrusion collection has 12 loops in it") {
size_t total_items = 0;
for (const auto& pair : print.get_brimMap()) {
total_items += pair.second.items_count();
}
REQUIRE(total_items == 14);
REQUIRE(total_items == 12);
}
}
}
}
// ---------------------------------------------------------------------------
// Print::validate() warning collection
//
// validate() returns its warnings in a vector. The warning paths deliberately
// differ in how many entries they produce; these tests pin down each behaviour:
// * independent checks -> stack (one entry each)
// * motion-ability -> coalesce into one (mutually exclusive, gated)
// * clumping detection -> one independent warning
// * layered clearance -> many collisions concatenated into one entry
// * null warnings pointer -> no-op, no crash, no blocking error
// ---------------------------------------------------------------------------
namespace {
// Build `n` 20mm cubes (spread apart, or stacked at the origin when `overlap`) into
// `model`/`print` and apply `config`, leaving the print ready to validate(). No slicing needed.
void build_cubes(Slic3r::Model& model, Slic3r::Print& print,
DynamicPrintConfig config, int n, bool overlap)
{
config.set_key_value("layer_change_gcode", new ConfigOptionString("G92 E0\n")); // validate() relative-E reset
for (int i = 0; i < n; ++i) {
ModelObject* object = model.add_object();
object->add_volume(Slic3r::Test::mesh(TestMesh::cube_20x20x20));
ModelInstance* inst = object->add_instance();
inst->set_offset(Vec3d(overlap ? 0.0 : i * 60.0, 0.0, 0.0));
}
for (ModelObject* mo : model.objects) {
mo->ensure_on_bed();
print.auto_assign_extruders(mo);
}
print.apply(model, config);
}
// Build cubes and run validate(), collecting warnings; returns the blocking error.
StringObjectException validate_cubes(const DynamicPrintConfig& config,
std::vector<StringObjectException>& warnings,
int n = 1, bool overlap = false)
{
Slic3r::Model model;
Slic3r::Print print;
build_cubes(model, print, config, n, overlap);
return print.validate(&warnings);
}
size_t count_opt_key(const std::vector<StringObjectException>& warnings, const std::string& key)
{
return std::count_if(warnings.begin(), warnings.end(),
[&](const StringObjectException& w) { return w.opt_key == key; });
}
// Make `default_acceleration` exceed the machine's extruding-acceleration limit.
void trigger_acceleration_warning(DynamicPrintConfig& c)
{
c.set_key_value("machine_max_acceleration_extruding", new ConfigOptionFloats{ 100. });
c.set_key_value("default_acceleration", new ConfigOptionFloat(100000.));
}
// Make `default_jerk` exceed the machine's jerk limit (junction deviation off so
// the jerk check is not skipped).
void trigger_jerk_warning(DynamicPrintConfig& c)
{
c.set_key_value("machine_max_junction_deviation", new ConfigOptionFloats{ 0. });
c.set_key_value("machine_max_jerk_x", new ConfigOptionFloats{ 1. });
c.set_key_value("machine_max_jerk_y", new ConfigOptionFloats{ 1. });
c.set_key_value("default_jerk", new ConfigOptionFloat(9999.));
}
// Precise outer wall is ignored unless the wall sequence is inner-outer.
void trigger_precise_wall_warning(DynamicPrintConfig& c)
{
c.set_key_value("precise_outer_wall", new ConfigOptionBool(true));
c.set_key_value("wall_sequence", new ConfigOptionEnum<WallSequence>(WallSequence::OuterInner));
}
} // namespace
TEST_CASE("Print::validate stacks independent warnings", "[Print][validate]")
{
// Two unrelated checks (region precise-wall + machine acceleration) must each
// contribute their own entry.
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
trigger_precise_wall_warning(config);
trigger_acceleration_warning(config);
std::vector<StringObjectException> warnings;
StringObjectException err = validate_cubes(config, warnings);
CHECK(err.string.empty());
CHECK(warnings.size() >= 2);
CHECK(count_opt_key(warnings, "precise_outer_wall") == 1); // jump-to key is preserved
for (const auto& w : warnings)
CHECK(w.is_warning); // every collected entry is a warning
}
TEST_CASE("Print::validate coalesces motion-ability warnings into one", "[Print][validate]")
{
// The jerk/junction/acceleration checks are mutually exclusive (gated on a shared
// key), so adding a second motion trigger must NOT add a second warning.
DynamicPrintConfig accel_only = DynamicPrintConfig::full_print_config();
trigger_acceleration_warning(accel_only);
std::vector<StringObjectException> w_accel;
CHECK(validate_cubes(accel_only, w_accel).string.empty());
DynamicPrintConfig accel_and_jerk = DynamicPrintConfig::full_print_config();
trigger_acceleration_warning(accel_and_jerk);
trigger_jerk_warning(accel_and_jerk);
std::vector<StringObjectException> w_both;
CHECK(validate_cubes(accel_and_jerk, w_both).string.empty());
CHECK(w_accel.size() >= 1);
CHECK(w_both.size() == w_accel.size()); // the extra motion trigger collapses into the same warning
}
TEST_CASE("Print::validate reports the clumping-detection warning", "[Print][validate]")
{
// A distinct single-shot path: clumping/wrapping detection without a prime tower warns
// (and carries the enable_prime_tower jump-to key). enable_prime_tower must be off, as
// the warning lives in the no-prime-tower branch.
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
config.set_key_value("enable_prime_tower", new ConfigOptionBool(false));
config.set_key_value("enable_wrapping_detection", new ConfigOptionBool(true));
std::vector<StringObjectException> warnings;
StringObjectException err = validate_cubes(config, warnings);
CHECK(err.string.empty());
CHECK(count_opt_key(warnings, "enable_prime_tower") == 1);
}
TEST_CASE("Print::validate concatenates layered-clearance collisions into one warning", "[Print][validate]")
{
// In by-layer mode, layered_print_cleareance_valid folds every too-close pair into a
// single warning entry (newline-joined), unlike the per-check stacking above. Isolate
// that entry by type so unrelated default-config warnings don't affect the assertion.
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
std::vector<StringObjectException> warnings;
StringObjectException err = validate_cubes(config, warnings, /*n=*/3, /*overlap=*/true);
CHECK(err.string.empty());
auto is_layered = [](const StringObjectException& w) {
return w.type == STRING_EXCEPT_OBJECT_COLLISION_IN_LAYER_PRINT; };
REQUIRE(std::count_if(warnings.begin(), warnings.end(), is_layered) == 1); // 3 objects, 2 collisions, 1 entry
auto it = std::find_if(warnings.begin(), warnings.end(), is_layered);
CHECK(it->string.find('\n') != std::string::npos); // the collisions were concatenated
}
TEST_CASE("Print::validate tolerates a null warnings pointer", "[Print][validate]")
{
// Callers may pass no warnings sink: a warning-producing config must not crash
// and must still return without a blocking error.
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
trigger_precise_wall_warning(config);
trigger_acceleration_warning(config);
Slic3r::Model model;
Slic3r::Print print;
build_cubes(model, print, config, /*n=*/1, /*overlap=*/false);
StringObjectException err = print.validate(); // warnings == nullptr
CHECK(err.string.empty());
}
+119 -94
View File
@@ -14,9 +14,14 @@
#include "libslic3r/GCodeReader.hpp"
#include "test_data.hpp"
#include "test_utils.hpp"
#include <algorithm>
#include <boost/regex.hpp>
#include <boost/filesystem.hpp>
#include <fstream>
#include <iterator>
#include <set>
using namespace Slic3r;
using namespace Slic3r::Test;
@@ -25,25 +30,26 @@ boost::regex perimeters_regex("G1 X[-0-9.]* Y[-0-9.]* E[-0-9.]* ; perimeter");
boost::regex infill_regex("G1 X[-0-9.]* Y[-0-9.]* E[-0-9.]* ; infill");
boost::regex skirt_regex("G1 X[-0-9.]* Y[-0-9.]* E[-0-9.]* ; skirt");
SCENARIO( "PrintGCode basic functionality", "[PrintGCode][.]") {
// [NotWorking]: slice() intermittently throws clipper's "Coordinate outside allowed
// range" in CI (Linux) while passing locally. Disabled pending a root-cause fix in a
// follow-up PR.
SCENARIO( "PrintGCode basic functionality", "[PrintGCode][NotWorking]") {
GIVEN("A default configuration and a print test object") {
WHEN("the output is executed with no support material") {
Slic3r::Print print;
Slic3r::Model model;
Slic3r::Test::init_print({TestMesh::cube_20x20x20}, print, model, {
{ "layer_height", 0.2 },
{ "first_layer_height", 0.2 },
{ "first_layer_extrusion_width", 0 },
{ "gcode_comments", true },
{ "start_gcode", "" }
{ "layer_height", 0.2 },
{ "initial_layer_print_height", 0.2 },
{ "initial_layer_line_width", 0 },
{ "gcode_comments", true },
{ "machine_start_gcode", "" },
{ "z_hop", 0 }
});
std::string gcode = Slic3r::Test::gcode(print);
THEN("Some text output is generated.") {
REQUIRE(gcode.size() > 0);
}
THEN("Exported text contains slic3r version") {
REQUIRE(gcode.find(SLIC3R_VERSION) != std::string::npos);
}
//THEN("Exported text contains git commit id") {
// REQUIRE(gcode.find("; Git Commit") != std::string::npos);
// REQUIRE(gcode.find(SLIC3R_BUILD_ID) != std::string::npos);
@@ -61,14 +67,9 @@ SCENARIO( "PrintGCode basic functionality", "[PrintGCode][.]") {
REQUIRE(gcode.find(";_EXTRUDE_SET_SPEED") == std::string::npos);
}
THEN("GCode preamble is emitted.") {
REQUIRE(gcode.find("G21 ; set units to millimeters") != std::string::npos);
}
THEN("Config options emitted for print config, default region config, default object config") {
REQUIRE(gcode.find("; first_layer_temperature") != std::string::npos);
THEN("The config trailer includes print and region settings") {
REQUIRE(gcode.find("; layer_height") != std::string::npos);
REQUIRE(gcode.find("; fill_density") != std::string::npos);
REQUIRE(gcode.find("; sparse_infill_density") != std::string::npos);
}
THEN("Infill is emitted.") {
boost::smatch has_match;
@@ -83,27 +84,22 @@ SCENARIO( "PrintGCode basic functionality", "[PrintGCode][.]") {
REQUIRE(boost::regex_search(gcode, has_match, skirt_regex));
}
THEN("final Z height is 20mm") {
double final_z = 0.0;
GCodeReader reader;
reader.apply_config(print.config());
reader.parse_buffer(gcode, [&final_z] (GCodeReader& self, const GCodeReader::GCodeLine& line) {
final_z = std::max<double>(final_z, static_cast<double>(self.z())); // record the highest Z point we reach
});
REQUIRE(final_z == Catch::Approx(20.));
REQUIRE_THAT(max_z(gcode), Catch::Matchers::WithinAbs(20., 1e-4));
}
}
WHEN("output is executed with complete objects and two differently-sized meshes") {
WHEN("output is executed with two objects printed sequentially") {
Slic3r::Print print;
Slic3r::Model model;
Slic3r::Test::init_print({TestMesh::cube_20x20x20,TestMesh::cube_20x20x20}, print, model, {
{ "first_layer_extrusion_width", 0 },
{ "first_layer_height", 0.3 },
{ "layer_height", 0.2 },
{ "support_material", false },
{ "raft_layers", 0 },
{ "complete_objects", true },
{ "gcode_comments", true },
{ "between_objects_gcode", "; between-object-gcode" }
{ "initial_layer_line_width", 0 },
{ "initial_layer_print_height", 0.3 },
{ "layer_height", 0.2 },
{ "enable_support", false },
{ "raft_layers", 0 },
{ "print_sequence", "by object" },
{ "gcode_comments", true },
{ "printing_by_object_gcode", "; between-object-gcode" },
{ "z_hop", 0 }
});
std::string gcode = Slic3r::Test::gcode(print);
THEN("Some text output is generated.") {
@@ -125,13 +121,7 @@ SCENARIO( "PrintGCode basic functionality", "[PrintGCode][.]") {
REQUIRE(gcode.find("; between-object-gcode") != std::string::npos);
}
THEN("final Z height is 20.1mm") {
double final_z = 0.0;
GCodeReader reader;
reader.apply_config(print.config());
reader.parse_buffer(gcode, [&final_z] (GCodeReader& self, const GCodeReader::GCodeLine& line) {
final_z = std::max(final_z, static_cast<double>(self.z())); // record the highest Z point we reach
});
REQUIRE(final_z == Catch::Approx(20.1));
REQUIRE_THAT(max_z(gcode), Catch::Matchers::WithinAbs(20.1, 1e-4));
}
THEN("Z height resets on object change") {
double final_z = 0.0;
@@ -147,27 +137,13 @@ SCENARIO( "PrintGCode basic functionality", "[PrintGCode][.]") {
});
REQUIRE(reset == true);
}
THEN("Shorter object is printed before taller object.") {
double final_z = 0.0;
bool reset = false;
GCodeReader reader;
reader.apply_config(print.config());
reader.parse_buffer(gcode, [&final_z, &reset] (GCodeReader& self, const GCodeReader::GCodeLine& line) {
if (final_z > 0 && std::abs(self.z() - 0.3) < 0.01 ) {
reset = (final_z > 20.0);
} else {
final_z = std::max(final_z, static_cast<double>(self.z())); // record the highest Z point we reach
}
});
REQUIRE(reset == true);
}
}
WHEN("the output is executed with support material") {
std::string gcode = ::Test::slice({TestMesh::cube_20x20x20}, {
{ "first_layer_extrusion_width", 0 },
{ "support_material", true },
{ "raft_layers", 3 },
{ "gcode_comments", true }
{ "initial_layer_line_width", 0 },
{ "enable_support", true },
{ "raft_layers", 3 },
{ "gcode_comments", true }
});
THEN("Some text output is generated.") {
REQUIRE(gcode.size() > 0);
@@ -187,7 +163,7 @@ SCENARIO( "PrintGCode basic functionality", "[PrintGCode][.]") {
}
WHEN("the output is executed with a separate first layer extrusion width") {
std::string gcode = ::Test::slice({ TestMesh::cube_20x20x20 }, {
{ "first_layer_extrusion_width", "0.5" }
{ "initial_layer_line_width", "0.5" }
});
THEN("Some text output is generated.") {
REQUIRE(gcode.size() > 0);
@@ -204,18 +180,18 @@ SCENARIO( "PrintGCode basic functionality", "[PrintGCode][.]") {
}
WHEN("Cooling is enabled and the fan is disabled.") {
std::string gcode = ::Test::slice({ TestMesh::cube_20x20x20 }, {
{ "cooling", true },
{ "disable_fan_first_layers", 5 }
{ "cooling", true },
{ "close_fan_the_first_x_layers", 5 }
});
THEN("GCode to disable fan is emitted."){
REQUIRE(gcode.find("M107") != std::string::npos);
REQUIRE(gcode.find("M106 S0") != std::string::npos);
}
}
WHEN("end_gcode exists with layer_num and layer_z") {
std::string gcode = ::Test::slice({ TestMesh::cube_20x20x20 }, {
{ "end_gcode", "; Layer_num [layer_num]\n; Layer_z [layer_z]" },
{ "layer_height", 0.1 },
{ "first_layer_height", 0.1 }
{ "machine_end_gcode", "; Layer_num [layer_num]\n; Layer_z [layer_z]" },
{ "layer_height", 0.1 },
{ "initial_layer_print_height", 0.1 }
});
THEN("layer_num and layer_z are processed in the end gcode") {
REQUIRE(gcode.find("; Layer_num 199") != std::string::npos);
@@ -223,39 +199,21 @@ SCENARIO( "PrintGCode basic functionality", "[PrintGCode][.]") {
}
}
WHEN("current_extruder exists in start_gcode") {
{
std::string gcode = ::Test::slice({ TestMesh::cube_20x20x20 }, {
{ "start_gcode", "; Extruder [current_extruder]" }
});
THEN("current_extruder is processed in the start gcode and set for first extruder") {
REQUIRE(gcode.find("; Extruder 0") != std::string::npos);
}
}
{
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
config.set_num_extruders(4);
config.set_deserialize_strict({
{ "start_gcode", "; Extruder [current_extruder]" },
{ "infill_extruder", 2 },
{ "solid_infill_extruder", 2 },
{ "perimeter_extruder", 2 },
{ "support_material_extruder", 2 },
{ "support_material_interface_extruder", 2 }
});
std::string gcode = Slic3r::Test::slice({TestMesh::cube_20x20x20}, config);
THEN("current_extruder is processed in the start gcode and set for second extruder") {
REQUIRE(gcode.find("; Extruder 1") != std::string::npos);
}
std::string gcode = ::Test::slice({ TestMesh::cube_20x20x20 }, {
{ "machine_start_gcode", "; Extruder [current_extruder]" }
});
THEN("current_extruder is processed in the start gcode and set for first extruder") {
REQUIRE(gcode.find("; Extruder 0") != std::string::npos);
}
}
WHEN("layer_num represents the layer's index from z=0") {
std::string gcode = ::Test::slice({ TestMesh::cube_20x20x20, TestMesh::cube_20x20x20 }, {
{ "complete_objects", true },
{ "gcode_comments", true },
{ "layer_gcode", ";Layer:[layer_num] ([layer_z] mm)" },
{ "layer_height", 0.1 },
{ "first_layer_height", 0.1 }
{ "print_sequence", "by object" },
{ "gcode_comments", true },
{ "layer_change_gcode", ";Layer:[layer_num] ([layer_z] mm)" },
{ "layer_height", 0.1 },
{ "initial_layer_print_height", 0.1 }
});
// End of the 1st object.
std::string token = ";Layer:199 ";
@@ -267,15 +225,82 @@ SCENARIO( "PrintGCode basic functionality", "[PrintGCode][.]") {
REQUIRE(pos < gcode.size());
double z = 0;
REQUIRE((sscanf(gcode.data() + pos, "(%lf mm)", &z) == 1));
REQUIRE(z == Catch::Approx(20.));
REQUIRE_THAT(z, Catch::Matchers::WithinAbs(20., 1e-4));
// Second object
pos = gcode.find(";Layer:399 ", pos);
REQUIRE(pos != std::string::npos);
pos += token.size();
REQUIRE(pos < gcode.size());
REQUIRE((sscanf(gcode.data() + pos, "(%lf mm)", &z) == 1));
REQUIRE(z == Catch::Approx(20.));
REQUIRE_THAT(z, Catch::Matchers::WithinAbs(20., 1e-4));
}
}
}
}
TEST_CASE("export_gcode writes G-code without a result pointer", "[PrintGCode][export_gcode]")
{
Print print;
Model model;
Slic3r::Test::init_print({TestMesh::cube_20x20x20}, print, model);
print.process();
SECTION("non-BBL printer") {}
SECTION("BBL printer") { print.is_BBL_printer() = true; }
ScopedTemporaryFile temp(".gcode");
REQUIRE_NOTHROW(print.export_gcode(temp.string(), nullptr, nullptr));
std::ifstream in(temp.string());
const std::string gcode((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>());
REQUIRE_FALSE(gcode.empty());
}
TEST_CASE("Initial layer height is honored", "[PrintGCode]")
{
const std::string gcode = Slic3r::Test::slice({TestMesh::cube_20x20x20}, {
{ "initial_layer_print_height", 0.3 },
{ "layer_height", 0.2 },
{ "z_hop", 0 } // keep recorded Z equal to the printed layer height
});
std::set<double> layer_zs;
GCodeReader reader;
reader.parse_buffer(gcode, [&layer_zs] (GCodeReader& self, const GCodeReader::GCodeLine& line) {
if (line.extruding(self) && line.dist_XY(self) > 0)
layer_zs.insert(self.z());
});
REQUIRE(layer_zs.size() > 1);
REQUIRE_THAT(*layer_zs.begin(), Catch::Matchers::WithinAbs(0.3, 1e-4));
REQUIRE_THAT(*std::next(layer_zs.begin()), Catch::Matchers::WithinAbs(0.5, 1e-4));
}
TEST_CASE("Sequential printing follows model order", "[PrintGCode]")
{
// Two objects of different heights, taller one added first. Orca prints
// sequential objects in model order, so the taller one is printed first.
const std::string gcode = Slic3r::Test::slice({ Slic3r::make_cube(20, 20, 20), Slic3r::make_cube(20, 20, 10) }, {
{ "print_sequence", "by object" },
{ "layer_height", 0.2 },
{ "initial_layer_print_height", 0.2 },
{ "z_hop", 0 }
});
// The first object's height is the peak Z reached before Z drops back to the
// first layer (the object change). With by-object printing only an object
// change returns Z to the bottom.
double first_object_peak_z = 0.0;
double running_peak = 0.0;
GCodeReader reader;
reader.parse_buffer(gcode, [&] (GCodeReader& self, const GCodeReader::GCodeLine& line) {
if (first_object_peak_z != 0.0 || !line.extruding(self)) return; // ignore travels (e.g. start-gcode Z lift)
if (running_peak > 1.0 && self.z() < 1.0)
first_object_peak_z = running_peak;
else
running_peak = std::max(running_peak, static_cast<double>(self.z()));
});
REQUIRE_THAT(first_object_peak_z, Catch::Matchers::WithinAbs(20.0, 0.3));
}
+53 -34
View File
@@ -9,14 +9,14 @@
using namespace Slic3r;
using namespace Slic3r::Test;
SCENARIO("PrintObject: object layer heights", "[PrintObject][.]") {
GIVEN("20mm cube and default initial config, initial layer height of 2mm") {
WHEN("generate_object_layers() is called for 2mm layer heights and nozzle diameter of 3mm") {
SCENARIO("PrintObject: object layer heights", "[PrintObject]") {
GIVEN("A 20mm cube") {
WHEN("sliced with a 2mm layer height and a 3mm nozzle") {
Slic3r::Print print;
Slic3r::Test::init_and_process_print({TestMesh::cube_20x20x20}, print, {
{ "first_layer_height", 2 },
{ "layer_height", 2 },
{ "nozzle_diameter", 3 }
{ "initial_layer_print_height", 2 },
{ "layer_height", 2 },
{ "nozzle_diameter", 3 }
});
ConstLayerPtrsAdaptor layers = print.objects().front()->layers();
THEN("The output vector has 10 entries") {
@@ -25,65 +25,84 @@ SCENARIO("PrintObject: object layer heights", "[PrintObject][.]") {
AND_THEN("Each layer is approximately 2mm above the previous Z") {
coordf_t last = 0.0;
for (size_t i = 0; i < layers.size(); ++ i) {
REQUIRE((layers[i]->print_z - last) == Catch::Approx(2.0));
REQUIRE_THAT(layers[i]->print_z - last, Catch::Matchers::WithinAbs(2.0, 1e-4));
last = layers[i]->print_z;
}
}
}
WHEN("generate_object_layers() is called for 10mm layer heights and nozzle diameter of 11mm") {
WHEN("sliced with a 10mm layer height and an 11mm nozzle") {
Slic3r::Print print;
Slic3r::Test::init_and_process_print({TestMesh::cube_20x20x20}, print, {
{ "first_layer_height", 2 },
{ "layer_height", 10 },
{ "nozzle_diameter", 11 }
{ "initial_layer_print_height", 2 },
{ "layer_height", 10 },
{ "nozzle_diameter", 11 }
});
ConstLayerPtrsAdaptor layers = print.objects().front()->layers();
THEN("The output vector has 3 entries") {
REQUIRE(layers.size() == 3);
}
AND_THEN("Layer 0 is at 2mm") {
REQUIRE(layers.front()->print_z == Catch::Approx(2.0));
REQUIRE_THAT(layers.front()->print_z, Catch::Matchers::WithinAbs(2.0, 1e-4));
}
AND_THEN("Layer 1 is at 12mm") {
REQUIRE(layers[1]->print_z == Catch::Approx(12.0));
REQUIRE_THAT(layers[1]->print_z, Catch::Matchers::WithinAbs(12.0, 1e-4));
}
}
WHEN("generate_object_layers() is called for 15mm layer heights and nozzle diameter of 16mm") {
WHEN("sliced with a 15mm layer height and a 16mm nozzle") {
Slic3r::Print print;
Slic3r::Test::init_and_process_print({TestMesh::cube_20x20x20}, print, {
{ "first_layer_height", 2 },
{ "layer_height", 15 },
{ "nozzle_diameter", 16 }
{ "initial_layer_print_height", 2 },
{ "layer_height", 15 },
{ "nozzle_diameter", 16 }
});
ConstLayerPtrsAdaptor layers = print.objects().front()->layers();
THEN("The output vector has 2 entries") {
REQUIRE(layers.size() == 2);
}
AND_THEN("Layer 0 is at 2mm") {
REQUIRE(layers[0]->print_z == Catch::Approx(2.0));
REQUIRE_THAT(layers[0]->print_z, Catch::Matchers::WithinAbs(2.0, 1e-4));
}
AND_THEN("Layer 1 is at 17mm") {
REQUIRE(layers[1]->print_z == Catch::Approx(17.0));
REQUIRE_THAT(layers[1]->print_z, Catch::Matchers::WithinAbs(17.0, 1e-4));
}
}
#if 0
WHEN("generate_object_layers() is called for 15mm layer heights and nozzle diameter of 5mm") {
WHEN("layer height exceeds the nozzle diameter") {
// Orca does not clamp an over-large layer height to the nozzle; it
// rejects the slice during flow computation. Pin that behavior.
THEN("Slicing is rejected") {
Slic3r::Print print;
REQUIRE_THROWS(Slic3r::Test::init_and_process_print({TestMesh::cube_20x20x20}, print, {
{ "initial_layer_print_height", 0.3 },
{ "layer_height", 0.5 },
{ "nozzle_diameter", 0.4 }
}));
}
}
}
}
SCENARIO("PrintObject: Perimeter generation", "[PrintObject]") {
GIVEN("20mm cube and default config") {
WHEN("make_perimeters() is called") {
Slic3r::Print print;
Slic3r::Test::init_and_process_print({TestMesh::cube_20x20x20}, print, { { "sparse_infill_density", 0 } });
const PrintObject &object = *print.objects().front();
THEN("Every layer in region 0 has 1 island of perimeters") {
for (const Layer *layer : object.layers())
REQUIRE(layer->regions().front()->perimeters.entities.size() == 1);
}
}
WHEN("wall_loops is set to 3") {
Slic3r::Print print;
Slic3r::Test::init_and_process_print({TestMesh::cube_20x20x20}, print, {
{ "first_layer_height", 2 },
{ "layer_height", 15 },
{ "nozzle_diameter", 5 }
});
const std::vector<Slic3r::Layer*> &layers = print.objects().front()->layers();
THEN("The layer height is limited to 5mm.") {
CHECK(layers.size() == 5);
coordf_t last = 2.0;
for (size_t i = 1; i < layers.size(); i++) {
REQUIRE((layers[i]->print_z - last) == Catch::Approx(5.0));
last = layers[i]->print_z;
}
{ "sparse_infill_density", 0 },
{ "wall_loops", 3 }
});
const PrintObject &object = *print.objects().front();
THEN("Every layer in region 0 has 3 perimeter loops") {
for (const Layer *layer : object.layers())
REQUIRE(layer->regions().front()->perimeters.items_count() == 3);
}
}
#endif
}
}
+86 -118
View File
@@ -3,23 +3,26 @@
#include "libslic3r/GCodeReader.hpp"
#include "libslic3r/Config.hpp"
#include "libslic3r/Geometry.hpp"
#include "libslic3r/Geometry/ConvexHull.hpp"
#include <boost/algorithm/string.hpp>
#include <cmath>
#include "test_data.hpp" // get access to init_print, etc
using namespace Slic3r::Test;
using namespace Slic3r;
/// Helper method to find the tool used for the brim (always the first extrusion)
static int get_brim_tool(const std::string &gcode)
/// Helper method to find the tool used for the brim (always the first extrusion).
[[maybe_unused]] static int get_brim_tool(const std::string &gcode)
{
int brim_tool = -1;
int tool = -1;
GCodeReader parser;
parser.parse_buffer(gcode, [&tool, &brim_tool] (Slic3r::GCodeReader &self, const Slic3r::GCodeReader::GCodeLine &line)
{
// if the command is a T command, set the the current tool
// if the command is a T command, set the current tool
if (boost::starts_with(line.cmd(), "T")) {
tool = atoi(line.cmd().data() + 1);
} else if (line.cmd() == "G1" && line.extruding(self) && line.dist_XY(self) > 0 && brim_tool < 0) {
@@ -29,16 +32,16 @@ static int get_brim_tool(const std::string &gcode)
return brim_tool;
}
TEST_CASE("Skirt height is honored", "[Skirt][.]") {
// [NotWorking]: slice() intermittently throws clipper's "Coordinate outside allowed
// range" in CI (Linux) while passing locally. Disabled pending a root-cause fix in a
// follow-up PR.
TEST_CASE("Skirt height is honored", "[SkirtBrim][NotWorking]") {
DynamicPrintConfig config = Slic3r::DynamicPrintConfig::full_print_config();
config.set_deserialize_strict({
{ "skirts", 1 },
{ "skirt_height", 5 },
{ "perimeters", 0 },
{ "support_material_speed", 99 },
// avoid altering speeds unexpectedly
{ "cooling", false },
{ "first_layer_speed", "100%" }
{ "skirt_loops", 1 },
{ "skirt_height", 5 },
{ "wall_loops", 0 },
{ "gcode_comments", true }
});
std::string gcode;
@@ -49,75 +52,39 @@ TEST_CASE("Skirt height is honored", "[Skirt][.]") {
gcode = Slic3r::Test::slice({TestMesh::cube_20x20x20, TestMesh::cube_20x20x20}, config);
}
std::map<double, bool> layers_with_skirt;
double support_speed = config.opt<Slic3r::ConfigOptionFloat>("support_material_speed")->value * MM_PER_MIN;
GCodeReader parser;
parser.parse_buffer(gcode, [&layers_with_skirt, &support_speed] (Slic3r::GCodeReader &self, const Slic3r::GCodeReader::GCodeLine &line) {
if (line.extruding(self) && self.f() == Catch::Approx(support_speed)) {
layers_with_skirt[self.z()] = 1;
}
});
REQUIRE(layers_with_skirt.size() == (size_t)config.opt_int("skirt_height"));
REQUIRE(layers_with_role(gcode, "skirt").size() == (size_t)config.opt_int("skirt_height"));
}
SCENARIO("Original Slic3r Skirt/Brim tests", "[SkirtBrim][.]") {
// [NotWorking]: see "Skirt height is honored" above; same CI-only clipper range throw.
SCENARIO("Skirt and brim generation", "[SkirtBrim][NotWorking]") {
GIVEN("A default configuration") {
DynamicPrintConfig config = Slic3r::DynamicPrintConfig::full_print_config();
config.set_num_extruders(4);
config.set_deserialize_strict({
{ "support_material_speed", 99 },
{ "first_layer_height", 0.3 },
{ "gcode_comments", true },
{ "initial_layer_print_height", 0.3 },
{ "gcode_comments", true },
// avoid altering speeds unexpectedly
{ "cooling", false },
{ "first_layer_speed", "100%" },
{ "slow_down_for_layer_cooling", false },
{ "initial_layer_speed", "100%" },
// remove noise from top/solid layers
{ "top_solid_layers", 0 },
{ "bottom_solid_layers", 1 },
{ "start_gcode", "T[initial_tool]\n" }
{ "top_shell_layers", 0 },
{ "bottom_shell_layers", 1 },
{ "machine_start_gcode", "T[initial_tool]\n" }
});
WHEN("Brim width is set to 5") {
config.set_deserialize_strict({
{ "perimeters", 0 },
{ "skirts", 0 },
{ "brim_width", 5 }
{ "wall_loops", 0 },
{ "skirt_loops", 0 },
{ "brim_type", "outer_only" },
{ "brim_width", 5 }
});
THEN("Brim is generated") {
std::string gcode = Slic3r::Test::slice({TestMesh::cube_20x20x20}, config);
bool brim_generated = false;
double support_speed = config.opt<Slic3r::ConfigOptionFloat>("support_material_speed")->value * MM_PER_MIN;
Slic3r::GCodeReader parser;
parser.parse_buffer(gcode, [&brim_generated, support_speed] (Slic3r::GCodeReader& self, const Slic3r::GCodeReader::GCodeLine& line) {
if (self.z() == Catch::Approx(0.3) || line.new_Z(self) == Catch::Approx(0.3)) {
if (line.extruding(self) && self.f() == Catch::Approx(support_speed)) {
brim_generated = true;
}
}
});
REQUIRE(brim_generated);
REQUIRE(! layers_with_role(gcode, "brim").empty());
}
}
WHEN("Skirt area is smaller than the brim") {
config.set_deserialize_strict({
{ "skirts", 1 },
{ "brim_width", 10}
});
THEN("Gcode generates") {
REQUIRE(! Slic3r::Test::slice({TestMesh::cube_20x20x20}, config).empty());
}
}
WHEN("Skirt height is 0 and skirts > 0") {
config.set_deserialize_strict({
{ "skirts", 2 },
{ "skirt_height", 0 }
});
THEN("Gcode generates") {
REQUIRE(! Slic3r::Test::slice({TestMesh::cube_20x20x20}, config).empty());
}
}
#if 0
// This is a real error! One shall print the brim with the external perimeter extruder!
@@ -154,10 +121,11 @@ SCENARIO("Original Slic3r Skirt/Brim tests", "[SkirtBrim][.]") {
WHEN("brim width to 1 with layer_width of 0.5") {
config.set_deserialize_strict({
{ "skirts", 0 },
{ "first_layer_extrusion_width", 0.5 },
{ "brim_width", 1 }
});
{ "skirt_loops", 0 },
{ "initial_layer_line_width", 0.5 },
{ "brim_type", "outer_only" },
{ "brim_width", 1 }
});
THEN("2 brim lines") {
Slic3r::Print print;
Slic3r::Test::init_and_process_print({TestMesh::cube_20x20x20}, print, config);
@@ -203,68 +171,68 @@ SCENARIO("Original Slic3r Skirt/Brim tests", "[SkirtBrim][.]") {
WHEN("Object is plated with overhang support and a brim") {
config.set_deserialize_strict({
{ "layer_height", 0.4 },
{ "first_layer_height", 0.4 },
{ "skirts", 1 },
{ "skirt_distance", 0 },
{ "support_material_speed", 99 },
{ "perimeter_extruder", 1 },
{ "support_material_extruder", 2 },
{ "infill_extruder", 3 }, // ensure that a tool command gets emitted.
{ "cooling", false }, // to prevent speeds to be altered
{ "first_layer_speed", "100%" }, // to prevent speeds to be altered
{ "start_gcode", "T[initial_tool]\n" }
{ "layer_height", 0.4 },
{ "initial_layer_print_height", 0.4 },
{ "skirt_loops", 1 },
{ "skirt_distance", 0 },
{ "enable_support", 1 },
{ "brim_type", "outer_only" },
{ "brim_width", 5 }
});
THEN("overhang generates?") {
//FIXME does it make sense?
REQUIRE(! Slic3r::Test::slice({TestMesh::overhang}, config).empty());
THEN("Support and brim are both emitted") {
std::string gcode = Slic3r::Test::slice({TestMesh::overhang}, config);
REQUIRE(! layers_with_role(gcode, "support").empty());
REQUIRE(! layers_with_role(gcode, "brim").empty());
}
// config.set("support_material", true); // to prevent speeds to be altered
}
WHEN("an object with support is surrounded by a skirt") {
config.set_deserialize_strict({
{ "enable_support", 1 },
{ "skirt_loops", 1 },
{ "skirt_distance", 2 },
{ "brim_type", "no_brim" },
{ "z_hop", 0 }
});
THEN("the skirt is long enough to enclose the object and its support") {
std::string gcode = Slic3r::Test::slice({TestMesh::overhang}, config);
const double first_layer_z = config.opt_float("initial_layer_print_height");
#if 0
// This test is not finished.
THEN("skirt length is large enough to contain object with support") {
CHECK(config.opt_bool("support_material")); // test is not valid if support material is off
std::string gcode = Slic3r::Test::slice({TestMesh::cube_20x20x20}, config);
double support_speed = config.opt<ConfigOptionFloat>("support_material_speed")->value * MM_PER_MIN;
double skirt_length = 0.0;
Points extrusion_points;
int tool = -1;
GCodeReader parser;
parser.parse_buffer(gcode, [config, &extrusion_points, &tool, &skirt_length, support_speed] (Slic3r::GCodeReader& self, const Slic3r::GCodeReader::GCodeLine& line) {
// std::cerr << line.cmd() << "\n";
if (boost::starts_with(line.cmd(), "T")) {
tool = atoi(line.cmd().data() + 1);
} else if (self.z() == Catch::Approx(config.opt<ConfigOptionFloat>("first_layer_height")->value)) {
// on first layer
if (line.extruding(self) && line.dist_XY(self) > 0) {
float speed = ( self.f() > 0 ? self.f() : line.new_F(self));
// std::cerr << "Tool " << tool << "\n";
if (speed == Catch::Approx(support_speed) && tool == config.opt_int("perimeter_extruder") - 1) {
// Skirt uses first material extruder, support material speed.
skirt_length += line.dist_XY(self);
} else
extrusion_points.push_back(Slic3r::Point::new_scale(line.new_X(self), line.new_Y(self)));
}
}
if (self.z() == Catch::Approx(0.3) || line.new_Z(self) == Catch::Approx(0.3)) {
if (line.extruding(self) && self.f() == Catch::Approx(support_speed)) {
}
}
// On the first layer, accumulate the skirt loop length and collect the
// object + support extrusion points; the skirt must enclose them.
double skirt_length = 0.0;
Points footprint;
GCodeReader parser;
parser.parse_buffer(gcode, [&] (GCodeReader& self, const GCodeReader::GCodeLine& line) {
if (! line.extruding(self) || line.dist_XY(self) <= 0 || std::abs(self.z() - first_layer_z) > 0.01)
return;
if (line.comment().find("skirt") != std::string_view::npos)
skirt_length += line.dist_XY(self);
else
footprint.push_back(Point::new_scale(line.new_X(self), line.new_Y(self)));
});
Slic3r::Polygon convex_hull = Slic3r::Geometry::convex_hull(extrusion_points);
double hull_perimeter = unscale<double>(convex_hull.split_at_first_point().length());
const double hull_perimeter = unscale<double>(Geometry::convex_hull(footprint).split_at_first_point().length());
REQUIRE(hull_perimeter > 0.0); // guard against an empty footprint passing trivially
REQUIRE(skirt_length > hull_perimeter);
}
#endif
}
WHEN("Large minimum skirt length is used.") {
config.set("min_skirt_length", 20);
THEN("Gcode generation doesn't crash") {
REQUIRE(! Slic3r::Test::slice({TestMesh::cube_20x20x20}, config).empty());
// One skirt loop around a 20mm cube is ~88mm, so 500mm forces extra loops.
config.set_deserialize_strict({
{ "skirt_loops", 1 },
{ "min_skirt_length", 500 }
});
THEN("The skirt is extended to at least the minimum length") {
std::string gcode = Slic3r::Test::slice({TestMesh::cube_20x20x20}, config);
double skirt_length = 0.0;
GCodeReader parser;
parser.parse_buffer(gcode, [&skirt_length] (GCodeReader& self, const GCodeReader::GCodeLine& line) {
if (line.extruding(self) && line.comment().find("skirt") != std::string_view::npos)
skirt_length += line.dist_XY(self);
});
REQUIRE(skirt_length >= 500.0);
}
}
}
+43 -186
View File
@@ -8,24 +8,41 @@
using namespace Slic3r::Test;
using namespace Slic3r;
TEST_CASE("SupportMaterial: Three raft layers created", "[SupportMaterial][.]")
TEST_CASE("SupportMaterial: Three raft layers created", "[SupportMaterial]")
{
Slic3r::Print print;
Slic3r::Test::init_and_process_print({ TestMesh::cube_20x20x20 }, print, {
{ "support_material", 1 },
{ "raft_layers", 3 }
{ "enable_support", 1 },
{ "raft_layers", 3 }
});
REQUIRE(print.objects().front()->support_layers().size() == 3);
}
SCENARIO("SupportMaterial: support_layers_z and contact_distance", "[SupportMaterial][.]")
TEST_CASE("SupportMaterial: enforced support layers are generated", "[SupportMaterial]")
{
// enforce_support_layers forces support on the first N layers even with support off.
Slic3r::Print baseline;
Slic3r::Test::init_and_process_print({ TestMesh::overhang }, baseline, {
{ "enable_support", 0 },
{ "enforce_support_layers", 0 }
});
REQUIRE(baseline.objects().front()->support_layers().empty());
Slic3r::Print enforced;
Slic3r::Test::init_and_process_print({ TestMesh::overhang }, enforced, {
{ "enable_support", 0 },
{ "enforce_support_layers", 100 }
});
REQUIRE(enforced.objects().front()->support_layers().size() > 0);
}
SCENARIO("SupportMaterial: support_layers_z and contact_distance", "[SupportMaterial]")
{
// Box h = 20mm, hole bottom at 5mm, hole height 10mm (top edge at 15mm).
TriangleMesh mesh = Slic3r::Test::mesh(Slic3r::Test::TestMesh::cube_with_hole);
mesh.rotate_x(float(M_PI / 2));
// mesh.write_binary("d:\\temp\\cube_with_hole.stl");
auto check = [](Slic3r::Print &print, bool &first_support_layer_height_ok, bool &layer_height_minimum_ok, bool &layer_height_maximum_ok, bool &top_spacing_ok)
auto check = [](Slic3r::Print &print, bool &first_support_layer_height_ok, bool &layer_height_minimum_ok, bool &layer_height_maximum_ok)
{
ConstSupportLayerPtrsAdaptor support_layers = print.objects().front()->support_layers();
@@ -43,196 +60,36 @@ SCENARIO("SupportMaterial: support_layers_z and contact_distance", "[SupportMate
if (support_layers[i]->print_z - support_layers[i - 1]->print_z > max_layer_height + EPSILON)
layer_height_maximum_ok = false;
}
#if 0
double expected_top_spacing = print.default_object_config().layer_height + print.config().nozzle_diameter.get_at(0);
bool wrong_top_spacing = 0;
std::vector<coordf_t> top_z { 1.1 };
for (coordf_t top_z_el : top_z) {
// find layer index of this top surface.
size_t layer_id = -1;
for (size_t i = 0; i < support_z.size(); ++ i) {
if (abs(support_z[i] - top_z_el) < EPSILON) {
layer_id = i;
i = static_cast<int>(support_z.size());
}
}
// check that first support layer above this top surface (or the next one) is spaced with nozzle diameter
if (abs(support_z[layer_id + 1] - support_z[layer_id] - expected_top_spacing) > EPSILON &&
abs(support_z[layer_id + 2] - support_z[layer_id] - expected_top_spacing) > EPSILON) {
wrong_top_spacing = 1;
}
}
d = ! wrong_top_spacing;
#else
top_spacing_ok = true;
#endif
};
GIVEN("A print object having one modelObject") {
WHEN("First layer height = 0.4") {
WHEN("Layer height = 0.2 and first layer height = 0.4") {
Slic3r::Print print;
Slic3r::Test::init_and_process_print({ mesh }, print, {
{ "support_material", 1 },
{ "layer_height", 0.2 },
{ "first_layer_height", 0.4 },
{ "dont_support_bridges", false },
{ "enable_support", 1 },
{ "layer_height", 0.2 },
{ "initial_layer_print_height", 0.4 },
{ "dont_support_bridges", false },
});
bool a, b, c, d;
check(print, a, b, c, d);
THEN("First layer height is honored") { REQUIRE(a == true); }
THEN("No null or negative support layers") { REQUIRE(b == true); }
THEN("No layers thicker than nozzle diameter") { REQUIRE(c == true); }
// THEN("Layers above top surfaces are spaced correctly") { REQUIRE(d == true); }
bool first_layer_ok, layer_min_ok, layer_max_ok;
check(print, first_layer_ok, layer_min_ok, layer_max_ok);
THEN("First layer height is honored") { REQUIRE(first_layer_ok == true); }
THEN("No null or negative support layers") { REQUIRE(layer_min_ok == true); }
THEN("No layers thicker than nozzle diameter") { REQUIRE(layer_max_ok == true); }
}
WHEN("Layer height = 0.2 and, first layer height = 0.3") {
WHEN("Layer height = 0.2 and first layer height = 0.3") {
Slic3r::Print print;
Slic3r::Test::init_and_process_print({ mesh }, print, {
{ "support_material", 1 },
{ "layer_height", 0.2 },
{ "first_layer_height", 0.3 },
{ "dont_support_bridges", false },
{ "enable_support", 1 },
{ "layer_height", 0.2 },
{ "initial_layer_print_height", 0.3 },
{ "dont_support_bridges", false },
});
bool a, b, c, d;
check(print, a, b, c, d);
THEN("First layer height is honored") { REQUIRE(a == true); }
THEN("No null or negative support layers") { REQUIRE(b == true); }
THEN("No layers thicker than nozzle diameter") { REQUIRE(c == true); }
// THEN("Layers above top surfaces are spaced correctly") { REQUIRE(d == true); }
}
WHEN("Layer height = nozzle_diameter[0]") {
Slic3r::Print print;
Slic3r::Test::init_and_process_print({ mesh }, print, {
{ "support_material", 1 },
{ "layer_height", 0.2 },
{ "first_layer_height", 0.3 },
{ "dont_support_bridges", false },
});
bool a, b, c, d;
check(print, a, b, c, d);
THEN("First layer height is honored") { REQUIRE(a == true); }
THEN("No null or negative support layers") { REQUIRE(b == true); }
THEN("No layers thicker than nozzle diameter") { REQUIRE(c == true); }
// THEN("Layers above top surfaces are spaced correctly") { REQUIRE(d == true); }
bool first_layer_ok, layer_min_ok, layer_max_ok;
check(print, first_layer_ok, layer_min_ok, layer_max_ok);
THEN("First layer height is honored") { REQUIRE(first_layer_ok == true); }
THEN("No null or negative support layers") { REQUIRE(layer_min_ok == true); }
THEN("No layers thicker than nozzle diameter") { REQUIRE(layer_max_ok == true); }
}
}
}
#if 0
// Test 8.
TEST_CASE("SupportMaterial: forced support is generated", "[SupportMaterial]")
{
// Create a mesh & modelObject.
TriangleMesh mesh = TriangleMesh::make_cube(20, 20, 20);
Model model = Model();
ModelObject *object = model.add_object();
object->add_volume(mesh);
model.add_default_instances();
model.align_instances_to_origin();
Print print = Print();
std::vector<coordf_t> contact_z = {1.9};
std::vector<coordf_t> top_z = {1.1};
print.default_object_config.support_material_enforce_layers = 100;
print.default_object_config.support_material = 0;
print.default_object_config.layer_height = 0.2;
print.default_object_config.set_deserialize("first_layer_height", "0.3");
print.add_model_object(model.objects[0]);
print.objects.front()->_slice();
SupportMaterial *support = print.objects.front()->_support_material();
auto support_z = support->support_layers_z(contact_z, top_z, print.default_object_config.layer_height);
bool check = true;
for (size_t i = 1; i < support_z.size(); i++) {
if (support_z[i] - support_z[i - 1] <= 0)
check = false;
}
REQUIRE(check == true);
}
// TODO
bool test_6_checks(Print& print)
{
bool has_bridge_speed = true;
// Pre-Processing.
PrintObject* print_object = print.objects.front();
print_object->infill();
SupportMaterial* support_material = print.objects.front()->_support_material();
support_material->generate(print_object);
// TODO but not needed in test 6 (make brims and make skirts).
// Exporting gcode.
// TODO validation found in Simple.pm
return has_bridge_speed;
}
// Test 6.
SCENARIO("SupportMaterial: Checking bridge speed", "[SupportMaterial]")
{
GIVEN("Print object") {
// Create a mesh & modelObject.
TriangleMesh mesh = TriangleMesh::make_cube(20, 20, 20);
Model model = Model();
ModelObject *object = model.add_object();
object->add_volume(mesh);
model.add_default_instances();
model.align_instances_to_origin();
Print print = Print();
print.config.brim_width = 0;
print.config.skirts = 0;
print.config.skirts = 0;
print.default_object_config.support_material = 1;
print.default_region_config.top_solid_layers = 0; // so that we don't have the internal bridge over infill.
print.default_region_config.bridge_speed = 99;
print.config.cooling = 0;
print.config.set_deserialize("first_layer_speed", "100%");
WHEN("support_material_contact_distance = 0.2") {
print.default_object_config.support_material_contact_distance = 0.2;
print.add_model_object(model.objects[0]);
bool check = test_6_checks(print);
REQUIRE(check == true); // bridge speed is used.
}
WHEN("support_material_contact_distance = 0") {
print.default_object_config.support_material_contact_distance = 0;
print.add_model_object(model.objects[0]);
bool check = test_6_checks(print);
REQUIRE(check == true); // bridge speed is not used.
}
WHEN("support_material_contact_distance = 0.2 & raft_layers = 5") {
print.default_object_config.support_material_contact_distance = 0.2;
print.default_object_config.raft_layers = 5;
print.add_model_object(model.objects[0]);
bool check = test_6_checks(print);
REQUIRE(check == true); // bridge speed is used.
}
WHEN("support_material_contact_distance = 0 & raft_layers = 5") {
print.default_object_config.support_material_contact_distance = 0;
print.default_object_config.raft_layers = 5;
print.add_model_object(model.objects[0]);
bool check = test_6_checks(print);
REQUIRE(check == true); // bridge speed is not used.
}
}
}
#endif
+6 -2
View File
@@ -1,8 +1,12 @@
get_filename_component(_TEST_NAME ${CMAKE_CURRENT_LIST_DIR} NAME)
add_executable(${_TEST_NAME}_tests
${_TEST_NAME}_tests_main.cpp
${_TEST_NAME}_tests.cpp
test_geometry.cpp
test_nfp.cpp
test_nfp_placer.cpp
printer_parts.cpp
printer_parts.hpp
libnest2d_test_utils.hpp
)
target_link_libraries(${_TEST_NAME}_tests test_common libnest2d Catch2::Catch2WithMain)
@@ -10,4 +14,4 @@ set_property(TARGET ${_TEST_NAME}_tests PROPERTY FOLDER "tests")
orcaslicer_copy_test_dlls()
catch_discover_tests(${_TEST_NAME}_tests)
orcaslicer_discover_tests(${_TEST_NAME}_tests)
+46
View File
@@ -0,0 +1,46 @@
#pragma once
// Shared setup for the libnest2d test suite.
//
// The no-fit-polygon numeric backend specialised below changes how NFP is
// computed for the whole program, so every translation unit that instantiates
// NFP (the geometry, nfp and placer tests) must see the same definition. Keep
// it here and include this header from every libnest2d test file.
#include <cstdint>
#include <libnest2d/libnest2d.hpp>
#include <libnest2d/utils/rotcalipers.hpp>
#if defined(_MSC_VER) && defined(__clang__)
#define BOOST_NO_CXX17_HDR_STRING_VIEW
#endif
#include "boost/multiprecision/integer.hpp"
#include "boost/rational.hpp"
namespace libnest2d {
#if !defined(_MSC_VER) && defined(__SIZEOF_INT128__) && !defined(__APPLE__)
using LargeInt = __int128;
#else
using LargeInt = boost::multiprecision::int128_t;
template<> struct _NumTag<LargeInt> { using Type = ScalarTag; };
#endif
template<class T> struct _NumTag<boost::rational<T>> { using Type = RationalTag; };
using RectangleItem = libnest2d::Rectangle;
namespace nfp {
// Use exact rational arithmetic for the convex NFP so the tests are not at the
// mercy of floating-point rounding.
template<class S>
struct NfpImpl<S, NfpLevel::CONVEX_ONLY> {
NfpResult<S> operator()(const S &sh, const S &other) {
return nfpConvexOnly<S, boost::rational<LargeInt>>(sh, other);
}
};
} // namespace nfp
} // namespace libnest2d
+49
View File
@@ -0,0 +1,49 @@
#include <catch2/catch_all.hpp>
#include "libnest2d_test_utils.hpp"
using namespace libnest2d;
// Basic behaviour of the Item type and the high-level nest() entry point:
// items copy independently, and nest() leaves degenerate or oversized items
// untouched.
TEST_CASE("Item construction and copy", "[Nesting]") {
Item sh = { {0, 0}, {1, 0}, {1, 1}, {0, 1} };
REQUIRE(sh.vertexCount() == 4u);
Item sh2({ {0, 0}, {1, 0}, {1, 1}, {0, 1} });
REQUIRE(sh2.vertexCount() == 4u);
Item sh3 = sh2; // copy
REQUIRE(sh3.vertexCount() == 4u);
sh2 = {}; // clearing the original leaves the copy intact
REQUIRE(sh2.vertexCount() == 0u);
REQUIRE(sh3.vertexCount() == 4u);
}
TEST_CASE("nest() leaves an empty or zero-area item untouched", "[Nesting]") {
auto bin = Box(250000000, 210000000);
std::vector<Item> items;
items.emplace_back(Item{}); // empty item
items.emplace_back(Item{ {0, 200} }); // zero-area item
size_t bins = nest(items, bin);
REQUIRE(bins == 0u);
for (const auto &itm : items) REQUIRE(itm.binId() == BIN_ID_UNSET);
}
TEST_CASE("nest() leaves an item larger than the bin untouched", "[Nesting]") {
auto bin = Box(250000000, 210000000);
std::vector<Item> items;
items.emplace_back(RectangleItem{250000001, 210000001}); // larger than the bin
size_t bins = nest(items, bin);
REQUIRE(bins == 0u);
REQUIRE(items.front().binId() == BIN_ID_UNSET);
}
File diff suppressed because it is too large Load Diff
+190
View File
@@ -0,0 +1,190 @@
#include <catch2/catch_all.hpp>
#include "libnest2d_test_utils.hpp"
#include "printer_parts.hpp"
using namespace libnest2d;
namespace {
using Catch::Matchers::WithinAbs;
using Catch::Matchers::WithinRel;
// Geometry values round-trip through floating point, so compare with a small
// tolerance that works both near and away from zero.
void require_close(double value, double expected) {
REQUIRE_THAT(value, WithinRel(expected, 1e-9) || WithinAbs(expected, 1e-9));
}
// The printer parts as nestable items, computed once.
const std::vector<Item> &prusa_parts() {
static const std::vector<Item> parts = [] {
std::vector<Item> ret;
ret.reserve(PRINTER_PART_POLYGONS.size());
for (auto &inp : PRINTER_PART_POLYGONS) {
auto inp_cpy = inp;
if (ClosureTypeV<PathImpl> == Closure::OPEN)
inp_cpy.points.pop_back();
if constexpr (!is_clockwise<PathImpl>())
std::reverse(inp_cpy.begin(), inp_cpy.end());
ret.emplace_back(inp_cpy);
}
return ret;
}();
return parts;
}
} // namespace
TEST_CASE("Degree and radian conversion round-trips", "[Geometry]") {
Degrees deg(180);
Radians rad(deg);
require_close(rad, Pi);
require_close(deg, 180);
require_close(Degrees(rad), 180);
require_close(rad, Radians(deg));
require_close(Degrees(rad), deg);
REQUIRE(rad == deg);
}
TEST_CASE("Segment angle to the X axis", "[Geometry]") {
auto quadrant = [](Point to) { return Degrees(Segment({0, 0}, to).angleToXaxis()); };
REQUIRE(quadrant({12, -10}) > 270); REQUIRE(quadrant({12, -10}) < 360);
REQUIRE(quadrant({12, 10}) > 0); REQUIRE(quadrant({12, 10}) < 90);
REQUIRE(quadrant({-12, 10}) > 90); REQUIRE(quadrant({-12, 10}) < 180);
REQUIRE(quadrant({-12, -10}) > 180); REQUIRE(quadrant({-12, -10}) < 270);
require_close(quadrant({1, 0}), 0);
require_close(quadrant({0, 1}), 90);
require_close(quadrant({-1, 0}), 180);
require_close(quadrant({0, -1}), 270);
}
TEST_CASE("Point to segment distance", "[Geometry]") {
Point p2 = {10, 0};
Segment seg({0, 0}, {10, 10});
auto check = [](TCompute<Coord> val, TCompute<Coord> expected) {
if (std::is_floating_point<TCompute<Coord>>::value)
require_close(double(val), double(expected));
else
REQUIRE(val == expected);
};
auto h = pointlike::horizontalDistance(p2, seg);
REQUIRE(h.second);
check(h.first, 10);
auto v = pointlike::verticalDistance(p2, seg);
REQUIRE(v.second);
check(v.first, -10);
v = pointlike::verticalDistance(Point{10, 20}, seg);
REQUIRE(v.second);
check(v.first, 10);
Point p4 = {80, 0};
Segment seg2({0, 0}, {0, 40});
h = pointlike::horizontalDistance(p4, seg2);
REQUIRE(h.second);
check(h.first, 80);
v = pointlike::verticalDistance(p4, seg2);
REQUIRE_FALSE(v.second); // the point does not project onto the segment
}
TEST_CASE("Item area", "[Geometry]") {
require_close(RectangleItem(10, 10).area(), 100);
require_close(RectangleItem(100, 100).area(), 10000);
Item item = {
{61, 97}, {70, 151}, {176, 151}, {189, 138},
{189, 59}, {70, 59}, {61, 77}, {61, 97}
};
REQUIRE(std::abs(shapelike::area(item.transformedShape())) > 0);
}
TEST_CASE("Point inside polygon", "[Geometry]") {
RectangleItem rect(10, 10);
REQUIRE(rect.isInside(Point{1, 1}));
REQUIRE(rect.isInside(Point{3, 3}));
REQUIRE_FALSE(rect.isInside(Point{11, 11}));
REQUIRE_FALSE(rect.isInside(Point{11, 12}));
}
TEST_CASE("Bounding circle of the printer parts", "[Geometry]") {
PolygonImpl p = {{{0, 10}, {10, 0}, {0, -10}, {0, 10}}, {}};
Circle c = placers::boundingCircle(p);
require_close(getX(c.center()), 0);
require_close(getY(c.center()), 0);
require_close(c.radius(), 10);
shapelike::translate(p, PointImpl{10, 10});
c = placers::boundingCircle(p);
require_close(getX(c.center()), 10);
require_close(getY(c.center()), 10);
require_close(c.radius(), 10);
for (auto &part : prusa_parts()) {
c = placers::boundingCircle(part.transformedShape());
REQUIRE_FALSE(std::isnan(c.radius()));
for (auto v : shapelike::contour(part.transformedShape())) {
auto d = pointlike::distance(v, c.center());
if (d > c.radius())
REQUIRE(std::abs(1.0 - d / c.radius()) <= 1e-3); // on the circle
}
}
}
TEST_CASE("Convex hull of a printer part", "[Geometry]") {
PathImpl poly = PRINTER_PART_POLYGONS[0];
auto chull = sl::convexHull(poly);
REQUIRE(chull.size() == poly.size()); // the part is already convex
}
namespace {
using Unit = int64_t;
using Ratio = boost::rational<boost::multiprecision::int128_t>;
// Reference minimum-area bounding box, found by brute force over every edge
// direction, to validate the rotating-calipers implementation.
long double ref_min_area_box(const PolygonImpl &p) {
long double min_area = std::numeric_limits<long double>::max();
auto update_min = [&](const Point &a, const Point &b) {
PolygonImpl rotated = p;
sl::rotate(rotated, -Segment(a, b).angleToXaxis());
min_area = std::min(min_area, cast<long double>(sl::area(sl::boundingBox(rotated))));
};
auto it = sl::cbegin(p), itx = std::next(it);
while (itx != sl::cend(p)) { update_min(*it, *itx); ++it; ++itx; }
update_min(*std::prev(sl::cend(p)), *sl::cbegin(p));
return min_area;
}
} // namespace
TEST_CASE("Minimum-area bounding box via rotating calipers", "[Geometry]") {
const long double tolerance = 500e6l;
for (const PathImpl &part : PRINTER_PART_POLYGONS) {
auto area = cast<long double>(minAreaBoundingBox<PathImpl, Unit, Ratio>(part).area());
REQUIRE(std::abs(ref_min_area_box(PolygonImpl(part)) - area) < tolerance);
}
for (PathImpl part : STEGOSAUR_POLYGONS) {
std::reverse(part.begin(), part.end());
PolygonImpl poly(removeCollinearPoints<PathImpl, PointImpl, Unit>(part, 1000000));
auto area = cast<long double>(minAreaBoundingBox<PolygonImpl, Unit, Ratio>(poly).area());
REQUIRE(std::abs(ref_min_area_box(poly) - area) < tolerance);
}
}
+267
View File
@@ -0,0 +1,267 @@
#include <catch2/catch_all.hpp>
#include "libnest2d_test_utils.hpp"
using namespace libnest2d;
namespace {
struct ItemPair {
Item orbiter;
Item stationary;
};
std::vector<ItemPair> nfp_testdata = {
{
{
{80, 50},
{100, 70},
{120, 50}
},
{
{10, 10},
{10, 40},
{40, 40},
{40, 10}
}
},
{
{
{80, 50},
{60, 70},
{80, 90},
{120, 90},
{140, 70},
{120, 50}
},
{
{10, 10},
{10, 40},
{40, 40},
{40, 10}
}
},
{
{
{40, 10},
{30, 10},
{20, 20},
{20, 30},
{30, 40},
{40, 40},
{50, 30},
{50, 20}
},
{
{80, 0},
{80, 30},
{110, 30},
{110, 0}
}
},
{
{
{117, 107},
{118, 109},
{120, 112},
{122, 113},
{128, 113},
{130, 112},
{132, 109},
{133, 107},
{133, 103},
{132, 101},
{130, 98},
{128, 97},
{122, 97},
{120, 98},
{118, 101},
{117, 103}
},
{
{102, 116},
{111, 126},
{114, 126},
{144, 106},
{148, 100},
{148, 85},
{147, 84},
{102, 84}
}
},
{
{
{99, 122},
{108, 140},
{110, 142},
{139, 142},
{151, 122},
{151, 102},
{142, 70},
{139, 68},
{111, 68},
{108, 70},
{99, 102}
},
{
{107, 124},
{128, 125},
{133, 125},
{136, 124},
{140, 121},
{142, 119},
{143, 116},
{143, 109},
{141, 93},
{139, 89},
{136, 86},
{134, 85},
{108, 85},
{107, 86}
}
},
{
{
{91, 100},
{94, 144},
{117, 153},
{118, 153},
{159, 112},
{159, 110},
{156, 66},
{133, 57},
{132, 57},
{91, 98}
},
{
{101, 90},
{103, 98},
{107, 113},
{114, 125},
{115, 126},
{135, 126},
{136, 125},
{144, 114},
{149, 90},
{149, 89},
{148, 87},
{145, 84},
{105, 84},
{102, 87},
{101, 89}
}
}
};
// libnest2d's vertex order depends on the backend; normalise to clockwise.
Item reversed_if_ccw(Item it) {
if (!is_clockwise<PolygonImpl>()) {
auto raw = it.rawShape();
std::reverse(sl::begin(raw), sl::end(raw));
it = Item{raw};
}
return it;
}
// Sliding `orbiter` around `stationary` along their no-fit polygon must keep the
// two shapes touching at every NFP vertex, and `stationary` must lie inside the
// resulting inner-fit polygon.
template<nfp::NfpLevel lvl, Coord SCALE>
void check_nfp(const std::vector<ItemPair> &testdata) {
auto check_pair = [](Item orbiter, Item stationary) {
orbiter.translate({210 * SCALE, 0});
auto &&nfp = nfp::noFitPolygon<lvl>(stationary.rawShape(), orbiter.transformedShape());
placers::correctNfpPosition(nfp, stationary, orbiter);
REQUIRE(shapelike::isValid(nfp.first).first);
Item infp(nfp.first);
REQUIRE(stationary.isInside(infp));
auto vo = nfp::referenceVertex(orbiter.transformedShape());
for (auto v : infp) {
Item moved = orbiter;
moved.translate({getX(v) - getX(vo), getY(v) - getY(vo)});
REQUIRE(Item::touches(moved, stationary));
}
};
for (const ItemPair &td : testdata) {
check_pair(reversed_if_ccw(td.orbiter), reversed_if_ccw(td.stationary));
check_pair(reversed_if_ccw(td.stationary), reversed_if_ccw(td.orbiter));
}
}
} // namespace
TEST_CASE("No-fit polygon of convex shapes keeps the items touching", "[Geometry][NFP]") {
check_nfp<nfp::NfpLevel::CONVEX_ONLY, 1>(nfp_testdata);
}
TEST_CASE("BottomLeftPlacer left and down polygons", "[Geometry][NFP]") {
Box bin(100, 100);
BottomLeftPlacer placer(bin);
PathImpl pitem = {{70, 75}, {88, 60}, {65, 50}, {60, 30}, {80, 20},
{42, 20}, {35, 35}, {35, 55}, {40, 75}};
PathImpl left_control = {{40, 75}, {35, 55}, {35, 35}, {42, 20}, {0, 20}, {0, 75}};
PathImpl down_control = {{88, 60}, {88, 0}, {35, 0}, {35, 35},
{42, 20}, {80, 20}, {60, 30}, {65, 50}};
if constexpr (!is_clockwise<PathImpl>()) {
std::reverse(sl::begin(pitem), sl::end(pitem));
std::reverse(sl::begin(left_control), sl::end(left_control));
std::reverse(sl::begin(down_control), sl::end(down_control));
}
if constexpr (ClosureTypeV<PathImpl> == Closure::CLOSED) {
sl::addVertex(pitem, sl::front(pitem));
sl::addVertex(left_control, sl::front(left_control));
sl::addVertex(down_control, sl::front(down_control));
}
auto require_same_vertices = [](const Item &got, const Item &expected) {
REQUIRE(shapelike::isValid(got.rawShape()).first);
REQUIRE(got.vertexCount() == expected.vertexCount());
for (unsigned long i = 0; i < expected.vertexCount(); ++i) {
REQUIRE(getX(got.vertex(i)) == getX(expected.vertex(i)));
REQUIRE(getY(got.vertex(i)) == getY(expected.vertex(i)));
}
};
Item item{pitem};
require_same_vertices(Item(placer.leftPoly(item)), Item{left_control});
require_same_vertices(Item(placer.downPoly(item)), Item{down_control});
}
TEST_CASE("EdgeCache maps a parameter to a contour point", "[Geometry][NFP]") {
RectangleItem input(10, 10);
placers::EdgeCache<PolygonImpl> ecache(input);
auto first = *input.begin();
REQUIRE(getX(first) == getX(ecache.coords(0)));
REQUIRE(getY(first) == getY(ecache.coords(0)));
auto last = *std::prev(input.end());
REQUIRE(getX(last) == getX(ecache.coords(1.0)));
REQUIRE(getY(last) == getY(ecache.coords(1.0)));
for (int i = 0; i <= 100; ++i)
REQUIRE(shapelike::touches(ecache.coords(i * 0.01), input.transformedShape()));
}
TEST_CASE("Merging a pile with a polygon", "[Geometry][NFP]") {
RectangleItem rect1(10, 15), rect2(15, 15), rect3(20, 15);
rect2.translate({10, 0});
rect3.translate({25, 0});
TMultiShape<PolygonImpl> pile;
pile.push_back(rect1.transformedShape());
pile.push_back(rect2.transformedShape());
auto result = nfp::merge(pile, rect3.transformedShape());
REQUIRE(result.size() == 1); // the three abutting rectangles merge into one
RectangleItem ref(45, 15);
REQUIRE_THAT(shapelike::area(result.front()),
Catch::Matchers::WithinRel(ref.area(), 1e-9));
}
+140
View File
@@ -0,0 +1,140 @@
#include <catch2/catch_all.hpp>
#include "libnest2d_test_utils.hpp"
using namespace libnest2d;
// NfpPlacer is the No-Fit-Polygon placement engine that Orca's arranger drives
// (via _Nester/FirstFitSelection in Arrange.cpp). These exercise the placer
// directly: pack()/accept() are the core geometric placement primitives.
namespace {
struct NfpPlacerFixture {
using Cfg = NfpPlacer::Config;
Box bin{250000000, 210000000}; // 250 x 210 mm bed at 1e6 scale
NfpPlacer placer_with(Cfg cfg = {}) const {
cfg.parallel = false; // deterministic, single-threaded for tests
NfpPlacer p{bin};
p.configure(cfg);
return p;
}
// pack + accept; returns whether the item was placed.
static bool place(NfpPlacer &p, Item &item) {
auto res = p.pack(item);
if (res) p.accept(res);
return bool(res);
}
// Place every item and REQUIRE each one is packed.
static void place_all(NfpPlacer &p, std::vector<RectangleItem> &items) {
for (size_t i = 0; i < items.size(); ++i) {
INFO("packing item " << i);
REQUIRE(place(p, items[i]));
}
}
// No two items overlap (a shared edge is allowed) and each stays in the bin.
void require_disjoint_in_bin(std::vector<RectangleItem> &items) const {
for (size_t i = 0; i < items.size(); ++i) {
REQUIRE(sl::isInside(items[i].boundingBox(), bin));
for (size_t j = i + 1; j < items.size(); ++j) {
const bool overlaps = Item::intersects(items[i], items[j]) &&
!Item::touches(items[i], items[j]);
INFO("items " << i << " and " << j);
REQUIRE_FALSE(overlaps);
}
}
}
static std::vector<RectangleItem> squares(size_t n, Coord side) {
return std::vector<RectangleItem>(n, RectangleItem{side, side});
}
};
} // namespace
TEST_CASE_METHOD(NfpPlacerFixture, "NfpPlacer places a single item inside the bin", "[Nesting][Placer]") {
NfpPlacer placer = placer_with();
RectangleItem item{100000000, 100000000};
REQUIRE(place(placer, item));
REQUIRE(placer.getItems().size() == 1u);
REQUIRE(sl::isInside(item.boundingBox(), bin));
}
TEST_CASE_METHOD(NfpPlacerFixture, "NfpPlacer rejects an item larger than the bin", "[Nesting][Placer]") {
NfpPlacer placer = placer_with();
RectangleItem big{300000000, 300000000}; // wider and taller than the bin
auto res = placer.pack(big);
REQUIRE_FALSE(bool(res));
REQUIRE(placer.getItems().empty());
}
TEST_CASE_METHOD(NfpPlacerFixture, "NfpPlacer positions the first item for any starting point", "[Nesting][Placer]") {
// setInitialPosition() seeds the first item from the configured starting
// corner; pack() (without accept()) drives that switch for every value.
using A = Cfg::Alignment;
auto start = GENERATE(A::CENTER, A::BOTTOM_LEFT, A::BOTTOM_RIGHT,
A::TOP_LEFT, A::TOP_RIGHT, A::USER_DEFINED, A::DONT_ALIGN);
CAPTURE(int(start));
Cfg cfg;
cfg.starting_point = start;
cfg.best_object_pos = bin.center();
NfpPlacer placer = placer_with(cfg);
RectangleItem item{100000000, 100000000};
auto res = placer.pack(item);
REQUIRE(bool(res));
REQUIRE(sl::isInside(item.boundingBox(), bin));
}
TEST_CASE_METHOD(NfpPlacerFixture, "NfpPlacer packs many items without overlap", "[Nesting][Placer]") {
// Each item is placed against the no-fit polygon of the growing pile.
auto items = squares(GENERATE(2u, 6u, 9u), 60000000);
NfpPlacer placer = placer_with();
place_all(placer, items);
REQUIRE(placer.getItems().size() == items.size());
require_disjoint_in_bin(items);
}
TEST_CASE_METHOD(NfpPlacerFixture, "NfpPlacer evaluates the rotation candidates", "[Nesting][Placer]") {
Cfg cfg;
cfg.rotations = {0.0, Pi / 2.0}; // exercise the rotation search loop
NfpPlacer placer = placer_with(cfg);
std::vector<RectangleItem> rects = {
{180000000, 40000000}, {180000000, 40000000}, {180000000, 40000000}};
place_all(placer, rects);
require_disjoint_in_bin(rects);
}
TEST_CASE_METHOD(NfpPlacerFixture, "NfpPlacer's final alignment keeps the pile clear of a fixed obstacle", "[Nesting][Placer]") {
// A preloaded fixed item makes finalAlign's recentring keep the pile clear of
// it instead of dropping it straight onto the bin centre. Box{w,h} centres on
// the origin, so the obstacle sits there too; virtual keeps it in place.
RectangleItem obstacle{80000000, 80000000};
obstacle.translation({-40000000, -40000000}); // 80x80 mm centred in the bin (origin)
obstacle.markAsFixedInBin(0);
obstacle.is_virt_object = true;
auto items = squares(4, 30000000);
{
NfpPlacer placer = placer_with();
NfpPlacer::ItemGroup fixed;
fixed.emplace_back(obstacle);
placer.preload(fixed);
place_all(placer, items);
} // the placer's destructor runs finalAlign, translating the packed items
for (size_t i = 0; i < items.size(); ++i) {
INFO("item " << i);
const bool overlaps = Item::intersects(items[i], obstacle) &&
!Item::touches(items[i], obstacle);
REQUIRE_FALSE(overlaps);
}
}
+2 -1
View File
@@ -6,6 +6,7 @@ add_executable(${_TEST_NAME}_tests
test_aabbindirect.cpp
test_appconfig.cpp
test_arachne_walls.cpp
test_arrange.cpp
test_bambu_networking.cpp
test_clipper_offset.cpp
test_clipper_utils.cpp
@@ -39,4 +40,4 @@ set_property(TARGET ${_TEST_NAME}_tests PROPERTY FOLDER "tests")
orcaslicer_copy_test_dlls()
catch_discover_tests(${_TEST_NAME}_tests)
orcaslicer_discover_tests(${_TEST_NAME}_tests)
+58
View File
@@ -19,11 +19,14 @@
#include "libslic3r/Arachne/WallToolPaths.hpp"
#include "libslic3r/Arachne/utils/ExtrusionLine.hpp"
#include "libslic3r/Arachne/BeadingStrategy/BeadingStrategyFactory.hpp"
#include "libslic3r/Arachne/BeadingStrategy/BeadingStrategy.hpp"
#include "libslic3r/Polygon.hpp"
#include "libslic3r/ExPolygon.hpp"
#include "libslic3r/ClipperUtils.hpp"
#include "libslic3r/Point.hpp"
#include <algorithm>
#include <cmath>
using namespace Slic3r;
@@ -207,3 +210,58 @@ TEST_CASE("Arachne wall generation - 60% min_bead_width", "[Arachne]") {
size_t duplicates = run_arachne_test(60);
REQUIRE(duplicates == 0);
}
// Regression test for #14376 ("Fuzzy skin artifacting" — a surface bulge at a fixed height).
//
// PR #14031 changed WideningBeadingStrategy::compute() to take the thin-wall single-bead
// branch whenever thickness < getTransitionThickness(1). That branch emits a single bead at
// the full wall thickness and ignores the requested bead_count. When the skeletal graph asks
// for 2 beads at a thickness inside the 1<->2 transition band (between the inner wall width and
// getTransitionThickness(1)), the request was collapsed into one over-wide bead — an
// over-extruded line that shows up as a bulge on curved surfaces at a deterministic height.
//
// Profile mirrors the reporter's project ("0.20mm Standard @BBL X1C", 0.4mm nozzle):
// outer 0.42mm / inner 0.45mm, min_bead_width 85% (0.34mm), 2 walls (max_bead_count 4).
// For these numbers wall_split_middle_threshold = 2*0.34/0.42 - 1 = 0.619, so
// getTransitionThickness(1) = (1 + 0.619) * 0.42 = 0.68mm. A 0.5mm-thick wall therefore sits
// in the transition band: alpha produced 2 beads here, beta collapses it to 1 fat bead.
TEST_CASE("Arachne widening keeps two beads in transition band (#14376)", "[Arachne]") {
using namespace Slic3r::Arachne;
// Widths in mm; the scaled coord_t values and the thresholds below are both derived from
// these so a width change cannot silently desync the transition-band math.
const double outer_mm = 0.42, inner_mm = 0.45, min_bead_mm = 0.34; // min_bead = 85% of 0.4mm nozzle
const coord_t outer_width = scaled<coord_t>(outer_mm);
const coord_t inner_width = scaled<coord_t>(inner_mm);
const coord_t min_bead_width = scaled<coord_t>(min_bead_mm);
const coord_t min_feature_size = scaled<coord_t>(0.10); // 25% of 0.4mm nozzle
const coord_t transition_length = scaled<coord_t>(0.40);
const coord_t max_bead_count = 4; // 2 * wall_loops
// Same derivation as WallToolPaths.cpp.
const double split_middle_threshold = std::clamp(2.0 * min_bead_mm / outer_mm - 1.0, 0.01, 0.99);
const double add_middle_threshold = std::clamp(min_bead_mm / inner_mm, 0.01, 0.99);
auto strategy = BeadingStrategyFactory::makeStrategy(
outer_width, inner_width, transition_length,
/*transitioning_angle*/ float(M_PI / 4.0), /*print_thin_walls*/ true,
min_bead_width, min_feature_size,
split_middle_threshold, add_middle_threshold,
max_bead_count, /*outer_wall_offset*/ 0, /*inward_distributed_center_wall_count*/ 1);
// A wall thickness inside the 1<->2 bead transition band (inner_width < t < transition).
const coord_t thickness = scaled<coord_t>(0.50);
REQUIRE(thickness > inner_width);
REQUIRE(thickness < strategy->getTransitionThickness(1));
// When the graph requests 2 beads, the strategy must produce 2 beads — not collapse them
// into a single full-thickness (bulge) bead.
const BeadingStrategy::Beading beading = strategy->compute(thickness, 2);
REQUIRE(beading.bead_widths.size() == 2);
// And neither bead may be over-wide: a single collapsed bead would be ~0.5mm (the full
// thickness), well above the configured wall widths.
for (const coord_t w : beading.bead_widths)
CHECK(w <= inner_width);
}
+224
View File
@@ -0,0 +1,224 @@
#include <catch2/catch_all.hpp>
#include "libslic3r/Arrange.hpp"
#include "libslic3r/BoundingBox.hpp"
#include "libslic3r/ClipperUtils.hpp"
#include "libslic3r/ExPolygon.hpp"
using namespace Slic3r;
using namespace Slic3r::arrangement;
namespace {
using Catch::Matchers::WithinRel;
// Square of the given (scaled) side, lower-left at the origin. bed_idx starts at
// 0 because arrange() seeds the nester's bin from it (see ModelArrange.cpp).
ArrangePolygon make_square(coord_t side)
{
ArrangePolygon ap;
Polygon p;
p.points = {Point(0, 0), Point(side, 0), Point(side, side), Point(0, side)};
ap.poly = ExPolygon(p);
ap.bed_idx = 0;
return ap;
}
ArrangePolygons squares(int n, double side_mm)
{
ArrangePolygons items;
for (int i = 0; i < n; ++i)
items.emplace_back(make_square(scaled(side_mm)));
return items;
}
// Bed [0,0]..[w,h] in scaled coordinates.
BoundingBox bed(double w_mm, double h_mm)
{
return BoundingBox(Point(0, 0), Point(scaled(w_mm), scaled(h_mm)));
}
// The default progress callback prints to stdout; silence it.
ArrangeParams quiet_params(coord_t min_dist = 0)
{
ArrangeParams p{min_dist};
p.progressind = [](unsigned, std::string) {};
return p;
}
ExPolygons placed_shapes(const ArrangePolygons &items)
{
ExPolygons out;
out.reserve(items.size());
for (const ArrangePolygon &ap : items)
out.emplace_back(ap.transformed_poly());
return out;
}
// Area double-counted across the shapes: the sum counts overlaps twice, the
// union once, so the difference is the overlapping area (0 when disjoint).
double overlap_area(const ExPolygons &shapes)
{
double sum = 0;
for (const ExPolygon &e : shapes)
sum += e.area();
double uni = 0;
for (const ExPolygon &e : union_ex(shapes))
uni += e.area();
return sum - uni;
}
// Relative tolerance absorbs the area-unit rounding the clipper union introduces.
bool disjoint(const ExPolygons &shapes)
{
double total = 0;
for (const ExPolygon &e : shapes)
total += e.area();
return overlap_area(shapes) <= total * 1e-9;
}
void require_no_overlap(const ArrangePolygons &items)
{
REQUIRE(disjoint(placed_shapes(items)));
}
} // namespace
// Prove the overlap check the other tests rely on actually detects overlap.
TEST_CASE("overlap_area detects overlap and ignores touching edges", "[Arrange]")
{
auto square_at = [](double x_mm) {
ArrangePolygon ap = make_square(scaled(20.));
ap.translation = Vec2crd(scaled(x_mm), 0);
return ap.transformed_poly();
};
ExPolygon a = square_at(0.);
SECTION("disjoint shapes are reported disjoint") {
REQUIRE(disjoint({a, square_at(30.)}));
}
SECTION("edge-touching shapes are reported disjoint") {
REQUIRE(disjoint({a, square_at(20.)}));
}
SECTION("overlapping shapes are not, and the area is measured") {
REQUIRE_FALSE(disjoint({a, square_at(10.)}));
REQUIRE_THAT(overlap_area({a, square_at(10.)}),
WithinRel(double(scaled(10.)) * scaled(20.), 1e-9)); // 10x20 mm
}
}
TEST_CASE("Arrange places every item on the physical bed", "[Arrange]")
{
ArrangePolygons items = squares(5, 20.);
arrange(items, bed(200, 200), quiet_params(scaled(1.)));
for (const ArrangePolygon &ap : items)
REQUIRE(ap.bed_idx == 0);
}
TEST_CASE("Arranged items stay within the bed", "[Arrange]")
{
ArrangePolygons items = squares(6, 30.);
arrange(items, bed(200, 200), quiet_params(scaled(1.)));
for (const ArrangePolygon &ap : items) {
REQUIRE(ap.bed_idx == 0);
REQUIRE(bed(200, 200).contains(ap.transformed_poly().contour.bounding_box()));
}
}
TEST_CASE("Arranged items do not overlap", "[Arrange]")
{
ArrangePolygons items = squares(6, 40.);
arrange(items, bed(250, 250), quiet_params(scaled(2.)));
require_no_overlap(items);
}
TEST_CASE("Arrange spaces items by their inflation", "[Arrange]")
{
// Per-item inflation is how the arranger enforces clearance (the GUI fills it
// from min_obj_distance). Two items inflated 4mm each end up >= 8mm apart.
ArrangePolygons items = squares(4, 20.);
for (ArrangePolygon &ap : items)
ap.inflation = scaled(4.);
arrange(items, bed(200, 200), quiet_params());
// Axis-aligned squares are their own bounding boxes, so the clearance between
// a pair is the distance between their boxes (1mm slack for nester rounding).
std::vector<BoundingBox> boxes;
for (const ExPolygon &e : placed_shapes(items))
boxes.push_back(e.contour.bounding_box());
double min_gap = std::numeric_limits<double>::max();
for (size_t i = 0; i < boxes.size(); ++i)
for (size_t j = i + 1; j < boxes.size(); ++j) {
coord_t sx = std::max<coord_t>(0, std::max(boxes[j].min.x() - boxes[i].max.x(),
boxes[i].min.x() - boxes[j].max.x()));
coord_t sy = std::max<coord_t>(0, std::max(boxes[j].min.y() - boxes[i].max.y(),
boxes[i].min.y() - boxes[j].max.y()));
min_gap = std::min(min_gap, std::sqrt(double(sx) * sx + double(sy) * sy));
}
REQUIRE(min_gap >= double(scaled(8.)) - double(scaled(0.5)));
}
TEST_CASE("An item larger than the bed cannot be placed", "[Arrange]")
{
ArrangePolygons items;
items.emplace_back(make_square(scaled(20.)));
items.emplace_back(make_square(scaled(400.))); // far bigger than the bed
arrange(items, bed(200, 200), quiet_params(scaled(1.)));
REQUIRE(items[0].bed_idx == 0);
REQUIRE(items[1].bed_idx == UNARRANGED);
}
TEST_CASE("Items overflowing one bed spill onto virtual beds", "[Arrange]")
{
ArrangePolygons items = squares(8, 90.); // eight 90mm squares cannot share a 200x200 bed
arrange(items, bed(200, 200), quiet_params(scaled(2.)));
int max_bed = 0;
for (const ArrangePolygon &ap : items) {
REQUIRE(ap.bed_idx >= 0); // placed somewhere
max_bed = std::max(max_bed, ap.bed_idx);
}
REQUIRE(max_bed >= 1); // at least one on a virtual bed
}
TEST_CASE("Arrange handles an empty input", "[Arrange]")
{
ArrangePolygons items;
REQUIRE_NOTHROW(arrange(items, bed(200, 200), quiet_params()));
REQUIRE(items.empty());
}
TEST_CASE("Arrange without final alignment keeps items disjoint", "[Arrange]")
{
// do_final_align = false selects Alignment::DONT_ALIGN (skips recentering).
ArrangePolygons items = squares(6, 40.);
ArrangeParams params = quiet_params(scaled(2.));
params.do_final_align = false;
arrange(items, bed(250, 250), params);
for (const ArrangePolygon &ap : items)
REQUIRE(ap.bed_idx == 0);
require_no_overlap(items);
}
TEST_CASE("Arrange aligns the pile to a custom center", "[Arrange]")
{
// align_center != (0.5, 0.5) selects Alignment::USER_DEFINED.
ArrangePolygons items = squares(5, 30.);
ArrangeParams params = quiet_params(scaled(2.));
params.align_center = Vec2d(0.3, 0.7);
arrange(items, bed(250, 250), params);
for (const ArrangePolygon &ap : items)
REQUIRE(ap.bed_idx == 0);
require_no_overlap(items);
}
+48
View File
@@ -452,6 +452,54 @@ SCENARIO("update_non_diff_values_to_base_config preserves child vectors when chi
}
}
SCENARIO("update_diff_values_to_child_config tolerates legacy machine-limit vector sizes",
"[Config][Variant]") {
// Regression: loading a user printer preset that inherits a non-BBL multi-extruder base and
// overrides stride-2 machine limits used to throw in ConfigOptionVector::set_only_diff
// ("invalid diff_index size"). The base's machine-limit vectors get length-extended by the
// nozzle count while it carries no printer_extruder_variant, so the base length (nozzles*2)
// no longer matches variant_index.size()*2. The throw was caught upstream and DELETED the
// user's preset file. The merge must instead degrade gracefully.
GIVEN("A 4-nozzle parent with stride-2 limits extended to nozzles*2 but no printer_extruder_variant") {
Slic3r::DynamicPrintConfig parent;
Slic3r::DynamicPrintConfig child;
parent.set_key_value("nozzle_diameter",
new Slic3r::ConfigOptionFloats({0.4, 0.4, 0.4, 0.4}));
parent.set_key_value("machine_max_acceleration_x",
new Slic3r::ConfigOptionFloats({25000, 25000, 25000, 25000, 25000, 25000, 25000, 25000}));
// Child user preset declares 4 extruder variants and overrides the machine limit.
child.set_key_value("printer_extruder_id",
new Slic3r::ConfigOptionInts({1, 2, 3, 4}));
child.set_key_value("printer_extruder_variant",
new Slic3r::ConfigOptionStrings({"Direct Drive Standard", "Direct Drive Standard",
"Direct Drive Standard", "Direct Drive Standard"}));
child.set_key_value("machine_max_acceleration_x",
new Slic3r::ConfigOptionFloats({8000, 8000, 8000, 8000, 8000, 8000, 8000, 8000}));
WHEN("update_diff_values_to_child_config merges the child overrides") {
std::string id_name = "printer_extruder_id";
std::string var_name = "printer_extruder_variant";
THEN("it does not throw on the legacy size mismatch") {
REQUIRE_NOTHROW(parent.update_diff_values_to_child_config(
child, id_name, var_name,
Slic3r::printer_options_with_variant_1,
Slic3r::printer_options_with_variant_2));
AND_THEN("the child's overridden machine limit is preserved") {
auto* mx = parent.option<Slic3r::ConfigOptionFloats>("machine_max_acceleration_x");
REQUIRE(mx != nullptr);
REQUIRE(mx->values.size() >= 2);
REQUIRE_THAT(mx->values[0], Catch::Matchers::WithinAbs(8000.0, 1e-6));
REQUIRE_THAT(mx->values[1], Catch::Matchers::WithinAbs(8000.0, 1e-6));
}
}
}
}
}
// SCENARIO("DynamicPrintConfig JSON serialization", "[Config]") {
// WHEN("DynamicPrintConfig is serialized and deserialized") {
// auto now = std::chrono::high_resolution_clock::now();
+1 -1
View File
@@ -9,4 +9,4 @@ set_property(TARGET ${_TEST_NAME}_tests PROPERTY FOLDER "tests")
orcaslicer_copy_test_dlls()
catch_discover_tests(${_TEST_NAME}_tests)
orcaslicer_discover_tests(${_TEST_NAME}_tests)
+1 -1
View File
@@ -12,4 +12,4 @@ set_property(TARGET ${_TEST_NAME}_tests PROPERTY FOLDER "tests")
orcaslicer_copy_test_dlls()
catch_discover_tests(${_TEST_NAME}_tests)
orcaslicer_discover_tests(${_TEST_NAME}_tests)
+24
View File
@@ -4,6 +4,8 @@
#include <libslic3r/TriangleMesh.hpp>
#include <libslic3r/Format/OBJ.hpp>
#include <boost/filesystem.hpp>
#if defined(WIN32) || defined(_WIN32)
#define PATH_SEPARATOR R"(\)"
#else
@@ -20,4 +22,26 @@ inline Slic3r::TriangleMesh load_model(const std::string &obj_filename)
return mesh;
}
// RAII holder for a unique temporary file path, removed when the guard goes out
// of scope so a failing assertion never leaks it. Uses the system temp dir with
// a unique name (parallel-safe, cross-platform). The file itself is created by
// whoever writes to path()/string(); this only reserves the name and cleans up.
class ScopedTemporaryFile
{
public:
explicit ScopedTemporaryFile(const std::string &extension = ".tmp")
: m_path(boost::filesystem::temp_directory_path()
/ boost::filesystem::unique_path("orca-%%%%-%%%%-%%%%" + extension))
{}
~ScopedTemporaryFile() { boost::system::error_code ec; boost::filesystem::remove(m_path, ec); }
ScopedTemporaryFile(const ScopedTemporaryFile &) = delete;
ScopedTemporaryFile &operator=(const ScopedTemporaryFile &) = delete;
const boost::filesystem::path &path() const { return m_path; }
std::string string() const { return m_path.string(); }
private:
boost::filesystem::path m_path;
};
#endif // SLIC3R_TEST_UTILS