mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-10-16 12:21:11 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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_
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user