GLGizmoTextureDisplacement::filament_palette()
return palette;
}
+float GLGizmoTextureDisplacement::color_band_mm(const ModelVolume &mv)
+{
+ const float lh = print_layer_height();
+ const float edge = (mv.texture_displacement_options.v2_refine_mm > 0.f) ? mv.texture_displacement_options.v2_refine_mm
+ : v2_recommendation(mv).edge_mm;
+ if (edge <= 0.f || lh <= 0.f)
+ return lh;
+ // A refined triangle of edge e stacks in rows about 0.87 * e apart (an equilateral triangle's
+ // height), and a dither needs at least two rows per period to be a dither at all.
+ constexpr float ROW_PER_EDGE = 0.87f;
+ return lh * std::max(1.f, std::ceil(2.f * ROW_PER_EDGE * edge / lh));
+}
+
float GLGizmoTextureDisplacement::print_layer_height()
{
try {
@@ -3968,7 +4266,7 @@ ColorResolveFn GLGizmoTextureDisplacement::make_mix_resolver(const std::vector int {
+ return [entries, mode, band, cell](int index, const Vec3f &pos, const Vec3f &normal) -> int {
if (index < 0 || size_t(index) >= entries->size())
return -1;
const PaletteEntry &e = (*entries)[size_t(index)];
@@ -3977,7 +4275,15 @@ ColorResolveFn GLGizmoTextureDisplacement::make_mix_resolver(const std::vector
= e.den ? e.a : e.b;
+ const bool bands = mode == ColorMixMode::ZBands || mode == ColorMixMode::Auto;
+ if (bands) {
// One band per print layer. floorf, not a cast, so this stays correct below z = 0.
const int slot = int(std::floor(pos.z() / band));
const int phase = ((slot % e.den) + e.den) % e.den;
@@ -4018,15 +4324,25 @@ ColorQuantizeFn GLGizmoTextureDisplacement::make_palette_quantizer(const std::ve
float l0, a0, b0;
const Vec3f lab0 = srgb_to_lab(Vec3f((r + 0.5f) / E, (g + 0.5f) / E, (b + 0.5f) / E));
l0 = lab0.x(); a0 = lab0.y(); b0 = lab0.z();
- int best = 0;
- float best_d = std::numeric_limits::max();
+ int best = 0, best_pure = -1;
+ float best_d = std::numeric_limits::max(), best_pure_d = best_d;
for (size_t i = 0; i < palette_lab.size(); ++i) {
const float d = DeltaE00(l0, a0, b0, palette_lab[i].l, palette_lab[i].a, palette_lab[i].b);
if (d < best_d) {
best_d = d;
best = int(i);
}
+ if (!palette[i].is_mix() && d < best_pure_d) {
+ best_pure_d = d;
+ best_pure = int(i);
+ }
}
+ // A mix is an interleave that only reads as its colour from a distance; up close
+ // it is stripes. Spend it only where it buys a clearly better match than the nearest
+ // single filament: ten Delta E is a visible step, less is not worth the stripes.
+ constexpr float PREFER_PURE_DE = 10.f;
+ if (best_pure >= 0 && palette[size_t(best)].is_mix() && best_pure_d - best_d < PREFER_PURE_DE)
+ best = best_pure;
(*lut)[(size_t(r) * E + size_t(g)) * E + size_t(b)] = uint8_t(best);
}
});
@@ -4929,16 +5245,25 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
const float approx_height = m_imgui->scaled(24.f);
y = std::min(y, bottom_limit - approx_height);
- // Docked (the default) the panel is pinned next to the gizmo toolbar and cannot be moved, like
- // every other gizmo's. Undocked it becomes an ordinary floating window: a title bar to drag it
- // by, and no forced position - this panel is tall enough (layer stack, per-layer controls) that
- // it can cover the very part of the model being painted, and being able to shove it aside is the
- // point. The position is deliberately *not* seeded on undock, so the window stays exactly where
- // it already was and the user just gains the ability to move it from there.
+ // Docked (the default) the panel is pinned to the right edge of the 3D canvas and cannot be
+ // moved. Deliberately *not* next to the gizmo toolbar, which is where `x` points and where every
+ // other gizmo's window goes: this panel is far taller than those (layer stack plus the whole
+ // per-layer control set), so at the toolbar it sits right on top of the part of the model being
+ // painted. Pinning it to the canvas edge also parks it against the UV editor, since that pane is
+ // docked on the right and the canvas therefore ends exactly at the pane's left edge - so the
+ // panel follows the pane in and out instead of being clipped by it.
+ //
+ // Undocked it becomes an ordinary floating window: a title bar to drag it by, and no forced
+ // position - the position is deliberately not seeded on undock, so the window stays exactly
+ // where it already was and the user just gains the ability to move it from there.
ImGuiWindowFlags flags = ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoCollapse;
if (!m_undocked) {
flags |= ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoTitleBar;
- GizmoImguiSetNextWIndowPos(x, y, ImGuiCond_Always, 1.0f, 0.0f);
+ // Right-aligned (pivot 1), so the width the panel auto-resized to last frame does not need to
+ // be known here. Width 0 skips GizmoImguiSetNextWIndowPos()'s own left-aligned fit-to-canvas
+ // clamp, which would push the window back off the edge it is being pinned to.
+ float right = float(m_parent.get_canvas_size().get_width()) - m_imgui->scaled(0.5f);
+ GizmoImguiSetNextWIndowPos(right, y, 0.f, 0.f, ImGuiCond_Always, 1.0f, 0.0f);
}
ImGuiWrapper::push_toolbar_style(m_parent.get_scale());
@@ -5251,7 +5576,8 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
m_erase_mode = true;
}
- // ---- Tools: brush / face / connected area, and the active tool's own control ----
+ // ---- Tools: brush / face / connected area on the left, the whole-model actions on the right, and the
+ // active tool's own control on the line below ----
// "Face" and "Connected area" reuse the exact same selection machinery every other paint gizmo has
// (single-facet click, and angle-limited flood fill respectively).
{
@@ -5276,7 +5602,36 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
m_tool_type = ToolType::SMART_FILL;
m_cursor_type = TriangleSelector::CursorType::POINTER;
}
+
+ // Whole model: paint every face with the active layer, or clear its paint from all of them. Actions
+ // rather than tools, so they sit apart at the right end of the row.
+ const wxString whole_na = busy ? _L("Wait for the bake to finish.") :
+ active == nullptr ? _L("Add a layer first.") :
+ wxString();
+ const wxString erase_na = !whole_na.empty() ? whole_na :
+ !slot_painted(m_active_layer_slot) ? _L("The active layer has no paint yet.") :
+ wxString();
ImGui::SameLine();
+ ImGui::SetCursorPosX(std::max(ImGui::GetCursorPosX(), ImGui::GetWindowContentRegionMax().x - (2.f * icon_md + gap_s)));
+ if (icon_toggle(806, "texture_displacement_select_all.svg", false, icon_md, _L("Select whole model"),
+ _L("Select whole model - paint every face of the model with the active layer"), whole_na))
+ select_whole_model();
+ ImGui::SameLine(0.f, gap_s);
+ if (icon_toggle(807, "texture_displacement_erase_all.svg", false, icon_md, _L("Erase whole model"),
+ _L("Erase whole model - clear the active layer's paint from every face"), erase_na)) {
+ Plater::TakeSnapshot snapshot(wxGetApp().plater(), _u8L("Reset texture displacement selection"),
+ UndoRedo::SnapshotType::GizmoAction);
+ int idx = -1;
+ for (ModelVolume *v : mo->volumes)
+ if (v->is_model_part()) {
+ ++idx;
+ m_triangle_selectors[idx]->reset();
+ m_triangle_selectors[idx]->request_update_render_data();
+ }
+ update_model_object();
+ m_parent.set_as_dirty();
+ }
+
if (is_brush_mode) {
ImGui::SetNextItemWidth(-(3.f * gap_s + 1.f + 2.f * icon_sm));
ImGui::SliderFloat("##cursor_radius", &m_cursor_radius, CursorRadiusMin, CursorRadiusMax, "%.2f mm",
@@ -5302,33 +5657,6 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
}
}
- // ---- Whole model ----
- {
- const float half = std::floor((ImGui::GetContentRegionAvail().x - style.ItemSpacing.x) * 0.5f);
- m_imgui->disabled_begin(busy || active == nullptr);
- if (ImGui::Button(_u8L("Select whole model").c_str(), ImVec2(half, 0.f)))
- select_whole_model();
- m_imgui->disabled_end();
- hover_tip(_u8L("Paint every face of the model with the active layer"));
- ImGui::SameLine();
- m_imgui->disabled_begin(busy || active == nullptr || !slot_painted(m_active_layer_slot));
- if (ImGui::Button(_u8L("Erase whole model").c_str(), ImVec2(half, 0.f))) {
- Plater::TakeSnapshot snapshot(wxGetApp().plater(), _u8L("Reset texture displacement selection"),
- UndoRedo::SnapshotType::GizmoAction);
- int idx = -1;
- for (ModelVolume *v : mo->volumes)
- if (v->is_model_part()) {
- ++idx;
- m_triangle_selectors[idx]->reset();
- m_triangle_selectors[idx]->request_update_render_data();
- }
- update_model_object();
- m_parent.set_as_dirty();
- }
- m_imgui->disabled_end();
- hover_tip(_u8L("Clear the active layer's paint from every face"));
- }
-
// ---- View: Normal / Fast / Checker / Distortion as one group, Wireframe on its own ----
// The underlying state stays m_use_bump_preview + m_uv_check_mode.
{
@@ -5651,13 +5979,15 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
if (ImGui::Checkbox(_u8L("Mix filaments").c_str(), &opts.color_mix_enabled))
m_preview_params_dirty = true;
hover_tip(_u8L("Interleave pairs of filaments to reach colours between them, so a few "
- "filaments cover far more than a few colours. Off means every triangle "
- "prints in one of the filaments exactly."));
+ "filaments cover far more than a few colours. Used only on images with "
+ "continuous colour (photographs, gradients); a texture of flat colours "
+ "prints in single filaments either way. Off forces single filaments."));
if (opts.color_mix_enabled) {
slider_label(_L("Mix by"));
const std::string mix_z = _u8L("Layers");
const std::string mix_xy = _u8L("Surface");
- const char *mix_items[] = { mix_z.c_str(), mix_xy.c_str() };
+ const std::string mix_auto = _u8L("Automatic");
+ const char *mix_items[] = { mix_z.c_str(), mix_xy.c_str(), mix_auto.c_str() };
int mix_mode = int(opts.color_mix_mode);
ImGui::SetNextItemWidth(-card_pad);
if (scoped_combo("##color_mix_mode", &mix_mode, mix_items, IM_ARRAYSIZE(mix_items))) {
@@ -5668,7 +5998,9 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
"blends smoothly on upright surfaces but disappears on flat-facing "
"ones, where a whole layer is a single band.\n"
"Surface: a fine checkerboard across the surface, which works at "
- "any angle but can read as texture rather than as a blend."));
+ "any angle but can read as texture rather than as a blend.\n"
+ "Automatic: layers on upright faces; flat-facing faces take the nearer "
+ "single filament, since a checkerboard there shows as a pattern."));
ImGui::TextDisabled("%s", Slic3r::format(_u8L("%1% printable colours from %2% filaments"),
int(cached_palette().size()), int(m_palette_filaments.size())).c_str());
}
@@ -6409,9 +6741,8 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
m_parent.set_as_dirty();
}
m_imgui->disabled_end();
- hover_tip(_u8L("Auto: the resolution and the budget are chosen from the texture (its pixel "
- "size on the model and how sharp it is) and the model's size, the way "
- "BumpMesh's smart resolution does. Untick to set them by hand."));
+ hover_tip(_u8L("Auto: the resolution follows the model's size and the budget is the standard "
+ "750 k, the same defaults as bumpmesh.com. Untick to set them by hand."));
ImGui::SameLine();
ImGui::SetNextItemWidth(x0 + panel_w - ImGui::GetCursorPosX());
float shown = auto_res ? rec.edge_mm : opts.v2_refine_mm;
@@ -6424,10 +6755,7 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
}
m_imgui->disabled_end();
if (auto_res && rec.edge_mm > 0.f)
- hover_tip(Slic3r::format(_u8L("%1% texture pixels per edge x %2% mm per pixel%3%. Budget %4% k."),
- rec.pixels_per_edge, Slic3r::format("%.3f", rec.texel_mm),
- rec.budget_bound ? _u8L(", held back by the triangle cap") : std::string(),
- rec.budget_k));
+ hover_tip(Slic3r::format(_u8L("The model's diagonal / 250, as bumpmesh.com sets it. Budget %1% k."), rec.budget_k));
else
hover_tip(_u8L("Triangle edge length the painted area is refined to before displacement. "
"Smaller carries finer texture detail and costs more triangles; the budget "
@@ -6487,7 +6815,10 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
ImGui::PopStyleColor(5);
if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled))
m_imgui->tooltip(mv != nullptr && !mv->is_texture_displacement_painted() ?
- _u8L("Nothing is painted yet.") :
+ (m_seam_edit_mode ? _u8L("Nothing is painted yet. The UV editor's seam tool is on, so "
+ "strokes on the model mark seams instead of painting - turn "
+ "it off in the pane to paint.") :
+ _u8L("Nothing is painted yet.")) :
pro_mode() ?
_u8L("Turn the painted height maps into real geometry, by moving the vertices that are "
"already there. Use Subdivide first if the mesh is too coarse to show the detail.") :
diff --git a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp
index 8cbfa9c84e..7ec7428c20 100644
--- a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp
+++ b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp
@@ -104,6 +104,14 @@ public:
// The print's layer height, which sizes ColorMixMode::ZBands. Falls back to 0.2 mm if it cannot be
// read - a wrong band size is a cosmetic error, not a reason to refuse to colour anything.
static float print_layer_height();
+ // The Z band height, in mm. One print layer is the ideal, but the interleave is realised per
+ // *facet*: a band thinner than the mesh can resolve does not dither, it beats against the triangle
+ // grid and comes out as broad horizontal stripes - and since MMU segmentation reads facet colour,
+ // it does so in the print too, not only on screen. The refinement edge is chosen from the model's
+ // diagonal and knows nothing about the layer height, so the band is rounded up to a whole number of
+ // layers at least two facet rows tall: still exact on the printer, and representable by the mesh
+ // that has to carry it. Used by both the bake settings and the preview shader, so the two agree.
+ float color_band_mm(const ModelVolume &mv);
// The Normal preview's triangles, grouped by the filament they will print in. Colour is per facet
// and there are at most sixteen filaments, so the mesh is uploaded once with its index buffer
@@ -480,6 +488,17 @@ private:
// painting into a slot with no texture assigned is harmless, it just has no visible/bake
// effect until a texture is added to that slot.
int m_active_layer_slot = 0;
+ // Set when m_triangle_selectors could not be loaded from the stored paint masks, because a mask
+ // was recorded against a different topology: TriangleSelector::deserialize() rejects that and
+ // returns without a word, leaving the selector empty even though the mask is not. While this is
+ // set, an *empty* selector says nothing about the paint, so update_model_object() must not flush
+ // one back - serializing it over the mask destroys the user's paint for good, and the bake then
+ // reports "nothing is painted" about the data the flush had just deleted.
+ //
+ // Deliberately not a blanket refusal to flush: once the user paints, the selector holds real
+ // content again and writing it back is exactly right - it replaces the unusable mask with one
+ // recorded against the current mesh. So only the empty-over-non-empty case is held back.
+ bool m_selectors_stale = false;
bool m_bake_in_progress = false;
// When set, the true-displacement geometry is rebuilt on every parameter change (live), instead of
@@ -636,11 +655,22 @@ private:
// patch only), translucent (the preview stays visible through it) and rebuilt live during a
// stroke.
GLModel m_paint_overlay_glmodel;
+ // The islands selected in the UV editor, tinted on the model so the pane's selection can be seen
+ // in place. Rebuilt whenever the pane's selection differs from the one it was built for.
+ GLModel m_island_overlay_glmodel;
+ std::vector m_island_overlay_selection;
+ void rebuild_island_overlay(const std::vector &selection);
+ void render_island_overlay();
// Set on every paint event, cleared when the overlay is rebuilt in render_painter_gizmo(). Kept
// separate from m_bump_preview_dirty so a stroke refreshes only the small painted patch per frame,
bool m_paint_overlay_dirty = false;
void rebuild_paint_overlay();
- void render_paint_overlay();
+ void render_paint_overlay(GLModel &overlay);
+ // Every *other* layer's paint, muted, so all layers stay visible while one is edited. Rebuilt only when that
+ // paint, the active layer or the preview it is lifted onto changes (m_other_paint_key).
+ GLModel m_other_paint_glmodel;
+ std::string m_other_paint_key;
+ void rebuild_other_paint_overlay();
// Whether render_bump_preview_mesh() would actually draw something. Checked before the real volume
// is hidden: with no layer, no texture or no shader the bump path draws nothing, and hiding the
// volume for it left the model invisible.
@@ -648,6 +678,12 @@ private:
// Whether the current bump mesh carries a precomputed per-vertex uv (LSCM) that the shader
// should sample at directly, rather than projecting in-shader. Set by rebuild_bump_preview_mesh().
bool m_bump_preview_uses_vertex_uv = false;
+ // The projection frame handed to the bump shader, captured when the mesh is built. Cylindrical and
+ // Spherical are reconstructed in the fragment shader (there is no per-vertex uv for them) and wrap
+ // around the whole patch, which no fragment can work out for itself. See layer_projection_frame().
+ int m_bump_projection_mode = 0;
+ Vec3f m_bump_patch_center = Vec3f::Zero();
+ Vec3f m_bump_patch_axis = Vec3f::UnitZ();
// The palette the fast preview's per-triangle filament indices were built against, captured when
// the mesh was. Empty when the active layer is not colouring, which is what tells the shader to
// fall back to the model's own colour. Held rather than re-read at draw time so the indices baked
@@ -659,14 +695,23 @@ private:
// the dragged island's vertices flagged, v_normal.y = 1) and then moved purely through the shader's
// island_delta uniform - one uniform update per mouse move, no rebuild - so it tracks the cursor
// as smoothly as Adjust placement. m_bump_active_chart is the dragged island (or -1);
- // m_bump_active_vertex flags its base vertices; m_bump_baked_active_xf is that island's placement
- // baked into the current mesh, against which the live delta is measured; m_bump_island_delta is the
- // resulting final-uv-space affine handed to the shader (identity except mid-drag).
+ // m_bump_active_face flags the dragged islands' *triangles*, indexed by painted-patch face;
+ // m_bump_baked_active_xf is that island's placement baked into the current mesh, against which the
+ // live delta is measured; m_bump_island_delta is the resulting final-uv-space affine handed to the
+ // shader (identity except mid-drag).
+ //
+ // Per triangle rather than per vertex deliberately: a seam vertex belongs to every chart touching
+ // it, so flagging the dragged chart's base vertices also flagged the corners its neighbours use.
+ // island_active is an interpolated varying, so those neighbouring triangles then had island_delta
+ // applied too - dragging one island moved every adjacent island's texture while the editor, which
+ // is per chart, correctly moved only the one. A triangle belongs to exactly one chart.
int m_bump_active_chart = -1;
- std::vector m_bump_active_vertex;
+ std::vector m_bump_active_face;
Eigen::Matrix m_bump_baked_active_xf = Eigen::Matrix::Identity();
Eigen::Matrix m_bump_island_delta = Eigen::Matrix::Identity();
- void compute_bump_active_vertices(const std::vector &charts);
+ // Flags `charts`' triangles in m_bump_active_face, sized to `patch_face_count` (the painted patch
+ // the bump mesh is being built from). Cleared if the unwrap carries no face map.
+ void compute_bump_active_faces(const std::vector &charts, size_t patch_face_count);
// The set of islands the current UV-editor drag moves together: the pane's multi-selection unioned
// with each selected island's join group (see build_island_move_set()). Populated at drag start and
@@ -687,6 +732,19 @@ private:
// the shader projects on its own. Shared by the bump preview and the UV-check overlay.
std::vector compute_layer_vertex_uvs(const indexed_triangle_set &patch,
const TextureDisplacementLayer &layer) const;
+ // The same, but three UVs per patch triangle (corner 0..2 of triangle i at 3i..3i+2). This is what
+ // the flat, unshared-vertex preview meshes actually want: under LSCM a seam vertex has a different
+ // UV in each island it borders, so collapsing to one per vertex handed a triangle at an unjoined
+ // seam its neighbour's placement - one visibly skewed triangle per face. Every other projection is
+ // single-valued per point, so there a corner's UV is just its vertex's.
+ std::vector compute_layer_corner_uvs(const indexed_triangle_set &patch,
+ const TextureDisplacementLayer &layer) const;
+ // The in-shader projection for `layer` (0 Triplanar, 1 Cylindrical, 2 Spherical) plus, for the two
+ // wrapping ones, the patch centroid and cylinder axis they wrap around - in the texture frame the
+ // shaders project in. Taken from the bake's own texture_displacement_patch_frame(), so a preview
+ // can never wrap around a different centre, or pick a different axis, than the bake will.
+ int layer_projection_frame(const indexed_triangle_set &local_patch, const TextureDisplacementLayer &layer,
+ Vec3f ¢er, Vec3f &axis) const;
// `patch` with its vertices moved into world millimetres - the space the bake maps the texture in
// (see build_texture_displacement()). Returned by value because the caller usually still needs the
// original: the patch doubles as render geometry, which is drawn through the volume's own matrix.
@@ -699,6 +757,10 @@ private:
UVCheckMode m_uv_check_mode = UVCheckMode::None;
GLModel m_uvcheck_glmodel;
bool m_uvcheck_uses_vertex_uv = false;
+ // As m_bump_projection_mode and friends, for the Checker overlay.
+ int m_uvcheck_projection_mode = 0;
+ Vec3f m_uvcheck_patch_center = Vec3f::Zero();
+ Vec3f m_uvcheck_patch_axis = Vec3f::UnitZ();
void rebuild_uvcheck_mesh();
void render_uvcheck_mesh();
diff --git a/src/slic3r/GUI/Jobs/TextureDisplacementBakeJob.cpp b/src/slic3r/GUI/Jobs/TextureDisplacementBakeJob.cpp
index 05df414ca5..f48ec6b28c 100644
--- a/src/slic3r/GUI/Jobs/TextureDisplacementBakeJob.cpp
+++ b/src/slic3r/GUI/Jobs/TextureDisplacementBakeJob.cpp
@@ -38,6 +38,8 @@ void TextureDisplacementBakeJob::process(Ctl &ctl)
TextureColorRequest *color = nullptr;
if (!m_input.color.empty()) {
color_request.quantize = GLGizmoTextureDisplacement::make_palette_quantizer(m_input.color.palette);
+ if (!m_input.color.palette_pure.empty())
+ color_request.quantize_pure = GLGizmoTextureDisplacement::make_palette_quantizer(m_input.color.palette_pure);
color_request.resolve = GLGizmoTextureDisplacement::make_mix_resolver(
m_input.color.palette, m_input.color.mix_mode, m_input.color.layer_height,
m_input.color.dither_cell_mm);
@@ -79,6 +81,22 @@ void TextureDisplacementBakeJob::finalize(bool canceled, std::exception_ptr &ept
if (canceled || eptr || m_result.empty())
return;
+ // A bake that moved nothing - no layer could be sampled, or every sample was zero - must not be
+ // committed: committing is what clears the baked layers' paint, so the user would see the painted
+ // region simply vanish with no relief in its place and no idea why. Keep the paint and say so.
+ {
+ const indexed_triangle_set &out = m_result.its;
+ bool unchanged = out.indices.size() == m_input.base_mesh.indices.size() &&
+ out.vertices.size() == m_input.base_mesh.vertices.size();
+ for (size_t i = 0; unchanged && i < out.vertices.size(); ++i)
+ unchanged = (out.vertices[i] - m_input.base_mesh.vertices[i]).cwiseAbs().maxCoeff() < 1e-5f;
+ if (unchanged) {
+ show_error(nullptr, _u8L("The bake produced no displacement, so nothing was changed and the paint was kept. "
+ "Check that the painted layer has a texture and a non-zero depth."));
+ return;
+ }
+ }
+
Plater *plater = wxGetApp().plater();
const auto commit = [this, plater]() {
@@ -109,11 +127,21 @@ void TextureDisplacementBakeJob::finalize(bool canceled, std::exception_ptr &ept
// Clear the paint mask of every layer that was actually baked so a repeat bake (or the paint
// overlay) doesn't act on triangles that no longer represent the same unbaked surface. The
- // texture layer definitions themselves (and paint outside the baked area, if any) are left
- // untouched so the user can keep sculpting with the same textures.
- for (const TextureDisplacementLayer &layer : m_input.layers)
- if (!layer.empty() && layer.slot >= 0 && layer.slot < int(TEXTURE_DISPLACEMENT_MAX_LAYERS))
- volume->texture_displacement_facet(layer.slot).reset();
+ // texture layer definitions themselves are left untouched so the user can keep sculpting with
+ // the same textures.
+ //
+ // A mask the bake did *not* consume only still means what it did if the topology is unchanged,
+ // which is true of the classic path (it moves existing vertices) but not of the one-run
+ // pipeline, which rebuilds and then simplifies the mesh. A mask left behind against the old
+ // topology is exactly what makes the gizmo reload an empty selector over a non-empty mask and
+ // then erase it on the next flush - see GLGizmoTextureDisplacement::update_from_model_object().
+ const bool topology_changed = m_input.base_mesh.indices.size() != volume->mesh().its.indices.size();
+ for (int slot = 0; slot < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++slot) {
+ const auto it = std::find_if(m_input.layers.begin(), m_input.layers.end(),
+ [slot](const TextureDisplacementLayer &l) { return l.slot == slot; });
+ if (topology_changed || (it != m_input.layers.end() && !it->empty()))
+ volume->texture_displacement_facet(slot).reset();
+ }
ModelObject *object = volume->get_object();
if (object == nullptr)
diff --git a/src/slic3r/GUI/Jobs/TextureDisplacementPreviewJob.cpp b/src/slic3r/GUI/Jobs/TextureDisplacementPreviewJob.cpp
index 013ea79750..1845314f0d 100644
--- a/src/slic3r/GUI/Jobs/TextureDisplacementPreviewJob.cpp
+++ b/src/slic3r/GUI/Jobs/TextureDisplacementPreviewJob.cpp
@@ -24,6 +24,8 @@ void TextureDisplacementPreviewJob::process(Ctl &ctl)
TextureColorRequest *color = nullptr;
if (!m_input.color.empty()) {
color_request.quantize = GLGizmoTextureDisplacement::make_palette_quantizer(m_input.color.palette);
+ if (!m_input.color.palette_pure.empty())
+ color_request.quantize_pure = GLGizmoTextureDisplacement::make_palette_quantizer(m_input.color.palette_pure);
color_request.resolve = GLGizmoTextureDisplacement::make_mix_resolver(
m_input.color.palette, m_input.color.mix_mode, m_input.color.layer_height,
m_input.color.dither_cell_mm);
diff --git a/tests/libslic3r/test_texture_displacement.cpp b/tests/libslic3r/test_texture_displacement.cpp
index e2f0147efb..3b9f0dac25 100644
--- a/tests/libslic3r/test_texture_displacement.cpp
+++ b/tests/libslic3r/test_texture_displacement.cpp
@@ -1313,54 +1313,42 @@ TEST_CASE("TextureDisplacement: edge flips lay a stepped field's wall along the
// Automatic resolution (v2 pipeline)
// ---------------------------------------------------------------------------------------------
-TEST_CASE("TextureDisplacement: automatic resolution follows the texture's texel size and sharpness", "[TextureDisplacement]")
+TEST_CASE("TextureDisplacement: automatic resolution follows the model's size like bumpmesh.com", "[TextureDisplacement]")
{
- // A 20 mm cube (diagonal 34.6 mm, so the edge may go up to 0.69 mm) with an 8 mm tile.
+ // A 20 mm cube: diagonal 34.64 mm, so diagonal / 250 = 0.1386 mm, rounded up to 0.14.
const indexed_triangle_set cube = its_make_cube(20.f, 20.f, 20.f);
+ TextureDisplacementLayer layer;
+ layer.image_data = make_checkerboard_png(16, 16);
+ layer.tiling_scale = 8.f;
- SECTION("a hard-edged texture gets one texel per edge")
+ SECTION("edge from the diagonal, budget the standard 750 k")
{
- TextureDisplacementLayer layer;
- layer.image_data = make_checkerboard_png(16, 16); // 2x2 texel checks: every other texel is a step
- layer.tiling_scale = 8.f; // texel = 0.5 mm
- const TextureDetail detail = analyze_texture_detail(layer);
- CHECK(detail.sharp_fraction > 0.15f);
- CHECK_THAT(detail.pixels_per_edge, WithinAbs(1.f, 1e-6f));
const V2Resolution rec = recommend_v2_resolution(cube, { layer });
- CHECK_THAT(rec.texel_mm, WithinAbs(0.5f, 1e-4f));
- CHECK_THAT(rec.edge_mm, WithinAbs(0.5f, 1e-4f));
- CHECK(rec.budget_k >= 10);
- CHECK(rec.budget_k <= 2000);
+ CHECK_THAT(rec.edge_mm, WithinAbs(0.14f, 1e-4f));
+ CHECK(rec.budget_k == 750);
+ CHECK_THAT(rec.texel_mm, WithinAbs(0.5f, 1e-4f)); // reported for the panel
}
- SECTION("a flat texture gets four texels per edge, within the model's clamp")
+ SECTION("the world transform scales the diagonal")
{
- TextureDisplacementLayer layer;
- layer.image_data = make_flat_gray_png(128, 16, 16);
- layer.tiling_scale = 8.f; // texel 0.5 mm x 4 = 2 mm, clamped to diagonal / 50
- const TextureDetail detail = analyze_texture_detail(layer);
- CHECK_THAT(detail.pixels_per_edge, WithinAbs(4.f, 1e-6f));
- const V2Resolution rec = recommend_v2_resolution(cube, { layer });
- CHECK_THAT(rec.edge_mm, WithinAbs(0.70f, 0.011f)); // ceil(34.64 / 50 = 0.693) at 0.01
- }
-
- SECTION("the world transform scales the tile against the model")
- {
- TextureDisplacementLayer layer;
- layer.image_data = make_checkerboard_png(16, 16);
- layer.tiling_scale = 8.f;
- // Scaled up 3x the cube is 60 mm; the texel is still 0.5 mm in world terms, so the edge holds.
const V2Resolution rec = recommend_v2_resolution(cube, { layer }, Transform3d(Eigen::Scaling(3.0)));
- CHECK_THAT(rec.edge_mm, WithinAbs(0.5f, 1e-4f));
- const V2Resolution plain = recommend_v2_resolution(cube, { layer });
- CHECK(rec.budget_k > plain.budget_k); // nine times the area wants more triangles
+ CHECK_THAT(rec.edge_mm, WithinAbs(0.42f, 1e-4f)); // 103.9 / 250 = 0.4157 -> 0.42
}
- SECTION("no usable texture gives no recommendation")
+ SECTION("a tiny model stops at the 0.05 mm floor")
{
- TextureDisplacementLayer empty;
- const V2Resolution rec = recommend_v2_resolution(cube, { empty });
- CHECK(rec.edge_mm == 0.f);
+ const indexed_triangle_set small = its_make_cube(2.f, 2.f, 2.f);
+ const V2Resolution rec = recommend_v2_resolution(small, { layer });
+ CHECK_THAT(rec.edge_mm, WithinAbs(0.05f, 1e-4f));
+ }
+
+ SECTION("the texture's sharpness class is still measured")
+ {
+ const TextureDetail sharp = analyze_texture_detail(layer);
+ CHECK_THAT(sharp.pixels_per_edge, WithinAbs(1.f, 1e-6f));
+ TextureDisplacementLayer flat;
+ flat.image_data = make_flat_gray_png(128, 16, 16);
+ CHECK_THAT(analyze_texture_detail(flat).pixels_per_edge, WithinAbs(4.f, 1e-6f));
}
}
@@ -1464,6 +1452,122 @@ static std::vector unwrap_charts_are_disks(const PatchUnwrap &u)
return disks;
}
+TEST_CASE("TextureDisplacement: the default pipeline bakes an unwrap (LSCM) layer", "[TextureDisplacement]")
+{
+ // The one-run pipeline samples per point, and an unwrap has no per-point formula, so unwrap layers
+ // used to be dropped from it altogether: the bake moved nothing, and the job then cleared the paint
+ // as if it had baked - "paint, unwrap, bake, and the painted region just disappears".
+ const indexed_triangle_set cube = its_make_cube(10., 10., 10.);
+ TextureDisplacementLayer layer;
+ layer.slot = 0;
+ layer.image_data = make_flat_gray_png(255);
+ layer.depth_mm = 0.5f;
+ layer.tiling_scale = 10.f;
+ layer.projection_method = TextureProjectionMethod::LSCM;
+
+ TextureDisplacementFacetsData facets;
+ facets[0] = paint_whole_mesh(cube);
+
+ TextureDisplacementOptions options;
+ options.pipeline_v2 = true;
+ options.v2_refine_mm = 2.f;
+ options.v2_max_triangles_k = 0;
+ const indexed_triangle_set out = build_texture_displacement(cube, { layer }, facets, options);
+ REQUIRE(!out.vertices.empty());
+
+ // A flat white texture at depth 0.5 pushes the faces out by 0.5, so the bounding box grows on
+ // every side (face interiors move the full depth; only corners, moving along their blended normal,
+ // move less). Before the fix it stayed exactly [0, 10].
+ Vec3f lo = out.vertices.front(), hi = lo;
+ for (const Vec3f &v : out.vertices) {
+ lo = lo.cwiseMin(v);
+ hi = hi.cwiseMax(v);
+ }
+ CHECK(lo.x() < -0.4f);
+ CHECK(hi.z() > 10.4f);
+}
+
+TEST_CASE("Per-corner LSCM UVs give each triangle its own island's placement", "[TextureDisplacement]")
+{
+ // compute_lscm_uvs() has to collapse a seam vertex onto one chart, because displacement is
+ // per vertex. Everything that samples per *triangle* must not: a cube corner belongs to three
+ // faces, so the collapse handed a triangle at an unjoined seam a neighbouring island's placement.
+ // That showed up as one visibly skewed triangle per face, and as every island's texture following
+ // the lowest-numbered island whenever it was dragged.
+ const indexed_triangle_set cube = its_make_cube(10., 10., 10.);
+ const PatchUnwrap unwrap = compute_patch_unwrap(cube, 30.f, 0.f);
+ REQUIRE(unwrap.chart_count == 6);
+ // The map back to the patch's own triangle order, without which there are no per-corner UVs.
+ REQUIRE(unwrap.source_face.size() == unwrap.indices.size());
+
+ TextureDisplacementLayer layer;
+ layer.projection_method = TextureProjectionMethod::LSCM;
+ layer.lscm_seam_angle_deg = 30.f;
+ layer.islands = compute_connected_net(unwrap);
+ REQUIRE(layer.islands.size() == 6);
+ // Move one island by hand. Its neighbours must stay exactly where they were.
+ layer.islands[0].offset += Vec2f(37.f, -19.f);
+
+ const std::vector corner = compute_lscm_corner_uvs(cube, layer);
+ REQUIRE(corner.size() == cube.indices.size() * 3);
+
+ for (size_t t = 0; t < unwrap.indices.size(); ++t) {
+ const size_t f = size_t(unwrap.source_face[t]);
+ REQUIRE(f < cube.indices.size());
+ // A triangle lies in exactly one chart, so any of its corners names that chart.
+ const int c = unwrap.vertex_chart[size_t(unwrap.indices[t][0])];
+ for (int k = 0; k < 3; ++k) {
+ const Vec2f want = apply_island_transform(unwrap.uvs[size_t(unwrap.indices[t][k])], c, unwrap, layer.islands);
+ CHECK_THAT(corner[f * 3 + size_t(k)].x(), WithinAbs(want.x(), 1e-4));
+ CHECK_THAT(corner[f * 3 + size_t(k)].y(), WithinAbs(want.y(), 1e-4));
+ }
+ }
+
+ // And the per-vertex path must disagree somewhere - otherwise this test proves nothing, because
+ // the bug it guards against is precisely that the two were the same thing.
+ const std::vector per_vertex = compute_lscm_uvs(cube, layer);
+ REQUIRE(per_vertex.size() == cube.vertices.size());
+ bool differs = false;
+ for (size_t f = 0; f < cube.indices.size() && !differs; ++f)
+ for (int k = 0; k < 3; ++k)
+ if ((corner[f * 3 + size_t(k)] - per_vertex[size_t(cube.indices[f][k])]).norm() > 1e-3f)
+ differs = true;
+ CHECK(differs);
+}
+
+TEST_CASE("The Cylindrical/Spherical patch frame is the bake's own, so a preview can share it", "[TextureDisplacement]")
+{
+ // The fast preview reconstructs these two projections in the fragment shader and needs the very
+ // centroid and axis the bake wraps around - a patch-only normal average would sometimes quantize
+ // to a different world axis and wrap the texture the other way round.
+ const indexed_triangle_set cube = its_make_cube(10., 10., 10.);
+ const std::vector normals = texture_displacement_vertex_normals(cube);
+ REQUIRE(normals.size() == cube.vertices.size());
+
+ // Just the +X face: its average normal is +X, so the cylinder axis must be a world axis
+ // perpendicular to it, and the centroid must sit on that face.
+ indexed_triangle_set face;
+ face.vertices = cube.vertices;
+ for (const stl_triangle_vertex_indices &t : cube.indices) {
+ const Vec3f n = (cube.vertices[size_t(t[1])] - cube.vertices[size_t(t[0])])
+ .cross(cube.vertices[size_t(t[2])] - cube.vertices[size_t(t[0])]);
+ if (n.normalized().x() > 0.99f)
+ face.indices.push_back(t);
+ }
+ REQUIRE(face.indices.size() == 2);
+
+ Vec3f center, axis, average_normal;
+ texture_displacement_patch_frame(face, normals, center, axis, average_normal);
+ CHECK_THAT(center.x(), WithinAbs(10., 1e-4));
+ CHECK_THAT(std::abs(axis.x()), WithinAbs(0., 1e-4)); // never the face's own normal direction
+ CHECK_THAT(axis.norm(), WithinAbs(1., 1e-4));
+
+ // An empty patch must not divide by zero; it falls back to +Z.
+ texture_displacement_patch_frame(indexed_triangle_set{}, normals, center, axis, average_normal);
+ CHECK_THAT(center.norm(), WithinAbs(0., 1e-6));
+ CHECK_THAT(axis.z(), WithinAbs(1., 1e-6));
+}
+
TEST_CASE("A cube unwraps into one island per face, laid out as a connected net", "[TextureDisplacement]")
{
const indexed_triangle_set cube = its_make_cube(10., 10., 10.);
@@ -1595,3 +1699,267 @@ TEST_CASE("TextureDisplacement: moving the model about the plate does not move t
max_diff = std::max(max_diff, (moved.vertices[i] - at_origin.vertices[i]).norm());
CHECK(max_diff < 1e-3f);
}
+
+// Longest edge over the shortest altitude: 1.15 for an equilateral triangle, 2 for a right isosceles
+// one, unbounded for a needle.
+static float triangle_aspect(const Vec3f &a, const Vec3f &b, const Vec3f &c)
+{
+ const float longest = std::max({ (b - a).norm(), (c - b).norm(), (a - c).norm() });
+ const float twice_area = (b - a).cross(c - a).norm();
+ return twice_area > 0.f ? longest * longest / twice_area : std::numeric_limits::infinity();
+}
+
+TEST_CASE("TextureDisplacement: baking a second face leaves the first face's relief untouched", "[TextureDisplacement]")
+{
+ // Paint the top of a cube and bake, then paint the front of the *result* and bake again, the way
+ // the gizmo does (each bake replaces the mesh and clears the baked paint). The top is unpainted the
+ // second time round, so it is excluded from refinement and pinned by the displacement: every
+ // vertex of its relief must still be there, in the same number of triangles, and no needle may
+ // appear on it.
+ const indexed_triangle_set cube = subdivide_mesh_uniform(its_make_cube(20., 20., 20.), 2.f, 6);
+
+ TextureDisplacementLayer layer;
+ layer.slot = 0;
+ layer.image_data = make_checkerboard_png(16, 16);
+ layer.tiling_scale = 5.f;
+ layer.depth_mm = 0.5f;
+
+ TextureDisplacementOptions options;
+ options.pipeline_v2 = true;
+ options.v2_refine_mm = 0.5f;
+ options.v2_max_triangles_k = 0; // no simplification
+
+ // Paints exactly the triangles whose three corners satisfy `on_face` - no brush spill.
+ const auto paint_where = [](const indexed_triangle_set &mesh, auto on_face) {
+ const TriangleMesh tm(mesh);
+ TriangleSelector selector(tm);
+ for (size_t f = 0; f < mesh.indices.size(); ++f) {
+ const stl_triangle_vertex_indices &t = mesh.indices[f];
+ if (on_face(mesh.vertices[size_t(t[0])]) && on_face(mesh.vertices[size_t(t[1])]) &&
+ on_face(mesh.vertices[size_t(t[2])]))
+ selector.set_facet(int(f), EnforcerBlockerType::ENFORCER);
+ }
+ return selector.serialize();
+ };
+ const auto on_top = [](const Vec3f &v) { return v.z() > 19.9f; };
+ const auto on_front = [](const Vec3f &v) { return v.y() < 0.1f; };
+
+ // The top region of a result: its vertices, and its triangles' count and worst aspect ratio.
+ struct TopRegion
+ {
+ std::vector vertices;
+ size_t triangles = 0;
+ float max_aspect = 0.f;
+ };
+ const auto top_region = [&on_top](const indexed_triangle_set &mesh) {
+ TopRegion r;
+ for (const Vec3f &v : mesh.vertices)
+ if (on_top(v))
+ r.vertices.push_back(v);
+ for (const stl_triangle_vertex_indices &t : mesh.indices) {
+ const Vec3f &a = mesh.vertices[size_t(t[0])], &b = mesh.vertices[size_t(t[1])], &c = mesh.vertices[size_t(t[2])];
+ if (!on_top(a) || !on_top(b) || !on_top(c))
+ continue;
+ ++r.triangles;
+ r.max_aspect = std::max(r.max_aspect, triangle_aspect(a, b, c));
+ }
+ return r;
+ };
+
+ TextureDisplacementFacetsData facets{};
+ facets[0] = paint_where(cube, on_top);
+ const indexed_triangle_set first = build_texture_displacement(cube, { layer }, facets, options);
+ REQUIRE(first.indices.size() > cube.indices.size());
+ const TopRegion top_before = top_region(first);
+ REQUIRE(top_before.triangles > 0);
+ // The relief really is there: the checkerboard raises part of the top by the full depth.
+ float top_z_max = 0.f;
+ for (const Vec3f &v : top_before.vertices)
+ top_z_max = std::max(top_z_max, v.z());
+ REQUIRE(top_z_max > 20.3f);
+ REQUIRE(top_before.max_aspect < 20.f);
+
+ // Only the front of the baked mesh is painted for the second bake.
+ facets[0] = paint_where(first, on_front);
+ REQUIRE_FALSE(facets[0].triangles_to_split.empty());
+
+ const auto check_top_untouched = [&](const indexed_triangle_set &second) {
+ REQUIRE_FALSE(second.indices.empty()); // an aborted bake returns {}
+ const TopRegion top_after = top_region(second);
+
+ // Every vertex of the first relief still exists, at the same place. O(n*m) over a few
+ // thousand vertices each, restricted to the top region on both sides.
+ size_t missing = 0;
+ Vec3f first_missing = Vec3f::Zero();
+ for (const Vec3f &v : top_before.vertices) {
+ bool found = false;
+ for (const Vec3f &w : top_after.vertices)
+ if ((w - v).squaredNorm() <= 1e-6f) { // within 1e-3 mm
+ found = true;
+ break;
+ }
+ if (!found) {
+ if (missing == 0)
+ first_missing = v;
+ ++missing;
+ }
+ }
+ INFO("first vertex of the top relief missing from the second bake: " << first_missing.transpose());
+ CHECK(missing == 0);
+
+ // Nothing was added to or taken from the top either - its triangles are excluded from the
+ // refinement, and a rim edge it shares with the front was already at the refine length.
+ CHECK(top_after.triangles == top_before.triangles);
+
+ // And no needles: the worst triangle on the top is no worse than after the first bake.
+ INFO("worst top-face aspect ratio after the second bake: " << top_after.max_aspect
+ << ", after the first: " << top_before.max_aspect);
+ CHECK(top_after.max_aspect < 20.f);
+ };
+
+ SECTION("bake mode: no simplification")
+ {
+ check_top_untouched(build_texture_displacement(first, { layer }, facets, options));
+ }
+
+ SECTION("export mode: simplification and T-junction repair run over the excluded region too")
+ {
+ // A budget far below the mesh forces the decimation (the locked top alone is over it, so it
+ // only harvests flat faces) and with it the repair pass - the two stages that walk every face,
+ // excluded ones included.
+ TextureDisplacementOptions export_options = options;
+ export_options.v2_max_triangles_k = 1;
+ check_top_untouched(build_texture_displacement(first, { layer }, facets, export_options));
+ }
+}
+
+TEST_CASE("TextureDisplacement: a brush stroke smaller than a triangle displaces only the stroke", "[TextureDisplacement]")
+{
+ // A plain 12-triangle cube. The selector splits the top triangle under a 3 mm spherical brush,
+ // so the painted pieces are far smaller than the triangle. The one-run pipeline used to include
+ // the whole source triangle: the entire top face rose.
+ const indexed_triangle_set cube = its_make_cube(20.f, 20.f, 20.f);
+ const TriangleMesh mesh(cube);
+ int top = -1;
+ for (size_t t = 0; t < cube.indices.size() && top < 0; ++t) {
+ const auto &f = cube.indices[t];
+ if (cube.vertices[size_t(f[0])].z() > 19.9f && cube.vertices[size_t(f[1])].z() > 19.9f &&
+ cube.vertices[size_t(f[2])].z() > 19.9f)
+ // The triangle that contains the face centre: the brush starts there.
+ for (int k = 0; k < 3; ++k)
+ if ((cube.vertices[size_t(f[k])] - Vec3f(10.f, 10.f, 20.f)).norm() < 15.f)
+ top = int(t);
+ }
+ REQUIRE(top >= 0);
+ TriangleSelector selector(mesh);
+ selector.select_patch(top,
+ TriangleSelector::SinglePointCursor::cursor_factory(
+ Vec3f(10.f, 10.f, 20.f), Vec3f(10.f, 10.f, 100.f), 3.f, TriangleSelector::CursorType::SPHERE,
+ Transform3d::Identity(), TriangleSelector::ClippingPlane()),
+ EnforcerBlockerType::ENFORCER, Transform3d::Identity(), /* triangle_splitting */ true);
+ TextureDisplacementFacetsData facets;
+ facets[0] = selector.serialize();
+ REQUIRE(TriangleSelector::has_facets(facets[0], EnforcerBlockerType::ENFORCER));
+
+ TextureDisplacementLayer layer;
+ layer.slot = 0;
+ layer.image_data = make_flat_gray_png(255, 8, 8); // uniform full height: every painted point rises
+ layer.tiling_scale = 4.f;
+ layer.depth_mm = 0.5f;
+ TextureDisplacementOptions options;
+ options.pipeline_v2 = true;
+ options.v2_refine_mm = 0.5f;
+ options.v2_max_triangles_k = 0;
+ const indexed_triangle_set out = build_texture_displacement(cube, { layer }, facets, options);
+ REQUIRE(out.indices.size() > cube.indices.size());
+
+ size_t raised_inside = 0, raised_outside = 0, outside = 0;
+ for (const Vec3f &v : out.vertices) {
+ if (v.z() < 19.9f)
+ continue; // not the top face
+ const float r = (Vec2f(v.x(), v.y()) - Vec2f(10.f, 10.f)).norm();
+ if (r < 2.f && v.z() > 20.3f)
+ ++raised_inside;
+ if (r > 4.5f) {
+ ++outside;
+ if (v.z() > 20.01f)
+ ++raised_outside;
+ }
+ }
+ CHECK(raised_inside > 0); // the stroke itself is displaced
+ CHECK(outside > 0);
+ CHECK(raised_outside == 0); // the rest of the face, inside the same source triangle, is not
+}
+
+TEST_CASE("TextureDisplacement: a texture of flat colours is told apart from a continuous one", "[TextureDisplacement]")
+{
+ // The verdict that decides whether a layer may use filament mixes: a checkerboard is two colours,
+ // a ramp spreads over every level.
+ TextureDisplacementLayer checker;
+ checker.image_data = make_checkerboard_png(32, 32);
+ CHECK(analyze_texture_detail(checker).flat_colors);
+
+ std::vector ramp(64 * 64);
+ for (size_t y = 0; y < 64; ++y)
+ for (size_t x = 0; x < 64; ++x)
+ ramp[y * 64 + x] = uint8_t((x * 4 + y) & 255);
+ const boost::filesystem::path tmp_path = boost::filesystem::temp_directory_path()
+ / boost::filesystem::unique_path("texdisp_test_%%%%%%%%.png");
+ REQUIRE(Slic3r::png::write_gray_to_file(tmp_path.string(), 64, 64, ramp));
+ std::vector bytes;
+ {
+ std::ifstream ifs(tmp_path.string(), std::ios::binary);
+ bytes.assign(std::istreambuf_iterator(ifs), std::istreambuf_iterator());
+ }
+ boost::system::error_code ec;
+ boost::filesystem::remove(tmp_path, ec);
+ TextureDisplacementLayer gradient;
+ gradient.image_data = std::make_shared>(std::move(bytes));
+ const TextureDetail d = analyze_texture_detail(gradient);
+ CHECK_FALSE(d.flat_colors);
+ CHECK(d.flat_share < 0.85f);
+}
+
+TEST_CASE("Each layer's texture is sampled only on its own painted area", "[TextureDisplacement]")
+{
+ // Two layers with different textures and depths, one painted on the cube's top, one on its -X side.
+ const indexed_triangle_set cube = its_make_cube(10., 10., 10.);
+ const TriangleMesh cube_mesh(cube);
+ const auto paint_facing = [&](const Vec3f &dir) {
+ TriangleSelector selector(cube_mesh);
+ for (int f = 0; f < int(cube.indices.size()); ++f) {
+ const stl_triangle_vertex_indices &t = cube.indices[size_t(f)];
+ const Vec3f n = (cube.vertices[size_t(t[1])] - cube.vertices[size_t(t[0])])
+ .cross(cube.vertices[size_t(t[2])] - cube.vertices[size_t(t[0])])
+ .normalized();
+ if (n.dot(dir) > 0.99f)
+ selector.set_facet(f, EnforcerBlockerType::ENFORCER);
+ }
+ return selector.serialize();
+ };
+ TextureDisplacementFacetsData facets{};
+ facets[0] = paint_facing(Vec3f::UnitZ());
+ facets[1] = paint_facing(-Vec3f::UnitX());
+
+ TextureDisplacementLayer top;
+ top.slot = 0;
+ top.depth_mm = 1.0f;
+ top.tiling_scale = 5.0f;
+ top.image_data = make_flat_gray_png(255);
+ TextureDisplacementLayer side = top;
+ side.slot = 1;
+ side.depth_mm = 0.5f;
+ side.image_data = make_flat_gray_png(64);
+
+ const HeightFieldSampler both = make_combined_displacement_sampler(cube, { top, side }, facets);
+ const HeightFieldSampler top_only = make_combined_displacement_sampler(cube, { top }, facets);
+ const HeightFieldSampler side_only = make_combined_displacement_sampler(cube, { side }, facets);
+ REQUIRE(both);
+ REQUIRE(top_only);
+ REQUIRE(side_only);
+
+ const Vec3f on_top(5.f, 5.f, 10.f), on_side(0.f, 5.f, 5.f), unpainted(5.f, 10.f, 5.f);
+ CHECK_THAT(both(on_top, Vec3f::UnitZ()), WithinAbs(top_only(on_top, Vec3f::UnitZ()), 1e-5f));
+ CHECK_THAT(both(on_side, -Vec3f::UnitX()), WithinAbs(side_only(on_side, -Vec3f::UnitX()), 1e-5f));
+ CHECK_THAT(both(unpainted, Vec3f::UnitY()), WithinAbs(0.f, 1e-6f));
+}