Give each bake its own budget, and warn when it is short

Relief preserved from an earlier bake is counted on top of the budget
instead of eating into it: baking a second area went from ~214 k
triangles to the ~1000 k it was given.

When the resolution needs more triangles than the budget allows, it is
said before the bake under Resolution and again afterwards.
This commit is contained in:
ExPikaPaka
2026-09-23 10:26:24 +02:00
parent cd5a156e6e
commit 8ce05c03ec
8 changed files with 159 additions and 19 deletions
@@ -280,15 +280,12 @@ PipelineResult run_pipeline(const TriSoup &input, const HeightSampleFn &sample,
}
// 4. Decimate - export only. A bake needs the face-parent map, which a collapse destroys.
std::vector<int> parent = std::move(sub.face_parent_id);
std::vector<int> parent = std::move(sub.face_parent_id);
const size_t displaced_before_decimate = displaced.triangle_count();
if (mode == PipelineMode::Export) {
// Only when the mesh is actually over budget. Harvesting flat faces on a mesh that already fits
// cost several times the decimation itself and degraded the relief it was handed; it now only
// runs as part of a decimation that has to happen anyway. The repair pass below keys off the
// same decision (it runs only when decimation did), so an under-budget bake skips both.
const bool needs_decimation = displaced.triangle_count() > settings.max_triangles;
if (needs_decimation) {
std::vector<uint8_t> locked;
std::vector<uint8_t> locked;
size_t preserved = 0;
{
if (settings.preserve_untextured && !displaced.exclude_weight.empty()) {
locked.assign(displaced.triangle_count(), 0);
// The corner average, as the displacement stage judges it: after the flip stage's
@@ -304,7 +301,21 @@ PipelineResult run_pipeline(const TriSoup &input, const HeightSampleFn &sample,
for (size_t t = 0; t < locked.size() && t < soft_excluded.size(); ++t)
if (soft_excluded[t])
locked[t] = 0;
preserved = size_t(std::count(locked.begin(), locked.end(), uint8_t(1)));
}
}
// The budget is what this bake may spend on what it refines. Geometry it only preserves - the
// relief of an earlier bake, which this one does not paint - is counted on top of it: charged
// against the same budget, a second bake over a fresh area had to evict the first one's
// triangles to fit, so every bake after the first came out coarser than the one before.
const size_t target = settings.max_triangles + preserved;
const bool over_budget = displaced.triangle_count() > target;
// Only when the mesh is actually over budget: the decimation pass also welds the soup and is
// followed by the T-junction repair, and putting an under-budget bake through both changed the
// sliced result by a fifth even with the collapse tolerance at zero, i.e. with nothing
// collapsed. Harvesting flat faces on a mesh that already fits needs that path to leave the
// geometry alone first.
if (over_budget) {
// Colour per face on the fine mesh, so colour boundaries become creases the collapse
// respects. Excluded (unpainted) faces take no colour.
std::vector<int> face_color;
@@ -322,14 +333,20 @@ PipelineResult run_pipeline(const TriSoup &input, const HeightSampleFn &sample,
}
});
}
DecimateResult dec = decimate(displaced, settings.max_triangles, settings.harvest_flat,
const size_t before = displaced.triangle_count();
DecimateResult dec = decimate(displaced, target, settings.harvest_flat,
settings.harvest_tol, locked,
[&](double f) { return report("decimate", f); }, face_color);
result.locked_over_budget = dec.locked_over_budget;
displaced = std::move(dec.geometry);
lap("decimate", displaced, "over budget, simplified");
BOOST_LOG_TRIVIAL(info) << "TextureBake decimate: " << before << " -> " << displaced.triangle_count()
<< " (budget " << target << ")";
parent.clear(); // no longer meaningful
}
result.triangles_refined = displaced_before_decimate;
result.triangles_budget = target;
result.budget_limited = over_budget;
if (!report("decimate", 1.0)) {
result.canceled = true;
return result;
@@ -74,9 +74,11 @@ struct PipelineSettings
std::function<bool(const Vec3f &centroid)> painted;
// Export mode only.
// What this bake may spend on what it refines. Geometry it only preserves (see preserve_untextured)
// is counted on top of it, so an earlier bake's relief does not have to be evicted to fit this one.
size_t max_triangles = 750'000;
// Keep removing zero-cost flat faces past the target. Only applies when decimation runs, i.e. when
// the displaced mesh is over max_triangles - an under-budget mesh is never decimated.
// the displaced mesh is over the budget - an under-budget mesh is never decimated.
bool harvest_flat = true;
double harvest_tol = DECIMATE_DEFAULT_HARVEST_TOL;
// Lock the untextured region against both regularization and decimation.
@@ -107,6 +109,12 @@ struct PipelineResult
bool locked_over_budget = false;
size_t collapse_count = 0;
bool canceled = false;
// What the refinement produced, before decimation, and the count it had to fit into (the budget
// plus the preserved geometry). budget_limited says the refined mesh did not fit: the result
// carries less of the texture than the resolution asked for, which is what a caller warns about.
size_t triangles_refined = 0;
size_t triangles_budget = 0;
bool budget_limited = false;
};
// `debug`, when given and enabled, receives the mesh after every stage that ran - which is the only
@@ -122,7 +130,7 @@ void clamp_below_bottom(TriSoup &geometry, float bottom_z);
// Flatten the bed-contact surface by snapping positions within `tol` of the bottom plane onto it.
//
// Gated, not unconditional: an unconditional band snap also flattens the undersides of texture bumps
// Gated, not unconditional: an unconditional band snap also flattens the undersides of texture relief
// near the base, folding them coplanar into the bottom face. Folded faces overlap the plate, so edges
// there pick up four incident faces - non-manifold edges and phantom shells on re-import. All copies
// of a position move together, and the move is rejected if any incident triangle would go degenerate
+17 -6
View File
@@ -2095,7 +2095,8 @@ indexed_triangle_set build_texture_displacement_v2(const indexed_triangle_set
const DisplacementProgressFn &progress,
const TextureColorRequest *color,
bool flip_normals,
BakeStageRecorder *debug)
BakeStageRecorder *debug,
TextureBakeStats *stats)
{
HeightFieldSampler combined = make_combined_displacement_sampler(mesh, layers, facets_data);
if (!combined)
@@ -2282,6 +2283,12 @@ indexed_triangle_set build_texture_displacement_v2(const indexed_triangle_set
if (result.canceled || result.geometry.empty())
return {};
if (stats != nullptr) {
stats->triangles_refined = result.triangles_refined;
stats->triangles_out = result.geometry.triangle_count();
stats->triangles_budget = result.triangles_budget;
stats->budget_limited = result.budget_limited;
}
indexed_triangle_set out = TextureBake::to_indexed_triangle_set(result.geometry);
if (out.indices.empty())
return mesh;
@@ -2365,8 +2372,11 @@ static indexed_triangle_set build_texture_displacement_in_place(
const DisplacementProgressFn &progress,
const TextureColorRequest *color,
bool flip_normals,
BakeStageRecorder *debug)
BakeStageRecorder *debug,
TextureBakeStats *stats)
{
// The classic path moves the vertices the mesh already has, so there is no budget to report on.
(void) stats;
// Returns true to keep going. An aborted run returns {} (see the header): an empty mesh is the
// one result no caller can mistake for a finished bake and commit onto the volume.
const auto report = [&progress](int percent) { return !progress || progress(percent); };
@@ -2400,7 +2410,7 @@ static indexed_triangle_set build_texture_displacement_in_place(
if (options.pipeline_v2)
return build_texture_displacement_v2(mesh, layers, facets_data, options, progress, color, flip_normals,
debug);
debug, stats);
// Layers are combined in slot order, like stacked layers in an image editor: each one folds its
// own displacement into the running total via its blend mode (see TextureBlendMode).
@@ -2813,7 +2823,8 @@ indexed_triangle_set build_texture_displacement(const indexed_triangle_set
const DisplacementProgressFn &progress,
const TextureColorRequest *color,
const Transform3d &volume_to_world,
BakeStageRecorder *debug)
BakeStageRecorder *debug,
TextureBakeStats *stats)
{
// An untransformed volume on an untransformed instance is by far the common case, and the round
// trip costs two matrix multiplies per vertex on a mesh that can carry millions of them - so take
@@ -2823,7 +2834,7 @@ indexed_triangle_set build_texture_displacement(const indexed_triangle_set
const Transform3d frame = texture_displacement_bake_frame(volume_to_world);
if (frame.matrix().isApprox(Transform3d::Identity().matrix()))
return build_texture_displacement_in_place(base_mesh, layers, facets_data, options, progress, color,
false, debug);
false, debug, stats);
const Transform3d to_local = frame.inverse();
// A mirroring placement leaves the positions correct but every winding-derived normal pointing
@@ -2838,7 +2849,7 @@ indexed_triangle_set build_texture_displacement(const indexed_triangle_set
const size_t debug_mark = (debug != nullptr) ? debug->mark() : 0;
indexed_triangle_set out = build_texture_displacement_in_place(world, layers, facets_data, options,
progress, color, mirrored, debug);
progress, color, mirrored, debug, stats);
// Everything the bake recorded is in world millimetres, like `out` itself. The debug view draws in
// the volume's local frame, so the stages are brought back the same way the result is.
if (debug != nullptr)
+18 -1
View File
@@ -820,6 +820,21 @@ void merge_small_color_regions(const indexed_triangle_set &mesh, std::vector<int
// displaced along the wrong direction: a mesh normal maps to the world normal through the inverse
// transpose, not through the transform itself, so the relief leaned. Identity - the default - is
// exactly the old behaviour and is what an untransformed volume gives.
// What a bake spent, for whoever wants to report it. Only the default pipeline fills it in; the
// classic path moves the vertices the mesh already has and has nothing to say here.
struct TextureBakeStats
{
// What the refinement produced, before simplification, and what the bake committed.
size_t triangles_refined = 0;
size_t triangles_out = 0;
// What the result had to fit into: the budget for what this bake refines, plus the triangles it
// only preserves (an earlier bake's relief, which this one does not paint).
size_t triangles_budget = 0;
// The refined mesh did not fit, so the simplification had to take detail out of it to make it:
// the result carries less of the texture than the chosen resolution asked for.
bool budget_limited = false;
};
indexed_triangle_set build_texture_displacement(const indexed_triangle_set &base_mesh,
const std::vector<TextureDisplacementLayer> &layers,
const TextureDisplacementFacetsData &facets_data,
@@ -829,7 +844,9 @@ indexed_triangle_set build_texture_displacement(const indexed_triangle_set
const Transform3d &volume_to_world = Transform3d::Identity(),
// When given and enabled, receives the mesh after each
// stage, already brought back into `base_mesh`'s frame.
BakeStageRecorder *debug = nullptr);
BakeStageRecorder *debug = nullptr,
// When given, receives what the bake spent.
TextureBakeStats *stats = nullptr);
// `volume`'s mesh coordinates -> world millimetres: its first instance's transform times its own.
// The mesh is shared by every instance, so a multi-instance object can only be baked for one of
@@ -4114,6 +4114,44 @@ TextureDisplacementFacetsData GLGizmoTextureDisplacement::masks_after_subdivisio
return out;
}
double GLGizmoTextureDisplacement::painted_area_mm2(const ModelVolume &mv)
{
// Keyed on the paint generation, which every stroke and every layer edit raises, so a panel that
// asks for this on every frame walks the mesh only when the answer can have changed.
const std::string key = std::to_string(mv.id().id) + ":" + std::to_string(mv.mesh().facets_count()) + ":" +
std::to_string(m_preview_generation->load());
if (key == m_painted_area_key)
return m_painted_area_mm2;
const TriangleMesh &mesh = mv.mesh();
std::vector<uint8_t> region;
double area = 0.;
if (collect_paint_region(mesh, facets_data_of(mv), region, nullptr)) {
// In the frame the bake refines in, so a scaled instance is measured at the size it prints at.
const Transform3d frame = texture_displacement_bake_frame(texture_displacement_volume_to_world(mv));
const indexed_triangle_set &its = mesh.its;
for (size_t t = 0; t < its.indices.size() && t < region.size(); ++t) {
if ((region[t] & REFINE_PAINTED) == 0)
continue;
const stl_triangle_vertex_indices &tri = its.indices[t];
const Vec3d a = frame * its.vertices[size_t(tri[0])].cast<double>();
const Vec3d b = frame * its.vertices[size_t(tri[1])].cast<double>();
const Vec3d c = frame * its.vertices[size_t(tri[2])].cast<double>();
area += 0.5 * (b - a).cross(c - a).norm();
}
}
m_painted_area_mm2 = area;
m_painted_area_key = key;
return area;
}
size_t GLGizmoTextureDisplacement::estimated_refined_triangles(const ModelVolume &mv, float edge_mm)
{
if (edge_mm <= 0.f)
return 0;
return size_t(4.0 * painted_area_mm2(mv) / (double(edge_mm) * double(edge_mm)));
}
const V2Resolution &GLGizmoTextureDisplacement::v2_recommendation(const ModelVolume &mv)
{
// Rebuilt only when something it depends on changes: the surface area scan is O(triangles) and the
@@ -6768,6 +6806,26 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
hover_tip(_u8L("Triangle edge length the painted area is refined to before displacement. "
"Smaller carries finer texture detail and costs more triangles; the budget "
"caps the result."));
// What this resolution costs over what is painted, against what the budget allows. Refining
// past the budget is not an error - the bake simplifies back down to it - but the result
// then carries less of the texture than the resolution asks for, and the only sign of that
// used to be a mesh that came out coarser than expected. Shown before the bake, so the
// answer is to change a number rather than to wait out a bake and redo it.
const float edge_now = auto_res ? rec.edge_mm : opts.v2_refine_mm;
const int budget_k = opts.v2_max_triangles_k < 0 ? rec.budget_k : opts.v2_max_triangles_k;
const size_t budget = size_t(std::max(0, budget_k)) * 1000;
const size_t needed = estimated_refined_triangles(*mv, edge_now);
// Only when it is clearly over: the estimate runs about 3% high where it matters and up to
// a third high at coarse resolutions, where the mesh's own triangles are already near the
// target, and a warning about a bake that would have fitted is worse than none.
if (budget > 0 && needed > budget * 5 / 4) {
const auto to_m = [](size_t n) { return double(n) / 1000000.; };
m_imgui->warning_text(Slic3r::format(_u8L("This resolution needs about %1$.1f M triangles, "
"budget %2$.1f M - the bake will simplify back to "
"the budget and lose detail."),
to_m(needed), to_m(budget)));
}
} else {
m_imgui->text(_L("Triangles"));
ImGui::SameLine();
@@ -611,6 +611,18 @@ private:
// is exactly what this exists to find.
bool m_debug_check_topology = true;
// World-millimetre area of everything painted on `mv`, cached on the mesh and the paint generation:
// it is what the refinement has to cover, so it is what says whether a budget can pay for a
// resolution. 0 when nothing is painted.
double painted_area_mm2(const ModelVolume &mv);
double m_painted_area_mm2 = 0.;
std::string m_painted_area_key;
// Triangles the refinement needs to reach `edge_mm` over that area. Bisection converges to four of
// them to a square of the target edge - measured within 3% on a test model over a 5x range of
// resolutions - but the real count depends on the shape of the triangles it starts from, so this
// is only ever used to say a budget is clearly too small, never as a promise of what will come out.
size_t estimated_refined_triangles(const ModelVolume &mv, float edge_mm);
// 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;
@@ -3,6 +3,7 @@
#include <algorithm>
#include "libslic3r/Model.hpp"
#include "libslic3r/format.hpp"
#include "libslic3r/TriangleSelector.hpp"
#include "slic3r/GUI/GLCanvas3D.hpp"
@@ -11,6 +12,7 @@
#include "slic3r/GUI/GUI_ObjectList.hpp"
#include "slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp"
#include "slic3r/GUI/I18N.hpp"
#include "slic3r/GUI/NotificationManager.hpp"
#include "slic3r/GUI/Plater.hpp"
#include "slic3r/Utils/UndoRedo.hpp"
@@ -64,7 +66,7 @@ void TextureDisplacementBakeJob::process(Ctl &ctl)
}
return true;
},
color, m_input.volume_to_world));
color, m_input.volume_to_world, nullptr, &m_stats));
// 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.
@@ -169,6 +171,19 @@ void TextureDisplacementBakeJob::finalize(bool canceled, std::exception_ptr &ept
} else {
commit();
}
// The refinement went finer than the budget could keep: the simplification had to take detail back
// out to fit, so what was baked carries less of the texture than the resolution asked for. Said
// here, with the numbers, because it is the only place that knows them - and not as an error
// dialog: the result is a usable mesh, just not the one the settings described.
if (m_stats.budget_limited) {
const auto millions = [](size_t n) { return double(n) / 1000000.; };
wxGetApp().notification_manager()->push_notification(
NotificationType::CustomNotification, NotificationManager::NotificationLevel::WarningNotificationLevel,
Slic3r::format(_u8L("The triangle budget limited the detail: this resolution needs %1$.1f M triangles, "
"the budget kept %2$.1f M. Raise Budget or use a coarser Resolution for the full detail."),
millions(m_stats.triangles_refined), millions(m_stats.triangles_out)));
}
}
void queue_texture_displacement_bake(const ModelVolume &volume, const TextureColorSettings &color,
@@ -50,6 +50,8 @@ public:
private:
TextureDisplacementBakeInput m_input;
TriangleMesh m_result;
// What the bake spent, for the message it leaves behind when the budget capped the detail.
TextureBakeStats m_stats;
// Per triangle of m_result: the filament to print it in, as an EnforcerBlockerType value
// (0 = leave alone). Empty unless a layer asked for colour. See TextureColorRequest.
std::vector<uint8_t> m_triangle_color;