Port color decompose recipe data and interpolation from BambuStudio

This commit is contained in:
SoftFever
2026-08-22 13:06:36 +08:00
parent fcdfcae427
commit 94a1cd6c93
4 changed files with 7625 additions and 7437 deletions

View File

@@ -61,6 +61,42 @@ static LabColor rgb_to_lab(const ColorDecomposeRgb& rgb)
return {116.0 * fy - 16.0, 500.0 * (fx - fy), 200.0 * (fy - fz)};
}
static std::string lab_to_srgb_hex(const LabColor& lab)
{
constexpr double Xn = 0.95047, Yn = 1.0, Zn = 1.08883;
auto f_inv = [](double t) -> double {
constexpr double eps = 216.0 / 24389.0;
constexpr double kappa = 24389.0 / 27.0;
const double t3 = t * t * t;
return t3 > eps ? t3 : (t * 116.0 - 16.0) / kappa;
};
const double fy = (lab.l + 16.0) / 116.0;
const double fx = lab.a / 500.0 + fy;
const double fz = fy - lab.b / 200.0;
const double X = Xn * f_inv(fx);
const double Y = Yn * f_inv(fy);
const double Z = Zn * f_inv(fz);
double r = 3.2406 * X - 1.5372 * Y - 0.4986 * Z;
double g = -0.9689 * X + 1.8758 * Y + 0.0415 * Z;
double b = 0.0557 * X - 0.2040 * Y + 1.0570 * Z;
auto gamma = [](double c) -> double {
c = std::max(0.0, std::min(1.0, c));
return c <= 0.0031308 ? 12.92 * c : 1.055 * std::pow(c, 1.0 / 2.4) - 0.055;
};
auto u8 = [&](double c) -> int {
return std::max(0, std::min(255, static_cast<int>(std::lround(gamma(c) * 255.0))));
};
char buf[8];
std::snprintf(buf, sizeof(buf), "#%02X%02X%02X", u8(r), u8(g), u8(b));
return std::string(buf);
}
static double delta_e76(const LabColor& a, const LabColor& b)
{
return std::sqrt(std::pow(a.l - b.l, 2.0) + std::pow(a.a - b.a, 2.0) + std::pow(a.b - b.b, 2.0));
@@ -224,6 +260,29 @@ ColorDecomposeRecipeResult recommend_from_physical_filaments(
if (preferred_material_type.empty() || material_matches(filament.type, preferred_material_type))
candidates.push_back(filament);
}
// Early exit: if a material-matched candidate has the exact target color,
// return it as 100%. Downstream rejects single-component results (no mixed
// slot created), which is correct -- the color already exists.
const std::string target_hex = color_decompose_rgb_to_hex(target);
for (const auto& cand : candidates) {
ColorDecomposeRgb cand_rgb;
if (!color_decompose_hex_to_rgb(cand.color_hex, cand_rgb))
continue;
if (color_decompose_rgb_to_hex(cand_rgb) == target_hex) {
ColorDecomposeRecipeResult exact;
exact.valid = true;
exact.mode = ColorDecomposeRecipeMode::MaterialList;
exact.matched_color_hex = cand.color_hex;
ColorDecomposeRecipeComponent comp;
comp.color_hex = cand.color_hex;
comp.ratio = 100;
comp.filament_index = cand.filament_index;
exact.components.push_back(comp);
return exact;
}
}
if (candidates.size() < 2)
candidates = physical_filaments;
candidates.erase(std::remove_if(candidates.begin(), candidates.end(), [](const auto& filament) {
@@ -328,34 +387,144 @@ std::string lookup_measured_blend_color(const std::vector<std::string>& componen
return std::string(buf);
};
std::vector<std::string> norm_hexes;
norm_hexes.reserve(component_hexes.size());
for (const auto& h : component_hexes) {
std::string n = normalize_hex(h);
if (n.empty())
// Stage 1: canonicalize input by sorting (hex, ratio) pairs so matching
// is independent of the caller's component order.
const size_t n = component_hexes.size();
std::vector<std::pair<std::string, int>> in_pairs;
in_pairs.reserve(n);
for (size_t i = 0; i < n; ++i) {
std::string nh = normalize_hex(component_hexes[i]);
if (nh.empty())
return {};
norm_hexes.push_back(std::move(n));
in_pairs.emplace_back(std::move(nh), ratios[i]);
}
std::sort(in_pairs.begin(), in_pairs.end());
std::vector<std::string> in_hexes;
std::vector<int> in_ratios;
in_hexes.reserve(n);
in_ratios.reserve(n);
for (const auto& p : in_pairs) {
in_hexes.push_back(p.first);
in_ratios.push_back(p.second);
}
// Normalize ratios to sum=100 (callers may pass arbitrary weights,
// e.g. MixedFilamentDialog uses ratio*10000).
{
int sum = 0;
for (int r : in_ratios) sum += r;
if (sum > 0 && sum != 100) {
int new_sum = 0;
for (size_t i = 0; i < in_ratios.size(); ++i) {
in_ratios[i] = static_cast<int>(std::lround(
static_cast<double>(in_ratios[i]) * 100.0 / static_cast<double>(sum)));
new_sum += in_ratios[i];
}
if (new_sum != 100) {
auto it = std::max_element(in_ratios.begin(), in_ratios.end());
*it += (100 - new_sum);
}
}
}
// Fall back to polynomial model for ratios outside the measured range.
{
bool out_of_range = false;
if (n == 2) {
for (int r : in_ratios)
if (r < 20 || r > 80) { out_of_range = true; break; }
} else {
for (int r : in_ratios)
if (r < 20) { out_of_range = true; break; }
}
if (out_of_range)
return {};
}
// Stage 2: collect anchors with the same component hex set; try exact match.
struct Anchor {
std::vector<int> ratios;
LabColor lab;
std::string hex;
};
std::vector<Anchor> anchors;
for (const StandardRecipeEntry& entry : standard_entries()) {
if (entry.source != "measured" && entry.source != "interpolated")
continue;
if (entry.component_hexes.size() != norm_hexes.size())
continue;
if (entry.ratios != ratios)
if (entry.component_hexes.size() != n)
continue;
bool match = true;
for (size_t i = 0; i < norm_hexes.size(); ++i) {
if (normalize_hex(entry.component_hexes[i]) != norm_hexes[i]) {
match = false;
break;
}
}
if (match)
return entry.measured_hex;
std::vector<std::pair<std::string, int>> e_pairs;
e_pairs.reserve(n);
for (size_t i = 0; i < n; ++i)
e_pairs.emplace_back(normalize_hex(entry.component_hexes[i]), entry.ratios[i]);
std::sort(e_pairs.begin(), e_pairs.end());
bool same_set = true;
for (size_t i = 0; i < n; ++i)
if (e_pairs[i].first != in_hexes[i]) { same_set = false; break; }
if (!same_set)
continue;
Anchor a;
a.ratios.reserve(n);
for (const auto& p : e_pairs) a.ratios.push_back(p.second);
a.lab = entry.measured_lab;
a.hex = entry.measured_hex;
if (a.ratios == in_ratios)
return a.hex;
anchors.push_back(std::move(a));
}
return {};
if (anchors.size() < 2)
return {};
// Stage 3: interpolation in Lab space.
if (n == 2) {
// 1D linear interpolation along ratio[0].
std::sort(anchors.begin(), anchors.end(),
[](const Anchor& a, const Anchor& b) { return a.ratios[0] < b.ratios[0]; });
const double x = static_cast<double>(in_ratios[0]);
size_t lo = 0;
while (lo + 2 < anchors.size() && static_cast<double>(anchors[lo + 1].ratios[0]) <= x)
++lo;
const Anchor& a0 = anchors[lo];
const Anchor& a1 = anchors[lo + 1];
const double span = static_cast<double>(a1.ratios[0] - a0.ratios[0]);
const double t = span > 0.0 ? (x - static_cast<double>(a0.ratios[0])) / span : 0.0;
return lab_to_srgb_hex({a0.lab.l + t * (a1.lab.l - a0.lab.l),
a0.lab.a + t * (a1.lab.a - a0.lab.a),
a0.lab.b + t * (a1.lab.b - a0.lab.b)});
}
// 3+ color: IDW (p=2) with 3 nearest anchors in the (ratio[0], ratio[1]) plane.
const double ra = static_cast<double>(in_ratios[0]);
const double rb = static_cast<double>(in_ratios[1]);
std::vector<std::pair<double, const Anchor*>> dists;
dists.reserve(anchors.size());
for (const Anchor& a : anchors) {
const double d = std::sqrt(std::pow(ra - static_cast<double>(a.ratios[0]), 2.0) +
std::pow(rb - static_cast<double>(a.ratios[1]), 2.0));
if (d == 0.0)
return a.hex;
dists.emplace_back(d, &a);
}
const size_t k = std::min(static_cast<size_t>(3), dists.size());
std::partial_sort(dists.begin(), dists.begin() + k, dists.end(),
[](const auto& a, const auto& b) { return a.first < b.first; });
double num_l = 0.0, num_a = 0.0, num_b = 0.0, den = 0.0;
for (size_t j = 0; j < k; ++j) {
const double w = 1.0 / (dists[j].first * dists[j].first);
num_l += w * dists[j].second->lab.l;
num_a += w * dists[j].second->lab.a;
num_b += w * dists[j].second->lab.b;
den += w;
}
return lab_to_srgb_hex({num_l / den, num_a / den, num_b / den});
}
} // namespace Slic3r

View File

@@ -652,7 +652,6 @@ void ColorDecomposeDialog::update_filament_limit_warning()
m_limit_warning_panel->Hide();
Layout();
Fit();
CenterOnParent();
}
return;
}
@@ -678,12 +677,11 @@ void ColorDecomposeDialog::update_filament_limit_warning()
m_limit_warning_text->Wrap(avail);
Layout();
// Only resize/recenter when the warning panel actually toggled from hidden
// to shown. While already visible, switching modes must not re-Fit/recenter
// the dialog, which would make it jump on every card switch.
// Only resize when the warning panel actually toggled from hidden to shown.
// While already visible, switching modes must not re-Fit the dialog, which
// would make it jump on every card switch. Fit keeps the user-moved position.
if (!was_shown) {
Fit();
CenterOnParent();
}
}

View File

@@ -4544,7 +4544,13 @@ void Sidebar::collect_physical_filament_info(std::vector<std::string>& color_str
Preset* preset = nullptr;
if (cfg_idx < preset_bundle.filament_presets.size())
preset = preset_bundle.filaments.find_preset(preset_bundle.filament_presets[cfg_idx]);
types.push_back(filament_type_for_color_decompose(preset));
std::string ft;
if (preset) {
std::string display_type;
ft = preset->config.get_filament_type(display_type);
}
if (ft.empty()) ft = "PLA";
types.push_back(ft);
}
}
@@ -4905,6 +4911,21 @@ void Sidebar::decompose_filament_color(int filament_idx)
std::vector<std::string> color_strs, names, types;
std::vector<size_t> physical_config_indices;
collect_physical_filament_info(color_strs, names, types, &physical_config_indices);
// Build decompose-specific types: ColorDecomposeDialog needs "PLA Basic"
// distinction (for CMYW/RYBW card visibility), while collect_physical_filament_info
// now returns coarse filament_type (e.g. "PLA" for all PLA variants).
std::vector<std::string> decompose_types;
{
auto& pb = *wxGetApp().preset_bundle;
for (size_t i = 0; i < physical_config_indices.size(); ++i) {
const size_t ci = physical_config_indices[i];
Preset* pr = (ci < pb.filament_presets.size())
? pb.filaments.find_preset(pb.filament_presets[ci]) : nullptr;
decompose_types.push_back(filament_type_for_color_decompose(pr));
}
}
size_t source_physical_idx = size_t(-1);
for (size_t i = 0; i < physical_config_indices.size(); ++i) {
if (physical_config_indices[i] == static_cast<size_t>(filament_idx)) {
@@ -4915,7 +4936,7 @@ void Sidebar::decompose_filament_color(int filament_idx)
ColorDecomposeDialog dlg(this,
source_physical_idx == size_t(-1) ? -1 : static_cast<int>(source_physical_idx),
target_color, color_strs, names, types,
target_color, color_strs, names, decompose_types,
wxGetApp().preset_bundle->filament_presets.size(),
static_cast<size_t>(EnforcerBlockerType::ExtruderMax),
physical_config_indices);
@@ -4925,7 +4946,7 @@ void Sidebar::decompose_filament_color(int filament_idx)
MixedFilamentResult mixed_result;
std::vector<DecomposeMissingComponent> missing_components;
if (!prepare_decompose_mixed_result(dialog_result, static_cast<size_t>(filament_idx), source_physical_idx,
color_strs, types, physical_config_indices, mixed_result, missing_components))
color_strs, decompose_types, physical_config_indices, mixed_result, missing_components))
return;
if (!confirm_create_decompose_missing_components(this, missing_components))