Compare commits

..
Author SHA1 Message Date
HanifKoh 2dd66b8f20 Merge branch 'main' into hanif/wizard-loading-signal 2026-10-15 13:44:00 +08:00
Hanif Koh d5165c3e75 Tell the Setup Wizard's Loading Page Only Once It Has Loaded
The wizard's loading page waits for HandleStudio to be called with
userguide_profile_load_finish and jumps to its target page on it; if the
call never arrives, a fallback timer in the page jumps after 180 s.

GuideFrame started the profile load from the first wxEVT_WEBVIEW_NAVIGATED
event and ran HandleStudio in the page as soon as the data was ready. That
event is the navigation's commit, before the page's scripts have run, and
with the wizard's derived cache the data is ready about 100 ms later.
Since the web view is created with an empty URL so the translated strings
can be injected before the first load, the first navigated event is the
commit of about:blank, so the data is ready before the real page has even
begun loading and the call reaches a page without HandleStudio. WebKit
drops the error and nothing retries, so on macOS the wizard often sits on
"Loading..." for three minutes before the printer list appears.

The loading page now reports in from its OnInit, the way the other wizard
pages request what they need, and the host sends the message once both
the data is ready and the page has reported; every instance of the page
reports again, so one that replaced an earlier one gets its own message.
2026-10-14 19:08:07 +08:00
5 changed files with 14 additions and 209 deletions
+5 -70
View File
@@ -91,16 +91,12 @@ bool DecodedHeightTexture::texel_tap(const Vec2f &uv, bool tile_enabled, Texture
const float fy = v * float(height);
const int x0 = std::clamp(int(std::floor(fx)), 0, width - 1);
const int y0 = std::clamp(int(std::floor(fy)), 0, height - 1);
// Neighbour for bilinear filtering. Repeat wraps, because its next tile really does start at the
// opposite edge. The other two clamp: an untiled placement has nothing past its border, and a
// mirrored tile folds back on itself there, so the texel past the edge is the edge again. Wrapping
// a mirrored seam blends the two opposite edges of the image together, which is both a visible
// line and a disagreement with the preview - the GPU path binds GL_MIRRORED_REPEAT, which folds.
const bool wrap_neighbour = tile_enabled && tile_method != TextureTileMethod::MirroredRepeat;
// Neighbour for bilinear filtering: wrap for tiling methods, clamp at the edge otherwise (a
// repeating neighbour would incorrectly blend against the opposite edge of the image).
tap.x0 = x0;
tap.y0 = y0;
tap.x1 = wrap_neighbour ? (x0 + 1) % width : std::min(x0 + 1, width - 1);
tap.y1 = wrap_neighbour ? (y0 + 1) % height : std::min(y0 + 1, height - 1);
tap.x1 = tile_enabled ? (x0 + 1) % width : std::min(x0 + 1, width - 1);
tap.y1 = tile_enabled ? (y0 + 1) % height : std::min(y0 + 1, height - 1);
tap.tx = fx - std::floor(fx);
tap.ty = fy - std::floor(fy);
return true;
@@ -696,68 +692,7 @@ TextureDetail analyze_texture_detail(const TextureDisplacementLayer &layer)
for (int i = 0; i < 8; ++i)
top += bins[size_t(i)];
out.flat_share = float(double(top) / double(npx));
// How much of the principal colour axis carries pixels. Sampled rather than exhaustive: the
// axis is a bulk property, and a stride keeps this a few milliseconds on a large image.
{
constexpr size_t MAX_SAMPLES = size_t(1) << 16;
const size_t stride = std::max<size_t>(1, npx / MAX_SAMPLES);
std::vector<Vec3f> pts;
pts.reserve(npx / stride + 1);
for (size_t i = 0; i < npx; i += stride)
pts.push_back(tex.has_color() ?
Vec3f(float(tex.rgb[i * 3]), float(tex.rgb[i * 3 + 1]), float(tex.rgb[i * 3 + 2])) :
Vec3f::Constant(float(tex.pixels[i])));
if (pts.size() >= 16) {
Vec3f mean = Vec3f::Zero();
for (const Vec3f &p : pts)
mean += p;
mean /= float(pts.size());
// Power iteration on the covariance: the axis, without forming the matrix.
Vec3f axis(1.f, 0.3f, 0.2f);
for (int it = 0; it < 24; ++it) {
Vec3f next = Vec3f::Zero();
for (const Vec3f &p : pts) {
const Vec3f d = p - mean;
next += d.dot(axis) * d;
}
const float n = next.norm();
if (n < 1e-6f)
break;
axis = next / n;
}
std::vector<float> proj(pts.size());
for (size_t i = 0; i < pts.size(); ++i)
proj[i] = (pts[i] - mean).dot(axis);
std::sort(proj.begin(), proj.end());
// Trimmed, so a handful of stray pixels cannot stretch the range and make a flat
// image look like it spans one.
const float lo = proj[size_t(0.01 * double(proj.size()))];
const float hi = proj[std::min(proj.size() - 1, size_t(0.99 * double(proj.size())))];
const float span = hi - lo;
if (span > 1e-3f) {
constexpr int BUCKETS = 32;
std::array<size_t, BUCKETS> hist{};
for (const float s : proj)
if (s >= lo && s <= hi)
++hist[size_t(std::min(BUCKETS - 1, int((s - lo) / span * BUCKETS)))];
// A bucket counts as carrying pixels at a fifth of a percent of them, which is
// above the noise a dithered image leaves between its colours.
const size_t floor_count = size_t(0.002 * double(proj.size()));
int filled = 0;
for (const size_t c : hist)
filled += c >= floor_count;
out.axis_coverage = float(filled) / float(BUCKETS);
}
}
}
// Both signals, because either alone misreads a common case: the histogram calls a
// two-colour gradient flat (measured 0.90 on a green-to-red ramp, and 0.96 on a painted
// model texture), while coverage alone would call a noisy two-colour logo a gradient. An
// image whose axis could not be measured keeps coverage at 0, so the flat-share test decides
// alone - which is what an image of a single colour needs.
out.flat_colors = out.flat_share >= 0.85f && out.axis_coverage < 0.25f;
out.flat_colors = out.flat_share >= 0.85f;
}
std::lock_guard<std::mutex> lock(g_texture_detail_cache.mutex);
auto &entries = g_texture_detail_cache.entries;
-9
View File
@@ -504,15 +504,6 @@ struct TextureDetail
// verdict: a "flat-colour" image (tiles, logos, camouflage) whose colours should each print in a
// single filament, versus a photograph or gradient where interleaved filament mixes pay off.
float flat_share = 0.f;
// Share of the image's principal colour axis that actually carries pixels, in 32 steps. A ramp
// covers it end to end; a few flat colours sit in spikes with gaps between them. The histogram
// above cannot tell those apart - a two-colour gradient occupies as few bins as the two colours
// on their own - so both are needed before an image counts as flat.
//
// Zero when there was nothing to measure: an image of one colour, or too few pixels to fit an
// axis through. That is the right default, because it leaves the flat-share test deciding on its
// own, exactly as it did before this signal existed.
float axis_coverage = 0.f;
bool flat_colors = false;
};
TextureDetail analyze_texture_detail(const TextureDisplacementLayer &layer);
@@ -286,22 +286,10 @@ constexpr float PREFER_PURE_DE = 2.f;
// the quantizer's lookup cube.
constexpr size_t MIX_TARGET_SAMPLES = size_t(1) << 20;
constexpr size_t MIX_TARGET_BINS = 256;
// The longest interleave cycle a mix may use, as the denominator of its ratio. The slicer alternates
// a mix layer by layer, so the cycle is how many layers one repeat spans; past this it stops reading
// as a colour and starts reading as bands of its two filaments. It also sets how many mixes exist at
// all - the coprime ratios per pair of filaments - so it is what decides how finely a gradient can be
// stepped. Ten gives 33 ratios per pair against the 11 of six: measured on four filaments, a
// green-to-red gradient goes from 11 usable mixes to 16, and the shipped Colour Bricks from 6 to 10.
constexpr int MIX_MAX_DENOMINATOR = 10;
// rank_mixes() stops once the best remaining mix would improve the match by less than this FRACTION
// of what the best mix bought: a mix that only touches a few stray pixels is not worth a filament
// slot. Relative rather than a fixed amount of CIEDE2000, because the gains decay smoothly and a
// fixed cut lands wherever that curve happens to cross it - on a red-to-green gradient with five
// filaments the old 0.05 kept a mix worth 0.052 and refused one worth 0.042, and it stopped at the
// same count whether the user allowed 8 mixes or 28, and whether four filaments were loaded or six.
// As a fraction it scales with the texture, so both the setting and the loaded filaments matter.
constexpr double MIN_MIX_GAIN_FRACTION = 0.001;
// rank_mixes() stops once the best remaining mix would improve the match by less than this, in
// CIEDE2000 averaged over every pixel of the colouring layers (see mix_targets()): a mix that only
// touches a few stray pixels is not worth a filament slot.
constexpr float MIN_MIX_GAIN = 0.05f;
// The project's mixed filament slots, one string each, as the palette cache compares them: anything
// that changes which of them a mix can reuse changes this.
@@ -5363,7 +5351,7 @@ std::vector<GLGizmoTextureDisplacement::PaletteEntry> GLGizmoTextureDisplacement
std::vector<PaletteEntry> candidates;
for (int i = 0; i < n; ++i)
for (int j = i + 1; j < n; ++j)
for (int den = 2; den <= MIX_MAX_DENOMINATOR; ++den)
for (int den = 2; den <= 6; ++den)
for (int num = 1; num < den; ++num) {
if (std::gcd(num, den) != 1)
continue; // 2/4 is 1/2, already there
@@ -5402,8 +5390,6 @@ std::vector<GLGizmoTextureDisplacement::PaletteEntry> GLGizmoTextureDisplacement
std::vector<float> current = pure_d;
std::vector<char> taken(nc, 0);
std::vector<PaletteEntry> out;
// Set from the first mix taken, so the floor is read off this texture rather than assumed.
double floor_gain = 0.;
const auto counts = [&](size_t c, size_t t) {
const float d = dist[c * nt + t];
return d < current[t] && d <= pure_d[t] - PREFER_PURE_DE;
@@ -5423,11 +5409,8 @@ std::vector<GLGizmoTextureDisplacement::PaletteEntry> GLGizmoTextureDisplacement
best = c;
}
}
// Nothing left that helps, or the remaining mixes are noise beside the first one.
if (best == nc || best_gain <= 0. || best_gain < floor_gain)
if (best == nc || best_gain < double(MIN_MIX_GAIN))
break;
if (out.empty())
floor_gain = best_gain * MIN_MIX_GAIN_FRACTION;
taken[best] = 1;
out.push_back(candidates[best]);
for (size_t t = 0; t < nt; ++t)
@@ -7375,9 +7358,8 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
// physical filaments.
const int max_mixes = std::max(1, int(EnforcerBlockerType::ExtruderMax) - int(m_palette_filaments.size()));
if (int_row("##color_mix_count", _L("Mixed colors"), &opts.color_mix_count, 1, max_mixes, "%d", card_pad,
_L("The most mixed filaments a bake may add - a ceiling, not a count. They "
"are picked from the texture's colors, so a texture the filaments already "
"cover gets fewer, and only the ones the bake actually uses are created."),
_L("The most mixed filaments a bake may add. They are picked from the "
"texture's colors, and only the ones the bake actually uses are created."),
{ { "mixed_colors_few.png", _L("Few") }, { "mixed_colors_many.png", _L("Many") } }))
m_preview_params_dirty = true;
// After the slider, so a change shows in the same frame.
@@ -7388,27 +7370,6 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
// TRN %1% is the number of printable colors, %2% is "from N filaments".
ImGui::TextDisabled("%s", Slic3r::format(into_u8(_L_PLURAL("%1% printable color %2%", "%1% printable colors %2%", n_colors)),
n_colors, from_filaments).c_str());
// Two reasons the palette can hold fewer mixes than the setting asks for, and both
// used to be silent, leaving the user raising a number that changed nothing. A
// flat-color image is matched against the filaments alone, so it gets none at all;
// otherwise the ranking stops once another mix would no longer noticeably improve
// the match.
const int n_mixes = std::max(0, n_colors - n_filaments);
if (n_mixes < opts.color_mix_count) {
const bool flat = std::any_of(mv->texture_displacement_layers.begin(),
mv->texture_displacement_layers.end(),
[](const TextureDisplacementLayer &l) {
return l.color_enabled && !l.empty() &&
height_texture_has_color(l) &&
analyze_texture_detail(l).flat_colors;
});
ImGui::PushTextWrapPos(content_rx - card_pad);
ImGui::TextDisabled("%s", flat ?
_u8L("Flat-color image: each color prints in one filament, so no mixes are used.").c_str() :
Slic3r::format(_u8L("This texture uses %1% of them - more mixes would not "
"change the result."), n_mixes).c_str());
ImGui::PopTextWrapPos();
}
}
if (int_row("##color_despeckle", _L("Denoise"), &opts.color_despeckle, 0, 6, "%d", card_pad,
_L("Cleans up single stray triangles of the wrong color, which detail finer "
@@ -103,7 +103,7 @@ public:
// actually pick it. Stops early once another mix would make no noticeable difference, so a texture
// the filaments already cover gets few mixes or none.
//
// Ratios are k/d for d up to MIX_MAX_DENOMINATOR: the slicer interleaves a mix layer by layer,
// Ratios are the short-cycle ones (k/d for d up to 6): the slicer interleaves a mix layer by layer,
// and a long cycle prints as visible bands rather than as a colour. A mix's `rgb` is the colour its
// mixed filament slot will show (blend_color_multi(), as the sidebar computes it), so the match, the
// preview and the slot all agree on what the mix looks like.
@@ -94,33 +94,6 @@ static std::shared_ptr<std::vector<unsigned char>> make_flat_rgb_png(uint8_t r,
return std::make_shared<std::vector<unsigned char>>(std::move(bytes));
}
// An RGB image built by a caller-supplied function of the pixel position, written through the same
// temp-file round trip the flat helpers above use.
static std::shared_ptr<std::vector<unsigned char>> make_rgb_png(
size_t w, size_t h, const std::function<std::array<uint8_t, 3>(size_t, size_t)> &at)
{
std::vector<uint8_t> rgb;
rgb.reserve(w * h * 3);
for (size_t y = 0; y < h; ++y)
for (size_t x = 0; x < w; ++x) {
const std::array<uint8_t, 3> c = at(x, y);
rgb.insert(rgb.end(), { c[0], c[1], c[2] });
}
const boost::filesystem::path tmp_path = boost::filesystem::temp_directory_path()
/ boost::filesystem::unique_path("texdisp_test_%%%%%%%%.png");
REQUIRE(Slic3r::png::write_rgb_to_file(tmp_path.string(), w, h, rgb));
std::vector<unsigned char> bytes;
{
std::ifstream ifs(tmp_path.string(), std::ios::binary);
bytes.assign(std::istreambuf_iterator<char>(ifs), std::istreambuf_iterator<char>());
}
boost::system::error_code ec;
boost::filesystem::remove(tmp_path, ec);
REQUIRE_FALSE(bytes.empty());
return std::make_shared<std::vector<unsigned char>>(std::move(bytes));
}
// A hard-edged black/white checkerboard, the worst case for a height map: every texel boundary is a
// step, which is precisely the relief the post-process smoothing exists to round off.
static std::shared_ptr<std::vector<unsigned char>> make_checkerboard_png(size_t w = 16, size_t h = 16)
@@ -2653,61 +2626,6 @@ TEST_CASE("TextureDisplacement: a texture of flat colours is told apart from a c
CHECK(d.flat_share < 0.85f);
}
TEST_CASE("A mirrored tile folds at its seam instead of wrapping to the far edge", "[TextureDisplacement]")
{
// Mirrored repeat exists so a tile edge lines up with itself, which means the texel past the edge
// is the edge again. Wrapping the bilinear neighbour instead blends the image's two opposite edges
// together: on a ramp from black to white it turned the white edge into near-black at every seam,
// a visible line and a disagreement with the preview, where the GPU binds GL_MIRRORED_REPEAT.
TextureDisplacementLayer layer;
layer.image_data = make_rgb_png(8, 8, [](size_t x, size_t) {
const uint8_t v = uint8_t(x * 36); // 0 at the left edge, 252 at the right
return std::array<uint8_t, 3>{ v, v, v };
});
const DecodedHeightTexture tex = decode_height_texture(layer);
REQUIRE(tex.width == 8);
const float bright = 252.f / 255.f;
// Either side of a seam the fold puts the bright edge, not a blend with the dark one.
for (const float u : { 0.999f, 1.001f, 2.999f, 3.001f }) {
INFO("u = " << u);
CHECK_THAT(tex.sample(Vec2f(u, 0.5f), true, TextureTileMethod::MirroredRepeat),
WithinAbs(bright, 0.02f));
}
// Plain repeat is the opposite case and must keep wrapping: its next tile really does start at the
// far edge, so just inside the seam the neighbour is the dark one.
CHECK(tex.sample(Vec2f(0.999f, 0.5f), true, TextureTileMethod::Repeat) < 0.1f);
}
TEST_CASE("A two-colour ramp is not mistaken for an image of flat colours", "[TextureDisplacement]")
{
// The histogram alone cannot tell these two apart: a ramp between two colours visits as few
// coarse bins as the two colours on their own, and both score far above the flat-share
// threshold. What separates them is that the ramp fills its colour axis and the pair does not.
const std::array<uint8_t, 3> a{ 0x4f, 0xc3, 0x08 };
const std::array<uint8_t, 3> b{ 0xd4, 0x2f, 0x25 };
TextureDisplacementLayer ramp;
ramp.image_data = make_rgb_png(96, 96, [&](size_t, size_t y) {
const float t = float(y) / 95.f;
return std::array<uint8_t, 3>{ uint8_t(std::lround(a[0] + (b[0] - a[0]) * t)),
uint8_t(std::lround(a[1] + (b[1] - a[1]) * t)),
uint8_t(std::lround(a[2] + (b[2] - a[2]) * t)) };
});
const TextureDetail ramp_detail = analyze_texture_detail(ramp);
CHECK(ramp_detail.flat_share >= 0.85f); // the old signal, on its own, calls this flat
CHECK(ramp_detail.axis_coverage > 0.8f); // but its colours run the length of the axis
CHECK_FALSE(ramp_detail.flat_colors); // so mixes stay available
// The same two colours as areas rather than a ramp: nothing in between, so no mix would serve it.
TextureDisplacementLayer two_tone;
two_tone.image_data = make_rgb_png(96, 96, [&](size_t, size_t y) { return y < 48 ? a : b; });
const TextureDetail two_tone_detail = analyze_texture_detail(two_tone);
CHECK(two_tone_detail.axis_coverage < 0.25f);
CHECK(two_tone_detail.flat_colors);
}
TEST_CASE("Each layer's texture is sampled only on its own painted area", "[TextureDisplacement]")
{
// Two layers with different textures and depths, one painted on the cube's top, one on its -X side.