mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-07-25 11:52:05 +00:00
fix merge conflicts
This commit is contained in:
@@ -2,6 +2,11 @@
|
||||
|
||||
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**
|
||||
|
||||
@@ -13,13 +13,17 @@ endif()
|
||||
set(TEST_DATA_DIR ${CMAKE_CURRENT_SOURCE_DIR}/data)
|
||||
file(TO_NATIVE_PATH "${TEST_DATA_DIR}" TEST_DATA_DIR)
|
||||
|
||||
# Shipped vendor profiles, so tests can exercise the real machine/filament gcode.
|
||||
set(PROFILES_DIR ${CMAKE_SOURCE_DIR}/resources/profiles)
|
||||
file(TO_NATIVE_PATH "${PROFILES_DIR}" PROFILES_DIR)
|
||||
|
||||
include(CTest)
|
||||
include(Catch)
|
||||
|
||||
set(CATCH_EXTRA_ARGS "" CACHE STRING "Extra arguments for catch2 test suites.") # Unknown if this still works and/or should be replaced with something else.
|
||||
|
||||
add_library(test_common INTERFACE)
|
||||
target_compile_definitions(test_common INTERFACE TEST_DATA_DIR=R"\(${TEST_DATA_DIR}\)" CATCH_CONFIG_FAST_COMPILE)
|
||||
target_compile_definitions(test_common INTERFACE TEST_DATA_DIR=R"\(${TEST_DATA_DIR}\)" PROFILES_DIR=R"\(${PROFILES_DIR}\)" CATCH_CONFIG_FAST_COMPILE)
|
||||
target_include_directories(test_common INTERFACE ${CMAKE_CURRENT_SOURCE_DIR})
|
||||
|
||||
if (APPLE)
|
||||
@@ -57,5 +61,6 @@ add_subdirectory(libslic3r)
|
||||
add_subdirectory(slic3rutils)
|
||||
add_subdirectory(fff_print)
|
||||
add_subdirectory(sla_print)
|
||||
add_subdirectory(filament_group)
|
||||
|
||||
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
get_filename_component(_TEST_NAME ${CMAKE_CURRENT_LIST_DIR} NAME)
|
||||
add_executable(${_TEST_NAME}_tests
|
||||
${_TEST_NAME}_tests.cpp
|
||||
test_data.cpp
|
||||
test_data.hpp
|
||||
test_helpers.cpp
|
||||
test_helpers.hpp
|
||||
test_cooling.cpp
|
||||
test_extrusion_entity.cpp
|
||||
test_fill.cpp
|
||||
test_flow.cpp
|
||||
test_gcode.cpp
|
||||
test_gcode_timing.cpp
|
||||
test_gcodewriter.cpp
|
||||
test_model.cpp
|
||||
test_multifilament.cpp
|
||||
test_print.cpp
|
||||
test_printgcode.cpp
|
||||
test_printobject.cpp
|
||||
test_skirt_brim.cpp
|
||||
test_slicing_pipeline_hook.cpp
|
||||
|
||||
93
tests/fff_print/README.md
Normal file
93
tests/fff_print/README.md
Normal file
@@ -0,0 +1,93 @@
|
||||
# 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]"
|
||||
27
tests/fff_print/test_cooling.cpp
Normal file
27
tests/fff_print/test_cooling.cpp
Normal file
@@ -0,0 +1,27 @@
|
||||
#include <catch2/catch_all.hpp>
|
||||
|
||||
#include "test_helpers.hpp"
|
||||
|
||||
#include <string>
|
||||
|
||||
using namespace Slic3r;
|
||||
using namespace Slic3r::Test;
|
||||
|
||||
// The fan is held off for the first close_fan_the_first_x_layers layers, so an explicit
|
||||
// fan-off command is emitted.
|
||||
TEST_CASE("Fan is held off for the initial layers", "[Cooling]")
|
||||
{
|
||||
const std::string gcode = slice({ cube(20) }, {
|
||||
{ "cooling", true },
|
||||
{ "close_fan_the_first_x_layers", 5 },
|
||||
});
|
||||
CHECK(gcode.find("M106 S0") != std::string::npos);
|
||||
}
|
||||
|
||||
// The cooling pass resolves and strips its internal speed placeholders; none leak into
|
||||
// the final G-code.
|
||||
TEST_CASE("Cooling consumes its internal speed markers", "[Cooling]")
|
||||
{
|
||||
const std::string gcode = slice({ cube(20) }, { { "layer_height", 0.2 } });
|
||||
CHECK(gcode.find(";_EXTRUDE_SET_SPEED") == std::string::npos);
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
#ifndef SLIC3R_TEST_DATA_HPP
|
||||
#define SLIC3R_TEST_DATA_HPP
|
||||
|
||||
#include "libslic3r/Config.hpp"
|
||||
#include "libslic3r/Geometry.hpp"
|
||||
#include "libslic3r/Model.hpp"
|
||||
#include "libslic3r/Point.hpp"
|
||||
#include "libslic3r/Print.hpp"
|
||||
#include "libslic3r/TriangleMesh.hpp"
|
||||
|
||||
#include <set>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
|
||||
namespace Slic3r { namespace Test {
|
||||
|
||||
constexpr double MM_PER_MIN = 60.0;
|
||||
|
||||
/// Enumeration of test meshes
|
||||
enum class TestMesh {
|
||||
A,
|
||||
L,
|
||||
V,
|
||||
_40x10,
|
||||
cube_20x20x20,
|
||||
sphere_50mm,
|
||||
bridge,
|
||||
bridge_with_hole,
|
||||
cube_with_concave_hole,
|
||||
cube_with_hole,
|
||||
gt2_teeth,
|
||||
ipadstand,
|
||||
overhang,
|
||||
pyramid,
|
||||
sloping_hole,
|
||||
slopy_cube,
|
||||
small_dorito,
|
||||
step,
|
||||
two_hollow_squares
|
||||
};
|
||||
|
||||
// Necessary for <c++17
|
||||
struct TestMeshHash {
|
||||
std::size_t operator()(TestMesh tm) const {
|
||||
return static_cast<std::size_t>(tm);
|
||||
}
|
||||
};
|
||||
|
||||
/// Mesh enumeration to name mapping
|
||||
extern const std::unordered_map<TestMesh, const char*, TestMeshHash> mesh_names;
|
||||
|
||||
/// Port of Slic3r::Test::mesh
|
||||
/// Basic cubes/boxes should call TriangleMesh::make_cube() directly and rescale/translate it
|
||||
TriangleMesh mesh(TestMesh m);
|
||||
|
||||
TriangleMesh mesh(TestMesh m, Vec3d translate, Vec3d scale = Vec3d(1.0, 1.0, 1.0));
|
||||
TriangleMesh mesh(TestMesh m, Vec3d translate, double scale = 1.0);
|
||||
|
||||
/// Templated function to see if two values are equivalent (+/- epsilon)
|
||||
template <typename T>
|
||||
bool _equiv(const T& a, const T& b) { return std::abs(a - b) < EPSILON; }
|
||||
|
||||
template <typename T>
|
||||
bool _equiv(const T& a, const T& b, double epsilon) { return abs(a - b) < epsilon; }
|
||||
|
||||
Slic3r::Model model(const std::string& model_name, TriangleMesh&& _mesh);
|
||||
void init_print(std::vector<TriangleMesh> &&meshes, Slic3r::Print &print, Slic3r::Model& model, const DynamicPrintConfig &config_in, bool comments = false);
|
||||
void init_print(std::initializer_list<TestMesh> meshes, Slic3r::Print &print, Slic3r::Model& model, const Slic3r::DynamicPrintConfig &config_in = Slic3r::DynamicPrintConfig::full_print_config(), bool comments = false);
|
||||
void init_print(std::initializer_list<TriangleMesh> meshes, Slic3r::Print &print, Slic3r::Model& model, const Slic3r::DynamicPrintConfig &config_in = Slic3r::DynamicPrintConfig::full_print_config(), bool comments = false);
|
||||
void init_print(std::initializer_list<TestMesh> meshes, Slic3r::Print &print, Slic3r::Model& model, std::initializer_list<Slic3r::ConfigBase::SetDeserializeItem> config_items, bool comments = false);
|
||||
void init_print(std::initializer_list<TriangleMesh> meshes, Slic3r::Print &print, Slic3r::Model& model, std::initializer_list<Slic3r::ConfigBase::SetDeserializeItem> config_items, bool comments = false);
|
||||
|
||||
void init_and_process_print(std::initializer_list<TestMesh> meshes, Slic3r::Print &print, const DynamicPrintConfig& config, bool comments = false);
|
||||
void init_and_process_print(std::initializer_list<TriangleMesh> meshes, Slic3r::Print &print, const DynamicPrintConfig& config, bool comments = false);
|
||||
void init_and_process_print(std::initializer_list<TestMesh> meshes, Slic3r::Print &print, std::initializer_list<Slic3r::ConfigBase::SetDeserializeItem> config_items, bool comments = false);
|
||||
void init_and_process_print(std::initializer_list<TriangleMesh> meshes, Slic3r::Print &print, std::initializer_list<Slic3r::ConfigBase::SetDeserializeItem> config_items, bool comments = false);
|
||||
|
||||
std::string gcode(Print& print);
|
||||
|
||||
std::string slice(std::initializer_list<TestMesh> meshes, const DynamicPrintConfig &config, bool comments = false);
|
||||
std::string slice(std::initializer_list<TriangleMesh> meshes, const DynamicPrintConfig &config, bool comments = false);
|
||||
std::string slice(std::initializer_list<TestMesh> meshes, std::initializer_list<Slic3r::ConfigBase::SetDeserializeItem> config_items, bool comments = false);
|
||||
std::string slice(std::initializer_list<TriangleMesh> meshes, std::initializer_list<Slic3r::ConfigBase::SetDeserializeItem> config_items, bool comments = false);
|
||||
|
||||
// Distinct layer Z heights that carry an extrusion tagged with the given role
|
||||
// comment (requires gcode_comments), e.g. "skirt", "brim", "support".
|
||||
std::set<double> layers_with_role(const std::string &gcode, const std::string &role);
|
||||
|
||||
// Highest Z reached by any move in the gcode.
|
||||
double max_z(const std::string &gcode);
|
||||
|
||||
} } // namespace Slic3r::Test
|
||||
|
||||
|
||||
#endif // SLIC3R_TEST_DATA_HPP
|
||||
@@ -7,7 +7,7 @@
|
||||
#include "libslic3r/Point.hpp"
|
||||
#include "libslic3r/libslic3r.h"
|
||||
|
||||
#include "test_data.hpp"
|
||||
#include "test_helpers.hpp"
|
||||
|
||||
using namespace Slic3r;
|
||||
|
||||
@@ -35,7 +35,7 @@ static Slic3r::ExtrusionPaths random_paths(size_t count = 10, size_t length = 20
|
||||
return p;
|
||||
}
|
||||
|
||||
SCENARIO("ExtrusionEntityCollection: Polygon flattening", "[ExtrusionEntity]") {
|
||||
SCENARIO("Polygon flattening", "[ExtrusionEntity]") {
|
||||
srand(0xDEADBEEF); // consistent seed for test reproducibility.
|
||||
|
||||
// Generate one specific random path set and save it for later comparison
|
||||
|
||||
@@ -11,14 +11,14 @@
|
||||
#include "libslic3r/SVG.hpp"
|
||||
#include "libslic3r/libslic3r.h"
|
||||
|
||||
#include "test_data.hpp"
|
||||
#include "test_helpers.hpp"
|
||||
|
||||
using namespace Slic3r;
|
||||
|
||||
bool test_if_solid_surface_filled(const ExPolygon& expolygon, double flow_spacing, double angle = 0, double density = 1.0);
|
||||
|
||||
#if 0
|
||||
TEST_CASE("Fill: adjusted solid distance") {
|
||||
TEST_CASE("Adjusted solid distance", "[Fill]") {
|
||||
int surface_width = 250;
|
||||
int distance = Slic3r::Flow::solid_spacing(surface_width, 47);
|
||||
REQUIRE(distance == Catch::Approx(50));
|
||||
@@ -26,7 +26,7 @@ TEST_CASE("Fill: adjusted solid distance") {
|
||||
}
|
||||
#endif
|
||||
|
||||
TEST_CASE("Fill: Pattern Path Length", "[Fill]") {
|
||||
TEST_CASE("Pattern path length", "[Fill]") {
|
||||
std::unique_ptr<Slic3r::Fill> filler(Slic3r::Fill::new_from_type("rectilinear"));
|
||||
filler->angle = float(-(PI)/2.0);
|
||||
FillParams fill_params;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
#include <numeric>
|
||||
#include <sstream>
|
||||
|
||||
#include "test_data.hpp" // get access to init_print, etc
|
||||
#include "test_helpers.hpp" // get access to init_print, etc
|
||||
|
||||
#include "libslic3r/Config.hpp"
|
||||
#include "libslic3r/Model.hpp"
|
||||
@@ -17,7 +17,7 @@ using namespace Slic3r;
|
||||
|
||||
/// Test the expected behavior for auto-width,
|
||||
/// spacing, etc
|
||||
SCENARIO("Flow: Flow math for non-bridges", "[Flow]") {
|
||||
SCENARIO("Flow math for non-bridges", "[Flow]") {
|
||||
GIVEN("Nozzle Diameter of 0.4, a desired width of 1mm and layer height of 0.5") {
|
||||
ConfigOptionFloatOrPercent width(1.0, false);
|
||||
float nozzle_diameter = 0.4f;
|
||||
@@ -79,7 +79,7 @@ SCENARIO("Flow: Flow math for non-bridges", "[Flow]") {
|
||||
}
|
||||
|
||||
/// Spacing, width calculation for bridge extrusions
|
||||
SCENARIO("Flow: Flow math for bridges", "[Flow]") {
|
||||
SCENARIO("Flow math for bridges", "[Flow]") {
|
||||
GIVEN("Nozzle Diameter of 0.4, a desired width of 1mm and layer height of 0.5") {
|
||||
float nozzle_diameter = 0.4f;
|
||||
float bridge_flow = 1.0f;
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
#include <catch2/catch_all.hpp>
|
||||
|
||||
#include <memory>
|
||||
|
||||
#include "libslic3r/GCode.hpp"
|
||||
|
||||
using namespace Slic3r;
|
||||
|
||||
SCENARIO("Origin manipulation", "[GCode]") {
|
||||
Slic3r::GCode gcodegen;
|
||||
WHEN("set_origin to (10,0)") {
|
||||
gcodegen.set_origin(Vec2d(10,0));
|
||||
REQUIRE(gcodegen.origin() == Vec2d(10, 0));
|
||||
}
|
||||
WHEN("set_origin to (10,0) and translate by (5, 5)") {
|
||||
gcodegen.set_origin(Vec2d(10,0));
|
||||
gcodegen.set_origin(gcodegen.origin() + Vec2d(5, 5));
|
||||
THEN("origin returns reference to point") {
|
||||
REQUIRE(gcodegen.origin() == Vec2d(15,5));
|
||||
}
|
||||
}
|
||||
}
|
||||
420
tests/fff_print/test_gcode_timing.cpp
Normal file
420
tests/fff_print/test_gcode_timing.cpp
Normal file
@@ -0,0 +1,420 @@
|
||||
#include <catch2/catch_all.hpp>
|
||||
|
||||
#include "libslic3r/libslic3r.h"
|
||||
#include "libslic3r/GCode/GCodeProcessor.hpp"
|
||||
#include "libslic3r/MultiNozzleUtils.hpp"
|
||||
#include "libslic3r/PrintConfig.hpp"
|
||||
|
||||
#include "test_utils.hpp"
|
||||
|
||||
#include <fstream>
|
||||
#include <map>
|
||||
#include <memory>
|
||||
|
||||
using namespace Slic3r;
|
||||
using Catch::Matchers::WithinAbs;
|
||||
|
||||
// Regression coverage for filament/tool-change time being folded into the first
|
||||
// pending motion block (an extrusion move) instead of the tool-change move, and
|
||||
// for that delay being dropped entirely when too few motion blocks precede the
|
||||
// change. See BambuStudio "seperate flush time from other types" (c54a8333c7)
|
||||
// and the follow-up "unprocessed addtional time" fix (27ef0b1bef).
|
||||
namespace {
|
||||
|
||||
constexpr size_t NORMAL = static_cast<size_t>(PrintEstimatedStatistics::ETimeMode::Normal);
|
||||
|
||||
FullPrintConfig make_config(double load_time, double unload_time, double tool_change_time)
|
||||
{
|
||||
FullPrintConfig config; // default-initialized with the built-in defaults
|
||||
config.gcode_flavor.value = gcfMarlinFirmware;
|
||||
// Two filaments, both assigned to the same (single) extruder, so a T1 after
|
||||
// T0 is a same-extruder filament swap that costs unload + load time.
|
||||
config.filament_diameter.values = {1.75, 1.75};
|
||||
config.filament_map.values = {1, 1};
|
||||
config.machine_load_filament_time.value = load_time;
|
||||
config.machine_unload_filament_time.value = unload_time;
|
||||
config.machine_tool_change_time.value = tool_change_time;
|
||||
return config;
|
||||
}
|
||||
|
||||
void run_processor(GCodeProcessor& proc, const FullPrintConfig& config, const char* gcode)
|
||||
{
|
||||
// reserved_tag() selects between two tag tables based on this shared static, and
|
||||
// other tests in the binary mutate it -- pin it so our "; FEATURE:" role tags are
|
||||
// parsed deterministically regardless of test execution order.
|
||||
GCodeProcessor::s_IsBBLPrinter = true;
|
||||
ScopedTemporaryFile temp(".gcode");
|
||||
{
|
||||
std::ofstream os(temp.string());
|
||||
os << gcode;
|
||||
}
|
||||
proc.apply_config(config);
|
||||
// No producer marker in the gcode, so process_file keeps our applied config.
|
||||
proc.process_file(temp.string());
|
||||
}
|
||||
|
||||
// Estimated time per extrusion role, grouped exactly the way libvgcode builds the
|
||||
// feature-type legend: sum MoveVertex.time over EMoveType::Extrude moves keyed by
|
||||
// extrusion_role (see ViewerImpl.cpp:1017 -- only Extrude moves are counted).
|
||||
std::map<ExtrusionRole, double> role_times(const GCodeProcessorResult& r)
|
||||
{
|
||||
std::map<ExtrusionRole, double> m;
|
||||
for (const auto& mv : r.moves)
|
||||
if (mv.type == EMoveType::Extrude)
|
||||
m[mv.extrusion_role] += mv.time[NORMAL];
|
||||
return m;
|
||||
}
|
||||
|
||||
// Sum of estimated time attributed to tool-change moves.
|
||||
double sum_tool_change_time(const GCodeProcessorResult& r)
|
||||
{
|
||||
double t = 0.0;
|
||||
for (const auto& mv : r.moves)
|
||||
if (mv.type == EMoveType::Tool_change)
|
||||
t += mv.time[NORMAL];
|
||||
return t;
|
||||
}
|
||||
|
||||
// Total filament-change delay, accumulated independently of the timing machinery.
|
||||
double filament_change_delay(const GCodeProcessorResult& r)
|
||||
{
|
||||
const auto& s = r.print_statistics;
|
||||
return s.total_filament_load_time + s.total_filament_unload_time + s.total_tool_change_time;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("Filament-change time is attributed to tool-change moves, not extrusion roles", "[GCodeTiming]")
|
||||
{
|
||||
// Relative extrusion (M83) so every "E5" is a real 5mm extrusion move rather
|
||||
// than a zero-delta travel. Two real travels precede T0 so its delay is flushed
|
||||
// cleanly. The extrusions after T0 span several roles (Outer wall, Sparse infill,
|
||||
// Inner wall); the first pending block at T1 is an "Outer wall" move, so the
|
||||
// buggy code folds the T1 delay into that role. The per-role check below verifies
|
||||
// EVERY role stays clean, not just one, and catches any role-to-role misattribution.
|
||||
const char* gcode =
|
||||
"M83\n"
|
||||
"; FEATURE: Outer wall\n"
|
||||
"G1 X10 Y10 Z0.2 F600\n"
|
||||
"G1 X0 Y0 F6000\n"
|
||||
"T0\n"
|
||||
"; FEATURE: Outer wall\n"
|
||||
"G1 X50 Y0 E5 F1800\n"
|
||||
"G1 X50 Y50 E5\n"
|
||||
"; FEATURE: Sparse infill\n"
|
||||
"G1 X0 Y50 E5\n"
|
||||
"G1 X0 Y0 E5\n"
|
||||
"T1\n"
|
||||
"; FEATURE: Inner wall\n"
|
||||
"G1 X50 Y0 E5\n"
|
||||
"G1 X50 Y50 E5\n";
|
||||
|
||||
GCodeProcessor proc_zero;
|
||||
run_processor(proc_zero, make_config(0.0, 0.0, 0.0), gcode);
|
||||
const GCodeProcessorResult& r_zero = proc_zero.get_result();
|
||||
|
||||
const double load = 10.0;
|
||||
const double unload = 5.0;
|
||||
GCodeProcessor proc_delay;
|
||||
run_processor(proc_delay, make_config(load, unload, 0.0), gcode);
|
||||
const GCodeProcessorResult& r_delay = proc_delay.get_result();
|
||||
|
||||
const double delay = filament_change_delay(r_delay);
|
||||
|
||||
// Preconditions: the filament changes were charged, and cost nothing in the
|
||||
// zero-time baseline.
|
||||
REQUIRE(delay > 0.0);
|
||||
REQUIRE_THAT(filament_change_delay(r_zero), WithinAbs(0.0, 1e-9));
|
||||
|
||||
// The delay must not inflate the time of ANY extrusion role. Compare the full
|
||||
// per-role breakdown (exactly how the feature-type legend is built) between the
|
||||
// zero-delay and delayed runs -- every role must match to within tolerance.
|
||||
const auto roles_zero = role_times(r_zero);
|
||||
const auto roles_delay = role_times(r_delay);
|
||||
// Guard: the gcode must genuinely exercise multiple distinct roles (Outer wall,
|
||||
// Sparse infill, Inner wall), otherwise this check would silently cover only one.
|
||||
REQUIRE(roles_zero.size() >= 3);
|
||||
REQUIRE(roles_zero.size() == roles_delay.size());
|
||||
for (const auto& [role, zero_time] : roles_zero) {
|
||||
INFO("extrusion role index = " << static_cast<int>(role));
|
||||
REQUIRE(roles_delay.count(role) == 1);
|
||||
REQUIRE_THAT(roles_delay.at(role), WithinAbs(zero_time, 1e-2));
|
||||
}
|
||||
|
||||
// The delay must instead land on the tool-change moves, so per-move consumers
|
||||
// (layer-time view, layer slider) stay consistent.
|
||||
REQUIRE_THAT(sum_tool_change_time(r_delay), WithinAbs(delay, 1e-2));
|
||||
|
||||
// Both tool changes occur on layer 1, so the delay must also be reflected in
|
||||
// the first-layer time.
|
||||
const double first_layer_delta = proc_delay.get_first_layer_time(PrintEstimatedStatistics::ETimeMode::Normal)
|
||||
- proc_zero.get_first_layer_time(PrintEstimatedStatistics::ETimeMode::Normal);
|
||||
REQUIRE_THAT(first_layer_delta, WithinAbs(delay, 1e-2));
|
||||
}
|
||||
|
||||
TEST_CASE("Filament-change time is not dropped when few motion blocks precede the change", "[GCodeTiming]")
|
||||
{
|
||||
// Only a single motion block precedes T0, so the buggy code's "fewer than two
|
||||
// pending blocks" early-out discards that filament-change delay entirely,
|
||||
// making the total print time inconsistent with the reported statistics.
|
||||
const char* gcode =
|
||||
"; FEATURE: Outer wall\n"
|
||||
"G1 X10 Y10 Z0.2 F600\n"
|
||||
"T0\n"
|
||||
"G1 X50 Y0 E5 F1800\n"
|
||||
"G1 X50 Y50 E5\n"
|
||||
"T1\n"
|
||||
"G1 X0 Y50 E5\n"
|
||||
"G1 X0 Y0 E5\n";
|
||||
|
||||
GCodeProcessor proc_zero;
|
||||
run_processor(proc_zero, make_config(0.0, 0.0, 0.0), gcode);
|
||||
|
||||
const double load = 10.0;
|
||||
const double unload = 5.0;
|
||||
GCodeProcessor proc_delay;
|
||||
run_processor(proc_delay, make_config(load, unload, 0.0), gcode);
|
||||
const GCodeProcessorResult& r_delay = proc_delay.get_result();
|
||||
|
||||
const double delay = filament_change_delay(r_delay);
|
||||
REQUIRE(delay > 0.0);
|
||||
|
||||
// Every second of reported filament-change delay must be present in the total
|
||||
// estimated print time; none may be silently dropped.
|
||||
const double total_delta = proc_delay.get_time(PrintEstimatedStatistics::ETimeMode::Normal)
|
||||
- proc_zero.get_time(PrintEstimatedStatistics::ETimeMode::Normal);
|
||||
REQUIRE_THAT(total_delta, WithinAbs(delay, 1e-2));
|
||||
}
|
||||
|
||||
TEST_CASE("Back-to-back tool changes buffer then merge into one tool-change block", "[GCodeTiming]")
|
||||
{
|
||||
// T0 is the very first line: the block queue is empty when its delay is synchronized,
|
||||
// so with only the single (artificial) tool-change block queued the delay can't be
|
||||
// attributed yet and is buffered. T1 follows immediately with no motion between; its
|
||||
// synchronize now sees two tool-change blocks queued, so its own delay joins the buffered
|
||||
// T0 entry at application time, the two same-type entries merge into one, and the sum
|
||||
// lands entirely on the first tool-change block. The trailing travels leave both runs
|
||||
// with >= 2 blocks so their end-of-file flush is identical and cancels in every delta.
|
||||
const char* gcode =
|
||||
"T0\n" // first charged change (load only); empty queue -> buffers (Tool_change,10)
|
||||
"T1\n" // same-extruder swap (unload+load); merges with buffered T0 entry to (Tool_change,25)
|
||||
"G1 X10 Y0 Z0.2 F6000\n" // travels: keep >= 2 blocks queued at EOF (flushed identically by both runs)
|
||||
"G1 X10 Y10\n"
|
||||
"G1 X0 Y10\n";
|
||||
|
||||
GCodeProcessor proc_zero;
|
||||
run_processor(proc_zero, make_config(0.0, 0.0, 0.0), gcode);
|
||||
const GCodeProcessorResult& r_zero = proc_zero.get_result();
|
||||
|
||||
GCodeProcessor proc_delay;
|
||||
run_processor(proc_delay, make_config(10.0, 5.0, 0.0), gcode);
|
||||
const GCodeProcessorResult& r_delay = proc_delay.get_result();
|
||||
|
||||
// T0 load 10 + T1 unload 5 + T1 load 10 = 25.
|
||||
const double delay = filament_change_delay(r_delay);
|
||||
REQUIRE(delay > 0.0);
|
||||
REQUIRE_THAT(delay, WithinAbs(25.0, 1e-6));
|
||||
REQUIRE_THAT(filament_change_delay(r_zero), WithinAbs(0.0, 1e-9));
|
||||
|
||||
// The whole buffered-then-merged delay must reach the total print time.
|
||||
const double total_delta = proc_delay.get_time(PrintEstimatedStatistics::ETimeMode::Normal)
|
||||
- proc_zero.get_time(PrintEstimatedStatistics::ETimeMode::Normal);
|
||||
REQUIRE_THAT(total_delta, WithinAbs(delay, 1e-2));
|
||||
|
||||
// ...and must land on the tool-change moves, not on any extrusion role.
|
||||
REQUIRE_THAT(sum_tool_change_time(r_delay), WithinAbs(25.0, 1e-2));
|
||||
REQUIRE_THAT(sum_tool_change_time(r_zero), WithinAbs(0.0, 1e-9));
|
||||
|
||||
// Characterization (documents the current merge-collapse behavior, not a correctness
|
||||
// requirement): the two buffered same-type entries combine onto the FIRST artificial
|
||||
// tool-change block; the second receives nothing. Had the merge regressed, the 10 and 15
|
||||
// would land on separate moves instead of 25 and 0.
|
||||
std::vector<double> tc;
|
||||
for (const auto& mv : r_delay.moves)
|
||||
if (mv.type == EMoveType::Tool_change)
|
||||
tc.push_back(mv.time[NORMAL]);
|
||||
REQUIRE(tc.size() >= 2);
|
||||
REQUIRE_THAT(tc[0], WithinAbs(25.0, 1e-2));
|
||||
REQUIRE_THAT(tc[1], WithinAbs(0.0, 1e-9));
|
||||
}
|
||||
|
||||
TEST_CASE("Trailing tool change at end of file is drained, not dropped", "[GCodeTiming]")
|
||||
{
|
||||
// A tool change is the last line of the file, with only its single artificial block
|
||||
// queued. Its delay is buffered (fewer than two blocks) and there is no later motion to
|
||||
// flush it, so only the finalization pass can attribute it. Without the end-of-file drain
|
||||
// the delay would be stranded in the buffer and the total print time would disagree with
|
||||
// the reported filament-change statistics.
|
||||
const char* gcode =
|
||||
"G1 X10 Y0 Z0.2 F6000\n" // three travels -> three blocks queued (no E, so no filament is selected)
|
||||
"G1 X10 Y10\n"
|
||||
"G1 X0 Y10\n"
|
||||
"G4 S0\n" // dwell with S present -> full flush; queue and buffer now empty
|
||||
"T0\n"; // trailing change, nothing after: buffers (Tool_change,10), one block queued
|
||||
|
||||
GCodeProcessor proc_zero;
|
||||
run_processor(proc_zero, make_config(0.0, 0.0, 0.0), gcode);
|
||||
const GCodeProcessorResult& r_zero = proc_zero.get_result();
|
||||
|
||||
GCodeProcessor proc_delay;
|
||||
run_processor(proc_delay, make_config(10.0, 5.0, 0.0), gcode);
|
||||
const GCodeProcessorResult& r_delay = proc_delay.get_result();
|
||||
|
||||
// T0 is the first charged change on an empty extruder, so it costs the load time only.
|
||||
const double delay = filament_change_delay(r_delay);
|
||||
REQUIRE(delay > 0.0);
|
||||
REQUIRE_THAT(delay, WithinAbs(10.0, 1e-6));
|
||||
|
||||
// The trailing change's delay must survive to the total: the zero run buffers nothing and
|
||||
// drops its artificial block, so the motion cancels and the delta is exactly the drained delay.
|
||||
const double total_delta = proc_delay.get_time(PrintEstimatedStatistics::ETimeMode::Normal)
|
||||
- proc_zero.get_time(PrintEstimatedStatistics::ETimeMode::Normal);
|
||||
REQUIRE_THAT(total_delta, WithinAbs(delay, 1e-2));
|
||||
|
||||
// The size-1 drain runs the body, so the delay lands on the artificial tool-change move.
|
||||
REQUIRE_THAT(sum_tool_change_time(r_delay), WithinAbs(10.0, 1e-2));
|
||||
REQUIRE_THAT(sum_tool_change_time(r_zero), WithinAbs(0.0, 1e-9));
|
||||
}
|
||||
|
||||
TEST_CASE("Carried-forward tool-change delay reaches the total without polluting roles", "[GCodeTiming]")
|
||||
{
|
||||
// A wildcard dwell delay is buffered ahead of the tool-change delay, so when the blocks
|
||||
// are next flushed the dwell's (Noop) entry consumes the artificial tool-change block and
|
||||
// the tool-change entry finds no matching block and carries forward. It stays unmatched
|
||||
// through the remaining extrusion moves and is only resolved at finalization, where the
|
||||
// end-of-file fold adds it to the machine total and the custom-gcode cache -- never to a
|
||||
// move vertex, so it cannot leak into an extrusion role's time.
|
||||
const char* gcode =
|
||||
"M83\n"
|
||||
"G4 S3\n" // empty queue -> buffers (Noop,3) [wildcard delay]
|
||||
"T0\n" // one block queued -> buffers (Tool_change,10) behind the dwell
|
||||
"; FEATURE: Inner wall\n"
|
||||
"G1 X20 Y0 Z0.2 E5 F1800\n" // extrusion m1: queue is [artificial_TC0, m1]
|
||||
"G4 S0\n" // flush: (Noop,3) consumes artificial_TC0; (Tool_change,10) carries forward
|
||||
"G1 X20 Y20 E5\n" // extrusion m2
|
||||
"G1 X0 Y20 E5\n"; // extrusion m3: at EOF queue is [m2, m3], buffer is [(Tool_change,10)]
|
||||
|
||||
GCodeProcessor proc_zero;
|
||||
run_processor(proc_zero, make_config(0.0, 0.0, 0.0), gcode);
|
||||
const GCodeProcessorResult& r_zero = proc_zero.get_result();
|
||||
|
||||
GCodeProcessor proc_delay;
|
||||
run_processor(proc_delay, make_config(10.0, 5.0, 0.0), gcode);
|
||||
const GCodeProcessorResult& r_delay = proc_delay.get_result();
|
||||
|
||||
// T0 is the first charged change (load only); the fixed dwell delays are not in these counters.
|
||||
const double delay = filament_change_delay(r_delay);
|
||||
REQUIRE(delay > 0.0);
|
||||
REQUIRE_THAT(delay, WithinAbs(10.0, 1e-6));
|
||||
|
||||
// The stranded tool-change delay must be drained into the total, not dropped. The 3s dwell
|
||||
// is identical in both runs and cancels along with all motion, leaving exactly the delay.
|
||||
const double total_delta = proc_delay.get_time(PrintEstimatedStatistics::ETimeMode::Normal)
|
||||
- proc_zero.get_time(PrintEstimatedStatistics::ETimeMode::Normal);
|
||||
REQUIRE_THAT(total_delta, WithinAbs(delay, 1e-2));
|
||||
|
||||
// Pollution safety: the drained delay must NOT appear in any extrusion role. Every role's
|
||||
// time must match between the zero and delayed runs -- this is what the total-only fold buys.
|
||||
const auto rz = role_times(r_zero);
|
||||
const auto rd = role_times(r_delay);
|
||||
REQUIRE(rz.size() >= 1);
|
||||
REQUIRE(rz.size() == rd.size());
|
||||
for (const auto& [role, zero_time] : rz) {
|
||||
INFO("extrusion role index = " << static_cast<int>(role));
|
||||
REQUIRE(rd.count(role) == 1);
|
||||
REQUIRE_THAT(rd.at(role), WithinAbs(zero_time, 1e-2));
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("Per-slot machine limits follow the active nozzle", "[GCodeTiming][MultiNozzle]")
|
||||
{
|
||||
// Single physical extruder carrying two nozzle variants: machine slot 0 (Standard) caps X/Y
|
||||
// speed at 200 mm/s, slot 1 (High Flow) at 50 mm/s. The estimator must clamp each move by the
|
||||
// slot of the nozzle the active filament occupies -- resolved from the grouping context handed
|
||||
// over before the replay plus the occupancy recorder, i.e. the exact in-slicer streaming path.
|
||||
FullPrintConfig config = make_config(0.0, 0.0, 0.0);
|
||||
config.extruder_type.values = {static_cast<int>(etDirectDrive)};
|
||||
config.printer_extruder_id.values = {1, 1};
|
||||
config.printer_extruder_variant.values = {"Direct Drive Standard", "Direct Drive High Flow"};
|
||||
// Slot-major layout: [slot0-Normal, slot0-Stealth, slot1-Normal, slot1-Stealth].
|
||||
config.machine_max_speed_x.values = {200., 200., 50., 50.};
|
||||
config.machine_max_speed_y.values = {200., 200., 50., 50.};
|
||||
config.machine_max_speed_z.values = {200., 200., 50., 50.};
|
||||
config.machine_max_speed_e.values = {200., 200., 50., 50.};
|
||||
// Keep acceleration and jerk far from limiting so move times are speed-dominated.
|
||||
for (auto *accel : {&config.machine_max_acceleration_x, &config.machine_max_acceleration_y,
|
||||
&config.machine_max_acceleration_z, &config.machine_max_acceleration_e})
|
||||
accel->values = {100000., 100000., 100000., 100000.};
|
||||
config.machine_max_acceleration_travel.values = {100000., 100000.};
|
||||
config.machine_max_acceleration_extruding.values = {100000., 100000.};
|
||||
config.machine_max_jerk_x.values = {10000., 10000.};
|
||||
config.machine_max_jerk_y.values = {10000., 10000.};
|
||||
config.machine_max_jerk_z.values = {10000., 10000.};
|
||||
config.machine_max_jerk_e.values = {10000., 10000.};
|
||||
|
||||
// Grouping stub: filament 0 lives on the Standard nozzle (slot 0), filament 1 on the
|
||||
// High Flow nozzle (slot 1), both mounted on extruder 0.
|
||||
std::vector<MultiNozzleUtils::NozzleInfo> nozzles;
|
||||
{
|
||||
MultiNozzleUtils::NozzleInfo n;
|
||||
n.diameter = "0.4";
|
||||
n.volume_type = nvtStandard; n.extruder_id = 0; n.group_id = 0; nozzles.push_back(n);
|
||||
n.volume_type = nvtHighFlow; n.extruder_id = 0; n.group_id = 1; nozzles.push_back(n);
|
||||
}
|
||||
std::vector<int> filament_nozzle_map = {0, 1};
|
||||
std::vector<unsigned int> used_filaments = {0, 1};
|
||||
auto group = MultiNozzleUtils::LayeredNozzleGroupResult::create(filament_nozzle_map, nozzles, used_filaments);
|
||||
REQUIRE(group.has_value());
|
||||
auto context = std::make_shared<MultiNozzleUtils::LayeredNozzleGroupResult>(*group);
|
||||
|
||||
// Two identical 100 mm X travels, one per filament; T..H.. carries the target nozzle id.
|
||||
// The trailing 1 mm move keeps two blocks queued at finalize, so the measured move's time is
|
||||
// flushed (a lone final block is never attributed); it adds 1 mm to the second bucket.
|
||||
const char* gcode =
|
||||
"M83\n"
|
||||
"T0 H0\n"
|
||||
"G1 X100 F30000\n"
|
||||
"T1 H1\n"
|
||||
"G1 X0 F30000\n"
|
||||
"G1 X1 F30000\n";
|
||||
|
||||
// Travel time accumulated after each tool-change move (bucket 0 = before any T).
|
||||
auto travel_times_by_tool = [](const GCodeProcessorResult& r) {
|
||||
std::vector<double> out(1, 0.0);
|
||||
for (const auto& mv : r.moves) {
|
||||
if (mv.type == EMoveType::Tool_change)
|
||||
out.push_back(0.0);
|
||||
else if (mv.type == EMoveType::Travel)
|
||||
out.back() += mv.time[NORMAL];
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
SECTION("the move on the High Flow nozzle is clamped by its own slot") {
|
||||
GCodeProcessor proc;
|
||||
proc.initialize_from_context(context);
|
||||
run_processor(proc, config, gcode);
|
||||
auto times = travel_times_by_tool(proc.get_result());
|
||||
REQUIRE(times.size() == 3);
|
||||
REQUIRE_THAT(times[1], Catch::Matchers::WithinRel(100.0 / 200.0, 0.10));
|
||||
REQUIRE_THAT(times[2], Catch::Matchers::WithinRel(101.0 / 50.0, 0.10));
|
||||
}
|
||||
SECTION("an emitted envelope line reaches every slot") {
|
||||
const std::string enveloped = std::string("M201 X20000\nM203 X80\n") + gcode;
|
||||
GCodeProcessor proc;
|
||||
proc.initialize_from_context(context);
|
||||
run_processor(proc, config, enveloped.c_str());
|
||||
auto times = travel_times_by_tool(proc.get_result());
|
||||
REQUIRE(times.size() == 3);
|
||||
REQUIRE_THAT(times[1], Catch::Matchers::WithinRel(100.0 / 80.0, 0.10));
|
||||
REQUIRE_THAT(times[2], Catch::Matchers::WithinRel(101.0 / 80.0, 0.10));
|
||||
}
|
||||
SECTION("no grouping context degrades to slot 0") {
|
||||
GCodeProcessor proc;
|
||||
run_processor(proc, config, gcode);
|
||||
auto times = travel_times_by_tool(proc.get_result());
|
||||
REQUIRE(times.size() == 3);
|
||||
REQUIRE_THAT(times[1], Catch::Matchers::WithinRel(100.0 / 200.0, 0.10));
|
||||
REQUIRE_THAT(times[2], Catch::Matchers::WithinRel(101.0 / 200.0, 0.10));
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,33 @@
|
||||
#include <catch2/catch_all.hpp>
|
||||
|
||||
#include <cstdlib>
|
||||
#include <memory>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "libslic3r/GCodeWriter.hpp"
|
||||
#include "libslic3r/GCode.hpp"
|
||||
#include "libslic3r/Model.hpp"
|
||||
#include "libslic3r/Print.hpp"
|
||||
#include "libslic3r/ModelArrange.hpp"
|
||||
|
||||
#include <boost/filesystem.hpp>
|
||||
|
||||
#include "test_helpers.hpp"
|
||||
|
||||
using namespace Slic3r;
|
||||
using namespace Slic3r::Test;
|
||||
|
||||
// Arrange on a finite bed, not an unbounded InfiniteBed: the latter places items
|
||||
// near INT64_MIN/4 (~2.3e18), which reaches ClipperLib's coordinate limit and throws
|
||||
// "Coordinate outside allowed range" on Windows/arm64. A 500x500 bed keeps coordinates
|
||||
// small while still covering large printers.
|
||||
static void arrange_objects_on_test_bed(Model &model, const DynamicPrintConfig &config)
|
||||
{
|
||||
const BoundingBox bed{Point::new_scale(0., 0.), Point::new_scale(500., 500.)};
|
||||
arrange_objects(model, bed, ArrangeParams{scaled(min_object_distance(config))});
|
||||
}
|
||||
|
||||
SCENARIO("set_speed emits values with fixed-point output.", "[GCodeWriter]") {
|
||||
|
||||
@@ -57,3 +80,743 @@ SCENARIO("z_hop lifts the nozzle when a lift is requested", "[GCodeWriter]") {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SCENARIO("Origin manipulation", "[GCodeWriter]") {
|
||||
Slic3r::GCode gcodegen;
|
||||
WHEN("set_origin to (10,0)") {
|
||||
gcodegen.set_origin(Vec2d(10,0));
|
||||
REQUIRE(gcodegen.origin() == Vec2d(10, 0));
|
||||
}
|
||||
WHEN("set_origin to (10,0) and translate by (5, 5)") {
|
||||
gcodegen.set_origin(Vec2d(10,0));
|
||||
gcodegen.set_origin(gcodegen.origin() + Vec2d(5, 5));
|
||||
THEN("origin returns reference to point") {
|
||||
REQUIRE(gcodegen.origin() == Vec2d(15,5));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Verify that emit_machine_limits_to_gcode emits the correct max value across
|
||||
// used extruders (regression for commit b4ee665: "Emit max value of machine
|
||||
// limit among used extruders").
|
||||
TEST_CASE("Machine envelope emits max limit among used extruders", "[GCodeWriter]")
|
||||
{
|
||||
SECTION("Single extruder emits its configured values") {
|
||||
const std::string gcode = Slic3r::Test::slice({ cube(20) }, {
|
||||
{ "emit_machine_limits_to_gcode", "1" },
|
||||
{ "gcode_flavor", "marlin2" },
|
||||
{ "gcode_comments", "1" },
|
||||
{ "machine_start_gcode", "" },
|
||||
{ "layer_height", "0.2" },
|
||||
{ "initial_layer_print_height", "0.2" },
|
||||
{ "initial_layer_line_width", "0" },
|
||||
{ "z_hop", "0" },
|
||||
// stride-2 options: (normal, silent)
|
||||
{ "machine_max_acceleration_x", "500,600" },
|
||||
{ "machine_max_acceleration_y", "700,800" },
|
||||
{ "machine_max_acceleration_z", "100,200" },
|
||||
{ "machine_max_acceleration_e", "5000,6000" },
|
||||
{ "machine_max_acceleration_extruding", "1200,1300" },
|
||||
{ "machine_max_acceleration_retracting", "1400,1500" },
|
||||
{ "machine_max_acceleration_travel", "1600,1700" },
|
||||
// stride-2 options: (normal, silent)
|
||||
{ "machine_max_speed_x", "100,100" },
|
||||
{ "machine_max_speed_y", "110,110" },
|
||||
{ "machine_max_speed_z", "10,10" },
|
||||
{ "machine_max_speed_e", "50,50" },
|
||||
{ "machine_max_jerk_x", "8,8" },
|
||||
{ "machine_max_jerk_y", "9,9" },
|
||||
{ "machine_max_jerk_z", "0.4,0.4" },
|
||||
{ "machine_max_jerk_e", "5,5" },
|
||||
{ "machine_max_junction_deviation", "0.02,0.03" },
|
||||
});
|
||||
|
||||
THEN("M201 uses the normal acceleration values") {
|
||||
REQUIRE(gcode.find("M201 X500 Y700 Z100 E5000") != std::string::npos);
|
||||
}
|
||||
THEN("M203 uses the speed values") {
|
||||
REQUIRE(gcode.find("M203 X100 Y110 Z10 E50") != std::string::npos);
|
||||
}
|
||||
THEN("M204 (Marlin 2) uses extruding / retracting / travel") {
|
||||
REQUIRE(gcode.find("M204 P1200 R1400 T1600") != std::string::npos);
|
||||
}
|
||||
THEN("M205 uses the jerk values") {
|
||||
REQUIRE(gcode.find("M205 X8.00 Y9.00 Z0.40 E5.00") != std::string::npos);
|
||||
}
|
||||
THEN("M205 J uses the junction deviation") {
|
||||
REQUIRE(gcode.find("M205 J0.020") != std::string::npos);
|
||||
}
|
||||
}
|
||||
|
||||
SECTION("Legacy Marlin flavor emits correct format") {
|
||||
const std::string gcode = Slic3r::Test::slice({ cube(20) }, {
|
||||
{ "emit_machine_limits_to_gcode", "1" },
|
||||
{ "gcode_flavor", "marlin" },
|
||||
{ "gcode_comments", "1" },
|
||||
{ "machine_start_gcode", "" },
|
||||
{ "layer_height", "0.2" },
|
||||
{ "initial_layer_print_height", "0.2" },
|
||||
{ "initial_layer_line_width", "0" },
|
||||
{ "z_hop", "0" },
|
||||
// All machine limits must be provided — defaults are empty vectors.
|
||||
{ "machine_max_acceleration_x", "500,600" },
|
||||
{ "machine_max_acceleration_y", "500,600" },
|
||||
{ "machine_max_acceleration_z", "500,600" },
|
||||
{ "machine_max_acceleration_e", "5000,6000" },
|
||||
{ "machine_max_acceleration_extruding", "1200,1300" },
|
||||
{ "machine_max_acceleration_retracting", "1400,1500" },
|
||||
{ "machine_max_acceleration_travel", "1600,1700" },
|
||||
{ "machine_max_speed_x", "100,100" },
|
||||
{ "machine_max_speed_y", "110,110" },
|
||||
{ "machine_max_speed_z", "10,10" },
|
||||
{ "machine_max_speed_e", "50,50" },
|
||||
{ "machine_max_jerk_x", "8,8" },
|
||||
{ "machine_max_jerk_y", "9,9" },
|
||||
{ "machine_max_jerk_z", "0.4,0.4" },
|
||||
{ "machine_max_jerk_e", "5,5" },
|
||||
{ "machine_max_junction_deviation", "0.02,0.03" },
|
||||
});
|
||||
|
||||
THEN("Legacy Marlin: M204 travel_acc = extruding_acc") {
|
||||
// gcfMarlinLegacy uses extruding acc for travel too
|
||||
REQUIRE(gcode.find("M204 P1200 R1400 T1200") != std::string::npos);
|
||||
}
|
||||
THEN("Legacy Marlin: M205 uses mm/sec format") {
|
||||
REQUIRE(gcode.find("M205 X8.00 Y9.00 Z0.40 E5.00") != std::string::npos);
|
||||
}
|
||||
}
|
||||
|
||||
SECTION("Multi extruder - max of used extruders is emitted") {
|
||||
// Build config with 2 extruders that have *different* machine limits.
|
||||
// Extruder 1 has higher values; the emitted G-code must use the max.
|
||||
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
||||
|
||||
// Print basics
|
||||
config.set_key_value("emit_machine_limits_to_gcode", new ConfigOptionBool(true));
|
||||
config.set_key_value("gcode_flavor", new ConfigOptionEnum<GCodeFlavor>(gcfMarlinFirmware));
|
||||
config.set_key_value("gcode_comments", new ConfigOptionBool(true));
|
||||
config.set_key_value("machine_start_gcode", new ConfigOptionString(""));
|
||||
config.set_key_value("layer_height", new ConfigOptionFloat(0.2));
|
||||
config.set_key_value("initial_layer_print_height", new ConfigOptionFloat(0.2));
|
||||
config.set_key_value("initial_layer_line_width", new ConfigOptionFloatOrPercent(0, false));
|
||||
config.set_key_value("z_hop", new ConfigOptionFloats({0}));
|
||||
// Print objects sequentially so each uses its own extruder without
|
||||
// wipe-tower / tool-change complexity.
|
||||
config.set_key_value("print_sequence", new ConfigOptionEnum<PrintSequence>(PrintSequence::ByObject));
|
||||
|
||||
// 2 extruders
|
||||
config.set_key_value("nozzle_diameter", new ConfigOptionFloats({0.4, 0.4}));
|
||||
config.set_key_value("printer_extruder_id", new ConfigOptionInts({1, 2}));
|
||||
config.set_key_value("printer_extruder_variant", new ConfigOptionStrings({"Direct Drive Standard", "Direct Drive Standard"}));
|
||||
config.set_key_value("filament_diameter", new ConfigOptionFloats({1.75, 1.75}));
|
||||
config.set_key_value("filament_colour", new ConfigOptionStrings({"#FF0000", "#00FF00"}));
|
||||
config.set_key_value("filament_type", new ConfigOptionStrings({"PLA", "PLA"}));
|
||||
// filament_map maps filament slot index (1-based) → logical extruder ID (1-based).
|
||||
// Default [1] maps everything to extruder 0. Need [1, 2] for two distinct extruders.
|
||||
// fmmManual prevents auto-computation from overwriting the explicit mapping.
|
||||
config.option<ConfigOptionEnum<FilamentMapMode>>("filament_map_mode", true)->value = fmmManual;
|
||||
config.set_key_value("filament_map", new ConfigOptionInts({1, 2}));
|
||||
config.set_key_value("default_filament_colour", new ConfigOptionStrings({"#FF0000", "#00FF00"}));
|
||||
config.set_key_value("nozzle_temperature", new ConfigOptionInts({210, 210}));
|
||||
config.set_key_value("nozzle_temperature_range_low", new ConfigOptionInts({190, 190}));
|
||||
config.set_key_value("nozzle_temperature_range_high", new ConfigOptionInts({240, 240}));
|
||||
// flush_volumes_matrix must be filament_count^2 * heads_count entries.
|
||||
// 2 filaments * 2 * 1 head = 4 entries (all zero — flush volumes not tested here).
|
||||
config.set_key_value("flush_multiplier", new ConfigOptionFloats({1}));
|
||||
config.set_key_value("flush_volumes_matrix", new ConfigOptionFloats({0, 0, 0, 0}));
|
||||
|
||||
// Machine limits: extruder 0 low, extruder 1 high
|
||||
// Stride-2 (normal, silent pairs): e0_n, e0_s, e1_n, e1_s
|
||||
config.set_key_value("machine_max_acceleration_x", new ConfigOptionFloats({500, 0, 1000, 0}));
|
||||
config.set_key_value("machine_max_acceleration_y", new ConfigOptionFloats({700, 0, 1100, 0}));
|
||||
config.set_key_value("machine_max_acceleration_z", new ConfigOptionFloats({100, 0, 300, 0}));
|
||||
config.set_key_value("machine_max_acceleration_e", new ConfigOptionFloats({5000, 0, 8000, 0}));
|
||||
config.set_key_value("machine_max_acceleration_extruding", new ConfigOptionFloats({1200, 0, 2200, 0}));
|
||||
config.set_key_value("machine_max_acceleration_retracting", new ConfigOptionFloats({1400, 0, 2400, 0}));
|
||||
config.set_key_value("machine_max_acceleration_travel", new ConfigOptionFloats({1600, 0, 2600, 0}));
|
||||
config.set_key_value("machine_max_speed_x", new ConfigOptionFloats({100, 0, 200, 0}));
|
||||
config.set_key_value("machine_max_speed_y", new ConfigOptionFloats({110, 0, 210, 0}));
|
||||
config.set_key_value("machine_max_speed_z", new ConfigOptionFloats({10, 0, 30, 0}));
|
||||
config.set_key_value("machine_max_speed_e", new ConfigOptionFloats({50, 0, 80, 0}));
|
||||
config.set_key_value("machine_max_jerk_x", new ConfigOptionFloats({8, 0, 12, 0}));
|
||||
config.set_key_value("machine_max_jerk_y", new ConfigOptionFloats({9, 0, 13, 0}));
|
||||
config.set_key_value("machine_max_jerk_z", new ConfigOptionFloats({0.4, 0, 0.6, 0}));
|
||||
config.set_key_value("machine_max_jerk_e", new ConfigOptionFloats({5, 0, 10, 0}));
|
||||
config.set_key_value("machine_max_junction_deviation", new ConfigOptionFloats({0.02, 0, 0.05, 0}));
|
||||
|
||||
// Model: two objects assigned to different extruders
|
||||
Model model;
|
||||
auto* obj1 = model.add_object();
|
||||
obj1->add_volume(cube(20));
|
||||
obj1->add_instance();
|
||||
// obj1 uses default extruder=1 (0-based index 0)
|
||||
|
||||
auto* obj2 = model.add_object();
|
||||
obj2->add_volume(cube(20));
|
||||
obj2->add_instance();
|
||||
obj2->config.set_key_value("extruder", new ConfigOptionInt(2)); // 0-based index 1
|
||||
|
||||
Print print;
|
||||
arrange_objects_on_test_bed(model, config);
|
||||
for (auto* mo : model.objects) {
|
||||
mo->ensure_on_bed();
|
||||
print.auto_assign_extruders(mo);
|
||||
}
|
||||
|
||||
print.apply(model, config);
|
||||
print.validate();
|
||||
print.set_status_silent();
|
||||
print.process();
|
||||
|
||||
std::string gcode = Slic3r::Test::gcode(print);
|
||||
|
||||
THEN("M201 contains max (extruder 1's) acceleration values") {
|
||||
REQUIRE(gcode.find("M201 X1000 Y1100 Z300 E8000") != std::string::npos);
|
||||
}
|
||||
THEN("M203 contains max speed values") {
|
||||
REQUIRE(gcode.find("M203 X200 Y210 Z30 E80") != std::string::npos);
|
||||
}
|
||||
THEN("M204 contains max extruding / retracting / travel") {
|
||||
REQUIRE(gcode.find("M204 P2200 R2400 T2600") != std::string::npos);
|
||||
}
|
||||
THEN("M205 contains max jerk values") {
|
||||
REQUIRE(gcode.find("M205 X12.00 Y13.00 Z0.60 E10.00") != std::string::npos);
|
||||
}
|
||||
THEN("M205 contains max m_max_junction_deviation ") {
|
||||
REQUIRE(gcode.find("M205 J0.050") != std::string::npos);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Verify that the EXTRUDER_LIMIT macro (GCodeWriter.cpp) correctly:
|
||||
// 1) Uses the active extruder's specific limit when filament() is known.
|
||||
// 2) Falls back to the maximum of all extruder limits when filament() is nullptr.
|
||||
//
|
||||
// These two behaviours were introduced in:
|
||||
// - "Use per-extruder motion limit" (1ab34a7454)
|
||||
// - "Use max limit when current extruder is unknown" (b7240ab1c6)
|
||||
TEST_CASE("EXTRUDER_LIMIT per-extruder clamping and max fallback", "[GCodeWriter]")
|
||||
{
|
||||
// --- Build config with 2 extruders that have different machine limits ---
|
||||
// Extruder 0: low limits
|
||||
// Extruder 1: high limits
|
||||
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
||||
|
||||
config.set_key_value("emit_machine_limits_to_gcode", new ConfigOptionBool(true));
|
||||
config.set_key_value("gcode_flavor", new ConfigOptionEnum<GCodeFlavor>(gcfMarlinFirmware));
|
||||
config.set_key_value("gcode_comments", new ConfigOptionBool(true));
|
||||
config.set_key_value("machine_start_gcode", new ConfigOptionString(""));
|
||||
config.set_key_value("layer_height", new ConfigOptionFloat(0.2));
|
||||
config.set_key_value("initial_layer_print_height", new ConfigOptionFloat(0.2));
|
||||
config.set_key_value("initial_layer_line_width", new ConfigOptionFloatOrPercent(0, false));
|
||||
config.set_key_value("z_hop", new ConfigOptionFloats({0}));
|
||||
config.set_key_value("print_sequence", new ConfigOptionEnum<PrintSequence>(PrintSequence::ByObject));
|
||||
|
||||
// 2 extruders, 2 filaments
|
||||
config.set_key_value("nozzle_diameter", new ConfigOptionFloats({0.4, 0.4}));
|
||||
config.set_key_value("printer_extruder_id", new ConfigOptionInts({1, 2}));
|
||||
config.set_key_value("printer_extruder_variant", new ConfigOptionStrings({"Direct Drive Standard", "Direct Drive Standard"}));
|
||||
config.set_key_value("filament_diameter", new ConfigOptionFloats({1.75, 1.75}));
|
||||
config.set_key_value("filament_colour", new ConfigOptionStrings({"#FF0000", "#00FF00"}));
|
||||
config.set_key_value("filament_type", new ConfigOptionStrings({"PLA", "PLA"}));
|
||||
config.option<ConfigOptionEnum<FilamentMapMode>>("filament_map_mode", true)->value = fmmManual;
|
||||
config.set_key_value("filament_map", new ConfigOptionInts({1, 2}));
|
||||
config.set_key_value("default_filament_colour", new ConfigOptionStrings({"#FF0000", "#00FF00"}));
|
||||
config.set_key_value("nozzle_temperature", new ConfigOptionInts({210, 210}));
|
||||
config.set_key_value("nozzle_temperature_range_low", new ConfigOptionInts({190, 190}));
|
||||
config.set_key_value("nozzle_temperature_range_high", new ConfigOptionInts({240, 240}));
|
||||
config.set_key_value("flush_multiplier", new ConfigOptionFloats({1}));
|
||||
config.set_key_value("flush_volumes_matrix", new ConfigOptionFloats({0, 0, 0, 0}));
|
||||
|
||||
// --- Machine limits (stride-2: e0_n, e0_s, e1_n, e1_s) ---
|
||||
// Extruder 0 has LOW limits, Extruder 1 has HIGH limits.
|
||||
config.set_key_value("machine_max_acceleration_x", new ConfigOptionFloats({500, 0, 1000, 0}));
|
||||
config.set_key_value("machine_max_acceleration_y", new ConfigOptionFloats({500, 0, 1000, 0}));
|
||||
config.set_key_value("machine_max_acceleration_z", new ConfigOptionFloats({100, 0, 200, 0}));
|
||||
config.set_key_value("machine_max_acceleration_e", new ConfigOptionFloats({5000, 0, 5000, 0}));
|
||||
config.set_key_value("machine_max_acceleration_extruding", new ConfigOptionFloats({500, 0, 2000, 0}));
|
||||
config.set_key_value("machine_max_acceleration_retracting", new ConfigOptionFloats({600, 0, 2000, 0}));
|
||||
config.set_key_value("machine_max_acceleration_travel", new ConfigOptionFloats({700, 0, 2500, 0}));
|
||||
config.set_key_value("machine_max_speed_x", new ConfigOptionFloats({100, 0, 200, 0}));
|
||||
config.set_key_value("machine_max_speed_y", new ConfigOptionFloats({110, 0, 210, 0}));
|
||||
config.set_key_value("machine_max_speed_z", new ConfigOptionFloats({10, 0, 30, 0}));
|
||||
config.set_key_value("machine_max_speed_e", new ConfigOptionFloats({50, 0, 80, 0}));
|
||||
config.set_key_value("machine_max_jerk_x", new ConfigOptionFloats({5, 0, 15, 0}));
|
||||
config.set_key_value("machine_max_jerk_y", new ConfigOptionFloats({6, 0, 16, 0}));
|
||||
config.set_key_value("machine_max_jerk_z", new ConfigOptionFloats({0.4, 0, 0.8, 0}));
|
||||
config.set_key_value("machine_max_jerk_e", new ConfigOptionFloats({3, 0, 8, 0}));
|
||||
config.set_key_value("machine_max_junction_deviation", new ConfigOptionFloats({0.02, 0, 0.08, 0}));
|
||||
|
||||
// --- Print acceleration: 1500 mm/s² ---
|
||||
// Exceeds extruder 0's limit (500) → should be clamped to 500.
|
||||
// Does NOT exceed extruder 1's limit (2000) → passes through as 1500.
|
||||
config.set_key_value("default_acceleration", new ConfigOptionFloats({1500, 1500}));
|
||||
config.set_key_value("outer_wall_acceleration", new ConfigOptionFloats({1500, 1500}));
|
||||
config.set_key_value("inner_wall_acceleration", new ConfigOptionFloats({1500, 1500}));
|
||||
config.set_key_value("top_surface_acceleration", new ConfigOptionFloats({1500, 1500}));
|
||||
config.set_key_value("initial_layer_acceleration", new ConfigOptionFloats({1500, 1500}));
|
||||
config.set_key_value("travel_acceleration", new ConfigOptionFloats({1500, 1500}));
|
||||
|
||||
// Model: two objects assigned to different extruders
|
||||
Model model;
|
||||
auto* obj1 = model.add_object();
|
||||
obj1->add_volume(cube(20));
|
||||
obj1->add_instance();
|
||||
|
||||
auto* obj2 = model.add_object();
|
||||
obj2->add_volume(cube(20));
|
||||
obj2->add_instance();
|
||||
obj2->config.set_key_value("extruder", new ConfigOptionInt(2)); // 0-based index 1
|
||||
|
||||
Print print;
|
||||
arrange_objects_on_test_bed(model, config);
|
||||
for (auto* mo : model.objects) {
|
||||
mo->ensure_on_bed();
|
||||
print.auto_assign_extruders(mo);
|
||||
}
|
||||
|
||||
print.apply(model, config);
|
||||
print.validate();
|
||||
print.set_status_silent();
|
||||
print.process();
|
||||
|
||||
std::string gcode = Slic3r::Test::gcode(print);
|
||||
|
||||
SECTION("Preamble: max limit among used extruders") {
|
||||
THEN("M201 uses max (extruder 1's) acceleration values") {
|
||||
REQUIRE(gcode.find("M201 X1000 Y1000 Z200 E5000") != std::string::npos);
|
||||
}
|
||||
THEN("M204 uses max extruding/retracting/travel") {
|
||||
REQUIRE(gcode.find("M204 P2000 R2000 T2500") != std::string::npos);
|
||||
}
|
||||
THEN("M205 uses max jerk values") {
|
||||
REQUIRE(gcode.find("M205 X15.00 Y16.00 Z0.80 E8.00") != std::string::npos);
|
||||
}
|
||||
}
|
||||
|
||||
SECTION("Preamble: EXTRUDER_LIMIT falls back to max when no filament is active") {
|
||||
// set_junction_deviation() is called during preamble with no active filament.
|
||||
// EXTRUDER_LIMIT(m_max_junction_deviation) → filament() == nullptr → max of all (0.08).
|
||||
THEN("M205 J uses max junction deviation") {
|
||||
REQUIRE(gcode.find("M205 J0.080") != std::string::npos);
|
||||
}
|
||||
}
|
||||
|
||||
SECTION("Print: extruder 0 acceleration clamped to its specific limit") {
|
||||
// Extruder 0 machine limit = 500. Print accel = 1500 > 500 → clamped to 500.
|
||||
THEN("M204 P500 appears (extruder 0 clamped)") {
|
||||
REQUIRE(gcode.find("M204 P500") != std::string::npos);
|
||||
}
|
||||
THEN("M204 T700 appears (extruder 0 travel clamped)") {
|
||||
REQUIRE(gcode.find("M204 T700") != std::string::npos);
|
||||
}
|
||||
}
|
||||
|
||||
SECTION("Print: extruder 1 acceleration NOT clamped to extruder 0's limit") {
|
||||
// Extruder 1 machine limit = 2000. Print accel = 1500 < 2000 → not clamped.
|
||||
THEN("M204 P1500 appears (extruder 1 not clamped to 500)") {
|
||||
REQUIRE(gcode.find("M204 P1500") != std::string::npos);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SCENARIO("Extruder reads the injected config column", "[GCodeWriter][H2C]") {
|
||||
GIVEN("A writer whose per-variant arrays hold three columns for two filaments") {
|
||||
GCodeWriter writer;
|
||||
// Column layout after a migrating regroup: filament 0 -> column 0, filament 1 ->
|
||||
// columns 1 (its first variant) and 2 (its second variant).
|
||||
writer.config.retraction_length.values = {0.8, 0.5, 1.2};
|
||||
writer.config.z_hop.values = {0.4, 0.6, 0.9};
|
||||
writer.config.retraction_speed.values = {30., 40., 50.};
|
||||
writer.config.filament_flow_ratio.values = {0.98, 1.0, 1.02};
|
||||
// Filament-indexed arrays keep one entry per filament.
|
||||
writer.config.filament_diameter.values = {1.75, 1.75};
|
||||
writer.set_extruders({0, 1});
|
||||
writer.toolchange(1, 1);
|
||||
Extruder *fil = writer.filament();
|
||||
REQUIRE(fil != nullptr);
|
||||
REQUIRE(fil->id() == 1);
|
||||
const double crossection = 1.75 * 1.75 * 0.25 * PI;
|
||||
|
||||
WHEN("no column has been injected") {
|
||||
THEN("the getters read the filament id's column") {
|
||||
REQUIRE(fil->config_index() == 1);
|
||||
REQUIRE_THAT(fil->retraction_length(), Catch::Matchers::WithinAbs(0.5, 1e-9));
|
||||
REQUIRE_THAT(fil->retract_lift(), Catch::Matchers::WithinAbs(0.6, 1e-9));
|
||||
REQUIRE(fil->retract_speed() == 40);
|
||||
REQUIRE_THAT(fil->e_per_mm3(), Catch::Matchers::WithinRel(1.0 / crossection, 1e-9));
|
||||
}
|
||||
}
|
||||
WHEN("the second variant column is injected") {
|
||||
fil->set_config_index(2);
|
||||
THEN("the getters follow the column and the flow cache is rescaled") {
|
||||
REQUIRE(fil->config_index() == 2);
|
||||
REQUIRE_THAT(fil->retraction_length(), Catch::Matchers::WithinAbs(1.2, 1e-9));
|
||||
REQUIRE_THAT(fil->retract_lift(), Catch::Matchers::WithinAbs(0.9, 1e-9));
|
||||
REQUIRE(fil->retract_speed() == 50);
|
||||
REQUIRE_THAT(fil->e_per_mm3(), Catch::Matchers::WithinRel(1.02 / crossection, 1e-9));
|
||||
}
|
||||
THEN("filament-indexed reads keep using the filament id") {
|
||||
REQUIRE_THAT(fil->filament_diameter(), Catch::Matchers::WithinAbs(1.75, 1e-9));
|
||||
}
|
||||
}
|
||||
WHEN("a negative index is injected") {
|
||||
fil->set_config_index(2);
|
||||
fil->set_config_index(-1);
|
||||
THEN("resolution resets to the filament id") {
|
||||
REQUIRE(fil->config_index() == 1);
|
||||
REQUIRE_THAT(fil->retraction_length(), Catch::Matchers::WithinAbs(0.5, 1e-9));
|
||||
REQUIRE_THAT(fil->e_per_mm3(), Catch::Matchers::WithinRel(1.0 / crossection, 1e-9));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Numeric argument of every line starting with `prefix`, in file order.
|
||||
static std::vector<int> collect_line_args(const std::string &gcode, const std::string &prefix)
|
||||
{
|
||||
std::vector<int> values;
|
||||
std::istringstream stream(gcode);
|
||||
std::string line;
|
||||
while (std::getline(stream, line))
|
||||
if (line.compare(0, prefix.size(), prefix) == 0)
|
||||
values.push_back(std::atoi(line.c_str() + int(prefix.size())));
|
||||
return values;
|
||||
}
|
||||
|
||||
static int count_lines_with_prefix(const std::string &gcode, const std::string &prefix)
|
||||
{
|
||||
return (int) collect_line_args(gcode, prefix).size();
|
||||
}
|
||||
|
||||
// A toolchange ordinal sequence is healthy when it advances by exactly one per
|
||||
// change block; a change-less prime-tower visit must not consume an ordinal.
|
||||
static bool ordinals_consecutive(const std::vector<int> &values)
|
||||
{
|
||||
for (size_t i = 1; i < values.size(); ++i)
|
||||
if (values[i] != values[i - 1] + 1)
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
SCENARIO("Toolchange emission and prefix per printer kind", "[GCodeWriter][H2C]") {
|
||||
GIVEN("A dual-extruder writer with two filaments") {
|
||||
GCodeWriter writer;
|
||||
writer.config.filament_diameter.values = {1.75, 1.75};
|
||||
writer.set_extruders({0, 1});
|
||||
|
||||
WHEN("the printer is a BBL machine") {
|
||||
writer.set_is_bbl_machine(true);
|
||||
THEN("the toolchange prefix is the plain T command") {
|
||||
REQUIRE_THAT(writer.toolchange_prefix(), Catch::Matchers::Equals("T"));
|
||||
}
|
||||
THEN("toolchange emits a single M1020 with the nozzle id") {
|
||||
const std::string gcode = writer.toolchange(1, 0);
|
||||
REQUIRE_THAT(gcode, Catch::Matchers::ContainsSubstring("M1020 S1 H0"));
|
||||
REQUIRE_THAT(gcode, !Catch::Matchers::StartsWith("T1"));
|
||||
}
|
||||
THEN("the other filament and nozzle emit their own ids") {
|
||||
REQUIRE_THAT(writer.toolchange(0, 1), Catch::Matchers::ContainsSubstring("M1020 S0 H1"));
|
||||
}
|
||||
THEN("an unresolved nozzle keeps the literal -1 convention") {
|
||||
REQUIRE_THAT(writer.toolchange(1, -1), Catch::Matchers::ContainsSubstring("M1020 S1 H-1"));
|
||||
}
|
||||
}
|
||||
WHEN("the printer is a BBL machine with manual filament change") {
|
||||
writer.set_is_bbl_machine(true);
|
||||
writer.config.manual_filament_change.value = true;
|
||||
THEN("the manual tag wins over the M1020 form") {
|
||||
REQUIRE_THAT(writer.toolchange_prefix(), Catch::Matchers::StartsWith(";"));
|
||||
const std::string gcode = writer.toolchange(1, 0);
|
||||
REQUIRE_THAT(gcode, Catch::Matchers::ContainsSubstring(writer.toolchange_prefix() + "1"));
|
||||
REQUIRE_THAT(gcode, !Catch::Matchers::ContainsSubstring("M1020"));
|
||||
}
|
||||
}
|
||||
WHEN("the printer is not a BBL machine") {
|
||||
THEN("toolchange keeps the plain T command") {
|
||||
REQUIRE_THAT(writer.toolchange_prefix(), Catch::Matchers::Equals("T"));
|
||||
const std::string gcode = writer.toolchange(1, 0);
|
||||
REQUIRE_THAT(gcode, Catch::Matchers::StartsWith("T1"));
|
||||
REQUIRE_THAT(gcode, !Catch::Matchers::ContainsSubstring("M1020"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Shared dual-extruder printer config for the toolchange-count scenarios below.
|
||||
static DynamicPrintConfig dual_extruder_toolchange_config()
|
||||
{
|
||||
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
||||
config.set_key_value("gcode_flavor", new ConfigOptionEnum<GCodeFlavor>(gcfMarlinFirmware));
|
||||
config.set_key_value("emit_machine_limits_to_gcode", new ConfigOptionBool(false));
|
||||
config.set_key_value("machine_start_gcode", new ConfigOptionString(""));
|
||||
config.set_key_value("layer_height", new ConfigOptionFloat(0.2));
|
||||
config.set_key_value("initial_layer_print_height", new ConfigOptionFloat(0.2));
|
||||
config.set_key_value("initial_layer_line_width", new ConfigOptionFloatOrPercent(0, false));
|
||||
config.set_key_value("z_hop", new ConfigOptionFloats({0., 0.}));
|
||||
// The change block carries both a real toolchange command and the ordinal
|
||||
// placeholder the stock profiles feed to the firmware.
|
||||
config.set_key_value("change_filament_gcode",
|
||||
new ConfigOptionString("T[next_filament_id]\nM620 O{toolchange_count + 1}\n"));
|
||||
|
||||
// 2 extruders, one filament each (manual map so nothing regroups them).
|
||||
config.set_key_value("nozzle_diameter", new ConfigOptionFloats({0.4, 0.4}));
|
||||
config.set_key_value("printer_extruder_id", new ConfigOptionInts({1, 2}));
|
||||
config.set_key_value("printer_extruder_variant", new ConfigOptionStrings({"Direct Drive Standard", "Direct Drive Standard"}));
|
||||
config.set_key_value("filament_diameter", new ConfigOptionFloats({1.75, 1.75}));
|
||||
config.set_key_value("filament_colour", new ConfigOptionStrings({"#FF0000", "#00FF00"}));
|
||||
config.set_key_value("default_filament_colour", new ConfigOptionStrings({"#FF0000", "#00FF00"}));
|
||||
config.set_key_value("filament_type", new ConfigOptionStrings({"PLA", "PLA"}));
|
||||
config.option<ConfigOptionEnum<FilamentMapMode>>("filament_map_mode", true)->value = fmmManual;
|
||||
config.set_key_value("filament_map", new ConfigOptionInts({1, 2}));
|
||||
config.set_key_value("nozzle_temperature", new ConfigOptionInts({210, 210}));
|
||||
config.set_key_value("nozzle_temperature_range_low", new ConfigOptionInts({190, 190}));
|
||||
config.set_key_value("nozzle_temperature_range_high", new ConfigOptionInts({240, 240}));
|
||||
config.set_key_value("flush_multiplier", new ConfigOptionFloats({1}));
|
||||
config.set_key_value("flush_volumes_matrix", new ConfigOptionFloats({0, 140, 140, 0}));
|
||||
return config;
|
||||
}
|
||||
|
||||
SCENARIO("Change blocks carry consecutive toolchange ordinals without a duplicate command", "[GCodeWriter][H2C]") {
|
||||
GIVEN("Two sequentially printed objects on different extruders of a BBL machine") {
|
||||
DynamicPrintConfig config = dual_extruder_toolchange_config();
|
||||
config.set_key_value("print_sequence", new ConfigOptionEnum<PrintSequence>(PrintSequence::ByObject));
|
||||
|
||||
Model model;
|
||||
auto *obj1 = model.add_object();
|
||||
obj1->add_volume(cube(20));
|
||||
obj1->add_instance();
|
||||
auto *obj2 = model.add_object();
|
||||
obj2->add_volume(cube(20));
|
||||
obj2->add_instance();
|
||||
obj2->config.set_key_value("extruder", new ConfigOptionInt(2));
|
||||
|
||||
auto slice_to_gcode = [&]() {
|
||||
Print print;
|
||||
print.is_BBL_printer() = true;
|
||||
arrange_objects_on_test_bed(model, config);
|
||||
for (auto *mo : model.objects) {
|
||||
mo->ensure_on_bed();
|
||||
print.auto_assign_extruders(mo);
|
||||
}
|
||||
print.apply(model, config);
|
||||
print.validate();
|
||||
print.set_status_silent();
|
||||
print.process();
|
||||
return Slic3r::Test::gcode(print);
|
||||
};
|
||||
|
||||
WHEN("the change block already changes the tool") {
|
||||
const std::string gcode = slice_to_gcode();
|
||||
const std::vector<int> ordinals = collect_line_args(gcode, "M620 O");
|
||||
THEN("each change block advances the ordinal by exactly one, without inflation") {
|
||||
REQUIRE(!ordinals.empty());
|
||||
REQUIRE(ordinals_consecutive(ordinals));
|
||||
REQUIRE(ordinals.front() <= 3);
|
||||
}
|
||||
THEN("the writer's own command is suppressed as a duplicate") {
|
||||
REQUIRE(count_lines_with_prefix(gcode, "M1020") == 0);
|
||||
REQUIRE(count_lines_with_prefix(gcode, "T1") >= 1);
|
||||
}
|
||||
}
|
||||
WHEN("the change block does not change the tool itself") {
|
||||
config.set_key_value("change_filament_gcode",
|
||||
new ConfigOptionString("M620 O{toolchange_count + 1}\n"));
|
||||
const std::string gcode = slice_to_gcode();
|
||||
const std::vector<int> ordinals = collect_line_args(gcode, "M620 O");
|
||||
THEN("the writer's toolchange survives and carries a nozzle id") {
|
||||
REQUIRE(count_lines_with_prefix(gcode, "M1020 S1 H") >= 1);
|
||||
}
|
||||
THEN("the ordinal sequence stays consecutive") {
|
||||
REQUIRE(!ordinals.empty());
|
||||
REQUIRE(ordinals_consecutive(ordinals));
|
||||
REQUIRE(ordinals.front() <= 3);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SCENARIO("Prime-tower visits without a filament change do not advance the toolchange ordinal", "[GCodeWriter][H2C]") {
|
||||
GIVEN("A print whose only filament change happens far above the bed") {
|
||||
DynamicPrintConfig config = dual_extruder_toolchange_config();
|
||||
config.set_key_value("enable_prime_tower", new ConfigOptionBool(true));
|
||||
|
||||
// Filament 2 is used only above z=6, so every tower layer below it is a
|
||||
// change-less visit — the exact geometry that used to inflate the ordinal.
|
||||
Model model;
|
||||
auto *obj = model.add_object();
|
||||
obj->add_volume(cube(10));
|
||||
obj->add_instance();
|
||||
DynamicPrintConfig range_config;
|
||||
range_config.set_key_value("extruder", new ConfigOptionInt(2));
|
||||
// Every layer range must carry a layer_height (see layer_height_profile_from_ranges).
|
||||
range_config.set_key_value("layer_height", new ConfigOptionFloat(0.2));
|
||||
obj->layer_config_ranges[{6.0, 10.0}].assign_config(std::move(range_config));
|
||||
|
||||
Print print;
|
||||
print.is_BBL_printer() = true;
|
||||
arrange_objects_on_test_bed(model, config);
|
||||
for (auto *mo : model.objects) {
|
||||
mo->ensure_on_bed();
|
||||
print.auto_assign_extruders(mo);
|
||||
}
|
||||
print.apply(model, config);
|
||||
print.validate();
|
||||
print.set_status_silent();
|
||||
print.process();
|
||||
const std::string gcode = Slic3r::Test::gcode(print);
|
||||
|
||||
WHEN("the print is exported") {
|
||||
const std::vector<int> ordinals = collect_line_args(gcode, "M620 O");
|
||||
THEN("the prime-tower toolchange path was exercised") {
|
||||
REQUIRE_THAT(gcode, Catch::Matchers::ContainsSubstring("CP TOOLCHANGE START"));
|
||||
}
|
||||
THEN("dozens of change-less tower layers consume no ordinal") {
|
||||
REQUIRE(!ordinals.empty());
|
||||
REQUIRE(ordinals_consecutive(ordinals));
|
||||
REQUIRE(ordinals.front() <= 3);
|
||||
}
|
||||
THEN("no duplicate toolchange command follows the change block") {
|
||||
REQUIRE(count_lines_with_prefix(gcode, "M1020") == 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Real-profile toolchange coverage, targeted. The all-vendors sweep in test_profile_slicing.cpp now
|
||||
// slices a two-colour cube per printer, so it already expands every shipped change_filament_gcode with
|
||||
// each printer's DEFAULT extruder variants (both the single-nozzle append_tcr and dual-nozzle set_extruder
|
||||
// paths). What that sweep can't reach is a variant-conditional branch the defaults never select — H2D's
|
||||
// change gcode has an `== "Direct Drive TPU High Flow"` block. This scenario forces that branch by handing
|
||||
// the extruders distinct kits, so an unregistered placeholder inside it still throws "Variable does not
|
||||
// exist" here instead of only in the field.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Two 20mm cubes on separate extruders of a BBL machine, printed by object so exactly
|
||||
// one real toolchange fires and drives the change_filament_gcode. Returns the g-code.
|
||||
static std::string slice_two_object_bbl(DynamicPrintConfig &config)
|
||||
{
|
||||
config.set_key_value("print_sequence", new ConfigOptionEnum<PrintSequence>(PrintSequence::ByObject));
|
||||
|
||||
Model model;
|
||||
auto *obj1 = model.add_object();
|
||||
obj1->add_volume(cube(20));
|
||||
obj1->add_instance();
|
||||
auto *obj2 = model.add_object();
|
||||
obj2->add_volume(cube(20));
|
||||
obj2->add_instance();
|
||||
obj2->config.set_key_value("extruder", new ConfigOptionInt(2));
|
||||
|
||||
Print print;
|
||||
print.is_BBL_printer() = true;
|
||||
arrange_objects_on_test_bed(model, config);
|
||||
for (auto *mo : model.objects) {
|
||||
mo->ensure_on_bed();
|
||||
print.auto_assign_extruders(mo);
|
||||
}
|
||||
print.apply(model, config);
|
||||
print.validate();
|
||||
print.set_status_silent();
|
||||
print.process();
|
||||
return Slic3r::Test::gcode(print);
|
||||
}
|
||||
|
||||
// The real change_filament_gcode of a shipped "<printer> 0.4 nozzle" machine profile.
|
||||
static std::string shipped_change_filament_gcode(const std::string &printer)
|
||||
{
|
||||
const std::string path = std::string(PROFILES_DIR) + "/BBL/machine/Bambu Lab " + printer + " 0.4 nozzle.json";
|
||||
// PROFILES_DIR is an absolute path baked in at build time; a sparse test checkout
|
||||
// without resources/ leaves it missing. Skip rather than dereference a config that
|
||||
// never loaded - this is the only fff_print test that reads a shipped profile.
|
||||
if (!boost::filesystem::exists(path))
|
||||
SKIP("shipped profile not present in this checkout: " << path);
|
||||
DynamicPrintConfig config;
|
||||
std::map<std::string, std::string> key_values;
|
||||
std::string reason;
|
||||
config.load_from_json(path, ForwardCompatibilitySubstitutionRule::Enable, key_values, reason);
|
||||
// Fail loudly on a malformed/renamed profile instead of null-dereferencing in opt_string.
|
||||
INFO("profile: " << path << (reason.empty() ? "" : (" load reason: " + reason)));
|
||||
REQUIRE(config.has("change_filament_gcode"));
|
||||
return config.opt_string("change_filament_gcode");
|
||||
}
|
||||
|
||||
SCENARIO("Toolchange gcode resolves old/new_extruder_variant from printer_extruder_variant", "[GCodeWriter][H2C]")
|
||||
{
|
||||
GIVEN("a BBL dual-extruder print whose change gcode reads the extruder-variant placeholders") {
|
||||
DynamicPrintConfig config = dual_extruder_toolchange_config();
|
||||
// A distinctive variant that can only reach the g-code through printer_extruder_variant.
|
||||
// Both entries carry it so the assertion is independent of which physical extruder the
|
||||
// emitted change routes through.
|
||||
config.set_key_value("printer_extruder_variant",
|
||||
new ConfigOptionStrings({"Direct Drive TPU High Flow", "Direct Drive TPU High Flow"}));
|
||||
config.set_key_value("change_filament_gcode", new ConfigOptionString(
|
||||
"; VARIANT old={old_extruder_variant} new={new_extruder_variant}\nT[next_filament_id]\n"));
|
||||
|
||||
WHEN("the print is sliced") {
|
||||
const std::string gcode = slice_two_object_bbl(config);
|
||||
THEN("both placeholders resolve to the printer_extruder_variant value") {
|
||||
// The resolved line is the proof: an unresolved token or a parser throw would
|
||||
// prevent this exact line from being emitted. (A negative "{token}" check is
|
||||
// unreliable — the g-code's trailing config dump echoes the raw template.)
|
||||
REQUIRE_THAT(gcode, Catch::Matchers::ContainsSubstring(
|
||||
"; VARIANT old=Direct Drive TPU High Flow new=Direct Drive TPU High Flow"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SCENARIO("Global current-tool placeholders resolve in a context with no local injection", "[GCodeWriter][H2C]")
|
||||
{
|
||||
GIVEN("a BBL dual-extruder print whose before_layer_change_gcode reads the current-tool placeholders") {
|
||||
DynamicPrintConfig config = dual_extruder_toolchange_config();
|
||||
// before_layer_change is one of the contexts that inject NO current_* into their local config
|
||||
// (unlike change_filament / machine_end / layer_change), so these placeholders can only resolve
|
||||
// through the GLOBAL parser vars published at each toolchange (and at the initial set_extruder).
|
||||
// Pre-fix current_filament_id / current_extruder_id / current_nozzle_id were undefined here and the
|
||||
// whole slice threw a PlaceholderParserError — the same failure mode X2D's layer_change hit.
|
||||
config.set_key_value("before_layer_change_gcode", new ConfigOptionString(
|
||||
"; GVAR fid={current_filament_id} eid={current_extruder_id} nid={current_nozzle_id}\n"));
|
||||
|
||||
WHEN("the print is sliced (obj1 on filament 0, obj2 on filament 1)") {
|
||||
std::string gcode;
|
||||
REQUIRE_NOTHROW(gcode = slice_two_object_bbl(config));
|
||||
THEN("all three globals resolve to the CORRECT active-tool values on both sides of the change") {
|
||||
// Assert the FULL resolved marker, not just no-throw: obj1 prints on filament 0 (extruder 0,
|
||||
// nozzle 0) and obj2 on filament 1 (extruder 1, nozzle 1). Locking every field means a
|
||||
// stale or wrong global (e.g. obj2 still reading fid=0, or a mismatched extruder/nozzle id)
|
||||
// fails here — this is the value guard that replaces the old "throws on undefined" canary.
|
||||
// Only the emitted before_layer_change lines carry resolved values; the trailing config dump
|
||||
// keeps the raw "{current_filament_id}" template, so these are unambiguous.
|
||||
REQUIRE_THAT(gcode, Catch::Matchers::ContainsSubstring("; GVAR fid=0 eid=0 nid=0"));
|
||||
REQUIRE_THAT(gcode, Catch::Matchers::ContainsSubstring("; GVAR fid=1 eid=1 nid=1"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SCENARIO("Shipped dual-nozzle change_filament_gcode resolves during a real slice", "[GCodeWriter][H2C][Profiles]")
|
||||
{
|
||||
const std::string printer = GENERATE(std::string("H2C"), std::string("H2D"), std::string("H2D Pro"), std::string("X2D"));
|
||||
|
||||
GIVEN("the real " + printer + " change_filament_gcode driving a BBL dual-extruder slice") {
|
||||
DynamicPrintConfig config = dual_extruder_toolchange_config();
|
||||
config.set_key_value("change_filament_gcode", new ConfigOptionString(shipped_change_filament_gcode(printer)));
|
||||
// H2D's gcode branches on the extruder variant; give the extruders distinct kits so the
|
||||
// "Direct Drive TPU High Flow" branch is reachable.
|
||||
config.set_key_value("printer_extruder_variant",
|
||||
new ConfigOptionStrings({"Direct Drive Standard", "Direct Drive TPU High Flow"}));
|
||||
// Extruder-indexed machine rates the stock gcode divides by (default size 1); size to 2 extruders.
|
||||
config.set_key_value("hotend_cooling_rate", new ConfigOptionFloatsNullable({2.0, 2.0}));
|
||||
config.set_key_value("hotend_heating_rate", new ConfigOptionFloatsNullable({2.0, 2.0}));
|
||||
|
||||
THEN("every placeholder resolves (no undefined-variable throw) and the change block runs") {
|
||||
std::string gcode;
|
||||
REQUIRE_NOTHROW(gcode = slice_two_object_bbl(config));
|
||||
// A resolved marker only the emitted change block produces (the trailing config
|
||||
// dump keeps the raw "{filament_type[...]}" template), so this confirms the real
|
||||
// change_filament_gcode was expanded, not merely echoed.
|
||||
REQUIRE_THAT(gcode, Catch::Matchers::ContainsSubstring("set_filament_type:PLA"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#include "test_data.hpp"
|
||||
#include "test_helpers.hpp"
|
||||
|
||||
#include "libslic3r/TriangleMesh.hpp"
|
||||
#include "libslic3r/GCodeReader.hpp"
|
||||
@@ -19,13 +19,11 @@ using namespace std;
|
||||
|
||||
namespace Slic3r { namespace Test {
|
||||
|
||||
// Mesh enumeration to name mapping
|
||||
const std::unordered_map<TestMesh, const char*, TestMeshHash> mesh_names {
|
||||
std::pair<TestMesh, const char*>(TestMesh::A, "A"),
|
||||
std::pair<TestMesh, const char*>(TestMesh::L, "L"),
|
||||
std::pair<TestMesh, const char*>(TestMesh::V, "V"),
|
||||
std::pair<TestMesh, const char*>(TestMesh::_40x10, "40x10"),
|
||||
std::pair<TestMesh, const char*>(TestMesh::cube_20x20x20, "cube_20x20x20"),
|
||||
std::pair<TestMesh, const char*>(TestMesh::sphere_50mm, "sphere_50mm"),
|
||||
std::pair<TestMesh, const char*>(TestMesh::bridge, "bridge"),
|
||||
std::pair<TestMesh, const char*>(TestMesh::bridge_with_hole, "bridge_with_hole"),
|
||||
@@ -46,9 +44,6 @@ TriangleMesh mesh(TestMesh m)
|
||||
{
|
||||
TriangleMesh mesh;
|
||||
switch(m) {
|
||||
case TestMesh::cube_20x20x20:
|
||||
mesh = Slic3r::make_cube(20, 20, 20);
|
||||
break;
|
||||
case TestMesh::sphere_50mm:
|
||||
mesh = Slic3r::make_sphere(50, PI / 243.0);
|
||||
break;
|
||||
@@ -187,30 +182,72 @@ TriangleMesh mesh(TestMesh m)
|
||||
return mesh;
|
||||
}
|
||||
|
||||
static bool verbose_gcode()
|
||||
Slic3r::Model model(const std::string &model_name, TriangleMesh &&_mesh)
|
||||
{
|
||||
const char *v = std::getenv("SLIC3R_TESTS_GCODE");
|
||||
if (v == nullptr)
|
||||
return false;
|
||||
std::string s(v);
|
||||
return s == "1" || s == "on" || s == "yes";
|
||||
Slic3r::Model result;
|
||||
ModelObject *object = result.add_object();
|
||||
object->name += model_name + ".stl";
|
||||
object->add_volume(_mesh);
|
||||
object->add_instance();
|
||||
return result;
|
||||
}
|
||||
|
||||
void init_print(std::vector<TriangleMesh> &&meshes, Slic3r::Print &print, Slic3r::Model &model, const DynamicPrintConfig &config_in, bool comments)
|
||||
DynamicPrintConfig multifilament_config(unsigned int filaments, std::initializer_list<Slic3r::ConfigBase::SetDeserializeItem> extra)
|
||||
{
|
||||
// Single nozzle, `filaments` filaments. Colours must be DISTINCT: filament grouping
|
||||
// treats same-colour filaments as one and the tool-order path then drops/segfaults.
|
||||
static const char *palette[] = { "#FF0000", "#00FF00", "#0000FF", "#FFFF00",
|
||||
"#FF00FF", "#00FFFF", "#FF8000", "#8000FF" };
|
||||
std::string diameters, colours, flush;
|
||||
for (unsigned int i = 0; i < filaments; ++i) {
|
||||
diameters += (i ? "," : "") + std::string("1.75");
|
||||
colours += (i ? ";" : "") + std::string(palette[i % (sizeof(palette) / sizeof(palette[0]))]);
|
||||
for (unsigned int j = 0; j < filaments; ++j)
|
||||
flush += ((i || j) ? "," : "") + std::string(i == j ? "0" : "280");
|
||||
}
|
||||
|
||||
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
||||
config.set_deserialize_strict({ { "nozzle_diameter", "0.4" }, { "filament_diameter", diameters } });
|
||||
config.set_num_filaments(filaments);
|
||||
|
||||
// These are read by filament id during export but absent from filament_option_keys(),
|
||||
// so set_num_filaments leaves them size 1; size them too, else out-of-range access.
|
||||
const auto &defaults = FullPrintConfig::defaults();
|
||||
for (const char *key : { "filament_type", "filament_vendor", "filament_start_gcode" })
|
||||
static_cast<ConfigOptionVectorBase *>(config.option(key, true))->resize(filaments, defaults.option(key));
|
||||
|
||||
// flush_volumes_matrix must be sized filaments*filaments or export rejects it.
|
||||
config.set_deserialize_strict({ { "filament_colour", colours }, { "flush_volumes_matrix", flush } });
|
||||
|
||||
if (extra.size() > 0)
|
||||
config.set_deserialize_strict(extra);
|
||||
return config;
|
||||
}
|
||||
|
||||
void init_print(std::vector<TriangleMesh> &&meshes, Slic3r::Print &print, Slic3r::Model &model, const DynamicPrintConfig &config_in,
|
||||
const std::vector<std::vector<ConfigBase::SetDeserializeItem>> *per_object_overrides, bool arrange)
|
||||
{
|
||||
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
||||
config.apply(config_in);
|
||||
config.set_key_value("gcode_comments", new ConfigOptionBool(true));
|
||||
|
||||
if (verbose_gcode())
|
||||
config.set_key_value("gcode_comments", new ConfigOptionBool(true));
|
||||
|
||||
size_t object_idx = 0;
|
||||
for (const TriangleMesh &t : meshes) {
|
||||
ModelObject *object = model.add_object();
|
||||
object->name += "object.stl";
|
||||
object->add_volume(std::move(t));
|
||||
object->add_instance();
|
||||
|
||||
if (per_object_overrides && object_idx < per_object_overrides->size() && !(*per_object_overrides)[object_idx].empty()) {
|
||||
DynamicPrintConfig oc;
|
||||
for (const auto &item : (*per_object_overrides)[object_idx])
|
||||
oc.set_deserialize_strict(item.opt_key, item.opt_value);
|
||||
object->config.apply(oc);
|
||||
}
|
||||
++object_idx;
|
||||
}
|
||||
arrange_objects(model, InfiniteBed{}, ArrangeParams{ scaled(min_object_distance(config))});
|
||||
if (arrange)
|
||||
arrange_objects(model, InfiniteBed{}, ArrangeParams{ scaled(min_object_distance(config))});
|
||||
for (ModelObject *mo : model.objects) {
|
||||
mo->ensure_on_bed();
|
||||
print.auto_assign_extruders(mo);
|
||||
@@ -221,63 +258,63 @@ void init_print(std::vector<TriangleMesh> &&meshes, Slic3r::Print &print, Slic3r
|
||||
print.set_status_silent();
|
||||
}
|
||||
|
||||
void init_print(std::initializer_list<TestMesh> test_meshes, Slic3r::Print &print, Slic3r::Model &model, const Slic3r::DynamicPrintConfig &config_in, bool comments)
|
||||
void init_print(std::initializer_list<TestMesh> test_meshes, Slic3r::Print &print, Slic3r::Model &model, const Slic3r::DynamicPrintConfig &config_in)
|
||||
{
|
||||
std::vector<TriangleMesh> triangle_meshes;
|
||||
triangle_meshes.reserve(test_meshes.size());
|
||||
for (const TestMesh test_mesh : test_meshes)
|
||||
triangle_meshes.emplace_back(mesh(test_mesh));
|
||||
init_print(std::move(triangle_meshes), print, model, config_in, comments);
|
||||
init_print(std::move(triangle_meshes), print, model, config_in);
|
||||
}
|
||||
|
||||
void init_print(std::initializer_list<TriangleMesh> input_meshes, Slic3r::Print &print, Slic3r::Model &model, const DynamicPrintConfig &config_in, bool comments)
|
||||
void init_print(std::initializer_list<TriangleMesh> input_meshes, Slic3r::Print &print, Slic3r::Model &model, const DynamicPrintConfig &config_in)
|
||||
{
|
||||
std::vector<TriangleMesh> triangle_meshes;
|
||||
triangle_meshes.reserve(input_meshes.size());
|
||||
for (const TriangleMesh &input_mesh : input_meshes)
|
||||
triangle_meshes.emplace_back(input_mesh);
|
||||
init_print(std::move(triangle_meshes), print, model, config_in, comments);
|
||||
init_print(std::move(triangle_meshes), print, model, config_in);
|
||||
}
|
||||
|
||||
void init_print(std::initializer_list<TestMesh> meshes, Slic3r::Print &print, Slic3r::Model &model, std::initializer_list<Slic3r::ConfigBase::SetDeserializeItem> config_items, bool comments)
|
||||
void init_print(std::initializer_list<TestMesh> meshes, Slic3r::Print &print, Slic3r::Model &model, std::initializer_list<Slic3r::ConfigBase::SetDeserializeItem> config_items)
|
||||
{
|
||||
Slic3r::DynamicPrintConfig config = Slic3r::DynamicPrintConfig::full_print_config();
|
||||
config.set_deserialize_strict(config_items);
|
||||
init_print(meshes, print, model, config, comments);
|
||||
init_print(meshes, print, model, config);
|
||||
}
|
||||
|
||||
void init_print(std::initializer_list<TriangleMesh> meshes, Slic3r::Print &print, Slic3r::Model &model, std::initializer_list<Slic3r::ConfigBase::SetDeserializeItem> config_items, bool comments)
|
||||
void init_print(std::initializer_list<TriangleMesh> meshes, Slic3r::Print &print, Slic3r::Model &model, std::initializer_list<Slic3r::ConfigBase::SetDeserializeItem> config_items)
|
||||
{
|
||||
Slic3r::DynamicPrintConfig config = Slic3r::DynamicPrintConfig::full_print_config();
|
||||
config.set_deserialize_strict(config_items);
|
||||
init_print(meshes, print, model, config, comments);
|
||||
init_print(meshes, print, model, config);
|
||||
}
|
||||
|
||||
void init_and_process_print(std::initializer_list<TestMesh> meshes, Slic3r::Print &print, const DynamicPrintConfig &config, bool comments)
|
||||
void init_and_process_print(std::initializer_list<TestMesh> meshes, Slic3r::Print &print, const DynamicPrintConfig &config)
|
||||
{
|
||||
Slic3r::Model model;
|
||||
init_print(meshes, print, model, config, comments);
|
||||
init_print(meshes, print, model, config);
|
||||
print.process();
|
||||
}
|
||||
|
||||
void init_and_process_print(std::initializer_list<TriangleMesh> meshes, Slic3r::Print &print, const DynamicPrintConfig &config, bool comments)
|
||||
void init_and_process_print(std::initializer_list<TriangleMesh> meshes, Slic3r::Print &print, const DynamicPrintConfig &config)
|
||||
{
|
||||
Slic3r::Model model;
|
||||
init_print(meshes, print, model, config, comments);
|
||||
init_print(meshes, print, model, config);
|
||||
print.process();
|
||||
}
|
||||
|
||||
void init_and_process_print(std::initializer_list<TestMesh> meshes, Slic3r::Print &print, std::initializer_list<Slic3r::ConfigBase::SetDeserializeItem> config_items, bool comments)
|
||||
void init_and_process_print(std::initializer_list<TestMesh> meshes, Slic3r::Print &print, std::initializer_list<Slic3r::ConfigBase::SetDeserializeItem> config_items)
|
||||
{
|
||||
Slic3r::Model model;
|
||||
init_print(meshes, print, model, config_items, comments);
|
||||
init_print(meshes, print, model, config_items);
|
||||
print.process();
|
||||
}
|
||||
|
||||
void init_and_process_print(std::initializer_list<TriangleMesh> meshes, Slic3r::Print &print, std::initializer_list<Slic3r::ConfigBase::SetDeserializeItem> config_items, bool comments)
|
||||
void init_and_process_print(std::initializer_list<TriangleMesh> meshes, Slic3r::Print &print, std::initializer_list<Slic3r::ConfigBase::SetDeserializeItem> config_items)
|
||||
{
|
||||
Slic3r::Model model;
|
||||
init_print(meshes, print, model, config_items, comments);
|
||||
init_print(meshes, print, model, config_items);
|
||||
print.process();
|
||||
}
|
||||
|
||||
@@ -292,6 +329,76 @@ std::string gcode(Print & print)
|
||||
return str;
|
||||
}
|
||||
|
||||
std::string slice(std::initializer_list<TestMesh> meshes, const DynamicPrintConfig &config)
|
||||
{
|
||||
Slic3r::Print print;
|
||||
Slic3r::Model model;
|
||||
init_print(meshes, print, model, config);
|
||||
return gcode(print);
|
||||
}
|
||||
|
||||
std::string slice(std::initializer_list<TriangleMesh> meshes, const DynamicPrintConfig &config)
|
||||
{
|
||||
Slic3r::Print print;
|
||||
Slic3r::Model model;
|
||||
init_print(meshes, print, model, config);
|
||||
return gcode(print);
|
||||
}
|
||||
|
||||
std::string slice(std::initializer_list<TestMesh> meshes, std::initializer_list<Slic3r::ConfigBase::SetDeserializeItem> config_items)
|
||||
{
|
||||
Slic3r::Print print;
|
||||
Slic3r::Model model;
|
||||
init_print(meshes, print, model, config_items);
|
||||
return gcode(print);
|
||||
}
|
||||
|
||||
std::string slice(std::initializer_list<TriangleMesh> meshes, std::initializer_list<Slic3r::ConfigBase::SetDeserializeItem> config_items)
|
||||
{
|
||||
Slic3r::Print print;
|
||||
Slic3r::Model model;
|
||||
init_print(meshes, print, model, config_items);
|
||||
return gcode(print);
|
||||
}
|
||||
|
||||
std::string slice_with_object_overrides(std::initializer_list<TriangleMesh> meshes, const DynamicPrintConfig &config,
|
||||
const std::vector<std::vector<ConfigBase::SetDeserializeItem>> &per_object_overrides)
|
||||
{
|
||||
Slic3r::Print print;
|
||||
Slic3r::Model model;
|
||||
init_print(std::vector<TriangleMesh>(meshes), print, model, config, &per_object_overrides);
|
||||
return gcode(print);
|
||||
}
|
||||
|
||||
std::string slice_two_cubes_arranged(std::initializer_list<Slic3r::ConfigBase::SetDeserializeItem> config_items)
|
||||
{
|
||||
return slice({ cube(20), cube(20) }, config_items);
|
||||
}
|
||||
|
||||
void place_two_cubes_apart(double gap, std::initializer_list<Slic3r::ConfigBase::SetDeserializeItem> config_items,
|
||||
Print &print, Model &model)
|
||||
{
|
||||
TriangleMesh a = cube(20);
|
||||
a.translate(80, 80, 0);
|
||||
TriangleMesh b = cube(20);
|
||||
b.translate(80 + 20 + gap, 80, 0);
|
||||
std::vector<TriangleMesh> meshes;
|
||||
meshes.push_back(std::move(a));
|
||||
meshes.push_back(std::move(b));
|
||||
|
||||
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
||||
config.set_deserialize_strict(config_items);
|
||||
init_print(std::move(meshes), print, model, config, nullptr, /*arrange=*/false);
|
||||
}
|
||||
|
||||
std::string slice_two_cubes_apart(double gap, std::initializer_list<Slic3r::ConfigBase::SetDeserializeItem> config_items)
|
||||
{
|
||||
Print print;
|
||||
Model model;
|
||||
place_two_cubes_apart(gap, config_items, print, model);
|
||||
return gcode(print);
|
||||
}
|
||||
|
||||
std::set<double> layers_with_role(const std::string &gcode, const std::string &role)
|
||||
{
|
||||
std::set<double> layers;
|
||||
@@ -313,59 +420,48 @@ double max_z(const std::string &gcode)
|
||||
return z;
|
||||
}
|
||||
|
||||
Slic3r::Model model(const std::string &model_name, TriangleMesh &&_mesh)
|
||||
int role_passes(const std::string &gcode, const std::string &role)
|
||||
{
|
||||
Slic3r::Model result;
|
||||
ModelObject *object = result.add_object();
|
||||
object->name += model_name + ".stl";
|
||||
object->add_volume(_mesh);
|
||||
object->add_instance();
|
||||
return result;
|
||||
int passes = 0;
|
||||
bool in_role = false;
|
||||
GCodeReader reader;
|
||||
reader.parse_buffer(gcode, [&](GCodeReader &self, const GCodeReader::GCodeLine &line) {
|
||||
if (! line.extruding(self)) return;
|
||||
const bool is_role = line.comment().find(role) != std::string_view::npos;
|
||||
if (is_role && ! in_role) ++passes;
|
||||
in_role = is_role;
|
||||
});
|
||||
return passes;
|
||||
}
|
||||
|
||||
std::string slice(std::initializer_list<TestMesh> meshes, const DynamicPrintConfig &config, bool comments)
|
||||
std::vector<std::string> role_sequence(const std::string &gcode, const std::vector<std::string> &roles)
|
||||
{
|
||||
Slic3r::Print print;
|
||||
Slic3r::Model model;
|
||||
init_print(meshes, print, model, config, comments);
|
||||
return gcode(print);
|
||||
}
|
||||
|
||||
std::string slice(std::initializer_list<TriangleMesh> meshes, const DynamicPrintConfig &config, bool comments)
|
||||
{
|
||||
Slic3r::Print print;
|
||||
Slic3r::Model model;
|
||||
init_print(meshes, print, model, config, comments);
|
||||
return gcode(print);
|
||||
}
|
||||
|
||||
std::string slice(std::initializer_list<TestMesh> meshes, std::initializer_list<Slic3r::ConfigBase::SetDeserializeItem> config_items, bool comments)
|
||||
{
|
||||
Slic3r::Print print;
|
||||
Slic3r::Model model;
|
||||
init_print(meshes, print, model, config_items, comments);
|
||||
return gcode(print);
|
||||
}
|
||||
|
||||
std::string slice(std::initializer_list<TriangleMesh> meshes, std::initializer_list<Slic3r::ConfigBase::SetDeserializeItem> config_items, bool comments)
|
||||
{
|
||||
Slic3r::Print print;
|
||||
Slic3r::Model model;
|
||||
init_print(meshes, print, model, config_items, comments);
|
||||
return gcode(print);
|
||||
std::vector<std::string> seq;
|
||||
std::string current;
|
||||
GCodeReader reader;
|
||||
reader.parse_buffer(gcode, [&](GCodeReader &self, const GCodeReader::GCodeLine &line) {
|
||||
if (! line.extruding(self)) return;
|
||||
const std::string_view comment = line.comment();
|
||||
for (const std::string &role : roles)
|
||||
if (comment.find(role) != std::string_view::npos) {
|
||||
if (current != role) { seq.push_back(role); current = role; }
|
||||
break;
|
||||
}
|
||||
});
|
||||
return seq;
|
||||
}
|
||||
|
||||
} } // namespace Slic3r::Test
|
||||
|
||||
#include <catch2/catch_all.hpp>
|
||||
|
||||
SCENARIO("init_print functionality", "[test_data]") {
|
||||
SCENARIO("init_print functionality", "[test_helpers]") {
|
||||
GIVEN("A default config") {
|
||||
Slic3r::DynamicPrintConfig config = Slic3r::DynamicPrintConfig::full_print_config();
|
||||
WHEN("init_print is called with a single mesh.") {
|
||||
Slic3r::Model model;
|
||||
Slic3r::Print print;
|
||||
Slic3r::Test::init_print({ Slic3r::Test::TestMesh::cube_20x20x20 }, print, model, config, true);
|
||||
Slic3r::Test::init_print({ Slic3r::Test::cube(20) }, print, model, config);
|
||||
THEN("One mesh/printobject is in the resulting Print object.") {
|
||||
REQUIRE(print.objects().size() == 1);
|
||||
}
|
||||
125
tests/fff_print/test_helpers.hpp
Normal file
125
tests/fff_print/test_helpers.hpp
Normal file
@@ -0,0 +1,125 @@
|
||||
#ifndef SLIC3R_TEST_HELPERS_HPP
|
||||
#define SLIC3R_TEST_HELPERS_HPP
|
||||
|
||||
#include "libslic3r/Config.hpp"
|
||||
#include "libslic3r/Geometry.hpp"
|
||||
#include "libslic3r/Model.hpp"
|
||||
#include "libslic3r/Point.hpp"
|
||||
#include "libslic3r/Print.hpp"
|
||||
#include "libslic3r/TriangleMesh.hpp"
|
||||
|
||||
#include <set>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
namespace Slic3r { namespace Test {
|
||||
|
||||
constexpr double MM_PER_MIN = 60.0;
|
||||
|
||||
// True when `a` and `b` are within EPSILON.
|
||||
template <typename T>
|
||||
bool _equiv(const T& a, const T& b) { return std::abs(a - b) < EPSILON; }
|
||||
|
||||
// True when `a` and `b` are within `epsilon`.
|
||||
template <typename T>
|
||||
bool _equiv(const T& a, const T& b, double epsilon) { return abs(a - b) < epsilon; }
|
||||
|
||||
// Named reusable test meshes, resolved by mesh().
|
||||
enum class TestMesh {
|
||||
A,
|
||||
L,
|
||||
V,
|
||||
_40x10,
|
||||
sphere_50mm,
|
||||
bridge,
|
||||
bridge_with_hole,
|
||||
cube_with_concave_hole,
|
||||
cube_with_hole,
|
||||
gt2_teeth,
|
||||
ipadstand,
|
||||
overhang,
|
||||
pyramid,
|
||||
sloping_hole,
|
||||
slopy_cube,
|
||||
small_dorito,
|
||||
step,
|
||||
two_hollow_squares
|
||||
};
|
||||
|
||||
// Hash for TestMesh (std::hash lacks scoped-enum support before C++17).
|
||||
struct TestMeshHash {
|
||||
std::size_t operator()(TestMesh tm) const {
|
||||
return static_cast<std::size_t>(tm);
|
||||
}
|
||||
};
|
||||
|
||||
// TestMesh value to name mapping.
|
||||
extern const std::unordered_map<TestMesh, const char*, TestMeshHash> mesh_names;
|
||||
|
||||
// Geometry for the named test fixture `m`, optionally translated and scaled.
|
||||
TriangleMesh mesh(TestMesh m);
|
||||
TriangleMesh mesh(TestMesh m, Vec3d translate, Vec3d scale = Vec3d(1.0, 1.0, 1.0));
|
||||
TriangleMesh mesh(TestMesh m, Vec3d translate, double scale = 1.0);
|
||||
|
||||
// An equal-sided cube, `size` mm on each edge.
|
||||
inline TriangleMesh cube(double size) { return make_cube(size, size, size); }
|
||||
|
||||
// A Model holding one object built from `mesh`.
|
||||
Slic3r::Model model(const std::string& model_name, TriangleMesh&& _mesh);
|
||||
|
||||
// Single-nozzle, `filaments`-filament config from defaults; `extra` is applied last.
|
||||
DynamicPrintConfig multifilament_config(unsigned int filaments,
|
||||
std::initializer_list<Slic3r::ConfigBase::SetDeserializeItem> extra = {});
|
||||
|
||||
// Apply `meshes` and config to `print`/`model`; optional per-object overrides, auto-arranged unless `arrange` is false.
|
||||
void init_print(std::vector<TriangleMesh> &&meshes, Slic3r::Print &print, Slic3r::Model &model, const DynamicPrintConfig &config_in,
|
||||
const std::vector<std::vector<Slic3r::ConfigBase::SetDeserializeItem>> *per_object_overrides = nullptr, bool arrange = true);
|
||||
void init_print(std::initializer_list<TestMesh> meshes, Slic3r::Print &print, Slic3r::Model &model, const Slic3r::DynamicPrintConfig &config_in = Slic3r::DynamicPrintConfig::full_print_config());
|
||||
void init_print(std::initializer_list<TriangleMesh> meshes, Slic3r::Print &print, Slic3r::Model &model, const Slic3r::DynamicPrintConfig &config_in = Slic3r::DynamicPrintConfig::full_print_config());
|
||||
void init_print(std::initializer_list<TestMesh> meshes, Slic3r::Print &print, Slic3r::Model &model, std::initializer_list<Slic3r::ConfigBase::SetDeserializeItem> config_items);
|
||||
void init_print(std::initializer_list<TriangleMesh> meshes, Slic3r::Print &print, Slic3r::Model &model, std::initializer_list<Slic3r::ConfigBase::SetDeserializeItem> config_items);
|
||||
|
||||
// init_print followed by process(), leaving a sliced `print` to inspect.
|
||||
void init_and_process_print(std::initializer_list<TestMesh> meshes, Slic3r::Print &print, const DynamicPrintConfig& config);
|
||||
void init_and_process_print(std::initializer_list<TriangleMesh> meshes, Slic3r::Print &print, const DynamicPrintConfig& config);
|
||||
void init_and_process_print(std::initializer_list<TestMesh> meshes, Slic3r::Print &print, std::initializer_list<Slic3r::ConfigBase::SetDeserializeItem> config_items);
|
||||
void init_and_process_print(std::initializer_list<TriangleMesh> meshes, Slic3r::Print &print, std::initializer_list<Slic3r::ConfigBase::SetDeserializeItem> config_items);
|
||||
|
||||
// Process `print` and return its exported G-code.
|
||||
std::string gcode(Print& print);
|
||||
|
||||
// Build, slice, and return the G-code for `meshes` under the given config.
|
||||
std::string slice(std::initializer_list<TestMesh> meshes, const DynamicPrintConfig &config);
|
||||
std::string slice(std::initializer_list<TriangleMesh> meshes, const DynamicPrintConfig &config);
|
||||
std::string slice(std::initializer_list<TestMesh> meshes, std::initializer_list<Slic3r::ConfigBase::SetDeserializeItem> config_items);
|
||||
std::string slice(std::initializer_list<TriangleMesh> meshes, std::initializer_list<Slic3r::ConfigBase::SetDeserializeItem> config_items);
|
||||
|
||||
// Slice `meshes`, applying per_object_overrides[i] to object i first (empty entry = none).
|
||||
std::string slice_with_object_overrides(std::initializer_list<TriangleMesh> meshes, const DynamicPrintConfig &config,
|
||||
const std::vector<std::vector<Slic3r::ConfigBase::SetDeserializeItem>> &per_object_overrides);
|
||||
|
||||
// Slice two auto-arranged 20mm cubes (the arranger positions them).
|
||||
std::string slice_two_cubes_arranged(std::initializer_list<Slic3r::ConfigBase::SetDeserializeItem> config_items);
|
||||
|
||||
// Place two 20mm cubes `gap` mm apart edge-to-edge, not auto-arranged (the caller controls spacing).
|
||||
void place_two_cubes_apart(double gap, std::initializer_list<Slic3r::ConfigBase::SetDeserializeItem> config_items,
|
||||
Slic3r::Print &print, Slic3r::Model &model);
|
||||
// Slice two 20mm cubes `gap` mm apart (not auto-arranged) and return the G-code.
|
||||
std::string slice_two_cubes_apart(double gap, std::initializer_list<Slic3r::ConfigBase::SetDeserializeItem> config_items);
|
||||
|
||||
// Distinct layer Z heights carrying an extrusion of the given `role` (e.g. "skirt").
|
||||
std::set<double> layers_with_role(const std::string &gcode, const std::string &role);
|
||||
|
||||
// Highest Z reached by any move in the G-code.
|
||||
double max_z(const std::string &gcode);
|
||||
|
||||
// Count of contiguous extrusion blocks of `role` (each uninterrupted run counts once).
|
||||
int role_passes(const std::string &gcode, const std::string &role);
|
||||
|
||||
// The `roles` in the order their extrusion blocks first appear, consecutive repeats collapsed.
|
||||
std::vector<std::string> role_sequence(const std::string &gcode, const std::vector<std::string> &roles);
|
||||
|
||||
} } // namespace Slic3r::Test
|
||||
|
||||
#endif // SLIC3R_TEST_HELPERS_HPP
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
#include <boost/filesystem.hpp>
|
||||
|
||||
#include "test_data.hpp"
|
||||
#include "test_helpers.hpp"
|
||||
#include "test_utils.hpp"
|
||||
|
||||
using namespace Slic3r;
|
||||
|
||||
106
tests/fff_print/test_multifilament.cpp
Normal file
106
tests/fff_print/test_multifilament.cpp
Normal file
@@ -0,0 +1,106 @@
|
||||
#include <catch2/catch_all.hpp>
|
||||
|
||||
#include "libslic3r/GCodeReader.hpp"
|
||||
|
||||
#include "test_helpers.hpp"
|
||||
|
||||
#include <cctype>
|
||||
#include <set>
|
||||
#include <string>
|
||||
|
||||
using namespace Slic3r;
|
||||
using namespace Slic3r::Test;
|
||||
|
||||
// 0-based tool indices used by extrusions whose role comment contains `role` (needs gcode_comments).
|
||||
static std::set<int> tools_for_role(const std::string& gcode, const std::string& role)
|
||||
{
|
||||
std::set<int> tools;
|
||||
int current_tool = 0;
|
||||
GCodeReader reader;
|
||||
reader.parse_buffer(gcode, [&](GCodeReader& self, const GCodeReader::GCodeLine& line) {
|
||||
const std::string cmd(line.cmd());
|
||||
if (cmd.size() >= 2 && cmd[0] == 'T' && std::isdigit((unsigned char)cmd[1]))
|
||||
current_tool = std::stoi(cmd.substr(1));
|
||||
else if (line.extruding(self) && std::string(line.comment()).find(role) != std::string::npos)
|
||||
tools.insert(current_tool);
|
||||
});
|
||||
return tools;
|
||||
}
|
||||
|
||||
// Tool index = filament id - 1; brim and skirt follow the wall filament.
|
||||
TEST_CASE("Each feature prints with its assigned filament", "[MultiFilament]")
|
||||
{
|
||||
auto [infill_filament, wall_filament] = GENERATE(table<int, int>({ {1, 1}, {1, 2}, {2, 1}, {2, 2} }));
|
||||
DYNAMIC_SECTION("infill filament " << infill_filament << ", wall filament " << wall_filament) {
|
||||
const std::string gcode = slice({ cube(20) },
|
||||
multifilament_config(2, {
|
||||
{ "sparse_infill_filament_id", infill_filament },
|
||||
{ "internal_solid_filament_id", infill_filament },
|
||||
{ "top_surface_filament_id", infill_filament },
|
||||
{ "bottom_surface_filament_id", infill_filament },
|
||||
{ "outer_wall_filament_id", wall_filament },
|
||||
{ "inner_wall_filament_id", wall_filament },
|
||||
{ "skirt_loops", 1 },
|
||||
{ "brim_type", "outer_only" },
|
||||
{ "brim_width", 5 },
|
||||
}));
|
||||
const std::set<int> wall_tool{ wall_filament - 1 };
|
||||
const std::set<int> infill_tool{ infill_filament - 1 };
|
||||
CHECK(tools_for_role(gcode, "perimeter") == wall_tool);
|
||||
CHECK(tools_for_role(gcode, "infill") == infill_tool); // sparse + solid + top/bottom
|
||||
CHECK(tools_for_role(gcode, "brim") == wall_tool);
|
||||
CHECK(tools_for_role(gcode, "skirt") == wall_tool);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("Each feature prints with its assigned filament (three filaments)", "[MultiFilament]")
|
||||
{
|
||||
const std::string gcode = slice({ cube(20) },
|
||||
multifilament_config(3, {
|
||||
{ "sparse_infill_filament_id", 2 },
|
||||
{ "internal_solid_filament_id", 2 },
|
||||
{ "top_surface_filament_id", 2 },
|
||||
{ "bottom_surface_filament_id", 2 },
|
||||
{ "outer_wall_filament_id", 3 },
|
||||
{ "inner_wall_filament_id", 3 },
|
||||
{ "skirt_loops", 0 },
|
||||
{ "brim_type", "no_brim" },
|
||||
}));
|
||||
CHECK(tools_for_role(gcode, "perimeter") == std::set<int>{ 2 }); // filament 3
|
||||
CHECK(tools_for_role(gcode, "infill") == std::set<int>{ 1 }); // filament 2
|
||||
}
|
||||
|
||||
// The override must survive tool ordering: object 1's walls print on their filament's
|
||||
// tool, object 0 stays on the first. If dropped, every wall prints on tool 0.
|
||||
TEST_CASE("Per-object wall filament override is honored", "[MultiFilament]")
|
||||
{
|
||||
const std::string gcode = slice_with_object_overrides(
|
||||
{ cube(20), cube(20) },
|
||||
multifilament_config(2, {
|
||||
{ "skirt_loops", 0 },
|
||||
{ "brim_type", "no_brim" },
|
||||
{ "print_sequence", "by object" },
|
||||
}),
|
||||
{ {}, { { "outer_wall_filament_id", 2 }, { "inner_wall_filament_id", 2 } } });
|
||||
CHECK(tools_for_role(gcode, "perimeter") == std::set<int>{ 0, 1 });
|
||||
CHECK(tools_for_role(gcode, "infill") == std::set<int>{ 0 }); // infill not overridden: stays on F1
|
||||
}
|
||||
|
||||
// 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;
|
||||
// the other per-extruder keys stay extruder-length so slicing reaches the code under test.
|
||||
TEST_CASE("Multi-extruder slice stays in bounds with a short max_layer_height", "[MultiFilament]")
|
||||
{
|
||||
DynamicPrintConfig config = multifilament_config(2);
|
||||
config.set_deserialize_strict({
|
||||
{ "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" },
|
||||
{ "max_layer_height", "0.3" }, // deliberately one entry short
|
||||
});
|
||||
Print print;
|
||||
init_and_process_print({ cube(20) }, print, config);
|
||||
REQUIRE_FALSE(print.objects().front()->layers().empty());
|
||||
}
|
||||
@@ -1,35 +1,32 @@
|
||||
#ifdef WIN32
|
||||
#ifndef WIN32_LEAN_AND_MEAN
|
||||
#define WIN32_LEAN_AND_MEAN
|
||||
#endif
|
||||
#ifndef NOMINMAX
|
||||
#define NOMINMAX
|
||||
#endif
|
||||
#include <Windows.h>
|
||||
#endif
|
||||
|
||||
#include <catch2/catch_all.hpp>
|
||||
|
||||
#include "libslic3r/libslic3r.h"
|
||||
#include "libslic3r/Print.hpp"
|
||||
#include "libslic3r/Layer.hpp"
|
||||
#include "libslic3r/Model.hpp"
|
||||
#include "libslic3r/GCodeReader.hpp"
|
||||
|
||||
#include "test_data.hpp"
|
||||
#include "test_helpers.hpp"
|
||||
#include "test_utils.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <fstream>
|
||||
#include <iterator>
|
||||
|
||||
using namespace Slic3r;
|
||||
using namespace Slic3r::Test;
|
||||
|
||||
SCENARIO("Print: Skirt generation", "[Print]") {
|
||||
GIVEN("20mm cube and default config") {
|
||||
WHEN("skirt_loops is set to 2") {
|
||||
Slic3r::Print print;
|
||||
Slic3r::Test::init_and_process_print({TestMesh::cube_20x20x20}, print, {
|
||||
{ "skirt_height", 1 },
|
||||
{ "skirt_distance", 1 },
|
||||
{ "skirt_loops", 2 }
|
||||
});
|
||||
THEN("Skirt Extrusion collection has 2 loops in it") {
|
||||
REQUIRE(print.skirt().items_count() == 2);
|
||||
REQUIRE(print.skirt().flatten().entities.size() == 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SCENARIO("Print: Changing number of solid shell layers does not cause all surfaces to become internal.", "[Print]") {
|
||||
SCENARIO("Changing the number of solid shell layers does not make all surfaces internal", "[Print]") {
|
||||
GIVEN("sliced 20mm cube and config with top_shell_layers = 2 and bottom_shell_layers = 1") {
|
||||
Slic3r::DynamicPrintConfig config = Slic3r::DynamicPrintConfig::full_print_config();
|
||||
config.set_deserialize_strict({
|
||||
@@ -40,7 +37,7 @@ SCENARIO("Print: Changing number of solid shell layers does not cause all surfac
|
||||
});
|
||||
Slic3r::Print print;
|
||||
Slic3r::Model model;
|
||||
Slic3r::Test::init_print({TestMesh::cube_20x20x20}, print, model, config);
|
||||
Slic3r::Test::init_print({cube(20)}, print, model, config);
|
||||
// Precondition: Ensure that the model has 2 solid top layers (79, 78)
|
||||
// and one solid bottom layer (0).
|
||||
auto test_is_solid_infill = [&print](size_t obj_id, size_t layer_id) {
|
||||
@@ -72,41 +69,6 @@ SCENARIO("Print: Changing number of solid shell layers does not cause all surfac
|
||||
}
|
||||
}
|
||||
|
||||
SCENARIO("Print: Brim generation", "[Print]") {
|
||||
GIVEN("20mm cube and default config, 1mm first layer width") {
|
||||
WHEN("Brim is set to 6mm") {
|
||||
Slic3r::Print print;
|
||||
Slic3r::Test::init_and_process_print({TestMesh::cube_20x20x20}, print, {
|
||||
{ "brim_type", "outer_only" },
|
||||
{ "initial_layer_line_width", 1 },
|
||||
{ "brim_width", 6 }
|
||||
});
|
||||
THEN("Brim Extrusion collection has 6 loops in it") {
|
||||
size_t total_items = 0;
|
||||
for (const auto& pair : print.get_brimMap()) {
|
||||
total_items += pair.second.items_count();
|
||||
}
|
||||
REQUIRE(total_items == 6);
|
||||
}
|
||||
}
|
||||
WHEN("Brim is set to 6mm, extrusion width 0.5mm") {
|
||||
Slic3r::Print print;
|
||||
Slic3r::Test::init_and_process_print({TestMesh::cube_20x20x20}, print, {
|
||||
{ "brim_type", "outer_only" },
|
||||
{ "brim_width", 6 },
|
||||
{ "initial_layer_line_width", 0.5 }
|
||||
});
|
||||
THEN("Brim Extrusion collection has 12 loops in it") {
|
||||
size_t total_items = 0;
|
||||
for (const auto& pair : print.get_brimMap()) {
|
||||
total_items += pair.second.items_count();
|
||||
}
|
||||
REQUIRE(total_items == 12);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Print::validate() warning collection
|
||||
//
|
||||
@@ -129,7 +91,7 @@ void build_cubes(Slic3r::Model& model, Slic3r::Print& print,
|
||||
|
||||
for (int i = 0; i < n; ++i) {
|
||||
ModelObject* object = model.add_object();
|
||||
object->add_volume(Slic3r::Test::mesh(TestMesh::cube_20x20x20));
|
||||
object->add_volume(cube(20));
|
||||
ModelInstance* inst = object->add_instance();
|
||||
inst->set_offset(Vec3d(overlap ? 0.0 : i * 60.0, 0.0, 0.0));
|
||||
}
|
||||
@@ -337,3 +299,144 @@ TEST_CASE("Print::validate tolerates a null warnings pointer", "[Print][validate
|
||||
StringObjectException err = print.validate(); // warnings == nullptr
|
||||
CHECK(err.string.empty());
|
||||
}
|
||||
|
||||
TEST_CASE("A default slice emits perimeter, infill, and skirt", "[Print]")
|
||||
{
|
||||
const std::string gcode = slice({ cube(20) }, {
|
||||
{ "layer_height", 0.2 },
|
||||
{ "initial_layer_print_height", 0.2 },
|
||||
{ "z_hop", 0 } // keep recorded Z at the printed height
|
||||
});
|
||||
CHECK(role_passes(gcode, "perimeter") > 0);
|
||||
CHECK(role_passes(gcode, "infill") > 0);
|
||||
CHECK(role_passes(gcode, "skirt") > 0);
|
||||
CHECK_THAT(max_z(gcode), Catch::Matchers::WithinAbs(20.0, 1e-4));
|
||||
}
|
||||
|
||||
// The G-code carries a config-comment block describing the resolved settings. The
|
||||
// per-region width lines are always present; the support and first-layer lines appear
|
||||
// only when those features are configured.
|
||||
TEST_CASE("G-code lists the resolved extrusion-width settings", "[Print]")
|
||||
{
|
||||
const std::string gcode = slice({ cube(20) }, { { "initial_layer_line_width", 0 } });
|
||||
CHECK(gcode.find("; external perimeters extrusion width") != std::string::npos);
|
||||
CHECK(gcode.find("; perimeters extrusion width") != std::string::npos);
|
||||
CHECK(gcode.find("; infill extrusion width") != std::string::npos);
|
||||
CHECK(gcode.find("; solid infill extrusion width") != std::string::npos);
|
||||
CHECK(gcode.find("; top infill extrusion width") != std::string::npos);
|
||||
CHECK(gcode.find("; support material extrusion width") == std::string::npos);
|
||||
CHECK(gcode.find("; first layer extrusion width") == std::string::npos);
|
||||
CHECK(gcode.find("; layer_height") != std::string::npos);
|
||||
CHECK(gcode.find("; sparse_infill_density") != std::string::npos);
|
||||
|
||||
const std::string with_support = slice({ cube(20) }, {
|
||||
{ "initial_layer_line_width", 0 }, { "enable_support", true }, { "raft_layers", 3 },
|
||||
});
|
||||
CHECK(with_support.find("; support material extrusion width") != std::string::npos);
|
||||
|
||||
const std::string with_first_layer = slice({ cube(20) }, { { "initial_layer_line_width", "0.5" } });
|
||||
CHECK(with_first_layer.find("; first layer extrusion width") != std::string::npos);
|
||||
}
|
||||
|
||||
// Custom G-code templates substitute placeholders during export.
|
||||
TEST_CASE("Custom G-code placeholders are substituted", "[Print]")
|
||||
{
|
||||
// [current_extruder] in the start G-code.
|
||||
CHECK(slice({ cube(20) }, { { "machine_start_gcode", "; Extruder [current_extruder]" } })
|
||||
.find("; Extruder 0") != std::string::npos);
|
||||
|
||||
// [layer_num] / [layer_z] in the end G-code (a 20mm cube at 0.1mm is 200 layers).
|
||||
const std::string end_gcode = slice({ cube(20) }, {
|
||||
{ "machine_end_gcode", "; Layer_num [layer_num]\n; Layer_z [layer_z]" },
|
||||
{ "layer_height", 0.1 },
|
||||
{ "initial_layer_print_height", 0.1 },
|
||||
});
|
||||
CHECK(end_gcode.find("; Layer_num 199") != std::string::npos);
|
||||
CHECK(end_gcode.find("; Layer_z 20") != std::string::npos);
|
||||
|
||||
// printing_by_object_gcode is emitted between sequentially printed objects.
|
||||
CHECK(slice_two_cubes_arranged({
|
||||
{ "print_sequence", "by object" },
|
||||
{ "printing_by_object_gcode", "; between-object-gcode" },
|
||||
})
|
||||
.find("; between-object-gcode") != std::string::npos);
|
||||
|
||||
// [layer_num] keeps counting across sequentially printed objects (199 then 399).
|
||||
const std::string per_layer = slice_two_cubes_arranged({
|
||||
{ "print_sequence", "by object" },
|
||||
{ "layer_change_gcode", ";Layer:[layer_num] ([layer_z] mm)" },
|
||||
{ "layer_height", 0.1 },
|
||||
{ "initial_layer_print_height", 0.1 },
|
||||
});
|
||||
CHECK(per_layer.find(";Layer:199 ") != std::string::npos);
|
||||
CHECK(per_layer.find(";Layer:399 ") != std::string::npos);
|
||||
}
|
||||
|
||||
TEST_CASE("export_gcode writes G-code without a result pointer", "[Print][export_gcode]")
|
||||
{
|
||||
Print print;
|
||||
Model model;
|
||||
Slic3r::Test::init_print({cube(20)}, print, model);
|
||||
print.process();
|
||||
|
||||
SECTION("non-BBL printer") {}
|
||||
SECTION("BBL printer") { print.is_BBL_printer() = true; }
|
||||
|
||||
ScopedTemporaryFile temp(".gcode");
|
||||
REQUIRE_NOTHROW(print.export_gcode(temp.string(), nullptr, nullptr));
|
||||
|
||||
std::ifstream in(temp.string());
|
||||
const std::string gcode((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>());
|
||||
|
||||
REQUIRE_FALSE(gcode.empty());
|
||||
}
|
||||
|
||||
TEST_CASE("Sequential printing follows model order", "[Print]")
|
||||
{
|
||||
// Two objects of different heights, taller one added first. Orca prints
|
||||
// sequential objects in model order, so the taller one is printed first.
|
||||
const std::string gcode = Slic3r::Test::slice({ cube(20), Slic3r::make_cube(20, 20, 10) }, {
|
||||
{ "print_sequence", "by object" },
|
||||
{ "layer_height", 0.2 },
|
||||
{ "initial_layer_print_height", 0.2 },
|
||||
{ "z_hop", 0 }
|
||||
});
|
||||
|
||||
// The first object's height is the peak Z reached before Z drops back to the
|
||||
// first layer (the object change). With by-object printing only an object
|
||||
// change returns Z to the bottom.
|
||||
double first_object_peak_z = 0.0;
|
||||
double running_peak = 0.0;
|
||||
GCodeReader reader;
|
||||
reader.parse_buffer(gcode, [&] (GCodeReader& self, const GCodeReader::GCodeLine& line) {
|
||||
if (first_object_peak_z != 0.0 || !line.extruding(self)) return; // ignore travels (e.g. start-gcode Z lift)
|
||||
if (running_peak > 1.0 && self.z() < 1.0)
|
||||
first_object_peak_z = running_peak;
|
||||
else
|
||||
running_peak = std::max(running_peak, static_cast<double>(self.z()));
|
||||
});
|
||||
|
||||
REQUIRE_THAT(first_object_peak_z, Catch::Matchers::WithinAbs(20.0, 0.3));
|
||||
}
|
||||
|
||||
// A sequential (by-object) print must publish the print-level nozzle group result just
|
||||
// like a by-layer print, so custom g-code can index the per-nozzle placeholder tables
|
||||
// (e.g. nozzle_diameter_at_nozzle_id[]) instead of failing on an empty vector.
|
||||
TEST_CASE("Sequential printing publishes the nozzle group result", "[Print][MultiNozzle]")
|
||||
{
|
||||
SECTION("process() publishes the result") {
|
||||
Print print;
|
||||
Model model;
|
||||
place_two_cubes_apart(60.0, { { "print_sequence", "by object" } }, print, model);
|
||||
print.process();
|
||||
REQUIRE(print.get_layered_nozzle_group_result() != nullptr);
|
||||
}
|
||||
|
||||
SECTION("start g-code can index the per-nozzle diameter table") {
|
||||
const std::string gcode = slice_two_cubes_arranged({
|
||||
{ "print_sequence", "by object" },
|
||||
{ "machine_start_gcode", "{if nozzle_diameter_at_nozzle_id[0] > 0}; SEQ-ND-OK\n{endif}" },
|
||||
});
|
||||
CHECK(gcode.find("; SEQ-ND-OK") != std::string::npos);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,629 +0,0 @@
|
||||
#ifdef WIN32
|
||||
#ifndef WIN32_LEAN_AND_MEAN
|
||||
#define WIN32_LEAN_AND_MEAN
|
||||
#endif
|
||||
#ifndef NOMINMAX
|
||||
#define NOMINMAX
|
||||
#endif
|
||||
#include <Windows.h>
|
||||
#endif
|
||||
|
||||
#include <catch2/catch_all.hpp>
|
||||
|
||||
#include "libslic3r/libslic3r.h"
|
||||
#include "libslic3r/GCodeReader.hpp"
|
||||
|
||||
#include "test_data.hpp"
|
||||
#include "test_utils.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <boost/regex.hpp>
|
||||
#include <libslic3r/ModelArrange.hpp>
|
||||
#include <boost/filesystem.hpp>
|
||||
#include <fstream>
|
||||
#include <iterator>
|
||||
#include <set>
|
||||
|
||||
using namespace Slic3r;
|
||||
using namespace Slic3r::Test;
|
||||
|
||||
boost::regex perimeters_regex("G1 X[-0-9.]* Y[-0-9.]* E[-0-9.]* ; perimeter");
|
||||
boost::regex infill_regex("G1 X[-0-9.]* Y[-0-9.]* E[-0-9.]* ; infill");
|
||||
boost::regex skirt_regex("G1 X[-0-9.]* Y[-0-9.]* E[-0-9.]* ; skirt");
|
||||
|
||||
SCENARIO( "PrintGCode basic functionality", "[PrintGCode]") {
|
||||
GIVEN("A default configuration and a print test object") {
|
||||
WHEN("the output is executed with no support material") {
|
||||
Slic3r::Print print;
|
||||
Slic3r::Model model;
|
||||
Slic3r::Test::init_print({TestMesh::cube_20x20x20}, print, model, {
|
||||
{ "layer_height", 0.2 },
|
||||
{ "initial_layer_print_height", 0.2 },
|
||||
{ "initial_layer_line_width", 0 },
|
||||
{ "gcode_comments", true },
|
||||
{ "machine_start_gcode", "" },
|
||||
{ "z_hop", 0 }
|
||||
});
|
||||
std::string gcode = Slic3r::Test::gcode(print);
|
||||
THEN("Some text output is generated.") {
|
||||
REQUIRE(gcode.size() > 0);
|
||||
}
|
||||
//THEN("Exported text contains git commit id") {
|
||||
// REQUIRE(gcode.find("; Git Commit") != std::string::npos);
|
||||
// REQUIRE(gcode.find(SLIC3R_BUILD_ID) != std::string::npos);
|
||||
//}
|
||||
THEN("Exported text contains extrusion statistics.") {
|
||||
REQUIRE(gcode.find("; external perimeters extrusion width") != std::string::npos);
|
||||
REQUIRE(gcode.find("; perimeters extrusion width") != std::string::npos);
|
||||
REQUIRE(gcode.find("; infill extrusion width") != std::string::npos);
|
||||
REQUIRE(gcode.find("; solid infill extrusion width") != std::string::npos);
|
||||
REQUIRE(gcode.find("; top infill extrusion width") != std::string::npos);
|
||||
REQUIRE(gcode.find("; support material extrusion width") == std::string::npos);
|
||||
REQUIRE(gcode.find("; first layer extrusion width") == std::string::npos);
|
||||
}
|
||||
THEN("Exported text does not contain cooling markers (they were consumed)") {
|
||||
REQUIRE(gcode.find(";_EXTRUDE_SET_SPEED") == std::string::npos);
|
||||
}
|
||||
|
||||
THEN("The config trailer includes print and region settings") {
|
||||
REQUIRE(gcode.find("; layer_height") != std::string::npos);
|
||||
REQUIRE(gcode.find("; sparse_infill_density") != std::string::npos);
|
||||
}
|
||||
THEN("Infill is emitted.") {
|
||||
boost::smatch has_match;
|
||||
REQUIRE(boost::regex_search(gcode, has_match, infill_regex));
|
||||
}
|
||||
THEN("Perimeters are emitted.") {
|
||||
boost::smatch has_match;
|
||||
REQUIRE(boost::regex_search(gcode, has_match, perimeters_regex));
|
||||
}
|
||||
THEN("Skirt is emitted.") {
|
||||
boost::smatch has_match;
|
||||
REQUIRE(boost::regex_search(gcode, has_match, skirt_regex));
|
||||
}
|
||||
THEN("final Z height is 20mm") {
|
||||
REQUIRE_THAT(max_z(gcode), Catch::Matchers::WithinAbs(20., 1e-4));
|
||||
}
|
||||
}
|
||||
WHEN("output is executed with two objects printed sequentially") {
|
||||
Slic3r::Print print;
|
||||
Slic3r::Model model;
|
||||
Slic3r::Test::init_print({TestMesh::cube_20x20x20,TestMesh::cube_20x20x20}, print, model, {
|
||||
{ "initial_layer_line_width", 0 },
|
||||
{ "initial_layer_print_height", 0.3 },
|
||||
{ "layer_height", 0.2 },
|
||||
{ "enable_support", false },
|
||||
{ "raft_layers", 0 },
|
||||
{ "print_sequence", "by object" },
|
||||
{ "gcode_comments", true },
|
||||
{ "printing_by_object_gcode", "; between-object-gcode" },
|
||||
{ "z_hop", 0 }
|
||||
});
|
||||
std::string gcode = Slic3r::Test::gcode(print);
|
||||
THEN("Some text output is generated.") {
|
||||
REQUIRE(gcode.size() > 0);
|
||||
}
|
||||
THEN("Infill is emitted.") {
|
||||
boost::smatch has_match;
|
||||
REQUIRE(boost::regex_search(gcode, has_match, infill_regex));
|
||||
}
|
||||
THEN("Perimeters are emitted.") {
|
||||
boost::smatch has_match;
|
||||
REQUIRE(boost::regex_search(gcode, has_match, perimeters_regex));
|
||||
}
|
||||
THEN("Skirt is emitted.") {
|
||||
boost::smatch has_match;
|
||||
REQUIRE(boost::regex_search(gcode, has_match, skirt_regex));
|
||||
}
|
||||
THEN("Between-object-gcode is emitted.") {
|
||||
REQUIRE(gcode.find("; between-object-gcode") != std::string::npos);
|
||||
}
|
||||
THEN("final Z height is 20.1mm") {
|
||||
REQUIRE_THAT(max_z(gcode), Catch::Matchers::WithinAbs(20.1, 1e-4));
|
||||
}
|
||||
THEN("Z height resets on object change") {
|
||||
double final_z = 0.0;
|
||||
bool reset = false;
|
||||
GCodeReader reader;
|
||||
reader.apply_config(print.config());
|
||||
reader.parse_buffer(gcode, [&final_z, &reset] (GCodeReader& self, const GCodeReader::GCodeLine& line) {
|
||||
if (final_z > 0 && std::abs(self.z() - 0.3) < 0.01 ) { // saw higher Z before this, now it's lower
|
||||
reset = true;
|
||||
} else {
|
||||
final_z = std::max(final_z, static_cast<double>(self.z())); // record the highest Z point we reach
|
||||
}
|
||||
});
|
||||
REQUIRE(reset == true);
|
||||
}
|
||||
}
|
||||
WHEN("the output is executed with support material") {
|
||||
std::string gcode = ::Test::slice({TestMesh::cube_20x20x20}, {
|
||||
{ "initial_layer_line_width", 0 },
|
||||
{ "enable_support", true },
|
||||
{ "raft_layers", 3 },
|
||||
{ "gcode_comments", true }
|
||||
});
|
||||
THEN("Some text output is generated.") {
|
||||
REQUIRE(gcode.size() > 0);
|
||||
}
|
||||
THEN("Exported text contains extrusion statistics.") {
|
||||
REQUIRE(gcode.find("; external perimeters extrusion width") != std::string::npos);
|
||||
REQUIRE(gcode.find("; perimeters extrusion width") != std::string::npos);
|
||||
REQUIRE(gcode.find("; infill extrusion width") != std::string::npos);
|
||||
REQUIRE(gcode.find("; solid infill extrusion width") != std::string::npos);
|
||||
REQUIRE(gcode.find("; top infill extrusion width") != std::string::npos);
|
||||
REQUIRE(gcode.find("; support material extrusion width") != std::string::npos);
|
||||
REQUIRE(gcode.find("; first layer extrusion width") == std::string::npos);
|
||||
}
|
||||
THEN("Raft is emitted.") {
|
||||
REQUIRE(gcode.find("; raft") != std::string::npos);
|
||||
}
|
||||
}
|
||||
WHEN("the output is executed with a separate first layer extrusion width") {
|
||||
std::string gcode = ::Test::slice({ TestMesh::cube_20x20x20 }, {
|
||||
{ "initial_layer_line_width", "0.5" }
|
||||
});
|
||||
THEN("Some text output is generated.") {
|
||||
REQUIRE(gcode.size() > 0);
|
||||
}
|
||||
THEN("Exported text contains extrusion statistics.") {
|
||||
REQUIRE(gcode.find("; external perimeters extrusion width") != std::string::npos);
|
||||
REQUIRE(gcode.find("; perimeters extrusion width") != std::string::npos);
|
||||
REQUIRE(gcode.find("; infill extrusion width") != std::string::npos);
|
||||
REQUIRE(gcode.find("; solid infill extrusion width") != std::string::npos);
|
||||
REQUIRE(gcode.find("; top infill extrusion width") != std::string::npos);
|
||||
REQUIRE(gcode.find("; support material extrusion width") == std::string::npos);
|
||||
REQUIRE(gcode.find("; first layer extrusion width") != std::string::npos);
|
||||
}
|
||||
}
|
||||
WHEN("Cooling is enabled and the fan is disabled.") {
|
||||
std::string gcode = ::Test::slice({ TestMesh::cube_20x20x20 }, {
|
||||
{ "cooling", true },
|
||||
{ "close_fan_the_first_x_layers", 5 }
|
||||
});
|
||||
THEN("GCode to disable fan is emitted."){
|
||||
REQUIRE(gcode.find("M106 S0") != std::string::npos);
|
||||
}
|
||||
}
|
||||
WHEN("end_gcode exists with layer_num and layer_z") {
|
||||
std::string gcode = ::Test::slice({ TestMesh::cube_20x20x20 }, {
|
||||
{ "machine_end_gcode", "; Layer_num [layer_num]\n; Layer_z [layer_z]" },
|
||||
{ "layer_height", 0.1 },
|
||||
{ "initial_layer_print_height", 0.1 }
|
||||
});
|
||||
THEN("layer_num and layer_z are processed in the end gcode") {
|
||||
REQUIRE(gcode.find("; Layer_num 199") != std::string::npos);
|
||||
REQUIRE(gcode.find("; Layer_z 20") != std::string::npos);
|
||||
}
|
||||
}
|
||||
WHEN("current_extruder exists in start_gcode") {
|
||||
std::string gcode = ::Test::slice({ TestMesh::cube_20x20x20 }, {
|
||||
{ "machine_start_gcode", "; Extruder [current_extruder]" }
|
||||
});
|
||||
THEN("current_extruder is processed in the start gcode and set for first extruder") {
|
||||
REQUIRE(gcode.find("; Extruder 0") != std::string::npos);
|
||||
}
|
||||
}
|
||||
|
||||
WHEN("layer_num represents the layer's index from z=0") {
|
||||
std::string gcode = ::Test::slice({ TestMesh::cube_20x20x20, TestMesh::cube_20x20x20 }, {
|
||||
{ "print_sequence", "by object" },
|
||||
{ "gcode_comments", true },
|
||||
{ "layer_change_gcode", ";Layer:[layer_num] ([layer_z] mm)" },
|
||||
{ "layer_height", 0.1 },
|
||||
{ "initial_layer_print_height", 0.1 }
|
||||
});
|
||||
// End of the 1st object.
|
||||
std::string token = ";Layer:199 ";
|
||||
size_t pos = gcode.find(token);
|
||||
THEN("First and second object last layer is emitted") {
|
||||
// First object
|
||||
REQUIRE(pos != std::string::npos);
|
||||
pos += token.size();
|
||||
REQUIRE(pos < gcode.size());
|
||||
double z = 0;
|
||||
REQUIRE((sscanf(gcode.data() + pos, "(%lf mm)", &z) == 1));
|
||||
REQUIRE_THAT(z, Catch::Matchers::WithinAbs(20., 1e-4));
|
||||
// Second object
|
||||
pos = gcode.find(";Layer:399 ", pos);
|
||||
REQUIRE(pos != std::string::npos);
|
||||
pos += token.size();
|
||||
REQUIRE(pos < gcode.size());
|
||||
REQUIRE((sscanf(gcode.data() + pos, "(%lf mm)", &z) == 1));
|
||||
REQUIRE_THAT(z, Catch::Matchers::WithinAbs(20., 1e-4));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("export_gcode writes G-code without a result pointer", "[PrintGCode][export_gcode]")
|
||||
{
|
||||
Print print;
|
||||
Model model;
|
||||
Slic3r::Test::init_print({TestMesh::cube_20x20x20}, print, model);
|
||||
print.process();
|
||||
|
||||
SECTION("non-BBL printer") {}
|
||||
SECTION("BBL printer") { print.is_BBL_printer() = true; }
|
||||
|
||||
ScopedTemporaryFile temp(".gcode");
|
||||
REQUIRE_NOTHROW(print.export_gcode(temp.string(), nullptr, nullptr));
|
||||
|
||||
std::ifstream in(temp.string());
|
||||
const std::string gcode((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>());
|
||||
|
||||
REQUIRE_FALSE(gcode.empty());
|
||||
}
|
||||
|
||||
TEST_CASE("Initial layer height is honored", "[PrintGCode]")
|
||||
{
|
||||
const std::string gcode = Slic3r::Test::slice({TestMesh::cube_20x20x20}, {
|
||||
{ "initial_layer_print_height", 0.3 },
|
||||
{ "layer_height", 0.2 },
|
||||
{ "z_hop", 0 } // keep recorded Z equal to the printed layer height
|
||||
});
|
||||
|
||||
std::set<double> layer_zs;
|
||||
GCodeReader reader;
|
||||
reader.parse_buffer(gcode, [&layer_zs] (GCodeReader& self, const GCodeReader::GCodeLine& line) {
|
||||
if (line.extruding(self) && line.dist_XY(self) > 0)
|
||||
layer_zs.insert(self.z());
|
||||
});
|
||||
|
||||
REQUIRE(layer_zs.size() > 1);
|
||||
REQUIRE_THAT(*layer_zs.begin(), Catch::Matchers::WithinAbs(0.3, 1e-4));
|
||||
REQUIRE_THAT(*std::next(layer_zs.begin()), Catch::Matchers::WithinAbs(0.5, 1e-4));
|
||||
}
|
||||
|
||||
TEST_CASE("Sequential printing follows model order", "[PrintGCode]")
|
||||
{
|
||||
// Two objects of different heights, taller one added first. Orca prints
|
||||
// sequential objects in model order, so the taller one is printed first.
|
||||
const std::string gcode = Slic3r::Test::slice({ Slic3r::make_cube(20, 20, 20), Slic3r::make_cube(20, 20, 10) }, {
|
||||
{ "print_sequence", "by object" },
|
||||
{ "layer_height", 0.2 },
|
||||
{ "initial_layer_print_height", 0.2 },
|
||||
{ "z_hop", 0 }
|
||||
});
|
||||
|
||||
// The first object's height is the peak Z reached before Z drops back to the
|
||||
// first layer (the object change). With by-object printing only an object
|
||||
// change returns Z to the bottom.
|
||||
double first_object_peak_z = 0.0;
|
||||
double running_peak = 0.0;
|
||||
GCodeReader reader;
|
||||
reader.parse_buffer(gcode, [&] (GCodeReader& self, const GCodeReader::GCodeLine& line) {
|
||||
if (first_object_peak_z != 0.0 || !line.extruding(self)) return; // ignore travels (e.g. start-gcode Z lift)
|
||||
if (running_peak > 1.0 && self.z() < 1.0)
|
||||
first_object_peak_z = running_peak;
|
||||
else
|
||||
running_peak = std::max(running_peak, static_cast<double>(self.z()));
|
||||
});
|
||||
|
||||
REQUIRE_THAT(first_object_peak_z, Catch::Matchers::WithinAbs(20.0, 0.3));
|
||||
}
|
||||
|
||||
// Verify that emit_machine_limits_to_gcode emits the correct max value across
|
||||
// used extruders (regression for commit b4ee665: "Emit max value of machine
|
||||
// limit among used extruders").
|
||||
TEST_CASE("Machine envelope emits max limit among used extruders", "[PrintGCode][MachineEnvelope]")
|
||||
{
|
||||
SECTION("Single extruder emits its configured values") {
|
||||
const std::string gcode = Slic3r::Test::slice({TestMesh::cube_20x20x20}, {
|
||||
{ "emit_machine_limits_to_gcode", "1" },
|
||||
{ "gcode_flavor", "marlin2" },
|
||||
{ "gcode_comments", "1" },
|
||||
{ "machine_start_gcode", "" },
|
||||
{ "layer_height", "0.2" },
|
||||
{ "initial_layer_print_height", "0.2" },
|
||||
{ "initial_layer_line_width", "0" },
|
||||
{ "z_hop", "0" },
|
||||
// stride-2 options: (normal, silent)
|
||||
{ "machine_max_acceleration_x", "500,600" },
|
||||
{ "machine_max_acceleration_y", "700,800" },
|
||||
{ "machine_max_acceleration_z", "100,200" },
|
||||
{ "machine_max_acceleration_e", "5000,6000" },
|
||||
{ "machine_max_acceleration_extruding", "1200,1300" },
|
||||
{ "machine_max_acceleration_retracting", "1400,1500" },
|
||||
{ "machine_max_acceleration_travel", "1600,1700" },
|
||||
// stride-2 options: (normal, silent)
|
||||
{ "machine_max_speed_x", "100,100" },
|
||||
{ "machine_max_speed_y", "110,110" },
|
||||
{ "machine_max_speed_z", "10,10" },
|
||||
{ "machine_max_speed_e", "50,50" },
|
||||
{ "machine_max_jerk_x", "8,8" },
|
||||
{ "machine_max_jerk_y", "9,9" },
|
||||
{ "machine_max_jerk_z", "0.4,0.4" },
|
||||
{ "machine_max_jerk_e", "5,5" },
|
||||
{ "machine_max_junction_deviation", "0.02,0.03" },
|
||||
});
|
||||
|
||||
THEN("M201 uses the normal acceleration values") {
|
||||
REQUIRE(gcode.find("M201 X500 Y700 Z100 E5000") != std::string::npos);
|
||||
}
|
||||
THEN("M203 uses the speed values") {
|
||||
REQUIRE(gcode.find("M203 X100 Y110 Z10 E50") != std::string::npos);
|
||||
}
|
||||
THEN("M204 (Marlin 2) uses extruding / retracting / travel") {
|
||||
REQUIRE(gcode.find("M204 P1200 R1400 T1600") != std::string::npos);
|
||||
}
|
||||
THEN("M205 uses the jerk values") {
|
||||
REQUIRE(gcode.find("M205 X8.00 Y9.00 Z0.40 E5.00") != std::string::npos);
|
||||
}
|
||||
THEN("M205 J uses the junction deviation") {
|
||||
REQUIRE(gcode.find("M205 J0.020") != std::string::npos);
|
||||
}
|
||||
}
|
||||
|
||||
SECTION("Legacy Marlin flavor emits correct format") {
|
||||
const std::string gcode = Slic3r::Test::slice({TestMesh::cube_20x20x20}, {
|
||||
{ "emit_machine_limits_to_gcode", "1" },
|
||||
{ "gcode_flavor", "marlin" },
|
||||
{ "gcode_comments", "1" },
|
||||
{ "machine_start_gcode", "" },
|
||||
{ "layer_height", "0.2" },
|
||||
{ "initial_layer_print_height", "0.2" },
|
||||
{ "initial_layer_line_width", "0" },
|
||||
{ "z_hop", "0" },
|
||||
// All machine limits must be provided — defaults are empty vectors.
|
||||
{ "machine_max_acceleration_x", "500,600" },
|
||||
{ "machine_max_acceleration_y", "500,600" },
|
||||
{ "machine_max_acceleration_z", "500,600" },
|
||||
{ "machine_max_acceleration_e", "5000,6000" },
|
||||
{ "machine_max_acceleration_extruding", "1200,1300" },
|
||||
{ "machine_max_acceleration_retracting", "1400,1500" },
|
||||
{ "machine_max_acceleration_travel", "1600,1700" },
|
||||
{ "machine_max_speed_x", "100,100" },
|
||||
{ "machine_max_speed_y", "110,110" },
|
||||
{ "machine_max_speed_z", "10,10" },
|
||||
{ "machine_max_speed_e", "50,50" },
|
||||
{ "machine_max_jerk_x", "8,8" },
|
||||
{ "machine_max_jerk_y", "9,9" },
|
||||
{ "machine_max_jerk_z", "0.4,0.4" },
|
||||
{ "machine_max_jerk_e", "5,5" },
|
||||
{ "machine_max_junction_deviation", "0.02,0.03" },
|
||||
});
|
||||
|
||||
THEN("Legacy Marlin: M204 travel_acc = extruding_acc") {
|
||||
// gcfMarlinLegacy uses extruding acc for travel too
|
||||
REQUIRE(gcode.find("M204 P1200 R1400 T1200") != std::string::npos);
|
||||
}
|
||||
THEN("Legacy Marlin: M205 uses mm/sec format") {
|
||||
REQUIRE(gcode.find("M205 X8.00 Y9.00 Z0.40 E5.00") != std::string::npos);
|
||||
}
|
||||
}
|
||||
|
||||
SECTION("Multi extruder - max of used extruders is emitted") {
|
||||
// Build config with 2 extruders that have *different* machine limits.
|
||||
// Extruder 1 has higher values; the emitted G-code must use the max.
|
||||
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
||||
|
||||
// Print basics
|
||||
config.set_key_value("emit_machine_limits_to_gcode", new ConfigOptionBool(true));
|
||||
config.set_key_value("gcode_flavor", new ConfigOptionEnum<GCodeFlavor>(gcfMarlinFirmware));
|
||||
config.set_key_value("gcode_comments", new ConfigOptionBool(true));
|
||||
config.set_key_value("machine_start_gcode", new ConfigOptionString(""));
|
||||
config.set_key_value("layer_height", new ConfigOptionFloat(0.2));
|
||||
config.set_key_value("initial_layer_print_height", new ConfigOptionFloat(0.2));
|
||||
config.set_key_value("initial_layer_line_width", new ConfigOptionFloatOrPercent(0, false));
|
||||
config.set_key_value("z_hop", new ConfigOptionFloats({0}));
|
||||
// Print objects sequentially so each uses its own extruder without
|
||||
// wipe-tower / tool-change complexity.
|
||||
config.set_key_value("print_sequence", new ConfigOptionEnum<PrintSequence>(PrintSequence::ByObject));
|
||||
|
||||
// 2 extruders
|
||||
config.set_key_value("nozzle_diameter", new ConfigOptionFloats({0.4, 0.4}));
|
||||
config.set_key_value("printer_extruder_id", new ConfigOptionInts({1, 2}));
|
||||
config.set_key_value("printer_extruder_variant", new ConfigOptionStrings({"Direct Drive Standard", "Direct Drive Standard"}));
|
||||
config.set_key_value("filament_diameter", new ConfigOptionFloats({1.75, 1.75}));
|
||||
config.set_key_value("filament_colour", new ConfigOptionStrings({"#FF0000", "#00FF00"}));
|
||||
config.set_key_value("filament_type", new ConfigOptionStrings({"PLA", "PLA"}));
|
||||
// filament_map maps filament slot index (1-based) → logical extruder ID (1-based).
|
||||
// Default [1] maps everything to extruder 0. Need [1, 2] for two distinct extruders.
|
||||
// fmmManual prevents auto-computation from overwriting the explicit mapping.
|
||||
config.option<ConfigOptionEnum<FilamentMapMode>>("filament_map_mode", true)->value = fmmManual;
|
||||
config.set_key_value("filament_map", new ConfigOptionInts({1, 2}));
|
||||
config.set_key_value("default_filament_colour", new ConfigOptionStrings({"#FF0000", "#00FF00"}));
|
||||
config.set_key_value("nozzle_temperature", new ConfigOptionInts({210, 210}));
|
||||
config.set_key_value("nozzle_temperature_range_low", new ConfigOptionInts({190, 190}));
|
||||
config.set_key_value("nozzle_temperature_range_high", new ConfigOptionInts({240, 240}));
|
||||
// flush_volumes_matrix must be filament_count^2 * heads_count entries.
|
||||
// 2 filaments * 2 * 1 head = 4 entries (all zero — flush volumes not tested here).
|
||||
config.set_key_value("flush_multiplier", new ConfigOptionFloats({1}));
|
||||
config.set_key_value("flush_volumes_matrix", new ConfigOptionFloats({0, 0, 0, 0}));
|
||||
|
||||
// Machine limits: extruder 0 low, extruder 1 high
|
||||
// Stride-2 (normal, silent pairs): e0_n, e0_s, e1_n, e1_s
|
||||
config.set_key_value("machine_max_acceleration_x", new ConfigOptionFloats({500, 0, 1000, 0}));
|
||||
config.set_key_value("machine_max_acceleration_y", new ConfigOptionFloats({700, 0, 1100, 0}));
|
||||
config.set_key_value("machine_max_acceleration_z", new ConfigOptionFloats({100, 0, 300, 0}));
|
||||
config.set_key_value("machine_max_acceleration_e", new ConfigOptionFloats({5000, 0, 8000, 0}));
|
||||
config.set_key_value("machine_max_acceleration_extruding", new ConfigOptionFloats({1200, 0, 2200, 0}));
|
||||
config.set_key_value("machine_max_acceleration_retracting", new ConfigOptionFloats({1400, 0, 2400, 0}));
|
||||
config.set_key_value("machine_max_acceleration_travel", new ConfigOptionFloats({1600, 0, 2600, 0}));
|
||||
config.set_key_value("machine_max_speed_x", new ConfigOptionFloats({100, 0, 200, 0}));
|
||||
config.set_key_value("machine_max_speed_y", new ConfigOptionFloats({110, 0, 210, 0}));
|
||||
config.set_key_value("machine_max_speed_z", new ConfigOptionFloats({10, 0, 30, 0}));
|
||||
config.set_key_value("machine_max_speed_e", new ConfigOptionFloats({50, 0, 80, 0}));
|
||||
config.set_key_value("machine_max_jerk_x", new ConfigOptionFloats({8, 0, 12, 0}));
|
||||
config.set_key_value("machine_max_jerk_y", new ConfigOptionFloats({9, 0, 13, 0}));
|
||||
config.set_key_value("machine_max_jerk_z", new ConfigOptionFloats({0.4, 0, 0.6, 0}));
|
||||
config.set_key_value("machine_max_jerk_e", new ConfigOptionFloats({5, 0, 10, 0}));
|
||||
config.set_key_value("machine_max_junction_deviation", new ConfigOptionFloats({0.02, 0, 0.05, 0}));
|
||||
|
||||
// Model: two objects assigned to different extruders
|
||||
Model model;
|
||||
auto* obj1 = model.add_object();
|
||||
obj1->add_volume(mesh(TestMesh::cube_20x20x20));
|
||||
obj1->add_instance();
|
||||
// obj1 uses default extruder=1 (0-based index 0)
|
||||
|
||||
auto* obj2 = model.add_object();
|
||||
obj2->add_volume(mesh(TestMesh::cube_20x20x20));
|
||||
obj2->add_instance();
|
||||
obj2->config.set_key_value("extruder", new ConfigOptionInt(2)); // 0-based index 1
|
||||
|
||||
Print print;
|
||||
arrange_objects(model, InfiniteBed{},
|
||||
ArrangeParams{scaled(min_object_distance(config))});
|
||||
for (auto* mo : model.objects) {
|
||||
mo->ensure_on_bed();
|
||||
print.auto_assign_extruders(mo);
|
||||
}
|
||||
|
||||
print.apply(model, config);
|
||||
print.validate();
|
||||
print.set_status_silent();
|
||||
print.process();
|
||||
|
||||
std::string gcode = Slic3r::Test::gcode(print);
|
||||
|
||||
THEN("M201 contains max (extruder 1's) acceleration values") {
|
||||
REQUIRE(gcode.find("M201 X1000 Y1100 Z300 E8000") != std::string::npos);
|
||||
}
|
||||
THEN("M203 contains max speed values") {
|
||||
REQUIRE(gcode.find("M203 X200 Y210 Z30 E80") != std::string::npos);
|
||||
}
|
||||
THEN("M204 contains max extruding / retracting / travel") {
|
||||
REQUIRE(gcode.find("M204 P2200 R2400 T2600") != std::string::npos);
|
||||
}
|
||||
THEN("M205 contains max jerk values") {
|
||||
REQUIRE(gcode.find("M205 X12.00 Y13.00 Z0.60 E10.00") != std::string::npos);
|
||||
}
|
||||
THEN("M205 contains max m_max_junction_deviation ") {
|
||||
REQUIRE(gcode.find("M205 J0.050") != std::string::npos);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Verify that the EXTRUDER_LIMIT macro (GCodeWriter.cpp) correctly:
|
||||
// 1) Uses the active extruder's specific limit when filament() is known.
|
||||
// 2) Falls back to the maximum of all extruder limits when filament() is nullptr.
|
||||
//
|
||||
// These two behaviours were introduced in:
|
||||
// - "Use per-extruder motion limit" (1ab34a7454)
|
||||
// - "Use max limit when current extruder is unknown" (b7240ab1c6)
|
||||
TEST_CASE("EXTRUDER_LIMIT per-extruder clamping and max fallback", "[PrintGCode][MachineEnvelope]")
|
||||
{
|
||||
// --- Build config with 2 extruders that have different machine limits ---
|
||||
// Extruder 0: low limits
|
||||
// Extruder 1: high limits
|
||||
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
||||
|
||||
config.set_key_value("emit_machine_limits_to_gcode", new ConfigOptionBool(true));
|
||||
config.set_key_value("gcode_flavor", new ConfigOptionEnum<GCodeFlavor>(gcfMarlinFirmware));
|
||||
config.set_key_value("gcode_comments", new ConfigOptionBool(true));
|
||||
config.set_key_value("machine_start_gcode", new ConfigOptionString(""));
|
||||
config.set_key_value("layer_height", new ConfigOptionFloat(0.2));
|
||||
config.set_key_value("initial_layer_print_height", new ConfigOptionFloat(0.2));
|
||||
config.set_key_value("initial_layer_line_width", new ConfigOptionFloatOrPercent(0, false));
|
||||
config.set_key_value("z_hop", new ConfigOptionFloats({0}));
|
||||
config.set_key_value("print_sequence", new ConfigOptionEnum<PrintSequence>(PrintSequence::ByObject));
|
||||
|
||||
// 2 extruders, 2 filaments
|
||||
config.set_key_value("nozzle_diameter", new ConfigOptionFloats({0.4, 0.4}));
|
||||
config.set_key_value("printer_extruder_id", new ConfigOptionInts({1, 2}));
|
||||
config.set_key_value("printer_extruder_variant", new ConfigOptionStrings({"Direct Drive Standard", "Direct Drive Standard"}));
|
||||
config.set_key_value("filament_diameter", new ConfigOptionFloats({1.75, 1.75}));
|
||||
config.set_key_value("filament_colour", new ConfigOptionStrings({"#FF0000", "#00FF00"}));
|
||||
config.set_key_value("filament_type", new ConfigOptionStrings({"PLA", "PLA"}));
|
||||
config.option<ConfigOptionEnum<FilamentMapMode>>("filament_map_mode", true)->value = fmmManual;
|
||||
config.set_key_value("filament_map", new ConfigOptionInts({1, 2}));
|
||||
config.set_key_value("default_filament_colour", new ConfigOptionStrings({"#FF0000", "#00FF00"}));
|
||||
config.set_key_value("nozzle_temperature", new ConfigOptionInts({210, 210}));
|
||||
config.set_key_value("nozzle_temperature_range_low", new ConfigOptionInts({190, 190}));
|
||||
config.set_key_value("nozzle_temperature_range_high", new ConfigOptionInts({240, 240}));
|
||||
config.set_key_value("flush_multiplier", new ConfigOptionFloats({1}));
|
||||
config.set_key_value("flush_volumes_matrix", new ConfigOptionFloats({0, 0, 0, 0}));
|
||||
|
||||
// --- Machine limits (stride-2: e0_n, e0_s, e1_n, e1_s) ---
|
||||
// Extruder 0 has LOW limits, Extruder 1 has HIGH limits.
|
||||
config.set_key_value("machine_max_acceleration_x", new ConfigOptionFloats({500, 0, 1000, 0}));
|
||||
config.set_key_value("machine_max_acceleration_y", new ConfigOptionFloats({500, 0, 1000, 0}));
|
||||
config.set_key_value("machine_max_acceleration_z", new ConfigOptionFloats({100, 0, 200, 0}));
|
||||
config.set_key_value("machine_max_acceleration_e", new ConfigOptionFloats({5000, 0, 5000, 0}));
|
||||
config.set_key_value("machine_max_acceleration_extruding", new ConfigOptionFloats({500, 0, 2000, 0}));
|
||||
config.set_key_value("machine_max_acceleration_retracting", new ConfigOptionFloats({600, 0, 2000, 0}));
|
||||
config.set_key_value("machine_max_acceleration_travel", new ConfigOptionFloats({700, 0, 2500, 0}));
|
||||
config.set_key_value("machine_max_speed_x", new ConfigOptionFloats({100, 0, 200, 0}));
|
||||
config.set_key_value("machine_max_speed_y", new ConfigOptionFloats({110, 0, 210, 0}));
|
||||
config.set_key_value("machine_max_speed_z", new ConfigOptionFloats({10, 0, 30, 0}));
|
||||
config.set_key_value("machine_max_speed_e", new ConfigOptionFloats({50, 0, 80, 0}));
|
||||
config.set_key_value("machine_max_jerk_x", new ConfigOptionFloats({5, 0, 15, 0}));
|
||||
config.set_key_value("machine_max_jerk_y", new ConfigOptionFloats({6, 0, 16, 0}));
|
||||
config.set_key_value("machine_max_jerk_z", new ConfigOptionFloats({0.4, 0, 0.8, 0}));
|
||||
config.set_key_value("machine_max_jerk_e", new ConfigOptionFloats({3, 0, 8, 0}));
|
||||
config.set_key_value("machine_max_junction_deviation", new ConfigOptionFloats({0.02, 0, 0.08, 0}));
|
||||
|
||||
// --- Print acceleration: 1500 mm/s² ---
|
||||
// Exceeds extruder 0's limit (500) → should be clamped to 500.
|
||||
// Does NOT exceed extruder 1's limit (2000) → passes through as 1500.
|
||||
config.set_key_value("default_acceleration", new ConfigOptionFloats({1500, 1500}));
|
||||
config.set_key_value("outer_wall_acceleration", new ConfigOptionFloats({1500, 1500}));
|
||||
config.set_key_value("inner_wall_acceleration", new ConfigOptionFloats({1500, 1500}));
|
||||
config.set_key_value("top_surface_acceleration", new ConfigOptionFloats({1500, 1500}));
|
||||
config.set_key_value("initial_layer_acceleration", new ConfigOptionFloats({1500, 1500}));
|
||||
config.set_key_value("travel_acceleration", new ConfigOptionFloats({1500, 1500}));
|
||||
|
||||
// Model: two objects assigned to different extruders
|
||||
Model model;
|
||||
auto* obj1 = model.add_object();
|
||||
obj1->add_volume(mesh(TestMesh::cube_20x20x20));
|
||||
obj1->add_instance();
|
||||
|
||||
auto* obj2 = model.add_object();
|
||||
obj2->add_volume(mesh(TestMesh::cube_20x20x20));
|
||||
obj2->add_instance();
|
||||
obj2->config.set_key_value("extruder", new ConfigOptionInt(2)); // 0-based index 1
|
||||
|
||||
Print print;
|
||||
arrange_objects(model, InfiniteBed{}, ArrangeParams{scaled(min_object_distance(config))});
|
||||
for (auto* mo : model.objects) {
|
||||
mo->ensure_on_bed();
|
||||
print.auto_assign_extruders(mo);
|
||||
}
|
||||
|
||||
print.apply(model, config);
|
||||
print.validate();
|
||||
print.set_status_silent();
|
||||
print.process();
|
||||
|
||||
std::string gcode = Slic3r::Test::gcode(print);
|
||||
|
||||
SECTION("Preamble: max limit among used extruders") {
|
||||
THEN("M201 uses max (extruder 1's) acceleration values") {
|
||||
REQUIRE(gcode.find("M201 X1000 Y1000 Z200 E5000") != std::string::npos);
|
||||
}
|
||||
THEN("M204 uses max extruding/retracting/travel") {
|
||||
REQUIRE(gcode.find("M204 P2000 R2000 T2500") != std::string::npos);
|
||||
}
|
||||
THEN("M205 uses max jerk values") {
|
||||
REQUIRE(gcode.find("M205 X15.00 Y16.00 Z0.80 E8.00") != std::string::npos);
|
||||
}
|
||||
}
|
||||
|
||||
SECTION("Preamble: EXTRUDER_LIMIT falls back to max when no filament is active") {
|
||||
// set_junction_deviation() is called during preamble with no active filament.
|
||||
// EXTRUDER_LIMIT(m_max_junction_deviation) → filament() == nullptr → max of all (0.08).
|
||||
THEN("M205 J uses max junction deviation") {
|
||||
REQUIRE(gcode.find("M205 J0.080") != std::string::npos);
|
||||
}
|
||||
}
|
||||
|
||||
SECTION("Print: extruder 0 acceleration clamped to its specific limit") {
|
||||
// Extruder 0 machine limit = 500. Print accel = 1500 > 500 → clamped to 500.
|
||||
THEN("M204 P500 appears (extruder 0 clamped)") {
|
||||
REQUIRE(gcode.find("M204 P500") != std::string::npos);
|
||||
}
|
||||
THEN("M204 T700 appears (extruder 0 travel clamped)") {
|
||||
REQUIRE(gcode.find("M204 T700") != std::string::npos);
|
||||
}
|
||||
}
|
||||
|
||||
SECTION("Print: extruder 1 acceleration NOT clamped to extruder 0's limit") {
|
||||
// Extruder 1 machine limit = 2000. Print accel = 1500 < 2000 → not clamped.
|
||||
THEN("M204 P1500 appears (extruder 1 not clamped to 500)") {
|
||||
REQUIRE(gcode.find("M204 P1500") != std::string::npos);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,17 +3,21 @@
|
||||
#include "libslic3r/libslic3r.h"
|
||||
#include "libslic3r/Print.hpp"
|
||||
#include "libslic3r/Layer.hpp"
|
||||
#include "libslic3r/GCodeReader.hpp"
|
||||
|
||||
#include "test_data.hpp"
|
||||
#include "test_helpers.hpp"
|
||||
|
||||
#include <iterator>
|
||||
#include <set>
|
||||
|
||||
using namespace Slic3r;
|
||||
using namespace Slic3r::Test;
|
||||
|
||||
SCENARIO("PrintObject: object layer heights", "[PrintObject]") {
|
||||
SCENARIO("Object layer heights", "[PrintObject]") {
|
||||
GIVEN("A 20mm cube") {
|
||||
WHEN("sliced with a 2mm layer height and a 3mm nozzle") {
|
||||
Slic3r::Print print;
|
||||
Slic3r::Test::init_and_process_print({TestMesh::cube_20x20x20}, print, {
|
||||
Slic3r::Test::init_and_process_print({cube(20)}, print, {
|
||||
{ "initial_layer_print_height", 2 },
|
||||
{ "layer_height", 2 },
|
||||
{ "nozzle_diameter", 3 }
|
||||
@@ -32,7 +36,7 @@ SCENARIO("PrintObject: object layer heights", "[PrintObject]") {
|
||||
}
|
||||
WHEN("sliced with a 10mm layer height and an 11mm nozzle") {
|
||||
Slic3r::Print print;
|
||||
Slic3r::Test::init_and_process_print({TestMesh::cube_20x20x20}, print, {
|
||||
Slic3r::Test::init_and_process_print({cube(20)}, print, {
|
||||
{ "initial_layer_print_height", 2 },
|
||||
{ "layer_height", 10 },
|
||||
{ "nozzle_diameter", 11 }
|
||||
@@ -50,7 +54,7 @@ SCENARIO("PrintObject: object layer heights", "[PrintObject]") {
|
||||
}
|
||||
WHEN("sliced with a 15mm layer height and a 16mm nozzle") {
|
||||
Slic3r::Print print;
|
||||
Slic3r::Test::init_and_process_print({TestMesh::cube_20x20x20}, print, {
|
||||
Slic3r::Test::init_and_process_print({cube(20)}, print, {
|
||||
{ "initial_layer_print_height", 2 },
|
||||
{ "layer_height", 15 },
|
||||
{ "nozzle_diameter", 16 }
|
||||
@@ -71,7 +75,7 @@ SCENARIO("PrintObject: object layer heights", "[PrintObject]") {
|
||||
// rejects the slice during flow computation. Pin that behavior.
|
||||
THEN("Slicing is rejected") {
|
||||
Slic3r::Print print;
|
||||
REQUIRE_THROWS(Slic3r::Test::init_and_process_print({TestMesh::cube_20x20x20}, print, {
|
||||
REQUIRE_THROWS(Slic3r::Test::init_and_process_print({cube(20)}, print, {
|
||||
{ "initial_layer_print_height", 0.3 },
|
||||
{ "layer_height", 0.5 },
|
||||
{ "nozzle_diameter", 0.4 }
|
||||
@@ -81,11 +85,11 @@ SCENARIO("PrintObject: object layer heights", "[PrintObject]") {
|
||||
}
|
||||
}
|
||||
|
||||
SCENARIO("PrintObject: Perimeter generation", "[PrintObject]") {
|
||||
SCENARIO("Perimeter generation", "[PrintObject]") {
|
||||
GIVEN("20mm cube and default config") {
|
||||
WHEN("make_perimeters() is called") {
|
||||
Slic3r::Print print;
|
||||
Slic3r::Test::init_and_process_print({TestMesh::cube_20x20x20}, print, { { "sparse_infill_density", 0 } });
|
||||
Slic3r::Test::init_and_process_print({cube(20)}, print, { { "sparse_infill_density", 0 } });
|
||||
const PrintObject &object = *print.objects().front();
|
||||
THEN("Every layer in region 0 has 1 island of perimeters") {
|
||||
for (const Layer *layer : object.layers())
|
||||
@@ -94,7 +98,7 @@ SCENARIO("PrintObject: Perimeter generation", "[PrintObject]") {
|
||||
}
|
||||
WHEN("wall_loops is set to 3") {
|
||||
Slic3r::Print print;
|
||||
Slic3r::Test::init_and_process_print({TestMesh::cube_20x20x20}, print, {
|
||||
Slic3r::Test::init_and_process_print({cube(20)}, print, {
|
||||
{ "sparse_infill_density", 0 },
|
||||
{ "wall_loops", 3 }
|
||||
});
|
||||
@@ -106,3 +110,23 @@ SCENARIO("PrintObject: Perimeter generation", "[PrintObject]") {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("Initial layer height is honored", "[PrintObject]")
|
||||
{
|
||||
const std::string gcode = Slic3r::Test::slice({cube(20)}, {
|
||||
{ "initial_layer_print_height", 0.3 },
|
||||
{ "layer_height", 0.2 },
|
||||
{ "z_hop", 0 } // keep recorded Z equal to the printed layer height
|
||||
});
|
||||
|
||||
std::set<double> layer_zs;
|
||||
GCodeReader reader;
|
||||
reader.parse_buffer(gcode, [&layer_zs] (GCodeReader& self, const GCodeReader::GCodeLine& line) {
|
||||
if (line.extruding(self) && line.dist_XY(self) > 0)
|
||||
layer_zs.insert(self.z());
|
||||
});
|
||||
|
||||
REQUIRE(layer_zs.size() > 1);
|
||||
REQUIRE_THAT(*layer_zs.begin(), Catch::Matchers::WithinAbs(0.3, 1e-4));
|
||||
REQUIRE_THAT(*std::next(layer_zs.begin()), Catch::Matchers::WithinAbs(0.5, 1e-4));
|
||||
}
|
||||
|
||||
@@ -9,190 +9,319 @@
|
||||
|
||||
#include <cmath>
|
||||
|
||||
#include "test_data.hpp" // get access to init_print, etc
|
||||
#include "test_helpers.hpp" // get access to init_print, etc
|
||||
|
||||
using namespace Slic3r::Test;
|
||||
using namespace Slic3r;
|
||||
|
||||
/// Helper method to find the tool used for the brim (always the first extrusion).
|
||||
[[maybe_unused]] static int get_brim_tool(const std::string &gcode)
|
||||
// Distinct brim regions (combine_brims merges touching brims into one covering >1 object).
|
||||
static int brim_count(const Print &print)
|
||||
{
|
||||
int brim_tool = -1;
|
||||
int tool = -1;
|
||||
GCodeReader parser;
|
||||
parser.parse_buffer(gcode, [&tool, &brim_tool] (Slic3r::GCodeReader &self, const Slic3r::GCodeReader::GCodeLine &line)
|
||||
{
|
||||
// if the command is a T command, set the current tool
|
||||
if (boost::starts_with(line.cmd(), "T")) {
|
||||
tool = atoi(line.cmd().data() + 1);
|
||||
} else if (line.cmd() == "G1" && line.extruding(self) && line.dist_XY(self) > 0 && brim_tool < 0) {
|
||||
brim_tool = tool;
|
||||
int n = 0;
|
||||
for (const auto &group : print.skirt_brim_groups())
|
||||
n += (int) group.brims.size();
|
||||
return n;
|
||||
}
|
||||
|
||||
// Total brim loops across all objects.
|
||||
static size_t brim_loop_count(Print &print)
|
||||
{
|
||||
size_t n = 0;
|
||||
for (const auto &kv : print.get_brimMap())
|
||||
n += kv.second.items_count();
|
||||
return n;
|
||||
}
|
||||
|
||||
// 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]")
|
||||
{
|
||||
const int object_layers = 100; // 20mm cube at 0.2mm layers
|
||||
const char *skirt_type = GENERATE("combined", "perobject");
|
||||
const char *print_seq = GENERATE("by layer", "by object");
|
||||
const char *draft_shield = GENERATE("disabled", "enabled");
|
||||
const int skirt_height = GENERATE(0, 1, 3);
|
||||
|
||||
DYNAMIC_SECTION(skirt_type << " | " << print_seq << " | draft=" << draft_shield << " | height=" << skirt_height) {
|
||||
auto do_slice = [&] {
|
||||
return slice_two_cubes_arranged({
|
||||
{ "skirt_loops", 1 },
|
||||
{ "skirt_height", skirt_height },
|
||||
{ "skirt_distance", 3 },
|
||||
{ "skirt_type", skirt_type },
|
||||
{ "draft_shield", draft_shield },
|
||||
{ "print_sequence", print_seq },
|
||||
{ "layer_height", 0.2 },
|
||||
});
|
||||
};
|
||||
const bool draft = std::string(draft_shield) == "enabled";
|
||||
const bool has_skirt = draft || skirt_height > 0;
|
||||
const bool unsafe_by_object = std::string(skirt_type) == "perobject"
|
||||
&& std::string(print_seq) == "by object" && has_skirt;
|
||||
|
||||
if (unsafe_by_object) {
|
||||
REQUIRE_THROWS(do_slice());
|
||||
} else {
|
||||
const int expected_layers = draft ? object_layers : skirt_height;
|
||||
CHECK(role_passes(do_slice(), "skirt") == expected_layers);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Each per-object skirt prints right before its own object, so distant objects yield two
|
||||
// non-contiguous skirt passes; close objects group into a single skirt.
|
||||
TEST_CASE("Per-object skirts group when objects are close", "[SkirtBrim]")
|
||||
{
|
||||
auto [gap, expected_skirts] = GENERATE(table<double, int>({ { 5.0, 1 }, { 60.0, 2 } }));
|
||||
DYNAMIC_SECTION("gap=" << gap) {
|
||||
const std::string gcode = slice_two_cubes_apart(gap, {
|
||||
{ "skirt_loops", 1 },
|
||||
{ "skirt_height", 1 },
|
||||
{ "skirt_distance", 3 },
|
||||
{ "skirt_type", "perobject" },
|
||||
{ "print_sequence", "by layer" },
|
||||
{ "layer_height", 0.2 },
|
||||
});
|
||||
CHECK(role_passes(gcode, "skirt") == expected_skirts);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("Combine brims merges touching brims", "[SkirtBrim]")
|
||||
{
|
||||
auto [gap, combine, expected_brims] = GENERATE(table<double, int, int>({
|
||||
{ 5.0, 1, 1 }, // touching + combine -> one merged brim
|
||||
{ 5.0, 0, 2 }, // touching, no combine -> separate
|
||||
{ 60.0, 1, 2 }, // far apart -> nothing to merge
|
||||
}));
|
||||
DYNAMIC_SECTION("gap=" << gap << " combine_brims=" << combine) {
|
||||
Print print;
|
||||
Model model;
|
||||
place_two_cubes_apart(gap, {
|
||||
{ "skirt_loops", 1 },
|
||||
{ "skirt_height", 1 },
|
||||
{ "skirt_distance", 3 },
|
||||
{ "skirt_type", "perobject" },
|
||||
{ "print_sequence", "by layer" },
|
||||
{ "brim_type", "outer_only" },
|
||||
{ "brim_width", 5 },
|
||||
{ "combine_brims", combine },
|
||||
{ "layer_height", 0.2 },
|
||||
}, print, model);
|
||||
print.process();
|
||||
CHECK(brim_count(print) == expected_brims);
|
||||
}
|
||||
}
|
||||
|
||||
// Each object's skirt and brim come right before that object, not all skirts then all brims first.
|
||||
TEST_CASE("By-layer per-object skirt and brim precede each object", "[SkirtBrim]")
|
||||
{
|
||||
const std::string gcode = slice_two_cubes_apart(60, { // far apart: a skirt+brim per object
|
||||
{ "skirt_loops", 1 },
|
||||
{ "skirt_height", 1 },
|
||||
{ "skirt_distance", 3 },
|
||||
{ "skirt_type", "perobject" },
|
||||
{ "print_sequence", "by layer" },
|
||||
{ "brim_type", "outer_only" },
|
||||
{ "brim_width", 5 },
|
||||
{ "layer_height", 0.2 },
|
||||
});
|
||||
const std::vector<std::string> expected{ "skirt", "brim", "perimeter", "skirt", "brim", "perimeter" };
|
||||
CHECK(role_sequence(gcode, { "skirt", "brim", "perimeter" }) == expected);
|
||||
}
|
||||
|
||||
// A square's corners are 90 degrees, so they get ears only when brim_ears_max_angle is above 90.
|
||||
TEST_CASE("Brim ears appear only at corners within the max angle", "[SkirtBrim]")
|
||||
{
|
||||
auto [max_angle, expect_ears] = GENERATE(table<int, bool>({ { 91, true }, { 90, false }, { 89, false } }));
|
||||
DYNAMIC_SECTION("brim_ears_max_angle=" << max_angle) {
|
||||
Print print;
|
||||
init_and_process_print({ cube(20) }, print, {
|
||||
{ "skirt_loops", 0 },
|
||||
{ "brim_type", "brim_ears" },
|
||||
{ "brim_width", 1 },
|
||||
{ "brim_ears_max_angle", max_angle },
|
||||
{ "initial_layer_line_width", 0.5 },
|
||||
});
|
||||
if (expect_ears) CHECK(brim_loop_count(print) > 0);
|
||||
else CHECK(brim_loop_count(print) == 0);
|
||||
}
|
||||
}
|
||||
|
||||
SCENARIO("Skirt has the configured number of loops", "[SkirtBrim]") {
|
||||
GIVEN("20mm cube and default config") {
|
||||
WHEN("skirt_loops is set to 2") {
|
||||
Print print;
|
||||
init_and_process_print({cube(20)}, print, {
|
||||
{ "skirt_height", 1 },
|
||||
{ "skirt_distance", 1 },
|
||||
{ "skirt_loops", 2 }
|
||||
});
|
||||
THEN("Skirt Extrusion collection has 2 loops in it") {
|
||||
REQUIRE(print.skirt().items_count() == 2);
|
||||
REQUIRE(print.skirt().flatten().entities.size() == 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SCENARIO("Brim has the configured number of loops", "[SkirtBrim]") {
|
||||
GIVEN("20mm cube and default config, 1mm first layer width") {
|
||||
WHEN("Brim is set to 6mm") {
|
||||
Print print;
|
||||
init_and_process_print({cube(20)}, print, {
|
||||
{ "brim_type", "outer_only" },
|
||||
{ "initial_layer_line_width", 1 },
|
||||
{ "brim_width", 6 }
|
||||
});
|
||||
THEN("Brim Extrusion collection has 6 loops in it") {
|
||||
REQUIRE(brim_loop_count(print) == 6);
|
||||
}
|
||||
}
|
||||
WHEN("Brim is set to 6mm, extrusion width 0.5mm") {
|
||||
Print print;
|
||||
init_and_process_print({cube(20)}, print, {
|
||||
{ "brim_type", "outer_only" },
|
||||
{ "brim_width", 6 },
|
||||
{ "initial_layer_line_width", 0.5 }
|
||||
});
|
||||
THEN("Brim Extrusion collection has 12 loops in it") {
|
||||
REQUIRE(brim_loop_count(print) == 12);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static double first_extrusion_feedrate_for_feature(const std::string &gcode, const std::string_view feature)
|
||||
{
|
||||
double feedrate = 0.0;
|
||||
bool feature_active = false;
|
||||
GCodeReader parser;
|
||||
parser.parse_buffer(gcode, [&feedrate, &feature_active, feature] (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)
|
||||
feature_active = comment.find(feature) != std::string_view::npos;
|
||||
|
||||
if (feature_active && line.extruding(self) && line.dist_XY(self) > 0) {
|
||||
feedrate = line.new_F(self);
|
||||
self.quit_parsing();
|
||||
}
|
||||
});
|
||||
return brim_tool;
|
||||
return feedrate;
|
||||
}
|
||||
|
||||
TEST_CASE("Skirt height is honored", "[SkirtBrim]") {
|
||||
DynamicPrintConfig config = Slic3r::DynamicPrintConfig::full_print_config();
|
||||
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
||||
config.set_deserialize_strict({
|
||||
{ "skirt_loops", 1 },
|
||||
{ "skirt_height", 5 },
|
||||
{ "wall_loops", 0 },
|
||||
{ "gcode_comments", true }
|
||||
{ "skirt_loops", 1 },
|
||||
{ "skirt_height", 5 },
|
||||
{ "wall_loops", 0 },
|
||||
});
|
||||
|
||||
std::string gcode;
|
||||
std::string gcode;
|
||||
SECTION("printing a single object") {
|
||||
gcode = Slic3r::Test::slice({TestMesh::cube_20x20x20}, config);
|
||||
gcode = slice({ cube(20) }, config);
|
||||
}
|
||||
SECTION("printing multiple objects") {
|
||||
gcode = Slic3r::Test::slice({TestMesh::cube_20x20x20, TestMesh::cube_20x20x20}, config);
|
||||
gcode = slice({ cube(20), cube(20) }, config);
|
||||
}
|
||||
|
||||
REQUIRE(layers_with_role(gcode, "skirt").size() == (size_t)config.opt_int("skirt_height"));
|
||||
REQUIRE(layers_with_role(gcode, "skirt").size() == (size_t) config.opt_int("skirt_height"));
|
||||
}
|
||||
|
||||
TEST_CASE("Brim uses first layer speed", "[SkirtBrim]") {
|
||||
DynamicPrintConfig config = Slic3r::DynamicPrintConfig::full_print_config();
|
||||
config.set_deserialize_strict({
|
||||
{ "brim_type", "outer_only" },
|
||||
{ "brim_width", 5 },
|
||||
{ "gcode_comments", true },
|
||||
{ "initial_layer_speed", 10 },
|
||||
{ "initial_layer_infill_speed", 20 },
|
||||
{ "machine_start_gcode", "" },
|
||||
{ "skirt_loops", 0 },
|
||||
{ "slow_down_for_layer_cooling", false },
|
||||
{ "z_hop", 0 }
|
||||
});
|
||||
|
||||
const std::string gcode = Slic3r::Test::slice({cube(20)}, config);
|
||||
|
||||
const double brim_feedrate = first_extrusion_feedrate_for_feature(gcode, "Brim");
|
||||
REQUIRE(brim_feedrate > 0.0);
|
||||
REQUIRE_THAT(brim_feedrate, Catch::Matchers::WithinAbs(600.0, 1e-3));
|
||||
|
||||
const double bottom_surface_feedrate = first_extrusion_feedrate_for_feature(gcode, "Bottom surface");
|
||||
REQUIRE(bottom_surface_feedrate > 0.0);
|
||||
REQUIRE_THAT(bottom_surface_feedrate, Catch::Matchers::WithinAbs(1200.0, 1e-3));
|
||||
}
|
||||
|
||||
SCENARIO("Skirt and brim generation", "[SkirtBrim]") {
|
||||
GIVEN("A default configuration") {
|
||||
DynamicPrintConfig config = Slic3r::DynamicPrintConfig::full_print_config();
|
||||
config.set_num_extruders(4);
|
||||
config.set_deserialize_strict({
|
||||
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
||||
config.set_num_extruders(4);
|
||||
config.set_deserialize_strict({
|
||||
{ "initial_layer_print_height", 0.3 },
|
||||
{ "gcode_comments", true },
|
||||
// avoid altering speeds unexpectedly
|
||||
// avoid altering speeds unexpectedly
|
||||
{ "slow_down_for_layer_cooling", false },
|
||||
{ "initial_layer_speed", "100%" },
|
||||
// remove noise from top/solid layers
|
||||
// remove noise from top/solid layers
|
||||
{ "top_shell_layers", 0 },
|
||||
{ "bottom_shell_layers", 1 },
|
||||
{ "machine_start_gcode", "T[initial_tool]\n" }
|
||||
{ "machine_start_gcode", "T[initial_tool]\n" },
|
||||
});
|
||||
|
||||
WHEN("Brim width is set to 5") {
|
||||
config.set_deserialize_strict({
|
||||
config.set_deserialize_strict({
|
||||
{ "wall_loops", 0 },
|
||||
{ "skirt_loops", 0 },
|
||||
{ "brim_type", "outer_only" },
|
||||
{ "brim_width", 5 }
|
||||
});
|
||||
THEN("Brim is generated") {
|
||||
std::string gcode = Slic3r::Test::slice({TestMesh::cube_20x20x20}, config);
|
||||
{ "brim_width", 5 },
|
||||
});
|
||||
THEN("Brim is generated") {
|
||||
std::string gcode = slice({ cube(20) }, config);
|
||||
REQUIRE(! layers_with_role(gcode, "brim").empty());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#if 0
|
||||
// This is a real error! One shall print the brim with the external perimeter extruder!
|
||||
WHEN("Perimeter extruder = 2 and support extruders = 3") {
|
||||
THEN("Brim is printed with the extruder used for the perimeters of first object") {
|
||||
config.set_deserialize_strict({
|
||||
{ "skirts", 0 },
|
||||
{ "brim_width", 5 },
|
||||
{ "perimeter_extruder", 2 },
|
||||
{ "support_material_extruder", 3 },
|
||||
{ "infill_extruder", 4 }
|
||||
});
|
||||
std::string gcode = Slic3r::Test::slice({TestMesh::cube_20x20x20}, config);
|
||||
int tool = get_brim_tool(gcode);
|
||||
REQUIRE(tool == config.opt_int("perimeter_extruder") - 1);
|
||||
}
|
||||
}
|
||||
WHEN("Perimeter extruder = 2, support extruders = 3, raft is enabled") {
|
||||
THEN("brim is printed with same extruder as skirt") {
|
||||
config.set_deserialize_strict({
|
||||
{ "skirts", 0 },
|
||||
{ "brim_width", 5 },
|
||||
{ "perimeter_extruder", 2 },
|
||||
{ "support_material_extruder", 3 },
|
||||
{ "infill_extruder", 4 },
|
||||
{ "raft_layers", 1 }
|
||||
});
|
||||
std::string gcode = Slic3r::Test::slice({TestMesh::cube_20x20x20}, config);
|
||||
int tool = get_brim_tool(gcode);
|
||||
REQUIRE(tool == config.opt_int("support_material_extruder") - 1);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
WHEN("brim width to 1 with layer_width of 0.5") {
|
||||
config.set_deserialize_strict({
|
||||
config.set_deserialize_strict({
|
||||
{ "skirt_loops", 0 },
|
||||
{ "initial_layer_line_width", 0.5 },
|
||||
{ "brim_type", "outer_only" },
|
||||
{ "brim_width", 1 }
|
||||
});
|
||||
{ "brim_width", 1 },
|
||||
});
|
||||
THEN("2 brim lines") {
|
||||
Slic3r::Print print;
|
||||
Slic3r::Test::init_and_process_print({TestMesh::cube_20x20x20}, print, config);
|
||||
size_t total_entities = 0;
|
||||
for (const auto& pair : print.get_brimMap()) {
|
||||
total_entities += pair.second.entities.size();
|
||||
}
|
||||
REQUIRE(total_entities == 2);
|
||||
Print print;
|
||||
init_and_process_print({ cube(20) }, print, config);
|
||||
REQUIRE(brim_loop_count(print) == 2);
|
||||
}
|
||||
}
|
||||
|
||||
#if 0
|
||||
WHEN("brim ears on a square") {
|
||||
config.set_deserialize_strict({
|
||||
{ "skirts", 0 },
|
||||
{ "first_layer_extrusion_width", 0.5 },
|
||||
{ "brim_width", 1 },
|
||||
{ "brim_ears", 1 },
|
||||
{ "brim_ears_max_angle", 91 }
|
||||
});
|
||||
Slic3r::Print print;
|
||||
Slic3r::Test::init_and_process_print({TestMesh::cube_20x20x20}, print, config);
|
||||
THEN("Four brim ears") {
|
||||
REQUIRE(print.brim().entities.size() == 4);
|
||||
}
|
||||
}
|
||||
|
||||
WHEN("brim ears on a square but with a too small max angle") {
|
||||
config.set_deserialize_strict({
|
||||
{ "skirts", 0 },
|
||||
{ "first_layer_extrusion_width", 0.5 },
|
||||
{ "brim_width", 1 },
|
||||
{ "brim_ears", 1 },
|
||||
{ "brim_ears_max_angle", 89 }
|
||||
});
|
||||
THEN("no brim") {
|
||||
Slic3r::Print print;
|
||||
Slic3r::Test::init_and_process_print({ TestMesh::cube_20x20x20 }, print, config);
|
||||
REQUIRE(print.brim().entities.size() == 0);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
WHEN("Object is plated with overhang support and a brim") {
|
||||
config.set_deserialize_strict({
|
||||
config.set_deserialize_strict({
|
||||
{ "layer_height", 0.4 },
|
||||
{ "initial_layer_print_height", 0.4 },
|
||||
{ "skirt_loops", 1 },
|
||||
{ "skirt_distance", 0 },
|
||||
{ "enable_support", 1 },
|
||||
{ "brim_type", "outer_only" },
|
||||
{ "brim_width", 5 }
|
||||
});
|
||||
|
||||
{ "brim_width", 5 },
|
||||
});
|
||||
THEN("Support and brim are both emitted") {
|
||||
std::string gcode = Slic3r::Test::slice({TestMesh::overhang}, config);
|
||||
std::string gcode = slice({ TestMesh::overhang }, config);
|
||||
REQUIRE(! layers_with_role(gcode, "support").empty());
|
||||
REQUIRE(! layers_with_role(gcode, "brim").empty());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
WHEN("an object with support is surrounded by a skirt") {
|
||||
config.set_deserialize_strict({
|
||||
{ "enable_support", 1 },
|
||||
{ "skirt_loops", 1 },
|
||||
{ "skirt_distance", 2 },
|
||||
{ "brim_type", "no_brim" },
|
||||
{ "z_hop", 0 }
|
||||
{ "z_hop", 0 },
|
||||
});
|
||||
THEN("the skirt is long enough to enclose the object and its support") {
|
||||
std::string gcode = Slic3r::Test::slice({TestMesh::overhang}, config);
|
||||
std::string gcode = slice({ TestMesh::overhang }, config);
|
||||
const double first_layer_z = config.opt_float("initial_layer_print_height");
|
||||
|
||||
// On the first layer, accumulate the skirt loop length and collect the
|
||||
@@ -200,7 +329,7 @@ SCENARIO("Skirt and brim generation", "[SkirtBrim]") {
|
||||
double skirt_length = 0.0;
|
||||
Points footprint;
|
||||
GCodeReader parser;
|
||||
parser.parse_buffer(gcode, [&] (GCodeReader& self, const GCodeReader::GCodeLine& line) {
|
||||
parser.parse_buffer(gcode, [&](GCodeReader &self, const GCodeReader::GCodeLine &line) {
|
||||
if (! line.extruding(self) || line.dist_XY(self) <= 0 || std::abs(self.z() - first_layer_z) > 0.01)
|
||||
return;
|
||||
if (line.comment().find("skirt") != std::string_view::npos)
|
||||
@@ -214,17 +343,18 @@ SCENARIO("Skirt and brim generation", "[SkirtBrim]") {
|
||||
REQUIRE(skirt_length > hull_perimeter);
|
||||
}
|
||||
}
|
||||
|
||||
WHEN("Large minimum skirt length is used.") {
|
||||
// One skirt loop around a 20mm cube is ~88mm, so 500mm forces extra loops.
|
||||
config.set_deserialize_strict({
|
||||
{ "skirt_loops", 1 },
|
||||
{ "min_skirt_length", 500 }
|
||||
{ "min_skirt_length", 500 },
|
||||
});
|
||||
THEN("The skirt is extended to at least the minimum length") {
|
||||
std::string gcode = Slic3r::Test::slice({TestMesh::cube_20x20x20}, config);
|
||||
std::string gcode = slice({ cube(20) }, config);
|
||||
double skirt_length = 0.0;
|
||||
GCodeReader parser;
|
||||
parser.parse_buffer(gcode, [&skirt_length] (GCodeReader& self, const GCodeReader::GCodeLine& line) {
|
||||
parser.parse_buffer(gcode, [&skirt_length](GCodeReader &self, const GCodeReader::GCodeLine &line) {
|
||||
if (line.extruding(self) && line.comment().find("skirt") != std::string_view::npos)
|
||||
skirt_length += line.dist_XY(self);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#include <catch2/catch_test_macros.hpp>
|
||||
#include "libslic3r/PrintConfig.hpp"
|
||||
#include "test_helpers.hpp"
|
||||
using namespace Slic3r;
|
||||
|
||||
TEST_CASE("slicing_pipeline_plugin option exists and defaults empty", "[slicing_pipeline]") {
|
||||
@@ -24,7 +25,6 @@ TEST_CASE("slicing pipeline hook setter is a no-op-safe injection", "[slicing_pi
|
||||
CHECK(calls == 0);
|
||||
}
|
||||
|
||||
#include "test_data.hpp"
|
||||
#include <vector>
|
||||
#include <algorithm>
|
||||
using namespace Slic3r::Test;
|
||||
@@ -38,7 +38,7 @@ TEST_CASE("SlicingPipeline hook fires once per step per object in order", "[slic
|
||||
Slic3r::Print print; Slic3r::Model model;
|
||||
Slic3r::DynamicPrintConfig config = Slic3r::DynamicPrintConfig::full_print_config();
|
||||
config.set_key_value("slicing_pipeline_plugin", new Slic3r::ConfigOptionStrings({"probe"})); // activate
|
||||
init_print({TestMesh::cube_20x20x20}, print, model, config);
|
||||
init_print({cube(20)}, print, model, config);
|
||||
print.process();
|
||||
Slic3r::Print::set_slicing_pipeline_hook_fn(nullptr);
|
||||
|
||||
@@ -102,7 +102,7 @@ TEST_CASE("Inactive hook: process output is byte-identical (no-op hook == unset)
|
||||
Slic3r::Print::set_slicing_pipeline_hook_fn([](Slic3r::Print&, const Slic3r::PrintObject*, Slic3r::SlicingPipelineStepPlugin){});
|
||||
else
|
||||
Slic3r::Print::set_slicing_pipeline_hook_fn(nullptr);
|
||||
init_print({TestMesh::cube_20x20x20}, print, model, config);
|
||||
init_print({cube(20)}, print, model, config);
|
||||
std::string g = Slic3r::Test::gcode(print);
|
||||
Slic3r::Print::set_slicing_pipeline_hook_fn(nullptr);
|
||||
return g;
|
||||
@@ -126,7 +126,7 @@ TEST_CASE("Empty option: registered hook is gated off and never fires", "[slicin
|
||||
Slic3r::Print print; Slic3r::Model model;
|
||||
auto config = Slic3r::DynamicPrintConfig::full_print_config();
|
||||
// option left EMPTY -> inactive regardless of the registered hook.
|
||||
init_print({TestMesh::cube_20x20x20}, print, model, config);
|
||||
init_print({cube(20)}, print, model, config);
|
||||
print.process();
|
||||
Slic3r::Print::set_slicing_pipeline_hook_fn(nullptr);
|
||||
CHECK(calls == 0);
|
||||
@@ -150,7 +150,7 @@ TEST_CASE("Duplicate objects share a slice: Slice hook fires exactly once", "[sl
|
||||
config.set_key_value("slicing_pipeline_plugin", new Slic3r::ConfigOptionStrings({"probe"})); // activate
|
||||
|
||||
// init_print builds one arranged, on-bed cube object (o1).
|
||||
init_print({TestMesh::cube_20x20x20}, print, model, config);
|
||||
init_print({cube(20)}, print, model, config);
|
||||
Slic3r::ModelObject* o1 = model.objects.front();
|
||||
// Model::add_object(const ModelObject&) force-sets object extruder=1 on the clone; give o1
|
||||
// the same so the two objects' configs match (is_print_object_the_same compares config).
|
||||
@@ -198,7 +198,7 @@ TEST_CASE("Mutating slices at the Slice boundary cascades downstream", "[slicing
|
||||
}
|
||||
});
|
||||
else Slic3r::Print::set_slicing_pipeline_hook_fn(nullptr);
|
||||
init_print({TestMesh::cube_20x20x20}, print, model, config);
|
||||
init_print({cube(20)}, print, model, config);
|
||||
print.process();
|
||||
double a = 0; for (auto* l : print.objects().front()->layers()) for (auto* r : l->regions()) for (auto& s : r->fill_surfaces.surfaces) a += s.expolygon.area();
|
||||
Slic3r::Print::set_slicing_pipeline_hook_fn(nullptr);
|
||||
@@ -210,7 +210,7 @@ TEST_CASE("Mutating slices at the Slice boundary cascades downstream", "[slicing
|
||||
TEST_CASE("Changing slicing_pipeline_plugin invalidates posSlice", "[slicing_pipeline]") {
|
||||
Slic3r::Print print; Slic3r::Model model;
|
||||
auto config = Slic3r::DynamicPrintConfig::full_print_config();
|
||||
init_print({TestMesh::cube_20x20x20}, print, model, config);
|
||||
init_print({cube(20)}, print, model, config);
|
||||
print.process();
|
||||
REQUIRE(print.objects().front()->is_step_done(posSlice));
|
||||
config.set_key_value("slicing_pipeline_plugin", new Slic3r::ConfigOptionStrings({"probe"}));
|
||||
@@ -271,7 +271,7 @@ TEST_CASE("Rotating slices at the Slice boundary cascades (area preserved, bbox
|
||||
}
|
||||
});
|
||||
else Slic3r::Print::set_slicing_pipeline_hook_fn(nullptr);
|
||||
init_print({TestMesh::cube_20x20x20}, print, model, config);
|
||||
init_print({cube(20)}, print, model, config);
|
||||
print.process();
|
||||
double area = 0;
|
||||
coord_t nx=0, xx=0, ny=0, xy=0; bool seeded=false;
|
||||
@@ -320,7 +320,7 @@ TEST_CASE("Identity round-trip through slices.set() is byte-identical", "[slicin
|
||||
r->slices.set(std::move(in)); // write back unchanged: identity transform
|
||||
}
|
||||
});
|
||||
init_print({TestMesh::cube_20x20x20}, print, model, config);
|
||||
init_print({cube(20)}, print, model, config);
|
||||
std::string g = Slic3r::Test::gcode(print);
|
||||
Slic3r::Print::set_slicing_pipeline_hook_fn(nullptr);
|
||||
return g;
|
||||
@@ -376,7 +376,7 @@ TEST_CASE("raw_slices captures post-hook geometry so a perimeter re-run keeps th
|
||||
Slic3r::Print print; Slic3r::Model model;
|
||||
auto config = Slic3r::DynamicPrintConfig::full_print_config();
|
||||
config.set_key_value("slicing_pipeline_plugin", new Slic3r::ConfigOptionStrings({"probe"}));
|
||||
init_print({TestMesh::cube_20x20x20}, print, model, config);
|
||||
init_print({cube(20)}, print, model, config);
|
||||
print.process();
|
||||
const double w_mutated = outer_slices_width(print); // inset applied at the Slice hook
|
||||
|
||||
@@ -410,7 +410,7 @@ TEST_CASE("fill_surfaces mutation cascades at PrepareInfill but not at Infill",
|
||||
r->fill_surfaces.set(std::move(out));
|
||||
}
|
||||
});
|
||||
init_print({TestMesh::cube_20x20x20}, print, model, config);
|
||||
init_print({cube(20)}, print, model, config);
|
||||
print.process();
|
||||
size_t n = 0;
|
||||
for (auto* l : print.objects().front()->layers())
|
||||
@@ -451,7 +451,7 @@ TEST_CASE("refreshing lslices after a slice mutation makes islands track the geo
|
||||
l->make_slices();
|
||||
}
|
||||
});
|
||||
init_print({TestMesh::cube_20x20x20}, print, model, config);
|
||||
init_print({cube(20)}, print, model, config);
|
||||
print.process();
|
||||
coord_t min_x = 0, max_x = 0; bool seeded = false;
|
||||
for (auto* l : print.objects().front()->layers())
|
||||
@@ -533,7 +533,7 @@ TEST_CASE("Fuzzing slice contours at the Slice boundary cascades with bounded di
|
||||
}
|
||||
});
|
||||
else Slic3r::Print::set_slicing_pipeline_hook_fn(nullptr);
|
||||
init_print({TestMesh::cube_20x20x20}, print, model, config);
|
||||
init_print({cube(20)}, print, model, config);
|
||||
print.process();
|
||||
Measure m { 0.0, 0, outer_slices_width(print) };
|
||||
for (auto* l : print.objects().front()->layers())
|
||||
|
||||
@@ -3,22 +3,22 @@
|
||||
#include "libslic3r/GCodeReader.hpp"
|
||||
#include "libslic3r/Layer.hpp"
|
||||
|
||||
#include "test_data.hpp" // get access to init_print, etc
|
||||
#include "test_helpers.hpp" // get access to init_print, etc
|
||||
|
||||
using namespace Slic3r::Test;
|
||||
using namespace Slic3r;
|
||||
|
||||
TEST_CASE("SupportMaterial: Three raft layers created", "[SupportMaterial]")
|
||||
TEST_CASE("Three raft layers are created", "[SupportMaterial]")
|
||||
{
|
||||
Slic3r::Print print;
|
||||
Slic3r::Test::init_and_process_print({ TestMesh::cube_20x20x20 }, print, {
|
||||
Slic3r::Test::init_and_process_print({ cube(20) }, print, {
|
||||
{ "enable_support", 1 },
|
||||
{ "raft_layers", 3 }
|
||||
});
|
||||
REQUIRE(print.objects().front()->support_layers().size() == 3);
|
||||
}
|
||||
|
||||
TEST_CASE("SupportMaterial: enforced support layers are generated", "[SupportMaterial]")
|
||||
TEST_CASE("Enforced support layers are generated", "[SupportMaterial]")
|
||||
{
|
||||
// enforce_support_layers forces support on the first N layers even with support off.
|
||||
Slic3r::Print baseline;
|
||||
@@ -36,7 +36,7 @@ TEST_CASE("SupportMaterial: enforced support layers are generated", "[SupportMat
|
||||
REQUIRE(enforced.objects().front()->support_layers().size() > 0);
|
||||
}
|
||||
|
||||
SCENARIO("SupportMaterial: support_layers_z and contact_distance", "[SupportMaterial]")
|
||||
SCENARIO("Support layer Z honors contact distance", "[SupportMaterial]")
|
||||
{
|
||||
// Box h = 20mm, hole bottom at 5mm, hole height 10mm (top edge at 15mm).
|
||||
TriangleMesh mesh = Slic3r::Test::mesh(Slic3r::Test::TestMesh::cube_with_hole);
|
||||
@@ -93,3 +93,14 @@ SCENARIO("SupportMaterial: support_layers_z and contact_distance", "[SupportMate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// extrude_support once held a `static` lambda capturing `this`, so a second export in the
|
||||
// same process dereferenced a returned stack frame (ASan: stack-use-after-return).
|
||||
TEST_CASE("Support G-code emission survives a second slice in the same process", "[SupportMaterial][Regression]")
|
||||
{
|
||||
const std::string first = slice({ TestMesh::overhang }, { { "enable_support", 1 } });
|
||||
REQUIRE(! layers_with_role(first, "support").empty());
|
||||
|
||||
const std::string second = slice({ TestMesh::overhang }, { { "enable_support", 1 } });
|
||||
REQUIRE(! layers_with_role(second, "support").empty());
|
||||
}
|
||||
|
||||
@@ -12,14 +12,14 @@
|
||||
#include <chrono>
|
||||
|
||||
//#include "test_options.hpp"
|
||||
#include "test_data.hpp"
|
||||
#include "test_helpers.hpp"
|
||||
|
||||
using namespace Slic3r;
|
||||
using namespace std;
|
||||
|
||||
static inline TriangleMesh make_cube() { return make_cube(20., 20, 20); }
|
||||
|
||||
SCENARIO( "TriangleMesh: Basic mesh statistics") {
|
||||
SCENARIO("Basic mesh statistics", "[TriangleMesh]") {
|
||||
GIVEN( "A 20mm cube, built from constexpr std::array" ) {
|
||||
std::vector<Vec3f> vertices { {20,20,0}, {20,0,0}, {0,0,0}, {0,20,0}, {20,20,20}, {0,20,20}, {0,0,20}, {20,0,20} };
|
||||
std::vector<Vec3i32> facets { {0,1,2}, {0,2,3}, {4,5,6}, {4,6,7}, {0,4,7}, {0,7,1}, {1,7,6}, {1,6,2}, {2,6,5}, {2,5,3}, {4,0,3}, {4,3,5} };
|
||||
@@ -71,7 +71,7 @@ SCENARIO( "TriangleMesh: Basic mesh statistics") {
|
||||
}
|
||||
}
|
||||
|
||||
SCENARIO( "TriangleMesh: Transformation functions affect mesh as expected.") {
|
||||
SCENARIO("Transformation functions affect the mesh as expected", "[TriangleMesh]") {
|
||||
GIVEN( "A 20mm cube with one corner on the origin") {
|
||||
auto cube = make_cube();
|
||||
|
||||
@@ -134,7 +134,7 @@ SCENARIO( "TriangleMesh: Transformation functions affect mesh as expected.") {
|
||||
}
|
||||
}
|
||||
|
||||
SCENARIO( "TriangleMesh: slice behavior.") {
|
||||
SCENARIO("Slice behavior", "[TriangleMesh]") {
|
||||
GIVEN( "A 20mm cube with one corner on the origin") {
|
||||
auto cube = make_cube();
|
||||
|
||||
@@ -177,7 +177,7 @@ SCENARIO( "TriangleMesh: slice behavior.") {
|
||||
}
|
||||
}
|
||||
|
||||
SCENARIO( "make_xxx functions produce meshes.") {
|
||||
SCENARIO("make_xxx functions produce meshes", "[TriangleMesh]") {
|
||||
GIVEN("make_cube() function") {
|
||||
WHEN("make_cube() is called with arguments 20,20,20") {
|
||||
TriangleMesh cube = make_cube(20,20,20);
|
||||
@@ -232,7 +232,7 @@ SCENARIO( "make_xxx functions produce meshes.") {
|
||||
}
|
||||
}
|
||||
|
||||
SCENARIO( "TriangleMesh: split functionality.") {
|
||||
SCENARIO("Split functionality", "[TriangleMesh]") {
|
||||
GIVEN( "A 20mm cube with one corner on the origin") {
|
||||
auto cube = make_cube();
|
||||
WHEN( "The mesh is split into its component parts.") {
|
||||
@@ -260,7 +260,7 @@ SCENARIO( "TriangleMesh: split functionality.") {
|
||||
}
|
||||
}
|
||||
|
||||
SCENARIO( "TriangleMesh: Mesh merge functions") {
|
||||
SCENARIO("Mesh merge functions", "[TriangleMesh]") {
|
||||
GIVEN( "Two 20mm cubes, each with one corner on the origin") {
|
||||
auto cube = make_cube();
|
||||
TriangleMesh cube2(cube);
|
||||
@@ -274,7 +274,7 @@ SCENARIO( "TriangleMesh: Mesh merge functions") {
|
||||
}
|
||||
}
|
||||
|
||||
SCENARIO( "TriangleMeshSlicer: Cut behavior.") {
|
||||
SCENARIO("Cut behavior", "[TriangleMesh]") {
|
||||
GIVEN( "A 20mm cube with one corner on the origin") {
|
||||
auto cube = make_cube();
|
||||
WHEN( "Object is cut at the bottom") {
|
||||
@@ -302,7 +302,7 @@ SCENARIO( "TriangleMeshSlicer: Cut behavior.") {
|
||||
}
|
||||
}
|
||||
#ifdef TEST_PERFORMANCE
|
||||
TEST_CASE("Regression test for issue #4486 - files take forever to slice") {
|
||||
TEST_CASE("Large mesh slices within the time budget (#4486)", "[TriangleMesh][Regression]") {
|
||||
TriangleMesh mesh;
|
||||
DynamicPrintConfig config = Slic3r::DynamicPrintConfig::full_print_config();
|
||||
mesh.ReadSTLFile(std::string(testfile_dir) + "test_trianglemesh/4486/100_000.stl");
|
||||
@@ -329,7 +329,7 @@ TEST_CASE("Regression test for issue #4486 - files take forever to slice") {
|
||||
#endif // TEST_PERFORMANCE
|
||||
|
||||
#ifdef BUILD_PROFILE
|
||||
TEST_CASE("Profile test for issue #4486 - files take forever to slice") {
|
||||
TEST_CASE("Large mesh slicing profile (#4486)", "[TriangleMesh][Profile]") {
|
||||
TriangleMesh mesh;
|
||||
DynamicPrintConfig config = Slic3r::DynamicPrintConfig::full_print_config();
|
||||
mesh.ReadSTLFile(std::string(testfile_dir) + "test_trianglemesh/4486/10_000.stl");
|
||||
|
||||
23
tests/filament_group/CMakeLists.txt
Normal file
23
tests/filament_group/CMakeLists.txt
Normal file
@@ -0,0 +1,23 @@
|
||||
get_filename_component(_TEST_NAME ${CMAKE_CURRENT_LIST_DIR} NAME)
|
||||
|
||||
set(FG_GOLDEN_DIR ${CMAKE_CURRENT_SOURCE_DIR}/golden)
|
||||
file(TO_NATIVE_PATH "${FG_GOLDEN_DIR}" FG_GOLDEN_DIR)
|
||||
|
||||
add_executable(${_TEST_NAME}_tests
|
||||
filament_group_regression_main.cpp
|
||||
)
|
||||
|
||||
target_link_libraries(${_TEST_NAME}_tests test_common libslic3r nlohmann_json Catch2::Catch2WithMain)
|
||||
# ${CMAKE_SOURCE_DIR}/deps_src puts <nlohmann/json.hpp> on the include path the same way the
|
||||
# libslic3r translation units resolve it (via the admesh/.. system include); nlohmann_json's own
|
||||
# interface dir only exposes <json.hpp>, so the source-tree include is required for the <nlohmann/…>
|
||||
# spelling the serializers use.
|
||||
target_include_directories(${_TEST_NAME}_tests PRIVATE ${CMAKE_SOURCE_DIR}/src ${CMAKE_SOURCE_DIR}/deps_src)
|
||||
target_compile_definitions(${_TEST_NAME}_tests PRIVATE
|
||||
FG_TEST_GOLDEN_DIR=R"\(${FG_GOLDEN_DIR}\)"
|
||||
)
|
||||
set_property(TARGET ${_TEST_NAME}_tests PROPERTY FOLDER "tests")
|
||||
|
||||
orcaslicer_copy_test_dlls()
|
||||
|
||||
orcaslicer_discover_tests(${_TEST_NAME}_tests)
|
||||
237
tests/filament_group/fg_test_evaluator.hpp
Normal file
237
tests/filament_group/fg_test_evaluator.hpp
Normal file
@@ -0,0 +1,237 @@
|
||||
#ifndef FG_TEST_EVALUATOR_HPP
|
||||
#define FG_TEST_EVALUATOR_HPP
|
||||
|
||||
#include "fg_test_serialization.hpp"
|
||||
#include <libslic3r/FilamentGroup.hpp>
|
||||
#include <libslic3r/GCode/ToolOrderUtils.hpp>
|
||||
#include <libslic3r/MultiNozzleUtils.hpp>
|
||||
|
||||
#include <chrono>
|
||||
#include <sstream>
|
||||
#include <unordered_set>
|
||||
|
||||
namespace Slic3r {
|
||||
namespace FGTest {
|
||||
|
||||
inline bool check_constraints(const FilamentGroupContext& ctx,
|
||||
const std::vector<int>& filament_map,
|
||||
std::vector<std::string>& violations) {
|
||||
violations.clear();
|
||||
auto used_filaments = collect_sorted_used_filaments(ctx.model_info.layer_filaments);
|
||||
|
||||
// 1. unprintable_filaments check
|
||||
for (size_t ext = 0; ext < ctx.model_info.unprintable_filaments.size(); ++ext) {
|
||||
for (int fil : ctx.model_info.unprintable_filaments[ext]) {
|
||||
if (fil < 0 || fil >= (int)filament_map.size())
|
||||
continue;
|
||||
int assigned_nozzle = filament_map[fil];
|
||||
if (assigned_nozzle < 0 || assigned_nozzle >= (int)ctx.nozzle_info.nozzle_list.size())
|
||||
continue;
|
||||
if (ctx.nozzle_info.nozzle_list[assigned_nozzle].extruder_id == (int)ext) {
|
||||
std::ostringstream ss;
|
||||
ss << "filament " << fil << " assigned to nozzle " << assigned_nozzle
|
||||
<< " (extruder " << ext << ") but is unprintable there";
|
||||
violations.push_back(ss.str());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. unprintable_volumes check
|
||||
for (auto& [fil, volume_types] : ctx.model_info.unprintable_volumes) {
|
||||
if (fil < 0 || fil >= (int)filament_map.size())
|
||||
continue;
|
||||
int assigned_nozzle = filament_map[fil];
|
||||
if (assigned_nozzle < 0 || assigned_nozzle >= (int)ctx.nozzle_info.nozzle_list.size())
|
||||
continue;
|
||||
if (volume_types.count(ctx.nozzle_info.nozzle_list[assigned_nozzle].volume_type)) {
|
||||
std::ostringstream ss;
|
||||
ss << "filament " << fil << " assigned to nozzle " << assigned_nozzle
|
||||
<< " with volume_type " << (int)ctx.nozzle_info.nozzle_list[assigned_nozzle].volume_type
|
||||
<< " but that type is unprintable for this filament";
|
||||
violations.push_back(ss.str());
|
||||
}
|
||||
}
|
||||
|
||||
// 3. max_group_size per extruder. This cap is an invariant of the flush-partition
|
||||
// modes only: those solvers partition the filaments across extruders subject to
|
||||
// each extruder's capacity. MatchMode instead maps every filament to the extruder
|
||||
// holding the nearest-color loaded AMS filament and does not partition by capacity
|
||||
// (its solver capacity is the filament count, not max_group_size), so a legitimate
|
||||
// match may place more than max_group_size filaments on one extruder. Enforce the
|
||||
// cap only for the partition modes, and only when the instance is feasible.
|
||||
int total_capacity = 0;
|
||||
for (auto sz : ctx.machine_info.max_group_size)
|
||||
total_capacity += sz;
|
||||
|
||||
if (ctx.group_info.mode != FGMode::MatchMode &&
|
||||
total_capacity >= (int)used_filaments.size()) {
|
||||
std::map<int, int> extruder_count;
|
||||
for (auto fil : used_filaments) {
|
||||
if (fil >= filament_map.size()) continue;
|
||||
int nozzle_id = filament_map[fil];
|
||||
if (nozzle_id < 0 || nozzle_id >= (int)ctx.nozzle_info.nozzle_list.size())
|
||||
continue;
|
||||
extruder_count[ctx.nozzle_info.nozzle_list[nozzle_id].extruder_id]++;
|
||||
}
|
||||
for (auto& [ext, count] : extruder_count) {
|
||||
if (ext >= 0 && ext < (int)ctx.machine_info.max_group_size.size()) {
|
||||
if (count > ctx.machine_info.max_group_size[ext]) {
|
||||
std::ostringstream ss;
|
||||
ss << "extruder " << ext << " has " << count << " filaments but max is "
|
||||
<< ctx.machine_info.max_group_size[ext];
|
||||
violations.push_back(ss.str());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return violations.empty();
|
||||
}
|
||||
|
||||
inline int compute_flush_cost(const FilamentGroupContext& ctx,
|
||||
const std::vector<int>& filament_map) {
|
||||
auto used_filaments = collect_sorted_used_filaments(ctx.model_info.layer_filaments);
|
||||
if (used_filaments.empty())
|
||||
return 0;
|
||||
|
||||
auto nozzle_group_result = MultiNozzleUtils::LayeredNozzleGroupResult::create(
|
||||
filament_map, ctx.nozzle_info.nozzle_list, used_filaments);
|
||||
|
||||
if (!nozzle_group_result)
|
||||
return -1;
|
||||
|
||||
std::vector<std::vector<unsigned int>> filament_sequences;
|
||||
auto get_custom_seq_null = [](int, std::vector<int>&) -> bool { return false; };
|
||||
|
||||
int cost = reorder_filaments_for_multi_nozzle_extruder(
|
||||
used_filaments,
|
||||
*nozzle_group_result,
|
||||
ctx.model_info.layer_filaments,
|
||||
ctx.model_info.flush_matrix,
|
||||
get_custom_seq_null,
|
||||
&filament_sequences,
|
||||
MultiNozzleUtils::NozzleStatusRecorder{});
|
||||
|
||||
return cost;
|
||||
}
|
||||
|
||||
struct FullEvalResult {
|
||||
int flush_cost = 0;
|
||||
double change_time = 0.0;
|
||||
double full_score = 0.0;
|
||||
bool constraints_ok = true;
|
||||
std::vector<std::string> violations;
|
||||
};
|
||||
|
||||
inline double evaluate_score(double flush, double time) {
|
||||
double approx_density = 1.26;
|
||||
double approx_flush_speed = 180;
|
||||
double correction_factor = 2;
|
||||
double flush_score = flush * approx_density * approx_flush_speed * correction_factor / 1000;
|
||||
return flush_score + time;
|
||||
}
|
||||
|
||||
inline double calc_change_time_for_group_eval(
|
||||
const std::vector<int>& filament_change_seq,
|
||||
const std::vector<int>& nozzle_change_seq,
|
||||
const std::vector<int>& logical_filaments,
|
||||
const std::vector<MultiNozzleUtils::NozzleInfo>& nozzle_list,
|
||||
const MultiNozzleUtils::FilamentChangeTimeParams& time_params,
|
||||
const std::vector<bool>& ams_preload_enabled,
|
||||
const std::vector<int>& group_of_filament)
|
||||
{
|
||||
auto r = MultiNozzleUtils::simulate_filament_change_time(
|
||||
logical_filaments, nozzle_list, filament_change_seq,
|
||||
nozzle_change_seq, group_of_filament, time_params,
|
||||
ams_preload_enabled);
|
||||
return r.actual_time;
|
||||
}
|
||||
|
||||
inline FullEvalResult full_evaluate_map(const FilamentGroupContext& ctx,
|
||||
const std::vector<int>& filament_map) {
|
||||
FullEvalResult result;
|
||||
auto used_filaments = collect_sorted_used_filaments(ctx.model_info.layer_filaments);
|
||||
if (used_filaments.empty()) return result;
|
||||
|
||||
auto nozzle_group_result = MultiNozzleUtils::LayeredNozzleGroupResult::create(
|
||||
filament_map, ctx.nozzle_info.nozzle_list, used_filaments);
|
||||
if (!nozzle_group_result) return result;
|
||||
|
||||
MultiNozzleUtils::NozzleStatusRecorder initial_status;
|
||||
for (auto& [nozzle_id, filament_id] : ctx.nozzle_info.nozzle_status) {
|
||||
if (filament_id >= 0) {
|
||||
int extruder_id = 0;
|
||||
for (const auto& nozzle : ctx.nozzle_info.nozzle_list) {
|
||||
if (nozzle.group_id == nozzle_id) { extruder_id = nozzle.extruder_id; break; }
|
||||
}
|
||||
initial_status.set_nozzle_status(nozzle_id, filament_id, extruder_id);
|
||||
}
|
||||
}
|
||||
|
||||
std::vector<std::vector<unsigned int>> filament_sequences;
|
||||
auto get_custom_seq_null = [](int, std::vector<int>&) -> bool { return false; };
|
||||
|
||||
result.flush_cost = reorder_filaments_for_multi_nozzle_extruder(
|
||||
used_filaments, *nozzle_group_result, ctx.model_info.layer_filaments,
|
||||
ctx.model_info.flush_matrix, get_custom_seq_null, &filament_sequences, initial_status);
|
||||
|
||||
if (!filament_sequences.empty()) {
|
||||
std::vector<int> filament_change_seq;
|
||||
std::vector<int> nozzle_change_seq;
|
||||
int prev_fil = -1, prev_nozzle = -1;
|
||||
for (const auto& layer_seq : filament_sequences) {
|
||||
for (unsigned int fil : layer_seq) {
|
||||
auto nozzle_info = nozzle_group_result->get_first_nozzle_for_filament(fil);
|
||||
if (!nozzle_info) continue;
|
||||
int nid = nozzle_info->group_id;
|
||||
if ((int)fil == prev_fil && nid == prev_nozzle) continue;
|
||||
filament_change_seq.push_back((int)fil);
|
||||
nozzle_change_seq.push_back(nid);
|
||||
prev_fil = (int)fil;
|
||||
prev_nozzle = nid;
|
||||
}
|
||||
}
|
||||
|
||||
std::vector<int> logical_filaments(used_filaments.begin(), used_filaments.end());
|
||||
std::vector<int> group_of_filament(used_filaments.size(), 0);
|
||||
for (size_t fi = 0; fi < used_filaments.size(); ++fi) {
|
||||
int nid = filament_map[used_filaments[fi]];
|
||||
if (nid >= 0 && nid < (int)ctx.nozzle_info.nozzle_list.size())
|
||||
group_of_filament[fi] = ctx.nozzle_info.nozzle_list[nid].extruder_id;
|
||||
}
|
||||
result.change_time = calc_change_time_for_group_eval(
|
||||
filament_change_seq, nozzle_change_seq, logical_filaments,
|
||||
ctx.nozzle_info.nozzle_list, ctx.speed_info.change_time_params,
|
||||
ctx.speed_info.ams_preload_enabled, group_of_filament);
|
||||
}
|
||||
|
||||
result.full_score = evaluate_score(result.flush_cost, result.change_time);
|
||||
result.constraints_ok = check_constraints(ctx, filament_map, result.violations);
|
||||
return result;
|
||||
}
|
||||
|
||||
inline TestResult run_and_evaluate(const FilamentGroupContext& ctx,
|
||||
const ClusteringBudget& budget = {}) {
|
||||
TestResult result;
|
||||
|
||||
auto start = std::chrono::high_resolution_clock::now();
|
||||
int algo_cost = 0;
|
||||
|
||||
FilamentGroup fg(ctx);
|
||||
fg.set_clustering_budget(budget);
|
||||
result.filament_map = fg.calc_filament_group(&algo_cost);
|
||||
|
||||
auto end = std::chrono::high_resolution_clock::now();
|
||||
result.elapsed_ms = std::chrono::duration<double, std::milli>(end - start).count();
|
||||
|
||||
result.flush_cost = compute_flush_cost(ctx, result.filament_map);
|
||||
|
||||
result.constraints_ok = check_constraints(ctx, result.filament_map, result.violations);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
} // namespace FGTest
|
||||
} // namespace Slic3r
|
||||
|
||||
#endif // FG_TEST_EVALUATOR_HPP
|
||||
447
tests/filament_group/fg_test_serialization.hpp
Normal file
447
tests/filament_group/fg_test_serialization.hpp
Normal file
@@ -0,0 +1,447 @@
|
||||
#ifndef FG_TEST_SERIALIZATION_HPP
|
||||
#define FG_TEST_SERIALIZATION_HPP
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <libslic3r/FilamentGroup.hpp>
|
||||
#include <libslic3r/FilamentGroupUtils.hpp>
|
||||
#include <libslic3r/MultiNozzleUtils.hpp>
|
||||
#include <libslic3r/PrintConfig.hpp>
|
||||
|
||||
#include <fstream>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <set>
|
||||
#include <map>
|
||||
#include <unordered_map>
|
||||
|
||||
using json = nlohmann::json;
|
||||
|
||||
// Put serializers in correct ADL namespaces for each type
|
||||
|
||||
namespace Slic3r {
|
||||
namespace FilamentGroupUtils {
|
||||
|
||||
inline void to_json(json& j, const Color& c) {
|
||||
char buf[10];
|
||||
snprintf(buf, sizeof(buf), "#%02X%02X%02X%02X", c.r, c.g, c.b, c.a);
|
||||
j = std::string(buf);
|
||||
}
|
||||
|
||||
inline void from_json(const json& j, Color& c) {
|
||||
std::string s = j.get<std::string>();
|
||||
if (s.size() >= 7 && s[0] == '#') {
|
||||
c.r = (unsigned char)std::stoi(s.substr(1, 2), nullptr, 16);
|
||||
c.g = (unsigned char)std::stoi(s.substr(3, 2), nullptr, 16);
|
||||
c.b = (unsigned char)std::stoi(s.substr(5, 2), nullptr, 16);
|
||||
c.a = (s.size() >= 9) ? (unsigned char)std::stoi(s.substr(7, 2), nullptr, 16) : 255;
|
||||
}
|
||||
}
|
||||
|
||||
inline void to_json(json& j, const FilamentInfo& fi) {
|
||||
j = json{
|
||||
{"color", fi.color},
|
||||
{"type", fi.type},
|
||||
{"is_support", fi.is_support},
|
||||
{"usage_type", (int)fi.usage_type}
|
||||
};
|
||||
}
|
||||
|
||||
inline void from_json(const json& j, FilamentInfo& fi) {
|
||||
fi.color = j.at("color").get<Color>();
|
||||
j.at("type").get_to(fi.type);
|
||||
j.at("is_support").get_to(fi.is_support);
|
||||
fi.usage_type = (FilamentUsageType)j.at("usage_type").get<int>();
|
||||
}
|
||||
|
||||
inline void to_json(json& j, const MachineFilamentInfo& mfi) {
|
||||
j = json{
|
||||
{"color", mfi.color},
|
||||
{"type", mfi.type},
|
||||
{"is_support", mfi.is_support},
|
||||
{"usage_type", (int)mfi.usage_type},
|
||||
{"extruder_id", mfi.extruder_id},
|
||||
{"is_extended", mfi.is_extended}
|
||||
};
|
||||
}
|
||||
|
||||
inline void from_json(const json& j, MachineFilamentInfo& mfi) {
|
||||
mfi.color = j.at("color").get<Color>();
|
||||
j.at("type").get_to(mfi.type);
|
||||
j.at("is_support").get_to(mfi.is_support);
|
||||
mfi.usage_type = (FilamentUsageType)j.at("usage_type").get<int>();
|
||||
j.at("extruder_id").get_to(mfi.extruder_id);
|
||||
j.at("is_extended").get_to(mfi.is_extended);
|
||||
}
|
||||
|
||||
} // namespace FilamentGroupUtils
|
||||
|
||||
namespace MultiNozzleUtils {
|
||||
|
||||
inline void to_json(json& j, const NozzleInfo& ni) {
|
||||
j = json{
|
||||
{"diameter", ni.diameter},
|
||||
{"volume_type", (int)ni.volume_type},
|
||||
{"extruder_id", ni.extruder_id},
|
||||
{"group_id", ni.group_id}
|
||||
};
|
||||
}
|
||||
|
||||
inline void from_json(const json& j, NozzleInfo& ni) {
|
||||
j.at("diameter").get_to(ni.diameter);
|
||||
ni.volume_type = (NozzleVolumeType)j.at("volume_type").get<int>();
|
||||
j.at("extruder_id").get_to(ni.extruder_id);
|
||||
j.at("group_id").get_to(ni.group_id);
|
||||
}
|
||||
|
||||
inline void to_json(json& j, const FilamentChangeTimeParams& p) {
|
||||
j = json{
|
||||
{"selector_load_time", p.selector_load_time},
|
||||
{"selector_unload_time", p.selector_unload_time},
|
||||
{"standard_load_time", p.standard_load_time},
|
||||
{"standard_unload_time", p.standard_unload_time}
|
||||
};
|
||||
}
|
||||
|
||||
inline void from_json(const json& j, FilamentChangeTimeParams& p) {
|
||||
j.at("selector_load_time").get_to(p.selector_load_time);
|
||||
j.at("selector_unload_time").get_to(p.selector_unload_time);
|
||||
j.at("standard_load_time").get_to(p.standard_load_time);
|
||||
j.at("standard_unload_time").get_to(p.standard_unload_time);
|
||||
}
|
||||
|
||||
} // namespace MultiNozzleUtils
|
||||
|
||||
// ============ Helper: set<int> as JSON array ============
|
||||
namespace FGTestDetail {
|
||||
inline json set_to_json(const std::set<int>& s) {
|
||||
return json(std::vector<int>(s.begin(), s.end()));
|
||||
}
|
||||
|
||||
inline std::set<int> json_to_set(const json& j) {
|
||||
auto v = j.get<std::vector<int>>();
|
||||
return std::set<int>(v.begin(), v.end());
|
||||
}
|
||||
|
||||
inline json nvt_set_to_json(const std::set<NozzleVolumeType>& s) {
|
||||
std::vector<int> v;
|
||||
for (auto t : s) v.push_back((int)t);
|
||||
return json(v);
|
||||
}
|
||||
|
||||
inline std::set<NozzleVolumeType> json_to_nvt_set(const json& j) {
|
||||
std::set<NozzleVolumeType> s;
|
||||
for (auto& item : j) s.insert((NozzleVolumeType)item.get<int>());
|
||||
return s;
|
||||
}
|
||||
} // namespace FGTestDetail
|
||||
|
||||
// ============ FilamentGroupContext::ModelInfo ============
|
||||
inline void to_json(json& j, const FilamentGroupContext::ModelInfo& mi) {
|
||||
using namespace FGTestDetail;
|
||||
j["flush_matrix"] = mi.flush_matrix;
|
||||
j["layer_filaments"] = mi.layer_filaments;
|
||||
|
||||
j["filament_info"] = json::array();
|
||||
for (auto& fi : mi.filament_info)
|
||||
j["filament_info"].push_back(fi);
|
||||
|
||||
j["filament_ids"] = mi.filament_ids;
|
||||
|
||||
j["unprintable_filaments"] = json::array();
|
||||
for (auto& s : mi.unprintable_filaments)
|
||||
j["unprintable_filaments"].push_back(set_to_json(s));
|
||||
|
||||
json uv = json::object();
|
||||
for (auto& [fil, types] : mi.unprintable_volumes)
|
||||
uv[std::to_string(fil)] = nvt_set_to_json(types);
|
||||
j["unprintable_volumes"] = uv;
|
||||
}
|
||||
|
||||
inline void from_json(const json& j, FilamentGroupContext::ModelInfo& mi) {
|
||||
using namespace FGTestDetail;
|
||||
j.at("flush_matrix").get_to(mi.flush_matrix);
|
||||
j.at("layer_filaments").get_to(mi.layer_filaments);
|
||||
|
||||
mi.filament_info.clear();
|
||||
for (auto& item : j.at("filament_info"))
|
||||
mi.filament_info.push_back(item.get<FilamentGroupUtils::FilamentInfo>());
|
||||
|
||||
j.at("filament_ids").get_to(mi.filament_ids);
|
||||
|
||||
mi.unprintable_filaments.clear();
|
||||
for (auto& item : j.at("unprintable_filaments"))
|
||||
mi.unprintable_filaments.push_back(json_to_set(item));
|
||||
|
||||
mi.unprintable_volumes.clear();
|
||||
if (j.contains("unprintable_volumes")) {
|
||||
for (auto& [k, v] : j.at("unprintable_volumes").items())
|
||||
mi.unprintable_volumes[std::stoi(k)] = json_to_nvt_set(v);
|
||||
}
|
||||
}
|
||||
|
||||
// ============ FilamentGroupContext::GroupInfo ============
|
||||
inline void to_json(json& j, const FilamentGroupContext::GroupInfo& gi) {
|
||||
j = json{
|
||||
{"total_filament_num", gi.total_filament_num},
|
||||
{"max_gap_threshold", gi.max_gap_threshold},
|
||||
{"mode", (int)gi.mode},
|
||||
{"strategy", (int)gi.strategy},
|
||||
{"ignore_ext_filament", gi.ignore_ext_filament},
|
||||
{"has_filament_switcher", gi.has_filament_switcher},
|
||||
{"filament_volume_map", gi.filament_volume_map}
|
||||
};
|
||||
}
|
||||
|
||||
inline void from_json(const json& j, FilamentGroupContext::GroupInfo& gi) {
|
||||
j.at("total_filament_num").get_to(gi.total_filament_num);
|
||||
j.at("max_gap_threshold").get_to(gi.max_gap_threshold);
|
||||
gi.mode = (FGMode)j.at("mode").get<int>();
|
||||
gi.strategy = (FGStrategy)j.at("strategy").get<int>();
|
||||
j.at("ignore_ext_filament").get_to(gi.ignore_ext_filament);
|
||||
j.at("has_filament_switcher").get_to(gi.has_filament_switcher);
|
||||
j.at("filament_volume_map").get_to(gi.filament_volume_map);
|
||||
}
|
||||
|
||||
// ============ FilamentGroupContext::MachineInfo ============
|
||||
inline void to_json(json& j, const FilamentGroupContext::MachineInfo& mi) {
|
||||
j["max_group_size"] = mi.max_group_size;
|
||||
|
||||
j["machine_filament_info"] = json::array();
|
||||
for (auto& vec : mi.machine_filament_info) {
|
||||
json arr = json::array();
|
||||
for (auto& mfi : vec) arr.push_back(mfi);
|
||||
j["machine_filament_info"].push_back(arr);
|
||||
}
|
||||
|
||||
j["prefer_non_model_filament"] = mi.prefer_non_model_filament;
|
||||
j["master_extruder_id"] = mi.master_extruder_id;
|
||||
}
|
||||
|
||||
inline void from_json(const json& j, FilamentGroupContext::MachineInfo& mi) {
|
||||
j.at("max_group_size").get_to(mi.max_group_size);
|
||||
|
||||
mi.machine_filament_info.clear();
|
||||
for (auto& arr : j.at("machine_filament_info")) {
|
||||
std::vector<FilamentGroupUtils::MachineFilamentInfo> vec;
|
||||
for (auto& item : arr)
|
||||
vec.push_back(item.get<FilamentGroupUtils::MachineFilamentInfo>());
|
||||
mi.machine_filament_info.push_back(std::move(vec));
|
||||
}
|
||||
|
||||
j.at("prefer_non_model_filament").get_to(mi.prefer_non_model_filament);
|
||||
j.at("master_extruder_id").get_to(mi.master_extruder_id);
|
||||
}
|
||||
|
||||
// ============ FilamentGroupContext::SpeedInfo ============
|
||||
inline void to_json(json& j, const FilamentGroupContext::SpeedInfo& si) {
|
||||
json fpt = json::object();
|
||||
for (auto& [fil, inner] : si.filament_print_time) {
|
||||
json inner_j = json::object();
|
||||
for (auto& [layer, time] : inner)
|
||||
inner_j[std::to_string(layer)] = time;
|
||||
fpt[std::to_string(fil)] = inner_j;
|
||||
}
|
||||
j["filament_print_time"] = fpt;
|
||||
j["extruder_change_time"] = si.extruder_change_time;
|
||||
j["filament_change_time"] = si.filament_change_time;
|
||||
j["group_with_time"] = si.group_with_time;
|
||||
j["change_time_params"] = si.change_time_params;
|
||||
j["ams_preload_enabled"] = si.ams_preload_enabled;
|
||||
}
|
||||
|
||||
inline void from_json(const json& j, FilamentGroupContext::SpeedInfo& si) {
|
||||
si.filament_print_time.clear();
|
||||
if (j.contains("filament_print_time")) {
|
||||
for (auto& [k, v] : j.at("filament_print_time").items()) {
|
||||
int fil = std::stoi(k);
|
||||
for (auto& [k2, v2] : v.items())
|
||||
si.filament_print_time[fil][std::stoi(k2)] = v2.get<double>();
|
||||
}
|
||||
}
|
||||
j.at("extruder_change_time").get_to(si.extruder_change_time);
|
||||
j.at("filament_change_time").get_to(si.filament_change_time);
|
||||
j.at("group_with_time").get_to(si.group_with_time);
|
||||
si.change_time_params = j.at("change_time_params").get<MultiNozzleUtils::FilamentChangeTimeParams>();
|
||||
j.at("ams_preload_enabled").get_to(si.ams_preload_enabled);
|
||||
}
|
||||
|
||||
// ============ FilamentGroupContext::NozzleInfo ============
|
||||
inline void to_json(json& j, const FilamentGroupContext::NozzleInfo& ni) {
|
||||
json enl = json::object();
|
||||
for (auto& [ext, nozzles] : ni.extruder_nozzle_list)
|
||||
enl[std::to_string(ext)] = nozzles;
|
||||
j["extruder_nozzle_list"] = enl;
|
||||
|
||||
j["nozzle_list"] = json::array();
|
||||
for (auto& n : ni.nozzle_list)
|
||||
j["nozzle_list"].push_back(n);
|
||||
|
||||
json ns = json::object();
|
||||
for (auto& [noz, fil] : ni.nozzle_status)
|
||||
ns[std::to_string(noz)] = fil;
|
||||
j["nozzle_status"] = ns;
|
||||
}
|
||||
|
||||
inline void from_json(const json& j, FilamentGroupContext::NozzleInfo& ni) {
|
||||
ni.extruder_nozzle_list.clear();
|
||||
for (auto& [k, v] : j.at("extruder_nozzle_list").items())
|
||||
ni.extruder_nozzle_list[std::stoi(k)] = v.get<std::vector<int>>();
|
||||
|
||||
ni.nozzle_list.clear();
|
||||
for (auto& item : j.at("nozzle_list"))
|
||||
ni.nozzle_list.push_back(item.get<MultiNozzleUtils::NozzleInfo>());
|
||||
|
||||
ni.nozzle_status.clear();
|
||||
if (j.contains("nozzle_status")) {
|
||||
for (auto& [k, v] : j.at("nozzle_status").items())
|
||||
ni.nozzle_status[std::stoi(k)] = v.get<int>();
|
||||
}
|
||||
}
|
||||
|
||||
// ============ Full FilamentGroupContext ============
|
||||
inline void to_json(json& j, const FilamentGroupContext& ctx) {
|
||||
json mi, gi, mai, si, ni;
|
||||
to_json(mi, ctx.model_info);
|
||||
to_json(gi, ctx.group_info);
|
||||
to_json(mai, ctx.machine_info);
|
||||
to_json(si, ctx.speed_info);
|
||||
to_json(ni, ctx.nozzle_info);
|
||||
j["model_info"] = mi;
|
||||
j["group_info"] = gi;
|
||||
j["machine_info"] = mai;
|
||||
j["speed_info"] = si;
|
||||
j["nozzle_info"] = ni;
|
||||
}
|
||||
|
||||
inline void from_json(const json& j, FilamentGroupContext& ctx) {
|
||||
from_json(j.at("model_info"), ctx.model_info);
|
||||
from_json(j.at("group_info"), ctx.group_info);
|
||||
from_json(j.at("machine_info"), ctx.machine_info);
|
||||
from_json(j.at("speed_info"), ctx.speed_info);
|
||||
from_json(j.at("nozzle_info"), ctx.nozzle_info);
|
||||
}
|
||||
|
||||
} // namespace Slic3r
|
||||
|
||||
// ============ Test-specific types in FGTest namespace ============
|
||||
namespace Slic3r {
|
||||
namespace FGTest {
|
||||
|
||||
struct TestMetadata {
|
||||
std::string id;
|
||||
std::string config_type;
|
||||
int seed = 0;
|
||||
};
|
||||
|
||||
inline void to_json(json& j, const TestMetadata& m) {
|
||||
j = json{{"id", m.id}, {"config_type", m.config_type}, {"seed", m.seed}};
|
||||
}
|
||||
|
||||
inline void from_json(const json& j, TestMetadata& m) {
|
||||
j.at("id").get_to(m.id);
|
||||
j.at("config_type").get_to(m.config_type);
|
||||
j.at("seed").get_to(m.seed);
|
||||
}
|
||||
|
||||
struct TestResult {
|
||||
std::vector<int> filament_map;
|
||||
int flush_cost = 0;
|
||||
double elapsed_ms = 0;
|
||||
bool constraints_ok = true;
|
||||
std::vector<std::string> violations;
|
||||
};
|
||||
|
||||
inline void to_json(json& j, const TestResult& r) {
|
||||
j = json{
|
||||
{"filament_map", r.filament_map},
|
||||
{"flush_cost", r.flush_cost},
|
||||
{"elapsed_ms", r.elapsed_ms},
|
||||
{"constraints_ok", r.constraints_ok},
|
||||
{"violations", r.violations}
|
||||
};
|
||||
}
|
||||
|
||||
inline void from_json(const json& j, TestResult& r) {
|
||||
j.at("filament_map").get_to(r.filament_map);
|
||||
j.at("flush_cost").get_to(r.flush_cost);
|
||||
j.at("elapsed_ms").get_to(r.elapsed_ms);
|
||||
j.at("constraints_ok").get_to(r.constraints_ok);
|
||||
if (j.contains("violations"))
|
||||
j.at("violations").get_to(r.violations);
|
||||
}
|
||||
|
||||
// ============ Base Result (golden baseline stored in input file) ============
|
||||
struct BaseResult {
|
||||
double full_score = 0;
|
||||
int flush_cost = 0;
|
||||
bool constraints_ok = true;
|
||||
};
|
||||
|
||||
inline void to_json(json& j, const BaseResult& g) {
|
||||
j = json{
|
||||
{"full_score", g.full_score},
|
||||
{"flush_cost", g.flush_cost},
|
||||
{"constraints_ok", g.constraints_ok}
|
||||
};
|
||||
}
|
||||
|
||||
inline void from_json(const json& j, BaseResult& g) {
|
||||
j.at("full_score").get_to(g.full_score);
|
||||
j.at("flush_cost").get_to(g.flush_cost);
|
||||
j.at("constraints_ok").get_to(g.constraints_ok);
|
||||
}
|
||||
|
||||
// ============ File I/O ============
|
||||
struct TestCase {
|
||||
TestMetadata metadata;
|
||||
FilamentGroupContext context;
|
||||
std::optional<BaseResult> base_result;
|
||||
};
|
||||
|
||||
inline TestCase load_test_case(const std::string& path) {
|
||||
std::ifstream f(path);
|
||||
json j = json::parse(f);
|
||||
TestCase tc;
|
||||
tc.metadata = j.at("metadata").get<TestMetadata>();
|
||||
Slic3r::from_json(j.at("context"), tc.context);
|
||||
if (j.contains("base_result"))
|
||||
tc.base_result = j.at("base_result").get<BaseResult>();
|
||||
return tc;
|
||||
}
|
||||
|
||||
inline void save_test_case(const std::string& path, const TestCase& tc) {
|
||||
json j;
|
||||
j["metadata"] = tc.metadata;
|
||||
json ctx_j;
|
||||
Slic3r::to_json(ctx_j, tc.context);
|
||||
j["context"] = ctx_j;
|
||||
if (tc.base_result)
|
||||
j["base_result"] = *tc.base_result;
|
||||
std::ofstream f(path);
|
||||
f << j.dump(-1);
|
||||
}
|
||||
|
||||
inline void save_result(const std::string& case_path, const TestResult& result) {
|
||||
std::string result_path = case_path;
|
||||
auto pos = result_path.rfind(".json");
|
||||
if (pos != std::string::npos)
|
||||
result_path = result_path.substr(0, pos) + ".result.json";
|
||||
else
|
||||
result_path += ".result.json";
|
||||
|
||||
json j = result;
|
||||
std::ofstream f(result_path);
|
||||
f << j.dump(2);
|
||||
}
|
||||
|
||||
inline TestResult load_result(const std::string& result_path) {
|
||||
std::ifstream f(result_path);
|
||||
json j = json::parse(f);
|
||||
return j.get<TestResult>();
|
||||
}
|
||||
|
||||
} // namespace FGTest
|
||||
} // namespace Slic3r
|
||||
|
||||
#endif // FG_TEST_SERIALIZATION_HPP
|
||||
406
tests/filament_group/fg_test_utils.hpp
Normal file
406
tests/filament_group/fg_test_utils.hpp
Normal file
@@ -0,0 +1,406 @@
|
||||
#ifndef FG_TEST_UTILS_HPP
|
||||
#define FG_TEST_UTILS_HPP
|
||||
|
||||
#include "fg_test_serialization.hpp"
|
||||
#include <random>
|
||||
#include <algorithm>
|
||||
#include <cassert>
|
||||
|
||||
namespace Slic3r {
|
||||
namespace FGTest {
|
||||
|
||||
class TestRng {
|
||||
public:
|
||||
explicit TestRng(int seed) : m_gen(seed) {}
|
||||
|
||||
int rand_int(int lo, int hi) {
|
||||
std::uniform_int_distribution<int> dist(lo, hi);
|
||||
return dist(m_gen);
|
||||
}
|
||||
|
||||
float rand_float(float lo, float hi) {
|
||||
std::uniform_real_distribution<float> dist(lo, hi);
|
||||
return dist(m_gen);
|
||||
}
|
||||
|
||||
double rand_double(double lo, double hi) {
|
||||
std::uniform_real_distribution<double> dist(lo, hi);
|
||||
return dist(m_gen);
|
||||
}
|
||||
|
||||
bool rand_bool(double prob = 0.5) {
|
||||
return rand_double(0, 1) < prob;
|
||||
}
|
||||
|
||||
template<typename T>
|
||||
void shuffle(std::vector<T>& v) {
|
||||
std::shuffle(v.begin(), v.end(), m_gen);
|
||||
}
|
||||
|
||||
private:
|
||||
std::mt19937 m_gen;
|
||||
};
|
||||
|
||||
// Generate a flush matrix for one extruder: [filament_count x filament_count]
|
||||
inline std::vector<std::vector<float>> generate_flush_matrix(int filament_count, TestRng& rng) {
|
||||
std::vector<std::vector<float>> matrix(filament_count, std::vector<float>(filament_count, 0.0f));
|
||||
for (int i = 0; i < filament_count; ++i) {
|
||||
for (int j = 0; j < filament_count; ++j) {
|
||||
if (i == j)
|
||||
matrix[i][j] = 0.0f;
|
||||
else
|
||||
matrix[i][j] = rng.rand_float(10.0f, 600.0f);
|
||||
}
|
||||
}
|
||||
return matrix;
|
||||
}
|
||||
|
||||
// Generate layer_filaments with interval characteristics
|
||||
inline std::vector<std::vector<unsigned int>> generate_layer_filaments_interval(
|
||||
int num_layers, int total_filaments, const std::vector<unsigned int>& used_filaments, TestRng& rng)
|
||||
{
|
||||
std::vector<std::vector<unsigned int>> layers;
|
||||
layers.reserve(num_layers);
|
||||
|
||||
int n_used = (int)used_filaments.size();
|
||||
int fils_per_layer_min = std::min(2, n_used);
|
||||
int fils_per_layer_max = std::min(n_used, std::max(2, n_used / 2 + 1));
|
||||
|
||||
// First layer: random subset
|
||||
int first_count = rng.rand_int(fils_per_layer_min, fils_per_layer_max);
|
||||
std::vector<unsigned int> pool = used_filaments;
|
||||
rng.shuffle(pool);
|
||||
std::vector<unsigned int> current(pool.begin(), pool.begin() + first_count);
|
||||
std::sort(current.begin(), current.end());
|
||||
layers.push_back(current);
|
||||
|
||||
for (int layer = 1; layer < num_layers; ++layer) {
|
||||
// 10% chance: completely random new set (object boundary)
|
||||
if (rng.rand_bool(0.10)) {
|
||||
int count = rng.rand_int(fils_per_layer_min, fils_per_layer_max);
|
||||
pool = used_filaments;
|
||||
rng.shuffle(pool);
|
||||
current.assign(pool.begin(), pool.begin() + count);
|
||||
} else {
|
||||
// Markov: keep each filament with 70% prob, maybe add new ones
|
||||
std::vector<unsigned int> next;
|
||||
for (auto f : current) {
|
||||
if (rng.rand_bool(0.70))
|
||||
next.push_back(f);
|
||||
}
|
||||
// Maybe add a filament not in current
|
||||
if (rng.rand_bool(0.30) || next.empty()) {
|
||||
std::vector<unsigned int> candidates;
|
||||
std::set<unsigned int> cur_set(next.begin(), next.end());
|
||||
for (auto f : used_filaments) {
|
||||
if (!cur_set.count(f))
|
||||
candidates.push_back(f);
|
||||
}
|
||||
if (!candidates.empty()) {
|
||||
next.push_back(candidates[rng.rand_int(0, (int)candidates.size() - 1)]);
|
||||
}
|
||||
}
|
||||
if (next.empty())
|
||||
next.push_back(used_filaments[rng.rand_int(0, n_used - 1)]);
|
||||
current = next;
|
||||
}
|
||||
std::sort(current.begin(), current.end());
|
||||
current.erase(std::unique(current.begin(), current.end()), current.end());
|
||||
layers.push_back(current);
|
||||
}
|
||||
|
||||
return layers;
|
||||
}
|
||||
|
||||
// Generate layer_filaments where every layer is different (stress/edge)
|
||||
inline std::vector<std::vector<unsigned int>> generate_layer_filaments_chaotic(
|
||||
int num_layers, int total_filaments, const std::vector<unsigned int>& used_filaments, TestRng& rng)
|
||||
{
|
||||
std::vector<std::vector<unsigned int>> layers;
|
||||
int n_used = (int)used_filaments.size();
|
||||
int fils_per_layer_min = std::min(2, n_used);
|
||||
int fils_per_layer_max = n_used;
|
||||
|
||||
for (int layer = 0; layer < num_layers; ++layer) {
|
||||
int count = rng.rand_int(fils_per_layer_min, fils_per_layer_max);
|
||||
std::vector<unsigned int> pool = used_filaments;
|
||||
rng.shuffle(pool);
|
||||
std::vector<unsigned int> current(pool.begin(), pool.begin() + count);
|
||||
std::sort(current.begin(), current.end());
|
||||
layers.push_back(current);
|
||||
}
|
||||
return layers;
|
||||
}
|
||||
|
||||
// Generate layer_filaments where all layers are the same (edge)
|
||||
inline std::vector<std::vector<unsigned int>> generate_layer_filaments_uniform(
|
||||
int num_layers, const std::vector<unsigned int>& used_filaments)
|
||||
{
|
||||
return std::vector<std::vector<unsigned int>>(num_layers, used_filaments);
|
||||
}
|
||||
|
||||
// Generate filament info
|
||||
inline std::vector<FilamentGroupUtils::FilamentInfo> generate_filament_info(int count, TestRng& rng) {
|
||||
static const char* types[] = {"PLA", "ABS", "PETG", "TPU", "PA", "PLA-S"};
|
||||
std::vector<FilamentGroupUtils::FilamentInfo> infos;
|
||||
for (int i = 0; i < count; ++i) {
|
||||
FilamentGroupUtils::FilamentInfo fi;
|
||||
fi.color = FilamentGroupUtils::Color(
|
||||
(unsigned char)rng.rand_int(0, 255),
|
||||
(unsigned char)rng.rand_int(0, 255),
|
||||
(unsigned char)rng.rand_int(0, 255));
|
||||
fi.type = types[rng.rand_int(0, 5)];
|
||||
fi.is_support = (fi.type == "PLA-S");
|
||||
fi.usage_type = fi.is_support ? FilamentUsageType::SupportOnly : FilamentUsageType::ModelOnly;
|
||||
infos.push_back(fi);
|
||||
}
|
||||
return infos;
|
||||
}
|
||||
|
||||
// Generate machine filament info (per extruder)
|
||||
inline std::vector<std::vector<FilamentGroupUtils::MachineFilamentInfo>> generate_machine_filament_info(
|
||||
int num_extruders, int filaments_per_extruder, TestRng& rng)
|
||||
{
|
||||
std::vector<std::vector<FilamentGroupUtils::MachineFilamentInfo>> result;
|
||||
for (int ext = 0; ext < num_extruders; ++ext) {
|
||||
std::vector<FilamentGroupUtils::MachineFilamentInfo> vec;
|
||||
for (int i = 0; i < filaments_per_extruder; ++i) {
|
||||
FilamentGroupUtils::MachineFilamentInfo mfi;
|
||||
mfi.color = FilamentGroupUtils::Color(
|
||||
(unsigned char)rng.rand_int(0, 255),
|
||||
(unsigned char)rng.rand_int(0, 255),
|
||||
(unsigned char)rng.rand_int(0, 255));
|
||||
mfi.type = "PLA";
|
||||
mfi.is_support = false;
|
||||
mfi.usage_type = FilamentUsageType::ModelOnly;
|
||||
mfi.extruder_id = ext;
|
||||
mfi.is_extended = (i >= 4);
|
||||
vec.push_back(mfi);
|
||||
}
|
||||
result.push_back(vec);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ============ Machine Config Builders ============
|
||||
|
||||
// Config A: 2 extruders, 1 nozzle each
|
||||
inline void build_config_a(FilamentGroupContext& ctx, int num_filaments, TestRng& rng) {
|
||||
auto& ni = ctx.nozzle_info;
|
||||
ni.nozzle_list.clear();
|
||||
ni.nozzle_list.push_back({"0.4", NozzleVolumeType::nvtStandard, 0, 0});
|
||||
ni.nozzle_list.push_back({"0.4", NozzleVolumeType::nvtStandard, 1, 1});
|
||||
ni.extruder_nozzle_list = {{0, {0}}, {1, {1}}};
|
||||
|
||||
ctx.machine_info.max_group_size = {num_filaments / 2 + 1, num_filaments / 2 + 1};
|
||||
ctx.machine_info.prefer_non_model_filament = {false, true};
|
||||
ctx.machine_info.master_extruder_id = 0;
|
||||
ctx.machine_info.machine_filament_info = generate_machine_filament_info(2, 4, rng);
|
||||
|
||||
ctx.group_info.filament_volume_map.assign(num_filaments, (int)NozzleVolumeType::nvtHybrid);
|
||||
|
||||
ctx.model_info.unprintable_filaments.resize(2);
|
||||
ctx.model_info.flush_matrix.resize(2);
|
||||
for (int ext = 0; ext < 2; ++ext)
|
||||
ctx.model_info.flush_matrix[ext] = generate_flush_matrix(num_filaments, rng);
|
||||
}
|
||||
|
||||
// Config B: 2 extruders, ext0 has 1 nozzle, ext1 has K nozzles (K in [2,6])
|
||||
inline void build_config_b(FilamentGroupContext& ctx, int num_filaments, int k_nozzles, TestRng& rng) {
|
||||
auto& ni = ctx.nozzle_info;
|
||||
ni.nozzle_list.clear();
|
||||
ni.nozzle_list.push_back({"0.4", NozzleVolumeType::nvtStandard, 0, 0});
|
||||
|
||||
static const NozzleVolumeType vol_types[] = {
|
||||
NozzleVolumeType::nvtStandard, NozzleVolumeType::nvtHighFlow, NozzleVolumeType::nvtTPUHighFlow};
|
||||
|
||||
std::vector<int> ext1_nozzles;
|
||||
for (int i = 0; i < k_nozzles; ++i) {
|
||||
int group_id = i + 1;
|
||||
NozzleVolumeType vt = vol_types[rng.rand_int(0, 2)];
|
||||
ni.nozzle_list.push_back({"0.4", vt, 1, group_id});
|
||||
ext1_nozzles.push_back(group_id);
|
||||
}
|
||||
ni.extruder_nozzle_list = {{0, {0}}, {1, ext1_nozzles}};
|
||||
|
||||
int ext0_max = std::max(4, num_filaments / 2 + 1);
|
||||
int ext1_max = std::max(k_nozzles * 2, num_filaments - ext0_max + 1);
|
||||
ctx.machine_info.max_group_size = {ext0_max, ext1_max};
|
||||
ctx.machine_info.prefer_non_model_filament = {false, false};
|
||||
ctx.machine_info.master_extruder_id = 0;
|
||||
ctx.machine_info.machine_filament_info = generate_machine_filament_info(2, 4, rng);
|
||||
|
||||
ctx.group_info.filament_volume_map.assign(num_filaments, (int)NozzleVolumeType::nvtHybrid);
|
||||
|
||||
ctx.model_info.unprintable_filaments.resize(2);
|
||||
ctx.model_info.flush_matrix.resize(2);
|
||||
for (int ext = 0; ext < 2; ++ext)
|
||||
ctx.model_info.flush_matrix[ext] = generate_flush_matrix(num_filaments, rng);
|
||||
}
|
||||
|
||||
// Config C: 1 extruder, K nozzles (K in [3,9])
|
||||
inline void build_config_c(FilamentGroupContext& ctx, int num_filaments, int k_nozzles, TestRng& rng) {
|
||||
auto& ni = ctx.nozzle_info;
|
||||
ni.nozzle_list.clear();
|
||||
|
||||
static const NozzleVolumeType vol_types[] = {
|
||||
NozzleVolumeType::nvtStandard, NozzleVolumeType::nvtHighFlow,
|
||||
NozzleVolumeType::nvtHybrid, NozzleVolumeType::nvtTPUHighFlow};
|
||||
|
||||
std::vector<int> nozzle_ids;
|
||||
for (int i = 0; i < k_nozzles; ++i) {
|
||||
NozzleVolumeType vt = vol_types[i % 4];
|
||||
ni.nozzle_list.push_back({"0.4", vt, 0, i});
|
||||
nozzle_ids.push_back(i);
|
||||
}
|
||||
ni.extruder_nozzle_list = {{0, nozzle_ids}};
|
||||
|
||||
ctx.machine_info.max_group_size = {num_filaments};
|
||||
ctx.machine_info.prefer_non_model_filament = {false};
|
||||
ctx.machine_info.master_extruder_id = 0;
|
||||
ctx.machine_info.machine_filament_info = generate_machine_filament_info(1, 4, rng);
|
||||
|
||||
ctx.group_info.filament_volume_map.assign(num_filaments, (int)NozzleVolumeType::nvtHybrid);
|
||||
|
||||
ctx.model_info.unprintable_filaments.resize(1);
|
||||
ctx.model_info.flush_matrix.resize(1);
|
||||
ctx.model_info.flush_matrix[0] = generate_flush_matrix(num_filaments, rng);
|
||||
}
|
||||
|
||||
// ============ Constraint Injection ============
|
||||
|
||||
// Add unprintable_filaments constraints (some filaments forbidden on some extruders)
|
||||
inline void inject_unprintable_constraints(FilamentGroupContext& ctx,
|
||||
const std::vector<unsigned int>& used_filaments,
|
||||
TestRng& rng, int num_constraints) {
|
||||
int num_ext = (int)ctx.model_info.unprintable_filaments.size();
|
||||
for (int i = 0; i < num_constraints && !used_filaments.empty(); ++i) {
|
||||
int fil = used_filaments[rng.rand_int(0, (int)used_filaments.size() - 1)];
|
||||
int ext = rng.rand_int(0, num_ext - 1);
|
||||
ctx.model_info.unprintable_filaments[ext].insert(fil);
|
||||
}
|
||||
// Ensure no filament is banned from ALL extruders
|
||||
for (auto fil : used_filaments) {
|
||||
bool can_print_somewhere = false;
|
||||
for (int ext = 0; ext < num_ext; ++ext) {
|
||||
if (!ctx.model_info.unprintable_filaments[ext].count(fil)) {
|
||||
can_print_somewhere = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!can_print_somewhere) {
|
||||
int ext_to_allow = rng.rand_int(0, num_ext - 1);
|
||||
ctx.model_info.unprintable_filaments[ext_to_allow].erase(fil);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add unprintable_volumes constraints
|
||||
inline void inject_volume_constraints(FilamentGroupContext& ctx,
|
||||
const std::vector<unsigned int>& used_filaments,
|
||||
TestRng& rng, int num_constraints) {
|
||||
static const NozzleVolumeType vols[] = {
|
||||
NozzleVolumeType::nvtStandard, NozzleVolumeType::nvtHighFlow,
|
||||
NozzleVolumeType::nvtTPUHighFlow};
|
||||
|
||||
for (int i = 0; i < num_constraints && !used_filaments.empty(); ++i) {
|
||||
int fil = used_filaments[rng.rand_int(0, (int)used_filaments.size() - 1)];
|
||||
NozzleVolumeType vt = vols[rng.rand_int(0, 2)];
|
||||
ctx.model_info.unprintable_volumes[fil].insert(vt);
|
||||
}
|
||||
// Ensure no filament is banned from ALL nozzle volume types present
|
||||
for (auto fil : used_filaments) {
|
||||
if (!ctx.model_info.unprintable_volumes.count(fil))
|
||||
continue;
|
||||
auto& banned = ctx.model_info.unprintable_volumes[fil];
|
||||
bool can_go_somewhere = false;
|
||||
for (auto& noz : ctx.nozzle_info.nozzle_list) {
|
||||
if (!banned.count(noz.volume_type)) {
|
||||
can_go_somewhere = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!can_go_somewhere && !banned.empty()) {
|
||||
// Remove one random ban
|
||||
auto it = banned.begin();
|
||||
std::advance(it, rng.rand_int(0, (int)banned.size() - 1));
|
||||
banned.erase(it);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============ Full Case Builder ============
|
||||
|
||||
inline TestCase build_test_case(const std::string& id, const std::string& config_type,
|
||||
int seed, int num_filaments, int num_layers,
|
||||
bool chaotic_layers, bool with_constraints,
|
||||
FGMode mode, FGStrategy strategy, bool group_with_time) {
|
||||
TestRng rng(seed);
|
||||
TestCase tc;
|
||||
tc.metadata.id = id;
|
||||
tc.metadata.config_type = config_type;
|
||||
tc.metadata.seed = seed;
|
||||
|
||||
auto& ctx = tc.context;
|
||||
|
||||
// Used filaments: 0-based indices
|
||||
std::vector<unsigned int> used_filaments;
|
||||
for (int i = 0; i < num_filaments; ++i)
|
||||
used_filaments.push_back((unsigned int)i);
|
||||
|
||||
// Build machine config
|
||||
if (config_type == "A") {
|
||||
build_config_a(ctx, num_filaments, rng);
|
||||
} else if (config_type == "B") {
|
||||
int k = rng.rand_int(2, 6);
|
||||
build_config_b(ctx, num_filaments, k, rng);
|
||||
} else {
|
||||
int k = rng.rand_int(3, 9);
|
||||
build_config_c(ctx, num_filaments, k, rng);
|
||||
}
|
||||
|
||||
// Layer filaments
|
||||
if (chaotic_layers)
|
||||
ctx.model_info.layer_filaments = generate_layer_filaments_chaotic(num_layers, num_filaments, used_filaments, rng);
|
||||
else
|
||||
ctx.model_info.layer_filaments = generate_layer_filaments_interval(num_layers, num_filaments, used_filaments, rng);
|
||||
|
||||
// Filament info
|
||||
ctx.model_info.filament_info = generate_filament_info(num_filaments, rng);
|
||||
ctx.model_info.filament_ids.resize(num_filaments);
|
||||
for (int i = 0; i < num_filaments; ++i)
|
||||
ctx.model_info.filament_ids[i] = "GFL_" + std::to_string(i);
|
||||
|
||||
// Group info
|
||||
ctx.group_info.total_filament_num = num_filaments;
|
||||
ctx.group_info.max_gap_threshold = 0.01;
|
||||
ctx.group_info.mode = mode;
|
||||
ctx.group_info.strategy = strategy;
|
||||
ctx.group_info.ignore_ext_filament = false;
|
||||
ctx.group_info.has_filament_switcher = false;
|
||||
|
||||
// Speed info
|
||||
ctx.speed_info.extruder_change_time = 5.0;
|
||||
ctx.speed_info.filament_change_time = 2.0;
|
||||
ctx.speed_info.group_with_time = group_with_time;
|
||||
ctx.speed_info.change_time_params = {1.0f, 1.0f, 3.0f, 2.0f};
|
||||
int num_ext = (config_type == "C") ? 1 : 2;
|
||||
ctx.speed_info.ams_preload_enabled.assign(num_ext, true);
|
||||
|
||||
// Constraints
|
||||
if (with_constraints) {
|
||||
inject_unprintable_constraints(ctx, used_filaments, rng, rng.rand_int(1, num_filaments / 2));
|
||||
if (config_type != "A")
|
||||
inject_volume_constraints(ctx, used_filaments, rng, rng.rand_int(1, 3));
|
||||
}
|
||||
|
||||
// Nozzle status (initially empty)
|
||||
ctx.nozzle_info.nozzle_status.clear();
|
||||
|
||||
return tc;
|
||||
}
|
||||
|
||||
} // namespace FGTest
|
||||
} // namespace Slic3r
|
||||
|
||||
#endif // FG_TEST_UTILS_HPP
|
||||
330
tests/filament_group/filament_group_regression_main.cpp
Normal file
330
tests/filament_group/filament_group_regression_main.cpp
Normal file
@@ -0,0 +1,330 @@
|
||||
// H2C/A2L FilamentGroup golden regression harness.
|
||||
//
|
||||
// Notes:
|
||||
// * Orca: links Catch2::Catch2WithMain and uses the v3 convenience include <catch2/catch_all.hpp>.
|
||||
// * All three golden families (config_a one-nozzle-per-extruder, config_b/config_c nozzle-centric)
|
||||
// are evaluated against the goldens. The nozzle-centric FilamentGroup engine and solver layer run
|
||||
// the same algorithm the goldens were generated with, scored via the nozzle-aware reorder
|
||||
// (fg_test_evaluator.hpp) at a 3% one-directional tolerance.
|
||||
// * The hidden [update-golden] utility is intentionally omitted: the goldens are the reference
|
||||
// and must not be rewritten from Orca output.
|
||||
|
||||
#include <catch2/catch_all.hpp>
|
||||
|
||||
#include "fg_test_serialization.hpp"
|
||||
#include "fg_test_evaluator.hpp"
|
||||
#include "fg_test_utils.hpp"
|
||||
|
||||
#include <filesystem>
|
||||
#include <iostream>
|
||||
#include <fstream>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <algorithm>
|
||||
#include <numeric>
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
using namespace Slic3r;
|
||||
using namespace Slic3r::FGTest;
|
||||
|
||||
// ============ Helpers ============
|
||||
|
||||
static std::vector<std::string> collect_test_files(const std::string& dir) {
|
||||
std::vector<std::string> files;
|
||||
if (!fs::exists(dir)) return files;
|
||||
for (auto& entry : fs::recursive_directory_iterator(dir)) {
|
||||
if (entry.path().extension() == ".json" &&
|
||||
entry.path().string().find(".result.") == std::string::npos) {
|
||||
files.push_back(entry.path().string());
|
||||
}
|
||||
}
|
||||
std::sort(files.begin(), files.end());
|
||||
return files;
|
||||
}
|
||||
|
||||
static std::vector<std::string> get_golden_files() {
|
||||
static std::vector<std::string> files = collect_test_files(FG_TEST_GOLDEN_DIR);
|
||||
return files;
|
||||
}
|
||||
|
||||
static bool is_constraint_feasible(const FilamentGroupContext& ctx,
|
||||
const std::vector<unsigned int>& used_filaments) {
|
||||
int total_capacity = 0;
|
||||
for (auto sz : ctx.machine_info.max_group_size)
|
||||
total_capacity += sz;
|
||||
if (total_capacity < (int)used_filaments.size())
|
||||
return false;
|
||||
|
||||
// Check that every filament has at least one valid nozzle
|
||||
for (auto fil : used_filaments) {
|
||||
bool has_valid_nozzle = false;
|
||||
for (size_t nid = 0; nid < ctx.nozzle_info.nozzle_list.size(); ++nid) {
|
||||
auto& nozzle = ctx.nozzle_info.nozzle_list[nid];
|
||||
// Check unprintable_filaments
|
||||
if (nozzle.extruder_id >= 0 && nozzle.extruder_id < (int)ctx.model_info.unprintable_filaments.size()) {
|
||||
if (ctx.model_info.unprintable_filaments[nozzle.extruder_id].count(fil))
|
||||
continue;
|
||||
}
|
||||
// Check unprintable_volumes
|
||||
if (ctx.model_info.unprintable_volumes.count(fil)) {
|
||||
if (ctx.model_info.unprintable_volumes.at(fil).count(nozzle.volume_type))
|
||||
continue;
|
||||
}
|
||||
has_valid_nozzle = true;
|
||||
break;
|
||||
}
|
||||
if (!has_valid_nozzle)
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ============ Property Check Specs ============
|
||||
|
||||
struct PropertySpec {
|
||||
std::string id;
|
||||
std::string config;
|
||||
int seed;
|
||||
int num_filaments;
|
||||
int num_layers;
|
||||
bool chaotic;
|
||||
bool with_constraints;
|
||||
FGMode mode;
|
||||
FGStrategy strategy;
|
||||
bool group_with_time;
|
||||
};
|
||||
|
||||
static std::vector<PropertySpec> build_property_specs() {
|
||||
std::vector<PropertySpec> specs;
|
||||
|
||||
// Config A: 20 cases
|
||||
for (int i = 0; i < 6; ++i) {
|
||||
int seed = 90000 + i;
|
||||
TestRng rng(seed);
|
||||
specs.push_back({"prop_a_basic_" + std::to_string(i), "A", seed,
|
||||
rng.rand_int(2, 6), rng.rand_int(100, 400),
|
||||
false, false, FGMode::FlushMode, FGStrategy::BestCost, false});
|
||||
}
|
||||
for (int i = 0; i < 4; ++i) {
|
||||
int seed = 90100 + i;
|
||||
TestRng rng(seed);
|
||||
specs.push_back({"prop_a_stress_" + std::to_string(i), "A", seed,
|
||||
rng.rand_int(7, 10), rng.rand_int(500, 1000),
|
||||
false, false, FGMode::FlushMode, FGStrategy::BestCost, false});
|
||||
}
|
||||
for (int i = 0; i < 4; ++i) {
|
||||
int seed = 90200 + i;
|
||||
TestRng rng(seed);
|
||||
specs.push_back({"prop_a_constraint_" + std::to_string(i), "A", seed,
|
||||
rng.rand_int(3, 8), rng.rand_int(100, 400),
|
||||
false, true, FGMode::FlushMode, FGStrategy::BestCost, false});
|
||||
}
|
||||
for (int i = 0; i < 3; ++i) {
|
||||
int seed = 90300 + i;
|
||||
TestRng rng(seed);
|
||||
specs.push_back({"prop_a_edge_" + std::to_string(i), "A", seed,
|
||||
rng.rand_int(2, 3), rng.rand_int(10, 50),
|
||||
true, false, FGMode::FlushMode, FGStrategy::BestCost, false});
|
||||
}
|
||||
specs.push_back({"prop_a_mode_match", "A", 90400,
|
||||
5, 200, false, false, FGMode::MatchMode, FGStrategy::BestCost, false});
|
||||
specs.push_back({"prop_a_mode_bestfit", "A", 90401,
|
||||
5, 200, false, false, FGMode::FlushMode, FGStrategy::BestFit, false});
|
||||
specs.push_back({"prop_a_mode_time", "A", 90402,
|
||||
5, 200, false, false, FGMode::FlushMode, FGStrategy::BestCost, true});
|
||||
|
||||
// Config B: 25 cases
|
||||
for (int i = 0; i < 6; ++i) {
|
||||
int seed = 91000 + i;
|
||||
TestRng rng(seed);
|
||||
specs.push_back({"prop_b_basic_" + std::to_string(i), "B", seed,
|
||||
rng.rand_int(3, 8), rng.rand_int(100, 400),
|
||||
false, false, FGMode::FlushMode, FGStrategy::BestCost, false});
|
||||
}
|
||||
for (int i = 0; i < 6; ++i) {
|
||||
int seed = 91100 + i;
|
||||
TestRng rng(seed);
|
||||
specs.push_back({"prop_b_stress_" + std::to_string(i), "B", seed,
|
||||
rng.rand_int(9, 12), rng.rand_int(500, 1000),
|
||||
false, false, FGMode::FlushMode, FGStrategy::BestCost, false});
|
||||
}
|
||||
for (int i = 0; i < 7; ++i) {
|
||||
int seed = 91200 + i;
|
||||
TestRng rng(seed);
|
||||
specs.push_back({"prop_b_constraint_" + std::to_string(i), "B", seed,
|
||||
rng.rand_int(4, 10), rng.rand_int(100, 400),
|
||||
false, true, FGMode::FlushMode, FGStrategy::BestCost, false});
|
||||
}
|
||||
for (int i = 0; i < 3; ++i) {
|
||||
int seed = 91300 + i;
|
||||
TestRng rng(seed);
|
||||
specs.push_back({"prop_b_edge_" + std::to_string(i), "B", seed,
|
||||
rng.rand_int(2, 4), rng.rand_int(10, 50),
|
||||
true, false, FGMode::FlushMode, FGStrategy::BestCost, false});
|
||||
}
|
||||
specs.push_back({"prop_b_mode_match", "B", 91400,
|
||||
6, 200, false, false, FGMode::MatchMode, FGStrategy::BestCost, false});
|
||||
specs.push_back({"prop_b_mode_bestfit", "B", 91401,
|
||||
6, 200, false, false, FGMode::FlushMode, FGStrategy::BestFit, false});
|
||||
specs.push_back({"prop_b_mode_time", "B", 91402,
|
||||
6, 200, false, false, FGMode::FlushMode, FGStrategy::BestCost, true});
|
||||
|
||||
// Config C: 15 cases
|
||||
for (int i = 0; i < 5; ++i) {
|
||||
int seed = 92000 + i;
|
||||
TestRng rng(seed);
|
||||
specs.push_back({"prop_c_basic_" + std::to_string(i), "C", seed,
|
||||
rng.rand_int(3, 9), rng.rand_int(100, 400),
|
||||
false, false, FGMode::FlushMode, FGStrategy::BestCost, false});
|
||||
}
|
||||
for (int i = 0; i < 3; ++i) {
|
||||
int seed = 92100 + i;
|
||||
TestRng rng(seed);
|
||||
specs.push_back({"prop_c_stress_" + std::to_string(i), "C", seed,
|
||||
rng.rand_int(10, 15), rng.rand_int(500, 1000),
|
||||
false, false, FGMode::FlushMode, FGStrategy::BestCost, false});
|
||||
}
|
||||
for (int i = 0; i < 3; ++i) {
|
||||
int seed = 92200 + i;
|
||||
TestRng rng(seed);
|
||||
specs.push_back({"prop_c_constraint_" + std::to_string(i), "C", seed,
|
||||
rng.rand_int(4, 9), rng.rand_int(100, 400),
|
||||
false, true, FGMode::FlushMode, FGStrategy::BestCost, false});
|
||||
}
|
||||
for (int i = 0; i < 2; ++i) {
|
||||
int seed = 92300 + i;
|
||||
TestRng rng(seed);
|
||||
specs.push_back({"prop_c_edge_" + std::to_string(i), "C", seed,
|
||||
rng.rand_int(2, 4), rng.rand_int(10, 50),
|
||||
true, false, FGMode::FlushMode, FGStrategy::BestCost, false});
|
||||
}
|
||||
specs.push_back({"prop_c_mode_match", "C", 92400,
|
||||
6, 200, false, false, FGMode::MatchMode, FGStrategy::BestCost, false});
|
||||
specs.push_back({"prop_c_mode_bestfit", "C", 92401,
|
||||
6, 200, false, false, FGMode::FlushMode, FGStrategy::BestFit, false});
|
||||
|
||||
return specs;
|
||||
}
|
||||
|
||||
static std::vector<PropertySpec>& get_property_specs() {
|
||||
static std::vector<PropertySpec> specs = build_property_specs();
|
||||
return specs;
|
||||
}
|
||||
|
||||
// Under the default wall clock the result depends on how fast the machine is (see ClusteringBudget),
|
||||
// so the goldens are graded under a fixed budget instead. Two restarts is the fewest that reaches
|
||||
// parity with the reference on every golden, stress_79 being the last to get there. Four leaves
|
||||
// margin, since the search follows a different path on each standard library (see below).
|
||||
static constexpr ClusteringBudget FIXED_SEARCH_BUDGET{
|
||||
/*timeout_ms*/ 0, // no wall clock
|
||||
/*max_restarts*/ 4};
|
||||
|
||||
// ============ Layer 1: Golden Regression (all configs) ============
|
||||
|
||||
// Graded against the BambuStudio golden the harness was ported from, one-directional at 3%.
|
||||
//
|
||||
// The tolerance is a parity allowance, and it also covers a small spread across standard libraries.
|
||||
// The k-medoids search seeds each restart with std::shuffle, whose algorithm the C++ standard leaves
|
||||
// unspecified, so libstdc++, libc++ and the MSVC STL permute the same seed differently, start from
|
||||
// different medoids, and settle on slightly different groupings, about 3e-4 apart on either side of
|
||||
// the reference, and only on the goldens heavy enough to reach the k-medoids search.
|
||||
TEST_CASE("FilamentGroup golden regression", "[filament_group][golden]") {
|
||||
auto files = get_golden_files();
|
||||
if (files.empty()) {
|
||||
WARN("No golden files found in " FG_TEST_GOLDEN_DIR);
|
||||
REQUIRE(!files.empty());
|
||||
return;
|
||||
}
|
||||
|
||||
auto file_path = GENERATE_REF(from_range(files));
|
||||
|
||||
DYNAMIC_SECTION("Golden: " << fs::path(file_path).stem().string()) {
|
||||
auto tc = load_test_case(file_path);
|
||||
REQUIRE(tc.base_result.has_value());
|
||||
|
||||
auto result = run_and_evaluate(tc.context, FIXED_SEARCH_BUDGET);
|
||||
auto eval = full_evaluate_map(tc.context, result.filament_map);
|
||||
|
||||
auto& base = *tc.base_result;
|
||||
|
||||
INFO("Case: " << tc.metadata.id);
|
||||
INFO("Golden score: " << base.full_score);
|
||||
INFO("Actual score: " << eval.full_score);
|
||||
INFO("Flush cost: " << eval.flush_cost << " (golden " << base.flush_cost << ")");
|
||||
INFO("Elapsed: " << result.elapsed_ms << " ms");
|
||||
|
||||
int tolerance = std::max(50, (int)(base.full_score * 0.03));
|
||||
|
||||
REQUIRE(result.constraints_ok);
|
||||
REQUIRE(eval.full_score <= base.full_score + tolerance);
|
||||
|
||||
// A slower search still scores the same above, since it searches just as far, but in slicing
|
||||
// it would mean fewer restarts fit in the wall clock and so worse groupings. Loose on
|
||||
// purpose, so it never becomes a proxy for how loaded the runner is.
|
||||
const double throughput_ceiling_ms = 10.0 * ClusteringBudget{}.timeout_ms;
|
||||
REQUIRE(result.elapsed_ms < throughput_ceiling_ms);
|
||||
}
|
||||
}
|
||||
|
||||
// Covers the path real slicing takes, under the default wall clock. The score there depends on the
|
||||
// runner rather than on the code (see FIXED_SEARCH_BUDGET), so the only things worth asserting are
|
||||
// that the grouping comes back valid and that the search terminates.
|
||||
TEST_CASE("FilamentGroup returns a valid grouping under the default budget", "[filament_group][budget]") {
|
||||
auto files = get_golden_files();
|
||||
REQUIRE(!files.empty());
|
||||
|
||||
auto file_path = GENERATE_REF(from_range(files));
|
||||
|
||||
DYNAMIC_SECTION("Golden: " << fs::path(file_path).stem().string()) {
|
||||
auto tc = load_test_case(file_path);
|
||||
|
||||
auto result = run_and_evaluate(tc.context); // the default budget, as real slicing runs it
|
||||
|
||||
INFO("Case: " << tc.metadata.id);
|
||||
INFO("Elapsed: " << result.elapsed_ms << " ms");
|
||||
|
||||
REQUIRE(result.constraints_ok);
|
||||
// A hang guard. The clock is only checked between swaps, so a sweep can overshoot.
|
||||
REQUIRE(result.elapsed_ms < 40000.0);
|
||||
}
|
||||
}
|
||||
|
||||
// ============ Layer 2: Property Checks (all configs) ============
|
||||
|
||||
TEST_CASE("FilamentGroup property checks", "[filament_group][property]") {
|
||||
auto& specs = get_property_specs();
|
||||
auto spec = GENERATE_REF(from_range(specs));
|
||||
|
||||
DYNAMIC_SECTION("Property: " << spec.id) {
|
||||
auto tc = build_test_case(spec.id, spec.config, spec.seed,
|
||||
spec.num_filaments, spec.num_layers,
|
||||
spec.chaotic, spec.with_constraints,
|
||||
spec.mode, spec.strategy, spec.group_with_time);
|
||||
|
||||
auto result = run_and_evaluate(tc.context);
|
||||
|
||||
INFO("Case: " << spec.id);
|
||||
INFO("Config: " << spec.config);
|
||||
INFO("Flush cost: " << result.flush_cost);
|
||||
INFO("Elapsed: " << result.elapsed_ms << " ms");
|
||||
|
||||
// RelWithDebInfo runaway guard; the Release-calibrated 10 s limit is raised for the slower
|
||||
// build (config_b/config_c cases evaluate the full per-layer nozzle-aware reorder for every
|
||||
// candidate grouping; this is a guard against hangs, not a micro-perf gate).
|
||||
REQUIRE(result.elapsed_ms < 40000.0);
|
||||
REQUIRE(result.flush_cost >= 0);
|
||||
|
||||
auto used_filaments = collect_sorted_used_filaments(tc.context.model_info.layer_filaments);
|
||||
if (is_constraint_feasible(tc.context, used_filaments)) {
|
||||
if (!result.constraints_ok) {
|
||||
for (auto& v : result.violations)
|
||||
WARN("Violation: " << v);
|
||||
}
|
||||
REQUIRE(result.constraints_ok);
|
||||
} else {
|
||||
if (!result.constraints_ok) {
|
||||
WARN("Constraint violation (infeasible case, soft): " << spec.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
1
tests/filament_group/golden/config_a/basic_17.json
Normal file
1
tests/filament_group/golden/config_a/basic_17.json
Normal file
@@ -0,0 +1 @@
|
||||
{"base_result":{"constraints_ok":true,"flush_cost":0,"full_score":4.0},"context":{"group_info":{"filament_volume_map":[2,2],"has_filament_switcher":false,"ignore_ext_filament":false,"max_gap_threshold":0.01,"mode":0,"strategy":0,"total_filament_num":2},"machine_info":{"machine_filament_info":[[{"color":"#850C02FF","extruder_id":0,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1},{"color":"#F10331FF","extruder_id":0,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1},{"color":"#429A7CFF","extruder_id":0,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1},{"color":"#8D67FEFF","extruder_id":0,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1}],[{"color":"#2677E2FF","extruder_id":1,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1},{"color":"#3A3922FF","extruder_id":1,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1},{"color":"#DB8EB9FF","extruder_id":1,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1},{"color":"#53A5A6FF","extruder_id":1,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1}]],"master_extruder_id":0,"max_group_size":[2,2],"prefer_non_model_filament":[false,true]},"model_info":{"filament_ids":["GFL_0","GFL_1"],"filament_info":[{"color":"#E8650CFF","is_support":true,"type":"PLA-S","usage_type":0},{"color":"#0A9E99FF","is_support":false,"type":"PETG","usage_type":1}],"flush_matrix":[[[0.0,18.864795684814453],[512.7918701171875,0.0]],[[0.0,332.1666259765625],[58.37631607055664,0.0]]],"layer_filaments":[[0,1],[0,1],[0,1],[1],[0,1],[0,1],[0,1],[0],[0],[0],[0,1],[0,1],[0,1],[0,1],[1],[1],[0],[0],[0],[0],[0],[0],[0],[0],[1],[1],[1],[0,1],[0,1],[0,1],[0],[0,1],[0,1],[0,1],[0,1],[0,1],[0,1],[0,1],[0,1],[0,1],[0,1],[0,1],[0,1],[1],[1],[0],[0,1],[0],[0],[0],[0,1],[0,1],[0,1],[0],[0],[1],[0],[0,1],[0,1],[0,1],[0,1],[0,1],[0,1],[0,1],[0,1],[0],[0,1],[0,1],[0,1],[0,1],[0,1],[0],[0],[0],[0],[0,1],[0,1],[1],[1],[1],[1],[1],[0],[0],[0,1],[1],[0],[0,1],[1],[0],[0],[0,1],[0],[0],[0],[0,1],[0,1],[1],[0,1],[0],[0],[0],[0,1],[1],[1],[1],[1],[0,1],[0,1],[1],[0,1],[0,1],[0],[0],[0],[0],[0],[0,1],[1]],"unprintable_filaments":[[],[]],"unprintable_volumes":{}},"nozzle_info":{"extruder_nozzle_list":{"0":[0],"1":[1]},"nozzle_list":[{"diameter":"0.4","extruder_id":0,"group_id":0,"volume_type":0},{"diameter":"0.4","extruder_id":1,"group_id":1,"volume_type":0}],"nozzle_status":{}},"speed_info":{"ams_preload_enabled":[true,true],"change_time_params":{"selector_load_time":1.0,"selector_unload_time":1.0,"standard_load_time":3.0,"standard_unload_time":2.0},"extruder_change_time":5.0,"filament_change_time":2.0,"filament_print_time":{},"group_with_time":false}},"metadata":{"config_type":"A","id":"A_basic_17","seed":10017}}
|
||||
1
tests/filament_group/golden/config_a/basic_39.json
Normal file
1
tests/filament_group/golden/config_a/basic_39.json
Normal file
@@ -0,0 +1 @@
|
||||
{"base_result":{"constraints_ok":true,"flush_cost":0,"full_score":4.0},"context":{"group_info":{"filament_volume_map":[2,2],"has_filament_switcher":false,"ignore_ext_filament":false,"max_gap_threshold":0.01,"mode":0,"strategy":0,"total_filament_num":2},"machine_info":{"machine_filament_info":[[{"color":"#3C6B30FF","extruder_id":0,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1},{"color":"#FE9954FF","extruder_id":0,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1},{"color":"#FBBAB3FF","extruder_id":0,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1},{"color":"#A1A591FF","extruder_id":0,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1}],[{"color":"#55EE36FF","extruder_id":1,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1},{"color":"#A8F42BFF","extruder_id":1,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1},{"color":"#930D0FFF","extruder_id":1,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1},{"color":"#55C501FF","extruder_id":1,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1}]],"master_extruder_id":0,"max_group_size":[2,2],"prefer_non_model_filament":[false,true]},"model_info":{"filament_ids":["GFL_0","GFL_1"],"filament_info":[{"color":"#C613F1FF","is_support":false,"type":"TPU","usage_type":1},{"color":"#0C0CBAFF","is_support":false,"type":"PLA","usage_type":1}],"flush_matrix":[[[0.0,463.53955078125],[50.04071044921875,0.0]],[[0.0,54.75393295288086],[566.574951171875,0.0]]],"layer_filaments":[[0,1],[0],[0],[0,1],[0,1],[0,1],[0],[0],[0,1],[1],[1],[1],[1],[0,1],[0,1],[0],[0],[0],[0],[0],[1],[1],[0,1],[0,1],[1],[0,1],[0],[0],[0],[0],[0,1],[1],[0],[0],[0],[0,1],[0,1],[0,1],[1],[1],[1],[1],[1],[0,1],[0],[0,1],[0,1],[0,1],[1],[1],[1],[0,1],[0,1],[1],[0],[0,1],[1],[0],[0],[1],[0,1],[1],[1],[1],[0,1],[0,1],[0],[0],[0,1],[0,1],[0,1],[0],[0,1],[0,1],[0,1],[1],[1],[1],[0,1],[0,1],[0,1],[0,1],[0,1],[1],[0,1],[0,1],[0,1],[0,1],[0,1],[0,1],[0,1],[0,1],[0],[0],[0,1],[1],[0,1],[1],[0],[0],[1],[0,1],[0,1],[1],[0,1],[0,1],[1],[1],[1],[0,1],[0,1],[0,1],[0,1],[0],[0,1],[0,1],[0,1],[1],[1],[0,1],[0,1],[0,1],[0],[0],[0,1],[1],[1],[1],[0,1],[1],[0,1],[0,1],[1],[1],[1],[0],[0],[0],[1],[1],[0,1],[1],[1],[0,1],[0],[0],[1],[0,1],[1],[0,1],[1],[1],[0],[0,1],[0,1],[0,1],[1],[1],[0,1],[0,1],[0,1],[1],[0,1],[0],[0,1],[0,1],[0],[0],[1],[1],[0,1],[0,1],[0,1],[0,1],[1],[1],[1],[0,1],[0,1],[0],[0,1],[0,1],[0,1],[1],[0,1],[0,1],[0,1],[0,1],[0,1],[0,1],[0,1],[0,1],[0],[0],[0,1],[1],[0],[0],[1],[1],[0],[0],[0,1],[0],[0],[0],[0,1],[0],[0,1],[0,1],[0],[0,1],[0,1],[0,1],[1],[0],[0,1],[0],[0,1],[0,1],[0,1],[0,1],[1],[0,1],[0,1],[0,1],[0,1],[1],[1],[1],[0,1],[1],[0,1],[0,1],[0,1],[0,1],[0],[0],[1],[0,1],[0,1],[1],[1],[1],[1],[0],[0],[0],[0],[0],[0],[0],[0],[0],[0,1],[1],[1],[1],[1],[0],[0],[0],[0,1],[0,1],[0],[0,1],[0,1],[0,1]],"unprintable_filaments":[[],[]],"unprintable_volumes":{}},"nozzle_info":{"extruder_nozzle_list":{"0":[0],"1":[1]},"nozzle_list":[{"diameter":"0.4","extruder_id":0,"group_id":0,"volume_type":0},{"diameter":"0.4","extruder_id":1,"group_id":1,"volume_type":0}],"nozzle_status":{}},"speed_info":{"ams_preload_enabled":[true,true],"change_time_params":{"selector_load_time":1.0,"selector_unload_time":1.0,"standard_load_time":3.0,"standard_unload_time":2.0},"extruder_change_time":5.0,"filament_change_time":2.0,"filament_print_time":{},"group_with_time":false}},"metadata":{"config_type":"A","id":"A_basic_39","seed":10039}}
|
||||
1
tests/filament_group/golden/config_a/basic_9.json
Normal file
1
tests/filament_group/golden/config_a/basic_9.json
Normal file
@@ -0,0 +1 @@
|
||||
{"base_result":{"constraints_ok":true,"flush_cost":0,"full_score":4.0},"context":{"group_info":{"filament_volume_map":[2,2],"has_filament_switcher":false,"ignore_ext_filament":false,"max_gap_threshold":0.01,"mode":0,"strategy":0,"total_filament_num":2},"machine_info":{"machine_filament_info":[[{"color":"#835F29FF","extruder_id":0,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1},{"color":"#ACB239FF","extruder_id":0,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1},{"color":"#5EF84BFF","extruder_id":0,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1},{"color":"#FE36A8FF","extruder_id":0,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1}],[{"color":"#7B6ABDFF","extruder_id":1,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1},{"color":"#4FFBDDFF","extruder_id":1,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1},{"color":"#E06DDEFF","extruder_id":1,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1},{"color":"#751104FF","extruder_id":1,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1}]],"master_extruder_id":0,"max_group_size":[2,2],"prefer_non_model_filament":[false,true]},"model_info":{"filament_ids":["GFL_0","GFL_1"],"filament_info":[{"color":"#48BC3DFF","is_support":false,"type":"PETG","usage_type":1},{"color":"#1DEED1FF","is_support":false,"type":"PETG","usage_type":1}],"flush_matrix":[[[0.0,523.2988891601563],[480.04058837890625,0.0]],[[0.0,18.886493682861328],[47.65232467651367,0.0]]],"layer_filaments":[[0,1],[0],[0,1],[1],[0],[0],[1],[1],[0,1],[0,1],[0,1],[0,1],[0,1],[0,1],[0,1],[1],[1],[0,1],[0],[0],[0],[0],[0],[0,1],[0,1],[0,1],[0,1],[0,1],[1],[1],[1],[0],[0],[1],[1],[1],[0],[1],[1],[0,1],[0,1],[0,1],[0,1],[0,1],[0,1],[1],[0,1],[0,1],[0,1],[0],[1],[0],[0],[0],[0],[0,1],[0,1],[0,1],[0,1],[0,1],[0,1],[0,1],[0,1],[0,1],[1],[1],[0],[0],[0],[0],[0],[0,1],[0,1],[0,1],[0,1],[0,1],[1],[0],[0],[0],[0],[0],[0],[0,1],[0,1],[0],[0],[1],[0,1],[0],[0],[0],[0],[0],[0,1],[0,1],[0,1],[0,1],[0],[0],[1],[1],[0,1],[0,1],[0,1],[0],[0,1],[0,1],[0,1],[1],[1],[0],[0],[0],[0],[0],[0,1],[0],[0,1],[0,1],[0],[0],[0],[0],[0],[1],[1],[0,1],[0,1],[0,1],[1],[1],[1],[1],[1],[1],[0,1],[0,1],[0,1],[1],[1],[0,1],[0,1],[1],[1],[1],[0,1],[0,1],[0,1],[0,1],[1],[1],[0,1],[0,1],[0,1],[0,1],[0,1],[0,1],[0,1],[0,1],[0],[0],[0,1],[0,1],[0],[1],[1],[1],[1],[1],[1],[0,1],[0,1],[0,1],[1],[1],[0,1],[1],[0,1],[0,1],[0],[0,1],[0,1],[0,1],[0],[1],[0,1],[0,1],[1],[1],[1],[1],[0,1],[0,1],[0,1],[0,1],[0,1],[0,1],[0,1],[0,1],[0],[0],[0],[0],[0,1],[0,1],[0,1],[0,1],[0,1],[1],[0,1],[1],[1],[1],[0,1],[0,1],[0,1],[0,1],[0,1],[0,1],[0,1],[0,1],[0,1],[0],[0,1],[0],[0,1],[1],[0,1],[0,1],[1],[1],[0,1],[0,1],[0,1],[0,1],[0],[0,1],[0],[0,1],[0,1],[0,1],[0,1],[0,1],[0],[0],[0,1],[0,1],[0,1]],"unprintable_filaments":[[],[]],"unprintable_volumes":{}},"nozzle_info":{"extruder_nozzle_list":{"0":[0],"1":[1]},"nozzle_list":[{"diameter":"0.4","extruder_id":0,"group_id":0,"volume_type":0},{"diameter":"0.4","extruder_id":1,"group_id":1,"volume_type":0}],"nozzle_status":{}},"speed_info":{"ams_preload_enabled":[true,true],"change_time_params":{"selector_load_time":1.0,"selector_unload_time":1.0,"standard_load_time":3.0,"standard_unload_time":2.0},"extruder_change_time":5.0,"filament_change_time":2.0,"filament_print_time":{},"group_with_time":false}},"metadata":{"config_type":"A","id":"A_basic_9","seed":10009}}
|
||||
1
tests/filament_group/golden/config_a/constraint_117.json
Normal file
1
tests/filament_group/golden/config_a/constraint_117.json
Normal file
@@ -0,0 +1 @@
|
||||
{"base_result":{"constraints_ok":true,"flush_cost":9996,"full_score":4878.185600000001},"context":{"group_info":{"filament_volume_map":[2,2,2],"has_filament_switcher":false,"ignore_ext_filament":false,"max_gap_threshold":0.01,"mode":0,"strategy":0,"total_filament_num":3},"machine_info":{"machine_filament_info":[[{"color":"#862B28FF","extruder_id":0,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1},{"color":"#5DE136FF","extruder_id":0,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1},{"color":"#B737ACFF","extruder_id":0,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1},{"color":"#AB25E0FF","extruder_id":0,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1}],[{"color":"#03D322FF","extruder_id":1,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1},{"color":"#FEF6E8FF","extruder_id":1,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1},{"color":"#6ACCEDFF","extruder_id":1,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1},{"color":"#5F679DFF","extruder_id":1,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1}]],"master_extruder_id":0,"max_group_size":[2,2],"prefer_non_model_filament":[false,true]},"model_info":{"filament_ids":["GFL_0","GFL_1","GFL_2"],"filament_info":[{"color":"#D4E014FF","is_support":false,"type":"PA","usage_type":1},{"color":"#D01CEEFF","is_support":false,"type":"PA","usage_type":1},{"color":"#E433C7FF","is_support":false,"type":"TPU","usage_type":1}],"flush_matrix":[[[0.0,345.6607360839844,334.3067626953125],[167.5570526123047,0.0,469.119140625],[205.99964904785156,536.2994384765625,0.0]],[[0.0,16.684810638427734,322.7650146484375],[260.761962890625,0.0,230.73336791992188],[267.0419616699219,64.71019744873047,0.0]]],"layer_filaments":[[0,2],[0,1],[0],[1],[1],[1],[1],[1],[1],[1],[1,2],[0,1,2],[0],[2],[1],[1],[1],[1],[1,2],[1,2],[1],[0,1],[0,1],[1,2],[1,2],[1,2],[1,2],[1,2],[2],[0],[1,2],[1,2],[1],[0,2],[0,1,2],[0,1,2],[1],[1,2],[1,2],[1,2],[1,2],[0,1,2],[2],[1],[0,1],[0,1,2],[0,1,2],[0,1,2],[0,2],[0,2],[0,2],[0,2],[0,1,2],[0,1,2],[0,1,2],[2],[2],[2],[1],[0,1],[0,1],[1],[1,2],[1,2],[0,1],[0,1,2],[0,1],[1],[2],[1,2],[2],[2],[2],[2],[2],[1,2],[2],[2],[0,2],[0,1],[0,1],[0,1],[0],[0,1],[0,1],[1],[1],[1],[0],[0,2],[2],[2],[2],[2],[0,2],[1],[1,2],[1],[1],[1],[1],[1],[0,1],[0,1],[0,1],[1],[2],[2],[0],[0],[0],[0],[0,2],[1,2],[0,2],[0,2],[0,2],[0],[1],[1,2],[0,2],[2],[1,2],[1,2],[1,2],[1,2],[1,2],[1,2],[2],[1,2],[2],[0,2],[0,2],[0,2],[0],[0],[0],[0,2],[2],[2],[0,2],[2],[0,1],[0],[0],[0],[0],[0],[1,2],[0,1,2],[0,1,2],[0,1],[1],[1],[0],[0],[0],[0],[1],[1],[1],[0,2],[0,2],[0],[0],[1,2],[1,2]],"unprintable_filaments":[[2],[]],"unprintable_volumes":{}},"nozzle_info":{"extruder_nozzle_list":{"0":[0],"1":[1]},"nozzle_list":[{"diameter":"0.4","extruder_id":0,"group_id":0,"volume_type":0},{"diameter":"0.4","extruder_id":1,"group_id":1,"volume_type":0}],"nozzle_status":{}},"speed_info":{"ams_preload_enabled":[true,true],"change_time_params":{"selector_load_time":1.0,"selector_unload_time":1.0,"standard_load_time":3.0,"standard_unload_time":2.0},"extruder_change_time":5.0,"filament_change_time":2.0,"filament_print_time":{},"group_with_time":false}},"metadata":{"config_type":"A","id":"A_constraint_117","seed":10117}}
|
||||
1
tests/filament_group/golden/config_a/constraint_87.json
Normal file
1
tests/filament_group/golden/config_a/constraint_87.json
Normal file
@@ -0,0 +1 @@
|
||||
{"base_result":{"constraints_ok":true,"flush_cost":15640,"full_score":7498.304000000001},"context":{"group_info":{"filament_volume_map":[2,2,2,2,2],"has_filament_switcher":false,"ignore_ext_filament":false,"max_gap_threshold":0.01,"mode":0,"strategy":0,"total_filament_num":5},"machine_info":{"machine_filament_info":[[{"color":"#690A58FF","extruder_id":0,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1},{"color":"#13D9ACFF","extruder_id":0,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1},{"color":"#255B88FF","extruder_id":0,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1},{"color":"#14AD07FF","extruder_id":0,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1}],[{"color":"#E1E180FF","extruder_id":1,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1},{"color":"#A69E85FF","extruder_id":1,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1},{"color":"#9F27DAFF","extruder_id":1,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1},{"color":"#96A697FF","extruder_id":1,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1}]],"master_extruder_id":0,"max_group_size":[3,3],"prefer_non_model_filament":[false,true]},"model_info":{"filament_ids":["GFL_0","GFL_1","GFL_2","GFL_3","GFL_4"],"filament_info":[{"color":"#84C1C1FF","is_support":false,"type":"PLA","usage_type":1},{"color":"#1EB5C4FF","is_support":false,"type":"PLA","usage_type":1},{"color":"#8D50E5FF","is_support":false,"type":"PETG","usage_type":1},{"color":"#F22BC4FF","is_support":false,"type":"PA","usage_type":1},{"color":"#B7A86EFF","is_support":false,"type":"PA","usage_type":1}],"flush_matrix":[[[0.0,494.378173828125,256.524658203125,43.245582580566406,405.5243835449219],[16.6104679107666,0.0,150.26849365234375,400.1143493652344,30.314559936523438],[24.301469802856445,130.10775756835938,0.0,536.2122192382813,383.1336669921875],[437.3901062011719,272.96868896484375,82.35665893554688,0.0,17.11191177368164],[468.4289245605469,355.7682800292969,452.1650085449219,45.59552764892578,0.0]],[[0.0,491.8825378417969,205.77655029296875,349.1083984375,506.652099609375],[101.32499694824219,0.0,175.80380249023438,120.50504302978516,438.80804443359375],[152.76100158691406,355.5727233886719,0.0,581.8612060546875,585.173583984375],[546.7792358398438,22.963092803955078,407.84173583984375,0.0,105.83439636230469],[356.05926513671875,145.5247344970703,230.94595336914063,345.7974548339844,0.0]]],"layer_filaments":[[0,3,4],[0],[0],[0],[0],[0],[4],[4],[1],[1],[1],[3],[1],[1],[3],[0,1,4],[0,1],[0,1,3],[0,1],[1],[0],[0,3],[0,3],[2],[3,4],[0,2],[2,4],[4],[3],[3],[3],[1],[2],[4],[4],[4],[4],[0,3,4],[0,3],[0,3,4],[0,4],[0,3,4],[0,2,3],[0,2],[0,2],[0,2],[0,2],[0],[0],[0],[0,3,4],[3,4],[1,3,4],[3,4],[3,4],[3],[2],[4],[4],[4],[4],[4],[1],[1],[1],[1],[1],[1,4],[1],[1,3],[2,3],[2,3,4],[2,3,4],[3,4],[3,4],[0,1,3],[0,1],[0,1],[0,1,2],[0,1,2],[1,2],[1,2],[1,2],[0,2,3],[0,2,3],[0,2,3],[3],[3],[0,3,4],[0,2,3,4],[0,2,3,4],[0,2,3],[2],[2],[2],[0],[1],[1],[1],[4],[1,4],[0,4],[0,3,4],[0,3,4],[0,3,4],[0,3,4],[0,3,4],[0,3,4],[3],[3],[3],[0,3],[0,3],[0],[4],[3,4]],"unprintable_filaments":[[],[0,1]],"unprintable_volumes":{}},"nozzle_info":{"extruder_nozzle_list":{"0":[0],"1":[1]},"nozzle_list":[{"diameter":"0.4","extruder_id":0,"group_id":0,"volume_type":0},{"diameter":"0.4","extruder_id":1,"group_id":1,"volume_type":0}],"nozzle_status":{}},"speed_info":{"ams_preload_enabled":[true,true],"change_time_params":{"selector_load_time":1.0,"selector_unload_time":1.0,"standard_load_time":3.0,"standard_unload_time":2.0},"extruder_change_time":5.0,"filament_change_time":2.0,"filament_print_time":{},"group_with_time":false}},"metadata":{"config_type":"A","id":"A_constraint_87","seed":10087}}
|
||||
@@ -0,0 +1 @@
|
||||
{"base_result":{"constraints_ok":true,"flush_cost":22348,"full_score":10641.052800000001},"context":{"group_info":{"filament_volume_map":[2,2,2,2,2,2,2],"has_filament_switcher":false,"ignore_ext_filament":false,"max_gap_threshold":0.01,"mode":0,"strategy":1,"total_filament_num":7},"machine_info":{"machine_filament_info":[[{"color":"#CA0BBFFF","extruder_id":0,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1},{"color":"#04BBE7FF","extruder_id":0,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1},{"color":"#0C9192FF","extruder_id":0,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1},{"color":"#D03AE7FF","extruder_id":0,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1}],[{"color":"#58ED4CFF","extruder_id":1,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1},{"color":"#F0F7E7FF","extruder_id":1,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1},{"color":"#C01A00FF","extruder_id":1,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1},{"color":"#41BF7EFF","extruder_id":1,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1}]],"master_extruder_id":0,"max_group_size":[4,4],"prefer_non_model_filament":[false,true]},"model_info":{"filament_ids":["GFL_0","GFL_1","GFL_2","GFL_3","GFL_4","GFL_5","GFL_6"],"filament_info":[{"color":"#15D1F3FF","is_support":false,"type":"PETG","usage_type":1},{"color":"#2E765BFF","is_support":false,"type":"TPU","usage_type":1},{"color":"#F7F661FF","is_support":false,"type":"TPU","usage_type":1},{"color":"#1F8FA4FF","is_support":false,"type":"TPU","usage_type":1},{"color":"#548538FF","is_support":false,"type":"TPU","usage_type":1},{"color":"#49CDDAFF","is_support":true,"type":"PLA-S","usage_type":0},{"color":"#77400CFF","is_support":false,"type":"TPU","usage_type":1}],"flush_matrix":[[[0.0,522.1661987304688,100.72758483886719,133.01226806640625,491.89971923828125,10.22260570526123,398.2569580078125],[199.46890258789063,0.0,193.49847412109375,173.7811737060547,254.3165283203125,524.8251342773438,11.158357620239258],[25.300485610961914,185.6349334716797,0.0,14.763650894165039,527.9827270507813,503.7408142089844,196.4144287109375],[14.506301879882813,388.3427429199219,592.0447387695313,0.0,43.87137222290039,568.1431274414063,473.7308654785156],[320.220703125,309.9248352050781,39.37203598022461,47.134002685546875,0.0,97.27034759521484,458.011962890625],[572.5841674804688,336.4319152832031,47.922828674316406,278.3333740234375,182.32958984375,0.0,402.6477966308594],[447.379150390625,588.4238891601563,13.493745803833008,433.3789978027344,390.13360595703125,461.5366516113281,0.0]],[[0.0,298.9002380371094,586.3795776367188,271.62158203125,272.1965637207031,152.54571533203125,239.88775634765625],[99.5313949584961,0.0,165.59657287597656,83.9988021850586,219.39901733398438,418.60491943359375,575.6079711914063],[35.19260787963867,44.58149719238281,0.0,212.56053161621094,139.3209686279297,596.1610107421875,564.784423828125],[488.8506774902344,419.78521728515625,321.46514892578125,0.0,46.86149597167969,310.4212341308594,303.2931213378906],[531.5128173828125,116.25019073486328,207.16612243652344,50.03121566772461,0.0,385.4001770019531,108.3058090209961],[497.9175720214844,444.3203125,331.2178649902344,499.4423828125,546.33642578125,0.0,352.6327209472656],[423.0767822265625,189.04090881347656,132.35194396972656,453.5607604980469,29.324968338012695,336.557861328125,0.0]]],"layer_filaments":[[0,3,4,5],[0,4,5],[0,1,4,5],[1,4,5],[1,5],[4],[4],[5],[5],[5],[5],[6],[1],[1],[1,3],[1,3],[0,1,3],[0,3,4,5],[1,4,5],[1,4],[3,4],[0,1],[0,1],[0,1],[0,1],[1,5],[1,5],[1],[1],[1],[4],[4],[3,4],[0,1,6],[0,1,6],[1,3,6],[1,3,6],[6],[6],[3,6],[3,6],[3,6],[0,3,4,5],[0,3,4,5],[0,2,3,4,5],[0,2,3],[0,3],[0,3],[0,3],[0],[0,4],[4],[2,3,4,6],[2,4],[2,4],[5],[5],[5],[5],[2,5],[2,3,5],[2,3,5],[2,3,5],[2],[2,3,4],[2,3,4],[0,3,4],[0,3,4],[3,4,6],[0,3,4,6],[0,3,6],[0,3,6],[1,3,4,5],[0,3],[1,3,5,6],[5,6],[4,5,6],[3,4],[3],[1,3],[3],[2,3,5,6],[3,5,6],[5],[2],[6],[6],[4,6],[6],[6],[4,6],[1,4,6],[4],[0],[0],[0,5],[5],[6],[0,1],[0,1],[0,1,3],[0],[0],[0],[0,3],[0,3],[0,3],[0,3,5],[3,5],[3],[3],[3],[3],[3,5],[3,5],[0,3,5],[0]],"unprintable_filaments":[[],[]],"unprintable_volumes":{}},"nozzle_info":{"extruder_nozzle_list":{"0":[0],"1":[1]},"nozzle_list":[{"diameter":"0.4","extruder_id":0,"group_id":0,"volume_type":0},{"diameter":"0.4","extruder_id":1,"group_id":1,"volume_type":0}],"nozzle_status":{}},"speed_info":{"ams_preload_enabled":[true,true],"change_time_params":{"selector_load_time":1.0,"selector_unload_time":1.0,"standard_load_time":3.0,"standard_unload_time":2.0},"extruder_change_time":5.0,"filament_change_time":2.0,"filament_print_time":{},"group_with_time":false}},"metadata":{"config_type":"A","id":"A_mode_bestfit_161","seed":10161}}
|
||||
1
tests/filament_group/golden/config_a/mode_match_150.json
Normal file
1
tests/filament_group/golden/config_a/mode_match_150.json
Normal file
@@ -0,0 +1 @@
|
||||
{"base_result":{"constraints_ok":true,"flush_cost":28788,"full_score":13492.236799999999},"context":{"group_info":{"filament_volume_map":[2,2,2,2],"has_filament_switcher":false,"ignore_ext_filament":false,"max_gap_threshold":0.01,"mode":1,"strategy":0,"total_filament_num":4},"machine_info":{"machine_filament_info":[[{"color":"#2C1334FF","extruder_id":0,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1},{"color":"#672D31FF","extruder_id":0,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1},{"color":"#40C111FF","extruder_id":0,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1},{"color":"#0D6A9EFF","extruder_id":0,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1}],[{"color":"#1C9D92FF","extruder_id":1,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1},{"color":"#DFD03AFF","extruder_id":1,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1},{"color":"#9F1CE2FF","extruder_id":1,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1},{"color":"#3F8A30FF","extruder_id":1,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1}]],"master_extruder_id":0,"max_group_size":[3,3],"prefer_non_model_filament":[false,true]},"model_info":{"filament_ids":["GFL_0","GFL_1","GFL_2","GFL_3"],"filament_info":[{"color":"#3F3A9BFF","is_support":false,"type":"TPU","usage_type":1},{"color":"#2A420FFF","is_support":false,"type":"TPU","usage_type":1},{"color":"#37A850FF","is_support":false,"type":"PETG","usage_type":1},{"color":"#EB9B49FF","is_support":false,"type":"PA","usage_type":1}],"flush_matrix":[[[0.0,357.30389404296875,131.00808715820313,575.68603515625],[289.8275451660156,0.0,56.926780700683594,548.9874877929688],[323.252685546875,156.2064666748047,0.0,210.6259002685547],[469.44366455078125,165.70809936523438,174.34884643554688,0.0]],[[0.0,141.2342529296875,146.28591918945313,193.52146911621094],[548.3370971679688,0.0,404.035400390625,559.38232421875],[110.14042663574219,127.97674560546875,0.0,582.9161376953125],[221.37879943847656,321.6277160644531,375.734619140625,0.0]]],"layer_filaments":[[0,1,2],[0],[0],[1],[1],[1],[1],[1,3],[2],[2],[2],[0],[0,1,2],[1,2],[2],[2],[2],[2],[2],[3],[0,3],[0,3],[0,2],[2],[1,2],[1,2],[1],[0,1,2],[0,1],[0,1],[1,3],[0,1,3],[0,1,3],[0],[0,2],[0],[0],[0],[0],[0],[0,1,2],[0,1,3],[1,3],[1,3],[1,3],[1,3],[1,2,3],[0,1,2,3],[1,2,3],[0,1,2],[0,1,2],[0,1],[0,1],[0,1],[0,1,2],[0,1,2],[0,1,2,3],[0,1,2,3],[0,1],[1,2,3],[1,2],[0],[1,3],[0],[0],[2],[2,3],[1,2],[2],[1,2],[0,1],[1],[1,2],[1],[0,3],[0,3],[1,3],[1,3],[1,3],[1,3],[1],[1],[1],[1],[1],[1],[1],[1],[0,2],[0,2],[0,2,3],[1,2],[1,2],[1,2],[2],[2],[2],[2],[1,3],[1],[1],[1],[2,3],[2],[2],[2],[2],[2],[2],[2],[0,1],[0,2,3],[0,2,3],[0,2],[0,2],[0,2],[0],[1,3],[1,3],[3],[1,3],[0,3],[0,2,3],[0,2,3],[0,2,3],[3],[3],[3],[2],[1,2]],"unprintable_filaments":[[],[]],"unprintable_volumes":{}},"nozzle_info":{"extruder_nozzle_list":{"0":[0],"1":[1]},"nozzle_list":[{"diameter":"0.4","extruder_id":0,"group_id":0,"volume_type":0},{"diameter":"0.4","extruder_id":1,"group_id":1,"volume_type":0}],"nozzle_status":{}},"speed_info":{"ams_preload_enabled":[true,true],"change_time_params":{"selector_load_time":1.0,"selector_unload_time":1.0,"standard_load_time":3.0,"standard_unload_time":2.0},"extruder_change_time":5.0,"filament_change_time":2.0,"filament_print_time":{},"group_with_time":false}},"metadata":{"config_type":"A","id":"A_mode_match_150","seed":10150}}
|
||||
1
tests/filament_group/golden/config_a/mode_time_164.json
Normal file
1
tests/filament_group/golden/config_a/mode_time_164.json
Normal file
File diff suppressed because one or more lines are too long
1
tests/filament_group/golden/config_a/stress_55.json
Normal file
1
tests/filament_group/golden/config_a/stress_55.json
Normal file
File diff suppressed because one or more lines are too long
1
tests/filament_group/golden/config_a/stress_68.json
Normal file
1
tests/filament_group/golden/config_a/stress_68.json
Normal file
File diff suppressed because one or more lines are too long
1
tests/filament_group/golden/config_b/basic_24.json
Normal file
1
tests/filament_group/golden/config_b/basic_24.json
Normal file
@@ -0,0 +1 @@
|
||||
{"base_result":{"constraints_ok":true,"flush_cost":0,"full_score":184.0},"context":{"group_info":{"filament_volume_map":[2,2,2],"has_filament_switcher":false,"ignore_ext_filament":false,"max_gap_threshold":0.01,"mode":0,"strategy":0,"total_filament_num":3},"machine_info":{"machine_filament_info":[[{"color":"#58191BFF","extruder_id":0,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1},{"color":"#65BA4BFF","extruder_id":0,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1},{"color":"#8198CBFF","extruder_id":0,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1},{"color":"#ACC0ABFF","extruder_id":0,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1}],[{"color":"#22ED52FF","extruder_id":1,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1},{"color":"#C09D59FF","extruder_id":1,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1},{"color":"#2DA502FF","extruder_id":1,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1},{"color":"#F7A2FAFF","extruder_id":1,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1}]],"master_extruder_id":0,"max_group_size":[4,4],"prefer_non_model_filament":[false,false]},"model_info":{"filament_ids":["GFL_0","GFL_1","GFL_2"],"filament_info":[{"color":"#D0206DFF","is_support":true,"type":"PLA-S","usage_type":0},{"color":"#8F4679FF","is_support":false,"type":"PA","usage_type":1},{"color":"#29B62DFF","is_support":false,"type":"ABS","usage_type":1}],"flush_matrix":[[[0.0,22.997100830078125,311.9136962890625],[467.8121032714844,0.0,309.3307189941406],[409.413330078125,543.3587646484375,0.0]],[[0.0,424.8780517578125,513.323974609375],[432.9106750488281,0.0,186.18800354003906],[419.3179016113281,409.17095947265625,0.0]]],"layer_filaments":[[0,1],[0,2],[0],[0,1],[0,1,2],[1],[1],[1],[1],[2],[0],[0,2],[0],[0],[0,2],[0,1],[0],[2],[2],[2],[2],[2],[2],[1,2],[1,2],[1],[2],[2],[0,1],[0,2],[1,2],[1],[1],[0,1],[0,1,2],[2],[2],[2],[1],[1],[2],[2],[0],[1],[0],[0,1],[0,1],[0,1],[1],[1],[1],[0,1],[1,2],[0,1,2],[1,2],[1,2],[1,2],[1,2],[0,2],[0,1],[0,1],[1,2],[1],[1],[2],[2],[2],[2],[1,2],[1],[1],[1],[1],[0],[0],[2],[0,2],[0,1,2],[2],[2],[2],[0,2],[0,1,2],[0,2],[2],[1],[1],[1],[2],[2],[2],[1,2],[1,2],[2],[2],[2],[0,2],[0,2],[2],[0,2],[0],[0],[0],[0,1],[0,1],[0,1],[0,1],[0,2],[0,2],[0],[0,2],[2],[1,2],[2],[1,2],[0,2],[0],[0,2],[1],[1],[1],[2],[0,1]],"unprintable_filaments":[[],[]],"unprintable_volumes":{}},"nozzle_info":{"extruder_nozzle_list":{"0":[0],"1":[1,2]},"nozzle_list":[{"diameter":"0.4","extruder_id":0,"group_id":0,"volume_type":0},{"diameter":"0.4","extruder_id":1,"group_id":1,"volume_type":0},{"diameter":"0.4","extruder_id":1,"group_id":2,"volume_type":0}],"nozzle_status":{}},"speed_info":{"ams_preload_enabled":[true,true],"change_time_params":{"selector_load_time":1.0,"selector_unload_time":1.0,"standard_load_time":3.0,"standard_unload_time":2.0},"extruder_change_time":5.0,"filament_change_time":2.0,"filament_print_time":{},"group_with_time":false}},"metadata":{"config_type":"B","id":"B_basic_24","seed":20024}}
|
||||
1
tests/filament_group/golden/config_b/basic_27.json
Normal file
1
tests/filament_group/golden/config_b/basic_27.json
Normal file
@@ -0,0 +1 @@
|
||||
{"base_result":{"constraints_ok":true,"flush_cost":0,"full_score":439.0},"context":{"group_info":{"filament_volume_map":[2,2,2,2,2],"has_filament_switcher":false,"ignore_ext_filament":false,"max_gap_threshold":0.01,"mode":0,"strategy":0,"total_filament_num":5},"machine_info":{"machine_filament_info":[[{"color":"#CE9589FF","extruder_id":0,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1},{"color":"#C1CA1FFF","extruder_id":0,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1},{"color":"#63C10EFF","extruder_id":0,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1},{"color":"#1FB0EDFF","extruder_id":0,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1}],[{"color":"#D1526EFF","extruder_id":1,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1},{"color":"#FFFDCCFF","extruder_id":1,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1},{"color":"#FE458BFF","extruder_id":1,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1},{"color":"#FE7A24FF","extruder_id":1,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1}]],"master_extruder_id":0,"max_group_size":[4,8],"prefer_non_model_filament":[false,false]},"model_info":{"filament_ids":["GFL_0","GFL_1","GFL_2","GFL_3","GFL_4"],"filament_info":[{"color":"#E892ACFF","is_support":false,"type":"PETG","usage_type":1},{"color":"#9BF6B9FF","is_support":false,"type":"ABS","usage_type":1},{"color":"#B37CAAFF","is_support":false,"type":"PETG","usage_type":1},{"color":"#E57574FF","is_support":true,"type":"PLA-S","usage_type":0},{"color":"#4821E8FF","is_support":false,"type":"PETG","usage_type":1}],"flush_matrix":[[[0.0,375.2281494140625,463.1433410644531,91.72261810302734,543.7777709960938],[40.411563873291016,0.0,46.204158782958984,133.43045043945313,140.9217987060547],[385.8175354003906,90.685302734375,0.0,140.52508544921875,75.50867462158203],[316.38568115234375,89.43927764892578,424.980224609375,0.0,27.291486740112305],[50.60981369018555,200.03822326660156,547.7901000976563,466.6471862792969,0.0]],[[0.0,240.17831420898438,577.3026123046875,222.15658569335938,590.8341064453125],[424.3199157714844,0.0,434.2823486328125,209.71908569335938,437.5057373046875],[349.07037353515625,464.85870361328125,0.0,32.95913314819336,527.3640747070313],[256.4498596191406,298.9818115234375,598.7887573242188,0.0,31.422487258911133],[529.8190307617188,558.2802734375,43.493682861328125,432.5175476074219,0.0]]],"layer_filaments":[[1,2,4],[1,2,4],[1,4],[1,4],[4],[4],[2],[2,4],[2,4],[2,4],[2,3],[1,2,3],[2,3],[2,3],[3],[3],[0,4],[0,4],[0,4],[0],[0],[0,3],[0,2,3],[2],[2],[1,2],[1,2],[0,2],[0,2],[0,2],[0],[0,2,4],[0,2,4],[0,1,3],[0,1,3,4],[0,1,2,3],[0,1,2,3],[2,3],[1,2,3],[1,2],[2],[3],[0,2,4],[0,4],[0,4],[1,4],[0,1,4],[0,1,4],[0,4],[4],[4],[4],[3],[3],[3],[0,1,4],[2],[0,2],[0,2],[0,2,4],[0,1,2],[0,1],[0,1],[1],[1],[1],[1],[3],[3],[3],[4],[3,4],[3,4],[4],[4],[4],[4],[4],[2],[2],[2],[2,4],[2,3,4],[3],[0,2,4],[0],[0],[1],[1],[2],[2],[2],[1],[1],[0,1],[0,1,2],[0,2],[1,3],[1,3],[1,3],[0,1],[0,1,3],[0,1,3,4],[0,1,3],[0,3],[3],[2,3],[2],[2],[2],[1],[3],[2,3],[1,3],[3],[3],[2],[2,4],[0,3,4],[3,4],[3],[0,3,4],[0,4],[3,4],[2,3],[1,2],[1,2],[1,2],[1,2],[1,2],[2],[2],[1,2],[1,2]],"unprintable_filaments":[[],[]],"unprintable_volumes":{}},"nozzle_info":{"extruder_nozzle_list":{"0":[0],"1":[1,2,3,4]},"nozzle_list":[{"diameter":"0.4","extruder_id":0,"group_id":0,"volume_type":0},{"diameter":"0.4","extruder_id":1,"group_id":1,"volume_type":0},{"diameter":"0.4","extruder_id":1,"group_id":2,"volume_type":1},{"diameter":"0.4","extruder_id":1,"group_id":3,"volume_type":0},{"diameter":"0.4","extruder_id":1,"group_id":4,"volume_type":3}],"nozzle_status":{}},"speed_info":{"ams_preload_enabled":[true,true],"change_time_params":{"selector_load_time":1.0,"selector_unload_time":1.0,"standard_load_time":3.0,"standard_unload_time":2.0},"extruder_change_time":5.0,"filament_change_time":2.0,"filament_print_time":{},"group_with_time":false}},"metadata":{"config_type":"B","id":"B_basic_27","seed":20027}}
|
||||
1
tests/filament_group/golden/config_b/basic_47.json
Normal file
1
tests/filament_group/golden/config_b/basic_47.json
Normal file
@@ -0,0 +1 @@
|
||||
{"base_result":{"constraints_ok":true,"flush_cost":0,"full_score":279.0},"context":{"group_info":{"filament_volume_map":[2,2,2],"has_filament_switcher":false,"ignore_ext_filament":false,"max_gap_threshold":0.01,"mode":0,"strategy":0,"total_filament_num":3},"machine_info":{"machine_filament_info":[[{"color":"#21E724FF","extruder_id":0,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1},{"color":"#B2066AFF","extruder_id":0,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1},{"color":"#727804FF","extruder_id":0,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1},{"color":"#CBC06DFF","extruder_id":0,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1}],[{"color":"#0EF122FF","extruder_id":1,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1},{"color":"#7299D2FF","extruder_id":1,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1},{"color":"#EA9A57FF","extruder_id":1,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1},{"color":"#3A2E9EFF","extruder_id":1,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1}]],"master_extruder_id":0,"max_group_size":[4,4],"prefer_non_model_filament":[false,false]},"model_info":{"filament_ids":["GFL_0","GFL_1","GFL_2"],"filament_info":[{"color":"#88704DFF","is_support":false,"type":"PETG","usage_type":1},{"color":"#D3D1CCFF","is_support":false,"type":"PLA","usage_type":1},{"color":"#F8C24BFF","is_support":false,"type":"PLA","usage_type":1}],"flush_matrix":[[[0.0,123.0057144165039,149.25132751464844],[140.3647918701172,0.0,395.5540771484375],[354.4726257324219,160.0218048095703,0.0]],[[0.0,249.49559020996094,285.7671203613281],[366.1941223144531,0.0,411.5499267578125],[69.0042724609375,557.7216186523438,0.0]]],"layer_filaments":[[0,2],[0,1],[0,1],[0,2],[0,2],[0,1,2],[0,1,2],[0],[0],[0],[0],[1],[0,1],[0],[0],[0,1],[1],[0,1],[0,1],[0,1],[0,1],[1,2],[2],[1],[1],[1],[0],[0],[0],[0],[0],[1],[1],[1],[2],[1,2],[1,2],[1],[1,2],[2],[1,2],[1],[1,2],[1,2],[1,2],[2],[2],[1,2],[1],[0],[0],[0],[0,1],[0,1],[1],[0,1],[1],[1,2],[0,1],[0,1],[0,1],[0,1],[0,1,2],[1,2],[1,2],[1,2],[1,2],[2],[2],[2],[1],[1],[1],[1],[1],[1],[0,1],[0],[0],[0],[0],[0],[0],[0],[0],[0,1],[1],[1,2],[1],[0,1],[0],[0],[0],[0],[0],[0],[0,1],[0,2],[2],[0],[1,2],[0,1,2],[0,1,2],[0,1,2],[0,1,2],[0,1,2],[0,2],[1],[1],[1],[0,1],[0,1,2],[0,2],[0],[1],[0],[0],[0],[0],[0],[0,2],[0],[0],[0],[0],[0],[0],[0,1],[0],[0],[0,1],[1],[1],[1],[2],[0,2],[2],[0,2],[0],[2],[1,2],[0,1],[0,1],[0,1,2],[0,1],[0,1],[2],[2],[0,2],[1,2],[1],[1,2],[1,2],[1,2],[1,2],[1],[1],[1],[1],[1],[1],[1],[0,1],[0,1],[0,1],[0],[0],[0],[0],[0],[0],[2],[1],[1],[0],[0],[0,2],[1],[1],[1,2],[1,2],[0,1,2],[0,1,2],[0,1,2],[0],[0],[0],[0,2],[2],[0,1],[0],[1,2],[2],[1],[1,2],[1,2],[1,2],[0,1,2],[1],[1],[1],[1],[1],[0],[0,1],[1],[2],[1],[1,2],[0,1],[0,2],[0,1,2]],"unprintable_filaments":[[],[]],"unprintable_volumes":{}},"nozzle_info":{"extruder_nozzle_list":{"0":[0],"1":[1,2]},"nozzle_list":[{"diameter":"0.4","extruder_id":0,"group_id":0,"volume_type":0},{"diameter":"0.4","extruder_id":1,"group_id":1,"volume_type":0},{"diameter":"0.4","extruder_id":1,"group_id":2,"volume_type":3}],"nozzle_status":{}},"speed_info":{"ams_preload_enabled":[true,true],"change_time_params":{"selector_load_time":1.0,"selector_unload_time":1.0,"standard_load_time":3.0,"standard_unload_time":2.0},"extruder_change_time":5.0,"filament_change_time":2.0,"filament_print_time":{},"group_with_time":false}},"metadata":{"config_type":"B","id":"B_basic_47","seed":20047}}
|
||||
1
tests/filament_group/golden/config_b/constraint_103.json
Normal file
1
tests/filament_group/golden/config_b/constraint_103.json
Normal file
@@ -0,0 +1 @@
|
||||
{"base_result":{"constraints_ok":true,"flush_cost":12987,"full_score":6744.9032},"context":{"group_info":{"filament_volume_map":[2,2,2,2],"has_filament_switcher":false,"ignore_ext_filament":false,"max_gap_threshold":0.01,"mode":0,"strategy":0,"total_filament_num":4},"machine_info":{"machine_filament_info":[[{"color":"#1BC545FF","extruder_id":0,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1},{"color":"#D494DDFF","extruder_id":0,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1},{"color":"#99E903FF","extruder_id":0,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1},{"color":"#6C8B32FF","extruder_id":0,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1}],[{"color":"#961DFEFF","extruder_id":1,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1},{"color":"#FF2B42FF","extruder_id":1,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1},{"color":"#BFEBE0FF","extruder_id":1,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1},{"color":"#A6F508FF","extruder_id":1,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1}]],"master_extruder_id":0,"max_group_size":[4,4],"prefer_non_model_filament":[false,false]},"model_info":{"filament_ids":["GFL_0","GFL_1","GFL_2","GFL_3"],"filament_info":[{"color":"#2093D1FF","is_support":false,"type":"TPU","usage_type":1},{"color":"#4B9E95FF","is_support":false,"type":"PLA","usage_type":1},{"color":"#79642EFF","is_support":true,"type":"PLA-S","usage_type":0},{"color":"#DC89BAFF","is_support":false,"type":"PETG","usage_type":1}],"flush_matrix":[[[0.0,145.34756469726563,196.37205505371094,358.7149353027344],[25.324295043945313,0.0,117.23784637451172,303.9642333984375],[553.1095581054688,434.2644348144531,0.0,28.46591567993164],[561.9080810546875,554.3380737304688,435.35601806640625,0.0]],[[0.0,412.2175598144531,165.17893981933594,252.97967529296875],[259.94940185546875,0.0,117.8436279296875,560.5171508789063],[168.64022827148438,568.7840576171875,0.0,28.568143844604492],[517.0733642578125,385.4801330566406,224.5548095703125,0.0]]],"layer_filaments":[[0,3],[1,3],[0,3],[0,1,3],[0,1,3],[1,3],[0,1,3],[1,2,3],[0,2],[2],[2],[2],[3],[1,2,3],[2,3],[1,2,3],[3],[3],[3],[3],[0,3],[3],[3],[2],[3],[2],[2],[0,2,3],[0,2,3],[0,1,2],[0,2],[2],[2],[2],[2],[2],[2],[2],[1,2],[1,2],[1,2],[1,2],[1,2],[2],[2],[2],[1],[1],[3],[3],[1],[0,1],[0,1],[0,1],[0,2,3],[0,2,3],[0],[0],[0,1],[0,1],[0,1,3],[0,1,3],[0,1,3],[3],[2],[2],[2],[2,3],[3],[0],[1,2,3],[3],[3],[1],[1],[1],[1],[0],[0,3],[0,3],[3],[3],[0,3],[3],[3],[3],[3],[2,3],[2,3],[2,3],[2,3],[0,2,3],[0,2,3],[0,3],[1,2,3],[3],[1],[3],[0],[0],[2],[2],[0,1,3],[0,3],[0],[0],[0],[0,2],[0,2],[2],[2],[2],[0,2],[2,3],[2,3],[2,3],[0,2,3],[0,2,3],[0,1,2,3],[0,2,3],[0,2,3],[2,3],[2],[2],[1,2],[2],[2],[0,3],[0,1],[0,1],[3],[3],[0,1,3],[0,1,3],[0,2,3],[0,3],[0],[0,2],[2],[0],[0,1],[0,1],[0,3],[0,3],[0,3],[0,3],[0,1,2],[0,2],[2,3],[1,2,3],[0,1,2],[3],[3],[3],[3],[1],[0,1],[0,1,3],[0,1,3],[0,1,2,3],[1,2,3],[1,2,3],[0],[0],[0],[0],[1],[3],[3],[0,1,2],[1,2],[2],[0,2],[0],[0,2],[0,2],[0,2],[1,2],[1,2],[0,1,2],[2],[1,2],[1,2],[1,2],[1,3],[1],[1],[1,3],[3],[0,1,3],[0,1],[0,1],[0,1],[0],[0],[0],[0],[2],[1],[1],[1],[3],[3],[3],[0,3],[0,3],[0,3],[0,2,3],[0,2,3],[0,1,2],[0,1,2],[1,2],[0,1],[0,1,3],[0,1,2],[0,1,2,3],[0,1,2,3],[1,3],[1,2],[0,1,2],[0,1,2],[0,1,2],[0,1,2],[1],[1],[3],[3],[0,3],[0,3],[0,1,3],[0,1],[1],[0],[0,2],[0,2,3],[2,3],[1,2,3],[0,1,2,3],[0,1,2],[0,1,2],[0,1,2,3],[0,1,2,3],[0,1],[1]],"unprintable_filaments":[[],[3]],"unprintable_volumes":{"2":[3],"3":[1]}},"nozzle_info":{"extruder_nozzle_list":{"0":[0],"1":[1,2]},"nozzle_list":[{"diameter":"0.4","extruder_id":0,"group_id":0,"volume_type":0},{"diameter":"0.4","extruder_id":1,"group_id":1,"volume_type":1},{"diameter":"0.4","extruder_id":1,"group_id":2,"volume_type":3}],"nozzle_status":{}},"speed_info":{"ams_preload_enabled":[true,true],"change_time_params":{"selector_load_time":1.0,"selector_unload_time":1.0,"standard_load_time":3.0,"standard_unload_time":2.0},"extruder_change_time":5.0,"filament_change_time":2.0,"filament_print_time":{},"group_with_time":false}},"metadata":{"config_type":"B","id":"B_constraint_103","seed":20103}}
|
||||
1
tests/filament_group/golden/config_b/constraint_88.json
Normal file
1
tests/filament_group/golden/config_b/constraint_88.json
Normal file
@@ -0,0 +1 @@
|
||||
{"base_result":{"constraints_ok":true,"flush_cost":9750,"full_score":4926.6},"context":{"group_info":{"filament_volume_map":[2,2,2,2],"has_filament_switcher":false,"ignore_ext_filament":false,"max_gap_threshold":0.01,"mode":0,"strategy":0,"total_filament_num":4},"machine_info":{"machine_filament_info":[[{"color":"#037005FF","extruder_id":0,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1},{"color":"#D87849FF","extruder_id":0,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1},{"color":"#F3CE18FF","extruder_id":0,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1},{"color":"#BCFEA7FF","extruder_id":0,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1}],[{"color":"#924B26FF","extruder_id":1,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1},{"color":"#3A216BFF","extruder_id":1,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1},{"color":"#D0924FFF","extruder_id":1,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1},{"color":"#A345ADFF","extruder_id":1,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1}]],"master_extruder_id":0,"max_group_size":[4,4],"prefer_non_model_filament":[false,false]},"model_info":{"filament_ids":["GFL_0","GFL_1","GFL_2","GFL_3"],"filament_info":[{"color":"#7FA1F1FF","is_support":false,"type":"PLA","usage_type":1},{"color":"#5864ACFF","is_support":false,"type":"TPU","usage_type":1},{"color":"#F36945FF","is_support":false,"type":"PA","usage_type":1},{"color":"#F02F44FF","is_support":false,"type":"PLA","usage_type":1}],"flush_matrix":[[[0.0,500.582275390625,562.5870971679688,81.93852233886719],[51.72758865356445,0.0,90.24585723876953,490.09320068359375],[430.67327880859375,232.1397247314453,0.0,146.47518920898438],[161.84280395507813,211.6325225830078,363.18682861328125,0.0]],[[0.0,554.25244140625,69.40193176269531,157.41722106933594],[122.196533203125,0.0,229.16275024414063,209.3227996826172],[192.6871337890625,78.21271514892578,0.0,35.126708984375],[227.9981689453125,364.66082763671875,66.126220703125,0.0]]],"layer_filaments":[[0,1,2],[0,1,2],[0,1,2,3],[0,1,2,3],[0,3],[0,3],[0,2],[0,1,3],[3],[1],[0],[2,3],[2],[2],[0,1,3],[0,1,3],[1,2,3],[0,1,2,3],[0,1,2,3],[0,2,3],[0,1,2],[0,1,2],[0,2],[0,2],[2],[2],[2],[0,2],[0,1,2],[0,1,2],[0,1,2],[0,1,2],[0],[2],[0,2],[0,2],[2],[2,3],[1,2],[1],[0],[0],[0,1,3],[1],[1],[0,2,3],[1,2],[1],[1],[2],[2],[1,2],[1,2],[1],[1,2],[1,2],[1,3],[3],[3],[1],[1],[0,1],[0,1],[0],[0,1],[0,1],[0],[1,2,3],[0,1,3],[0,1,2],[0,1],[1,2,3],[2],[2],[2],[2],[2],[1],[2,3],[1,2],[0],[0],[0],[0,2],[0],[0],[0],[0,2],[0,1,3],[0,1,3],[0,1,3],[1,3],[1],[0],[0],[0],[0,3],[0,3],[0],[0],[0],[1],[1,2],[1,2],[2],[0,2],[0,2],[0,2],[0,2],[1],[1],[1],[1],[1,2],[1],[1],[3],[0,3],[0,3],[0,2,3],[2,3],[2,3],[2],[2,3],[2,3],[3],[3],[1],[1,2],[0,1,2],[2],[2],[3],[3],[0,1,2],[0,1,2],[0,2],[0,2],[0],[0],[0],[1,2,3],[1,2,3],[1,2,3],[0,1,2],[0,1],[0,1],[1,2],[1,2],[0,1,2],[0,1,2],[1,2,3],[1,2,3],[1,2,3]],"unprintable_filaments":[[0],[2]],"unprintable_volumes":{"1":[1],"2":[1],"3":[1]}},"nozzle_info":{"extruder_nozzle_list":{"0":[0],"1":[1,2]},"nozzle_list":[{"diameter":"0.4","extruder_id":0,"group_id":0,"volume_type":0},{"diameter":"0.4","extruder_id":1,"group_id":1,"volume_type":0},{"diameter":"0.4","extruder_id":1,"group_id":2,"volume_type":1}],"nozzle_status":{}},"speed_info":{"ams_preload_enabled":[true,true],"change_time_params":{"selector_load_time":1.0,"selector_unload_time":1.0,"standard_load_time":3.0,"standard_unload_time":2.0},"extruder_change_time":5.0,"filament_change_time":2.0,"filament_print_time":{},"group_with_time":false}},"metadata":{"config_type":"B","id":"B_constraint_88","seed":20088}}
|
||||
1
tests/filament_group/golden/config_b/constraint_97.json
Normal file
1
tests/filament_group/golden/config_b/constraint_97.json
Normal file
@@ -0,0 +1 @@
|
||||
{"base_result":{"constraints_ok":true,"flush_cost":3367,"full_score":2146.2712},"context":{"group_info":{"filament_volume_map":[2,2,2,2,2],"has_filament_switcher":false,"ignore_ext_filament":false,"max_gap_threshold":0.01,"mode":0,"strategy":0,"total_filament_num":5},"machine_info":{"machine_filament_info":[[{"color":"#2A5557FF","extruder_id":0,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1},{"color":"#995701FF","extruder_id":0,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1},{"color":"#CBB5F1FF","extruder_id":0,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1},{"color":"#E09394FF","extruder_id":0,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1}],[{"color":"#38D65DFF","extruder_id":1,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1},{"color":"#A65DE8FF","extruder_id":1,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1},{"color":"#D6E92AFF","extruder_id":1,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1},{"color":"#21A474FF","extruder_id":1,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1}]],"master_extruder_id":0,"max_group_size":[4,6],"prefer_non_model_filament":[false,false]},"model_info":{"filament_ids":["GFL_0","GFL_1","GFL_2","GFL_3","GFL_4"],"filament_info":[{"color":"#341F11FF","is_support":false,"type":"ABS","usage_type":1},{"color":"#58D9A4FF","is_support":false,"type":"PETG","usage_type":1},{"color":"#11D7FFFF","is_support":true,"type":"PLA-S","usage_type":0},{"color":"#756DEFFF","is_support":true,"type":"PLA-S","usage_type":0},{"color":"#D28F74FF","is_support":false,"type":"TPU","usage_type":1}],"flush_matrix":[[[0.0,40.422149658203125,281.1308898925781,420.726318359375,245.7325439453125],[564.1063842773438,0.0,80.42797088623047,132.09776306152344,33.372379302978516],[29.10088539123535,327.1266784667969,0.0,318.0596923828125,352.4109191894531],[79.35222625732422,349.1927795410156,450.20599365234375,0.0,473.4333190917969],[84.93012237548828,343.9147033691406,480.2047119140625,440.6584167480469,0.0]],[[0.0,203.69725036621094,148.45620727539063,208.9656524658203,217.2571258544922],[170.0343780517578,0.0,516.4771728515625,587.4338989257813,442.1988830566406],[37.863525390625,265.5264892578125,0.0,318.85748291015625,227.68455505371094],[257.746826171875,217.14395141601563,120.68830108642578,0.0,310.3852233886719],[327.35687255859375,328.03076171875,271.71405029296875,420.61297607421875,0.0]]],"layer_filaments":[[1,2],[1],[1],[1],[1],[0,1],[1],[2],[1],[1,3],[4],[1],[4],[4],[4],[4],[2],[2,4],[4],[4],[2],[1,2],[1,2],[1],[0],[0],[0],[0,3],[2],[2],[2,3],[2],[2],[2],[2],[2],[2],[2],[0],[0],[2],[3],[4],[4],[2],[2],[2],[2],[2],[2],[2,3],[2,3],[2,3],[0,2,3],[1,3],[1,3,4],[1,3,4],[1,3,4],[3],[0],[1],[1],[1],[1,4],[1,4],[1,4],[0,1,4],[0,1,3,4],[0,1,3],[0,3],[0,3],[2,3,4],[2,4],[2],[2],[3],[1,3],[1,3,4],[1,2,3,4],[0,1,2,3,4],[0,1,2,4],[2,3,4],[2,3,4],[1],[4],[1,4],[1,4],[3],[3],[3],[3],[3],[1,3],[1,3,4],[1,3,4],[1,3],[0,1,3],[0,1,2,3],[0,2,3],[3],[1,3],[0,3,4],[2,3,4],[2,4],[2,3,4],[2,3,4],[0,2,3,4],[0,2,3,4],[0,1,2,3,4],[0,3,4],[0,3,4],[0],[0],[0,4],[4],[0,2],[0,2],[2],[2],[2],[0,2,3],[0,2,3],[0,1,2,3],[1,2,3],[2,3],[2,3],[0,2],[0,2,3],[2,3,4],[0,2,3],[0,1,2],[0,1,2],[0,1,2],[0,1,2],[1,2],[2],[2],[0,2],[0,2],[0,2,3],[0,2,3],[0,3],[0]],"unprintable_filaments":[[3],[]],"unprintable_volumes":{"3":[3],"4":[0]}},"nozzle_info":{"extruder_nozzle_list":{"0":[0],"1":[1,2,3]},"nozzle_list":[{"diameter":"0.4","extruder_id":0,"group_id":0,"volume_type":0},{"diameter":"0.4","extruder_id":1,"group_id":1,"volume_type":0},{"diameter":"0.4","extruder_id":1,"group_id":2,"volume_type":3},{"diameter":"0.4","extruder_id":1,"group_id":3,"volume_type":1}],"nozzle_status":{}},"speed_info":{"ams_preload_enabled":[true,true],"change_time_params":{"selector_load_time":1.0,"selector_unload_time":1.0,"standard_load_time":3.0,"standard_unload_time":2.0},"extruder_change_time":5.0,"filament_change_time":2.0,"filament_print_time":{},"group_with_time":false}},"metadata":{"config_type":"B","id":"B_constraint_97","seed":20097}}
|
||||
@@ -0,0 +1 @@
|
||||
{"base_result":{"constraints_ok":true,"flush_cost":3336,"full_score":2002.2096},"context":{"group_info":{"filament_volume_map":[2,2,2,2],"has_filament_switcher":false,"ignore_ext_filament":false,"max_gap_threshold":0.01,"mode":0,"strategy":1,"total_filament_num":4},"machine_info":{"machine_filament_info":[[{"color":"#28AC58FF","extruder_id":0,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1},{"color":"#412846FF","extruder_id":0,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1},{"color":"#733049FF","extruder_id":0,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1},{"color":"#0AC1B4FF","extruder_id":0,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1}],[{"color":"#A402D9FF","extruder_id":1,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1},{"color":"#0E94B8FF","extruder_id":1,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1},{"color":"#3F2B52FF","extruder_id":1,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1},{"color":"#CC874FFF","extruder_id":1,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1}]],"master_extruder_id":0,"max_group_size":[4,4],"prefer_non_model_filament":[false,false]},"model_info":{"filament_ids":["GFL_0","GFL_1","GFL_2","GFL_3"],"filament_info":[{"color":"#2739B9FF","is_support":false,"type":"PETG","usage_type":1},{"color":"#103A81FF","is_support":false,"type":"PA","usage_type":1},{"color":"#4B971AFF","is_support":false,"type":"PETG","usage_type":1},{"color":"#82A327FF","is_support":false,"type":"PLA","usage_type":1}],"flush_matrix":[[[0.0,160.58099365234375,186.70541381835938,293.4424133300781],[452.10089111328125,0.0,427.1346435546875,482.00482177734375],[26.20694351196289,235.17530822753906,0.0,410.3768615722656],[269.29888916015625,244.5442657470703,496.617919921875,0.0]],[[0.0,409.6128845214844,39.27631378173828,447.314697265625],[520.747314453125,0.0,143.31735229492188,21.33603286743164],[532.3141479492188,215.94216918945313,0.0,394.37481689453125],[321.899169921875,174.60623168945313,109.6452407836914,0.0]]],"layer_filaments":[[1,2,3],[1],[0,1],[0,1],[0,1],[0,1,2],[2],[0],[0],[2],[2],[0,3],[0,3],[0,2,3],[0,2,3],[0,2,3],[0,1,2,3],[0,1,2],[0,2],[0,2],[0,2],[1,2],[2],[2],[2],[2],[1,2],[1,2,3],[1,2,3],[0,2,3],[2,3],[3],[3],[3],[2,3],[2,3],[1,2],[0,2],[0,2],[2],[2],[2],[0,2],[2],[2],[0,1],[0,1],[1],[1],[1],[1],[1,2],[1],[0,2],[0,2,3],[2,3],[3],[0],[0,3],[3],[3],[2,3],[2,3],[2,3],[0,1],[0,1],[1],[1,3],[0,1,3],[0,1],[0,1,2],[0,1,2],[0,3],[0],[2],[2,3],[0,2,3],[0,1],[1],[2],[0],[0,2],[0,2],[2],[0,3],[0,1],[0,1],[0],[0,3],[0,2],[0],[0,1],[1],[1],[1],[0,1],[0],[0],[0,2,3],[3],[3],[3],[0,2],[0,2],[0,1],[0],[0],[0,1],[0,1,2],[0,2],[0,2],[0,1,2],[2,3],[2,3],[2,3],[3],[1,3],[1,3],[3],[1],[0,1,3],[0,3],[0,3],[0,3],[3],[3],[1],[0,1],[0,1],[0,1,2],[0,1],[0,1],[0,1,2],[0,1,2,3],[0,2,3],[3],[3],[3],[3],[1,2,3],[1,3],[1,2,3],[1,2,3],[1,2],[1,2],[1,2],[1],[1,2],[0,1,2],[2,3],[2,3],[3],[3],[0],[0,1],[0,1],[0,1],[0,2],[0,3],[2,3],[2,3],[2,3],[2],[2],[2],[0],[0,3],[0],[0],[0]],"unprintable_filaments":[[],[]],"unprintable_volumes":{}},"nozzle_info":{"extruder_nozzle_list":{"0":[0],"1":[1,2]},"nozzle_list":[{"diameter":"0.4","extruder_id":0,"group_id":0,"volume_type":0},{"diameter":"0.4","extruder_id":1,"group_id":1,"volume_type":0},{"diameter":"0.4","extruder_id":1,"group_id":2,"volume_type":0}],"nozzle_status":{}},"speed_info":{"ams_preload_enabled":[true,true],"change_time_params":{"selector_load_time":1.0,"selector_unload_time":1.0,"standard_load_time":3.0,"standard_unload_time":2.0},"extruder_change_time":5.0,"filament_change_time":2.0,"filament_print_time":{},"group_with_time":false}},"metadata":{"config_type":"B","id":"B_mode_bestfit_162","seed":20162}}
|
||||
1
tests/filament_group/golden/config_b/mode_match_148.json
Normal file
1
tests/filament_group/golden/config_b/mode_match_148.json
Normal file
File diff suppressed because one or more lines are too long
1
tests/filament_group/golden/config_b/mode_time_168.json
Normal file
1
tests/filament_group/golden/config_b/mode_time_168.json
Normal file
@@ -0,0 +1 @@
|
||||
{"base_result":{"constraints_ok":true,"flush_cost":5010,"full_score":2616.536},"context":{"group_info":{"filament_volume_map":[2,2,2,2,2],"has_filament_switcher":false,"ignore_ext_filament":false,"max_gap_threshold":0.01,"mode":0,"strategy":0,"total_filament_num":5},"machine_info":{"machine_filament_info":[[{"color":"#A7BDFDFF","extruder_id":0,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1},{"color":"#E4B564FF","extruder_id":0,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1},{"color":"#64E4F7FF","extruder_id":0,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1},{"color":"#79AE21FF","extruder_id":0,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1}],[{"color":"#BB3A10FF","extruder_id":1,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1},{"color":"#342AAEFF","extruder_id":1,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1},{"color":"#1B853FFF","extruder_id":1,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1},{"color":"#C98C73FF","extruder_id":1,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1}]],"master_extruder_id":0,"max_group_size":[4,4],"prefer_non_model_filament":[false,false]},"model_info":{"filament_ids":["GFL_0","GFL_1","GFL_2","GFL_3","GFL_4"],"filament_info":[{"color":"#B0F63DFF","is_support":false,"type":"TPU","usage_type":1},{"color":"#053F79FF","is_support":false,"type":"ABS","usage_type":1},{"color":"#2236EAFF","is_support":true,"type":"PLA-S","usage_type":0},{"color":"#3E1A46FF","is_support":true,"type":"PLA-S","usage_type":0},{"color":"#E193E6FF","is_support":false,"type":"PETG","usage_type":1}],"flush_matrix":[[[0.0,357.560302734375,158.03790283203125,78.87968444824219,56.35820388793945],[312.62969970703125,0.0,578.679443359375,354.6175537109375,333.78289794921875],[385.2695007324219,297.87310791015625,0.0,18.705421447753906,101.41615295410156],[106.06452941894531,286.4847412109375,112.56126403808594,0.0,244.12152099609375],[234.8699951171875,51.119590759277344,95.25473022460938,578.6362915039063,0.0]],[[0.0,354.75567626953125,51.230716705322266,514.29296875,451.8372802734375],[30.5628719329834,0.0,426.8621520996094,304.156005859375,114.46688079833984],[305.9984436035156,567.757568359375,0.0,254.84019470214844,480.94757080078125],[302.3028259277344,503.4031982421875,186.15228271484375,0.0,498.27532958984375],[198.755859375,412.3115234375,207.9266815185547,364.662841796875,0.0]]],"layer_filaments":[[1,2,4],[1,2],[1,2],[1,2],[1],[4],[4],[4],[0,4],[0,4],[1,2],[2],[2],[0,1,4],[0,1,2],[0,1,2],[0,2],[0],[0,4],[0,3,4],[0,2,3,4],[2,4],[0,2,4],[0,1,4],[0,1,2],[0,2],[0,2,3],[0,3],[0],[0],[2],[2],[2],[0,2],[0,1],[1],[1,2],[1],[1,3],[1,3],[3],[4],[0,3,4],[0,3,4],[4],[4],[4],[4],[4],[4],[2,4],[3],[3],[0],[4],[2,4],[2],[1],[4],[4],[4],[2,3,4],[1,2,4],[2,4],[3,4],[1],[1],[2],[2,3],[2,4],[2,4],[2,4],[2,4],[2],[2],[2],[2],[2],[2],[0],[0],[0,4],[0],[0],[1],[2],[1,2,4],[1,2],[1,2],[1],[1],[1],[1],[0],[0,4],[4],[4],[4],[1],[1],[1],[0,1],[0,1],[0,1],[0,3],[3],[0,1,4],[0],[4],[2],[2],[2],[0],[2],[2],[2],[2],[2,3],[0,2,4],[4],[4],[1,4],[4]],"unprintable_filaments":[[],[]],"unprintable_volumes":{}},"nozzle_info":{"extruder_nozzle_list":{"0":[0],"1":[1,2]},"nozzle_list":[{"diameter":"0.4","extruder_id":0,"group_id":0,"volume_type":0},{"diameter":"0.4","extruder_id":1,"group_id":1,"volume_type":0},{"diameter":"0.4","extruder_id":1,"group_id":2,"volume_type":0}],"nozzle_status":{}},"speed_info":{"ams_preload_enabled":[true,true],"change_time_params":{"selector_load_time":1.0,"selector_unload_time":1.0,"standard_load_time":3.0,"standard_unload_time":2.0},"extruder_change_time":5.0,"filament_change_time":2.0,"filament_print_time":{},"group_with_time":true}},"metadata":{"config_type":"B","id":"B_mode_time_168","seed":20168}}
|
||||
1
tests/filament_group/golden/config_b/stress_53.json
Normal file
1
tests/filament_group/golden/config_b/stress_53.json
Normal file
File diff suppressed because one or more lines are too long
1
tests/filament_group/golden/config_c/basic_3.json
Normal file
1
tests/filament_group/golden/config_c/basic_3.json
Normal file
@@ -0,0 +1 @@
|
||||
{"base_result":{"constraints_ok":true,"flush_cost":0,"full_score":533.0},"context":{"group_info":{"filament_volume_map":[2,2,2],"has_filament_switcher":false,"ignore_ext_filament":false,"max_gap_threshold":0.01,"mode":0,"strategy":0,"total_filament_num":3},"machine_info":{"machine_filament_info":[[{"color":"#FD981AFF","extruder_id":0,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1},{"color":"#6618F5FF","extruder_id":0,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1},{"color":"#BBA143FF","extruder_id":0,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1},{"color":"#26550AFF","extruder_id":0,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1}]],"master_extruder_id":0,"max_group_size":[3],"prefer_non_model_filament":[false]},"model_info":{"filament_ids":["GFL_0","GFL_1","GFL_2"],"filament_info":[{"color":"#911BD7FF","is_support":false,"type":"TPU","usage_type":1},{"color":"#89C1C0FF","is_support":false,"type":"PA","usage_type":1},{"color":"#C64EE6FF","is_support":false,"type":"ABS","usage_type":1}],"flush_matrix":[[[0.0,64.12446594238281,186.0951385498047],[482.5750427246094,0.0,78.35060119628906],[161.81735229492188,233.08798217773438,0.0]]],"layer_filaments":[[0,1],[0,1],[0],[0],[0],[0],[0,2],[1,2],[1,2],[0],[0,2],[0,1],[0],[0,2],[0,2],[2],[2],[1],[0,2],[0,2],[0],[0,2],[2],[2],[2],[0],[0],[0,1],[0,1],[0,2],[1,2],[1,2],[0,1],[0],[0],[0],[0,2],[0,1,2],[1,2],[0],[0],[0],[0],[2],[0,2],[0,2],[1,2],[1,2],[0],[0],[2],[2],[0],[0],[2],[2],[0,2],[0,2],[2],[1,2],[1,2],[1,2],[2],[2],[1],[1],[1],[1],[1],[0,1],[0],[0],[0,2],[0,2],[0,2],[0],[0],[0,2],[1,2],[1,2],[1,2],[0,2],[1,2],[1,2],[2],[2],[2],[0,1],[0,1],[1],[1],[0],[0,2],[0,2],[0,2],[0,2],[0,2],[0,1],[0],[0],[0,1],[0,1],[1],[1,2],[1],[1],[0],[0,2],[0,2],[0,2],[0,2],[0],[1],[1],[0,1],[0,1],[1,2],[0,1],[0,1],[1],[0],[0,2],[0,2],[0],[0],[0],[0],[0,2],[2],[0],[1],[0,2],[0,2],[0,1,2],[0,2],[0,2],[0,2],[1,2],[2],[2],[1]],"unprintable_filaments":[[]],"unprintable_volumes":{}},"nozzle_info":{"extruder_nozzle_list":{"0":[0,1,2]},"nozzle_list":[{"diameter":"0.4","extruder_id":0,"group_id":0,"volume_type":0},{"diameter":"0.4","extruder_id":0,"group_id":1,"volume_type":1},{"diameter":"0.4","extruder_id":0,"group_id":2,"volume_type":2}],"nozzle_status":{}},"speed_info":{"ams_preload_enabled":[true],"change_time_params":{"selector_load_time":1.0,"selector_unload_time":1.0,"standard_load_time":3.0,"standard_unload_time":2.0},"extruder_change_time":5.0,"filament_change_time":2.0,"filament_print_time":{},"group_with_time":false}},"metadata":{"config_type":"C","id":"C_basic_3","seed":30003}}
|
||||
1
tests/filament_group/golden/config_c/basic_33.json
Normal file
1
tests/filament_group/golden/config_c/basic_33.json
Normal file
@@ -0,0 +1 @@
|
||||
{"base_result":{"constraints_ok":true,"flush_cost":0,"full_score":608.0},"context":{"group_info":{"filament_volume_map":[2,2,2,2,2],"has_filament_switcher":false,"ignore_ext_filament":false,"max_gap_threshold":0.01,"mode":0,"strategy":0,"total_filament_num":5},"machine_info":{"machine_filament_info":[[{"color":"#93FE0AFF","extruder_id":0,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1},{"color":"#FE6A12FF","extruder_id":0,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1},{"color":"#E78D3BFF","extruder_id":0,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1},{"color":"#A20CB0FF","extruder_id":0,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1}]],"master_extruder_id":0,"max_group_size":[5],"prefer_non_model_filament":[false]},"model_info":{"filament_ids":["GFL_0","GFL_1","GFL_2","GFL_3","GFL_4"],"filament_info":[{"color":"#C51507FF","is_support":false,"type":"ABS","usage_type":1},{"color":"#D366B2FF","is_support":false,"type":"ABS","usage_type":1},{"color":"#D07D0EFF","is_support":false,"type":"PLA","usage_type":1},{"color":"#E4C00AFF","is_support":true,"type":"PLA-S","usage_type":0},{"color":"#12B359FF","is_support":false,"type":"PETG","usage_type":1}],"flush_matrix":[[[0.0,274.9917297363281,455.0113830566406,551.6867065429688,562.2197265625],[345.1457214355469,0.0,337.8075866699219,484.70074462890625,465.59490966796875],[332.17742919921875,481.7458801269531,0.0,339.1631164550781,394.23785400390625],[317.8833923339844,417.9119873046875,523.1845092773438,0.0,164.09495544433594],[455.5447692871094,228.3113555908203,309.9906311035156,487.07562255859375,0.0]]],"layer_filaments":[[1,3,4],[1,2],[1,2,4],[2,4],[2,4],[2,4],[3,4],[3,4],[1,3,4],[0,3,4],[0,1,3,4],[0,1,3,4],[0,1,2,3,4],[1,2],[1],[0],[4],[2],[2],[2,4],[0,2],[0,2,3],[1,2],[1],[1],[1,4],[4],[0,4],[1],[1],[1],[1,2],[0,3,4],[1,2,3],[1,2],[0,1,2],[0,1,2],[0,1,2],[2,4],[2,4],[2],[2],[1],[1],[0],[1,3,4],[0,1,3,4],[0,1,2,3],[0,1,3,4],[0,1,2],[0,2],[0,2],[2],[2],[2,3],[2,3],[0,1,4],[0,4],[0,4],[0,4],[0,2,4],[0,4],[0],[0,2],[0,2],[0,2],[0,2],[0],[0],[2],[2],[2],[0,2],[0,2],[0,2],[0],[2],[2],[2],[2],[1],[0],[0],[0],[0],[2],[2,3],[3,4],[3],[3],[0,2],[4],[0],[0],[2,3],[2,3],[2,3],[2],[1,2,4],[1,2],[1,2],[1,2,3],[2,3],[0,3],[0],[0,3],[0,2,3],[0,2],[2],[2],[2],[2],[1],[1],[1]],"unprintable_filaments":[[]],"unprintable_volumes":{}},"nozzle_info":{"extruder_nozzle_list":{"0":[0,1,2,3,4]},"nozzle_list":[{"diameter":"0.4","extruder_id":0,"group_id":0,"volume_type":0},{"diameter":"0.4","extruder_id":0,"group_id":1,"volume_type":1},{"diameter":"0.4","extruder_id":0,"group_id":2,"volume_type":2},{"diameter":"0.4","extruder_id":0,"group_id":3,"volume_type":3},{"diameter":"0.4","extruder_id":0,"group_id":4,"volume_type":0}],"nozzle_status":{}},"speed_info":{"ams_preload_enabled":[true],"change_time_params":{"selector_load_time":1.0,"selector_unload_time":1.0,"standard_load_time":3.0,"standard_unload_time":2.0},"extruder_change_time":5.0,"filament_change_time":2.0,"filament_print_time":{},"group_with_time":false}},"metadata":{"config_type":"C","id":"C_basic_33","seed":30033}}
|
||||
1
tests/filament_group/golden/config_c/constraint_85.json
Normal file
1
tests/filament_group/golden/config_c/constraint_85.json
Normal file
@@ -0,0 +1 @@
|
||||
{"base_result":{"constraints_ok":true,"flush_cost":8700,"full_score":4679.32},"context":{"group_info":{"filament_volume_map":[2,2,2,2],"has_filament_switcher":false,"ignore_ext_filament":false,"max_gap_threshold":0.01,"mode":0,"strategy":0,"total_filament_num":4},"machine_info":{"machine_filament_info":[[{"color":"#D04D20FF","extruder_id":0,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1},{"color":"#408006FF","extruder_id":0,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1},{"color":"#A31F43FF","extruder_id":0,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1},{"color":"#846951FF","extruder_id":0,"is_extended":false,"is_support":false,"type":"PLA","usage_type":1}]],"master_extruder_id":0,"max_group_size":[4],"prefer_non_model_filament":[false]},"model_info":{"filament_ids":["GFL_0","GFL_1","GFL_2","GFL_3"],"filament_info":[{"color":"#92C1F9FF","is_support":false,"type":"PLA","usage_type":1},{"color":"#B0D9AAFF","is_support":false,"type":"PA","usage_type":1},{"color":"#5855D4FF","is_support":false,"type":"TPU","usage_type":1},{"color":"#2CEAEAFF","is_support":false,"type":"PLA","usage_type":1}],"flush_matrix":[[[0.0,263.98394775390625,335.4018249511719,562.2523193359375],[514.525390625,0.0,491.3005676269531,28.14336585998535],[562.559814453125,351.77215576171875,0.0,321.54388427734375],[304.2557067871094,320.45123291015625,470.2841796875,0.0]]],"layer_filaments":[[2,3],[3],[3],[3],[1,3],[1,2,3],[0,1,2],[2],[2],[2],[2],[2],[2],[1],[1,2],[0,2],[0,2],[0,2],[1],[1],[1],[1],[1],[1],[1,3],[1,3],[1,3],[0],[0],[0],[0],[0],[3],[3],[3],[1,3],[1,3],[2],[2],[0,2],[2,3],[2,3],[3],[3],[1,2],[1,3],[1,3],[1,3],[0,1,3],[1,3],[3],[3],[3],[0,3],[0,1,2],[2],[2],[3],[3],[2,3],[1,2,3],[1,2,3],[1],[1],[1,2],[1,2],[1,2],[1,2],[2],[2,3],[2,3],[3],[0,2,3],[0,2,3],[0,1,2,3],[0,1,2,3],[1,2,3],[1,2,3],[1,2,3],[1,2,3],[0,1,3],[0,1,3],[0,1,3],[0,1,2,3],[2,3],[2,3],[2],[2],[0,2],[0,2],[0,2],[0,1,2],[1,2],[2,3],[2,3],[2],[2],[0,1],[0],[1,3],[2,3],[2],[2,3],[3],[3],[3],[0,2],[0,2],[0,2],[0,2],[0,1,2],[0,1,2],[0,1,2],[0,2,3],[1,3],[1,3],[1,3],[0,1],[0,1],[1,3],[3],[2,3],[3],[3],[0,3],[0],[0],[0,3],[0,1],[0,1],[0,1],[1,3],[1,3],[3],[3],[3],[2,3],[2],[2],[1],[1],[0,1,2],[0,1,3],[2,3],[2,3],[3],[1,3],[1,3],[0,1,3],[0,2],[1,3]],"unprintable_filaments":[[]],"unprintable_volumes":{"2":[1],"3":[1]}},"nozzle_info":{"extruder_nozzle_list":{"0":[0,1,2]},"nozzle_list":[{"diameter":"0.4","extruder_id":0,"group_id":0,"volume_type":0},{"diameter":"0.4","extruder_id":0,"group_id":1,"volume_type":1},{"diameter":"0.4","extruder_id":0,"group_id":2,"volume_type":2}],"nozzle_status":{}},"speed_info":{"ams_preload_enabled":[true],"change_time_params":{"selector_load_time":1.0,"selector_unload_time":1.0,"standard_load_time":3.0,"standard_unload_time":2.0},"extruder_change_time":5.0,"filament_change_time":2.0,"filament_print_time":{},"group_with_time":false}},"metadata":{"config_type":"C","id":"C_constraint_85","seed":30085}}
|
||||
1
tests/filament_group/golden/config_c/stress_66.json
Normal file
1
tests/filament_group/golden/config_c/stress_66.json
Normal file
File diff suppressed because one or more lines are too long
1
tests/filament_group/golden/config_c/stress_79.json
Normal file
1
tests/filament_group/golden/config_c/stress_79.json
Normal file
File diff suppressed because one or more lines are too long
@@ -56,8 +56,10 @@ struct NfpPlacerFixture {
|
||||
} // namespace
|
||||
|
||||
TEST_CASE_METHOD(NfpPlacerFixture, "NfpPlacer places a single item inside the bin", "[Nesting][Placer]") {
|
||||
NfpPlacer placer = placer_with();
|
||||
// The placer only keeps references to the items it packs and re-reads them
|
||||
// from finalAlign() in its destructor, so the item must outlive the placer.
|
||||
RectangleItem item{100000000, 100000000};
|
||||
NfpPlacer placer = placer_with();
|
||||
|
||||
REQUIRE(place(placer, item));
|
||||
REQUIRE(placer.getItems().size() == 1u);
|
||||
@@ -103,12 +105,15 @@ TEST_CASE_METHOD(NfpPlacerFixture, "NfpPlacer packs many items without overlap",
|
||||
}
|
||||
|
||||
TEST_CASE_METHOD(NfpPlacerFixture, "NfpPlacer evaluates the rotation candidates", "[Nesting][Placer]") {
|
||||
// The placer re-reads its packed items from finalAlign() in its destructor,
|
||||
// so the items must outlive the placer — declare them first.
|
||||
std::vector<RectangleItem> rects = {
|
||||
{180000000, 40000000}, {180000000, 40000000}, {180000000, 40000000}};
|
||||
|
||||
Cfg cfg;
|
||||
cfg.rotations = {0.0, Pi / 2.0}; // exercise the rotation search loop
|
||||
NfpPlacer placer = placer_with(cfg);
|
||||
|
||||
std::vector<RectangleItem> rects = {
|
||||
{180000000, 40000000}, {180000000, 40000000}, {180000000, 40000000}};
|
||||
place_all(placer, rects);
|
||||
require_disjoint_in_bin(rects);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ add_executable(${_TEST_NAME}_tests
|
||||
test_clipper_offset.cpp
|
||||
test_clipper_utils.cpp
|
||||
test_config.cpp
|
||||
test_config_variant_expansion.cpp
|
||||
test_toolordering_nozzle_group.cpp
|
||||
test_preset_bundle_loading.cpp
|
||||
test_preset_setting_id.cpp
|
||||
test_preset_diff.cpp
|
||||
@@ -21,9 +23,11 @@ add_executable(${_TEST_NAME}_tests
|
||||
test_polygon.cpp
|
||||
test_mutable_polygon.cpp
|
||||
test_mutable_priority_queue.cpp
|
||||
test_nozzle_volume_type.cpp
|
||||
test_stl.cpp
|
||||
test_meshboolean.cpp
|
||||
test_marchingsquares.cpp
|
||||
test_utils.cpp
|
||||
test_timeutils.cpp
|
||||
test_voronoi.cpp
|
||||
test_optimizers.cpp
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
|
||||
#include "libslic3r/Model.hpp"
|
||||
#include "libslic3r/Format/3mf.hpp"
|
||||
#include "libslic3r/Format/bbs_3mf.hpp"
|
||||
#include "libslic3r/Format/STL.hpp"
|
||||
#include "libslic3r/PrintConfig.hpp"
|
||||
#include "libslic3r/Semver.hpp"
|
||||
#include "libslic3r/Preset.hpp"
|
||||
#include "libslic3r/MultiNozzleUtils.hpp"
|
||||
#include "libslic3r/ProjectTask.hpp"
|
||||
|
||||
#include <boost/filesystem/operations.hpp>
|
||||
|
||||
@@ -133,6 +139,376 @@ SCENARIO("Export+Import geometry to/from 3mf file cycle", "[3mf]") {
|
||||
}
|
||||
}
|
||||
|
||||
// .3mf multi-nozzle round-trip.
|
||||
// Locks the load/save handling for the H2C multi-nozzle plate metadata:
|
||||
// * filament_volume_maps -> plate config "filament_volume_map" (with the >1 -> 0 clamp)
|
||||
// * nozzle_volume_type -> PlateData::nozzle_volume_types (previously write-only)
|
||||
// and pins the deliberately-lossy keys (enable_filament_dynamic_map) so a future change has to
|
||||
// consciously unpin them. Uses a store_bbs_3mf -> load_bbs_3mf cycle (no external fixture needed).
|
||||
SCENARIO("H2C multi-nozzle .3mf round-trip", "[3mf][MultiNozzle]") {
|
||||
GIVEN("a plate carrying multi-nozzle filament assignment metadata") {
|
||||
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();
|
||||
|
||||
// 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);
|
||||
|
||||
// Global (printer) config: give nozzle_volume_type a non-default value so the slice_info
|
||||
// read-back is a meaningful assertion (High Flow == 1).
|
||||
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
||||
config.set_key_value("nozzle_volume_type",
|
||||
new ConfigOptionEnumsGeneric({ (int) NozzleVolumeType::nvtHighFlow }));
|
||||
|
||||
PlateData* plate = new PlateData();
|
||||
plate->plate_index = 0;
|
||||
plate->is_sliced_valid = true; // gate for the slice_info.config writer (nozzle_volume_type)
|
||||
plate->filament_maps = { 1, 2, 1 }; // slice_info uses this; keep it == model_settings' value
|
||||
plate->config.set_key_value("filament_map_mode", new ConfigOptionEnum<FilamentMapMode>(fmmManual));
|
||||
plate->config.set_key_value("filament_map", new ConfigOptionInts({ 1, 2, 1 }));
|
||||
// Deliberately include out-of-range volume-type ids (2 == Hybrid, 3 == TPU High Flow):
|
||||
// the loader must clamp them back to Standard (0).
|
||||
plate->config.set_key_value("filament_volume_map", new ConfigOptionInts({ 0, 2, 1, 3 }));
|
||||
// Known-lossy: a true value must NOT survive the round-trip (slice_info hardcodes false,
|
||||
// model_settings never writes it).
|
||||
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";
|
||||
|
||||
StoreParams store_params;
|
||||
store_params.path = test_file.c_str();
|
||||
store_params.model = &model;
|
||||
store_params.config = &config;
|
||||
store_params.plate_data_list.push_back(plate);
|
||||
store_params.strategy = SaveStrategy::Zip64 | SaveStrategy::Silence;
|
||||
REQUIRE(store_bbs_3mf(store_params));
|
||||
|
||||
Model dst_model;
|
||||
DynamicPrintConfig dst_config;
|
||||
ConfigSubstitutionContext ctxt{ ForwardCompatibilitySubstitutionRule::Enable };
|
||||
PlateDataPtrs dst_plates;
|
||||
std::vector<Preset*> project_presets;
|
||||
bool is_bbl_3mf = false, is_orca_3mf = false;
|
||||
Semver file_version;
|
||||
// LoadConfig is required for slice_info.config (nozzle_volume_type) to be parsed —
|
||||
// matches how the app loads projects.
|
||||
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);
|
||||
PlateData* rt = dst_plates.front();
|
||||
|
||||
// filament_map (model_settings + slice_info; already round-tripped)
|
||||
auto* fmap = rt->config.option<ConfigOptionInts>("filament_map");
|
||||
REQUIRE(fmap != nullptr);
|
||||
REQUIRE(fmap->values == std::vector<int>({ 1, 2, 1 }));
|
||||
|
||||
// filament_volume_map (model_settings) with the >1 -> 0 clamp
|
||||
auto* fvmap = rt->config.option<ConfigOptionInts>("filament_volume_map");
|
||||
REQUIRE(fvmap != nullptr);
|
||||
REQUIRE(fvmap->values == std::vector<int>({ 0, 0, 1, 0 }));
|
||||
|
||||
// nozzle_volume_type read-back into PlateData::nozzle_volume_types
|
||||
REQUIRE(rt->nozzle_volume_types == "1");
|
||||
|
||||
// enable_filament_dynamic_map pinned lossy: model_settings never serializes it and
|
||||
// slice_info hardcodes false, so the `true` we set is dropped. Pinned here
|
||||
// (absent or false, never true) so a future change that persists it must update this.
|
||||
auto* dyn = rt->config.option<ConfigOptionBool>("enable_filament_dynamic_map");
|
||||
const bool persisted_true = (dyn != nullptr && dyn->value);
|
||||
REQUIRE_FALSE(persisted_true);
|
||||
}
|
||||
|
||||
release_PlateData_list(dst_plates);
|
||||
}
|
||||
delete plate; // store_bbs_3mf does not take ownership of the source plate
|
||||
boost::filesystem::remove_all(backup_dir);
|
||||
}
|
||||
}
|
||||
|
||||
// Saved nozzle diameter for a single-nozzle-per-extruder printer with a non-standard nozzle.
|
||||
// The grouping result rounds every nozzle diameter to the nearest of {0.2,0.4,0.6,0.8} for its
|
||||
// internal matching key. That rounded value must NOT reach the saved <filament>/<nozzle> metadata on
|
||||
// a printer whose extruders each carry one nozzle: the exact per-extruder config diameter is written
|
||||
// instead, so a 0.5 mm nozzle is preserved rather than saved as 0.4. (Only an extruder that carries a
|
||||
// nozzle cluster, which the per-extruder config cannot express, keeps the grouping result's diameter.)
|
||||
SCENARIO("Non-standard nozzle diameter survives .3mf save on a single-nozzle printer", "[3mf][MultiNozzle]") {
|
||||
GIVEN("a single-extruder plate whose nozzle is 0.5 mm and whose stamped diameter was rounded to 0.4") {
|
||||
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();
|
||||
|
||||
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);
|
||||
|
||||
// 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.
|
||||
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
||||
config.set_key_value("nozzle_diameter", new ConfigOptionFloats({ 0.5 }));
|
||||
|
||||
PlateData* plate = new PlateData();
|
||||
plate->plate_index = 0;
|
||||
plate->is_sliced_valid = true; // gate for the slice_info.config writer
|
||||
plate->filament_maps = { 1 };
|
||||
|
||||
// Seed the stamped diameter with the grouping result's rounded value (0.5 -> 0.4) so the
|
||||
// assertion proves the writer ignores it and emits the exact config diameter instead.
|
||||
FilamentInfo fi;
|
||||
fi.id = 0;
|
||||
fi.type = "PLA";
|
||||
fi.color = "#FFFFFFFF";
|
||||
fi.group_id = { 0 };
|
||||
fi.nozzle_diameter = 0.4; // rounded; must NOT be the value written
|
||||
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";
|
||||
|
||||
StoreParams store_params;
|
||||
store_params.path = test_file.c_str();
|
||||
store_params.model = &model;
|
||||
store_params.config = &config;
|
||||
store_params.plate_data_list.push_back(plate);
|
||||
store_params.strategy = SaveStrategy::Zip64 | SaveStrategy::Silence;
|
||||
REQUIRE(store_bbs_3mf(store_params));
|
||||
|
||||
Model dst_model;
|
||||
DynamicPrintConfig dst_config;
|
||||
ConfigSubstitutionContext ctxt{ ForwardCompatibilitySubstitutionRule::Enable };
|
||||
PlateDataPtrs dst_plates;
|
||||
std::vector<Preset*> project_presets;
|
||||
bool is_bbl_3mf = false, is_orca_3mf = false;
|
||||
Semver file_version;
|
||||
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);
|
||||
PlateData* rt = dst_plates.front();
|
||||
|
||||
// <nozzle> tag: device-facing per-nozzle diameter string, written verbatim.
|
||||
REQUIRE(rt->nozzles_info.size() >= 1);
|
||||
REQUIRE(rt->nozzles_info.front().diameter == "0.5");
|
||||
|
||||
// <filament> tag: per-filament nozzle_diameter parsed back as 0.5, not 0.4.
|
||||
REQUIRE(rt->slice_filaments_info.size() >= 1);
|
||||
REQUIRE_THAT(rt->slice_filaments_info.front().nozzle_diameter, Catch::Matchers::WithinAbs(0.5, 1e-6));
|
||||
}
|
||||
|
||||
release_PlateData_list(dst_plates);
|
||||
}
|
||||
delete plate; // store_bbs_3mf does not take ownership of the source plate
|
||||
boost::filesystem::remove_all(backup_dir);
|
||||
}
|
||||
}
|
||||
|
||||
// A legacy / foreign project (no multi-nozzle metadata) must load crash-safe through the BBS
|
||||
// importer and must not fabricate a filament_volume_map.
|
||||
SCENARIO("Legacy project loads crash-safe via load_bbs_3mf", "[3mf][MultiNozzle]") {
|
||||
GIVEN("a project without any multi-nozzle metadata") {
|
||||
std::string path = std::string(TEST_DATA_DIR) + "/test_3mf/Geräte/Büchse.3mf";
|
||||
Model model;
|
||||
DynamicPrintConfig config;
|
||||
ConfigSubstitutionContext ctxt{ ForwardCompatibilitySubstitutionRule::Enable };
|
||||
PlateDataPtrs plates;
|
||||
std::vector<Preset*> project_presets;
|
||||
bool is_bbl_3mf = false, is_orca_3mf = false;
|
||||
Semver file_version;
|
||||
|
||||
WHEN("loaded through the BBS importer") {
|
||||
bool loaded = false;
|
||||
REQUIRE_NOTHROW(loaded = load_bbs_3mf(path.c_str(), &config, &ctxt, &model, &plates,
|
||||
&project_presets, &is_bbl_3mf, &is_orca_3mf,
|
||||
&file_version, nullptr,
|
||||
LoadStrategy::LoadModel | LoadStrategy::LoadConfig));
|
||||
THEN("it does not crash and invents no per-filament volume map") {
|
||||
for (PlateData* p : plates) {
|
||||
REQUIRE(p->config.option<ConfigOptionInts>("filament_volume_map") == nullptr);
|
||||
}
|
||||
}
|
||||
release_PlateData_list(plates);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Device-side nozzle-grouping serialization surface.
|
||||
// Direct unit coverage for the pure serialize/deserialize + StaticNozzleGroupResult helpers that the
|
||||
// gcode.3mf writer/reader lean on.
|
||||
SCENARIO("MultiNozzle serialization helpers", "[3mf][MultiNozzle]") {
|
||||
using namespace Slic3r::MultiNozzleUtils;
|
||||
|
||||
GIVEN("NozzleInfo / NozzleGroupInfo") {
|
||||
NozzleInfo n0; n0.group_id = 0; n0.extruder_id = 0; n0.diameter = "0.4"; n0.volume_type = nvtStandard;
|
||||
NozzleInfo n1; n1.group_id = 1; n1.extruder_id = 1; n1.diameter = "0.4"; n1.volume_type = nvtHighFlow;
|
||||
|
||||
THEN("NozzleInfo::serialize matches the <nozzle> tag attributes (extruder_id 1-based)") {
|
||||
REQUIRE(n0.serialize() == "id=\"0\" extruder_id=\"1\" nozzle_diameter=\"0.4\" volume_type=\"Standard\"");
|
||||
REQUIRE(n1.serialize() == "id=\"1\" extruder_id=\"2\" nozzle_diameter=\"0.4\" volume_type=\"High Flow\"");
|
||||
}
|
||||
THEN("NozzleGroupInfo serialize/deserialize round-trips and rejects malformed input") {
|
||||
NozzleGroupInfo g("0.4", nvtHighFlow, 1, 3);
|
||||
REQUIRE(g.serialize() == "1-0.4-High Flow-3");
|
||||
auto rt = NozzleGroupInfo::deserialize(g.serialize());
|
||||
REQUIRE(rt.has_value());
|
||||
REQUIRE(*rt == g);
|
||||
REQUIRE_FALSE(NozzleGroupInfo::deserialize("1-0.4-Standard").has_value()); // too few tokens
|
||||
REQUIRE_FALSE(NozzleGroupInfo::deserialize("x-0.4-Standard-3").has_value()); // non-numeric extruder
|
||||
}
|
||||
}
|
||||
|
||||
GIVEN("a StaticNozzleGroupResult built from filament + nozzle infos") {
|
||||
std::vector<NozzleInfo> nozzles;
|
||||
{ NozzleInfo n; n.group_id = 0; n.extruder_id = 0; n.diameter = "0.4"; n.volume_type = nvtStandard; nozzles.push_back(n); }
|
||||
{ NozzleInfo n; n.group_id = 1; n.extruder_id = 1; n.diameter = "0.4"; n.volume_type = nvtHighFlow; nozzles.push_back(n); }
|
||||
|
||||
std::vector<FilamentInfo> filaments(3);
|
||||
filaments[0].id = 0; filaments[0].group_id = { 0 };
|
||||
filaments[1].id = 1; filaments[1].group_id = { 1 };
|
||||
filaments[2].id = 2; filaments[2].group_id = { 0, 1 };
|
||||
|
||||
auto result = StaticNozzleGroupResult::create(filaments, nozzles, { 0, 1, 2 }, { 0, 1, 0 }, false);
|
||||
REQUIRE(result.has_value());
|
||||
|
||||
THEN("filament->nozzle queries resolve to the stored mapping") {
|
||||
REQUIRE(result->get_extruder_count() == 2);
|
||||
REQUIRE(result->get_used_extruders() == std::vector<int>({ 0, 1 }));
|
||||
REQUIRE(result->get_used_filaments() == std::vector<unsigned int>({ 0, 1, 2 }));
|
||||
REQUIRE(result->get_nozzles_for_filament(0).size() == 1);
|
||||
REQUIRE(result->get_nozzles_for_filament(2).size() == 2);
|
||||
// first-use resolves through the (filament,nozzle) change sequences.
|
||||
auto first = result->get_first_nozzle_for_filament(1);
|
||||
REQUIRE(first.has_value());
|
||||
REQUIRE(first->group_id == 1);
|
||||
}
|
||||
THEN("empty inputs yield nullopt") {
|
||||
REQUIRE_FALSE(StaticNozzleGroupResult::create({}, nozzles, {}, {}, false).has_value());
|
||||
REQUIRE_FALSE(StaticNozzleGroupResult::create(filaments, {}, {}, {}, false).has_value());
|
||||
}
|
||||
}
|
||||
|
||||
GIVEN("load_nozzle_infos_with_compatibility fallbacks") {
|
||||
std::vector<NozzleInfo> new_format;
|
||||
{ NozzleInfo n; n.group_id = 1; n.extruder_id = 1; n.diameter = "0.4"; n.volume_type = nvtHighFlow; new_format.push_back(n); }
|
||||
{ NozzleInfo n; n.group_id = 0; n.extruder_id = 0; n.diameter = "0.4"; n.volume_type = nvtStandard; new_format.push_back(n); }
|
||||
|
||||
THEN("new-format <nozzle> tags are returned sorted by logical id") {
|
||||
auto out = load_nozzle_infos_with_compatibility(new_format, {}, {}, {}, {});
|
||||
REQUIRE(out.size() == 2);
|
||||
REQUIRE(out[0].group_id == 0);
|
||||
REQUIRE(out[1].group_id == 1);
|
||||
}
|
||||
THEN("oldest single-nozzle 3mf (no tags, no filament group_id) rebuilds from diameters/volume types") {
|
||||
std::vector<NozzleVolumeType> vt = { nvtStandard, nvtHighFlow };
|
||||
std::vector<double> dia = { 0.4, 0.4 };
|
||||
auto out = load_nozzle_infos_with_compatibility({}, {}, {}, vt, dia);
|
||||
REQUIRE(out.size() == 2);
|
||||
REQUIRE(out[0].extruder_id == 0);
|
||||
REQUIRE(out[0].volume_type == nvtStandard);
|
||||
REQUIRE(out[1].volume_type == nvtHighFlow);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The layer-aware grouping result must survive the gcode.3mf write/read as
|
||||
// <nozzle> tags and the enable_filament_dynamic_map flag. Proves the parse_filament_info stamping,
|
||||
// the NOZZLE_TAG writer, the _handle_config_nozzle reader, and the nozzles_info plate copy.
|
||||
SCENARIO("Nozzle-group metadata .3mf round-trip", "[3mf][MultiNozzle]") {
|
||||
GIVEN("a plate carrying a two-nozzle LayeredNozzleGroupResult") {
|
||||
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();
|
||||
|
||||
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);
|
||||
|
||||
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
||||
|
||||
std::vector<MultiNozzleUtils::NozzleInfo> nozzles;
|
||||
{ MultiNozzleUtils::NozzleInfo n; n.group_id = 0; n.extruder_id = 0; n.diameter = "0.4"; n.volume_type = NozzleVolumeType::nvtStandard; nozzles.push_back(n); }
|
||||
{ MultiNozzleUtils::NozzleInfo n; n.group_id = 1; n.extruder_id = 1; n.diameter = "0.4"; n.volume_type = NozzleVolumeType::nvtHighFlow; nozzles.push_back(n); }
|
||||
auto group = MultiNozzleUtils::LayeredNozzleGroupResult::create(
|
||||
std::vector<int>{ 0, 1, 0 }, nozzles, std::vector<unsigned int>{ 0, 1, 2 });
|
||||
REQUIRE(group.has_value());
|
||||
|
||||
PlateData* plate = new PlateData();
|
||||
plate->plate_index = 0;
|
||||
plate->is_sliced_valid = true;
|
||||
plate->filament_maps = { 1, 2, 1 };
|
||||
plate->nozzle_group_result = group;
|
||||
plate->config.set_key_value("filament_map_mode", new ConfigOptionEnum<FilamentMapMode>(fmmManual));
|
||||
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";
|
||||
|
||||
StoreParams store_params;
|
||||
store_params.path = test_file.c_str();
|
||||
store_params.model = &model;
|
||||
store_params.config = &config;
|
||||
store_params.plate_data_list.push_back(plate);
|
||||
store_params.strategy = SaveStrategy::Zip64 | SaveStrategy::Silence;
|
||||
REQUIRE(store_bbs_3mf(store_params));
|
||||
|
||||
Model dst_model;
|
||||
DynamicPrintConfig dst_config;
|
||||
ConfigSubstitutionContext ctxt{ ForwardCompatibilitySubstitutionRule::Enable };
|
||||
PlateDataPtrs dst_plates;
|
||||
std::vector<Preset*> project_presets;
|
||||
bool is_bbl_3mf = false, is_orca_3mf = false;
|
||||
Semver file_version;
|
||||
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);
|
||||
PlateData* rt = dst_plates.front();
|
||||
|
||||
REQUIRE(rt->nozzles_info.size() == 2);
|
||||
// reader stores extruder_id 0-based (tag is 1-based), diameter/volume_type preserved.
|
||||
std::sort(rt->nozzles_info.begin(), rt->nozzles_info.end());
|
||||
REQUIRE(rt->nozzles_info[0].group_id == 0);
|
||||
REQUIRE(rt->nozzles_info[0].extruder_id == 0);
|
||||
REQUIRE(rt->nozzles_info[0].diameter == "0.4");
|
||||
REQUIRE(rt->nozzles_info[0].volume_type == NozzleVolumeType::nvtStandard);
|
||||
REQUIRE(rt->nozzles_info[1].group_id == 1);
|
||||
REQUIRE(rt->nozzles_info[1].extruder_id == 1);
|
||||
REQUIRE(rt->nozzles_info[1].volume_type == NozzleVolumeType::nvtHighFlow);
|
||||
|
||||
// A static (non-selector) result must persist enable_filament_dynamic_map = false.
|
||||
auto* dyn = rt->config.option<ConfigOptionBool>("enable_filament_dynamic_map");
|
||||
const bool persisted_true = (dyn != nullptr && dyn->value);
|
||||
REQUIRE_FALSE(persisted_true);
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
#include <catch2/catch_all.hpp>
|
||||
|
||||
#include "libslic3r/Arachne/WallToolPaths.hpp"
|
||||
#include "libslic3r/Arachne/SkeletalTrapezoidation.hpp"
|
||||
#include "libslic3r/Arachne/utils/ExtrusionLine.hpp"
|
||||
#include "libslic3r/Arachne/BeadingStrategy/BeadingStrategyFactory.hpp"
|
||||
#include "libslic3r/Arachne/BeadingStrategy/BeadingStrategy.hpp"
|
||||
@@ -265,3 +266,46 @@ TEST_CASE("Arachne widening keeps two beads in transition band (#14376)", "[Arac
|
||||
for (const coord_t w : beading.bead_widths)
|
||||
CHECK(w <= inner_width);
|
||||
}
|
||||
|
||||
namespace {
|
||||
// Exposes the protected static interpolate() for a focused unit test.
|
||||
struct InterpolateProbe : SkeletalTrapezoidation {
|
||||
using SkeletalTrapezoidation::interpolate;
|
||||
};
|
||||
} // anonymous namespace
|
||||
|
||||
// interpolate() indexes the merged beading with an index derived from `left`. The merged beading
|
||||
// follows the thicker of left/right, so when the thicker side has fewer insets the index runs past
|
||||
// its end.
|
||||
TEST_CASE("Beading interpolation tolerates a thicker side with fewer insets", "[Arachne][Regression]") {
|
||||
using Beading = BeadingStrategy::Beading;
|
||||
|
||||
// Thicker side (right) has fewer insets, so the merged beading holds only 2 toolpath locations.
|
||||
const coord_t w = scaled<coord_t>(0.42);
|
||||
Beading left;
|
||||
left.total_thickness = scaled<coord_t>(1.0);
|
||||
left.bead_widths = { w, w, w, w };
|
||||
left.toolpath_locations = { scaled<coord_t>(0.1), scaled<coord_t>(0.3), scaled<coord_t>(0.5), scaled<coord_t>(0.7) };
|
||||
left.left_over = 0;
|
||||
|
||||
Beading right;
|
||||
right.total_thickness = scaled<coord_t>(2.0);
|
||||
right.bead_widths = { w, w };
|
||||
right.toolpath_locations = { scaled<coord_t>(0.1), scaled<coord_t>(0.3) };
|
||||
right.left_over = 0;
|
||||
|
||||
// Just past left's location [2] (0.5), so the derived index is 2, past the end of the 2-inset merged beading.
|
||||
const coord_t switching_radius = scaled<coord_t>(0.6);
|
||||
|
||||
Beading result;
|
||||
REQUIRE_NOTHROW(result = InterpolateProbe::interpolate(left, 0.5, right, switching_radius));
|
||||
|
||||
// With the guard the adjustment is skipped, so the result is the plain interpolation.
|
||||
const Beading expected = InterpolateProbe::interpolate(left, 0.5, right);
|
||||
REQUIRE(result.toolpath_locations.size() == expected.toolpath_locations.size());
|
||||
REQUIRE(result.bead_widths.size() == expected.bead_widths.size());
|
||||
for (size_t i = 0; i < expected.toolpath_locations.size(); ++i) {
|
||||
CHECK(result.toolpath_locations[i] == expected.toolpath_locations[i]);
|
||||
CHECK(result.bead_widths[i] == expected.bead_widths[i]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -535,3 +535,247 @@ TEST_CASE_METHOD(PluginResolverFixture,
|
||||
|
||||
CHECK(manifest == std::vector<std::string>{"x;;slicing-pipeline"});
|
||||
}
|
||||
|
||||
TEST_CASE("H2C/A2L-era multi-nozzle and pre-heat config keys exist", "[config]") {
|
||||
// Foundation keys backing H2C 6-nozzle cluster grouping, the pre-heat/pre-cool time
|
||||
// model, and wipe-tower nozzle-change handling. Defaults must keep existing
|
||||
// single-nozzle printers behaving identically.
|
||||
Slic3r::DynamicPrintConfig config = Slic3r::DynamicPrintConfig::full_print_config();
|
||||
|
||||
// Printer / per-extruder options
|
||||
REQUIRE(config.option<ConfigOptionIntsNullable>("extruder_max_nozzle_count") != nullptr);
|
||||
REQUIRE(config.option<ConfigOptionIntsNullable>("extruder_max_nozzle_count")->values == std::vector<int>{1});
|
||||
REQUIRE(config.option<ConfigOptionBool>("enable_pre_heating") != nullptr);
|
||||
REQUIRE(config.option<ConfigOptionBool>("enable_pre_heating")->value == false);
|
||||
REQUIRE(config.option<ConfigOptionFloatsNullable>("hotend_cooling_rate") != nullptr);
|
||||
REQUIRE(config.option<ConfigOptionFloatsNullable>("hotend_heating_rate") != nullptr);
|
||||
REQUIRE(config.option<ConfigOptionFloat>("machine_hotend_change_time") != nullptr);
|
||||
REQUIRE(config.option<ConfigOptionFloat>("machine_prepare_compensation_time") != nullptr);
|
||||
|
||||
// Filament pre-cooling / ramming / nozzle-change (nc) options
|
||||
REQUIRE(config.option<ConfigOptionIntsNullable>("filament_pre_cooling_temperature") != nullptr);
|
||||
REQUIRE(config.option<ConfigOptionIntsNullable>("filament_pre_cooling_temperature_nc") != nullptr);
|
||||
REQUIRE(config.option<ConfigOptionFloatsNullable>("filament_preheat_temperature_delta") != nullptr);
|
||||
REQUIRE(config.option<ConfigOptionFloatsNullable>("filament_retract_length_nc") != nullptr);
|
||||
REQUIRE(config.option<ConfigOptionFloats>("filament_change_length_nc") != nullptr);
|
||||
REQUIRE(config.option<ConfigOptionFloats>("filament_prime_volume_nc") != nullptr);
|
||||
REQUIRE(config.option<ConfigOptionFloatsNullable>("filament_ramming_travel_time") != nullptr);
|
||||
REQUIRE(config.option<ConfigOptionFloatsNullable>("filament_ramming_travel_time_nc") != nullptr);
|
||||
REQUIRE(config.option<ConfigOptionFloatsNullable>("filament_ramming_volumetric_speed") != nullptr);
|
||||
REQUIRE(config.option<ConfigOptionFloatsNullable>("filament_ramming_volumetric_speed_nc") != nullptr);
|
||||
|
||||
// Spot-check defaults that must not alter existing behavior.
|
||||
REQUIRE(config.option<ConfigOptionFloatsNullable>("filament_retract_length_nc")->values == std::vector<double>{10.});
|
||||
REQUIRE(config.option<ConfigOptionFloats>("filament_prime_volume_nc")->values == std::vector<double>{60.});
|
||||
REQUIRE(config.option<ConfigOptionIntsNullable>("filament_pre_cooling_temperature_nc")->values == std::vector<int>{0});
|
||||
REQUIRE(config.option<ConfigOptionFloatsNullable>("filament_ramming_volumetric_speed")->values == std::vector<double>{-1});
|
||||
}
|
||||
|
||||
SCENARIO("ConfigOptionVector::set_to_index with stride=1 copies values correctly", "[Config][set_to_index]") {
|
||||
GIVEN("A destination vector and a source vector with 3 values") {
|
||||
Slic3r::ConfigOptionFloats dest({0.0});
|
||||
Slic3r::ConfigOptionFloats src({10.0, 20.0, 30.0});
|
||||
std::vector<int> variant_index = {0, 1, 2};
|
||||
int stride = 1;
|
||||
|
||||
WHEN("set_to_index is called with stride=1") {
|
||||
dest.set_to_index(&src, variant_index, stride);
|
||||
|
||||
THEN("The destination contains the source values") {
|
||||
REQUIRE(dest.values.size() == 3);
|
||||
REQUIRE(dest.values[0] == 10.0);
|
||||
REQUIRE(dest.values[1] == 20.0);
|
||||
REQUIRE(dest.values[2] == 30.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
GIVEN("A destination vector and a source vector with subset mapping") {
|
||||
Slic3r::ConfigOptionFloats dest({0.0});
|
||||
Slic3r::ConfigOptionFloats src({100.0, 200.0, 300.0});
|
||||
std::vector<int> variant_index = {1, 2};
|
||||
int stride = 1;
|
||||
|
||||
WHEN("set_to_index maps only indices 1 and 2") {
|
||||
dest.set_to_index(&src, variant_index, stride);
|
||||
|
||||
THEN("Only the mapped values are copied, default fills the others") {
|
||||
REQUIRE(dest.values.size() == 2);
|
||||
REQUIRE(dest.values[0] == 200.0);
|
||||
REQUIRE(dest.values[1] == 300.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SCENARIO("ConfigOptionVector::set_to_index with stride=2 copies grouped values correctly", "[Config][set_to_index]") {
|
||||
GIVEN("A destination vector and a source vector with stride=2 (e.g., nozzle groups)") {
|
||||
// Source has 4 groups of 2 values each: (10,11), (20,21), (30,31), (40,41)
|
||||
Slic3r::ConfigOptionFloats dest({0.0});
|
||||
Slic3r::ConfigOptionFloats src({10.0, 11.0, 20.0, 21.0, 30.0, 31.0, 40.0, 41.0});
|
||||
int stride = 2;
|
||||
|
||||
WHEN("set_to_index maps groups 0, 1, 3") {
|
||||
std::vector<int> variant_index = {0, 1, 3};
|
||||
dest.set_to_index(&src, variant_index, stride);
|
||||
|
||||
THEN("The destination has 3 groups (6 values) mapped correctly") {
|
||||
REQUIRE(dest.values.size() == 6);
|
||||
// Group 0: (10, 11)
|
||||
REQUIRE(dest.values[0] == 10.0);
|
||||
REQUIRE(dest.values[1] == 11.0);
|
||||
// Group 1: (20, 21)
|
||||
REQUIRE(dest.values[2] == 20.0);
|
||||
REQUIRE(dest.values[3] == 21.0);
|
||||
// Group 3: (40, 41)
|
||||
REQUIRE(dest.values[4] == 40.0);
|
||||
REQUIRE(dest.values[5] == 41.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
GIVEN("A destination and a single-group source") {
|
||||
Slic3r::ConfigOptionFloats dest({0.0});
|
||||
// Source has 1 group of 2 values
|
||||
Slic3r::ConfigOptionFloats src({50.0, 60.0});
|
||||
int stride = 2;
|
||||
|
||||
WHEN("set_to_index maps group 0 from a single-group source") {
|
||||
std::vector<int> variant_index = {0};
|
||||
dest.set_to_index(&src, variant_index, stride);
|
||||
|
||||
THEN("The destination contains the single group correctly") {
|
||||
REQUIRE(dest.values.size() == 2);
|
||||
REQUIRE(dest.values[0] == 50.0);
|
||||
REQUIRE(dest.values[1] == 60.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SCENARIO("ConfigOptionVector::set_to_index handles empty dest_index", "[Config][set_to_index]") {
|
||||
GIVEN("A destination and source with stride=2") {
|
||||
Slic3r::ConfigOptionFloats dest({0.0});
|
||||
Slic3r::ConfigOptionFloats src({10.0, 11.0, 20.0, 21.0});
|
||||
std::vector<int> variant_index = {};
|
||||
int stride = 2;
|
||||
|
||||
WHEN("set_to_index is called with an empty index vector") {
|
||||
dest.set_to_index(&src, variant_index, stride);
|
||||
|
||||
THEN("The destination is resized to 0") {
|
||||
REQUIRE(dest.values.size() == 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SCENARIO("ConfigOptionVector::set_to_index handles nil values in source", "[Config][set_to_index]") {
|
||||
GIVEN("A source with a nil group (stride=2)") {
|
||||
Slic3r::ConfigOptionFloatsNullable dest({0.0});
|
||||
Slic3r::ConfigOptionFloatsNullable src({10.0, 11.0,
|
||||
Slic3r::ConfigOptionFloatsNullable::nil_value(), Slic3r::ConfigOptionFloatsNullable::nil_value(),
|
||||
30.0, 31.0});
|
||||
int stride = 2;
|
||||
|
||||
WHEN("set_to_index maps all groups including the nil one") {
|
||||
std::vector<int> variant_index = {0, 1, 2};
|
||||
dest.set_to_index(&src, variant_index, stride);
|
||||
|
||||
THEN("Non-nil groups are copied and the nil group keeps the default") {
|
||||
REQUIRE(dest.values.size() == 6);
|
||||
// Group 0: (10, 11) — copied
|
||||
REQUIRE(dest.values[0] == 10.0);
|
||||
REQUIRE(dest.values[1] == 11.0);
|
||||
// Group 1: nil — keeps default (the front value = 10.0)
|
||||
REQUIRE(dest.values[2] == 10.0);
|
||||
REQUIRE(dest.values[3] == 10.0);
|
||||
// Group 2: (30, 31) — copied
|
||||
REQUIRE(dest.values[4] == 30.0);
|
||||
REQUIRE(dest.values[5] == 31.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SCENARIO("ConfigOptionVector::set_to_index handles out-of-bounds dest_index", "[Config][set_to_index]") {
|
||||
GIVEN("A source with only 2 groups (4 values) but dest_index references group 3") {
|
||||
Slic3r::ConfigOptionFloats dest({0.0});
|
||||
Slic3r::ConfigOptionFloats src({10.0, 11.0, 20.0, 21.0}); // 2 groups of stride 2
|
||||
int stride = 2;
|
||||
|
||||
WHEN("set_to_index maps group 3 which is out of bounds") {
|
||||
std::vector<int> variant_index = {0, 3}; // group 3 is out of range
|
||||
dest.set_to_index(&src, variant_index, stride);
|
||||
|
||||
THEN("Group 0 is copied, group 3 falls back to default without crashing") {
|
||||
REQUIRE(dest.values.size() == 4);
|
||||
// Group 0: (10, 11) — copied
|
||||
REQUIRE(dest.values[0] == 10.0);
|
||||
REQUIRE(dest.values[1] == 11.0);
|
||||
// Group 3: out of bounds — keeps default (10.0 = src.values.front())
|
||||
REQUIRE(dest.values[2] == 10.0);
|
||||
REQUIRE(dest.values[3] == 10.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SCENARIO("ConfigOptionVector::set_to_index handles negative dest_index values", "[Config][set_to_index]") {
|
||||
GIVEN("A destination and source with a negative entry in dest_index") {
|
||||
// The dest is initially empty, so resize fills all slots with src.values.front().
|
||||
Slic3r::ConfigOptionFloats dest;
|
||||
Slic3r::ConfigOptionFloats src({100.0, 101.0, 200.0, 201.0});
|
||||
int stride = 2;
|
||||
|
||||
WHEN("set_to_index maps group 0 and a negative index") {
|
||||
std::vector<int> variant_index = {-1, 0};
|
||||
dest.set_to_index(&src, variant_index, stride);
|
||||
|
||||
THEN("The negative index is skipped, the valid group is copied") {
|
||||
REQUIRE(dest.values.size() == 4);
|
||||
// Position 0 (variant_index[0] = -1): skipped, keeps default fill
|
||||
// from resize (src.values.front() = 100.0, applied to all new elements)
|
||||
REQUIRE(dest.values[0] == 100.0);
|
||||
REQUIRE(dest.values[1] == 100.0);
|
||||
// Position 1 (variant_index[1] = 0): copied from group 0 of src
|
||||
REQUIRE(dest.values[2] == 100.0);
|
||||
REQUIRE(dest.values[3] == 101.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SCENARIO("ConfigOptionVector::set_to_index handles single-element groups with stride=1", "[Config][set_to_index]") {
|
||||
GIVEN("A destination re-mapping one variant index with a stride=1 source") {
|
||||
// Simulates the PrintObject.cpp code path: stride=1, variant_index={1}
|
||||
Slic3r::ConfigOptionFloats dest({99.0, 99.0, 99.0, 99.0}); // pre-sized for 4 extruders
|
||||
Slic3r::ConfigOptionFloats src({0.5, 0.6, 0.7, 0.8}); // 4 extruder values
|
||||
std::vector<int> variant_index = {1}; // only extruder 1 is active
|
||||
int stride = 1;
|
||||
|
||||
WHEN("set_to_index is called") {
|
||||
dest.set_to_index(&src, variant_index, stride);
|
||||
|
||||
THEN("Only the mapped value is copied, rest are defaulted") {
|
||||
REQUIRE(dest.values.size() == 1);
|
||||
REQUIRE(dest.values[0] == 0.6);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SCENARIO("ConfigOptionVector::set_to_index throws on incompatible type", "[Config][set_to_index]") {
|
||||
GIVEN("A Floats destination and an Ints source") {
|
||||
Slic3r::ConfigOptionFloats dest({0.0});
|
||||
Slic3r::ConfigOptionInts src({1, 2, 3});
|
||||
std::vector<int> variant_index = {0};
|
||||
int stride = 1;
|
||||
|
||||
WHEN("set_to_index is called with mismatched types") {
|
||||
THEN("A ConfigurationError is thrown") {
|
||||
REQUIRE_THROWS_AS(dest.set_to_index(&src, variant_index, stride), Slic3r::ConfigurationError);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
336
tests/libslic3r/test_config_variant_expansion.cpp
Normal file
336
tests/libslic3r/test_config_variant_expansion.cpp
Normal file
@@ -0,0 +1,336 @@
|
||||
#include <catch2/catch_all.hpp>
|
||||
|
||||
#include "libslic3r/PrintConfig.hpp"
|
||||
|
||||
using namespace Slic3r;
|
||||
|
||||
namespace {
|
||||
|
||||
// A 2-extruder printer whose second extruder holds both a Standard and a High Flow nozzle
|
||||
// (nozzle_volume_type Hybrid), described by extruder_nozzle_stats. The variant lists carry one
|
||||
// column per (extruder x volume type) as composed from the presets.
|
||||
DynamicPrintConfig make_hybrid_printer_config()
|
||||
{
|
||||
DynamicPrintConfig config;
|
||||
config.option<ConfigOptionStrings>("extruder_nozzle_stats", true)->values = {"Standard#1", "Standard#3|High Flow#2"};
|
||||
config.option<ConfigOptionEnumsGeneric>("extruder_type", true)->values = {etDirectDrive, etDirectDrive};
|
||||
config.option<ConfigOptionEnumsGeneric>("nozzle_volume_type", true)->values = {nvtStandard, nvtHybrid};
|
||||
config.option<ConfigOptionStrings>("extruder_variant_list", true)->values = {"Direct Drive Standard,Direct Drive High Flow",
|
||||
"Direct Drive Standard,Direct Drive High Flow"};
|
||||
return config;
|
||||
}
|
||||
|
||||
void add_print_variant_columns(DynamicPrintConfig &config)
|
||||
{
|
||||
config.option<ConfigOptionInts>("print_extruder_id", true)->values = {1, 1, 2, 2};
|
||||
config.option<ConfigOptionStrings>("print_extruder_variant", true)->values = {"Direct Drive Standard", "Direct Drive High Flow",
|
||||
"Direct Drive Standard", "Direct Drive High Flow"};
|
||||
config.option<ConfigOptionFloats>("outer_wall_speed", true)->values = {30., 200., 50., 500.};
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("apply_override fills nil entries from the 0-based default index", "[Config]")
|
||||
{
|
||||
ConfigOptionFloats machine({10., 20., 30.});
|
||||
ConfigOptionFloatsNullable filament;
|
||||
filament.values = {ConfigOptionFloatsNullable::nil_value(), 42.};
|
||||
|
||||
SECTION("a nil entry picks the slot addressed by its 0-based index") {
|
||||
std::vector<int> slot_index{2, 0};
|
||||
ConfigOptionFloats resolved(machine);
|
||||
REQUIRE(resolved.apply_override(&filament, slot_index));
|
||||
REQUIRE(resolved.values == std::vector<double>({30., 42.}));
|
||||
}
|
||||
|
||||
SECTION("an index past the machine slots falls back to the first slot") {
|
||||
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};
|
||||
ConfigOptionFloats resolved(machine);
|
||||
REQUIRE(resolved.apply_override(&filament, slot_index));
|
||||
REQUIRE(resolved.values == std::vector<double>({10., 42.}));
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("get_config_index_base resolves (volume type, extruder type, id) to a slot", "[Config]")
|
||||
{
|
||||
const std::vector<std::string> variant_list = {"Direct Drive Standard", "Direct Drive High Flow",
|
||||
"Direct Drive Standard", "Direct Drive High Flow"};
|
||||
const std::vector<int> variant_ids = {1, 1, 2, 2};
|
||||
|
||||
SECTION("a matching (variant, id) pair yields its slot") {
|
||||
REQUIRE(get_config_index_base(nvtStandard, etDirectDrive, 1, variant_list, variant_ids) == 0);
|
||||
REQUIRE(get_config_index_base(nvtHighFlow, etDirectDrive, 1, variant_list, variant_ids) == 1);
|
||||
REQUIRE(get_config_index_base(nvtStandard, etDirectDrive, 2, variant_list, variant_ids) == 2);
|
||||
REQUIRE(get_config_index_base(nvtHighFlow, etDirectDrive, 2, variant_list, variant_ids) == 3);
|
||||
}
|
||||
|
||||
SECTION("no matching column falls back to slot 0") {
|
||||
REQUIRE(get_config_index_base(nvtStandard, etDirectDrive, 3, variant_list, variant_ids) == 0);
|
||||
REQUIRE(get_config_index_base(nvtStandard, etBowden, 1, variant_list, variant_ids) == 0);
|
||||
}
|
||||
|
||||
SECTION("Hybrid is not a preset variant string and falls back to slot 0") {
|
||||
REQUIRE(get_config_index_base(nvtHybrid, etDirectDrive, 2, variant_list, variant_ids) == 0);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("get_extruder_nozzle_volume_count reads the per-extruder volume-type layout", "[Config]")
|
||||
{
|
||||
std::vector<std::vector<NozzleVolumeType>> nozzle_volume_types;
|
||||
|
||||
SECTION("absent stats fall back to one slot per extruder") {
|
||||
DynamicPrintConfig config;
|
||||
REQUIRE(config.get_extruder_nozzle_volume_count(2, nozzle_volume_types) == 2);
|
||||
REQUIRE(nozzle_volume_types.size() == 2);
|
||||
REQUIRE(nozzle_volume_types[0].empty());
|
||||
REQUIRE(nozzle_volume_types[1].empty());
|
||||
}
|
||||
|
||||
SECTION("stats sized differently from the extruder count are ignored") {
|
||||
DynamicPrintConfig config;
|
||||
config.option<ConfigOptionStrings>("extruder_nozzle_stats", true)->values = {"Standard#1"};
|
||||
REQUIRE(config.get_extruder_nozzle_volume_count(2, nozzle_volume_types) == 2);
|
||||
REQUIRE(nozzle_volume_types[0].empty());
|
||||
REQUIRE(nozzle_volume_types[1].empty());
|
||||
}
|
||||
|
||||
SECTION("single volume type per extruder counts one slot each") {
|
||||
DynamicPrintConfig config;
|
||||
config.option<ConfigOptionStrings>("extruder_nozzle_stats", true)->values = {"Standard#1", "High Flow#1"};
|
||||
REQUIRE(config.get_extruder_nozzle_volume_count(2, nozzle_volume_types) == 2);
|
||||
REQUIRE(nozzle_volume_types[0] == std::vector<NozzleVolumeType>{nvtStandard});
|
||||
REQUIRE(nozzle_volume_types[1] == std::vector<NozzleVolumeType>{nvtHighFlow});
|
||||
}
|
||||
|
||||
SECTION("a mixed-nozzle extruder contributes one slot per volume type, ascending enum order") {
|
||||
DynamicPrintConfig config;
|
||||
// list High Flow first in the token string: parsing must still order Standard before High Flow
|
||||
config.option<ConfigOptionStrings>("extruder_nozzle_stats", true)->values = {"Standard#3", "High Flow#3|Standard#3"};
|
||||
REQUIRE(config.get_extruder_nozzle_volume_count(2, nozzle_volume_types) == 3);
|
||||
REQUIRE(nozzle_volume_types[0] == std::vector<NozzleVolumeType>{nvtStandard});
|
||||
REQUIRE(nozzle_volume_types[1] == std::vector<NozzleVolumeType>({nvtStandard, nvtHighFlow}));
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("update_values_to_printer_extruders expands one slot per (extruder x volume type)", "[Config]")
|
||||
{
|
||||
SECTION("Hybrid extruder yields three slots, extruder-ascending then volume-ascending") {
|
||||
DynamicPrintConfig config = make_hybrid_printer_config();
|
||||
add_print_variant_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");
|
||||
|
||||
REQUIRE(variant_index == std::vector<int>({0, 2, 3}));
|
||||
REQUIRE(config.option<ConfigOptionFloats>("outer_wall_speed")->values == std::vector<double>({30., 50., 500.}));
|
||||
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"}));
|
||||
}
|
||||
|
||||
SECTION("stride-2 options keep (normal, silent) pairs together per slot") {
|
||||
DynamicPrintConfig config = make_hybrid_printer_config();
|
||||
config.option<ConfigOptionInts>("printer_extruder_id", true)->values = {1, 1, 2, 2};
|
||||
config.option<ConfigOptionStrings>("printer_extruder_variant", true)->values = {"Direct Drive Standard", "Direct Drive High Flow",
|
||||
"Direct Drive Standard", "Direct Drive High Flow"};
|
||||
config.option<ConfigOptionFloats>("machine_max_speed_x", true)->values = {100., 50., 110., 55., 120., 60., 130., 65.};
|
||||
|
||||
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,
|
||||
printer_options_with_variant_2, "printer_extruder_id", "printer_extruder_variant", 2);
|
||||
|
||||
REQUIRE(variant_index == std::vector<int>({0, 2, 3}));
|
||||
REQUIRE(config.option<ConfigOptionFloats>("machine_max_speed_x")->values ==
|
||||
std::vector<double>({100., 50., 120., 60., 130., 65.}));
|
||||
}
|
||||
|
||||
SECTION("single-slot expansion on a Hybrid extruder resolves via the filament volume type") {
|
||||
DynamicPrintConfig printer_config = make_hybrid_printer_config();
|
||||
|
||||
DynamicPrintConfig filament_config;
|
||||
filament_config.option<ConfigOptionStrings>("filament_extruder_variant", true)->values = {"Direct Drive Standard", "Direct Drive High Flow"};
|
||||
filament_config.option<ConfigOptionFloats>("filament_max_volumetric_speed", true)->values = {12., 20.};
|
||||
|
||||
std::vector<std::vector<NozzleVolumeType>> nozzle_volume_types;
|
||||
int extruder_count = 2;
|
||||
int count = printer_config.get_extruder_nozzle_volume_count(extruder_count, nozzle_volume_types);
|
||||
|
||||
SECTION("default filament volume type selects the Standard column") {
|
||||
std::vector<int> variant_index = filament_config.update_values_to_printer_extruders(printer_config, extruder_count, count,
|
||||
nozzle_volume_types, filament_options_with_variant, "", "filament_extruder_variant", 1, 2);
|
||||
REQUIRE(variant_index == std::vector<int>({0}));
|
||||
REQUIRE(filament_config.option<ConfigOptionFloats>("filament_max_volumetric_speed")->values == std::vector<double>({12.}));
|
||||
}
|
||||
|
||||
SECTION("a High Flow filament volume type selects the High Flow column") {
|
||||
std::vector<int> variant_index = filament_config.update_values_to_printer_extruders(printer_config, extruder_count, count,
|
||||
nozzle_volume_types, filament_options_with_variant, "", "filament_extruder_variant", 1, 2, nvtHighFlow);
|
||||
REQUIRE(variant_index == std::vector<int>({1}));
|
||||
REQUIRE(filament_config.option<ConfigOptionFloats>("filament_max_volumetric_speed")->values == std::vector<double>({20.}));
|
||||
}
|
||||
}
|
||||
|
||||
SECTION("an extruder without per-type stats does not overrun the slot table when another is Hybrid") {
|
||||
DynamicPrintConfig config;
|
||||
// e0 carries no per-type stats (empty entry), so the summed volume-type count (2) does
|
||||
// not exceed the extruder count even though the Hybrid e1 emits one slot per volume type.
|
||||
config.option<ConfigOptionStrings>("extruder_nozzle_stats", true)->values = {"", "Standard#3|High Flow#3"};
|
||||
config.option<ConfigOptionEnumsGeneric>("extruder_type", true)->values = {etDirectDrive, etDirectDrive};
|
||||
config.option<ConfigOptionEnumsGeneric>("nozzle_volume_type", true)->values = {nvtStandard, nvtHybrid};
|
||||
config.option<ConfigOptionStrings>("extruder_variant_list", true)->values = {"Direct Drive Standard,Direct Drive High Flow",
|
||||
"Direct Drive Standard,Direct Drive High Flow"};
|
||||
add_print_variant_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 == 2);
|
||||
REQUIRE(nozzle_volume_types[0].empty());
|
||||
|
||||
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");
|
||||
|
||||
// e0 resolves by its configured type; the Hybrid e1 emits one slot per stats volume type
|
||||
REQUIRE(variant_index == std::vector<int>({0, 2, 3}));
|
||||
REQUIRE(config.option<ConfigOptionFloats>("outer_wall_speed")->values == std::vector<double>({30., 50., 500.}));
|
||||
}
|
||||
|
||||
SECTION("without Hybrid or extra slots the expansion matches the per-extruder resolution") {
|
||||
DynamicPrintConfig config;
|
||||
config.option<ConfigOptionEnumsGeneric>("extruder_type", true)->values = {etDirectDrive, etDirectDrive};
|
||||
config.option<ConfigOptionEnumsGeneric>("nozzle_volume_type", true)->values = {nvtStandard, nvtHighFlow};
|
||||
config.option<ConfigOptionStrings>("extruder_variant_list", true)->values = {"Direct Drive Standard,Direct Drive High Flow",
|
||||
"Direct Drive Standard,Direct Drive High Flow"};
|
||||
add_print_variant_columns(config);
|
||||
|
||||
// compute what the per-extruder loop resolves directly, before the arrays are rewritten
|
||||
std::vector<int> expected_index;
|
||||
for (int e_index = 0; e_index < 2; e_index++)
|
||||
expected_index.push_back(config.get_index_for_extruder(e_index + 1, "print_extruder_id", etDirectDrive,
|
||||
e_index == 0 ? nvtStandard : nvtHighFlow, "print_extruder_variant"));
|
||||
REQUIRE(expected_index == std::vector<int>({0, 3}));
|
||||
|
||||
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 == 2);
|
||||
|
||||
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 == expected_index);
|
||||
REQUIRE(config.option<ConfigOptionFloats>("outer_wall_speed")->values == std::vector<double>({30., 500.}));
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("update_values_to_printer_extruders_for_multiple_filaments resolves per-filament slots", "[Config]")
|
||||
{
|
||||
auto make_filament_arrays = [](DynamicPrintConfig &config) {
|
||||
config.option<ConfigOptionInts>("filament_self_index", true)->values = {1, 1, 2, 2};
|
||||
config.option<ConfigOptionStrings>("filament_extruder_variant", true)->values = {"Direct Drive Standard", "Direct Drive High Flow",
|
||||
"Direct Drive Standard", "Direct Drive High Flow"};
|
||||
config.option<ConfigOptionFloats>("filament_max_volumetric_speed", true)->values = {12., 20., 13., 21.};
|
||||
};
|
||||
|
||||
std::set<std::string> filament_keys = filament_options_with_variant;
|
||||
filament_keys.insert("filament_self_index");
|
||||
|
||||
SECTION("filament_volume_map picks the concrete volume type on a Hybrid extruder") {
|
||||
DynamicPrintConfig config = make_hybrid_printer_config();
|
||||
make_filament_arrays(config);
|
||||
config.option<ConfigOptionInts>("filament_map", true)->values = {2, 2};
|
||||
config.option<ConfigOptionInts>("filament_volume_map", true)->values = {nvtStandard, nvtHighFlow};
|
||||
|
||||
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_for_multiple_filaments(config, extruder_count, count, filament_keys,
|
||||
"filament_self_index", "filament_extruder_variant");
|
||||
|
||||
REQUIRE(config.option<ConfigOptionFloats>("filament_max_volumetric_speed")->values == std::vector<double>({12., 21.}));
|
||||
REQUIRE(config.option<ConfigOptionStrings>("filament_extruder_variant")->values ==
|
||||
std::vector<std::string>({"Direct Drive Standard", "Direct Drive High Flow"}));
|
||||
REQUIRE(config.option<ConfigOptionInts>("filament_self_index")->values == std::vector<int>({1, 2}));
|
||||
}
|
||||
|
||||
SECTION("a volume map not sized to the filament count is ignored") {
|
||||
DynamicPrintConfig config = make_hybrid_printer_config();
|
||||
make_filament_arrays(config);
|
||||
config.option<ConfigOptionInts>("filament_map", true)->values = {2, 2};
|
||||
// the registered default is a single-element vector; it must not override slot resolution
|
||||
config.option<ConfigOptionInts>("filament_volume_map", true)->values = {nvtStandard};
|
||||
|
||||
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_for_multiple_filaments(config, extruder_count, count, filament_keys,
|
||||
"filament_self_index", "filament_extruder_variant");
|
||||
|
||||
// Hybrid resolves as Standard when no usable per-filament map exists
|
||||
REQUIRE(config.option<ConfigOptionFloats>("filament_max_volumetric_speed")->values == std::vector<double>({12., 13.}));
|
||||
REQUIRE(config.option<ConfigOptionInts>("filament_self_index")->values == std::vector<int>({1, 2}));
|
||||
}
|
||||
|
||||
SECTION("a single-filament explicit assignment on a Hybrid extruder is honored") {
|
||||
DynamicPrintConfig config = make_hybrid_printer_config();
|
||||
config.option<ConfigOptionInts>("filament_self_index", true)->values = {1, 1};
|
||||
config.option<ConfigOptionStrings>("filament_extruder_variant", true)->values = {"Direct Drive Standard", "Direct Drive High Flow"};
|
||||
config.option<ConfigOptionFloats>("filament_max_volumetric_speed", true)->values = {12., 20.};
|
||||
config.option<ConfigOptionInts>("filament_map", true)->values = {2};
|
||||
// sized to the (single) filament count: the producers guarantee sizing, so a
|
||||
// single-filament map is as trustworthy as any other and the explicit High Flow
|
||||
// request must win over the Hybrid->Standard fallback
|
||||
config.option<ConfigOptionInts>("filament_volume_map", true)->values = {nvtHighFlow};
|
||||
|
||||
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_for_multiple_filaments(config, extruder_count, count, filament_keys,
|
||||
"filament_self_index", "filament_extruder_variant");
|
||||
|
||||
REQUIRE(config.option<ConfigOptionFloats>("filament_max_volumetric_speed")->values == std::vector<double>({20.}));
|
||||
REQUIRE(config.option<ConfigOptionInts>("filament_self_index")->values == std::vector<int>({1}));
|
||||
}
|
||||
|
||||
SECTION("without Hybrid or extra slots the volume map is not consulted") {
|
||||
DynamicPrintConfig config;
|
||||
config.option<ConfigOptionEnumsGeneric>("extruder_type", true)->values = {etDirectDrive, etDirectDrive};
|
||||
config.option<ConfigOptionEnumsGeneric>("nozzle_volume_type", true)->values = {nvtStandard, nvtHighFlow};
|
||||
config.option<ConfigOptionStrings>("extruder_variant_list", true)->values = {"Direct Drive Standard,Direct Drive High Flow",
|
||||
"Direct Drive Standard,Direct Drive High Flow"};
|
||||
make_filament_arrays(config);
|
||||
config.option<ConfigOptionInts>("filament_map", true)->values = {1, 2};
|
||||
// sized to the filament count, but inert because no extruder exposes multiple volume types
|
||||
config.option<ConfigOptionInts>("filament_volume_map", true)->values = {nvtHighFlow, nvtStandard};
|
||||
|
||||
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 == 2);
|
||||
|
||||
config.update_values_to_printer_extruders_for_multiple_filaments(config, extruder_count, count, filament_keys,
|
||||
"filament_self_index", "filament_extruder_variant");
|
||||
|
||||
// filament 1 keeps its extruder's Standard column, filament 2 its extruder's High Flow column
|
||||
REQUIRE(config.option<ConfigOptionFloats>("filament_max_volumetric_speed")->values == std::vector<double>({12., 21.}));
|
||||
REQUIRE(config.option<ConfigOptionInts>("filament_self_index")->values == std::vector<int>({1, 2}));
|
||||
}
|
||||
}
|
||||
31
tests/libslic3r/test_nozzle_volume_type.cpp
Normal file
31
tests/libslic3r/test_nozzle_volume_type.cpp
Normal file
@@ -0,0 +1,31 @@
|
||||
#include <catch2/catch_all.hpp>
|
||||
|
||||
#include "libslic3r/PrintConfig.hpp"
|
||||
|
||||
using namespace Slic3r;
|
||||
|
||||
TEST_CASE("convert_to_nvt_type maps extruder variant strings to nozzle volume types", "[Config]")
|
||||
{
|
||||
SECTION("Direct Drive variants") {
|
||||
REQUIRE(convert_to_nvt_type("Direct Drive Standard") == nvtStandard);
|
||||
REQUIRE(convert_to_nvt_type("Direct Drive High Flow") == nvtHighFlow);
|
||||
REQUIRE(convert_to_nvt_type("Direct Drive TPU High Flow") == nvtTPUHighFlow);
|
||||
}
|
||||
|
||||
SECTION("Bowden variants") {
|
||||
REQUIRE(convert_to_nvt_type("Bowden Standard") == nvtStandard);
|
||||
REQUIRE(convert_to_nvt_type("Bowden High Flow") == nvtHighFlow);
|
||||
}
|
||||
|
||||
SECTION("Unparsable strings fall back to hybrid") {
|
||||
REQUIRE(convert_to_nvt_type("Unknown Extruder") == nvtHybrid);
|
||||
REQUIRE(convert_to_nvt_type("") == nvtHybrid);
|
||||
REQUIRE(convert_to_nvt_type("High Flow") == nvtHybrid);
|
||||
REQUIRE(convert_to_nvt_type("Direct Drive") == nvtHybrid);
|
||||
}
|
||||
|
||||
SECTION("Whitespace around the volume-type remainder is trimmed") {
|
||||
REQUIRE(convert_to_nvt_type("Direct Drive High Flow ") == nvtHighFlow);
|
||||
REQUIRE(convert_to_nvt_type(" Bowden Standard") == nvtStandard);
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,11 @@ SCENARIO("Placeholder parser scripting", "[PlaceholderParser]") {
|
||||
SECTION("math: round(-13.4)") { REQUIRE(parser.process("{round(-13.4)}") == "-13"); }
|
||||
SECTION("math: round(13.6)") { REQUIRE(parser.process("{round(13.6)}") == "14"); }
|
||||
SECTION("math: round(-13.6)") { REQUIRE(parser.process("{round(-13.6)}") == "-14"); }
|
||||
SECTION("math: round(13.5)") { REQUIRE(parser.process("{round(13.5)}") == "14"); }
|
||||
SECTION("math: floor(13.9)") { REQUIRE(parser.process("{floor(13.9)}") == "13"); }
|
||||
SECTION("math: floor(-13.1)") { REQUIRE(parser.process("{floor(-13.1)}") == "-14"); }
|
||||
SECTION("math: ceil(13.1)") { REQUIRE(parser.process("{ceil(13.1)}") == "14"); }
|
||||
SECTION("math: ceil(-13.9)") { REQUIRE(parser.process("{ceil(-13.9)}") == "-13"); }
|
||||
SECTION("math: digits(5, 15)") { REQUIRE(parser.process("{digits(5, 15)}") == " 5"); }
|
||||
SECTION("math: digits(5., 15)") { REQUIRE(parser.process("{digits(5., 15)}") == " 5"); }
|
||||
SECTION("math: zdigits(5, 15)") { REQUIRE(parser.process("{zdigits(5, 15)}") == "000000000000005"); }
|
||||
@@ -65,6 +70,21 @@ SCENARIO("Placeholder parser scripting", "[PlaceholderParser]") {
|
||||
SECTION("math: interpolate_table(13.84375892476, (0, 0), (20, 20))") { REQUIRE(std::stod(parser.process("{interpolate_table(13.84375892476, (0, 0), (20, 20))}")) == Catch::Approx(13.84375892476)); }
|
||||
SECTION("math: interpolate_table(13, (0, 0), (20, 20), (30, 20))") { REQUIRE(std::stod(parser.process("{interpolate_table(13, (0, 0), (20, 20), (30, 20))}")) == Catch::Approx(13.)); }
|
||||
SECTION("math: interpolate_table(25, (0, 0), (20, 20), (30, 20))") { REQUIRE(std::stod(parser.process("{interpolate_table(25, (0, 0), (20, 20), (30, 20))}")) == Catch::Approx(20.)); }
|
||||
// Only the grammar's built-in functions are callable; any other name is an undefined variable and throws.
|
||||
SECTION("math: a non-built-in function name throws") { REQUIRE_THROWS(parser.process("{sqrt(16)}")); }
|
||||
|
||||
// regex_replace(subject, /pattern/, replacement): the string-transform primitive.
|
||||
SECTION("regex_replace: strips a file extension") { REQUIRE(parser.process("{regex_replace(\"part.stl\", /\\.[^.]*$/, \"\")}") == "part"); }
|
||||
SECTION("regex_replace: leaves a non-matching dot untouched") { REQUIRE(parser.process("{regex_replace(\"Bracket v2.1\", /\\.stl$/, \"\")}") == "Bracket v2.1"); }
|
||||
SECTION("regex_replace: replaces every match") { REQUIRE(parser.process("{regex_replace(\"a-b-c\", /-/, \"_\")}") == "a_b_c"); }
|
||||
SECTION("regex_replace: replacement may reference a capture group") { REQUIRE(parser.process("{regex_replace(\"v12\", /v(\\d+)/, \"$1\")}") == "12"); }
|
||||
// The result is an ordinary string, usable in further expressions (the real filename-template shape).
|
||||
SECTION("regex_replace: result composes with concatenation") { REQUIRE(parser.process("{regex_replace(\"part.stl\", /\\.[^.]*$/, \"\") + \".gcode\"}") == "part.gcode"); }
|
||||
// A malformed pattern and a non-string subject are both hard errors.
|
||||
SECTION("regex_replace: an invalid pattern throws") { REQUIRE_THROWS(parser.process("{regex_replace(\"x\", /[/, \"\")}")); }
|
||||
SECTION("regex_replace: a non-string subject throws") { REQUIRE_THROWS(parser.process("{regex_replace(123, /2/, \"\")}")); }
|
||||
// Inside a skipped branch the subject is TYPE_EMPTY and the call must no-op (exercises the guard).
|
||||
SECTION("regex_replace: is skipped inside a false if-branch") { REQUIRE(parser.process("{if false}{regex_replace(\"x\", /x/, \"y\")}{endif}done") == "done"); }
|
||||
|
||||
// Test the "coFloatOrPercent" and "xxx_line_width" substitutions.
|
||||
// min_width_top_surface ratio_over inner_wall_line_width.
|
||||
@@ -130,6 +150,7 @@ SCENARIO("Placeholder parser variables", "[PlaceholderParser]") {
|
||||
|
||||
SECTION("create an int local variable") { REQUIRE(parser.process("{local myint = 33+2}{myint}", 0, nullptr, nullptr, nullptr) == "35"); }
|
||||
SECTION("create a string local variable") { REQUIRE(parser.process("{local mystr = \"mine\" + \"only\" + \"mine\"}{mystr}", 0, nullptr, nullptr, nullptr) == "mineonlymine"); }
|
||||
SECTION("regex_replace transforms a string variable") { REQUIRE(parser.process("{local n = \"part.stl\"}{regex_replace(n, /\\.[^.]*$/, \"\")}", 0, nullptr, nullptr, nullptr) == "part"); }
|
||||
SECTION("create a bool local variable") { REQUIRE(parser.process("{local mybool = 1 + 1 == 2}{mybool}", 0, nullptr, nullptr, nullptr) == "true"); }
|
||||
SECTION("create an int global variable") { REQUIRE(parser.process("{global myint = 33+2}{myint}", 0, nullptr, nullptr, &context_with_global_dict) == "35"); }
|
||||
SECTION("create a string global variable") { REQUIRE(parser.process("{global mystr = \"mine\" + \"only\" + \"mine\"}{mystr}", 0, nullptr, nullptr, &context_with_global_dict) == "mineonlymine"); }
|
||||
|
||||
936
tests/libslic3r/test_toolordering_nozzle_group.cpp
Normal file
936
tests/libslic3r/test_toolordering_nozzle_group.cpp
Normal file
@@ -0,0 +1,936 @@
|
||||
#include <catch2/catch_all.hpp>
|
||||
|
||||
#include "libslic3r/FilamentGroupUtils.hpp"
|
||||
#include "libslic3r/MultiNozzleUtils.hpp"
|
||||
#include "libslic3r/PrintConfig.hpp"
|
||||
#include "libslic3r/GCode/ToolOrdering.hpp"
|
||||
#include "libslic3r/Model.hpp"
|
||||
#include "libslic3r/Print.hpp"
|
||||
#include "libslic3r/TriangleMesh.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <map>
|
||||
#include <set>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
#include <boost/filesystem.hpp>
|
||||
|
||||
// H2C/A2L multi-nozzle filament grouping core.
|
||||
//
|
||||
// These tests pin the behaviour of the grouping result type
|
||||
// (Slic3r::MultiNozzleUtils::LayeredNozzleGroupResult) that GCode consumes via
|
||||
// group_result->get_nozzle_id(filament, layer) and
|
||||
// group_result->get_first_nozzle_for_filament(filament)->group_id.
|
||||
//
|
||||
// The central requirement is ZERO behaviour change for existing (single-nozzle)
|
||||
// printers: with extruder_max_nozzle_count == 1 per extruder the result collapses
|
||||
// to the classic filament->extruder grouping (nozzle id == extruder id).
|
||||
|
||||
using namespace Slic3r;
|
||||
using namespace Slic3r::MultiNozzleUtils;
|
||||
|
||||
namespace {
|
||||
// Build a trivial "one logical nozzle per extruder" list, the single-nozzle case
|
||||
// that every current printer profile produces.
|
||||
std::vector<NozzleInfo> single_nozzle_per_extruder(int extruder_count)
|
||||
{
|
||||
std::vector<NozzleInfo> nozzle_list;
|
||||
for (int e = 0; e < extruder_count; ++e) {
|
||||
NozzleInfo n;
|
||||
n.diameter = "0.4";
|
||||
n.volume_type = nvtStandard;
|
||||
n.extruder_id = e;
|
||||
n.group_id = e; // one nozzle per extruder => nozzle id == extruder id
|
||||
nozzle_list.push_back(n);
|
||||
}
|
||||
return nozzle_list;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("Multi-nozzle gate predicate mirrors BambuStudio", "[ToolOrdering][H2C]")
|
||||
{
|
||||
// The multi-nozzle gate: std::any_of(extruder_max_nozzle_count > 1).
|
||||
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
||||
|
||||
auto *opt = config.option<ConfigOptionIntsNullable>("extruder_max_nozzle_count");
|
||||
REQUIRE(opt != nullptr); // extruder_max_nozzle_count must be a real config option
|
||||
|
||||
// extruder_nozzle_stats must be a real config option so printer profiles and
|
||||
// 3mf projects round-trip the per-extruder nozzle inventory (GUI producers wire it later).
|
||||
REQUIRE(config.option<ConfigOptionStrings>("extruder_nozzle_stats") != nullptr);
|
||||
|
||||
auto has_multiple_nozzle = [](const std::vector<int> &values) {
|
||||
return std::any_of(values.begin(), values.end(), [](int v) { return v > 1; });
|
||||
};
|
||||
|
||||
// Default for every existing printer: 1 nozzle per extruder => gate is closed.
|
||||
REQUIRE_FALSE(has_multiple_nozzle(opt->values));
|
||||
|
||||
// Synthetic H2C-like machine: extruder 1 is a 6-nozzle cluster => gate opens.
|
||||
REQUIRE(has_multiple_nozzle(std::vector<int>{1, 6}));
|
||||
}
|
||||
|
||||
TEST_CASE("Single-nozzle grouping: every filament maps to its extruder nozzle", "[ToolOrdering][H2C]")
|
||||
{
|
||||
SECTION("single extruder => all filaments map to nozzle 0")
|
||||
{
|
||||
auto nozzle_list = single_nozzle_per_extruder(1);
|
||||
// 3 filaments, all assigned to the single extruder 0.
|
||||
std::vector<int> filament_nozzle_map = {0, 0, 0};
|
||||
std::vector<unsigned int> used_filaments = {0, 1, 2};
|
||||
|
||||
auto group_opt = LayeredNozzleGroupResult::create(filament_nozzle_map, nozzle_list, used_filaments);
|
||||
REQUIRE(group_opt.has_value());
|
||||
auto &group = *group_opt;
|
||||
|
||||
for (int f = 0; f < 3; ++f) {
|
||||
REQUIRE(group.get_nozzle_id(f) == 0);
|
||||
REQUIRE(group.get_extruder_id(f) == 0);
|
||||
auto first = group.get_first_nozzle_for_filament(f);
|
||||
REQUIRE(first.has_value());
|
||||
REQUIRE(first->group_id == 0);
|
||||
}
|
||||
REQUIRE_FALSE(group.is_support_dynamic_nozzle_map());
|
||||
}
|
||||
|
||||
SECTION("dual extruder => nozzle id equals the classic extruder grouping")
|
||||
{
|
||||
auto nozzle_list = single_nozzle_per_extruder(2);
|
||||
// filament -> extruder map (the map Orca's reorder already computes).
|
||||
std::vector<int> filament_map = {0, 1, 0, 1};
|
||||
std::vector<unsigned int> used_filaments = {0, 1, 2, 3};
|
||||
|
||||
auto group_opt = LayeredNozzleGroupResult::create(filament_map, nozzle_list, used_filaments);
|
||||
REQUIRE(group_opt.has_value());
|
||||
auto &group = *group_opt;
|
||||
|
||||
REQUIRE(group.get_nozzle_id(0) == 0);
|
||||
REQUIRE(group.get_nozzle_id(1) == 1);
|
||||
REQUIRE(group.get_nozzle_id(2) == 0);
|
||||
REQUIRE(group.get_nozzle_id(3) == 1);
|
||||
// With one nozzle per extruder, nozzle id and extruder id agree.
|
||||
for (int f = 0; f < 4; ++f)
|
||||
REQUIRE(group.get_nozzle_id(f) == group.get_extruder_id(f));
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("H2C multi-nozzle: filaments get distinct nozzles on the 6-nozzle extruder", "[ToolOrdering][H2C]")
|
||||
{
|
||||
// Synthetic H2C-like config: 2 extruders, extruder_max_nozzle_count = {1, 6},
|
||||
// 4 filaments all assigned to extruder 1 (0-based). Each filament requests a
|
||||
// distinct logical nozzle cluster (as the grouping algorithm would emit), so the
|
||||
// create() overload must resolve them to 4 distinct physical nozzles.
|
||||
std::vector<unsigned int> used_filaments = {0, 1, 2, 3};
|
||||
std::vector<int> filament_map = {1, 1, 1, 1}; // extruder 1
|
||||
std::vector<int> filament_volume_map = {0, 0, 0, 0}; // nvtStandard
|
||||
std::vector<int> filament_nozzle_map = {0, 1, 2, 3}; // distinct clusters
|
||||
|
||||
std::vector<std::map<NozzleVolumeType, int>> nozzle_count(2);
|
||||
nozzle_count[0] = {}; // extruder 0: 1-nozzle (unused here)
|
||||
nozzle_count[1] = {{nvtStandard, 6}}; // extruder 1: 6-nozzle cluster
|
||||
|
||||
auto group_opt = LayeredNozzleGroupResult::create(
|
||||
used_filaments, filament_map, filament_volume_map, filament_nozzle_map, nozzle_count, 0.4f);
|
||||
REQUIRE(group_opt.has_value());
|
||||
auto &group = *group_opt;
|
||||
|
||||
// All four filaments live on extruder 1, on four distinct physical nozzles.
|
||||
std::set<int> distinct_nozzles;
|
||||
for (int f = 0; f < 4; ++f) {
|
||||
REQUIRE(group.get_extruder_id(f) == 1);
|
||||
int nid = group.get_nozzle_id(f);
|
||||
REQUIRE(nid >= 0);
|
||||
distinct_nozzles.insert(nid);
|
||||
}
|
||||
REQUIRE(distinct_nozzles.size() == 4);
|
||||
|
||||
// get_nozzle_id must be stable across layers (no per-layer / selector map here).
|
||||
for (int f = 0; f < 4; ++f) {
|
||||
int base = group.get_nozzle_id(f, -1);
|
||||
REQUIRE(group.get_nozzle_id(f, 0) == base);
|
||||
REQUIRE(group.get_nozzle_id(f, 5) == base);
|
||||
}
|
||||
|
||||
// first-nozzle lookup agrees with the per-layer lookup for a static map.
|
||||
for (int f = 0; f < 4; ++f) {
|
||||
auto first = group.get_first_nozzle_for_filament(f);
|
||||
REQUIRE(first.has_value());
|
||||
REQUIRE(first->extruder_id == 1);
|
||||
REQUIRE(first->group_id == group.get_nozzle_id(f));
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("H2C dynamic selector: per-layer nozzle ids reach the g-code surface", "[ToolOrdering][H2C][Dynamic]")
|
||||
{
|
||||
// The per-layer regroup engine
|
||||
// (plan_filament_mapping_and_order_by_combo_ranges -> 4-arg LayeredNozzleGroupResult::create)
|
||||
// produces a *selector* result whose filament->nozzle map varies across layers. This is exactly
|
||||
// what GCode reads for H2C dynamic mode: hotend_id_for_gcode_placeholder /
|
||||
// nozzle_id_for_gcode_placeholder call group->is_support_dynamic_nozzle_map() and, when true,
|
||||
// group->get_nozzle_id(filament, layer) / get_first_nozzle_for_filament(filament). Here we build
|
||||
// the selector result directly (the engine's output shape) and assert those accessors return
|
||||
// per-layer values -- the surface that "goes live" only in dynamic mode. The static path (every
|
||||
// other test above) keeps is_support_dynamic_nozzle_map() == false and a stable nozzle id, so its
|
||||
// g-code is unchanged.
|
||||
|
||||
// H2C-like fleet: extruder 0 = 1 nozzle (group 0), extruder 1 = a 3-nozzle rack (groups 1..3).
|
||||
std::vector<NozzleInfo> nozzle_list;
|
||||
for (int g = 0; g < 4; ++g) {
|
||||
NozzleInfo n;
|
||||
n.diameter = "0.4";
|
||||
n.volume_type = nvtStandard;
|
||||
n.extruder_id = (g == 0) ? 0 : 1;
|
||||
n.group_id = g;
|
||||
nozzle_list.push_back(n);
|
||||
}
|
||||
|
||||
// Three filaments; filament 2 is reassigned from physical nozzle 2 (layers 0-1) to nozzle 3
|
||||
// (layers 2-3) by the per-layer selector -- the case that sets support_dynamic_nozzle_map.
|
||||
std::vector<std::vector<int>> layer_filament_nozzle_maps = {
|
||||
{0, 1, 2}, // layer 0
|
||||
{0, 1, 2}, // layer 1
|
||||
{0, 1, 3}, // layer 2: filament 2 moved to nozzle 3
|
||||
{0, 1, 3}, // layer 3
|
||||
};
|
||||
std::vector<std::vector<unsigned int>> layer_filament_sequences = {
|
||||
{0, 1, 2}, {0, 1, 2}, {0, 1, 2}, {0, 1, 2},
|
||||
};
|
||||
std::vector<unsigned int> used_filaments = {0, 1, 2};
|
||||
|
||||
auto group_opt = LayeredNozzleGroupResult::create(layer_filament_nozzle_maps, nozzle_list, used_filaments, layer_filament_sequences);
|
||||
REQUIRE(group_opt.has_value());
|
||||
auto &group = *group_opt;
|
||||
|
||||
// The selector is active: a filament maps to more than one physical nozzle across layers.
|
||||
REQUIRE(group.is_support_dynamic_nozzle_map());
|
||||
|
||||
// Per-layer hotend/nozzle ids -- the values the dynamic g-code placeholders emit.
|
||||
REQUIRE(group.get_nozzle_id(2, 0) == 2);
|
||||
REQUIRE(group.get_nozzle_id(2, 1) == 2);
|
||||
REQUIRE(group.get_nozzle_id(2, 2) == 3); // reassigned on layer 2
|
||||
REQUIRE(group.get_nozzle_id(2, 3) == 3);
|
||||
REQUIRE(group.get_extruder_id(2, 0) == 1);
|
||||
REQUIRE(group.get_extruder_id(2, 2) == 1);
|
||||
|
||||
// Unmoved filaments keep a stable id across layers.
|
||||
REQUIRE(group.get_nozzle_id(0, 0) == 0);
|
||||
REQUIRE(group.get_nozzle_id(0, 3) == 0);
|
||||
REQUIRE(group.get_nozzle_id(1, 0) == 1);
|
||||
REQUIRE(group.get_nozzle_id(1, 3) == 1);
|
||||
|
||||
// first-nozzle lookup (used by the *_first_* placeholders / start g-code) is the first layer's id.
|
||||
auto first2 = group.get_first_nozzle_for_filament(2);
|
||||
REQUIRE(first2.has_value());
|
||||
REQUIRE(first2->group_id == 2);
|
||||
|
||||
// every physical nozzle a filament visits is reported (3mf metadata / nozzle_diameters_by_nozzle_id).
|
||||
std::set<int> fil2_nozzles;
|
||||
for (const auto &n : group.get_nozzles_for_filament(2))
|
||||
fil2_nozzles.insert(n.group_id);
|
||||
REQUIRE(fil2_nozzles == std::set<int>({2, 3}));
|
||||
}
|
||||
|
||||
TEST_CASE("Multi-nozzle reorder tolerates a filament with no nozzle (RL-48)", "[ToolOrdering][H2C][Dynamic]")
|
||||
{
|
||||
// The per-layer engine can hand reorder_filaments_for_multi_nozzle_extruder a group result that
|
||||
// resolves no nozzle for a layer's filament (a degenerate/malformed input where a layer references
|
||||
// a filament index outside the grouping map). Unguarded, that dereferences std::max_element() on an
|
||||
// empty extruder set (SIGSEGV). The guard must instead emit each layer's filaments in order and
|
||||
// return, so a bad input degrades gracefully rather than crashing.
|
||||
auto nozzle_list = single_nozzle_per_extruder(2);
|
||||
std::vector<int> filament_nozzle_map = {0}; // map only covers filament 0
|
||||
auto group_opt = LayeredNozzleGroupResult::create(filament_nozzle_map, nozzle_list, std::vector<unsigned int>{0});
|
||||
REQUIRE(group_opt.has_value());
|
||||
|
||||
std::vector<unsigned int> filament_lists = {3}; // filament 3 resolves to no nozzle
|
||||
std::vector<std::vector<unsigned int>> layer_filaments = {{3}, {3}};
|
||||
std::vector<std::vector<std::vector<float>>> flush_matrix(2, {{0.f}}); // unused on the guard path
|
||||
std::vector<std::vector<unsigned int>> sequences;
|
||||
|
||||
REQUIRE_NOTHROW(reorder_filaments_for_multi_nozzle_extruder(filament_lists, *group_opt, layer_filaments, flush_matrix, nullptr, &sequences));
|
||||
// Each layer still gets a valid sequence (its own filaments) — no reorder, no crash.
|
||||
REQUIRE(sequences.size() == layer_filaments.size());
|
||||
REQUIRE(sequences[0] == std::vector<unsigned int>{3});
|
||||
REQUIRE(sequences[1] == std::vector<unsigned int>{3});
|
||||
}
|
||||
|
||||
// The round-robin build_multi_nozzle_group_result adapter was superseded by the
|
||||
// nozzle-centric FilamentGroup engine (get_recommended_filament_maps now decides nozzle co-location
|
||||
// by flush cost, not round-robin). The two former pipeline tests are dropped:
|
||||
// * H2C multi-nozzle physical-nozzle resolution (6-arg create) is covered above by the
|
||||
// "H2C multi-nozzle: filaments get distinct nozzles" case;
|
||||
// * the single-nozzle "nozzle id == extruder id" degradation is covered above by the
|
||||
// "Single-nozzle grouping" case (build_default_nozzle_list + 3-arg create is the exact path the
|
||||
// gate-closed branch and by-object fallback use);
|
||||
// * end-to-end H2C/H2D grouping co-location is now pinned by the filament_group golden suite
|
||||
// (tests/filament_group, config_b/config_c).
|
||||
|
||||
TEST_CASE("extruder_nozzle_stats round-trips through save/parse", "[ToolOrdering][H2C]")
|
||||
{
|
||||
// The per-extruder nozzle inventory must survive save_extruder_nozzle_stats_to_string ->
|
||||
// get_extruder_nozzle_stats unchanged, so printer presets and 3mf projects persist it.
|
||||
std::vector<std::map<NozzleVolumeType, int>> stats = {
|
||||
{{nvtStandard, 1}}, // extruder 0: single standard nozzle
|
||||
{{nvtStandard, 5}, {nvtHighFlow, 1}}, // extruder 1: 6-nozzle mixed cluster
|
||||
};
|
||||
REQUIRE(get_extruder_nozzle_stats(save_extruder_nozzle_stats_to_string(stats)) == stats);
|
||||
}
|
||||
|
||||
// The filament-change-time model (MultiNozzleUtils::simulate_filament_change_time) is self-contained
|
||||
// analytic code with no slicing-pipeline caller yet; these fixtures pin its numeric output so future
|
||||
// changes and its first consumer (the filament_group golden harness) build on a locked model. Expected
|
||||
// values are hand-traced through the AMS -> selector -> extruder transport model.
|
||||
TEST_CASE("Filament-change-time model matches the BBS analytic simulation", "[MultiNozzle][H2C][ChangeTime]")
|
||||
{
|
||||
using Catch::Matchers::WithinAbs;
|
||||
|
||||
// Load/unload constants mirror the golden config_c change_time_params
|
||||
// (selector 1/1, standard 3/2): a selector move costs 1, a full AMS load 3 / unload 2.
|
||||
FilamentChangeTimeParams params;
|
||||
params.selector_load_time = 1.0f;
|
||||
params.selector_unload_time = 1.0f;
|
||||
params.standard_load_time = 3.0f;
|
||||
params.standard_unload_time = 2.0f;
|
||||
|
||||
// One extruder carrying one physical nozzle (nozzle id == extruder id == 0).
|
||||
std::vector<NozzleInfo> nozzle_list(1);
|
||||
nozzle_list[0].diameter = "0.4";
|
||||
nozzle_list[0].volume_type = nvtStandard;
|
||||
nozzle_list[0].extruder_id = 0;
|
||||
nozzle_list[0].group_id = 0;
|
||||
|
||||
// Two filaments in distinct AMS groups, printed in the order A, B, A on nozzle 0.
|
||||
std::vector<int> logical_filaments = {0, 1};
|
||||
std::vector<int> group_of_filament = {0, 1};
|
||||
std::vector<int> filament_change_seq = {0, 1, 0};
|
||||
std::vector<int> nozzle_change_seq = {0, 0, 0};
|
||||
|
||||
SECTION("no AMS pre-load: each change is a full AMS<->extruder transport")
|
||||
{
|
||||
auto r = simulate_filament_change_time(
|
||||
logical_filaments, nozzle_list, filament_change_seq, nozzle_change_seq,
|
||||
group_of_filament, params, /*ams_preload_enabled=*/{}, /*calc_sliced_time=*/true);
|
||||
// load0(3) + [unload0(2)+load1(3)] + [unload1(2)+load0(3)] = 13
|
||||
REQUIRE_THAT(r.actual_time, WithinAbs(13.0, 1e-6));
|
||||
// Single nozzle, no selector overlap => slicer estimate equals the actual time.
|
||||
REQUIRE_THAT(r.sliced_time, WithinAbs(13.0, 1e-6));
|
||||
}
|
||||
|
||||
SECTION("AMS pre-load overlaps transport, shrinking the actual time")
|
||||
{
|
||||
std::vector<bool> preload = {true, true};
|
||||
auto r = simulate_filament_change_time(
|
||||
logical_filaments, nozzle_list, filament_change_seq, nozzle_change_seq,
|
||||
group_of_filament, params, preload, /*calc_sliced_time=*/false);
|
||||
// Pre-loading the next filament into the selector runs in parallel with the current
|
||||
// extruder move, so the selector<->extruder legs dominate: 3 + (1+1) + (1+1) = 7.
|
||||
REQUIRE_THAT(r.actual_time, WithinAbs(7.0, 1e-6));
|
||||
}
|
||||
|
||||
SECTION("degenerate inputs return zero")
|
||||
{
|
||||
auto r = simulate_filament_change_time({}, nozzle_list, filament_change_seq,
|
||||
nozzle_change_seq, {}, params);
|
||||
REQUIRE_THAT(r.actual_time, WithinAbs(0.0, 1e-6));
|
||||
REQUIRE_THAT(r.sliced_time, WithinAbs(0.0, 1e-6));
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("NozzleStatusRecorder tracks nozzle/extruder occupancy", "[MultiNozzle][H2C][ChangeTime]")
|
||||
{
|
||||
NozzleStatusRecorder rec;
|
||||
REQUIRE(rec.is_nozzle_empty(0));
|
||||
REQUIRE(rec.get_filament_in_nozzle(0) == -1);
|
||||
REQUIRE(rec.get_nozzle_in_extruder(0) == -1);
|
||||
|
||||
rec.set_nozzle_status(2, 5, 1); // nozzle 2 holds filament 5, mounted on extruder 1
|
||||
REQUIRE_FALSE(rec.is_nozzle_empty(2));
|
||||
REQUIRE(rec.get_filament_in_nozzle(2) == 5);
|
||||
REQUIRE(rec.get_nozzle_in_extruder(1) == 2);
|
||||
|
||||
rec.clear_nozzle_status(2);
|
||||
REQUIRE(rec.is_nozzle_empty(2));
|
||||
REQUIRE(rec.get_filament_in_nozzle(2) == -1);
|
||||
// Clearing a nozzle leaves the extruder->nozzle association intact.
|
||||
REQUIRE(rec.get_nozzle_in_extruder(1) == 2);
|
||||
}
|
||||
|
||||
TEST_CASE("Hybrid nozzle stats resolve to concrete volume types", "[ToolOrdering][H2C]")
|
||||
{
|
||||
// Extruder 0 is Standard-only; extruder 1 carries a mixed Standard + High Flow inventory
|
||||
// (the "Hybrid" flow selection). The write-back pipeline persists get_volume_map(), so the
|
||||
// result must always carry concrete per-filament volume types, never the Hybrid seed.
|
||||
auto stats = get_extruder_nozzle_stats({"Standard#1", "Standard#1|High Flow#1"});
|
||||
REQUIRE(stats.size() == 2);
|
||||
REQUIRE(stats[1].size() == 2);
|
||||
|
||||
std::vector<unsigned int> used_filaments = {0, 1, 2};
|
||||
std::vector<int> filament_map = {0, 1, 1}; // 0-based extruder ids
|
||||
std::vector<int> volume_requests = {(int) nvtStandard, (int) nvtHighFlow, (int) nvtStandard};
|
||||
std::vector<int> nozzle_requests = {0, 1, 2}; // distinct logical nozzles
|
||||
|
||||
auto group = LayeredNozzleGroupResult::create(used_filaments, filament_map, volume_requests, nozzle_requests, stats, 0.4f);
|
||||
REQUIRE(group.has_value());
|
||||
|
||||
auto volume_map = group->get_volume_map();
|
||||
REQUIRE(volume_map == volume_requests);
|
||||
for (auto fid : used_filaments)
|
||||
REQUIRE(volume_map[fid] != (int) nvtHybrid);
|
||||
|
||||
// The Hybrid seed itself matches no physical nozzle: such a request is unsatisfiable.
|
||||
std::vector<int> hybrid_requests = {(int) nvtStandard, (int) nvtHybrid, (int) nvtStandard};
|
||||
REQUIRE_FALSE(LayeredNozzleGroupResult::create(used_filaments, filament_map, hybrid_requests, nozzle_requests, stats, 0.4f).has_value());
|
||||
}
|
||||
|
||||
TEST_CASE("update_used_filament_values merges only used filaments", "[ToolOrdering][H2C]")
|
||||
{
|
||||
// The config write-back merges the engine's per-filament values over the config baseline:
|
||||
// used filaments adopt the engine value, unused filaments keep their config assignment.
|
||||
std::vector<int> old_values = {1, 1, 2, 1};
|
||||
std::vector<int> new_values = {2, 2, 1, 2};
|
||||
std::vector<unsigned int> used = {0, 2};
|
||||
|
||||
auto merged = FilamentGroupUtils::update_used_filament_values(old_values, new_values, used);
|
||||
REQUIRE(merged == std::vector<int>{2, 1, 1, 1});
|
||||
|
||||
// No used filaments => the config baseline is returned untouched.
|
||||
REQUIRE(FilamentGroupUtils::update_used_filament_values(old_values, new_values, {}) == old_values);
|
||||
}
|
||||
|
||||
TEST_CASE("Print config-index resolvers pick per-filament Hybrid slots", "[Print][H2C]")
|
||||
{
|
||||
// A 2-extruder printer whose second extruder is Hybrid (Standard + High Flow nozzles).
|
||||
// The preset-style variant columns carry one column per (extruder x volume type); apply()
|
||||
// expands them to the 3-slot layout [e1-Std, e2-Std, e2-HF].
|
||||
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
||||
config.option<ConfigOptionFloats>("nozzle_diameter", true)->values = {0.4, 0.4};
|
||||
config.option<ConfigOptionStrings>("extruder_nozzle_stats", true)->values = {"Standard#1", "Standard#1|High Flow#2"};
|
||||
config.option<ConfigOptionEnumsGeneric>("extruder_type", true)->values = {etDirectDrive, etDirectDrive};
|
||||
config.option<ConfigOptionEnumsGeneric>("nozzle_volume_type", true)->values = {nvtStandard, nvtHybrid};
|
||||
config.option<ConfigOptionStrings>("extruder_variant_list", true)->values = {"Direct Drive Standard,Direct Drive High Flow",
|
||||
"Direct Drive Standard,Direct Drive High Flow"};
|
||||
config.option<ConfigOptionInts>("print_extruder_id", true)->values = {1, 1, 2, 2};
|
||||
config.option<ConfigOptionStrings>("print_extruder_variant", true)->values = {"Direct Drive Standard", "Direct Drive High Flow",
|
||||
"Direct Drive Standard", "Direct Drive High Flow"};
|
||||
config.option<ConfigOptionFloats>("outer_wall_speed", true)->values = {30., 200., 50., 500.};
|
||||
|
||||
// Three filaments: 0 -> extruder 1 (Std), 1 -> extruder 2 (Std), 2 -> extruder 2 (High Flow).
|
||||
config.option<ConfigOptionFloats>("filament_diameter", true)->values = {1.75, 1.75, 1.75};
|
||||
config.option<ConfigOptionStrings>("filament_colour", true)->values = {"#FF0000", "#00FF00", "#0000FF"};
|
||||
config.option<ConfigOptionInts>("filament_map", true)->values = {1, 2, 2};
|
||||
config.option<ConfigOptionInts>("filament_volume_map", true)->values = {(int) nvtStandard, (int) nvtStandard, (int) nvtHighFlow};
|
||||
|
||||
Model model;
|
||||
model.add_object("cube", "", make_cube(20, 20, 20))->add_instance();
|
||||
|
||||
Print print;
|
||||
print.apply(model, config);
|
||||
|
||||
// Stub grouping result mirroring the maps above: one nozzle per (extruder, volume type).
|
||||
std::vector<NozzleInfo> nozzle_list;
|
||||
{
|
||||
NozzleInfo n;
|
||||
n.diameter = "0.4";
|
||||
n.volume_type = nvtStandard; n.extruder_id = 0; n.group_id = 0; nozzle_list.push_back(n);
|
||||
n.volume_type = nvtStandard; n.extruder_id = 1; n.group_id = 1; nozzle_list.push_back(n);
|
||||
n.volume_type = nvtHighFlow; n.extruder_id = 1; n.group_id = 2; nozzle_list.push_back(n);
|
||||
}
|
||||
std::vector<unsigned int> used_filaments = {0, 1, 2};
|
||||
auto group = LayeredNozzleGroupResult::create(std::vector<int>{0, 1, 2}, nozzle_list, used_filaments);
|
||||
REQUIRE(group.has_value());
|
||||
print.set_nozzle_group_result(std::make_shared<LayeredNozzleGroupResult>(*group));
|
||||
|
||||
// The write-back re-expands the config and refreshes the resolver caches.
|
||||
print.update_filament_maps_to_config({1, 2, 2}, {(int) nvtStandard, (int) nvtStandard, (int) nvtHighFlow}, {0, 1, 2});
|
||||
|
||||
// The expansion must have produced the 3-slot layout the resolvers index into.
|
||||
const auto ®ion_config = print.default_region_config();
|
||||
REQUIRE(region_config.print_extruder_variant.values ==
|
||||
std::vector<std::string>({"Direct Drive Standard", "Direct Drive Standard", "Direct Drive High Flow"}));
|
||||
REQUIRE(region_config.print_extruder_id.values == std::vector<int>({1, 2, 2}));
|
||||
|
||||
SECTION("each filament resolves to its own (extruder x volume type) slot") {
|
||||
REQUIRE(print.get_nozzle_config_index(0, 0) == 0); // extruder 1, Standard
|
||||
REQUIRE(print.get_nozzle_config_index(1, 0) == 1); // extruder 2, Standard
|
||||
REQUIRE(print.get_nozzle_config_index(2, 0) == 2); // extruder 2, High Flow
|
||||
}
|
||||
|
||||
SECTION("without a group result the resolver falls back to the filament's extruder slot") {
|
||||
print.set_nozzle_group_result(nullptr);
|
||||
REQUIRE(print.get_nozzle_config_index(0, 0) == 0);
|
||||
REQUIRE(print.get_nozzle_config_index(1, 0) == 1);
|
||||
REQUIRE(print.get_nozzle_config_index(2, 0) == 1); // extruder slot, not the High Flow slot
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("Re-applying an unchanged config after slicing keeps the result valid", "[Print][H2C]")
|
||||
{
|
||||
// apply() rebuilds m_config.filament_map_2 to the real per-filament slot map, while the
|
||||
// incoming full config only ever carries the ConfigDef default for it. The engine-derived
|
||||
// key must therefore be kept out of the apply diff: the GUI re-applies right after slicing
|
||||
// completes, and a phantom filament_map_2 diff would invalidate every freshly sliced result
|
||||
// on any multi-extruder printer.
|
||||
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
||||
config.option<ConfigOptionFloats>("nozzle_diameter", true)->values = {0.4, 0.4};
|
||||
config.option<ConfigOptionStrings>("extruder_nozzle_stats", true)->values = {"Standard#1", "Standard#1|High Flow#2"};
|
||||
config.option<ConfigOptionEnumsGeneric>("extruder_type", true)->values = {etDirectDrive, etDirectDrive};
|
||||
config.option<ConfigOptionEnumsGeneric>("nozzle_volume_type", true)->values = {nvtStandard, nvtHybrid};
|
||||
config.option<ConfigOptionStrings>("extruder_variant_list", true)->values = {"Direct Drive Standard,Direct Drive High Flow",
|
||||
"Direct Drive Standard,Direct Drive High Flow"};
|
||||
config.option<ConfigOptionInts>("print_extruder_id", true)->values = {1, 1, 2, 2};
|
||||
config.option<ConfigOptionStrings>("print_extruder_variant", true)->values = {"Direct Drive Standard", "Direct Drive High Flow",
|
||||
"Direct Drive Standard", "Direct Drive High Flow"};
|
||||
config.option<ConfigOptionFloats>("filament_diameter", true)->values = {1.75, 1.75, 1.75};
|
||||
config.option<ConfigOptionStrings>("filament_colour", true)->values = {"#FF0000", "#00FF00", "#0000FF"};
|
||||
config.option<ConfigOptionInts>("filament_map", true)->values = {1, 2, 2};
|
||||
config.option<ConfigOptionInts>("filament_volume_map", true)->values = {(int) nvtStandard, (int) nvtStandard, (int) nvtHighFlow};
|
||||
|
||||
Model model;
|
||||
ModelObject *object = model.add_object("cube", "", make_cube(20, 20, 20));
|
||||
object->add_instance()->set_offset(Vec3d(100., 100., 0.));
|
||||
|
||||
Print print;
|
||||
print.apply(model, config);
|
||||
print.process();
|
||||
REQUIRE(print.is_step_done(psSlicingFinished));
|
||||
|
||||
auto status = print.apply(model, config);
|
||||
REQUIRE(status != PrintBase::APPLY_STATUS_INVALIDATED);
|
||||
REQUIRE(print.is_step_done(psSlicingFinished));
|
||||
}
|
||||
|
||||
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") {
|
||||
// Filament 1 extrudes on layers 0 (nozzle 1) and 3 (nozzle 2); the planner leaves stale
|
||||
// entries on the layers in between.
|
||||
std::vector<std::vector<int>> maps = {
|
||||
{0, 1},
|
||||
{0, -1}, // filament 1 idle
|
||||
{0, -1}, // filament 1 idle
|
||||
{0, 2},
|
||||
};
|
||||
std::vector<std::vector<unsigned int>> filaments = {{0, 1}, {0}, {0}, {0, 1}};
|
||||
|
||||
normalize_nozzle_map_per_layer(maps, filaments);
|
||||
|
||||
REQUIRE(maps[0] == std::vector<int>({0, 1}));
|
||||
REQUIRE(maps[1] == std::vector<int>({0, 1})); // carried forward
|
||||
REQUIRE(maps[2] == std::vector<int>({0, 1})); // carried forward
|
||||
REQUIRE(maps[3] == std::vector<int>({0, 2})); // used layer untouched
|
||||
}
|
||||
|
||||
SECTION("layers before a filament's first use inherit its first nozzle") {
|
||||
std::vector<std::vector<int>> maps = {
|
||||
{0, -1},
|
||||
{0, -1},
|
||||
{0, 3}, // filament 1 first extrudes here
|
||||
};
|
||||
std::vector<std::vector<unsigned int>> filaments = {{0}, {0}, {0, 1}};
|
||||
|
||||
normalize_nozzle_map_per_layer(maps, filaments);
|
||||
|
||||
REQUIRE(maps[0] == std::vector<int>({0, 3})); // back-filled
|
||||
REQUIRE(maps[1] == std::vector<int>({0, 3})); // back-filled
|
||||
REQUIRE(maps[2] == std::vector<int>({0, 3}));
|
||||
}
|
||||
|
||||
SECTION("empty and ragged inputs are safe no-ops") {
|
||||
std::vector<std::vector<int>> empty_maps;
|
||||
std::vector<std::vector<unsigned int>> no_filaments;
|
||||
REQUIRE_NOTHROW(normalize_nozzle_map_per_layer(empty_maps, no_filaments));
|
||||
REQUIRE(empty_maps.empty());
|
||||
|
||||
// Rows of different widths and a filament list shorter than the map list.
|
||||
std::vector<std::vector<int>> ragged = {{0}, {0, 1, 2}};
|
||||
std::vector<std::vector<unsigned int>> short_filaments = {{0}};
|
||||
REQUIRE_NOTHROW(normalize_nozzle_map_per_layer(ragged, short_filaments));
|
||||
REQUIRE(ragged[0] == std::vector<int>({0}));
|
||||
}
|
||||
|
||||
SECTION("a single layer is left unchanged") {
|
||||
std::vector<std::vector<int>> maps = {{2, 1, 0}};
|
||||
std::vector<std::vector<unsigned int>> filaments = {{0, 1, 2}};
|
||||
normalize_nozzle_map_per_layer(maps, filaments);
|
||||
REQUIRE(maps[0] == std::vector<int>({2, 1, 0}));
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("Stitched sequential blocks resolve per-layer after normalization", "[MultiNozzle][H2C][Dynamic]")
|
||||
{
|
||||
// Shape of the sequential (by-object) stitch: two per-object plan blocks concatenated on one
|
||||
// global layer axis, where the second object's plan moves filament 1 to another physical
|
||||
// nozzle. After normalization the 4-arg create() must detect the migration (selector result)
|
||||
// and resolve stable ids inside each object's layer range.
|
||||
std::vector<NozzleInfo> nozzle_list;
|
||||
for (int g = 0; g < 3; ++g) {
|
||||
NozzleInfo n;
|
||||
n.diameter = "0.4";
|
||||
n.volume_type = nvtStandard;
|
||||
n.extruder_id = (g == 0) ? 0 : 1;
|
||||
n.group_id = g;
|
||||
nozzle_list.push_back(n);
|
||||
}
|
||||
|
||||
// Object A (layers 0-1): filament 1 on nozzle 1, filament 0 idle until layer 1.
|
||||
// Object B (layers 2-3): filament 1 moved to nozzle 2.
|
||||
std::vector<std::vector<int>> stitched_maps = {
|
||||
{-1, 1},
|
||||
{0, 1},
|
||||
{0, 2},
|
||||
{0, 2},
|
||||
};
|
||||
std::vector<std::vector<unsigned int>> stitched_filaments = {{1}, {0, 1}, {0, 1}, {0, 1}};
|
||||
std::vector<unsigned int> used_filaments = {0, 1};
|
||||
|
||||
normalize_nozzle_map_per_layer(stitched_maps, stitched_filaments);
|
||||
REQUIRE(stitched_maps[0] == std::vector<int>({0, 1})); // filament 0 back-filled to its first nozzle
|
||||
|
||||
auto group_opt = LayeredNozzleGroupResult::create(stitched_maps, nozzle_list, used_filaments, stitched_filaments);
|
||||
REQUIRE(group_opt.has_value());
|
||||
auto &group = *group_opt;
|
||||
|
||||
// A filament on two physical nozzles across the objects => selector result.
|
||||
REQUIRE(group.is_support_dynamic_nozzle_map());
|
||||
REQUIRE(group.get_nozzle_id(1, 0) == 1);
|
||||
REQUIRE(group.get_nozzle_id(1, 1) == 1);
|
||||
REQUIRE(group.get_nozzle_id(1, 2) == 2); // second object's range
|
||||
REQUIRE(group.get_nozzle_id(1, 3) == 2);
|
||||
// The default (out-of-range) map is the first layer's normalized row.
|
||||
REQUIRE(group.get_nozzle_id(0, 999) == 0);
|
||||
REQUIRE(group.get_nozzle_id(1, 999) == 1);
|
||||
}
|
||||
|
||||
TEST_CASE("Sequential selector prints publish a stitched result and cache the plans", "[Print][H2C][Dynamic]")
|
||||
{
|
||||
// By-object + smart filament assign: the by-object branch of Print::process must plan each
|
||||
// object with nozzle-status threading, cache the plans for the g-code export, stitch them
|
||||
// into the published print-wide result, and write the grouping result back to the config
|
||||
// once (per-object orderings must not churn the config).
|
||||
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
||||
config.option<ConfigOptionFloats>("nozzle_diameter", true)->values = {0.4, 0.4};
|
||||
config.option<ConfigOptionStrings>("extruder_nozzle_stats", true)->values = {"Standard#1", "Standard#1|High Flow#2"};
|
||||
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 High Flow",
|
||||
"Direct Drive Standard,Direct Drive High Flow"};
|
||||
config.option<ConfigOptionInts>("print_extruder_id", true)->values = {1, 1, 2, 2};
|
||||
config.option<ConfigOptionStrings>("print_extruder_variant", true)->values = {"Direct Drive Standard", "Direct Drive High Flow",
|
||||
"Direct Drive Standard", "Direct Drive High Flow"};
|
||||
config.option<ConfigOptionFloats>("filament_diameter", true)->values = {1.75, 1.75};
|
||||
config.option<ConfigOptionStrings>("filament_colour", true)->values = {"#FF0000", "#00FF00"};
|
||||
config.option<ConfigOptionInts>("filament_map", true)->values = {1, 2};
|
||||
config.option<ConfigOptionInts>("filament_volume_map", true)->values = {(int) nvtStandard, (int) nvtStandard};
|
||||
config.set_key_value("enable_filament_dynamic_map", new ConfigOptionBool(true));
|
||||
config.option<ConfigOptionEnum<FilamentMapMode>>("filament_map_mode", true)->value = FilamentMapMode::fmmAutoForFlush;
|
||||
config.option<ConfigOptionEnum<PrintSequence>>("print_sequence", true)->value = PrintSequence::ByObject;
|
||||
// Export validates flush_volumes_matrix as filaments^2 values per head.
|
||||
config.option<ConfigOptionFloats>("flush_volumes_matrix", true)->values = std::vector<double>(8, 140.);
|
||||
config.option<ConfigOptionFloats>("flush_multiplier", true)->values = {1., 1.};
|
||||
|
||||
Model model;
|
||||
ModelObject *object_a = model.add_object("cube_a", "", make_cube(20, 20, 20));
|
||||
ModelInstance *instance_a = object_a->add_instance();
|
||||
instance_a->set_offset(Vec3d(70., 100., 0.));
|
||||
ModelObject *object_b = model.add_object("cube_b", "", make_cube(20, 20, 20));
|
||||
object_b->config.set_key_value("extruder", new ConfigOptionInt(2));
|
||||
ModelInstance *instance_b = object_b->add_instance();
|
||||
instance_b->set_offset(Vec3d(150., 100., 0.));
|
||||
// The sequential instance ordering keys on arrange_order, which validate() assigns before
|
||||
// process() in the real pipeline (instances tying at 0 get dropped from the ordering);
|
||||
// initialize it here since the test drives process() directly.
|
||||
instance_a->arrange_order = 1;
|
||||
instance_b->arrange_order = 2;
|
||||
|
||||
Print print;
|
||||
print.apply(model, config);
|
||||
REQUIRE(print.objects().size() == 2);
|
||||
print.process();
|
||||
REQUIRE(print.is_step_done(psSlicingFinished));
|
||||
|
||||
auto result = print.get_layered_nozzle_group_result();
|
||||
REQUIRE(result != nullptr);
|
||||
// One cached plan per unique object, and a stitched layer axis spanning both objects.
|
||||
REQUIRE(print.sequential_dynamic_orderings().size() == 2);
|
||||
REQUIRE(result->get_layer_count() > 0);
|
||||
// The write-back mirrors the stitched result's extruder map.
|
||||
REQUIRE(print.config().filament_map.values == result->get_extruder_map(false));
|
||||
// The per-slot filament arrays stay label-consistent whether or not the stitched plan
|
||||
// actually migrated a filament (one slot per filament, plus one per extra variant).
|
||||
REQUIRE(print.config().filament_extruder_variant.values.size() == print.config().filament_self_index.values.size());
|
||||
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);
|
||||
}
|
||||
|
||||
TEST_CASE("Per-variant expansion gives migrating filaments one slot per variant", "[PrintConfig][H2C][Dynamic]")
|
||||
{
|
||||
// The selector write-back rebuilds the filament arrays from the grouping result: a filament
|
||||
// that prints through several (extruder x volume type) variants keeps one slot per variant,
|
||||
// and every key grows in lockstep with the self-index / variant labels.
|
||||
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
||||
// Extruder 1 Standard, extruder 2 Hybrid (Standard + High Flow): 3 nozzle slots, 2 extruders.
|
||||
config.option<ConfigOptionEnumsGeneric>("extruder_type", true)->values = {etDirectDrive, etDirectDrive};
|
||||
config.option<ConfigOptionEnumsGeneric>("nozzle_volume_type", true)->values = {nvtStandard, nvtHybrid};
|
||||
config.option<ConfigOptionStrings>("extruder_variant_list", true)->values = {"Direct Drive Standard,Direct Drive High Flow",
|
||||
"Direct Drive Standard,Direct Drive High Flow"};
|
||||
config.option<ConfigOptionInts>("print_extruder_id", true)->values = {1, 1, 2, 2};
|
||||
config.option<ConfigOptionStrings>("print_extruder_variant", true)->values = {"Direct Drive Standard", "Direct Drive High Flow",
|
||||
"Direct Drive Standard", "Direct Drive High Flow"};
|
||||
// Two filaments with superset arrays: one column per (filament x variant).
|
||||
config.option<ConfigOptionInts>("filament_map", true)->values = {1, 2};
|
||||
config.option<ConfigOptionInts>("filament_volume_map", true)->values = {(int) nvtStandard, (int) nvtHighFlow};
|
||||
config.option<ConfigOptionInts>("filament_self_index", true)->values = {1, 1, 2, 2};
|
||||
config.option<ConfigOptionStrings>("filament_extruder_variant", true)->values = {"Direct Drive Standard", "Direct Drive High Flow",
|
||||
"Direct Drive Standard", "Direct Drive High Flow"};
|
||||
config.option<ConfigOptionInts>("nozzle_temperature", true)->values = {220, 230, 240, 250};
|
||||
|
||||
std::set<std::string> key_set = {"filament_self_index", "filament_extruder_variant", "nozzle_temperature"};
|
||||
|
||||
auto make_use = [](ExtruderType et, NozzleVolumeType nvt, int extruder_id) {
|
||||
FilamentVariantUse use;
|
||||
use.extruder_type = et;
|
||||
use.nozzle_volume_type = nvt;
|
||||
use.extruder_id = extruder_id;
|
||||
return use;
|
||||
};
|
||||
|
||||
SECTION("a migrating filament expands, machine slots track each output slot") {
|
||||
std::unordered_map<int, std::vector<FilamentVariantUse>> uses;
|
||||
uses[0] = {make_use(etDirectDrive, nvtStandard, 0), make_use(etDirectDrive, nvtHighFlow, 1)};
|
||||
uses[1] = {make_use(etDirectDrive, nvtHighFlow, 1)};
|
||||
std::vector<int> slot_machine_indices;
|
||||
config.update_filament_config_values_for_multiple_extruders(config, uses, 2, 3, key_set,
|
||||
"filament_self_index", "filament_extruder_variant",
|
||||
&slot_machine_indices);
|
||||
REQUIRE(config.option<ConfigOptionInts>("filament_self_index")->values == std::vector<int>{1, 1, 2});
|
||||
REQUIRE(config.option<ConfigOptionStrings>("filament_extruder_variant")->values ==
|
||||
std::vector<std::string>({"Direct Drive Standard", "Direct Drive High Flow", "Direct Drive High Flow"}));
|
||||
REQUIRE(config.option<ConfigOptionInts>("nozzle_temperature")->values == std::vector<int>{220, 230, 250});
|
||||
// Slot 0 backs onto extruder 1 Standard; slots 1-2 onto extruder 2 High Flow.
|
||||
REQUIRE(slot_machine_indices == std::vector<int>{0, 3, 3});
|
||||
}
|
||||
|
||||
SECTION("filaments absent from the uses fall back to their static assignment") {
|
||||
std::unordered_map<int, std::vector<FilamentVariantUse>> uses;
|
||||
uses[0] = {make_use(etDirectDrive, nvtStandard, 0)};
|
||||
// Filament 1 unrouted: filament_map -> extruder 2 (Hybrid) -> volume map -> High Flow.
|
||||
config.update_filament_config_values_for_multiple_extruders(config, uses, 2, 3, key_set,
|
||||
"filament_self_index", "filament_extruder_variant");
|
||||
REQUIRE(config.option<ConfigOptionInts>("filament_self_index")->values == std::vector<int>{1, 2});
|
||||
REQUIRE(config.option<ConfigOptionInts>("nozzle_temperature")->values == std::vector<int>{220, 250});
|
||||
}
|
||||
|
||||
SECTION("a mis-sized filament_volume_map is ignored") {
|
||||
config.option<ConfigOptionInts>("filament_volume_map", true)->values = {(int) nvtHighFlow};
|
||||
std::unordered_map<int, std::vector<FilamentVariantUse>> uses;
|
||||
uses[0] = {make_use(etDirectDrive, nvtStandard, 0)};
|
||||
// Unrouted filament 1 keeps the extruder's own typing (Hybrid folds to Standard).
|
||||
config.update_filament_config_values_for_multiple_extruders(config, uses, 2, 3, key_set,
|
||||
"filament_self_index", "filament_extruder_variant");
|
||||
REQUIRE(config.option<ConfigOptionInts>("nozzle_temperature")->values == std::vector<int>{220, 240});
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("Selector write-back expands migrating filaments and survives re-apply", "[Print][H2C][Dynamic]")
|
||||
{
|
||||
// A filament the per-layer plan moves between nozzle variants must end up with one config
|
||||
// slot per variant (so per-layer temperatures/retractions resolve correctly), the extruder
|
||||
// retract overrides must key each slot to its own variant's machine value, and an unchanged
|
||||
// re-apply must reproduce the expansion instead of trimming it back to one slot per
|
||||
// filament — a trim-back would diff the freshly written values and invalidate the result.
|
||||
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
||||
config.option<ConfigOptionFloats>("nozzle_diameter", true)->values = {0.4, 0.4};
|
||||
config.option<ConfigOptionStrings>("extruder_nozzle_stats", true)->values = {"Standard#1", "Standard#1|High Flow#2"};
|
||||
config.option<ConfigOptionEnumsGeneric>("extruder_type", true)->values = {etDirectDrive, etDirectDrive};
|
||||
config.option<ConfigOptionEnumsGeneric>("nozzle_volume_type", true)->values = {nvtStandard, nvtHybrid};
|
||||
config.option<ConfigOptionStrings>("extruder_variant_list", true)->values = {"Direct Drive Standard,Direct Drive High Flow",
|
||||
"Direct Drive Standard,Direct Drive High Flow"};
|
||||
config.option<ConfigOptionInts>("print_extruder_id", true)->values = {1, 1, 2, 2};
|
||||
config.option<ConfigOptionStrings>("print_extruder_variant", true)->values = {"Direct Drive Standard", "Direct Drive High Flow",
|
||||
"Direct Drive Standard", "Direct Drive High Flow"};
|
||||
// Three filaments: 0 -> extruder 1 (Std), 1 -> extruder 2 (Std), 2 -> extruder 2, migrating
|
||||
// Standard -> High Flow between layers. Superset arrays: one column per (filament x variant).
|
||||
// filament_type must be sized to the filament count: the variant-use collection (like the
|
||||
// full-config producers) keys the per-filament loop on it.
|
||||
config.option<ConfigOptionStrings>("filament_type", true)->values = {"PLA", "PLA", "PLA"};
|
||||
config.option<ConfigOptionFloats>("filament_diameter", true)->values = {1.75, 1.75, 1.75};
|
||||
config.option<ConfigOptionStrings>("filament_colour", true)->values = {"#FF0000", "#00FF00", "#0000FF"};
|
||||
config.option<ConfigOptionInts>("filament_map", true)->values = {1, 2, 2};
|
||||
config.option<ConfigOptionInts>("filament_volume_map", true)->values = {(int) nvtStandard, (int) nvtStandard, (int) nvtStandard};
|
||||
config.option<ConfigOptionInts>("filament_self_index", true)->values = {1, 1, 2, 2, 3, 3};
|
||||
config.option<ConfigOptionStrings>("filament_extruder_variant", true)->values = {"Direct Drive Standard", "Direct Drive High Flow",
|
||||
"Direct Drive Standard", "Direct Drive High Flow",
|
||||
"Direct Drive Standard", "Direct Drive High Flow"};
|
||||
config.option<ConfigOptionInts>("nozzle_temperature", true)->values = {200, 210, 220, 230, 240, 250};
|
||||
// The migrating filament's Standard column is nil, so the override merge must fall back to
|
||||
// the machine value of the Standard slot (not the High Flow one).
|
||||
config.option<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values =
|
||||
{0.5, 0.5, 0.6, 0.6, ConfigOptionFloatsNullable::nil_value(), 1.2};
|
||||
config.option<ConfigOptionFloats>("retraction_length", true)->values = {0.8, 0.9, 1.0, 1.1};
|
||||
|
||||
Model model;
|
||||
model.add_object("cube", "", make_cube(20, 20, 20))->add_instance();
|
||||
|
||||
Print print;
|
||||
print.apply(model, config);
|
||||
|
||||
// Stub grouping result: nozzles as in the resolver test; filament 2 prints on the Standard
|
||||
// nozzle at layer 0 and on the High Flow nozzle at layer 1.
|
||||
std::vector<NozzleInfo> nozzle_list;
|
||||
{
|
||||
NozzleInfo n;
|
||||
n.diameter = "0.4";
|
||||
n.volume_type = nvtStandard; n.extruder_id = 0; n.group_id = 0; nozzle_list.push_back(n);
|
||||
n.volume_type = nvtStandard; n.extruder_id = 1; n.group_id = 1; nozzle_list.push_back(n);
|
||||
n.volume_type = nvtHighFlow; n.extruder_id = 1; n.group_id = 2; nozzle_list.push_back(n);
|
||||
}
|
||||
std::vector<std::vector<int>> layer_maps = {{0, 1, 1}, {0, 1, 2}};
|
||||
std::vector<std::vector<unsigned int>> layer_seqs = {{0, 1, 2}, {0, 1, 2}};
|
||||
auto group = LayeredNozzleGroupResult::create(layer_maps, nozzle_list, {0, 1, 2}, layer_seqs);
|
||||
REQUIRE(group.has_value());
|
||||
REQUIRE(group->is_support_dynamic_nozzle_map());
|
||||
print.set_nozzle_group_result(std::make_shared<LayeredNozzleGroupResult>(*group));
|
||||
|
||||
print.update_to_config_by_nozzle_group_result(*group);
|
||||
|
||||
// Filament 2 holds two slots (Standard + High Flow), everything in lockstep.
|
||||
REQUIRE(print.config().filament_map.values == group->get_extruder_map(false));
|
||||
REQUIRE(print.config().filament_self_index.values == std::vector<int>{1, 2, 3, 3});
|
||||
REQUIRE(print.config().nozzle_temperature.values == std::vector<int>{200, 220, 240, 250});
|
||||
// The layer-aware resolver picks the slot matching each layer's variant.
|
||||
REQUIRE(print.get_filament_config_indx(2, 0) == 2);
|
||||
REQUIRE(print.get_filament_config_indx(2, 1) == 3);
|
||||
// Retract overrides: non-nil slots take the filament value; the nil Standard slot of the
|
||||
// migrating filament falls back to its own variant's machine value.
|
||||
const auto &machine_retract = print.full_print_config().option<ConfigOptionFloats>("retraction_length")->values;
|
||||
int f2_std_machine_slot = print.full_print_config().get_index_for_extruder(2, "print_extruder_id", etDirectDrive, nvtStandard,
|
||||
"print_extruder_variant");
|
||||
REQUIRE(f2_std_machine_slot >= 0);
|
||||
const std::vector<double> merged_retract = print.config().retraction_length.values;
|
||||
REQUIRE(merged_retract.size() == 4);
|
||||
REQUIRE_THAT(merged_retract[0], Catch::Matchers::WithinAbs(0.5, 1e-9));
|
||||
REQUIRE_THAT(merged_retract[1], Catch::Matchers::WithinAbs(0.6, 1e-9));
|
||||
REQUIRE_THAT(merged_retract[2], Catch::Matchers::WithinAbs(machine_retract[f2_std_machine_slot], 1e-9));
|
||||
REQUIRE_THAT(merged_retract[3], Catch::Matchers::WithinAbs(1.2, 1e-9));
|
||||
|
||||
// Re-apply the unchanged config: the persisted result must reproduce the exact expansion.
|
||||
auto status = print.apply(model, config);
|
||||
REQUIRE(status != PrintBase::APPLY_STATUS_INVALIDATED);
|
||||
REQUIRE(print.config().filament_self_index.values == std::vector<int>{1, 2, 3, 3});
|
||||
REQUIRE(print.config().nozzle_temperature.values == std::vector<int>{200, 220, 240, 250});
|
||||
REQUIRE(print.config().retraction_length.values == merged_retract);
|
||||
}
|
||||
|
||||
TEST_CASE("Filaments ordered after a migrator shift columns and the resolver tracks them", "[Print][H2C][Dynamic]")
|
||||
{
|
||||
// When a mid-list filament expands to two columns, every later filament's values move one
|
||||
// column to the right — a raw get_at(filament_id) lands in the migrator's second column.
|
||||
// The layer-aware resolver must return the shifted column for both the expanded filament
|
||||
// arrays and the merged machine overrides.
|
||||
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
||||
config.option<ConfigOptionFloats>("nozzle_diameter", true)->values = {0.4, 0.4};
|
||||
config.option<ConfigOptionStrings>("extruder_nozzle_stats", true)->values = {"Standard#1", "Standard#1|High Flow#2"};
|
||||
config.option<ConfigOptionEnumsGeneric>("extruder_type", true)->values = {etDirectDrive, etDirectDrive};
|
||||
config.option<ConfigOptionEnumsGeneric>("nozzle_volume_type", true)->values = {nvtStandard, nvtHybrid};
|
||||
config.option<ConfigOptionStrings>("extruder_variant_list", true)->values = {"Direct Drive Standard,Direct Drive High Flow",
|
||||
"Direct Drive Standard,Direct Drive High Flow"};
|
||||
config.option<ConfigOptionInts>("print_extruder_id", true)->values = {1, 1, 2, 2};
|
||||
config.option<ConfigOptionStrings>("print_extruder_variant", true)->values = {"Direct Drive Standard", "Direct Drive High Flow",
|
||||
"Direct Drive Standard", "Direct Drive High Flow"};
|
||||
// Three filaments: 0 -> extruder 1 (Std), 1 -> extruder 2, migrating Standard -> High Flow
|
||||
// between layers, 2 -> extruder 2 (Std) — ordered AFTER the migrator.
|
||||
config.option<ConfigOptionStrings>("filament_type", true)->values = {"PLA", "PLA", "PLA"};
|
||||
config.option<ConfigOptionFloats>("filament_diameter", true)->values = {1.75, 1.75, 1.75};
|
||||
config.option<ConfigOptionStrings>("filament_colour", true)->values = {"#FF0000", "#00FF00", "#0000FF"};
|
||||
config.option<ConfigOptionInts>("filament_map", true)->values = {1, 2, 2};
|
||||
config.option<ConfigOptionInts>("filament_volume_map", true)->values = {(int) nvtStandard, (int) nvtStandard, (int) nvtStandard};
|
||||
config.option<ConfigOptionInts>("filament_self_index", true)->values = {1, 1, 2, 2, 3, 3};
|
||||
config.option<ConfigOptionStrings>("filament_extruder_variant", true)->values = {"Direct Drive Standard", "Direct Drive High Flow",
|
||||
"Direct Drive Standard", "Direct Drive High Flow",
|
||||
"Direct Drive Standard", "Direct Drive High Flow"};
|
||||
config.option<ConfigOptionInts>("nozzle_temperature", true)->values = {200, 210, 220, 230, 240, 250};
|
||||
config.option<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = {0.5, 0.5, 0.7, 0.9, 1.4, 1.4};
|
||||
config.option<ConfigOptionFloats>("retraction_length", true)->values = {0.8, 0.9, 1.0, 1.1};
|
||||
|
||||
Model model;
|
||||
model.add_object("cube", "", make_cube(20, 20, 20))->add_instance();
|
||||
|
||||
Print print;
|
||||
print.apply(model, config);
|
||||
|
||||
std::vector<NozzleInfo> nozzle_list;
|
||||
{
|
||||
NozzleInfo n;
|
||||
n.diameter = "0.4";
|
||||
n.volume_type = nvtStandard; n.extruder_id = 0; n.group_id = 0; nozzle_list.push_back(n);
|
||||
n.volume_type = nvtStandard; n.extruder_id = 1; n.group_id = 1; nozzle_list.push_back(n);
|
||||
n.volume_type = nvtHighFlow; n.extruder_id = 1; n.group_id = 2; nozzle_list.push_back(n);
|
||||
}
|
||||
// Filament 1: Standard nozzle on layer 0, High Flow nozzle on layer 1; filament 2 stays Standard.
|
||||
std::vector<std::vector<int>> layer_maps = {{0, 1, 1}, {0, 2, 1}};
|
||||
std::vector<std::vector<unsigned int>> layer_seqs = {{0, 1, 2}, {0, 1, 2}};
|
||||
auto group = LayeredNozzleGroupResult::create(layer_maps, nozzle_list, {0, 1, 2}, layer_seqs);
|
||||
REQUIRE(group.has_value());
|
||||
REQUIRE(group->is_support_dynamic_nozzle_map());
|
||||
print.set_nozzle_group_result(std::make_shared<LayeredNozzleGroupResult>(*group));
|
||||
|
||||
print.update_to_config_by_nozzle_group_result(*group);
|
||||
|
||||
// Filament 1 holds columns 1-2; filament 2's values shift to column 3.
|
||||
REQUIRE(print.config().filament_self_index.values == std::vector<int>{1, 2, 2, 3});
|
||||
REQUIRE(print.config().nozzle_temperature.values == std::vector<int>{200, 220, 230, 240});
|
||||
// The migrator resolves per layer to its two columns.
|
||||
REQUIRE(print.get_filament_config_indx(1, 0) == 1);
|
||||
REQUIRE(print.get_filament_config_indx(1, 1) == 2);
|
||||
// The filament after it no longer lives at its raw index on any layer.
|
||||
REQUIRE(print.get_filament_config_indx(2, 0) == 3);
|
||||
REQUIRE(print.get_filament_config_indx(2, 1) == 3);
|
||||
// Merged machine override: filament 2's value sits in the shifted column, while a raw
|
||||
// get_at(2) would read the migrator's High Flow column.
|
||||
const std::vector<double> merged = print.config().retraction_length.values;
|
||||
REQUIRE(merged.size() == 4);
|
||||
REQUIRE_THAT(merged[3], Catch::Matchers::WithinAbs(1.4, 1e-9));
|
||||
REQUIRE_THAT(merged[2], Catch::Matchers::WithinAbs(0.9, 1e-9));
|
||||
}
|
||||
|
||||
TEST_CASE("Selector slicing keeps the result valid across re-apply", "[Print][H2C][Dynamic]")
|
||||
{
|
||||
// The dynamic counterpart of the static re-apply test above: a full process() run through
|
||||
// the selector branch (whatever grouping it settles on) must leave the config in a state
|
||||
// the next apply reproduces without invalidating the freshly sliced result.
|
||||
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
||||
config.option<ConfigOptionFloats>("nozzle_diameter", true)->values = {0.4, 0.4};
|
||||
config.option<ConfigOptionStrings>("extruder_nozzle_stats", true)->values = {"Standard#1", "Standard#1|High Flow#2"};
|
||||
config.option<ConfigOptionEnumsGeneric>("extruder_type", true)->values = {etDirectDrive, etDirectDrive};
|
||||
config.option<ConfigOptionEnumsGeneric>("nozzle_volume_type", true)->values = {nvtStandard, nvtHybrid};
|
||||
config.option<ConfigOptionStrings>("extruder_variant_list", true)->values = {"Direct Drive Standard,Direct Drive High Flow",
|
||||
"Direct Drive Standard,Direct Drive High Flow"};
|
||||
config.option<ConfigOptionInts>("print_extruder_id", true)->values = {1, 1, 2, 2};
|
||||
config.option<ConfigOptionStrings>("print_extruder_variant", true)->values = {"Direct Drive Standard", "Direct Drive High Flow",
|
||||
"Direct Drive Standard", "Direct Drive High Flow"};
|
||||
config.option<ConfigOptionFloats>("filament_diameter", true)->values = {1.75, 1.75, 1.75};
|
||||
config.option<ConfigOptionStrings>("filament_colour", true)->values = {"#FF0000", "#00FF00", "#0000FF"};
|
||||
config.option<ConfigOptionInts>("filament_map", true)->values = {1, 2, 2};
|
||||
config.option<ConfigOptionInts>("filament_volume_map", true)->values = {(int) nvtStandard, (int) nvtStandard, (int) nvtHighFlow};
|
||||
config.set_key_value("enable_filament_dynamic_map", new ConfigOptionBool(true));
|
||||
config.option<ConfigOptionEnum<FilamentMapMode>>("filament_map_mode", true)->value = FilamentMapMode::fmmAutoForFlush;
|
||||
|
||||
Model model;
|
||||
ModelObject *object = model.add_object("cube", "", make_cube(20, 20, 20));
|
||||
object->add_instance()->set_offset(Vec3d(100., 100., 0.));
|
||||
|
||||
Print print;
|
||||
print.apply(model, config);
|
||||
print.process();
|
||||
REQUIRE(print.is_step_done(psSlicingFinished));
|
||||
|
||||
auto status = print.apply(model, config);
|
||||
REQUIRE(status != PrintBase::APPLY_STATUS_INVALIDATED);
|
||||
REQUIRE(print.is_step_done(psSlicingFinished));
|
||||
}
|
||||
54
tests/libslic3r/test_utils.cpp
Normal file
54
tests/libslic3r/test_utils.cpp
Normal file
@@ -0,0 +1,54 @@
|
||||
#include <catch2/catch_all.hpp>
|
||||
|
||||
#include "libslic3r/Utils.hpp"
|
||||
|
||||
#ifndef _WIN32
|
||||
#include <unistd.h> // getuid
|
||||
#endif
|
||||
|
||||
using namespace Slic3r;
|
||||
|
||||
TEST_CASE("per_user_temp_dir composes a per-user temp root", "[utils]") {
|
||||
const std::string base = "/tmp";
|
||||
|
||||
SECTION("an empty id returns base unchanged") {
|
||||
REQUIRE(per_user_temp_dir(base, "") == base);
|
||||
}
|
||||
SECTION("a non-empty id is appended at the top level") {
|
||||
REQUIRE(per_user_temp_dir(base, "1000") == base + "/orcaslicer_1000");
|
||||
}
|
||||
SECTION("distinct ids produce distinct roots") {
|
||||
REQUIRE(per_user_temp_dir(base, "1000") != per_user_temp_dir(base, "1001"));
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("per_user_temp_id follows the platform contract", "[utils]") {
|
||||
const std::string id = per_user_temp_id();
|
||||
|
||||
SECTION("stable across calls") {
|
||||
REQUIRE(per_user_temp_id() == id);
|
||||
}
|
||||
#ifdef _WIN32
|
||||
SECTION("empty on Windows (its temp dir is already per-user)") {
|
||||
REQUIRE(id.empty());
|
||||
}
|
||||
#else
|
||||
SECTION("the current uid on Linux/macOS") {
|
||||
REQUIRE_FALSE(id.empty());
|
||||
REQUIRE(id == std::to_string(static_cast<unsigned long>(::getuid())));
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
// The end-to-end contract callers depend on: the temp root is left alone on
|
||||
// Windows and isolated per user on Linux/macOS.
|
||||
TEST_CASE("per-user temp root is unchanged on Windows, isolated elsewhere", "[utils]") {
|
||||
const std::string base = "/tmp";
|
||||
const std::string root = per_user_temp_dir(base, per_user_temp_id());
|
||||
#ifdef _WIN32
|
||||
REQUIRE(root == base);
|
||||
#else
|
||||
REQUIRE(root != base);
|
||||
REQUIRE_THAT(root, Catch::Matchers::StartsWith(base + "/orcaslicer_"));
|
||||
#endif
|
||||
}
|
||||
@@ -4,8 +4,8 @@ add_executable(${_TEST_NAME}_tests
|
||||
test_action_source.cpp
|
||||
test_plugin_host_api.cpp
|
||||
test_plugin_callback_list.cpp
|
||||
test_plugin_capability_identifier.cpp
|
||||
test_plugin_install.cpp
|
||||
test_plugin_lifecycle.cpp
|
||||
test_slicing_pipeline_bindings.cpp
|
||||
test_plugin_sort.cpp
|
||||
test_speed_dial_action_id.cpp
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
#include <catch2/catch_all.hpp>
|
||||
|
||||
#include <slic3r/plugin/PluginLoader.hpp>
|
||||
|
||||
#include <unordered_map>
|
||||
|
||||
using Slic3r::PluginCapabilityIdentifier;
|
||||
using Slic3r::PluginCapabilityType;
|
||||
|
||||
TEST_CASE("PluginCapabilityIdentifier equality includes plugin_key", "[plugin][identifier]") {
|
||||
PluginCapabilityIdentifier a{PluginCapabilityType::SlicingPipeline, "Cleanup", "a.py"};
|
||||
PluginCapabilityIdentifier b{PluginCapabilityType::SlicingPipeline, "Cleanup", "b.py"};
|
||||
PluginCapabilityIdentifier a2{PluginCapabilityType::SlicingPipeline, "Cleanup", "a.py"};
|
||||
|
||||
CHECK(a == a2);
|
||||
CHECK_FALSE(a == b); // same (type,name), different plugin_key -> distinct
|
||||
}
|
||||
|
||||
TEST_CASE("PluginCapabilityIdentifier is usable as a hash-map key", "[plugin][identifier]") {
|
||||
std::unordered_map<PluginCapabilityIdentifier, int> m;
|
||||
m[{PluginCapabilityType::SlicingPipeline, "Cleanup", "a.py"}] = 1;
|
||||
m[{PluginCapabilityType::SlicingPipeline, "Cleanup", "b.py"}] = 2; // no collision
|
||||
CHECK(m.size() == 2);
|
||||
CHECK(m.at({PluginCapabilityType::SlicingPipeline, "Cleanup", "a.py"}) == 1);
|
||||
}
|
||||
@@ -57,15 +57,14 @@ TEST_CASE("install_plugin rejects a cloud UUID containing path traversal", "[Plu
|
||||
// Package contents are irrelevant: the UUID is validated before metadata is read.
|
||||
const fs::path py = write_py_file(data_dir_guard.dir / "src", "evil.py", "print('hi')\n");
|
||||
|
||||
PluginLoader loader;
|
||||
loader.set_cloud_user_id("test-user");
|
||||
|
||||
|
||||
PluginDescriptor descriptor;
|
||||
// is_cloud_plugin() -> true; cloud_uuid() -> the traversal string.
|
||||
descriptor.cloud = CloudPluginState{"../../escape", true, false, false, false};
|
||||
|
||||
std::string error;
|
||||
const bool installed = loader.install_plugin(py, descriptor, error);
|
||||
const bool installed = plugin_loader::install_plugin(py, /*cloud_user_id=*/"test-user", descriptor, error);
|
||||
|
||||
REQUIRE_FALSE(installed);
|
||||
CHECK_THAT(error, Catch::Matchers::ContainsSubstring("valid identifier"));
|
||||
@@ -78,11 +77,10 @@ TEST_CASE("install_plugin rejects a side-loaded .py with no PEP 723 metadata", "
|
||||
// No `# /// script` block -> name stays empty and type stays Unknown.
|
||||
const fs::path py = write_py_file(data_dir_guard.dir / "src", "nameless.py", "print('no metadata here')\n");
|
||||
|
||||
PluginLoader loader; // non-cloud: no cloud user id, descriptor has no cloud state
|
||||
|
||||
PluginDescriptor descriptor;
|
||||
std::string error;
|
||||
const bool installed = loader.install_plugin(py, descriptor, error);
|
||||
const bool installed = plugin_loader::install_plugin(py, /*cloud_user_id=*/"", descriptor, error);
|
||||
|
||||
REQUIRE_FALSE(installed);
|
||||
CHECK_THAT(error, Catch::Matchers::ContainsSubstring("PEP 723"));
|
||||
@@ -103,11 +101,10 @@ TEST_CASE("install_plugin accepts a side-loaded .py with complete PEP 723 metada
|
||||
"print('ok')\n";
|
||||
const fs::path py = write_py_file(data_dir_guard.dir / "src", "good.py", contents);
|
||||
|
||||
PluginLoader loader; // non-cloud
|
||||
|
||||
PluginDescriptor descriptor;
|
||||
std::string error;
|
||||
const bool installed = loader.install_plugin(py, descriptor, error);
|
||||
const bool installed = plugin_loader::install_plugin(py, /*cloud_user_id=*/"", descriptor, error);
|
||||
|
||||
// Positive control: a complete side-loaded .py must still install (guards against over-rejection).
|
||||
REQUIRE(installed);
|
||||
@@ -168,10 +165,9 @@ TEST_CASE("install_plugin parses [tool.orcaslicer.plugin.settings] into descript
|
||||
"print('ok')\n";
|
||||
const fs::path py = write_py_file(data_dir_guard.dir / "src", "settings.py", contents);
|
||||
|
||||
PluginLoader loader; // non-cloud
|
||||
PluginDescriptor descriptor;
|
||||
std::string error;
|
||||
const bool installed = loader.install_plugin(py, descriptor, error);
|
||||
const bool installed = plugin_loader::install_plugin(py, /*cloud_user_id=*/"", descriptor, error);
|
||||
|
||||
REQUIRE(installed);
|
||||
CHECK(error.empty());
|
||||
|
||||
760
tests/slic3rutils/test_plugin_lifecycle.cpp
Normal file
760
tests/slic3rutils/test_plugin_lifecycle.cpp
Normal file
@@ -0,0 +1,760 @@
|
||||
#include <catch2/catch_all.hpp>
|
||||
|
||||
#include <libslic3r/Utils.hpp>
|
||||
#include <slic3r/plugin/PluginDescriptor.hpp>
|
||||
#include <slic3r/plugin/PluginManager.hpp>
|
||||
#include <slic3r/plugin/PythonFileUtils.hpp>
|
||||
#include <slic3r/plugin/PythonInterpreter.hpp>
|
||||
|
||||
#include <boost/filesystem.hpp>
|
||||
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <fstream>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
using namespace Slic3r;
|
||||
namespace fs = boost::filesystem;
|
||||
|
||||
// Plugin load/unload lifecycle: discovery -> load -> capability materialization -> enable/disable
|
||||
// -> unload.
|
||||
//
|
||||
// Each Catch2 test case runs in its own process (catch_discover_tests), so the PluginManager and
|
||||
// interpreter singletons are brought up at most once per test.
|
||||
|
||||
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
|
||||
// destructor, mirrors what the app does (GUI_App finalizes it before exit). Left to static
|
||||
// destruction, shutdown()'s logging runs after boost::log has torn down its thread-local storage
|
||||
// and throws, aborting the process after the tests have already passed.
|
||||
//
|
||||
// Declare this FIRST in a test so it is destroyed last.
|
||||
struct ScopedPluginManager
|
||||
{
|
||||
bool initialized = false;
|
||||
|
||||
ScopedPluginManager() { initialized = PluginManager::instance().initialize(); }
|
||||
~ScopedPluginManager()
|
||||
{
|
||||
PluginManager::instance().shutdown();
|
||||
PythonInterpreter::instance().shutdown();
|
||||
}
|
||||
};
|
||||
|
||||
// A minimal script plugin exposing exactly one capability, "Echo".
|
||||
const char* const ECHO_PLUGIN_SOURCE = R"PY(# /// script
|
||||
# requires-python = ">=3.12"
|
||||
#
|
||||
# [tool.orcaslicer.plugin]
|
||||
# name = "Echo Plugin"
|
||||
# description = "Plugin lifecycle characterization fixture"
|
||||
# author = "OrcaSlicer"
|
||||
# version = "1.0"
|
||||
# type = "script"
|
||||
# ///
|
||||
import orca
|
||||
|
||||
class Echo(orca.script.ScriptPluginCapabilityBase):
|
||||
def get_name(self):
|
||||
return "Echo"
|
||||
|
||||
def execute(self, ctx):
|
||||
return orca.ExecutionResult.success()
|
||||
|
||||
@orca.plugin
|
||||
class EchoPackage(orca.base):
|
||||
def register_capabilities(self):
|
||||
orca.register_capability(Echo)
|
||||
)PY";
|
||||
|
||||
// Writes {data_dir}/orca_plugins/<stem>/<stem>.py and returns the plugin directory.
|
||||
fs::path write_plugin(const ScopedDataDir& data_dir_guard, const std::string& stem, const std::string& source)
|
||||
{
|
||||
const fs::path plugin_dir = data_dir_guard.plugins_dir() / stem;
|
||||
fs::create_directories(plugin_dir);
|
||||
|
||||
std::ofstream out((plugin_dir / (stem + ".py")).string(), std::ios::binary);
|
||||
out << source;
|
||||
out.close();
|
||||
|
||||
return plugin_dir;
|
||||
}
|
||||
|
||||
// Loads a plugin and blocks until the detached worker thread is done with it.
|
||||
bool load_and_wait(PluginManager& manager,
|
||||
const std::string& plugin_key,
|
||||
std::string& error,
|
||||
std::vector<std::string> capabilities_to_enable = {})
|
||||
{
|
||||
manager.load_plugin(plugin_key, /*skip_deps=*/true, std::move(capabilities_to_enable));
|
||||
return manager.wait_for_plugin_load(plugin_key, std::chrono::seconds(120), error);
|
||||
}
|
||||
|
||||
std::shared_ptr<PluginCapabilityInterface> find_capability(PluginManager& manager, const std::string& plugin_key,
|
||||
const std::string& name)
|
||||
{
|
||||
return manager.get_plugin_capability(plugin_key, name, PluginCapabilityType::Unknown, /*only_enabled=*/false);
|
||||
}
|
||||
|
||||
std::vector<std::shared_ptr<PluginCapabilityInterface>> capabilities_of(PluginManager& manager, const std::string& plugin_key)
|
||||
{
|
||||
return manager.get_plugin_capabilities(plugin_key, PluginCapabilityType::Unknown, /*only_enabled=*/false);
|
||||
}
|
||||
|
||||
PluginDescriptor descriptor_of(PluginManager& manager, const std::string& plugin_key)
|
||||
{
|
||||
PluginDescriptor descriptor;
|
||||
manager.try_get_plugin_descriptor(plugin_key, descriptor);
|
||||
return descriptor;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("A discovered script plugin loads and materializes its capability", "[PluginLifecycle][Python]")
|
||||
{
|
||||
ScopedPluginManager plugin_system;
|
||||
if (!plugin_system.initialized)
|
||||
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
||||
|
||||
ScopedDataDir data_dir_guard("lifecycle-load");
|
||||
write_plugin(data_dir_guard, "Echo_Plugin", ECHO_PLUGIN_SOURCE);
|
||||
|
||||
PluginManager& manager = PluginManager::instance();
|
||||
manager.discover_plugins(/*async=*/false, /*clear=*/true);
|
||||
|
||||
PluginDescriptor descriptor;
|
||||
REQUIRE(manager.try_get_valid_plugin_descriptor("Echo_Plugin", descriptor));
|
||||
CHECK(descriptor.name == "Echo Plugin");
|
||||
|
||||
std::string error;
|
||||
REQUIRE(load_and_wait(manager, "Echo_Plugin", error));
|
||||
INFO("load error: " << error);
|
||||
CHECK(error.empty());
|
||||
|
||||
CHECK(manager.is_plugin_loaded("Echo_Plugin"));
|
||||
CHECK(manager.get_plugin_load_error("Echo_Plugin").empty());
|
||||
|
||||
const auto capabilities = capabilities_of(manager, "Echo_Plugin");
|
||||
REQUIRE(capabilities.size() == 1);
|
||||
|
||||
const auto& echo = capabilities.front();
|
||||
CHECK(echo->name() == "Echo");
|
||||
CHECK(echo->type() == PluginCapabilityType::Script);
|
||||
CHECK(echo->is_enabled());
|
||||
CHECK(echo->audit_plugin_key() == "Echo_Plugin");
|
||||
|
||||
CHECK(manager.get_plugin_capability("Echo_Plugin", "Echo", PluginCapabilityType::Script) == echo);
|
||||
|
||||
manager.unload_plugin("Echo_Plugin");
|
||||
}
|
||||
|
||||
TEST_CASE("Plugin manager can initialize again after shutdown", "[PluginLifecycle][Python]")
|
||||
{
|
||||
ScopedPluginManager plugin_system;
|
||||
if (!plugin_system.initialized)
|
||||
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
||||
|
||||
ScopedDataDir data_dir_guard("lifecycle-reinitialize");
|
||||
write_plugin(data_dir_guard, "Echo_Plugin", ECHO_PLUGIN_SOURCE);
|
||||
|
||||
PluginManager& manager = PluginManager::instance();
|
||||
manager.discover_plugins(/*async=*/false, /*clear=*/true);
|
||||
manager.shutdown();
|
||||
|
||||
REQUIRE(manager.initialize());
|
||||
manager.discover_plugins(/*async=*/false, /*clear=*/true);
|
||||
|
||||
std::string error;
|
||||
REQUIRE(load_and_wait(manager, "Echo_Plugin", error));
|
||||
CHECK(manager.is_plugin_loaded("Echo_Plugin"));
|
||||
|
||||
manager.unload_plugin("Echo_Plugin");
|
||||
}
|
||||
|
||||
TEST_CASE("Duplicate discovered plugin keys are reported", "[PluginLifecycle][Python]")
|
||||
{
|
||||
ScopedPluginManager plugin_system;
|
||||
if (!plugin_system.initialized)
|
||||
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
||||
|
||||
ScopedDataDir data_dir_guard("lifecycle-duplicate-key");
|
||||
for (const char* directory_name : {"first", "second"}) {
|
||||
const fs::path plugin_dir = data_dir_guard.plugins_dir() / directory_name;
|
||||
fs::create_directories(plugin_dir);
|
||||
std::ofstream out((plugin_dir / "Shared.py").string(), std::ios::binary);
|
||||
out << ECHO_PLUGIN_SOURCE;
|
||||
}
|
||||
|
||||
PluginManager& manager = PluginManager::instance();
|
||||
manager.discover_plugins(/*async=*/false, /*clear=*/true);
|
||||
|
||||
PluginDescriptor descriptor;
|
||||
REQUIRE(manager.try_get_plugin_descriptor("Shared", descriptor));
|
||||
CHECK(descriptor.has_error());
|
||||
CHECK(descriptor.normalized_error().find("Duplicate plugin key") != std::string::npos);
|
||||
}
|
||||
|
||||
TEST_CASE("Unloading a plugin drops the package and its capabilities", "[PluginLifecycle][Python]")
|
||||
{
|
||||
ScopedPluginManager plugin_system;
|
||||
if (!plugin_system.initialized)
|
||||
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
||||
|
||||
ScopedDataDir data_dir_guard("lifecycle-unload");
|
||||
write_plugin(data_dir_guard, "Echo_Plugin", ECHO_PLUGIN_SOURCE);
|
||||
|
||||
PluginManager& manager = PluginManager::instance();
|
||||
manager.discover_plugins(/*async=*/false, /*clear=*/true);
|
||||
|
||||
std::string error;
|
||||
REQUIRE(load_and_wait(manager, "Echo_Plugin", error));
|
||||
REQUIRE(manager.is_plugin_loaded("Echo_Plugin"));
|
||||
|
||||
CHECK(manager.unload_plugin("Echo_Plugin"));
|
||||
|
||||
CHECK_FALSE(manager.is_plugin_loaded("Echo_Plugin"));
|
||||
CHECK(manager.get_plugin_capabilities("Echo_Plugin").empty());
|
||||
CHECK(manager.get_plugin_capability("Echo_Plugin", "Echo", PluginCapabilityType::Script) == nullptr);
|
||||
|
||||
// The package stays discovered, but nothing capability-shaped survives the unload.
|
||||
const PluginDescriptor descriptor = descriptor_of(manager, "Echo_Plugin");
|
||||
CHECK(descriptor.plugin_key == "Echo_Plugin");
|
||||
CHECK(capabilities_of(manager, "Echo_Plugin").empty());
|
||||
}
|
||||
|
||||
TEST_CASE("Python module release removes package submodules and owned sys.path", "[PluginLifecycle][Python]")
|
||||
{
|
||||
ScopedPluginManager plugin_system;
|
||||
if (!plugin_system.initialized)
|
||||
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
||||
|
||||
ScopedDataDir data_dir_guard("module-release");
|
||||
const fs::path package_root = data_dir_guard.dir / "reload_package";
|
||||
fs::create_directories(package_root);
|
||||
|
||||
auto write_package = [&](const std::string& value) {
|
||||
std::ofstream init((package_root / "__init__.py").string());
|
||||
init << "import reload_helper\nfrom . import sub\nVALUE = sub.VALUE\n";
|
||||
std::ofstream sub((package_root / "sub.py").string());
|
||||
sub << "VALUE = " << value << "\n";
|
||||
std::ofstream helper((package_root.parent_path() / "reload_helper.py").string());
|
||||
helper << "VALUE = 'helper'\n";
|
||||
};
|
||||
|
||||
write_package("'old'");
|
||||
|
||||
PythonInterpreter& interpreter = PythonInterpreter::instance();
|
||||
std::vector<std::string> paths;
|
||||
std::vector<std::string> modules;
|
||||
std::string error;
|
||||
PyObject* module = interpreter.load_module_from_directory(
|
||||
package_root.parent_path().string(), "reload_package", error, &paths, &modules);
|
||||
REQUIRE(module != nullptr);
|
||||
INFO("module load error: " << error);
|
||||
REQUIRE(error.empty());
|
||||
REQUIRE(paths.size() == 1);
|
||||
|
||||
{
|
||||
PythonGILState gil;
|
||||
REQUIRE(gil);
|
||||
PyObject* modules = PyImport_GetModuleDict();
|
||||
REQUIRE(modules != nullptr);
|
||||
CHECK(PyDict_GetItemString(modules, "reload_package") != nullptr);
|
||||
CHECK(PyDict_GetItemString(modules, "reload_package.sub") != nullptr);
|
||||
CHECK(PyDict_GetItemString(modules, "reload_helper") != nullptr);
|
||||
}
|
||||
|
||||
Plugin loaded;
|
||||
loaded.module = module;
|
||||
loaded.module_name = "reload_package";
|
||||
loaded.plugin_sys_paths = paths;
|
||||
loaded.plugin_modules = modules;
|
||||
loaded.release_module();
|
||||
|
||||
{
|
||||
PythonGILState gil;
|
||||
REQUIRE(gil);
|
||||
PyObject* modules = PyImport_GetModuleDict();
|
||||
REQUIRE(modules != nullptr);
|
||||
CHECK(PyDict_GetItemString(modules, "reload_package") == nullptr);
|
||||
CHECK(PyDict_GetItemString(modules, "reload_package.sub") == nullptr);
|
||||
CHECK(PyDict_GetItemString(modules, "reload_helper") == nullptr);
|
||||
|
||||
PyObject* sys_path = PySys_GetObject("path");
|
||||
REQUIRE(sys_path != nullptr);
|
||||
PyObjectPtr path(PyUnicode_DecodeFSDefault(paths.front().c_str()));
|
||||
REQUIRE(path);
|
||||
CHECK(PySequence_Contains(sys_path, path.get()) == 0);
|
||||
}
|
||||
|
||||
// Ensure the next import executes the new submodule rather than reusing a stale package child.
|
||||
write_package("'new'");
|
||||
boost::system::error_code ec;
|
||||
fs::remove_all(package_root / "__pycache__", ec);
|
||||
|
||||
paths.clear();
|
||||
modules.clear();
|
||||
module = interpreter.load_module_from_directory(
|
||||
package_root.parent_path().string(), "reload_package", error, &paths, &modules);
|
||||
REQUIRE(module != nullptr);
|
||||
REQUIRE(error.empty());
|
||||
|
||||
{
|
||||
PythonGILState gil;
|
||||
REQUIRE(gil);
|
||||
PyObjectPtr value(PyObject_GetAttrString(module, "VALUE"));
|
||||
REQUIRE(value);
|
||||
CHECK(std::string(PyUnicode_AsUTF8(value.get())) == "new");
|
||||
}
|
||||
|
||||
loaded.module = module;
|
||||
loaded.module_name = "reload_package";
|
||||
loaded.plugin_sys_paths = paths;
|
||||
loaded.plugin_modules = modules;
|
||||
loaded.release_module();
|
||||
}
|
||||
|
||||
TEST_CASE("A capability disabled in the sidecar loads disabled", "[PluginLifecycle][Python]")
|
||||
{
|
||||
ScopedPluginManager plugin_system;
|
||||
if (!plugin_system.initialized)
|
||||
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
||||
|
||||
ScopedDataDir data_dir_guard("lifecycle-disabled");
|
||||
const fs::path plugin_dir = write_plugin(data_dir_guard, "Echo_Plugin", ECHO_PLUGIN_SOURCE);
|
||||
|
||||
// Pre-seed the sidecar with the capability disabled, as a previous session would have.
|
||||
PluginInstallState state;
|
||||
state.installed_from = "local";
|
||||
state.installed_version = "1.0";
|
||||
state.plugin_name = "Echo Plugin";
|
||||
state.enabled = true;
|
||||
state.capabilities = {{"Echo", false}};
|
||||
REQUIRE(write_install_state(plugin_dir, state));
|
||||
|
||||
PluginManager& manager = PluginManager::instance();
|
||||
manager.discover_plugins(/*async=*/false, /*clear=*/true);
|
||||
|
||||
std::string error;
|
||||
REQUIRE(load_and_wait(manager, "Echo_Plugin", error));
|
||||
REQUIRE(manager.is_plugin_loaded("Echo_Plugin"));
|
||||
|
||||
// The capability still materializes — it is loaded, but logically disabled, so consumers skip it.
|
||||
const auto echo = find_capability(manager, "Echo_Plugin", "Echo");
|
||||
REQUIRE(echo != nullptr);
|
||||
CHECK_FALSE(echo->is_enabled());
|
||||
|
||||
CHECK(manager.get_plugin_capabilities("Echo_Plugin", PluginCapabilityType::Unknown, /*only_enabled=*/true).empty());
|
||||
CHECK(manager.get_plugin_capabilities("Echo_Plugin", PluginCapabilityType::Unknown, /*only_enabled=*/false).size() == 1);
|
||||
|
||||
// An empty load request must preserve the persisted disabled state even when the package is
|
||||
// already loaded.
|
||||
std::string no_request_error;
|
||||
REQUIRE(load_and_wait(manager, "Echo_Plugin", no_request_error));
|
||||
CHECK_FALSE(find_capability(manager, "Echo_Plugin", "Echo")->is_enabled());
|
||||
|
||||
manager.unload_plugin("Echo_Plugin");
|
||||
}
|
||||
|
||||
TEST_CASE("Disabling a capability round-trips through the sidecar and survives a reload", "[PluginLifecycle][Python]")
|
||||
{
|
||||
ScopedPluginManager plugin_system;
|
||||
if (!plugin_system.initialized)
|
||||
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
||||
|
||||
ScopedDataDir data_dir_guard("lifecycle-roundtrip");
|
||||
const fs::path plugin_dir = write_plugin(data_dir_guard, "Echo_Plugin", ECHO_PLUGIN_SOURCE);
|
||||
|
||||
PluginManager& manager = PluginManager::instance();
|
||||
manager.discover_plugins(/*async=*/false, /*clear=*/true);
|
||||
|
||||
std::string error;
|
||||
REQUIRE(load_and_wait(manager, "Echo_Plugin", error));
|
||||
REQUIRE(find_capability(manager, "Echo_Plugin", "Echo")->is_enabled());
|
||||
|
||||
// Disabling writes the choice through to .install_state.json.
|
||||
manager.set_capability_enabled("Echo_Plugin", "Echo", false);
|
||||
CHECK_FALSE(find_capability(manager, "Echo_Plugin", "Echo")->is_enabled());
|
||||
|
||||
PluginInstallState persisted;
|
||||
REQUIRE(read_install_state(plugin_dir, persisted));
|
||||
REQUIRE(persisted.capabilities.size() == 1);
|
||||
CHECK(persisted.capabilities.front().first == "Echo");
|
||||
CHECK_FALSE(persisted.capabilities.front().second);
|
||||
|
||||
// Unload and reload: the user's choice must survive.
|
||||
REQUIRE(manager.unload_plugin("Echo_Plugin"));
|
||||
|
||||
std::string reload_error;
|
||||
REQUIRE(load_and_wait(manager, "Echo_Plugin", reload_error));
|
||||
|
||||
const auto echo = find_capability(manager, "Echo_Plugin", "Echo");
|
||||
REQUIRE(echo != nullptr);
|
||||
CHECK_FALSE(echo->is_enabled());
|
||||
|
||||
manager.unload_plugin("Echo_Plugin");
|
||||
}
|
||||
|
||||
TEST_CASE("A capability disabled after load stays disabled when rediscovered and reloaded", "[PluginLifecycle][Python]")
|
||||
{
|
||||
ScopedPluginManager plugin_system;
|
||||
if (!plugin_system.initialized)
|
||||
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
||||
|
||||
ScopedDataDir data_dir_guard("lifecycle-reload-live");
|
||||
write_plugin(data_dir_guard, "Echo_Plugin", ECHO_PLUGIN_SOURCE);
|
||||
|
||||
PluginManager& manager = PluginManager::instance();
|
||||
manager.discover_plugins(/*async=*/false, /*clear=*/true);
|
||||
|
||||
std::string error;
|
||||
REQUIRE(load_and_wait(manager, "Echo_Plugin", error));
|
||||
|
||||
manager.set_capability_enabled("Echo_Plugin", "Echo", false);
|
||||
REQUIRE(manager.unload_plugin("Echo_Plugin"));
|
||||
|
||||
// Rediscover, as the app does when a plugin is toggled off and back on. The enable flags the
|
||||
// loader seeds from must come from the sidecar just written, not from a stale cache.
|
||||
manager.discover_plugins(/*async=*/false, /*clear=*/false);
|
||||
|
||||
std::string reload_error;
|
||||
REQUIRE(load_and_wait(manager, "Echo_Plugin", reload_error));
|
||||
|
||||
const auto echo = find_capability(manager, "Echo_Plugin", "Echo");
|
||||
REQUIRE(echo != nullptr);
|
||||
CHECK_FALSE(echo->is_enabled());
|
||||
|
||||
manager.unload_plugin("Echo_Plugin");
|
||||
}
|
||||
|
||||
TEST_CASE("Re-enabling a disabled capability writes the sidecar back", "[PluginLifecycle][Python]")
|
||||
{
|
||||
ScopedPluginManager plugin_system;
|
||||
if (!plugin_system.initialized)
|
||||
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
||||
|
||||
ScopedDataDir data_dir_guard("lifecycle-reenable");
|
||||
const fs::path plugin_dir = write_plugin(data_dir_guard, "Echo_Plugin", ECHO_PLUGIN_SOURCE);
|
||||
|
||||
PluginInstallState state;
|
||||
state.installed_from = "local";
|
||||
state.plugin_name = "Echo Plugin";
|
||||
state.enabled = true;
|
||||
state.capabilities = {{"Echo", false}};
|
||||
REQUIRE(write_install_state(plugin_dir, state));
|
||||
|
||||
PluginManager& manager = PluginManager::instance();
|
||||
manager.discover_plugins(/*async=*/false, /*clear=*/true);
|
||||
|
||||
std::string error;
|
||||
REQUIRE(load_and_wait(manager, "Echo_Plugin", error));
|
||||
|
||||
// An explicit request overrides the persisted disabled state, including on a fresh load.
|
||||
REQUIRE(manager.unload_plugin("Echo_Plugin"));
|
||||
std::string enable_error;
|
||||
REQUIRE(load_and_wait(manager, "Echo_Plugin", enable_error, {"Echo"}));
|
||||
|
||||
const auto echo = find_capability(manager, "Echo_Plugin", "Echo");
|
||||
REQUIRE(echo != nullptr);
|
||||
CHECK(echo->is_enabled());
|
||||
|
||||
PluginInstallState persisted;
|
||||
REQUIRE(read_install_state(plugin_dir, persisted));
|
||||
REQUIRE(persisted.capabilities.size() == 1);
|
||||
CHECK(persisted.capabilities.front().first == "Echo");
|
||||
CHECK(persisted.capabilities.front().second);
|
||||
|
||||
manager.unload_plugin("Echo_Plugin");
|
||||
}
|
||||
|
||||
TEST_CASE("Overwriting a local plugin unloads its live module", "[PluginLifecycle][Python]")
|
||||
{
|
||||
ScopedPluginManager plugin_system;
|
||||
if (!plugin_system.initialized)
|
||||
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
||||
|
||||
ScopedDataDir data_dir_guard("lifecycle-overwrite");
|
||||
const fs::path package_dir = data_dir_guard.dir / "packages";
|
||||
fs::create_directories(package_dir);
|
||||
const fs::path package = package_dir / "Echo_Plugin.py";
|
||||
{
|
||||
std::ofstream out(package.string(), std::ios::binary);
|
||||
out << ECHO_PLUGIN_SOURCE;
|
||||
}
|
||||
|
||||
PluginManager& manager = PluginManager::instance();
|
||||
std::string error;
|
||||
REQUIRE(manager.install_plugin(package, error));
|
||||
manager.discover_plugins(/*async=*/false, /*clear=*/true);
|
||||
REQUIRE(load_and_wait(manager, "Echo_Plugin", error));
|
||||
REQUIRE(manager.is_plugin_loaded("Echo_Plugin"));
|
||||
|
||||
REQUIRE(manager.install_plugin(package, error));
|
||||
CHECK_FALSE(manager.is_plugin_loaded("Echo_Plugin"));
|
||||
}
|
||||
|
||||
TEST_CASE("capabilities_to_enable selects which capabilities come up enabled", "[PluginLifecycle][Python]")
|
||||
{
|
||||
ScopedPluginManager plugin_system;
|
||||
if (!plugin_system.initialized)
|
||||
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
||||
|
||||
// Two capabilities in one package; only the second is requested.
|
||||
const char* const two_cap_source = R"PY(# /// script
|
||||
# requires-python = ">=3.12"
|
||||
#
|
||||
# [tool.orcaslicer.plugin]
|
||||
# name = "Duo Plugin"
|
||||
# version = "1.0"
|
||||
# type = "script"
|
||||
# ///
|
||||
import orca
|
||||
|
||||
class Alpha(orca.script.ScriptPluginCapabilityBase):
|
||||
def get_name(self):
|
||||
return "Alpha"
|
||||
|
||||
def execute(self, ctx):
|
||||
return orca.ExecutionResult.success()
|
||||
|
||||
class Beta(orca.script.ScriptPluginCapabilityBase):
|
||||
def get_name(self):
|
||||
return "Beta"
|
||||
|
||||
def execute(self, ctx):
|
||||
return orca.ExecutionResult.success()
|
||||
|
||||
@orca.plugin
|
||||
class DuoPackage(orca.base):
|
||||
def register_capabilities(self):
|
||||
orca.register_capability(Alpha)
|
||||
orca.register_capability(Beta)
|
||||
)PY";
|
||||
|
||||
ScopedDataDir data_dir_guard("lifecycle-select");
|
||||
write_plugin(data_dir_guard, "Duo_Plugin", two_cap_source);
|
||||
|
||||
PluginManager& manager = PluginManager::instance();
|
||||
manager.discover_plugins(/*async=*/false, /*clear=*/true);
|
||||
|
||||
std::string error;
|
||||
REQUIRE(load_and_wait(manager, "Duo_Plugin", error, {"Beta"}));
|
||||
|
||||
REQUIRE(capabilities_of(manager, "Duo_Plugin").size() == 2);
|
||||
|
||||
const auto alpha = find_capability(manager, "Duo_Plugin", "Alpha");
|
||||
const auto beta = find_capability(manager, "Duo_Plugin", "Beta");
|
||||
REQUIRE(alpha != nullptr);
|
||||
REQUIRE(beta != nullptr);
|
||||
|
||||
CHECK_FALSE(alpha->is_enabled());
|
||||
CHECK(beta->is_enabled());
|
||||
|
||||
manager.unload_plugin("Duo_Plugin");
|
||||
}
|
||||
|
||||
TEST_CASE("A cancelled load keeps blocking wait_for_all_plugin_loads until the worker exits", "[PluginLifecycle][Python]")
|
||||
{
|
||||
ScopedPluginManager plugin_system;
|
||||
if (!plugin_system.initialized)
|
||||
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
||||
|
||||
// Stalls inside the module import, so the detached load worker is still executing Python while
|
||||
// the test cancels it.
|
||||
const char* const slow_source = R"PY(# /// script
|
||||
# requires-python = ">=3.12"
|
||||
#
|
||||
# [tool.orcaslicer.plugin]
|
||||
# name = "Slow Load Plugin"
|
||||
# version = "1.0"
|
||||
# type = "script"
|
||||
# ///
|
||||
import time
|
||||
|
||||
import orca
|
||||
|
||||
time.sleep(2)
|
||||
|
||||
class Slow(orca.script.ScriptPluginCapabilityBase):
|
||||
def get_name(self):
|
||||
return "Slow"
|
||||
|
||||
def execute(self, ctx):
|
||||
return orca.ExecutionResult.success()
|
||||
|
||||
@orca.plugin
|
||||
class SlowPackage(orca.base):
|
||||
def register_capabilities(self):
|
||||
orca.register_capability(Slow)
|
||||
)PY";
|
||||
|
||||
ScopedDataDir data_dir_guard("lifecycle-cancel");
|
||||
write_plugin(data_dir_guard, "Slow_Load_Plugin", slow_source);
|
||||
|
||||
PluginManager& manager = PluginManager::instance();
|
||||
manager.discover_plugins(/*async=*/false, /*clear=*/true);
|
||||
|
||||
manager.load_plugin("Slow_Load_Plugin", /*skip_deps=*/true);
|
||||
|
||||
// The key is registered before the worker is spawned, so this is not a race.
|
||||
REQUIRE(manager.is_plugin_load_in_progress("Slow_Load_Plugin"));
|
||||
REQUIRE(manager.cancel_plugin_load("Slow_Load_Plugin"));
|
||||
|
||||
// Cancelling must not release the worker's slot. shutdown() unloads everything and GUI_App
|
||||
// finalizes the interpreter as soon as this wait returns, so reporting "no loads in progress"
|
||||
// while the worker is still inside Python is how the app crashes on exit.
|
||||
CHECK(manager.is_plugin_load_in_progress("Slow_Load_Plugin"));
|
||||
CHECK_FALSE(manager.wait_for_all_plugin_loads(std::chrono::milliseconds(0)));
|
||||
|
||||
// The worker releases the slot itself, once it has unwound.
|
||||
CHECK(manager.wait_for_all_plugin_loads(std::chrono::seconds(60)));
|
||||
CHECK_FALSE(manager.is_plugin_load_in_progress("Slow_Load_Plugin"));
|
||||
CHECK_FALSE(manager.is_plugin_loaded("Slow_Load_Plugin"));
|
||||
}
|
||||
|
||||
TEST_CASE("Loading an unknown plugin key records an error instead of crashing", "[PluginLifecycle][Python]")
|
||||
{
|
||||
// discover_plugins() initializes the plugin system (and with it the interpreter), so this
|
||||
// needs the same explicit teardown as the load tests.
|
||||
ScopedPluginManager plugin_system;
|
||||
if (!plugin_system.initialized)
|
||||
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
||||
|
||||
ScopedDataDir data_dir_guard("lifecycle-missing");
|
||||
|
||||
PluginManager& manager = PluginManager::instance();
|
||||
manager.discover_plugins(/*async=*/false, /*clear=*/true);
|
||||
|
||||
// Rejected synchronously: no worker thread is spawned for an unknown key.
|
||||
manager.load_plugin("No_Such_Plugin", /*skip_deps=*/true);
|
||||
|
||||
CHECK_FALSE(manager.is_plugin_loaded("No_Such_Plugin"));
|
||||
CHECK(manager.get_plugin_load_error("No_Such_Plugin") == "Plugin not found: No_Such_Plugin");
|
||||
CHECK(manager.get_plugin_capabilities("No_Such_Plugin").empty());
|
||||
}
|
||||
|
||||
TEST_CASE("The startup auto-load list only contains packages whose sidecar enables them", "[PluginLifecycle][Python]")
|
||||
{
|
||||
ScopedPluginManager plugin_system;
|
||||
if (!plugin_system.initialized)
|
||||
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
||||
|
||||
ScopedDataDir data_dir_guard("lifecycle-autoload");
|
||||
|
||||
// No sidecar at all: never installed through Orca, so it carries no auto-load intent.
|
||||
write_plugin(data_dir_guard, "Bare_Plugin", ECHO_PLUGIN_SOURCE);
|
||||
|
||||
// Sidecar with enabled = true: auto-loads.
|
||||
const fs::path on_dir = write_plugin(data_dir_guard, "Enabled_Plugin", ECHO_PLUGIN_SOURCE);
|
||||
PluginInstallState on_state;
|
||||
on_state.installed_from = "local";
|
||||
on_state.enabled = true;
|
||||
REQUIRE(write_install_state(on_dir, on_state));
|
||||
|
||||
// Sidecar with enabled = false: the user turned it off.
|
||||
const fs::path off_dir = write_plugin(data_dir_guard, "Disabled_Plugin", ECHO_PLUGIN_SOURCE);
|
||||
PluginInstallState off_state;
|
||||
off_state.installed_from = "local";
|
||||
off_state.enabled = false;
|
||||
REQUIRE(write_install_state(off_dir, off_state));
|
||||
|
||||
PluginManager& manager = PluginManager::instance();
|
||||
manager.discover_plugins(/*async=*/false, /*clear=*/true);
|
||||
|
||||
const std::vector<std::string> keys = manager.get_enabled_plugin_keys();
|
||||
|
||||
CHECK(std::find(keys.begin(), keys.end(), "Enabled_Plugin") != keys.end());
|
||||
CHECK(std::find(keys.begin(), keys.end(), "Disabled_Plugin") == keys.end());
|
||||
CHECK(std::find(keys.begin(), keys.end(), "Bare_Plugin") == keys.end());
|
||||
}
|
||||
|
||||
TEST_CASE("Signing out drops every cloud plugin row, installed or not", "[PluginLifecycle][Python]")
|
||||
{
|
||||
ScopedPluginManager plugin_system;
|
||||
if (!plugin_system.initialized)
|
||||
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
||||
|
||||
ScopedDataDir data_dir_guard("lifecycle-signout");
|
||||
|
||||
// A local package, which must survive sign-out.
|
||||
write_plugin(data_dir_guard, "Echo_Plugin", ECHO_PLUGIN_SOURCE);
|
||||
|
||||
PluginManager& manager = PluginManager::instance();
|
||||
manager.discover_plugins(/*async=*/false, /*clear=*/true);
|
||||
|
||||
// Two cloud rows: one merely available (nothing installed), one with a local package behind it —
|
||||
// the case that used to linger, unloaded but still listed, until the user hit refresh.
|
||||
PluginDescriptor available;
|
||||
available.plugin_key = "11111111-1111-1111-1111-111111111111";
|
||||
available.name = "Available Cloud Plugin";
|
||||
available.cloud = CloudPluginState{available.plugin_key, /*installed=*/false, false, false, false};
|
||||
|
||||
PluginDescriptor installed;
|
||||
installed.plugin_key = "22222222-2222-2222-2222-222222222222";
|
||||
installed.name = "Installed Cloud Plugin";
|
||||
installed.plugin_root = (data_dir_guard.plugins_dir() / "_subscribed" / "user" / installed.plugin_key).string();
|
||||
installed.cloud = CloudPluginState{installed.plugin_key, /*installed=*/true, false, false, false};
|
||||
|
||||
manager.update_cloud_catalog({available, installed});
|
||||
|
||||
const auto has_key = [&manager](const std::string& key) {
|
||||
PluginDescriptor descriptor;
|
||||
return manager.try_get_plugin_descriptor(key, descriptor);
|
||||
};
|
||||
|
||||
REQUIRE(has_key(available.plugin_key));
|
||||
REQUIRE(has_key(installed.plugin_key));
|
||||
REQUIRE(has_key("Echo_Plugin"));
|
||||
|
||||
// Sign out. The per-user _subscribed directory stops being scanned, so both cloud rows are now
|
||||
// stale and must go — not just the one with nothing installed behind it.
|
||||
manager.unload_cloud_plugins();
|
||||
manager.clear_cloud_plugin_catalog();
|
||||
manager.set_cloud_user("");
|
||||
|
||||
CHECK_FALSE(has_key(available.plugin_key));
|
||||
CHECK_FALSE(has_key(installed.plugin_key));
|
||||
CHECK(has_key("Echo_Plugin"));
|
||||
}
|
||||
|
||||
TEST_CASE("Unloading a plugin that is not loaded is a no-op", "[PluginLifecycle]")
|
||||
{
|
||||
ScopedDataDir data_dir_guard("lifecycle-noop-unload");
|
||||
|
||||
PluginManager& manager = PluginManager::instance();
|
||||
|
||||
// Current behavior: unloading an unknown key succeeds (it fires the unload callbacks and
|
||||
// reports success) rather than reporting "nothing to unload".
|
||||
CHECK(manager.unload_plugin("No_Such_Plugin"));
|
||||
CHECK_FALSE(manager.is_plugin_loaded("No_Such_Plugin"));
|
||||
}
|
||||
Reference in New Issue
Block a user