diff --git a/src/libslic3r/CMakeLists.txt b/src/libslic3r/CMakeLists.txt index 1a7080d5c7..4fab598ea3 100644 --- a/src/libslic3r/CMakeLists.txt +++ b/src/libslic3r/CMakeLists.txt @@ -325,6 +325,8 @@ set(lisbslic3r_sources Geometry/VoronoiUtils.cpp Geometry/VoronoiUtils.hpp Geometry/VoronoiVisualUtils.hpp + ChromaStack.cpp + ChromaStack.hpp IMEXArrange.cpp IMEXArrange.hpp IMEXHelpers.cpp diff --git a/src/libslic3r/ChromaStack.cpp b/src/libslic3r/ChromaStack.cpp new file mode 100644 index 0000000000..762c35bdee --- /dev/null +++ b/src/libslic3r/ChromaStack.cpp @@ -0,0 +1,394 @@ +#include "ChromaStack.hpp" + +#include +#include +#include +#include +#include +#include + +#include +#include + +#include + +namespace Slic3r { + +// How many e-folding lengths of absorption count as "the colour beneath no longer shows". Three +// passes 5% of the light, which is about where the eye stops seeing what is under a layer. +static constexpr float OPAQUE_EFOLDS = 3.f; + +// TD is a filament property, so a zero or negative value in a preset is a data error rather than a +// request for an infinitely opaque filament. Clamp instead: below this the filament is opaque at a +// single layer anyway. +static constexpr float TD_MIN_MM = 0.05f; +static constexpr float TD_MAX_MM = 100.f; + +static float srgb_to_linear(float c) +{ + return c <= 0.04045f ? c / 12.92f : std::pow((c + 0.055f) / 1.055f, 2.4f); +} + +static float linear_to_srgb(float c) +{ + return c <= 0.0031308f ? c * 12.92f : 1.055f * std::pow(c, 1.f / 2.4f) - 0.055f; +} + +// CIELAB under D65, from non-linear sRGB in [0, 1]. Only used for nearest-colour search, so the +// absolute white point matters far less than both sides using the same one. +struct Lab +{ + float l = 0.f, a = 0.f, b = 0.f; +}; + +static Lab srgb_to_lab(float r, float g, float b) +{ + const float rl = srgb_to_linear(r), gl = srgb_to_linear(g), bl = srgb_to_linear(b); + const float x = (0.4124f * rl + 0.3576f * gl + 0.1805f * bl) / 0.95047f; + const float y = (0.2126f * rl + 0.7152f * gl + 0.0722f * bl); + const float z = (0.0193f * rl + 0.1192f * gl + 0.9505f * bl) / 1.08883f; + auto f = [](float t) { + return t > 0.008856f ? std::cbrt(t) : (7.787f * t + 16.f / 116.f); + }; + const float fx = f(x), fy = f(y), fz = f(z); + return Lab{ 116.f * fy - 16.f, 500.f * (fx - fy), 200.f * (fy - fz) }; +} + +static float delta_e(const Lab &p, const Lab &q) +{ + const float dl = p.l - q.l, da = p.a - q.a, db = p.b - q.b; + return std::sqrt(dl * dl + da * da + db * db); +} + +int ChromaStack::relief_layers() const +{ + int n = 0; + for (const ChromaBand &band : bands) + n += std::max(0, band.layers); + return n; +} + +int ChromaStack::total_layers() const { return std::max(0, base_layers) + relief_layers(); } + +float ChromaStack::depth_mm() const { return float(relief_layers()) * layer_height_mm; } + +std::vector chroma_palette(const ChromaStack &stack) +{ + const int total = stack.total_layers(); + // No bands means no picture and no filament to print the base in, whatever base_layers says. + if (total <= 0 || stack.bands.empty() || stack.layer_height_mm <= 0.f) + return {}; + + // The filament each printed layer is in, bottom-up: the base first, in the bottom band's filament, + // then the bands themselves. + std::vector per_layer; + per_layer.reserve(size_t(total)); + for (int i = 0; i < std::max(0, stack.base_layers); ++i) + per_layer.push_back(&stack.bands.front().filament); + for (const ChromaBand &band : stack.bands) + for (int i = 0; i < band.layers; ++i) + per_layer.push_back(&band.filament); + + std::vector palette(size_t(total) + 1); + + // Linear light, walking up from an opaque base: C_0 is the bottom layer's own colour, so a + // column one layer tall comes out exactly that colour however translucent the filament is. + std::array acc{ srgb_to_linear(per_layer.front()->color.r()), srgb_to_linear(per_layer.front()->color.g()), + srgb_to_linear(per_layer.front()->color.b()) }; + auto store = [&palette](size_t k, const std::array &lin) { + palette[k] = ColorRGB(std::clamp(linear_to_srgb(lin[0]), 0.f, 1.f), std::clamp(linear_to_srgb(lin[1]), 0.f, 1.f), + std::clamp(linear_to_srgb(lin[2]), 0.f, 1.f)); + }; + store(0, acc); + + for (int k = 1; k <= total; ++k) { + const ChromaFilament &f = *per_layer[size_t(k - 1)]; + const float td = std::clamp(f.td_mm, TD_MIN_MM, TD_MAX_MM); + // The depth at which this filament hides what is behind it, turned into the e-folding length + // of its absorption; see CHROMA_TD_OPAQUE_FRACTION. + const float efold = CHROMA_TD_OPAQUE_FRACTION * td / OPAQUE_EFOLDS; + const float t = std::exp(-stack.layer_height_mm / efold); + const std::array own{ srgb_to_linear(f.color.r()), srgb_to_linear(f.color.g()), + srgb_to_linear(f.color.b()) }; + for (int c = 0; c < 3; ++c) + acc[size_t(c)] = (1.f - t) * own[size_t(c)] + t * acc[size_t(c)]; + store(size_t(k), acc); + } + return palette; +} + +ChromaSolve chroma_solve(const uint8_t *rgb, int width, int height, const ChromaStack &stack) +{ + ChromaSolve solve; + const int total = stack.total_layers(); + const int base = std::max(0, stack.base_layers); + const int relief = stack.relief_layers(); + if (rgb == nullptr || width <= 0 || height <= 0 || relief <= 0 || relief > CHROMA_MAX_RELIEF_LAYERS) + return solve; + + const std::vector palette = chroma_palette(stack); + if (int(palette.size()) != total + 1) + return solve; + + // Only the heights the solver may choose: base (nothing of the relief printed) up to total. + const int choices = relief + 1; + const size_t choice_count = size_t(choices); + std::vector choice_lab(choice_count); + std::vector> choice_rgb(choice_count); + for (int i = 0; i < choices; ++i) { + const ColorRGB &c = palette[size_t(base + i)]; + choice_lab[size_t(i)] = srgb_to_lab(c.r(), c.g(), c.b()); + choice_rgb[size_t(i)] = { uint8_t(std::lround(c.r() * 255.f)), uint8_t(std::lround(c.g() * 255.f)), + uint8_t(std::lround(c.b() * 255.f)) }; + } + + const size_t count = size_t(width) * size_t(height); + solve.width = width; + solve.height = height; + solve.levels.resize(count); + solve.grey.resize(count); + solve.preview.resize(count * 3); + + std::vector errors(count, 0.f); + + auto pick = [&](float r, float g, float b, float &out_error) { + const Lab target = srgb_to_lab(std::clamp(r, 0.f, 1.f), std::clamp(g, 0.f, 1.f), std::clamp(b, 0.f, 1.f)); + int best = 0; + float best_d = std::numeric_limits::max(); + for (int i = 0; i < choices; ++i) { + const float d = delta_e(target, choice_lab[size_t(i)]); + if (d < best_d) { + best_d = d; + best = i; + } + } + out_error = best_d; + return best; + }; + + // The grey level a chosen height is written as. Rounded to the nearest of 256 levels, which for + // any practical layer budget (a few dozen) maps every height to its own level exactly - the + // relief's steps stay on layer boundaries, which is the whole point of quantizing here. + auto grey_of = [relief](int choice) { + return uint8_t(std::lround(255.0 * double(choice) / double(relief))); + }; + + auto write = [&](size_t px, int choice, float err) { + solve.levels[px] = uint16_t(base + choice); + solve.grey[px] = grey_of(choice); + solve.preview[px * 3] = choice_rgb[size_t(choice)][0]; + solve.preview[px * 3 + 1] = choice_rgb[size_t(choice)][1]; + solve.preview[px * 3 + 2] = choice_rgb[size_t(choice)][2]; + errors[px] = err; + }; + + if (stack.dither) { + // Floyd-Steinberg, serpentine so the error does not drift consistently to one side. The + // diffusion buffer holds the two rows the kernel reaches, in sRGB - which is where the + // artifact being traded away lives, and what every reference implementation diffuses in. + const size_t row_len = size_t(width); + std::vector> row(row_len), next(row_len); + for (int x = 0; x < width; ++x) { + const uint8_t *src = rgb + size_t(x) * 3; + row[size_t(x)] = { src[0] / 255.f, src[1] / 255.f, src[2] / 255.f }; + } + for (int y = 0; y < height; ++y) { + if (y + 1 < height) + for (int x = 0; x < width; ++x) { + const uint8_t *src = rgb + (size_t(y + 1) * size_t(width) + size_t(x)) * 3; + next[size_t(x)] = { src[0] / 255.f, src[1] / 255.f, src[2] / 255.f }; + } + const bool ltr = (y % 2) == 0; + for (int i = 0; i < width; ++i) { + const int x = ltr ? i : width - 1 - i; + const size_t px = size_t(y) * size_t(width) + size_t(x); + float err = 0.f; + const int choice = pick(row[size_t(x)][0], row[size_t(x)][1], row[size_t(x)][2], err); + write(px, choice, err); + + std::array residual{}; + for (int c = 0; c < 3; ++c) + residual[size_t(c)] = row[size_t(x)][size_t(c)] - choice_rgb[size_t(choice)][size_t(c)] / 255.f; + + const int ahead = ltr ? x + 1 : x - 1; + const int back = ltr ? x - 1 : x + 1; + auto spread = [&](std::vector> &dst, int at, float w) { + if (at < 0 || at >= width) + return; + for (int c = 0; c < 3; ++c) + dst[size_t(at)][size_t(c)] += residual[size_t(c)] * w; + }; + spread(row, ahead, 7.f / 16.f); + if (y + 1 < height) { + spread(next, back, 3.f / 16.f); + spread(next, x, 5.f / 16.f); + spread(next, ahead, 1.f / 16.f); + } + } + row.swap(next); + } + } else { + tbb::parallel_for(tbb::blocked_range(0, height), [&](const tbb::blocked_range &range) { + for (int y = range.begin(); y < range.end(); ++y) + for (int x = 0; x < width; ++x) { + const size_t px = size_t(y) * size_t(width) + size_t(x); + const uint8_t *src = rgb + px * 3; + float err = 0.f; + write(px, pick(src[0] / 255.f, src[1] / 255.f, src[2] / 255.f, err), err); + } + }); + } + + solve.mean_delta_e = count == 0 ? 0. : std::accumulate(errors.begin(), errors.end(), 0.) / double(count); + solve.max_delta_e = errors.empty() ? 0. : double(*std::max_element(errors.begin(), errors.end())); + return solve; +} + +float chroma_layers_to_opaque(float td_mm, float layer_height_mm) +{ + if (layer_height_mm <= 0.f) + return 0.f; + return CHROMA_TD_OPAQUE_FRACTION * std::clamp(td_mm, TD_MIN_MM, TD_MAX_MM) / layer_height_mm; +} + +// Below about one dE two colours are the same colour to the eye. Half of that is the threshold for +// "this layer changed something", so a step has to be comfortably invisible before a layer is called +// wasted. +static constexpr float VISIBLE_DE = 0.5f; + +static float step_delta_e(const ColorRGB &a, const ColorRGB &b) +{ + return delta_e(srgb_to_lab(a.r(), a.g(), a.b()), srgb_to_lab(b.r(), b.g(), b.b())); +} + +int chroma_distinct_levels(const ChromaStack &stack) +{ + const std::vector palette = chroma_palette(stack); + const int base = std::max(0, stack.base_layers); + if (int(palette.size()) <= base + 1) + return 0; + int n = 0; + for (size_t i = size_t(base) + 1; i < palette.size(); ++i) + if (step_delta_e(palette[i - 1], palette[i]) > VISIBLE_DE) + ++n; + return n; +} + +void chroma_trim_bands(ChromaStack &stack) +{ + // Walked over the stack's own palette rather than over the filaments' TD, because how fast a band + // runs out of anything to say depends on what is under it as much as on the filament. A light band + // over a dark one is finished in four or five layers; a dark one over a light one goes on paying + // for many more, long past the depth at which it is nominally opaque, because the last few percent + // of light coming through still moves a near-black colour perceptibly. + const std::vector palette = chroma_palette(stack); + if (palette.empty()) + return; + int layer = std::max(0, stack.base_layers); // palette index of the layer under the band being looked at + for (ChromaBand &band : stack.bands) { + int keep = 1; + for (int i = 1; i <= band.layers; ++i) { + const size_t cur = size_t(layer) + size_t(i); + if (cur >= palette.size()) + break; + if (step_delta_e(palette[cur - 1], palette[cur]) > VISIBLE_DE) + keep = i; // the last layer of this band that still changes the colour + } + layer += band.layers; + band.layers = std::clamp(keep, 1, band.layers); + } +} + +int chroma_level_at(const ChromaStack &stack, double z_mm) +{ + const int total = stack.total_layers(); + if (total <= 0 || stack.layer_height_mm <= 0.f) + return 0; + // Layer k occupies ((k-1)h, k h], so a point inside it rounds up. The epsilon keeps a surface + // sitting exactly on a layer boundary in the layer that printed it rather than in the one above, + // which is the difference between a terrace showing its own colour and showing the next band's. + const int k = int(std::ceil(z_mm / double(stack.layer_height_mm) - 1e-6)); + return std::clamp(k, 1, total); +} + +std::vector chroma_encode_height_png(const ChromaSolve &solve) +{ + if (solve.empty()) + return {}; + size_t size = 0; + void *png = tdefl_write_image_to_png_file_in_memory_ex(solve.grey.data(), solve.width, solve.height, 1, &size, + MZ_DEFAULT_LEVEL, MZ_FALSE); + if (png == nullptr || size == 0) + return {}; + const unsigned char *bytes = static_cast(png); + std::vector out(bytes, bytes + size); + mz_free(png); + return out; +} + +std::vector chroma_ranges(const ChromaStack &stack, double z_bottom) +{ + std::vector out; + if (stack.layer_height_mm <= 0.f) + return out; + // The layer height is stored as a float, like every other dimension of a texture displacement + // layer, so multiplying it up leaves noise in the last few digits - 5 layers of 0.1 mm come out as + // 0.5000000074. The slicer derives its own layer boundaries from the print config in double + // precision and lands on 0.5 exactly, and a boundary that misses it by any amount is a boundary a + // layer can fall on the wrong side of. Rounding to the nanometre removes the noise without + // disturbing any layer height a profile can express. + const auto snap = [](double z) { return std::round(z * 1e6) / 1e6; }; + + // The base is printed in the bottom band's filament - it is what is loaded when the print starts - + // so it extends that band's range down to the first layer instead of being a swap of its own. + int layer = std::max(0, stack.base_layers); + bool first = true; + for (const ChromaBand &band : stack.bands) { + if (band.layers <= 0) + continue; + const double z_min = snap(z_bottom + (first ? 0. : double(layer) * double(stack.layer_height_mm))); + first = false; + layer += band.layers; + const double z_max = snap(z_bottom + double(layer) * double(stack.layer_height_mm)); + if (!out.empty() && out.back().filament == band.filament.filament) + out.back().z_max = z_max; // no swap happens here, so it is one range + else + out.push_back(ChromaRange{ z_min, z_max, band.filament.filament }); + } + return out; +} + +ChromaStack chroma_default_stack(const std::vector &filaments, float layer_height_mm, + int base_layers, int relief_layers) +{ + ChromaStack stack; + stack.layer_height_mm = layer_height_mm > 0.f ? layer_height_mm : 0.08f; + stack.base_layers = std::max(0, base_layers); + if (filaments.empty() || relief_layers <= 0) + return stack; + + std::vector ordered = filaments; + std::stable_sort(ordered.begin(), ordered.end(), [](const ChromaFilament &a, const ChromaFilament &b) { + // Relative luminance of the linear colour: the perceptual order, so a saturated yellow + // counts as light and a saturated blue as dark, which a plain r+g+b sum gets wrong. + auto lum = [](const ColorRGB &c) { + return 0.2126f * srgb_to_linear(c.r()) + 0.7152f * srgb_to_linear(c.g()) + 0.0722f * srgb_to_linear(c.b()); + }; + return lum(a.color) > lum(b.color); + }); + + const int count = int(ordered.size()); + int left = relief_layers; + for (int i = 0; i < count; ++i) { + // Spread the remainder over the first bands rather than piling it onto the last one. + const int share = left / (count - i) + ((left % (count - i)) > 0 ? 1 : 0); + ChromaBand band; + band.filament = ordered[size_t(i)]; + band.layers = std::max(1, share); + left -= share; + stack.bands.push_back(band); + } + return stack; +} + +} // namespace Slic3r diff --git a/src/libslic3r/ChromaStack.hpp b/src/libslic3r/ChromaStack.hpp new file mode 100644 index 0000000000..1df097fb5d --- /dev/null +++ b/src/libslic3r/ChromaStack.hpp @@ -0,0 +1,286 @@ +#ifndef slic3r_ChromaStack_hpp_ +#define slic3r_ChromaStack_hpp_ + +#include +#include +#include +#include + +#include +#include + +#include "Color.hpp" + +namespace Slic3r { + +// ChromaStack-style colour printing: a picture is reproduced on a single face by stacking thin layers +// of a few filaments, swapping filament at fixed layer boundaries, and varying the *height* of each +// pixel's column. Where the column is low, the layers above it are missing and the filament below +// shows; where it is high, the topmost filament covers everything beneath it. Because filament is +// translucent, a column that stops part-way through a run shows a blend of that run's filament and +// what is under it, which is what gives far more colours than there are filaments loaded. +// +// So there are only two things to compute, and they are both here: +// - the *palette*: the colour the stack shows at every possible column height (chroma_palette()); +// - the *height map*: per pixel, the column height whose palette colour is closest to the picture +// (chroma_solve()). +// +// Everything else is machinery that already exists. The height map is handed to a texture +// displacement layer as its height texture, so the existing projection/preview/bake pipeline turns +// it into real geometry (see TextureDisplacement.hpp), and the filament swaps are handed to the +// object's height ranges (ModelObject::layer_config_ranges), which is the slicer's own way of +// saying "print this Z band with that filament". + +// The depth of filament that blocks essentially all the light behind it, as a fraction of the +// filament's transmission distance (TD). +// +// TD's scale is empirical, not physical: its values run from about 1 +// for an opaque filament to 10 and above for a nearly clear one, and the community's calibration +// prints (the "seashell" step test) read a TD off the step where the colour beneath stops showing. +// That step is about a tenth of the TD number, which is the only thing this constant encodes - a +// filament of TD 2 stops hiding what is under it at roughly 0.2 mm, i.e. after two or three layers, +// while one of TD 8 needs most of a millimetre. The model below turns that into a per-layer +// transmittance; see chroma_palette(). +static constexpr float CHROMA_TD_OPAQUE_FRACTION = 0.1f; + +// What a fresh stack gets above the base. Twenty-four layers is about two millimetres at a fine layer +// height: enough steps for a continuous tonal ramp, and still a print that finishes in an evening. +static constexpr int CHROMA_DEFAULT_RELIEF_LAYERS = 24; + +// The most picture layers the solve can express. A column's height above the base is stored as one +// byte per pixel in the 8-bit grayscale height map the displacement reads, so beyond this two +// different heights would have to share a grey level and the relief's steps would stop landing on +// layer boundaries - which is the one thing the quantization exists to guarantee. +// +// It bounds the bands, not the stack: the base is whatever the model already is under the painted +// face, often hundreds of layers on a tall part, and nothing about it is quantized into the height +// map. As a limit on the picture it is not one in practice - 255 layers is 20 mm of relief at +// 0.08 mm, far past where a picture gains anything. +static constexpr int CHROMA_MAX_RELIEF_LAYERS = 255; + +// The most filament changes one stack may hold. Every band is a swap the printer has to make, so this is +// a limit on patience rather than on the model; the palette itself is bounded by the layer count. +static constexpr size_t CHROMA_MAX_BANDS = 16; + +// One filament as the colour model sees it: what colour it is, and how far light gets into it. +struct ChromaFilament +{ + // 0-based index into the project's filaments, which is what a height range's `extruder` option + // (1-based) is written from. -1 for a filament that is not loaded, which the GUI refuses to + // solve with rather than silently printing in the wrong colour. + int filament = -1; + ColorRGB color; + // The filament preset's own name, so the panel can say which filament this is. Six similar greys + // are six identical swatches and six identical numbers otherwise. Stored rather than looked up on + // demand because a stack outlives the project's filament list - a preset can be swapped out from + // under a saved stack, and then the name is the only record of what it was solved against. + std::string name; + // Transmission distance, mm, as the filament preset reports it + // (`filament_transmission_distance`). Clamped to a sane range when the palette is built: zero + // would make a layer infinitely opaque and a negative value is meaningless. + float td_mm = 4.f; + + template void serialize(Archive &ar) + { + float r = color.r(), g = color.g(), b = color.b(); + ar(filament, r, g, b, td_mm, name); + color = ColorRGB(r, g, b); + } +}; + +// A run of consecutive layers printed in one filament. The stack is a list of these, bottom-up, so +// a band boundary is exactly where the print pauses and the filament is changed. +struct ChromaBand +{ + ChromaFilament filament; + int layers = 1; + + template void serialize(Archive &ar) { ar(filament, layers); } +}; + +// The whole recipe: which filaments, in which order, how thick a layer is, and how much of the +// bottom is solid backing rather than picture. +struct ChromaStack +{ + std::vector bands; // bottom-up; bands.front() is the one the base is printed in + + // The print's layer height, mm. Taken from the print profile rather than chosen here - a stack + // solved against 0.08 mm layers prints as the wrong picture at 0.2 mm, so the GUI re-solves when + // the profile's layer height changes. + float layer_height_mm = 0.08f; + + // Layers of solid backing under the picture: everything the model already is below the painted + // face. Not part of any band - the bands are the picture - but printed in the bottom band's + // filament, since that is what is loaded when the print starts. + // + // Keeping it out of the bands is what lets it be what it really is: a measurement of the model, + // often a hundred layers or more, while a band is a handful. Folding the two together put a + // three-digit number on a slider meant for single digits and made "how thick is the backing" and + // "how many layers does the first filament get" the same control, which they are not. + // + // The colour model treats the base as opaque. That is what makes a column's colour depend only on + // the layers above it, and why the panel warns about a base thin enough to let the plate show + // through. + int base_layers = 4; + + // Whether `base_layers` follows the model or was typed in. It follows by default, and it should: + // the painted face is wherever the geometry puts it, and a base that disagrees with it shifts + // every colour in the picture - the stack ends up anchored at the bottom of the part instead of + // under the face, so the picture's whole range is squeezed into the first few millimetres and + // everything above it clamps to the top band. + // + // It cannot be measured once and left: a layer is created, and its picture chosen, before there + // is any paint to measure, and the face moves again whenever the part is scaled or the layer + // height changes. So the panel re-measures it every frame while this is set. + bool base_auto = true; + + // Spread the quantization error into neighbouring pixels (Floyd-Steinberg, serpentine) instead + // of rounding each pixel to its nearest palette colour on its own. A palette of twenty or thirty + // colours has to cover a photograph's whole range, so plain rounding leaves visible bands across + // a sky or a cheek; diffusing the error trades them for fine stipple, which at a tenth of a + // millimetre per pixel reads as the intermediate colour. Off for flat-coloured artwork, where + // stipple is the artifact rather than the cure. + bool dither = false; + + // Total printed layers, base included: what the tallest column is. + int total_layers() const; + // Layers the picture is built from, i.e. the bands' own total above the base. + int relief_layers() const; + // The relief's height in mm, which is the `depth_mm` of the texture displacement layer the + // height map is handed to. + float depth_mm() const; + bool empty() const { return bands.empty(); } + + template void serialize(Archive &ar) { ar(bands, layer_height_mm, base_layers, dither, base_auto); } +}; + +// The colour the stack shows at every column height: entry k is a column of exactly k printed +// layers, so the vector has total_layers() + 1 entries. +// +// The model is Beer-Lambert absorption, layer by layer. A layer of thickness h in a filament of +// transmission distance TD passes +// +// T = exp(-h / (CHROMA_TD_OPAQUE_FRACTION * TD / 3)) +// +// of the light coming from under it and shows its own colour with the rest, so +// +// C_k = (1 - T_k) * c_k + T_k * C_(k-1) +// +// walking up from an opaque base, C_0 = c_1. The divisor is what makes "opaque at +// CHROMA_TD_OPAQUE_FRACTION * TD" true: three e-folding lengths pass 5% of the light, which is +// where the colour beneath stops being visible to the eye. +// +// Beer-Lambert is not just the physically honest choice here, it is the only one that stays +// consistent when applied per layer: transmittance multiplies, so a run of n layers of one filament +// passes exp(-n h / d) whether it is composited in one step or n, and the palette does not depend on +// how the stack happens to be cut into bands. +// +// Compositing is done on linear light and the entries are converted back to sRGB, because the +// quantity being attenuated is light, not an sRGB code. +std::vector chroma_palette(const ChromaStack &stack); + +// What chroma_solve() produced. +struct ChromaSolve +{ + int width = 0; + int height = 0; + + // Per pixel, row-major and top-to-bottom like DecodedHeightTexture: the column's printed layer + // count, between base_layers and total_layers(). Sixteen bits because it counts from the build + // plate, and the base alone can be several hundred layers on a tall part. + std::vector levels; + // The same thing as the 8-bit grayscale height map the displacement samples: 0 at base_layers, + // 255 at total_layers(). This is what gets encoded as a PNG into the layer's `image_data`, which + // is why the solve has to be the thing that owns the quantization - the relief's steps have to + // land on layer boundaries, and a grey level is the only channel the displacement reads. + std::vector grey; + // Per pixel RGB: the palette colour the solve chose, i.e. what the print is predicted to look + // like. Shown in the panel next to the source picture, since the 3D view can only show the + // relief - the colours do not exist until the print swaps filament. + std::vector preview; + + // How far the prediction landed from the picture, as a mean and worst-case CIE76 dE over all + // pixels. The panel reports it because it is the only honest answer to "will this stack + // reproduce my image": under about 5 is a good match, over about 15 means the filaments cannot + // reach the picture's colours and no amount of layers will fix it. + double mean_delta_e = 0.; + double max_delta_e = 0.; + + bool empty() const { return width <= 0 || height <= 0 || levels.empty(); } +}; + +// Solves `rgb` (width * height * 3 bytes, row-major, top-to-bottom) against the stack's palette. +// An empty stack, a zero-sized image or a stack with no relief layers gives an empty result. +ChromaSolve chroma_solve(const uint8_t *rgb, int width, int height, const ChromaStack &stack); + +// How many layers of this filament it takes to hide what is under it, at this layer height. The +// single most useful number about a stack and the one nothing else makes visible: it is what decides +// whether a band is a gradient or a step. +// +// A filament hides what is behind it at about CHROMA_TD_OPAQUE_FRACTION of its TD (see +// chroma_palette()), so this is that depth in layers. Below two the band has no intermediate colours +// at all - the picture can only use the filament or not use it - which is what a coarse layer height +// does to an otherwise good stack: TD 4 at 0.2 mm covers in two layers, the same filament at 0.08 mm +// in five. +float chroma_layers_to_opaque(float td_mm, float layer_height_mm); + +// How many of the picture's layers actually change the colour. +// +// A band eventually stops saying anything: once it hides what is under it, another layer of it shows +// the colour the one below already showed. Those layers still cost print time and still take up +// relief height, and nothing about the stack makes them visible - the band looks like ten layers of +// gradient and is, say, five of gradient and five of flat. +// +// Measured against the palette rather than against TD, because how long a band keeps paying depends +// on what is under it as much as on the filament: a light band over a dark one is finished in four or +// five layers, while a dark one over a light one goes on moving the colour perceptibly well past the +// depth at which it is nominally opaque. +int chroma_distinct_levels(const ChromaStack &stack); + +// Shortens every band to its last layer that still changes the colour. Bands already shorter than +// that are left alone: they are still inside their fade, where every layer counts. +void chroma_trim_bands(ChromaStack &stack); + +// The printed layer a point `z_mm` above the bottom of the object falls in: 1-based, clamped to the +// stack, 0 for a stack that prints nothing. Pair it with chroma_palette() to get the colour shown +// there. +// +// This, rather than the height map, is what decides the colour a surface shows. A filament swap +// happens at one print Z across the whole plate, so every surface crossing that height changes colour +// whatever the picture says - the terraces of the relief, the walls between them, and the sides of the +// model alike. Colouring a preview this way is therefore not an approximation of the print; it is what +// the print does, and it keeps working after the relief has been baked into the mesh and the height +// map is no longer attached to anything on screen. +int chroma_level_at(const ChromaStack &stack, double z_mm); + +// The solve's height map as an 8-bit grayscale PNG, ready to be stored in a +// TextureDisplacementLayer's `image_data`. +std::vector chroma_encode_height_png(const ChromaSolve &solve); + +// One filament swap as an object height range: the Z band, in the object's own coordinates (0 at its +// bottom), that prints in `filament` (0-based). +struct ChromaRange +{ + double z_min = 0.; + double z_max = 0.; + int filament = -1; +}; + +// The stack's bands as height ranges, in object coordinates, starting at `z_bottom` (which is 0 for +// a stack whose base starts at the object's first layer). Bands printed in the same filament as the +// band below are merged, so a stack that repeats a filament does not produce a pointless swap. +std::vector chroma_ranges(const ChromaStack &stack, double z_bottom = 0.); + +// A starting point for a stack: the given filaments, lightest at the bottom, each with an equal +// share of `relief_layers` on top of `base_layers`. +// +// Light-to-dark is the convention filament-painting guides teach, and it is not arbitrary: a dark +// filament over a light one reads as a midtone at one or two layers and as the dark colour at four, +// so putting the dark filaments last is what buys the long tonal ramp. The reverse order gives a few +// washed-out steps and then nothing. +ChromaStack chroma_default_stack(const std::vector &filaments, float layer_height_mm, + int base_layers, int relief_layers); + +} // namespace Slic3r + +#endif // slic3r_ChromaStack_hpp_ diff --git a/src/libslic3r/Preset.cpp b/src/libslic3r/Preset.cpp index 78fa0785ef..b688e3c327 100644 --- a/src/libslic3r/Preset.cpp +++ b/src/libslic3r/Preset.cpp @@ -1487,7 +1487,7 @@ static std::vector s_Preset_print_options{ static std::vector s_Preset_filament_options {/*"filament_colour", */ "default_filament_colour", "required_nozzle_HRC", "filament_diameter", "pellet_flow_coefficient", "volumetric_speed_coefficients", "filament_type", "filament_soluble", "filament_is_support", "filament_printable", "filament_extruder_compatibility", "filament_max_volumetric_speed", "filament_adaptive_volumetric_speed", - "filament_flow_ratio", "filament_density", "filament_adhesiveness_category", "filament_cost", "filament_minimal_purge_on_wipe_tower", + "filament_flow_ratio", "filament_density", "filament_transmission_distance", "filament_adhesiveness_category", "filament_cost", "filament_minimal_purge_on_wipe_tower", "filament_tower_interface_pre_extrusion_dist", "filament_tower_interface_pre_extrusion_length", "filament_tower_ironing_area", "filament_tower_interface_purge_volume", "filament_tower_interface_print_temp", "nozzle_temperature", "nozzle_temperature_initial_layer", diff --git a/src/libslic3r/Print.cpp b/src/libslic3r/Print.cpp index 026a6fce9b..3189eb29d2 100644 --- a/src/libslic3r/Print.cpp +++ b/src/libslic3r/Print.cpp @@ -226,6 +226,7 @@ bool Print::invalidate_state_by_config_options(const ConfigOptionResolver & /* n "filament_diameter", "volumetric_speed_coefficients", "filament_density", + "filament_transmission_distance", "filament_cost", "filament_notes", "outer_wall_acceleration", diff --git a/src/libslic3r/PrintConfig.cpp b/src/libslic3r/PrintConfig.cpp index 9e3958bc25..eebb9d0508 100644 --- a/src/libslic3r/PrintConfig.cpp +++ b/src/libslic3r/PrintConfig.cpp @@ -3441,6 +3441,20 @@ void PrintConfigDef::init_fff_params() def->mode = comAdvanced; def->set_default_value(new ConfigOptionFloats { 0. }); + def = this->add("filament_transmission_distance", coFloats); + def->label = L("Transmission distance"); + def->tooltip = L("How deep light gets into this filament, in millimetres, also called its " + "transmission distance or TD. About 1 for an opaque filament, 4 to 7 for a usefully " + "translucent one, 8 and above for a nearly clear one. Used only to predict the " + "colours of a ChromaStack in the texture displacement tool; it has no effect on " + "slicing. Measure it with a step test print, or take a published value for the " + "filament."); + def->sidetext = L("mm"); + def->min = 0; + def->max = 100; + def->mode = comAdvanced; + def->set_default_value(new ConfigOptionFloats { 4. }); + def = this->add("filament_type", coStrings); def->label = L("Type"); def->tooltip = L("Filament material type"); diff --git a/src/libslic3r/PrintConfig.hpp b/src/libslic3r/PrintConfig.hpp index 8b724a7058..c4b9c5a8e9 100644 --- a/src/libslic3r/PrintConfig.hpp +++ b/src/libslic3r/PrintConfig.hpp @@ -1709,6 +1709,9 @@ PRINT_CONFIG_CLASS_DEFINE( ((ConfigOptionStrings, volumetric_speed_coefficients)) ((ConfigOptionInts, filament_adhesiveness_category)) ((ConfigOptionFloats, filament_density)) + // Transmission distance: how deep light gets into this filament. Not used by + // slicing - it is the colour model of a ChromaStack stack (see ChromaStack.hpp). + ((ConfigOptionFloats, filament_transmission_distance)) ((ConfigOptionStrings, filament_type)) ((ConfigOptionBools, filament_soluble)) ((ConfigOptionStrings, filament_ids)) diff --git a/src/libslic3r/TextureDisplacement.hpp b/src/libslic3r/TextureDisplacement.hpp index d61739c7d8..3589c03fde 100644 --- a/src/libslic3r/TextureDisplacement.hpp +++ b/src/libslic3r/TextureDisplacement.hpp @@ -18,6 +18,7 @@ #include +#include "ChromaStack.hpp" #include "Point.hpp" #include "TriangleSelector.hpp" @@ -293,16 +294,33 @@ struct TextureDisplacementLayer // for the boundary to be drawn on. bool color_enabled = false; + // ChromaStack (see ChromaStack.hpp): when set, `image_data` is not a texture the user picked but the + // height map chroma_solve() computed, so that the whole displacement pipeline - preview, UV + // editor, bake - goes on reading one grayscale height map and needs to know nothing about colour + // stacks. The picture it was solved from is kept here instead, because the solve has to be redone + // whenever the stack, the print's layer height or the picture itself changes. + // + // A ChromaStack layer gets its colour from filament swaps at fixed Z, which is what `chroma` + // records and what the object's height ranges carry out. That is a different mechanism from + // `color_enabled` (per-triangle filament assignment), and the two are mutually exclusive: the + // panel turns colour off on a ChromaStack layer, since painting the relief in one filament per + // triangle would hide the very stack the heights were solved against. + bool chroma_enabled = false; + ChromaStack chroma; + std::shared_ptr> chroma_source; + bool empty() const { return !image_data || image_data->empty(); } template void save(Archive &ar) const { std::string blob = image_data ? std::string(image_data->begin(), image_data->end()) : std::string(); + std::string source = chroma_source ? std::string(chroma_source->begin(), chroma_source->end()) : std::string(); ar(slot, name, path, path_in_3mf, blob, depth_mm, tiling_scale, rotation_deg, offset, invert, tile_enabled, static_cast(tile_method), static_cast(projection_method), lscm_seam_angle_deg, islands, static_cast(blend_mode), midlevel, island_padding_mm, lscm_seam_edges, view_project_right, view_project_up, smoothing, edge_smoothing, edge_smoothing_amount, auto_connect_islands, island_groups, - lscm_uv_overrides, view_project_projective, view_project_matrix, color_enabled); + lscm_uv_overrides, view_project_projective, view_project_matrix, color_enabled, chroma_enabled, chroma, + source); } template void load(Archive &ar) { @@ -310,12 +328,14 @@ struct TextureDisplacementLayer int tile_method_int = 0; int projection_method_int = 0; int blend_mode_int = 0; + std::string source; ar(slot, name, path, path_in_3mf, blob, depth_mm, tiling_scale, rotation_deg, offset, invert, tile_enabled, tile_method_int, projection_method_int, lscm_seam_angle_deg, islands, blend_mode_int, midlevel, island_padding_mm, lscm_seam_edges, view_project_right, view_project_up, smoothing, edge_smoothing, edge_smoothing_amount, auto_connect_islands, island_groups, lscm_uv_overrides, view_project_projective, - view_project_matrix, color_enabled); + view_project_matrix, color_enabled, chroma_enabled, chroma, source); image_data = blob.empty() ? nullptr : std::make_shared>(blob.begin(), blob.end()); + chroma_source = source.empty() ? nullptr : std::make_shared>(source.begin(), source.end()); tile_method = static_cast(tile_method_int); projection_method = static_cast(projection_method_int); blend_mode = static_cast(blend_mode_int); diff --git a/tests/libslic3r/CMakeLists.txt b/tests/libslic3r/CMakeLists.txt index 9e1e7c44f8..912b8ef9bb 100644 --- a/tests/libslic3r/CMakeLists.txt +++ b/tests/libslic3r/CMakeLists.txt @@ -37,6 +37,7 @@ add_executable(${_TEST_NAME}_tests test_fill_plane_path.cpp test_fill_tpms_adaptive.cpp test_geometry.cpp + test_chromastack.cpp test_imex_arrange.cpp test_imex_helpers.cpp test_imex_zones.cpp diff --git a/tests/libslic3r/test_chromastack.cpp b/tests/libslic3r/test_chromastack.cpp new file mode 100644 index 0000000000..79ac50122d --- /dev/null +++ b/tests/libslic3r/test_chromastack.cpp @@ -0,0 +1,379 @@ +#include +#include +#include + +#include +#include +#include +#include + +#include "libslic3r/Color.hpp" +#include "libslic3r/ChromaStack.hpp" +#include "libslic3r/PNGReadWrite.hpp" + +using namespace Slic3r; +using Catch::Matchers::WithinAbs; +using Catch::Matchers::WithinRel; + +namespace { + +ChromaFilament filament(int index, const ColorRGB &color, float td_mm) +{ + ChromaFilament f; + f.filament = index; + f.color = color; + f.td_mm = td_mm; + return f; +} + +ChromaBand band(const ChromaFilament &f, int layers) +{ + ChromaBand b; + b.filament = f; + b.layers = layers; + return b; +} + +// An opaque filament (TD 1 hides what is under it within 0.1 mm) and a translucent one (TD 8 needs +// most of a millimetre), which is the whole range the model has to behave sensibly over. +const ChromaFilament WHITE_OPAQUE = filament(0, ColorRGB(1.f, 1.f, 1.f), 1.f); +const ChromaFilament BLACK_OPAQUE = filament(1, ColorRGB(0.f, 0.f, 0.f), 1.f); +const ChromaFilament BLACK_TRANSLUCENT = filament(2, ColorRGB(0.f, 0.f, 0.f), 8.f); + +float luminance(const ColorRGB &c) { return 0.2126f * c.r() + 0.7152f * c.g() + 0.0722f * c.b(); } + +} // namespace + +TEST_CASE("A stack's palette has one entry per printed layer", "[ChromaStack]") +{ + ChromaStack stack; + stack.layer_height_mm = 0.08f; + stack.base_layers = 4; + stack.bands = { band(WHITE_OPAQUE, 2), band(BLACK_TRANSLUCENT, 10) }; + + // The base is backing under the picture, not part of a band: the bands are the 12 layers the + // picture is built from, and the stack is those on top of the 4 the model already has. + REQUIRE(stack.relief_layers() == 12); + REQUIRE(stack.total_layers() == 16); + REQUIRE_THAT(stack.depth_mm(), WithinAbs(12 * 0.08f, 1e-6f)); + + const std::vector palette = chroma_palette(stack); + REQUIRE(palette.size() == 17); + // Index 0 is the base's own colour, so a column one layer tall is exactly its filament - whatever + // that filament's transmission distance. + REQUIRE(palette[0] == palette[1]); + REQUIRE_THAT(luminance(palette[1]), WithinAbs(1.f, 1e-3f)); +} + +TEST_CASE("A run of one filament gives the same palette however it is split into bands", "[ChromaStack]") +{ + // The property that justifies compositing layer by layer at all: transmittance multiplies, so + // cutting a run in two must not change the colours. A model without it would make the palette + // depend on how the stack was typed in. + ChromaStack one; + one.layer_height_mm = 0.1f; + one.base_layers = 0; + one.bands = { band(WHITE_OPAQUE, 2), band(BLACK_TRANSLUCENT, 8) }; + + ChromaStack split = one; + split.bands = { band(WHITE_OPAQUE, 2), band(BLACK_TRANSLUCENT, 3), band(BLACK_TRANSLUCENT, 5) }; + + const std::vector a = chroma_palette(one); + const std::vector b = chroma_palette(split); + REQUIRE(a.size() == b.size()); + for (size_t i = 0; i < a.size(); ++i) + REQUIRE_THAT(luminance(a[i]), WithinAbs(luminance(b[i]), 1e-5f)); +} + +TEST_CASE("A translucent filament darkens gradually and an opaque one at once", "[ChromaStack]") +{ + ChromaStack translucent; + translucent.layer_height_mm = 0.08f; + translucent.base_layers = 0; + translucent.bands = { band(WHITE_OPAQUE, 1), band(BLACK_TRANSLUCENT, 12) }; + const std::vector slow = chroma_palette(translucent); + + // Monotonically darker, and still short of the filament's own black after a single layer: that + // gradient is what a ChromaStack stack prints its midtones with. + for (size_t i = 2; i < slow.size(); ++i) + REQUIRE(luminance(slow[i]) < luminance(slow[i - 1])); + REQUIRE(luminance(slow[2]) > 0.5f); + // Twelve layers of a TD 8 filament pass under 3% of the light, which is the dark end of the ramp + // rather than true black - and that is the honest prediction, not a rounding artifact. + REQUIRE(luminance(slow.back()) < 0.25f); + + ChromaStack opaque; + opaque.layer_height_mm = 0.08f; + opaque.base_layers = 0; + opaque.bands = { band(WHITE_OPAQUE, 1), band(BLACK_OPAQUE, 12) }; + const std::vector fast = chroma_palette(opaque); + // TD 1 is opaque within about 0.1 mm, so two layers of it leave little of the white showing. + REQUIRE(luminance(fast[3]) < 0.2f); +} + +TEST_CASE("Solving a picture of the palette's own colors reproduces it exactly", "[ChromaStack]") +{ + ChromaStack stack; + stack.layer_height_mm = 0.08f; + stack.base_layers = 2; + stack.bands = { band(WHITE_OPAQUE, 2), band(BLACK_TRANSLUCENT, 8) }; + + const std::vector palette = chroma_palette(stack); + const int relief = stack.relief_layers(); + + // One pixel per reachable height, in order. + std::vector rgb; + for (int i = 0; i <= relief; ++i) { + const ColorRGB &c = palette[size_t(stack.base_layers + i)]; + rgb.push_back(uint8_t(std::lround(c.r() * 255.f))); + rgb.push_back(uint8_t(std::lround(c.g() * 255.f))); + rgb.push_back(uint8_t(std::lround(c.b() * 255.f))); + } + + const ChromaSolve solve = chroma_solve(rgb.data(), relief + 1, 1, stack); + REQUIRE_FALSE(solve.empty()); + REQUIRE(solve.width == relief + 1); + REQUIRE(solve.levels.size() == size_t(relief + 1)); + // Every pixel comes back as the colour it was generated from, and the error is rounding only. + // + // The colour rather than the height: the bottom band prints in the same filament as the base, so + // its first layers show exactly the colour the base already showed, and any of those heights is a + // correct answer for that pixel. Asserting the height would be asserting which of several equally + // right answers the search happens to reach first. + for (int i = 0; i <= relief; ++i) { + const int chosen = int(solve.levels[size_t(i)]); + REQUIRE(chosen >= stack.base_layers); + REQUIRE(chosen <= stack.total_layers()); + const ColorRGB &got = palette[size_t(chosen)]; + const ColorRGB &want = palette[size_t(stack.base_layers + i)]; + REQUIRE_THAT(got.r(), WithinAbs(want.r(), 2.f / 255.f)); + REQUIRE_THAT(got.g(), WithinAbs(want.g(), 2.f / 255.f)); + REQUIRE_THAT(got.b(), WithinAbs(want.b(), 2.f / 255.f)); + } + // Only 8-bit rounding of the palette separates the two, which near black is still under one dE. + REQUIRE(solve.max_delta_e < 2.); + + // The height map's ends are the base and the top of the stack, which is what ties a grey level to + // a printed layer. + REQUIRE(solve.grey.front() == 0); + REQUIRE(solve.grey.back() == 255); +} + +TEST_CASE("A picture the filaments cannot reach is reported as a poor match", "[ChromaStack]") +{ + ChromaStack stack; + stack.layer_height_mm = 0.08f; + stack.base_layers = 2; + stack.bands = { band(WHITE_OPAQUE, 2), band(BLACK_TRANSLUCENT, 8) }; + + // Saturated red against a stack of white and black: no height can produce it, and the panel's + // warning depends on the solve saying so rather than silently picking a grey. + const std::vector red{ 255, 0, 0 }; + const ChromaSolve solve = chroma_solve(red.data(), 1, 1, stack); + REQUIRE_FALSE(solve.empty()); + REQUIRE(solve.mean_delta_e > 15.); +} + +TEST_CASE("Dithering keeps every pixel in the stack's range", "[ChromaStack]") +{ + ChromaStack stack; + stack.layer_height_mm = 0.08f; + stack.base_layers = 2; + stack.bands = { band(WHITE_OPAQUE, 2), band(BLACK_TRANSLUCENT, 8) }; + stack.dither = true; + + // A flat mid-grey: the error diffusion has to spread it over neighbouring heights without ever + // leaving the stack, which is what a clamped palette index would quietly get wrong. + std::vector grey(size_t(16 * 16) * 3, 128); + const ChromaSolve solve = chroma_solve(grey.data(), 16, 16, stack); + REQUIRE_FALSE(solve.empty()); + REQUIRE(solve.levels.size() == size_t(16 * 16)); + const auto [lo, hi] = std::minmax_element(solve.levels.begin(), solve.levels.end()); + REQUIRE(int(*lo) >= stack.base_layers); + REQUIRE(int(*hi) <= stack.total_layers()); +} + +TEST_CASE("The solved height map is an 8-bit grayscale PNG of the picture's size", "[ChromaStack]") +{ + // The displacement pipeline reads one thing from a layer: an 8-bit grayscale PNG. A solve that + // produced anything else would leave the relief silently flat. + ChromaStack stack; + stack.layer_height_mm = 0.08f; + stack.base_layers = 2; + stack.bands = { band(WHITE_OPAQUE, 2), band(BLACK_TRANSLUCENT, 6) }; + + std::vector picture(size_t(8 * 4) * 3); + for (size_t i = 0; i < picture.size(); ++i) + picture[i] = uint8_t(i % 256); + + const ChromaSolve solve = chroma_solve(picture.data(), 8, 4, stack); + const std::vector png = chroma_encode_height_png(solve); + REQUIRE_FALSE(png.empty()); + + const png::ReadBuf buf{ png.data(), png.size() }; + png::ImageGreyscale decoded; + REQUIRE(png::is_png(buf)); + REQUIRE(png::decode_png(buf, decoded)); + REQUIRE(decoded.cols == 8); + REQUIRE(decoded.rows == 4); + REQUIRE(decoded.buf == solve.grey); +} + +TEST_CASE("Each band becomes a height range, and a repeated filament is not a swap", "[ChromaStack]") +{ + ChromaStack stack; + stack.layer_height_mm = 0.1f; + stack.base_layers = 0; + stack.bands = { band(WHITE_OPAQUE, 5), band(BLACK_OPAQUE, 3), band(BLACK_OPAQUE, 2) }; + + const std::vector ranges = chroma_ranges(stack, 0.); + // Three bands, two filaments: the second and third merge, because nothing is swapped between them. + REQUIRE(ranges.size() == 2); + REQUIRE_THAT(ranges[0].z_min, WithinAbs(0., 1e-9)); + REQUIRE_THAT(ranges[0].z_max, WithinAbs(0.5, 1e-9)); + REQUIRE(ranges[0].filament == WHITE_OPAQUE.filament); + REQUIRE_THAT(ranges[1].z_min, WithinAbs(0.5, 1e-9)); + REQUIRE_THAT(ranges[1].z_max, WithinAbs(1.0, 1e-9)); + REQUIRE(ranges[1].filament == BLACK_OPAQUE.filament); +} + +TEST_CASE("A default stack puts the lightest filament at the bottom", "[ChromaStack]") +{ + // Light to dark is what gives the long tonal ramp; the order is the one thing a seeded stack has + // to get right, since everything else is a layer count the user can drag. + const std::vector loaded{ BLACK_OPAQUE, WHITE_OPAQUE, + filament(3, ColorRGB(0.5f, 0.5f, 0.5f), 4.f) }; + const ChromaStack stack = chroma_default_stack(loaded, 0.08f, 4, 24); + + REQUIRE(stack.bands.size() == 3); + REQUIRE(stack.bands.front().filament.filament == WHITE_OPAQUE.filament); + REQUIRE(stack.bands.back().filament.filament == BLACK_OPAQUE.filament); + REQUIRE(stack.base_layers == 4); + // The base is printed in the bottom band, so the relief is what was asked for and the base sits + // underneath it. + REQUIRE(stack.total_layers() == 4 + 24); + REQUIRE(stack.relief_layers() == 24); + REQUIRE_THAT(stack.layer_height_mm, WithinRel(0.08f)); +} + +TEST_CASE("An empty stack or picture solves to nothing", "[ChromaStack]") +{ + ChromaStack empty; + REQUIRE(chroma_palette(empty).empty()); + REQUIRE(chroma_ranges(empty, 0.).empty()); + + // A base with nothing on it: there is no picture to solve, and no filament to print the base in. + ChromaStack base_only; + base_only.layer_height_mm = 0.08f; + base_only.base_layers = 4; + REQUIRE(base_only.relief_layers() == 0); + REQUIRE(chroma_palette(base_only).empty()); + const std::vector pixel{ 128, 128, 128 }; + REQUIRE(chroma_solve(pixel.data(), 1, 1, base_only).empty()); + + ChromaStack usable; + usable.layer_height_mm = 0.08f; + usable.base_layers = 2; + usable.bands = { band(WHITE_OPAQUE, 2), band(BLACK_OPAQUE, 4) }; + REQUIRE(chroma_solve(nullptr, 4, 4, usable).empty()); +} + +TEST_CASE("The base extends the bottom band's range down to the first layer", "[ChromaStack]") +{ + // The base is not a swap of its own: it prints in whatever is loaded at the start, which is the + // bottom band's filament, so the first range has to reach the build plate. + ChromaStack stack; + stack.layer_height_mm = 0.1f; + stack.base_layers = 20; + stack.bands = { band(WHITE_OPAQUE, 5), band(BLACK_OPAQUE, 5) }; + + const std::vector ranges = chroma_ranges(stack, 0.); + REQUIRE(ranges.size() == 2); + REQUIRE_THAT(ranges[0].z_min, WithinAbs(0., 1e-9)); + REQUIRE_THAT(ranges[0].z_max, WithinAbs(2.5, 1e-9)); // 20 base + 5 band layers at 0.1 mm + REQUIRE_THAT(ranges[1].z_min, WithinAbs(2.5, 1e-9)); + REQUIRE_THAT(ranges[1].z_max, WithinAbs(3.0, 1e-9)); + REQUIRE(ranges.back().z_max == Catch::Approx(double(stack.total_layers()) * double(stack.layer_height_mm))); +} + +TEST_CASE("A surface's color follows the printed layer it sits at", "[ChromaStack]") +{ + // What the preview colours by: a swap happens at one print Z, so the layer a surface sits in is + // what decides its colour, whatever the height map says. + ChromaStack stack; + stack.layer_height_mm = 0.1f; + stack.base_layers = 2; + stack.bands = { band(WHITE_OPAQUE, 3), band(BLACK_OPAQUE, 5) }; + + REQUIRE(chroma_level_at(stack, 0.05) == 1); // inside the first layer + REQUIRE(chroma_level_at(stack, 0.10) == 1); // exactly its top, which that layer printed + REQUIRE(chroma_level_at(stack, 0.15) == 2); + REQUIRE(chroma_level_at(stack, 0.50) == 5); // the top of the base plus the white band + // Clamped at both ends: below the plate and above the stack there is nothing else to show. + REQUIRE(chroma_level_at(stack, -1.) == 1); + REQUIRE(chroma_level_at(stack, 99.) == stack.total_layers()); +} + +TEST_CASE("How fast a filament covers is its TD against the layer height", "[ChromaStack]") +{ + // The number that decides whether a band is a gradient or a step, and the one the panel reports: + // a filament hides what is under it at about a tenth of its TD, so how many layers that is + // depends entirely on how thick they are. + REQUIRE_THAT(chroma_layers_to_opaque(4.f, 0.2f), WithinRel(2.f, 1e-4f)); + REQUIRE_THAT(chroma_layers_to_opaque(4.f, 0.08f), WithinRel(5.f, 1e-4f)); + REQUIRE_THAT(chroma_layers_to_opaque(8.f, 0.08f), WithinRel(10.f, 1e-4f)); + // An opaque filament at a coarse layer height covers within one layer, which is where a stack + // stops having any colours between its filaments at all. + REQUIRE(chroma_layers_to_opaque(1.f, 0.2f) < 1.f); + REQUIRE_THAT(chroma_layers_to_opaque(4.f, 0.f), WithinAbs(0.f, 1e-6f)); +} + +TEST_CASE("A finer layer height buys colors between the filaments", "[ChromaStack]") +{ + // The whole of the user-visible problem in one assertion: the same stack, the same filaments, two + // layer heights. The coarse one steps from white to black in two layers; the fine one fades. + const auto distinct = [](float layer_height) { + ChromaStack stack; + stack.layer_height_mm = layer_height; + stack.base_layers = 0; + stack.bands = { band(WHITE_OPAQUE, 1), band(filament(5, ColorRGB(0.f, 0.f, 0.f), 8.f), 10) }; + const std::vector palette = chroma_palette(stack); + int n = 0; + for (size_t i = 1; i < palette.size(); ++i) + if (std::abs(luminance(palette[i]) - luminance(palette[i - 1])) > 0.02f) + ++n; + return n; + }; + REQUIRE(distinct(0.08f) > distinct(0.3f)); +} + +TEST_CASE("Layers past a filament's covering depth add no color", "[ChromaStack]") +{ + // The band is twice as long as the filament needs to hide what is under it, so half of it shows a + // colour the layer below already showed. That is invisible in the stack and obvious here. + ChromaStack stack; + stack.layer_height_mm = 0.08f; + stack.base_layers = 0; + stack.bands = { band(WHITE_OPAQUE, 1), band(filament(5, ColorRGB(0.f, 0.f, 0.f), 4.f), 10) }; + + // Trimming keeps every layer that still changes the colour and drops the rest, so the stack gets + // shorter and loses none of its colours. + const int distinct = chroma_distinct_levels(stack); + const int before = stack.relief_layers(); + chroma_trim_bands(stack); + REQUIRE(stack.relief_layers() <= before); + REQUIRE(chroma_distinct_levels(stack) == distinct); + // The white band is a single layer and has nothing to give up. + REQUIRE(stack.bands.front().layers == 1); + + // A light band over a dark one is the case where layers really are wasted: it saturates within a + // few layers, and the rest of a long band shows what the layer below already showed. + ChromaStack light_over_dark; + light_over_dark.layer_height_mm = 0.08f; + light_over_dark.base_layers = 0; + light_over_dark.bands = { band(filament(5, ColorRGB(0.f, 0.f, 0.f), 4.f), 1), band(WHITE_OPAQUE, 14) }; + const int kept = chroma_distinct_levels(light_over_dark); + chroma_trim_bands(light_over_dark); + REQUIRE(light_over_dark.bands.back().layers < 14); + REQUIRE(chroma_distinct_levels(light_over_dark) == kept); +}