Merge branch 'main' into fix/slicing-evt-name

This commit is contained in:
Ian Chua
2026-09-27 17:57:40 +08:00
committed by GitHub
2671 changed files with 202582 additions and 265477 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));
}
}
+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);
}
+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.
+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);