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");