From 8baeba1341504937ea266d9bd34faf565dda8cd4 Mon Sep 17 00:00:00 2001 From: ExPikaPaka Date: Fri, 16 Oct 2026 11:44:33 +0200 Subject: [PATCH] ChromaStack: the layer card, the swaps and the preview shader Add layer asks which kind of layer it is making - a relief layer, a height map pushed into the surface, or a ChromaStack picture - and the layer keeps the answer, so a card never shows a control that cannot do anything on it. Depth, Midlevel, Smoothing, Invert, Edge fade, Tile and Blend are absent from a picture card: each would move the relief's steps off the print's layer boundaries, or has nothing to act on. Tile size becomes Picture size. A picture cannot be reordered, since anything printed over it buries the colours, and an object can hold only one, refused when the layer is added rather than when its swaps would overwrite the first one's. The card is the feature in one block: the picture and the predicted print side by side, how far the prediction lands from the picture, the base measured from the painted face, a row per band with the filament's name and an editable TD, and the colours the stack reaches as a ramp - computed from the stack alone, so it answers whether these filaments can print this picture before a solve is spent finding out they cannot. The swaps go onto the object as height ranges with extruder and layer_height set, so they are object-relative, saved in the project and visible in the object list; ObjectList::rebuild_layer_ranges() mirrors them in the tree without touching the selection, which inside a gizmo would tear the gizmo down. The colours exist nowhere else - they are blends of translucent filaments, not filaments, so the scene's per-triangle filament assignment cannot express them - so the gizmo draws them itself, with a shader that reads each fragment's own print Z. A swap happens at one Z across the whole plate, so that is what decides a surface's colour, a band edge lands on the layer boundary however coarse the mesh is, and it still works after a bake when the height map is attached to nothing on screen. On top of the colour it shades like printed plastic: a wrapped terminator and a grazing glow scaled by the top filament's TD, and layer lines on the walls, all fading out through fwidth once a pixel covers more than half a layer. --- resources/shaders/110/chromastack.fs | 92 ++ resources/shaders/110/chromastack.vs | 40 + resources/shaders/140/chromastack.fs | 94 ++ resources/shaders/140/chromastack.vs | 40 + src/slic3r/GUI/GLShadersManager.cpp | 2 + src/slic3r/GUI/GUI_ObjectList.cpp | 31 + src/slic3r/GUI/GUI_ObjectList.hpp | 5 + .../GUI/Gizmos/GLGizmoTextureDisplacement.cpp | 1279 +++++++++++++++-- .../GUI/Gizmos/GLGizmoTextureDisplacement.hpp | 109 +- src/slic3r/GUI/Tab.cpp | 1 + 10 files changed, 1566 insertions(+), 127 deletions(-) create mode 100644 resources/shaders/110/chromastack.fs create mode 100644 resources/shaders/110/chromastack.vs create mode 100644 resources/shaders/140/chromastack.fs create mode 100644 resources/shaders/140/chromastack.vs diff --git a/resources/shaders/110/chromastack.fs b/resources/shaders/110/chromastack.fs new file mode 100644 index 0000000000..8f3c87581a --- /dev/null +++ b/resources/shaders/110/chromastack.fs @@ -0,0 +1,92 @@ +#version 110 + +// What a ChromaStack print looks like, shaded per fragment. +// +// The colour is not a vertex attribute and not a texture on the surface: it is read from the +// fragment's own height. A filament swap happens at one print Z across the whole plate, so the layer +// a point sits in is what decides its colour - which means a band edge lands exactly on the layer +// boundary however coarse the mesh is, and the same shader is right before a bake (on the preview's +// displaced mesh) and after one (on the volume's own). +// +// On top of that it tries to look like printed plastic rather than like flat shaded colour: the +// terminator is wrapped and the grazing angles glow, because filament translucent enough to be worth +// stacking scatters light rather than reflecting it; and the walls carry layer lines, because that is +// the first thing the eye uses to read a print as a print. + +#define INTENSITY_CORRECTION 0.6 +const vec3 LIGHT_TOP_DIR = vec3(-0.4574957, 0.4574957, 0.7624929); +#define LIGHT_TOP_DIFFUSE (0.8 * INTENSITY_CORRECTION) +#define LIGHT_TOP_SPECULAR (0.125 * INTENSITY_CORRECTION) +#define LIGHT_TOP_SHININESS 20.0 +const vec3 LIGHT_FRONT_DIR = vec3(0.6985074, 0.1397015, 0.6985074); +#define LIGHT_FRONT_DIFFUSE (0.3 * INTENSITY_CORRECTION) +#define INTENSITY_AMBIENT 0.3 + +// The stack's colours, one texel per printed layer, bottom to top. A texture rather than a uniform +// array because a base can be several hundred layers and no uniform array survives that. +uniform sampler2D palette; +uniform float palette_size; // texels in `palette`, i.e. the stack's total printed layers +uniform float base_z; // world Z of the bottom of the object +uniform float layer_height; // mm +// How translucent the stack reads, 0 to 1, from the transmission distance of the filament on top. +// It drives the wrap and the rim: an opaque stack should not glow. +uniform float translucency; +uniform float alpha; + +varying vec3 clipping_planes_dots; +varying vec3 eye_normal; +varying vec3 eye_position; +varying vec3 world_normal; +varying float world_z; + +void main() +{ + if (any(lessThan(clipping_planes_dots, vec3(0.0)))) + discard; + + float z_rel = world_z - base_z; + float layers = max(z_rel / layer_height, 0.0); + // How much of a layer one pixel covers. Everything below is faded out as this grows: layer lines + // finer than a pixel are noise, and a colour read from one layer out of the dozen under a pixel + // flickers as the camera moves. + float px = fwidth(layers); + + // The layer this fragment sits in, 1-based. Up close that is an exact texel of the palette; once a + // pixel spans a layer or more it slides to a continuous coordinate so the linear filter averages + // the bands it covers instead of picking one of them at random. + float stepped = clamp(ceil(layers - 1.0e-4), 1.0, palette_size) - 0.5; + float smooth_ = clamp(layers, 0.5, palette_size - 0.5); + float u = mix(stepped, smooth_, clamp(px - 0.5, 0.0, 1.0)) / palette_size; + vec3 albedo = texture2D(palette, vec2(u, 0.5)).rgb; + + vec3 N = normalize(eye_normal); + vec3 V = normalize(-eye_position); + + // Layer lines, on the walls only: a terrace is one layer's top surface and has none to show. The + // groove runs round the part at every layer boundary, so it is a function of height alone. + float wall = 1.0 - abs(world_normal.z); + float phase = fract(layers); + float groove = smoothstep(0.0, 0.16, phase) * smoothstep(1.0, 0.84, phase); + groove = mix(groove, 0.65, clamp(px * 1.5, 0.0, 1.0)); // fade to flat when too fine to resolve + float lines = mix(1.0, 0.84 + 0.16 * groove, wall * 0.9); + albedo *= lines; + + // Diffuse, with a wrapped terminator: light entering translucent filament comes back out a little + // way round the curve, so the shaded side never goes as dark as an opaque surface's would. + float wrap = 0.25 + 0.45 * translucency; + float ndotl = dot(N, LIGHT_TOP_DIR); + float diffuse = max((ndotl + wrap) / (1.0 + wrap), 0.0) * LIGHT_TOP_DIFFUSE; + diffuse += max((dot(N, LIGHT_FRONT_DIR) + wrap) / (1.0 + wrap), 0.0) * LIGHT_FRONT_DIFFUSE; + + // Specular, strongest along a layer line, which is what makes a printed wall glint in stripes + // rather than evenly. + float spec = LIGHT_TOP_SPECULAR * pow(max(dot(V, reflect(-LIGHT_TOP_DIR, N)), 0.0), LIGHT_TOP_SHININESS); + spec *= mix(1.0, 0.35 + 0.65 * groove, wall * 0.9); + + // Scattering out of the edges: a translucent part lights up where it is seen at a grazing angle + // and where it is thin. Tinted by the filament, because what comes back out has been through it. + float rim = pow(1.0 - clamp(dot(N, V), 0.0, 1.0), 4.0); + + vec3 color = albedo * (INTENSITY_AMBIENT + diffuse) + vec3(spec) + albedo * rim * translucency * 0.55; + gl_FragColor = vec4(color, alpha); +} diff --git a/resources/shaders/110/chromastack.vs b/resources/shaders/110/chromastack.vs new file mode 100644 index 0000000000..fb2fc2b2a7 --- /dev/null +++ b/resources/shaders/110/chromastack.vs @@ -0,0 +1,40 @@ +#version 110 + +uniform mat4 view_model_matrix; +uniform mat4 projection_matrix; +uniform mat3 view_normal_matrix; +uniform mat4 volume_world_matrix; +// The volume's normal matrix in world space, so the fragment shader can tell a wall from a terrace - +// which is what decides whether layer lines show at all. +uniform mat3 world_normal_matrix; +// Clipping plane, x = min z, y = max z (the FFF/SLA preview's top/bottom cut). +uniform vec2 z_range; +// Clipping plane, general orientation (the section view). +uniform vec4 clipping_plane; + +// GLModel's P3N3 layout, which is what init_from(indexed_triangle_set) produces - this shader needs +// nothing per vertex that the mesh does not already carry, because the colour comes from the +// fragment's own height rather than from anything baked into the vertices. +attribute vec3 v_position; +attribute vec3 v_normal; + +varying vec3 clipping_planes_dots; +varying vec3 eye_normal; +varying vec3 eye_position; +varying vec3 world_normal; +varying float world_z; + +void main() +{ + vec4 model_pos = vec4(v_position, 1.0); + vec4 world_pos = volume_world_matrix * model_pos; + world_z = world_pos.z; + world_normal = normalize(world_normal_matrix * v_normal); + eye_normal = normalize(view_normal_matrix * v_normal); + + vec4 eye_pos = view_model_matrix * model_pos; + eye_position = eye_pos.xyz; + gl_Position = projection_matrix * eye_pos; + + clipping_planes_dots = vec3(dot(world_pos, clipping_plane), world_pos.z - z_range.x, z_range.y - world_pos.z); +} diff --git a/resources/shaders/140/chromastack.fs b/resources/shaders/140/chromastack.fs new file mode 100644 index 0000000000..b18c8cd87a --- /dev/null +++ b/resources/shaders/140/chromastack.fs @@ -0,0 +1,94 @@ +#version 140 + +// What a ChromaStack print looks like, shaded per fragment. +// +// The colour is not a vertex attribute and not a texture on the surface: it is read from the +// fragment's own height. A filament swap happens at one print Z across the whole plate, so the layer +// a point sits in is what decides its colour - which means a band edge lands exactly on the layer +// boundary however coarse the mesh is, and the same shader is right before a bake (on the preview's +// displaced mesh) and after one (on the volume's own). +// +// On top of that it tries to look like printed plastic rather than like flat shaded colour: the +// terminator is wrapped and the grazing angles glow, because filament translucent enough to be worth +// stacking scatters light rather than reflecting it; and the walls carry layer lines, because that is +// the first thing the eye uses to read a print as a print. + +#define INTENSITY_CORRECTION 0.6 +const vec3 LIGHT_TOP_DIR = vec3(-0.4574957, 0.4574957, 0.7624929); +#define LIGHT_TOP_DIFFUSE (0.8 * INTENSITY_CORRECTION) +#define LIGHT_TOP_SPECULAR (0.125 * INTENSITY_CORRECTION) +#define LIGHT_TOP_SHININESS 20.0 +const vec3 LIGHT_FRONT_DIR = vec3(0.6985074, 0.1397015, 0.6985074); +#define LIGHT_FRONT_DIFFUSE (0.3 * INTENSITY_CORRECTION) +#define INTENSITY_AMBIENT 0.3 + +// The stack's colours, one texel per printed layer, bottom to top. A texture rather than a uniform +// array because a base can be several hundred layers and no uniform array survives that. +uniform sampler2D palette; +uniform float palette_size; // texels in `palette`, i.e. the stack's total printed layers +uniform float base_z; // world Z of the bottom of the object +uniform float layer_height; // mm +// How translucent the stack reads, 0 to 1, from the transmission distance of the filament on top. +// It drives the wrap and the rim: an opaque stack should not glow. +uniform float translucency; +uniform float alpha; + +in vec3 clipping_planes_dots; +in vec3 eye_normal; +in vec3 eye_position; +in vec3 world_normal; +in float world_z; + +out vec4 out_color; + +void main() +{ + if (any(lessThan(clipping_planes_dots, vec3(0.0)))) + discard; + + float z_rel = world_z - base_z; + float layers = max(z_rel / layer_height, 0.0); + // How much of a layer one pixel covers. Everything below is faded out as this grows: layer lines + // finer than a pixel are noise, and a colour read from one layer out of the dozen under a pixel + // flickers as the camera moves. + float px = fwidth(layers); + + // The layer this fragment sits in, 1-based. Up close that is an exact texel of the palette; once a + // pixel spans a layer or more it slides to a continuous coordinate so the linear filter averages + // the bands it covers instead of picking one of them at random. + float stepped = clamp(ceil(layers - 1.0e-4), 1.0, palette_size) - 0.5; + float smooth_ = clamp(layers, 0.5, palette_size - 0.5); + float u = mix(stepped, smooth_, clamp(px - 0.5, 0.0, 1.0)) / palette_size; + vec3 albedo = texture(palette, vec2(u, 0.5)).rgb; + + vec3 N = normalize(eye_normal); + vec3 V = normalize(-eye_position); + + // Layer lines, on the walls only: a terrace is one layer's top surface and has none to show. The + // groove runs round the part at every layer boundary, so it is a function of height alone. + float wall = 1.0 - abs(world_normal.z); + float phase = fract(layers); + float groove = smoothstep(0.0, 0.16, phase) * smoothstep(1.0, 0.84, phase); + groove = mix(groove, 0.65, clamp(px * 1.5, 0.0, 1.0)); // fade to flat when too fine to resolve + float lines = mix(1.0, 0.84 + 0.16 * groove, wall * 0.9); + albedo *= lines; + + // Diffuse, with a wrapped terminator: light entering translucent filament comes back out a little + // way round the curve, so the shaded side never goes as dark as an opaque surface's would. + float wrap = 0.25 + 0.45 * translucency; + float ndotl = dot(N, LIGHT_TOP_DIR); + float diffuse = max((ndotl + wrap) / (1.0 + wrap), 0.0) * LIGHT_TOP_DIFFUSE; + diffuse += max((dot(N, LIGHT_FRONT_DIR) + wrap) / (1.0 + wrap), 0.0) * LIGHT_FRONT_DIFFUSE; + + // Specular, strongest along a layer line, which is what makes a printed wall glint in stripes + // rather than evenly. + float spec = LIGHT_TOP_SPECULAR * pow(max(dot(V, reflect(-LIGHT_TOP_DIR, N)), 0.0), LIGHT_TOP_SHININESS); + spec *= mix(1.0, 0.35 + 0.65 * groove, wall * 0.9); + + // Scattering out of the edges: a translucent part lights up where it is seen at a grazing angle + // and where it is thin. Tinted by the filament, because what comes back out has been through it. + float rim = pow(1.0 - clamp(dot(N, V), 0.0, 1.0), 4.0); + + vec3 color = albedo * (INTENSITY_AMBIENT + diffuse) + vec3(spec) + albedo * rim * translucency * 0.55; + out_color = vec4(color, alpha); +} diff --git a/resources/shaders/140/chromastack.vs b/resources/shaders/140/chromastack.vs new file mode 100644 index 0000000000..c7f4e319ec --- /dev/null +++ b/resources/shaders/140/chromastack.vs @@ -0,0 +1,40 @@ +#version 140 + +uniform mat4 view_model_matrix; +uniform mat4 projection_matrix; +uniform mat3 view_normal_matrix; +uniform mat4 volume_world_matrix; +// The volume's normal matrix in world space, so the fragment shader can tell a wall from a terrace - +// which is what decides whether layer lines show at all. +uniform mat3 world_normal_matrix; +// Clipping plane, x = min z, y = max z (the FFF/SLA preview's top/bottom cut). +uniform vec2 z_range; +// Clipping plane, general orientation (the section view). +uniform vec4 clipping_plane; + +// GLModel's P3N3 layout, which is what init_from(indexed_triangle_set) produces - this shader needs +// nothing per vertex that the mesh does not already carry, because the colour comes from the +// fragment's own height rather than from anything baked into the vertices. +in vec3 v_position; +in vec3 v_normal; + +out vec3 clipping_planes_dots; +out vec3 eye_normal; +out vec3 eye_position; +out vec3 world_normal; +out float world_z; + +void main() +{ + vec4 model_pos = vec4(v_position, 1.0); + vec4 world_pos = volume_world_matrix * model_pos; + world_z = world_pos.z; + world_normal = normalize(world_normal_matrix * v_normal); + eye_normal = normalize(view_normal_matrix * v_normal); + + vec4 eye_pos = view_model_matrix * model_pos; + eye_position = eye_pos.xyz; + gl_Position = projection_matrix * eye_pos; + + clipping_planes_dots = vec3(dot(world_pos, clipping_plane), world_pos.z - z_range.x, z_range.y - world_pos.z); +} diff --git a/src/slic3r/GUI/GLShadersManager.cpp b/src/slic3r/GUI/GLShadersManager.cpp index 003f994a21..0753ad3672 100644 --- a/src/slic3r/GUI/GLShadersManager.cpp +++ b/src/slic3r/GUI/GLShadersManager.cpp @@ -114,6 +114,8 @@ std::pair GLShadersManager::init() valid &= append_shader("mm_gouraud", { prefix + "mm_gouraud.vs", prefix + "mm_gouraud.fs" }); // Fast shaded preview for the texture displacement gizmo (see libslic3r/TextureDisplacement.hpp). valid &= append_shader("texture_displacement_shaded", { prefix + "texture_displacement_shaded.vs", prefix + "texture_displacement_shaded.fs" }); + // ChromaStack: the printed colours on the model, read per fragment from its own height. + valid &= append_shader("chromastack", { prefix + "chromastack.vs", prefix + "chromastack.fs" }); // UV-check overlay for the same gizmo: a procedural checker or a distortion heatmap over the // painted patch, to sanity-check the unwrap. valid &= append_shader("texture_displacement_uvcheck", { prefix + "texture_displacement_uvcheck.vs", prefix + "texture_displacement_uvcheck.fs" }); diff --git a/src/slic3r/GUI/GUI_ObjectList.cpp b/src/slic3r/GUI/GUI_ObjectList.cpp index 4a733d3342..194942505d 100644 --- a/src/slic3r/GUI/GUI_ObjectList.cpp +++ b/src/slic3r/GUI/GUI_ObjectList.cpp @@ -2875,6 +2875,37 @@ void ObjectList::del_layer_from_object(const int obj_idx, const t_layer_height_r changed_object(obj_idx); } +void ObjectList::rebuild_layer_ranges(const int obj_idx) +{ + if (obj_idx < 0 || obj_idx >= int(m_objects->size())) + return; + const wxDataViewItem obj_item = m_objects_model->GetItemById(obj_idx); + if (!obj_item.IsOk()) + return; + + const t_layer_config_ranges& ranges = object(obj_idx)->layer_config_ranges; + // Adding and deleting tree items fires selection events on GTK; suppress them as every other + // bulk edit of the tree here does. + const bool prevent_events = m_prevent_list_events; + m_prevent_list_events = true; + wxDataViewItem root = m_objects_model->GetLayerRootItem(obj_item); + if (root.IsOk()) { + m_objects_model->DeleteChildren(root); + if (ranges.empty()) + m_objects_model->Delete(root); + else { + for (const auto& range : ranges) + add_layer_item(range.first, root); + Expand(root); + } + } else { + add_layer_root_item(obj_item); + } + m_prevent_list_events = prevent_events; + + changed_object(obj_idx); +} + void ObjectList::del_layers_from_object(const int obj_idx) { object(obj_idx)->layer_config_ranges.clear(); diff --git a/src/slic3r/GUI/GUI_ObjectList.hpp b/src/slic3r/GUI/GUI_ObjectList.hpp index 03bffb6cdc..e54ae9d58e 100644 --- a/src/slic3r/GUI/GUI_ObjectList.hpp +++ b/src/slic3r/GUI/GUI_ObjectList.hpp @@ -324,6 +324,11 @@ public: void del_settings_from_config(const wxDataViewItem& parent_item); void del_instances_from_object(const int obj_idx); void del_layer_from_object(const int obj_idx, const t_layer_height_range& layer_range); + // Mirrors an object's height ranges in the tree after they have been rewritten from outside the + // list - the texture displacement gizmo writes a whole set at once for a ChromaStack stack's + // filament swaps. Deliberately leaves the selection alone: changing it while a gizmo is open + // tears the gizmo down under the user. + void rebuild_layer_ranges(const int obj_idx); void del_layers_from_object(const int obj_idx); bool del_from_cut_object(bool is_connector, bool is_model_part = false, bool is_negative_volume = false); bool del_subobject_from_object(const int obj_idx, const int idx, const int type); diff --git a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp index 96af821505..f64aa9385b 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp @@ -9,6 +9,8 @@ #include "libslic3r/AABBTreeIndirect.hpp" #include "libslic3r/Color.hpp" #include "libslic3r/FilamentMixer.hpp" +#include "libslic3r/ChromaStack.hpp" +#include "libslic3r/libslic3r.h" #include "libslic3r/PresetBundle.hpp" #include "libslic3r/MeshBoolean.hpp" #include "libslic3r/Model.hpp" @@ -681,6 +683,13 @@ void GLGizmoTextureDisplacement::render_painter_gizmo() // 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); + // The ChromaStack stack's colours, over the same surface. No ordering question with the paint above: + // a ChromaStack layer colours by filament swap and never carries per-triangle colour, so the two + // overlays are mutually exclusive by construction. + const TextureDisplacementLayer *chroma_layer = active_layer(); + const bool chroma_colors_drawn = m_show_chroma_colors && m_debug_stage < 0 && chroma_layer != nullptr && + chroma_layer->chroma_enabled && render_chroma_colors(*chroma_layer); + // 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. if (show_paint_overlay) @@ -693,7 +702,7 @@ void GLGizmoTextureDisplacement::render_painter_gizmo() // 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())) + if (show_paint_overlay && (preview_drawn ? paint_feedback : (painted_colors_drawn || chroma_colors_drawn) && is_painting())) 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 @@ -1849,6 +1858,138 @@ bool GLGizmoTextureDisplacement::render_painted_colors(bool whole_stack) return drawn; } +void GLGizmoTextureDisplacement::rebuild_chroma_colors(const TextureDisplacementLayer &layer) +{ + const ModelObject *mo = m_c->selection_info()->model_object(); + const ModelVolume *mv = texture_volume(); + const auto drop = [this]() { + m_chroma_color_model.reset(); + m_chroma_palette_tex.reset(); + m_chroma_color_key = 0; + }; + if (mo == nullptr || mv == nullptr || mo->instances.empty() || layer.chroma.bands.empty()) + return drop(); + + // Before a bake the surface on screen is the preview's displaced mesh; after one the relief is in + // the volume itself and there is no preview at all (baking clears the paint it was made from). + const indexed_triangle_set &its = m_preview_its.indices.empty() ? mv->mesh().its : m_preview_its; + if (its.indices.empty()) + return drop(); + + // The mesh is uploaded as it is, with no per-triangle colour and no index sorting: the shader + // reads the colour from each fragment's own height, so the geometry carries nothing about the + // stack and only has to be rebuilt when the surface itself changes. + size_t key = 0; + const auto mix = [&key](size_t v) { key = key * 1099511628211ull + v + 0x9e3779b9ull; }; + mix(reinterpret_cast(its.indices.data())); + mix(its.indices.size()); + mix(its.vertices.size()); + if (key != m_chroma_color_key || !m_chroma_color_model.is_initialized()) { + m_chroma_color_model.reset(); + m_chroma_color_model.init_from(its); + m_chroma_color_key = key; + } + + // The palette as a texture, one texel per printed layer. A uniform array would not do: the base + // alone can be several hundred layers on a tall part. + size_t pkey = 0; + const auto pmix = [&pkey](size_t v) { pkey = pkey * 1099511628211ull + v + 0x9e3779b9ull; }; + pmix(std::hash{}(layer.chroma.layer_height_mm)); + pmix(size_t(layer.chroma.base_layers)); + for (const ChromaBand &band : layer.chroma.bands) { + pmix(size_t(band.layers)); + pmix(size_t(band.filament.filament + 1)); + pmix(std::hash{}(band.filament.color.r() + 2.f * band.filament.color.g() + 3.f * band.filament.color.b())); + pmix(std::hash{}(band.filament.td_mm)); + } + if (pkey == m_chroma_palette_key && m_chroma_palette_tex) + return; + + const std::vector palette = chroma_palette(layer.chroma); + if (palette.size() < 2) { + m_chroma_palette_tex.reset(); + m_chroma_palette_key = 0; + return; + } + // Entry 0 is the sentinel "nothing printed yet" colour; the texture holds the printed layers, so + // texel i is layer i + 1, which is what the shader's 1-based level indexes. + const size_t count = palette.size() - 1; + std::vector rgba(count * 4); + for (size_t i = 0; i < count; ++i) { + const ColorRGB &c = palette[i + 1]; + rgba[i * 4] = uint8_t(std::lround(c.r() * 255.f)); + rgba[i * 4 + 1] = uint8_t(std::lround(c.g() * 255.f)); + rgba[i * 4 + 2] = uint8_t(std::lround(c.b() * 255.f)); + rgba[i * 4 + 3] = 255; + } + auto tex = std::make_unique(); + if (!tex->load_from_raw_data(std::move(rgba), (unsigned int) count, 1u, false, /* use_mipmaps */ false)) { + m_chroma_palette_tex.reset(); + m_chroma_palette_key = 0; + return; + } + m_chroma_palette_tex = std::move(tex); + m_chroma_palette_key = pkey; +} + +bool GLGizmoTextureDisplacement::render_chroma_colors(const TextureDisplacementLayer &layer) +{ + rebuild_chroma_colors(layer); + const ModelObject *mo = m_c->selection_info()->model_object(); + const ModelVolume *mv = texture_volume(); + GLShaderProgram *shader = wxGetApp().get_shader("chromastack"); + if (mo == nullptr || mv == nullptr || shader == nullptr || !m_chroma_color_model.is_initialized() || + !m_chroma_palette_tex || m_chroma_palette_tex->get_width() <= 0) + return false; + + const Selection &selection = m_parent.get_selection(); + const size_t inst = size_t(std::max(0, selection.get_instance_idx())); + const Transform3d trafo_matrix = mo->instances[inst]->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(); + + shader->start_using(); + 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("world_normal_matrix", Matrix3f(normal_matrix.cast())); + 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("base_z", float(mo->instance_bounding_box(inst, false).min.z())); + shader->set_uniform("layer_height", std::max(1e-3f, layer.chroma.layer_height_mm)); + shader->set_uniform("palette_size", float(m_chroma_palette_tex->get_width())); + // How translucent the print reads, from the filament on top - the one the light has to get through + // before any of the stack below it shows. An opaque stack must not glow at its edges. + const float td = std::clamp(layer.chroma.bands.back().filament.td_mm, 0.5f, 12.f); + shader->set_uniform("translucency", std::clamp((td - 1.f) / 7.f, 0.f, 1.f)); + shader->set_uniform("alpha", 1.f); + shader->set_uniform("palette", 0); + glsafe(::glActiveTexture(GL_TEXTURE0)); + glsafe(::glBindTexture(GL_TEXTURE_2D, (GLuint) m_chroma_palette_tex->get_id())); + // Clamped and linear: the shader slides the lookup off the texel centres on purpose once a pixel + // covers more than a layer, and it is that filtering which keeps the bands from flickering. + glsafe(::glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE)); + glsafe(::glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE)); + glsafe(::glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR)); + glsafe(::glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR)); + + // Pulled forward and without depth writes, as the painted-colour overlay is: this is the same + // surface as whatever is underneath it, so without the offset the two fight for the depth test. + glsafe(::glEnable(GL_POLYGON_OFFSET_FILL)); + glsafe(::glPolygonOffset(-2.f, -2.f)); + glsafe(::glDepthMask(GL_FALSE)); + m_chroma_color_model.render(); + glsafe(::glDepthMask(GL_TRUE)); + glsafe(::glDisable(GL_POLYGON_OFFSET_FILL)); + glsafe(::glBindTexture(GL_TEXTURE_2D, 0)); + shader->stop_using(); + return true; +} + void GLGizmoTextureDisplacement::rebuild_paint_overlay() { m_paint_overlay_glmodel.reset(); @@ -3701,12 +3842,24 @@ void GLGizmoTextureDisplacement::ensure_panel_icons() m_panel_icon_map[names[i]] = icons[i]; } -void GLGizmoTextureDisplacement::add_texture_layer() +void GLGizmoTextureDisplacement::add_texture_layer(bool chroma) { ModelVolume *mv = texture_volume(); if (!mv) return; + // One ChromaStack layer per object, refused here rather than at Apply: the swaps live in the + // object's height ranges, of which there is one set, so a second stack could be solved and + // previewed and would then silently lose its swaps to the first. + if (chroma) + for (const TextureDisplacementLayer &l : mv->texture_displacement_layers) + if (l.chroma_enabled) { + show_error(nullptr, _u8L("This object already has a ChromaStack picture. An object can only have one: " + "the filament swaps are the object's own height ranges, and there is one set " + "of those.")); + return; + } + std::array used{}; for (const TextureDisplacementLayer &l : mv->texture_displacement_layers) if (l.slot >= 0 && size_t(l.slot) < used.size()) @@ -3723,16 +3876,30 @@ void GLGizmoTextureDisplacement::add_texture_layer() TextureDisplacementLayer layer; layer.slot = free_slot; - // Start the layer off on the first library texture rather than on nothing at all: a textureless - // layer looks broken (painting on it appears to do nothing, because there is no height map to - // displace by). The user swaps it for another from the layer's own picker. - const std::vector &library = texture_library(); - if (!library.empty()) - if (const LibraryTexture *tex = get_library_texture(library.front().path)) { - layer.name = library.front().name; - layer.path = library.front().path; - layer.image_data = tex->image_data; - } + if (chroma) { + // A ChromaStack layer's height map is solved, never picked, so it starts with no texture and + // the card asks for a picture instead. Everything the solve writes is pinned here so the card + // never has to show a control that would break it. + layer.chroma_enabled = true; + layer.name = _u8L("ChromaStack picture"); + layer.tile_enabled = false; + layer.color_enabled = false; + layer.invert = false; + layer.midlevel = 0.f; + layer.smoothing = 0.f; + layer.blend_mode = TextureBlendMode::Add; + } else { + // Start a relief layer off on the first library texture rather than on nothing at all: a + // textureless layer looks broken (painting on it appears to do nothing, because there is no + // height map to displace by). The user swaps it for another from the layer's own picker. + const std::vector &library = texture_library(); + if (!library.empty()) + if (const LibraryTexture *tex = get_library_texture(library.front().path)) { + layer.name = library.front().name; + layer.path = library.front().path; + layer.image_data = tex->image_data; + } + } mv->texture_displacement_layers.push_back(std::move(layer)); // A new layer opens with every setting showing; there is nothing on it yet to hide settings from. m_layer_expanded[size_t(free_slot)] = true; @@ -3742,6 +3909,12 @@ void GLGizmoTextureDisplacement::add_texture_layer() // for the very first layer added), so the preview would not pick the new texture up on its own. rebuild_preview(); m_parent.set_as_dirty(); + + // The picture is the one thing a ChromaStack layer cannot do without, so choosing the kind goes + // straight on to choosing it. Last, so the layer is in the model and active before the modal opens. + if (chroma) + if (TextureDisplacementLayer *added = active_layer()) + chroma_pick_picture(*added); } const GLGizmoTextureDisplacement::LibraryTexture *GLGizmoTextureDisplacement::get_library_texture(const std::string &path) @@ -3811,6 +3984,739 @@ void GLGizmoTextureDisplacement::import_custom_texture(TextureDisplacementLayer set_layer_texture(layer, *entry); } +// ---- ChromaStack --------------------------------------------------------------------------------- + +std::vector GLGizmoTextureDisplacement::chroma_filaments() +{ + const std::vector colors = filament_palette(); + const PresetBundle *bundle = wxGetApp().preset_bundle; + + std::vector out; + out.reserve(colors.size()); + for (size_t i = 0; i < colors.size(); ++i) { + ChromaFilament f; + f.filament = int(i); + f.color = to_rgb(colors[i]); + // The colour comes from the project (it follows the sidebar), the TD from the filament's own + // preset - it is a property of the material, not of this project. + if (bundle != nullptr && i < bundle->filament_presets.size()) { + const Preset *preset = bundle->filaments.find_preset(bundle->filament_presets[i], false); + if (preset != nullptr) { + f.name = preset->label(false); + if (preset->config.has("filament_transmission_distance")) + f.td_mm = float(preset->config.opt_float("filament_transmission_distance", 0)); + } + } + out.push_back(f); + } + return out; +} + +float GLGizmoTextureDisplacement::print_layer_height() +{ + const PresetBundle *bundle = wxGetApp().preset_bundle; + if (bundle == nullptr) + return 0.f; + const DynamicPrintConfig &print = bundle->prints.get_edited_preset().config; + return print.has("layer_height") ? float(print.opt_float("layer_height")) : 0.f; +} + +GLGizmoTextureDisplacement::ChromaPatch GLGizmoTextureDisplacement::chroma_patch(const TextureDisplacementLayer &layer) const +{ + ChromaPatch out; + const ModelVolume *mv = texture_volume(); + const ModelObject *mo = m_c->selection_info()->model_object(); + if (mv == nullptr || mo == nullptr || mo->instances.empty() || layer.slot < 0) + return out; + + // The patch in the object's printed coordinates: a filament swap happens at a print Z, so the + // stack has to be measured against the same frame the slicer will see, instance transform + // included. + const size_t inst = size_t(std::max(0, m_c->selection_info()->get_active_instance())); + const Transform3d trafo = mo->instances[inst]->get_transformation().get_matrix() * mv->get_matrix(); + // The frame the bake projects the texture in, which is where tiling_scale and offset live. Not the + // same as `trafo`: it drops the translation, so the texture rides with the model, and it is built + // from the first instance, as the bake's own projection is. + const Transform3d bake = texture_displacement_bake_frame(texture_displacement_volume_to_world(*mv)); + const size_t slot = size_t(layer.slot); + const uint64_t stamp = mv->texture_displacement_facet(layer.slot).timestamp(); + if (m_chroma_patch_cache[slot].valid && m_chroma_patch_stamp[slot] == stamp && + m_chroma_patch_trafo[slot].isApprox(trafo) && m_chroma_patch_bake[slot].isApprox(bake)) + return m_chroma_patch_cache[slot]; + + const indexed_triangle_set patch = extract_painted_patch(mv->mesh().its, + mv->texture_displacement_facet(layer.slot).get_data()); + if (patch.vertices.empty()) + return out; + + BoundingBoxf3 box; + BoundingBoxf3 planar; + for (const Vec3f &v : patch.vertices) { + const Vec3d p = v.cast(); + box.merge(trafo * p); + planar.merge(bake * p); + } + + const double bottom = mo->instance_bounding_box(inst, false).min.z(); + out.valid = true; + out.top_mm = float(std::max(0., box.max.z() - bottom)); + out.spread_mm = float(box.size().z()); + out.planar_min = Vec2f(float(planar.min.x()), float(planar.min.y())); + out.planar_max = Vec2f(float(planar.max.x()), float(planar.max.y())); + + m_chroma_patch_cache[slot] = out; + m_chroma_patch_stamp[slot] = stamp; + m_chroma_patch_trafo[slot] = trafo; + m_chroma_patch_bake[slot] = bake; + return out; +} + +int GLGizmoTextureDisplacement::chroma_measured_base(const TextureDisplacementLayer &layer) const +{ + const ChromaPatch patch = chroma_patch(layer); + if (!patch.valid || layer.chroma.layer_height_mm <= 0.f) + return -1; + return std::max(1, int(std::lround(patch.top_mm / layer.chroma.layer_height_mm))); +} + +void GLGizmoTextureDisplacement::chroma_pick_picture(TextureDisplacementLayer &layer) +{ + const wxString wildcard = _L("Images (*.png;*.jpg;*.jpeg;*.bmp)|*.png;*.jpg;*.jpeg;*.bmp"); + wxFileDialog dialog(nullptr, _L("Choose the picture to print in color"), wxEmptyString, wxEmptyString, wildcard, + wxFD_OPEN | wxFD_FILE_MUST_EXIST); + if (dialog.ShowModal() != wxID_OK) + return; + + const std::string path = into_u8(dialog.GetPath()); + std::string error; + // The same loader the texture library uses, which keeps the image's colour - which is the whole + // input to the solve, so a grayscale-only import would defeat the feature. + std::shared_ptr> bytes = load_texture_image_data(path, error); + if (!bytes) { + show_error(nullptr, error); + return; + } + + Plater::TakeSnapshot snapshot(wxGetApp().plater(), _u8L("Set ChromaStack picture"), UndoRedo::SnapshotType::GizmoAction); + layer.chroma_source = std::move(bytes); + layer.chroma_enabled = true; + layer.name = boost::filesystem::path(path).stem().string(); + layer.path = path; + // Colour by filament swap, never per triangle: the two mechanisms would fight over the same + // surface. See TextureDisplacementLayer::chroma_enabled. + layer.color_enabled = false; + // One copy of the picture over the patch, not a repeating tile. + layer.tile_enabled = false; + layer.invert = false; + layer.midlevel = 0.f; + + const ChromaPatch patch = chroma_patch(layer); + if (patch.valid) { + // Lay the one copy of the picture over the painted patch. Without this the picture would be a + // tile of some size starting at the volume's own origin, which for a part centred on that + // origin puts three quarters of the patch outside the picture and leaves it undisplaced. + // + // uv = (planar / tiling_scale) * (1, aspect) + offset, so fitting means a scale that makes the + // longer side of the patch one tile wide and an offset that moves the patch's own corner to + // uv 0 and centres the shorter side. The aspect is the picture's, so a wide picture is not + // squeezed into a square tile. + TextureDisplacementLayer probe; + probe.image_data = layer.chroma_source; + const float aspect = layer_texture_aspect(probe); + const Vec2f size = patch.planar_max - patch.planar_min; + const float along_u = std::max(size.x(), 1e-3f); + const float along_v = std::max(size.y() * aspect, 1e-3f); + const float scale = std::max(along_u, along_v); + layer.tiling_scale = scale; + layer.rotation_deg = 0.f; // the fit below is in unrotated planar coordinates + layer.offset = Vec2f(-patch.planar_min.x() / scale + 0.5f * (1.f - along_u / scale), + -patch.planar_min.y() * aspect / scale + 0.5f * (1.f - along_v / scale)); + } + + if (layer.chroma.bands.empty()) { + const float layer_h = print_layer_height(); + // The base is whatever the model already has under the painted face, in layers: the relief is + // printed on top of it, so this is not a choice but a measurement. + const int base = (layer_h > 0.f && patch.valid) ? std::max(1, int(std::lround(patch.top_mm / layer_h))) : 4; + layer.chroma = chroma_default_stack(chroma_filaments(), layer_h > 0.f ? layer_h : 0.08f, base, + CHROMA_DEFAULT_RELIEF_LAYERS); + } + chroma_solve_layer(layer); +} + +bool GLGizmoTextureDisplacement::chroma_solve_layer(TextureDisplacementLayer &layer) +{ + if (layer.slot < 0 || size_t(layer.slot) >= TEXTURE_DISPLACEMENT_MAX_LAYERS) + return false; + const size_t slot = size_t(layer.slot); + m_chroma_report[slot] = ChromaReport{}; + m_chroma_preview[slot].reset(); + + // The panel keeps this up to date while it is open, but a solve is the point at which being wrong + // stops being recoverable: it is what writes the height map every later step reads. + if (layer.chroma.base_auto) + if (const int measured = chroma_measured_base(layer); measured > 0) + layer.chroma.base_layers = measured; + + if (!layer.chroma_source || layer.chroma.bands.empty() || layer.chroma.relief_layers() <= 0) { + show_error(nullptr, _u8L("Pick a picture and at least two layers of filament before solving.")); + return false; + } + + // Decoding goes through the same cache as a layer's own texture: the probe carries nothing but the + // image, and the cache is keyed by that buffer, so re-solving never decodes the PNG twice. + TextureDisplacementLayer probe; + probe.image_data = layer.chroma_source; + const DecodedHeightTexture source = decode_height_texture(probe); + if (source.empty()) { + show_error(nullptr, _u8L("Could not read the picture.")); + return false; + } + + // A grayscale picture is a legitimate input - a tonal relief in two filaments - and the solve only + // ever reads RGB, so grey is expanded rather than rejected. + std::vector grey_rgb; + const uint8_t *rgb = source.rgb.data(); + if (!source.has_color()) { + grey_rgb.resize(source.pixels.size() * 3); + for (size_t i = 0; i < source.pixels.size(); ++i) + grey_rgb[i * 3] = grey_rgb[i * 3 + 1] = grey_rgb[i * 3 + 2] = source.pixels[i]; + rgb = grey_rgb.data(); + } + + const ChromaSolve solve = chroma_solve(rgb, source.width, source.height, layer.chroma); + std::vector png = chroma_encode_height_png(solve); + if (solve.empty() || png.empty()) { + show_error(nullptr, _u8L("Could not solve the picture against this stack.")); + return false; + } + + layer.image_data = std::make_shared>(std::move(png)); + // The relief is exactly as tall as the layers above the base, and every step in the height map is + // one of them - which is what makes the printed swaps line up with the heights. + layer.depth_mm = layer.chroma.depth_mm(); + layer.invert = false; + layer.midlevel = 0.f; + // Blurring a solved height map moves its steps off the layer boundaries they were solved for. + layer.smoothing = 0.f; + + DecodedHeightTexture predicted; + predicted.width = solve.width; + predicted.height = solve.height; + predicted.pixels = solve.grey; + predicted.rgb = solve.preview; + m_chroma_preview[slot] = upload_height_thumbnail(predicted); + m_chroma_report[slot] = ChromaReport{ true, solve.mean_delta_e, solve.max_delta_e }; + m_chroma_stale[slot] = false; + + rebuild_preview(); + m_parent.set_as_dirty(); + return true; +} + +bool GLGizmoTextureDisplacement::chroma_swaps_applied(const TextureDisplacementLayer &layer) const +{ + const ModelObject *mo = m_c->selection_info()->model_object(); + if (mo == nullptr) + return false; + const std::vector ranges = chroma_ranges(layer.chroma, 0.); + if (ranges.size() != mo->layer_config_ranges.size()) + return false; + + auto it = mo->layer_config_ranges.begin(); + for (const ChromaRange &r : ranges) { + if (!is_approx(it->first.first, r.z_min) || !is_approx(it->first.second, r.z_max)) + return false; + if (!it->second.has("extruder") || it->second.opt_int("extruder") != r.filament + 1) + return false; + ++it; + } + return true; +} + +void GLGizmoTextureDisplacement::chroma_apply_swaps(const TextureDisplacementLayer &layer) +{ + ModelObject *mo = m_c->selection_info()->model_object(); + if (mo == nullptr) + return; + const std::vector ranges = chroma_ranges(layer.chroma, 0.); + if (ranges.empty()) + return; + + // The object's height ranges are the only place a per-Z filament can live, so applying takes them + // over wholesale. Anything already there was put there by hand or by an earlier stack, and either + // way the user has to agree to lose it. + if (!mo->layer_config_ranges.empty() && !chroma_swaps_applied(layer)) { + MessageDialog dlg(wxGetApp().plater(), + _L("This object already has height ranges. Replace them with the filament swaps of this " + "ChromaStack stack?"), + _L("ChromaStack"), wxICON_QUESTION | wxYES_NO); + if (dlg.ShowModal() != wxID_YES) + return; + } + + Plater::TakeSnapshot snapshot(wxGetApp().plater(), _u8L("Apply ChromaStack filament swaps"), + UndoRedo::SnapshotType::GizmoAction); + mo->layer_config_ranges.clear(); + for (const ChromaRange &r : ranges) { + ModelConfig &config = mo->layer_config_ranges[{ r.z_min, r.z_max }]; + config.set_key_value("extruder", new ConfigOptionInt(r.filament + 1)); + // Every range carries its layer height: the solve is only valid at the height it was run at, + // and layer_height_profile_from_ranges() reads the option without checking it is there. + config.set_key_value("layer_height", new ConfigOptionFloat(double(layer.chroma.layer_height_mm))); + } + + if (ObjectList *list = wxGetApp().obj_list(); list != nullptr) { + const int obj_idx = m_parent.get_selection().get_object_idx(); + if (obj_idx >= 0) + list->rebuild_layer_ranges(obj_idx); + } +} + +void GLGizmoTextureDisplacement::render_chroma_card(TextureDisplacementLayer &layer, float card_pad, float label_w, + float wrap_w) +{ + const ImGuiStyle &style = ImGui::GetStyle(); + const float frame_h = ImGui::GetFrameHeight(); + const float gap_s = std::round(m_imgui->scaled(0.4f)); + const size_t slot = size_t(layer.slot); + ImDrawList *dl = ImGui::GetWindowDrawList(); + const float right = std::max(card_pad, 1.f); + + const auto tip = [&](const std::string &text) { + if (ImGui::IsItemHovered()) + m_imgui->tooltip(text, wrap_w); + }; + const auto row_label = [&](const wxString &text) { + const float x0 = ImGui::GetCursorPosX(); + ImGui::AlignTextToFramePadding(); + m_imgui->text(text); + ImGui::SameLine(); + ImGui::SetCursorPosX(std::max(ImGui::GetCursorPosX(), x0 + label_w)); + }; + const auto wrapped = [&](const std::string &text, bool warn) { + ImGui::PushTextWrapPos(ImGui::GetWindowContentRegionMax().x - card_pad); + if (warn) + m_imgui->warning_text(text.c_str()); + else + ImGui::TextDisabled("%s", text.c_str()); + ImGui::PopTextWrapPos(); + }; + const auto swatch = [&](const ColorRGB &c, float size) { + const ImVec2 p = ImGui::GetCursorScreenPos(); + ImGui::Dummy(ImVec2(size, frame_h)); + const float y = p.y + 0.5f * (frame_h - size); + dl->AddRectFilled(ImVec2(p.x, y), ImVec2(p.x + size, y + size), + IM_COL32(int(c.r() * 255.f), int(c.g() * 255.f), int(c.b() * 255.f), 255), style.FrameRounding); + dl->AddRect(ImVec2(p.x, y), ImVec2(p.x + size, y + size), ImGui::GetColorU32(ImGuiCol_Separator), style.FrameRounding); + }; + // A typed field, not a slider: a base is measured in hundreds of layers and a band in single + // digits, and dragging past the end of a slider's range is how a stack silently loses its backing. + // + // Deliberately without InputInt's own step buttons. They are drawn *inside* the item's width, two + // square buttons plus their spacing, which at the widths this panel can spare leaves nothing at all + // for the number - the row reads as "- +" with a hole where the value should be. + const auto int_field = [&](const char *id, int *v, int v_min, int v_max, float width) { + ImGui::SetNextItemWidth(width); + const bool changed = ImGui::InputInt(id, v, 0, 0); + *v = std::clamp(*v, v_min, v_max); + return changed; + }; + + bool solve_dirty = false; // an edit that leaves the solved height map describing the previous stack + + const float profile_h = print_layer_height(); + const int relief = layer.chroma.relief_layers(); + + // ---- What it is, in one line ---- + ImGui::TextDisabled("%s", Slic3r::format(_u8L("Base %1% + %2% picture layers"), layer.chroma.base_layers, relief).c_str()); + + // ---- The picture, and the print it predicts ---- + if (!layer.chroma_source) { + if (m_imgui->button(_L("Choose picture"))) + chroma_pick_picture(layer); + tip(_u8L("The color picture to reproduce. It is kept with the layer, and the height map is computed from it " + "every time the stack changes.")); + ImGui::SameLine(); + ImGui::AlignTextToFramePadding(); + ImGui::TextDisabled("%s", _u8L("nothing to solve yet").c_str()); + } else { + // Side by side and the same size: the only place the picture and the result can be compared. + const float avail = ImGui::GetContentRegionAvail().x - card_pad; + const float thumb = std::max(m_imgui->scaled(2.f), std::round(0.5f * (avail - gap_s))); + if (m_chroma_source_key[slot] != layer.chroma_source.get()) { + TextureDisplacementLayer probe; + probe.image_data = layer.chroma_source; + m_chroma_source_thumb[slot] = upload_height_thumbnail(decode_height_texture(probe)); + m_chroma_source_key[slot] = layer.chroma_source.get(); + } + const auto panel = [&](GLTexture *tex, const wxString &caption, const std::string &tip_text) { + ImGui::BeginGroup(); + if (tex != nullptr) + ImGui::Image((ImTextureID) (intptr_t) tex->get_id(), ImVec2(thumb, thumb)); + else { + const ImVec2 p = ImGui::GetCursorScreenPos(); + ImGui::Dummy(ImVec2(thumb, thumb)); + dl->AddRect(p, ImVec2(p.x + thumb, p.y + thumb), ImGui::GetColorU32(ImGuiCol_Separator)); + } + if (!tip_text.empty()) + tip(tip_text); + ImGui::TextDisabled("%s", caption.ToUTF8().data()); + ImGui::EndGroup(); + }; + panel(m_chroma_source_thumb[slot].get(), _L("Picture"), _u8L("The picture being reproduced.")); + ImGui::SameLine(0.f, gap_s); + panel(m_chroma_preview[slot].get(), _L("Predicted print"), + _u8L("What this stack is predicted to print. The 3D view shows the same colors on the model; what it " + "cannot show is the picture beside them.")); + + if (m_imgui->button(_L("Change picture"))) + chroma_pick_picture(layer); + tip(_u8L("Choose a different picture. The stack and the placement stay as they are.")); + if (m_chroma_report[slot].valid) { + const ChromaReport &r = m_chroma_report[slot]; + const bool poor = r.mean > 15.; + const std::string text = Slic3r::format(_u8L("Match %1$.1f avg, %2$.1f worst"), r.mean, r.max); + ImGui::SameLine(); + ImGui::SetCursorPosX(std::max(ImGui::GetCursorPosX(), + ImGui::GetWindowContentRegionMax().x - card_pad - ImGui::CalcTextSize(text.c_str()).x)); + ImGui::AlignTextToFramePadding(); + if (poor) + m_imgui->warning_text(text.c_str()); + else + ImGui::TextDisabled("%s", text.c_str()); + tip(_u8L("How far the prediction lands from the picture, as a color difference. Under 5 is a good match; " + "over 15 means these filaments cannot reach the picture's colors.")); + if (poor) + wrapped(_u8L("These filaments cannot reach the picture's colors. More layers will not help - swap one " + "for something more translucent, or lighter."), + true); + } + } + + // ---- The base: everything the model already is below the painted face ---- + { + const ChromaPatch patch = chroma_patch(layer); + const int measured = chroma_measured_base(layer); + // While it follows the model it is a measurement, not a setting, and it has to be taken every + // frame: the picture is chosen before there is any paint to measure, and the face moves again + // whenever the part is scaled or the profile's layer height changes. + if (layer.chroma.base_auto && measured > 0 && measured != layer.chroma.base_layers) { + layer.chroma.base_layers = measured; + solve_dirty = true; + } + row_label(_L("Base")); + int base = layer.chroma.base_layers; + m_imgui->disabled_begin(layer.chroma.base_auto); + if (int_field("##hf_base", &base, 0, 20000, m_imgui->scaled(3.4f)) && !layer.chroma.base_auto) { + layer.chroma.base_layers = base; + solve_dirty = true; + } + m_imgui->disabled_end(); + if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) + m_imgui->tooltip(_u8L("How much of the print is solid backing under the picture, in layers. The colors are " + "computed on the assumption that no light gets through it, so it has to be thick " + "enough to hide the build plate.\n\n" + "It is a measurement of the model rather than a choice: it is everything below the " + "painted face, and a base that disagrees with the face shifts every color in the " + "picture."), + wrap_w); + ImGui::SameLine(); + if (layer.chroma.base_auto) { + ImGui::AlignTextToFramePadding(); + if (measured > 0) + ImGui::TextDisabled("%s", Slic3r::format(_u8L("= %1$.2f mm, from the painted face"), + double(layer.chroma.base_layers) * + double(layer.chroma.layer_height_mm)) + .c_str()); + else + ImGui::TextDisabled("%s", _u8L("paint the face to measure it").c_str()); + ImGui::SameLine(); + if (m_imgui->button(_L("Set by hand"))) + layer.chroma.base_auto = false; + tip(_u8L("Stop following the model and type the backing thickness yourself. Only needed when the picture " + "is not meant to start at the painted face.")); + } else { + if (m_imgui->button(_L("Measure"))) { + layer.chroma.base_auto = true; + solve_dirty = true; + } + tip(measured > 0 ? Slic3r::format(_u8L("Go back to following the model: the painted face is %1$.2f mm up, " + "which is %2% layers at this layer height."), + patch.top_mm, measured) : + _u8L("Go back to following the model, once something is painted to measure.")); + } + if (patch.valid && patch.spread_mm > std::max(0.05f, layer.chroma.layer_height_mm)) + wrapped(_u8L("The painted area is not flat. A filament swap happens at one height across the whole " + "plate, so the colors are only right where the surface is level."), + true); + } + + // ---- The filaments, bottom to top ---- + { + const std::vector loaded = chroma_filaments(); + int remove = -1; + int move = -1; // band to swap with the one above it + for (size_t bi = 0; bi < layer.chroma.bands.size(); ++bi) { + ChromaBand &band = layer.chroma.bands[bi]; + ImGui::PushID(int(bi)); + swatch(band.filament.color, std::round(frame_h * 0.7f)); + ImGui::SameLine(0.f, gap_s); + + const std::string current = band.filament.name.empty() ? + Slic3r::format(_u8L("Filament %1%"), band.filament.filament + 1) : + Slic3r::format("%1% %2%", band.filament.filament + 1, band.filament.name); + ImGui::SetNextItemWidth(-(m_imgui->scaled(3.2f) + m_imgui->scaled(2.6f) + 2.f * frame_h + 4.f * gap_s + right)); + ImGuiWrapper::push_combo_style(m_parent.get_scale()); + if (ImGui::BeginCombo("##hf_filament", current.c_str())) { + for (const ChromaFilament &f : loaded) { + const std::string item = f.name.empty() ? Slic3r::format(_u8L("Filament %1%"), f.filament + 1) : + Slic3r::format("%1% %2%", f.filament + 1, f.name); + if (ImGui::Selectable(item.c_str(), f.filament == band.filament.filament)) { + band.filament = f; + solve_dirty = true; + } + } + ImGui::EndCombo(); + } + ImGuiWrapper::pop_combo_style(); + tip(_u8L("Which of the loaded filaments this band prints in. Its color and its transmission distance come " + "from that filament's preset.")); + + ImGui::SameLine(0.f, gap_s); + if (int_field("##hf_layers", &band.layers, 1, CHROMA_MAX_RELIEF_LAYERS, m_imgui->scaled(3.2f))) + solve_dirty = true; + tip(_u8L("How many layers this filament gets. More layers of a translucent filament means a longer fade " + "from what is under it to its own color; one layer is a single faint tint.")); + + ImGui::SameLine(0.f, gap_s); + ImGui::SetNextItemWidth(m_imgui->scaled(3.4f)); + float td = band.filament.td_mm; + if (ImGui::InputFloat("##hf_td", &td, 0.f, 0.f, "TD %.1f")) { + // One filament, one transmission distance: a stack that used the same filament twice + // with two different TDs would be describing a material that does not exist. + const float v = std::clamp(td, 0.05f, 100.f); + for (ChromaBand &b : layer.chroma.bands) + if (b.filament.filament == band.filament.filament) + b.filament.td_mm = v; + solve_dirty = true; + } + tip(Slic3r::format(_u8L("This filament's transmission distance: how deep light gets into it. It starts " + "from the filament's preset and can be tried out here without changing it.\n\n" + "At %1$.3f mm layers this one hides what is under it in about %2$.1f layers. Below " + "two there are no colors between it and what is under it, and the picture can only " + "use the filament or not use it."), + layer.chroma.layer_height_mm, + chroma_layers_to_opaque(band.filament.td_mm, layer.chroma.layer_height_mm))); + + ImGui::SameLine(0.f, gap_s); + m_imgui->disabled_begin(bi + 1 >= layer.chroma.bands.size()); + if (m_imgui->button("^##hf_up")) + move = int(bi); + m_imgui->disabled_end(); + if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) + m_imgui->tooltip(_u8L("Print this filament later, i.e. further up the stack."), wrap_w); + ImGui::SameLine(0.f, gap_s); + m_imgui->disabled_begin(layer.chroma.bands.size() <= 1); + if (m_imgui->button("x##hf_del")) + remove = int(bi); + m_imgui->disabled_end(); + ImGui::PopID(); + } + + if (move >= 0) { + std::swap(layer.chroma.bands[size_t(move)], layer.chroma.bands[size_t(move) + 1]); + solve_dirty = true; + } + if (remove >= 0) { + layer.chroma.bands.erase(layer.chroma.bands.begin() + remove); + solve_dirty = true; + } + + if (!loaded.empty() && layer.chroma.bands.size() < CHROMA_MAX_BANDS) { + if (m_imgui->button(_L("Add filament"))) + ImGui::OpenPopup("hf_add"); + tip(_u8L("Adds another filament change on top of the stack.")); + if (ImGui::BeginPopup("hf_add")) { + for (const ChromaFilament &f : loaded) { + ImGui::PushID(f.filament); + swatch(f.color, std::round(frame_h * 0.7f)); + ImGui::SameLine(0.f, gap_s); + const std::string item = f.name.empty() ? Slic3r::format(_u8L("Filament %1%"), f.filament + 1) : + Slic3r::format("%1% %2%", f.filament + 1, f.name); + if (ImGui::Selectable(item.c_str())) { + ChromaBand band; + band.filament = f; + band.layers = 4; + layer.chroma.bands.push_back(band); + solve_dirty = true; + } + ImGui::PopID(); + } + ImGui::EndPopup(); + } + ImGui::SameLine(0.f, gap_s); + } + { + const int distinct = chroma_distinct_levels(layer.chroma); + m_imgui->disabled_begin(distinct >= layer.chroma.relief_layers()); + if (m_imgui->button(_L("Trim"))) { + chroma_trim_bands(layer.chroma); + solve_dirty = true; + } + m_imgui->disabled_end(); + if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) + m_imgui->tooltip(_u8L("Shortens each band to the point where its filament stops letting light " + "through. The layers past it show the colour the one below already showed, so " + "they cost print time and relief height and buy nothing."), + wrap_w); + ImGui::SameLine(0.f, gap_s); + } + if (m_imgui->button(_L("Reset"))) { + layer.chroma = chroma_default_stack(loaded, + layer.chroma.layer_height_mm > 0.f ? layer.chroma.layer_height_mm : profile_h, + layer.chroma.base_layers, CHROMA_DEFAULT_RELIEF_LAYERS); + solve_dirty = true; + } + tip(_u8L("Starts again from every loaded filament, lightest at the bottom, each with the same number of " + "layers.")); + { + const std::string totals = Slic3r::format(_u8L("%1% layers, %2$.2f mm"), layer.chroma.total_layers(), + layer.chroma.depth_mm()); + ImGui::SameLine(); + ImGui::SetCursorPosX(std::max(ImGui::GetCursorPosX(), ImGui::GetWindowContentRegionMax().x - card_pad - + ImGui::CalcTextSize(totals.c_str()).x)); + ImGui::AlignTextToFramePadding(); + ImGui::TextDisabled("%s", totals.c_str()); + } + + // The colours this stack can reach, bottom to top. Computed from the stack alone, so it answers + // "can these filaments print this picture" before a solve is spent finding out that they cannot. + const std::vector palette = chroma_palette(layer.chroma); + if (int(palette.size()) > layer.chroma.base_layers) { + const float w = ImGui::GetContentRegionAvail().x - card_pad; + const float h = std::round(m_imgui->scaled(0.8f)); + const ImVec2 p = ImGui::GetCursorScreenPos(); + const int first = std::max(0, layer.chroma.base_layers); + const int count = int(palette.size()) - first; + ImGui::Dummy(ImVec2(w, h)); + for (int i = 0; i < count; ++i) { + const ColorRGB &c = palette[size_t(first + i)]; + const float x0 = p.x + w * float(i) / float(count); + const float x1 = p.x + w * float(i + 1) / float(count); + dl->AddRectFilled(ImVec2(x0, p.y), ImVec2(x1, p.y + h), + IM_COL32(int(c.r() * 255.f), int(c.g() * 255.f), int(c.b() * 255.f), 255)); + } + dl->AddRect(p, ImVec2(p.x + w, p.y + h), ImGui::GetColorU32(ImGuiCol_Separator)); + if (ImGui::IsItemHovered()) + m_imgui->tooltip(_u8L("Every color this stack can print, from the base at the left to the top of the " + "stack at the right. A short or flat ramp means the filaments are too alike, or " + "too opaque, to give the picture anything to work with."), + wrap_w); + ImGui::TextDisabled("%s", _u8L("Colors this stack reaches, base to top").c_str()); + + // Why that ramp looks the way it does. A stack blends only as far as its filaments let + // light through at this layer height, and nothing else in the panel makes that visible: + // the filaments all look fine, the bands all look long enough, and the result is steps. + float thinnest = 1e9f; + for (const ChromaBand &band : layer.chroma.bands) + thinnest = std::min(thinnest, chroma_layers_to_opaque(band.filament.td_mm, layer.chroma.layer_height_mm)); + if (thinnest < 1e8f) { + if (thinnest < 2.f) + wrapped(Slic3r::format(_u8L("At %1$.3f mm layers these filaments cover in %2$.1f layers, so the " + "stack steps straight from one color to the next. A finer layer height, " + "or filaments with a higher TD, is what buys the colors in between."), + layer.chroma.layer_height_mm, thinnest), + true); + else { + const int distinct = chroma_distinct_levels(layer.chroma); + const int relief = layer.chroma.relief_layers(); + const std::string text = + Slic3r::format(_u8L("About %1$.0f layers to fade, and %2% of %3% picture layers add a color"), + thinnest, distinct, relief); + if (distinct * 4 < relief * 3) + wrapped(text, true); + else + ImGui::TextDisabled("%s", text.c_str()); + if (ImGui::IsItemHovered()) + m_imgui->tooltip(_u8L("A layer past the point where its filament stops letting light through " + "shows the color the one below it already showed. It still costs print " + "time and relief height. Trim shortens every band to the layer where " + "that happens."), + wrap_w); + } + } + } + if (layer.chroma.relief_layers() > CHROMA_MAX_RELIEF_LAYERS) + wrapped(Slic3r::format(_u8L("The picture cannot be more than %1% layers tall. Give the bands fewer layers, " + "or raise the layer height."), + CHROMA_MAX_RELIEF_LAYERS), + true); + } + + // ---- Dither, the layer height it is all solved against, and the two actions ---- + if (m_imgui->bbl_checkbox(_L("Dither"), layer.chroma.dither)) + solve_dirty = true; + tip(_u8L("Scatters the rounding error into neighbouring pixels instead of rounding each one on its own. It trades " + "the bands a photograph shows across a sky or a cheek for fine stipple, which at this scale reads as the " + "color in between. Leave it off for flat-colored artwork.")); + { + const std::string lh = Slic3r::format(_u8L("%1$.3f mm layers"), layer.chroma.layer_height_mm); + ImGui::SameLine(); + ImGui::SetCursorPosX(std::max(ImGui::GetCursorPosX(), + ImGui::GetWindowContentRegionMax().x - card_pad - ImGui::CalcTextSize(lh.c_str()).x)); + ImGui::AlignTextToFramePadding(); + ImGui::TextDisabled("%s", lh.c_str()); + tip(_u8L("The layer height the stack is solved for. It comes from the print profile, because the colors depend " + "on how thick each layer of filament is.")); + } + if (profile_h > 0.f && std::abs(profile_h - layer.chroma.layer_height_mm) > 1e-4f) { + wrapped(Slic3r::format(_u8L("The print profile is at %1$.3f mm now."), profile_h), true); + if (m_imgui->button(Slic3r::format(_u8L("Use %1$.3f mm"), profile_h))) { + layer.chroma.layer_height_mm = profile_h; + solve_dirty = true; + } + } + + if (m_imgui->button(_L("Solve"))) + chroma_solve_layer(layer); + tip(_u8L("Works out the height of every pixel so the stack shows the picture's colors there, and replaces this " + "layer's height map with the result.")); + ImGui::SameLine(0.f, gap_s); + const bool applied = chroma_swaps_applied(layer); + m_imgui->disabled_begin(layer.chroma.bands.empty()); + if (m_imgui->button(_L("Apply swaps"))) + chroma_apply_swaps(layer); + m_imgui->disabled_end(); + if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) + m_imgui->tooltip(_u8L("Writes the stack onto the object as height ranges, which is how the slicer is told to " + "print each band of layers in a different filament. Any height ranges the object already " + "has are replaced."), + wrap_w); + if (!layer.chroma.bands.empty()) { + if (applied) { + ImGui::SameLine(); + ImGui::TextDisabled("%s", _u8L("Swaps applied").c_str()); + } else { + wrapped(_u8L("The filament swaps have not been applied, so this would print in one color."), true); + } + } + + // The one place the stack's colours can be seen on the model. + if (m_imgui->bbl_checkbox(_L("Show printed colors"), m_show_chroma_colors)) { + m_chroma_color_key = 0; + m_parent.set_as_dirty(); + } + tip(_u8L("Paints the model with the colors this stack will print: every surface takes the color of the printed " + "layer it sits at, which is what a filament swap actually does. Seen from above, that is the picture.\n\n" + "It stays after a bake, and it is the only place these colors exist outside the sliced preview - they are " + "blends of translucent filaments, not filaments, so the 3D scene cannot paint them.")); + + m_chroma_stale[slot] = m_chroma_stale[slot] || solve_dirty; + if (m_chroma_stale[slot]) + wrapped(_u8L("Solve again to bring the relief up to date."), true); +} + void GLGizmoTextureDisplacement::render_texture_library_popup(const ImVec2 &panel_min, const ImVec2 &panel_max) { ModelVolume *mv = texture_volume(); @@ -6320,6 +7226,25 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float }; const auto drag_tint = [dark](int alpha) { return dark ? IM_COL32(255, 255, 255, alpha) : IM_COL32(90, 90, 90, alpha); }; + // A card's type badge: a 1px frame around a word. Teal for a ChromaStack picture, the disabled grey + // for a relief layer, so the two kinds are told apart by frame and word rather than by colour alone. + const auto chip_width = [&](const wxString &text) { + return ImGui::CalcTextSize(into_u8(text).c_str()).x + 2.f * std::round(m_imgui->scaled(0.18f)); + }; + const auto chip = [&](const wxString &text, bool accent) { + const std::string t = into_u8(text); + const ImVec2 ts = ImGui::CalcTextSize(t.c_str()); + const float pad = std::round(m_imgui->scaled(0.18f)); + const ImVec2 p = ImGui::GetCursorScreenPos(); + const float h = ts.y + 2.f * pad; + ImGui::Dummy(ImVec2(ts.x + 2.f * pad, frame_h)); + const float y = std::round(p.y + 0.5f * (frame_h - h)); + const ImU32 col = accent ? ImGui::GetColorU32(orca) : ImGui::GetColorU32(ImGuiCol_TextDisabled); + ImDrawList *d = ImGui::GetWindowDrawList(); + d->AddRect(ImVec2(p.x, y), ImVec2(p.x + ts.x + 2.f * pad, y + h), col, style.FrameRounding); + d->AddText(ImVec2(p.x + pad, y + pad), col, t.c_str()); + }; + const float grip_w = std::round(m_imgui->scaled(0.7f)); // Six-dot handle that drags a layer onto another one to reorder the stack. const auto grip = [&](const TextureDisplacementLayer &l, float h) { @@ -6378,6 +7303,9 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float TextureDisplacementLayer &layer = *ordered[li]; const bool is_active = layer.slot == m_active_layer_slot; const bool painted = slot_painted(layer.slot); + // Which of the two cards this is. The kind is the layer's own, fixed when it was added, so a + // card never shows a control that does not apply to it. + const bool chroma_card = layer.chroma_enabled; ImGui::PushID(layer.slot); if (is_active) { @@ -6394,16 +7322,43 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float // Header: grip, thumbnail and name (both open the library), paint state, order, remove. { ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(gap_s, style.ItemSpacing.y)); - grip(layer, frame_h); - ImGui::SameLine(); - if (GLTexture *thumb = get_layer_thumbnail(layer)) { - if (ImGui::ImageButton((ImTextureID) (intptr_t) thumb->get_id(), ImVec2(frame_h, frame_h), ImVec2(0.f, 0.f), - ImVec2(1.f, 1.f), 0)) - open_picker(layer.slot); - } else if (ImGui::Button("?##thumb", ImVec2(frame_h, frame_h))) { - open_picker(layer.slot); + if (chroma_card) { + // No grip: a ChromaStack picture is always the top layer. Its colours come from + // swapping filament at a height, so everything printed above it would bury them, + // and its relief replaces what is under it rather than blending into it. + ImGui::Dummy(ImVec2(grip_w, frame_h)); + if (ImGui::IsItemHovered()) + m_imgui->tooltip(_u8L("A ChromaStack picture is always the top layer: anything printed over it " + "would bury the colours."), + wrap_w); + ImGui::SameLine(); } - hover_tip(_u8L("Pick a different image for this layer. The paint, depth and tiling stay as they are.")); + else + grip(layer, frame_h), ImGui::SameLine(); + // The ChromaStack card's thumbnail is the *predicted print* rather than the source + // picture: it is the most informative image in the panel, and this is where the eye + // already lands - including on a card that has been collapsed. + GLTexture *thumb = chroma_card ? (m_chroma_preview[size_t(layer.slot)] ? + m_chroma_preview[size_t(layer.slot)].get() : + nullptr) : + get_layer_thumbnail(layer); + if (thumb != nullptr) { + if (ImGui::ImageButton((ImTextureID) (intptr_t) thumb->get_id(), ImVec2(frame_h, frame_h), ImVec2(0.f, 0.f), + ImVec2(1.f, 1.f), 0)) { + if (chroma_card) + chroma_pick_picture(layer); + else + open_picker(layer.slot); + } + } else if (ImGui::Button("?##thumb", ImVec2(frame_h, frame_h))) { + if (chroma_card) + chroma_pick_picture(layer); + else + open_picker(layer.slot); + } + hover_tip(chroma_card ? + _u8L("The print this stack is predicted to produce. Click to choose a different picture.") : + _u8L("Pick a different image for this layer. The paint, depth and tiling stay as they are.")); { // The teal outline on the thumbnail is what marks this card as the layer being painted. const ImVec2 a = ImGui::GetItemRectMin(), b = ImGui::GetItemRectMax(); @@ -6412,34 +7367,53 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float ImGui::SameLine(); const std::string not_painted = _u8L("not painted"); - const float right_w = (painted ? 0.f : ImGui::CalcTextSize(not_painted.c_str()).x + gap_s) + 3.f * frame_h + 2.f * gap_s; + const wxString chip_text = chroma_card ? _L("ChromaStack") : _L("Relief"); + const float right_w = (painted ? 0.f : ImGui::CalcTextSize(not_painted.c_str()).x + gap_s) + + chip_width(chip_text) + gap_s + + (chroma_card ? 1.f : 3.f) * frame_h + 2.f * gap_s; const float name_w = std::max(frame_h, ImGui::GetWindowContentRegionMax().x - card_pad - ImGui::GetCursorPosX() - gap_s - right_w); ImGui::PushStyleColor(ImGuiCol_HeaderHovered, ImVec4(orca.x, orca.y, orca.z, 0.2f)); ImGui::PushStyleColor(ImGuiCol_HeaderActive, ImVec4(orca.x, orca.y, orca.z, 0.35f)); ImGui::AlignTextToFramePadding(); const std::string shown = ellipsize(layer_name(layer), name_w - 2.f * style.FramePadding.x); - if (ImGui::Selectable((shown + "##name").c_str(), false, 0, ImVec2(name_w, frame_h))) - open_picker(layer.slot); + if (ImGui::Selectable((shown + "##name").c_str(), false, 0, ImVec2(name_w, frame_h))) { + if (chroma_card) + chroma_pick_picture(layer); + else + open_picker(layer.slot); + } ImGui::PopStyleColor(2); - hover_tip(_u8L("Pick a different image for this layer. The paint, depth and tiling stay as they are.")); + hover_tip(chroma_card ? _u8L("Choose a different picture. The paint, the stack and the placement stay " + "as they are.") : + _u8L("Pick a different image for this layer. The paint, depth and tiling stay " + "as they are.")); + ImGui::SameLine(0.f, gap_s); + chip(chip_text, chroma_card); + if (ImGui::IsItemHovered()) + m_imgui->tooltip(chroma_card ? _u8L("A color picture made of filament swaps. Its height map is " + "solved from the picture and the filaments below.") : + _u8L("A height map pushed into the surface. Tiles, depth, blending."), + wrap_w); if (!painted) { ImGui::SameLine(); ImGui::TextDisabled("%s", not_painted.c_str()); } - ImGui::SameLine(); - m_imgui->disabled_begin(busy || li == 0); - if (icon_button(611, "texture_displacement_move_up.svg", frame_h, _L("Move up"), _L("Move up - applied earlier"))) { - move_slot = layer.slot; - move_to = int(li) - 1; + if (!chroma_card) { + ImGui::SameLine(); + m_imgui->disabled_begin(busy || li == 0); + if (icon_button(611, "texture_displacement_move_up.svg", frame_h, _L("Move up"), _L("Move up - applied earlier"))) { + move_slot = layer.slot; + move_to = int(li) - 1; + } + m_imgui->disabled_end(); + ImGui::SameLine(); + m_imgui->disabled_begin(busy || li + 1 >= ordered.size()); + if (icon_button(612, "texture_displacement_move_down.svg", frame_h, _L("Move down"), _L("Move down - applied later"))) { + move_slot = layer.slot; + move_to = int(li) + 2; + } + m_imgui->disabled_end(); } - m_imgui->disabled_end(); - ImGui::SameLine(); - m_imgui->disabled_begin(busy || li + 1 >= ordered.size()); - if (icon_button(612, "texture_displacement_move_down.svg", frame_h, _L("Move down"), _L("Move down - applied later"))) { - move_slot = layer.slot; - move_to = int(li) + 2; - } - m_imgui->disabled_end(); ImGui::SameLine(); m_imgui->disabled_begin(busy); if (icon_button(600 + layer.slot, "texture_displacement_cross.svg", frame_h, m_desc.at("remove_layer"), @@ -6449,6 +7423,19 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float ImGui::PopStyleVar(); } + if (chroma_card) { + // Everything the stack is and does, in one block: the picture and the print it predicts, + // the base, the filaments, the colours they reach, and the two actions. + render_chroma_card(layer, card_pad, label_w, wrap_w); + // Placement is the only part of the relief controls a picture still has a use for. + m_preview_params_dirty |= float_row("##tile_size", _L("Picture size"), &layer.tiling_scale, 0.2f, 500.f, + "%.2f mm", true, card_pad); + hover_tip(_u8L("How wide the picture is on the model. It is set to cover the painted face when the " + "picture is chosen; change it to crop in or to leave a margin.")); + m_preview_params_dirty |= float_row("##rotation", _L("Rotation"), &layer.rotation_deg, 0.f, 360.f, "%.0f°", + false, card_pad); + hover_tip(_u8L("Turns the picture on the surface, in degrees.")); + } else { // The three controls nearly every layer needs; everything else waits behind "More settings". // Depth and tile size are logarithmic, so both ends of their wide ranges stay usable. m_preview_params_dirty |= float_row("##depth", _L("Depth"), &layer.depth_mm, 0.01f, 10.f, "%.3f mm", true, card_pad); @@ -6461,6 +7448,7 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float m_preview_params_dirty |= float_row("##rotation", _L("Rotation"), &layer.rotation_deg, 0.f, 360.f, "%.0f°", false, card_pad); hover_tip(_u8L("Turns the texture on the surface, in degrees - for lining a pattern up with an " "edge of the model.")); + } if (m_layer_expanded[size_t(layer.slot)]) { { @@ -6473,94 +7461,103 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float ImGui::Dummy(ImVec2(1.f, 1.f)); } - // Midlevel: the height that means "don't move". At 0 the surface only ever bulges outwards; - // at 0.5 mid-grey is neutral and darker texels cut inwards. - m_preview_params_dirty |= float_row("##midlevel", _L("Midlevel"), &layer.midlevel, 0.f, 10.f, "%.2f", false, card_pad); - hover_tip(_u8L("Which gray stays where the surface already is. At 0 the texture only pushes " - "outwards; at 0.5 mid-gray stays put, so darker grays cut in and lighter ones " - "still push out - one image both embosses and engraves.\n\n" - "What cuts in has to fit: inside a sharp corner or through a thin wall, a deep " - "cut can pass through the other side.")); - if (layer.midlevel > 0.f && layer.depth_mm > 1.f) { - ImGui::PushTextWrapPos(content_rx - card_pad); - m_imgui->warning_text(_L("Deep inward displacement may self-intersect.")); - ImGui::PopTextWrapPos(); - } - - m_preview_params_dirty |= float_row("##smoothing", _L("Smoothing"), &layer.smoothing, 0.f, 1.f, "%.2f", false, card_pad); - hover_tip(_u8L("Blurs the image before it is used, which rounds off hard steps and removes " - "speckle from a noisy photo. Raise it if the relief looks harsh or grainy; it " - "costs fine detail.")); - - // Edge fade: the relief flattens toward the boundary of the painted area. - m_preview_params_dirty |= m_imgui->bbl_checkbox(_L("Edge fade") + "##edge_smoothing", layer.edge_smoothing); - hover_tip(_u8L("Flattens the relief as it approaches the edge of the painted area, so it " - "blends into the bare surface instead of stopping at a step.")); - ImGui::SameLine(); - m_imgui->disabled_begin(!layer.edge_smoothing); - ImGui::SetNextItemWidth(-card_pad); - m_preview_params_dirty |= ImGui::SliderFloat("##edge_amount", &layer.edge_smoothing_amount, 0.02f, 1.f, "%.2f", - ImGuiSliderFlags_AlwaysClamp); - m_imgui->disabled_end(); - hover_tip(_u8L("How far in the fade reaches, as a share of the painted area. Small values " - "soften a narrow band at the edge; 1 flattens almost all of it.")); - - // Invert and Colours share a row. - { - const float x0 = ImGui::GetCursorPosX(); - m_preview_params_dirty |= m_imgui->bbl_checkbox(_L("Invert"), layer.invert); - hover_tip(_u8L("Turns the relief inside out: what stood out is cut in, and the other way " - "round. The same as using a negative of the image.")); - ImGui::SameLine(); - ImGui::SetCursorPosX(std::max(ImGui::GetCursorPosX(), x0 + 0.5f * (ImGui::GetWindowContentRegionMax().x - card_pad - x0))); - - // Only offered for a texture that actually has colour - the shipped library is grayscale, - // and a checkbox that silently does nothing on nine textures out of ten is worse than no - // checkbox. Disabled rather than hidden so it is clear the feature exists and what it wants. - const bool has_color = decode_height_texture(layer).has_color(); - bool color_enabled = layer.color_enabled && has_color; - m_imgui->disabled_begin(!has_color); - if (m_imgui->bbl_checkbox(_L("Colors"), color_enabled)) { - layer.color_enabled = color_enabled; - m_preview_params_dirty = true; + // Everything from here to Mapping shapes a height map by hand. A ChromaStack picture's + // height map is solved, and every one of these would move its steps off the print's layer + // boundaries, so that card does not carry them at all. + if (!chroma_card) { + // Midlevel: the height that means "don't move". At 0 the surface only ever bulges outwards; + // at 0.5 mid-grey is neutral and darker texels cut inwards. + m_preview_params_dirty |= float_row("##midlevel", _L("Midlevel"), &layer.midlevel, 0.f, 10.f, "%.2f", false, card_pad); + hover_tip(_u8L("Which gray stays where the surface already is. At 0 the texture only pushes " + "outwards; at 0.5 mid-gray stays put, so darker grays cut in and lighter ones " + "still push out - one image both embosses and engraves.\n\n" + "What cuts in has to fit: inside a sharp corner or through a thin wall, a deep " + "cut can pass through the other side.")); + if (layer.midlevel > 0.f && layer.depth_mm > 1.f) { + ImGui::PushTextWrapPos(content_rx - card_pad); + m_imgui->warning_text(_L("Deep inward displacement may self-intersect.")); + ImGui::PopTextWrapPos(); } - m_imgui->disabled_end(); - if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) - m_imgui->tooltip(has_color ? - _u8L("Prints the painted area in the texture's colors as well as its " - "relief. Each color is matched to the nearest of your loaded " - "filaments; anything you did not paint keeps the object's own.") : - _u8L("This texture is a grayscale height map, so it has no colors to " - "apply. Import a color image to use this."), - wrap_w); - // The rest of colour belongs to the whole stack, not to this layer, so it only appears once - - // under whichever layer turned colour on. - if (color_enabled) { - TextureDisplacementOptions &opts = mv->texture_displacement_options; - if (m_imgui->bbl_checkbox(_L("Mix filaments"), opts.color_mix_enabled)) + m_preview_params_dirty |= float_row("##smoothing", _L("Smoothing"), &layer.smoothing, 0.f, 1.f, "%.2f", false, card_pad); + hover_tip(_u8L("Blurs the image before it is used, which rounds off hard steps and removes " + "speckle from a noisy photo. Raise it if the relief looks harsh or grainy; it " + "costs fine detail.")); + + // Edge fade: the relief flattens toward the boundary of the painted area. + m_preview_params_dirty |= m_imgui->bbl_checkbox(_L("Edge fade") + "##edge_smoothing", layer.edge_smoothing); + hover_tip(_u8L("Flattens the relief as it approaches the edge of the painted area, so it " + "blends into the bare surface instead of stopping at a step.")); + ImGui::SameLine(); + m_imgui->disabled_begin(!layer.edge_smoothing); + ImGui::SetNextItemWidth(-card_pad); + m_preview_params_dirty |= ImGui::SliderFloat("##edge_amount", &layer.edge_smoothing_amount, 0.02f, 1.f, "%.2f", + ImGuiSliderFlags_AlwaysClamp); + m_imgui->disabled_end(); + hover_tip(_u8L("How far in the fade reaches, as a share of the painted area. Small values " + "soften a narrow band at the edge; 1 flattens almost all of it.")); + + // Invert and Colours share a row. + { + const float x0 = ImGui::GetCursorPosX(); + m_preview_params_dirty |= m_imgui->bbl_checkbox(_L("Invert"), layer.invert); + hover_tip(_u8L("Turns the relief inside out: what stood out is cut in, and the other way " + "round. The same as using a negative of the image.")); + ImGui::SameLine(); + ImGui::SetCursorPosX(std::max(ImGui::GetCursorPosX(), x0 + 0.5f * (ImGui::GetWindowContentRegionMax().x - card_pad - x0))); + + // Only offered for a texture that actually has colour - the shipped library is grayscale, + // and a checkbox that silently does nothing on nine textures out of ten is worse than no + // checkbox. Disabled rather than hidden so it is clear the feature exists and what it wants. + const bool has_color = decode_height_texture(layer).has_color() && !layer.chroma_enabled; + bool color_enabled = layer.color_enabled && has_color; + m_imgui->disabled_begin(!has_color); + if (m_imgui->bbl_checkbox(_L("Colors"), color_enabled)) { + layer.color_enabled = color_enabled; m_preview_params_dirty = true; - hover_tip(_u8L("Interleaves two filaments to fake the colors in between, so a handful " - "of filaments can cover a photo or a gradient. An image of flat colors " - "prints the same either way. Off uses one filament per area.")); - if (opts.color_mix_enabled) { - cached_palette(); // brings m_palette_filaments up to date - // Every mix can become a filament slot, and there are only so many beside the - // physical filaments. - const int max_mixes = std::max(1, int(EnforcerBlockerType::ExtruderMax) - int(m_palette_filaments.size())); - if (int_row("##color_mix_count", _L("Mixed colors"), &opts.color_mix_count, 1, max_mixes, "%d", card_pad)) - m_preview_params_dirty = true; - hover_tip(_u8L("The most mixed filaments a bake may add. They are picked from the " - "texture's colors, and only the ones the bake actually uses are created.")); - // After the slider, so a change shows in the same frame. - ImGui::TextDisabled("%s", Slic3r::format(_u8L("%1% printable colors from %2% filaments"), - int(cached_palette().size()), int(m_palette_filaments.size())).c_str()); } - if (int_row("##color_despeckle", _L("Denoise"), &opts.color_despeckle, 0, 6, "%d", card_pad)) - m_preview_params_dirty = true; - hover_tip(_u8L("Cleans up single stray triangles of the wrong color, which detail finer " - "than the mesh leaves behind. Raise it if the result looks speckled, " - "lower it if small features are being swallowed.")); + m_imgui->disabled_end(); + if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) + m_imgui->tooltip(has_color ? + _u8L("Prints the painted area in the texture's colors as well as its " + "relief. Each color is matched to the nearest of your loaded " + "filaments; anything you did not paint keeps the object's own.") : + layer.chroma_enabled ? + _u8L("This layer gets its colors from ChromaStack's filament swaps, which " + "is a different way of coloring the same surface. Turn ChromaStack off " + "to paint one filament per area instead.") : + _u8L("This texture is a grayscale height map, so it has no colors to " + "apply. Import a color image to use this."), + wrap_w); + + // The rest of colour belongs to the whole stack, not to this layer, so it only appears once - + // under whichever layer turned colour on. + if (color_enabled) { + TextureDisplacementOptions &opts = mv->texture_displacement_options; + if (m_imgui->bbl_checkbox(_L("Mix filaments"), opts.color_mix_enabled)) + m_preview_params_dirty = true; + hover_tip(_u8L("Interleaves two filaments to fake the colors in between, so a handful " + "of filaments can cover a photo or a gradient. An image of flat colors " + "prints the same either way. Off uses one filament per area.")); + if (opts.color_mix_enabled) { + cached_palette(); // brings m_palette_filaments up to date + // Every mix can become a filament slot, and there are only so many beside the + // physical filaments. + const int max_mixes = std::max(1, int(EnforcerBlockerType::ExtruderMax) - int(m_palette_filaments.size())); + if (int_row("##color_mix_count", _L("Mixed colors"), &opts.color_mix_count, 1, max_mixes, "%d", card_pad)) + m_preview_params_dirty = true; + hover_tip(_u8L("The most mixed filaments a bake may add. They are picked from the " + "texture's colors, and only the ones the bake actually uses are created.")); + // After the slider, so a change shows in the same frame. + ImGui::TextDisabled("%s", Slic3r::format(_u8L("%1% printable colors from %2% filaments"), + int(cached_palette().size()), int(m_palette_filaments.size())).c_str()); + } + if (int_row("##color_despeckle", _L("Denoise"), &opts.color_despeckle, 0, 6, "%d", card_pad)) + m_preview_params_dirty = true; + hover_tip(_u8L("Cleans up single stray triangles of the wrong color, which detail finer " + "than the mesh leaves behind. Raise it if the result looks speckled, " + "lower it if small features are being swallowed.")); + } } } @@ -6697,8 +7694,8 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float } } - // Tile, and how it repeats. - { + // Tile, and how it repeats. A picture is one copy, never a tile. + if (!chroma_card) { const float x0 = ImGui::GetCursorPosX(); m_preview_params_dirty |= m_imgui->bbl_checkbox(_L("Tile") + "##tile_enabled", layer.tile_enabled); hover_tip(_u8L("Repeats the texture across the painted area. Off places one copy, like a decal, " @@ -6720,9 +7717,12 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float } } - // Blend, and placement on the model. + // Blend, and placement on the model. A picture replaces what is under it rather than + // blending into it - it is the top layer by rule - so it keeps the placement and not the mode. { + const bool blend_row = !chroma_card; const float x0 = ImGui::GetCursorPosX(); + if (blend_row) { ImGui::AlignTextToFramePadding(); ImGui::TextDisabled("%s", _u8L("Blend").c_str()); ImGui::SameLine(); @@ -6754,6 +7754,14 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float wrap_w); ImGui::SameLine(0.f, gap_s); + } else { + // No blend mode on a picture, but the placement handle still belongs to it, so the + // row keeps its label column and loses only the combo. + ImGui::AlignTextToFramePadding(); + ImGui::TextDisabled("%s", _u8L("Placement").c_str()); + ImGui::SameLine(); + ImGui::SetCursorPosX(std::max(ImGui::GetCursorPosX(), x0 + label_w)); + } if (icon_toggle(842, "texture_displacement_adjust.svg", m_adjust_texture_mode, icon_sm, _L("Adjust placement"), _L("Adjust placement - drag the handle on the model to move the texture"))) { if (!m_adjust_texture_mode) { @@ -6918,8 +7926,27 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float "+ " + _u8L("Add layer"); m_imgui->disabled_begin(busy || full); if (ImGui::Button((label + "##add_layer").c_str(), ImVec2(ImGui::GetContentRegionAvail().x, 0.f))) - add_texture_layer(); + ImGui::OpenPopup("add_layer_kind"); m_imgui->disabled_end(); + // The kind is chosen here because it is a property of the layer, not of the panel: the two + // cards carry different controls and a card never shows one that does not apply to it. + if (ImGui::BeginPopup("add_layer_kind")) { + const auto kind = [&](const wxString &title, const std::string &what) { + const bool picked = ImGui::Selectable(("##" + into_u8(title)).c_str(), false, 0, + ImVec2(0.f, 2.f * ImGui::GetTextLineHeight() + 4.f)); + const ImVec2 mn = ImGui::GetItemRectMin(); + ImDrawList *dl = ImGui::GetWindowDrawList(); + dl->AddText(ImVec2(mn.x, mn.y + 2.f), ImGui::GetColorU32(ImGuiCol_Text), title.ToUTF8().data()); + dl->AddText(ImVec2(mn.x, mn.y + 2.f + ImGui::GetTextLineHeight()), + ImGui::GetColorU32(ImGuiCol_TextDisabled), what.c_str()); + return picked; + }; + if (kind(_L("Relief layer"), _u8L("A height map pushed into the surface. Tiles, depth, blending."))) + add_texture_layer(false); + if (kind(_L("ChromaStack picture"), _u8L("A color picture made of filament swaps. One copy, flat face."))) + add_texture_layer(true); + ImGui::EndPopup(); + } } // ---- Mesh preparation: Pro only. Standard pins all of it and runs it from Bake. ---- diff --git a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp index 48e0ab72f6..f6a558d185 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp @@ -235,7 +235,11 @@ private: ModelVolume* texture_volume(); const ModelVolume* texture_volume() const; - void add_texture_layer(); + // `chroma` picks the kind: a relief layer, a height map pushed into the surface, or a ChromaStack + // picture, a colour picture made of filament swaps. The kind is a property of the layer and is + // fixed when it is created - there is no panel-wide mode, so a plaque can carry a picture on its + // face and a knurl on its rim at the same time. + void add_texture_layer(bool chroma = false); void remove_texture_layer(int slot); // Moves the layer in `slot` to position `to_index` of the slot-ordered stack (an insertion index, // 0..layer count), which is the order blend modes apply in. Layers keep their data; what moves is @@ -399,6 +403,109 @@ private: void set_layer_texture(TextureDisplacementLayer &layer, const TextureLibraryEntry &entry); void import_custom_texture(TextureDisplacementLayer &layer); + // ---- ChromaStack (see libslic3r/ChromaStack.hpp) ---- + // + // A ChromaStack layer is an ordinary texture displacement layer whose height map is computed rather + // than imported: the picture the user picked is kept in `chroma_source`, the stack in + // `chroma`, and `image_data` holds whatever the last solve produced. Everything downstream - + // projection, preview, bake - therefore needs no changes at all, and this is the whole of the + // feature's GUI side: pick a picture, edit the stack, solve, hand the filament swaps to the + // object's height ranges. + + // The project's loaded filaments as the colour model sees them: colour from the project, TD from + // each filament's preset. Index is the filament index the swaps are written from. + static std::vector chroma_filaments(); + // The layer height the print profile is set to, mm; 0 when there is no print preset. The solve is + // only valid for the layer height it was run at, so the panel compares it against the stack's. + static float print_layer_height(); + + // Where the active layer's painted patch sits, in the object's own printed coordinates - which is + // what the stack has to be lined up with, because a filament swap happens at a print Z and the + // relief grows upward from this patch. + struct ChromaPatch + { + bool valid = false; + float top_mm = 0.f; // the patch's surface above the bottom of the object + float spread_mm = 0.f; // how far from flat the patch is in Z; 0 is a horizontal face + // The patch's extent in the plane the texture is projected from, in the bake frame - i.e. the + // (x, y) the Z-facing plane of the tri-planar projection samples by. This is what the picture + // has to be placed against: tiling_scale and offset are in that space, not in the patch's. + Vec2f planar_min = Vec2f::Zero(); + Vec2f planar_max = Vec2f::Zero(); + }; + ChromaPatch chroma_patch(const TextureDisplacementLayer &layer) const; + // The painted face's height in layers, which is what the stack's base has to be: -1 when nothing + // is painted yet or there is no layer height to divide by. + int chroma_measured_base(const TextureDisplacementLayer &layer) const; + // Extracting the painted patch is far too expensive to repeat once a frame, which is how often the + // panel wants the number. The paint's own timestamp and the transform the patch was measured in are + // between them everything the answer depends on. + mutable std::array m_chroma_patch_cache{}; + mutable std::array m_chroma_patch_stamp{}; + mutable std::array m_chroma_patch_trafo{}; + // The patch is measured in two frames at once - the instance's, for the print height, and the + // bake's, for the placement - so both are part of what the cached answer depends on. + mutable std::array m_chroma_patch_bake{}; + + // Picks a colour picture for the layer, turns ChromaStack on, and seeds a stack from the loaded + // filaments and the patch if the layer has none yet. Solves straight away, so the relief appears + // without a second click. + void chroma_pick_picture(TextureDisplacementLayer &layer); + // Re-runs the solve and replaces the layer's height map with its result. False (with the reason + // shown to the user) when the layer has no picture, no stack or no relief layers. + bool chroma_solve_layer(TextureDisplacementLayer &layer); + // Writes the stack's filament swaps onto the object as height ranges, replacing whatever ranges it + // had. Takes its own undo snapshot. + void chroma_apply_swaps(const TextureDisplacementLayer &layer); + // The stack's swaps as the object's height ranges already express them: true when applying would + // change nothing, which is what the panel's hint keys off. + bool chroma_swaps_applied(const TextureDisplacementLayer &layer) const; + // The whole of a ChromaStack card between its header and More settings: the picture and the print + // it predicts, the base, the filaments and the colours they reach, and the two actions. + void render_chroma_card(TextureDisplacementLayer &layer, float card_pad, float label_w, float wrap_w); + + // The surface drawn in the colours the stack will print, over whatever neutral surface is there: + // the live preview's displaced mesh before a bake, the volume's own mesh after one. Every triangle + // takes the palette colour of the printed layer its centroid falls in (chroma_level_at()), which + // is what the eye will see at that height - so the relief's terraces read as the picture, the walls + // between them show the bands they cut through, and looking straight down gives the blended colours + // the whole feature exists to produce. + // + // Nothing else in the application can show this: the colours are blends of translucent filaments + // that no single filament can make, so they cannot be expressed as the per-triangle filament + // assignment the 3D scene paints with. They exist only here and in the sliced preview. + void rebuild_chroma_colors(const TextureDisplacementLayer &layer); + bool render_chroma_colors(const TextureDisplacementLayer &layer); + GLModel m_chroma_color_model; + // The surface the overlay was built from. The mesh alone: the colours are not in it, so a change + // to the stack costs a palette upload rather than a mesh rebuild. + size_t m_chroma_color_key = 0; + // The stack's colours, one texel per printed layer, and what they were built from. A texture + // because a uniform array cannot hold a base of several hundred layers. + std::unique_ptr m_chroma_palette_tex; + size_t m_chroma_palette_key = 0; + bool m_show_chroma_colors = true; + + // What the last solve of each slot reported, for the panel. Not saved: it is a property of the + // solve, and a project is reopened with whatever height map the solve produced. + struct ChromaReport + { + bool valid = false; + double mean = 0.; + double max = 0.; + }; + std::array m_chroma_report{}; + // The predicted print, per slot: the palette colours the solve chose, as a panel thumbnail. The + // 3D view cannot show this - the colours do not exist until the print swaps filament - so it is + // the only place the user sees what the stack will actually produce. + std::array, TEXTURE_DISPLACEMENT_MAX_LAYERS> m_chroma_preview; + // The source picture's thumbnail, beside the prediction, keyed by the image it was built from. + std::array, TEXTURE_DISPLACEMENT_MAX_LAYERS> m_chroma_source_thumb; + std::array m_chroma_source_key{}; + // Per slot: the stack has been edited since the height map was solved, so the relief on screen is the + // previous stack's. Nothing else can show this - a stale height map looks like a perfectly good one. + std::array m_chroma_stale{}; + // Paint / Erase, from the panel. See get_left_button_state_type(). bool m_erase_mode = false; // Per slot: whether the layer's card shows every setting or only Depth, Tile size and Rotation. diff --git a/src/slic3r/GUI/Tab.cpp b/src/slic3r/GUI/Tab.cpp index 456bfb82fd..4c0947b052 100644 --- a/src/slic3r/GUI/Tab.cpp +++ b/src/slic3r/GUI/Tab.cpp @@ -4533,6 +4533,7 @@ void TabFilament::build() optgroup->append_single_option_line("filament_adhesiveness_category", "material_basic_information#adhesiveness-category"); optgroup->append_single_option_line("filament_density", "material_basic_information#density"); + optgroup->append_single_option_line("filament_transmission_distance", "material_basic_information#transmission-distance"); optgroup->append_single_option_line("filament_shrink", "material_basic_information#shrinkage-xy"); optgroup->append_single_option_line("filament_shrinkage_compensation_z", "material_basic_information#shrinkage-z"); optgroup->append_single_option_line("filament_cost", "material_basic_information#price");