Bake texture displacement on every painted part of an object (#16721)

# Description

Texture displacement read the layer stack and the bake target from an
object's first model part, so a multibody object was displaced only
there while the paint on its other parts was kept and ignored. The bake,
prepare and preview jobs now each carry a vector of parts and run them
in one job, under one progress bar and one undo step; remesh and
subdivide cover every part too

# Screenshots/Recordings/Graphs
<img width="3515" height="1838" alt="image"
src="https://github.com/user-attachments/assets/227cd067-4b04-476a-982e-b52c4fdcca23"
/>

# Other
Fixes #16559

[How to Download Pull Requests Artifacts for
Testing](https://www.orcaslicer.com/wiki/how_to_download_pr_artifacts)
This commit is contained in:
SoftFever authored and GitHub committed 2026-10-22 18:45:27 +08:00
commit e5bb802378
15 files changed
+1129 -467

No files matched your search

+30 -15
View File
@@ -2724,7 +2724,8 @@ indexed_triangle_set build_texture_displacement_v2(const indexed_triangle_set
bool flip_normals,
BakeStageRecorder *debug,
TextureBakeStats *stats,
const std::vector<Vec2f> *model_uv)
const std::vector<Vec2f> *model_uv,
float resting_drop)
{
HeightFieldSampler combined = make_combined_displacement_sampler(mesh, layers, facets_data, model_uv);
if (!combined)
@@ -2871,6 +2872,9 @@ indexed_triangle_set build_texture_displacement_v2(const indexed_triangle_set
bounds.min = bounds.min.cwiseMin(v);
bounds.max = bounds.max.cwiseMax(v);
}
// The bottom the pipeline clamps and snaps to is the plane the whole object rests on, not this
// part's own: a part held off the plate by another keeps the relief on its underside.
bounds.min.z() -= resting_drop;
const auto sample = [&combined](const Vec3f &pos, const Vec3f &, const Vec3f &blend_normal) {
// The blend normal chooses the projection; the move itself is along the smooth normal, which
@@ -3030,7 +3034,8 @@ static indexed_triangle_set build_texture_displacement_in_place(
bool flip_normals,
BakeStageRecorder *debug,
TextureBakeStats *stats,
const std::vector<Vec2f> *model_uv)
const std::vector<Vec2f> *model_uv,
float resting_drop)
{
// The classic path moves the vertices the mesh already has, so there is no budget to report on.
(void) stats;
@@ -3067,7 +3072,7 @@ static indexed_triangle_set build_texture_displacement_in_place(
if (options.pipeline_v2)
return build_texture_displacement_v2(mesh, layers, facets_data, options, progress, color, flip_normals,
debug, stats, model_uv);
debug, stats, model_uv, resting_drop);
// Layers are combined in slot order, like stacked layers in an image editor: each one folds its
// own displacement into the running total via its blend mode (see TextureBlendMode).
@@ -3399,10 +3404,12 @@ static indexed_triangle_set build_texture_displacement_in_place(
if (!any_displacement)
return mesh;
// The model's own resting plane, taken before anything moves - see the clamp after smoothing.
// The plane the model rests on, taken before anything moves - see the clamp after smoothing. That
// is this mesh's own bottom, lowered to the object's when another part of it sits lower.
float resting_z = std::numeric_limits<float>::max();
for (const Vec3f &v : mesh.vertices)
resting_z = std::min(resting_z, v.z());
resting_z -= resting_drop;
size_t moved_count = 0;
for (size_t vi = 0; vi < mesh.vertices.size(); ++vi)
@@ -3494,17 +3501,20 @@ indexed_triangle_set build_texture_displacement(const indexed_triangle_set
const Transform3d &volume_to_world,
BakeStageRecorder *debug,
TextureBakeStats *stats,
const std::vector<Vec2f> *model_uv)
const std::vector<Vec2f> *model_uv,
const Vec3d *texture_anchor,
float resting_drop)
{
// An untransformed volume on an untransformed instance is by far the common case, and the round
// trip costs two matrix multiplies per vertex on a mesh that can carry millions of them - so take
// the identity out of the way rather than paying for it.
// The texture is projected in the bake frame - world orientation and scale, anchored at the
// volume's origin (see texture_displacement_bake_frame()).
const Transform3d frame = texture_displacement_bake_frame(volume_to_world);
// texture's anchor (see texture_displacement_bake_frame()).
const Transform3d frame = texture_anchor != nullptr ? texture_displacement_bake_frame(volume_to_world, *texture_anchor) :
texture_displacement_bake_frame(volume_to_world);
if (frame.matrix().isApprox(Transform3d::Identity().matrix()))
return build_texture_displacement_in_place(base_mesh, layers, facets_data, options, progress, color,
false, debug, stats, model_uv);
false, debug, stats, model_uv, resting_drop);
const Transform3d to_local = frame.inverse();
// A mirroring placement leaves the positions correct but every winding-derived normal pointing
@@ -3520,8 +3530,8 @@ indexed_triangle_set build_texture_displacement(const indexed_triangle_set
const size_t debug_mark = (debug != nullptr) ? debug->mark() : 0;
// The UV channel rides along untouched: a uv is a coordinate in the texture, not on the model, so
// the frame the mesh is measured in does not reach it.
indexed_triangle_set out = build_texture_displacement_in_place(world, layers, facets_data, options,
progress, color, mirrored, debug, stats, model_uv);
indexed_triangle_set out = build_texture_displacement_in_place(world, layers, facets_data, options, progress,
color, mirrored, debug, stats, model_uv, resting_drop);
// Everything the bake recorded is in world millimetres, like `out` itself. The debug view draws in
// the volume's local frame, so the stages are brought back the same way the result is.
if (debug != nullptr)
@@ -3568,13 +3578,18 @@ void texture_displacement_patch_frame(const indexed_triangle_set &patch, const s
(an.y() <= an.x() && an.y() <= an.z()) ? Vec3f::UnitY() : Vec3f::UnitZ();
}
Transform3d texture_displacement_bake_frame(const Transform3d &volume_to_world, const Vec3d &anchor)
{
// World orientation and scale, but the origin moved onto the model: the texture then rides with
// the model when it is moved about the plate (and a single, untiled stamp starts on the model
// rather than at the plate's corner), while a tile is still `tiling_scale` printed millimetres
// whatever the instance's scale.
return Eigen::Translation3d(-anchor) * volume_to_world;
}
Transform3d texture_displacement_bake_frame(const Transform3d &volume_to_world)
{
// World orientation and scale, but the origin moved to where the volume's own origin sits: the
// texture then rides with the model when it is moved about the plate (and a single, untiled stamp
// starts on the model rather than at the plate's corner), while a tile is still `tiling_scale`
// printed millimetres whatever the instance's scale.
return Eigen::Translation3d(-volume_to_world.translation()) * volume_to_world;
return texture_displacement_bake_frame(volume_to_world, volume_to_world.translation());
}
Transform3d texture_displacement_volume_to_world(const ModelVolume &volume)
+15 -3
View File
@@ -1040,14 +1040,26 @@ indexed_triangle_set build_texture_displacement(const indexed_triangle_set
// When given, receives what the bake spent.
TextureBakeStats *stats = nullptr,
// The model's own UV channel; see above.
const std::vector<Vec2f> *model_uv = nullptr);
const std::vector<Vec2f> *model_uv = nullptr,
// The world point the texture is anchored at, null for
// the volume's own origin. Every part of a multibody
// object passes the same point, so the pattern runs on
// across them. See texture_displacement_bake_frame().
const Vec3d *texture_anchor = nullptr,
// How far the object's lowest point lies below this
// volume's own, in mm. Relief is kept above the plane
// the whole object rests on, so a part held off the
// plate by another keeps the relief on its underside.
float resting_drop = 0.f);
// `volume`'s mesh coordinates -> world millimetres: its first instance's transform times its own.
// The mesh is shared by every instance, so a multi-instance object can only be baked for one of
// them; the first is what the gizmo edits against. Identity when the volume has no object yet.
Transform3d texture_displacement_volume_to_world(const ModelVolume &volume);
// The frame the bake and the previews project the texture in: `volume_to_world` with its translation
// removed, i.e. world orientation and scale about the volume's own origin. See build_texture_displacement().
// The frame the bake and the previews project the texture in: world orientation and scale, with the
// world point `anchor` as its origin. See build_texture_displacement().
Transform3d texture_displacement_bake_frame(const Transform3d &volume_to_world, const Vec3d &anchor);
// Anchored at the volume's own origin, which is where a single-part object's texture sits.
Transform3d texture_displacement_bake_frame(const Transform3d &volume_to_world);
// Area-weighted vertex normals of `its` - the directions the bake both projects and displaces along.
+6 -1
View File
@@ -114,6 +114,11 @@ Vec3f GLGizmoPainterBase::get_tilt_up_direction() const
}
void GLGizmoPainterBase::render_triangles(const Selection& selection, const ModelVolume* skip) const
{
render_triangles(selection, skip == nullptr ? std::vector<const ModelVolume*>{} : std::vector<const ModelVolume*>{skip});
}
void GLGizmoPainterBase::render_triangles(const Selection& selection, const std::vector<const ModelVolume*>& skip) const
{
auto* shader = wxGetApp().get_shader("mm_gouraud");
if (!shader)
@@ -135,7 +140,7 @@ void GLGizmoPainterBase::render_triangles(const Selection& selection, const Mode
continue;
++mesh_id;
if (mv == skip)
if (std::find(skip.begin(), skip.end(), mv) != skip.end())
continue;
Transform3d trafo_matrix;
@@ -235,6 +235,10 @@ public:
protected:
// Draws every model part's selector, except `skip`'s when given.
virtual void render_triangles(const Selection& selection, const ModelVolume* skip = nullptr) const;
// The same, skipping every part in `skip`. A gizmo can have a preview standing in for more than one
// model part at a time - the texture-displacement gizmo previews every painted part of a multibody
// object - and drawing those parts' selectors over it would bury the preview under a flat surface.
void render_triangles(const Selection& selection, const std::vector<const ModelVolume*>& skip) const;
void render_cursor();
void render_cursor_circle();
void render_cursor_sphere(const Transform3d& trafo) const;
@@ -7,6 +7,7 @@
#include "ColorSpaceConvert.hpp"
#include "libslic3r/AABBTreeIndirect.hpp"
#include "libslic3r/BoundingBox.hpp"
#include "libslic3r/Color.hpp"
#include "libslic3r/FilamentMixer.hpp"
#include "libslic3r/PresetBundle.hpp"
@@ -26,6 +27,7 @@
#include "slic3r/GUI/GuiColor.hpp"
#include "slic3r/GUI/ImGuiWrapper.hpp"
#include "slic3r/GUI/MainFrame.hpp" // wxGetApp().mainframe, as the projector window's parent
#include "slic3r/GUI/MeshUtils.hpp"
#include "slic3r/GUI/MsgDialog.hpp"
#include "slic3r/GUI/OpenGLManager.hpp"
#include "slic3r/GUI/Plater.hpp"
@@ -580,6 +582,7 @@ void GLGizmoTextureDisplacement::on_shutdown()
{
m_parent.toggle_model_objects_visibility(true);
m_preview_glmodel.reset();
m_part_previews.clear();
m_shaded_preview_glmodel.reset();
m_paint_overlay_glmodel.reset();
m_paint_overlay_dirty = false;
@@ -705,8 +708,7 @@ void GLGizmoTextureDisplacement::render_painter_gizmo()
// why those two modes looked like they did nothing. Leave it out and let the undisplaced volume show
// through instead (toggle_model_objects_visibility below) - that one *is* coincident with the
// overlay, which is what the offset assumes, and it is the surface whose mapping is being inspected.
const bool use_true_preview = !use_shaded && m_uv_check_mode == UVCheckMode::None &&
m_preview_glmodel.is_initialized();
const bool use_true_preview = !use_shaded && m_uv_check_mode == UVCheckMode::None && has_true_preview();
// In Checker/Distortion mode the UV-check overlay *is* the surface visualization the user is
// looking at, so the opaque paint-selection highlight must not be drawn on top of it - same
// reasoning as skipping it for the shaded preview (see bug #12). Without this the painted area
@@ -717,10 +719,23 @@ void GLGizmoTextureDisplacement::render_painter_gizmo()
// put it back. Getting this wrong leaves an invisible model, so it is decided once, here, rather
// than per branch below.
ModelVolume *mv = texture_volume();
// The parts a preview is standing in for, and whose real volumes therefore have to be hidden and
// whose selectors must not be drawn over it. The shaded view only ever shades the layer stack's own
// part; the true-displacement view covers every painted part of a multibody object.
std::vector<const ModelVolume *> previewed;
if (use_shaded && mv != nullptr) {
previewed.push_back(mv);
} else if (use_true_preview) {
if (mv != nullptr && m_preview_glmodel.is_initialized())
previewed.push_back(mv);
for (const std::unique_ptr<PartPreview> &preview : m_part_previews)
if (const ModelVolume *part = preview_part(*preview); part != nullptr)
previewed.push_back(part);
}
m_parent.toggle_model_objects_visibility(true);
if ((use_shaded || use_true_preview) && mv != nullptr)
for (const ModelVolume *volume : previewed)
m_parent.toggle_model_objects_visibility(false, m_c->selection_info()->model_object(),
m_c->selection_info()->get_active_instance(), mv);
m_c->selection_info()->get_active_instance(), volume);
// Over a preview, the paint feedback - the selectors' highlight in the Normal view, the tint in both -
// is drawn only while a mouse button is down: during a stroke, a fill click included. Between strokes
@@ -734,14 +749,15 @@ void GLGizmoTextureDisplacement::render_painter_gizmo()
if (use_shaded) {
render_shaded_preview_mesh();
// The shaded mesh is the textured volume alone, so the other model parts are still the selectors' to draw.
render_triangles(selection, mv);
render_triangles(selection, previewed);
textured_selector_drawn = false;
} else if (use_true_preview) {
render_preview_mesh();
glsafe(::glEnable(GL_POLYGON_OFFSET_FILL));
glsafe(::glPolygonOffset(-1.0f, -1.0f));
// Without paint feedback, only the other model parts: render_preview_mesh() draws the textured one.
render_triangles(selection, paint_feedback ? nullptr : mv);
// Without paint feedback, only the parts with no preview over them: render_preview_mesh() draws
// the rest.
render_triangles(selection, paint_feedback ? std::vector<const ModelVolume *>{} : previewed);
glsafe(::glDisable(GL_POLYGON_OFFSET_FILL));
textured_selector_drawn = paint_feedback;
} else {
@@ -1239,36 +1255,61 @@ void GLGizmoTextureDisplacement::render_seam_overlay()
void GLGizmoTextureDisplacement::render_preview_mesh()
{
const ModelObject *mo = m_c->selection_info()->model_object();
const ModelVolume *mv = texture_volume();
if (mo == nullptr || mv == nullptr)
if (mo == nullptr)
return;
const Selection &selection = m_parent.get_selection();
const Transform3d trafo_matrix = mo->instances[selection.get_instance_idx()]->get_transformation().get_matrix() * mv->get_matrix();
auto *shader = wxGetApp().get_shader("gouraud_light");
if (shader == nullptr)
return;
const Selection &selection = m_parent.get_selection();
const Transform3d instance_matrix = mo->instances[selection.get_instance_idx()]->get_transformation().get_matrix();
const Camera &camera = wxGetApp().plater()->get_camera();
const Transform3d &view_matrix = camera.get_view_matrix();
shader->start_using();
const Camera &camera = wxGetApp().plater()->get_camera();
const Transform3d &view_matrix = camera.get_view_matrix();
shader->set_uniform("view_model_matrix", view_matrix * trafo_matrix);
shader->set_uniform("projection_matrix", camera.get_projection_matrix());
const Matrix3d view_normal_matrix = view_matrix.matrix().block(0, 0, 3, 3) * trafo_matrix.matrix().block(0, 0, 3, 3).inverse().transpose();
shader->set_uniform("view_normal_matrix", view_normal_matrix);
if (m_preview_color_runs.empty()) {
m_preview_glmodel.render();
} else {
// set_color() writes the uniform the shader reads, so one call per group is all the
// per-triangle colour this needs.
for (const PreviewColorRun &run : m_preview_color_runs) {
m_preview_glmodel.set_color(run.color);
m_preview_glmodel.render(run.range, shader);
// Each part is drawn through its own matrix: a multibody object's parts sit in different places
// inside the object, so one matrix for all of them would pile every preview onto the first part.
const auto draw = [&](const ModelVolume &volume, GLModel &model, const std::vector<PreviewColorRun> &runs) {
if (!model.is_initialized())
return;
const Transform3d trafo_matrix = instance_matrix * volume.get_matrix();
shader->set_uniform("view_model_matrix", view_matrix * trafo_matrix);
shader->set_uniform("projection_matrix", camera.get_projection_matrix());
const Matrix3d view_normal_matrix = view_matrix.matrix().block(0, 0, 3, 3) *
trafo_matrix.matrix().block(0, 0, 3, 3).inverse().transpose();
shader->set_uniform("view_normal_matrix", view_normal_matrix);
if (runs.empty()) {
model.render();
} else {
// set_color() writes the uniform the shader reads, so one call per group is all the
// per-triangle colour this needs.
for (const PreviewColorRun &run : runs) {
model.set_color(run.color);
model.render(run.range, shader);
}
}
}
};
if (const ModelVolume *owner = texture_volume(); owner != nullptr)
draw(*owner, m_preview_glmodel, m_preview_color_runs);
for (const std::unique_ptr<PartPreview> &preview : m_part_previews)
if (const ModelVolume *part = preview_part(*preview); part != nullptr)
draw(*part, preview->glmodel, preview->color_runs);
shader->stop_using();
}
const ModelVolume *GLGizmoTextureDisplacement::preview_part(const PartPreview &preview) const
{
const ModelObject *mo = m_c->selection_info()->model_object();
if (mo == nullptr)
return nullptr;
for (const ModelVolume *mv : mo->volumes)
if (mv->id() == preview.volume_id)
return mv;
return nullptr; // the part was removed, or its mesh replaced, since the preview was built
}
float GLGizmoTextureDisplacement::layer_texture_aspect(const TextureDisplacementLayer &layer)
{
// decode_height_texture() is cached on the image_data allocation, so this is a hash lookup rather
@@ -2362,8 +2403,9 @@ void GLGizmoTextureDisplacement::rebuild_preview()
return;
const ModelVolume *mv = texture_volume();
if (mv == nullptr || !mv->is_texture_displacement_painted()) {
if (mv == nullptr || !any_part_painted()) {
m_preview_glmodel.reset();
m_part_previews.clear();
m_preview_its = indexed_triangle_set{}; // no displaced mesh; wireframe falls back to the base
m_preview_job_pending = false;
refresh_wireframe();
@@ -2384,14 +2426,44 @@ void GLGizmoTextureDisplacement::rebuild_preview()
queue_preview_job();
}
indexed_triangle_set GLGizmoTextureDisplacement::group_preview_by_color(const indexed_triangle_set &its,
const std::vector<uint8_t> &triangle_color,
const std::vector<ColorRGBA> &entry_colors,
std::vector<PreviewColorRun> &runs)
{
runs.clear();
if (triangle_color.size() != its.indices.size() || entry_colors.empty())
return its;
indexed_triangle_set sorted;
sorted.vertices = its.vertices;
sorted.indices.reserve(its.indices.size());
for (int want = 0; want <= int(entry_colors.size()); ++want) {
const size_t first = sorted.indices.size();
for (size_t i = 0; i < its.indices.size(); ++i)
if (int(triangle_color[i]) == want)
sorted.indices.push_back(its.indices[i]);
if (sorted.indices.size() == first)
continue;
runs.push_back({ { first * 3, sorted.indices.size() * 3 },
want == 0 ? GLVolume::NEUTRAL_COLOR : entry_colors[size_t(want - 1)] });
}
return sorted;
}
void GLGizmoTextureDisplacement::queue_preview_job()
{
// A job in flight when the gizmo closes still runs its completion handler, which would otherwise
// happily queue the follow-up run it was holding - against a gizmo nobody is looking at any more.
if (m_state != On)
return;
const ModelVolume *mv = texture_volume();
if (mv == nullptr || !mv->is_texture_displacement_painted())
const ModelVolume *owner = texture_volume();
if (owner == nullptr)
return;
// Every painted part, so the preview shows the whole of what Bake will do rather than its first
// part - the same set bake() displaces.
const std::vector<ModelVolume *> parts = painted_parts();
if (parts.empty())
return;
// One in flight at a time; everything requested meanwhile collapses into a single follow-up run
@@ -2404,22 +2476,39 @@ void GLGizmoTextureDisplacement::queue_preview_job()
const uint64_t generation = m_preview_generation->load();
TextureDisplacementPreviewInput input;
input.base_mesh = mv->mesh().its;
input.layers = mv->texture_displacement_layers;
input.options = mv->texture_displacement_options;
// The preview is looked at, not printed or sliced: skip the simplification and the repair that
// follows it, which are most of the one-run pipeline's time. The relief is the same.
if (input.options.pipeline_v2)
input.options.v2_max_triangles_k = 0;
input.volume_to_world = texture_displacement_volume_to_world(*mv);
if (mv->has_texture_uv())
input.model_uv = mv->texture_uv;
for (int i = 0; i < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++i)
input.facets_data[size_t(i)] = mv->texture_displacement_facet(i).get_data();
// Captured here rather than read in the handler: the palette is main-thread state, and the preview
// has to be grouped against the same palette it was computed with, not whatever it is by the time
// the result lands.
input.color = color_settings_for(*mv);
// the result lands. Read off the layer stack's own part, since that is where the stack lives.
input.color = color_settings_for(*owner);
input.volumes.reserve(parts.size());
// Which part each result belongs to, so the handler can tell the layer stack's own preview - the one
// the overlays and the wireframe are keyed to - from the rest.
std::vector<ObjectID> part_ids;
part_ids.reserve(parts.size());
const Vec3d anchor = texture_anchor();
const std::vector<float> drops = resting_drops(parts);
for (size_t i = 0; i < parts.size(); ++i) {
const ModelVolume *part = parts[i];
TextureDisplacementPreviewVolume entry;
entry.base_mesh = part->mesh().its;
entry.layers = layers_for_part(*part);
entry.options = owner->texture_displacement_options;
// The preview is looked at, not printed or sliced: skip the simplification and the repair that
// follows it, which are most of the one-run pipeline's time. The relief is the same.
if (entry.options.pipeline_v2)
entry.options.v2_max_triangles_k = 0;
entry.volume_to_world = texture_displacement_volume_to_world(*part);
entry.texture_anchor = anchor;
entry.resting_drop = drops[i];
if (part->has_texture_uv())
entry.model_uv = part->texture_uv;
for (int slot = 0; slot < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++slot)
entry.facets_data[size_t(slot)] = part->texture_displacement_facet(slot).get_data();
input.volumes.push_back(std::move(entry));
part_ids.push_back(part->id());
}
const ObjectID owner_id = owner->id();
// The result names a palette entry per triangle (index + 1), so these are the colours to draw it in:
// a single filament's own, or for a mix the colour its slot will show once a bake creates it.
std::vector<ColorRGBA> entry_colors;
@@ -2430,43 +2519,52 @@ void GLGizmoTextureDisplacement::queue_preview_job()
m_preview_job_running = true;
auto &worker = wxGetApp().plater()->get_ui_job_worker();
queue_job(worker, std::make_unique<TextureDisplacementPreviewJob>(std::move(input), generation, m_preview_generation,
[this, entry_colors](TextureDisplacementPreviewResult result, uint64_t result_generation) {
indexed_triangle_set its = std::move(result.mesh);
[this, entry_colors, part_ids, owner_id](std::vector<TextureDisplacementPreviewResult> results,
uint64_t result_generation) {
m_preview_job_running = false;
if (result_generation != m_preview_generation->load()) {
// Superseded while this was computing (it will have aborted early and come back
// empty). Whatever the newest state is, it still needs a run.
m_preview_job_pending = true;
} else if (its.indices.empty()) {
} else if (results.empty()) {
// Aborted or cancelled rather than finished - the handler runs on every outcome so
// the in-flight latch above always clears. Keep whatever preview is already on screen
// rather than blanking it; there is no new result to show, not a new empty one.
} else {
// A finished run carries the whole object, so it replaces the previous one outright: a
// part that is no longer painted must lose its preview, or its flat volume would stay
// hidden behind geometry from before.
m_preview_glmodel.reset();
m_preview_color_runs.clear();
if (result.triangle_color.size() == its.indices.size() && !entry_colors.empty()) {
indexed_triangle_set sorted;
sorted.vertices = its.vertices;
sorted.indices.reserve(its.indices.size());
for (int want = 0; want <= int(entry_colors.size()); ++want) {
const size_t first = sorted.indices.size();
for (size_t i = 0; i < its.indices.size(); ++i)
if (int(result.triangle_color[i]) == want)
sorted.indices.push_back(its.indices[i]);
if (sorted.indices.size() == first)
continue;
m_preview_color_runs.push_back(
{ { first * 3, sorted.indices.size() * 3 },
want == 0 ? GLVolume::NEUTRAL_COLOR : entry_colors[size_t(want - 1)] });
m_preview_its = indexed_triangle_set{};
m_part_previews.clear();
for (size_t i = 0; i < results.size() && i < part_ids.size(); ++i) {
indexed_triangle_set its = std::move(results[i].mesh);
if (its.indices.empty())
continue; // this part's layers moved nothing; its own volume is drawn instead
std::vector<PreviewColorRun> runs;
indexed_triangle_set grouped =
group_preview_by_color(its, results[i].triangle_color, entry_colors, runs);
if (part_ids[i] == owner_id) {
m_preview_color_runs = std::move(runs);
m_preview_glmodel.init_from(grouped);
m_preview_glmodel.set_color(GLVolume::NEUTRAL_COLOR);
// Keep the displaced mesh so the wireframe overlay can be drawn on the raised
// surface actually shown in the true-displacement view.
m_preview_its = std::move(its);
} else {
auto preview = std::make_unique<PartPreview>();
preview->volume_id = part_ids[i];
preview->color_runs = std::move(runs);
preview->glmodel.init_from(grouped);
preview->glmodel.set_color(GLVolume::NEUTRAL_COLOR);
m_part_previews.push_back(std::move(preview));
}
m_preview_glmodel.init_from(sorted);
} else {
m_preview_glmodel.init_from(its);
}
m_preview_glmodel.set_color(GLVolume::NEUTRAL_COLOR);
// Keep the displaced mesh so the wireframe overlay can be drawn on it (the
// true-displacement view), then refresh the wireframe from it.
m_preview_its = std::move(its);
refresh_wireframe();
// The paint tint rides the displaced surface in this view, so it follows the new mesh.
m_paint_overlay_dirty = true;
@@ -3702,6 +3800,82 @@ const ModelVolume* GLGizmoTextureDisplacement::texture_volume() const
return const_cast<GLGizmoTextureDisplacement *>(this)->texture_volume();
}
std::vector<ModelVolume *> GLGizmoTextureDisplacement::texture_parts()
{
std::vector<ModelVolume *> out;
ModelObject *mo = m_c->selection_info()->model_object();
if (mo == nullptr)
return out;
for (ModelVolume *mv : mo->volumes)
if (mv->is_model_part())
out.push_back(mv);
return out;
}
std::vector<ModelVolume *> GLGizmoTextureDisplacement::painted_parts()
{
std::vector<ModelVolume *> out;
for (ModelVolume *mv : texture_parts())
if (mv->is_texture_displacement_painted())
out.push_back(mv);
return out;
}
bool GLGizmoTextureDisplacement::any_part_painted() const
{
for (const ModelVolume *mv : const_cast<GLGizmoTextureDisplacement *>(this)->texture_parts())
if (mv->is_texture_displacement_painted())
return true;
return false;
}
std::vector<TextureDisplacementLayer> GLGizmoTextureDisplacement::layers_for_part(const ModelVolume &part) const
{
const ModelVolume *owner = texture_volume();
if (owner == nullptr)
return {};
std::vector<TextureDisplacementLayer> out = owner->texture_displacement_layers;
if (&part == owner)
return out;
for (TextureDisplacementLayer &layer : out) {
layer.lscm_seam_edges.clear();
layer.islands.clear();
layer.island_groups.clear();
layer.lscm_uv_overrides.clear();
}
return out;
}
Vec3d GLGizmoTextureDisplacement::texture_anchor() const
{
const ModelVolume *owner = texture_volume();
return owner != nullptr ? Vec3d(texture_displacement_volume_to_world(*owner).translation()) : Vec3d::Zero();
}
std::vector<float> GLGizmoTextureDisplacement::resting_drops(const std::vector<ModelVolume *> &parts)
{
const auto bottom = [](const ModelVolume &part) {
return part.mesh().transformed_bounding_box(texture_displacement_volume_to_world(part)).min.z();
};
// Each part's bottom is measured the same way as the object's, so the lowest part's drop is exactly
// 0 and a single-part object bakes against its own bottom as it always has.
double object_bottom = std::numeric_limits<double>::max();
for (const ModelVolume *part : texture_parts())
object_bottom = std::min(object_bottom, bottom(*part));
std::vector<float> out;
out.reserve(parts.size());
for (const ModelVolume *part : parts)
out.push_back(float(bottom(*part) - object_bottom));
return out;
}
Transform3d GLGizmoTextureDisplacement::prepare_texture_frame(const ModelVolume &part)
{
if (texture_parts().size() < 2)
return Transform3d::Identity();
return texture_displacement_bake_frame(texture_displacement_volume_to_world(part), texture_anchor());
}
void GLGizmoTextureDisplacement::update_model_object()
{
store_panel_state();
@@ -4982,7 +5156,10 @@ void GLGizmoTextureDisplacement::remove_texture_layer(int slot)
[slot](const TextureDisplacementLayer &l) { return l.slot == slot; }),
layers.end());
if (slot >= 0 && slot < int(TEXTURE_DISPLACEMENT_MAX_LAYERS)) {
mv->texture_displacement_facet(slot).reset();
// Every model part: paint left in the slot on another part would still bake, and the next layer
// added reuses the slot.
for (ModelVolume *part : texture_parts())
part->texture_displacement_facet(slot).reset();
m_thumbnails[size_t(slot)].reset();
m_thumbnail_source[size_t(slot)].reset();
m_layer_expanded[size_t(slot)] = false;
@@ -5073,25 +5250,19 @@ void GLGizmoTextureDisplacement::move_texture_layer(int slot, int to_index)
int GLGizmoTextureDisplacement::select_visible_faces(const std::array<float, 12> *uv_clip)
{
ModelVolume *mv = texture_volume();
ModelObject *mo = m_c->selection_info()->model_object();
const int idx = texture_volume_raycaster_index();
if (mv == nullptr || mo == nullptr || idx < 0 || idx >= int(m_triangle_selectors.size()))
const ModelVolume *owner = texture_volume();
ModelObject *mo = m_c->selection_info()->model_object();
// Every model part, at its selector's and its raycaster's index (see texture_parts()): what is in
// view gets painted whichever part it belongs to, and one part can hide another.
const std::vector<ModelVolume *> parts = texture_parts();
const std::vector<const MeshRaycaster *> raycasters = m_c->raycaster()->raycasters();
if (owner == nullptr || mo == nullptr || parts.size() > m_triangle_selectors.size() || parts.size() > raycasters.size())
return 0;
const indexed_triangle_set &its = mv->mesh().its;
if (its.indices.empty())
return 0;
const Selection &selection = m_parent.get_selection();
const Geometry::Transformation trafo(mo->instances[selection.get_instance_idx()]->get_transformation().get_matrix() *
mv->get_matrix());
const Transform3d &to_world = trafo.get_matrix();
const Camera &camera = wxGetApp().plater()->get_camera();
// Normals transform by the inverse transpose, not by the matrix itself - with a non-uniform
// scale the two differ, and using the wrong one flips the facing test on the scaled axes.
const Matrix3d normal_matrix = to_world.matrix().block<3, 3>(0, 0).inverse().transpose();
const Selection &selection = m_parent.get_selection();
const Transform3d instance = mo->instances[selection.get_instance_idx()]->get_transformation().get_matrix();
const Transform3d owner_to_world = instance * owner->get_matrix();
const Camera &camera = wxGetApp().plater()->get_camera();
// Pass 1 (cheap): drop back-facing triangles. Under perspective the view direction varies across
// the model, so it is taken per triangle from the eye to the centroid; under an orthographic
@@ -5101,69 +5272,109 @@ int GLGizmoTextureDisplacement::select_visible_faces(const std::array<float, 12>
const bool ortho = camera.get_type() == Camera::EType::Ortho;
const Vec3d fwd = camera.get_dir_forward();
std::vector<Vec3f> centroids; // world coords, what get_unobscured_idxs() expects
std::vector<unsigned> front_facing; // parallel: which facet each centroid came from
centroids.reserve(its.indices.size() / 2);
front_facing.reserve(its.indices.size() / 2);
std::vector<Geometry::Transformation> trafos;
std::vector<std::vector<Vec3f>> centroids(parts.size()); // world coords, what get_unobscured_idxs() expects
std::vector<std::vector<unsigned>> front_facing(parts.size()); // parallel: which facet each centroid came from
trafos.reserve(parts.size());
for (size_t p = 0; p < parts.size(); ++p) {
const ModelVolume &part = *parts[p];
const indexed_triangle_set &its = part.mesh().its;
trafos.emplace_back(instance * part.get_matrix());
const Transform3d &to_world = trafos.back().get_matrix();
// The projection frame maps the owner's local coordinates (see apply_projection_frame()), so
// another part's centroids are brought into them before the clip test.
const Transform3d to_owner = (&part == owner) ? Transform3d::Identity() : Transform3d(owner_to_world.inverse() * to_world);
for (size_t f = 0; f < its.indices.size(); ++f) {
const stl_triangle_vertex_indices &tri = its.indices[f];
const Vec3d a = its.vertices[tri[0]].cast<double>();
const Vec3d b = its.vertices[tri[1]].cast<double>();
const Vec3d c = its.vertices[tri[2]].cast<double>();
const Vec3d n_world = normal_matrix * (b - a).cross(c - a);
if (n_world.squaredNorm() < 1e-20)
continue; // degenerate triangle: no meaningful normal, so no meaningful facing test
const Vec3d centroid_local = (a + b + c) / 3.0;
const Vec3d centroid_world = to_world * centroid_local;
const Vec3d view_dir = ortho ? fwd : Vec3d(centroid_world - eye);
if (n_world.dot(view_dir) >= 0.0)
continue; // facing away from the camera
// Normals transform by the inverse transpose, not by the matrix itself - with a non-uniform
// scale the two differ, and using the wrong one flips the facing test on the scaled axes.
const Matrix3d normal_matrix = to_world.matrix().block<3, 3>(0, 0).inverse().transpose();
// Clip to the projection frame before the raycast, not after: outside the frame the texture
// samples to nothing anyway, so those facets would only be painted to no effect - and this
// is also what keeps the ray queries proportional to the framed area instead of the model.
if (uv_clip != nullptr) {
Vec2f uv;
if (!project_uv_projective(*uv_clip, centroid_local.cast<float>(), uv))
continue; // behind the projector
if (uv.x() < 0.f || uv.x() > 1.f || uv.y() < 0.f || uv.y() > 1.f)
continue;
centroids[p].reserve(its.indices.size() / 2);
front_facing[p].reserve(its.indices.size() / 2);
for (size_t f = 0; f < its.indices.size(); ++f) {
const stl_triangle_vertex_indices &tri = its.indices[f];
const Vec3d a = its.vertices[tri[0]].cast<double>();
const Vec3d b = its.vertices[tri[1]].cast<double>();
const Vec3d c = its.vertices[tri[2]].cast<double>();
const Vec3d n_world = normal_matrix * (b - a).cross(c - a);
if (n_world.squaredNorm() < 1e-20)
continue; // degenerate triangle: no meaningful normal, so no meaningful facing test
const Vec3d centroid_local = (a + b + c) / 3.0;
const Vec3d centroid_world = to_world * centroid_local;
const Vec3d view_dir = ortho ? fwd : Vec3d(centroid_world - eye);
if (n_world.dot(view_dir) >= 0.0)
continue; // facing away from the camera
// Clip to the projection frame before the raycast, not after: outside the frame the texture
// samples to nothing anyway, so those facets would only be painted to no effect - and this
// is also what keeps the ray queries proportional to the framed area instead of the model.
if (uv_clip != nullptr) {
Vec2f uv;
if (!project_uv_projective(*uv_clip, (to_owner * centroid_local).cast<float>(), uv))
continue; // behind the projector
if (uv.x() < 0.f || uv.x() > 1.f || uv.y() < 0.f || uv.y() > 1.f)
continue;
}
centroids[p].emplace_back(centroid_world.cast<float>());
front_facing[p].push_back(unsigned(f));
}
centroids.emplace_back(centroid_world.cast<float>());
front_facing.push_back(unsigned(f));
}
if (centroids.empty())
return 0;
// Pass 2 (the expensive one): a real ray query per surviving centroid, so geometry in front of a
// front-facing triangle correctly hides it - the far inner wall of a cup is front-facing but not
// visible. This is why the whole thing is click-driven rather than live.
std::vector<unsigned> unobscured;
// visible. This is why the whole thing is click-driven rather than live. Each raycaster only sees
// its own part's mesh, so a centroid has to be clear of every one of them; its own part goes first,
// since that is what hides most of them.
size_t visible = 0;
{
wxBusyCursor wait;
unobscured = m_c->raycaster()->raycasters()[size_t(idx)]->get_unobscured_idxs(
trafo, camera, centroids, m_c->object_clipper()->get_clipping_plane());
const ClippingPlane *clipping_plane = m_c->object_clipper()->get_clipping_plane();
for (size_t p = 0; p < parts.size(); ++p) {
std::vector<Vec3f> &points = centroids[p];
std::vector<unsigned> &facets = front_facing[p];
const auto keep_unobscured_by = [&](size_t q) {
const std::vector<unsigned> clear = raycasters[q]->get_unobscured_idxs(trafos[q], camera, points, clipping_plane);
std::vector<Vec3f> kept_points;
std::vector<unsigned> kept_facets;
kept_points.reserve(clear.size());
kept_facets.reserve(clear.size());
for (unsigned i : clear) {
kept_points.push_back(points[i]);
kept_facets.push_back(facets[i]);
}
points = std::move(kept_points);
facets = std::move(kept_facets);
};
if (!points.empty())
keep_unobscured_by(p);
for (size_t q = 0; q < parts.size() && !points.empty(); ++q)
if (q != p)
keep_unobscured_by(q);
visible += facets.size();
}
}
if (unobscured.empty())
if (visible == 0)
return 0;
Plater::TakeSnapshot snapshot(wxGetApp().plater(), _u8L("Select visible faces for texture displacement"),
UndoRedo::SnapshotType::GizmoAction);
// Replaces the layer's paint rather than adding to it: the checkbox means "project onto what I
// can see", so a second capture from a new angle should not leave the previous angle painted.
// Replaces the layer's paint rather than adding to it, on every part including those nothing was
// visible on: the checkbox means "project onto what I can see", so a second capture from a new
// angle should not leave the previous angle painted.
// TriangleSelectorGUI, not the TriangleSelector base: request_update_render_data() is declared on
// the GUI subclass, so binding to the base here would drop it.
TriangleSelectorGUI &selector = *m_triangle_selectors[size_t(idx)];
selector.reset();
for (unsigned i : unobscured)
selector.set_facet(int(front_facing[i]), EnforcerBlockerType::ENFORCER);
selector.request_update_render_data(true);
for (size_t p = 0; p < parts.size(); ++p) {
TriangleSelectorGUI &selector = *m_triangle_selectors[p];
selector.reset();
for (unsigned f : front_facing[p])
selector.set_facet(int(f), EnforcerBlockerType::ENFORCER);
selector.request_update_render_data(true);
}
update_model_object();
m_parent.set_as_dirty();
return int(unobscured.size());
return int(visible);
}
void GLGizmoTextureDisplacement::select_whole_model()
@@ -5190,27 +5401,31 @@ void GLGizmoTextureDisplacement::select_whole_model()
void GLGizmoTextureDisplacement::subdivide_model()
{
ModelVolume *mv = texture_volume();
ModelObject *mo = m_c->selection_info()->model_object();
if (mv == nullptr || mo == nullptr || m_subdivide_count < 1)
// Every model part, like the remesh: this is a plain mesh operation on the object, and a part left
// coarse is a part no height map can raise relief on.
const std::vector<ModelVolume *> parts = texture_parts();
if (mo == nullptr || parts.empty() || m_subdivide_count < 1)
return; // 0 passes means "no subdivision" - don't take a snapshot for a no-op
Plater *plater = wxGetApp().plater();
Plater::TakeSnapshot snapshot(plater, _u8L("Subdivide model for texture displacement"), UndoRedo::SnapshotType::GizmoAction);
// Same save/replace/restore-painting dance GLGizmoSimplify uses when it re-tessellates a
// volume's mesh: supported/seam/mmu/fuzzy-skin masks get remapped onto the new triangles,
// texture-displacement doesn't (no remap support for it yet) and is dropped instead of being
// left referring to triangle indices that no longer mean the same thing.
std::optional<TriangleSelector::SavedPainting> saved_painting = mv->save_painting();
for (ModelVolume *mv : parts) {
// Same save/replace/restore-painting dance GLGizmoSimplify uses when it re-tessellates a
// volume's mesh: supported/seam/mmu/fuzzy-skin masks get remapped onto the new triangles,
// texture-displacement doesn't (no remap support for it yet) and is dropped instead of being
// left referring to triangle indices that no longer mean the same thing.
std::optional<TriangleSelector::SavedPainting> saved_painting = mv->save_painting();
// max_edge_length 0 means "no triangle is ever small enough", so every non-degenerate edge is
// split on each of the m_subdivide_count passes - i.e. a plain "subdivide the whole mesh N times".
TriangleMesh new_mesh(subdivide_mesh_uniform(mv->mesh().its, 0.f, m_subdivide_count));
mv->set_mesh(std::move(new_mesh));
mv->set_new_unique_id();
mv->calculate_convex_hull();
mv->restore_painting(saved_painting);
// max_edge_length 0 means "no triangle is ever small enough", so every non-degenerate edge is
// split on each of the m_subdivide_count passes - i.e. a plain "subdivide the whole mesh N times".
TriangleMesh new_mesh(subdivide_mesh_uniform(mv->mesh().its, 0.f, m_subdivide_count));
mv->set_mesh(std::move(new_mesh));
mv->set_new_unique_id();
mv->calculate_convex_hull();
mv->restore_painting(saved_painting);
}
if (ObjectList *obj_list = wxGetApp().obj_list()) {
const ModelObjectPtrs &objs = plater->model().objects;
@@ -5855,13 +6070,35 @@ ColorQuantizeFn GLGizmoTextureDisplacement::make_palette_quantizer(const std::ve
TextureDisplacementPrepareResult GLGizmoTextureDisplacement::prepare_mesh(
const indexed_triangle_set &base, const TextureDisplacementFacetsData &masks,
const std::vector<TextureDisplacementLayer> &layers, const std::vector<Vec2f> &model_uv,
const TextureDisplacementPrepareParams &params,
const Transform3d &texture_frame, const TextureDisplacementPrepareParams &params,
const TextureColorSettings &color_settings, const DisplacementProgressFn &progress,
BakeStageRecorder *debug)
{
TextureDisplacementPrepareResult out;
const auto report = [&progress](int pct) { return !progress || progress(pct); };
// The samplers are built over a copy of the current mesh moved into `texture_frame`, and queried
// with points and normals mapped across, so the stages below go on measuring the mesh itself.
const bool framed = !texture_frame.matrix().isApprox(Transform3d::Identity().matrix());
const Matrix3d frame_normal = texture_frame.linear().inverse().transpose();
indexed_triangle_set framed_mesh;
const auto mesh_to_sample = [&](const indexed_triangle_set &its) -> const indexed_triangle_set & {
if (!framed)
return its;
framed_mesh = its;
for (Vec3f &v : framed_mesh.vertices)
v = (texture_frame * v.cast<double>()).cast<float>();
return framed_mesh;
};
const auto sample_in_frame = [&](auto sampler) -> decltype(sampler) {
if (!framed || !sampler)
return sampler;
return [sampler, frame = texture_frame, frame_normal](const Vec3f &pos, const Vec3f &normal) {
return sampler((frame * pos.cast<double>()).cast<float>(),
(frame_normal * normal.cast<double>()).normalized().cast<float>());
};
};
// Stage capture for the debug view. Timed from the end of the previous stage, so the capture -
// a copy plus an edge scan - never lands inside the measurement it reports.
auto stage_clock = std::chrono::steady_clock::now();
@@ -5937,17 +6174,20 @@ TextureDisplacementPrepareResult GLGizmoTextureDisplacement::prepare_mesh(
// length baseline alone.
const std::vector<Vec2f> uv_stage = uv_now();
const std::vector<Vec2f> *uv_ptr = uv_stage.empty() ? nullptr : &uv_stage;
const indexed_triangle_set &sampled = mesh_to_sample(mesh.its);
HeightFieldSampler sampler;
if (params.subdiv_feature)
sampler = make_combined_displacement_sampler(mesh.its, layers, current, uv_ptr);
sampler = sample_in_frame(make_combined_displacement_sampler(sampled, layers, current, uv_ptr));
// Colour boundaries need triangles of their own - the chord test cannot see them, since
// the height field is perfectly smooth across a change of filament.
ColorFieldSampler color;
// A flat-colour layer is matched against single filaments only, as the bake does, so its
// boundaries are refined where the bake will actually change filament.
if (params.subdiv_color_edge_mm > 0.f && !color_settings.empty())
color = make_combined_color_sampler(mesh.its, layers, current, make_palette_quantizer(color_settings.palette),
make_palette_quantizer(color_settings.palette_pure), uv_ptr);
color = sample_in_frame(make_combined_color_sampler(sampled, layers, current,
make_palette_quantizer(color_settings.palette),
make_palette_quantizer(color_settings.palette_pure),
uv_ptr));
// The refinement follows perceived colour: a mix is one colour here, however the slicer
// interleaves its filaments layer by layer.
// "Min edge" is a feature-mode control (it is the floor the curvature test refines down
@@ -6008,8 +6248,8 @@ TextureDisplacementPrepareResult GLGizmoTextureDisplacement::prepare_mesh(
cut_region[i] = (region[size_t(source[i])] & REFINE_PAINTED) ? 1 : 0;
const std::vector<Vec2f> uv_refined = uv_now();
const HeightFieldSampler refined_sampler =
make_combined_displacement_sampler(mesh.its, layers, current,
uv_refined.empty() ? nullptr : &uv_refined);
sample_in_frame(make_combined_displacement_sampler(mesh_to_sample(mesh.its), layers, current,
uv_refined.empty() ? nullptr : &uv_refined));
std::vector<int> cut_source;
size_t cuts = 0;
indexed_triangle_set cut = cut_mesh_at_steps(mesh.its, cut_region, refined_sampler,
@@ -6047,7 +6287,7 @@ void GLGizmoTextureDisplacement::subdivide_model_adaptive()
update_model_object(); // flush any in-progress stroke into the committed masks first
if (!mv->is_texture_displacement_painted()) {
if (!any_part_painted()) {
show_error(nullptr, _u8L("Paint the area you want to subdivide first - adaptive subdivision only "
"refines where you have painted."));
return;
@@ -6209,7 +6449,8 @@ void GLGizmoTextureDisplacement::remesh_model()
params.remesh_sharp_deg = m_remesh_keep_sharp_edges ? m_remesh_sharp_angle_deg : 0.f;
queue_prepare(params, _u8L("Remesh model for texture displacement"), /* then_bake */ false,
_u8L("Remeshing did not change the model. It may be non-manifold (open edges or edges "
"shared by more than two triangles), or the target edge length may already be met."));
"shared by more than two triangles), or the target edge length may already be met."),
PrepareScope::AllParts);
}
void GLGizmoTextureDisplacement::rebuild_subdivide_preview()
@@ -6360,23 +6601,35 @@ GLTexture *GLGizmoTextureDisplacement::get_layer_height_texture(const TextureDis
void GLGizmoTextureDisplacement::bake(bool own_snapshot)
{
ModelVolume *mv = texture_volume();
if (!mv || m_bake_in_progress)
ModelVolume *owner = texture_volume();
if (owner == nullptr || m_bake_in_progress)
return;
// Make sure the currently active layer's in-progress edits are flushed into the model before
// baking, otherwise the most recent, not-yet-committed strokes would be silently skipped.
update_model_object();
if (!mv->is_texture_displacement_painted()) {
// Every painted part, not only the one holding the layer stack: the brush paints across all of
// them (see update_model_object()), so baking one would leave the user's paint on the others
// untouched and their geometry flat.
const std::vector<ModelVolume *> parts = painted_parts();
if (parts.empty()) {
show_error(nullptr, _u8L("Nothing is painted, there is nothing to bake."));
return;
}
const Vec3d anchor = texture_anchor();
const std::vector<float> drops = resting_drops(parts);
std::vector<TextureDisplacementBakeVolume> volumes;
volumes.reserve(parts.size());
for (size_t i = 0; i < parts.size(); ++i)
volumes.push_back(texture_displacement_bake_volume(*parts[i], layers_for_part(*parts[i]),
owner->texture_displacement_options, anchor, drops[i]));
m_bake_in_progress = true;
queue_texture_displacement_bake(*mv, color_settings_for(*mv), [this]() {
queue_texture_displacement_bake(std::move(volumes), color_settings_for(*owner), [this]() {
m_bake_in_progress = false;
// Baking replaces the volume's mesh (new id, new topology) without changing the object's
// Baking replaces each baked part's mesh (new id, new topology) without changing the object's
// id or volume count, so GLGizmoPainterBase::data_changed()'s usual change-detection never
// notices it needs to reload - do it explicitly here, otherwise the gizmo keeps painting
// and rendering against the stale, pre-bake TriangleSelectorPatch until the object is
@@ -6448,7 +6701,7 @@ void GLGizmoTextureDisplacement::bake_standard()
return;
update_model_object(); // flush the active layer's in-progress strokes before anything reads them
if (!mv->is_texture_displacement_painted()) {
if (!any_part_painted()) {
show_error(nullptr, _u8L("Nothing is painted, there is nothing to bake."));
return;
}
@@ -6520,7 +6773,8 @@ void GLGizmoTextureDisplacement::run_stage_debug()
m_debug_in_progress = true;
queue_texture_displacement_debug(
*mv, color_settings_for(*mv), params, run_prepare, m_debug_check_topology,
*mv, color_settings_for(*mv), params, prepare_texture_frame(*mv), resting_drops({mv}).front(), run_prepare,
m_debug_check_topology,
[this](std::vector<BakeStageSnapshot> stages) {
m_debug_in_progress = false;
if (!stages.empty()) {
@@ -6548,6 +6802,9 @@ void GLGizmoTextureDisplacement::show_debug_stage(int index)
m_use_shaded_preview = false;
m_preview_color_runs.clear();
m_preview_glmodel.reset();
// The stage is a capture of one part's run, so the other parts go back to being drawn as themselves
// rather than keeping a live preview the captured stages say nothing about.
m_part_previews.clear();
indexed_triangle_set its;
its.vertices = stage.mesh.vertices;
@@ -6669,23 +6926,36 @@ void GLGizmoTextureDisplacement::render_debug_stage_panel(ModelVolume *mv)
void GLGizmoTextureDisplacement::queue_prepare(const TextureDisplacementPrepareParams &params,
const std::string &snapshot_name, bool then_bake,
const std::string &unchanged_msg)
const std::string &unchanged_msg, PrepareScope scope)
{
ModelVolume *mv = texture_volume();
if (mv == nullptr || m_prepare_in_progress || m_bake_in_progress)
ModelVolume *owner = texture_volume();
if (owner == nullptr || m_prepare_in_progress || m_bake_in_progress)
return;
// Preparing only the layer stack's own part would hand the displacement a coarse mesh everywhere
// else - a height map can only move vertices that already exist, so on a multibody object that is
// the difference between relief and a flat face. See PrepareScope for which parts that means.
const std::vector<ModelVolume *> parts = scope == PrepareScope::AllParts ? texture_parts() : painted_parts();
if (parts.empty())
return;
TextureDisplacementPrepareInput input;
input.volume_id = mv->id();
input.base_mesh = mv->mesh().its;
input.masks = facets_data_of(*mv);
input.layers = mv->texture_displacement_layers;
input.params = params;
input.snapshot_name = snapshot_name;
if (mv->has_texture_uv())
input.model_uv = mv->texture_uv;
input.volumes.reserve(parts.size());
for (const ModelVolume *part : parts) {
TextureDisplacementPrepareVolume entry;
entry.volume_id = part->id();
entry.base_mesh = part->mesh().its;
entry.masks = facets_data_of(*part);
entry.layers = layers_for_part(*part);
if (part->has_texture_uv())
entry.model_uv = part->texture_uv;
entry.texture_frame = prepare_texture_frame(*part);
input.volumes.push_back(std::move(entry));
}
if (params.subdiv_color_edge_mm > 0.f)
input.color = color_settings_for(*mv);
input.color = color_settings_for(*owner);
m_prepare_in_progress = true;
queue_texture_displacement_prepare(std::move(input), [this, then_bake, unchanged_msg](
@@ -8399,9 +8669,20 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
const float footer_top = ImGui::GetCursorScreenPos().y;
ImGui::Separator();
{
const float x0 = ImGui::GetCursorPosX();
const int base_k = mv != nullptr ? int((mv->mesh().facets_count() + 500) / 1000) : 0;
const bool v2 = mv != nullptr && mv->texture_displacement_options.pipeline_v2;
const float x0 = ImGui::GetCursorPosX();
// Over every part the bake will act on, not just the one holding the layer stack, so the count
// the footer quotes is the count Bake is actually working with on a multibody object. Before
// anything is painted there is nothing to bake yet, and the whole object is the honest estimate.
size_t base_facets = 0;
{
std::vector<ModelVolume *> counted = painted_parts();
if (counted.empty())
counted = texture_parts();
for (const ModelVolume *part : counted)
base_facets += part->mesh().facets_count();
}
const int base_k = int((base_facets + 500) / 1000);
const bool v2 = mv != nullptr && mv->texture_displacement_options.pipeline_v2;
// The triangle budget is the one subdivision control Standard mode keeps: its right value depends on
// the part rather than on the recipe (a big model, or a fine texture, simply needs more of them), and
@@ -8534,7 +8815,9 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
ImGui::SameLine(x0 + panel_w - button_w * 2 - style.ItemSpacing.x);
const bool can_bake = !busy && mv != nullptr && mv->is_texture_displacement_painted();
// Read before Close below, which releases the selection any_part_painted() finds the parts through.
const bool painted = mv != nullptr && any_part_painted();
const bool can_bake = !busy && painted;
const std::string bake_label = m_prepare_in_progress ? _u8L("Preparing...") :
m_bake_in_progress ? _u8L("Baking...") :
into_u8(m_desc.at("bake"));
@@ -8554,7 +8837,7 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
m_imgui->pop_bold_font();
GLGizmoUtils::pop_orca_button_style();
if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled))
panel_tooltip(mv != nullptr && !mv->is_texture_displacement_painted() ?
panel_tooltip(mv != nullptr && !painted ?
(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.") :
@@ -8580,8 +8863,8 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
// What Bake will produce, and which layers it will skip.
if (mv != nullptr) {
const std::string base_count = base_k > 0 ? Slic3r::format(_u8L("%1% k"), base_k) :
std::to_string(mv->mesh().facets_count());
const unsigned base_n = base_k > 0 ? unsigned(base_k) * 1000 : unsigned(mv->mesh().facets_count());
std::to_string(base_facets);
const unsigned base_n = base_k > 0 ? unsigned(base_k) * 1000 : unsigned(base_facets);
const unsigned result_n = unsigned(base_k + m_subdivide_budget_k) * 1000;
// TRN %1% is a triangle count, either exact or rounded to thousands such as "120 k".
std::string note = v2 ? Slic3r::format(into_u8(_L_PLURAL("Model: %1% triangle - the bake refines to the resolution above, up to its budget",
@@ -8591,7 +8874,7 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
"Bake moves existing vertices - the model stays at %1% triangles", base_n)), base_count) :
Slic3r::format(into_u8(_L_PLURAL("Bake result up to ~%1% k triangle", "Bake result up to ~%1% k triangles", result_n)),
base_k + m_subdivide_budget_k);
if (mv->is_texture_displacement_painted()) {
if (painted) {
std::vector<std::string> skipped;
for (const TextureDisplacementLayer &l : mv->texture_displacement_layers)
if (!slot_painted(l.slot))
@@ -15,6 +15,7 @@
#include <array>
#include <atomic>
#include "libslic3r/Color.hpp"
#include "libslic3r/ObjectID.hpp"
#include <cstddef>
#include "libslic3r/TriangleSelector.hpp"
#include "libslic3r/TriangleMesh.hpp"
@@ -69,6 +70,14 @@ public:
// to the samplers so a ModelUV layer drives
// the refinement through its own layout.
const std::vector<Vec2f> &model_uv,
// `base`'s coordinates -> the frame the bake
// samples the texture in, so the refinement
// follows the texture the bake will apply.
// Only the sampling moves into it: the mesh,
// and every length in `params`, stay in
// `base`'s own coordinates. Identity samples
// there as well.
const Transform3d &texture_frame,
const TextureDisplacementPrepareParams &params,
const TextureColorSettings &color_settings,
const DisplacementProgressFn &progress,
@@ -185,6 +194,35 @@ public:
ColorRGBA color;
};
std::vector<PreviewColorRun> m_preview_color_runs;
// The displaced preview of each painted model part *other* than the one owning the layer stack. That
// part keeps m_preview_glmodel and m_preview_its, which the paint overlays, the wireframe and the UV
// and seam editors are all keyed to; the remaining parts of a multibody object only need their
// geometry drawn, so that is all this holds. Built by the same preview job, one entry per part.
//
// Held behind unique_ptr because GLModel owns GPU buffers and has no move constructor: a vector of
// them by value would copy the handles on reallocation and free them twice.
struct PartPreview
{
ObjectID volume_id;
GLModel glmodel;
std::vector<PreviewColorRun> color_runs;
};
std::vector<std::unique_ptr<PartPreview>> m_part_previews;
// The part `preview` was built for, or nullptr if it has gone away or had its mesh replaced since -
// in which case that part's own volume is drawn instead of a preview that no longer describes it.
const ModelVolume *preview_part(const PartPreview &preview) const;
// Whether the true-displacement view has anything to draw. Asking m_preview_glmodel alone would
// hide the real volumes behind an empty preview whenever only another part is painted.
bool has_true_preview() const { return m_preview_glmodel.is_initialized() || !m_part_previews.empty(); }
// The preview mesh as it is uploaded: when the run reported a palette entry per triangle, the index
// buffer is reordered so that each palette colour becomes one contiguous draw range (returned in
// `runs`); otherwise `its` is handed back unchanged and `runs` stays empty. Only the index buffer is
// permuted, never the vertices - see PreviewColorRun.
static indexed_triangle_set group_preview_by_color(const indexed_triangle_set &its,
const std::vector<uint8_t> &triangle_color,
const std::vector<ColorRGBA> &entry_colors,
std::vector<PreviewColorRun> &runs);
// True if any of the volume's layers would actually colour something: colour turned on, and a
// texture that has colour to give. What decides whether a palette is captured into a job at all,
// and so whether the colour criterion and the mmu write ever run.
@@ -258,12 +296,46 @@ private:
void on_shutdown() override;
PainterGizmoType get_painter_type() const override;
// Phase 1 restricts the texture layer list to the first model-part volume of the current
// object (the common single-volume case); multi-part objects only get texture layers on
// their first part until a later phase. Returns nullptr if there is no model part.
// The model part that owns the object's texture layer stack, its options, and the unwrap the UV and
// seam editors work on: the first model part of the current object. Those are object-wide settings
// kept on one part rather than copied onto all of them. Returns nullptr if there is no model part.
//
// This is *not* the set of parts a bake acts on - see painted_parts(). Reading the layer stack and
// baking are different questions, and answering the second one with this is what made texture
// displacement ignore every part of a multibody object but the first.
ModelVolume* texture_volume();
const ModelVolume* texture_volume() const;
// Every model part of the current object, in the object's own volume order - which is also the
// order update_from_model_object() builds m_triangle_selectors in, so index i here is selector i.
std::vector<ModelVolume *> texture_parts();
// The parts a bake or a preview acts on: those of texture_parts() carrying paint in at least one
// slot. Empty when nothing is painted anywhere on the object, which is what the Bake button and its
// "nothing is painted" check test.
std::vector<ModelVolume *> painted_parts();
bool any_part_painted() const;
// The object's layer stack as it applies to `part`. The stack itself lives on texture_volume(), but
// four of each layer's fields index *that* part's mesh and the chart ids of *its* unwrap - the LSCM
// seam edges, the island placements, their join groups and the per-vertex uv overrides. Carried onto
// another part they would address triangles and charts that have nothing to do with it, so they are
// dropped and that part unwraps on its own. Everything else - the image, depth, tiling, projection
// method, blend mode, colour - applies unchanged, since every part is baked in the one texture frame
// anchored at texture_anchor(); that is what lets one stack texture a whole multibody object.
std::vector<TextureDisplacementLayer> layers_for_part(const ModelVolume &part) const;
// The world point the object's texture is anchored at: texture_volume()'s origin. Every part is
// baked and previewed in the frame anchored there (see texture_displacement_bake_frame()), so a
// tiled pattern runs on across the parts and a view projection lands where it was aimed on each.
Vec3d texture_anchor() const;
// For each of `parts`, at the same index: how far the object's lowest point lies below that part's
// own, in mm - what its bake lowers its resting plane by, so every part is clamped against the one
// plane the object rests on. 0 for the lowest part, and so for a single-part object.
std::vector<float> resting_drops(const std::vector<ModelVolume *> &parts);
// Where prepare_mesh() samples `part`'s texture: the frame its bake samples in when the object has
// several model parts, so they are all refined for one continuous texture. A single-part object
// keeps the identity, the frame it has always been refined in.
Transform3d prepare_texture_frame(const ModelVolume &part);
void add_texture_layer();
void remove_texture_layer(int slot);
// Moves the layer in `slot` to position `to_index` of the slot-ordered stack (an insertion index,
@@ -310,8 +382,14 @@ private:
// - `unchanged_msg`, when not empty, is shown if the run had nothing to do. Standard's Bake passes
// nothing: a mesh that already meets the criteria is not an error there, it just goes straight
// on to the displacement.
// - `scope` is which of the object's model parts to prepare. Anything feeding a displacement
// prepares exactly the parts that displacement will move; a plain Remesh is a mesh operation on
// the object and takes all of them, so painting the rest of a multibody object afterwards has a
// dense enough mesh to show relief on.
enum class PrepareScope { PaintedParts, AllParts };
void queue_prepare(const TextureDisplacementPrepareParams &params, const std::string &snapshot_name,
bool then_bake, const std::string &unchanged_msg);
bool then_bake, const std::string &unchanged_msg,
PrepareScope scope = PrepareScope::PaintedParts);
// Set from queue_prepare() until its job's result has been committed. Distinct from
// m_bake_in_progress because Standard's Bake sets both in turn, and because every button that would
// read or replace the mesh has to stay disabled for the whole of it.
@@ -351,14 +429,14 @@ private:
// volume's slot among them, or -1 if it has none (no selection, or the lists disagree).
int texture_volume_raycaster_index() const;
// Paints exactly the facets currently visible from the camera onto the active layer, replacing
// whatever that layer had painted. "Visible" is two tests: the facet faces the camera, and its
// centroid is not hidden behind other geometry (a real raycast, so a concave part's far inner
// wall is correctly excluded). When `uv_clip` is given (the projection frame's matrix), facets
// whose centroid falls outside the frame's uv unit square are skipped first - which both clips
// the selection to the frame and spares the raycast for everything outside it. Costs one ray
// query per surviving facet, so it is a one-shot action, never a per-frame one. Returns the
// number of facets selected.
// Paints exactly the facets currently visible from the camera onto the active layer, on every model
// part, replacing whatever that layer had painted on each. "Visible" is two tests: the facet faces
// the camera, and its centroid is not hidden behind other geometry of any part (a real raycast, so
// a concave part's far inner wall is correctly excluded). When `uv_clip` is given (the projection
// frame's matrix), facets whose centroid falls outside the frame's uv unit square are skipped first
// - which both clips the selection to the frame and spares the raycast for everything outside it.
// Costs one ray query per surviving facet and part, so it is a one-shot action, never a per-frame
// one. Returns the number of facets selected.
int select_visible_faces(const std::array<float, 12> *uv_clip = nullptr);
// When set, "Capture current view" also re-selects the visible faces, so the viewpoint the
+220 -148
View File
@@ -6,6 +6,7 @@
#include <string>
#include <vector>
#include "libslic3r/TextureDisplacement.hpp"
#include "libslic3r/Point.hpp"
#include <exception>
#include <cstddef>
#include <cstdint>
@@ -50,6 +51,9 @@ void TextureDisplacementBakeJob::process(Ctl &ctl)
// bake, which on a subdivided mesh can run for several seconds.
// Colour, when any layer asks for it, is computed in the same pass as the displacement: both need
// the same per-layer projection and UV work, and doing it twice would double the expensive part.
//
// The quantizers are built once for the whole job rather than per part: each fills a colour lookup
// cube that costs tens of milliseconds, and the palette is the object's, not the part's.
TextureColorRequest color_request;
TextureColorRequest *color = nullptr;
if (!m_input.color.empty()) {
@@ -57,27 +61,38 @@ void TextureDisplacementBakeJob::process(Ctl &ctl)
if (!m_input.color.palette_pure.empty())
color_request.quantize_pure = GLGizmoTextureDisplacement::make_palette_quantizer(m_input.color.palette_pure);
color_request.despeckle_passes = m_input.color.despeckle_passes;
color_request.out_triangle = &m_triangle_color;
if (color_request.quantize)
color = &color_request;
}
// Each part gets its own slice of the 1..100 range, in order, so one progress bar covers the whole
// bake however many parts it displaces.
const size_t count = m_input.volumes.size();
m_results.resize(count);
int last_reported = 1;
m_result = TriangleMesh(build_texture_displacement(
m_input.base_mesh, m_input.layers, m_input.facets_data, m_input.options,
[&ctl, &status, &last_reported](int percent) {
if (ctl.was_canceled())
return false;
// The notification repaints (and wakes the idle loop) on every call, so only push a
// message when the displayed integer percentage actually moves.
if (percent > last_reported) {
last_reported = percent;
ctl.update_status(percent, status);
}
return true;
},
color, m_input.volume_to_world, nullptr, &m_stats,
m_input.model_uv.empty() ? nullptr : &m_input.model_uv));
for (size_t i = 0; i < count; ++i) {
const TextureDisplacementBakeVolume &volume = m_input.volumes[i];
if (color != nullptr)
color_request.out_triangle = &m_results[i].triangle_color;
m_results[i].mesh = TriangleMesh(build_texture_displacement(
volume.base_mesh, volume.layers, volume.facets_data, volume.options,
[&ctl, &status, &last_reported, i, count](int percent) {
if (ctl.was_canceled())
return false;
// The notification repaints (and wakes the idle loop) on every call, so only push a
// message when the displayed integer percentage actually moves.
const int overall = int((i * 100 + size_t(std::max(0, percent))) / count);
if (overall > last_reported) {
last_reported = overall;
ctl.update_status(overall, status);
}
return true;
},
color, volume.volume_to_world, nullptr, &m_results[i].stats,
volume.model_uv.empty() ? nullptr : &volume.model_uv, &volume.texture_anchor, volume.resting_drop));
if (ctl.was_canceled())
break;
}
// Always finish at 100: this is what closes the notification. Reported even on cancel, where
// build_texture_displacement() returns an empty mesh and finalize() commits nothing.
@@ -92,147 +107,184 @@ void TextureDisplacementBakeJob::finalize(bool canceled, std::exception_ptr &ept
~OnExit() { if (fn) fn(); }
} on_exit{m_on_finished};
if (canceled || eptr || m_result.empty())
if (canceled || eptr)
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;
}
// Judged per part, because on a multibody object only some parts may carry a layer that displaces;
// the ones that did move are committed and the rest are left exactly as they were.
const auto moved = [this](size_t i) {
const TriangleMesh &result = m_results[i].mesh;
if (result.empty())
return false;
const indexed_triangle_set &out = result.its;
const indexed_triangle_set &base = m_input.volumes[i].base_mesh;
bool unchanged = out.indices.size() == base.indices.size() && out.vertices.size() == base.vertices.size();
for (size_t v = 0; unchanged && v < out.vertices.size(); ++v)
unchanged = (out.vertices[v] - base.vertices[v]).cwiseAbs().maxCoeff() < 1e-5f;
return !unchanged;
};
std::vector<size_t> to_commit;
for (size_t i = 0; i < m_results.size(); ++i)
if (moved(i))
to_commit.push_back(i);
if (to_commit.empty()) {
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]() {
ModelVolume *volume = get_model_volume(m_input.volume_id, plater->model().objects);
if (volume == nullptr)
return;
// The filament each palette entry prints in. This is where a mix becomes a mixed filament slot,
// and only a mix the bake actually painted with: the preview never creates one, so the project
// gains only the slots this result needs. Done before anything below names them - adding a slot
// runs ModelVolume::update_extruder_count(), which clamps paint above the old filament count.
// The filament each palette entry prints in. This is where a mix becomes a mixed filament slot,
// and only a mix the bake actually painted with: the preview never creates one, so the project
// gains only the slots this result needs. Decided once for the whole bake, over the entries every
// part used between them, so the same mix cannot end up in two slots. Done before anything below
// names them - adding a slot runs ModelVolume::update_extruder_count(), which clamps paint above
// the old filament count.
const auto filaments_for_palette = [this, plater, &to_commit]() {
// palette_filaments() only cares *which* entries appear, so the union of the parts' entries
// stands in for their concatenated per-triangle colours.
std::vector<char> seen(m_input.color.palette.size() + 1, 0);
std::vector<uint8_t> used;
for (const size_t i : to_commit)
for (const uint8_t entry : m_results[i].triangle_color)
if (entry > 0 && size_t(entry) < seen.size() && !seen[size_t(entry)]) {
seen[size_t(entry)] = 1;
used.push_back(entry);
}
Sidebar &sidebar = plater->sidebar();
const std::vector<int> filament_of = GLGizmoTextureDisplacement::palette_filaments(
m_input.color.palette, m_triangle_color, [&sidebar](const PrintableColor &mix) {
return GLGizmoTextureDisplacement::palette_filaments(
m_input.color.palette, used, [&sidebar](const PrintableColor &mix) {
return sidebar.ensure_mixed_filament({ unsigned(mix.a + 1), unsigned(mix.b + 1) },
{ mix.a_percent(), 100 - mix.a_percent() });
});
};
// Whether the bake replaced the triangles or only moved the vertices it was given. The classic
// path is topology-preserving by contract, so every per-triangle annotation on the volume still
// means what it did; the default pipeline refines, cuts and simplifies, so none of them do.
// Both are checked: the flag is what the bake promised, the count is what it delivered. Only
// the triangles matter - the annotations are per facet and reference no vertex index - so a
// vertex count that changed on its own (the bake compacts unreferenced vertices away) is not a
// reason to remap anything.
const bool topology_changed = m_input.options.pipeline_v2 ||
m_result.its.indices.size() != m_input.base_mesh.indices.size();
const auto commit = [this, plater, &to_commit, &filaments_for_palette]() {
const std::vector<int> filament_of = filaments_for_palette();
ModelObject *object = nullptr;
// Carry the volume's existing paint - the model's own colours above all - across that rebuild.
// Without it a bake left the mmu_segmentation_facets bitstream pointing at a triangle set that
// no longer existed, which is what scrambled the colours of an imported coloured model even
// with colour switched off on every layer and nothing in the bake that was meant to touch them.
//
// transfer_facet_paint() rather than ModelVolume::restore_painting(): the latter's remap wants
// a target triangle overlapping the source paint to within 0.01 mm, which the baked surface is
// nowhere near - it has moved by the relief depth. Nothing to do when the topology is intact:
// triangle i is still triangle i, so the paint is already exactly right and carrying it at all
// could only lose detail.
std::array<TriangleSelector::TriangleSplittingData, 4> before;
if (topology_changed) {
before[0] = volume->supported_facets.get_data();
before[1] = volume->seam_facets.get_data();
before[2] = volume->mmu_segmentation_facets.get_data();
before[3] = volume->fuzzy_skin_facets.get_data();
}
for (const size_t i : to_commit) {
ModelVolume *volume = get_model_volume(m_input.volumes[i].volume_id, plater->model().objects);
// The lookup doubles as a staleness check: anything that replaces a volume's mesh also gives it
// a new id, so a part replaced since this bake started - by an undo, another bake, or a
// boolean - simply fails to find its volume and is skipped.
if (volume == nullptr)
continue;
const TextureDisplacementBakeVolume &input = m_input.volumes[i];
const std::vector<uint8_t> &triangle_color = m_results[i].triangle_color;
volume->set_mesh(std::move(m_result));
volume->set_new_unique_id();
volume->calculate_convex_hull();
// Whether the bake replaced the triangles or only moved the vertices it was given. The classic
// path is topology-preserving by contract, so every per-triangle annotation on the volume still
// means what it did; the default pipeline refines, cuts and simplifies, so none of them do.
// Both are checked: the flag is what the bake promised, the count is what it delivered. Only
// the triangles matter - the annotations are per facet and reference no vertex index - so a
// vertex count that changed on its own (the bake compacts unreferenced vertices away) is not a
// reason to remap anything.
const bool topology_changed = input.options.pipeline_v2 ||
m_results[i].mesh.its.indices.size() != input.base_mesh.indices.size();
if (topology_changed) {
const std::array<FacetsAnnotation *, 4> target{ &volume->supported_facets, &volume->seam_facets,
&volume->mmu_segmentation_facets,
&volume->fuzzy_skin_facets };
for (size_t ch = 0; ch < target.size(); ++ch) {
const std::vector<uint8_t> state =
transfer_facet_paint(m_input.base_mesh, before[ch], volume->mesh().its);
// Nothing was painted in this channel, or there is nothing to paint it onto: either
// way the annotation must not keep describing the mesh that is gone.
if (state.empty()) {
target[ch]->reset();
continue;
}
TriangleSelector selector(volume->mesh());
bool any = false;
for (size_t i = 0; i < state.size(); ++i)
if (state[i] != 0) {
selector.set_facet(int(i), EnforcerBlockerType(state[i]));
any = true;
// Carry the volume's existing paint - the model's own colours above all - across that rebuild.
// Without it a bake left the mmu_segmentation_facets bitstream pointing at a triangle set that
// no longer existed, which is what scrambled the colours of an imported coloured model even
// with colour switched off on every layer and nothing in the bake that was meant to touch them.
//
// transfer_facet_paint() rather than ModelVolume::restore_painting(): the latter's remap wants
// a target triangle overlapping the source paint to within 0.01 mm, which the baked surface is
// nowhere near - it has moved by the relief depth. Nothing to do when the topology is intact:
// triangle i is still triangle i, so the paint is already exactly right and carrying it at all
// could only lose detail.
std::array<TriangleSelector::TriangleSplittingData, 4> before;
if (topology_changed) {
before[0] = volume->supported_facets.get_data();
before[1] = volume->seam_facets.get_data();
before[2] = volume->mmu_segmentation_facets.get_data();
before[3] = volume->fuzzy_skin_facets.get_data();
}
volume->set_mesh(std::move(m_results[i].mesh));
volume->set_new_unique_id();
volume->calculate_convex_hull();
if (topology_changed) {
const std::array<FacetsAnnotation *, 4> target{ &volume->supported_facets, &volume->seam_facets,
&volume->mmu_segmentation_facets,
&volume->fuzzy_skin_facets };
for (size_t ch = 0; ch < target.size(); ++ch) {
const std::vector<uint8_t> state =
transfer_facet_paint(input.base_mesh, before[ch], volume->mesh().its);
// Nothing was painted in this channel, or there is nothing to paint it onto: either
// way the annotation must not keep describing the mesh that is gone.
if (state.empty()) {
target[ch]->reset();
continue;
}
if (any)
target[ch]->set(selector);
else
target[ch]->reset();
TriangleSelector selector(volume->mesh());
bool any = false;
for (size_t t = 0; t < state.size(); ++t)
if (state[t] != 0) {
selector.set_facet(int(t), EnforcerBlockerType(state[t]));
any = true;
}
if (any)
target[ch]->set(selector);
else
target[ch]->reset();
}
}
}
// Colour lands in mmu_segmentation_facets, merged *over* whatever is already painted there
// rather than replacing it: a triangle the texture does not colour keeps its existing filament,
// and one the user never painted at all stays at NONE, which already means "the volume's own
// filament". That is what confines the effect to the painted area without having to invent a
// colour for everything outside it. What was already painted is whatever the transfer above
// left in the annotation, so this bake's colour lands on top of it rather than instead of it.
if (!m_triangle_color.empty() && m_triangle_color.size() == volume->mesh().its.indices.size()) {
TriangleSelector selector(volume->mesh());
const TriangleSelector::TriangleSplittingData &existing = volume->mmu_segmentation_facets.get_data();
if (!existing.bitstream.empty())
selector.deserialize(existing, false);
for (size_t i = 0; i < m_triangle_color.size(); ++i) {
const size_t entry = size_t(m_triangle_color[i]);
if (entry > 0 && entry <= filament_of.size() && filament_of[entry - 1] >= 0)
selector.set_facet(int(i), EnforcerBlockerType(filament_of[entry - 1] + 1));
// Colour lands in mmu_segmentation_facets, merged *over* whatever is already painted there
// rather than replacing it: a triangle the texture does not colour keeps its existing filament,
// and one the user never painted at all stays at NONE, which already means "the volume's own
// filament". That is what confines the effect to the painted area without having to invent a
// colour for everything outside it. What was already painted is whatever the transfer above
// left in the annotation, so this bake's colour lands on top of it rather than instead of it.
if (!triangle_color.empty() && triangle_color.size() == volume->mesh().its.indices.size()) {
TriangleSelector selector(volume->mesh());
const TriangleSelector::TriangleSplittingData &existing = volume->mmu_segmentation_facets.get_data();
if (!existing.bitstream.empty())
selector.deserialize(existing, false);
for (size_t t = 0; t < triangle_color.size(); ++t) {
const size_t entry = size_t(triangle_color[t]);
if (entry > 0 && entry <= filament_of.size() && filament_of[entry - 1] >= 0)
selector.set_facet(int(t), EnforcerBlockerType(filament_of[entry - 1] + 1));
}
volume->mmu_segmentation_facets.set(selector);
}
volume->mmu_segmentation_facets.set(selector);
// 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 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().
for (int slot = 0; slot < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++slot) {
const auto it = std::find_if(input.layers.begin(), input.layers.end(),
[slot](const TextureDisplacementLayer &l) { return l.slot == slot; });
if (topology_changed || (it != input.layers.end() && !it->empty()))
volume->texture_displacement_facet(slot).reset();
}
// The model's own UV channel. Resampled onto a rebuilt mesh from the surface the bake started
// from: a displaced vertex came from its foot point on that surface, which is the position its
// uv belongs to - so no distance limit, the relief depth is exactly how far it has moved.
if (!input.model_uv.empty())
volume->texture_uv = topology_changed ? transfer_uv_channel(input.base_mesh, input.model_uv,
volume->mesh().its) :
input.model_uv;
if (object == nullptr)
object = volume->get_object();
}
// 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 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().
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();
}
// The model's own UV channel. Resampled onto a rebuilt mesh from the surface the bake started
// from: a displaced vertex came from its foot point on that surface, which is the position its
// uv belongs to - so no distance limit, the relief depth is exactly how far it has moved.
if (!m_input.model_uv.empty())
volume->texture_uv = topology_changed ? transfer_uv_channel(m_input.base_mesh, m_input.model_uv,
volume->mesh().its) :
m_input.model_uv;
ModelObject *object = volume->get_object();
if (object == nullptr)
return;
@@ -261,8 +313,15 @@ void TextureDisplacementBakeJob::finalize(bool canceled, std::exception_ptr &ept
// The refinement went finer than the budget could keep: the simplification had to take detail back
// out to fit, so what was baked carries less of the texture than the resolution asked for. Said
// here, with the numbers, because it is the only place that knows them - and not as an error
// dialog: the result is a usable mesh, just not the one the settings described.
if (m_stats.budget_limited) {
// dialog: the result is a usable mesh, just not the one the settings described. Summed over the
// parts that hit it, since the budget is spent per part and one message is enough.
size_t refined = 0, budget = 0;
for (const size_t i : to_commit)
if (m_results[i].stats.budget_limited) {
refined += m_results[i].stats.triangles_refined;
budget += m_results[i].stats.triangles_budget;
}
if (refined > 0) {
// Thousands under a million, so a small budget is not reported as "0.1 M".
const auto count = [](size_t n) {
return n >= 1000000 ? Slic3r::format("%1$.1f M", double(n) / 1000000.) :
@@ -272,26 +331,39 @@ void TextureDisplacementBakeJob::finalize(bool canceled, std::exception_ptr &ept
NotificationType::CustomNotification, NotificationManager::NotificationLevel::WarningNotificationLevel,
Slic3r::format(_u8L("The triangle budget limited the detail: this resolution needs %1% triangles, "
"the budget kept %2%. Raise Budget or use a coarser Resolution for the full detail."),
count(m_stats.triangles_refined), count(m_stats.triangles_budget)));
count(refined), count(budget)));
}
}
void queue_texture_displacement_bake(const ModelVolume &volume, const TextureColorSettings &color,
TextureDisplacementBakeVolume texture_displacement_bake_volume(const ModelVolume &volume,
const std::vector<TextureDisplacementLayer> &layers,
const TextureDisplacementOptions &options,
const Vec3d &texture_anchor,
float resting_drop)
{
TextureDisplacementBakeVolume out;
out.volume_id = volume.id();
out.base_mesh = volume.mesh().its;
out.layers = layers;
out.options = options;
out.volume_to_world = texture_displacement_volume_to_world(volume);
out.texture_anchor = texture_anchor;
out.resting_drop = resting_drop;
if (volume.has_texture_uv())
out.model_uv = volume.texture_uv;
for (int i = 0; i < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++i)
out.facets_data[size_t(i)] = volume.texture_displacement_facet(i).get_data();
return out;
}
void queue_texture_displacement_bake(std::vector<TextureDisplacementBakeVolume> volumes,
const TextureColorSettings &color,
std::function<void()> on_finished, bool take_snapshot)
{
TextureDisplacementBakeInput input;
input.volumes = std::move(volumes);
input.color = color;
input.take_snapshot = take_snapshot;
input.volume_id = volume.id();
input.base_mesh = volume.mesh().its;
if (volume.has_texture_uv())
input.model_uv = volume.texture_uv;
input.layers = volume.texture_displacement_layers;
input.options = volume.texture_displacement_options;
input.volume_to_world = texture_displacement_volume_to_world(volume);
for (int i = 0; i < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++i)
input.facets_data[size_t(i)] = volume.texture_displacement_facet(i).get_data();
auto &worker = wxGetApp().plater()->get_ui_job_worker();
queue_job(worker, std::make_unique<TextureDisplacementBakeJob>(std::move(input), std::move(on_finished)));
@@ -17,10 +17,11 @@ namespace Slic3r { class ModelVolume; }
namespace Slic3r::GUI {
// Everything process() needs, captured by value on the main thread when the job is queued so the
// worker thread never touches the live Model concurrently with the UI (mirrors how EmbossJob's
// DataBase is captured before process() runs).
struct TextureDisplacementBakeInput
// One model part's share of a bake: its own mesh, its own paint masks and UV channel, and the layer
// stack to displace it by. Captured by value on the main thread when the job is queued so the worker
// thread never touches the live Model concurrently with the UI (mirrors how EmbossJob's DataBase is
// captured before process() runs).
struct TextureDisplacementBakeVolume
{
ObjectID volume_id;
indexed_triangle_set base_mesh;
@@ -30,22 +31,38 @@ struct TextureDisplacementBakeInput
// Mesh coordinates -> world millimetres. Captured here with everything else so the worker never
// reaches back into the live Model for it. See build_texture_displacement().
Transform3d volume_to_world = Transform3d::Identity();
// Captured on the main thread. Empty unless some layer is colouring, in which case the bake also
// writes the volume's mmu_segmentation_facets - the same per-triangle filament assignment the MMU
// paint gizmo writes - alongside the displaced geometry.
TextureColorSettings color;
// The volume's own UV channel (ModelVolume::texture_uv), empty when it has none: what a ModelUV
// The part's own UV channel (ModelVolume::texture_uv), empty when it has none: what a ModelUV
// layer maps through, and what the commit puts back on the baked mesh.
std::vector<Vec2f> model_uv;
// Where the part sits in its object: the world point the object's texture is anchored at, and how
// far the object's lowest point lies below the part's own. See build_texture_displacement().
Vec3d texture_anchor = Vec3d::Zero();
float resting_drop = 0.f;
};
struct TextureDisplacementBakeInput
{
// Every model part the bake acts on, in the object's own volume order. An object made of several
// parts carries one entry per part the paint actually reaches: the layer stack is the object's,
// but each part is displaced through its own mesh, its own masks and its own UV channel. Baking
// only the first part is what made texture displacement silently ignore every other body of a
// multibody object.
std::vector<TextureDisplacementBakeVolume> volumes;
// Captured on the main thread, and shared by every part: the palette comes from the project's
// filaments and from the layer stack, neither of which is per part. Empty unless some layer is
// colouring, in which case the bake also writes each part's mmu_segmentation_facets - the same
// per-triangle filament assignment the MMU paint gizmo writes - alongside the displaced geometry.
TextureColorSettings color;
// Whether this job pushes its own undo step when it commits. False when the caller has already
// taken one that is meant to cover the displacement as well - Standard mode's Bake, which remeshes
// and subdivides first and has to undo as a single action.
bool take_snapshot = true;
bool take_snapshot = true;
};
// Bakes a volume's painted texture-displacement layers into real mesh geometry in the background,
// Bakes an object's painted texture-displacement layers into real mesh geometry in the background,
// then commits the result on the main thread - mirrors EmbossJob's UpdateJob/update_volume()
// bake-and-commit pattern (see EmbossJob.cpp).
// bake-and-commit pattern (see EmbossJob.cpp). Every part is displaced in the one job and committed
// under the one undo step, so a multibody object undoes as a single action.
class TextureDisplacementBakeJob : public Job
{
public:
@@ -55,24 +72,41 @@ public:
void finalize(bool canceled, std::exception_ptr &eptr) override;
private:
// What the bake produced for one part of m_input.volumes, at the same index.
struct VolumeResult
{
TriangleMesh mesh;
// Per triangle of `mesh`: the palette entry to print it in, as its index + 1 (0 = leave
// alone). Empty unless a layer asked for colour. See TextureColorRequest; finalize() turns
// these into filaments.
std::vector<uint8_t> triangle_color;
// What the bake spent, for the message it leaves behind when the budget capped the detail.
TextureBakeStats stats;
};
TextureDisplacementBakeInput m_input;
TriangleMesh m_result;
// What the bake spent, for the message it leaves behind when the budget capped the detail.
TextureBakeStats m_stats;
// Per triangle of m_result: the palette entry to print it in, as its index + 1 (0 = leave alone).
// Empty unless a layer asked for colour. See TextureColorRequest; finalize() turns these into
// filaments.
std::vector<uint8_t> m_triangle_color;
std::vector<VolumeResult> m_results;
std::function<void()> m_on_finished;
};
// Captures `volume`'s current mesh/layers/paint data and queues a TextureDisplacementBakeJob on
// the app's UI job worker. `on_finished` is always called once the job settles (success, failure,
// or cancellation), so the caller can clear its own "bake in progress" UI state. Must be called
// from the main thread.
void queue_texture_displacement_bake(const ModelVolume &volume, const TextureColorSettings &color,
// Captures each of `volumes`' current mesh/layers/paint data and queues a single
// TextureDisplacementBakeJob on the app's UI job worker. `on_finished` is always called once the job
// settles (success, failure, or cancellation), so the caller can clear its own "bake in progress" UI
// state. Must be called from the main thread.
void queue_texture_displacement_bake(std::vector<TextureDisplacementBakeVolume> volumes,
const TextureColorSettings &color,
std::function<void()> on_finished, bool take_snapshot = true);
// `volume`'s share of a bake, read off the live Model. `layers`, `options`, `texture_anchor` and
// `resting_drop` are passed in rather than taken from `volume` because they belong to the object, not
// to the part: every part of a multibody object is displaced by the same stack, in the same texture
// frame and against the same plate (see GLGizmoTextureDisplacement::layers_for_part()).
TextureDisplacementBakeVolume texture_displacement_bake_volume(const ModelVolume &volume,
const std::vector<TextureDisplacementLayer> &layers,
const TextureDisplacementOptions &options,
const Vec3d &texture_anchor,
float resting_drop);
} // namespace Slic3r::GUI
#endif // slic3r_TextureDisplacementBakeJob_hpp_
@@ -60,8 +60,8 @@ void TextureDisplacementDebugJob::process(Ctl &ctl)
// experimental pipeline refines as part of the bake and has nothing to prepare.
if (m_input.run_prepare && !m_input.options.pipeline_v2) {
const TextureDisplacementPrepareResult prepared =
GLGizmoTextureDisplacement::prepare_mesh(mesh, masks, m_input.layers, uv, m_input.prepare_params,
m_input.color,
GLGizmoTextureDisplacement::prepare_mesh(mesh, masks, m_input.layers, uv, m_input.prepare_texture_frame,
m_input.prepare_params, m_input.color,
// Preparation is roughly half the run; the bake
// takes the progress bar from there.
[&report](int pct) { return report(1 + pct / 2); },
@@ -86,7 +86,8 @@ void TextureDisplacementDebugJob::process(Ctl &ctl)
build_texture_displacement(mesh, m_input.layers, masks, m_input.options,
[&report](int pct) { return report(50 + pct / 2); },
/* color */ nullptr, m_input.volume_to_world, &recorder, /* stats */ nullptr,
uv.empty() ? nullptr : &uv);
uv.empty() ? nullptr : &uv, /* texture_anchor: the volume's own origin */ nullptr,
m_input.resting_drop);
m_stages = recorder.take();
ctl.update_status(100, status); // always finish at 100: this is what closes the notification
@@ -106,6 +107,7 @@ void TextureDisplacementDebugJob::finalize(bool canceled, std::exception_ptr &ep
void queue_texture_displacement_debug(const ModelVolume &volume, const TextureColorSettings &color,
const TextureDisplacementPrepareParams &prepare_params,
const Transform3d &prepare_texture_frame, float resting_drop,
bool run_prepare, bool check_topology,
std::function<void(std::vector<BakeStageSnapshot>)> on_finished)
{
@@ -119,6 +121,8 @@ void queue_texture_displacement_debug(const ModelVolume &volume, const TextureCo
input.check_topology = check_topology;
input.color = color;
input.volume_to_world = texture_displacement_volume_to_world(volume);
input.prepare_texture_frame = prepare_texture_frame;
input.resting_drop = resting_drop;
if (volume.has_texture_uv())
input.model_uv = volume.texture_uv;
for (int i = 0; i < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++i)
@@ -44,6 +44,11 @@ struct TextureDisplacementDebugInput
// Mesh coordinates -> world millimetres, as the bake gets it. The captured stages are brought
// back into mesh coordinates before they are handed over, so the gizmo can draw them directly.
Transform3d volume_to_world = Transform3d::Identity();
// Where the preparation samples the texture, and how far below the volume's own bottom its object
// rests - both as the bake and its preparation get them (see TextureDisplacementPrepareVolume and
// TextureDisplacementBakeVolume).
Transform3d prepare_texture_frame = Transform3d::Identity();
float resting_drop = 0.f;
// Only the quantizer is used, and only by the refinement's colour criterion - the debug run
// produces no colour of its own.
TextureColorSettings color;
@@ -79,6 +84,7 @@ private:
// caller can clear its own in-progress state. Must be called from the main thread.
void queue_texture_displacement_debug(const ModelVolume &volume, const TextureColorSettings &color,
const TextureDisplacementPrepareParams &prepare_params,
const Transform3d &prepare_texture_frame, float resting_drop,
bool run_prepare, bool check_topology,
std::function<void(std::vector<BakeStageSnapshot>)> on_finished);
@@ -9,6 +9,7 @@
#include <optional>
#include <utility>
#include <string>
#include <vector>
#include "slic3r/GUI/GLCanvas3D.hpp"
#include "slic3r/GUI/Jobs/Worker.hpp"
@@ -44,18 +45,29 @@ void TextureDisplacementPrepareJob::process(Ctl &ctl)
// once it reaches 100%, and below that it shows Cancel, which is wired through here. Reporting is
// throttled to whole percentage points because every call repaints the notification and wakes the
// idle loop.
// Each part gets its own slice of the range, in order, so one bar covers them all.
const size_t count = m_input.volumes.size();
m_results.resize(count);
int last_reported = 1;
m_result = GLGizmoTextureDisplacement::prepare_mesh(m_input.base_mesh, m_input.masks, m_input.layers,
m_input.model_uv, m_input.params, m_input.color,
[&ctl, &status, &last_reported](int percent) {
if (ctl.was_canceled())
return false;
if (percent > last_reported) {
last_reported = percent;
ctl.update_status(percent, status);
}
return true;
});
for (size_t i = 0; i < count; ++i) {
const TextureDisplacementPrepareVolume &volume = m_input.volumes[i];
m_results[i] = GLGizmoTextureDisplacement::prepare_mesh(volume.base_mesh, volume.masks, volume.layers,
volume.model_uv, volume.texture_frame,
m_input.params, m_input.color,
[&ctl, &status, &last_reported, i, count](int percent) {
if (ctl.was_canceled())
return false;
const int overall =
int((i * 100 + size_t(std::max(0, percent))) / count);
if (overall > last_reported) {
last_reported = overall;
ctl.update_status(overall, status);
}
return true;
});
if (ctl.was_canceled())
break;
}
ctl.update_status(100, status); // always finish at 100: this is what closes the notification
}
@@ -72,44 +84,56 @@ void TextureDisplacementPrepareJob::finalize(bool canceled, std::exception_ptr &
if (canceled || eptr)
return;
if (m_result.paint_lost) {
outcome = TextureDisplacementPrepareOutcome::PaintLost;
return;
// One part losing its paint stops the whole prepare, exactly as it stops a single-part one: the
// promise is that a prepare which cannot carry the paint changes nothing, and committing the other
// parts would break it halfway. The message that follows tells the user to remesh by hand instead.
for (const TextureDisplacementPrepareResult &result : m_results)
if (result.paint_lost) {
outcome = TextureDisplacementPrepareOutcome::PaintLost;
return;
}
Plater *plater = wxGetApp().plater();
// Resolved before the snapshot is opened, so a run whose parts have all gone away leaves no empty
// undo step behind. The lookup doubles as the staleness check: anything that replaces a volume's
// mesh also gives it a new id (set_new_unique_id()), so a prepare computed from a mesh that has
// since been replaced - by an undo, another bake, or a boolean - simply fails to find its volume.
std::vector<std::pair<size_t, ModelVolume *>> to_commit;
for (size_t i = 0; i < m_results.size(); ++i) {
if (m_results[i].mesh.indices.empty())
continue;
if (ModelVolume *volume = get_model_volume(m_input.volumes[i].volume_id, plater->model().objects);
volume != nullptr && volume->get_object() != nullptr)
to_commit.emplace_back(i, volume);
}
if (m_result.mesh.indices.empty()) {
if (to_commit.empty()) {
// Nothing needed doing anywhere, which is not an error - every mesh already met the criteria.
outcome = TextureDisplacementPrepareOutcome::Unchanged;
return;
}
Plater *plater = wxGetApp().plater();
ModelVolume *volume = get_model_volume(m_input.volume_id, plater->model().objects);
// The lookup doubles as a staleness check: anything that replaces a volume's mesh also gives it a
// new id (set_new_unique_id()), so a prepare computed from a mesh that has since been replaced -
// by an undo, another bake, or a boolean - simply fails to find its volume and commits nothing.
if (volume == nullptr)
return;
ModelObject *object = volume->get_object();
if (object == nullptr)
return;
ModelObject *object = to_commit.front().second->get_object();
{
Plater::TakeSnapshot snapshot(plater, m_input.snapshot_name, UndoRedo::SnapshotType::GizmoAction);
// The four standard paint channels ride across on ModelVolume's own spatial remap. The eight
// texture-displacement masks do not go through it - prepare_mesh() has already carried them,
// exactly, from the source triangles the refinement records - so they are put back after
// restore_painting(), which resets every extra facet before remapping the channels it knows.
std::optional<TriangleSelector::SavedPainting> saved_painting = volume->save_painting();
volume->set_mesh(TriangleMesh(std::move(m_result.mesh)));
volume->set_new_unique_id();
volume->calculate_convex_hull();
volume->restore_painting(saved_painting);
for (int i = 0; i < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++i)
volume->texture_displacement_facet(i).set_data(std::move(m_result.masks[size_t(i)]));
// The model's own UV channel, resampled onto the new mesh by the run. Also put back after
// restore_painting(), which resets it along with the extra facets for the same reason: it is
// indexed by triangle and the triangles have changed.
volume->texture_uv = std::move(m_result.uv);
for (const auto &[i, volume] : to_commit) {
// The four standard paint channels ride across on ModelVolume's own spatial remap. The eight
// texture-displacement masks do not go through it - prepare_mesh() has already carried them,
// exactly, from the source triangles the refinement records - so they are put back after
// restore_painting(), which resets every extra facet before remapping the channels it knows.
std::optional<TriangleSelector::SavedPainting> saved_painting = volume->save_painting();
volume->set_mesh(TriangleMesh(std::move(m_results[i].mesh)));
volume->set_new_unique_id();
volume->calculate_convex_hull();
volume->restore_painting(saved_painting);
for (int slot = 0; slot < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++slot)
volume->texture_displacement_facet(slot).set_data(std::move(m_results[i].masks[size_t(slot)]));
// The model's own UV channel, resampled onto the new mesh by the run. Also put back after
// restore_painting(), which resets it along with the extra facets for the same reason: it is
// indexed by triangle and the triangles have changed.
volume->texture_uv = std::move(m_results[i].uv);
}
if (ObjectList *obj_list = wxGetApp().obj_list()) {
const ModelObjectPtrs &objs = plater->model().objects;
@@ -31,36 +31,50 @@ namespace Slic3r::GUI {
// this job adds is the worker thread, the progress notification with its Cancel button, and the commit.
enum class TextureDisplacementPrepareOutcome
{
Committed, // the volume now carries the prepared mesh and the paint carried onto it
Unchanged, // nothing needed doing - the mesh already met the criteria; the model was not touched
PaintLost, // the remesh landed but no layer's paint survived it, so nothing was committed
Failed, // cancelled, threw, or the volume went away while the job ran
Committed, // the parts now carry the prepared mesh and the paint carried onto it
Unchanged, // nothing needed doing - the meshes already met the criteria; the model was not touched
PaintLost, // a remesh landed but no layer's paint survived it, so nothing was committed
Failed, // cancelled, threw, or the volumes went away while the job ran
};
struct TextureDisplacementPrepareInput
// One model part's share of a prepare. As with the bake, `layers` belongs to the object rather than to
// the part (see GLGizmoTextureDisplacement::layers_for_part()).
struct TextureDisplacementPrepareVolume
{
ObjectID volume_id;
indexed_triangle_set base_mesh;
TextureDisplacementFacetsData masks;
std::vector<TextureDisplacementLayer> layers;
TextureDisplacementPrepareParams params;
// Captured on the main thread. Empty when no layer is colouring, in which case the refinement
// skips the colour criterion entirely. Only the *quantizer* is used here: refinement follows
// perceived colour, never the interleaving that realises a mix.
TextureColorSettings color;
// The volume's own UV channel (ModelVolume::texture_uv), empty when it has none. prepare_mesh()
// The part's own UV channel (ModelVolume::texture_uv), empty when it has none. prepare_mesh()
// resamples it onto the mesh it produces, and the commit puts it back - like the masks, it is
// indexed by triangle and so cannot survive the stages on its own.
std::vector<Vec2f> model_uv;
// Where prepare_mesh() samples the texture: the bake frame shared by every part of a multibody
// object, so each part is refined for the texture the bake will put on it. Identity, the part's own
// coordinates, for a single-part object.
Transform3d texture_frame = Transform3d::Identity();
};
struct TextureDisplacementPrepareInput
{
// Every model part to prepare, in the object's own volume order - the same parts the bake that
// follows will displace, so a multibody object is refined everywhere its paint reaches rather than
// only on its first part.
std::vector<TextureDisplacementPrepareVolume> volumes;
TextureDisplacementPrepareParams params;
// Captured on the main thread. Empty when no layer is colouring, in which case the refinement
// skips the colour criterion entirely. Only the *quantizer* is used here: refinement follows
// perceived colour, never the interleaving that realises a mix.
TextureColorSettings color;
// The undo step the commit opens. Standard mode's Bake names it after the bake, because the
// displacement job that follows commits into this same step rather than pushing its own.
std::string snapshot_name;
std::string snapshot_name;
};
class TextureDisplacementPrepareJob : public Job
{
TextureDisplacementPrepareInput m_input;
TextureDisplacementPrepareResult m_result;
std::vector<TextureDisplacementPrepareResult> m_results;
std::function<void(TextureDisplacementPrepareOutcome)> m_on_finished;
public:
@@ -1,19 +1,22 @@
#include "TextureDisplacementPreviewJob.hpp"
#include "slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp"
#include <cstddef>
#include <cstdint>
#include <memory>
#include <atomic>
#include <functional>
#include <utility>
#include <vector>
#include "libslic3r/TextureDisplacement.hpp"
#include <exception>
namespace Slic3r::GUI {
TextureDisplacementPreviewJob::TextureDisplacementPreviewJob(TextureDisplacementPreviewInput &&input, uint64_t generation,
std::shared_ptr<const std::atomic<uint64_t>> current_generation,
std::function<void(TextureDisplacementPreviewResult, uint64_t)> on_finished)
TextureDisplacementPreviewJob::TextureDisplacementPreviewJob(
TextureDisplacementPreviewInput &&input, uint64_t generation,
std::shared_ptr<const std::atomic<uint64_t>> current_generation,
std::function<void(std::vector<TextureDisplacementPreviewResult>, uint64_t)> on_finished)
: m_input(std::move(input)), m_generation(generation), m_current_generation(std::move(current_generation)),
m_on_finished(std::move(on_finished))
{
@@ -26,6 +29,9 @@ void TextureDisplacementPreviewJob::process(Ctl &ctl)
// Only ever touches m_input (captured by value before this job was queued) and local state -
// never the live Model - so this is safe to run concurrently with the UI thread.
//
// The quantizers are built once for the whole run: the palette is the object's, not the part's,
// and each one fills a colour lookup cube that costs tens of milliseconds.
TextureColorRequest color_request;
TextureColorRequest *color = nullptr;
if (!m_input.color.empty()) {
@@ -33,23 +39,32 @@ void TextureDisplacementPreviewJob::process(Ctl &ctl)
if (!m_input.color.palette_pure.empty())
color_request.quantize_pure = GLGizmoTextureDisplacement::make_palette_quantizer(m_input.color.palette_pure);
color_request.despeckle_passes = m_input.color.despeckle_passes;
color_request.out_triangle = &m_result.triangle_color;
if (color_request.quantize)
color = &color_request;
}
m_result.mesh = build_texture_displacement(m_input.base_mesh, m_input.layers, m_input.facets_data,
m_input.options,
[this, &ctl](int) {
// Bail the moment this preview stops being the current
// one; build_texture_displacement() then returns an
// empty mesh and finalize() drops it.
return !ctl.was_canceled() &&
(!m_current_generation ||
m_current_generation->load() == m_generation);
},
color, m_input.volume_to_world, nullptr, nullptr,
m_input.model_uv.empty() ? nullptr : &m_input.model_uv);
// Bail the moment this preview stops being the current one: build_texture_displacement() then
// returns an empty mesh, and finalize() drops the whole run rather than showing half of it.
const auto still_wanted = [this, &ctl](int) {
return !ctl.was_canceled() &&
(!m_current_generation || m_current_generation->load() == m_generation);
};
m_results.resize(m_input.volumes.size());
for (size_t i = 0; i < m_input.volumes.size(); ++i) {
const TextureDisplacementPreviewVolume &volume = m_input.volumes[i];
if (color != nullptr)
color_request.out_triangle = &m_results[i].triangle_color;
m_results[i].mesh = build_texture_displacement(volume.base_mesh, volume.layers, volume.facets_data,
volume.options, still_wanted, color, volume.volume_to_world,
nullptr, nullptr,
volume.model_uv.empty() ? nullptr : &volume.model_uv,
&volume.texture_anchor, volume.resting_drop);
if (!still_wanted(0)) {
m_results.clear();
return;
}
}
}
void TextureDisplacementPreviewJob::finalize(bool canceled, std::exception_ptr &eptr)
@@ -62,9 +77,9 @@ void TextureDisplacementPreviewJob::finalize(bool canceled, std::exception_ptr &
// was ever queued again for the rest of the session. An empty result is the caller's signal that
// nothing usable came back; it already handles that.
if (canceled || eptr)
m_on_finished(TextureDisplacementPreviewResult{}, m_generation);
m_on_finished({}, m_generation);
else
m_on_finished(std::move(m_result), m_generation);
m_on_finished(std::move(m_results), m_generation);
}
} // namespace Slic3r::GUI
@@ -15,9 +15,9 @@
namespace Slic3r::GUI {
// Everything process() needs, captured by value on the main thread when the job is queued -
// mirrors TextureDisplacementBakeInput, but a preview never writes back to the Model.
struct TextureDisplacementPreviewInput
// One model part's share of a preview. Captured by value on the main thread when the job is queued -
// mirrors TextureDisplacementBakeVolume, but a preview never writes back to the Model.
struct TextureDisplacementPreviewVolume
{
indexed_triangle_set base_mesh;
std::vector<TextureDisplacementLayer> layers;
@@ -26,16 +26,26 @@ struct TextureDisplacementPreviewInput
// Mesh coordinates -> world millimetres, so the preview is displaced in the same space the bake
// is and the two cannot disagree. See build_texture_displacement().
Transform3d volume_to_world = Transform3d::Identity();
// Empty unless a layer is colouring, in which case the preview reports the palette entry per
// triangle alongside the mesh, so the Normal view shows the colours the bake will produce.
TextureColorSettings color;
// The volume's own UV channel (ModelVolume::texture_uv), so a ModelUV layer previews through the
// same layout it will bake through. Empty when the volume has none.
// The part's own UV channel (ModelVolume::texture_uv), so a ModelUV layer previews through the
// same layout it will bake through. Empty when the part has none.
std::vector<Vec2f> model_uv;
// Where the part sits in its object, as TextureDisplacementBakeVolume has it.
Vec3d texture_anchor = Vec3d::Zero();
float resting_drop = 0.f;
};
// A preview result: the displaced mesh, and - when the input carried a palette - one palette entry per
// triangle (its index + 1; 0 means "no colour from the texture").
struct TextureDisplacementPreviewInput
{
// The same parts the bake would displace, in the object's own volume order, so the preview shows
// the whole of what Bake will do on a multibody object rather than just its first part.
std::vector<TextureDisplacementPreviewVolume> volumes;
// Empty unless a layer is colouring, in which case the preview reports the palette entry per
// triangle alongside the mesh, so the Normal view shows the colours the bake will produce.
TextureColorSettings color;
};
// A preview result for one part: the displaced mesh, and - when the input carried a palette - one
// palette entry per triangle (its index + 1; 0 means "no colour from the texture").
struct TextureDisplacementPreviewResult
{
indexed_triangle_set mesh;
@@ -65,9 +75,12 @@ public:
// already been superseded stops burning CPU instead of running to completion for a result that
// will only be thrown away. That matters because the UI job worker runs one job at a time in FIFO
// order: without it, a Bake queued behind a handful of stale previews waits for every one of them.
//
// `on_finished` receives one result per input part, at the same index - or an empty vector when
// the run was superseded, cancelled or threw.
TextureDisplacementPreviewJob(TextureDisplacementPreviewInput &&input, uint64_t generation,
std::shared_ptr<const std::atomic<uint64_t>> current_generation,
std::function<void(TextureDisplacementPreviewResult, uint64_t)> on_finished);
std::function<void(std::vector<TextureDisplacementPreviewResult>, uint64_t)> on_finished);
void process(Ctl &ctl) override;
void finalize(bool canceled, std::exception_ptr &eptr) override;
@@ -76,8 +89,8 @@ private:
TextureDisplacementPreviewInput m_input;
uint64_t m_generation;
std::shared_ptr<const std::atomic<uint64_t>> m_current_generation;
TextureDisplacementPreviewResult m_result;
std::function<void(TextureDisplacementPreviewResult, uint64_t)> m_on_finished;
std::vector<TextureDisplacementPreviewResult> m_results;
std::function<void(std::vector<TextureDisplacementPreviewResult>, uint64_t)> m_on_finished;
};
} // namespace Slic3r::GUI
@@ -2424,6 +2424,89 @@ TEST_CASE("TextureDisplacement: moving the model about the plate does not move t
CHECK(max_diff < 1e-3f);
}
TEST_CASE("Parts anchored at one point carry one continuous texture", "[TextureDisplacement]")
{
// The same surface, once as a part whose origin is the anchor and once as a part whose own origin
// sits `offset` away from it - the way the bodies of a multibody object are each centred on their
// own origin. Anchored at the same point they must bake the same relief; anchored at their own
// origins, the second is the tiled pattern shifted by `offset`.
const indexed_triangle_set cube = subdivide_mesh_uniform(its_make_cube(10.f, 10.f, 10.f), 1.f, 6);
// Not a multiple of the 4 mm tile, and exact in binary like the grid itself: the checkerboard's steps
// fall right on grid vertices, so a vertex rounded across a step would bake the other side of it.
const Vec3d offset(1.25, 2.5, 0.75);
indexed_triangle_set shifted = cube;
for (Vec3f &v : shifted.vertices)
v -= offset.cast<float>();
const Transform3d shifted_to_world = Transform3d(Eigen::Translation3d(offset));
TextureDisplacementLayer layer;
layer.slot = 0;
layer.image_data = make_checkerboard_png(16, 16);
layer.tiling_scale = 4.f;
layer.depth_mm = 0.5f;
TextureDisplacementFacetsData facets;
facets[0] = paint_whole_mesh(cube);
const indexed_triangle_set reference = build_texture_displacement(cube, { layer }, facets, classic_options());
const Vec3d anchor = Vec3d::Zero();
const indexed_triangle_set anchored = build_texture_displacement(shifted, { layer }, facets, classic_options(), {},
nullptr, shifted_to_world, nullptr, nullptr,
nullptr, &anchor);
const indexed_triangle_set own_origin = build_texture_displacement(shifted, { layer }, facets, classic_options(), {},
nullptr, shifted_to_world);
REQUIRE(anchored.vertices.size() == reference.vertices.size());
REQUIRE(own_origin.vertices.size() == reference.vertices.size());
float anchored_diff = 0.f, own_origin_diff = 0.f;
for (size_t i = 0; i < reference.vertices.size(); ++i) {
anchored_diff = std::max(anchored_diff, (anchored.vertices[i] + offset.cast<float>() - reference.vertices[i]).norm());
own_origin_diff = std::max(own_origin_diff, (own_origin.vertices[i] + offset.cast<float>() - reference.vertices[i]).norm());
}
CHECK(anchored_diff < 1e-3f);
CHECK(own_origin_diff > 0.1f);
}
TEST_CASE("A part held above the plate keeps the relief on its underside", "[TextureDisplacement]")
{
// A fully painted cube displaces its bottom face straight down by the full depth. On its own, that
// relief is clamped to the cube's bottom (see "relief is never driven below the model's resting
// plane"); as a part of an object resting `drop` below it, it is clamped only at the object's plane.
const bool v2 = GENERATE(false, true);
const indexed_triangle_set cube = subdivide_mesh_uniform(its_make_cube(10.f, 10.f, 10.f), 2.f, 6);
TextureDisplacementFacetsData facets{};
facets[0] = paint_whole_mesh(cube);
TextureDisplacementLayer layer;
layer.slot = 0;
layer.depth_mm = 2.0f;
layer.tiling_scale = 5.0f;
layer.image_data = make_flat_gray_png(255); // full depth everywhere
TextureDisplacementOptions options = classic_options();
options.pipeline_v2 = v2;
options.v2_refine_mm = 2.f;
options.v2_max_triangles_k = 0;
const auto lowest_after = [&](float drop) {
const indexed_triangle_set out = build_texture_displacement(cube, { layer }, facets, options, {}, nullptr,
Transform3d::Identity(), nullptr, nullptr, nullptr,
nullptr, drop);
REQUIRE_FALSE(out.vertices.empty());
float lowest = std::numeric_limits<float>::max();
for (const Vec3f &v : out.vertices)
lowest = std::min(lowest, v.z());
return lowest;
};
CAPTURE(v2);
// The cube's bottom is z = 0. With the object resting 5 mm lower, the 2 mm relief fits above it...
CHECK_THAT(lowest_after(5.f), WithinAbs(-2.0f, 1e-3f));
// ...with the object resting 1 mm lower, it is clamped at that plane...
CHECK_THAT(lowest_after(1.f), WithinAbs(-1.0f, 1e-3f));
// ...and as the lowest part, at its own bottom.
CHECK_THAT(lowest_after(0.f), WithinAbs(0.0f, 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)