mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-09-09 10:16:50 +00:00
BBL Port Color Mix Base
This commit is contained in:
@@ -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
|
||||
|
||||
182
tests/libslic3r/test_filament_mixer.cpp
Normal file
182
tests/libslic3r/test_filament_mixer.cpp
Normal file
@@ -0,0 +1,182 @@
|
||||
#include <catch2/catch_all.hpp>
|
||||
|
||||
#include "libslic3r/FilamentMixer.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("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 a round trip through
|
||||
// it is near-identity rather than exact (the model documents a mean Delta-E around 2).
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -566,3 +566,46 @@ TEST_CASE("A printer specific filament supersedes the generic library filament w
|
||||
CHECK(is_compatible_with_printer(generic_lib, PresetWithVendorProfile(*printer_c, nullptr)));
|
||||
}
|
||||
|
||||
|
||||
// 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]")
|
||||
{
|
||||
static 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",
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user