Compare commits

...
Author SHA1 Message Date
SoftFever e4142db820 Let texture bakes create a chosen number of mixed colours (#16320)
# Description

Follow-up to #16242, which turned texture colour mixes into mixed
filament slots but created a slot for every possible mix, even when only
previewing. A new Mixed colors setting (default 8) caps how many mixed
filaments a bake adds. They are picked from the texture's own colours,
and only the ones the bake actually paints with are created. Previews no
longer touch the project's filaments, both previews show a mix in the
colour its slot will have, and the bake paints each mix with the slot it
got, which went wrong whenever the project already held mixed slots.

It also improves the colour preview: the green paint highlight no longer
covers the colours, and the colours a bake writes stay visible in the
gizmo.

Only texture displacement changes. Models without a colour layer behave
as before, and there is no change to project or profile formats.

# Screenshots/Recordings/Graphs

**Before**



https://github.com/user-attachments/assets/554a0d0d-cd9f-4095-840e-ca764dde6d52

**After**



https://github.com/user-attachments/assets/7fe10431-8c3f-4759-bd3f-78b4f7a12fc7



## Tests

<!--
> Please describe the tests that you have conducted to verify the
changes made in this PR.
-->

<!--
> A guide for users on how to download the artifacts from this PR.
-->

[How to Download Pull Requests Artifacts for
Testing](https://www.orcaslicer.com/wiki/how_to_download_pr_artifacts)
2026-10-10 12:00:39 +08:00
SoftFever d629922060 Keep the paint highlight off every texture preview between strokes 2026-10-10 11:44:09 +08:00
SoftFever 901b14a723 Merge branch 'main' into feature/texture-color-number 2026-10-10 02:46:28 +08:00
SoftFever b8665b69b0 Merge branch 'main' into feature/texture-color-number 2026-10-09 22:58:10 +08:00
SoftFever 7d141bd691 Keep the texture displacement gizmo out of the assemble view 2026-10-09 22:26:19 +08:00
SoftFever 10787dd59d Show the model's painted colours in the texture displacement gizmo 2026-10-09 22:25:36 +08:00
SoftFever 1fb5da4148 Keep the paint highlight off the texture colour preview between strokes
The green highlight and tint no longer cover a colour preview, and return while a stroke is
painted. The Fast view also keeps the other parts of a multi-part object.
2026-10-09 22:24:40 +08:00
SoftFever 0473da4ef8 Let texture bakes create a chosen number of mixed colours
The new Mixed colors setting caps how many mixed filaments a bake adds. They are picked from the
texture's colours, and only the ones the bake paints with are created. Previewing no longer creates
filament slots, both previews show a mix in its slot's colour, and the bake paints each mix with the
slot it actually got.
2026-10-09 22:23:07 +08:00
21 changed files with 1257 additions and 364 deletions
@@ -29,13 +29,11 @@ uniform vec3 palette_lab[64];
uniform vec3 palette_rgb[64]; uniform vec3 palette_rgb[64];
uniform int palette_count; uniform int palette_count;
uniform bool pure_only; // match against single filaments only (flat-colour image) 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 // The entry's two filaments, equal for a single filament - only so a mix can be told apart. An entry's
// slot, whose components the slicer alternates per print layer, so the fragment just looks that slot's // palette_rgb is already the colour it prints in (for a mix, its mixed filament slot's).
// colour up.
uniform int palette_a[64]; uniform int palette_a[64];
uniform int palette_b[64]; uniform int palette_b[64];
uniform vec3 filament_rgb[16]; uniform float prefer_pure_de; // PREFER_PURE_DE: how much better than a single filament a mix must be
uniform int filament_count;
uniform sampler2D color_tex; // the layer's colour image, sampled at the same uv as the height uniform sampler2D color_tex; // the layer's colour image, sampled at the same uv as the height
uniform bool has_color_tex; uniform bool has_color_tex;
uniform bool volume_mirrored; uniform bool volume_mirrored;
@@ -210,31 +208,16 @@ int nearest_palette_entry(vec3 rgb)
best = i; best = i;
} }
} }
// The same bias make_palette_quantizer() applies (PREFER_PURE_DE = 10): a mix is an interleave, so // The same bias make_palette_quantizer() applies: a mix is an interleave, so it is only worth taking
// it is only worth taking when it beats the nearest single filament by a visible step. Without it // when it beats the nearest single filament by a visible step - otherwise the preview shows mixes
// this picked a mix for almost every fragment - with four filaments the palette is 4 pure entries // where the bake prints a single filament. Compared on the distances rather than their squares, so
// against 30 mixes - while the bake picked a single filament for most of them, so the preview // the margin means the same thing as it does on the CPU (up to CIE76 against CIEDE2000, the
// interleaved the whole wall where the bake interleaves only patches. Compared on the distances // approximation already noted above).
// rather than their squares, so the threshold means the same thing as it does on the CPU (up to if (best_pure >= 0 && palette_a[best] != palette_b[best] && sqrt(bd_pure) - sqrt(bd) < prefer_pure_de)
// 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)
best = best_pure; best = best_pure;
return best; 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() void main()
{ {
if (any(lessThan(clipping_planes_dots, ZERO))) if (any(lessThan(clipping_planes_dots, ZERO)))
@@ -354,16 +337,11 @@ void main()
NdotL = max(dot(eye_normal, LIGHT_FRONT_DIR), 0.0); NdotL = max(dot(eye_normal, LIGHT_FRONT_DIR), 0.0);
intensity.x += NdotL * LIGHT_FRONT_DIFFUSE; intensity.x += NdotL * LIGHT_FRONT_DIFFUSE;
// Diffuse albedo: the image's colour at this fragment, snapped to the nearest printable colour - // 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.
// Only the albedo - the specular term (intensity.y) stays white - so a coloured fragment reads as // 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. // the same material under the same light, and the relief this preview exists to show is unaffected.
vec3 albedo = uniform_color.rgb; vec3 albedo = uniform_color.rgb;
if (palette_count > 0 && has_color_tex && have_uv && weight > 0.0) 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 albedo = palette_rgb[nearest_palette_entry(texture2D(color_tex, color_uv).rgb)];
// 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));
gl_FragColor = vec4(vec3(intensity.y) + albedo * intensity.x, uniform_color.a); gl_FragColor = vec4(vec3(intensity.y) + albedo * intensity.x, uniform_color.a);
} }
@@ -88,13 +88,11 @@ uniform vec3 palette_lab[64];
uniform vec3 palette_rgb[64]; uniform vec3 palette_rgb[64];
uniform int palette_count; uniform int palette_count;
uniform bool pure_only; // match against single filaments only (flat-colour image) 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 // The entry's two filaments, equal for a single filament - only so a mix can be told apart. An entry's
// slot, whose components the slicer alternates per print layer, so the fragment just looks that slot's // palette_rgb is already the colour it prints in (for a mix, its mixed filament slot's).
// colour up.
uniform int palette_a[64]; uniform int palette_a[64];
uniform int palette_b[64]; uniform int palette_b[64];
uniform vec3 filament_rgb[16]; uniform float prefer_pure_de; // PREFER_PURE_DE: how much better than a single filament a mix must be
uniform int filament_count;
uniform sampler2D color_tex; // the layer's colour image, sampled at the same uv as the height uniform sampler2D color_tex; // the layer's colour image, sampled at the same uv as the height
uniform bool has_color_tex; uniform bool has_color_tex;
uniform bool volume_mirrored; uniform bool volume_mirrored;
@@ -276,31 +274,16 @@ int nearest_palette_entry(vec3 rgb)
best = i; best = i;
} }
} }
// The same bias make_palette_quantizer() applies (PREFER_PURE_DE = 10): a mix is an interleave, so // The same bias make_palette_quantizer() applies: a mix is an interleave, so it is only worth taking
// it is only worth taking when it beats the nearest single filament by a visible step. Without it // when it beats the nearest single filament by a visible step - otherwise the preview shows mixes
// this picked a mix for almost every fragment - with four filaments the palette is 4 pure entries // where the bake prints a single filament. Compared on the distances rather than their squares, so
// against 30 mixes - while the bake picked a single filament for most of them, so the preview // the margin means the same thing as it does on the CPU (up to CIE76 against CIEDE2000, the
// interleaved the whole wall where the bake interleaves only patches. Compared on the distances // approximation already noted above).
// rather than their squares, so the threshold means the same thing as it does on the CPU (up to if (best_pure >= 0 && palette_a[best] != palette_b[best] && sqrt(bd_pure) - sqrt(bd) < prefer_pure_de)
// 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)
best = best_pure; best = best_pure;
return best; 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() void main()
{ {
if (any(lessThan(clipping_planes_dots, ZERO))) if (any(lessThan(clipping_planes_dots, ZERO)))
@@ -442,16 +425,11 @@ void main()
NdotL = max(dot(eye_normal, LIGHT_FRONT_DIR), 0.0); NdotL = max(dot(eye_normal, LIGHT_FRONT_DIR), 0.0);
intensity.x += NdotL * LIGHT_FRONT_DIFFUSE; intensity.x += NdotL * LIGHT_FRONT_DIFFUSE;
// Diffuse albedo: the image's colour at this fragment, snapped to the nearest printable colour - // 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.
// Only the albedo - the specular term (intensity.y) stays white - so a coloured fragment reads as // 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. // the same material under the same light, and the relief this preview exists to show is unaffected.
vec3 albedo = uniform_color.rgb; vec3 albedo = uniform_color.rgb;
if (palette_count > 0 && has_color_tex && have_uv && weight > 0.0) 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 albedo = palette_rgb[nearest_palette_entry(texture(color_tex, color_uv).rgb)];
// 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));
out_color = vec4(vec3(intensity.y) + albedo * intensity.x, uniform_color.a); out_color = vec4(vec3(intensity.y) + albedo * intensity.x, uniform_color.a);
} }
+54
View File
@@ -18,6 +18,7 @@
#include <utility> #include <utility>
#include "ColorDecomposeRecipe.hpp" #include "ColorDecomposeRecipe.hpp"
#include "Config.hpp"
#include "FilamentMixerModel.hpp" #include "FilamentMixerModel.hpp"
#include "LocalesUtils.hpp" #include "LocalesUtils.hpp"
@@ -427,6 +428,59 @@ std::vector<double> parse_mixed_ratios(const std::string &str, size_t n_componen
return ratios; return ratios;
} }
std::string format_mixed_components(const std::vector<unsigned int> &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<int> &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<unsigned int> &components,
const std::vector<int> &weights)
{
const auto *is_mixed = project_config.option<ConfigOptionBools>("filament_is_mixed");
const auto *comps = project_config.option<ConfigOptionStrings>("filament_mixed_components");
const auto *ratios = project_config.option<ConfigOptionStrings>("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<ConfigOptionBools>("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<unsigned char> &is_mixed) bool has_any_mixed_filament(const std::vector<unsigned char> &is_mixed)
{ {
for (unsigned char v : is_mixed) for (unsigned char v : is_mixed)
+18
View File
@@ -11,6 +11,8 @@
namespace Slic3r { namespace Slic3r {
class ConfigBase;
// Photoshop-style gradient curve control point in [0,1] x [0,1]. // 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 // (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 // overrides. NaN means "use the PCHIP-computed default", which is the case for plain
@@ -94,6 +96,22 @@ std::vector<unsigned int> parse_mixed_components(const std::string &str);
// Normalizes so the sum equals 1.0. // Normalizes so the sum equals 1.0.
std::vector<double> parse_mixed_ratios(const std::string &str, size_t n_components); std::vector<double> 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<unsigned int> &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<int> &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<unsigned int> &components,
const std::vector<int> &weights);
// Returns true if any element in is_mixed is true. // Returns true if any element in is_mixed is true.
// ConfigOptionBools stores values as std::vector<unsigned char>. // ConfigOptionBools stores values as std::vector<unsigned char>.
bool has_any_mixed_filament(const std::vector<unsigned char> &is_mixed); bool has_any_mixed_filament(const std::vector<unsigned char> &is_mixed);
+14
View File
@@ -247,6 +247,20 @@ void smooth_height_pixels(std::vector<uint8_t> &pixels, int width, int height, f
} }
} // namespace } // namespace
bool height_texture_has_color(const TextureDisplacementLayer &layer)
{
if (layer.empty())
return false;
{
std::lock_guard<std::mutex> lock(g_decoded_texture_cache.mutex);
const auto it = g_decoded_texture_cache.entries.find(layer.image_data.get());
if (it != g_decoded_texture_cache.entries.end() && it->second.first.lock() == layer.image_data)
return it->second.second.has_color();
}
// Not decoded yet. Decoding caches the raw image, so this happens once per image.
return decode_height_texture(layer).has_color();
}
DecodedHeightTexture decode_height_texture(const TextureDisplacementLayer &layer) DecodedHeightTexture decode_height_texture(const TextureDisplacementLayer &layer)
{ {
DecodedHeightTexture result; DecodedHeightTexture result;
+17 -5
View File
@@ -1,6 +1,7 @@
#ifndef slic3r_TextureDisplacement_hpp_ #ifndef slic3r_TextureDisplacement_hpp_
#define slic3r_TextureDisplacement_hpp_ #define slic3r_TextureDisplacement_hpp_
#include <cmath>
#include <cstddef> #include <cstddef>
#include <Eigen/Core> #include <Eigen/Core>
#include <cstdint> #include <cstdint>
@@ -387,6 +388,10 @@ struct TextureDisplacementOptions
// image (TextureDetail::flat_colors): a texture of flat colours prints in single filaments, a // image (TextureDetail::flat_colors): a texture of flat colours prints in single filaments, a
// photograph or gradient in mixes. Off forces single filaments everywhere. // photograph or gradient in mixes. Off forces single filaments everywhere.
bool color_mix_enabled = true; 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 - // 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 // 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. // 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, ar(displace_border, smooth_enabled, smooth_strength, smooth_iterations, smooth_skip_border,
pipeline_v2, v2_refine_mm, v2_regularize, v2_max_triangles_k, 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);
} }
}; };
@@ -480,6 +485,10 @@ struct DecodedHeightTexture
// DecodedHeightTexture if image_data is empty or is not a PNG at all. // DecodedHeightTexture if image_data is empty or is not a PNG at all.
DecodedHeightTexture decode_height_texture(const TextureDisplacementLayer &layer); DecodedHeightTexture decode_height_texture(const TextureDisplacementLayer &layer);
// decode_height_texture(layer).has_color(), answered from the decode cache rather than from a copy of the
// texture - cheap enough to ask every frame. Smoothing does not change it, so the raw decode is what is read.
bool height_texture_has_color(const TextureDisplacementLayer &layer);
// Maps a linear RGB colour in [0, 1] to an index into the caller's palette, or -1 for "no colour". // Maps a linear RGB colour in [0, 1] to an index into the caller's palette, or -1 for "no colour".
// //
// Deliberately a callback rather than a function here: matching a colour to a filament is a // Deliberately a callback rather than a function here: matching a colour to a filament is a
@@ -494,12 +503,14 @@ using ColorQuantizeFn = std::function<int(const Vec3f &)>;
// interleaving, which the slicer does per print layer. Plain data, so it can be captured into a job. // interleaving, which the slicer does per print layer. Plain data, so it can be captured into a job.
struct PrintableColor 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 a = 0; // filament index
int b = 0; // the second filament; == a for a pure entry int b = 0; // the second filament; == a for a pure entry
int num = 1; // a's share of the interleave, out of `den` int num = 1; // a's share of the interleave, out of `den`
int den = 1; int den = 1;
bool is_mix() const { return a != b; } 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 // Everything needed to colour a mesh, captured on the main thread and handed to a job. An empty
@@ -762,9 +773,10 @@ struct TextureColorRequest
float min_color_region_mm2 = 0.5f; float min_color_region_mm2 = 0.5f;
// Filled per *base mesh* triangle (the bake is topology-preserving, so this indexes the returned // 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 // 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 // the texture". The +1 lines up with EnforcerBlockerType, where 0 is NONE ("use the volume's own
// ("use the volume's own filament") and 1..16 are Extruder1..16, so the caller can hand these // filament"): where every palette entry is a filament, these go straight to a TriangleSelector. A
// straight to a TriangleSelector without a second mapping table. // palette with mixes maps each index to the mix's filament slot first (see
// GLGizmoTextureDisplacement::palette_filaments()).
std::vector<uint8_t> *out_triangle = nullptr; std::vector<uint8_t> *out_triangle = nullptr;
}; };
+3 -1
View File
@@ -113,7 +113,7 @@ Vec3f GLGizmoPainterBase::get_tilt_up_direction() const
return build_plate_tilt_up_direction().cast<float>(); return build_plate_tilt_up_direction().cast<float>();
} }
void GLGizmoPainterBase::render_triangles(const Selection& selection) const void GLGizmoPainterBase::render_triangles(const Selection& selection, const ModelVolume* skip) const
{ {
auto* shader = wxGetApp().get_shader("mm_gouraud"); auto* shader = wxGetApp().get_shader("mm_gouraud");
if (!shader) if (!shader)
@@ -135,6 +135,8 @@ void GLGizmoPainterBase::render_triangles(const Selection& selection) const
continue; continue;
++mesh_id; ++mesh_id;
if (mv == skip)
continue;
Transform3d trafo_matrix; Transform3d trafo_matrix;
if (m_parent.get_canvas_type() == GLCanvas3D::CanvasAssembleView) { if (m_parent.get_canvas_type() == GLCanvas3D::CanvasAssembleView) {
+8 -2
View File
@@ -48,6 +48,9 @@ public:
virtual ~TriangleSelectorGUI() = default; virtual ~TriangleSelectorGUI() = default;
virtual void render(ImGuiWrapper* imgui, const Transform3d& matrix); virtual void render(ImGuiWrapper* imgui, const Transform3d& matrix);
// The seed-fill contour alone, as render() last built it - for a gizmo that draws over the selector
// and has to put the contour back on top.
void render_paint_contour(const Transform3d& matrix);
//void render(const Transform3d& matrix) { this->render(nullptr, matrix); } //void render(const Transform3d& matrix) { this->render(nullptr, matrix); }
void set_wireframe_needed(bool need_wireframe) { m_need_wireframe = need_wireframe; } void set_wireframe_needed(bool need_wireframe) { m_need_wireframe = need_wireframe; }
bool get_wireframe_needed() { return m_need_wireframe; } bool get_wireframe_needed() { return m_need_wireframe; }
@@ -90,7 +93,6 @@ protected:
GLModel m_paint_contour; GLModel m_paint_contour;
void update_paint_contour(); void update_paint_contour();
void render_paint_contour(const Transform3d& matrix);
bool m_need_wireframe {false}; bool m_need_wireframe {false};
}; };
@@ -231,7 +233,8 @@ public:
bool on_mouse(const wxMouseEvent &mouse_event) override; bool on_mouse(const wxMouseEvent &mouse_event) override;
protected: protected:
virtual void render_triangles(const Selection& selection) const; // Draws every model part's selector, except `skip`'s when given.
virtual void render_triangles(const Selection& selection, const ModelVolume* skip = nullptr) const;
void render_cursor(); void render_cursor();
void render_cursor_circle(); void render_cursor_circle();
void render_cursor_sphere(const Transform3d& trafo) const; void render_cursor_sphere(const Transform3d& trafo) const;
@@ -328,6 +331,9 @@ protected:
TriangleSelector::ClippingPlane get_clipping_plane_in_volume_coordinates(const Transform3d &trafo) const; TriangleSelector::ClippingPlane get_clipping_plane_in_volume_coordinates(const Transform3d &trafo) const;
// True while a paint or erase stroke is under way.
bool is_painting() const { return m_button_down != Button::None; }
private: private:
std::vector<std::vector<ProjectedMousePosition>> get_projected_mouse_positions(const Vec2d &mouse_position, double resolution, const std::vector<Transform3d> &trafo_matrices) const; std::vector<std::vector<ProjectedMousePosition>> get_projected_mouse_positions(const Vec2d &mouse_position, double resolution, const std::vector<Transform3d> &trafo_matrices) const;
@@ -8,6 +8,7 @@
#include "ColorSpaceConvert.hpp" #include "ColorSpaceConvert.hpp"
#include "libslic3r/AABBTreeIndirect.hpp" #include "libslic3r/AABBTreeIndirect.hpp"
#include "libslic3r/Color.hpp" #include "libslic3r/Color.hpp"
#include "libslic3r/FilamentMixer.hpp"
#include "libslic3r/PresetBundle.hpp" #include "libslic3r/PresetBundle.hpp"
#include "libslic3r/MeshBoolean.hpp" #include "libslic3r/MeshBoolean.hpp"
#include "libslic3r/Model.hpp" #include "libslic3r/Model.hpp"
@@ -69,6 +70,7 @@
#include <array> #include <array>
#include <cmath> #include <cmath>
#include <limits> #include <limits>
#include <numeric>
#include <queue> #include <queue>
#include <set> #include <set>
#include <vector> #include <vector>
@@ -257,11 +259,54 @@ TriangleSelector::TriangleSplittingData remap_texture_paint_spatial(
// entry to fill. // entry to fill.
constexpr int PALETTE_LUT_EDGE = 24; 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; 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. // A mix is an interleave that only reads as its colour from a distance; up close it is stripes. So it
constexpr int PALETTE_MAX_FILAMENTS = 16; // 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<std::string> mixed_slot_signature(const DynamicPrintConfig &project_config)
{
std::vector<std::string> out;
const auto *is_mixed = project_config.option<ConfigOptionBools>("filament_is_mixed");
const auto *comps = project_config.option<ConfigOptionStrings>("filament_mixed_components");
const auto *ratios = project_config.option<ConfigOptionStrings>("filament_mixed_sublayer_ratios");
const auto *gradient = project_config.option<ConfigOptionBools>("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<PrintableColor> &l, const std::vector<PrintableColor> &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 // 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. // quantizer, the mixed-palette entries and the per-fragment preview all match in the same space.
@@ -458,6 +503,11 @@ std::string GLGizmoTextureDisplacement::on_get_name() const
return _u8L("Texture displacement"); return _u8L("Texture displacement");
} }
bool GLGizmoTextureDisplacement::on_is_activable() const
{
return m_parent.get_canvas_type() != GLCanvas3D::CanvasAssembleView && GLGizmoPainterBase::on_is_activable();
}
void GLGizmoTextureDisplacement::on_shutdown() void GLGizmoTextureDisplacement::on_shutdown()
{ {
m_parent.toggle_model_objects_visibility(true); m_parent.toggle_model_objects_visibility(true);
@@ -465,10 +515,25 @@ void GLGizmoTextureDisplacement::on_shutdown()
m_shaded_preview_glmodel.reset(); m_shaded_preview_glmodel.reset();
m_paint_overlay_glmodel.reset(); m_paint_overlay_glmodel.reset();
m_paint_overlay_dirty = false; m_paint_overlay_dirty = false;
m_painted_colors = PaintedColors{};
m_painted_colors_key.clear();
m_painted_colors_glmodel.reset();
m_painted_colors_drawn_key.clear();
m_painted_colors_runs.clear();
m_seed_fill_last_mesh_id = -1; // a hover from this session must not count in the next
// Any preview still in flight is superseded: raising the shared counter makes it abort at its next // Any preview still in flight is superseded: raising the shared counter makes it abort at its next
// progress poll, and its completion handler then finds nothing to do. // progress poll, and its completion handler then finds nothing to do.
m_preview_generation->fetch_add(1); m_preview_generation->fetch_add(1);
m_preview_job_pending = false; 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_uvcheck_glmodel.reset();
m_wireframe_overlay_glmodel.reset(); m_wireframe_overlay_glmodel.reset();
m_wireframe_overlay_vcount = 0; m_wireframe_overlay_vcount = 0;
@@ -527,22 +592,17 @@ void GLGizmoTextureDisplacement::render_painter_gizmo()
glsafe(::glEnable(GL_DEPTH_TEST)); glsafe(::glEnable(GL_DEPTH_TEST));
// Once anything is painted, m_preview_glmodel holds the true displaced result (same algorithm // Once anything is painted, m_preview_glmodel holds the true displaced result (same algorithm
// Bake uses). The untouched original topology (what render_triangles() draws) coincides // Bake uses). The untouched original topology (what render_triangles() draws) coincides exactly
// exactly with it everywhere except the painted/displaced area, so both are drawn: the real // with it everywhere except the displaced area, so both can be drawn: the real preview geometry
// preview geometry first, then the usual selection-highlight overlay with a small depth bias // first, then the selectors' highlight with a small depth bias so it wins the depth test on the
// so it wins the depth test on the coincident (unpainted) surface - keeping the familiar // coincident surface. Where the surface has actually been displaced, the raised preview geometry
// enforcer/blocker highlight for precise brush editing there. Where the surface has actually // legitimately occludes the flat highlight - that visible relief is itself the "this is painted"
// been displaced, the raised preview geometry legitimately occludes the flat overlay - that // indicator in that area.
// visible relief is itself the "this is painted" indicator in that area.
// //
// The shaded preview is different: it never actually moves geometry (it only shades), so // The shaded preview never actually moves geometry (it only shades), so its depth is identical to
// its depth is identical to the overlay's *everywhere*, not just in the unpainted area - the // the highlight's *everywhere*: the depth-biased opaque highlight would win the depth test across the
// depth-biased opaque overlay would win the depth test across the whole surface and hide the relief // whole surface and hide the shading entirely. So render_triangles() leaves the textured volume out
// shading entirely. So render_triangles() is skipped for it. What is *not* skipped is // there, and the translucent tint drawn further down is the paint feedback instead.
// render_paint_overlay(): leaving the shading as the only paint feedback meant a stroke that
// erased paint, or added it with no texture picked, changed nothing on screen until the whole
// preview rebuilt at stroke end - and in the true-displacement view the opaque overlay is hidden
// by the raised surface for the same reason. The translucent tint covers both cases.
// Coalesced shaded-preview rebuild from an in-progress UV island drag (see on_island_edited): done here, at // Coalesced shaded-preview rebuild from an in-progress UV island drag (see on_island_edited): done here, at
// most once per drawn frame, rather than synchronously in the UV canvas's mouse-move handler. // most once per drawn frame, rather than synchronously in the UV canvas's mouse-move handler.
if (m_use_shaded_preview && m_shaded_preview_dirty) { if (m_use_shaded_preview && m_shaded_preview_dirty) {
@@ -579,24 +639,34 @@ void GLGizmoTextureDisplacement::render_painter_gizmo()
// Hide the real volume only when something is actually going to be drawn in its place; otherwise // Hide the real volume only when something is actually going to be drawn in its place; otherwise
// put it back. Getting this wrong leaves an invisible model, so it is decided once, here, rather // put it back. Getting this wrong leaves an invisible model, so it is decided once, here, rather
// than per branch below. // than per branch below.
ModelVolume *mv = texture_volume();
m_parent.toggle_model_objects_visibility(true); m_parent.toggle_model_objects_visibility(true);
if (use_shaded || use_true_preview) { if ((use_shaded || use_true_preview) && mv != nullptr)
if (ModelVolume *mv = texture_volume()) m_parent.toggle_model_objects_visibility(false, m_c->selection_info()->model_object(),
m_parent.toggle_model_objects_visibility(false, m_c->selection_info()->model_object(), m_c->selection_info()->get_active_instance(), mv);
m_c->selection_info()->get_active_instance(), mv);
}
// Over a preview, the paint feedback - the selectors' highlight in the Normal view, the tint in both -
// is drawn only while a mouse button is down: during a stroke, a fill click included. Between strokes
// it would cover the preview: the highlight buries it under a flat plane wherever the relief does not
// rise, and the tint washes it green. The debug view shows a captured stage rather than a preview, so
// it keeps the feedback.
const bool paint_feedback = is_painting() || m_debug_stage >= 0;
// Whether the textured volume's selector - and with it a fill tool's contour - is drawn this frame.
bool textured_selector_drawn = true;
if (use_shaded) { if (use_shaded) {
render_shaded_preview_mesh(); render_shaded_preview_mesh();
// The shaded mesh is the textured volume alone, so the other model parts are still the selectors' to draw.
render_triangles(selection, mv);
textured_selector_drawn = false;
} else if (use_true_preview) { } else if (use_true_preview) {
render_preview_mesh(); render_preview_mesh();
glsafe(::glEnable(GL_POLYGON_OFFSET_FILL));
if (show_paint_overlay) { glsafe(::glPolygonOffset(-1.0f, -1.0f));
glsafe(::glEnable(GL_POLYGON_OFFSET_FILL)); // Without paint feedback, only the other model parts: render_preview_mesh() draws the textured one.
glsafe(::glPolygonOffset(-1.0f, -1.0f)); render_triangles(selection, paint_feedback ? nullptr : mv);
render_triangles(selection); glsafe(::glDisable(GL_POLYGON_OFFSET_FILL));
glsafe(::glDisable(GL_POLYGON_OFFSET_FILL)); textured_selector_drawn = paint_feedback;
}
} else { } else {
// render_triangles() *is* the model in a painter gizmo (it draws every model-part volume with the // render_triangles() *is* the model in a painter gizmo (it draws every model-part volume with the
// selector's colours), not an overlay on top of one - so it still has to run under a UV-check // selector's colours), not an overlay on top of one - so it still has to run under a UV-check
@@ -607,6 +677,10 @@ void GLGizmoTextureDisplacement::render_painter_gizmo()
render_triangles(selection); render_triangles(selection);
} }
// The model's colour paint, over whichever surface was drawn, left out where that surface's preview shows
// paint of its own: every layer's for the Normal mesh, the active layer's otherwise.
const bool painted_colors_drawn = show_paint_overlay && m_debug_stage < 0 && render_painted_colors(use_true_preview);
// Every other layer's paint, in muted grey, so all layers stay visible while one of them is edited. Drawn // Every other layer's paint, in muted grey, so all layers stay visible while one of them is edited. Drawn
// before the active layer's tint so that one reads on top where the two overlap. // before the active layer's tint so that one reads on top where the two overlap.
if (show_paint_overlay) if (show_paint_overlay)
@@ -615,10 +689,32 @@ void GLGizmoTextureDisplacement::render_painter_gizmo()
// The translucent paint tint. Needed in the shaded view because the opaque highlight above is // The translucent paint tint. Needed in the shaded view because the opaque highlight above is
// skipped there, and in the true-displacement view because the displaced surface rises *above* // skipped there, and in the true-displacement view because the displaced surface rises *above*
// the undisplaced overlay geometry and hides it exactly where the relief is strongest - in both // the undisplaced overlay geometry and hides it exactly where the relief is strongest - in both
// cases leaving an erase stroke with no visible effect until the next full preview rebuild. // cases leaving an erase stroke with no visible effect until the next full preview rebuild. With no
if (show_paint_overlay && (use_shaded || use_true_preview)) // preview, the highlight is the selectors' own, except during a stroke over the colour paint: the paint
// there is not flushed yet, so the colours are still drawn over the stroke's highlight.
const bool preview_drawn = use_shaded || use_true_preview;
if (show_paint_overlay && (preview_drawn ? paint_feedback : painted_colors_drawn && is_painting()))
render_paint_overlay(m_paint_overlay_glmodel); render_paint_overlay(m_paint_overlay_glmodel);
// A fill tool's contour is drawn with the selectors, under the colour paint drawn since - which covers it
// on a steep face, where the paint's slope-scaled offset outruns the contour's fixed one. Put it back on top,
// at the depth its first draw stored, hence LEQUAL. Only where the selector was drawn this frame: drawing it
// is what rebuilds the contour. The tool test matters because the base keeps the last hovered mesh when the
// tool changes.
const bool fill_tool = m_tool_type == ToolType::SMART_FILL || m_tool_type == ToolType::BUCKET_FILL ||
(m_tool_type == ToolType::BRUSH && m_cursor_type == TriangleSelector::CursorType::POINTER);
const int textured_mesh_id = texture_volume_raycaster_index();
if (painted_colors_drawn && textured_selector_drawn && fill_tool && textured_mesh_id >= 0 &&
textured_mesh_id == m_seed_fill_last_mesh_id && size_t(textured_mesh_id) < m_triangle_selectors.size()) {
const ModelObject *mo = m_c->selection_info()->model_object();
GLint depth_func = GL_LESS;
glsafe(::glGetIntegerv(GL_DEPTH_FUNC, &depth_func));
glsafe(::glDepthFunc(GL_LEQUAL));
m_triangle_selectors[size_t(textured_mesh_id)]->render_paint_contour(
mo->instances[selection.get_instance_idx()]->get_transformation().get_matrix() * mv->get_matrix());
glsafe(::glDepthFunc(GLenum(depth_func)));
}
// The UV editor's island selection, shown on the model. Polled here rather than pushed: the pane // The UV editor's island selection, shown on the model. Polled here rather than pushed: the pane
// changes its selection in its own mouse handling, and a compare of a few ints per frame is free. // changes its selection in its own mouse handling, and a compare of a few ints per frame is free.
{ {
@@ -1495,10 +1591,9 @@ void GLGizmoTextureDisplacement::render_shaded_preview_mesh()
shader->set_uniform("patch_center", m_shaded_patch_center); shader->set_uniform("patch_center", m_shaded_patch_center);
shader->set_uniform("patch_axis", m_shaded_patch_axis); 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 // The printable palette, in RGB for display and in Lab for the match. Uploaded rather than matched
// colouring", and the shader keeps the model's own colour for every fragment. // on the CPU because the quantization is per fragment here. Count 0 means "no layer is colouring",
// The printable palette, in RGB for display and in Lab for the match. Uploaded rather than // and the shader keeps the model's own colour for every fragment.
// matched on the CPU because the quantization is per fragment here.
const GLTexture *color_tex = get_layer_color_texture(*layer); const GLTexture *color_tex = get_layer_color_texture(*layer);
const int palette_count = const int palette_count =
(color_tex != nullptr) ? int(std::min(m_shaded_preview_palette.size(), size_t(PALETTE_MAX_ENTRIES))) : 0; (color_tex != nullptr) ? int(std::min(m_shaded_preview_palette.size(), size_t(PALETTE_MAX_ENTRIES))) : 0;
@@ -1506,24 +1601,17 @@ void GLGizmoTextureDisplacement::render_shaded_preview_mesh()
shader->set_uniform("has_color_tex", color_tex != nullptr); shader->set_uniform("has_color_tex", color_tex != nullptr);
// A flat-colour image is matched against single filaments only, as the bake does. // 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("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) { for (int i = 0; i < palette_count; ++i) {
const PaletteEntry &e = m_shaded_preview_palette[size_t(i)]; const PaletteEntry &e = m_shaded_preview_palette[size_t(i)];
const std::string idx = "[" + std::to_string(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_rgb" + idx).c_str(), e.rgb);
shader->set_uniform(("palette_lab" + idx).c_str(), srgb_to_lab(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_a" + idx).c_str(), e.a);
shader->set_uniform(("palette_b" + idx).c_str(), e.b); 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) { if (color_tex != nullptr) {
shader->set_uniform("color_tex", 1); shader->set_uniform("color_tex", 1);
glsafe(::glActiveTexture(GL_TEXTURE1)); glsafe(::glActiveTexture(GL_TEXTURE1));
@@ -1617,6 +1705,150 @@ void GLGizmoTextureDisplacement::rebuild_other_paint_overlay()
m_other_paint_glmodel.set_color(ColorRGBA(0.55f, 0.58f, 0.60f, 0.35f)); m_other_paint_glmodel.set_color(ColorRGBA(0.55f, 0.58f, 0.60f, 0.35f));
} }
std::vector<size_t> GLGizmoTextureDisplacement::PaintedColors::outside(const std::vector<bool> &excluded) const
{
std::vector<size_t> out;
out.reserve(source.size());
for (size_t i = 0; i < source.size(); ++i)
if (size_t(source[i]) >= excluded.size() || !excluded[size_t(source[i])])
out.push_back(i);
return out;
}
GLGizmoTextureDisplacement::PaintedColors GLGizmoTextureDisplacement::painted_colors(const TriangleMesh &mesh,
const TriangleSelector::TriangleSplittingData &paint)
{
PaintedColors out;
TriangleSelector selector(mesh);
selector.deserialize(paint, false);
for (const EnforcerBlockerType state : TriangleSelector::extract_used_facet_states(paint)) {
if (state == EnforcerBlockerType::NONE)
continue;
std::vector<int> source;
const indexed_triangle_set part = selector.get_facets_strict(state, &source);
// Every state comes back over the same vertex array, only the triangles differ.
if (out.facets.vertices.empty())
out.facets.vertices = part.vertices;
out.facets.indices.insert(out.facets.indices.end(), part.indices.begin(), part.indices.end());
out.source.insert(out.source.end(), source.begin(), source.end());
out.state.resize(out.facets.indices.size(), int(state));
}
return out;
}
void GLGizmoTextureDisplacement::rebuild_painted_colors(bool whole_stack)
{
const ModelVolume *mv = texture_volume();
const bool shown = mv != nullptr && any_layer_colors(*mv) && !mv->mmu_segmentation_facets.empty();
// The sub-triangles, keyed on what they were read from: the volume, its mesh and the paint.
std::string key;
if (shown)
key = std::to_string(mv->id().id) + ":" + std::to_string(reinterpret_cast<uintptr_t>(mv->mesh_ptr().get())) + ":" +
std::to_string(mv->mmu_segmentation_facets.timestamp());
if (key != m_painted_colors_key) {
m_painted_colors_key = std::move(key);
m_painted_colors = shown ? painted_colors(mv->mesh(), mv->mmu_segmentation_facets.get_data()) : PaintedColors{};
m_painted_colors_drawn_key.clear();
m_painted_colors_glmodel.reset();
m_painted_colors_runs.clear();
}
if (m_painted_colors.state.empty())
return;
// The part drawn, keyed on whose paint is left out and on that paint.
std::string drawn_key = m_painted_colors_key + (whole_stack ? std::string(":all") : ":" + std::to_string(m_active_layer_slot));
for (const TextureDisplacementLayer &l : mv->texture_displacement_layers)
if (l.slot >= 0 && l.slot < int(TEXTURE_DISPLACEMENT_MAX_LAYERS))
drawn_key += "|" + std::to_string(l.slot) + "@" + std::to_string(mv->texture_displacement_facet(l.slot).timestamp());
if (drawn_key == m_painted_colors_drawn_key)
return;
m_painted_colors_drawn_key = std::move(drawn_key);
m_painted_colors_glmodel.reset();
m_painted_colors_runs.clear();
// Whole model triangles, as the facets record what they touch: a triangle the paint only partly covers
// is left to the preview.
std::vector<bool> excluded(mv->mesh().its.indices.size(), false);
for (const TextureDisplacementLayer &l : mv->texture_displacement_layers) {
if (l.slot < 0 || l.slot >= int(TEXTURE_DISPLACEMENT_MAX_LAYERS) || (!whole_stack && l.slot != m_active_layer_slot))
continue;
for (const TriangleSelector::TriangleBitStreamMapping &m : mv->texture_displacement_facet(l.slot).get_data().triangles_to_split)
if (m.triangle_idx >= 0 && size_t(m.triangle_idx) < excluded.size())
excluded[size_t(m.triangle_idx)] = true;
}
// One model, its triangles in filament order (painted_colors() groups them), drawn a range per filament.
const std::vector<size_t> kept = m_painted_colors.outside(excluded);
GLModel::Geometry init_data;
init_data.format = { GLModel::Geometry::EPrimitiveType::Triangles, GLModel::Geometry::EVertexLayout::P3 };
init_data.reserve_vertices(kept.size() * 3);
init_data.reserve_indices(kept.size() * 3);
unsigned n = 0;
for (const size_t t : kept) {
const int state = m_painted_colors.state[t];
if (m_painted_colors_runs.empty() || m_painted_colors_runs.back().first != state)
m_painted_colors_runs.push_back({ state, { size_t(n), size_t(n) } });
for (int i = 0; i < 3; ++i)
init_data.add_vertex(m_painted_colors.facets.vertices[size_t(m_painted_colors.facets.indices[t][i])]);
init_data.add_triangle(n, n + 1, n + 2);
n += 3;
m_painted_colors_runs.back().second.second = size_t(n);
}
if (!init_data.is_empty())
m_painted_colors_glmodel.init_from(std::move(init_data));
}
bool GLGizmoTextureDisplacement::render_painted_colors(bool whole_stack)
{
rebuild_painted_colors(whole_stack);
const ModelObject *mo = m_c->selection_info()->model_object();
const ModelVolume *mv = texture_volume();
GLShaderProgram *shader = wxGetApp().get_shader("mm_gouraud");
if (mo == nullptr || mv == nullptr || shader == nullptr || !m_painted_colors_glmodel.is_initialized())
return false;
const Selection &selection = m_parent.get_selection();
const Transform3d trafo_matrix = mo->instances[selection.get_instance_idx()]->get_transformation().get_matrix() * mv->get_matrix();
const Camera &camera = wxGetApp().plater()->get_camera();
const Transform3d &view_matrix = camera.get_view_matrix();
const Matrix3d normal_matrix = trafo_matrix.matrix().block(0, 0, 3, 3).inverse().transpose();
const std::vector<ColorRGBA> colors = wxGetApp().plater()->get_extruders_colors();
shader->start_using();
// Set up as render_triangles() sets it up, so the colours are lit, clipped and slope-marked exactly as the
// neutral surface they cover.
const ClippingPlaneDataWrapper clp_data = get_clipping_plane_data();
shader->set_uniform("clipping_plane", clp_data.clp_dataf);
shader->set_uniform("z_range", clp_data.z_range);
shader->set_uniform("view_model_matrix", view_matrix * trafo_matrix);
shader->set_uniform("projection_matrix", camera.get_projection_matrix());
shader->set_uniform("view_normal_matrix", Matrix3d(view_matrix.matrix().block(0, 0, 3, 3) * normal_matrix));
shader->set_uniform("volume_world_matrix", trafo_matrix);
shader->set_uniform("volume_mirrored", trafo_matrix.matrix().determinant() < 0.);
shader->set_uniform("slope.actived", m_parent.is_using_slope());
shader->set_uniform("slope.volume_world_normal_matrix", Matrix3f(normal_matrix.cast<float>()));
shader->set_uniform("slope.normal_z", float(-std::cos(Geometry::deg2rad(m_highlight_by_angle_threshold_deg))));
shader->set_uniform("slope.up_direction", get_tilt_up_direction());
shader->set_uniform("show_wireframe", false);
// Pulled forward and without depth writes, as the tint is (see render_paint_overlay()). The tint is
// drawn after this, so it shows on top.
glsafe(::glEnable(GL_POLYGON_OFFSET_FILL));
glsafe(::glPolygonOffset(-2.f, -2.f));
glsafe(::glDepthMask(GL_FALSE));
bool drawn = false;
for (const auto &[state, range] : m_painted_colors_runs)
if (state >= 1 && size_t(state) <= colors.size()) {
m_painted_colors_glmodel.set_color(adjust_color_for_rendering(colors[size_t(state - 1)]));
m_painted_colors_glmodel.render(range, shader);
drawn = true;
}
glsafe(::glDepthMask(GL_TRUE));
glsafe(::glDisable(GL_POLYGON_OFFSET_FILL));
shader->stop_using();
return drawn;
}
void GLGizmoTextureDisplacement::rebuild_paint_overlay() void GLGizmoTextureDisplacement::rebuild_paint_overlay()
{ {
m_paint_overlay_glmodel.reset(); m_paint_overlay_glmodel.reset();
@@ -1671,11 +1903,12 @@ void GLGizmoTextureDisplacement::render_paint_overlay(GLModel &overlay)
shader->set_uniform("view_model_matrix", camera.get_view_matrix() * trafo_matrix); shader->set_uniform("view_model_matrix", camera.get_view_matrix() * trafo_matrix);
shader->set_uniform("projection_matrix", camera.get_projection_matrix()); shader->set_uniform("projection_matrix", camera.get_projection_matrix());
// Translucent, and pulled toward the camera so it wins the depth test against the coincident // Translucent, and pulled toward the camera so it wins the depth test against the coincident
// shaded surface. Depth writes are off: this is a tint, and letting it own the depth buffer would // shaded surface - and against the selectors' highlight at -1 in the Normal view, by the full depth
// make the wireframe and seam overlays drawn after it fight with geometry that is not really // unit OpenGL guarantees to tell apart. Depth writes are off: this is a tint, and letting it own the
// there. Blending is already enabled by render_painter_gizmo(). // depth buffer would make the wireframe and seam overlays drawn after it fight with geometry that is
// not really there. Blending is already enabled by render_painter_gizmo().
glsafe(::glEnable(GL_POLYGON_OFFSET_FILL)); glsafe(::glEnable(GL_POLYGON_OFFSET_FILL));
glsafe(::glPolygonOffset(-1.5f, -1.5f)); glsafe(::glPolygonOffset(-2.f, -2.f));
glsafe(::glDepthMask(GL_FALSE)); glsafe(::glDepthMask(GL_FALSE));
overlay.render(); overlay.render();
glsafe(::glDepthMask(GL_TRUE)); glsafe(::glDepthMask(GL_TRUE));
@@ -1991,6 +2224,9 @@ void GLGizmoTextureDisplacement::rebuild_preview()
// finishes after the job queued below - and, since the counter is shared with the worker, that // 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. // job also notices mid-run and aborts rather than computing a result nobody will use.
m_preview_generation->fetch_add(1); 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(); update_uv_editor();
rebuild_shaded_preview_mesh(); rebuild_shaded_preview_mesh();
rebuild_paint_overlay(); rebuild_paint_overlay();
@@ -2061,25 +2297,21 @@ void GLGizmoTextureDisplacement::queue_preview_job()
input.volume_to_world = texture_displacement_volume_to_world(*mv); input.volume_to_world = texture_displacement_volume_to_world(*mv);
for (int i = 0; i < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++i) for (int i = 0; i < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++i)
input.facets_data[size_t(i)] = mv->texture_displacement_facet(i).get_data(); 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 // Captured here rather than read in the handler: the palette is main-thread state, and the preview
// the preview has to be grouped against the same palette it was computed with, not whatever is // has to be grouped against the same palette it was computed with, not whatever it is by the time
// loaded by the time it lands. // the result lands.
input.color = color_settings_for(*mv); input.color = color_settings_for(*mv);
// The filament list the result's indices refer to, captured with the job rather than read back // The result names a palette entry per triangle (index + 1), so these are the colours to draw it in:
// when it lands - loading a filament meanwhile must not recolour a preview computed against a // a single filament's own, or for a mix the colour its slot will show once a bake creates it.
// different list. std::vector<ColorRGBA> entry_colors;
// Every extruder, not the palette's physical-only list: the bake writes the filament it resolved entry_colors.reserve(input.color.palette.size());
// to, and a mix resolves to a *mixed filament slot*, which is an extruder past the physical ones. for (const PrintableColor &e : input.color.palette)
// Grouping against the shorter list dropped every triangle carrying such a slot out of the mesh entry_colors.emplace_back(e.rgb.x(), e.rgb.y(), e.rgb.z(), 1.f);
// 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<ColorRGBA> filaments = wxGetApp().plater()->get_extruders_colors();
m_preview_job_running = true; m_preview_job_running = true;
auto &worker = wxGetApp().plater()->get_ui_job_worker(); auto &worker = wxGetApp().plater()->get_ui_job_worker();
queue_job(worker, std::make_unique<TextureDisplacementPreviewJob>(std::move(input), generation, m_preview_generation, queue_job(worker, std::make_unique<TextureDisplacementPreviewJob>(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); indexed_triangle_set its = std::move(result.mesh);
m_preview_job_running = false; m_preview_job_running = false;
if (result_generation != m_preview_generation->load()) { if (result_generation != m_preview_generation->load()) {
@@ -2093,14 +2325,11 @@ void GLGizmoTextureDisplacement::queue_preview_job()
} else { } else {
m_preview_glmodel.reset(); m_preview_glmodel.reset();
m_preview_color_runs.clear(); m_preview_color_runs.clear();
if (result.triangle_color.size() == its.indices.size() && !filaments.empty()) { if (result.triangle_color.size() == its.indices.size() && !entry_colors.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.
indexed_triangle_set sorted; indexed_triangle_set sorted;
sorted.vertices = its.vertices; sorted.vertices = its.vertices;
sorted.indices.reserve(its.indices.size()); 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(); const size_t first = sorted.indices.size();
for (size_t i = 0; i < its.indices.size(); ++i) for (size_t i = 0; i < its.indices.size(); ++i)
if (int(result.triangle_color[i]) == want) if (int(result.triangle_color[i]) == want)
@@ -2109,7 +2338,7 @@ void GLGizmoTextureDisplacement::queue_preview_job()
continue; continue;
m_preview_color_runs.push_back( m_preview_color_runs.push_back(
{ { first * 3, sorted.indices.size() * 3 }, { { 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); m_preview_glmodel.init_from(sorted);
} else { } else {
@@ -3366,6 +3595,9 @@ void GLGizmoTextureDisplacement::update_from_model_object(bool first_update)
const ModelObject *mo = m_c->selection_info()->model_object(); const ModelObject *mo = m_c->selection_info()->model_object();
m_triangle_selectors.clear(); m_triangle_selectors.clear();
// The base keeps the last mesh a fill tool hovered, and render_painter_gizmo() reads it as a hover that is
// still on: a new set of selectors has none.
m_seed_fill_last_mesh_id = -1;
std::vector<ColorRGBA> ebt_colors; std::vector<ColorRGBA> ebt_colors;
ebt_colors.push_back(GLVolume::NEUTRAL_COLOR); ebt_colors.push_back(GLVolume::NEUTRAL_COLOR);
@@ -4266,84 +4498,101 @@ TextureDisplacementFacetsData GLGizmoTextureDisplacement::facets_data_of(const M
bool GLGizmoTextureDisplacement::any_layer_colors(const ModelVolume &mv) bool GLGizmoTextureDisplacement::any_layer_colors(const ModelVolume &mv)
{ {
for (const TextureDisplacementLayer &layer : mv.texture_displacement_layers) for (const TextureDisplacementLayer &layer : mv.texture_displacement_layers)
if (layer.color_enabled && !layer.empty() && decode_height_texture(layer).has_color()) if (layer.color_enabled && !layer.empty() && height_texture_has_color(layer))
return true; return true;
return false; return false;
} }
void GLGizmoTextureDisplacement::bind_mixes_to_filament_slots(std::vector<PaletteEntry> &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 GLGizmoTextureDisplacement::color_settings_for(const ModelVolume &mv)
{ {
TextureColorSettings out; TextureColorSettings out;
if (!any_layer_colors(mv)) if (!any_layer_colors(mv))
return out; // nothing is colouring: every colour path stays switched off return out; // nothing is colouring: every colour path stays switched off
out.palette = cached_palette(); out.palette = cached_palette();
out.palette_pure = make_palette(m_palette_filaments, /* mixing */ false, PALETTE_MAX_ENTRIES); out.palette_pure = make_palette(m_palette_filaments, {});
// 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.despeckle_passes = mv.texture_displacement_options.color_despeckle; out.despeckle_passes = mv.texture_displacement_options.color_despeckle;
return out; return out;
} }
const std::vector<GLGizmoTextureDisplacement::PaletteEntry> &GLGizmoTextureDisplacement::cached_palette() const std::vector<GLGizmoTextureDisplacement::PaletteEntry> &GLGizmoTextureDisplacement::cached_palette()
{ {
// Rebuilt only when the loaded filaments or the mixing setting actually change. The shaded preview // Rebuilt only when something it depends on actually changes. The shaded preview rebuilds on every
// rebuilds on every paint stroke and the subdivide preview on every slider frame, and filling the // paint stroke and the panel asks every frame, while ranking the mixes and filling the quantizer's
// quantizer's lookup cube for a 64-entry palette is tens of milliseconds - paying that per stroke // lookup cube each take tens of milliseconds - paying that per stroke is the difference between
// is the difference between painting that keeps up and painting that stutters. // 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; // 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<ColorRGBA> filaments = filament_palette(); std::vector<ColorRGBA> filaments = filament_palette();
// Every mix costs a filament slot once they are bound to one, and the mask can name only so many // The images themselves rather than their addresses, so a freed and reallocated image can never
// states, so the palette has to leave room beside the physical filaments it already counts. // pass for the old one. Whether one has colour, and whether its colours are flat, follows from it.
const int cap = int(EnforcerBlockerType::ExtruderMax); std::vector<std::shared_ptr<std::vector<unsigned char>>> images;
if (m_palette_cache.empty() || filaments != m_palette_filaments || mixing != m_palette_mixing || if (mv != nullptr)
cap != m_palette_cap) { 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_filaments = std::move(filaments);
m_palette_mixing = mixing; m_palette_images = std::move(images);
m_palette_cap = cap; m_mix_ranking.reset();
m_palette_cache = make_palette(m_palette_filaments, mixing, cap); }
m_palette_quantizer = make_palette_quantizer(m_palette_cache); 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<std::string> 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<PaletteEntry> 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<PaletteEntry> 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; return m_palette_cache;
} }
std::pair<ColorQuantizeFn, ColorQuantizeFn> 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<ColorRGBA> GLGizmoTextureDisplacement::filament_palette() std::vector<ColorRGBA> GLGizmoTextureDisplacement::filament_palette()
{ {
std::vector<ColorRGBA> all = wxGetApp().plater()->get_extruders_colors(); std::vector<ColorRGBA> all = wxGetApp().plater()->get_extruders_colors();
// Physical filaments only. The mixes this palette produces each become a mixed filament slot of // Physical filaments only. A mix bakes into a mixed filament slot of its own, and those slots are
// their own (see bind_mixes_to_filament_slots()), and those slots are extruders too - so taking the // extruders too - mixing them again would hand a blend components naming a virtual slot, where it
// list as it comes meant the next rebuild mixed *them* again, and handed components naming a // can only name physical ones ("Mixed filament has invalid or mismatched components"). Mixed slots
// virtual slot to a blend that can only name physical ones. That is what left entries reading // are kept after the physical ones, so filament i here is extruder i.
// "filament 1 plus nothing" and raised "Mixed filament has invalid or mismatched components".
const auto *is_mixed = wxGetApp().preset_bundle->project_config.option<ConfigOptionBools>("filament_is_mixed"); const auto *is_mixed = wxGetApp().preset_bundle->project_config.option<ConfigOptionBools>("filament_is_mixed");
std::vector<ColorRGBA> palette; std::vector<ColorRGBA> palette;
palette.reserve(all.size()); palette.reserve(all.size());
@@ -4357,44 +4606,211 @@ std::vector<ColorRGBA> GLGizmoTextureDisplacement::filament_palette()
return palette; return palette;
} }
std::vector<GLGizmoTextureDisplacement::MixTarget> GLGizmoTextureDisplacement::mix_targets(
std::vector<GLGizmoTextureDisplacement::PaletteEntry> GLGizmoTextureDisplacement::make_palette( const std::vector<TextureDisplacementLayer> &layers)
const std::vector<ColorRGBA> &filaments, bool mixing, int max_entries)
{ {
std::vector<PaletteEntry> out; // Each layer's bins, weighted by their share of that layer's pixels.
const int n = int(filaments.size()); std::vector<std::vector<MixTarget>> per_layer;
for (int i = 0; i < n; ++i) for (const TextureDisplacementLayer &layer : layers) {
out.push_back({ Vec3f(filaments[size_t(i)].r(), filaments[size_t(i)].g(), filaments[size_t(i)].b()), if (!layer.color_enabled || layer.empty() || analyze_texture_detail(layer).flat_colors)
i, i, 1, 1 }); continue;
if (!mixing || n < 2) TextureDisplacementLayer raw = layer;
return out; 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 // 16 levels per channel, each bin keeping the mean of the colours that fell in it: a coarse grid
// PALETTE_MAX_ENTRIES. Fewer filaments means more room for mixes, which is also what you want: // to gather on, without snapping every colour to a bin corner.
// with two filaments the mixes are the only way to get anywhere, and with sixteen there is little struct Bin
// 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; double r = 0., g = 0., b = 0.;
int steps = 0; uint32_t n = 0;
for (int s = 5; s >= 1; --s) };
if (n + pairs * s <= max_entries) { std::vector<Bin> bins(size_t(16 * 16 * 16));
steps = s; // Past the budget, one pixel from each run of `stride`, at an offset jittered by a fixed-seed
break; // 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<size_t>(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) std::vector<MixTarget> targets;
return out; for (const Bin &bin : bins)
const int den = steps + 1; 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<MixTarget> out;
for (std::vector<MixTarget> &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::PaletteEntry> GLGizmoTextureDisplacement::rank_mixes(
const std::vector<ColorRGBA> &filaments, const std::vector<MixTarget> &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<std::string> hex(filaments.size());
for (int i = 0; i < n; ++i) for (int i = 0; i < n; ++i)
for (int j = i + 1; j < n; ++j) { hex[size_t(i)] = encode_color(filaments[size_t(i)]);
const Vec3f lab_i = srgb_to_lab(Vec3f(filaments[size_t(i)].r(), filaments[size_t(i)].g(), filaments[size_t(i)].b())); std::vector<PaletteEntry> candidates;
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 i = 0; i < n; ++i)
for (int k = 1; k <= steps; ++k) { for (int j = i + 1; j < n; ++j)
// k/den of filament i, the rest of j - averaged in Lab, which is what the eye does for (int den = 2; den <= 6; ++den)
// when the two are interleaved too finely to resolve. for (int num = 1; num < den; ++num) {
const float t = float(k) / float(den); if (std::gcd(num, den) != 1)
out.push_back({ lab_to_srgb(lab_i * t + lab_j * (1.f - t)), i, j, k, den }); 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<Vec3f> 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<float> pure_d(nt, std::numeric_limits<float>::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<float> dist(nc * nt);
tbb::parallel_for(tbb::blocked_range<size_t>(0, nc), [&](const tbb::blocked_range<size_t> &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<float> current = pure_d;
std::vector<char> taken(nc, 0);
std::vector<PaletteEntry> 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::PaletteEntry> GLGizmoTextureDisplacement::pick_mixes(
const std::vector<PaletteEntry> &ranking, int count, int free_slots, const std::function<bool(const PaletteEntry &)> &reusable)
{
std::vector<PaletteEntry> 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::PaletteEntry> GLGizmoTextureDisplacement::make_palette(
const std::vector<ColorRGBA> &filaments, const std::vector<PaletteEntry> &mixes)
{
std::vector<PaletteEntry> 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<int> GLGizmoTextureDisplacement::palette_filaments(const std::vector<PaletteEntry> &palette,
const std::vector<uint8_t> &triangle_color,
const std::function<int(const PaletteEntry &)> &slot_for_mix)
{
std::vector<char> 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<int> 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; return out;
} }
@@ -4434,15 +4850,7 @@ ColorQuantizeFn GLGizmoTextureDisplacement::make_palette_quantizer(const std::ve
best_pure = int(i); best_pure = int(i);
} }
} }
// A mix is an interleave that only reads as its colour from a distance; up close it is // A mix only where it clearly beats the nearest single filament, see PREFER_PURE_DE.
// 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;
if (best_pure >= 0 && palette[size_t(best)].is_mix() && best_pure_d - best_d < PREFER_PURE_DE) if (best_pure >= 0 && palette[size_t(best)].is_mix() && best_pure_d - best_d < PREFER_PURE_DE)
best = best_pure; best = best_pure;
(*lut)[(size_t(r) * E + size_t(g)) * E + size_t(b)] = uint8_t(best); (*lut)[(size_t(r) * E + size_t(g)) * E + size_t(b)] = uint8_t(best);
@@ -4461,7 +4869,7 @@ ColorQuantizeFn GLGizmoTextureDisplacement::make_palette_quantizer(const std::ve
TextureDisplacementPrepareResult GLGizmoTextureDisplacement::prepare_mesh( TextureDisplacementPrepareResult GLGizmoTextureDisplacement::prepare_mesh(
const indexed_triangle_set &base, const TextureDisplacementFacetsData &masks, const indexed_triangle_set &base, const TextureDisplacementFacetsData &masks,
const std::vector<TextureDisplacementLayer> &layers, const TextureDisplacementPrepareParams &params, const std::vector<TextureDisplacementLayer> &layers, const TextureDisplacementPrepareParams &params,
const std::vector<PrintableColor> &palette, const DisplacementProgressFn &progress, const TextureColorSettings &color_settings, const DisplacementProgressFn &progress,
BakeStageRecorder *debug) BakeStageRecorder *debug)
{ {
TextureDisplacementPrepareResult out; TextureDisplacementPrepareResult out;
@@ -4539,10 +4947,13 @@ TextureDisplacementPrepareResult GLGizmoTextureDisplacement::prepare_mesh(
// Colour boundaries need triangles of their own - the chord test cannot see them, since // 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. // the height field is perfectly smooth across a change of filament.
ColorFieldSampler color; ColorFieldSampler color;
if (params.subdiv_color_edge_mm > 0.f && !palette.empty()) // A flat-colour layer is matched against single filaments only, as the bake does, so its
color = make_combined_color_sampler(mesh.its, layers, current, make_palette_quantizer(palette)); // boundaries are refined where the bake will actually change filament.
// Note the sampler is built on the *quantizer* alone - the refinement follows perceived if (params.subdiv_color_edge_mm > 0.f && !color_settings.empty())
// colour, never the interleaving that realises a mix - the slicer does that per layer. 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 // "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 // 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. // must not be allowed to silently override a target the user set below it.
@@ -4830,9 +5241,9 @@ void GLGizmoTextureDisplacement::rebuild_subdivide_preview()
// Same colour criterion Apply will use, so the previewed wireframe is the mesh that commits. // Same colour criterion Apply will use, so the previewed wireframe is the mesh that commits.
ColorFieldSampler color; ColorFieldSampler color;
if (m_subdivide_color_mm > 0.f && any_layer_colors(*mv)) { if (m_subdivide_color_mm > 0.f && any_layer_colors(*mv)) {
cached_palette(); // refreshes m_palette_quantizer if the filaments changed const auto [quantize, quantize_pure] = palette_quantizers();
color = make_combined_color_sampler(mv->mesh().its, mv->texture_displacement_layers, facets, color = make_combined_color_sampler(mv->mesh().its, mv->texture_displacement_layers, facets, quantize,
m_palette_quantizer); quantize_pure);
} }
its = subdivide_mesh_adaptive(mv->mesh().its, region, m_subdivide_target_mm, its = subdivide_mesh_adaptive(mv->mesh().its, region, m_subdivide_target_mm,
int(mv->mesh().its.indices.size()) + m_subdivide_budget_k * 1000, int(mv->mesh().its.indices.size()) + m_subdivide_budget_k * 1000,
@@ -5344,6 +5755,12 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
return; return;
ModelVolume *mv = texture_volume(); 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(); float scale = m_parent.get_scale();
#ifdef WIN32 #ifdef WIN32
int dpi = get_dpi_for_window(wxGetApp().GetTopWindow()); int dpi = get_dpi_for_window(wxGetApp().GetTopWindow());
@@ -6127,6 +6544,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 " "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.")); "prints the same either way. Off uses one filament per area."));
if (opts.color_mix_enabled) { 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"), ImGui::TextDisabled("%s", Slic3r::format(_u8L("%1% printable colors from %2% filaments"),
int(cached_palette().size()), int(m_palette_filaments.size())).c_str()); int(cached_palette().size()), int(m_palette_filaments.size())).c_str());
} }
@@ -16,11 +16,15 @@
#include "libslic3r/Color.hpp" #include "libslic3r/Color.hpp"
#include <cstddef> #include <cstddef>
#include "libslic3r/TriangleSelector.hpp" #include "libslic3r/TriangleSelector.hpp"
#include "libslic3r/TriangleMesh.hpp"
#include "admesh/stl.h"
#include <cstdint> #include <cstdint>
#include <functional>
#include "libslic3r/Point.hpp" #include "libslic3r/Point.hpp"
#include <imgui.h> #include <imgui.h>
#include <map> #include <map>
#include <memory> #include <memory>
#include <optional>
#include "slic3r/GUI/Gizmos/GLGizmoBase.hpp" #include "slic3r/GUI/Gizmos/GLGizmoBase.hpp"
#include <string> #include <string>
#include <vector> #include <vector>
@@ -58,7 +62,7 @@ public:
const TextureDisplacementFacetsData &masks, const TextureDisplacementFacetsData &masks,
const std::vector<TextureDisplacementLayer> &layers, const std::vector<TextureDisplacementLayer> &layers,
const TextureDisplacementPrepareParams &params, const TextureDisplacementPrepareParams &params,
const std::vector<PrintableColor> &palette, const TextureColorSettings &color_settings,
const DisplacementProgressFn &progress, const DisplacementProgressFn &progress,
// Optional step capture: receives the mesh // Optional step capture: receives the mesh
// after the remesh and after the refinement, // after the remesh and after the refinement,
@@ -70,22 +74,43 @@ public:
using PaletteEntry = PrintableColor; using PaletteEntry = PrintableColor;
// The printable palette: the loaded filaments (clamped to the sixteen mmu_segmentation_facets can // One colour mixes are meant to reach: a bin of the textures' colour histogram, in CIELAB, and how
// address), plus - when `mixing` - every pair of them at evenly spaced ratios. // 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<MixTarget> mix_targets(const std::vector<TextureDisplacementLayer> &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 // Ratios are the short-cycle ones (k/d for d up to 6): the slicer interleaves a mix layer by layer,
// finely to resolve are averaged by the eye, which is what a perceptual space models. Yellow and // and a long cycle prints as visible bands rather than as a colour. A mix's `rgb` is the colour its
// blue banded together read as a desaturated grey-green, and that is what the preview must promise // mixed filament slot will show (blend_color_multi(), as the sidebar computes it), so the match, the
// - blending them subtractively would show a green the printer cannot produce this way. // preview and the slot all agree on what the mix looks like.
// static std::vector<PaletteEntry> rank_mixes(const std::vector<ColorRGBA> &filaments,
// How many ratios depends on how many filaments there are, so the palette stays bounded: the const std::vector<MixTarget> &targets, int limit);
// 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. // The first `count` mixes of `ranking` the project can give a filament slot to. A mix `reusable`
// `max_entries` bounds the whole palette. It is normally the quantizer's own limit, but when the // reports as already having a fixed slot costs nothing; any other uses up one of `free_slots`, and
// mixes become filament slots it has to be the paint mask's instead: a mask can name only // is skipped once they run out - so the palette never offers a colour a bake could not print.
// EnforcerBlockerType::ExtruderMax states, and every mix now occupies one of them. static std::vector<PaletteEntry> pick_mixes(const std::vector<PaletteEntry> &ranking, int count, int free_slots,
static std::vector<PaletteEntry> make_palette(const std::vector<ColorRGBA> &filaments, bool mixing, const std::function<bool(const PaletteEntry &)> &reusable);
int max_entries);
// The printable palette: the loaded filaments, entry i being filament i, followed by `mixes`.
static std::vector<PaletteEntry> make_palette(const std::vector<ColorRGBA> &filaments,
const std::vector<PaletteEntry> &mixes);
// Maps an image colour to the closest entry of `palette`, perceptually (CIEDE2000 over CIELAB - a // 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 // plain RGB distance picks visibly wrong filaments, most obviously between a saturated colour and
@@ -97,40 +122,49 @@ public:
// to a worker thread and outlives the palette it was built from. // to a worker thread and outlives the palette it was built from.
static ColorQuantizeFn make_palette_quantizer(const std::vector<PaletteEntry> &palette); static ColorQuantizeFn make_palette_quantizer(const std::vector<PaletteEntry> &palette);
// Turns a palette index plus a position into the filament to print there, interleaving the two // The filament (0-based) each entry of `palette` prints in, by palette index: a single filament is
// filaments of a mixed entry per `mode`. `layer_height` sizes the Z bands; `cell_mm` the dither // 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<int> palette_filaments(const std::vector<PaletteEntry> &palette,
const std::vector<uint8_t> &triangle_color,
const std::function<int(const PaletteEntry &)> &slot_for_mix);
// Everything the jobs need to colour with, for the current volume: palette, mix mode, layer // Everything the jobs need to colour with, for the current volume: the palette, its single-filament
// height, despeckle. Empty when no layer is actually colouring. // 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); TextureColorSettings color_settings_for(const ModelVolume &mv);
// The printable palette for the current filaments and mixing setting, rebuilt only when either // The printable palette for the current volume and project, rebuilt only when what it depends on
// actually changes - see the definition for why that caching is not optional. // changes - see the definition for why that caching is not optional.
const std::vector<PaletteEntry> &cached_palette(); const std::vector<PaletteEntry> &cached_palette();
// Turns every mix in `palette` into a mixed filament slot and rewrites the entry to name that slot // The quantizers for the cached palette and for its single filaments alone (flat-colour images),
// as a plain filament, so nothing downstream has to know a mix is involved: is_mix() goes false and // filled on first use. Only the subdivide preview matches colours on this thread - the jobs build
// the resolver simply returns it. The per-layer interleaving then happens in the slicer, where it is // their own from the palette they capture - so a palette rebuild, such as every step of a count
// not limited by how fine the mesh is. Entries whose slot could not be created (the paint-state cap) // drag, costs no lookup cube unless that preview asks for one.
// fall back to the nearer of the two components. std::pair<ColorQuantizeFn, ColorQuantizeFn> palette_quantizers();
void bind_mixes_to_filament_slots(std::vector<PaletteEntry> &palette); std::vector<PaletteEntry> m_palette_cache;
std::vector<PaletteEntry> m_palette_cache; ColorQuantizeFn m_palette_quantizer;
std::vector<ColorRGBA> m_palette_filaments; ColorQuantizeFn m_palette_pure_quantizer;
int m_palette_cap = 0; // the max_entries m_palette_cache was built with // Set when a rebuild changed the palette the previews were drawn with; cleared by rebuild_preview().
bool m_palette_mixing = false; bool m_palette_changed = false;
ColorQuantizeFn m_palette_quantizer; // What the cache was built from.
std::vector<ColorRGBA> m_palette_filaments;
std::vector<std::shared_ptr<std::vector<unsigned char>>> m_palette_images; // the colouring layers' images
std::optional<std::vector<PaletteEntry>> 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<std::string> 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<ColorRGBA> filament_palette(); static std::vector<ColorRGBA> 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 // The Normal preview's triangles, grouped by the palette entry 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 // 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 // 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. // colour attribute, and so no change to GLModel's vertex layouts.
// //
@@ -148,6 +182,21 @@ public:
// and so whether the colour criterion and the mmu write ever run. // and so whether the colour criterion and the mmu write ever run.
static bool any_layer_colors(const ModelVolume &mv); static bool any_layer_colors(const ModelVolume &mv);
// The model's own colour paint (mmu_segmentation_facets) as the gizmo draws it over its surface: the
// sub-triangles painted in a filament, grouped by that filament. NONE - the volume's own filament - is
// left out, so those triangles keep the gizmo's neutral, as they do in the preview.
struct PaintedColors
{
indexed_triangle_set facets; // over the paint's whole vertex array
std::vector<int> source; // per triangle of `facets`: the model triangle it lies in
std::vector<int> state; // per triangle of `facets`: its filament state, 1-based
// The triangles of `facets` outside the model triangles `excluded` marks, in order. A model
// triangle past the end of `excluded` is not excluded.
std::vector<size_t> outside(const std::vector<bool> &excluded) const;
};
static PaintedColors painted_colors(const TriangleMesh &mesh, const TriangleSelector::TriangleSplittingData &paint);
void render_painter_gizmo() override; void render_painter_gizmo() override;
// Intercepts mouse input while "Adjust Texture" mode is on (dragging the on-canvas offset/ // Intercepts mouse input while "Adjust Texture" mode is on (dragging the on-canvas offset/
@@ -157,6 +206,9 @@ public:
protected: protected:
void on_render_input_window(float x, float y, float bottom_limit) override; void on_render_input_window(float x, float y, float bottom_limit) override;
std::string on_get_name() const override; std::string on_get_name() const override;
// Never in the assemble view: its toolbar does not offer this gizmo, and every preview here is drawn
// with the main canvas's instance transform. The base alone would let the keyboard shortcut open it there.
bool on_is_activable() const override;
wxString handle_snapshot_action_name(bool shift_down, Button button_down) const override; wxString handle_snapshot_action_name(bool shift_down, Button button_down) const override;
@@ -675,13 +727,9 @@ private:
bool m_shaded_preview_dirty = false; bool m_shaded_preview_dirty = false;
GLModel m_shaded_preview_glmodel; GLModel m_shaded_preview_glmodel;
// Translucent tint over the active layer's painted triangles, drawn on top of whichever preview // Translucent tint over the active layer's painted triangles: the paint feedback over a preview (see
// is showing. The base painter's own opaque paint highlight (render_triangles()) cannot be used // render_painter_gizmo()). Cheap (the painted patch only), translucent so the preview shows through,
// in either preview mode - it is coincident with the surface and simply covers it - so the only // and rebuilt live during a stroke.
// paint feedback the gizmo had was the relief itself, which meant erasing showed nothing at all
// until the stroke ended and the whole preview rebuilt. This is that feedback: cheap (the painted
// patch only), translucent (the preview stays visible through it) and rebuilt live during a
// stroke.
GLModel m_paint_overlay_glmodel; GLModel m_paint_overlay_glmodel;
// The islands selected in the UV editor, tinted on the model so the pane's selection can be seen // The islands selected in the UV editor, tinted on the model so the pane's selection can be seen
// in place. Rebuilt whenever the pane's selection differs from the one it was built for. // in place. Rebuilt whenever the pane's selection differs from the one it was built for.
@@ -699,6 +747,23 @@ private:
GLModel m_other_paint_glmodel; GLModel m_other_paint_glmodel;
std::string m_other_paint_key; std::string m_other_paint_key;
void rebuild_other_paint_overlay(); void rebuild_other_paint_overlay();
// The model's colour paint, drawn over the surface so the colours a bake wrote stay visible - the
// canvas draws no volume while a paint gizmo is open, and the selectors hold only displacement paint.
// Left out wherever the preview on screen shows paint of its own (`whole_stack`: every layer's, as the
// Normal preview does; otherwise the active layer's), since an opaque overlay there would hide that
// preview. Only while a layer colours: it is the colour workflow's result, and every other paint gizmo
// shows the model neutral.
//
// Two levels: the paint's sub-triangles, which take a selector over the whole mesh and change only with
// the paint itself, and the part drawn, which follows every flushed stroke.
PaintedColors m_painted_colors;
std::string m_painted_colors_key;
GLModel m_painted_colors_glmodel;
std::string m_painted_colors_drawn_key;
std::vector<std::pair<int, std::pair<size_t, size_t>>> m_painted_colors_runs; // filament state, index range
void rebuild_painted_colors(bool whole_stack);
// False when there was nothing to draw.
bool render_painted_colors(bool whole_stack);
// Whether render_shaded_preview_mesh() would actually draw something. Checked before the real volume // Whether render_shaded_preview_mesh() would actually draw something. Checked before the real volume
// is hidden: with no layer, no texture or no shader the shaded path draws nothing, and hiding the // is hidden: with no layer, no texture or no shader the shaded path draws nothing, and hiding the
// volume for it left the model invisible. // volume for it left the model invisible.
@@ -712,11 +777,9 @@ private:
int m_shaded_projection_mode = 0; int m_shaded_projection_mode = 0;
Vec3f m_shaded_patch_center = Vec3f::Zero(); Vec3f m_shaded_patch_center = Vec3f::Zero();
Vec3f m_shaded_patch_axis = Vec3f::UnitZ(); Vec3f m_shaded_patch_axis = Vec3f::UnitZ();
// The palette the fast preview's per-triangle filament indices were built against, captured when // The palette the fast preview matches each fragment against, captured with its mesh. Empty when the
// the mesh was. Empty when the active layer is not colouring, which is what tells the shader to // active layer is not colouring, which is what tells the shader to fall back to the model's own
// fall back to the model's own colour. Held rather than re-read at draw time so the indices baked // colour. Every entry carries the colour it prints in, so drawing it needs nothing else.
// 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.
std::vector<PaletteEntry> m_shaded_preview_palette; std::vector<PaletteEntry> m_shaded_preview_palette;
// GPU island drag: while an island is dragged in the UV editor, the displacement mesh is baked once (with // GPU island drag: while an island is dragged in the UV editor, the displacement mesh is baked once (with
@@ -4,6 +4,7 @@
#include <functional> #include <functional>
#include <utility> #include <utility>
#include <string> #include <string>
#include <vector>
#include "libslic3r/TextureDisplacement.hpp" #include "libslic3r/TextureDisplacement.hpp"
#include <exception> #include <exception>
#include <cstddef> #include <cstddef>
@@ -114,6 +115,17 @@ void TextureDisplacementBakeJob::finalize(bool canceled, std::exception_ptr &ept
if (volume == nullptr) if (volume == nullptr)
return; 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<int> 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_mesh(std::move(m_result));
volume->set_new_unique_id(); volume->set_new_unique_id();
volume->calculate_convex_hull(); 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(); const TriangleSelector::TriangleSplittingData &existing = volume->mmu_segmentation_facets.get_data();
if (!existing.bitstream.empty()) if (!existing.bitstream.empty())
selector.deserialize(existing, false); selector.deserialize(existing, false);
for (size_t i = 0; i < m_triangle_color.size(); ++i) for (size_t i = 0; i < m_triangle_color.size(); ++i) {
if (m_triangle_color[i] > 0) const size_t entry = size_t(m_triangle_color[i]);
selector.set_facet(int(i), EnforcerBlockerType(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); volume->mmu_segmentation_facets.set(selector);
} }
@@ -56,8 +56,9 @@ private:
TriangleMesh m_result; TriangleMesh m_result;
// What the bake spent, for the message it leaves behind when the budget capped the detail. // What the bake spent, for the message it leaves behind when the budget capped the detail.
TextureBakeStats m_stats; TextureBakeStats m_stats;
// Per triangle of m_result: the filament to print it in, as an EnforcerBlockerType value // Per triangle of m_result: the palette entry to print it in, as its index + 1 (0 = leave alone).
// (0 = leave alone). Empty unless a layer asked for colour. See TextureColorRequest. // Empty unless a layer asked for colour. See TextureColorRequest; finalize() turns these into
// filaments.
std::vector<uint8_t> m_triangle_color; std::vector<uint8_t> m_triangle_color;
std::function<void()> m_on_finished; std::function<void()> m_on_finished;
}; };
@@ -59,7 +59,7 @@ void TextureDisplacementDebugJob::process(Ctl &ctl)
if (m_input.run_prepare && !m_input.options.pipeline_v2) { if (m_input.run_prepare && !m_input.options.pipeline_v2) {
const TextureDisplacementPrepareResult prepared = const TextureDisplacementPrepareResult prepared =
GLGizmoTextureDisplacement::prepare_mesh(mesh, masks, m_input.layers, m_input.prepare_params, 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 // Preparation is roughly half the run; the bake
// takes the progress bar from there. // takes the progress bar from there.
[&report](int pct) { return report(1 + pct / 2); }, [&report](int pct) { return report(1 + pct / 2); },
@@ -46,7 +46,7 @@ void TextureDisplacementPrepareJob::process(Ctl &ctl)
// idle loop. // idle loop.
int last_reported = 1; int last_reported = 1;
m_result = GLGizmoTextureDisplacement::prepare_mesh(m_input.base_mesh, m_input.masks, m_input.layers, 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) { [&ctl, &status, &last_reported](int percent) {
if (ctl.was_canceled()) if (ctl.was_canceled())
return false; return false;
@@ -26,13 +26,13 @@ struct TextureDisplacementPreviewInput
// Mesh coordinates -> world millimetres, so the preview is displaced in the same space the bake // 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(). // is and the two cannot disagree. See build_texture_displacement().
Transform3d volume_to_world = Transform3d::Identity(); Transform3d volume_to_world = Transform3d::Identity();
// Empty unless a layer is colouring, in which case the preview reports the filament per triangle // Empty unless a layer is colouring, in which case the preview reports the palette entry per
// alongside the mesh, so the Normal view shows what the bake will produce - interleaving included. // triangle alongside the mesh, so the Normal view shows the colours the bake will produce.
TextureColorSettings color; TextureColorSettings color;
}; };
// A preview result: the displaced mesh, and - when the input carried a palette - one filament index // A preview result: the displaced mesh, and - when the input carried a palette - one palette entry per
// per triangle (an EnforcerBlockerType value; 0 means "no colour from the texture"). // triangle (its index + 1; 0 means "no colour from the texture").
struct TextureDisplacementPreviewResult struct TextureDisplacementPreviewResult
{ {
indexed_triangle_set mesh; indexed_triangle_set mesh;
+6 -50
View File
@@ -5048,35 +5048,15 @@ static bool create_mixed_filament_from_result(
is_mixed_opt->values[new_idx] = true; 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<ConfigOptionStrings>("filament_mixed_components"); auto* comp_opt = project_config.option<ConfigOptionStrings>("filament_mixed_components");
while (comp_opt->values.size() <= new_idx) comp_opt->values.push_back(std::string{}); while (comp_opt->values.size() <= new_idx) comp_opt->values.push_back(std::string{});
comp_opt->values[new_idx] = comp_str; comp_opt->values[new_idx] = format_mixed_components(result.components);
}
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;
}
} }
{ {
auto* ratios_opt = project_config.option<ConfigOptionStrings>("filament_mixed_sublayer_ratios"); auto* ratios_opt = project_config.option<ConfigOptionStrings>("filament_mixed_sublayer_ratios");
while (ratios_opt->values.size() <= new_idx) ratios_opt->values.push_back(std::string{}); 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")) if (!project_config.option("filament_mixed_gradient"))
@@ -5136,36 +5116,12 @@ int Sidebar::ensure_mixed_filament(const std::vector<unsigned int> &components,
return -1; return -1;
if (p->combos_filament.size() < 2) if (p->combos_filament.size() < 2)
return -1; return -1;
if (std::accumulate(ratios.begin(), ratios.end(), 0) <= 0)
// 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)
return -1; return -1;
std::string comp_str, ratio_str; if (const int existing = find_fixed_mixed_filament(wxGetApp().preset_bundle->project_config, components, ratios);
{ existing >= 0)
CNumericLocalesSetter c_locale_setter; return existing;
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<ConfigOptionBools>("filament_is_mixed");
const auto *comp_opt = project_config.option<ConfigOptionStrings>("filament_mixed_components");
const auto *ratios_opt = project_config.option<ConfigOptionStrings>("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 (wxGetApp().preset_bundle->filament_presets.size() >= size_t(EnforcerBlockerType::ExtruderMax)) if (wxGetApp().preset_bundle->filament_presets.size() >= size_t(EnforcerBlockerType::ExtruderMax))
return -1; return -1;
+4 -3
View File
@@ -292,11 +292,12 @@ public:
// Mixed-color filament sidebar section // Mixed-color filament sidebar section
void add_mixed_filament(); void add_mixed_filament();
// The filament slot that blends `components` (1-based physical filament indices) in `ratios` // 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 // (percentages) at a fixed ratio, creating it when no existing fixed mixed slot already describes
// 0-based filament index, or -1 when the paint-state cap leaves no room for another one. // 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: // 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. // interleaving from its own paint mask to the slicer, where it happens per layer.
int ensure_mixed_filament(const std::vector<unsigned int> &components, const std::vector<int> &ratios); int ensure_mixed_filament(const std::vector<unsigned int> &components, const std::vector<int> &ratios);
void edit_mixed_filament(size_t idx); void edit_mixed_filament(size_t idx);
+36
View File
@@ -255,3 +255,39 @@ TEST_CASE("blend_color_multi weights components", "[FilamentMixer]")
REQUIRE(std::abs(comp(mixed, i) - comp("#123456", i)) <= 8); 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<ConfigOptionBools>("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);
}
}
@@ -68,6 +68,28 @@ static std::shared_ptr<std::vector<unsigned char>> make_flat_gray_png(uint8_t va
return std::make_shared<std::vector<unsigned char>>(std::move(bytes)); return std::make_shared<std::vector<unsigned char>>(std::move(bytes));
} }
// The same round trip for a flat colour image, which decode_height_texture() reads through its colour path.
static std::shared_ptr<std::vector<unsigned char>> make_flat_rgb_png(uint8_t r, uint8_t g, uint8_t b, size_t w = 4, size_t h = 4)
{
std::vector<uint8_t> rgb;
for (size_t i = 0; i < w * h; ++i)
rgb.insert(rgb.end(), { r, g, b });
const boost::filesystem::path tmp_path = boost::filesystem::temp_directory_path()
/ boost::filesystem::unique_path("texdisp_test_%%%%%%%%.png");
REQUIRE(Slic3r::png::write_rgb_to_file(tmp_path.string(), w, h, rgb));
std::vector<unsigned char> bytes;
{
std::ifstream ifs(tmp_path.string(), std::ios::binary);
bytes.assign(std::istreambuf_iterator<char>(ifs), std::istreambuf_iterator<char>());
}
boost::system::error_code ec;
boost::filesystem::remove(tmp_path, ec);
REQUIRE_FALSE(bytes.empty());
return std::make_shared<std::vector<unsigned char>>(std::move(bytes));
}
// A hard-edged black/white checkerboard, the worst case for a height map: every texel boundary is a // A hard-edged black/white checkerboard, the worst case for a height map: every texel boundary is a
// step, which is precisely the relief the post-process smoothing exists to round off. // step, which is precisely the relief the post-process smoothing exists to round off.
static std::shared_ptr<std::vector<unsigned char>> make_checkerboard_png(size_t w = 16, size_t h = 16) static std::shared_ptr<std::vector<unsigned char>> make_checkerboard_png(size_t w = 16, size_t h = 16)
@@ -2135,3 +2157,20 @@ TEST_CASE("A second bake beside a first comes out as fine as a single bake", "[T
CHECK(second <= single * 5 / 4); CHECK(second <= single * 5 / 4);
} }
TEST_CASE("whether a layer's texture has colour agrees with its decode", "[TextureDisplacement]")
{
TextureDisplacementLayer gray;
gray.image_data = make_flat_gray_png(128);
CHECK_FALSE(height_texture_has_color(gray));
TextureDisplacementLayer color;
color.image_data = make_flat_rgb_png(200, 40, 10);
// Before the image is decoded and after, and whatever the smoothing.
CHECK(height_texture_has_color(color));
CHECK(decode_height_texture(color).has_color());
CHECK(height_texture_has_color(color));
color.smoothing = 0.5f;
CHECK(height_texture_has_color(color));
CHECK_FALSE(height_texture_has_color(TextureDisplacementLayer{}));
}
+1
View File
@@ -36,6 +36,7 @@ add_executable(${_TEST_NAME}_tests
test_plugin_audit.cpp test_plugin_audit.cpp
test_plugin_json_depth.cpp test_plugin_json_depth.cpp
test_shortcuts.cpp test_shortcuts.cpp
test_texture_color_palette.cpp
test_file_url.cpp test_file_url.cpp
test_user_manager.cpp test_user_manager.cpp
../fff_print/test_helpers.cpp ../fff_print/test_helpers.cpp
@@ -0,0 +1,294 @@
// 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 <windows.h>; keep
// WIN32_LEAN_AND_MEAN / NOMINMAX ahead of the Catch2 headers).
#ifdef WIN32
#ifndef WIN32_LEAN_AND_MEAN
#define WIN32_LEAN_AND_MEAN
#endif
#ifndef NOMINMAX
#define NOMINMAX
#endif
#include <Windows.h>
#endif
#include <cstddef>
#include <cstdint>
#include <fstream>
#include <ios>
#include <iterator>
#include <memory>
#include <string>
#include <utility>
#include <vector>
#include <catch2/catch_all.hpp>
#include <catch2/catch_test_macros.hpp>
#include <catch2/matchers/catch_matchers.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp>
#include "libslic3r/Color.hpp"
#include "libslic3r/FilamentMixer.hpp"
#include "libslic3r/PNGReadWrite.hpp"
#include "libslic3r/Point.hpp"
#include "libslic3r/TextureDisplacement.hpp"
#include "libslic3r/TriangleMesh.hpp"
#include "libslic3r/TriangleSelector.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<uint8_t> &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<unsigned char> bytes{ std::istreambuf_iterator<char>(in), std::istreambuf_iterator<char>() };
REQUIRE_FALSE(bytes.empty());
TextureDisplacementLayer layer;
layer.image_data = std::make_shared<std::vector<unsigned char>>(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<uint8_t> 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<class RedFn> TextureDisplacementLayer striped_layer(RedFn red)
{
const int w = 2048, h = 1100;
std::vector<uint8_t> 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<MixTarget> &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<MixTarget> 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<uint8_t>(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<MixTarget> targets = { target(slot_color(BLACK, WHITE, 50), 1.f) };
const std::vector<Entry> 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<MixTarget> 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<ColorRGBA> filaments = { RED, BLUE, YELLOW };
const std::vector<MixTarget> targets = Gizmo::mix_targets({ gradient_layer() });
for (const int limit : { 1, 3 }) {
const std::vector<Entry> ranked = Gizmo::rank_mixes(filaments, targets, limit);
CHECK(int(ranked.size()) <= limit);
CHECK_FALSE(ranked.empty());
}
const std::vector<Entry> 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<Entry> 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<Entry> 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<Entry> 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<Entry> 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<uint8_t> triangle_color = { 1, 3, 3, 0 };
std::vector<Entry> asked;
const auto slot_seven = [&asked](const Entry &e) {
asked.push_back(e);
return 7;
};
const std::vector<int> 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<int> 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
}
}
TEST_CASE("the model's colour paint is drawn by filament, and only where no layer paint covers it", "[TextureColorPalette][TextureDisplacement]")
{
// A strip of four triangles: filament 2 on the first and last, filament 5 on the second, and the third
// left to the volume's own filament.
indexed_triangle_set strip;
strip.vertices = { Vec3f(0, 0, 0), Vec3f(1, 0, 0), Vec3f(0, 1, 0), Vec3f(1, 1, 0), Vec3f(0, 2, 0), Vec3f(1, 2, 0) };
strip.indices = { stl_triangle_vertex_indices(0, 1, 2), stl_triangle_vertex_indices(1, 3, 2), stl_triangle_vertex_indices(2, 3, 4),
stl_triangle_vertex_indices(3, 5, 4) };
const TriangleMesh mesh(strip);
TriangleSelector paint(mesh);
paint.set_facet(0, EnforcerBlockerType(2));
paint.set_facet(1, EnforcerBlockerType(5));
paint.set_facet(3, EnforcerBlockerType(2));
const Gizmo::PaintedColors colors = Gizmo::painted_colors(mesh, paint.serialize());
// Grouped by filament; the triangle in the volume's own filament is never drawn.
REQUIRE(colors.facets.indices.size() == 3);
REQUIRE(colors.source.size() == 3);
REQUIRE(colors.state.size() == 3);
CHECK(colors.state == std::vector<int>{ 2, 2, 5 });
CHECK(colors.source == std::vector<int>{ 0, 3, 1 });
SECTION("A model triangle a layer's paint covers is left to the preview") {
std::vector<bool> excluded(mesh.its.indices.size(), false);
excluded[3] = true;
const std::vector<size_t> kept = colors.outside(excluded);
REQUIRE(kept.size() == 2);
CHECK(colors.source[kept[0]] == 0);
CHECK(colors.source[kept[1]] == 1);
}
SECTION("A mask shorter than the model leaves the rest drawn") {
CHECK(colors.outside({ true }).size() == 2);
}
}