Texture gizmo: one-run pipeline by default, auto resolution, colour fixes, debug view

This commit is contained in:
ExPikaPaka
2026-09-15 08:58:26 +02:00
parent d766854af7
commit 2f4c9ab66d
14 changed files with 3787 additions and 1537 deletions
+2
View File
@@ -331,6 +331,8 @@ set(SLIC3R_GUI_SOURCES
GUI/Jobs/TextureDisplacementBakeJob.hpp
GUI/Jobs/TextureDisplacementPrepareJob.cpp
GUI/Jobs/TextureDisplacementPrepareJob.hpp
GUI/Jobs/TextureDisplacementDebugJob.cpp
GUI/Jobs/TextureDisplacementDebugJob.hpp
GUI/Jobs/TextureDisplacementPreviewJob.cpp
GUI/Jobs/TextureDisplacementPreviewJob.hpp
GUI/Jobs/ThreadSafeQueue.hpp
File diff suppressed because it is too large Load Diff
@@ -2,6 +2,7 @@
#define slic3r_GLGizmoTextureDisplacement_hpp_
#include "GLGizmoPainterBase.hpp"
#include "libslic3r/TextureBake/TextureBakeDebug.hpp"
#include "libslic3r/TextureDisplacement.hpp"
#include "slic3r/GUI/GLModel.hpp"
#include "slic3r/GUI/GLTexture.hpp"
@@ -46,7 +47,11 @@ public:
const std::vector<TextureDisplacementLayer> &layers,
const TextureDisplacementPrepareParams &params,
const std::vector<PrintableColor> &palette,
const DisplacementProgressFn &progress);
const DisplacementProgressFn &progress,
// Optional step capture: receives the mesh
// after the remesh and after the refinement,
// so a debug run can show the whole recipe.
BakeStageRecorder *debug = nullptr);
// The volume's eight texture-displacement masks, gathered into the array every pure function here
// (and every job input) takes.
static TextureDisplacementFacetsData facets_data_of(const ModelVolume &mv);
@@ -135,8 +140,10 @@ protected:
std::string get_gizmo_leaving_text() const override { return _u8L("Leaving Texture displacement painting"); }
std::string get_action_snapshot_name() const override { return _u8L("Texture displacement editing"); }
EnforcerBlockerType get_left_button_state_type() const override { return EnforcerBlockerType::ENFORCER; }
EnforcerBlockerType get_right_button_state_type() const override { return EnforcerBlockerType::NONE; }
// The panel's Paint / Erase toggle swaps what the two buttons do, so the right button always does the
// opposite of the left. Shift still erases in both modes (GLGizmoPainterBase::gizmo_event()).
EnforcerBlockerType get_left_button_state_type() const override { return m_erase_mode ? EnforcerBlockerType::NONE : EnforcerBlockerType::ENFORCER; }
EnforcerBlockerType get_right_button_state_type() const override { return m_erase_mode ? EnforcerBlockerType::ENFORCER : EnforcerBlockerType::NONE; }
private:
bool on_init() override;
@@ -154,6 +161,13 @@ private:
void add_texture_layer();
void remove_texture_layer(int slot);
// Moves the layer in `slot` to position `to_index` of the slot-ordered stack (an insertion index,
// 0..layer count), which is the order blend modes apply in. Layers keep their data; what moves is
// the slot each one occupies, so their paint masks move with them on every model part.
void move_texture_layer(int slot, int to_index);
// Exchanges everything two slots hold - layer definition, paint masks, panel caches. No snapshot and
// no selector reload; move_texture_layer() does both once around a run of these.
void swap_layer_slots(int slot_a, int slot_b);
void set_active_layer(int slot); // flushes the previous layer's edits, then reloads selectors
// `own_snapshot` false when the caller has already taken an undo step that is meant to cover the
// displacement too - see bake_standard().
@@ -299,18 +313,21 @@ private:
};
const LibraryTexture *get_library_texture(const std::string &path);
// The layer's texture chooser: a drop-down whose closed state and every one of whose entries
// shows a large preview image on the left and the texture's name on the right, plus an adjacent
// button that imports an image file from disk into the user texture folder. Shipped and
// user-imported textures are listed under separate headings.
void render_texture_picker(TextureDisplacementLayer &layer);
// The texture library: a popup grid of thumbnails beside the panel, shipped textures and the user's
// own under separate headings, with a tile that imports an image from disk. Opened for
// m_picker_slot by a click on a layer's thumbnail or name. `panel_min`/`panel_max` are the panel
// window's screen rectangle, which the popup is placed against.
void render_texture_library_popup(const ImVec2 &panel_min, const ImVec2 &panel_max);
int m_picker_slot = -1;
bool m_picker_open_request = false;
void set_layer_texture(TextureDisplacementLayer &layer, const TextureLibraryEntry &entry);
void import_custom_texture(TextureDisplacementLayer &layer);
// Draws a picker row (image left, name right) on top of a full-width Selectable, and leaves the
// cursor below it. Shared by the drop-down's closed state and its individual entries so the two
// cannot drift apart. Returns true when the row is clicked.
bool texture_row(const char *id, const std::string &name, GLTexture *thumbnail, bool selected, float width);
float texture_row_height() const;
// Paint / Erase, from the panel. See get_left_button_state_type().
bool m_erase_mode = false;
// Per slot: whether the layer's card shows every setting or only Depth, Tile size and Rotation.
// Panel state only, not saved with the project.
std::array<bool, TEXTURE_DISPLACEMENT_MAX_LAYERS> m_layer_expanded{};
// "Adjust Texture" mode: instead of painting, dragging an on-canvas handle changes the active
// layer's offset. The handle is a flat panel lying in the paint patch's own tangent plane
@@ -400,7 +417,21 @@ private:
// Handles a toolbar command forwarded from the UV pane that needs the layer data the canvas
// doesn't hold (average island scale, cut island). Takes the command as an int (a cast of
// UVEditorCanvas::Command) so this header needn't pull in glad/wxGLCanvas via the canvas header.
void on_uv_command(int cmd);
void on_uv_command(int cmd, float value);
// Pane commands, queued by on_uv_command() and run from the panel render: pane clicks arrive in wx event
// handlers, outside the 3D canvas's GL frame, and several of these rebuild GPU meshes.
std::vector<std::pair<int, float>> m_uv_command_queue;
void process_uv_commands();
void run_uv_command(int cmd, float value);
// Sends the UV editor pane what its controls show (see UVEditorCanvas::PaneState).
void push_uv_pane_state();
// The panel's view modes: 0 Normal, 1 Fast, 2 Checker, 3 Distortion. Shared by the View row and the pane's
// background buttons, so both switch views the same way.
void apply_view_mode(int mode);
// The pane header's thumbnail of the active layer's texture, rebuilt only when its image changes.
static constexpr int UV_THUMB_PX = 64;
const void *m_uv_thumb_source = nullptr;
std::vector<unsigned char> m_uv_thumb_rgb;
// Splits one unwrap chart in two by marking the mesh edges that straddle the plane through its
// 3D centroid, perpendicular to its longest axis, as seams (#17). The re-unwrap then separates it.
void cut_island(TextureDisplacementLayer &layer, int chart);
@@ -543,6 +574,41 @@ private:
bool m_remesh_keep_sharp_edges = true;
void remesh_model();
// ---- Bake stage debug view ----
//
// A bake is a chain of stages that each rewrite the whole mesh, so when the result looks wrong the
// only useful question is which stage made it wrong. "Capture stages" runs the same recipe Bake
// runs, keeps every intermediate mesh, and draws the selected one through the ordinary
// true-displacement preview - so this needs no rendering code of its own.
//
// The run commits nothing: TextureDisplacementDebugJob never touches the Model.
std::vector<BakeStageSnapshot> m_debug_stages;
// Which stage is on screen. -1 means the debug view is off and the live preview owns
// m_preview_glmodel again; rebuild_preview() checks this before replacing it.
int m_debug_stage = -1;
bool m_debug_in_progress = false;
// The open / non-manifold counts come from a sort over every half-edge, which on a multi-million
// triangle stage costs more than the stage did. On by default because a stage that tore the mesh
// is exactly what this exists to find.
bool m_debug_check_topology = true;
// The default pipeline's automatic resolution/budget for the volume, cached on what it depends on.
const V2Resolution &v2_recommendation(const ModelVolume &mv);
V2Resolution m_v2_rec;
std::string m_v2_rec_key;
// Queues the capture run. Uses Standard mode's recipe (remesh, refine, displace) unless Pro mode
// has already prepared the mesh or the experimental pipeline is on, either of which has nothing
// to prepare.
void run_stage_debug();
// Puts stage `index` on screen. A stage over the memory cap has counts but no geometry and is
// skipped.
void show_debug_stage(int index);
// Drops the captured stages and hands m_preview_glmodel back to the live preview.
void exit_debug_view();
// The panel: capture button, stage list, and what each stage produced.
void render_debug_stage_panel(ModelVolume *mv);
// Live, pre-bake preview of the true displaced geometry (built by the same algorithm Bake
// uses). Empty/uninitialized whenever nothing is painted yet, in which case the gizmo falls
// back to the standard paint-mask overlay like every other painting gizmo.
@@ -621,6 +687,10 @@ private:
// the shader projects on its own. Shared by the bump preview and the UV-check overlay.
std::vector<Vec2f> compute_layer_vertex_uvs(const indexed_triangle_set &patch,
const TextureDisplacementLayer &layer) const;
// `patch` with its vertices moved into world millimetres - the space the bake maps the texture in
// (see build_texture_displacement()). Returned by value because the caller usually still needs the
// original: the patch doubles as render geometry, which is drawn through the volume's own matrix.
indexed_triangle_set patch_in_world(const indexed_triangle_set &patch) const;
// UV-check overlay drawn over the painted patch to sanity-check the unwrap (#13/#14). Built by
// rebuild_uvcheck_mesh(), drawn by render_uvcheck_mesh() with the "texture_displacement_uvcheck"
@@ -655,6 +725,7 @@ private:
enum class UVBackground { None, Height, Checker };
UVBackground m_uv_editor_bg = UVBackground::None;
float m_uv_editor_bg_smoothing = -1.f; // smoothing the height backdrop was uploaded at
const void *m_uv_editor_bg_image = nullptr; // the layer image it was uploaded from
// Per-chart distortion heatmap colour for the UV pane (#7/#14), computed once when the unwrap is
// re-solved (relative stretch doesn't change when islands are merely moved), fed to the canvas only
// while the Distortion check mode is on. Empty otherwise.
@@ -751,6 +822,18 @@ private:
// open/close within a session, so the choice sticks while working.
bool m_undocked = false;
// Smooth scrolling for the panel body (everything between the header and the pinned Bake footer).
// ImGui jumps a fixed number of lines per wheel notch, which on tall layer cards reads as a hard
// jolt rather than a scroll. The wheel is intercepted (ImGuiWindowFlags_NoScrollWithMouse) and
// moves a *target* offset instead; the real scroll is eased toward it over the following frames.
float m_panel_scroll_target = 0.f;
float m_panel_scroll_applied = -1.f; // what the easing wrote last frame; <0 until the first one
// Last frame's body content height and footer height. The body is a child window that has to be
// given its height before its content is laid out, so it is sized from the previous frame: as tall
// as its content, capped so the footer still fits above the bottom of the canvas.
float m_panel_body_h = 0.f;
float m_panel_footer_h = 0.f;
// See the "Adjust Texture" block of private methods above.
bool m_adjust_texture_mode = false;
bool m_adjust_anchor_valid = false;
@@ -774,14 +857,7 @@ private:
std::map<std::string, wxString> m_desc;
// The tool's SVG (toolbar_texture_displacement.svg) uploaded once as a GL texture, so it can be
// used as an ImGui image button in the panel (currently the "add layer" affordance next to the
// Texture layers heading). Lazily loaded on first use, when a GL context is guaranteed current.
GLTexture m_tool_icon;
bool m_tool_icon_tried = false;
unsigned int tool_icon_id(); // 0 if the icon could not be loaded
// Icons for the panel's selection-mode and view-mode button rows. Loaded through IconManager with
// Icons for the panel's icon buttons (tools, views, mapping, tiling, layer actions). Loaded through IconManager with
// the same colour/monochrome variants the main toolbar uses, so an inactive button shows the icon in
// the theme's normal (grey) foreground colour and an active one shows it in its original colours -
// matching the toolbar's selected/unselected look. Uploaded once on first panel render.
@@ -61,7 +61,7 @@ void TextureDisplacementBakeJob::process(Ctl &ctl)
}
return true;
},
color));
color, m_input.volume_to_world));
// Always finish at 100: this is what closes the notification. Reported even on cancel, where
// build_texture_displacement() returns an empty mesh and finalize() commits nothing.
@@ -152,6 +152,7 @@ void queue_texture_displacement_bake(const ModelVolume &volume, const TextureCol
input.base_mesh = volume.mesh().its;
input.layers = volume.texture_displacement_layers;
input.options = volume.texture_displacement_options;
input.volume_to_world = texture_displacement_volume_to_world(volume);
for (int i = 0; i < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++i)
input.facets_data[size_t(i)] = volume.texture_displacement_facet(i).get_data();
@@ -23,6 +23,9 @@ struct TextureDisplacementBakeInput
std::vector<TextureDisplacementLayer> layers;
TextureDisplacementFacetsData facets_data;
TextureDisplacementOptions options;
// Mesh coordinates -> world millimetres. Captured here with everything else so the worker never
// reaches back into the live Model for it. See build_texture_displacement().
Transform3d volume_to_world = Transform3d::Identity();
// Captured on the main thread. Empty unless some layer is colouring, in which case the bake also
// writes the volume's mmu_segmentation_facets - the same per-triangle filament assignment the MMU
// paint gizmo writes - alongside the displaced geometry.
@@ -0,0 +1,113 @@
#include "TextureDisplacementDebugJob.hpp"
#include "libslic3r/Model.hpp"
#include "slic3r/GUI/GUI_App.hpp"
#include "slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp"
#include "slic3r/GUI/I18N.hpp"
#include "slic3r/GUI/Plater.hpp"
namespace Slic3r::GUI {
TextureDisplacementDebugJob::TextureDisplacementDebugJob(
TextureDisplacementDebugInput &&input, std::function<void(std::vector<BakeStageSnapshot>)> on_finished)
: m_input(std::move(input)), m_on_finished(std::move(on_finished))
{
}
void TextureDisplacementDebugJob::process(Ctl &ctl)
{
const std::string status = _u8L("Capturing bake stages");
ctl.update_status(1, status);
// Only ever touches m_input (captured by value before this job was queued) and local state - and,
// unlike the bake job, never writes anything back either.
int last_reported = 1;
const auto report = [&ctl, &status, &last_reported](int percent) {
if (ctl.was_canceled())
return false;
if (percent > last_reported) {
last_reported = percent;
ctl.update_status(percent, status);
}
return true;
};
BakeStageRecorder recorder;
recorder.enable(true);
recorder.set_check_topology(m_input.check_topology);
recorder.set_mesh_cap(m_input.mesh_cap);
indexed_triangle_set mesh = m_input.base_mesh;
TextureDisplacementFacetsData masks = m_input.facets_data;
// The default pipeline can only move vertices the mesh already has, so its recipe starts with the
// preparation - which is where "first it remeshes, then it refines" actually happens. The
// experimental pipeline refines as part of the bake and has nothing to prepare.
if (m_input.run_prepare && !m_input.options.pipeline_v2) {
const TextureDisplacementPrepareResult prepared =
GLGizmoTextureDisplacement::prepare_mesh(mesh, masks, m_input.layers, m_input.prepare_params,
m_input.color.palette,
// Preparation is roughly half the run; the bake
// takes the progress bar from there.
[&report](int pct) { return report(1 + pct / 2); },
&recorder);
if (ctl.was_canceled())
return;
// An empty result means nothing needed doing, which is not a failure - the bake below simply
// runs on the mesh as it stands. paint_lost means the remesh dropped every layer's paint, so
// there is nothing left to displace and the stages recorded so far are the whole story.
if (prepared.paint_lost) {
m_stages = recorder.take();
ctl.update_status(100, status);
return;
}
if (!prepared.mesh.indices.empty()) {
mesh = prepared.mesh;
masks = prepared.masks;
}
}
build_texture_displacement(mesh, m_input.layers, masks, m_input.options,
[&report](int pct) { return report(50 + pct / 2); },
/* color */ nullptr, m_input.volume_to_world, &recorder);
m_stages = recorder.take();
ctl.update_status(100, status); // always finish at 100: this is what closes the notification
}
void TextureDisplacementDebugJob::finalize(bool canceled, std::exception_ptr &eptr)
{
if (!m_on_finished)
return;
// The handler must run on every outcome - the caller uses it to clear its in-progress latch, and
// an empty vector is its signal that nothing usable came back.
if (canceled || eptr)
m_on_finished({});
else
m_on_finished(std::move(m_stages));
}
void queue_texture_displacement_debug(const ModelVolume &volume, const TextureColorSettings &color,
const TextureDisplacementPrepareParams &prepare_params,
bool run_prepare, bool check_topology,
std::function<void(std::vector<BakeStageSnapshot>)> on_finished)
{
TextureDisplacementDebugInput input;
input.volume_id = volume.id();
input.base_mesh = volume.mesh().its;
input.layers = volume.texture_displacement_layers;
input.options = volume.texture_displacement_options;
input.prepare_params = prepare_params;
input.run_prepare = run_prepare;
input.check_topology = check_topology;
input.color = color;
input.volume_to_world = texture_displacement_volume_to_world(volume);
for (int i = 0; i < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++i)
input.facets_data[size_t(i)] = volume.texture_displacement_facet(i).get_data();
auto &worker = wxGetApp().plater()->get_ui_job_worker();
queue_job(worker, std::make_unique<TextureDisplacementDebugJob>(std::move(input), std::move(on_finished)));
}
} // namespace Slic3r::GUI
@@ -0,0 +1,82 @@
#ifndef slic3r_TextureDisplacementDebugJob_hpp_
#define slic3r_TextureDisplacementDebugJob_hpp_
#include <functional>
#include <vector>
#include "libslic3r/ObjectID.hpp"
#include "libslic3r/TextureBake/TextureBakeDebug.hpp"
#include "libslic3r/TextureDisplacement.hpp"
#include "libslic3r/TriangleMesh.hpp"
#include "Job.hpp"
namespace Slic3r {
class ModelVolume;
}
namespace Slic3r::GUI {
// Runs a bake with stage capture on and commits nothing.
//
// A bake is a chain of stages that each rewrite the whole mesh - remesh, refine, displace, smooth, or
// for the experimental pipeline subdivide, regularize, relocate, displace, decimate, repair - and when
// the result looks wrong the only useful question is which of them made it wrong. This runs the same
// recipe the Bake button runs, keeps every intermediate mesh, and hands them back for the gizmo to
// step through.
//
// Deliberately separate from TextureDisplacementBakeJob rather than a flag on it: this must never
// touch the Model, and keeping the committing path free of a "but not this time" branch is what
// guarantees that.
struct TextureDisplacementDebugInput
{
ObjectID volume_id;
indexed_triangle_set base_mesh;
std::vector<TextureDisplacementLayer> layers;
TextureDisplacementFacetsData facets_data;
TextureDisplacementOptions options;
// The preparation stages - remesh and adaptive refinement. Only the default pipeline uses them;
// the experimental one refines as part of the bake and ignores this entirely.
TextureDisplacementPrepareParams prepare_params;
bool run_prepare = true;
// Mesh coordinates -> world millimetres, as the bake gets it. The captured stages are brought
// back into mesh coordinates before they are handed over, so the gizmo can draw them directly.
Transform3d volume_to_world = Transform3d::Identity();
// Only the quantizer is used, and only by the refinement's colour criterion - the debug run
// produces no colour of its own.
TextureColorSettings color;
// The edge scan behind the open / non-manifold counts is a sort over every half-edge, which on a
// multi-million triangle stage costs more than the stage did. On by default because a stage that
// tore the mesh is exactly what this exists to find.
bool check_topology = true;
// Stages above this keep their counts but not their geometry - see BakeStageRecorder.
size_t mesh_cap = 4'000'000;
};
class TextureDisplacementDebugJob : public Job
{
public:
TextureDisplacementDebugJob(TextureDisplacementDebugInput &&input,
std::function<void(std::vector<BakeStageSnapshot>)> on_finished);
void process(Ctl &ctl) override;
void finalize(bool canceled, std::exception_ptr &eptr) override;
private:
TextureDisplacementDebugInput m_input;
std::vector<BakeStageSnapshot> m_stages;
std::function<void(std::vector<BakeStageSnapshot>)> m_on_finished;
};
// Captures `volume`'s current mesh, layers and paint and queues one debug run. `on_finished` always
// runs exactly once on the UI thread, with an empty vector if the run was cancelled or threw, so the
// caller can clear its own in-progress state. Must be called from the main thread.
void queue_texture_displacement_debug(const ModelVolume &volume, const TextureColorSettings &color,
const TextureDisplacementPrepareParams &prepare_params,
bool run_prepare, bool check_topology,
std::function<void(std::vector<BakeStageSnapshot>)> on_finished);
} // namespace Slic3r::GUI
#endif // slic3r_TextureDisplacementDebugJob_hpp_
@@ -43,7 +43,7 @@ void TextureDisplacementPreviewJob::process(Ctl &ctl)
(!m_current_generation ||
m_current_generation->load() == m_generation);
},
color);
color, m_input.volume_to_world);
}
void TextureDisplacementPreviewJob::finalize(bool canceled, std::exception_ptr &eptr)
@@ -23,6 +23,9 @@ struct TextureDisplacementPreviewInput
std::vector<TextureDisplacementLayer> layers;
TextureDisplacementFacetsData facets_data;
TextureDisplacementOptions options;
// Mesh coordinates -> world millimetres, so the preview is displaced in the same space the bake
// is and the two cannot disagree. See build_texture_displacement().
Transform3d volume_to_world = Transform3d::Identity();
// Empty unless a layer is colouring, in which case the preview reports the filament per triangle
// alongside the mesh, so the Normal view shows what the bake will produce - interleaving included.
TextureColorSettings color;
+6
View File
@@ -7484,6 +7484,12 @@ Plater::priv::priv(Plater *q, MainFrame *main_frame)
.Right()
.Hide()
.BestSize(wxSize(40 * wxGetApp().em_unit(), 40 * wxGetApp().em_unit())));
// Closing the pane with its own X has to reach the gizmo, or its next update would simply show the pane again.
q->Bind(wxEVT_AUI_PANE_CLOSE, [this](wxAuiManagerEvent &evt) {
evt.Skip();
if (evt.GetPane() != nullptr && evt.GetPane()->window == uv_editor_panel && uv_editor_canvas != nullptr)
uv_editor_canvas->run_command(UVEditorCanvas::Command::PaneClosed);
});
auto* panel_sizer = new wxBoxSizer(wxHORIZONTAL);
panel_sizer->Add(view3D, 1, wxEXPAND | wxALL, 0);
+105
View File
@@ -2,6 +2,8 @@
#include <algorithm>
#include <fstream>
#include <limits>
#include <unordered_map>
#include <boost/algorithm/string/case_conv.hpp>
#include <boost/filesystem.hpp>
@@ -15,7 +17,10 @@
#include "libslic3r/PNGReadWrite.hpp"
#include "libslic3r/Utils.hpp"
#include "libslic3r/AppConfig.hpp"
#include "slic3r/GUI/GUI.hpp"
#include "slic3r/GUI/GUI_App.hpp"
#include "slic3r/GUI/I18N.hpp"
namespace Slic3r::GUI {
@@ -168,6 +173,70 @@ bool is_supported_height_map(const std::vector<unsigned char> &bytes)
std::vector<TextureLibraryEntry> g_library;
bool g_library_scanned = false;
// The user's own textures, most recently used first, kept in the app config. File names are stored rather
// than paths so the order survives a moved data directory; '/' separates them because no platform allows
// it inside a file name. Bounded so a long-lived config does not grow a line without end.
const char *const RECENT_SECTION = "texture_displacement";
const char *const RECENT_KEY = "recent_textures";
const size_t RECENT_MAX = 200;
std::vector<std::string> recent_texture_names()
{
std::vector<std::string> names;
if (wxGetApp().app_config == nullptr)
return names;
const std::string value = wxGetApp().app_config->get(RECENT_SECTION, RECENT_KEY);
for (size_t begin = 0; begin <= value.size();) {
const size_t end = std::min(value.find('/', begin), value.size());
if (end > begin)
names.push_back(value.substr(begin, end - begin));
begin = end + 1;
}
return names;
}
void store_recent_texture_names(std::vector<std::string> names)
{
if (wxGetApp().app_config == nullptr)
return;
if (names.size() > RECENT_MAX)
names.resize(RECENT_MAX);
std::string value;
for (const std::string &name : names)
value += (value.empty() ? "" : "/") + name;
wxGetApp().app_config->set(RECENT_SECTION, RECENT_KEY, value);
}
// Reorders the user's entries (the tail of `library`) by recent use. Stable, so textures never used keep
// the name order scan_dir() gave them, after every used one.
void sort_user_textures(std::vector<TextureLibraryEntry> &library)
{
const std::vector<std::string> names = recent_texture_names();
std::unordered_map<std::string, int> rank;
for (size_t i = 0; i < names.size(); ++i)
rank.emplace(names[i], int(i));
const auto rank_of = [&rank](const TextureLibraryEntry &e) {
const auto it = rank.find(boost::filesystem::path(e.path).filename().string());
return it == rank.end() ? std::numeric_limits<int>::max() : it->second;
};
const auto first_user = std::find_if(library.begin(), library.end(), [](const TextureLibraryEntry &e) { return e.is_user; });
std::stable_sort(first_user, library.end(),
[&rank_of](const TextureLibraryEntry &a, const TextureLibraryEntry &b) { return rank_of(a) < rank_of(b); });
}
// True if `path` is a file directly inside user_texture_dir() - the only place textures may be removed
// from, or have their use recorded.
bool in_user_texture_dir(const std::string &path)
{
const std::string dir = user_texture_dir();
if (dir.empty())
return false;
boost::system::error_code ec;
const bool same = boost::filesystem::equivalent(boost::filesystem::path(path).parent_path(),
boost::filesystem::path(dir), ec);
return !ec && same;
}
} // namespace
std::string user_texture_dir()
@@ -192,11 +261,47 @@ const std::vector<TextureLibraryEntry> &texture_library(bool force_rescan)
const std::string user_dir = user_texture_dir();
if (!user_dir.empty())
scan_dir(boost::filesystem::path(user_dir), true, g_library);
sort_user_textures(g_library);
g_library_scanned = true;
return g_library;
}
void touch_user_texture(const std::string &path)
{
if (!in_user_texture_dir(path))
return;
const std::string name = boost::filesystem::path(path).filename().string();
std::vector<std::string> names = recent_texture_names();
names.erase(std::remove(names.begin(), names.end(), name), names.end());
names.insert(names.begin(), name);
store_recent_texture_names(std::move(names));
if (g_library_scanned)
sort_user_textures(g_library);
}
bool remove_user_texture(const std::string &path, std::string &error)
{
if (!in_user_texture_dir(path)) {
error = _u8L("Only your own imported textures can be removed.");
return false;
}
boost::system::error_code ec;
boost::filesystem::remove(boost::filesystem::path(path), ec);
if (ec) {
BOOST_LOG_TRIVIAL(error) << "Could not delete texture " << path << ": " << ec.message();
error = _u8L("Could not delete the texture file.");
return false;
}
const std::string name = boost::filesystem::path(path).filename().string();
std::vector<std::string> names = recent_texture_names();
names.erase(std::remove(names.begin(), names.end(), name), names.end());
store_recent_texture_names(std::move(names));
texture_library(true); // drop it from the list
return true;
}
std::optional<TextureLibraryEntry> import_texture_to_library(const std::string &source_path, std::string &error)
{
const std::string user_dir = user_texture_dir();
+14 -2
View File
@@ -16,8 +16,9 @@ struct TextureLibraryEntry
bool is_user; // imported by the user, as opposed to shipped with OrcaSlicer
};
// Every available height-map texture: the ones shipped in resources/textures/displacement first,
// then the user's own from <data_dir>/textures/displacement, each group sorted by name.
// Every available height-map texture: the ones shipped in resources/textures/displacement first, sorted
// by name, then the user's own from <data_dir>/textures/displacement - most recently used first (see
// touch_user_texture()), then any never used, by name.
//
// The two live in separate directories deliberately: an app update replaces the resources tree
// wholesale, so anything the user imported has to sit somewhere that update can never overwrite or
@@ -27,6 +28,17 @@ struct TextureLibraryEntry
// into either folder by hand while the app was running.
const std::vector<TextureLibraryEntry> &texture_library(bool force_rescan = false);
// Moves one of the user's own textures to the front of the recently-used order texture_library() lists
// them in, and stores that order in the app config so it survives a restart. No-op for anything that is
// not in user_texture_dir().
void touch_user_texture(const std::string &path);
// Deletes one of the user's own imported textures from user_texture_dir() and forgets it. Anything outside
// that folder is refused, so a shipped texture can never be removed this way. Layers already using the
// texture are unaffected: they hold the image bytes themselves, and a project stores those. Returns false
// with `error` set on failure.
bool remove_user_texture(const std::string &path, std::string &error);
// <data_dir>/textures/displacement, created if it does not exist yet. Empty string on failure.
std::string user_texture_dir();
File diff suppressed because it is too large Load Diff
+118 -17
View File
@@ -15,12 +15,17 @@
#include <wx/button.h>
#include <wx/tglbtn.h>
#include <wx/stattext.h>
#include <wx/statbmp.h>
#include <wx/sizer.h>
#include "libslic3r/Point.hpp"
#include "GLModel.hpp"
#include "GLTexture.hpp"
// Orca's own widgets (slic3r/GUI/Widgets), declared at global scope.
class SpinInput;
class CheckBox;
namespace Slic3r::GUI {
// Standalone 2D viewer/editor for a flattened (UV-unwrapped) mesh patch - shows the result of
@@ -92,14 +97,50 @@ public:
void set_snap_enabled(bool enabled) { m_snap_enabled = enabled; }
bool snap_enabled() const { return m_snap_enabled; }
// High-level actions the pane toolbar triggers. The canvas handles the view-only ones (framing,
// High-level actions the pane's controls trigger. The canvas handles the view-only ones (framing,
// the snap toggle) itself and forwards the rest to whoever owns the island data (the gizmo), via
// the command callback - the canvas has the selection and the view, the gizmo has the layer.
enum class Command { FrameAll, ToggleSnap, AverageScale, CutSelectedIsland, ProjectFromView, JoinSelected, UnjoinSelected };
void run_command(Command cmd);
using CommandFn = std::function<void(Command)>;
// `value` carries the new setting for the Set* commands (an angle, a flag as 0/1, or an index into
// SelectMode / Background) and is 0 otherwise.
enum class Command {
FrameAll, ToggleSnap, AverageScale, CutSelectedIsland, ProjectFromView, JoinSelected, UnjoinSelected,
Unwrap, SetSeamAngle, SetConnectIslands, SetSelectMode, SetMarkSeams, SetSeamPath, ClearSeams, ClearUVEdits,
SetBackground, PickTexture, PaneClosed
};
void run_command(Command cmd, float value = 0.f);
using CommandFn = std::function<void(Command, float)>;
void set_command_callback(CommandFn fn) { m_on_command = std::move(fn); }
// What the canvas shows under the islands; mirrors the gizmo's Normal/Checker/Distortion views.
enum class Background { Height, Checker, Distortion };
// Everything the pane's own controls show that the gizmo owns: the active layer and its unwrap settings.
// The gizmo pushes it whenever any of it may have changed, and the pane redraws its header, settings row
// and tool strip from it - so the controls never hold state of their own that could drift from the layer.
struct PaneState
{
bool has_layer = false; // an active layer mapped with Unwrap
wxString layer_name;
float tile_mm = 0.f;
std::vector<unsigned char> thumbnail_rgb; // thumbnail_px square, 3 bytes per pixel; empty for none
int thumbnail_px = 0;
float seam_angle_deg = 40.f;
bool connect_islands = true;
bool unwrapped = false; // an unwrap exists for this layer
bool unwrap_stale = false; // paint, seams or the seam angle changed since it was made
bool mark_seams = false;
bool seam_path = false;
bool has_seams = false;
bool has_uv_edits = false;
Background background = Background::Height;
int island_count = 0;
size_t face_count = 0;
};
void set_pane_state(PaneState state);
const PaneState &pane_state() const { return m_pane_state; }
using PaneStateFn = std::function<void(const PaneState &)>;
void set_pane_state_callback(PaneStateFn fn) { m_on_pane_state = std::move(fn); }
// Called whenever the one-line status/hint text changes (current gesture + the shortcuts that
// apply right now), so the pane can show it Blender-style along the bottom.
using StatusFn = std::function<void(const wxString &)>;
@@ -133,11 +174,21 @@ public:
// The primary (last-clicked) island, still the pivot for rotate/scale and the target of the
// single-island toolbar commands (Cut/Join/Unjoin). -1 if nothing is selected.
int selected_island() const { return m_selected_island; }
// Whether there is an unwrap on screen at all.
bool has_islands() const { return m_islands.island_count > 0 && !m_islands.indices.empty(); }
// The full multi-selection (Shift adds, Ctrl toggles). Always contains m_selected_island when it is
// >= 0. The gizmo reads this to decide which islands a drag moves together, unioned with each
// selected island's join group.
const std::vector<int> &selected_islands() const { return m_selection; }
void reset_view();
// Rebuilds every GPU object at the next paint, from the data the canvas keeps on the CPU. Called when the
// pane is shown again, so a hidden-and-reshown canvas never depends on GL objects surviving its native
// window being torn down.
void invalidate_gl()
{
m_gl_reset_pending = true;
Refresh();
}
private:
void on_paint(wxPaintEvent &evt);
@@ -155,7 +206,11 @@ private:
// The UV region worth looking at: every island, plus always at least the texture's first tile, so
// there is something sensibly framed even before anything is painted.
void content_bounds(Vec2f &min_uv, Vec2f &max_uv) const;
// Frames content_bounds(). Bound to Home, and run once each time an unwrap first appears.
// What is actually *drawn*, which is content_bounds() snapped out to whole tiles whenever the
// backdrop tiles (see rebuild_background_quad()). Both the framing and the backdrop go through
// this so they cannot disagree.
void framed_bounds(Vec2f &min_uv, Vec2f &max_uv) const;
// Frames framed_bounds(). Bound to Home, and run once each time an unwrap first appears.
void fit_view_to_content();
// Half-extents of the visible UV region. Split out because both rendering and every mouse
@@ -185,6 +240,8 @@ private:
void end_gesture();
// Rebuilds the status line from the current gesture/selection and pushes it to m_on_status.
void update_status();
// Re-picks what a click at `pos` would grab in the current select mode, and repaints when that changed.
void update_hover(const wxPoint &pos);
wxGLContext *m_context = nullptr; // owned by OpenGLManager/GUI_App, not by this canvas
@@ -195,13 +252,22 @@ private:
// Boundary vertices per island, for snapping - a patch's boundary is a tiny fraction of it, and
// rescanning the whole uv array on every snap test would not be.
std::vector<std::vector<int>> m_island_boundary_verts;
// Per island: its outline edges, its triangles (indices into m_islands.indices) and the bounding box of its
// raw coordinates - so picking tests one island's triangles only when the cursor is inside its box.
std::vector<std::vector<std::pair<int, int>>> m_island_boundary_edges;
std::vector<std::vector<int>> m_island_tris;
std::vector<std::pair<Vec2f, Vec2f>> m_island_raw_bounds;
bool m_mesh_dirty = true;
bool m_gl_reset_pending = false;
// One set of models per island, so an island can be drawn through its own transform. Built once
// per unwrap, never on a drag.
// per unwrap, never on a drag. Outlines are not among them: they are drawn as screen-width quads,
// rebuilt every paint (see render()), because GL wide lines are not reliably wider than 1 px.
std::vector<GLModel> m_island_wireframe; // interior edges
std::vector<GLModel> m_island_boundary; // outline
std::vector<GLModel> m_island_fill; // filled, for the selected island's wash
std::vector<GLModel> m_island_fill; // filled; also the stencil mask that keeps islands undimmed
GLModel m_stroke_glmodel; // scratch model for the quads of one stroke pass
GLModel m_dim_quad_glmodel; // full-viewport quad that dims the texture outside the islands
int m_stencil_bits = -1; // of the default framebuffer; -1 = not queried yet
GLModel m_tile_outline_glmodel; // the texture's first tile, [0,1]^2 - the "you are here"
GLModel m_grid_glmodel;
@@ -274,8 +340,10 @@ private:
bool m_vertex_edit_moved = false;
// Lazily-built small filled square, drawn at an edited/hovered vertex as a handle.
GLModel m_vertex_marker_glmodel;
// Rebuilt each frame at the cursor while a +/- add-remove hint is shown (Vertex/Edge mode).
GLModel m_cursor_sign_glmodel;
// What a click would grab right now, highlighted so the user sees the target before clicking.
int m_hover_island = -1;
int m_hover_vertex = -1;
std::pair<int, int> m_hover_edge{ -1, -1 };
int m_selected_island = -1;
// The full multi-selection; m_selected_island is its primary (last-clicked) member. Kept as a small
// vector rather than a set because it is tiny and iteration order (primary last) is convenient.
@@ -315,12 +383,20 @@ private:
UVVertexEditFn m_on_vertex_edit;
CommandFn m_on_command;
StatusFn m_on_status;
PaneState m_pane_state;
PaneStateFn m_on_pane_state;
};
// Hosts a UVEditorCanvas together with a small icon toolbar (frame, snap, average scale, cut,
// project-from-view) and a Blender-style status line along the bottom that names the current gesture
// and the shortcuts in play (#18). This is what actually goes into Plater's "uv_editor" AUI pane;
// the gizmo still talks to the inner canvas, reached via canvas().
class UVToolButton; // a drawn icon button, defined in UVEditorCanvas.cpp
// The UV editor pane that goes into Plater's "uv_editor" AUI pane: a header with the active layer, the
// canvas background and Unwrap; a settings row with the unwrap's seam angle and island connection; a
// narrow tool strip down the left (selection mode, seams, island actions, snap and framing); the canvas;
// and a status line naming the current gesture, with the unwrap summary on its right.
//
// It holds no editing state of its own. Every control sends a Command through the canvas to the gizmo,
// and the gizmo pushes PaneState back, from which the controls are redrawn (see apply_state()). The gizmo
// still talks to the inner canvas, reached via canvas().
class UVEditorPanel : public wxPanel
{
public:
@@ -329,10 +405,35 @@ public:
private:
void on_tool(wxCommandEvent &evt);
void apply_state(const UVEditorCanvas::PaneState &state);
// Selection-mode toggles, and the tools that need a selected island, follow the canvas.
void refresh_selection_tools();
UVEditorCanvas *m_canvas = nullptr;
wxToggleButton *m_snap_button = nullptr;
wxStaticText *m_status = nullptr;
UVEditorCanvas *m_canvas = nullptr;
UVToolButton *m_thumb = nullptr; // the layer's texture; a click opens the texture library
wxStaticText *m_layer_name = nullptr;
wxStaticText *m_tile = nullptr;
UVToolButton *m_background[3]{};
UVToolButton *m_unwrap = nullptr;
::SpinInput *m_seam_angle = nullptr;
::CheckBox *m_connect = nullptr;
UVToolButton *m_select[3]{};
UVToolButton *m_mark_seams = nullptr;
UVToolButton *m_seam_path = nullptr;
UVToolButton *m_clear_seams = nullptr;
UVToolButton *m_avg_scale = nullptr;
UVToolButton *m_cut = nullptr;
UVToolButton *m_join = nullptr;
UVToolButton *m_unjoin = nullptr;
UVToolButton *m_clear_edits = nullptr;
UVToolButton *m_snap = nullptr;
UVToolButton *m_frame = nullptr;
wxStaticText *m_status = nullptr;
wxStaticText *m_stats = nullptr;
};
} // namespace Slic3r::GUI