Merge branch 'main' into dev/ffmpeg-player

This commit is contained in:
Noisyfox
2026-08-26 21:47:20 +08:00
committed by GitHub
311 changed files with 51625 additions and 3151 deletions

View File

@@ -5,6 +5,7 @@ add_executable(${_TEST_NAME}_tests
test_helpers.hpp
test_cooling.cpp
test_extrusion_entity.cpp
test_extrusion_processor.cpp
test_fill.cpp
test_flow.cpp
test_gcode_timing.cpp
@@ -14,6 +15,7 @@ add_executable(${_TEST_NAME}_tests
test_perimeters.cpp
test_print.cpp
test_printobject.cpp
test_mixed_filament.cpp
test_skirt_brim.cpp
test_slicing_pipeline_hook.cpp
test_support_material.cpp

View File

@@ -0,0 +1,441 @@
#include <catch2/catch_all.hpp>
#include "libslic3r/AABBTreeLines.hpp"
#include "libslic3r/GCode/ExtrusionProcessor.hpp"
#include "libslic3r/GCodeReader.hpp"
#include "libslic3r/TriangleMesh.hpp"
#include "test_helpers.hpp"
#include <algorithm>
#include <cmath>
#include <string>
#include <vector>
using namespace Slic3r;
using namespace Slic3r::Test;
namespace {
// Print settings the assertions below are derived from.
constexpr double caged_layer_height = 0.2; // mm
constexpr double caged_wall_width = 0.42; // mm, outer wall line width
constexpr double caged_outer_wall_speed = 200.; // mm/s
constexpr double caged_slow_speed = 100.; // mm/s, between every configured overhang speed (<= 50) and the wall speed
// A wall running 0.2mm out over a previous layer whose edge dishes 0.03mm away from it in the middle,
// standing in for the endpoint readings a caged overhang perimeter takes: enough of a difference to
// print at another speed, but only a fraction of the distance at which slowdown begins.
constexpr double dished_wall_gap = 0.2; // mm, how far the wall runs out past the previous layer's edge
constexpr double dished_layer_depth = 0.03; // mm, how much further out the middle of it reads
constexpr double dished_min_distance = 0.042; // mm, the reading at which the configured speeds begin to slow down
// Every reading here is past that, so the whole wall is slowed and only the amount is in question.
constexpr float dished_end_reading = float(dished_wall_gap + 0.5 * caged_wall_width);
constexpr float dished_mid_reading = float(dished_end_reading + dished_layer_depth);
// The two readings are dished_layer_depth apart, so half of that tells them apart while still allowing
// for the points the passes after sampling add, which read a little further out than the ends do.
constexpr double dished_reading_tolerance = 0.5 * dished_layer_depth;
// A 40 x 20 x 20 mm box with a 45 degree overhang cut into the y = 0 side. The sloped face spans
// x = 5.086 .. 34.914 only, so the full-height walls of the box cage both ends of every overhang
// perimeter: the endpoints look supported even though the span between them is not.
TriangleMesh caged_overhang_mesh()
{
return TriangleMesh(
{
{5.0859987f, 10.167065f, 5.711731f}, {34.914257f, 10.167065f, 5.711731f},
{34.914257f, 0.f, 15.878796f}, {5.0859995f, 0.f, 15.878796f},
{0.f, 0.f, 0.f}, {0.f, 0.f, 20.f},
{0.f, 20.f, 20.f}, {0.f, 20.f, 0.f},
{40.f, 20.f, 20.f}, {40.f, 20.f, 0.f},
{40.f, 0.f, 20.f}, {40.f, 0.f, 0.f},
{34.914257f, 0.f, 0.f}, {5.0859995f, 0.f, 0.f},
{34.914257f, 10.167065f, 0.f}, {5.0859995f, 10.167065f, 0.f},
},
{
{0, 1, 2}, {0, 2, 3}, {4, 5, 6}, {4, 6, 7}, {7, 6, 8}, {7, 8, 9},
{9, 8, 10}, {9, 10, 11}, {12, 11, 10}, {5, 4, 13}, {5, 13, 3}, {2, 12, 10},
{5, 3, 2}, {10, 5, 2}, {9, 11, 12}, {9, 12, 14}, {13, 4, 7}, {9, 14, 15},
{15, 13, 7}, {7, 9, 15}, {8, 6, 5}, {8, 5, 10}, {14, 1, 0}, {14, 0, 15},
{2, 1, 14}, {2, 14, 12}, {15, 0, 3}, {15, 3, 13},
});
}
// Mesh geometry the wall filters below are derived from.
constexpr double caged_box_depth = 20.; // mm, the box spans y = 0 .. 20
constexpr double caged_slope_face_sum = 15.878796; // mm, y + z of the sloped face, from its corners
// The sloped face spans this x range; outside it the box walls run full height.
constexpr double caged_slope_x_min = 5.0859995;
constexpr double caged_slope_x_max = 34.914257;
constexpr double caged_slope_span = caged_slope_x_max - caged_slope_x_min; // ~29.8 mm
// The z range the sloped face occupies, from the same fixture vertices.
constexpr double caged_slope_z_min = 5.711731;
constexpr double caged_slope_z_max = 15.878796;
// The lowest slope layer still sits on the solid body below the notch, so it is fully supported and
// runs at the outer wall speed by design. The caged span proper begins one layer above it.
constexpr double caged_span_z_min = caged_slope_z_min + caged_layer_height;
// A layer printed at z is sliced at z - layer_height / 2, and the outer wall centreline sits half a
// line width inside the contour, so the wall on the slope satisfies y + z = 16.189.
constexpr double caged_slope_wall_sum = caged_slope_face_sum + 0.5 * caged_layer_height + 0.5 * caged_wall_width;
// Same inset on the fully supported y = 20 face, vertical over the whole height.
constexpr double caged_back_wall_y = caged_box_depth - 0.5 * caged_wall_width;
// And on the y = 0 face, which runs full height only outside the slope's x range.
constexpr double caged_front_wall_y = 0.5 * caged_wall_width;
// Arachne varies the wall width along a face, and the centreline inset is half that width, so a
// wall sits within about half a line width of where the nominal inset alone would put it. The
// faces being selected are millimetres apart, so this stays far from ambiguous.
constexpr double caged_wall_tolerance = 0.5 * caged_wall_width;
// Feed rates in mm/min of the long outer wall extrusions `keep_line` selects.
template<typename KeepLine> std::vector<double> outer_wall_feed_rates(const std::string& gcode, KeepLine keep_line)
{
std::vector<double> feed_rates;
bool outer_wall = false;
GCodeReader parser;
parser.parse_buffer(gcode, [&feed_rates, &outer_wall, &keep_line](GCodeReader& self, const GCodeReader::GCodeLine& line) {
const std::string_view comment = line.comment();
if (comment.find("FEATURE:") != std::string_view::npos || comment.find("TYPE:") != std::string_view::npos)
outer_wall = comment.find("Outer wall") != std::string_view::npos ||
comment.find("External perimeter") != std::string_view::npos;
if (outer_wall && line.extruding(self) && line.dist_XY(self) > 1.0 && keep_line(self, line))
feed_rates.push_back(line.new_F(self));
});
return feed_rates;
}
// The caged 45 degree overhang: outer walls crossing the sloped face for most of its width, on the
// layers where the face genuinely overhangs.
// Both ends are tested against the slope plane rather than requiring a constant Y. Arachne's
// variable-width walls drift slightly in Y along the same slope (Y6.186 -> Y6.189 on one move), so
// a constant-Y filter matches almost nothing under Arachne and silently reduces its coverage.
// The length test excludes the cage walls: they are only as wide as the box is either side of the
// slope, but being vertical their y + z sweeps through the slope plane as z rises, so a couple of
// their fully supported moves would otherwise be counted as part of the span.
std::vector<double> caged_slope_feed_rates(const std::string& gcode)
{
return outer_wall_feed_rates(gcode, [](const GCodeReader& self, const GCodeReader::GCodeLine& line) {
const double z = line.new_Z(self);
return z > caged_span_z_min && z < caged_slope_z_max &&
line.dist_XY(self) > 0.5 * caged_slope_span &&
std::abs(self.y() + z - caged_slope_wall_sum) < caged_wall_tolerance &&
std::abs(line.new_Y(self) + z - caged_slope_wall_sum) < caged_wall_tolerance;
});
}
// The opposite, fully supported face, skipping the initial layer and its own speed settings.
std::vector<double> back_wall_feed_rates(const std::string& gcode)
{
return outer_wall_feed_rates(gcode, [](const GCodeReader& self, const GCodeReader::GCodeLine& line) {
return line.new_Z(self) > 1.5 * caged_layer_height &&
std::abs(self.y() - caged_back_wall_y) < caged_wall_tolerance &&
std::abs(line.new_Y(self) - caged_back_wall_y) < caged_wall_tolerance;
});
}
// The first layer printed entirely above the slope. Its y = 0 wall runs the full width of the box.
const double caged_layer_above_slope_z = std::ceil(caged_slope_z_max / caged_layer_height) * caged_layer_height;
// The parts of that wall standing on the cage rather than the slope, so on a contour identical to their own.
// Where the support changes is found by bisection, which stops at spans of 2mm, so the move spanning each end of
// the slope reaches a little way into the cage. Taking only the moves lying wholly outside the slope's x range
// leaves the wall that is unambiguously supported, without asserting how closely the bisection converged.
std::vector<double> cage_shoulder_feed_rates(const std::string& gcode)
{
return outer_wall_feed_rates(gcode, [](const GCodeReader& self, const GCodeReader::GCodeLine& line) {
return std::abs(line.new_Z(self) - caged_layer_above_slope_z) < 0.5 * caged_layer_height &&
std::abs(self.y() - caged_front_wall_y) < caged_wall_tolerance &&
std::abs(line.new_Y(self) - caged_front_wall_y) < caged_wall_tolerance &&
(std::max(self.x(), line.new_X(self)) <= caged_slope_x_min ||
std::min(self.x(), line.new_X(self)) >= caged_slope_x_max);
});
}
// The readings a 40mm wall takes over a previous layer whose edge falls away by 0.03mm towards the
// middle: both ends read the same, and the middle reads slightly further out over air. Whether that
// middle reading survives is what decides the speed the wall is printed at.
std::vector<ExtendedPoint<2>> sampled_wall_over_dished_layer(const std::function<float(float)>& distance_to_speed)
{
const AABBTreeLines::LinesDistancer<Linef> prev_layer(std::vector<Linef>{
{{0., 0.}, {20., -dished_layer_depth}},
{{20., -dished_layer_depth}, {40., 0.}},
{{40., 0.}, {40., -10.}},
{{40., -10.}, {0., -10.}},
{{0., -10.}, {0., 0.}},
});
const Points wall{Point::new_scale(0., dished_wall_gap), Point::new_scale(40., dished_wall_gap)};
return estimate_points_properties<true, true, true, true>(wall, prev_layer, caged_wall_width, -1.f,
dished_min_distance, distance_to_speed);
}
// A straight, otherwise supported wall over a previous-layer boundary with a 2mm-wide pocket. Moving the
// pocket between x = 10 and x = 20 covers both discovery away from the wall's midpoint and refinement around
// a midpoint that has already been discovered. The current wall is inset half its width from the flat boundary,
// so its supported readings are zero after the estimator applies its boundary offset.
constexpr double narrow_pocket_wall_length = 40.;
constexpr double narrow_pocket_width = 2.;
constexpr double narrow_pocket_depth = 0.3;
std::vector<ExtendedPoint<2>> sampled_wall_over_narrow_pocket(
double pocket_center, const std::function<float(float)>& distance_to_speed)
{
const double pocket_left = pocket_center - 0.5 * narrow_pocket_width;
const double pocket_right = pocket_center + 0.5 * narrow_pocket_width;
const AABBTreeLines::LinesDistancer<Linef> prev_layer(std::vector<Linef>{
{{0., 0.}, {pocket_left, 0.}},
{{pocket_left, 0.}, {pocket_left, -narrow_pocket_depth}},
{{pocket_left, -narrow_pocket_depth}, {pocket_right, -narrow_pocket_depth}},
{{pocket_right, -narrow_pocket_depth}, {pocket_right, 0.}},
{{pocket_right, 0.}, {narrow_pocket_wall_length, 0.}},
{{narrow_pocket_wall_length, 0.}, {narrow_pocket_wall_length, -10.}},
{{narrow_pocket_wall_length, -10.}, {0., -10.}},
{{0., -10.}, {0., 0.}},
});
const double wall_y = -0.5 * caged_wall_width;
const Points wall{Point::new_scale(0., wall_y), Point::new_scale(narrow_pocket_wall_length, wall_y)};
return estimate_points_properties<true, true, true, true>(wall, prev_layer, caged_wall_width, -1.f,
dished_min_distance, distance_to_speed);
}
// A cross section that grows a layer's worth on the two faces meeting at either end of a wall, as any
// 45 degree overhang does. The wall itself stands on a contour identical to its own, but its ends sit
// where the growing faces cut the corners off, and the previous layer's edge there is nearer than the
// half line width the centreline is inset by. Both ends therefore read an overhang while everything
// between them reads supported: the reverse of the caged span, and the case the sampling above must
// leave to the passes after it.
constexpr double stepped_wall_inset = 0.5 * caged_wall_width; // mm, centreline inset from the contour
constexpr double stepped_end_gap = stepped_wall_inset - caged_layer_height; // mm, how far inside the corner ends up
constexpr double stepped_wall_span = 30.; // mm, the length of the wall
std::vector<ExtendedPoint<2>> sampled_wall_between_growing_corners(const std::function<float(float)>& distance_to_speed)
{
const AABBTreeLines::LinesDistancer<Linef> prev_layer(std::vector<Linef>{
{{0., 0.}, {32., 0.}},
{{32., 0.}, {32., -stepped_wall_span}},
{{32., -stepped_wall_span}, {0., -stepped_wall_span}},
{{0., -stepped_wall_span}, {0., 0.}},
});
const Points wall{Point::new_scale(stepped_wall_inset, -stepped_end_gap),
Point::new_scale(stepped_wall_inset, stepped_end_gap - stepped_wall_span)};
return estimate_points_properties<true, true, true, true>(wall, prev_layer, caged_wall_width, -1.f,
dished_min_distance, distance_to_speed);
}
// How much of a path is printed below the speed a fully supported reading gives. A segment is printed
// at the lower of the speeds its ends read.
double slowed_length(const std::vector<ExtendedPoint<2>>& points, const std::function<float(float)>& distance_to_speed)
{
double length = 0.;
for (size_t i = 0; i + 1 < points.size(); ++i)
if (std::min(distance_to_speed(points[i].distance), distance_to_speed(points[i + 1].distance)) < distance_to_speed(0.f))
length += (points[i + 1].position - points[i].position).norm();
return length;
}
float furthest_reading(const std::vector<ExtendedPoint<2>>& points)
{
return std::max_element(points.begin(), points.end(), [](const ExtendedPoint<2>& l, const ExtendedPoint<2>& r) {
return l.distance < r.distance;
})->distance;
}
DynamicPrintConfig caged_overhang_config(const char* wall_generator){
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
config.set_deserialize_strict({
{"nozzle_diameter", "0.4"},
{"initial_layer_print_height", caged_layer_height},
{"layer_height", caged_layer_height},
{"line_width", caged_wall_width},
{"outer_wall_line_width", caged_wall_width},
{"inner_wall_line_width", "0.45"},
{"wall_loops", "2"},
{"wall_generator", wall_generator},
{"wall_sequence", "inner wall/outer wall"},
{"sparse_infill_density", "15%"},
{"detect_overhang_wall", "1"},
{"enable_overhang_speed", "1"},
{"slowdown_for_curled_perimeters", "0"},
{"zaa_enabled", "0"},
{"outer_wall_speed", caged_outer_wall_speed},
{"inner_wall_speed", "300"},
{"overhang_1_4_speed", "0"},
{"overhang_2_4_speed", "50"},
{"overhang_3_4_speed", "30"},
{"overhang_4_4_speed", "10"},
{"bridge_speed", "50"},
{"filament_max_volumetric_speed", "22"},
{"slow_down_for_layer_cooling", "0"},
{"slow_down_layers", "0"}, // Nothing but the overhang settings may lower a wall speed
});
return config;
}
std::string caged_overhang_gcode(const char* wall_generator)
{
Print print;
Model model;
init_print(std::vector<TriangleMesh>{caged_overhang_mesh()}, print, model, caged_overhang_config(wall_generator), nullptr,
false);
return gcode(print);
}
// Reports the matched move count alongside the extremes, so a filter that selected nothing is
// distinguishable from a span that simply was not slowed.
void info_feed_rates(const char* span, const std::vector<double>& feed_rates)
{
UNSCOPED_INFO("matched " << feed_rates.size() << " " << span << " moves");
if (!feed_rates.empty()) {
const auto extremes = std::minmax_element(feed_rates.begin(), feed_rates.end());
UNSCOPED_INFO("slowest " << *extremes.first / MM_PER_MIN << " mm/s, fastest " << *extremes.second / MM_PER_MIN << " mm/s");
}
}
} // namespace
// Classic reproduces the endpoint-sampling bug: it emits the span as one long move whose endpoints
// both read as supported, so endpoint-only sampling never slows it. Arachne's endpoints already read
// as overhanging, but their placement near the cage makes the inferred support vary by layer. Arachne
// parity is therefore part of this regression's scope: both generators must classify the unsupported
// interior of the same 45-degree span consistently.
TEST_CASE("Caged external overhangs are slowed along their span", "[ExtrusionProcessor][Regression]")
{
const char* wall_generator = GENERATE("classic", "arachne");
INFO("wall generator: " << wall_generator);
const std::vector<double> feed_rates = caged_slope_feed_rates(caged_overhang_gcode(wall_generator));
info_feed_rates("caged slope", feed_rates);
REQUIRE_FALSE(feed_rates.empty());
// The endpoint bug left Classic at the full wall speed, while Arachne's cage-adjacent endpoint
// samples selected much faster bands on some layers. The whole span must stay in the slowed range
// for both generators, without requiring their different path segmentations to match.
const double fastest = *std::max_element(feed_rates.begin(), feed_rates.end());
REQUIRE(fastest < caged_slow_speed * MM_PER_MIN);
}
// The other side of the fix: the midpoint probe fires on every long external perimeter, so a
// regression that over-slows would leave the test above green. A fully supported wall must keep the
// speed it was configured with.
TEST_CASE("Supported vertical walls keep their normal speed", "[ExtrusionProcessor][Regression]")
{
const char* wall_generator = GENERATE("classic", "arachne");
INFO("wall generator: " << wall_generator);
const std::vector<double> feed_rates = back_wall_feed_rates(caged_overhang_gcode(wall_generator));
info_feed_rates("back wall", feed_rates);
REQUIRE_FALSE(feed_rates.empty());
const double slowest = *std::min_element(feed_rates.begin(), feed_rates.end());
REQUIRE(slowest >= caged_slow_speed * MM_PER_MIN);
}
// The slope's top edge falls mid layer, so the first layer above it still stands 0.179mm proud of the layer
// below wherever that layer was still on the slope. That is a real overhang and is slowed, but it ends with the
// slope: outside the slope's x range the box runs full height, so the same wall stands on a contour identical to
// its own. Sampling the interior of that wall at a single point reported one support reading for all of it and
// slowed these fully supported ends along with the rest.
TEST_CASE("Wall sections beside a caged overhang keep their normal speed", "[ExtrusionProcessor][Regression]")
{
const char* wall_generator = GENERATE("classic", "arachne");
INFO("wall generator: " << wall_generator);
const std::vector<double> feed_rates = cage_shoulder_feed_rates(caged_overhang_gcode(wall_generator));
info_feed_rates("cage shoulder", feed_rates);
REQUIRE_FALSE(feed_rates.empty());
const double slowest = *std::min_element(feed_rates.begin(), feed_rates.end());
REQUIRE_THAT(slowest / MM_PER_MIN, Catch::Matchers::WithinRel(caged_outer_wall_speed, 0.01));
}
// A wall is printed at the lower of the speeds its ends read, so a reading only earns a point in the
// path where it prints at a different speed from the readings around it. Judging that on the readings
// themselves rather than the speeds they produce was too coarse: the configured speeds interpolate
// between their sections, so readings a fraction of the slowdown threshold apart still print more than
// 10% apart, and a real 45 degree overhang had its true reading dropped as if it agreed with its ends.
// The ends then chose the speed on their own, and being next to the walls either side of the overhang
// they read differently from layer to layer, banding an overhang that should have been uniform.
TEST_CASE("An overhang reading is kept whenever it changes the speed", "[ExtrusionProcessor][Regression]")
{
// A steep speed curve, of the kind the configured overhang speeds interpolate across.
const std::vector<ExtendedPoint<2>> points =
sampled_wall_over_dished_layer([](float distance) { return std::round(200.f - 400.f * distance); });
REQUIRE_THAT(furthest_reading(points), Catch::Matchers::WithinAbs(dished_mid_reading, dished_reading_tolerance));
}
// The complement, and why the readings alone were tempting: a reading that prints at the same speed as
// its neighbours cannot change the G-code, so sampling must leave the path alone however far out it is.
TEST_CASE("An overhang reading is dropped when the speed is unchanged", "[ExtrusionProcessor]")
{
// A flat speed curve, of the kind a single configured overhang speed produces.
const std::vector<ExtendedPoint<2>> points = sampled_wall_over_dished_layer([](float) { return 50.f; });
REQUIRE_THAT(furthest_reading(points), Catch::Matchers::WithinAbs(dished_end_reading, dished_reading_tolerance));
}
TEST_CASE("Coarse probing detects an unsupported pocket away from the wall midpoint",
"[ExtrusionProcessor][Regression]")
{
const std::function<float(float)> distance_to_speed = [](float distance) { return distance <= 0.2f ? 100.f : 50.f; };
const std::vector<ExtendedPoint<2>> points =
sampled_wall_over_narrow_pocket(0.25 * narrow_pocket_wall_length, distance_to_speed);
const double slowed = slowed_length(points, distance_to_speed);
REQUIRE(slowed > 0.);
REQUIRE(slowed < 5.);
}
TEST_CASE("Coarse probing brackets a narrow slowdown at the wall midpoint",
"[ExtrusionProcessor][Regression]")
{
// Half of the pocket reading still maps to full speed. A matching probe in either half therefore must not
// prune that half before a supported point has been found close enough to bracket the slow midpoint.
const std::function<float(float)> distance_to_speed = [](float distance) { return distance <= 0.2f ? 100.f : 50.f; };
const std::vector<ExtendedPoint<2>> points =
sampled_wall_over_narrow_pocket(0.5 * narrow_pocket_wall_length, distance_to_speed);
const double slowed = slowed_length(points, distance_to_speed);
REQUIRE(slowed > 0.);
REQUIRE(slowed < 5.);
}
// Sampling probes the interior, so it must not answer for the ends. On a supported wall between two
// corners that read an overhang, the reading that differs is the end's own, and the pass that ends a
// slowdown an end reads places its point from how far out that end is. Sampling took the difference as
// its own to report and put a point at the nearest position bisection had reached instead, which both
// sits further along the wall and leaves too little of it for that pass to run on, so the corner
// slowdown ran millimetres up an otherwise supported wall. Its length grows with the wall, so on a
// model whose cross section keeps growing it reads as a stair stepped band up the corner.
TEST_CASE("A supported wall between overhanging corners is slowed no further than its ends require",
"[ExtrusionProcessor][Regression]")
{
// A steep speed curve, so the ends and the interior between them print at clearly different speeds.
const std::function<float(float)> distance_to_speed = [](float distance) {
return std::round(float(caged_outer_wall_speed) - 400.f * distance);
};
const double sampled = slowed_length(sampled_wall_between_growing_corners(distance_to_speed), distance_to_speed);
// The same wall with sampling switched off: what the endpoint driven passes alone make of the corners.
const double unsampled = slowed_length(sampled_wall_between_growing_corners({}), distance_to_speed);
// The corners do read an overhang, so there is a slowdown for sampling to have lengthened.
REQUIRE(unsampled > 0.);
REQUIRE(sampled <= unsampled);
}
TEST_CASE("Benchmark caged overhang interior sampling", "[ExtrusionProcessor][!benchmark]"){
const char* wall_generator = GENERATE("classic", "arachne");
BENCHMARK(wall_generator)
{
return caged_overhang_gcode(wall_generator);
};
}

View File

@@ -698,3 +698,327 @@ TEST_CASE("Solid infill direction offsets every layer when no template is set",
CHECK(delta == 30);
}
}
TEST_CASE("Honeycomb infill rounds its cell corners with the smooth factor", "[Fill]")
{
// A cell whose sides are several times the line width, so that the corners have room to be rounded.
const double spacing = 0.45;
const double density = 0.1;
auto fill = [spacing, density](double smooth_factor) {
std::unique_ptr<Slic3r::Fill> filler(Slic3r::Fill::new_from_type("honeycomb"));
filler->spacing = spacing;
FillParams params;
params.density = float(density);
params.dont_adjust = true;
// Keep the fragments apart, so that only the turns of the pattern itself are measured.
params.anchor_length_max = 0.f;
params.smooth_factor = smooth_factor;
Slic3r::ExPolygon square{ Slic3r::Points{
Point::new_scale(0., 0.), Point::new_scale(50., 0.), Point::new_scale(50., 50.), Point::new_scale(0., 50.) } };
Slic3r::Surface surface(stInternal, square);
return filler->fill_surface(&surface, params);
};
// Cosine of the sharpest turn of any of the paths, 1 meaning none of them turns at all.
auto sharpest_turn_cosine = [](const Slic3r::Polylines &polylines) {
double sharpest = 1.;
for (const Polyline &polyline : polylines)
for (size_t i = 1; i + 1 < polyline.size(); ++i) {
const Vec2d incoming = (polyline[i] - polyline[i - 1]).cast<double>().normalized();
const Vec2d outgoing = (polyline[i + 1] - polyline[i]).cast<double>().normalized();
sharpest = std::min(sharpest, incoming.dot(outgoing));
}
return sharpest;
};
auto point_count = [](const Slic3r::Polylines &polylines) {
return std::accumulate(polylines.begin(), polylines.end(), size_t(0),
[](size_t count, const Polyline &polyline) { return count + polyline.size(); });
};
const Slic3r::Polylines sharp = fill(0.);
const Slic3r::Polylines smooth = fill(1.);
REQUIRE(!sharp.empty());
REQUIRE(smooth.size() == sharp.size());
REQUIRE(point_count(smooth) > point_count(sharp));
// The cell corners turn by 60 degrees; smoothing replaces them by gentle curves.
REQUIRE(sharpest_turn_cosine(sharp) < 0.6);
REQUIRE(sharpest_turn_cosine(smooth) > 0.9);
}
// Point count, number of turns sharper than 25 degrees and length of the sparse infill of a print.
// A rounded corner is a run of much gentler turns, so smoothing shows up as fewer sharp ones.
struct SparseInfillShape {
size_t point_count { 0 };
size_t sharp_turns { 0 };
size_t path_count { 0 };
double length { 0. };
// Digest of every point in the order it is printed. The counts above all survive the same
// extrusions being joined into different polylines, so only this tells two such fills apart.
uint64_t sequence { 14695981039346656037ull };
};
static SparseInfillShape sparse_infill_shape(const Print &print)
{
SparseInfillShape shape;
auto account = [&shape](const ExtrusionPath &path) {
if (!sparse_role(path.role()))
return;
const Points3 &pts = path.polyline.points;
++shape.path_count;
shape.point_count += pts.size();
for (const auto &pt : pts)
for (const coord_t coordinate : {pt.x(), pt.y(), pt.z()})
shape.sequence = (shape.sequence ^ uint64_t(coordinate)) * 1099511628211ull;
for (size_t i = 1; i < pts.size(); ++i)
shape.length += (pts[i] - pts[i - 1]).head<2>().cast<double>().norm();
for (size_t i = 1; i + 1 < pts.size(); ++i) {
const Vec2d incoming = (pts[i] - pts[i - 1]).head<2>().cast<double>();
const Vec2d outgoing = (pts[i + 1] - pts[i]).head<2>().cast<double>();
if (incoming.squaredNorm() > 0. && outgoing.squaredNorm() > 0. &&
incoming.normalized().dot(outgoing.normalized()) < 0.9)
++shape.sharp_turns;
}
};
for (const Layer *layer : print.objects().front()->layers())
for (const LayerRegion *region : layer->regions())
for (const ExtrusionEntity *entity : region->fills.flatten().entities) {
if (auto *path = dynamic_cast<const ExtrusionPath *>(entity))
account(*path);
else if (auto *multi = dynamic_cast<const ExtrusionMultiPath *>(entity))
for (const ExtrusionPath &p : multi->paths)
account(p);
else if (auto *loop = dynamic_cast<const ExtrusionLoop *>(entity))
for (const ExtrusionPath &p : loop->paths)
account(p);
}
return shape;
}
TEST_CASE("Lightning infill slices the same model the same way twice", "[Fill][Regression]")
{
// Slicing twice in one process catches a generator that carries state from one slice to the
// next, or whose result depends on how the parallel layer fill interleaves.
auto shape = [] {
Print print;
Slic3r::Test::init_and_process_print({Slic3r::Test::cube(20)}, print,
{{"sparse_infill_pattern", "lightning"},
{"sparse_infill_density", "50%"},
{"layer_height", 0.2}});
return sparse_infill_shape(print);
};
const SparseInfillShape first = shape();
const SparseInfillShape second = shape();
REQUIRE(first.path_count > 0);
REQUIRE(second.path_count == first.path_count);
REQUIRE(second.point_count == first.point_count);
REQUIRE(second.sharp_turns == first.sharp_turns);
// No tolerance: the same extrusions in the same order add up to the very same number.
REQUIRE_THAT(second.length, Catch::Matchers::WithinAbs(first.length, 0.));
// All of the above agree when the same branches are joined into different polylines, so the
// point sequence is what actually decides whether the two slices produced the same infill.
REQUIRE(second.sequence == first.sequence);
}
TEST_CASE("Lightning infill rounds the turns of its branches with the smooth factor", "[Fill]")
{
auto shape_for = [](const std::string &smooth_factor) {
Print print;
Slic3r::Test::init_and_process_print({Slic3r::Test::cube(20)}, print,
{{"sparse_infill_pattern", "lightning"},
{"sparse_infill_density", "15%"},
{"sparse_infill_smooth_factor", smooth_factor},
{"layer_height", 0.2}});
return sparse_infill_shape(print);
};
const SparseInfillShape sharp = shape_for("0%");
const SparseInfillShape smooth = shape_for("100%");
REQUIRE(sharp.point_count > 0);
// The branch turns are replaced by curves, which cut the corners off and take more points to
// describe. The turns where two branches are joined into one path stay sharp.
REQUIRE(smooth.point_count > sharp.point_count);
REQUIRE(smooth.sharp_turns < sharp.sharp_turns);
REQUIRE(smooth.length < sharp.length);
}
TEST_CASE("Concentric infill rounds its loops with the smooth factor", "[Fill]")
{
auto shape_for = [](const std::string &smooth_factor) {
Print print;
Slic3r::Test::init_and_process_print({Slic3r::Test::cube(20)}, print,
{{"sparse_infill_pattern", "concentric"},
{"sparse_infill_density", "20%"},
{"sparse_infill_smooth_factor", smooth_factor},
{"layer_height", 0.2}});
return sparse_infill_shape(print);
};
const SparseInfillShape sharp = shape_for("0%");
const SparseInfillShape smooth = shape_for("100%");
REQUIRE(sharp.point_count > 0);
REQUIRE(smooth.point_count > sharp.point_count);
REQUIRE(smooth.sharp_turns < sharp.sharp_turns);
REQUIRE(smooth.length < sharp.length);
}
TEST_CASE("Cross hatch infill rounds its transition layers with the smooth factor", "[Fill]")
{
auto shape_for = [](const std::string &smooth_factor) {
Print print;
Slic3r::Test::init_and_process_print({Slic3r::Test::cube(20)}, print,
{{"sparse_infill_pattern", "crosshatch"},
{"sparse_infill_density", "20%"},
{"sparse_infill_smooth_factor", smooth_factor},
{"layer_height", 0.2}});
return sparse_infill_shape(print);
};
const SparseInfillShape sharp = shape_for("0%");
const SparseInfillShape smooth = shape_for("100%");
REQUIRE(sharp.point_count > 0);
REQUIRE(smooth.point_count > sharp.point_count);
REQUIRE(smooth.sharp_turns < sharp.sharp_turns);
REQUIRE(smooth.length < sharp.length);
}
TEST_CASE("Trapezoidal grid infill rounds its corners only with more than one line", "[Fill]")
{
auto shape_for = [](int multiline, const std::string &smooth_factor) {
Print print;
Slic3r::Test::init_and_process_print({Slic3r::Test::cube(20)}, print,
{{"sparse_infill_pattern", "grid"},
{"sparse_infill_density", "20%"},
{"fill_multiline", multiline},
{"sparse_infill_smooth_factor", smooth_factor},
{"layer_height", 0.2}});
return sparse_infill_shape(print);
};
const SparseInfillShape sharp = shape_for(2, "0%");
const SparseInfillShape smooth = shape_for(2, "100%");
REQUIRE(sharp.point_count > 0);
REQUIRE(smooth.point_count > sharp.point_count);
REQUIRE(smooth.sharp_turns < sharp.sharp_turns);
REQUIRE(smooth.length < sharp.length);
// A single line per infill wall is the plain crossing line grid, which has no corner of its own.
const SparseInfillShape single_sharp = shape_for(1, "0%");
const SparseInfillShape single_smooth = shape_for(1, "100%");
REQUIRE(single_sharp.point_count > 0);
REQUIRE(single_smooth.point_count == single_sharp.point_count);
REQUIRE(single_smooth.length == single_sharp.length);
}
TEST_CASE("3D honeycomb infill rounds its octahedral waves with the smooth factor", "[Fill]")
{
auto shape_for = [](const std::string &smooth_factor) {
Print print;
Slic3r::Test::init_and_process_print({Slic3r::Test::cube(20)}, print,
{{"sparse_infill_pattern", "3dhoneycomb"},
{"sparse_infill_density", "20%"},
{"sparse_infill_smooth_factor", smooth_factor},
{"layer_height", 0.2}});
return sparse_infill_shape(print);
};
const SparseInfillShape sharp = shape_for("0%");
const SparseInfillShape smooth = shape_for("100%");
REQUIRE(sharp.point_count > 0);
REQUIRE(smooth.point_count > sharp.point_count);
REQUIRE(smooth.sharp_turns < sharp.sharp_turns);
REQUIRE(smooth.length < sharp.length);
}
TEST_CASE("Smoothed concentric infill stays inside the fill region", "[Fill][Regression]")
{
// The concentric loops are offsets of the fill region and are never clipped to it, so a corner
// rounded across its boundary ends up in a hole or over a wall. Rounding cuts toward the inside of
// the turn, which leaves the region at every corner of a hole, and in a region thinner than the
// curve even at a corner turning inwards.
const bool thin_region = GENERATE(false, true);
ExPolygon region;
if (thin_region) {
// An L of two 1.2mm wide arms: cutting the corner they meet at crosses both of them.
region = ExPolygon{ Slic3r::Points{
Point::new_scale(0., 0.), Point::new_scale(20., 0.), Point::new_scale(20., 1.2),
Point::new_scale(1.2, 1.2), Point::new_scale(1.2, 20.), Point::new_scale(0., 20.) } };
} else {
region = ExPolygon{ Slic3r::Points{ Point::new_scale(0., 0.), Point::new_scale(50., 0.),
Point::new_scale(50., 50.), Point::new_scale(0., 50.) },
Slic3r::Points{ Point::new_scale(30., 20.), Point::new_scale(30., 30.),
Point::new_scale(20., 30.), Point::new_scale(20., 20.) } };
}
CAPTURE(thin_region);
auto fill = [&region](double smooth_factor) {
std::unique_ptr<Slic3r::Fill> filler(Slic3r::Fill::new_from_type("concentric"));
filler->spacing = 0.45;
FillParams params;
params.density = 0.1f;
params.dont_adjust = true;
params.smooth_factor = smooth_factor;
Slic3r::Surface surface(stInternal, region);
return filler->fill_surface(&surface, params);
};
auto point_count = [](const Slic3r::Polylines &polylines) {
return std::accumulate(polylines.begin(), polylines.end(), size_t(0),
[](size_t count, const Polyline &polyline) { return count + polyline.size(); });
};
const Slic3r::Polylines sharp = fill(0.);
const Slic3r::Polylines smooth = fill(1.);
REQUIRE(!sharp.empty());
// Nothing leaves the fill region, which the unrounded loops already touch from the inside.
const ExPolygons bounds = offset_ex(region, float(SCALED_EPSILON));
REQUIRE(diff_pl(sharp, bounds).empty());
REQUIRE(diff_pl(smooth, bounds).empty());
// The corners that the region has room for are still rounded.
if (!thin_region)
REQUIRE(point_count(smooth) > point_count(sharp));
}
TEST_CASE("Smoothing multiline lightning infill keeps its outlines connected", "[Fill][Regression]")
{
// With more than one line per infill wall, the branches are printed as outlines drawn around them,
// and the outlines of branches that run close to each other merge into one. Rounding the branches
// before those outlines are built moves them apart, which breaks the merged outlines up into
// separate loops - many more of them, each needing its own travel move.
auto shape_for = [](const std::string &smooth_factor) {
Print print;
Slic3r::Test::init_and_process_print({Slic3r::Test::cube(20)}, print,
{{"sparse_infill_pattern", "lightning"},
{"sparse_infill_density", "50%"},
{"fill_multiline", 2},
{"sparse_infill_smooth_factor", smooth_factor},
{"layer_height", 0.2}});
return sparse_infill_shape(print);
};
const SparseInfillShape sharp = shape_for("0%");
const SparseInfillShape smooth = shape_for("100%");
REQUIRE(sharp.path_count > 0);
// The loop count varies by a loop or two between platforms and between runs, so this is not an
// exact comparison. Smoothing should leave it about where it was; uncapping the smoothing
// reach, the regression this guards against, adds about 10%.
const size_t allowed_extra = sharp.path_count / 50; // 2%
REQUIRE(smooth.path_count <= sharp.path_count + allowed_extra);
// The outlines are still rounded.
REQUIRE(smooth.point_count > sharp.point_count);
REQUIRE(smooth.sharp_turns < sharp.sharp_turns);
}

View File

@@ -7,9 +7,14 @@
#include "test_utils.hpp"
#include <cmath>
#include <fstream>
#include <iomanip>
#include <map>
#include <memory>
#include <sstream>
#include <string>
#include <vector>
using namespace Slic3r;
using Catch::Matchers::WithinAbs;
@@ -418,3 +423,194 @@ TEST_CASE("Per-slot machine limits follow the active nozzle", "[GCodeTiming][Mul
REQUIRE_THAT(times[2], Catch::Matchers::WithinRel(101.0 / 200.0, 0.10));
}
}
// Junction planning decides the speeds the "actual speed" / "actual flow" preview shows. Per-axis
// jerk limits a corner by the largest single-axis component of the velocity change, allowing sqrt(2)
// more speed on a diagonal than on an axis -- a four-lobed ripple around every circle. Klipper and
// Marlin 2 with M205 J plan with junction deviation instead, which sees only the corner angle.
namespace {
// One acceleration everywhere and axis limits far above it, so only the junction model under test
// can slow a corner down.
FullPrintConfig make_junction_config(GCodeFlavor flavor, double corner_velocity, double junction_deviation)
{
FullPrintConfig config;
config.gcode_flavor.value = flavor;
config.filament_diameter.values = {1.75};
config.filament_map.values = {1};
const std::vector<double> accel = {1000.0, 1000.0};
const std::vector<double> axis = {20000.0, 20000.0};
const std::vector<double> speed = {500.0, 500.0};
config.machine_max_acceleration_extruding.values = accel;
config.machine_max_acceleration_travel.values = accel;
config.machine_max_acceleration_retracting.values = accel;
config.machine_max_acceleration_x.values = axis;
config.machine_max_acceleration_y.values = axis;
config.machine_max_acceleration_z.values = axis;
config.machine_max_acceleration_e.values = axis;
config.machine_max_speed_x.values = speed;
config.machine_max_speed_y.values = speed;
config.machine_max_speed_z.values = speed;
config.machine_max_speed_e.values = speed;
// Klipper reads this as the square corner velocity, Marlin as classic jerk.
config.machine_max_jerk_x.values = {corner_velocity, corner_velocity};
config.machine_max_jerk_y.values = {corner_velocity, corner_velocity};
config.machine_max_jerk_z.values = {corner_velocity, corner_velocity};
// Kept out of the way so it never binds in the classic-jerk comparisons.
config.machine_max_jerk_e.values = {100.0, 100.0};
config.machine_max_junction_deviation.values = {junction_deviation, junction_deviation};
config.machine_min_extruding_rate.values = {0.0, 0.0};
config.machine_min_travel_rate.values = {0.0, 0.0};
return config;
}
constexpr double junction_x = 60.0;
constexpr double junction_y = 60.0;
// Two 40mm moves meeting at (junction_x, junction_y) with the given turn, rotated by `orientation`.
// 40mm is long enough to reach the commanded 150mm/s and brake back to any corner speed these tests
// produce. `e_per_mm` of zero makes them travels, which keeps the junction vector purely geometric
// as the formulas below assume.
std::string corner_gcode(double turn_deg, double orientation_deg, double e_per_mm = 0.0)
{
const double len = 40.0;
const double a_in = orientation_deg * M_PI / 180.0;
const double a_out = (orientation_deg + turn_deg) * M_PI / 180.0;
std::ostringstream extrude;
if (e_per_mm > 0.0)
extrude << std::fixed << std::setprecision(4) << " E" << len * e_per_mm;
std::ostringstream os;
os << std::fixed << std::setprecision(4)
<< "M83\n"
<< "G1 Z0.2 F1200\n"
<< "G1 X" << junction_x - len * std::cos(a_in) << " Y" << junction_y - len * std::sin(a_in) << " F6000\n"
<< "G1 X" << junction_x << " Y" << junction_y << extrude.str() << " F9000\n"
<< "G1 X" << junction_x + len * std::cos(a_out) << " Y" << junction_y + len * std::sin(a_out)
<< extrude.str() << " F9000\n";
return os.str();
}
// Speed allowed through the corner: the vertex ending the incoming move carries that block's exit
// speed, and the vertices the actual-speed pass inserts are all strictly interior.
double corner_speed(const GCodeProcessorResult& r)
{
for (const auto& mv : r.moves)
if ((mv.type == EMoveType::Travel || mv.type == EMoveType::Extrude) &&
std::abs(mv.position.x() - junction_x) < 1e-3 &&
std::abs(mv.position.y() - junction_y) < 1e-3)
return mv.actual_feedrate;
return -1.0;
}
double planned_corner_speed(GCodeFlavor flavor, double corner_velocity, double junction_deviation,
double turn_deg, double orientation_deg = 0.0, double e_per_mm = 0.0)
{
GCodeProcessor proc;
run_processor(proc, make_junction_config(flavor, corner_velocity, junction_deviation),
corner_gcode(turn_deg, orientation_deg, e_per_mm).c_str());
return corner_speed(proc.get_result());
}
} // namespace
TEST_CASE("Klipper corners are planned with junction deviation derived from the square corner velocity",
"[GCodeTiming][JunctionDeviation]")
{
// jd = scv^2 * (sqrt(2) - 1) / max_accel, then v^2 = jd * accel * sin(t/2) / (1 - sin(t/2)).
// The acceleration cancels: the corner speed depends only on the scv and the angle.
const double scv = 5.0;
SECTION("a right angle is taken at exactly the square corner velocity") {
// sin(t/2) = sqrt(0.5) at 90 degrees, so v == scv -- the definition of the square corner
// velocity, and what makes the mapping above the right one.
REQUIRE_THAT(planned_corner_speed(gcfKlipper, scv, 0.0, 90.0), Catch::Matchers::WithinRel(scv, 0.02));
}
SECTION("a shallow corner is taken far faster than the per-axis jerk model allows") {
// 6 degrees: sin(t/2) = cos(3 deg), so v = 5 * sqrt((sqrt(2) - 1) * 728.68) = 86.9mm/s. Per-axis
// jerk ignores the angle and caps the velocity *change* (2v*sin(3 deg)), giving 47.8mm/s.
const double jd_speed = planned_corner_speed(gcfKlipper, scv, 0.0, 6.0);
const double jerk_speed = planned_corner_speed(gcfMarlinLegacy, scv, 0.0, 6.0);
REQUIRE_THAT(jd_speed, Catch::Matchers::WithinRel(86.87, 0.02));
REQUIRE_THAT(jerk_speed, Catch::Matchers::WithinRel(47.75, 0.02));
}
}
TEST_CASE("Junction deviation limits a corner by its angle alone, not by its orientation",
"[GCodeTiming][JunctionDeviation]")
{
// The four-lobed ripple on circular walls is per-axis jerk being anisotropic: a velocity change
// lying on an axis gets sqrt(2) less headroom than the same change on the diagonal.
const double scv = 5.0;
const double turn = 6.0;
SECTION("Klipper plans both orientations identically") {
const double on_axis = planned_corner_speed(gcfKlipper, scv, 0.0, turn, 0.0);
const double diagonal = planned_corner_speed(gcfKlipper, scv, 0.0, turn, 45.0);
REQUIRE(on_axis > 0.0);
REQUIRE_THAT(diagonal, Catch::Matchers::WithinRel(on_axis, 0.02));
}
SECTION("the classic jerk model keeps its orientation dependence") {
const double on_axis = planned_corner_speed(gcfMarlinLegacy, scv, 0.0, turn, 0.0);
const double diagonal = planned_corner_speed(gcfMarlinLegacy, scv, 0.0, turn, 45.0);
REQUIRE(on_axis > 0.0);
REQUIRE(diagonal / on_axis > 1.2);
}
}
TEST_CASE("Junction deviation is only used where the firmware actually plans with it",
"[GCodeTiming][JunctionDeviation]")
{
const double jerk = 5.0;
SECTION("Marlin 2 with M205 J disabled keeps the classic jerk planning") {
// machine_max_junction_deviation == 0 is how a Marlin 2 printer says it runs classic jerk.
const double classic = planned_corner_speed(gcfMarlinLegacy, jerk, 0.0, 90.0);
REQUIRE(classic > 0.0);
REQUIRE_THAT(planned_corner_speed(gcfMarlinFirmware, jerk, 0.0, 90.0),
Catch::Matchers::WithinRel(classic, 1e-4));
}
SECTION("Marlin 2 with M205 J enabled switches to junction deviation") {
// sqrt(1000 * 0.05 * 2.4142136) = 11.0mm/s, independent of the jerk values it no longer reads.
REQUIRE_THAT(planned_corner_speed(gcfMarlinFirmware, jerk, 0.05, 90.0),
Catch::Matchers::WithinRel(10.99, 0.02));
}
SECTION("machines without junction deviation are untouched by the jerk values it would ignore") {
// A flavor that never enters the junction deviation path must ignore the setting entirely.
const double without = planned_corner_speed(gcfMarlinLegacy, jerk, 0.0, 90.0);
REQUIRE_THAT(planned_corner_speed(gcfMarlinLegacy, jerk, 0.05, 90.0),
Catch::Matchers::WithinRel(without, 1e-4));
}
}
TEST_CASE("How fast a corner is taken does not depend on how much is extruded through it",
"[GCodeTiming][JunctionDeviation]")
{
// The junction cosine is taken over XYZE, so the direction vectors have to be unit length or the
// E term makes the two paths look more parallel than they are and the corner comes out too fast,
// the more so the higher the flow. Marlin normalizes over XYZE on any extruding move
// (planner.cpp, esteps > 0) and Klipper leaves E out of the cosine altogether
// (toolhead.py::Move.calc_junction); on both, this corner is planned by its geometry alone.
const double scv = 5.0;
const double turn = 6.0;
const double geometric = planned_corner_speed(gcfKlipper, scv, 0.0, turn);
REQUIRE(geometric > 0.0);
// 0.029mm/mm is an ordinary 0.42 x 0.2 line on 1.75mm filament; 0.1 is a fat large-nozzle one.
// Unnormalized these came out at 94.4 and 150.0mm/s against a geometric 86.9.
for (double e_per_mm : {0.029, 0.1})
REQUIRE_THAT(planned_corner_speed(gcfKlipper, scv, 0.0, turn, 0.0, e_per_mm),
Catch::Matchers::WithinRel(geometric, 0.02));
SECTION("and the same holds on Marlin 2") {
const double marlin = planned_corner_speed(gcfMarlinFirmware, scv, 0.05, turn);
REQUIRE(marlin > 0.0);
REQUIRE_THAT(planned_corner_speed(gcfMarlinFirmware, scv, 0.05, turn, 0.0, 0.029),
Catch::Matchers::WithinRel(marlin, 0.02));
}
}

View File

@@ -0,0 +1,324 @@
#include <catch2/catch_all.hpp>
#include "libslic3r/GCode/ToolOrdering.hpp"
#include "libslic3r/MultiNozzleUtils.hpp"
#include "libslic3r/Print.hpp"
#include "test_helpers.hpp"
using namespace Slic3r;
using namespace Slic3r::Test;
namespace {
// Two physical filaments plus one mixed slot (config index 2, 1-based id 3) blending them 60/40.
// The mixed arrays are parallel to filament_colour and must be sized to the filament count.
// Note ConfigOptionBools deserializes on ',' while ConfigOptionStrings uses ';'.
DynamicPrintConfig mixed_config(bool sublayer_on, const char *ratios = "0.6,0.4")
{
DynamicPrintConfig config = multifilament_config(3);
config.set_deserialize_strict({
{"filament_is_mixed", "0,0,1"},
{"filament_mixed_components", ";;1,2"},
{"filament_mixed_sublayer_ratios", std::string(";;") + ratios},
{"filament_mixed_gradient", "0,0,0"},
{"filament_mixed_gradient_range", ";;"},
{"filament_mixed_gradient_curve", ";;"},
{"filament_mixed_gradient_per_part","0,0,0"},
{"enable_mixed_color_sublayer", sublayer_on ? "1" : "0"},
// Assign every region role to the mixed slot so it actually participates in slicing.
{"outer_wall_filament_id", "3"},
{"inner_wall_filament_id", "3"},
{"sparse_infill_filament_id", "3"},
{"internal_solid_filament_id", "3"},
{"top_surface_filament_id", "3"},
{"bottom_surface_filament_id", "3"},
});
return config;
}
// Total sub-layer groups and per-layer mixed-filament resolutions across the whole tool ordering.
void count_mixed(ToolOrdering &to, size_t &groups, size_t &resolutions)
{
groups = resolutions = 0;
for (const LayerTools &lt : to.layer_tools()) {
groups += lt.mixed_sub_layer_groups.size();
resolutions += lt.mixed_filament_resolution.size();
}
}
} // namespace
TEST_CASE("enable_mixed_color_sublayer reaches the Print config", "[MixedFilament]")
{
Print print;
Model model;
init_print({cube(20)}, print, model, mixed_config(true));
// The option lives in PrintConfig; if it did not survive Print::apply the slicer would
// silently fall back to the whole-layer path.
CHECK(print.config().enable_mixed_color_sublayer.value == true);
REQUIRE(print.config().filament_is_mixed.values.size() == 3);
CHECK(print.config().filament_is_mixed.values[2] == true);
REQUIRE(print.config().filament_mixed_components.values.size() == 3);
CHECK(print.config().filament_mixed_components.values[2] == "1,2");
}
TEST_CASE("Mixed filament splits layers into sub-layers when the option is on", "[MixedFilament]")
{
Print print;
Model model;
init_print({cube(20)}, print, model, mixed_config(true));
print.process();
ToolOrdering &to = const_cast<ToolOrdering &>(print.tool_ordering());
REQUIRE(!to.layer_tools().empty());
size_t groups = 0, resolutions = 0;
count_mixed(to, groups, resolutions);
INFO("layers=" << to.layer_tools().size() << " groups=" << groups);
CHECK(groups > 0);
}
TEST_CASE("Mixed filament alternates whole layers when the option is off", "[MixedFilament]")
{
Print print;
Model model;
init_print({cube(20)}, print, model, mixed_config(false));
print.process();
ToolOrdering &to = const_cast<ToolOrdering &>(print.tool_ordering());
REQUIRE(!to.layer_tools().empty());
size_t groups = 0, resolutions = 0;
count_mixed(to, groups, resolutions);
// With splitting off the slot is realized by the deficit round-robin scheduler instead:
// no sub-layer groups, but a per-layer resolution to one physical component.
INFO("layers=" << to.layer_tools().size() << " resolutions=" << resolutions);
CHECK(groups == 0);
CHECK(resolutions > 0);
}
TEST_CASE("Sub-layer splitting emits the scaled sub-heights into G-code", "[MixedFilament]")
{
// layer_height 0.2 split 60/40 gives sub-layers of 0.12 and 0.08. The emitter reports the
// sub-height (not the nominal layer height) in the HEIGHT tag and scales flow to match.
DynamicPrintConfig config = mixed_config(true);
config.set_deserialize_strict({{"layer_height", "0.2"}, {"initial_layer_print_height", "0.2"}});
Print print;
Model model;
init_print({cube(20)}, print, model, config);
print.process();
const std::string gc = Slic3r::Test::gcode(print);
REQUIRE(!gc.empty());
INFO("gcode bytes=" << gc.size());
CHECK(gc.find(";HEIGHT:0.12") != std::string::npos);
CHECK(gc.find(";HEIGHT:0.08") != std::string::npos);
}
TEST_CASE("Whole-layer mixing emits only the nominal layer height", "[MixedFilament]")
{
DynamicPrintConfig config = mixed_config(false);
config.set_deserialize_strict({{"layer_height", "0.2"}, {"initial_layer_print_height", "0.2"}});
Print print;
Model model;
init_print({cube(20)}, print, model, config);
print.process();
const std::string gc = Slic3r::Test::gcode(print);
REQUIRE(!gc.empty());
// No sub-layer split, so the 60/40 sub-heights must never appear.
CHECK(gc.find(";HEIGHT:0.12") == std::string::npos);
CHECK(gc.find(";HEIGHT:0.08") == std::string::npos);
}
TEST_CASE("By-object prints without mixed filaments keep their used-filament set", "[MixedFilament]")
{
// With no mixed slot the by-object bookkeeping stays plain: object 2 prints with filament 2,
// so both filaments are used and no mixed filament is reported.
DynamicPrintConfig config = multifilament_config(2, {{"print_sequence", "by object"}});
const std::vector<std::vector<ConfigBase::SetDeserializeItem>> overrides{ {}, { {"extruder", "2"} } };
Print print;
Model model;
init_print(std::vector<TriangleMesh>{cube(20), cube(20)}, print, model, config, &overrides);
REQUIRE(print.objects().size() == 2);
print.process();
CHECK(print.get_slice_used_filaments(false) == std::vector<unsigned int>{0, 1});
CHECK(print.get_slice_used_filaments(true) == std::vector<unsigned int>{0, 1});
CHECK(print.get_slice_used_mixed_filaments().empty());
}
TEST_CASE("By-layer prints record a mixed slot's components and the slot itself", "[MixedFilament]")
{
// Control for the by-object case below: the by-layer path publishes the physical
// components (0-based 0 and 1) as used filaments and the mixed slot (config index 2) as
// a used mixed filament. By-object prints must report exactly the same.
Print print;
Model model;
init_print({cube(20)}, print, model, mixed_config(false));
print.process();
CHECK(print.get_slice_used_filaments(false) == std::vector<unsigned int>{0, 1});
CHECK(print.get_slice_used_mixed_filaments() == std::vector<unsigned int>{2});
}
TEST_CASE("By-object prints expand a mixed slot to its components in the slice bookkeeping", "[MixedFilament]")
{
// Sequential prints build their filament lists from unsorted per-object orderings, which
// still carry the virtual slot (config index 2). The slice-used sets and the published
// grouping result must see the physical components 0 and 1 instead, and the slot itself
// must still be reported as a used mixed filament — exactly what the by-layer path yields.
DynamicPrintConfig config = mixed_config(false);
config.set_deserialize_strict({{"print_sequence", "by object"}});
Print print;
Model model;
init_print({cube(20), cube(20)}, print, model, config);
REQUIRE(print.objects().size() == 2);
print.process();
const std::vector<unsigned int> components{0, 1};
CHECK(print.get_slice_used_filaments(false) == components);
CHECK(print.get_slice_used_filaments(true) == components);
CHECK(print.get_slice_used_mixed_filaments() == std::vector<unsigned int>{2});
auto group_result = print.get_layered_nozzle_group_result();
REQUIRE(group_result != nullptr);
CHECK(group_result->get_used_filaments() == components);
}
TEST_CASE("By-object G-code lists a mixed slot's components in the filament header", "[MixedFilament]")
{
DynamicPrintConfig config = mixed_config(false);
config.set_deserialize_strict({{"print_sequence", "by object"}});
Print print;
Model model;
init_print({cube(20), cube(20)}, print, model, config);
const std::string gc = Slic3r::Test::gcode(print);
REQUIRE(!gc.empty());
// The header names the filaments that must be loaded (components 1 and 2, 1-based),
// never the virtual slot 3.
CHECK(gc.find("; filament: 1,2\n") != std::string::npos);
CHECK(gc.find("; filament: 3") == std::string::npos);
}
TEST_CASE("Print::validate rejects a mixed filament as the wipe tower filament", "[MixedFilament]")
{
// The validate backstop refuses a mixed (virtual) slot as the wipe tower filament; the GUI hides
// the slot from that option. Two cubes on physical filaments 1 and 2 make the tower real, and the
// region roles mixed_config() points at the slot are reset so only the tower uses it.
DynamicPrintConfig config = mixed_config(false);
config.set_deserialize_strict({
{"enable_prime_tower", "1"},
{"wipe_tower_x", "50"}, // inside the 200x200 test bed
{"wipe_tower_y", "50"}, // (the default y, 220, is not)
{"layer_change_gcode", "G92 E0\n"}, // validate() relative-E reset, as in test_print.cpp's build_cubes
{"outer_wall_filament_id", "0"},
{"inner_wall_filament_id", "0"},
{"sparse_infill_filament_id", "0"},
{"internal_solid_filament_id", "0"},
{"top_surface_filament_id", "0"},
{"bottom_surface_filament_id", "0"},
});
const std::vector<std::vector<ConfigBase::SetDeserializeItem>> overrides{ { {"extruder", "1"} }, { {"extruder", "2"} } };
SECTION("a physical wipe tower filament validates") {
config.set_deserialize_strict({{"wipe_tower_filament", "2"}});
Print print;
Model model;
init_print(std::vector<TriangleMesh>{cube(20), cube(20)}, print, model, config, &overrides);
REQUIRE(print.has_wipe_tower());
const StringObjectException err = print.validate();
INFO(err.string);
CHECK(err.string.empty());
}
SECTION("the mixed slot is refused") {
config.set_deserialize_strict({{"wipe_tower_filament", "3"}});
Print print;
Model model;
init_print(std::vector<TriangleMesh>{cube(20), cube(20)}, print, model, config, &overrides);
REQUIRE(print.has_wipe_tower());
const StringObjectException err = print.validate();
CHECK_FALSE(err.string.empty());
CHECK(err.opt_key == "wipe_tower_filament");
}
}
TEST_CASE("Print::validate warns when a gradient mixed filament is used without sublayer mixing", "[MixedFilament]")
{
// A gradient mixed filament only renders its gradient with the process option enabled; without
// it ToolOrdering prints one whole component per layer and the gradient is dropped silently,
// so validate() warns whenever the slot actually takes part in the print. The layer-change
// reset avoids an unrelated relative-extrusion warning, as in the wipe tower test above.
DynamicPrintConfig config = mixed_config(false);
config.set_deserialize_strict({
{"filament_mixed_gradient", "0,0,1"},
{"layer_change_gcode", "G92 E0\n"},
});
auto count_opt = [](Print &print, const char *opt_key) {
std::vector<StringObjectException> warnings;
print.validate(&warnings);
return std::count_if(warnings.begin(), warnings.end(),
[&](const StringObjectException &w) { return w.opt_key == opt_key; });
};
SECTION("gradient slot used, sublayer mixing off") {
Print print;
Model model;
init_print({cube(20)}, print, model, config);
std::vector<StringObjectException> warnings;
const StringObjectException err = print.validate(&warnings);
CHECK(err.string.empty());
const auto it = std::find_if(warnings.begin(), warnings.end(), [](const StringObjectException &w) {
return w.opt_key == "enable_mixed_color_sublayer";
});
REQUIRE(it != warnings.end());
CHECK(it->is_warning);
CHECK(std::count_if(warnings.begin(), warnings.end(), [](const StringObjectException &w) {
return w.opt_key == "enable_mixed_color_sublayer";
}) == 1);
}
SECTION("sublayer mixing on") {
config.set_deserialize_strict({{"enable_mixed_color_sublayer", "1"}});
Print print;
Model model;
init_print({cube(20)}, print, model, config);
CHECK(count_opt(print, "enable_mixed_color_sublayer") == 0);
}
SECTION("gradient flag off") {
config.set_deserialize_strict({{"filament_mixed_gradient", "0,0,0"}});
Print print;
Model model;
init_print({cube(20)}, print, model, config);
CHECK(count_opt(print, "enable_mixed_color_sublayer") == 0);
}
SECTION("mixed slot not used") {
config.set_deserialize_strict({
{"outer_wall_filament_id", "0"},
{"inner_wall_filament_id", "0"},
{"sparse_infill_filament_id", "0"},
{"internal_solid_filament_id", "0"},
{"top_surface_filament_id", "0"},
{"bottom_surface_filament_id", "0"},
});
Print print;
Model model;
const std::vector<std::vector<ConfigBase::SetDeserializeItem>> overrides{{{ "extruder", "1" }}};
init_print(std::vector<TriangleMesh>{cube(20)}, print, model, config, &overrides);
CHECK(count_opt(print, "enable_mixed_color_sublayer") == 0);
}
}

View File

@@ -17,7 +17,10 @@ add_executable(${_TEST_NAME}_tests
test_preset_bundle_loading.cpp
test_preset_setting_id.cpp
test_preset_diff.cpp
test_vendor_cache.cpp
test_elephant_foot_compensation.cpp
test_fill_corner_smoothing.cpp
test_filament_mixer.cpp
test_fill_plane_path.cpp
test_geometry.cpp
test_multimaterial_segmentation.cpp
@@ -27,6 +30,7 @@ add_executable(${_TEST_NAME}_tests
test_mutable_priority_queue.cpp
test_nozzle_volume_type.cpp
test_stl.cpp
test_triangle_selector.cpp
test_meshboolean.cpp
test_marchingsquares.cpp
test_model.cpp

View File

@@ -1,5 +1,6 @@
#include "libslic3r/Model.hpp"
#include "libslic3r/TriangleSelector.hpp"
#include "libslic3r/Format/3mf.hpp"
#include "libslic3r/Format/bbs_3mf.hpp"
#include "libslic3r/Format/STL.hpp"
@@ -497,3 +498,95 @@ SCENARIO("Nozzle-group metadata .3mf round-trip", "[3mf][MultiNozzle]") {
delete plate;
}
}
// A mixed-color filament occupies an ordinary filament slot, and painting with it stores an
// ordinary extruder state: a project saved by BambuStudio encodes filament 5 of a 5-slot setup
// as paint state 5, with the mix described by the parallel filament_mixed_* project arrays.
SCENARIO("Mixed-color filament setup and painting round-trip through a .3mf", "[3mf][MixedFilament]") {
GIVEN("a painted model whose project config describes a mixed filament in the last slot") {
Model model;
std::string src_file = std::string(TEST_DATA_DIR) + "/test_3mf/Prusa.stl";
REQUIRE(load_stl(src_file.c_str(), &model));
model.add_default_instances();
// Both the exporter and the importer stage Metadata/project_settings.config through the
// model's backup path; point them at writable temp dirs.
ScopedTemporaryDir backup_dir("orca_mixed_src");
model.set_backup_path(backup_dir.string());
ModelVolume* mv = model.objects.front()->volumes.front();
{
TriangleSelector selector(mv->mesh());
selector.set_facet(0, EnforcerBlockerType::Extruder5); // the mixed slot
selector.set_facet(1, EnforcerBlockerType::Extruder2);
REQUIRE(mv->mmu_segmentation_facets.set(selector));
}
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
config.set_key_value("filament_colour", new ConfigOptionStrings(
{ "#00AE42", "#FFFF00", "#FF0000", "#0000FF", "#FF6A26" }));
config.set_key_value("filament_is_mixed", new ConfigOptionBools(
{ false, false, false, false, true }));
config.set_key_value("filament_mixed_components", new ConfigOptionStrings(
{ "", "", "", "", "3,2" }));
config.set_key_value("filament_mixed_sublayer_ratios", new ConfigOptionStrings(
{ "", "", "", "", "0.4200,0.5800" }));
WHEN("stored to and reloaded from a .3mf") {
ScopedTemporaryFile temp(".3mf");
const std::string test_file = temp.string();
PlateData* plate = new PlateData();
plate->plate_index = 0;
StoreParams store_params;
store_params.path = test_file.c_str();
store_params.model = &model;
store_params.config = &config;
store_params.strategy = SaveStrategy::Zip64 | SaveStrategy::Silence;
store_params.plate_data_list.push_back(plate);
REQUIRE(store_bbs_3mf(store_params));
Model dst_model;
ScopedTemporaryDir dst_backup_dir("orca_mixed_dst");
dst_model.set_backup_path(dst_backup_dir.string());
DynamicPrintConfig dst_config;
ConfigSubstitutionContext ctxt{ ForwardCompatibilitySubstitutionRule::Enable };
PlateDataPtrs dst_plates;
std::vector<Preset*> project_presets;
bool is_bbl_3mf = false, is_orca_3mf = false;
Semver file_version;
REQUIRE(load_bbs_3mf(test_file.c_str(), &dst_config, &ctxt, &dst_model, &dst_plates,
&project_presets, &is_bbl_3mf, &is_orca_3mf, &file_version, nullptr,
LoadStrategy::LoadModel | LoadStrategy::LoadConfig));
THEN("the mixed-filament project keys survive") {
auto* is_mixed = dst_config.option<ConfigOptionBools>("filament_is_mixed");
REQUIRE(is_mixed != nullptr);
REQUIRE(is_mixed->values == std::vector<unsigned char>({ 0, 0, 0, 0, 1 }));
auto* components = dst_config.option<ConfigOptionStrings>("filament_mixed_components");
REQUIRE(components != nullptr);
REQUIRE(components->values.size() == 5);
REQUIRE(components->values[4] == "3,2");
auto* ratios = dst_config.option<ConfigOptionStrings>("filament_mixed_sublayer_ratios");
REQUIRE(ratios != nullptr);
REQUIRE(ratios->values.size() == 5);
REQUIRE(ratios->values[4] == "0.4200,0.5800");
}
THEN("the painted facets survive, including the one painted with the mixed slot") {
REQUIRE(dst_model.objects.size() == 1);
ModelVolume* dst_mv = dst_model.objects.front()->volumes.front();
REQUIRE_FALSE(dst_mv->mmu_segmentation_facets.empty());
REQUIRE(dst_mv->mmu_segmentation_facets.has_facets(*dst_mv, EnforcerBlockerType::Extruder2));
REQUIRE(dst_mv->mmu_segmentation_facets.has_facets(*dst_mv, EnforcerBlockerType::Extruder5));
}
release_PlateData_list(dst_plates);
delete plate; // store_bbs_3mf does not take ownership of the source plate
}
}
}

View File

@@ -235,6 +235,56 @@ SCENARIO("Config ini load/save interface", "[Config]") {
}
}
TEST_CASE("Flush-volume warning predicate respects used filament transitions", "[Config][Regression]")
{
const std::vector<double> multipliers = {1.0};
SECTION("Single used filament does not trigger warning with zero transition entries")
{
const std::vector<double> matrix = {
0.0, 0.0,
0.0, 0.0
};
const std::vector<int> used_filaments = {1};
REQUIRE_FALSE(has_zero_flush_volume_for_used_filaments(matrix, multipliers, used_filaments));
}
SECTION("Two used filaments trigger warning when transition flush entry is zero")
{
const std::vector<double> matrix = {
0.0, 0.0,
0.0, 0.0
};
const std::vector<int> used_filaments = {1, 2};
REQUIRE(has_zero_flush_volume_for_used_filaments(matrix, multipliers, used_filaments));
}
SECTION("Two used filaments do not trigger warning when transitions are non-zero")
{
const std::vector<double> matrix = {
0.0, 280.0,
280.0, 0.0
};
const std::vector<int> used_filaments = {1, 2};
REQUIRE_FALSE(has_zero_flush_volume_for_used_filaments(matrix, multipliers, used_filaments));
}
SECTION("Zero multiplier still triggers warning when multiple filaments are used")
{
const std::vector<double> matrix = {
0.0, 280.0,
280.0, 0.0
};
const std::vector<double> zero_multiplier = {0.0};
const std::vector<int> used_filaments = {1, 2};
REQUIRE(has_zero_flush_volume_for_used_filaments(matrix, zero_multiplier, used_filaments));
}
}
// TODO: https://github.com/SoftFever/OrcaSlicer/issues/11269 - Is this test still relevant? Delete if not.
// It was failing so at least "nozzle_type" and "extruder_printable_area" could not be serialized
// and an exception was thrown, but "nozzle_type" has been around for at least 3 months now.

View File

@@ -0,0 +1,205 @@
#include <catch2/catch_all.hpp>
#include "libslic3r/FilamentMixer.hpp"
#include "libslic3r/PrintConfig.hpp"
using namespace Slic3r;
TEST_CASE("parse_mixed_components reads 1-based component ids", "[FilamentMixer]")
{
REQUIRE(parse_mixed_components("1,3") == std::vector<unsigned int>{1, 3});
REQUIRE(parse_mixed_components("2, 4 ,5") == std::vector<unsigned int>{2, 4, 5});
SECTION("Malformed input yields no components") {
REQUIRE(parse_mixed_components("").empty());
REQUIRE(parse_mixed_components("abc").empty());
}
}
TEST_CASE("parse_mixed_ratios normalizes to sum 1.0", "[FilamentMixer]")
{
auto r = parse_mixed_ratios("0.7,0.3", 2);
REQUIRE(r.size() == 2);
REQUIRE_THAT(r[0], Catch::Matchers::WithinAbs(0.7, 1e-9));
REQUIRE_THAT(r[1], Catch::Matchers::WithinAbs(0.3, 1e-9));
SECTION("Unnormalized input is rescaled") {
auto v = parse_mixed_ratios("2,2", 2);
REQUIRE_THAT(v[0], Catch::Matchers::WithinAbs(0.5, 1e-9));
REQUIRE_THAT(v[1], Catch::Matchers::WithinAbs(0.5, 1e-9));
}
SECTION("Empty or mismatched input falls back to equal shares") {
auto v = parse_mixed_ratios("", 3);
REQUIRE(v.size() == 3);
for (double x : v)
REQUIRE_THAT(x, Catch::Matchers::WithinAbs(1.0 / 3.0, 1e-9));
}
}
TEST_CASE("has_any_mixed_filament detects mixed slots", "[FilamentMixer]")
{
REQUIRE_FALSE(has_any_mixed_filament({}));
REQUIRE_FALSE(has_any_mixed_filament({0, 0, 0}));
REQUIRE(has_any_mixed_filament({0, 1, 0}));
}
TEST_CASE("expand_mixed_filaments replaces mixed slots with their components", "[FilamentMixer]")
{
// Slot 2 (0-based) is a mix of physical filaments 1 and 2 (1-based) => 0 and 1 (0-based).
const std::vector<unsigned char> is_mixed = {0, 0, 1};
const std::vector<std::string> comp_strs = {"", "", "1,2"};
REQUIRE(expand_mixed_filaments({2}, is_mixed, comp_strs) == std::vector<unsigned int>{0, 1});
SECTION("Non-mixed entries pass through, result is sorted and deduplicated") {
REQUIRE(expand_mixed_filaments({2, 0}, is_mixed, comp_strs) == std::vector<unsigned int>{0, 1});
}
}
TEST_CASE("check_mixed_filament_integrity flags dangling component references", "[FilamentMixer]")
{
const std::vector<unsigned char> is_mixed = {0, 0, 1};
SECTION("All components resolve") {
REQUIRE(check_mixed_filament_integrity(is_mixed, {"", "", "1,2"}, 2).empty());
}
SECTION("A component past the physical filament count is broken") {
auto broken = check_mixed_filament_integrity(is_mixed, {"", "", "1,9"}, 2);
REQUIRE(broken == std::vector<size_t>{2});
}
}
TEST_CASE("remap_mixed_components_on_delete rewrites ids around the deleted slot", "[FilamentMixer]")
{
const std::vector<unsigned char> is_mixed = {0, 0, 0, 1};
std::vector<std::string> comps = {"", "", "", "1,3"};
SECTION("Deleting a filament below the references shifts them down") {
remap_mixed_components_on_delete(is_mixed, comps, 2);
REQUIRE(comps[3] == "1,2");
}
SECTION("Deleting a referenced filament zeroes that component") {
remap_mixed_components_on_delete(is_mixed, comps, 1);
// 1 -> 0 (deleted sentinel), 3 -> 2
REQUIRE(comps[3] == "0,2");
}
}
TEST_CASE("check_mixed_filament_type_consistency flags mismatched component types", "[FilamentMixer]")
{
const std::vector<unsigned char> is_mixed = {0, 0, 1};
const std::vector<std::string> comp_strs = {"", "", "1,2"};
REQUIRE(check_mixed_filament_type_consistency(is_mixed, comp_strs, {"PLA", "PLA"}).empty());
auto bad = check_mixed_filament_type_consistency(is_mixed, comp_strs, {"PLA", "PETG"});
REQUIRE(bad == std::vector<size_t>{2});
}
TEST_CASE("a support-flagged component reads as its own filament type for the consistency check", "[FilamentMixer]")
{
// The sidebar derives each component's type through DynamicPrintConfig::get_filament_type,
// which folds filament_is_support into the type, so toggling that flag alone flips the
// verdict and the mixed filament list has to be refreshed on filament_is_support too.
DynamicPrintConfig plain_pla;
plain_pla.set_key_value("filament_type", new ConfigOptionStrings({"PLA"}));
plain_pla.set_key_value("filament_is_support", new ConfigOptionBools({false}));
std::string displayed;
REQUIRE(plain_pla.get_filament_type(displayed) == "PLA");
DynamicPrintConfig support_pla;
support_pla.set_key_value("filament_type", new ConfigOptionStrings({"PLA"}));
support_pla.set_key_value("filament_is_support", new ConfigOptionBools({true}));
REQUIRE(support_pla.get_filament_type(displayed) == "PLA-S");
REQUIRE(displayed == "Sup.PLA");
const std::vector<unsigned char> is_mixed = {0, 0, 1};
const std::vector<std::string> comp_strs = {"", "", "1,2"};
REQUIRE(check_mixed_filament_type_consistency(is_mixed, comp_strs, {"PLA", "PLA-S"}) == std::vector<size_t>{2});
}
TEST_CASE("gradient curves round-trip and sample monotonically", "[FilamentMixer]")
{
SECTION("Empty input yields an empty curve") {
REQUIRE(parse_gradient_curve("").empty());
REQUIRE(serialize_gradient_curve(GradientCurve{}).empty());
}
SECTION("Legacy 2-field anchors survive a parse/serialize round trip") {
GradientCurve c = parse_gradient_curve("0,0.15|0.5,0.5|1,0.85");
REQUIRE(c.points.size() == 3);
// Anchors with no tangent override serialize back to the 2-field legacy form
// (canonical fixed-precision, so compare by re-parsing rather than by string).
const std::string round_tripped = serialize_gradient_curve(c);
REQUIRE(round_tripped.find(",nan") == std::string::npos);
GradientCurve c2 = parse_gradient_curve(round_tripped);
REQUIRE(c2.points.size() == c.points.size());
for (size_t i = 0; i < c.points.size(); ++i) {
REQUIRE_THAT(c2.points[i].x, Catch::Matchers::WithinAbs(c.points[i].x, 1e-4));
REQUIRE_THAT(c2.points[i].y, Catch::Matchers::WithinAbs(c.points[i].y, 1e-4));
}
}
SECTION("Sampling is clamped at the ends and monotone in between") {
GradientCurve c = parse_gradient_curve("0,0.15|0.5,0.5|1,0.85");
REQUIRE_THAT(sample_gradient_curve(c, 0.0), Catch::Matchers::WithinAbs(0.15, 1e-9));
REQUIRE_THAT(sample_gradient_curve(c, 1.0), Catch::Matchers::WithinAbs(0.85, 1e-9));
// Outside the control point range the end values are held.
REQUIRE_THAT(sample_gradient_curve(c, -1.0), Catch::Matchers::WithinAbs(0.15, 1e-9));
REQUIRE_THAT(sample_gradient_curve(c, 2.0), Catch::Matchers::WithinAbs(0.85, 1e-9));
double prev = sample_gradient_curve(c, 0.0);
for (int i = 1; i <= 20; ++i) {
double v = sample_gradient_curve(c, i / 20.0);
REQUIRE(v >= prev - 1e-9);
prev = v;
}
}
SECTION("A curve with fewer than two points falls back to 0.5") {
GradientCurve c = parse_gradient_curve("0.5,0.7");
REQUIRE_THAT(sample_gradient_curve(c, 0.3), Catch::Matchers::WithinAbs(0.5, 1e-9));
}
}
TEST_CASE("blend_color mixes two hex colors", "[FilamentMixer]")
{
// ratio 0 keeps the first color, ratio 1 the second.
REQUIRE(blend_color("#FF0000", "#0000FF", 0.0f) == "#FF0000");
REQUIRE(blend_color("#FF0000", "#0000FF", 1.0f) == "#0000FF");
SECTION("Blue and yellow make green, not grey (pigment mixing)") {
// The polynomial model approximates subtractive pigment behaviour.
std::string mixed = blend_color("#0021D0", "#FCD300", 0.5f);
REQUIRE(mixed.size() == 7);
REQUIRE(mixed[0] == '#');
auto comp = [&](int i) { return std::stoi(mixed.substr(1 + 2 * i, 2), nullptr, 16); };
// Green channel should dominate red and blue.
REQUIRE(comp(1) > comp(0));
REQUIRE(comp(1) > comp(2));
}
}
TEST_CASE("blend_color_multi weights components", "[FilamentMixer]")
{
SECTION("A single component is returned unchanged") {
REQUIRE(blend_color_multi({"#FF0000"}, {1}) == "#FF0000");
}
SECTION("Mixing a color with itself stays close to that color") {
// The mixer is a degree-4 polynomial fit of pigment behaviour, so mixing a color with
// itself lands near it rather than exactly on it; allow a small per-channel drift.
std::string mixed = blend_color_multi({"#123456", "#123456"}, {1, 1});
REQUIRE(mixed.size() == 7);
auto comp = [](const std::string &hex, int i) {
return std::stoi(hex.substr(1 + 2 * i, 2), nullptr, 16);
};
for (int i = 0; i < 3; ++i)
REQUIRE(std::abs(comp(mixed, i) - comp("#123456", i)) <= 8);
}
}

View File

@@ -0,0 +1,194 @@
#include <catch2/catch_all.hpp>
#include <algorithm>
#include <cmath>
#include <limits>
#include "libslic3r/Fill/FillCornerSmoothing.hpp"
#include "libslic3r/Polyline.hpp"
#include "libslic3r/libslic3r.h"
using namespace Slic3r;
namespace {
// A right angle turn, with the outgoing leg ten times longer than the incoming one.
Polyline asymmetric_corner()
{
return Polyline{ Point::new_scale(0., 0.), Point::new_scale(10., 0.), Point::new_scale(10., 100.) };
}
double max_turn_cosine(const Polyline &polyline)
{
double sharpest = 1.;
for (size_t i = 1; i + 1 < polyline.size(); ++i) {
const Vec2d incoming = (polyline[i] - polyline[i - 1]).cast<double>().normalized();
const Vec2d outgoing = (polyline[i + 1] - polyline[i]).cast<double>().normalized();
sharpest = std::min(sharpest, incoming.dot(outgoing));
}
return sharpest;
}
bool contains(const Polyline &polyline, const Point &point)
{
return std::find(polyline.points.begin(), polyline.points.end(), point) != polyline.points.end();
}
const double tolerance = scaled<double>(0.0125);
} // namespace
TEST_CASE("Corner smoothing replaces a sharp vertex by a curve", "[FillCornerSmoothing]")
{
const Polyline sharp = asymmetric_corner();
Polyline smooth = sharp;
smooth_polyline_corners(smooth, 1., tolerance);
REQUIRE(smooth.size() > sharp.size());
REQUIRE(smooth.front() == sharp.front());
REQUIRE(smooth.back() == sharp.back());
// The right angle is gone, every remaining turn is a gentle one.
REQUIRE(max_turn_cosine(sharp) < 0.1);
REQUIRE(max_turn_cosine(smooth) > 0.9);
REQUIRE(smooth.length() < sharp.length());
}
TEST_CASE("Corner smoothing keeps the path untouched at a zero factor", "[FillCornerSmoothing]")
{
const Polyline sharp = asymmetric_corner();
Polyline none = sharp;
smooth_polyline_corners(none, 0., tolerance);
REQUIRE(none.points == sharp.points);
Polyline invalid = sharp;
smooth_polyline_corners(invalid, std::numeric_limits<double>::quiet_NaN(), tolerance);
REQUIRE(invalid.points == sharp.points);
}
TEST_CASE("Corner smoothing consumes at most half of the shorter leg", "[FillCornerSmoothing]")
{
// The curve must not reach beyond the middle of either adjoining segment, otherwise the curves of
// two adjacent corners would overlap. The shorter leg is 10mm long, so the corner at (10, 0) is
// left 5mm before it and rejoined 5mm past it, even though the other leg is 100mm long.
Polyline smooth = asymmetric_corner();
smooth_polyline_corners(smooth, 1., tolerance);
REQUIRE(contains(smooth, Point::new_scale(5., 0.)));
REQUIRE(contains(smooth, Point::new_scale(10., 5.)));
// A Bezier curve stays within the convex hull of its control points, so the rounded path stays
// inside the box spanned by the two legs.
for (const Point &point : smooth.points) {
REQUIRE(point.x() >= 0);
REQUIRE(point.y() >= 0);
REQUIRE(point.x() <= Point::new_scale(10., 0.).x());
REQUIRE(point.y() <= Point::new_scale(0., 100.).y());
}
}
TEST_CASE("Corner smoothing scales the curve with the factor", "[FillCornerSmoothing]")
{
Polyline half = asymmetric_corner();
smooth_polyline_corners(half, 0.5, tolerance);
Polyline full = asymmetric_corner();
smooth_polyline_corners(full, 1., tolerance);
// Half of the factor leaves the 10mm leg half as far from the corner.
REQUIRE(contains(half, Point::new_scale(7.5, 0.)));
REQUIRE(contains(full, Point::new_scale(5., 0.)));
// A larger factor rounds a wider portion of the legs, cutting more of the corner off.
REQUIRE(full.length() < half.length());
}
TEST_CASE("Corner smoothing leaves hairpins sharp", "[FillCornerSmoothing]")
{
// Both ends of a curve replacing a nearly reversing turn coincide, which would round the hairpin
// into a degenerate loop instead of a tip.
Polyline hairpin{ Point::new_scale(0., 0.), Point::new_scale(10., 0.), Point::new_scale(0., 0.5) };
const Polyline sharp = hairpin;
smooth_polyline_corners(hairpin, 1., tolerance);
REQUIRE(hairpin == sharp);
}
TEST_CASE("Corner smoothing follows the flattening tolerance", "[FillCornerSmoothing]")
{
Polyline coarse = asymmetric_corner();
smooth_polyline_corners(coarse, 1., scaled<double>(0.2));
Polyline fine = asymmetric_corner();
smooth_polyline_corners(fine, 1., scaled<double>(0.001));
REQUIRE(fine.size() > coarse.size());
REQUIRE(fine.front() == coarse.front());
REQUIRE(fine.back() == coarse.back());
}
TEST_CASE("Corner smoothing emits no zero length segments", "[FillCornerSmoothing]")
{
// Fully smoothed adjacent corners meet at the midpoint of the segment they share.
Polyline zigzag;
for (int i = 0; i < 8; ++i)
zigzag.points.emplace_back(Point::new_scale(i, i % 2 ? 1. : 0.));
smooth_polyline_corners(zigzag, 1., tolerance);
for (size_t i = 1; i < zigzag.size(); ++i)
REQUIRE((zigzag[i] - zigzag[i - 1]).cast<double>().squaredNorm() > 0.);
}
TEST_CASE("Corner smoothing rounds every vertex of a polygon", "[FillCornerSmoothing]")
{
// A polygon closes implicitly, so none of its corners may stay sharp, not even the first one.
const Polygon square{ Point::new_scale(0., 0.), Point::new_scale(10., 0.), Point::new_scale(10., 10.),
Point::new_scale(0., 10.) };
Polygons smooth{ square };
smooth_polygons_corners(smooth, 1., tolerance);
const Polyline rounded = smooth.front().split_at_first_point();
REQUIRE(smooth.front().size() > square.size());
REQUIRE(max_turn_cosine(rounded) > 0.9);
// The turn from the closing segment back into the first one must be gentle as well.
const Vec2d incoming = (rounded[rounded.size() - 1] - rounded[rounded.size() - 2]).cast<double>().normalized();
const Vec2d outgoing = (rounded[1] - rounded[0]).cast<double>().normalized();
REQUIRE(incoming.dot(outgoing) > 0.9);
// None of the corners is cut by more than half of a 10mm side.
for (const Point &point : smooth.front().points) {
REQUIRE(point.x() >= 0);
REQUIRE(point.y() >= 0);
REQUIRE(point.x() <= Point::new_scale(10., 0.).x());
REQUIRE(point.y() <= Point::new_scale(0., 10.).y());
}
}
TEST_CASE("Corner smoothing keeps the ends of a path that returns to its start", "[FillCornerSmoothing][Regression]")
{
// A branch of a lightning tree walks out and retraces its way back, ending where it started. Its
// ends are two free ends that happen to coincide, and joining them would close it into a loop.
Polyline retrace{ Point::new_scale(0., 0.), Point::new_scale(10., 0.), Point::new_scale(10., 10.),
Point::new_scale(5., 10.), Point::new_scale(0., 0.) };
const Polyline sharp = retrace;
smooth_polyline_corners(retrace, 1., tolerance);
REQUIRE(retrace.size() > sharp.size());
REQUIRE(retrace.front() == sharp.front());
REQUIRE(retrace.back() == sharp.back());
}
TEST_CASE("Corner smoothing ignores vertices splitting a straight leg", "[FillCornerSmoothing][Regression]")
{
// The triangular and grid infills emit a vertex halfway along the straight run joining two of
// their corners. Measuring the legs up to that vertex instead of up to the next corner let the
// rounding reach only half as far there as it did into the very same run elsewhere in the
// pattern, so geometrically identical corners came out rounded to different radii.
const Polyline plain{ Point::new_scale(0., 20.), Point::new_scale(10., 0.),
Point::new_scale(20., 0.), Point::new_scale(30., 20.) };
Polyline split = plain;
split.points.insert(split.points.begin() + 2, Point::new_scale(15., 0.));
Polyline smooth_plain = plain;
smooth_polyline_corners(smooth_plain, 1., tolerance);
Polyline smooth_split = split;
smooth_polyline_corners(smooth_split, 1., tolerance);
REQUIRE(smooth_split.points == smooth_plain.points);
// Both corners reach the middle of the 10mm run they share, which the extra vertex sat on.
REQUIRE(contains(smooth_plain, Point::new_scale(15., 0.)));
}

View File

@@ -27,6 +27,31 @@ public:
}
};
class TestableOctagramSpiral : public FillOctagramSpiral
{
public:
Points generate_points(double resolution, double smooth_factor = 0., coord_t max_coordinate = 7)
{
InfillPolylineOutput output(output_scale);
FillParams params;
params.smooth_factor = smooth_factor;
FillOctagramSpiral::generate(-max_coordinate, -max_coordinate, max_coordinate, max_coordinate, resolution, params, output);
return std::move(output.result());
}
};
// Cosine of the sharpest turn of a path, 1 meaning it has no turn at all.
double sharpest_turn_cosine(const Points &points)
{
double sharpest = 1.;
for (size_t i = 1; i + 1 < points.size(); ++i) {
const Vec2d incoming = (points[i] - points[i - 1]).cast<double>().normalized();
const Vec2d outgoing = (points[i + 1] - points[i]).cast<double>().normalized();
sharpest = std::min(sharpest, incoming.dot(outgoing));
}
return sharpest;
}
double path_length(const Points &points)
{
double length = 0.;
@@ -146,6 +171,35 @@ TEST_CASE("Hilbert smoothing joins straight segments with continuous curvature",
REQUIRE(fine_entry_curvature < 0.25 * coarse_entry_curvature);
}
TEST_CASE("Octagram spiral smoothing rounds the turns of the spiral", "[FillPlanePath]")
{
const Points sharp = TestableOctagramSpiral().generate_points(0.005);
const Points smooth = TestableOctagramSpiral().generate_points(0.005, 1.);
REQUIRE(smooth.size() > sharp.size());
REQUIRE(smooth.front() == sharp.front());
REQUIRE(smooth.back() == sharp.back());
// The spiral alternates between 90 and 135 degree turns; both are rounded into gentle ones.
REQUIRE(sharpest_turn_cosine(sharp) < -0.7);
REQUIRE(sharpest_turn_cosine(smooth) > 0.9);
for (size_t i = 1; i < smooth.size(); ++i)
REQUIRE((smooth[i] - smooth[i - 1]).cast<double>().squaredNorm() > 0.);
}
TEST_CASE("Octagram spiral smooth factor controls corner curvature", "[FillPlanePath]")
{
const Points sharp = TestableOctagramSpiral().generate_points(0.005);
const Points half_smooth = TestableOctagramSpiral().generate_points(0.005, 0.5);
const Points full_smooth = TestableOctagramSpiral().generate_points(0.005, 1.);
const Points invalid_factor = TestableOctagramSpiral().generate_points(
0.005, std::numeric_limits<double>::quiet_NaN());
REQUIRE(path_length(full_smooth) < path_length(half_smooth));
REQUIRE(path_length(half_smooth) < path_length(sharp));
REQUIRE(invalid_factor == sharp);
}
TEST_CASE("Hilbert curve smooth factor controls corner curvature", "[FillPlanePath]")
{
const Points sharp = TestableHilbertCurve().generate_points(0.005);

View File

@@ -574,11 +574,6 @@ TEST_CASE("Convex polygon intersection on two squares touching one vertex", "[Ge
Polygon B = A;
B.translate(10 / SCALING_FACTOR, 10 / SCALING_FACTOR);
SVG svg{std::string("one_vertex_touch") + ".svg"};
svg.draw(A, "blue");
svg.draw(B, "green");
svg.Close();
bool is_inters = Geometry::convex_polygons_intersect(A, B);
REQUIRE(is_inters == false);

View File

@@ -1,6 +1,7 @@
#include <catch2/catch_all.hpp>
#include <boost/filesystem.hpp>
#include <fstream>
#include "libslic3r/PresetBundle.hpp"
#include "libslic3r/AppConfig.hpp"
@@ -132,7 +133,7 @@ TEST_CASE("Current vendor type tolerates missing printer model", "[Preset][Bundl
{
PresetBundle bundle;
VendorProfile orca_vendor("ORCA");
VendorProfile orca_vendor; orca_vendor.id = "ORCA";
VendorProfile::PrinterModel model;
model.name = "Orca Test";
orca_vendor.models.emplace_back(model);
@@ -143,6 +144,31 @@ TEST_CASE("Current vendor type tolerates missing printer model", "[Preset][Bundl
CHECK(bundle.get_current_vendor_type() == VendorType::Unknown);
}
TEST_CASE("A malformed entry in a vendor's preset list is counted, not thrown", "[Preset][Bundle]")
{
ScopedTemporaryDir dir;
// A bare number where the list wants an object. An array element has no key,
// so reporting one as if it did throws nlohmann's invalid_iterator - which is
// not a parse_error, and escapes the catch around the vendor profile parse.
std::ofstream((dir.path() / "Acme.json").string())
<< R"({"version":"1.0.0","name":"Acme","process_list":[123,)"
<< R"({"name":"0.20mm Standard @Acme","sub_path":"process/standard.json"}]})";
fs::create_directories(dir.path() / "Acme" / "process");
std::ofstream((dir.path() / "Acme" / "process" / "standard.json").string())
<< R"({"type":"process","name":"0.20mm Standard @Acme","from":"system",)"
<< R"("instantiation":"true","layer_height":"0.2"})";
PresetBundle bundle;
size_t loaded = 0;
REQUIRE_NOTHROW(loaded = bundle.load_vendor_configs_from_json(
dir.path().string(), "Acme", PresetBundle::LoadSystem,
ForwardCompatibilitySubstitutionRule::EnableSilent).second);
CHECK(bundle.error_count() > 0); // the malformed element was counted
CHECK(loaded == 1); // the well-formed one beside it still loaded
}
TEST_CASE("Printer extruder count tolerates missing nozzle diameter", "[Preset][Bundle]")
{
PresetBundle bundle;
@@ -540,3 +566,327 @@ TEST_CASE("A printer specific filament supersedes the generic library filament w
CHECK(is_compatible_with_printer(generic_lib, PresetWithVendorProfile(*printer_c, nullptr)));
}
namespace {
const char *kMixedKeys[] = {
"filament_is_mixed",
"filament_mixed_components",
"filament_mixed_sublayer_ratios",
"filament_mixed_gradient",
"filament_mixed_gradient_range",
"filament_mixed_gradient_curve",
"filament_mixed_gradient_per_part",
};
} // namespace
// Mixed-color filament metadata lives in project_config as parallel per-filament arrays.
// set_num_filaments() is the single place that grows them alongside filament_colour; if it
// misses them, creating a mixed slot writes past the end of the short arrays.
TEST_CASE("set_num_filaments keeps mixed-color arrays in step with the filament count", "[Preset][Bundle][FilamentMixer]")
{
auto mixed_array_size = [](const DynamicPrintConfig &cfg, const std::string &key) -> size_t {
if (const auto *b = cfg.option<ConfigOptionBools>(key))
return b->values.size();
if (const auto *s = cfg.option<ConfigOptionStrings>(key))
return s->values.size();
return size_t(-1); // key missing entirely
};
PresetBundle bundle;
const unsigned int n = GENERATE(2u, 4u, 8u);
bundle.set_num_filaments(n, std::string("#FF0000"));
REQUIRE(bundle.project_config.option<ConfigOptionStrings>("filament_colour")->values.size() == n);
for (const char *key : kMixedKeys) {
DYNAMIC_SECTION("grown: " << key) {
CHECK(mixed_array_size(bundle.project_config, key) == n);
}
}
SECTION("shrinking keeps them in step too") {
bundle.set_num_filaments(1, std::string("#00FF00"));
REQUIRE(bundle.project_config.option<ConfigOptionStrings>("filament_colour")->values.size() == 1);
for (const char *key : kMixedKeys)
CHECK(mixed_array_size(bundle.project_config, key) == 1);
}
}
// A mix is described by 1-based indices into the project's filament list, which Orca rebuilds
// from the selected printer's snapshot (filament_%02u / filament_colors) at startup and on every
// printer selection. Held anywhere but that same per-printer snapshot, the mixed arrays end up
// indexing a filament list they were never saved against.
TEST_CASE("Mixed-color filament metadata is snapshotted per printer, with its filament list", "[Preset][Bundle][FilamentMixer]")
{
PresetBundle bundle;
// export_selections skips the built-in "Default Printer" placeholder entirely.
add_inmemory_preset(bundle.printers, "Test Printer");
bundle.printers.select_preset_by_name("Test Printer", true);
bundle.set_num_filaments(2u, std::string("#FF0000"));
bundle.project_config.option<ConfigOptionBools>("filament_is_mixed")->values = { false, true };
bundle.project_config.option<ConfigOptionStrings>("filament_mixed_components")->values = { "", "1,2" };
bundle.project_config.option<ConfigOptionStrings>("filament_mixed_sublayer_ratios")->values = { "", "0.5,0.5" };
AppConfig app_config;
bundle.export_selections(app_config);
const std::string printer_name = bundle.printers.get_selected_preset_name();
for (const char *key : kMixedKeys) {
DYNAMIC_SECTION("per printer, not global: " << key) {
CHECK(app_config.has_printer_setting(printer_name, key));
CHECK_FALSE(app_config.has("presets", key));
}
}
SECTION("with the encoding load_selections reads back") {
CHECK(app_config.get_printer_setting(printer_name, "filament_is_mixed") == "0,1");
CHECK(app_config.get_printer_setting(printer_name, "filament_mixed_components") == "|1,2");
CHECK(app_config.get_printer_setting(printer_name, "filament_mixed_sublayer_ratios") == "|0.5,0.5");
}
}
// The gradient curve is the one mixed array whose values contain '|' themselves — it separates the
// control points — so it cannot be '|'-joined into the app config like its siblings without a
// multi-point curve being split across filament slots on the way back in.
TEST_CASE("A multi-point gradient curve survives the app-config snapshot", "[Preset][Bundle][FilamentMixer]")
{
const std::vector<std::string> curves = { "", "", "0,0|0.5,0.3|1,1" };
PresetBundle bundle;
add_inmemory_preset(bundle.printers, "Test Printer");
bundle.printers.select_preset_by_name("Test Printer", true);
bundle.set_num_filaments(3u, std::string("#FF0000"));
bundle.project_config.option<ConfigOptionStrings>("filament_mixed_gradient_curve")->values = curves;
AppConfig app_config;
bundle.export_selections(app_config);
// Decoding the stored form returns the three slots intact, curve delimiters and all. A plain
// '|' join would decode as five slots here instead of three.
std::vector<std::string> decoded;
REQUIRE(unescape_strings_cstyle(
app_config.get_printer_setting(bundle.printers.get_selected_preset_name(), "filament_mixed_gradient_curve"), decoded));
CHECK(decoded == curves);
}
// A multi-tool printer sizes the filament list from its nozzle count. Mixed-color slots are extra
// virtual filaments at the tail of that list with no nozzle of their own, so the count has to
// allow for them: sizing to the nozzle count alone drops the project's mixes and strips every
// painted facet above the new count.
TEST_CASE("Sizing the filament list to a multi-tool nozzle count keeps mixed slots", "[Preset][Bundle][FilamentMixer]")
{
// The 5-slot layout of a 4-tool project carrying one mix of filaments 2 and 3.
const size_t nozzle_count = 4;
PresetBundle bundle;
bundle.set_num_filaments(5u, std::string("#FF0000"));
bundle.project_config.option<ConfigOptionBools>("filament_is_mixed")->values =
{ false, false, false, false, true };
bundle.project_config.option<ConfigOptionStrings>("filament_mixed_components")->values =
{ "", "", "", "", "2,3" };
REQUIRE(bundle.num_mixed_filaments() == 1);
SECTION("nozzle count plus the mixed slots preserves the mix") {
bundle.set_num_filaments(nozzle_count + bundle.num_mixed_filaments(), std::string("#00FF00"));
CHECK(bundle.filament_presets.size() == 5);
CHECK(bundle.num_mixed_filaments() == 1);
CHECK(bundle.is_mixed_filament(4));
CHECK(bundle.project_config.option<ConfigOptionStrings>("filament_mixed_components")->values[4] == "2,3");
}
SECTION("the nozzle count alone is what truncated it away") {
bundle.set_num_filaments(nozzle_count, std::string("#00FF00"));
CHECK(bundle.filament_presets.size() == nozzle_count);
CHECK(bundle.num_mixed_filaments() == 0);
}
}
// The nozzle-count top-up in update_multi_material_filament_presets() grows filament_presets on
// its own, so a physical count derived from that list reports a slot no per-filament array has
// yet. That is what made the extruder-count handler conclude there was nothing to add and leave
// the new sidebar combo with no colour to draw.
TEST_CASE("The physical filament count is not fooled by a lone filament_presets top-up", "[Preset][Bundle][FilamentMixer]")
{
PresetBundle bundle;
SECTION("no mixed slots") {
bundle.set_num_filaments(4u, std::string("#FF0000"));
bundle.printers.get_edited_preset().config.option<ConfigOptionFloats>("nozzle_diameter", true)->values =
{ 0.4, 0.4, 0.4, 0.4, 0.4 };
bundle.update_multi_material_filament_presets();
REQUIRE(bundle.filament_presets.size() == 5); // the top-up moved this list on its own
REQUIRE(bundle.project_config.option<ConfigOptionStrings>("filament_colour")->values.size() == 4);
CHECK(bundle.num_physical_filaments() == 4);
}
SECTION("behind a mixed tail") {
bundle.set_num_filaments(5u, std::string("#FF0000"));
bundle.project_config.option<ConfigOptionBools>("filament_is_mixed")->values =
{ false, false, false, false, true };
bundle.printers.get_edited_preset().config.option<ConfigOptionFloats>("nozzle_diameter", true)->values =
{ 0.4, 0.4, 0.4, 0.4, 0.4, 0.4 };
bundle.update_multi_material_filament_presets();
REQUIRE(bundle.filament_presets.size() == 6);
REQUIRE(bundle.project_config.option<ConfigOptionStrings>("filament_colour")->values.size() == 5);
CHECK(bundle.num_physical_filaments() == 4);
CHECK(bundle.num_mixed_filaments() == 1);
}
}
// Which slots are new is a fact about the per-filament arrays, not about filament_presets, for the
// same reason. Keyed off the wrong one, a freshly opened slot silently keeps filament 1's colour.
TEST_CASE("New filament colours are placed by array position", "[Preset][Bundle][FilamentMixer]")
{
PresetBundle bundle;
bundle.set_num_filaments(4u, std::string("#FF0000"));
bundle.printers.get_edited_preset().config.option<ConfigOptionFloats>("nozzle_diameter", true)->values =
{ 0.4, 0.4, 0.4, 0.4, 0.4 };
bundle.update_multi_material_filament_presets();
REQUIRE(bundle.filament_presets.size() == 5);
REQUIRE(bundle.project_config.option<ConfigOptionStrings>("filament_colour")->values.size() == 4);
// The call Sidebar::add_custom_filament makes once the extruder count opens a slot.
bundle.set_num_filaments(5u, std::string("#00FF00"));
const auto &colours = bundle.project_config.option<ConfigOptionStrings>("filament_colour")->values;
REQUIRE(colours.size() == 5);
CHECK(colours[4] == "#00FF00"); // not colours[0], which resize() would have padded with
}
// The mixed-slot flags are written into the app config on exit and read back on the next start.
// If the read side loses them the slots survive as filaments but stop being mixes, so the project
// comes back with the mix showing as an ordinary physical filament.
TEST_CASE("A saved mix is still a mix after an app restart", "[Preset][Bundle][FilamentMixer]")
{
AppConfig app_config;
// Last session: a 4-tool project carrying one mix of filaments 2 and 3 at the tail.
{
PresetBundle bundle;
add_inmemory_preset(bundle.printers, "Test Printer");
bundle.printers.select_preset_by_name("Test Printer", true);
add_inmemory_preset(bundle.filaments, "Test Filament");
bundle.filaments.select_preset_by_name("Test Filament", true);
bundle.set_num_filaments(5u, std::string("#FF0000"));
bundle.filament_presets.assign(5, "Test Filament");
bundle.project_config.option<ConfigOptionBools>("filament_is_mixed")->values =
{ false, false, false, false, true };
bundle.project_config.option<ConfigOptionStrings>("filament_mixed_components")->values =
{ "", "", "", "", "2,3" };
bundle.export_selections(app_config);
REQUIRE(app_config.get_printer_setting("Test Printer", "filament_is_mixed") == "0,0,0,0,1");
}
// This session.
PresetBundle bundle;
add_inmemory_preset(bundle.printers, "Test Printer");
add_inmemory_preset(bundle.filaments, "Test Filament");
bundle.load_selections(app_config);
CHECK(bundle.filament_presets.size() == 5);
CHECK(bundle.num_mixed_filaments() == 1);
CHECK(bundle.is_mixed_filament(4));
CHECK(bundle.project_config.option<ConfigOptionStrings>("filament_mixed_components")->values[4] == "2,3");
}
// The same restart, on the printer shape that actually shows the bug: a 4-tool changer whose
// saved filament list is one longer than its nozzle count, because the extra slot is the mix.
TEST_CASE("A saved mix survives a restart on a multi-tool printer", "[Preset][Bundle][FilamentMixer]")
{
auto make_toolchanger = [](PresetBundle &bundle) -> Preset & {
Preset &p = add_inmemory_preset(bundle.printers, "Tool Changer");
p.config.option<ConfigOptionFloats>("nozzle_diameter", true)->values = { 0.4, 0.4, 0.4, 0.4 };
p.config.option<ConfigOptionBool>("single_extruder_multi_material", true)->value = false;
return p;
};
AppConfig app_config;
{
PresetBundle bundle;
make_toolchanger(bundle);
bundle.printers.select_preset_by_name("Tool Changer", true);
add_inmemory_preset(bundle.filaments, "Test Filament");
bundle.filaments.select_preset_by_name("Test Filament", true);
bundle.set_num_filaments(5u, std::string("#FF0000"));
bundle.filament_presets.assign(5, "Test Filament");
bundle.project_config.option<ConfigOptionBools>("filament_is_mixed")->values =
{ false, false, false, false, true };
bundle.project_config.option<ConfigOptionStrings>("filament_mixed_components")->values =
{ "", "", "", "", "1,2" };
bundle.export_selections(app_config);
REQUIRE(app_config.get_printer_setting("Tool Changer", "filament_is_mixed") == "0,0,0,0,1");
}
PresetBundle bundle;
make_toolchanger(bundle);
add_inmemory_preset(bundle.filaments, "Test Filament");
bundle.load_selections(app_config);
CHECK(bundle.filament_presets.size() == 5);
CHECK(bundle.num_mixed_filaments() == 1);
CHECK(bundle.is_mixed_filament(4));
SECTION("and through the GUI startup calls that follow it") {
// GUI_App::load_current_presets sizes the list for a non-SEMM printer, growing only.
const size_t target = 4u + bundle.num_mixed_filaments();
if (target > bundle.filament_presets.size())
bundle.set_num_filaments(target);
CHECK(bundle.num_mixed_filaments() == 1);
// TabPrinter::extruders_count_changed.
bundle.on_extruders_count_changed(4);
CHECK(bundle.num_mixed_filaments() == 1);
// Tab::select_preset re-reads the snapshot when remember_printer_config is on.
bundle.update_selections(app_config);
CHECK(bundle.filament_presets.size() == 5);
CHECK(bundle.num_mixed_filaments() == 1);
CHECK(bundle.is_mixed_filament(4));
}
}
// The startup sizing in GUI_App::load_current_presets targets the nozzle count plus the mixes.
// That is a floor, never a ceiling: set_num_filaments() trims at the raw tail, which is exactly
// where the mixes live, so applying the target to a longer list deletes them. A list longer than
// the target is reachable - raising the extruder count without saving the printer preset leaves
// the extra physical slot behind on the next start - so the startup sizing must only ever grow.
TEST_CASE("Sizing down to the nozzle count plus mixes is what eats the mixed tail", "[Preset][Bundle][FilamentMixer]")
{
// 5 physical + 1 mix, on a printer preset still reporting 4 nozzles.
const size_t nozzle_count = 4;
PresetBundle bundle;
bundle.set_num_filaments(6u, std::string("#FF0000"));
bundle.project_config.option<ConfigOptionBools>("filament_is_mixed")->values =
{ false, false, false, false, false, true };
bundle.project_config.option<ConfigOptionStrings>("filament_mixed_components")->values =
{ "", "", "", "", "", "1,2" };
REQUIRE(bundle.num_physical_filaments() == 5);
const size_t target = nozzle_count + bundle.num_mixed_filaments();
REQUIRE(target < bundle.filament_presets.size());
SECTION("applied as written, the mix is gone and every slot reads physical") {
bundle.set_num_filaments(target);
CHECK(bundle.filament_presets.size() == target);
CHECK(bundle.num_mixed_filaments() == 0);
CHECK(bundle.num_physical_filaments() == target);
}
SECTION("applied as a floor, the mix is left alone") {
if (target > bundle.filament_presets.size())
bundle.set_num_filaments(target);
CHECK(bundle.filament_presets.size() == 6);
CHECK(bundle.num_mixed_filaments() == 1);
CHECK(bundle.is_mixed_filament(5));
CHECK(bundle.project_config.option<ConfigOptionStrings>("filament_mixed_components")->values[5] == "1,2");
}
}

View File

@@ -0,0 +1,125 @@
#include <catch2/catch_all.hpp>
#include "libslic3r/TriangleSelector.hpp"
#include "libslic3r/TriangleMesh.hpp"
using namespace Slic3r;
// A sphere gives well over ExtruderMax original facets, so every extruder state can be assigned
// to a facet of its own without any splitting getting in the way.
static TriangleMesh test_mesh() { return make_sphere(5., 2 * PI / 24); }
// Read the nibble_idx-th 4-bit group of a serialized bitstream, least significant bit first.
static int nibble_at(const std::vector<bool> &bitstream, size_t nibble_idx)
{
int n = 0;
for (size_t bit = 0; bit < 4; ++bit)
n |= int(bitstream[nibble_idx * 4 + bit]) << bit;
return n;
}
TEST_CASE("Every extruder state survives a serialize/deserialize round trip", "[TriangleSelector]")
{
const TriangleMesh mesh = test_mesh();
const int max_state = int(EnforcerBlockerType::ExtruderMax);
REQUIRE(int(mesh.its.indices.size()) >= max_state);
TriangleSelector selector(mesh);
for (int state = 1; state <= max_state; ++state)
selector.set_facet(state - 1, EnforcerBlockerType(state));
TriangleSelector restored(mesh);
restored.deserialize(selector.serialize());
for (int state = 1; state <= max_state; ++state) {
INFO("Extruder " << state);
REQUIRE(restored.has_facets(EnforcerBlockerType(state)));
REQUIRE(restored.num_facets(EnforcerBlockerType(state)) == 1);
}
}
TEST_CASE("Serialized data reports the extruder states it uses", "[TriangleSelector]")
{
const TriangleMesh mesh = test_mesh();
TriangleSelector selector(mesh);
selector.set_facet(0, EnforcerBlockerType::Extruder16);
selector.set_facet(1, EnforcerBlockerType::Extruder32);
const TriangleSelector::TriangleSplittingData data = selector.serialize();
REQUIRE(data.used_states.size() == size_t(EnforcerBlockerType::ExtruderMax) + 1);
REQUIRE(data.used_states[size_t(EnforcerBlockerType::Extruder16)]);
REQUIRE(data.used_states[size_t(EnforcerBlockerType::Extruder32)]);
REQUIRE_FALSE(data.used_states[size_t(EnforcerBlockerType::Extruder17)]);
SECTION("used_states recomputed from the bitstream agrees") {
TriangleSelector::TriangleSplittingData recomputed = data;
recomputed.reset_used_states();
recomputed.update_used_states(0);
REQUIRE(recomputed.used_states == data.used_states);
}
SECTION("has_facets on the raw data agrees") {
REQUIRE(TriangleSelector::has_facets(data, EnforcerBlockerType::Extruder32));
REQUIRE_FALSE(TriangleSelector::has_facets(data, EnforcerBlockerType::Extruder17));
}
}
// States 3..17 must keep the pre-existing encoding ("11" prefix plus one nibble of state-3) so
// projects written by older builds stay readable and newly written ones stay readable by them.
TEST_CASE("Extruder states up to 17 keep the single-nibble encoding", "[TriangleSelector]")
{
const int state = GENERATE(3, 8, 16, 17);
TriangleSelector selector(test_mesh());
selector.set_facet(0, EnforcerBlockerType(state));
const std::vector<bool> bitstream = selector.serialize().bitstream;
INFO("Extruder " << state);
// Two nibbles: the "11"-prefixed leaf code, then the state itself.
REQUIRE(bitstream.size() == 8);
REQUIRE(nibble_at(bitstream, 0) == 0b1100);
REQUIRE(nibble_at(bitstream, 1) == state - 3);
}
// States 18 and above set the state nibble to 0b1111 and carry (state-18) in one more nibble.
TEST_CASE("Extruder states above 17 are encoded in a second nibble", "[TriangleSelector]")
{
const int state = GENERATE(18, 25, 32);
TriangleSelector selector(test_mesh());
selector.set_facet(0, EnforcerBlockerType(state));
const std::vector<bool> bitstream = selector.serialize().bitstream;
INFO("Extruder " << state);
REQUIRE(bitstream.size() == 12);
REQUIRE(nibble_at(bitstream, 0) == 0b1100);
REQUIRE(nibble_at(bitstream, 1) == 0b1111);
REQUIRE(nibble_at(bitstream, 2) == state - 18);
}
// Model.cpp writes these hex strings into the 3MF for colored mesh imports; the selector must
// decode exactly the states CONST_FILAMENTS assigns to them.
TEST_CASE("Extruder states match the CONST_FILAMENTS hex encoding", "[TriangleSelector]")
{
struct Case { const char *hex; int state; };
const auto c = GENERATE(values<Case>({
{"8", 2}, {"0C", 3}, {"DC", 16}, {"EC", 17}, {"0FC", 18}, {"EFC", 32},
}));
// get_triangle_as_string emits the nibbles most significant first, so read the hex backwards.
const std::string hex = c.hex;
std::vector<bool> bitstream;
for (auto it = hex.rbegin(); it != hex.rend(); ++it) {
const int nibble = *it >= 'A' ? (*it - 'A' + 10) : (*it - '0');
for (int bit = 0; bit < 4; ++bit)
bitstream.push_back((nibble >> bit) & 1);
}
TriangleSelector::TriangleSplittingData data;
data.triangles_to_split.emplace_back(0, 0);
data.bitstream = bitstream;
INFO("Hex " << c.hex << " -> extruder " << c.state);
REQUIRE(TriangleSelector::has_facets(data, EnforcerBlockerType(c.state)));
}

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,7 @@ get_filename_component(_TEST_NAME ${CMAKE_CURRENT_LIST_DIR} NAME)
add_executable(${_TEST_NAME}_tests
${_TEST_NAME}_tests_main.cpp
test_dev_mapping.cpp
test_filament_bitmap_utils.cpp
test_network_versions.cpp
test_action_source.cpp
test_plugin_host_api.cpp

View File

@@ -0,0 +1,256 @@
// recompute_mixed_slot_colors lives in libslic3r_gui; this is the only suite that links it.
// Same Windows include prologue as test_dev_mapping.cpp (wx pulls in <windows.h>; keep
// WIN32_LEAN_AND_MEAN / NOMINMAX ahead of the Catch2 headers).
#ifdef WIN32
#ifndef WIN32_LEAN_AND_MEAN
#define WIN32_LEAN_AND_MEAN
#endif
#ifndef NOMINMAX
#define NOMINMAX
#endif
#include <Windows.h>
#endif
#include <catch2/catch_all.hpp>
#include <cmath>
#include <wx/colour.h>
#include <wx/string.h>
#include "libslic3r/FilamentMixer.hpp"
#include "libslic3r/PrintConfig.hpp"
#include "slic3r/GUI/FilamentBitmapUtils.hpp"
using namespace Slic3r;
using Slic3r::GUI::recompute_mixed_slot_colors;
namespace {
// Two physical slots (1 = red, 2 = blue) and mixed slot 3 built from them.
DynamicPrintConfig mixed_config(const std::string& components = "1,2", const std::string& ratios = "0.5,0.5")
{
DynamicPrintConfig cfg;
cfg.set_key_value("filament_is_mixed", new ConfigOptionBools({false, false, true}));
cfg.set_key_value("filament_mixed_components", new ConfigOptionStrings({"", "", components}));
cfg.set_key_value("filament_mixed_sublayer_ratios", new ConfigOptionStrings({"", "", ratios}));
cfg.set_key_value("filament_mixed_gradient", new ConfigOptionBools({false, false, false}));
cfg.set_key_value("filament_colour", new ConfigOptionStrings({"#FF0000", "#0000FF", "#000000"}));
return cfg;
}
wxColour expected_blend(const std::vector<std::string>& hex, const std::vector<int>& weights)
{
return wxColour(wxString(blend_color_multi(hex, weights)));
}
// Compare channels one at a time so a failure names the channel.
void require_same_rgb(const wxColour& actual, const wxColour& expected)
{
REQUIRE(int(actual.Red()) == int(expected.Red()));
REQUIRE(int(actual.Green()) == int(expected.Green()));
REQUIRE(int(actual.Blue()) == int(expected.Blue()));
}
} // namespace
TEST_CASE("recompute_mixed_slot_colors blends a mixed slot from its components' colours", "[FilamentBitmapUtils]")
{
std::vector<wxColour> colors{wxColour(255, 0, 0), wxColour(0, 0, 255)};
recompute_mixed_slot_colors(colors, mixed_config());
REQUIRE(colors.size() == 3);
require_same_rgb(colors[2], expected_blend({"#FF0000", "#0000FF"}, {5000, 5000}));
REQUIRE(int(colors[2].Alpha()) == 255);
// Physical slots are left alone.
require_same_rgb(colors[0], wxColour(255, 0, 0));
require_same_rgb(colors[1], wxColour(0, 0, 255));
}
TEST_CASE("recompute_mixed_slot_colors leaves the colours alone without mixed slots", "[FilamentBitmapUtils]")
{
std::vector<wxColour> colors{wxColour(255, 0, 0), wxColour(0, 0, 255)};
SECTION("no mixed keys at all") {
recompute_mixed_slot_colors(colors, DynamicPrintConfig{});
}
SECTION("mixed flags present but all false") {
DynamicPrintConfig cfg;
cfg.set_key_value("filament_is_mixed", new ConfigOptionBools({false, false}));
cfg.set_key_value("filament_mixed_components", new ConfigOptionStrings({"", ""}));
recompute_mixed_slot_colors(colors, cfg);
}
REQUIRE(colors.size() == 2);
require_same_rgb(colors[0], wxColour(255, 0, 0));
require_same_rgb(colors[1], wxColour(0, 0, 255));
}
TEST_CASE("recompute_mixed_slot_colors falls back to grey for a broken component reference", "[FilamentBitmapUtils]")
{
const wxColour grey(128, 128, 128, 255);
std::vector<wxColour> colors{wxColour(255, 0, 0), wxColour(0, 0, 255)};
SECTION("dangling component id") {
recompute_mixed_slot_colors(colors, mixed_config("1,9"));
}
SECTION("empty component list") {
recompute_mixed_slot_colors(colors, mixed_config(""));
}
REQUIRE(colors.size() == 3);
require_same_rgb(colors[2], grey);
}
TEST_CASE("recompute_mixed_slot_colors uses the project colour when a slot colour is unset", "[FilamentBitmapUtils]")
{
// Slot 2 carries no colour in the vector; filament_colour[1] = "#0000FF" is used instead.
std::vector<wxColour> colors{wxColour(255, 0, 0), wxColour()};
recompute_mixed_slot_colors(colors, mixed_config());
require_same_rgb(colors[2], expected_blend({"#FF0000", "#0000FF"}, {5000, 5000}));
}
TEST_CASE("recompute_mixed_slot_colors blends a gradient slot from its end points only", "[FilamentBitmapUtils]")
{
DynamicPrintConfig cfg;
cfg.set_key_value("filament_is_mixed", new ConfigOptionBools({false, false, false, true}));
cfg.set_key_value("filament_mixed_components", new ConfigOptionStrings({"", "", "", "1,2,3"}));
cfg.set_key_value("filament_mixed_sublayer_ratios", new ConfigOptionStrings({"", "", "", "0.2,0.3,0.5"}));
cfg.set_key_value("filament_mixed_gradient", new ConfigOptionBools({false, false, false, true}));
cfg.set_key_value("filament_colour", new ConfigOptionStrings({"#FF0000", "#00FF00", "#0000FF", "#000000"}));
std::vector<wxColour> colors{wxColour(255, 0, 0), wxColour(0, 255, 0), wxColour(0, 0, 255)};
recompute_mixed_slot_colors(colors, cfg);
REQUIRE(colors.size() == 4);
require_same_rgb(colors[3], expected_blend({"#FF0000", "#0000FF"}, {5000, 5000}));
}
TEST_CASE("recompute_mixed_slot_colors honours the configured ratios and is idempotent", "[FilamentBitmapUtils]")
{
std::vector<wxColour> colors{wxColour(255, 0, 0), wxColour(0, 0, 255)};
const DynamicPrintConfig cfg = mixed_config("1,2", "0.7,0.3");
recompute_mixed_slot_colors(colors, cfg);
const wxColour first = colors[2];
// The configured 70/30 ratio must reach the blend (it is not the equal-share default).
require_same_rgb(first, expected_blend({"#FF0000", "#0000FF"}, {7000, 3000}));
REQUIRE(first != expected_blend({"#FF0000", "#0000FF"}, {5000, 5000}));
recompute_mixed_slot_colors(colors, cfg);
require_same_rgb(colors[2], first);
}
// --- mixed_gradient_ramp / sample_gradient_ramp -----------------------------------------
//
// The ramp is what every mixed filament swatch is drawn from, so these pin the three things
// a plain fade between two endpoint colours cannot express: the reserved ratio band, the
// component order, and the custom curve.
namespace {
// Slot 3 (index 2) is a gradient mix of physical slots 1 (red) and 2 (blue).
DynamicPrintConfig gradient_config(const std::string& components = "1,2",
const std::string& range = "0.9,0.1",
const std::string& curve = "")
{
DynamicPrintConfig cfg;
cfg.set_key_value("filament_is_mixed", new ConfigOptionBools({false, false, true}));
cfg.set_key_value("filament_mixed_components", new ConfigOptionStrings({"", "", components}));
cfg.set_key_value("filament_mixed_gradient", new ConfigOptionBools({false, false, true}));
cfg.set_key_value("filament_mixed_gradient_range", new ConfigOptionStrings({"", "", range}));
cfg.set_key_value("filament_mixed_gradient_curve", new ConfigOptionStrings({"", "", curve}));
cfg.set_key_value("filament_colour", new ConfigOptionStrings({"#FF0000", "#0000FF", "#000000"}));
return cfg;
}
} // namespace
TEST_CASE("mixed_gradient_ramp runs bottom to top and never reaches a pure component", "[FilamentBitmapUtils]")
{
// range "0.9,0.1": component 1 (red) is the majority at the bottom and the minority at the top.
const auto ramp = Slic3r::GUI::mixed_gradient_ramp(gradient_config(), 2, 16);
REQUIRE(ramp.size() == 16);
// Neither end is the pure component colour - the slicer clamps the blend to
// [kGradientMinRatio, kGradientMaxRatio], which a fade between the pure colours would ignore.
REQUIRE(ramp.front() != wxColour(255, 0, 0));
REQUIRE(ramp.back() != wxColour(0, 0, 255));
// Red falls and blue rises monotonically from bottom to top.
for (size_t i = 1; i < ramp.size(); ++i) {
REQUIRE(int(ramp[i].Red()) <= int(ramp[i - 1].Red()));
REQUIRE(int(ramp[i].Blue()) >= int(ramp[i - 1].Blue()));
}
}
TEST_CASE("mixed_gradient_ramp follows the range's direction rather than the component order", "[FilamentBitmapUtils]")
{
const auto rising = Slic3r::GUI::mixed_gradient_ramp(gradient_config("1,2", "0.1,0.9"), 2, 16);
const auto falling = Slic3r::GUI::mixed_gradient_ramp(gradient_config("1,2", "0.9,0.1"), 2, 16);
REQUIRE(rising.size() == 16);
REQUIRE(falling.size() == 16);
// "0.1,0.9" starts blue-heavy at the bottom; "0.9,0.1" starts red-heavy. Reversing the
// range must reverse the ramp, which endpoint colours ordered by HSV cannot express.
REQUIRE(int(rising.front().Blue()) > int(rising.front().Red()));
REQUIRE(int(falling.front().Red()) > int(falling.front().Blue()));
require_same_rgb(rising.front(), falling.back());
}
TEST_CASE("mixed_gradient_ramp bends with a custom curve", "[FilamentBitmapUtils]")
{
// Component 1 holds near its maximum for the first half, then drops - a shape a straight
// fade between two endpoints cannot draw.
const auto curved = Slic3r::GUI::mixed_gradient_ramp(
gradient_config("1,2", "0.9,0.1", "0,0.9|0.5,0.85|1,0.1"), 2, 16);
const auto linear = Slic3r::GUI::mixed_gradient_ramp(gradient_config("1,2", "0.9,0.1"), 2, 16);
REQUIRE(curved.size() == 16);
// The curve holds component 1 high through the lower half, so every band up to mid height
// is at least as red as the straight fade and mid height is strictly redder.
for (size_t i = 0; i <= curved.size() / 2; ++i)
REQUIRE(int(curved[i].Red()) >= int(linear[i].Red()));
REQUIRE(int(curved[curved.size() / 2].Red()) > int(linear[linear.size() / 2].Red()));
// It still ends blue-dominant, like the straight fade.
REQUIRE(int(curved.back().Blue()) > int(curved.back().Red()));
}
TEST_CASE("mixed_gradient_ramp is empty for anything but a two-component gradient slot", "[FilamentBitmapUtils]")
{
SECTION("slot is not mixed") {
REQUIRE(Slic3r::GUI::mixed_gradient_ramp(gradient_config(), 0, 16).empty());
}
SECTION("gradient is off") {
DynamicPrintConfig cfg = gradient_config();
cfg.set_key_value("filament_mixed_gradient", new ConfigOptionBools({false, false, false}));
REQUIRE(Slic3r::GUI::mixed_gradient_ramp(cfg, 2, 16).empty());
}
SECTION("three components") {
REQUIRE(Slic3r::GUI::mixed_gradient_ramp(gradient_config("1,2,3"), 2, 16).empty());
}
SECTION("slot out of range") {
REQUIRE(Slic3r::GUI::mixed_gradient_ramp(gradient_config(), 9, 16).empty());
}
SECTION("no mixed keys at all") {
REQUIRE(Slic3r::GUI::mixed_gradient_ramp(DynamicPrintConfig{}, 0, 16).empty());
}
}
TEST_CASE("sample_gradient_ramp blends each step through the shared blender", "[FilamentBitmapUtils]")
{
// A flat curve makes every step the same 30/70 mix, which must come out as the blend the
// dialog's own swatches are drawn with - not a channel lerp between the two components.
GradientCurve curve;
curve.points = {{0.0, 0.3, NAN, NAN}, {1.0, 0.3, NAN, NAN}};
const auto ramp = Slic3r::GUI::sample_gradient_ramp(wxColour(255, 0, 0), wxColour(0, 0, 255), curve, 4);
REQUIRE(ramp.size() == 4);
const wxColour expected = Slic3r::GUI::blend_n_colors({wxColour(255, 0, 0), wxColour(0, 0, 255)}, {0.3, 0.7});
for (const wxColour& c : ramp)
require_same_rgb(c, expected);
}
TEST_CASE("sample_gradient_ramp returns nothing without a usable curve or step count", "[FilamentBitmapUtils]")
{
GradientCurve curve;
REQUIRE(Slic3r::GUI::sample_gradient_ramp(wxColour(255, 0, 0), wxColour(0, 0, 255), curve, 8).empty());
curve.points = {{0.0, kGradientMaxRatio, NAN, NAN}, {1.0, kGradientMinRatio, NAN, NAN}};
REQUIRE(Slic3r::GUI::sample_gradient_ramp(wxColour(255, 0, 0), wxColour(0, 0, 255), curve, 0).empty());
}