Merge main

This commit is contained in:
Lam Wei Lun
2026-10-12 19:55:28 +08:00
895 changed files with 86139 additions and 78120 deletions
+7
View File
@@ -11,6 +11,13 @@ endif ()
add_test(NAME cli_strict_mode
COMMAND bash ${CMAKE_CURRENT_SOURCE_DIR}/test_cli_strict.sh $<TARGET_FILE:OrcaSlicer> ${ORCA_CLI_TEST_PYTHON})
add_test(NAME cli_empty_project_config
COMMAND bash ${CMAKE_CURRENT_SOURCE_DIR}/test_cli_empty_project_config.sh $<TARGET_FILE:OrcaSlicer> ${ORCA_CLI_TEST_PYTHON} ${CMAKE_SOURCE_DIR})
set_tests_properties(cli_empty_project_config PROPERTIES
LABELS "CLI;RequiresApp"
SKIP_RETURN_CODE 77
TIMEOUT 300)
set_tests_properties(cli_strict_mode PROPERTIES
LABELS "CLI;RequiresApp"
SKIP_RETURN_CODE 77
+52
View File
@@ -0,0 +1,52 @@
#!/usr/bin/env bash
# Regression check: a 3mf whose Metadata/project_settings.config carries no settings must load.
#
# The CLI reads printable_height out of the project config with opt_float(), which dereferences
# what option<>() returns. With create = false that is null when the key is absent, so a project
# saved without settings used to take the CLI down with a segfault. Both models in
# resources/handy_models are such files.
#
# usage: test_cli_empty_project_config.sh <orca-slicer binary> <python3> <source dir>
set -u
BIN="${1:-}"
PY="${2:-python3}"
SRC="${3:-}"
# 77 is the test's SKIP_RETURN_CODE.
[ -x "$BIN" ] || { echo "SKIP: orca-slicer binary not found: $BIN"; exit 77; }
[ -f "$SRC/resources/handy_models/OrcaBadge.3mf" ] || { echo "SKIP: handy model not found"; exit 77; }
WORK="$(mktemp -d "${TMPDIR:-/tmp}/orca-cli-emptycfg.XXXXXX")"
trap 'rm -rf "$WORK"' EXIT
mkdir -p "$WORK/datadir"
# Rewrite the project settings to an empty object, so the test holds no matter what the shipped
# models carry later on.
cp "$SRC/resources/handy_models/OrcaBadge.3mf" "$WORK/empty_config.3mf"
"$PY" - "$WORK/empty_config.3mf" <<'PYEOF'
import shutil, sys, zipfile
path = sys.argv[1]
entry = "Metadata/project_settings.config"
with zipfile.ZipFile(path) as src:
items = [(i, src.read(i.filename)) for i in src.infolist()]
with zipfile.ZipFile(path + ".new", "w", zipfile.ZIP_DEFLATED) as dst:
seen = False
for info, data in items:
if info.filename == entry:
data, seen = b"{\n}\n", True
dst.writestr(info, data)
if not seen:
dst.writestr(entry, b"{\n}\n")
shutil.move(path + ".new", path)
PYEOF
"$BIN" --datadir "$WORK/datadir" --info "$WORK/empty_config.3mf" > "$WORK/info.txt" 2>&1
rc=$?
if [ $rc -ne 0 ]; then
echo "FAIL: --info on a project with empty settings exited $rc"
tail -20 "$WORK/info.txt"
exit 1
fi
grep -q "size_x" "$WORK/info.txt" || { echo "FAIL: --info printed no geometry"; cat "$WORK/info.txt"; exit 1; }
echo "PASS: a project with empty settings loads"
+166
View File
@@ -0,0 +1,166 @@
ISO-10303-21;
HEADER;
FILE_DESCRIPTION(('Open CASCADE Model'),'2;1');
FILE_NAME('Open CASCADE Shape Model','2026-10-08T09:57:33',('Author'),(
'Open CASCADE'),'Open CASCADE STEP processor 8.0','Open CASCADE 8.0'
,'Unknown');
FILE_SCHEMA(('AUTOMOTIVE_DESIGN { 1 0 10303 214 1 1 1 1 }'));
ENDSEC;
DATA;
#1 = APPLICATION_PROTOCOL_DEFINITION('international standard',
'automotive_design',2000,#2);
#2 = APPLICATION_CONTEXT(
'core data for automotive mechanical design processes');
#3 = SHAPE_DEFINITION_REPRESENTATION(#4,#10);
#4 = PRODUCT_DEFINITION_SHAPE('','',#5);
#5 = PRODUCT_DEFINITION('design','',#6,#9);
#6 = PRODUCT_DEFINITION_FORMATION('','',#7);
#7 = PRODUCT('Open CASCADE STEP translator 8.0 1',
'Open CASCADE STEP translator 8.0 1','',(#8));
#8 = PRODUCT_CONTEXT('',#2,'mechanical');
#9 = PRODUCT_DEFINITION_CONTEXT('part definition',#2,'design');
#10 = ADVANCED_BREP_SHAPE_REPRESENTATION('',(#11,#15),#121);
#11 = AXIS2_PLACEMENT_3D('',#12,#13,#14);
#12 = CARTESIAN_POINT('',(0.,0.,0.));
#13 = DIRECTION('',(0.,0.,1.));
#14 = DIRECTION('',(1.,0.,-0.));
#15 = MANIFOLD_SOLID_BREP('',#16);
#16 = CLOSED_SHELL('',(#17,#113,#117));
#17 = ADVANCED_FACE('',(#18),#31,.T.);
#18 = FACE_BOUND('',#19,.T.);
#19 = EDGE_LOOP('',(#20,#58,#81,#112));
#20 = ORIENTED_EDGE('',*,*,#21,.F.);
#21 = EDGE_CURVE('',#22,#22,#24,.T.);
#22 = VERTEX_POINT('',#23);
#23 = CARTESIAN_POINT('',(-36.4,0.,25.));
#24 = SURFACE_CURVE('',#25,(#30,#42),.PCURVE_S1.);
#25 = CIRCLE('',#26,36.4);
#26 = AXIS2_PLACEMENT_3D('',#27,#28,#29);
#27 = CARTESIAN_POINT('',(0.,0.,25.));
#28 = DIRECTION('',(0.,0.,1.));
#29 = DIRECTION('',(1.,0.,-0.));
#30 = PCURVE('',#31,#36);
#31 = CONICAL_SURFACE('',#32,36.4,0.785398163397);
#32 = AXIS2_PLACEMENT_3D('',#33,#34,#35);
#33 = CARTESIAN_POINT('',(0.,0.,25.));
#34 = DIRECTION('',(0.,0.,1.));
#35 = DIRECTION('',(1.,0.,-0.));
#36 = DEFINITIONAL_REPRESENTATION('',(#37),#41);
#37 = LINE('',#38,#39);
#38 = CARTESIAN_POINT('',(-6.28318530718,3.552713678801E-15));
#39 = VECTOR('',#40,1.);
#40 = DIRECTION('',(1.,0.));
#41 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2)
PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE',''
) );
#42 = PCURVE('',#43,#48);
#43 = PLANE('',#44);
#44 = AXIS2_PLACEMENT_3D('',#45,#46,#47);
#45 = CARTESIAN_POINT('',(0.,0.,25.));
#46 = DIRECTION('',(-0.,-0.,-1.));
#47 = DIRECTION('',(-1.,0.,0.));
#48 = DEFINITIONAL_REPRESENTATION('',(#49),#57);
#49 = ( BOUNDED_CURVE() B_SPLINE_CURVE(2,(#50,#51,#52,#53,#54,#55,#56),
.UNSPECIFIED.,.T.,.F.) B_SPLINE_CURVE_WITH_KNOTS((1,2,2,2,2,1),(
-2.094395102393,0.,2.094395102393,4.188790204786,6.28318530718,
8.377580409573),.UNSPECIFIED.) CURVE() GEOMETRIC_REPRESENTATION_ITEM()
RATIONAL_B_SPLINE_CURVE((1.,0.5,1.,0.5,1.,0.5,1.)) REPRESENTATION_ITEM(
'') );
#50 = CARTESIAN_POINT('',(-36.4,0.));
#51 = CARTESIAN_POINT('',(-36.4,63.046649395507));
#52 = CARTESIAN_POINT('',(18.2,31.523324697754));
#53 = CARTESIAN_POINT('',(72.8,8.915428697793E-15));
#54 = CARTESIAN_POINT('',(18.2,-31.52332469775));
#55 = CARTESIAN_POINT('',(-36.4,-63.0466493955));
#56 = CARTESIAN_POINT('',(-36.4,0.));
#57 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2)
PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE',''
) );
#58 = ORIENTED_EDGE('',*,*,#59,.T.);
#59 = EDGE_CURVE('',#22,#60,#62,.T.);
#60 = VERTEX_POINT('',#61);
#61 = CARTESIAN_POINT('',(-11.4,0.,3.552713678801E-15));
#62 = SEAM_CURVE('',#63,(#67,#74),.PCURVE_S1.);
#63 = LINE('',#64,#65);
#64 = CARTESIAN_POINT('',(-36.4,0.,25.));
#65 = VECTOR('',#66,1.);
#66 = DIRECTION('',(0.707106781187,8.659560562349E-17,-0.707106781187));
#67 = PCURVE('',#31,#68);
#68 = DEFINITIONAL_REPRESENTATION('',(#69),#73);
#69 = LINE('',#70,#71);
#70 = CARTESIAN_POINT('',(-3.14159265359,3.552713678801E-15));
#71 = VECTOR('',#72,1.);
#72 = DIRECTION('',(2.13162820728E-16,-1.));
#73 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2)
PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE',''
) );
#74 = PCURVE('',#31,#75);
#75 = DEFINITIONAL_REPRESENTATION('',(#76),#80);
#76 = LINE('',#77,#78);
#77 = CARTESIAN_POINT('',(3.14159265359,3.552713678801E-15));
#78 = VECTOR('',#79,1.);
#79 = DIRECTION('',(2.13162820728E-16,-1.));
#80 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2)
PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE',''
) );
#81 = ORIENTED_EDGE('',*,*,#82,.F.);
#82 = EDGE_CURVE('',#60,#60,#83,.T.);
#83 = SURFACE_CURVE('',#84,(#89,#96),.PCURVE_S1.);
#84 = CIRCLE('',#85,11.4);
#85 = AXIS2_PLACEMENT_3D('',#86,#87,#88);
#86 = CARTESIAN_POINT('',(0.,0.,3.552713678801E-15));
#87 = DIRECTION('',(0.,0.,-1.));
#88 = DIRECTION('',(1.,0.,0.));
#89 = PCURVE('',#31,#90);
#90 = DEFINITIONAL_REPRESENTATION('',(#91),#95);
#91 = LINE('',#92,#93);
#92 = CARTESIAN_POINT('',(6.28318530718,-25.));
#93 = VECTOR('',#94,1.);
#94 = DIRECTION('',(-1.,0.));
#95 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2)
PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE',''
) );
#96 = PCURVE('',#97,#102);
#97 = PLANE('',#98);
#98 = AXIS2_PLACEMENT_3D('',#99,#100,#101);
#99 = CARTESIAN_POINT('',(0.,0.,3.552713678801E-15));
#100 = DIRECTION('',(0.,0.,1.));
#101 = DIRECTION('',(1.,0.,-0.));
#102 = DEFINITIONAL_REPRESENTATION('',(#103),#111);
#103 = ( BOUNDED_CURVE() B_SPLINE_CURVE(2,(#104,#105,#106,#107,#108,#109
,#110),.UNSPECIFIED.,.T.,.F.) B_SPLINE_CURVE_WITH_KNOTS((1,2,2,2,2,1),(
-2.094395102393,0.,2.094395102393,4.188790204786,6.28318530718,
8.377580409573),.UNSPECIFIED.) CURVE() GEOMETRIC_REPRESENTATION_ITEM()
RATIONAL_B_SPLINE_CURVE((1.,0.5,1.,0.5,1.,0.5,1.)) REPRESENTATION_ITEM(
'') );
#104 = CARTESIAN_POINT('',(11.4,0.));
#105 = CARTESIAN_POINT('',(11.4,-19.74537920628));
#106 = CARTESIAN_POINT('',(-5.7,-9.872689603143));
#107 = CARTESIAN_POINT('',(-22.8,-2.792194702056E-15));
#108 = CARTESIAN_POINT('',(-5.7,9.872689603143));
#109 = CARTESIAN_POINT('',(11.4,19.745379206285));
#110 = CARTESIAN_POINT('',(11.4,0.));
#111 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2)
PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE',''
) );
#112 = ORIENTED_EDGE('',*,*,#59,.F.);
#113 = ADVANCED_FACE('',(#114),#43,.F.);
#114 = FACE_BOUND('',#115,.F.);
#115 = EDGE_LOOP('',(#116));
#116 = ORIENTED_EDGE('',*,*,#21,.F.);
#117 = ADVANCED_FACE('',(#118),#97,.F.);
#118 = FACE_BOUND('',#119,.F.);
#119 = EDGE_LOOP('',(#120));
#120 = ORIENTED_EDGE('',*,*,#82,.F.);
#121 = ( GEOMETRIC_REPRESENTATION_CONTEXT(3)
GLOBAL_UNCERTAINTY_ASSIGNED_CONTEXT((#125)) GLOBAL_UNIT_ASSIGNED_CONTEXT
((#122,#123,#124)) REPRESENTATION_CONTEXT('Context #1',
'3D Context with UNIT and UNCERTAINTY') );
#122 = ( LENGTH_UNIT() NAMED_UNIT(*) SI_UNIT(.MILLI.,.METRE.) );
#123 = ( NAMED_UNIT(*) PLANE_ANGLE_UNIT() SI_UNIT($,.RADIAN.) );
#124 = ( NAMED_UNIT(*) SI_UNIT($,.STERADIAN.) SOLID_ANGLE_UNIT() );
#125 = UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE(1.E-07),#122,
'distance_accuracy_value','confusion accuracy');
#126 = PRODUCT_RELATED_PRODUCT_CATEGORY('part',$,(#7));
ENDSEC;
END-ISO-10303-21;
+3
View File
@@ -8,10 +8,13 @@ add_executable(${_TEST_NAME}_tests
test_extrusion_processor.cpp
test_fill.cpp
test_flow.cpp
test_gcode_processor.cpp
test_gcode_timing.cpp
test_gcode.cpp
test_gcodeprocessor.cpp
test_gcodewriter.cpp
test_imex_mode_gcode.cpp
test_imex_slice_offset.cpp
test_model.cpp
test_multifilament.cpp
test_perimeters.cpp
+59
View File
@@ -4,6 +4,7 @@
#include "test_helpers.hpp"
#include <algorithm>
#include <cstddef>
#include "libslic3r/PrintConfig.hpp"
#include "libslic3r/Config.hpp"
#include "libslic3r/TriangleMesh.hpp"
@@ -35,6 +36,64 @@ TEST_CASE("Cooling consumes its internal speed markers", "[Cooling]")
CHECK(gcode.find(";_EXTRUDE_SET_SPEED") == std::string::npos);
}
TEST_CASE("Arc fitting remains active with overhang fan control", "[Cooling][Regression]")
{
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
config.set_deserialize_strict({
{ "enable_arc_fitting", true },
{ "enable_overhang_bridge_fan", true },
{ "enable_overhang_speed", true },
{ "initial_layer_print_height", 0.2 },
{ "layer_height", 0.2 },
{ "slow_down_for_layer_cooling", false },
});
config.set_key_value("fan_max_speed", new ConfigOptionFloats{20.0});
config.set_key_value("fan_min_speed", new ConfigOptionFloats{20.0});
config.set_key_value("overhang_fan_speed", new ConfigOptionInts{100});
config.set_key_value("overhang_fan_threshold", new ConfigOptionEnumsGeneric{Overhang_threshold_2_4});
config.set_key_value("layer_change_gcode", new ConfigOptionString{";TEST_LAYER=[layer_num]"});
const auto has_arc_after_first_layer = [](const std::string &gcode) {
std::istringstream input(gcode);
std::string line;
size_t layers_seen = 0;
while (std::getline(input, line)) {
if (line.rfind(";TEST_LAYER=", 0) == 0)
++layers_seen;
else if (layers_seen > 1 && (line.rfind("G2 ", 0) == 0 || line.rfind("G3 ", 0) == 0))
return true;
}
return false;
};
const auto has_arc_with_overhang_fan = [](const std::string &gcode) {
std::istringstream input(gcode);
std::string line;
bool overhang_fan_active = false;
bool outer_wall = false;
while (std::getline(input, line)) {
if (line.rfind("M106 S255", 0) == 0)
overhang_fan_active = true;
else if (line.rfind("M106 S0", 0) == 0 || line.rfind("M107", 0) == 0)
overhang_fan_active = false;
else if (line.rfind(";TYPE:", 0) == 0)
outer_wall = line == ";TYPE:Outer wall";
else if (overhang_fan_active && outer_wall && (line.rfind("G2 ", 0) == 0 || line.rfind("G3 ", 0) == 0))
return true;
}
return false;
};
const std::string with_overhang_speed = slice({ make_cylinder(25.0, 10.0, 2.0 * PI / 72.0) }, config);
CHECK(has_arc_after_first_layer(with_overhang_speed));
config.set_deserialize_strict({ { "enable_overhang_speed", false } });
const std::string without_overhang_speed = slice({ make_cylinder(25.0, 10.0, 2.0 * PI / 72.0) }, config);
CHECK(has_arc_after_first_layer(without_overhang_speed));
const std::string uniform_overhang_gcode = slice({ make_sphere(50.0, 2.0 * PI / 72.0) }, config);
CHECK(has_arc_with_overhang_fan(uniform_overhang_gcode));
}
TEST_CASE("Overhang fan transitions do not depend on overhang speed", "[Cooling][Regression]")
{
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
+564 -1
View File
@@ -1,6 +1,7 @@
#include <catch2/catch_all.hpp>
#include <algorithm>
#include <array>
#include <cmath>
#include <cstddef>
#include <cstdint>
@@ -36,6 +37,7 @@
#include "libslic3r/Fill/Fill.hpp"
#include "libslic3r/Fill/FillAdaptive.hpp"
#include "libslic3r/Fill/FillGyroid.hpp"
#include "libslic3r/Fill/FillTpmsAdaptive.hpp"
#include "libslic3r/Flow.hpp"
#include "libslic3r/Geometry.hpp"
#include "libslic3r/IntersectionPoints.hpp"
@@ -1779,7 +1781,7 @@ TEST_CASE("Sparse plane-path anchors match the printed infill", "[Fill][Internal
const AABBTreeLines::LinesDistancer<Line> printed_tree(to_lines(printed));
// Orca: Exclude perimeter connections: anchoring and extrusion can trim those differently.
const Polylines anchors = intersection_pl(layer.generate_sparse_infill_polylines_for_anchoring(nullptr, nullptr, nullptr),
const Polylines anchors = intersection_pl(layer.generate_sparse_infill_polylines_for_anchoring(nullptr, nullptr),
shrink(to_polygons(layer.lslices), scale_(3.)));
REQUIRE_FALSE(anchors.empty());
double max_distance = 0.;
@@ -1790,3 +1792,564 @@ TEST_CASE("Sparse plane-path anchors match the printed infill", "[Fill][Internal
// would hide anchors that no longer coincide with printed lines.
CHECK(unscale<double>(max_distance) <= config.opt_float("resolution"));
}
// Orca: Slices the meshes as the parts of one object, where they are, with modifiers of their own config.
static Print &slice_parts(Print &print, DynamicPrintConfig config, const std::vector<TriangleMesh> &parts,
const std::vector<std::pair<TriangleMesh, DynamicPrintConfig>> &modifiers = {})
{
config.set_deserialize_strict({{"layer_height", 0.2},
{"initial_layer_print_height", 0.2},
{"elefant_foot_compensation", 0},
{"top_shell_thickness", 0},
{"bottom_shell_thickness", 0}});
Model model;
Slic3r::Test::init_print({parts.front()}, print, model, config, nullptr, false);
for (size_t i = 1; i < parts.size(); ++ i)
model.objects.front()->add_volume(TriangleMesh(parts[i]), ModelVolumeType::MODEL_PART, false);
for (const auto &[mesh, modifier_config] : modifiers)
model.objects.front()->add_volume(TriangleMesh(mesh), ModelVolumeType::PARAMETER_MODIFIER, false)->config.apply(modifier_config);
print.apply(model, config);
print.process();
return print;
}
// Orca: Two identical cubes in one mesh that never touch, so each is a body of its own.
static Print &slice_two_bodies(Print &print, const DynamicPrintConfig &config, double height)
{
TriangleMesh mesh = make_cube(20, 20, height);
TriangleMesh second = make_cube(20, 20, height);
second.translate(33, 7, 0);
mesh.merge(second);
return slice_parts(print, config, {mesh});
}
// Orca: Counts the points sampled along both sets that the other set does not repeat.
static void count_unmatched(const Polylines &a, const Polylines &b, size_t &sampled, size_t &unmatched)
{
const std::array<const Polylines *, 2> sets{&a, &b};
for (size_t i = 0; i < 2; ++ i) {
const Polylines &other = *sets[1 - i];
const AABBTreeLines::LinesDistancer<Line> distancer(to_lines(other));
for (const Polyline &path : *sets[i])
for (const Point &point : path.equally_spaced_points(scale_(0.2))) {
++ sampled;
unmatched += other.empty() || distancer.distance_from_lines<false>(point) > scale_(0.05);
}
}
}
static Polylines layer_paths(const Layer &layer, ExtrusionRole role)
{
Polylines polylines;
for (const LayerRegion *region : layer.regions())
for (const ExtrusionEntity *entity : region->fills.flatten().entities)
if (entity->role() == role)
entity->collect_polylines(polylines);
return polylines;
}
// Orca: Share of the paths of a role that the other body does not repeat around its own center.
static double unmatched_between_bodies(const Print &print, ExtrusionRole role)
{
size_t sampled = 0, unmatched = 0;
for (const Layer *layer : print.objects().front()->layers()) {
REQUIRE(layer->lslices.size() == 2);
const Polylines polylines = layer_paths(*layer, role);
std::array<Polylines, 2> paths;
for (size_t body = 0; body < 2; ++ body) {
// Orca: Exclude the links along the walls, which each body may chain differently.
paths[body] = intersection_pl(polylines, shrink(to_polygons(layer->lslices[body]), scale_(3.)));
for (Polyline &path : paths[body])
path.translate(-layer->lslices_bboxes[body].center());
}
count_unmatched(paths[0], paths[1], sampled, unmatched);
}
REQUIRE(sampled > 0);
return double(unmatched) / double(sampled);
}
// Orca: Share of the paths of a role inside a bed region that two slices of the same body do not share.
static double unmatched_between_prints(const Print &a, const Print &b, ExtrusionRole role, const Polygons &region)
{
const PrintObject &object_a = *a.objects().front(), &object_b = *b.objects().front();
REQUIRE(object_a.layer_count() == object_b.layer_count());
size_t sampled = 0, unmatched = 0;
for (size_t i = 0; i < object_a.layer_count(); ++ i) {
std::array<Polylines, 2> paths;
for (const PrintObject *object : {&object_a, &object_b}) {
Polylines &out = paths[object == &object_b];
out = layer_paths(*object->get_layer(int(i)), role);
for (Polyline &path : out)
path.translate(object->instances().front().shift);
out = intersection_pl(out, region);
}
count_unmatched(paths[0], paths[1], sampled, unmatched);
}
REQUIRE(sampled > 0);
return double(unmatched) / double(sampled);
}
TEST_CASE("Separated infill centers the sparse infill of each body on itself", "[Fill][Regression]")
{
const std::string pattern = GENERATE("line", "zigzag", "crosszag", "honeycomb", "3dhoneycomb", "crosshatch", "tpmsd", "tpmsfk", "gyroid");
const bool separated = GENERATE(false, true);
CAPTURE(pattern, separated);
auto config = DynamicPrintConfig::full_print_config();
// Orca: The Zig Zag patterns mirror each body about its own center.
config.set_deserialize_strict({{"sparse_infill_pattern", pattern},
{"sparse_infill_density", "20%"},
{"symmetric_infill_y_axis", true},
{"top_shell_layers", 0},
{"bottom_shell_layers", 0},
{"separated_infills", separated}});
Print print;
const double unmatched = unmatched_between_bodies(slice_two_bodies(print, config, 2.), erInternalInfill);
// Orca: Without separation both bodies cut one object-wide pattern at different places.
if (separated)
CHECK(unmatched < 0.02);
else
CHECK(unmatched > 0.5);
}
TEST_CASE("Separated infill centers monotonic and rectilinear bridges on each body", "[Fill][InternalBridge][Regression]")
{
// Orca: Bridges use the Monotonic pattern below monotonic top surfaces and Rectilinear otherwise.
const std::string top_pattern = GENERATE("monotonicline", "rectilinear");
const bool separated = GENERATE(false, true);
CAPTURE(top_pattern, separated);
auto config = DynamicPrintConfig::full_print_config();
config.set_deserialize_strict({{"sparse_infill_pattern", "rectilinear"},
{"sparse_infill_density", "15%"},
{"top_surface_pattern", top_pattern},
{"top_shell_layers", 4},
{"bottom_shell_layers", 0},
{"separated_infills", separated}});
Print print;
const double unmatched = unmatched_between_bodies(slice_two_bodies(print, config, 4.), erInternalBridgeInfill);
if (separated)
CHECK(unmatched < 0.02);
else
CHECK(unmatched > 0.5);
}
// Orca: Share of the infill of an off center pillar, and of the frame of four overlapping bars around it,
// that each body sliced alone does not repeat. The frame is one body of several parts that holds the pillar.
static std::pair<double, double> frame_and_pillar_unmatched(const DynamicPrintConfig &config)
{
auto box = [](double x, double y, double size_x, double size_y) {
TriangleMesh mesh = make_cube(size_x, size_y, 6);
mesh.translate(x, y, 0);
return mesh;
};
const std::vector<TriangleMesh> frame{box(0, 0, 60, 14), box(0, 46, 60, 14), box(0, 0, 14, 60), box(46, 0, 14, 60)};
const std::vector<TriangleMesh> pillar{box(18, 20, 16, 16)};
std::vector<TriangleMesh> both = frame;
both.push_back(pillar.front());
Print print_both, print_frame, print_pillar;
slice_parts(print_both, config, both);
slice_parts(print_frame, config, frame);
slice_parts(print_pillar, config, pillar);
// Orca: Bed regions 3 mm inside the walls, away from the links along them.
auto rect = [](double x0, double y0, double x1, double y1) {
return Polygon({Point::new_scale(x0, y0), Point::new_scale(x1, y0), Point::new_scale(x1, y1), Point::new_scale(x0, y1)});
};
return {unmatched_between_prints(print_both, print_pillar, erInternalInfill, {rect(21, 23, 31, 33)}),
unmatched_between_prints(print_both, print_frame, erInternalInfill, diff(Polygons{rect(3, 3, 57, 57)}, Polygons{rect(11, 11, 49, 49)}))};
}
TEST_CASE("Separated infill fills each body like the body sliced alone", "[Fill][Regression]")
{
// Orca: Hilbert Curve and the Zig Zag links follow the extent of the box, not only its center.
const std::string pattern = GENERATE("hilbertcurve", "zigzag", "crosszag", "gyroid");
CAPTURE(pattern);
auto config = DynamicPrintConfig::full_print_config();
config.set_deserialize_strict({{"sparse_infill_pattern", pattern},
{"sparse_infill_density", "20%"},
{"symmetric_infill_y_axis", true},
{"top_shell_layers", 0},
{"bottom_shell_layers", 0},
{"separated_infills", true}});
const std::pair<double, double> unmatched = frame_and_pillar_unmatched(config);
CHECK(unmatched.first < 0.02);
CHECK(unmatched.second < 0.02);
}
TEST_CASE("Adaptive infill fills each body like the body sliced alone", "[Fill][Regression]")
{
const std::string pattern = GENERATE("adaptivecubic", "supportcubic");
// Orca: Octree infill centers each body whether or not separated infills are enabled.
const bool separated = GENERATE(false, true);
CAPTURE(pattern, separated);
auto config = DynamicPrintConfig::full_print_config();
config.set_deserialize_strict({{"sparse_infill_pattern", pattern},
{"sparse_infill_density", "40%"},
{"top_shell_layers", 0},
{"bottom_shell_layers", 0},
{"separated_infills", separated}});
// Orca: The octree of the whole object is laid out from its center, which the off center pillar does not share.
const std::pair<double, double> unmatched = frame_and_pillar_unmatched(config);
CHECK(unmatched.first < 0.02);
CHECK(unmatched.second < 0.02);
}
TEST_CASE("Adaptive infill of a modifier leaves the density of the other regions", "[Fill][Regression]")
{
const std::string pattern = GENERATE("adaptivecubic", "supportcubic");
CAPTURE(pattern);
auto config = DynamicPrintConfig::full_print_config();
config.set_deserialize_strict({{"sparse_infill_pattern", pattern},
{"sparse_infill_density", "15%"},
{"top_shell_layers", 0},
{"bottom_shell_layers", 0}});
TriangleMesh bodies = make_cube(30, 30, 6), second = make_cube(30, 30, 6);
second.translate(40, 0, 0);
bodies.merge(second);
// Orca: A denser modifier over the right half of the second body.
TriangleMesh modifier = make_cube(20, 40, 10);
modifier.translate(55, -5, -2);
DynamicPrintConfig dense = config;
dense.set_deserialize_strict({{"sparse_infill_density", "60%"}});
Print print, print_sparse, print_dense;
slice_parts(print, config, {bodies}, {{modifier, dense}});
slice_parts(print_sparse, config, {bodies});
slice_parts(print_dense, dense, {bodies});
// Orca: Bed regions 3 mm inside the walls and the modifier, away from the links along them.
auto rect = [](double x0, double y0, double x1, double y1) {
return Polygon({Point::new_scale(x0, y0), Point::new_scale(x1, y0), Point::new_scale(x1, y1), Point::new_scale(x0, y1)});
};
CHECK(unmatched_between_prints(print, print_sparse, erInternalInfill, {rect(3, 3, 27, 27), rect(43, 3, 52, 27)}) < 0.02);
CHECK(unmatched_between_prints(print, print_dense, erInternalInfill, {rect(58, 3, 67, 27)}) < 0.02);
}
// Length of the sparse infill of the first object over the layers with print_z in [z_min, z_max], inside clip.
static double sparse_infill_length(const Print &print, coordf_t z_min, coordf_t z_max, const Polygons &clip)
{
Polylines polylines;
for (const Layer *layer : print.objects().front()->layers())
if (layer->print_z >= z_min && layer->print_z <= z_max)
for (const LayerRegion *region : layer->regions())
for (const ExtrusionEntity *entity : region->fills.flatten().entities)
if (entity->role() == erInternalInfill)
entity->collect_polylines(polylines);
return unscale<double>(total_length(intersection_pl(polylines, clip)));
}
static Polygons centered_square(double half)
{
return {Polygon({Point::new_scale(-half, -half), Point::new_scale(half, -half), Point::new_scale(half, half), Point::new_scale(-half, half)})};
}
TEST_CASE("Adaptive TPMS infill thins out from the surface to the interior density", "[Fill]")
{
// The modes following the distance print more lines than their target where its levels meet: along the shells, or
// where the patterns blend.
const auto row = GENERATE(table<std::string, std::string, double>({{"tpmsd", "lobes", 0.5},
{"tpmsfk", "lobes", 0.5},
{"gyroid", "lobes", 0.5},
{"gyroid", "stepped_shells", 0.6},
{"tpmsfk", "smooth_blend", 0.6},
{"tpmsd", "distance_warp", 0.5}}));
const std::string pattern = std::get<0>(row);
const std::string mode = std::get<1>(row);
const double max_core = std::get<2>(row);
CAPTURE(pattern, mode);
// A 60 mm cube in 0.4 mm layers, centered on the origin in XY. Its center is 30 mm from every face.
auto slice = [&pattern](const std::string &adaptive, Print &print) {
Slic3r::Test::init_and_process_print({Slic3r::Test::cube(60)}, print,
{{"sparse_infill_pattern", pattern},
{"sparse_infill_density", "25%"},
{"tpms_adaptive", adaptive},
{"tpms_interior_density", "5%"},
{"tpms_adaptive_gradient", "linear"},
{"layer_height", 0.4},
{"initial_layer_print_height", 0.4}});
};
Print uniform, adaptive;
slice("disabled", uniform);
slice(mode, adaptive);
// The middle of the cube, at most 10 mm from the center: at most 5% + 20% * 10 / 30 = 11.7% dense.
const Polygons core = centered_square(10.);
// Along the sides at mid height, 24 to 28 mm from the center: at least 5% + 20% * 24 / 30 = 21% dense.
const Polygons shell = diff(centered_square(28.), centered_square(24.));
const double core_uniform = sparse_infill_length(uniform, 25., 35., core);
const double shell_uniform = sparse_infill_length(uniform, 25., 35., shell);
REQUIRE(core_uniform > 0.);
REQUIRE(shell_uniform > 0.);
CHECK(sparse_infill_length(adaptive, 25., 35., core) < max_core * core_uniform);
CHECK(sparse_infill_length(adaptive, 25., 35., shell) > 0.75 * shell_uniform);
}
TEST_CASE("Adaptive TPMS infill of a tall object is sparsest at its middle height", "[Fill]")
{
// A 30 x 30 x 90 mm box in 0.4 mm layers: its center is 45 mm high, the middle of its core, not a column.
Print print;
Slic3r::Test::init_and_process_print({make_cube(30., 30., 90.)}, print,
{{"sparse_infill_pattern", "tpmsd"},
{"sparse_infill_density", "25%"},
{"tpms_adaptive", "lobes"},
{"tpms_interior_density", "5%"},
{"tpms_adaptive_gradient", "linear"},
{"layer_height", 0.4},
{"initial_layer_print_height", 0.4}});
const Polygons core = centered_square(5.);
const double middle = sparse_infill_length(print, 40., 50., core);
const double low = sparse_infill_length(print, 10., 20., core);
REQUIRE(low > 0.);
CHECK(middle < 0.7 * low);
}
TEST_CASE("2D adaptive TPMS infill does not change along its axis", "[Fill]")
{
// A box of 30 x 30 mm sections, 90 mm long along the axis, centered on the origin in XY.
const std::string mode = GENERATE("normal_x", "normal_z");
CAPTURE(mode);
const bool along_x = mode == "normal_x";
auto slice = [&](const std::string &adaptive, Print &print) {
Slic3r::Test::init_and_process_print({along_x ? make_cube(90., 30., 30.) : make_cube(30., 30., 90.)}, print,
{{"sparse_infill_pattern", "gyroid"},
{"sparse_infill_density", "25%"},
{"tpms_adaptive", adaptive},
{"tpms_interior_density", "5%"},
{"tpms_adaptive_gradient", "linear"},
{"layer_height", 0.4},
{"initial_layer_print_height", 0.4}});
};
Print uniform, adaptive;
slice("disabled", uniform);
slice(mode, adaptive);
// The core of the sections near an end and at the middle of the box, at most 5 mm from their center.
auto rectangle = [](double x0, double x1) {
return Polygons{Polygon({Point::new_scale(x0, -5.), Point::new_scale(x1, -5.), Point::new_scale(x1, 5.), Point::new_scale(x0, 5.)})};
};
auto core = [&](const Print &print, bool end) {
return along_x ? sparse_infill_length(print, 10., 20., rectangle(end ? -40. : -5., end ? -30. : 5.)) :
sparse_infill_length(print, end ? 10. : 40., end ? 20. : 50., centered_square(5.));
};
const double end = core(adaptive, true);
const double middle = core(adaptive, false);
REQUIRE(end > 0.);
CHECK(middle > 0.8 * end);
CHECK(middle < 1.25 * end);
CHECK(middle < 0.7 * core(uniform, false));
}
TEST_CASE("Adaptive TPMS shells and blend keep the whole deep core of a tall object sparse", "[Fill]")
{
// Distance warp grades it partly along the height, like Lobes, as its warp is centered on the middle.
const std::string mode = GENERATE("stepped_shells", "smooth_blend");
CAPTURE(mode);
// A 30 x 30 x 90 mm box: from 15 to 75 mm high its axis is 15 mm deep, as deep as its center.
auto slice = [](const std::string &adaptive, Print &print) {
Slic3r::Test::init_and_process_print({make_cube(30., 30., 90.)}, print,
{{"sparse_infill_pattern", "tpmsd"},
{"sparse_infill_density", "25%"},
{"tpms_adaptive", adaptive},
{"tpms_interior_density", "5%"},
{"tpms_adaptive_gradient", "linear"},
{"layer_height", 0.4},
{"initial_layer_print_height", 0.4}});
};
Print uniform, distance;
slice("disabled", uniform);
slice(mode, distance);
// At 15 to 25 mm high the axis is as sparse as at the middle, where Lobes grades it towards the bottom.
const Polygons core = centered_square(5.);
const double uniform_low = sparse_infill_length(uniform, 15., 25., core);
const double low = sparse_infill_length(distance, 15., 25., core);
const double middle = sparse_infill_length(distance, 40., 50., core);
CAPTURE(uniform_low, low, middle);
REQUIRE(middle > 0.);
CHECK(low < 0.7 * uniform_low);
CHECK(low < 1.25 * middle);
}
TEST_CASE("Adaptive TPMS infill is sparse at the center of both of two united spheres", "[Fill]")
{
// Two spheres of 20 mm, their centers 32 mm apart at 20 mm high: centered on the origin, they are at x = -16 and 16.
TriangleMesh spheres = make_sphere(20., 2. * PI / 90.);
spheres.translate(-16.f, 0.f, 20.f);
TriangleMesh second = make_sphere(20., 2. * PI / 90.);
second.translate(16.f, 0.f, 20.f);
spheres.merge(second);
auto slice = [&spheres](const std::string &adaptive, Print &print) {
Slic3r::Test::init_and_process_print({TriangleMesh(spheres)}, print,
{{"sparse_infill_pattern", "gyroid"},
{"sparse_infill_density", "25%"},
{"tpms_adaptive", adaptive},
{"tpms_interior_density", "5%"},
{"tpms_adaptive_gradient", "linear"},
{"layer_height", 0.4},
{"initial_layer_print_height", 0.4}});
};
Print uniform, adaptive;
slice("disabled", uniform);
slice("lobes", adaptive);
// Within 5 mm of either center: at most 5% + 20% * 7.1 / 20 = 12.1% dense, where one center for both
// would leave the other sphere at more than 60% of the uniform density.
for (const double x : {-16., 16.}) {
CAPTURE(x);
Polygons core = centered_square(5.);
for (Polygon &square : core)
square.translate(Point::new_scale(x, 0.));
const double core_uniform = sparse_infill_length(uniform, 18., 22., core);
REQUIRE(core_uniform > 0.);
CHECK(sparse_infill_length(adaptive, 18., 22., core) < 0.45 * core_uniform);
}
}
TEST_CASE("Adaptive TPMS gradients keep the surface density deeper in the order quadratic, linear, exponential", "[Fill]")
{
// With a denser surface, t^2 <= t and the geometric interpolation is below the linear one at every depth.
auto length_for = [](const std::string &gradient) {
Print print;
Slic3r::Test::init_and_process_print({Slic3r::Test::cube(40)}, print,
{{"sparse_infill_pattern", "tpmsd"},
{"sparse_infill_density", "25%"},
{"tpms_adaptive", "lobes"},
{"tpms_interior_density", "5%"},
{"tpms_adaptive_gradient", gradient},
{"layer_height", 0.4},
{"initial_layer_print_height", 0.4}});
return sparse_infill_length(print, 0., 40., centered_square(20.));
};
const double quadratic = length_for("quadratic");
const double linear = length_for("linear");
const double exponential = length_for("exponential");
CHECK(quadratic > linear);
CHECK(linear > exponential);
}
TEST_CASE("Adaptive TPMS settings leave the infill unchanged when they do not apply", "[Fill]")
{
// Adaptive density turned off, or turned on for a pattern that is no TPMS.
const auto [pattern, adaptive] = GENERATE(
table<std::string, std::string>({{"tpmsd", "disabled"}, {"tpmsfk", "disabled"}, {"gyroid", "disabled"}, {"grid", "lobes"}, {"grid", "stepped_shells"}, {"grid", "smooth_blend"}, {"grid", "distance_warp"}, {"grid", "normal_z"}}));
CAPTURE(pattern, adaptive);
Print reference, tuned;
Slic3r::Test::init_and_process_print({Slic3r::Test::cube(20)}, reference,
{{"sparse_infill_pattern", pattern}, {"sparse_infill_density", "20%"}, {"layer_height", 0.2}});
Slic3r::Test::init_and_process_print({Slic3r::Test::cube(20)}, tuned,
{{"sparse_infill_pattern", pattern},
{"sparse_infill_density", "20%"},
{"layer_height", 0.2},
{"tpms_adaptive", adaptive},
{"tpms_interior_density", "40%"},
{"tpms_adaptive_gradient", "exponential"}});
const SparseInfillShape expected = sparse_infill_shape(reference);
REQUIRE(expected.path_count > 0);
CHECK(sparse_infill_shape(tuned).sequence == expected.sequence);
}
TEST_CASE("Adaptive gyroid infill ignores the Z-buckling optimization", "[Fill]")
{
auto shape_for = [](const std::string &gyroid_optimized) {
Print print;
Slic3r::Test::init_and_process_print({Slic3r::Test::cube(20)}, print,
{{"sparse_infill_pattern", "gyroid"},
{"sparse_infill_density", "10%"},
{"gyroid_optimized", gyroid_optimized},
{"tpms_adaptive", "lobes"},
{"layer_height", 0.2}});
return sparse_infill_shape(print);
};
const SparseInfillShape expected = shape_for("0");
REQUIRE(expected.path_count > 0);
CHECK(shape_for("1").sequence == expected.sequence);
}
TEST_CASE("Adaptive TPMS infill of a region matches the infill of a larger region of the same object", "[Fill]")
{
const InfillPattern pattern = GENERATE(ipGyroid, ipTpmsD, ipTpmsFK);
CAPTURE(pattern);
// An 80 x 80 x 40 mm box around both regions, which share its radial field.
const ExPolygons box{ExPolygon(Points{Point::new_scale(60., 20.), Point::new_scale(140., 20.), Point::new_scale(140., 100.),
Point::new_scale(60., 100.)})};
std::vector<TpmsRadialField::Slice> slices;
for (int i = 0; i < 200; ++i)
slices.push_back({0.2 * i, 0.2 * (i + 1), &box});
const TpmsRadialField field(slices, get_extents(box), TpmsAdaptiveMode::Lobes, [] {});
auto circle = [](double radius) {
Polygon contour = make_circle_num_segments(scale_(radius), 120);
contour.translate(Point::new_scale(100., 60.));
return ExPolygon(std::move(contour));
};
const ExPolygon region = circle(20.);
const ExPolygon larger = circle(30.);
auto fill = [pattern, &field](const ExPolygon &expolygon, double z) {
std::unique_ptr<Fill> filler(Fill::new_from_type(pattern));
filler->spacing = 0.45;
filler->angle = float(M_PI / 7.);
filler->z = z;
filler->tpms_radial_field = &field;
FillParams params;
params.density = 0.2f;
params.tpms_adaptive = TpmsAdaptiveMode::Lobes;
params.tpms_interior_density = 0.05f;
params.tpms_adaptive_gradient = TpmsAdaptiveGradient::Linear;
params.layer_height = 0.2;
params.dont_adjust = true;
Surface surface(stInternal, expolygon);
return filler->fill_surface(&surface, params);
};
// Away from the boundary of the region, where both are clipped and connected the same way.
const Polygons inner = shrink(to_polygons(region), scale_(1.));
auto farthest = [&inner](const Polylines &from, const Polylines &to) {
const AABBTreeLines::LinesDistancer<Line> tree(to_lines(to));
double distance = 0.;
for (const Polyline &path : intersection_pl(from, inner))
for (const Point &point : path.equally_spaced_points(scale_(0.2)))
distance = std::max(distance, tree.distance_from_lines<false>(point));
return unscale<double>(distance);
};
// Marching squares simplifies rings that start elsewhere in each region. At 15.325 mm TPMS-FK has a saddle,
// whose lines connect one way or the other with the sampling grid.
const double tolerance = SPARSE_INFILL_RESOLUTION + 0.01;
for (const double z : {5.1, 12.3, 15.325, 20.1, 27.9, 34.7}) {
CAPTURE(z);
const Polylines paths = fill(region, z);
REQUIRE_FALSE(paths.empty());
const Polylines reference = fill(larger, z);
CHECK(farthest(reference, paths) < tolerance);
CHECK(farthest(paths, reference) < tolerance);
}
}
TEST_CASE("Adaptive TPMS anchors match the printed infill", "[Fill][InternalBridge]")
{
const std::string pattern = GENERATE("tpmsd", "tpmsfk", "gyroid");
CAPTURE(pattern);
Print print;
Slic3r::Test::init_and_process_print({Slic3r::Test::cube(30)}, print,
{{"sparse_infill_pattern", pattern},
{"sparse_infill_density", "25%"},
{"tpms_adaptive", "lobes"},
{"tpms_interior_density", "5%"},
{"layer_height", 0.2},
{"initial_layer_print_height", 0.2},
{"resolution", 0.012}});
const Layer &layer = *print.objects().front()->get_layer(40);
Polylines printed;
for (const LayerRegion *region : layer.regions())
for (const ExtrusionEntity *entity : region->fills.flatten().entities)
if (entity->role() == erInternalInfill)
entity->collect_polylines(printed);
REQUIRE_FALSE(printed.empty());
const AABBTreeLines::LinesDistancer<Line> printed_tree(to_lines(printed));
// Exclude the perimeter connections, which anchoring and extrusion trim differently.
const Polylines anchors = intersection_pl(layer.generate_sparse_infill_polylines_for_anchoring(nullptr, nullptr),
shrink(to_polygons(layer.lslices), scale_(3.)));
REQUIRE_FALSE(anchors.empty());
double max_distance = 0.;
for (const Polyline &path : anchors)
for (const Point &point : path.equally_spaced_points(scale_(0.25)))
max_distance = std::max(max_distance, printed_tree.distance_from_lines<false>(point));
CHECK(unscale<double>(max_distance) <= 0.012);
}
+95
View File
@@ -0,0 +1,95 @@
#include <catch2/catch_all.hpp>
#include "libslic3r/libslic3r.h"
#include "libslic3r/GCode/GCodeProcessor.hpp"
#include "libslic3r/PrintConfig.hpp"
#include "test_utils.hpp"
#include <cstddef>
#include <fstream>
#include <string>
#include <catch2/catch_test_macros.hpp>
#include <catch2/matchers/catch_matchers.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp>
using namespace Slic3r;
using Catch::Matchers::WithinAbs;
namespace {
float processed_belt_tilt(const std::string &gcode)
{
ScopedTemporaryFile temp(".gcode");
{
std::ofstream os(temp.string());
os << gcode;
}
GCodeProcessor proc;
proc.apply_config(FullPrintConfig{});
proc.process_file(temp.string());
return proc.get_result().belt_tilt_angle;
}
constexpr const char *body = "G1 X10 Y10 Z0.2 F3000\nG1 X20 Y10 E1 F1200\n";
} // namespace
TEST_CASE("The config block's belt angle does not mark G-code as belt G-code", "[GCodeProcessor][belt]")
{
// Every printer's config block lists belt_slice_rotation_angle (default 45), belt or not.
const std::string gcode = std::string("; CONFIG_BLOCK_START\n; belt_printer = 0\n; belt_slice_rotation_angle = 45\n; CONFIG_BLOCK_END\n") + body;
CHECK_THAT(processed_belt_tilt(gcode), WithinAbs(0., 1e-6));
}
TEST_CASE("The belt header's angle marks G-code as belt G-code", "[GCodeProcessor][belt]")
{
const std::string gcode = std::string("; belt_slice_rotation_angle = -45.0\n") + body +
"; CONFIG_BLOCK_START\n; belt_printer = 1\n; belt_slice_rotation_angle = -45\n; CONFIG_BLOCK_END\n";
CHECK_THAT(processed_belt_tilt(gcode), WithinAbs(45., 1e-6));
}
TEST_CASE("Non-belt start G-code moves keep the first-layer Z in the processor", "[GCodeProcessor]")
{
// The belt path (GCodeWriter tests: "start-gcode prepare-stage moves keep their real Z")
// stores the real Z of a move inside the start G-code. Every other printer must keep
// the historical behaviour: a prepare-stage move is pinned to the first-layer height
// so the preview does not draw the start sequence's travel. The gate is the belt
// header, so a file without one, whatever its config block says, takes this path.
struct BBLPrinterGuard {
bool prev = GCodeProcessor::s_IsBBLPrinter;
BBLPrinterGuard() { GCodeProcessor::s_IsBBLPrinter = false; }
~BBLPrinterGuard() { GCodeProcessor::s_IsBBLPrinter = prev; }
} bbl_guard;
const std::string gcode =
"G90\n"
"G21\n"
"M83\n"
";TYPE:Custom\n"
"G1 E-1.5 F2100\n"
"G1 X45 Y0.3 Z50 F12000\n" // prepare-stage travel to a high Z
"G1 E1.5 F1800\n"
";TYPE:Outer wall\n"
"G1 X46 Y0.3 Z50 E0.05\n"
"; CONFIG_BLOCK_START\n; belt_printer = 0\n; belt_slice_rotation_angle = 45\n; CONFIG_BLOCK_END\n";
GCodeProcessor processor;
FullPrintConfig config;
config.initial_layer_print_height.value = 0.3;
processor.apply_config(config);
processor.process_buffer(gcode);
const GCodeProcessorResult &result = processor.get_result();
REQUIRE_THAT(result.belt_tilt_angle, WithinAbs(0., 1e-6));
size_t first_extrude = result.moves.size();
for (size_t i = 0; i < result.moves.size(); ++i)
if (result.moves[i].type == EMoveType::Extrude) { first_extrude = i; break; }
REQUIRE(first_extrude < result.moves.size());
REQUIRE(first_extrude > 0);
// The extrusion keeps its real Z; the prepare-stage move before it is pinned to the
// first-layer height.
CHECK_THAT(result.moves[first_extrude].position.z(), WithinAbs(50., 1e-3));
CHECK_THAT(result.moves[first_extrude - 1].position.z(), WithinAbs(0.3, 1e-3));
}
+293 -12
View File
@@ -6,9 +6,12 @@
#include <catch2/matchers/catch_matchers.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp>
#include "libslic3r/libslic3r.h"
#include "libslic3r/BoundingBox.hpp"
#include "libslic3r/Config.hpp"
#include "libslic3r/GCode/GCodeProcessor.hpp"
#include "libslic3r/Model.hpp"
#include "libslic3r/Print.hpp"
#include "libslic3r/TriangleMesh.hpp"
#include "libslic3r/Utils.hpp"
#include "test_helpers.hpp"
@@ -17,6 +20,7 @@
#include <algorithm>
#include <cstddef>
#include <fstream>
#include <initializer_list>
#include "libslic3r/PrintConfig.hpp"
#include "libslic3r/Point.hpp"
#include <sstream>
@@ -98,6 +102,22 @@ TEST_CASE("Reserved keyword detection reports every offending line", "[GCodeProc
namespace {
void process_gcode(const std::string &gcode, GCodeProcessorResult &result)
{
FullPrintConfig config;
config.gcode_flavor.value = gcfMarlinFirmware;
// s_IsBBLPrinter selects the "; FEATURE: " role tags the G-code uses.
const bool was_bbl_printer = GCodeProcessor::s_IsBBLPrinter;
const ScopeGuard restore_bbl_printer([was_bbl_printer] { GCodeProcessor::s_IsBBLPrinter = was_bbl_printer; });
GCodeProcessor::s_IsBBLPrinter = true;
ScopedTemporaryFile temp(".gcode");
std::ofstream(temp.string()) << gcode;
GCodeProcessor processor;
processor.apply_config(config);
processor.process_file(temp.string());
result = std::move(processor.extract_result());
}
// Closed outer-wall squares, each after a fast travel and before an inner-wall move, so the processor
// records seams and inserts actual speed moves. virtual_moves adds a VG1 move after each square.
void process_squares(int squares, GCodeProcessorResult &result, bool virtual_moves = false)
@@ -113,18 +133,42 @@ void process_squares(int squares, GCodeProcessorResult &result, bool virtual_mov
if (virtual_moves)
gcode << "VG1 X20 Y30 F12000\n";
}
FullPrintConfig config;
config.gcode_flavor.value = gcfMarlinFirmware;
// s_IsBBLPrinter selects the "; FEATURE: " role tags this G-code uses.
const bool was_bbl_printer = GCodeProcessor::s_IsBBLPrinter;
const ScopeGuard restore_bbl_printer([was_bbl_printer] { GCodeProcessor::s_IsBBLPrinter = was_bbl_printer; });
GCodeProcessor::s_IsBBLPrinter = true;
ScopedTemporaryFile temp(".gcode");
std::ofstream(temp.string()) << gcode.str();
GCodeProcessor processor;
processor.apply_config(config);
processor.process_file(temp.string());
result = std::move(processor.extract_result());
process_gcode(gcode.str(), result);
}
// Objects A and B on the first layer and A again on the second, with A's brim and support. The skirt and the
// prime tower belong to neither.
void process_two_objects(GCodeProcessorResult &result)
{
std::ostringstream gcode;
gcode << "M83\nG90\n"
<< "; CHANGE_LAYER\n; LAYER_HEIGHT: 0.2\nG1 Z0.2 F12000\n"
<< "; FEATURE: Skirt\nG1 X0 Y100 E5 F3000\n"
<< "; FEATURE: Brim\nG1 X8 Y8 F12000\nG1 X12 Y8 E1 F3000\n"
<< "; FEATURE: Support\nG1 X10 Y20 F12000\nG1 X10 Y30 E1 F3000\n"
<< "; FEATURE: Outer wall\nG1 X10 Y10 F12000\nG1 X20 Y10 E1 F3000\n"
<< "; FEATURE: Outer wall\nG1 X50 Y50 F12000\nG1 X60 Y50 E2 F3000\n"
<< "; FEATURE: Prime tower\nG1 X80 Y80 F12000\nG1 X90 Y80 E1 F3000\n"
<< "; CHANGE_LAYER\n; LAYER_HEIGHT: 0.2\nG1 Z0.4 F12000\n"
<< "; FEATURE: Outer wall\nG1 X10 Y10 F12000\nG1 X20 Y10 E1 F3000\n";
process_gcode(gcode.str(), result);
}
// Bead centers of process_two_objects(), half the 0.2 mm layer below the nozzle.
const Vec3d a_brim(10., 8., 0.1), a_support(10., 25., 0.1), a_wall_0(15., 10., 0.1), a_wall_1(15., 10., 0.3), b_wall(55., 50., 0.1);
Vec3d center_of(const GCodeProcessorResult::ObjectMass::Sum &sum) { return sum.moment / sum.mass; }
// One filament, so each bead weighs as much as the E it was extruded with.
Vec3d weighted_center(std::initializer_list<std::pair<double, Vec3d>> beads)
{
double mass = 0.;
Vec3d moment = Vec3d::Zero();
for (const auto &[e, center] : beads) {
mass += e;
moment += e * center;
}
return moment / mass;
}
bool is_block_move(const GCodeProcessorResult::MoveVertex &move)
@@ -221,3 +265,240 @@ TEST_CASE("Line ends of the exported G-code mark every newline in the file", "[G
INFO("first difference at line " << difference.first - result.lines_ends.begin() + 1);
CHECK(difference.first == result.lines_ends.end());
}
TEST_CASE("Reloaded moves name their lines in G-code a script rewrote in place", "[GCodeProcessor]")
{
Print print;
Model model;
Test::init_print({ Test::cube(20) }, print, model);
GCodeProcessorResult result;
const std::string gcode = Test::gcode(print, &result);
const auto exported_moves = result.moves;
// A script that prepends one comment and, writing in text mode on Windows, turns every LF into CRLF.
const std::string prepended = ";EDITED\r\n";
std::string edited = prepended;
for (const char c : gcode) {
if (c == '\n')
edited += '\r';
edited += c;
}
ScopedTemporaryFile temp(".gcode");
save_string_file(temp.path(), edited);
result.filename = temp.string();
print.reload_gcode_moves(&result);
std::vector<size_t> newline_ends;
for (size_t i = edited.find('\n'); i != std::string::npos; i = edited.find('\n', i + 1))
newline_ends.push_back(i + 1);
CHECK(result.lines_ends == newline_ends);
// Every move that came from a line now names the same line one further down.
REQUIRE(result.moves.size() == exported_moves.size());
const auto difference = std::mismatch(exported_moves.begin(), exported_moves.end(), result.moves.begin(),
[](const auto &exported, const auto &reloaded) {
return reloaded.gcode_id == (exported.gcode_id == 0 ? 0 : exported.gcode_id + 1);
});
INFO("first difference at move " << difference.first - exported_moves.begin());
CHECK(difference.first == exported_moves.end());
}
TEST_CASE("Rewritten G-code that cannot be re-read keeps the moves and hides the G-code window", "[GCodeProcessor]")
{
Print print;
Model model;
Test::init_print({ Test::cube(20) }, print, model);
GCodeProcessorResult result;
const std::string gcode = Test::gcode(print, &result);
const auto exported_moves = result.moves;
// A script that strips the trailing config block, which the G-code reader needs.
const size_t config_block = gcode.find("; CONFIG_BLOCK_START");
REQUIRE(config_block != std::string::npos);
ScopedTemporaryFile temp(".gcode");
save_string_file(temp.path(), gcode.substr(0, config_block));
result.filename = temp.string();
print.reload_gcode_moves(&result);
CHECK(result.lines_ends.empty());
REQUIRE(result.moves.size() == exported_moves.size());
CHECK(result.moves.back().gcode_id == exported_moves.back().gcode_id);
}
TEST_CASE("The plate's center of mass takes every extrusion of G-code without a print behind it", "[GCodeProcessor]")
{
GCodeProcessorResult result;
process_two_objects(result);
CHECK(result.object_masses.empty());
CHECK(result.body_masses.empty());
const GCodeProcessorResult::ObjectMass &plate = result.plate_mass;
REQUIRE(plate.printed_up_to_layer.size() == 2);
CHECK_THAT((center_of(plate.printed_up_to_layer.front()) -
weighted_center({ { 1., a_brim }, { 1., a_support }, { 1., a_wall_0 }, { 2., b_wall } })).norm(),
Catch::Matchers::WithinAbs(0., 1e-5));
CHECK_THAT((center_of(plate.printed_up_to_layer.back()) -
weighted_center({ { 1., a_brim }, { 1., a_support }, { 1., a_wall_0 }, { 2., b_wall }, { 1., a_wall_1 } })).norm(),
Catch::Matchers::WithinAbs(0., 1e-5));
// Each bead weighs its volume at the default density and spreads along its move, (a^2 + ab + b^2) / 3 for one from
// a to b: the brim from x 8 to 12 at y 8, the support at x 10 from y 20 to 30, A's walls from x 10 to 20 at y 10 and
// B's from x 50 to 60 at y 50 with twice the filament, all at z 0.1 but A's second wall at 0.3.
const GCodeProcessorResult::ObjectMass::Sum total = plate.total();
CHECK_THAT(total.mass / total.volume, Catch::Matchers::WithinRel(double(DEFAULT_FILAMENT_DENSITY), 1e-6));
const Vec3d second = total.second / total.mass;
CHECK_THAT(second.x(), Catch::Matchers::WithinRel((304. / 3. + 100. + 2. * 700. / 3. + 2. * 9100. / 3.) / 6., 1e-6));
CHECK_THAT(second.y(), Catch::Matchers::WithinRel((64. + 1900. / 3. + 2. * 100. + 2. * 2500.) / 6., 1e-6));
CHECK_THAT(second.z(), Catch::Matchers::WithinRel((5. * 0.01 + 0.09) / 6., 1e-5));
// The beads' center lines, brim and support included, from the first layer's bottom to the second's top.
CHECK_THAT((plate.box.min - Vec3d(8., 8., 0.)).norm(), Catch::Matchers::WithinAbs(0., 1e-5));
CHECK_THAT((plate.box.max - Vec3d(60., 50., 0.4)).norm(), Catch::Matchers::WithinAbs(0., 1e-5));
}
TEST_CASE("Each sliced cube's center of mass is its center, and the brim lowers the plate's printed one", "[GCodeProcessor]")
{
const bool copies = GENERATE(false, true);
INFO((copies ? "two copies of one cube" : "two cubes"));
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
config.set_deserialize_strict({ { "skirt_loops", 0 }, { "brim_type", "outer_only" }, { "brim_width", 5 }, { "combine_brims", 0 } });
std::vector<TriangleMesh> cubes{ Test::cube(20) };
if (!copies)
cubes.emplace_back(Test::cube(20));
Print print;
Model model;
Test::init_print(std::move(cubes), print, model, config, nullptr, true, copies ? 2 : 1);
GCodeProcessorResult result;
Test::gcode(print, &result);
CHECK(result.body_masses.empty());
REQUIRE(result.object_masses.size() == 2);
for (const ModelObject *object : model.objects)
for (size_t instance = 0; instance < object->instances.size(); ++instance) {
const Vec3d center = object->instance_bounding_box(instance).center();
const auto mass = std::min_element(result.object_masses.begin(), result.object_masses.end(), [&center](const auto &l, const auto &r) {
return (center_of(l.total()) - center).squaredNorm() < (center_of(r.total()) - center).squaredNorm();
});
// Off the center only by the infill's alignment and the top and bottom shells.
const Vec3d part = center_of(mass->total());
CHECK_THAT(part.x(), Catch::Matchers::WithinAbs(center.x(), 0.5));
CHECK_THAT(part.y(), Catch::Matchers::WithinAbs(center.y(), 0.5));
CHECK_THAT(part.z(), Catch::Matchers::WithinAbs(center.z(), 1.));
// The outer walls' center lines run half a line inside the cube's sides, of copies touching each other too.
const BoundingBoxf3 box = object->instance_bounding_box(instance);
for (int axis = 0; axis < 3; ++axis) {
CHECK_THAT(mass->box.min[axis], Catch::Matchers::WithinAbs(box.min[axis], 0.3));
CHECK_THAT(mass->box.max[axis], Catch::Matchers::WithinAbs(box.max[axis], 0.3));
}
}
GCodeProcessorResult::ObjectMass::Sum objects;
for (const GCodeProcessorResult::ObjectMass &object : result.object_masses)
objects.add(object.total());
const GCodeProcessorResult::ObjectMass::Sum plate = result.plate_mass.total();
CHECK(plate.mass > objects.mass);
CHECK(center_of(plate).z() < center_of(objects).z());
}
TEST_CASE("Each cube's raft is its support, centered below it", "[GCodeProcessor]")
{
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
config.set_deserialize_strict({ { "skirt_loops", 0 }, { "brim_type", "no_brim" }, { "raft_layers", 3 } });
Print print;
Model model;
Test::init_print({ Test::cube(20), Test::cube(20) }, print, model, config);
GCodeProcessorResult result;
Test::gcode(print, &result);
REQUIRE(result.support_masses.size() == 2);
for (size_t i = 0; i < 2; ++i) {
const GCodeProcessorResult::ObjectMass::Sum support = result.support_masses[i].total();
const Vec3d object = center_of(result.object_masses[i].total());
REQUIRE(support.mass > 0.);
CHECK_THAT(center_of(support).x(), Catch::Matchers::WithinAbs(object.x(), 1.));
CHECK_THAT(center_of(support).y(), Catch::Matchers::WithinAbs(object.y(), 1.));
CHECK(center_of(support).z() < 1.);
}
}
TEST_CASE("A spiral vase cube counts all its extrusions, rising through each layer", "[GCodeProcessor]")
{
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
config.set_deserialize_strict({ { "skirt_loops", 0 }, { "brim_type", "no_brim" }, { "spiral_mode", 1 }, { "wall_loops", 1 },
{ "top_shell_layers", 0 }, { "sparse_infill_density", 0 } });
Print print;
Model model;
Test::init_print({ Test::cube(20) }, print, model, config);
GCodeProcessorResult result;
Test::gcode(print, &result);
REQUIRE(result.object_masses.size() == 1);
CHECK_THAT(result.object_masses.front().total().mass, Catch::Matchers::WithinRel(result.plate_mass.total().mass, 1e-6));
}
TEST_CASE("Each separate part of an assembly gets its center of mass, overlapping parts one", "[GCodeProcessor]")
{
const bool overlapping = GENERATE(false, true);
// Separated infills finds the bodies first, which the G-code export then takes.
const bool separated = GENERATE(false, true);
INFO((overlapping ? "overlapping parts" : "separate parts") << (separated ? ", separated infills" : ""));
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
config.set_deserialize_strict({ { "skirt_loops", 0 }, { "brim_type", "no_brim" }, { "separated_infills", separated ? 1 : 0 } });
TriangleMesh first = make_cube(20, 20, 20);
TriangleMesh second = make_cube(20, 20, 20);
first.translate(50, 50, 0);
second.translate(overlapping ? 60 : 90, 50, 0);
Print print;
Model model;
Test::init_print({ first }, print, model, config, nullptr, false);
model.objects.front()->add_volume(std::move(second), ModelVolumeType::MODEL_PART, false);
print.apply(model, config);
GCodeProcessorResult result;
Test::gcode(print, &result);
REQUIRE(result.object_masses.size() == 1);
CHECK(result.object_masses.front().assembly);
// One body is the object itself.
if (overlapping) {
CHECK(result.body_masses.empty());
return;
}
REQUIRE(result.body_masses.size() == 2);
CHECK(print.objects().front()->separated_body_bboxes().size() == (separated ? 2 : 0));
const ModelObject &object = *model.objects.front();
for (const ModelVolume *volume : object.volumes) {
const Vec3d center = volume->mesh().transformed_bounding_box(object.instances.front()->get_matrix() * volume->get_matrix()).center();
const auto body = std::min_element(result.body_masses.begin(), result.body_masses.end(), [&center](const auto &l, const auto &r) {
return (center_of(l.total()) - center).squaredNorm() < (center_of(r.total()) - center).squaredNorm();
});
const Vec3d part = center_of(body->total());
CHECK_THAT(part.x(), Catch::Matchers::WithinAbs(center.x(), 0.5));
CHECK_THAT(part.y(), Catch::Matchers::WithinAbs(center.y(), 0.5));
CHECK_THAT(part.z(), Catch::Matchers::WithinAbs(center.z(), 1.));
}
}
TEST_CASE("Each extrusion weighs its filament's density", "[GCodeProcessor]")
{
// Two like cubes, the second's filament three times as dense.
DynamicPrintConfig config = Test::multifilament_config(2, { { "filament_density", "1,3" }, { "skirt_loops", 0 }, { "brim_type", "no_brim" } });
const std::vector<std::vector<ConfigBase::SetDeserializeItem>> overrides{ { { "extruder", 1 } }, { { "extruder", 2 } } };
Print print;
Model model;
Test::init_print({ Test::cube(20), Test::cube(20) }, print, model, config, &overrides);
GCodeProcessorResult result;
Test::gcode(print, &result);
REQUIRE(result.object_masses.size() == 2);
std::vector<const GCodeProcessorResult::ObjectMass *> masses;
for (const ModelObject *object : model.objects) {
const Vec3d center = object->instance_bounding_box(0).center();
masses.emplace_back(&*std::min_element(result.object_masses.begin(), result.object_masses.end(), [&center](const auto &l, const auto &r) {
return (center_of(l.total()) - center).squaredNorm() < (center_of(r.total()) - center).squaredNorm();
}));
}
CHECK_THAT(masses[1]->total().mass / masses[0]->total().mass, Catch::Matchers::WithinRel(3., 0.02));
// The plate's center lies three quarters of the way to the dense cube.
const Vec3d plate = center_of(result.plate_mass.total());
const Vec3d light = center_of(masses[0]->total());
const Vec3d dense = center_of(masses[1]->total());
CHECK_THAT((plate - light).dot(dense - light) / (dense - light).squaredNorm(), Catch::Matchers::WithinAbs(0.75, 0.01));
}
+740
View File
@@ -8,6 +8,7 @@
#include "libslic3r/Point.hpp"
#include "libslic3r/Config.hpp"
#include "libslic3r/Extruder.hpp"
#include "libslic3r/Geometry.hpp"
#include "libslic3r/libslic3r.h"
#include <map>
#include <memory>
@@ -36,11 +37,52 @@
#include <boost/filesystem.hpp>
#include "test_helpers.hpp"
#include <cmath>
#include "libslic3r/GCode/GCodeProcessor.hpp"
#include <algorithm>
#include <limits>
#include "libslic3r/GCode/BeltKinematics.hpp"
#include "libslic3r/BeltTransform.hpp"
#include "libslic3r/GCodeReader.hpp"
#include "libslic3r/PrintConfig.hpp"
#include "libslic3r/Arrange.hpp"
using namespace Slic3r;
using namespace Slic3r::Test;
TEST_CASE("Belt machine coordinates retain a non-45-degree slicing angle", "[GCodeWriter][belt]")
{
PrintConfig config;
config.belt_printer.value = true;
config.belt_slice_rotation.value = BeltRotationAxis::X;
config.belt_slice_rotation_angle.value = 30.;
config.gcode_remap_x.value = RemapAxis::PosX;
config.gcode_remap_y.value = RemapAxis::PosZ;
config.gcode_remap_z.value = RemapAxis::PosY;
GCodeWriter writer;
install_belt_kinematics(writer, config);
writer.set_axis_remap(int(config.gcode_remap_x.value),
int(config.gcode_remap_y.value),
int(config.gcode_remap_z.value));
// Start with a point in the unrotated model frame, then feed the writer the
// same rotated coordinate produced by the pre-slice mesh transform. The
// back-transform must recover the model point before the axis swap and
// machine-frame shear/scale are applied.
const Vec3d model(4., 10., 3.);
Transform3d forward = BeltTransformPipeline::build_forward_transform(config);
const Vec3d machine = writer.kinematics().to_machine(forward * model);
// The conventional X-tilt remap produces (x, z, y). At 30 degrees the
// gantry coordinate is z/sin(30) and belt travel is y + z*cot(30).
// The complementary tan/inv-cos formulas accidentally used by the unified
// transform are indistinguishable at 45 degrees, but fail this case.
REQUIRE_THAT(machine.x(), Catch::Matchers::WithinAbs(4., 1e-9));
REQUIRE_THAT(machine.y(), Catch::Matchers::WithinAbs(3. / std::sin(Geometry::deg2rad(30.)), 1e-9));
REQUIRE_THAT(machine.z(), Catch::Matchers::WithinAbs(10. + 3. / std::tan(Geometry::deg2rad(30.)), 1e-9));
}
// Arrange on a 500x500 bed, which keeps coordinates small while still covering large printers.
static void arrange_objects_on_test_bed(Model &model, const DynamicPrintConfig &config)
{
@@ -939,6 +981,270 @@ SCENARIO("Shipped dual-nozzle change_filament_gcode resolves during a real slice
}
}
SCENARIO("set_pressure_advance emits nothing for negative PA", "[GCodeWriter][PressureAdvance]") {
GIVEN("A default GCodeWriter") {
GCodeWriter writer;
THEN("Negative PA returns empty regardless of firmware flavor") {
writer.config.gcode_flavor.value = gcfKlipper;
REQUIRE(writer.set_pressure_advance(-1.0).empty());
writer.config.gcode_flavor.value = gcfRepRapFirmware;
REQUIRE(writer.set_pressure_advance(-0.001).empty());
writer.config.gcode_flavor.value = gcfMarlinFirmware;
REQUIRE(writer.set_pressure_advance(-100.0).empty());
}
}
}
SCENARIO("set_pressure_advance emits Klipper form with optional EXTRUDER=extruder<N>", "[GCodeWriter][PressureAdvance]") {
GIVEN("A Klipper-flavored GCodeWriter") {
GCodeWriter writer;
writer.config.gcode_flavor.value = gcfKlipper;
WHEN("set_pressure_advance is called without a tool index") {
std::string out = writer.set_pressure_advance(0.05);
THEN("Output contains SET_PRESSURE_ADVANCE ADVANCE=0.05 with no EXTRUDER qualifier") {
REQUIRE_THAT(out, Catch::Matchers::ContainsSubstring("SET_PRESSURE_ADVANCE"));
REQUIRE_THAT(out, Catch::Matchers::ContainsSubstring("ADVANCE=0.05; Override"));
REQUIRE_THAT(out, !Catch::Matchers::ContainsSubstring("EXTRUDER="));
}
}
WHEN("set_pressure_advance is called with tool=0") {
std::string out = writer.set_pressure_advance(0.04, 0);
THEN("Output targets EXTRUDER=extruder (no trailing index)") {
REQUIRE_THAT(out, Catch::Matchers::ContainsSubstring("EXTRUDER=extruder;"));
REQUIRE_THAT(out, !Catch::Matchers::ContainsSubstring("EXTRUDER=extruder0"));
}
}
WHEN("set_pressure_advance is called with tool=3") {
std::string out = writer.set_pressure_advance(0.06, 3);
THEN("Output targets EXTRUDER=extruder3") {
REQUIRE_THAT(out, Catch::Matchers::ContainsSubstring("EXTRUDER=extruder3"));
}
}
}
}
SCENARIO("set_pressure_advance emits RepRapFirmware form with D<N>", "[GCodeWriter][PressureAdvance]") {
GIVEN("An RRF-flavored GCodeWriter") {
GCodeWriter writer;
writer.config.gcode_flavor.value = gcfRepRapFirmware;
WHEN("set_pressure_advance is called without a tool index") {
std::string out = writer.set_pressure_advance(0.07);
THEN("Output keeps the historical D0 rather than depending on the selected tool") {
REQUIRE_THAT(out, Catch::Matchers::ContainsSubstring("M572 D0 S0.07"));
}
}
WHEN("set_pressure_advance is called with tool=0") {
std::string out = writer.set_pressure_advance(0.08, 0);
THEN("Output contains D0, same as the tool-less form") {
REQUIRE_THAT(out, Catch::Matchers::ContainsSubstring("M572 D0 S0.08"));
}
}
WHEN("set_pressure_advance is called with tool=2") {
std::string out = writer.set_pressure_advance(0.09, 2);
THEN("Output contains D2") {
REQUIRE_THAT(out, Catch::Matchers::ContainsSubstring("M572 D2 S0.09"));
}
}
}
}
SCENARIO("set_pressure_advance emits Marlin 2.x form with optional T<N>", "[GCodeWriter][PressureAdvance]") {
GIVEN("A Marlin 2-flavored GCodeWriter") {
GCodeWriter writer;
writer.config.gcode_flavor.value = gcfMarlinFirmware;
WHEN("set_pressure_advance is called without a tool index") {
std::string out = writer.set_pressure_advance(0.10);
THEN("Output is bare M900 K... with no T qualifier") {
REQUIRE_THAT(out, Catch::Matchers::ContainsSubstring("M900 K0.1; Override"));
REQUIRE_THAT(out, !Catch::Matchers::ContainsSubstring(" T"));
}
}
WHEN("set_pressure_advance is called with tool=0") {
std::string out = writer.set_pressure_advance(0.11, 0);
THEN("Output contains T0") {
REQUIRE_THAT(out, Catch::Matchers::ContainsSubstring("M900 K0.11 T0"));
}
}
WHEN("set_pressure_advance is called with tool=1") {
std::string out = writer.set_pressure_advance(0.12, 1);
THEN("Output contains T1") {
REQUIRE_THAT(out, Catch::Matchers::ContainsSubstring("M900 K0.12 T1"));
}
}
}
}
SCENARIO("set_pressure_advance emits Marlin Legacy form without tool qualifier even when tool index is supplied",
"[GCodeWriter][PressureAdvance]") {
GIVEN("A Marlin Legacy-flavored GCodeWriter") {
GCodeWriter writer;
writer.config.gcode_flavor.value = gcfMarlinLegacy;
WHEN("set_pressure_advance is called without a tool index") {
std::string out = writer.set_pressure_advance(0.05);
THEN("Output is bare M900 K...") {
REQUIRE_THAT(out, Catch::Matchers::ContainsSubstring("M900 K0.05"));
REQUIRE_THAT(out, !Catch::Matchers::ContainsSubstring(" T"));
}
}
WHEN("set_pressure_advance is called with tool=2 (a hypothetical IMEX secondary)") {
std::string out = writer.set_pressure_advance(0.06, 2);
THEN("Output is still bare M900 — Marlin Legacy has no per-tool LA") {
REQUIRE_THAT(out, Catch::Matchers::ContainsSubstring("M900 K0.06"));
REQUIRE_THAT(out, !Catch::Matchers::ContainsSubstring(" T2"));
}
}
}
}
SCENARIO("set_temperature per-flavor command routing", "[GCodeWriter][Temperature]") {
GIVEN("temperature=210, no tool index, no wait") {
WHEN("flavor is Marlin 2") {
std::string out = GCodeWriter::set_temperature(210, gcfMarlinFirmware, false, -1, std::string());
THEN("output is M104 S210 with no tool qualifier") {
REQUIRE_THAT(out, Catch::Matchers::ContainsSubstring("M104 S210"));
REQUIRE_THAT(out, !Catch::Matchers::ContainsSubstring(" T"));
REQUIRE_THAT(out, !Catch::Matchers::ContainsSubstring("M109"));
}
}
WHEN("flavor is RepRapFirmware") {
std::string out = GCodeWriter::set_temperature(210, gcfRepRapFirmware, false, -1, std::string());
THEN("output is G10 S210 (M104 is deprecated on RRF)") {
REQUIRE_THAT(out, Catch::Matchers::ContainsSubstring("G10 S210"));
REQUIRE_THAT(out, !Catch::Matchers::ContainsSubstring("M104"));
}
}
WHEN("flavor is Mach3 or Machinekit") {
std::string mach3 = GCodeWriter::set_temperature(210, gcfMach3, false, -1, std::string());
std::string machinekit = GCodeWriter::set_temperature(210, gcfMachinekit, false, -1, std::string());
THEN("output uses P-prefix for the value instead of S") {
REQUIRE_THAT(mach3, Catch::Matchers::ContainsSubstring("M104 P210"));
REQUIRE_THAT(machinekit, Catch::Matchers::ContainsSubstring("M104 P210"));
REQUIRE_THAT(mach3, !Catch::Matchers::ContainsSubstring("S210"));
}
}
}
}
SCENARIO("set_temperature wait=true handling per firmware", "[GCodeWriter][Temperature]") {
WHEN("flavor is Marlin 2 with wait") {
std::string out = GCodeWriter::set_temperature(210, gcfMarlinFirmware, true, -1, std::string());
THEN("output is M109 S210 (blocking wait)") {
REQUIRE_THAT(out, Catch::Matchers::ContainsSubstring("M109 S210"));
REQUIRE_THAT(out, !Catch::Matchers::ContainsSubstring("M104"));
}
}
WHEN("flavor is MakerWare or Sailfish with wait") {
std::string mw = GCodeWriter::set_temperature(210, gcfMakerWare, true, -1, std::string());
std::string sf = GCodeWriter::set_temperature(210, gcfSailfish, true, -1, std::string());
THEN("output is empty — these flavors don't support blocking waits") {
REQUIRE(mw.empty());
REQUIRE(sf.empty());
}
}
WHEN("flavor is Teacup with wait") {
std::string out = GCodeWriter::set_temperature(210, gcfTeacup, true, -1, std::string());
THEN("output emits M104 + a separate M116 poll (Teacup doesn't support M109)") {
REQUIRE_THAT(out, Catch::Matchers::ContainsSubstring("M104 S210"));
REQUIRE_THAT(out, Catch::Matchers::ContainsSubstring("M116"));
REQUIRE_THAT(out, !Catch::Matchers::ContainsSubstring("M109"));
}
}
WHEN("flavor is RepRapFirmware with wait") {
std::string out = GCodeWriter::set_temperature(210, gcfRepRapFirmware, true, -1, std::string());
THEN("output emits G10 + M116 (same poll pattern as Teacup)") {
REQUIRE_THAT(out, Catch::Matchers::ContainsSubstring("G10 S210"));
REQUIRE_THAT(out, Catch::Matchers::ContainsSubstring("M116"));
}
}
}
SCENARIO("set_temperature per-tool qualifier routing for IMEX secondary carriages",
"[GCodeWriter][Temperature]") {
// IMEX secondary tools never go through a tool-change, so layer-change temperature
// for them is emitted via the tool-qualified static set_temperature overload.
GIVEN("temperature=220, tool=2, no wait") {
WHEN("flavor is Marlin 2") {
std::string out = GCodeWriter::set_temperature(220, gcfMarlinFirmware, false, 2, std::string());
THEN("output contains T2 qualifier") {
REQUIRE_THAT(out, Catch::Matchers::ContainsSubstring("M104 S220 T2"));
}
}
WHEN("flavor is RepRapFirmware") {
std::string out = GCodeWriter::set_temperature(220, gcfRepRapFirmware, false, 2, std::string());
THEN("output uses P-prefix for tool (RRF convention), not T") {
REQUIRE_THAT(out, Catch::Matchers::ContainsSubstring("G10 S220 P2"));
REQUIRE_THAT(out, !Catch::Matchers::ContainsSubstring(" T2"));
}
}
WHEN("flavor is Klipper") {
std::string out = GCodeWriter::set_temperature(220, gcfKlipper, false, 1, std::string());
THEN("output contains T1 qualifier (Klipper layer-change temperature uses T)") {
REQUIRE_THAT(out, Catch::Matchers::ContainsSubstring("M104 S220 T1"));
}
}
}
}
SCENARIO("set_temperature instance overload forces tool=-1 on single-extruder writers",
"[GCodeWriter][Temperature]") {
// Guards against spuriously emitting `T0` on printers that only have one extruder.
// The instance overload discards the tool argument when !multiple_extruders.
GIVEN("A default GCodeWriter (multiple_extruders=false)") {
GCodeWriter writer;
writer.config.gcode_flavor.value = gcfMarlinFirmware;
WHEN("set_temperature is called with tool=2") {
std::string out = writer.set_temperature(210, false, 2);
THEN("output has no T qualifier despite the caller passing tool=2") {
REQUIRE_THAT(out, Catch::Matchers::ContainsSubstring("M104 S210"));
REQUIRE_THAT(out, !Catch::Matchers::ContainsSubstring(" T"));
}
}
}
GIVEN("A GCodeWriter with multiple_extruders=true (not SEMM)") {
GCodeWriter writer;
writer.config.gcode_flavor.value = gcfMarlinFirmware;
writer.multiple_extruders = true;
WHEN("set_temperature is called with tool=2") {
std::string out = writer.set_temperature(210, false, 2);
THEN("tool argument passes through — output contains T2") {
REQUIRE_THAT(out, Catch::Matchers::ContainsSubstring("M104 S210 T2"));
}
}
}
}
SCENARIO("set_pressure_advance emits BBL M900 L1000 M10 regardless of tool index",
"[GCodeWriter][PressureAdvance]") {
GIVEN("A BBL-flagged GCodeWriter (the flag overrides firmware flavor routing)") {
GCodeWriter writer;
writer.set_is_bbl_machine(true);
// Flavor intentionally set to something other than the BBL branch to prove the flag wins.
writer.config.gcode_flavor.value = gcfMarlinFirmware;
WHEN("set_pressure_advance is called without a tool index") {
std::string out = writer.set_pressure_advance(0.05);
THEN("Output is the BBL-specific M900 Kx L1000 M10 form") {
REQUIRE_THAT(out, Catch::Matchers::ContainsSubstring("M900 K0.05 L1000 M10"));
}
}
WHEN("set_pressure_advance is called with a tool index") {
std::string out = writer.set_pressure_advance(0.05, 2);
THEN("BBL output is unchanged — no per-tool qualifier is emitted on BBL printers") {
REQUIRE_THAT(out, Catch::Matchers::ContainsSubstring("M900 K0.05 L1000 M10"));
REQUIRE_THAT(out, !Catch::Matchers::ContainsSubstring(" T2"));
REQUIRE_THAT(out, !Catch::Matchers::ContainsSubstring("EXTRUDER="));
}
}
}
}
TEST_CASE("Custom G-code motion limits are restored before generated moves", "[GCodeWriter]")
{
const std::string gcode = Slic3r::Test::slice({ cube(20) }, {
@@ -966,6 +1272,440 @@ TEST_CASE("Custom G-code motion limits are restored before generated moves", "[G
REQUIRE(gcode.find("M205 X8 Y8 ; adjust jerk", custom_gcode_pos) != std::string::npos);
}
// Regression test for the belt-printer "illegal gantry move at print start" bug.
//
// On a belt printer the layer-change z-hop is deferred (lazy_lift) and consumed
// by the first travel_to_xyz, whose NormalLift branch lifts in place via
// _travel_to_z(). On a normal printer _travel_to_z emits a Z-only move, but in
// belt mode Z is coupled to Y/X, so _travel_to_z re-emits the current m_pos
// through the belt shear. At print start (and after custom gcode)
// is_current_position_clear() is false and m_pos.xy is still the uninitialised
// origin (0,0), which shears into machine (X=bed_max, Y=layer_z) — a move far up
// the gantry, e.g. "G1 X95 Y168.19 Z237.857". The fix guards that lift on
// is_current_position_clear(), mirroring the SlopeLift branch.
SCENARIO("Belt: the first travel does not lift through the uninitialised origin", "[GCodeWriter][belt]")
{
GIVEN("A fresh belt-kinematics GCodeWriter configured for an X-tilt 45 degree belt") {
// Machine-frame + slicer->world back-transform config (X tilt, 45 deg).
PrintConfig belt_config;
belt_config.belt_printer.value = true;
belt_config.belt_slice_rotation.value = BeltRotationAxis::X;
belt_config.belt_slice_rotation_angle.value = 45.0;
belt_config.belt_frame_tilt_decouple.value = false;
belt_config.belt_frame_tilt_angle.value = 45.0;
GCodeWriter writer;
install_belt_kinematics(writer, belt_config);
std::vector<unsigned int> extruder_ids { 0 };
writer.set_extruders(extruder_ids);
writer.set_extruder(0);
// travel_speed became per-extruder (ConfigOptionFloatsNullable) upstream.
writer.config.travel_speed.values = { 100.0 };
writer.config.z_hop.values = { 0.4 };
writer.config.retract_lift_above.values = { 0.0 };
writer.config.retract_lift_below.values = { 0.0 };
// A fresh writer has not established its planar position yet — this is the
// precondition that made the origin leak into the first move.
REQUIRE_FALSE(writer.is_current_position_clear());
WHEN("a layer-change z-hop is pending and we travel to the first object point") {
// Defer a z-hop, exactly as a retract on layer change leaves it.
writer.lazy_lift(LiftType::NormalLift);
// First object point in slicing coordinates: a near-belt point (y ~= -z)
// so its transformed gantry Y is small (~1mm). The bogus origin lift, in
// contrast, would shear to machine Y ~= nominal_z.
const double nominal_z = 100.0;
std::string gcode = writer.travel_to_xyz(Vec3d(10.0, -(nominal_z - 1.0), nominal_z));
THEN("no emitted move flies up the gantry; machine Y stays near the part") {
double max_y = std::numeric_limits<double>::lowest();
GCodeReader reader;
reader.parse_buffer(gcode, [&max_y](GCodeReader &, const GCodeReader::GCodeLine &line) {
if (line.cmd_is("G1") && line.has(Y))
max_y = std::max(max_y, double(line.y()));
});
// The destination shears to machine Y ~= 1mm. The old origin-lift bug
// produced a separate move at machine Y ~= nominal_z (100mm), so any
// Y well above the part means the origin leaked into a move.
REQUIRE(max_y > 0.0); // the destination move was emitted and parsed
REQUIRE(max_y < 10.0); // ... and nothing flew up the gantry
}
}
}
}
// Regression test for the belt-printer "phantom extrusion line from Y=0" bug.
//
// GCodeProcessor::store_move_vertex pins a move's stored Z to the first-layer
// height while m_processing_start_custom_gcode is set (the start G-code "prepare"
// stage), because on a normal printer the toolhead Z there is not yet a real print
// height. On a belt printer that override is wrong: Z is written explicitly and the
// designed-view back-transform couples machine Z into the rendered model Y (the
// belt tilt mixes the height and belt-feed axes). Overriding it back-transforms the
// last prepare-stage move (the unretract right before the first extrusion) to
// model Y ~= 0, and libvgcode then draws a phantom extrusion segment from Y ~= 0 to
// the first real toolpath — rendered in the first extrusion role's color. The fix
// keeps the real Z for belt printers (gated on belt_tilt_angle). Here we assert the
// prepare-stage move keeps its real Z so it can no longer leak to Y ~= 0.
SCENARIO("Belt: start-gcode prepare-stage moves keep their real Z", "[GCode][belt]")
{
// Belt printers are non-Bambu, so the G-code uses the "compatible" reserved
// tags ("TYPE:" for the extrusion role). The processor selects the tag table
// from the static s_IsBBLPrinter flag, so mirror the belt-printer setting here
// (saved/restored so test ordering stays unaffected).
struct BBLPrinterGuard {
bool prev = GCodeProcessor::s_IsBBLPrinter;
BBLPrinterGuard() { GCodeProcessor::s_IsBBLPrinter = false; }
~BBLPrinterGuard() { GCodeProcessor::s_IsBBLPrinter = prev; }
} bbl_guard;
GIVEN("A belt G-code whose start sequence travels to a high machine Z before the first extrusion") {
// The leading "; belt_slice_rotation_angle = 45" header sets belt_tilt_angle
// (parsed before the body), enabling the belt code path. ;TYPE:Custom before
// any G1 turns on the prepare stage; ;TYPE:Outer wall turns it off, exactly
// as a sliced belt print is laid out.
const std::string gcode =
"; belt_slice_rotation_angle = 45\n"
"G90\n"
"G21\n"
"M83\n"
";TYPE:Custom\n"
"G1 E-1.5 F2100\n" // retract at the (0,0,0) origin
"G1 X45 Y0.3 Z50 F12000\n" // travel to the approach point (prepare stage)
"G1 E1.5 F1800\n" // unretract in place (prepare stage)
";TYPE:Outer wall\n"
"G1 X46 Y0.3 Z50 E0.05\n"; // first extrusion, same Z as the approach
GCodeProcessor processor;
processor.process_buffer(gcode);
const GCodeProcessorResult& result = processor.get_result();
THEN("the belt code path is active") {
REQUIRE_THAT(result.belt_tilt_angle, Catch::Matchers::WithinAbs(45.0, 1e-4));
}
WHEN("locating the first extrusion and the move that precedes it") {
size_t first_extrude = result.moves.size();
for (size_t i = 0; i < result.moves.size(); ++i)
if (result.moves[i].type == EMoveType::Extrude) { first_extrude = i; break; }
THEN("an extrusion and a preceding move exist") {
REQUIRE(first_extrude < result.moves.size());
REQUIRE(first_extrude > 0);
}
THEN("the preceding prepare-stage move shares the extrusion's real Z (no leak to Y=0)") {
const float extrude_z = result.moves[first_extrude].position.z();
const float prev_z = result.moves[first_extrude - 1].position.z();
// The first extrusion is at the real Z=50; before the fix the
// prepare-stage move's Z was pinned to the first-layer height
// (0 here) instead, which back-transforms to model Y ~= 0 and
// produces the phantom extrusion segment.
REQUIRE_THAT(extrude_z, Catch::Matchers::WithinAbs(50.0, 1e-3));
REQUIRE_THAT(prev_z, Catch::Matchers::WithinAbs(50.0, 1e-3));
}
}
}
}
// ---------------------------------------------------------------------------
// Regression tests for the two latent bugs the MachineKinematics refactor
// preserved deliberately and the follow-up commit fixed.
// ---------------------------------------------------------------------------
// Bug 1. _travel_to_z() emits full XYZ whenever the mapping must emit every
// axis, and it builds that point from m_pos. While the position is unknown,
// m_pos.xy is the uninitialised origin, which a reverse remap maps to the far
// corner of the bed. Belt kinematics guarded this; a Cartesian writer with an
// axis remap did not, and would command a rapid across the whole bed.
static void configure_lift_writer(GCodeWriter &writer)
{
std::vector<unsigned int> extruder_ids { 0 };
writer.set_extruders(extruder_ids);
writer.set_extruder(0);
writer.config.travel_speed.values = { 100.0 };
writer.config.travel_speed_z.values = { 100.0 };
writer.config.z_hop.values = { 0.4 };
writer.config.retract_lift_above.values = { 0.0 };
writer.config.retract_lift_below.values = { 0.0 };
}
// Largest X word in a chunk of emitted G-code, or lowest() if none.
static double max_emitted_x(const std::string &gcode)
{
double max_x = std::numeric_limits<double>::lowest();
GCodeReader reader;
reader.parse_buffer(gcode, [&max_x](GCodeReader &, const GCodeReader::GCodeLine &line) {
if (line.cmd_is("G1") && line.has(X))
max_x = std::max(max_x, double(line.x()));
});
return max_x;
}
static size_t count_g1(const std::string &gcode)
{
size_t n = 0;
GCodeReader reader;
reader.parse_buffer(gcode, [&n](GCodeReader &, const GCodeReader::GCodeLine &line) {
if (line.cmd_is("G1")) ++n;
});
return n;
}
SCENARIO("Axis remap: no lift is commanded through the uninitialised origin", "[GCodeWriter][remap]")
{
// Reverse X: machine X = build_vol_max.x - logical X, so the uninitialised
// origin maps to the far edge of the bed and is unmistakable in the output.
const double bed_x = 250.0;
GIVEN("a writer with a reverse-X remap and an unknown current position") {
GCodeWriter writer;
configure_lift_writer(writer);
writer.set_axis_remap(6, 1, 2);
writer.set_build_volume_max(Vec3d(bed_x, 250.0, 250.0));
REQUIRE(writer.kinematics().must_emit_all_axes());
REQUIRE_FALSE(writer.is_current_position_clear());
WHEN("a z-hop is pending and we travel to the first point") {
writer.lazy_lift(LiftType::NormalLift);
const std::string gcode = writer.travel_to_xyz(Vec3d(10.0, 10.0, 5.0));
THEN("nothing is commanded at the image of the origin") {
// The destination maps to machine X = 250 - 10 = 240; the bogus
// origin lift would have mapped to machine X = 250.
REQUIRE(max_emitted_x(gcode) < bed_x - 1.0);
}
THEN("only the destination move is emitted") {
REQUIRE(count_g1(gcode) == 1);
}
}
}
GIVEN("the same writer once its position is known") {
GCodeWriter writer;
configure_lift_writer(writer);
writer.set_axis_remap(6, 1, 2);
writer.set_build_volume_max(Vec3d(bed_x, 250.0, 250.0));
writer.travel_to_xyz(Vec3d(20.0, 20.0, 5.0));
REQUIRE(writer.is_current_position_clear());
WHEN("a z-hop is pending and we travel again") {
writer.lazy_lift(LiftType::NormalLift);
const std::string gcode = writer.travel_to_xyz(Vec3d(30.0, 30.0, 5.0));
THEN("the separate lift move is still emitted") {
// Suppression must be pinned to the unknown position, not to the
// presence of a remap.
REQUIRE(count_g1(gcode) == 2);
}
}
}
GIVEN("an identity-mapping writer with an unknown position") {
GCodeWriter writer;
configure_lift_writer(writer);
REQUIRE_FALSE(writer.kinematics().must_emit_all_axes());
REQUIRE_FALSE(writer.is_current_position_clear());
WHEN("a z-hop is pending and we travel to the first point") {
writer.lazy_lift(LiftType::NormalLift);
const std::string gcode = writer.travel_to_xyz(Vec3d(10.0, 10.0, 5.0));
THEN("behaviour is unchanged: the lift is still emitted") {
// Three moves, not two: with no remap and an unknown position the
// destination is emitted as a separate XY move followed by its own
// Z move, on top of the lift. That split is the pre-existing
// identity-mapping path and must not change.
REQUIRE(count_g1(gcode) == 3);
}
}
}
}
SCENARIO("Axis remap: eager_lift does not lift, or record a lift, at an unknown position",
"[GCodeWriter][remap]")
{
GIVEN("a writer with a reverse-X remap and an unknown current position") {
GCodeWriter writer;
configure_lift_writer(writer);
writer.set_axis_remap(6, 1, 2);
writer.set_build_volume_max(Vec3d(250.0, 250.0, 250.0));
REQUIRE_FALSE(writer.is_current_position_clear());
WHEN("an eager lift is requested") {
const std::string lift = writer.eager_lift(LiftType::NormalLift);
THEN("no move is emitted") {
REQUIRE(lift.empty());
}
THEN("no lift is recorded, so unlift does not descend from it") {
// If m_lifted had been set while nothing was commanded, unlift()
// would emit a descent from a height the machine never reached.
REQUIRE(writer.unlift().empty());
}
}
}
GIVEN("an identity-mapping writer with an unknown position") {
GCodeWriter writer;
configure_lift_writer(writer);
WHEN("an eager lift is requested") {
const std::string lift = writer.eager_lift(LiftType::NormalLift);
THEN("behaviour is unchanged: the lift is emitted and can be undone") {
REQUIRE_FALSE(lift.empty());
REQUIRE_FALSE(writer.unlift().empty());
}
}
}
}
// Bug 2. extrude_arc_to_xy() emits G2/G3 with logical X/Y and I/J and never
// consulted the mapping. An arc is only representable when logical X and Y reach
// the machine unchanged -- which is a narrower question than "is the remap the
// identity", because a mapping that only touches Z leaves every emitted word alone.
SCENARIO("Arc support is decided by whether the mapping leaves X and Y alone", "[GCodeWriter][remap]")
{
GIVEN("a Cartesian writer") {
GCodeWriter writer;
THEN("the identity mapping supports arcs") {
REQUIRE(writer.kinematics().supports_arc_moves());
}
THEN("a Z-only negation still supports arcs") {
// (+X, +Y, -Z): non-identity, but X, Y, I and J are all untouched.
writer.set_axis_remap(0, 1, 5);
REQUIRE(writer.kinematics().must_emit_all_axes());
REQUIRE(writer.kinematics().supports_arc_moves());
}
THEN("a Z-only reversal still supports arcs") {
writer.set_axis_remap(0, 1, 8);
REQUIRE(writer.kinematics().supports_arc_moves());
}
THEN("swapping X and Y does not support arcs") {
writer.set_axis_remap(1, 0, 2);
REQUIRE_FALSE(writer.kinematics().supports_arc_moves());
}
THEN("the X-tilt style (x, z, y) remap does not support arcs") {
writer.set_axis_remap(0, 2, 1);
REQUIRE_FALSE(writer.kinematics().supports_arc_moves());
}
}
GIVEN("a belt writer") {
PrintConfig belt_config;
belt_config.belt_printer.value = true;
belt_config.belt_slice_rotation.value = BeltRotationAxis::X;
belt_config.belt_slice_rotation_angle.value = 45.0;
GCodeWriter writer;
install_belt_kinematics(writer, belt_config);
THEN("arcs are never supported, because the frame shears") {
REQUIRE_FALSE(writer.kinematics().supports_arc_moves());
}
}
}
SCENARIO("An unrepresentable arc degrades to its chord rather than emitting a wrong G2/G3",
"[GCodeWriter][remap]")
{
auto emitted_commands = [](const std::string &gcode) {
std::vector<std::string> cmds;
GCodeReader reader;
reader.parse_buffer(gcode, [&cmds](GCodeReader &, const GCodeReader::GCodeLine &line) {
if (! line.cmd().empty()) cmds.emplace_back(line.cmd());
});
return cmds;
};
GIVEN("an identity-mapping writer") {
GCodeWriter writer;
configure_lift_writer(writer);
WHEN("an arc is extruded") {
const std::string gcode = writer.extrude_arc_to_xy(
Vec2d(10.0, 0.0), Vec2d(5.0, 0.0), 0.0, /*is_ccw=*/true, "", /*force_no_extrusion=*/true);
THEN("it is still a G3") {
const auto cmds = emitted_commands(gcode);
REQUIRE(cmds.size() == 1);
REQUIRE(cmds.front() == "G3");
}
}
}
GIVEN("a writer whose mapping swaps X and Y") {
GCodeWriter writer;
configure_lift_writer(writer);
writer.set_axis_remap(1, 0, 2);
WHEN("an arc is extruded") {
const std::string gcode = writer.extrude_arc_to_xy(
Vec2d(10.0, 0.0), Vec2d(5.0, 0.0), 0.0, /*is_ccw=*/true, "", /*force_no_extrusion=*/true);
THEN("no arc is emitted; it is approximated with linear moves") {
const auto cmds = emitted_commands(gcode);
REQUIRE(! cmds.empty());
for (const auto &c : cmds)
REQUIRE(c == "G1");
}
}
}
// The first version of this test used dE = 0 with force_no_extrusion, which
// hid a real bug: the capability check sat AFTER filament()->extrude(dE), so
// the fallback into extrude_to_xy() advanced E twice. Extrusion accounting has
// to be asserted with a positive dE.
GIVEN("a writer whose mapping cannot express arcs, extruding a real amount") {
GCodeWriter writer;
configure_lift_writer(writer);
writer.set_axis_remap(1, 0, 2);
const double dE = 1.5;
// used_filament() accumulates across moves; E() is reset per line in
// relative-E mode, so it would only show the last segment.
const double used_before = writer.filament()->used_filament();
WHEN("an arc carrying that extrusion is emitted") {
const std::string gcode = writer.extrude_arc_to_xy(
Vec2d(10.0, 0.0), Vec2d(5.0, 0.0), dE, /*is_ccw=*/true, "", /*force_no_extrusion=*/false);
THEN("exactly dE is accounted for, not twice dE") {
REQUIRE_THAT(writer.filament()->used_filament() - used_before,
Catch::Matchers::WithinAbs(dE, 1e-6));
}
THEN("no G2/G3 survives") {
REQUIRE(gcode.find("G2") == std::string::npos);
REQUIRE(gcode.find("G3") == std::string::npos);
}
}
}
GIVEN("a writer whose mapping CAN express arcs, extruding a real amount") {
GCodeWriter writer;
configure_lift_writer(writer);
const double dE = 1.5;
// used_filament() accumulates across moves; E() is reset per line in
// relative-E mode, so it would only show the last segment.
const double used_before = writer.filament()->used_filament();
WHEN("an arc carrying that extrusion is emitted") {
const std::string gcode = writer.extrude_arc_to_xy(
Vec2d(10.0, 0.0), Vec2d(5.0, 0.0), dE, /*is_ccw=*/true, "", /*force_no_extrusion=*/false);
THEN("it is still a single arc and accounts for dE once") {
REQUIRE(emitted_commands(gcode).size() == 1);
REQUIRE_THAT(writer.filament()->used_filament() - used_before,
Catch::Matchers::WithinAbs(dE, 1e-6));
}
}
}
}
TEST_CASE("Percent accelerations resolve against the option they are a percentage of", "[GCodeWriter]")
{
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
+17 -1
View File
@@ -234,6 +234,19 @@ DynamicPrintConfig multifilament_config(unsigned int filaments, std::initializer
return config;
}
void size_flush_to_nozzles(DynamicPrintConfig &config)
{
// One filaments x filaments flush block and one flush multiplier per nozzle, as the GUI
// sizes them when the nozzle count changes; a single block leaves each nozzle's slice short.
const size_t nozzles = config.option<ConfigOptionFloats>("nozzle_diameter")->values.size();
auto &flush = config.option<ConfigOptionFloats>("flush_volumes_matrix", true)->values;
const std::vector<double> block = flush;
for (size_t i = 1; i < nozzles; ++i)
flush.insert(flush.end(), block.begin(), block.end());
for (const char *key : { "flush_multiplier", "flush_multiplier_fast" })
config.option<ConfigOptionFloats>(key, true)->resize(nozzles);
}
void init_print(std::vector<TriangleMesh> &&meshes, Slic3r::Print &print, Slic3r::Model &model, const DynamicPrintConfig &config_in,
const std::vector<std::vector<ConfigBase::SetDeserializeItem>> *per_object_overrides, bool arrange, size_t instances)
{
@@ -457,7 +470,10 @@ int role_passes(const std::string &gcode, const std::string &role)
bool in_role = false;
GCodeReader reader;
reader.parse_buffer(gcode, [&](GCodeReader &self, const GCodeReader::GCodeLine &line) {
if (! line.extruding(self)) return;
// E-only unretraction moves have positive E but do not lay down material. Ignoring
// them keeps a role pass contiguous across travel/retraction bookkeeping.
if (! line.extruding(self) || (line.dist_XY(self) <= EPSILON && std::abs(line.dist_Z(self)) <= EPSILON))
return;
const bool is_role = line.comment().find(role) != std::string_view::npos;
if (is_role && ! in_role) ++passes;
in_role = is_role;
+4
View File
@@ -78,6 +78,10 @@ Slic3r::Model model(const std::string& model_name, TriangleMesh&& _mesh);
DynamicPrintConfig multifilament_config(unsigned int filaments,
std::initializer_list<Slic3r::ConfigBase::SetDeserializeItem> extra = {});
// Repeat multifilament_config's single flush block, and the flush multipliers, once per nozzle.
// Call after setting nozzle_diameter to more than one nozzle.
void size_flush_to_nozzles(DynamicPrintConfig &config);
// Apply `meshes` and config to `print`/`model`, each object with `instances` copies; optional per-object overrides,
// auto-arranged unless `arrange` is false.
void init_print(std::vector<TriangleMesh> &&meshes, Slic3r::Print &print, Slic3r::Model &model, const DynamicPrintConfig &config_in,
+390
View File
@@ -0,0 +1,390 @@
#include <catch2/catch_all.hpp>
#include "catch2/catch_test_macros.hpp"
#include "catch2/generators/catch_generators.hpp"
#include "catch2/catch_message.hpp"
#include "libslic3r/Config.hpp"
#include "libslic3r/PrintConfig.hpp"
#include "test_helpers.hpp"
#include "test_utils.hpp"
#include <cstddef>
#include <string>
#include <vector>
using namespace Slic3r;
using namespace Slic3r::Test;
// The IMEX mode placeholders ({imex_mode}, {imex_mode_index}, {imex_mode_gcode}) and the mode
// script that consumes them are set up in GCode.cpp's _do_export, immediately before
// machine_start_gcode is processed. Two contracts live in that ordering:
// * the three placeholders exist for every G-code script the export runs, IMEX or not;
// * the mode script is itself run through the placeholder parser, and it runs FIRST, so a
// {global ...} it declares is in scope by the time machine_start_gcode is processed.
// Nothing else in the suite exercises them.
// Offset of the first line of `gcode` that equals `expected` once trailing whitespace is
// stripped, or npos. Whole-line matching is deliberate on both counts: prefix matching would
// let ";IMEX_MODE_INDEX:2" pass for an emitted index of 20, and the config block the exporter
// appends to every file repeats machine_start_gcode and imex_mode_gcodes verbatim (as
// "; key = value"), which would make any substring search for the templates below trivially
// true and every "not emitted" assertion trivially false.
static std::size_t find_exact_line(const std::string &gcode, const std::string &expected)
{
std::size_t pos = 0;
while (pos <= gcode.size()) {
const std::size_t eol = gcode.find('\n', pos);
const std::size_t end = (eol == std::string::npos) ? gcode.size() : eol;
std::string line = gcode.substr(pos, end - pos);
while (!line.empty() && (line.back() == '\r' || line.back() == ' ' || line.back() == '\t'))
line.pop_back();
if (line == expected)
return pos;
if (eol == std::string::npos)
break;
pos = eol + 1;
}
return std::string::npos;
}
static bool has_line(const std::string &gcode, const std::string &expected)
{
return find_exact_line(gcode, expected) != std::string::npos;
}
// Any command line -- not a comment -- containing `token`. Used where the assertion is that a
// carriage was addressed at all, rather than with a particular value: the config block the
// exporter appends restates every setting as "; key = value", so the comment lines have to be
// excluded or a search for a setting's own name matches itself.
static bool has_command_containing(const std::string &gcode, const std::string &token)
{
std::size_t pos = 0;
while (pos < gcode.size()) {
const std::size_t eol = gcode.find('\n', pos);
const std::string line = gcode.substr(pos, (eol == std::string::npos ? gcode.size() : eol) - pos);
if (!line.empty() && line.front() != ';' && line.find(token) != std::string::npos)
return true;
if (eol == std::string::npos)
break;
pos = eol + 1;
}
return false;
}
// Mode scripts. Free of placeholders so that a test can tell "the script was emitted" apart
// from "the script was expanded"; the expansion case below supplies its own template.
static const char *kCopyScript = "SET_DUAL_CARRIAGE MODE=COPY";
static const char *kQuadScript = "SET_QUAD_CARRIAGE MODE=COPY";
// Reports all three placeholders on their own lines so find_exact_line can pin each value.
static const char *kReportStartGcode = ";IMEX_MODE:{imex_mode}\n"
";IMEX_MODE_INDEX:{imex_mode_index}\n"
";IMEX_MODE_GCODE:{imex_mode_gcode}\n";
// Shared IMEX printer geometry: 7 logical extruders across 4 physical heads, three modes.
// physical_extruder_map is only honoured when its length matches the nozzle count
// (PrintApply feeds effective_physical_extruder_map the nozzle_diameter size), so the nozzle
// keys must be sized to 7 or the map is silently replaced with the identity and IMEX quietly
// stops happening at all. Mirrors imex_7x4_printer() in test_multifilament.cpp; `iq-copy` is
// carried over from the IQEX case there, which is a mode combination known to slice.
//
// imex_mode_gcodes is left to each test: the position a mode's script occupies in this list is
// exactly what {imex_mode_index} is asserted against.
static void imex_7x4_printer(DynamicPrintConfig &config)
{
config.set_deserialize_strict({
{ "nozzle_diameter", "0.4,0.4,0.4,0.4,0.4,0.4,0.4" },
{ "printer_extruder_id", "1,2,3,4,5,6,7" },
{ "printer_extruder_variant", "Direct Drive Standard,Direct Drive Standard,Direct Drive Standard,"
"Direct Drive Standard,Direct Drive Standard,Direct Drive Standard,"
"Direct Drive Standard" },
{ "extruder_printable_height", "0,0,0,0,0,0,0" },
{ "physical_extruder_map", "0,0,0,0,1,2,3" },
// Authored 1..n, as every production path does: PresetBundle writes it in
// full_fff_config(), and config load synthesizes it when absent. The all-1s default is
// not a neutral placeholder -- get_config_index_base() keys the slot-to-column lookup
// on it, and with every entry equal each per-filament vector collapses to filament 1's
// value, leaving a per-slot assertion comparing a value against itself.
{ "filament_self_index", "1,2,3,4,5,6,7" },
{ "is_imex", "1" },
{ "imex_mode_names", "primary;copy;iq-copy" },
{ "imex_mode_active_tools", "0:P;0:P,1:C;0:P,1:C,2:C,3:C" },
{ "skirt_loops", "0" },
{ "brim_type", "no_brim" },
// Klipper skips the bed/extruder temperature block that would otherwise be written
// between the mode script and machine_start_gcode, so the ordering assertion below
// compares the two scripts and nothing else.
{ "gcode_flavor", "klipper" },
});
size_flush_to_nozzles(config);
}
// Set the mode scripts positionally, one per name in imex_mode_names. Assigning the vector
// beats set_deserialize_strict here: a coStrings round trip through a ';'-joined string has to
// quote and escape empty entries and embedded braces, and the empty first entry (primary, which
// deliberately has no script) is exactly the entry that would be lost.
static void set_mode_gcodes(DynamicPrintConfig &config, const std::vector<std::string> &gcodes)
{
config.option<ConfigOptionStrings>("imex_mode_gcodes", true)->values = gcodes;
}
// Route every region to one filament. An unset *_filament_id is not "inherit":
// clamp_feature_filament_to_valid rewrites <=0 to 1, which would drag tool 0 into
// tool_ordering. Filament 1 is logical slot 0, which pem routes to head 0 -- the head every
// mode here declares Primary -- so Print::validate() accepts the plate.
static void all_regions_on_filament(DynamicPrintConfig &config, int filament_1based)
{
for (const char *key : { "outer_wall_filament_id", "inner_wall_filament_id",
"sparse_infill_filament_id", "internal_solid_filament_id",
"top_surface_filament_id", "bottom_surface_filament_id" })
config.set_deserialize_strict({ { key, std::to_string(filament_1based) } });
}
// {imex_mode} is the plate's active mode, {imex_mode_index} its position in imex_mode_names and
// {imex_mode_gcode} the raw script at that same position. Two modes are exercised because a
// single one cannot tell a resolved index apart from a constant: `copy` sits at 1 and `iq-copy`
// at 2, so an index that stopped tracking imex_mode_names fails at least one of them.
TEST_CASE("IMEX mode placeholders resolve to the active mode's name, index and script",
"[ImexModeGcode][IMEX]")
{
auto [mode, mode_index, mode_script] = GENERATE(table<std::string, int, std::string>({
{ "copy", 1, kCopyScript },
{ "iq-copy", 2, kQuadScript },
}));
INFO("active mode: " << mode);
DynamicPrintConfig config = multifilament_config(7);
imex_7x4_printer(config);
all_regions_on_filament(config, 1);
set_mode_gcodes(config, { "", kCopyScript, kQuadScript });
config.set_deserialize_strict({
{ "imex_parallel_mode", mode },
{ "machine_start_gcode", kReportStartGcode },
});
const std::string gcode = slice({ cube(20) }, config);
CHECK(has_line(gcode, ";IMEX_MODE:" + mode));
CHECK(has_line(gcode, ";IMEX_MODE_INDEX:" + std::to_string(mode_index)));
CHECK(has_line(gcode, ";IMEX_MODE_GCODE:" + mode_script));
// The script for the active mode -- and only that one -- reaches the file.
CHECK(has_line(gcode, mode_script));
CHECK_FALSE(has_line(gcode, mode == "copy" ? kQuadScript : kCopyScript));
}
// The mode script is a G-code template, not a literal: it goes through
// placeholder_parser_process like machine_start_gcode does. Because the three IMEX placeholders
// are set before that call, the script can also read its own mode -- which is what
// distinguishes "the script is processed" from "the script is processed too early to see them".
TEST_CASE("A placeholder inside an IMEX mode's G-code script is expanded",
"[ImexModeGcode][IMEX]")
{
const std::string templ = "SET_DUAL_CARRIAGE MODE={imex_mode} INDEX={imex_mode_index}"
" TEMP={nozzle_temperature_initial_layer[0]}";
DynamicPrintConfig config = multifilament_config(7);
imex_7x4_printer(config);
all_regions_on_filament(config, 1);
set_mode_gcodes(config, { "", templ, kQuadScript });
config.set_deserialize_strict({
{ "imex_parallel_mode", "copy" },
// The multi-extruder normalization collapses the per-filament temperature vector to a
// single value, so [0] is this literal 200 rather than a per-slot default.
{ "nozzle_temperature_initial_layer", "200" },
{ "machine_start_gcode", ";START\n" },
});
const std::string gcode = slice({ cube(20) }, config);
CHECK(has_line(gcode, "SET_DUAL_CARRIAGE MODE=copy INDEX=1 TEMP=200"));
// If the script were written straight to the file the braces would survive verbatim.
CHECK_FALSE(has_line(gcode, templ));
}
// The ordering guarantee, and the reason the placeholder block sits where it does: the mode
// script is processed BEFORE machine_start_gcode, so a {global} the mode declares is in scope
// for machine_start_gcode. Reorder the two and machine_start_gcode references an undefined
// variable, which the placeholder parser raises as an error out of the export -- so a
// regression here fails this test whether the export throws or merely writes nothing useful.
TEST_CASE("A global declared in the IMEX mode G-code is visible to machine_start_gcode",
"[ImexModeGcode][IMEX]")
{
DynamicPrintConfig config = multifilament_config(7);
imex_7x4_printer(config);
all_regions_on_filament(config, 1);
set_mode_gcodes(config, { "", std::string("{global imex_carriage_count = 2}") + kCopyScript, kQuadScript });
config.set_deserialize_strict({
{ "imex_parallel_mode", "copy" },
{ "machine_start_gcode", ";IMEX_CARRIAGES:{imex_carriage_count}\n" },
});
const std::string gcode = slice({ cube(20) }, config);
// The value is the mode script's, not a default: nothing else in this config defines it.
const std::size_t start_gcode_at = find_exact_line(gcode, ";IMEX_CARRIAGES:2");
REQUIRE(start_gcode_at != std::string::npos);
// ...and the two land in the file in that same order. Ordering is the contract here, so it
// is asserted directly rather than left implicit in the global resolving.
const std::size_t mode_script_at = find_exact_line(gcode, kCopyScript);
REQUIRE(mode_script_at != std::string::npos);
CHECK(mode_script_at < start_gcode_at);
}
// Primary is single-carriage printing: it names a real mode (position 0 of imex_mode_names, not
// the not-found fallback) but carries no script, so the export must add nothing. This is what
// keeps the mode machinery out of the way of an IMEX printer that is not printing in parallel.
TEST_CASE("Primary-mode IMEX prints emit no mode G-code", "[ImexModeGcode][IMEX]")
{
DynamicPrintConfig config = multifilament_config(7);
imex_7x4_printer(config);
all_regions_on_filament(config, 1);
set_mode_gcodes(config, { "", kCopyScript, kQuadScript });
config.set_deserialize_strict({
{ "imex_parallel_mode", "primary" },
{ "machine_start_gcode", kReportStartGcode },
});
const std::string gcode = slice({ cube(20) }, config);
CHECK(has_line(gcode, ";IMEX_MODE:primary"));
CHECK(has_line(gcode, ";IMEX_MODE_INDEX:0"));
CHECK(has_line(gcode, ";IMEX_MODE_GCODE:"));
CHECK_FALSE(has_line(gcode, kCopyScript));
CHECK_FALSE(has_line(gcode, kQuadScript));
}
// A plate stores its IMEX mode as the mode's *name* and the exporter resolves that name against
// the printer's imex_mode_names at slice time, so the two drift apart whenever the mode is
// renamed or deleted after a plate was set to it, or the project is opened against a printer
// preset that names its modes differently. A name matching no row must be treated as Primary --
// not as a parallel mode whose every name-keyed lookup happens to come back empty, which is how
// it used to behave: no mode script (there is none to find), no active-tool roster, and yet the
// parallel branches taken all the way through the export.
//
// Primary is a row like any other, so falling back to it means running its script too; a
// non-empty script is used here so "fell back to Primary" is distinguishable from "resolved
// nothing and emitted nothing".
TEST_CASE("An IMEX mode the printer no longer defines falls back to Primary",
"[ImexModeGcode][IMEX]")
{
static const char *kPrimaryScript = "SET_DUAL_CARRIAGE MODE=PRIMARY";
DynamicPrintConfig config = multifilament_config(7);
imex_7x4_printer(config);
all_regions_on_filament(config, 1);
set_mode_gcodes(config, { kPrimaryScript, kCopyScript, kQuadScript });
config.set_deserialize_strict({
// A name no row in imex_mode_names carries -- what `copy` becomes once it is renamed.
{ "imex_parallel_mode", "copy-renamed" },
{ "machine_start_gcode", kReportStartGcode },
});
const std::string gcode = slice({ cube(20) }, config);
// The placeholders report the mode the export actually ran, so the stale name must not
// survive into them -- a mode script keyed on {imex_mode} would otherwise set the printer
// up for a mode the slicer did not slice for.
CHECK(has_line(gcode, ";IMEX_MODE:primary"));
CHECK(has_line(gcode, ";IMEX_MODE_INDEX:0"));
CHECK(has_line(gcode, ";IMEX_MODE_GCODE:" + std::string(kPrimaryScript)));
// Primary's own script runs; neither parallel mode's does.
CHECK(has_line(gcode, kPrimaryScript));
CHECK_FALSE(has_line(gcode, kCopyScript));
CHECK_FALSE(has_line(gcode, kQuadScript));
}
// The whole block is gated on is_imex. A printer preset can carry a filled-in mode table and
// still not be an IMEX machine -- and a plate config can still carry a stale imex_parallel_mode
// -- so with the flag off the placeholders stay empty and no mode script is injected. Run on an
// ordinary single-extruder printer on purpose: the guard is one boolean, and the multi-head
// geometry the other cases need would only add ways for this one to fail for another reason.
TEST_CASE("A printer with IMEX disabled resolves the mode placeholders to nothing",
"[ImexModeGcode][IMEX]")
{
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
set_mode_gcodes(config, { "", kCopyScript, kQuadScript });
config.set_deserialize_strict({
{ "imex_mode_names", "primary;copy;iq-copy" },
{ "imex_mode_active_tools", "0:P;0:P,1:C;0:P,1:C,2:C,3:C" },
{ "is_imex", "0" },
{ "imex_parallel_mode", "copy" },
{ "skirt_loops", "0" },
{ "brim_type", "no_brim" },
{ "gcode_flavor", "klipper" },
{ "machine_start_gcode", kReportStartGcode },
});
const std::string gcode = slice({ cube(20) }, config);
CHECK(has_line(gcode, ";IMEX_MODE:"));
CHECK(has_line(gcode, ";IMEX_MODE_INDEX:0"));
CHECK(has_line(gcode, ";IMEX_MODE_GCODE:"));
CHECK_FALSE(has_line(gcode, kCopyScript));
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
// a carriage that falls out of one of those loops emits nothing at all rather than emitting
// something wrong -- it silently holds the initial-layer temperature, or whatever pressure
// advance the firmware was last told, for the whole job. So the property worth pinning is that
// the set of carriages addressed is exactly the set the mode declares active.
TEST_CASE("Every carriage a parallel mode declares active is addressed, and no other",
"[ImexModeGcode][IMEX]")
{
DynamicPrintConfig config = multifilament_config(7);
imex_7x4_printer(config);
all_regions_on_filament(config, 1);
set_mode_gcodes(config, { "", kCopyScript, kQuadScript });
config.set_deserialize_strict({
{ "imex_parallel_mode", "copy" },
{ "enable_pressure_advance", "1,1,1,1,1,1,1" },
// Distinct per slot, so each assertion below names one filament and no other. copy
// mode's secondary rides physical 1, which physical_extruder_map routes to the fifth
// slot -- 0.05 and 244, not the primary's 0.01 and 240.
{ "pressure_advance", "0.010,0.020,0.030,0.040,0.050,0.060,0.070" },
{ "nozzle_temperature_initial_layer", "235,235,235,235,235,235,235" },
{ "nozzle_temperature", "240,241,242,243,244,245,246" },
});
const std::string gcode = slice({ cube(20) }, config);
// imex_mode_active_tools declares copy as "0:P,1:C", so exactly these two carriages run, and
// each transitions to the temperature of the filament its own head is routed to. The other
// two heads this printer has must stay untouched.
CHECK(has_command_containing(gcode, "M104 S240 T0 ; set IMEX tool temperature"));
CHECK(has_command_containing(gcode, "M104 S244 T1 ; set IMEX tool temperature"));
CHECK_FALSE(has_command_containing(gcode, "T2 ; set IMEX tool temperature"));
CHECK_FALSE(has_command_containing(gcode, "T3 ; set IMEX tool temperature"));
// The companion loop, which skips the primary because set_extruder() has already emitted it
// on the ordinary path. The qualified line is therefore the one that belongs to this loop:
// Klipper names the first carriage as the unnumbered "extruder" and the rest by index, so
// dropping the secondary removes `extruder1` and leaves `extruder` in place.
CHECK(has_command_containing(gcode, "SET_PRESSURE_ADVANCE ADVANCE=0.05 EXTRUDER=extruder1;"));
CHECK(has_command_containing(gcode, "SET_PRESSURE_ADVANCE ADVANCE=0.01 EXTRUDER=extruder;"));
CHECK_FALSE(has_command_containing(gcode, "EXTRUDER=extruder2;"));
}
+495
View File
@@ -0,0 +1,495 @@
#include <catch2/catch_all.hpp>
#include "catch2/matchers/catch_matchers_floating_point.hpp"
#include "catch2/catch_test_macros.hpp"
#include "catch2/matchers/catch_matchers.hpp"
#include "libslic3r/GCodeReader.hpp"
#include "libslic3r/Model.hpp"
#include "libslic3r/Point.hpp"
#include "libslic3r/Print.hpp"
#include "test_helpers.hpp"
#include "test_utils.hpp"
#include <algorithm>
#include <cmath>
#include <cstddef>
#include <exception>
#include <libslic3r/PrintConfig.hpp>
#include <limits>
#include <sstream>
#include <string>
#include <string_view>
#include <vector>
using namespace Slic3r;
using namespace Slic3r::Test;
using Catch::Matchers::WithinAbs;
// The IMEX slice offset is the firmware-managed handoff: an extra XY shift applied at emission
// so the primary tool's zone lands centred on the bed origin and the firmware can fan copies /
// mirrors out from there. `Print::update_imex_slice_offset()` DERIVES it from the applied
// config -- nobody hands it in. That matters because the only thing that used to hand it in was
// the plater, so a headless `orca-slicer --slice` produced slicer-frame coordinates while the
// mode G-code told the firmware to apply its own offsets on top.
//
// This file covers both halves:
//
// * Derivation -- which config makes the offset non-zero, what value it takes, and (the part
// the GUI push got wrong) that it is in PLATE-LOCAL mm and therefore does not move when the
// plate origin does. `compute_imex_slice_offset()` and `compute_imex_zone_layout()` are
// covered as pure functions in tests/libslic3r/; what is covered here is the Print reaching
// them with the right inputs.
// * Consumption -- that a slice actually comes out shifted. Two sites consume the offset:
// GCode::set_gcode_offset_with_imex_shift() (GCode.hpp), which augments the writer offset
// so every emitted coordinate moves, and Print::translate_to_print_space() (Print.cpp),
// which feeds the first_layer_print_min/max placeholders a start-G-code template uses to
// declare the print area to firmware. The two must stay in one frame, so the last test
// cross-checks them against each other.
//
// The offset is applied at emission, after slicing, so it is a pure translation: assertions are
// on the SHIFT between two slices of the same plate, never on absolute coordinates or on a
// golden file. That keeps them independent of where the arranger puts the cube, and immune to
// the run-to-run variation in this slicer's parallel infill generation.
// ---------------------------------------------------------------------------------------------
// Helpers (unnamed namespace: every suite links into one binary, so nothing here may collide
// with a same-named helper in a sibling test file)
// ---------------------------------------------------------------------------------------------
namespace {
// The bed the derivation divides up, and the answer it must reach.
//
// With `imex_gantry_count` 1, `imex_tools_per_gantry` 2 and `imex_tool_layout` front-left, T0
// owns the left column and T1 the right one, so a mode declaring `0:P,1:C` gives the primary
// the left half of the bed: x [0, 150], y [0, 200]. Its centre is the offset. Both numbers are
// set by the config below, not inherited from a default.
constexpr double kBedWidth = 300.0;
constexpr double kBedDepth = 200.0;
const Vec2d kPrimaryZoneCentre(kBedWidth / 4.0, kBedDepth / 2.0);
// XY extent of a set of emitted moves. `empty()` means nothing matched.
struct XYBounds
{
double min_x = std::numeric_limits<double>::max();
double min_y = std::numeric_limits<double>::max();
double max_x = std::numeric_limits<double>::lowest();
double max_y = std::numeric_limits<double>::lowest();
bool empty() const { return min_x > max_x; }
double size_x() const { return max_x - min_x; }
double size_y() const { return max_y - min_y; }
};
// Extent of the END points of every extruding move commented "perimeter", up to `z_max`.
//
// Perimeters only: they are the outermost extrusions, so they carry the plate's extent, and
// unlike infill their geometry and ordering are stable run to run. End points only: the parse
// callback runs BEFORE the reader advances, so the start point of the first extrusion of a
// polyline is whatever the preceding travel left behind -- and a cube's perimeters are closed
// loops, whose last point is their first, so the end points alone already give the true extent.
static XYBounds perimeter_bounds(const std::string &gcode, double z_max = std::numeric_limits<double>::max())
{
XYBounds bounds;
GCodeReader reader;
reader.parse_buffer(gcode, [&bounds, z_max](GCodeReader &self, const GCodeReader::GCodeLine &line) {
if (!line.extruding(self))
return;
if (line.comment().find("perimeter") == std::string_view::npos)
return;
if (double(line.new_Z(self)) > z_max)
return;
const double x = line.new_X(self);
const double y = line.new_Y(self);
bounds.min_x = std::min(bounds.min_x, x);
bounds.max_x = std::max(bounds.max_x, x);
bounds.min_y = std::min(bounds.min_y, y);
bounds.max_y = std::max(bounds.max_y, y);
});
return bounds;
}
// Read "<tag>x,y" out of `gcode`. The config block the exporter appends restates
// machine_start_gcode verbatim, so the tag also occurs there with the placeholder still
// unexpanded; occurrences are tried in turn and the first one that parses as two numbers wins.
static bool read_marker(const std::string &gcode, const std::string &tag, Vec2d &out)
{
for (size_t at = gcode.find(tag); at != std::string::npos; at = gcode.find(tag, at + 1)) {
const size_t from = at + tag.size();
const size_t to = gcode.find('\n', from);
const std::string payload = gcode.substr(from, to == std::string::npos ? std::string::npos : to - from);
const size_t comma = payload.find(',');
if (comma == std::string::npos)
continue;
try {
out = Vec2d(std::stod(payload.substr(0, comma)), std::stod(payload.substr(comma + 1)));
} catch (const std::exception &) {
continue;
}
return true;
}
return false;
}
// An IMEX machine: 7 logical extruders across 4 physical heads, mirroring the geometry the IMEX
// cases in test_multifilament.cpp use, on a 2-tool single-gantry grid.
//
// physical_extruder_map is only honoured when its length matches the nozzle count (PrintApply
// hands effective_physical_extruder_map the nozzle_diameter size), so the nozzle keys have to be
// sized to 7 as well -- otherwise the map is silently replaced with the identity and the plate
// stops being an IMEX plate at all.
//
// The grid, the tool layout and the bed are all set explicitly: they are exactly the inputs the
// zone layout divides to reach kPrimaryZoneCentre, so none of them may come from a default.
// `imex_firmware_managed_zones` is deliberately NOT set here -- it is the switch under test, and
// every case states it for itself.
//
// Everything that would put extrusions outside the object footprint is off (skirt, brim, prime
// tower, infill, top/bottom shells): that leaves perimeters as the only extrusions, so the
// emitted extent and the first-layer convex hull both reduce to the cube's own outline.
static void imex_printer(DynamicPrintConfig &config)
{
config.set_deserialize_strict({
{ "nozzle_diameter", "0.4,0.4,0.4,0.4,0.4,0.4,0.4" },
{ "printer_extruder_id", "1,2,3,4,5,6,7" },
{ "printer_extruder_variant", "Direct Drive Standard,Direct Drive Standard,Direct Drive Standard,"
"Direct Drive Standard,Direct Drive Standard,Direct Drive Standard,"
"Direct Drive Standard" },
{ "extruder_printable_height", "0,0,0,0,0,0,0" },
{ "physical_extruder_map", "0,0,0,0,1,2,3" },
{ "printable_area", "0x0,300x0,300x200,0x200" },
{ "is_imex", "1" },
{ "imex_gantry_count", "1" },
{ "imex_tools_per_gantry", "2" },
{ "imex_tool_layout", "front-left" },
{ "imex_mode_names", "primary;copy" },
{ "imex_mode_active_tools", "0:P;0:P,1:C" },
// `copy` declares head 0 Primary, and filament 1 (logical slot 0) routes there, so the
// plate is well-formed and Print::validate() lets it through.
{ "imex_parallel_mode", "copy" },
{ "skirt_loops", "0" },
{ "brim_type", "no_brim" },
{ "enable_prime_tower", "0" },
{ "sparse_infill_density", "0%" },
{ "top_shell_layers", "0" },
{ "bottom_shell_layers", "0" },
{ "wall_loops", "2" },
{ "layer_height", "0.2" },
{ "initial_layer_print_height","0.2" },
{ "gcode_flavor", "klipper" },
});
size_flush_to_nozzles(config);
}
// Route every region to one filament. An unset *_filament_id is not "inherit":
// clamp_feature_filament_to_valid rewrites <=0 to 1, so leaving them unset would drag extra
// tools into tool_ordering. PrintObject.cpp's call to that function is the source of truth for
// this key list.
static void all_regions_on_filament(DynamicPrintConfig &config, int filament_1based)
{
for (const char *key : { "outer_wall_filament_id", "inner_wall_filament_id",
"sparse_infill_filament_id", "internal_solid_filament_id",
"top_surface_filament_id", "bottom_surface_filament_id" })
config.set_deserialize_strict({ { key, std::to_string(filament_1based) } });
}
// A ready-to-slice firmware-managed IMEX plate. `firmware_managed` is the one thing that
// varies between a baseline slice and a shifted one.
static DynamicPrintConfig imex_config(bool firmware_managed)
{
DynamicPrintConfig config = multifilament_config(7);
imex_printer(config);
all_regions_on_filament(config, 1); // filament 1 => logical slot 0 => physical head 0
config.set_deserialize_strict({ { "imex_firmware_managed_zones", firmware_managed ? "1" : "0" } });
return config;
}
// The offset the Print works out for itself, with nothing pushed in. Applying the config is
// enough -- the derivation reads only the config and the objects, so this needs no slice.
static Vec2d derived_offset(const DynamicPrintConfig &config, const Vec3d &plate_origin = Vec3d::Zero())
{
Print print;
Model model;
init_print({ cube(20) }, print, model, config);
print.set_plate_origin(plate_origin);
print.update_imex_slice_offset();
return print.get_imex_slice_offset();
}
// Slice one 20mm cube. Note what is NOT here: no offset is handed to the Print. Whatever shift
// the G-code comes out with, the Print derived on its own from `config`.
static std::string slice_cube(const DynamicPrintConfig &config, const Vec3d &plate_origin = Vec3d::Zero())
{
Print print;
Model model;
init_print({ cube(20) }, print, model, config);
print.set_plate_origin(plate_origin);
return gcode(print);
}
} // namespace
// ---------------------------------------------------------------------------------------------
// Derivation
// ---------------------------------------------------------------------------------------------
// The bug this file exists for: the offset used to arrive only from PartPlate, which a headless
// slice never runs, so `--slice` on a firmware-managed plate emitted slicer-managed coordinates
// while the mode G-code told the firmware to fan copies out from them. Nothing pushes anything
// here; the Print is expected to reach the primary zone's centre from the config alone.
TEST_CASE("A firmware-managed IMEX plate derives its slice offset with nothing pushed in",
"[IMEXSliceOffset][IMEX]")
{
const Vec2d offset = derived_offset(imex_config(true));
CHECK_THAT(offset.x(), WithinAbs(kPrimaryZoneCentre.x(), 1e-9));
CHECK_THAT(offset.y(), WithinAbs(kPrimaryZoneCentre.y(), 1e-9));
}
// The offset is a PLATE-LOCAL quantity. Both consumers already subtract the plate origin
// separately, so an offset that moved with the plate would subtract it twice and put every
// plate after the first a full plate stride out. This is precisely what the old plater-side
// computation got wrong: it divided the plate's world-frame outline, not the bed.
TEST_CASE("The derived IMEX slice offset is plate-local and does not move with the plate origin",
"[IMEXSliceOffset][IMEX]")
{
const DynamicPrintConfig config = imex_config(true);
const Vec2d at_origin = derived_offset(config);
const Vec2d on_plate3 = derived_offset(config, Vec3d(kBedWidth * 2.0, -kBedDepth * 2.0, 0.0));
CHECK_THAT(on_plate3.x(), WithinAbs(at_origin.x(), 1e-9));
CHECK_THAT(on_plate3.y(), WithinAbs(at_origin.y(), 1e-9));
}
// The offset tracks the bed it divides, so it is not a constant that happens to match one
// printer. Halving the bed halves the primary zone and its centre with it.
TEST_CASE("The derived IMEX slice offset follows the printable area", "[IMEXSliceOffset][IMEX]")
{
DynamicPrintConfig config = imex_config(true);
config.set_deserialize_strict({ { "printable_area", "0x0,150x0,150x100,0x100" } });
const Vec2d offset = derived_offset(config);
CHECK_THAT(offset.x(), WithinAbs(kPrimaryZoneCentre.x() / 2.0, 1e-9));
CHECK_THAT(offset.y(), WithinAbs(kPrimaryZoneCentre.y() / 2.0, 1e-9));
}
// Every case that must leave the offset at exactly Vec2d::Zero(). This is what protects
// existing users: an exactly-zero offset is what makes the firmware-managed path reduce to the
// old set_gcode_offset() behaviour, to the last digit.
TEST_CASE("The derived IMEX slice offset is zero unless firmware-managed zones are in play",
"[IMEXSliceOffset][IMEX]")
{
// A Print nobody has applied anything to starts at zero. This is the value every
// non-IMEX printer keeps, and the reason nothing else in the exporter had to change.
Print fresh;
REQUIRE_THAT(fresh.get_imex_slice_offset().x(), WithinAbs(0.0, 1e-12));
REQUIRE_THAT(fresh.get_imex_slice_offset().y(), WithinAbs(0.0, 1e-12));
Vec2d offset = Vec2d::Zero();
SECTION("an ordinary printer with no IMEX configuration at all") {
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
config.set_deserialize_strict({
{ "sparse_infill_density", "0%" },
{ "layer_height", "0.2" },
{ "initial_layer_print_height", "0.2" },
});
offset = derived_offset(config);
}
SECTION("an IMEX printer in a parallel mode, but firmware-managed zones off") {
offset = derived_offset(imex_config(false));
}
SECTION("firmware-managed zones on, but the plate is in Primary mode") {
DynamicPrintConfig config = imex_config(true);
config.set_deserialize_strict({ { "imex_parallel_mode", "primary" } });
offset = derived_offset(config);
}
SECTION("firmware-managed zones on, but the plate has no mode at all") {
DynamicPrintConfig config = imex_config(true);
config.set_deserialize_strict({ { "imex_parallel_mode", "" } });
offset = derived_offset(config);
}
// A plate can name a mode this printer does not define -- a mode renamed or deleted after
// the plate was set to it, or a project opened against a different printer preset. The
// exporter falls back to Primary and warns; the offset has to make the same choice, or the
// file would be shifted for a mode nothing ever activates.
SECTION("firmware-managed zones on, but the plate's mode is not one this printer defines") {
DynamicPrintConfig config = imex_config(true);
config.set_deserialize_strict({ { "imex_parallel_mode", "renamed-since" } });
offset = derived_offset(config);
}
// The layout hangs off the primary tool's cell. `imex_tools_per_gantry` 1 on a single
// gantry is a one-cell grid: there is nothing to divide, so there is nothing to shift by.
SECTION("firmware-managed zones on, but the tool grid holds a single tool") {
DynamicPrintConfig config = imex_config(true);
config.set_deserialize_strict({ { "imex_tools_per_gantry", "1" },
{ "imex_mode_active_tools", "0:P;0:P" } });
offset = derived_offset(config);
}
CHECK_THAT(offset.x(), WithinAbs(0.0, 1e-12));
CHECK_THAT(offset.y(), WithinAbs(0.0, 1e-12));
}
// ---------------------------------------------------------------------------------------------
// Consumption
// ---------------------------------------------------------------------------------------------
// The whole point of the firmware-managed path: the emitted toolpaths move, and they move by the
// derived offset. The writer subtracts plate origin + IMEX shift from every point it formats, so
// the firmware-managed slice comes out at (baseline - offset). Asserting the shift rather than
// absolute coordinates keeps this independent of wherever the arranger drops the cube.
TEST_CASE("A firmware-managed IMEX plate emits coordinates shifted by the offset it derived",
"[IMEXSliceOffset][IMEX]")
{
const XYBounds baseline = perimeter_bounds(slice_cube(imex_config(false)));
const XYBounds shifted = perimeter_bounds(slice_cube(imex_config(true)));
REQUIRE_FALSE(baseline.empty());
REQUIRE_FALSE(shifted.empty());
CHECK_THAT(shifted.min_x, WithinAbs(baseline.min_x - kPrimaryZoneCentre.x(), 1e-3));
CHECK_THAT(shifted.max_x, WithinAbs(baseline.max_x - kPrimaryZoneCentre.x(), 1e-3));
CHECK_THAT(shifted.min_y, WithinAbs(baseline.min_y - kPrimaryZoneCentre.y(), 1e-3));
CHECK_THAT(shifted.max_y, WithinAbs(baseline.max_y - kPrimaryZoneCentre.y(), 1e-3));
// A translation, not a re-slice: the plate keeps its size.
CHECK_THAT(shifted.size_x(), WithinAbs(baseline.size_x(), 1e-3));
CHECK_THAT(shifted.size_y(), WithinAbs(baseline.size_y(), 1e-3));
}
// The IMEX shift is added to the plate origin, not substituted for it. A multi-plate project
// already carries a non-zero plate origin, so dropping either term from
// set_gcode_offset_with_imex_shift() would put every emitted coordinate on the wrong plate --
// invisibly to a test that only ever slices plate 1 at the origin. The expected total is
// origin + offset precisely because the derived offset does NOT itself contain the origin.
TEST_CASE("An IMEX slice offset composes with the plate origin rather than replacing it",
"[IMEXSliceOffset][IMEX]")
{
const Vec3d plate_origin(kBedWidth * 1.1, -kBedDepth * 1.1, 0.0);
const XYBounds baseline = perimeter_bounds(slice_cube(imex_config(false)));
const XYBounds shifted = perimeter_bounds(slice_cube(imex_config(true), plate_origin));
REQUIRE_FALSE(baseline.empty());
REQUIRE_FALSE(shifted.empty());
const double expected_x = plate_origin.x() + kPrimaryZoneCentre.x();
const double expected_y = plate_origin.y() + kPrimaryZoneCentre.y();
CHECK_THAT(shifted.min_x, WithinAbs(baseline.min_x - expected_x, 1e-3));
CHECK_THAT(shifted.max_x, WithinAbs(baseline.max_x - expected_x, 1e-3));
CHECK_THAT(shifted.min_y, WithinAbs(baseline.min_y - expected_y, 1e-3));
CHECK_THAT(shifted.max_y, WithinAbs(baseline.max_y - expected_y, 1e-3));
}
// The guard for everyone who is not using this feature. `imex_firmware_managed_zones` is read
// by nothing else in the engine, so setting it on a printer the derivation refuses to shift must
// leave the slice where it was -- it must short-circuit on `is_imex` before it ever looks at a
// bed or a mode. The zero-offset sections above cover an ordinary printer that never mentions
// the option; this covers the option turned ON where nothing may act on it.
//
// Asserted as the derived offset plus the extent of the emitted perimeters, NOT as a line-by-line
// comparison of the two exports. This slicer's output is not stable run to run -- parallel infill
// generation is the documented source -- and the M73 time estimates, the seam placement and the
// travel ordering all ride on that variation, so comparing every emitted command would flake in
// CI rather than catch a shift. The offset is applied at emission as a pure translation, so a
// non-zero one moves the extent and this catches it; that it is exactly zero is what the
// derivation check states.
TEST_CASE("Turning on firmware-managed zones changes nothing on a non-IMEX printer",
"[IMEXSliceOffset][IMEX]")
{
auto ordinary_printer = [](bool firmware_managed) {
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
config.set_deserialize_strict({
{ "sparse_infill_density", "0%" },
{ "top_shell_layers", "0" },
{ "bottom_shell_layers", "0" },
{ "wall_loops", "2" },
{ "layer_height", "0.2" },
{ "initial_layer_print_height", "0.2" },
{ "skirt_loops", "0" },
{ "brim_type", "no_brim" },
{ "enable_prime_tower", "0" },
{ "imex_firmware_managed_zones", firmware_managed ? "1" : "0" },
});
return config;
};
// Nothing to apply in the first place.
const Vec2d offset = derived_offset(ordinary_printer(true));
CHECK_THAT(offset.x(), WithinAbs(0.0, 1e-12));
CHECK_THAT(offset.y(), WithinAbs(0.0, 1e-12));
// ...and the toolpaths bear that out: same plate, same place. Infill, skirt, brim and the
// prime tower are all off in this config, so the perimeters carry the whole extent.
const XYBounds baseline = perimeter_bounds(slice_cube(ordinary_printer(false)));
const XYBounds with_flag = perimeter_bounds(slice_cube(ordinary_printer(true)));
REQUIRE_FALSE(baseline.empty());
REQUIRE_FALSE(with_flag.empty());
CHECK_THAT(with_flag.min_x, WithinAbs(baseline.min_x, 1e-3));
CHECK_THAT(with_flag.max_x, WithinAbs(baseline.max_x, 1e-3));
CHECK_THAT(with_flag.min_y, WithinAbs(baseline.min_y, 1e-3));
CHECK_THAT(with_flag.max_y, WithinAbs(baseline.max_y, 1e-3));
}
// first_layer_print_min/max are what a start-G-code template hands the firmware to declare the
// print area (bed mesh bounds, PRINT_MIN/PRINT_MAX). They come from the first-layer convex hull
// pushed through Print::translate_to_print_space(), which subtracts the same IMEX shift the
// writer does, so they have to travel with the toolpaths. If they did not, a firmware-managed
// plate would probe one area and print in another.
TEST_CASE("first_layer_print_min/max track the derived IMEX slice offset",
"[IMEXSliceOffset][IMEX]")
{
auto with_markers = [](bool firmware_managed) {
DynamicPrintConfig config = imex_config(firmware_managed);
config.set_deserialize_strict({
{ "machine_start_gcode",
";FLMIN:{first_layer_print_min[0]},{first_layer_print_min[1]}\n"
";FLMAX:{first_layer_print_max[0]},{first_layer_print_max[1]}\n" },
});
return config;
};
const std::string baseline_gcode = slice_cube(with_markers(false));
const std::string shifted_gcode = slice_cube(with_markers(true));
Vec2d baseline_min, baseline_max, shifted_min, shifted_max;
REQUIRE(read_marker(baseline_gcode, ";FLMIN:", baseline_min));
REQUIRE(read_marker(baseline_gcode, ";FLMAX:", baseline_max));
REQUIRE(read_marker(shifted_gcode, ";FLMIN:", shifted_min));
REQUIRE(read_marker(shifted_gcode, ";FLMAX:", shifted_max));
CHECK_THAT(shifted_min.x(), WithinAbs(baseline_min.x() - kPrimaryZoneCentre.x(), 1e-3));
CHECK_THAT(shifted_min.y(), WithinAbs(baseline_min.y() - kPrimaryZoneCentre.y(), 1e-3));
CHECK_THAT(shifted_max.x(), WithinAbs(baseline_max.x() - kPrimaryZoneCentre.x(), 1e-3));
CHECK_THAT(shifted_max.y(), WithinAbs(baseline_max.y() - kPrimaryZoneCentre.y(), 1e-3));
// Declared area and toolpaths must be in ONE frame. The declared bounds come from the
// first-layer convex hull, which Print::first_layer_islands() builds from the object's SLICE
// CONTOUR (lslices) -- not from any extrusion path -- so it sits outside the emitted
// centrelines by whatever wall geometry lies between the two. That distance is a property of
// the wall generator, not of this feature, so it is not asserted as a constant here.
//
// What IS asserted: the declared bounds enclose the toolpaths, and the gap between the two is
// the SAME in both frames. A frame divergence -- one of the two consumers shifted, the other
// not -- moves the declared box relative to the toolpaths and breaks this by the offset.
const XYBounds baseline_layer = perimeter_bounds(baseline_gcode, 0.3); // initial_layer_print_height 0.2
const XYBounds shifted_layer = perimeter_bounds(shifted_gcode, 0.3);
REQUIRE_FALSE(baseline_layer.empty());
REQUIRE_FALSE(shifted_layer.empty());
CHECK(shifted_min.x() <= shifted_layer.min_x + 1e-3);
CHECK(shifted_min.y() <= shifted_layer.min_y + 1e-3);
CHECK(shifted_max.x() >= shifted_layer.max_x - 1e-3);
CHECK(shifted_max.y() >= shifted_layer.max_y - 1e-3);
CHECK_THAT(shifted_layer.min_x - shifted_min.x(),
WithinAbs(baseline_layer.min_x - baseline_min.x(), 1e-3));
CHECK_THAT(shifted_layer.min_y - shifted_min.y(),
WithinAbs(baseline_layer.min_y - baseline_min.y(), 1e-3));
CHECK_THAT(shifted_max.x() - shifted_layer.max_x,
WithinAbs(baseline_max.x() - baseline_layer.max_x, 1e-3));
CHECK_THAT(shifted_max.y() - shifted_layer.max_y,
WithinAbs(baseline_max.y() - baseline_layer.max_y, 1e-3));
}
+622
View File
@@ -20,7 +20,10 @@
#include "libslic3r/Model.hpp"
#include "libslic3r/TriangleMesh.hpp"
#include "libslic3r/Print.hpp"
#include <libslic3r/PrintBase.hpp>
#include <libslic3r/CustomGCode.hpp>
#include <limits>
#include <map>
#include <optional>
#include <regex>
#include <set>
@@ -762,6 +765,587 @@ TEST_CASE("Multi-extruder slice stays in bounds with a short max_layer_height",
}
// Shared IMEX printer geometry: 7 logical extruders across 4 physical heads.
// physical_extruder_map is only honoured when its length matches the nozzle count
// (PrintApply feeds effective_physical_extruder_map the nozzle_diameter size), so the
// nozzle keys must be sized to 7 or the map is silently replaced with the identity and
// every logical slot resolves to its own head -- which hides the defects under test.
static void imex_7x4_printer(DynamicPrintConfig &config)
{
config.set_deserialize_strict({
{ "nozzle_diameter", "0.4,0.4,0.4,0.4,0.4,0.4,0.4" },
{ "printer_extruder_id", "1,2,3,4,5,6,7" },
{ "printer_extruder_variant", "Direct Drive Standard,Direct Drive Standard,Direct Drive Standard,"
"Direct Drive Standard,Direct Drive Standard,Direct Drive Standard,"
"Direct Drive Standard" },
{ "extruder_printable_height", "0,0,0,0,0,0,0" },
{ "physical_extruder_map", "0,0,0,0,1,2,3" },
{ "is_imex", "1" },
{ "imex_mode_names", "primary;copy" },
{ "imex_mode_active_tools", "0:P;0:P,1:C" },
{ "skirt_loops", "0" },
{ "brim_type", "no_brim" },
// Temperature assertions below spell "M104 S<t> T<n>"; RepRapFirmware would emit
// "G10 S<t> P<n>" from the same code, so the flavor is pinned rather than defaulted.
{ "gcode_flavor", "klipper" },
});
size_flush_to_nozzles(config);
}
// Route every region to one filament. An unset *_filament_id is not "inherit":
// clamp_feature_filament_to_valid rewrites <=0 to 1, which would drag tool 0 into
// tool_ordering and mask what these tests assert. PrintObject.cpp's call to that
// function is the source of truth for this key list -- a new one has to be added here.
static void all_regions_on_filament(DynamicPrintConfig &config, int filament_1based)
{
for (const char *key : { "outer_wall_filament_id", "inner_wall_filament_id",
"sparse_infill_filament_id", "internal_solid_filament_id",
"top_surface_filament_id", "bottom_surface_filament_id" })
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
// head is 0, which the unguarded supplement resolved back to filament slot 0.
TEST_CASE("Primary-mode IMEX prints mark only the filament slot they print with",
"[MultiFilament][IMEX]")
{
DynamicPrintConfig config = multifilament_config(7);
imex_7x4_printer(config);
all_regions_on_filament(config, 6); // filament 6 => logical slot 5 => physical head 2
config.set_deserialize_strict({
{ "imex_parallel_mode", "primary" },
{ "machine_start_gcode",
";USED0:{if is_extruder_used[0]}1{else}0{endif}\n"
";USED5:{if is_extruder_used[5]}1{else}0{endif}\n" },
});
const std::string gcode = slice({ cube(20) }, config);
CHECK(gcode.find(";USED5:1") != std::string::npos);
CHECK(gcode.find(";USED0:0") != std::string::npos);
}
// Guard rail for the fix above: the modes the supplement exists for must keep marking their
// secondaries. Printed on filament 1 (slot 0), which pem routes to head 0 -- the head `copy`
// declares Primary -- so the plate is well-formed and validate() lets it through. Head 0 is
// the initial tool's head and is skipped (tool_ordering already marked slot 0); head 1 is the
// secondary and resolves to slot 4.
//
// This case previously printed on filament 6, which routes to head 2 while `copy` declares
// head 0 Primary. Print::validate() now refuses that plate outright (no filament on it can
// feed the Primary tool), so asserting it slices correctly would contradict
// "An IMEX plate whose filament never routes to the primary carriage is blocked" below.
// slice() does not surface validate()'s return, so the contradiction would have gone unnoticed.
TEST_CASE("Copy-mode IMEX prints still mark every secondary carriage's filament slot",
"[MultiFilament][IMEX]")
{
DynamicPrintConfig config = multifilament_config(7);
imex_7x4_printer(config);
all_regions_on_filament(config, 1);
config.set_deserialize_strict({
{ "imex_parallel_mode", "copy" },
{ "machine_start_gcode",
";USED0:{if is_extruder_used[0]}1{else}0{endif}\n"
";USED4:{if is_extruder_used[4]}1{else}0{endif}\n"
";USED5:{if is_extruder_used[5]}1{else}0{endif}\n" },
});
const std::string gcode = slice({ cube(20) }, config);
CHECK(gcode.find(";USED0:1") != std::string::npos); // the filament actually printed
CHECK(gcode.find(";USED4:1") != std::string::npos); // head 1, the secondary carriage
CHECK(gcode.find(";USED5:0") != std::string::npos);
}
// The two IMEX changes are coupled: get_imex_active_tools() now returns an empty roster in
// primary mode, so if the temperature branch ever stopped excluding primary it would enter,
// emit nothing, skip the standard path, and silently restore the bug the copy-mode case above
// covers -- with every other test still green.
TEST_CASE("Primary-mode IMEX prints still transition to the second-layer temperature",
"[MultiFilament][IMEX]")
{
DynamicPrintConfig config = multifilament_config(7);
imex_7x4_printer(config);
all_regions_on_filament(config, 1);
config.set_deserialize_strict({
{ "imex_parallel_mode", "primary" },
{ "nozzle_temperature_initial_layer", "200" },
{ "nozzle_temperature", "240" },
});
const std::string gcode = slice({ cube(20) }, config);
CHECK(gcode.find("M104 S240") != std::string::npos);
}
// A plate carries its IMEX mode as a name, matched against the printer's imex_mode_names at
// slice time, so a mode renamed or deleted underneath the plate -- or a project opened against a
// preset that names its modes differently -- leaves the plate pointing at nothing. That used to
// take every "not Primary" branch in the exporter while every name-keyed lookup came back empty,
// which is worse than either interpretation on its own:
// * the 1st->2nd layer temperature branch is mutually exclusive with the standard one, so an
// empty active-tool roster meant NO head was transitioned and every one of them held
// nozzle_temperature_initial_layer for the whole print; and
// * the initial T<n> was suppressed on the assumption that the mode's setup script would
// select the tool, while that script -- resolved by the same name -- did not exist.
// Print::validate() does not catch it either: the unresolved name yields an empty tools string,
// so there is no declared primary and its IMEX routing guard is skipped.
//
// Falling back to Primary is what makes the file coherent again. Asserted on the two emissions
// that were actually broken rather than on the mode string, which the placeholder test in
// test_imex_mode_gcode.cpp covers.
TEST_CASE("An IMEX plate set to a mode the printer no longer defines slices as Primary",
"[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
config.set_deserialize_strict({
// imex_mode_names is "primary;copy" -- this is `copy` after a rename.
{ "imex_parallel_mode", "copy-renamed" },
{ "nozzle_temperature_initial_layer", "200" },
{ "nozzle_temperature", "240" },
});
const std::string gcode = slice({ cube(20) }, config);
// The head the toolpaths run on steps 200 -> 240 at the second layer, via the standard
// per-extruder path a Primary-mode print uses.
CHECK(gcode.find("M104 S240") != std::string::npos);
// ...and only that head, addressed the way a single-head print addresses it: nothing drives
// a copy carriage here, so the fallback goes through the standard per-extruder path, where
// only filament slot 0 prints. GCodeWriter::set_temperature therefore sees
// multiple_extruders == false and emits no tool qualifier at all. Excluding the whole
// " T" suffix rather than " T1" is what makes that the assertion: a regression that routed
// the transition to T2 or T3 instead would satisfy an exclusion of T1, and the unqualified
// find above is itself prefix-satisfied by any "M104 S240 T<n>".
CHECK(gcode.find("M104 S240 T") == std::string::npos);
// The initial tool selection is emitted. Matched as a line-leading token rather than a whole
// line so the assertion does not depend on the trailing "; change extruder" comment, which
// is switched off by a global unrelated to IMEX.
bool selects_initial_tool = false;
std::istringstream tool_lines(gcode);
std::string line;
while (std::getline(tool_lines, line))
if (line.rfind("T0", 0) == 0) {
selects_initial_tool = true;
break;
}
CHECK(selects_initial_tool);
}
// IQEX: when the second gantry is active the mode drives all four carriages, so every one of
// them needs its own filament resolved -- for the first layer via is_extruder_used (consumed by
// machine_start_gcode) and for the second via the per-tool transition. pem routes filament 1 to
// head 0, and heads 1/2/3 to filament slots 4/5/6, so all four slots must appear.
TEST_CASE("IQEX modes emit first- and second-layer temperatures for every active carriage",
"[MultiFilament][IMEX]")
{
DynamicPrintConfig config = multifilament_config(7);
imex_7x4_printer(config);
all_regions_on_filament(config, 1);
config.set_deserialize_strict({
{ "imex_mode_names", "primary;copy;iq-copy" },
{ "imex_mode_active_tools", "0:P;0:P,1:C;0:P,1:C,2:C,3:C" },
{ "imex_parallel_mode", "iq-copy" },
{ "nozzle_temperature_initial_layer", "200" },
{ "nozzle_temperature", "240" },
{ "machine_start_gcode",
";USED0:{if is_extruder_used[0]}1{else}0{endif}\n"
";USED4:{if is_extruder_used[4]}1{else}0{endif}\n"
";USED5:{if is_extruder_used[5]}1{else}0{endif}\n"
";USED6:{if is_extruder_used[6]}1{else}0{endif}\n" },
});
const std::string gcode = slice({ cube(20) }, config);
// First layer: every active carriage's filament is declared to machine_start_gcode.
CHECK(gcode.find(";USED0:1") != std::string::npos);
CHECK(gcode.find(";USED4:1") != std::string::npos);
CHECK(gcode.find(";USED5:1") != std::string::npos);
CHECK(gcode.find(";USED6:1") != std::string::npos);
// Second layer: every active carriage gets its own transition.
CHECK(gcode.find("M104 S240 T0") != std::string::npos);
CHECK(gcode.find("M104 S240 T1") != std::string::npos);
CHECK(gcode.find("M104 S240 T2") != std::string::npos);
CHECK(gcode.find("M104 S240 T3") != std::string::npos);
}
// M104/M109 name a physical heater, but every caller of the instance set_temperature overload
// addresses filaments by logical id. pem routes filament 5 (logical 4) to head 1, so a
// toolchange between filaments 1 and 5 must cool and wait on T1 -- never T4, which on this
// machine is an AFC lane index and names no heater at all.
//
// Regression: the same-physical short-circuit in set_extruder hides this for lane swaps within
// one head (filaments 1-4 all map to head 0, so no cool-down is emitted), so only a toolchange
// that CROSSES heads reaches the emission. Ooze prevention must be on for pre/post_toolchange
// to run at all.
TEST_CASE("IMEX heater commands name the physical head, not the logical filament",
"[MultiFilament][IMEX][Regression]")
{
DynamicPrintConfig config = multifilament_config(7);
imex_7x4_printer(config);
config.set_deserialize_strict({
{ "imex_parallel_mode", "primary" },
{ "ooze_prevention", "1" },
{ "standby_temperature_delta", "-50" },
{ "single_extruder_multi_material", "0" },
{ "nozzle_temperature_initial_layer", "200,200,200,200,200,200,200" },
{ "nozzle_temperature", "240,240,240,240,240,240,240" },
});
// Two objects on filaments 1 and 5: logical 0 -> head 0, logical 4 -> head 1.
const std::vector<std::vector<ConfigBase::SetDeserializeItem>> overrides{
{ { "extruder", "1" } },
{ { "extruder", "5" } },
};
const std::string gcode = slice_with_object_overrides({ cube(20), cube(20) }, config, overrides);
// The bare toolchange stays LOGICAL -- it is an AFC lane selector, not a heater.
CHECK(gcode.find("\nT4") != std::string::npos);
// Every heater command carrying a tool must name a configured head (0-3 here), never a
// logical slot above the head count. Scanning beats a fixed-string check: it fails on any
// stray unmapped emission, not just the two sites this test was written for.
std::istringstream ss(gcode);
std::string line;
std::vector<std::string> offenders;
while (std::getline(ss, line)) {
if (line.rfind("M104", 0) != 0 && line.rfind("M109", 0) != 0)
continue;
const size_t t = line.find(" T");
if (t == std::string::npos || t + 2 >= line.size() || !std::isdigit((unsigned char) line[t + 2]))
continue;
if (std::stoi(line.substr(t + 2)) > 3)
offenders.push_back(line);
}
INFO("heater commands naming a non-existent head: " << offenders.size()
<< (offenders.empty() ? "" : " e.g. " + offenders.front()));
CHECK(offenders.empty());
}
// The other half of that translation, and the half nothing else covers: GCodeWriter::set_temperature
// passes `this->config.is_imex.value` as the guard, NOT a constant, so a printer that is not an
// IMEX printer keeps addressing heaters by logical filament id exactly as upstream does.
//
// physical_extruder_map is not an IMEX-only key. Shipping dual-nozzle BBL profiles author it --
// fdm_bbl_3dp_002_common ships {1, 0} -- for the inherited BBL reading of the key, and PrintApply
// deliberately leaves a non-IMEX printer's map exactly as it arrives (IMEXHelpers.hpp spells out
// the two readings). Hardcode `true` at that call site and this whole suite still passes, because
// every other case here runs either on an IMEX printer or on one with no authored map -- while
// those profiles start sending filament 0's M104/M109 to heater 1 and filament 1's to heater 0.
//
// The two filaments carry DIFFERENT idle temperatures, so the S value and the T index cross-check
// each other: a remap moves both onto the other tool and fails both halves, and no assertion can
// be satisfied by a prefix.
//
// Idle temperature rather than nozzle_temperature, for a reason specific to THIS HARNESS. Keys in
// filament_options_with_variant are rewritten at apply time by
// update_values_to_printer_extruders_for_multiple_filaments, which sets each filament's value to
// `opt->get_at(variant_index[f])` -- it RE-INDEXES per filament by that filament's extruder
// variant, it does not flatten. Per-filament nozzle temperature is a real, working feature and
// survives that pass on a printer whose filaments resolve to different variant slots.
// multifilament_config pins nozzle_diameter to a single 0.4, so every filament here resolves to
// the SAME variant slot and therefore ends up with the same value -- which is why the IMEX cases
// above tell heads apart by tool qualifier rather than by temperature. idle_temperature is not in
// that key set, so "151,173" reaches the emitter intact and the two filaments stay distinguishable.
// Ooze prevention is what puts the idle temperatures into the file at all.
TEST_CASE("Heater commands keep the logical filament id on a non-IMEX printer",
"[MultiFilament][IMEX][Regression]")
{
DynamicPrintConfig config = multifilament_config(2, {
{ "nozzle_diameter", "0.4,0.4" },
{ "printer_extruder_id", "1,2" },
{ "printer_extruder_variant", "Direct Drive Standard,Direct Drive Standard" },
{ "extruder_printable_height", "0,0" },
// The shipping two-nozzle profile: not an IMEX printer, but it authors the swap map.
{ "is_imex", "0" },
{ "physical_extruder_map", "1,0" },
{ "single_extruder_multi_material", "0" },
// Ooze prevention drops the outgoing filament to its idle temperature on every tool
// change, through the instance set_temperature overload that carries the guard.
{ "ooze_prevention", "1" },
{ "standby_temperature_delta", "-50" }, // unused while idle_temperature is set
// Per filament and distinct: these are the values the assertions pair with a heater.
{ "idle_temperature", "151,173" },
{ "nozzle_temperature_initial_layer", "215,215" },
{ "nozzle_temperature", "240,240" },
// GCodeProcessor's preheat pass rewrites the tool-change temperature commands and drops
// the ";cooldown" M104s outright, applying physical_extruder_map itself as it does. That
// pass is not the code under test, so switch it off and let the writer's emissions stand.
{ "preheat_time", "0" },
{ "enable_prime_tower", "0" },
// The assertions spell "M104 S<t> T<n>"; RepRapFirmware emits "G10 S<t> P<n>" from the
// same code, so the flavor is pinned rather than defaulted.
{ "gcode_flavor", "klipper" },
});
// One filament per object, so both heaters are addressed and a tool change happens in both
// directions. Assigned at the object level: a region-level filament id would not raise the
// used-filament count the tool ordering works from.
const std::vector<std::vector<ConfigBase::SetDeserializeItem>> overrides{
{ { "extruder", "1" } },
{ { "extruder", "2" } },
};
const std::string gcode = slice_with_object_overrides({ cube(20), cube(20) }, config, overrides);
// Every heater command that names a tool, collected as temperature -> the tools it was
// addressed to. Scanning beats fixed-string finds: "M104 S151" on its own is prefix-satisfied
// by "M104 S151 T1", and excluding just " T1" would let a command routed to some third tool
// through. Mirrors the offender scan in the IMEX case above.
std::map<int, std::set<int>> tools_by_temp;
std::istringstream ss(gcode);
for (std::string line; std::getline(ss, line);) {
if (line.rfind("M104", 0) != 0 && line.rfind("M109", 0) != 0)
continue;
const size_t s = line.find('S');
const size_t t = line.find(" T");
if (s == std::string::npos || t == std::string::npos)
continue;
if (s + 1 >= line.size() || !std::isdigit((unsigned char) line[s + 1]))
continue;
if (t + 2 >= line.size() || !std::isdigit((unsigned char) line[t + 2]))
continue;
tools_by_temp[std::stoi(line.substr(s + 1))].insert(std::stoi(line.substr(t + 2)));
}
std::string seen;
for (const auto& [temperature, tools] : tools_by_temp) {
seen += " S" + std::to_string(temperature) + "->";
for (int tool : tools)
seen += "T" + std::to_string(tool);
}
INFO("heater commands naming a tool:" << seen);
// Both idle temperatures have to be in the file, or the pairing below would prove nothing.
REQUIRE(tools_by_temp.count(151) == 1);
REQUIRE(tools_by_temp.count(173) == 1);
// Filament 0's idle temperature goes to heater 0 and nowhere else, filament 1's to heater 1.
// Applying physical_extruder_map {1, 0} here would swap both.
CHECK(tools_by_temp[151] == std::set<int>{ 0 });
CHECK(tools_by_temp[173] == std::set<int>{ 1 });
}
// The IMEX Primary tool prints the sliced paths directly, so it can only use a filament the
// printer's physical_extruder_map routes to it. The ghost filament picker enforces that for
// the secondary tools; the primary's filament comes from the ordinary object selector, which
// has no IMEX awareness. `copy` declares T0 Primary, but every filament this plate uses --
// filament 6, slot 5 -- routes to head 2, so nothing can feed T0 and validate() must refuse.
//
// The object's own extruder is pinned too: ModelVolume::get_extruders() reports the volume's
// extruder_id (1 by default), which would put slot 0 on the plate. Slot 0 routes to head 0,
// the declared primary, so the plate would be well-formed and correctly NOT blocked.
TEST_CASE("An IMEX plate whose filament never routes to the Primary tool is blocked",
"[MultiFilament][IMEX]")
{
DynamicPrintConfig config = multifilament_config(7);
imex_7x4_printer(config);
all_regions_on_filament(config, 6);
config.set_deserialize_strict({ { "imex_parallel_mode", "copy" } });
std::vector<TriangleMesh> meshes;
meshes.push_back(cube(20));
const std::vector<std::vector<ConfigBase::SetDeserializeItem>> overrides{ { { "extruder", "6" } } };
Slic3r::Model model;
Slic3r::Print print;
init_print(std::move(meshes), print, model, config, &overrides, false);
std::vector<StringObjectException> warnings;
const StringObjectException err = print.validate(&warnings);
REQUIRE_FALSE(err.string.empty());
CHECK(err.string.find("T0") != std::string::npos); // the declared primary
CHECK(err.string.find("T2") != std::string::npos); // where the filament actually lives
}
// A mixed filament is blended at the nozzle by its component toolheads, which a parallel
// mode is already using to print copies. Unsupported regardless of where the components
// route, so this must refuse even though component filament 1 sits on the declared primary
// T0 -- and it must refuse with the mixed message, not the routing one. Mixed slots normally
// sit past the end of physical_extruder_map, so the routing rule would call them unrouted.
TEST_CASE("An IMEX plate using a mixed filament is blocked", "[MultiFilament][IMEX]")
{
DynamicPrintConfig config = multifilament_config(8);
imex_7x4_printer(config);
all_regions_on_filament(config, 8);
config.set_deserialize_strict({
{ "imex_parallel_mode", "copy" },
{ "filament_is_mixed", "0,0,0,0,0,0,0,1" },
{ "filament_mixed_components", ";;;;;;;1,5" },
// The mixed arrays run parallel to filament_colour and must be sized to the filament
// count (see test_mixed_filament.cpp). validate() returns before the other five are
// read, but that is a property of where the rule sits, not something to rely on.
{ "filament_mixed_sublayer_ratios", ";;;;;;;" },
{ "filament_mixed_gradient", "0,0,0,0,0,0,0,0" },
{ "filament_mixed_gradient_range", ";;;;;;;" },
{ "filament_mixed_gradient_curve", ";;;;;;;" },
{ "filament_mixed_gradient_per_part", "0,0,0,0,0,0,0,0" },
});
std::vector<TriangleMesh> meshes;
meshes.push_back(cube(20));
const std::vector<std::vector<ConfigBase::SetDeserializeItem>> overrides{ { { "extruder", "8" } } };
Slic3r::Model model;
Slic3r::Print print;
init_print(std::move(meshes), print, model, config, &overrides, false);
std::vector<StringObjectException> warnings;
const StringObjectException err = print.validate(&warnings);
REQUIRE_FALSE(err.string.empty());
CHECK(err.string.find("Mixed filaments") != std::string::npos);
}
// Filament 8 (slot 7) is the blend, filament 1 (slot 0) is ordinary, so used_filaments is > 1
// and the multi-color rule's gate opens too. This pins that the mixed rule still wins: with the
// two the other way round the user is told the mode's active tools all sit on one gantry --
// a lecture about a multi-color print they never asked for -- and never learns the blend is
// the problem.
//
// The second half is what keeps this honest. The multi-color rule only fires here because this
// fixture's `copy` mode is degenerate (imex_tools_per_gantry defaults to 2, so "0:P,1:C" puts
// both tools on gantry 0). Give the mode a Span tool and it returns nothing, and this test would
// pass under EITHER ordering while appearing to guard it. So assert the rule is actually armed.
TEST_CASE("A mixed filament outranks the multi-color rule on the same plate",
"[MultiFilament][IMEX][Regression]")
{
// Two cubes, offset: make_cube() is corner-at-origin, so identical meshes would be exactly
// coincident. validate() returns from the IMEX block before any geometry check today, but a
// future check landing earlier would fail this test for a reason it is not about.
const auto build = [](bool blend_slot_8) {
DynamicPrintConfig config = multifilament_config(8);
imex_7x4_printer(config);
all_regions_on_filament(config, 8);
config.set_deserialize_strict({
{ "imex_parallel_mode", "copy" },
{ "filament_is_mixed", "0,0,0,0,0,0,0,1" },
{ "filament_mixed_components", ";;;;;;;1,5" },
// The mixed arrays run parallel to filament_colour and must be sized to the filament
// count (see test_mixed_filament.cpp). validate() returns before the other five are
// read, but that is a property of where the rule sits, not something to rely on.
{ "filament_mixed_sublayer_ratios", ";;;;;;;" },
{ "filament_mixed_gradient", "0,0,0,0,0,0,0,0" },
{ "filament_mixed_gradient_range", ";;;;;;;" },
{ "filament_mixed_gradient_curve", ";;;;;;;" },
{ "filament_mixed_gradient_per_part", "0,0,0,0,0,0,0,0" },
});
if (!blend_slot_8)
config.set_deserialize_strict({ { "filament_is_mixed", "0,0,0,0,0,0,0,0" } });
return config;
};
const std::vector<std::vector<ConfigBase::SetDeserializeItem>> overrides{
{ { "extruder", "8" } }, { { "extruder", "1" } },
};
const auto validate_plate = [&](const DynamicPrintConfig& config, Slic3r::Print& print,
Slic3r::Model& model) {
std::vector<TriangleMesh> meshes;
meshes.push_back(cube(20));
TriangleMesh second = cube(20);
second.translate(30.0, 0.0, 0.0);
meshes.push_back(second);
init_print(std::move(meshes), print, model, config, &overrides, false);
std::vector<StringObjectException> warnings;
return print.validate(&warnings);
};
// The multi-color rule IS armed for this plate -- without that, the check below proves nothing.
{
Slic3r::Model model;
Slic3r::Print print;
const StringObjectException err = validate_plate(build(false), print, model);
REQUIRE_FALSE(err.string.empty());
CHECK(err.string.find("Multi-color") != std::string::npos);
}
// With the blend present the mixed rule takes precedence over it.
{
Slic3r::Model model;
Slic3r::Print print;
const StringObjectException err = validate_plate(build(true), print, model);
REQUIRE_FALSE(err.string.empty());
CHECK(err.string.find("not supported in IDEX/IQEX parallel modes") != std::string::npos);
CHECK(err.string.find("Multi-color") == std::string::npos);
// The two rules differ in more than wording: the mixed path attaches an object (for the
// notification's "Jump to" link), the multi-color path returns none. Pins which fired
// independently of the message text.
CHECK(err.object == print.objects().front());
}
}
// A tool change added from the layer slider switches heads mid-print just as a painted color
// does, so a parallel mode has to judge it with the plate's other filaments. Before it counted,
// a one-filament plate with a slider change to a copying head's filament passed validate().
TEST_CASE("A layer slider tool change counts as a color in a parallel mode", "[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
config.set_deserialize_strict({ { "imex_parallel_mode", "copy" } });
Slic3r::Model model;
Slic3r::Print print;
init_print({ cube(20) }, print, model, config);
std::vector<StringObjectException> warnings;
REQUIRE(print.validate(&warnings).string.empty());
// Filament 5 routes to head 1, which copies the primary in this mode.
model.plates_custom_gcodes[model.curr_plate_index].gcodes.push_back(
{ 5.0, CustomGCode::Type::ToolChange, 5, "", "" });
print.apply(model, print.full_print_config());
REQUIRE_FALSE(print.validate(&warnings).string.empty());
}
// Guard rail: the block must not fire on a well-formed plate. Filament 1 (slot 0) routes to
// head 0, which `copy` declares Primary, so the Primary tool has something to print with.
TEST_CASE("An IMEX plate whose filament routes to the Primary tool validates",
"[MultiFilament][IMEX]")
{
DynamicPrintConfig config = multifilament_config(7);
imex_7x4_printer(config);
all_regions_on_filament(config, 1);
config.set_deserialize_strict({ { "imex_parallel_mode", "copy" } });
Slic3r::Model model;
Slic3r::Print print;
init_print({ cube(20) }, print, model, config);
std::vector<StringObjectException> warnings;
const StringObjectException err = print.validate(&warnings);
CHECK(err.string.empty());
}
// A filament can define several variants (Standard, High Flow). Each filament prints with its
// variant of the extruder's variant string, or with its own first variant when it defines none, on a
// printer listing a single variant as on one listing several.
@@ -947,6 +1531,44 @@ TEST_CASE("Each filament sets the pressure advance of its extruder variant on a
}
}
// Without a dynamic nozzle map the hotend placeholders carry no hotend index (-1) on a BBL printer: Bambu
// firmware reads an explicit index as a request for the Filament Track Switch and rejects the job. Any other
// printer gets the extruder index of the filament.
TEST_CASE("Hotend placeholders resolve to -1 on a BBL printer and to the extruder index elsewhere", "[MultiFilament]")
{
// filament 1 prints the walls on extruder 2, filament 2 the infill on extruder 1
auto [is_bbl, start, changes] = GENERATE(table<bool, std::string, std::set<std::string>>({
{ true, "; hotend start filament 0: -1 -1 -1",
{ "; hotend change filament 0: -1 -1", "; hotend change filament 1: -1 -1" } },
// the first change loads filament 1 with no filament before it, so there is no outgoing extruder
{ false, "; hotend start filament 0: 1 1 1",
{ "; hotend change filament -1: -1 1", "; hotend change filament 0: 1 0", "; hotend change filament 1: 0 1" } },
}));
DynamicPrintConfig config = two_extruder_pressure_advance_config("2,1", "0,0,0,0", 1, 2);
config.set_key_value("machine_start_gcode", new ConfigOptionString(
"; hotend start filament [initial_no_support_extruder]: [initial_no_support_hotend] [current_hotend] {first_non_support_hotend[0]}"));
config.set_key_value("change_filament_gcode", new ConfigOptionString(
"; hotend change filament [current_filament_id]: [current_hotend] [next_hotend]"));
Print print;
print.is_BBL_printer() = is_bbl;
Model model;
init_print({ cube(20) }, print, model, config);
const std::string gcode = Slic3r::Test::gcode(print);
INFO("BBL printer: " << is_bbl);
std::vector<std::string> starts;
std::set<std::string> found;
std::istringstream stream(gcode);
for (std::string line; std::getline(stream, line);) {
if (line.rfind("; hotend start ", 0) == 0)
starts.push_back(line);
else if (line.rfind("; hotend change ", 0) == 0)
found.insert(line);
}
CHECK(starts == std::vector<std::string>{ start });
CHECK(found == changes);
}
// Filament 1 prints the walls on extruder 1 (variant index 0), filament 2 the infill on extruder 2 (variant index 3).
TEST_CASE("Adaptive pressure advance on one extruder leaves the other extruder's pressure advance alone", "[MultiFilament]")
{
+43
View File
@@ -23,9 +23,11 @@
#include "libslic3r/GCode/SeamPlacer.hpp"
#include "libslic3r/GCodeReader.hpp"
#include "libslic3r/TriangleMesh.hpp"
#include "libslic3r/ExPolygon.hpp"
#include <cstddef>
#include <string>
#include <string_view>
#include <cstdlib>
using namespace Slic3r;
@@ -1427,3 +1429,44 @@ TEST_CASE("Full containment warns for Blocked but not for Enforced", "[PreciseSe
else
CHECK(fixture.warning().empty());
}
TEST_CASE("Belt printers slice Precise Seam modifiers in the frame the object was sliced in", "[PreciseSeam][belt]")
{
Model model;
Print print;
Test::init_print({Test::cube(20)}, print, model, {
{ "belt_printer", 1 },
{ "belt_slice_rotation", "x" },
{ "belt_slice_rotation_angle", 45 },
{ "layer_height", 0.2 },
{ "initial_layer_print_height", 0.2 },
{ "skirt_loops", 0 },
// Keep the first layer's islands the raw slice, like the modifier's.
{ "elefant_foot_compensation", 0 },
});
print.process();
const PrintObject *object = print.get_object(0);
REQUIRE(object->layer_count() > 0);
// A modifier with the object's own mesh and placement must slice to the object's own islands
// on every layer. Sliced without the belt rotation it would give 20 mm squares on the lower
// layers and nothing above 20 mm, while the tilted cube reaches about 28 mm.
Model modifiers;
ModelVolume *modifier = modifiers.add_object()->add_volume(*model.objects.front()->volumes.front());
modifier->set_type(ModelVolumeType::PRECISE_SEAM_BLOCKED);
const std::vector<ExPolygons> slices = object->slice_single_volume_regions(modifier);
REQUIRE(slices.size() == object->layer_count());
const double tolerance = scale_(0.05) * scale_(20.);
for (size_t i = 0; i < slices.size(); ++i) {
CAPTURE(i, object->get_layer(int(i))->slice_z);
const ExPolygons &islands = object->get_layer(int(i))->lslices;
double object_area = 0., modifier_area = 0.;
for (const ExPolygon &island : islands)
object_area += island.area();
for (const ExPolygon &region : slices[i])
modifier_area += region.area();
CHECK(std::abs(modifier_area - object_area) < tolerance);
CHECK(get_extents(slices[i]).inflated(scale_(0.05)).contains(get_extents(islands)));
}
}
+834
View File
@@ -14,6 +14,7 @@
#include <catch2/generators/catch_generators_range.hpp>
#include <catch2/catch_message.hpp>
#include "libslic3r/PrintConfig.hpp"
#include "libslic3r/libslic3r.h"
#include <cstddef>
#include "libslic3r/Surface.hpp"
#include "libslic3r/Config.hpp"
@@ -26,6 +27,17 @@
#include "libslic3r/Print.hpp"
#include "libslic3r/Layer.hpp"
#include "libslic3r/BuildVolume.hpp"
#include "libslic3r/Support/TreeModelVolumes.hpp"
#include "libslic3r/Support/TreeSupportCommon.hpp"
#include "libslic3r/Support/BeltFloorContext.hpp"
#include "libslic3r/ClipperUtils.hpp"
#include "libslic3r/ExtrusionEntity.hpp"
#include "libslic3r/Polyline.hpp"
#include <limits>
#include <cmath>
#include <map>
#include "libslic3r/Polygon.hpp"
#include "libslic3r/Model.hpp"
#include "libslic3r/GCodeReader.hpp"
#include "libslic3r/GCode/GCodeProcessor.hpp"
@@ -36,6 +48,10 @@
#include "test_utils.hpp"
#include <algorithm>
#include <boost/algorithm/string/predicate.hpp>
#include <cstdlib>
#include <sstream>
#include <limits>
#include <fstream>
#include <iterator>
#include <memory>
@@ -451,6 +467,88 @@ TEST_CASE("Print::validate tolerates a null warnings pointer", "[Print][validate
CHECK(err.string.empty());
}
TEST_CASE("Purge tower selection keeps ordinary printers on the classic path", "[Print][PurgeTower][Regression]")
{
DynamicPrintConfig config = multifilament_config(2, {
{ "belt_printer", 0 },
{ "enable_prime_tower", 1 },
{ "enable_belt_purge_tower", 1 }
});
config.set_key_value("timelapse_type", new ConfigOptionEnum<TimelapseType>(TimelapseType::tlSmooth));
Model model;
Print print;
build_cubes(model, print, config, /*n=*/1, /*overlap=*/false);
CHECK(print.has_wipe_tower());
CHECK_FALSE(print.has_belt_purge_tower());
}
TEST_CASE("Belt purge planning requires its managed purge object", "[Print][PurgeTower][Regression]")
{
DynamicPrintConfig config = multifilament_config(2, {
{ "belt_printer", 1 },
{ "enable_belt_purge_tower", 1 }
});
Model model;
Print print;
build_cubes(model, print, config, /*n=*/1, /*overlap=*/false);
CHECK_FALSE(print.has_belt_purge_tower());
model.objects.front()->config.set_key_value("belt_purge_tower_object", new ConfigOptionBool(true));
print.apply(model, config);
CHECK(print.has_belt_purge_tower());
CHECK_FALSE(print.has_wipe_tower());
}
// The GUI creates the purge tower object; a project sliced without one (the CLI) must say
// that its filament changes go unpurged.
TEST_CASE("Belt purge tower enabled without a tower object warns", "[Print][PurgeTower][belt]")
{
DynamicPrintConfig config = multifilament_config(2, {
{ "belt_printer", 1 },
{ "enable_belt_purge_tower", 1 },
{ "layer_change_gcode", "G92 E0\n" }
});
auto purge_warnings = [](Print &print) {
std::vector<StringObjectException> warnings;
print.validate(&warnings);
return std::count_if(warnings.begin(), warnings.end(), [](const StringObjectException &w) {
return w.opt_key == "enable_belt_purge_tower";
});
};
Model model;
Print print;
build_cubes(model, print, config, /*n=*/2, /*overlap=*/false);
model.objects[1]->config.set_key_value("extruder", new ConfigOptionInt(2));
print.apply(model, config);
REQUIRE(print.extruders().size() > 1);
CHECK(purge_warnings(print) == 1);
model.objects.front()->config.set_key_value("belt_purge_tower_object", new ConfigOptionBool(true));
print.apply(model, config);
CHECK(purge_warnings(print) == 0);
}
TEST_CASE("Belt purge rejects multiple managed purge objects", "[Print][PurgeTower][Regression]")
{
DynamicPrintConfig config = multifilament_config(2, {
{ "belt_printer", 1 },
{ "enable_belt_purge_tower", 1 }
});
Model model;
Print print;
build_cubes(model, print, config, /*n=*/2, /*overlap=*/false);
for (ModelObject *object : model.objects)
object->config.set_key_value("belt_purge_tower_object", new ConfigOptionBool(true));
print.apply(model, config);
CHECK_FALSE(print.validate().string.empty());
}
TEST_CASE("A default slice emits perimeter, infill, and skirt", "[Print]")
{
const std::string gcode = slice({ cube(20) }, {
@@ -661,6 +759,199 @@ TEST_CASE("Sequential printing publishes the nozzle group result", "[Print][Mult
}
}
// A scarf joint starts one layer height below the layer and ramps up along the
// wall. On a tilted belt that start is a step backwards along the belt axis, into
// the previous layer's wall at the seam: 0.283 mm per 0.2 mm layer at 45 degrees.
// With an aligned seam the nozzle rams the same spot on every layer (field report
// from a BabyBelt Pro: the belt "jumped backwards" and knocked the part loose).
// Belt printers therefore never get a scarf, whatever the process preset says.
TEST_CASE("Belt printers never start a scarf seam below the layer", "[Print][belt][Seam]")
{
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 },
{ "top_shell_layers", 0 },
{ "bottom_shell_layers", 1 },
{ "wall_loops", 2 },
{ "seam_position", "back" },
{ "seam_slope_type", "external" },
{ "seam_slope_inner_walls", 1 },
{ "seam_slope_start_height", 0 },
// No z-hop: on a belt a lift is a move along the belt axis (0.4 mm / sin 45 = 0.57 mm)
// and its return would read as a back-step. The shipped belt profiles print without one.
{ "z_hop", 0 },
{ "machine_start_gcode", "T[initial_tool]\n" },
{ "layer_change_gcode", "G92 E0\n" },
});
const std::string gcode = slice({ cube(20) }, config);
REQUIRE(! gcode.empty());
// The belt axis is machine Z. Within a layer it only drifts by the frame
// coupling (well under 0.1 mm across a 20 mm cube); a scarf start is a full
// layer pitch (0.283 mm) backwards.
double last_z = std::numeric_limits<double>::lowest();
double worst_backstep = 0.;
GCodeReader parser;
parser.parse_buffer(gcode, [&](GCodeReader &, const GCodeReader::GCodeLine &line) {
if (! line.cmd_is("G1") || ! line.has_z())
return;
const double z = line.z();
if (last_z != std::numeric_limits<double>::lowest())
worst_backstep = std::max(worst_backstep, last_z - z);
last_z = z;
});
CHECK(worst_backstep < 0.2);
}
// printable_height on a belt printer is the clearance under the gantry, so an object taller
// than that is refused whatever the machine-frame transform does to the emitted coordinates.
TEST_CASE("Belt printers refuse an object taller than the gantry clearance", "[Print][belt]")
{
auto belt_config = [](double printable_height) {
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" },
{ "printable_height", printable_height },
{ "skirt_loops", 0 },
{ "layer_change_gcode", "G92 E0\n" },
});
return config;
};
SECTION("a 20 mm cube fits under 50 mm of clearance") {
Print print;
Model model;
init_print({ cube(20) }, print, model, belt_config(50));
CHECK(print.validate().string.empty());
}
SECTION("a 60 mm cube does not") {
Print print;
Model model;
init_print({ cube(60) }, print, model, belt_config(50));
CHECK(print.validate().string.find("height") != std::string::npos);
}
}
// On a belt every tilted layer starts on the belt, so "the first layers" the fan stays off
// for are a band along the belt, not the first slicing layers. The generator marks where
// each extrusion segment enters and leaves that band and the cooling buffer keeps the fan
// off inside it, on every layer.
TEST_CASE("Belt printers keep the part fan off within the band above the belt", "[Print][belt][Cooling]")
{
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 },
// Three layers, 0.6 mm: the lowest wall of each tilted layer is centred about 0.3 mm
// above the belt (half a line width in from the contact edge).
{ "close_fan_the_first_x_layers", 3 },
{ "full_fan_speed_layer", 0 },
{ "fan_min_speed", 100 },
{ "fan_max_speed", 100 },
{ "slow_down_layer_time", 1000 },
{ "fan_cooling_layer_time", 1001 },
{ "reduce_fan_stop_start_freq", 0 },
{ "machine_start_gcode", "T[initial_tool]\n" },
{ "layer_change_gcode", "G92 E0\n" },
});
const std::string gcode = slice({ cube(20) }, config);
REQUIRE(! gcode.empty());
// The markers are consumed by the cooling buffer and never reach the file.
CHECK(gcode.find(";_BELT_BAND") == std::string::npos);
// With this axis mapping machine Y is the height above the belt along the gantry. Walk
// the moves with the fan state: extrusions that stay within 0.45 mm of the belt are well
// inside the band and must print with the fan off; extrusions that stay 5 mm clear of it
// must print with it on. The first three slicing layers have the fan off altogether.
size_t in_band = 0, in_band_fan_on = 0, clear = 0, clear_fan_off = 0;
int layer = -1;
bool fan_on = false;
double y = 0.;
std::istringstream lines(gcode);
for (std::string line; std::getline(lines, line); ) {
if (boost::starts_with(line, ";LAYER_CHANGE")) {
++ layer;
} else if (boost::starts_with(line, "M107")) {
fan_on = false;
} else if (boost::starts_with(line, "M106")) {
const size_t s = line.find('S');
fan_on = s != std::string::npos && std::atof(line.c_str() + s + 1) > 0.;
} else if (boost::starts_with(line, "G1 ")) {
const size_t comment = line.find(';');
const std::string cmd = line.substr(0, comment);
const size_t ypos = cmd.find(" Y"), epos = cmd.find(" E");
if (ypos == std::string::npos)
continue;
const double y_new = std::atof(cmd.c_str() + ypos + 2);
const bool extruding = epos != std::string::npos && std::atof(cmd.c_str() + epos + 2) > 0.;
if (extruding && layer >= 3) {
if (std::max(y, y_new) < 0.45) {
++ in_band;
in_band_fan_on += fan_on;
} else if (std::min(y, y_new) > 5.) {
++ clear;
clear_fan_off += ! fan_on;
}
}
y = y_new;
}
}
CHECK(in_band > 20);
CHECK(in_band_fan_on == 0);
CHECK(clear > 20);
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;
@@ -686,3 +977,546 @@ TEST_CASE("Slicing errors are reported per object with the object's name", "[Pri
CHECK(message.rfind("floating cube: ", 0) == 0);
CHECK(message.find("empty first layer") != std::string::npos);
}
// ---------------------------------------------------------------------------
// Belt mode must be invisible when it is off, and must not leave traces behind.
// ---------------------------------------------------------------------------
// Everything the slicer decided, without the lines that legitimately differ between
// two exports of the same print: comments (the config block lists every key, the
// header carries the export time) and the thumbnail blocks.
static std::string gcode_body(const std::string &gcode)
{
std::string body;
std::istringstream in(gcode);
for (std::string line; std::getline(in, line); ) {
line.erase(std::min(line.size(), line.find(';')));
while (! line.empty() && line.back() == ' ')
line.pop_back();
if (! line.empty())
body += line + '\n';
}
return body;
}
static DynamicPrintConfig belt_test_config()
{
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 },
{ "machine_start_gcode", "T[initial_tool]\n" },
{ "layer_change_gcode", "G92 E0\n" },
});
return config;
}
TEST_CASE("Belt-only keys at non-default values leave non-belt G-code unchanged", "[Print][belt][Regression]")
{
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
config.set_deserialize_strict({
{ "layer_height", 0.2 },
{ "initial_layer_print_height", 0.2 },
{ "z_hop", 0 },
{ "brim_type", "outer_only" },
{ "brim_width", 4 },
{ "enable_support", 1 },
{ "support_type", "tree(auto)" },
{ "support_style", "organic" },
{ "sparse_infill_pattern", "adaptivecubic" },
{ "machine_start_gcode", "T[initial_tool]\n" },
{ "layer_change_gcode", "G92 E0\n" },
});
const std::string reference = gcode_body(slice({ TestMesh::overhang }, config));
REQUIRE(! reference.empty());
// Every belt key a profile can carry, at a value that would change a belt print.
// belt_printer stays off, so none of them may reach the G-code: the axis remaps are
// gated on belt mode, the rest is only read on belt printers. build_plate_tilt_x/y
// is a feature of its own on a flat bed and is left alone here; "leading_edge_only"
// prints as an outer brim by design.
config.set_deserialize_strict({
{ "belt_printer", 0 },
{ "belt_printer_infinite_y", 0 },
{ "belt_slice_rotation", "y" },
{ "belt_slice_rotation_angle", 30 },
{ "gcode_remap_x", "rev_x" },
{ "gcode_remap_y", "pos_z" },
{ "gcode_remap_z", "pos_y" },
{ "belt_frame_tilt_decouple", 1 },
{ "belt_frame_tilt_angle", 30 },
{ "belt_support_floor_offset", -5 },
{ "enable_belt_purge_tower", 1 },
{ "belt_purge_tower_width", 10 },
{ "leading_brim_length", 10 },
{ "extra_brim_width", 5 },
});
CHECK(gcode_body(slice({ TestMesh::overhang }, config)) == reference);
}
TEST_CASE("Switching a sliced project from belt to non-belt matches a fresh slice", "[Print][belt][Regression]")
{
// The organic support layers and the adaptive infill octree are placed with the
// belt global Z offset, and the mesh with the belt min-Z lift. Both are only
// written while belt mode slices, so they used to survive a switch away from it.
DynamicPrintConfig flat = DynamicPrintConfig::full_print_config();
flat.set_deserialize_strict({
{ "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" },
{ "sparse_infill_pattern", "adaptivecubic" },
{ "machine_start_gcode", "T[initial_tool]\n" },
{ "layer_change_gcode", "G92 E0\n" },
});
DynamicPrintConfig belt = belt_test_config();
belt.set_deserialize_strict({
{ "enable_support", 1 },
{ "support_type", "tree(auto)" },
{ "support_style", "organic" },
{ "sparse_infill_pattern", "adaptivecubic" },
});
// Both prints are placed with the belt config, so only the slicing history differs.
auto fresh_slice = [&](const DynamicPrintConfig &target) {
Print print;
Model model;
init_print({ TestMesh::overhang }, print, model, belt);
print.apply(model, target);
const std::string out = gcode(print);
return gcode_body(out);
};
auto resliced = [&](const DynamicPrintConfig &target) {
Print print;
Model model;
init_print({ TestMesh::overhang }, print, model, belt);
REQUIRE(! gcode(print).empty());
print.apply(model, target);
const std::string out = gcode(print);
return gcode_body(out);
};
SECTION("belt printer to a flat-bed printer") {
CHECK(resliced(flat) == fresh_slice(flat));
}
SECTION("belt tilt axis set to None") {
DynamicPrintConfig untilted = belt;
untilted.set_deserialize_strict({ { "belt_slice_rotation", "none" } });
CHECK(resliced(untilted) == fresh_slice(untilted));
}
}
TEST_CASE("A support-only change on a belt purge print matches a fresh slice", "[Print][belt][PurgeTower][Regression]")
{
// Snapping the purge prism onto the parts' layer grid shifts every object's layers by
// up to half a layer. A support-only change reruns support generation without
// reslicing, so the cached belt floor and the global Z offset have to carry the
// snap too, or the supports land on the pre-snap grid.
auto make_config = [](bool support) {
DynamicPrintConfig config = multifilament_config(2, {
{ "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_belt_purge_tower", 1 },
{ "machine_start_gcode", "T[initial_tool]\n" },
{ "layer_change_gcode", "G92 E0\n" },
});
config.set_deserialize_strict({
{ "enable_support", support ? 1 : 0 },
{ "support_type", "tree(auto)" },
{ "support_style", "organic" },
});
return config;
};
const std::vector<std::vector<Slic3r::ConfigBase::SetDeserializeItem>> overrides {
{ { "extruder", 1 } }, { { "extruder", 2 } },
};
auto build = [&](Print &print, Model &model, const DynamicPrintConfig &config) {
init_print(std::vector<TriangleMesh>{ mesh(TestMesh::overhang), cube(20) }, print, model, config, &overrides);
model.objects.back()->config.set_key_value("belt_purge_tower_object", new ConfigOptionBool(true));
print.apply(model, config);
REQUIRE(print.has_belt_purge_tower());
};
std::string fresh;
{
Print print;
Model model;
build(print, model, make_config(true));
fresh = gcode_body(gcode(print));
}
REQUIRE(! fresh.empty());
Print print;
Model model;
build(print, model, make_config(false));
REQUIRE(! gcode(print).empty());
// Support only: posSlice stays valid, posSupportMaterial reruns.
print.apply(model, make_config(true));
CHECK(gcode_body(gcode(print)) == fresh);
}
TEST_CASE("Organic tree supports place a support blocker at its own height above a raft", "[Print][Support][Regression]")
{
// TreeModelVolumes consumes the support blockers in the same index space as the
// layer outlines, where object layer i sits at num_raft_layers + i, but
// slice_support_blockers() returns them in object-layer space. Without the shift
// every blocker lands num_raft_layers too low, so branches are kept out of the
// wrong layers and may pass through the blocked ones.
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
config.set_deserialize_strict({
{ "layer_height", 0.2 },
{ "initial_layer_print_height", 0.2 },
{ "skirt_loops", 0 },
{ "enable_support", 1 },
{ "support_type", "tree(auto)" },
{ "support_style", "organic" },
{ "raft_layers", 3 },
});
Print print;
Model model;
init_print({ cube(20) }, print, model, config);
// A blocker floating beside the cube, 8 mm to 12 mm above the bed, so a collision at
// its centre can only come from the blocker itself (the part keeps its mesh
// coordinates in object space, hence the offset relative to the part).
ModelObject *object = model.objects.front();
ModelVolume *blocker = object->add_volume(TriangleMesh(its_make_cube(6., 6., 4.)));
blocker->set_type(ModelVolumeType::SUPPORT_BLOCKER);
const Vec3d part_offset = object->volumes.front()->get_offset();
blocker->set_offset(Vec3d(part_offset.x() + 20., part_offset.y(), 10.));
print.apply(model, config);
print.set_status_silent();
print.process();
const PrintObject &print_object = *print.objects().front();
const std::vector<Vec2d> bed = { { 0., 0. }, { 200., 0. }, { 200., 200. }, { 0., 200. } };
const BuildVolume build_volume{ bed, print.config().printable_height.value, {}, {} };
TreeSupport3D::TreeModelVolumes volumes{ print_object, build_volume, scaled<coord_t>(1.), scaled<coord_t>(0.5), 0, {} };
// The generator's raft layer count: the raft itself plus the gap layers up to the object.
const size_t num_raft = TreeSupport3D::TreeSupportSettings(TreeSupport3D::TreeSupportMeshGroupSettings(print_object),
print_object.slicing_parameters()).raft_layers.size();
REQUIRE(num_raft >= 3);
// Object layers the blocker was sliced into (object-layer space, as the generator
// receives them).
const std::vector<Polygons> blockers = print_object.slice_support_blockers();
size_t first = 0, last = 0;
bool found = false;
for (size_t i = 0; i < blockers.size(); ++ i)
if (! blockers[i].empty()) {
if (! found) { first = i; found = true; }
last = i;
}
REQUIRE(found);
REQUIRE(last - first > num_raft);
// The blocker's centre in the slicing frame (add_volume centred its mesh on its offset).
const Vec3d centre3 = print_object.trafo_sliced() * blocker->get_offset();
const Point centre = Point::new_scale(centre3.x(), centre3.y());
auto collides = [&](size_t tree_layer) {
for (const Slic3r::Polygon &poly : volumes.getCollision(0, TreeSupport3D::LayerIndex(tree_layer), false))
if (poly.contains(centre))
return true;
return false;
};
// In TreeModelVolumes' index space the blocker lives at num_raft + object layer.
CHECK(collides(num_raft + first));
CHECK(collides(num_raft + last));
// The layers just below it, where an unshifted blocker would land, are free; the
// layers just above the unshifted range, which the blocker does occupy, are not.
CHECK_FALSE(collides(first));
CHECK_FALSE(collides(first + num_raft - 1));
CHECK(collides(last + 1));
CHECK(collides(last + num_raft));
}
// organic_draw_branches() trims every branch slice against the collision volume (the
// part grown by the support XY distance), the bed and, on a belt, the belt plane before
// it becomes support, so a branch never runs into the part it supports. Not a belt
// feature: this is the generator every printer uses.
TEST_CASE("Organic tree supports keep their distance from the part", "[Print][Support]")
{
// A 20 mm cube carrying a 60 x 60 mm plate: a 20 mm wide ceiling all around the
// cube, 16 mm above the bed, with the cube's four corners in the way of the branches
// that drop from it. The plate reaches into the cube so the two shells overlap
// instead of sharing a face.
indexed_triangle_set its = its_make_cube(20., 20., 20.);
indexed_triangle_set plate = its_make_cube(60., 60., 4.);
its_translate(its, Vec3f(20.f, 20.f, 0.f));
its_translate(plate, Vec3f(0.f, 0.f, 16.f));
its_merge(its, plate);
TriangleMesh mesh(std::move(its));
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
config.set_deserialize_strict({
{ "layer_height", 0.2 },
{ "initial_layer_print_height", 0.2 },
{ "skirt_loops", 0 },
{ "enable_support", 1 },
{ "support_type", "tree(auto)" },
{ "support_style", "organic" },
{ "support_threshold_angle", 30 },
});
Print print;
Model model;
init_print({ mesh }, print, model, config);
// On the bed, not at its corner (the fixture leaves the object at the origin).
model.objects.front()->instances.front()->set_offset(Vec3d(100., 100., 0.));
print.apply(model, config);
print.set_status_silent();
print.process();
const PrintObject &object = *print.objects().front();
INFO("object layers " << object.layers().size() << ", support layers " << object.support_layers().size());
REQUIRE(! object.support_layers().empty());
// Support exists under the plate at all.
size_t support_layers_with_fills = 0;
for (const SupportLayer *layer : object.support_layers())
if (! layer->support_fills.empty())
++ support_layers_with_fills;
INFO("support layers with extrusions " << support_layers_with_fills);
CHECK(support_layers_with_fills > 20);
// Object layers by print_z, to look up the part's slice at a support layer's height.
std::map<coord_t, const Layer *> object_layers;
for (const Layer *layer : object.layers())
object_layers[scaled<coord_t>(layer->print_z)] = layer;
auto contains = [](const ExPolygons &expolys, const Point &pt) {
for (const ExPolygon &ex : expolys)
if (ex.contains(pt))
return true;
return false;
};
// No support extrusion may run closer to the part's slice than half a line width:
// the generator keeps the support XY distance (0.35 mm by default) plus the line's
// own half width away from it.
const float min_gap = scaled<float>(0.2);
size_t too_close = 0, points = 0, layers_checked = 0, layers_unmatched = 0;
for (const SupportLayer *layer : object.support_layers()) {
if (layer->support_fills.empty())
continue;
// The object layer whose slab spans this support layer's height.
auto it = object_layers.lower_bound(scaled<coord_t>(layer->print_z - EPSILON));
if (it == object_layers.end()) {
++ layers_unmatched;
continue;
}
++ layers_checked;
const ExPolygons grown = offset_ex(it->second->lslices, min_gap);
for (const ExtrusionEntity *entity : layer->support_fills.flatten().entities)
for (const Slic3r::Polyline &pl : entity->as_polylines())
for (size_t i = 0; i < pl.points.size(); ++ i) {
// The vertices and the midpoints of the segments between them.
++ points;
if (contains(grown, pl.points[i]))
++ too_close;
if (i + 1 < pl.points.size() && contains(grown, (pl.points[i] + pl.points[i + 1]) / 2))
++ too_close;
}
}
INFO("support layers checked " << layers_checked << " (unmatched " << layers_unmatched << "), support points " << points
<< ", within 0.2 mm of the part " << too_close);
CHECK(layers_checked > 20);
CHECK(layers_unmatched == 0);
REQUIRE(points > 0);
CHECK(too_close == 0);
}
// Two parts along the belt: the second part's slicing frame starts at the belt
// below its leading end, so its first layers are empty and interleave with the
// first part's printing layers. Those must not reach the G-code as layer changes
// that print nothing: the preview numbers its layers from the moves it sees, and
// a gap folded every later layer into the one before it.
TEST_CASE("Belt G-code has no layer that prints nothing", "[Print][belt][GCode][Regression]")
{
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 },
{ "brim_type", "outer_only" },
{ "brim_width", 4 },
{ "machine_start_gcode", "T[initial_tool]\n" },
{ "layer_change_gcode", "G92 E0\n" },
});
// Both export paths drop the empty layers and count the layers the same way.
SECTION("by layer") { config.set_deserialize_strict({{ "print_sequence", "by layer" }}); }
SECTION("by object") { config.set_deserialize_strict({{ "print_sequence", "by object" }}); }
Print print;
Model model;
TriangleMesh cube_a(its_make_cube(20., 20., 20.));
TriangleMesh cube_b(its_make_cube(20., 20., 20.));
init_print({ cube_a, cube_b }, print, model, config);
// 60 mm apart along the belt: the second cube's lead-in layers fall among the
// first cube's layers.
model.objects[0]->instances.front()->set_offset(Vec3d(50., 40., 0.));
model.objects[1]->instances.front()->set_offset(Vec3d(50., 100., 0.));
print.apply(model, config);
print.set_status_silent();
const std::string gc = gcode(print);
REQUIRE(! gc.empty());
size_t layers = 0, empty = 0, total_header = 0, total_count = 0;
bool extruded = true; // before the first layer change
auto close_layer = [&]() { if (! extruded) ++ empty; };
GCodeReader reader;
reader.apply_config(config);
reader.parse_buffer(gc, [&](GCodeReader &self, const GCodeReader::GCodeLine &line) {
const std::string &raw = line.raw();
if (raw.rfind(";LAYER_CHANGE", 0) == 0) {
close_layer();
++ layers;
extruded = false;
} else if (raw.rfind("; total layer number: ", 0) == 0) {
// Counted by the G-code processor from the layer changes it saw.
total_header = size_t(std::atoi(raw.c_str() + 22));
} else if (raw.rfind("; total layers count = ", 0) == 0) {
// GCode::m_layer_count, counted up front from the objects' layers; it also
// drives the M73 progress and the total_layer_count placeholder.
total_count = size_t(std::atoi(raw.c_str() + 23));
} else if (! extruded && line.extruding(self) && line.dist_XY(self) > EPSILON) {
// Material laid down along a move: a wipe or an unretraction does not count.
extruded = true;
}
});
close_layer();
INFO("layers " << layers << ", header " << total_header << ", count " << total_count
<< ", layers without extrusion " << empty);
CHECK(layers > 150); // both cubes, 141 layers each, overlapping along the belt
CHECK(empty == 0);
CHECK(total_header == layers);
CHECK(total_count == layers);
}
// A part with an overhang on its LEADING side (the end that prints first) needs
// supports below the object's own lowest slicing layer: the belt under that overhang
// is reached before the object's first contact with it, so the support layers sit at
// a lower slicing Z than any object layer. A generator that stops at the object's
// first layer, or at global Z = 0, leaves those supports floating above the belt.
TEST_CASE("Belt supports reach the belt under a leading overhang", "[Print][belt][Support][Regression]")
{
// default resolves to organic for tree support; tree_hybrid is the classic tree.
const char *support_type = GENERATE("normal(auto)", "tree(auto)");
const char *support_style = GENERATE("default", "organic", "tree_hybrid");
if (std::string(support_type) == "normal(auto)" && std::string(support_style) != "default")
return; // organic and tree_hybrid are tree styles
DYNAMIC_SECTION(support_type << " / " << support_style) {
// A 20 mm cube with a 2 mm thick fin that leaves its top edge and reaches
// 20 mm toward -Y, the end of the part that prints first, climbing at 45 deg
// as it goes (from z = 18 at the cube to z = 38 at the tip). With the layers
// leaning toward -Y at 45 deg the fin's underside is parallel to the layers:
// a ceiling 20 x 28 mm in one layer, with nothing but air between it and the
// belt, which lies up to 41 mm (of slicing Z) below the object's own lowest
// point. Support has to span all of it.
indexed_triangle_set its = its_make_cube(20., 20., 20.);
indexed_triangle_set fin = its_make_cube(20., 20., 2.);
Transform3d shear = Transform3d::Identity();
shear.matrix() << 1., 0., 0., 0.,
0., 1., 0., -20.,
0., -1., 1., 38.,
0., 0., 0., 1.;
its_transform(fin, shear);
its_merge(its, fin);
TriangleMesh mesh(std::move(its));
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", support_type },
{ "support_style", support_style },
{ "support_threshold_angle", 30 },
{ "machine_start_gcode", "T[initial_tool]\n" },
{ "layer_change_gcode", "G92 E0\n" },
});
Print print;
Model model;
init_print({ mesh }, print, model, config);
// On the bed, not at its corner: organic tree support clips its branches to
// the bed outline, and the fixture leaves the object at the origin.
model.objects.front()->instances.front()->set_offset(Vec3d(100., 100., 0.));
print.apply(model, config);
print.set_status_silent();
print.process();
const PrintObject &object = *print.objects().front();
REQUIRE(! object.layers().empty());
// The whole part is sliced: the layers lean at 45 deg, so the part spans
// (y + z) / sqrt(2) of slicing Z, and every layer in that span has geometry.
{
double lo = std::numeric_limits<double>::max(), hi = std::numeric_limits<double>::lowest();
for (const stl_vertex &v : mesh.its.vertices) {
lo = std::min<double>(lo, v.y() + v.z());
hi = std::max<double>(hi, v.y() + v.z());
}
const double span = (hi - lo) / std::sqrt(2.);
size_t nonempty = 0;
for (const Layer *layer : object.layers())
if (! layer->lslices.empty())
++ nonempty;
INFO("non-empty object layers " << nonempty << ", slicing span " << span << " mm");
CHECK(double(nonempty) * 0.2 > span - 0.6);
}
BeltFloorContext floor;
REQUIRE(floor.init(object.slicing_parameters(), print.config()));
// The lowest support layer that prints anything, and the belt floor beneath it.
const SupportLayer *lowest = nullptr;
for (const SupportLayer *layer : object.support_layers())
if (! layer->support_fills.empty() && (lowest == nullptr || layer->print_z < lowest->print_z))
lowest = layer;
REQUIRE(lowest != nullptr);
double floor_under_lowest = std::numeric_limits<double>::max();
for (const ExtrusionEntity *entity : lowest->support_fills.flatten().entities)
for (const Slic3r::Polyline &pl : entity->as_polylines())
for (const Point &pt : pl.points)
floor_under_lowest = std::min(floor_under_lowest, floor.floor_print_z(pt));
// The object's lowest geometry. The slicing frame starts at the lowest
// belt-floor point under the footprint, so the layers below the leading
// tip of the overhang are empty.
double first_object_z = std::numeric_limits<double>::max();
for (const Layer *layer : object.layers())
if (! layer->lslices.empty()) { first_object_z = layer->print_z; break; }
REQUIRE(first_object_z < std::numeric_limits<double>::max());
INFO("lowest support z " << lowest->print_z << ", floor under it " << floor_under_lowest
<< ", first object layer " << first_object_z);
// Well below the object's own lowest layer (the belt under the tip of the fin
// is ~41 mm of slicing Z below the cube's leading edge, which rests on it)...
CHECK(lowest->print_z < first_object_z - 5.);
// ...and resting on the belt: within a few layers of the floor beneath its own lines.
CHECK(lowest->print_z - floor_under_lowest < 4. * 0.2 + EPSILON);
CHECK(lowest->print_z - floor_under_lowest > -0.2 - EPSILON);
}
}
+6 -4
View File
@@ -512,12 +512,13 @@ TEST_CASE("Separated infill keeps fragmented and nested bodies independent", "[P
print.process();
const PrintObject &object = *print.objects().front();
REQUIRE(object.layer_count() > 1);
CHECK(object.separated_body_bboxes().size() == grid_size * grid_size + 2);
for (const Layer *layer : object.layers()) {
REQUIRE(layer->lslices.size() == grid_size * grid_size + 2);
REQUIRE(layer->lslices_separated_component_bboxes.size() == layer->lslices.size());
REQUIRE(layer->lslices_separated_component_ids.size() == layer->lslices.size());
size_t holes = 0;
for (size_t i = 0; i < layer->lslices.size(); ++ i) {
const BoundingBox &body = layer->lslices_separated_component_bboxes[i];
const BoundingBox &body = object.separated_body_bboxes()[layer->lslices_separated_component_ids[i]];
const BoundingBox &island = layer->lslices_bboxes[i];
CHECK(body.min == island.min);
CHECK(body.max == island.max);
@@ -574,6 +575,7 @@ TEST_CASE("Body centering survives islands merging and splitting between layers"
REQUIRE(object.get_layer(1)->lslices.size() == 3);
REQUIRE(object.get_layer(2)->lslices.size() == 3);
REQUIRE(object.get_layer(4)->lslices.size() == 5);
CHECK(object.separated_body_bboxes().size() == 2);
BoundingBox isolated_bbox = object.get_layer(0)->lslices_bboxes.front();
for (const BoundingBox &bbox : object.get_layer(0)->lslices_bboxes)
@@ -585,10 +587,10 @@ TEST_CASE("Body centering survives islands merging and splitting between layers"
if (bbox.min.x() < isolated_bbox.min.x())
connected_bbox.merge(bbox);
for (const Layer *layer : object.layers()) {
REQUIRE(layer->lslices_separated_component_bboxes.size() == layer->lslices.size());
REQUIRE(layer->lslices_separated_component_ids.size() == layer->lslices.size());
for (size_t i = 0; i < layer->lslices.size(); ++ i) {
const BoundingBox &expected = layer->lslices_bboxes[i].min.x() < isolated_bbox.min.x() ? connected_bbox : isolated_bbox;
const BoundingBox &actual = layer->lslices_separated_component_bboxes[i];
const BoundingBox &actual = object.separated_body_bboxes()[layer->lslices_separated_component_ids[i]];
CHECK(actual.min == expected.min);
CHECK(actual.max == expected.max);
}
+798 -1
View File
@@ -6,13 +6,15 @@
#include <catch2/catch_message.hpp>
#include <catch2/matchers/catch_matchers.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp>
#include "libslic3r/ClipperUtils.hpp"
#include "libslic3r/GCodeReader.hpp"
#include "libslic3r/Layer.hpp"
#include "libslic3r/Geometry.hpp"
#include "libslic3r/Geometry/ConvexHull.hpp"
#include "libslic3r/Layer.hpp"
#include <boost/algorithm/string.hpp>
#include <cctype>
#include <cmath>
#include <cstddef>
#include "libslic3r/Polygon.hpp"
@@ -24,6 +26,13 @@
#include "libslic3r/PrintConfig.hpp"
#include "libslic3r/libslic3r.h"
#include <string_view>
#include <limits>
#include <map>
#include <set>
#include <sstream>
#include <boost/algorithm/string/predicate.hpp>
#include <initializer_list>
#include <utility>
#include "test_helpers.hpp" // get access to init_print, etc
#include "libslic3r/BoundingBox.hpp"
@@ -32,6 +41,10 @@
#include "libslic3r/ExtrusionEntityCollection.hpp"
#include "libslic3r/ObjectID.hpp"
#include "libslic3r/Print.hpp"
#include "libslic3r/BeltBrim.hpp"
#include "libslic3r/Config.hpp"
#include "libslic3r/ExtrusionEntity.hpp"
#include "libslic3r/TriangleMesh.hpp"
using namespace Slic3r::Test;
using namespace Slic3r;
@@ -612,3 +625,787 @@ SCENARIO("Skirt and brim generation", "[SkirtBrim]") {
}
}
}
// Belt printers ---------------------------------------------------------------
//
// On a tilted belt the brim is laid onto the belt PLANE rather than into the Z=0
// bed plane, so it is spread across many layers instead of living on the first
// one. The discriminating measurement is the number of contiguous brim runs in
// the G-code: a flat plate brim gives a single run, a belt brim gives one per
// layer that carries a band. Distinct Z values are useless here, because the
// machine-frame transform couples Y into Z so every belt move has its own Z.
static DynamicPrintConfig belt_brim_config()
{
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 },
{ "top_shell_layers", 0 },
{ "bottom_shell_layers", 1 },
{ "machine_start_gcode", "T[initial_tool]\n" },
{ "layer_change_gcode", "G92 E0\n" },
});
return config;
}
// Same belt as belt_brim_config(), but with `filaments` distinct filaments so the brim's
// tool selection can be observed. Kept separate from belt_brim_config() so the existing
// single-filament belt tests are untouched.
static DynamicPrintConfig belt_brim_multifilament_config(unsigned int filaments,
std::initializer_list<Slic3r::ConfigBase::SetDeserializeItem> extra = {})
{
DynamicPrintConfig config = multifilament_config(filaments);
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 },
{ "top_shell_layers", 0 },
{ "bottom_shell_layers", 1 },
{ "machine_start_gcode", "T[initial_tool]\n" },
{ "layer_change_gcode", "G92 E0\n" },
});
if (extra.size() > 0)
config.set_deserialize_strict(extra);
return config;
}
// 0-based tool indices used by extrusions whose role comment contains `role` (needs
// gcode_comments). Mirrors tools_for_role in test_multifilament.cpp; statics do not cross
// translation units, so it is repeated here.
static std::set<int> belt_tools_for_role(const std::string &gcode, const std::string &role)
{
std::set<int> tools;
int current_tool = 0;
GCodeReader reader;
reader.parse_buffer(gcode, [&](GCodeReader &self, const GCodeReader::GCodeLine &line) {
const std::string cmd(line.cmd());
if (cmd.size() >= 2 && cmd[0] == 'T' && std::isdigit((unsigned char) cmd[1]))
current_tool = std::stoi(cmd.substr(1));
else if (line.extruding(self) && std::string(line.comment()).find(role) != std::string::npos)
tools.insert(current_tool);
});
return tools;
}
// Machine Z of the first extruding move whose role comment contains `role`, in file order;
// numeric_limits<double>::max() when the role never extrudes.
static double first_role_z(const std::string &gcode, const std::string &role)
{
double z = std::numeric_limits<double>::max();
GCodeReader parser;
parser.parse_buffer(gcode, [&z, &role](GCodeReader &self, const GCodeReader::GCodeLine &line) {
if (line.extruding(self) && line.comment().find(role) != std::string_view::npos) {
z = self.z();
self.quit_parsing();
}
});
return z;
}
// Number of object layers that carry a belt brim band. Every band prints at its own
// layer Z, so this equals role_layers(gcode, "brim") (plus any apron bands below the
// first object layer). It is not a pass count: the bands on the empty lead-in layers
// ahead of the object's first contact print back to back, so they fold into one pass.
static int nonempty_belt_brim_layers(const PrintObject &object)
{
int n = 0;
for (const ExtrusionEntityCollection &band : object.belt_brim_by_layer())
if (! band.empty())
++ n;
return n;
}
// Number of distinct Z heights at which `role` extrudes: one per layer that prints it.
static int role_layers(const std::string &gcode, const std::string &role)
{
std::set<long> zs;
GCodeReader reader;
reader.parse_buffer(gcode, [&](GCodeReader &self, const GCodeReader::GCodeLine &line) {
if (! line.extruding(self) || line.dist_XY(self) <= EPSILON)
return;
if (line.comment().find(role) != std::string_view::npos)
zs.insert(std::lround(self.z() * 1000.));
});
return int(zs.size());
}
// Apron bands below the object's first layer that print something.
static int belt_brim_apron_bands(const PrintObject &object)
{
int n = 0;
for (const BeltBrimBand &band : object.belt_brim_prologue())
if (! band.fills.empty())
++ n;
return n;
}
// For each active tool, the ordinal (1-based, over extruding moves) of the FIRST move whose
// role comment contains `role`. Lets a per-object ordering check key off the object's
// unique wall filament.
static std::map<int, long> first_move_by_tool(const std::string &gcode, const std::string &role)
{
std::map<int, long> first;
int tool = 0;
long idx = 0;
GCodeReader reader;
reader.parse_buffer(gcode, [&](GCodeReader &self, const GCodeReader::GCodeLine &line) {
const std::string cmd(line.cmd());
if (cmd.size() >= 2 && cmd[0] == 'T' && std::isdigit((unsigned char) cmd[1])) {
tool = std::stoi(cmd.substr(1));
return;
}
if (! line.extruding(self))
return;
++ idx;
if (std::string(line.comment()).find(role) != std::string::npos && ! first.count(tool))
first[tool] = idx;
});
return first;
}
// C - the band coincident with the object's FIRST contact with the belt must not be dropped:
// a belt brim has to appear at or below the object's first perimeter. On the unfixed feature
// the first-contact band is dropped and the first brim then appears only at a later (higher)
// layer. Machine Z is meaningful and shared between roles under the belt remap, so the first
// brim's Z must not exceed the first perimeter's. Both with and without support.
TEST_CASE("Belt brim is laid at the object's first belt contact", "[SkirtBrim][belt]")
{
const bool support = GENERATE(false, true);
DYNAMIC_SECTION("enable_support=" << support) {
DynamicPrintConfig config = belt_brim_config();
config.set_deserialize_strict({
{ "brim_type", "outer_only" },
{ "brim_width", 4 },
{ "leading_brim_length", 0 },
{ "extra_brim_width", 0 },
{ "brim_object_gap", 0 },
{ "enable_support", support ? 1 : 0 },
});
const std::string gcode = slice({ cube(20) }, config);
const double brim_z = first_role_z(gcode, "brim");
const double peri_z = first_role_z(gcode, "perimeter");
REQUIRE(brim_z < std::numeric_limits<double>::max());
REQUIRE(peri_z < std::numeric_limits<double>::max());
CHECK(brim_z <= peri_z + EPSILON);
}
}
// C control - when the band's own object layer has extrusion (any interior layer of a solid
// cube), the band takes the ordinary process_layer() path and must be drawn immediately
// before that layer's perimeters, and exactly once: never dropped, never double-emitted.
TEST_CASE("Belt brim on an object layer precedes its perimeters, 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);
// Ordering: the first thing extruded is brim, then perimeter.
const std::vector<std::string> seq = role_sequence(gc, { "brim", "perimeter" });
REQUIRE(seq.size() >= 2);
CHECK(seq[0] == "brim");
CHECK(seq[1] == "perimeter");
// Exactly once: every band prints at its own layer Z, so the number of Z heights with
// brim equals the number of bands - not fewer, which a dropped band would give. (A
// double emission would print twice at one Z: the pass count below catches that for
// the bands that sit on layers with perimeters.)
const PrintObject &object = *print.objects().front();
const int bands = nonempty_belt_brim_layers(object);
REQUIRE(bands > 0);
CHECK(role_layers(gc, "brim") == bands + belt_brim_apron_bands(object));
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
// band emitted twice back to back (two copies of the same band merge into one pass).
static long belt_brim_segments(const PrintObject &object)
{
auto segments = [](const ExtrusionEntityCollection &fills) {
long n = 0;
for (const ExtrusionEntity *entity : fills.flatten().entities)
for (const Polyline &pl : entity->as_polylines())
n += long(pl.size()) - 1;
return n;
};
long n = 0;
for (const ExtrusionEntityCollection &band : object.belt_brim_by_layer())
n += segments(band);
for (const BeltBrimBand &band : object.belt_brim_prologue())
n += segments(band.fills);
return n;
}
// Number of extruding moves in the G-code whose role is `role`.
static long role_segments(const std::string &gcode, const std::string &role)
{
long n = 0;
GCodeReader reader;
reader.parse_buffer(gcode, [&](GCodeReader &self, const GCodeReader::GCodeLine &line) {
if (line.extruding(self) && line.dist_XY(self) > EPSILON && line.comment().find(role) != std::string_view::npos)
++ n;
});
return n;
}
TEST_CASE("Belt brim writes every generated segment exactly once", "[SkirtBrim][belt]")
{
// The pass count above cannot tell one band from the same band twice in a row; the
// segment count can, so a brim band emitted twice back to back fails here.
DynamicPrintConfig config = belt_brim_config();
config.set_deserialize_strict({
{ "brim_type", "outer_only" },
{ "brim_width", 4 },
{ "brim_object_gap", 0 },
{ "leading_brim_length", 6 },
});
Print print;
Model model;
init_print({ cube(20) }, print, model, config);
const std::string gc = gcode(print);
const long expected = belt_brim_segments(*print.objects().front());
REQUIRE(expected > 100);
CHECK(role_segments(gc, "brim") == expected);
}
// B - multi extruder (wall filament id 2). Every belt-brim line must print on the object's
// wall filament (index 2 -> tool 1), and the total number of passes must equal the
// single-extruder baseline: no per-filament doubling.
TEST_CASE("Belt brim on a multi-extruder object uses the wall filament, no doubling", "[SkirtBrim][belt]")
{
// Single-extruder baseline built the same way (same nozzle/flow), so the band geometry -
// and thus the band count - is identical and only the filament assignment differs.
DynamicPrintConfig base = belt_brim_multifilament_config(1, {
{ "brim_type", "outer_only" },
{ "brim_width", 4 },
{ "brim_object_gap", 0 },
});
const int baseline = role_passes(slice({ cube(20) }, base), "brim");
REQUIRE(baseline > 0);
DynamicPrintConfig config = belt_brim_multifilament_config(2, {
{ "brim_type", "outer_only" },
{ "brim_width", 4 },
{ "brim_object_gap", 0 },
{ "outer_wall_filament_id", 2 },
{ "inner_wall_filament_id", 2 },
});
const std::string gc = slice({ cube(20) }, config);
CHECK(belt_tools_for_role(gc, "brim") == std::set<int>{ 1 }); // filament 2 -> tool 1
CHECK(role_passes(gc, "brim") == baseline);
}
// B - two objects offset ALONG the belt (Y, since the tilt is about X), each with its own
// wall filament. Each object's brim/apron must print on that object's filament AND before
// that object's own perimeters. The object is identified by its unique tool.
TEST_CASE("Belt brim of each object precedes its perimeters on its own filament", "[SkirtBrim][belt]")
{
DynamicPrintConfig config = belt_brim_multifilament_config(2, {
{ "brim_type", "outer_only" },
{ "brim_width", 4 },
{ "leading_brim_length", 6 },
{ "brim_object_gap", 0 },
});
std::vector<TriangleMesh> meshes;
meshes.emplace_back(cube(20));
TriangleMesh second = cube(20);
second.translate(0.f, 40.f, 0.f); // offset along the belt so it lands well after the first
meshes.emplace_back(std::move(second));
const std::vector<std::vector<Slic3r::ConfigBase::SetDeserializeItem>> overrides {
{ { "outer_wall_filament_id", 1 }, { "inner_wall_filament_id", 1 } },
{ { "outer_wall_filament_id", 2 }, { "inner_wall_filament_id", 2 } },
};
Print print;
Model model;
init_print(std::move(meshes), print, model, config, &overrides, /*arrange=*/false);
print.process();
const std::string gc = gcode(print);
// Both brims appear, each on its object's wall filament (1 -> T0, 2 -> T1).
CHECK(belt_tools_for_role(gc, "brim") == std::set<int>{ 0, 1 });
const std::map<int, long> brim_first = first_move_by_tool(gc, "brim");
const std::map<int, long> peri_first = first_move_by_tool(gc, "perimeter");
for (int tool : { 0, 1 }) {
REQUIRE(brim_first.count(tool) == 1);
REQUIRE(peri_first.count(tool) == 1);
CHECK(brim_first.at(tool) < peri_first.at(tool));
}
}
// D - the belt-brim predicate must not fire on a request that produces no belt brim.
// leading_brim_length / extra_brim_width only feed the OUTER ring, so inner_only with zero
// brim_width yields nothing and must not claim the layers the prime tower / spiral vase need.
TEST_CASE("Belt inner-only leading brim does not reject the prime tower or spiral vase", "[SkirtBrim][belt]")
{
auto inner_leading = [](std::initializer_list<Slic3r::ConfigBase::SetDeserializeItem> extra) {
DynamicPrintConfig config = belt_brim_config();
config.set_deserialize_strict({
{ "brim_type", "inner_only" },
{ "brim_width", 0 },
{ "leading_brim_length", 6 },
{ "brim_object_gap", 0 },
});
config.set_deserialize_strict(extra);
return config;
};
auto init_inner_leading_with_prime_tower = [](Print &print, Model &model, double brim_width) {
DynamicPrintConfig config = belt_brim_multifilament_config(2, {
{ "brim_type", "inner_only" },
{ "brim_width", brim_width },
{ "leading_brim_length", 6 },
{ "brim_object_gap", 0 },
{ "enable_prime_tower", 1 },
});
const std::vector<std::vector<Slic3r::ConfigBase::SetDeserializeItem>> overrides {
{ { "extruder", 1 } }, { { "extruder", 2 } },
};
init_print({ cube(20), cube(20) }, print, model, config, &overrides);
};
SECTION("prime tower is left alone") {
Print print;
Model model;
init_inner_leading_with_prime_tower(print, model, 0);
CHECK_FALSE(print.objects().front()->has_belt_brim());
CHECK(print.validate().string.empty());
}
SECTION("spiral vase is left alone") {
Print print;
Model model;
init_print({ cube(20) }, print, model, inner_leading({ { "spiral_mode", 1 } }));
CHECK_FALSE(print.objects().front()->has_belt_brim());
CHECK(print.validate().string.empty());
}
// enable_prime_tower stays on for any multi-filament project, but a belt printer never
// prints the classic tower, so the setting alone must not cost the print its brim.
SECTION("a real inner brim is accepted with the prime tower setting on") {
Print print;
Model model;
init_inner_leading_with_prime_tower(print, model, 4);
CHECK(print.objects().front()->has_belt_brim());
CHECK(print.validate().string.empty());
CHECK_FALSE(gcode(print).empty());
}
// A purge tower object is accepted too: the purge plan moves every object, apron
// bands included, onto one layer grid.
SECTION("a brim is accepted next to a belt purge tower object") {
DynamicPrintConfig config = belt_brim_multifilament_config(2, {
{ "brim_type", "outer_only" },
{ "brim_width", 4 },
{ "brim_object_gap", 0 },
{ "enable_belt_purge_tower", 1 },
});
const std::vector<std::vector<Slic3r::ConfigBase::SetDeserializeItem>> overrides {
{ { "extruder", 1 } }, { { "extruder", 2 } },
};
Print print;
Model model;
init_print({ cube(20), cube(20) }, print, model, config, &overrides);
model.objects.back()->config.set_key_value("belt_purge_tower_object", new ConfigOptionBool(true));
print.apply(model, config);
REQUIRE(print.has_belt_purge_tower());
CHECK(print.validate().string.empty());
CHECK_FALSE(gcode(print).empty());
}
}
TEST_CASE("Belt brim spans many layers instead of one", "[SkirtBrim][belt]")
{
DynamicPrintConfig config = belt_brim_config();
config.set_deserialize_strict({
{ "brim_type", "outer_only" },
{ "brim_width", 5 },
});
const std::string gcode = slice({ cube(20) }, config);
// A plate-brim implementation would score 1 here.
CHECK(role_passes(gcode, "brim") > 10);
}
TEST_CASE("Belt brim is absent when both widths are zero", "[SkirtBrim][belt]")
{
// The "no effect when disabled" guard: brim_type Auto is the shipped default and
// reports has_brim() even at width 0, so this also pins the gate that keeps the
// flat plate brim from running on a tilted belt.
const char *brim_type = GENERATE("auto_brim", "outer_only", "no_brim");
DYNAMIC_SECTION("brim_type " << brim_type) {
DynamicPrintConfig config = belt_brim_config();
config.set_deserialize_strict({
{ "brim_type", brim_type },
{ "brim_width", 0 },
{ "leading_brim_length", 0 },
{ "extra_brim_width", 0 },
});
const std::string gcode = slice({ cube(20) }, config);
CHECK(role_passes(gcode, "brim") == 0);
}
}
TEST_CASE("Leading brim length alone produces a belt brim", "[SkirtBrim][belt]")
{
// Exercises the leading_brim_length-only enablement path and the downhill sweep.
DynamicPrintConfig config = belt_brim_config();
config.set_deserialize_strict({
{ "brim_type", "outer_only" },
{ "brim_width", 0 },
{ "leading_brim_length", 5 },
{ "brim_object_gap", 0 },
});
const std::string gcode = slice({ cube(20) }, config);
CHECK(role_passes(gcode, "brim") > 0);
}
TEST_CASE("Leading brim length reaches further ahead of the object", "[SkirtBrim][belt]")
{
// Compared between two runs rather than against an absolute coordinate, so the
// assertion survives any change of origin or axis remap.
auto brim_extent = [](double extra) {
DynamicPrintConfig config = belt_brim_config();
config.set_deserialize_strict({
{ "brim_type", "outer_only" },
{ "brim_width", 3 },
{ "leading_brim_length", extra },
{ "brim_object_gap", 0 },
});
const std::string gcode = slice({ cube(20) }, config);
// The apron prints before the object reaches the belt, so it shows up as brim
// extrusion at the lowest machine Z of any brim move.
double min_z = std::numeric_limits<double>::max();
GCodeReader parser;
parser.parse_buffer(gcode, [&min_z](GCodeReader &self, const GCodeReader::GCodeLine &line) {
if (line.extruding(self) && line.comment().find("brim") != std::string_view::npos)
min_z = std::min(min_z, static_cast<double>(self.z()));
});
return min_z;
};
const double without = brim_extent(0.);
const double with = brim_extent(10.);
REQUIRE(without < std::numeric_limits<double>::max());
REQUIRE(with < std::numeric_limits<double>::max());
CHECK(with < without);
}
TEST_CASE("Every brim type slices on a belt printer", "[SkirtBrim][belt]")
{
// Auto / Mouse ear / Painted collapse to outer-only rather than crashing or
// silently producing nothing.
const char *brim_type = GENERATE("auto_brim", "brim_ears", "painted", "outer_only",
"inner_only", "outer_and_inner", "leading_edge_only", "no_brim");
DYNAMIC_SECTION("brim_type " << brim_type) {
DynamicPrintConfig config = belt_brim_config();
config.set_deserialize_strict({
{ "brim_type", brim_type },
{ "brim_width", 5 },
});
const std::string gcode = slice({ cube(20) }, config);
REQUIRE(! gcode.empty());
if (std::string(brim_type) == "no_brim")
CHECK(role_passes(gcode, "brim") == 0);
else if (std::string(brim_type) != "inner_only")
// A solid cube has no holes, so inner_only legitimately yields nothing.
CHECK(role_passes(gcode, "brim") > 0);
}
}
// The leading-edge-only brim is the outer brim cut down to the part's first contact
// with the belt. The cut has to be taken at the first layer that touches the belt: the
// slicing frame starts at the belt below the footprint, so layers().front() is an empty
// lead-in layer whose contact lies ahead of the part, and a cut taken there left no brim
// at all.
TEST_CASE("Leading-edge-only brim is laid at the first contact and nowhere else", "[SkirtBrim][belt][Regression]")
{
auto brim_gcode = [](const char *brim_type) {
DynamicPrintConfig config = belt_brim_config();
config.set_deserialize_strict({
{ "brim_type", brim_type },
{ "brim_width", 5 },
{ "leading_brim_length", 10 },
{ "extra_brim_width", 0 },
{ "brim_object_gap", 0 },
});
return slice({ cube(20) }, config);
};
const std::string leading = brim_gcode("leading_edge_only");
const std::string outer = brim_gcode("outer_only");
const double brim_z = first_role_z(leading, "brim");
const double peri_z = first_role_z(leading, "perimeter");
REQUIRE(brim_z < std::numeric_limits<double>::max());
REQUIRE(peri_z < std::numeric_limits<double>::max());
// At the first contact: the brim starts no later than the part does...
CHECK(brim_z <= peri_z + EPSILON);
// ...and stops there, while the outer brim keeps following the footprint.
const int leading_layers = role_layers(leading, "brim");
const int outer_layers = role_layers(outer, "brim");
INFO("brim layers: leading-edge " << leading_layers << ", outer " << outer_layers);
CHECK(leading_layers > 0);
CHECK(leading_layers < outer_layers);
}
// An overhang on the leading side is sliced before the part reaches the belt, so the
// first layer with geometry is the overhang's tip, above the belt. A leading-edge cut
// taken there lies ahead of the part: the brim shrank to a sliver well ahead of it, or
// vanished once the overhang reached further forward than the brim. The overhang does
// not touch the belt, so it must not change the brim at all.
TEST_CASE("Leading-edge-only brim ignores an overhang ahead of the part", "[SkirtBrim][belt][Regression]")
{
const double fin_length = GENERATE(30., 40.);
CAPTURE(fin_length);
// A 20 mm cube, with or without a 2 mm thick fin leaving its top edge and reaching
// `fin` toward -Y, the end of the part that prints first. The fin overlaps the cube
// by 1 mm so the two shells merge instead of sharing a face.
auto brim_layers = [](double fin) {
indexed_triangle_set its = its_make_cube(20., 20., 20.);
if (fin > 0.) {
indexed_triangle_set fin_its = its_make_cube(20., fin + 1., 2.);
its_translate(fin_its, Vec3f(0.f, float(-fin), 18.f));
its_merge(its, fin_its);
}
DynamicPrintConfig config = belt_brim_config();
config.set_deserialize_strict({
{ "brim_type", "leading_edge_only" },
{ "brim_width", 5 },
{ "leading_brim_length", 10 },
{ "extra_brim_width", 0 },
{ "brim_object_gap", 0 },
});
return role_layers(slice({ TriangleMesh(std::move(its)) }, config), "brim");
};
const int plain = brim_layers(0.);
const int with_fin = brim_layers(fin_length);
INFO("leading-edge brim layers: plain cube " << plain << ", with the fin " << with_fin);
REQUIRE(plain > 0);
// One layer of slack: the fin widens the part's footprint on the plate, which can
// move the layer grid by a fraction of a layer.
CHECK(std::abs(with_fin - plain) <= 1);
}
TEST_CASE("An untilted belt printer gets no brim", "[SkirtBrim][belt]")
{
// Belt brim needs a tilt to have a belt plane to lie on, and the flat plate brim
// cannot reach the G-code on any belt printer: it is emitted out of
// skirt_brim_groups(), which _make_skirt() builds, and that returns early for every
// belt printer. So an untilted belt printer gets nothing - unchanged by this
// feature. Making the flat brim work here would mean reopening the belt skirt gate,
// which is a separate change; Print::validate() warns instead.
DynamicPrintConfig config = belt_brim_config();
config.set_deserialize_strict({
{ "belt_slice_rotation", "none" },
{ "brim_type", "outer_only" },
{ "brim_width", 5 },
});
const std::string gcode = slice({ cube(20) }, config);
CHECK(role_passes(gcode, "brim") == 0);
}
TEST_CASE("Belt brim does not resurrect the skirt", "[SkirtBrim][belt]")
{
DynamicPrintConfig config = belt_brim_config();
config.set_deserialize_strict({
{ "brim_type", "outer_only" },
{ "brim_width", 5 },
{ "skirt_loops", 2 },
});
const std::string gcode = slice({ cube(20) }, config);
CHECK(role_passes(gcode, "skirt") == 0);
}
TEST_CASE("Belt brim lines all have the same width", "[SkirtBrim][belt]")
{
// Each brim line's extrusion volume comes from its nozzle-to-belt clearance. Anchoring
// every line to a fixed fraction of its own band gives them all the same clearance, so
// they all come out the same width. The nominal-spacing lattice this replaced let each
// line land wherever it fell inside its band, so the clearance - and the width with it -
// varied by 2x, which showed up as visibly ragged brim.
DynamicPrintConfig config = belt_brim_config();
config.set_deserialize_strict({
{ "brim_type", "outer_only" },
{ "brim_width", 5 },
{ "brim_object_gap", 0 },
});
Print print;
init_and_process_print({ cube(20) }, print, config);
const PrintObject *obj = print.objects().front();
std::vector<float> widths;
auto collect = [&widths](const ExtrusionEntityCollection &coll) {
for (const ExtrusionEntity *ee : coll.entities)
if (const auto *path = dynamic_cast<const ExtrusionPath *>(ee))
widths.push_back(path->width);
};
for (const ExtrusionEntityCollection &band : obj->belt_brim_by_layer())
collect(band);
for (const BeltBrimBand &band : obj->belt_brim_prologue())
collect(band.fills);
REQUIRE(widths.size() > 10);
const float lo = *std::min_element(widths.begin(), widths.end());
const float hi = *std::max_element(widths.begin(), widths.end());
CHECK_THAT(hi, Catch::Matchers::WithinRel(lo, 1e-4f));
}
TEST_CASE("Belt apron survives another object printing at the same Z", "[SkirtBrim][belt]")
{
// An apron band prints below its OWN object's first layer, but with two objects on the
// belt the second one is already printing at that print_z. The layer then has an
// object layer and takes the ordinary process_layer() path rather than the brim-only
// branch, so the band must be emitted from both or it is silently dropped. A
// single-object print cannot exercise this.
auto brim_passes = [](int object_count) {
DynamicPrintConfig config = belt_brim_config();
config.set_deserialize_strict({
{ "brim_type", "outer_only" },
{ "brim_width", 3 },
{ "leading_brim_length", 8 },
{ "brim_object_gap", 0 },
});
std::vector<TriangleMesh> meshes;
for (int i = 0; i < object_count; ++ i) {
TriangleMesh m = cube(20);
// Offset along the belt so the second object starts well after the first.
m.translate(0.f, float(40 * i), 0.f);
meshes.emplace_back(std::move(m));
}
Print print;
Model model;
init_print(std::move(meshes), print, model, config);
print.process();
return role_passes(gcode(print), "brim");
};
const int one = brim_passes(1);
const int two = brim_passes(2);
REQUIRE(one > 0);
// Two identical objects should carry twice the brim. Merely asserting `two > one`
// would not be decisive: the FIRST object's apron survives the bug, because nothing
// else is printing that early, so only the second object's apron goes missing.
// Requiring close to 2x is what actually detects the dropped bands.
CHECK(two >= 1.8 * one);
}
TEST_CASE("Belt brim coexists with support material", "[SkirtBrim][belt]")
{
// Supports put extra layers into the same z stream as the apron bands, which is what
// the three-way merge in collect_layers_to_print() exists to handle: a band sharing a
// print_z with a support layer of the SAME object used to overwrite it in the
// print-wide merge. A smoke test - it cannot prove the collision occurred - but it
// does exercise the merge with all three streams populated.
DynamicPrintConfig config = belt_brim_config();
config.set_deserialize_strict({
{ "brim_type", "outer_only" },
{ "brim_width", 4 },
{ "leading_brim_length", 6 },
{ "brim_object_gap", 0 },
{ "enable_support", 1 },
});
const std::string gc = slice({ TestMesh::overhang }, config);
REQUIRE(! gc.empty());
CHECK(role_passes(gc, "brim") > 0);
}
// With a 0.3 mm first layer at 45 degrees the brim band on the belt is wider than one bead,
// so its lines go on the nominal lattice instead of at a fixed fraction of the band. A
// lattice line can then land where the belt is almost at the band's print_z; it must be
// moved uphill to the same 0.75 fraction the single-line case uses, not laid scraping the
// belt with its flow clamped to half a layer.
TEST_CASE("Belt brim lattice lines keep their clearance above the belt", "[SkirtBrim][belt]")
{
DynamicPrintConfig config = belt_brim_config();
config.set_deserialize_strict({
{ "layer_height", 0.3 },
{ "initial_layer_print_height", 0.3 },
{ "brim_type", "outer_only" },
{ "brim_width", 4 },
{ "brim_object_gap", 0 },
});
const std::string gcode = slice({ cube(20) }, config);
// Heights of the brim extrusions, from the ;HEIGHT: tags inside ;TYPE:Brim sections.
std::vector<double> brim_heights;
bool in_brim = false;
std::istringstream lines(gcode);
for (std::string line; std::getline(lines, line); ) {
if (boost::starts_with(line, ";TYPE:"))
in_brim = boost::starts_with(line, ";TYPE:Brim");
else if (in_brim && boost::starts_with(line, ";HEIGHT:"))
brim_heights.push_back(std::stod(line.substr(8)));
}
REQUIRE(! brim_heights.empty());
for (const double h : brim_heights) {
CHECK(h >= 0.75 * 0.3 - 1e-3);
CHECK(h <= 0.3 + 1e-3);
}
}
// The brim prints in the object's outer wall filament even when every extrusion of the object
// is offered to purging (flush_into_objects): the tool ordering registers the brim filament
// itself, so the writer always knows it.
TEST_CASE("Belt brim slices when every object is a flush target", "[SkirtBrim][belt]")
{
DynamicPrintConfig config = belt_brim_multifilament_config(2, {
{ "brim_type", "outer_only" },
{ "brim_width", 4 },
{ "brim_object_gap", 0 },
{ "flush_into_objects", 1 },
{ "flush_into_infill", 1 },
});
const std::vector<std::vector<Slic3r::ConfigBase::SetDeserializeItem>> overrides {
{ { "extruder", 1 } }, { { "extruder", 2 } },
};
Print print;
Model model;
init_print({ cube(20), cube(20) }, print, model, config, &overrides);
REQUIRE(print.validate().string.empty());
const std::string out = gcode(print);
CHECK(out.find(";TYPE:Brim") != std::string::npos);
}
+58 -60
View File
@@ -17,20 +17,18 @@
#include <map>
#include <unordered_map>
using json = nlohmann::json;
// Put serializers in correct ADL namespaces for each type
namespace Slic3r {
namespace FilamentGroupUtils {
inline void to_json(json& j, const Color& c) {
inline void to_json(nlohmann::json& j, const Color& c) {
char buf[10];
snprintf(buf, sizeof(buf), "#%02X%02X%02X%02X", c.r, c.g, c.b, c.a);
j = std::string(buf);
}
inline void from_json(const json& j, Color& c) {
inline void from_json(const nlohmann::json& j, Color& c) {
std::string s = j.get<std::string>();
if (s.size() >= 7 && s[0] == '#') {
c.r = (unsigned char)std::stoi(s.substr(1, 2), nullptr, 16);
@@ -40,8 +38,8 @@ inline void from_json(const json& j, Color& c) {
}
}
inline void to_json(json& j, const FilamentInfo& fi) {
j = json{
inline void to_json(nlohmann::json& j, const FilamentInfo& fi) {
j = nlohmann::json{
{"color", fi.color},
{"type", fi.type},
{"is_support", fi.is_support},
@@ -49,15 +47,15 @@ inline void to_json(json& j, const FilamentInfo& fi) {
};
}
inline void from_json(const json& j, FilamentInfo& fi) {
inline void from_json(const nlohmann::json& j, FilamentInfo& fi) {
fi.color = j.at("color").get<Color>();
j.at("type").get_to(fi.type);
j.at("is_support").get_to(fi.is_support);
fi.usage_type = (FilamentUsageType)j.at("usage_type").get<int>();
}
inline void to_json(json& j, const MachineFilamentInfo& mfi) {
j = json{
inline void to_json(nlohmann::json& j, const MachineFilamentInfo& mfi) {
j = nlohmann::json{
{"color", mfi.color},
{"type", mfi.type},
{"is_support", mfi.is_support},
@@ -67,7 +65,7 @@ inline void to_json(json& j, const MachineFilamentInfo& mfi) {
};
}
inline void from_json(const json& j, MachineFilamentInfo& mfi) {
inline void from_json(const nlohmann::json& j, MachineFilamentInfo& mfi) {
mfi.color = j.at("color").get<Color>();
j.at("type").get_to(mfi.type);
j.at("is_support").get_to(mfi.is_support);
@@ -80,8 +78,8 @@ inline void from_json(const json& j, MachineFilamentInfo& mfi) {
namespace MultiNozzleUtils {
inline void to_json(json& j, const NozzleInfo& ni) {
j = json{
inline void to_json(nlohmann::json& j, const NozzleInfo& ni) {
j = nlohmann::json{
{"diameter", ni.diameter},
{"volume_type", (int)ni.volume_type},
{"extruder_id", ni.extruder_id},
@@ -89,15 +87,15 @@ inline void to_json(json& j, const NozzleInfo& ni) {
};
}
inline void from_json(const json& j, NozzleInfo& ni) {
inline void from_json(const nlohmann::json& j, NozzleInfo& ni) {
j.at("diameter").get_to(ni.diameter);
ni.volume_type = (NozzleVolumeType)j.at("volume_type").get<int>();
j.at("extruder_id").get_to(ni.extruder_id);
j.at("group_id").get_to(ni.group_id);
}
inline void to_json(json& j, const FilamentChangeTimeParams& p) {
j = json{
inline void to_json(nlohmann::json& j, const FilamentChangeTimeParams& p) {
j = nlohmann::json{
{"selector_load_time", p.selector_load_time},
{"selector_unload_time", p.selector_unload_time},
{"standard_load_time", p.standard_load_time},
@@ -105,7 +103,7 @@ inline void to_json(json& j, const FilamentChangeTimeParams& p) {
};
}
inline void from_json(const json& j, FilamentChangeTimeParams& p) {
inline void from_json(const nlohmann::json& j, FilamentChangeTimeParams& p) {
j.at("selector_load_time").get_to(p.selector_load_time);
j.at("selector_unload_time").get_to(p.selector_unload_time);
j.at("standard_load_time").get_to(p.standard_load_time);
@@ -116,22 +114,22 @@ inline void from_json(const json& j, FilamentChangeTimeParams& p) {
// ============ Helper: set<int> as JSON array ============
namespace FGTestDetail {
inline json set_to_json(const std::set<int>& s) {
return json(std::vector<int>(s.begin(), s.end()));
inline nlohmann::json set_to_json(const std::set<int>& s) {
return nlohmann::json(std::vector<int>(s.begin(), s.end()));
}
inline std::set<int> json_to_set(const json& j) {
inline std::set<int> json_to_set(const nlohmann::json& j) {
auto v = j.get<std::vector<int>>();
return std::set<int>(v.begin(), v.end());
}
inline json nvt_set_to_json(const std::set<NozzleVolumeType>& s) {
inline nlohmann::json nvt_set_to_json(const std::set<NozzleVolumeType>& s) {
std::vector<int> v;
for (auto t : s) v.push_back((int)t);
return json(v);
return nlohmann::json(v);
}
inline std::set<NozzleVolumeType> json_to_nvt_set(const json& j) {
inline std::set<NozzleVolumeType> json_to_nvt_set(const nlohmann::json& j) {
std::set<NozzleVolumeType> s;
for (auto& item : j) s.insert((NozzleVolumeType)item.get<int>());
return s;
@@ -139,28 +137,28 @@ inline std::set<NozzleVolumeType> json_to_nvt_set(const json& j) {
} // namespace FGTestDetail
// ============ FilamentGroupContext::ModelInfo ============
inline void to_json(json& j, const FilamentGroupContext::ModelInfo& mi) {
inline void to_json(nlohmann::json& j, const FilamentGroupContext::ModelInfo& mi) {
using namespace FGTestDetail;
j["flush_matrix"] = mi.flush_matrix;
j["layer_filaments"] = mi.layer_filaments;
j["filament_info"] = json::array();
j["filament_info"] = nlohmann::json::array();
for (auto& fi : mi.filament_info)
j["filament_info"].push_back(fi);
j["filament_ids"] = mi.filament_ids;
j["unprintable_filaments"] = json::array();
j["unprintable_filaments"] = nlohmann::json::array();
for (auto& s : mi.unprintable_filaments)
j["unprintable_filaments"].push_back(set_to_json(s));
json uv = json::object();
nlohmann::json uv = nlohmann::json::object();
for (auto& [fil, types] : mi.unprintable_volumes)
uv[std::to_string(fil)] = nvt_set_to_json(types);
j["unprintable_volumes"] = uv;
}
inline void from_json(const json& j, FilamentGroupContext::ModelInfo& mi) {
inline void from_json(const nlohmann::json& j, FilamentGroupContext::ModelInfo& mi) {
using namespace FGTestDetail;
j.at("flush_matrix").get_to(mi.flush_matrix);
j.at("layer_filaments").get_to(mi.layer_filaments);
@@ -183,8 +181,8 @@ inline void from_json(const json& j, FilamentGroupContext::ModelInfo& mi) {
}
// ============ FilamentGroupContext::GroupInfo ============
inline void to_json(json& j, const FilamentGroupContext::GroupInfo& gi) {
j = json{
inline void to_json(nlohmann::json& j, const FilamentGroupContext::GroupInfo& gi) {
j = nlohmann::json{
{"total_filament_num", gi.total_filament_num},
{"max_gap_threshold", gi.max_gap_threshold},
{"mode", (int)gi.mode},
@@ -195,7 +193,7 @@ inline void to_json(json& j, const FilamentGroupContext::GroupInfo& gi) {
};
}
inline void from_json(const json& j, FilamentGroupContext::GroupInfo& gi) {
inline void from_json(const nlohmann::json& j, FilamentGroupContext::GroupInfo& gi) {
j.at("total_filament_num").get_to(gi.total_filament_num);
j.at("max_gap_threshold").get_to(gi.max_gap_threshold);
gi.mode = (FGMode)j.at("mode").get<int>();
@@ -206,12 +204,12 @@ inline void from_json(const json& j, FilamentGroupContext::GroupInfo& gi) {
}
// ============ FilamentGroupContext::MachineInfo ============
inline void to_json(json& j, const FilamentGroupContext::MachineInfo& mi) {
inline void to_json(nlohmann::json& j, const FilamentGroupContext::MachineInfo& mi) {
j["max_group_size"] = mi.max_group_size;
j["machine_filament_info"] = json::array();
j["machine_filament_info"] = nlohmann::json::array();
for (auto& vec : mi.machine_filament_info) {
json arr = json::array();
nlohmann::json arr = nlohmann::json::array();
for (auto& mfi : vec) arr.push_back(mfi);
j["machine_filament_info"].push_back(arr);
}
@@ -220,7 +218,7 @@ inline void to_json(json& j, const FilamentGroupContext::MachineInfo& mi) {
j["master_extruder_id"] = mi.master_extruder_id;
}
inline void from_json(const json& j, FilamentGroupContext::MachineInfo& mi) {
inline void from_json(const nlohmann::json& j, FilamentGroupContext::MachineInfo& mi) {
j.at("max_group_size").get_to(mi.max_group_size);
mi.machine_filament_info.clear();
@@ -236,10 +234,10 @@ inline void from_json(const json& j, FilamentGroupContext::MachineInfo& mi) {
}
// ============ FilamentGroupContext::SpeedInfo ============
inline void to_json(json& j, const FilamentGroupContext::SpeedInfo& si) {
json fpt = json::object();
inline void to_json(nlohmann::json& j, const FilamentGroupContext::SpeedInfo& si) {
nlohmann::json fpt = nlohmann::json::object();
for (auto& [fil, inner] : si.filament_print_time) {
json inner_j = json::object();
nlohmann::json inner_j = nlohmann::json::object();
for (auto& [layer, time] : inner)
inner_j[std::to_string(layer)] = time;
fpt[std::to_string(fil)] = inner_j;
@@ -252,7 +250,7 @@ inline void to_json(json& j, const FilamentGroupContext::SpeedInfo& si) {
j["ams_preload_enabled"] = si.ams_preload_enabled;
}
inline void from_json(const json& j, FilamentGroupContext::SpeedInfo& si) {
inline void from_json(const nlohmann::json& j, FilamentGroupContext::SpeedInfo& si) {
si.filament_print_time.clear();
if (j.contains("filament_print_time")) {
for (auto& [k, v] : j.at("filament_print_time").items()) {
@@ -269,23 +267,23 @@ inline void from_json(const json& j, FilamentGroupContext::SpeedInfo& si) {
}
// ============ FilamentGroupContext::NozzleInfo ============
inline void to_json(json& j, const FilamentGroupContext::NozzleInfo& ni) {
json enl = json::object();
inline void to_json(nlohmann::json& j, const FilamentGroupContext::NozzleInfo& ni) {
nlohmann::json enl = nlohmann::json::object();
for (auto& [ext, nozzles] : ni.extruder_nozzle_list)
enl[std::to_string(ext)] = nozzles;
j["extruder_nozzle_list"] = enl;
j["nozzle_list"] = json::array();
j["nozzle_list"] = nlohmann::json::array();
for (auto& n : ni.nozzle_list)
j["nozzle_list"].push_back(n);
json ns = json::object();
nlohmann::json ns = nlohmann::json::object();
for (auto& [noz, fil] : ni.nozzle_status)
ns[std::to_string(noz)] = fil;
j["nozzle_status"] = ns;
}
inline void from_json(const json& j, FilamentGroupContext::NozzleInfo& ni) {
inline void from_json(const nlohmann::json& j, FilamentGroupContext::NozzleInfo& ni) {
ni.extruder_nozzle_list.clear();
for (auto& [k, v] : j.at("extruder_nozzle_list").items())
ni.extruder_nozzle_list[std::stoi(k)] = v.get<std::vector<int>>();
@@ -302,8 +300,8 @@ inline void from_json(const json& j, FilamentGroupContext::NozzleInfo& ni) {
}
// ============ Full FilamentGroupContext ============
inline void to_json(json& j, const FilamentGroupContext& ctx) {
json mi, gi, mai, si, ni;
inline void to_json(nlohmann::json& j, const FilamentGroupContext& ctx) {
nlohmann::json mi, gi, mai, si, ni;
to_json(mi, ctx.model_info);
to_json(gi, ctx.group_info);
to_json(mai, ctx.machine_info);
@@ -316,7 +314,7 @@ inline void to_json(json& j, const FilamentGroupContext& ctx) {
j["nozzle_info"] = ni;
}
inline void from_json(const json& j, FilamentGroupContext& ctx) {
inline void from_json(const nlohmann::json& j, FilamentGroupContext& ctx) {
from_json(j.at("model_info"), ctx.model_info);
from_json(j.at("group_info"), ctx.group_info);
from_json(j.at("machine_info"), ctx.machine_info);
@@ -336,11 +334,11 @@ struct TestMetadata {
int seed = 0;
};
inline void to_json(json& j, const TestMetadata& m) {
j = json{{"id", m.id}, {"config_type", m.config_type}, {"seed", m.seed}};
inline void to_json(nlohmann::json& j, const TestMetadata& m) {
j = nlohmann::json{{"id", m.id}, {"config_type", m.config_type}, {"seed", m.seed}};
}
inline void from_json(const json& j, TestMetadata& m) {
inline void from_json(const nlohmann::json& j, TestMetadata& m) {
j.at("id").get_to(m.id);
j.at("config_type").get_to(m.config_type);
j.at("seed").get_to(m.seed);
@@ -354,8 +352,8 @@ struct TestResult {
std::vector<std::string> violations;
};
inline void to_json(json& j, const TestResult& r) {
j = json{
inline void to_json(nlohmann::json& j, const TestResult& r) {
j = nlohmann::json{
{"filament_map", r.filament_map},
{"flush_cost", r.flush_cost},
{"elapsed_ms", r.elapsed_ms},
@@ -364,7 +362,7 @@ inline void to_json(json& j, const TestResult& r) {
};
}
inline void from_json(const json& j, TestResult& r) {
inline void from_json(const nlohmann::json& j, TestResult& r) {
j.at("filament_map").get_to(r.filament_map);
j.at("flush_cost").get_to(r.flush_cost);
j.at("elapsed_ms").get_to(r.elapsed_ms);
@@ -380,15 +378,15 @@ struct BaseResult {
bool constraints_ok = true;
};
inline void to_json(json& j, const BaseResult& g) {
j = json{
inline void to_json(nlohmann::json& j, const BaseResult& g) {
j = nlohmann::json{
{"full_score", g.full_score},
{"flush_cost", g.flush_cost},
{"constraints_ok", g.constraints_ok}
};
}
inline void from_json(const json& j, BaseResult& g) {
inline void from_json(const nlohmann::json& j, BaseResult& g) {
j.at("full_score").get_to(g.full_score);
j.at("flush_cost").get_to(g.flush_cost);
j.at("constraints_ok").get_to(g.constraints_ok);
@@ -403,7 +401,7 @@ struct TestCase {
inline TestCase load_test_case(const std::string& path) {
std::ifstream f(path);
json j = json::parse(f);
nlohmann::json j = nlohmann::json::parse(f);
TestCase tc;
tc.metadata = j.at("metadata").get<TestMetadata>();
Slic3r::from_json(j.at("context"), tc.context);
@@ -413,9 +411,9 @@ inline TestCase load_test_case(const std::string& path) {
}
inline void save_test_case(const std::string& path, const TestCase& tc) {
json j;
nlohmann::json j;
j["metadata"] = tc.metadata;
json ctx_j;
nlohmann::json ctx_j;
Slic3r::to_json(ctx_j, tc.context);
j["context"] = ctx_j;
if (tc.base_result)
@@ -432,14 +430,14 @@ inline void save_result(const std::string& case_path, const TestResult& result)
else
result_path += ".result.json";
json j = result;
nlohmann::json j = result;
std::ofstream f(result_path);
f << j.dump(2);
}
inline TestResult load_result(const std::string& result_path) {
std::ifstream f(result_path);
json j = json::parse(f);
nlohmann::json j = nlohmann::json::parse(f);
return j.get<TestResult>();
}
+8
View File
@@ -3,6 +3,7 @@ get_filename_component(_TEST_NAME ${CMAKE_CURRENT_LIST_DIR} NAME)
add_executable(${_TEST_NAME}_tests
${_TEST_NAME}_tests.cpp
test_3mf.cpp
test_amf.cpp
# Round-trip seam metadata and active/dormant volume settings in both formats.
test_precise_seam_3mf.cpp
# Pure perimeter extraction is independent of Print/Layer fixtures.
@@ -13,6 +14,7 @@ add_executable(${_TEST_NAME}_tests
test_arrange.cpp
test_assemble_list.cpp
test_bambu_networking.cpp
test_belt_brim.cpp
test_buildvolume.cpp
test_calib.cpp
test_clipper_offset.cpp
@@ -34,9 +36,14 @@ add_executable(${_TEST_NAME}_tests
test_fill_corner_smoothing.cpp
test_filament_mixer.cpp
test_fill_plane_path.cpp
test_fill_tpms_adaptive.cpp
test_geometry.cpp
test_imex_arrange.cpp
test_imex_helpers.cpp
test_imex_zones.cpp
test_multimaterial_segmentation.cpp
test_placeholder_parser.cpp
test_png_read_write.cpp
test_polygon.cpp
test_mutable_polygon.cpp
test_mutable_priority_queue.cpp
@@ -62,6 +69,7 @@ add_executable(${_TEST_NAME}_tests
test_ordering_strategies.cpp
# test_png_io.cpp
test_indexed_triangle_set.cpp
test_connected_bodies.cpp
test_texture_displacement.cpp
test_instance_lock.cpp
../libnest2d/printer_parts.cpp
+332
View File
@@ -8,6 +8,7 @@
#include "libslic3r/Format/3mf.hpp"
#include "libslic3r/Format/bbs_3mf.hpp"
#include "libslic3r/Format/STL.hpp"
#include "libslic3r/Utils.hpp"
#include "libslic3r/miniz_extension.hpp"
#include "libslic3r/Zipper.hpp"
#include "libslic3r/PrintConfig.hpp"
@@ -45,6 +46,7 @@
#include <sstream>
#include <type_traits> // for std::enable_if_t
#include <typeinfo> // for typeid
#include <regex>
#include <vector>
#include <utility>
@@ -415,6 +417,68 @@ TEST_CASE("A project with a plate id below 1 fails to load", "[3mf][Regression]"
REQUIRE_FALSE(loaded);
}
TEST_CASE("A project whose components reference themselves fails to load", "[3mf][Regression]")
{
// One self-reference keeps the expansion going without ever reaching a mesh. A thousand also make
// each expansion queue a thousand more, so the bound has to hold the work list, not just the loop.
const int references = GENERATE(1, 1000);
INFO("self-references " << references);
ScopedTemporaryFile temp(".3mf");
store_painted_cube(temp.string());
// Point the component back at the object that holds it, repeated `references` times.
REQUIRE(rewrite_3mf_entries(temp.string(), [references](std::string& name, std::string& data) {
if (!boost::algorithm::ends_with(name, "3dmodel.model"))
return false;
std::smatch match;
if (!std::regex_search(data, match, std::regex("<object id=\"([0-9]+)\"[^>]*>\\s*<components")))
return false;
data = std::regex_replace(data, std::regex("objectid=\"[0-9]+\""), "objectid=\"" + match[1].str() + "\"");
std::smatch component;
if (!std::regex_search(data, component, std::regex("<component [^>]*/>")))
return false;
std::string repeated;
for (int i = 0; i < references; ++i)
repeated += component.str();
data.replace(component.position(), component.length(), repeated);
return true;
}));
ScopedTemporaryDir backup_dir("orca_cycle_dst");
Model model;
bool loaded = true;
REQUIRE_NOTHROW(loaded = load_project(temp.string(), model, backup_dir));
REQUIRE_FALSE(loaded);
}
TEST_CASE("An object loads up to the component reference budget and fails past it", "[3mf][Regression]")
{
// The importer queues at most 100000 component references per object. Every reference besides the
// cube's own points at an object the file does not define: it counts toward the budget, then expands
// to nothing, so the object stays a single part whatever the count.
const auto [references, loads] = GENERATE(table<int, bool>({ { 100000, true }, { 100001, false } }));
INFO("component references " << references);
ScopedTemporaryFile temp(".3mf");
store_painted_cube(temp.string());
std::string dangling;
for (int i = 1; i < references; ++i)
dangling += "<component objectid=\"999999\"/>";
REQUIRE(replace_in_3mf_entry(temp.string(), "3dmodel.model", "</components>", dangling + "</components>"));
ScopedTemporaryDir backup_dir("orca_budget_dst");
Model model;
bool loaded = !loads;
REQUIRE_NOTHROW(loaded = load_project(temp.string(), model, backup_dir));
REQUIRE(loaded == loads);
if (loads) {
REQUIRE(model.objects.size() == 1);
CHECK(model.objects.front()->volumes.size() == 1);
}
}
TEST_CASE("A project with malformed paint data loads without the damaged facet", "[3mf][Regression]")
{
ScopedTemporaryFile temp(".3mf");
@@ -946,6 +1010,237 @@ SCENARIO("Nozzle-group metadata .3mf round-trip", "[3mf][MultiNozzle]") {
}
}
SCENARIO("BBS 3MF round-trips per-plate IMEX state (parallel mode + head filament map)", "[3mf][IMEX]") {
// Regression guard for the class of bug where per-plate state silently drops through
// save/load (the variant-truncation bug was the precipitating example; IMEX plate state
// rides the same XML metadata path and is equally vulnerable).
//
// The mode name is user-typed free text, so the values below deliberately carry every
// character class that is special inside an XML attribute value:
// & and < must be escaped or the document is not well formed and the project will
// not load at all,
// " must be escaped or it terminates the attribute early,
// tab must be written as a numeric character reference, because XML normalizes
// literal whitespace in attribute values on read and the mode would be
// silently renamed,
// ' and > are legal raw inside a double-quoted value and must survive untouched.
// The head filament map value is machine generated in practice; it is given hostile
// content here only to pin the escaping of the sibling attribute, so this asserts the
// XML transport, not the map grammar (the loader stores the string verbatim).
//
// BBS exporter scaffolds a backup dir under temporary_dir() for the project config file;
// point it at a writable location for the test process.
set_temporary_dir(boost::filesystem::temp_directory_path().string());
const std::string hostile_mode = "PLA & ABS <hot> \"2x\"\tcopy's mode";
const std::string hostile_hfm = "1:2,2:3 & <\"\tx>";
GIVEN("A Model with a single object on plate 0 and IMEX plate state set") {
Model src_model;
std::string src_file = std::string(TEST_DATA_DIR) + "/test_3mf/Prusa.stl";
REQUIRE(load_stl(src_file.c_str(), &src_model));
src_model.add_default_instances();
DynamicPrintConfig src_config;
PlateDataPtrs src_plates;
auto *plate0 = new PlateData();
plate0->plate_index = 0;
plate0->config.set_key_value("imex_parallel_mode", new ConfigOptionString(hostile_mode));
plate0->config.set_key_value("imex_head_filament_map", new ConfigOptionString(hostile_hfm));
src_plates.push_back(plate0);
WHEN("the model is saved to BBS 3MF and loaded back") {
std::string test_file = std::string(TEST_DATA_DIR) + "/test_3mf/imex_roundtrip.3mf";
StoreParams store_params;
store_params.path = test_file.c_str();
store_params.model = &src_model;
store_params.plate_data_list = src_plates;
store_params.config = &src_config;
REQUIRE(store_bbs_3mf(store_params));
Model dst_model;
DynamicPrintConfig dst_config;
PlateDataPtrs dst_plates;
std::vector<Preset*> dst_presets;
bool is_bbl = false;
bool is_orca = false;
Semver file_version;
ConfigSubstitutionContext ctxt{ ForwardCompatibilitySubstitutionRule::Disable };
bool loaded = load_bbs_3mf(test_file.c_str(), &dst_config, &ctxt, &dst_model,
&dst_plates, &dst_presets, &is_bbl, &is_orca, &file_version);
boost::filesystem::remove(test_file);
THEN("load succeeds") {
REQUIRE(loaded);
}
THEN("the loaded plate list has the same number of plates") {
REQUIRE(dst_plates.size() == src_plates.size());
}
THEN("imex_parallel_mode round-trips byte for byte, XML metacharacters included") {
REQUIRE(dst_plates.size() >= 1);
auto *mode_opt = dst_plates[0]->config.option<ConfigOptionString>("imex_parallel_mode");
REQUIRE(mode_opt != nullptr);
REQUIRE(mode_opt->value == hostile_mode);
}
THEN("imex_head_filament_map round-trips byte for byte, XML metacharacters included") {
REQUIRE(dst_plates.size() >= 1);
auto *hfm_opt = dst_plates[0]->config.option<ConfigOptionString>("imex_head_filament_map");
REQUIRE(hfm_opt != nullptr);
REQUIRE(hfm_opt->value == hostile_hfm);
}
release_PlateData_list(dst_plates);
}
release_PlateData_list(src_plates);
}
}
SCENARIO("BBS 3MF round-trips distinct IMEX state across multiple plates", "[3mf][IMEX]") {
// Guards against a plate-indexing regression where IMEX metadata lands on the wrong
// plate or bleeds across plates on reload. Each plate carries distinct mode + head-
// filament-map values; the reload must reproduce them in the same order.
set_temporary_dir(boost::filesystem::temp_directory_path().string());
GIVEN("A Model with two plates each carrying different IMEX state") {
Model src_model;
std::string src_file = std::string(TEST_DATA_DIR) + "/test_3mf/Prusa.stl";
REQUIRE(load_stl(src_file.c_str(), &src_model));
src_model.add_default_instances();
DynamicPrintConfig src_config;
PlateDataPtrs src_plates;
auto *plate0 = new PlateData();
plate0->plate_index = 0;
plate0->config.set_key_value("imex_parallel_mode", new ConfigOptionString("copy_mode"));
plate0->config.set_key_value("imex_head_filament_map", new ConfigOptionString("1:2"));
src_plates.push_back(plate0);
auto *plate1 = new PlateData();
plate1->plate_index = 1;
plate1->config.set_key_value("imex_parallel_mode", new ConfigOptionString("mirror_mode"));
plate1->config.set_key_value("imex_head_filament_map", new ConfigOptionString("2:4,3:5"));
src_plates.push_back(plate1);
WHEN("the model is saved to BBS 3MF and loaded back") {
std::string test_file = std::string(TEST_DATA_DIR) + "/test_3mf/imex_multiplate_roundtrip.3mf";
StoreParams store_params;
store_params.path = test_file.c_str();
store_params.model = &src_model;
store_params.plate_data_list = src_plates;
store_params.config = &src_config;
REQUIRE(store_bbs_3mf(store_params));
Model dst_model;
DynamicPrintConfig dst_config;
PlateDataPtrs dst_plates;
std::vector<Preset*> dst_presets;
bool is_bbl = false;
bool is_orca = false;
Semver file_version;
ConfigSubstitutionContext ctxt{ ForwardCompatibilitySubstitutionRule::Disable };
bool loaded = load_bbs_3mf(test_file.c_str(), &dst_config, &ctxt, &dst_model,
&dst_plates, &dst_presets, &is_bbl, &is_orca, &file_version);
boost::filesystem::remove(test_file);
THEN("load succeeds and both plates are returned") {
REQUIRE(loaded);
REQUIRE(dst_plates.size() == 2);
}
THEN("plate 0 retains its own IMEX state (copy_mode, 1:2) and does not inherit plate 1's") {
REQUIRE(dst_plates.size() == 2);
auto *mode = dst_plates[0]->config.option<ConfigOptionString>("imex_parallel_mode");
auto *hfm = dst_plates[0]->config.option<ConfigOptionString>("imex_head_filament_map");
REQUIRE(mode != nullptr);
REQUIRE(hfm != nullptr);
REQUIRE(mode->value == "copy_mode");
REQUIRE(hfm->value == "1:2");
}
THEN("plate 1 retains its own IMEX state (mirror_mode, 2:4,3:5) and does not inherit plate 0's") {
REQUIRE(dst_plates.size() == 2);
auto *mode = dst_plates[1]->config.option<ConfigOptionString>("imex_parallel_mode");
auto *hfm = dst_plates[1]->config.option<ConfigOptionString>("imex_head_filament_map");
REQUIRE(mode != nullptr);
REQUIRE(hfm != nullptr);
REQUIRE(mode->value == "mirror_mode");
REQUIRE(hfm->value == "2:4,3:5");
}
release_PlateData_list(dst_plates);
}
release_PlateData_list(src_plates);
}
}
SCENARIO("BBS 3MF does not emit IMEX metadata when plate is in primary mode", "[3mf][IMEX]") {
// The serialization guard short-circuits when the mode is empty or "primary", so loading
// a plate that was saved in primary mode must not leave a stale imex_parallel_mode option
// on the plate's config. If this regressed, we'd see ghost "primary" strings appearing on
// plates that had no IMEX state at all.
set_temporary_dir(boost::filesystem::temp_directory_path().string());
GIVEN("A Model with plate 0 in primary mode and an empty head-filament map") {
Model src_model;
std::string src_file = std::string(TEST_DATA_DIR) + "/test_3mf/Prusa.stl";
REQUIRE(load_stl(src_file.c_str(), &src_model));
src_model.add_default_instances();
DynamicPrintConfig src_config;
PlateDataPtrs src_plates;
auto *plate0 = new PlateData();
plate0->plate_index = 0;
plate0->config.set_key_value("imex_parallel_mode", new ConfigOptionString("primary"));
plate0->config.set_key_value("imex_head_filament_map", new ConfigOptionString(""));
src_plates.push_back(plate0);
WHEN("the model is saved to BBS 3MF and loaded back") {
std::string test_file = std::string(TEST_DATA_DIR) + "/test_3mf/imex_primary_roundtrip.3mf";
StoreParams store_params;
store_params.path = test_file.c_str();
store_params.model = &src_model;
store_params.plate_data_list = src_plates;
store_params.config = &src_config;
REQUIRE(store_bbs_3mf(store_params));
Model dst_model;
DynamicPrintConfig dst_config;
PlateDataPtrs dst_plates;
std::vector<Preset*> dst_presets;
bool is_bbl = false;
bool is_orca = false;
Semver file_version;
ConfigSubstitutionContext ctxt{ ForwardCompatibilitySubstitutionRule::Disable };
bool loaded = load_bbs_3mf(test_file.c_str(), &dst_config, &ctxt, &dst_model,
&dst_plates, &dst_presets, &is_bbl, &is_orca, &file_version);
boost::filesystem::remove(test_file);
THEN("load succeeds") {
REQUIRE(loaded);
}
THEN("the loaded plate has no imex_parallel_mode option set (primary is not serialized)") {
REQUIRE(dst_plates.size() >= 1);
auto *mode_opt = dst_plates[0]->config.option<ConfigOptionString>("imex_parallel_mode");
REQUIRE(mode_opt == nullptr);
}
THEN("the loaded plate has no imex_head_filament_map option set (empty is not serialized)") {
REQUIRE(dst_plates.size() >= 1);
auto *hfm_opt = dst_plates[0]->config.option<ConfigOptionString>("imex_head_filament_map");
REQUIRE(hfm_opt == nullptr);
}
release_PlateData_list(dst_plates);
}
release_PlateData_list(src_plates);
}
}
// A mixed-color filament occupies an ordinary filament slot, and painting with it stores an
// ordinary extruder state: a project saved by BambuStudio encodes filament 5 of a 5-slot setup
// as paint state 5, with the mix described by the parallel filament_mixed_* project arrays.
@@ -1748,3 +2043,40 @@ TEST_CASE("3MF XML entries declaring more than an int can hold fail to load", "[
CHECK_FALSE(parser.check_3mf_from_prusa(path));
}
}
TEST_CASE("Loading a gcode.3mf from a stream keeps the plate's pick and pattern file paths", "[3mf][Regression]")
{
Model model;
REQUIRE(load_stl((std::string(TEST_DATA_DIR) + "/test_3mf/Prusa.stl").c_str(), &model));
model.add_default_instances();
ScopedTemporaryDir backup_dir("orca_pick");
model.set_backup_path(backup_dir.string());
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
auto plate = std::make_unique<PlateData>();
plate->plate_index = 0;
plate->pick_file = (backup_dir.path() / "pick.png").string();
plate->pattern_bbox_file = (backup_dir.path() / "plate.json").string();
ScopedTemporaryFile temp(".3mf");
const std::string test_file = temp.string();
StoreParams store_params;
store_params.path = test_file.c_str();
store_params.model = &model;
store_params.config = &config;
store_params.plate_data_list.push_back(plate.get());
store_params.strategy = SaveStrategy::Zip64 | SaveStrategy::Silence;
REQUIRE(store_bbs_3mf(store_params));
boost::nowide::ifstream in(test_file, std::ios::binary);
Model dst_model;
DynamicPrintConfig dst_config;
PlateDataPtrs dst_plates;
Semver file_version;
dst_model.set_backup_path(backup_dir.string());
REQUIRE(load_gcode_3mf_from_stream(in, &dst_config, &dst_model, &dst_plates, &file_version));
REQUIRE(dst_plates.size() == 1);
CHECK(dst_plates.front()->pick_file == "Metadata/pick_1.png");
CHECK(dst_plates.front()->pattern_bbox_file == "Metadata/plate_1.json");
release_PlateData_list(dst_plates);
}
+80
View File
@@ -0,0 +1,80 @@
#include <catch2/catch_test_macros.hpp>
#include "libslic3r/Config.hpp"
#include "libslic3r/Format/AMF.hpp"
#include "libslic3r/Model.hpp"
#include "libslic3r/PrintConfig.hpp"
#include "test_utils.hpp"
#include <boost/nowide/fstream.hpp>
#include <ios>
#include <string>
using namespace Slic3r;
namespace {
// The smallest AMF the loader accepts: one object holding one volume, a tetrahedron. The metadata
// element of the object is dropped in verbatim, so a test can hand the parser a malformed one.
std::string amf_with_object_metadata(const std::string &object_metadata)
{
return "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
"<amf unit=\"millimeter\">\n"
" <object id=\"0\">\n"
" " + object_metadata + "\n"
" <mesh>\n"
" <vertices>\n"
" <vertex><coordinates><x>0</x><y>0</y><z>0</z></coordinates></vertex>\n"
" <vertex><coordinates><x>1</x><y>0</y><z>0</z></coordinates></vertex>\n"
" <vertex><coordinates><x>0</x><y>1</y><z>0</z></coordinates></vertex>\n"
" <vertex><coordinates><x>0</x><y>0</y><z>1</z></coordinates></vertex>\n"
" </vertices>\n"
" <volume>\n"
" <triangle><v1>0</v1><v2>2</v2><v3>1</v3></triangle>\n"
" <triangle><v1>0</v1><v2>1</v2><v3>3</v3></triangle>\n"
" <triangle><v1>0</v1><v2>3</v2><v3>2</v3></triangle>\n"
" <triangle><v1>1</v1><v2>2</v2><v3>3</v3></triangle>\n"
" </volume>\n"
" </mesh>\n"
" </object>\n"
"</amf>\n";
}
void write_file(const std::string &path, const std::string &content)
{
boost::nowide::ofstream f(path, std::ios::binary);
f << content;
}
bool load(const std::string &path, Model &model)
{
DynamicPrintConfig config;
ConfigSubstitutionContext substitutions(ForwardCompatibilitySubstitutionRule::Disable);
return load_amf(path.c_str(), &config, &substitutions, &model, nullptr);
}
} // namespace
TEST_CASE("An AMF object metadata element with no type attribute is rejected", "[AMF]")
{
ScopedTemporaryFile tmp(".amf");
SECTION("with the attribute the file loads")
{
write_file(tmp.string(), amf_with_object_metadata("<metadata type=\"name\">tetra</metadata>"));
Model model;
REQUIRE(load(tmp.string(), model));
CHECK(model.objects.size() == 1);
}
SECTION("without it the load fails instead of reading a null attribute")
{
write_file(tmp.string(), amf_with_object_metadata("<metadata>tetra</metadata>"));
Model model;
CHECK_FALSE(load(tmp.string(), model));
}
}
+96
View File
@@ -14,6 +14,7 @@
#include <catch2/catch_test_macros.hpp>
#include <catch2/matchers/catch_matchers.hpp>
#include <catch2/generators/catch_generators.hpp>
#include <catch2/catch_message.hpp>
#include "libslic3r/Arrange.hpp"
#include "libslic3r/BoundingBox.hpp"
#include "libslic3r/ClipperUtils.hpp"
@@ -274,6 +275,101 @@ TEST_CASE("Arrange aligns the pile to a custom center", "[Arrange]")
require_no_overlap(items);
}
// A belt printer starts its parts at the leading end of the belt (best_object_pos 0.5, 0.05).
// Centring a pile on a point that close to the edge pushed everything longer than the room
// around it off the bed: four 90 mm parts on a 95 x 500 mm belt ended with one across the
// edge and one outside, with 290 mm of belt free behind them. The pile stops at the edge.
TEST_CASE("Arrange keeps a pile aligned near an edge on the bed", "[Arrange][belt]")
{
const BoundingBox belt = bed(95, 500);
ArrangePolygons items = squares(4, 90.);
ArrangeParams params = quiet_params(scaled(2.));
params.align_center = Vec2d(0.5, 0.05);
params.is_belt = true;
params.belt_axis = 1;
params.belt_tilt_slope = 1.f;
arrange(items, belt, params);
coord_t lowest = std::numeric_limits<coord_t>::max();
for (const ArrangePolygon &ap : items) {
REQUIRE(ap.bed_idx == 0);
const BoundingBox bb = ap.transformed_poly().contour.bounding_box();
CHECK(belt.contains(bb));
lowest = std::min(lowest, bb.min.y());
}
// Snapped to the edge it was aimed at, less the spacing margin, not re-centred.
CHECK(lowest < scaled(10.));
require_no_overlap(items);
}
// The clamp is a belt feature. Printers whose best_object_pos is off-centre (the A1 mini
// and the H2 family) keep their final alignment: the pile is centred on that point, even
// when that puts part of it outside the bed.
TEST_CASE("Arrange leaves the final alignment of a flat bed unclamped", "[Arrange]")
{
const BoundingBox bed_ = bed(95, 500);
ArrangePolygons items = squares(4, 90.);
ArrangeParams params = quiet_params(scaled(2.));
params.align_center = Vec2d(0.5, 0.05);
arrange(items, bed_, params);
BoundingBox pile;
for (const ArrangePolygon &ap : items) {
REQUIRE(ap.bed_idx == 0);
pile.merge(ap.transformed_poly().contour.bounding_box());
}
// Centred on the 5% mark of the bed's length, not pushed inside it.
CHECK_THAT(unscaled<double>(pile.center().y()), Catch::Matchers::WithinAbs(0.05 * 500., 15.));
CHECK(pile.min.y() < 0);
require_no_overlap(items);
}
// On a belt the parts print in belt order, so two colours that alternate along the
// belt, or sit side by side, cost a filament change on every shared layer. Arrange
// keeps each colour together: no part shares belt length with a part of another
// colour, counting the tilted layers that run cot(angle) * height past its far edge,
// whichever end of the belt prints first.
TEST_CASE("Arrange groups the colours of a belt print along the belt", "[Arrange][belt]")
{
const bool reversed = GENERATE(false, true);
CAPTURE(reversed);
const BoundingBox belt = bed(95, 500);
ArrangePolygons items = squares(6, 30., 20.);
for (size_t i = 0; i < items.size(); ++i)
items[i].extrude_ids = { int(i % 3) + 1 }; // three colours, two parts each
ArrangeParams params = quiet_params(scaled(2.));
params.align_center = Vec2d(0.5, 0.05);
params.is_belt = true;
params.belt_axis = 1;
params.belt_reversed = reversed;
params.belt_tilt_slope = 1.f; // 45 degrees
arrange(items, belt, params);
require_no_overlap(items);
// Belt position in print order, so the same check serves both directions.
const coord_t dir = reversed ? -1 : 1;
auto start = [&](const ArrangePolygon &ap) { const BoundingBox bb = ap.transformed_poly().contour.bounding_box(); return dir * (reversed ? bb.max.y() : bb.min.y()); };
auto end = [&](const ArrangePolygon &ap) { const BoundingBox bb = ap.transformed_poly().contour.bounding_box(); return dir * (reversed ? bb.min.y() : bb.max.y()) + scaled(ap.height * params.belt_tilt_slope); };
for (const ArrangePolygon &ap : items) {
REQUIRE(ap.bed_idx == 0);
CHECK(belt.contains(ap.transformed_poly().contour.bounding_box()));
}
for (const ArrangePolygon &a : items)
for (const ArrangePolygon &b : items) {
if (a.extrude_ids == b.extrude_ids)
continue;
// The part printed later starts after the earlier one has finished.
const coord_t earlier_end = start(a) <= start(b) ? end(a) : end(b);
const coord_t later_start = std::max(start(a), start(b));
INFO("colour " << a.extrude_ids.front() << " vs " << b.extrude_ids.front());
CHECK(earlier_end <= later_start);
}
}
TEST_CASE("Sequential print floors the object distance by object height", "[Arrange]")
{
// The only place sequential-print clearance is enforced. The arrange menu offers
+435
View File
@@ -0,0 +1,435 @@
#include <catch2/catch_all.hpp>
#include <algorithm>
#include <catch2/catch_test_macros.hpp>
#include <catch2/generators/catch_generators.hpp>
#include <catch2/matchers/catch_matchers.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp>
#include <cmath>
#include <cstddef>
#include <cstdlib>
#include <utility>
#include <vector>
#include "libslic3r/BeltBrim.hpp"
#include "libslic3r/BoundingBox.hpp"
#include "libslic3r/ClipperUtils.hpp"
#include "libslic3r/ExPolygon.hpp"
#include "libslic3r/Polygon.hpp"
#include "libslic3r/libslic3r.h"
using namespace Slic3r;
// Pure-geometry tests for the belt brim. No Print, no slicing: everything here is
// a property of the tilted-belt mapping and the sweep/lattice helpers, which is
// exactly the part that has to be right before any G-code is worth looking at.
static ExPolygon make_box(coord_t x0, coord_t y0, coord_t x1, coord_t y1)
{
ExPolygon out;
out.contour.points = { Point(x0, y0), Point(x1, y0), Point(x1, y1), Point(x0, y1) };
return out;
}
static void add_hole(ExPolygon &ex, coord_t x0, coord_t y0, coord_t x1, coord_t y1)
{
Polygon hole;
// Holes run clockwise, opposite the contour.
hole.points = { Point(x0, y0), Point(x0, y1), Point(x1, y1), Point(x1, y0) };
ex.holes.emplace_back(std::move(hole));
}
SCENARIO("sweep_ex sweeps a box", "[BeltBrim]") {
const coord_t mm = scale_(1.);
GIVEN("a 10x10 mm box") {
const ExPolygons src { make_box(0, 0, 10 * mm, 10 * mm) };
WHEN("swept 5 mm along -Y") {
const ExPolygons out = sweep_ex(src, Point(0, -5 * mm));
THEN("it becomes one 10x15 mm box") {
REQUIRE(out.size() == 1);
REQUIRE(out.front().holes.empty());
const BoundingBox bb = get_extents(out);
CHECK(bb.min.y() == -5 * mm);
CHECK(bb.max.y() == 10 * mm);
CHECK(bb.min.x() == 0);
CHECK(bb.max.x() == 10 * mm);
CHECK_THAT(unscale<double>(unscale<double>(out.front().area())),
Catch::Matchers::WithinRel(150., 1e-6));
}
}
WHEN("swept by a zero vector") {
THEN("it is unchanged") {
const ExPolygons out = sweep_ex(src, Point(0, 0));
REQUIRE(out.size() == 1);
CHECK(out.front().area() == src.front().area());
}
}
WHEN("swept diagonally") {
// For a convex P, area(P + [0,t]) == area(P) + |t| * width of P
// perpendicular to t. For a square swept along (1,1)/sqrt2 the
// perpendicular width is the diagonal, 10*sqrt2.
const ExPolygons out = sweep_ex(src, Point(3 * mm, 3 * mm));
THEN("area grows by |t| times the perpendicular width") {
const double expected = 100. + std::sqrt(2. * 9.) * (10. * std::sqrt(2.));
CHECK_THAT(unscale<double>(unscale<double>(out.front().area())),
Catch::Matchers::WithinRel(expected, 1e-6));
}
}
}
}
SCENARIO("sweep_ex closes holes narrower than the sweep", "[BeltBrim]") {
const coord_t mm = scale_(1.);
// This is the assertion that catches the two likeliest implementation bugs:
// omitting hole boundaries from the parallelogram set, or using the wrong
// Clipper fill rule. Note hole survival depends on the hole's extent ALONG
// the sweep direction, not on its narrowest dimension.
GIVEN("a 20x20 mm box with a hole 2 mm tall in the sweep direction") {
ExPolygon ex = make_box(0, 0, 20 * mm, 20 * mm);
add_hole(ex, 5 * mm, 9 * mm, 15 * mm, 11 * mm);
WHEN("swept 5 mm along -Y") {
const ExPolygons out = sweep_ex(ExPolygons{ ex }, Point(0, -5 * mm));
THEN("the hole is filled in") {
REQUIRE(out.size() == 1);
CHECK(out.front().holes.empty());
}
}
}
GIVEN("a 20x20 mm box with a hole 12 mm tall in the sweep direction") {
ExPolygon ex = make_box(0, 0, 20 * mm, 20 * mm);
add_hole(ex, 5 * mm, 4 * mm, 15 * mm, 16 * mm);
WHEN("swept 5 mm along -Y") {
const ExPolygons out = sweep_ex(ExPolygons{ ex }, Point(0, -5 * mm));
THEN("the hole survives, shrunk by the sweep") {
REQUIRE(out.size() == 1);
REQUIRE(out.front().holes.size() == 1);
const BoundingBox hb = get_extents(out.front().holes.front());
CHECK(hb.max.y() - hb.min.y() == 7 * mm);
}
}
}
GIVEN("a box whose edges are parallel to the sweep vector") {
// Degenerate parallelograms; Clipper must simply discard them.
const ExPolygons src { make_box(0, 0, 10 * mm, 10 * mm) };
WHEN("swept along +X") {
const ExPolygons out = sweep_ex(src, Point(4 * mm, 0));
THEN("the result is the expected rectangle") {
REQUIRE(out.size() == 1);
const BoundingBox bb = get_extents(out);
CHECK(bb.min.x() == 0);
CHECK(bb.max.x() == 14 * mm);
}
}
}
GIVEN("a reversed (clockwise) contour") {
ExPolygon ex = make_box(0, 0, 10 * mm, 10 * mm);
ex.contour.reverse();
WHEN("swept") {
const ExPolygons out = sweep_ex(ExPolygons{ ex }, Point(0, -5 * mm));
THEN("material is still produced") {
REQUIRE(! out.empty());
CHECK(get_extents(out).min.y() == -5 * mm);
}
}
}
}
SCENARIO("Belt flattening round-trips and rescales only the shear axis", "[BeltBrim]") {
const coord_t mm = scale_(1.);
const double shear = GENERATE(0.1, 0.5, 1.0, 3.0);
const int from_axis = GENERATE(0, 1);
DYNAMIC_SECTION("shear " << shear << " axis " << from_axis) {
const BeltBrimFrame frame { shear, from_axis };
// An L shape with a hole, so contours and holes are both exercised.
ExPolygon ex;
ex.contour.points = { Point(0, 0), Point(20 * mm, 0), Point(20 * mm, 6 * mm),
Point(6 * mm, 6 * mm), Point(6 * mm, 20 * mm), Point(0, 20 * mm) };
add_hole(ex, 2 * mm, 2 * mm, 4 * mm, 4 * mm);
const ExPolygons src { ex };
const ExPolygons round_tripped = belt_unflatten(belt_flatten(src, frame), frame);
REQUIRE(round_tripped.size() == src.size());
REQUIRE(round_tripped.front().holes.size() == src.front().holes.size());
for (size_t c = 0; c < src.front().num_contours(); ++ c) {
const Points &a = src.front().contour_or_hole(c).points;
const Points &b = round_tripped.front().contour_or_hole(c).points;
REQUIRE(a.size() == b.size());
for (size_t i = 0; i < a.size(); ++ i) {
// Rounding, not truncation, so the round trip stays within a
// couple of coordinate units.
CHECK(std::abs(a[i].x() - b[i].x()) <= 2);
CHECK(std::abs(a[i].y() - b[i].y()) <= 2);
// The axis that is not stretched must come back untouched.
if (from_axis == 0)
CHECK(a[i].y() == b[i].y());
else
CHECK(a[i].x() == b[i].x());
}
}
}
}
SCENARIO("Flattening makes shear-axis distances true on-belt distances", "[BeltBrim]") {
// The property the whole design rests on: an in-plane distance w projects to
// dw = w * cos(tilt) along the shear axis, so stretching that axis by
// 1/cos(tilt) makes ordinary Clipper offsets measure real on-belt distance.
const coord_t mm = scale_(1.);
const double shear = GENERATE(0.1, 0.5, 1.0, 3.0);
const int from_axis = GENERATE(0, 1);
DYNAMIC_SECTION("shear " << shear << " axis " << from_axis) {
const BeltBrimFrame frame { shear, from_axis };
const double stretch = std::sqrt(1. + shear * shear);
CHECK_THAT(frame.u_stretch(), Catch::Matchers::WithinRel(stretch, 1e-12));
CHECK_THAT(frame.cos_tilt() * frame.u_stretch(), Catch::Matchers::WithinRel(1., 1e-12));
// Two points 1 mm apart along the shear axis are stretch mm apart once
// flattened.
ExPolygon seg = make_box(0, 0, 1 * mm, 1 * mm);
const BoundingBox flat = get_extents(belt_flatten(ExPolygons{ seg }, frame));
const coord_t span_u = from_axis == 0 ? flat.max.x() - flat.min.x()
: flat.max.y() - flat.min.y();
CHECK_THAT(unscale<double>(span_u), Catch::Matchers::WithinRel(stretch, 1e-5));
}
}
SCENARIO("belt_brim_line_positions walks an exact lattice", "[BeltBrim]") {
const coord_t pitch = 420; // arbitrary units; the point is exactness
const coord_t anchor = 1000;
GIVEN("a band narrower than the pitch containing no lattice point") {
// Between anchor+0 and anchor+pitch, pick a window that misses both.
const std::vector<coord_t> us = belt_brim_line_positions(anchor + 100, anchor + 300, pitch, anchor);
THEN("nothing is emitted") { CHECK(us.empty()); }
}
GIVEN("a band containing exactly one lattice point") {
const std::vector<coord_t> us = belt_brim_line_positions(anchor - 10, anchor + 10, pitch, anchor);
THEN("that point is emitted") {
REQUIRE(us.size() == 1);
CHECK(us.front() == anchor);
}
}
GIVEN("a wide band, as at a shallow belt tilt") {
const std::vector<coord_t> us = belt_brim_line_positions(anchor, anchor + 5 * pitch, pitch, anchor);
THEN("several lines are emitted at exactly the pitch") {
REQUIRE(us.size() == 5);
for (size_t i = 1; i < us.size(); ++ i)
CHECK(us[i] - us[i - 1] == pitch);
}
}
GIVEN("two adjacent bands sharing a boundary") {
// Half-open ownership: a lattice point landing on the shared bound belongs
// to the upper band only, so no line is duplicated or dropped.
const coord_t bound = anchor + 2 * pitch;
const std::vector<coord_t> lower = belt_brim_line_positions(anchor, bound, pitch, anchor);
const std::vector<coord_t> upper = belt_brim_line_positions(bound, bound + 2 * pitch, pitch, anchor);
THEN("the boundary point appears exactly once, in the upper band") {
CHECK(std::count(lower.begin(), lower.end(), bound) == 0);
CHECK(std::count(upper.begin(), upper.end(), bound) == 1);
CHECK(lower.size() == 2);
CHECK(upper.size() == 2);
}
}
GIVEN("a lattice anchored below zero") {
THEN("negative lattice points are handled") {
const std::vector<coord_t> us = belt_brim_line_positions(-3 * pitch, -pitch, pitch, 0);
REQUIRE(us.size() == 2);
CHECK(us.front() == -3 * pitch);
CHECK(us.back() == -2 * pitch);
}
}
GIVEN("a degenerate pitch or band") {
THEN("nothing is emitted rather than looping forever") {
CHECK(belt_brim_line_positions(0, 1000, 0, 0).empty());
CHECK(belt_brim_line_positions(1000, 1000, pitch, 0).empty());
CHECK(belt_brim_line_positions(1000, 500, pitch, 0).empty());
}
}
}
SCENARIO("belt_brim_region reduces to the plate brim without an apron", "[BeltBrim]") {
const coord_t mm = scale_(1.);
const BeltBrimFrame frame { 1.0, 1 };
const ExPolygons footprint { make_box(0, 0, 20 * mm, 20 * mm) };
const coord_t width = 3 * mm;
const coord_t gap = 1 * mm;
GIVEN("outer brim, no apron") {
const ExPolygons region = belt_brim_region(footprint, true, false, width, gap, 0, 0, frame);
THEN("it matches the plate brim ring built from the same offsets") {
const ExPolygons inner = offset_ex(Polygons{ footprint.front().contour }, float(gap), jtRound, SCALED_RESOLUTION);
const ExPolygons outer = offset_ex(inner, float(width), jtRound, SCALED_RESOLUTION);
const ExPolygons expect = diff_ex(outer, inner);
// Not exact: the region is offset from the CLOSED footprint, and closing a
// single convex island is a geometric no-op but still round-trips every
// vertex through a dilate/erode, which perturbs the area in the 8th
// significant figure.
CHECK_THAT(unscale<double>(unscale<double>(area(region))),
Catch::Matchers::WithinRel(unscale<double>(unscale<double>(area(expect))), 1e-6));
}
}
GIVEN("no outer and no inner brim") {
THEN("the region is empty") {
CHECK(belt_brim_region(footprint, false, false, width, gap, 5 * mm, 0, frame).empty());
}
}
GIVEN("an apron but no brim width") {
const ExPolygons region = belt_brim_region(footprint, true, false, 0, 0, 5 * mm, 0, frame);
THEN("brim appears only downhill of the footprint") {
REQUIRE(! region.empty());
const BoundingBox rb = get_extents(region);
// shear > 0 means downhill is -u, and from_axis 1 means u is Y.
CHECK(rb.min.y() < 0);
CHECK(rb.max.y() <= 0 + 2); // nothing above the footprint's own base
}
}
}
SCENARIO("belt_brim_region builds an inner ring inside a hole", "[BeltBrim]") {
// Holed prisms (a washer) are the only footprints an inner brim has anything to grab.
// The inner path offsets the hole boundary inward and keeps the ring between the two
// offsets, clipped back inside the hole - it must be non-empty and live in the hole,
// never spill out onto the plate. No apron is applied to the inner ring.
const coord_t mm = scale_(1.);
const BeltBrimFrame frame { 1.0, 1 };
const coord_t width = 3 * mm;
const coord_t gap = 1 * mm;
GIVEN("a 40x40 mm washer with a 20 mm square hole") {
ExPolygon washer = make_box(0, 0, 40 * mm, 40 * mm);
add_hole(washer, 10 * mm, 10 * mm, 30 * mm, 30 * mm);
const ExPolygons footprint { washer };
const BoundingBox hole_bb = get_extents(washer.holes.front());
WHEN("an inner-only brim is requested") {
const ExPolygons region = belt_brim_region(footprint, false, true, width, gap, 0, 0, frame);
THEN("a non-empty ring is produced strictly inside the hole") {
REQUIRE(! region.empty());
CHECK(area(region) > 0);
const BoundingBox rb = get_extents(region);
CHECK(rb.min.x() >= hole_bb.min.x());
CHECK(rb.min.y() >= hole_bb.min.y());
CHECK(rb.max.x() <= hole_bb.max.x());
CHECK(rb.max.y() <= hole_bb.max.y());
}
}
WHEN("no inner brim is requested") {
THEN("the hole contributes nothing") {
CHECK(belt_brim_region(footprint, false, false, width, gap, 0, 0, frame).empty());
}
}
}
}
SCENARIO("Leading-edge-only retains the downhill half of the brim region", "[BeltBrim]") {
// The production clip, belt_brim_clip_leading_edge(), keeps what lies at or downhill
// of the first-contact cut. downhill_sign() pins the convention for both tilt signs:
// low_side = shear > 0, i.e. downhill is -u.
const coord_t mm = scale_(1.);
const double shear = GENERATE(1.0, -1.0);
DYNAMIC_SECTION("shear " << shear) {
const BeltBrimFrame frame { shear, 1 }; // from_axis 1 => u is Y
CHECK((frame.downhill_sign() < 0) == (frame.shear > 0.));
const ExPolygons region { make_box(0, 0, 20 * mm, 20 * mm) }; // straddles the cut
const coordf_t u_cut = 8.; // mm
const ExPolygons kept = belt_brim_clip_leading_edge(region, frame, u_cut);
REQUIRE(! kept.empty());
const BoundingBox kb = get_extents(kept);
if (frame.shear > 0.) {
// downhill is -u: nothing above the cut survives.
CHECK(kb.max.y() <= 8 * mm + 2);
CHECK(kb.min.y() < 8 * mm);
} else {
// downhill is +u: nothing below the cut survives.
CHECK(kb.min.y() >= 8 * mm - 2);
CHECK(kb.max.y() > 8 * mm);
}
// Half of the box is kept either way, and the full width across the belt.
CHECK_THAT(area(kept), Catch::Matchers::WithinRel(area(region) * (frame.shear > 0. ? 8. / 20. : 12. / 20.), 0.01));
CHECK(kb.min.x() == 0);
CHECK(kb.max.x() == 20 * mm);
}
WHEN("the cut lies beyond the region") {
const BeltBrimFrame frame { 1.0, 1 };
const ExPolygons region { make_box(0, 0, 20 * mm, 20 * mm) };
THEN("a cut past the uphill end keeps everything") {
CHECK_THAT(area(belt_brim_clip_leading_edge(region, frame, 30.)), Catch::Matchers::WithinRel(area(region), 0.001));
}
THEN("a cut before the downhill end keeps nothing") {
CHECK(belt_brim_clip_leading_edge(region, frame, -5.).empty());
}
THEN("an empty region stays empty") {
CHECK(belt_brim_clip_leading_edge(ExPolygons{}, frame, 8.).empty());
}
}
}
SCENARIO("The apron follows the sign of the shear", "[BeltBrim]") {
// Guards the one sign convention that is easiest to get backwards: which way
// is downhill, i.e. which way the belt carries the part.
const coord_t mm = scale_(1.);
const ExPolygons footprint { make_box(0, 0, 20 * mm, 20 * mm) };
const ExPolygons pos = belt_brim_region(footprint, true, false, 0, 0, 5 * mm, 0, BeltBrimFrame{ 1.0, 1 });
const ExPolygons neg = belt_brim_region(footprint, true, false, 0, 0, 5 * mm, 0, BeltBrimFrame{ -1.0, 1 });
REQUIRE(! pos.empty());
REQUIRE(! neg.empty());
CHECK(get_extents(pos).min.y() < 0);
CHECK(get_extents(neg).max.y() > 20 * mm);
}
SCENARIO("Outer belt brim does not fill the space between contact islands", "[BeltBrim]") {
// A belt contact patch is often a narrow, broken-up strip. Offsetting each island
// outwards by brim_width merges the rings of any two islands closer than
// 2 x brim_width and fills the space between them - and on a belt that space is
// UNDERNEATH the part, which is not what "outer brim only" means. Closing the
// footprint before the outward offset is what prevents it.
const coord_t mm = scale_(1.);
const BeltBrimFrame frame { 1.0, 1 };
const coord_t width = 3 * mm;
GIVEN("two contact islands 4 mm apart, closer than 2 x brim width") {
const ExPolygons footprint {
make_box(0, 0, 10 * mm, 10 * mm),
make_box(14 * mm, 0, 24 * mm, 10 * mm),
};
const ExPolygons region = belt_brim_region(footprint, true, false, width, 0, 0, 0, frame);
THEN("no brim is placed in the gap between them") {
REQUIRE(! region.empty());
// Midpoint of the gap, and a point just inside either edge of it.
for (const coord_t x : { 12 * mm, coord_t(10.5 * mm), coord_t(13.5 * mm) }) {
const Point probe(x, 5 * mm);
bool covered = false;
for (const ExPolygon &ex : region)
if (ex.contains(probe)) { covered = true; break; }
CHECK(! covered);
}
}
THEN("brim is still placed outside the pair") {
bool outside_covered = false;
const Point probe(-1 * mm, 5 * mm); // 1 mm left of the left island
for (const ExPolygon &ex : region)
if (ex.contains(probe)) { outside_covered = true; break; }
CHECK(outside_covered);
}
}
GIVEN("an apron on a fragmented footprint") {
const ExPolygons footprint {
make_box(0, 0, 10 * mm, 10 * mm),
make_box(14 * mm, 0, 24 * mm, 10 * mm),
};
// Closing fills concavities only, so an outward protrusion such as the apron must
// survive it untouched.
const ExPolygons region = belt_brim_region(footprint, true, false, width, 0, 5 * mm, 0, frame);
THEN("the apron still reaches downhill") {
REQUIRE(! region.empty());
CHECK(get_extents(region).min.y() <= -5 * mm);
}
}
}
+67
View File
@@ -5,6 +5,7 @@
#include <catch2/matchers/catch_matchers.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp>
#include <catch2/generators/catch_generators.hpp>
#include "catch2/catch_approx.hpp"
#include "libslic3r/PrintConfig.hpp"
#include "libslic3r/PrintConfigConstants.hpp"
#include "libslic3r/LocalesUtils.hpp"
@@ -1456,6 +1457,72 @@ TEST_CASE("min_object_distance yields no floor when an FFF config lacks the opti
}
}
TEST_CASE("handle_legacy migrates every IDEX/IQEX key testers already have", "[Config]") {
// Three eras of saved profiles: the feature shipped as iXex (is_ixex + ixex_*), the clearance
// pair was renamed to say what it measures, then the whole prefix became imex_. Driven from the
// full era-1 key list, because the gap this test was rewritten to catch was a key the earlier
// hand-picked version simply did not mention.
ConfigSubstitutionContext ctxt(ForwardCompatibilitySubstitutionRule::Enable);
DynamicPrintConfig era1;
era1.set_deserialize("is_ixex", "1", ctxt);
era1.set_deserialize("ixex_gantry_count", "2", ctxt);
era1.set_deserialize("ixex_tools_per_gantry", "2", ctxt);
era1.set_deserialize("ixex_carriage_width_x", "12.5", ctxt);
era1.set_deserialize("ixex_carriage_width_y", "7.25", ctxt);
era1.set_deserialize("ixex_carriage_margin", "1.5", ctxt);
era1.set_deserialize("ixex_tool_layout", "front-right", ctxt);
era1.set_deserialize("ixex_viz_theme", "deuteranopia", ctxt);
era1.set_deserialize("ixex_parallel_mode", "copy", ctxt);
era1.set_deserialize("ixex_mode_names", "\"Duplicate\";\"Mirror\"", ctxt);
era1.set_deserialize("ixex_mode_active_tools", "\"0:P,1:C\";\"0:P,1:M\"", ctxt);
era1.set_deserialize("ixex_mode_gcodes", "\"M118 copy\";\"M118 mirror\"", ctxt);
// The gate first: every other value is inert without it, and a populated but disabled config is
// worse than an empty one because it looks configured.
REQUIRE(era1.option("is_imex") != nullptr);
CHECK(era1.opt_bool("is_imex"));
CHECK(era1.opt_int("imex_gantry_count") == 2);
CHECK(era1.opt_int("imex_tools_per_gantry") == 2);
CHECK(era1.opt_float("imex_nozzle_clearance_x") == Catch::Approx(12.5));
CHECK(era1.opt_float("imex_nozzle_clearance_y") == Catch::Approx(7.25));
CHECK(era1.opt_float("imex_carriage_margin") == Catch::Approx(1.5));
CHECK(era1.opt_string("imex_parallel_mode") == "copy");
// Both enums changed type from coString, and a forward-compatible substitution would hand back
// a default rather than fail, so assert the value and not merely that the option exists.
CHECK(era1.opt_enum<ImexToolLayout>("imex_tool_layout") == ImexToolLayout::FrontRight);
CHECK(era1.opt_enum<ImexVizTheme>("imex_viz_theme") == ImexVizTheme::Deuteranopia);
// The three lists carry escaped, semicolon-separated values - the likeliest place for a silent
// change - so check both elements survive in order.
CHECK(era1.opt_string("imex_mode_names", 0u) == "Duplicate");
CHECK(era1.opt_string("imex_mode_names", 1u) == "Mirror");
CHECK(era1.opt_string("imex_mode_active_tools", 0u) == "0:P,1:C");
CHECK(era1.opt_string("imex_mode_gcodes", 1u) == "M118 mirror");
// Era 2: renamed clearance keys, still under the old prefix.
DynamicPrintConfig era2;
era2.set_deserialize("ixex_nozzle_clearance_x", "3.5", ctxt);
CHECK(era2.opt_float("imex_nozzle_clearance_x") == Catch::Approx(3.5));
// Era 3 (current) is untouched by the branch.
DynamicPrintConfig era3;
era3.set_deserialize("imex_nozzle_clearance_x", "9.0", ctxt);
CHECK(era3.opt_float("imex_nozzle_clearance_x") == Catch::Approx(9.0));
// The two keys with no modern counterpart are dropped and reported, not mapped onto a key that
// does not exist.
ConfigSubstitutionContext obsolete_ctxt(ForwardCompatibilitySubstitutionRule::Enable);
DynamicPrintConfig obsolete;
obsolete.set_deserialize("ixex_primary_col", "1", obsolete_ctxt);
obsolete.set_deserialize("ixex_primary_row", "0", obsolete_ctxt);
CHECK(obsolete.option("imex_primary_col") == nullptr);
CHECK(obsolete.option("imex_primary_row") == nullptr);
CHECK(obsolete_ctxt.unrecogized_keys.size() == 2);
}
TEST_CASE("Static print configs compare, order and hash by their option values", "[Config]")
{
// PrintObjectConfig comes from PRINT_CONFIG_CLASS_DEFINE; PrintConfig combines MachineEnvelopeConfig
@@ -863,3 +863,23 @@ TEST_CASE("The device drying options are rebuilt as each filament's values in sl
set_filament_dev_options(config, {&two_values, &no_value});
REQUIRE(config.option<ConfigOptionStrings>("filament_dev_ams_drying_ams_limitations")->values == std::vector<std::string>({"1", "0", ""}));
}
TEST_CASE("The mixed filament metadata is sized to the filament count", "[Config]")
{
DynamicPrintConfig config;
config.option<ConfigOptionBools>("filament_is_mixed", true)->values = {false, false, true};
config.option<ConfigOptionStrings>("filament_mixed_components", true)->values = {"", "", "1,2"};
config.option<ConfigOptionBools>("filament_mixed_gradient", true)->values = {false};
config.option<ConfigOptionStrings>("filament_mixed_gradient_range", true)->values = {""};
resize_mixed_filament_metadata(config, 3, 3);
REQUIRE(config.option<ConfigOptionBools>("filament_is_mixed")->values == std::vector<unsigned char>({false, false, true}));
REQUIRE(config.option<ConfigOptionStrings>("filament_mixed_components")->values == std::vector<std::string>({"", "", "1,2"}));
REQUIRE(config.option<ConfigOptionBools>("filament_mixed_gradient")->values == std::vector<unsigned char>({false, false, false}));
REQUIRE(config.option<ConfigOptionStrings>("filament_mixed_gradient_range")->values == std::vector<std::string>({"", "", ""}));
REQUIRE(config.option<ConfigOptionStrings>("filament_mixed_gradient_curve")->values == std::vector<std::string>({"", "", ""}));
resize_mixed_filament_metadata(config, 2, 4);
REQUIRE(config.option<ConfigOptionBools>("filament_is_mixed")->values == std::vector<unsigned char>({false, false, false, false}));
REQUIRE(config.option<ConfigOptionStrings>("filament_mixed_components")->values == std::vector<std::string>({"", "", "", ""}));
}
+154
View File
@@ -0,0 +1,154 @@
#include <catch2/catch_test_macros.hpp>
#include <catch2/matchers/catch_matchers.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp>
#include "libslic3r/BoundingBox.hpp"
#include "libslic3r/ExPolygon.hpp"
#include "libslic3r/Geometry.hpp"
#include "libslic3r/Point.hpp"
#include "libslic3r/Polygon.hpp"
#include "libslic3r/ConnectedBodies.hpp"
#include "libslic3r/TriangleMesh.hpp"
#include "libslic3r/libslic3r.h"
#include <cstddef>
#include <utility>
#include <vector>
using namespace Slic3r;
using Catch::Matchers::WithinAbs;
using Catch::Matchers::WithinRel;
namespace {
ExPolygon rectangle(double x, double width) { return ExPolygon(Polygon::new_scale({ { x, 0. }, { x + width, 0. }, { x + width, 10. }, { x, 10. } })); }
} // namespace
TEST_CASE("Islands overlapping their neighbors' make one body", "[ConnectedBodies]")
{
const ExPolygons apart = { rectangle(0., 10.), rectangle(20., 10.) };
const ExPolygons bridge = { rectangle(0., 30.) };
const ExPolygons left = { rectangle(0., 10.) };
const ExPolygons right = { rectangle(20., 10.) };
size_t count = 0;
const std::vector<std::vector<size_t>> stacked = connected_bodies({ &apart, &apart }, count);
CHECK(count == 2);
CHECK(stacked[1][0] == stacked[0][0]);
CHECK(stacked[1][1] == stacked[0][1]);
connected_bodies({ &apart, &bridge, &apart }, count);
CHECK(count == 1);
// Neighbors that do not overlap stay apart even with one island a layer.
connected_bodies({ &left, &right }, count);
CHECK(count == 2);
}
TEST_CASE("The island locator tests the outlines only where boxes overlap", "[ConnectedBodies]")
{
const auto square = [](double from, double to) {
return Polygon::new_scale({ { from, from }, { to, from }, { to, to }, { from, to } });
};
Polygon hole = square(5., 25.);
hole.make_clockwise();
ExPolygon ring(square(0., 30.));
ring.holes.emplace_back(hole);
ExPolygon alone(square(40., 50.));
const ExPolygons islands = { ring, ExPolygon(square(10., 20.)), alone };
const IslandLocator locator(islands, scaled<coord_t>(1.));
const auto at = [](double x, double y) { return Point::new_scale(x, y); };
CHECK(locator.find(at(2., 2.)).first == 0);
CHECK(locator.find(at(15., 15.)).first == 1);
// In the ring's hole, outside the island within it, the nearest outline counts.
CHECK(locator.find(at(7., 15.)).first == 0);
CHECK(locator.find(at(9.5, 15.)).first == 1);
// An island no other box reaches takes the margin past its outline.
CHECK(locator.find(at(50.5, 45.)).first == 2);
// Unless strict, as for an island whose neighbor is another instance's: then it is only the nearest, 0.5 mm away.
const auto [nearest, distance] = locator.find(at(50.5, 45.), true);
CHECK(nearest == 2);
CHECK_THAT(distance, WithinRel(sqr(scaled<double>(0.5)), 1e-6));
CHECK_FALSE(locator.holds(2, at(50.5, 45.), true));
CHECK(locator.holds(2, at(50.5, 45.)));
CHECK_FALSE(locator.holds(1, at(7., 15.)));
CHECK(locator.find(at(35., 45.)).first == -1);
}
TEST_CASE("Separate solids are separate bodies", "[ConnectedBodies]")
{
const indexed_triangle_set cube = its_make_cube(10., 10., 10.);
const auto bodies = solid_bodies({ { &cube, Transform3d::Identity() }, { &cube, Geometry::translation_transform({ 20., 0., 0. }) } }, { 1., 1. }, {}, 100);
REQUIRE(bodies.size() == 2);
CHECK_THAT(bodies[0].mass, WithinRel(1000., 1e-4));
CHECK_THAT((bodies[0].center - Vec3d(5., 5., 5.)).norm(), WithinAbs(0., 1e-4));
CHECK_THAT(bodies[1].mass, WithinRel(1000., 1e-4));
CHECK_THAT((bodies[1].center - Vec3d(25., 5., 5.)).norm(), WithinAbs(0., 1e-4));
}
TEST_CASE("Overlapping solids are one body that counts the overlap once", "[ConnectedBodies]")
{
const indexed_triangle_set cube = its_make_cube(10., 10., 10.);
const auto bodies = solid_bodies({ { &cube, Transform3d::Identity() }, { &cube, Geometry::translation_transform({ 5., 0., 0. }) } }, { 1., 1. }, {}, 100);
// Their union is a 15 x 10 x 10 box, which spreads a^2 / 12 along each side a.
REQUIRE(bodies.size() == 1);
const SolidBody &body = bodies.front();
CHECK_THAT(body.mass, WithinRel(1500., 1e-4));
CHECK_THAT(body.volume, WithinRel(1500., 1e-4));
CHECK_THAT((body.center - Vec3d(7.5, 5., 5.)).norm(), WithinAbs(0., 1e-4));
const Matrix3d spread = Vec3d(225., 100., 100.).asDiagonal() * (1. / 12.);
CHECK_THAT((body.spread - spread).norm(), WithinAbs(0., 1e-4));
// Turned a quarter about z, the box spans what was its y in -x.
const BoundingBoxf3 box = body.bounding_box(Geometry::rotation_transform({ 0., 0., 0.5 * PI }));
CHECK_THAT((box.min - Vec3d(-10., 0., 0.)).norm(), WithinAbs(0., 1e-4));
CHECK_THAT((box.max - Vec3d(0., 15., 10.)).norm(), WithinAbs(0., 1e-4));
}
TEST_CASE("A negative solid is cut away from the body", "[ConnectedBodies]")
{
const indexed_triangle_set cube = its_make_cube(10., 10., 10.);
const indexed_triangle_set notch = its_make_cube(4., 4., 4.);
const auto bodies = solid_bodies({ { &cube, Transform3d::Identity() } }, { 1. }, { { &notch, Transform3d::Identity() } }, 100);
// A 10 mm cube centered at 5 less a 4 mm cube centered at 2, in each axis alike.
const double expected = (1000. * 5. - 64. * 2.) / (1000. - 64.);
REQUIRE(bodies.size() == 1);
CHECK_THAT(bodies[0].mass, WithinRel(1000. - 64., 1e-4));
CHECK_THAT((bodies[0].center - Vec3d(expected, expected, expected)).norm(), WithinAbs(0., 1e-4));
}
TEST_CASE("Each solid weighs its density, the later of two overlapping ones the overlap", "[ConnectedBodies]")
{
const indexed_triangle_set cube = its_make_cube(10., 10., 10.);
const MeshInPlace left{ &cube, Transform3d::Identity() };
const MeshInPlace right{ &cube, Geometry::translation_transform({ 5., 0., 0. }) };
// The right cube, three times as dense, prints the overlap from x 5 to 10.
auto bodies = solid_bodies({ left, right }, { 1., 3. }, {}, 100);
REQUIRE(bodies.size() == 1);
CHECK_THAT(bodies[0].mass, WithinRel(500. + 3. * 1000., 1e-4));
CHECK_THAT(bodies[0].volume, WithinRel(1500., 1e-4));
CHECK_THAT(bodies[0].center.x(), WithinAbs((500. * 2.5 + 3000. * 10.) / 3500., 1e-4));
// Listed the other way round, the left cube prints it.
bodies = solid_bodies({ right, left }, { 3., 1. }, {}, 100);
REQUIRE(bodies.size() == 1);
CHECK_THAT(bodies[0].mass, WithinRel(1000. + 3. * 500., 1e-4));
CHECK_THAT(bodies[0].center.x(), WithinAbs((1000. * 5. + 1500. * 12.5) / 2500., 1e-4));
}
TEST_CASE("Separate solids weigh their own densities", "[ConnectedBodies]")
{
const indexed_triangle_set cube = its_make_cube(10., 10., 10.);
const auto bodies = solid_bodies({ { &cube, Transform3d::Identity() }, { &cube, Geometry::translation_transform({ 20., 0., 0. }) } },
{ 1.24, 2. }, {}, 100);
REQUIRE(bodies.size() == 2);
CHECK_THAT(bodies[0].mass, WithinRel(1240., 1e-4));
CHECK_THAT(bodies[1].mass, WithinRel(2000., 1e-4));
}
+80
View File
@@ -65,6 +65,50 @@ TEST_CASE("expand_mixed_filaments replaces mixed slots with their components", "
}
}
TEST_CASE("belt purge tower island count ignores virtual mixed slots", "[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.
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.
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") {
const std::vector<unsigned char> none_mixed = {0, 0, 0, 0, 0};
REQUIRE_FALSE(has_any_mixed_filament(none_mixed));
REQUIRE(expand_mixed_filaments(assigned, none_mixed, {"", "", "", "", ""}) == assigned);
}
}
TEST_CASE("check_mixed_filament_integrity flags dangling component references", "[FilamentMixer]")
{
const std::vector<unsigned char> is_mixed = {0, 0, 1};
@@ -211,3 +255,39 @@ TEST_CASE("blend_color_multi weights components", "[FilamentMixer]")
REQUIRE(std::abs(comp(mixed, i) - comp("#123456", i)) <= 8);
}
}
TEST_CASE("format_mixed_ratios normalises weights to four decimals", "[FilamentMixer]")
{
REQUIRE(format_mixed_components({1, 3}) == "1,3");
REQUIRE(format_mixed_ratios({50, 50}) == "0.5000,0.5000");
REQUIRE(format_mixed_ratios({1, 2}) == "0.3333,0.6667");
REQUIRE(format_mixed_ratios({1, 1}) == format_mixed_ratios({50, 50}));
}
TEST_CASE("find_fixed_mixed_filament reuses only a fixed slot of the same blend", "[FilamentMixer]")
{
// Physical slots 0 and 1; slot 2 blends them 50:50 as a gradient, slot 3 at a fixed 50:50.
DynamicPrintConfig cfg;
cfg.set_key_value("filament_is_mixed", new ConfigOptionBools({false, false, true, true}));
cfg.set_key_value("filament_mixed_components", new ConfigOptionStrings({"", "", "1,2", "1,2"}));
cfg.set_key_value("filament_mixed_sublayer_ratios",
new ConfigOptionStrings({"", "", format_mixed_ratios({50, 50}), format_mixed_ratios({50, 50})}));
cfg.set_key_value("filament_mixed_gradient", new ConfigOptionBools({false, false, true, false}));
SECTION("The fixed slot is found, whatever scale the weights are given at") {
REQUIRE(find_fixed_mixed_filament(cfg, {1, 2}, {50, 50}) == 3);
REQUIRE(find_fixed_mixed_filament(cfg, {1, 2}, {1, 1}) == 3);
}
SECTION("A gradient slot with the same components and ratios is not a match") {
cfg.option<ConfigOptionBools>("filament_is_mixed")->values[3] = false;
REQUIRE(find_fixed_mixed_filament(cfg, {1, 2}, {50, 50}) == -1);
}
SECTION("A project without the gradient key still matches its fixed slots") {
cfg.erase("filament_mixed_gradient");
REQUIRE(find_fixed_mixed_filament(cfg, {1, 2}, {50, 50}) == 2);
}
SECTION("Another ratio or another component order is a different blend") {
REQUIRE(find_fixed_mixed_filament(cfg, {1, 2}, {1, 2}) == -1);
REQUIRE(find_fixed_mixed_filament(cfg, {2, 1}, {50, 50}) == -1);
}
}
+292
View File
@@ -0,0 +1,292 @@
#include <catch2/catch_all.hpp>
#include <catch2/catch_message.hpp>
#include <catch2/catch_test_macros.hpp>
#include <catch2/matchers/catch_matchers.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp>
#include <algorithm>
#include <array>
#include <cmath>
#include <cstddef>
#include <vector>
#include "libslic3r/BoundingBox.hpp"
#include "libslic3r/ClipperUtils.hpp"
#include "libslic3r/ExPolygon.hpp"
#include "libslic3r/Fill/FillTpmsAdaptive.hpp"
#include "libslic3r/Point.hpp"
#include "libslic3r/Polygon.hpp"
#include "libslic3r/PrintConfig.hpp"
#include "libslic3r/libslic3r.h"
using namespace Slic3r;
using Catch::Matchers::WithinAbs;
namespace {
ExPolygon rectangle(double x0, double y0, double x1, double y1)
{
return ExPolygon(Points{Point::new_scale(x0, y0), Point::new_scale(x1, y0), Point::new_scale(x1, y1), Point::new_scale(x0, y1)});
}
// The expolygons stacked in 0.2 mm layers from z = 0 to height.
TpmsRadialField radial_field(const ExPolygons &expolygons, double height, TpmsAdaptiveMode mode = TpmsAdaptiveMode::Lobes)
{
std::vector<TpmsRadialField::Slice> slices;
for (int i = 0; 0.2 * (i + 1) < height + EPSILON; ++i)
slices.push_back({0.2 * i, 0.2 * (i + 1), &expolygons});
return TpmsRadialField(slices, get_extents(expolygons), mode, [] {});
}
double radial(const TpmsRadialField &field, const Vec3d &pt)
{
TpmsRadialField::Radials radials;
field.radial(pt, radials);
return radials[0].t;
}
Vec3d center(const TpmsRadialField &field, const Vec3d &pt)
{
TpmsRadialField::Radials radials;
field.radial(pt, radials);
return radials[0].center;
}
// The grid cells are 0.5 mm, so a radial coordinate over 10 mm is accurate to about a twentieth.
constexpr double Tolerance = 0.075;
} // namespace
TEST_CASE("TPMS radial field is zero at the center of a cube and one at its faces", "[FillTpmsAdaptive]")
{
// A 20 mm cube, 10 mm from its center to every face.
const ExPolygons square{rectangle(0., 0., 20., 20.)};
const TpmsRadialField field = radial_field(square, 20.);
const Vec3d c = center(field, {10., 10., 10.});
CHECK_THAT(c.x(), WithinAbs(10., 0.5));
CHECK_THAT(c.y(), WithinAbs(10., 0.5));
CHECK_THAT(c.z(), WithinAbs(10., 0.5));
CHECK_THAT(radial(field, {10., 10., 10.}), WithinAbs(0., Tolerance));
for (const Vec3d &face : {Vec3d(0., 10., 10.), Vec3d(20., 10., 10.), Vec3d(10., 0., 10.), Vec3d(10., 10., 0.), Vec3d(10., 10., 20.)}) {
CAPTURE(face.x(), face.y(), face.z());
CHECK_THAT(radial(field, face), WithinAbs(1., Tolerance));
}
}
TEST_CASE("TPMS radial field grows linearly from the center of a cube to its faces", "[FillTpmsAdaptive]")
{
const ExPolygons square{rectangle(0., 0., 20., 20.)};
const TpmsRadialField field = radial_field(square, 20.);
for (double d = 1.; d < 10.; d += 1.) {
CAPTURE(d);
CHECK_THAT(radial(field, {10. - d, 10., 10.}), WithinAbs(d / 10., Tolerance));
CHECK_THAT(radial(field, {10., 10., 10. + d}), WithinAbs(d / 10., Tolerance));
}
}
TEST_CASE("TPMS radial field of a tall box is centered at its middle height", "[FillTpmsAdaptive]")
{
// 20 x 20 x 60 mm: 10 mm from the center to the sides, 30 mm to the top and the bottom.
const ExPolygons square{rectangle(0., 0., 20., 20.)};
const TpmsRadialField field = radial_field(square, 60.);
CHECK_THAT(center(field, {10., 10., 45.}).z(), WithinAbs(30., 0.5));
CHECK_THAT(radial(field, {10., 10., 15.}), WithinAbs(0.5, Tolerance));
CHECK_THAT(radial(field, {10., 10., 45.}), WithinAbs(0.5, Tolerance));
CHECK_THAT(radial(field, {15., 10., 30.}), WithinAbs(0.5, Tolerance));
}
TEST_CASE("TPMS radial field grades every body towards its own center", "[FillTpmsAdaptive]")
{
const ExPolygons squares{rectangle(0., 0., 20., 20.), rectangle(30., 0., 50., 20.)};
const TpmsRadialField field = radial_field(squares, 20.);
CHECK_THAT(center(field, {5., 10., 10.}).x(), WithinAbs(10., 0.5));
CHECK_THAT(center(field, {45., 10., 10.}).x(), WithinAbs(40., 0.5));
CHECK_THAT(radial(field, {40., 10., 10.}), WithinAbs(0., Tolerance));
CHECK_THAT(radial(field, {30., 10., 10.}), WithinAbs(1., Tolerance));
}
TEST_CASE("TPMS radial field is beyond one outside of the object", "[FillTpmsAdaptive]")
{
const ExPolygons square{rectangle(0., 0., 20., 20.)};
const TpmsRadialField field = radial_field(square, 20.);
CHECK(radial(field, {-5., 10., 10.}) > 1.);
CHECK(radial(field, {10., 10., 30.}) > 1.);
}
TEST_CASE("TPMS radial field grades every lobe of a body towards its own center", "[FillTpmsAdaptive]")
{
// Two spheres of 10 mm united, their centers 16 mm apart: the neck between them is 6 mm deep.
const Vec3d c1(10., 10., 10.), c2(26., 10., 10.);
std::vector<ExPolygons> layers;
std::vector<TpmsRadialField::Slice> slices;
for (int i = 0; i < 100; ++i) {
const double z = 0.2 * i + 0.1, r = std::sqrt(std::max(0., 100. - sqr(z - 10.)));
Polygons circles;
for (const Vec3d &c : {c1, c2}) {
Polygon &circle = circles.emplace_back();
for (int k = 0; k < 90; ++k)
circle.points.push_back(Point::new_scale(c.x() + r * std::cos(k * 2. * PI / 90.), c.y() + r * std::sin(k * 2. * PI / 90.)));
}
layers.push_back(union_ex(circles));
}
for (int i = 0; i < 100; ++i)
slices.push_back({0.2 * i, 0.2 * (i + 1), &layers[i]});
const TpmsRadialField field(slices, get_extents(layers[50]), TpmsAdaptiveMode::Lobes, [] {});
for (const Vec3d &c : {c1, c2}) {
CAPTURE(c.x());
CHECK_THAT(center(field, c).x(), WithinAbs(c.x(), 0.5));
CHECK_THAT(radial(field, c), WithinAbs(0., Tolerance));
CHECK_THAT(radial(field, c + Vec3d(0., 0., 9.5)), WithinAbs(1., 2. * Tolerance));
}
// The side between the lobes is half way to the surface, where both patterns morph into each other.
TpmsRadialField::Radials radials;
REQUIRE(field.radial(0.5 * (c1 + c2), radials) == 2);
for (size_t i = 0; i < 2; ++i) {
CHECK_THAT(radials[i].t, WithinAbs(0.5, 2. * Tolerance));
CHECK_THAT(radials[i].weight, WithinAbs(0.5, 0.05));
}
}
TEST_CASE("TPMS radial field blends the lobes meeting at a junction continuously", "[FillTpmsAdaptive]")
{
// Three spheres of 10 mm united, their centers on a triangle of 16 mm sides: the necks meet at its middle.
const std::array<Vec3d, 3> centers{Vec3d(10., 10., 10.), Vec3d(26., 10., 10.), Vec3d(18., 10. + 8. * std::sqrt(3.), 10.)};
std::vector<ExPolygons> layers;
std::vector<TpmsRadialField::Slice> slices;
for (int i = 0; i < 100; ++i) {
const double z = 0.2 * i + 0.1, r = std::sqrt(std::max(0., 100. - sqr(z - 10.)));
Polygons circles;
for (const Vec3d &c : centers) {
Polygon &circle = circles.emplace_back();
for (int k = 0; k < 90; ++k)
circle.points.push_back(Point::new_scale(c.x() + r * std::cos(k * 2. * PI / 90.), c.y() + r * std::sin(k * 2. * PI / 90.)));
}
layers.push_back(union_ex(circles));
}
for (int i = 0; i < 100; ++i)
slices.push_back({0.2 * i, 0.2 * (i + 1), &layers[i]});
const TpmsRadialField field(slices, get_extents(layers[50]), TpmsAdaptiveMode::Lobes, [] {});
// Around the junction the nearest lobes swap, but the weight of every lobe changes smoothly.
const Vec3d junction = (centers[0] + centers[1] + centers[2]) / 3.;
size_t max_count = 0;
double max_jump = 0.;
double max_error = 0.;
for (int row = 0; row <= 100; ++row) {
std::array<float, 3> previous{};
for (int step = 0; step <= 200; ++step) {
TpmsRadialField::Radials radials;
const size_t count = field.radial(junction + Vec3d(0.01 * step - 1., 0.02 * row - 1., 0.), radials);
max_count = std::max(max_count, count);
std::array<float, 3> weights{};
for (size_t i = 0; i < count; ++i) {
auto nearest = std::min_element(centers.begin(), centers.end(), [&](const Vec3d &a, const Vec3d &b) {
return (a - radials[i].center).norm() < (b - radials[i].center).norm();
});
weights[nearest - centers.begin()] += radials[i].weight;
}
max_error = std::max(max_error, std::abs(weights[0] + weights[1] + weights[2] - 1.));
if (step > 0)
for (size_t k = 0; k < 3; ++k)
max_jump = std::max(max_jump, double(std::abs(weights[k] - previous[k])));
previous = weights;
}
}
CHECK(max_count == 3);
CHECK(max_error < 1e-5);
CHECK(max_jump < 0.05);
}
TEST_CASE("TPMS radial field is empty when the object is thinner than the grid cells", "[FillTpmsAdaptive]")
{
// A 0.3 mm square bar between the nodes of a grid sized by a 200 mm bounding box, with cells of 0.5 mm or more.
const ExPolygons bar{rectangle(0.1, 0.1, 0.4, 0.4)};
std::vector<TpmsRadialField::Slice> slices;
for (int i = 0; i < 1000; ++i)
slices.push_back({0.2 * i, 0.2 * (i + 1), &bar});
const TpmsRadialField field(slices, BoundingBox(Point::new_scale(0., 0.), Point::new_scale(200., 200.)),
TpmsAdaptiveMode::Lobes, [] {});
CHECK(field.empty());
}
TEST_CASE("TPMS depth follows the distance to the surface relative to the deepest point", "[FillTpmsAdaptive]")
{
// 20 x 20 x 60 mm: from 10 to 50 mm high the axis is 10 mm deep, as deep as the center.
const ExPolygons square{rectangle(0., 0., 20., 20.)};
const TpmsRadialField field = radial_field(square, 60., TpmsAdaptiveMode::SmoothBlend);
for (double z : {15., 30., 45.}) {
CAPTURE(z);
CHECK_THAT(field.depth({10., 10., z}), WithinAbs(1., Tolerance));
}
CHECK_THAT(field.depth({5., 10., 30.}), WithinAbs(0.5, Tolerance));
CHECK_THAT(field.depth({10., 10., 55.}), WithinAbs(0.5, Tolerance));
CHECK_THAT(field.depth({0., 10., 30.}), WithinAbs(0., Tolerance));
}
TEST_CASE("TPMS radial field in Distance warp mode follows the distance to the surface", "[FillTpmsAdaptive]")
{
// In a cube the depth falls linearly along every ray from the center, so Distance warp matches Lobes.
const ExPolygons square{rectangle(0., 0., 20., 20.)};
const TpmsRadialField cube = radial_field(square, 20., TpmsAdaptiveMode::DistanceWarp);
CHECK_THAT(radial(cube, {10., 10., 10.}), WithinAbs(0., Tolerance));
CHECK_THAT(radial(cube, {15., 10., 10.}), WithinAbs(0.5, Tolerance));
CHECK_THAT(radial(cube, {10., 10., 20.}), WithinAbs(1., Tolerance));
// 20 x 20 x 60 mm: Lobes grades the axis towards the top and the bottom, Distance warp keeps it deep.
const TpmsRadialField lobes = radial_field(square, 60.);
const TpmsRadialField warp = radial_field(square, 60., TpmsAdaptiveMode::DistanceWarp);
for (double z : {15., 45.}) {
CAPTURE(z);
CHECK_THAT(radial(lobes, {10., 10., z}), WithinAbs(0.5, Tolerance));
CHECK(radial(warp, {10., 10., z}) < 0.25);
}
CHECK_THAT(radial(warp, {0., 10., 30.}), WithinAbs(1., Tolerance));
}
TEST_CASE("TPMS stepped shells split a layer by depth from the surface inwards", "[FillTpmsAdaptive]")
{
// The middle layer of a 40 mm cube, 20 mm from its center to every face, 20% at the surface to 5% inside.
const ExPolygons square{rectangle(0., 0., 40., 40.)};
const TpmsRadialField field = radial_field(square, 40., TpmsAdaptiveMode::SteppedShells);
const std::vector<TpmsShell> shells = make_tpms_shells(field, square.front(), 20., 0.2f, 0.05f, TpmsAdaptiveGradient::Linear);
REQUIRE(shells.size() == 5);
CHECK_THAT(shells.front().density, WithinAbs(0.2, 1e-6));
CHECK_THAT(shells.back().density, WithinAbs(0.05, 1e-6));
double area = 0.;
for (size_t i = 0; i < shells.size(); ++i) {
CAPTURE(i);
if (i > 0)
CHECK(shells[i].density < shells[i - 1].density);
for (const ExPolygon &expolygon : shells[i].expolygons)
area += expolygon.area();
}
CHECK_THAT(area / square.front().area(), WithinAbs(1., 0.01));
const Point center = Point::new_scale(20., 20.);
CHECK(std::any_of(shells.back().expolygons.begin(), shells.back().expolygons.end(),
[&center](const ExPolygon &expolygon) { return expolygon.contains(center); }));
}
TEST_CASE("TPMS radial field in 2D grades every section normal to the axis on its own", "[FillTpmsAdaptive]")
{
// 20 x 20 x 60 mm: every section normal to Z is 10 mm from its center to the sides, whatever its height.
const ExPolygons square{rectangle(0., 0., 20., 20.)};
const TpmsRadialField normal_z = radial_field(square, 60., TpmsAdaptiveMode::NormalZ);
for (double z : {5., 30., 55.}) {
CAPTURE(z);
CHECK_THAT(radial(normal_z, {10., 10., z}), WithinAbs(0., Tolerance));
CHECK_THAT(radial(normal_z, {15., 10., z}), WithinAbs(0.5, Tolerance));
CHECK_THAT(radial(normal_z, {10., 0., z}), WithinAbs(1., Tolerance));
}
// Normal to X, the sections are 20 x 60 mm: 10 mm from the center to the sides, 30 mm to the top and the bottom.
const TpmsRadialField normal_x = radial_field(square, 60., TpmsAdaptiveMode::NormalX);
for (double x : {3., 10., 17.}) {
CAPTURE(x);
CHECK_THAT(radial(normal_x, {x, 15., 30.}), WithinAbs(0.5, Tolerance));
CHECK_THAT(radial(normal_x, {x, 10., 45.}), WithinAbs(0.5, Tolerance));
}
}
+119
View File
@@ -3,6 +3,10 @@
#include <catch2/catch_test_macros.hpp>
#include <catch2/catch_message.hpp>
#include <catch2/generators/catch_generators.hpp>
#include <catch2/generators/catch_generators_range.hpp>
#include <catch2/matchers/catch_matchers.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp>
#include "libslic3r/Point.hpp"
#include "libslic3r/BoundingBox.hpp"
#include "libslic3r/Polygon.hpp"
@@ -758,3 +762,118 @@ TEST_CASE("Convex polygon intersection test prusa polygons", "[Geometry][Rotcali
REQUIRE(res == ref);
}
}
namespace {
// Scale on the local axes, with a mirror on `mirror_axis`, then rotation.
Transform3d mirrored_transform(const Matrix3d &rotation, const Vec3d &scale, int mirror_axis)
{
Vec3d signed_scale = scale;
signed_scale[mirror_axis] = -signed_scale[mirror_axis];
Transform3d trafo = Transform3d::Identity();
trafo.linear() = rotation * signed_scale.asDiagonal();
return trafo;
}
const std::vector<Matrix3d> &test_rotations()
{
static const std::vector<Matrix3d> rotations = {
Matrix3d::Identity(),
Eigen::AngleAxisd(0.5 * PI, Vec3d::UnitZ()).toRotationMatrix(),
Eigen::AngleAxisd(0.7, Vec3d(1., 2., 3.).normalized()).toRotationMatrix(),
Eigen::AngleAxisd(2.5, Vec3d(-1., 0.5, 2.).normalized()).toRotationMatrix(),
};
return rotations;
}
} // namespace
TEST_CASE("Mirrored transformations decompose into a rotation and a diagonal scale", "[Geometry]")
{
const size_t rotation_idx = GENERATE(range(size_t(0), test_rotations().size()));
const int mirror_axis = GENERATE(0, 1, 2);
const Vec3d scale = GENERATE(Vec3d(1., 1., 1.), Vec3d(2., 2., 2.), Vec3d(1., 1., 3.), Vec3d(1., 2., 3.));
CAPTURE(rotation_idx, mirror_axis, scale.transpose());
const Geometry::Transformation trafo(mirrored_transform(test_rotations()[rotation_idx], scale, mirror_axis));
const Vec3d scaling = trafo.get_scaling_factor();
const Vec3d mirror = trafo.get_mirror();
for (int i = 0; i < 3; ++i) {
CHECK_THAT(scaling[i], Catch::Matchers::WithinAbs(scale[i], 1e-9));
CHECK_THAT(std::abs(mirror[i]), Catch::Matchers::WithinAbs(1., 1e-12));
}
CHECK_THAT(mirror.prod(), Catch::Matchers::WithinAbs(-1., 1e-12));
CHECK_FALSE(trafo.has_skew());
const bool uniform = scale.x() == scale.y() && scale.y() == scale.z();
CHECK(trafo.is_scaling_uniform() == uniform);
const Matrix3d rotation = trafo.get_rotation_matrix().linear();
CHECK_THAT(rotation.determinant(), Catch::Matchers::WithinAbs(1., 1e-9));
const Matrix3d rebuilt = rotation * trafo.get_scaling_factor_matrix().linear() * trafo.get_mirror_matrix().linear();
CHECK(rebuilt.isApprox(trafo.get_matrix().linear(), 1e-9));
}
TEST_CASE("An unrotated mirror keeps its axis and needs no rotation", "[Geometry]")
{
const int mirror_axis = GENERATE(0, 1, 2);
const Vec3d scale = GENERATE(Vec3d(1., 1., 1.), Vec3d(1., 2., 3.));
CAPTURE(mirror_axis, scale.transpose());
const Geometry::Transformation trafo(mirrored_transform(Matrix3d::Identity(), scale, mirror_axis));
const Vec3d mirror = trafo.get_mirror();
for (int i = 0; i < 3; ++i)
CHECK_THAT(mirror[i], Catch::Matchers::WithinAbs(i == mirror_axis ? -1. : 1., 1e-12));
CHECK(trafo.get_rotation().isZero(1e-9));
}
TEST_CASE("A mirrored and rotated transformation at unit scale reports unit scales", "[Geometry]")
{
// Valid mirrored instance transform as stored in a 3MF project.
Transform3d matrix = Transform3d::Identity();
matrix.linear() << 4.4408921e-16, 0.819152044, 0.573576436,
1.0, -4.4408921e-16, -1.11022302e-16,
-5.55111512e-17, -0.573576436, 0.819152044;
const Geometry::Transformation trafo(matrix);
CHECK(trafo.get_scaling_factor().isApprox(Vec3d::Ones(), 1e-8));
CHECK(trafo.get_mirror().allFinite());
CHECK(trafo.is_scaling_uniform());
}
TEST_CASE("Scaling one axis of a mirrored transformation keeps the mirror and adds no skew", "[Geometry]")
{
const size_t rotation_idx = GENERATE(range(size_t(0), test_rotations().size()));
const int mirror_axis = GENERATE(0, 1, 2);
const int scaled_axis = GENERATE(0, 1, 2);
CAPTURE(rotation_idx, mirror_axis, scaled_axis);
const Matrix3d &rotation = test_rotations()[rotation_idx];
Geometry::Transformation trafo(mirrored_transform(rotation, Vec3d::Ones(), mirror_axis));
trafo.set_scaling_factor(Axis(scaled_axis), 2.);
Vec3d expected_scale = Vec3d::Ones();
expected_scale[scaled_axis] = 2.;
CHECK(trafo.get_matrix().linear().isApprox(mirrored_transform(rotation, expected_scale, mirror_axis).linear(), 1e-9));
CHECK(trafo.is_left_handed());
CHECK_FALSE(trafo.has_skew());
}
TEST_CASE("Transformations without a mirror decompose as computeRotationScaling() does", "[Geometry]")
{
Transform3d skewed = Transform3d::Identity();
skewed.linear() << 1., 0.3, 0.,
0., 1., 0.,
0., 0., 2.;
const Transform3d matrix = GENERATE_COPY(Transform3d(test_rotations()[2]) * Geometry::scale_transform(Vec3d(1., 2., 3.)),
Transform3d(test_rotations()[3]) * skewed);
Matrix3d rotation;
Matrix3d scale;
matrix.computeRotationScaling(&rotation, &scale);
const Geometry::Transformation trafo(matrix);
CHECK(trafo.get_rotation_matrix().linear() == rotation);
CHECK(trafo.has_skew() == !scale.isDiagonal());
}
TEST_CASE("Skew is detected with and without a mirror", "[Geometry]")
{
Transform3d skewed = Transform3d::Identity();
skewed.linear() << 1., 0.3, 0.,
0., 1., 0.,
0., 0., 1.;
const bool mirrored = GENERATE(false, true);
const Transform3d matrix = Transform3d(test_rotations()[2]) * skewed * Geometry::scale_transform(Vec3d(mirrored ? -1. : 1., 1., 1.));
CHECK(Geometry::Transformation(matrix).has_skew());
}
+287
View File
@@ -0,0 +1,287 @@
#include <catch2/catch_all.hpp>
#include <algorithm>
#include <optional>
#include <vector>
#include "catch2/catch_test_macros.hpp"
#include "catch2/matchers/catch_matchers.hpp"
#include "catch2/matchers/catch_matchers_floating_point.hpp"
#include "libslic3r/Arrange.hpp"
#include "libslic3r/BoundingBox.hpp"
#include "libslic3r/ClipperUtils.hpp"
#include "libslic3r/ExPolygon.hpp"
#include "libslic3r/IMEXArrange.hpp"
#include "libslic3r/Point.hpp"
#include "libslic3r/Polygon.hpp"
using namespace Slic3r;
using Catch::Matchers::WithinAbs;
namespace {
// A 300 x 200 mm bed. A two-carriage IDEX in a parallel mode gives T0 the left half, and a
// mirror mode adds a 30 mm strip along that half's inner edge.
const BoundingBoxf kBed{Vec2d(0., 0.), Vec2d(300., 200.)};
const BoundingBoxf kLeftHalf{Vec2d(0., 0.), Vec2d(150., 200.)};
const BoundingBoxf kRightHalf{Vec2d(150., 0.), Vec2d(300., 200.)};
const BoundingBoxf kStrip{Vec2d(120., 0.), Vec2d(150., 200.)};
const ImexArrangeZones kCopy{kLeftHalf, {}};
const ImexArrangeZones kMirror{kLeftHalf, {kStrip}};
// A 1 mm bed edge margin, and no rotation, so every expected box below is exact.
arrangement::ArrangeParams test_params()
{
arrangement::ArrangeParams params;
params.progressind = {};
params.bed_shrink_x = 1.f;
params.bed_shrink_y = 1.f;
params.allow_rotations = false;
return params;
}
// The bed shrunk by that 1 mm margin, as get_shrink_bedpts() hands it to the arranger.
Points shrunk_bed()
{
return scaled(BoundingBoxf(Vec2d(1., 1.), Vec2d(299., 199.))).polygon().points;
}
// A `width` x `depth` mm part, kept 1 mm from its neighbors, ready to arrange: on bed 0, as
// get_instance_arrange_poly() leaves it.
arrangement::ArrangePolygon part(double width, double depth)
{
arrangement::ArrangePolygon ap;
ap.poly.contour = scaled(BoundingBoxf(Vec2d(-width / 2., -depth / 2.), Vec2d(width / 2., depth / 2.))).polygon();
ap.inflation = scaled(1.);
ap.extrude_ids = {0};
ap.height = 10.;
ap.bed_idx = 0;
return ap;
}
arrangement::ArrangePolygons squares(int count, double size)
{
arrangement::ArrangePolygons out(count, part(size, size));
for (int i = 0; i < count; ++i)
out[i].itemid = i;
return out;
}
// Arranges `items` the way ArrangeJob does.
void arrange(arrangement::ArrangePolygons& items, const ImexArrangeInput& input)
{
const arrangement::ArrangeParams params = test_params();
const ImexArranger imex(input, params, shrunk_bed());
arrangement::ArrangePolygons fixed;
imex.add_keep_outs(fixed);
const arrangement::ArrangePolygons before = items;
arrangement::arrange(items, fixed, imex.bed_shape(), params);
imex.finish(items, before, fixed, params);
}
BoundingBoxf extents(const arrangement::ArrangePolygons& items)
{
BoundingBox bb;
for (const arrangement::ArrangePolygon& ap : items)
bb.merge(get_extents(ap.transformed_poly()));
return BoundingBoxf(unscaled(bb.min), unscaled(bb.max));
}
bool crosses(const arrangement::ArrangePolygon& ap, const BoundingBoxf& box)
{
return !intersection(ap.transformed_poly().contour, scaled(box).polygon()).empty();
}
} // namespace
TEST_CASE("ImexArranger changes nothing when no plate has zones", "[IMEXArrange][IMEX]")
{
ImexArrangeInput input;
input.beds = {std::nullopt, std::nullopt};
input.adds_plates = true;
const ImexArranger imex(input, test_params(), shrunk_bed());
CHECK_FALSE(imex.active());
CHECK(imex.bed_shape() == shrunk_bed());
arrangement::ArrangePolygons fixed;
imex.add_keep_outs(fixed);
CHECK(fixed.empty());
}
TEST_CASE("Plates sharing a primary zone arrange inside it, clear of the strip", "[IMEXArrange][IMEX]")
{
// The second plate computes the same zone from where it sits, off in the last bits.
ImexArrangeZones shifted = kMirror;
shifted.primary_zone.max.x() += 1e-9;
ImexArrangeInput input;
input.beds = {kMirror, shifted};
input.adds_plates = true;
input.added_plate = kMirror;
// The bed shape is the zone less the bed's 1 mm edge margin.
const ImexArranger imex(input, test_params(), shrunk_bed());
const BoundingBox shape(imex.bed_shape());
CHECK(shape.min == scaled(Vec2d(1., 1.)));
CHECK(shape.max == scaled(Vec2d(149., 199.)));
// Enough parts that, centered in the zone, they would reach the strip.
arrangement::ArrangePolygons items = squares(20, 20.);
arrange(items, input);
for (const arrangement::ArrangePolygon& ap : items) {
CHECK(ap.bed_idx >= 0);
CHECK_FALSE(crosses(ap, kStrip));
}
}
TEST_CASE("Beside a plate in Primary, a parallel plate's parts are centered in its zone", "[IMEXArrange][IMEX]")
{
ImexArrangeInput input;
input.beds = {kMirror, std::nullopt};
input.adds_plates = true;
// Different zones: the bed shape stays the whole bed, and the mirror plate is fenced in.
const ImexArranger imex(input, test_params(), shrunk_bed());
CHECK(imex.bed_shape() == shrunk_bed());
arrangement::ArrangePolygons items = squares(4, 20.);
arrange(items, input);
for (const arrangement::ArrangePolygon& ap : items)
REQUIRE(ap.bed_idx == 0);
const BoundingBoxf pile = extents(items);
CHECK(kLeftHalf.contains(pile.min));
CHECK(kLeftHalf.contains(pile.max));
// Centered in the zone, rather than piled against its edge nearest the bed's center.
CHECK_THAT(pile.center().x(), WithinAbs(75., 5.));
CHECK_THAT(pile.center().y(), WithinAbs(100., 5.));
}
TEST_CASE("A plate in Primary uses the whole bed beside a parallel plate", "[IMEXArrange][IMEX]")
{
ImexArrangeInput input;
input.beds = {std::nullopt, kMirror};
input.adds_plates = true;
arrangement::ArrangePolygons items = squares(4, 20.);
arrange(items, input);
for (const arrangement::ArrangePolygon& ap : items)
REQUIRE(ap.bed_idx == 0);
CHECK_THAT(extents(items).center().x(), WithinAbs(150., 5.));
}
TEST_CASE("A part too big for its primary zone is left unarranged", "[IMEXArrange][IMEX]")
{
ImexArrangeInput input;
SECTION("arranging one plate, the zone is the bed shape")
{
input.beds = {kCopy};
}
SECTION("arranging all plates, the zone is fenced in")
{
input.beds = {kCopy, std::nullopt};
input.adds_plates = true;
}
// Narrower than the bed but wider than the zone.
arrangement::ArrangePolygons items{part(180., 50.)};
arrange(items, input);
CHECK(items.front().bed_idx == arrangement::UNARRANGED);
}
TEST_CASE("A strip keeps room for the bed margin and the brim", "[IMEXArrange][IMEX]")
{
ImexArrangeInput input;
input.beds = {kMirror};
input.brim = 5.;
const ImexArranger imex(input, test_params(), shrunk_bed());
arrangement::ArrangePolygons fixed;
imex.add_keep_outs(fixed);
REQUIRE(fixed.size() == 1);
// Grown by the 1 mm margin and the 5 mm brim on every side.
const BoundingBox strip = get_extents(fixed.front().poly);
CHECK(strip.min == scaled(Vec2d(114., -6.)));
CHECK(strip.max == scaled(Vec2d(156., 206.)));
}
TEST_CASE("A plate the arrange adds takes the zones it will have", "[IMEXArrange][IMEX]")
{
ImexArrangeInput input;
input.beds = {kCopy};
input.adds_plates = true;
SECTION("the same zone keeps it the bed shape, with the new plates' strips")
{
input.added_plate = kMirror;
const ImexArranger imex(input, test_params(), shrunk_bed());
CHECK(imex.bed_shape() != shrunk_bed());
arrangement::ArrangePolygons fixed;
imex.add_keep_outs(fixed);
auto on = [&fixed](int bed_idx) {
return std::count_if(fixed.begin(), fixed.end(), [bed_idx](const arrangement::ArrangePolygon& ap) { return ap.bed_idx == bed_idx; });
};
CHECK(on(0) == 0);
CHECK(on(1) == 1);
}
SECTION("a new plate in Primary gets the whole bed, so the zone is fenced in instead")
{
const ImexArranger imex(input, test_params(), shrunk_bed());
CHECK(imex.bed_shape() == shrunk_bed());
arrangement::ArrangePolygons fixed;
imex.add_keep_outs(fixed);
for (const arrangement::ArrangePolygon& ap : fixed)
CHECK(ap.bed_idx == 0);
}
SECTION("another zone makes the whole bed the bed shape")
{
input.added_plate = ImexArrangeZones{kRightHalf, {}};
const ImexArranger imex(input, test_params(), shrunk_bed());
CHECK(imex.bed_shape() == shrunk_bed());
}
SECTION("arranging one plate never adds one")
{
input.adds_plates = false;
input.added_plate = ImexArrangeZones{kRightHalf, {}};
const ImexArranger imex(input, test_params(), shrunk_bed());
CHECK(imex.bed_shape() != shrunk_bed());
}
}
TEST_CASE("A zone narrower than its margins takes no parts", "[IMEXArrange][IMEX]")
{
ImexArrangeInput input;
input.beds = {ImexArrangeZones{BoundingBoxf(Vec2d(0., 0.), Vec2d(1.5, 200.)), {}}};
const ImexArranger imex(input, test_params(), shrunk_bed());
CHECK(imex.bed_shape() == shrunk_bed());
arrangement::ArrangePolygons items = squares(1, 20.);
arrange(items, input);
CHECK(items.front().bed_idx == arrangement::UNARRANGED);
}
TEST_CASE("Parts that spill onto a new plate in Primary use its whole bed", "[IMEXArrange][IMEX]")
{
// Every plate in copy mode, while a plate the arrange adds is in Primary.
ImexArrangeInput input;
input.beds = {kCopy};
input.adds_plates = true;
// The zone holds 3 x 4 of these 40 mm parts, so the rest spill onto a new plate.
arrangement::ArrangePolygons items = squares(16, 40.);
arrange(items, input);
arrangement::ArrangePolygons spilled;
for (const arrangement::ArrangePolygon& ap : items) {
REQUIRE(ap.bed_idx >= 0);
if (ap.bed_idx == 0) {
const BoundingBoxf placed = extents({ap});
CHECK(kLeftHalf.contains(placed.min));
CHECK(kLeftHalf.contains(placed.max));
} else
spilled.push_back(ap);
}
REQUIRE_FALSE(spilled.empty());
// Centered on the whole bed, not packed into the copy plate's zone.
CHECK_THAT(extents(spilled).center().x(), WithinAbs(150., 10.));
}
File diff suppressed because it is too large Load Diff
+579
View File
@@ -0,0 +1,579 @@
#include <catch2/catch_all.hpp>
#include <libslic3r/BoundingBox.hpp>
#include <libslic3r/Config.hpp>
#include <string>
#include <vector>
#include "catch2/matchers/catch_matchers_floating_point.hpp"
#include "catch2/matchers/catch_matchers.hpp"
#include "catch2/catch_test_macros.hpp"
#include "libslic3r/IMEXHelpers.hpp"
#include "libslic3r/IMEXZones.hpp"
#include "libslic3r/PrintConfig.hpp"
using namespace Slic3r;
using Catch::Matchers::WithinAbs;
namespace {
// A square 200x200 bed keeps every expected coordinate an exact round number, so a
// failure reports a wrong decision rather than a rounding difference.
const BoundingBoxf kBed{ Vec2d(0.0, 0.0), Vec2d(200.0, 200.0) };
constexpr const char* kMode = "parallel";
struct PrinterCfg
{
int gantry_count = 1;
int tools_per_gantry = 2;
ImexToolLayout tool_layout = ImexToolLayout::FrontLeft;
std::string active_tools; // the imex_mode_active_tools entry for kMode
double clearance_x = 0.0;
double clearance_y = 0.0;
double margin = 0.0;
bool is_imex = true;
};
// Builds the subset of the printer preset compute_imex_zone_layout actually reads. Every
// key is set explicitly so no assertion below depends on a PrintConfig default.
DynamicPrintConfig make_cfg(const PrinterCfg& p)
{
DynamicPrintConfig cfg;
cfg.set_key_value("is_imex", new ConfigOptionBool(p.is_imex));
cfg.set_key_value("imex_gantry_count", new ConfigOptionInt(p.gantry_count));
cfg.set_key_value("imex_tools_per_gantry", new ConfigOptionInt(p.tools_per_gantry));
// Deserialized from its stored string, not constructed typed: that is the generic form a
// preset, a project's settings and the CLI all carry, and the form a reader can reject
// while the typed one it never sees would have answered correctly.
cfg.set_deserialize_strict("imex_tool_layout", ConfigOptionEnum<ImexToolLayout>(p.tool_layout).serialize());
// Slot 0 is the reserved Primary mode, which never carries tools; kMode is slot 1.
cfg.set_key_value("imex_mode_names",
new ConfigOptionStrings(std::vector<std::string>{ kImexPrimaryMode, kMode }));
cfg.set_key_value("imex_mode_active_tools",
new ConfigOptionStrings(std::vector<std::string>{ std::string(), p.active_tools }));
cfg.set_key_value("imex_nozzle_clearance_x", new ConfigOptionFloat(p.clearance_x));
cfg.set_key_value("imex_nozzle_clearance_y", new ConfigOptionFloat(p.clearance_y));
cfg.set_key_value("imex_carriage_margin", new ConfigOptionFloat(p.margin));
return cfg;
}
ImexZoneLayout layout_for(const PrinterCfg& p,
const std::string& plate_mode = kMode,
const std::string& process_mode = std::string())
{
return compute_imex_zone_layout(make_cfg(p), plate_mode, process_mode, kBed);
}
void check_rect(const BoundingBoxf& b, double x0, double y0, double x1, double y1)
{
CHECK_THAT(b.min.x(), WithinAbs(x0, 1e-9));
CHECK_THAT(b.min.y(), WithinAbs(y0, 1e-9));
CHECK_THAT(b.max.x(), WithinAbs(x1, 1e-9));
CHECK_THAT(b.max.y(), WithinAbs(y1, 1e-9));
}
void check_box_xy(const BoundingBoxf3& b, double x0, double y0, double x1, double y1)
{
CHECK_THAT(b.min.x(), WithinAbs(x0, 1e-9));
CHECK_THAT(b.min.y(), WithinAbs(y0, 1e-9));
CHECK_THAT(b.max.x(), WithinAbs(x1, 1e-9));
CHECK_THAT(b.max.y(), WithinAbs(y1, 1e-9));
}
void check_point(const Vec2d& p, double x, double y)
{
CHECK_THAT(p.x(), WithinAbs(x, 1e-9));
CHECK_THAT(p.y(), WithinAbs(y, 1e-9));
}
bool is_empty(const ImexZoneLayout& l)
{
return l.primary_head == -1
&& !l.primary_zone_box.has_value()
&& l.head_zone_centers.empty()
&& l.copy_zones.empty()
&& l.mirror_zones.empty()
&& l.secondary_zone_boxes.empty()
&& l.collision_zones.empty()
&& l.margin_bands.empty();
}
} // namespace
// D1 regression: both strip loops are gated on `carriage_w > 0.0`, so a clearance fallback of
// 0.0 would emit no collision strips at all -- the geometry that keeps two carriages from
// meeting -- while the preview still drew 30 mm toolhead boxes. The fallback must match the
// value registered in PrintConfig.cpp (30.0), not zero.
//
// So the strip's WIDTH is the claim, not its existence: a fallback of 0.1 would leave the zone
// list non-empty and the carriages a nozzle's width apart. 1 gantry x 2 tools on the 200mm bed
// puts the column boundary at x = 100, and the strip lies one clearance inside the primary's
// column, so the registered 30.0 is the rectangle x [70, 100] over the primary's full depth.
TEST_CASE("a config missing the nozzle clearance keys falls back to the registered clearance",
"[IMEXZones]")
{
// Deliberately NOT via make_cfg(): the point is a config that never carries the keys.
// imex_tool_layout is left out too, so the layout falls back to FrontLeft and T0 owns the
// left column -- the same corner every other case in this file starts from.
DynamicPrintConfig cfg;
cfg.set_key_value("is_imex", new ConfigOptionBool(true));
cfg.set_key_value("imex_gantry_count", new ConfigOptionInt(1));
cfg.set_key_value("imex_tools_per_gantry", new ConfigOptionInt(2));
cfg.set_key_value("imex_mode_names", new ConfigOptionStrings({ kMode }));
cfg.set_key_value("imex_mode_active_tools", new ConfigOptionStrings({ "0:P,1:M" }));
const ImexZoneLayout l = compute_imex_zone_layout(cfg, kMode, std::string(), kBed);
REQUIRE(l.primary_head == 0);
REQUIRE(l.primary_zone_box.has_value());
check_rect(*l.primary_zone_box, 0.0, 0.0, 100.0, 200.0);
// One X boundary, one strip, 30mm wide against it.
REQUIRE(l.collision_zones.size() == 1);
check_box_xy(l.collision_zones[0], 70.0, 0.0, 100.0, 200.0);
// The margin's own fallback is 0.0, and a zero margin raises no band.
CHECK(l.margin_bands.empty());
}
TEST_CASE("an IDEX mirror pair splits the bed into two columns", "[IMEXZones]")
{
// 1 gantry x 2 tools: the classic IDEX case. T1 mirrors T0, so the split is on X and
// the boundary between the two zones carries a carriage danger strip.
PrinterCfg p;
p.gantry_count = 1;
p.tools_per_gantry = 2;
p.active_tools = "0:P,1:M";
p.clearance_x = 5.0;
p.margin = 2.0;
const ImexZoneLayout l = layout_for(p);
REQUIRE(l.primary_head == 0);
REQUIRE(l.primary_zone_box.has_value());
check_rect(*l.primary_zone_box, 0.0, 0.0, 100.0, 200.0);
CHECK(l.copy_zones.empty());
REQUIRE(l.mirror_zones.size() == 1);
check_rect(l.mirror_zones[0], 100.0, 0.0, 200.0, 200.0);
// Both heads get a zone centre; the ghost offset is the difference between them.
REQUIRE(l.head_zone_centers.size() == 2);
check_point(l.head_zone_centers.at(0), 50.0, 100.0);
check_point(l.head_zone_centers.at(1), 150.0, 100.0);
// The mirror zone is off limits to objects, over the full Z range.
REQUIRE(l.secondary_zone_boxes.size() == 1);
check_box_xy(l.secondary_zone_boxes[0], 100.0, 0.0, 200.0, 200.0);
CHECK_THAT(l.secondary_zone_boxes[0].min.z(), WithinAbs(-1.0, 1e-9));
CHECK(l.secondary_zone_boxes[0].max.z() > 1000.0);
// Danger strip sits inside the primary zone, against the boundary, one clearance wide.
REQUIRE(l.collision_zones.size() == 1);
check_box_xy(l.collision_zones[0], 95.0, 0.0, 100.0, 200.0);
// The advisory margin band abuts the strip on the primary side, one margin wide.
REQUIRE(l.margin_bands.size() == 1);
check_rect(l.margin_bands[0], 93.0, 0.0, 95.0, 200.0);
}
TEST_CASE("a copy secondary claims a zone but raises no collision strip", "[IMEXZones]")
{
// Copy tools travel in the same direction as the primary and can never close on it,
// so the shared boundary needs no danger strip -- only mirrors do.
PrinterCfg p;
p.gantry_count = 1;
p.tools_per_gantry = 2;
p.active_tools = "0:P,1:C";
p.clearance_x = 5.0;
p.margin = 2.0;
const ImexZoneLayout l = layout_for(p);
REQUIRE(l.copy_zones.size() == 1);
check_rect(l.copy_zones[0], 100.0, 0.0, 200.0, 200.0);
CHECK(l.mirror_zones.empty());
CHECK(l.secondary_zone_boxes.size() == 1);
CHECK(l.collision_zones.empty());
CHECK(l.margin_bands.empty());
}
TEST_CASE("the tool layout decides which corner the primary zone occupies", "[IMEXZones]")
{
// imex_tool_layout names the bed corner T0 sits in. With all four tools of a 2x2 IQEX
// active the bed splits into quadrants, so the primary quadrant is a direct readout of
// how tool indices were mapped onto columns and rows.
struct Case { ImexToolLayout layout; const char* name; double x0, y0, x1, y1; };
const Case cases[] = {
{ ImexToolLayout::FrontLeft, "FrontLeft", 0.0, 0.0, 100.0, 100.0 },
{ ImexToolLayout::FrontRight, "FrontRight", 100.0, 0.0, 200.0, 100.0 },
{ ImexToolLayout::RearLeft, "RearLeft", 0.0, 100.0, 100.0, 200.0 },
{ ImexToolLayout::RearRight, "RearRight", 100.0, 100.0, 200.0, 200.0 },
};
for (const Case& c : cases) {
DYNAMIC_SECTION(c.name) {
PrinterCfg p;
p.gantry_count = 2;
p.tools_per_gantry = 2;
p.tool_layout = c.layout;
p.active_tools = "0:P,1:C,2:M,3:M";
const ImexZoneLayout l = layout_for(p);
REQUIRE(l.primary_zone_box.has_value());
check_rect(*l.primary_zone_box, c.x0, c.y0, c.x1, c.y1);
// The primary's zone centre must agree with its zone.
REQUIRE(l.head_zone_centers.count(0) == 1);
check_point(l.head_zone_centers.at(0), 0.5 * (c.x0 + c.x1), 0.5 * (c.y0 + c.y1));
// Three secondaries, each with its own quadrant.
CHECK(l.secondary_zone_boxes.size() == 3);
}
}
}
TEST_CASE("a 2x2 IQEX with independent secondaries gives one quadrant per tool", "[IMEXZones]")
{
// No Span is declared, so nothing aggregates: T1, T2 and T3 each keep their own cell.
// Only T2 shares the primary's column, so it alone can collide with the primary
// carriage -- the diagonal T3 cannot, and must not raise a strip of its own.
PrinterCfg p;
p.gantry_count = 2;
p.tools_per_gantry = 2;
p.active_tools = "0:P,1:C,2:M,3:M";
p.clearance_x = 6.0;
p.clearance_y = 4.0;
const ImexZoneLayout l = layout_for(p);
REQUIRE(l.primary_zone_box.has_value());
check_rect(*l.primary_zone_box, 0.0, 0.0, 100.0, 100.0);
REQUIRE(l.copy_zones.size() == 1);
check_rect(l.copy_zones[0], 100.0, 0.0, 200.0, 100.0);
REQUIRE(l.mirror_zones.size() == 2);
check_rect(l.mirror_zones[0], 0.0, 100.0, 100.0, 200.0);
check_rect(l.mirror_zones[1], 100.0, 100.0, 200.0, 200.0);
// Blocking boxes are the copy rects followed by the mirror rects, so the placement
// check and the rendered overlay always describe the same three areas.
REQUIRE(l.secondary_zone_boxes.size() == 3);
check_box_xy(l.secondary_zone_boxes[0], 100.0, 0.0, 200.0, 100.0);
check_box_xy(l.secondary_zone_boxes[1], 0.0, 100.0, 100.0, 200.0);
check_box_xy(l.secondary_zone_boxes[2], 100.0, 100.0, 200.0, 200.0);
// T2 is directly behind the primary: one strip on the primary's rear boundary,
// clearance_y deep, spanning the primary zone's width. T3 contributes nothing.
REQUIRE(l.collision_zones.size() == 1);
check_box_xy(l.collision_zones[0], 0.0, 96.0, 100.0, 100.0);
CHECK(l.margin_bands.empty()); // margin defaults to 0
}
TEST_CASE("a Span partner collapses the far gantry into one full-width strip", "[IMEXZones]")
{
// Paired-gantry multicolor: T1 is the primary's within-gantry Span partner, so the
// second gantry acts as a single mirrored unit rather than two independent tools. Its
// two mirrors merge into one row strip spanning the whole bed width, and the primary
// zone widens to match.
PrinterCfg p;
p.gantry_count = 2;
p.tools_per_gantry = 2;
p.active_tools = "0:P,1:S,2:M,3:M";
p.clearance_x = 6.0;
p.clearance_y = 4.0;
const ImexZoneLayout l = layout_for(p);
REQUIRE(l.primary_head == 0);
REQUIRE(l.primary_zone_box.has_value());
check_rect(*l.primary_zone_box, 0.0, 0.0, 200.0, 100.0);
REQUIRE(l.mirror_zones.size() == 1);
check_rect(l.mirror_zones[0], 0.0, 100.0, 200.0, 200.0);
CHECK(l.copy_zones.empty());
CHECK(l.secondary_zone_boxes.size() == 1);
// A Span tool prints in the primary's own zone, so it owns no zone centre of its own.
CHECK(l.head_zone_centers.count(1) == 0);
check_point(l.head_zone_centers.at(0), 50.0, 50.0);
check_point(l.head_zone_centers.at(2), 50.0, 150.0);
check_point(l.head_zone_centers.at(3), 150.0, 150.0);
// Regression guard: T3 sits on the other gantry's row, so the gantries never overlap
// in X and no right-edge strip may be drawn. Only the rear boundary is dangerous, and
// it runs the full width of the widened primary zone.
REQUIRE(l.collision_zones.size() == 1);
check_box_xy(l.collision_zones[0], 0.0, 96.0, 200.0, 100.0);
}
TEST_CASE("inactive tools donate their bed share to the active ones", "[IMEXZones]")
{
// Three tools on one gantry, but the mode activates only T0 and T2. Zones are sized by
// active tool count, so each gets half the bed rather than a third with a dead middle
// band -- and T2's zone starts where the primary's ends.
PrinterCfg p;
p.gantry_count = 1;
p.tools_per_gantry = 3;
p.active_tools = "0:P,2:M";
p.clearance_x = 5.0;
const ImexZoneLayout l = layout_for(p);
REQUIRE(l.primary_zone_box.has_value());
check_rect(*l.primary_zone_box, 0.0, 0.0, 100.0, 200.0);
CHECK_THAT(l.primary_zone_box->size().x(), WithinAbs(100.0, 1e-9)); // half, not 200/3
REQUIRE(l.mirror_zones.size() == 1);
check_rect(l.mirror_zones[0], 100.0, 0.0, 200.0, 200.0);
REQUIRE(l.head_zone_centers.size() == 2);
check_point(l.head_zone_centers.at(0), 50.0, 100.0);
check_point(l.head_zone_centers.at(2), 150.0, 100.0);
// The two zones are adjacent, so the boundary between them is a real collision risk.
REQUIRE(l.collision_zones.size() == 1);
check_box_xy(l.collision_zones[0], 95.0, 0.0, 100.0, 200.0);
}
TEST_CASE("the plate mode falls back to the process preset only when it is Primary", "[IMEXZones]")
{
PrinterCfg p;
p.gantry_count = 1;
p.tools_per_gantry = 2;
p.active_tools = "0:P,1:M";
SECTION("a plate still on Primary picks up the process preset's mode") {
const ImexZoneLayout l = layout_for(p, kImexPrimaryMode, kMode);
REQUIRE(l.primary_zone_box.has_value());
check_rect(*l.primary_zone_box, 0.0, 0.0, 100.0, 200.0);
}
SECTION("a plate with its own mode ignores the process preset") {
// The process preset names a mode that does not exist; the plate's own mode wins,
// so the layout is still built from kMode's tools.
const ImexZoneLayout l = layout_for(p, kMode, "no-such-mode");
REQUIRE(l.primary_zone_box.has_value());
check_rect(*l.primary_zone_box, 0.0, 0.0, 100.0, 200.0);
}
SECTION("Primary on both sides yields no zones at all") {
CHECK(is_empty(layout_for(p, kImexPrimaryMode, std::string())));
CHECK(is_empty(layout_for(p, kImexPrimaryMode, kImexPrimaryMode)));
}
}
TEST_CASE("configurations with nothing to divide yield an empty layout", "[IMEXZones]")
{
PrinterCfg base;
base.gantry_count = 1;
base.tools_per_gantry = 2;
base.active_tools = "0:P,1:M";
SECTION("a non-IMEX printer") {
PrinterCfg p = base;
p.is_imex = false;
CHECK(is_empty(layout_for(p)));
}
SECTION("an empty plate mode") {
CHECK(is_empty(layout_for(base, std::string(), std::string())));
}
SECTION("a single-tool grid has no second zone to dim") {
PrinterCfg p = base;
p.gantry_count = 1;
p.tools_per_gantry = 1;
p.active_tools = "0:P";
CHECK(is_empty(layout_for(p)));
}
SECTION("a mode name that the printer does not define") {
// A stale mode carried in from another printer's process preset: the name resolves
// to no tool roster, so there is nothing to lay out.
CHECK(is_empty(layout_for(base, "carried-over-mode")));
}
SECTION("a mode whose tool roster is empty") {
PrinterCfg p = base;
p.active_tools = "";
CHECK(is_empty(layout_for(p)));
}
}
TEST_CASE("a Primary that is not on the grid yields no zones at all", "[IMEXZones]")
{
// The modes editor preserves tool assignments across imex_gantry_count changes, so a mode
// authored on a 4-tool IQEX with Primary on T3 keeps that assignment after the printer is
// reconfigured as a 2-tool IDEX. T3 is then off the grid and cannot anchor a layout: the
// primary's cell is what every zone, strip and offset is measured from.
PrinterCfg p;
p.gantry_count = 1;
p.tools_per_gantry = 2;
p.active_tools = "3:P,0:M";
p.clearance_x = 5.0;
p.margin = 2.0;
const ImexZoneLayout l = layout_for(p);
CHECK(is_empty(l));
// Spelled out, because the failure this guards is a self-contradiction rather than a
// wrong rectangle: the primary zone used to report the whole bed printable while the
// secondary box reported the very same rectangle blocked.
CHECK(l.primary_head == -1);
CHECK_FALSE(l.primary_zone_box.has_value());
CHECK(l.secondary_zone_boxes.empty());
CHECK(l.mirror_zones.empty());
// No zone centre either, so no ghost is placed from a cell no tool occupies.
CHECK(l.head_zone_centers.empty());
// And with no primary zone, a firmware-managed printer shifts the slice by nothing
// rather than by the bed centre.
check_point(compute_imex_slice_offset(true, kMode, l.primary_zone_box), 0.0, 0.0);
}
TEST_CASE("a mode with no Primary marker at all yields no zones", "[IMEXZones]")
{
// Same defect by a different route: every tool is a secondary, so there is no `:P` to
// anchor the layout even though the roster is non-empty and entirely on the grid.
PrinterCfg p;
p.gantry_count = 1;
p.tools_per_gantry = 2;
p.active_tools = "0:M,1:M";
p.clearance_x = 5.0;
CHECK(is_empty(layout_for(p)));
}
TEST_CASE("a Primary on the last tool of the grid lays out normally", "[IMEXZones]")
{
// Boundary of the bound that drops an off-grid Primary: the highest valid index must
// still be accepted, so the guard cannot be an off-by-one that swallows real modes.
SECTION("last column of a 1x2 IDEX") {
PrinterCfg p;
p.gantry_count = 1;
p.tools_per_gantry = 2;
p.active_tools = "1:P,0:M";
p.clearance_x = 5.0;
p.margin = 2.0;
const ImexZoneLayout l = layout_for(p);
REQUIRE(l.primary_head == 1);
REQUIRE(l.primary_zone_box.has_value());
check_rect(*l.primary_zone_box, 100.0, 0.0, 200.0, 200.0);
REQUIRE(l.mirror_zones.size() == 1);
check_rect(l.mirror_zones[0], 0.0, 0.0, 100.0, 200.0);
REQUIRE(l.head_zone_centers.size() == 2);
check_point(l.head_zone_centers.at(0), 50.0, 100.0);
check_point(l.head_zone_centers.at(1), 150.0, 100.0);
// The mirror sits to the primary's left, so the strip hugs the primary zone's
// left boundary and the advisory band lies further inside it.
REQUIRE(l.collision_zones.size() == 1);
check_box_xy(l.collision_zones[0], 100.0, 0.0, 105.0, 200.0);
REQUIRE(l.margin_bands.size() == 1);
check_rect(l.margin_bands[0], 105.0, 0.0, 107.0, 200.0);
}
SECTION("last tool of a 2x2 IQEX") {
PrinterCfg p;
p.gantry_count = 2;
p.tools_per_gantry = 2;
p.active_tools = "3:P,0:M";
p.clearance_x = 5.0;
p.clearance_y = 5.0;
const ImexZoneLayout l = layout_for(p);
REQUIRE(l.primary_head == 3);
REQUIRE(l.primary_zone_box.has_value());
check_rect(*l.primary_zone_box, 100.0, 100.0, 200.0, 200.0);
REQUIRE(l.mirror_zones.size() == 1);
check_rect(l.mirror_zones[0], 0.0, 0.0, 100.0, 100.0);
// T0 is diagonally opposite the primary, so the gantries never overlap and no
// boundary of the primary zone is a collision risk.
CHECK(l.collision_zones.empty());
}
}
TEST_CASE("zero clearance and zero margin suppress the strips they size", "[IMEXZones]")
{
// The strip width is the literal clearance value, so a printer that has not configured
// one gets no strip rather than a zero-width box the placement check would still test.
PrinterCfg p;
p.gantry_count = 1;
p.tools_per_gantry = 2;
p.active_tools = "0:P,1:M";
SECTION("no clearance, no strip") {
const ImexZoneLayout l = layout_for(p);
CHECK(l.collision_zones.empty());
CHECK(l.margin_bands.empty());
}
SECTION("clearance without margin gives the strip but no advisory band") {
p.clearance_x = 5.0;
const ImexZoneLayout l = layout_for(p);
CHECK(l.collision_zones.size() == 1);
CHECK(l.margin_bands.empty());
}
}
TEST_CASE("an aggregated gantry raises its collision strip from the zone it paints", "[IMEXZones]")
{
// A Span partner on the primary's gantry collapses the far gantry to a single cell,
// pinned to the primary's column and expanded into a full-bed-width row strip. The
// danger strip on the primary's rear boundary has to follow that painted strip, not any
// one head's own grid cell -- with three tools per gantry the surviving mirrors sit in
// columns 1 and 2, neither of which is the primary's, yet the zone they jointly paint
// still runs the full width of the bed and still closes on the primary's carriage.
//
// Both rosters describe the same machine geometry (one primary gantry, one mirrored
// gantry behind it), so both must produce the same rectangles.
struct Case { const char* name; int tools_per_gantry; const char* active_tools; };
const Case cases[] = {
{ "two tools per gantry, mirrors on T2 and T3", 2, "0:P,1:S,2:M,3:M" },
{ "three tools per gantry, mirrors on T4 and T5", 3, "0:P,1:S,4:M,5:M" },
};
for (const Case& c : cases) {
DYNAMIC_SECTION(c.name) {
PrinterCfg p;
p.gantry_count = 2;
p.tools_per_gantry = c.tools_per_gantry;
p.active_tools = c.active_tools;
p.clearance_x = 6.0;
p.clearance_y = 4.0;
p.margin = 2.0;
const ImexZoneLayout l = layout_for(p);
REQUIRE(l.primary_head == 0);
REQUIRE(l.primary_zone_box.has_value());
check_rect(*l.primary_zone_box, 0.0, 0.0, 200.0, 100.0);
// One merged rear strip for the whole far gantry, however many heads it carries.
CHECK(l.copy_zones.empty());
REQUIRE(l.mirror_zones.size() == 1);
check_rect(l.mirror_zones[0], 0.0, 100.0, 200.0, 200.0);
REQUIRE(l.secondary_zone_boxes.size() == 1);
check_box_xy(l.secondary_zone_boxes[0], 0.0, 100.0, 200.0, 200.0);
// The rear boundary is the collision risk, one clearance_y deep and as wide as
// the widened primary zone. Exactly one strip: the two gantries share no X
// extent, so neither side boundary may raise one of its own.
REQUIRE(l.collision_zones.size() == 1);
check_box_xy(l.collision_zones[0], 0.0, 96.0, 200.0, 100.0);
// And the advisory band immediately inside it, one margin deep.
REQUIRE(l.margin_bands.size() == 1);
check_rect(l.margin_bands[0], 0.0, 94.0, 200.0, 96.0);
}
}
}
@@ -12,11 +12,16 @@
#include <string>
#include <catch2/catch_test_macros.hpp>
#include <catch2/matchers/catch_matchers.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp>
#include "libslic3r/Geometry.hpp"
#include "libslic3r/TriangleMesh.hpp"
#include "test_utils.hpp"
using namespace Slic3r;
using Catch::Matchers::WithinAbs;
using Catch::Matchers::WithinRel;
TEST_CASE("Split empty mesh", "[its_split][its]") {
@@ -317,3 +322,75 @@ TEST_CASE("Simplified cube should not be empty.", "[its]")
its_quadric_edge_collapse(its, wanted_count, &max_error);
CHECK(!its.indices.empty());
}
TEST_CASE("A box far from the origin has its center of mass at its center and spreads as a box", "[its]")
{
indexed_triangle_set box = its_make_cube(10., 20., 30.);
for (Vec3f &v : box.vertices)
v += Vec3f(1000.f, 2000.f, 300.f);
const MassProperties solid = its_mass_properties(box);
CHECK_THAT(solid.volume, WithinRel(10. * 20. * 30., 1e-6));
CHECK_THAT(solid.mass, WithinRel(solid.volume, 1e-12));
CHECK_THAT(solid.center.x(), WithinAbs(1005., 1e-6));
CHECK_THAT(solid.center.y(), WithinAbs(2010., 1e-6));
CHECK_THAT(solid.center.z(), WithinAbs(315., 1e-6));
// A box of side a spreads a^2 / 12 along it.
const Matrix3d spread = Vec3d(100., 400., 900.).asDiagonal() * (1. / 12.);
CHECK_THAT((solid.spread - spread).norm(), WithinAbs(0., 1e-6));
}
TEST_CASE("The center of mass of a cone lies a quarter of its height above the base", "[its]")
{
// Neither the surface centroid nor the vertex average lands there.
const double h = 40.;
const MassProperties solid = its_mass_properties(its_make_cone(10., h));
CHECK(solid.volume > 0.);
CHECK_THAT(solid.center.z(), WithinAbs(h / 4., 1e-4));
CHECK_THAT(solid.center.x(), WithinAbs(0., 1e-4));
CHECK_THAT(solid.center.y(), WithinAbs(0., 1e-4));
// 3 h^2 / 80 along the axis.
CHECK_THAT(solid.spread(2, 2), WithinRel(3. * h * h / 80., 1e-4));
}
TEST_CASE("A cavity moves the center of mass away from it", "[its]")
{
indexed_triangle_set solid = its_make_cube(20., 20., 20.);
indexed_triangle_set cavity = its_make_cube(10., 10., 8.);
for (Vec3f &v : cavity.vertices)
v += Vec3f(5.f, 5.f, 10.f);
its_flip_triangles(cavity);
its_merge(solid, cavity);
const MassProperties hollow = its_mass_properties(solid);
// A 20 mm cube centered at z 10 less a 10x10x8 mm cavity centered at z 14.
CHECK_THAT(hollow.volume, WithinRel(8000. - 800., 1e-6));
CHECK_THAT(hollow.center.x(), WithinAbs(10., 1e-6));
CHECK_THAT(hollow.center.y(), WithinAbs(10., 1e-6));
CHECK_THAT(hollow.center.z(), WithinAbs((8000. * 10. - 800. * 14.) / (8000. - 800.), 1e-6));
}
TEST_CASE("The mass properties follow an affine transformation of the mesh", "[its]")
{
indexed_triangle_set cone = its_make_cone(10., 40.);
const MassProperties solid = its_mass_properties(cone);
const Transform3d trafo = Geometry::translation_transform({ 50., -20., 7. }) * Geometry::rotation_transform({ 0.3, -0.5, 1.2 }) *
Geometry::scale_transform({ 2., 0.5, 1.5 });
for (Vec3f &v : cone.vertices)
v = (trafo * v.cast<double>()).cast<float>();
const MassProperties moved = its_mass_properties(cone);
const MassProperties expected = solid.transformed(trafo);
CHECK_THAT(moved.volume, WithinRel(expected.volume, 1e-5));
CHECK_THAT(moved.mass, WithinRel(expected.mass, 1e-5));
CHECK_THAT((moved.center - expected.center).norm(), WithinAbs(0., 1e-4));
CHECK_THAT((moved.spread - expected.spread).norm(), WithinAbs(0., 1e-3));
}
TEST_CASE("Flipped faces keep the mass properties", "[its]")
{
indexed_triangle_set cone = its_make_cone(10., 40.);
const MassProperties solid = its_mass_properties(cone);
its_flip_triangles(cone);
const MassProperties flipped = its_mass_properties(cone);
CHECK_THAT(flipped.volume, WithinRel(solid.volume, 1e-9));
CHECK_THAT((flipped.center - solid.center).norm(), WithinAbs(0., 1e-9));
CHECK_THAT((flipped.spread - solid.spread).norm(), WithinAbs(0., 1e-9));
}
+39 -1
View File
@@ -1,9 +1,11 @@
#include <catch2/catch_all.hpp>
#include <catch2/catch_test_macros.hpp>
#include <catch2/catch_approx.hpp>
#include <catch2/matchers/catch_matchers.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp>
#include <libslic3r/TriangleMesh.hpp>
#include <libslic3r/MeshBoolean.hpp>
#include <vector>
using namespace Slic3r;
@@ -24,3 +26,39 @@ TEST_CASE("CGAL and TriangleMesh conversions", "[MeshBoolean]") {
REQUIRE(! MeshBoolean::cgal::does_self_intersect(M));
}
TEST_CASE("mcut difference handles source splits between cuts", "[MeshBoolean]") {
TriangleMesh body = make_cube(30., 10., 10.);
TriangleMesh tool;
// First cut splits the source into two disconnected components.
TriangleMesh slab = make_cube(2., 12., 20.);
slab.translate(Vec3f(14.f, -1.f, -5.f));
its_merge(tool.its, slab.its);
// These cuts must still be applied after the source has been split.
TriangleMesh left_hole = make_cube(4., 4., 20.);
left_hole.translate(Vec3f(3.f, 3.f, -5.f));
its_merge(tool.its, left_hole.its);
TriangleMesh right_hole = make_cube(4., 4., 20.);
right_hole.translate(Vec3f(21.f, 3.f, -5.f));
its_merge(tool.its, right_hole.its);
std::vector<TriangleMesh> result;
MeshBoolean::mcut::make_boolean(body, tool, result, "A_NOT_B");
REQUIRE(result.size() == 1);
const std::vector<indexed_triangle_set> components =
its_split(result.front().its);
REQUIRE(components.size() == 2);
// 3000 - 200 - 160 - 160 = 2480.
REQUIRE_THAT(
result.front().volume(),
Catch::Matchers::WithinRel(2480., 1e-3)
);
}
+53
View File
@@ -1,12 +1,20 @@
#include <catch2/catch_all.hpp>
#include <algorithm>
#include <utility>
#include <vector>
#include "libslic3r/TriangleMesh.hpp"
#include "libslic3r/Polygon.hpp"
#include "libslic3r/BoundingBox.hpp"
#include "libslic3r/Point.hpp"
#include <catch2/catch_test_macros.hpp>
#include <catch2/catch_message.hpp>
#include <catch2/generators/catch_generators.hpp>
#include <catch2/matchers/catch_matchers.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp>
#include "libslic3r/Model.hpp"
#include "libslic3r/Geometry.hpp"
#include "libslic3r/libslic3r.h"
using namespace Slic3r;
@@ -44,3 +52,48 @@ TEST_CASE("A part's 2D convex hull is its footprint projected onto the bed", "[M
CHECK(bb.max.y() == scaled(45.));
}
}
TEST_CASE("An object's raw mesh keeps the triangles of each part on its own vertices", "[Model]")
{
Model model;
ModelObject *object = model.add_object();
object->add_volume(make_cube(10, 10, 10), ModelVolumeType::MODEL_PART, false);
TriangleMesh second = make_cube(10, 10, 10);
second.translate(30, 0, 0);
object->add_volume(std::move(second), ModelVolumeType::MODEL_PART, false);
// Two separate cubes stay two closed components, one around each cube.
const std::vector<indexed_triangle_set> parts = its_split(object->raw_indexed_triangle_set());
REQUIRE(parts.size() == 2);
std::vector<double> min_x;
for (const indexed_triangle_set &part : parts) {
CHECK(part.indices.size() == 12);
min_x.push_back(bounding_box(part).min.x());
}
std::sort(min_x.begin(), min_x.end());
CHECK_THAT(min_x.front(), Catch::Matchers::WithinAbs(0., 1e-4));
CHECK_THAT(min_x.back(), Catch::Matchers::WithinAbs(30., 1e-4));
}
TEST_CASE("An instance added from another's scale, rotation and mirror matches it", "[Model]")
{
// Add instance and Fill bed copy an instance this way.
const int case_idx = GENERATE(0, 1);
Transform3d trafo = Transform3d::Identity();
if (case_idx == 0)
trafo.linear() = Eigen::AngleAxisd(0.5 * PI, Vec3d::UnitZ()).toRotationMatrix() * Vec3d(-1., 1., 1.).asDiagonal();
else
trafo.linear() << 4.4408921e-16, 0.819152044, 0.573576436,
1.0, -4.4408921e-16, -1.11022302e-16,
-5.55111512e-17, -0.573576436, 0.819152044;
CAPTURE(case_idx);
Model model;
ModelObject *object = model.add_object();
object->add_volume(make_cube(10, 20, 30));
ModelInstance *original = object->add_instance();
original->set_transformation(Geometry::Transformation(trafo));
const ModelInstance *copy = object->add_instance(original->get_offset(), original->get_scaling_factor(),
original->get_rotation(), original->get_mirror());
CHECK(copy->get_matrix().linear().isApprox(original->get_matrix().linear(), 1e-9));
}
+83
View File
@@ -0,0 +1,83 @@
#include <catch2/catch_all.hpp>
#include <cstdint>
#include <fstream>
#include <iterator>
#include <vector>
#include <boost/filesystem.hpp>
#include "libslic3r/PNGReadWrite.hpp"
using namespace Slic3r;
// libpng reports a corrupt or truncated file by longjmp()ing out of the decoder, so the decoders have
// to come back with false rather than crash or hand back a half filled image.
namespace {
// A real PNG, produced by the writer next door, so the bytes are a file libpng accepts.
std::vector<uint8_t> encoded_png(size_t w, size_t h)
{
std::vector<uint8_t> pixels(w * h);
for (size_t i = 0; i < pixels.size(); ++ i)
pixels[i] = uint8_t((i * 7) % 256);
const boost::filesystem::path path = boost::filesystem::temp_directory_path() /
boost::filesystem::unique_path("png_rw_%%%%%%%%.png");
REQUIRE(png::write_gray_to_file(path.string(), w, h, pixels));
std::vector<uint8_t> bytes;
{
std::ifstream ifs(path.string(), std::ios::binary);
bytes.assign(std::istreambuf_iterator<char>(ifs), std::istreambuf_iterator<char>());
}
boost::system::error_code ec;
boost::filesystem::remove(path, ec);
REQUIRE(bytes.size() > 64);
return bytes;
}
png::ReadBuf buf_of(const std::vector<uint8_t> &bytes, size_t size)
{
return png::ReadBuf{ bytes.data(), size };
}
} // namespace
TEST_CASE("A whole PNG decodes", "[PNG]") {
const std::vector<uint8_t> bytes = encoded_png(24, 16);
png::ImageGreyscale grey;
REQUIRE(png::decode_png(buf_of(bytes, bytes.size()), grey));
CHECK(grey.cols == 24);
CHECK(grey.rows == 16);
CHECK(grey.buf.size() == 24 * 16);
}
TEST_CASE("A truncated PNG is refused instead of crashing", "[PNG]") {
const std::vector<uint8_t> bytes = encoded_png(64, 64);
// Cut past the signature: inside the header, and inside the pixel data. Not in the trailing
// chunks - decode_png() does not read those, so a file missing only its IEND still decodes, and
// that is the pre-existing contract rather than anything this change touches.
const size_t size = GENERATE_COPY(size_t(16), size_t(40), bytes.size() / 2, bytes.size() * 3 / 4);
REQUIRE(size < bytes.size());
png::ImageGreyscale grey;
CHECK_FALSE(png::decode_png(buf_of(bytes, size), grey));
png::ImageColorscale colour;
CHECK_FALSE(png::decode_colored_png(buf_of(bytes, size), colour));
}
TEST_CASE("A PNG whose body is garbage is refused", "[PNG]") {
std::vector<uint8_t> bytes = encoded_png(32, 32);
// Keep the signature, scribble over everything after it.
for (size_t i = 8; i < bytes.size(); ++ i)
bytes[i] = uint8_t(0xA5);
png::ImageGreyscale grey;
CHECK_FALSE(png::decode_png(buf_of(bytes, bytes.size()), grey));
png::ImageColorscale colour;
CHECK_FALSE(png::decode_colored_png(buf_of(bytes, bytes.size()), colour));
}
+643 -1
View File
@@ -6,6 +6,8 @@
#include <algorithm>
#include <boost/filesystem.hpp>
#include <boost/nowide/convert.hpp>
#include <boost/nowide/fstream.hpp>
#include <cstddef>
#include <fstream>
@@ -15,16 +17,17 @@
#include <catch2/catch_message.hpp>
#include <catch2/generators/catch_generators.hpp>
#include "libslic3r/PresetBundle.hpp"
#include "libslic3r/IMEXHelpers.hpp"
#include "libslic3r/ParallelResolve.hpp"
#include "libslic3r/AppConfig.hpp"
#include "libslic3r/Model.hpp"
#include "libslic3r/TriangleMesh.hpp"
#include "libslic3r/Utils.hpp"
#include "libslic3r/miniz_extension.hpp"
#include "libslic3r/PrinterBedAssets.hpp"
#include "test_utils.hpp"
#include <algorithm>
#include <iostream>
#include <initializer_list>
#include <vector>
@@ -5870,6 +5873,630 @@ TEST_CASE("Config import confines zip entries, preset names and bundle ids to th
}
}
namespace {
const std::string kTextureBytes("\x89PNG\x00tex", 8);
const std::string kModelBytes("stl\x00model", 9);
const std::string kOtherBytes("\x89PNG\x00other", 10);
void write_bytes(const std::string &utf8_path, const std::string &bytes)
{
const auto slash = utf8_path.find_last_of("/\\");
if (slash != std::string::npos) {
#ifdef _WIN32
fs::create_directories(fs::path(boost::nowide::widen(utf8_path.substr(0, slash))));
#else
fs::create_directories(utf8_path.substr(0, slash));
#endif
}
boost::nowide::ofstream out(utf8_path, std::ios::binary | std::ios::trunc);
REQUIRE(out);
out.write(bytes.data(), static_cast<std::streamsize>(bytes.size()));
REQUIRE(out);
}
fs::path write_printer_json(const DynamicPrintConfig &defaults, const fs::path &file, const std::string &name,
const std::string &texture, const std::string &model, const std::string &inherits = {})
{
DynamicPrintConfig config(defaults);
config.option<ConfigOptionString>("printer_settings_id", true)->value = name;
config.option<ConfigOptionString>("inherits", true)->value = inherits;
if (texture.empty())
config.erase("bed_custom_texture");
else
config.option<ConfigOptionString>("bed_custom_texture", true)->value = texture;
if (model.empty())
config.erase("bed_custom_model");
else
config.option<ConfigOptionString>("bed_custom_model", true)->value = model;
fs::create_directories(file.parent_path());
config.save_to_json(file.string(), name, "User", "1.0.0");
return file;
}
nlohmann::json bed_manifest(const std::string &bundle_id)
{
nlohmann::json structure = nlohmann::json::object();
structure["id"] = bundle_id;
structure["bundle_type"] = "printer config bundle";
structure["printer_config"] = nlohmann::json::array({"printer/BedPrinter.json"});
return structure;
}
void set_printer_bed_asset(nlohmann::json &structure, const std::string &option, const std::string &zip_name,
const std::string &printer_entry = "printer/BedPrinter.json")
{
structure["printer_asset"][printer_entry][option] = zip_name;
}
nlohmann::json pack_bed_zip(const fs::path &zip_file, const fs::path &printer_json, const DynamicPrintConfig &defaults,
const std::string &texture, const std::string &model, const std::string &bundle_id,
bool include_bed_assets = true)
{
DynamicPrintConfig config(defaults);
if (!texture.empty())
config.option<ConfigOptionString>("bed_custom_texture", true)->value = texture;
if (!model.empty())
config.option<ConfigOptionString>("bed_custom_model", true)->value = model;
nlohmann::json structure = bed_manifest(bundle_id);
mz_zip_archive zip;
mz_zip_zero_struct(&zip);
const std::string printer_bytes = read_file(printer_json);
REQUIRE(open_zip_writer(&zip, zip_file.string()));
REQUIRE(mz_zip_writer_add_mem(&zip, "printer/BedPrinter.json", printer_bytes.data(), printer_bytes.size(),
MZ_DEFAULT_COMPRESSION));
if (include_bed_assets)
REQUIRE(append_printer_bed_assets(zip, config, structure, "printer/BedPrinter.json", 0));
const std::string manifest = structure.dump();
REQUIRE(mz_zip_writer_add_mem(&zip, BUNDLE_STRUCTURE_JSON_NAME, manifest.data(), manifest.size(),
MZ_DEFAULT_COMPRESSION));
REQUIRE(mz_zip_writer_finalize_archive(&zip));
REQUIRE(close_zip_writer(&zip));
return structure;
}
std::vector<std::string> zip_entry_names(const fs::path &zip_file)
{
mz_zip_archive zip;
mz_zip_zero_struct(&zip);
REQUIRE(open_zip_reader(&zip, zip_file.string()));
std::vector<std::string> names;
const int n = static_cast<int>(mz_zip_reader_get_num_files(&zip));
for (int i = 0; i < n; ++i) {
mz_zip_archive_file_stat st;
REQUIRE(mz_zip_reader_file_stat(&zip, i, &st));
names.emplace_back(st.m_filename);
}
REQUIRE(close_zip_reader(&zip));
return names;
}
const Preset *import_named_printer(PresetBundle &bundle, const fs::path &zip_file, const fs::path &data_root,
const std::string &bare_name, int confirm = 1)
{
ScopedDataDir scoped(data_root);
AppConfig config;
std::vector<std::string> files{zip_file.string()};
bundle.import_presets(files, [confirm](std::string const &) { return confirm; },
ForwardCompatibilitySubstitutionRule::Disable, config);
for (const Preset &preset : bundle.printers.get_presets())
if (get_preset_bare_name(preset.name) == bare_name && !preset.file.empty())
return &preset;
return nullptr;
}
const Preset *import_bed(PresetBundle &bundle, const fs::path &zip_file, const fs::path &data_root, int confirm = 1)
{
return import_named_printer(bundle, zip_file, data_root, "BedPrinter", confirm);
}
struct BedRoots {
ScopedTemporaryDir temp_dir;
fs::path src;
fs::path data_root;
PresetBundle bundle;
BedRoots()
: src(temp_dir.path() / "src")
, data_root(temp_dir.path() / "datadir")
{}
};
} // namespace
TEST_CASE("Printer bundle round trip packs bed texture and model and rewrites both keys", "[Preset][Bundle]")
{
// tex_rel "*" builds a non-ASCII source path under the temp directory.
const auto pack_import = [](const std::string &bundle_id, const std::string &tex_rel, const std::string &mod_rel,
const std::string &tex_bytes, const std::string &mod_bytes, bool tex_exists,
bool mod_exists, int imports) {
ScopedTemporaryDir temp_dir;
const fs::path src = temp_dir.path() / "src";
const fs::path data_root = temp_dir.path() / "datadir";
std::string texture = tex_rel.empty() ? std::string() : (src / tex_rel).make_preferred().string();
std::string model = mod_rel.empty() ? std::string() : (src / mod_rel).make_preferred().string();
if (tex_rel == "*") {
const fs::path dir = fs::path(src.wstring()) / fs::path(boost::nowide::widen("\xE5\xBA\x8A"));
texture = boost::nowide::narrow((dir / fs::path(boost::nowide::widen("\xE7\xBA\xB9\xE7\x90\x86.png"))).wstring());
}
if (tex_exists)
write_bytes(texture, tex_bytes);
if (mod_exists)
write_bytes(model, mod_bytes);
PresetBundle bundle;
const fs::path printer_json = write_printer_json(bundle.printers.default_preset().config, src / "BedPrinter.json",
"BedPrinter", texture, model);
const fs::path zip_file = src / "bundle.orca_printer";
const nlohmann::json structure = pack_bed_zip(zip_file, printer_json, bundle.printers.default_preset().config,
texture, model, bundle_id);
const Preset *saved = nullptr;
for (int i = 0; i < imports; ++i)
saved = import_bed(bundle, zip_file, data_root);
REQUIRE(saved != nullptr);
const std::string got[] = {saved->config.opt_string("bed_custom_texture"), saved->config.opt_string("bed_custom_model")};
const std::string rel[] = {tex_rel, mod_rel};
const std::string bytes[] = {tex_bytes, mod_bytes};
const std::string path[] = {texture, model};
const bool exists[] = {tex_exists, mod_exists};
const char *option[] = {"bed_custom_texture", "bed_custom_model"};
const char *stem[] = {"texture", "model"};
for (int slot = 0; slot < 2; ++slot) {
if (exists[slot]) {
const std::string ext = fs::path(rel[slot] == "*" ? "x.png" : rel[slot]).extension().string();
const std::string zip_name = std::string("bed/0/") + stem[slot] + ext;
REQUIRE(structure["printer_asset"]["printer/BedPrinter.json"][option[slot]] == zip_name);
REQUIRE(fs::path(got[slot]).filename().string() == std::string(stem[slot]) + fs::path(zip_name).extension().string());
REQUIRE(fs::path(got[slot]).parent_path().filename() == "0");
REQUIRE(fs::path(got[slot]).parent_path().parent_path().filename() == "bed");
REQUIRE(got[slot].find(bundle_id) != std::string::npos);
REQUIRE(read_file(got[slot]) == bytes[slot]);
REQUIRE(got[slot] != path[slot]);
} else if (!rel[slot].empty() && rel[slot] != "*") {
REQUIRE_FALSE(structure.value("printer_asset", nlohmann::json::object())
.value("printer/BedPrinter.json", nlohmann::json::object())
.contains(option[slot]));
REQUIRE(got[slot] == path[slot]);
} else if (rel[slot].empty()) {
REQUIRE(got[slot].empty());
}
}
if (imports > 1) {
int copies = 0;
for (fs::recursive_directory_iterator it(data_root), end; it != end; ++it)
if (it->path().filename() == "texture.png" || it->path().filename() == "model.stl")
++copies;
REQUIRE(copies == (tex_exists ? 1 : 0) + (mod_exists ? 1 : 0));
}
};
SECTION("both assets are packed from the preset config and rewritten on import") {
pack_import("bed-roundtrip", "fixture/baseline_bed_texture.png", "fixture/baseline_bed_model.stl",
kTextureBytes, kModelBytes, true, true, 1);
}
SECTION("texture only") {
pack_import("texture-only", "only.png", "", kTextureBytes, "", true, false, 1);
}
SECTION("model only") {
pack_import("model-only", "", "only.stl", "", kModelBytes, false, true, 1);
}
SECTION("texture and model share a source basename") {
pack_import("shared-basename", "a/plate.png", "b/plate.stl", kTextureBytes, kOtherBytes, true, true, 1);
}
SECTION("importing the same bundle twice keeps one copy of the assets") {
// First import installs kTextureBytes / kModelBytes. The second pack uses different bytes so
// the Yes-overwrite path (copy_file overwrite_existing) is actually proven.
BedRoots bed;
const std::string texture = (bed.src / "twice.png").make_preferred().string();
const std::string model = (bed.src / "twice.stl").make_preferred().string();
write_bytes(texture, kTextureBytes);
write_bytes(model, kModelBytes);
const fs::path printer_json = write_printer_json(bed.bundle.printers.default_preset().config,
bed.src / "BedPrinter.json", "BedPrinter", texture, model);
const fs::path zip_file = bed.src / "bundle.orca_printer";
pack_bed_zip(zip_file, printer_json, bed.bundle.printers.default_preset().config, texture, model, "reimport");
const Preset *saved = import_bed(bed.bundle, zip_file, bed.data_root);
REQUIRE(saved != nullptr);
const std::string got_tex = saved->config.opt_string("bed_custom_texture");
const std::string got_mod = saved->config.opt_string("bed_custom_model");
REQUIRE(read_file(got_tex) == kTextureBytes);
REQUIRE(read_file(got_mod) == kModelBytes);
write_bytes(texture, kOtherBytes);
write_bytes(model, std::string("stl\x00other", 9));
pack_bed_zip(zip_file, printer_json, bed.bundle.printers.default_preset().config, texture, model, "reimport");
saved = import_bed(bed.bundle, zip_file, bed.data_root);
REQUIRE(saved != nullptr);
REQUIRE(saved->config.opt_string("bed_custom_texture") == got_tex);
REQUIRE(saved->config.opt_string("bed_custom_model") == got_mod);
REQUIRE(read_file(got_tex) == kOtherBytes);
REQUIRE(read_file(got_mod) == std::string("stl\x00other", 9));
}
SECTION("a non-ASCII source path is packed") {
pack_import("non-ascii", "*", "", kTextureBytes, "", true, false, 1);
}
}
TEST_CASE("An old printer bundle without printer_asset imports unchanged", "[Preset][Bundle]")
{
BedRoots bed;
const std::string texture = (bed.src / "old.png").make_preferred().string();
const std::string model = (bed.src / "old.stl").make_preferred().string();
write_bytes(texture, kTextureBytes);
write_bytes(model, kModelBytes);
const fs::path printer_json = write_printer_json(bed.bundle.printers.default_preset().config, bed.src / "BedPrinter.json",
"BedPrinter", texture, model);
nlohmann::json structure = bed_manifest("old-bundle");
REQUIRE_FALSE(structure.contains("printer_asset"));
const fs::path zip_file = bed.src / "old.orca_printer";
write_zip(zip_file, {{"printer/BedPrinter.json", read_file(printer_json)}, {BUNDLE_STRUCTURE_JSON_NAME, structure.dump()}});
// A bed/ folder left by an earlier import into the same bundle folder must survive an asset-less import.
const fs::path old_bed = bed.data_root / "user" / "default" / "_local" / "old-bundle" / "bed";
write_bytes((old_bed / "texture.png").string(), kTextureBytes);
const Preset *saved = import_bed(bed.bundle, zip_file, bed.data_root);
REQUIRE(saved != nullptr);
REQUIRE(saved->config.opt_string("bed_custom_texture") == texture);
REQUIRE(saved->config.opt_string("bed_custom_model") == model);
CHECK(fs::exists(old_bed / "texture.png"));
}
TEST_CASE("Printer bundle asset names that escape the bundle directory are rejected", "[Preset][Bundle]")
{
const char *bad_names[] = {"../../pwned.png", "bed/../../pwned.png", "..\\..\\pwned.png", "C:/Windows/pwned.png",
"machine/texture.png", "/bed/texture.png"};
const std::string marker = "PWNED-BED-ASSET-MARKER";
for (const char *name : bad_names) {
DYNAMIC_SECTION("rejects " << name) {
BedRoots bed;
const std::string texture = (bed.src / "kept.png").make_preferred().string();
const std::string model = (bed.src / "kept.stl").make_preferred().string();
write_bytes(texture, kTextureBytes);
write_bytes(model, kModelBytes);
const fs::path printer_json = write_printer_json(bed.bundle.printers.default_preset().config,
bed.src / "BedPrinter.json", "BedPrinter", texture, model);
nlohmann::json structure = bed_manifest("traverse");
set_printer_bed_asset(structure, "bed_custom_texture", name);
std::vector<std::pair<std::string, std::string>> entries = {
{"printer/BedPrinter.json", read_file(printer_json)}, {BUNDLE_STRUCTURE_JSON_NAME, structure.dump()}};
if (name[0] != '/' && name[0] != '\\')
entries.emplace_back(name, marker);
const fs::path zip_file = bed.src / "bad.orca_printer";
write_zip(zip_file, entries);
const Preset *saved = import_bed(bed.bundle, zip_file, bed.data_root);
REQUIRE(saved != nullptr);
REQUIRE(saved->config.opt_string("bed_custom_texture") == texture);
REQUIRE(saved->config.opt_string("bed_custom_model") == model);
CHECK_FALSE(any_filename_contains(bed.data_root, fs::path(name).filename().string()));
if (fs::exists(bed.data_root))
for (fs::recursive_directory_iterator it(bed.data_root), end; it != end; ++it)
if (fs::is_regular_file(*it))
CHECK(read_file(*it).find(marker) == std::string::npos);
}
}
}
TEST_CASE("A printer bundle asset that is missing is skipped", "[Preset][Bundle]")
{
SECTION("a manifest entry with no zip bytes leaves the original path") {
BedRoots bed;
const std::string texture = (bed.src / "gone.png").make_preferred().string();
write_bytes(texture, kTextureBytes);
const fs::path printer_json = write_printer_json(bed.bundle.printers.default_preset().config, bed.src / "BedPrinter.json",
"BedPrinter", texture, {});
nlohmann::json structure = bed_manifest("missing-zip");
set_printer_bed_asset(structure, "bed_custom_texture", "bed/0/texture.png");
const fs::path zip_file = bed.src / "missing.orca_printer";
write_zip(zip_file, {{"printer/BedPrinter.json", read_file(printer_json)}, {BUNDLE_STRUCTURE_JSON_NAME, structure.dump()}});
const Preset *saved = import_bed(bed.bundle, zip_file, bed.data_root);
REQUIRE(saved != nullptr);
REQUIRE(saved->config.opt_string("bed_custom_texture") == texture);
CHECK_FALSE(any_filename_contains(bed.data_root, "texture.png"));
}
SECTION("a missing file at export is omitted and the export still succeeds") {
BedRoots bed;
const std::string missing = (bed.src / "missing.png").make_preferred().string();
const std::string model = (bed.src / "present.stl").make_preferred().string();
write_bytes(model, kModelBytes);
const fs::path printer_json = write_printer_json(bed.bundle.printers.default_preset().config, bed.src / "BedPrinter.json",
"BedPrinter", missing, model);
const fs::path zip_file = bed.src / "partial.orca_printer";
const nlohmann::json structure = pack_bed_zip(zip_file, printer_json, bed.bundle.printers.default_preset().config,
missing, model, "missing-export");
REQUIRE_FALSE(structure["printer_asset"]["printer/BedPrinter.json"].contains("bed_custom_texture"));
REQUIRE(structure["printer_asset"]["printer/BedPrinter.json"]["bed_custom_model"] == "bed/0/model.stl");
const Preset *saved = import_bed(bed.bundle, zip_file, bed.data_root);
REQUIRE(saved != nullptr);
REQUIRE(saved->config.opt_string("bed_custom_texture") == missing);
REQUIRE(read_file(saved->config.opt_string("bed_custom_model")) == kModelBytes);
REQUIRE(fs::path(saved->config.opt_string("bed_custom_model")).filename() == "model.stl");
CHECK_FALSE(any_filename_contains(bed.data_root, "texture.png"));
}
}
TEST_CASE("An inherited printer bundle rewrites bed paths taken from the parent", "[Preset][Bundle]")
{
BedRoots bed;
const std::string texture = (bed.src / "parent.png").make_preferred().string();
const std::string model = (bed.src / "parent.stl").make_preferred().string();
write_bytes(texture, kTextureBytes);
write_bytes(model, kModelBytes);
DynamicPrintConfig parent(bed.bundle.printers.default_preset().config);
parent.option<ConfigOptionString>("printer_settings_id", true)->value = "ParentPrinter";
parent.option<ConfigOptionString>("inherits", true)->value.clear();
parent.option<ConfigOptionString>("bed_custom_texture", true)->value = texture;
parent.option<ConfigOptionString>("bed_custom_model", true)->value = model;
bed.bundle.printers.load_preset((bed.src / "ParentPrinter.json").string(), "ParentPrinter", std::move(parent), false);
const fs::path child_json = write_printer_json(bed.bundle.printers.default_preset().config, bed.src / "BedPrinter.json",
"BedPrinter", {}, {}, "ParentPrinter");
const nlohmann::json written = nlohmann::json::parse(read_file(child_json));
REQUIRE_FALSE(written.contains("bed_custom_texture"));
REQUIRE_FALSE(written.contains("bed_custom_model"));
nlohmann::json structure = bed_manifest("inherit-bed");
set_printer_bed_asset(structure, "bed_custom_texture", "bed/0/texture.png");
set_printer_bed_asset(structure, "bed_custom_model", "bed/0/model.stl");
const fs::path zip_file = bed.src / "inherit.orca_printer";
write_zip(zip_file, {{"printer/BedPrinter.json", read_file(child_json)}, {BUNDLE_STRUCTURE_JSON_NAME, structure.dump()},
{"bed/0/texture.png", kTextureBytes}, {"bed/0/model.stl", kModelBytes}});
const Preset *saved = import_bed(bed.bundle, zip_file, bed.data_root);
REQUIRE(saved != nullptr);
REQUIRE(saved->config.opt_string("bed_custom_texture") != texture);
REQUIRE(saved->config.opt_string("bed_custom_model") != model);
REQUIRE(read_file(saved->config.opt_string("bed_custom_texture")) == kTextureBytes);
REQUIRE(read_file(saved->config.opt_string("bed_custom_model")) == kModelBytes);
REQUIRE(fs::path(saved->config.opt_string("bed_custom_texture")).filename() == "texture.png");
REQUIRE(fs::path(saved->config.opt_string("bed_custom_model")).filename() == "model.stl");
}
TEST_CASE("A printer bundle rejects bed assets whose extension the bed dialog does not allow", "[Preset][Bundle]")
{
BedRoots bed;
const fs::path printer_json = write_printer_json(bed.bundle.printers.default_preset().config, bed.src / "BedPrinter.json",
"BedPrinter", {}, {});
nlohmann::json structure = bed_manifest("bad-ext");
set_printer_bed_asset(structure, "bed_custom_texture", "bed/0/texture.stl");
set_printer_bed_asset(structure, "bed_custom_model", "bed/0/model.exe");
const fs::path zip_file = bed.src / "bad-ext.orca_printer";
write_zip(zip_file, {{"printer/BedPrinter.json", read_file(printer_json)}, {BUNDLE_STRUCTURE_JSON_NAME, structure.dump()},
{"bed/0/texture.stl", kTextureBytes}, {"bed/0/model.exe", std::string("MZ")}});
REQUIRE(import_bed(bed.bundle, zip_file, bed.data_root) != nullptr);
CHECK_FALSE(any_filename_contains(bed.data_root, "model.exe"));
CHECK_FALSE(any_filename_contains(bed.data_root, "texture.stl"));
}
TEST_CASE("A manifest asset name that points at a preset does not drop that preset", "[Preset][Bundle]")
{
BedRoots bed;
const fs::path printer_json = write_printer_json(bed.bundle.printers.default_preset().config, bed.src / "BedPrinter.json",
"BedPrinter", {}, {});
nlohmann::json structure = bed_manifest("name-clash");
set_printer_bed_asset(structure, "bed_custom_texture", "printer/BedPrinter.json");
const fs::path zip_file = bed.src / "clash.orca_printer";
write_zip(zip_file, {{"printer/BedPrinter.json", read_file(printer_json)}, {BUNDLE_STRUCTURE_JSON_NAME, structure.dump()}});
REQUIRE(import_bed(bed.bundle, zip_file, bed.data_root) != nullptr);
CHECK_FALSE(any_filename_contains(bed.data_root, "texture.png"));
}
TEST_CASE("A declined printer bundle import leaves existing bed files alone", "[Preset][Bundle]")
{
BedRoots bed;
const fs::path kept = bed.data_root / "user" / "default" / "_local" / "keep-bed" / "bed" / "texture.png";
const std::string original("kept-texture");
write_bytes(kept.string(), original);
DynamicPrintConfig existing(bed.bundle.printers.default_preset().config);
existing.option<ConfigOptionString>("printer_settings_id", true)->value = "BedPrinter";
existing.option<ConfigOptionString>("inherits", true)->value.clear();
bed.bundle.printers.load_preset((bed.src / "existing.json").string(), "_local/keep-bed/BedPrinter", std::move(existing),
false);
const fs::path printer_json = write_printer_json(bed.bundle.printers.default_preset().config, bed.src / "BedPrinter.json",
"BedPrinter", {}, {});
nlohmann::json structure = bed_manifest("keep-bed");
set_printer_bed_asset(structure, "bed_custom_texture", "bed/0/texture.png");
set_printer_bed_asset(structure, "bed_custom_model", "bed/0/model.stl");
const fs::path zip_file = bed.src / "keep.orca_printer";
write_zip(zip_file, {{"printer/BedPrinter.json", read_file(printer_json)}, {BUNDLE_STRUCTURE_JSON_NAME, structure.dump()},
{"bed/0/texture.png", kTextureBytes}, {"bed/0/model.stl", kModelBytes}});
import_bed(bed.bundle, zip_file, bed.data_root, 0);
// This import wrote nothing: the existing texture is unchanged and model.stl was not added.
REQUIRE(fs::exists(kept));
REQUIRE(read_file(kept) == original);
CHECK_FALSE(fs::exists(kept.parent_path() / "model.stl"));
CHECK_FALSE(fs::exists(kept.parent_path() / "0" / "model.stl"));
}
TEST_CASE("A declined re-import does not replace bed bytes an earlier import wrote", "[Preset][Bundle]")
{
BedRoots bed;
const fs::path printer_json = write_printer_json(bed.bundle.printers.default_preset().config, bed.src / "BedPrinter.json",
"BedPrinter", {}, {});
nlohmann::json structure = bed_manifest("reimport-no");
set_printer_bed_asset(structure, "bed_custom_texture", "bed/0/texture.png");
const std::string manifest = structure.dump();
const std::string printer_bytes = read_file(printer_json);
const fs::path first_zip = bed.src / "first.orca_printer";
const fs::path second_zip = bed.src / "second.orca_printer";
write_zip(first_zip, {{"printer/BedPrinter.json", printer_bytes}, {BUNDLE_STRUCTURE_JSON_NAME, manifest},
{"bed/0/texture.png", kTextureBytes}});
write_zip(second_zip, {{"printer/BedPrinter.json", printer_bytes}, {BUNDLE_STRUCTURE_JSON_NAME, manifest},
{"bed/0/texture.png", kOtherBytes}});
const Preset *saved = import_bed(bed.bundle, first_zip, bed.data_root);
REQUIRE(saved != nullptr);
const std::string installed = saved->config.opt_string("bed_custom_texture");
REQUIRE(read_file(installed) == kTextureBytes);
import_bed(bed.bundle, second_zip, bed.data_root, 0);
REQUIRE(fs::exists(installed));
REQUIRE(read_file(installed) == kTextureBytes);
}
TEST_CASE("Export with bed assets disabled packs no bed files and writes no printer_asset", "[Preset][Bundle]")
{
BedRoots bed;
const std::string texture = (bed.src / "off.png").make_preferred().string();
const std::string model = (bed.src / "off.stl").make_preferred().string();
write_bytes(texture, kTextureBytes);
write_bytes(model, kModelBytes);
const fs::path printer_json = write_printer_json(bed.bundle.printers.default_preset().config, bed.src / "BedPrinter.json",
"BedPrinter", texture, model);
const fs::path zip_file = bed.src / "off.orca_printer";
const nlohmann::json structure = pack_bed_zip(zip_file, printer_json, bed.bundle.printers.default_preset().config,
texture, model, "bed-off", false);
REQUIRE_FALSE(structure.contains("printer_asset"));
for (const std::string &name : zip_entry_names(zip_file))
REQUIRE(name.compare(0, 4, "bed/") != 0);
}
TEST_CASE("Two printers in one bundle keep separate bed folders", "[Preset][Bundle]")
{
BedRoots bed;
const std::string tex_a = (bed.src / "a.png").make_preferred().string();
const std::string mod_a = (bed.src / "a.stl").make_preferred().string();
const std::string tex_b = (bed.src / "b.png").make_preferred().string();
const std::string mod_b = (bed.src / "b.stl").make_preferred().string();
write_bytes(tex_a, kTextureBytes);
write_bytes(mod_a, kModelBytes);
write_bytes(tex_b, kOtherBytes);
write_bytes(mod_b, std::string("stl\x00other", 9));
const fs::path json_a = write_printer_json(bed.bundle.printers.default_preset().config, bed.src / "PrinterA.json",
"PrinterA", tex_a, mod_a);
const fs::path json_b = write_printer_json(bed.bundle.printers.default_preset().config, bed.src / "PrinterB.json",
"PrinterB", tex_b, mod_b);
nlohmann::json structure = nlohmann::json::object();
structure["id"] = "two-printers";
structure["bundle_type"] = "printer config bundle";
structure["printer_config"] = nlohmann::json::array({"printer/PrinterA.json", "printer/PrinterB.json"});
DynamicPrintConfig config_a(bed.bundle.printers.default_preset().config);
config_a.option<ConfigOptionString>("bed_custom_texture", true)->value = tex_a;
config_a.option<ConfigOptionString>("bed_custom_model", true)->value = mod_a;
DynamicPrintConfig config_b(bed.bundle.printers.default_preset().config);
config_b.option<ConfigOptionString>("bed_custom_texture", true)->value = tex_b;
config_b.option<ConfigOptionString>("bed_custom_model", true)->value = mod_b;
const fs::path zip_file = bed.src / "two.orca_printer";
mz_zip_archive zip;
mz_zip_zero_struct(&zip);
const std::string bytes_a = read_file(json_a);
const std::string bytes_b = read_file(json_b);
REQUIRE(open_zip_writer(&zip, zip_file.string()));
REQUIRE(mz_zip_writer_add_mem(&zip, "printer/PrinterA.json", bytes_a.data(), bytes_a.size(), MZ_DEFAULT_COMPRESSION));
REQUIRE(mz_zip_writer_add_mem(&zip, "printer/PrinterB.json", bytes_b.data(), bytes_b.size(), MZ_DEFAULT_COMPRESSION));
REQUIRE(append_printer_bed_assets(zip, config_a, structure, "printer/PrinterA.json", 0));
REQUIRE(append_printer_bed_assets(zip, config_b, structure, "printer/PrinterB.json", 1));
const std::string manifest = structure.dump();
REQUIRE(mz_zip_writer_add_mem(&zip, BUNDLE_STRUCTURE_JSON_NAME, manifest.data(), manifest.size(),
MZ_DEFAULT_COMPRESSION));
REQUIRE(mz_zip_writer_finalize_archive(&zip));
REQUIRE(close_zip_writer(&zip));
REQUIRE(structure["printer_asset"]["printer/PrinterA.json"]["bed_custom_texture"] == "bed/0/texture.png");
REQUIRE(structure["printer_asset"]["printer/PrinterB.json"]["bed_custom_texture"] == "bed/1/texture.png");
ScopedDataDir scoped(bed.data_root);
AppConfig app_config;
std::vector<std::string> files{zip_file.string()};
bed.bundle.import_presets(files, [](std::string const &) { return 1; },
ForwardCompatibilitySubstitutionRule::Disable, app_config);
const Preset *saved_a = nullptr;
const Preset *saved_b = nullptr;
for (const Preset &preset : bed.bundle.printers.get_presets()) {
if (preset.file.empty())
continue;
if (get_preset_bare_name(preset.name) == "PrinterA")
saved_a = &preset;
if (get_preset_bare_name(preset.name) == "PrinterB")
saved_b = &preset;
}
REQUIRE(saved_a != nullptr);
REQUIRE(saved_b != nullptr);
const std::string got_tex_a = saved_a->config.opt_string("bed_custom_texture");
const std::string got_tex_b = saved_b->config.opt_string("bed_custom_texture");
REQUIRE(fs::path(got_tex_a).parent_path().filename() == "0");
REQUIRE(fs::path(got_tex_b).parent_path().filename() == "1");
REQUIRE(got_tex_a != got_tex_b);
REQUIRE(read_file(got_tex_a) == kTextureBytes);
REQUIRE(read_file(got_tex_b) == kOtherBytes);
REQUIRE(read_file(saved_a->config.opt_string("bed_custom_model")) == kModelBytes);
REQUIRE(read_file(saved_b->config.opt_string("bed_custom_model")) == std::string("stl\x00other", 9));
}
TEST_CASE("A printer_asset entry keyed to a printer not in the zip is ignored", "[Preset][Bundle]")
{
BedRoots bed;
const std::string texture = (bed.src / "kept.png").make_preferred().string();
write_bytes(texture, kTextureBytes);
const fs::path printer_json = write_printer_json(bed.bundle.printers.default_preset().config, bed.src / "BedPrinter.json",
"BedPrinter", texture, {});
nlohmann::json structure = bed_manifest("ghost-key");
set_printer_bed_asset(structure, "bed_custom_texture", "bed/0/texture.png", "printer/Ghost.json");
const fs::path zip_file = bed.src / "ghost.orca_printer";
write_zip(zip_file, {{"printer/BedPrinter.json", read_file(printer_json)}, {BUNDLE_STRUCTURE_JSON_NAME, structure.dump()},
{"bed/0/texture.png", kOtherBytes}});
const Preset *saved = import_bed(bed.bundle, zip_file, bed.data_root);
REQUIRE(saved != nullptr);
REQUIRE(saved->config.opt_string("bed_custom_texture") == texture);
CHECK_FALSE(any_filename_contains(bed.data_root, "texture.png"));
}
TEST_CASE("Keyed printer_asset rejects malformed bed paths", "[Preset][Bundle]")
{
const char *bad_names[] = {"bed/texture.png", "bed/x/texture.png", "bed/0/../texture.png", "bed/0/model.png"};
const std::string marker = "PWNED-BED-ASSET-MARKER";
for (const char *name : bad_names) {
DYNAMIC_SECTION("rejects " << name) {
BedRoots bed;
const std::string texture = (bed.src / "kept.png").make_preferred().string();
write_bytes(texture, kTextureBytes);
const fs::path printer_json = write_printer_json(bed.bundle.printers.default_preset().config,
bed.src / "BedPrinter.json", "BedPrinter", texture, {});
nlohmann::json structure = bed_manifest("bad-keyed");
set_printer_bed_asset(structure, "bed_custom_texture", name);
std::vector<std::pair<std::string, std::string>> entries = {
{"printer/BedPrinter.json", read_file(printer_json)}, {BUNDLE_STRUCTURE_JSON_NAME, structure.dump()},
{name, marker}};
const fs::path zip_file = bed.src / "bad-keyed.orca_printer";
write_zip(zip_file, entries);
const Preset *saved = import_bed(bed.bundle, zip_file, bed.data_root);
REQUIRE(saved != nullptr);
REQUIRE(saved->config.opt_string("bed_custom_texture") == texture);
if (fs::exists(bed.data_root))
for (fs::recursive_directory_iterator it(bed.data_root), end; it != end; ++it)
if (fs::is_regular_file(*it))
CHECK(read_file(*it).find(marker) == std::string::npos);
}
}
SECTION("field name does not match file kind") {
BedRoots bed;
const std::string texture = (bed.src / "kept.png").make_preferred().string();
write_bytes(texture, kTextureBytes);
const fs::path printer_json = write_printer_json(bed.bundle.printers.default_preset().config, bed.src / "BedPrinter.json",
"BedPrinter", texture, {});
nlohmann::json structure = bed_manifest("kind-mismatch");
set_printer_bed_asset(structure, "bed_custom_texture", "bed/0/model.stl");
const fs::path zip_file = bed.src / "kind.orca_printer";
write_zip(zip_file, {{"printer/BedPrinter.json", read_file(printer_json)}, {BUNDLE_STRUCTURE_JSON_NAME, structure.dump()},
{"bed/0/model.stl", kModelBytes}});
const Preset *saved = import_bed(bed.bundle, zip_file, bed.data_root);
REQUIRE(saved != nullptr);
REQUIRE(saved->config.opt_string("bed_custom_texture") == texture);
CHECK_FALSE(any_filename_contains(bed.data_root, "model.stl"));
}
}
TEST_CASE("A saved printer preset reloads the tool layout it was saved with", "[Preset][Bundle][IMEX]")
{
ScopedTemporaryDir temp_dir;
PresetBundle bundle;
PresetsConfigSubstitutions substitutions;
DynamicPrintConfig config(bundle.printers.default_preset().config);
config.set_deserialize_strict("imex_tool_layout", "rear-left");
config.option<ConfigOptionString>(BBL_JSON_KEY_INHERITS, true)->value = "";
const fs::path file = temp_dir.path() / PRESET_PRINTER_NAME / "ImexPrinter.json";
fs::create_directories(file.parent_path());
config.save_to_json(file.string(), "ImexPrinter", "User", "1.0.0");
bundle.printers.load_presets(temp_dir.path().string(), PRESET_PRINTER_NAME, substitutions,
ForwardCompatibilitySubstitutionRule::Disable);
const Preset *loaded = bundle.printers.find_preset("ImexPrinter");
REQUIRE(loaded != nullptr);
CHECK(int(imex_cfg_enum<ImexToolLayout>(loaded->config, "imex_tool_layout")) == int(ImexToolLayout::RearLeft));
}
// A project saved before a key joined filament_options_with_variant stores it once per filament,
// while the keys that were already per variant store it once per filament variant. Loading such a
// project gives every variant of a filament that filament's value.
@@ -5906,6 +6533,21 @@ TEST_CASE("A project saved with pressure advance per filament applies it to ever
check_double_vector(petg.opt<ConfigOptionFloatsNullable>("filament_flow_ratio")->values, { 0.97 });
}
TEST_CASE("A multi-toolhead project saved without filament self indices loads every filament", "[Preset][Bundle]")
{
const std::vector<std::string> colors = { "#FF0000", "#000000", "#FFFFFF", "#FFFF00" };
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
config.opt<ConfigOptionStrings>("filament_colour")->values = colors;
config.opt<ConfigOptionFloats>("nozzle_diameter")->values = std::vector<double>(colors.size(), 0.4);
config.option<ConfigOptionBool>("single_extruder_multi_material")->value = false;
Preset::normalize(config);
PresetBundle bundle;
REQUIRE_NOTHROW(bundle.load_config_model("test.3mf", std::move(config)));
CHECK(bundle.filament_presets.size() == colors.size());
CHECK(bundle.project_config.opt<ConfigOptionStrings>("filament_colour")->values == colors);
}
TEST_CASE("A system preset resolves by name from the bundled profiles", "[Preset][Bundle]")
{
ScopedTemporaryDir temp_dir;
+18
View File
@@ -8,6 +8,7 @@
#include <catch2/generators/catch_generators.hpp>
#include "libslic3r/Model.hpp"
#include "libslic3r/Format/STEP.hpp"
#include "libslic3r/TriangleMesh.hpp"
#include "test_utils.hpp"
using namespace Slic3r;
@@ -73,6 +74,23 @@ TEST_CASE("A security classification assignment does not crash import", "[Step]"
CHECK(model.objects.front()->volumes.front()->mesh().facets_count() == 4); // a tetrahedron
}
// The fixture is a truncated cone whose seam pcurves have the direction (2.1e-16, -1).
TEST_CASE("A cone with a slightly tilted seam imports as a closed mesh", "[Step]")
{
const std::string path = std::string(TEST_DATA_DIR) + PATH_SEPARATOR "cone_tilted_seam_pcurve.step";
Model model;
bool cancel = false;
Step step(path);
REQUIRE(step.load() == Step::Step_Status::LOAD_SUCCESS);
REQUIRE(step.mesh(&model, cancel, false) == Step::Step_Status::MESH_SUCCESS);
REQUIRE(model.objects.size() == 1);
REQUIRE(model.objects.front()->volumes.size() == 1);
CHECK(its_num_open_edges(model.objects.front()->volumes.front()->mesh().its) == 0);
}
TEST_CASE("isUtf8 recognises two, three and four byte sequences", "[Step]")
{
CHECK(StepPreProcessor::isUtf8("\xC3\xA9")); // U+00E9
@@ -68,6 +68,28 @@ static std::shared_ptr<std::vector<unsigned char>> make_flat_gray_png(uint8_t va
return std::make_shared<std::vector<unsigned char>>(std::move(bytes));
}
// The same round trip for a flat colour image, which decode_height_texture() reads through its colour path.
static std::shared_ptr<std::vector<unsigned char>> make_flat_rgb_png(uint8_t r, uint8_t g, uint8_t b, size_t w = 4, size_t h = 4)
{
std::vector<uint8_t> rgb;
for (size_t i = 0; i < w * h; ++i)
rgb.insert(rgb.end(), { r, g, b });
const boost::filesystem::path tmp_path = boost::filesystem::temp_directory_path()
/ boost::filesystem::unique_path("texdisp_test_%%%%%%%%.png");
REQUIRE(Slic3r::png::write_rgb_to_file(tmp_path.string(), w, h, rgb));
std::vector<unsigned char> bytes;
{
std::ifstream ifs(tmp_path.string(), std::ios::binary);
bytes.assign(std::istreambuf_iterator<char>(ifs), std::istreambuf_iterator<char>());
}
boost::system::error_code ec;
boost::filesystem::remove(tmp_path, ec);
REQUIRE_FALSE(bytes.empty());
return std::make_shared<std::vector<unsigned char>>(std::move(bytes));
}
// A hard-edged black/white checkerboard, the worst case for a height map: every texel boundary is a
// step, which is precisely the relief the post-process smoothing exists to round off.
static std::shared_ptr<std::vector<unsigned char>> make_checkerboard_png(size_t w = 16, size_t h = 16)
@@ -2135,3 +2157,20 @@ TEST_CASE("A second bake beside a first comes out as fine as a single bake", "[T
CHECK(second <= single * 5 / 4);
}
TEST_CASE("whether a layer's texture has colour agrees with its decode", "[TextureDisplacement]")
{
TextureDisplacementLayer gray;
gray.image_data = make_flat_gray_png(128);
CHECK_FALSE(height_texture_has_color(gray));
TextureDisplacementLayer color;
color.image_data = make_flat_rgb_png(200, 40, 10);
// Before the image is decoded and after, and whatever the smoothing.
CHECK(height_texture_has_color(color));
CHECK(decode_height_texture(color).has_color());
CHECK(height_texture_has_color(color));
color.smoothing = 0.5f;
CHECK(height_texture_has_color(color));
CHECK_FALSE(height_texture_has_color(TextureDisplacementLayer{}));
}
@@ -11,6 +11,7 @@
#include "libslic3r/GCode/WipeTower2.hpp"
#include "libslic3r/GCode/WipeTowerEstimate.hpp"
#include "libslic3r/PrintConfig.hpp"
#include "libslic3r/FilamentMixer.hpp"
#include <cmath>
#include "libslic3r/Config.hpp"
@@ -365,3 +366,41 @@ TEST_CASE("A config missing a tower key falls back to that key's default", "[Wip
print_config_def.get("wipe_tower_extra_spacing")->default_value->clone());
CHECK_THAT(estimate(partial, 3, 0.2, 5.).depth, WithinAbs(estimate(defaulted, 3, 0.2, 5.).depth, 1e-9));
}
TEST_CASE("prime_tower_is_printed answers exactly what normalize_fdm_2 decides", "[WipeTowerEstimate]") {
// The estimate says how big a tower is; this says whether there is one, and the authority is
// normalize_fdm_2, which clears enable_prime_tower before the plate is sliced. Pre-slice
// consumers cannot call it (it mutates a config), so the rule is mirrored - and mirrored rules
// drift, which is what this pins. Every combination the rule looks at, both verdicts compared.
const int used_filaments = GENERATE(1, 2, 3);
const bool has_mixed = GENERATE(false, true);
const int num_objects = GENERATE(1, 2);
const bool by_object = GENERATE(false, true);
const bool smooth = GENERATE(false, true);
const bool wrapping = GENERATE(false, true);
DynamicPrintConfig config = preset_shaped_defaults();
config.set_key_value("enable_prime_tower", new ConfigOptionBool(true));
config.set_deserialize_strict("print_sequence", by_object ? "by object" : "by layer");
config.set_deserialize_strict("timelapse_type", smooth ? "1" : "0");
config.set_key_value("enable_wrapping_detection", new ConfigOptionBool(wrapping));
config.set_key_value("filament_is_mixed", new ConfigOptionBools(
has_mixed ? std::vector<unsigned char>{0, 1} : std::vector<unsigned char>{0, 0}));
REQUIRE(has_any_mixed_filament(config.option<ConfigOptionBools>("filament_is_mixed")->values) == has_mixed);
DynamicPrintConfig normalized = config;
normalized.normalize_fdm_2(num_objects, used_filaments);
const bool slicer_prints_one = normalized.opt_bool("enable_prime_tower");
CHECK(prime_tower_is_printed(config, used_filaments, num_objects, has_mixed) == slicer_prints_one);
}
TEST_CASE("prime_tower_is_printed follows the option the user set", "[WipeTowerEstimate]") {
DynamicPrintConfig config = preset_shaped_defaults();
config.set_key_value("enable_prime_tower", new ConfigOptionBool(false));
CHECK_FALSE(prime_tower_is_printed(config, 2, 1, false));
// Below one filament normalize_fdm_2 leaves the option alone, so this does too.
config.set_key_value("enable_prime_tower", new ConfigOptionBool(true));
CHECK(prime_tower_is_printed(config, 0, 1, false));
}
+2
View File
@@ -31,6 +31,8 @@
#include "libslic3r/BoundingBox.hpp"
#include "libslic3r/ExtrusionEntity.hpp"
using namespace Slic3r;
namespace {
const char *const BELOW_PAD_TEST_OBJECTS[] = {
+2
View File
@@ -36,6 +36,8 @@
#include "libslic3r/BoundingBox.hpp"
#include "libslic3r/Line.hpp"
using namespace Slic3r;
void test_support_model_collision(const std::string &obj_filename,
const sla::SupportTreeConfig &input_supportcfg,
const sla::HollowingConfig &hollowingcfg,
+36 -38
View File
@@ -33,33 +33,31 @@ namespace Slic3r::sla { struct PadConfig; }
namespace Slic3r::sla { struct SupportTreeConfig; }
using namespace Slic3r;
enum e_validity {
ASSUME_NO_EMPTY = 1,
ASSUME_MANIFOLD = 2,
ASSUME_NO_REPAIR = 4
};
void check_validity(const TriangleMesh &input_mesh,
void check_validity(const Slic3r::TriangleMesh &input_mesh,
int flags = ASSUME_NO_EMPTY | ASSUME_MANIFOLD |
ASSUME_NO_REPAIR);
struct PadByproducts
{
ExPolygons model_contours;
ExPolygons support_contours;
TriangleMesh mesh;
Slic3r::ExPolygons model_contours;
Slic3r::ExPolygons support_contours;
Slic3r::TriangleMesh mesh;
};
void test_concave_hull(const ExPolygons &polys);
void test_concave_hull(const Slic3r::ExPolygons &polys);
void test_pad(const std::string & obj_filename,
const sla::PadConfig &padcfg,
const Slic3r::sla::PadConfig &padcfg,
PadByproducts & out);
inline void test_pad(const std::string & obj_filename,
const sla::PadConfig &padcfg = {})
const Slic3r::sla::PadConfig &padcfg = {})
{
PadByproducts byproducts;
test_pad(obj_filename, padcfg, byproducts);
@@ -69,53 +67,53 @@ struct SupportByproducts
{
std::string obj_fname;
std::vector<float> slicegrid;
std::vector<ExPolygons> model_slices;
sla::SupportTreeBuilder supporttree;
TriangleMesh input_mesh;
std::vector<Slic3r::ExPolygons> model_slices;
Slic3r::sla::SupportTreeBuilder supporttree;
Slic3r::TriangleMesh input_mesh;
};
const constexpr float CLOSING_RADIUS = 0.005f;
void check_support_tree_integrity(const sla::SupportTreeBuilder &stree,
const sla::SupportTreeConfig &cfg);
void check_support_tree_integrity(const Slic3r::sla::SupportTreeBuilder &stree,
const Slic3r::sla::SupportTreeConfig &cfg);
void test_supports(const std::string &obj_filename,
const sla::SupportTreeConfig &supportcfg,
const sla::HollowingConfig &hollowingcfg,
const sla::DrainHoles &drainholes,
const Slic3r::sla::SupportTreeConfig &supportcfg,
const Slic3r::sla::HollowingConfig &hollowingcfg,
const Slic3r::sla::DrainHoles &drainholes,
SupportByproducts &out);
inline void test_supports(const std::string &obj_filename,
const sla::SupportTreeConfig &supportcfg,
const Slic3r::sla::SupportTreeConfig &supportcfg,
SupportByproducts &out)
{
sla::HollowingConfig hcfg;
Slic3r::sla::HollowingConfig hcfg;
hcfg.enabled = false;
test_supports(obj_filename, supportcfg, hcfg, {}, out);
}
inline void test_supports(const std::string &obj_filename,
const sla::SupportTreeConfig &supportcfg = {})
const Slic3r::sla::SupportTreeConfig &supportcfg = {})
{
SupportByproducts byproducts;
test_supports(obj_filename, supportcfg, byproducts);
}
void export_failed_case(const std::vector<ExPolygons> &support_slices,
void export_failed_case(const std::vector<Slic3r::ExPolygons> &support_slices,
const SupportByproducts &byproducts);
void test_support_model_collision(
const std::string &obj_filename,
const sla::SupportTreeConfig &input_supportcfg,
const sla::HollowingConfig &hollowingcfg,
const sla::DrainHoles &drainholes);
const Slic3r::sla::SupportTreeConfig &input_supportcfg,
const Slic3r::sla::HollowingConfig &hollowingcfg,
const Slic3r::sla::DrainHoles &drainholes);
inline void test_support_model_collision(
const std::string &obj_filename,
const sla::SupportTreeConfig &input_supportcfg = {})
const Slic3r::sla::SupportTreeConfig &input_supportcfg = {})
{
sla::HollowingConfig hcfg;
Slic3r::sla::HollowingConfig hcfg;
hcfg.enabled = false;
test_support_model_collision(obj_filename, input_supportcfg, hcfg, {});
}
@@ -156,8 +154,8 @@ template <class I, class II> void test_pairhash()
REQUIRE(a != b);
II hash_ab = sla::pairhash<I, II>(a, b);
II hash_ba = sla::pairhash<I, II>(b, a);
II hash_ab = Slic3r::sla::pairhash<I, II>(a, b);
II hash_ba = Slic3r::sla::pairhash<I, II>(b, a);
REQUIRE(hash_ab == hash_ba);
auto it = ints.find(hash_ab);
@@ -180,23 +178,23 @@ static constexpr const TPixel FullBlack = 0;
template <class A, int N> constexpr int arraysize(const A (&)[N]) { return N; }
void check_raster_transformations(sla::RasterBase::Orientation o,
sla::RasterBase::TMirroring mirroring);
void check_raster_transformations(Slic3r::sla::RasterBase::Orientation o,
Slic3r::sla::RasterBase::TMirroring mirroring);
ExPolygon square_with_hole(double v);
Slic3r::ExPolygon square_with_hole(double v);
inline double pixel_area(TPixel px, const sla::PixelDim &pxdim)
inline double pixel_area(TPixel px, const Slic3r::sla::PixelDim &pxdim)
{
return (pxdim.h_mm * pxdim.w_mm) * px * 1. / (FullWhite - FullBlack);
}
double raster_white_area(const sla::RasterGrayscaleAA &raster);
long raster_pxsum(const sla::RasterGrayscaleAA &raster);
double raster_white_area(const Slic3r::sla::RasterGrayscaleAA &raster);
long raster_pxsum(const Slic3r::sla::RasterGrayscaleAA &raster);
double predict_error(const ExPolygon &p, const sla::PixelDim &pd);
double predict_error(const Slic3r::ExPolygon &p, const Slic3r::sla::PixelDim &pd);
sla::SupportPoints calc_support_pts(
const TriangleMesh & mesh,
const sla::SupportPointGenerator::Config &cfg = {});
Slic3r::sla::SupportPoints calc_support_pts(
const Slic3r::TriangleMesh & mesh,
const Slic3r::sla::SupportPointGenerator::Config &cfg = {});
#endif // SLA_TEST_UTILS_HPP
+2
View File
@@ -37,7 +37,9 @@ add_executable(${_TEST_NAME}_tests
test_plugin_sort.cpp
test_plugin_cloud_metadata.cpp
test_plugin_audit.cpp
test_plugin_json_depth.cpp
test_shortcuts.cpp
test_texture_color_palette.cpp
test_file_url.cpp
test_user_manager.cpp
../fff_print/test_helpers.cpp
+81 -2
View File
@@ -4,16 +4,23 @@
#include <catch2/catch_message.hpp>
#include "slic3r/GUI/ActionRegistry.hpp"
#include "slic3r/GUI/GUI_Factories.hpp"
#include "slic3r/GUI/I18N.hpp"
#include "slic3r/GUI/NativeCommands.hpp"
#include "slic3r/GUI/OptionsGroup.hpp"
#include "slic3r/GUI/SettingsIndex.hpp"
#include <boost/filesystem.hpp>
#include <nlohmann/json.hpp>
#include <wx/string.h>
#include "libslic3r/Config.hpp"
#include <algorithm>
#include <memory>
#include <set>
#include <string>
#include <type_traits>
#include <utility>
#include <vector>
using Slic3r::GUI::AppAction;
@@ -27,7 +34,9 @@ namespace {
class TestAppAction final : public AppAction
{
public:
TestAppAction() : AppAction("test", "Action title", "src-key", "Action source") {}
explicit TestAppAction(std::string title = "Action title", std::string source_name = "Action source")
: AppAction("test", std::move(title), "src-key", std::move(source_name))
{}
AppActionRunResult run(const std::string& param = {}) const override { return {}; }
};
@@ -93,7 +102,9 @@ TEST_CASE("Command actions are keyed by catalog key, not display title", "[Actio
}
// 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.
// 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,
// so the displayed title and group equal them.
TEST_CASE("Command action construction keys by catalog key", "[ActionSource][SpeedDial]")
{
const std::vector<Slic3r::GUI::NativeCommand>& commands = Slic3r::GUI::NativeCommands::catalog();
@@ -106,6 +117,8 @@ TEST_CASE("Command action construction keys by catalog key", "[ActionSource][Spe
CHECK(action->id() != AppAction::compose_id("orca_command", c.title, "orca"));
CHECK(action->title() == c.title);
CHECK(action->group == c.group);
CHECK(action->title_en == c.title);
CHECK(action->group_en == c.group);
CHECK(action->input == c.input);
CHECK(action->icon == c.icon);
}
@@ -171,6 +184,7 @@ TEST_CASE("Native command catalog has unique keys and present titles", "[ActionS
for (const auto& c : commands) {
CHECK_FALSE(c.key.empty());
CHECK_FALSE(c.title.empty());
CHECK_FALSE(c.group.empty());
// A duplicated key would silently shadow the earlier command in the palette.
CHECK(seen.insert(c.key).second);
}
@@ -288,6 +302,16 @@ TEST_CASE("Native command catalog covers the Add menus", "[ActionSource][SpeedDi
REQUIRE(group != nullptr);
CHECK(*group == *handy_group);
}
// A handy model's command is titled by the English label its Add-menu entry is built from, so it is
// searchable in English in any UI language.
for (const auto& model : Slic3r::GUI::MenuFactory::handy_models()) {
const std::string key = "add_handy_" + std::string(model.key);
INFO(key);
const auto it = std::find_if(commands.begin(), commands.end(), [&key](const auto& c) { return c.key == key; });
REQUIRE(it != commands.end());
CHECK(it->title == model.label);
}
}
// A setting action is named like its settings row, not the ConfigOptionDef label: the row's
@@ -319,6 +343,61 @@ TEST_CASE("Setting display labels mirror the settings row", "[ActionSource][Spee
CHECK(resolve_setting_title(L"", L"", false).IsEmpty());
}
// The page receives an English alias only where it differs from the displayed text, so an English UI
// (where every alias equals its text) receives none and searches exactly the displayed text.
TEST_CASE("Action JSON sends English aliases only when they differ from the displayed text", "[ActionSource][SpeedDial]")
{
const char* aliases[] = {"title_en", "group_en", "source_en", "full_label_en"};
TestAppAction english("Reverse on even", "Process : Quality : Overhangs");
english.full_label = "Overhang reversal";
english.title_en = english.title();
english.source_en = english.source_name();
english.full_label_en = english.full_label;
const nlohmann::json same = Slic3r::GUI::action_json(english);
for (const char* key : aliases) {
INFO(key);
CHECK_FALSE(same.contains(key));
}
CHECK(same.at("title") == "Reverse on even");
// A translated action (German stands in for any UI language) carries each differing English name.
TestAppAction localized("Bei geraden Schichten umkehren", "Prozess : Qualitaet : Ueberhaenge");
localized.group = "Befehle";
localized.full_label = "Ueberhangumkehr";
localized.title_en = "Reverse on even";
localized.group_en = "Commands";
localized.source_en = "Process : Quality : Overhangs";
localized.full_label_en = "Overhang reversal";
const nlohmann::json differs = Slic3r::GUI::action_json(localized);
CHECK(differs.at("title_en") == "Reverse on even");
CHECK(differs.at("group_en") == "Commands");
CHECK(differs.at("source_en") == "Process : Quality : Overhangs");
CHECK(differs.at("full_label_en") == "Overhang reversal");
CHECK(differs.at("title") == "Bei geraden Schichten umkehren");
// No English counterpart (a plugin, a file name) sends nothing.
const nlohmann::json none = Slic3r::GUI::action_json(TestAppAction("Optimize", "Gcode Optimizer"));
for (const char* key : aliases) {
INFO(key);
CHECK_FALSE(none.contains(key));
}
}
// A settings row keeps the untranslated English of its label, from the ctor and from a runtime
// rename, so the Speed Dial can offer the row's English name in any UI language. No catalog is loaded
// in the tests, so the displayed label equals the English.
TEST_CASE("Settings rows keep the English of their label", "[ActionSource][SpeedDial]")
{
Slic3r::GUI::Line line{L("Cool Plate"), ""};
CHECK(line.label_en == "Cool Plate");
CHECK(line.label == "Cool Plate");
line.set_label(L("Brim ear radius"));
CHECK(line.label_en == "Brim ear radius");
CHECK(line.label == "Brim ear radius");
}
// A setting whose mode is above the user's current mode must be prompted before it can be edited.
// Developer settings (comDevelop) are above every non-developer mode, so they always prompt then.
TEST_CASE("Settings above the current mode require a switch", "[ActionSource][SpeedDial]")
+366
View File
@@ -2,14 +2,38 @@
#include <boost/filesystem/operations.hpp>
#include <catch2/catch_all.hpp>
#include <boost/algorithm/string/predicate.hpp>
#include <boost/asio/buffer.hpp>
#include <boost/asio/io_context.hpp>
#include <boost/asio/ip/address.hpp>
#include <boost/asio/ip/tcp.hpp>
#include <boost/asio/read_until.hpp>
#include <boost/asio/streambuf.hpp>
#include <boost/asio/write.hpp>
#include <boost/filesystem.hpp>
#include <boost/filesystem/fstream.hpp>
#include <boost/nowide/cstdlib.hpp>
#include <boost/system/error_code.hpp>
#include <atomic>
#include <chrono>
#include <condition_variable>
#include <cstddef>
#include <functional>
#include <istream>
#include <memory>
#include <mutex>
#include <optional>
#include <string>
#include <string_view>
#include <thread>
#include <utility>
#include <vector>
#include <catch2/catch_test_macros.hpp>
#include "slic3r/Utils/ICloudServiceAgent.hpp"
#include "slic3r/Utils/OrcaCloudServiceAgent.hpp"
#include "slic3r/Utils/bambu_networking.hpp"
#include "test_utils.hpp"
using namespace Slic3r;
@@ -61,6 +85,227 @@ std::string resolved_display_name(const nlohmann::json& session)
return agent->get_user_nickname();
}
using namespace std::chrono_literals;
using boost::asio::ip::tcp;
// A loopback HTTP server for the health check to talk to. It answers every request according to
// the current mode and records what it was sent.
class LoopbackServer
{
public:
enum class Mode { Ok, Unavailable, Silent };
struct Request
{
std::string request_line;
bool has_authorization{false};
};
LoopbackServer() : m_acceptor(m_io, tcp::endpoint(boost::asio::ip::make_address("127.0.0.1"), 0))
{
accept_next();
m_thread = std::thread([this] { m_io.run(); });
}
~LoopbackServer()
{
m_io.stop();
m_thread.join();
}
std::string url() const { return "http://127.0.0.1:" + std::to_string(m_acceptor.local_endpoint().port()); }
void set_mode(Mode mode) { m_mode = mode; }
bool wait_for_accepts(size_t count, std::chrono::milliseconds timeout)
{
std::unique_lock<std::mutex> lock(m_mutex);
return m_cv.wait_for(lock, timeout, [&] { return m_connections.size() >= count; });
}
std::vector<Request> requests() const
{
std::lock_guard<std::mutex> lock(m_mutex);
return m_requests;
}
private:
struct Connection
{
explicit Connection(tcp::socket socket) : socket(std::move(socket)) {}
tcp::socket socket;
boost::asio::streambuf buffer;
};
void accept_next()
{
m_acceptor.async_accept([this](boost::system::error_code ec, tcp::socket socket) {
if (ec)
return;
auto connection = std::make_shared<Connection>(std::move(socket));
{
std::lock_guard<std::mutex> lock(m_mutex);
// Held here so a Silent connection stays open until the client gives up.
m_connections.push_back(connection);
}
m_cv.notify_all();
read_request(connection);
accept_next();
});
}
void read_request(const std::shared_ptr<Connection>& connection)
{
boost::asio::async_read_until(connection->socket, connection->buffer, "\r\n\r\n",
[this, connection](boost::system::error_code ec, size_t) {
if (ec)
return;
std::istream in(&connection->buffer);
Request request;
std::string line;
std::getline(in, line);
request.request_line = line.substr(0, line.find('\r'));
while (std::getline(in, line) && line != "\r")
request.has_authorization |= boost::istarts_with(line, "authorization:");
{
std::lock_guard<std::mutex> lock(m_mutex);
m_requests.push_back(request);
}
const Mode mode = m_mode;
if (mode == Mode::Silent)
return;
const std::string_view response = mode == Mode::Ok ?
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 11\r\nConnection: close\r\n\r\n{\"ok\":true}" :
"HTTP/1.1 503 Service Unavailable\r\nContent-Length: 0\r\nConnection: close\r\n\r\n";
boost::asio::async_write(connection->socket, boost::asio::buffer(response),
[connection](boost::system::error_code, size_t) {
boost::system::error_code ignored;
connection->socket.shutdown(tcp::socket::shutdown_both, ignored);
});
});
}
// Declared first so it outlives the sockets that use it.
boost::asio::io_context m_io;
tcp::acceptor m_acceptor;
std::thread m_thread;
std::atomic<Mode> m_mode{Mode::Ok};
mutable std::mutex m_mutex;
std::condition_variable m_cv;
std::vector<Request> m_requests;
std::vector<std::shared_ptr<Connection>> m_connections;
};
// Sets an environment variable for the lifetime of the guard and restores the previous value.
class ScopedEnvVar
{
public:
ScopedEnvVar(std::string name, const std::string& value) : m_name(std::move(name))
{
if (const char* old = boost::nowide::getenv(m_name.c_str()))
m_previous = old;
boost::nowide::setenv(m_name.c_str(), value.c_str(), 1);
}
~ScopedEnvVar()
{
if (m_previous)
boost::nowide::setenv(m_name.c_str(), m_previous->c_str(), 1);
else
boost::nowide::unsetenv(m_name.c_str());
}
private:
std::string m_name;
std::optional<std::string> m_previous;
};
// What the agent reported through its callbacks, recorded on whichever thread delivers it so the
// assertions can run on the test thread.
struct CallbackLog
{
void add_connected(int return_code, int reason_code)
{
{
std::lock_guard<std::mutex> lock(mutex);
connected.emplace_back(return_code, reason_code);
}
cv.notify_all();
}
void add_http_error(unsigned http_code)
{
std::lock_guard<std::mutex> lock(mutex);
http_errors.push_back(http_code);
}
void add_queued(std::function<void()> task)
{
{
std::lock_guard<std::mutex> lock(mutex);
queued.push_back(std::move(task));
}
cv.notify_all();
}
bool wait_for_connected(size_t count, std::chrono::milliseconds timeout = 10s)
{
std::unique_lock<std::mutex> lock(mutex);
return cv.wait_for(lock, timeout, [&] { return connected.size() >= count; });
}
bool wait_for_queued(size_t count, std::chrono::milliseconds timeout = 10s)
{
std::unique_lock<std::mutex> lock(mutex);
return cv.wait_for(lock, timeout, [&] { return queued.size() >= count; });
}
std::mutex mutex;
std::condition_variable cv;
std::vector<std::pair<int, int>> connected;
std::vector<unsigned> http_errors;
std::vector<std::function<void()>> queued;
};
// An agent whose API and auth endpoints both point at a loopback server, with its callbacks
// recorded. Members are declared so the agent, and the health check thread it owns, is destroyed
// before the server, the log and the storage it uses.
struct HealthCheckFixture
{
HealthCheckFixture() : agent(make_file_backed_agent(dir.path()))
{
agent->set_api_base_url(server.url());
agent->set_auth_base_url(server.url());
agent->set_on_server_connected_fn([this](CloudEvent, int return_code, int reason_code) {
log.add_connected(return_code, reason_code);
});
agent->set_on_http_error_fn([this](CloudEvent, unsigned http_code, std::string) { log.add_http_error(http_code); });
}
// Defers callbacks the way the GUI's CallAfter does, instead of running them on the worker.
void queue_callbacks() { agent->set_queue_on_main_fn([this](std::function<void()> task) { log.add_queued(std::move(task)); }); }
// Starts the next health check, retrying while a call is still folded into the previous one:
// its callback fires before the worker thread finishes.
bool start_check(size_t expected_accepts)
{
const auto deadline = std::chrono::steady_clock::now() + 5s;
do {
agent->refresh_connection();
if (server.wait_for_accepts(expected_accepts, 10ms))
return true;
} while (std::chrono::steady_clock::now() < deadline);
return false;
}
// Loopback traffic must not go through a proxy configured in the environment. curl reads no_proxy
// before NO_PROXY.
ScopedEnvVar no_proxy{"no_proxy", "127.0.0.1"};
LoopbackServer server;
CallbackLog log;
ScopedTemporaryDir dir{"orca-health"};
std::unique_ptr<OrcaCloudServiceAgent> agent;
};
} // namespace
TEST_CASE("Logging out removes the secret this instance saved", "[OrcaCloudServiceAgent]")
@@ -174,3 +419,124 @@ TEST_CASE("Orca cloud nested session resolves display name consistently", "[Orca
{"username", "orca_username"}
})) == "orca_username");
}
TEST_CASE_METHOD(HealthCheckFixture, "Refreshing the server connection returns before the health check finishes", "[OrcaCloudServiceAgent]")
{
server.set_mode(LoopbackServer::Mode::Silent);
const auto start = std::chrono::steady_clock::now();
CHECK(agent->refresh_connection() == BAMBU_NETWORK_SUCCESS);
// A synchronous check would wait for the request's 30 s timeout.
CHECK(std::chrono::steady_clock::now() - start < 2s);
std::lock_guard<std::mutex> lock(log.mutex);
CHECK(log.connected.empty());
}
TEST_CASE_METHOD(HealthCheckFixture, "Successive health checks follow the server's state", "[OrcaCloudServiceAgent]")
{
server.set_mode(LoopbackServer::Mode::Ok);
REQUIRE(start_check(1));
REQUIRE(log.wait_for_connected(1));
CHECK(agent->is_server_connected());
server.set_mode(LoopbackServer::Mode::Unavailable);
REQUIRE(start_check(2));
REQUIRE(log.wait_for_connected(2));
CHECK_FALSE(agent->is_server_connected());
server.set_mode(LoopbackServer::Mode::Ok);
REQUIRE(start_check(3));
REQUIRE(log.wait_for_connected(3));
CHECK(agent->is_server_connected());
std::lock_guard<std::mutex> lock(log.mutex);
CHECK(log.connected == std::vector<std::pair<int, int>>{{0, 200}, {-1, 503}, {0, 200}});
CHECK(log.http_errors == std::vector<unsigned>{503});
}
TEST_CASE_METHOD(HealthCheckFixture, "A refresh while a health check is in flight starts no second check", "[OrcaCloudServiceAgent]")
{
server.set_mode(LoopbackServer::Mode::Silent);
agent->refresh_connection();
REQUIRE(server.wait_for_accepts(1, 5s));
agent->refresh_connection();
agent->refresh_connection();
CHECK_FALSE(server.wait_for_accepts(2, 200ms));
}
TEST_CASE_METHOD(HealthCheckFixture, "Stopping the health check cancels the request in flight", "[OrcaCloudServiceAgent]")
{
server.set_mode(LoopbackServer::Mode::Silent);
queue_callbacks();
agent->refresh_connection();
REQUIRE(server.wait_for_accepts(1, 5s));
const auto start = std::chrono::steady_clock::now();
agent->stop_health_check();
// Well under the request's 30 s timeout.
CHECK(std::chrono::steady_clock::now() - start < 5s);
CHECK(agent->refresh_connection() == BAMBU_NETWORK_ERR_CANCELED);
std::lock_guard<std::mutex> lock(log.mutex);
CHECK(log.connected.empty());
CHECK(log.http_errors.empty());
CHECK(log.queued.empty());
}
TEST_CASE_METHOD(HealthCheckFixture, "Connecting to the server reports the result synchronously", "[OrcaCloudServiceAgent]")
{
server.set_mode(LoopbackServer::Mode::Ok);
CHECK(agent->connect_server() == BAMBU_NETWORK_SUCCESS);
CHECK(agent->is_server_connected());
server.set_mode(LoopbackServer::Mode::Unavailable);
CHECK(agent->connect_server() == BAMBU_NETWORK_ERR_CONNECTION_TO_SERVER_FAILED);
CHECK_FALSE(agent->is_server_connected());
}
TEST_CASE_METHOD(HealthCheckFixture, "A health check result queued before stopping is still delivered and nothing is queued after", "[OrcaCloudServiceAgent]")
{
server.set_mode(LoopbackServer::Mode::Ok);
queue_callbacks();
agent->refresh_connection();
REQUIRE(log.wait_for_queued(1));
agent->stop_health_check();
std::function<void()> task;
{
std::lock_guard<std::mutex> lock(log.mutex);
REQUIRE(log.queued.size() == 1);
task = log.queued.front();
}
// The GUI may run a task it queued after the agent is gone; the task must not need the agent.
agent.reset();
task();
std::lock_guard<std::mutex> lock(log.mutex);
CHECK(log.connected == std::vector<std::pair<int, int>>{{0, 200}});
}
TEST_CASE_METHOD(HealthCheckFixture, "The health check does no token work", "[OrcaCloudServiceAgent]")
{
// A non-JWT access token has no expiry, so the session reads as due for a refresh.
REQUIRE(agent->set_user_session(flat_session_json({{"refresh_token", "test-refresh-token"}}), false));
server.set_mode(LoopbackServer::Mode::Ok);
REQUIRE(start_check(1));
REQUIRE(log.wait_for_connected(1));
CHECK(agent->connect_server() == BAMBU_NETWORK_SUCCESS);
const auto requests = server.requests();
REQUIRE(requests.size() == 2);
for (const auto& request : requests) {
CHECK(boost::starts_with(request.request_line, "GET /api/v1/health "));
CHECK_FALSE(request.has_authorization);
}
CHECK(agent->is_user_login());
CHECK(agent->get_access_token() == "test-token");
}
+12 -3
View File
@@ -222,18 +222,19 @@ TEST_CASE("Plugin audit denies secret/certificate/config-like paths by keyword",
CHECK(mgr.is_denied_path_keyword(fs::path("/resources/certificates/ca.pem")));
}
SECTION("a 'conf'/'config' directory or file component is denied")
SECTION("a 'conf'/'config' directory or config file component is denied")
{
CHECK(mgr.is_denied_path_keyword(fs::path("/plugin/conf/settings.json")));
CHECK(mgr.is_denied_path_keyword(fs::path("/plugin/config/settings.json")));
CHECK(mgr.is_denied_path_keyword(fs::path("/plugin/plugin.conf")));
CHECK(mgr.is_denied_path_keyword(fs::path("/plugin/plugin.ini")));
}
SECTION("matching is case-insensitive")
{
CHECK(mgr.is_denied_path_keyword(fs::path("/plugin/SECRETS/token.txt")));
CHECK(mgr.is_denied_path_keyword(fs::path("/resources/CertBundle/ca.pem")));
CHECK(mgr.is_denied_path_keyword(fs::path("/plugin/CONFIG.JSON")));
CHECK(mgr.is_denied_path_keyword(fs::path("/resources/Certificates/ca.pem")));
CHECK(mgr.is_denied_path_keyword(fs::path("/plugin/PLUGIN.CONF")));
}
SECTION("matching is not limited to the base name -- any ancestor component counts")
@@ -245,6 +246,14 @@ TEST_CASE("Plugin audit denies secret/certificate/config-like paths by keyword",
{
CHECK_FALSE(mgr.is_denied_path_keyword(fs::path("/plugin/output/model.gcode")));
CHECK_FALSE(mgr.is_denied_path_keyword(fs::path("/plugin/storage/state.json")));
CHECK_FALSE(mgr.is_denied_path_keyword(fs::path("/python/packages/cp312/numpy/__config__.py")));
CHECK_FALSE(mgr.is_denied_path_keyword(fs::path("/python/packages/cp312/numpy/_core/_ufunc_config.py")));
CHECK_FALSE(mgr.is_denied_path_keyword(fs::path("/python/Lib/configparser.py")));
CHECK_FALSE(mgr.is_denied_path_keyword(fs::path("/python/Lib/sysconfig.py")));
CHECK_FALSE(mgr.is_denied_path_keyword(fs::path("/python/Lib/logging/config.py")));
CHECK_FALSE(mgr.is_denied_path_keyword(fs::path("/python/packages/cp312/certifi/cacert.pem")));
CHECK_FALSE(mgr.is_denied_path_keyword(fs::path("/users/Conference/output.txt")));
CHECK_FALSE(mgr.is_denied_path_keyword(fs::path("/users/Concert/output.txt")));
}
SECTION("an empty path is not denied")
+12 -1
View File
@@ -133,7 +133,7 @@ TEST_CASE("Plugin host API reports unavailable GUI objects before Orca app initi
{
py::object host = import_orca_module().attr("host");
for (const char* function_name : { "preset_bundle", "plater", "model" }) {
for (const char* function_name : { "preset_bundle", "plater", "model", "app_info", "selected_printer" }) {
CAPTURE(function_name);
try {
host.attr(function_name)();
@@ -145,6 +145,17 @@ TEST_CASE("Plugin host API reports unavailable GUI objects before Orca app initi
}
}
TEST_CASE("Plugin host API reports no GL details before the 3D view exists", "[PluginHost][Python]")
{
py::object host = import_orca_module().attr("host");
CHECK(host.attr("gl_info")().is_none());
py::object plater_type = host.attr("Plater");
CHECK(has_attr(plater_type, "project_path"));
CHECK(has_attr(plater_type, "export_3mf_copy"));
}
TEST_CASE("Plugin host API exposes the UI module and guards it before Orca app initialization", "[PluginHost][Python]")
{
py::object host = import_orca_module().attr("host");
+42
View File
@@ -122,6 +122,7 @@ TEST_CASE("install-state sidecar is the source of truth for a cloud plugin's ins
state.permissions.network_http = {"https://api.example.com"};
state.permissions.network_socket = {"192.168.45.6:443"};
state.permissions.process = {"/usr/bin/curl"};
state.permissions.threading = {"thread"};
REQUIRE(write_install_state(plugin_dir, state));
// Permission data is persisted in the same sidecar as the installation metadata.
@@ -132,6 +133,7 @@ TEST_CASE("install-state sidecar is the source of truth for a cloud plugin's ins
CHECK(persisted.permissions.network_http == state.permissions.network_http);
CHECK(persisted.permissions.network_socket == state.permissions.network_socket);
CHECK(persisted.permissions.process == state.permissions.process);
CHECK(persisted.permissions.threading == state.permissions.threading);
// Reading the sidecar back onto a freshly-scanned descriptor (whose header version is still
// 1.0.0) must surface the cloud-installed 1.2.0. This is what lets update_cloud_metadata compare
@@ -142,3 +144,43 @@ TEST_CASE("install-state sidecar is the source of truth for a cloud plugin's ins
read_install_state(plugin_dir, scanned);
CHECK(scanned.installed_version == "1.2.0");
}
TEST_CASE("a local plugin's installed version follows its entry file header", "[PluginInstall]")
{
ScopedDataDir data_dir_guard("local-installed-version");
const fs::path plugin_dir = data_dir_guard.dir / "plugin";
fs::create_directories(plugin_dir);
// Sidecar written when the local plugin was first installed at 1.0.0.
PluginInstallState state;
state.installed_from = "local";
state.installed_version = "1.0.0";
REQUIRE(write_install_state(plugin_dir, state));
// The user then edited the plugin file: the freshly scanned header says 1.1.0.
PluginDescriptor scanned;
scanned.version = "1.1.0";
REQUIRE(read_install_state(plugin_dir, scanned));
CHECK(scanned.installed_version == "1.1.0");
}
TEST_CASE("an unsubscribed cloud plugin follows its header version", "[PluginInstall]")
{
ScopedDataDir data_dir_guard("unsubscribed-installed-version");
const fs::path plugin_dir = data_dir_guard.dir / "plugin";
fs::create_directories(plugin_dir);
// What keep_installed_plugin_as_local leaves behind: a normal local package. The sidecar still
// holds the version fetched from the cloud (1.2.0), but the header (1.0.0) is the source of truth.
PluginInstallState state;
state.installed_from = "local";
state.installed_version = "1.2.0";
REQUIRE(write_install_state(plugin_dir, state));
PluginDescriptor scanned;
scanned.version = "1.0.0";
REQUIRE(read_install_state(plugin_dir, scanned));
CHECK(scanned.installed_version == "1.0.0");
}
@@ -0,0 +1,73 @@
#include <catch2/catch_all.hpp>
#include <catch2/catch_test_macros.hpp>
#include <slic3r/plugin/PluginFsUtils.hpp>
#include <slic3r/plugin/PluginManager.hpp>
#include <slic3r/plugin/PythonInterpreter.hpp>
#include "plugin_test_utils.hpp"
#include <cstdint>
#include <exception>
#include <utility>
#include <nlohmann/json.hpp>
#include <pybind11/embed.h>
#include <pybind11/gil.h>
#include <pybind11/pytypes.h>
using namespace Slic3r;
namespace {
// Brings the embedded interpreter up for one test and tears it down before boost::log does,
// mirroring the ScopedPluginManager idiom in the other plugin tests.
struct ScopedPluginManager
{
ScopedDataDir python_data_dir{"plugin-json-depth"};
bool initialized = PluginManager::instance().initialize();
~ScopedPluginManager()
{
PluginManager::instance().shutdown();
PythonInterpreter::instance().shutdown();
}
};
} // namespace
TEST_CASE("py_to_json raises instead of overflowing on pathologically deep input", "[PluginHost][Python]")
{
ScopedPluginManager manager;
REQUIRE(manager.initialized);
namespace py = pybind11;
py::gil_scoped_acquire gil;
// [[[ ... 0 ... ]]] nested 300 deep: past the 200 conversion-depth cap, but shallow enough
// that the pre-fix code returns without crashing, so a regression fails cleanly rather than
// taking the process down. Built in C++ so the test does not depend on Python builtins.
py::object deep = py::int_(0);
for (int i = 0; i < 300; ++i) {
py::list wrapper;
wrapper.append(deep);
deep = std::move(wrapper);
}
CHECK_THROWS_AS(py_to_json(deep), std::exception);
}
TEST_CASE("py_to_json still converts reasonably nested input", "[PluginHost][Python]")
{
ScopedPluginManager manager;
REQUIRE(manager.initialized);
namespace py = pybind11;
py::gil_scoped_acquire gil;
py::dict d;
d["a"] = py::int_(1);
py::list inner;
inner.append(py::str("x"));
inner.append(py::int_(2));
d["b"] = inner;
const nlohmann::json j = py_to_json(d);
CHECK(j.at("a").get<std::int64_t>() == 1);
CHECK(j.at("b").at(0).get<std::string>() == "x");
CHECK(j.at("b").at(1).get<std::int64_t>() == 2);
}
@@ -0,0 +1,294 @@
// The texture displacement gizmo's palette helpers live in libslic3r_gui; this is the suite that links it.
// Same Windows include prologue as test_filament_bitmap_utils.cpp (wx pulls in <windows.h>; keep
// WIN32_LEAN_AND_MEAN / NOMINMAX ahead of the Catch2 headers).
#ifdef WIN32
#ifndef WIN32_LEAN_AND_MEAN
#define WIN32_LEAN_AND_MEAN
#endif
#ifndef NOMINMAX
#define NOMINMAX
#endif
#include <Windows.h>
#endif
#include <cstddef>
#include <cstdint>
#include <fstream>
#include <ios>
#include <iterator>
#include <memory>
#include <string>
#include <utility>
#include <vector>
#include <catch2/catch_all.hpp>
#include <catch2/catch_test_macros.hpp>
#include <catch2/matchers/catch_matchers.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp>
#include "libslic3r/Color.hpp"
#include "libslic3r/FilamentMixer.hpp"
#include "libslic3r/PNGReadWrite.hpp"
#include "libslic3r/Point.hpp"
#include "libslic3r/TextureDisplacement.hpp"
#include "libslic3r/TriangleMesh.hpp"
#include "libslic3r/TriangleSelector.hpp"
#include "slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp"
#include "slic3r/Utils/ColorSpaceConvert.hpp"
#include "test_utils.hpp"
using namespace Slic3r;
using Catch::Matchers::WithinAbs;
using Gizmo = Slic3r::GUI::GLGizmoTextureDisplacement;
using Entry = Gizmo::PaletteEntry;
using MixTarget = Gizmo::MixTarget;
namespace {
const ColorRGBA BLACK{ 0.f, 0.f, 0.f, 1.f };
const ColorRGBA WHITE{ 1.f, 1.f, 1.f, 1.f };
const ColorRGBA RED{ 1.f, 0.f, 0.f, 1.f };
const ColorRGBA BLUE{ 0.f, 0.f, 1.f, 1.f };
const ColorRGBA YELLOW{ 1.f, 1.f, 0.f, 1.f };
MixTarget target(const Vec3f &rgb, float weight)
{
MixTarget t;
RGB2Lab(rgb.x(), rgb.y(), rgb.z(), &t.lab.x(), &t.lab.y(), &t.lab.z());
t.weight = weight;
return t;
}
// The colour a mixed slot of these two filaments shows, as the sidebar computes it.
Vec3f slot_color(const ColorRGBA &a, const ColorRGBA &b, int a_percent)
{
ColorRGB c;
REQUIRE(decode_color(blend_color_multi({ encode_color(a), encode_color(b) }, { a_percent, 100 - a_percent }), c));
return Vec3f(c.r(), c.g(), c.b());
}
// A colour image layer, through Slic3r's own PNG writer so decode_height_texture() reads it the way it
// reads an imported texture.
TextureDisplacementLayer color_layer(int w, int h, const std::vector<uint8_t> &rgb)
{
ScopedTemporaryFile png(".png");
REQUIRE(png::write_rgb_to_file(png.string(), size_t(w), size_t(h), rgb));
std::ifstream in(png.string(), std::ios::binary);
std::vector<unsigned char> bytes{ std::istreambuf_iterator<char>(in), std::istreambuf_iterator<char>() };
REQUIRE_FALSE(bytes.empty());
TextureDisplacementLayer layer;
layer.image_data = std::make_shared<std::vector<unsigned char>>(std::move(bytes));
layer.color_enabled = true;
return layer;
}
// A red/green ramp over a fixed blue: colours spread over many bins, so the image is not flat-colour.
TextureDisplacementLayer gradient_layer()
{
const int n = 64;
std::vector<uint8_t> rgb;
for (int y = 0; y < n; ++y)
for (int x = 0; x < n; ++x) {
rgb.push_back(uint8_t(x * 4));
rgb.push_back(uint8_t(y * 4));
rgb.push_back(128);
}
return color_layer(n, n, rgb);
}
// A 2048x1100 image, over mix_targets()' sampling budget, that is pure red wherever `red(x, y)` holds and
// elsewhere a gradient spread over far more than eight coarse bins, so the image is not flat-colour.
template<class RedFn> TextureDisplacementLayer striped_layer(RedFn red)
{
const int w = 2048, h = 1100;
std::vector<uint8_t> rgb;
rgb.reserve(size_t(w) * size_t(h) * 3);
for (int y = 0; y < h; ++y)
for (int x = 0; x < w; ++x) {
const bool is_red = red(x, y);
rgb.push_back(is_red ? 255 : uint8_t(x * 255 / w));
rgb.push_back(is_red ? 0 : uint8_t(y * 255 / h));
rgb.push_back(is_red ? 0 : 128);
}
return color_layer(w, h, rgb);
}
// How much of the targets' weight is pure red.
float red_weight(const std::vector<MixTarget> &targets)
{
Vec3f red;
RGB2Lab(1.f, 0.f, 0.f, &red.x(), &red.y(), &red.z());
float weight = 0.f;
for (const MixTarget &t : targets)
if ((t.lab - red).norm() < 3.f)
weight += t.weight;
return weight;
}
} // namespace
TEST_CASE("mix targets cover each colouring photo layer once and skip the rest", "[TextureColorPalette][TextureDisplacement]")
{
TextureDisplacementLayer photo = gradient_layer();
const std::vector<MixTarget> targets = Gizmo::mix_targets({ photo });
REQUIRE_FALSE(targets.empty());
float total = 0.f;
for (const MixTarget &t : targets)
total += t.weight;
REQUIRE_THAT(total, WithinAbs(1., 1e-4));
SECTION("A layer that does not colour gives no targets") {
photo.color_enabled = false;
REQUIRE(Gizmo::mix_targets({ photo }).empty());
}
SECTION("A flat-colour image prints in single filaments, so it gives no targets") {
const TextureDisplacementLayer flat = color_layer(8, 8, std::vector<uint8_t>(8 * 8 * 3, 200));
REQUIRE(Gizmo::mix_targets({ flat }).empty());
}
}
TEST_CASE("mix targets of a large image weigh each colour by its share, whatever its stripes", "[TextureColorPalette][TextureDisplacement]")
{
// Sampled rather than read in full, these must not line up with the samples: a fixed sampling step
// sees only one phase of a stripe pattern, and a generator whose offsets repeat sees only some.
SECTION("Red on every other column") {
CHECK_THAT(red_weight(Gizmo::mix_targets({ striped_layer([](int x, int) { return x % 2 == 0; }) })),
WithinAbs(1. / 2., 0.03));
}
SECTION("Red on every fourth column") {
CHECK_THAT(red_weight(Gizmo::mix_targets({ striped_layer([](int x, int) { return x % 4 == 0; }) })),
WithinAbs(1. / 4., 0.03));
}
SECTION("Red on diagonals") {
CHECK_THAT(red_weight(Gizmo::mix_targets({ striped_layer([](int x, int y) { return (x - y) % 3 == 0; }) })),
WithinAbs(1. / 3., 0.03));
}
}
TEST_CASE("the mix ranked first is the one the image needs most", "[TextureColorPalette][TextureDisplacement]")
{
// The whole image is exactly the colour of a 1:1 black/white slot.
const std::vector<MixTarget> targets = { target(slot_color(BLACK, WHITE, 50), 1.f) };
const std::vector<Entry> ranked = Gizmo::rank_mixes({ BLACK, WHITE }, targets, 1);
REQUIRE(ranked.size() == 1);
CHECK(ranked.front().a == 0);
CHECK(ranked.front().b == 1);
CHECK(ranked.front().num * 2 == ranked.front().den);
}
TEST_CASE("an image the filaments already match ranks no mixes", "[TextureColorPalette][TextureDisplacement]")
{
const std::vector<MixTarget> targets = { target(Vec3f(1.f, 0.f, 0.f), 0.5f), target(Vec3f(0.f, 0.f, 1.f), 0.5f) };
REQUIRE(Gizmo::rank_mixes({ RED, BLUE }, targets, 8).empty());
}
TEST_CASE("ranked mixes stay within the limit and show their slot's colour", "[TextureColorPalette][TextureDisplacement]")
{
const std::vector<ColorRGBA> filaments = { RED, BLUE, YELLOW };
const std::vector<MixTarget> targets = Gizmo::mix_targets({ gradient_layer() });
for (const int limit : { 1, 3 }) {
const std::vector<Entry> ranked = Gizmo::rank_mixes(filaments, targets, limit);
CHECK(int(ranked.size()) <= limit);
CHECK_FALSE(ranked.empty());
}
const std::vector<Entry> ranked = Gizmo::rank_mixes(filaments, targets, 6);
for (const Entry &e : ranked) {
REQUIRE(e.is_mix());
const Vec3f expected = slot_color(filaments[size_t(e.a)], filaments[size_t(e.b)], e.a_percent());
CHECK_THAT(e.rgb.x(), WithinAbs(expected.x(), 1e-6));
CHECK_THAT(e.rgb.y(), WithinAbs(expected.y(), 1e-6));
CHECK_THAT(e.rgb.z(), WithinAbs(expected.z(), 1e-6));
}
}
TEST_CASE("picked mixes never need more slots than the project has free", "[TextureColorPalette][TextureDisplacement]")
{
const std::vector<Entry> ranking = { { Vec3f::Zero(), 0, 1, 1, 2 }, { Vec3f::Zero(), 0, 1, 1, 3 }, { Vec3f::Zero(), 0, 1, 2, 3 } };
const auto reusable_second = [](const Entry &e) { return e.num == 1 && e.den == 3; };
SECTION("The count caps the pick") {
REQUIRE(Gizmo::pick_mixes(ranking, 2, 10, nullptr).size() == 2);
}
SECTION("With no free slot only a mix that already has one is kept") {
const std::vector<Entry> picked = Gizmo::pick_mixes(ranking, 3, 0, reusable_second);
REQUIRE(picked.size() == 1);
CHECK(picked.front().den == 3);
CHECK(picked.front().num == 1);
}
SECTION("A reusable mix costs no free slot") {
const std::vector<Entry> picked = Gizmo::pick_mixes(ranking, 3, 1, reusable_second);
REQUIRE(picked.size() == 2);
CHECK(picked[0].den == 2);
CHECK(picked[1].den == 3);
}
}
TEST_CASE("a baked mix paints the slot it was given, wherever that slot sits", "[TextureColorPalette][TextureDisplacement]")
{
// Two filaments, then two mixes of them. Palette index 2 is a mix, but its slot need not be
// filament 2: a project that already holds other mixed slots puts it further along.
const std::vector<Entry> palette = Gizmo::make_palette({ BLACK, WHITE }, { { Vec3f::Zero(), 0, 1, 1, 2 }, { Vec3f::Zero(), 0, 1, 1, 3 } });
REQUIRE(palette.size() == 4);
// Filament 0, the 1:1 mix twice, nothing: the 1:2 mix is never used.
const std::vector<uint8_t> triangle_color = { 1, 3, 3, 0 };
std::vector<Entry> asked;
const auto slot_seven = [&asked](const Entry &e) {
asked.push_back(e);
return 7;
};
const std::vector<int> filament = Gizmo::palette_filaments(palette, triangle_color, slot_seven);
REQUIRE(filament.size() == 4);
CHECK(filament[0] == 0);
CHECK(filament[1] == 1);
CHECK(filament[2] == 7);
CHECK(filament[3] == -1);
// Asking creates a slot, so an unused mix is never asked for.
REQUIRE(asked.size() == 1);
CHECK(asked.front().den == 2);
SECTION("A mix that gets no slot prints in its dominant component") {
const std::vector<int> fallback = Gizmo::palette_filaments(palette, { 3, 4 }, [](const Entry &) { return -1; });
CHECK(fallback[2] == 0); // 1:1 - the first component
CHECK(fallback[3] == 1); // 1 part black in 3 - white dominates
}
}
TEST_CASE("the model's colour paint is drawn by filament, and only where no layer paint covers it", "[TextureColorPalette][TextureDisplacement]")
{
// A strip of four triangles: filament 2 on the first and last, filament 5 on the second, and the third
// left to the volume's own filament.
indexed_triangle_set strip;
strip.vertices = { Vec3f(0, 0, 0), Vec3f(1, 0, 0), Vec3f(0, 1, 0), Vec3f(1, 1, 0), Vec3f(0, 2, 0), Vec3f(1, 2, 0) };
strip.indices = { stl_triangle_vertex_indices(0, 1, 2), stl_triangle_vertex_indices(1, 3, 2), stl_triangle_vertex_indices(2, 3, 4),
stl_triangle_vertex_indices(3, 5, 4) };
const TriangleMesh mesh(strip);
TriangleSelector paint(mesh);
paint.set_facet(0, EnforcerBlockerType(2));
paint.set_facet(1, EnforcerBlockerType(5));
paint.set_facet(3, EnforcerBlockerType(2));
const Gizmo::PaintedColors colors = Gizmo::painted_colors(mesh, paint.serialize());
// Grouped by filament; the triangle in the volume's own filament is never drawn.
REQUIRE(colors.facets.indices.size() == 3);
REQUIRE(colors.source.size() == 3);
REQUIRE(colors.state.size() == 3);
CHECK(colors.state == std::vector<int>{ 2, 2, 5 });
CHECK(colors.source == std::vector<int>{ 0, 3, 1 });
SECTION("A model triangle a layer's paint covers is left to the preview") {
std::vector<bool> excluded(mesh.its.indices.size(), false);
excluded[3] = true;
const std::vector<size_t> kept = colors.outside(excluded);
REQUIRE(kept.size() == 2);
CHECK(colors.source[kept[0]] == 0);
CHECK(colors.source[kept[1]] == 1);
}
SECTION("A mask shorter than the model leaves the rest drawn") {
CHECK(colors.outside({ true }).size() == 2);
}
}