Merge branch 'main' into feature/texture_displacement

This commit is contained in:
ExPikaPaka
2026-08-19 09:13:53 +02:00
940 changed files with 96799 additions and 37162 deletions
+57
View File
@@ -0,0 +1,57 @@
# Test suite rules
Rules for writing tests under `tests/`. [CATCH2.md](CATCH2.md) is the Catch2 reference. Building and running the suites is covered on the wiki, at <https://www.orcaslicer.com/wiki/developer_reference/how_to_test.html>.
## The suites
- `libslic3r`: the core library. Geometry, meshes, file formats, config and presets, Clipper, algorithms, data structures.
- `fff_print`: the FFF slicing pipeline, from a `Model` plus config through `Print` and `PrintObject` to emitted G-code.
- `sla_print`: SLA support-tree and pad geometry, support-point generation, raycast.
- `libnest2d`: 2D nesting and packing.
- `slic3rutils`: the Python plugin system and its slicing-pipeline bindings.
- `filament_group`: filament-to-extruder grouping, checked against golden files.
## Building and running
Tests are off by default, so the build has to be told to include them.
- Windows: `build_release_vs.bat tests`, then `ctest --test-dir build/tests -C Release`
- macOS: `./build_release_macos.sh -s -a arm64 -T`, which builds and runs them
- Linux: `./build_linux.sh -t`, then `ctest --test-dir build/tests`
Rebuild a single suite with `cmake --build build --config Release --target <suite>_tests`. Visual Studio and Xcode are multi-configuration generators, so `ctest` needs `-C` there; on Linux it does not.
## Where a test goes
- Pick the suite by the production code the test exercises, not by how the test is written.
- A property of a class that holds with no `Print` involved belongs in `libslic3r`. Behavior that depends on print settings, or produces or consumes G-code or slicing state, belongs in `fff_print`.
- One file per subsystem, named `test_<subsystem>.cpp`. It owns every test for that subsystem, whether the test reads in-memory state or generated output.
- When you add a file, list it in that suite's `CMakeLists.txt` in the same change.
## Use the existing helpers
Check these before writing your own setup or output-parsing code.
- `tests/test_utils.hpp` is shared by every suite. `load_model()` loads a mesh from `tests/data/`, and `ScopedTemporaryFile` gives a temp path that removes itself.
- `fff_print/test_helpers.hpp` builds and slices a `Print` and parses the emitted G-code. Read it before writing an fff_print test rather than assembling a `Print` by hand.
- The other suites have their own: `sla_print/sla_test_utils.hpp`, `libnest2d/libnest2d_test_utils.hpp`, `slic3rutils/plugin_test_utils.hpp`, `filament_group/fg_test_utils.hpp`. `libslic3r` has none and uses the shared header.
- Test data lives in `tests/data/` and is reached through the `TEST_DATA_DIR` define. Wrap it in `std::string(...)` before joining a path onto it.
## Writing the test
- Name the test case as a plain behavioral sentence in the present tense. No `Subsystem:` prefix.
- Tag it with the subsystem it covers, matching the file, in PascalCase. That tag is what people filter on, so every test needs one.
- Add further tags where they help: a narrower one to slice a large file (`[Rotcalip]`, `[Placer]`), a shared one for something spanning files (`[Python]`, `[H2C]`, `[Regression]`), or `[NotWorking]` / `[.]` to disable or hide a test. Say why in a comment if you disable or hide.
- Prefer a flat `TEST_CASE` per behavior, with `GENERATE` for parameterized cases. Reserve `SCENARIO` / `GIVEN` / `WHEN` / `THEN` for genuine shared setup that branches into a few close variations.
- Set the config keys your test depends on, and derive the expected values from what you set. A 20mm cube sliced at `layer_height` 2 is 10 layers, and the test should state both parts. If a number in your assertion comes from a key you never set, the test is also testing that default.
- Assert the defining property, not an incidental value. "Skirt present" or "at least 2 brim loops" survives a refactor; exact coordinates and byte counts do not.
- Name a regression test for the behavior it protects, never for an issue or PR number.
- When asserting on G-code, match the meaningful token such as `; skirt` rather than whole lines, whitespace or comment wording. Depend on ordering only when ordering is the contract.
## Catch2 rules that cause real breakage
- Never reuse a `SECTION` name inside a loop. Use `DYNAMIC_SECTION` so each iteration is unique.
- Never assert from a spawned thread. Catch2 assertions are not thread-safe. Collect results in the thread and assert on the main thread.
- Never combine conditions with `&&` or `||` inside one assertion. Split them so Catch2 can print both operands on failure.
- Compare floats with `WithinAbs` or `WithinRel`, never `==`. Prefer these over `Approx` in new tests.
- Keep tests self-contained: no shared state, green under `--order rand`.
+369
View File
@@ -0,0 +1,369 @@
# Catch2 reference
How to write and structure test code with Catch2 in OrcaSlicer. For where a test belongs, how to name and tag it, and how to build and run the suites, see [AGENTS.md](AGENTS.md).
OrcaSlicer uses **Catch2 v3.11.0**, vendored in `tests/catch2/`. Include it with the single-header convenience include:
```cpp
#include <catch2/catch_all.hpp>
```
## Critical rules
These three mistakes produce undefined behavior, crashes, or useless failure output rather than a normal test failure. Avoid them everywhere.
### 1. Never reuse a section name inside a loop
A repeated `SECTION` name in a loop makes Catch2's section tracking behave unpredictably. Use `DYNAMIC_SECTION` so each iteration is unique.
```cpp
// WRONG: same name every iteration
for (int i = 0; i < 3; ++i)
SECTION("Same name") { REQUIRE(i >= 0); }
// CORRECT
for (int i = 0; i < 3; ++i)
DYNAMIC_SECTION("Section " << i) { REQUIRE(i >= 0); }
```
### 2. Assertions are not thread-safe
Catch2 assertions are not thread-safe by default. A `REQUIRE`/`CHECK` from a spawned thread corrupts internal state or terminates the process. Collect results in the thread, assert on the main thread.
```cpp
// WRONG
std::thread t([&]{ REQUIRE(work() == expected); });
// CORRECT
std::atomic<int> passed{0};
std::thread t([&]{ if (work() == expected) passed++; });
t.join();
REQUIRE(passed == 1);
```
> Catch2 v3.9.0+ has opt-in thread-safe assertions via `CATCH_CONFIG_EXPERIMENTAL_THREAD_SAFE_ASSERTIONS`. OrcaSlicer does not enable that flag, so assertions remain non-thread-safe. See [Thread safety](#thread-safety) below for the full rule list.
### 3. Do not combine conditions with binary operators
Catch2 decomposes a single comparison to show both operands on failure. A `&&`/`||` inside one assertion collapses to `false` with no values. Split it.
```cpp
REQUIRE(a > 0 && b < 10); // WRONG: prints "false"
REQUIRE(a > 0); // CORRECT: each prints its operands
REQUIRE(b < 10);
```
## Test structure
```cpp
#include <catch2/catch_all.hpp>
#include "libslic3r/Point.hpp"
using namespace Slic3r;
TEST_CASE("Behavioral description", "[SubsystemTag]") {
// ...
}
```
## Assertions
```cpp
// Stop the test on failure
REQUIRE(expression);
REQUIRE_FALSE(expression);
// Continue the test after failure (report all failures in the case)
CHECK(expression);
CHECK_FALSE(expression);
// Record the result without failing (for assumptions that may be violated)
CHECK_NOFAIL(expression);
```
### Exceptions
```cpp
REQUIRE_NOTHROW(function_call());
REQUIRE_THROWS(risky_function());
REQUIRE_THROWS_AS(function_call(), SpecificException);
REQUIRE_THROWS_WITH(function_call(), "Expected error message");
REQUIRE_THROWS_MATCHES(function_call(), SpecificException,
Catch::Matchers::Message("contains this"));
```
Prefer these over a hand-rolled `try`/`catch` with a bool flag.
## Matchers
```cpp
#include <catch2/matchers/catch_matchers.hpp>
// String matchers
using Catch::Matchers::StartsWith;
using Catch::Matchers::EndsWith;
using Catch::Matchers::ContainsSubstring; // v2's "Contains" no longer exists
using Catch::Matchers::Equals;
using Catch::Matchers::Matches; // regex
REQUIRE_THAT(result, StartsWith("Expected prefix"));
REQUIRE_THAT(result, ContainsSubstring("middle part"));
REQUIRE_THAT(result, Matches(".*pattern.*"));
// Float matchers - always prefer these over Approx
using Catch::Matchers::WithinAbs;
using Catch::Matchers::WithinRel;
using Catch::Matchers::WithinULP;
REQUIRE_THAT(v, WithinAbs(expected, 0.001));
REQUIRE_THAT(v, WithinRel(expected, 0.01));
REQUIRE_THAT(v, WithinULP(expected, 4));
// Combine: relative OR absolute (useful when the value can be near zero)
REQUIRE_THAT(v, WithinRel(expected, 0.001) || WithinAbs(0.0, 0.000001));
```
## Sections
Each `SECTION` re-runs the enclosing `TEST_CASE` body from the top, so setup declared before the sections is fresh for each one.
```cpp
TEST_CASE("Complex feature", "[Feature]") {
SomeObject obj; // rebuilt for every section
SECTION("First scenario") { REQUIRE(obj.method1() == expected_value); }
SECTION("Second scenario") { REQUIRE(obj.method2() == other_expected); }
}
```
## BDD-style tests
`SCENARIO` / `GIVEN` / `WHEN` / `THEN` are aliases for `TEST_CASE` and `SECTION` with prefixed names. New tests should prefer a flat `TEST_CASE`; reserve BDD for genuine shared setup that branches into closely related variations (see the test-design guidance in [AGENTS.md](AGENTS.md)).
```cpp
SCENARIO("User performs an operation", "[UserStory]") {
GIVEN("A setup condition") {
GCodeWriter writer;
WHEN("The user acts") {
auto result = writer.some_operation();
THEN("The outcome holds") {
REQUIRE(result.size() > 0);
}
}
}
}
```
## Generators
```cpp
// Value list
auto v = GENERATE(1, 3, 5, 7, 11, 13);
// Range
auto i = GENERATE(range(1, 10)); // 1..9
// From a variable (use GENERATE_REF / GENERATE_COPY for captured references)
std::vector<int> values = {1, 2, 3, 4, 5};
auto x = GENERATE_REF(from_range(values));
// Random
auto r = GENERATE(take(100, random(-1000, 1000)));
```
## Fixtures
```cpp
class GeometryFixture {
public:
Point origin{0, 0};
Point unit_x{1, 0};
};
TEST_CASE_METHOD(GeometryFixture, "Point operations", "[Geometry]") {
REQUIRE(origin.distance_to(unit_x) == 1.0);
}
```
Persistent (`TEST_CASE_PERSISTENT_FIXTURE`, one instance for the whole case) and type-parameterized (`TEMPLATE_TEST_CASE_METHOD`) variants also exist; neither is used in the suite today.
## Advanced features
### Logging and control
```cpp
INFO("Persists until end of scope");
UNSCOPED_INFO("Survives beyond its scope"); // v2.7.0+
CAPTURE(some_variable, another_var); // logs names and values
WARN("Warns without failing");
SKIP("Reason"); // marks the test skipped (v3.3.0+)
FAIL("Stops the test");
SUCCEED("Explicit success marker");
```
### Other macros
Available but currently unused in the suite; see the upstream docs for details.
- **Compile-time asserts**: `STATIC_REQUIRE` / `STATIC_CHECK` (v3.0.1+) check type traits at compile time.
- **Conditional blocks**: `CHECKED_IF` / `CHECKED_ELSE` record a branch condition without counting it as a failure.
- **Benchmarking** (v2.9.0+): `BENCHMARK("name") { return work(); };`, or `BENCHMARK_ADVANCED` when setup must be excluded from the measurement.
## Usage patterns in OrcaSlicer
Concrete shapes for exercising the codebase's own types. Test data is reached through the `TEST_DATA_DIR` define; always wrap it in `std::string(...)` before concatenating a path.
```cpp
// Geometry, with epsilon tolerance
TEST_CASE("Line operations", "[Geometry]") {
Line line{{100000, 0}, {0, 0}};
Line rotated(line);
rotated.rotate(0.9 * EPSILON, {0, 0});
REQUIRE(line.parallel_to(rotated));
}
// Config from an ini
TEST_CASE("Config loading", "[Config]") {
DynamicPrintConfig config;
REQUIRE_NOTHROW(config.load_from_ini(std::string(TEST_DATA_DIR) + "/test_config/sample.ini",
ForwardCompatibilitySubstitutionRule::Disable));
REQUIRE(config.has("layer_height"));
}
// File I/O
TEST_CASE("STL file parsing", "[FileFormat]") {
TriangleMesh mesh;
REQUIRE_NOTHROW(mesh.ReadSTLFile((std::string(TEST_DATA_DIR) + "/test_stl/20mmbox.stl").c_str()));
REQUIRE_FALSE(mesh.empty());
REQUIRE(mesh.volume() > 0);
}
// G-code emission, matched by token (see test_gcodewriter.cpp)
TEST_CASE("z_hop lifts the nozzle", "[GCodeWriter]") {
GCodeWriter writer;
writer.set_extruders({0});
writer.set_extruder(0);
writer.travel_to_z(10.0);
writer.config.z_hop.values = {1.0};
REQUIRE_THAT(writer.eager_lift(LiftType::NormalLift), Catch::Matchers::ContainsSubstring("Z11"));
}
```
### Custom string conversions
Give Catch2 a way to print a custom type on failure. The usual case is an `operator<<` overload:
```cpp
std::ostream& operator<<(std::ostream& os, const Point& p) {
return os << "Point(" << p.x << ", " << p.y << ")";
}
```
When you cannot add `operator<<`, specialize `Catch::StringMaker<T>`. Enums can be registered with `CATCH_REGISTER_ENUM` (at global scope) and exceptions translated with `CATCH_TRANSLATE_EXCEPTION`; see the upstream docs for those.
## Command line
[AGENTS.md](AGENTS.md) covers the everyday commands (CTest, per-suite runs, tag filtering as CTest labels). The flags below are Catch2's own, available when you run a suite executable directly.
```bash
# Filtering
suite_tests "[Geometry]" # by tag
suite_tests "*geometry*" # by name pattern
suite_tests "~[Performance]" # exclude a tag
suite_tests "[Geometry][Config],[Algorithm]" # (Geometry AND Config) OR Algorithm
# Discovery
suite_tests --list-tests
suite_tests --list-tags
suite_tests --list-reporters
# Debugging a failure
suite_tests --break # break into the debugger on failure
suite_tests --success # show passing assertions too
suite_tests --durations yes # per-test timing
suite_tests --abort # stop at the first failure
```
### Ordering and sharding
Run in random order so tests stay independent. For parallel shards, all shards must share one seed.
```bash
suite_tests --order rand --warn NoAssertions
suite_tests --order rand --shard-index 0 --shard-count 4 --rng-seed 0xBEEF
suite_tests --order rand --shard-index 1 --shard-count 4 --rng-seed 0xBEEF
# ...one invocation per shard index
```
### Reporters
```bash
suite_tests --reporter console # default, human-readable
suite_tests --reporter compact
suite_tests --reporter xml # Catch2 XML
suite_tests --reporter junit # JUnit XML (CI)
suite_tests --reporter tap
suite_tests --reporter console --reporter junit::out=results.xml # multiple at once
```
## Common pitfalls
### Floating-point comparison
Compare floats with the float matchers, never with `==`. New tests should prefer the `Within*` matchers over `Approx`. Many existing tests still use `Approx`, which works but is:
- **Asymmetric**: `Approx(10).epsilon(0.1) != 11.1` yet `Approx(11.1).epsilon(0.1) == 10`.
- **Double-only**: all math is done in `double`, which misbehaves for `float` inputs.
- **Relative by default**: `Approx(0) == X` holds only for `X == 0`.
Use `WithinAbs` near zero, `WithinRel` across magnitudes, `WithinULP` for the tightest check, or combine them. `Catch::StringMaker<double>::precision = 15;` widens printed precision.
### Exception testing
Use `REQUIRE_THROWS` / `REQUIRE_THROWS_AS` rather than a `try`/`catch` with a bool flag.
### Thread safety
Assertions are not thread-safe (see [Critical rule 2](#2-assertions-are-not-thread-safe)). The full list of macros that must stay on the main thread:
- **`REQUIRE` family**: throws in a spawned thread with no handler, terminating the process.
- **`CHECK` family**: can corrupt internal state.
- **`SKIP`, `FAIL`, `SUCCEED`**: unsafe even with v3's opt-in thread-safe assertions.
- **Message macros** (`INFO`, `CAPTURE`, `WARN`): unsafe.
- **`STATIC_REQUIRE` / `STATIC_CHECK`**: unsafe (rely on runtime registration).
### Path handling
Wrap `TEST_DATA_DIR` in `std::string(...)` before concatenating, or use `boost::filesystem`:
```cpp
std::string path = std::string(TEST_DATA_DIR) + "/model.obj";
```
### Memory
Prefer RAII and smart pointers so a failing assertion cleans up automatically.
## Compilation and performance flags
```cpp
#define CATCH_CONFIG_FAST_COMPILE // ~20% faster compile, disables some features
#define CATCH_CONFIG_DISABLE_STRINGIFICATION // works around the VS2017 raw-string bug
#define CATCH_CONFIG_WINDOWS_CRTDBG // memory-leak detection (whole build)
```
The test build already defines `CATCH_CONFIG_FAST_COMPILE` (via `test_common` in `tests/CMakeLists.txt`).
## Platform-specific workarounds
- **MinGW/Cygwin** slow linking: build with `-fuse-ld=lld`.
- **Visual Studio 2017** raw-string-literal bug: define `CATCH_CONFIG_DISABLE_STRINGIFICATION` (disables expression stringification).
- **Visual Studio 2022** spaceship operator: `REQUIRE((a <=> b) == 0)` may not compile; use clang-cl or avoid `<=>` in assertions.
## Catch2 v3 notes
Available on v3.11.0: `SKIP()` (v3.3.0+), opt-in thread-safe assertions (v3.9.0+, not enabled here), built-in `BENCHMARK`, multiple simultaneous reporters (v3.0.1+), `STATIC_CHECK` (v3.0.1+), built-in sharding (`--shard-*`).
Two behavior notes: the string matcher is `ContainsSubstring` (v2's `Contains` is gone), and a section is re-run when a later sibling section fails (unchanged from v2).
+1 -964
View File
@@ -1,964 +1 @@
# CLAUDE.md - Testing Guide for OrcaSlicer
This guide provides comprehensive instructions for Claude Code when writing, maintaining, and understanding tests in the OrcaSlicer codebase.
> **Adding or organizing `fff_print` tests?** See
> [fff_print/README.md](fff_print/README.md) for where a test belongs and how to
> name it. This guide covers Catch2 mechanics; that README is the suite's
> organizing contract.
## ⚠️ CRITICAL RULES - MUST FOLLOW
### 1. **SECTIONS IN LOOPS - NEVER REUSE NAMES**
❌ **WRONG**: Will cause unpredictable behavior
```cpp
TEST_CASE("Bad loop sections") {
for (int i = 0; i < 3; ++i) {
SECTION("Same name") { // WRONG! Same name used multiple times
REQUIRE(i >= 0);
}
}
}
```
✅ **CORRECT**: Use DYNAMIC_SECTION or incorporate counter
```cpp
TEST_CASE("Good loop sections") {
for (int i = 0; i < 3; ++i) {
DYNAMIC_SECTION("Section " << i) { // Unique name per iteration
REQUIRE(i >= 0);
}
}
}
```
### 2. **THREAD SAFETY - ASSERTIONS ARE NOT THREAD-SAFE**
❌ **WRONG**: Will cause undefined behavior or crashes
```cpp
TEST_CASE("Multi-threaded test") {
std::vector<std::thread> threads;
for (int i = 0; i < 4; ++i) {
threads.emplace_back([]() {
REQUIRE(some_calculation() == expected); // NOT THREAD-SAFE!
});
}
}
```
✅ **CORRECT**: Synchronize results, test on main thread
```cpp
TEST_CASE("Multi-threaded test") {
std::vector<std::thread> threads;
std::atomic<int> passed{0};
for (int i = 0; i < 4; ++i) {
threads.emplace_back([&passed]() {
if (some_calculation() == expected) {
passed++;
}
});
}
for (auto& t : threads) t.join();
REQUIRE(passed == 4); // Test results on main thread
}
```
### 3. **EXPRESSION DECOMPOSITION - AVOID BINARY OPERATORS**
❌ **WRONG**: Cannot decompose properly
```cpp
REQUIRE(a > 0 && b < 10); // Shows "false" on failure, not individual values
```
✅ **CORRECT**: Split into separate assertions
```cpp
REQUIRE(a > 0);
REQUIRE(b < 10); // Each shows individual values on failure
```
### 4. **FLOATING POINT - NEVER USE APPROX**
❌ **WRONG**: Approx is deprecated and asymmetric
```cpp
REQUIRE(calculated_value == Catch::Approx(expected)); // Deprecated!
```
✅ **CORRECT**: Use floating point matchers
```cpp
REQUIRE_THAT(calculated_value, WithinAbs(expected, 0.001));
REQUIRE_THAT(calculated_value, WithinRel(expected, 0.01)); // 1% tolerance
REQUIRE_THAT(calculated_value, WithinULP(expected, 4)); // 4 ULPs apart
```
### 5. **TEST ORDERING - ALWAYS USE RANDOM ORDER**
✅ **REQUIRED**: For CI/CD and development
```bash
# Essential flags for running tests
./tests --order rand --warn NoAssertions
# For test sharding (parallel execution), share random seed
./tests --order rand --shard-index 0 --shard-count 3 --rng-seed 0xBEEF
./tests --order rand --shard-index 1 --shard-count 3 --rng-seed 0xBEEF
./tests --order rand --shard-index 2 --shard-count 3 --rng-seed 0xBEEF
```
## Overview of OrcaSlicer's Testing Framework
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**: 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
```
tests/
├── CMakeLists.txt # Main test configuration
├── catch_main.hpp # Custom test reporter
├── libslic3r/ # Core library tests (21 test files)
├── fff_print/ # FFF printing tests (12 test files)
├── sla_print/ # SLA printing tests (4 test files)
├── libnest2d/ # 2D nesting tests
├── slic3rutils/ # Utility tests
├── data/ # Test data files and meshes
└── catch2/ # Catch2 framework files
```
### Build Integration
- Tests are built using CMake with `catch_discover_tests()` integration
- Each test module creates a separate executable (e.g., `libslic3r_tests`, `fff_print_tests`)
- Test data directory is available via `TEST_DATA_DIR` preprocessor definition
- Custom verbose console reporter provides detailed test output
## Test Suite Organization
### libslic3r Tests
Core slicing engine tests covering:
- **Geometry operations**: Points, polygons, lines, Voronoi diagrams
- **File formats**: STL, 3MF, AMF parsing and validation
- **Algorithms**: Clipper operations, mesh boolean operations, optimization
- **Configuration**: Print settings validation and parsing
- **Utilities**: String processing, time utilities, data structures
### fff_print Tests
Fused Filament Fabrication specific tests:
- **G-code generation**: Writer functionality, cooling, lift/unlift
- **Slicing algorithms**: Layer generation, infill patterns
- **Print mechanics**: Flow calculations, extrusion, support material
- **Model processing**: Print objects, skirt/brim generation
### sla_print Tests
Stereolithography specific tests:
- **SLA print processing**: Layer curing, support generation
- **Raycast operations**: Light path calculations
- **Test utilities**: SLA-specific helper functions
## Writing New Tests - Best Practices
### File Organization
1. **Naming Convention**: `test_<feature>.cpp` (e.g., `test_geometry.cpp`)
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_all.hpp>
#include "libslic3r/Point.hpp"
using namespace Slic3r;
TEST_CASE("Feature description", "[category_tag]") {
// Test implementation
}
```
### Tagging System
Use descriptive tags for test categorization:
- `[Geometry]` - Geometric operations and calculations
- `[GCodeWriter]` - G-code generation functionality
- `[Config]` - Configuration and settings tests
- `[FileFormat]` - File I/O operations (STL, 3MF, etc.)
- `[Algorithm]` - Core algorithms and processing
- `[Performance]` - Performance benchmarks (if applicable)
## Catch2 Features Guide
### Basic Assertions
```cpp
// Primary assertions - stop test on failure
REQUIRE(expression);
REQUIRE_FALSE(expression);
// Continuing assertions - continue test after failure
CHECK(expression);
CHECK_FALSE(expression);
// Non-failing checks - record result but don't fail test
CHECK_NOFAIL(expression); // Useful for assumptions that might be violated
```
### Exception Testing
```cpp
// Verify no exception is thrown
REQUIRE_NOTHROW(function_call());
// Verify any exception is thrown
REQUIRE_THROWS(risky_function());
// Verify specific exception type
REQUIRE_THROWS_AS(function_call(), SpecificException);
// Verify exception message
REQUIRE_THROWS_WITH(function_call(), "Expected error message");
// Verify exception with matchers (for partial matching)
REQUIRE_THROWS_MATCHES(function_call(), SpecificException,
Catch::Matchers::Message("contains this"));
```
### Complex Assertions with Matchers
```cpp
#include <catch2/matchers/catch_matchers.hpp>
// String matchers
using Catch::Matchers::StartsWith;
using Catch::Matchers::EndsWith;
using Catch::Matchers::ContainsSubstring;
using Catch::Matchers::Equals;
using Catch::Matchers::Matches; // Regex matching
REQUIRE_THAT(result_string, StartsWith("Expected prefix"));
REQUIRE_THAT(result_string, ContainsSubstring("middle part"));
REQUIRE_THAT(result_string, Matches(".*pattern.*"));
// Floating point matchers - ALWAYS use these instead of Approx!
using Catch::Matchers::WithinAbs;
using Catch::Matchers::WithinRel;
using Catch::Matchers::WithinULP;
REQUIRE_THAT(float_value, WithinAbs(expected, 0.001)); // Absolute tolerance
REQUIRE_THAT(float_value, WithinRel(expected, 0.01)); // Relative tolerance (1%)
REQUIRE_THAT(float_value, WithinULP(expected, 4)); // ULP difference (requires IEEE-754)
// Combining matchers
REQUIRE_THAT(value, WithinRel(expected, 0.001) || WithinAbs(0.0, 0.000001));
```
### Sections for Test Organization
```cpp
TEST_CASE("Complex feature testing", "[Feature]") {
// Common setup code
SomeObject obj;
SECTION("First scenario") {
// Specific test case
REQUIRE(obj.method1() == expected_value);
}
SECTION("Second scenario") {
// Another test case with same setup
REQUIRE(obj.method2() == other_expected);
}
}
```
### BDD-Style Tests
Use for complex scenarios and user story testing:
> **Note**: BDD macros are aliases for TEST_CASE and SECTION with prefixed names
```cpp
SCENARIO("User performs complex operation", "[UserStory]") {
GIVEN("A specific setup condition") {
GCodeWriter writer;
// Setup code
WHEN("User performs action") {
auto result = writer.some_operation();
THEN("Expected outcome occurs") {
REQUIRE(result.size() > 0);
AND_WHEN("Follow-up action occurs") {
auto next_result = writer.next_operation();
THEN("Final outcome is correct") {
REQUIRE(next_result == expected);
}
}
}
}
}
}
```
### Data Generators for Parameterized Tests
```cpp
TEST_CASE("Function works with various inputs", "[Algorithm]") {
auto test_value = GENERATE(1, 3, 5, 7, 11, 13);
REQUIRE(is_odd(test_value));
REQUIRE(test_value > 0);
}
// Range-based generators
TEST_CASE("Range testing", "[Algorithm]") {
auto i = GENERATE(range(1, 10)); // 1 to 9
REQUIRE(process_value(i) > i);
}
// Using variables in generators (requires GENERATE_COPY or GENERATE_REF)
TEST_CASE("Generator with variables", "[Algorithm]") {
std::vector<int> values = {1, 2, 3, 4, 5};
auto test_value = GENERATE_REF(from_range(values)); // Use GENERATE_REF for references
REQUIRE(test_value > 0);
}
// Custom generators
TEST_CASE("Random values", "[Algorithm]") {
auto random_int = GENERATE(take(100, random(-1000, 1000))); // 100 random values
REQUIRE(process_random_value(random_int));
}
```
### Test Fixtures
```cpp
class GeometryFixture {
public:
Point origin{0, 0};
Point unit_x{1, 0};
Point unit_y{0, 1};
mutable double tolerance = EPSILON; // Use mutable for data that might change
};
// Standard fixture - new instance per test run
TEST_CASE_METHOD(GeometryFixture, "Point operations", "[Geometry]") {
REQUIRE(origin.distance_to(unit_x) == 1.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;
INFO("This fixture persists across sections, call: " << call_count);
SECTION("First section") {
REQUIRE(origin.distance_to(unit_x) == 1.0);
}
SECTION("Second section") {
REQUIRE(origin.distance_to(unit_y) == 1.0);
// call_count will be 2 here with persistent fixture
}
}
// Template fixtures for type-parameterized tests
template<typename T>
class NumericFixture {
public:
T zero = T{0};
T one = T{1};
};
TEMPLATE_TEST_CASE_METHOD(NumericFixture, "Numeric operations", "[Template]", int, float, double) {
REQUIRE(TestType{} == this->zero);
REQUIRE(TestType{1} == this->one);
}
```
### Advanced Testing Features
#### Logging and Information Macros
```cpp
TEST_CASE("Advanced logging", "[Logging]") {
INFO("This info persists until end of scope");
SECTION("Section A") {
INFO("Section A specific info");
CAPTURE(some_variable, another_var); // Captures variable names and values
CHECK(some_condition);
}
SECTION("Section B") {
UNSCOPED_INFO("This survives beyond its scope"); // v2.7.0+
CHECK(other_condition);
}
}
// Warning and explicit control
TEST_CASE("Explicit test control", "[Control]") {
WARN("This warns but doesn't fail the test");
if (precondition_not_met) {
SKIP("Reason"); // Marks the test as skipped (v3.3.0+, available)
return;
}
if (critical_failure) {
FAIL("Critical condition failed"); // Fails and stops test
}
SUCCEED("Reached successful completion"); // Explicit success marker
}
```
#### Static Assertions (Compile-time Testing)
```cpp
TEST_CASE("Compile-time checks", "[Static]") {
STATIC_REQUIRE(sizeof(int) >= 4); // Checked at compile time
STATIC_REQUIRE_FALSE(std::is_void_v<int>);
// For traits and template metaprogramming
STATIC_CHECK(std::is_trivially_copyable_v<Point>); // v3.0.1+
}
```
#### Conditional Testing
```cpp
TEST_CASE("Conditional blocks", "[Conditional]") {
int value = get_test_value();
// These record the expression but don't count as test failures (v3.0.1+)
CHECKED_IF(value > 0) {
// This block runs if value > 0
REQUIRE(value <= 100);
} CHECKED_ELSE(value > 0) {
// This block runs if value <= 0
REQUIRE(value >= -100);
}
}
```
#### Benchmarking (v2.9.0+)
```cpp
TEST_CASE("Performance testing", "[Benchmark]") {
// Simple benchmarking
BENCHMARK("Algorithm performance") {
return expensive_algorithm();
};
// Advanced benchmarking with setup
BENCHMARK_ADVANCED("Advanced benchmark")(Catch::Benchmark::Chronometer meter) {
std::vector<int> data = setup_test_data(); // Setup not measured
meter.measure([&] {
return process_data(data); // Only this is measured
});
};
}
```
## OrcaSlicer-Specific Testing Patterns
### Geometry Testing
```cpp
TEST_CASE("Line operations", "[Geometry]") {
Line line{{100000, 0}, {0, 0}};
Line parallel{{200000, 0}, {0, 0}};
REQUIRE(line.parallel_to(line));
REQUIRE(line.parallel_to(parallel));
// Test with epsilon tolerance
Line rotated(parallel);
rotated.rotate(0.9 * EPSILON, {0, 0});
REQUIRE(line.parallel_to(rotated));
}
```
### Configuration Testing
```cpp
TEST_CASE("Config loading", "[Config]") {
DynamicPrintConfig config;
std::string config_path = std::string(TEST_DATA_DIR) + "/test_config/sample.ini";
REQUIRE_NOTHROW(config.load_from_ini(config_path));
REQUIRE(config.has("layer_height"));
}
```
### File I/O Testing
```cpp
TEST_CASE("STL file parsing", "[FileFormat]") {
std::string stl_path = std::string(TEST_DATA_DIR) + "/test_stl/20mmbox.stl";
TriangleMesh mesh;
REQUIRE_NOTHROW(mesh.ReadSTLFile(stl_path.c_str()));
REQUIRE(!mesh.empty());
REQUIRE(mesh.volume() > 0);
}
```
### G-code Generation Testing
```cpp
TEST_CASE("G-code writer functionality", "[GCodeWriter]") {
GCodeWriter writer;
// Load test configuration
std::string config_path = std::string(TEST_DATA_DIR) + "/fff_print_tests/test_config.ini";
writer.config.load(config_path, ForwardCompatibilitySubstitutionRule::Disable);
// Test specific G-code generation
std::string result = writer.lift();
REQUIRE(!result.empty());
REQUIRE_THAT(result, Catch::Matchers::ContainsSubstring("G1"));
}
```
### Performance Testing Patterns
```cpp
TEST_CASE("Algorithm performance", "[Performance][Algorithm]") {
// Large test data
std::vector<Point> points = generate_large_point_set(10000);
// 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();
auto duration = std::chrono::duration_cast<std::chrono::milliseconds>(end - start);
REQUIRE(result.size() > 0);
REQUIRE(duration.count() < 1000); // Should complete in < 1 second
}
```
### Custom String Conversions
#### For Custom Types
```cpp
// Method 1: operator<< overload (preferred)
std::ostream& operator<<(std::ostream& os, const Point& point) {
os << "Point(" << point.x << ", " << point.y << ")";
return os;
}
// Method 2: StringMaker specialization
namespace Catch {
template<>
struct StringMaker<MyCustomType> {
static std::string convert(const MyCustomType& value) {
return "MyCustomType{" + std::to_string(value.data) + "}";
}
};
}
// Method 3: Enum registration (v2.8.0+)
enum class Status { Ready, Processing, Complete, Error };
// Must be at global scope!
CATCH_REGISTER_ENUM(Status, Status::Ready, Status::Processing, Status::Complete, Status::Error);
// Method 4: Exception translation
CATCH_TRANSLATE_EXCEPTION(MyCustomException const& ex) {
return "MyCustomException: " + std::string(ex.what());
}
// Method 5: Disable range iteration for problematic types
namespace Catch {
template<>
struct is_range<ProblematicType> {
static const bool value = false;
};
}
```
## Running and Debugging Tests
### Building Tests
```bash
# Build all tests
cd build && make
# Build specific test suite
cd build && make libslic3r_tests
# Build and run tests
cd build && make && ctest
```
### Running Tests
#### Essential Test Execution Patterns
```bash
# REQUIRED: Random order with assertion warnings (best practice)
cd build && ./tests/libslic3r/libslic3r_tests --order rand --warn NoAssertions
# Run all tests with verbose output via CTest
cd build && ctest --output-on-failure
# Run specific test suite with best practices
cd build && ./tests/libslic3r/libslic3r_tests --order rand --warn NoAssertions
# Filter tests with specific tags
cd build && ./tests/libslic3r/libslic3r_tests "[Geometry]" --order rand
# Filter by test name patterns
cd build && ./tests/libslic3r/libslic3r_tests "*geometry*" --order rand
# Exclude tests (negation)
cd build && ./tests/libslic3r/libslic3r_tests "~[Performance]" --order rand
# Combine filters: (Geometry AND Config) OR Algorithm
cd build && ./tests/libslic3r/libslic3r_tests "[Geometry][Config],[Algorithm]" --order rand
# List available tests, tags, and reporters
cd build && ./tests/libslic3r/libslic3r_tests --list-tests
cd build && ./tests/libslic3r/libslic3r_tests --list-tags
cd build && ./tests/libslic3r/libslic3r_tests --list-reporters
# Debug failing tests
cd build && ./tests/libslic3r/libslic3r_tests --break # Break into debugger on failure
cd build && ./tests/libslic3r/libslic3r_tests --success # Show passing tests too
cd build && ./tests/libslic3r/libslic3r_tests --durations yes # Show timing info
# Abort on first failure
cd build && ./tests/libslic3r/libslic3r_tests --abort
# Test sharding for parallel execution (MUST share random seed)
cd build && ./tests/libslic3r/libslic3r_tests --order rand --shard-index 0 --shard-count 4 --rng-seed 0xBEEF &
cd build && ./tests/libslic3r/libslic3r_tests --order rand --shard-index 1 --shard-count 4 --rng-seed 0xBEEF &
cd build && ./tests/libslic3r/libslic3r_tests --order rand --shard-index 2 --shard-count 4 --rng-seed 0xBEEF &
cd build && ./tests/libslic3r/libslic3r_tests --order rand --shard-index 3 --shard-count 4 --rng-seed 0xBEEF &
wait # Wait for all to complete
```
#### Reporter Options for CI Integration
```bash
# Different output formats for CI systems
cd build && ./tests/libslic3r/libslic3r_tests --reporter console # Default human-readable
cd build && ./tests/libslic3r/libslic3r_tests --reporter compact # Minimal output
cd build && ./tests/libslic3r/libslic3r_tests --reporter xml # Catch2 XML format
cd build && ./tests/libslic3r/libslic3r_tests --reporter junit # JUnit XML (widely supported)
cd build && ./tests/libslic3r/libslic3r_tests --reporter tap # Test Anything Protocol
cd build && ./tests/libslic3r/libslic3r_tests --reporter teamcity # TeamCity integration
cd build && ./tests/libslic3r/libslic3r_tests --reporter sonarqube # SonarQube integration
cd build && ./tests/libslic3r/libslic3r_tests --reporter automake # Automake integration
# Multiple reporters simultaneously (if supported)
cd build && ./tests/libslic3r/libslic3r_tests --reporter console --reporter junit::out=results.xml
```
### Test Output Control
The custom `VerboseConsoleReporter` provides enhanced output:
- Test case start/end notifications with timing
- Section execution tracking
- Color-coded success/failure indicators
- Duration reporting for performance analysis
## Test Data Management
### Using TEST_DATA_DIR
All test data is accessible via the `TEST_DATA_DIR` preprocessor definition:
```cpp
std::string mesh_path = std::string(TEST_DATA_DIR) + "/20mm_cube.obj";
std::string config_path = std::string(TEST_DATA_DIR) + "/test_config/printer.ini";
```
### Available Test Assets
#### 3D Models
- **Basic shapes**: `20mm_cube.obj`, `pyramid.obj`, `sphere.obj`
- **Complex geometry**: `extruder_idler.obj`, `ipadstand.obj`, `bridge.obj`
- **Edge cases**: `cube_with_hole.obj`, `sloping_hole.obj`, `small_dorito.obj`
#### File Format Tests
- **STL variants**: ASCII/binary, different line endings, Unicode names
- **3MF files**: Multi-material, complex assemblies
- **Configuration files**: Various printer/material profiles
#### Test Utilities
The `Test` namespace provides helper functions:
```cpp
using namespace Slic3r::Test;
// Load standard test meshes
TriangleMesh mesh = mesh(TestMesh::cube_20x20x20);
// Standard test configurations
DynamicPrintConfig config = config(TestConfig::PLA_default);
```
## Common Pitfalls and Solutions
### Floating-Point Comparisons
> **CRITICAL**: Never use Approx - it's deprecated due to asymmetry and other issues
❌ **Incorrect**:
```cpp
REQUIRE(calculated_volume == expected_volume); // Exact equality
REQUIRE(calculated_volume == Catch::Approx(expected)); // Deprecated! Asymmetric!
```
✅ **Correct**: Always use floating point matchers
```cpp
// Absolute tolerance - good when values are near zero
REQUIRE_THAT(calculated_volume, WithinAbs(expected_volume, 0.001));
// Relative tolerance - good for values with different magnitudes
REQUIRE_THAT(calculated_volume, WithinRel(expected_volume, 0.01)); // 1% tolerance
// ULP (Units in Last Place) - most precise, requires IEEE-754
REQUIRE_THAT(calculated_volume, WithinULP(expected_volume, 4));
// Combined approach - relative OR absolute
REQUIRE_THAT(calculated_volume,
WithinRel(expected_volume, 0.001) || WithinAbs(0.0, 0.000001));
// Precision control for output
Catch::StringMaker<double>::precision = 15; // Show more decimal places
```
### Why Approx is Problematic:
- **Asymmetric**: `Approx(10).epsilon(0.1) != 11.1` but `Approx(11.1).epsilon(0.1) == 10`
- **Double-only**: All computation done in `double`, causes issues with `float` inputs
- **Default behavior**: Only uses relative comparison, so `Approx(0) == X` only works for `X == 0`
### Path Handling
❌ **Incorrect**:
```cpp
std::string path = TEST_DATA_DIR + "/model.obj"; // May have path separator issues
```
✅ **Correct**:
```cpp
std::string path = std::string(TEST_DATA_DIR) + "/model.obj";
// or use boost::filesystem for complex path operations
```
### Exception Testing
❌ **Incorrect**:
```cpp
bool threw_exception = false;
try {
risky_function();
} catch (...) {
threw_exception = true;
}
REQUIRE(threw_exception);
```
✅ **Correct**:
```cpp
REQUIRE_THROWS(risky_function());
// or for specific exceptions
REQUIRE_THROWS_AS(risky_function(), SpecificException);
```
### Thread Safety
⚠️ **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`. 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
std::thread t([&]() {
REQUIRE(threaded_operation() == expected); // NOT THREAD-SAFE!
CHECK(other_operation()); // NOT THREAD-SAFE!
});
```
✅ **Correct**: Collect results, assert on main thread
```cpp
std::atomic<bool> success{false};
std::atomic<int> error_count{0};
std::thread t([&]() {
// Do work in thread, collect results
bool result1 = (threaded_operation() == expected);
bool result2 = other_operation();
if (result1 && result2) {
success = true;
} else {
error_count++;
}
});
t.join();
// Assert results on main thread
REQUIRE(success);
REQUIRE(error_count == 0);
```
#### Thread Safety Rules:
- **REQUIRE family**: Would terminate process in spawned threads (throws exception with no try-catch)
- **CHECK family**: Not thread-safe, can corrupt internal state
- **SKIP, FAIL, SUCCEED**: Not thread-safe even with v3 thread-safe assertions
- **Message macros**: INFO, CAPTURE, WARN - not thread-safe
- **STATIC_REQUIRE/CHECK**: Not thread-safe (relies on runtime registration)
### Memory Management
Use RAII and smart pointers in tests:
```cpp
TEST_CASE("Resource management", "[Memory]") {
auto model = std::make_unique<Model>();
// Automatic cleanup on test completion/failure
REQUIRE(model->objects.empty());
}
```
## Performance Considerations
### Compilation Optimizations
```cpp
// In CMakeLists.txt or as preprocessor definition
#define CATCH_CONFIG_FAST_COMPILE // 20% faster compilation, disables some features
// For faster test iteration during development
#define CATCH_CONFIG_DISABLE_STRINGIFICATION // Workaround for VS2017 raw string bug
```
### Runtime Performance
```cpp
TEST_CASE("Performance-sensitive test", "[Performance]") {
// Manual timing example (Catch2's built-in BENCHMARK macro is also available)
auto start = std::chrono::high_resolution_clock::now();
auto result = expensive_operation();
auto end = std::chrono::high_resolution_clock::now();
auto duration = std::chrono::duration_cast<std::chrono::milliseconds>(end - start);
REQUIRE(result.is_valid());
REQUIRE(duration.count() < 1000); // Should complete in < 1 second
INFO("Operation took " << duration.count() << "ms");
}
```
### Memory Leak Detection
```cpp
// For Windows builds - detects memory leaks
#define CATCH_CONFIG_WINDOWS_CRTDBG // Must be defined for whole build
```
## Integration with CMake
### Adding New Test Files
1. Create test file: `test_new_feature.cpp`
2. Add to appropriate `CMakeLists.txt`:
```cmake
add_executable(${_TEST_NAME}_tests
${_TEST_NAME}_tests.cpp
test_existing_feature.cpp
test_new_feature.cpp # Add here
)
```
### Advanced Test Discovery
```cmake
# Basic test discovery
catch_discover_tests(${_TEST_NAME}_tests TEST_PREFIX "${_TEST_NAME}: ")
# Advanced test discovery with customization
catch_discover_tests(${_TEST_NAME}_tests
TEST_PREFIX "${_TEST_NAME}: "
TEST_SUFFIX " (auto)"
WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}
EXTRA_ARGS --order rand --warn NoAssertions
PROPERTIES
TIMEOUT 300
LABELS "unit;core"
DISCOVERY_MODE PRE_TEST # or POST_BUILD
REPORTER junit
OUTPUT_DIR ${CMAKE_BINARY_DIR}/test-results
OUTPUT_PREFIX "results_"
OUTPUT_SUFFIX ".xml"
)
# Test sharding for parallel execution
include(CatchShardTests) # If available
catch_shard_tests(${_TEST_NAME}_tests
SHARD_COUNT 4
TEST_PREFIX "${_TEST_NAME}_shard: "
)
```
### Conditional Test Compilation
```cmake
# Feature-dependent tests
if (TARGET OpenVDB::openvdb)
target_sources(${_TEST_NAME}_tests PRIVATE test_hollowing.cpp)
endif()
# Platform-specific tests
if(WIN32)
target_sources(${_TEST_NAME}_tests PRIVATE test_windows_specific.cpp)
elseif(UNIX)
target_sources(${_TEST_NAME}_tests PRIVATE test_unix_specific.cpp)
endif()
# Compiler-specific workarounds
if(MSVC)
target_compile_definitions(${_TEST_NAME}_tests PRIVATE CATCH_CONFIG_DISABLE_STRINGIFICATION)
endif()
# Fast compile mode for development
if(CMAKE_BUILD_TYPE STREQUAL "Debug")
target_compile_definitions(${_TEST_NAME}_tests PRIVATE CATCH_CONFIG_FAST_COMPILE)
endif()
```
## Known Issues and Workarounds
### Platform-Specific Issues
```cpp
// MinGW/CygWin slow linking workaround
// Use: -fuse-ld=lld flag to speed up linking significantly
// Visual Studio 2017 raw string literal bug
#define CATCH_CONFIG_DISABLE_STRINGIFICATION
// This disables expression stringification but works around the compiler bug
// Visual Studio 2022 spaceship operator issue
// REQUIRE((a <=> b) == 0); // May not compile with MSVC
// Workaround: use clang-cl or avoid spaceship in assertions
// QNX/VxWorks C stdlib issues
#include <cfoo> // Use C++ headers
std::foo_function(); // Always call qualified
// NOT: #include <foo.h> and foo_function();
```
### Catch2 v3 Features Available
```cpp
// 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
// 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
#### Project Structure Rules
1. **1:1 correspondence**: One test binary per library/module
2. **Hidden tests**: Use `[.]` or `[!benchmark]` tags for tests that shouldn't run by default
3. **Tag hierarchy**: Use consistent tagging scheme across the project
4. **File naming**: Follow `test_<feature>.cpp` pattern
#### CI/CD Integration
```bash
# Essential CI test command
./tests --order rand --warn NoAssertions --reporter junit::out=results.xml
# For coverage analysis
./tests --order rand --warn NoAssertions --reporter console --success
# For performance tracking
./tests --order rand --warn NoAssertions --durations yes
```
This comprehensive guide ensures robust, maintainable, and efficient testing practices for OrcaSlicer development with Claude Code, incorporating all critical knowledge from the official Catch2 documentation.
@AGENTS.md
+8
View File
@@ -0,0 +1,8 @@
# OrcaSlicer tests
Building, running and writing tests is documented on the wiki, under [How to Test](https://www.orcaslicer.com/wiki/developer_reference/how_to_test.html).
Two files here rather than there, because coding agents only read what is in the repository:
- [AGENTS.md](AGENTS.md) is the same guidance in short form, and is what an agent working under `tests/` picks up.
- [CATCH2.md](CATCH2.md) is the Catch2 reference, including the mistakes that break a test at runtime.
+88
View File
@@ -0,0 +1,88 @@
# Compare Analyzer — G-code Slicing Comparison Tools
Tools for deep comparison and analysis of `.3mf` slicing project files, designed for
verifying multi-nozzle (H2C carousel) and multi-extruder slicing correctness.
## Tools
### `compare_slices.py` — Slice Comparison Analyzer
Deep comparison of two `.3mf` files (OrcaSlicer, BambuStudio, or any compatible slicer).
Generates a comprehensive Markdown report covering:
- **Filament usage** — per-filament weight/length with color mapping
- **Nozzle/extruder mapping** — Vortek carousel slot assignments
- **Tool change sequences** — T-code ordering and count
- **Prime tower analysis** — tower entries, G-code line count
- **Temperature timeline** — pre-heat lead times, target temperatures per tool change
- **Retract parameters** — M620.11 analysis during nozzle switches
- **Filament change G-code blocks** — line-by-line diff of change_filament_gcode
- **Control command diff** — timeline of M/G-code differences
- **Critical discrepancy detection** — automatic flagging of weight/time anomalies
#### Usage
```bash
# Compare two slice files
python3 compare_slices.py file1.3mf file2.3mf
# With custom labels
python3 compare_slices.py file1.3mf file2.3mf --labels "Upstream" "Fixed"
```
#### Output
Markdown report saved to `mp_reports/compare_report_YYYYMMDD_HHMMSS.md`
#### Example: Detecting H2C purge regression
```
⚠️ CRITICAL DISCREPANCY: Huge difference in part weight:
OrcaSlicer 60.90 g vs BambuStudio 17.47 g (difference 43.43 g or 71.3%).
The reason is incorrect nozzle mapping, causing huge AMS flushing.
```
---
### `show_temp_plot.py` — Temperature Timeline Plotter
Generates interactive HTML temperature plots for analyzing thermal profiles during
multi-nozzle prints. Visualizes heater temperature commands (M104/M109) per tool change,
showing pre-heat timing and temperature convergence.
#### Architecture
- H2C dual-extruder layout with Vortek carousel nozzles
- Physical heaters mapped dynamically:
- Heater 0: Extruder 2 (right nozzle slot, T0/T2/T3/T4)
- Heater 1: Extruder 1 (left nozzle slot, T1)
- Active heater mapping derived from G-code temperature signals
#### Usage
```bash
# Single file analysis
python3 show_temp_plot.py file.3mf
# Side-by-side comparison of two files
python3 show_temp_plot.py file1.3mf file2.3mf
```
#### Output
Interactive HTML report saved to Desktop as `temp_plot_v3.html`
---
## Requirements
- **Python 3.8+**
- **No external dependencies** — uses only Python standard library
(`json`, `zipfile`, `xml.etree.ElementTree`, `difflib`, `webbrowser`)
## Use Cases
1. **Regression testing** — compare slices before/after code changes to verify
no unintended differences in purge volumes, tool ordering, or temperature timing
2. **BBS compatibility verification** — compare OrcaSlicer output against BambuStudio
reference slices to ensure behavioral parity
3. **H2C carousel validation** — verify per-slot nozzle tracking produces correct
purge volumes (not collapsed per-extruder)
4. **Temperature protocol analysis** — verify pre-heat lead times and cooling
temperatures during nozzle changes match expected profiles
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,172 @@
# Temperature and tool-change commands of a wait_for_temp_on_wipe_tower-off slice,
# captured from the main branch at a10d9e77cf. Regeneration is described
# at the test that reads this file: "Toolchange temperature commands are unchanged
# when the wipe tower wait is off" in tests/fff_print/test_multifilament.cpp.
#
# The "time:" and "lead" values are toolchain-specific -- GCC, Clang and MSVC each produce
# slightly different estimates from an identical toolpath -- so they are compared with a
# tolerance, not exactly. Do not regenerate this file to resolve a mismatch in them: no single
# capture satisfies all three, and recapturing just moves the failure to other platforms.
M104 S215 T0 ; set nozzle temperature
M104 S215 T1 ; set nozzle temperature
; CP PRIMING START
T1 ; change extruder
M109 S215 T1 ; set nozzle temperature and wait for it to be reached
M104 S175 T1 ; set nozzle temperature ;cooldown
T0 ; change extruder
M109 S215 T0 ; set nozzle temperature and wait for it to be reached
; CP PRIMING END
M104 S215 T1 ; preheat T1 time: 30s lead 30.2s
; CP TOOLCHANGE START
M104 S175 T0 ; set nozzle temperature ;cooldown
T1 ; change extruder
M109 S215 T1 ; set nozzle temperature and wait for it to be reached
; CP TOOLCHANGE END
M104 S240 T1 ; set nozzle temperature
M104 S240 T0 ; preheat T0 time: 30s lead 30.0s
; CP TOOLCHANGE START
M104 S200 T1 ; set nozzle temperature ;cooldown
T0 ; change extruder
M109 S240 T0 ; set nozzle temperature and wait for it to be reached
; CP TOOLCHANGE END
M104 S240 T1 ; preheat T1 time: 30s lead 30.4s
; CP TOOLCHANGE START
M104 S200 T0 ; set nozzle temperature ;cooldown
T1 ; change extruder
M109 S240 T1 ; set nozzle temperature and wait for it to be reached
; CP TOOLCHANGE END
M104 S240 T0 ; preheat T0 time: 31s lead 30.9s
; CP TOOLCHANGE START
M104 S200 T1 ; set nozzle temperature ;cooldown
T0 ; change extruder
M109 S240 T0 ; set nozzle temperature and wait for it to be reached
; CP TOOLCHANGE END
M104 S240 T1 ; preheat T1 time: 30s lead 30.2s
; CP TOOLCHANGE START
M104 S200 T0 ; set nozzle temperature ;cooldown
T1 ; change extruder
M109 S240 T1 ; set nozzle temperature and wait for it to be reached
; CP TOOLCHANGE END
M104 S240 T0 ; preheat T0 time: 30s lead 30.2s
; CP TOOLCHANGE START
M104 S200 T1 ; set nozzle temperature ;cooldown
T0 ; change extruder
M109 S240 T0 ; set nozzle temperature and wait for it to be reached
; CP TOOLCHANGE END
M104 S240 T1 ; preheat T1 time: 30s lead 30.2s
; CP TOOLCHANGE START
M104 S200 T0 ; set nozzle temperature ;cooldown
T1 ; change extruder
M109 S240 T1 ; set nozzle temperature and wait for it to be reached
; CP TOOLCHANGE END
M104 S240 T0 ; preheat T0 time: 30s lead 30.2s
; CP TOOLCHANGE START
M104 S200 T1 ; set nozzle temperature ;cooldown
T0 ; change extruder
M109 S240 T0 ; set nozzle temperature and wait for it to be reached
; CP TOOLCHANGE END
M104 S240 T1 ; preheat T1 time: 30s lead 30.2s
; CP TOOLCHANGE START
M104 S200 T0 ; set nozzle temperature ;cooldown
T1 ; change extruder
M109 S240 T1 ; set nozzle temperature and wait for it to be reached
; CP TOOLCHANGE END
M104 S240 T0 ; preheat T0 time: 30s lead 30.2s
; CP TOOLCHANGE START
M104 S200 T1 ; set nozzle temperature ;cooldown
T0 ; change extruder
M109 S240 T0 ; set nozzle temperature and wait for it to be reached
; CP TOOLCHANGE END
M104 S240 T1 ; preheat T1 time: 31s lead 30.7s
; CP TOOLCHANGE START
M104 S200 T0 ; set nozzle temperature ;cooldown
T1 ; change extruder
M109 S240 T1 ; set nozzle temperature and wait for it to be reached
; CP TOOLCHANGE END
M104 S240 T0 ; preheat T0 time: 31s lead 30.6s
; CP TOOLCHANGE START
M104 S200 T1 ; set nozzle temperature ;cooldown
T0 ; change extruder
M109 S240 T0 ; set nozzle temperature and wait for it to be reached
; CP TOOLCHANGE END
M104 S240 T1 ; preheat T1 time: 30s lead 30.3s
; CP TOOLCHANGE START
M104 S200 T0 ; set nozzle temperature ;cooldown
T1 ; change extruder
M109 S240 T1 ; set nozzle temperature and wait for it to be reached
; CP TOOLCHANGE END
M104 S240 T0 ; preheat T0 time: 31s lead 30.6s
; CP TOOLCHANGE START
M104 S200 T1 ; set nozzle temperature ;cooldown
T0 ; change extruder
M109 S240 T0 ; set nozzle temperature and wait for it to be reached
; CP TOOLCHANGE END
M104 S240 T1 ; preheat T1 time: 30s lead 30.0s
; CP TOOLCHANGE START
M104 S200 T0 ; set nozzle temperature ;cooldown
T1 ; change extruder
M109 S240 T1 ; set nozzle temperature and wait for it to be reached
; CP TOOLCHANGE END
M104 S240 T0 ; preheat T0 time: 30s lead 30.2s
; CP TOOLCHANGE START
M104 S200 T1 ; set nozzle temperature ;cooldown
T0 ; change extruder
M109 S240 T0 ; set nozzle temperature and wait for it to be reached
; CP TOOLCHANGE END
M104 S240 T1 ; preheat T1 time: 30s lead 30.2s
; CP TOOLCHANGE START
M104 S200 T0 ; set nozzle temperature ;cooldown
T1 ; change extruder
M109 S240 T1 ; set nozzle temperature and wait for it to be reached
; CP TOOLCHANGE END
M104 S240 T0 ; preheat T0 time: 30s lead 30.2s
; CP TOOLCHANGE START
M104 S200 T1 ; set nozzle temperature ;cooldown
T0 ; change extruder
M109 S240 T0 ; set nozzle temperature and wait for it to be reached
; CP TOOLCHANGE END
M104 S240 T1 ; preheat T1 time: 30s lead 30.2s
; CP TOOLCHANGE START
M104 S200 T0 ; set nozzle temperature ;cooldown
T1 ; change extruder
M109 S240 T1 ; set nozzle temperature and wait for it to be reached
; CP TOOLCHANGE END
M104 S240 T0 ; preheat T0 time: 30s lead 30.2s
; CP TOOLCHANGE START
M104 S200 T1 ; set nozzle temperature ;cooldown
T0 ; change extruder
M109 S240 T0 ; set nozzle temperature and wait for it to be reached
; CP TOOLCHANGE END
M104 S240 T1 ; preheat T1 time: 31s lead 30.7s
; CP TOOLCHANGE START
M104 S200 T0 ; set nozzle temperature ;cooldown
T1 ; change extruder
M109 S240 T1 ; set nozzle temperature and wait for it to be reached
; CP TOOLCHANGE END
M104 S240 T0 ; preheat T0 time: 30s lead 30.4s
; CP TOOLCHANGE START
M104 S200 T1 ; set nozzle temperature ;cooldown
T0 ; change extruder
M109 S240 T0 ; set nozzle temperature and wait for it to be reached
; CP TOOLCHANGE END
M104 S240 T1 ; preheat T1 time: 30s lead 30.4s
; CP TOOLCHANGE START
M104 S200 T0 ; set nozzle temperature ;cooldown
T1 ; change extruder
M109 S240 T1 ; set nozzle temperature and wait for it to be reached
; CP TOOLCHANGE END
M104 S240 T0 ; preheat T0 time: 30s lead 30.0s
; CP TOOLCHANGE START
M104 S200 T1 ; set nozzle temperature ;cooldown
T0 ; change extruder
M109 S240 T0 ; set nozzle temperature and wait for it to be reached
; CP TOOLCHANGE END
M104 S240 T1 ; preheat T1 time: 30s lead 30.0s
; CP TOOLCHANGE START
M104 S200 T0 ; set nozzle temperature ;cooldown
T1 ; change extruder
M109 S240 T1 ; set nozzle temperature and wait for it to be reached
; CP TOOLCHANGE END
; CP TOOLCHANGE START
; CP TOOLCHANGE END
M104 S0 ; turn off temperature
+3
View File
@@ -5,18 +5,21 @@ add_executable(${_TEST_NAME}_tests
test_helpers.hpp
test_cooling.cpp
test_extrusion_entity.cpp
test_extrusion_processor.cpp
test_fill.cpp
test_flow.cpp
test_gcode_timing.cpp
test_gcodewriter.cpp
test_model.cpp
test_multifilament.cpp
test_perimeters.cpp
test_print.cpp
test_printobject.cpp
test_skirt_brim.cpp
test_slicing_pipeline_hook.cpp
test_support_material.cpp
test_trianglemesh.cpp
test_wipe_tower.cpp
)
target_link_libraries(${_TEST_NAME}_tests test_common libslic3r Catch2::Catch2WithMain)
set_property(TARGET ${_TEST_NAME}_tests PROPERTY FOLDER "tests")
-93
View File
@@ -1,93 +0,0 @@
# fff_print test suite
Component- and pipeline-level tests for FFF slicing: the path from a `Model` plus config, through `Print` / `PrintObject`, to emitted G-code.
For Catch2 mechanics (assertions, generators, matchers, random ordering, thread-safety), see [../CLAUDE.md](../CLAUDE.md). This document is the organizing contract for the suite: where a test goes, and how it is named.
## Organizing principle
**One file per subsystem. A subsystem is usually a single production class (`Flow`, `PrintObject`), but may be a cohesive feature that spans several (skirt/brim lives in `Brim.cpp`, `Print.cpp`, and `GCode.cpp`). That file owns every test for the subsystem: in-memory-state assertions and emitted-G-code assertions alike.**
A test's home is decided by *what production code it exercises*, never by *how it observes the result*. A skirt test that inspects `print.skirt()` and one that greps the G-code for `; skirt` live in the same file.
If you touched code in a subsystem, its test file is where your test goes. If a subsystem has no file yet, add `test_<subsystem>.cpp` and list it in `CMakeLists.txt`.
## File ownership
### Building blocks (one class, exercised through its API)
| File | Source (`src/libslic3r/`) | Covers |
|---|---|---|
| `test_trianglemesh` | `TriangleMesh.{c,h}pp` | mesh stats, transforms, slicing, split/merge/cut |
| `test_flow` | `Flow.{c,h}pp` | extrusion width / area math |
| `test_extrusion_entity` | `ExtrusionEntity.{c,h}pp` | extrusion-collection geometry |
| `test_gcodewriter` | `GCodeWriter.{c,h}pp`, `GCode.cpp` | low-level G-code emit primitives, origin |
| `test_model` | `Model.{c,h}pp` | object / volume / instance construction |
### Slicing pipeline (build a `Print`, then assert state or G-code)
| File | Source (`src/libslic3r/`) | Covers |
|---|---|---|
| `test_printobject` | `PrintObject.cpp` | layer heights, perimeter generation |
| `test_fill` | `Fill/` | infill patterns and infill G-code |
| `test_skirt_brim` | `Brim.cpp`, `Print.cpp` | skirt/brim loop counts, grouping, brim ears, emission order |
| `test_support_material` | `Support/` | support & raft layers, contact distance |
| `test_cooling` | `GCode/CoolingBuffer.cpp` | fan control, speed-marker consumption |
| `test_multifilament` | `GCode/ToolOrdering.cpp` | per-feature and per-object filament routing |
| `test_print` | `Print.{c,h}pp` | `validate()`, solid-shell behavior, sequential printing, custom G-code & config comments, default-slice smoke |
Paths are under `src/libslic3r/`. A trailing `/` is a directory of related files; otherwise it is a single class. `{c,h}pp` means the `.cpp`/`.hpp` pair.
## Naming and tags
- **File:** `test_<subsystem>.cpp`.
- **Test name:** a plain behavioral sentence, present tense, stating the contract the test pins down. No `Subsystem:` prefix (the tag carries that).
- Good: `TEST_CASE("Skirt is emitted once per layer it spans", "[SkirtBrim]")`
- Avoid: `TEST_CASE("Print: Skirt generation", "[Print]")`
- **Tags:**
- Exactly one **subsystem** tag, PascalCase, matching the file (`[SkirtBrim]`, `[PrintObject]`, `[Fill]`). This is the grouping / filter key.
- Optional **cross-cutting** tags for a concern that genuinely spans files (`[validate]`, `[Regression]`).
- **Status** tags: `[NotWorking]` marks a test disabled for a known, documented reason; CI excludes it via `~[NotWorking]` (it does not hide itself). Use `[.]` to hide a test from default runs entirely. Either way, say why in a one-line comment.
## Test style
Prefer a flat `TEST_CASE` per behavior, with `GENERATE` for parameterized cases and shared setup factored into helpers. The test name carries the behavior, so the BDD scaffolding is usually redundant. Reserve `SCENARIO` / `GIVEN` / `WHEN` / `THEN` for a test with genuine shared setup that branches into a few closely related variations, and never let a `SCENARIO` accumulate unrelated `WHEN`s: that grab-bag is what this contract exists to prevent (and it hides failures behind a single coarse test case).
## Robust tests
A test should fail only when the behavior it names breaks, not from unrelated changes (the "change-detector" anti-pattern). Test behavior, not incidentals, and aim for one reason to fail. Concretely:
- Don't depend on or assert defaults: set the config keys the behavior needs, and derive expected values from those inputs (a 20mm cube at 0.2mm = 100 layers), not from a default that may change.
- Assert the defining property, not an incidental value: prefer "skirt present", "at least 2 brim loops", or "ears vs none" over exact coordinates, extrusion amounts, line counts, or byte sizes.
- Compare floats with a tolerance (`WithinAbs` / `WithinRel`), never `==`.
- Match the meaningful G-code token (`; skirt`), not whole lines, whitespace, or comment wording.
- Rely on ordering only when it is the contract (as `role_sequence` does).
- Keep tests self-contained: no shared state, green under `--order rand`.
## Helpers
Reuse these instead of building a `Print` or parsing G-code by hand.
- **Global** (`tests/test_utils.hpp`, available to every suite):
- `load_model("file.obj")`: load a `TriangleMesh` from `tests/data/`.
- `ScopedTemporaryFile`: an RAII temp-file path, removed on scope exit.
- **Suite harness** (`fff_print/test_helpers.{hpp,cpp}`):
- Build and run: `init_print(...)`, `init_and_process_print(...)`, `slice(...)` (returns the G-code string), and `gcode(print)`.
- Two-cube placement: `slice_two_cubes_arranged(...)` (arranger-positioned), and `place_two_cubes_apart(...)` / `slice_two_cubes_apart(...)` (a fixed gap, not arranged).
- Meshes: `cube(size)` / `make_cube(...)` for simple shapes; the `TestMesh` enum with `mesh(...)` for named fixtures.
- G-code analysis: `layers_with_role(gcode, role)`, `max_z(gcode)`, `role_passes(gcode, role)`, `role_sequence(gcode, roles)`. Subsystem-specific checks stay local (for example `brim_count` in `test_skirt_brim`).
Promote a helper into the suite harness when it is a general test primitive (not tied to one subsystem's logic), even if only one file uses it today; keep genuinely subsystem-specific helpers local (file-static). Reuse potential, not current usage count, is the test.
## Adding a test (checklist)
1. Find the subsystem's file in the tables; create `test_<subsystem>.cpp` if missing.
2. Build the print with a harness helper; set only the config keys the behavior needs.
3. Assert the behavior, in-memory or via parsed G-code, whichever is clearest.
4. Name it as a behavioral sentence and tag it `[Subsystem]`.
5. For a bug fix, add the regression test in the owning file. Name it for the behavior it protects; the test must stand on its own without relying on an external issue or PR for meaning.
## Running
ctest --test-dir build/tests/fff_print
build/tests/fff_print/<config>/fff_print_tests --order rand "~[NotWorking]"
@@ -0,0 +1,441 @@
#include <catch2/catch_all.hpp>
#include "libslic3r/AABBTreeLines.hpp"
#include "libslic3r/GCode/ExtrusionProcessor.hpp"
#include "libslic3r/GCodeReader.hpp"
#include "libslic3r/TriangleMesh.hpp"
#include "test_helpers.hpp"
#include <algorithm>
#include <cmath>
#include <string>
#include <vector>
using namespace Slic3r;
using namespace Slic3r::Test;
namespace {
// Print settings the assertions below are derived from.
constexpr double caged_layer_height = 0.2; // mm
constexpr double caged_wall_width = 0.42; // mm, outer wall line width
constexpr double caged_outer_wall_speed = 200.; // mm/s
constexpr double caged_slow_speed = 100.; // mm/s, between every configured overhang speed (<= 50) and the wall speed
// A wall running 0.2mm out over a previous layer whose edge dishes 0.03mm away from it in the middle,
// standing in for the endpoint readings a caged overhang perimeter takes: enough of a difference to
// print at another speed, but only a fraction of the distance at which slowdown begins.
constexpr double dished_wall_gap = 0.2; // mm, how far the wall runs out past the previous layer's edge
constexpr double dished_layer_depth = 0.03; // mm, how much further out the middle of it reads
constexpr double dished_min_distance = 0.042; // mm, the reading at which the configured speeds begin to slow down
// Every reading here is past that, so the whole wall is slowed and only the amount is in question.
constexpr float dished_end_reading = float(dished_wall_gap + 0.5 * caged_wall_width);
constexpr float dished_mid_reading = float(dished_end_reading + dished_layer_depth);
// The two readings are dished_layer_depth apart, so half of that tells them apart while still allowing
// for the points the passes after sampling add, which read a little further out than the ends do.
constexpr double dished_reading_tolerance = 0.5 * dished_layer_depth;
// A 40 x 20 x 20 mm box with a 45 degree overhang cut into the y = 0 side. The sloped face spans
// x = 5.086 .. 34.914 only, so the full-height walls of the box cage both ends of every overhang
// perimeter: the endpoints look supported even though the span between them is not.
TriangleMesh caged_overhang_mesh()
{
return TriangleMesh(
{
{5.0859987f, 10.167065f, 5.711731f}, {34.914257f, 10.167065f, 5.711731f},
{34.914257f, 0.f, 15.878796f}, {5.0859995f, 0.f, 15.878796f},
{0.f, 0.f, 0.f}, {0.f, 0.f, 20.f},
{0.f, 20.f, 20.f}, {0.f, 20.f, 0.f},
{40.f, 20.f, 20.f}, {40.f, 20.f, 0.f},
{40.f, 0.f, 20.f}, {40.f, 0.f, 0.f},
{34.914257f, 0.f, 0.f}, {5.0859995f, 0.f, 0.f},
{34.914257f, 10.167065f, 0.f}, {5.0859995f, 10.167065f, 0.f},
},
{
{0, 1, 2}, {0, 2, 3}, {4, 5, 6}, {4, 6, 7}, {7, 6, 8}, {7, 8, 9},
{9, 8, 10}, {9, 10, 11}, {12, 11, 10}, {5, 4, 13}, {5, 13, 3}, {2, 12, 10},
{5, 3, 2}, {10, 5, 2}, {9, 11, 12}, {9, 12, 14}, {13, 4, 7}, {9, 14, 15},
{15, 13, 7}, {7, 9, 15}, {8, 6, 5}, {8, 5, 10}, {14, 1, 0}, {14, 0, 15},
{2, 1, 14}, {2, 14, 12}, {15, 0, 3}, {15, 3, 13},
});
}
// Mesh geometry the wall filters below are derived from.
constexpr double caged_box_depth = 20.; // mm, the box spans y = 0 .. 20
constexpr double caged_slope_face_sum = 15.878796; // mm, y + z of the sloped face, from its corners
// The sloped face spans this x range; outside it the box walls run full height.
constexpr double caged_slope_x_min = 5.0859995;
constexpr double caged_slope_x_max = 34.914257;
constexpr double caged_slope_span = caged_slope_x_max - caged_slope_x_min; // ~29.8 mm
// The z range the sloped face occupies, from the same fixture vertices.
constexpr double caged_slope_z_min = 5.711731;
constexpr double caged_slope_z_max = 15.878796;
// The lowest slope layer still sits on the solid body below the notch, so it is fully supported and
// runs at the outer wall speed by design. The caged span proper begins one layer above it.
constexpr double caged_span_z_min = caged_slope_z_min + caged_layer_height;
// A layer printed at z is sliced at z - layer_height / 2, and the outer wall centreline sits half a
// line width inside the contour, so the wall on the slope satisfies y + z = 16.189.
constexpr double caged_slope_wall_sum = caged_slope_face_sum + 0.5 * caged_layer_height + 0.5 * caged_wall_width;
// Same inset on the fully supported y = 20 face, vertical over the whole height.
constexpr double caged_back_wall_y = caged_box_depth - 0.5 * caged_wall_width;
// And on the y = 0 face, which runs full height only outside the slope's x range.
constexpr double caged_front_wall_y = 0.5 * caged_wall_width;
// Arachne varies the wall width along a face, and the centreline inset is half that width, so a
// wall sits within about half a line width of where the nominal inset alone would put it. The
// faces being selected are millimetres apart, so this stays far from ambiguous.
constexpr double caged_wall_tolerance = 0.5 * caged_wall_width;
// Feed rates in mm/min of the long outer wall extrusions `keep_line` selects.
template<typename KeepLine> std::vector<double> outer_wall_feed_rates(const std::string& gcode, KeepLine keep_line)
{
std::vector<double> feed_rates;
bool outer_wall = false;
GCodeReader parser;
parser.parse_buffer(gcode, [&feed_rates, &outer_wall, &keep_line](GCodeReader& self, const GCodeReader::GCodeLine& line) {
const std::string_view comment = line.comment();
if (comment.find("FEATURE:") != std::string_view::npos || comment.find("TYPE:") != std::string_view::npos)
outer_wall = comment.find("Outer wall") != std::string_view::npos ||
comment.find("External perimeter") != std::string_view::npos;
if (outer_wall && line.extruding(self) && line.dist_XY(self) > 1.0 && keep_line(self, line))
feed_rates.push_back(line.new_F(self));
});
return feed_rates;
}
// The caged 45 degree overhang: outer walls crossing the sloped face for most of its width, on the
// layers where the face genuinely overhangs.
// Both ends are tested against the slope plane rather than requiring a constant Y. Arachne's
// variable-width walls drift slightly in Y along the same slope (Y6.186 -> Y6.189 on one move), so
// a constant-Y filter matches almost nothing under Arachne and silently reduces its coverage.
// The length test excludes the cage walls: they are only as wide as the box is either side of the
// slope, but being vertical their y + z sweeps through the slope plane as z rises, so a couple of
// their fully supported moves would otherwise be counted as part of the span.
std::vector<double> caged_slope_feed_rates(const std::string& gcode)
{
return outer_wall_feed_rates(gcode, [](const GCodeReader& self, const GCodeReader::GCodeLine& line) {
const double z = line.new_Z(self);
return z > caged_span_z_min && z < caged_slope_z_max &&
line.dist_XY(self) > 0.5 * caged_slope_span &&
std::abs(self.y() + z - caged_slope_wall_sum) < caged_wall_tolerance &&
std::abs(line.new_Y(self) + z - caged_slope_wall_sum) < caged_wall_tolerance;
});
}
// The opposite, fully supported face, skipping the initial layer and its own speed settings.
std::vector<double> back_wall_feed_rates(const std::string& gcode)
{
return outer_wall_feed_rates(gcode, [](const GCodeReader& self, const GCodeReader::GCodeLine& line) {
return line.new_Z(self) > 1.5 * caged_layer_height &&
std::abs(self.y() - caged_back_wall_y) < caged_wall_tolerance &&
std::abs(line.new_Y(self) - caged_back_wall_y) < caged_wall_tolerance;
});
}
// The first layer printed entirely above the slope. Its y = 0 wall runs the full width of the box.
const double caged_layer_above_slope_z = std::ceil(caged_slope_z_max / caged_layer_height) * caged_layer_height;
// The parts of that wall standing on the cage rather than the slope, so on a contour identical to their own.
// Where the support changes is found by bisection, which stops at spans of 2mm, so the move spanning each end of
// the slope reaches a little way into the cage. Taking only the moves lying wholly outside the slope's x range
// leaves the wall that is unambiguously supported, without asserting how closely the bisection converged.
std::vector<double> cage_shoulder_feed_rates(const std::string& gcode)
{
return outer_wall_feed_rates(gcode, [](const GCodeReader& self, const GCodeReader::GCodeLine& line) {
return std::abs(line.new_Z(self) - caged_layer_above_slope_z) < 0.5 * caged_layer_height &&
std::abs(self.y() - caged_front_wall_y) < caged_wall_tolerance &&
std::abs(line.new_Y(self) - caged_front_wall_y) < caged_wall_tolerance &&
(std::max(self.x(), line.new_X(self)) <= caged_slope_x_min ||
std::min(self.x(), line.new_X(self)) >= caged_slope_x_max);
});
}
// The readings a 40mm wall takes over a previous layer whose edge falls away by 0.03mm towards the
// middle: both ends read the same, and the middle reads slightly further out over air. Whether that
// middle reading survives is what decides the speed the wall is printed at.
std::vector<ExtendedPoint<2>> sampled_wall_over_dished_layer(const std::function<float(float)>& distance_to_speed)
{
const AABBTreeLines::LinesDistancer<Linef> prev_layer(std::vector<Linef>{
{{0., 0.}, {20., -dished_layer_depth}},
{{20., -dished_layer_depth}, {40., 0.}},
{{40., 0.}, {40., -10.}},
{{40., -10.}, {0., -10.}},
{{0., -10.}, {0., 0.}},
});
const Points wall{Point::new_scale(0., dished_wall_gap), Point::new_scale(40., dished_wall_gap)};
return estimate_points_properties<true, true, true, true>(wall, prev_layer, caged_wall_width, -1.f,
dished_min_distance, distance_to_speed);
}
// A straight, otherwise supported wall over a previous-layer boundary with a 2mm-wide pocket. Moving the
// pocket between x = 10 and x = 20 covers both discovery away from the wall's midpoint and refinement around
// a midpoint that has already been discovered. The current wall is inset half its width from the flat boundary,
// so its supported readings are zero after the estimator applies its boundary offset.
constexpr double narrow_pocket_wall_length = 40.;
constexpr double narrow_pocket_width = 2.;
constexpr double narrow_pocket_depth = 0.3;
std::vector<ExtendedPoint<2>> sampled_wall_over_narrow_pocket(
double pocket_center, const std::function<float(float)>& distance_to_speed)
{
const double pocket_left = pocket_center - 0.5 * narrow_pocket_width;
const double pocket_right = pocket_center + 0.5 * narrow_pocket_width;
const AABBTreeLines::LinesDistancer<Linef> prev_layer(std::vector<Linef>{
{{0., 0.}, {pocket_left, 0.}},
{{pocket_left, 0.}, {pocket_left, -narrow_pocket_depth}},
{{pocket_left, -narrow_pocket_depth}, {pocket_right, -narrow_pocket_depth}},
{{pocket_right, -narrow_pocket_depth}, {pocket_right, 0.}},
{{pocket_right, 0.}, {narrow_pocket_wall_length, 0.}},
{{narrow_pocket_wall_length, 0.}, {narrow_pocket_wall_length, -10.}},
{{narrow_pocket_wall_length, -10.}, {0., -10.}},
{{0., -10.}, {0., 0.}},
});
const double wall_y = -0.5 * caged_wall_width;
const Points wall{Point::new_scale(0., wall_y), Point::new_scale(narrow_pocket_wall_length, wall_y)};
return estimate_points_properties<true, true, true, true>(wall, prev_layer, caged_wall_width, -1.f,
dished_min_distance, distance_to_speed);
}
// A cross section that grows a layer's worth on the two faces meeting at either end of a wall, as any
// 45 degree overhang does. The wall itself stands on a contour identical to its own, but its ends sit
// where the growing faces cut the corners off, and the previous layer's edge there is nearer than the
// half line width the centreline is inset by. Both ends therefore read an overhang while everything
// between them reads supported: the reverse of the caged span, and the case the sampling above must
// leave to the passes after it.
constexpr double stepped_wall_inset = 0.5 * caged_wall_width; // mm, centreline inset from the contour
constexpr double stepped_end_gap = stepped_wall_inset - caged_layer_height; // mm, how far inside the corner ends up
constexpr double stepped_wall_span = 30.; // mm, the length of the wall
std::vector<ExtendedPoint<2>> sampled_wall_between_growing_corners(const std::function<float(float)>& distance_to_speed)
{
const AABBTreeLines::LinesDistancer<Linef> prev_layer(std::vector<Linef>{
{{0., 0.}, {32., 0.}},
{{32., 0.}, {32., -stepped_wall_span}},
{{32., -stepped_wall_span}, {0., -stepped_wall_span}},
{{0., -stepped_wall_span}, {0., 0.}},
});
const Points wall{Point::new_scale(stepped_wall_inset, -stepped_end_gap),
Point::new_scale(stepped_wall_inset, stepped_end_gap - stepped_wall_span)};
return estimate_points_properties<true, true, true, true>(wall, prev_layer, caged_wall_width, -1.f,
dished_min_distance, distance_to_speed);
}
// How much of a path is printed below the speed a fully supported reading gives. A segment is printed
// at the lower of the speeds its ends read.
double slowed_length(const std::vector<ExtendedPoint<2>>& points, const std::function<float(float)>& distance_to_speed)
{
double length = 0.;
for (size_t i = 0; i + 1 < points.size(); ++i)
if (std::min(distance_to_speed(points[i].distance), distance_to_speed(points[i + 1].distance)) < distance_to_speed(0.f))
length += (points[i + 1].position - points[i].position).norm();
return length;
}
float furthest_reading(const std::vector<ExtendedPoint<2>>& points)
{
return std::max_element(points.begin(), points.end(), [](const ExtendedPoint<2>& l, const ExtendedPoint<2>& r) {
return l.distance < r.distance;
})->distance;
}
DynamicPrintConfig caged_overhang_config(const char* wall_generator){
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
config.set_deserialize_strict({
{"nozzle_diameter", "0.4"},
{"initial_layer_print_height", caged_layer_height},
{"layer_height", caged_layer_height},
{"line_width", caged_wall_width},
{"outer_wall_line_width", caged_wall_width},
{"inner_wall_line_width", "0.45"},
{"wall_loops", "2"},
{"wall_generator", wall_generator},
{"wall_sequence", "inner wall/outer wall"},
{"sparse_infill_density", "15%"},
{"detect_overhang_wall", "1"},
{"enable_overhang_speed", "1"},
{"slowdown_for_curled_perimeters", "0"},
{"zaa_enabled", "0"},
{"outer_wall_speed", caged_outer_wall_speed},
{"inner_wall_speed", "300"},
{"overhang_1_4_speed", "0"},
{"overhang_2_4_speed", "50"},
{"overhang_3_4_speed", "30"},
{"overhang_4_4_speed", "10"},
{"bridge_speed", "50"},
{"filament_max_volumetric_speed", "22"},
{"slow_down_for_layer_cooling", "0"},
{"slow_down_layers", "0"}, // Nothing but the overhang settings may lower a wall speed
});
return config;
}
std::string caged_overhang_gcode(const char* wall_generator)
{
Print print;
Model model;
init_print(std::vector<TriangleMesh>{caged_overhang_mesh()}, print, model, caged_overhang_config(wall_generator), nullptr,
false);
return gcode(print);
}
// Reports the matched move count alongside the extremes, so a filter that selected nothing is
// distinguishable from a span that simply was not slowed.
void info_feed_rates(const char* span, const std::vector<double>& feed_rates)
{
UNSCOPED_INFO("matched " << feed_rates.size() << " " << span << " moves");
if (!feed_rates.empty()) {
const auto extremes = std::minmax_element(feed_rates.begin(), feed_rates.end());
UNSCOPED_INFO("slowest " << *extremes.first / MM_PER_MIN << " mm/s, fastest " << *extremes.second / MM_PER_MIN << " mm/s");
}
}
} // namespace
// Classic reproduces the endpoint-sampling bug: it emits the span as one long move whose endpoints
// both read as supported, so endpoint-only sampling never slows it. Arachne's endpoints already read
// as overhanging, but their placement near the cage makes the inferred support vary by layer. Arachne
// parity is therefore part of this regression's scope: both generators must classify the unsupported
// interior of the same 45-degree span consistently.
TEST_CASE("Caged external overhangs are slowed along their span", "[ExtrusionProcessor][Regression]")
{
const char* wall_generator = GENERATE("classic", "arachne");
INFO("wall generator: " << wall_generator);
const std::vector<double> feed_rates = caged_slope_feed_rates(caged_overhang_gcode(wall_generator));
info_feed_rates("caged slope", feed_rates);
REQUIRE_FALSE(feed_rates.empty());
// The endpoint bug left Classic at the full wall speed, while Arachne's cage-adjacent endpoint
// samples selected much faster bands on some layers. The whole span must stay in the slowed range
// for both generators, without requiring their different path segmentations to match.
const double fastest = *std::max_element(feed_rates.begin(), feed_rates.end());
REQUIRE(fastest < caged_slow_speed * MM_PER_MIN);
}
// The other side of the fix: the midpoint probe fires on every long external perimeter, so a
// regression that over-slows would leave the test above green. A fully supported wall must keep the
// speed it was configured with.
TEST_CASE("Supported vertical walls keep their normal speed", "[ExtrusionProcessor][Regression]")
{
const char* wall_generator = GENERATE("classic", "arachne");
INFO("wall generator: " << wall_generator);
const std::vector<double> feed_rates = back_wall_feed_rates(caged_overhang_gcode(wall_generator));
info_feed_rates("back wall", feed_rates);
REQUIRE_FALSE(feed_rates.empty());
const double slowest = *std::min_element(feed_rates.begin(), feed_rates.end());
REQUIRE(slowest >= caged_slow_speed * MM_PER_MIN);
}
// The slope's top edge falls mid layer, so the first layer above it still stands 0.179mm proud of the layer
// below wherever that layer was still on the slope. That is a real overhang and is slowed, but it ends with the
// slope: outside the slope's x range the box runs full height, so the same wall stands on a contour identical to
// its own. Sampling the interior of that wall at a single point reported one support reading for all of it and
// slowed these fully supported ends along with the rest.
TEST_CASE("Wall sections beside a caged overhang keep their normal speed", "[ExtrusionProcessor][Regression]")
{
const char* wall_generator = GENERATE("classic", "arachne");
INFO("wall generator: " << wall_generator);
const std::vector<double> feed_rates = cage_shoulder_feed_rates(caged_overhang_gcode(wall_generator));
info_feed_rates("cage shoulder", feed_rates);
REQUIRE_FALSE(feed_rates.empty());
const double slowest = *std::min_element(feed_rates.begin(), feed_rates.end());
REQUIRE_THAT(slowest / MM_PER_MIN, Catch::Matchers::WithinRel(caged_outer_wall_speed, 0.01));
}
// A wall is printed at the lower of the speeds its ends read, so a reading only earns a point in the
// path where it prints at a different speed from the readings around it. Judging that on the readings
// themselves rather than the speeds they produce was too coarse: the configured speeds interpolate
// between their sections, so readings a fraction of the slowdown threshold apart still print more than
// 10% apart, and a real 45 degree overhang had its true reading dropped as if it agreed with its ends.
// The ends then chose the speed on their own, and being next to the walls either side of the overhang
// they read differently from layer to layer, banding an overhang that should have been uniform.
TEST_CASE("An overhang reading is kept whenever it changes the speed", "[ExtrusionProcessor][Regression]")
{
// A steep speed curve, of the kind the configured overhang speeds interpolate across.
const std::vector<ExtendedPoint<2>> points =
sampled_wall_over_dished_layer([](float distance) { return std::round(200.f - 400.f * distance); });
REQUIRE_THAT(furthest_reading(points), Catch::Matchers::WithinAbs(dished_mid_reading, dished_reading_tolerance));
}
// The complement, and why the readings alone were tempting: a reading that prints at the same speed as
// its neighbours cannot change the G-code, so sampling must leave the path alone however far out it is.
TEST_CASE("An overhang reading is dropped when the speed is unchanged", "[ExtrusionProcessor]")
{
// A flat speed curve, of the kind a single configured overhang speed produces.
const std::vector<ExtendedPoint<2>> points = sampled_wall_over_dished_layer([](float) { return 50.f; });
REQUIRE_THAT(furthest_reading(points), Catch::Matchers::WithinAbs(dished_end_reading, dished_reading_tolerance));
}
TEST_CASE("Coarse probing detects an unsupported pocket away from the wall midpoint",
"[ExtrusionProcessor][Regression]")
{
const std::function<float(float)> distance_to_speed = [](float distance) { return distance <= 0.2f ? 100.f : 50.f; };
const std::vector<ExtendedPoint<2>> points =
sampled_wall_over_narrow_pocket(0.25 * narrow_pocket_wall_length, distance_to_speed);
const double slowed = slowed_length(points, distance_to_speed);
REQUIRE(slowed > 0.);
REQUIRE(slowed < 5.);
}
TEST_CASE("Coarse probing brackets a narrow slowdown at the wall midpoint",
"[ExtrusionProcessor][Regression]")
{
// Half of the pocket reading still maps to full speed. A matching probe in either half therefore must not
// prune that half before a supported point has been found close enough to bracket the slow midpoint.
const std::function<float(float)> distance_to_speed = [](float distance) { return distance <= 0.2f ? 100.f : 50.f; };
const std::vector<ExtendedPoint<2>> points =
sampled_wall_over_narrow_pocket(0.5 * narrow_pocket_wall_length, distance_to_speed);
const double slowed = slowed_length(points, distance_to_speed);
REQUIRE(slowed > 0.);
REQUIRE(slowed < 5.);
}
// Sampling probes the interior, so it must not answer for the ends. On a supported wall between two
// corners that read an overhang, the reading that differs is the end's own, and the pass that ends a
// slowdown an end reads places its point from how far out that end is. Sampling took the difference as
// its own to report and put a point at the nearest position bisection had reached instead, which both
// sits further along the wall and leaves too little of it for that pass to run on, so the corner
// slowdown ran millimetres up an otherwise supported wall. Its length grows with the wall, so on a
// model whose cross section keeps growing it reads as a stair stepped band up the corner.
TEST_CASE("A supported wall between overhanging corners is slowed no further than its ends require",
"[ExtrusionProcessor][Regression]")
{
// A steep speed curve, so the ends and the interior between them print at clearly different speeds.
const std::function<float(float)> distance_to_speed = [](float distance) {
return std::round(float(caged_outer_wall_speed) - 400.f * distance);
};
const double sampled = slowed_length(sampled_wall_between_growing_corners(distance_to_speed), distance_to_speed);
// The same wall with sampling switched off: what the endpoint driven passes alone make of the corners.
const double unsampled = slowed_length(sampled_wall_between_growing_corners({}), distance_to_speed);
// The corners do read an overhang, so there is a slowdown for sampling to have lengthened.
REQUIRE(unsampled > 0.);
REQUIRE(sampled <= unsampled);
}
TEST_CASE("Benchmark caged overhang interior sampling", "[ExtrusionProcessor][!benchmark]"){
const char* wall_generator = GENERATE("classic", "arachne");
BENCHMARK(wall_generator)
{
return caged_overhang_gcode(wall_generator);
};
}
+509
View File
@@ -1,12 +1,18 @@
#include <catch2/catch_all.hpp>
#include <algorithm>
#include <cmath>
#include <map>
#include <numeric>
#include <sstream>
#include <string>
#include <vector>
#include "libslic3r/ClipperUtils.hpp"
#include "libslic3r/Fill/Fill.hpp"
#include "libslic3r/Flow.hpp"
#include "libslic3r/Geometry.hpp"
#include "libslic3r/Layer.hpp"
#include "libslic3r/Print.hpp"
#include "libslic3r/SVG.hpp"
#include "libslic3r/libslic3r.h"
@@ -476,3 +482,506 @@ bool test_if_solid_surface_filled(const ExPolygon& expolygon, double flow_spacin
return uncovered.empty(); // solid surface is fully filled
}
// Length-weighted dominant direction of the layer's role_wanted extrusions, whole degrees
// [0, 180), or -1 if it has none. Needs a line pattern such as monotonic or rectilinear.
template<typename RolePred> static int dominant_fill_angle(const Layer &layer, RolePred role_wanted)
{
std::map<int, double> weight_per_degree;
auto account = [&weight_per_degree, &role_wanted](const ExtrusionPath &path) {
if (!role_wanted(path.role()))
return;
const Points3 &pts = path.polyline.points;
for (size_t i = 1; i < pts.size(); ++i) {
const double dx = double(pts[i].x() - pts[i - 1].x());
const double dy = double(pts[i].y() - pts[i - 1].y());
const double len = std::hypot(dx, dy);
if (len <= 0.)
continue;
int deg = int(std::lround(Geometry::rad2deg(std::atan2(dy, dx)))) % 180;
if (deg < 0)
deg += 180;
weight_per_degree[deg] += len;
}
};
for (const LayerRegion *region : layer.regions())
for (const ExtrusionEntity *entity : region->fills.flatten().entities) {
if (auto *path = dynamic_cast<const ExtrusionPath *>(entity))
account(*path);
else if (auto *multi = dynamic_cast<const ExtrusionMultiPath *>(entity))
for (const ExtrusionPath &p : multi->paths)
account(p);
else if (auto *loop = dynamic_cast<const ExtrusionLoop *>(entity))
for (const ExtrusionPath &p : loop->paths)
account(p);
}
if (weight_per_degree.empty())
return -1;
return std::max_element(weight_per_degree.begin(), weight_per_degree.end(),
[](const auto &a, const auto &b) { return a.second < b.second; })->first;
}
template<typename RolePred> static std::vector<int> angles_per_layer(const Print &print, RolePred role_wanted)
{
std::vector<int> angles;
for (const Layer *layer : print.objects().front()->layers())
angles.push_back(dominant_fill_angle(*layer, role_wanted));
return angles;
}
static bool solid_role(ExtrusionRole role) { return is_solid_infill(role) && role != erIroning; }
static bool sparse_role(ExtrusionRole role) { return role == erInternalInfill; }
static bool ironing_role(ExtrusionRole role) { return role == erIroning; }
TEST_CASE("Infill rotation template is unaffected by a raft", "[Fill][Regression]")
{
// More angles than raft layers, so a raft cannot alias back to the same angle.
const std::string template_string = GENERATE("+45", "0,25,50,75,100,125,150");
const int raft_layers = GENERATE(1, 3);
CAPTURE(template_string, raft_layers);
auto angles_for = [&template_string](int rafts) {
Print print;
// 100% density makes every layer solid, so the template shows on all 100, not just shells.
Slic3r::Test::init_and_process_print({Slic3r::Test::cube(20)}, print,
{{"solid_infill_rotate_template", template_string},
{"sparse_infill_density", "100%"},
{"internal_solid_infill_pattern", "monotonic"},
{"layer_height", 0.2},
{"raft_layers", rafts}});
return angles_per_layer(print, solid_role);
};
const std::vector<int> without_raft = angles_for(0);
const std::vector<int> with_raft = angles_for(raft_layers);
REQUIRE(without_raft.size() == 100);
REQUIRE(with_raft.size() == without_raft.size());
REQUIRE(std::count(without_raft.begin(), without_raft.end(), -1) == 0);
CHECK(with_raft == without_raft);
}
TEST_CASE("Sparse infill rotation template turns the infill layer by layer", "[Fill]")
{
const std::vector<int> expected_cycle = {0, 25, 50, 75, 100, 125, 150};
Print print;
// No shells, so every layer is sparse infill rather than solid.
Slic3r::Test::init_and_process_print({Slic3r::Test::cube(10)}, print,
{{"sparse_infill_rotate_template", "0,25,50,75,100,125,150"},
{"sparse_infill_density", "40%"},
{"sparse_infill_pattern", "rectilinear"},
{"top_shell_layers", 0},
{"bottom_shell_layers", 0},
{"layer_height", 0.2}});
const std::vector<int> angles = angles_per_layer(print, sparse_role);
REQUIRE(angles.size() == 50);
REQUIRE(std::count(angles.begin(), angles.end(), -1) == 0);
std::vector<int> expected;
for (size_t i = 0; i < angles.size(); ++i)
expected.push_back(expected_cycle[i % expected_cycle.size()]);
CHECK(angles == expected);
}
TEST_CASE("Infill rotation template layer count modifier holds each angle for N layers", "[Fill]")
{
Print print;
// "+45#2" turns 45 degrees every 2 layers, so equal angles come in pairs.
Slic3r::Test::init_and_process_print({Slic3r::Test::cube(10)}, print,
{{"solid_infill_rotate_template", "+45#2"},
{"sparse_infill_density", "100%"},
{"internal_solid_infill_pattern", "monotonic"},
{"layer_height", 0.2}});
const std::vector<int> angles = angles_per_layer(print, solid_role);
REQUIRE(angles.size() == 50);
REQUIRE(std::count(angles.begin(), angles.end(), -1) == 0);
std::vector<int> run_lengths;
for (size_t i = 0; i < angles.size();) {
size_t j = i;
while (j < angles.size() && angles[j] == angles[i])
++j;
run_lengths.push_back(int(j - i));
i = j;
}
// The first and last runs can be clipped by the start and end of the object.
REQUIRE(run_lengths.size() > 3);
const std::vector<int> interior(run_lengths.begin() + 1, run_lengths.end() - 1);
CHECK(std::count(interior.begin(), interior.end(), 2) == int(interior.size()));
}
TEST_CASE("Z anti-aliasing keeps the infill rotation template's step", "[Fill]")
{
Print print;
Slic3r::Test::init_and_process_print({Slic3r::Test::cube(10)}, print,
{{"solid_infill_rotate_template", "+45"},
{"sparse_infill_density", "100%"},
{"internal_solid_infill_pattern", "monotonic"},
{"zaa_enabled", 1},
{"zaa_min_z", 0.05},
{"layer_height", 0.2}});
// Z contouring varies the layer heights, so the layer count is not 10mm / 0.2mm here.
const std::vector<int> angles = angles_per_layer(print, solid_role);
REQUIRE(angles.size() > 10);
REQUIRE(std::count(angles.begin(), angles.end(), -1) == 0);
// Z contouring may change when the template advances, but each step must still be 45 degrees.
int steps = 0;
for (size_t i = 1; i < angles.size(); ++i) {
const int delta = ((angles[i] - angles[i - 1]) % 180 + 180) % 180;
CAPTURE(i, angles[i - 1], angles[i]);
// Split rather than "delta == 0 || delta == 45" so Catch2 can show the operands.
REQUIRE(delta % 45 == 0);
REQUIRE(delta <= 45);
steps += delta == 45;
}
CHECK(steps > 0);
}
TEST_CASE("Ironing follows the solid infill rotation template", "[Fill]")
{
Print print;
Slic3r::Test::init_and_process_print({Slic3r::Test::cube(10)}, print,
{{"solid_infill_rotate_template", "+45"},
{"internal_solid_infill_pattern", "monotonic"},
{"top_surface_pattern", "monotonic"},
// Every solid surface, so the comparison covers every layer.
{"ironing_type", "solid"},
{"sparse_infill_density", "100%"},
{"ironing_angle", 0},
{"ironing_angle_fixed", 0},
{"layer_height", 0.2}});
const std::vector<int> ironing = angles_per_layer(print, ironing_role);
const std::vector<int> solid = angles_per_layer(print, solid_role);
REQUIRE(ironing.size() == solid.size());
// With no fixed angle and no offset, ironing runs along the template's angle for that layer.
int compared = 0;
for (size_t i = 0; i < ironing.size(); ++i)
if (ironing[i] != -1 && solid[i] != -1) {
CAPTURE(i, ironing[i], solid[i]);
CHECK(ironing[i] == solid[i]);
++compared;
}
// Most of the object, not one lucky layer.
REQUIRE(compared > int(ironing.size()) / 2);
}
TEST_CASE("Solid infill direction offsets every layer when no template is set", "[Fill]")
{
auto angles_for = [](int direction) {
Print print;
Slic3r::Test::init_and_process_print({Slic3r::Test::cube(10)}, print,
{{"solid_infill_direction", direction},
{"sparse_infill_density", "100%"},
{"internal_solid_infill_pattern", "monotonic"},
{"layer_height", 0.2}});
return angles_per_layer(print, solid_role);
};
const std::vector<int> at_0 = angles_for(0);
const std::vector<int> at_30 = angles_for(30);
REQUIRE(at_0.size() == at_30.size());
REQUIRE(std::count(at_0.begin(), at_0.end(), -1) == 0);
for (size_t i = 0; i < at_0.size(); ++i) {
const int delta = ((at_30[i] - at_0[i]) % 180 + 180) % 180;
CAPTURE(i, at_0[i], at_30[i]);
CHECK(delta == 30);
}
}
TEST_CASE("Honeycomb infill rounds its cell corners with the smooth factor", "[Fill]")
{
// A cell whose sides are several times the line width, so that the corners have room to be rounded.
const double spacing = 0.45;
const double density = 0.1;
auto fill = [spacing, density](double smooth_factor) {
std::unique_ptr<Slic3r::Fill> filler(Slic3r::Fill::new_from_type("honeycomb"));
filler->spacing = spacing;
FillParams params;
params.density = float(density);
params.dont_adjust = true;
// Keep the fragments apart, so that only the turns of the pattern itself are measured.
params.anchor_length_max = 0.f;
params.smooth_factor = smooth_factor;
Slic3r::ExPolygon square{ Slic3r::Points{
Point::new_scale(0., 0.), Point::new_scale(50., 0.), Point::new_scale(50., 50.), Point::new_scale(0., 50.) } };
Slic3r::Surface surface(stInternal, square);
return filler->fill_surface(&surface, params);
};
// Cosine of the sharpest turn of any of the paths, 1 meaning none of them turns at all.
auto sharpest_turn_cosine = [](const Slic3r::Polylines &polylines) {
double sharpest = 1.;
for (const Polyline &polyline : polylines)
for (size_t i = 1; i + 1 < polyline.size(); ++i) {
const Vec2d incoming = (polyline[i] - polyline[i - 1]).cast<double>().normalized();
const Vec2d outgoing = (polyline[i + 1] - polyline[i]).cast<double>().normalized();
sharpest = std::min(sharpest, incoming.dot(outgoing));
}
return sharpest;
};
auto point_count = [](const Slic3r::Polylines &polylines) {
return std::accumulate(polylines.begin(), polylines.end(), size_t(0),
[](size_t count, const Polyline &polyline) { return count + polyline.size(); });
};
const Slic3r::Polylines sharp = fill(0.);
const Slic3r::Polylines smooth = fill(1.);
REQUIRE(!sharp.empty());
REQUIRE(smooth.size() == sharp.size());
REQUIRE(point_count(smooth) > point_count(sharp));
// The cell corners turn by 60 degrees; smoothing replaces them by gentle curves.
REQUIRE(sharpest_turn_cosine(sharp) < 0.6);
REQUIRE(sharpest_turn_cosine(smooth) > 0.9);
}
// Point count, number of turns sharper than 25 degrees and length of the sparse infill of a print.
// A rounded corner is a run of much gentler turns, so smoothing shows up as fewer sharp ones.
struct SparseInfillShape {
size_t point_count { 0 };
size_t sharp_turns { 0 };
size_t path_count { 0 };
double length { 0. };
};
static SparseInfillShape sparse_infill_shape(const Print &print)
{
SparseInfillShape shape;
auto account = [&shape](const ExtrusionPath &path) {
if (!sparse_role(path.role()))
return;
const Points3 &pts = path.polyline.points;
++shape.path_count;
shape.point_count += pts.size();
for (size_t i = 1; i < pts.size(); ++i)
shape.length += (pts[i] - pts[i - 1]).head<2>().cast<double>().norm();
for (size_t i = 1; i + 1 < pts.size(); ++i) {
const Vec2d incoming = (pts[i] - pts[i - 1]).head<2>().cast<double>();
const Vec2d outgoing = (pts[i + 1] - pts[i]).head<2>().cast<double>();
if (incoming.squaredNorm() > 0. && outgoing.squaredNorm() > 0. &&
incoming.normalized().dot(outgoing.normalized()) < 0.9)
++shape.sharp_turns;
}
};
for (const Layer *layer : print.objects().front()->layers())
for (const LayerRegion *region : layer->regions())
for (const ExtrusionEntity *entity : region->fills.flatten().entities) {
if (auto *path = dynamic_cast<const ExtrusionPath *>(entity))
account(*path);
else if (auto *multi = dynamic_cast<const ExtrusionMultiPath *>(entity))
for (const ExtrusionPath &p : multi->paths)
account(p);
else if (auto *loop = dynamic_cast<const ExtrusionLoop *>(entity))
for (const ExtrusionPath &p : loop->paths)
account(p);
}
return shape;
}
TEST_CASE("Lightning infill rounds the turns of its branches with the smooth factor", "[Fill]")
{
auto shape_for = [](const std::string &smooth_factor) {
Print print;
Slic3r::Test::init_and_process_print({Slic3r::Test::cube(20)}, print,
{{"sparse_infill_pattern", "lightning"},
{"sparse_infill_density", "15%"},
{"sparse_infill_smooth_factor", smooth_factor},
{"layer_height", 0.2}});
return sparse_infill_shape(print);
};
const SparseInfillShape sharp = shape_for("0%");
const SparseInfillShape smooth = shape_for("100%");
REQUIRE(sharp.point_count > 0);
// The branch turns are replaced by curves, which cut the corners off and take more points to
// describe. The turns where two branches are joined into one path stay sharp.
REQUIRE(smooth.point_count > sharp.point_count);
REQUIRE(smooth.sharp_turns < sharp.sharp_turns);
REQUIRE(smooth.length < sharp.length);
}
TEST_CASE("Concentric infill rounds its loops with the smooth factor", "[Fill]")
{
auto shape_for = [](const std::string &smooth_factor) {
Print print;
Slic3r::Test::init_and_process_print({Slic3r::Test::cube(20)}, print,
{{"sparse_infill_pattern", "concentric"},
{"sparse_infill_density", "20%"},
{"sparse_infill_smooth_factor", smooth_factor},
{"layer_height", 0.2}});
return sparse_infill_shape(print);
};
const SparseInfillShape sharp = shape_for("0%");
const SparseInfillShape smooth = shape_for("100%");
REQUIRE(sharp.point_count > 0);
REQUIRE(smooth.point_count > sharp.point_count);
REQUIRE(smooth.sharp_turns < sharp.sharp_turns);
REQUIRE(smooth.length < sharp.length);
}
TEST_CASE("Cross hatch infill rounds its transition layers with the smooth factor", "[Fill]")
{
auto shape_for = [](const std::string &smooth_factor) {
Print print;
Slic3r::Test::init_and_process_print({Slic3r::Test::cube(20)}, print,
{{"sparse_infill_pattern", "crosshatch"},
{"sparse_infill_density", "20%"},
{"sparse_infill_smooth_factor", smooth_factor},
{"layer_height", 0.2}});
return sparse_infill_shape(print);
};
const SparseInfillShape sharp = shape_for("0%");
const SparseInfillShape smooth = shape_for("100%");
REQUIRE(sharp.point_count > 0);
REQUIRE(smooth.point_count > sharp.point_count);
REQUIRE(smooth.sharp_turns < sharp.sharp_turns);
REQUIRE(smooth.length < sharp.length);
}
TEST_CASE("Trapezoidal grid infill rounds its corners only with more than one line", "[Fill]")
{
auto shape_for = [](int multiline, const std::string &smooth_factor) {
Print print;
Slic3r::Test::init_and_process_print({Slic3r::Test::cube(20)}, print,
{{"sparse_infill_pattern", "grid"},
{"sparse_infill_density", "20%"},
{"fill_multiline", multiline},
{"sparse_infill_smooth_factor", smooth_factor},
{"layer_height", 0.2}});
return sparse_infill_shape(print);
};
const SparseInfillShape sharp = shape_for(2, "0%");
const SparseInfillShape smooth = shape_for(2, "100%");
REQUIRE(sharp.point_count > 0);
REQUIRE(smooth.point_count > sharp.point_count);
REQUIRE(smooth.sharp_turns < sharp.sharp_turns);
REQUIRE(smooth.length < sharp.length);
// A single line per infill wall is the plain crossing line grid, which has no corner of its own.
const SparseInfillShape single_sharp = shape_for(1, "0%");
const SparseInfillShape single_smooth = shape_for(1, "100%");
REQUIRE(single_sharp.point_count > 0);
REQUIRE(single_smooth.point_count == single_sharp.point_count);
REQUIRE(single_smooth.length == single_sharp.length);
}
TEST_CASE("3D honeycomb infill rounds its octahedral waves with the smooth factor", "[Fill]")
{
auto shape_for = [](const std::string &smooth_factor) {
Print print;
Slic3r::Test::init_and_process_print({Slic3r::Test::cube(20)}, print,
{{"sparse_infill_pattern", "3dhoneycomb"},
{"sparse_infill_density", "20%"},
{"sparse_infill_smooth_factor", smooth_factor},
{"layer_height", 0.2}});
return sparse_infill_shape(print);
};
const SparseInfillShape sharp = shape_for("0%");
const SparseInfillShape smooth = shape_for("100%");
REQUIRE(sharp.point_count > 0);
REQUIRE(smooth.point_count > sharp.point_count);
REQUIRE(smooth.sharp_turns < sharp.sharp_turns);
REQUIRE(smooth.length < sharp.length);
}
TEST_CASE("Smoothed concentric infill stays inside the fill region", "[Fill][Regression]")
{
// The concentric loops are offsets of the fill region and are never clipped to it, so a corner
// rounded across its boundary ends up in a hole or over a wall. Rounding cuts toward the inside of
// the turn, which leaves the region at every corner of a hole, and in a region thinner than the
// curve even at a corner turning inwards.
const bool thin_region = GENERATE(false, true);
ExPolygon region;
if (thin_region) {
// An L of two 1.2mm wide arms: cutting the corner they meet at crosses both of them.
region = ExPolygon{ Slic3r::Points{
Point::new_scale(0., 0.), Point::new_scale(20., 0.), Point::new_scale(20., 1.2),
Point::new_scale(1.2, 1.2), Point::new_scale(1.2, 20.), Point::new_scale(0., 20.) } };
} else {
region = ExPolygon{ Slic3r::Points{ Point::new_scale(0., 0.), Point::new_scale(50., 0.),
Point::new_scale(50., 50.), Point::new_scale(0., 50.) },
Slic3r::Points{ Point::new_scale(30., 20.), Point::new_scale(30., 30.),
Point::new_scale(20., 30.), Point::new_scale(20., 20.) } };
}
CAPTURE(thin_region);
auto fill = [&region](double smooth_factor) {
std::unique_ptr<Slic3r::Fill> filler(Slic3r::Fill::new_from_type("concentric"));
filler->spacing = 0.45;
FillParams params;
params.density = 0.1f;
params.dont_adjust = true;
params.smooth_factor = smooth_factor;
Slic3r::Surface surface(stInternal, region);
return filler->fill_surface(&surface, params);
};
auto point_count = [](const Slic3r::Polylines &polylines) {
return std::accumulate(polylines.begin(), polylines.end(), size_t(0),
[](size_t count, const Polyline &polyline) { return count + polyline.size(); });
};
const Slic3r::Polylines sharp = fill(0.);
const Slic3r::Polylines smooth = fill(1.);
REQUIRE(!sharp.empty());
// Nothing leaves the fill region, which the unrounded loops already touch from the inside.
const ExPolygons bounds = offset_ex(region, float(SCALED_EPSILON));
REQUIRE(diff_pl(sharp, bounds).empty());
REQUIRE(diff_pl(smooth, bounds).empty());
// The corners that the region has room for are still rounded.
if (!thin_region)
REQUIRE(point_count(smooth) > point_count(sharp));
}
TEST_CASE("Smoothing multiline lightning infill keeps its outlines connected", "[Fill][Regression]")
{
// With more than one line per infill wall, the branches are printed as outlines drawn around them,
// and the outlines of branches that run close to each other merge into one. Rounding the branches
// before those outlines are built moves them apart, which breaks the merged outlines up into
// separate loops - many more of them, each needing its own travel move.
auto shape_for = [](const std::string &smooth_factor) {
Print print;
Slic3r::Test::init_and_process_print({Slic3r::Test::cube(20)}, print,
{{"sparse_infill_pattern", "lightning"},
{"sparse_infill_density", "50%"},
{"fill_multiline", 2},
{"sparse_infill_smooth_factor", smooth_factor},
{"layer_height", 0.2}});
return sparse_infill_shape(print);
};
const SparseInfillShape sharp = shape_for("0%");
const SparseInfillShape smooth = shape_for("100%");
REQUIRE(sharp.path_count > 0);
REQUIRE(smooth.path_count <= sharp.path_count);
// The outlines are still rounded.
REQUIRE(smooth.point_count > sharp.point_count);
REQUIRE(smooth.sharp_turns < sharp.sharp_turns);
}
+27
View File
@@ -820,3 +820,30 @@ SCENARIO("Shipped dual-nozzle change_filament_gcode resolves during a real slice
}
}
}
TEST_CASE("Custom G-code motion limits are restored before generated moves", "[GCodeWriter]")
{
const std::string gcode = Slic3r::Test::slice({ cube(20) }, {
{ "gcode_flavor", "marlin" },
{ "gcode_comments", "1" },
{ "machine_start_gcode", "" },
{ "layer_change_gcode", "M204 S5000\nm205 x5 y5\n" },
{ "layer_height", "0.2" },
{ "initial_layer_print_height", "0.2" },
{ "initial_layer_line_width", "0" },
{ "z_hop", "0" },
{ "default_acceleration", "6000" },
{ "initial_layer_acceleration", "6000" },
{ "outer_wall_acceleration", "6000" },
{ "inner_wall_acceleration", "0" },
{ "default_jerk", "8" },
{ "initial_layer_jerk", "8" },
{ "outer_wall_jerk", "8" },
{ "inner_wall_jerk", "0" },
});
const size_t custom_gcode_pos = gcode.find("m205 x5 y5");
REQUIRE(custom_gcode_pos != std::string::npos);
REQUIRE(gcode.find("M204 S6000 ; adjust acceleration", custom_gcode_pos) != std::string::npos);
REQUIRE(gcode.find("M205 X8 Y8 ; adjust jerk", custom_gcode_pos) != std::string::npos);
}
-11
View File
@@ -491,17 +491,6 @@ SCENARIO("init_print functionality", "[test_helpers]") {
THEN("Export gcode functions outputs text.") {
REQUIRE(! Slic3r::Test::gcode(print).empty());
}
#if 0
THEN("Embedded meshes exported") {
std::string path = "C:\\data\\temp\\embedded_meshes\\";
for (auto kvp : Slic3r::Test::mesh_names) {
Slic3r::TriangleMesh m = mesh(kvp.first);
std::string name = kvp.second;
REQUIRE(Slic3r::store_stl((path + name + ".stl").c_str(), &m, true) == true);
REQUIRE(Slic3r::store_obj((path + name + ".obj").c_str(), &m) == true);
}
}
#endif
}
}
}
+611
View File
@@ -1,12 +1,25 @@
#include <catch2/catch_all.hpp>
#include "libslic3r/GCode/GCodeProcessor.hpp"
#include "libslic3r/GCodeReader.hpp"
#include "test_helpers.hpp"
#include "test_utils.hpp"
#include <algorithm>
#include <cctype>
#include <cmath>
#include <cstdio>
#include <cstdlib>
#include <fstream>
#include <limits>
#include <optional>
#include <set>
#include <sstream>
#include <string>
#include <string_view>
#include <utility>
#include <vector>
using namespace Slic3r;
using namespace Slic3r::Test;
@@ -27,6 +40,210 @@ static std::set<int> tools_for_role(const std::string& gcode, const std::string&
return tools;
}
// X where the nozzle sits while each tagged _WAIT_FOR_TEMP_ON_WIPE_TOWER M109 blocks:
// the nearest preceding G1 carrying an X (the park travel emitted just before the wait).
static std::vector<double> wait_park_xs(const std::string& gcode)
{
std::vector<std::string> lines;
std::istringstream stream(gcode);
for (std::string line; std::getline(stream, line);)
lines.emplace_back(std::move(line));
std::vector<double> xs;
for (size_t i = 0; i < lines.size(); ++i) {
if (lines[i].rfind("M109", 0) != 0 || lines[i].find("_WAIT_FOR_TEMP_ON_WIPE_TOWER") == std::string::npos)
continue;
for (size_t j = i; j-- > 0;) {
if (lines[j].rfind("G1 ", 0) != 0)
continue;
const size_t x_pos = lines[j].find('X');
if (x_pos == std::string::npos)
continue;
xs.push_back(std::stod(lines[j].substr(x_pos + 1)));
break;
}
}
return xs;
}
// Estimated print time at each 1-based line of an exported G-code file, from a second
// GCodeProcessor pass over it. MoveVertex::time is the duration of one move and gcode_id is the
// line it came from (already rebased past the M73 insertions), so the running sum before the first
// move of a line is the elapsed time at that line. The file carries its own config footer, so
// process_file configures the processor -- including the shared s_IsBBLPrinter static that other
// tests in this binary mutate -- from the settings the export itself used.
static std::vector<double> elapsed_time_by_line(const std::string& gcode)
{
ScopedTemporaryFile temp_gcode(".gcode");
{
std::ofstream os(temp_gcode.string());
os << gcode;
}
GCodeProcessor processor;
processor.process_file(temp_gcode.string());
constexpr size_t NORMAL = size_t(PrintEstimatedStatistics::ETimeMode::Normal);
const size_t n_lines = size_t(std::count(gcode.begin(), gcode.end(), '\n')) + 2;
std::vector<double> elapsed(n_lines, 0.);
double running = 0.;
size_t next = 0;
for (const auto& move : processor.get_result().moves) {
const size_t id = std::min<size_t>(move.gcode_id, n_lines - 1);
while (next <= id)
elapsed[next++] = running;
running += move.time[NORMAL];
}
while (next < n_lines)
elapsed[next++] = running;
return elapsed;
}
// The temperature-relevant projection of `gcode`: every M104/M109/Tn line, plus the toolchange and
// priming markers that anchor them, in order. A preheat -- an M104 the GCodeProcessor backtrace
// inserts mid-object, outside any block, naming a tool other than the one currently loaded -- also
// carries "lead <n>s", the estimated time from there to the tool change it heats for, which is the
// property preheat_time controls. No other temperature command gets one: for an M104 retargeting
// the active tool (the first-layer-to-other-layers bump) or one inside a block, the distance to the
// next Tn is a layer time or a handful of moves and says nothing about preheat_time. Everything
// else is dropped, so the trace does not move when travel, tower geometry or line numbering do.
static std::vector<std::string> temperature_trace(const std::string& gcode)
{
std::vector<std::string> lines;
std::istringstream stream(gcode);
for (std::string line; std::getline(stream, line);) {
line.erase(0, line.find_first_not_of(" \t"));
while (!line.empty() && (line.back() == '\r' || line.back() == ' ' || line.back() == '\t'))
line.pop_back();
lines.emplace_back(std::move(line));
}
const std::vector<double> elapsed = elapsed_time_by_line(gcode);
const auto is_tool = [](const std::string& l) { return l.size() >= 2 && l[0] == 'T' && std::isdigit((unsigned char) l[1]); };
const auto is_temp = [](const std::string& l) { return l.rfind("M104", 0) == 0 || l.rfind("M109", 0) == 0; };
const auto marker = [](const std::string& l) -> const char* {
for (const char* m : { "; CP TOOLCHANGE START", "; CP TOOLCHANGE END", "; CP PRIMING START", "; CP PRIMING END" })
if (l.find(m) != std::string::npos)
return m;
return nullptr;
};
// Tool a "T<n>" line, or the "T<n>" argument of an M104, names -- or -1 when it names none.
const auto tool_of = [&is_tool](const std::string& l) -> int {
size_t t = std::string::npos; // index of the 'T'
if (is_tool(l))
t = 0;
else if (l.rfind("M104", 0) == 0 && l.find(" T") != std::string::npos)
t = l.find(" T") + 1;
if (t == std::string::npos || t + 1 >= l.size() || !std::isdigit((unsigned char) l[t + 1]))
return -1;
return std::stoi(l.substr(t + 1));
};
std::vector<std::string> trace;
bool in_block = false;
int current_tool = -1;
for (size_t i = 0; i < lines.size(); ++i) {
if (const char* m = marker(lines[i])) {
in_block = std::string(m).find("START") != std::string::npos;
trace.emplace_back(m); // the marker alone: some carry a trailing tool id, some do not
} else if (is_tool(lines[i]) || is_temp(lines[i])) {
std::string entry = lines[i];
const int named = tool_of(lines[i]);
if (!in_block && lines[i].rfind("M104", 0) == 0 && current_tool != -1 && named != -1 && named != current_tool) {
size_t tn = i;
while (tn < lines.size() && !is_tool(lines[tn]))
++tn;
if (tn < lines.size()) {
char lead[32];
std::snprintf(lead, sizeof(lead), "\tlead %.1fs", elapsed[tn + 1] - elapsed[i + 1]);
entry += lead;
}
}
if (is_tool(lines[i]))
current_tool = named;
trace.emplace_back(std::move(entry));
}
}
return trace;
}
// "M104 S240 T0 ; preheat T0 time: 31s<TAB>lead 30.9s" carries the same quantity twice, and both
// vary by toolchain: the backtrace picks the first line at least preheat_time out, so a sub-tenth
// difference in the estimate selects a neighbouring move and "lead" steps by that move's duration.
// Tolerate "lead", still far below the tens of seconds a displaced preheat would shift it. Check
// "time:" against its own entry's "lead" instead of across runs -- being a rounding of it, that
// still catches a change in how it is derived without tracking the absolute estimate.
static constexpr double TRACE_TIME_TOLERANCE_S = 1.5;
static constexpr double TRACE_ROUNDING_SLACK_S = 0.05; // correct rounding keeps |time - lead| <= 0.5
struct TraceEntry
{
std::string text; // timing values replaced by a placeholder
std::optional<double> time_s;
std::optional<double> lead_s;
};
static TraceEntry parse_trace_entry(const std::string& entry)
{
TraceEntry out;
std::string text = entry;
// Split off the tail only when it really is a "lead <n>s", so an unexpected one still compares.
const size_t tab = text.find('\t');
if (tab != std::string::npos) {
const std::string tail = text.substr(tab + 1); // "lead 30.2s"
const size_t sp = tail.find(' ');
if (sp != std::string::npos && sp + 1 < tail.size()
&& std::isdigit(static_cast<unsigned char>(tail[sp + 1]))) {
out.lead_s = std::stod(tail.substr(sp + 1));
text.erase(tab);
}
}
static constexpr std::string_view k_time = "time: ";
const size_t at = text.find(k_time);
// Require a digit first: a dots-only run would otherwise reach std::stod and throw.
if (at != std::string::npos && at + k_time.size() < text.size()
&& std::isdigit(static_cast<unsigned char>(text[at + k_time.size()]))) {
const size_t first = at + k_time.size();
size_t last = first;
while (last < text.size() && (std::isdigit(static_cast<unsigned char>(text[last])) || text[last] == '.'))
++last;
out.time_s = std::stod(text.substr(first, last - first));
text.replace(first, last - first, "<n>"); // surrounding text, incl. the "s", still compared
}
out.text = std::move(text);
return out;
}
static bool timings_match(const std::optional<double>& a, const std::optional<double>& b)
{
if (a.has_value() != b.has_value())
return false;
return !a.has_value() || std::abs(*a - *b) <= TRACE_TIME_TOLERANCE_S;
}
// "time:" must be its own entry's "lead" rounded to a whole second.
static bool time_is_rounded_lead(const TraceEntry& e)
{
if (!e.time_s.has_value() || !e.lead_s.has_value())
return true; // nothing to cross-check
return std::abs(*e.time_s - *e.lead_s) <= 0.5 + TRACE_ROUNDING_SLACK_S;
}
// `a` is the slice under test, `b` the recorded golden.
static bool trace_entries_match(const std::string& a, const std::string& b)
{
const auto x = parse_trace_entry(a);
const auto y = parse_trace_entry(b);
if (x.text != y.text)
return false;
// A field appearing or disappearing is a real change even though the values are tolerated.
if (x.time_s.has_value() != y.time_s.has_value())
return false;
return timings_match(x.lead_s, y.lead_s) && time_is_rounded_lead(x);
}
// Tool index = filament id - 1; brim and skirt follow the wall filament.
TEST_CASE("Each feature prints with its assigned filament", "[MultiFilament]")
{
@@ -86,6 +303,399 @@ TEST_CASE("Per-object wall filament override is honored", "[MultiFilament]")
CHECK(tools_for_role(gcode, "infill") == std::set<int>{ 0 }); // infill not overridden: stays on F1
}
// With wait_for_temp_on_wipe_tower the blocking M109 moves from right after the Tn command to
// a stop point parked beside the wipe tower (heat-up drool falls next to the tower, not onto
// its top): tagged with _WAIT_FOR_TEMP_ON_WIPE_TOWER, after the toolchange and before the
// repositioning move and the first extrusion of the purge. The restore that used to block there
// demotes to a non-blocking M104 and moves ahead of the Tn, so the incoming tool heats up over
// the change itself. Ordering and the off-tower stop are the contract here.
TEST_CASE("Toolchange temperature wait moves to the wipe tower when enabled", "[MultiFilament]")
{
const bool wait_on_tower = GENERATE(false, true);
DYNAMIC_SECTION("wait_for_temp_on_wipe_tower " << (wait_on_tower ? 1 : 0)) {
const std::string gcode = slice_with_object_overrides(
{ cube(20), cube(20) },
multifilament_config(2, {
{ "nozzle_diameter", "0.4,0.4" },
{ "printer_extruder_id", "1,2" },
{ "printer_extruder_variant", "Direct Drive Standard,Direct Drive Standard" },
{ "extruder_printable_height", "0,0" },
{ "single_extruder_multi_material", 0 },
{ "enable_prime_tower", 1 },
{ "prime_tower_width", 35 },
{ "wipe_tower_x", "50" },
{ "wipe_tower_y", "50" },
{ "ooze_prevention", 1 },
{ "standby_temperature_delta", -40 },
// The post-processor's own preheat pass also inserts an M104 for the incoming
// filament ahead of the Tn; switch it off so the temperature commands under test
// are the only ones in the toolchange block.
{ "preheat_time", 0 },
{ "wait_for_temp_on_wipe_tower", wait_on_tower ? 1 : 0 },
}),
// One filament per object -> a toolchange on every layer. Assigned at the object
// level: the used-filament count that gates the prime tower is derived from
// object/volume configs on the harness's single apply (region filament ids such
// as sparse_infill_filament_id are not counted there and the tower would be
// silently disabled).
{ { { "extruder", 1 } }, { { "extruder", 2 } } });
// Split into lines and scan the "; CP TOOLCHANGE START".."; CP TOOLCHANGE END" blocks.
std::vector<std::string> lines;
std::istringstream gcode_stream(gcode);
for (std::string line; std::getline(gcode_stream, line);)
lines.emplace_back(std::move(line));
const auto is_tool_line = [](const std::string& l) { return l.size() >= 2 && l[0] == 'T' && std::isdigit((unsigned char)l[1]); };
const auto is_m109_line = [](const std::string& l) { return l.rfind("M109", 0) == 0; };
// A non-blocking set-temperature naming one specific tool, e.g. "M104 S255 T1".
const auto is_m104_for_tool = [](const std::string& l, int tool) {
if (l.rfind("M104", 0) != 0)
return false;
const std::string token = " T" + std::to_string(tool);
const size_t at = l.find(token);
return at != std::string::npos && !std::isdigit((unsigned char)l[at + token.size()]);
};
const auto is_tagged_wait = [](const std::string& l) { return l.find("_WAIT_FOR_TEMP_ON_WIPE_TOWER") != std::string::npos; };
const auto is_extruding = [](const std::string& l) {
if (l.rfind("G1 ", 0) != 0)
return false;
const size_t e = l.find(" E");
return e != std::string::npos && l.find_first_of("XY") != std::string::npos && l[e + 2] != '-';
};
int checked_blocks = 0;
for (size_t i = 0; i < lines.size(); ++i) {
if (lines[i].find("; CP TOOLCHANGE START") == std::string::npos)
continue;
size_t block_end = i;
while (block_end < lines.size() && lines[block_end].find("; CP TOOLCHANGE END") == std::string::npos)
++block_end;
size_t tool_line = block_end;
for (size_t j = i; j < block_end; ++j)
if (is_tool_line(lines[j])) { tool_line = j; break; }
if (tool_line == block_end)
continue; // final unload block, no toolchange
++checked_blocks;
// Where the incoming tool's target temperature is raised, relative to its Tn.
const int new_tool = std::stoi(lines[tool_line].substr(1));
size_t preheat = tool_line, restore = block_end;
for (size_t j = i; j < tool_line; ++j)
if (is_m104_for_tool(lines[j], new_tool)) { preheat = j; break; }
for (size_t j = tool_line + 1; j < block_end; ++j)
if (is_m104_for_tool(lines[j], new_tool)) { restore = j; break; }
size_t tagged_wait = block_end, untagged_m109 = block_end, first_extrusion = block_end;
for (size_t j = tool_line + 1; j < block_end; ++j) {
if (is_m109_line(lines[j]) && tagged_wait == block_end && is_tagged_wait(lines[j]))
tagged_wait = j;
if (is_m109_line(lines[j]) && untagged_m109 == block_end && !is_tagged_wait(lines[j]))
untagged_m109 = j;
if (first_extrusion == block_end && is_extruding(lines[j]))
first_extrusion = j;
}
INFO("toolchange block at line " << i + 1);
if (wait_on_tower) {
// The only blocking wait is the tagged one, parked beside the tower before the purge.
REQUIRE(tagged_wait < block_end);
CHECK(untagged_m109 == block_end);
// The target is raised ahead of the toolchange, so the incoming tool heats up
// while it is picked up, and nothing sets it again afterwards.
CHECK(preheat < tool_line);
CHECK(restore == block_end);
REQUIRE(first_extrusion < block_end);
CHECK(tagged_wait < first_extrusion);
// The travel preceding the wait parks outside the tower footprint. The tower
// auto-sizes, so derive its extent from the purge extrusions of this block.
size_t stop_line = block_end;
for (size_t j = tagged_wait; j-- > tool_line;)
if (lines[j].rfind("G1 ", 0) == 0 && lines[j].find('X') != std::string::npos) { stop_line = j; break; }
REQUIRE(stop_line < block_end);
const double stop_x = std::stod(lines[stop_line].substr(lines[stop_line].find('X') + 1));
double purge_min_x = std::numeric_limits<double>::max(), purge_max_x = std::numeric_limits<double>::lowest();
for (size_t j = tagged_wait; j < block_end; ++j) {
const size_t x_pos = lines[j].find('X');
if (!is_extruding(lines[j]) || x_pos == std::string::npos)
continue;
const double x = std::stod(lines[j].substr(x_pos + 1));
purge_min_x = std::min(purge_min_x, x);
purge_max_x = std::max(purge_max_x, x);
}
REQUIRE(purge_min_x <= purge_max_x);
INFO("stop travel: " << lines[stop_line] << " purge x range: " << purge_min_x << ".." << purge_max_x);
const bool beside_tower = stop_x < purge_min_x - 0.5 || stop_x > purge_max_x + 0.5;
CHECK(beside_tower);
} else {
// Stock behavior: the blocking wait follows the toolchange command directly, and
// nothing raises the incoming tool's target before it.
REQUIRE(untagged_m109 < block_end);
CHECK(tagged_wait == block_end);
CHECK(preheat == tool_line);
if (first_extrusion < block_end)
CHECK(untagged_m109 < first_extrusion);
}
i = block_end;
}
REQUIRE(checked_blocks > 0);
if (!wait_on_tower)
CHECK(gcode.find("_WAIT_FOR_TEMP_ON_WIPE_TOWER") == std::string::npos);
}
}
// Priming runs before the first layer is set up, so set_extruder sees no layer at all: its
// on_first_layer() test is false and print_z is the initial layer height rather than 0. The
// tower nonetheless blocks on the first layer temperature there, so the pre-heat raised ahead
// of each priming Tn has to name that same temperature — pre-heating to the "other layers"
// value instead leaves the tagged M109 asking the firmware to cool back down before the
// priming lines are extruded.
TEST_CASE("Wipe tower priming pre-heats to the first layer temperature", "[MultiFilament]")
{
const std::string gcode = slice_with_object_overrides(
{ cube(20), cube(20) },
multifilament_config(2, {
{ "nozzle_diameter", "0.4,0.4" },
{ "printer_extruder_id", "1,2" },
{ "printer_extruder_variant", "Direct Drive Standard,Direct Drive Standard" },
{ "extruder_printable_height", "0,0" },
{ "single_extruder_multi_material", 0 },
{ "single_extruder_multi_material_priming", 1 },
{ "enable_prime_tower", 1 },
{ "prime_tower_width", 35 },
{ "wipe_tower_x", "50" },
{ "wipe_tower_y", "50" },
{ "preheat_time", 0 }, // see the wait test above
// Distinct enough that picking the wrong one is unambiguous.
{ "nozzle_temperature_initial_layer", "215,215" },
{ "nozzle_temperature", "240,240" },
{ "wait_for_temp_on_wipe_tower", 1 },
}),
{ { { "extruder", 1 } }, { { "extruder", 2 } } });
std::vector<std::string> lines;
std::istringstream gcode_stream(gcode);
for (std::string line; std::getline(gcode_stream, line);)
lines.emplace_back(std::move(line));
// Temperature of an M104/M109, or -1 when the line is neither.
const auto temp_of = [](const std::string& l) {
if (l.rfind("M104", 0) != 0 && l.rfind("M109", 0) != 0)
return -1;
const size_t s = l.find('S');
return s == std::string::npos ? -1 : std::stoi(l.substr(s + 1));
};
size_t start = lines.size(), end = lines.size();
for (size_t i = 0; i < lines.size(); ++i) {
if (start == lines.size() && lines[i].find("; CP PRIMING START") != std::string::npos)
start = i;
else if (start < lines.size() && lines[i].find("; CP PRIMING END") != std::string::npos) {
end = i;
break;
}
}
REQUIRE(start < end);
int checked_waits = 0;
for (size_t i = start; i < end; ++i) {
if (lines[i].find("_WAIT_FOR_TEMP_ON_WIPE_TOWER") == std::string::npos)
continue;
++checked_waits;
INFO("priming wait at line " << i + 1 << ": " << lines[i]);
CHECK(temp_of(lines[i]) == 215); // the tower waits on the first layer temperature
// The most recent set-temperature before it is the pre-heat, and must agree with it.
int preheat = -1;
for (size_t j = i; j-- > start;)
if ((preheat = temp_of(lines[j])) != -1)
break;
CHECK(preheat == 215);
}
REQUIRE(checked_waits > 0); // the feature under test is active
}
// The temperature-wait park picks its side of the tower by testing bed containment with the
// tower position at psWipeTower generation time, while WipeTowerIntegration shifts the cached
// moves by the CURRENT position at export. Moving the tower normally invalidates only
// psSkirtBrim (tower gcode is position-independent), but the park makes it bed-relative, so a
// GUI-style move-and-reslice on the same Print must regenerate the tower — otherwise the stale
// park prints outside the bed. Contract: every tagged wait parks inside the printable area.
TEST_CASE("Wipe tower temperature-wait park is regenerated when the tower moves", "[MultiFilament]")
{
// Two objects, one filament each: a toolchange (and a tagged wait) on every layer, like
// the wait test above — but on a single-extruder machine profile: the synthetic
// dual-extruder keys would drag in the extruder-variant expansion, which is not
// idempotent on the default machine profile and would pollute the re-apply diff below.
// Rectangle wall and no brim keep the tower-local footprint inside [0, 35], so the park
// sits at the generator's 2mm side gap: local -2 or 37.
DynamicPrintConfig config = multifilament_config(2, {
{ "single_extruder_multi_material", 0 },
{ "enable_prime_tower", 1 },
{ "prime_tower_width", 35 },
{ "wipe_tower_wall_type", "rectangle" }, // the default rib bulges past the width
{ "prime_tower_brim_width", 0 }, // the default 3 widens the first-layer envelope
{ "printable_area", "0x0,200x0,200x200,0x200" },
{ "wipe_tower_x", "0" },
{ "wipe_tower_y", "50" },
{ "ooze_prevention", 1 },
{ "standby_temperature_delta", -40 },
{ "wait_for_temp_on_wipe_tower", 1 },
});
// init_print force-sets this on its own copy; set it here too so the re-apply below
// diffs in wipe_tower_x ONLY — the exact GUI increment under test.
config.set_key_value("gcode_comments", new ConfigOptionBool(true));
Print print;
Model model;
const std::vector<std::vector<ConfigBase::SetDeserializeItem>> overrides{
{ { "extruder", 1 } }, { { "extruder", 2 } } }; // object-level, see the wait test above
init_print(std::vector<TriangleMesh>{ cube(20), cube(20) }, print, model, config, &overrides);
const std::string at_edge = gcode(print);
const std::vector<double> at_edge_parks = wait_park_xs(at_edge);
REQUIRE(!at_edge_parks.empty()); // the feature under test is active
for (double x : at_edge_parks) {
INFO("wait park X " << x << " with the tower at x=0 on a 200mm bed");
CHECK(x >= -0.05);
CHECK(x <= 200.05);
}
REQUIRE(print.is_step_done(psWipeTower));
// Move the tower to the right bed edge (164 + 35 = 199 keeps the body printable) and
// re-apply on the SAME Print, as the GUI does. Base the re-apply on the print's own
// resolved config so the diff is wipe_tower_x alone — re-applying the caller's config
// would also diff the apply-time extruder normalization write-backs, and those keys
// regenerate the tower for the wrong reason. The cached right-side park would export
// at 164 + 37 = 201, off the bed; regeneration clamps the park against the bed edge.
// Assemble the moved config exactly the way init_print assembled the first one — the
// apply-time normalization is only idempotent when both applies start from the same
// derivation, and any stray diff key would regenerate the tower for the wrong reason.
config.set_deserialize_strict({ { "wipe_tower_x", "164" } });
DynamicPrintConfig moved_config = DynamicPrintConfig::full_print_config();
moved_config.apply(config);
moved_config.set_key_value("gcode_comments", new ConfigOptionBool(true));
print.apply(model, moved_config);
CHECK_FALSE(print.is_step_done(psWipeTower)); // the move must re-generate the tower
const std::string moved = gcode(print);
const std::vector<double> moved_parks = wait_park_xs(moved);
REQUIRE(!moved_parks.empty()); // the waits must survive the re-slice
for (double x : moved_parks) {
INFO("wait park X " << x << " with the tower at x=164 on a 200mm bed");
CHECK(x >= -0.05);
CHECK(x <= 200.05);
}
}
// The flag-off half of the three tests above. Every site wait_for_temp_on_wipe_tower touches is
// guarded -- set_extruder's pre-toolchange preheat block and its post_toolchange skip,
// toolchange_Change's park, the interface-temp guard in WipeTower2::tool_change, and append_tcr2's
// tagged-M109 filter -- so with the option off the feature has to be inert and temperature emission
// has to stay exactly as it was before the option existed. That is pinned against a trace captured
// from main rather than against expectations written from the current code, which would be
// re-derived from the very code they are meant to guard.
//
// Note what main emits here, since it is easy to misread as a missing wait: with preheat_time set,
// the toolchange carries no blocking M109 at all. GCodeProcessor's backtrace moves the heat-up to
// an M104 preheat_time seconds earlier and demotes the in-place command, which is the entire point
// of preheating. The lead times below are what pin that placement.
TEST_CASE("Toolchange temperature commands are unchanged when the wipe tower wait is off", "[MultiFilament][Regression]")
{
// 20x20x5 cubes at the default 0.2mm layer height are 25 layers, one filament each, so there is
// a toolchange -- and a preheat ahead of it -- on every layer.
const std::string gcode = slice_with_object_overrides(
{ make_cube(20., 20., 5.), make_cube(20., 20., 5.) },
multifilament_config(2, {
{ "nozzle_diameter", "0.4,0.4" },
{ "printer_extruder_id", "1,2" },
{ "printer_extruder_variant", "Direct Drive Standard,Direct Drive Standard" },
{ "extruder_printable_height", "0,0" },
{ "single_extruder_multi_material", 0 },
{ "single_extruder_multi_material_priming", 1 }, // reaches toolchange_Change's priming path
{ "enable_prime_tower", 1 },
{ "prime_tower_width", 35 },
{ "wipe_tower_x", "50" },
{ "wipe_tower_y", "50" },
// GCodeProcessor::apply_config enables the preheat backtrace on
// ooze_prevention && preheat_time > 0 && !SEMM && filaments > 1. That is what puts an
// M104 preheat_time seconds ahead of every Tn, and it also gives set_extruder's
// standby/restore pair, which the option demotes and moves when it is on.
{ "ooze_prevention", 1 },
{ "standby_temperature_delta", -40 },
{ "preheat_time", 30 },
{ "preheat_steps", 1 },
// enable_tower_interface_features is deliberately left off: the interface temperature
// is observable only through a change_filament_gcode template that reads
// new_filament_temp, since append_tcr2 strips the tower's own M109 for it, and the
// default template here has none. The option's interface-temp guard is covered by the
// enabled-path tests above instead.
//
// Distinct enough that a wrong pick between the two is unambiguous in the trace.
{ "nozzle_temperature_initial_layer", "215,215" },
{ "nozzle_temperature", "240,240" },
{ "wait_for_temp_on_wipe_tower", 0 },
}),
// Object-level, so the used-filament count that gates the prime tower is derived from it.
{ { { "extruder", 1 } }, { { "extruder", 2 } } });
const std::vector<std::string> trace = temperature_trace(gcode);
REQUIRE(trace.size() > 1);
CHECK(gcode.find("_WAIT_FOR_TEMP_ON_WIPE_TOWER") == std::string::npos);
const std::string golden_path = std::string(TEST_DATA_DIR PATH_SEPARATOR "wipe_tower_temperature_trace_main.txt");
// Regenerate by appending this test and its helpers to the same file on main (dropping the
// wait_for_temp_on_wipe_tower key, which main's config does not know), rebuilding
// fff_print_tests there, running it with ORCA_UPDATE_WIPE_TOWER_TEMP_TRACE=1, copying the file
// it writes back here, and filling in the commit it was captured from.
if (std::getenv("ORCA_UPDATE_WIPE_TOWER_TEMP_TRACE") != nullptr) {
std::ofstream out(golden_path);
REQUIRE(out.good());
out << "# Temperature and tool-change commands of a wait_for_temp_on_wipe_tower-off slice,\n"
"# captured from the main branch at <fill in the commit>. Regeneration is described\n"
"# at the test that reads this file: \"Toolchange temperature commands are unchanged\n"
"# when the wipe tower wait is off\" in tests/fff_print/test_multifilament.cpp.\n";
for (const std::string& entry : trace)
out << entry << "\n";
WARN("Rewrote " << golden_path << " from this run; it no longer reflects main.");
return;
}
std::vector<std::string> golden;
{
std::ifstream in(golden_path);
INFO("reading " << golden_path);
REQUIRE(in.good());
for (std::string line; std::getline(in, line);) {
if (!line.empty() && line.back() == '\r')
line.pop_back();
if (!line.empty() && line[0] != '#')
golden.push_back(std::move(line));
}
}
REQUIRE(!golden.empty());
// Reported separately from the golden comparison below: it is a different failure.
for (size_t i = 0; i < trace.size(); ++i) {
const auto entry = parse_trace_entry(trace[i]);
if (time_is_rounded_lead(entry))
continue;
INFO("at trace entry " << i + 1);
INFO(" " << trace[i]);
FAIL("\"time:\" is not its entry's \"lead\" rounded to a whole second");
}
const size_t common = std::min(trace.size(), golden.size());
for (size_t i = 0; i < common; ++i) {
if (trace_entries_match(trace[i], golden[i]))
continue;
// Report the first difference only: past it the two are misaligned and every later entry
// would be reported as a difference too.
INFO("first difference at trace entry " << i + 1);
INFO(" main: " << golden[i]);
INFO(" branch: " << trace[i]);
FAIL("temperature emission differs from main with wait_for_temp_on_wipe_tower off");
}
CHECK(trace.size() == golden.size());
}
// max_layer_height can be shorter than the extruder count (normalization sizes it to the
// filament count under single_extruder_multi_material). calc_max_layer_height() in ToolOrdering
// indexed it per-nozzle and read past the end. Shortened directly here to isolate that read;
@@ -104,3 +714,4 @@ TEST_CASE("Multi-extruder slice stays in bounds with a short max_layer_height",
init_and_process_print({ cube(20) }, print, config);
REQUIRE_FALSE(print.objects().front()->layers().empty());
}
+257
View File
@@ -0,0 +1,257 @@
#include <catch2/catch_all.hpp>
#include "libslic3r/ExtrusionEntity.hpp"
#include "libslic3r/ExtrusionEntityCollection.hpp"
#include "libslic3r/Layer.hpp"
#include "libslic3r/Print.hpp"
#include <algorithm>
#include <cmath>
#include <vector>
#include "test_helpers.hpp"
using namespace Slic3r;
using namespace Slic3r::Test;
namespace {
// The layer at this Z is the last one of the base, so its top surface is the ledge.
const double ledge_z = 5.0;
// The first layer, at initial_layer_print_height.
const double first_layer_z = 0.2;
// TestMesh::step scaled 3x in X/Y: a 60x60x5 base carrying a 54x54 column up to z=10, leaving a 3mm
// top ledge around a feature that keeps rising. That is the geometry both only_one_wall_top and the
// top surface expansion act on. The ledge has to stay wider than the wall band plus two top-infill
// lines, or the expansion discards it as a sliver and the tests below assert nothing.
TriangleMesh step_with_ledge()
{
TriangleMesh m = Slic3r::Test::mesh(TestMesh::step);
m.scale(Vec3f(3.f, 3.f, 1.f));
return m;
}
// Every setting the assertions depend on, so none of them rests on a default.
DynamicPrintConfig base_config(const char *wall_generator)
{
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
config.set_deserialize_strict({
{ "wall_generator", wall_generator },
{ "layer_height", 0.2 }, // puts a layer boundary exactly on ledge_z
{ "initial_layer_print_height", 0.2 },
{ "wall_loops", 3 },
{ "sparse_infill_density", "15%" },
{ "top_shell_layers", 3 },
{ "bottom_shell_layers", 3 },
{ "top_surface_density", "100%" },
{ "top_surface_expansion", 0.0 },
{ "only_one_wall_top", false },
{ "only_one_wall_first_layer", false },
// Do not let the one-wall threshold discard the 3mm ledge before the feature sees it.
{ "min_width_top_surface", 0.0 },
});
return config;
}
double collection_length(const ExtrusionEntityCollection &coll)
{
double len = 0.;
for (const ExtrusionEntity *entity : coll.flatten().entities)
if (! entity->is_collection())
len += entity->length();
return len;
}
// Extruded length per layer. Two slices are compared through this rather than through their G-code,
// because the G-code carries a config block that differs whenever any setting differs.
struct SliceLengths {
std::vector<double> perimeters;
std::vector<double> fills;
};
SliceLengths slice_lengths(const Print &print)
{
SliceLengths out;
for (const Layer *layer : print.objects().front()->layers()) {
double perimeters = 0., fills = 0.;
for (const LayerRegion *region : layer->regions()) {
perimeters += collection_length(region->perimeters);
fills += collection_length(region->fills);
}
out.perimeters.push_back(perimeters);
out.fills.push_back(fills);
}
return out;
}
double perimeter_length_at(const Print &print, double print_z)
{
for (const Layer *layer : print.objects().front()->layers())
if (std::abs(layer->print_z - print_z) < 1e-4) {
double len = 0.;
for (const LayerRegion *region : layer->regions())
len += collection_length(region->perimeters);
return len;
}
return 0.;
}
// Largest per-layer difference between two series; a negative result means they are not comparable.
double max_difference(const std::vector<double> &a, const std::vector<double> &b)
{
if (a.size() != b.size() || a.empty())
return -1.;
double worst = 0.;
for (size_t i = 0; i < a.size(); ++ i)
worst = std::max(worst, std::abs(a[i] - b[i]));
return worst;
}
} // namespace
// The expansion only retypes area as top solid infill, so it can do nothing where there is no top
// fill to begin with: zero top shell layers retypes the top surfaces as internal, and a top surface
// density of 0% leaves the top layer with walls only. The last section is the control - the same
// expansion on the same model does change the slice once a top fill exists - without which the two
// equality checks above it would hold for an unrelated reason.
TEST_CASE("Top surface expansion only acts where there is a top fill", "[Perimeters]")
{
const char *wall_generator = GENERATE("classic", "arachne");
CAPTURE(wall_generator);
auto lengths_for = [wall_generator](int top_shell_layers, const char *top_surface_density, double expansion) {
DynamicPrintConfig config = base_config(wall_generator);
config.set_deserialize_strict({
{ "top_shell_layers", top_shell_layers },
{ "top_surface_density", top_surface_density },
{ "top_surface_expansion", expansion },
});
Print print;
init_and_process_print({ step_with_ledge() }, print, config);
REQUIRE_FALSE(print.objects().empty());
return slice_lengths(print);
};
SECTION("no top shell layers") {
const SliceLengths off = lengths_for(0, "100%", 0.0);
const SliceLengths on = lengths_for(0, "100%", 2.0);
REQUIRE(off.perimeters.size() == on.perimeters.size());
CHECK_THAT(max_difference(off.perimeters, on.perimeters), Catch::Matchers::WithinAbs(0., 1.0));
CHECK_THAT(max_difference(off.fills, on.fills), Catch::Matchers::WithinAbs(0., 1.0));
}
SECTION("zero top surface density") {
const SliceLengths off = lengths_for(3, "0%", 0.0);
const SliceLengths on = lengths_for(3, "0%", 2.0);
REQUIRE(off.perimeters.size() == on.perimeters.size());
CHECK_THAT(max_difference(off.perimeters, on.perimeters), Catch::Matchers::WithinAbs(0., 1.0));
CHECK_THAT(max_difference(off.fills, on.fills), Catch::Matchers::WithinAbs(0., 1.0));
}
SECTION("with a top fill the same expansion does change the slice") {
const SliceLengths off = lengths_for(3, "100%", 0.0);
const SliceLengths on = lengths_for(3, "100%", 2.0);
REQUIRE(off.fills.size() == on.fills.size());
CHECK(max_difference(off.fills, on.fills) > scale_(0.5));
}
}
// With no top shell the top surfaces are retyped as internal, so the top surface density has nothing
// left to control: there is no top fill, and only_one_wall_top - the one route from the density to the
// perimeters - is itself switched off for want of a top surface to act on.
TEST_CASE("Top surface density does not affect a slice without a top shell", "[Perimeters]")
{
const char *wall_generator = GENERATE("classic", "arachne");
CAPTURE(wall_generator);
auto lengths_for = [wall_generator](const char *top_surface_density) {
DynamicPrintConfig config = base_config(wall_generator);
config.set_deserialize_strict({
{ "top_shell_layers", 0 },
{ "only_one_wall_top", true },
{ "top_surface_density", top_surface_density },
});
Print print;
init_and_process_print({ step_with_ledge() }, print, config);
REQUIRE_FALSE(print.objects().empty());
return slice_lengths(print);
};
const SliceLengths solid = lengths_for("100%");
const SliceLengths none = lengths_for("0%");
REQUIRE(solid.perimeters.size() == none.perimeters.size());
CHECK_THAT(max_difference(solid.perimeters, none.perimeters), Catch::Matchers::WithinAbs(0., 1.0));
CHECK_THAT(max_difference(solid.fills, none.fills), Catch::Matchers::WithinAbs(0., 1.0));
}
// On the ledge layer the inner walls are given up to the top fill, so that layer loses wall length.
// The handover needs a top fill that reaches the freed space: at a top surface density of 0% there is
// no top fill at all, and without top_surface_expansion the fill never grows over the walls. Either
// way the feature still runs, through the original generation, which keeps the inner walls up to the
// top boundary - putting that layer back between the plain and the one-wall slice.
TEST_CASE("Only one wall on top surfaces drops inner walls only where a top fill replaces them", "[Perimeters]")
{
const char *wall_generator = GENERATE("classic", "arachne");
CAPTURE(wall_generator);
auto ledge_perimeters_for = [wall_generator](bool only_one_wall_top, const char *top_surface_density, double expansion) {
DynamicPrintConfig config = base_config(wall_generator);
config.set_deserialize_strict({
{ "only_one_wall_top", only_one_wall_top },
{ "top_surface_density", top_surface_density },
{ "top_surface_expansion", expansion },
});
Print print;
init_and_process_print({ step_with_ledge() }, print, config);
REQUIRE_FALSE(print.objects().empty());
return perimeter_length_at(print, ledge_z);
};
const double plain = ledge_perimeters_for(false, "100%", 2.0);
const double one_wall = ledge_perimeters_for(true, "100%", 2.0);
const double one_wall_no_fill = ledge_perimeters_for(true, "0%", 2.0);
const double one_wall_no_expand = ledge_perimeters_for(true, "100%", 0.0);
REQUIRE(plain > 0.);
CHECK(one_wall < plain);
// Both fall back to the original generation, which cuts the walls back to the top boundary but not past it.
CHECK(one_wall_no_fill > one_wall);
CHECK(one_wall_no_fill < plain);
CHECK(one_wall_no_expand > one_wall);
CHECK(one_wall_no_expand < plain);
}
// The bottom counterpart: the first layer is thinned to a single wall only where a bottom shell fills the
// space behind it. With no bottom shell layers the bottom surfaces are retyped as internal, so that wall
// would ring sparse infill on the bed - the option is switched off instead, and the GUI hides it in that
// state so a profile that left it enabled cannot act behind a hidden checkbox.
TEST_CASE("Only one wall on the first layer needs a bottom shell", "[Perimeters]")
{
const char *wall_generator = GENERATE("classic", "arachne");
CAPTURE(wall_generator);
auto first_layer_perimeters_for = [wall_generator](bool only_one_wall_first_layer, int bottom_shell_layers) {
DynamicPrintConfig config = base_config(wall_generator);
config.set_deserialize_strict({
{ "only_one_wall_first_layer", only_one_wall_first_layer },
{ "bottom_shell_layers", bottom_shell_layers },
});
Print print;
init_and_process_print({ step_with_ledge() }, print, config);
REQUIRE_FALSE(print.objects().empty());
return perimeter_length_at(print, first_layer_z);
};
const double plain = first_layer_perimeters_for(false, 3);
const double one_wall = first_layer_perimeters_for(true, 3);
// Both at zero bottom shell layers, so everything else that setting changes cancels out between them.
const double plain_no_shell = first_layer_perimeters_for(false, 0);
const double one_wall_no_shell = first_layer_perimeters_for(true, 0);
REQUIRE(plain > 0.);
CHECK(one_wall < plain);
// No bottom shell: the option is inert, down to the same walls an unchecked box gives.
CHECK_THAT(one_wall_no_shell, Catch::Matchers::WithinAbs(plain_no_shell, 1.0));
}
+16
View File
@@ -338,6 +338,22 @@ TEST_CASE("G-code lists the resolved extrusion-width settings", "[Print]")
CHECK(with_first_layer.find("; first layer extrusion width") != std::string::npos);
}
// gcode_skip_config_block suppresses the resolved-settings block while leaving the
// header and executable blocks intact.
TEST_CASE("gcode_skip_config_block omits the resolved-settings comment block", "[Print]")
{
const std::string gcode = slice({ cube(20) }, {
{ "gcode_skip_config_block", true },
{ "gcode_comments", true },
});
CHECK(gcode.find("; CONFIG_BLOCK_START") == std::string::npos);
CHECK(gcode.find("; CONFIG_BLOCK_END") == std::string::npos);
CHECK(gcode.find("; layer_height =") == std::string::npos);
CHECK(gcode.find("; fill_density =") == std::string::npos);
CHECK(gcode.find("; HEADER_BLOCK_START") != std::string::npos);
CHECK(gcode.find("; EXECUTABLE_BLOCK_START") != std::string::npos);
}
// Custom G-code templates substitute placeholders during export.
TEST_CASE("Custom G-code placeholders are substituted", "[Print]")
{
+168
View File
@@ -4,6 +4,7 @@
#include "libslic3r/Config.hpp"
#include "libslic3r/Geometry.hpp"
#include "libslic3r/Geometry/ConvexHull.hpp"
#include "libslic3r/Layer.hpp"
#include <boost/algorithm/string.hpp>
@@ -32,6 +33,30 @@ static size_t brim_loop_count(Print &print)
return n;
}
static bool brim_enters_first_layer_hole(Print &print)
{
const PrintObject *object = print.get_object(0);
Polygons holes;
for (const ExPolygon &slice : object->layers().front()->lslices)
holes.insert(holes.end(), slice.holes.begin(), slice.holes.end());
const Vec3d plate_origin = print.get_plate_origin();
Point shift = object->instances().front().shift_without_plate_offset();
shift += Point(scaled(plate_origin.x()), scaled(plate_origin.y()));
for (Polygon &hole : holes)
hole.translate(shift);
for (const auto &kv : print.get_brimMap()) {
Polylines brim_paths;
kv.second.collect_polylines(brim_paths);
for (const Polyline &path : brim_paths)
for (const Point &point : path.points)
if (contains(holes, point, false))
return true;
}
return false;
}
// The span is skirt_height layers, or every layer when a draft shield is on (forced even at
// height 0); per-object skirts are rejected in By object printing (no room between objects).
TEST_CASE("Skirt is emitted once per layer it spans", "[SkirtBrim]")
@@ -153,6 +178,24 @@ TEST_CASE("Object brims are generated per instance", "[SkirtBrim]")
}
}
TEST_CASE("Uncombined neighboring brims precede their respective objects", "[SkirtBrim]")
{
Print print;
Model model;
place_two_cubes_apart(0, {
{ "skirt_loops", 0 },
{ "brim_type", "outer_only" },
{ "brim_width", 5 },
{ "combine_brims", 0 },
}, print, model);
print.process();
REQUIRE(print.skirt_brim_groups().size() == 1);
REQUIRE(print.skirt_brim_groups().front().brims.size() == 2);
CHECK(role_sequence(gcode(print), { "brim", "perimeter" }) ==
std::vector<std::string>{ "brim", "perimeter", "brim", "perimeter" });
}
TEST_CASE("Combine brims merges neighboring object instances", "[SkirtBrim]")
{
Print print;
@@ -207,6 +250,131 @@ TEST_CASE("Brim ears appear only at corners within the max angle", "[SkirtBrim]"
}
}
TEST_CASE("Outer-only brim ears stay out of model holes", "[SkirtBrim]")
{
const bool outer_only = GENERATE(false, true);
DYNAMIC_SECTION("brim_ears_outer_only=" << outer_only) {
Print print;
init_and_process_print({ TestMesh::cube_with_concave_hole }, print, {
{ "skirt_loops", 0 },
{ "brim_type", "brim_ears" },
{ "brim_width", 2 },
{ "brim_ears_max_angle", 125 },
{ "brim_ears_detection_length", 0 },
{ "brim_ears_outer_only", outer_only },
{ "initial_layer_line_width", 0.5 },
});
REQUIRE(brim_loop_count(print) > 0);
CHECK(brim_enters_first_layer_hole(print) != outer_only);
}
}
TEST_CASE("Painted brim ear radius controls sliced size", "[SkirtBrim]")
{
constexpr double ear_radius = 10.0;
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
config.set_deserialize_strict({
{ "skirt_loops", 0 },
{ "brim_type", "painted" },
{ "brim_width", 15 },
{ "brim_object_gap", 0.1 },
{ "brim_ears_outer_only", true },
{ "initial_layer_line_width", 0.5 },
});
Print print;
Model model;
init_print({ cube(20) }, print, model, config);
print.process();
const PrintObject *object = print.get_object(0);
REQUIRE(!object->layers().front()->lslices.empty());
const Point ear_center = object->layers().front()->lslices.front().contour.points.front();
Transform3d model_transform = model.objects.front()->instances.front()->get_transformation().get_matrix_no_offset();
const Point &center_offset = object->center_offset();
model_transform = model_transform.pretranslate(
Vec3d(-unscale<double>(center_offset.x()), -unscale<double>(center_offset.y()), 0));
Vec3d model_pos = model_transform.inverse() *
Vec3d(unscale<double>(ear_center.x()), unscale<double>(ear_center.y()), 0);
model_pos.z() = model.objects.front()->raw_mesh_bounding_box().min.z() - 0.0001;
model.objects.front()->brim_points = {
BrimPoint(model_pos.cast<float>(), float(ear_radius)),
};
print.apply(model, config);
print.process();
const Vec3d plate_origin = print.get_plate_origin();
Point path_center = ear_center + object->instances().front().shift_without_plate_offset();
path_center += Point(scaled(plate_origin.x()), scaled(plate_origin.y()));
double max_path_radius = 0.0;
for (const auto &kv : print.get_brimMap()) {
Polylines brim_paths;
kv.second.collect_polylines(brim_paths);
for (const Polyline &path : brim_paths)
for (const Point &point : path.points)
max_path_radius = std::max(max_path_radius, unscale<double>((point - path_center).cast<double>().norm()));
}
REQUIRE(max_path_radius > 0.0);
INFO("Outermost painted-ear path radius: " << max_path_radius << " mm");
CHECK(max_path_radius > ear_radius - 0.5);
CHECK(max_path_radius < ear_radius);
}
TEST_CASE("Outer-only painted brim ears stay out of model holes", "[SkirtBrim]")
{
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
config.set_deserialize_strict({
{ "skirt_loops", 0 },
{ "brim_type", "painted" },
{ "brim_ears_outer_only", true },
{ "initial_layer_line_width", 0.5 },
});
Print print;
Model model;
init_print({ TestMesh::cube_with_concave_hole }, print, model, config);
// Slice once to obtain exact outer and inner contour points in print
// coordinates, then express them in the model coordinates painted ears store.
print.process();
const PrintObject *object = print.get_object(0);
REQUIRE(!object->layers().front()->lslices.empty());
REQUIRE(!object->layers().front()->lslices.front().holes.empty());
Transform3d model_transform = model.objects.front()->instances.front()->get_transformation().get_matrix_no_offset();
const Point &center_offset = object->center_offset();
model_transform = model_transform.pretranslate(
Vec3d(-unscale<double>(center_offset.x()), -unscale<double>(center_offset.y()), 0));
const double bottom_z = model.objects.front()->raw_mesh_bounding_box().min.z() - 0.0001;
auto painted_point = [&model_transform, bottom_z](const Point &point) {
Vec3d model_pos = model_transform.inverse() *
Vec3d(unscale<double>(point.x()), unscale<double>(point.y()), 0);
model_pos.z() = bottom_z;
return BrimPoint(model_pos.cast<float>(), 3.f);
};
const ExPolygon &first_slice = object->layers().front()->lslices.front();
Polygon inner_contour = first_slice.holes.front();
inner_contour.reverse();
const Points inner_ear_points = inner_contour.concave_points(55. * PI / 180.);
REQUIRE(!inner_ear_points.empty());
model.objects.front()->brim_points = {
painted_point(first_slice.contour.points.front()),
painted_point(inner_ear_points.front()),
};
print.apply(model, config);
print.process();
REQUIRE(brim_loop_count(print) > 0);
CHECK_FALSE(brim_enters_first_layer_hole(print));
}
SCENARIO("Skirt has the configured number of loops", "[SkirtBrim]") {
GIVEN("20mm cube and default config") {
WHEN("skirt_loops is set to 2") {
+15 -1
View File
@@ -218,10 +218,24 @@ TEST_CASE("Changing slicing_pipeline_plugin invalidates posSlice", "[slicing_pip
CHECK_FALSE(print.objects().front()->is_step_done(posSlice)); // re-slice required
}
// Editing a slicing plugin's config (print_plugin_config_overrides) must re-run posSlice, where the
// plugin transforms each layer's geometry; otherwise the cached slice keeps the old config's result.
TEST_CASE("Changing print_plugin_config_overrides invalidates posSlice", "[slicing_pipeline]") {
Slic3r::Print print; Slic3r::Model model;
auto config = Slic3r::DynamicPrintConfig::full_print_config();
init_print({cube(20)}, print, model, config);
print.process();
REQUIRE(print.objects().front()->is_step_done(posSlice));
config.set_key_value("print_plugin_config_overrides",
new Slic3r::ConfigOptionString("[{\"type\":\"slicing-pipeline\",\"name\":\"Twistify\",\"config\":{\"twist_deg_per_mm\":2.0}}]"));
print.apply(model, config);
CHECK_FALSE(print.objects().front()->is_step_done(posSlice)); // re-slice required
}
#include <catch2/matchers/catch_matchers_floating_point.hpp>
// A similarity transform (rotate + uniform scale) applied to slices at Step.posSlice, matching
// what the Twistify sample (sandboxes/orca_twistify_plugin_example_any.py) does. This C++ analogue
// what the Twistify plugin (sandboxes/orca_twistify_plugin_any.py) does. This C++ analogue
// rotates every region's slices a fixed 45 deg about the object's base-footprint center -- the same
// seam and cascade the sample drives through the slices.set() + Layer::make_slices() path. Two
// end-to-end invariants after process() confirm the approach:
+184
View File
@@ -0,0 +1,184 @@
#include <catch2/catch_all.hpp>
#include <string>
#include <vector>
#include "libslic3r/BoundingBox.hpp"
#include "libslic3r/ClipperUtils.hpp"
#include "libslic3r/GCode/GCodeProcessor.hpp"
#include "libslic3r/GCode/WipeTower.hpp"
#include "libslic3r/PrintConfig.hpp"
#include "test_helpers.hpp"
using namespace Slic3r;
using namespace Slic3r::Test;
// Taken from the config enum map rather than hand-listed, so a flavor added to GCodeFlavor later
// is covered here without editing this file.
static std::vector<GCodeFlavor> non_klipper_flavors()
{
std::vector<GCodeFlavor> flavors;
for (const auto &[name, value] : ConfigOptionEnum<GCodeFlavor>::get_enum_values())
if (GCodeFlavor(value) != gcfKlipper)
flavors.push_back(GCodeFlavor(value));
return flavors;
}
static std::string flavor_name(GCodeFlavor flavor)
{
return ConfigOptionEnum<GCodeFlavor>::get_enum_names()[int(flavor)];
}
TEST_CASE("Klipper flushes the wipe tower planner queue with M400", "[WipeTower]")
{
CHECK(std::string(flush_planner_queue_command(gcfKlipper)) == "M400\n");
}
TEST_CASE("Other flavors flush the wipe tower planner queue with a zero dwell", "[WipeTower]")
{
const GCodeFlavor flavor = GENERATE(from_range(non_klipper_flavors()));
INFO("gcode flavor: " << flavor_name(flavor));
CHECK(std::string(flush_planner_queue_command(flavor)) == "G4 S0\n");
}
// 1.5s is exactly representable as a float, so neither form can drift when rounded.
TEST_CASE("Klipper waits in the wipe tower with a millisecond dwell", "[WipeTower]")
{
CHECK(wait_command(gcfKlipper, 1.5f) == "G4 P1500\n");
}
TEST_CASE("Other flavors wait in the wipe tower with a seconds dwell", "[WipeTower]")
{
const GCodeFlavor flavor = GENERATE(from_range(non_klipper_flavors()));
INFO("gcode flavor: " << flavor_name(flavor));
CHECK(wait_command(flavor, 1.5f) == "G4 S1.500\n");
}
// The prime tower is validated against the real printable outline, so the placement clamps have to
// agree with it wherever that outline is not a rectangle. A regular hexagon inscribed in a 200mm
// circle stands in for the shipped delta beds.
TEST_CASE("The wipe tower placement clamp follows a non-rectangular bed outline", "[WipeTower]")
{
const coord_t margin = scaled<coord_t>(1.);
auto square_at = [](double x, double y, double side) {
return BoundingBox(Point::new_scale(x, y), Point::new_scale(x + side, y + side));
};
// Does the footprint, padded by pad, sit inside the outline once the returned move is applied?
auto lands_inside = [](BoundingBox box, const Polygons &bed, const Vec2f &move, coord_t pad) {
box.translate(Point::new_scale(move.x(), move.y()));
return diff(Polygons{box.inflated(pad).polygon()}, bed).empty();
};
const Polygons hex_bed{make_circle_num_segments(scaled<double>(100.), 6)};
const Polygons square_bed{Polygon::new_scale(Pointfs{{0., 0.}, {200., 0.}, {200., 200.}, {0., 200.}})};
SECTION("a rectangular bed is left to the bounding box clamp") {
const Vec2f move = WipeTower::move_box_inside_polygon(square_at(50., 50., 30.), square_bed, margin);
CHECK_THAT(move.x(), Catch::Matchers::WithinAbs(0., 1e-6));
CHECK_THAT(move.y(), Catch::Matchers::WithinAbs(0., 1e-6));
}
// Dragging the tower off one edge may not pull it away from the other, or it would jump out from
// under the cursor instead of sliding along the edge.
SECTION("only the violated axis is clamped") {
const Vec2f move = WipeTower::move_box_inside_polygon(square_at(185., 50., 30.), square_bed, margin);
CHECK_THAT(move.x(), Catch::Matchers::WithinAbs(-16., 1e-6));
CHECK_THAT(move.y(), Catch::Matchers::WithinAbs(0., 1e-6));
}
SECTION("a footprint already inside the outline is left alone") {
const Vec2f move = WipeTower::move_box_inside_polygon(square_at(-15., -15., 30.), hex_bed, margin);
CHECK_THAT(move.x(), Catch::Matchers::WithinAbs(0., 1e-6));
CHECK_THAT(move.y(), Catch::Matchers::WithinAbs(0., 1e-6));
}
SECTION("a footprint in the bounding box corner is pulled onto the bed") {
const BoundingBox box = square_at(55., 50., 30.);
REQUIRE_FALSE(lands_inside(box, hex_bed, Vec2f::Zero(), margin)); // in the bbox, off the hexagon
CHECK(lands_inside(box, hex_bed, WipeTower::move_box_inside_polygon(box, hex_bed, margin), margin));
}
// An unresolved auto brim width reaches the drag clamp as a negative margin. Padding by it would
// shrink the footprint and hand back a position the slice validation still rejects.
SECTION("a negative margin still lands the footprint inside the outline") {
const BoundingBox box = square_at(55., 50., 30.);
const coord_t brim = scaled<coord_t>(-0.5);
CHECK(lands_inside(box, hex_bed, WipeTower::move_box_inside_polygon(box, hex_bed, brim), 0));
}
SECTION("a footprint too large for the bed is left alone") {
const Vec2f move = WipeTower::move_box_inside_polygon(square_at(-200., -200., 400.), hex_bed, margin);
CHECK_THAT(move.x(), Catch::Matchers::WithinAbs(0., 1e-6));
CHECK_THAT(move.y(), Catch::Matchers::WithinAbs(0., 1e-6));
}
}
// The cases above only exercise the helpers in isolation. The one below slices a real
// two-filament print, so it also covers the binding constraint of both changes: that the
// configured `gcode_flavor` reaches the wipe tower writer and lands in the exported G-code.
// The G-code inside each WIPE_TOWER_START/WIPE_TOWER_END pair, concatenated, so an M400 emitted
// outside the tower (e.g. GCodeProcessor's pre-heat injector) cannot create a false match.
static std::string wipe_tower_regions(const std::string &gcode)
{
const std::string &start_tag = GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Wipe_Tower_Start);
const std::string &end_tag = GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Wipe_Tower_End);
std::string regions;
size_t pos = 0;
while (true) {
size_t start = gcode.find(start_tag, pos);
if (start == std::string::npos)
break;
size_t end = gcode.find(end_tag, start);
if (end == std::string::npos)
break;
regions.append(gcode, start, end - start);
pos = end + 1;
}
return regions;
}
// A per-layer toolchange between the wall and infill filaments, same shape as
// test_multifilament.cpp's "Each feature prints with its assigned filament", so the wipe tower
// runs its toolchange path (and so `flush_planner_queue()`) on every layer.
static DynamicPrintConfig wipe_tower_toolchange_config(const std::string &gcode_flavor)
{
return multifilament_config(2, {
{ "sparse_infill_filament_id", 1 },
{ "internal_solid_filament_id", 1 },
{ "top_surface_filament_id", 1 },
{ "bottom_surface_filament_id", 1 },
{ "outer_wall_filament_id", 2 },
{ "inner_wall_filament_id", 2 },
{ "enable_prime_tower", true },
{ "layer_height", 0.3 },
{ "gcode_flavor", gcode_flavor },
});
}
// Slices a 10mm cube under `config`. Not plain Test::slice: a brand-new Print's first `apply()`
// counts one filament in use, and DynamicPrintConfig::normalize_fdm_2's single-filament rule then
// clears `enable_prime_tower`. A second apply, once init_print's regions have settled, sees both
// filaments and the tower survives.
static std::string slice_with_prime_tower(const DynamicPrintConfig &config)
{
Print print;
Model model;
init_print({ cube(10) }, print, model, config);
print.apply(model, config);
return gcode(print);
}
TEST_CASE("The wipe tower's toolchange planner flush follows the gcode flavor", "[WipeTower]")
{
auto [flavor, expected, unexpected] = GENERATE(table<std::string, std::string, std::string>({
{ "klipper", "M400", "G4 S0" },
{ "marlin", "G4 S0", "M400" } }));
DYNAMIC_SECTION(flavor) {
const std::string tower = wipe_tower_regions(slice_with_prime_tower(wipe_tower_toolchange_config(flavor)));
REQUIRE_FALSE(tower.empty());
CHECK_THAT(tower, Catch::Matchers::ContainsSubstring(expected));
CHECK_THAT(tower, !Catch::Matchers::ContainsSubstring(unexpected));
}
}
+5
View File
@@ -18,7 +18,10 @@ add_executable(${_TEST_NAME}_tests
test_preset_setting_id.cpp
test_preset_diff.cpp
test_elephant_foot_compensation.cpp
test_fill_corner_smoothing.cpp
test_fill_plane_path.cpp
test_geometry.cpp
test_multimaterial_segmentation.cpp
test_placeholder_parser.cpp
test_polygon.cpp
test_mutable_polygon.cpp
@@ -27,10 +30,12 @@ add_executable(${_TEST_NAME}_tests
test_stl.cpp
test_meshboolean.cpp
test_marchingsquares.cpp
test_model.cpp
test_utils.cpp
test_timeutils.cpp
test_voronoi.cpp
test_optimizers.cpp
test_ordering_strategies.cpp
# test_png_io.cpp
test_indexed_triangle_set.cpp
test_texture_displacement.cpp
+16 -88
View File
@@ -9,6 +9,8 @@
#include "libslic3r/MultiNozzleUtils.hpp"
#include "libslic3r/ProjectTask.hpp"
#include "test_utils.hpp"
#include <boost/filesystem/operations.hpp>
#include <catch2/catch_tostring.hpp>
@@ -109,8 +111,8 @@ SCENARIO("Export+Import geometry to/from 3mf file cycle", "[3mf]") {
src_object->instances.front()->set_transformation(src_instance_transform);
WHEN("model is saved+loaded to/from 3mf file") {
// save the model to 3mf file
std::string test_file = std::string(TEST_DATA_DIR) + "/test_3mf/prusa.3mf";
ScopedTemporaryFile temp(".3mf");
const std::string test_file = temp.string();
store_3mf(test_file.c_str(), &src_model, nullptr, false);
// load back the model from the 3mf file
@@ -120,7 +122,6 @@ SCENARIO("Export+Import geometry to/from 3mf file cycle", "[3mf]") {
ConfigSubstitutionContext ctxt{ ForwardCompatibilitySubstitutionRule::Disable };
load_3mf(test_file.c_str(), dst_config, ctxt, &dst_model, false);
}
boost::filesystem::remove(test_file);
// compare meshes
TriangleMesh src_mesh = src_model.mesh();
@@ -154,10 +155,8 @@ SCENARIO("H2C multi-nozzle .3mf round-trip", "[3mf][MultiNozzle]") {
// store_bbs_3mf stages Metadata/project_settings.config through the model's backup path;
// point it at a writable temp dir (the default lives under a read-only root in CI).
std::string backup_dir =
(boost::filesystem::temp_directory_path() / boost::filesystem::unique_path("orca_mn_%%%%%%%%")).string();
boost::filesystem::create_directories(backup_dir);
model.set_backup_path(backup_dir);
ScopedTemporaryDir backup_dir("orca_mn");
model.set_backup_path(backup_dir.string());
// Global (printer) config: give nozzle_volume_type a non-default value so the slice_info
// read-back is a meaningful assertion (High Flow == 1).
@@ -179,7 +178,8 @@ SCENARIO("H2C multi-nozzle .3mf round-trip", "[3mf][MultiNozzle]") {
plate->config.set_key_value("enable_filament_dynamic_map", new ConfigOptionBool(true));
WHEN("stored to and reloaded from a .3mf") {
std::string test_file = std::string(TEST_DATA_DIR) + "/test_3mf/mn_roundtrip.3mf";
ScopedTemporaryFile temp(".3mf");
const std::string test_file = temp.string();
StoreParams store_params;
store_params.path = test_file.c_str();
@@ -201,8 +201,6 @@ SCENARIO("H2C multi-nozzle .3mf round-trip", "[3mf][MultiNozzle]") {
bool loaded = load_bbs_3mf(test_file.c_str(), &dst_config, &ctxt, &dst_model, &dst_plates,
&project_presets, &is_bbl_3mf, &is_orca_3mf, &file_version, nullptr,
LoadStrategy::LoadModel | LoadStrategy::LoadConfig);
boost::filesystem::remove(test_file);
THEN("every multi-nozzle key round-trips as expected") {
REQUIRE(loaded);
REQUIRE(dst_plates.size() >= 1);
@@ -232,7 +230,6 @@ SCENARIO("H2C multi-nozzle .3mf round-trip", "[3mf][MultiNozzle]") {
release_PlateData_list(dst_plates);
}
delete plate; // store_bbs_3mf does not take ownership of the source plate
boost::filesystem::remove_all(backup_dir);
}
}
@@ -249,10 +246,8 @@ SCENARIO("Non-standard nozzle diameter survives .3mf save on a single-nozzle pri
REQUIRE(load_stl(src_file.c_str(), &model));
model.add_default_instances();
std::string backup_dir =
(boost::filesystem::temp_directory_path() / boost::filesystem::unique_path("orca_nd_%%%%%%%%")).string();
boost::filesystem::create_directories(backup_dir);
model.set_backup_path(backup_dir);
ScopedTemporaryDir backup_dir("orca_nd");
model.set_backup_path(backup_dir.string());
// Single extruder with a non-standard 0.5 mm nozzle; extruder_max_nozzle_count stays at its
// default (no nozzle cluster), so the writer must emit the exact config diameter.
@@ -275,7 +270,8 @@ SCENARIO("Non-standard nozzle diameter survives .3mf save on a single-nozzle pri
plate->slice_filaments_info.push_back(fi);
WHEN("stored to and reloaded from a .3mf") {
std::string test_file = std::string(TEST_DATA_DIR) + "/test_3mf/nd_roundtrip.3mf";
ScopedTemporaryFile temp(".3mf");
const std::string test_file = temp.string();
StoreParams store_params;
store_params.path = test_file.c_str();
@@ -295,8 +291,6 @@ SCENARIO("Non-standard nozzle diameter survives .3mf save on a single-nozzle pri
bool loaded = load_bbs_3mf(test_file.c_str(), &dst_config, &ctxt, &dst_model, &dst_plates,
&project_presets, &is_bbl_3mf, &is_orca_3mf, &file_version, nullptr,
LoadStrategy::LoadModel | LoadStrategy::LoadConfig);
boost::filesystem::remove(test_file);
THEN("the saved nozzle diameter is the exact 0.5, not the rounded 0.4") {
REQUIRE(loaded);
REQUIRE(dst_plates.size() >= 1);
@@ -314,7 +308,6 @@ SCENARIO("Non-standard nozzle diameter survives .3mf save on a single-nozzle pri
release_PlateData_list(dst_plates);
}
delete plate; // store_bbs_3mf does not take ownership of the source plate
boost::filesystem::remove_all(backup_dir);
}
}
@@ -435,10 +428,8 @@ SCENARIO("Nozzle-group metadata .3mf round-trip", "[3mf][MultiNozzle]") {
REQUIRE(load_stl(src_file.c_str(), &model));
model.add_default_instances();
std::string backup_dir =
(boost::filesystem::temp_directory_path() / boost::filesystem::unique_path("orca_ng_%%%%%%%%")).string();
boost::filesystem::create_directories(backup_dir);
model.set_backup_path(backup_dir);
ScopedTemporaryDir backup_dir("orca_ng");
model.set_backup_path(backup_dir.string());
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
@@ -458,7 +449,8 @@ SCENARIO("Nozzle-group metadata .3mf round-trip", "[3mf][MultiNozzle]") {
plate->config.set_key_value("filament_map", new ConfigOptionInts({ 1, 2, 1 }));
WHEN("stored to and reloaded from a .3mf") {
std::string test_file = std::string(TEST_DATA_DIR) + "/test_3mf/ng_roundtrip.3mf";
ScopedTemporaryFile temp(".3mf");
const std::string test_file = temp.string();
StoreParams store_params;
store_params.path = test_file.c_str();
@@ -478,8 +470,6 @@ SCENARIO("Nozzle-group metadata .3mf round-trip", "[3mf][MultiNozzle]") {
bool loaded = load_bbs_3mf(test_file.c_str(), &dst_config, &ctxt, &dst_model, &dst_plates,
&project_presets, &is_bbl_3mf, &is_orca_3mf, &file_version, nullptr,
LoadStrategy::LoadModel | LoadStrategy::LoadConfig);
boost::filesystem::remove(test_file);
THEN("the <nozzle> tags round-trip into the loaded plate's nozzles_info") {
REQUIRE(loaded);
REQUIRE(dst_plates.size() >= 1);
@@ -505,67 +495,5 @@ SCENARIO("Nozzle-group metadata .3mf round-trip", "[3mf][MultiNozzle]") {
release_PlateData_list(dst_plates);
}
delete plate;
boost::filesystem::remove_all(backup_dir);
}
}
SCENARIO("2D convex hull of sinking object", "[3mf][.]") {
GIVEN("model") {
// load a model
Model model;
std::string src_file = std::string(TEST_DATA_DIR) + "/test_3mf/Prusa.stl";
REQUIRE(load_stl(src_file.c_str(), &model));
model.add_default_instances();
WHEN("model is rotated, scaled and set as sinking") {
ModelObject* object = model.objects[0];
object->center_around_origin(false);
// This outputs the same exact data as the Prusaslicer test
object->volumes[0]->mesh().write_ascii("/tmp/orca.ascii");
// set instance's attitude so that it is rotated, scaled (and sinking? how is it sinking? the rotation? does it matter if it's sinking?)
ModelInstance* instance = object->instances[0];
instance->set_rotation(X, -M_PI / 4.0);
instance->set_offset(Vec3d::Zero());
instance->set_scaling_factor({ 2.0, 2.0, 2.0 });
// calculate 2D convex hull
auto trafo = instance->get_transformation().get_matrix();
// This matrix is the same exact matrix as the Prusaslicer test
CAPTURE(trafo);
Polygon hull_2d = object->convex_hull_2d(trafo);
// But we get different hull_2d.points here (and somehow decimal numbers despite being int64_t values, but that's probabaly printing configuration somewhere -- Prusaslicer's prints out with newlines between the X&Y and not one between coordinates, which is about the worse possible output).
// I think it's something to do with PrusaSlicer ignoring everything under the Z plane, which makes sense from the results.
// See the comments added to ModelObject::convex_hull_2d for more information.
// verify result
Points result = {
{ -91501496, -15914144 },
{ 91501496, -15914144 },
{ 91501496, 4243 },
{ 78229680, 4246883 },
{ 56898100, 4246883 },
{ -85501496, 4242641 },
{ -91501496, 4243 }
};
THEN("2D convex hull should match with reference") {
// Allow 1um error due to floating point rounding.
bool res = hull_2d.points.size() == result.size();
if (res) {
for (size_t i = 0; i < result.size(); ++ i) {
const Point &p1 = result[i];
const Point &p2 = hull_2d.points[i];
CHECK((std::abs(p1.x() - p2.x()) > 1 || std::abs(p1.y() - p2.y()) > 1));
}
}
CAPTURE(hull_2d.points);
REQUIRE(res);
}
}
}
}
+70
View File
@@ -1,5 +1,9 @@
#include <catch2/catch_all.hpp>
#include <algorithm>
#include <sstream>
#include <string>
#include "libslic3r/calib.hpp"
#include "libslic3r/Model.hpp"
#include "libslic3r/TriangleMesh.hpp"
@@ -38,3 +42,69 @@ TEST_CASE("Zero calibration line width resolves to a positive default", "[Calib]
REQUIRE(pattern.line_width() > 0.);
REQUIRE(pattern.line_width_first_layer() > 0.);
}
namespace {
struct EndState { double final_e; double max_e; };
EndState simulate_absolute_e(const std::string &gcode)
{
double final_e = 0.;
double max_e = 0.;
std::istringstream lines(gcode);
std::string line;
while (std::getline(lines, line)) {
std::istringstream words(line);
std::string op;
if (!(words >> op))
continue;
if (op != "G1" && op != "G0" && op != "G92")
continue;
std::string word;
while (words >> word) {
if (word.size() >= 2 && word[0] == 'E') {
final_e = std::stod(word.substr(1));
max_e = std::max(max_e, final_e);
break;
}
}
}
return {final_e, max_e};
}
} // namespace
TEST_CASE("PA pattern resets the extruder after the final layer in absolute E mode", "[Calib][Regression]")
{
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
config.set_deserialize_strict({
{"use_relative_e_distances", "0"},
{"line_width", "0.45"},
{"initial_layer_line_width", "0.45"},
});
Model model;
model.add_object("cube", "", make_cube(20, 20, 20))->add_instance();
Calib_Params params;
params.mode = CalibMode::Calib_PA_Pattern;
params.start = 0.;
params.end = 0.08;
params.step = 0.002;
CalibPressureAdvancePattern pattern(params, config, /* is_bbl_machine */ false, *model.objects.front(), Vec3d(0, 0, 0));
const CustomGCode::Info info = pattern.generate_custom_gcodes(config, /* is_bbl_machine */ false, *model.objects.front(),
Vec3d(0, 0, 0));
std::string gcode;
for (const CustomGCode::Item &item : info.gcodes)
gcode += item.extra;
const EndState state = simulate_absolute_e(gcode);
REQUIRE(state.max_e > 1.);
REQUIRE_THAT(state.final_e, Catch::Matchers::WithinAbs(0., 1e-9));
}
+53 -4
View File
@@ -4,6 +4,8 @@
#include "libslic3r/PrintConfigConstants.hpp"
#include "libslic3r/LocalesUtils.hpp"
#include "test_utils.hpp"
#include <cereal/types/polymorphic.hpp>
#include <cereal/types/string.hpp>
#include <cereal/types/vector.hpp>
@@ -233,6 +235,56 @@ SCENARIO("Config ini load/save interface", "[Config]") {
}
}
TEST_CASE("Flush-volume warning predicate respects used filament transitions", "[Config][Regression]")
{
const std::vector<double> multipliers = {1.0};
SECTION("Single used filament does not trigger warning with zero transition entries")
{
const std::vector<double> matrix = {
0.0, 0.0,
0.0, 0.0
};
const std::vector<int> used_filaments = {1};
REQUIRE_FALSE(has_zero_flush_volume_for_used_filaments(matrix, multipliers, used_filaments));
}
SECTION("Two used filaments trigger warning when transition flush entry is zero")
{
const std::vector<double> matrix = {
0.0, 0.0,
0.0, 0.0
};
const std::vector<int> used_filaments = {1, 2};
REQUIRE(has_zero_flush_volume_for_used_filaments(matrix, multipliers, used_filaments));
}
SECTION("Two used filaments do not trigger warning when transitions are non-zero")
{
const std::vector<double> matrix = {
0.0, 280.0,
280.0, 0.0
};
const std::vector<int> used_filaments = {1, 2};
REQUIRE_FALSE(has_zero_flush_volume_for_used_filaments(matrix, multipliers, used_filaments));
}
SECTION("Zero multiplier still triggers warning when multiple filaments are used")
{
const std::vector<double> matrix = {
0.0, 280.0,
280.0, 0.0
};
const std::vector<double> zero_multiplier = {0.0};
const std::vector<int> used_filaments = {1, 2};
REQUIRE(has_zero_flush_volume_for_used_filaments(matrix, zero_multiplier, used_filaments));
}
}
// TODO: https://github.com/SoftFever/OrcaSlicer/issues/11269 - Is this test still relevant? Delete if not.
// It was failing so at least "nozzle_type" and "extruder_printable_area" could not be serialized
// and an exception was thrown, but "nozzle_type" has been around for at least 3 months now.
@@ -407,8 +459,7 @@ SCENARIO("update_diff_values_to_child_config tolerates legacy machine-limit vect
// }
TEST_CASE("save_to_json round-trips plugin capability references as strings", "[Config][plugins]") {
namespace fs = boost::filesystem;
const fs::path tmp = fs::temp_directory_path() / fs::unique_path("orca_plugins_%%%%-%%%%.json");
ScopedTemporaryFile tmp(".json");
const std::vector<std::string> refs = {
"local_plugin;;inset",
"cloud_plugin;550e8400-e29b-41d4-a716-446655440000;inset"
@@ -435,8 +486,6 @@ TEST_CASE("save_to_json round-trips plugin capability references as strings", "[
REQUIRE(reloaded.load_from_json(tmp.string(), substitutions, true, key_values, reason) == 0);
CHECK(reason.empty());
CHECK(reloaded.option<ConfigOptionStrings>("slicing_pipeline_plugin")->values == refs);
fs::remove(tmp);
}
TEST_CASE("plugin capability references survive string-map serialization", "[Config][plugins]") {
@@ -43,18 +43,67 @@ TEST_CASE("apply_override fills nil entries from the 0-based default index", "[C
REQUIRE(resolved.values == std::vector<double>({30., 42.}));
}
SECTION("an index past the machine slots falls back to the first slot") {
SECTION("an index past the machine slots keeps the slot's own value") {
std::vector<int> slot_index{5, 0};
ConfigOptionFloats resolved(machine);
REQUIRE(resolved.apply_override(&filament, slot_index));
REQUIRE(resolved.values == std::vector<double>({10., 42.}));
}
SECTION("a negative index (unresolved slot) falls back to the first slot") {
std::vector<int> slot_index{-1, 0};
SECTION("a negative index (unresolved slot) keeps the slot's own value") {
ConfigOptionFloatsNullable all_nil;
all_nil.values = {ConfigOptionFloatsNullable::nil_value(), ConfigOptionFloatsNullable::nil_value(),
ConfigOptionFloatsNullable::nil_value()};
std::vector<int> slot_index{2, -1, 0};
ConfigOptionFloats resolved(machine);
REQUIRE(resolved.apply_override(&filament, slot_index));
REQUIRE(resolved.values == std::vector<double>({10., 42.}));
REQUIRE(!resolved.apply_override(&all_nil, slot_index));
REQUIRE(resolved.values == std::vector<double>({30., 20., 10.}));
}
SECTION("all-nil overrides keyed by unresolved slots leave the machine values intact") {
// The failed-lookup map a degenerate print_extruder_id used to produce; the negative
// slots must not collapse the machine array to its first value.
ConfigOptionFloats per_extruder({100., 70., 70., 70., 100.});
ConfigOptionFloatsNullable all_nil;
all_nil.values.assign(5, ConfigOptionFloatsNullable::nil_value());
std::vector<int> slot_index{0, -1, -1, -1, 0};
ConfigOptionFloats resolved(per_extruder);
REQUIRE(!resolved.apply_override(&all_nil, slot_index));
REQUIRE(resolved.values == std::vector<double>({100., 70., 70., 70., 100.}));
}
}
TEST_CASE("support_different_extruders is true only when the printer defines more than one variant column", "[Config]")
{
int extruder_count = 0;
SECTION("a non-Bambu dual-nozzle printer with one variant column reports false") {
DynamicPrintConfig config;
config.option<ConfigOptionFloats>("nozzle_diameter", true)->values = {0.4, 0.4};
// Both extruders resolve to the same default variant, so there is only one column.
config.option<ConfigOptionStrings>("extruder_variant_list", true)->values = {"Direct Drive Standard",
"Direct Drive Standard"};
REQUIRE(config.support_different_extruders(extruder_count) == false);
REQUIRE(extruder_count == 2);
}
SECTION("a Bambu H2D-style printer with distinct variants reports true") {
DynamicPrintConfig config;
config.option<ConfigOptionFloats>("nozzle_diameter", true)->values = {0.4, 0.4};
config.option<ConfigOptionStrings>("extruder_variant_list", true)->values = {
"Direct Drive Standard,Direct Drive High Flow",
"Direct Drive Standard,Direct Drive High Flow,Direct Drive TPU High Flow"};
REQUIRE(config.support_different_extruders(extruder_count) == true);
REQUIRE(extruder_count == 2);
}
SECTION("a many-toolhead printer that never opts into variants reports false") {
// A Snapmaker U1 has four identical toolheads and never defines extruder_variant_list,
// so the config falls back to a single default variant token.
DynamicPrintConfig config;
config.option<ConfigOptionFloats>("nozzle_diameter", true)->values = {0.4, 0.4, 0.4, 0.4};
REQUIRE(config.support_different_extruders(extruder_count) == false);
REQUIRE(extruder_count == 4);
}
}
@@ -238,6 +287,102 @@ TEST_CASE("update_values_to_printer_extruders expands one slot per (extruder x v
}
}
TEST_CASE("update_values_to_printer_extruders synthesizes degenerate process variant columns", "[Config]")
{
// Non-BBL process presets and 3mf project configs keep the length-1 defaults for
// print_extruder_id/print_extruder_variant; only BBL system presets ship full-width columns.
auto add_degenerate_print_columns = [](DynamicPrintConfig &config) {
config.option<ConfigOptionInts>("print_extruder_id", true)->values = {1};
config.option<ConfigOptionStrings>("print_extruder_variant", true)->values = {"Direct Drive Standard"};
config.option<ConfigOptionFloats>("outer_wall_speed", true)->values = {30.};
};
SECTION("a single-column pair on a multi-extruder machine expands to one column per extruder") {
DynamicPrintConfig config;
config.option<ConfigOptionEnumsGeneric>("extruder_type", true)->values = {etDirectDrive, etDirectDrive};
config.option<ConfigOptionEnumsGeneric>("nozzle_volume_type", true)->values = {nvtStandard, nvtStandard};
config.option<ConfigOptionStrings>("extruder_variant_list", true)->values = {"Direct Drive Standard", "Direct Drive Standard"};
add_degenerate_print_columns(config);
std::vector<std::vector<NozzleVolumeType>> nozzle_volume_types;
int extruder_count = 2;
int count = config.get_extruder_nozzle_volume_count(extruder_count, nozzle_volume_types);
std::vector<int> variant_index = config.update_values_to_printer_extruders(config, extruder_count, count, nozzle_volume_types,
print_options_with_variant, "print_extruder_id", "print_extruder_variant");
REQUIRE(variant_index == std::vector<int>({0, 1}));
REQUIRE(config.option<ConfigOptionInts>("print_extruder_id")->values == std::vector<int>({1, 2}));
REQUIRE(config.option<ConfigOptionStrings>("print_extruder_variant")->values ==
std::vector<std::string>({"Direct Drive Standard", "Direct Drive Standard"}));
// width-1 data arrays replicate their only column into every slot
REQUIRE(config.option<ConfigOptionFloats>("outer_wall_speed")->values == std::vector<double>({30., 30.}));
}
SECTION("a multi-variant list synthesizes one column per (extruder x variant)") {
DynamicPrintConfig config = make_hybrid_printer_config();
add_degenerate_print_columns(config);
std::vector<std::vector<NozzleVolumeType>> nozzle_volume_types;
int extruder_count = 2;
int count = config.get_extruder_nozzle_volume_count(extruder_count, nozzle_volume_types);
REQUIRE(count == 3);
std::vector<int> variant_index = config.update_values_to_printer_extruders(config, extruder_count, count, nozzle_volume_types,
print_options_with_variant, "print_extruder_id", "print_extruder_variant");
// same slot resolution as the explicit BBL-style 4-column layout
REQUIRE(variant_index == std::vector<int>({0, 2, 3}));
REQUIRE(config.option<ConfigOptionInts>("print_extruder_id")->values == std::vector<int>({1, 2, 2}));
REQUIRE(config.option<ConfigOptionStrings>("print_extruder_variant")->values ==
std::vector<std::string>({"Direct Drive Standard", "Direct Drive Standard", "Direct Drive High Flow"}));
REQUIRE(config.option<ConfigOptionFloats>("outer_wall_speed")->values == std::vector<double>({30., 30., 30.}));
}
SECTION("a single-extruder single-column layout is not treated as degenerate") {
DynamicPrintConfig config;
config.option<ConfigOptionEnumsGeneric>("extruder_type", true)->values = {etDirectDrive};
config.option<ConfigOptionEnumsGeneric>("nozzle_volume_type", true)->values = {nvtStandard};
config.option<ConfigOptionStrings>("extruder_variant_list", true)->values = {"Direct Drive Standard"};
add_degenerate_print_columns(config);
std::vector<std::vector<NozzleVolumeType>> nozzle_volume_types;
int extruder_count = 1;
int count = config.get_extruder_nozzle_volume_count(extruder_count, nozzle_volume_types);
config.update_values_to_printer_extruders(config, extruder_count, count, nozzle_volume_types,
print_options_with_variant, "print_extruder_id", "print_extruder_variant");
REQUIRE(config.option<ConfigOptionInts>("print_extruder_id")->values == std::vector<int>({1}));
REQUIRE(config.option<ConfigOptionFloats>("outer_wall_speed")->values == std::vector<double>({30.}));
}
SECTION("a second expansion leaves the synthesized layout unchanged") {
DynamicPrintConfig config;
config.option<ConfigOptionEnumsGeneric>("extruder_type", true)->values = {etDirectDrive, etDirectDrive};
config.option<ConfigOptionEnumsGeneric>("nozzle_volume_type", true)->values = {nvtStandard, nvtStandard};
config.option<ConfigOptionStrings>("extruder_variant_list", true)->values = {"Direct Drive Standard", "Direct Drive Standard"};
add_degenerate_print_columns(config);
std::vector<std::vector<NozzleVolumeType>> nozzle_volume_types;
int extruder_count = 2;
int count = config.get_extruder_nozzle_volume_count(extruder_count, nozzle_volume_types);
config.update_values_to_printer_extruders(config, extruder_count, count, nozzle_volume_types,
print_options_with_variant, "print_extruder_id", "print_extruder_variant");
DynamicPrintConfig once = config;
config.update_values_to_printer_extruders(config, extruder_count, count, nozzle_volume_types,
print_options_with_variant, "print_extruder_id", "print_extruder_variant");
REQUIRE(config.option<ConfigOptionInts>("print_extruder_id")->values ==
once.option<ConfigOptionInts>("print_extruder_id")->values);
REQUIRE(config.option<ConfigOptionStrings>("print_extruder_variant")->values ==
once.option<ConfigOptionStrings>("print_extruder_variant")->values);
REQUIRE(config.option<ConfigOptionFloats>("outer_wall_speed")->values ==
once.option<ConfigOptionFloats>("outer_wall_speed")->values);
}
}
TEST_CASE("update_values_to_printer_extruders_for_multiple_filaments resolves per-filament slots", "[Config]")
{
auto make_filament_arrays = [](DynamicPrintConfig &config) {
@@ -0,0 +1,173 @@
#include <catch2/catch_all.hpp>
#include <algorithm>
#include <cmath>
#include <limits>
#include "libslic3r/Fill/FillCornerSmoothing.hpp"
#include "libslic3r/Polyline.hpp"
#include "libslic3r/libslic3r.h"
using namespace Slic3r;
namespace {
// A right angle turn, with the outgoing leg ten times longer than the incoming one.
Polyline asymmetric_corner()
{
return Polyline{ Point::new_scale(0., 0.), Point::new_scale(10., 0.), Point::new_scale(10., 100.) };
}
double max_turn_cosine(const Polyline &polyline)
{
double sharpest = 1.;
for (size_t i = 1; i + 1 < polyline.size(); ++i) {
const Vec2d incoming = (polyline[i] - polyline[i - 1]).cast<double>().normalized();
const Vec2d outgoing = (polyline[i + 1] - polyline[i]).cast<double>().normalized();
sharpest = std::min(sharpest, incoming.dot(outgoing));
}
return sharpest;
}
bool contains(const Polyline &polyline, const Point &point)
{
return std::find(polyline.points.begin(), polyline.points.end(), point) != polyline.points.end();
}
const double tolerance = scaled<double>(0.0125);
} // namespace
TEST_CASE("Corner smoothing replaces a sharp vertex by a curve", "[FillCornerSmoothing]")
{
const Polyline sharp = asymmetric_corner();
Polyline smooth = sharp;
smooth_polyline_corners(smooth, 1., tolerance);
REQUIRE(smooth.size() > sharp.size());
REQUIRE(smooth.front() == sharp.front());
REQUIRE(smooth.back() == sharp.back());
// The right angle is gone, every remaining turn is a gentle one.
REQUIRE(max_turn_cosine(sharp) < 0.1);
REQUIRE(max_turn_cosine(smooth) > 0.9);
REQUIRE(smooth.length() < sharp.length());
}
TEST_CASE("Corner smoothing keeps the path untouched at a zero factor", "[FillCornerSmoothing]")
{
const Polyline sharp = asymmetric_corner();
Polyline none = sharp;
smooth_polyline_corners(none, 0., tolerance);
REQUIRE(none.points == sharp.points);
Polyline invalid = sharp;
smooth_polyline_corners(invalid, std::numeric_limits<double>::quiet_NaN(), tolerance);
REQUIRE(invalid.points == sharp.points);
}
TEST_CASE("Corner smoothing consumes at most half of the shorter leg", "[FillCornerSmoothing]")
{
// The curve must not reach beyond the middle of either adjoining segment, otherwise the curves of
// two adjacent corners would overlap. The shorter leg is 10mm long, so the corner at (10, 0) is
// left 5mm before it and rejoined 5mm past it, even though the other leg is 100mm long.
Polyline smooth = asymmetric_corner();
smooth_polyline_corners(smooth, 1., tolerance);
REQUIRE(contains(smooth, Point::new_scale(5., 0.)));
REQUIRE(contains(smooth, Point::new_scale(10., 5.)));
// A Bezier curve stays within the convex hull of its control points, so the rounded path stays
// inside the box spanned by the two legs.
for (const Point &point : smooth.points) {
REQUIRE(point.x() >= 0);
REQUIRE(point.y() >= 0);
REQUIRE(point.x() <= Point::new_scale(10., 0.).x());
REQUIRE(point.y() <= Point::new_scale(0., 100.).y());
}
}
TEST_CASE("Corner smoothing scales the curve with the factor", "[FillCornerSmoothing]")
{
Polyline half = asymmetric_corner();
smooth_polyline_corners(half, 0.5, tolerance);
Polyline full = asymmetric_corner();
smooth_polyline_corners(full, 1., tolerance);
// Half of the factor leaves the 10mm leg half as far from the corner.
REQUIRE(contains(half, Point::new_scale(7.5, 0.)));
REQUIRE(contains(full, Point::new_scale(5., 0.)));
// A larger factor rounds a wider portion of the legs, cutting more of the corner off.
REQUIRE(full.length() < half.length());
}
TEST_CASE("Corner smoothing leaves hairpins sharp", "[FillCornerSmoothing]")
{
// Both ends of a curve replacing a nearly reversing turn coincide, which would round the hairpin
// into a degenerate loop instead of a tip.
Polyline hairpin{ Point::new_scale(0., 0.), Point::new_scale(10., 0.), Point::new_scale(0., 0.5) };
const Polyline sharp = hairpin;
smooth_polyline_corners(hairpin, 1., tolerance);
REQUIRE(hairpin == sharp);
}
TEST_CASE("Corner smoothing follows the flattening tolerance", "[FillCornerSmoothing]")
{
Polyline coarse = asymmetric_corner();
smooth_polyline_corners(coarse, 1., scaled<double>(0.2));
Polyline fine = asymmetric_corner();
smooth_polyline_corners(fine, 1., scaled<double>(0.001));
REQUIRE(fine.size() > coarse.size());
REQUIRE(fine.front() == coarse.front());
REQUIRE(fine.back() == coarse.back());
}
TEST_CASE("Corner smoothing emits no zero length segments", "[FillCornerSmoothing]")
{
// Fully smoothed adjacent corners meet at the midpoint of the segment they share.
Polyline zigzag;
for (int i = 0; i < 8; ++i)
zigzag.points.emplace_back(Point::new_scale(i, i % 2 ? 1. : 0.));
smooth_polyline_corners(zigzag, 1., tolerance);
for (size_t i = 1; i < zigzag.size(); ++i)
REQUIRE((zigzag[i] - zigzag[i - 1]).cast<double>().squaredNorm() > 0.);
}
TEST_CASE("Corner smoothing rounds every vertex of a polygon", "[FillCornerSmoothing]")
{
// A polygon closes implicitly, so none of its corners may stay sharp, not even the first one.
const Polygon square{ Point::new_scale(0., 0.), Point::new_scale(10., 0.), Point::new_scale(10., 10.),
Point::new_scale(0., 10.) };
Polygons smooth{ square };
smooth_polygons_corners(smooth, 1., tolerance);
const Polyline rounded = smooth.front().split_at_first_point();
REQUIRE(smooth.front().size() > square.size());
REQUIRE(max_turn_cosine(rounded) > 0.9);
// The turn from the closing segment back into the first one must be gentle as well.
const Vec2d incoming = (rounded[rounded.size() - 1] - rounded[rounded.size() - 2]).cast<double>().normalized();
const Vec2d outgoing = (rounded[1] - rounded[0]).cast<double>().normalized();
REQUIRE(incoming.dot(outgoing) > 0.9);
// None of the corners is cut by more than half of a 10mm side.
for (const Point &point : smooth.front().points) {
REQUIRE(point.x() >= 0);
REQUIRE(point.y() >= 0);
REQUIRE(point.x() <= Point::new_scale(10., 0.).x());
REQUIRE(point.y() <= Point::new_scale(0., 10.).y());
}
}
TEST_CASE("Corner smoothing keeps the ends of a path that returns to its start", "[FillCornerSmoothing][Regression]")
{
// A branch of a lightning tree walks out and retraces its way back, ending where it started. Its
// ends are two free ends that happen to coincide, and joining them would close it into a loop.
Polyline retrace{ Point::new_scale(0., 0.), Point::new_scale(10., 0.), Point::new_scale(10., 10.),
Point::new_scale(5., 10.), Point::new_scale(0., 0.) };
const Polyline sharp = retrace;
smooth_polyline_corners(retrace, 1., tolerance);
REQUIRE(retrace.size() > sharp.size());
REQUIRE(retrace.front() == sharp.front());
REQUIRE(retrace.back() == sharp.back());
}
+218
View File
@@ -0,0 +1,218 @@
#include <catch2/catch_all.hpp>
#include <algorithm>
#include <cmath>
#include <limits>
#include <utility>
#include "libslic3r/Fill/FillPlanePath.hpp"
#include "libslic3r/PrintConfig.hpp"
using namespace Slic3r;
namespace {
constexpr double output_scale = 1'000'000.;
class TestableHilbertCurve : public FillHilbertCurve
{
public:
Points generate_points(double resolution, double smooth_factor = 0., coord_t max_coordinate = 7)
{
InfillPolylineOutput output(output_scale);
FillParams params;
params.smooth_factor = smooth_factor;
FillHilbertCurve::generate(0, 0, max_coordinate, max_coordinate, resolution, params, output);
return std::move(output.result());
}
};
class TestableOctagramSpiral : public FillOctagramSpiral
{
public:
Points generate_points(double resolution, double smooth_factor = 0., coord_t max_coordinate = 7)
{
InfillPolylineOutput output(output_scale);
FillParams params;
params.smooth_factor = smooth_factor;
FillOctagramSpiral::generate(-max_coordinate, -max_coordinate, max_coordinate, max_coordinate, resolution, params, output);
return std::move(output.result());
}
};
// Cosine of the sharpest turn of a path, 1 meaning it has no turn at all.
double sharpest_turn_cosine(const Points &points)
{
double sharpest = 1.;
for (size_t i = 1; i + 1 < points.size(); ++i) {
const Vec2d incoming = (points[i] - points[i - 1]).cast<double>().normalized();
const Vec2d outgoing = (points[i + 1] - points[i]).cast<double>().normalized();
sharpest = std::min(sharpest, incoming.dot(outgoing));
}
return sharpest;
}
double path_length(const Points &points)
{
double length = 0.;
for (size_t i = 1; i < points.size(); ++i)
length += (points[i] - points[i - 1]).cast<double>().norm();
return length;
}
double discrete_curvature_at(const Points &points, const Point &point)
{
const auto point_it = std::find(points.begin(), points.end(), point);
REQUIRE(point_it != points.end());
const size_t point_idx = size_t(std::distance(points.begin(), point_it));
REQUIRE(point_idx > 0);
REQUIRE(point_idx + 1 < points.size());
const Vec2d incoming = (points[point_idx] - points[point_idx - 1]).cast<double>() / output_scale;
const Vec2d outgoing = (points[point_idx + 1] - points[point_idx]).cast<double>() / output_scale;
const Vec2d chord = incoming + outgoing;
const double cross = std::abs(incoming.x() * outgoing.y() - incoming.y() * outgoing.x());
return 2. * cross / (incoming.norm() * outgoing.norm() * chord.norm());
}
} // namespace
TEST_CASE("Hilbert curve exposes a smoothing factor", "[FillPlanePath]")
{
const ConfigOptionDef *factor_def = print_config_def.get("sparse_infill_smooth_factor");
REQUIRE(factor_def != nullptr);
REQUIRE(factor_def->type == coPercent);
REQUIRE_THAT(factor_def->min, Catch::Matchers::WithinAbs(0., 1e-12));
REQUIRE_THAT(factor_def->max, Catch::Matchers::WithinAbs(100., 1e-12));
REQUIRE_THAT(factor_def->get_default_value<ConfigOptionPercent>()->value,
Catch::Matchers::WithinAbs(0., 1e-12));
}
TEST_CASE("Hilbert curve smoothing rounds right angle turns", "[FillPlanePath]")
{
const Points sharp = TestableHilbertCurve().generate_points(0.005);
const Points smooth = TestableHilbertCurve().generate_points(0.005, 1.);
REQUIRE(smooth.front() == sharp.front());
REQUIRE(smooth.back() == sharp.back());
REQUIRE(smooth.size() > sharp.size());
bool has_turn = false;
for (size_t i = 1; i < smooth.size(); ++i) {
const Vec2d segment = (smooth[i] - smooth[i - 1]).cast<double>();
REQUIRE(segment.squaredNorm() > 0.);
}
for (size_t i = 1; i + 1 < smooth.size(); ++i) {
const Vec2d incoming = (smooth[i] - smooth[i - 1]).cast<double>();
const Vec2d outgoing = (smooth[i + 1] - smooth[i]).cast<double>();
const double cross = incoming.x() * outgoing.y() - incoming.y() * outgoing.x();
const double cosine = incoming.dot(outgoing) / (incoming.norm() * outgoing.norm());
has_turn |= std::abs(cross) > 0.;
REQUIRE(cosine > 0.);
}
REQUIRE(has_turn);
const coord_t upper_bound = coord_t(7 * output_scale);
for (const Point &point : smooth) {
REQUIRE(point.x() >= 0);
REQUIRE(point.y() >= 0);
REQUIRE(point.x() <= upper_bound);
REQUIRE(point.y() <= upper_bound);
}
}
TEST_CASE("Smoothed Hilbert curve honors path resolution", "[FillPlanePath]")
{
const Points coarse = TestableHilbertCurve().generate_points(0.1, 1.);
const Points fine = TestableHilbertCurve().generate_points(0.001, 1.);
REQUIRE(fine.size() > coarse.size());
REQUIRE(fine.front() == coarse.front());
REQUIRE(fine.back() == coarse.back());
}
TEST_CASE("Smoothed Hilbert corners use a uniform subdivision depth", "[FillPlanePath]")
{
const Points smooth = TestableHilbertCurve().generate_points(0.0035, 1., 1);
const Point curve_entry(0, coord_t(0.5 * output_scale));
const Point curve_exit(coord_t(0.5 * output_scale), coord_t(output_scale));
const auto entry_it = std::find(smooth.begin(), smooth.end(), curve_entry);
REQUIRE(entry_it != smooth.end());
const auto exit_it = std::find(entry_it, smooth.end(), curve_exit);
REQUIRE(exit_it != smooth.end());
const size_t segment_count = size_t(std::distance(entry_it, exit_it));
REQUIRE(segment_count > 1);
REQUIRE((segment_count & (segment_count - 1)) == 0);
double previous_length = (entry_it[1] - entry_it[0]).cast<double>().norm();
REQUIRE(previous_length > 0.);
double max_length_ratio = 1.;
for (size_t segment = 1; segment < segment_count; ++segment) {
const double current_length = (entry_it[segment + 1] - entry_it[segment]).cast<double>().norm();
REQUIRE(current_length > 0.);
max_length_ratio = std::max(max_length_ratio,
std::max(current_length / previous_length, previous_length / current_length));
previous_length = current_length;
}
REQUIRE(max_length_ratio < 1.5);
}
TEST_CASE("Hilbert smoothing joins straight segments with continuous curvature", "[FillPlanePath]")
{
const Points coarse = TestableHilbertCurve().generate_points(0.005, 0.5, 1);
const Points fine = TestableHilbertCurve().generate_points(0.0001, 0.5, 1);
const Point first_curve_entry(0, coord_t(0.75 * output_scale));
const double coarse_entry_curvature = discrete_curvature_at(coarse, first_curve_entry);
const double fine_entry_curvature = discrete_curvature_at(fine, first_curve_entry);
REQUIRE(coarse_entry_curvature > 0.);
REQUIRE(fine_entry_curvature < 0.25 * coarse_entry_curvature);
}
TEST_CASE("Octagram spiral smoothing rounds the turns of the spiral", "[FillPlanePath]")
{
const Points sharp = TestableOctagramSpiral().generate_points(0.005);
const Points smooth = TestableOctagramSpiral().generate_points(0.005, 1.);
REQUIRE(smooth.size() > sharp.size());
REQUIRE(smooth.front() == sharp.front());
REQUIRE(smooth.back() == sharp.back());
// The spiral alternates between 90 and 135 degree turns; both are rounded into gentle ones.
REQUIRE(sharpest_turn_cosine(sharp) < -0.7);
REQUIRE(sharpest_turn_cosine(smooth) > 0.9);
for (size_t i = 1; i < smooth.size(); ++i)
REQUIRE((smooth[i] - smooth[i - 1]).cast<double>().squaredNorm() > 0.);
}
TEST_CASE("Octagram spiral smooth factor controls corner curvature", "[FillPlanePath]")
{
const Points sharp = TestableOctagramSpiral().generate_points(0.005);
const Points half_smooth = TestableOctagramSpiral().generate_points(0.005, 0.5);
const Points full_smooth = TestableOctagramSpiral().generate_points(0.005, 1.);
const Points invalid_factor = TestableOctagramSpiral().generate_points(
0.005, std::numeric_limits<double>::quiet_NaN());
REQUIRE(path_length(full_smooth) < path_length(half_smooth));
REQUIRE(path_length(half_smooth) < path_length(sharp));
REQUIRE(invalid_factor == sharp);
}
TEST_CASE("Hilbert curve smooth factor controls corner curvature", "[FillPlanePath]")
{
const Points sharp = TestableHilbertCurve().generate_points(0.005);
const Points half_smooth = TestableHilbertCurve().generate_points(0.005, 0.5);
const Points full_smooth = TestableHilbertCurve().generate_points(0.005, 1.);
const Points invalid_factor = TestableHilbertCurve().generate_points(
0.005, std::numeric_limits<double>::quiet_NaN());
REQUIRE(full_smooth.front() == half_smooth.front());
REQUIRE(full_smooth.back() == half_smooth.back());
REQUIRE(path_length(full_smooth) < path_length(half_smooth));
REQUIRE(invalid_factor == sharp);
for (size_t i = 1; i < full_smooth.size(); ++i)
REQUIRE((full_smooth[i] - full_smooth[i - 1]).squaredNorm() > 0);
}
+3 -1
View File
@@ -4,6 +4,8 @@
#include "libslic3r/SLA/Hollowing.hpp"
#include "test_utils.hpp"
TEST_CASE("Hollow two overlapping spheres") {
using namespace Slic3r;
@@ -16,6 +18,6 @@ TEST_CASE("Hollow two overlapping spheres") {
sla::hollow_mesh(sphere1, sla::HollowingConfig{}, sla::HollowingFlags::hfRemoveInsideTriangles);
sphere1.WriteOBJFile("twospheres.obj");
write_debug_obj("hollowing/twospheres.obj", sphere1);
}
@@ -5,6 +5,8 @@
#include "libslic3r/TriangleMesh.hpp"
#include "test_utils.hpp"
using namespace Slic3r;
TEST_CASE("Split empty mesh", "[its_split][its]") {
@@ -29,13 +31,15 @@ TEST_CASE("Split simple mesh consisting of one part", "[its_split][its]") {
REQUIRE(res.front().vertices.size() == cube.vertices.size());
}
// Dump each split part as its own OBJ for eyeballing; no-op in release.
void debug_write_obj(const std::vector<indexed_triangle_set> &res, const std::string &name)
{
#ifndef NDEBUG
size_t part_idx = 0;
for (auto &part_its : res) {
its_write_obj(part_its, (name + std::to_string(part_idx++) + ".obj").c_str());
}
for (const auto &part_its : res)
write_debug_obj("indexed_triangle_set/" + name + std::to_string(part_idx++) + ".obj", part_its);
#else
(void) res; (void) name;
#endif
}
@@ -260,7 +264,6 @@ TEST_CASE("Reduce one edge by Quadric Edge Collapse", "[its]")
CHECK(is_similar(its_, its, cfg));
}
#include "test_utils.hpp"
TEST_CASE("Simplify mesh by Quadric edge collapse to 5%", "[its]")
{
TriangleMesh mesh = load_model("frog_legs.obj");
+21 -29
View File
@@ -191,22 +191,21 @@ static void test_expolys(Rst&& rst, const ExPolygons& ref, Vec2i32 window, const
for (const ExPolygon& expoly : ref)
rst.draw(expoly);
std::fstream out(name + ".png", std::ios::out);
out << rst.encode(sla::PNGRasterEncoder{});
out.close();
write_debug_stream("marchingsquares/" + name + ".png",
[&] { return rst.encode(sla::PNGRasterEncoder{}); });
const ExPolygons bmp = rstGetPolys(rst);
const ExPolygons ext = sla::raster_to_polygons(rst, window);
SVG svg(name + ".svg", raster_bb);
svg.draw(bmp, "green");
if (pixel_size.x() >= scale_(0.5))
svg.draw_grid(raster_bb, "grey", scale_(0.05), pixel_size.x());
if (window_size.x() >= scale_(1.0))
svg.draw_grid(raster_bb, "grey", scale_(0.10), window_size.x());
svg.draw_outline(ref, "red", "red", scale_(0.3));
svg.draw_outline(ext, "blue", "blue");
svg.Close();
write_debug_svg("marchingsquares/" + name + ".svg", raster_bb, [&](SVG &svg) {
svg.draw(bmp, "green");
if (pixel_size.x() >= scale_(0.5))
svg.draw_grid(raster_bb, "grey", scale_(0.05), pixel_size.x());
if (window_size.x() >= scale_(1.0))
svg.draw_grid(raster_bb, "grey", scale_(0.10), window_size.x());
svg.draw_outline(ref, "red", "red", scale_(0.3));
svg.draw_outline(ext, "blue", "blue");
});
// Note all these areas are unscaled back to mm^2.
double raster_area = unscaled(unscaled(area(bmp)));
@@ -432,9 +431,7 @@ static void recreate_object_from_rasters(const std::string& objname, float lh)
double disp_w = 120.96;
double disp_h = 68.04;
#ifndef NDEBUG
size_t cntr = 0;
#endif
for (ExPolygons& layer : layers) {
auto rst = create_raster(res, disp_w, disp_h);
@@ -442,11 +439,8 @@ static void recreate_object_from_rasters(const std::string& objname, float lh)
rst.draw(island);
}
#ifndef NDEBUG
std::fstream out(objname + std::to_string(cntr) + ".png", std::ios::out);
out << rst.encode(sla::PNGRasterEncoder{});
out.close();
#endif
write_debug_stream("marchingsquares/" + objname + std::to_string(cntr) + ".png",
[&] { return rst.encode(sla::PNGRasterEncoder{}); });
ExPolygons layer_ = sla::raster_to_polygons(rst);
// float delta = scaled(std::min(rst.pixel_dimensions().h_mm,
@@ -454,21 +448,19 @@ static void recreate_object_from_rasters(const std::string& objname, float lh)
// layer_ = expolygons_simplify(layer_, delta);
#ifndef NDEBUG
SVG svg(objname + std::to_string(cntr) + ".svg", rstBBox(rst));
svg.draw(layer_);
svg.draw(layer, "green");
svg.Close();
#endif
write_debug_svg("marchingsquares/" + objname + std::to_string(cntr) + ".svg", rstBBox(rst),
[&](SVG &svg) {
svg.draw(layer_);
svg.draw(layer, "green");
});
double layera = 0., layera_ = 0.;
for (auto& p : layer)
layera += p.area();
for (auto& p : layer_)
layera_ += p.area();
#ifndef NDEBUG
std::cout << cntr++ << std::endl;
#endif
++cntr;
double diff = std::abs(layera_ - layera);
REQUIRE((diff <= 0.1 * layera || diff < scaled<double>(1.) * scaled<double>(1.)));
@@ -477,7 +469,7 @@ static void recreate_object_from_rasters(const std::string& objname, float lh)
indexed_triangle_set out = slices_to_mesh(layers, bb.min.z(), double(lh), double(lh));
its_write_obj(out, "out_from_rasters.obj");
write_debug_obj("marchingsquares/out_from_rasters.obj", out);
}
TEST_CASE("Recreate object from rasters", "[SL1Import]") { recreate_object_from_rasters("frog_legs.obj", 0.05f); }
+40
View File
@@ -0,0 +1,40 @@
#include <catch2/catch_all.hpp>
#include "libslic3r/Model.hpp"
using namespace Slic3r;
// convex_hull_2d does not clip geometry below the bed, so these cases avoid
// sinking transforms.
TEST_CASE("A part's 2D convex hull is its footprint projected onto the bed", "[Model]")
{
Model model;
ModelObject* object = model.add_object();
// Keep the cube's raw coordinates ([0,20] on every axis): the default
// add_volume re-centers the geometry, which would move the footprint.
object->add_volume(make_cube(20, 20, 20), ModelVolumeType::MODEL_PART, false);
SECTION("identity transform yields the 20 mm square") {
const Polygon hull = object->convex_hull_2d(Geometry::Transformation{}.get_matrix());
const BoundingBox bb = hull.bounding_box();
CHECK(hull.size() == 4);
CHECK(bb.min.x() == scaled(0.));
CHECK(bb.min.y() == scaled(0.));
CHECK(bb.max.x() == scaled(20.));
CHECK(bb.max.y() == scaled(20.));
}
SECTION("scaling and offset move and grow the footprint") {
Geometry::Transformation t;
t.set_scaling_factor({2, 2, 2}); // cube now spans [0,40]
t.set_offset({10, 5, 0}); // then shift +10 in X, +5 in Y
const Polygon hull = object->convex_hull_2d(t.get_matrix());
const BoundingBox bb = hull.bounding_box();
CHECK(hull.size() == 4);
CHECK(bb.min.x() == scaled(10.));
CHECK(bb.min.y() == scaled(5.));
CHECK(bb.max.x() == scaled(50.));
CHECK(bb.max.y() == scaled(45.));
}
}
@@ -0,0 +1,57 @@
#include <catch2/catch_all.hpp>
// MultiMaterialSegmentation.hpp declares boost::polygon traits for ColoredLine, so its
// geometry/boost dependencies must be included first.
#include <boost/polygon/polygon.hpp>
#include "libslic3r/Line.hpp"
#include "libslic3r/Flow.hpp"
#include "libslic3r/MultiMaterialSegmentation.hpp"
#include "libslic3r/PrintConfig.hpp"
using namespace Slic3r;
TEST_CASE("Multi-material segmentation resolves the outer-wall line width", "[MultiMaterialSegmentation][Regression]")
{
struct Case
{
std::string description;
double outer_value;
bool outer_percent;
double line_value;
bool line_percent;
std::vector<double> nozzle_diameters;
int outer_wall_filament_id;
double expected;
};
auto c = GENERATE(values<Case>({
{"absolute outer-wall width is used as-is", 0.6, false, 0.42, false, {0.4}, 1, 0.6},
{"percent outer-wall width uses the nozzle", 120, true, 0.42, false, {0.5}, 1, 0.6},
{"zero outer-wall width uses the line width", 0, false, 0.5, false, {0.4}, 1, 0.5},
{"zero outer-wall width uses a percent line", 0, false, 100, true, {0.5}, 1, 0.5},
{"zero width falls back to auto", 0, false, 0, false, {0.4}, 1, Flow::auto_extrusion_width(frExternalPerimeter, 0.4)},
{"the auto fallback scales with the nozzle", 0, false, 0, false, {0.6}, 1, Flow::auto_extrusion_width(frExternalPerimeter, 0.6)},
{"a percent width uses the outer wall's nozzle", 120, true, 0.42, false, {0.4, 0.8}, 2, 0.96},
{"the auto width uses the outer wall's nozzle", 0, false, 0, false, {0.4, 0.8}, 2, Flow::auto_extrusion_width(frExternalPerimeter, 0.8)},
{"an absolute width ignores the nozzle", 0.6, false, 0.42, false, {0.4, 0.8}, 2, 0.6},
{"a zero percent width uses the line width", 0, true, 0.5, false, {0.4}, 1, 0.5},
{"an unset filament id uses the first nozzle", 0, false, 0, false, {0.4, 0.8}, 0, Flow::auto_extrusion_width(frExternalPerimeter, 0.4)},
{"an out-of-range filament id uses nozzle 1", 0, false, 0, false, {0.4, 0.8}, 5, Flow::auto_extrusion_width(frExternalPerimeter, 0.4)},
}));
DYNAMIC_SECTION(c.description)
{
PrintConfig print_config;
print_config.nozzle_diameter.values = c.nozzle_diameters;
PrintObjectConfig object_config;
object_config.line_width = ConfigOptionFloatOrPercent(c.line_value, c.line_percent);
PrintRegionConfig region_config;
region_config.outer_wall_line_width = ConfigOptionFloatOrPercent(c.outer_value, c.outer_percent);
region_config.outer_wall_filament_id.value = c.outer_wall_filament_id;
REQUIRE_THAT(resolve_outer_wall_line_width(region_config, object_config, print_config),
Catch::Matchers::WithinAbs(c.expected, 1e-9));
}
}
@@ -0,0 +1,297 @@
#include <catch2/catch_all.hpp>
#define SLIC3R_TEST_HARNESS
#include "libslic3r/Point.hpp"
#include "libslic3r/GCode/OrderingStrategies.hpp"
#include "libslic3r/Geometry.hpp"
#include <algorithm>
#include <unordered_set>
using namespace Slic3r;
// --- Helpers ---
static double euclidean_path_length(const std::vector<size_t>& path, const Points& centers)
{
return tsp_cycle_path_length(path, centers);
}
static bool has_crossings(const std::vector<size_t>& path, const Points& centers)
{
size_t pn = path.size();
if (pn < 4) return false;
for (size_t i = 0; i < pn; ++i) {
size_t i_next = (i + 1) % pn;
for (size_t j = i + 2; j < pn; ++j) {
if (j == i_next) continue;
if (j == (pn - 1) && i == 0) continue;
size_t j_next = (j + 1) % pn;
if (Geometry::segments_intersect(
centers[path[i]], centers[path[i_next]],
centers[path[j]], centers[path[j_next]])) {
return true;
}
}
}
return false;
}
static bool is_permutation(const std::vector<size_t>& path, size_t n)
{
if (path.size() != n) return false;
std::unordered_set<size_t> seen(path.begin(), path.end());
for (size_t i = 0; i < n; ++i) {
if (seen.count(i) != 1) return false;
}
return true;
}
// --- Test fixtures ---
static Points make_grid_4x4()
{
Points pts;
for (int row = 0; row < 4; ++row)
for (int col = 0; col < 4; ++col)
pts.emplace_back(100000 * col, 100000 * row);
return pts;
}
static Points make_linear_5()
{
Points pts;
for (int i = 0; i < 5; ++i)
pts.emplace_back(100000 * i, 0);
return pts;
}
static Points make_ring_8()
{
Points pts;
constexpr double R = 100000.0;
for (int i = 0; i < 8; ++i) {
double angle = 2.0 * M_PI * i / 8.0;
pts.emplace_back(static_cast<coord_t>(R * std::cos(angle)),
static_cast<coord_t>(R * std::sin(angle)));
}
return pts;
}
static Points make_random_16()
{
// Deterministic "random" points via simple hash.
Points pts;
for (int i = 0; i < 16; ++i) {
uint32_t h = static_cast<uint32_t>(i * 2654435761u);
coord_t x = static_cast<coord_t>((h >> 16) & 0xFFFF) * 10;
coord_t y = static_cast<coord_t>(h & 0xFFFF) * 10;
pts.emplace_back(x, y);
}
return pts;
}
// --- TSP Post-Processing Tests ---
TEST_CASE("tsp_2opt_improve reduces path length", "[TSPPostProcessing]") {
Points centers = make_random_16();
std::vector<size_t> path(centers.size());
// Reverse half the path to create a deliberately bad ordering.
for (size_t i = 0; i < path.size(); ++i) path[i] = i;
std::reverse(path.begin(), path.end() - path.size() / 2);
double before = euclidean_path_length(path, centers);
tsp_2opt_improve(path, centers);
double after = euclidean_path_length(path, centers);
REQUIRE(is_permutation(path, centers.size()));
CHECK(after <= before);
}
TEST_CASE("tsp_remove_crossings eliminates crossings", "[TSPPostProcessing]") {
Points centers = make_random_16();
std::vector<size_t> path(centers.size());
for (size_t i = 0; i < path.size(); ++i) path[i] = i;
// Create a crossing by reversing a middle segment.
if (path.size() >= 4) {
std::reverse(path.begin() + 1, path.end() - 1);
}
tsp_remove_crossings(path, centers);
CHECK(!has_crossings(path, centers));
REQUIRE(is_permutation(path, centers.size()));
}
TEST_CASE("tsp_rotate_minimize_closing shortens closing edge", "[TSPPostProcessing]") {
Points centers = make_random_16();
std::vector<size_t> path(centers.size());
for (size_t i = 0; i < path.size(); ++i) path[i] = i;
// Compute all possible closing edge lengths.
size_t pn = path.size();
double min_closing2 = std::numeric_limits<double>::max();
for (size_t start = 0; start < pn; ++start) {
size_t last = (start + pn - 1) % pn;
double d2 = (centers[path[start]].cast<double>() - centers[path[last]].cast<double>()).squaredNorm();
if (d2 < min_closing2) min_closing2 = d2;
}
tsp_rotate_minimize_closing(path, centers);
// Closing edge should be the minimum possible.
double actual_closing2 = (centers[path.front()].cast<double>() - centers[path.back()].cast<double>()).squaredNorm();
CHECK(actual_closing2 == min_closing2);
REQUIRE(is_permutation(path, centers.size()));
}
TEST_CASE("tsp_cycle_path_length is correct for triangle", "[TSPPostProcessing]") {
Points pts;
pts.emplace_back(0, 0);
pts.emplace_back(100000, 0);
pts.emplace_back(50000, 86602); // equilateral ~100mm sides
std::vector<size_t> path = {0, 1, 2};
double len = tsp_cycle_path_length(path, pts);
// Perimeter of equilateral triangle with side ~100000.
REQUIRE(len > 290000);
REQUIRE(len < 310000);
}
TEST_CASE("tsp_max_edge_length finds longest edge", "[TSPPostProcessing]") {
Points pts;
pts.emplace_back(0, 0);
pts.emplace_back(100000, 0);
pts.emplace_back(50000, 0);
std::vector<size_t> path = {0, 1, 2};
double mx = tsp_max_edge_length(path, pts);
// Longest edge is 0->1 = 100000.
CHECK(mx == Catch::Approx(100000).margin(1));
}
// --- Core Strategy Tests: Empty / Small Inputs ---
TEST_CASE("snake_core handles empty input", "[Snake]") {
Points centers;
auto path = snake_core(centers);
REQUIRE(path.empty());
}
TEST_CASE("snake_core handles single point", "[Snake]") {
Points pts{{100, 200}};
CHECK(snake_core(pts) == std::vector<size_t>{0});
}
TEST_CASE("snake_core handles two points", "[Snake]") {
Points pts{{100, 200}, {300, 400}};
auto p2 = snake_core(pts);
REQUIRE(is_permutation(p2, 2));
}
// --- Core Strategy Tests: Grid Layout ---
TEST_CASE("snake produces good path on grid", "[Snake]") {
Points centers = make_grid_4x4();
auto path = snake_core(centers);
REQUIRE(is_permutation(path, centers.size()));
CHECK(!has_crossings(path, centers));
}
// --- Core Strategy Tests: Variable Row Spacing ---
TEST_CASE("snake handles variable Y spacing", "[Snake]") {
// Rows at Y = 0, 50, 100, 1000 (large gap between last two rows).
// The adaptive row detection should identify the tight cluster (0, 50, 100)
// and the isolated row (1000) without splitting them incorrectly.
Points pts;
pts.emplace_back(0, 0); pts.emplace_back(100000, 0);
pts.emplace_back(0, 50000); pts.emplace_back(100000, 50000);
pts.emplace_back(0, 100000); pts.emplace_back(100000, 100000);
pts.emplace_back(0, 1000000); pts.emplace_back(100000, 1000000);
auto path = snake_core(pts);
REQUIRE(is_permutation(path, pts.size()));
CHECK(!has_crossings(path, pts));
}
// --- Core Strategy Tests: All Points Same Y ---
TEST_CASE("snake handles all points on same Y", "[Snake]") {
// All points share the same Y coordinate. This exercises the
// division-by-zero guard (ys.size() == 1).
Points pts;
for (int i = 0; i < 6; ++i)
pts.emplace_back(100000 * i, 50000);
auto path = snake_core(pts);
REQUIRE(is_permutation(path, pts.size()));
}
// --- Core Strategy Tests: Collinear Points ---
TEST_CASE("snake_core handles collinear points", "[Snake]") {
Points centers = make_linear_5();
auto p2 = snake_core(centers);
REQUIRE(is_permutation(p2, centers.size()));
}
// --- Core Strategy Tests: Ring Layout ---
TEST_CASE("snake_core produces valid paths on ring", "[Snake]") {
Points centers = make_ring_8();
auto p2 = snake_core(centers);
REQUIRE(is_permutation(p2, centers.size()));
}
// --- Core Strategy Tests: Random Layout ---
TEST_CASE("snake_core produces valid paths on random input", "[Snake]") {
Points centers = make_random_16();
auto p2 = snake_core(centers);
REQUIRE(is_permutation(p2, centers.size()));
}
// --- Quality Comparison Tests ---
TEST_CASE("snake has no crossings on random input", "[Snake]") {
Points centers = make_random_16();
auto path = snake_core(centers);
REQUIRE(is_permutation(path, centers.size()));
CHECK(!has_crossings(path, centers));
}
// --- Edge Cases ---
TEST_CASE("snake_core handles duplicate points", "[Snake]") {
Points pts;
pts.emplace_back(100, 200);
pts.emplace_back(100, 200); // duplicate
pts.emplace_back(300, 400);
auto p2 = snake_core(pts);
REQUIRE(p2.size() == pts.size());
}
TEST_CASE("snake_core handles three points", "[Snake]") {
Points pts;
pts.emplace_back(0, 0);
pts.emplace_back(100000, 0);
pts.emplace_back(50000, 86602);
auto p2 = snake_core(pts);
REQUIRE(is_permutation(p2, 3));
}
+108 -32
View File
@@ -5,28 +5,14 @@
#include "libslic3r/PresetBundle.hpp"
#include "libslic3r/AppConfig.hpp"
#include "test_utils.hpp"
using namespace Slic3r;
namespace {
namespace fs = boost::filesystem;
struct TempPresetDir {
fs::path path;
TempPresetDir()
{
path = fs::temp_directory_path() / fs::unique_path("orcaslicer-preset-%%%%-%%%%-%%%%");
fs::create_directories(path);
}
~TempPresetDir()
{
boost::system::error_code ec;
fs::remove_all(path, ec);
}
};
void write_print_preset(const DynamicPrintConfig &default_config, const fs::path &file, const std::string &name, const std::string &inherits = {})
{
DynamicPrintConfig config(default_config);
@@ -82,17 +68,17 @@ struct RenameTestCollection : public PresetCollection
TEST_CASE("Preset identity is canonicalized from load path", "[Preset][Identity]")
{
TempPresetDir temp_dir;
ScopedTemporaryDir temp_dir;
PresetBundle bundle;
PresetsConfigSubstitutions substitutions;
write_print_preset(bundle.prints.default_preset().config, temp_dir.path / PRESET_PRINT_NAME / "User.json", "User");
write_print_preset(bundle.prints.default_preset().config, temp_dir.path / PRESET_LOCAL_DIR / "bundle-1" / PRESET_PRINT_NAME / "LocalBundle.json", "LocalBundle");
write_print_preset(bundle.prints.default_preset().config, temp_dir.path / PRESET_SUBSCRIBED_DIR / "remote-1" / PRESET_PRINT_NAME / "Subscribed.json", "Subscribed");
write_print_preset(bundle.prints.default_preset().config, temp_dir.path() / PRESET_PRINT_NAME / "User.json", "User");
write_print_preset(bundle.prints.default_preset().config, temp_dir.path() / PRESET_LOCAL_DIR / "bundle-1" / PRESET_PRINT_NAME / "LocalBundle.json", "LocalBundle");
write_print_preset(bundle.prints.default_preset().config, temp_dir.path() / PRESET_SUBSCRIBED_DIR / "remote-1" / PRESET_PRINT_NAME / "Subscribed.json", "Subscribed");
bundle.prints.load_presets(temp_dir.path.string(), PRESET_PRINT_NAME, substitutions, ForwardCompatibilitySubstitutionRule::Disable);
bundle.prints.load_presets((temp_dir.path / PRESET_LOCAL_DIR / "bundle-1").string(), PRESET_PRINT_NAME, substitutions, ForwardCompatibilitySubstitutionRule::Disable);
bundle.prints.load_presets((temp_dir.path / PRESET_SUBSCRIBED_DIR / "remote-1").string(), PRESET_PRINT_NAME, substitutions, ForwardCompatibilitySubstitutionRule::Disable);
bundle.prints.load_presets(temp_dir.path().string(), PRESET_PRINT_NAME, substitutions, ForwardCompatibilitySubstitutionRule::Disable);
bundle.prints.load_presets((temp_dir.path() / PRESET_LOCAL_DIR / "bundle-1").string(), PRESET_PRINT_NAME, substitutions, ForwardCompatibilitySubstitutionRule::Disable);
bundle.prints.load_presets((temp_dir.path() / PRESET_SUBSCRIBED_DIR / "remote-1").string(), PRESET_PRINT_NAME, substitutions, ForwardCompatibilitySubstitutionRule::Disable);
const Preset *root_user = bundle.prints.find_preset("User");
REQUIRE(root_user != nullptr);
@@ -112,14 +98,14 @@ TEST_CASE("Preset identity is canonicalized from load path", "[Preset][Identity]
TEST_CASE("Legacy bundle import without bundle metadata stays in the user preset directory", "[Preset][Identity]")
{
TempPresetDir temp_dir;
ScopedTemporaryDir temp_dir;
PresetBundle bundle;
PresetsConfigSubstitutions substitutions;
std::vector<std::string> result;
int overwrite = 0;
std::string file = (temp_dir.path / "legacy-bundle" / "Imported.json").string();
const fs::path user_root = temp_dir.path / "user";
std::string file = (temp_dir.path() / "legacy-bundle" / "Imported.json").string();
const fs::path user_root = temp_dir.path() / "user";
write_print_preset(bundle.prints.default_preset().config, file, "Imported");
fs::create_directories(user_root);
@@ -252,7 +238,7 @@ TEST_CASE("find_preset2 auto-matches removed Generic vendor profiles to the libr
TEST_CASE("Renamed parent is normalized into a loaded preset's inherits", "[Preset][Rename]")
{
TempPresetDir temp_dir;
ScopedTemporaryDir temp_dir;
RenameTestCollection coll;
// Current parent, renamed from "Old Process".
@@ -262,10 +248,10 @@ TEST_CASE("Renamed parent is normalized into a loaded preset's inherits", "[Pres
// A user preset on disk that still inherits the OLD name.
write_preset_with_inherits(coll.default_preset().config,
temp_dir.path / PRESET_PRINT_NAME / "Child.json", "Child", "Old Process");
temp_dir.path() / PRESET_PRINT_NAME / "Child.json", "Child", "Old Process");
PresetsConfigSubstitutions substitutions;
coll.load_presets(temp_dir.path.string(), PRESET_PRINT_NAME, substitutions,
coll.load_presets(temp_dir.path().string(), PRESET_PRINT_NAME, substitutions,
ForwardCompatibilitySubstitutionRule::Disable);
const Preset *child = coll.find_preset("Child");
@@ -279,17 +265,17 @@ TEST_CASE("Renamed parent is normalized into a loaded preset's inherits", "[Pres
TEST_CASE("Removed Generic parent is normalized into a loaded filament's inherits", "[Preset][Rename]")
{
TempPresetDir temp_dir;
ScopedTemporaryDir temp_dir;
PresetBundle bundle;
add_inmemory_preset(bundle.filaments, "Generic PLA @System");
// A user filament that still inherits a removed "<vendor> Generic PLA" profile.
write_preset_with_inherits(bundle.filaments.default_preset().config,
temp_dir.path / PRESET_FILAMENT_NAME / "MyPLA.json", "MyPLA", "Voron Generic PLA");
temp_dir.path() / PRESET_FILAMENT_NAME / "MyPLA.json", "MyPLA", "Voron Generic PLA");
PresetsConfigSubstitutions substitutions;
bundle.filaments.load_presets(temp_dir.path.string(), PRESET_FILAMENT_NAME, substitutions,
bundle.filaments.load_presets(temp_dir.path().string(), PRESET_FILAMENT_NAME, substitutions,
ForwardCompatibilitySubstitutionRule::Disable);
const Preset *child = bundle.filaments.find_preset("MyPLA");
@@ -464,3 +450,93 @@ TEST_CASE("Profile validator flags dangling and renamed preset references", "[Pr
}
}
// Under a shared override key, the last preset merged into the full config overwrote the others', so an
// edited slicing-pipeline override never reached Print::apply's diff and re-configuring a plugin never
// re-sliced. Per-type keys make that collision impossible; guard the scoping here.
TEST_CASE("Plugin capability override keys are scoped per preset type", "[Preset][Plugin]")
{
// Pin the key names: presets and 3mf files store them verbatim, so a rename is a format change.
CHECK(Preset::plugin_overrides_key(Preset::TYPE_PRINT) == std::string("print_plugin_config_overrides"));
CHECK(Preset::plugin_overrides_key(Preset::TYPE_PRINTER) == std::string("printer_plugin_config_overrides"));
CHECK(Preset::plugin_overrides_key(Preset::TYPE_FILAMENT) == std::string("filament_plugin_config_overrides"));
// ...and each key lives on exactly its own preset type's option list, so no two ever share a slot.
const std::pair<Preset::Type, const std::vector<std::string>*> scopes[] = {
{Preset::TYPE_PRINT, &Preset::print_options()},
{Preset::TYPE_PRINTER, &Preset::printer_options()},
{Preset::TYPE_FILAMENT, &Preset::filament_options()},
};
for (const auto &owner : scopes)
for (const auto &scoped : scopes) {
const std::string key = Preset::plugin_overrides_key(scoped.first);
CAPTURE(owner.first, key);
CHECK(contains(*owner.second, key) == (owner.first == scoped.first));
}
}
namespace {
// A standalone filament collection that exposes the protected library masking builder, so the Orca
// Filament Library scenario can be set up without the full system-profile load pipeline.
struct LibraryFilamentTestCollection : public PresetCollection
{
LibraryFilamentTestCollection()
: PresetCollection(Preset::TYPE_FILAMENT, Preset::filament_options(),
static_cast<const PrintRegionConfig &>(FullPrintConfig::defaults()))
{}
using PresetCollection::update_library_profile_excluded_from;
};
} // namespace
// Orca: a filament in the Orca Filament Library that names its compatible printers has to hide the generic
// library filament sharing its alias, the same way a vendor owned filament does. Otherwise both are compatible
// with that printer and the plater combo box lists the shared alias twice.
TEST_CASE("A printer specific filament supersedes the generic library filament with the same alias", "[Preset][Bundle]")
{
LibraryFilamentTestCollection filaments;
PresetCollection printers(Preset::TYPE_PRINTER, Preset::printer_options(),
static_cast<const PrintRegionConfig &>(FullPrintConfig::defaults()));
// The masking keys off the vendor name, which VendorProfile's constructor does not derive from the id.
VendorProfile library(PresetBundle::ORCA_FILAMENT_LIBRARY);
VendorProfile vendor("Vendor");
library.name = PresetBundle::ORCA_FILAMENT_LIBRARY;
vendor.name = "Vendor";
auto add_filament = [&filaments](const VendorProfile &owner, const std::string &name, std::vector<std::string> compatible_printers) {
Preset &preset = add_inmemory_preset(filaments, name);
preset.alias = "Generic ABS";
preset.vendor = &owner;
preset.config.option<ConfigOptionStrings>("compatible_printers", true)->values = std::move(compatible_printers);
};
add_filament(library, "Generic ABS @System", {});
add_filament(library, "Generic ABS @Printer A", { "Printer A" });
add_filament(vendor, "Generic ABS @Printer B", { "Printer B" });
filaments.update_library_profile_excluded_from();
const Preset *generic = filaments.find_preset("Generic ABS @System");
REQUIRE(generic != nullptr);
CHECK(generic->m_excluded_from.count("Printer A") == 1);
CHECK(generic->m_excluded_from.count("Printer B") == 1);
CHECK(generic->m_excluded_from.size() == 2);
// A printer specific profile names printers, so it is never the one being hidden - not even by itself.
const Preset *specific = filaments.find_preset("Generic ABS @Printer A");
REQUIRE(specific != nullptr);
CHECK(specific->m_excluded_from.empty());
// ...and the generic profile really drops out of the compatible set on the printer it is hidden from.
add_inmemory_preset(printers, "Printer A");
add_inmemory_preset(printers, "Printer C");
const Preset *printer_a = printers.find_preset("Printer A");
const Preset *printer_c = printers.find_preset("Printer C");
REQUIRE(printer_a != nullptr);
REQUIRE(printer_c != nullptr);
const PresetWithVendorProfile generic_lib(*generic, &library);
CHECK_FALSE(is_compatible_with_printer(generic_lib, PresetWithVendorProfile(*printer_a, nullptr)));
CHECK(is_compatible_with_printer(generic_lib, PresetWithVendorProfile(*printer_c, nullptr)));
}
@@ -8,6 +8,8 @@
#include "libslic3r/Print.hpp"
#include "libslic3r/TriangleMesh.hpp"
#include "test_utils.hpp"
#include <algorithm>
#include <map>
#include <set>
@@ -500,6 +502,52 @@ TEST_CASE("Re-applying an unchanged config after slicing keeps the result valid"
REQUIRE(print.is_step_done(psSlicingFinished));
}
TEST_CASE("A degenerate process variant map on a custom multi-extruder printer slices to a stable result", "[Print][Regression]")
{
// Non-BBL multi-extruder printers get machine-scope variant columns synthesized on preset
// load (extend_extruder_variant), but nothing ships process-scope print_extruder_id /
// print_extruder_variant: presets and 3mf project configs carry the length-1 defaults. The
// apply-time expansion must synthesize the process columns from extruder_variant_list;
// otherwise the failed per-extruder lookups collapse the per-extruder retract overrides
// during slicing and the post-slice re-apply invalidates every fresh result, forever.
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
config.set_num_extruders(5);
config.option<ConfigOptionFloats>("nozzle_diameter", true)->values = {0.4, 0.4, 0.4, 0.4, 0.4};
// per-extruder machine values that a first-slot collapse would destroy
config.option<ConfigOptionPercents>("retract_before_wipe", true)->values = {100., 70., 70., 70., 100.};
config.option<ConfigOptionEnumsGeneric>("z_hop_types", true)->values = {zhtSlope, zhtNormal, zhtNormal, zhtNormal, zhtSlope};
// filament presets carry the nullable override twins (all-nil = "no override"); they are what
// routes the machine values through apply_override in the in-slice override recompute
config.option<ConfigOptionPercentsNullable>("filament_retract_before_wipe", true)->values =
std::vector<double>(5, ConfigOptionPercentsNullable::nil_value());
config.option<ConfigOptionEnumsGenericNullable>("filament_z_hop_types", true)->values =
std::vector<int>(5, ConfigOptionEnumsGenericNullable::nil_value());
config.option<ConfigOptionFloats>("filament_diameter", true)->values = std::vector<double>(5, 1.75);
config.option<ConfigOptionStrings>("filament_colour", true)->values = {"#FF0000", "#00FF00", "#0000FF", "#FFFF00", "#00FFFF"};
config.option<ConfigOptionInts>("filament_map", true)->values = {1, 2, 3, 4, 1};
Model model;
model.add_object("cube", "", make_cube(20, 20, 20))->add_instance()->set_offset(Vec3d(100., 100., 0.));
Print print;
print.apply(model, config);
print.process();
REQUIRE(print.is_step_done(psSlicingFinished));
// BackgroundSlicingProcess reads the engine-computed maps back into the plate config after
// slicing; the next apply overlays that written-back state.
config.option<ConfigOptionInts>("filament_map", true)->values = print.get_filament_maps();
config.option<ConfigOptionInts>("filament_volume_map", true)->values = print.get_filament_volume_maps();
config.option<ConfigOptionInts>("filament_nozzle_map", true)->values = print.get_filament_nozzle_maps();
auto status = print.apply(model, config);
REQUIRE(status == PrintBase::APPLY_STATUS_UNCHANGED);
REQUIRE(print.is_step_done(psSlicingFinished));
// the per-extruder machine values must survive the in-slice override recompute
REQUIRE(print.config().retract_before_wipe.values == std::vector<double>({100., 70., 70., 70., 100.}));
REQUIRE(print.config().z_hop_types.values == std::vector<int>({zhtSlope, zhtNormal, zhtNormal, zhtNormal, zhtSlope}));
}
TEST_CASE("normalize_nozzle_map_per_layer makes per-filament assignments gap-free", "[MultiNozzle][H2C][Dynamic]")
{
SECTION("gaps inherit the last used nozzle, entries on used layers stay untouched") {
@@ -662,10 +710,9 @@ TEST_CASE("Sequential selector prints publish a stitched result and cache the pl
REQUIRE(print.config().filament_self_index.values.size() >= print.config().filament_map.values.size());
// Export must consume the cached plans and produce g-code without throwing.
boost::filesystem::path gcode_path = boost::filesystem::temp_directory_path() / "orca_seq_dynamic_publish_test.gcode";
REQUIRE_NOTHROW(print.export_gcode(gcode_path.string(), nullptr, nullptr));
REQUIRE(boost::filesystem::exists(gcode_path));
boost::filesystem::remove(gcode_path);
ScopedTemporaryFile gcode(".gcode");
REQUIRE_NOTHROW(print.export_gcode(gcode.string(), nullptr, nullptr));
REQUIRE(boost::filesystem::exists(gcode.path()));
}
TEST_CASE("Per-variant expansion gives migrating filaments one slot per variant", "[PrintConfig][H2C][Dynamic]")
+1 -1
View File
@@ -229,7 +229,7 @@ TEST_CASE("halfcone test", "[halfcone]") {
indexed_triangle_set m = sla::get_mesh(br, 45);
its_merge_vertices(m);
its_write_obj(m, "Halfcone.obj");
write_debug_obj("sla_print/Halfcone.obj", m);
}
TEST_CASE("Test concurrency")
+5 -5
View File
@@ -13,7 +13,7 @@ TEST_CASE("Overhanging point should be supported", "[SupGen]") {
// Pyramid with 45 deg slope
TriangleMesh mesh = make_pyramid(10.f, 10.f);
mesh.rotate_y(float(PI));
mesh.WriteOBJFile("Pyramid.obj");
write_debug_obj("sla_supptgen/Pyramid.obj", mesh);
sla::SupportPoints pts = calc_support_pts(mesh);
@@ -55,7 +55,7 @@ TEST_CASE("Overhanging horizontal surface should be supported", "[SupGen]") {
TriangleMesh mesh = make_cube(width, depth, height);
mesh.translate(0., 0., 5.); // lift up
mesh.WriteOBJFile("Cuboid.obj");
write_debug_obj("sla_supptgen/Cuboid.obj", mesh);
sla::SupportPointGenerator::Config cfg;
sla::SupportPoints pts = calc_support_pts(mesh, cfg);
@@ -81,7 +81,7 @@ TEST_CASE("Overhanging edge should be supported", "[SupGen]") {
TriangleMesh mesh = make_prism(width, depth, height);
mesh.rotate_y(float(PI)); // rotate on its back
mesh.translate(0., 0., height);
mesh.WriteOBJFile("Prism.obj");
write_debug_obj("sla_supptgen/Prism.obj", mesh);
sla::SupportPointGenerator::Config cfg;
sla::SupportPoints pts = calc_support_pts(mesh, cfg);
@@ -106,7 +106,7 @@ TEST_CASE("Hollowed cube should be supported from the inside", "[SupGen][Hollowe
hollow_mesh(mesh, HollowingConfig{});
mesh.WriteOBJFile("cube_hollowed.obj");
write_debug_obj("sla_supptgen/cube_hollowed.obj", mesh);
auto bb = mesh.bounding_box();
auto h = float(bb.max.z() - bb.min.z());
@@ -129,7 +129,7 @@ TEST_CASE("Two parallel plates should be supported", "[SupGen][Hollowed]")
mesh_high.translate(0., 0., 10.); // lift up
mesh.merge(mesh_high);
mesh.WriteOBJFile("parallel_plates.obj");
write_debug_obj("sla_supptgen/parallel_plates.obj", mesh);
sla::SupportPointGenerator::Config cfg;
sla::SupportPoints pts = calc_support_pts(mesh, cfg);
+15 -16
View File
@@ -47,8 +47,9 @@ void test_support_model_collision(const std::string &obj_filename,
notouch = notouch && area(intersections) < PI * pinhead_r * pinhead_r;
}
/*if (!notouch) */export_failed_case(support_slices, byproducts);
if (!notouch)
export_failed_case(support_slices, byproducts);
REQUIRE(notouch);
}
@@ -62,11 +63,11 @@ void export_failed_case(const std::vector<ExPolygons> &support_slices, const Sup
std::stringstream ss;
if (!intersections.empty()) {
ss << byproducts.obj_fname << std::setprecision(4) << n << ".svg";
SVG svg(ss.str());
svg.draw(sup_slice, "green");
svg.draw(mod_slice, "blue");
svg.draw(intersections, "red");
svg.Close();
write_debug_svg("sla/" + ss.str(), [&](SVG &svg) {
svg.draw(sup_slice, "green");
svg.draw(mod_slice, "blue");
svg.draw(intersections, "red");
});
}
}
@@ -74,8 +75,8 @@ void export_failed_case(const std::vector<ExPolygons> &support_slices, const Sup
byproducts.supporttree.retrieve_full_mesh(its);
TriangleMesh m{its};
m.merge(byproducts.input_mesh);
m.WriteOBJFile((Catch::getResultCapture().getCurrentTestName() + "_" +
byproducts.obj_fname).c_str());
write_debug_obj("sla/" + Catch::getResultCapture().getCurrentTestName() +
"_" + byproducts.obj_fname, m);
}
void test_supports(const std::string &obj_filename,
@@ -350,13 +351,11 @@ void check_raster_transformations(sla::RasterBase::Orientation o, sla::RasterBas
REQUIRE((w < res.width_px && h < res.height_px));
auto px = raster.read_pixel(w, h);
if (px != FullWhite) {
std::fstream outf("out.png", std::ios::out);
outf << raster.encode(sla::PNGRasterEncoder());
}
if (px != FullWhite)
write_debug_stream("sla/raster_transform_mismatch.png",
[&] { return raster.encode(sla::PNGRasterEncoder()); });
REQUIRE(px == FullWhite);
}
+9 -11
View File
@@ -6,6 +6,8 @@
#include <string>
#include "test_utils.hpp"
namespace Slic3r {
// Point data_dir() at a throwaway directory for the lifetime of a test and
@@ -13,24 +15,20 @@ namespace Slic3r {
// disposable tree and tests don't leak state into each other.
struct ScopedDataDir
{
ScopedTemporaryDir tmp; // owns the temp dir (create + recursive remove)
boost::filesystem::path dir; // = tmp.path(); kept as a member for callers
std::string previous;
boost::filesystem::path dir;
explicit ScopedDataDir(const std::string& tag)
: tmp("orca-" + tag), dir(tmp.path()), previous(data_dir())
{
namespace fs = boost::filesystem;
previous = data_dir();
dir = fs::temp_directory_path() / fs::unique_path("orca-" + tag + "-%%%%-%%%%");
fs::create_directories(dir);
set_data_dir(dir.string());
}
~ScopedDataDir()
{
set_data_dir(previous);
boost::system::error_code ec;
boost::filesystem::remove_all(dir, ec);
}
~ScopedDataDir() { set_data_dir(previous); } // tmp removes the directory
// The plugin manager scans {data_dir}/orca_plugins.
boost::filesystem::path plugins_dir() const { return dir / "orca_plugins"; }
ScopedDataDir(const ScopedDataDir&) = delete;
ScopedDataDir& operator=(const ScopedDataDir&) = delete;
+58 -19
View File
@@ -6,6 +6,8 @@
#include "libslic3r/Utils.hpp"
#include "slic3r/Utils/bambu_networking.hpp"
#include "plugin_test_utils.hpp"
using namespace Slic3r;
namespace fs = boost::filesystem;
@@ -25,27 +27,16 @@ static const char* PLUGIN_EXT = ".so";
struct PluginFolderFixture
{
fs::path root;
std::string previous_data_dir;
ScopedDataDir data{"netver"};
PluginFolderFixture()
{
previous_data_dir = data_dir();
root = fs::temp_directory_path() / fs::unique_path("orca-netver-%%%%%%%%");
fs::create_directories(root / "plugins");
set_data_dir(root.string());
}
~PluginFolderFixture()
{
set_data_dir(previous_data_dir);
boost::system::error_code ec;
fs::remove_all(root, ec);
fs::create_directories(data.dir / "plugins");
}
void add_plugin(const std::string& version)
{
boost::nowide::ofstream f((root / "plugins" / (PLUGIN_PREFIX + version + PLUGIN_EXT)).string());
boost::nowide::ofstream f((data.dir / "plugins" / (PLUGIN_PREFIX + version + PLUGIN_EXT)).string());
f << "stub";
}
};
@@ -85,7 +76,8 @@ TEST_CASE_METHOD(PluginFolderFixture, "Managed builds fold into the series; cust
{
add_plugin("02.08.01.55"); // managed, same series -> folded into the 02.08.01 row
add_plugin("02.09.00.10"); // managed, unknown series -> not listed
add_plugin("02.03.00.62"); // managed, series no longer whitelisted -> not listed
add_plugin("02.03.00.62"); // managed, older whitelisted series -> folded into 02.03.00
add_plugin("02.01.01.52"); // managed, series with no ABI in this build -> not listed
add_plugin("02.08.01_custom"); // custom, whitelisted series -> listed under it
add_plugin("02.08.01.52-dev"); // custom (dash-suffixed), whitelisted series -> listed
@@ -96,17 +88,24 @@ TEST_CASE_METHOD(PluginFolderFixture, "Managed builds fold into the series; cust
REQUIRE(count_version(versions, "02.08.01") == 1);
REQUIRE(count_version(versions, "02.09.00.10") == 0);
REQUIRE(count_version(versions, "02.03.00.62") == 0);
REQUIRE(count_version(versions, "02.03.00") == 1);
REQUIRE(count_version(versions, "02.01.01.52") == 0);
// Custom-named builds are distinct files kept under their own name.
REQUIRE(count_version(versions, "02.08.01_custom") == 1);
REQUIRE(count_version(versions, "02.08.01.52-dev") == 1);
// Newest series first, its customs nested under it (suffix sort: "" < ".52-dev" < "_custom"),
// legacy last.
// then older series, legacy last.
REQUIRE(versions[0].version == "02.08.01");
REQUIRE(versions[1].version == "02.08.01.52-dev");
REQUIRE(versions[2].version == "02.08.01_custom");
REQUIRE(versions[3].version == "02.03.00");
REQUIRE(versions.back().version == BAMBU_NETWORK_AGENT_VERSION_LEGACY);
// An older whitelisted series is a flat row of its own, and never holds "(Latest)".
REQUIRE(versions[3].suffix.empty());
REQUIRE_FALSE(versions[3].is_latest);
// Customs sort/render nested under their series (non-empty suffix, base = the series).
REQUIRE(versions[1].base_version == "02.08.01");
REQUIRE_FALSE(versions[1].suffix.empty());
@@ -137,6 +136,16 @@ TEST_CASE_METHOD(PluginFolderFixture, "Only the loaded series is marked installe
REQUIRE(marked == 1);
}
// An older series is marked the same way, and never bleeds onto the latest row.
{
add_plugin("02.03.00.62");
auto versions = get_all_available_versions("02.03.00.62");
int marked = 0;
for (const auto& info : versions)
if (info.is_loaded) { ++marked; REQUIRE(info.version == "02.03.00"); }
REQUIRE(marked == 1);
}
// A loaded custom build matches its own row, never the bare series.
{
auto versions = get_all_available_versions("02.08.01_custom");
@@ -153,19 +162,25 @@ TEST_CASE_METHOD(PluginFolderFixture, "Only the loaded series is marked installe
TEST_CASE("Only whitelisted series pass the load gate", "[NetworkVersions]")
{
// The whitelisted series, its builds, and custom-named builds of that series.
// Each whitelisted series, its builds, and custom-named builds of that series.
REQUIRE(is_supported_network_version("02.08.01"));
REQUIRE(is_supported_network_version("02.08.01.52"));
REQUIRE(is_supported_network_version("02.08.01.55"));
REQUIRE(is_supported_network_version("02.08.01_custom"));
REQUIRE(is_supported_network_version("02.08.01.52-dev"));
REQUIRE(is_supported_network_version("02.03.00"));
REQUIRE(is_supported_network_version("02.03.00.62"));
REQUIRE(is_supported_network_version("02.03.00.70"));
REQUIRE(is_supported_network_version("02.03.00_custom"));
REQUIRE(is_supported_network_version(BAMBU_NETWORK_AGENT_VERSION_LEGACY));
// Series whitelisted by previous Orca releases - their ABI no longer matches.
REQUIRE_FALSE(is_supported_network_version("02.03.00.62"));
// Series whitelisted by previous Orca releases that no generation here can call.
REQUIRE_FALSE(is_supported_network_version("02.01.01.52"));
REQUIRE_FALSE(is_supported_network_version("02.00.02.50"));
// A neighbouring series of a whitelisted one is still its own ABI.
REQUIRE_FALSE(is_supported_network_version("02.03.01.51"));
// Unknown series, legacy siblings, and malformed values.
REQUIRE_FALSE(is_supported_network_version("02.09.00.10"));
std::string legacy = BAMBU_NETWORK_AGENT_VERSION_LEGACY;
@@ -175,6 +190,30 @@ TEST_CASE("Only whitelisted series pass the load gate", "[NetworkVersions]")
REQUIRE_FALSE(is_supported_network_version("02.08"));
}
TEST_CASE("Each version resolves to the ABI generation that can call it", "[NetworkVersions]")
{
// The generation is keyed on the series, so every build of a series - including the
// custom-named ones - resolves to the same one.
CHECK(network_plugin_abi("02.08.01") == NetworkAbi::Current);
CHECK(network_plugin_abi("02.08.01.55") == NetworkAbi::Current);
CHECK(network_plugin_abi("02.08.01.52-dev") == NetworkAbi::Current);
CHECK(network_plugin_abi("02.03.00") == NetworkAbi::V0203);
CHECK(network_plugin_abi("02.03.00.62") == NetworkAbi::V0203);
CHECK(network_plugin_abi("02.03.00_custom") == NetworkAbi::V0203);
CHECK(network_plugin_abi(BAMBU_NETWORK_AGENT_VERSION_LEGACY) == NetworkAbi::Legacy);
// Anything the load gate rejects must dispatch through nothing at all, rather than
// defaulting to a layout it does not share.
CHECK(network_plugin_abi("02.01.01.52") == NetworkAbi::Unsupported);
CHECK(network_plugin_abi("02.00.02.50") == NetworkAbi::Unsupported);
CHECK(network_plugin_abi("02.09.00.10") == NetworkAbi::Unsupported);
CHECK(network_plugin_abi("") == NetworkAbi::Unsupported);
// A series may only be offered once the dispatch layer implements its generation.
for (size_t i = 0; i < AVAILABLE_NETWORK_VERSIONS_COUNT; ++i)
CHECK(AVAILABLE_NETWORK_VERSIONS[i].abi != NetworkAbi::Unsupported);
}
TEST_CASE_METHOD(PluginFolderFixture, "Legacy series never adopts discovered builds", "[NetworkVersions]")
{
// A different build of the legacy series must not be surfaced: is_legacy_version()
@@ -13,6 +13,7 @@
#include <boost/filesystem.hpp>
#include <nlohmann/json.hpp>
#include <chrono>
#include <fstream>
#include <string>
@@ -48,10 +49,45 @@ const char* const CLOUD_PLUGIN_SOURCE = R"PY(# /// script
# version = "1.0"
# ///
print('ok')
import orca
class stubscript(orca.script.ScriptPluginCapabilityBase):
def get_name(self):
return "stubscript"
def execute(self):
return orca.ExecutionResult.success("Stub orca script.")
@orca.plugin
class stubpackage(orca.base):
def register_capabilities(self):
orca.register_capability(stubscript)
)PY";
} // namespace
TEST_CASE("plugin latest version uses the authoritative catalog field", "[PluginDescriptor]")
{
PluginDescriptor descriptor;
descriptor.version = "1.3.0";
descriptor.latest_version = "1.3.0";
PluginChangelog changelog;
changelog.version = "1.2.0";
descriptor.changelog.push_back(changelog);
CHECK(descriptor.latest_available_version() == "1.3.0");
}
TEST_CASE("plugin latest version falls back to the descriptor version", "[PluginDescriptor]")
{
PluginDescriptor descriptor;
descriptor.version = "1.1.0";
PluginChangelog changelog;
changelog.version = "1.0.0";
descriptor.changelog.push_back(changelog);
CHECK(descriptor.latest_available_version() == "1.1.0");
}
// Regression: update_cloud_metadata() replaces a matched entry's descriptor wholesale with the
// cloud catalog record (`entry = cloud_entry`). Configuration used to ride on the descriptor, so
// that overwrite silently wiped it and plugins fell back to their built-in defaults (found via
@@ -129,4 +165,31 @@ TEST_CASE("cloud metadata refresh preserves a plugin's stored config", "[PluginC
reloaded.load();
REQUIRE(reloaded.has_config(id));
CHECK(reloaded.get_config(id)->config == configured);
// A local package can remain after the cloud subscription disappears. The cloud identity is
// retained for diagnosis, but the orphaned state must suppress update availability until the
// plugin is returned by a later cloud refresh.
PluginDescriptor orphaned_record = cloud_record;
orphaned_record.cloud->orphaned = true;
orphaned_record.cloud->update_available = true;
manager.update_cloud_metadata({orphaned_record});
const PluginDescriptor orphaned = find_by_uuid();
REQUIRE(orphaned.cloud.has_value());
CHECK(orphaned.cloud->orphaned);
CHECK_FALSE(orphaned.has_error());
CHECK(orphaned.get_update_status() == PluginUpdateStatus::Normal);
// Orphaned is informational only: the local package must remain loadable and usable.
std::string load_error;
manager.load_plugin(uuid, /*skip_deps=*/true);
REQUIRE(manager.wait_for_plugin_load(uuid, std::chrono::seconds(120), load_error));
INFO("load error: " << load_error);
CHECK(load_error.empty());
CHECK(manager.is_plugin_loaded(uuid));
CHECK(manager.unload_plugin(uuid));
// Seeing the plugin in a subsequent cloud response clears the orphaned marker.
manager.update_cloud_metadata({cloud_record});
CHECK_FALSE(find_by_uuid().cloud->orphaned);
}
+2 -26
View File
@@ -6,6 +6,8 @@
#include <slic3r/plugin/PluginFsUtils.hpp>
#include <slic3r/plugin/PythonInterpreter.hpp>
#include "plugin_test_utils.hpp"
#include <boost/filesystem.hpp>
#include <algorithm>
@@ -25,32 +27,6 @@ namespace fs = boost::filesystem;
namespace {
// Point data_dir() at a throwaway directory for the lifetime of a test and restore the previous
// value afterwards, so discovery scans a disposable {data_dir}/orca_plugins tree and tests don't
// leak state into each other.
struct ScopedDataDir
{
std::string previous;
fs::path dir;
explicit ScopedDataDir(const std::string& tag)
{
previous = data_dir();
dir = fs::temp_directory_path() / fs::unique_path("orca-" + tag + "-%%%%-%%%%");
fs::create_directories(dir);
set_data_dir(dir.string());
}
~ScopedDataDir()
{
set_data_dir(previous);
boost::system::error_code ec;
fs::remove_all(dir, ec);
}
fs::path plugins_dir() const { return dir / "orca_plugins"; }
};
// Brings the plugin system up, and tears it down explicitly at the end of the test.
//
// Shutting the interpreter down here, rather than leaving it to PythonInterpreter's static
@@ -16,6 +16,8 @@ TEST_CASE("SlicingPipeline capability-type string maps round-trip", "[slicing_pi
#include "libslic3r/Point.hpp"
#include "libslic3r/ExPolygon.hpp"
#include "libslic3r/Surface.hpp"
#include "test_utils.hpp"
#include "libslic3r/Layer.hpp"
#include "libslic3r/ExtrusionEntity.hpp"
#include "libslic3r/ExtrusionEntityCollection.hpp"
@@ -142,7 +144,7 @@ TEST_CASE("orca.slicing psGCodePostProcess context: file edit in place + config
import_orca_module();
py::gil_scoped_acquire gil;
const fs::path gpath = fs::temp_directory_path() / fs::unique_path("orca_pp_%%%%-%%%%.gcode");
ScopedTemporaryFile gpath(".gcode");
{
boost::nowide::ofstream ofs(gpath.string());
ofs << "; header\nG1 X0 Y0\n";
@@ -196,9 +198,7 @@ _pp_result = Stamp().execute(_pp_ctx)
boost::nowide::ifstream ifs(gpath.string());
std::stringstream ss; ss << ifs.rdbuf(); contents = ss.str();
}
CHECK(contents.find("; stamped by File") != std::string::npos);
fs::remove(gpath);
}
CHECK(contents.find("; stamped by File") != std::string::npos);}
// ---------------------------------------------------------------------------
// Toolpath helpers for the raw-graph tests.
+127 -14
View File
@@ -3,9 +3,14 @@
#include <libslic3r/TriangleMesh.hpp>
#include <libslic3r/Format/OBJ.hpp>
#include <libslic3r/SVG.hpp>
#include <boost/filesystem.hpp>
#include <cstdio>
#include <fstream>
#include <string>
#if defined(WIN32) || defined(_WIN32)
#define PATH_SEPARATOR R"(\)"
#else
@@ -22,26 +27,134 @@ 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
// ---------------------------------------------------------------------------
// Scoped temporary paths
// ---------------------------------------------------------------------------
// Owns a unique path under the system temp dir, "<prefix>-<unique>[<extension>]"
// (parallel-safe, cross-platform). Shared base for the two RAII temp guards below.
class ScopedTemporaryPath
{
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(); }
ScopedTemporaryPath(const ScopedTemporaryPath &) = delete;
ScopedTemporaryPath &operator=(const ScopedTemporaryPath &) = delete;
protected:
ScopedTemporaryPath(const std::string &prefix, const std::string &extension)
: m_path(boost::filesystem::temp_directory_path()
/ boost::filesystem::unique_path(prefix + "-%%%%-%%%%-%%%%" + extension))
{}
~ScopedTemporaryPath() = default; // non-virtual: never deleted through a base pointer
private:
boost::filesystem::path m_path;
};
// A temp file the caller creates by writing to path()/string(); the guard only
// reserves the name and removes the file on scope exit.
class ScopedTemporaryFile : public ScopedTemporaryPath
{
public:
explicit ScopedTemporaryFile(const std::string &extension = ".tmp")
: ScopedTemporaryPath("orca", extension) {}
~ScopedTemporaryFile() { boost::system::error_code ec; boost::filesystem::remove(m_path, ec); }
};
// A temp directory created on construction and removed recursively on scope exit.
class ScopedTemporaryDir : public ScopedTemporaryPath
{
public:
explicit ScopedTemporaryDir(const std::string &prefix = "orca")
: ScopedTemporaryPath(prefix, "") { boost::filesystem::create_directories(m_path); }
~ScopedTemporaryDir() { boost::system::error_code ec; boost::filesystem::remove_all(m_path, ec); }
};
// ---------------------------------------------------------------------------
// Debug-only test artifacts
//
// Files a test dumps for inspection: a mesh, an SVG, or any streamable blob such
// as a PNG. In debug builds each run writes to a fresh temp folder (path printed
// once); the name may include a subfolder (e.g. "marchingsquares/foo.svg").
// ---------------------------------------------------------------------------
// Maps name to a path under the run's temp folder, creating any parent dirs
// (forward slashes work on Windows). Not gated, so only call it from a
// write_debug_* helper or inside an #ifndef NDEBUG block.
inline std::string debug_artifact_path(const std::string &name)
{
static const boost::filesystem::path root = [] {
boost::filesystem::path dir = boost::filesystem::temp_directory_path()
/ boost::filesystem::unique_path("orca-test-artifacts-%%%%-%%%%");
boost::filesystem::create_directories(dir);
std::fprintf(stderr, "Debug test artifacts will be written to %s\n", dir.string().c_str());
return dir;
}();
boost::filesystem::path full = root / name;
boost::filesystem::create_directories(full.parent_path());
return full.string();
}
// Dump a mesh as OBJ.
inline void write_debug_obj([[maybe_unused]] const std::string &name,
[[maybe_unused]] const Slic3r::TriangleMesh &mesh)
{
#ifndef NDEBUG
mesh.WriteOBJFile(debug_artifact_path(name).c_str());
#endif
}
inline void write_debug_obj([[maybe_unused]] const std::string &name,
[[maybe_unused]] const indexed_triangle_set &its)
{
#ifndef NDEBUG
its_write_obj(its, debug_artifact_path(name).c_str());
#endif
}
// Dump a mesh as ASCII STL.
inline void write_debug_stl([[maybe_unused]] const std::string &name,
[[maybe_unused]] const Slic3r::TriangleMesh &mesh)
{
#ifndef NDEBUG
mesh.write_ascii(debug_artifact_path(name).c_str());
#endif
}
// Draw an SVG artifact through a callback that receives the open SVG. Second
// overload takes a BoundingBox when the drawing needs one.
template<class Draw>
inline void write_debug_svg([[maybe_unused]] const std::string &name, [[maybe_unused]] Draw &&draw)
{
#ifndef NDEBUG
Slic3r::SVG svg(debug_artifact_path(name));
draw(svg);
svg.Close();
#endif
}
template<class Draw>
inline void write_debug_svg([[maybe_unused]] const std::string &name,
[[maybe_unused]] const Slic3r::BoundingBox &bbox,
[[maybe_unused]] Draw &&draw)
{
#ifndef NDEBUG
Slic3r::SVG svg(debug_artifact_path(name), bbox);
draw(svg);
svg.Close();
#endif
}
// Write a callback's result (e.g. raster.encode(sla::PNGRasterEncoder{})) to an
// artifact. operator<< is resolved by ADL at the call site, so this header needn't
// include the producer's headers.
template<class Produce>
inline void write_debug_stream([[maybe_unused]] const std::string &name, [[maybe_unused]] Produce &&produce)
{
#ifndef NDEBUG
std::ofstream out(debug_artifact_path(name), std::ios::out | std::ios::binary);
out << produce();
#endif
}
#endif // SLIC3R_TEST_UTILS