Compare commits

...
Author SHA1 Message Date
ExPikaPaka 4662bc67bb Treat an image with no colour spread as flat, not as a ramp
The axis-coverage signal defaulted to 1, meaning "spans its range", which is
what a ramp measures. An image of a single colour has no axis to measure, so it
kept that default and stopped counting as flat - the most flat image there is.
It broke the slic3rutils test that feeds mix_targets() a uniform 8x8.

Zero is the right default: an unmeasurable axis leaves the flat-share test
deciding on its own, exactly as before this signal existed. That also covers an
image with too few pixels to fit an axis through.
2026-10-15 12:15:41 +02:00
ExPikaPaka edcf865191 Use finer interleave ratios, and fold a mirrored tile at its seam
Two changes to how many colours a texture can reach, and whether they are the
right ones.

A mix alternates its two filaments over a cycle of at most six layers, which
also caps how many distinct mixes exist: eleven ratios per pair of filaments.
Raised to ten, which gives 33 and so lets a gradient be stepped more finely.
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.

Separately, the bilinear neighbour was wrapped for every tiling method. Repeat
does start again at the opposite edge, but a mirrored tile folds back on itself,
so there the texel past the edge is the edge again. Wrapping blended the image's
two opposite edges at every mirrored seam: on a black-to-white ramp the bright
edge sampled as 0.008 instead of 0.988. It also disagreed with the preview,
which binds GL_MIRRORED_REPEAT and folds - so a bake could not match what the
viewport showed.
2026-10-15 08:42:44 +02:00
ExPikaPaka e30b9fe93e Stop reading a two-colour ramp as an image of flat colours
A layer whose image is "flat colours" is matched against the loaded filaments
alone, no mixes. The verdict came from one signal: the share of the image
covered by the eight fullest bins of a coarse histogram, flat above 0.85.

A ramp between two colours is a straight line through colour space, so it
occupies as few bins as the two colours on their own and scores just as high.
The shipped "Colour Bricks.png" measures 0.96 and gets no mixes at all; a
green-to-red gradient measures 0.90 and prints as two flat areas.

Judged on a second signal as well: the share of the image's principal colour
axis that carries pixels, in 32 steps. A ramp fills it end to end, a handful of
flat colours sits in spikes with gaps between them. An image counts as flat only
when both agree, so this can only restore mixes where they were refused, never
take them away. Measured coverage: 1.00 for a gradient, 0.06 for a two-colour
logo, 0.41 to 0.62 for photographs, against a threshold of 0.25.

Colour Bricks.png goes from 0 mixes to 6, a green-to-red gradient from 0 to 11.
2026-10-15 08:16:15 +02:00
ExPikaPaka b7bff92ede Measure the mix ranking's stopping point against the texture, not a constant
The ranking stopped once a mix would improve the match by less than 0.05
CIEDE2000. The gains decay smoothly, so that fixed cut landed wherever the
curve happened to cross it: on a red-to-green gradient with five filaments it
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 - which is what made the setting look broken.

The floor is now a thousandth of what the best mix bought, read off the texture
itself, so a richer texture and a larger filament set both produce more mixes.
Measured on that gradient: 9 -> 12 mixes with four filaments, 10 -> 16 with
five, 12 -> 19 with six, for under a millisecond of extra palette work. A
candidate that improves nothing still stops the ranking, so a texture the
filaments already cover gains nothing from this.
2026-10-15 08:05:15 +02:00
ExPikaPaka d48012e18b Say when a texture cannot use every mix the setting allows
Mixed colors reads as a count, but it is a ceiling: a flat-color image is
matched against the filaments alone and gets no mixes, and otherwise the
ranking stops once another mix would no longer noticeably improve the match.
Both were silent, so raising the number past what the texture can use looked
like it did nothing. On a dithered red-to-green gradient with four filaments
the ranking tops out at nine whatever the setting says.

The palette line now names the figure the setting did not reach, and the
tooltip calls the setting a ceiling.
2026-10-15 08:05:15 +02:00
HanifKoh 5d73410281 Tell the Setup Wizard's Loading Page Only Once It Has Loaded (#16475)
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-15 13:44:06 +08:00
8 changed files with 234 additions and 15 deletions
+5
View File
@@ -10,6 +10,11 @@ function OnInit()
// Orca: fallback timeout in case the C++ -> JS signal fails (e.g., WebKit issues).
// Jump to the target page after 3 minutes so slow computers don't get stuck on a partially loaded page.
setTimeout("JumpToTarget()",180*1000);
var tSend={};
tSend['sequence_id']=Math.round(new Date() / 1000);
tSend['command']="userguide_loading_page_ready";
SendWXMessage(JSON.stringify(tSend));
}
function HandleStudio( pVal )
+70 -5
View File
@@ -91,12 +91,16 @@ 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: wrap for tiling methods, clamp at the edge otherwise (a
// repeating neighbour would incorrectly blend against the opposite edge of the image).
// 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;
tap.x0 = x0;
tap.y0 = y0;
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.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.tx = fx - std::floor(fx);
tap.ty = fy - std::floor(fy);
return true;
@@ -692,7 +696,68 @@ 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));
out.flat_colors = out.flat_share >= 0.85f;
// 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;
}
std::lock_guard<std::mutex> lock(g_texture_detail_cache.mutex);
auto &entries = g_texture_detail_cache.entries;
+9
View File
@@ -504,6 +504,15 @@ 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,10 +286,22 @@ 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;
// 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 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;
// 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.
@@ -5351,7 +5363,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 <= 6; ++den)
for (int den = 2; den <= MIX_MAX_DENOMINATOR; ++den)
for (int num = 1; num < den; ++num) {
if (std::gcd(num, den) != 1)
continue; // 2/4 is 1/2, already there
@@ -5390,6 +5402,8 @@ 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;
@@ -5409,8 +5423,11 @@ std::vector<GLGizmoTextureDisplacement::PaletteEntry> GLGizmoTextureDisplacement
best = c;
}
}
if (best == nc || best_gain < double(MIN_MIX_GAIN))
// Nothing left that helps, or the remaining mixes are noise beside the first one.
if (best == nc || best_gain <= 0. || best_gain < floor_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)
@@ -7358,8 +7375,9 @@ 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. They are picked from the "
"texture's colors, and only the ones the bake actually uses are created."),
_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."),
{ { "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.
@@ -7370,6 +7388,27 @@ 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 the short-cycle ones (k/d for d up to 6): the slicer interleaves a mix layer by layer,
// Ratios are k/d for d up to MIX_MAX_DENOMINATOR: 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.
+15 -1
View File
@@ -451,6 +451,16 @@ void GuideFrame::on_profile_loaded()
SaveProfileData();
const std::string strAll = m_ProfileJson.dump(-1, ' ', false, json::error_handler_t::ignore);
BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << ", finished, json contents:\n" << strAll;
m_profile_ready = true;
notify_profile_loaded();
}
// The data is often ready before the loading page has run its scripts, and a
// call into a page without HandleStudio is lost, so the message waits for both.
void GuideFrame::notify_profile_loaded()
{
if (!m_profile_ready || !m_page_ready)
return;
json res;
res["command"] = "userguide_profile_load_finish";
res["sequence_id"] = "10001";
@@ -546,7 +556,11 @@ void GuideFrame::OnScriptMessage(wxWebViewEvent &evt)
wxString strCmd = j["command"];
BOOST_LOG_TRIVIAL(trace) << "GuideFrame::OnScriptMessage;Command:" << strCmd;
if (strCmd == "close_page") {
if (strCmd == "userguide_loading_page_ready") {
m_page_ready = true;
notify_profile_loaded();
}
else if (strCmd == "close_page") {
this->EndModal(wxID_CANCEL);
}
if (strCmd == "user_clause") {
+5
View File
@@ -102,6 +102,7 @@ public:
int LoadProfileFamily(std::string strVendor, std::string strFilePath);
void init_guide_paths();
void on_profile_loaded();
void notify_profile_loaded();
bool BuildProfileJson(const PresetBundle& bundle, bool require_all_resource_vendors);
bool BuildProfileDataFromPresetBundle();
bool BuildProfileDataFromVendors();
@@ -142,6 +143,10 @@ private:
//First Load
bool bFirstComplete{false};
// The loading page gets userguide_profile_load_finish once both are true;
// every instance of it reports in, so the flag is never reset.
bool m_profile_ready{false};
bool m_page_ready{false};
// Set once in the destructor. Read through `this` by the loading thread
// (joined before `this` dies) and captured as the shared_ptr by CallAfter
// lambdas so they don't touch `this` after the object is freed.
@@ -94,6 +94,33 @@ 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)
@@ -2626,6 +2653,61 @@ 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.