Merge branch 'feat/printer-agent-infra' into feat/printer-agent-impl

This commit is contained in:
Ian Chua
2026-09-28 21:15:32 +08:00
committed by GitHub
2933 changed files with 209923 additions and 266930 deletions
+101
View File
@@ -84,3 +84,104 @@ SCENARIO("Polygon flattening", "[ExtrusionEntity]") {
}
}
}
static ExtrusionPaths straight_path(const std::vector<double> &xs)
{
ExtrusionPath path{erExternalPerimeter, 1.0, 0.45f, 0.2f};
for (double x : xs)
path.polyline.append(Point3::new_scale(x, 0., 0.));
return {path};
}
TEST_CASE("Scarf ramp ends on the next loop vertex instead of leaving a short stub", "[ExtrusionEntity]")
{
using Catch::Matchers::WithinAbs;
// A 20 mm scarf in 10 steps: a remainder shorter than half a 2 mm step is snapped forward.
const double slope_length = 20.;
const double max_segment = scale_(slope_length / 10);
SECTION("a 0.09 mm remainder extends the ramp to the vertex") {
ExtrusionPaths paths = straight_path({0., 5., 10., 15., 20.09, 25., 30.});
ExtrusionLoopSloped loop(paths, 0., slope_length, max_segment, 0.);
REQUIRE(loop.starts.size() == 1);
REQUIRE(loop.ends.size() == 1);
REQUIRE(loop.paths.size() == 1);
CHECK_THAT(unscale_(loop.starts.front().polyline.last_point().x()), WithinAbs(20.09, 1e-3));
CHECK_THAT(unscale_(loop.ends.front().polyline.last_point().x()), WithinAbs(20.09, 1e-3));
CHECK_THAT(unscale_(loop.paths.front().polyline.first_point().x()), WithinAbs(20.09, 1e-3));
CHECK_THAT(unscale_(loop.paths.front().polyline.lines().front().length()), WithinAbs(4.91, 1e-3));
}
SECTION("a remainder longer than half a step keeps the exact scarf length") {
ExtrusionPaths paths = straight_path({0., 5., 10., 15., 21.5, 25., 30.});
ExtrusionLoopSloped loop(paths, 0., slope_length, max_segment, 0.);
REQUIRE(loop.starts.size() == 1);
REQUIRE(loop.paths.size() == 1);
CHECK_THAT(unscale_(loop.starts.front().polyline.last_point().x()), WithinAbs(20., 1e-3));
CHECK_THAT(unscale_(loop.paths.front().polyline.first_point().x()), WithinAbs(20., 1e-3));
CHECK_THAT(unscale_(loop.paths.front().polyline.lines().front().length()), WithinAbs(1.5, 1e-3));
}
SECTION("the ramp never grows by more than a millimetre, whatever the step size") {
ExtrusionPaths paths = straight_path({0., 5., 10., 15., 21.5, 25., 30.});
ExtrusionLoopSloped loop(paths, 0., slope_length, scale_(slope_length), 0.); // a single 20 mm step
REQUIRE(loop.paths.size() == 1);
CHECK_THAT(unscale_(loop.starts.front().polyline.last_point().x()), WithinAbs(20., 1e-3));
}
SECTION("snapping onto the path's last vertex leaves no single-point flat path") {
ExtrusionPaths paths = straight_path({0., 5., 10., 15., 20.5});
ExtrusionLoopSloped loop(paths, 0., slope_length, max_segment, 0.);
REQUIRE(loop.starts.size() == 1);
CHECK(loop.paths.empty());
CHECK_THAT(unscale_(loop.starts.front().polyline.last_point().x()), WithinAbs(20.5, 1e-3));
}
}
TEST_CASE("Scarf loop drops the micro segments the seam insertion leaves at both ends", "[ExtrusionEntity]")
{
using Catch::Matchers::WithinAbs;
const double slope_length = 20.;
const double max_segment = scale_(slope_length / 10);
SECTION("a 3 um segment at each end of a single path is removed, the seam point stays") {
ExtrusionPaths paths = straight_path({0., 0.003, 5., 10., 15., 21.5, 25., 29.997, 30.});
ExtrusionLoopSloped loop(paths, 0., slope_length, max_segment, 0.);
REQUIRE(loop.starts.size() == 1);
REQUIRE(loop.paths.size() == 1);
const Polyline3 &start = loop.starts.front().polyline;
CHECK_THAT(unscale_(start.first_point().x()), WithinAbs(0., 1e-4));
CHECK_THAT(unscale_(start.lines().front().length()), WithinAbs(1.25, 1e-3)); // 5 mm halved twice
const Polyline3 &flat = loop.paths.front().polyline;
CHECK_THAT(unscale_(flat.last_point().x()), WithinAbs(30., 1e-4));
CHECK_THAT(unscale_(flat.lines().back().length()), WithinAbs(5., 1e-3));
}
SECTION("a micro path of its own is dropped and the neighbour ends at the seam point") {
ExtrusionPaths paths = straight_path({0., 0.003});
ExtrusionPaths rest = straight_path({0.003, 5., 10., 15., 21.5, 25., 30.});
paths.push_back(rest.front());
ExtrusionLoopSloped loop(paths, 0., slope_length, max_segment, 0.);
REQUIRE(loop.starts.size() == 1);
CHECK_THAT(unscale_(loop.starts.front().polyline.first_point().x()), WithinAbs(0., 1e-4));
CHECK_THAT(unscale_(loop.starts.front().polyline.lines().front().length()), WithinAbs(1.25, 1e-3));
}
SECTION("a scarf covering the whole loop still ends at full flow after a trim") {
// The caller sizes the scarf from the untrimmed loop: 10.003 mm here, 10 mm after the trim.
ExtrusionPaths paths = straight_path({0., 0.003, 5., 10.});
ExtrusionLoopSloped loop(paths, 0., 10.003, max_segment, 0.);
REQUIRE(loop.starts.size() == 1);
CHECK(loop.paths.empty());
CHECK_THAT(loop.starts.back().slope_end.e_ratio, WithinAbs(1., 1e-9));
CHECK_THAT(unscale_(loop.starts.back().polyline.last_point().x()), WithinAbs(10., 1e-4));
}
SECTION("segments longer than the tolerance are kept") {
ExtrusionPaths paths = straight_path({0., 0.3, 5., 10., 15., 21.5, 25., 29.7, 30.});
ExtrusionLoopSloped loop(paths, 0., slope_length, max_segment, 0.);
REQUIRE(loop.paths.size() == 1);
CHECK_THAT(unscale_(loop.starts.front().polyline.lines().front().length()), WithinAbs(0.3, 1e-3));
CHECK_THAT(unscale_(loop.paths.front().polyline.lines().back().length()), WithinAbs(0.3, 1e-3));
}
}
@@ -244,6 +244,44 @@ float furthest_reading(const std::vector<ExtendedPoint<2>>& points)
})->distance;
}
// A wall along a supported edge of the previous layer, ending past or just short of the edge's end. Crossing the edge's
// end reads half a line width out.
constexpr double edge_run_length = 64.; // mm, wall start, measured from the end of the previous layer's edge
constexpr double edge_step = 0.384; // mm, how far this layer's contour extends past the previous layer's end
// The centreline is inset half a line width from the contour.
constexpr double edge_wall_end_past = edge_step - 0.5 * caged_wall_width;
constexpr double edge_wall_end_short = 0.05; // mm short of the edge, reading 0.21 - 0.05 = 0.16mm out
// Segmentation splits 1.5 line widths plus the end's reading from an end, so an end's slowdown and cooling stay within this.
constexpr double edge_affected_length = 3. * caged_wall_width;
std::vector<ExtendedPoint<2>> sampled_wall_along_edge(double wall_end_x,
const std::function<float(float)>& distance_to_speed,
float min_distance,
float fan_overlap_threshold)
{
const AABBTreeLines::LinesDistancer<Linef> prev_layer(std::vector<Linef>{
{{0., 0.}, {edge_run_length + 10., 0.}},
{{edge_run_length + 10., 0.}, {edge_run_length + 10., -10.}},
{{edge_run_length + 10., -10.}, {0., -10.}},
{{0., -10.}, {0., 0.}},
});
const double wall_y = -0.5 * caged_wall_width;
const Points wall{Point::new_scale(edge_run_length, wall_y), Point::new_scale(wall_end_x, wall_y)};
return estimate_points_properties<true, true, true, true>(wall, prev_layer, caged_wall_width, -1.f, min_distance,
distance_to_speed, fan_overlap_threshold);
}
// Length printed with the overhang fan on: segments with either end's overlap at or below the threshold.
double cooled_length(const std::vector<ExtendedPoint<2>>& points, float fan_overlap_threshold)
{
double length = 0.;
for (size_t i = 0; i + 1 < points.size(); ++i)
if (1.f - std::max(points[i].distance, points[i + 1].distance) / float(caged_wall_width) <= fan_overlap_threshold)
length += (points[i + 1].position - points[i].position).norm();
return length;
}
DynamicPrintConfig caged_overhang_config(const char* wall_generator){
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
config.set_deserialize_strict({
@@ -431,6 +469,61 @@ TEST_CASE("A supported wall between overhanging corners is slowed no further tha
REQUIRE(sampled <= unsampled);
}
// Regression: the line up to a step past the previous layer was not split, so the step's slowdown and cooling covered the
// whole wall. The split required an end reading beyond where the slowdown begins, and an edge crossing reads exactly
// there when the wall speed is held below the reference speed (e.g. resonance avoidance).
TEST_CASE("A wall stepping past the previous layer is slowed and cooled only beside the step", "[ExtrusionProcessor][Regression]")
{
const float crossing_reading = 0.5f * float(caged_wall_width);
const std::function<float(float)> distance_to_speed = [crossing_reading](float distance) {
return distance < crossing_reading ? 70.f : 15.f;
};
const float fan_overlap_threshold = 0.75f; // The fan switches on at a 25% overhang
const std::vector<ExtendedPoint<2>> points = sampled_wall_along_edge(-edge_wall_end_past, distance_to_speed, crossing_reading,
fan_overlap_threshold);
const double slowed = slowed_length(points, distance_to_speed);
const double cooled = cooled_length(points, fan_overlap_threshold);
REQUIRE(slowed > 0.);
REQUIRE(cooled > 0.);
REQUIRE(slowed < edge_affected_length);
REQUIRE(cooled < edge_affected_length);
}
// Regression: the fan can switch on at a smaller overhang than the first slowdown. Splitting only on speed changes left
// the whole wall cooled when its end read between the two.
TEST_CASE("A wall is split where only the overhang fan changes", "[ExtrusionProcessor][Regression]")
{
const float crossing_reading = 0.5f * float(caged_wall_width);
const std::function<float(float)> distance_to_speed = [crossing_reading](float distance) {
return distance < crossing_reading ? 70.f : 15.f;
};
// The end reads 0.16mm out (overlap 0.62): cooled at a 25% threshold, but not slowed.
const float fan_overlap_threshold = 0.75f;
const std::vector<ExtendedPoint<2>> points = sampled_wall_along_edge(edge_wall_end_short, distance_to_speed, crossing_reading,
fan_overlap_threshold);
const double cooled = cooled_length(points, fan_overlap_threshold);
REQUIRE_THAT(slowed_length(points, distance_to_speed), Catch::Matchers::WithinAbs(0., 1e-9));
REQUIRE(cooled > 0.);
REQUIRE(cooled < edge_affected_length);
}
// With one speed and a fan threshold no reading reaches, only the wall's ends and the edge crossing remain.
TEST_CASE("A wall is left whole where neither its speed nor its cooling changes", "[ExtrusionProcessor]")
{
const std::function<float(float)> distance_to_speed = [](float) { return 70.f; };
// 95% overhang; the step reads 0.384mm out (overlap 0.09).
const float fan_overlap_threshold = 0.05f;
const std::vector<ExtendedPoint<2>> points = sampled_wall_along_edge(-edge_wall_end_past, distance_to_speed, -1.f,
fan_overlap_threshold);
REQUIRE(points.size() == 3);
}
TEST_CASE("Benchmark caged overhang interior sampling", "[ExtrusionProcessor][!benchmark]"){
const char* wall_generator = GENERATE("classic", "arachne");
+228
View File
@@ -2,6 +2,7 @@
#include <algorithm>
#include <cmath>
#include <functional>
#include <map>
#include <numeric>
#include <sstream>
@@ -11,8 +12,10 @@
#include "libslic3r/ClipperUtils.hpp"
#include "libslic3r/AABBTreeLines.hpp"
#include "libslic3r/Fill/Fill.hpp"
#include "libslic3r/Fill/FillAdaptive.hpp"
#include "libslic3r/Flow.hpp"
#include "libslic3r/Geometry.hpp"
#include "libslic3r/IntersectionPoints.hpp"
#include "libslic3r/Layer.hpp"
#include "libslic3r/Print.hpp"
#include "libslic3r/PrintConfig.hpp"
@@ -1196,6 +1199,231 @@ TEST_CASE("Trapezoidal grid infill rounds its corners only with more than one li
REQUIRE(single_smooth.length == single_sharp.length);
}
TEST_CASE("Multiline cubic infill follows the cubic lines without crossing itself", "[Fill]")
{
const int multiline = GENERATE(2, 3);
const double spacing = 0.45;
const double density = 0.3;
const double wall = multiline * spacing;
CAPTURE(multiline);
const ExPolygon region{ Slic3r::Points{ Point::new_scale(0., 0.), Point::new_scale(40., 0.),
Point::new_scale(40., 40.), Point::new_scale(0., 40.) } };
auto fill = [&region, spacing](int lines, double density, size_t layer_id, double z) {
std::unique_ptr<Slic3r::Fill> filler(Slic3r::Fill::new_from_type("cubic"));
filler->spacing = spacing;
filler->angle = float(M_PI / 7.);
filler->layer_id = layer_id;
filler->z = z;
FillParams params;
params.density = float(density);
params.multiline = lines;
params.dont_adjust = true;
params.anchor_length_max = 0.f; // The bare pattern, without connections along the boundary.
Slic3r::Surface surface(stInternal, region);
return filler->fill_surface(&surface, params);
};
// Away from the boundary, where a line is clipped earlier than the side of its wall.
const Polygons inner = shrink(to_polygons(region), scale_(3.));
auto farthest = [&inner](const Polylines &from, const Polylines &to) {
const AABBTreeLines::LinesDistancer<Line> tree(to_lines(to));
double distance = 0.;
for (const Polyline &path : intersection_pl(from, inner))
for (const Point &point : path.equally_spaced_points(scale_(0.2)))
distance = std::max(distance, tree.distance_from_lines<false>(point));
return unscale<double>(distance);
};
// One z period of the pattern: sqrt(2) / 3 of the 3 * wall / density line spacing.
const double z_period = std::sqrt(2.) * wall / density;
const size_t layers = 30;
for (size_t layer_id = 0; layer_id < layers; ++layer_id) {
const double z = z_period * (layer_id + 0.5) / layers;
CAPTURE(layer_id, z);
const Polylines walls = fill(multiline, density, layer_id, z);
REQUIRE_FALSE(walls.empty());
CHECK(get_intersections(to_lines(walls)).empty());
// Long paths running out to the boundary, not loops around the cells.
CHECK(std::none_of(walls.begin(), walls.end(), [](const Polyline &path) { return path.first_point() == path.last_point(); }));
// Single lines at the same spacing: the walls are drawn along them.
const Polylines lines = fill(1, density / multiline, layer_id, z);
REQUIRE_FALSE(lines.empty());
CHECK(farthest(lines, walls) < 0.5 * wall);
CHECK(farthest(walls, lines) < 1.5 * wall);
}
}
TEST_CASE("Multiline adaptive cubic infill keeps its lines apart without closing them around the cells", "[Fill]")
{
const std::string pattern = GENERATE("adaptivecubic", "supportcubic");
const int multiline = GENERATE(2, 3);
CAPTURE(pattern, multiline);
// A sphere refines the octree all around, so the finer lines end on the coarser ones at every layer.
TriangleMesh sphere = Slic3r::Test::mesh(Slic3r::Test::TestMesh::sphere_50mm);
sphere.scale(0.3f);
Print print;
Slic3r::Test::init_and_process_print({sphere}, print,
{{"sparse_infill_pattern", pattern},
{"sparse_infill_density", "40%"},
{"fill_multiline", multiline},
{"infill_anchor", 0},
{"infill_anchor_max", 0},
{"layer_height", 0.3}});
size_t paths = 0, loops = 0;
for (const Layer *layer : print.objects().front()->layers()) {
Polylines printed;
Polygons sparse;
double spacing = 0.;
for (const LayerRegion *region : layer->regions()) {
for (const ExtrusionEntity *entity : region->fills.flatten().entities)
if (entity->role() == erInternalInfill)
entity->collect_polylines(printed);
for (const Surface &surface : region->fill_surfaces.surfaces)
if (surface.surface_type == stInternal)
append(sparse, shrink(to_polygons(surface.expolygon), scale_(1.)));
spacing = region->flow(frInfill).spacing();
}
if (printed.empty())
continue;
CAPTURE(layer->print_z);
paths += printed.size();
loops += std::count_if(printed.begin(), printed.end(), [](const Polyline &pl) { return pl.first_point() == pl.last_point(); });
CHECK(get_intersections(to_lines(printed)).empty());
// Neighbouring lines stay a line spacing apart, less the overlap of a line end with the wall it stops on.
// Pieces of one line that meet end to end are one line.
std::vector<size_t> line_of(printed.size());
std::iota(line_of.begin(), line_of.end(), 0);
std::function<size_t(size_t)> find = [&](size_t i) { return line_of[i] == i ? i : line_of[i] = find(line_of[i]); };
for (size_t i = 0; i < printed.size(); ++i)
for (size_t j = i + 1; j < printed.size(); ++j)
for (const Point &a : { printed[i].first_point(), printed[i].last_point() })
for (const Point &b : { printed[j].first_point(), printed[j].last_point() })
if ((a - b).cast<double>().norm() < SCALED_EPSILON)
line_of[find(i)] = find(j);
Lines lines;
std::vector<size_t> owner;
for (size_t i = 0; i < printed.size(); ++i)
for (const Line &line : printed[i].lines()) {
lines.push_back(line);
owner.push_back(find(i));
}
AABBTreeLines::LinesDistancer<Line> tree(lines);
double closest = spacing;
for (size_t i = 0; i < printed.size(); ++i)
for (const Point &p : printed[i].equally_spaced_points(scale_(0.1)))
if (contains(sparse, p))
for (size_t k : tree.all_lines_in_radius(p, scale_(spacing)))
if (owner[k] != find(i))
closest = std::min(closest, unscale<double>(lines[k].distance_to(p)));
CHECK(closest > 0.45 * spacing);
}
REQUIRE(paths > 0);
// The lines run on through the cells instead of each cell getting its own loops.
CHECK(loops < paths / 4);
}
TEST_CASE("Multiline adaptive cubic paths touch where they bounce off each other", "[Fill]")
{
const int sweep = GENERATE(0, 1, 2);
// Offset of the third family in walls, so the three meet in points or in small triangles.
const double shift = GENERATE(0., 0.1, 0.5, 1., 2.5, -0.5, -1.);
// Like finer octree lines ending on coarser ones, the 60 degree lines may start on the horizontal line through 0.
const bool starting = GENERATE(false, true);
CAPTURE(sweep, shift, starting);
const double d1 = scale_(0.8), pitch = scale_(8.), inner = scale_(12.);
Lines lines;
for (int k = 0; k < 3; ++k) {
const Vec2d dir(std::cos(k * M_PI / 3.), std::sin(k * M_PI / 3.)), normal(-dir.y(), dir.x());
for (int i = -6; i <= 6; ++i) {
const Vec2d mid = (i * pitch + (k == 2 ? shift * d1 : 0.)) * normal;
const double start = k == 1 && starting ? -mid.y() / dir.y() : -10. * pitch;
lines.emplace_back((mid + start * dir).cast<coord_t>(), (mid + 10. * pitch * dir).cast<coord_t>());
}
}
const Polylines paths = FillAdaptive::multiline_paths(lines, d1, 0., sweep, BoundingBox(Point::new_scale(-20., -20.), Point::new_scale(20., 20.)));
REQUIRE_FALSE(paths.empty());
CHECK(get_intersections(to_lines(paths)).empty());
Lines pieces;
std::vector<size_t> owner;
for (size_t i = 0; i < paths.size(); ++i)
for (const Line &line : paths[i].lines()) {
pieces.push_back(line);
owner.push_back(i);
}
AABBTreeLines::LinesDistancer<Line> tree(pieces);
auto clearance = [&](size_t i) {
const Line &a = pieces[i];
double distance = std::numeric_limits<double>::max();
for (size_t j : tree.all_lines_in_radius(a.midpoint(), 0.5 * a.length() + 2. * d1))
if (owner[j] != owner[i]) {
const Line &b = pieces[j];
distance = std::min({ distance, a.distance_to(b.a), a.distance_to(b.b), b.distance_to(a.a), b.distance_to(a.b) });
}
return distance;
};
auto inside = [inner](const Point &p) { return std::abs(p.x()) < inner && std::abs(p.y()) < inner; };
double closest = std::numeric_limits<double>::max();
for (size_t i = 0; i < pieces.size(); ++i)
if (inside(pieces[i].midpoint()))
closest = std::min(closest, clearance(i));
CHECK(closest > 0.99 * d1);
// Each path at a crossing touches another one there, none stops short of it.
double widest = 0.;
for (size_t i = 0; i < lines.size(); ++i)
for (size_t j = i + 1; j < lines.size(); ++j)
if (Point crossing; line_alg::intersection(lines[i], lines[j], &crossing) && inside(crossing)) {
std::map<size_t, double> at;
for (size_t k : tree.all_lines_in_radius(crossing, 1.2 * d1))
at.emplace(owner[k], std::numeric_limits<double>::max());
for (size_t k : tree.all_lines_in_radius(crossing, 2. * d1))
if (auto it = at.find(owner[k]); it != at.end())
it->second = std::min(it->second, clearance(k));
for (const auto &path : at)
widest = std::max(widest, path.second);
}
CHECK(widest < 1.02 * d1);
}
TEST_CASE("Multiline adaptive cubic paths reach the line they end on when another path ends on them", "[Fill]")
{
const int sweep = GENERATE(0, 1, 2);
// Where the 120 degree line starts on the horizontal one, in walls from the 60 degree line.
const double start = GENERATE(0.3, 0.6, 1., 2.);
CAPTURE(sweep, start);
const double d1 = scale_(0.8), overlap = 0.1 * d1, length = scale_(30.);
const Vec2d diagonal(0.5, 0.5 * std::sqrt(3.)), horizontal(1., 0.), steep(-0.5, 0.5 * std::sqrt(3.));
const Vec2d on_horizontal = start * d1 * horizontal;
const Lines lines{ Line((-length * diagonal).cast<coord_t>(), (length * diagonal).cast<coord_t>()),
Line(Point(0, 0), (length * horizontal).cast<coord_t>()),
Line(on_horizontal.cast<coord_t>(), (on_horizontal - length * steep).cast<coord_t>()) };
const Polylines paths = FillAdaptive::multiline_paths(lines, d1, overlap, sweep, BoundingBox(Point::new_scale(-40., -40.), Point::new_scale(40., 40.)));
// The end of the path along each line nearest to where that line starts.
auto end_along = [&paths](const Line &line) {
for (const Polyline &path : paths)
if (line.distance_to(path.first_point()) < SCALED_EPSILON && line.distance_to(path.last_point()) < SCALED_EPSILON)
return (path.first_point() - line.a).cast<double>().norm() < (path.last_point() - line.a).cast<double>().norm() ? path.first_point() : path.last_point();
return Point(std::numeric_limits<coord_t>::max(), 0);
};
const Point horizontal_end = end_along(lines[1]), steep_end = end_along(lines[2]);
REQUIRE(horizontal_end.x() != std::numeric_limits<coord_t>::max());
REQUIRE(steep_end.x() != std::numeric_limits<coord_t>::max());
// Both reach the overlap into the wall of the path they stop at, none stops short of it.
CHECK_THAT(line_alg::distance_to_infinite(lines[0], horizontal_end) / d1, Catch::Matchers::WithinAbs(0.9, 0.01));
CHECK(lines[1].distance_to(steep_end) / d1 < 0.91);
CHECK(get_intersections(to_lines(paths)).empty());
}
TEST_CASE("3D honeycomb infill rounds its octahedral waves with the smooth factor", "[Fill]")
{
auto shape_for = [](const std::string &smooth_factor) {
+34 -9
View File
@@ -1,11 +1,15 @@
#include <catch2/catch_all.hpp>
#include <algorithm>
#include <cstdlib>
#include <map>
#include <memory>
#include <sstream>
#include <string>
#include <vector>
#include "nlohmann/json.hpp"
#include "libslic3r/GCodeWriter.hpp"
#include "libslic3r/GCode.hpp"
#include "libslic3r/Model.hpp"
@@ -726,20 +730,41 @@ static std::string slice_two_object_bbl(DynamicPrintConfig &config)
}
// The real change_filament_gcode of a shipped "<printer> 0.4 nozzle" machine profile.
// The profile does not state it inline any more: it names the template carrying it in
// `include`, and the loader layers that template under the preset. Follow the same list
// - so a profile that stops naming one fails here instead of silently slicing G-code it
// no longer ships.
static std::string shipped_change_filament_gcode(const std::string &printer)
{
const std::string path = std::string(PROFILES_DIR) + "/BBL/machine/Bambu Lab " + printer + " 0.4 nozzle.json";
const std::string machine_dir = std::string(PROFILES_DIR) + "/BBL/machine/";
const std::string machine_path = machine_dir + "Bambu Lab " + printer + " 0.4 nozzle.json";
const std::string template_name = "Bambu Lab " + printer + " 0.4 nozzle template change_filament_gcode";
// PROFILES_DIR is an absolute path baked in at build time; a sparse test checkout
// without resources/ leaves it missing. Skip rather than dereference a config that
// never loaded - this is the only fff_print test that reads a shipped profile.
if (!boost::filesystem::exists(path))
SKIP("shipped profile not present in this checkout: " << path);
DynamicPrintConfig config;
std::map<std::string, std::string> key_values;
std::string reason;
config.load_from_json(path, ForwardCompatibilitySubstitutionRule::Enable, key_values, reason);
// Fail loudly on a malformed/renamed profile instead of null-dereferencing in opt_string.
INFO("profile: " << path << (reason.empty() ? "" : (" load reason: " + reason)));
if (!boost::filesystem::exists(machine_path))
SKIP("shipped profile not present in this checkout: " << machine_path);
auto load = [](const std::string &file, std::map<std::string, std::string> &key_values) {
DynamicPrintConfig config;
ConfigSubstitutionContext substitutions{ForwardCompatibilitySubstitutionRule::Enable};
std::string reason;
// false: `inherits` and `include` stay out of the config and land in key_values,
// for the loader to resolve - neither is a slicing setting.
config.load_from_json(file, substitutions, false, key_values, reason);
// Fail loudly on a malformed/renamed profile instead of null-dereferencing in opt_string.
INFO("profile: " << file << (reason.empty() ? "" : (" load reason: " + reason)));
return config;
};
std::map<std::string, std::string> machine_values;
load(machine_path, machine_values);
REQUIRE(machine_values.count("include") == 1);
const nlohmann::json includes = nlohmann::json::parse(machine_values["include"]);
REQUIRE(std::find(includes.begin(), includes.end(), nlohmann::json(template_name)) != includes.end());
std::map<std::string, std::string> template_values;
const DynamicPrintConfig config = load(machine_dir + template_name + ".json", template_values);
REQUIRE(config.has("change_filament_gcode"));
return config.opt_string("change_filament_gcode");
}
+94
View File
@@ -630,3 +630,97 @@ TEST_CASE("A lower layer sliver too thin to print does not support the wall abov
// A rib that does get printed takes the 20mm outer wall running along it out of the overhangs.
CHECK(printable < no_rib - scale_(15.));
}
namespace {
// Every setting the fuzzy skin assertions below depend on.
DynamicPrintConfig fuzzy_skin_config(const char *wall_generator)
{
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
config.set_deserialize_strict({
{ "wall_generator", wall_generator },
{ "layer_height", 0.2 },
{ "initial_layer_print_height", 0.2 },
// One wall, so every wall point along the long sides belongs to the fuzzed outer wall.
{ "wall_loops", 1 },
{ "fuzzy_skin", "external" },
{ "fuzzy_skin_noise_type", "classic" },
{ "fuzzy_skin_thickness", 0.3 },
{ "fuzzy_skin_point_distance", 0.8 },
});
return config;
}
// How far the wall points over the middle 60% of the layer's length stray across its width, worst side.
// A negative result means there is no layer at `print_z`.
double mid_span_wall_spread(const Print &print, double print_z)
{
for (const Layer *layer : print.objects().front()->layers()) {
if (std::abs(layer->print_z - print_z) > 1e-4)
continue;
const BoundingBox bbox = get_extents(layer->lslices);
const coord_t x_min = bbox.min.x() + bbox.size().x() / 5;
const coord_t x_max = bbox.max.x() - bbox.size().x() / 5;
Points points;
for (const LayerRegion *region : layer->regions())
region->perimeters.collect_points(points);
coord_t spread = 0;
for (const bool south : { true, false }) {
coord_t lo = bbox.max.y(), hi = bbox.min.y();
for (const Point &p : points)
if (p.x() > x_min && p.x() < x_max && (p.y() < bbox.center().y()) == south) {
lo = std::min(lo, p.y());
hi = std::max(hi, p.y());
}
spread = std::max(spread, hi - lo);
}
return unscale<double>(spread);
}
return -1.;
}
} // namespace
// TestMesh::bridge is a 50x10mm deck from z=5 to z=8 on two 5mm-wide pillars, leaving a 40mm span. The deck's
// first layer (print_z 5.2) crosses the span unsupported; the layers above it rest on the deck.
TEST_CASE("Fuzzy skin leaves the walls of a bridge smooth", "[Perimeters]")
{
const char *wall_generator = GENERATE("classic", "arachne");
CAPTURE(wall_generator);
Print print;
init_and_process_print({ TestMesh::bridge }, print, fuzzy_skin_config(wall_generator));
REQUIRE_FALSE(print.objects().empty());
// Control: one deck layer up the same walls rest on the deck, so they are fuzzed.
CHECK(mid_span_wall_spread(print, 5.6) > 0.1);
// Over the unsupported span the walls stay straight.
const double bridged = mid_span_wall_spread(print, 5.2);
CHECK(bridged >= 0.);
CHECK(bridged < 0.001);
}
// One object: a 20x20x3mm block on the bed and a second one floating above it from z=5 to z=8. The layers in
// the gap are empty, so the floating block's first layer (print_z 5.2) has a layer below it with nothing
// printed on it; the layers above rest on the floating block.
TEST_CASE("Fuzzy skin leaves the walls over an empty layer smooth", "[Perimeters]")
{
const char *wall_generator = GENERATE("classic", "arachne");
CAPTURE(wall_generator);
TriangleMesh mesh = make_cube(20., 20., 3.);
TriangleMesh floating = make_cube(20., 20., 3.);
floating.translate(0.f, 0.f, 5.f);
mesh.merge(floating);
Print print;
init_and_process_print({ mesh }, print, fuzzy_skin_config(wall_generator));
REQUIRE_FALSE(print.objects().empty());
// Control: one layer up the walls rest on the floating block, so they are fuzzed.
CHECK(mid_span_wall_spread(print, 5.6) > 0.1);
// Nothing is printed under the first floating layer, so its walls stay straight.
const double floating_first_layer = mid_span_wall_spread(print, 5.2);
CHECK(floating_first_layer >= 0.);
CHECK(floating_first_layer < 0.001);
}
+112
View File
@@ -15,6 +15,9 @@
#include "libslic3r/Layer.hpp"
#include "libslic3r/Model.hpp"
#include "libslic3r/GCodeReader.hpp"
#include "libslic3r/GCode/GCodeProcessor.hpp"
#include "libslic3r/Exception.hpp"
#include "libslic3r/LifecycleEvents.hpp"
#include "test_helpers.hpp"
#include "test_utils.hpp"
@@ -22,7 +25,10 @@
#include <algorithm>
#include <fstream>
#include <iterator>
#include <memory>
#include <string_view>
#include <utility>
#include <vector>
using namespace Slic3r;
using namespace Slic3r::Test;
@@ -224,8 +230,88 @@ std::string resolved_output_name(Model& model, const std::string& format, const
return print.output_filename(filename_base);
}
struct ScopedLifecycleHook
{
explicit ScopedLifecycleHook(LifecycleHookFn hook) { set_lifecycle_hook_fn(std::move(hook)); }
~ScopedLifecycleHook() { set_lifecycle_hook_fn(nullptr); }
};
} // namespace
TEST_CASE("Slicing lifecycle events identify the model", "[Print][LifecycleEvents]")
{
struct ObservedEvent {
LifecycleEvent event;
std::string id;
std::string name;
};
std::vector<ObservedEvent> events;
ScopedLifecycleHook hook([&](LifecycleEvent event, const LifecycleEventContext& ctx) {
events.push_back({ event, ctx.id, ctx.name });
});
Print print;
Model model;
ModelInfo info;
info.model_name = "Lifecycle test model";
model.model_info = std::make_shared<ModelInfo>(std::move(info));
init_print({cube(20)}, print, model);
print.process();
ScopedTemporaryFile temp(".gcode");
print.export_gcode(temp.string(), nullptr, nullptr);
GCodeProcessorResult result;
print.export_gcode_from_previous_file(temp.string(), &result);
const std::string expected_id = std::to_string(print.model().id().id);
const std::vector<LifecycleEvent> expected_events = {
LifecycleEvent::SliceStarted,
LifecycleEvent::SliceGeometryFinished,
LifecycleEvent::GCodeExportStarted,
LifecycleEvent::GCodeExportFinished,
LifecycleEvent::GCodeExportStarted,
LifecycleEvent::GCodeExportFinished,
};
REQUIRE(events.size() == expected_events.size());
for (size_t i = 0; i < expected_events.size(); ++i) {
CHECK(events[i].event == expected_events[i]);
CHECK(events[i].id == expected_id);
CHECK(events[i].name == "Lifecycle test model");
}
}
TEST_CASE("Slicing lifecycle event name is empty without model metadata", "[Print][LifecycleEvents]")
{
std::string event_id;
std::string event_name = "unset";
ScopedLifecycleHook hook([&](LifecycleEvent event, const LifecycleEventContext& ctx) {
if (event == LifecycleEvent::SliceStarted) {
event_id = ctx.id;
event_name = ctx.name;
}
});
Print print;
Model model;
init_print({cube(20)}, print, model);
print.process();
CHECK(event_id == std::to_string(print.model().id().id));
CHECK(event_name.empty());
}
TEST_CASE("Output filenames with numeric statistics fail before slicing finishes", "[Print][Regression]")
{
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
config.set_key_value("filename_format", new ConfigOptionString("{int(total_weight*10) / 10.0}"));
Print print;
Model model;
init_print({cube(20)}, print, model, config);
CHECK_THROWS_AS(print.output_filename(), PlaceholderParserError);
}
TEST_CASE("Print: {first_object_name} names the first printable object on the plate", "[Print]")
{
Model model;
@@ -505,3 +591,29 @@ TEST_CASE("Sequential printing publishes the nozzle group result", "[Print][Mult
CHECK(gcode.find("; SEQ-ND-OK") != std::string::npos);
}
}
TEST_CASE("Slicing errors are reported per object with the object's name", "[Print]")
{
Print print;
Model model;
init_print({Slic3r::Test::cube(20.)}, print, model);
// Lift the cube off the bed: its first layer is empty, which G-code export reports per object.
ModelObject *object = model.objects.front();
object->name = "floating cube";
object->instances.front()->set_offset(object->instances.front()->get_offset() + Vec3d(0., 0., 2.));
print.apply(model, DynamicPrintConfig::full_print_config());
print.set_status_silent();
ScopedTemporaryFile temp(".gcode");
std::string message;
try {
print.process();
print.export_gcode(temp.string(), nullptr, nullptr);
FAIL("slicing did not report the empty first layer");
} catch (const SlicingErrors &errors) {
REQUIRE(errors.errors_.size() == 1);
message = print.slicing_errors_message(errors);
}
CHECK(message.rfind("floating cube: ", 0) == 0);
CHECK(message.find("empty first layer") != std::string::npos);
}
+113
View File
@@ -308,6 +308,119 @@ TEST_CASE("A single-filament plate reserves a tower only when one is actually pr
}
}
// Filament 2 on the top surface only, so every layer below it is a toolchange-free tower layer: the
// run "Combine sparse layers" folds. The two heights decide whether anything folds, so they are the
// caller's business.
static DynamicPrintConfig sparse_run_config(double layer_height, const char *max_layer_height, bool combine)
{
DynamicPrintConfig config = multifilament_config(2, {
{ "top_surface_filament_id", 2 },
{ "enable_prime_tower", true },
{ "wipe_tower_x", 50 }, // inside the 200x200 test bed
{ "wipe_tower_y", 50 },
{ "prime_tower_width", 35 },
{ "min_layer_height", "0.08"},
{ "single_extruder_multi_material", true },
{ "timelapse_type", "0" },
{ "enable_wrapping_detection", false },
{ "raft_layers", "0" } });
// A taller first layer would top the plan and hide what the run does, so slice at one height.
config.set_deserialize_strict({ { "layer_height", std::to_string(layer_height) },
{ "initial_layer_print_height", std::to_string(layer_height) },
{ "max_layer_height", max_layer_height },
{ "wipe_tower_sparse_layers_combination", combine ? "1" : "0" } });
return config;
}
// What a sliced tower did with its sparse run.
struct SparseRunResult { size_t planned, sparse, folded; float tallest_printed, printed_height; std::string gcode; };
static SparseRunResult slice_sparse_run(const DynamicPrintConfig &config)
{
Print print;
Model model;
init_print({ cube(10) }, print, model, config);
print.apply(model, config);
print.process();
REQUIRE(print.is_step_done(psWipeTower));
SparseRunResult r{};
for (const std::vector<WipeTower::ToolChangeResult> &layer : print.wipe_tower_data().tool_changes) {
if (layer.empty())
continue;
++r.planned;
if (wipe_tower_layer_is_sparse(layer))
++r.sparse;
if (wipe_tower_layer_is_combined_away(layer)) {
++r.folded;
} else {
r.tallest_printed = std::max(r.tallest_printed, layer.front().layer_height);
r.printed_height += layer.front().layer_height;
}
}
r.gcode = Slic3r::Test::gcode(print);
return r;
}
// How often the G-code declares `height` in the tag this printer's processor reads. The dialect is a
// global the exporter sets from the printer, so this is only correct after a slice - the point below.
static size_t count_height_tags(const std::string &gcode, const char *height)
{
const std::string tag = ";" + GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Height) + height + "\n";
size_t n = 0;
for (size_t p = gcode.find(tag); p != std::string::npos; p = gcode.find(tag, p + 1))
++n;
return n;
}
TEST_CASE("Combining sparse layers folds a run into whole layers the nozzle can lay down", "[WipeTower]")
{
// 0.1 mm layers under a 0.32 mm cap: three fit (0.3), a fourth does not, so a run prints once
// every three layers at 0.3 mm.
const SparseRunResult plain = slice_sparse_run(sparse_run_config(0.1, "0.32", false));
const SparseRunResult combined = slice_sparse_run(sparse_run_config(0.1, "0.32", true));
REQUIRE(plain.planned == combined.planned); // the plan still has one layer per object layer
REQUIRE(plain.sparse > 10);
CHECK(plain.folded == 0);
CHECK_THAT(plain.tallest_printed, Catch::Matchers::WithinAbs(0.1f, 1e-4f));
CHECK(combined.folded > 0);
CHECK_THAT(combined.tallest_printed, Catch::Matchers::WithinAbs(0.3f, 1e-4f));
// Two of every three sparse layers fold away, leaving the toolchange layers untouched.
CHECK(combined.folded <= plain.sparse);
CHECK(combined.folded >= plain.sparse / 2);
// What folds away comes back as height on the layer that prints the run: no gap, nothing twice.
CHECK_THAT(combined.printed_height, Catch::Matchers::WithinAbs(plain.printed_height, 1e-3f));
}
TEST_CASE("A run too thin to reach the nozzle's layer height is left alone", "[WipeTower]")
{
// Only whole layers merge, so two 0.2 mm layers (0.4) do not fit a 0.32 mm maximum and the tower
// prints as if the option were off. This is the common 0.4 nozzle case; the tooltip says so.
const SparseRunResult plain = slice_sparse_run(sparse_run_config(0.2, "0.32", false));
const SparseRunResult combined = slice_sparse_run(sparse_run_config(0.2, "0.32", true));
REQUIRE(plain.sparse > 10);
CHECK(combined.folded == 0);
CHECK(combined.planned == plain.planned);
CHECK_THAT(combined.tallest_printed, Catch::Matchers::WithinAbs(0.2f, 1e-4f));
}
TEST_CASE("A merged tower layer declares its own height to the G-code processor", "[WipeTower]")
{
// Each writer declares a height in a hardcoded tag dialect while the processor reads only its
// printer's, so one of them is always dropped. A merged layer is the first time that shows, as a
// thick layer drawn and costed as a thin one. 0.2 mm layers under a 0.42 mm maximum merge in pairs.
const SparseRunResult plain = slice_sparse_run(sparse_run_config(0.2, "0.42", false));
const SparseRunResult combined = slice_sparse_run(sparse_run_config(0.2, "0.42", true));
REQUIRE(combined.folded > 0);
CHECK_THAT(combined.tallest_printed, Catch::Matchers::WithinAbs(0.4f, 1e-4f));
// Every layer that prints a merged run has to say so, and nothing may say so without the option.
CHECK(count_height_tags(combined.gcode, "0.4") - count_height_tags(plain.gcode, "0.4") == combined.folded);
}
TEST_CASE("A tower printed without a tool change is still validated against the bed", "[WipeTower]")
{
// Wrapping detection prints a tower on a plate that purges one filament. Neither the old
+18
View File
@@ -490,6 +490,24 @@ TEST_CASE("save_to_json round-trips plugin capability references as strings", "[
CHECK(reloaded.option<ConfigOptionStrings>("slicing_pipeline_plugin")->values == refs);
}
TEST_CASE("load_from_json hands a preset's include list to the caller instead of the config", "[Config]") {
ScopedTemporaryFile tmp(".json");
{
boost::nowide::ofstream ofs(tmp.string());
ofs << R"({"type":"machine","name":"P","instantiation":"true","include":["T start","T end"],"machine_end_gcode":"M84"})";
}
DynamicPrintConfig config;
ConfigSubstitutionContext substitutions(ForwardCompatibilitySubstitutionRule::Disable);
std::map<std::string, std::string> key_values;
std::string reason;
REQUIRE(config.load_from_json(tmp.string(), substitutions, false, key_values, reason) == 0);
CHECK(reason.empty());
CHECK(key_values["include"] == R"(["T start","T end"])");
CHECK_FALSE(config.has("include"));
CHECK(substitutions.unrecogized_keys.empty());
CHECK(config.opt_string("machine_end_gcode") == "M84");
}
TEST_CASE("save_to_json writes the same document to a stream as to a file", "[Config]") {
DynamicPrintConfig config;
config.set_key_value("layer_height", new ConfigOptionFloat(0.2));
+104 -10
View File
@@ -53,41 +53,64 @@ void write_vendor_tree(const fs::path& dir, const std::string& vendor, const std
}
// A small but complete vendor: one machine model, one process, a non-instantiated
// base filament with an instantiated child inheriting it, a second standalone
// filament carrying explicit metadata, and one machine preset with a rename — so
// the equivalence test below sees every CachedPreset field populated.
void write_full_vendor_tree(const fs::path& dir, const std::string& vendor, const std::string& version)
// base filament with an instantiated child that inherits it and includes a
// dual-extruder template, a second standalone filament carrying explicit
// metadata, and one machine preset with a rename that includes a G-code template
// — so the equivalence test below sees every CachedPreset field populated. The
// templates are listed before the presets that include them, as update-index
// orders them, or after them when `templates_last` asks for the broken order.
void write_full_vendor_tree(const fs::path& dir, const std::string& vendor, const std::string& version,
bool templates_last = false)
{
fs::create_directories(dir / vendor / "process");
fs::create_directories(dir / vendor / "filament");
fs::create_directories(dir / vendor / "machine");
const std::string filament_template = R"({"name":")" + vendor + R"( dual template","sub_path":"filament/template.json"})";
const std::string filament_presets = R"({"name":")" + vendor + R"( Base PLA","sub_path":"filament/base.json"},)"
R"({"name":")" + vendor + R"( PLA @0.4","sub_path":"filament/pla.json"},)"
R"({"name":")" + vendor + R"( Silk PLA @0.4","sub_path":"filament/silk.json"})";
const std::string machine_template = R"({"name":")" + vendor + R"( 0.4 template machine_start_gcode","sub_path":"machine/start.json"})";
const std::string machine_presets = R"({"name":")" + vendor + R"( 0.4 nozzle","sub_path":"machine/printer.json"})";
std::ofstream((dir / (vendor + ".json")).string())
<< R"({"version":")" << version << R"(","name":")" << vendor << R"(",)"
<< R"("machine_model_list":[{"name":"Test Model","sub_path":"machine/model.json"}],)"
<< R"("process_list":[{"name":"0.20mm Standard @)" << vendor << R"(","sub_path":"process/standard.json"}],)"
<< R"("filament_list":[)"
<< R"({"name":")" << vendor << R"( Base PLA","sub_path":"filament/base.json"},)"
<< R"({"name":")" << vendor << R"( PLA @0.4","sub_path":"filament/pla.json"},)"
<< R"({"name":")" << vendor << R"( Silk PLA @0.4","sub_path":"filament/silk.json"}],)"
<< R"("machine_list":[{"name":")" << vendor << R"( 0.4 nozzle","sub_path":"machine/printer.json"}]})";
<< (templates_last ? filament_presets + "," + filament_template : filament_template + "," + filament_presets)
<< R"(],"machine_list":[)"
<< (templates_last ? machine_presets + "," + machine_template : machine_template + "," + machine_presets)
<< "]}";
std::ofstream((dir / vendor / "machine" / "model.json").string())
<< R"({"type":"machine_model","name":"Test Model","nozzle_diameter":"0.4"})";
std::ofstream((dir / vendor / "process" / "standard.json").string())
<< R"({"type":"process","name":"0.20mm Standard @)" << vendor
<< R"(","from":"system","instantiation":"true","layer_height":"0.2"})";
// The base sets two per-variant keys; the template restates one of them for
// two variants and adds a third; the child restates the third.
std::ofstream((dir / vendor / "filament" / "base.json").string())
<< R"({"type":"filament","name":")" << vendor
<< R"( Base PLA","from":"system","instantiation":"false","filament_id":"GFA_base","filament_cost":"42"})";
<< R"( Base PLA","from":"system","instantiation":"false","filament_id":"GFA_base","filament_cost":"42",)"
<< R"("activate_air_filtration":["1"],"filament_max_volumetric_speed":["12"]})";
std::ofstream((dir / vendor / "filament" / "template.json").string())
<< R"({"type":"filament","name":")" << vendor << R"( dual template","from":"system","instantiation":"false",)"
<< R"("filament_extruder_variant":["Direct Drive Standard","Direct Drive High Flow"],)"
<< R"("filament_max_volumetric_speed":["20","22"],"filament_flush_temp":["0","0"]})";
std::ofstream((dir / vendor / "filament" / "pla.json").string())
<< R"({"type":"filament","name":")" << vendor
<< R"( PLA @0.4","from":"system","instantiation":"true","filament_id":"GFA00","filament_cost":"20",)"
<< R"("setting_id":"GFSA04","description":"Test PLA description"})";
std::ofstream((dir / vendor / "filament" / "silk.json").string())
<< R"({"type":"filament","name":")" << vendor
<< R"( Silk PLA @0.4","from":"system","instantiation":"true","inherits":")" << vendor << R"( Base PLA"})";
<< R"( Silk PLA @0.4","from":"system","instantiation":"true","inherits":")" << vendor << R"( Base PLA",)"
<< R"("include":[")" << vendor << R"( dual template"],"filament_flush_temp":["5","5"]})";
// The template states two G-codes; the printer restates one of them.
std::ofstream((dir / vendor / "machine" / "start.json").string())
<< R"({"type":"machine","name":")" << vendor << R"( 0.4 template machine_start_gcode","from":"system",)"
<< R"("instantiation":"false","machine_start_gcode":"G28 ; template","machine_end_gcode":"M84 ; template"})";
std::ofstream((dir / vendor / "machine" / "printer.json").string())
<< R"({"type":"machine","name":")" << vendor
<< R"( 0.4 nozzle","from":"system","instantiation":"true","printer_model":"Test Model","printer_variant":"0.4",)"
<< R"("include":[")" << vendor << R"( 0.4 template machine_start_gcode"],"machine_end_gcode":"M84 ; own",)"
<< R"("renamed_from":")" << vendor << R"( old 0.4 nozzle"})";
}
@@ -598,6 +621,7 @@ TEST_CASE("a cache-loaded vendor is indistinguishable from a JSON-loaded one", "
const Preset* pr = from_cache.printers.find_preset("Acme 0.4 nozzle", false);
REQUIRE(pr != nullptr);
CHECK(pr->renamed_from == std::vector<std::string>{"Acme old 0.4 nozzle"});
CHECK(pr->config.opt_string("machine_start_gcode") == "G28 ; template"); // through the include
}
TEST_CASE("a cache-served vendor reports the errors its parse counted", "[VendorCache]")
@@ -1618,3 +1642,73 @@ TEST_CASE("a stamp string with an absurd length is rejected, not allocated", "[V
CHECK(VendorCacheFile::peek_version(cache, "Evil").empty());
}
TEST_CASE("an included template's keys land on the preset, between the parent's and its own", "[VendorCache]")
{
InstallDirs dirs;
write_full_vendor_tree(dirs.system, "Acme", "1.0.0");
PresetBundle bundle;
bundle.load_vendor_configs_from_json(dirs.system.string(), "Acme", PresetBundle::LoadSystem,
ForwardCompatibilitySubstitutionRule::EnableSilent);
CHECK(bundle.error_count() == 0);
const Preset* pr = bundle.printers.find_preset("Acme 0.4 nozzle", false);
REQUIRE(pr != nullptr);
CHECK(pr->config.opt_string("machine_start_gcode") == "G28 ; template"); // from the include
CHECK(pr->config.opt_string("machine_end_gcode") == "M84 ; own"); // the preset's own key wins
CHECK(presets_for(bundle.printers, "Acme").size() == 1); // the template is no preset
const Preset* silk = bundle.filaments.find_preset("Acme Silk PLA @0.4", false);
REQUIRE(silk != nullptr);
const auto* speed = silk->config.option<ConfigOptionFloats>("filament_max_volumetric_speed");
REQUIRE(speed != nullptr);
CHECK(speed->values == std::vector<double>{20., 22.}); // the include wins over the parent
const auto* flush = silk->config.option<ConfigOptionInts>("filament_flush_temp");
REQUIRE(flush != nullptr);
CHECK(flush->values == std::vector<int>{5, 5}); // the preset's own key wins
// A per-variant key the template never mentions keeps the parent's value.
// The loader pads every base to its variant count; an include taken from the
// padded copy would carry the padded default [0,0] over the parent's 1.
const auto* air = silk->config.option<ConfigOptionBools>("activate_air_filtration");
REQUIRE(air != nullptr);
CHECK(air->values == std::vector<unsigned char>{1, 1});
CHECK(presets_for(bundle.filaments, "Acme").size() == 2); // the template is no preset
}
TEST_CASE("an include listed after the preset that names it is an error, and the preset loads without it", "[VendorCache]")
{
InstallDirs dirs;
write_full_vendor_tree(dirs.system, "Acme", "1.0.0", /*templates_last=*/true);
PresetBundle bundle;
bundle.load_vendor_configs_from_json(dirs.system.string(), "Acme", PresetBundle::LoadSystem,
ForwardCompatibilitySubstitutionRule::EnableSilent);
// One error per unresolved include: the printer's and the filament's.
CHECK(bundle.error_count() == 2);
const Preset* pr = bundle.printers.find_preset("Acme 0.4 nozzle", false);
REQUIRE(pr != nullptr);
CHECK(pr->config.opt_string("machine_start_gcode") != "G28 ; template");
const Preset* silk = bundle.filaments.find_preset("Acme Silk PLA @0.4", false);
REQUIRE(silk != nullptr);
CHECK(silk->config.option<ConfigOptionFloats>("filament_max_volumetric_speed")->values == std::vector<double>{12.});
}
TEST_CASE("a G-code template that states no instantiation is included, not loaded as a preset", "[VendorCache]")
{
InstallDirs dirs;
write_full_vendor_tree(dirs.system, "Acme", "1.0.0");
const fs::path start = dirs.system / "Acme" / "machine" / "start.json";
SECTION("named as G-code") {
std::ofstream(start.string()) << R"({"type":"machine","name":"Acme 0.4 template machine_start_gcode","from":"system",)"
<< R"("machine_start_gcode":"G28 ; template"})";
}
SECTION("not named, so included by its name in the vendor index") {
std::ofstream(start.string()) << R"({"type":"machine","from":"system","machine_start_gcode":"G28 ; template"})";
}
PresetBundle bundle;
bundle.load_vendor_configs_from_json(dirs.system.string(), "Acme", PresetBundle::LoadSystem,
ForwardCompatibilitySubstitutionRule::EnableSilent);
CHECK(bundle.error_count() == 0);
const Preset* pr = bundle.printers.find_preset("Acme 0.4 nozzle", false);
REQUIRE(pr != nullptr);
CHECK(pr->config.opt_string("machine_start_gcode") == "G28 ; template");
CHECK(presets_for(bundle.printers, "Acme").size() == 1);
}
+92
View File
@@ -278,6 +278,98 @@ TEST_CASE("Only the keep-out ring an object is measured against is drawn", "[Wip
CHECK_THAT(unscaled(get_extents(zone.grown_body).max.x()), WithinAbs(10. + 0.5 * (40. - 0.2), 0.02));
}
// ---------------------------------------------------------------------------------------------
// "Combine sparse layers": folding a run of toolchange-free layers into one thicker tower layer.
// ---------------------------------------------------------------------------------------------
TEST_CASE("Sparse layers are combined only when every layer is still the tower's to place", "[WipeTower][CombineSparseLayers]") {
PrintConfig cfg;
cfg.timelapse_type.value = TimelapseType::tlTraditional;
cfg.enable_wrapping_detection.value = false;
cfg.wipe_tower_no_sparse_layers.value = false;
cfg.wipe_tower_sparse_layers_combination.value = false;
CHECK_FALSE(wipe_tower_sparse_layers_combined(cfg));
cfg.wipe_tower_sparse_layers_combination.value = true;
CHECK(wipe_tower_sparse_layers_combined(cfg));
// Dropping the sparse layers outright leaves nothing to combine.
cfg.wipe_tower_no_sparse_layers.value = true;
CHECK_FALSE(wipe_tower_sparse_layers_combined(cfg));
CHECK(wipe_tower_sparse_layers_skipped(cfg));
cfg.wipe_tower_no_sparse_layers.value = false;
// Both of these park the nozzle on the tower every layer, so no layer may be folded away.
cfg.timelapse_type.value = TimelapseType::tlSmooth;
CHECK_FALSE(wipe_tower_sparse_layers_combined(cfg));
cfg.timelapse_type.value = TimelapseType::tlTraditional;
cfg.enable_wrapping_detection.value = true;
CHECK_FALSE(wipe_tower_sparse_layers_combined(cfg));
}
TEST_CASE("A layer folded into a later one is marked on the results the emitter reads", "[WipeTower][CombineSparseLayers]") {
WipeTower::ToolChangeResult folded = make_tcr(1, 1, 0.2f);
folded.combined_away = true;
CHECK(wipe_tower_layer_is_combined_away({folded}));
CHECK_FALSE(wipe_tower_layer_is_combined_away({make_tcr(1, 1, 0.2f)}));
CHECK_FALSE(wipe_tower_layer_is_combined_away({}));
}
TEST_CASE("A run of sparse layers prints once, on its last layer, at the height it covers", "[WipeTower][CombineSparseLayers]") {
// Eight 0.1 mm layers on a 0.3 mm cap: a toolchange on the first and the last, sparse between.
std::vector<float> heights(8, 0.1f);
const std::vector<char> sparse{0, 1, 1, 1, 1, 1, 1, 0};
const std::vector<float> caps(8, 0.3f);
const std::vector<char> combined = combine_sparse_wipe_tower_layers(heights, sparse, caps, 0);
REQUIRE(combined.size() == heights.size());
// Three layers fill the cap exactly: the run flushes on layers 3 and 6, the two below each go.
CHECK(combined == std::vector<char>{0, 1, 1, 0, 1, 1, 0, 0});
CHECK_THAT(heights[3], WithinAbs(0.3f, 1e-5f));
CHECK_THAT(heights[6], WithinAbs(0.3f, 1e-5f));
// Layers that print keep the object covered: nothing is lost and nothing is printed twice.
float printed = 0.f;
for (size_t i = 0; i < heights.size(); ++i)
if (! combined[i])
printed += heights[i];
CHECK_THAT(printed, WithinAbs(0.8f, 1e-5f));
// A toolchange has to purge at its own z, so those layers are left exactly as planned.
CHECK_THAT(heights[0], WithinAbs(0.1f, 1e-5f));
CHECK_THAT(heights[7], WithinAbs(0.1f, 1e-5f));
}
TEST_CASE("The maximum layer height of the nozzle that prints the run caps the merge", "[WipeTower][CombineSparseLayers]") {
// The cap that counts belongs to the layer that prints the run; one that prints nothing lays
// nothing down, so its own cap cannot constrain it. Five 0.1 mm layers, sparse above the first,
// layer 3's nozzle taking only 0.15. (A real run holds one filament, so this only tests the
// look-ahead.)
std::vector<float> heights(5, 0.1f);
std::vector<float> caps(5, 0.3f);
caps[3] = 0.15f;
const std::vector<char> combined = combine_sparse_wipe_tower_layers(heights, {0, 1, 1, 1, 1}, caps, 0);
// Layer 2 cannot hand its 0.2 mm on to layer 3, so it prints there and a fresh run starts above.
CHECK(combined == std::vector<char>{0, 1, 0, 1, 0});
CHECK_THAT(heights[2], WithinAbs(0.2f, 1e-5f));
CHECK_THAT(heights[4], WithinAbs(0.2f, 1e-5f));
// A single layer already past the cap is printed as planned rather than shrunk.
std::vector<float> tall{0.2f, 0.4f, 0.4f};
const std::vector<char> tall_combined = combine_sparse_wipe_tower_layers(tall, {0, 1, 1}, {0.3f, 0.3f, 0.3f}, 0);
CHECK(tall_combined == std::vector<char>{0, 0, 0});
CHECK_THAT(tall[1], WithinAbs(0.4f, 1e-5f));
}
TEST_CASE("The tower's first layer is never folded away", "[WipeTower][CombineSparseLayers]") {
// It carries the brim and has to sit on the bed, however little it purges.
std::vector<float> heights(4, 0.1f);
const std::vector<char> combined = combine_sparse_wipe_tower_layers(heights, {1, 1, 1, 1}, std::vector<float>(4, 0.5f), 0);
CHECK(combined.front() == 0);
CHECK_THAT(heights.front(), WithinAbs(0.1f, 1e-5f));
// Everything above it merges into the top layer, which the cap still fits.
CHECK(combined == std::vector<char>{0, 1, 1, 0});
CHECK_THAT(heights.back(), WithinAbs(0.3f, 1e-5f));
}
TEST_CASE("Footprint padding covers the brim and the extrusion half width on each side", "[WipeTower][NoSparseLayers]") {
// A nominal outline hulls extrusion centre lines and is re-centred once the real wall is known,
// so a line width per side on top of the brim is what keeps an estimate enclosing the real tower.
+5
View File
@@ -8,6 +8,10 @@ add_executable(${_TEST_NAME}_tests
test_device_progress.cpp
test_device_manager_integration.cpp
test_web_media_controller.cpp
test_scene_raycaster.cpp
test_lazy.cpp
test_prebuild_queue.cpp
test_staged_build.cpp
test_network_versions.cpp
test_action_source.cpp
test_plugin_host_api.cpp
@@ -22,6 +26,7 @@ add_executable(${_TEST_NAME}_tests
test_plugin_install.cpp
test_plugin_lifecycle.cpp
test_plugin_printer_agent.cpp
test_printhost.cpp
test_slicing_pipeline_bindings.cpp
test_slicing_pipeline_config.cpp
test_plugin_sort.cpp
+240
View File
@@ -0,0 +1,240 @@
#include <catch2/catch_all.hpp>
#include <functional>
#include <memory>
#include <stdexcept>
#include <vector>
#include "slic3r/GUI/Lazy.hpp"
using Slic3r::GUI::Lazy;
using Slic3r::GUI::LazyBase;
using Slic3r::GUI::LazyInstance;
using Slic3r::GUI::StagedBuild;
namespace {
struct Plain
{
int value{ 1 };
};
struct One : LazyInstance<One>
{
int value{ 2 };
};
// Two steps after the constructor.
struct Staged : StagedBuild, LazyInstance<Staged>
{
std::vector<int> ran;
Staged()
{
add_build_step([this] { ran.push_back(1); });
add_build_step([this] { ran.push_back(2); });
}
void add_step(std::function<void()> step) { add_build_step(std::move(step)); }
};
// Owns what the factories make, since a Lazy does not.
template <class T>
struct Made
{
std::vector<std::unique_ptr<T>> objects;
T* make()
{
objects.push_back(std::make_unique<T>());
return objects.back().get();
}
typename Lazy<T>::Factory factory()
{
return [this] { return make(); };
}
};
} // namespace
TEST_CASE("The factory runs on the first unit, not at construction", "[Lazy]")
{
Made<Plain> made;
Lazy<Plain> lazy("plain", 0, made.factory());
REQUIRE(made.objects.empty());
REQUIRE_FALSE(lazy.built());
REQUIRE(lazy.pending());
REQUIRE(lazy.get() == nullptr);
REQUIRE_FALSE(lazy.build_step()); // the only unit
REQUIRE(made.objects.size() == 1);
REQUIRE(lazy.built());
REQUIRE_FALSE(lazy.pending());
REQUIRE(lazy.get() == made.objects[0].get());
REQUIRE_FALSE(lazy.build_step());
REQUIRE(made.objects.size() == 1);
}
TEST_CASE("A staged type takes one unit for the constructor and one per step", "[Lazy]")
{
Made<Staged> made;
Lazy<Staged> lazy("staged", 0, made.factory());
REQUIRE(lazy.build_step());
REQUIRE(made.objects.size() == 1);
REQUIRE(lazy.get() == nullptr); // exists but incomplete
REQUIRE(lazy.build_step());
REQUIRE(made.objects[0]->ran == std::vector<int>{1});
REQUIRE_FALSE(lazy.build_step());
REQUIRE(made.objects[0]->ran == std::vector<int>{1, 2});
REQUIRE(lazy.get() == made.objects[0].get());
}
TEST_CASE("ensure builds whatever is left and is a no-op afterwards", "[Lazy]")
{
Made<Staged> made;
Lazy<Staged> lazy("staged", 0, made.factory());
lazy.build_step();
Staged* s = lazy.ensure();
REQUIRE(s == made.objects[0].get());
REQUIRE(s->ran == std::vector<int>{1, 2});
REQUIRE(lazy.ensure() == s);
REQUIRE(made.objects.size() == 1);
}
TEST_CASE("when_built waits for completion, then runs at once", "[Lazy]")
{
Made<Staged> made;
Lazy<Staged> lazy("staged", 0, made.factory());
std::vector<int> seen;
lazy.when_built([&](Staged& s) { seen.push_back(int(s.ran.size())); });
lazy.build_step();
lazy.build_step();
REQUIRE(seen.empty());
lazy.build_step();
REQUIRE(seen == std::vector<int>{2});
lazy.when_built([&](Staged&) { seen.push_back(9); });
REQUIRE(seen == std::vector<int>{2, 9});
}
TEST_CASE("A LazyInstance type reaches its holder through the statics", "[Lazy]")
{
REQUIRE(One::if_built() == nullptr);
REQUIRE(One::ensure() == nullptr);
Made<One> made;
{
Lazy<One> lazy("one", 0, made.factory());
REQUIRE(One::if_built() == nullptr);
One* one = One::ensure();
REQUIRE(one == made.objects[0].get());
REQUIRE(One::if_built() == one);
int seen = 0;
One::when_built([&](One& o) { seen = o.value; });
REQUIRE(seen == 2);
}
REQUIRE(One::if_built() == nullptr);
}
TEST_CASE("A newer holder replaces the registration; the older one leaves it alone", "[Lazy]")
{
Made<One> made;
auto first = std::make_unique<Lazy<One>>("first", 0, made.factory());
first->ensure();
Lazy<One> second("second", 0, made.factory());
REQUIRE(One::if_built() == nullptr); // the new holder has not built yet
second.ensure();
REQUIRE(One::if_built() == made.objects[1].get());
first.reset();
REQUIRE(One::if_built() == made.objects[1].get());
}
TEST_CASE("The holder reports the name and order it was given", "[Lazy]")
{
Made<Plain> made;
Lazy<Plain> lazy("plain", 7, made.factory());
LazyBase& base = lazy;
REQUIRE(base.name() == "plain");
REQUIRE(base.prebuild_order() == 7);
}
TEST_CASE("A unit that re-enters the holder builds nothing twice", "[Lazy]")
{
Made<Plain> made;
Lazy<Plain>* self = nullptr;
int nested_units = 0;
Lazy<Plain> lazy("plain", 0, [&] {
if (self->build_step()) // as if the constructor pumped the event loop into a slice
++nested_units;
return made.make();
});
self = &lazy;
REQUIRE_FALSE(lazy.build_step());
REQUIRE(nested_units == 0);
REQUIRE(made.objects.size() == 1);
REQUIRE(lazy.built());
}
TEST_CASE("A factory that returns null leaves the holder unbuilt and not pending", "[Lazy]")
{
int calls = 0;
Lazy<Plain> lazy("plain", 0, [&] { ++calls; return static_cast<Plain*>(nullptr); });
REQUIRE_FALSE(lazy.build_step());
REQUIRE_FALSE(lazy.built());
REQUIRE_FALSE(lazy.pending());
REQUIRE(lazy.get() == nullptr);
REQUIRE_FALSE(lazy.build_step()); // not retried
REQUIRE(calls == 1);
}
TEST_CASE("ensure returns null for a factory that returned null", "[Lazy]")
{
Lazy<Plain> lazy("plain", 0, [] { return static_cast<Plain*>(nullptr); });
REQUIRE(lazy.ensure() == nullptr);
REQUIRE_FALSE(lazy.built());
}
TEST_CASE("A nested ensure inside the factory returns null", "[Lazy]")
{
Made<Plain> made;
Lazy<Plain>* self = nullptr;
Plain* nested = reinterpret_cast<Plain*>(1);
Lazy<Plain> lazy("plain", 0, [&] {
nested = self->ensure(); // as if the constructor pumped the event loop into a caller
return made.make();
});
self = &lazy;
Plain* built = lazy.ensure();
REQUIRE(built == made.objects[0].get());
REQUIRE(nested == nullptr);
}
TEST_CASE("A nested ensure during a staged step returns null", "[Lazy]")
{
Made<Staged> made;
Lazy<Staged>* self = nullptr;
Staged* nested = reinterpret_cast<Staged*>(1);
Lazy<Staged> lazy("staged", 0, [&] {
Staged* s = made.make();
s->add_step([&] { nested = self->ensure(); }); // as if a step pumped the event loop into a caller
return s;
});
self = &lazy;
Staged* built = lazy.ensure();
REQUIRE(built == made.objects[0].get());
REQUIRE(nested == nullptr);
}
TEST_CASE("A unit that throws leaves the holder free to build the rest", "[Lazy]")
{
Made<Staged> made;
bool thrown = false;
Lazy<Staged> lazy("staged", 0, [&] {
Staged* s = made.make();
s->add_step([&] { thrown = true; throw std::runtime_error("step"); });
return s;
});
lazy.build_step();
lazy.build_step();
lazy.build_step();
REQUIRE_THROWS(lazy.build_step());
REQUIRE(thrown);
REQUIRE(lazy.pending());
REQUIRE_FALSE(lazy.build_step()); // the next unit runs
REQUIRE(lazy.built());
}
+183
View File
@@ -0,0 +1,183 @@
#include <catch2/catch_all.hpp>
#include <string>
#include <vector>
#include "slic3r/GUI/PrebuildQueue.hpp"
using Slic3r::GUI::LazyBase;
using Slic3r::GUI::PrebuildQueue;
namespace {
// A task with `left` units, each taking `unit_ms` of the shared fake clock and logging its id.
struct Counter : LazyBase
{
std::string id;
int left;
int order;
long long unit_ms{ 1 };
inline static std::vector<int> log;
inline static long long now = 0;
Counter(int id, int left, int order, long long unit_ms = 1) : id(std::to_string(id)), left(left), order(order), unit_ms(unit_ms) {}
const std::string& name() const override { return id; }
bool built() const override { return left == 0; }
bool build_step() override
{
now += unit_ms;
log.push_back(std::stoi(id));
return --left > 0;
}
int prebuild_order() const override { return order; }
};
// Resets the shared log and clock at the start of a case.
struct Reset
{
Reset() { Counter::log.clear(); Counter::now = 0; }
};
const auto fake_clock = [] { return Counter::now; };
const auto no_input = [] { return false; };
// Runs slices with an unlimited budget until nothing is pending; one task per slice.
void drain(PrebuildQueue& q)
{
while (q.pending())
q.run_slice(1000000, fake_clock, no_input);
}
} // namespace
TEST_CASE("Tasks run lowest order first, equal order in the order added", "[PrebuildQueue]")
{
Reset reset;
Counter a{ 1, 1, 10 }, b{ 2, 1, 10 }, c{ 3, 1, 50 }, d{ 4, 1, 100 };
PrebuildQueue q;
q.add(c);
q.add(a);
q.add(b);
q.add(d);
REQUIRE(q.names() == "1, 2, 3, 4");
drain(q);
REQUIRE(Counter::log == std::vector<int>{1, 2, 3, 4});
}
TEST_CASE("A task with nothing pending is skipped, not removed", "[PrebuildQueue]")
{
Reset reset;
Counter a{ 1, 0, 0 }, b{ 2, 2, 1 };
PrebuildQueue q;
q.add(a);
q.add(b);
REQUIRE(q.pending());
auto slice = q.run_slice(1, fake_clock, no_input); // one unit of b
REQUIRE(slice.units == 1);
REQUIRE(Counter::log == std::vector<int>{2});
a.left = 1; // a's work returned; it comes first again
q.run_slice(1, fake_clock, no_input);
REQUIRE(Counter::log == std::vector<int>{2, 1});
}
TEST_CASE("A slice with nothing pending runs no unit", "[PrebuildQueue]")
{
Reset reset;
PrebuildQueue q;
REQUIRE_FALSE(q.pending());
auto slice = q.run_slice(40, fake_clock, no_input);
REQUIRE(slice.units == 0);
REQUIRE_FALSE(slice.completed);
REQUIRE_FALSE(slice.remaining);
}
TEST_CASE("A slice stops once its budget is spent, after the unit that crossed it", "[PrebuildQueue]")
{
Reset reset;
Counter a{ 1, 10, 0, 15 };
PrebuildQueue q;
q.add(a);
auto slice = q.run_slice(40, fake_clock, no_input);
REQUIRE(slice.units == 3); // units end at 15, 30 and 45 ms; the one crossing 40 is the last
REQUIRE(slice.ms == 45);
REQUIRE_FALSE(slice.completed);
REQUIRE(slice.remaining);
REQUIRE(a.left == 7);
}
TEST_CASE("A slice stops after the unit during which input arrived", "[PrebuildQueue]")
{
Reset reset;
Counter a{ 1, 10, 0 };
bool input = false;
PrebuildQueue q;
q.add(a);
auto slice = q.run_slice(40, fake_clock, [&] { input = a.left == 8; return input; });
REQUIRE(slice.units == 2);
REQUIRE_FALSE(slice.completed);
REQUIRE(slice.remaining);
}
TEST_CASE("A slice reports completion, whether work remains, and each unit's time", "[PrebuildQueue]")
{
Reset reset;
Counter a{ 1, 2, 0, 5 }, b{ 2, 1, 1 };
std::vector<long long> unit_ms;
PrebuildQueue q;
q.add(a);
q.add(b);
auto slice = q.run_slice(40, fake_clock, no_input, [&](const std::string& name, long long ms) {
REQUIRE(name == "1");
unit_ms.push_back(ms);
});
REQUIRE(slice.units == 2);
REQUIRE(slice.completed);
REQUIRE(slice.name == "1");
REQUIRE(slice.remaining); // b
REQUIRE(unit_ms == std::vector<long long>{5, 5});
slice = q.run_slice(40, fake_clock, no_input);
REQUIRE(slice.completed);
REQUIRE_FALSE(slice.remaining);
REQUIRE_FALSE(q.pending());
}
TEST_CASE("A unit may add a task to the queue it runs from", "[PrebuildQueue]")
{
Reset reset;
PrebuildQueue q;
Counter later{ 2, 1, 5 };
struct Adder : LazyBase
{
PrebuildQueue& q;
Counter& later;
std::string id{ "1" };
bool done{ false };
Adder(PrebuildQueue& q, Counter& later) : q(q), later(later) {}
const std::string& name() const override { return id; }
bool built() const override { return done; }
bool build_step() override
{
Counter::log.push_back(1);
done = true;
q.add(later);
return false;
}
int prebuild_order() const override { return 0; }
} first{ q, later };
q.add(first);
drain(q);
REQUIRE(Counter::log == std::vector<int>{1, 2});
}
TEST_CASE("clear drops every task", "[PrebuildQueue]")
{
Reset reset;
Counter a{ 1, 1, 0 };
PrebuildQueue q;
q.add(a);
q.clear();
REQUIRE_FALSE(q.pending());
REQUIRE(q.run_slice(40, fake_clock, no_input).units == 0);
}
+213
View File
@@ -0,0 +1,213 @@
#include <catch2/catch_all.hpp>
#include <nlohmann/json.hpp>
#include "slic3r/Utils/PrintHost.hpp"
using namespace Slic3r;
namespace {
class TestPrintHost : public PrintHost
{
public:
using PrintHost::format_error;
const char* get_name() const override { return "Test"; }
bool test(wxString&) const override { return true; }
wxString get_test_ok_msg() const override { return {}; }
wxString get_test_failed_msg(wxString&) const override { return {}; }
bool upload(PrintHostUpload, ProgressFn, ErrorFn, InfoFn) const override { return true; }
bool has_auto_discovery() const override { return false; }
bool can_test() const override { return false; }
PrintHostPostUploadActions get_post_upload_actions() const override { return {}; }
std::string get_host() const override { return {}; }
};
std::string format_error(const std::string& body, const std::string& error, unsigned status)
{
return TestPrintHost().format_error(body, error, status).ToStdString();
}
std::string envelope(int code, const std::string& message, const std::string& traceback)
{
return nlohmann::json{{"error", {{"code", code}, {"message", message}, {"traceback", traceback}}}}.dump();
}
std::string moonraker_error(int code, const std::string& message, const std::string& detail = {})
{
std::string line = "tornado.web.HTTPError: HTTP " + std::to_string(code) + ": " + message;
if (!detail.empty())
line += " (" + detail + ")";
return envelope(code, message, "Traceback (most recent call last):\n ...\n" + line + "\n");
}
// A real Moonraker body for uploading a file that is being printed.
constexpr const char* k_busy_file_403 =
R"JSON({"error": {"code": 403, "message": "Forbidden", "traceback": "Traceback (most recent call last):\n\n File \"/home/lava/moonraker/moonraker/components/file_manager/file_manager.py\", line 1017, in _finish_gcode_upload\n can_start = self._handle_operation_check(check_path)\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n\nmoonraker.utils.exceptions.ServerError: File currently in use\n\nDuring handling of the above exception, another exception occurred:\n\nTraceback (most recent call last):\n\n File \"/home/lava/moonraker/moonraker/components/application.py\", line 1069, in post\n raise tornado.web.HTTPError(\ntornado.web.HTTPError: HTTP 403: Forbidden (File is loaded, upload not permitted)\n"}})JSON";
} // namespace
TEST_CASE("A Klipper upload error shows its reason instead of a Python traceback", "[PrintHost][Regression]")
{
const std::string msg = format_error(k_busy_file_403, "", 403);
INFO("actual: " << msg);
CHECK(msg == "HTTP 403: Forbidden (File is loaded, upload not permitted)");
CHECK_THAT(msg, !Catch::Matchers::ContainsSubstring("Traceback"));
CHECK_THAT(msg, !Catch::Matchers::ContainsSubstring("file_manager.py"));
}
TEST_CASE("The specific cause is recovered from a file endpoint's traceback", "[PrintHost]")
{
SECTION("a plain detail")
{
const std::string body = moonraker_error(403, "Forbidden", "File is loaded, upload not permitted");
CHECK(format_error(body, "", 403) == "HTTP 403: Forbidden (File is loaded, upload not permitted)");
}
SECTION("a detail whose own parentheses nest (a filename)")
{
const std::string detail = "Directory does not exist (/home/pi/gcodes/plate (1).gcode)";
const std::string body = moonraker_error(400, "Bad Request", detail);
CHECK(format_error(body, "", 400) == "HTTP 400: Bad Request (" + detail + ")");
}
SECTION("a detail that contains the reason phrase")
{
const std::string body = moonraker_error(403, "Forbidden", "Forbidden zone: access denied");
CHECK(format_error(body, "", 403) == "HTTP 403: Forbidden (Forbidden zone: access denied)");
}
SECTION("a detail that spans lines")
{
const std::string detail = "Move out of range\nX=250.000 Y=10.000";
const std::string body = moonraker_error(400, "Bad Request", detail);
CHECK(format_error(body, "", 400) == "HTTP 400: Bad Request (" + detail + ")");
}
SECTION("a detail that only repeats the reason phrase is dropped")
{
const std::string body = moonraker_error(401, "Unauthorized", "Unauthorized");
CHECK(format_error(body, "", 401) == "HTTP 401: Unauthorized");
}
}
TEST_CASE("An unhandled exception shows its type and message", "[PrintHost]")
{
const std::string frame = "Traceback (most recent call last):\n"
" File \"/home/pi/moonraker/moonraker/components/file_manager/file_manager.py\", line 1, in write\n"
" self._write(data)\n";
SECTION("a one-line message")
{
const std::string body = envelope(500, "Internal Server Error", frame + "OSError: [Errno 28] No space left on device\n");
CHECK(format_error(body, "", 500) == "HTTP 500: Internal Server Error (OSError: [Errno 28] No space left on device)");
}
SECTION("a message that spans lines")
{
const std::string body = envelope(500, "Internal Server Error", frame + "ServerError: Klippy request failed\n see klippy.log\n");
CHECK(format_error(body, "", 500) == "HTTP 500: Internal Server Error (ServerError: Klippy request failed\n see klippy.log)");
}
SECTION("raised while handling an HTTPError with the same code")
{
const std::string traceback = frame + "tornado.web.HTTPError: HTTP 500: Internal Server Error (Database locked)\n\n"
"During handling of the above exception, another exception occurred:\n\n" +
frame + "OSError: [Errno 5] Input/output error\n";
const std::string body = envelope(500, "Internal Server Error", traceback);
CHECK(format_error(body, "", 500) == "HTTP 500: Internal Server Error (OSError: [Errno 5] Input/output error)");
}
SECTION("a traceback with no header")
{
const std::string body = envelope(500, "Internal Server Error", "OSError: [Errno 5] Input/output error");
CHECK(format_error(body, "", 500) == "HTTP 500: Internal Server Error (OSError: [Errno 5] Input/output error)");
}
}
TEST_CASE("A reason already complete in message is shown unchanged", "[PrintHost]")
{
SECTION("message is the whole reason, no trailing detail")
{
const std::string body = moonraker_error(503, "Klippy is not ready");
CHECK(format_error(body, "", 503) == "HTTP 503: Klippy is not ready");
}
SECTION("a message that itself contains parentheses is not duplicated")
{
const std::string reason = "Requested blocks (0-5) are unavailable";
const std::string body = moonraker_error(400, reason);
CHECK(format_error(body, "", 400) == "HTTP 400: " + reason);
}
}
TEST_CASE("A Moonraker error with no usable detail shows just the reason phrase", "[PrintHost]")
{
struct Case
{
const char* name;
const char* body;
unsigned status;
const char* expected;
};
const auto c = GENERATE(
Case{"an empty traceback", R"JSON({"error": {"code": 500, "message": "Internal Server Error", "traceback": ""}})JSON", 500,
"HTTP 500: Internal Server Error"},
Case{"a traceback of only whitespace", R"JSON({"error": {"code": 500, "message": "Internal Server Error", "traceback": "\n \n"}})JSON",
500, "HTTP 500: Internal Server Error"});
DYNAMIC_SECTION(c.name) { CHECK(format_error(c.body, "", c.status) == c.expected); }
}
TEST_CASE("A percent sign in the reason is not a format specifier", "[PrintHost]")
{
const std::string body = moonraker_error(507, "Insufficient Storage", "disk 100% full");
CHECK(format_error(body, "", 507) == "HTTP 507: Insufficient Storage (disk 100% full)");
}
TEST_CASE("Error bodies that are not a Moonraker envelope are left unchanged", "[PrintHost]")
{
SECTION("OctoPrint's string-valued error member")
{
const std::string body = R"JSON({"error": "File not found"})JSON";
CHECK(format_error(body, "", 404) == "HTTP 404: " + body);
}
SECTION("PrusaLink's top-level message, not under error")
{
const std::string body = R"JSON({"title": "Conflict", "message": "Printer is printing"})JSON";
CHECK(format_error(body, "", 409) == "HTTP 409: " + body);
}
SECTION("a body that is not JSON")
{
const std::string html = "<html><head><title>502 Bad Gateway</title></head></html>";
CHECK(format_error(html, "", 502) == "HTTP 502: " + html);
}
SECTION("an error object with no traceback")
{
const std::string body = R"JSON({"error": {"code": 500, "message": "Internal Server Error"}})JSON";
CHECK(format_error(body, "", 500) == "HTTP 500: " + body);
}
SECTION("an error object whose traceback is null")
{
const std::string body = R"JSON({"error": {"code": 500, "message": "Internal Server Error", "traceback": null}})JSON";
CHECK(format_error(body, "", 500) == "HTTP 500: " + body);
}
SECTION("an envelope whose reason phrase is empty")
{
const std::string body = envelope(403, "", "Traceback (most recent call last):\nOSError: denied\n");
CHECK(format_error(body, "", 403) == "HTTP 403: " + body);
}
SECTION("a transport error with no HTTP status")
{
CHECK(format_error("", "curl:Could not connect", 0) == "curl:Could not connect");
}
}
+178
View File
@@ -0,0 +1,178 @@
// Orca: This suite links libslic3r_gui; navigation raycasts need no wx application or GL context.
#ifdef WIN32
#ifndef WIN32_LEAN_AND_MEAN
#define WIN32_LEAN_AND_MEAN
#endif
#ifndef NOMINMAX
#define NOMINMAX
#endif
#include <Windows.h>
// Match the GUI precompiled header: wx/msw/wrapcctl.h needs HDITEM from CommCtrl.h.
#include <CommCtrl.h>
#endif
#include <catch2/catch_all.hpp>
#include "slic3r/GUI/Camera.hpp"
#include "slic3r/GUI/CameraUtils.hpp"
#include "slic3r/GUI/PartPlate.hpp"
#include "slic3r/GUI/SceneRaycaster.hpp"
using namespace Slic3r;
using namespace Slic3r::GUI;
namespace {
Camera horizontal_camera(Camera::EType type = Camera::EType::Perspective)
{
Camera camera;
camera.set_type(type);
camera.look_at({0.0, 0.0, 10.0}, {0.0, 100.0, 10.0}, Vec3d::UnitZ());
camera.set_viewport(0, 0, 600, 600);
camera.apply_projection(-1.0, 1.0, -1.0, 1.0, 1.0, 1000.0);
return camera;
}
SceneRaycaster::HitResult scene_hit(const SceneRaycaster& scene, const Camera& camera, const Vec3d& point,
SceneRaycaster::EHitMode mode = SceneRaycaster::EHitMode::SceneOnly)
{
return scene.hit(CameraUtils::project(camera, point).cast<double>(), camera, nullptr, mode);
}
} // namespace
TEST_CASE("Navigation hits the visible bed below a horizontal perspective view", "[SceneRaycaster][Regression]")
{
const MeshRaycaster bed(TriangleMesh(
{{-100.f, 20.f, 0.f}, {100.f, 20.f, 0.f}, {100.f, 200.f, 0.f}, {-100.f, 200.f, 0.f}},
{{0, 1, 2}, {0, 2, 3}}));
SceneRaycaster scene;
scene.add_raycaster(SceneRaycaster::EType::Bed, 0, bed, Transform3d::Identity());
const Camera camera = horizontal_camera();
const auto hit = scene_hit(scene, camera, {0.0, 100.0, 0.0});
REQUIRE(hit.is_valid());
CHECK(hit.type == SceneRaycaster::EType::Bed);
CHECK_THAT(hit.position.z(), Catch::Matchers::WithinAbs(0.0, 1e-4));
}
TEST_CASE("Navigation hits a visible side face away from the perspective view center", "[SceneRaycaster][Regression]")
{
const auto mode = GENERATE(SceneRaycaster::EHitMode::SceneOnly, SceneRaycaster::EHitMode::VolumesOnly);
const MeshRaycaster side(TriangleMesh(
{{10.f, 20.f, -100.f}, {10.f, 20.f, 100.f}, {10.f, 200.f, 100.f}, {10.f, 200.f, -100.f}},
{{0, 1, 2}, {0, 2, 3}}));
SceneRaycaster scene;
scene.add_raycaster(SceneRaycaster::EType::Volume, 0, side, Transform3d::Identity());
const Camera camera = horizontal_camera();
const auto hit = scene_hit(scene, camera, {10.0, 100.0, 10.0}, mode);
REQUIRE(hit.is_valid());
CHECK(hit.type == SceneRaycaster::EType::Volume);
CHECK_THAT(hit.position.x(), Catch::Matchers::WithinAbs(10.0, 1e-4));
}
TEST_CASE("Navigation ignores gizmos and inactive volumes and chooses the nearest scene surface", "[SceneRaycaster]")
{
const bool gizmos_on_top = GENERATE(false, true);
const auto mode = GENERATE(SceneRaycaster::EHitMode::SceneOnly, SceneRaycaster::EHitMode::VolumesOnly);
const auto type = GENERATE(Camera::EType::Perspective, Camera::EType::Ortho);
const MeshRaycaster cube(make_cube(20.0, 20.0, 20.0));
SceneRaycaster scene;
scene.set_gizmos_on_top(gizmos_on_top);
scene.add_raycaster(SceneRaycaster::EType::Gizmo, 0, cube, Geometry::translation_transform({-10.0, 20.0, 0.0}));
scene.add_raycaster(SceneRaycaster::EType::FallbackGizmo, 0, cube, Geometry::translation_transform({-10.0, 30.0, 0.0}));
scene.add_raycaster(SceneRaycaster::EType::Volume, 0, cube, Geometry::translation_transform({-10.0, 40.0, 0.0}))->set_active(false);
scene.add_raycaster(SceneRaycaster::EType::Volume, 1, cube, Geometry::translation_transform({-10.0, 150.0, 0.0}));
scene.add_raycaster(SceneRaycaster::EType::Volume, 2, cube, Geometry::translation_transform({-10.0, 100.0, 0.0}));
const Camera camera = horizontal_camera(type);
const auto hit = scene_hit(scene, camera, {0.0, 100.0, 10.0}, mode);
REQUIRE(hit.is_valid());
CHECK(hit.type == SceneRaycaster::EType::Volume);
CHECK(hit.raycaster_id == 2);
CHECK_THAT(hit.position.y(), Catch::Matchers::WithinAbs(100.0, 1e-4));
}
TEST_CASE("Navigation skips bed raycasters when the bed is hidden", "[SceneRaycaster][Regression]")
{
const auto mode = GENERATE(SceneRaycaster::EHitMode::SceneOnly, SceneRaycaster::EHitMode::VolumesOnly);
const auto type = GENERATE(Camera::EType::Perspective, Camera::EType::Ortho);
const bool looking_downward = GENERATE(false, true);
const MeshRaycaster cube(make_cube(20.0, 20.0, 20.0));
SceneRaycaster scene;
scene.add_raycaster(SceneRaycaster::EType::Bed, 0, cube, Geometry::translation_transform({-10.0, 40.0, 0.0}));
scene.add_raycaster(SceneRaycaster::EType::Volume, 0, cube, Geometry::translation_transform({-10.0, 100.0, 0.0}));
Camera camera = horizontal_camera(type);
if (looking_downward)
camera.look_at({0.0, 0.0, 10.0}, {0.0, 100.0, 0.0}, Vec3d::UnitZ());
const auto hit = scene_hit(scene, camera, {0.0, 100.0, 10.0}, mode);
REQUIRE(hit.is_valid());
CHECK(hit.type == (mode == SceneRaycaster::EHitMode::SceneOnly ?
SceneRaycaster::EType::Bed : SceneRaycaster::EType::Volume));
scene.remove_raycasters(SceneRaycaster::EType::Volume);
CHECK(scene_hit(scene, camera, {0.0, 100.0, 10.0}, mode).is_valid() ==
(mode == SceneRaycaster::EHitMode::SceneOnly));
}
TEST_CASE("Navigation ignores plate controls while retaining plate surfaces and volumes", "[SceneRaycaster][Regression]")
{
const int plate_index = GENERATE(0, 2);
const int component = GENERATE(range(1, int(PartPlate::GRABBER_COUNT)));
const int bed_id = plate_index * PartPlate::GRABBER_COUNT;
const MeshRaycaster cube(make_cube(20.0, 20.0, 20.0));
SceneRaycaster scene;
scene.add_raycaster(SceneRaycaster::EType::Bed, bed_id + component, cube,
Geometry::translation_transform({-10.0, 40.0, 0.0}));
scene.add_raycaster(SceneRaycaster::EType::Bed, bed_id, cube,
Geometry::translation_transform({-10.0, 100.0, 0.0}));
scene.add_raycaster(SceneRaycaster::EType::Volume, 0, cube,
Geometry::translation_transform({-10.0, 150.0, 0.0}));
const Camera camera = horizontal_camera();
auto hit = scene_hit(scene, camera, {0.0, 100.0, 10.0});
REQUIRE(hit.is_valid());
CHECK(hit.type == SceneRaycaster::EType::Bed);
CHECK(hit.raycaster_id == bed_id);
scene.remove_raycasters(SceneRaycaster::EType::Bed, bed_id);
hit = scene_hit(scene, camera, {0.0, 100.0, 10.0});
REQUIRE(hit.is_valid());
CHECK(hit.type == SceneRaycaster::EType::Volume);
// A control alone must leave navigation free to choose its fallback anchor.
scene.remove_raycasters(SceneRaycaster::EType::Volume);
CHECK_FALSE(scene_hit(scene, camera, {0.0, 100.0, 10.0}).is_valid());
}
TEST_CASE("Navigation respects the back-face policy away from the perspective view center", "[SceneRaycaster]")
{
const bool use_back_faces = GENERATE(false, true);
const auto mode = GENERATE(SceneRaycaster::EHitMode::SceneOnly, SceneRaycaster::EHitMode::VolumesOnly);
const MeshRaycaster side(TriangleMesh(
{{10.f, 20.f, -100.f}, {10.f, 20.f, 100.f}, {10.f, 200.f, 100.f}, {10.f, 200.f, -100.f}},
{{0, 2, 1}, {0, 3, 2}}));
SceneRaycaster scene;
scene.add_raycaster(SceneRaycaster::EType::Volume, 0, side, Transform3d::Identity(), use_back_faces);
const auto hit = scene_hit(scene, horizontal_camera(), {10.0, 100.0, 10.0}, mode);
CHECK(hit.is_valid() == use_back_faces);
}
TEST_CASE("Navigation ignores volume surfaces removed by the clipping plane", "[SceneRaycaster]")
{
const auto mode = GENERATE(SceneRaycaster::EHitMode::SceneOnly, SceneRaycaster::EHitMode::VolumesOnly);
const MeshRaycaster cube(make_cube(20.0, 20.0, 20.0));
SceneRaycaster scene;
scene.add_raycaster(SceneRaycaster::EType::Volume, 0, cube, Geometry::translation_transform({-10.0, 100.0, 0.0}));
scene.add_raycaster(SceneRaycaster::EType::Volume, 1, cube, Geometry::translation_transform({-10.0, 150.0, 0.0}));
const Camera camera = horizontal_camera();
const ClippingPlane clipping_plane(-Vec3d::UnitY(), -130.0);
const auto hit = scene.hit({300.0, 300.0}, camera, &clipping_plane, mode);
REQUIRE(hit.is_valid());
CHECK(hit.raycaster_id == 1);
CHECK_THAT(hit.position.y(), Catch::Matchers::WithinAbs(150.0, 1e-4));
}
+14
View File
@@ -120,6 +120,20 @@ TEST_CASE("Only modified or non-printable chords qualify as menu accelerators",
CHECK(registry.accelerator(Shortcut::KeyboardShortcuts).empty());
}
TEST_CASE("Chords the desktop keeps for itself are recognized", "[Shortcuts]")
{
#ifdef _WIN32
CHECK(KeyChord{ WXK_F4, wxMOD_ALT }.is_system_shortcut());
CHECK(KeyChord{ WXK_SPACE, wxMOD_ALT }.is_system_shortcut());
#else
CHECK_FALSE(KeyChord{ WXK_F4, wxMOD_ALT }.is_system_shortcut());
CHECK_FALSE(KeyChord{ WXK_SPACE, wxMOD_ALT }.is_system_shortcut());
#endif
CHECK_FALSE(KeyChord{ WXK_F4, wxMOD_ALT | wxMOD_SHIFT }.is_system_shortcut());
CHECK_FALSE(KeyChord{ WXK_F4, wxMOD_CONTROL }.is_system_shortcut());
CHECK_FALSE(KeyChord{ WXK_SPACE }.is_system_shortcut());
}
TEST_CASE("Chords convert to wx accelerator entries", "[Shortcuts]")
{
const wxAcceleratorEntry entry = KeyChord{ 'S', wxMOD_CONTROL | wxMOD_SHIFT }.to_accelerator_entry(42);
+105
View File
@@ -0,0 +1,105 @@
#include <catch2/catch_all.hpp>
#include <vector>
#include "slic3r/GUI/StagedBuild.hpp"
using Slic3r::GUI::StagedBuild;
namespace {
// Exposes the protected queueing calls and records the order steps ran in.
struct Staged : StagedBuild
{
std::vector<int> ran;
void queue(int id) { add_build_step([this, id] { ran.push_back(id); }); }
void queue_child(Staged& child) { add_build_steps_of(child); }
void queue_nested(int id, int nested)
{
add_build_step([this, id, nested] {
ran.push_back(id);
queue(nested);
});
}
};
} // namespace
TEST_CASE("Steps run in the order they were queued, one per build_step", "[StagedBuild]")
{
Staged s;
s.queue(1);
s.queue(2);
s.queue(3);
REQUIRE_FALSE(s.built());
REQUIRE(s.build_step());
REQUIRE(s.ran == std::vector<int>{1});
REQUIRE(s.build_step());
REQUIRE(s.ran == std::vector<int>{1, 2});
REQUIRE_FALSE(s.build_step());
REQUIRE(s.ran == std::vector<int>{1, 2, 3});
REQUIRE(s.built());
REQUIRE_FALSE(s.build_step());
REQUIRE(s.ran.size() == 3);
}
TEST_CASE("A panel with no steps is built from the start", "[StagedBuild]")
{
Staged s;
REQUIRE(s.built());
REQUIRE_FALSE(s.build_step());
}
TEST_CASE("A step may queue another step, which runs after the ones already queued", "[StagedBuild]")
{
Staged s;
s.queue_nested(1, 3);
s.queue(2);
REQUIRE(s.build_step());
REQUIRE_FALSE(s.built());
REQUIRE(s.build_step());
REQUIRE_FALSE(s.build_step());
REQUIRE(s.ran == std::vector<int>{1, 2, 3});
REQUIRE(s.built());
}
TEST_CASE("A parent waits for steps a child queues after being adopted", "[StagedBuild]")
{
Staged child;
child.queue_nested(1, 2); // step 1 queues step 2 while it runs
Staged parent;
parent.queue_child(child); // one forwarder, for step 1
parent.queue(10);
REQUIRE(parent.build_step()); // child step 1, which queues step 2
REQUIRE(parent.build_step()); // 10; own steps exhausted, the child still has 2
REQUIRE_FALSE(parent.built());
REQUIRE_FALSE(parent.build_step()); // child step 2
REQUIRE(child.ran == std::vector<int>{1, 2});
REQUIRE(parent.ran == std::vector<int>{10});
REQUIRE(parent.built());
}
TEST_CASE("A child's remaining steps are forwarded one per parent step", "[StagedBuild]")
{
Staged child;
child.queue(1);
child.queue(2);
child.queue(3);
REQUIRE(child.build_step()); // the parent adopts only what is left
Staged parent;
parent.queue_child(child);
parent.queue(10);
REQUIRE(parent.build_step());
REQUIRE(child.ran == std::vector<int>{1, 2});
REQUIRE(parent.build_step());
REQUIRE(child.ran == std::vector<int>{1, 2, 3});
REQUIRE(child.built());
REQUIRE_FALSE(parent.build_step());
REQUIRE(parent.ran == std::vector<int>{10});
REQUIRE(parent.built());
}