diff --git a/resources/shaders/110/texture_displacement_shaded.fs b/resources/shaders/110/texture_displacement_shaded.fs index 44ea89b844..0b9192749c 100644 --- a/resources/shaders/110/texture_displacement_shaded.fs +++ b/resources/shaders/110/texture_displacement_shaded.fs @@ -29,13 +29,11 @@ uniform vec3 palette_lab[64]; uniform vec3 palette_rgb[64]; uniform int palette_count; uniform bool pure_only; // match against single filaments only (flat-colour image) -// How each entry prints. Every entry names a single filament: a mix is given its own mixed filament -// slot, whose components the slicer alternates per print layer, so the fragment just looks that slot's -// colour up. +// The entry's two filaments, equal for a single filament - only so a mix can be told apart. An entry's +// palette_rgb is already the colour it prints in (for a mix, its mixed filament slot's). uniform int palette_a[64]; uniform int palette_b[64]; -uniform vec3 filament_rgb[16]; -uniform int filament_count; +uniform float prefer_pure_de; // PREFER_PURE_DE: how much better than a single filament a mix must be uniform sampler2D color_tex; // the layer's colour image, sampled at the same uv as the height uniform bool has_color_tex; uniform bool volume_mirrored; @@ -210,31 +208,16 @@ int nearest_palette_entry(vec3 rgb) best = i; } } - // The same bias make_palette_quantizer() applies (PREFER_PURE_DE = 10): a mix is an interleave, so - // it is only worth taking when it beats the nearest single filament by a visible step. Without it - // this picked a mix for almost every fragment - with four filaments the palette is 4 pure entries - // against 30 mixes - while the bake picked a single filament for most of them, so the preview - // interleaved the whole wall where the bake interleaves only patches. Compared on the distances - // rather than their squares, so the threshold means the same thing as it does on the CPU (up to - // CIE76 against CIEDE2000, the approximation already noted above). - if (best_pure >= 0 && palette_a[best] != palette_b[best] && sqrt(bd_pure) - sqrt(bd) < 10.0) + // The same bias make_palette_quantizer() applies: a mix is an interleave, so it is only worth taking + // when it beats the nearest single filament by a visible step - otherwise the preview shows mixes + // where the bake prints a single filament. Compared on the distances rather than their squares, so + // the margin means the same thing as it does on the CPU (up to CIE76 against CIEDE2000, the + // approximation already noted above). + if (best_pure >= 0 && palette_a[best] != palette_b[best] && sqrt(bd_pure) - sqrt(bd) < prefer_pure_de) best = best_pure; return best; } -// One 2x2 Bayer cell, {0, 2; 3, 1}, for x and y in {0, 1}. - -// The colour the printer lays down at world point `pos` for palette entry `index`. Every entry names a -// single filament: a mix is given its own mixed filament slot, whose components the slicer alternates -// per print layer, so there is nothing left to interleave here. -vec3 printed_color(int index) -{ - int a = palette_a[index]; - if (a < 0 || a >= filament_count) - return palette_rgb[index]; // no filament to resolve to: the entry's own colour - return filament_rgb[a]; -} - void main() { if (any(lessThan(clipping_planes_dots, ZERO))) @@ -354,16 +337,11 @@ void main() NdotL = max(dot(eye_normal, LIGHT_FRONT_DIR), 0.0); intensity.x += NdotL * LIGHT_FRONT_DIFFUSE; - // Diffuse albedo: the image's colour at this fragment, snapped to the nearest printable colour - - // and, where that is a mix, the filament the interleave puts here, so the pattern that prints shows. + // Diffuse albedo: the image's colour at this fragment, snapped to the nearest printable colour. // Only the albedo - the specular term (intensity.y) stays white - so a coloured fragment reads as // the same material under the same light, and the relief this preview exists to show is unaffected. vec3 albedo = uniform_color.rgb; if (palette_count > 0 && has_color_tex && have_uv && weight > 0.0) - // tex_pos, not world_pos: the bake resolves the interleave in the bake frame (world - // orientation and scale about the volume's origin, see texture_displacement_bake_frame()), so - // measuring z from the bed instead shifted the band phase by the volume origin's height - a - // different filament in the same place than the bake produces. - albedo = printed_color(nearest_palette_entry(texture2D(color_tex, color_uv).rgb)); + albedo = palette_rgb[nearest_palette_entry(texture2D(color_tex, color_uv).rgb)]; gl_FragColor = vec4(vec3(intensity.y) + albedo * intensity.x, uniform_color.a); } diff --git a/resources/shaders/140/texture_displacement_shaded.fs b/resources/shaders/140/texture_displacement_shaded.fs index b2936598cd..911a919aaf 100644 --- a/resources/shaders/140/texture_displacement_shaded.fs +++ b/resources/shaders/140/texture_displacement_shaded.fs @@ -88,13 +88,11 @@ uniform vec3 palette_lab[64]; uniform vec3 palette_rgb[64]; uniform int palette_count; uniform bool pure_only; // match against single filaments only (flat-colour image) -// How each entry prints. Every entry names a single filament: a mix is given its own mixed filament -// slot, whose components the slicer alternates per print layer, so the fragment just looks that slot's -// colour up. +// The entry's two filaments, equal for a single filament - only so a mix can be told apart. An entry's +// palette_rgb is already the colour it prints in (for a mix, its mixed filament slot's). uniform int palette_a[64]; uniform int palette_b[64]; -uniform vec3 filament_rgb[16]; -uniform int filament_count; +uniform float prefer_pure_de; // PREFER_PURE_DE: how much better than a single filament a mix must be uniform sampler2D color_tex; // the layer's colour image, sampled at the same uv as the height uniform bool has_color_tex; uniform bool volume_mirrored; @@ -276,31 +274,16 @@ int nearest_palette_entry(vec3 rgb) best = i; } } - // The same bias make_palette_quantizer() applies (PREFER_PURE_DE = 10): a mix is an interleave, so - // it is only worth taking when it beats the nearest single filament by a visible step. Without it - // this picked a mix for almost every fragment - with four filaments the palette is 4 pure entries - // against 30 mixes - while the bake picked a single filament for most of them, so the preview - // interleaved the whole wall where the bake interleaves only patches. Compared on the distances - // rather than their squares, so the threshold means the same thing as it does on the CPU (up to - // CIE76 against CIEDE2000, the approximation already noted above). - if (best_pure >= 0 && palette_a[best] != palette_b[best] && sqrt(bd_pure) - sqrt(bd) < 10.0) + // The same bias make_palette_quantizer() applies: a mix is an interleave, so it is only worth taking + // when it beats the nearest single filament by a visible step - otherwise the preview shows mixes + // where the bake prints a single filament. Compared on the distances rather than their squares, so + // the margin means the same thing as it does on the CPU (up to CIE76 against CIEDE2000, the + // approximation already noted above). + if (best_pure >= 0 && palette_a[best] != palette_b[best] && sqrt(bd_pure) - sqrt(bd) < prefer_pure_de) best = best_pure; return best; } -// One 2x2 Bayer cell, {0, 2; 3, 1}, for x and y in {0, 1}. - -// The colour the printer lays down at world point `pos` for palette entry `index`. Every entry names a -// single filament: a mix is given its own mixed filament slot, whose components the slicer alternates -// per print layer, so there is nothing left to interleave here. -vec3 printed_color(int index) -{ - int a = palette_a[index]; - if (a < 0 || a >= filament_count) - return palette_rgb[index]; // no filament to resolve to: the entry's own colour - return filament_rgb[a]; -} - void main() { if (any(lessThan(clipping_planes_dots, ZERO))) @@ -442,16 +425,11 @@ void main() NdotL = max(dot(eye_normal, LIGHT_FRONT_DIR), 0.0); intensity.x += NdotL * LIGHT_FRONT_DIFFUSE; - // Diffuse albedo: the image's colour at this fragment, snapped to the nearest printable colour - - // and, where that is a mix, the filament the interleave puts here, so the pattern that prints shows. + // Diffuse albedo: the image's colour at this fragment, snapped to the nearest printable colour. // Only the albedo - the specular term (intensity.y) stays white - so a coloured fragment reads as // the same material under the same light, and the relief this preview exists to show is unaffected. vec3 albedo = uniform_color.rgb; if (palette_count > 0 && has_color_tex && have_uv && weight > 0.0) - // tex_pos, not world_pos: the bake resolves the interleave in the bake frame (world - // orientation and scale about the volume's origin, see texture_displacement_bake_frame()), so - // measuring z from the bed instead shifted the band phase by the volume origin's height - a - // different filament in the same place than the bake produces. - albedo = printed_color(nearest_palette_entry(texture(color_tex, color_uv).rgb)); + albedo = palette_rgb[nearest_palette_entry(texture(color_tex, color_uv).rgb)]; out_color = vec4(vec3(intensity.y) + albedo * intensity.x, uniform_color.a); } diff --git a/src/libslic3r/FilamentMixer.cpp b/src/libslic3r/FilamentMixer.cpp index 51952c94c9..e4f42cfdb8 100644 --- a/src/libslic3r/FilamentMixer.cpp +++ b/src/libslic3r/FilamentMixer.cpp @@ -18,6 +18,7 @@ #include #include "ColorDecomposeRecipe.hpp" +#include "Config.hpp" #include "FilamentMixerModel.hpp" #include "LocalesUtils.hpp" @@ -427,6 +428,59 @@ std::vector parse_mixed_ratios(const std::string &str, size_t n_componen return ratios; } +std::string format_mixed_components(const std::vector &components) +{ + std::string out; + for (size_t i = 0; i < components.size(); ++i) { + if (i > 0) + out += ","; + out += std::to_string(components[i]); + } + return out; +} + +std::string format_mixed_ratios(const std::vector &weights) +{ + int sum = std::accumulate(weights.begin(), weights.end(), 0); + if (sum <= 0) + sum = 100; + CNumericLocalesSetter c_locale_setter; + std::string out; + for (size_t i = 0; i < weights.size(); ++i) { + if (i > 0) + out += ","; + char buf[32]; + std::snprintf(buf, sizeof(buf), "%.4f", float(weights[i]) / float(sum)); + out += buf; + } + return out; +} + +int find_fixed_mixed_filament(const ConfigBase &project_config, + const std::vector &components, + const std::vector &weights) +{ + const auto *is_mixed = project_config.option("filament_is_mixed"); + const auto *comps = project_config.option("filament_mixed_components"); + const auto *ratios = project_config.option("filament_mixed_sublayer_ratios"); + if (is_mixed == nullptr || comps == nullptr || ratios == nullptr) + return -1; + // Created lazily with the first mixed slot, so an older project may not have it at all. + const auto *gradient = project_config.option("filament_mixed_gradient"); + + const std::string comp_str = format_mixed_components(components); + const std::string ratio_str = format_mixed_ratios(weights); + for (size_t i = 0; i < is_mixed->values.size(); ++i) { + if (!is_mixed->values[i] || i >= comps->values.size() || i >= ratios->values.size()) + continue; + if (gradient != nullptr && i < gradient->values.size() && gradient->values[i]) + continue; + if (comps->values[i] == comp_str && ratios->values[i] == ratio_str) + return int(i); + } + return -1; +} + bool has_any_mixed_filament(const std::vector &is_mixed) { for (unsigned char v : is_mixed) diff --git a/src/libslic3r/FilamentMixer.hpp b/src/libslic3r/FilamentMixer.hpp index 5ea4484437..a14521e1a5 100644 --- a/src/libslic3r/FilamentMixer.hpp +++ b/src/libslic3r/FilamentMixer.hpp @@ -11,6 +11,8 @@ namespace Slic3r { +class ConfigBase; + // Photoshop-style gradient curve control point in [0,1] x [0,1]. // (x, y) is the anchor position; (m_in, m_out) are optional cubic Hermite tangent // overrides. NaN means "use the PCHIP-computed default", which is the case for plain @@ -94,6 +96,22 @@ std::vector parse_mixed_components(const std::string &str); // Normalizes so the sum equals 1.0. std::vector parse_mixed_ratios(const std::string &str, size_t n_components); +// The text a mixed slot stores in filament_mixed_components, e.g. {1, 3} → "1,3". +std::string format_mixed_components(const std::vector &components); + +// The text a mixed slot stores in filament_mixed_sublayer_ratios: the weights normalised to sum to 1, +// four decimals, e.g. {1, 2} → "0.3333,0.6667". A non-positive sum is read as 100. +std::string format_mixed_ratios(const std::vector &weights); + +// The 0-based index of the mixed slot in `project_config` that blends `components` (1-based physical +// filaments) in `weights` at a fixed ratio, or -1 when there is none. Matched on the stored text, as +// format_mixed_components() and format_mixed_ratios() write it. A gradient slot never matches: its +// ratio drifts from layer to layer, so it is not the blend asked for even where its stored ratios +// are the same. +int find_fixed_mixed_filament(const ConfigBase &project_config, + const std::vector &components, + const std::vector &weights); + // Returns true if any element in is_mixed is true. // ConfigOptionBools stores values as std::vector. bool has_any_mixed_filament(const std::vector &is_mixed); diff --git a/src/libslic3r/TextureDisplacement.hpp b/src/libslic3r/TextureDisplacement.hpp index 34c3543088..feee193491 100644 --- a/src/libslic3r/TextureDisplacement.hpp +++ b/src/libslic3r/TextureDisplacement.hpp @@ -1,6 +1,7 @@ #ifndef slic3r_TextureDisplacement_hpp_ #define slic3r_TextureDisplacement_hpp_ +#include #include #include #include @@ -387,6 +388,10 @@ struct TextureDisplacementOptions // image (TextureDetail::flat_colors): a texture of flat colours prints in single filaments, a // photograph or gradient in mixes. Off forces single filaments everywhere. bool color_mix_enabled = true; + // The most mixes the palette may offer. Every mix a bake paints with becomes a mixed filament slot, + // so this is also the most slots one bake can add. The mixes themselves are picked from the + // texture's colours, those that improve the match the most coming first. + int color_mix_count = 8; // Majority-filter passes over the assigned colours. See TextureColorRequest::despeckle_passes - // this is the control for it, and 2 is enough to clear the salt-and-pepper an image with detail // finer than the mesh leaves behind, without eating features that are genuinely a facet wide. @@ -396,7 +401,7 @@ struct TextureDisplacementOptions { ar(displace_border, smooth_enabled, smooth_strength, smooth_iterations, smooth_skip_border, pipeline_v2, v2_refine_mm, v2_regularize, v2_max_triangles_k, - v2_relocate, color_mix_enabled, color_despeckle); + v2_relocate, color_mix_enabled, color_despeckle, color_mix_count); } }; @@ -494,12 +499,14 @@ using ColorQuantizeFn = std::function; // interleaving, which the slicer does per print layer. Plain data, so it can be captured into a job. struct PrintableColor { - Vec3f rgb = Vec3f::Zero(); // what it looks like; for a mix, the perceptual average of the two + Vec3f rgb = Vec3f::Zero(); // what it looks like; for a mix, the colour its mixed filament slot shows int a = 0; // filament index int b = 0; // the second filament; == a for a pure entry int num = 1; // a's share of the interleave, out of `den` int den = 1; bool is_mix() const { return a != b; } + // a's share in percent, the form a mixed filament slot is created from. + int a_percent() const { return int(std::lround(100.0 * double(num) / double(den))); } }; // Everything needed to colour a mesh, captured on the main thread and handed to a job. An empty @@ -762,9 +769,10 @@ struct TextureColorRequest float min_color_region_mm2 = 0.5f; // Filled per *base mesh* triangle (the bake is topology-preserving, so this indexes the returned // mesh too): the quantize callback's index plus one, or 0 for "this triangle takes no colour from - // the texture". The +1 is not arbitrary - it lines up with EnforcerBlockerType, where 0 is NONE - // ("use the volume's own filament") and 1..16 are Extruder1..16, so the caller can hand these - // straight to a TriangleSelector without a second mapping table. + // the texture". The +1 lines up with EnforcerBlockerType, where 0 is NONE ("use the volume's own + // filament"): where every palette entry is a filament, these go straight to a TriangleSelector. A + // palette with mixes maps each index to the mix's filament slot first (see + // GLGizmoTextureDisplacement::palette_filaments()). std::vector *out_triangle = nullptr; }; diff --git a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp index b6d68383fe..2e20d90a55 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp @@ -8,6 +8,7 @@ #include "ColorSpaceConvert.hpp" #include "libslic3r/AABBTreeIndirect.hpp" #include "libslic3r/Color.hpp" +#include "libslic3r/FilamentMixer.hpp" #include "libslic3r/PresetBundle.hpp" #include "libslic3r/MeshBoolean.hpp" #include "libslic3r/Model.hpp" @@ -69,6 +70,7 @@ #include #include #include +#include #include #include #include @@ -257,11 +259,54 @@ TriangleSelector::TriangleSplittingData remap_texture_paint_spatial( // entry to fill. constexpr int PALETTE_LUT_EDGE = 24; -// Ceiling on the printable palette, which bounds that fill cost (and the shader's uniform array). +// The shaded preview shader's palette arrays. The palette itself stays within the paint mask's +// EnforcerBlockerType::ExtruderMax states, since every entry has to become a filament. constexpr int PALETTE_MAX_ENTRIES = 64; -// Ceiling on the filaments the palette's entries can refer to (the shaded preview shader's filament_rgb[]); -// mmu segmentation stops at Extruder16 anyway. -constexpr int PALETTE_MAX_FILAMENTS = 16; + +// A mix is an interleave that only reads as its colour from a distance; up close it is stripes. So it +// is spent only where it beats the nearest single filament by this much (CIEDE2000). Two is about +// where a side-by-side difference stops being arguable; a margin of ten already turns most of a +// greyscale ramp - the shape a height texture actually traces - back into single filaments. The +// quantizer, the mix ranking and the shaded preview shader all apply it, so they agree on where a mix +// is used. +constexpr float PREFER_PURE_DE = 2.f; + +// mix_targets(): the most pixels read per layer, and the histogram bins kept over all layers. Together +// they bound rank_mixes() to candidates x MIX_TARGET_BINS colour differences, the same order as filling +// the quantizer's lookup cube. +constexpr size_t MIX_TARGET_SAMPLES = size_t(1) << 20; +constexpr size_t MIX_TARGET_BINS = 256; +// rank_mixes() stops once the best remaining mix would improve the match by less than this, in +// CIEDE2000 averaged over every pixel of the colouring layers (see mix_targets()): a mix that only +// touches a few stray pixels is not worth a filament slot. +constexpr float MIN_MIX_GAIN = 0.05f; + +// The project's mixed filament slots, one string each, as the palette cache compares them: anything +// that changes which of them a mix can reuse changes this. +std::vector mixed_slot_signature(const DynamicPrintConfig &project_config) +{ + std::vector out; + const auto *is_mixed = project_config.option("filament_is_mixed"); + const auto *comps = project_config.option("filament_mixed_components"); + const auto *ratios = project_config.option("filament_mixed_sublayer_ratios"); + const auto *gradient = project_config.option("filament_mixed_gradient"); + if (is_mixed == nullptr || comps == nullptr || ratios == nullptr) + return out; + for (size_t i = 0; i < is_mixed->values.size(); ++i) + if (is_mixed->values[i]) + out.push_back(std::to_string(i) + ':' + (i < comps->values.size() ? comps->values[i] : std::string()) + '|' + + (i < ratios->values.size() ? ratios->values[i] : std::string()) + '|' + + (gradient != nullptr && i < gradient->values.size() && gradient->values[i] ? "g" : "")); + return out; +} + +// Whether two palettes would draw and print the same. +bool same_palette(const std::vector &l, const std::vector &r) +{ + return std::equal(l.begin(), l.end(), r.begin(), r.end(), [](const PrintableColor &x, const PrintableColor &y) { + return x.a == y.a && x.b == y.b && x.num == y.num && x.den == y.den && x.rgb == y.rgb; + }); +} // sRGB (0..1) <-> CIELAB, D65. Exactly what the preview shader's srgb_to_lab() computes, so the CPU // quantizer, the mixed-palette entries and the per-fragment preview all match in the same space. @@ -469,6 +514,15 @@ void GLGizmoTextureDisplacement::on_shutdown() // progress poll, and its completion handler then finds nothing to do. m_preview_generation->fetch_add(1); m_preview_job_pending = false; + // The palette caches hold the last volume's images; a closed gizmo should not keep them alive. + m_palette_cache.clear(); + m_palette_quantizer = nullptr; + m_palette_pure_quantizer = nullptr; + m_palette_filaments.clear(); + m_palette_images.clear(); + m_mix_ranking.reset(); + m_palette_slots.clear(); + m_palette_changed = false; m_uvcheck_glmodel.reset(); m_wireframe_overlay_glmodel.reset(); m_wireframe_overlay_vcount = 0; @@ -1495,10 +1549,9 @@ void GLGizmoTextureDisplacement::render_shaded_preview_mesh() shader->set_uniform("patch_center", m_shaded_patch_center); shader->set_uniform("patch_axis", m_shaded_patch_axis); - // The filament palette the mesh's per-triangle indices refer to. Count 0 means "no layer is - // colouring", and the shader keeps the model's own colour for every fragment. - // The printable palette, in RGB for display and in Lab for the match. Uploaded rather than - // matched on the CPU because the quantization is per fragment here. + // The printable palette, in RGB for display and in Lab for the match. Uploaded rather than matched + // on the CPU because the quantization is per fragment here. Count 0 means "no layer is colouring", + // and the shader keeps the model's own colour for every fragment. const GLTexture *color_tex = get_layer_color_texture(*layer); const int palette_count = (color_tex != nullptr) ? int(std::min(m_shaded_preview_palette.size(), size_t(PALETTE_MAX_ENTRIES))) : 0; @@ -1506,24 +1559,17 @@ void GLGizmoTextureDisplacement::render_shaded_preview_mesh() shader->set_uniform("has_color_tex", color_tex != nullptr); // A flat-colour image is matched against single filaments only, as the bake does. shader->set_uniform("pure_only", color_tex != nullptr && analyze_texture_detail(*layer).flat_colors); + shader->set_uniform("prefer_pure_de", PREFER_PURE_DE); for (int i = 0; i < palette_count; ++i) { const PaletteEntry &e = m_shaded_preview_palette[size_t(i)]; const std::string idx = "[" + std::to_string(i) + "]"; + // An entry's colour is what it prints as: its filament's, or for a mix its mixed filament slot's. shader->set_uniform(("palette_rgb" + idx).c_str(), e.rgb); shader->set_uniform(("palette_lab" + idx).c_str(), srgb_to_lab(e.rgb)); - // How the entry prints: its filament, or for a mix the two it interleaves and in what ratio. + // Only so the shader can tell a mix (a != b) from a single filament. shader->set_uniform(("palette_a" + idx).c_str(), e.a); shader->set_uniform(("palette_b" + idx).c_str(), e.b); } - // The filaments those indices refer to, and the interleave the shader resolves a mix with - the - // the mix's smooth average colour. m_palette_filaments is what m_shaded_preview_palette was built from. - const int filament_count = - (palette_count > 0) ? int(std::min(m_palette_filaments.size(), size_t(PALETTE_MAX_FILAMENTS))) : 0; - shader->set_uniform("filament_count", filament_count); - for (int i = 0; i < filament_count; ++i) { - const ColorRGBA &c = m_palette_filaments[size_t(i)]; - shader->set_uniform(("filament_rgb[" + std::to_string(i) + "]").c_str(), Vec3f(c.r(), c.g(), c.b())); - } if (color_tex != nullptr) { shader->set_uniform("color_tex", 1); glsafe(::glActiveTexture(GL_TEXTURE1)); @@ -1991,6 +2037,9 @@ void GLGizmoTextureDisplacement::rebuild_preview() // finishes after the job queued below - and, since the counter is shared with the worker, that // job also notices mid-run and aborts rather than computing a result nobody will use. m_preview_generation->fetch_add(1); + // Everything rebuilt from here on reads the current palette. Cleared before any of the early returns + // below, which would otherwise leave it set and re-run this every frame. + m_palette_changed = false; update_uv_editor(); rebuild_shaded_preview_mesh(); rebuild_paint_overlay(); @@ -2061,25 +2110,21 @@ void GLGizmoTextureDisplacement::queue_preview_job() input.volume_to_world = texture_displacement_volume_to_world(*mv); for (int i = 0; i < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++i) input.facets_data[size_t(i)] = mv->texture_displacement_facet(i).get_data(); - // Captured here rather than read in the handler: get_extruders_colors() is main-thread state and - // the preview has to be grouped against the same palette it was computed with, not whatever is - // loaded by the time it lands. + // Captured here rather than read in the handler: the palette is main-thread state, and the preview + // has to be grouped against the same palette it was computed with, not whatever it is by the time + // the result lands. input.color = color_settings_for(*mv); - // The filament list the result's indices refer to, captured with the job rather than read back - // when it lands - loading a filament meanwhile must not recolour a preview computed against a - // different list. - // Every extruder, not the palette's physical-only list: the bake writes the filament it resolved - // to, and a mix resolves to a *mixed filament slot*, which is an extruder past the physical ones. - // Grouping against the shorter list dropped every triangle carrying such a slot out of the mesh - // entirely - the relief vanished and left only the few triangles that happened to print in a plain - // filament. The palette still has to be built from physical filaments alone (see filament_palette()), - // which is why these two are not the same list. - const std::vector filaments = wxGetApp().plater()->get_extruders_colors(); + // The result names a palette entry per triangle (index + 1), so these are the colours to draw it in: + // a single filament's own, or for a mix the colour its slot will show once a bake creates it. + std::vector entry_colors; + entry_colors.reserve(input.color.palette.size()); + for (const PrintableColor &e : input.color.palette) + entry_colors.emplace_back(e.rgb.x(), e.rgb.y(), e.rgb.z(), 1.f); m_preview_job_running = true; auto &worker = wxGetApp().plater()->get_ui_job_worker(); queue_job(worker, std::make_unique(std::move(input), generation, m_preview_generation, - [this, filaments](TextureDisplacementPreviewResult result, uint64_t result_generation) { + [this, entry_colors](TextureDisplacementPreviewResult result, uint64_t result_generation) { indexed_triangle_set its = std::move(result.mesh); m_preview_job_running = false; if (result_generation != m_preview_generation->load()) { @@ -2093,14 +2138,11 @@ void GLGizmoTextureDisplacement::queue_preview_job() } else { m_preview_glmodel.reset(); m_preview_color_runs.clear(); - if (result.triangle_color.size() == its.indices.size() && !filaments.empty()) { - // Group by *filament*, not by palette entry: what the bake wrote is the resolved - // filament, interleaving already applied, so this shows the real banding rather - // than the flat average the eye will turn it into. + if (result.triangle_color.size() == its.indices.size() && !entry_colors.empty()) { indexed_triangle_set sorted; sorted.vertices = its.vertices; sorted.indices.reserve(its.indices.size()); - for (int want = 0; want <= int(filaments.size()); ++want) { + for (int want = 0; want <= int(entry_colors.size()); ++want) { const size_t first = sorted.indices.size(); for (size_t i = 0; i < its.indices.size(); ++i) if (int(result.triangle_color[i]) == want) @@ -2109,7 +2151,7 @@ void GLGizmoTextureDisplacement::queue_preview_job() continue; m_preview_color_runs.push_back( { { first * 3, sorted.indices.size() * 3 }, - want == 0 ? GLVolume::NEUTRAL_COLOR : filaments[size_t(want - 1)] }); + want == 0 ? GLVolume::NEUTRAL_COLOR : entry_colors[size_t(want - 1)] }); } m_preview_glmodel.init_from(sorted); } else { @@ -4271,79 +4313,96 @@ bool GLGizmoTextureDisplacement::any_layer_colors(const ModelVolume &mv) return false; } -void GLGizmoTextureDisplacement::bind_mixes_to_filament_slots(std::vector &palette) -{ - Sidebar *sidebar = &wxGetApp().plater()->sidebar(); - if (sidebar == nullptr) - return; - for (PaletteEntry &e : palette) { - if (!e.is_mix()) - continue; - // Components are 1-based in the config; the ratios are percentages summing to 100, which is the - // form create_mixed_filament_from_result() normalises from. - const int a_pct = int(std::lround(100.0 * double(e.num) / double(e.den))); - const int slot = sidebar->ensure_mixed_filament({ unsigned(e.a + 1), unsigned(e.b + 1) }, - { a_pct, 100 - a_pct }); - if (slot >= 0) { - e.a = e.b = slot; - e.num = e.den = 1; - } else { - // No room for another slot. Collapse to the component that dominates the blend, which is what - // the old per-triangle path did on a surface it could not band anyway. - const int dominant = (e.num * 2 >= e.den) ? e.a : e.b; - e.a = e.b = dominant; - e.num = e.den = 1; - } - } -} - TextureColorSettings GLGizmoTextureDisplacement::color_settings_for(const ModelVolume &mv) { TextureColorSettings out; if (!any_layer_colors(mv)) return out; // nothing is colouring: every colour path stays switched off out.palette = cached_palette(); - out.palette_pure = make_palette(m_palette_filaments, /* mixing */ false, PALETTE_MAX_ENTRIES); - // Done here rather than in cached_palette(): this runs when a preview or a bake is queued, off a - // user action, while that one is also touched from the render path - and creating filament slots - // there would mutate the project mid-frame. - bind_mixes_to_filament_slots(out.palette); + out.palette_pure = make_palette(m_palette_filaments, {}); out.despeckle_passes = mv.texture_displacement_options.color_despeckle; return out; } const std::vector &GLGizmoTextureDisplacement::cached_palette() { - // Rebuilt only when the loaded filaments or the mixing setting actually change. The shaded preview - // rebuilds on every paint stroke and the subdivide preview on every slider frame, and filling the - // quantizer's lookup cube for a 64-entry palette is tens of milliseconds - paying that per stroke - // is the difference between painting that keeps up and painting that stutters. - const ModelVolume *mv = texture_volume(); - const bool mixing = mv != nullptr && mv->texture_displacement_options.color_mix_enabled; + // Rebuilt only when something it depends on actually changes. The shaded preview rebuilds on every + // paint stroke and the panel asks every frame, while ranking the mixes and filling the quantizer's + // lookup cube each take tens of milliseconds - paying that per stroke is the difference between + // painting that keeps up and painting that stutters. + // + // Two levels. The ranking depends only on the filaments and the images, so dragging the count, or a + // bake creating slots, re-picks from it without ranking again; the palette and its quantizers depend + // on that pick as well. + const ModelVolume *mv = texture_volume(); + const bool mixing = mv != nullptr && mv->texture_displacement_options.color_mix_enabled; + const int mix_count = mv != nullptr ? std::max(0, mv->texture_displacement_options.color_mix_count) : 0; std::vector filaments = filament_palette(); - // Every mix costs a filament slot once they are bound to one, and the mask can name only so many - // states, so the palette has to leave room beside the physical filaments it already counts. - const int cap = int(EnforcerBlockerType::ExtruderMax); - if (m_palette_cache.empty() || filaments != m_palette_filaments || mixing != m_palette_mixing || - cap != m_palette_cap) { + // The images themselves rather than their addresses, so a freed and reallocated image can never + // pass for the old one. Whether one has colour, and whether its colours are flat, follows from it. + std::vector>> images; + if (mv != nullptr) + for (const TextureDisplacementLayer &layer : mv->texture_displacement_layers) + if (layer.color_enabled && !layer.empty()) + images.push_back(layer.image_data); + + const bool ranking_stale = filaments != m_palette_filaments || images != m_palette_images; + if (ranking_stale) { m_palette_filaments = std::move(filaments); - m_palette_mixing = mixing; - m_palette_cap = cap; - m_palette_cache = make_palette(m_palette_filaments, mixing, cap); - m_palette_quantizer = make_palette_quantizer(m_palette_cache); + m_palette_images = std::move(images); + m_mix_ranking.reset(); + } + if (mixing && !m_mix_ranking) + m_mix_ranking = rank_mixes(m_palette_filaments, mix_targets(mv->texture_displacement_layers), + int(EnforcerBlockerType::ExtruderMax) - int(m_palette_filaments.size())); + + // Which mixes the project can still print: those it has a fixed slot for, plus as many new ones as + // there are free slots. + const PresetBundle &bundle = *wxGetApp().preset_bundle; + const int free_slots = std::max(0, int(EnforcerBlockerType::ExtruderMax) - int(bundle.filament_presets.size())); + const std::vector slots = mixed_slot_signature(bundle.project_config); + if (ranking_stale || m_palette_cache.empty() || mixing != m_palette_mixing || mix_count != m_palette_mix_count || + free_slots != m_palette_free_slots || slots != m_palette_slots) { + m_palette_mixing = mixing; + m_palette_mix_count = mix_count; + m_palette_free_slots = free_slots; + m_palette_slots = slots; + std::vector mixes; + if (mixing && m_mix_ranking) + mixes = pick_mixes(*m_mix_ranking, mix_count, free_slots, [&bundle](const PaletteEntry &e) { + return find_fixed_mixed_filament(bundle.project_config, {unsigned(e.a + 1), unsigned(e.b + 1)}, + {e.a_percent(), 100 - e.a_percent()}) >= 0; + }); + std::vector palette = make_palette(m_palette_filaments, mixes); + // The previews keep what they were drawn with, so a palette that really changed under them - + // a filament or a mixed slot edited in the sidebar - has to send them round again. + m_palette_changed = m_palette_changed || (!m_palette_cache.empty() && !same_palette(palette, m_palette_cache)); + m_palette_cache = std::move(palette); + m_palette_quantizer = nullptr; + m_palette_pure_quantizer = nullptr; } return m_palette_cache; } +std::pair GLGizmoTextureDisplacement::palette_quantizers() +{ + cached_palette(); + if (!m_palette_quantizer) { + m_palette_quantizer = make_palette_quantizer(m_palette_cache); + const bool has_mixes = m_palette_cache.size() > m_palette_filaments.size(); + m_palette_pure_quantizer = has_mixes ? make_palette_quantizer(make_palette(m_palette_filaments, {})) : m_palette_quantizer; + } + return { m_palette_quantizer, m_palette_pure_quantizer }; +} + std::vector GLGizmoTextureDisplacement::filament_palette() { std::vector all = wxGetApp().plater()->get_extruders_colors(); - // Physical filaments only. The mixes this palette produces each become a mixed filament slot of - // their own (see bind_mixes_to_filament_slots()), and those slots are extruders too - so taking the - // list as it comes meant the next rebuild mixed *them* again, and handed components naming a - // virtual slot to a blend that can only name physical ones. That is what left entries reading - // "filament 1 plus nothing" and raised "Mixed filament has invalid or mismatched components". + // Physical filaments only. A mix bakes into a mixed filament slot of its own, and those slots are + // extruders too - mixing them again would hand a blend components naming a virtual slot, where it + // can only name physical ones ("Mixed filament has invalid or mismatched components"). Mixed slots + // are kept after the physical ones, so filament i here is extruder i. const auto *is_mixed = wxGetApp().preset_bundle->project_config.option("filament_is_mixed"); std::vector palette; palette.reserve(all.size()); @@ -4357,44 +4416,211 @@ std::vector GLGizmoTextureDisplacement::filament_palette() return palette; } - -std::vector GLGizmoTextureDisplacement::make_palette( - const std::vector &filaments, bool mixing, int max_entries) +std::vector GLGizmoTextureDisplacement::mix_targets( + const std::vector &layers) { - std::vector out; - const int n = int(filaments.size()); - for (int i = 0; i < n; ++i) - out.push_back({ Vec3f(filaments[size_t(i)].r(), filaments[size_t(i)].g(), filaments[size_t(i)].b()), - i, i, 1, 1 }); - if (!mixing || n < 2) - return out; + // Each layer's bins, weighted by their share of that layer's pixels. + std::vector> per_layer; + for (const TextureDisplacementLayer &layer : layers) { + if (!layer.color_enabled || layer.empty() || analyze_texture_detail(layer).flat_colors) + continue; + TextureDisplacementLayer raw = layer; + raw.smoothing = 0.f; + const DecodedHeightTexture tex = decode_height_texture(raw); + if (!tex.has_color()) + continue; - // How many intermediate steps each pair gets, chosen so the whole palette stays under - // PALETTE_MAX_ENTRIES. Fewer filaments means more room for mixes, which is also what you want: - // with two filaments the mixes are the only way to get anywhere, and with sixteen there is little - // point mixing at all. `den` is also the band/dither repeat, so a small one is a short pattern. - const int pairs = n * (n - 1) / 2; - int steps = 0; - for (int s = 5; s >= 1; --s) - if (n + pairs * s <= max_entries) { - steps = s; - break; + // 16 levels per channel, each bin keeping the mean of the colours that fell in it: a coarse grid + // to gather on, without snapping every colour to a bin corner. + struct Bin + { + double r = 0., g = 0., b = 0.; + uint32_t n = 0; + }; + std::vector bins(size_t(16 * 16 * 16)); + // Past the budget, one pixel from each run of `stride`, at an offset jittered by a fixed-seed + // generator. A fixed step would sample a lattice that a striped texture can line up with, so + // that only one of its colours is ever seen; jittered, stripes of any period or orientation are + // sampled in proportion, and the same image still always gives the same targets. The offset + // comes from the generator's high bits: a power-of-two LCG's low bits repeat every 2, 4, 8... + // steps, and the stride is a power of two for exactly the images large enough to need this. + const size_t npx = size_t(tex.width) * size_t(tex.height); + const size_t stride = std::max(1, npx / MIX_TARGET_SAMPLES); + uint64_t state = 0x9E3779B97F4A7C15ull; + size_t sampled = 0; + for (size_t start = 0; start < npx; start += stride) { + state = state * 6364136223846793005ull + 1442695040888963407ull; + const size_t i = start + size_t((uint64_t(uint32_t(state >> 32)) * uint64_t(stride)) >> 32); + if (i >= npx) + break; + const uint8_t *px = &tex.rgb[i * 3]; + Bin &bin = bins[size_t(px[0] >> 4) * 256 + size_t(px[1] >> 4) * 16 + size_t(px[2] >> 4)]; + bin.r += px[0]; + bin.g += px[1]; + bin.b += px[2]; + ++bin.n; + ++sampled; } - if (steps == 0) - return out; - const int den = steps + 1; + std::vector targets; + for (const Bin &bin : bins) + if (bin.n > 0) { + const double inv = 1. / (255. * double(bin.n)); + targets.push_back({ srgb_to_lab(Vec3f(float(bin.r * inv), float(bin.g * inv), float(bin.b * inv))), + float(double(bin.n) / double(sampled)) }); + } + per_layer.push_back(std::move(targets)); + } + // The layers weigh the same and together 1, settled before the pruning below: what rank_mixes() sums + // over the kept bins is then a mean over every pixel of the colouring layers, with the pixels of a + // dropped bin counted as no better off. + std::vector out; + for (std::vector &targets : per_layer) + for (MixTarget &t : targets) { + t.weight /= float(per_layer.size()); + out.push_back(t); + } + if (out.size() > MIX_TARGET_BINS) { + std::partial_sort(out.begin(), out.begin() + MIX_TARGET_BINS, out.end(), + [](const MixTarget &l, const MixTarget &r) { return l.weight > r.weight; }); + out.resize(MIX_TARGET_BINS); + } + return out; +} + +std::vector GLGizmoTextureDisplacement::rank_mixes( + const std::vector &filaments, const std::vector &targets, int limit) +{ + const int n = int(filaments.size()); + if (n < 2 || targets.empty() || limit <= 0) + return {}; + + // Every pair at every short-cycle ratio, coloured as its slot will be. + std::vector hex(filaments.size()); for (int i = 0; i < n; ++i) - for (int j = i + 1; j < n; ++j) { - const Vec3f lab_i = srgb_to_lab(Vec3f(filaments[size_t(i)].r(), filaments[size_t(i)].g(), filaments[size_t(i)].b())); - const Vec3f lab_j = srgb_to_lab(Vec3f(filaments[size_t(j)].r(), filaments[size_t(j)].g(), filaments[size_t(j)].b())); - for (int k = 1; k <= steps; ++k) { - // k/den of filament i, the rest of j - averaged in Lab, which is what the eye does - // when the two are interleaved too finely to resolve. - const float t = float(k) / float(den); - out.push_back({ lab_to_srgb(lab_i * t + lab_j * (1.f - t)), i, j, k, den }); + hex[size_t(i)] = encode_color(filaments[size_t(i)]); + std::vector candidates; + for (int i = 0; i < n; ++i) + for (int j = i + 1; j < n; ++j) + for (int den = 2; den <= 6; ++den) + for (int num = 1; num < den; ++num) { + if (std::gcd(num, den) != 1) + continue; // 2/4 is 1/2, already there + PaletteEntry e{ Vec3f::Zero(), i, j, num, den }; + ColorRGB blended; + if (!decode_color(blend_color_multi({ hex[size_t(i)], hex[size_t(j)] }, + { e.a_percent(), 100 - e.a_percent() }), + blended)) + continue; + e.rgb = Vec3f(blended.r(), blended.g(), blended.b()); + candidates.push_back(e); + } + + // How far each target is from the nearest single filament, and from every candidate. + const size_t nt = targets.size(), nc = candidates.size(); + std::vector filament_lab(size_t(n), Vec3f::Zero()); + for (int i = 0; i < n; ++i) + filament_lab[size_t(i)] = srgb_to_lab(Vec3f(filaments[size_t(i)].r(), filaments[size_t(i)].g(), filaments[size_t(i)].b())); + const auto de = [](const Vec3f &l, const Vec3f &r) { return DeltaE00(l.x(), l.y(), l.z(), r.x(), r.y(), r.z()); }; + std::vector pure_d(nt, std::numeric_limits::max()); + for (size_t t = 0; t < nt; ++t) + for (const Vec3f &lab : filament_lab) + pure_d[t] = std::min(pure_d[t], de(targets[t].lab, lab)); + std::vector dist(nc * nt); + tbb::parallel_for(tbb::blocked_range(0, nc), [&](const tbb::blocked_range &range) { + for (size_t c = range.begin(); c < range.end(); ++c) { + const Vec3f lab = srgb_to_lab(candidates[c].rgb); + for (size_t t = 0; t < nt; ++t) + dist[c * nt + t] = de(targets[t].lab, lab); + } + }); + + // Greedy: each round takes the candidate that lowers the weighted error the most. A candidate only + // counts where it beats the single filament by PREFER_PURE_DE, since everywhere else the quantizer + // picks the filament anyway. + std::vector current = pure_d; + std::vector taken(nc, 0); + std::vector out; + const auto counts = [&](size_t c, size_t t) { + const float d = dist[c * nt + t]; + return d < current[t] && d <= pure_d[t] - PREFER_PURE_DE; + }; + while (int(out.size()) < limit) { + size_t best = nc; + double best_gain = 0.; + for (size_t c = 0; c < nc; ++c) { + if (taken[c]) + continue; + double gain = 0.; + for (size_t t = 0; t < nt; ++t) + if (counts(c, t)) + gain += double(targets[t].weight) * double(current[t] - dist[c * nt + t]); + if (gain > best_gain) { + best_gain = gain; + best = c; } } + if (best == nc || best_gain < double(MIN_MIX_GAIN)) + break; + taken[best] = 1; + out.push_back(candidates[best]); + for (size_t t = 0; t < nt; ++t) + if (counts(best, t)) + current[t] = dist[best * nt + t]; + } + return out; +} + +std::vector GLGizmoTextureDisplacement::pick_mixes( + const std::vector &ranking, int count, int free_slots, const std::function &reusable) +{ + std::vector out; + for (const PaletteEntry &e : ranking) { + if (int(out.size()) >= count) + break; + // Out of free slots, a later mix that already has one still fits. + if (reusable && reusable(e)) { + out.push_back(e); + } else if (free_slots > 0) { + out.push_back(e); + --free_slots; + } + } + return out; +} + +std::vector GLGizmoTextureDisplacement::make_palette( + const std::vector &filaments, const std::vector &mixes) +{ + std::vector out; + out.reserve(filaments.size() + mixes.size()); + for (int i = 0; i < int(filaments.size()); ++i) + out.push_back({ Vec3f(filaments[size_t(i)].r(), filaments[size_t(i)].g(), filaments[size_t(i)].b()), i, i, 1, 1 }); + out.insert(out.end(), mixes.begin(), mixes.end()); + return out; +} + +std::vector GLGizmoTextureDisplacement::palette_filaments(const std::vector &palette, + const std::vector &triangle_color, + const std::function &slot_for_mix) +{ + std::vector used(palette.size(), 0); + for (const uint8_t v : triangle_color) + if (v > 0 && size_t(v) <= palette.size()) + used[size_t(v) - 1] = 1; + + std::vector out(palette.size(), -1); + for (size_t i = 0; i < palette.size(); ++i) { + const PaletteEntry &e = palette[i]; + if (!e.is_mix()) { + out[i] = e.a; + } else if (used[i]) { + const int slot = slot_for_mix ? slot_for_mix(e) : -1; + // No room for another slot: the component that dominates the blend is the nearest the print + // can come. + out[i] = slot >= 0 ? slot : (e.num * 2 >= e.den ? e.a : e.b); + } + } return out; } @@ -4434,15 +4660,7 @@ ColorQuantizeFn GLGizmoTextureDisplacement::make_palette_quantizer(const std::ve best_pure = int(i); } } - // A mix is an interleave that only reads as its colour from a distance; up close it is - // stripes. So it is spent only where it buys a better match than the nearest single - // filament - but "better" was set at ten Delta E, which is not a visible step, it is a - // different colour. Measured over the whole cube that threshold turned 94% of the - // lookups that wanted a mix back into a pure filament, leaving 38%; along a greyscale - // ramp, the shape a height texture actually traces, it cut 80% to 66%. Two Delta E is - // about where a side-by-side difference stops being arguable, which is the right place - // to start paying for stripes. - constexpr float PREFER_PURE_DE = 2.f; + // A mix only where it clearly beats the nearest single filament, see PREFER_PURE_DE. if (best_pure >= 0 && palette[size_t(best)].is_mix() && best_pure_d - best_d < PREFER_PURE_DE) best = best_pure; (*lut)[(size_t(r) * E + size_t(g)) * E + size_t(b)] = uint8_t(best); @@ -4461,7 +4679,7 @@ ColorQuantizeFn GLGizmoTextureDisplacement::make_palette_quantizer(const std::ve TextureDisplacementPrepareResult GLGizmoTextureDisplacement::prepare_mesh( const indexed_triangle_set &base, const TextureDisplacementFacetsData &masks, const std::vector &layers, const TextureDisplacementPrepareParams ¶ms, - const std::vector &palette, const DisplacementProgressFn &progress, + const TextureColorSettings &color_settings, const DisplacementProgressFn &progress, BakeStageRecorder *debug) { TextureDisplacementPrepareResult out; @@ -4539,10 +4757,13 @@ TextureDisplacementPrepareResult GLGizmoTextureDisplacement::prepare_mesh( // Colour boundaries need triangles of their own - the chord test cannot see them, since // the height field is perfectly smooth across a change of filament. ColorFieldSampler color; - if (params.subdiv_color_edge_mm > 0.f && !palette.empty()) - color = make_combined_color_sampler(mesh.its, layers, current, make_palette_quantizer(palette)); - // Note the sampler is built on the *quantizer* alone - the refinement follows perceived - // colour, never the interleaving that realises a mix - the slicer does that per layer. + // A flat-colour layer is matched against single filaments only, as the bake does, so its + // boundaries are refined where the bake will actually change filament. + if (params.subdiv_color_edge_mm > 0.f && !color_settings.empty()) + color = make_combined_color_sampler(mesh.its, layers, current, make_palette_quantizer(color_settings.palette), + make_palette_quantizer(color_settings.palette_pure)); + // The refinement follows perceived colour: a mix is one colour here, however the slicer + // interleaves its filaments layer by layer. // "Min edge" is a feature-mode control (it is the floor the curvature test refines down // to); in plain adaptive mode the target edge length is the only criterion, so the floor // must not be allowed to silently override a target the user set below it. @@ -4830,9 +5051,9 @@ void GLGizmoTextureDisplacement::rebuild_subdivide_preview() // Same colour criterion Apply will use, so the previewed wireframe is the mesh that commits. ColorFieldSampler color; if (m_subdivide_color_mm > 0.f && any_layer_colors(*mv)) { - cached_palette(); // refreshes m_palette_quantizer if the filaments changed - color = make_combined_color_sampler(mv->mesh().its, mv->texture_displacement_layers, facets, - m_palette_quantizer); + const auto [quantize, quantize_pure] = palette_quantizers(); + color = make_combined_color_sampler(mv->mesh().its, mv->texture_displacement_layers, facets, quantize, + quantize_pure); } its = subdivide_mesh_adaptive(mv->mesh().its, region, m_subdivide_target_mm, int(mv->mesh().its.indices.size()) + m_subdivide_budget_k * 1000, @@ -5344,6 +5565,12 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float return; ModelVolume *mv = texture_volume(); + // The palette also follows project state nobody tells this gizmo about - the filaments and mixed + // slots in the sidebar - so it is checked once a frame, and a change re-runs the previews. + cached_palette(); + if (m_palette_changed) + m_preview_params_dirty = true; + float scale = m_parent.get_scale(); #ifdef WIN32 int dpi = get_dpi_for_window(wxGetApp().GetTopWindow()); @@ -6127,6 +6354,15 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float "of filaments can cover a photo or a gradient. An image of flat colors " "prints the same either way. Off uses one filament per area.")); if (opts.color_mix_enabled) { + cached_palette(); // brings m_palette_filaments up to date + // Every mix can become a filament slot, and there are only so many beside the + // physical filaments. + const int max_mixes = std::max(1, int(EnforcerBlockerType::ExtruderMax) - int(m_palette_filaments.size())); + if (int_row("##color_mix_count", _L("Mixed colors"), &opts.color_mix_count, 1, max_mixes, "%d", card_pad)) + m_preview_params_dirty = true; + hover_tip(_u8L("The most mixed filaments a bake may add. They are picked from the " + "texture's colors, and only the ones the bake actually uses are created.")); + // After the slider, so a change shows in the same frame. ImGui::TextDisabled("%s", Slic3r::format(_u8L("%1% printable colors from %2% filaments"), int(cached_palette().size()), int(m_palette_filaments.size())).c_str()); } diff --git a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp index 2406ddbe84..01167510f9 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp @@ -17,10 +17,12 @@ #include #include "libslic3r/TriangleSelector.hpp" #include +#include #include "libslic3r/Point.hpp" #include #include #include +#include #include "slic3r/GUI/Gizmos/GLGizmoBase.hpp" #include #include @@ -58,7 +60,7 @@ public: const TextureDisplacementFacetsData &masks, const std::vector &layers, const TextureDisplacementPrepareParams ¶ms, - const std::vector &palette, + const TextureColorSettings &color_settings, const DisplacementProgressFn &progress, // Optional step capture: receives the mesh // after the remesh and after the refinement, @@ -70,22 +72,43 @@ public: using PaletteEntry = PrintableColor; - // The printable palette: the loaded filaments (clamped to the sixteen mmu_segmentation_facets can - // address), plus - when `mixing` - every pair of them at evenly spaced ratios. + // One colour mixes are meant to reach: a bin of the textures' colour histogram, in CIELAB, and how + // much of the image it covers. + struct MixTarget + { + Vec3f lab = Vec3f::Zero(); + float weight = 0.f; + }; + + // The colours worth mixing for: those of the colouring layers in `layers` whose image is not made of + // flat colours (TextureDetail::flat_colors - those print in single filaments only, so no mix could + // serve them). The layers weigh the same and, before only the heaviest bins are kept (which is what + // bounds rank_mixes()), together 1. Read from the image as imported, as analyze_texture_detail() + // does, so the Smoothing slider does not move the palette around. + static std::vector mix_targets(const std::vector &layers); + + // Mixes of pairs of `filaments`, best first and at most `limit` of them. Each is the one that most + // improves the match to `targets` given those ranked before it, counted only where it beats the + // nearest single filament by the quantizer's prefer-pure margin - which is where the quantizer will + // actually pick it. Stops early once another mix would make no noticeable difference, so a texture + // the filaments already cover gets few mixes or none. // - // Mixes are averaged in **CIELAB**, not RGB and not subtractively: two filaments interleaved too - // finely to resolve are averaged by the eye, which is what a perceptual space models. Yellow and - // blue banded together read as a desaturated grey-green, and that is what the preview must promise - // - blending them subtractively would show a green the printer cannot produce this way. - // - // How many ratios depends on how many filaments there are, so the palette stays bounded: the - // quantizer's lookup cube costs one DeltaE00 per cell per entry to fill, and with sixteen - // filaments there are already plenty of colours without mixing any of them. - // `max_entries` bounds the whole palette. It is normally the quantizer's own limit, but when the - // mixes become filament slots it has to be the paint mask's instead: a mask can name only - // EnforcerBlockerType::ExtruderMax states, and every mix now occupies one of them. - static std::vector make_palette(const std::vector &filaments, bool mixing, - int max_entries); + // Ratios are the short-cycle ones (k/d for d up to 6): the slicer interleaves a mix layer by layer, + // and a long cycle prints as visible bands rather than as a colour. A mix's `rgb` is the colour its + // mixed filament slot will show (blend_color_multi(), as the sidebar computes it), so the match, the + // preview and the slot all agree on what the mix looks like. + static std::vector rank_mixes(const std::vector &filaments, + const std::vector &targets, int limit); + + // The first `count` mixes of `ranking` the project can give a filament slot to. A mix `reusable` + // reports as already having a fixed slot costs nothing; any other uses up one of `free_slots`, and + // is skipped once they run out - so the palette never offers a colour a bake could not print. + static std::vector pick_mixes(const std::vector &ranking, int count, int free_slots, + const std::function &reusable); + + // The printable palette: the loaded filaments, entry i being filament i, followed by `mixes`. + static std::vector make_palette(const std::vector &filaments, + const std::vector &mixes); // Maps an image colour to the closest entry of `palette`, perceptually (CIEDE2000 over CIELAB - a // plain RGB distance picks visibly wrong filaments, most obviously between a saturated colour and @@ -97,40 +120,49 @@ public: // to a worker thread and outlives the palette it was built from. static ColorQuantizeFn make_palette_quantizer(const std::vector &palette); - // Turns a palette index plus a position into the filament to print there, interleaving the two - // filaments of a mixed entry per `mode`. `layer_height` sizes the Z bands; `cell_mm` the dither + // The filament (0-based) each entry of `palette` prints in, by palette index: a single filament is + // itself, a mix is the slot `slot_for_mix` returns for it. Only the mixes `triangle_color` actually + // uses (palette index + 1 per triangle, 0 for none) are asked for, since asking is what creates a + // slot. A mix that gets no slot (-1) prints in its dominant component; one nothing uses maps to -1. + // + // This is what the bake writes into the paint: a palette index is a filament only for the single + // filaments, while a mix's slot can sit anywhere among the project's mixed slots. + static std::vector palette_filaments(const std::vector &palette, + const std::vector &triangle_color, + const std::function &slot_for_mix); - // Everything the jobs need to colour with, for the current volume: palette, mix mode, layer - // height, despeckle. Empty when no layer is actually colouring. + // Everything the jobs need to colour with, for the current volume: the palette, its single-filament + // part, and the despeckle passes. Empty when no layer is actually colouring. Read-only: no filament + // slot is created here, only when a bake commits (see palette_filaments()). TextureColorSettings color_settings_for(const ModelVolume &mv); - // The printable palette for the current filaments and mixing setting, rebuilt only when either - // actually changes - see the definition for why that caching is not optional. + // The printable palette for the current volume and project, rebuilt only when what it depends on + // changes - see the definition for why that caching is not optional. const std::vector &cached_palette(); - // Turns every mix in `palette` into a mixed filament slot and rewrites the entry to name that slot - // as a plain filament, so nothing downstream has to know a mix is involved: is_mix() goes false and - // the resolver simply returns it. The per-layer interleaving then happens in the slicer, where it is - // not limited by how fine the mesh is. Entries whose slot could not be created (the paint-state cap) - // fall back to the nearer of the two components. - void bind_mixes_to_filament_slots(std::vector &palette); - std::vector m_palette_cache; - std::vector m_palette_filaments; - int m_palette_cap = 0; // the max_entries m_palette_cache was built with - bool m_palette_mixing = false; - ColorQuantizeFn m_palette_quantizer; + // The quantizers for the cached palette and for its single filaments alone (flat-colour images), + // filled on first use. Only the subdivide preview matches colours on this thread - the jobs build + // their own from the palette they capture - so a palette rebuild, such as every step of a count + // drag, costs no lookup cube unless that preview asks for one. + std::pair palette_quantizers(); + std::vector m_palette_cache; + ColorQuantizeFn m_palette_quantizer; + ColorQuantizeFn m_palette_pure_quantizer; + // Set when a rebuild changed the palette the previews were drawn with; cleared by rebuild_preview(). + bool m_palette_changed = false; + // What the cache was built from. + std::vector m_palette_filaments; + std::vector>> m_palette_images; // the colouring layers' images + std::optional> m_mix_ranking; // rank_mixes(), computed on demand + bool m_palette_mixing = false; + int m_palette_mix_count = 0; + int m_palette_free_slots = 0; + std::vector m_palette_slots; // the project's mixed slots - // The loaded filaments, clamped to the sixteen mmu_segmentation_facets can address. + // The loaded physical filaments, clamped to the states mmu_segmentation_facets can address. static std::vector filament_palette(); - // The Z band height, in mm. One print layer is the ideal, but the interleave is realised per - // *facet*: a band thinner than the mesh can resolve does not dither, it beats against the triangle - // grid and comes out as broad horizontal stripes - and since MMU segmentation reads facet colour, - // it does so in the print too, not only on screen. The refinement edge is chosen from the model's - // diagonal and knows nothing about the layer height, so the band is rounded up to a whole number of - // layers at least two facet rows tall: still exact on the printer, and representable by the mesh - // that has to carry it. Used by both the bake settings and the preview shader, so the two agree. - // The Normal preview's triangles, grouped by the filament they will print in. Colour is per facet - // and there are at most sixteen filaments, so the mesh is uploaded once with its index buffer + // The Normal preview's triangles, grouped by the palette entry they will print in. Colour is per facet + // and the palette is small, so the mesh is uploaded once with its index buffer // sorted by colour and drawn as one GLModel::render(range) per group - which needs no per-vertex // colour attribute, and so no change to GLModel's vertex layouts. // @@ -712,11 +744,9 @@ private: int m_shaded_projection_mode = 0; Vec3f m_shaded_patch_center = Vec3f::Zero(); Vec3f m_shaded_patch_axis = Vec3f::UnitZ(); - // The palette the fast preview's per-triangle filament indices were built against, captured when - // the mesh was. Empty when the active layer is not colouring, which is what tells the shader to - // fall back to the model's own colour. Held rather than re-read at draw time so the indices baked - // into the mesh can never be resolved against a different set of filaments than they were computed - // from - loading a filament mid-session would otherwise recolour a stale preview at random. + // The palette the fast preview matches each fragment against, captured with its mesh. Empty when the + // active layer is not colouring, which is what tells the shader to fall back to the model's own + // colour. Every entry carries the colour it prints in, so drawing it needs nothing else. std::vector m_shaded_preview_palette; // GPU island drag: while an island is dragged in the UV editor, the displacement mesh is baked once (with diff --git a/src/slic3r/GUI/Jobs/TextureDisplacementBakeJob.cpp b/src/slic3r/GUI/Jobs/TextureDisplacementBakeJob.cpp index b876acb62e..e769800749 100644 --- a/src/slic3r/GUI/Jobs/TextureDisplacementBakeJob.cpp +++ b/src/slic3r/GUI/Jobs/TextureDisplacementBakeJob.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include "libslic3r/TextureDisplacement.hpp" #include #include @@ -114,6 +115,17 @@ void TextureDisplacementBakeJob::finalize(bool canceled, std::exception_ptr &ept if (volume == nullptr) return; + // The filament each palette entry prints in. This is where a mix becomes a mixed filament slot, + // and only a mix the bake actually painted with: the preview never creates one, so the project + // gains only the slots this result needs. Done before anything below names them - adding a slot + // runs ModelVolume::update_extruder_count(), which clamps paint above the old filament count. + Sidebar &sidebar = plater->sidebar(); + const std::vector filament_of = GLGizmoTextureDisplacement::palette_filaments( + m_input.color.palette, m_triangle_color, [&sidebar](const PrintableColor &mix) { + return sidebar.ensure_mixed_filament({ unsigned(mix.a + 1), unsigned(mix.b + 1) }, + { mix.a_percent(), 100 - mix.a_percent() }); + }); + volume->set_mesh(std::move(m_result)); volume->set_new_unique_id(); volume->calculate_convex_hull(); @@ -129,9 +141,11 @@ void TextureDisplacementBakeJob::finalize(bool canceled, std::exception_ptr &ept const TriangleSelector::TriangleSplittingData &existing = volume->mmu_segmentation_facets.get_data(); if (!existing.bitstream.empty()) selector.deserialize(existing, false); - for (size_t i = 0; i < m_triangle_color.size(); ++i) - if (m_triangle_color[i] > 0) - selector.set_facet(int(i), EnforcerBlockerType(m_triangle_color[i])); + for (size_t i = 0; i < m_triangle_color.size(); ++i) { + const size_t entry = size_t(m_triangle_color[i]); + if (entry > 0 && entry <= filament_of.size() && filament_of[entry - 1] >= 0) + selector.set_facet(int(i), EnforcerBlockerType(filament_of[entry - 1] + 1)); + } volume->mmu_segmentation_facets.set(selector); } diff --git a/src/slic3r/GUI/Jobs/TextureDisplacementBakeJob.hpp b/src/slic3r/GUI/Jobs/TextureDisplacementBakeJob.hpp index 1fd61da0fe..40d7ab7b2b 100644 --- a/src/slic3r/GUI/Jobs/TextureDisplacementBakeJob.hpp +++ b/src/slic3r/GUI/Jobs/TextureDisplacementBakeJob.hpp @@ -56,8 +56,9 @@ private: TriangleMesh m_result; // What the bake spent, for the message it leaves behind when the budget capped the detail. TextureBakeStats m_stats; - // Per triangle of m_result: the filament to print it in, as an EnforcerBlockerType value - // (0 = leave alone). Empty unless a layer asked for colour. See TextureColorRequest. + // Per triangle of m_result: the palette entry to print it in, as its index + 1 (0 = leave alone). + // Empty unless a layer asked for colour. See TextureColorRequest; finalize() turns these into + // filaments. std::vector m_triangle_color; std::function m_on_finished; }; diff --git a/src/slic3r/GUI/Jobs/TextureDisplacementDebugJob.cpp b/src/slic3r/GUI/Jobs/TextureDisplacementDebugJob.cpp index e70125a521..12701ef3c9 100644 --- a/src/slic3r/GUI/Jobs/TextureDisplacementDebugJob.cpp +++ b/src/slic3r/GUI/Jobs/TextureDisplacementDebugJob.cpp @@ -59,7 +59,7 @@ void TextureDisplacementDebugJob::process(Ctl &ctl) if (m_input.run_prepare && !m_input.options.pipeline_v2) { const TextureDisplacementPrepareResult prepared = GLGizmoTextureDisplacement::prepare_mesh(mesh, masks, m_input.layers, m_input.prepare_params, - m_input.color.palette, + m_input.color, // Preparation is roughly half the run; the bake // takes the progress bar from there. [&report](int pct) { return report(1 + pct / 2); }, diff --git a/src/slic3r/GUI/Jobs/TextureDisplacementPrepareJob.cpp b/src/slic3r/GUI/Jobs/TextureDisplacementPrepareJob.cpp index 17d60d1fad..8a956af98d 100644 --- a/src/slic3r/GUI/Jobs/TextureDisplacementPrepareJob.cpp +++ b/src/slic3r/GUI/Jobs/TextureDisplacementPrepareJob.cpp @@ -46,7 +46,7 @@ void TextureDisplacementPrepareJob::process(Ctl &ctl) // idle loop. int last_reported = 1; m_result = GLGizmoTextureDisplacement::prepare_mesh(m_input.base_mesh, m_input.masks, m_input.layers, - m_input.params, m_input.color.palette, + m_input.params, m_input.color, [&ctl, &status, &last_reported](int percent) { if (ctl.was_canceled()) return false; diff --git a/src/slic3r/GUI/Jobs/TextureDisplacementPreviewJob.hpp b/src/slic3r/GUI/Jobs/TextureDisplacementPreviewJob.hpp index e927f4d4a3..07fcca8664 100644 --- a/src/slic3r/GUI/Jobs/TextureDisplacementPreviewJob.hpp +++ b/src/slic3r/GUI/Jobs/TextureDisplacementPreviewJob.hpp @@ -26,13 +26,13 @@ struct TextureDisplacementPreviewInput // Mesh coordinates -> world millimetres, so the preview is displaced in the same space the bake // is and the two cannot disagree. See build_texture_displacement(). Transform3d volume_to_world = Transform3d::Identity(); - // Empty unless a layer is colouring, in which case the preview reports the filament per triangle - // alongside the mesh, so the Normal view shows what the bake will produce - interleaving included. + // Empty unless a layer is colouring, in which case the preview reports the palette entry per + // triangle alongside the mesh, so the Normal view shows the colours the bake will produce. TextureColorSettings color; }; -// A preview result: the displaced mesh, and - when the input carried a palette - one filament index -// per triangle (an EnforcerBlockerType value; 0 means "no colour from the texture"). +// A preview result: the displaced mesh, and - when the input carried a palette - one palette entry per +// triangle (its index + 1; 0 means "no colour from the texture"). struct TextureDisplacementPreviewResult { indexed_triangle_set mesh; diff --git a/src/slic3r/GUI/Plater.cpp b/src/slic3r/GUI/Plater.cpp index f557ee9427..b24bfe42b4 100644 --- a/src/slic3r/GUI/Plater.cpp +++ b/src/slic3r/GUI/Plater.cpp @@ -5048,35 +5048,15 @@ static bool create_mixed_filament_from_result( is_mixed_opt->values[new_idx] = true; } - std::string comp_str; - for (size_t i = 0; i < result.components.size(); ++i) { - if (i > 0) comp_str += ","; - comp_str += std::to_string(result.components[i]); - } { auto* comp_opt = project_config.option("filament_mixed_components"); while (comp_opt->values.size() <= new_idx) comp_opt->values.push_back(std::string{}); - comp_opt->values[new_idx] = comp_str; - } - - int ratio_sum = 0; - for (int r : result.ratios) ratio_sum += r; - if (ratio_sum <= 0) ratio_sum = 100; - - std::string ratio_str; - { - CNumericLocalesSetter c_locale_setter; - for (size_t i = 0; i < result.ratios.size(); ++i) { - if (i > 0) ratio_str += ","; - char buf[32]; - std::snprintf(buf, sizeof(buf), "%.4f", (float)result.ratios[i] / ratio_sum); - ratio_str += buf; - } + comp_opt->values[new_idx] = format_mixed_components(result.components); } { auto* ratios_opt = project_config.option("filament_mixed_sublayer_ratios"); while (ratios_opt->values.size() <= new_idx) ratios_opt->values.push_back(std::string{}); - ratios_opt->values[new_idx] = ratio_str; + ratios_opt->values[new_idx] = format_mixed_ratios(result.ratios); } if (!project_config.option("filament_mixed_gradient")) @@ -5136,36 +5116,12 @@ int Sidebar::ensure_mixed_filament(const std::vector &components, return -1; if (p->combos_filament.size() < 2) return -1; - - // Normalise the way create_mixed_filament_from_result() stores them, so the comparison below sees - // the same text the config holds rather than two spellings of one blend. - int ratio_sum = 0; - for (const int r : ratios) - ratio_sum += r; - if (ratio_sum <= 0) + if (std::accumulate(ratios.begin(), ratios.end(), 0) <= 0) return -1; - std::string comp_str, ratio_str; - { - CNumericLocalesSetter c_locale_setter; - for (size_t i = 0; i < components.size(); ++i) { - if (i > 0) { comp_str += ","; ratio_str += ","; } - comp_str += std::to_string(components[i]); - char buf[32]; - std::snprintf(buf, sizeof(buf), "%.4f", float(ratios[i]) / float(ratio_sum)); - ratio_str += buf; - } - } - - const auto &project_config = wxGetApp().preset_bundle->project_config; - const auto *is_mixed_opt = project_config.option("filament_is_mixed"); - const auto *comp_opt = project_config.option("filament_mixed_components"); - const auto *ratios_opt = project_config.option("filament_mixed_sublayer_ratios"); - if (is_mixed_opt != nullptr && comp_opt != nullptr && ratios_opt != nullptr) - for (size_t i = 0; i < is_mixed_opt->values.size(); ++i) - if (is_mixed_opt->values[i] && i < comp_opt->values.size() && i < ratios_opt->values.size() && - comp_opt->values[i] == comp_str && ratios_opt->values[i] == ratio_str) - return int(i); + if (const int existing = find_fixed_mixed_filament(wxGetApp().preset_bundle->project_config, components, ratios); + existing >= 0) + return existing; if (wxGetApp().preset_bundle->filament_presets.size() >= size_t(EnforcerBlockerType::ExtruderMax)) return -1; diff --git a/src/slic3r/GUI/Plater.hpp b/src/slic3r/GUI/Plater.hpp index 46192794c6..1487c98ac9 100644 --- a/src/slic3r/GUI/Plater.hpp +++ b/src/slic3r/GUI/Plater.hpp @@ -292,11 +292,12 @@ public: // Mixed-color filament sidebar section void add_mixed_filament(); // The filament slot that blends `components` (1-based physical filament indices) in `ratios` - // (percentages), creating it when no existing mixed slot already describes that blend. Returns the - // 0-based filament index, or -1 when the paint-state cap leaves no room for another one. + // (percentages) at a fixed ratio, creating it when no existing fixed mixed slot already describes + // that blend (see find_fixed_mixed_filament()). Returns the 0-based filament index, or -1 when the + // paint-state cap leaves no room for another one. // // Exists so a feature that needs a blend can ask for one without going through the modal dialog: - // the texture displacement gizmo turns each mix in its palette into a slot, which is what moves the + // a texture displacement bake turns each mix it painted with into a slot, which is what moves the // interleaving from its own paint mask to the slicer, where it happens per layer. int ensure_mixed_filament(const std::vector &components, const std::vector &ratios); void edit_mixed_filament(size_t idx); diff --git a/tests/libslic3r/test_filament_mixer.cpp b/tests/libslic3r/test_filament_mixer.cpp index 7c01b26d87..6da90950bf 100644 --- a/tests/libslic3r/test_filament_mixer.cpp +++ b/tests/libslic3r/test_filament_mixer.cpp @@ -255,3 +255,39 @@ TEST_CASE("blend_color_multi weights components", "[FilamentMixer]") REQUIRE(std::abs(comp(mixed, i) - comp("#123456", i)) <= 8); } } + +TEST_CASE("format_mixed_ratios normalises weights to four decimals", "[FilamentMixer]") +{ + REQUIRE(format_mixed_components({1, 3}) == "1,3"); + REQUIRE(format_mixed_ratios({50, 50}) == "0.5000,0.5000"); + REQUIRE(format_mixed_ratios({1, 2}) == "0.3333,0.6667"); + REQUIRE(format_mixed_ratios({1, 1}) == format_mixed_ratios({50, 50})); +} + +TEST_CASE("find_fixed_mixed_filament reuses only a fixed slot of the same blend", "[FilamentMixer]") +{ + // Physical slots 0 and 1; slot 2 blends them 50:50 as a gradient, slot 3 at a fixed 50:50. + DynamicPrintConfig cfg; + cfg.set_key_value("filament_is_mixed", new ConfigOptionBools({false, false, true, true})); + cfg.set_key_value("filament_mixed_components", new ConfigOptionStrings({"", "", "1,2", "1,2"})); + cfg.set_key_value("filament_mixed_sublayer_ratios", + new ConfigOptionStrings({"", "", format_mixed_ratios({50, 50}), format_mixed_ratios({50, 50})})); + cfg.set_key_value("filament_mixed_gradient", new ConfigOptionBools({false, false, true, false})); + + SECTION("The fixed slot is found, whatever scale the weights are given at") { + REQUIRE(find_fixed_mixed_filament(cfg, {1, 2}, {50, 50}) == 3); + REQUIRE(find_fixed_mixed_filament(cfg, {1, 2}, {1, 1}) == 3); + } + SECTION("A gradient slot with the same components and ratios is not a match") { + cfg.option("filament_is_mixed")->values[3] = false; + REQUIRE(find_fixed_mixed_filament(cfg, {1, 2}, {50, 50}) == -1); + } + SECTION("A project without the gradient key still matches its fixed slots") { + cfg.erase("filament_mixed_gradient"); + REQUIRE(find_fixed_mixed_filament(cfg, {1, 2}, {50, 50}) == 2); + } + SECTION("Another ratio or another component order is a different blend") { + REQUIRE(find_fixed_mixed_filament(cfg, {1, 2}, {1, 2}) == -1); + REQUIRE(find_fixed_mixed_filament(cfg, {2, 1}, {50, 50}) == -1); + } +} diff --git a/tests/slic3rutils/CMakeLists.txt b/tests/slic3rutils/CMakeLists.txt index 2e0fcdb444..db56a510ac 100644 --- a/tests/slic3rutils/CMakeLists.txt +++ b/tests/slic3rutils/CMakeLists.txt @@ -36,6 +36,7 @@ add_executable(${_TEST_NAME}_tests test_plugin_audit.cpp test_plugin_json_depth.cpp test_shortcuts.cpp + test_texture_color_palette.cpp test_file_url.cpp test_user_manager.cpp ../fff_print/test_helpers.cpp diff --git a/tests/slic3rutils/test_texture_color_palette.cpp b/tests/slic3rutils/test_texture_color_palette.cpp new file mode 100644 index 0000000000..7292f9422a --- /dev/null +++ b/tests/slic3rutils/test_texture_color_palette.cpp @@ -0,0 +1,257 @@ +// The texture displacement gizmo's palette helpers live in libslic3r_gui; this is the suite that links it. +// Same Windows include prologue as test_filament_bitmap_utils.cpp (wx pulls in ; 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 +#endif + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +#include "libslic3r/Color.hpp" +#include "libslic3r/FilamentMixer.hpp" +#include "libslic3r/PNGReadWrite.hpp" +#include "libslic3r/Point.hpp" +#include "libslic3r/TextureDisplacement.hpp" +#include "slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp" +#include "slic3r/Utils/ColorSpaceConvert.hpp" +#include "test_utils.hpp" + +using namespace Slic3r; +using Catch::Matchers::WithinAbs; +using Gizmo = Slic3r::GUI::GLGizmoTextureDisplacement; +using Entry = Gizmo::PaletteEntry; +using MixTarget = Gizmo::MixTarget; + +namespace { + +const ColorRGBA BLACK{ 0.f, 0.f, 0.f, 1.f }; +const ColorRGBA WHITE{ 1.f, 1.f, 1.f, 1.f }; +const ColorRGBA RED{ 1.f, 0.f, 0.f, 1.f }; +const ColorRGBA BLUE{ 0.f, 0.f, 1.f, 1.f }; +const ColorRGBA YELLOW{ 1.f, 1.f, 0.f, 1.f }; + +MixTarget target(const Vec3f &rgb, float weight) +{ + MixTarget t; + RGB2Lab(rgb.x(), rgb.y(), rgb.z(), &t.lab.x(), &t.lab.y(), &t.lab.z()); + t.weight = weight; + return t; +} + +// The colour a mixed slot of these two filaments shows, as the sidebar computes it. +Vec3f slot_color(const ColorRGBA &a, const ColorRGBA &b, int a_percent) +{ + ColorRGB c; + REQUIRE(decode_color(blend_color_multi({ encode_color(a), encode_color(b) }, { a_percent, 100 - a_percent }), c)); + return Vec3f(c.r(), c.g(), c.b()); +} + +// A colour image layer, through Slic3r's own PNG writer so decode_height_texture() reads it the way it +// reads an imported texture. +TextureDisplacementLayer color_layer(int w, int h, const std::vector &rgb) +{ + ScopedTemporaryFile png(".png"); + REQUIRE(png::write_rgb_to_file(png.string(), size_t(w), size_t(h), rgb)); + std::ifstream in(png.string(), std::ios::binary); + std::vector bytes{ std::istreambuf_iterator(in), std::istreambuf_iterator() }; + REQUIRE_FALSE(bytes.empty()); + + TextureDisplacementLayer layer; + layer.image_data = std::make_shared>(std::move(bytes)); + layer.color_enabled = true; + return layer; +} + +// A red/green ramp over a fixed blue: colours spread over many bins, so the image is not flat-colour. +TextureDisplacementLayer gradient_layer() +{ + const int n = 64; + std::vector rgb; + for (int y = 0; y < n; ++y) + for (int x = 0; x < n; ++x) { + rgb.push_back(uint8_t(x * 4)); + rgb.push_back(uint8_t(y * 4)); + rgb.push_back(128); + } + return color_layer(n, n, rgb); +} + +// A 2048x1100 image, over mix_targets()' sampling budget, that is pure red wherever `red(x, y)` holds and +// elsewhere a gradient spread over far more than eight coarse bins, so the image is not flat-colour. +template TextureDisplacementLayer striped_layer(RedFn red) +{ + const int w = 2048, h = 1100; + std::vector rgb; + rgb.reserve(size_t(w) * size_t(h) * 3); + for (int y = 0; y < h; ++y) + for (int x = 0; x < w; ++x) { + const bool is_red = red(x, y); + rgb.push_back(is_red ? 255 : uint8_t(x * 255 / w)); + rgb.push_back(is_red ? 0 : uint8_t(y * 255 / h)); + rgb.push_back(is_red ? 0 : 128); + } + return color_layer(w, h, rgb); +} + +// How much of the targets' weight is pure red. +float red_weight(const std::vector &targets) +{ + Vec3f red; + RGB2Lab(1.f, 0.f, 0.f, &red.x(), &red.y(), &red.z()); + float weight = 0.f; + for (const MixTarget &t : targets) + if ((t.lab - red).norm() < 3.f) + weight += t.weight; + return weight; +} + +} // namespace + +TEST_CASE("mix targets cover each colouring photo layer once and skip the rest", "[TextureColorPalette][TextureDisplacement]") +{ + TextureDisplacementLayer photo = gradient_layer(); + + const std::vector targets = Gizmo::mix_targets({ photo }); + REQUIRE_FALSE(targets.empty()); + float total = 0.f; + for (const MixTarget &t : targets) + total += t.weight; + REQUIRE_THAT(total, WithinAbs(1., 1e-4)); + + SECTION("A layer that does not colour gives no targets") { + photo.color_enabled = false; + REQUIRE(Gizmo::mix_targets({ photo }).empty()); + } + SECTION("A flat-colour image prints in single filaments, so it gives no targets") { + const TextureDisplacementLayer flat = color_layer(8, 8, std::vector(8 * 8 * 3, 200)); + REQUIRE(Gizmo::mix_targets({ flat }).empty()); + } +} + +TEST_CASE("mix targets of a large image weigh each colour by its share, whatever its stripes", "[TextureColorPalette][TextureDisplacement]") +{ + // Sampled rather than read in full, these must not line up with the samples: a fixed sampling step + // sees only one phase of a stripe pattern, and a generator whose offsets repeat sees only some. + SECTION("Red on every other column") { + CHECK_THAT(red_weight(Gizmo::mix_targets({ striped_layer([](int x, int) { return x % 2 == 0; }) })), + WithinAbs(1. / 2., 0.03)); + } + SECTION("Red on every fourth column") { + CHECK_THAT(red_weight(Gizmo::mix_targets({ striped_layer([](int x, int) { return x % 4 == 0; }) })), + WithinAbs(1. / 4., 0.03)); + } + SECTION("Red on diagonals") { + CHECK_THAT(red_weight(Gizmo::mix_targets({ striped_layer([](int x, int y) { return (x - y) % 3 == 0; }) })), + WithinAbs(1. / 3., 0.03)); + } +} + +TEST_CASE("the mix ranked first is the one the image needs most", "[TextureColorPalette][TextureDisplacement]") +{ + // The whole image is exactly the colour of a 1:1 black/white slot. + const std::vector targets = { target(slot_color(BLACK, WHITE, 50), 1.f) }; + const std::vector ranked = Gizmo::rank_mixes({ BLACK, WHITE }, targets, 1); + REQUIRE(ranked.size() == 1); + CHECK(ranked.front().a == 0); + CHECK(ranked.front().b == 1); + CHECK(ranked.front().num * 2 == ranked.front().den); +} + +TEST_CASE("an image the filaments already match ranks no mixes", "[TextureColorPalette][TextureDisplacement]") +{ + const std::vector targets = { target(Vec3f(1.f, 0.f, 0.f), 0.5f), target(Vec3f(0.f, 0.f, 1.f), 0.5f) }; + REQUIRE(Gizmo::rank_mixes({ RED, BLUE }, targets, 8).empty()); +} + +TEST_CASE("ranked mixes stay within the limit and show their slot's colour", "[TextureColorPalette][TextureDisplacement]") +{ + const std::vector filaments = { RED, BLUE, YELLOW }; + const std::vector targets = Gizmo::mix_targets({ gradient_layer() }); + + for (const int limit : { 1, 3 }) { + const std::vector ranked = Gizmo::rank_mixes(filaments, targets, limit); + CHECK(int(ranked.size()) <= limit); + CHECK_FALSE(ranked.empty()); + } + + const std::vector ranked = Gizmo::rank_mixes(filaments, targets, 6); + for (const Entry &e : ranked) { + REQUIRE(e.is_mix()); + const Vec3f expected = slot_color(filaments[size_t(e.a)], filaments[size_t(e.b)], e.a_percent()); + CHECK_THAT(e.rgb.x(), WithinAbs(expected.x(), 1e-6)); + CHECK_THAT(e.rgb.y(), WithinAbs(expected.y(), 1e-6)); + CHECK_THAT(e.rgb.z(), WithinAbs(expected.z(), 1e-6)); + } +} + +TEST_CASE("picked mixes never need more slots than the project has free", "[TextureColorPalette][TextureDisplacement]") +{ + const std::vector ranking = { { Vec3f::Zero(), 0, 1, 1, 2 }, { Vec3f::Zero(), 0, 1, 1, 3 }, { Vec3f::Zero(), 0, 1, 2, 3 } }; + const auto reusable_second = [](const Entry &e) { return e.num == 1 && e.den == 3; }; + + SECTION("The count caps the pick") { + REQUIRE(Gizmo::pick_mixes(ranking, 2, 10, nullptr).size() == 2); + } + SECTION("With no free slot only a mix that already has one is kept") { + const std::vector picked = Gizmo::pick_mixes(ranking, 3, 0, reusable_second); + REQUIRE(picked.size() == 1); + CHECK(picked.front().den == 3); + CHECK(picked.front().num == 1); + } + SECTION("A reusable mix costs no free slot") { + const std::vector picked = Gizmo::pick_mixes(ranking, 3, 1, reusable_second); + REQUIRE(picked.size() == 2); + CHECK(picked[0].den == 2); + CHECK(picked[1].den == 3); + } +} + +TEST_CASE("a baked mix paints the slot it was given, wherever that slot sits", "[TextureColorPalette][TextureDisplacement]") +{ + // Two filaments, then two mixes of them. Palette index 2 is a mix, but its slot need not be + // filament 2: a project that already holds other mixed slots puts it further along. + const std::vector palette = Gizmo::make_palette({ BLACK, WHITE }, { { Vec3f::Zero(), 0, 1, 1, 2 }, { Vec3f::Zero(), 0, 1, 1, 3 } }); + REQUIRE(palette.size() == 4); + // Filament 0, the 1:1 mix twice, nothing: the 1:2 mix is never used. + const std::vector triangle_color = { 1, 3, 3, 0 }; + + std::vector asked; + const auto slot_seven = [&asked](const Entry &e) { + asked.push_back(e); + return 7; + }; + const std::vector filament = Gizmo::palette_filaments(palette, triangle_color, slot_seven); + REQUIRE(filament.size() == 4); + CHECK(filament[0] == 0); + CHECK(filament[1] == 1); + CHECK(filament[2] == 7); + CHECK(filament[3] == -1); + // Asking creates a slot, so an unused mix is never asked for. + REQUIRE(asked.size() == 1); + CHECK(asked.front().den == 2); + + SECTION("A mix that gets no slot prints in its dominant component") { + const std::vector fallback = Gizmo::palette_filaments(palette, { 3, 4 }, [](const Entry &) { return -1; }); + CHECK(fallback[2] == 0); // 1:1 - the first component + CHECK(fallback[3] == 1); // 1 part black in 3 - white dominates + } +}