ChromaStack: the colour model, and transmission distance per filament

A picture is reproduced on one face by stacking thin layers of a few
filaments, swapping filament at fixed layer boundaries, and varying how
high each pixel is built up. Filament is translucent, so a column that
stops part-way through a run shows a blend of that run's filament and what
is under it - which is where the colours come from, far more of them than
there are filaments loaded.

libslic3r/ChromaStack.hpp holds the whole model, free of the GUI. A stack
is a list of bands (a filament and its layer count, bottom-up) on a base
of solid backing. chroma_palette() turns it into the colour shown at every
column height, by Beer-Lambert absorption with each filament's
transmission distance; chroma_solve() maps every pixel to the height whose
colour is closest in CIELAB, optionally diffusing the rounding error, and
returns the 8-bit height map and the colours it predicts. Beer-Lambert is
the only model that stays consistent applied per layer, so the palette
does not depend on how the stack was cut into bands, and a test asserts
it.

chroma_layers_to_opaque() and chroma_distinct_levels() are what make a
stack's behaviour legible: how many layers a filament needs to hide what
is under it, and how many of a picture's layers change the colour at all.
chroma_ranges() turns the bands into object height ranges, which is how
the slicer is told to print a Z band in another filament.

Transmission distance is a property of the material, so it is a filament
preset option: filament_transmission_distance, with no effect on slicing.
This commit is contained in:
ExPikaPaka
2026-10-16 11:44:33 +02:00
parent d2675f15d1
commit e0a6000d50
10 changed files with 1103 additions and 3 deletions
+2
View File
@@ -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
+394
View File
@@ -0,0 +1,394 @@
#include "ChromaStack.hpp"
#include <algorithm>
#include <array>
#include <cmath>
#include <limits>
#include <cstdlib>
#include <numeric>
#include <tbb/blocked_range.h>
#include <tbb/parallel_for.h>
#include <miniz.h>
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<ColorRGB> 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<const ChromaFilament *> 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<ColorRGB> 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<float, 3> 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<float, 3> &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<float, 3> 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<ColorRGB> 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<Lab> choice_lab(choice_count);
std::vector<std::array<uint8_t, 3>> 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<float> 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<float>::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<std::array<float, 3>> 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<float, 3> 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<std::array<float, 3>> &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<int>(0, height), [&](const tbb::blocked_range<int> &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<ColorRGB> 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<ColorRGB> 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<unsigned char> 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<const unsigned char *>(png);
std::vector<unsigned char> out(bytes, bytes + size);
mz_free(png);
return out;
}
std::vector<ChromaRange> chroma_ranges(const ChromaStack &stack, double z_bottom)
{
std::vector<ChromaRange> 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<ChromaFilament> &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<ChromaFilament> 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
+286
View File
@@ -0,0 +1,286 @@
#ifndef slic3r_ChromaStack_hpp_
#define slic3r_ChromaStack_hpp_
#include <cstddef>
#include <cstdint>
#include <string>
#include <vector>
#include <cereal/cereal.hpp>
#include <cereal/types/vector.hpp>
#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<class Archive> 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<class Archive> 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<ChromaBand> 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<class Archive> 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<ColorRGB> 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<uint16_t> 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<uint8_t> 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<uint8_t> 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<unsigned char> 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<ChromaRange> 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<ChromaFilament> &filaments, float layer_height_mm,
int base_layers, int relief_layers);
} // namespace Slic3r
#endif // slic3r_ChromaStack_hpp_
+1 -1
View File
@@ -1487,7 +1487,7 @@ static std::vector<std::string> s_Preset_print_options{
static std::vector<std::string> 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",
+1
View File
@@ -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",
+14
View File
@@ -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");
+3
View File
@@ -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))
+22 -2
View File
@@ -18,6 +18,7 @@
#include <array>
#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<std::vector<unsigned char>> chroma_source;
bool empty() const { return !image_data || image_data->empty(); }
template<class Archive> 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<int>(tile_method), static_cast<int>(projection_method), lscm_seam_angle_deg, islands,
static_cast<int>(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<class Archive> 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<std::vector<unsigned char>>(blob.begin(), blob.end());
chroma_source = source.empty() ? nullptr : std::make_shared<std::vector<unsigned char>>(source.begin(), source.end());
tile_method = static_cast<TextureTileMethod>(tile_method_int);
projection_method = static_cast<TextureProjectionMethod>(projection_method_int);
blend_mode = static_cast<TextureBlendMode>(blend_mode_int);
+1
View File
@@ -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
+379
View File
@@ -0,0 +1,379 @@
#include <catch2/catch_approx.hpp>
#include <catch2/catch_test_macros.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp>
#include <algorithm>
#include <cmath>
#include <cstdint>
#include <vector>
#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<ColorRGB> 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<ColorRGB> a = chroma_palette(one);
const std::vector<ColorRGB> 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<ColorRGB> 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<ColorRGB> 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<ColorRGB> palette = chroma_palette(stack);
const int relief = stack.relief_layers();
// One pixel per reachable height, in order.
std::vector<uint8_t> 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<uint8_t> 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<uint8_t> 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<uint8_t> 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<unsigned char> 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<ChromaRange> 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<ChromaFilament> 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<uint8_t> 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<ChromaRange> 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<ColorRGB> 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);
}