#include "ColorDecomposeRecipe.hpp" #include "FilamentMixer.hpp" #include "Utils.hpp" #include "nlohmann/json.hpp" #include #include #include #include #include #include namespace Slic3r { namespace { struct LabColor { double l{0.0}; double a{0.0}; double b{0.0}; }; struct StandardRecipeEntry { ColorDecomposeRecipeMode mode{ColorDecomposeRecipeMode::CMYW}; std::string material; std::string source; std::vector component_keys; std::vector component_hexes; std::vector ratios; std::string measured_hex; LabColor measured_lab; }; static double srgb_to_linear(double v) { v /= 255.0; return v <= 0.04045 ? v / 12.92 : std::pow((v + 0.055) / 1.055, 2.4); } static double xyz_to_lab_component(double v) { constexpr double eps = 216.0 / 24389.0; constexpr double kappa = 24389.0 / 27.0; return v > eps ? std::cbrt(v) : (kappa * v + 16.0) / 116.0; } static LabColor rgb_to_lab(const ColorDecomposeRgb& rgb) { const double r = srgb_to_linear(rgb.r); const double g = srgb_to_linear(rgb.g); const double b = srgb_to_linear(rgb.b); const double x = (0.4124564 * r + 0.3575761 * g + 0.1804375 * b) / 0.95047; const double y = (0.2126729 * r + 0.7151522 * g + 0.0721750 * b); const double z = (0.0193339 * r + 0.1191920 * g + 0.9503041 * b) / 1.08883; const double fx = xyz_to_lab_component(x); const double fy = xyz_to_lab_component(y); const double fz = xyz_to_lab_component(z); 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(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)); } static bool material_matches(const std::string& a, const std::string& b) { if (a.empty() || b.empty()) return false; return a == b || a == b + " Basic" || b == a + " Basic"; } static std::vector> ratio_grid(size_t n) { std::vector> out; if (n == 2) { for (int a = 20; a <= 80; a += 5) out.push_back({a, 100 - a}); } else if (n == 3) { for (int a = 20; a <= 60; a += 5) for (int b = 20; b <= 80 - a; b += 5) { const int c = 100 - a - b; if (c >= 20) out.push_back({a, b, c}); } } return out; } static ColorDecomposeRecipeMode parse_mode(const std::string& s) { if (s == "RYBW" || s == "RGBY") return ColorDecomposeRecipeMode::RYBW; return ColorDecomposeRecipeMode::CMYW; } static std::vector load_standard_entries() { std::vector entries; const std::string path = resources_dir() + "/filament_mixing/standard_color_recipes.json"; std::ifstream ifs(path); if (!ifs) return entries; nlohmann::json root = nlohmann::json::parse(ifs, nullptr, false); if (root.is_discarded() || !root.contains("entries") || !root["entries"].is_array()) return entries; for (const auto& item : root["entries"]) { if (!item.is_object()) continue; StandardRecipeEntry entry; entry.mode = parse_mode(item.value("mode", "CMYW")); entry.material = item.value("material", ""); entry.source = item.value("source", ""); entry.measured_hex = item.value("measured_rgb", ""); if (item.contains("components") && item["components"].is_array()) { for (const auto& comp : item["components"]) { if (comp.is_object()) { entry.component_keys.push_back(comp.value("key", "")); entry.component_hexes.push_back(comp.value("rgb", "")); } } } if (item.contains("ratios") && item["ratios"].is_array()) { for (const auto& ratio : item["ratios"]) { if (ratio.is_number_integer()) entry.ratios.push_back(ratio.get()); } } if (item.contains("measured_lab") && item["measured_lab"].is_array() && item["measured_lab"].size() >= 3) { entry.measured_lab = { item["measured_lab"][0].get(), item["measured_lab"][1].get(), item["measured_lab"][2].get() }; } else { ColorDecomposeRgb measured_rgb; if (!color_decompose_hex_to_rgb(entry.measured_hex, measured_rgb)) continue; entry.measured_lab = rgb_to_lab(measured_rgb); } if (entry.component_hexes.size() >= 2 && entry.component_hexes.size() == entry.ratios.size() && !entry.measured_hex.empty()) entries.push_back(std::move(entry)); } return entries; } static const std::vector& standard_entries() { static const std::vector entries = load_standard_entries(); return entries; } static void evaluate_candidate(const ColorDecomposeRgb& target, const std::vector& hexes, const std::vector& ratios, const std::vector& indices, ColorDecomposeRecipeMode mode, double& best_score, ColorDecomposeRecipeResult& best) { const std::string mixed = blend_color_multi(hexes, ratios); ColorDecomposeRgb mixed_rgb; if (!color_decompose_hex_to_rgb(mixed, mixed_rgb)) return; const double score = delta_e76(rgb_to_lab(target), rgb_to_lab(mixed_rgb)); if (score >= best_score) return; best_score = score; best.valid = true; best.mode = mode; best.matched_color_hex = mixed; best.components.clear(); for (size_t i = 0; i < hexes.size(); ++i) { ColorDecomposeRecipeComponent comp; comp.color_hex = hexes[i]; comp.ratio = ratios[i]; comp.filament_index = i < indices.size() ? indices[i] : 0; best.components.push_back(comp); } } } // namespace std::string color_decompose_rgb_to_hex(const ColorDecomposeRgb& rgb) { char buf[8]; std::snprintf(buf, sizeof(buf), "#%02X%02X%02X", rgb.r, rgb.g, rgb.b); return std::string(buf); } bool color_decompose_hex_to_rgb(const std::string& hex, ColorDecomposeRgb& out) { if (hex.size() < 7 || hex[0] != '#') return false; unsigned r = 0, g = 0, b = 0; if (std::sscanf(hex.c_str(), "#%02x%02x%02x", &r, &g, &b) != 3) return false; out = {static_cast(r), static_cast(g), static_cast(b)}; return true; } ColorDecomposeRecipeResult recommend_from_physical_filaments( const ColorDecomposeRgb& target, const std::vector& physical_filaments, const std::string& preferred_material_type) { std::vector candidates; for (const auto& filament : physical_filaments) { if (filament.is_mixed) continue; ColorDecomposeRgb ignored; if (!color_decompose_hex_to_rgb(filament.color_hex, ignored)) continue; 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) { if (filament.is_mixed) return true; ColorDecomposeRgb ignored; return !color_decompose_hex_to_rgb(filament.color_hex, ignored); }), candidates.end()); constexpr size_t kMaxCandidates = 8; if (candidates.size() > kMaxCandidates) { const LabColor target_lab = rgb_to_lab(target); std::sort(candidates.begin(), candidates.end(), [&target_lab](const ColorDecomposePhysicalFilament& a, const ColorDecomposePhysicalFilament& b) { ColorDecomposeRgb rgb_a, rgb_b; color_decompose_hex_to_rgb(a.color_hex, rgb_a); color_decompose_hex_to_rgb(b.color_hex, rgb_b); return delta_e76(target_lab, rgb_to_lab(rgb_a)) < delta_e76(target_lab, rgb_to_lab(rgb_b)); }); candidates.resize(kMaxCandidates); } ColorDecomposeRecipeResult best; double best_score = std::numeric_limits::max(); for (size_t i = 0; i < candidates.size(); ++i) { for (size_t j = i + 1; j < candidates.size(); ++j) { const std::vector hexes = {candidates[i].color_hex, candidates[j].color_hex}; const std::vector indices = {candidates[i].filament_index, candidates[j].filament_index}; for (const auto& ratios : ratio_grid(2)) evaluate_candidate(target, hexes, ratios, indices, ColorDecomposeRecipeMode::MaterialList, best_score, best); for (size_t k = j + 1; k < candidates.size(); ++k) { const std::vector hexes3 = {candidates[i].color_hex, candidates[j].color_hex, candidates[k].color_hex}; const std::vector indices3 = {candidates[i].filament_index, candidates[j].filament_index, candidates[k].filament_index}; for (const auto& ratios : ratio_grid(3)) evaluate_candidate(target, hexes3, ratios, indices3, ColorDecomposeRecipeMode::MaterialList, best_score, best); } } } return best; } ColorDecomposeRecipeResult lookup_standard_recipe( const ColorDecomposeRgb& target, ColorDecomposeRecipeMode mode, const std::string& preferred_material_type) { const LabColor target_lab = rgb_to_lab(target); ColorDecomposeRecipeResult best; double best_score = std::numeric_limits::max(); auto consider = [&](bool require_material_match) { for (const StandardRecipeEntry& entry : standard_entries()) { if (entry.mode != mode) continue; if (require_material_match && !material_matches(entry.material, preferred_material_type)) continue; if (!require_material_match && !preferred_material_type.empty() && material_matches(entry.material, preferred_material_type)) continue; const double score = delta_e76(target_lab, entry.measured_lab); if (score >= best_score) continue; best_score = score; best.valid = true; best.mode = mode; best.matched_color_hex = entry.measured_hex; best.components.clear(); for (size_t i = 0; i < entry.component_hexes.size(); ++i) { ColorDecomposeRecipeComponent comp; comp.color_hex = entry.component_hexes[i]; comp.base_color = i < entry.component_keys.size() ? entry.component_keys[i] : ""; comp.ratio = entry.ratios[i]; comp.filament_index = 0; best.components.push_back(comp); } } }; consider(true); if (!best.valid) consider(false); return best; } std::string lookup_measured_blend_color(const std::vector& component_hexes, const std::vector& ratios) { if (component_hexes.size() < 2 || component_hexes.size() != ratios.size()) return {}; auto normalize_hex = [](const std::string& hex) -> std::string { ColorDecomposeRgb rgb; if (!color_decompose_hex_to_rgb(hex, rgb)) return {}; char buf[8]; std::snprintf(buf, sizeof(buf), "#%02X%02X%02X", rgb.r, rgb.g, rgb.b); return std::string(buf); }; // 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> 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 {}; in_pairs.emplace_back(std::move(nh), ratios[i]); } std::sort(in_pairs.begin(), in_pairs.end()); std::vector in_hexes; std::vector 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(std::lround( static_cast(in_ratios[i]) * 100.0 / static_cast(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 ratios; LabColor lab; std::string hex; }; std::vector anchors; for (const StandardRecipeEntry& entry : standard_entries()) { if (entry.source != "measured" && entry.source != "interpolated") continue; if (entry.component_hexes.size() != n) continue; std::vector> 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)); } 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(in_ratios[0]); size_t lo = 0; while (lo + 2 < anchors.size() && static_cast(anchors[lo + 1].ratios[0]) <= x) ++lo; const Anchor& a0 = anchors[lo]; const Anchor& a1 = anchors[lo + 1]; const double span = static_cast(a1.ratios[0] - a0.ratios[0]); const double t = span > 0.0 ? (x - static_cast(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(in_ratios[0]); const double rb = static_cast(in_ratios[1]); std::vector> dists; dists.reserve(anchors.size()); for (const Anchor& a : anchors) { const double d = std::sqrt(std::pow(ra - static_cast(a.ratios[0]), 2.0) + std::pow(rb - static_cast(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(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