mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-09-16 13:32:44 +00:00
4156 lines
215 KiB
C++
4156 lines
215 KiB
C++
#include "GLGizmoTextureDisplacement.hpp"
|
|
|
|
#include <boost/log/trivial.hpp>
|
|
|
|
#include "libslic3r/MeshBoolean.hpp"
|
|
#include "libslic3r/Model.hpp"
|
|
#include "libslic3r/Utils.hpp"
|
|
#include "libslic3r/format.hpp"
|
|
|
|
#include "slic3r/GUI/Camera.hpp"
|
|
#include "slic3r/GUI/CameraUtils.hpp"
|
|
#include "slic3r/GUI/GLCanvas3D.hpp"
|
|
#include "slic3r/GUI/GLToolbar.hpp" // GLToolbar::Default_Icons_Size, to match the toolbar's icon size
|
|
#include "slic3r/GUI/GUI.hpp"
|
|
#include "slic3r/GUI/GUI_App.hpp"
|
|
#include "slic3r/GUI/GUI_ObjectList.hpp"
|
|
#include "slic3r/GUI/ImGuiWrapper.hpp"
|
|
#include "slic3r/GUI/MainFrame.hpp" // wxGetApp().mainframe, as the projector window's parent
|
|
#include "slic3r/GUI/MsgDialog.hpp"
|
|
#include "slic3r/GUI/OpenGLManager.hpp"
|
|
#include "slic3r/GUI/Plater.hpp"
|
|
#include "slic3r/GUI/TextureLibrary.hpp"
|
|
#include "slic3r/GUI/TextureProjectorFrame.hpp"
|
|
#include "slic3r/GUI/UVEditorCanvas.hpp"
|
|
#include "slic3r/GUI/Jobs/TextureDisplacementBakeJob.hpp"
|
|
#include "slic3r/GUI/Jobs/TextureDisplacementPreviewJob.hpp"
|
|
#include "slic3r/Utils/UndoRedo.hpp"
|
|
#include "GLGizmoUtils.hpp"
|
|
|
|
#include <glad/gl.h>
|
|
#include <algorithm>
|
|
#include <array>
|
|
#include <cmath>
|
|
#include <limits>
|
|
#include <queue>
|
|
#include <set>
|
|
|
|
namespace Slic3r::GUI {
|
|
|
|
namespace {
|
|
// ImGuiWrapper::slider_float()'s trailing `power` parameter is forwarded straight into
|
|
// ImGui::SliderFloat()'s ImGuiSliderFlags argument - this ImGui (1.83) replaced the old float
|
|
// "power" curve API with a flags word but kept the parameter in the same position, and
|
|
// ImGuiWrapper never caught up with the rename. Passing the Logarithmic flag through it is
|
|
// therefore how a log-scaled slider gets requested here.
|
|
//
|
|
// Depth and tile size both want it: they span two to three orders of magnitude, and the values
|
|
// that need the most precision (a few hundredths of a millimetre of relief, a very fine tile) are
|
|
// all bunched into the very bottom of a linear range, where a single pixel of slider travel is a
|
|
// bigger jump than the whole useful region.
|
|
constexpr float ImGuiLogSlider = float(ImGuiSliderFlags_Logarithmic);
|
|
|
|
// Uploads decoded 8-bit grayscale height data as an RGBA GPU texture for display in the panel.
|
|
// Shared by the per-layer thumbnail and the picker's library entries, which differ only in where
|
|
// their pixels came from.
|
|
//
|
|
// The source is downscaled to THUMBNAIL_MAX_PX first, with a box filter, and uploaded *without*
|
|
// mipmaps. Both halves of that matter, and together they are the fix for thumbnails rendering as
|
|
// garbage:
|
|
// - GLTexture's mipmap levels are not real downscales (see load_from_raw_data()'s header) - every
|
|
// level re-uploads the full-resolution buffer reinterpreted at a smaller size. A 512px texture
|
|
// drawn into a ~48px row is minified ~10x, which is exactly the regime where OpenGL picks those
|
|
// broken levels, so the thumbnail showed a scrambled crop of the image rather than the image.
|
|
// Turning mipmaps off makes it sample level 0, which is the real picture.
|
|
// - But level 0 at 512px sampled down to 48px with plain GL_LINEAR (a 2x2 tap) would alias badly
|
|
// on exactly the high-frequency patterns these textures are (knurl, hexagons, weave). Box-
|
|
// filtering down to roughly twice the displayed size first is what actually removes the
|
|
// frequencies that would alias, and it cuts the VRAM these hold by ~16x as a bonus.
|
|
constexpr int THUMBNAIL_MAX_PX = 128;
|
|
|
|
std::unique_ptr<GLTexture> upload_height_thumbnail(const DecodedHeightTexture &decoded)
|
|
{
|
|
if (decoded.empty())
|
|
return nullptr;
|
|
|
|
// Preserve aspect; never upscale a texture that is already small.
|
|
const int scale = std::max(1, (std::max(decoded.width, decoded.height) + THUMBNAIL_MAX_PX - 1) / THUMBNAIL_MAX_PX);
|
|
const int w = std::max(1, decoded.width / scale);
|
|
const int h = std::max(1, decoded.height / scale);
|
|
|
|
std::vector<unsigned char> rgba(size_t(w) * size_t(h) * 4);
|
|
for (int y = 0; y < h; ++y)
|
|
for (int x = 0; x < w; ++x) {
|
|
// Average the source block this destination pixel covers.
|
|
const int x0 = x * decoded.width / w, x1 = std::max(x0 + 1, (x + 1) * decoded.width / w);
|
|
const int y0 = y * decoded.height / h, y1 = std::max(y0 + 1, (y + 1) * decoded.height / h);
|
|
unsigned int sum = 0, n = 0;
|
|
for (int sy = y0; sy < y1 && sy < decoded.height; ++sy)
|
|
for (int sx = x0; sx < x1 && sx < decoded.width; ++sx, ++n)
|
|
sum += decoded.pixels[size_t(sy) * size_t(decoded.width) + size_t(sx)];
|
|
|
|
const unsigned char gray = (n > 0) ? static_cast<unsigned char>(sum / n) : 0;
|
|
const size_t di = (size_t(y) * size_t(w) + size_t(x)) * 4;
|
|
rgba[di + 0] = gray;
|
|
rgba[di + 1] = gray;
|
|
rgba[di + 2] = gray;
|
|
rgba[di + 3] = 255;
|
|
}
|
|
|
|
auto texture = std::make_unique<GLTexture>();
|
|
if (!texture->load_from_raw_data(std::move(rgba), (unsigned int) w, (unsigned int) h, false, /* use_mipmaps */ false))
|
|
return nullptr;
|
|
return texture;
|
|
}
|
|
|
|
// Intersects the camera ray through `mouse_pos` (screen coords) with the plane passing through
|
|
// `plane_point_local`/`plane_normal_local` (mesh-local coords, transformed to world by `trafo`).
|
|
// Returns false if the ray is parallel to the plane or the plane is behind the camera.
|
|
bool ray_plane_hit(const Camera &camera, const Vec2d &mouse_pos, const Transform3d &trafo,
|
|
const Vec3f &plane_point_local, const Vec3f &plane_normal_local, Vec3d &out_world_hit)
|
|
{
|
|
Vec3d ray_origin, ray_dir;
|
|
CameraUtils::ray_from_screen_pos(camera, mouse_pos, ray_origin, ray_dir);
|
|
|
|
const Vec3d plane_point_world = trafo * plane_point_local.cast<double>();
|
|
const Vec3d plane_normal_world = (trafo.matrix().block(0, 0, 3, 3).inverse().transpose() * plane_normal_local.cast<double>()).normalized();
|
|
|
|
const double denom = ray_dir.dot(plane_normal_world);
|
|
if (std::abs(denom) < 1e-8)
|
|
return false;
|
|
|
|
const double t = (plane_point_world - ray_origin).dot(plane_normal_world) / denom;
|
|
if (t < 0.0)
|
|
return false;
|
|
|
|
out_world_hit = ray_origin + ray_dir * t;
|
|
return true;
|
|
}
|
|
} // namespace
|
|
|
|
GLGizmoTextureDisplacement::GLGizmoTextureDisplacement(GLCanvas3D& parent, const std::string& icon_filename, unsigned int sprite_id)
|
|
: GLGizmoPainterBase(parent, icon_filename, sprite_id)
|
|
{
|
|
}
|
|
|
|
bool GLGizmoTextureDisplacement::on_init()
|
|
{
|
|
m_desc["cursor_size"] = _L("Brush size");
|
|
m_desc["circle"] = _L("Circle");
|
|
m_desc["sphere"] = _L("Sphere");
|
|
m_desc["add_texture"] = _L("Add layer");
|
|
m_desc["remove_layer"] = _L("Remove");
|
|
m_desc["bake"] = _L("Bake");
|
|
m_desc["remove_all"] = _L("Erase all");
|
|
return true;
|
|
}
|
|
|
|
std::string GLGizmoTextureDisplacement::on_get_name() const
|
|
{
|
|
return _u8L("Texture displacement");
|
|
}
|
|
|
|
void GLGizmoTextureDisplacement::on_shutdown()
|
|
{
|
|
m_parent.toggle_model_objects_visibility(true);
|
|
m_preview_glmodel.reset();
|
|
m_bump_preview_glmodel.reset();
|
|
m_uvcheck_glmodel.reset();
|
|
m_wireframe_overlay_glmodel.reset();
|
|
m_wireframe_overlay_vcount = 0;
|
|
m_seam_glmodel.reset();
|
|
m_seam_hover_glmodel.reset();
|
|
m_seam_hover_edge = { -1, -1 };
|
|
m_seam_hover_vertex = -1;
|
|
m_seam_anchor_glmodel.reset();
|
|
m_seam_path_mode = false;
|
|
m_seam_path_anchor = -1;
|
|
m_subdivide_editing = false;
|
|
m_subdivide_preview_tris = -1;
|
|
m_subdivide_preview_glmodel.reset();
|
|
m_bump_active_chart = -1;
|
|
m_bump_active_vertex.clear();
|
|
m_bump_island_delta = Eigen::Matrix<float, 2, 3>::Identity();
|
|
m_island_drag_active = false;
|
|
m_island_move_set.clear();
|
|
m_adjust_texture_mode = false;
|
|
m_seam_edit_mode = false;
|
|
m_adjust_drag_handle = AdjustHandle::None;
|
|
m_adjust_anchor_valid = false;
|
|
// The pane is request-only: closing the gizmo drops the request, so reopening it later doesn't
|
|
// silently reopen the pane too.
|
|
m_show_uv_editor = false;
|
|
wxGetApp().plater()->show_uv_editor(false);
|
|
|
|
// Destroyed, not just hidden: unlike the UV pane (owned by Plater), this frame is owned here, and
|
|
// it holds a callback capturing `this`. Leaving it alive past the gizmo would leave that callback
|
|
// pointing at a gizmo that is no longer driving anything.
|
|
if (m_projector_frame != nullptr) {
|
|
m_projector_frame->Destroy();
|
|
m_projector_frame = nullptr;
|
|
}
|
|
m_projector_tex_source = nullptr; // a rebuilt frame starts with no texture in it
|
|
m_projector_tex_smoothing = -1.f;
|
|
}
|
|
|
|
PainterGizmoType GLGizmoTextureDisplacement::get_painter_type() const
|
|
{
|
|
return PainterGizmoType::TEXTURE_DISPLACEMENT;
|
|
}
|
|
|
|
wxString GLGizmoTextureDisplacement::handle_snapshot_action_name(bool shift_down, GLGizmoPainterBase::Button button_down) const
|
|
{
|
|
return shift_down ? _L("Erase texture displacement paint") : _L("Paint texture displacement");
|
|
}
|
|
|
|
void GLGizmoTextureDisplacement::render_painter_gizmo()
|
|
{
|
|
const Selection &selection = m_parent.get_selection();
|
|
|
|
glsafe(::glEnable(GL_BLEND));
|
|
glsafe(::glEnable(GL_DEPTH_TEST));
|
|
|
|
// Once anything is painted, m_preview_glmodel holds the true displaced result (same algorithm
|
|
// Bake uses). The untouched original topology (what render_triangles() draws) coincides
|
|
// exactly with it everywhere except the painted/displaced area, so both are drawn: the real
|
|
// preview geometry first, then the usual selection-highlight overlay with a small depth bias
|
|
// so it wins the depth test on the coincident (unpainted) surface - keeping the familiar
|
|
// enforcer/blocker highlight for precise brush editing there. Where the surface has actually
|
|
// been displaced, the raised preview geometry legitimately occludes the flat overlay - that
|
|
// visible bump is itself the "this is painted" indicator in that area.
|
|
//
|
|
// The bump preview is different: it never actually moves geometry (it's a shading trick), so
|
|
// its depth is identical to the overlay's *everywhere*, not just in the unpainted area - the
|
|
// depth-biased overlay would win the depth test across the whole surface and hide the bump
|
|
// shading entirely. So the overlay is skipped for it; the bump shading itself is the only
|
|
// feedback in that mode (still fine for painting, since render_cursor() below shows the brush).
|
|
// Coalesced bump rebuild from an in-progress UV island drag (see on_island_edited): done here, at
|
|
// most once per drawn frame, rather than synchronously in the UV canvas's mouse-move handler.
|
|
if (m_use_bump_preview && m_bump_preview_dirty) {
|
|
rebuild_bump_preview_mesh();
|
|
m_bump_preview_dirty = false;
|
|
}
|
|
const bool use_bump = m_use_bump_preview && m_bump_preview_glmodel.is_initialized();
|
|
// 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 bump preview (see bug #12). Without this the painted area
|
|
// covers the checker/heatmap and it can't be seen.
|
|
const bool show_paint_overlay = m_uv_check_mode == UVCheckMode::None;
|
|
if (use_bump) {
|
|
m_parent.toggle_model_objects_visibility(true);
|
|
if (ModelVolume *mv = texture_volume())
|
|
m_parent.toggle_model_objects_visibility(false, m_c->selection_info()->model_object(),
|
|
m_c->selection_info()->get_active_instance(), mv);
|
|
render_bump_preview_mesh();
|
|
} else if (m_preview_glmodel.is_initialized()) {
|
|
m_parent.toggle_model_objects_visibility(true);
|
|
if (ModelVolume *mv = texture_volume())
|
|
m_parent.toggle_model_objects_visibility(false, m_c->selection_info()->model_object(),
|
|
m_c->selection_info()->get_active_instance(), mv);
|
|
render_preview_mesh();
|
|
|
|
if (show_paint_overlay) {
|
|
glsafe(::glEnable(GL_POLYGON_OFFSET_FILL));
|
|
glsafe(::glPolygonOffset(-1.0f, -1.0f));
|
|
render_triangles(selection);
|
|
glsafe(::glDisable(GL_POLYGON_OFFSET_FILL));
|
|
}
|
|
} else if (show_paint_overlay) {
|
|
render_triangles(selection);
|
|
}
|
|
|
|
// Diagnostic overlays, drawn on top of whatever preview is active (both pull toward the camera
|
|
// with a polygon offset so they win the depth test against the coincident surface).
|
|
if (m_uv_check_mode != UVCheckMode::None)
|
|
render_uvcheck_mesh();
|
|
// While previewing a subdivision, its wireframe stands in for the mesh wireframe - it shows the
|
|
// density the model *would* have. The normal wireframe toggle is left untouched underneath, so it
|
|
// returns to whatever it was once the preview ends (which is what keeps an already-on wireframe on).
|
|
if (m_subdivide_editing)
|
|
render_subdivide_preview();
|
|
else if (m_wireframe_overlay)
|
|
render_wireframe_overlay();
|
|
// Marked seams are always shown for an LSCM layer, so existing cuts are visible before entering
|
|
// seam-edit mode - but they matter most while marking.
|
|
render_seam_overlay();
|
|
|
|
m_c->object_clipper()->render_cut();
|
|
m_c->instances_hider()->render_cut();
|
|
if (m_adjust_texture_mode)
|
|
render_adjust_texture_gizmo();
|
|
else if (!m_seam_edit_mode) // the brush cursor is meaningless while marking seams
|
|
render_cursor();
|
|
|
|
glsafe(::glDisable(GL_BLEND));
|
|
}
|
|
|
|
bool GLGizmoTextureDisplacement::on_mouse(const wxMouseEvent &mouse_event)
|
|
{
|
|
if (m_seam_edit_mode)
|
|
return on_mouse_seam(mouse_event);
|
|
if (m_adjust_texture_mode)
|
|
return on_mouse_adjust_texture(mouse_event);
|
|
return GLGizmoPainterBase::on_mouse(mouse_event);
|
|
}
|
|
|
|
bool GLGizmoTextureDisplacement::on_mouse_seam(const wxMouseEvent &mouse_event)
|
|
{
|
|
// Hold Ctrl to orbit/pan the camera while in seam mode, exactly as the base painter lets you do
|
|
// while painting: with Ctrl down we consume nothing, so the canvas gets the drag and moves the
|
|
// view. Without this, seam mode swallowed every left-drag and the camera couldn't be rotated.
|
|
if (mouse_event.CmdDown())
|
|
return false;
|
|
|
|
// Left-click marks/unmarks an edge; swallow the rest of the left-button stream so a drag doesn't
|
|
// paint, but let everything else through so the camera still orbits/pans/zooms normally.
|
|
if (mouse_event.LeftDown()) {
|
|
const Vec2d pos(mouse_event.GetX(), mouse_event.GetY());
|
|
if (m_seam_path_mode) {
|
|
// Two-click shortest-path seam: first click sets the start vertex, the next seams the whole
|
|
// path to it and becomes the new start (so a seam line chains click by click).
|
|
const int v = seam_vertex_at(pos);
|
|
if (v >= 0) {
|
|
if (m_seam_path_anchor < 0)
|
|
m_seam_path_anchor = v;
|
|
else {
|
|
mark_seam_path(m_seam_path_anchor, v);
|
|
m_seam_path_anchor = v;
|
|
}
|
|
rebuild_seam_anchor_overlay();
|
|
m_parent.set_as_dirty();
|
|
}
|
|
} else {
|
|
toggle_seam_at(pos);
|
|
}
|
|
return true;
|
|
}
|
|
// Live hover: highlight what a click would pick, so the clickable target is obvious (the "I don't
|
|
// know how it works" the user hit). In normal mode that is an edge; in shortest-path mode it is a
|
|
// vertex. Don't swallow the motion - the camera still needs it.
|
|
if (mouse_event.Moving()) {
|
|
const Vec2d pos(mouse_event.GetX(), mouse_event.GetY());
|
|
if (m_seam_path_mode) {
|
|
const int v = seam_vertex_at(pos);
|
|
if (v != m_seam_hover_vertex) {
|
|
m_seam_hover_vertex = v;
|
|
m_seam_hover_edge = { -1, -1 };
|
|
rebuild_seam_hover_overlay();
|
|
m_parent.set_as_dirty();
|
|
}
|
|
} else {
|
|
const std::pair<int, int> edge = seam_edge_at(pos);
|
|
if (edge != m_seam_hover_edge) {
|
|
m_seam_hover_edge = edge;
|
|
m_seam_hover_vertex = -1;
|
|
rebuild_seam_hover_overlay();
|
|
m_parent.set_as_dirty();
|
|
}
|
|
}
|
|
}
|
|
if (mouse_event.LeftUp() || (mouse_event.Dragging() && mouse_event.LeftIsDown()))
|
|
return true;
|
|
return false;
|
|
}
|
|
|
|
int GLGizmoTextureDisplacement::texture_volume_raycaster_index() const
|
|
{
|
|
const ModelVolume *mv = texture_volume();
|
|
const ModelObject *mo = m_c->selection_info()->model_object();
|
|
if (mv == nullptr || mo == nullptr)
|
|
return -1;
|
|
|
|
int idx = -1, count = 0;
|
|
for (const ModelVolume *v : mo->volumes) {
|
|
if (!v->is_model_part())
|
|
continue;
|
|
if (v == mv) { idx = count; break; }
|
|
++count;
|
|
}
|
|
return (idx >= 0 && idx < int(m_c->raycaster()->raycasters().size())) ? idx : -1;
|
|
}
|
|
|
|
std::pair<int, int> GLGizmoTextureDisplacement::seam_edge_at(const Vec2d &mouse_pos) const
|
|
{
|
|
const ModelVolume *mv = texture_volume();
|
|
const ModelObject *mo = m_c->selection_info()->model_object();
|
|
if (mv == nullptr || mo == nullptr)
|
|
return { -1, -1 };
|
|
|
|
const int idx = texture_volume_raycaster_index();
|
|
if (idx < 0)
|
|
return { -1, -1 };
|
|
const auto &raycasters = m_c->raycaster()->raycasters();
|
|
|
|
const Selection &selection = m_parent.get_selection();
|
|
const Transform3d trafo = mo->instances[selection.get_instance_idx()]->get_transformation().get_matrix() * mv->get_matrix();
|
|
const Camera &camera = wxGetApp().plater()->get_camera();
|
|
|
|
Vec3f hit = Vec3f::Zero(), normal = Vec3f::Zero();
|
|
size_t facet = 0;
|
|
if (!raycasters[size_t(idx)]->unproject_on_mesh(mouse_pos, trafo, camera, hit, normal,
|
|
m_c->object_clipper()->get_clipping_plane(), &facet))
|
|
return { -1, -1 };
|
|
|
|
const indexed_triangle_set &its = mv->mesh().its;
|
|
if (facet >= its.indices.size())
|
|
return { -1, -1 };
|
|
const stl_triangle_vertex_indices &tri = its.indices[facet];
|
|
|
|
// The facet edge nearest the hit point (point-to-segment distance in mesh space).
|
|
const auto seg_dist = [](const Vec3f &p, const Vec3f &a, const Vec3f &b) {
|
|
const Vec3f ab = b - a;
|
|
const float l2 = ab.squaredNorm();
|
|
const float t = (l2 > 1e-12f) ? std::clamp((p - a).dot(ab) / l2, 0.f, 1.f) : 0.f;
|
|
return (p - (a + ab * t)).norm();
|
|
};
|
|
int best_i = 0;
|
|
float best_d = std::numeric_limits<float>::max();
|
|
for (int i = 0; i < 3; ++i) {
|
|
const float d = seg_dist(hit, its.vertices[tri[i]], its.vertices[tri[(i + 1) % 3]]);
|
|
if (d < best_d) { best_d = d; best_i = i; }
|
|
}
|
|
const int a = tri[best_i], b = tri[(best_i + 1) % 3];
|
|
return { std::min(a, b), std::max(a, b) };
|
|
}
|
|
|
|
void GLGizmoTextureDisplacement::toggle_seam_at(const Vec2d &mouse_pos)
|
|
{
|
|
TextureDisplacementLayer *layer = active_layer();
|
|
if (layer == nullptr)
|
|
return;
|
|
const std::pair<int, int> edge = seam_edge_at(mouse_pos);
|
|
if (edge.first < 0)
|
|
return;
|
|
|
|
Plater::TakeSnapshot snapshot(wxGetApp().plater(), _u8L("Mark texture seam"), UndoRedo::SnapshotType::GizmoAction);
|
|
auto &seams = layer->lscm_seam_edges;
|
|
if (const auto it = std::find(seams.begin(), seams.end(), edge); it != seams.end())
|
|
seams.erase(it);
|
|
else
|
|
seams.push_back(edge);
|
|
rebuild_preview();
|
|
}
|
|
|
|
int GLGizmoTextureDisplacement::seam_vertex_at(const Vec2d &mouse_pos) const
|
|
{
|
|
const ModelVolume *mv = texture_volume();
|
|
const ModelObject *mo = m_c->selection_info()->model_object();
|
|
if (mv == nullptr || mo == nullptr)
|
|
return -1;
|
|
const int idx = texture_volume_raycaster_index();
|
|
if (idx < 0)
|
|
return -1;
|
|
const auto &raycasters = m_c->raycaster()->raycasters();
|
|
|
|
const Selection &selection = m_parent.get_selection();
|
|
const Transform3d trafo = mo->instances[selection.get_instance_idx()]->get_transformation().get_matrix() * mv->get_matrix();
|
|
const Camera &camera = wxGetApp().plater()->get_camera();
|
|
|
|
Vec3f hit = Vec3f::Zero(), normal = Vec3f::Zero();
|
|
size_t facet = 0;
|
|
if (!raycasters[size_t(idx)]->unproject_on_mesh(mouse_pos, trafo, camera, hit, normal,
|
|
m_c->object_clipper()->get_clipping_plane(), &facet))
|
|
return -1;
|
|
const indexed_triangle_set &its = mv->mesh().its;
|
|
if (facet >= its.indices.size())
|
|
return -1;
|
|
const stl_triangle_vertex_indices &tri = its.indices[facet];
|
|
int best = tri[0];
|
|
float best_d = std::numeric_limits<float>::max();
|
|
for (int i = 0; i < 3; ++i) {
|
|
const float d = (hit - its.vertices[tri[i]]).squaredNorm();
|
|
if (d < best_d) { best_d = d; best = tri[i]; }
|
|
}
|
|
return best;
|
|
}
|
|
|
|
void GLGizmoTextureDisplacement::mark_seam_path(int v_from, int v_to)
|
|
{
|
|
TextureDisplacementLayer *layer = active_layer();
|
|
const ModelVolume *mv = texture_volume();
|
|
if (layer == nullptr || mv == nullptr || v_from < 0 || v_to < 0 || v_from == v_to)
|
|
return;
|
|
const indexed_triangle_set &its = mv->mesh().its;
|
|
const size_t n = its.vertices.size();
|
|
if (size_t(v_from) >= n || size_t(v_to) >= n)
|
|
return;
|
|
|
|
// Shortest path over the mesh's edge graph (Dijkstra, edge weight = length). Built on demand; one
|
|
// pass per click is fine even on a dense mesh.
|
|
std::vector<std::vector<std::pair<int, float>>> adj(n);
|
|
for (const stl_triangle_vertex_indices &tri : its.indices)
|
|
for (int i = 0; i < 3; ++i) {
|
|
const int a = tri[i], b = tri[(i + 1) % 3];
|
|
const float w = (its.vertices[size_t(a)] - its.vertices[size_t(b)]).norm();
|
|
adj[size_t(a)].push_back({ b, w });
|
|
adj[size_t(b)].push_back({ a, w });
|
|
}
|
|
|
|
std::vector<float> dist(n, std::numeric_limits<float>::infinity());
|
|
std::vector<int> prev(n, -1);
|
|
using QN = std::pair<float, int>;
|
|
std::priority_queue<QN, std::vector<QN>, std::greater<QN>> pq;
|
|
dist[size_t(v_from)] = 0.f;
|
|
pq.push({ 0.f, v_from });
|
|
while (!pq.empty()) {
|
|
const auto [d, u] = pq.top();
|
|
pq.pop();
|
|
if (d > dist[size_t(u)])
|
|
continue;
|
|
if (u == v_to)
|
|
break;
|
|
for (const auto &[w, ew] : adj[size_t(u)]) {
|
|
const float nd = d + ew;
|
|
if (nd < dist[size_t(w)]) {
|
|
dist[size_t(w)] = nd;
|
|
prev[size_t(w)] = u;
|
|
pq.push({ nd, w });
|
|
}
|
|
}
|
|
}
|
|
if (prev[size_t(v_to)] < 0)
|
|
return; // unreachable (disconnected components)
|
|
|
|
Plater::TakeSnapshot snapshot(wxGetApp().plater(), _u8L("Mark texture seam path"), UndoRedo::SnapshotType::GizmoAction);
|
|
auto &seams = layer->lscm_seam_edges;
|
|
for (int v = v_to; v != v_from && v >= 0; v = prev[size_t(v)]) {
|
|
const int p = prev[size_t(v)];
|
|
if (p < 0)
|
|
break;
|
|
const std::pair<int, int> e{ std::min(v, p), std::max(v, p) };
|
|
if (std::find(seams.begin(), seams.end(), e) == seams.end())
|
|
seams.push_back(e);
|
|
}
|
|
rebuild_preview();
|
|
}
|
|
|
|
void GLGizmoTextureDisplacement::rebuild_seam_anchor_overlay()
|
|
{
|
|
m_seam_anchor_glmodel.reset();
|
|
const ModelVolume *mv = texture_volume();
|
|
if (!m_seam_edit_mode || !m_seam_path_mode || mv == nullptr || m_seam_path_anchor < 0)
|
|
return;
|
|
const indexed_triangle_set &its = mv->mesh().its;
|
|
if (size_t(m_seam_path_anchor) >= its.vertices.size())
|
|
return;
|
|
|
|
// The anchor's incident edges, so the path's start vertex is visible on the model.
|
|
GLModel::Geometry init_data;
|
|
init_data.format = { GLModel::Geometry::EPrimitiveType::Lines, GLModel::Geometry::EVertexLayout::P3 };
|
|
unsigned nn = 0;
|
|
for (const stl_triangle_vertex_indices &tri : its.indices)
|
|
for (int i = 0; i < 3; ++i) {
|
|
const int a = tri[i], b = tri[(i + 1) % 3];
|
|
if (a == m_seam_path_anchor || b == m_seam_path_anchor) {
|
|
init_data.add_vertex(its.vertices[size_t(a)]);
|
|
init_data.add_vertex(its.vertices[size_t(b)]);
|
|
init_data.add_line(nn, nn + 1);
|
|
nn += 2;
|
|
}
|
|
}
|
|
if (!init_data.is_empty())
|
|
m_seam_anchor_glmodel.init_from(std::move(init_data));
|
|
}
|
|
|
|
void GLGizmoTextureDisplacement::rebuild_seam_hover_overlay()
|
|
{
|
|
m_seam_hover_glmodel.reset();
|
|
const ModelVolume *mv = texture_volume();
|
|
if (!m_seam_edit_mode || mv == nullptr)
|
|
return;
|
|
const indexed_triangle_set &its = mv->mesh().its;
|
|
|
|
GLModel::Geometry init_data;
|
|
init_data.format = { GLModel::Geometry::EPrimitiveType::Lines, GLModel::Geometry::EVertexLayout::P3 };
|
|
|
|
if (m_seam_path_mode) {
|
|
// Highlight the hovered vertex as its ring of incident edges, so the click target is legible on
|
|
// a dense mesh (matching the green anchor's style, in the hover yellow render_seam_overlay uses).
|
|
if (m_seam_hover_vertex < 0 || size_t(m_seam_hover_vertex) >= its.vertices.size())
|
|
return;
|
|
unsigned nn = 0;
|
|
for (const stl_triangle_vertex_indices &tri : its.indices)
|
|
for (int i = 0; i < 3; ++i) {
|
|
const int a = tri[i], b = tri[(i + 1) % 3];
|
|
if (a == m_seam_hover_vertex || b == m_seam_hover_vertex) {
|
|
init_data.add_vertex(its.vertices[size_t(a)]);
|
|
init_data.add_vertex(its.vertices[size_t(b)]);
|
|
init_data.add_line(nn, nn + 1);
|
|
nn += 2;
|
|
}
|
|
}
|
|
if (!init_data.is_empty())
|
|
m_seam_hover_glmodel.init_from(std::move(init_data));
|
|
return;
|
|
}
|
|
|
|
if (m_seam_hover_edge.first < 0 || size_t(m_seam_hover_edge.first) >= its.vertices.size() ||
|
|
size_t(m_seam_hover_edge.second) >= its.vertices.size())
|
|
return;
|
|
init_data.reserve_vertices(2);
|
|
init_data.reserve_indices(2);
|
|
init_data.add_vertex(its.vertices[size_t(m_seam_hover_edge.first)]);
|
|
init_data.add_vertex(its.vertices[size_t(m_seam_hover_edge.second)]);
|
|
init_data.add_line(0, 1);
|
|
m_seam_hover_glmodel.init_from(std::move(init_data));
|
|
}
|
|
|
|
void GLGizmoTextureDisplacement::rebuild_seam_overlay()
|
|
{
|
|
m_seam_glmodel.reset();
|
|
const ModelVolume *mv = texture_volume();
|
|
const TextureDisplacementLayer *layer = active_layer();
|
|
if (mv == nullptr || layer == nullptr || layer->lscm_seam_edges.empty())
|
|
return;
|
|
const indexed_triangle_set &its = mv->mesh().its;
|
|
|
|
GLModel::Geometry init_data;
|
|
init_data.format = { GLModel::Geometry::EPrimitiveType::Lines, GLModel::Geometry::EVertexLayout::P3 };
|
|
init_data.reserve_vertices(layer->lscm_seam_edges.size() * 2);
|
|
init_data.reserve_indices(layer->lscm_seam_edges.size() * 2);
|
|
unsigned n = 0;
|
|
for (const auto &[a, b] : layer->lscm_seam_edges) {
|
|
if (a < 0 || b < 0 || size_t(a) >= its.vertices.size() || size_t(b) >= its.vertices.size())
|
|
continue;
|
|
init_data.add_vertex(its.vertices[size_t(a)]);
|
|
init_data.add_vertex(its.vertices[size_t(b)]);
|
|
init_data.add_line(n, n + 1);
|
|
n += 2;
|
|
}
|
|
if (!init_data.is_empty())
|
|
m_seam_glmodel.init_from(std::move(init_data));
|
|
}
|
|
|
|
void GLGizmoTextureDisplacement::render_seam_overlay()
|
|
{
|
|
const ModelObject *mo = m_c->selection_info()->model_object();
|
|
const ModelVolume *mv = texture_volume();
|
|
const bool have_marked = m_seam_glmodel.is_initialized();
|
|
const bool have_hover = m_seam_edit_mode && m_seam_hover_glmodel.is_initialized();
|
|
const bool have_anchor = m_seam_edit_mode && m_seam_anchor_glmodel.is_initialized();
|
|
if (mo == nullptr || mv == nullptr || (!have_marked && !have_hover && !have_anchor))
|
|
return;
|
|
GLShaderProgram *shader = wxGetApp().get_shader("flat");
|
|
if (shader == 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();
|
|
const Camera &camera = wxGetApp().plater()->get_camera();
|
|
|
|
shader->start_using();
|
|
shader->set_uniform("view_model_matrix", camera.get_view_matrix() * trafo_matrix);
|
|
shader->set_uniform("projection_matrix", camera.get_projection_matrix());
|
|
glsafe(::glEnable(GL_POLYGON_OFFSET_LINE));
|
|
glsafe(::glPolygonOffset(-2.0f, -2.0f)); // pull further forward than the wireframe so seams read on top
|
|
// A seam edge is geometrically the same line as a wireframe edge, so a mere polygon offset is a
|
|
// fragile way to make the red seam beat the white wireframe - drivers apply GL_POLYGON_OFFSET_LINE
|
|
// inconsistently, and the two lines then z-fight and the wireframe wins. When the wireframe is on,
|
|
// or while actively marking, just draw the seams with depth testing off so they are unconditionally
|
|
// on top - being visible is the one thing this overlay has to guarantee.
|
|
const bool seams_on_top = m_wireframe_overlay || m_seam_edit_mode;
|
|
if (seams_on_top)
|
|
glsafe(::glDisable(GL_DEPTH_TEST));
|
|
#if !SLIC3R_OPENGL_ES
|
|
const bool wide = !OpenGLManager::get_gl_info().is_core_profile();
|
|
if (wide)
|
|
glsafe(::glLineWidth(4.0f));
|
|
#endif // !SLIC3R_OPENGL_ES
|
|
if (have_marked) {
|
|
m_seam_glmodel.set_color(ColorRGBA(1.0f, 0.15f, 0.15f, 1.0f)); // Blender's seam red
|
|
m_seam_glmodel.render();
|
|
}
|
|
// The edge a click would toggle, in yellow and pulled the furthest forward, so it is unmistakable
|
|
// which edge is being targeted while marking seams.
|
|
if (have_hover) {
|
|
glsafe(::glPolygonOffset(-3.0f, -3.0f));
|
|
m_seam_hover_glmodel.set_color(ColorRGBA(1.0f, 0.9f, 0.15f, 1.0f));
|
|
m_seam_hover_glmodel.render();
|
|
}
|
|
// The shortest-path start vertex, shown as its ring of incident edges in green.
|
|
if (have_anchor) {
|
|
glsafe(::glPolygonOffset(-3.0f, -3.0f));
|
|
m_seam_anchor_glmodel.set_color(ColorRGBA(0.2f, 1.0f, 0.4f, 1.0f));
|
|
m_seam_anchor_glmodel.render();
|
|
}
|
|
#if !SLIC3R_OPENGL_ES
|
|
if (wide)
|
|
glsafe(::glLineWidth(1.0f));
|
|
#endif // !SLIC3R_OPENGL_ES
|
|
glsafe(::glDisable(GL_POLYGON_OFFSET_LINE));
|
|
if (seams_on_top)
|
|
glsafe(::glEnable(GL_DEPTH_TEST));
|
|
shader->stop_using();
|
|
}
|
|
|
|
void GLGizmoTextureDisplacement::render_preview_mesh()
|
|
{
|
|
const ModelObject *mo = m_c->selection_info()->model_object();
|
|
const ModelVolume *mv = texture_volume();
|
|
if (mo == nullptr || mv == 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;
|
|
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);
|
|
m_preview_glmodel.render();
|
|
shader->stop_using();
|
|
}
|
|
|
|
std::vector<Vec2f> GLGizmoTextureDisplacement::compute_layer_vertex_uvs(const indexed_triangle_set &patch,
|
|
const TextureDisplacementLayer &layer) const
|
|
{
|
|
if (layer.projection_method == TextureProjectionMethod::LSCM)
|
|
return compute_lscm_uvs(patch, layer); // one final uv per patch vertex (0 where unassigned)
|
|
if (layer.projection_method == TextureProjectionMethod::ViewProjected) {
|
|
std::vector<Vec2f> uv(patch.vertices.size());
|
|
for (size_t vi = 0; vi < patch.vertices.size(); ++vi) {
|
|
if (layer.view_project_projective) {
|
|
// Matches sample_layer_height()'s projective branch, including skipping
|
|
// apply_uv_transform(). A vertex behind the projector gets a uv far outside [0,1] so
|
|
// it samples as nothing, rather than the mirrored coordinate a blind divide gives.
|
|
if (!project_uv_projective(layer.view_project_matrix, patch.vertices[vi], uv[vi]))
|
|
uv[vi] = Vec2f(-1e6f, -1e6f);
|
|
continue;
|
|
}
|
|
const Vec2f planar(patch.vertices[vi].dot(layer.view_project_right),
|
|
patch.vertices[vi].dot(layer.view_project_up));
|
|
uv[vi] = apply_uv_transform(planar, layer);
|
|
}
|
|
return uv;
|
|
}
|
|
return {}; // Triplanar / Cylindrical / Spherical: the shader projects on its own
|
|
}
|
|
|
|
void GLGizmoTextureDisplacement::rebuild_bump_preview_mesh()
|
|
{
|
|
m_bump_preview_glmodel.reset();
|
|
|
|
const ModelVolume *mv = texture_volume();
|
|
if (mv == nullptr || m_triangle_selectors.empty())
|
|
return;
|
|
|
|
// Uses the *live* selector (not the flushed model facet data), so this reflects an in-progress
|
|
// stroke immediately rather than only once it ends - the point of this preview mode is to be
|
|
// the fast, no-CPU-meshing one.
|
|
const indexed_triangle_set patch = m_triangle_selectors[0]->get_facets_strict(EnforcerBlockerType::ENFORCER);
|
|
if (patch.indices.empty())
|
|
return;
|
|
|
|
const indexed_triangle_set &base = mv->mesh().its;
|
|
if (base.vertices.size() != patch.vertices.size())
|
|
return; // shouldn't happen: get_facets_strict() always returns the full vertex array
|
|
|
|
// Unpainted triangles, so the surrounding surface still renders (the render path hides the real
|
|
// model in bump mode). get_facets_strict() returns the same vertex array whatever state is asked.
|
|
const indexed_triangle_set rest = m_triangle_selectors[0]->get_facets_strict(EnforcerBlockerType::NONE);
|
|
|
|
// For LSCM we hand the shader the finished per-vertex texture uv (island placement + tiling/
|
|
// rotation/offset already folded in, exactly what the bake samples), because it cannot be
|
|
// reconstructed in the fragment shader the way a triplanar projection can. This is also what
|
|
// makes the fast preview follow the UV editor: the uvs move when an island is dragged, so this
|
|
// mesh rebuilds (on drag end) with them. The other projections keep projecting in-shader.
|
|
const TextureDisplacementLayer *active = active_layer();
|
|
std::vector<Vec2f> vertex_uv = active != nullptr ? compute_layer_vertex_uvs(patch, *active) : std::vector<Vec2f>{};
|
|
m_bump_preview_uses_vertex_uv = vertex_uv.size() == base.vertices.size();
|
|
if (!m_bump_preview_uses_vertex_uv)
|
|
vertex_uv.clear();
|
|
|
|
GLModel::Geometry init_data;
|
|
// P3N3T2: normal.x carries the paint weight, tex_coord the precomputed uv (see the vertex shader).
|
|
init_data.format = { GLModel::Geometry::EPrimitiveType::Triangles, GLModel::Geometry::EVertexLayout::P3N3T2 };
|
|
|
|
// Per-triangle weighting via a *flat* (unshared-vertex) mesh: every corner of a painted triangle
|
|
// gets weight 1, every corner of an unpainted one weight 0. This is what a coarse mesh needs - one
|
|
// painted face of a raw cube has no strictly-interior vertex (all 8 are shared), so per-vertex
|
|
// weighting would either bleed onto the neighbours (boundary weight 1) or vanish outright (boundary
|
|
// weight 0, which is what made a single face show nothing). Duplicating vertices costs no shading
|
|
// quality here because the bump shader takes its surface normal from screen-space derivatives of
|
|
// position (dFdx/dFdy), not from a per-vertex normal. normal.y flags the UV-editor island being
|
|
// dragged so the shader can move just that island via the island_delta uniform.
|
|
const bool have_active = m_bump_active_chart >= 0 && !m_bump_active_vertex.empty();
|
|
const size_t tri_total = patch.indices.size() + rest.indices.size();
|
|
init_data.reserve_vertices(tri_total * 3);
|
|
init_data.reserve_indices(tri_total * 3);
|
|
unsigned vcount = 0;
|
|
const auto emit_triangles = [&](const indexed_triangle_set &its, float weight) {
|
|
for (const stl_triangle_vertex_indices &tri : its.indices) {
|
|
for (int i = 0; i < 3; ++i) {
|
|
const int idx = tri[i];
|
|
const float act = (have_active && idx >= 0 && size_t(idx) < m_bump_active_vertex.size() &&
|
|
m_bump_active_vertex[size_t(idx)]) ? 1.f : 0.f;
|
|
const Vec2f uv = (weight > 0.5f && m_bump_preview_uses_vertex_uv && size_t(idx) < vertex_uv.size()) ?
|
|
vertex_uv[size_t(idx)] : Vec2f::Zero();
|
|
init_data.add_vertex(its.vertices[size_t(idx)], Vec3f(weight, act, 0.f), uv);
|
|
}
|
|
init_data.add_triangle(vcount, vcount + 1, vcount + 2);
|
|
vcount += 3;
|
|
}
|
|
};
|
|
emit_triangles(patch, 1.f); // painted -> bumped
|
|
emit_triangles(rest, 0.f); // untouched surface -> flat, so it still shows but isn't bumped
|
|
|
|
m_bump_preview_glmodel.init_from(std::move(init_data));
|
|
// GLModel::render() unconditionally re-sets the shader's "uniform_color" from this internal
|
|
// color field right before drawing (see GLModel.cpp) - setting the uniform manually in
|
|
// render_bump_preview_mesh() would just get overwritten by it, so it must be set here instead.
|
|
// GLModel::Geometry defaults to BLACK, which is exactly what showed up before this was added.
|
|
m_bump_preview_glmodel.set_color(GLVolume::NEUTRAL_COLOR);
|
|
|
|
// The mesh now reflects the islands' current placement, so any live drag delta is measured from
|
|
// here: reset it to identity and record the dragged island's baked transform.
|
|
m_bump_island_delta = Eigen::Matrix<float, 2, 3>::Identity();
|
|
const TextureDisplacementLayer *al = active_layer();
|
|
if (m_bump_active_chart >= 0 && al != nullptr) {
|
|
const std::vector<Eigen::Matrix<float, 2, 3>> xf = uv_editor_island_transforms(*al);
|
|
m_bump_baked_active_xf = (size_t(m_bump_active_chart) < xf.size()) ? xf[size_t(m_bump_active_chart)]
|
|
: Eigen::Matrix<float, 2, 3>::Identity();
|
|
} else {
|
|
m_bump_baked_active_xf = Eigen::Matrix<float, 2, 3>::Identity();
|
|
}
|
|
}
|
|
|
|
void GLGizmoTextureDisplacement::compute_bump_active_vertices(const std::vector<int> &charts)
|
|
{
|
|
m_bump_active_vertex.clear();
|
|
const ModelVolume *mv = texture_volume();
|
|
if (mv == nullptr || charts.empty())
|
|
return;
|
|
const PatchUnwrap &u = m_uv_editor_unwrap;
|
|
m_bump_active_vertex.assign(mv->mesh().its.vertices.size(), 0);
|
|
// Flag the base vertices of every chart being moved. For a group/multi move that is more than one
|
|
// chart, but since such a move is a pure translation the shader applies the same delta to them all
|
|
// (see on_island_edited) - exactly the "joined islands move together" behaviour.
|
|
for (size_t i = 0; i < u.uvs.size(); ++i) {
|
|
if (i >= u.vertex_chart.size() ||
|
|
std::find(charts.begin(), charts.end(), u.vertex_chart[i]) == charts.end())
|
|
continue;
|
|
const int sv = (i < u.source_vertex.size()) ? u.source_vertex[i] : -1;
|
|
if (sv >= 0 && size_t(sv) < m_bump_active_vertex.size())
|
|
m_bump_active_vertex[size_t(sv)] = 1;
|
|
}
|
|
}
|
|
|
|
int GLGizmoTextureDisplacement::island_group_of(const std::vector<int> &groups, int c)
|
|
{
|
|
return (c >= 0 && size_t(c) < groups.size() && groups[size_t(c)] >= 0) ? groups[size_t(c)] : c;
|
|
}
|
|
|
|
void GLGizmoTextureDisplacement::join_island_groups(std::vector<int> &groups, int a, int b, int chart_count)
|
|
{
|
|
if (a < 0 || b < 0 || chart_count <= 0)
|
|
return;
|
|
// Materialise to a full explicit table first, so singletons (which were implicit) get a concrete id
|
|
// that the relabel loop below can match on.
|
|
if (int(groups.size()) < chart_count) {
|
|
const size_t old = groups.size();
|
|
groups.resize(size_t(chart_count));
|
|
for (size_t i = old; i < groups.size(); ++i)
|
|
groups[i] = int(i);
|
|
}
|
|
for (size_t i = 0; i < groups.size(); ++i)
|
|
if (groups[i] < 0)
|
|
groups[i] = int(i);
|
|
const int ga = groups[size_t(a)], gb = groups[size_t(b)];
|
|
if (ga == gb)
|
|
return;
|
|
const int g = std::min(ga, gb);
|
|
for (int &x : groups)
|
|
if (x == ga || x == gb)
|
|
x = g;
|
|
}
|
|
|
|
std::vector<int> GLGizmoTextureDisplacement::build_island_move_set(const TextureDisplacementLayer &layer, int primary) const
|
|
{
|
|
std::vector<int> set;
|
|
const int chart_count = std::max(m_uv_editor_unwrap.chart_count, 0);
|
|
|
|
// Seed with the pane's multi-selection (falling back to just the primary if the canvas has none).
|
|
std::vector<int> seeds;
|
|
if (const UVEditorCanvas *canvas = wxGetApp().plater()->get_uv_editor_canvas())
|
|
seeds = canvas->selected_islands();
|
|
if (seeds.empty() && primary >= 0)
|
|
seeds.push_back(primary);
|
|
|
|
const auto add = [&set](int c) {
|
|
if (c >= 0 && std::find(set.begin(), set.end(), c) == set.end())
|
|
set.push_back(c);
|
|
};
|
|
for (int s : seeds) {
|
|
add(s);
|
|
// Pull in every chart sharing s's join group, so a joined pair moves as one.
|
|
const int gs = island_group_of(layer.island_groups, s);
|
|
for (int c = 0; c < chart_count; ++c)
|
|
if (island_group_of(layer.island_groups, c) == gs)
|
|
add(c);
|
|
}
|
|
add(primary); // never leave the primary out, whatever the selection state
|
|
return set;
|
|
}
|
|
|
|
void GLGizmoTextureDisplacement::render_bump_preview_mesh()
|
|
{
|
|
const ModelObject *mo = m_c->selection_info()->model_object();
|
|
const ModelVolume *mv = texture_volume();
|
|
if (mo == nullptr || mv == nullptr || !m_bump_preview_glmodel.is_initialized())
|
|
return;
|
|
|
|
const TextureDisplacementLayer *layer = active_layer();
|
|
if (layer == nullptr || layer->empty())
|
|
return;
|
|
|
|
// Reuses the layer-list panel's already-decoded, already-uploaded GPU thumbnail (smoothing-aware),
|
|
// whose grayscale value lives in the R channel exactly as the shader samples it. Its width/height
|
|
// are read straight off the texture - decoding the PNG here every frame would re-run the smoothing
|
|
// blur on every camera move, which is what tanked the frame rate at high smoothing.
|
|
GLTexture *tex = get_layer_thumbnail(*layer);
|
|
if (tex == nullptr || tex->get_width() <= 0 || tex->get_height() <= 0)
|
|
return;
|
|
|
|
GLShaderProgram *shader = wxGetApp().get_shader("texture_displacement_bump");
|
|
if (shader == 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();
|
|
const Camera &camera = wxGetApp().plater()->get_camera();
|
|
|
|
shader->start_using();
|
|
shader->set_uniform("view_model_matrix", camera.get_view_matrix() * trafo_matrix);
|
|
shader->set_uniform("projection_matrix", camera.get_projection_matrix());
|
|
shader->set_uniform("volume_world_matrix", trafo_matrix);
|
|
const ClippingPlaneDataWrapper clp_data = this->get_clipping_plane_data();
|
|
shader->set_uniform("clipping_plane", clp_data.clp_dataf);
|
|
shader->set_uniform("z_range", clp_data.z_range);
|
|
const Matrix3d view_normal_matrix =
|
|
camera.get_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);
|
|
shader->set_uniform("volume_mirrored", trafo_matrix.matrix().determinant() < 0.0);
|
|
glsafe(::glActiveTexture(GL_TEXTURE0));
|
|
glsafe(::glBindTexture(GL_TEXTURE_2D, tex->get_id()));
|
|
shader->set_uniform("height_tex", 0);
|
|
shader->set_uniform("height_tex_texel", Vec2f(1.f / float(tex->get_width()), 1.f / float(tex->get_height())));
|
|
shader->set_uniform("depth_mm", layer->depth_mm);
|
|
shader->set_uniform("tiling_scale", layer->tiling_scale);
|
|
shader->set_uniform("rotation_rad", layer->rotation_deg * float(M_PI) / 180.f);
|
|
shader->set_uniform("uv_offset", layer->offset);
|
|
shader->set_uniform("invert", layer->invert);
|
|
// When set, the shader samples at the per-vertex uv baked into the mesh (LSCM) rather than
|
|
// projecting; see rebuild_bump_preview_mesh().
|
|
shader->set_uniform("use_vertex_uv", m_bump_preview_uses_vertex_uv);
|
|
// The live UV-editor island drag rides this 2x3 affine (identity except mid-drag); only the flagged
|
|
// island's vertices apply it, so a drag is a uniform update rather than a mesh rebuild.
|
|
const Eigen::Matrix<float, 2, 3> &d = m_bump_island_delta;
|
|
shader->set_uniform("island_delta_lin", std::array<float, 4>{ d(0, 0), d(0, 1), d(1, 0), d(1, 1) });
|
|
shader->set_uniform("island_delta_tr", Vec2f(d(0, 2), d(1, 2)));
|
|
m_bump_preview_glmodel.render();
|
|
glsafe(::glBindTexture(GL_TEXTURE_2D, 0));
|
|
shader->stop_using();
|
|
}
|
|
|
|
void GLGizmoTextureDisplacement::rebuild_uvcheck_mesh()
|
|
{
|
|
m_uvcheck_glmodel.reset();
|
|
if (m_uv_check_mode == UVCheckMode::None)
|
|
return;
|
|
|
|
const ModelVolume *mv = texture_volume();
|
|
if (mv == nullptr || m_triangle_selectors.empty())
|
|
return;
|
|
const indexed_triangle_set patch = m_triangle_selectors[0]->get_facets_strict(EnforcerBlockerType::ENFORCER);
|
|
if (patch.indices.empty())
|
|
return;
|
|
const indexed_triangle_set &base = mv->mesh().its;
|
|
if (base.vertices.size() != patch.vertices.size())
|
|
return;
|
|
const TextureDisplacementLayer *layer = active_layer();
|
|
if (layer == nullptr)
|
|
return;
|
|
|
|
// The checker samples wherever the projection puts it; the projections the shader can't
|
|
// reconstruct (LSCM, ViewProjected) get a precomputed per-vertex uv, the rest project in-shader.
|
|
std::vector<Vec2f> uv = compute_layer_vertex_uvs(patch, *layer);
|
|
const bool have_uvs = uv.size() == base.vertices.size();
|
|
m_uvcheck_uses_vertex_uv = have_uvs;
|
|
|
|
// Per-vertex area distortion in [0,1] (0.5 == ideal), only when both requested and possible.
|
|
std::vector<float> distortion(base.vertices.size(), 0.5f);
|
|
if (m_uv_check_mode == UVCheckMode::Distortion && have_uvs) {
|
|
std::vector<float> tri_log(patch.indices.size(), 0.f);
|
|
for (size_t f = 0; f < patch.indices.size(); ++f) {
|
|
const stl_triangle_vertex_indices &t = patch.indices[f];
|
|
const float a3 = 0.5f * (base.vertices[t[1]] - base.vertices[t[0]]).cross(base.vertices[t[2]] - base.vertices[t[0]]).norm();
|
|
const Vec2f e0 = uv[t[1]] - uv[t[0]];
|
|
const Vec2f e1 = uv[t[2]] - uv[t[0]];
|
|
const float a2 = 0.5f * std::abs(e0.x() * e1.y() - e0.y() * e1.x());
|
|
tri_log[f] = (a3 > 1e-12f && a2 > 1e-12f) ? std::log2(a2 / a3) : 0.f;
|
|
}
|
|
// Centre the heatmap on the patch's own median stretch, so a globally-scaled unwrap reads as
|
|
// uniformly "ideal" and only *relative* stretching (the thing that matters) shows up as colour.
|
|
std::vector<float> sorted = tri_log;
|
|
float median = 0.f;
|
|
if (!sorted.empty()) {
|
|
std::nth_element(sorted.begin(), sorted.begin() + sorted.size() / 2, sorted.end());
|
|
median = sorted[sorted.size() / 2];
|
|
}
|
|
std::vector<float> sum(base.vertices.size(), 0.f);
|
|
std::vector<int> cnt(base.vertices.size(), 0);
|
|
for (size_t f = 0; f < patch.indices.size(); ++f) {
|
|
// +/- 2 stops (4x stretch either way) spans the full blue->red range.
|
|
const float d = std::clamp(0.5f + (tri_log[f] - median) / 4.f, 0.f, 1.f);
|
|
for (int k = 0; k < 3; ++k) {
|
|
sum[patch.indices[f][k]] += d;
|
|
++cnt[patch.indices[f][k]];
|
|
}
|
|
}
|
|
for (size_t v = 0; v < distortion.size(); ++v)
|
|
if (cnt[v] > 0)
|
|
distortion[v] = sum[v] / float(cnt[v]);
|
|
}
|
|
|
|
GLModel::Geometry init_data;
|
|
init_data.format = { GLModel::Geometry::EPrimitiveType::Triangles, GLModel::Geometry::EVertexLayout::P3N3T2 };
|
|
init_data.reserve_vertices(base.vertices.size());
|
|
init_data.reserve_indices(patch.indices.size() * 3);
|
|
for (size_t vi = 0; vi < base.vertices.size(); ++vi)
|
|
init_data.add_vertex(base.vertices[vi], Vec3f(distortion[vi], 0.f, 0.f),
|
|
have_uvs ? uv[vi] : Vec2f::Zero());
|
|
for (const stl_triangle_vertex_indices &tri : patch.indices)
|
|
init_data.add_triangle(unsigned(tri[0]), unsigned(tri[1]), unsigned(tri[2]));
|
|
|
|
m_uvcheck_glmodel.init_from(std::move(init_data));
|
|
}
|
|
|
|
void GLGizmoTextureDisplacement::render_uvcheck_mesh()
|
|
{
|
|
const ModelObject *mo = m_c->selection_info()->model_object();
|
|
const ModelVolume *mv = texture_volume();
|
|
if (mo == nullptr || mv == nullptr || !m_uvcheck_glmodel.is_initialized())
|
|
return;
|
|
const TextureDisplacementLayer *layer = active_layer();
|
|
if (layer == nullptr)
|
|
return;
|
|
GLShaderProgram *shader = wxGetApp().get_shader("texture_displacement_uvcheck");
|
|
if (shader == 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();
|
|
const Camera &camera = wxGetApp().plater()->get_camera();
|
|
|
|
shader->start_using();
|
|
shader->set_uniform("view_model_matrix", camera.get_view_matrix() * trafo_matrix);
|
|
shader->set_uniform("projection_matrix", camera.get_projection_matrix());
|
|
shader->set_uniform("volume_world_matrix", trafo_matrix);
|
|
const ClippingPlaneDataWrapper clp_data = this->get_clipping_plane_data();
|
|
shader->set_uniform("clipping_plane", clp_data.clp_dataf);
|
|
shader->set_uniform("z_range", clp_data.z_range);
|
|
const Matrix3d view_normal_matrix =
|
|
camera.get_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);
|
|
shader->set_uniform("volume_mirrored", trafo_matrix.matrix().determinant() < 0.0);
|
|
shader->set_uniform("mode", m_uv_check_mode == UVCheckMode::Distortion ? 1 : 0);
|
|
shader->set_uniform("checker_freq", 4.f); // squares per texture tile
|
|
shader->set_uniform("tiling_scale", layer->tiling_scale);
|
|
shader->set_uniform("rotation_rad", layer->rotation_deg * float(M_PI) / 180.f);
|
|
shader->set_uniform("uv_offset", layer->offset);
|
|
shader->set_uniform("use_vertex_uv", m_uvcheck_uses_vertex_uv);
|
|
|
|
// Coincident with the base surface, so pull it toward the camera to win the depth test.
|
|
glsafe(::glEnable(GL_POLYGON_OFFSET_FILL));
|
|
glsafe(::glPolygonOffset(-1.0f, -1.0f));
|
|
m_uvcheck_glmodel.render();
|
|
glsafe(::glDisable(GL_POLYGON_OFFSET_FILL));
|
|
shader->stop_using();
|
|
}
|
|
|
|
void GLGizmoTextureDisplacement::build_wireframe_from_its(const indexed_triangle_set &its)
|
|
{
|
|
m_wireframe_overlay_glmodel.reset();
|
|
m_wireframe_overlay_vcount = its.vertices.size();
|
|
if (its.indices.empty())
|
|
return;
|
|
|
|
GLModel::Geometry init_data;
|
|
init_data.format = { GLModel::Geometry::EPrimitiveType::Lines, GLModel::Geometry::EVertexLayout::P3 };
|
|
init_data.reserve_vertices(its.vertices.size());
|
|
init_data.reserve_indices(its.indices.size() * 6);
|
|
for (const Vec3f &v : its.vertices)
|
|
init_data.add_vertex(v);
|
|
// One segment per triangle edge; shared edges drawn twice, harmless for a wireframe and far
|
|
// cheaper than deduplicating a million of them.
|
|
for (const stl_triangle_vertex_indices &tri : its.indices)
|
|
for (int i = 0; i < 3; ++i)
|
|
init_data.add_line(unsigned(tri[i]), unsigned(tri[(i + 1) % 3]));
|
|
|
|
if (!init_data.is_empty())
|
|
m_wireframe_overlay_glmodel.init_from(std::move(init_data));
|
|
}
|
|
|
|
void GLGizmoTextureDisplacement::rebuild_wireframe_overlay()
|
|
{
|
|
if (!m_wireframe_overlay) {
|
|
m_wireframe_overlay_glmodel.reset();
|
|
m_wireframe_overlay_vcount = 0;
|
|
return;
|
|
}
|
|
const ModelVolume *mv = texture_volume();
|
|
if (mv == nullptr)
|
|
return;
|
|
const indexed_triangle_set &its = mv->mesh().its;
|
|
if (its.indices.empty())
|
|
return;
|
|
|
|
// Building from the base mesh (bump/paint mode); its topology only changes on bake/subdivide, and
|
|
// this runs on every rebuild_preview(), so rebuild only when the vertex count actually changes.
|
|
if (m_wireframe_overlay_glmodel.is_initialized() && m_wireframe_overlay_vcount == its.vertices.size())
|
|
return;
|
|
build_wireframe_from_its(its);
|
|
}
|
|
|
|
void GLGizmoTextureDisplacement::refresh_wireframe()
|
|
{
|
|
if (!m_wireframe_overlay) {
|
|
m_wireframe_overlay_glmodel.reset();
|
|
m_wireframe_overlay_vcount = 0;
|
|
return;
|
|
}
|
|
// The wireframe has to sit on whatever mesh is actually on screen. In the true-displacement view
|
|
// that is the raised preview geometry (m_preview_its) - drawing the flat base mesh's edges there
|
|
// leaves them buried inside the bumps, which is why the wireframe "didn't show in real mode". In
|
|
// Fast (bump) mode or with nothing painted, the surface is the undisplaced base mesh.
|
|
if (!m_use_bump_preview && !m_preview_its.indices.empty())
|
|
build_wireframe_from_its(m_preview_its);
|
|
else
|
|
rebuild_wireframe_overlay();
|
|
}
|
|
|
|
void GLGizmoTextureDisplacement::render_wireframe_overlay()
|
|
{
|
|
const ModelObject *mo = m_c->selection_info()->model_object();
|
|
const ModelVolume *mv = texture_volume();
|
|
if (mo == nullptr || mv == nullptr || !m_wireframe_overlay_glmodel.is_initialized())
|
|
return;
|
|
GLShaderProgram *shader = wxGetApp().get_shader("flat");
|
|
if (shader == 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();
|
|
const Camera &camera = wxGetApp().plater()->get_camera();
|
|
|
|
shader->start_using();
|
|
shader->set_uniform("view_model_matrix", camera.get_view_matrix() * trafo_matrix);
|
|
shader->set_uniform("projection_matrix", camera.get_projection_matrix());
|
|
// Pull the lines toward the camera so they sit on the surface rather than z-fighting into it.
|
|
glsafe(::glEnable(GL_POLYGON_OFFSET_LINE));
|
|
glsafe(::glPolygonOffset(-1.0f, -1.0f));
|
|
m_wireframe_overlay_glmodel.set_color(ColorRGBA(1.0f, 1.0f, 1.0f, 0.6f)); // white, so it reads on any material
|
|
m_wireframe_overlay_glmodel.render();
|
|
glsafe(::glDisable(GL_POLYGON_OFFSET_LINE));
|
|
shader->stop_using();
|
|
}
|
|
|
|
void GLGizmoTextureDisplacement::rebuild_preview()
|
|
{
|
|
// Bumped first: any in-flight job's result (captured generation from before this call) will
|
|
// now compare unequal to m_preview_generation and be discarded when it completes, even if it
|
|
// finishes after the job queued below.
|
|
const uint64_t generation = ++m_preview_generation;
|
|
update_uv_editor();
|
|
rebuild_bump_preview_mesh();
|
|
rebuild_uvcheck_mesh();
|
|
rebuild_seam_overlay();
|
|
// The adaptive subdivision preview is driven by the painted area, so it has to follow the paint
|
|
// while it is open - a plain stroke changes which triangles would be refined. The uniform preview
|
|
// depends only on the mesh, so it is left to its own controls.
|
|
if (m_subdivide_editing && m_subdivide_adaptive)
|
|
rebuild_subdivide_preview();
|
|
|
|
const ModelVolume *mv = texture_volume();
|
|
if (mv == nullptr || !mv->is_texture_displacement_painted()) {
|
|
m_preview_glmodel.reset();
|
|
m_preview_its = indexed_triangle_set{}; // no displaced mesh; wireframe falls back to the base
|
|
refresh_wireframe();
|
|
return;
|
|
}
|
|
// In Fast/paint modes the wireframe follows the base mesh and can be built now; the true-displacement
|
|
// view's wireframe needs the displaced mesh, which only exists once the job below completes.
|
|
if (m_use_bump_preview)
|
|
refresh_wireframe();
|
|
|
|
TextureDisplacementPreviewInput input;
|
|
input.base_mesh = mv->mesh().its;
|
|
input.layers = mv->texture_displacement_layers;
|
|
input.options = mv->texture_displacement_options;
|
|
for (int i = 0; i < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++i)
|
|
input.facets_data[size_t(i)] = mv->texture_displacement_facet(i).get_data();
|
|
|
|
auto &worker = wxGetApp().plater()->get_ui_job_worker();
|
|
queue_job(worker, std::make_unique<TextureDisplacementPreviewJob>(std::move(input), generation,
|
|
[this](indexed_triangle_set its, uint64_t result_generation) {
|
|
if (result_generation != m_preview_generation)
|
|
return; // superseded by a newer edit while this was computing
|
|
m_preview_glmodel.reset();
|
|
if (!its.indices.empty()) {
|
|
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();
|
|
m_parent.set_as_dirty();
|
|
}));
|
|
}
|
|
|
|
void GLGizmoTextureDisplacement::update_uv_editor()
|
|
{
|
|
Plater *plater = wxGetApp().plater();
|
|
UVEditorCanvas *uv_canvas = plater->get_uv_editor_canvas();
|
|
if (uv_canvas == nullptr)
|
|
return;
|
|
|
|
const ModelVolume *mv = texture_volume();
|
|
TextureDisplacementLayer *layer = active_layer();
|
|
// The pane is opened only on the user's explicit request (m_show_uv_editor), and only for an LSCM
|
|
// layer - never automatically just because something is painted. Keep the cached state/unwrap so
|
|
// that switching the toggle back on re-shows instantly (and re-solves if the paint changed while
|
|
// it was hidden, via the state comparison below).
|
|
if (!m_show_uv_editor || mv == nullptr || layer == nullptr ||
|
|
layer->projection_method != TextureProjectionMethod::LSCM) {
|
|
plater->show_uv_editor(false);
|
|
return;
|
|
}
|
|
|
|
// A vertex/edge edit committing (or an undo reverting one) changes the per-vertex UV overrides
|
|
// without going through the Unwrap button. Detect that and force a re-solve, so the pane's geometry
|
|
// stays in step with what will bake - the one exception to "only re-solve on Unwrap".
|
|
{
|
|
size_t sig = 1469598103934665603ull; // FNV-1a seed
|
|
const auto mix = [&sig](uint64_t x) { sig = (sig ^ x) * 1099511628211ull; };
|
|
mix(layer->lscm_uv_overrides.size());
|
|
for (const auto &[v, uv] : layer->lscm_uv_overrides) {
|
|
mix(uint64_t(uint32_t(v)));
|
|
mix(uint64_t(uint32_t(int32_t(std::llround(uv.x() * 1024.f)))));
|
|
mix(uint64_t(uint32_t(int32_t(std::llround(uv.y() * 1024.f)))));
|
|
}
|
|
if (sig != m_uv_overrides_sig) {
|
|
m_uv_overrides_sig = sig;
|
|
m_uv_unwrap_pending = true;
|
|
}
|
|
}
|
|
|
|
UVEditorState state;
|
|
state.slot = m_active_layer_slot;
|
|
state.image_data = layer->image_data.get();
|
|
state.seam_angle = layer->lscm_seam_angle_deg;
|
|
state.padding = layer->island_padding_mm;
|
|
state.facets = mv->texture_displacement_facet(m_active_layer_slot).get_data();
|
|
state.seam_edges = layer->lscm_seam_edges;
|
|
|
|
// The re-solve happens only when the user pressed "Unwrap" (m_uv_unwrap_pending). Every other call
|
|
// into here - a paint stroke ending, a slider release, the check mode changing - must not pay for
|
|
// a fresh LSCM solve; it just re-applies the cheap affine transforms over whatever unwrap already
|
|
// exists. If the paint changed underneath but the user hasn't asked to re-unwrap, the pane keeps
|
|
// showing the last unwrap on purpose (that is the whole point of making it an explicit action).
|
|
bool unwrap_changed = false;
|
|
if (m_uv_unwrap_pending) {
|
|
m_uv_unwrap_pending = false;
|
|
const indexed_triangle_set patch = extract_painted_patch(mv->mesh().its, state.facets);
|
|
if (patch.indices.empty()) {
|
|
m_uv_editor_state = UVEditorState{};
|
|
m_uv_editor_unwrap = PatchUnwrap{};
|
|
m_uv_editor_distortion_colors.clear();
|
|
plater->show_uv_editor(false);
|
|
return;
|
|
}
|
|
// Padding disabled (0): the user asked to pack islands with no gap between them.
|
|
m_uv_editor_unwrap = compute_patch_unwrap(patch, layer->lscm_seam_angle_deg, 0.f, layer->lscm_seam_edges);
|
|
// Re-apply any stored per-vertex UV edits onto the fresh unwrap, so the pane shows exactly what
|
|
// compute_lscm_uvs() will bake (which applies the same overrides). Keyed by mesh vertex, so every
|
|
// unwrapped copy of that vertex gets it - matching the bake's single-UV-per-vertex settle.
|
|
if (!layer->lscm_uv_overrides.empty()) {
|
|
std::map<int, Vec2f> ov;
|
|
for (const auto &[mv2, uv] : layer->lscm_uv_overrides)
|
|
ov[mv2] = uv;
|
|
for (size_t i = 0; i < m_uv_editor_unwrap.uvs.size(); ++i) {
|
|
const int sv = (i < m_uv_editor_unwrap.source_vertex.size()) ? m_uv_editor_unwrap.source_vertex[i] : -1;
|
|
const auto it = ov.find(sv);
|
|
if (it != ov.end())
|
|
m_uv_editor_unwrap.uvs[i] = it->second;
|
|
}
|
|
}
|
|
m_uv_editor_state = std::move(state);
|
|
unwrap_changed = true;
|
|
// Precompute the distortion heatmap now, while the patch is in hand - relative stretch doesn't
|
|
// change when islands are only moved, so this need not be redone on a drag. It is fed to the
|
|
// canvas below only while the Distortion check mode is on.
|
|
compute_uv_editor_distortion_colors(patch);
|
|
}
|
|
|
|
if (m_uv_editor_unwrap.empty()) {
|
|
// Nothing has been unwrapped yet (or the paint was cleared): keep the pane hidden until the user
|
|
// presses Unwrap. The panel shows a "Press Unwrap" hint in this state.
|
|
plater->show_uv_editor(false);
|
|
return;
|
|
}
|
|
|
|
// The pane background either mirrors the height texture (default) or shows a UV checker (#7), and is
|
|
// only re-uploaded when that choice, or the unwrap, actually changes - the height image is large.
|
|
const UVBackground desired_bg = (m_uv_check_mode == UVCheckMode::Checker) ? UVBackground::Checker : UVBackground::Height;
|
|
const bool bg_smoothing_changed = (desired_bg == UVBackground::Height) && (m_uv_editor_bg_smoothing != layer->smoothing);
|
|
if (unwrap_changed || desired_bg != m_uv_editor_bg || bg_smoothing_changed) {
|
|
m_uv_editor_bg_smoothing = layer->smoothing;
|
|
if (desired_bg == UVBackground::Checker) {
|
|
// An even squares-per-axis count so the pattern tiles seamlessly across the UV unit
|
|
// boundary (texcoord == position repeats it once per tile). Softened grays, not pure
|
|
// black/white, so it doesn't fight the island wires drawn over it.
|
|
constexpr int tex = 512, squares = 8, cell = tex / squares;
|
|
std::vector<unsigned char> checker(size_t(tex) * size_t(tex));
|
|
for (int y = 0; y < tex; ++y)
|
|
for (int x = 0; x < tex; ++x)
|
|
checker[size_t(y) * tex + x] = ((x / cell + y / cell) & 1) ? 205 : 70;
|
|
uv_canvas->set_background_texture(checker, tex, tex);
|
|
} else {
|
|
const DecodedHeightTexture height = decode_height_texture(*layer);
|
|
if (!height.empty())
|
|
uv_canvas->set_background_texture(height.pixels, height.width, height.height);
|
|
else
|
|
uv_canvas->set_background_texture({}, 0, 0);
|
|
}
|
|
m_uv_editor_bg = desired_bg;
|
|
}
|
|
|
|
// Grow (never shrink) the layer's island list to cover every chart. Shrinking would throw away a
|
|
// hand placement the moment a stroke temporarily merged two islands, and a stale extra entry is
|
|
// harmless - island_transform_matrix() only looks up the charts that actually exist.
|
|
if (layer->islands.size() < size_t(m_uv_editor_unwrap.chart_count))
|
|
layer->islands.resize(size_t(m_uv_editor_unwrap.chart_count));
|
|
|
|
// Connected-net layout (on by default): a *fresh* unwrap is unfolded so adjacent charts sit
|
|
// edge-to-edge (cube -> a net), rather than as separately packed squares. Only when the user pressed
|
|
// Unwrap (m_uv_apply_connected_net) - a re-segmentation renumbers charts anyway, so any hand
|
|
// placement from before is already meaningless. A *refresh* re-solve (a committed vertex edit, or an
|
|
// undo) must NOT relayout, or it would throw away every island placement on every vertex edit.
|
|
if (unwrap_changed && m_uv_apply_connected_net && layer->auto_connect_islands) {
|
|
std::vector<TextureIsland> net = compute_connected_net(m_uv_editor_unwrap);
|
|
if (net.size() == size_t(m_uv_editor_unwrap.chart_count)) {
|
|
if (layer->islands.size() < net.size())
|
|
layer->islands.resize(net.size());
|
|
for (size_t i = 0; i < net.size(); ++i)
|
|
layer->islands[i] = net[i];
|
|
}
|
|
}
|
|
if (unwrap_changed)
|
|
m_uv_apply_connected_net = false; // consumed; a refresh re-solve leaves placements alone
|
|
|
|
// The geometry goes over in the unwrap's *raw* mm coordinates and is only re-uploaded when the
|
|
// unwrap itself changed. Everything a slider or a drag can touch - island placement, tiling,
|
|
// rotation, offset - is an affine map on top of that, so it goes over as one 2x3 matrix per
|
|
// island instead. That is the whole reason dragging an island is now free: a patch of a million
|
|
// triangles has a million UVs to re-transform and re-upload otherwise, and it was doing exactly
|
|
// that on every single mouse-move event.
|
|
if (unwrap_changed) {
|
|
UVEditorCanvas::Islands view;
|
|
view.uvs = m_uv_editor_unwrap.uvs;
|
|
view.indices = m_uv_editor_unwrap.indices;
|
|
view.vertex_island = m_uv_editor_unwrap.vertex_chart;
|
|
view.boundary_edges = m_uv_editor_unwrap.boundary_edges;
|
|
view.island_count = m_uv_editor_unwrap.chart_count;
|
|
|
|
uv_canvas->set_island_edit_callback(
|
|
[this](int island, const Vec2f &offset_delta, float rotation_delta, float scale_factor, bool finished) {
|
|
on_island_edited(island, offset_delta, rotation_delta, scale_factor, finished);
|
|
});
|
|
uv_canvas->set_vertex_edit_callback(
|
|
[this](const std::vector<std::pair<int, Vec2f>> &edits) { on_uv_vertex_edited(edits); });
|
|
uv_canvas->set_command_callback([this](UVEditorCanvas::Command cmd) { on_uv_command(int(cmd)); });
|
|
uv_canvas->set_islands(std::move(view));
|
|
}
|
|
|
|
uv_canvas->set_select_mode(static_cast<UVEditorCanvas::SelectMode>(m_uv_select_mode));
|
|
|
|
uv_canvas->set_uv_transform(layer->tiling_scale, layer->rotation_deg, layer->tile_enabled,
|
|
layer->tile_method == TextureTileMethod::MirroredRepeat);
|
|
uv_canvas->set_island_transforms(uv_editor_island_transforms(*layer));
|
|
// The distortion heatmap tints the island fills only while its check mode is on; otherwise the
|
|
// canvas falls back to its default light-green wash.
|
|
if (m_uv_check_mode == UVCheckMode::Distortion)
|
|
uv_canvas->set_island_fill_colors(m_uv_editor_distortion_colors);
|
|
else
|
|
uv_canvas->set_island_fill_colors({});
|
|
|
|
plater->show_uv_editor(true);
|
|
}
|
|
|
|
void GLGizmoTextureDisplacement::compute_uv_editor_distortion_colors(const indexed_triangle_set &patch)
|
|
{
|
|
m_uv_editor_distortion_colors.clear();
|
|
const PatchUnwrap &u = m_uv_editor_unwrap;
|
|
if (u.empty() || u.chart_count <= 0)
|
|
return;
|
|
|
|
// log2(uv area / 3D area) per unwrap triangle - the same measure the 3D distortion overlay uses.
|
|
std::vector<float> tri_log(u.indices.size(), 0.f);
|
|
std::vector<int> tri_chart(u.indices.size(), -1);
|
|
for (size_t f = 0; f < u.indices.size(); ++f) {
|
|
const stl_triangle_vertex_indices &t = u.indices[f];
|
|
if (t[0] < 0 || size_t(t[0]) >= u.vertex_chart.size())
|
|
continue;
|
|
tri_chart[f] = u.vertex_chart[size_t(t[0])];
|
|
const auto p3 = [&](int uv_idx) -> Vec3f {
|
|
const int sv = (size_t(uv_idx) < u.source_vertex.size()) ? u.source_vertex[size_t(uv_idx)] : -1;
|
|
return (sv >= 0 && size_t(sv) < patch.vertices.size()) ? patch.vertices[size_t(sv)] : Vec3f::Zero();
|
|
};
|
|
const float a3 = 0.5f * (p3(t[1]) - p3(t[0])).cross(p3(t[2]) - p3(t[0])).norm();
|
|
const Vec2f e0 = u.uvs[size_t(t[1])] - u.uvs[size_t(t[0])];
|
|
const Vec2f e1 = u.uvs[size_t(t[2])] - u.uvs[size_t(t[0])];
|
|
const float a2 = 0.5f * std::abs(e0.x() * e1.y() - e0.y() * e1.x());
|
|
tri_log[f] = (a3 > 1e-12f && a2 > 1e-12f) ? std::log2(a2 / a3) : 0.f;
|
|
}
|
|
|
|
// Centre on the median stretch, so a globally scaled unwrap reads as uniform and only *relative*
|
|
// stretching shows up - matching the 3D overlay's convention.
|
|
std::vector<float> sorted = tri_log;
|
|
float median = 0.f;
|
|
if (!sorted.empty()) {
|
|
std::nth_element(sorted.begin(), sorted.begin() + sorted.size() / 2, sorted.end());
|
|
median = sorted[sorted.size() / 2];
|
|
}
|
|
|
|
std::vector<double> chart_sum(size_t(u.chart_count), 0.0);
|
|
std::vector<int> chart_cnt(size_t(u.chart_count), 0);
|
|
for (size_t f = 0; f < tri_log.size(); ++f) {
|
|
const int c = tri_chart[f];
|
|
if (c >= 0 && c < u.chart_count) {
|
|
chart_sum[size_t(c)] += tri_log[f];
|
|
++chart_cnt[size_t(c)];
|
|
}
|
|
}
|
|
|
|
// Blue (compressed) -> green (ideal) -> red (stretched), +/- 2 stops spanning the full range.
|
|
const auto heat = [](float t) -> ColorRGBA {
|
|
t = std::clamp(t, 0.f, 1.f);
|
|
const ColorRGBA blue{ 0.15f, 0.35f, 1.0f, 0.5f }, green{ 0.2f, 0.9f, 0.3f, 0.5f }, red{ 1.0f, 0.2f, 0.15f, 0.5f };
|
|
if (t < 0.5f) { const float s = t * 2.f; return blue * (1.f - s) + green * s; }
|
|
const float s = (t - 0.5f) * 2.f; return green * (1.f - s) + red * s;
|
|
};
|
|
|
|
m_uv_editor_distortion_colors.resize(size_t(u.chart_count), heat(0.5f));
|
|
for (int c = 0; c < u.chart_count; ++c)
|
|
if (chart_cnt[size_t(c)] > 0) {
|
|
const float avg = float(chart_sum[size_t(c)] / chart_cnt[size_t(c)]);
|
|
m_uv_editor_distortion_colors[size_t(c)] = heat(std::clamp(0.5f + (avg - median) / 4.f, 0.f, 1.f));
|
|
}
|
|
}
|
|
|
|
void GLGizmoTextureDisplacement::on_uv_command(int cmd)
|
|
{
|
|
TextureDisplacementLayer *layer = active_layer();
|
|
if (layer == nullptr)
|
|
return;
|
|
|
|
if (cmd == int(UVEditorCanvas::Command::AverageScale)) {
|
|
Plater::TakeSnapshot snapshot(wxGetApp().plater(), _u8L("Average island scale"), UndoRedo::SnapshotType::GizmoAction);
|
|
average_island_scales(layer->islands);
|
|
rebuild_preview();
|
|
} else if (cmd == int(UVEditorCanvas::Command::CutSelectedIsland)) {
|
|
UVEditorCanvas *canvas = wxGetApp().plater()->get_uv_editor_canvas();
|
|
const int chart = canvas != nullptr ? canvas->selected_island() : -1;
|
|
if (chart < 0) {
|
|
show_error(nullptr, _u8L("Select an island in the UV editor first."));
|
|
return;
|
|
}
|
|
Plater::TakeSnapshot snapshot(wxGetApp().plater(), _u8L("Cut texture island"), UndoRedo::SnapshotType::GizmoAction);
|
|
cut_island(*layer, chart);
|
|
rebuild_preview();
|
|
} else if (cmd == int(UVEditorCanvas::Command::JoinSelected)) {
|
|
UVEditorCanvas *canvas = wxGetApp().plater()->get_uv_editor_canvas();
|
|
const int chart = canvas != nullptr ? canvas->selected_island() : -1;
|
|
if (chart < 0) {
|
|
show_error(nullptr, _u8L("Select an island in the UV editor first."));
|
|
return;
|
|
}
|
|
// Join to whichever neighbouring island (one it shares an edge with) is currently placed
|
|
// nearest - i.e. the one it was dragged up against.
|
|
const Eigen::Matrix<float, 2, 3> sel_m = island_transform_matrix(chart, m_uv_editor_unwrap, layer->islands);
|
|
const Vec2f sel_c = sel_m.block<2, 2>(0, 0) * m_uv_editor_unwrap.chart_centroid[size_t(chart)] + sel_m.col(2);
|
|
int best_parent = -1;
|
|
float best_d2 = std::numeric_limits<float>::max();
|
|
TextureIsland best_place, cand;
|
|
for (int p = 0; p < m_uv_editor_unwrap.chart_count; ++p) {
|
|
if (p == chart)
|
|
continue;
|
|
if (!join_chart_placement(m_uv_editor_unwrap, layer->islands, chart, p, cand))
|
|
continue; // not a neighbour
|
|
const Eigen::Matrix<float, 2, 3> pm = island_transform_matrix(p, m_uv_editor_unwrap, layer->islands);
|
|
const Vec2f pc = pm.block<2, 2>(0, 0) * m_uv_editor_unwrap.chart_centroid[size_t(p)] + pm.col(2);
|
|
const float d2 = (pc - sel_c).squaredNorm();
|
|
if (d2 < best_d2) { best_d2 = d2; best_parent = p; best_place = cand; }
|
|
}
|
|
if (best_parent < 0) {
|
|
show_error(nullptr, _u8L("This island has no neighbour it shares an edge with."));
|
|
return;
|
|
}
|
|
Plater::TakeSnapshot snapshot(wxGetApp().plater(), _u8L("Join texture island"), UndoRedo::SnapshotType::GizmoAction);
|
|
if (layer->islands.size() <= size_t(chart))
|
|
layer->islands.resize(size_t(chart) + 1);
|
|
layer->islands[size_t(chart)] = best_place;
|
|
// Record the join so the two (and anything already grouped with either) move together from now
|
|
// on, not just visually snap once.
|
|
join_island_groups(layer->island_groups, chart, best_parent, m_uv_editor_unwrap.chart_count);
|
|
rebuild_preview();
|
|
} else if (cmd == int(UVEditorCanvas::Command::UnjoinSelected)) {
|
|
UVEditorCanvas *canvas = wxGetApp().plater()->get_uv_editor_canvas();
|
|
const int chart = canvas != nullptr ? canvas->selected_island() : -1;
|
|
if (chart < 0 || size_t(chart) >= layer->islands.size()) {
|
|
show_error(nullptr, _u8L("Select an island in the UV editor first."));
|
|
return;
|
|
}
|
|
Plater::TakeSnapshot snapshot(wxGetApp().plater(), _u8L("Unjoin texture island"), UndoRedo::SnapshotType::GizmoAction);
|
|
layer->islands[size_t(chart)] = TextureIsland{}; // back to its own packed position
|
|
// Break its join link too, so it stops moving with the others (the rest stay grouped).
|
|
if (size_t(chart) < layer->island_groups.size())
|
|
layer->island_groups[size_t(chart)] = chart;
|
|
rebuild_preview();
|
|
}
|
|
// FrameAll/ToggleSnap are handled inside the canvas; ProjectFromView is not wired yet.
|
|
}
|
|
|
|
void GLGizmoTextureDisplacement::capture_view_projection(TextureDisplacementLayer &layer)
|
|
{
|
|
const ModelVolume *mv = texture_volume();
|
|
const ModelObject *mo = m_c->selection_info()->model_object();
|
|
if (mv == nullptr || mo == nullptr)
|
|
return;
|
|
|
|
const Camera &camera = wxGetApp().plater()->get_camera();
|
|
const Selection &selection = m_parent.get_selection();
|
|
const Transform3d trafo = mo->instances[selection.get_instance_idx()]->get_transformation().get_matrix() * mv->get_matrix();
|
|
|
|
// The view matrix's rotation rows are the camera axes in world space; bring them into the
|
|
// volume's local frame (where the mesh vertices live) so the projector rides along with the part.
|
|
const Matrix3d view_rot = camera.get_view_matrix().matrix().block<3, 3>(0, 0);
|
|
const Matrix3d trafo_rot = trafo.matrix().block<3, 3>(0, 0);
|
|
const Matrix3d world_to_local = trafo_rot.inverse();
|
|
const Vec3d local_right = world_to_local * Vec3d(view_rot.row(0).transpose());
|
|
const Vec3d local_up = world_to_local * Vec3d(view_rot.row(1).transpose());
|
|
|
|
// Unit axes: the projected planar coordinate must stay in mm so tiling_scale keeps meaning mm.
|
|
layer.view_project_right = local_right.norm() > 1e-9 ? Vec3f(local_right.normalized().cast<float>()) : Vec3f::UnitX();
|
|
layer.view_project_up = local_up.norm() > 1e-9 ? Vec3f(local_up.normalized().cast<float>()) : Vec3f::UnitY();
|
|
}
|
|
|
|
void GLGizmoTextureDisplacement::show_projector(bool show)
|
|
{
|
|
if (!show) {
|
|
if (m_projector_frame != nullptr)
|
|
m_projector_frame->Hide();
|
|
return;
|
|
}
|
|
|
|
if (m_projector_frame == nullptr) {
|
|
// Parented to the main frame, not to Plater: Plater is a wxPanel, and wxFRAME_FLOAT_ON_PARENT
|
|
// wants a real top-level window to float above.
|
|
m_projector_frame = new TextureProjectorFrame(wxGetApp().mainframe);
|
|
m_projector_tex_source = nullptr; // fresh window, nothing uploaded into it yet
|
|
m_projector_tex_smoothing = -1.f;
|
|
m_projector_frame->set_opacity(m_projector_opacity);
|
|
|
|
// Opened centred over the 3D canvas, at about half its size: the frame is meant to be
|
|
// dragged onto part of the model, so starting somewhere on top of it beats the OS's default
|
|
// cascade position, which is often off over the sidebar.
|
|
if (const wxGLCanvas *cnv = m_parent.get_wxglcanvas(); cnv != nullptr) {
|
|
const wxRect area = cnv->GetScreenRect();
|
|
const wxSize size(std::max(160, area.width / 2), std::max(160, area.height / 2));
|
|
m_projector_frame->SetSize(wxRect(area.GetTopLeft() + wxPoint((area.width - size.x) / 2,
|
|
(area.height - size.y) / 2),
|
|
size));
|
|
}
|
|
}
|
|
|
|
m_projector_frame->Show();
|
|
m_projector_frame->Raise();
|
|
update_projector();
|
|
}
|
|
|
|
void GLGizmoTextureDisplacement::update_projector()
|
|
{
|
|
if (m_projector_frame == nullptr || !m_projector_frame->IsShown())
|
|
return;
|
|
|
|
const TextureDisplacementLayer *layer = active_layer();
|
|
if (layer == nullptr || layer->projection_method != TextureProjectionMethod::ViewProjected) {
|
|
m_projector_frame->set_texture({}, 0, 0);
|
|
m_projector_tex_source = nullptr;
|
|
m_projector_tex_smoothing = -1.f;
|
|
return;
|
|
}
|
|
|
|
// Only re-upload when the pixels actually changed - this is reached from the panel's per-edit
|
|
// flush, and rebuilding the bitmap from identical bytes every time would be pure waste.
|
|
if (m_projector_tex_source != layer->image_data.get() || m_projector_tex_smoothing != layer->smoothing) {
|
|
const DecodedHeightTexture height = decode_height_texture(*layer);
|
|
if (height.empty())
|
|
m_projector_frame->set_texture({}, 0, 0);
|
|
else
|
|
m_projector_frame->set_texture(height.pixels, height.width, height.height);
|
|
m_projector_tex_source = layer->image_data.get();
|
|
m_projector_tex_smoothing = layer->smoothing;
|
|
}
|
|
}
|
|
|
|
int GLGizmoTextureDisplacement::apply_projection_frame()
|
|
{
|
|
TextureDisplacementLayer *layer = active_layer();
|
|
const ModelVolume *mv = texture_volume();
|
|
const ModelObject *mo = m_c->selection_info()->model_object();
|
|
wxGLCanvas *cnv = m_parent.get_wxglcanvas();
|
|
if (layer == nullptr || mv == nullptr || mo == nullptr || cnv == nullptr || m_projector_frame == nullptr ||
|
|
!m_projector_frame->IsShown())
|
|
return -1;
|
|
|
|
// The frame's gate, brought from screen coordinates into the GL viewport's pixel space. Two
|
|
// conversions, both necessary: ScreenToClient() because the viewport's origin is the canvas's
|
|
// top-left, not the desktop's, and the retina scale because the viewport is sized in physical
|
|
// pixels (see GLCanvas3D::get_canvas_size()) while wx hands out logical ones.
|
|
const wxRect gate = m_projector_frame->client_rect_on_screen();
|
|
const wxPoint tl = cnv->ScreenToClient(gate.GetTopLeft());
|
|
const double scale = double(m_parent.get_scale());
|
|
const double rx = double(tl.x) * scale, ry = double(tl.y) * scale;
|
|
const double rw = double(gate.width) * scale, rh = double(gate.height) * scale;
|
|
if (rw < 1.0 || rh < 1.0)
|
|
return -1;
|
|
|
|
const Camera &camera = wxGetApp().plater()->get_camera();
|
|
const std::array<int, 4> &vp = camera.get_viewport();
|
|
const Selection &sel = m_parent.get_selection();
|
|
const Transform3d trafo = mo->instances[sel.get_instance_idx()]->get_transformation().get_matrix() * mv->get_matrix();
|
|
|
|
// Local space -> clip space, the same product the renderer uses, so the mapping agrees with what
|
|
// is actually on screen rather than with an idealisation of it.
|
|
const Eigen::Matrix4d K = camera.get_projection_matrix().matrix() * camera.get_view_matrix().matrix() * trafo.matrix();
|
|
|
|
// Window coordinates follow igl::project's convention (as CameraUtils::project does):
|
|
// win_x = vp.x + vp.w * (ndc.x + 1) / 2, and y measured downward as vp.h - win_y_gl.
|
|
// Turning those into uv = (win - rect_origin) / rect_size gives u and v as affine functions of
|
|
// ndc.x and ndc.y, and since ndc = clip.xyz / clip.w, multiplying through by clip.w leaves a
|
|
// plain linear combination of K's rows - i.e. one 3x4 matrix carrying the perspective divide.
|
|
const double A = double(vp[2]) / (2.0 * rw);
|
|
const double B = (double(vp[0]) + double(vp[2]) * 0.5 - rx) / rw;
|
|
const double C = -double(vp[3]) / (2.0 * rh);
|
|
const double D = (double(vp[3]) * 0.5 - double(vp[1]) - ry) / rh;
|
|
|
|
const Eigen::Vector4d row_u = A * K.row(0).transpose() + B * K.row(3).transpose();
|
|
const Eigen::Vector4d row_v = C * K.row(1).transpose() + D * K.row(3).transpose();
|
|
const Eigen::Vector4d row_w = K.row(3).transpose();
|
|
|
|
std::array<float, 12> m{};
|
|
for (int i = 0; i < 4; ++i) {
|
|
m[size_t(i)] = float(row_u[i]);
|
|
m[size_t(4 + i)] = float(row_v[i]);
|
|
m[size_t(8 + i)] = float(row_w[i]);
|
|
}
|
|
|
|
Plater::TakeSnapshot snapshot(wxGetApp().plater(), _u8L("Apply texture projection frame"),
|
|
UndoRedo::SnapshotType::GizmoAction);
|
|
|
|
layer->projection_method = TextureProjectionMethod::ViewProjected;
|
|
layer->view_project_projective = true;
|
|
layer->view_project_matrix = m;
|
|
// Off, so the height sampler returns 0 outside [0,1) and the frame's border becomes a hard edge
|
|
// of the displacement rather than the first seam of an endless repeat.
|
|
layer->tile_enabled = false;
|
|
// The affine axes are kept up to date too, so turning the projective mapping off later leaves a
|
|
// sane flat projection from this same viewpoint instead of whatever was captured long ago.
|
|
capture_view_projection(*layer);
|
|
|
|
const int painted = select_visible_faces(&m);
|
|
m_preview_params_dirty = true;
|
|
rebuild_preview();
|
|
m_parent.set_as_dirty();
|
|
return painted;
|
|
}
|
|
|
|
void GLGizmoTextureDisplacement::cut_island(TextureDisplacementLayer &layer, int chart)
|
|
{
|
|
const ModelVolume *mv = texture_volume();
|
|
if (mv == nullptr)
|
|
return;
|
|
const std::vector<Vec3f> &verts = mv->mesh().its.vertices;
|
|
const PatchUnwrap &u = m_uv_editor_unwrap;
|
|
|
|
// Collect the chart's triangles back in mesh-vertex space, and its 3D bounding box.
|
|
std::vector<std::array<int, 3>> tris;
|
|
Vec3f lo(std::numeric_limits<float>::max(), std::numeric_limits<float>::max(), std::numeric_limits<float>::max());
|
|
Vec3f hi(std::numeric_limits<float>::lowest(), std::numeric_limits<float>::lowest(), std::numeric_limits<float>::lowest());
|
|
for (const stl_triangle_vertex_indices &t : u.indices) {
|
|
if (t[0] < 0 || size_t(t[0]) >= u.vertex_chart.size() || u.vertex_chart[size_t(t[0])] != chart)
|
|
continue;
|
|
std::array<int, 3> bt{};
|
|
bool ok = true;
|
|
for (int k = 0; k < 3; ++k) {
|
|
const int uvv = t[k];
|
|
if (uvv < 0 || size_t(uvv) >= u.source_vertex.size()) { ok = false; break; }
|
|
const int base = u.source_vertex[size_t(uvv)];
|
|
if (base < 0 || size_t(base) >= verts.size()) { ok = false; break; }
|
|
bt[k] = base;
|
|
lo = lo.cwiseMin(verts[size_t(base)]);
|
|
hi = hi.cwiseMax(verts[size_t(base)]);
|
|
}
|
|
if (ok)
|
|
tris.push_back(bt);
|
|
}
|
|
if (tris.empty())
|
|
return;
|
|
|
|
// Cut perpendicular to the longest axis, through the centroid - a long thin island is split
|
|
// across its narrow middle, which is exactly the "islands might be very long" case.
|
|
const Vec3f ext = hi - lo;
|
|
const int axis = (ext.x() >= ext.y() && ext.x() >= ext.z()) ? 0 : (ext.y() >= ext.z() ? 1 : 2);
|
|
const float mid = 0.5f * (lo[axis] + hi[axis]);
|
|
|
|
std::set<std::pair<int, int>> seams(layer.lscm_seam_edges.begin(), layer.lscm_seam_edges.end());
|
|
for (const std::array<int, 3> &bt : tris)
|
|
for (int i = 0; i < 3; ++i) {
|
|
const int a = bt[i], b = bt[(i + 1) % 3];
|
|
// Endpoints on opposite sides of the plane -> this edge crosses it -> make it a seam.
|
|
if ((verts[size_t(a)][axis] < mid) != (verts[size_t(b)][axis] < mid))
|
|
seams.insert({ std::min(a, b), std::max(a, b) });
|
|
}
|
|
layer.lscm_seam_edges.assign(seams.begin(), seams.end());
|
|
}
|
|
|
|
// unwrap mm -> texture uv, per island: the island's own hand placement, then the layer's
|
|
// tiling/rotation/offset. Both are affine, so they compose into one matrix the canvas can hand
|
|
// straight to a shader.
|
|
std::vector<Eigen::Matrix<float, 2, 3>>
|
|
GLGizmoTextureDisplacement::uv_editor_island_transforms(const TextureDisplacementLayer &layer)
|
|
{
|
|
const float uv_scale = (layer.tiling_scale > 1e-6f) ? (1.f / layer.tiling_scale) : 1.f;
|
|
const float rad = layer.rotation_deg * float(M_PI) / 180.f;
|
|
const float cs = std::cos(rad) * uv_scale;
|
|
const float sn = std::sin(rad) * uv_scale;
|
|
|
|
Eigen::Matrix2f uv_linear;
|
|
uv_linear << cs, -sn,
|
|
sn, cs;
|
|
|
|
m_uv_editor_bbox_min = Vec2f(std::numeric_limits<float>::max(), std::numeric_limits<float>::max());
|
|
m_uv_editor_bbox_max = Vec2f(std::numeric_limits<float>::lowest(), std::numeric_limits<float>::lowest());
|
|
|
|
std::vector<Eigen::Matrix<float, 2, 3>> transforms(size_t(std::max(m_uv_editor_unwrap.chart_count, 0)));
|
|
for (int c = 0; c < m_uv_editor_unwrap.chart_count; ++c) {
|
|
const Eigen::Matrix<float, 2, 3> island = island_transform_matrix(c, m_uv_editor_unwrap, layer.islands);
|
|
Eigen::Matrix<float, 2, 3> &m = transforms[size_t(c)];
|
|
m.block<2, 2>(0, 0) = uv_linear * island.block<2, 2>(0, 0);
|
|
m.col(2) = uv_linear * island.col(2) + layer.offset;
|
|
}
|
|
|
|
// Only for the panel's readout; the canvas computes its own bounds.
|
|
for (size_t i = 0; i < m_uv_editor_unwrap.uvs.size(); ++i) {
|
|
const int c = m_uv_editor_unwrap.vertex_chart[i];
|
|
if (c < 0 || size_t(c) >= transforms.size())
|
|
continue;
|
|
const Vec2f uv = transforms[size_t(c)].block<2, 2>(0, 0) * m_uv_editor_unwrap.uvs[i] + transforms[size_t(c)].col(2);
|
|
m_uv_editor_bbox_min = m_uv_editor_bbox_min.cwiseMin(uv);
|
|
m_uv_editor_bbox_max = m_uv_editor_bbox_max.cwiseMax(uv);
|
|
}
|
|
return transforms;
|
|
}
|
|
|
|
void GLGizmoTextureDisplacement::on_island_edited(int island, const Vec2f &offset_delta, float rotation_delta,
|
|
float scale_factor, bool finished)
|
|
{
|
|
TextureDisplacementLayer *layer = active_layer();
|
|
if (layer == nullptr || island < 0)
|
|
return;
|
|
if (size_t(island) >= layer->islands.size())
|
|
layer->islands.resize(size_t(island) + 1);
|
|
|
|
// A pure translation (no rotation, no scale) is the only gesture that moves a whole group/selection
|
|
// together; rotate and scale stay on the primary island alone. This split is what makes flagging a
|
|
// group's worth of vertices on the GPU safe: the shared shader delta is a pure translation, so the
|
|
// same offset is correct for every flagged island.
|
|
const bool is_move = rotation_delta == 0.f && scale_factor == 1.f;
|
|
|
|
if (!m_island_drag_active) {
|
|
// Taken before the first delta lands, so one undo reverts the whole drag rather than just
|
|
// its final mouse-move.
|
|
Plater::TakeSnapshot snapshot(wxGetApp().plater(), _u8L("Move texture island"), UndoRedo::SnapshotType::GizmoAction);
|
|
m_island_drag_active = true;
|
|
// Decide the moved set once, at drag start: the whole selection + join groups for a move, or
|
|
// just the primary for a rotate/scale.
|
|
m_island_move_set = is_move ? build_island_move_set(*layer, island) : std::vector<int>{ island };
|
|
// Set up the GPU drag: flag the moved islands' vertices and bake the mesh once (via the dirty
|
|
// flag). From then on the drag is a uniform update, no rebuild - see render_bump_preview_mesh().
|
|
m_bump_active_chart = island;
|
|
compute_bump_active_vertices(m_island_move_set);
|
|
m_bump_island_delta = Eigen::Matrix<float, 2, 3>::Identity();
|
|
m_bump_preview_dirty = true;
|
|
}
|
|
|
|
// Apply the edit. A move goes to every island in the moved set (same offset -> they translate as
|
|
// one); a rotate/scale goes only to the primary, about its own centroid.
|
|
const std::vector<int> single{ island };
|
|
const std::vector<int> &targets = (is_move && !m_island_move_set.empty()) ? m_island_move_set : single;
|
|
for (int c : targets) {
|
|
if (c < 0)
|
|
continue;
|
|
if (size_t(c) >= layer->islands.size())
|
|
layer->islands.resize(size_t(c) + 1);
|
|
TextureIsland &target = layer->islands[size_t(c)];
|
|
target.offset += offset_delta;
|
|
if (c == island) {
|
|
target.rotation_deg += rotation_delta;
|
|
// Guarded: a scale that reaches zero is unrecoverable (every subsequent factor multiplies it)
|
|
// and would collapse the island to a point - exactly the failure this feature hit once already.
|
|
target.scale = std::clamp(target.scale * scale_factor, 0.001f, 1000.f);
|
|
}
|
|
}
|
|
|
|
if (finished) {
|
|
m_island_drag_active = false;
|
|
m_bump_active_chart = -1;
|
|
m_bump_active_vertex.clear();
|
|
m_island_move_set.clear();
|
|
m_bump_island_delta = Eigen::Matrix<float, 2, 3>::Identity();
|
|
rebuild_preview(); // the real displaced geometry moved: recompute it once, at the end
|
|
} else {
|
|
const std::vector<Eigen::Matrix<float, 2, 3>> xf = uv_editor_island_transforms(*layer);
|
|
if (UVEditorCanvas *uv_canvas = wxGetApp().plater()->get_uv_editor_canvas()) {
|
|
// Live feedback in the pane, and deliberately *just* the transforms: nothing about the
|
|
// unwrap changed, so none of the vertex buffers need touching. This is what makes a drag
|
|
// interactive on a patch with a million triangles.
|
|
uv_canvas->set_island_transforms(xf);
|
|
}
|
|
// Move the island on the model live through the shader's island_delta uniform - no mesh
|
|
// rebuild. delta = F_current * F_baked^-1 in final-uv space (the bump mesh bakes F_baked; the
|
|
// shader applies delta to the flagged island's uv). The one rebuild that bakes the flags is
|
|
// scheduled at drag start above and consumed once per frame by render_painter_gizmo().
|
|
if (m_use_bump_preview && m_bump_active_chart == island && size_t(island) < xf.size()) {
|
|
Eigen::Matrix3f cur = Eigen::Matrix3f::Identity();
|
|
cur.topRows<2>() = xf[size_t(island)];
|
|
Eigen::Matrix3f bak = Eigen::Matrix3f::Identity();
|
|
bak.topRows<2>() = m_bump_baked_active_xf;
|
|
m_bump_island_delta = (cur * bak.inverse()).topRows<2>();
|
|
m_parent.set_as_dirty();
|
|
}
|
|
}
|
|
}
|
|
|
|
void GLGizmoTextureDisplacement::on_uv_vertex_edited(const std::vector<std::pair<int, Vec2f>> &edits)
|
|
{
|
|
TextureDisplacementLayer *layer = active_layer();
|
|
if (layer == nullptr || edits.empty() || m_uv_editor_unwrap.empty())
|
|
return;
|
|
|
|
Plater::TakeSnapshot snapshot(wxGetApp().plater(), _u8L("Edit texture UV"), UndoRedo::SnapshotType::GizmoAction);
|
|
|
|
for (const auto &[unwrapped, raw_uv] : edits) {
|
|
if (unwrapped < 0 || size_t(unwrapped) >= m_uv_editor_unwrap.source_vertex.size())
|
|
continue;
|
|
// Keep the gizmo's own unwrap copy in step, so the pane and the bake agree without a re-solve.
|
|
if (size_t(unwrapped) < m_uv_editor_unwrap.uvs.size())
|
|
m_uv_editor_unwrap.uvs[size_t(unwrapped)] = raw_uv;
|
|
|
|
const int mesh_v = m_uv_editor_unwrap.source_vertex[size_t(unwrapped)];
|
|
if (mesh_v < 0)
|
|
continue;
|
|
// Store (or update) the override for this mesh vertex. Small list, linear scan is fine.
|
|
auto it = std::find_if(layer->lscm_uv_overrides.begin(), layer->lscm_uv_overrides.end(),
|
|
[mesh_v](const std::pair<int, Vec2f> &p) { return p.first == mesh_v; });
|
|
if (it != layer->lscm_uv_overrides.end())
|
|
it->second = raw_uv;
|
|
else
|
|
layer->lscm_uv_overrides.emplace_back(mesh_v, raw_uv);
|
|
}
|
|
|
|
rebuild_preview(); // the baked displacement samples the moved uv now
|
|
}
|
|
|
|
bool GLGizmoTextureDisplacement::update_adjust_anchor()
|
|
{
|
|
const ModelVolume *mv = texture_volume();
|
|
m_adjust_anchor_valid = mv != nullptr &&
|
|
compute_layer_paint_anchor(mv->mesh().its, mv->texture_displacement_facet(m_active_layer_slot).get_data(),
|
|
m_adjust_anchor_pos, m_adjust_anchor_normal);
|
|
return m_adjust_anchor_valid;
|
|
}
|
|
|
|
TextureDisplacementLayer *GLGizmoTextureDisplacement::active_layer()
|
|
{
|
|
ModelVolume *mv = texture_volume();
|
|
if (mv == nullptr)
|
|
return nullptr;
|
|
for (TextureDisplacementLayer &l : mv->texture_displacement_layers)
|
|
if (l.slot == m_active_layer_slot)
|
|
return &l;
|
|
return nullptr;
|
|
}
|
|
|
|
const TextureDisplacementLayer *GLGizmoTextureDisplacement::active_layer() const
|
|
{
|
|
return const_cast<GLGizmoTextureDisplacement *>(this)->active_layer();
|
|
}
|
|
|
|
Vec3f GLGizmoTextureDisplacement::adjust_plane_point() const
|
|
{
|
|
const ModelVolume *mv = texture_volume();
|
|
const float bbox_size = (mv != nullptr) ? float(mv->mesh().bounding_box().size().norm()) : 1.f;
|
|
const float lift = bbox_size * 0.01f + 0.2f; // clear of the surface, to avoid z-fighting
|
|
return m_adjust_anchor_pos + m_adjust_anchor_normal * lift;
|
|
}
|
|
|
|
Vec3f GLGizmoTextureDisplacement::adjust_handle_center(const TextureDisplacementLayer &layer) const
|
|
{
|
|
// apply_uv_transform() maps a planar mm coordinate p to uv = R(p / tiling_scale) + offset, and
|
|
// on_mouse_adjust_texture() drives offset by offset = offset_start - R(delta / tiling_scale).
|
|
// Inverting that, the handle's displacement from the anchor is - R^-1(offset) * tiling_scale -
|
|
// which, substituted into the drag equation, moves the handle by exactly `delta`. So the handle
|
|
// follows the cursor precisely, and is back on the anchor exactly when offset is zero.
|
|
const float rad = layer.rotation_deg * float(M_PI) / 180.f;
|
|
const float cs = std::cos(rad), sn = std::sin(rad);
|
|
const Vec2f unrotated(layer.offset.x() * cs + layer.offset.y() * sn, -layer.offset.x() * sn + layer.offset.y() * cs);
|
|
const Vec2f planar = -unrotated * layer.tiling_scale;
|
|
|
|
Vec3f u_axis, v_axis;
|
|
adjust_tangent_basis(u_axis, v_axis);
|
|
return adjust_plane_point() + u_axis * planar.x() + v_axis * planar.y();
|
|
}
|
|
|
|
void GLGizmoTextureDisplacement::adjust_tangent_basis(Vec3f &u_axis, Vec3f &v_axis) const
|
|
{
|
|
// Mirrors project_planar()'s own dominant-axis choice exactly, so the ring's "0 degrees" and
|
|
// the offset handle's plane always agree with what project_texture_displacement_uv() does.
|
|
const Vec3f n = m_adjust_anchor_normal.cwiseAbs();
|
|
if (n.x() >= n.y() && n.x() >= n.z()) {
|
|
u_axis = Vec3f::UnitY();
|
|
v_axis = Vec3f::UnitZ();
|
|
} else if (n.y() >= n.x() && n.y() >= n.z()) {
|
|
u_axis = Vec3f::UnitX();
|
|
v_axis = Vec3f::UnitZ();
|
|
} else {
|
|
u_axis = Vec3f::UnitX();
|
|
v_axis = Vec3f::UnitY();
|
|
}
|
|
}
|
|
|
|
void GLGizmoTextureDisplacement::render_adjust_texture_gizmo()
|
|
{
|
|
if (!m_adjust_anchor_valid)
|
|
return;
|
|
|
|
const ModelObject *mo = m_c->selection_info()->model_object();
|
|
const ModelVolume *mv = texture_volume();
|
|
const TextureDisplacementLayer *layer = active_layer();
|
|
if (mo == nullptr || mv == nullptr || layer == 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();
|
|
|
|
// Handle sizes scale with the volume so they stay usable on both tiny and huge models.
|
|
const float bbox_size = float(mv->mesh().bounding_box().size().norm());
|
|
const float panel_half = bbox_size * 0.04f + 1.f;
|
|
const float arrow_length = panel_half * 2.2f;
|
|
|
|
// Tracks the layer's offset, so the handle actually travels with the texture as it is dragged.
|
|
const Vec3f handle_center_local = adjust_handle_center(*layer);
|
|
|
|
GLShaderProgram *shader = wxGetApp().get_shader("flat");
|
|
if (shader == nullptr)
|
|
return;
|
|
|
|
glsafe(::glDisable(GL_DEPTH_TEST));
|
|
glsafe(::glEnable(GL_BLEND));
|
|
shader->start_using();
|
|
|
|
const Camera &camera = wxGetApp().plater()->get_camera();
|
|
|
|
Vec3f u_axis, v_axis;
|
|
adjust_tangent_basis(u_axis, v_axis);
|
|
Transform3d plane_transform = Transform3d::Identity();
|
|
plane_transform.linear().col(0) = u_axis.cast<double>();
|
|
plane_transform.linear().col(1) = v_axis.cast<double>();
|
|
plane_transform.linear().col(2) = m_adjust_anchor_normal.cast<double>();
|
|
plane_transform.translation() = handle_center_local.cast<double>();
|
|
|
|
// Pan panel: a flat square lying in the patch's own tangent plane. Dragging anywhere on it
|
|
// moves the texture freely along both axes at once.
|
|
if (!m_adjust_panel_glmodel.is_initialized()) {
|
|
GLModel::Geometry init_data;
|
|
init_data.format = { GLModel::Geometry::EPrimitiveType::Triangles, GLModel::Geometry::EVertexLayout::P3 };
|
|
init_data.reserve_vertices(4);
|
|
init_data.reserve_indices(6);
|
|
init_data.add_vertex(Vec3f(-1.f, -1.f, 0.f));
|
|
init_data.add_vertex(Vec3f(1.f, -1.f, 0.f));
|
|
init_data.add_vertex(Vec3f(1.f, 1.f, 0.f));
|
|
init_data.add_vertex(Vec3f(-1.f, 1.f, 0.f));
|
|
init_data.add_triangle(0, 1, 2);
|
|
init_data.add_triangle(0, 2, 3);
|
|
m_adjust_panel_glmodel.init_from(std::move(init_data));
|
|
}
|
|
Transform3d view_model_matrix = camera.get_view_matrix() * trafo_matrix * plane_transform *
|
|
Geometry::assemble_transform(Vec3d::Zero(), Vec3d::Zero(), Vec3d(panel_half, panel_half, panel_half));
|
|
shader->set_uniform("view_model_matrix", view_model_matrix);
|
|
shader->set_uniform("projection_matrix", camera.get_projection_matrix());
|
|
ColorRGBA panel_color = m_adjust_drag_handle == AdjustHandle::Pan ? ColorRGBA::YELLOW() : ColorRGBA::ORANGE();
|
|
panel_color.a(0.45f);
|
|
m_adjust_panel_glmodel.set_color(panel_color);
|
|
m_adjust_panel_glmodel.render();
|
|
|
|
// Axis arrows: a shaft plus a small V-shaped arrowhead, both along local +X. Reused for both
|
|
// the U and V axes below by swapping which world direction local +X is transformed to.
|
|
if (!m_adjust_arrow_glmodel.is_initialized()) {
|
|
GLModel::Geometry init_data;
|
|
init_data.format = { GLModel::Geometry::EPrimitiveType::Lines, GLModel::Geometry::EVertexLayout::P3 };
|
|
init_data.reserve_vertices(4);
|
|
init_data.reserve_indices(6);
|
|
init_data.add_vertex(Vec3f(0.f, 0.f, 0.f));
|
|
init_data.add_vertex(Vec3f(1.f, 0.f, 0.f));
|
|
init_data.add_vertex(Vec3f(0.82f, 0.08f, 0.f));
|
|
init_data.add_vertex(Vec3f(0.82f, -0.08f, 0.f));
|
|
init_data.add_line(0, 1);
|
|
init_data.add_line(1, 2);
|
|
init_data.add_line(1, 3);
|
|
m_adjust_arrow_glmodel.init_from(std::move(init_data));
|
|
}
|
|
#if !SLIC3R_OPENGL_ES
|
|
if (!OpenGLManager::get_gl_info().is_core_profile())
|
|
glsafe(::glLineWidth(2.0f));
|
|
#endif // !SLIC3R_OPENGL_ES
|
|
|
|
auto render_arrow = [&](const Vec3f &axis, const Vec3f &other_axis, bool is_active) {
|
|
Transform3d arrow_transform = Transform3d::Identity();
|
|
arrow_transform.linear().col(0) = axis.cast<double>();
|
|
arrow_transform.linear().col(1) = other_axis.cast<double>();
|
|
arrow_transform.linear().col(2) = m_adjust_anchor_normal.cast<double>();
|
|
arrow_transform.translation() = handle_center_local.cast<double>();
|
|
|
|
const Transform3d vmm = camera.get_view_matrix() * trafo_matrix * arrow_transform *
|
|
Geometry::assemble_transform(Vec3d::Zero(), Vec3d::Zero(), Vec3d(arrow_length, arrow_length, arrow_length));
|
|
shader->set_uniform("view_model_matrix", vmm);
|
|
shader->set_uniform("projection_matrix", camera.get_projection_matrix());
|
|
m_adjust_arrow_glmodel.set_color(is_active ? ColorRGBA::YELLOW() : ColorRGBA::ORANGE());
|
|
m_adjust_arrow_glmodel.render();
|
|
};
|
|
render_arrow(u_axis, v_axis, m_adjust_drag_handle == AdjustHandle::AxisU);
|
|
render_arrow(v_axis, u_axis, m_adjust_drag_handle == AdjustHandle::AxisV);
|
|
|
|
shader->stop_using();
|
|
glsafe(::glDisable(GL_BLEND));
|
|
glsafe(::glEnable(GL_DEPTH_TEST));
|
|
}
|
|
|
|
bool GLGizmoTextureDisplacement::on_mouse_adjust_texture(const wxMouseEvent &mouse_event)
|
|
{
|
|
if (!m_adjust_anchor_valid)
|
|
return false;
|
|
|
|
ModelVolume *mv = texture_volume();
|
|
ModelObject *mo = m_c->selection_info()->model_object();
|
|
TextureDisplacementLayer *layer = active_layer();
|
|
if (mv == nullptr || mo == nullptr || layer == nullptr)
|
|
return false;
|
|
|
|
const Selection &selection = m_parent.get_selection();
|
|
const Transform3d trafo_matrix = mo->instances[selection.get_instance_idx()]->get_transformation().get_matrix() * mv->get_matrix();
|
|
const Camera &camera = wxGetApp().plater()->get_camera();
|
|
|
|
const float bbox_size = float(mv->mesh().bounding_box().size().norm());
|
|
const float panel_half = bbox_size * 0.04f + 1.f;
|
|
const float arrow_length = panel_half * 2.2f;
|
|
// Where the handle is drawn (moves with the layer's offset) vs. the plane the drag is measured
|
|
// against (fixed at the anchor). Keeping them apart is what stops the handle's own motion from
|
|
// feeding back into the delta that produced it.
|
|
const Vec3f handle_center_local = adjust_handle_center(*layer);
|
|
const Vec3f drag_plane_local = adjust_plane_point();
|
|
const Vec3d handle_center_world = trafo_matrix * handle_center_local.cast<double>();
|
|
const Vec2d mouse_pos(mouse_event.GetX(), mouse_event.GetY());
|
|
|
|
Vec3f u_axis, v_axis;
|
|
adjust_tangent_basis(u_axis, v_axis);
|
|
|
|
const Point handle_screen = CameraUtils::project(camera, handle_center_world);
|
|
const Vec2d handle_screen_d(double(handle_screen.x()), double(handle_screen.y()));
|
|
|
|
// Point-to-segment distance in screen space, for the arrow shafts.
|
|
auto dist_to_segment_px = [](const Vec2d &p, const Vec2d &a, const Vec2d &b) {
|
|
const Vec2d ab = b - a;
|
|
const double len2 = ab.squaredNorm();
|
|
const double t = (len2 > 1e-9) ? std::clamp((p - a).dot(ab) / len2, 0.0, 1.0) : 0.0;
|
|
return (p - (a + ab * t)).norm();
|
|
};
|
|
|
|
if (mouse_event.LeftDown()) {
|
|
const Vec3d u_tip_world = trafo_matrix * (handle_center_local + u_axis * arrow_length).cast<double>();
|
|
const Vec3d v_tip_world = trafo_matrix * (handle_center_local + v_axis * arrow_length).cast<double>();
|
|
const Point u_tip_screen = CameraUtils::project(camera, u_tip_world);
|
|
const Point v_tip_screen = CameraUtils::project(camera, v_tip_world);
|
|
const Vec2d u_tip_screen_d(double(u_tip_screen.x()), double(u_tip_screen.y()));
|
|
const Vec2d v_tip_screen_d(double(v_tip_screen.x()), double(v_tip_screen.y()));
|
|
|
|
// Panel screen-space "radius", approximated from one corner (a loose circle around the
|
|
// square is close enough for hit-testing purposes).
|
|
const Vec3d panel_corner_world = trafo_matrix * (handle_center_local + (u_axis + v_axis) * panel_half).cast<double>();
|
|
const Point panel_corner_screen = CameraUtils::project(camera, panel_corner_world);
|
|
const double panel_screen_radius = (Vec2d(double(panel_corner_screen.x()), double(panel_corner_screen.y())) - handle_screen_d).norm();
|
|
|
|
constexpr double pick_tolerance_px = 8.0;
|
|
const double dist_to_u = dist_to_segment_px(mouse_pos, handle_screen_d, u_tip_screen_d);
|
|
const double dist_to_v = dist_to_segment_px(mouse_pos, handle_screen_d, v_tip_screen_d);
|
|
const double dist_to_panel = (handle_screen_d - mouse_pos).norm();
|
|
|
|
// Arrows take priority over the panel (their tips extend past it), then the panel covers
|
|
// the broader central area.
|
|
if (dist_to_u <= pick_tolerance_px && dist_to_u <= dist_to_v)
|
|
m_adjust_drag_handle = AdjustHandle::AxisU;
|
|
else if (dist_to_v <= pick_tolerance_px)
|
|
m_adjust_drag_handle = AdjustHandle::AxisV;
|
|
else if (dist_to_panel <= panel_screen_radius)
|
|
m_adjust_drag_handle = AdjustHandle::Pan;
|
|
else {
|
|
m_adjust_drag_handle = AdjustHandle::None;
|
|
return false;
|
|
}
|
|
|
|
m_adjust_drag_start_offset = layer->offset;
|
|
|
|
Vec3d world_hit;
|
|
if (ray_plane_hit(camera, mouse_pos, trafo_matrix, drag_plane_local, m_adjust_anchor_normal, world_hit)) {
|
|
const Vec3f local_hit = (trafo_matrix.inverse() * world_hit).cast<float>();
|
|
m_adjust_drag_start_planar = project_planar(local_hit, m_adjust_anchor_normal);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
if (mouse_event.Dragging() && m_adjust_drag_handle != AdjustHandle::None) {
|
|
Vec3d world_hit;
|
|
if (!ray_plane_hit(camera, mouse_pos, trafo_matrix, drag_plane_local, m_adjust_anchor_normal, world_hit))
|
|
return true;
|
|
const Vec3f local_hit = (trafo_matrix.inverse() * world_hit).cast<float>();
|
|
const Vec2f current_planar = project_planar(local_hit, m_adjust_anchor_normal);
|
|
|
|
Vec2f delta_planar = current_planar - m_adjust_drag_start_planar;
|
|
// project_planar()'s (x, y) axes are exactly u_axis/v_axis (see adjust_tangent_basis()),
|
|
// so zeroing one component constrains the drag to only the other axis.
|
|
if (m_adjust_drag_handle == AdjustHandle::AxisU)
|
|
delta_planar.y() = 0.f;
|
|
else if (m_adjust_drag_handle == AdjustHandle::AxisV)
|
|
delta_planar.x() = 0.f;
|
|
|
|
const float scale = (layer->tiling_scale > 1e-6f) ? (1.f / layer->tiling_scale) : 1.f;
|
|
const Vec2f delta_scaled = delta_planar * scale;
|
|
const float rad = layer->rotation_deg * float(M_PI) / 180.f;
|
|
const float cs = std::cos(rad), sn = std::sin(rad);
|
|
const Vec2f delta_rotated(delta_scaled.x() * cs - delta_scaled.y() * sn, delta_scaled.x() * sn + delta_scaled.y() * cs);
|
|
// Increasing `offset` shifts which texel is sampled at a fixed world position, which
|
|
// visually slides the pattern the *opposite* way - subtracting is this session's
|
|
// best-effort reasoning about the direction that feels like "dragging the texture",
|
|
// unverified against an actual render (see header comment).
|
|
layer->offset = m_adjust_drag_start_offset - delta_rotated;
|
|
|
|
m_preview_params_dirty = true;
|
|
m_parent.set_as_dirty();
|
|
return true;
|
|
}
|
|
|
|
if (mouse_event.LeftUp() && m_adjust_drag_handle != AdjustHandle::None) {
|
|
m_adjust_drag_handle = AdjustHandle::None;
|
|
rebuild_preview();
|
|
m_preview_params_dirty = false;
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
ModelVolume* GLGizmoTextureDisplacement::texture_volume()
|
|
{
|
|
ModelObject *mo = m_c->selection_info()->model_object();
|
|
if (!mo)
|
|
return nullptr;
|
|
for (ModelVolume *mv : mo->volumes)
|
|
if (mv->is_model_part())
|
|
return mv;
|
|
return nullptr;
|
|
}
|
|
|
|
const ModelVolume* GLGizmoTextureDisplacement::texture_volume() const
|
|
{
|
|
return const_cast<GLGizmoTextureDisplacement *>(this)->texture_volume();
|
|
}
|
|
|
|
void GLGizmoTextureDisplacement::update_model_object()
|
|
{
|
|
bool updated = false;
|
|
ModelObject *mo = m_c->selection_info()->model_object();
|
|
int idx = -1;
|
|
for (ModelVolume *mv : mo->volumes) {
|
|
if (!mv->is_model_part())
|
|
continue;
|
|
++idx;
|
|
updated |= mv->texture_displacement_facet(m_active_layer_slot).set(*m_triangle_selectors[idx]);
|
|
}
|
|
|
|
// The fast (bump) preview reads the live selector, so it has to be rebuilt after any stroke that
|
|
// flushes here - not only when set() reports a change. Rebuilding it via rebuild_preview() below
|
|
// is gated on `updated`, which misses e.g. the first paint into a slot; marking it dirty makes the
|
|
// render loop (render_painter_gizmo) rebuild it next frame regardless. Without this, fast preview -
|
|
// now the default view - stayed blank until a full reload (select-whole-model / reopen).
|
|
m_bump_preview_dirty = true;
|
|
|
|
if (updated) {
|
|
const ModelObjectPtrs &mos = wxGetApp().model().objects;
|
|
wxGetApp().obj_list()->update_info_items(std::find(mos.begin(), mos.end(), mo) - mos.begin());
|
|
m_parent.post_event(SimpleEvent(EVT_GLCANVAS_SCHEDULE_BACKGROUND_PROCESS));
|
|
rebuild_preview();
|
|
}
|
|
}
|
|
|
|
void GLGizmoTextureDisplacement::update_from_model_object(bool first_update)
|
|
{
|
|
wxBusyCursor wait;
|
|
|
|
const ModelObject *mo = m_c->selection_info()->model_object();
|
|
m_triangle_selectors.clear();
|
|
|
|
std::vector<ColorRGBA> ebt_colors;
|
|
ebt_colors.push_back(GLVolume::NEUTRAL_COLOR);
|
|
ebt_colors.push_back(TriangleSelectorGUI::enforcers_color);
|
|
ebt_colors.push_back(TriangleSelectorGUI::blockers_color);
|
|
for (const ModelVolume *mv : mo->volumes) {
|
|
if (!mv->is_model_part())
|
|
continue;
|
|
|
|
const TriangleMesh *mesh = &mv->mesh();
|
|
m_triangle_selectors.emplace_back(std::make_unique<TriangleSelectorPatch>(*mesh, ebt_colors));
|
|
m_triangle_selectors.back()->deserialize(mv->texture_displacement_facet(m_active_layer_slot).get_data(), false);
|
|
m_triangle_selectors.back()->request_update_render_data();
|
|
}
|
|
|
|
// Start a freshly opened, never-textured volume with one layer already in place, so the panel is
|
|
// ready to paint straight away rather than showing an empty layer list. Only on first open, and
|
|
// only when there are none - never during an undo/redo or layer-switch reload (which also come
|
|
// through here), where silently adding a layer would be wrong.
|
|
if (first_update)
|
|
if (ModelVolume *tv = texture_volume(); tv != nullptr && tv->texture_displacement_layers.empty()) {
|
|
add_texture_layer(); // takes its own snapshot and rebuilds the preview
|
|
return;
|
|
}
|
|
|
|
rebuild_preview();
|
|
}
|
|
|
|
void GLGizmoTextureDisplacement::set_active_layer(int slot)
|
|
{
|
|
if (slot == m_active_layer_slot)
|
|
return;
|
|
// Flush edits made while the previous layer was active before switching what the selectors
|
|
// reflect - otherwise they would be silently lost.
|
|
update_model_object();
|
|
m_active_layer_slot = slot;
|
|
update_from_model_object(false);
|
|
// The on-canvas gizmo (if on) is anchored to whichever layer is active - keep it in sync
|
|
// instead of leaving it pointing at the previous layer's (now stale) paint patch.
|
|
if (m_adjust_texture_mode)
|
|
update_adjust_anchor();
|
|
// Refresh every preview/overlay (bump, UV editor, seams, ...) for the newly active layer.
|
|
rebuild_preview();
|
|
}
|
|
|
|
unsigned int GLGizmoTextureDisplacement::tool_icon_id()
|
|
{
|
|
if (!m_tool_icon_tried) {
|
|
m_tool_icon_tried = true;
|
|
// Runs from the panel render, i.e. with a GL context current, so the upload is safe here.
|
|
m_tool_icon.load_from_svg_file(resources_dir() + "/images/texture_displacement_add.svg", false, false, false, 32);
|
|
}
|
|
return m_tool_icon.get_id();
|
|
}
|
|
|
|
void GLGizmoTextureDisplacement::ensure_panel_icons()
|
|
{
|
|
if (m_panel_icons_tried)
|
|
return;
|
|
m_panel_icons_tried = true;
|
|
|
|
// Order is irrelevant; the map keys by file name. Loaded with color_wite_gray so each icon has both
|
|
// a monochrome ("normal", grey in the current theme) and an original-colour variant.
|
|
static const std::vector<std::string> names = {
|
|
"toolbar_big_brush.svg", "toolbar_face.svg", "texture_displacement_connected_area.svg",
|
|
"texture_displacement_real_preview.svg", "texture_displacement_fast_preview.svg",
|
|
"texture_displacement_checker.svg", "texture_displacement_distortion.svg",
|
|
"texture_displacement_wireframe.svg", "texture_displacement_cross.svg",
|
|
"texture_displacement_uv_select_island.svg", "texture_displacement_uv_select_edge.svg",
|
|
"texture_displacement_uv_select_vertex.svg",
|
|
};
|
|
std::vector<std::string> paths;
|
|
paths.reserve(names.size());
|
|
for (const std::string &n : names)
|
|
paths.push_back(resources_dir() + "/images/" + n);
|
|
|
|
// Runs from the panel render, i.e. with a GL context current, so the upload is safe here.
|
|
//
|
|
// Rasterized at twice GLToolbar::Default_Icons_Size rather than at it: these are drawn at the
|
|
// toolbar's icon size, which is that constant scaled by DPI (toolbar_icon_scale() folds in
|
|
// em_unit), so on a 200% display the draw size reaches 80 px. Rasterizing at 40 would upscale a
|
|
// 40 px bitmap there, which is what actually reads as blurry - downscaling does not. The icon set
|
|
// is rasterized once for the gizmo's lifetime, so it cannot re-raster on a DPI change; sizing for
|
|
// the larger case and letting ImGui shrink it is the version that looks right on both.
|
|
const std::vector<IconManager::Icons> icons =
|
|
m_panel_icons.init(paths, ImVec2(2 * GLToolbar::Default_Icons_Size, 2 * GLToolbar::Default_Icons_Size),
|
|
IconManager::RasterType::color_wite_gray);
|
|
for (size_t i = 0; i < names.size() && i < icons.size(); ++i)
|
|
m_panel_icon_map[names[i]] = icons[i];
|
|
}
|
|
|
|
void GLGizmoTextureDisplacement::add_texture_layer()
|
|
{
|
|
ModelVolume *mv = texture_volume();
|
|
if (!mv)
|
|
return;
|
|
|
|
std::array<bool, TEXTURE_DISPLACEMENT_MAX_LAYERS> used{};
|
|
for (const TextureDisplacementLayer &l : mv->texture_displacement_layers)
|
|
if (l.slot >= 0 && size_t(l.slot) < used.size())
|
|
used[size_t(l.slot)] = true;
|
|
int free_slot = -1;
|
|
for (size_t i = 0; i < used.size(); ++i)
|
|
if (!used[i]) { free_slot = int(i); break; }
|
|
if (free_slot < 0) {
|
|
show_error(nullptr, _u8L("Maximum number of texture displacement layers reached."));
|
|
return;
|
|
}
|
|
|
|
Plater::TakeSnapshot snapshot(wxGetApp().plater(), _u8L("Add texture displacement layer"), UndoRedo::SnapshotType::GizmoAction);
|
|
|
|
TextureDisplacementLayer layer;
|
|
layer.slot = free_slot;
|
|
// Start the layer off on the first library texture rather than on nothing at all: a textureless
|
|
// layer looks broken (painting on it appears to do nothing, because there is no height map to
|
|
// displace by). The user swaps it for another from the layer's own picker.
|
|
const std::vector<TextureLibraryEntry> &library = texture_library();
|
|
if (!library.empty())
|
|
if (const LibraryTexture *tex = get_library_texture(library.front().path)) {
|
|
layer.name = library.front().name;
|
|
layer.path = library.front().path;
|
|
layer.image_data = tex->image_data;
|
|
}
|
|
mv->texture_displacement_layers.push_back(std::move(layer));
|
|
|
|
set_active_layer(free_slot);
|
|
// set_active_layer() is a no-op when the new slot happens to be the one already active (slot 0,
|
|
// for the very first layer added), so the preview would not pick the new texture up on its own.
|
|
rebuild_preview();
|
|
m_parent.set_as_dirty();
|
|
}
|
|
|
|
const GLGizmoTextureDisplacement::LibraryTexture *GLGizmoTextureDisplacement::get_library_texture(const std::string &path)
|
|
{
|
|
if (auto it = m_library_textures.find(path); it != m_library_textures.end())
|
|
return it->second.image_data ? &it->second : nullptr;
|
|
|
|
LibraryTexture entry;
|
|
std::string error;
|
|
entry.image_data = load_texture_image_data(path, error);
|
|
if (entry.image_data) {
|
|
TextureDisplacementLayer probe;
|
|
probe.image_data = entry.image_data;
|
|
entry.thumbnail = upload_height_thumbnail(decode_height_texture(probe));
|
|
if (!entry.thumbnail)
|
|
entry.image_data.reset(); // decoded to nothing usable - treat it as a failed load
|
|
} else {
|
|
BOOST_LOG_TRIVIAL(error) << "Texture displacement: could not load texture " << path << ": " << error;
|
|
}
|
|
|
|
// Cached whether it loaded or not: a file that failed is remembered as unusable, so the picker
|
|
// does not retry (and re-log) it on every frame it is on screen.
|
|
const auto [pos, inserted] = m_library_textures.emplace(path, std::move(entry));
|
|
return pos->second.image_data ? &pos->second : nullptr;
|
|
}
|
|
|
|
void GLGizmoTextureDisplacement::set_layer_texture(TextureDisplacementLayer &layer, const TextureLibraryEntry &entry)
|
|
{
|
|
const LibraryTexture *tex = get_library_texture(entry.path);
|
|
if (tex == nullptr) {
|
|
show_error(nullptr, _u8L("Could not load the selected texture."));
|
|
return;
|
|
}
|
|
|
|
Plater::TakeSnapshot snapshot(wxGetApp().plater(), _u8L("Change texture displacement texture"), UndoRedo::SnapshotType::GizmoAction);
|
|
layer.name = entry.name;
|
|
layer.path = entry.path;
|
|
// Handing over the library's own buffer (rather than a copy) is what lets decode_height_texture()
|
|
// - whose cache is keyed by exactly this pointer - hit straight away instead of re-decoding
|
|
// the PNG the first time the layer is previewed or baked.
|
|
layer.image_data = tex->image_data;
|
|
|
|
rebuild_preview();
|
|
m_parent.set_as_dirty();
|
|
}
|
|
|
|
void GLGizmoTextureDisplacement::import_custom_texture(TextureDisplacementLayer &layer)
|
|
{
|
|
const wxString wildcard = "Images (*.png;*.jpg;*.jpeg;*.bmp)|*.png;*.jpg;*.jpeg;*.bmp";
|
|
wxFileDialog dialog(nullptr, _L("Choose a texture image (height map)"), wxEmptyString, wxEmptyString, wildcard,
|
|
wxFD_OPEN | wxFD_FILE_MUST_EXIST);
|
|
if (dialog.ShowModal() != wxID_OK)
|
|
return;
|
|
|
|
// Converts to the 8-bit grayscale PNG the bake code understands and copies it into the user's
|
|
// own texture folder, so it stays available for later models (and survives an app update, which
|
|
// rewrites the shipped folder wholesale).
|
|
std::string error;
|
|
const std::optional<TextureLibraryEntry> entry = import_texture_to_library(into_u8(dialog.GetPath()), error);
|
|
if (!entry) {
|
|
show_error(nullptr, error);
|
|
return;
|
|
}
|
|
set_layer_texture(layer, *entry);
|
|
}
|
|
|
|
float GLGizmoTextureDisplacement::texture_row_height() const
|
|
{
|
|
return m_imgui->scaled(3.f);
|
|
}
|
|
|
|
bool GLGizmoTextureDisplacement::texture_row(const char *id, const std::string &name, GLTexture *thumbnail, bool selected, float width)
|
|
{
|
|
const float row_h = texture_row_height();
|
|
const ImVec2 start = ImGui::GetCursorPos();
|
|
const bool clicked = ImGui::Selectable(id, selected, 0, ImVec2(width, row_h));
|
|
const ImVec2 after = ImGui::GetCursorPos();
|
|
|
|
// Lay the image and the name back over the Selectable that was just emitted, so the whole row
|
|
// - preview included - is the clickable target rather than just a strip of text next to it.
|
|
ImGui::SetCursorPos(ImVec2(start.x + ImGui::GetStyle().FramePadding.x, start.y));
|
|
if (thumbnail != nullptr) {
|
|
// Fit inside a row_h square without distorting a non-square source image.
|
|
const float aspect = (thumbnail->get_height() > 0) ? float(thumbnail->get_width()) / float(thumbnail->get_height()) : 1.f;
|
|
const ImVec2 dim = (aspect >= 1.f) ? ImVec2(row_h, row_h / aspect) : ImVec2(row_h * aspect, row_h);
|
|
ImGui::Image((ImTextureID) (intptr_t) thumbnail->get_id(), dim);
|
|
ImGui::SameLine();
|
|
}
|
|
ImGui::SetCursorPosY(start.y + (row_h - ImGui::GetTextLineHeight()) * 0.5f); // centre the name on the image
|
|
ImGui::TextUnformatted(name.c_str());
|
|
|
|
ImGui::SetCursorPos(after);
|
|
return clicked;
|
|
}
|
|
|
|
void GLGizmoTextureDisplacement::render_texture_picker(TextureDisplacementLayer &layer)
|
|
{
|
|
const float row_h = texture_row_height();
|
|
const float import_btn_w = m_imgui->scaled(1.6f);
|
|
const float spacing = ImGui::GetStyle().ItemSpacing.x;
|
|
const float picker_w = std::max(m_imgui->scaled(10.f), ImGui::GetContentRegionAvail().x - import_btn_w - spacing);
|
|
|
|
const ImVec2 start = ImGui::GetCursorPos();
|
|
if (texture_row("##texture_picker", layer.name.empty() ? _u8L("Choose a texture...") : layer.name,
|
|
get_layer_thumbnail(layer), false, picker_w))
|
|
ImGui::OpenPopup("##texture_library");
|
|
const ImVec2 after = ImGui::GetCursorPos();
|
|
|
|
// Positioned explicitly rather than with SameLine(): texture_row() draws several widgets and
|
|
// then rewinds the cursor, so ImGui's notion of "the previous line" is not the row's own.
|
|
ImGui::SetCursorPos(ImVec2(start.x + picker_w + spacing, start.y));
|
|
if (ImGui::Button("+", ImVec2(import_btn_w, row_h)))
|
|
import_custom_texture(layer);
|
|
if (ImGui::IsItemHovered())
|
|
m_imgui->tooltip(_u8L("Import your own image as a height map. It is converted to the format the slicer "
|
|
"bakes from and saved to your personal texture folder, kept separate from the "
|
|
"textures shipped with OrcaSlicer."),
|
|
m_imgui->scaled(20.f));
|
|
ImGui::SetCursorPos(after);
|
|
|
|
if (ImGui::BeginPopup("##texture_library")) {
|
|
const std::vector<TextureLibraryEntry> &library = texture_library();
|
|
if (library.empty())
|
|
m_imgui->text(_L("No textures found."));
|
|
|
|
bool shipped_heading = false;
|
|
bool user_heading = false;
|
|
for (const TextureLibraryEntry &entry : library) {
|
|
if (!entry.is_user && !shipped_heading) {
|
|
ImGui::TextDisabled("%s", _u8L("Built-in").c_str());
|
|
shipped_heading = true;
|
|
} else if (entry.is_user && !user_heading) {
|
|
if (shipped_heading)
|
|
ImGui::Separator();
|
|
ImGui::TextDisabled("%s", _u8L("My textures").c_str());
|
|
user_heading = true;
|
|
}
|
|
|
|
ImGui::PushID(entry.path.c_str());
|
|
const LibraryTexture *tex = get_library_texture(entry.path);
|
|
if (texture_row("##entry", entry.name, tex != nullptr ? tex->thumbnail.get() : nullptr,
|
|
entry.path == layer.path, m_imgui->scaled(14.f))) {
|
|
set_layer_texture(layer, entry);
|
|
ImGui::CloseCurrentPopup();
|
|
}
|
|
ImGui::PopID();
|
|
}
|
|
ImGui::EndPopup();
|
|
}
|
|
}
|
|
|
|
void GLGizmoTextureDisplacement::remove_texture_layer(int slot)
|
|
{
|
|
ModelVolume *mv = texture_volume();
|
|
if (!mv)
|
|
return;
|
|
|
|
Plater::TakeSnapshot snapshot(wxGetApp().plater(), _u8L("Remove texture displacement layer"), UndoRedo::SnapshotType::GizmoAction);
|
|
|
|
auto &layers = mv->texture_displacement_layers;
|
|
layers.erase(std::remove_if(layers.begin(), layers.end(),
|
|
[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();
|
|
m_thumbnails[size_t(slot)].reset();
|
|
m_thumbnail_source[size_t(slot)] = nullptr;
|
|
}
|
|
|
|
if (m_active_layer_slot == slot)
|
|
update_from_model_object(false); // also rebuilds the preview
|
|
else
|
|
rebuild_preview(); // a non-active layer's contribution to the combined preview changed
|
|
|
|
m_parent.set_as_dirty();
|
|
}
|
|
|
|
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()))
|
|
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();
|
|
|
|
// 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
|
|
// camera get_position() is still a point on the view axis, so the same expression stays correct
|
|
// in direction terms for everything actually on screen.
|
|
const Vec3d eye = camera.get_position();
|
|
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);
|
|
|
|
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, 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.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;
|
|
{
|
|
wxBusyCursor wait;
|
|
unobscured = m_c->raycaster()->raycasters()[size_t(idx)]->get_unobscured_idxs(
|
|
trafo, camera, centroids, m_c->object_clipper()->get_clipping_plane());
|
|
}
|
|
if (unobscured.empty())
|
|
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.
|
|
// 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();
|
|
|
|
update_model_object();
|
|
m_parent.set_as_dirty();
|
|
return int(unobscured.size());
|
|
}
|
|
|
|
void GLGizmoTextureDisplacement::select_whole_model()
|
|
{
|
|
ModelObject *mo = m_c->selection_info()->model_object();
|
|
if (!mo)
|
|
return;
|
|
|
|
Plater::TakeSnapshot snapshot(wxGetApp().plater(), _u8L("Select whole model for texture displacement"), UndoRedo::SnapshotType::GizmoAction);
|
|
|
|
int idx = -1;
|
|
for (const ModelVolume *v : mo->volumes) {
|
|
if (!v->is_model_part())
|
|
continue;
|
|
++idx;
|
|
const size_t facet_count = v->mesh().its.indices.size();
|
|
for (size_t i = 0; i < facet_count; ++i)
|
|
m_triangle_selectors[idx]->set_facet(int(i), EnforcerBlockerType::ENFORCER);
|
|
m_triangle_selectors[idx]->request_update_render_data();
|
|
}
|
|
update_model_object();
|
|
m_parent.set_as_dirty();
|
|
}
|
|
|
|
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)
|
|
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();
|
|
|
|
// 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;
|
|
auto it = std::find(objs.begin(), objs.end(), mo);
|
|
if (it != objs.end())
|
|
obj_list->update_info_items(size_t(it - objs.begin()));
|
|
}
|
|
|
|
plater->changed_object(*mo);
|
|
update_from_model_object(false); // reload selectors/preview against the new mesh + cleared paint
|
|
m_parent.set_as_dirty();
|
|
}
|
|
|
|
bool GLGizmoTextureDisplacement::collect_paint_region(
|
|
std::vector<uint8_t> ®ion,
|
|
std::array<std::vector<uint8_t>, TEXTURE_DISPLACEMENT_MAX_LAYERS> *painted_tri) const
|
|
{
|
|
const ModelVolume *mv = texture_volume();
|
|
if (mv == nullptr)
|
|
return false;
|
|
const indexed_triangle_set &its = mv->mesh().its;
|
|
const size_t ntri = its.indices.size();
|
|
const size_t nvert = its.vertices.size();
|
|
|
|
region.assign(ntri, 0);
|
|
if (painted_tri)
|
|
for (auto &pt : *painted_tri)
|
|
pt.clear();
|
|
|
|
// Sorted-vertex-triple -> triangle index, so a fully-painted patch sub-triangle (which comes back
|
|
// with the original mesh's own three vertex indices) can be mapped to its source triangle. Only
|
|
// the paint carry-forward needs it, and the live subdivide preview calls this on every slider
|
|
// frame, so it is not built for the region-only path.
|
|
std::map<std::array<int, 3>, int> tri_by_verts;
|
|
if (painted_tri)
|
|
for (size_t i = 0; i < ntri; ++i) {
|
|
std::array<int, 3> k{ its.indices[i][0], its.indices[i][1], its.indices[i][2] };
|
|
std::sort(k.begin(), k.end());
|
|
tri_by_verts.emplace(k, int(i));
|
|
}
|
|
|
|
bool any_paint = false;
|
|
for (int slot = 0; slot < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++slot) {
|
|
const TriangleSelector::TriangleSplittingData &data = mv->texture_displacement_facet(slot).get_data();
|
|
if (!TriangleSelector::has_facets(data, EnforcerBlockerType::ENFORCER))
|
|
continue;
|
|
|
|
// The refine region is exactly the original triangles the brush touched. `triangles_to_split`
|
|
// lists precisely those: serialize() records an entry for every original triangle that is
|
|
// either split (i.e. partially painted, which is the patch boundary) or carries a non-default
|
|
// state (fully painted). No dilation - an earlier version marked every triangle sharing a
|
|
// *vertex* with the patch, which on a coarse model pulls in a whole fan of huge unpainted
|
|
// neighbours and then refines them to the resolution floor, since the height field the detail
|
|
// test samples is not restricted to the painted area. The conformal closure inside
|
|
// subdivide_mesh_adaptive() already grades the size change outward on its own.
|
|
for (const TriangleSelector::TriangleBitStreamMapping &m : data.triangles_to_split)
|
|
if (size_t(m.triangle_idx) < ntri)
|
|
region[m.triangle_idx] = 1;
|
|
|
|
if (painted_tri) {
|
|
TriangleSelector sel(mv->mesh());
|
|
sel.deserialize(data, false);
|
|
(*painted_tri)[slot].assign(ntri, 0);
|
|
for (const stl_triangle_vertex_indices &t : sel.get_facets_strict(EnforcerBlockerType::ENFORCER).indices) {
|
|
// A sub-triangle produced by a *partial* stroke always carries at least one appended
|
|
// (split) vertex, so "all three indices are original" is exactly the test for a whole,
|
|
// fully-painted triangle - the only kind whose paint can be inherited wholesale.
|
|
if (size_t(t[0]) >= nvert || size_t(t[1]) >= nvert || size_t(t[2]) >= nvert)
|
|
continue;
|
|
std::array<int, 3> k{ t[0], t[1], t[2] };
|
|
std::sort(k.begin(), k.end());
|
|
if (auto it = tri_by_verts.find(k); it != tri_by_verts.end())
|
|
(*painted_tri)[slot][it->second] = 1;
|
|
}
|
|
}
|
|
any_paint = true;
|
|
}
|
|
return any_paint;
|
|
}
|
|
|
|
void GLGizmoTextureDisplacement::subdivide_model_adaptive()
|
|
{
|
|
ModelVolume *mv = texture_volume();
|
|
ModelObject *mo = m_c->selection_info()->model_object();
|
|
if (mv == nullptr || mo == nullptr || m_subdivide_target_mm <= 0.f)
|
|
return;
|
|
|
|
update_model_object(); // flush any in-progress stroke into the committed masks first
|
|
|
|
std::vector<uint8_t> region;
|
|
std::array<std::vector<uint8_t>, TEXTURE_DISPLACEMENT_MAX_LAYERS> painted_tri;
|
|
if (!collect_paint_region(region, &painted_tri)) {
|
|
show_error(nullptr, _u8L("Paint the area you want to subdivide first - adaptive subdivision only "
|
|
"refines where you have painted."));
|
|
return;
|
|
}
|
|
|
|
// Feature-adaptive: sample the combined displacement so refinement follows texture curvature. A
|
|
// null sampler (only LSCM layers, or nothing decodable) falls back to the uniform target below.
|
|
HeightFieldSampler sampler;
|
|
if (m_subdivide_feature) {
|
|
TextureDisplacementFacetsData facets{};
|
|
for (int i = 0; i < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++i)
|
|
facets[size_t(i)] = mv->texture_displacement_facet(i).get_data();
|
|
sampler = make_combined_displacement_sampler(mv->mesh().its, mv->texture_displacement_layers, facets);
|
|
}
|
|
|
|
// Do the (potentially slow) refinement before taking the snapshot, so a no-op leaves no empty
|
|
// undo step - mirrors remesh_model().
|
|
// "Min edge" is a feature-mode control (it is the floor the curvature test refines down to); in
|
|
// plain adaptive mode the target edge length is the only criterion, so the floor must not be
|
|
// allowed to silently override a target the user set below it.
|
|
const float tol = m_subdivide_feature ? m_subdivide_detail_mm : 0.f;
|
|
const float floor = m_subdivide_feature ? m_subdivide_min_edge_mm : 0.f;
|
|
std::vector<int> source;
|
|
indexed_triangle_set refined;
|
|
{
|
|
wxBusyCursor wait;
|
|
// The budget slider is "triangles the refinement may *add*", so the model's own count is the
|
|
// baseline - otherwise the control would be meaningless (or a dead end) on a dense model.
|
|
refined = subdivide_mesh_adaptive(mv->mesh().its, region, m_subdivide_target_mm,
|
|
int(mv->mesh().its.indices.size()) + m_subdivide_budget_k * 1000,
|
|
&source, sampler, tol, floor);
|
|
}
|
|
if (refined.indices.size() == mv->mesh().its.indices.size()) {
|
|
show_error(nullptr, _u8L("Nothing to subdivide - the painted area already meets the target edge "
|
|
"length and detail tolerance, or the triangle budget is already used up."));
|
|
return;
|
|
}
|
|
|
|
Plater *plater = wxGetApp().plater();
|
|
Plater::TakeSnapshot snapshot(plater, _u8L("Adaptive subdivide for texture displacement"), UndoRedo::SnapshotType::GizmoAction);
|
|
|
|
// Other paint channels ride across via the standard remap; texture-displacement paint is rebuilt
|
|
// by hand below from the source map, which is the whole point of driving this by the paint.
|
|
std::optional<TriangleSelector::SavedPainting> saved_painting = mv->save_painting();
|
|
mv->set_mesh(TriangleMesh(std::move(refined))); // refined is not needed past here; source carries the paint map
|
|
mv->set_new_unique_id();
|
|
mv->calculate_convex_hull();
|
|
mv->restore_painting(saved_painting); // resets extra facets (incl. texture-displacement) + remaps the rest
|
|
|
|
// Carry each layer's paint onto the new mesh: a new triangle is painted iff its source triangle
|
|
// was fully painted in that layer. Children inherit their parent's source, so this is exact.
|
|
for (int slot = 0; slot < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++slot) {
|
|
if (painted_tri[slot].empty())
|
|
continue;
|
|
TriangleSelector sel(mv->mesh());
|
|
for (size_t i = 0; i < source.size(); ++i)
|
|
if (painted_tri[slot][source[i]])
|
|
sel.set_facet(int(i), EnforcerBlockerType::ENFORCER);
|
|
mv->texture_displacement_facet(slot).set(sel);
|
|
}
|
|
|
|
if (ObjectList *obj_list = wxGetApp().obj_list()) {
|
|
const ModelObjectPtrs &objs = plater->model().objects;
|
|
auto it = std::find(objs.begin(), objs.end(), mo);
|
|
if (it != objs.end())
|
|
obj_list->update_info_items(size_t(it - objs.begin()));
|
|
}
|
|
plater->changed_object(*mo);
|
|
update_from_model_object(false); // reload selectors/preview against the new mesh + carried paint
|
|
m_parent.set_as_dirty();
|
|
}
|
|
|
|
void GLGizmoTextureDisplacement::smooth_model()
|
|
{
|
|
ModelVolume *mv = texture_volume();
|
|
ModelObject *mo = m_c->selection_info()->model_object();
|
|
if (mv == nullptr || mo == nullptr)
|
|
return;
|
|
const TextureDisplacementOptions &opts = mv->texture_displacement_options;
|
|
if (opts.smooth_strength <= 0.f || opts.smooth_iterations <= 0)
|
|
return;
|
|
|
|
update_model_object(); // flush any in-progress stroke, so the painted region below is current
|
|
|
|
// Movable = the vertices of the painted triangles, so the first ring of purely unpainted vertices
|
|
// outside them stays put and anchors the result. Restricted rather than whole-model on purpose:
|
|
// this runs on geometry that has already been baked, and relaxing the whole thing would quietly
|
|
// round off every unrelated feature on the part. With "Ignore outer ring" the patch's own rim is
|
|
// held as well - see TextureDisplacementOptions::smooth_skip_border.
|
|
const indexed_triangle_set &its = mv->mesh().its;
|
|
std::vector<uint8_t> painted_face(its.indices.size(), 0);
|
|
bool any = false;
|
|
for (int slot = 0; slot < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++slot)
|
|
for (const TriangleSelector::TriangleBitStreamMapping &m : mv->texture_displacement_facet(slot).get_data().triangles_to_split)
|
|
if (size_t(m.triangle_idx) < its.indices.size()) {
|
|
painted_face[size_t(m.triangle_idx)] = 1;
|
|
any = true;
|
|
}
|
|
if (!any) {
|
|
show_error(nullptr, _u8L("Paint the area you want to smooth first - smoothing only touches the "
|
|
"painted part of the model."));
|
|
return;
|
|
}
|
|
std::vector<uint8_t> movable(its.vertices.size(), 0);
|
|
for (size_t i = 0; i < its.indices.size(); ++i)
|
|
if (painted_face[i])
|
|
for (int k = 0; k < 3; ++k)
|
|
movable[size_t(its.indices[i][k])] = 1;
|
|
if (opts.smooth_skip_border)
|
|
for (size_t i = 0; i < its.indices.size(); ++i)
|
|
if (!painted_face[i])
|
|
for (int k = 0; k < 3; ++k)
|
|
movable[size_t(its.indices[i][k])] = 0; // also used by unpainted geometry -> the rim
|
|
if (std::none_of(movable.begin(), movable.end(), [](uint8_t m) { return m != 0; })) {
|
|
// Every painted vertex is on the patch's rim, so "Ignore outer ring" leaves nothing to move.
|
|
show_error(nullptr, _u8L("Nothing to smooth - the painted area is only one triangle deep, so with "
|
|
"\"Ignore outer ring\" on there are no interior vertices to relax."));
|
|
return;
|
|
}
|
|
|
|
indexed_triangle_set smoothed = its;
|
|
{
|
|
wxBusyCursor wait;
|
|
smooth_mesh_vertices(smoothed, movable, opts.smooth_strength, opts.smooth_iterations);
|
|
}
|
|
|
|
Plater *plater = wxGetApp().plater();
|
|
Plater::TakeSnapshot snapshot(plater, _u8L("Smooth texture displacement"), UndoRedo::SnapshotType::GizmoAction);
|
|
|
|
// No save/restore-painting dance here, unlike subdivide and remesh: smoothing only moves vertices,
|
|
// it does not touch the triangle list, so every paint channel still refers to exactly the triangles
|
|
// it did before. set_mesh() clears the extra facets, so this saves and puts back the
|
|
// texture-displacement masks verbatim - no remap needed, and none of them is lost.
|
|
std::optional<TriangleSelector::SavedPainting> saved_painting = mv->save_painting();
|
|
std::array<TriangleSelector::TriangleSplittingData, TEXTURE_DISPLACEMENT_MAX_LAYERS> saved_texture;
|
|
for (int i = 0; i < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++i)
|
|
saved_texture[size_t(i)] = mv->texture_displacement_facet(i).get_data();
|
|
|
|
mv->set_mesh(TriangleMesh(std::move(smoothed)));
|
|
mv->set_new_unique_id();
|
|
mv->calculate_convex_hull();
|
|
mv->restore_painting(saved_painting);
|
|
for (int i = 0; i < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++i)
|
|
mv->texture_displacement_facet(i).set_data(std::move(saved_texture[size_t(i)]));
|
|
|
|
if (ObjectList *obj_list = wxGetApp().obj_list()) {
|
|
const ModelObjectPtrs &objs = plater->model().objects;
|
|
auto it = std::find(objs.begin(), objs.end(), mo);
|
|
if (it != objs.end())
|
|
obj_list->update_info_items(size_t(it - objs.begin()));
|
|
}
|
|
plater->changed_object(*mo);
|
|
update_from_model_object(false);
|
|
m_parent.set_as_dirty();
|
|
}
|
|
|
|
void GLGizmoTextureDisplacement::remesh_model()
|
|
{
|
|
ModelVolume *mv = texture_volume();
|
|
ModelObject *mo = m_c->selection_info()->model_object();
|
|
if (mv == nullptr || mo == nullptr || m_remesh_target_edge_mm <= 0.f)
|
|
return;
|
|
|
|
Plater *plater = wxGetApp().plater();
|
|
|
|
// CGAL isotropic remeshing can be slow on a big mesh; do it before taking the snapshot so a failure
|
|
// (it returns the input unchanged) doesn't leave an empty undo step.
|
|
const indexed_triangle_set &src = mv->mesh().its;
|
|
indexed_triangle_set remeshed;
|
|
{
|
|
wxBusyCursor wait;
|
|
remeshed = MeshBoolean::cgal::remesh_isotropic(mv->mesh().its, double(m_remesh_target_edge_mm), 3,
|
|
m_remesh_keep_sharp_edges ? double(m_remesh_sharp_angle_deg) : 0.0);
|
|
}
|
|
// remesh_isotropic() signals failure by handing the input straight back, so compare against it
|
|
// structurally. Vertex count alone is not enough: a remesh that only redistributes triangles at
|
|
// roughly the current density legitimately lands on the same count, and treating that as failure
|
|
// meant a perfectly good result got thrown away with an error message.
|
|
const bool unchanged = remeshed.indices.empty() ||
|
|
(remeshed.vertices.size() == src.vertices.size() && remeshed.indices.size() == src.indices.size() &&
|
|
remeshed.indices == src.indices);
|
|
if (unchanged) {
|
|
show_error(nullptr, _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."));
|
|
return;
|
|
}
|
|
|
|
Plater::TakeSnapshot snapshot(plater, _u8L("Remesh model for texture displacement"), UndoRedo::SnapshotType::GizmoAction);
|
|
// Same save/replace/restore-painting dance as subdivide: texture-displacement paint has no remap
|
|
// across a topology change, so it is dropped rather than left pointing at triangles that moved.
|
|
std::optional<TriangleSelector::SavedPainting> saved_painting = mv->save_painting();
|
|
mv->set_mesh(TriangleMesh(std::move(remeshed)));
|
|
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;
|
|
auto it = std::find(objs.begin(), objs.end(), mo);
|
|
if (it != objs.end())
|
|
obj_list->update_info_items(size_t(it - objs.begin()));
|
|
}
|
|
plater->changed_object(*mo);
|
|
update_from_model_object(false);
|
|
m_parent.set_as_dirty();
|
|
}
|
|
|
|
void GLGizmoTextureDisplacement::rebuild_subdivide_preview()
|
|
{
|
|
m_subdivide_preview_glmodel.reset();
|
|
m_subdivide_preview_tris = -1;
|
|
const ModelVolume *mv = texture_volume();
|
|
if (mv == nullptr)
|
|
return;
|
|
|
|
// The same subdivision Apply would commit, kept in a throwaway mesh and shown only as a
|
|
// wireframe - the model itself is not touched until Apply.
|
|
indexed_triangle_set its;
|
|
if (m_subdivide_adaptive) {
|
|
if (m_subdivide_target_mm <= 0.f)
|
|
return;
|
|
std::vector<uint8_t> region;
|
|
if (!collect_paint_region(region, nullptr))
|
|
return; // nothing painted yet: nothing to preview
|
|
HeightFieldSampler sampler;
|
|
if (m_subdivide_feature) {
|
|
TextureDisplacementFacetsData facets{};
|
|
for (int i = 0; i < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++i)
|
|
facets[size_t(i)] = mv->texture_displacement_facet(i).get_data();
|
|
sampler = make_combined_displacement_sampler(mv->mesh().its, mv->texture_displacement_layers, facets);
|
|
}
|
|
// Feature mode: curvature (Detail tolerance) on top of the Max-edge baseline, down to the
|
|
// Min-edge floor. Plain adaptive: tol 0, so only the target-edge-length criterion applies.
|
|
const float tol = m_subdivide_feature ? m_subdivide_detail_mm : 0.f;
|
|
const float floor = m_subdivide_feature ? m_subdivide_min_edge_mm : 0.f;
|
|
its = subdivide_mesh_adaptive(mv->mesh().its, region, m_subdivide_target_mm,
|
|
int(mv->mesh().its.indices.size()) + m_subdivide_budget_k * 1000,
|
|
nullptr, sampler, tol, floor);
|
|
} else {
|
|
if (m_subdivide_count < 1)
|
|
return;
|
|
its = subdivide_mesh_uniform(mv->mesh().its, 0.f, m_subdivide_count);
|
|
}
|
|
if (its.indices.empty())
|
|
return;
|
|
m_subdivide_preview_tris = int(its.indices.size());
|
|
|
|
GLModel::Geometry init_data;
|
|
init_data.format = { GLModel::Geometry::EPrimitiveType::Lines, GLModel::Geometry::EVertexLayout::P3 };
|
|
init_data.reserve_vertices(its.vertices.size());
|
|
init_data.reserve_indices(its.indices.size() * 6);
|
|
for (const Vec3f &v : its.vertices)
|
|
init_data.add_vertex(v);
|
|
for (const stl_triangle_vertex_indices &tri : its.indices)
|
|
for (int i = 0; i < 3; ++i)
|
|
init_data.add_line(unsigned(tri[i]), unsigned(tri[(i + 1) % 3]));
|
|
if (!init_data.is_empty())
|
|
m_subdivide_preview_glmodel.init_from(std::move(init_data));
|
|
}
|
|
|
|
void GLGizmoTextureDisplacement::render_subdivide_preview()
|
|
{
|
|
const ModelObject *mo = m_c->selection_info()->model_object();
|
|
const ModelVolume *mv = texture_volume();
|
|
if (mo == nullptr || mv == nullptr || !m_subdivide_preview_glmodel.is_initialized())
|
|
return;
|
|
GLShaderProgram *shader = wxGetApp().get_shader("flat");
|
|
if (shader == 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();
|
|
const Camera &camera = wxGetApp().plater()->get_camera();
|
|
|
|
shader->start_using();
|
|
shader->set_uniform("view_model_matrix", camera.get_view_matrix() * trafo_matrix);
|
|
shader->set_uniform("projection_matrix", camera.get_projection_matrix());
|
|
glsafe(::glEnable(GL_POLYGON_OFFSET_LINE));
|
|
glsafe(::glPolygonOffset(-1.0f, -1.0f));
|
|
m_subdivide_preview_glmodel.set_color(ColorRGBA(0.2f, 0.9f, 1.0f, 0.7f)); // cyan, reads as "preview"
|
|
m_subdivide_preview_glmodel.render();
|
|
glsafe(::glDisable(GL_POLYGON_OFFSET_LINE));
|
|
shader->stop_using();
|
|
}
|
|
|
|
GLTexture *GLGizmoTextureDisplacement::get_layer_thumbnail(const TextureDisplacementLayer &layer)
|
|
{
|
|
if (layer.empty() || layer.slot < 0 || size_t(layer.slot) >= TEXTURE_DISPLACEMENT_MAX_LAYERS)
|
|
return nullptr;
|
|
|
|
const size_t slot = size_t(layer.slot);
|
|
if (m_thumbnails[slot] && m_thumbnail_source[slot] == layer.image_data.get() &&
|
|
m_thumbnail_smoothing[slot] == layer.smoothing)
|
|
return m_thumbnails[slot].get();
|
|
|
|
// Reuses the already-decoded, already-cached grayscale pixels (see decode_height_texture()'s
|
|
// own cache in TextureDisplacement.cpp) - only the gray-to-RGBA expansion and GPU upload below are
|
|
// new work. Rebuilt when the texture *or the smoothing* changes, so the fast/bump preview - which
|
|
// samples this GPU texture directly - reflects the current smoothing rather than the raw image.
|
|
std::unique_ptr<GLTexture> texture = upload_height_thumbnail(decode_height_texture(layer));
|
|
if (!texture)
|
|
return nullptr;
|
|
|
|
m_thumbnails[slot] = std::move(texture);
|
|
m_thumbnail_source[slot] = layer.image_data.get();
|
|
m_thumbnail_smoothing[slot] = layer.smoothing;
|
|
return m_thumbnails[slot].get();
|
|
}
|
|
|
|
void GLGizmoTextureDisplacement::bake()
|
|
{
|
|
ModelVolume *mv = texture_volume();
|
|
if (!mv || 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()) {
|
|
show_error(nullptr, _u8L("Nothing is painted, there is nothing to bake."));
|
|
return;
|
|
}
|
|
|
|
m_bake_in_progress = true;
|
|
queue_texture_displacement_bake(*mv, [this]() {
|
|
m_bake_in_progress = false;
|
|
// Baking replaces the volume'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
|
|
// deselected and reselected.
|
|
if (m_state == On && m_c->selection_info() && m_c->selection_info()->model_object())
|
|
update_from_model_object(false);
|
|
m_parent.set_as_dirty();
|
|
});
|
|
}
|
|
|
|
void GLGizmoTextureDisplacement::render_paint_cursor_hint()
|
|
{
|
|
// Only in the plain paint/select modes; seam and adjust modes have their own click semantics where
|
|
// an add/remove sign would just be noise.
|
|
if (m_seam_edit_mode || m_adjust_texture_mode)
|
|
return;
|
|
const ImGuiIO &io = ImGui::GetIO();
|
|
// The pointer must be over the 3D view, not over this panel (or any other ImGui window).
|
|
if (io.WantCaptureMouse || !ImGui::IsMousePosValid())
|
|
return;
|
|
|
|
// Shift erases (see handle_snapshot_action_name()); a plain stroke adds.
|
|
const bool removing = io.KeyShift;
|
|
const ImU32 color = removing ? IM_COL32(235, 70, 60, 255) : IM_COL32(90, 210, 110, 255);
|
|
const char *glyph = removing ? "-" : "+";
|
|
|
|
ImDrawList *dl = ImGui::GetForegroundDrawList();
|
|
const float fs = ImGui::GetFontSize() * 1.5f;
|
|
const ImVec2 at(io.MousePos.x + 15.f, io.MousePos.y - fs - 6.f);
|
|
// A translucent dark disc behind the glyph so it reads on any material colour.
|
|
dl->AddCircleFilled(ImVec2(at.x + fs * 0.28f, at.y + fs * 0.5f), fs * 0.62f, IM_COL32(0, 0, 0, 150));
|
|
dl->AddText(ImGui::GetFont(), fs, at, color, glyph);
|
|
}
|
|
|
|
void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float bottom_limit)
|
|
{
|
|
ModelObject *mo = m_c->selection_info()->model_object();
|
|
if (!mo)
|
|
return;
|
|
ModelVolume *mv = texture_volume();
|
|
|
|
const float approx_height = m_imgui->scaled(24.f);
|
|
y = std::min(y, bottom_limit - approx_height);
|
|
|
|
// Docked (the default) the panel is pinned next to the gizmo toolbar and cannot be moved, like
|
|
// every other gizmo's. Undocked it becomes an ordinary floating window: a title bar to drag it
|
|
// by, and no forced position - this panel is tall enough (layer stack, per-layer controls) that
|
|
// it can cover the very part of the model being painted, and being able to shove it aside is the
|
|
// point. The position is deliberately *not* seeded on undock, so the window stays exactly where
|
|
// it already was and the user just gains the ability to move it from there.
|
|
ImGuiWindowFlags flags = ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoCollapse;
|
|
if (!m_undocked) {
|
|
flags |= ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoTitleBar;
|
|
GizmoImguiSetNextWIndowPos(x, y, ImGuiCond_Always, 1.0f, 0.0f);
|
|
}
|
|
|
|
ImGuiWrapper::push_toolbar_style(m_parent.get_scale());
|
|
GizmoImguiBegin(get_name(), flags);
|
|
|
|
// Combo drop-downs otherwise inherit ImGui's near-black default popup background; under the light
|
|
// theme that leaves the dark item text unreadable ("the dropbox is black"). Pushed only around each
|
|
// Combo below (never around a tooltip, whose own near-black default is what makes it readable).
|
|
const ImVec4 combo_popup_bg = wxGetApp().dark_mode() ? ImVec4(0.18f, 0.18f, 0.19f, 1.f)
|
|
: ImVec4(0.93f, 0.93f, 0.93f, 1.f);
|
|
const auto scoped_combo = [&](const char *id, int *v, const char *const items[], int n) {
|
|
ImGui::PushStyleColor(ImGuiCol_PopupBg, combo_popup_bg);
|
|
const bool changed = ImGui::Combo(id, v, items, n);
|
|
ImGui::PopStyleColor();
|
|
return changed;
|
|
};
|
|
|
|
// Icon toggle button shared by the selection-mode and view-mode rows, styled like the main toolbar:
|
|
// an inactive button shows the icon in the theme's normal (grey) monochrome, an active one shows it
|
|
// in its original colours. All icons are the same square size. Falls back to a text checkbox if the
|
|
// icon set could not be loaded, so the control is never lost.
|
|
ensure_panel_icons();
|
|
// Sized to match the 3D toolbar's icons exactly, rather than to the panel's font. It is the same
|
|
// expression GLCanvas3D::_update_toolbar_icons_scale() uses, and it is valid here because ImGui's
|
|
// DisplaySize is set from the canvas's pixel size (GLCanvas3D::_resize()) - so one ImGui unit is
|
|
// one canvas pixel, the very units the toolbar is drawn in. Deriving it rather than hard-coding a
|
|
// font multiple also keeps the two in step when the toolbar auto-fit shrinks its icons to make
|
|
// them fit a narrow window, which it does by lowering the same toolbar_icon_scale() read here.
|
|
const float icon_btn_sz = GLToolbar::Default_Icons_Size * wxGetApp().toolbar_icon_scale() * m_parent.get_scale();
|
|
// The icon SVGs carry their own border, so the ImGui button's own frame border and idle fill are
|
|
// suppressed here (FrameBorderSize 0 + transparent ImGuiCol_Button) to avoid a doubled border - the
|
|
// hover/active fill is left in place for feedback. Applied only around these gizmo icon buttons.
|
|
const auto push_borderless_icon_style = []() {
|
|
ImGui::PushStyleVar(ImGuiStyleVar_FrameBorderSize, 0.f);
|
|
ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.f, 0.f, 0.f, 0.f));
|
|
};
|
|
const auto pop_borderless_icon_style = []() {
|
|
ImGui::PopStyleColor();
|
|
ImGui::PopStyleVar();
|
|
};
|
|
const auto icon_toggle = [&](int uid, const std::string &iconfile, bool active, const wxString &label,
|
|
const wxString &tip) -> bool {
|
|
bool clicked = false;
|
|
const auto it = m_panel_icon_map.find(iconfile);
|
|
ImGui::PushID(uid);
|
|
// color_wite_gray variants: [0] normal/grey, [1] original colour, [2] disabled.
|
|
if (it != m_panel_icon_map.end() && it->second.size() >= 2 && it->second[active ? 1 : 0]->is_valid()) {
|
|
const IconManager::Icon &ic = *it->second[active ? 1 : 0];
|
|
push_borderless_icon_style();
|
|
clicked = m_imgui->image_button((ImTextureID) (intptr_t) ic.tex_id, ImVec2(icon_btn_sz, icon_btn_sz),
|
|
ic.tl, ic.br, -1, ImVec4(0, 0, 0, 0), ImVec4(1, 1, 1, 1));
|
|
pop_borderless_icon_style();
|
|
} else {
|
|
bool v = active;
|
|
clicked = ImGui::Checkbox(label.ToUTF8().data(), &v);
|
|
}
|
|
ImGui::PopID();
|
|
if (ImGui::IsItemHovered())
|
|
m_imgui->tooltip(tip, m_imgui->scaled(18.f));
|
|
return clicked;
|
|
};
|
|
// Borderless icon button (non-toggle): always the grey monochrome variant. Falls back to a plain
|
|
// text button when the icon file is absent, so the control is never lost before the art lands.
|
|
const auto icon_button = [&](int uid, const std::string &iconfile, float sz, const wxString &label,
|
|
const wxString &tip) -> bool {
|
|
bool clicked = false;
|
|
const auto it = m_panel_icon_map.find(iconfile);
|
|
ImGui::PushID(uid);
|
|
if (it != m_panel_icon_map.end() && !it->second.empty() && it->second[0]->is_valid()) {
|
|
const IconManager::Icon &ic = *it->second[0];
|
|
push_borderless_icon_style();
|
|
clicked = m_imgui->image_button((ImTextureID) (intptr_t) ic.tex_id, ImVec2(sz, sz), ic.tl, ic.br, -1,
|
|
ImVec4(0, 0, 0, 0), ImVec4(1, 1, 1, 1));
|
|
pop_borderless_icon_style();
|
|
} else {
|
|
clicked = m_imgui->button(label);
|
|
}
|
|
ImGui::PopID();
|
|
if (!tip.empty() && ImGui::IsItemHovered())
|
|
m_imgui->tooltip(tip, m_imgui->scaled(18.f));
|
|
return clicked;
|
|
};
|
|
|
|
if (m_imgui->button(m_undocked ? _L("Dock panel") : _L("Undock panel")))
|
|
m_undocked = !m_undocked;
|
|
if (ImGui::IsItemHovered())
|
|
m_imgui->tooltip(_u8L("Detach this panel so it can be dragged anywhere over the 3D view, or dock it "
|
|
"back beside the toolbar."),
|
|
m_imgui->scaled(20.f));
|
|
ImGui::Separator();
|
|
|
|
// Selection mode: which of TriangleSelector's existing click/brush mechanisms drives painting.
|
|
// "Face" and "Connected area" reuse the exact same underlying selection machinery every other
|
|
// paint gizmo already has (single-facet click, and angle-limited flood fill respectively) -
|
|
// just exposed here as an alternative to brushing, one triangle/region at a time.
|
|
m_imgui->text(_L("Selection mode"));
|
|
const bool is_brush_mode = m_tool_type == ToolType::BRUSH && m_cursor_type != TriangleSelector::CursorType::POINTER;
|
|
const bool is_face_mode = m_tool_type == ToolType::BRUSH && m_cursor_type == TriangleSelector::CursorType::POINTER;
|
|
const bool is_area_mode = m_tool_type == ToolType::SMART_FILL;
|
|
ImGui::SameLine();
|
|
if (icon_toggle(801, "toolbar_big_brush.svg", is_brush_mode, _L("Brush"),
|
|
_L("Brush - paint over the surface by dragging"))) {
|
|
m_tool_type = ToolType::BRUSH;
|
|
m_cursor_type = TriangleSelector::CursorType::CIRCLE;
|
|
}
|
|
ImGui::SameLine();
|
|
if (icon_toggle(802, "toolbar_face.svg", is_face_mode, _L("Face"),
|
|
_L("Face - click individual triangles"))) {
|
|
m_tool_type = ToolType::BRUSH;
|
|
m_cursor_type = TriangleSelector::CursorType::POINTER;
|
|
}
|
|
ImGui::SameLine();
|
|
if (icon_toggle(803, "texture_displacement_connected_area.svg", is_area_mode, _L("Connected area"),
|
|
_L("Connected area - flood-fill the region reachable without crossing an edge sharper than the "
|
|
"angle threshold"))) {
|
|
m_tool_type = ToolType::SMART_FILL;
|
|
m_cursor_type = TriangleSelector::CursorType::POINTER;
|
|
}
|
|
|
|
if (is_brush_mode) {
|
|
m_imgui->text(m_desc.at("cursor_size"));
|
|
ImGui::SameLine();
|
|
ImGui::PushItemWidth(m_imgui->scaled(8.4f));
|
|
m_imgui->slider_float("##cursor_radius", &m_cursor_radius, CursorRadiusMin, CursorRadiusMax, "%.2f");
|
|
ImGui::PopItemWidth();
|
|
|
|
bool is_circle = m_cursor_type == TriangleSelector::CursorType::CIRCLE;
|
|
if (ImGui::RadioButton(m_desc.at("circle").ToUTF8().data(), is_circle))
|
|
m_cursor_type = TriangleSelector::CursorType::CIRCLE;
|
|
ImGui::SameLine();
|
|
if (ImGui::RadioButton(m_desc.at("sphere").ToUTF8().data(), !is_circle))
|
|
m_cursor_type = TriangleSelector::CursorType::SPHERE;
|
|
} else if (is_area_mode) {
|
|
ImGui::PushItemWidth(m_imgui->scaled(8.4f));
|
|
m_imgui->slider_float(_u8L("Angle threshold"), &m_smart_fill_angle, SmartFillAngleMin, SmartFillAngleMax, "%.0f");
|
|
ImGui::PopItemWidth();
|
|
}
|
|
|
|
if (m_imgui->button(_u8L("Select whole model")))
|
|
select_whole_model();
|
|
|
|
// View mode (#: "make View Mode with just icons"): Normal / Fast / Checker / Distortion behave as
|
|
// one radio group, Wireframe as an independent toggle. Each has its own icon; the tooltip carries
|
|
// the meaning. The underlying state stays m_use_bump_preview + m_uv_check_mode.
|
|
{
|
|
const int cur_mode = m_use_bump_preview ? 1 :
|
|
(m_uv_check_mode == UVCheckMode::Checker ? 2 :
|
|
m_uv_check_mode == UVCheckMode::Distortion ? 3 : 0);
|
|
int new_mode = cur_mode;
|
|
bool wf_toggle = false;
|
|
|
|
m_imgui->text(_L("View"));
|
|
ImGui::SameLine();
|
|
if (icon_toggle(701, "texture_displacement_real_preview.svg", cur_mode == 0, _L("Normal"),
|
|
_L("Normal - the true displaced geometry (what Bake produces)"))) new_mode = 0;
|
|
ImGui::SameLine();
|
|
if (icon_toggle(702, "texture_displacement_fast_preview.svg", cur_mode == 1, _L("Fast"),
|
|
_L("Fast - a bump-shaded approximation of the active layer only; quick to update, not exact"))) new_mode = 1;
|
|
ImGui::SameLine();
|
|
if (icon_toggle(703, "texture_displacement_checker.svg", cur_mode == 2, _L("Checker"),
|
|
_L("Checker - a test grid over the unwrap; squares stay square where it does not stretch"))) new_mode = 2;
|
|
ImGui::SameLine();
|
|
if (icon_toggle(704, "texture_displacement_distortion.svg", cur_mode == 3, _L("Distortion"),
|
|
_L("Distortion - blue-to-red stretch heatmap over the unwrap (needs the Unwrap/LSCM projection)"))) new_mode = 3;
|
|
ImGui::SameLine();
|
|
ImGui::Dummy(ImVec2(m_imgui->scaled(0.6f), 0.f));
|
|
ImGui::SameLine();
|
|
if (icon_toggle(705, "texture_displacement_wireframe.svg", m_wireframe_overlay, _L("Wireframe"),
|
|
_L("Wireframe - overlay the mesh edges; independent of the view above"))) wf_toggle = true;
|
|
|
|
if (new_mode != cur_mode) {
|
|
m_use_bump_preview = (new_mode == 1);
|
|
m_uv_check_mode = (new_mode == 2) ? UVCheckMode::Checker :
|
|
(new_mode == 3) ? UVCheckMode::Distortion : UVCheckMode::None;
|
|
rebuild_uvcheck_mesh();
|
|
if (m_use_bump_preview)
|
|
rebuild_bump_preview_mesh();
|
|
refresh_wireframe(); // Normal<->Fast swaps the wireframe between displaced and base mesh
|
|
update_uv_editor(); // mirror the checker / distortion heatmap into the UV pane too (#7)
|
|
m_parent.set_as_dirty();
|
|
}
|
|
if (wf_toggle) {
|
|
m_wireframe_overlay = !m_wireframe_overlay;
|
|
refresh_wireframe();
|
|
m_parent.set_as_dirty();
|
|
}
|
|
}
|
|
|
|
if (ImGui::Checkbox(_u8L("Auto update").c_str(), &m_auto_update))
|
|
if (m_auto_update)
|
|
rebuild_preview(); // catch up anything that changed while it was off
|
|
if (ImGui::IsItemHovered())
|
|
m_imgui->tooltip(_u8L("Rebuild the displaced geometry as soon as anything changes (painting, textures, "
|
|
"sliders). Turn off to only rebuild when you release a slider, on very heavy models."),
|
|
m_imgui->scaled(20.f));
|
|
|
|
ImGui::Separator();
|
|
m_imgui->text(_L("Texture layers"));
|
|
if (mv != nullptr) {
|
|
// Add-layer affordance as an icon beside the heading. It is deliberately *not* right-aligned
|
|
// against the window edge: this panel uses ImGuiWindowFlags_AlwaysAutoResize, and positioning
|
|
// an item at GetWindowContentRegionMax().x - w feeds the window's own width back into its
|
|
// auto-fit, growing it by one item-spacing every frame - which, with the panel docked and
|
|
// anchored by its right edge, walked it left off-screen on hover. A plain SameLine can't do that.
|
|
const unsigned int add_icon = tool_icon_id();
|
|
const float sz = m_imgui->scaled(1.3f);
|
|
ImGui::SameLine();
|
|
bool add_clicked;
|
|
if (add_icon != 0) {
|
|
// The add icon SVG carries its own border; suppress the ImGui frame border/idle fill.
|
|
push_borderless_icon_style();
|
|
add_clicked = m_imgui->image_button((ImTextureID) (intptr_t) add_icon, ImVec2(sz, sz));
|
|
pop_borderless_icon_style();
|
|
} else {
|
|
add_clicked = m_imgui->button(m_desc.at("add_texture"));
|
|
}
|
|
if (ImGui::IsItemHovered())
|
|
m_imgui->tooltip(_u8L("Add a texture layer"), m_imgui->scaled(20.f));
|
|
if (add_clicked)
|
|
add_texture_layer();
|
|
}
|
|
|
|
if (mv != nullptr) {
|
|
std::vector<TextureDisplacementLayer *> ordered;
|
|
for (TextureDisplacementLayer &l : mv->texture_displacement_layers)
|
|
ordered.push_back(&l);
|
|
std::sort(ordered.begin(), ordered.end(), [](const auto *a, const auto *b) { return a->slot < b->slot; });
|
|
|
|
// Removing a layer erases it from mv->texture_displacement_layers, which shifts every later
|
|
// element down and leaves `ordered` - and `layer` itself - pointing at the wrong element
|
|
// (or past the end). Removing mid-loop would therefore keep rendering this row's remaining
|
|
// widgets against freed/shifted memory. Defer it to after the loop instead.
|
|
int slot_to_remove = -1;
|
|
|
|
// The layer stack lives in its own scrolling, tinted region (#10, #12): with eight layers'
|
|
// worth of controls the panel otherwise runs off the bottom of the screen, and there was
|
|
// nothing to tell "settings that belong to this layer" apart from "settings that belong to
|
|
// the tool".
|
|
ImGui::PushStyleColor(ImGuiCol_ChildBg, ImVec4(1.f, 1.f, 1.f, 0.04f));
|
|
ImGui::BeginChild("##texture_layers", ImVec2(0.f, m_imgui->scaled(20.f)), true);
|
|
|
|
for (size_t li = 0; li < ordered.size(); ++li) {
|
|
TextureDisplacementLayer *layer = ordered[li];
|
|
ImGui::PushID(layer->slot);
|
|
const bool is_active = layer->slot == m_active_layer_slot;
|
|
|
|
// Tint the whole active layer's block, not just its header (#: "background color for the
|
|
// selected layer ... whole plate"). The block's height isn't known until it is laid out, so
|
|
// draw the block into a foreground channel and the backing rectangle into a background one,
|
|
// then merge - the standard ImGui "rect behind a group" trick.
|
|
ImDrawList *dl = ImGui::GetWindowDrawList();
|
|
const ImVec2 block_min = ImGui::GetCursorScreenPos();
|
|
const float block_rx = ImGui::GetWindowPos().x + ImGui::GetWindowContentRegionMax().x;
|
|
if (is_active) {
|
|
dl->ChannelsSplit(2);
|
|
dl->ChannelsSetCurrent(1);
|
|
}
|
|
|
|
// Clicking the header - or anywhere in the layer's block, see the group below - makes
|
|
// it the active layer (#11, #12). A radio button was doing this before, which worked but
|
|
// gave no sense of which block of controls belonged to which layer.
|
|
ImGui::PushStyleColor(ImGuiCol_Header, ImVec4(0.f, 0.68f, 0.58f, 0.55f));
|
|
ImGui::PushStyleColor(ImGuiCol_HeaderHovered, ImVec4(0.f, 0.68f, 0.58f, 0.35f));
|
|
const std::string header = layer->name.empty() ? Slic3r::format(_u8L("Layer %1%"), li + 1) : layer->name;
|
|
if (ImGui::Selectable(header.c_str(), is_active, 0, ImVec2(ImGui::GetContentRegionAvail().x - m_imgui->scaled(3.f), 0.f)))
|
|
set_active_layer(layer->slot);
|
|
ImGui::PopStyleColor(2);
|
|
ImGui::SameLine();
|
|
if (icon_button(600 + layer->slot, "texture_displacement_cross.svg", m_imgui->scaled(1.3f),
|
|
m_desc.at("remove_layer"), _u8L("Remove this layer")))
|
|
slot_to_remove = layer->slot;
|
|
|
|
// Everything below is this layer's own; the group lets a click anywhere inside it select
|
|
// the layer, which is what makes the block feel like one object rather than loose widgets.
|
|
ImGui::BeginGroup();
|
|
render_texture_picker(*layer);
|
|
|
|
ImGui::PushItemWidth(m_imgui->scaled(8.4f));
|
|
// Depth and tile size are logarithmic: see ImGuiLogSlider.
|
|
m_preview_params_dirty |= m_imgui->slider_float(_u8L("Depth (mm)"), &layer->depth_mm, 0.01f, 10.f, "%.3f", ImGuiLogSlider);
|
|
m_preview_params_dirty |= m_imgui->slider_float(_u8L("Tile size (mm)"), &layer->tiling_scale, 0.2f, 200.f, "%.2f", ImGuiLogSlider);
|
|
m_preview_params_dirty |= m_imgui->slider_float(_u8L("Rotation"), &layer->rotation_deg, 0.f, 360.f, "%.0f");
|
|
// Midlevel (#19): the height that means "don't move". At 0 the surface only ever bulges
|
|
// outwards; at 0.5 mid-grey is neutral and darker texels cut inwards.
|
|
m_preview_params_dirty |= m_imgui->slider_float(_u8L("Midlevel"), &layer->midlevel, 0.f, 10.f, "%.2f");
|
|
ImGui::PopItemWidth();
|
|
if (ImGui::IsItemHovered())
|
|
m_imgui->tooltip(_u8L("The grey level that stays put. At 0 the texture can only push the surface "
|
|
"outwards. Raise it and anything darker cuts inwards instead, so one height "
|
|
"map both embosses and engraves -0.5 makes mid-grey neutral.\n\n"
|
|
"Cutting inwards can fold the surface through itself where normals converge: "
|
|
"inside a sharp concave corner, or through a thin wall. Keep Depth small "
|
|
"relative to the feature you are cutting into."),
|
|
m_imgui->scaled(20.f));
|
|
if (layer->midlevel > 0.f && layer->depth_mm > 1.f)
|
|
m_imgui->warning_text(_L("Deep inward displacement may self-intersect."));
|
|
|
|
ImGui::PushItemWidth(m_imgui->scaled(8.4f));
|
|
m_preview_params_dirty |= m_imgui->slider_float(_u8L("Smoothing"), &layer->smoothing, 0.f, 1.f, "%.2f");
|
|
ImGui::PopItemWidth();
|
|
if (ImGui::IsItemHovered())
|
|
m_imgui->tooltip(_u8L("Blurs the height texture before it displaces the surface, rounding hard edges "
|
|
"and removing speckle without needing a softer source image. Affects the preview "
|
|
"and the bake alike."),
|
|
m_imgui->scaled(20.f));
|
|
|
|
// Edge smoothing: fade the displacement to flat toward the boundary of the painted area.
|
|
m_preview_params_dirty |= ImGui::Checkbox(_u8L("Edge smoothing").c_str(), &layer->edge_smoothing);
|
|
if (ImGui::IsItemHovered())
|
|
m_imgui->tooltip(_u8L("Fade the relief to flat toward the edge of the painted area, so it blends into "
|
|
"the surrounding surface. A small amount only softens a thin band at the very "
|
|
"edge; the maximum flattens the whole painted face."),
|
|
m_imgui->scaled(20.f));
|
|
if (layer->edge_smoothing) {
|
|
ImGui::PushItemWidth(m_imgui->scaled(8.4f));
|
|
m_preview_params_dirty |= m_imgui->slider_float(_u8L("Edge amount"), &layer->edge_smoothing_amount,
|
|
0.02f, 1.f, "%.2f");
|
|
ImGui::PopItemWidth();
|
|
}
|
|
|
|
m_preview_params_dirty |= ImGui::Checkbox(_u8L("Invert").c_str(), &layer->invert);
|
|
|
|
// The lowest painted layer has nothing underneath it to combine with - it *is* the
|
|
// base - so a blend mode would be meaningless (and Multiply/Divide against an implicit
|
|
// zero would annihilate it). build_texture_displacement() forces the first layer to
|
|
// reach a given vertex to behave additively regardless; say so rather than offering a
|
|
// control that silently does nothing.
|
|
if (li == 0) {
|
|
ImGui::TextDisabled("%s", _u8L("Base layer").c_str());
|
|
} else {
|
|
m_imgui->text(_u8L("Blend"));
|
|
ImGui::SameLine();
|
|
const std::string blend_add = _u8L("Add");
|
|
const std::string blend_subtract = _u8L("Subtract");
|
|
const std::string blend_multiply = _u8L("Multiply");
|
|
const std::string blend_divide = _u8L("Divide");
|
|
const char *blend_items[] = { blend_add.c_str(), blend_subtract.c_str(), blend_multiply.c_str(),
|
|
blend_divide.c_str() };
|
|
int blend_mode = static_cast<int>(layer->blend_mode);
|
|
ImGui::PushItemWidth(m_imgui->scaled(8.4f));
|
|
if (scoped_combo("##blend_mode", &blend_mode, blend_items, IM_ARRAYSIZE(blend_items))) {
|
|
layer->blend_mode = static_cast<TextureBlendMode>(blend_mode);
|
|
m_preview_params_dirty = true;
|
|
}
|
|
ImGui::PopItemWidth();
|
|
if (ImGui::IsItemHovered())
|
|
m_imgui->tooltip(_u8L("How this layer combines with the layers below it, wherever they overlap. "
|
|
"Add and Subtract pile relief on or carve it away. Multiply and Divide "
|
|
"scale the relief underneath, which makes this layer act as a mask over "
|
|
"it - for those two, Depth is a gain, and a depth of 1 mm on a white "
|
|
"part of the texture leaves the layers below unchanged."),
|
|
m_imgui->scaled(20.f));
|
|
}
|
|
|
|
m_preview_params_dirty |= ImGui::Checkbox(_u8L("Tile").c_str(), &layer->tile_enabled);
|
|
if (layer->tile_enabled) {
|
|
ImGui::SameLine();
|
|
const std::string tile_method_repeat = _u8L("Repeat");
|
|
const std::string tile_method_mirrored = _u8L("Mirrored repeat");
|
|
const char *tile_method_items[] = { tile_method_repeat.c_str(), tile_method_mirrored.c_str() };
|
|
int tile_method = static_cast<int>(layer->tile_method);
|
|
ImGui::PushItemWidth(m_imgui->scaled(8.4f));
|
|
if (scoped_combo("##tile_method", &tile_method, tile_method_items, IM_ARRAYSIZE(tile_method_items))) {
|
|
layer->tile_method = static_cast<TextureTileMethod>(tile_method);
|
|
m_preview_params_dirty = true;
|
|
}
|
|
ImGui::PopItemWidth();
|
|
}
|
|
|
|
{
|
|
m_imgui->text(_u8L("Projection"));
|
|
ImGui::SameLine();
|
|
const std::string projection_triplanar = _u8L("Triplanar (blended)");
|
|
const std::string projection_cylindrical = _u8L("Cylindrical");
|
|
const std::string projection_spherical = _u8L("Spherical");
|
|
const std::string projection_lscm = _u8L("Unwrap (LSCM)");
|
|
const std::string projection_view = _u8L("From view");
|
|
const char *projection_items[] = { projection_triplanar.c_str(), projection_cylindrical.c_str(),
|
|
projection_spherical.c_str(), projection_lscm.c_str(),
|
|
projection_view.c_str() };
|
|
int projection_method = static_cast<int>(layer->projection_method);
|
|
ImGui::PushItemWidth(m_imgui->scaled(8.4f));
|
|
if (scoped_combo("##projection_method", &projection_method, projection_items, IM_ARRAYSIZE(projection_items))) {
|
|
const auto new_method = static_cast<TextureProjectionMethod>(projection_method);
|
|
// Capture the current view the moment "From view" is chosen, so it does something
|
|
// sensible immediately rather than projecting from a stale/default direction.
|
|
if (new_method == TextureProjectionMethod::ViewProjected &&
|
|
layer->projection_method != TextureProjectionMethod::ViewProjected)
|
|
capture_view_projection(*layer);
|
|
layer->projection_method = new_method;
|
|
m_preview_params_dirty = true;
|
|
}
|
|
ImGui::PopItemWidth();
|
|
if (ImGui::IsItemHovered())
|
|
m_imgui->tooltip(layer->projection_method == TextureProjectionMethod::LSCM ?
|
|
_u8L("Flattens the painted area and maps the texture onto it with as little "
|
|
"stretching as possible. The area is cut into pieces at its sharp edges "
|
|
"first (see Seam angle), so each piece can lie flat on its own.") :
|
|
_u8L("Triplanar projects the texture from all three axes at once and blends "
|
|
"between them, so a patch wrapping around a sharp edge has no seam. "
|
|
"Cylindrical and Spherical wrap the texture around the painted area's "
|
|
"own centre, for round shapes."),
|
|
m_imgui->scaled(20.f));
|
|
|
|
if (layer->projection_method == TextureProjectionMethod::LSCM) {
|
|
ImGui::PushItemWidth(m_imgui->scaled(8.4f));
|
|
m_preview_params_dirty |= m_imgui->slider_float(_u8L("Seam angle"), &layer->lscm_seam_angle_deg,
|
|
5.f, 90.f, "%.0f");
|
|
ImGui::PopItemWidth();
|
|
if (ImGui::IsItemHovered())
|
|
m_imgui->tooltip(_u8L("Edges sharper than this are cut, and the pieces either side of them are "
|
|
"flattened separately. Lower it to cut more: each piece then lies flat "
|
|
"with less stretching, at the cost of the texture not running continuously "
|
|
"across the cut. Raise it to keep more of the area in one piece. Corners "
|
|
"of a box are 90 degrees, so the default cuts them apart; a smoothly "
|
|
"rounded surface stays whole."),
|
|
m_imgui->scaled(20.f));
|
|
|
|
if (ImGui::Checkbox(_u8L("Connect islands").c_str(), &layer->auto_connect_islands)) {
|
|
// Apply (or, when turned off, just stop re-applying) right away rather than
|
|
// waiting for the next re-unwrap.
|
|
if (layer->auto_connect_islands && !m_uv_editor_unwrap.empty()) {
|
|
std::vector<TextureIsland> net = compute_connected_net(m_uv_editor_unwrap);
|
|
if (net.size() == size_t(m_uv_editor_unwrap.chart_count)) {
|
|
if (layer->islands.size() < net.size())
|
|
layer->islands.resize(net.size());
|
|
for (size_t i = 0; i < net.size(); ++i)
|
|
layer->islands[i] = net[i];
|
|
}
|
|
}
|
|
m_preview_params_dirty = true;
|
|
}
|
|
if (ImGui::IsItemHovered())
|
|
m_imgui->tooltip(_u8L("Lay the unwrap out as a connected net: pieces that share an edge are "
|
|
"unfolded next to each other (a cube becomes a joined net rather than six "
|
|
"loose squares). They stay separate islands, so you can still move any of "
|
|
"them by hand afterwards."),
|
|
m_imgui->scaled(20.f));
|
|
|
|
if (is_active) {
|
|
// Explicit unwrap (#: "Add unwrap button so it does not recompute on every
|
|
// change"). The LSCM solve runs only when this is pressed - painting, the seam
|
|
// angle slider and seam marking no longer trigger it - and the pane opens right
|
|
// afterwards. Re-press it to fold in any edits made since.
|
|
if (m_imgui->button(_u8L("Unwrap"))) {
|
|
m_uv_unwrap_pending = true;
|
|
m_uv_apply_connected_net = true; // a genuine re-unwrap may relayout the islands
|
|
m_show_uv_editor = true;
|
|
update_uv_editor();
|
|
}
|
|
if (ImGui::IsItemHovered())
|
|
m_imgui->tooltip(_u8L("Flatten the painted area into UV islands and open the UV editor. The "
|
|
"unwrap is computed only when you press this, not on every edit - so "
|
|
"paint, change the seam angle or mark seams first, then press Unwrap to "
|
|
"see the result. Islands can then be moved, rotated and scaled."),
|
|
m_imgui->scaled(20.f));
|
|
|
|
// Select mode for the UV pane: whole islands, single vertices, or single edges.
|
|
// Vertex/Edge are free-form UV editing and feed the per-vertex overrides that the
|
|
// bake honours; Island is the move/rotate/scale-with-grouping behaviour.
|
|
if (!m_uv_editor_unwrap.empty()) {
|
|
m_imgui->text(_u8L("Select:"));
|
|
const auto set_uv_mode = [&](int mode) {
|
|
if (mode != m_uv_select_mode) {
|
|
m_uv_select_mode = mode;
|
|
if (UVEditorCanvas *c = wxGetApp().plater()->get_uv_editor_canvas())
|
|
c->set_select_mode(static_cast<UVEditorCanvas::SelectMode>(mode));
|
|
}
|
|
};
|
|
ImGui::SameLine();
|
|
if (icon_toggle(810, "texture_displacement_uv_select_island.svg", m_uv_select_mode == 0,
|
|
_L("Island"), _L("Island - move, rotate and scale whole islands")))
|
|
set_uv_mode(0);
|
|
ImGui::SameLine();
|
|
if (icon_toggle(811, "texture_displacement_uv_select_vertex.svg", m_uv_select_mode == 1,
|
|
_L("Vertex"), _L("Vertex - drag vertices to reshape; Shift/Ctrl to multi-select")))
|
|
set_uv_mode(1);
|
|
ImGui::SameLine();
|
|
if (icon_toggle(812, "texture_displacement_uv_select_edge.svg", m_uv_select_mode == 2,
|
|
_L("Edge"), _L("Edge - drag edges to reshape; Shift/Ctrl to multi-select")))
|
|
set_uv_mode(2);
|
|
if (!layer->lscm_uv_overrides.empty()) {
|
|
ImGui::SameLine();
|
|
if (m_imgui->button(_u8L("Clear UV edits"))) {
|
|
Plater::TakeSnapshot snapshot(wxGetApp().plater(), _u8L("Clear texture UV edits"),
|
|
UndoRedo::SnapshotType::GizmoAction);
|
|
layer->lscm_uv_overrides.clear();
|
|
m_uv_unwrap_pending = true; // re-solve so the pane drops the edited coords
|
|
update_uv_editor();
|
|
rebuild_preview();
|
|
}
|
|
if (ImGui::IsItemHovered())
|
|
m_imgui->tooltip(_u8L("Discard all manual vertex/edge moves and return the unwrap to its "
|
|
"automatic shape."),
|
|
m_imgui->scaled(20.f));
|
|
}
|
|
}
|
|
|
|
// Manual seam marking (#9): a click mode that toggles mesh edges as seams.
|
|
bool seam_mode = m_seam_edit_mode;
|
|
if (ImGui::Checkbox(_u8L("Mark seams").c_str(), &seam_mode)) {
|
|
m_seam_edit_mode = seam_mode;
|
|
if (seam_mode)
|
|
m_adjust_texture_mode = false; // the two click modes are mutually exclusive
|
|
else {
|
|
m_seam_hover_edge = { -1, -1 }; // drop the hover highlight when leaving the mode
|
|
m_seam_hover_vertex = -1;
|
|
m_seam_hover_glmodel.reset();
|
|
m_seam_path_anchor = -1;
|
|
m_seam_anchor_glmodel.reset();
|
|
}
|
|
m_parent.set_as_dirty();
|
|
}
|
|
if (ImGui::IsItemHovered())
|
|
m_imgui->tooltip(_u8L("Click edges on the model to cut the unwrap along them, like marking a "
|
|
"seam in Blender. The edge under the cursor is highlighted yellow; click "
|
|
"to mark it red. Click a marked (red) edge again to unmark it. Hold Ctrl "
|
|
"and drag to rotate the view. Painting is paused while this is on."),
|
|
m_imgui->scaled(20.f));
|
|
if (m_seam_edit_mode) {
|
|
// Shortest-path mode, for dense meshes: click two points, seam the whole path.
|
|
ImGui::SameLine();
|
|
bool path_mode = m_seam_path_mode;
|
|
if (ImGui::Checkbox(_u8L("Path").c_str(), &path_mode)) {
|
|
m_seam_path_mode = path_mode;
|
|
m_seam_path_anchor = -1;
|
|
m_seam_hover_edge = { -1, -1 }; // hover target type changes with the mode
|
|
m_seam_hover_vertex = -1;
|
|
m_seam_hover_glmodel.reset();
|
|
m_seam_anchor_glmodel.reset();
|
|
m_parent.set_as_dirty();
|
|
}
|
|
if (ImGui::IsItemHovered())
|
|
m_imgui->tooltip(_u8L("Instead of clicking every edge, click a start point and then an end "
|
|
"point: the whole shortest path between them is seamed at once (green "
|
|
"marks the start). Each click extends the seam from the last point. "
|
|
"Best for dense meshes."),
|
|
m_imgui->scaled(20.f));
|
|
}
|
|
if (!layer->lscm_seam_edges.empty()) {
|
|
ImGui::SameLine();
|
|
if (m_imgui->button(_u8L("Clear seams"))) {
|
|
Plater::TakeSnapshot snapshot(wxGetApp().plater(), _u8L("Clear texture seams"),
|
|
UndoRedo::SnapshotType::GizmoAction);
|
|
layer->lscm_seam_edges.clear();
|
|
rebuild_preview();
|
|
}
|
|
}
|
|
|
|
// What the UV editor is actually showing. Cheap, and the only way to tell an
|
|
// unwrap that produced nothing apart from one merely framed off-screen. The
|
|
// island tools (snap, average scale, cut) and the gesture hints now live in the
|
|
// UV pane itself - its toolbar and status line - rather than here (#18).
|
|
if (m_uv_editor_unwrap.empty())
|
|
m_imgui->text(_u8L("Press Unwrap to flatten the painted area."));
|
|
else
|
|
m_imgui->text(Slic3r::format(_u8L("Unwrap: %1% islands, %2% faces, %3% verts."),
|
|
m_uv_editor_unwrap.chart_count, m_uv_editor_unwrap.indices.size(),
|
|
m_uv_editor_unwrap.uvs.size()));
|
|
}
|
|
}
|
|
|
|
if (layer->projection_method == TextureProjectionMethod::ViewProjected) {
|
|
// Re-capture the projector from wherever the camera is now (#6): orbit the model,
|
|
// press this, and the texture is re-laid from the new angle.
|
|
if (m_imgui->button(_u8L("Capture current view"))) {
|
|
capture_view_projection(*layer);
|
|
// Selecting the visible faces *after* capturing means the projector axes are
|
|
// already the ones the selection was made against - the two describe the
|
|
// same viewpoint, which is the whole point of the option.
|
|
if (m_project_only_visible && select_visible_faces() == 0)
|
|
show_error(nullptr, _u8L("Nothing is visible from this angle - turn the model to face the "
|
|
"part you want to project onto."));
|
|
m_preview_params_dirty = true;
|
|
update_projector();
|
|
}
|
|
if (ImGui::IsItemHovered())
|
|
m_imgui->tooltip(_u8L("Projects the texture straight onto the painted area from the direction "
|
|
"you are currently looking, like a slide projector. Faces angled away from "
|
|
"that direction will stretch - turn the model to where you want the "
|
|
"texture crisp, then capture."),
|
|
m_imgui->scaled(20.f));
|
|
|
|
if (ImGui::Checkbox(_u8L("Project only on visible").c_str(), &m_project_only_visible)) {
|
|
if (m_project_only_visible && select_visible_faces() == 0)
|
|
show_error(nullptr, _u8L("Nothing is visible from this angle - turn the model to face the "
|
|
"part you want to project onto."));
|
|
m_preview_params_dirty = true;
|
|
update_projector();
|
|
}
|
|
if (ImGui::IsItemHovered())
|
|
m_imgui->tooltip(_u8L("Paints exactly the faces you can currently see - facing the camera and "
|
|
"not hidden behind anything else - and projects onto those. This replaces "
|
|
"the layer's painted area, and is re-applied each time you capture the "
|
|
"view."),
|
|
m_imgui->scaled(20.f));
|
|
|
|
ImGui::Separator();
|
|
bool projector_open = m_projector_frame != nullptr && m_projector_frame->IsShown();
|
|
if (ImGui::Checkbox(_u8L("Projection frame").c_str(), &projector_open))
|
|
show_projector(projector_open);
|
|
if (ImGui::IsItemHovered())
|
|
m_imgui->tooltip(_u8L("Opens a see-through window you drag over the model. Whatever shows "
|
|
"through it is what the texture is projected onto, and the window's "
|
|
"border becomes the edge of the projection. Move and resize it to frame "
|
|
"the area you want, then press Apply."),
|
|
m_imgui->scaled(20.f));
|
|
|
|
if (projector_open) {
|
|
ImGui::PushItemWidth(m_imgui->scaled(6.f));
|
|
if (ImGui::SliderInt(_u8L("Opacity").c_str(), &m_projector_opacity, 20, 255))
|
|
m_projector_frame->set_opacity(m_projector_opacity);
|
|
ImGui::PopItemWidth();
|
|
|
|
if (m_imgui->button(_u8L("Apply projection frame"))) {
|
|
const int painted = apply_projection_frame();
|
|
if (painted == 0)
|
|
show_error(nullptr, _u8L("Nothing of the model is inside the frame - move it over the "
|
|
"part you want to project onto."));
|
|
else if (painted < 0)
|
|
show_error(nullptr, _u8L("The frame could not be applied. Make sure it overlaps the "
|
|
"3D view."));
|
|
}
|
|
if (ImGui::IsItemHovered())
|
|
m_imgui->tooltip(_u8L("Projects the texture through the frame from the direction you are "
|
|
"looking now, and paints the visible faces inside it. This replaces "
|
|
"the layer's painted area. The result is fixed to the model, so you "
|
|
"can orbit freely afterwards."),
|
|
m_imgui->scaled(20.f));
|
|
}
|
|
|
|
if (layer->view_project_projective) {
|
|
m_imgui->text(_u8L("Placed by projection frame."));
|
|
ImGui::SameLine();
|
|
if (m_imgui->button(_u8L("Clear"))) {
|
|
// Back to the plain axis projection, where tiling/rotation/offset mean
|
|
// something again - the matrix path deliberately ignores them.
|
|
layer->view_project_projective = false;
|
|
layer->tile_enabled = true;
|
|
m_preview_params_dirty = true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (is_active) {
|
|
bool adjust_on = m_adjust_texture_mode;
|
|
if (ImGui::Checkbox(_u8L("Adjust placement").c_str(), &adjust_on)) {
|
|
if (adjust_on) {
|
|
update_model_object(); // flush any pending strokes before anchoring
|
|
if (update_adjust_anchor())
|
|
m_adjust_texture_mode = true;
|
|
else
|
|
show_error(nullptr, _u8L("Paint something with this layer first."));
|
|
} else {
|
|
m_adjust_texture_mode = false;
|
|
m_adjust_drag_handle = AdjustHandle::None;
|
|
}
|
|
}
|
|
}
|
|
ImGui::EndGroup();
|
|
// A click that lands on the layer's body (not on a widget, which consumes its own click)
|
|
// selects it too, so the whole block reads as one clickable object (#12).
|
|
if (!is_active && ImGui::IsItemClicked())
|
|
set_active_layer(layer->slot);
|
|
|
|
if (is_active) {
|
|
const float pad = m_imgui->scaled(0.25f);
|
|
const ImVec2 rmin(block_min.x - pad, block_min.y - pad);
|
|
const ImVec2 rmax(block_rx, ImGui::GetCursorScreenPos().y);
|
|
dl->ChannelsSetCurrent(0);
|
|
dl->AddRectFilled(rmin, rmax, ImGui::GetColorU32(ImVec4(0.0f, 0.59f, 0.53f, 0.22f)), m_imgui->scaled(0.2f));
|
|
dl->ChannelsMerge();
|
|
}
|
|
|
|
ImGui::PopID();
|
|
ImGui::Separator();
|
|
}
|
|
|
|
ImGui::EndChild();
|
|
ImGui::PopStyleColor();
|
|
|
|
if (slot_to_remove >= 0)
|
|
remove_texture_layer(slot_to_remove); // deferred: see slot_to_remove's declaration
|
|
}
|
|
|
|
// ImGui sliders report "changed" continuously on every frame while being dragged, not just
|
|
// once on release - rebuilding the preview (a real CPU mesh recompute) on every one of those
|
|
// frames is what made dragging these sliders feel slow. Only rebuild once the mouse button
|
|
// that's driving the drag is released, i.e. once per edit instead of dozens of times per drag.
|
|
// The feature-adaptive subdivision follows the *displaced* surface (it samples depth * texture),
|
|
// so depth / tile-size / rotation / invert all change where it puts triangles. The heavy
|
|
// displacement preview below only rebuilds on release, which made the subdivide wireframe look
|
|
// like it ignored those edits - so rebuild it live here (during the drag), the same cadence its
|
|
// own sliders use. Cheap: it is bounded by the painted region.
|
|
if (m_preview_params_dirty && m_subdivide_editing && m_subdivide_adaptive && m_subdivide_feature)
|
|
rebuild_subdivide_preview();
|
|
if (m_preview_params_dirty && (m_auto_update || !ImGui::IsMouseDown(ImGuiMouseButton_Left))) {
|
|
rebuild_preview();
|
|
// Same edits (tile size, rotation, offset, a new texture) are what the projector window
|
|
// draws, so it refreshes on the same one-per-edit cadence. No-op while it is closed.
|
|
update_projector();
|
|
m_preview_params_dirty = false;
|
|
}
|
|
// (The "Add layer" button now lives next to the "Texture layers" heading, as an icon.)
|
|
|
|
// Settings for the whole stack rather than one layer, so they sit outside the layer list.
|
|
if (mv != nullptr) {
|
|
TextureDisplacementOptions &opts = mv->texture_displacement_options;
|
|
ImGui::Separator();
|
|
|
|
m_preview_params_dirty |= ImGui::Checkbox(_u8L("Displace up to the border").c_str(), &opts.displace_border);
|
|
if (ImGui::IsItemHovered())
|
|
m_imgui->tooltip(_u8L("Move the outermost ring of painted vertices as well, so the relief runs all the "
|
|
"way to the edge of the painted area. Turn this off to hold that ring flat, which "
|
|
"keeps the displacement strictly inside the paint but flattens the pattern right "
|
|
"at the border."),
|
|
m_imgui->scaled(20.f));
|
|
|
|
m_preview_params_dirty |= ImGui::Checkbox(_u8L("Smooth result").c_str(), &opts.smooth_enabled);
|
|
if (ImGui::IsItemHovered())
|
|
m_imgui->tooltip(_u8L("Relax the displaced surface after the textures have been applied, to round off "
|
|
"the hard steps a bitmap height map leaves behind. Only the vertices the "
|
|
"displacement moved are touched. This is geometry smoothing - the per-layer "
|
|
"\"Blur\" slider instead softens the height map before it is sampled."),
|
|
m_imgui->scaled(20.f));
|
|
if (opts.smooth_enabled) {
|
|
ImGui::PushItemWidth(m_imgui->scaled(8.4f));
|
|
float percent = opts.smooth_strength * 100.f;
|
|
if (m_imgui->slider_float(std::string(_u8L("Smoothing (%)")) + "##dispsmooth", &percent, 1.f, 100.f, "%.0f", 1.f)) {
|
|
opts.smooth_strength = std::clamp(percent / 100.f, 0.01f, 1.f);
|
|
m_preview_params_dirty = true;
|
|
}
|
|
if (ImGui::SliderInt((_u8L("Passes") + "##dispsmoothit").c_str(), &opts.smooth_iterations, 1, 10)) {
|
|
opts.smooth_iterations = std::clamp(opts.smooth_iterations, 1, 10);
|
|
m_preview_params_dirty = true;
|
|
}
|
|
if (ImGui::IsItemHovered())
|
|
m_imgui->tooltip(_u8L("How many relaxation passes to run. More passes reach further across the "
|
|
"surface; strength controls how much each one moves a vertex."),
|
|
m_imgui->scaled(20.f));
|
|
ImGui::PopItemWidth();
|
|
|
|
m_preview_params_dirty |= ImGui::Checkbox(_u8L("Ignore outer ring").c_str(), &opts.smooth_skip_border);
|
|
if (ImGui::IsItemHovered())
|
|
m_imgui->tooltip(_u8L("Leave the outermost ring of painted vertices out of the smoothing. Their "
|
|
"neighbours outside the paint never move, so relaxing them drags the relief "
|
|
"back down and the pattern comes out half-melted right at the edge. Turn this "
|
|
"off only if you want that edge softened on purpose."),
|
|
m_imgui->scaled(20.f));
|
|
|
|
// The settings above ride along with Preview/Bake. This button is for geometry that has
|
|
// *already* been baked, where there is no displacement left to fold the smoothing into.
|
|
if (m_imgui->button(_u8L("Smooth baked mesh now")))
|
|
smooth_model();
|
|
if (ImGui::IsItemHovered())
|
|
m_imgui->tooltip(_u8L("Apply the smoothing above to the model's real geometry right now, using the "
|
|
"painted area to decide what to touch. For relief that is already baked in - "
|
|
"unbaked displacement is smoothed by Bake itself."),
|
|
m_imgui->scaled(20.f));
|
|
}
|
|
}
|
|
|
|
ImGui::Separator();
|
|
m_imgui->text(_u8L("Not enough vertices for fine detail?"));
|
|
|
|
if (ImGui::Checkbox(_u8L("Only painted area (adaptive)").c_str(), &m_subdivide_adaptive)) {
|
|
if (m_subdivide_editing)
|
|
rebuild_subdivide_preview(); // switch the wireframe between the uniform and adaptive result
|
|
m_parent.set_as_dirty();
|
|
}
|
|
if (ImGui::IsItemHovered())
|
|
m_imgui->tooltip(_u8L("Refine only where you have painted, down to a target edge length, instead of splitting "
|
|
"the whole model. Keeps the triangle count down on a big part with a small decal, and - "
|
|
"unlike whole-model subdivision - your paint is carried onto the finer mesh instead of "
|
|
"being cleared."),
|
|
m_imgui->scaled(20.f));
|
|
|
|
if (m_subdivide_adaptive) {
|
|
if (m_subdivide_target_mm <= 0.f && mv != nullptr) {
|
|
// Seed the target at about half the mesh's mean edge length, so the default already adds
|
|
// a useful amount of detail rather than landing on "no change".
|
|
const indexed_triangle_set &its = mv->mesh().its;
|
|
double sum = 0.0; size_t cnt = 0;
|
|
for (const stl_triangle_vertex_indices &tri : its.indices)
|
|
for (int i = 0; i < 3; ++i) {
|
|
sum += (its.vertices[tri[i]] - its.vertices[tri[(i + 1) % 3]]).norm();
|
|
++cnt;
|
|
}
|
|
m_subdivide_target_mm = cnt > 0 ? std::clamp(float(sum / double(cnt)) * 0.5f, 0.001f, 20.f) : 1.f;
|
|
}
|
|
// Live preview: rebuild the wireframe as the slider moves, not only on release, so it tracks
|
|
// the value. The rebuild is bounded by the painted region, so it stays responsive.
|
|
const auto preview_live = [this]() {
|
|
if (m_subdivide_editing)
|
|
rebuild_subdivide_preview();
|
|
m_parent.set_as_dirty();
|
|
};
|
|
|
|
if (ImGui::Checkbox(_u8L("Follow texture detail").c_str(), &m_subdivide_feature)) {
|
|
if (m_subdivide_editing)
|
|
rebuild_subdivide_preview();
|
|
m_parent.set_as_dirty();
|
|
}
|
|
if (ImGui::IsItemHovered())
|
|
m_imgui->tooltip(_u8L("Put triangles only where the texture actually bends - dense over hills, ridges and "
|
|
"noise, sparse over flat areas and smooth slopes - instead of an even density "
|
|
"everywhere. Uses the combined displacement of all painted layers."),
|
|
m_imgui->scaled(20.f));
|
|
|
|
ImGui::PushItemWidth(m_imgui->scaled(8.4f));
|
|
// The edge-length target is a baseline in both modes. In feature mode it is what guarantees
|
|
// the curvature test can actually see the texture: left too coarse, a big triangle over a
|
|
// fine pattern can sample four points that all happen to land at similar heights, report no
|
|
// error, and stall before refinement ever starts. "##subdiv" avoids an ID clash with the
|
|
// remesh "Target edge (mm)" slider below (same label == same widget to ImGui).
|
|
if (m_imgui->slider_float(std::string(m_subdivide_feature ? _u8L("Max edge (mm)") : _u8L("Target edge (mm)")) +
|
|
"##subdiv",
|
|
&m_subdivide_target_mm, 0.001f, 20.f, "%.3f", ImGuiLogSlider))
|
|
preview_live();
|
|
if (ImGui::IsItemHovered())
|
|
m_imgui->tooltip(m_subdivide_feature ?
|
|
_u8L("Nothing in the painted area stays coarser than this, even where the texture is "
|
|
"flat. Keep it near the size of the features you want picked up - too coarse and "
|
|
"fine detail can be missed entirely.") :
|
|
_u8L("Triangles in the painted area are split until every edge is at or below this "
|
|
"length. Smaller means finer detail and more triangles."),
|
|
m_imgui->scaled(20.f));
|
|
|
|
if (m_subdivide_feature) {
|
|
// On top of the baseline: the chord-error tolerance, and the resolution floor.
|
|
if (m_imgui->slider_float(std::string(_u8L("Detail (mm)")) + "##subdivdetail", &m_subdivide_detail_mm,
|
|
0.001f, 1.f, "%.3f", ImGuiLogSlider))
|
|
preview_live();
|
|
if (ImGui::IsItemHovered())
|
|
m_imgui->tooltip(_u8L("How closely the mesh follows the texture's relief. Smaller captures finer bumps; "
|
|
"larger only chases the big features."),
|
|
m_imgui->scaled(20.f));
|
|
if (m_imgui->slider_float(std::string(_u8L("Min edge (mm)")) + "##subdivmin", &m_subdivide_min_edge_mm,
|
|
0.001f, 20.f, "%.3f", ImGuiLogSlider))
|
|
preview_live();
|
|
if (ImGui::IsItemHovered())
|
|
m_imgui->tooltip(_u8L("The finest triangle size refinement will ever produce. Smaller captures finer "
|
|
"relief; also stops runaway subdivision at a sharp texture step, where the "
|
|
"surface never becomes flat."),
|
|
m_imgui->scaled(20.f));
|
|
}
|
|
|
|
// The budget. Refinement is worst-error-first, so a run that hits it has still spent its
|
|
// triangles on the biggest deviations - raising it buys detail, it does not redistribute it.
|
|
if (ImGui::SliderInt((_u8L("Added triangles (k)") + "##subdivbudget").c_str(), &m_subdivide_budget_k, 10, 2000)) {
|
|
m_subdivide_budget_k = std::clamp(m_subdivide_budget_k, 10, 2000);
|
|
preview_live();
|
|
}
|
|
if (ImGui::IsItemHovered())
|
|
m_imgui->tooltip(_u8L("How many thousand triangles the refinement may add. It always splits the "
|
|
"worst-fitting triangle first, so a run that uses the whole budget has still spent "
|
|
"it where it shows most - raise this if the preview still looks too coarse."),
|
|
m_imgui->scaled(20.f));
|
|
ImGui::PopItemWidth();
|
|
|
|
if (m_subdivide_editing && m_subdivide_preview_tris > 0)
|
|
m_imgui->text(Slic3r::format(_u8L("Preview: %1% triangles"), m_subdivide_preview_tris));
|
|
} else {
|
|
ImGui::PushItemWidth(m_imgui->scaled(8.4f));
|
|
if (ImGui::SliderInt(_u8L("Subdivide steps").c_str(), &m_subdivide_count, 0, 5)) {
|
|
m_subdivide_count = std::clamp(m_subdivide_count, 0, 5);
|
|
if (m_subdivide_editing)
|
|
rebuild_subdivide_preview(); // a count of 0 clears the preview, it doesn't compute one
|
|
m_parent.set_as_dirty();
|
|
}
|
|
ImGui::PopItemWidth();
|
|
if (ImGui::IsItemHovered())
|
|
m_imgui->tooltip(_u8L("How many times to split every triangle into four. Each step roughly quadruples the "
|
|
"triangle count, so there are enough vertices for the height texture to displace. "
|
|
"0 means no subdivision."),
|
|
m_imgui->scaled(20.f));
|
|
}
|
|
|
|
// Apply is a no-op when there is nothing to commit: 0 uniform passes, or an adaptive target that
|
|
// is not set (the preview covers the painted-area check itself).
|
|
const bool subdivide_ready = m_subdivide_adaptive ? (m_subdivide_target_mm > 0.f) : (m_subdivide_count >= 1);
|
|
if (!m_subdivide_editing) {
|
|
if (m_imgui->button(_u8L("Preview subdivision"))) {
|
|
m_subdivide_editing = true;
|
|
rebuild_subdivide_preview();
|
|
m_parent.set_as_dirty();
|
|
}
|
|
if (ImGui::IsItemHovered())
|
|
m_imgui->tooltip(_u8L("Shows the subdivided mesh as a wireframe without changing the model. Press Apply to "
|
|
"commit it, or Done to leave the model as it is."),
|
|
m_imgui->scaled(20.f));
|
|
} else {
|
|
m_imgui->disabled_begin(!subdivide_ready);
|
|
if (m_imgui->button(_u8L("Apply"))) {
|
|
if (m_subdivide_adaptive) {
|
|
subdivide_model_adaptive(); // refines only the painted area, carrying the paint forward
|
|
} else {
|
|
subdivide_model(); // commits m_subdivide_count passes (takes its own snapshot)
|
|
// Back to 0 rather than staying at the count just applied: the mesh is now up to 4^N
|
|
// times denser, so re-previewing the same N passes on top of it is both pointless (the
|
|
// density asked for is already committed) and by far the slowest thing this panel does.
|
|
m_subdivide_count = 0;
|
|
}
|
|
rebuild_subdivide_preview();
|
|
m_parent.set_as_dirty();
|
|
}
|
|
m_imgui->disabled_end();
|
|
if (ImGui::IsItemHovered())
|
|
m_imgui->tooltip(m_subdivide_adaptive ?
|
|
_u8L("Refines the painted area to the target edge length and carries your paint onto "
|
|
"the finer mesh. The rest of the model is left as it is.") :
|
|
_u8L("Replaces the model's geometry with the subdivided mesh and clears any not-yet-baked "
|
|
"paint on it (already-baked bumps are unaffected)."),
|
|
m_imgui->scaled(20.f));
|
|
ImGui::SameLine();
|
|
if (m_imgui->button(_u8L("Done"))) {
|
|
m_subdivide_editing = false;
|
|
m_subdivide_preview_tris = -1;
|
|
m_subdivide_preview_glmodel.reset();
|
|
m_parent.set_as_dirty();
|
|
}
|
|
}
|
|
|
|
// Remesh: even out uneven triangle sizes (CGAL isotropic remeshing). GPU Delaunay isn't practical
|
|
// here, but this delivers the same goal - a consistent triangle size across the whole model.
|
|
m_imgui->text(_u8L("Uneven triangle sizes?"));
|
|
if (m_remesh_target_edge_mm <= 0.f && mv != nullptr) {
|
|
// Seed the target with the model's current mean edge length, so the default is a sensible
|
|
// "make everything about the size it already averages".
|
|
const indexed_triangle_set &its = mv->mesh().its;
|
|
double sum = 0.0; size_t cnt = 0;
|
|
for (const stl_triangle_vertex_indices &tri : its.indices)
|
|
for (int i = 0; i < 3; ++i) {
|
|
sum += (its.vertices[tri[i]] - its.vertices[tri[(i + 1) % 3]]).norm();
|
|
++cnt;
|
|
}
|
|
m_remesh_target_edge_mm = cnt > 0 ? std::clamp(float(sum / double(cnt)), 0.1f, 20.f) : 1.f;
|
|
}
|
|
ImGui::PushItemWidth(m_imgui->scaled(8.4f));
|
|
m_imgui->slider_float(_u8L("Target edge (mm)"), &m_remesh_target_edge_mm, 0.1f, 20.f, "%.2f", ImGuiLogSlider);
|
|
ImGui::PopItemWidth();
|
|
|
|
ImGui::Checkbox(_u8L("Keep sharp edges").c_str(), &m_remesh_keep_sharp_edges);
|
|
if (ImGui::IsItemHovered())
|
|
m_imgui->tooltip(_u8L("Holds hard edges and open borders in place while the rest is remeshed. Without it "
|
|
"the remesher slides vertices along the surface and rounds every crisp edge off - "
|
|
"a cube comes back with wobbly edges."),
|
|
m_imgui->scaled(20.f));
|
|
if (m_remesh_keep_sharp_edges) {
|
|
ImGui::PushItemWidth(m_imgui->scaled(8.4f));
|
|
m_imgui->slider_float(_u8L("Sharp edge angle"), &m_remesh_sharp_angle_deg, 5.f, 90.f, "%.0f deg");
|
|
ImGui::PopItemWidth();
|
|
if (ImGui::IsItemHovered())
|
|
m_imgui->tooltip(_u8L("Edges bent by more than this count as hard features and are kept. Lower keeps "
|
|
"more detail but leaves more of the mesh untouched; higher remeshes more freely."),
|
|
m_imgui->scaled(20.f));
|
|
}
|
|
|
|
m_imgui->disabled_begin(mv == nullptr);
|
|
if (m_imgui->button(_u8L("Remesh")))
|
|
remesh_model();
|
|
m_imgui->disabled_end();
|
|
if (ImGui::IsItemHovered())
|
|
m_imgui->tooltip(_u8L("Rebuilds the whole model with triangles close to this edge length - splitting the big "
|
|
"ones and merging the small ones - so displacement has an even density to work with. "
|
|
"Replaces the geometry and clears any not-yet-baked paint (already-baked bumps are kept)."),
|
|
m_imgui->scaled(20.f));
|
|
|
|
ImGui::Separator();
|
|
|
|
m_imgui->disabled_begin(mv == nullptr || !mv->is_texture_displacement_painted());
|
|
if (m_imgui->button(m_desc.at("remove_all"))) {
|
|
Plater::TakeSnapshot snapshot(wxGetApp().plater(), _u8L("Reset texture displacement selection"), UndoRedo::SnapshotType::GizmoAction);
|
|
int idx = -1;
|
|
for (ModelVolume *v : mo->volumes)
|
|
if (v->is_model_part()) {
|
|
++idx;
|
|
m_triangle_selectors[idx]->reset();
|
|
m_triangle_selectors[idx]->request_update_render_data();
|
|
}
|
|
update_model_object();
|
|
m_parent.set_as_dirty();
|
|
}
|
|
m_imgui->disabled_end();
|
|
|
|
ImGui::SameLine();
|
|
m_imgui->disabled_begin(m_bake_in_progress || mv == nullptr || !mv->is_texture_displacement_painted());
|
|
if (m_imgui->button(m_bake_in_progress ? _L("Baking...") : m_desc.at("bake")))
|
|
bake();
|
|
m_imgui->disabled_end();
|
|
|
|
ImGui::Separator();
|
|
if (m_imgui->button(_L("Close")))
|
|
m_parent.reset_all_gizmos();
|
|
|
|
GizmoImguiEnd();
|
|
ImGuiWrapper::pop_toolbar_style();
|
|
|
|
// Drawn last, over everything, via the foreground draw list: the +/- add-remove sign next to the
|
|
// 3D cursor. Still inside the gizmo's ImGui frame here, which is what render_paint_cursor_hint() needs.
|
|
render_paint_cursor_hint();
|
|
}
|
|
|
|
} // namespace Slic3r::GUI
|