mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-10-04 22:31:02 +00:00
Merge branch 'main' into feature/texture_displacement
This commit is contained in:
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
Executable
+1282
File diff suppressed because it is too large
Load Diff
Executable
+1545
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
|
||||
@@ -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")
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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 = [®ion](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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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]")
|
||||
{
|
||||
|
||||
@@ -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 ¢er_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 ¢er_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") {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -191,22 +191,21 @@ static void test_expolys(Rst&& rst, const ExPolygons& ref, Vec2i32 window, const
|
||||
for (const ExPolygon& expoly : ref)
|
||||
rst.draw(expoly);
|
||||
|
||||
std::fstream out(name + ".png", std::ios::out);
|
||||
out << rst.encode(sla::PNGRasterEncoder{});
|
||||
out.close();
|
||||
write_debug_stream("marchingsquares/" + name + ".png",
|
||||
[&] { return rst.encode(sla::PNGRasterEncoder{}); });
|
||||
|
||||
const ExPolygons bmp = rstGetPolys(rst);
|
||||
const ExPolygons ext = sla::raster_to_polygons(rst, window);
|
||||
|
||||
SVG svg(name + ".svg", raster_bb);
|
||||
svg.draw(bmp, "green");
|
||||
if (pixel_size.x() >= scale_(0.5))
|
||||
svg.draw_grid(raster_bb, "grey", scale_(0.05), pixel_size.x());
|
||||
if (window_size.x() >= scale_(1.0))
|
||||
svg.draw_grid(raster_bb, "grey", scale_(0.10), window_size.x());
|
||||
svg.draw_outline(ref, "red", "red", scale_(0.3));
|
||||
svg.draw_outline(ext, "blue", "blue");
|
||||
svg.Close();
|
||||
write_debug_svg("marchingsquares/" + name + ".svg", raster_bb, [&](SVG &svg) {
|
||||
svg.draw(bmp, "green");
|
||||
if (pixel_size.x() >= scale_(0.5))
|
||||
svg.draw_grid(raster_bb, "grey", scale_(0.05), pixel_size.x());
|
||||
if (window_size.x() >= scale_(1.0))
|
||||
svg.draw_grid(raster_bb, "grey", scale_(0.10), window_size.x());
|
||||
svg.draw_outline(ref, "red", "red", scale_(0.3));
|
||||
svg.draw_outline(ext, "blue", "blue");
|
||||
});
|
||||
|
||||
// Note all these areas are unscaled back to mm^2.
|
||||
double raster_area = unscaled(unscaled(area(bmp)));
|
||||
@@ -432,9 +431,7 @@ static void recreate_object_from_rasters(const std::string& objname, float lh)
|
||||
double disp_w = 120.96;
|
||||
double disp_h = 68.04;
|
||||
|
||||
#ifndef NDEBUG
|
||||
size_t cntr = 0;
|
||||
#endif
|
||||
for (ExPolygons& layer : layers) {
|
||||
auto rst = create_raster(res, disp_w, disp_h);
|
||||
|
||||
@@ -442,11 +439,8 @@ static void recreate_object_from_rasters(const std::string& objname, float lh)
|
||||
rst.draw(island);
|
||||
}
|
||||
|
||||
#ifndef NDEBUG
|
||||
std::fstream out(objname + std::to_string(cntr) + ".png", std::ios::out);
|
||||
out << rst.encode(sla::PNGRasterEncoder{});
|
||||
out.close();
|
||||
#endif
|
||||
write_debug_stream("marchingsquares/" + objname + std::to_string(cntr) + ".png",
|
||||
[&] { return rst.encode(sla::PNGRasterEncoder{}); });
|
||||
|
||||
ExPolygons layer_ = sla::raster_to_polygons(rst);
|
||||
// float delta = scaled(std::min(rst.pixel_dimensions().h_mm,
|
||||
@@ -454,21 +448,19 @@ static void recreate_object_from_rasters(const std::string& objname, float lh)
|
||||
|
||||
// layer_ = expolygons_simplify(layer_, delta);
|
||||
|
||||
#ifndef NDEBUG
|
||||
SVG svg(objname + std::to_string(cntr) + ".svg", rstBBox(rst));
|
||||
svg.draw(layer_);
|
||||
svg.draw(layer, "green");
|
||||
svg.Close();
|
||||
#endif
|
||||
write_debug_svg("marchingsquares/" + objname + std::to_string(cntr) + ".svg", rstBBox(rst),
|
||||
[&](SVG &svg) {
|
||||
svg.draw(layer_);
|
||||
svg.draw(layer, "green");
|
||||
});
|
||||
|
||||
double layera = 0., layera_ = 0.;
|
||||
for (auto& p : layer)
|
||||
layera += p.area();
|
||||
for (auto& p : layer_)
|
||||
layera_ += p.area();
|
||||
#ifndef NDEBUG
|
||||
std::cout << cntr++ << std::endl;
|
||||
#endif
|
||||
++cntr;
|
||||
|
||||
double diff = std::abs(layera_ - layera);
|
||||
REQUIRE((diff <= 0.1 * layera || diff < scaled<double>(1.) * scaled<double>(1.)));
|
||||
|
||||
@@ -477,7 +469,7 @@ static void recreate_object_from_rasters(const std::string& objname, float lh)
|
||||
|
||||
indexed_triangle_set out = slices_to_mesh(layers, bb.min.z(), double(lh), double(lh));
|
||||
|
||||
its_write_obj(out, "out_from_rasters.obj");
|
||||
write_debug_obj("marchingsquares/out_from_rasters.obj", out);
|
||||
}
|
||||
|
||||
TEST_CASE("Recreate object from rasters", "[SL1Import]") { recreate_object_from_rasters("frog_legs.obj", 0.05f); }
|
||||
|
||||
@@ -0,0 +1,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));
|
||||
}
|
||||
@@ -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]")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user