mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-09-02 06:47:04 +00:00
Merge branch 'main' into feat/printer-agent-camera-stream
This commit is contained in:
@@ -30,25 +30,47 @@ if (APPLE)
|
||||
target_link_libraries(test_common INTERFACE "-liconv -framework IOKit" "-framework CoreFoundation" -lc++)
|
||||
endif()
|
||||
|
||||
# Copies runtime DLLs next to each test executable. Handles both single-config
|
||||
# generators (CMAKE_BUILD_TYPE set) and multi-config generators (Ninja
|
||||
# Multi-Config, Visual Studio) where CMAKE_BUILD_TYPE is empty and DLLs must
|
||||
# land in every per-config output directory.
|
||||
# Copies runtime shared libraries next to each test executable. Handles both
|
||||
# single-config generators (CMAKE_BUILD_TYPE set) and multi-config generators
|
||||
# (Ninja Multi-Config, Visual Studio) where CMAKE_BUILD_TYPE is empty and DLLs
|
||||
# must land in every per-config output directory. On Windows the loader finds
|
||||
# DLLs in the executable's directory; the Linux branch below does the same for
|
||||
# the deps-built FFmpeg libraries and adds an $ORIGIN rpath, since the ELF
|
||||
# loader does not search the executable's directory and the CI unit-test runner
|
||||
# only receives the tests artifact (no deps install).
|
||||
function(orcaslicer_copy_test_dlls)
|
||||
if (NOT WIN32)
|
||||
return()
|
||||
endif()
|
||||
set(_configs ${CMAKE_CONFIGURATION_TYPES})
|
||||
if (NOT _configs)
|
||||
set(_configs "${CMAKE_BUILD_TYPE}")
|
||||
endif()
|
||||
foreach(_cfg IN LISTS _configs)
|
||||
if (_cfg STREQUAL "Debug")
|
||||
orcaslicer_copy_dlls(COPY_DLLS "Debug" "d" _unused_dlls)
|
||||
else()
|
||||
orcaslicer_copy_dlls(COPY_DLLS "${_cfg}" "" _unused_dlls)
|
||||
if (WIN32)
|
||||
set(_configs ${CMAKE_CONFIGURATION_TYPES})
|
||||
if (NOT _configs)
|
||||
set(_configs "${CMAKE_BUILD_TYPE}")
|
||||
endif()
|
||||
endforeach()
|
||||
foreach(_cfg IN LISTS _configs)
|
||||
if (_cfg STREQUAL "Debug")
|
||||
orcaslicer_copy_dlls(COPY_DLLS "Debug" "d" _unused_dlls)
|
||||
else()
|
||||
orcaslicer_copy_dlls(COPY_DLLS "${_cfg}" "" _unused_dlls)
|
||||
endif()
|
||||
endforeach()
|
||||
elseif (UNIX AND NOT APPLE)
|
||||
# Only test executables that link libslic3r_gui pull in the FFmpeg
|
||||
# shared libraries (src/slic3r/CMakeLists.txt links PkgConfig::LIBAV
|
||||
# into it). Copy them next to the executable and give it an $ORIGIN
|
||||
# rpath so the loader finds them when the tests run on the CI unit-test
|
||||
# runner, which only receives this build/tests tree.
|
||||
get_target_property(_linked_libs ${_TEST_NAME}_tests LINK_LIBRARIES)
|
||||
if (NOT "libslic3r_gui" IN_LIST _linked_libs)
|
||||
return()
|
||||
endif()
|
||||
|
||||
set_property(TARGET ${_TEST_NAME}_tests PROPERTY BUILD_RPATH "$ORIGIN")
|
||||
set(_configs ${CMAKE_CONFIGURATION_TYPES})
|
||||
if (NOT _configs)
|
||||
set(_configs "${CMAKE_BUILD_TYPE}")
|
||||
endif()
|
||||
foreach(_cfg IN LISTS _configs)
|
||||
orcaslicer_copy_sos(${_TEST_NAME}_tests "${_cfg}" "" _unused_sos)
|
||||
endforeach()
|
||||
endif()
|
||||
endfunction()
|
||||
|
||||
# Register Catch2 tags as CTest labels so `ctest -L`/`-LE` can filter by tag.
|
||||
|
||||
1207
tests/data/utf8_part_names.step
Normal file
1207
tests/data/utf8_part_names.step
Normal file
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,7 @@ add_executable(${_TEST_NAME}_tests
|
||||
test_perimeters.cpp
|
||||
test_print.cpp
|
||||
test_printobject.cpp
|
||||
test_mixed_filament.cpp
|
||||
test_skirt_brim.cpp
|
||||
test_slicing_pipeline_hook.cpp
|
||||
test_support_material.cpp
|
||||
|
||||
324
tests/fff_print/test_mixed_filament.cpp
Normal file
324
tests/fff_print/test_mixed_filament.cpp
Normal file
@@ -0,0 +1,324 @@
|
||||
#include <catch2/catch_all.hpp>
|
||||
|
||||
#include "libslic3r/GCode/ToolOrdering.hpp"
|
||||
#include "libslic3r/MultiNozzleUtils.hpp"
|
||||
#include "libslic3r/Print.hpp"
|
||||
|
||||
#include "test_helpers.hpp"
|
||||
|
||||
using namespace Slic3r;
|
||||
using namespace Slic3r::Test;
|
||||
|
||||
namespace {
|
||||
|
||||
// Two physical filaments plus one mixed slot (config index 2, 1-based id 3) blending them 60/40.
|
||||
// The mixed arrays are parallel to filament_colour and must be sized to the filament count.
|
||||
// Note ConfigOptionBools deserializes on ',' while ConfigOptionStrings uses ';'.
|
||||
DynamicPrintConfig mixed_config(bool sublayer_on, const char *ratios = "0.6,0.4")
|
||||
{
|
||||
DynamicPrintConfig config = multifilament_config(3);
|
||||
config.set_deserialize_strict({
|
||||
{"filament_is_mixed", "0,0,1"},
|
||||
{"filament_mixed_components", ";;1,2"},
|
||||
{"filament_mixed_sublayer_ratios", std::string(";;") + ratios},
|
||||
{"filament_mixed_gradient", "0,0,0"},
|
||||
{"filament_mixed_gradient_range", ";;"},
|
||||
{"filament_mixed_gradient_curve", ";;"},
|
||||
{"filament_mixed_gradient_per_part","0,0,0"},
|
||||
{"enable_mixed_color_sublayer", sublayer_on ? "1" : "0"},
|
||||
// Assign every region role to the mixed slot so it actually participates in slicing.
|
||||
{"outer_wall_filament_id", "3"},
|
||||
{"inner_wall_filament_id", "3"},
|
||||
{"sparse_infill_filament_id", "3"},
|
||||
{"internal_solid_filament_id", "3"},
|
||||
{"top_surface_filament_id", "3"},
|
||||
{"bottom_surface_filament_id", "3"},
|
||||
});
|
||||
return config;
|
||||
}
|
||||
|
||||
// Total sub-layer groups and per-layer mixed-filament resolutions across the whole tool ordering.
|
||||
void count_mixed(ToolOrdering &to, size_t &groups, size_t &resolutions)
|
||||
{
|
||||
groups = resolutions = 0;
|
||||
for (const LayerTools < : to.layer_tools()) {
|
||||
groups += lt.mixed_sub_layer_groups.size();
|
||||
resolutions += lt.mixed_filament_resolution.size();
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("enable_mixed_color_sublayer reaches the Print config", "[MixedFilament]")
|
||||
{
|
||||
Print print;
|
||||
Model model;
|
||||
init_print({cube(20)}, print, model, mixed_config(true));
|
||||
|
||||
// The option lives in PrintConfig; if it did not survive Print::apply the slicer would
|
||||
// silently fall back to the whole-layer path.
|
||||
CHECK(print.config().enable_mixed_color_sublayer.value == true);
|
||||
REQUIRE(print.config().filament_is_mixed.values.size() == 3);
|
||||
CHECK(print.config().filament_is_mixed.values[2] == true);
|
||||
REQUIRE(print.config().filament_mixed_components.values.size() == 3);
|
||||
CHECK(print.config().filament_mixed_components.values[2] == "1,2");
|
||||
}
|
||||
|
||||
TEST_CASE("Mixed filament splits layers into sub-layers when the option is on", "[MixedFilament]")
|
||||
{
|
||||
Print print;
|
||||
Model model;
|
||||
init_print({cube(20)}, print, model, mixed_config(true));
|
||||
print.process();
|
||||
|
||||
ToolOrdering &to = const_cast<ToolOrdering &>(print.tool_ordering());
|
||||
REQUIRE(!to.layer_tools().empty());
|
||||
|
||||
size_t groups = 0, resolutions = 0;
|
||||
count_mixed(to, groups, resolutions);
|
||||
|
||||
INFO("layers=" << to.layer_tools().size() << " groups=" << groups);
|
||||
CHECK(groups > 0);
|
||||
}
|
||||
|
||||
TEST_CASE("Mixed filament alternates whole layers when the option is off", "[MixedFilament]")
|
||||
{
|
||||
Print print;
|
||||
Model model;
|
||||
init_print({cube(20)}, print, model, mixed_config(false));
|
||||
print.process();
|
||||
|
||||
ToolOrdering &to = const_cast<ToolOrdering &>(print.tool_ordering());
|
||||
REQUIRE(!to.layer_tools().empty());
|
||||
|
||||
size_t groups = 0, resolutions = 0;
|
||||
count_mixed(to, groups, resolutions);
|
||||
|
||||
// With splitting off the slot is realized by the deficit round-robin scheduler instead:
|
||||
// no sub-layer groups, but a per-layer resolution to one physical component.
|
||||
INFO("layers=" << to.layer_tools().size() << " resolutions=" << resolutions);
|
||||
CHECK(groups == 0);
|
||||
CHECK(resolutions > 0);
|
||||
}
|
||||
|
||||
TEST_CASE("Sub-layer splitting emits the scaled sub-heights into G-code", "[MixedFilament]")
|
||||
{
|
||||
// layer_height 0.2 split 60/40 gives sub-layers of 0.12 and 0.08. The emitter reports the
|
||||
// sub-height (not the nominal layer height) in the HEIGHT tag and scales flow to match.
|
||||
DynamicPrintConfig config = mixed_config(true);
|
||||
config.set_deserialize_strict({{"layer_height", "0.2"}, {"initial_layer_print_height", "0.2"}});
|
||||
|
||||
Print print;
|
||||
Model model;
|
||||
init_print({cube(20)}, print, model, config);
|
||||
print.process();
|
||||
const std::string gc = Slic3r::Test::gcode(print);
|
||||
|
||||
REQUIRE(!gc.empty());
|
||||
INFO("gcode bytes=" << gc.size());
|
||||
CHECK(gc.find(";HEIGHT:0.12") != std::string::npos);
|
||||
CHECK(gc.find(";HEIGHT:0.08") != std::string::npos);
|
||||
}
|
||||
|
||||
TEST_CASE("Whole-layer mixing emits only the nominal layer height", "[MixedFilament]")
|
||||
{
|
||||
DynamicPrintConfig config = mixed_config(false);
|
||||
config.set_deserialize_strict({{"layer_height", "0.2"}, {"initial_layer_print_height", "0.2"}});
|
||||
|
||||
Print print;
|
||||
Model model;
|
||||
init_print({cube(20)}, print, model, config);
|
||||
print.process();
|
||||
const std::string gc = Slic3r::Test::gcode(print);
|
||||
|
||||
REQUIRE(!gc.empty());
|
||||
// No sub-layer split, so the 60/40 sub-heights must never appear.
|
||||
CHECK(gc.find(";HEIGHT:0.12") == std::string::npos);
|
||||
CHECK(gc.find(";HEIGHT:0.08") == std::string::npos);
|
||||
}
|
||||
|
||||
TEST_CASE("By-object prints without mixed filaments keep their used-filament set", "[MixedFilament]")
|
||||
{
|
||||
// With no mixed slot the by-object bookkeeping stays plain: object 2 prints with filament 2,
|
||||
// so both filaments are used and no mixed filament is reported.
|
||||
DynamicPrintConfig config = multifilament_config(2, {{"print_sequence", "by object"}});
|
||||
const std::vector<std::vector<ConfigBase::SetDeserializeItem>> overrides{ {}, { {"extruder", "2"} } };
|
||||
|
||||
Print print;
|
||||
Model model;
|
||||
init_print(std::vector<TriangleMesh>{cube(20), cube(20)}, print, model, config, &overrides);
|
||||
REQUIRE(print.objects().size() == 2);
|
||||
print.process();
|
||||
|
||||
CHECK(print.get_slice_used_filaments(false) == std::vector<unsigned int>{0, 1});
|
||||
CHECK(print.get_slice_used_filaments(true) == std::vector<unsigned int>{0, 1});
|
||||
CHECK(print.get_slice_used_mixed_filaments().empty());
|
||||
}
|
||||
|
||||
TEST_CASE("By-layer prints record a mixed slot's components and the slot itself", "[MixedFilament]")
|
||||
{
|
||||
// Control for the by-object case below: the by-layer path publishes the physical
|
||||
// components (0-based 0 and 1) as used filaments and the mixed slot (config index 2) as
|
||||
// a used mixed filament. By-object prints must report exactly the same.
|
||||
Print print;
|
||||
Model model;
|
||||
init_print({cube(20)}, print, model, mixed_config(false));
|
||||
print.process();
|
||||
|
||||
CHECK(print.get_slice_used_filaments(false) == std::vector<unsigned int>{0, 1});
|
||||
CHECK(print.get_slice_used_mixed_filaments() == std::vector<unsigned int>{2});
|
||||
}
|
||||
|
||||
TEST_CASE("By-object prints expand a mixed slot to its components in the slice bookkeeping", "[MixedFilament]")
|
||||
{
|
||||
// Sequential prints build their filament lists from unsorted per-object orderings, which
|
||||
// still carry the virtual slot (config index 2). The slice-used sets and the published
|
||||
// grouping result must see the physical components 0 and 1 instead, and the slot itself
|
||||
// must still be reported as a used mixed filament — exactly what the by-layer path yields.
|
||||
DynamicPrintConfig config = mixed_config(false);
|
||||
config.set_deserialize_strict({{"print_sequence", "by object"}});
|
||||
|
||||
Print print;
|
||||
Model model;
|
||||
init_print({cube(20), cube(20)}, print, model, config);
|
||||
REQUIRE(print.objects().size() == 2);
|
||||
print.process();
|
||||
|
||||
const std::vector<unsigned int> components{0, 1};
|
||||
CHECK(print.get_slice_used_filaments(false) == components);
|
||||
CHECK(print.get_slice_used_filaments(true) == components);
|
||||
CHECK(print.get_slice_used_mixed_filaments() == std::vector<unsigned int>{2});
|
||||
|
||||
auto group_result = print.get_layered_nozzle_group_result();
|
||||
REQUIRE(group_result != nullptr);
|
||||
CHECK(group_result->get_used_filaments() == components);
|
||||
}
|
||||
|
||||
TEST_CASE("By-object G-code lists a mixed slot's components in the filament header", "[MixedFilament]")
|
||||
{
|
||||
DynamicPrintConfig config = mixed_config(false);
|
||||
config.set_deserialize_strict({{"print_sequence", "by object"}});
|
||||
|
||||
Print print;
|
||||
Model model;
|
||||
init_print({cube(20), cube(20)}, print, model, config);
|
||||
const std::string gc = Slic3r::Test::gcode(print);
|
||||
|
||||
REQUIRE(!gc.empty());
|
||||
// The header names the filaments that must be loaded (components 1 and 2, 1-based),
|
||||
// never the virtual slot 3.
|
||||
CHECK(gc.find("; filament: 1,2\n") != std::string::npos);
|
||||
CHECK(gc.find("; filament: 3") == std::string::npos);
|
||||
}
|
||||
|
||||
TEST_CASE("Print::validate rejects a mixed filament as the wipe tower filament", "[MixedFilament]")
|
||||
{
|
||||
// The validate backstop refuses a mixed (virtual) slot as the wipe tower filament; the GUI hides
|
||||
// the slot from that option. Two cubes on physical filaments 1 and 2 make the tower real, and the
|
||||
// region roles mixed_config() points at the slot are reset so only the tower uses it.
|
||||
DynamicPrintConfig config = mixed_config(false);
|
||||
config.set_deserialize_strict({
|
||||
{"enable_prime_tower", "1"},
|
||||
{"wipe_tower_x", "50"}, // inside the 200x200 test bed
|
||||
{"wipe_tower_y", "50"}, // (the default y, 220, is not)
|
||||
{"layer_change_gcode", "G92 E0\n"}, // validate() relative-E reset, as in test_print.cpp's build_cubes
|
||||
{"outer_wall_filament_id", "0"},
|
||||
{"inner_wall_filament_id", "0"},
|
||||
{"sparse_infill_filament_id", "0"},
|
||||
{"internal_solid_filament_id", "0"},
|
||||
{"top_surface_filament_id", "0"},
|
||||
{"bottom_surface_filament_id", "0"},
|
||||
});
|
||||
const std::vector<std::vector<ConfigBase::SetDeserializeItem>> overrides{ { {"extruder", "1"} }, { {"extruder", "2"} } };
|
||||
|
||||
SECTION("a physical wipe tower filament validates") {
|
||||
config.set_deserialize_strict({{"wipe_tower_filament", "2"}});
|
||||
Print print;
|
||||
Model model;
|
||||
init_print(std::vector<TriangleMesh>{cube(20), cube(20)}, print, model, config, &overrides);
|
||||
REQUIRE(print.has_wipe_tower());
|
||||
const StringObjectException err = print.validate();
|
||||
INFO(err.string);
|
||||
CHECK(err.string.empty());
|
||||
}
|
||||
|
||||
SECTION("the mixed slot is refused") {
|
||||
config.set_deserialize_strict({{"wipe_tower_filament", "3"}});
|
||||
Print print;
|
||||
Model model;
|
||||
init_print(std::vector<TriangleMesh>{cube(20), cube(20)}, print, model, config, &overrides);
|
||||
REQUIRE(print.has_wipe_tower());
|
||||
const StringObjectException err = print.validate();
|
||||
CHECK_FALSE(err.string.empty());
|
||||
CHECK(err.opt_key == "wipe_tower_filament");
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("Print::validate warns when a gradient mixed filament is used without sublayer mixing", "[MixedFilament]")
|
||||
{
|
||||
// A gradient mixed filament only renders its gradient with the process option enabled; without
|
||||
// it ToolOrdering prints one whole component per layer and the gradient is dropped silently,
|
||||
// so validate() warns whenever the slot actually takes part in the print. The layer-change
|
||||
// reset avoids an unrelated relative-extrusion warning, as in the wipe tower test above.
|
||||
DynamicPrintConfig config = mixed_config(false);
|
||||
config.set_deserialize_strict({
|
||||
{"filament_mixed_gradient", "0,0,1"},
|
||||
{"layer_change_gcode", "G92 E0\n"},
|
||||
});
|
||||
|
||||
auto count_opt = [](Print &print, const char *opt_key) {
|
||||
std::vector<StringObjectException> warnings;
|
||||
print.validate(&warnings);
|
||||
return std::count_if(warnings.begin(), warnings.end(),
|
||||
[&](const StringObjectException &w) { return w.opt_key == opt_key; });
|
||||
};
|
||||
|
||||
SECTION("gradient slot used, sublayer mixing off") {
|
||||
Print print;
|
||||
Model model;
|
||||
init_print({cube(20)}, print, model, config);
|
||||
std::vector<StringObjectException> warnings;
|
||||
const StringObjectException err = print.validate(&warnings);
|
||||
CHECK(err.string.empty());
|
||||
const auto it = std::find_if(warnings.begin(), warnings.end(), [](const StringObjectException &w) {
|
||||
return w.opt_key == "enable_mixed_color_sublayer";
|
||||
});
|
||||
REQUIRE(it != warnings.end());
|
||||
CHECK(it->is_warning);
|
||||
CHECK(std::count_if(warnings.begin(), warnings.end(), [](const StringObjectException &w) {
|
||||
return w.opt_key == "enable_mixed_color_sublayer";
|
||||
}) == 1);
|
||||
}
|
||||
|
||||
SECTION("sublayer mixing on") {
|
||||
config.set_deserialize_strict({{"enable_mixed_color_sublayer", "1"}});
|
||||
Print print;
|
||||
Model model;
|
||||
init_print({cube(20)}, print, model, config);
|
||||
CHECK(count_opt(print, "enable_mixed_color_sublayer") == 0);
|
||||
}
|
||||
|
||||
SECTION("gradient flag off") {
|
||||
config.set_deserialize_strict({{"filament_mixed_gradient", "0,0,0"}});
|
||||
Print print;
|
||||
Model model;
|
||||
init_print({cube(20)}, print, model, config);
|
||||
CHECK(count_opt(print, "enable_mixed_color_sublayer") == 0);
|
||||
}
|
||||
|
||||
SECTION("mixed slot not used") {
|
||||
config.set_deserialize_strict({
|
||||
{"outer_wall_filament_id", "0"},
|
||||
{"inner_wall_filament_id", "0"},
|
||||
{"sparse_infill_filament_id", "0"},
|
||||
{"internal_solid_filament_id", "0"},
|
||||
{"top_surface_filament_id", "0"},
|
||||
{"bottom_surface_filament_id", "0"},
|
||||
});
|
||||
Print print;
|
||||
Model model;
|
||||
const std::vector<std::vector<ConfigBase::SetDeserializeItem>> overrides{{{ "extruder", "1" }}};
|
||||
init_print(std::vector<TriangleMesh>{cube(20)}, print, model, config, &overrides);
|
||||
CHECK(count_opt(print, "enable_mixed_color_sublayer") == 0);
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ add_executable(${_TEST_NAME}_tests
|
||||
test_vendor_cache.cpp
|
||||
test_elephant_foot_compensation.cpp
|
||||
test_fill_corner_smoothing.cpp
|
||||
test_filament_mixer.cpp
|
||||
test_fill_plane_path.cpp
|
||||
test_geometry.cpp
|
||||
test_multimaterial_segmentation.cpp
|
||||
@@ -28,7 +29,9 @@ add_executable(${_TEST_NAME}_tests
|
||||
test_mutable_polygon.cpp
|
||||
test_mutable_priority_queue.cpp
|
||||
test_nozzle_volume_type.cpp
|
||||
test_step.cpp
|
||||
test_stl.cpp
|
||||
test_triangle_selector.cpp
|
||||
test_meshboolean.cpp
|
||||
test_marchingsquares.cpp
|
||||
test_model.cpp
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
|
||||
#include "libslic3r/Model.hpp"
|
||||
#include "libslic3r/TriangleSelector.hpp"
|
||||
#include "libslic3r/Format/3mf.hpp"
|
||||
#include "libslic3r/Format/bbs_3mf.hpp"
|
||||
#include "libslic3r/Format/STL.hpp"
|
||||
@@ -497,3 +498,95 @@ SCENARIO("Nozzle-group metadata .3mf round-trip", "[3mf][MultiNozzle]") {
|
||||
delete plate;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// A mixed-color filament occupies an ordinary filament slot, and painting with it stores an
|
||||
// ordinary extruder state: a project saved by BambuStudio encodes filament 5 of a 5-slot setup
|
||||
// as paint state 5, with the mix described by the parallel filament_mixed_* project arrays.
|
||||
SCENARIO("Mixed-color filament setup and painting round-trip through a .3mf", "[3mf][MixedFilament]") {
|
||||
GIVEN("a painted model whose project config describes a mixed filament in the last slot") {
|
||||
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();
|
||||
|
||||
// Both the exporter and the importer stage Metadata/project_settings.config through the
|
||||
// model's backup path; point them at writable temp dirs.
|
||||
ScopedTemporaryDir backup_dir("orca_mixed_src");
|
||||
model.set_backup_path(backup_dir.string());
|
||||
|
||||
ModelVolume* mv = model.objects.front()->volumes.front();
|
||||
{
|
||||
TriangleSelector selector(mv->mesh());
|
||||
selector.set_facet(0, EnforcerBlockerType::Extruder5); // the mixed slot
|
||||
selector.set_facet(1, EnforcerBlockerType::Extruder2);
|
||||
REQUIRE(mv->mmu_segmentation_facets.set(selector));
|
||||
}
|
||||
|
||||
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
||||
config.set_key_value("filament_colour", new ConfigOptionStrings(
|
||||
{ "#00AE42", "#FFFF00", "#FF0000", "#0000FF", "#FF6A26" }));
|
||||
config.set_key_value("filament_is_mixed", new ConfigOptionBools(
|
||||
{ false, false, false, false, true }));
|
||||
config.set_key_value("filament_mixed_components", new ConfigOptionStrings(
|
||||
{ "", "", "", "", "3,2" }));
|
||||
config.set_key_value("filament_mixed_sublayer_ratios", new ConfigOptionStrings(
|
||||
{ "", "", "", "", "0.4200,0.5800" }));
|
||||
|
||||
WHEN("stored to and reloaded from a .3mf") {
|
||||
ScopedTemporaryFile temp(".3mf");
|
||||
const std::string test_file = temp.string();
|
||||
|
||||
PlateData* plate = new PlateData();
|
||||
plate->plate_index = 0;
|
||||
|
||||
StoreParams store_params;
|
||||
store_params.path = test_file.c_str();
|
||||
store_params.model = &model;
|
||||
store_params.config = &config;
|
||||
store_params.strategy = SaveStrategy::Zip64 | SaveStrategy::Silence;
|
||||
store_params.plate_data_list.push_back(plate);
|
||||
REQUIRE(store_bbs_3mf(store_params));
|
||||
|
||||
Model dst_model;
|
||||
ScopedTemporaryDir dst_backup_dir("orca_mixed_dst");
|
||||
dst_model.set_backup_path(dst_backup_dir.string());
|
||||
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;
|
||||
REQUIRE(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));
|
||||
|
||||
THEN("the mixed-filament project keys survive") {
|
||||
auto* is_mixed = dst_config.option<ConfigOptionBools>("filament_is_mixed");
|
||||
REQUIRE(is_mixed != nullptr);
|
||||
REQUIRE(is_mixed->values == std::vector<unsigned char>({ 0, 0, 0, 0, 1 }));
|
||||
|
||||
auto* components = dst_config.option<ConfigOptionStrings>("filament_mixed_components");
|
||||
REQUIRE(components != nullptr);
|
||||
REQUIRE(components->values.size() == 5);
|
||||
REQUIRE(components->values[4] == "3,2");
|
||||
|
||||
auto* ratios = dst_config.option<ConfigOptionStrings>("filament_mixed_sublayer_ratios");
|
||||
REQUIRE(ratios != nullptr);
|
||||
REQUIRE(ratios->values.size() == 5);
|
||||
REQUIRE(ratios->values[4] == "0.4200,0.5800");
|
||||
}
|
||||
|
||||
THEN("the painted facets survive, including the one painted with the mixed slot") {
|
||||
REQUIRE(dst_model.objects.size() == 1);
|
||||
ModelVolume* dst_mv = dst_model.objects.front()->volumes.front();
|
||||
REQUIRE_FALSE(dst_mv->mmu_segmentation_facets.empty());
|
||||
REQUIRE(dst_mv->mmu_segmentation_facets.has_facets(*dst_mv, EnforcerBlockerType::Extruder2));
|
||||
REQUIRE(dst_mv->mmu_segmentation_facets.has_facets(*dst_mv, EnforcerBlockerType::Extruder5));
|
||||
}
|
||||
|
||||
release_PlateData_list(dst_plates);
|
||||
delete plate; // store_bbs_3mf does not take ownership of the source plate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
205
tests/libslic3r/test_filament_mixer.cpp
Normal file
205
tests/libslic3r/test_filament_mixer.cpp
Normal file
@@ -0,0 +1,205 @@
|
||||
#include <catch2/catch_all.hpp>
|
||||
|
||||
#include "libslic3r/FilamentMixer.hpp"
|
||||
#include "libslic3r/PrintConfig.hpp"
|
||||
|
||||
using namespace Slic3r;
|
||||
|
||||
TEST_CASE("parse_mixed_components reads 1-based component ids", "[FilamentMixer]")
|
||||
{
|
||||
REQUIRE(parse_mixed_components("1,3") == std::vector<unsigned int>{1, 3});
|
||||
REQUIRE(parse_mixed_components("2, 4 ,5") == std::vector<unsigned int>{2, 4, 5});
|
||||
|
||||
SECTION("Malformed input yields no components") {
|
||||
REQUIRE(parse_mixed_components("").empty());
|
||||
REQUIRE(parse_mixed_components("abc").empty());
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("parse_mixed_ratios normalizes to sum 1.0", "[FilamentMixer]")
|
||||
{
|
||||
auto r = parse_mixed_ratios("0.7,0.3", 2);
|
||||
REQUIRE(r.size() == 2);
|
||||
REQUIRE_THAT(r[0], Catch::Matchers::WithinAbs(0.7, 1e-9));
|
||||
REQUIRE_THAT(r[1], Catch::Matchers::WithinAbs(0.3, 1e-9));
|
||||
|
||||
SECTION("Unnormalized input is rescaled") {
|
||||
auto v = parse_mixed_ratios("2,2", 2);
|
||||
REQUIRE_THAT(v[0], Catch::Matchers::WithinAbs(0.5, 1e-9));
|
||||
REQUIRE_THAT(v[1], Catch::Matchers::WithinAbs(0.5, 1e-9));
|
||||
}
|
||||
|
||||
SECTION("Empty or mismatched input falls back to equal shares") {
|
||||
auto v = parse_mixed_ratios("", 3);
|
||||
REQUIRE(v.size() == 3);
|
||||
for (double x : v)
|
||||
REQUIRE_THAT(x, Catch::Matchers::WithinAbs(1.0 / 3.0, 1e-9));
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("has_any_mixed_filament detects mixed slots", "[FilamentMixer]")
|
||||
{
|
||||
REQUIRE_FALSE(has_any_mixed_filament({}));
|
||||
REQUIRE_FALSE(has_any_mixed_filament({0, 0, 0}));
|
||||
REQUIRE(has_any_mixed_filament({0, 1, 0}));
|
||||
}
|
||||
|
||||
TEST_CASE("expand_mixed_filaments replaces mixed slots with their components", "[FilamentMixer]")
|
||||
{
|
||||
// Slot 2 (0-based) is a mix of physical filaments 1 and 2 (1-based) => 0 and 1 (0-based).
|
||||
const std::vector<unsigned char> is_mixed = {0, 0, 1};
|
||||
const std::vector<std::string> comp_strs = {"", "", "1,2"};
|
||||
|
||||
REQUIRE(expand_mixed_filaments({2}, is_mixed, comp_strs) == std::vector<unsigned int>{0, 1});
|
||||
|
||||
SECTION("Non-mixed entries pass through, result is sorted and deduplicated") {
|
||||
REQUIRE(expand_mixed_filaments({2, 0}, is_mixed, comp_strs) == std::vector<unsigned int>{0, 1});
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("check_mixed_filament_integrity flags dangling component references", "[FilamentMixer]")
|
||||
{
|
||||
const std::vector<unsigned char> is_mixed = {0, 0, 1};
|
||||
|
||||
SECTION("All components resolve") {
|
||||
REQUIRE(check_mixed_filament_integrity(is_mixed, {"", "", "1,2"}, 2).empty());
|
||||
}
|
||||
|
||||
SECTION("A component past the physical filament count is broken") {
|
||||
auto broken = check_mixed_filament_integrity(is_mixed, {"", "", "1,9"}, 2);
|
||||
REQUIRE(broken == std::vector<size_t>{2});
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("remap_mixed_components_on_delete rewrites ids around the deleted slot", "[FilamentMixer]")
|
||||
{
|
||||
const std::vector<unsigned char> is_mixed = {0, 0, 0, 1};
|
||||
std::vector<std::string> comps = {"", "", "", "1,3"};
|
||||
|
||||
SECTION("Deleting a filament below the references shifts them down") {
|
||||
remap_mixed_components_on_delete(is_mixed, comps, 2);
|
||||
REQUIRE(comps[3] == "1,2");
|
||||
}
|
||||
|
||||
SECTION("Deleting a referenced filament zeroes that component") {
|
||||
remap_mixed_components_on_delete(is_mixed, comps, 1);
|
||||
// 1 -> 0 (deleted sentinel), 3 -> 2
|
||||
REQUIRE(comps[3] == "0,2");
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("check_mixed_filament_type_consistency flags mismatched component types", "[FilamentMixer]")
|
||||
{
|
||||
const std::vector<unsigned char> is_mixed = {0, 0, 1};
|
||||
const std::vector<std::string> comp_strs = {"", "", "1,2"};
|
||||
|
||||
REQUIRE(check_mixed_filament_type_consistency(is_mixed, comp_strs, {"PLA", "PLA"}).empty());
|
||||
|
||||
auto bad = check_mixed_filament_type_consistency(is_mixed, comp_strs, {"PLA", "PETG"});
|
||||
REQUIRE(bad == std::vector<size_t>{2});
|
||||
}
|
||||
|
||||
TEST_CASE("a support-flagged component reads as its own filament type for the consistency check", "[FilamentMixer]")
|
||||
{
|
||||
// The sidebar derives each component's type through DynamicPrintConfig::get_filament_type,
|
||||
// which folds filament_is_support into the type, so toggling that flag alone flips the
|
||||
// verdict and the mixed filament list has to be refreshed on filament_is_support too.
|
||||
DynamicPrintConfig plain_pla;
|
||||
plain_pla.set_key_value("filament_type", new ConfigOptionStrings({"PLA"}));
|
||||
plain_pla.set_key_value("filament_is_support", new ConfigOptionBools({false}));
|
||||
std::string displayed;
|
||||
REQUIRE(plain_pla.get_filament_type(displayed) == "PLA");
|
||||
|
||||
DynamicPrintConfig support_pla;
|
||||
support_pla.set_key_value("filament_type", new ConfigOptionStrings({"PLA"}));
|
||||
support_pla.set_key_value("filament_is_support", new ConfigOptionBools({true}));
|
||||
REQUIRE(support_pla.get_filament_type(displayed) == "PLA-S");
|
||||
REQUIRE(displayed == "Sup.PLA");
|
||||
|
||||
const std::vector<unsigned char> is_mixed = {0, 0, 1};
|
||||
const std::vector<std::string> comp_strs = {"", "", "1,2"};
|
||||
REQUIRE(check_mixed_filament_type_consistency(is_mixed, comp_strs, {"PLA", "PLA-S"}) == std::vector<size_t>{2});
|
||||
}
|
||||
|
||||
TEST_CASE("gradient curves round-trip and sample monotonically", "[FilamentMixer]")
|
||||
{
|
||||
SECTION("Empty input yields an empty curve") {
|
||||
REQUIRE(parse_gradient_curve("").empty());
|
||||
REQUIRE(serialize_gradient_curve(GradientCurve{}).empty());
|
||||
}
|
||||
|
||||
SECTION("Legacy 2-field anchors survive a parse/serialize round trip") {
|
||||
GradientCurve c = parse_gradient_curve("0,0.15|0.5,0.5|1,0.85");
|
||||
REQUIRE(c.points.size() == 3);
|
||||
|
||||
// Anchors with no tangent override serialize back to the 2-field legacy form
|
||||
// (canonical fixed-precision, so compare by re-parsing rather than by string).
|
||||
const std::string round_tripped = serialize_gradient_curve(c);
|
||||
REQUIRE(round_tripped.find(",nan") == std::string::npos);
|
||||
|
||||
GradientCurve c2 = parse_gradient_curve(round_tripped);
|
||||
REQUIRE(c2.points.size() == c.points.size());
|
||||
for (size_t i = 0; i < c.points.size(); ++i) {
|
||||
REQUIRE_THAT(c2.points[i].x, Catch::Matchers::WithinAbs(c.points[i].x, 1e-4));
|
||||
REQUIRE_THAT(c2.points[i].y, Catch::Matchers::WithinAbs(c.points[i].y, 1e-4));
|
||||
}
|
||||
}
|
||||
|
||||
SECTION("Sampling is clamped at the ends and monotone in between") {
|
||||
GradientCurve c = parse_gradient_curve("0,0.15|0.5,0.5|1,0.85");
|
||||
REQUIRE_THAT(sample_gradient_curve(c, 0.0), Catch::Matchers::WithinAbs(0.15, 1e-9));
|
||||
REQUIRE_THAT(sample_gradient_curve(c, 1.0), Catch::Matchers::WithinAbs(0.85, 1e-9));
|
||||
// Outside the control point range the end values are held.
|
||||
REQUIRE_THAT(sample_gradient_curve(c, -1.0), Catch::Matchers::WithinAbs(0.15, 1e-9));
|
||||
REQUIRE_THAT(sample_gradient_curve(c, 2.0), Catch::Matchers::WithinAbs(0.85, 1e-9));
|
||||
|
||||
double prev = sample_gradient_curve(c, 0.0);
|
||||
for (int i = 1; i <= 20; ++i) {
|
||||
double v = sample_gradient_curve(c, i / 20.0);
|
||||
REQUIRE(v >= prev - 1e-9);
|
||||
prev = v;
|
||||
}
|
||||
}
|
||||
|
||||
SECTION("A curve with fewer than two points falls back to 0.5") {
|
||||
GradientCurve c = parse_gradient_curve("0.5,0.7");
|
||||
REQUIRE_THAT(sample_gradient_curve(c, 0.3), Catch::Matchers::WithinAbs(0.5, 1e-9));
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("blend_color mixes two hex colors", "[FilamentMixer]")
|
||||
{
|
||||
// ratio 0 keeps the first color, ratio 1 the second.
|
||||
REQUIRE(blend_color("#FF0000", "#0000FF", 0.0f) == "#FF0000");
|
||||
REQUIRE(blend_color("#FF0000", "#0000FF", 1.0f) == "#0000FF");
|
||||
|
||||
SECTION("Blue and yellow make green, not grey (pigment mixing)") {
|
||||
// The polynomial model approximates subtractive pigment behaviour.
|
||||
std::string mixed = blend_color("#0021D0", "#FCD300", 0.5f);
|
||||
REQUIRE(mixed.size() == 7);
|
||||
REQUIRE(mixed[0] == '#');
|
||||
auto comp = [&](int i) { return std::stoi(mixed.substr(1 + 2 * i, 2), nullptr, 16); };
|
||||
// Green channel should dominate red and blue.
|
||||
REQUIRE(comp(1) > comp(0));
|
||||
REQUIRE(comp(1) > comp(2));
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("blend_color_multi weights components", "[FilamentMixer]")
|
||||
{
|
||||
SECTION("A single component is returned unchanged") {
|
||||
REQUIRE(blend_color_multi({"#FF0000"}, {1}) == "#FF0000");
|
||||
}
|
||||
|
||||
SECTION("Mixing a color with itself stays close to that color") {
|
||||
// The mixer is a degree-4 polynomial fit of pigment behaviour, so mixing a color with
|
||||
// itself lands near it rather than exactly on it; allow a small per-channel drift.
|
||||
std::string mixed = blend_color_multi({"#123456", "#123456"}, {1, 1});
|
||||
REQUIRE(mixed.size() == 7);
|
||||
auto comp = [](const std::string &hex, int i) {
|
||||
return std::stoi(hex.substr(1 + 2 * i, 2), nullptr, 16);
|
||||
};
|
||||
for (int i = 0; i < 3; ++i)
|
||||
REQUIRE(std::abs(comp(mixed, i) - comp("#123456", i)) <= 8);
|
||||
}
|
||||
}
|
||||
@@ -171,3 +171,24 @@ TEST_CASE("Corner smoothing keeps the ends of a path that returns to its start",
|
||||
REQUIRE(retrace.front() == sharp.front());
|
||||
REQUIRE(retrace.back() == sharp.back());
|
||||
}
|
||||
|
||||
TEST_CASE("Corner smoothing ignores vertices splitting a straight leg", "[FillCornerSmoothing][Regression]")
|
||||
{
|
||||
// The triangular and grid infills emit a vertex halfway along the straight run joining two of
|
||||
// their corners. Measuring the legs up to that vertex instead of up to the next corner let the
|
||||
// rounding reach only half as far there as it did into the very same run elsewhere in the
|
||||
// pattern, so geometrically identical corners came out rounded to different radii.
|
||||
const Polyline plain{ Point::new_scale(0., 20.), Point::new_scale(10., 0.),
|
||||
Point::new_scale(20., 0.), Point::new_scale(30., 20.) };
|
||||
Polyline split = plain;
|
||||
split.points.insert(split.points.begin() + 2, Point::new_scale(15., 0.));
|
||||
|
||||
Polyline smooth_plain = plain;
|
||||
smooth_polyline_corners(smooth_plain, 1., tolerance);
|
||||
Polyline smooth_split = split;
|
||||
smooth_polyline_corners(smooth_split, 1., tolerance);
|
||||
|
||||
REQUIRE(smooth_split.points == smooth_plain.points);
|
||||
// Both corners reach the middle of the 10mm run they share, which the extra vertex sat on.
|
||||
REQUIRE(contains(smooth_plain, Point::new_scale(15., 0.)));
|
||||
}
|
||||
|
||||
@@ -566,3 +566,327 @@ TEST_CASE("A printer specific filament supersedes the generic library filament w
|
||||
CHECK(is_compatible_with_printer(generic_lib, PresetWithVendorProfile(*printer_c, nullptr)));
|
||||
}
|
||||
|
||||
|
||||
namespace {
|
||||
|
||||
const char *kMixedKeys[] = {
|
||||
"filament_is_mixed",
|
||||
"filament_mixed_components",
|
||||
"filament_mixed_sublayer_ratios",
|
||||
"filament_mixed_gradient",
|
||||
"filament_mixed_gradient_range",
|
||||
"filament_mixed_gradient_curve",
|
||||
"filament_mixed_gradient_per_part",
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
// Mixed-color filament metadata lives in project_config as parallel per-filament arrays.
|
||||
// set_num_filaments() is the single place that grows them alongside filament_colour; if it
|
||||
// misses them, creating a mixed slot writes past the end of the short arrays.
|
||||
TEST_CASE("set_num_filaments keeps mixed-color arrays in step with the filament count", "[Preset][Bundle][FilamentMixer]")
|
||||
{
|
||||
auto mixed_array_size = [](const DynamicPrintConfig &cfg, const std::string &key) -> size_t {
|
||||
if (const auto *b = cfg.option<ConfigOptionBools>(key))
|
||||
return b->values.size();
|
||||
if (const auto *s = cfg.option<ConfigOptionStrings>(key))
|
||||
return s->values.size();
|
||||
return size_t(-1); // key missing entirely
|
||||
};
|
||||
|
||||
PresetBundle bundle;
|
||||
|
||||
const unsigned int n = GENERATE(2u, 4u, 8u);
|
||||
bundle.set_num_filaments(n, std::string("#FF0000"));
|
||||
|
||||
REQUIRE(bundle.project_config.option<ConfigOptionStrings>("filament_colour")->values.size() == n);
|
||||
for (const char *key : kMixedKeys) {
|
||||
DYNAMIC_SECTION("grown: " << key) {
|
||||
CHECK(mixed_array_size(bundle.project_config, key) == n);
|
||||
}
|
||||
}
|
||||
|
||||
SECTION("shrinking keeps them in step too") {
|
||||
bundle.set_num_filaments(1, std::string("#00FF00"));
|
||||
REQUIRE(bundle.project_config.option<ConfigOptionStrings>("filament_colour")->values.size() == 1);
|
||||
for (const char *key : kMixedKeys)
|
||||
CHECK(mixed_array_size(bundle.project_config, key) == 1);
|
||||
}
|
||||
}
|
||||
|
||||
// A mix is described by 1-based indices into the project's filament list, which Orca rebuilds
|
||||
// from the selected printer's snapshot (filament_%02u / filament_colors) at startup and on every
|
||||
// printer selection. Held anywhere but that same per-printer snapshot, the mixed arrays end up
|
||||
// indexing a filament list they were never saved against.
|
||||
TEST_CASE("Mixed-color filament metadata is snapshotted per printer, with its filament list", "[Preset][Bundle][FilamentMixer]")
|
||||
{
|
||||
PresetBundle bundle;
|
||||
// export_selections skips the built-in "Default Printer" placeholder entirely.
|
||||
add_inmemory_preset(bundle.printers, "Test Printer");
|
||||
bundle.printers.select_preset_by_name("Test Printer", true);
|
||||
bundle.set_num_filaments(2u, std::string("#FF0000"));
|
||||
bundle.project_config.option<ConfigOptionBools>("filament_is_mixed")->values = { false, true };
|
||||
bundle.project_config.option<ConfigOptionStrings>("filament_mixed_components")->values = { "", "1,2" };
|
||||
bundle.project_config.option<ConfigOptionStrings>("filament_mixed_sublayer_ratios")->values = { "", "0.5,0.5" };
|
||||
|
||||
AppConfig app_config;
|
||||
bundle.export_selections(app_config);
|
||||
|
||||
const std::string printer_name = bundle.printers.get_selected_preset_name();
|
||||
for (const char *key : kMixedKeys) {
|
||||
DYNAMIC_SECTION("per printer, not global: " << key) {
|
||||
CHECK(app_config.has_printer_setting(printer_name, key));
|
||||
CHECK_FALSE(app_config.has("presets", key));
|
||||
}
|
||||
}
|
||||
|
||||
SECTION("with the encoding load_selections reads back") {
|
||||
CHECK(app_config.get_printer_setting(printer_name, "filament_is_mixed") == "0,1");
|
||||
CHECK(app_config.get_printer_setting(printer_name, "filament_mixed_components") == "|1,2");
|
||||
CHECK(app_config.get_printer_setting(printer_name, "filament_mixed_sublayer_ratios") == "|0.5,0.5");
|
||||
}
|
||||
}
|
||||
|
||||
// The gradient curve is the one mixed array whose values contain '|' themselves — it separates the
|
||||
// control points — so it cannot be '|'-joined into the app config like its siblings without a
|
||||
// multi-point curve being split across filament slots on the way back in.
|
||||
TEST_CASE("A multi-point gradient curve survives the app-config snapshot", "[Preset][Bundle][FilamentMixer]")
|
||||
{
|
||||
const std::vector<std::string> curves = { "", "", "0,0|0.5,0.3|1,1" };
|
||||
|
||||
PresetBundle bundle;
|
||||
add_inmemory_preset(bundle.printers, "Test Printer");
|
||||
bundle.printers.select_preset_by_name("Test Printer", true);
|
||||
bundle.set_num_filaments(3u, std::string("#FF0000"));
|
||||
bundle.project_config.option<ConfigOptionStrings>("filament_mixed_gradient_curve")->values = curves;
|
||||
|
||||
AppConfig app_config;
|
||||
bundle.export_selections(app_config);
|
||||
|
||||
// Decoding the stored form returns the three slots intact, curve delimiters and all. A plain
|
||||
// '|' join would decode as five slots here instead of three.
|
||||
std::vector<std::string> decoded;
|
||||
REQUIRE(unescape_strings_cstyle(
|
||||
app_config.get_printer_setting(bundle.printers.get_selected_preset_name(), "filament_mixed_gradient_curve"), decoded));
|
||||
CHECK(decoded == curves);
|
||||
}
|
||||
|
||||
// A multi-tool printer sizes the filament list from its nozzle count. Mixed-color slots are extra
|
||||
// virtual filaments at the tail of that list with no nozzle of their own, so the count has to
|
||||
// allow for them: sizing to the nozzle count alone drops the project's mixes and strips every
|
||||
// painted facet above the new count.
|
||||
TEST_CASE("Sizing the filament list to a multi-tool nozzle count keeps mixed slots", "[Preset][Bundle][FilamentMixer]")
|
||||
{
|
||||
// The 5-slot layout of a 4-tool project carrying one mix of filaments 2 and 3.
|
||||
const size_t nozzle_count = 4;
|
||||
PresetBundle bundle;
|
||||
bundle.set_num_filaments(5u, std::string("#FF0000"));
|
||||
bundle.project_config.option<ConfigOptionBools>("filament_is_mixed")->values =
|
||||
{ false, false, false, false, true };
|
||||
bundle.project_config.option<ConfigOptionStrings>("filament_mixed_components")->values =
|
||||
{ "", "", "", "", "2,3" };
|
||||
|
||||
REQUIRE(bundle.num_mixed_filaments() == 1);
|
||||
|
||||
SECTION("nozzle count plus the mixed slots preserves the mix") {
|
||||
bundle.set_num_filaments(nozzle_count + bundle.num_mixed_filaments(), std::string("#00FF00"));
|
||||
|
||||
CHECK(bundle.filament_presets.size() == 5);
|
||||
CHECK(bundle.num_mixed_filaments() == 1);
|
||||
CHECK(bundle.is_mixed_filament(4));
|
||||
CHECK(bundle.project_config.option<ConfigOptionStrings>("filament_mixed_components")->values[4] == "2,3");
|
||||
}
|
||||
|
||||
SECTION("the nozzle count alone is what truncated it away") {
|
||||
bundle.set_num_filaments(nozzle_count, std::string("#00FF00"));
|
||||
|
||||
CHECK(bundle.filament_presets.size() == nozzle_count);
|
||||
CHECK(bundle.num_mixed_filaments() == 0);
|
||||
}
|
||||
}
|
||||
|
||||
// The nozzle-count top-up in update_multi_material_filament_presets() grows filament_presets on
|
||||
// its own, so a physical count derived from that list reports a slot no per-filament array has
|
||||
// yet. That is what made the extruder-count handler conclude there was nothing to add and leave
|
||||
// the new sidebar combo with no colour to draw.
|
||||
TEST_CASE("The physical filament count is not fooled by a lone filament_presets top-up", "[Preset][Bundle][FilamentMixer]")
|
||||
{
|
||||
PresetBundle bundle;
|
||||
|
||||
SECTION("no mixed slots") {
|
||||
bundle.set_num_filaments(4u, std::string("#FF0000"));
|
||||
bundle.printers.get_edited_preset().config.option<ConfigOptionFloats>("nozzle_diameter", true)->values =
|
||||
{ 0.4, 0.4, 0.4, 0.4, 0.4 };
|
||||
bundle.update_multi_material_filament_presets();
|
||||
|
||||
REQUIRE(bundle.filament_presets.size() == 5); // the top-up moved this list on its own
|
||||
REQUIRE(bundle.project_config.option<ConfigOptionStrings>("filament_colour")->values.size() == 4);
|
||||
CHECK(bundle.num_physical_filaments() == 4);
|
||||
}
|
||||
|
||||
SECTION("behind a mixed tail") {
|
||||
bundle.set_num_filaments(5u, std::string("#FF0000"));
|
||||
bundle.project_config.option<ConfigOptionBools>("filament_is_mixed")->values =
|
||||
{ false, false, false, false, true };
|
||||
bundle.printers.get_edited_preset().config.option<ConfigOptionFloats>("nozzle_diameter", true)->values =
|
||||
{ 0.4, 0.4, 0.4, 0.4, 0.4, 0.4 };
|
||||
bundle.update_multi_material_filament_presets();
|
||||
|
||||
REQUIRE(bundle.filament_presets.size() == 6);
|
||||
REQUIRE(bundle.project_config.option<ConfigOptionStrings>("filament_colour")->values.size() == 5);
|
||||
CHECK(bundle.num_physical_filaments() == 4);
|
||||
CHECK(bundle.num_mixed_filaments() == 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Which slots are new is a fact about the per-filament arrays, not about filament_presets, for the
|
||||
// same reason. Keyed off the wrong one, a freshly opened slot silently keeps filament 1's colour.
|
||||
TEST_CASE("New filament colours are placed by array position", "[Preset][Bundle][FilamentMixer]")
|
||||
{
|
||||
PresetBundle bundle;
|
||||
bundle.set_num_filaments(4u, std::string("#FF0000"));
|
||||
bundle.printers.get_edited_preset().config.option<ConfigOptionFloats>("nozzle_diameter", true)->values =
|
||||
{ 0.4, 0.4, 0.4, 0.4, 0.4 };
|
||||
bundle.update_multi_material_filament_presets();
|
||||
REQUIRE(bundle.filament_presets.size() == 5);
|
||||
REQUIRE(bundle.project_config.option<ConfigOptionStrings>("filament_colour")->values.size() == 4);
|
||||
|
||||
// The call Sidebar::add_custom_filament makes once the extruder count opens a slot.
|
||||
bundle.set_num_filaments(5u, std::string("#00FF00"));
|
||||
|
||||
const auto &colours = bundle.project_config.option<ConfigOptionStrings>("filament_colour")->values;
|
||||
REQUIRE(colours.size() == 5);
|
||||
CHECK(colours[4] == "#00FF00"); // not colours[0], which resize() would have padded with
|
||||
}
|
||||
|
||||
// The mixed-slot flags are written into the app config on exit and read back on the next start.
|
||||
// If the read side loses them the slots survive as filaments but stop being mixes, so the project
|
||||
// comes back with the mix showing as an ordinary physical filament.
|
||||
TEST_CASE("A saved mix is still a mix after an app restart", "[Preset][Bundle][FilamentMixer]")
|
||||
{
|
||||
AppConfig app_config;
|
||||
|
||||
// Last session: a 4-tool project carrying one mix of filaments 2 and 3 at the tail.
|
||||
{
|
||||
PresetBundle bundle;
|
||||
add_inmemory_preset(bundle.printers, "Test Printer");
|
||||
bundle.printers.select_preset_by_name("Test Printer", true);
|
||||
add_inmemory_preset(bundle.filaments, "Test Filament");
|
||||
bundle.filaments.select_preset_by_name("Test Filament", true);
|
||||
bundle.set_num_filaments(5u, std::string("#FF0000"));
|
||||
bundle.filament_presets.assign(5, "Test Filament");
|
||||
bundle.project_config.option<ConfigOptionBools>("filament_is_mixed")->values =
|
||||
{ false, false, false, false, true };
|
||||
bundle.project_config.option<ConfigOptionStrings>("filament_mixed_components")->values =
|
||||
{ "", "", "", "", "2,3" };
|
||||
bundle.export_selections(app_config);
|
||||
|
||||
REQUIRE(app_config.get_printer_setting("Test Printer", "filament_is_mixed") == "0,0,0,0,1");
|
||||
}
|
||||
|
||||
// This session.
|
||||
PresetBundle bundle;
|
||||
add_inmemory_preset(bundle.printers, "Test Printer");
|
||||
add_inmemory_preset(bundle.filaments, "Test Filament");
|
||||
bundle.load_selections(app_config);
|
||||
|
||||
CHECK(bundle.filament_presets.size() == 5);
|
||||
CHECK(bundle.num_mixed_filaments() == 1);
|
||||
CHECK(bundle.is_mixed_filament(4));
|
||||
CHECK(bundle.project_config.option<ConfigOptionStrings>("filament_mixed_components")->values[4] == "2,3");
|
||||
}
|
||||
|
||||
// The same restart, on the printer shape that actually shows the bug: a 4-tool changer whose
|
||||
// saved filament list is one longer than its nozzle count, because the extra slot is the mix.
|
||||
TEST_CASE("A saved mix survives a restart on a multi-tool printer", "[Preset][Bundle][FilamentMixer]")
|
||||
{
|
||||
auto make_toolchanger = [](PresetBundle &bundle) -> Preset & {
|
||||
Preset &p = add_inmemory_preset(bundle.printers, "Tool Changer");
|
||||
p.config.option<ConfigOptionFloats>("nozzle_diameter", true)->values = { 0.4, 0.4, 0.4, 0.4 };
|
||||
p.config.option<ConfigOptionBool>("single_extruder_multi_material", true)->value = false;
|
||||
return p;
|
||||
};
|
||||
|
||||
AppConfig app_config;
|
||||
{
|
||||
PresetBundle bundle;
|
||||
make_toolchanger(bundle);
|
||||
bundle.printers.select_preset_by_name("Tool Changer", true);
|
||||
add_inmemory_preset(bundle.filaments, "Test Filament");
|
||||
bundle.filaments.select_preset_by_name("Test Filament", true);
|
||||
bundle.set_num_filaments(5u, std::string("#FF0000"));
|
||||
bundle.filament_presets.assign(5, "Test Filament");
|
||||
bundle.project_config.option<ConfigOptionBools>("filament_is_mixed")->values =
|
||||
{ false, false, false, false, true };
|
||||
bundle.project_config.option<ConfigOptionStrings>("filament_mixed_components")->values =
|
||||
{ "", "", "", "", "1,2" };
|
||||
bundle.export_selections(app_config);
|
||||
REQUIRE(app_config.get_printer_setting("Tool Changer", "filament_is_mixed") == "0,0,0,0,1");
|
||||
}
|
||||
|
||||
PresetBundle bundle;
|
||||
make_toolchanger(bundle);
|
||||
add_inmemory_preset(bundle.filaments, "Test Filament");
|
||||
bundle.load_selections(app_config);
|
||||
|
||||
CHECK(bundle.filament_presets.size() == 5);
|
||||
CHECK(bundle.num_mixed_filaments() == 1);
|
||||
CHECK(bundle.is_mixed_filament(4));
|
||||
|
||||
SECTION("and through the GUI startup calls that follow it") {
|
||||
// GUI_App::load_current_presets sizes the list for a non-SEMM printer, growing only.
|
||||
const size_t target = 4u + bundle.num_mixed_filaments();
|
||||
if (target > bundle.filament_presets.size())
|
||||
bundle.set_num_filaments(target);
|
||||
CHECK(bundle.num_mixed_filaments() == 1);
|
||||
|
||||
// TabPrinter::extruders_count_changed.
|
||||
bundle.on_extruders_count_changed(4);
|
||||
CHECK(bundle.num_mixed_filaments() == 1);
|
||||
|
||||
// Tab::select_preset re-reads the snapshot when remember_printer_config is on.
|
||||
bundle.update_selections(app_config);
|
||||
CHECK(bundle.filament_presets.size() == 5);
|
||||
CHECK(bundle.num_mixed_filaments() == 1);
|
||||
CHECK(bundle.is_mixed_filament(4));
|
||||
}
|
||||
}
|
||||
|
||||
// The startup sizing in GUI_App::load_current_presets targets the nozzle count plus the mixes.
|
||||
// That is a floor, never a ceiling: set_num_filaments() trims at the raw tail, which is exactly
|
||||
// where the mixes live, so applying the target to a longer list deletes them. A list longer than
|
||||
// the target is reachable - raising the extruder count without saving the printer preset leaves
|
||||
// the extra physical slot behind on the next start - so the startup sizing must only ever grow.
|
||||
TEST_CASE("Sizing down to the nozzle count plus mixes is what eats the mixed tail", "[Preset][Bundle][FilamentMixer]")
|
||||
{
|
||||
// 5 physical + 1 mix, on a printer preset still reporting 4 nozzles.
|
||||
const size_t nozzle_count = 4;
|
||||
PresetBundle bundle;
|
||||
bundle.set_num_filaments(6u, std::string("#FF0000"));
|
||||
bundle.project_config.option<ConfigOptionBools>("filament_is_mixed")->values =
|
||||
{ false, false, false, false, false, true };
|
||||
bundle.project_config.option<ConfigOptionStrings>("filament_mixed_components")->values =
|
||||
{ "", "", "", "", "", "1,2" };
|
||||
REQUIRE(bundle.num_physical_filaments() == 5);
|
||||
|
||||
const size_t target = nozzle_count + bundle.num_mixed_filaments();
|
||||
REQUIRE(target < bundle.filament_presets.size());
|
||||
|
||||
SECTION("applied as written, the mix is gone and every slot reads physical") {
|
||||
bundle.set_num_filaments(target);
|
||||
|
||||
CHECK(bundle.filament_presets.size() == target);
|
||||
CHECK(bundle.num_mixed_filaments() == 0);
|
||||
CHECK(bundle.num_physical_filaments() == target);
|
||||
}
|
||||
|
||||
SECTION("applied as a floor, the mix is left alone") {
|
||||
if (target > bundle.filament_presets.size())
|
||||
bundle.set_num_filaments(target);
|
||||
|
||||
CHECK(bundle.filament_presets.size() == 6);
|
||||
CHECK(bundle.num_mixed_filaments() == 1);
|
||||
CHECK(bundle.is_mixed_filament(5));
|
||||
CHECK(bundle.project_config.option<ConfigOptionStrings>("filament_mixed_components")->values[5] == "1,2");
|
||||
}
|
||||
}
|
||||
|
||||
93
tests/libslic3r/test_step.cpp
Normal file
93
tests/libslic3r/test_step.cpp
Normal file
@@ -0,0 +1,93 @@
|
||||
#include <catch2/catch_all.hpp>
|
||||
|
||||
#include <boost/nowide/fstream.hpp>
|
||||
|
||||
#include "libslic3r/Model.hpp"
|
||||
#include "libslic3r/Format/STEP.hpp"
|
||||
#include "test_utils.hpp"
|
||||
|
||||
using namespace Slic3r;
|
||||
|
||||
static void write_step_line(const std::string &path, const std::string &line)
|
||||
{
|
||||
boost::nowide::ofstream file(path, std::ios::binary);
|
||||
file << "ISO-10303-21;\n" << line << "\nEND-ISO-10303-21;\n";
|
||||
}
|
||||
|
||||
// preprocess() hands back the input path unless it transcoded into a temporary.
|
||||
static std::string preprocess_result(const std::string &line)
|
||||
{
|
||||
ScopedSlic3rTemporaryDir scratch;
|
||||
|
||||
ScopedTemporaryFile step(".step");
|
||||
write_step_line(step.string(), line);
|
||||
|
||||
std::string output_path;
|
||||
StepPreProcessor preprocessor;
|
||||
REQUIRE(preprocessor.preprocess(step.string().c_str(), output_path));
|
||||
|
||||
return output_path == step.string() ? "untouched" : "transcoded";
|
||||
}
|
||||
|
||||
// data/utf8_part_names.step is three boxes written by OCCT's own STEP writer, whose
|
||||
// PRODUCT names were then patched to raw UTF-8. Most CAD exporters write non-ASCII names
|
||||
// that way rather than in the \X2\ escape form. The third part is ASCII, as a control.
|
||||
TEST_CASE("Part names with multi-byte UTF-8 survive import", "[Step]")
|
||||
{
|
||||
// getNamedSolids() replaces a name that isUtf8() rejects with a running number.
|
||||
const std::string path = TEST_DATA_DIR PATH_SEPARATOR "utf8_part_names.step";
|
||||
|
||||
Model model;
|
||||
bool cancel = false;
|
||||
Step step(path); // no isUtf8Fn, matching how Model::read_from_step builds it
|
||||
|
||||
REQUIRE(step.load() == Step::Step_Status::LOAD_SUCCESS);
|
||||
REQUIRE(step.mesh(&model, cancel, false) == Step::Step_Status::MESH_SUCCESS);
|
||||
|
||||
REQUIRE(model.objects.size() == 1);
|
||||
const ModelObject *object = model.objects.front();
|
||||
REQUIRE(object->volumes.size() == 3);
|
||||
// "ce" is split off, or the hex escape would swallow it as further hex digits.
|
||||
CHECK(object->volumes[0]->name == "pi\xC3\xA8" "ce");
|
||||
CHECK(object->volumes[1]->name == "Geh\xC3\xA4use");
|
||||
CHECK(object->volumes[2]->name == "bracket");
|
||||
}
|
||||
|
||||
TEST_CASE("isUtf8 recognises two, three and four byte sequences", "[Step]")
|
||||
{
|
||||
CHECK(StepPreProcessor::isUtf8("\xC3\xA9")); // U+00E9
|
||||
CHECK(StepPreProcessor::isUtf8("\xE4\xB8\xAD")); // U+4E2D
|
||||
CHECK(StepPreProcessor::isUtf8("\xF0\x9F\x94\xA9")); // U+1F529
|
||||
CHECK_FALSE(StepPreProcessor::isUtf8("\x81\x30")); // 0x81 is not a lead byte
|
||||
CHECK_FALSE(StepPreProcessor::isUtf8("\xC3")); // truncated sequence
|
||||
}
|
||||
|
||||
// The only caller of isGBK is preprocess(), which nothing calls today.
|
||||
TEST_CASE("Encoding detection decides whether a step file is transcoded", "[Step]")
|
||||
{
|
||||
SECTION("UTF-8, so left alone")
|
||||
{
|
||||
// A two byte sequence also satisfies every GBK range, so misdetecting it as
|
||||
// not-UTF-8 sends it to be transcoded.
|
||||
const std::string sequence = GENERATE(std::string("\xC3\xA9"), // U+00E9
|
||||
std::string("\xE4\xB8\xAD"), // U+4E2D
|
||||
std::string("\xF0\x9F\x94\xA9")); // U+1F529
|
||||
|
||||
CHECK(preprocess_result("NAME('" + sequence + "');") == "untouched");
|
||||
}
|
||||
|
||||
SECTION("neither UTF-8 nor GBK, so left alone")
|
||||
{
|
||||
// 0x81 is not a UTF-8 lead byte, and 0x30 is below the 0x40 floor for a GBK trail.
|
||||
CHECK(preprocess_result("NAME('\x81\x30');") == "untouched");
|
||||
}
|
||||
|
||||
SECTION("GBK, so transcoded")
|
||||
{
|
||||
// U+554A in GBK, whose lead byte is not valid UTF-8. Pins the other direction,
|
||||
// since a detector that never reports GBK would pass every case above.
|
||||
CHECK(preprocess_result("NAME('\xB0\xA1');") == "transcoded");
|
||||
}
|
||||
|
||||
SECTION("plain ASCII, so left alone") { CHECK(preprocess_result("NAME('bracket');") == "untouched"); }
|
||||
}
|
||||
125
tests/libslic3r/test_triangle_selector.cpp
Normal file
125
tests/libslic3r/test_triangle_selector.cpp
Normal file
@@ -0,0 +1,125 @@
|
||||
#include <catch2/catch_all.hpp>
|
||||
|
||||
#include "libslic3r/TriangleSelector.hpp"
|
||||
#include "libslic3r/TriangleMesh.hpp"
|
||||
|
||||
using namespace Slic3r;
|
||||
|
||||
// A sphere gives well over ExtruderMax original facets, so every extruder state can be assigned
|
||||
// to a facet of its own without any splitting getting in the way.
|
||||
static TriangleMesh test_mesh() { return make_sphere(5., 2 * PI / 24); }
|
||||
|
||||
// Read the nibble_idx-th 4-bit group of a serialized bitstream, least significant bit first.
|
||||
static int nibble_at(const std::vector<bool> &bitstream, size_t nibble_idx)
|
||||
{
|
||||
int n = 0;
|
||||
for (size_t bit = 0; bit < 4; ++bit)
|
||||
n |= int(bitstream[nibble_idx * 4 + bit]) << bit;
|
||||
return n;
|
||||
}
|
||||
|
||||
TEST_CASE("Every extruder state survives a serialize/deserialize round trip", "[TriangleSelector]")
|
||||
{
|
||||
const TriangleMesh mesh = test_mesh();
|
||||
const int max_state = int(EnforcerBlockerType::ExtruderMax);
|
||||
REQUIRE(int(mesh.its.indices.size()) >= max_state);
|
||||
|
||||
TriangleSelector selector(mesh);
|
||||
for (int state = 1; state <= max_state; ++state)
|
||||
selector.set_facet(state - 1, EnforcerBlockerType(state));
|
||||
|
||||
TriangleSelector restored(mesh);
|
||||
restored.deserialize(selector.serialize());
|
||||
|
||||
for (int state = 1; state <= max_state; ++state) {
|
||||
INFO("Extruder " << state);
|
||||
REQUIRE(restored.has_facets(EnforcerBlockerType(state)));
|
||||
REQUIRE(restored.num_facets(EnforcerBlockerType(state)) == 1);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("Serialized data reports the extruder states it uses", "[TriangleSelector]")
|
||||
{
|
||||
const TriangleMesh mesh = test_mesh();
|
||||
TriangleSelector selector(mesh);
|
||||
selector.set_facet(0, EnforcerBlockerType::Extruder16);
|
||||
selector.set_facet(1, EnforcerBlockerType::Extruder32);
|
||||
|
||||
const TriangleSelector::TriangleSplittingData data = selector.serialize();
|
||||
|
||||
REQUIRE(data.used_states.size() == size_t(EnforcerBlockerType::ExtruderMax) + 1);
|
||||
REQUIRE(data.used_states[size_t(EnforcerBlockerType::Extruder16)]);
|
||||
REQUIRE(data.used_states[size_t(EnforcerBlockerType::Extruder32)]);
|
||||
REQUIRE_FALSE(data.used_states[size_t(EnforcerBlockerType::Extruder17)]);
|
||||
|
||||
SECTION("used_states recomputed from the bitstream agrees") {
|
||||
TriangleSelector::TriangleSplittingData recomputed = data;
|
||||
recomputed.reset_used_states();
|
||||
recomputed.update_used_states(0);
|
||||
REQUIRE(recomputed.used_states == data.used_states);
|
||||
}
|
||||
|
||||
SECTION("has_facets on the raw data agrees") {
|
||||
REQUIRE(TriangleSelector::has_facets(data, EnforcerBlockerType::Extruder32));
|
||||
REQUIRE_FALSE(TriangleSelector::has_facets(data, EnforcerBlockerType::Extruder17));
|
||||
}
|
||||
}
|
||||
|
||||
// States 3..17 must keep the pre-existing encoding ("11" prefix plus one nibble of state-3) so
|
||||
// projects written by older builds stay readable and newly written ones stay readable by them.
|
||||
TEST_CASE("Extruder states up to 17 keep the single-nibble encoding", "[TriangleSelector]")
|
||||
{
|
||||
const int state = GENERATE(3, 8, 16, 17);
|
||||
|
||||
TriangleSelector selector(test_mesh());
|
||||
selector.set_facet(0, EnforcerBlockerType(state));
|
||||
const std::vector<bool> bitstream = selector.serialize().bitstream;
|
||||
|
||||
INFO("Extruder " << state);
|
||||
// Two nibbles: the "11"-prefixed leaf code, then the state itself.
|
||||
REQUIRE(bitstream.size() == 8);
|
||||
REQUIRE(nibble_at(bitstream, 0) == 0b1100);
|
||||
REQUIRE(nibble_at(bitstream, 1) == state - 3);
|
||||
}
|
||||
|
||||
// States 18 and above set the state nibble to 0b1111 and carry (state-18) in one more nibble.
|
||||
TEST_CASE("Extruder states above 17 are encoded in a second nibble", "[TriangleSelector]")
|
||||
{
|
||||
const int state = GENERATE(18, 25, 32);
|
||||
|
||||
TriangleSelector selector(test_mesh());
|
||||
selector.set_facet(0, EnforcerBlockerType(state));
|
||||
const std::vector<bool> bitstream = selector.serialize().bitstream;
|
||||
|
||||
INFO("Extruder " << state);
|
||||
REQUIRE(bitstream.size() == 12);
|
||||
REQUIRE(nibble_at(bitstream, 0) == 0b1100);
|
||||
REQUIRE(nibble_at(bitstream, 1) == 0b1111);
|
||||
REQUIRE(nibble_at(bitstream, 2) == state - 18);
|
||||
}
|
||||
|
||||
// Model.cpp writes these hex strings into the 3MF for colored mesh imports; the selector must
|
||||
// decode exactly the states CONST_FILAMENTS assigns to them.
|
||||
TEST_CASE("Extruder states match the CONST_FILAMENTS hex encoding", "[TriangleSelector]")
|
||||
{
|
||||
struct Case { const char *hex; int state; };
|
||||
const auto c = GENERATE(values<Case>({
|
||||
{"8", 2}, {"0C", 3}, {"DC", 16}, {"EC", 17}, {"0FC", 18}, {"EFC", 32},
|
||||
}));
|
||||
|
||||
// get_triangle_as_string emits the nibbles most significant first, so read the hex backwards.
|
||||
const std::string hex = c.hex;
|
||||
std::vector<bool> bitstream;
|
||||
for (auto it = hex.rbegin(); it != hex.rend(); ++it) {
|
||||
const int nibble = *it >= 'A' ? (*it - 'A' + 10) : (*it - '0');
|
||||
for (int bit = 0; bit < 4; ++bit)
|
||||
bitstream.push_back((nibble >> bit) & 1);
|
||||
}
|
||||
|
||||
TriangleSelector::TriangleSplittingData data;
|
||||
data.triangles_to_split.emplace_back(0, 0);
|
||||
data.bitstream = bitstream;
|
||||
|
||||
INFO("Hex " << c.hex << " -> extruder " << c.state);
|
||||
REQUIRE(TriangleSelector::has_facets(data, EnforcerBlockerType(c.state)));
|
||||
}
|
||||
@@ -2,6 +2,7 @@ get_filename_component(_TEST_NAME ${CMAKE_CURRENT_LIST_DIR} NAME)
|
||||
add_executable(${_TEST_NAME}_tests
|
||||
${_TEST_NAME}_tests_main.cpp
|
||||
test_dev_mapping.cpp
|
||||
test_filament_bitmap_utils.cpp
|
||||
test_device_progress.cpp
|
||||
test_network_versions.cpp
|
||||
test_action_source.cpp
|
||||
|
||||
256
tests/slic3rutils/test_filament_bitmap_utils.cpp
Normal file
256
tests/slic3rutils/test_filament_bitmap_utils.cpp
Normal file
@@ -0,0 +1,256 @@
|
||||
// recompute_mixed_slot_colors lives in libslic3r_gui; this is the only suite that links it.
|
||||
// Same Windows include prologue as test_dev_mapping.cpp (wx pulls in <windows.h>; keep
|
||||
// WIN32_LEAN_AND_MEAN / NOMINMAX ahead of the Catch2 headers).
|
||||
#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 <cmath>
|
||||
|
||||
#include <wx/colour.h>
|
||||
#include <wx/string.h>
|
||||
|
||||
#include "libslic3r/FilamentMixer.hpp"
|
||||
#include "libslic3r/PrintConfig.hpp"
|
||||
#include "slic3r/GUI/FilamentBitmapUtils.hpp"
|
||||
|
||||
using namespace Slic3r;
|
||||
using Slic3r::GUI::recompute_mixed_slot_colors;
|
||||
|
||||
namespace {
|
||||
|
||||
// Two physical slots (1 = red, 2 = blue) and mixed slot 3 built from them.
|
||||
DynamicPrintConfig mixed_config(const std::string& components = "1,2", const std::string& ratios = "0.5,0.5")
|
||||
{
|
||||
DynamicPrintConfig cfg;
|
||||
cfg.set_key_value("filament_is_mixed", new ConfigOptionBools({false, false, true}));
|
||||
cfg.set_key_value("filament_mixed_components", new ConfigOptionStrings({"", "", components}));
|
||||
cfg.set_key_value("filament_mixed_sublayer_ratios", new ConfigOptionStrings({"", "", ratios}));
|
||||
cfg.set_key_value("filament_mixed_gradient", new ConfigOptionBools({false, false, false}));
|
||||
cfg.set_key_value("filament_colour", new ConfigOptionStrings({"#FF0000", "#0000FF", "#000000"}));
|
||||
return cfg;
|
||||
}
|
||||
|
||||
wxColour expected_blend(const std::vector<std::string>& hex, const std::vector<int>& weights)
|
||||
{
|
||||
return wxColour(wxString(blend_color_multi(hex, weights)));
|
||||
}
|
||||
|
||||
// Compare channels one at a time so a failure names the channel.
|
||||
void require_same_rgb(const wxColour& actual, const wxColour& expected)
|
||||
{
|
||||
REQUIRE(int(actual.Red()) == int(expected.Red()));
|
||||
REQUIRE(int(actual.Green()) == int(expected.Green()));
|
||||
REQUIRE(int(actual.Blue()) == int(expected.Blue()));
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("recompute_mixed_slot_colors blends a mixed slot from its components' colours", "[FilamentBitmapUtils]")
|
||||
{
|
||||
std::vector<wxColour> colors{wxColour(255, 0, 0), wxColour(0, 0, 255)};
|
||||
recompute_mixed_slot_colors(colors, mixed_config());
|
||||
|
||||
REQUIRE(colors.size() == 3);
|
||||
require_same_rgb(colors[2], expected_blend({"#FF0000", "#0000FF"}, {5000, 5000}));
|
||||
REQUIRE(int(colors[2].Alpha()) == 255);
|
||||
// Physical slots are left alone.
|
||||
require_same_rgb(colors[0], wxColour(255, 0, 0));
|
||||
require_same_rgb(colors[1], wxColour(0, 0, 255));
|
||||
}
|
||||
|
||||
TEST_CASE("recompute_mixed_slot_colors leaves the colours alone without mixed slots", "[FilamentBitmapUtils]")
|
||||
{
|
||||
std::vector<wxColour> colors{wxColour(255, 0, 0), wxColour(0, 0, 255)};
|
||||
|
||||
SECTION("no mixed keys at all") {
|
||||
recompute_mixed_slot_colors(colors, DynamicPrintConfig{});
|
||||
}
|
||||
SECTION("mixed flags present but all false") {
|
||||
DynamicPrintConfig cfg;
|
||||
cfg.set_key_value("filament_is_mixed", new ConfigOptionBools({false, false}));
|
||||
cfg.set_key_value("filament_mixed_components", new ConfigOptionStrings({"", ""}));
|
||||
recompute_mixed_slot_colors(colors, cfg);
|
||||
}
|
||||
REQUIRE(colors.size() == 2);
|
||||
require_same_rgb(colors[0], wxColour(255, 0, 0));
|
||||
require_same_rgb(colors[1], wxColour(0, 0, 255));
|
||||
}
|
||||
|
||||
TEST_CASE("recompute_mixed_slot_colors falls back to grey for a broken component reference", "[FilamentBitmapUtils]")
|
||||
{
|
||||
const wxColour grey(128, 128, 128, 255);
|
||||
std::vector<wxColour> colors{wxColour(255, 0, 0), wxColour(0, 0, 255)};
|
||||
|
||||
SECTION("dangling component id") {
|
||||
recompute_mixed_slot_colors(colors, mixed_config("1,9"));
|
||||
}
|
||||
SECTION("empty component list") {
|
||||
recompute_mixed_slot_colors(colors, mixed_config(""));
|
||||
}
|
||||
REQUIRE(colors.size() == 3);
|
||||
require_same_rgb(colors[2], grey);
|
||||
}
|
||||
|
||||
TEST_CASE("recompute_mixed_slot_colors uses the project colour when a slot colour is unset", "[FilamentBitmapUtils]")
|
||||
{
|
||||
// Slot 2 carries no colour in the vector; filament_colour[1] = "#0000FF" is used instead.
|
||||
std::vector<wxColour> colors{wxColour(255, 0, 0), wxColour()};
|
||||
recompute_mixed_slot_colors(colors, mixed_config());
|
||||
require_same_rgb(colors[2], expected_blend({"#FF0000", "#0000FF"}, {5000, 5000}));
|
||||
}
|
||||
|
||||
TEST_CASE("recompute_mixed_slot_colors blends a gradient slot from its end points only", "[FilamentBitmapUtils]")
|
||||
{
|
||||
DynamicPrintConfig cfg;
|
||||
cfg.set_key_value("filament_is_mixed", new ConfigOptionBools({false, false, false, true}));
|
||||
cfg.set_key_value("filament_mixed_components", new ConfigOptionStrings({"", "", "", "1,2,3"}));
|
||||
cfg.set_key_value("filament_mixed_sublayer_ratios", new ConfigOptionStrings({"", "", "", "0.2,0.3,0.5"}));
|
||||
cfg.set_key_value("filament_mixed_gradient", new ConfigOptionBools({false, false, false, true}));
|
||||
cfg.set_key_value("filament_colour", new ConfigOptionStrings({"#FF0000", "#00FF00", "#0000FF", "#000000"}));
|
||||
|
||||
std::vector<wxColour> colors{wxColour(255, 0, 0), wxColour(0, 255, 0), wxColour(0, 0, 255)};
|
||||
recompute_mixed_slot_colors(colors, cfg);
|
||||
|
||||
REQUIRE(colors.size() == 4);
|
||||
require_same_rgb(colors[3], expected_blend({"#FF0000", "#0000FF"}, {5000, 5000}));
|
||||
}
|
||||
|
||||
TEST_CASE("recompute_mixed_slot_colors honours the configured ratios and is idempotent", "[FilamentBitmapUtils]")
|
||||
{
|
||||
std::vector<wxColour> colors{wxColour(255, 0, 0), wxColour(0, 0, 255)};
|
||||
const DynamicPrintConfig cfg = mixed_config("1,2", "0.7,0.3");
|
||||
recompute_mixed_slot_colors(colors, cfg);
|
||||
const wxColour first = colors[2];
|
||||
// The configured 70/30 ratio must reach the blend (it is not the equal-share default).
|
||||
require_same_rgb(first, expected_blend({"#FF0000", "#0000FF"}, {7000, 3000}));
|
||||
REQUIRE(first != expected_blend({"#FF0000", "#0000FF"}, {5000, 5000}));
|
||||
recompute_mixed_slot_colors(colors, cfg);
|
||||
require_same_rgb(colors[2], first);
|
||||
}
|
||||
|
||||
// --- mixed_gradient_ramp / sample_gradient_ramp -----------------------------------------
|
||||
//
|
||||
// The ramp is what every mixed filament swatch is drawn from, so these pin the three things
|
||||
// a plain fade between two endpoint colours cannot express: the reserved ratio band, the
|
||||
// component order, and the custom curve.
|
||||
|
||||
namespace {
|
||||
|
||||
// Slot 3 (index 2) is a gradient mix of physical slots 1 (red) and 2 (blue).
|
||||
DynamicPrintConfig gradient_config(const std::string& components = "1,2",
|
||||
const std::string& range = "0.9,0.1",
|
||||
const std::string& curve = "")
|
||||
{
|
||||
DynamicPrintConfig cfg;
|
||||
cfg.set_key_value("filament_is_mixed", new ConfigOptionBools({false, false, true}));
|
||||
cfg.set_key_value("filament_mixed_components", new ConfigOptionStrings({"", "", components}));
|
||||
cfg.set_key_value("filament_mixed_gradient", new ConfigOptionBools({false, false, true}));
|
||||
cfg.set_key_value("filament_mixed_gradient_range", new ConfigOptionStrings({"", "", range}));
|
||||
cfg.set_key_value("filament_mixed_gradient_curve", new ConfigOptionStrings({"", "", curve}));
|
||||
cfg.set_key_value("filament_colour", new ConfigOptionStrings({"#FF0000", "#0000FF", "#000000"}));
|
||||
return cfg;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("mixed_gradient_ramp runs bottom to top and never reaches a pure component", "[FilamentBitmapUtils]")
|
||||
{
|
||||
// range "0.9,0.1": component 1 (red) is the majority at the bottom and the minority at the top.
|
||||
const auto ramp = Slic3r::GUI::mixed_gradient_ramp(gradient_config(), 2, 16);
|
||||
REQUIRE(ramp.size() == 16);
|
||||
|
||||
// Neither end is the pure component colour - the slicer clamps the blend to
|
||||
// [kGradientMinRatio, kGradientMaxRatio], which a fade between the pure colours would ignore.
|
||||
REQUIRE(ramp.front() != wxColour(255, 0, 0));
|
||||
REQUIRE(ramp.back() != wxColour(0, 0, 255));
|
||||
|
||||
// Red falls and blue rises monotonically from bottom to top.
|
||||
for (size_t i = 1; i < ramp.size(); ++i) {
|
||||
REQUIRE(int(ramp[i].Red()) <= int(ramp[i - 1].Red()));
|
||||
REQUIRE(int(ramp[i].Blue()) >= int(ramp[i - 1].Blue()));
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("mixed_gradient_ramp follows the range's direction rather than the component order", "[FilamentBitmapUtils]")
|
||||
{
|
||||
const auto rising = Slic3r::GUI::mixed_gradient_ramp(gradient_config("1,2", "0.1,0.9"), 2, 16);
|
||||
const auto falling = Slic3r::GUI::mixed_gradient_ramp(gradient_config("1,2", "0.9,0.1"), 2, 16);
|
||||
REQUIRE(rising.size() == 16);
|
||||
REQUIRE(falling.size() == 16);
|
||||
|
||||
// "0.1,0.9" starts blue-heavy at the bottom; "0.9,0.1" starts red-heavy. Reversing the
|
||||
// range must reverse the ramp, which endpoint colours ordered by HSV cannot express.
|
||||
REQUIRE(int(rising.front().Blue()) > int(rising.front().Red()));
|
||||
REQUIRE(int(falling.front().Red()) > int(falling.front().Blue()));
|
||||
require_same_rgb(rising.front(), falling.back());
|
||||
}
|
||||
|
||||
TEST_CASE("mixed_gradient_ramp bends with a custom curve", "[FilamentBitmapUtils]")
|
||||
{
|
||||
// Component 1 holds near its maximum for the first half, then drops - a shape a straight
|
||||
// fade between two endpoints cannot draw.
|
||||
const auto curved = Slic3r::GUI::mixed_gradient_ramp(
|
||||
gradient_config("1,2", "0.9,0.1", "0,0.9|0.5,0.85|1,0.1"), 2, 16);
|
||||
const auto linear = Slic3r::GUI::mixed_gradient_ramp(gradient_config("1,2", "0.9,0.1"), 2, 16);
|
||||
REQUIRE(curved.size() == 16);
|
||||
|
||||
// The curve holds component 1 high through the lower half, so every band up to mid height
|
||||
// is at least as red as the straight fade and mid height is strictly redder.
|
||||
for (size_t i = 0; i <= curved.size() / 2; ++i)
|
||||
REQUIRE(int(curved[i].Red()) >= int(linear[i].Red()));
|
||||
REQUIRE(int(curved[curved.size() / 2].Red()) > int(linear[linear.size() / 2].Red()));
|
||||
// It still ends blue-dominant, like the straight fade.
|
||||
REQUIRE(int(curved.back().Blue()) > int(curved.back().Red()));
|
||||
}
|
||||
|
||||
TEST_CASE("mixed_gradient_ramp is empty for anything but a two-component gradient slot", "[FilamentBitmapUtils]")
|
||||
{
|
||||
SECTION("slot is not mixed") {
|
||||
REQUIRE(Slic3r::GUI::mixed_gradient_ramp(gradient_config(), 0, 16).empty());
|
||||
}
|
||||
SECTION("gradient is off") {
|
||||
DynamicPrintConfig cfg = gradient_config();
|
||||
cfg.set_key_value("filament_mixed_gradient", new ConfigOptionBools({false, false, false}));
|
||||
REQUIRE(Slic3r::GUI::mixed_gradient_ramp(cfg, 2, 16).empty());
|
||||
}
|
||||
SECTION("three components") {
|
||||
REQUIRE(Slic3r::GUI::mixed_gradient_ramp(gradient_config("1,2,3"), 2, 16).empty());
|
||||
}
|
||||
SECTION("slot out of range") {
|
||||
REQUIRE(Slic3r::GUI::mixed_gradient_ramp(gradient_config(), 9, 16).empty());
|
||||
}
|
||||
SECTION("no mixed keys at all") {
|
||||
REQUIRE(Slic3r::GUI::mixed_gradient_ramp(DynamicPrintConfig{}, 0, 16).empty());
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("sample_gradient_ramp blends each step through the shared blender", "[FilamentBitmapUtils]")
|
||||
{
|
||||
// A flat curve makes every step the same 30/70 mix, which must come out as the blend the
|
||||
// dialog's own swatches are drawn with - not a channel lerp between the two components.
|
||||
GradientCurve curve;
|
||||
curve.points = {{0.0, 0.3, NAN, NAN}, {1.0, 0.3, NAN, NAN}};
|
||||
const auto ramp = Slic3r::GUI::sample_gradient_ramp(wxColour(255, 0, 0), wxColour(0, 0, 255), curve, 4);
|
||||
REQUIRE(ramp.size() == 4);
|
||||
|
||||
const wxColour expected = Slic3r::GUI::blend_n_colors({wxColour(255, 0, 0), wxColour(0, 0, 255)}, {0.3, 0.7});
|
||||
for (const wxColour& c : ramp)
|
||||
require_same_rgb(c, expected);
|
||||
}
|
||||
|
||||
TEST_CASE("sample_gradient_ramp returns nothing without a usable curve or step count", "[FilamentBitmapUtils]")
|
||||
{
|
||||
GradientCurve curve;
|
||||
REQUIRE(Slic3r::GUI::sample_gradient_ramp(wxColour(255, 0, 0), wxColour(0, 0, 255), curve, 8).empty());
|
||||
curve.points = {{0.0, kGradientMaxRatio, NAN, NAN}, {1.0, kGradientMinRatio, NAN, NAN}};
|
||||
REQUIRE(Slic3r::GUI::sample_gradient_ramp(wxColour(255, 0, 0), wxColour(0, 0, 255), curve, 0).empty());
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
#include <libslic3r/TriangleMesh.hpp>
|
||||
#include <libslic3r/Format/OBJ.hpp>
|
||||
#include <libslic3r/SVG.hpp>
|
||||
#include <libslic3r/Utils.hpp>
|
||||
|
||||
#include <boost/filesystem.hpp>
|
||||
|
||||
@@ -32,7 +33,7 @@ inline Slic3r::TriangleMesh load_model(const std::string &obj_filename)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Owns a unique path under the system temp dir, "<prefix>-<unique>[<extension>]"
|
||||
// (parallel-safe, cross-platform). Shared base for the two RAII temp guards below.
|
||||
// (parallel-safe, cross-platform). Shared base for the RAII temp guards below.
|
||||
class ScopedTemporaryPath
|
||||
{
|
||||
public:
|
||||
@@ -70,6 +71,24 @@ public:
|
||||
~ScopedTemporaryDir() { boost::system::error_code ec; boost::filesystem::remove_all(m_path, ec); }
|
||||
};
|
||||
|
||||
// A temp directory that is also Slic3r::temporary_dir() for its lifetime. No test
|
||||
// process sets that global, so code under test which writes there (for example
|
||||
// StepPreProcessor::preprocess) lands at the filesystem root. Restored on scope exit
|
||||
// even when an assertion throws, so it cannot leak into later tests.
|
||||
class ScopedSlic3rTemporaryDir : public ScopedTemporaryDir
|
||||
{
|
||||
public:
|
||||
explicit ScopedSlic3rTemporaryDir(const std::string &prefix = "orca")
|
||||
: ScopedTemporaryDir(prefix), m_previous(Slic3r::temporary_dir())
|
||||
{ Slic3r::set_temporary_dir(string()); }
|
||||
// Runs before ~ScopedTemporaryDir, so the setting goes back while the directory
|
||||
// it names still exists.
|
||||
~ScopedSlic3rTemporaryDir() { Slic3r::set_temporary_dir(m_previous); }
|
||||
|
||||
private:
|
||||
const std::string m_previous;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Debug-only test artifacts
|
||||
//
|
||||
|
||||
Reference in New Issue
Block a user