mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-10-15 11:51:33 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4662bc67bb | ||
|
|
edcf865191 | ||
|
|
e30b9fe93e | ||
|
|
b7bff92ede | ||
|
|
d48012e18b | ||
|
|
5d73410281 |
@@ -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 )
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user