Compare commits

..
Author SHA1 Message Date
Hanif Koh ec5580667d Address Review of the Reworked Unit Tests
Recompute the v4 recipe comparison once instead of per feature (148 s to 6 s) and compare bounding boxes as well as volumes; drop the incidental NaN loop from the unrepairable Voronoi case; prove the renamed preset is the one selected instead of checking a dirty flag that selecting clears; tell sparse from empty infill in the solid-shell test; plus smaller cleanups.
2026-10-19 18:12:40 +08:00
Hanif Koh 32d92a55d3 Make Unit Tests That Could Never Fail Check Real Behavior
Rework tests whose assertions held whatever the production code did: inverted or self-referential checks, checks on values the test had just set, branches that never ran, symmetric fixtures that hid the behavior under test, font paths that skipped silently, and Voronoi reproducers that only exercised boost. Each now asserts the behavior its name describes.
2026-10-19 17:02:49 +08:00
44 changed files with 1908 additions and 299 deletions

No files matched your search

+1
View File
@@ -0,0 +1 @@
solid STL generated by MeshLab␍ facet normal 0.000000e+00 -0.000000e+00 -1.000000e+00␍ outer loop␍ vertex 2.000000e+01 2.000000e+01 0.000000e+00␍ vertex 2.000000e+01 0.000000e+00 0.000000e+00␍ vertex 0.000000e+00 0.000000e+00 0.000000e+00␍ endloop␍ endfacet␍ facet normal -0.000000e+00 0.000000e+00 -1.000000e+00␍ outer loop␍ vertex 2.000000e+01 2.000000e+01 0.000000e+00␍ vertex 0.000000e+00 0.000000e+00 0.000000e+00␍ vertex 0.000000e+00 2.000000e+01 0.000000e+00␍ endloop␍ endfacet␍ facet normal 0.000000e+00 0.000000e+00 1.000000e+00␍ outer loop␍ vertex 2.000000e+01 2.000000e+01 2.000000e+01␍ vertex 0.000000e+00 2.000000e+01 2.000000e+01␍ vertex 0.000000e+00 0.000000e+00 2.000000e+01␍ endloop␍ endfacet␍ facet normal 0.000000e+00 0.000000e+00 1.000000e+00␍ outer loop␍ vertex 2.000000e+01 2.000000e+01 2.000000e+01␍ vertex 0.000000e+00 0.000000e+00 2.000000e+01␍ vertex 2.000000e+01 0.000000e+00 2.000000e+01␍ endloop␍ endfacet␍ facet normal 1.000000e+00 0.000000e+00 -0.000000e+00␍ outer loop␍ vertex 2.000000e+01 2.000000e+01 0.000000e+00␍ vertex 2.000000e+01 2.000000e+01 2.000000e+01␍ vertex 2.000000e+01 0.000000e+00 2.000000e+01␍ endloop␍ endfacet␍ facet normal 1.000000e+00 0.000000e+00 0.000000e+00␍ outer loop␍ vertex 2.000000e+01 2.000000e+01 0.000000e+00␍ vertex 2.000000e+01 0.000000e+00 2.000000e+01␍ vertex 2.000000e+01 0.000000e+00 0.000000e+00␍ endloop␍ endfacet␍ facet normal 0.000000e+00 -1.000000e+00 0.000000e+00␍ outer loop␍ vertex 2.000000e+01 0.000000e+00 0.000000e+00␍ vertex 2.000000e+01 0.000000e+00 2.000000e+01␍ vertex 0.000000e+00 0.000000e+00 2.000000e+01␍ endloop␍ endfacet␍ facet normal 0.000000e+00 -1.000000e+00 0.000000e+00␍ outer loop␍ vertex 2.000000e+01 0.000000e+00 0.000000e+00␍ vertex 0.000000e+00 0.000000e+00 2.000000e+01␍ vertex 0.000000e+00 0.000000e+00 0.000000e+00␍ endloop␍ endfacet␍ facet normal -1.000000e+00 0.000000e+00 0.000000e+00␍ outer loop␍ vertex 0.000000e+00 0.000000e+00 0.000000e+00␍ vertex 0.000000e+00 0.000000e+00 2.000000e+01␍ vertex 0.000000e+00 2.000000e+01 2.000000e+01␍ endloop␍ endfacet␍ facet normal -1.000000e+00 0.000000e+00 0.000000e+00␍ outer loop␍ vertex 0.000000e+00 0.000000e+00 0.000000e+00␍ vertex 0.000000e+00 2.000000e+01 2.000000e+01␍ vertex 0.000000e+00 2.000000e+01 0.000000e+00␍ endloop␍ endfacet␍ facet normal 0.000000e+00 1.000000e+00 0.000000e+00␍ outer loop␍ vertex 2.000000e+01 2.000000e+01 2.000000e+01␍ vertex 2.000000e+01 2.000000e+01 0.000000e+00␍ vertex 0.000000e+00 2.000000e+01 0.000000e+00␍ endloop␍ endfacet␍ facet normal 0.000000e+00 1.000000e+00 0.000000e+00␍ outer loop␍ vertex 2.000000e+01 2.000000e+01 2.000000e+01␍ vertex 0.000000e+00 2.000000e+01 0.000000e+00␍ vertex 0.000000e+00 2.000000e+01 2.000000e+01␍ endloop␍ endfacet␍endsolid vcg␍
+39 -5
View File
@@ -1,6 +1,7 @@
#include <catch2/catch_all.hpp>
#include <catch2/catch_test_macros.hpp>
#include <catch2/catch_message.hpp>
#include "test_helpers.hpp"
#include <algorithm>
@@ -17,15 +18,48 @@
using namespace Slic3r;
using namespace Slic3r::Test;
// The fan is held off for the first close_fan_the_first_x_layers layers, so an explicit
// fan-off command is emitted.
// The fan stays off while printing the first close_fan_the_first_x_layers layers and runs afterwards.
TEST_CASE("Fan is held off for the initial layers", "[Cooling]")
{
const int fan_off_layers = 5;
const std::string gcode = slice({ cube(20) }, {
{ "cooling", true },
{ "close_fan_the_first_x_layers", 5 },
{ "close_fan_the_first_x_layers", fan_off_layers },
{ "full_fan_speed_layer", 0 },
{ "fan_min_speed", 50 },
{ "fan_max_speed", 100 },
{ "fan_cooling_layer_time", 1000 }, // every layer is short enough to run the fan
{ "layer_height", 0.2 },
{ "initial_layer_print_height", 0.2 },
});
CHECK(gcode.find("M106 S0") != std::string::npos);
// Record, for each layer, whether any extrusion was printed with the part fan running.
std::vector<bool> extruded_with_fan;
int layer = -1;
bool fan_on = false;
std::istringstream input(gcode);
std::string line;
while (std::getline(input, line)) {
if (line.rfind(";LAYER_CHANGE", 0) == 0) {
++layer;
extruded_with_fan.push_back(false);
} else if (line.rfind("M107", 0) == 0) {
fan_on = false;
} else if (line.rfind("M106 ", 0) == 0 && line.find(" P") == std::string::npos) {
const size_t s = line.find(" S");
fan_on = s != std::string::npos && std::stod(line.substr(s + 2)) > 0.;
} else if (layer >= 0 && fan_on && line.rfind("G1 ", 0) == 0 && line.find(" E") != std::string::npos &&
(line.find(" X") != std::string::npos || line.find(" Y") != std::string::npos)) {
extruded_with_fan[layer] = true;
}
}
// A 20 mm cube at 0.2 mm layers has 100 layers.
REQUIRE(extruded_with_fan.size() == 100);
for (int i = 0; i < fan_off_layers; ++i) {
CAPTURE(i);
CHECK_FALSE(extruded_with_fan[i]);
}
CHECK(extruded_with_fan[fan_off_layers]);
}
// The cooling pass resolves and strips its internal speed placeholders; none leak into
+298 -10
View File
@@ -44,6 +44,7 @@
#include "libslic3r/Layer.hpp"
#include "libslic3r/Print.hpp"
#include "libslic3r/PrintConfig.hpp"
#include "libslic3r/SVG.hpp"
#include "libslic3r/libslic3r.h"
#include "test_helpers.hpp"
@@ -52,6 +53,15 @@ using namespace Slic3r;
bool test_if_solid_surface_filled(const ExPolygon& expolygon, double flow_spacing, double angle = 0, double density = 1.0);
#if 0
TEST_CASE("Adjusted solid distance", "[Fill]") {
int surface_width = 250;
int distance = Slic3r::Flow::solid_spacing(surface_width, 47);
REQUIRE(distance == Catch::Approx(50));
REQUIRE(surface_width % distance == 0);
}
#endif
TEST_CASE("Pattern path length", "[Fill]") {
std::unique_ptr<Slic3r::Fill> filler(Slic3r::Fill::new_from_type("rectilinear"));
filler->angle = float(-(PI)/2.0);
@@ -76,10 +86,9 @@ TEST_CASE("Pattern path length", "[Fill]") {
Slic3r::Polylines paths = test(Slic3r::ExPolygon(test_set));
REQUIRE(paths.size() == 1); // one continuous path
// TODO: determine what the "Expected length" should be for rectilinear fill of a 100x100 polygon.
// This check only checks that it's above scale(3*100 + 2*50) + scaled_epsilon.
// ok abs($paths->[0]->length - scale(3*100 + 2*50)) - scaled_epsilon, 'path has expected length';
REQUIRE(std::abs(paths[0].length() - static_cast<double>(scale_(3*100 + 2*50))) - SCALED_EPSILON > 0); // path has expected length
// Lines are 50 mm apart and the square is inset by half a spacing, so the lines on its edges drop out
// and a single line crosses the middle, ending within half a spacing of the edges.
CHECK_THAT(unscale<double>(paths[0].length()), Catch::Matchers::WithinAbs(100., filler->spacing / 2.));
test_set.clear();
}
@@ -110,6 +119,15 @@ TEST_CASE("Pattern path length", "[Fill]") {
filler->angle = float(angle);
ExPolygon e(test_square, test_hole);
Slic3r::Polylines paths = test(e);
#if 0
{
BoundingBox bbox = get_extents(e);
SVG svg("c:\\data\\temp\\square_with_holes.svg", bbox);
svg.draw(e);
svg.draw(paths);
svg.Close();
}
#endif
REQUIRE((paths.size() >= 1 && paths.size() <= 3));
// paths don't cross hole
REQUIRE(diff_pl(paths, offset(e, float(SCALED_EPSILON*10))).size() == 0);
@@ -129,10 +147,9 @@ TEST_CASE("Pattern path length", "[Fill]") {
Slic3r::Polylines paths = test(Slic3r::ExPolygon(points));
REQUIRE(paths.size() == 1); // one continuous path
// TODO: determine what the "Expected length" should be for rectilinear fill of a 100x100 polygon.
// This check only checks that it's above scale(3*100 + 2*50) + scaled_epsilon.
// ok abs($paths->[0]->length - scale(3*100 + 2*50)) - scaled_epsilon, 'path has expected length';
REQUIRE(std::abs(paths[0].length() - static_cast<double>(scale_(3*100 + 2*50))) - SCALED_EPSILON > 0); // path has expected length
// A solid fill covers the area with lines one spacing apart, so a missing segment shortens the path.
const double expected_length = unscale<double>(unscale<double>(Slic3r::ExPolygon(points).area())) / filler->spacing;
CHECK_THAT(unscale<double>(paths[0].length()), Catch::Matchers::WithinRel(expected_length, 0.01));
}
SECTION("Rotated Square") {
@@ -149,13 +166,33 @@ TEST_CASE("Pattern path length", "[Fill]") {
fill_params.density = 1.0;
filler->spacing = flow.spacing();
for (auto angle : { 0.0, 45.0}) {
for (auto angle : { 0.0, PI / 4.0 }) {
surface.expolygon.rotate(angle, Point(0,0));
filler->bounding_box = get_extents(surface.expolygon.contour);
Polylines paths = filler->fill_surface(&surface, fill_params);
REQUIRE(paths.size() == 1);
}
}
#if 0 // Disabled temporarily due to precision issues on the Mac VM
SECTION("Solid surface fill") {
Slic3r::Points points {
Point::new_scale(6883102, 9598327.01296997),
Point::new_scale(6883102, 20327272.01297),
Point::new_scale(3116896, 20327272.01297),
Point::new_scale(3116896, 9598327.01296997)
};
Slic3r::ExPolygon expolygon(points);
REQUIRE(test_if_solid_surface_filled(expolygon, 0.55) == true);
for (size_t i = 0; i <= 20; ++i)
{
expolygon.scale(1.05);
REQUIRE(test_if_solid_surface_filled(expolygon, 0.55) == true);
}
}
#endif
SECTION("Solid surface fill") {
Slic3r::Points points {
Slic3r::Point(59515297,5422499),Slic3r::Point(59531249,5578697),Slic3r::Point(59695801,6123186),
@@ -184,10 +221,249 @@ TEST_CASE("Pattern path length", "[Fill]") {
};
Slic3r::ExPolygon expolygon(points);
REQUIRE(test_if_solid_surface_filled(expolygon, 0.5, 45.0, 0.99) == true);
REQUIRE(test_if_solid_surface_filled(expolygon, 0.5, PI / 4.0, 0.99) == true);
}
}
/*
{
my $collection = Slic3r::Polyline::Collection->new(
Slic3r::Polyline->new([0,15], [0,18], [0,20]),
Slic3r::Polyline->new([0,10], [0,8], [0,5]),
);
is_deeply
[ map $_->[Y], map @$_, @{$collection->chained_path_from(Slic3r::Point->new(0,30), 0)} ],
[20, 18, 15, 10, 8, 5],
'chained path';
}
{
my $collection = Slic3r::Polyline::Collection->new(
Slic3r::Polyline->new([4,0], [10,0], [15,0]),
Slic3r::Polyline->new([10,5], [15,5], [20,5]),
);
is_deeply
[ map $_->[X], map @$_, @{$collection->chained_path_from(Slic3r::Point->new(30,0), 0)} ],
[reverse 4, 10, 15, 10, 15, 20],
'chained path';
}
{
my $collection = Slic3r::ExtrusionPath::Collection->new(
map Slic3r::ExtrusionPath->new(polyline => $_, role => 0, mm3_per_mm => 1),
Slic3r::Polyline->new([0,15], [0,18], [0,20]),
Slic3r::Polyline->new([0,10], [0,8], [0,5]),
);
is_deeply
[ map $_->[Y], map @{$_->polyline}, @{$collection->chained_path_from(Slic3r::Point->new(0,30), 0)} ],
[20, 18, 15, 10, 8, 5],
'chained path';
}
{
my $collection = Slic3r::ExtrusionPath::Collection->new(
map Slic3r::ExtrusionPath->new(polyline => $_, role => 0, mm3_per_mm => 1),
Slic3r::Polyline->new([15,0], [10,0], [4,0]),
Slic3r::Polyline->new([10,5], [15,5], [20,5]),
);
is_deeply
[ map $_->[X], map @{$_->polyline}, @{$collection->chained_path_from(Slic3r::Point->new(30,0), 0)} ],
[reverse 4, 10, 15, 10, 15, 20],
'chained path';
}
for my $pattern (qw(rectilinear honeycomb hilbertcurve concentric)) {
my $config = Slic3r::Config->new_from_defaults;
$config->set('fill_pattern', $pattern);
$config->set('external_fill_pattern', $pattern);
$config->set('perimeters', 1);
$config->set('skirts', 0);
$config->set('fill_density', 20);
$config->set('layer_height', 0.05);
$config->set('perimeter_extruder', 1);
$config->set('infill_extruder', 2);
my $print = Slic3r::Test::init_print('20mm_cube', config => $config, scale => 2);
ok my $gcode = Slic3r::Test::gcode($print), "successful $pattern infill generation";
my $tool = undef;
my @perimeter_points = my @infill_points = ();
Slic3r::GCode::Reader->new->parse($gcode, sub {
my ($self, $cmd, $args, $info) = @_;
if ($cmd =~ /^T(\d+)/) {
$tool = $1;
} elsif ($cmd eq 'G1' && $info->{extruding} && $info->{dist_XY} > 0) {
if ($tool == $config->perimeter_extruder-1) {
push @perimeter_points, Slic3r::Point->new_scale($args->{X}, $args->{Y});
} elsif ($tool == $config->infill_extruder-1) {
push @infill_points, Slic3r::Point->new_scale($args->{X}, $args->{Y});
}
}
});
my $convex_hull = convex_hull(\@perimeter_points);
ok !(defined first { !$convex_hull->contains_point($_) } @infill_points), "infill does not exceed perimeters ($pattern)";
}
{
my $config = Slic3r::Config->new_from_defaults;
$config->set('infill_only_where_needed', 1);
$config->set('bottom_solid_layers', 0);
$config->set('infill_extruder', 2);
$config->set('infill_extrusion_width', 0.5);
$config->set('fill_density', 40);
$config->set('cooling', 0); # for preventing speeds from being altered
$config->set('first_layer_speed', '100%'); # for preventing speeds from being altered
my $test = sub {
my $print = Slic3r::Test::init_print('pyramid', config => $config);
my $tool = undef;
my @infill_extrusions = (); # array of polylines
Slic3r::GCode::Reader->new->parse(Slic3r::Test::gcode($print), sub {
my ($self, $cmd, $args, $info) = @_;
if ($cmd =~ /^T(\d+)/) {
$tool = $1;
} elsif ($cmd eq 'G1' && $info->{extruding} && $info->{dist_XY} > 0) {
if ($tool == $config->infill_extruder-1) {
push @infill_extrusions, Slic3r::Line->new_scale(
[ $self->X, $self->Y ],
[ $info->{new_X}, $info->{new_Y} ],
);
}
}
});
return 0 if !@infill_extrusions; # prevent calling convex_hull() with no points
my $convex_hull = convex_hull([ map $_->pp, map @$_, @infill_extrusions ]);
return unscale unscale sum(map $_->area, @{offset([$convex_hull], scale(+$config->infill_extrusion_width/2))});
};
my $tolerance = 5; # mm^2
$config->set('solid_infill_below_area', 0);
ok $test->() < $tolerance,
'no infill is generated when using infill_only_where_needed on a pyramid';
$config->set('solid_infill_below_area', 70);
ok abs($test->() - $config->solid_infill_below_area) < $tolerance,
'infill is only generated under the forced solid shells';
}
{
my $config = Slic3r::Config->new_from_defaults;
$config->set('skirts', 0);
$config->set('perimeters', 1);
$config->set('fill_density', 0);
$config->set('top_solid_layers', 0);
$config->set('bottom_solid_layers', 0);
$config->set('solid_infill_below_area', 20000000);
$config->set('solid_infill_every_layers', 2);
$config->set('perimeter_speed', 99);
$config->set('external_perimeter_speed', 99);
$config->set('cooling', 0);
$config->set('first_layer_speed', '100%');
my $print = Slic3r::Test::init_print('20mm_cube', config => $config);
my %layers_with_extrusion = ();
Slic3r::GCode::Reader->new->parse(Slic3r::Test::gcode($print), sub {
my ($self, $cmd, $args, $info) = @_;
if ($cmd eq 'G1' && $info->{dist_XY} > 0 && $info->{extruding}) {
if (($args->{F} // $self->F) != $config->perimeter_speed*60) {
$layers_with_extrusion{$self->Z} = ($args->{F} // $self->F);
}
}
});
ok !%layers_with_extrusion,
"solid_infill_below_area and solid_infill_every_layers are ignored when fill_density is 0";
}
{
my $config = Slic3r::Config->new_from_defaults;
$config->set('skirts', 0);
$config->set('perimeters', 3);
$config->set('fill_density', 0);
$config->set('layer_height', 0.2);
$config->set('first_layer_height', 0.2);
$config->set('nozzle_diameter', [0.35]);
$config->set('infill_extruder', 2);
$config->set('solid_infill_extruder', 2);
$config->set('infill_extrusion_width', 0.52);
$config->set('solid_infill_extrusion_width', 0.52);
$config->set('first_layer_extrusion_width', 0);
my $print = Slic3r::Test::init_print('A', config => $config);
my %infill = (); # Z => [ Line, Line ... ]
my $tool = undef;
Slic3r::GCode::Reader->new->parse(Slic3r::Test::gcode($print), sub {
my ($self, $cmd, $args, $info) = @_;
if ($cmd =~ /^T(\d+)/) {
$tool = $1;
} elsif ($cmd eq 'G1' && $info->{extruding} && $info->{dist_XY} > 0) {
if ($tool == $config->infill_extruder-1) {
my $z = 1 * $self->Z;
$infill{$z} ||= [];
push @{$infill{$z}}, Slic3r::Line->new_scale(
[ $self->X, $self->Y ],
[ $info->{new_X}, $info->{new_Y} ],
);
}
}
});
my $grow_d = scale($config->infill_extrusion_width)/2;
my $layer0_infill = union([ map @{$_->grow($grow_d)}, @{ $infill{0.2} } ]);
my $layer1_infill = union([ map @{$_->grow($grow_d)}, @{ $infill{0.4} } ]);
my $diff = diff($layer0_infill, $layer1_infill);
$diff = offset2_ex($diff, -$grow_d, +$grow_d);
$diff = [ grep { $_->area > 2*(($grow_d*2)**2) } @$diff ];
is scalar(@$diff), 0, 'no missing parts in solid shell when fill_density is 0';
}
{
# GH: #2697
my $config = Slic3r::Config->new_from_defaults;
$config->set('perimeter_extrusion_width', 0.72);
$config->set('top_infill_extrusion_width', 0.1);
$config->set('infill_extruder', 2); # in order to distinguish infill
$config->set('solid_infill_extruder', 2); # in order to distinguish infill
my $print = Slic3r::Test::init_print('20mm_cube', config => $config);
my %infill = (); # Z => [ Line, Line ... ]
my %other = (); # Z => [ Line, Line ... ]
my $tool = undef;
Slic3r::GCode::Reader->new->parse(Slic3r::Test::gcode($print), sub {
my ($self, $cmd, $args, $info) = @_;
if ($cmd =~ /^T(\d+)/) {
$tool = $1;
} elsif ($cmd eq 'G1' && $info->{extruding} && $info->{dist_XY} > 0) {
my $z = 1 * $self->Z;
my $line = Slic3r::Line->new_scale(
[ $self->X, $self->Y ],
[ $info->{new_X}, $info->{new_Y} ],
);
if ($tool == $config->infill_extruder-1) {
$infill{$z} //= [];
push @{$infill{$z}}, $line;
} else {
$other{$z} //= [];
push @{$other{$z}}, $line;
}
}
});
my $top_z = max(keys %infill);
my $top_infill_grow_d = scale($config->top_infill_extrusion_width)/2;
my $top_infill = union([ map @{$_->grow($top_infill_grow_d)}, @{ $infill{$top_z} } ]);
my $perimeters_grow_d = scale($config->perimeter_extrusion_width)/2;
my $perimeters = union([ map @{$_->grow($perimeters_grow_d)}, @{ $other{$top_z} } ]);
my $covered = union_ex([ @$top_infill, @$perimeters ]);
my @holes = map @{$_->holes}, @$covered;
ok sum(map unscale unscale $_->area*-1, @holes) < 1, 'no gaps between top solid infill and perimeters';
}
*/
bool test_if_solid_surface_filled(const ExPolygon& expolygon, double flow_spacing, double angle, double density)
{
std::unique_ptr<Slic3r::Fill> filler(Slic3r::Fill::new_from_type("rectilinear"));
@@ -221,6 +497,18 @@ bool test_if_solid_surface_filled(const ExPolygon& expolygon, double flow_spacin
const double scaled_flow_spacing = std::pow(scale_(flow_spacing), 2);
uncovered.erase(std::remove_if(uncovered.begin(), uncovered.end(), [scaled_flow_spacing](const ExPolygon& poly) { return poly.area() < scaled_flow_spacing; }), uncovered.end());
#if 0
if (! uncovered.empty()) {
BoundingBox bbox = get_extents(expolygon.contour);
bbox.merge(get_extents(uncovered));
bbox.merge(get_extents(grown_paths));
SVG svg("c:\\data\\temp\\test_if_solid_surface_filled.svg", bbox);
svg.draw(expolygon);
svg.draw(uncovered, "red");
svg.Close();
}
#endif
return uncovered.empty(); // solid surface is fully filled
}
+17
View File
@@ -58,6 +58,23 @@ SCENARIO("Flow math for non-bridges", "[Flow]") {
}
}
}
#if 0
/// Check for an edge case in the maths where the spacing could be 0; original
/// math is 0.99. Slic3r issue #4654
GIVEN("Input spacing of 0.414159 and a total width of 2") {
double in_spacing = 0.414159;
double total_width = 2.0;
auto flow = Flow::new_from_spacing(1.0, 0.4, 0.3);
WHEN("solid_spacing() is called") {
double result = flow.solid_spacing(total_width, in_spacing);
THEN("Yielded spacing is greater than 0") {
REQUIRE(result > 0);
}
}
}
#endif
}
/// Spacing, width calculation for bridge extrusions
+15
View File
@@ -142,6 +142,21 @@ SCENARIO("z_hop lifts the nozzle when a lift is requested", "[GCodeWriter]") {
}
}
SCENARIO("Origin manipulation", "[GCodeWriter]") {
Slic3r::GCode gcodegen;
WHEN("set_origin to (10,0)") {
gcodegen.set_origin(Vec2d(10,0));
REQUIRE(gcodegen.origin() == Vec2d(10, 0));
}
WHEN("set_origin to (10,0) and translate by (5, 5)") {
gcodegen.set_origin(Vec2d(10,0));
gcodegen.set_origin(gcodegen.origin() + Vec2d(5, 5));
THEN("origin returns reference to point") {
REQUIRE(gcodegen.origin() == Vec2d(15,5));
}
}
}
TEST_CASE("A cached config slot is looked up again whenever its key changes", "[GCodeWriter]")
{
GCode::ConfigIndexCache cache;
+24 -1
View File
@@ -5,7 +5,6 @@
#include "libslic3r/GCodeReader.hpp"
#include "libslic3r/Config.hpp"
#include "libslic3r/Print.hpp"
#include "libslic3r/Model.hpp"
#include <algorithm>
#include <cstdlib>
@@ -503,3 +502,27 @@ std::vector<std::string> role_sequence(const std::string &gcode, const std::vect
}
} } // namespace Slic3r::Test
#include <catch2/catch_all.hpp>
#include "libslic3r/Arrange.hpp"
#include "libslic3r/Model.hpp"
SCENARIO("init_print functionality", "[test_helpers]") {
GIVEN("A default config") {
Slic3r::DynamicPrintConfig config = Slic3r::DynamicPrintConfig::full_print_config();
WHEN("init_print is called with a single mesh.") {
Slic3r::Model model;
Slic3r::Print print;
Slic3r::Test::init_print({ Slic3r::Test::cube(20) }, print, model, config);
THEN("One mesh/printobject is in the resulting Print object.") {
REQUIRE(print.objects().size() == 1);
}
THEN("print.process() doesn't crash.") {
REQUIRE_NOTHROW(print.process());
}
THEN("Export gcode functions outputs text.") {
REQUIRE(! Slic3r::Test::gcode(print).empty());
}
}
}
}
+18
View File
@@ -327,6 +327,24 @@ TEST_CASE("A printer with IMEX disabled resolves the mode placeholders to nothin
CHECK_FALSE(has_line(gcode, kQuadScript));
}
// The placeholders are set unconditionally, ahead of the is_imex check, so they are defined for
// every printer. A single-extruder preset that mentions {imex_mode} must expand it to an empty
// string rather than fail the export on an unknown variable.
TEST_CASE("IMEX mode placeholders are defined on an ordinary single-extruder printer",
"[ImexModeGcode][IMEX]")
{
const std::string gcode = slice({ cube(20) }, {
{ "skirt_loops", "0" },
{ "brim_type", "no_brim" },
{ "gcode_flavor", "klipper" },
{ "machine_start_gcode", kReportStartGcode },
});
CHECK(has_line(gcode, ";IMEX_MODE:"));
CHECK(has_line(gcode, ";IMEX_MODE_INDEX:0"));
CHECK(has_line(gcode, ";IMEX_MODE_GCODE:"));
}
// In a parallel mode no tool changes occur, so every active carriage has to be addressed
// explicitly: once before the print for pressure advance, and again at the second layer for the
// drop from the initial-layer temperature. Nothing else in the suite covers either emission, and
+15
View File
@@ -66,6 +66,21 @@ void count_mixed(ToolOrdering &to, size_t &groups, size_t &resolutions)
} // namespace
TEST_CASE("enable_mixed_color_sublayer reaches the Print config", "[MixedFilament]")
{
Print print;
Model model;
init_print({cube(20)}, print, model, mixed_config(true));
// The option lives in PrintConfig; if it did not survive Print::apply the slicer would
// silently fall back to the whole-layer path.
CHECK(print.config().enable_mixed_color_sublayer.value == true);
REQUIRE(print.config().filament_is_mixed.values.size() == 3);
CHECK(print.config().filament_is_mixed.values[2] == true);
REQUIRE(print.config().filament_mixed_components.values.size() == 3);
CHECK(print.config().filament_mixed_components.values[2] == "1,2");
}
TEST_CASE("Mixed filament splits layers into sub-layers when the option is on", "[MixedFilament]")
{
Print print;
+26
View File
@@ -840,6 +840,32 @@ static void all_regions_on_filament(DynamicPrintConfig &config, int filament_1ba
config.set_deserialize_strict({ { key, std::to_string(filament_1based) } });
}
// IMEX parallel modes emit per-carriage temperatures from a branch that is mutually
// exclusive with the standard per-extruder path, and that branch skipped the head the
// print's own toolpaths run on. That head therefore never received its 1st->2nd layer
// transition and held nozzle_temperature_initial_layer for the whole job.
TEST_CASE("Parallel-mode IMEX prints transition the printing head to its second-layer temperature",
"[MultiFilament][IMEX]")
{
DynamicPrintConfig config = multifilament_config(7);
imex_7x4_printer(config);
all_regions_on_filament(config, 1); // filament 1 => logical slot 0 => physical head 0
// The multi-extruder normalization collapses per-filament temperature vectors to a
// single value, so heads are told apart by their tool qualifier, not by temperature.
config.set_deserialize_strict({
{ "imex_parallel_mode", "copy" },
{ "nozzle_temperature_initial_layer", "200" },
{ "nozzle_temperature", "240" },
});
const std::string gcode = slice({ cube(20) }, config);
// Head 0 runs the print's own toolpaths and must step 200 -> 240 at the second layer.
CHECK(gcode.find("M104 S240 T0") != std::string::npos);
// Head 1 is the copy carriage; it already worked and must keep working.
CHECK(gcode.find("M104 S240 T1") != std::string::npos);
}
// IMEX supplements is_extruder_used for the secondary carriages a parallel mode drives.
// `primary` drives exactly one tool, so the supplement must not run: routing every region
// to filament 6 puts the initial tool on physical head 2, while the mode's only declared
+91 -18
View File
@@ -54,6 +54,8 @@
#include <cstdlib>
#include <sstream>
#include <limits>
#include <fstream>
#include <iterator>
#include <memory>
#include <string_view>
#include <utility>
@@ -121,38 +123,58 @@ SCENARIO("Changing the number of solid shell layers does not make all surfaces i
config.set_deserialize_strict({
{ "top_shell_layers", 2 },
{ "bottom_shell_layers", 1 },
{ "layer_height", 0.25 }, // get a known number of layers
// Shell counts alone decide which layers are solid.
{ "top_shell_thickness", 0 },
{ "bottom_shell_thickness", 0 },
{ "sparse_infill_density", "20%" },
{ "layer_height", 0.25 }, // 80 layers: 0..79
{ "initial_layer_print_height", 0.25 }
});
Slic3r::Print print;
Slic3r::Model model;
Slic3r::Test::init_print({cube(20)}, print, model, config);
// Precondition: Ensure that the model has 2 solid top layers (79, 78)
// and one solid bottom layer (0).
auto test_is_solid_infill = [&print](size_t obj_id, size_t layer_id) {
// True when the layer has fill surfaces and all of them are solid.
auto is_solid_infill = [&print](size_t obj_id, size_t layer_id) {
const Layer &layer = *(print.objects().at(obj_id)->get_layer((int)layer_id));
// iterate over all of the regions in the layer
for (const LayerRegion *region : layer.regions()) {
// for each region, iterate over the fill surfaces
bool has_fill = false;
for (const LayerRegion *region : layer.regions())
for (const Surface &surface : region->fill_surfaces.surfaces) {
if (! surface.is_solid())
return false;
has_fill = true;
}
return has_fill;
};
// True when the layer has a sparse fill surface, so a layer with no fill at all does not count.
auto has_sparse_infill = [&print](size_t obj_id, size_t layer_id) {
const Layer &layer = *(print.objects().at(obj_id)->get_layer(static_cast<int>(layer_id)));
for (const LayerRegion *region : layer.regions())
for (const Surface &surface : region->fill_surfaces.surfaces)
CHECK(surface.is_solid());
}
if (! surface.is_solid())
return true;
return false;
};
print.process();
test_is_solid_infill(0, 0); // should be solid
test_is_solid_infill(0, 79); // should be solid
test_is_solid_infill(0, 78); // should be solid
// Precondition: 2 solid top layers (79, 78) and one solid bottom layer (0).
REQUIRE(is_solid_infill(0, 0));
REQUIRE(is_solid_infill(0, 79));
REQUIRE(is_solid_infill(0, 78));
REQUIRE(has_sparse_infill(0, 77));
WHEN("Model is re-sliced with top_shell_layers == 3") {
config.set("top_shell_layers", 3);
print.apply(model, config);
print.process();
THEN("Print object does not have 0 solid bottom layers.") {
test_is_solid_infill(0, 0);
CHECK(is_solid_infill(0, 0));
}
AND_THEN("Print object has 3 top solid layers") {
test_is_solid_infill(0, 79);
test_is_solid_infill(0, 78);
test_is_solid_infill(0, 77);
CHECK(is_solid_infill(0, 79));
CHECK(is_solid_infill(0, 78));
CHECK(is_solid_infill(0, 77));
CHECK(has_sparse_infill(0, 76));
}
AND_THEN("Middle layers keep sparse infill") {
CHECK(has_sparse_infill(0, 40));
}
}
}
@@ -670,14 +692,18 @@ TEST_CASE("gcode_skip_config_block omits the resolved-settings comment block", "
{
const std::string gcode = slice({ cube(20) }, {
{ "gcode_skip_config_block", true },
{ "gcode_comments", true },
});
CHECK(gcode.find("; CONFIG_BLOCK_START") == std::string::npos);
CHECK(gcode.find("; CONFIG_BLOCK_END") == std::string::npos);
CHECK(gcode.find("; layer_height =") == std::string::npos);
CHECK(gcode.find("; fill_density =") == std::string::npos);
CHECK(gcode.find("; sparse_infill_density =") == std::string::npos);
CHECK(gcode.find("; HEADER_BLOCK_START") != std::string::npos);
CHECK(gcode.find("; EXECUTABLE_BLOCK_START") != std::string::npos);
const std::string with_block = slice({ cube(20) }, { { "gcode_skip_config_block", false } });
CHECK(with_block.find("; CONFIG_BLOCK_START") != std::string::npos);
CHECK(with_block.find("; layer_height =") != std::string::npos);
CHECK(with_block.find("; sparse_infill_density =") != std::string::npos);
}
// Some firmwares only scan the last N lines of the file for "estimated printing time", so it
@@ -792,6 +818,25 @@ TEST_CASE("Print statistics in custom G-code match the final print statistics",
}
}
TEST_CASE("export_gcode writes G-code without a result pointer", "[Print][export_gcode]")
{
Print print;
Model model;
Slic3r::Test::init_print({cube(20)}, print, model);
print.process();
SECTION("non-BBL printer") {}
SECTION("BBL printer") { print.is_BBL_printer() = true; }
ScopedTemporaryFile temp(".gcode");
REQUIRE_NOTHROW(print.export_gcode(temp.string(), nullptr, nullptr));
std::ifstream in(temp.string());
const std::string gcode((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>());
REQUIRE_FALSE(gcode.empty());
}
TEST_CASE("Exporting a sliced print again gives the same G-code", "[Print][export_gcode][Regression]")
{
const int instances = GENERATE(1, 3);
@@ -1041,6 +1086,34 @@ TEST_CASE("Belt printers keep the part fan off within the band above the belt",
CHECK(clear_fan_off == 0);
}
// Organic supports under an overhang on a belt printer reach below the object's first layer,
// where the virtual belt raft layers sit at negative Z. The lowest of them used to get a
// negative height and abort slicing with a negative flow error.
TEST_CASE("Belt printers slice organic tree supports that reach the belt", "[Print][belt][Support]")
{
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
config.set_deserialize_strict({
{ "belt_printer", 1 },
{ "belt_slice_rotation", "x" },
{ "belt_slice_rotation_angle", 45 },
{ "gcode_remap_x", "rev_x" },
{ "gcode_remap_y", "pos_z" },
{ "gcode_remap_z", "pos_y" },
{ "layer_height", 0.2 },
{ "initial_layer_print_height", 0.2 },
{ "skirt_loops", 0 },
{ "z_hop", 0 },
{ "enable_support", 1 },
{ "support_type", "tree(auto)" },
{ "support_style", "organic" },
{ "machine_start_gcode", "T[initial_tool]\n" },
{ "layer_change_gcode", "G92 E0\n" },
});
std::string gcode;
REQUIRE_NOTHROW(gcode = slice({ TestMesh::overhang }, config));
CHECK(! gcode.empty());
}
TEST_CASE("Slicing errors are reported per object with the object's name", "[Print]")
{
Print print;
+24
View File
@@ -837,6 +837,30 @@ TEST_CASE("Belt brim on an object layer precedes its perimeters, once", "[SkirtB
CHECK(role_passes(gc, "brim") <= bands);
}
// B - single extruder (filament id 1). Every band must survive the 1-based -> 0-based
// filament-id conversion the apron path performs: a wrong conversion drops all single-extruder
// bands, so the pass count would collapse. The expected count is derived from the sliced
// layers, not a ratio.
TEST_CASE("Belt brim on a single extruder emits every band once", "[SkirtBrim][belt]")
{
DynamicPrintConfig config = belt_brim_config();
config.set_deserialize_strict({
{ "brim_type", "outer_only" },
{ "brim_width", 4 },
{ "brim_object_gap", 0 },
});
Print print;
Model model;
init_print({ cube(20) }, print, model, config);
const std::string gc = gcode(print);
const PrintObject &object = *print.objects().front();
const int expected = nonempty_belt_brim_layers(object) + belt_brim_apron_bands(object);
REQUIRE(expected > 0);
CHECK(role_layers(gc, "brim") == expected);
CHECK(belt_tools_for_role(gc, "brim") == std::set<int>{ 0 }); // filament 1 -> tool 0
}
// Number of brim segments the belt brim generator produced for `object`: the lattice
// lines of every per-layer band plus the apron prologue. Each segment is written as one
// extruding move, so this is what a G-code count has to match. A pass count cannot see a
@@ -27,6 +27,14 @@ TEST_CASE("slicing_pipeline_plugin option exists and defaults empty", "[slicing_
#include "libslic3r/Print.hpp"
TEST_CASE("slicing pipeline hook setter is a no-op-safe injection", "[slicing_pipeline]") {
int calls = 0;
Slic3r::Print::set_slicing_pipeline_hook_fn(
[&](Slic3r::Print&, const Slic3r::PrintObject*, Slic3r::SlicingPipelineStepPlugin){ ++calls; });
Slic3r::Print::set_slicing_pipeline_hook_fn(nullptr); // reset — must be legal
CHECK(calls == 0);
}
#include <vector>
#include <algorithm>
using namespace Slic3r::Test;
+92 -10
View File
@@ -1,6 +1,8 @@
#include <catch2/catch_all.hpp>
#include <catch2/catch_test_macros.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp>
#include <catch2/matchers/catch_matchers.hpp>
#include "libslic3r/TriangleMesh.hpp"
#include "libslic3r/TriangleMeshSlicer.hpp"
#include "libslic3r/Point.hpp"
@@ -10,6 +12,8 @@
#include <cstdlib>
#include <cstddef>
#include <cmath>
#include <future>
#include <chrono>
#include <vector>
#include <math.h>
#include "libslic3r/ExPolygon.hpp"
@@ -57,6 +61,12 @@ SCENARIO("Basic mesh statistics", "[TriangleMesh]") {
REQUIRE(cube.facets_count() == facets.size());
}
#if 0
THEN( "Number of normals is equal to the number of facets.") {
REQUIRE(cube.normals().size() == facets.size());
}
#endif
THEN( "center() returns the center of the object.") {
REQUIRE(cube.center() == Vec3d(10.0,10.0,10.0));
}
@@ -154,21 +164,29 @@ SCENARIO("Slice behavior", "[TriangleMesh]") {
const std::vector<Vec3f> vertices {{0,0,0},{0,0,20},{0,5,0},{0,5,20},{50,0,0},{50,0,20},{15,5,0},{35,5,0},{15,20,0},{50,5,0},{35,20,0},{15,5,10},{50,5,20},{35,5,10},{35,20,10},{15,20,10}};
const std::vector<Vec3i32> facets {{0,1,2},{2,1,3},{1,0,4},{5,1,4},{0,2,4},{4,2,6},{7,6,8},{4,6,7},{9,4,7},{7,8,10},{2,3,6},{11,3,12},{7,12,9},{13,12,7},{6,3,11},{11,12,13},{3,1,5},{12,3,5},{5,4,9},{12,5,9},{13,7,10},{14,13,10},{8,15,10},{10,15,14},{6,11,8},{8,11,15},{15,11,13},{14,15,13}};
auto cube = make_cube();
TriangleMesh mesh(vertices, facets);
// A 50x5x20 wall with a 20x15x10 block against it, so the block's top is at Z = 10.
const double wall_area = 50. * 5.;
const double wall_block_area = wall_area + 20. * 15.;
const auto area_mm2 = [](const ExPolygons &slice) { return slice.at(0).area() * SCALING_FACTOR * SCALING_FACTOR; };
WHEN(" a top tangent plane is sliced") {
// At Z = 10 we have a top horizontal surface.
std::vector<ExPolygons> slices = cube.slice({5.0, 10.0});
std::vector<ExPolygons> slices = mesh.slice({5.0, 10.0});
THEN( "its area is included") {
REQUIRE(slices.at(0).at(0).area() > 0);
REQUIRE(slices.at(1).at(0).area() > 0);
REQUIRE(slices.at(0).size() == 1);
REQUIRE(slices.at(1).size() == 1);
CHECK_THAT(area_mm2(slices.at(0)), Catch::Matchers::WithinRel(wall_block_area, 1e-6));
CHECK_THAT(area_mm2(slices.at(1)), Catch::Matchers::WithinRel(wall_block_area, 1e-6));
}
}
WHEN(" a model that has been transformed is sliced") {
cube.mirror_z();
std::vector<ExPolygons> slices = cube.slice({-5.0, -10.0});
THEN( "it is sliced properly (mirrored bottom plane area is included)") {
REQUIRE(slices.at(0).at(0).area() > 0);
REQUIRE(slices.at(1).at(0).area() > 0);
mesh.mirror_z();
std::vector<ExPolygons> slices = mesh.slice({-5.0, -10.0});
THEN( "it is sliced like the original, and the mirrored block's bottom plane is not part of it") {
REQUIRE(slices.at(0).size() == 1);
REQUIRE(slices.at(1).size() == 1);
CHECK_THAT(area_mm2(slices.at(0)), Catch::Matchers::WithinRel(wall_block_area, 1e-6));
// A triangle owns its top edge but not its bottom edge, so a bottom tangent plane is left out.
CHECK_THAT(area_mm2(slices.at(1)), Catch::Matchers::WithinRel(wall_area, 1e-6));
}
}
}
@@ -257,6 +275,20 @@ SCENARIO("Split functionality", "[TriangleMesh]") {
}
}
SCENARIO("Mesh merge functions", "[TriangleMesh]") {
GIVEN( "Two 20mm cubes, each with one corner on the origin") {
auto cube = make_cube();
TriangleMesh cube2(cube);
WHEN( "The two meshes are merged") {
cube.merge(cube2);
THEN( "There are twice as many facets in the merged mesh as the original.") {
REQUIRE(cube.facets_count() == 2 * cube2.facets_count());
}
}
}
}
SCENARIO("Cut behavior", "[TriangleMesh]") {
GIVEN( "A 20mm cube with one corner on the origin") {
auto cube = make_cube();
@@ -284,3 +316,53 @@ SCENARIO("Cut behavior", "[TriangleMesh]") {
}
}
}
#ifdef TEST_PERFORMANCE
TEST_CASE("Large mesh slices within the time budget (#4486)", "[TriangleMesh][Regression]") {
TriangleMesh mesh;
DynamicPrintConfig config = Slic3r::DynamicPrintConfig::full_print_config();
mesh.ReadSTLFile(std::string(testfile_dir) + "test_trianglemesh/4486/100_000.stl");
config.set("layer_height", 500);
config.set("first_layer_height", 250);
config.set("nozzle_diameter", 500);
Slic3r::Print print;
Slic3r::Model model;
Slic3r::Test::init_print({mesh}, print, model, config);
print.status_cb = [] (int ln, const std::string& msg) { Slic3r::Log::info("Print") << ln << " " << msg << "\n";};
std::future<void> fut = std::async([&print] () { print.process(); });
std::chrono::milliseconds span {120000};
bool timedout {false};
if(fut.wait_for(span) == std::future_status::timeout) {
timedout = true;
}
REQUIRE(timedout == false);
}
#endif // TEST_PERFORMANCE
#ifdef BUILD_PROFILE
TEST_CASE("Large mesh slicing profile (#4486)", "[TriangleMesh][Profile]") {
TriangleMesh mesh;
DynamicPrintConfig config = Slic3r::DynamicPrintConfig::full_print_config();
mesh.ReadSTLFile(std::string(testfile_dir) + "test_trianglemesh/4486/10_000.stl");
config.set("layer_height", 500);
config.set("first_layer_height", 250);
config.set("nozzle_diameter", 500);
config.set("fill_density", "5%");
Slic3r::Print print;
Slic3r::Model model;
Slic3r::Test::init_print({mesh}, print, model, config);
print.status_cb = [] (int ln, const std::string& msg) { Slic3r::Log::info("Print") << ln << " " << msg << "\n";};
print.process();
REQUIRE(true);
}
#endif //BUILD_PROFILE
+15
View File
@@ -348,6 +348,21 @@ TEST_CASE("Inactive inward wipe settings preserve the exported trajectory", "[Wi
CHECK_FALSE(trajectories_differ(regular_paths, inward_paths));
}
TEST_CASE("Inward wipe changes the exported trajectory when outer wall width is Auto", "[Wipe][Regression]")
{
const char *wall_generator = GENERATE("classic", "arachne");
INFO("wall generator: " << wall_generator);
const std::vector<Vec2d> regular = wipe_destinations(
slice({make_cube(10., 10., 1.)}, wipe_config(wall_generator, false)));
const std::vector<Vec2d> inward = wipe_destinations(
slice({make_cube(10., 10., 1.)}, wipe_config(wall_generator, true)));
REQUIRE_FALSE(regular.empty());
REQUIRE_FALSE(inward.empty());
REQUIRE(trajectories_differ(regular, inward));
}
TEST_CASE("Inward wipe recognizes an external wall starting on an overhang", "[Wipe][Regression]")
{
const char *wall_generator = GENERATE("classic", "arachne");
@@ -319,9 +319,18 @@ TEST_CASE("FilamentGroup property checks", "[filament_group][property]") {
// build (config_b/config_c cases evaluate the full per-layer nozzle-aware reorder for every
// candidate grouping; this is a guard against hangs, not a micro-perf gate).
REQUIRE(result.elapsed_ms < 40000.0);
REQUIRE(result.flush_cost >= 0);
// Every case, feasible or not, must yield a usable grouping: each used filament is
// assigned to an existing nozzle.
auto used_filaments = collect_sorted_used_filaments(tc.context.model_info.layer_filaments);
const auto &nozzle_list = tc.context.nozzle_info.nozzle_list;
for (unsigned int fil : used_filaments) {
INFO("Filament: " << fil);
REQUIRE(fil < result.filament_map.size());
CHECK(result.filament_map[fil] >= 0);
CHECK(result.filament_map[fil] < static_cast<int>(nozzle_list.size()));
}
if (is_constraint_feasible(tc.context, used_filaments)) {
if (!result.constraints_ok) {
for (auto& v : result.violations)
+104 -18
View File
@@ -1033,18 +1033,19 @@ SCENARIO("Non-standard nozzle diameter survives .3mf save on a single-nozzle pri
}
}
// A legacy / foreign project (no multi-nozzle metadata) must load crash-safe through the BBS
// importer and must not fabricate a filament_volume_map.
SCENARIO("Legacy project loads crash-safe via load_bbs_3mf", "[3mf][MultiNozzle]") {
GIVEN("a project without any multi-nozzle metadata") {
std::string path = std::string(TEST_DATA_DIR) + "/test_3mf/Geräte/Büchse.3mf";
Model model;
DynamicPrintConfig config;
ConfigSubstitutionContext ctxt{ ForwardCompatibilitySubstitutionRule::Enable };
PlateDataPtrs plates;
std::vector<Preset*> project_presets;
bool is_bbl_3mf = false, is_orca_3mf = false;
Semver file_version;
// A project without multi-nozzle metadata must load through the BBS importer and must not
// fabricate a filament_volume_map.
SCENARIO("Projects without multi-nozzle metadata load without a per-filament volume map", "[3mf][MultiNozzle]") {
ConfigSubstitutionContext ctxt{ ForwardCompatibilitySubstitutionRule::Enable };
std::vector<Preset*> project_presets;
bool is_bbl_3mf = false, is_orca_3mf = false;
Semver file_version;
GIVEN("a legacy project with a bare model and no plate metadata") {
std::string path = std::string(TEST_DATA_DIR) + "/test_3mf/Geräte/Büchse.3mf";
Model model;
DynamicPrintConfig config;
PlateDataPtrs plates;
WHEN("loaded through the BBS importer") {
bool loaded = false;
@@ -1052,10 +1053,32 @@ SCENARIO("Legacy project loads crash-safe via load_bbs_3mf", "[3mf][MultiNozzle]
&project_presets, &is_bbl_3mf, &is_orca_3mf,
&file_version, nullptr,
LoadStrategy::LoadModel | LoadStrategy::LoadConfig));
THEN("it does not crash and invents no per-filament volume map") {
for (PlateData* p : plates) {
REQUIRE(p->config.option<ConfigOptionInts>("filament_volume_map") == nullptr);
}
THEN("the model loads") {
REQUIRE(loaded);
REQUIRE_FALSE(model.objects.empty());
}
release_PlateData_list(plates);
}
}
GIVEN("a project with plate metadata but no filament volume map") {
ScopedTemporaryFile temp(".3mf");
store_painted_cube(temp.string());
ScopedTemporaryDir backup_dir("orca_volume_map_dst");
Model model;
model.set_backup_path(backup_dir.string());
DynamicPrintConfig config;
PlateDataPtrs plates;
WHEN("loaded through the BBS importer") {
const bool loaded = load_bbs_3mf(temp.string().c_str(), &config, &ctxt, &model, &plates,
&project_presets, &is_bbl_3mf, &is_orca_3mf, &file_version,
nullptr, LoadStrategy::LoadModel | LoadStrategy::LoadConfig);
THEN("every plate loads without a per-filament volume map") {
REQUIRE(loaded);
REQUIRE_FALSE(plates.empty());
for (PlateData* p : plates)
CHECK(p->config.option<ConfigOptionInts>("filament_volume_map") == nullptr);
}
release_PlateData_list(plates);
}
@@ -1647,6 +1670,69 @@ SCENARIO("Legacy 3MF without published metadata loads unchanged", "[3mf]") {
}
}
// Locks the serialization contract of the orca_published_material_keys metadata: the per-entry JSON
// must survive a store_bbs_3mf -> load_bbs_3mf round-trip verbatim, exactly like orca_published_keys.
SCENARIO("Published 3MF round-trips the published_material_keys metadata", "[3mf]") {
GIVEN("a model carrying published material keys metadata") {
Model model;
std::string src_file = std::string(TEST_DATA_DIR) + "/test_3mf/Prusa.stl";
REQUIRE(load_stl(src_file.c_str(), &model));
model.add_default_instances();
const std::string material_keys_json =
R"([{"material":{"filament_type":"PLA","filament_vendor":"Generic","filament_id":"GFL99"},"slot":0,"keys":["filament_retraction_length","filament_z_hop"]}])";
model.model_info = std::make_shared<ModelInfo>();
model.model_info->metadata_items[ORCA_PUBLISHED_MATERIAL_TAG] = material_keys_json;
ScopedTemporaryDir backup_dir("orca_pub_mat");
model.set_backup_path(backup_dir.string());
WHEN("stored to and reloaded from a .3mf") {
ScopedTemporaryFile temp(".3mf");
const std::string test_file = temp.string();
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
StoreParams store_params;
store_params.path = test_file.c_str();
store_params.model = &model;
store_params.config = &config;
store_params.strategy = SaveStrategy::Zip64 | SaveStrategy::Silence;
REQUIRE(store_bbs_3mf(store_params));
Model dst_model;
DynamicPrintConfig dst_config;
ConfigSubstitutionContext ctxt{ ForwardCompatibilitySubstitutionRule::Enable };
PlateDataPtrs dst_plates;
std::vector<Preset*> project_presets;
bool is_bbl_3mf = false, is_orca_3mf = false;
Semver file_version;
bool loaded = load_bbs_3mf(test_file.c_str(), &dst_config, &ctxt, &dst_model, &dst_plates,
&project_presets, &is_bbl_3mf, &is_orca_3mf, &file_version, nullptr,
LoadStrategy::LoadModel | LoadStrategy::LoadConfig);
THEN("the published material keys metadata round-trips unchanged") {
REQUIRE(loaded);
REQUIRE(dst_model.model_info != nullptr);
REQUIRE(dst_model.model_info->metadata_items[ORCA_PUBLISHED_MATERIAL_TAG] == material_keys_json);
// The value must parse back to one material entry carrying the nested identity
// object, the author slot ordinal and the key list.
nlohmann::json entries = nlohmann::json::parse(material_keys_json);
REQUIRE(entries.is_array());
REQUIRE(entries.size() == 1);
REQUIRE(entries[0]["material"]["filament_type"] == "PLA");
REQUIRE(entries[0]["material"]["filament_vendor"] == "Generic");
REQUIRE(entries[0]["material"]["filament_id"] == "GFL99");
REQUIRE(entries[0]["slot"] == 0);
REQUIRE(entries[0]["keys"].is_array());
REQUIRE(entries[0]["keys"].size() == 2);
REQUIRE(entries[0]["keys"][0] == "filament_retraction_length");
}
release_PlateData_list(dst_plates);
}
}
}
SCENARIO("Minimal published 3MF omits project config, preset dumps and slicer tags", "[3mf]") {
GIVEN("a multi-instance model carrying published metadata and a published_config payload") {
Model model;
@@ -1963,8 +2049,8 @@ SCENARIO("Published 3MF round-trips the extended material metadata", "[3mf]") {
REQUIRE(dst_model.model_info != nullptr);
REQUIRE(dst_model.model_info->metadata_items[ORCA_PUBLISHED_MATERIAL_TAG] == material_keys_json);
// The value must parse back with every extended field intact.
nlohmann::json entries = nlohmann::json::parse(material_keys_json);
// The loaded value must parse back with every extended field intact.
nlohmann::json entries = nlohmann::json::parse(dst_model.model_info->metadata_items[ORCA_PUBLISHED_MATERIAL_TAG]);
REQUIRE(entries.is_array());
REQUIRE(entries.size() == 1);
REQUIRE(entries[0]["full"].get<bool>() == true);
+9
View File
@@ -159,6 +159,15 @@ TEST_CASE("overlap_area detects overlap and ignores touching edges", "[Arrange]"
}
}
TEST_CASE("Arrange places every item on the physical bed", "[Arrange]")
{
ArrangePolygons items = squares(5, 20.);
arrange(items, bed(200, 200), quiet_params(scaled(1.)));
for (const ArrangePolygon &ap : items)
REQUIRE(ap.bed_idx == 0);
}
TEST_CASE("Arranged items stay within the bed", "[Arrange]")
{
ArrangePolygons items = squares(6, 30.);
+289 -93
View File
@@ -44,6 +44,7 @@
#include <cmath>
#include <cstring>
#include <boost/filesystem/path.hpp>
#include <fstream>
#include <memory>
#include <set>
@@ -53,6 +54,7 @@
#include <BRepAdaptor_Surface.hxx>
#include <TopExp_Explorer.hxx>
#include <TopoDS.hxx>
#include <BRepAlgoAPI_Common.hxx>
#include <BRepGProp.hxx>
#include <GProp_GProps.hxx>
#include <BRepPrimAPI_MakeBox.hxx>
@@ -772,16 +774,10 @@ TEST_CASE("entity constraints: tangent/midpoint/symmetric/angle", "[CadDocument]
TEST_CASE("imported text glyphs all extrude without failing (charset sweep)", "[CadDocument]")
{
std::string font = resources_dir().empty()
? std::string("resources/fonts/HarmonyOS_Sans_SC_Regular.ttf")
: resources_dir() + "/fonts/HarmonyOS_Sans_SC_Regular.ttf";
{
std::ifstream probe(font);
if (!probe.good()) {
SUCCEED("bundled font not reachable in this environment; covered live on :10");
return;
}
}
// The bundled font, from the source tree: PROFILES_DIR is <repo>/resources/profiles.
const std::string font =
(boost::filesystem::path(PROFILES_DIR).parent_path() / "fonts" / "HarmonyOS_Sans_SC_Regular.ttf").string();
REQUIRE(std::ifstream(font).good());
const std::string charset =
"ABCDEFGHIJKLMNOPQRSTUVWXYZ"
@@ -2061,6 +2057,49 @@ TEST_CASE("mass properties: invalid body index", "[CadDocument]")
REQUIRE_FALSE(mp3.valid);
}
TEST_CASE("serialize_recipe roundtrip with two bodies", "[CadDocument]")
{
using Catch::Matchers::WithinRel;
CadDocument doc;
// Body 1: rectangle sketch + extrude + fillet
int sk1 = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(),
20, 20, 10, "Rect1");
REQUIRE(sk1 >= 0);
doc.add_extrude(sk1, 10.0, false, BooleanMode::New, "Extrude1");
doc.add_fillet(2.0, FaceGroup::All, "Fillet1");
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
// Body 2: circle sketch + extrude (separate New body)
SketchEntity circ;
circ.type = SketchEntity::Type::Circle;
circ.center = Vec2d(0, 0);
circ.radius = 8.0;
int sk2 = doc.add_sketch_entities({circ}, SketchPlane::XZ(), "Circle2");
doc.add_extrude(sk2, 6.0, false, BooleanMode::New, "Extrude2");
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
REQUIRE(doc.bodies.size() >= 2);
std::vector<double> orig_vols;
for (const auto& b : doc.bodies)
orig_vols.push_back(double(SketchEngine::tessellate(b.shape).volume()));
auto blob = doc.serialize_recipe();
REQUIRE_FALSE(blob.empty());
CadDocument doc2;
REQUIRE(doc2.deserialize_recipe(blob));
REQUIRE(doc2.error.empty());
REQUIRE(doc2.bodies.size() == doc.bodies.size());
for (size_t i = 0; i < doc.bodies.size(); ++i) {
double v2 = double(SketchEngine::tessellate(doc2.bodies[i].shape).volume());
REQUIRE_THAT(v2, WithinRel(orig_vols[i], 1e-6));
}
}
TEST_CASE("deserialize_recipe rejects future version with error", "[CadDocument]")
{
CadDocument doc;
@@ -2104,6 +2143,41 @@ TEST_CASE("deserialize_recipe handles empty blob without throwing", "[CadDocumen
REQUIRE_FALSE(doc.error.empty());
}
TEST_CASE("deserialize_recipe error is non-empty on every failure path", "[CadDocument]")
{
CadDocument doc;
auto reset = [&]() { doc = CadDocument{}; };
// too new
{
std::ostringstream oss;
{ cereal::BinaryOutputArchive ar(oss); uint32_t v = 999; ar(v); }
reset();
REQUIRE_FALSE(doc.deserialize_recipe(oss.str()));
REQUIRE_FALSE(doc.error.empty());
}
// too old
{
std::ostringstream oss;
{ cereal::BinaryOutputArchive ar(oss); uint32_t v = 1; ar(v); }
reset();
REQUIRE_FALSE(doc.deserialize_recipe(oss.str()));
REQUIRE_FALSE(doc.error.empty());
}
// truncated / garbage
{
reset();
REQUIRE_FALSE(doc.deserialize_recipe("not a valid blob \x00\x01\x02"));
REQUIRE_FALSE(doc.error.empty());
}
// empty
{
reset();
REQUIRE_FALSE(doc.deserialize_recipe(""));
REQUIRE_FALSE(doc.error.empty());
}
}
TEST_CASE("a v4 project still opens", "[CadDocument][recipe]")
{
// The regression guard for everyone who already has projects: a pre-framing v4
@@ -2775,9 +2849,10 @@ TEST_CASE("datum coordinate system: non-perpendicular inputs produce orthonormal
using Catch::Matchers::WithinAbs;
CadDocument doc;
// Build a body so we have a face to reference for FaceAndDirection.
int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 10, "Box");
doc.add_extrude(sk, 10.0, false, BooleanMode::New, "BoxExt");
// A cylinder's end cap is bounded by one closed circular edge, which offers no in-plane
// direction, so the frame has to fall back on coordsys_x_hint.
int sk = doc.add_sketch(SketchShape::Circle, SketchPlane::XY(), 0, 0, 5.0, "Circle");
doc.add_extrude(sk, 10.0, false, BooleanMode::New, "Cyl");
REQUIRE(doc.recompute());
REQUIRE(doc.bodies.size() == 1);
int n_faces = GeometryEngine::face_count(doc.bodies[0].shape);
@@ -2787,34 +2862,33 @@ TEST_CASE("datum coordinate system: non-perpendicular inputs produce orthonormal
if (fn.z() > 0.9) { top_face = i; break; }
}
REQUIRE(top_face >= 0);
const Vec3d Z = GeometryEngine::face_normal_world(GeometryEngine::face_by_index(doc.bodies[0].shape, top_face));
int cs = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(0, 0, 0), "CS1");
REQUIRE(cs >= 0);
doc.features[cs].coordsys_type = CoordSysType::FaceAndDirection;
doc.features[cs].coordsys_body = 0;
doc.features[cs].coordsys_face = top_face;
// Deliberately non-perpendicular X hint (NOT orthogonal to face normal ~+Z).
// Deliberately non-perpendicular X hint (NOT orthogonal to the face normal +Z).
doc.features[cs].coordsys_x_hint = Vec3d(3.0, -1.0, 0.5);
auto css = doc.resolve_datum_coordsys();
REQUIRE(css.size() == 1);
REQUIRE(css[0].error.empty());
Vec3d X = css[0].x, Y = css[0].y;
// Orthonormality: each axis has unit length
const Vec3d X = css[0].x, Y = css[0].y;
// X is the hint projected into the face plane.
const Vec3d expected_x = Vec3d(3.0, -1.0, 0.0).normalized();
CHECK_THAT(X.x(), WithinAbs(expected_x.x(), 1e-9));
CHECK_THAT(X.y(), WithinAbs(expected_x.y(), 1e-9));
CHECK_THAT(X.z(), WithinAbs(expected_x.z(), 1e-9));
// Orthonormal, with Z the face normal and the frame right-handed.
CHECK_THAT(X.norm(), WithinAbs(1.0, 1e-9));
CHECK_THAT(Y.norm(), WithinAbs(1.0, 1e-9));
// Pairwise dot products are ~0
CHECK_THAT(std::abs(X.dot(Y)), WithinAbs(0.0, 1e-9));
// Z = X x Y (derived), also unit and perpendicular
Vec3d Z = X.cross(Y);
CHECK_THAT(Z.norm(), WithinAbs(1.0, 1e-9));
CHECK_THAT(std::abs(X.dot(Z)), WithinAbs(0.0, 1e-9));
CHECK_THAT(std::abs(Y.dot(Z)), WithinAbs(0.0, 1e-9));
// Right-handedness: X x Y == Z
CHECK_THAT(Z.x(), WithinAbs((X.cross(Y)).x(), 1e-9));
CHECK_THAT(Z.y(), WithinAbs((X.cross(Y)).y(), 1e-9));
CHECK_THAT(Z.z(), WithinAbs((X.cross(Y)).z(), 1e-9));
CHECK_THAT(X.dot(Y), WithinAbs(0.0, 1e-9));
CHECK_THAT(X.dot(Z), WithinAbs(0.0, 1e-9));
CHECK_THAT(Y.dot(Z), WithinAbs(0.0, 1e-9));
CHECK_THAT(X.cross(Y).dot(Z), WithinAbs(1.0, 1e-9));
}
TEST_CASE("datum coordinate system: a face-only frame rotates with its body", "[CadDocument]")
@@ -3839,17 +3913,23 @@ TEST_CASE("Use/Convert Entities: construction references never become solid", "[
}
REQUIRE(top_face >= 0);
const size_t body_count_before = doc.bodies.size();
const double volume_before = doc.body_mass_properties(0).volume;
int target = doc.add_sketch_entities({}, SketchPlane::XY(), "Use");
int appended = doc.project_edges_into_sketch(target, 0, {}, top_face);
REQUIRE(appended == 4);
doc.add_extrude(target, 5.0, false, BooleanMode::New, "UseExt");
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
REQUIRE(doc.bodies.size() == body_count_before);
REQUIRE_THAT(doc.body_mass_properties(0).volume, WithinRel(volume_before, 1e-9));
SECTION("construction edges form no profile, so the extrude fails") {
REQUIRE_FALSE(doc.recompute());
REQUIRE_FALSE(doc.error.empty());
}
SECTION("the same edges as solid geometry extrude a 20x20x5 body") {
for (auto& e : doc.features[target].entities)
e.construction = false;
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
REQUIRE(doc.bodies.size() == 2);
REQUIRE_THAT(doc.body_mass_properties(1).volume, WithinRel(20.0 * 20.0 * 5.0, 1e-6));
}
}
TEST_CASE("Use/Convert Entities: bad references return -1 and change nothing", "[CadDocument][project]")
@@ -4131,6 +4211,25 @@ static CadDocument make_golden_doc_v1()
return doc;
}
TEST_CASE("regenerate golden recipe fixture", "[.regen]")
{
CadDocument doc = make_golden_doc_v1();
// ponytail: serialize_recipe() only needs features, recompute is unnecessary
// for a fixture that exercises the serialization format.
auto blob = doc.serialize_recipe();
REQUIRE_FALSE(blob.empty());
// Version-free on purpose: this writes whatever today's format is, so a version bump does
// not have to remember to edit it. The version-stamped fixtures beside it are frozen
// evidence from older builds and are never regenerated.
std::string path = std::string(TEST_DATA_DIR) + "/cad_recipe_current.bin";
std::ofstream ofs(path, std::ios::binary);
REQUIRE(ofs.is_open());
ofs.write(blob.data(), static_cast<std::streamsize>(blob.size()));
ofs.close();
SUCCEED("Fixture written to " << path);
}
// The previous format's real blob, kept on disk deliberately. It is the only thing that can
// prove the version gate does its job: a v3 recipe carries FEWER fields per feature than this
// build reads, so without the bump to v4 it would have passed the gate and had two ints read
@@ -4155,7 +4254,7 @@ TEST_CASE("a previous-format recipe is refused, not silently misread", "[CadDocu
REQUIRE(doc.features.empty()); // nothing half-read was left behind
}
TEST_CASE("golden recipe v1 still deserialises", "[CadDocument]")
TEST_CASE("a v4 recipe keeps every field value", "[CadDocument]")
{
using Catch::Matchers::WithinRel;
using Catch::Matchers::WithinAbs;
@@ -4185,20 +4284,16 @@ TEST_CASE("golden recipe v1 still deserialises", "[CadDocument]")
}
CadDocument expected = make_golden_doc_v1();
// Scoped tightly: this advice is ONLY valid for a count mismatch. It must not be in
// scope for the field-value assertions below, where "regenerate the fixture" is the
// one thing you must never do -- regenerating after a reorder bakes the corrupted
// layout in as the new golden and permanently disarms this test.
{
INFO("Feature count changed - did you add/remove features in make_golden_doc_v1()?");
INFO("If so: run libslic3r_tests \"[.regen]\" and re-run this test.");
INFO("Feature count changed - make_golden_doc_v1() must keep building exactly the tree "
"frozen in cad_recipe_v4.bin, so do not add or remove features there.");
REQUIRE(features.size() == expected.features.size());
}
// A failure BELOW this point means the on-disk serialization format changed: some field
// in CadFeature::save/load was reordered, retyped, or removed. Fields may only ever be
// APPENDED at the end of both lists. Do NOT regenerate the fixture to make this pass --
// fix the field order instead. See scripts/CAD/run-kernel-tests.sh and the [.regen] case.
// fix the field order instead.
// Field-by-field assertions against expected values.
// Every field set to a distinctive non-default literal must be checked here.
@@ -4453,34 +4548,48 @@ TEST_CASE("golden recipe v1 still deserialises", "[CadDocument]")
}
}
// --- Layer 2: geometry check (optional — only if the document recomputes) ---
// Build expected document and try to recompute it.
// ponytail: the field-value layer above is the real tripwire;
// this layer is a bonus sanity check on the full recompute path.
// --- Layer 2: the production v4 load rebuilds the same geometry as the in-memory tree ---
// The golden tree is serialization coverage and does not recompute as a whole: these
// features fail on the geometry they get, so they are left off in both trees.
const std::set<std::string> unbuildable{"Fillet_Lat35", "Chamfer_Top2", "Draft_F3", "Shell_T1375",
"GoldenProject"};
CadDocument exp_doc = make_golden_doc_v1();
bool exp_ok = exp_doc.recompute();
if (!exp_ok) {
INFO("Expected document from make_golden_doc_v1() could not recompute "
"(complex feature tree). Field-value checks above are sufficient.");
// Fields appended after v4 are not in the file, so a v4 project reads them as defaults
// (a v4 thread, for one, is not major-nominal). The reference has to match that.
const CadFeature def;
for (CadFeature& f : exp_doc.features) {
f.thread_major_nominal = def.thread_major_nominal;
f.pattern_inclusive = def.pattern_inclusive;
f.dressup_edges = def.dressup_edges;
f.text_string = def.text_string;
f.text_font = def.text_font;
f.text_height = def.text_height;
f.revolve_axis_entity = def.revolve_axis_entity;
}
CadDocument doc;
bool ser_ok = doc.deserialize_recipe(blob);
if (exp_ok && ser_ok) {
REQUIRE(doc.error.empty());
REQUIRE(doc.bodies.size() == exp_doc.bodies.size());
for (size_t i = 0; i < doc.bodies.size(); ++i) {
double v = double(SketchEngine::tessellate(doc.bodies[i].shape).volume());
double ev = double(SketchEngine::tessellate(exp_doc.bodies[i].shape).volume());
REQUIRE_THAT(v, WithinRel(ev, 1e-6));
doc.deserialize_recipe(blob);
REQUIRE(doc.features.size() == exp_doc.features.size());
for (size_t i = 0; i < exp_doc.features.size(); ++i)
exp_doc.features[i].enabled = doc.features[i].enabled = unbuildable.count(exp_doc.features[i].name) == 0;
const bool exp_built = exp_doc.recompute();
INFO("reference error: " << exp_doc.error);
REQUIRE(exp_built);
const bool built = doc.recompute();
INFO("loaded error: " << doc.error);
REQUIRE(built);
REQUIRE_FALSE(exp_doc.bodies.empty());
REQUIRE(doc.bodies.size() == exp_doc.bodies.size());
for (size_t b = 0; b < doc.bodies.size(); ++b) {
INFO("body " << b);
TriangleMesh mesh = SketchEngine::tessellate(doc.bodies[b].shape);
TriangleMesh exp_mesh = SketchEngine::tessellate(exp_doc.bodies[b].shape);
CHECK_THAT(double(mesh.volume()), WithinRel(double(exp_mesh.volume()), 1e-6));
// Volume alone is blind to fields that only place a body, such as a transform.
const BoundingBoxf3 bb = mesh.bounding_box(), exp_bb = exp_mesh.bounding_box();
for (int k = 0; k < 3; ++k) {
CHECK_THAT(bb.min[k], WithinAbs(exp_bb.min[k], 1e-6));
CHECK_THAT(bb.max[k], WithinAbs(exp_bb.max[k], 1e-6));
}
} else {
INFO("The golden recipe fixture was loaded and field-value checks passed.");
INFO("Recompute on the deserialized or expected document failed — this is");
INFO("expected for the extended golden fixture (many feature types coexist");
INFO("purely for serialization coverage). The field-value tripwire above is");
INFO("the primary format check.");
}
}
@@ -5595,6 +5704,45 @@ TEST_CASE("surface-loft round-trip serialize/deserialize", "[CadDocument][surfac
// --- Mate tests (M8a) ---
TEST_CASE("fastened mate with zero offset/angle", "[CadDocument][mate]")
{
using Catch::Matchers::WithinAbs;
CadDocument doc;
int sk_box = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 10, 10, 0, "Box");
doc.add_extrude(sk_box, 5.0, false, BooleanMode::New, "BoxExt");
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
int cs_fixed = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(5, 5, 5), "CS_Fixed");
doc.features[cs_fixed].coordsys_body = 0;
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
int sk_cyl = doc.add_sketch(SketchShape::Circle, SketchPlane::XY(), 0, 0, 3, "Cyl");
doc.add_extrude(sk_cyl, 10.0, false, BooleanMode::New, "CylExt");
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
REQUIRE(doc.bodies.size() == 2);
int cs_moving = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(0, 0, 5), "CS_Moving");
doc.features[cs_moving].coordsys_body = 1;
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
int mi = doc.add_mate(0, cs_fixed, cs_moving, 0.0, 0.0, false, "Mate");
REQUIRE(mi >= 0);
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
GProp_GProps props;
BRepGProp::VolumeProperties(doc.bodies[1].shape, props);
gp_Pnt com = props.CentreOfMass();
REQUIRE_THAT(double(com.X()), WithinAbs(5.0, 1e-4));
REQUIRE_THAT(double(com.Y()), WithinAbs(5.0, 1e-4));
REQUIRE_THAT(double(com.Z()), WithinAbs(5.0, 1e-4));
}
TEST_CASE("fastened mate with offset", "[CadDocument][mate]")
{
using Catch::Matchers::WithinAbs;
@@ -5645,8 +5793,9 @@ TEST_CASE("fastened mate with angle", "[CadDocument][mate]")
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
int sk2 = doc.add_sketch(SketchShape::Circle, SketchPlane::XY(), 0, 0, 3, "Cyl");
doc.add_extrude(sk2, 10.0, false, BooleanMode::New, "CylExt");
// A 20x10 plate, so a quarter turn about the connector's Z swaps its X and Y extents.
int sk2 = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 10, 0, "Plate");
doc.add_extrude(sk2, 10.0, false, BooleanMode::New, "PlateExt");
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
@@ -5665,6 +5814,13 @@ TEST_CASE("fastened mate with angle", "[CadDocument][mate]")
gp_Pnt com = props.CentreOfMass();
REQUIRE_THAT(double(com.X()), WithinAbs(5.0, 1e-4));
REQUIRE_THAT(double(com.Y()), WithinAbs(5.0, 1e-4));
Bnd_Box bb;
BRepBndLib::Add(doc.bodies[1].shape, bb);
double x0, y0, z0, x1, y1, z1;
bb.Get(x0, y0, z0, x1, y1, z1);
REQUIRE_THAT(x1 - x0, WithinAbs(10.0, 1e-3));
REQUIRE_THAT(y1 - y0, WithinAbs(20.0, 1e-3));
}
TEST_CASE("fastened mate with flip", "[CadDocument][mate]")
@@ -5687,7 +5843,9 @@ TEST_CASE("fastened mate with flip", "[CadDocument][mate]")
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
int cs_moving = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(0, 0, 5), "CS_Moving");
// The connector sits on the cylinder's base, so the flip decides which side of the
// fixed connector (z = 5) the 10 mm cylinder hangs: above it unflipped, below it flipped.
int cs_moving = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(0, 0, 0), "CS_Moving");
doc.features[cs_moving].coordsys_body = 1;
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
@@ -5701,8 +5859,7 @@ TEST_CASE("fastened mate with flip", "[CadDocument][mate]")
gp_Pnt com = props.CentreOfMass();
REQUIRE_THAT(double(com.X()), WithinAbs(5.0, 1e-4));
REQUIRE_THAT(double(com.Y()), WithinAbs(5.0, 1e-4));
// Flip opposes Z axes: for a symmetric cylinder this is invisible in centroid,
// but the mate executed cleanly and the body moved to the target connector.
REQUIRE_THAT(double(com.Z()), WithinAbs(5.0 - 10.0 / 2, 1e-4));
}
TEST_CASE("planar mate: normal distance becomes mate_offset", "[CadDocument][mate]")
@@ -5791,6 +5948,30 @@ TEST_CASE("mate round-trip serialization", "[CadDocument][mate]")
REQUIRE(mf->mate_flip == true);
}
TEST_CASE("version 2 blob is rejected", "[CadDocument][mate]")
{
CadDocument doc;
int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 10, 10, 0, "Box");
doc.add_extrude(sk, 5.0, false, BooleanMode::New, "E");
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
std::ostringstream oss;
{
cereal::BinaryOutputArchive ar(oss);
uint32_t fake_v = 2;
ar(fake_v);
ar(doc.features);
ar(doc.variables);
}
std::string blob = oss.str();
CadDocument fresh;
REQUIRE_FALSE(fresh.deserialize_recipe(blob));
REQUIRE_CONTAINS(fresh.error, "older version");
}
TEST_CASE("mate error: out of range connectors", "[CadDocument][mate]")
{
@@ -6365,23 +6546,19 @@ TEST_CASE("fastened mate with flip on asymmetric body", "[CadDocument][mate]")
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
// Fastened, NO flip
doc.add_mate(0, cs_fixed, cs_moving, 0.0, 0.0, false, "MateNoFlip");
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
GProp_GProps nf_props;
BRepGProp::VolumeProperties(doc.bodies[1].shape, nf_props);
double nf_z = nf_props.CentreOfMass().Z();
doc.undo();
// Fastened, WITH flip
doc.add_mate(0, cs_fixed, cs_moving, 0.0, 0.0, true, "MateFlip");
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
GProp_GProps f_props;
BRepGProp::VolumeProperties(doc.bodies[1].shape, f_props);
double f_z = f_props.CentreOfMass().Z();
// The same assembly mated once without and once with flip, each in its own copy.
auto mated_com_z = [&](bool flip) {
CadDocument d = doc;
d.add_mate(0, cs_fixed, cs_moving, 0.0, 0.0, flip, "Mate");
REQUIRE(d.recompute());
REQUIRE(d.error.empty());
REQUIRE(d.mate_conflicts.empty());
GProp_GProps props;
BRepGProp::VolumeProperties(d.bodies[1].shape, props);
return props.CentreOfMass().Z();
};
const double nf_z = mated_com_z(false);
const double f_z = mated_com_z(true);
// Flip changes the centroid Z for an asymmetric body
REQUIRE_THAT(f_z, !WithinAbs(nf_z, 1e-4));
@@ -7164,13 +7341,22 @@ TEST_CASE("interference: sheet bodies are skipped", "[CadDocument][interference]
CadDocument doc;
int sk_a = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 0, "BoxA");
doc.add_extrude(sk_a, 10.0, false, BooleanMode::New, "EA");
// A sheet passing straight through the solid: it has no volume, so no interference.
int sk_s = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 40, 40, 0, "Sheet");
// Four 10x5 walls standing inside the 20x20x10 solid: a sheet has no volume, so no interference.
int sk_s = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 10, 10, 0, "Sheet");
doc.add_surface_extrude(sk_s, 5.0, "SE");
REQUIRE(doc.recompute());
REQUIRE(doc.bodies.size() == 2);
REQUIRE(CadDocument::is_sheet_shape(doc.bodies[1].shape));
// The raw overlap of the walls with the solid does measure a volume, so only the sheet
// skip keeps it out of the report.
BRepAlgoAPI_Common common(doc.bodies[0].shape, doc.bodies[1].shape);
common.Build();
REQUIRE(common.IsDone());
GProp_GProps props;
BRepGProp::VolumeProperties(common.Shape(), props);
REQUIRE(std::abs(props.Mass()) > 1e-6);
REQUIRE(doc.check_interference().empty());
}
@@ -7380,9 +7566,8 @@ TEST_CASE("A cut that removes no material is an error, not a silent success", "[
CHECK(doc.error.find("removed no material") != std::string::npos);
// And the body is left as it was, not half-applied.
CadDocument again = box();
REQUIRE(again.recompute());
CHECK_THAT(again.body_mass_properties(0).volume,
REQUIRE(doc.bodies.size() == 1);
CHECK_THAT(doc.body_mass_properties(0).volume,
Catch::Matchers::WithinAbs(solid, 1e-6));
}
@@ -7533,6 +7718,17 @@ TEST_CASE("two holes are both subtracted", "[CadDocument][sketchwire]")
REQUIRE_THAT(double(doc.display_mesh.volume()), Catch::Matchers::WithinRel(expected, 0.01));
}
TEST_CASE("a lone circle still extrudes exactly as before", "[CadDocument][sketchwire]")
{
std::vector<SketchEntity> ents = { circle_entity({0, 0}, 8.0) };
CadDocument doc = plate_doc(ents, 5.0);
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
const double expected = M_PI * 64.0 * 5.0;
REQUIRE_THAT(double(doc.display_mesh.volume()), Catch::Matchers::WithinRel(expected, 0.01));
}
TEST_CASE("a closed polygon still extrudes exactly as before", "[CadDocument][sketchwire]")
{
std::vector<SketchEntity> ents = rect_entities(20, 20);
+45
View File
@@ -155,6 +155,51 @@ SCENARIO("Various Clipper operations - xs/t/11_clipper.t", "[ClipperUtils]") {
REQUIRE(result.front().length() == Catch::Approx(subject.length()));
}
}
#if 0
{
# Clipper does not preserve polyline orientation
my $polyline = Slic3r::Polyline->new([50, 150], [300, 150]);
my $result = Slic3r::Geometry::Clipper::intersection_pl([$polyline], [$square]);
is scalar(@$result), 1, 'intersection_pl - correct number of result lines';
is_deeply $result->[0]->pp, [[100, 150], [200, 150]], 'clipped line orientation is preserved';
}
{
# Clipper does not preserve polyline orientation
my $polyline = Slic3r::Polyline->new([300, 150], [50, 150]);
my $result = Slic3r::Geometry::Clipper::intersection_pl([$polyline], [$square]);
is scalar(@$result), 1, 'intersection_pl - correct number of result lines';
is_deeply $result->[0]->pp, [[200, 150], [100, 150]], 'clipped line orientation is preserved';
}
{
# Disabled until Clipper bug #127 is fixed
my $subject = [
Slic3r::Polyline->new([-90000000, -100000000], [-90000000, 100000000]), # vertical
Slic3r::Polyline->new([-100000000, -10000000], [100000000, -10000000]), # horizontal
Slic3r::Polyline->new([-100000000, 0], [100000000, 0]), # horizontal
Slic3r::Polyline->new([-100000000, 10000000], [100000000, 10000000]), # horizontal
];
my $clip = Slic3r::Polygon->new(# a circular, convex, polygon
[99452190, 10452846], [97814760, 20791169], [95105652, 30901699], [91354546, 40673664], [86602540, 50000000],
[80901699, 58778525], [74314483, 66913061], [66913061, 74314483], [58778525, 80901699], [50000000, 86602540],
[40673664, 91354546], [30901699, 95105652], [20791169, 97814760], [10452846, 99452190], [0, 100000000],
[-10452846, 99452190], [-20791169, 97814760], [-30901699, 95105652], [-40673664, 91354546],
[-50000000, 86602540], [-58778525, 80901699], [-66913061, 74314483], [-74314483, 66913061],
[-80901699, 58778525], [-86602540, 50000000], [-91354546, 40673664], [-95105652, 30901699],
[-97814760, 20791169], [-99452190, 10452846], [-100000000, 0], [-99452190, -10452846],
[-97814760, -20791169], [-95105652, -30901699], [-91354546, -40673664], [-86602540, -50000000],
[-80901699, -58778525], [-74314483, -66913061], [-66913061, -74314483], [-58778525, -80901699],
[-50000000, -86602540], [-40673664, -91354546], [-30901699, -95105652], [-20791169, -97814760],
[-10452846, -99452190], [0, -100000000], [10452846, -99452190], [20791169, -97814760],
[30901699, -95105652], [40673664, -91354546], [50000000, -86602540], [58778525, -80901699],
[66913061, -74314483], [74314483, -66913061], [80901699, -58778525], [86602540, -50000000],
[91354546, -40673664], [95105652, -30901699], [97814760, -20791169], [99452190, -10452846], [100000000, 0]
);
my $result = Slic3r::Geometry::Clipper::intersection_pl($subject, [$clip]);
is scalar(@$result), scalar(@$subject), 'intersection_pl - expected number of polylines';
is sum(map scalar(@$_), @$result), scalar(@$subject) * 2, 'intersection_pl - expected number of points in polylines';
}
#endif
}
SCENARIO("Various Clipper operations - t/clipper.t", "[ClipperUtils]") {
+46 -10
View File
@@ -75,11 +75,10 @@ SCENARIO("Config accessor functions perform as expected.", "[Config]") {
}
}
WHEN("A boolean option is set to a string value representing something other than 0 or 1") {
THEN("A BadOptionTypeException exception is thrown.") {
config.set("gcode_comments", true);
THEN("A BadOptionTypeException exception is thrown and the value is unchanged.") {
REQUIRE_THROWS_AS(config.set("gcode_comments", "Z"), BadOptionTypeException);
}
AND_THEN("Value is unchanged.") {
REQUIRE(config.opt<ConfigOptionBool>("gcode_comments")->getBool() == false);
REQUIRE(config.opt<ConfigOptionBool>("gcode_comments")->getBool() == true);
}
}
WHEN("A boolean option is set to an int value") {
@@ -117,12 +116,10 @@ SCENARIO("Config accessor functions perform as expected.", "[Config]") {
}
}
WHEN("A numeric option is set to a non-numeric value.") {
auto prev_value = config.opt<ConfigOptionFloat>("max_bridge_length")->getFloat();
THEN("A BadOptionTypeException exception is thrown.") {
config.set("max_bridge_length", 7.5);
THEN("A BadOptionValueException exception is thrown and the value does not change.") {
REQUIRE_THROWS_AS(config.set_deserialize_strict("max_bridge_length", "zzzz"), BadOptionValueException);
}
THEN("The value does not change.") {
REQUIRE(config.opt<ConfigOptionFloat>("max_bridge_length")->getFloat() == prev_value);
REQUIRE_THAT(config.opt<ConfigOptionFloat>("max_bridge_length")->getFloat(), Catch::Matchers::WithinAbs(7.5, 1e-9));
}
}
WHEN("A string option is set through the string interface") {
@@ -198,7 +195,7 @@ SCENARIO("Config accessor functions perform as expected.", "[Config]") {
}
}
WHEN("An invalid option is requested during set.") {
THEN("A BadOptionTypeException exception is thrown.") {
THEN("A UnknownOptionException exception is thrown.") {
REQUIRE_THROWS_AS(config.set("deadbeef_invalid_option", 1), UnknownOptionException);
REQUIRE_THROWS_AS(config.set("deadbeef_invalid_option", 1.0), UnknownOptionException);
REQUIRE_THROWS_AS(config.set("deadbeef_invalid_option", "1"), UnknownOptionException);
@@ -643,6 +640,45 @@ SCENARIO("update_non_diff_values_to_base_config keeps a project's changed values
}
}
// SCENARIO("DynamicPrintConfig JSON serialization", "[Config]") {
// WHEN("DynamicPrintConfig is serialized and deserialized") {
// auto now = std::chrono::high_resolution_clock::now();
// auto timestamp = now.time_since_epoch().count();
// std::stringstream ss;
// ss << "catch_test_serialization_" << timestamp << ".json";
// std::string filename = (fs::temp_directory_path() / ss.str()).string();
// TODO: Finish making a unit test for JSON serialization
// FullPrintConfig full_print_config;
// DynamicPrintConfig cfg;
// cfg.apply(full_print_config, false);
// std::string serialized;
// try {
// std::ostringstream ss;
// cereal::BinaryOutputArchive oarchive(ss);
// oarchive(cfg);
// serialized = ss.str();
// } catch (const std::runtime_error & /* e */) {
// // e.what();
// }
// CAPTURE(serialized.length());
// THEN("Config object contains ini file options.") {
// DynamicPrintConfig cfg2;
// try {
// std::stringstream ss(serialized);
// cereal::BinaryInputArchive iarchive(ss);
// iarchive(cfg2);
// } catch (const std::runtime_error & /* e */) {
// // e.what();
// }
// CAPTURE(cfg.diff_report(cfg2));
// REQUIRE(cfg == cfg2);
// }
// }
// }
TEST_CASE("save_to_json round-trips plugin capability references as strings", "[Config][plugins]") {
ScopedTemporaryFile tmp(".json");
const std::vector<std::string> refs = {
@@ -17,6 +17,10 @@ using namespace Slic3r;
// #define TESTS_EXPORT_SVGS
namespace Slic3r {
Points mittered_offset_path_scaled(const Points& contour, const std::vector<float>& deltas, double miter_limit);
}
static ExPolygon spirograph_gear_1mm()
{
ExPolygon out;
@@ -475,6 +479,33 @@ SCENARIO("Elephant foot compensation", "[ElephantFoot]") {
}
}
#if 0
GIVEN("Varying inner offset") {
ExPolygon input = spirograph_gear_1mm().simplify(SCALED_EPSILON).front();
ExPolygon output;
std::vector<float> deltas(input.contour.points.size(), scale_(1.));
// mittered_offset_path_scaled_points is commented out somewhere above
output.contour.points = Slic3r::mittered_offset_path_scaled_points(input.contour.points, deltas, 2.);
#ifdef TESTS_EXPORT_SVGS
{
SVG svg(debug_out_path("elephant_foot_compensation_0.svg").c_str(), get_extents(output));
svg.draw(input, "blue");
svg.draw_outline(output, "black", coord_t(scale_(0.01)));
}
#endif /* TESTS_EXPORT_SVGS */
for (size_t i = 0; i <= deltas.size() / 2; ++ i)
deltas[i] = deltas[deltas.size() - i - 1] = scale_(1.) * double(i) / (0.5 * double(deltas.size()));
output.contour.points = Slic3r::mittered_offset_path_scaled_points(input.contour.points, deltas, 2.);
#ifdef TESTS_EXPORT_SVGS
{
SVG svg(debug_out_path("elephant_foot_compensation_varying.svg").c_str(), get_extents(output));
svg.draw(input, "blue");
svg.draw_outline(output, "black", coord_t(scale_(0.01)));
}
#endif /* TESTS_EXPORT_SVGS */
}
#endif
GIVEN("Rectangle with a narrow part sticking out") {
// Rectangle
ExPolygon expoly;
+6 -22
View File
@@ -65,41 +65,25 @@ TEST_CASE("expand_mixed_filaments replaces mixed slots with their components", "
}
}
TEST_CASE("belt purge tower island count ignores virtual mixed slots", "[FilamentMixer][belt]")
TEST_CASE("A mixed slot whose components are already used adds no filaments", "[FilamentMixer][belt]")
{
// Regression for the "extra purge tower" on a belt printer with a mixed
// filament (MCTEST5). The belt purge prism is sized as
// n_islands = used_filaments.size() - 1
// and GUI::ensure_belt_purge_tower() collected those filaments straight off
// the model objects' extruder assignments. A mixed slot is VIRTUAL -- no
// nozzle carries it, and ToolOrdering::resolve_mixed_filaments() replaces it
// with its components before any G-code is emitted -- so counting it as a
// filament of its own provisions one island that can never be reached.
//
// MCTEST5: five cubes on extruders 1..5, where filament 5 is a 50/50 blend of
// filaments 2 and 4. The G-code uses only T0..T3 and reports
// "filament used [g] = 53.35, 141.11, 40.84, 107.23, 0.00" -- filament 5
// consumes nothing, exactly as a virtual slot should.
// The belt purge tower sizes its islands from the expanded set. A mixed slot is virtual: no
// nozzle carries it and ToolOrdering::resolve_mixed_filaments() replaces it with its
// components, so it must not count as a filament of its own.
const std::vector<unsigned char> is_mixed = {0, 0, 0, 0, 1};
const std::vector<std::string> comp_strs = {"", "", "", "", "2,4"};
// The set the sizer used to see: slots 0..4 (filaments 1..5).
const std::vector<unsigned int> assigned = {0, 1, 2, 3, 4};
const auto physical = expand_mixed_filaments(assigned, is_mixed, comp_strs);
// Slot 4 dissolves into 1 and 3, which are already present.
REQUIRE(physical == std::vector<unsigned int>({0, 1, 2, 3}));
// Four physical filaments => three transitions => three islands, not four.
REQUIRE(int(physical.size()) - 1 == 3);
REQUIRE(int(assigned.size()) - 1 == 4); // what it produced before the fix
SECTION("A mixed slot whose components are otherwise unused still counts them") {
// Only the mixed slot is assigned: it must still yield its two components,
// i.e. one island, rather than collapsing to zero.
// Only the mixed slot is assigned: it must still yield its two components rather than
// collapsing to nothing.
const auto only_mixed = expand_mixed_filaments({4}, is_mixed, comp_strs);
REQUIRE(only_mixed == std::vector<unsigned int>({1, 3}));
REQUIRE(int(only_mixed.size()) - 1 == 1);
}
SECTION("No mixed filaments anywhere leaves the set untouched") {
+17
View File
@@ -208,3 +208,20 @@ TEST_CASE("Octagram spiral smooth factor controls corner curvature", "[FillPlane
REQUIRE(path_length(half_smooth) < path_length(sharp));
REQUIRE(invalid_factor == sharp);
}
TEST_CASE("Hilbert curve smooth factor controls corner curvature", "[FillPlanePath]")
{
const Points sharp = TestableHilbertCurve().generate_points(0.005);
const Points half_smooth = TestableHilbertCurve().generate_points(0.005, 0.5);
const Points full_smooth = TestableHilbertCurve().generate_points(0.005, 1.);
const Points invalid_factor = TestableHilbertCurve().generate_points(
0.005, std::numeric_limits<double>::quiet_NaN());
REQUIRE(full_smooth.front() == half_smooth.front());
REQUIRE(full_smooth.back() == half_smooth.back());
REQUIRE(path_length(full_smooth) < path_length(half_smooth));
REQUIRE(invalid_factor == sharp);
for (size_t i = 1; i < full_smooth.size(); ++i)
REQUIRE((full_smooth[i] - full_smooth[i - 1]).squaredNorm() > 0);
}
+69
View File
@@ -18,6 +18,8 @@
#include "libslic3r/ClipperUtils.hpp"
#include "libslic3r/ShortestPath.hpp"
//#include <random>
//#include "libnest2d/tools/benchmark.h"
#include "libslic3r/SVG.hpp"
#include "../libnest2d/printer_parts.hpp"
@@ -603,6 +605,73 @@ TEST_CASE("Convex polygon intersection on two overlapping squares", "[Geometry][
REQUIRE(is_inters == true);
}
//// Only for benchmarking
//static Polygon gen_convex_poly(std::mt19937_64 &rg, size_t point_cnt)
//{
// std::uniform_int_distribution<coord_t> dist(0, 100);
// Polygon out;
// out.points.reserve(point_cnt);
// coord_t tr = dist(rg) * 2 / SCALING_FACTOR;
// for (size_t i = 0; i < point_cnt; ++i)
// out.points.emplace_back(tr + dist(rg) / SCALING_FACTOR,
// tr + dist(rg) / SCALING_FACTOR);
// return Geometry::convex_hull(out.points);
//}
//TEST_CASE("Convex polygon intersection test on random polygons", "[Geometry]") {
// constexpr size_t TEST_CNT = 1000;
// constexpr size_t POINT_CNT = 1000;
// auto seed = std::random_device{}();
//// unsigned long seed = 2525634386;
// std::mt19937_64 rg{seed};
// Benchmark bench;
// auto tests = reserve_vector<std::pair<Polygon, Polygon>>(TEST_CNT);
// auto results = reserve_vector<bool>(TEST_CNT);
// auto expects = reserve_vector<bool>(TEST_CNT);
// for (size_t i = 0; i < TEST_CNT; ++i) {
// tests.emplace_back(gen_convex_poly(rg, POINT_CNT), gen_convex_poly(rg, POINT_CNT));
// }
// bench.start();
// for (const auto &test : tests)
// results.emplace_back(Geometry::convex_polygons_intersect(test.first, test.second));
// bench.stop();
// std::cout << "Test time: " << bench.getElapsedSec() << std::endl;
// bench.start();
// for (const auto &test : tests)
// expects.emplace_back(!intersection(test.first, test.second).empty());
// bench.stop();
// std::cout << "Clipper time: " << bench.getElapsedSec() << std::endl;
// REQUIRE(results.size() == expects.size());
// auto seedstr = std::to_string(seed);
// for (size_t i = 0; i < results.size(); ++i) {
// // std::cout << expects[i] << " ";
// if (results[i] != expects[i]) {
// SVG svg{std::string("fail_seed") + seedstr + "_" + std::to_string(i) + ".svg"};
// svg.draw(tests[i].first, "blue");
// svg.draw(tests[i].second, "green");
// svg.Close();
// // std::cout << std::endl;
// }
// REQUIRE(results[i] == expects[i]);
// }
// std::cout << std::endl;
//}
struct Pair
{
size_t first, second;
+15
View File
@@ -71,6 +71,15 @@ TEST_CASE("effective_physical_extruder_map - degenerate nozzle count still yield
REQUIRE(effective_physical_extruder_map(nullptr, 0).values == std::vector<int>{0});
}
TEST_CASE("effective_physical_extruder_map - IDEX ghost-color regression guard", "[IMEX]") {
// Dual extruder, no authored map. Before the GUI fix this produced a black ghost on T1
// because first_filament_for_physical_head({0}, 1) == -1.
auto default_pem = make_pem({0});
auto pem = effective_physical_extruder_map(&default_pem, 2);
REQUIRE(first_filament_for_physical_head(pem, 0) == 0);
REQUIRE(first_filament_for_physical_head(pem, 1) == 1); // no longer -1
}
TEST_CASE("imex_pem_tool_for - non-parallel mode returns -1", "[IMEX]") {
// Empty mode string (not in IMEX) and "primary" (IMEX present but not parallel) both
// short-circuit to bare-firmware PA/temp emission.
@@ -1049,6 +1058,12 @@ TEST_CASE("compute_imex_slice_offset - flag on + copy mode returns primary zone
REQUIRE_THAT(z.y(), WithinAbs(40.0, 1e-9));
}
TEST_CASE("compute_imex_slice_offset - flag on + mirror mode returns primary zone center", "[IMEX]") {
auto z = compute_imex_slice_offset(true, "mirror", kPrimaryZoneCenteredAt60_40);
REQUIRE_THAT(z.x(), WithinAbs(60.0, 1e-9));
REQUIRE_THAT(z.y(), WithinAbs(40.0, 1e-9));
}
TEST_CASE("compute_imex_slice_offset - user-defined non-primary mode names trigger shift", "[IMEX]") {
// Mode names are user-defined; anything not "primary" or "" should activate.
auto z = compute_imex_slice_offset(true, "iq-copy", kPrimaryZoneCenteredAt60_40);
@@ -6,6 +6,7 @@
#include "libslic3r/Point.hpp"
#include "libslic3r/libslic3r.h"
#include <limits>
#include <random>
#include <catch2/catch_all.hpp>
#include <vector>
#include <string>
@@ -116,6 +117,68 @@ TEST_CASE("Split two watertight meshes", "[its_split][its]") {
}
#include <libslic3r/QuadricEdgeCollapse.hpp>
static float triangle_area(const Vec3f &v0, const Vec3f &v1, const Vec3f &v2)
{
Vec3f ab = v1 - v0;
Vec3f ac = v2 - v0;
return ab.cross(ac).norm() / 2.f;
}
static float triangle_area(const stl_triangle_vertex_indices &triangle_indices, const std::vector<Vec3f> &vertices)
{
return triangle_area(vertices[triangle_indices[0]],
vertices[triangle_indices[1]],
vertices[triangle_indices[2]]);
}
#if 0
// clang complains about unused functions
static std::mt19937 create_random_generator() {
std::random_device rd;
std::mt19937 gen(rd());
return gen;
}
#endif
std::vector<Vec3f> its_sample_surface(const indexed_triangle_set &its,
double sample_per_mm2,
std::mt19937 random_generator) // = create_random_generator())
{
std::vector<Vec3f> samples;
std::uniform_real_distribution<float> rand01(0.f, 1.f);
for (const auto &triangle_indices : its.indices) {
float area = triangle_area(triangle_indices, its.vertices);
float countf;
float fractional = std::modf(area * sample_per_mm2, &countf);
int count = static_cast<int>(countf);
float generate = rand01(random_generator);
if (generate < fractional) ++count;
if (count == 0) continue;
const Vec3f &v0 = its.vertices[triangle_indices[0]];
const Vec3f &v1 = its.vertices[triangle_indices[1]];
const Vec3f &v2 = its.vertices[triangle_indices[2]];
for (int c = 0; c < count; c++) {
// barycentric coordinate
Vec3f b;
b[0] = rand01(random_generator);
b[1] = rand01(random_generator);
if ((b[0] + b[1]) > 1.f) {
b[0] = 1.f - b[0];
b[1] = 1.f - b[1];
}
b[2] = 1.f - b[0] - b[1];
Vec3f pos;
for (int i = 0; i < 3; i++) {
pos[i] = b[0] * v0[i] + b[1] * v1[i] + b[2] * v2[i];
}
samples.push_back(pos);
}
}
return samples;
}
#include "libslic3r/AABBTreeIndirect.hpp"
@@ -261,6 +261,16 @@ TEST_CASE("snake_core produces valid paths on ring", "[Snake]") {
REQUIRE(is_permutation(p2, centers.size()));
}
// --- Core Strategy Tests: Random Layout ---
TEST_CASE("snake_core produces valid paths on random input", "[Snake]") {
Points centers = make_random_16();
auto p2 = snake_core(centers);
REQUIRE(is_permutation(p2, centers.size()));
}
// --- Quality Comparison Tests ---
TEST_CASE("snake has no crossings on random input", "[Snake]") {
+19
View File
@@ -730,6 +730,25 @@ TEST_CASE("Neighboring vertices distinguish repeated anchors on different lobes"
CHECK(edges == std::vector<size_t>{0, 2, 3, 4, 6, 7});
}
TEST_CASE("Modifier holes subtract coverage while separate components add intervals", "[PreciseSeam][SegmentExtraction]")
{
const Polygon perimeter = rectangle(0, 0, 20, 20);
ExPolygon area(rectangle(1, -3, 10, 3));
area.holes.push_back(rectangle(4, -1, 7, 1));
area.holes.back().reverse();
const auto result = PreciseSeam::extract_perimeter_segments(PreciseSeam::PreparedPerimeter(perimeter),
PreciseSeam::prepare_modifier_regions({area, ExPolygon(rectangle(14, -3, 18, 3))}), ModelVolumeType::PRECISE_SEAM_CENTER);
check_provenance(perimeter, result);
REQUIRE(result.segments.size() == 3);
const std::vector<Point> starts{mm(1, 0), mm(7, 0), mm(14, 0)};
const std::vector<Point> ends{mm(4, 0), mm(10, 0), mm(18, 0)};
for (size_t i = 0; i < 3; ++i) {
CHECK(result.segments[i].polyline.points.front() == starts[i]);
CHECK(result.segments[i].polyline.points.back() == ends[i]);
}
CHECK_FALSE(result.full_containment);
}
TEST_CASE("Full coverage is distinct from an empty or point-only intersection", "[PreciseSeam][SegmentExtraction]")
{
const bool reverse = GENERATE(false, true);
+16 -15
View File
@@ -1334,12 +1334,17 @@ TEST_CASE("Renamed printer/process names are normalized into compatible lists on
CHECK(compatible_list(bundle.filaments, "My Filament", "compatible_printers") ==
(std::vector<std::string>{ "New Printer", "Unknown Printer" }));
// Normalizing rewrites config in place without flagging the preset dirty.
CHECK_FALSE(bundle.prints.find_preset("My Process", false, true)->is_dirty);
// Normalization runs before selection, so selecting copies the rewritten list into the edited preset.
bundle.prints.select_preset_by_name("My Process", true);
REQUIRE(bundle.prints.get_selected_preset_name() == "My Process");
CHECK(bundle.prints.get_edited_preset().config.option<ConfigOptionStrings>("compatible_printers")->values ==
std::vector<std::string>{ "New Printer" });
// A system preset that already references the current name is left untouched (idempotent no-op).
// Normalizing again is a no-op: the stored preset keeps the current name and still matches the
// edited copy, so the selection is not reported modified.
bundle.normalize_compatible_presets();
CHECK(compatible_list(bundle.prints, "My Process", "compatible_printers") == std::vector<std::string>{ "New Printer" });
CHECK_FALSE(bundle.prints.current_is_dirty());
}
TEST_CASE("Renamed names are normalized into a SYSTEM preset's compatible lists", "[Preset][Rename]")
@@ -1369,11 +1374,17 @@ TEST_CASE("Renamed names are normalized into a SYSTEM preset's compatible lists"
CHECK(compatible_list(bundle.filaments, "System Filament", "compatible_prints") ==
std::vector<std::string>{ "New Process" });
// The rewrite does not flag the system preset dirty, and is idempotent.
CHECK_FALSE(bundle.filaments.find_preset("System Filament", false, true)->is_dirty);
// Normalization runs before selection, so selecting copies the rewritten lists into the edited preset.
bundle.filaments.select_preset_by_name("System Filament", true);
REQUIRE(bundle.filaments.get_selected_preset_name() == "System Filament");
CHECK(bundle.filaments.get_edited_preset().config.option<ConfigOptionStrings>("compatible_printers")->values ==
std::vector<std::string>{ "New Printer" });
// Normalizing again is a no-op, so the selection is not reported modified.
bundle.normalize_compatible_presets();
CHECK(compatible_list(bundle.filaments, "System Filament", "compatible_printers") ==
std::vector<std::string>{ "New Printer" });
CHECK_FALSE(bundle.filaments.current_is_dirty());
}
TEST_CASE("compatible_prints on SLA materials resolves against sla_prints, not prints", "[Preset][Rename]")
@@ -5566,16 +5577,6 @@ TEST_CASE("Sizing down to the nozzle count plus mixes is what eats the mixed tai
CHECK(bundle.num_mixed_filaments() == 0);
CHECK(bundle.num_physical_filaments() == target);
}
SECTION("applied as a floor, the mix is left alone") {
if (target > bundle.filament_presets.size())
bundle.set_num_filaments(target);
CHECK(bundle.filament_presets.size() == 6);
CHECK(bundle.num_mixed_filaments() == 1);
CHECK(bundle.is_mixed_filament(5));
CHECK(bundle.project_config.option<ConfigOptionStrings>("filament_mixed_components")->values[5] == "1,2");
}
}
// After a published-3MF import relocated mixed-filament definitions, the freshly loaded
@@ -239,6 +239,30 @@ TEST_CASE("DistanceX on two Points binds real roles with non-negative prefill",
REQUIRE(p.defs[0].eb == 0);
}
TEST_CASE("Horizontal on a Point rejects with NeedALine", "[SketchConstraints]")
{
std::vector<SketchEntity> ents = { mk_point(1, 2) };
ConstraintPlan p = plan_entity_constraint(ents, 0, -1, -1, SketchConstraintType::Horizontal);
REQUIRE(p.kind == ConstraintPlan::Kind::Reject);
REQUIRE(p.reason == ConstraintReject::NeedALine);
}
TEST_CASE("Angle on two Circles rejects with NeedTwoLines", "[SketchConstraints]")
{
std::vector<SketchEntity> ents = { mk_circle(0, 0, 1), mk_circle(5, 0, 1) };
ConstraintPlan p = plan_entity_constraint(ents, 0, 1, -1, SketchConstraintType::Angle);
REQUIRE(p.kind == ConstraintPlan::Kind::Reject);
REQUIRE(p.reason == ConstraintReject::NeedTwoLines);
}
TEST_CASE("Parallel on a Line + Circle rejects with NeedTwoLines (new guard)", "[SketchConstraints]")
{
std::vector<SketchEntity> ents = { mk_line(0, 0, 1, 0), mk_circle(5, 0, 1) };
ConstraintPlan p = plan_entity_constraint(ents, 0, 1, -1, SketchConstraintType::Parallel);
REQUIRE(p.kind == ConstraintPlan::Kind::Reject);
REQUIRE(p.reason == ConstraintReject::NeedTwoLines);
}
TEST_CASE("Equal on two Circles promotes to EqualRadius", "[SketchConstraints]")
{
std::vector<SketchEntity> ents = { mk_circle(0, 0, 1), mk_circle(5, 0, 2) };
+5 -13
View File
@@ -5,9 +5,9 @@
#include <catch2/matchers/catch_matchers.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp>
#include "libslic3r/CAD/SketchImport.hpp"
#include "libslic3r/Utils.hpp" // resources_dir
#include "test_utils.hpp" // ScopedTemporaryFile
#include <boost/filesystem/path.hpp>
#include <cstdlib>
#include <fstream>
#include "libslic3r/Point.hpp"
@@ -78,18 +78,10 @@ TEST_CASE("transform_regions moves and scales independently", "[SketchImport]")
TEST_CASE("text_to_regions vectorizes glyphs with counters", "[SketchImport]")
{
// Locate the bundled font; resources_dir() may be unset under ctest, so
// fall back to a cwd-relative path (tests run from the repo root).
std::string font = resources_dir().empty()
? std::string("resources/fonts/HarmonyOS_Sans_SC_Regular.ttf")
: resources_dir() + "/fonts/HarmonyOS_Sans_SC_Regular.ttf";
{
std::ifstream probe(font);
if (!probe.good()) {
SUCCEED("bundled font not reachable in this environment; covered live on :10");
return;
}
}
// The bundled font, from the source tree: PROFILES_DIR is <repo>/resources/profiles.
const std::string font =
(boost::filesystem::path(PROFILES_DIR).parent_path() / "fonts" / "HarmonyOS_Sans_SC_Regular.ttf").string();
REQUIRE(std::ifstream(font).good());
// Bad input is rejected without throwing.
REQUIRE(text_to_regions("", 10.0, font).empty());
+3 -2
View File
@@ -33,8 +33,9 @@ TEST_CASE("inference: cursor near a line endpoint snaps Coincident-able to it",
TEST_CASE("inference: endpoint beats midpoint when both are in range", "[inference]")
{
std::vector<SketchEntity> ents = { line({0, 0}, {2, 0}) };
// Query equidistant-ish but closer to the endpoint: endpoint tier wins regardless.
auto s = infer_point_snap(ents, {1.9, 0.0}, 5.0);
// Query 0.2 from the midpoint (1,0) and 0.8 from the endpoint (2,0): the endpoint tier
// wins even though the midpoint is nearer.
auto s = infer_point_snap(ents, {1.2, 0.0}, 5.0, /*include_origin=*/false);
CHECK(s.kind == K::Endpoint);
CHECK(s.role == SketchPointRole::P1);
}
+4 -7
View File
@@ -113,14 +113,11 @@ TEST_CASE("profile: offsetting a closed rectangle keeps it closed", "[SketchProf
REQUIRE(closed_wires(out) == 1);
}
TEST_CASE("profile: offset outward grows the enclosed area by the right amount", "[SketchProfile]")
TEST_CASE("profile: offsetting a closed rectangle outward keeps it one closed loop", "[SketchProfile]")
{
// A rectangle offset outward by d is (w+2d) x (h+2d) with the corners rounded at r=d,
// so its area is w*h + 2d(w+h) + pi*d^2 whichever way the corners are healed... except
// for a sharp-corner offset, which is exactly (w+2d)*(h+2d). Either healing is defensible;
// a set of four disconnected segments is not, and that is what this measures.
const double w = 40, h = 20, d = 5;
const auto out = SketchEngine::offset_entities(rect(w, h), d);
// Outward is -d for a CCW loop. The miter join reconnects the corners, so the result is one
// closed wire, not four disconnected segments. Its area is checked by the sign test below.
const auto out = SketchEngine::offset_entities(rect(40, 20), -5.0);
const auto ws = SketchEngine::entities_to_wires(out, xy_plane());
REQUIRE(ws.size() == 1);
REQUIRE(ws[0].Closed());
+11
View File
@@ -44,6 +44,17 @@ SCENARIO("Reading an STL file", "[stl]") {
REQUIRE(is_approx(model.objects.front()->volumes.front()->mesh().size(), Vec3d(20, 20, 20)));
}
}
#if 0
// ASCII STLs ending with just carriage returns are not supported. These were used by the old Macs, while the Unix based MacOS uses LFs as any other Unix.
WHEN("line endings CR") {
Slic3r::Model model;
THEN("load should succeed") {
REQUIRE(Slic3r::load_stl(stl_path("ASCII/20mmbox-CR.stl").c_str(), &model));
REQUIRE(is_approx(model.objects.front()->volumes.front()->mesh().size(), Vec3d(20, 20, 20)));
}
}
#endif
WHEN("nonstandard STL file (text after ending tags, invalid normals, for example infinities)") {
Slic3r::Model model;
THEN("load should succeed") {
@@ -3285,6 +3285,33 @@ TEST_CASE("a ModelUV layer falls back to Triplanar when the volume carries no UV
CHECK_THAT((no_channel.vertices[i] - as_triplanar.vertices[i]).norm(), WithinAbs(0.f, 1e-5f));
}
TEST_CASE("a bake with colour off on every layer reports no colour at all", "[TextureDisplacement]")
{
const indexed_triangle_set cube = its_make_cube(10., 10., 10.);
TextureDisplacementLayer layer;
layer.slot = 0;
layer.depth_mm = 1.0f;
layer.tiling_scale = 5.0f;
layer.image_data = make_flat_rgb_png(200, 40, 10); // has colour to give...
layer.color_enabled = false; // ...but is not asked for it
TextureDisplacementFacetsData facets{};
facets[0] = paint_whole_mesh(cube);
std::vector<uint8_t> triangle_color;
TextureColorRequest request;
request.quantize = [](const Vec3f &) { return 0; }; // would colour everything, if asked
request.out_triangle = &triangle_color;
const indexed_triangle_set out = build_texture_displacement(cube, {layer}, facets, classic_options(), {}, &request);
REQUIRE_FALSE(out.vertices.empty());
// Every entry 0 ("this triangle takes no colour from the texture"), so the commit leaves whatever
// the model was already painted with exactly as it was.
for (const uint8_t entry : triangle_color)
CHECK(entry == 0);
}
TEST_CASE("transfer_uv_channel resamples a UV channel onto a subdivided copy of the same surface",
"[TextureDisplacement]")
{
@@ -60,9 +60,8 @@ std::vector<NozzleInfo> single_nozzle_per_extruder(int extruder_count)
}
} // namespace
TEST_CASE("Multi-nozzle gate predicate mirrors BambuStudio", "[ToolOrdering][H2C]")
TEST_CASE("Multi-nozzle options exist and default to one nozzle per extruder", "[ToolOrdering][H2C]")
{
// The multi-nozzle gate: std::any_of(extruder_max_nozzle_count > 1).
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
auto *opt = config.option<ConfigOptionIntsNullable>("extruder_max_nozzle_count");
@@ -72,15 +71,11 @@ TEST_CASE("Multi-nozzle gate predicate mirrors BambuStudio", "[ToolOrdering][H2C
// 3mf projects round-trip the per-extruder nozzle inventory (GUI producers wire it later).
REQUIRE(config.option<ConfigOptionStrings>("extruder_nozzle_stats") != nullptr);
auto has_multiple_nozzle = [](const std::vector<int> &values) {
return std::any_of(values.begin(), values.end(), [](int v) { return v > 1; });
};
// Default for every existing printer: 1 nozzle per extruder => gate is closed.
REQUIRE_FALSE(has_multiple_nozzle(opt->values));
// Synthetic H2C-like machine: extruder 1 is a 6-nozzle cluster => gate opens.
REQUIRE(has_multiple_nozzle(std::vector<int>{1, 6}));
// Default for every existing printer: 1 nozzle per extruder, so the multi-nozzle gate
// (any extruder_max_nozzle_count > 1) stays closed.
REQUIRE_FALSE(opt->values.empty());
for (int count : opt->values)
CHECK(count == 1);
}
TEST_CASE("Single-nozzle grouping: every filament maps to its extruder nozzle", "[ToolOrdering][H2C]")
+22
View File
@@ -1110,6 +1110,28 @@ TEST_CASE("a cache with the wrong cache version is rejected", "[VendorCache]")
REQUIRE(!out.load_vendor_cache(cache.string(), vid, Semver("1.0.0")));
}
TEST_CASE("a cache truncated mid-blob is rejected", "[VendorCache]")
{
TempDir tmp;
const std::string vid = "Acme";
const fs::path cache = tmp.path / "vendor.opc";
REQUIRE(save_one_vendor(cache.string(), one_vendor(vid), vid, "1.0.0",
{filament_entry(vid + " PLA")}));
{
std::ifstream in(cache.string(), std::ios::binary);
std::vector<char> buf(30); // 20-byte header + 10 bytes of blob
in.read(buf.data(), 30);
in.close();
std::ofstream out(cache.string(), std::ios::binary | std::ios::trunc);
out.write(buf.data(), 30);
}
PresetBundle out;
REQUIRE(!out.load_vendor_cache(cache.string(), vid, Semver("1.0.0")));
}
TEST_CASE("printer model bed texture fields survive a cache round-trip", "[VendorCache]")
{
TempDir tmp;
+161 -34
View File
@@ -32,8 +32,56 @@
using namespace Slic3r;
using VD = Geometry::VoronoiDiagram;
static bool has_missing_voronoi_vertices(const Polygons &polygons, const VD &vd);
using BoostVD = boost::polygon::voronoi_diagram<double>;
// https://svn.boost.org/trac10/ticket/12067
// This bug seems to be confirmed.
// Vojtech supposes that there may be no Voronoi edges produced for
// the 1st and last sweep line positions.
TEST_CASE("Voronoi missing edges - points 12067", "[Voronoi]")
{
Points pts {
{ -10, -20 },
{ 10, -20 },
{ 5, 0 },
{ 10, 20 },
{ -10, 20 },
{ -5, 0 }
};
#if 0
for (Point &p : pts) {
Vec2d q = p.cast<double>();
p.x() = scale_(p.x());
p.y() = scale_(p.y());
}
#endif
#if 0
// Try to rotate, maybe the issue is caused by incorrect handling of vertical or horizontal edges?
double a = 0.18764587962597876897475f;
double c = cos(a);
double s = sin(a);
for (Point &p : poly.points) {
Vec2d q = p.cast<double>();
p.x() = coord_t(q.x() * c + q.y() * s + 0.5);
p.y() = coord_t(- q.x() * s + q.y() * c + 0.5);
}
#endif
// Construction of the Voronoi Diagram.
BoostVD vd;
construct_voronoi(pts.begin(), pts.end(), &vd);
#ifdef VORONOI_DEBUG_OUT
dump_voronoi_to_svg(debug_out_path("voronoi-pts.svg").c_str(),
vd, pts, Lines());
#endif
// REQUIRE(closest_point.z() == Catch::Approx(1.));
}
// https://svn.boost.org/trac10/ticket/12707
// This issue is confirmed, there are no self intersections in the polygon.
// A minimal test case is created at the end of this test,
@@ -151,8 +199,12 @@ TEST_CASE("Voronoi missing edges - Alessandro gapfill 12707", "[Voronoi]")
REQUIRE(intersecting_edges({ poly }).empty());
Lines lines = to_lines(poly);
BoostVD vd;
construct_voronoi(lines.begin(), lines.end(), &vd);
VD vd;
vd.construct_voronoi(lines.begin(), lines.end());
// Orca's detector finds nothing wrong with this input, so no repair is needed.
CHECK(VD::detect_known_issues(vd, lines.begin(), lines.end()) == VD::IssueType::NO_ISSUE_DETECTED);
CHECK(vd.get_state() == VD::State::REPAIR_NOT_NEEDED);
CHECK_FALSE(has_missing_voronoi_vertices({ poly }, vd));
#ifdef VORONOI_DEBUG_OUT
dump_voronoi_to_svg(debug_out_path("voronoi-lines.svg").c_str(),
@@ -258,9 +310,16 @@ TEST_CASE("Voronoi weirdness", "[Voronoi]")
}
#endif
BoostVD vd;
VD vd;
Lines lines = to_lines(poly);
construct_voronoi(lines.begin(), lines.end(), &vd);
vd.construct_voronoi(lines.begin(), lines.end());
// Unrepaired, the diagram is missing a Voronoi vertex. construct_voronoi detects it and repairs it
// by rotating the input.
VD raw_vd;
raw_vd.construct_voronoi(lines.begin(), lines.end(), false);
CHECK(VD::detect_known_issues(raw_vd, lines.begin(), lines.end()) == VD::IssueType::MISSING_VORONOI_VERTEX);
CHECK(vd.get_state() == VD::State::REPAIR_SUCCESSFUL);
CHECK(vd.get_issue_type() == VD::IssueType::NO_ISSUE_DETECTED);
#ifdef VORONOI_DEBUG_OUT
dump_voronoi_to_svg(debug_out_path("voronoi-weirdness.svg").c_str(),
@@ -268,11 +327,40 @@ TEST_CASE("Voronoi weirdness", "[Voronoi]")
#endif
}
// https://svn.boost.org/trac10/ticket/12903
// division by zero reported, but this issue is most likely a non-issue, as it produces an infinity for the interval of validity
// of the floating point calculation, therefore forcing a recalculation with extended accuracy.
TEST_CASE("Voronoi division by zero 12903", "[Voronoi]")
{
Points pts { { 1, 1 }, { 3, 1 }, { 1, 3 }, { 3, 3 },
{ -1, 1 }, { 1, -1 }, { 5, 1 }, { 3, -1 },
{ -1, 3 }, { 1, 5 }, { 5, 3 }, { 3, 5 } };
{
auto pts2 { pts };
std::sort(pts2.begin(), pts2.end(), [](auto &l, auto &r) { return (l.x() == r.x()) ? l.y() < r.y() : l.x() < r.x(); });
// No point removed -> no duplicate.
REQUIRE(std::unique(pts2.begin(), pts2.end()) == pts2.end());
}
BoostVD vd;
construct_voronoi(pts.begin(), pts.end(), &vd);
#ifdef VORONOI_DEBUG_OUT
// Scale the voronoi vertices and input points, so that the dump_voronoi_to_svg will display them correctly.
for (auto &pt : vd.vertices()) {
const_cast<double&>(pt.x()) = scale_(pt.x());
const_cast<double&>(pt.y()) = scale_(pt.y());
}
for (auto &pt : pts)
pt = Point::new_scale(pt.x(), pt.y());
dump_voronoi_to_svg(debug_out_path("voronoi-div-by-zero.svg").c_str(), vd, pts, Lines());
#endif
}
// https://svn.boost.org/trac10/ticket/12139
// Funny sample from a dental industry?
// Vojtech confirms this test fails and rightly so, because the input data contain self intersections.
// This test is suppressed.
TEST_CASE("Voronoi NaN coordinates 12139", "[Voronoi][.][!mayfail]")
// The input data contain self intersections, so no valid Voronoi diagram can be built from it.
TEST_CASE("Voronoi NaN coordinates 12139", "[Voronoi]")
{
Lines lines = {
{ { 260500,1564400 }, { 261040,1562960 } },
@@ -1250,18 +1338,13 @@ TEST_CASE("Voronoi NaN coordinates 12139", "[Voronoi][.][!mayfail]")
}
#endif
BoostVD vd;
construct_voronoi(lines.begin(), lines.end(), &vd);
for (const auto& edge : vd.edges())
if (edge.is_finite()) {
auto v0 = edge.vertex0();
auto v1 = edge.vertex1();
REQUIRE((v0->x() == 0 || std::isnormal(v0->x())));
REQUIRE((v0->y() == 0 || std::isnormal(v0->y())));
REQUIRE((v1->x() == 0 || std::isnormal(v1->x())));
REQUIRE((v1->y() == 0 || std::isnormal(v1->y())));
}
VD vd;
vd.construct_voronoi(lines.begin(), lines.end());
// The diagram built from this self-intersecting input has non-finite vertices, and rotating the
// input does not repair it. It must be reported as invalid, which is what MedialAxis::build
// checks before falling back to a morphological closing.
CHECK(vd.get_state() == VD::State::REPAIR_UNSUCCESSFUL);
CHECK_FALSE(vd.is_valid());
#ifdef VORONOI_DEBUG_OUT
dump_voronoi_to_svg(debug_out_path("voronoi-NaNs.svg").c_str(),
@@ -1306,6 +1389,15 @@ TEST_CASE("Voronoi offset", "[VoronoiOffset]")
OffsetTest { scale_(0.55), 1, 0 }
}) {
#if 0
Polygons offsetted_polygons_out = Slic3r::Voronoi::offset(vd, lines, ot.distance, scale_(0.005));
#ifdef VORONOI_DEBUG_OUT
dump_voronoi_to_svg(debug_out_path("voronoi-offset-out-%lf.svg", ot.distance).c_str(),
vd, Points(), lines, offsetted_polygons_out);
#endif
REQUIRE(offsetted_polygons_out.size() == ot.num_outer);
#endif
Polygons offsetted_polygons_in = Slic3r::Voronoi::offset(vd, lines, - ot.distance, scale_(0.005));
#ifdef VORONOI_DEBUG_OUT
dump_voronoi_to_svg(debug_out_path("voronoi-offset-in-%lf.svg", ot.distance).c_str(),
@@ -1849,7 +1941,7 @@ TEST_CASE("Voronoi skeleton", "[VoronoiSkeleton]")
}
// Simple detection with complexity N^2 if there is any point in the input polygons that doesn't have Voronoi vertex.
[[maybe_unused]] static bool has_missing_voronoi_vertices(const Polygons &polygons, const VD &vd)
static bool has_missing_voronoi_vertices(const Polygons &polygons, const VD &vd)
{
auto are_equal = [](const VD::vertex_type v, const Point &p) { return (Vec2d(v.x(), v.y()) - p.cast<double>()).norm() <= SCALED_EPSILON; };
@@ -1871,7 +1963,7 @@ TEST_CASE("Voronoi skeleton", "[VoronoiSkeleton]")
// into two parts. Even we add more points to the edge, and then for these points, the Voronoin vertex is also missing. An
// infinity-edge passes through the missing Voronoi vertex. Therefore, this missing Voronoi vertex and edge can be reconstructed
// using the intersection between the infinity-edge with the input polygon.
// Rotation of the polygon solves this problem.
// VoronoiDiagram::construct_voronoi repairs it by rotating the input.
TEST_CASE("Voronoi missing vertex 1", "[VoronoiMissingVertex1]")
{
Polygon poly = {
@@ -1895,7 +1987,14 @@ TEST_CASE("Voronoi missing vertex 1", "[VoronoiMissingVertex1]")
dump_voronoi_to_svg(debug_out_path("voronoi-missing-vertex1-out.svg").c_str(), vd, Points(), lines);
#endif
// REQUIRE(!has_missing_voronoi_vertices({poly}, vd));
// Unrepaired, the diagram shows the defect. construct_voronoi detects it and repairs it by
// rotating the input.
VD raw_vd;
raw_vd.construct_voronoi(lines.begin(), lines.end(), false);
CHECK(has_missing_voronoi_vertices({poly}, raw_vd));
CHECK(vd.get_state() == VD::State::REPAIR_SUCCESSFUL);
CHECK(vd.get_issue_type() == VD::IssueType::NO_ISSUE_DETECTED);
CHECK_FALSE(has_missing_voronoi_vertices({poly}, vd));
}
// This case is composed of two square polygons (contour and hole), and again one of the edges is divided into two parts by a
@@ -1903,7 +2002,7 @@ TEST_CASE("Voronoi missing vertex 1", "[VoronoiMissingVertex1]")
// that for this case, through the missing Voronoi vertex is passing a finite edge between two internal Voronoin vertices.
// Therefore, this missing Voronoi vertex and edge can be reconstructed using the intersection between the finite edge with the
// input polygon.
// Rotation of the polygons solves this problem.
// VoronoiDiagram::construct_voronoi repairs it by rotating the input.
TEST_CASE("Voronoi missing vertex 2", "[VoronoiMissingVertex2]")
{
Polygons poly = {
@@ -1935,13 +2034,20 @@ TEST_CASE("Voronoi missing vertex 2", "[VoronoiMissingVertex2]")
dump_voronoi_to_svg(debug_out_path("voronoi-missing-vertex2-out.svg").c_str(), vd, Points(), lines);
#endif
// REQUIRE(!has_missing_voronoi_vertices(poly, vd));
// Unrepaired, the diagram shows the defect. construct_voronoi detects it and repairs it by
// rotating the input.
VD raw_vd;
raw_vd.construct_voronoi(lines.begin(), lines.end(), false);
CHECK(has_missing_voronoi_vertices(poly, raw_vd));
CHECK(vd.get_state() == VD::State::REPAIR_SUCCESSFUL);
CHECK(vd.get_issue_type() == VD::IssueType::NO_ISSUE_DETECTED);
CHECK_FALSE(has_missing_voronoi_vertices(poly, vd));
}
// This case is composed of two polygons, and again one of the edges is divided into two parts by a point that lies on this edge,
// and for this point is Voronoi vertex missing. A difference between the previous cases and this case through the missing
// Voronoi vertex is passing finite edge between one inner Voronoi vertex and one outer Voronoi vertex.
// Rotating the polygon also help solve this problem.
// VoronoiDiagram::construct_voronoi repairs it by rotating the input.
TEST_CASE("Voronoi missing vertex 3", "[VoronoiMissingVertex3]")
{
Polygons poly = {
@@ -1969,20 +2075,27 @@ TEST_CASE("Voronoi missing vertex 3", "[VoronoiMissingVertex3]")
// polygons_rotate(poly, PI/180);
// polygons_rotate(poly, PI/6);
BoostVD vd;
VD vd;
Lines lines = to_lines(poly);
construct_voronoi(lines.begin(), lines.end(), &vd);
vd.construct_voronoi(lines.begin(), lines.end());
#ifdef VORONOI_DEBUG_OUT
dump_voronoi_to_svg(debug_out_path("voronoi-missing-vertex3-out.svg").c_str(), vd, Points(), lines);
#endif
// REQUIRE(!has_missing_voronoi_vertices(poly, vd));
// Unrepaired, the diagram shows the defect. construct_voronoi detects it and repairs it by
// rotating the input.
VD raw_vd;
raw_vd.construct_voronoi(lines.begin(), lines.end(), false);
CHECK(has_missing_voronoi_vertices(poly, raw_vd));
CHECK(vd.get_state() == VD::State::REPAIR_SUCCESSFUL);
CHECK(vd.get_issue_type() == VD::IssueType::NO_ISSUE_DETECTED);
CHECK_FALSE(has_missing_voronoi_vertices(poly, vd));
}
// In this case, the Voronoi vertex (146873, -146873) is included twice.
// Also, near to those duplicate Voronoi vertices is another Voronoi vertex (146872, -146872).
// Rotating the polygon will help solve this problem, but then there arise three very close Voronoi vertices.
// Rotating of the input polygon will help solve this problem.
// VoronoiDiagram::construct_voronoi repairs it by rotating the input.
TEST_CASE("Duplicate Voronoi vertices", "[Voronoi]")
{
Polygon poly = {
@@ -2009,7 +2122,7 @@ TEST_CASE("Duplicate Voronoi vertices", "[Voronoi]")
dump_voronoi_to_svg(debug_out_path("voronoi-duplicate-vertices-out.svg").c_str(), vd, Points(), lines);
#endif
[[maybe_unused]] auto has_duplicate_vertices = [](const VD &vd) -> bool {
auto has_duplicate_vertices = [](const VD &vd) -> bool {
std::vector<Vec2d> vertices;
for (const auto &vertex : vd.vertices())
vertices.emplace_back(Vec2d(vertex.x(), vertex.y()));
@@ -2018,14 +2131,21 @@ TEST_CASE("Duplicate Voronoi vertices", "[Voronoi]")
return std::unique(vertices.begin(), vertices.end()) != vertices.end();
};
// REQUIRE(!has_duplicate_vertices(vd));
// Unrepaired, the diagram shows the defect. construct_voronoi detects it and repairs it by
// rotating the input.
VD raw_vd;
raw_vd.construct_voronoi(lines.begin(), lines.end(), false);
CHECK(has_duplicate_vertices(raw_vd));
CHECK(vd.get_state() == VD::State::REPAIR_SUCCESSFUL);
CHECK(vd.get_issue_type() == VD::IssueType::NO_ISSUE_DETECTED);
CHECK_FALSE(has_duplicate_vertices(vd));
}
// In this case, there are three very close Voronoi vertices like in the previous test case after rotation. There is also one
// missing Voronoi vertex. One infinity-edge (after clip [(146872, -70146871), (146872, -146871)]) passes through this missing
// Voronoi vertex. This infinite edge [(146872, -70146871), (146872, -146871)] and edge [(146873, -146873), (0, 0)] are intersecting.
// They intersect probably because the three points are very close to each other, with a combination of the missing Voronoi vertex.
// Rotating of the input polygon will help solve this problem.
// VoronoiDiagram::construct_voronoi repairs it by rotating the input.
TEST_CASE("Intersecting Voronoi edges", "[Voronoi]")
{
Polygon poly = {
@@ -2049,7 +2169,7 @@ TEST_CASE("Intersecting Voronoi edges", "[Voronoi]")
dump_voronoi_to_svg(debug_out_path("voronoi-intersecting-edges-out.svg").c_str(), vd, Points(), lines);
#endif
[[maybe_unused]] auto has_intersecting_edges = [](const Polygon &poly, const VD &vd) -> bool {
auto has_intersecting_edges = [](const Polygon &poly, const VD &vd) -> bool {
BoundingBox bbox = get_extents(poly);
const double bbox_dim_max = double(std::max(bbox.size().x(), bbox.size().y()));
@@ -2080,5 +2200,12 @@ TEST_CASE("Intersecting Voronoi edges", "[Voronoi]")
return false;
};
// REQUIRE(!has_intersecting_edges(poly, vd));
// Unrepaired, the diagram shows the defect. construct_voronoi detects it and repairs it by
// rotating the input.
VD raw_vd;
raw_vd.construct_voronoi(lines.begin(), lines.end(), false);
CHECK(has_intersecting_edges(poly, raw_vd));
CHECK(vd.get_state() == VD::State::REPAIR_SUCCESSFUL);
CHECK(vd.get_issue_type() == VD::IssueType::NO_ISSUE_DETECTED);
CHECK_FALSE(has_intersecting_edges(poly, vd));
}
@@ -1 +1,61 @@
#include <catch2/catch_all.hpp>
#include <catch2/catch_test_macros.hpp>
#include "slic3r/Utils/Http.hpp"
TEST_CASE("Check SSL certificates paths", "[Http][NotWorking]") {
Slic3r::Http g = Slic3r::Http::get("https://github.com/");
unsigned status = 0;
g.on_error([&status](std::string, std::string, unsigned http_status) {
status = http_status;
});
g.on_complete([&status](std::string /* body */, unsigned http_status){
status = http_status;
});
g.perform_sync();
REQUIRE(status == 200);
}
TEST_CASE("Http digest authentication", "[Http][NotWorking]") {
Slic3r::Http g = Slic3r::Http::get("https://httpbingo.org/digest-auth/auth/guest/guest");
g.auth_digest("guest", "guest");
unsigned status = 0;
g.on_error([&status](std::string, std::string, unsigned http_status) {
status = http_status;
});
g.on_complete([&status](std::string /* body */, unsigned http_status){
status = http_status;
});
g.perform_sync();
REQUIRE(status == 200);
}
TEST_CASE("Http basic authentication", "[Http][NotWorking]") {
Slic3r::Http g = Slic3r::Http::get("https://httpbingo.org/basic-auth/guest/guest");
g.auth_basic("guest", "guest");
unsigned status = 0;
g.on_error([&status](std::string, std::string, unsigned http_status) {
status = http_status;
});
g.on_complete([&status](std::string /* body */, unsigned http_status){
status = http_status;
});
g.perform_sync();
REQUIRE(status == 200);
}
+28
View File
@@ -73,6 +73,34 @@ TEST_CASE("ActionRegistry takes exclusive ownership of published actions", "[Act
STATIC_CHECK(std::is_same_v<decltype(&ActionRegistry::upsert), ExpectedUpsert>);
}
// A dynamic "Go to Plate N" action is keyed by plate index (not the display title), so renaming
// a plate never re-keys it - the same contract as a setting action.
TEST_CASE("Go-to-plate actions are keyed by index, not title", "[ActionSource][SpeedDial]")
{
CHECK(AppAction::compose_id("orca_plate_goto", "0", "orca") == "orca_plate_goto:0:orca");
CHECK(AppAction::compose_id("orca_plate_goto", "2", "orca") == "orca_plate_goto:2:orca");
}
// A dynamic "Open recent project" action is keyed by file path (not the display name), so renaming
// a project or reordering the recents list never re-keys it - the same contract as a setting action.
TEST_CASE("Recent-project actions are keyed by path, not title", "[ActionSource][SpeedDial]")
{
CHECK(AppAction::compose_id("orca_recent_project", "/a/b/project.3mf", "orca") ==
"orca_recent_project:/a/b/project.3mf:orca");
CHECK(AppAction::compose_id("orca_recent_project", "C:/Data/cube.3mf", "orca") ==
"orca_recent_project:C:/Data/cube.3mf:orca");
}
// A built-in command is keyed by its stable catalog key (not the localized display title), so a
// rename or a UI-language switch never re-keys the action and its persisted favourite/stats survive.
TEST_CASE("Command actions are keyed by catalog key, not display title", "[ActionSource][SpeedDial]")
{
CHECK(AppAction::compose_id("orca_command", "save_project", "orca") == "orca_command:save_project:orca");
// The second field is the stable key, so distinct commands never collide.
CHECK(AppAction::compose_id("orca_command", "save_project", "orca") !=
AppAction::compose_id("orca_command", "load_project", "orca"));
}
// The real catalog -> action mapping keys by the stable catalog key and copies presentation from the
// catalog, so a rename or a UI-language switch never re-keys the action. The catalog's English title
// and group become the action's English search aliases; no translation catalog is loaded in the tests,
@@ -8,6 +8,7 @@
#include <slic3r/plugin/PythonPluginInterface.hpp>
#include <catch2/catch_test_macros.hpp>
#include <catch2/catch_message.hpp>
#include "plugin_test_utils.hpp"
#include <pybind11/pytypes.h>
#include <pybind11/eval.h>
@@ -17,6 +18,12 @@
#include <pybind11/embed.h>
#include <pybind11/pybind11.h>
#include <boost/filesystem/operations.hpp>
#include <boost/filesystem/path.hpp>
#include <chrono>
#include <fstream>
#include <ios>
#include <memory>
#include <string>
#include <pybind11/cast.h>
@@ -105,6 +112,72 @@ PluginCapabilityId capability_id(PluginCapabilityType type, const char* name, co
return {type, name, plugin_key};
}
// restore_config_response looks the capability up in PluginManager, so the restore tests load a real
// plugin rather than building a detached capability.
const char* const RESTORE_PLUGIN_KEY = "Restore_Plugin";
const char* const RESTORE_PLUGIN_SOURCE = R"PY(# /// script
# requires-python = ">=3.12"
#
# [tool.orcaslicer.plugin]
# name = "Restore Plugin"
# description = "Restore defaults fixture"
# author = "OrcaSlicer"
# version = "1.0"
# type = "script"
# ///
import orca
class Target(orca.script.ScriptPluginCapabilityBase):
def get_name(self): return "Target"
def execute(self, ctx): return orca.ExecutionResult.success()
def get_default_config(self): return {"speed": 1}
class Bystander(orca.script.ScriptPluginCapabilityBase):
def get_name(self): return "Bystander"
def execute(self, ctx): return orca.ExecutionResult.success()
def get_default_config(self): return {"speed": 2}
# Loading seeds defaults through get_default_config and fails the plugin if it raises, so this one
# answers the load and raises from then on.
class Raising(orca.script.ScriptPluginCapabilityBase):
calls = 0
def get_name(self): return "Raising"
def execute(self, ctx): return orca.ExecutionResult.success()
def get_default_config(self):
Raising.calls += 1
if Raising.calls > 1:
raise RuntimeError("boom")
return {}
@orca.plugin
class RestorePackage(orca.base):
def register_capabilities(self):
orca.register_capability(Target)
orca.register_capability(Bystander)
orca.register_capability(Raising)
)PY";
// Writes the restore fixture into {data_dir}/orca_plugins and loads it, returning the load error
// (empty on success). Call without holding the GIL: the load runs on a worker thread.
std::string load_restore_plugin(const ScopedDataDir& data_dir_guard)
{
const boost::filesystem::path plugin_dir = data_dir_guard.plugins_dir() / RESTORE_PLUGIN_KEY;
boost::filesystem::create_directories(plugin_dir);
{
std::ofstream out((plugin_dir / (std::string(RESTORE_PLUGIN_KEY) + ".py")).string(), std::ios::binary);
out << RESTORE_PLUGIN_SOURCE;
}
PluginManager& manager = PluginManager::instance();
manager.discover_plugins(/*async=*/false, /*clear=*/true);
manager.load_plugin(RESTORE_PLUGIN_KEY, /*skip_deps=*/true);
std::string error;
if (!manager.wait_for_plugin_load(RESTORE_PLUGIN_KEY, std::chrono::seconds(120), error) && error.empty())
error = "the plugin did not load";
return error;
}
} // namespace
TEST_CASE("Capability config API is exposed on every Python capability", "[PluginConfig][Python]")
@@ -357,26 +430,27 @@ TEST_CASE("Restoring defaults overwrites only the target capability", "[PluginCo
ScopedPluginManager plugin_system; // declared first: destroyed last
if (!plugin_system.initialized)
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
py::gil_scoped_acquire gil; // released before plugin_system's destructor shuts Python down
ScopedDataDir data_dir_guard("plugin-config-py-restore");
host_config().load();
const std::string load_error = load_restore_plugin(data_dir_guard);
INFO("load error: " << load_error);
REQUIRE(load_error.empty());
const std::string defaults_body = " def get_name(self): return 'cap'\n"
" def get_default_config(self): return {'speed': 1}\n";
py::object target = make_capability("RestoreTargetCap", defaults_body, "plugin_a", "cap_a");
py::object bystander = make_capability("RestoreBystanderCap", defaults_body, "plugin_b", "cap_a");
const PluginCapabilityId target = capability_id(PluginCapabilityType::Script, "Target", RESTORE_PLUGIN_KEY);
const PluginCapabilityId bystander = capability_id(PluginCapabilityType::Script, "Bystander", RESTORE_PLUGIN_KEY);
const json edited = json{{"speed", 99}};
REQUIRE(py_save_config(target, edited));
REQUIRE(py_save_config(bystander, edited));
REQUIRE(host_config().store_capability_config(target, edited));
REQUIRE(host_config().store_capability_config(bystander, edited));
// What PluginsDialog::restore_capability_config does: ask the capability, store the answer.
auto iface = as_interface(target);
REQUIRE(host_config().store_capability_config(capability_id(PluginCapabilityType::Script, "cap_a", "plugin_a"), iface->get_default_config()));
const json response = PluginConfig::restore_config_response(target);
CHECK(host_config().get_config(capability_id(PluginCapabilityType::Script, "cap_a", "plugin_a"))->config == json{{"speed", 1}});
CHECK(host_config().get_config(capability_id(PluginCapabilityType::Script, "cap_a", "plugin_b"))->config == json{{"speed", 99}});
CHECK(response["ok"] == true);
CHECK(response["config"] == json{{"speed", 1}});
CHECK(host_config().get_config(target)->config == json{{"speed", 1}});
CHECK(host_config().get_config(bystander)->config == edited);
PluginManager::instance().unload_plugin(RESTORE_PLUGIN_KEY);
}
TEST_CASE("A raising get_default_config leaves the stored config untouched", "[PluginConfig][Python]")
@@ -384,24 +458,23 @@ TEST_CASE("A raising get_default_config leaves the stored config untouched", "[P
ScopedPluginManager plugin_system; // declared first: destroyed last
if (!plugin_system.initialized)
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
py::gil_scoped_acquire gil; // released before plugin_system's destructor shuts Python down
ScopedDataDir data_dir_guard("plugin-config-py-restore-raise");
host_config().load();
const std::string load_error = load_restore_plugin(data_dir_guard);
INFO("load error: " << load_error);
REQUIRE(load_error.empty());
py::object cap = make_capability("RaisingDefaultsCap",
" def get_name(self): return 'cap_a'\n"
" def get_default_config(self): raise RuntimeError('boom')\n",
"plugin_a", "cap_a");
const PluginCapabilityId raising = capability_id(PluginCapabilityType::Script, "Raising", RESTORE_PLUGIN_KEY);
REQUIRE(host_config().store_capability_config(raising, json{{"keep", "me"}}));
REQUIRE(py_save_config(cap, json{{"keep", "me"}}));
const json response = PluginConfig::restore_config_response(raising);
auto iface = as_interface(cap);
REQUIRE(iface);
CHECK_THROWS_AS(iface->get_default_config(), py::error_already_set);
CHECK(response["ok"] == false);
CHECK_FALSE(response["error"].get<std::string>().empty());
CHECK(host_config().get_config(raising)->config == json{{"keep", "me"}});
// The dialog stores nothing when the hook throws: a broken plugin must not wipe user settings.
CHECK(host_config().get_config(capability_id(PluginCapabilityType::Script, "cap_a", "plugin_a"))->config == json{{"keep", "me"}});
PluginManager::instance().unload_plugin(RESTORE_PLUGIN_KEY);
}
TEST_CASE("A raising config UI hook surfaces as an exception the host can catch", "[PluginConfig][Python]")
@@ -76,12 +76,12 @@ TEST_CASE("plugin latest version uses the authoritative catalog field", "[Plugin
{
PluginDescriptor descriptor;
descriptor.version = "1.3.0";
descriptor.latest_version = "1.3.0";
descriptor.latest_version = "1.4.0";
PluginChangelog changelog;
changelog.version = "1.2.0";
descriptor.changelog.push_back(changelog);
CHECK(descriptor.latest_available_version() == "1.3.0");
CHECK(descriptor.latest_available_version() == "1.4.0");
}
TEST_CASE("plugin latest version falls back to the descriptor version", "[PluginDescriptor]")
+16
View File
@@ -138,6 +138,22 @@ TEST_CASE("PluginConfig serializes the documented on-disk schema", "[PluginConfi
CHECK(entry.size() == 5);
}
TEST_CASE("PluginConfig keeps a capability's config after its plugin goes away", "[PluginConfig]")
{
ScopedDataDir data_dir_guard("plugin-config-retention");
{
PluginConfig config;
REQUIRE(config.store_capability_config(capability_id(PluginCapabilityType::Script, "cap_a", "plugin_a"), json{{"token", "keep me"}}));
}
// config.json is deliberately not keyed to installed plugins: a record outlives its plugin and is
// still there on reinstall. Asserts no cleanup path silently drops it.
PluginConfig after_removal;
after_removal.load();
CHECK(after_removal.get_config(capability_id(PluginCapabilityType::Script, "cap_a", "plugin_a"))->config == json{{"token", "keep me"}});
}
TEST_CASE("PluginConfig treats a missing config file as an empty store", "[PluginConfig]")
{
ScopedDataDir data_dir_guard("plugin-config-missing");
@@ -615,12 +615,19 @@ TEST_CASE("orca.host: SurfaceCollection.set mutates geometry; lslices via make_s
.attr("expolygon").attr("contour").attr("as_array")().cast<py::array>();
CHECK(c.shape(0) == 4);
// lslices are derived: make_slices() re-derives them + refreshes the bbox cache.
// lslices are derived: make_slices() re-derives them from the region slices and refreshes the
// bbox cache.
TestLayer layer;
layer.add_region(nullptr);
py::object ly = py::cast(static_cast<Slic3r::Layer*>(&layer), py::return_value_policy::reference);
// (A hand-built layer has no regions, so make_slices() yields empty lslices — still null-safe.)
ly.attr("regions")().cast<py::list>()[0].attr("slices").attr("set")(expolys, ST.attr("stInternalSolid"));
REQUIRE(layer.lslices.empty());
ly.attr("make_slices")();
CHECK(layer.lslices_bboxes.size() == layer.lslices.size());
REQUIRE(layer.lslices.size() == 1);
CHECK_THAT(layer.lslices.front().area(), WithinRel((double) s * (double) s, 1e-9));
REQUIRE(layer.lslices_bboxes.size() == 1);
CHECK(layer.lslices_bboxes.front().min == Point(0, 0));
CHECK(layer.lslices_bboxes.front().max == Point(s, s));
}
TEST_CASE("orca.host ExPolygon in-place transforms + SurfaceCollection.append (sample ops)", "[slicing_pipeline]") {