Merge branch 'main' into perf/slicing-optimizations

This commit is contained in:
raistlin7447 committed 2026-10-19 11:33:03 -05:00
commit 17c3bbb7b6
874 files changed
+200107 -30859

No files matched your search

+16
View File
@@ -31,6 +31,22 @@ set_tests_properties(cli_project_missing_keys PROPERTIES
SKIP_RETURN_CODE 77
TIMEOUT 900)
add_test(NAME cli_arrange_rotations
COMMAND bash ${CMAKE_CURRENT_SOURCE_DIR}/test_cli_arrange_rotations.sh $<TARGET_FILE:OrcaSlicer> ${ORCA_CLI_TEST_PYTHON}
${CMAKE_SOURCE_DIR}/resources/profiles/BBL)
set_tests_properties(cli_arrange_rotations PROPERTIES
LABELS "CLI;RequiresApp"
SKIP_RETURN_CODE 77
TIMEOUT 900)
add_test(NAME cli_no_suitable_objects
COMMAND bash ${CMAKE_CURRENT_SOURCE_DIR}/test_cli_no_suitable_objects.sh $<TARGET_FILE:OrcaSlicer> ${ORCA_CLI_TEST_PYTHON}
${CMAKE_SOURCE_DIR}/resources/profiles/BBL)
set_tests_properties(cli_no_suitable_objects PROPERTIES
LABELS "CLI;RequiresApp"
SKIP_RETURN_CODE 77
TIMEOUT 900)
add_test(NAME cli_malformed_input
COMMAND bash ${CMAKE_CURRENT_SOURCE_DIR}/test_cli_malformed_input.sh $<TARGET_FILE:OrcaSlicer> ${ORCA_CLI_TEST_PYTHON})
set_tests_properties(cli_malformed_input PROPERTIES
+89
View File
@@ -0,0 +1,89 @@
#!/usr/bin/env bash
# End-to-end check of the CLI's arrange rotation policy.
#
# Arrange is translate-only unless --allow-rotations is passed, so a bar that fits the bed only
# diagonally is not placed and the plate ends up with nothing fully inside the print volume. The
# run must then say that rotations were off, on stderr as well as in result.json, because
# result.json is written on Linux only. Passing the flag must place the same bar and slice it, and
# an object that fits axis-aligned must be unaffected by the default.
#
# usage: test_cli_arrange_rotations.sh <orca-slicer binary> <python3> <resources/profiles/BBL>
set -u
BIN="${1:-}"
PY="${2:-python3}"
PROFILES="${3:-}"
# 77 is the test's SKIP_RETURN_CODE.
[ -x "$BIN" ] || { echo "SKIP: orca-slicer binary not found: $BIN"; exit 77; }
[ -d "$PROFILES" ] || { echo "FAIL: profiles directory not found: $PROFILES"; exit 1; }
WORK="$(mktemp -d "${TMPDIR:-/tmp}/orca-cli-arrange-rotations.XXXXXX")"
trap 'rm -rf "$WORK"' EXIT
# box <file> <x> <y> <z>: an ASCII STL box, so the test needs no fixture.
box() {
"$PY" - "$@" <<'EOF'
import sys
out, sx, sy, sz = sys.argv[1], float(sys.argv[2]), float(sys.argv[3]), float(sys.argv[4])
v = [(x, y, z) for z in (0, sz) for y in (0, sy) for x in (0, sx)]
with open(out, "w") as f:
f.write("solid box\n")
for a, b, c, d in ((0, 2, 3, 1), (4, 5, 7, 6), (0, 1, 5, 4), (2, 6, 7, 3), (0, 4, 6, 2), (1, 3, 7, 5)):
for tri in ((v[a], v[b], v[c]), (v[a], v[c], v[d])):
f.write("facet normal 0 0 0\nouter loop\n")
for p in tri:
f.write("vertex %g %g %g\n" % p)
f.write("endloop\nendfacet\n")
f.write("endsolid box\n")
EOF
}
# Longer than the 256 mm bed edge, shorter than its 362 mm diagonal: it fits only turned.
box "$WORK/bar.stl" 300 10 5
# Fits axis-aligned, so the default must not change anything for it.
box "$WORK/cube.stl" 10 10 10
# slice <tag> <input> [option...] -> $WORK/<tag>/{rc,stdout,stderr,result.json}
slice() {
local out="$WORK/$1" input="$2"; shift 2
mkdir -p "$out"
timeout 300 "$BIN" --datadir "$out/datadir" \
--load-settings "$PROFILES/machine/Bambu Lab X1 Carbon 0.4 nozzle.json;$PROFILES/process/0.20mm Standard @BBL X1C.json" \
--load-defaultfila "$@" --slice 0 --outputdir "$out" "$input" \
> "$out/stdout" 2> "$out/stderr"
echo "$?" > "$out/rc"
}
# exit_code <tag>: the CLI's negative code as the shell saw it, e.g. -50 arrives as 206.
exit_code() { "$PY" -c "import sys; rc = int(open(sys.argv[1]).read()); print(rc - 256 if rc > 127 else rc)" "$WORK/$1/rc"; }
fail() { echo "FAIL: $*"; exit 1; }
# 1. Default: no flag, so no rotations, so the bar does not fit and the reason names rotation.
slice default "$WORK/bar.stl"
[ "$(exit_code default)" = "-50" ] || fail "default: expected -50, got $(exit_code default)"
grep -q "Arrange ran without rotations" "$WORK/default/stderr" \
|| fail "default: stderr does not mention rotations: $(cat "$WORK/default/stderr")"
"$PY" - "$WORK/default/result.json" <<'EOF' || exit 1
import json, sys
result = json.load(open(sys.argv[1]))
if result["return_code"] != -50:
sys.exit("FAIL: result.json return_code %s, expected -50" % result["return_code"])
if "Arrange ran without rotations" not in result["error_string"]:
sys.exit("FAIL: result.json error_string does not mention rotations: %s" % result["error_string"])
EOF
# 2. The flag is coBool, so it takes no value: it must place the same bar and slice it.
slice rotations "$WORK/bar.stl" --allow-rotations
[ "$(exit_code rotations)" = "0" ] || fail "--allow-rotations: expected 0, got $(exit_code rotations); $(tail -n 5 "$WORK/rotations/stdout")"
[ -s "$WORK/rotations/plate_1.gcode" ] || fail "--allow-rotations: no g-code written"
# 3. An object that fits axis-aligned is unaffected by the default, and says nothing about rotation.
slice cube "$WORK/cube.stl"
[ "$(exit_code cube)" = "0" ] || fail "cube: expected 0, got $(exit_code cube); $(tail -n 5 "$WORK/cube/stdout")"
[ -s "$WORK/cube/plate_1.gcode" ] || fail "cube: no g-code written"
grep -q "Arrange ran without rotations" "$WORK/cube/stderr" && fail "cube: unexpected rotation message on a successful run"
echo "PASS: arrange rotation default, its failure message, and the opt-in flag"
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env bash
# The reason recorded for CLI_NO_SUITABLE_OBJECTS (-50) has to fit the input the CLI was given.
# Any input format is arranged and sliced, so an object larger than the bed empties the plate just
# as a 3mf with an empty plate does, and the recorded reason must not send such a caller off to
# inspect a 3mf or an upload that is not part of their run.
#
# usage: test_cli_no_suitable_objects.sh <orca-slicer binary> <python3> <resources/profiles/BBL>
set -u
BIN="${1:-}"
PY="${2:-python3}"
PROFILES="${3:-}"
# 77 is the test's SKIP_RETURN_CODE.
[ -x "$BIN" ] || { echo "SKIP: orca-slicer binary not found: $BIN"; exit 77; }
[ -d "$PROFILES" ] || { echo "FAIL: profiles directory not found: $PROFILES"; exit 1; }
WORK="$(mktemp -d "${TMPDIR:-/tmp}/orca-cli-no-suitable-objects.XXXXXX")"
trap 'rm -rf "$WORK"' EXIT
# box <file> <x> <y> <z>: an ASCII STL box, so the test needs no fixture.
box() {
"$PY" - "$@" <<'EOF'
import sys
out, sx, sy, sz = sys.argv[1], float(sys.argv[2]), float(sys.argv[3]), float(sys.argv[4])
v = [(x, y, z) for z in (0, sz) for y in (0, sy) for x in (0, sx)]
with open(out, "w") as f:
f.write("solid box\n")
for a, b, c, d in ((0, 2, 3, 1), (4, 5, 7, 6), (0, 1, 5, 4), (2, 6, 7, 3), (0, 4, 6, 2), (1, 3, 7, 5)):
for tri in ((v[a], v[b], v[c]), (v[a], v[c], v[d])):
f.write("facet normal 0 0 0\nouter loop\n")
for p in tri:
f.write("vertex %g %g %g\n" % p)
f.write("endloop\nendfacet\n")
f.write("endsolid box\n")
EOF
}
# Wider than the 256 mm bed and wider than its 362 mm diagonal, so no placement can fit it.
box "$WORK/slab.stl" 400 400 5
# Fits, so the run must succeed: proves the harness itself is sound.
box "$WORK/cube.stl" 10 10 10
# slice <tag> <input> [option...] -> $WORK/<tag>/{rc,stdout,stderr,result.json}
slice() {
local out="$WORK/$1" input="$2"; shift 2
mkdir -p "$out"
timeout 300 "$BIN" --datadir "$out/datadir" \
--load-settings "$PROFILES/machine/Bambu Lab X1 Carbon 0.4 nozzle.json;$PROFILES/process/0.20mm Standard @BBL X1C.json" \
"$@" --slice 0 --outputdir "$out" "$input" \
> "$out/stdout" 2> "$out/stderr"
echo "$?" > "$out/rc"
}
# exit_code <tag>: the CLI's negative code as the shell saw it, e.g. -50 arrives as 206.
exit_code() { "$PY" -c "import sys; rc = int(open(sys.argv[1]).read()); print(rc - 256 if rc > 127 else rc)" "$WORK/$1/rc"; }
fail() { echo "FAIL: $*"; exit 1; }
# --allow-rotations keeps this on the generic reason: a translate-only arrange has its own,
# more specific one. The slab is wider than the 362 mm bed diagonal, so rotation cannot place it.
slice slab "$WORK/slab.stl" --allow-rotations
[ "$(exit_code slab)" = "-50" ] || fail "slab: expected -50, got $(exit_code slab); $(tail -n 5 "$WORK/slab/stdout")"
"$PY" - "$WORK/slab/result.json" <<'EOF' || exit 1
import json, sys
result = json.load(open(sys.argv[1]))
if result["return_code"] != -50:
sys.exit("FAIL: result.json return_code %s, expected -50" % result["return_code"])
reason = result["error_string"]
if not reason.strip():
sys.exit("FAIL: result.json carries no error_string")
# The run was handed an STL and asked for g-code: neither a 3mf nor an upload exists to check.
for word in ("3mf", "3MF", "upload"):
if word in reason:
sys.exit("FAIL: error_string mentions %r for an STL run: %s" % (word, reason))
EOF
slice cube "$WORK/cube.stl"
[ "$(exit_code cube)" = "0" ] || fail "cube: expected 0, got $(exit_code cube); $(tail -n 5 "$WORK/cube/stdout")"
[ -s "$WORK/cube/plate_1.gcode" ] || fail "cube: no g-code written"
echo "PASS: the no-suitable-objects reason fits a non-3mf run"
@@ -0,0 +1,35 @@
ISO-10303-21;
HEADER;
FILE_DESCRIPTION((''),'2;1');
FILE_NAME('surface_model_geometric_set','2026-10-12T00:00:00',(''),(''),'','','');
FILE_SCHEMA(('CONFIG_CONTROL_DESIGN'));
ENDSEC;
DATA;
#1=APPLICATION_CONTEXT('configuration controlled 3d designs of mechanical parts and assemblies');
#2=APPLICATION_PROTOCOL_DEFINITION('international standard','config_control_design',1994,#1);
#3=MECHANICAL_CONTEXT('',#1,'mechanical');
#4=PRODUCT('surfaces','surfaces','',(#3));
#5=PRODUCT_DEFINITION_FORMATION_WITH_SPECIFIED_SOURCE('','',#4,.NOT_KNOWN.);
#6=DESIGN_CONTEXT('',#1,'design');
#7=PRODUCT_DEFINITION('','',#5,#6);
#8=PRODUCT_DEFINITION_SHAPE('','',#7);
#9=SHAPE_DEFINITION_REPRESENTATION(#8,#20);
#10=(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT(.MILLI.,.METRE.));
#11=(NAMED_UNIT(*)PLANE_ANGLE_UNIT()SI_UNIT($,.RADIAN.));
#12=(NAMED_UNIT(*)SI_UNIT($,.STERADIAN.)SOLID_ANGLE_UNIT());
#13=UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE(1.E-6),#10,'distance_accuracy_value','');
#14=(GEOMETRIC_REPRESENTATION_CONTEXT(3)GLOBAL_UNCERTAINTY_ASSIGNED_CONTEXT((#13))GLOBAL_UNIT_ASSIGNED_CONTEXT((#10,#11,#12))REPRESENTATION_CONTEXT('',''));
#15=CARTESIAN_POINT('',(0.,0.,0.));
#16=DIRECTION('',(0.,0.,1.));
#17=DIRECTION('',(1.,0.,0.));
#18=AXIS2_PLACEMENT_3D('',#15,#16,#17);
#19=GEOMETRIC_SET('',(#30,#40));
#20=GEOMETRICALLY_BOUNDED_SURFACE_SHAPE_REPRESENTATION('',(#18,#19),#14);
#30=RECTANGULAR_TRIMMED_SURFACE('',#31,0.,10.,0.,10.,.T.,.T.);
#31=PLANE('',#18);
#40=RECTANGULAR_TRIMMED_SURFACE('',#41,0.,10.,0.,10.,.T.,.T.);
#41=PLANE('',#42);
#42=AXIS2_PLACEMENT_3D('',#43,#16,#17);
#43=CARTESIAN_POINT('',(0.,0.,10.));
ENDSEC;
END-ISO-10303-21;
@@ -163,7 +163,7 @@ M109 S240 T0 ; set nozzle temperature and wait for it to be reached
; CP TOOLCHANGE END
M104 S240 T1 ; preheat T1 time: 30s lead 30.0s
; CP TOOLCHANGE START
M104 S200 T0 ; set nozzle temperature ;cooldown
M104 S0 T0 ; set nozzle temperature ;cooldown
T1 ; change extruder
M109 S240 T1 ; set nozzle temperature and wait for it to be reached
; CP TOOLCHANGE END
+1
View File
@@ -9,6 +9,7 @@ add_executable(${_TEST_NAME}_tests
test_fill.cpp
test_flow.cpp
test_gcode_processor.cpp
test_gcode_statistics.cpp
test_gcode_timing.cpp
test_gcode.cpp
test_gcodeprocessor.cpp
+31 -31
View File
@@ -37,7 +37,7 @@
#include "libslic3r/Fill/Fill.hpp"
#include "libslic3r/Fill/FillAdaptive.hpp"
#include "libslic3r/Fill/FillGyroid.hpp"
#include "libslic3r/Fill/FillTpmsAdaptive.hpp"
#include "libslic3r/Fill/AdaptiveInfillField.hpp"
#include "libslic3r/Flow.hpp"
#include "libslic3r/Geometry.hpp"
#include "libslic3r/IntersectionPoints.hpp"
@@ -2060,9 +2060,9 @@ TEST_CASE("Adaptive TPMS infill thins out from the surface to the interior densi
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"},
{"adaptive_infill_mode", adaptive},
{"adaptive_infill_interior_density", "5%"},
{"adaptive_infill_gradient", "linear"},
{"layer_height", 0.4},
{"initial_layer_print_height", 0.4}});
};
@@ -2089,9 +2089,9 @@ TEST_CASE("Adaptive TPMS infill of a tall object is sparsest at its middle heigh
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"},
{"adaptive_infill_mode", "lobes"},
{"adaptive_infill_interior_density", "5%"},
{"adaptive_infill_gradient", "linear"},
{"layer_height", 0.4},
{"initial_layer_print_height", 0.4}});
const Polygons core = centered_square(5.);
@@ -2111,9 +2111,9 @@ TEST_CASE("2D adaptive TPMS infill does not change along its axis", "[Fill]")
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"},
{"adaptive_infill_mode", adaptive},
{"adaptive_infill_interior_density", "5%"},
{"adaptive_infill_gradient", "linear"},
{"layer_height", 0.4},
{"initial_layer_print_height", 0.4}});
};
@@ -2147,9 +2147,9 @@ TEST_CASE("Adaptive TPMS shells and blend keep the whole deep core of a tall obj
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"},
{"adaptive_infill_mode", adaptive},
{"adaptive_infill_interior_density", "5%"},
{"adaptive_infill_gradient", "linear"},
{"layer_height", 0.4},
{"initial_layer_print_height", 0.4}});
};
@@ -2179,9 +2179,9 @@ TEST_CASE("Adaptive TPMS infill is sparse at the center of both of two united sp
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"},
{"adaptive_infill_mode", adaptive},
{"adaptive_infill_interior_density", "5%"},
{"adaptive_infill_gradient", "linear"},
{"layer_height", 0.4},
{"initial_layer_print_height", 0.4}});
};
@@ -2209,9 +2209,9 @@ TEST_CASE("Adaptive TPMS gradients keep the surface density deeper in the order
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},
{"adaptive_infill_mode", "lobes"},
{"adaptive_infill_interior_density", "5%"},
{"adaptive_infill_gradient", gradient},
{"layer_height", 0.4},
{"initial_layer_print_height", 0.4}});
return sparse_infill_length(print, 0., 40., centered_square(20.));
@@ -2236,9 +2236,9 @@ TEST_CASE("Adaptive TPMS settings leave the infill unchanged when they do not ap
{{"sparse_infill_pattern", pattern},
{"sparse_infill_density", "20%"},
{"layer_height", 0.2},
{"tpms_adaptive", adaptive},
{"tpms_interior_density", "40%"},
{"tpms_adaptive_gradient", "exponential"}});
{"adaptive_infill_mode", adaptive},
{"adaptive_infill_interior_density", "40%"},
{"adaptive_infill_gradient", "exponential"}});
const SparseInfillShape expected = sparse_infill_shape(reference);
REQUIRE(expected.path_count > 0);
CHECK(sparse_infill_shape(tuned).sequence == expected.sequence);
@@ -2252,7 +2252,7 @@ TEST_CASE("Adaptive gyroid infill ignores the Z-buckling optimization", "[Fill]"
{{"sparse_infill_pattern", "gyroid"},
{"sparse_infill_density", "10%"},
{"gyroid_optimized", gyroid_optimized},
{"tpms_adaptive", "lobes"},
{"adaptive_infill_mode", "lobes"},
{"layer_height", 0.2}});
return sparse_infill_shape(print);
};
@@ -2268,10 +2268,10 @@ TEST_CASE("Adaptive TPMS infill of a region matches the infill of a larger regio
// 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;
std::vector<AdaptiveInfillField::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, [] {});
const AdaptiveInfillField field(slices, get_extents(box), AdaptiveInfillMode::Lobes, [] {});
auto circle = [](double radius) {
Polygon contour = make_circle_num_segments(scale_(radius), 120);
@@ -2285,13 +2285,13 @@ TEST_CASE("Adaptive TPMS infill of a region matches the infill of a larger regio
filler->spacing = 0.45;
filler->angle = float(M_PI / 7.);
filler->z = z;
filler->tpms_radial_field = &field;
filler->adaptive_infill_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.adaptive_infill_mode = AdaptiveInfillMode::Lobes;
params.adaptive_infill_interior_density = 0.05f;
params.adaptive_infill_gradient = AdaptiveInfillGradient::Linear;
params.layer_height = 0.2;
params.dont_adjust = true;
Surface surface(stInternal, expolygon);
@@ -2328,8 +2328,8 @@ TEST_CASE("Adaptive TPMS anchors match the printed infill", "[Fill][InternalBrid
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%"},
{"adaptive_infill_mode", "lobes"},
{"adaptive_infill_interior_density", "5%"},
{"layer_height", 0.2},
{"initial_layer_print_height", 0.2},
{"resolution", 0.012}});
+207
View File
@@ -3,17 +3,26 @@
#include <catch2/catch_message.hpp>
#include "libslic3r/BoundingBox.hpp"
#include "libslic3r/Config.hpp"
#include "libslic3r/Model.hpp"
#include "libslic3r/ModelArrange.hpp"
#include "libslic3r/Print.hpp"
#include "libslic3r/PrintConfig.hpp"
#include "libslic3r/Preset.hpp"
#include "libslic3r/PresetBundle.hpp"
#include "test_helpers.hpp"
#include "test_utils.hpp"
#include "libslic3r/TriangleMesh.hpp"
#include "libslic3r/Point.hpp"
#include <cstddef>
#include <fstream>
#include <ios>
#include <string>
#include <vector>
#include <catch2/matchers/catch_matchers.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp>
using namespace Slic3r;
@@ -41,3 +50,201 @@ TEST_CASE("Klipper object labels name each copy without the characters Klipper c
CHECK(gcode.find("EXCLUDE_OBJECT_START NAME=" + instance_label + "\n") != std::string::npos);
}
}
// The outline written to EXCLUDE_OBJECT_DEFINE is simplified with exclude_object_tolerance. A larger value must
// leave fewer points, which keeps the exclude-object array small on printers that stall while parsing it.
TEST_CASE("A larger exclude object tolerance writes fewer outline points", "[GCode]")
{
const auto slice_with_tolerance = [](const std::string &tolerance) {
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
config.set_deserialize_strict({{"gcode_flavor", "klipper"}, {"exclude_object", "1"},
{"exclude_object_tolerance", tolerance}});
// Default facet angle (2 degrees): 180 hull points, well inside either tolerance.
return Test::slice({make_cylinder(20., 5.)}, config);
};
// Points of the POLYGON=[[x,y],...] on the first EXCLUDE_OBJECT_DEFINE line.
const auto polygon_points = [](const std::string &gcode) {
const size_t define = gcode.find("EXCLUDE_OBJECT_DEFINE");
REQUIRE(define != std::string::npos);
const size_t begin = gcode.find("POLYGON=", define);
REQUIRE(begin != std::string::npos);
const std::string polygon = gcode.substr(begin, gcode.find('\n', begin) - begin);
size_t points = 1;
for (size_t pos = polygon.find("],["); pos != std::string::npos; pos = polygon.find("],[", pos + 1))
++points;
return points;
};
const size_t fine = polygon_points(slice_with_tolerance("0.1"));
const size_t coarse = polygon_points(slice_with_tolerance("0.4"));
INFO("points at 0.1 mm: " << fine << ", at 0.4 mm: " << coarse);
CHECK(coarse >= 4);
CHECK(coarse < fine);
}
// Existing profiles must keep writing the outlines they wrote when the tolerance was a constant 0.1 mm.
TEST_CASE("The exclude object tolerance defaults to the former fixed 0.1 mm", "[GCode]")
{
CHECK_THAT(DynamicPrintConfig::full_print_config().opt_float("exclude_object_tolerance"),
Catch::Matchers::WithinAbs(0.1, 1e-9));
}
// Firmwares read the compatibility header lines (Anycubic's GoKlipper heats the bed with
// first_layer_bed_temperature), so they must describe the filament the plate prints, not filament 1.
TEST_CASE("The G-code header names the first layer temperatures of the filament the plate prints", "[GCode]")
{
const std::string formula = GENERATE("by_first_filament", "by_highest_temp");
DYNAMIC_SECTION("bed_temperature_formula " << formula) {
// Filament 1: bed 60, nozzle 200. Filament 2: bed 100, nozzle 250. Everything prints with filament 2.
DynamicPrintConfig config = Test::multifilament_config(2, {
{"bed_temperature_formula", formula},
// Authored 1..n as PresetBundle writes it; the all-1s default collapses every per-filament value
// to filament 1.
{"filament_self_index", "1,2"},
{"filament_extruder_variant", "Direct Drive Standard;Direct Drive Standard"},
{"enable_prime_tower", 0},
{"nozzle_temperature_initial_layer", "200,250"},
{"nozzle_temperature", "200,250"},
{"supertack_plate_temp_initial_layer", "60,100"},
{"cool_plate_temp_initial_layer", "60,100"},
{"textured_cool_plate_temp_initial_layer", "60,100"},
{"eng_plate_temp_initial_layer", "60,100"},
{"hot_plate_temp_initial_layer", "60,100"},
{"textured_plate_temp_initial_layer", "60,100"},
{"sparse_infill_filament_id", 2},
{"internal_solid_filament_id", 2},
{"top_surface_filament_id", 2},
{"bottom_surface_filament_id", 2},
{"outer_wall_filament_id", 2},
{"inner_wall_filament_id", 2},
{"skirt_loops", 0},
{"brim_type", "no_brim"},
});
const std::string gcode = Test::slice({Test::cube(20)}, config);
const auto header_value = [&gcode](const std::string& key) {
const std::string token = "; " + key + " = ";
const size_t pos = gcode.find(token);
REQUIRE(pos != std::string::npos);
return std::stoi(gcode.substr(pos + token.size()));
};
CHECK(header_value("first_layer_bed_temperature") == 100);
CHECK(header_value("first_layer_temperature") == 250);
}
}
// Printing by object reassigns the initial filament to each object's first filament while it emits the objects, so
// the header must still name the filament the print starts with: the firmware heats the bed for the first object.
TEST_CASE("The G-code header names the first object's filament when printing by object", "[GCode]")
{
// Filament 1: bed 60, nozzle 200. Filament 2: bed 100, nozzle 250. Object 1 prints with filament 2, object 2
// with filament 1.
DynamicPrintConfig config = Test::multifilament_config(2, {
{"print_sequence", "by object"},
{"bed_temperature_formula", "by_first_filament"},
{"filament_self_index", "1,2"},
{"filament_extruder_variant", "Direct Drive Standard;Direct Drive Standard"},
{"enable_prime_tower", 0},
{"nozzle_temperature_initial_layer", "200,250"},
{"nozzle_temperature", "200,250"},
{"supertack_plate_temp_initial_layer", "60,100"},
{"cool_plate_temp_initial_layer", "60,100"},
{"textured_cool_plate_temp_initial_layer", "60,100"},
{"eng_plate_temp_initial_layer", "60,100"},
{"hot_plate_temp_initial_layer", "60,100"},
{"textured_plate_temp_initial_layer", "60,100"},
{"skirt_loops", 0},
{"brim_type", "no_brim"},
});
const std::string gcode = Test::slice_with_object_overrides({Test::cube(20), Test::cube(20)}, config,
{{{"extruder", "2"}}, {{"extruder", "1"}}});
// Filament 2 starts and filament 1 follows, so the last object's filament differs from the first one's.
const size_t first_t1 = gcode.find("\nT1");
const size_t first_t0 = gcode.find("\nT0");
REQUIRE(first_t1 != std::string::npos);
REQUIRE(first_t0 != std::string::npos);
REQUIRE(first_t1 < first_t0);
const auto header_value = [&gcode](const std::string& key) {
const std::string token = "; " + key + " = ";
const size_t pos = gcode.find(token);
REQUIRE(pos != std::string::npos);
return std::stoi(gcode.substr(pos + token.size()));
};
CHECK(header_value("first_layer_bed_temperature") == 100);
CHECK(header_value("first_layer_temperature") == 250);
}
TEST_CASE("Object label comments number each object and copy with or without exclude object", "[GCode]")
{
// repetier has no exclude-object commands, so set_object_info() writes nothing for it.
// BBL printers write M624/M625 skip blocks whatever exclude_object is set to.
const auto [flavor, exclude_object, bbl] = GENERATE(table<std::string, std::string, bool>({
{"marlin2", "0", false},
{"klipper", "0", false},
{"klipper", "1", false},
{"repetier", "1", false},
{"marlin2", "0", true},
}));
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
config.set_deserialize_strict({{"gcode_flavor", flavor}, {"exclude_object", exclude_object}, {"gcode_label_objects", "1"}});
Print print;
print.is_BBL_printer() = bbl;
Model model;
Test::init_print(std::vector<TriangleMesh>{Test::cube(20.), Test::cube(20.)}, print, model, config, nullptr, false, 2);
arrange_objects(model, BoundingBox{Point::new_scale(0., 0.), Point::new_scale(500., 500.)},
ArrangeParams{scaled(min_object_distance(config))});
print.apply(model, config);
const std::string gcode = Test::gcode(print);
INFO(flavor << ", exclude_object " << exclude_object << ", BBL " << bbl);
for (const char *label : {"id:0 copy 0", "id:0 copy 1", "id:1 copy 0", "id:1 copy 1"}) {
INFO(label);
CHECK(gcode.find("; printing object object.stl " + std::string(label) + "\n") != std::string::npos);
}
if (bbl)
for (const PrintObject *object : print.objects())
for (const PrintInstance &instance : object->instances()) {
const std::string label_id = std::to_string(instance.model_instance->get_labeled_id());
INFO("label id " << label_id);
CHECK(gcode.find("; start printing object, unique label id: " + label_id + "\nM624 ") != std::string::npos);
CHECK(gcode.find("; stop printing object, unique label id: " + label_id + "\nM625\n") != std::string::npos);
}
}
TEST_CASE("An exported G-code's settings load back as the settings it was sliced with", "[GCode]")
{
// Two filaments, so the config block carries the flush matrix already multiplied by flush_multiplier.
const DynamicPrintConfig config = Test::multifilament_config(2, {
{ "flush_multiplier", "0.5" },
{ "printable_area", "0x0,250x0,250x210,0x210" },
{ "printable_height", "180" },
});
Print print;
Model model;
Test::init_print(std::vector<TriangleMesh>{ Test::cube(20.) }, print, model, config, nullptr, false);
ScopedTemporaryFile file(".gcode");
{
std::ofstream os(file.string(), std::ios::binary);
os << Test::gcode(print);
REQUIRE(os.good());
}
PresetBundle bundle;
// The config block leaves prime_volume_mode out, so the receiver's choice has to survive the load.
bundle.project_config.option<ConfigOptionEnum<PrimeVolumeMode>>("prime_volume_mode")->value = PrimeVolumeMode::pvmFast;
ConfigSubstitutions substitutions;
bundle.load_config_model(file.string(), bundle.config_from_gcode(file.string(), ForwardCompatibilitySubstitutionRule::Enable, substitutions));
const DynamicPrintConfig &printer = bundle.printers.get_edited_preset().config;
CHECK(printer.opt_serialize("printable_area") == config.opt_serialize("printable_area"));
CHECK_THAT(printer.opt_float("printable_height"), Catch::Matchers::WithinAbs(180., 1e-6));
// The purge between the two filaments is what the slice used: 280 at a 0.5 multiplier.
const std::vector<double> &matrix = bundle.project_config.option<ConfigOptionFloats>("flush_volumes_matrix")->values;
const std::vector<double> &multiplier = bundle.project_config.option<ConfigOptionFloats>("flush_multiplier")->values;
REQUIRE(matrix.size() == 4);
REQUIRE(multiplier.size() == 1);
CHECK_THAT(matrix[1] * multiplier[0], Catch::Matchers::WithinAbs(140., 1e-6));
CHECK(bundle.project_config.option<ConfigOptionEnum<PrimeVolumeMode>>("prime_volume_mode")->value == PrimeVolumeMode::pvmFast);
}
+73
View File
@@ -2,14 +2,19 @@
#include "libslic3r/libslic3r.h"
#include "libslic3r/GCode/GCodeProcessor.hpp"
#include "libslic3r/Model.hpp"
#include "libslic3r/Print.hpp"
#include "libslic3r/PrintConfig.hpp"
#include "test_utils.hpp"
#include <cstddef>
#include <fstream>
#include <initializer_list>
#include <string>
#include <catch2/catch_message.hpp>
#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>
@@ -49,6 +54,74 @@ TEST_CASE("The belt header's angle marks G-code as belt G-code", "[GCodeProcesso
CHECK_THAT(processed_belt_tilt(gcode), WithinAbs(45., 1e-6));
}
TEST_CASE("A loaded G-code's belt settings and bed replace the selected printer's in the preview", "[GCodeProcessor][belt][Regression]")
{
// Loading a G-code applies the selected printer's config to the Print, then the file's render
// config on top, so the preview's belt view and back-transform follow the file. The selected
// printer is always the other kind, and every belt and bed key differs between the two.
const bool file_is_belt = GENERATE(false, true);
CAPTURE(file_is_belt);
DynamicPrintConfig file = DynamicPrintConfig::full_print_config();
file.set_deserialize_strict({
{ "belt_printer", file_is_belt },
{ "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", 35 },
{ "printable_area", "0x0,300x0,300x250,0x250" },
{ "printable_height", 123 },
});
DynamicPrintConfig selected = DynamicPrintConfig::full_print_config();
selected.set_deserialize_strict({
{ "belt_printer", !file_is_belt },
{ "belt_slice_rotation", "x" },
{ "belt_slice_rotation_angle", 45 },
{ "gcode_remap_x", "pos_x" },
{ "gcode_remap_y", "pos_y" },
{ "gcode_remap_z", "pos_z" },
{ "belt_frame_tilt_decouple", 0 },
{ "belt_frame_tilt_angle", 50 },
{ "printable_area", "0x0,180x0,180x180,0x180" },
{ "printable_height", 200 },
});
ScopedTemporaryFile temp(".gcode");
{
std::ofstream os(temp.string());
REQUIRE(os.is_open());
// A full config block, as the loader rejects one with fewer than 80 values.
os << "; generated by OrcaSlicer\n" << body << "; CONFIG_BLOCK_START\n";
for (const std::string &key : file.keys())
os << "; " << key << " = " << file.opt_serialize(key) << '\n';
os << "; CONFIG_BLOCK_END\n";
REQUIRE(os.good());
}
GCodeProcessor processor;
REQUIRE_NOTHROW(processor.process_file(temp.string()));
Model model;
Print print;
print.apply(model, selected);
REQUIRE_NOTHROW(print.apply_config_for_render(processor.export_config_for_render()));
// The file's belt settings win either way, so a non-belt file turns a selected belt printer's view off.
for (const char *key : { "belt_printer", "belt_slice_rotation", "belt_slice_rotation_angle", "gcode_remap_x",
"gcode_remap_y", "gcode_remap_z", "belt_frame_tilt_decouple", "belt_frame_tilt_angle" }) {
CAPTURE(key);
CHECK(print.config().opt_serialize(key) == file.opt_serialize(key));
}
// Only a belt file brings the bed it was sliced for.
const DynamicPrintConfig &bed_source = file_is_belt ? file : selected;
for (const char *key : { "printable_area", "printable_height" }) {
CAPTURE(key);
CHECK(print.config().opt_serialize(key) == bed_source.opt_serialize(key));
}
}
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")
+124
View File
@@ -0,0 +1,124 @@
#include <catch2/catch_test_macros.hpp>
#include <catch2/matchers/catch_matchers.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp>
#include "libslic3r/Config.hpp"
#include "libslic3r/GCode/GCodeProcessor.hpp"
#include "libslic3r/GCode/GCodeStatistics.hpp"
#include "libslic3r/Model.hpp"
#include "libslic3r/Point.hpp"
#include "libslic3r/Print.hpp"
#include "libslic3r/PrintConfig.hpp"
#include "libslic3r/Utils.hpp"
#include "test_helpers.hpp"
#include "test_utils.hpp"
#include <nlohmann/json.hpp>
#include <cstddef>
#include <initializer_list>
#include <set>
#include <string>
#include <vector>
using namespace Slic3r;
TEST_CASE("The statistics of a sliced plate add up per layer, feature and object instance", "[GCodeStatistics]")
{
// A 20 mm cube at 0.2 mm layers prints 100 of them; without skirt or brim all its filament goes to its instances.
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
config.set_deserialize_strict({ { "layer_height", 0.2 },
{ "initial_layer_print_height", 0.2 },
{ "skirt_loops", 0 },
{ "brim_type", "no_brim" },
{ "filament_density", 1.25 } });
Print print;
Model model;
Test::init_print({ Test::cube(20) }, print, model, config, nullptr, true, 2);
GCodeProcessorResult result;
Test::gcode(print, &result);
ScopedTemporaryFile file(".json");
export_gcode_statistics(result, file.string());
std::string text;
load_string_file(file.path(), text);
const nlohmann::json statistics = nlohmann::json::parse(text);
const nlohmann::json &summary = statistics["summary"];
const nlohmann::json &layers = statistics["layers"];
REQUIRE(layers.size() == 100);
CHECK(summary["layer_count"] == 100);
CHECK_THAT(layers.front()["z"].get<double>(), Catch::Matchers::WithinAbs(0.2, 1e-3));
CHECK_THAT(layers.back()["z"].get<double>(), Catch::Matchers::WithinAbs(20., 1e-3));
double time = 0., volume = 0.;
for (const nlohmann::json &layer : layers) {
time += layer["time"].get<double>();
volume += layer["volume"].get<double>();
double features_time = 0.;
for (const auto &[name, feature] : layer["features"].items())
features_time += feature["time"].get<double>();
CHECK(features_time <= layer["time"].get<double>() + 1e-3);
}
// Each value is rounded to the millisecond and the cubic millimeter.
CHECK_THAT(time, Catch::Matchers::WithinAbs(summary["time"].get<double>(), 0.1));
CHECK_THAT(volume, Catch::Matchers::WithinAbs(summary["volume"].get<double>(), 0.1));
CHECK_THAT(summary["weight"].get<double>(), Catch::Matchers::WithinRel(summary["volume"].get<double>() * 1.25 / 1000., 1e-4));
CHECK(summary.contains("filament_cost"));
// The filament splits by use as the preview's table does.
REQUIRE(summary["filaments"].size() == 1);
const nlohmann::json &filament = summary["filaments"].front();
double used = 0.;
for (const char *use : { "model", "support", "prime_tower", "flush" })
used += filament[use]["volume"].get<double>();
CHECK_THAT(used, Catch::Matchers::WithinAbs(filament["volume"].get<double>(), 0.01));
const nlohmann::json &features = summary["features"];
CHECK(features.contains("Outer wall"));
CHECK(features.contains("Sparse infill"));
double model_volume = 0.;
for (const auto &[name, feature] : features.items())
if (name != "Custom")
model_volume += feature["volume"].get<double>();
const nlohmann::json &objects = statistics["objects"];
REQUIRE(objects.size() == 2);
double objects_volume = 0.;
for (size_t i = 0; i < objects.size(); ++i) {
CHECK(objects[i]["name"] == model.objects.front()->name);
CHECK(objects[i]["instance"] == int(i));
CHECK(objects[i]["support_volume"].get<double>() == 0.);
objects_volume += objects[i]["volume"].get<double>();
}
CHECK_THAT(objects_volume, Catch::Matchers::WithinAbs(model_volume, 0.1));
}
TEST_CASE("Copies sliced as objects of their own keep their number in the model object", "[GCodeStatistics]")
{
// A copy of another scale slices as a print object of its own.
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
config.set_deserialize_strict({ { "skirt_loops", 0 }, { "brim_type", "no_brim" } });
Print print;
Model model;
Test::init_print({ Test::cube(20) }, print, model, config, nullptr, true, 2);
model.objects.front()->instances.back()->set_scaling_factor(Vec3d(0.5, 0.5, 0.5));
model.objects.front()->ensure_on_bed();
print.apply(model, config);
REQUIRE(print.objects().size() == 2);
GCodeProcessorResult result;
Test::gcode(print, &result);
const nlohmann::json objects = nlohmann::json::parse(gcode_statistics_json(result))["objects"];
std::set<int> instances;
for (const nlohmann::json &object : objects)
instances.insert(object["instance"].get<int>());
CHECK(instances == std::set<int>{ 0, 1 });
}
TEST_CASE("The statistics file takes the G-code's name with the statistics extension", "[GCodeStatistics]")
{
CHECK(gcode_statistics_path("plate_1.gcode") == "plate_1.statistics.json");
CHECK(gcode_statistics_path("out/Cube_PLA_1h.gcode") == "out/Cube_PLA_1h.statistics.json");
}
+49 -4
View File
@@ -7,6 +7,7 @@
#include "libslic3r/GCode/GCodeProcessor.hpp"
#include "libslic3r/MultiNozzleUtils.hpp"
#include "libslic3r/PrintConfig.hpp"
#include "libslic3r/Utils.hpp"
#include "test_utils.hpp"
@@ -21,6 +22,7 @@
#include <memory>
#include <sstream>
#include <string>
#include <utility>
#include <vector>
#include "libslic3r/Config.hpp"
@@ -52,12 +54,16 @@ FullPrintConfig make_config(double load_time, double unload_time, double tool_ch
return config;
}
void run_processor(GCodeProcessor& proc, const FullPrintConfig& config, const char* gcode)
void run_processor(GCodeProcessor& proc, const FullPrintConfig& config, const char* gcode, bool is_bbl_printer = true)
{
// reserved_tag() selects between two tag tables based on this shared static, and
// other tests in the binary mutate it -- pin it so our "; FEATURE:" role tags are
// parsed deterministically regardless of test execution order.
GCodeProcessor::s_IsBBLPrinter = true;
// other tests in the binary mutate it -- pin it so role tags are parsed
// deterministically regardless of test execution order.
const bool previous_is_bbl_printer = GCodeProcessor::s_IsBBLPrinter;
GCodeProcessor::s_IsBBLPrinter = is_bbl_printer;
const ScopeGuard restore_flag([previous_is_bbl_printer] {
GCodeProcessor::s_IsBBLPrinter = previous_is_bbl_printer;
});
ScopedTemporaryFile temp(".gcode");
{
std::ofstream os(temp.string());
@@ -99,6 +105,45 @@ double filament_change_delay(const GCodeProcessorResult& r)
} // namespace
TEST_CASE("G29 preparation time is added only once", "[GCodeTiming][Regression]")
{
const FullPrintConfig config = make_config(0.0, 0.0, 0.0);
auto estimate = [&config](bool is_bbl_printer, const char* commands) {
const auto mode = PrintEstimatedStatistics::ETimeMode::Normal;
std::string gcode = is_bbl_printer ? "; FEATURE: Custom\n" : ";TYPE:Custom\n";
gcode += "G1 X10 Y10 Z0.2 F600\nG1 X20 Y10 F600\n";
if (is_bbl_printer)
gcode += "M622 J1\n";
gcode += commands;
if (is_bbl_printer)
gcode += "M623\n";
gcode += "G1 X20 Y20 F600\nG1 X10 Y20 F600\n";
GCodeProcessor processor;
run_processor(processor, config, gcode.c_str(), is_bbl_printer);
return std::make_pair(processor.get_time(mode), processor.get_prepare_time(mode));
};
const auto [baseline_time, baseline_prepare] = estimate(false, "G4 S0\n");
const auto [one_time, one_prepare] = estimate(false, "G29\n");
const auto [repeated_time, repeated_prepare] = estimate(false,
"G29 P9 X10 Y-4 W32 H4\nG29 P1\nG29 P1 X0 Y0 W50 H20 C\nG29 P3.2\nG29 P3.13\nG29 A\n");
REQUIRE_THAT(one_time - baseline_time, WithinAbs(260.0, 1e-2));
REQUIRE_THAT(one_prepare - baseline_prepare, WithinAbs(260.0, 1e-2));
REQUIRE_THAT(repeated_time, WithinAbs(one_time, 1e-2));
REQUIRE_THAT(repeated_prepare, WithinAbs(one_prepare, 1e-2));
const auto [baseline_bbl_time, baseline_bbl_prepare] = estimate(true, "G4 S0\n");
const auto [one_bbl_time, one_bbl_prepare] = estimate(true, "G29\n");
const auto [repeated_bbl_time, repeated_bbl_prepare] = estimate(true, "G29\nG29\n");
REQUIRE_THAT(one_bbl_time - baseline_bbl_time, WithinAbs(260.0, 1e-2));
REQUIRE_THAT(one_bbl_prepare - baseline_bbl_prepare, WithinAbs(260.0, 1e-2));
REQUIRE_THAT(repeated_bbl_time, WithinAbs(one_bbl_time, 1e-2));
REQUIRE_THAT(repeated_bbl_prepare, WithinAbs(one_bbl_prepare, 1e-2));
}
TEST_CASE("Filament-change time is attributed to tool-change moves, not extrusion roles", "[GCodeTiming]")
{
// Relative extrusion (M83) so every "E5" is a real 5mm extrusion move rather
+55
View File
@@ -178,6 +178,61 @@ bool is_block_move(const GCodeProcessorResult::MoveVertex &move)
} // namespace
TEST_CASE("Loaded manual tool change comments switch subsequent moves when enabled", "[GCodeProcessor][Regression]")
{
const bool manual_filament_change = GENERATE(false, true);
CAPTURE(manual_filament_change);
const DynamicPrintConfig config = Test::multifilament_config(2, {
{ "gcode_flavor", "marlin" },
{ "filament_type", "PLA;PETG" },
{ "manual_filament_change", manual_filament_change },
});
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 = false;
ScopedTemporaryFile temp(".gcode");
{
std::ofstream out(temp.string());
REQUIRE(out.is_open());
out << "; generated by OrcaSlicer\n"
<< "G90\nM83\nT0\n"
<< "G1 X10 Y10 Z0.2 F3000\n"
<< "G1 X20 E1\n"
<< "; MANUAL_TOOL_CHANGE T1\n"
<< "G1 X30\nG1 X40 E1\nG1 X50\nG1 X60 E1\n";
// Let process_file load the embedded DynamicPrintConfig, as the standalone viewer does.
out << "; CONFIG_BLOCK_START\n";
for (const std::string &key : config.keys())
out << "; " << key << " = " << config.opt_serialize(key) << '\n';
out << "; CONFIG_BLOCK_END\n";
REQUIRE(out.good());
}
GCodeProcessor processor;
processor.process_file(temp.string());
const auto &result = processor.get_result();
REQUIRE(result.filaments_count == 2);
constexpr unsigned int tool_change_line = 7;
size_t before = 0;
size_t after = 0;
for (const auto &move : result.moves) {
if (!is_block_move(move))
continue;
CAPTURE(move.gcode_id, move.type);
if (move.gcode_id < tool_change_line) {
++before;
CHECK(move.extruder_id == 0);
} else if (move.gcode_id > tool_change_line) {
++after;
CHECK(move.extruder_id == (manual_filament_change ? 1 : 0));
}
}
REQUIRE(before > 0);
REQUIRE(after > 0);
}
TEST_CASE("Actual speed moves are inserted on their block's segment just before its move", "[GCodeProcessor]")
{
// 60 squares take several planner passes, which remap the blocks kept between passes.
+26
View File
@@ -1748,3 +1748,29 @@ TEST_CASE("Percent accelerations resolve against the option they are a percentag
REQUIRE(accelerations_by_role[role_name] == std::set<int>{ value });
}
}
TEST_CASE("Initial filament start G-code tolerates a list shorter than the filament count", "[GCodeWriter]")
{
// Two filaments but only one filament_start_gcode entry, and the only object prints on
// filament 2, so the BBL export starts on a filament past the end of the list.
DynamicPrintConfig config = multifilament_config(2);
config.set_key_value("filament_start_gcode", new ConfigOptionStrings({"; filament start"}));
Model model;
auto *obj = model.add_object();
obj->add_volume(cube(20));
obj->add_instance();
obj->config.set_key_value("extruder", new ConfigOptionInt(2));
Print print;
print.is_BBL_printer() = true;
arrange_objects_on_test_bed(model, config);
for (auto *mo : model.objects) {
mo->ensure_on_bed();
print.auto_assign_extruders(mo);
}
print.apply(model, config);
print.validate();
REQUIRE_NOTHROW(Slic3r::Test::gcode(print));
}
+4 -1
View File
@@ -223,7 +223,10 @@ DynamicPrintConfig multifilament_config(unsigned int filaments, std::initializer
// These are read by filament id during export but absent from filament_option_keys(),
// so set_num_filaments leaves them size 1; size them too, else out-of-range access.
const auto &defaults = FullPrintConfig::defaults();
for (const char *key : { "filament_type", "filament_vendor", "filament_start_gcode" })
for (const char *key : { "filament_type", "filament_vendor", "filament_start_gcode", "filament_change_length", "filament_change_length_nc",
"filament_ramming_travel_time", "filament_ramming_travel_time_nc",
"filament_ramming_volumetric_speed", "filament_ramming_volumetric_speed_nc",
"nozzle_temperature", "nozzle_temperature_initial_layer" })
static_cast<ConfigOptionVectorBase *>(config.option(key, true))->resize(filaments, defaults.option(key));
// flush_volumes_matrix must be sized filaments*filaments or export rejects it.
+75
View File
@@ -11,9 +11,15 @@
#include <catch2/catch_test_macros.hpp>
#include <catch2/catch_message.hpp>
#include <catch2/matchers/catch_matchers.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp>
#include "libslic3r/GCode/ToolOrdering.hpp"
#include "libslic3r/MultiNozzleUtils.hpp"
#include "libslic3r/Print.hpp"
#include "libslic3r/BoundingBox.hpp"
#include "libslic3r/ExPolygon.hpp"
#include "libslic3r/Layer.hpp"
#include "libslic3r/libslic3r.h"
#include "test_helpers.hpp"
@@ -333,3 +339,72 @@ TEST_CASE("Print::validate warns when a gradient mixed filament is used without
CHECK(count_opt(print, "enable_mixed_color_sublayer") == 0);
}
}
TEST_CASE("Filament shrinkage compensation compares the physical filaments of a mixed slot", "[MixedFilament][Print][Regression]")
{
// The mixed slot 3 blends filament 1 (90 %) and filament 2 (100 %); its own 90 % does not count.
DynamicPrintConfig config = mixed_config(false);
config.set_deserialize_strict({
{ "filament_shrink", "90%,100%,90%" },
{ "xy_contour_compensation", 0. },
{ "layer_change_gcode", "G92 E0\n" }, // validate() relative-E reset
});
Print print;
init_and_process_print({ cube(20.) }, print, config);
const PrintObject &object = *print.objects().front();
const BoundingBox extents = get_extents(object.get_layer(int(object.layer_count() / 2))->lslices);
CHECK_THAT(unscale<double>(extents.size().x()), Catch::Matchers::WithinAbs(20., 0.01));
std::vector<StringObjectException> warnings;
CHECK(print.validate(&warnings).string.empty());
CHECK(std::any_of(warnings.begin(), warnings.end(), [](const StringObjectException &w) {
return w.string.find("Filament shrinkage will not be used") != std::string::npos;
}));
}
TEST_CASE("Filament shrinkage compensation counts the wipe tower filament next to a mixed slot", "[MixedFilament][Print][Regression]")
{
// The object prints only with the mixed slot 4 of filaments 1 and 2 (both 90 %). The wipe tower filament 3 (100 %)
// may be flushed into the object, so the compensation is off.
DynamicPrintConfig config = multifilament_config(4);
config.set_deserialize_strict({
{"filament_is_mixed", "0,0,0,1"},
{"filament_mixed_components", ";;;1,2"},
{"filament_mixed_sublayer_ratios", ";;;0.5,0.5"},
{"filament_mixed_gradient", "0,0,0,0"},
{"filament_mixed_gradient_range", ";;;"},
{"filament_mixed_gradient_curve", ";;;"},
{"filament_mixed_gradient_per_part","0,0,0,0"},
{"outer_wall_filament_id", "4"},
{"inner_wall_filament_id", "4"},
{"sparse_infill_filament_id", "4"},
{"internal_solid_filament_id", "4"},
{"top_surface_filament_id", "4"},
{"bottom_surface_filament_id", "4"},
{"enable_prime_tower", "1"},
{"wipe_tower_filament", "3"},
{"prime_tower_width", "35"},
{"wipe_tower_x", "50"}, // inside the 200x200 test bed
{"wipe_tower_y", "50"},
{"filament_shrink", "90%,90%,100%,90%"},
{"xy_contour_compensation", "0"},
{"layer_change_gcode", "G92 E0\n"}, // validate() relative-E reset
});
// The object itself is on the mixed slot too, so no other filament is assigned.
const std::vector<std::vector<ConfigBase::SetDeserializeItem>> object_overrides { { { "extruder", 4 } } };
Print print;
Model model;
init_print({ cube(20.) }, print, model, config, &object_overrides);
print.process();
const PrintObject &object = *print.objects().front();
const BoundingBox extents = get_extents(object.get_layer(int(object.layer_count() / 2))->lslices);
CHECK_THAT(unscale<double>(extents.size().x()), Catch::Matchers::WithinAbs(20., 0.01));
std::vector<StringObjectException> warnings;
CHECK(print.validate(&warnings).string.empty());
CHECK(std::any_of(warnings.begin(), warnings.end(), [](const StringObjectException &w) {
return w.string.find("Filament shrinkage will not be used") != std::string::npos;
}));
}
+182
View File
@@ -315,6 +315,79 @@ TEST_CASE("Per-object wall filament override is honored", "[MultiFilament]")
CHECK(tools_for_role(gcode, "infill") == std::set<int>{ 0 }); // infill not overridden: stays on F1
}
// FanMover's guard against splitting a G1 inside custom gcode keys off the ";_FAN_MOVER_SKIP_START/END"
// markers GCode::set_extruder() must emit around change_filament_gcode's output. Direct
// toolchanges and Type2's wipe-tower toolchange (append_tcr2() calls set_extruder()) both go
// through it; Type1's wipe-tower toolchange (WipeTowerIntegration::append_tcr(), append_tcr2's only
// sibling caller of change_filament_gcode) needs the same bracketing of its own.
TEST_CASE("Toolchange gcode is bracketed against FanMover splitting its moves", "[MultiFilament]")
{
const std::string custom_gcode = "; fan full\nM106 P1 S255\nM400 S3\nG1 X77 F5000\nG1 X91 F3000\n";
// "direct": no prime tower, GCode::set_extruder() runs the toolchange. By-object sequencing
// with a per-object filament override is what makes one happen at all without a tower.
// "type1": a Type1 prime tower routes the toolchange through WipeTowerIntegration::append_tcr()
// instead (append_tcr2, Type2's equivalent, itself calls set_extruder() - so Type2 needs no
// separate case here).
const std::string path = GENERATE(as<std::string>{}, "direct", "type1");
CAPTURE(path);
std::string gcode_str;
if (path == "direct") {
const std::vector<std::vector<Slic3r::ConfigBase::SetDeserializeItem>> per_object_overrides = {
{}, { { "outer_wall_filament_id", "2" }, { "inner_wall_filament_id", "2" } }
};
DynamicPrintConfig config = multifilament_config(2, {
{ "skirt_loops", 0 },
{ "brim_type", "no_brim" },
{ "print_sequence", "by object" },
{ "change_filament_gcode", custom_gcode },
{ "fan_speedup_time", 0.5 },
{ "fan_kickstart", 0.1 },
});
gcode_str = slice_with_object_overrides({ cube(20), cube(20) }, config, per_object_overrides);
} else {
const std::vector<std::vector<Slic3r::ConfigBase::SetDeserializeItem>> per_object_overrides = {
{ { "extruder", "1" } }, { { "extruder", "2" } }
};
DynamicPrintConfig config = multifilament_config(2, {
{ "change_filament_gcode", custom_gcode },
{ "fan_speedup_time", 0.5 },
{ "fan_kickstart", 0.1 },
{ "enable_prime_tower", true },
{ "wipe_tower_type", "type1" },
{ "prime_tower_width", 35 },
{ "wipe_tower_x", 120 },
{ "wipe_tower_y", 120 },
{ "printable_area", "0x0,200x0,200x200,0x200" },
{ "skirt_loops", 0 },
{ "brim_type", "no_brim" },
});
gcode_str = slice_with_object_overrides({ cube(20), cube(20) }, config, per_object_overrides);
}
// The resolved-settings config footer echoes "change_filament_gcode = ..." with its value
// escaped, so drop it before counting or its own copy of "G1 X77 F5000" is miscounted.
const size_t footer_start = gcode_str.find("; CONFIG_BLOCK_START");
REQUIRE(footer_start != std::string::npos);
gcode_str.erase(footer_start);
auto count_occurrences = [](const std::string &haystack, const std::string &needle) {
size_t count = 0, pos = 0;
while ((pos = haystack.find(needle, pos)) != std::string::npos) {
++count;
pos += needle.size();
}
return count;
};
const size_t total_waypoints = count_occurrences(gcode_str, "G1 X77 F5000");
const size_t bracketed = count_occurrences(gcode_str, "M400 S3\nG1 X77 F5000");
REQUIRE(total_waypoints > 0);
CHECK(bracketed == total_waypoints);
// FanMover drops its own markers; only the start-of-print toolchange, which it never processes, keeps them.
CHECK(count_occurrences(gcode_str, ";_FAN_MOVER_SKIP_START") <= 1);
}
// With wait_for_temp_on_wipe_tower the blocking M109 moves from right after the Tn command to
// a stop point parked beside the wipe tower (heat-up drool falls next to the tower, not onto
// its top): tagged with _WAIT_FOR_TEMP_ON_WIPE_TOWER, after the toolchange and before the
@@ -1669,3 +1742,112 @@ TEST_CASE("Each filament cools with the fan speeds of its extruder variant", "[M
CHECK(gcode.find("; start range high " + std::to_string(range_high) + "\n") != std::string::npos);
}
}
TEST_CASE("Shared heater on IMEX printer stays hot until all filaments on it are done", "[MultiFilament]")
{
DynamicPrintConfig cfg = multifilament_config(7);
cfg.set_deserialize_strict({
{"ooze_prevention", 1},
{"preheat_time", 0},
{"single_extruder_multi_material", 0},
});
imex_7x4_printer(cfg);
const std::string gcode = slice_with_object_overrides({make_cube(20., 20., 10.), make_cube(20., 20., 4.), make_cube(20., 20., 10.)},
cfg,
{{{"extruder", 1}, {"layer_height", 0.30}},
{{"extruder", 2}, {"layer_height", 0.15}},
{{"extruder", 5}}});
bool premature_s0_t0 = false;
double current_z = 0.0;
GCodeReader reader;
reader.parse_buffer(gcode, [&](GCodeReader& /*self*/, const GCodeReader::GCodeLine& line) {
if (line.has_z())
current_z = line.z();
const std::string raw = line.raw();
if (raw.find("M104 S0 T0") != std::string::npos && raw.find(";cooldown") != std::string::npos) {
if (current_z < 9.0)
premature_s0_t0 = true;
}
});
// Filaments 1 and 2 share head 0, and filament 1 prints up to 10mm.
// T0 must not shut down at ~4mm when filament 2 finishes.
CHECK_FALSE(premature_s0_t0);
}
TEST_CASE("Unused extruder turns off after its final layer on multi-object print", "[MultiFilament]")
{
const std::string gcode = slice_with_object_overrides(
{ make_cube(20., 20., 10.), make_cube(20., 20., 4.) },
multifilament_config(2, {
{ "single_extruder_multi_material", 0 },
{ "ooze_prevention", 1 },
{ "standby_temperature_delta", -40 },
{ "nozzle_temperature", "240,240" },
}),
{
{ { "extruder", 1 }, { "layer_height", 0.20 } },
{ { "extruder", 2 }, { "layer_height", 0.15 } }
});
int t1_s0_cooldown_count = 0;
int t0_s0_cooldown_count = 0;
std::istringstream stream(gcode);
for (std::string line; std::getline(stream, line);) {
if (line.find(";cooldown") != std::string::npos) {
if (line.find("M104 S0 T1") != std::string::npos)
++t1_s0_cooldown_count;
if (line.find("M104 S0 T0") != std::string::npos)
++t0_s0_cooldown_count;
}
}
// T1 (extruder 2, 4mm cube) gets exactly one S0 after its last layer.
// T0 (extruder 1, 10mm cube) never gets S0 mid-print.
CHECK(t1_s0_cooldown_count == 1);
CHECK(t0_s0_cooldown_count == 0);
}
TEST_CASE("Unused extruder turns off on non-IMEX three-tool print", "[MultiFilament]")
{
const std::string gcode = slice_with_object_overrides(
{ make_cube(20., 20., 4.), make_cube(20., 20., 10.), make_cube(20., 20., 10.) },
multifilament_config(3, {
{ "single_extruder_multi_material", 0 },
{ "ooze_prevention", 1 },
{ "standby_temperature_delta", -40 },
{ "nozzle_temperature", "240,240,240" },
}),
{
{ { "extruder", 1 } },
{ { "extruder", 2 } },
{ { "extruder", 3 } }
});
bool premature_s0_t1 = false;
bool premature_s0_t2 = false;
double current_z = 0.0;
GCodeReader reader;
reader.parse_buffer(gcode, [&](GCodeReader& /*self*/, const GCodeReader::GCodeLine& line) {
if (line.has_z())
current_z = line.z();
const std::string raw = line.raw();
if (raw.find(";cooldown") != std::string::npos && current_z < 9.0) {
if (raw.find("M104 S0 T1") != std::string::npos)
premature_s0_t1 = true;
if (raw.find("M104 S0 T2") != std::string::npos)
premature_s0_t2 = true;
}
});
// T1 and T2 print up to 10mm — neither should get S0 before that.
CHECK_FALSE(premature_s0_t1);
CHECK_FALSE(premature_s0_t2);
}
+382
View File
@@ -22,6 +22,7 @@
#include "libslic3r/ClipperUtils.hpp"
#include "libslic3r/ExPolygon.hpp"
#include "libslic3r/Point.hpp"
#include "libslic3r/Polygon.hpp"
#include "libslic3r/Polyline.hpp"
#include <limits>
#include <string>
@@ -994,3 +995,384 @@ TEST_CASE("A hole whose outer wall touches the contour's runs with it", "[Perime
CHECK(layers[i].holes_ccw.front() == ccw);
}
}
namespace {
// The outer radius of round_tube().
const double tube_radius = 11.5;
// Every setting the tube walls below are measured against. With 0.5mm walls a 1mm tube wall holds only the two
// outer walls, touching, and every further 0.5mm makes room for one more inner wall.
DynamicPrintConfig tube_config(const char *wall_generator, int wall_loops)
{
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
config.set_deserialize_strict({
{ "wall_generator", wall_generator },
{ "wall_loops", wall_loops },
{ "wall_sequence", "inner wall/outer wall" },
{ "wall_direction", "ccw" },
{ "layer_height", 0.2 },
{ "initial_layer_print_height", 0.2 },
{ "nozzle_diameter", "0.4" },
{ "outer_wall_line_width", 0.5 },
{ "inner_wall_line_width", 0.5 },
{ "precise_outer_wall", true },
{ "detect_thin_wall", false },
{ "only_one_wall_top", false },
{ "only_one_wall_first_layer", false },
{ "brim_type", "no_brim" },
{ "detect_overhang_wall", true },
{ "overhang_reverse", false },
{ "overhang_reverse_threshold", "0%" },
});
return config;
}
// A 2mm tall round tube whose wall is `wall` mm thick.
Print &round_tube(Print &print, Model &model, const DynamicPrintConfig &config, double wall)
{
ModelObject *object = model.add_object();
object->name = "round_tube.stl";
object->add_volume(make_cylinder(tube_radius, 2.), ModelVolumeType::MODEL_PART, false);
object->add_volume(make_cylinder(tube_radius - wall, 2.), ModelVolumeType::NEGATIVE_VOLUME, false);
object->add_instance();
object->ensure_on_bed();
print.auto_assign_extruders(object);
print.apply(model, config);
print.validate();
print.set_status_silent();
print.process();
return print;
}
// The side of the tube wall a wall loop lies on. The centre is the middle of the tube wall, where Arachne lays the
// inner wall both sides share when the tube wall is too narrow for a pair of them.
enum class TubeSide { Contour, Hole, Centre };
struct TubeLoop {
TubeSide side;
int inset;
bool ccw;
};
// The wall loops of every layer of round_tube() in extrusion order.
std::vector<std::vector<TubeLoop>> tube_loops(const Print &print, double wall)
{
const double centre = tube_radius - 0.5 * wall;
std::vector<std::vector<TubeLoop>> out;
for (const Layer *layer : print.objects().front()->layers()) {
const Vec2d axis = get_extents(layer->lslices).center().cast<double>();
std::vector<TubeLoop> &loops = out.emplace_back();
for (const LayerRegion *region : layer->regions())
for (const ExtrusionEntity *entity : region->perimeters.flatten().entities) {
if (! entity->is_loop())
continue;
const ExtrusionLoop *loop = static_cast<const ExtrusionLoop*>(entity);
const Polygon polygon = loop->polygon();
double radius = 0.;
for (const Point &p : polygon.points)
radius += unscaled<double>((p.cast<double>() - axis).norm());
radius /= double(polygon.points.size());
const TubeSide side = radius > centre + 0.1 ? TubeSide::Contour : radius < centre - 0.1 ? TubeSide::Hole : TubeSide::Centre;
loops.push_back({ side, loop->inset_idx, polygon.is_counter_clockwise() });
}
}
return out;
}
// The insets one side of a wall is printed in, given its deepest inset: inside out for Inner/Outer, outside in for
// Outer/Inner, and for Inner/Outer/Inner the third wall onwards inside out, then the outer wall, then the first inner
// wall. Inner/Outer/Inner prints its first layer Inner/Outer, and outside in when there is no third wall.
std::vector<int> wall_sequence_insets(const std::string &wall_sequence, bool first_layer, int deepest)
{
std::vector<int> insets;
if (wall_sequence == "inner wall/outer wall" || (wall_sequence == "inner-outer-inner wall" && first_layer)) {
for (int inset = deepest; inset >= 0; -- inset)
insets.push_back(inset);
} else if (wall_sequence == "outer wall/inner wall" || deepest < 2) {
for (int inset = 0; inset <= deepest; ++ inset)
insets.push_back(inset);
} else {
for (int inset = deepest; inset >= 2; -- inset)
insets.push_back(inset);
insets.push_back(0);
insets.push_back(1);
}
return insets;
}
// Whether a wall of round_tube() runs with the contour: all but the walls of the hole, and those too when the hole
// is a thin wall hole, its outer wall touching the contour's with nothing between them.
bool runs_with_contour(const TubeLoop &loop, bool thin_wall_hole)
{
return loop.side != TubeSide::Hole || thin_wall_hole;
}
// The insets of the walls on one side of a round_tube() layer, the shared centre line included, in extrusion order.
std::vector<int> side_insets(const std::vector<TubeLoop> &loops, TubeSide side)
{
std::vector<int> insets;
for (const TubeLoop &loop : loops)
if (loop.side == side || loop.side == TubeSide::Centre)
insets.push_back(loop.inset);
return insets;
}
// The walls of a round_tube() layer in extrusion order, as side and inset: "c0 c1 h0 h1" is the contour's outer wall,
// then its first inner wall, then the same for the hole, and "m" marks the wall in the middle both sides share.
std::string wall_order(const std::vector<TubeLoop> &loops)
{
std::string out;
for (const TubeLoop &loop : loops)
out += std::string(out.empty() ? "" : " ") + (loop.side == TubeSide::Contour ? "c" : loop.side == TubeSide::Hole ? "h" : "m") +
std::to_string(loop.inset);
return out;
}
} // namespace
// Each side of a tube wall prints its walls in the order the wall sequence asks for, from two walls a side up to
// four. The inner wall Arachne lays in the middle of a wall too narrow for a pair of them is an inner wall of both
// sides; Classic fills that middle with gap fill instead, so it has one wall less a side there.
TEST_CASE("Each side of a tube wall prints its walls in the wall sequence order", "[Perimeters]")
{
struct TubeWall {
double wall;
int wall_loops;
// Walls a side, a shared inner wall included.
int arachne_walls;
int classic_walls;
};
const TubeWall tube = GENERATE(values<TubeWall>({
{ 1.5, 3, 2, 1 }, { 2.0, 2, 2, 2 }, { 2.5, 3, 3, 2 }, { 3.0, 3, 3, 3 }, { 4.0, 4, 4, 4 } }));
const char *wall_generator = GENERATE("classic", "arachne");
const std::string wall_sequence = GENERATE("inner wall/outer wall", "outer wall/inner wall", "inner-outer-inner wall");
CAPTURE(tube.wall, tube.wall_loops, wall_generator, wall_sequence);
DynamicPrintConfig config = tube_config(wall_generator, tube.wall_loops);
config.set_deserialize_strict("wall_sequence", wall_sequence);
Print print;
Model model;
round_tube(print, model, config, tube.wall);
const std::vector<std::vector<TubeLoop>> layers = tube_loops(print, tube.wall);
// 2mm at 0.2mm layers.
REQUIRE(layers.size() == 10);
const int walls = std::string(wall_generator) == "arachne" ? tube.arachne_walls : tube.classic_walls;
for (size_t i = 0; i < layers.size(); ++ i) {
for (const TubeSide side : { TubeSide::Contour, TubeSide::Hole }) {
const bool contour_side = side == TubeSide::Contour;
CAPTURE(i, contour_side);
const std::vector<int> insets = side_insets(layers[i], side);
REQUIRE(int(insets.size()) == walls);
CHECK(insets == wall_sequence_insets(wall_sequence, i == 0, walls - 1));
}
}
}
// The outer wall of a thin wall hole touches the contour's with nothing between them. The hole is printed first, so
// the contour leans on it, unless the wall sequence puts the outer walls first.
TEST_CASE("A thin wall hole is printed before the contour unless the outer walls go first", "[Perimeters]")
{
const char *wall_generator = GENERATE("classic", "arachne");
const std::string wall_sequence = GENERATE("inner wall/outer wall", "outer wall/inner wall", "inner-outer-inner wall");
CAPTURE(wall_generator, wall_sequence);
DynamicPrintConfig config = tube_config(wall_generator, 3);
config.set_deserialize_strict("wall_sequence", wall_sequence);
Print print;
Model model;
round_tube(print, model, config, 1.);
const std::vector<std::vector<TubeLoop>> layers = tube_loops(print, 1.);
REQUIRE(layers.size() == 10);
const TubeSide first = wall_sequence == "outer wall/inner wall" ? TubeSide::Contour : TubeSide::Hole;
for (size_t i = 0; i < layers.size(); ++ i) {
CAPTURE(i);
REQUIRE(layers[i].size() == 2);
REQUIRE(layers[i][0].side != layers[i][1].side);
CHECK(layers[i].front().side == first);
}
}
// A 1.5mm tube wall leaves Inner/Outer/Inner no third wall to sandwich, only the inner wall Arachne lays in the middle
// for both sides, or Classic's gap fill. Past the first layer it prints the hole first, then the contour, then the
// middle.
TEST_CASE("Inner/Outer/Inner prints the hole of a narrow tube wall first and the middle last", "[Perimeters]")
{
const char *wall_generator = GENERATE("classic", "arachne");
CAPTURE(wall_generator);
DynamicPrintConfig config = tube_config(wall_generator, 3);
config.set_deserialize_strict("wall_sequence", "inner-outer-inner wall");
Print print;
Model model;
round_tube(print, model, config, 1.5);
const std::vector<std::vector<TubeLoop>> layers = tube_loops(print, 1.5);
REQUIRE(layers.size() == 10);
// Classic's gap fill is not a wall loop, and goes after the walls.
const std::string expected = std::string(wall_generator) == "arachne" ? "h0 c0 m1" : "h0 c0";
for (size_t i = 1; i < layers.size(); ++ i) {
CAPTURE(i);
CHECK(wall_order(layers[i]) == expected);
}
}
// The contour's walls run in the wall direction and the hole's against it, except that the walls of a thin wall
// hole run with the contour, as does the inner wall Arachne shares between both sides of a 1.5mm tube wall.
TEST_CASE("Tube walls run in the wall direction and hole walls against it", "[Perimeters]")
{
const double wall = GENERATE(1.0, 1.5, 2.0, 3.0);
const char *wall_generator = GENERATE("classic", "arachne");
const std::string wall_direction = GENERATE("ccw", "cw");
CAPTURE(wall, wall_generator, wall_direction);
DynamicPrintConfig config = tube_config(wall_generator, 3);
config.set_deserialize_strict("wall_direction", wall_direction);
Print print;
Model model;
round_tube(print, model, config, wall);
const std::vector<std::vector<TubeLoop>> layers = tube_loops(print, wall);
REQUIRE(layers.size() == 10);
const bool thin_wall_hole = wall == 1.;
for (size_t i = 0; i < layers.size(); ++ i) {
CAPTURE(i);
REQUIRE(std::any_of(layers[i].begin(), layers[i].end(), [](const TubeLoop &loop) { return loop.side == TubeSide::Contour; }));
REQUIRE(std::any_of(layers[i].begin(), layers[i].end(), [](const TubeLoop &loop) { return loop.side == TubeSide::Hole; }));
for (const TubeLoop &loop : layers[i]) {
CAPTURE(int(loop.side), loop.inset);
CHECK(loop.ccw == (runs_with_contour(loop, thin_wall_hole) == (wall_direction == "ccw")));
}
}
}
// Reverse on even with no threshold reverses every wall of every other layer, or only the inner walls when it is
// restricted to them, and leaves the order of the walls alone.
TEST_CASE("Reverse on even reverses the tube walls of every other layer", "[Perimeters]")
{
const double wall = GENERATE(1.0, 1.5, 3.0);
const char *wall_generator = GENERATE("classic", "arachne");
const bool reverse_internal_only = GENERATE(false, true);
CAPTURE(wall, wall_generator, reverse_internal_only);
auto slice = [&](bool overhang_reverse) {
DynamicPrintConfig config = tube_config(wall_generator, 3);
config.set_deserialize_strict({
{ "overhang_reverse", overhang_reverse },
{ "overhang_reverse_internal_only", reverse_internal_only },
});
Print print;
Model model;
round_tube(print, model, config, wall);
return tube_loops(print, wall);
};
const std::vector<std::vector<TubeLoop>> plain = slice(false);
const std::vector<std::vector<TubeLoop>> reversed = slice(true);
REQUIRE(plain.size() == 10);
REQUIRE(reversed.size() == plain.size());
for (size_t i = 0; i < plain.size(); ++ i) {
CAPTURE(i);
REQUIRE(reversed[i].size() == plain[i].size());
for (size_t j = 0; j < plain[i].size(); ++ j) {
CAPTURE(j);
CHECK(reversed[i][j].side == plain[i][j].side);
CHECK(reversed[i][j].inset == plain[i][j].inset);
const bool flips = i % 2 == 1 && (! reverse_internal_only || plain[i][j].inset > 0);
CHECK(reversed[i][j].ccw == (plain[i][j].ccw != flips));
}
}
}
namespace {
DynamicPrintConfig brim_tube_config(const char *wall_generator, const std::string &wall_sequence, const char *brim_type, double brim_width)
{
DynamicPrintConfig config = tube_config(wall_generator, 3);
config.set_deserialize_strict({
{ "wall_sequence", wall_sequence },
{ "brim_type", brim_type },
{ "brim_width", brim_width },
});
return config;
}
} // namespace
// A brim against the outer walls makes the first layer print them first, whatever the wall sequence, so the brim runs
// straight on into the outer wall: each side of the tube wall outside in, and the contour before a thin wall hole.
// Brims that may leave the outer walls bare keep the wall sequence, and so do the layers above.
TEST_CASE("A brim against the outer walls makes the first layer print them first", "[Perimeters]")
{
struct Brim {
const char *type;
double width;
bool outer_walls_first;
};
const Brim brim = GENERATE(values<Brim>({
{ "no_brim", 5., false }, { "inner_only", 5., false }, { "auto_brim", 5., false }, { "outer_only", 0., false },
{ "outer_only", 5., true }, { "outer_and_inner", 5., true }, { "leading_edge_only", 5., true } }));
const double wall = GENERATE(1.0, 3.0);
const char *wall_generator = GENERATE("classic", "arachne");
const std::string wall_sequence = GENERATE("inner wall/outer wall", "inner-outer-inner wall");
CAPTURE(brim.type, brim.width, wall, wall_generator, wall_sequence);
Print print;
Model model;
round_tube(print, model, brim_tube_config(wall_generator, wall_sequence, brim.type, brim.width), wall);
const std::vector<std::vector<TubeLoop>> layers = tube_loops(print, wall);
REQUIRE(layers.size() == 10);
// The thin wall hole has its outer wall a side, the 3mm tube wall three walls a side.
const size_t walls = wall == 1. ? 1 : 3;
for (const size_t i : { size_t(0), size_t(1) }) {
const bool outer_walls_first = brim.outer_walls_first && i == 0;
for (const TubeSide side : { TubeSide::Contour, TubeSide::Hole }) {
const bool contour_side = side == TubeSide::Contour;
CAPTURE(i, contour_side);
const std::vector<int> insets = side_insets(layers[i], side);
REQUIRE(insets.size() == walls);
CHECK(insets == wall_sequence_insets(outer_walls_first ? "outer wall/inner wall" : wall_sequence, i == 0, int(walls) - 1));
}
if (wall == 1.)
CHECK(layers[i].front().side == (outer_walls_first ? TubeSide::Contour : TubeSide::Hole));
}
}
// Changing the brim of a sliced print regenerates the walls when the first layer's wall order changes with it, so
// the print gets the walls a slice from scratch with the new brim gets.
TEST_CASE("Changing the brim updates the wall order of the first layer", "[Perimeters]")
{
struct BrimChange {
const char *from_type;
double from_width;
const char *to_type;
double to_width;
};
const BrimChange change = GENERATE(values<BrimChange>({
{ "outer_only", 0., "outer_only", 5. }, { "outer_only", 5., "outer_only", 0. },
{ "no_brim", 5., "outer_and_inner", 5. }, { "outer_and_inner", 5., "inner_only", 5. } }));
const char *wall_generator = GENERATE("classic", "arachne");
CAPTURE(change.from_type, change.from_width, change.to_type, change.to_width, wall_generator);
const std::string wall_sequence = "inner wall/outer wall";
Print print;
Model model;
round_tube(print, model, brim_tube_config(wall_generator, wall_sequence, change.from_type, change.from_width), 3.);
const std::string before = wall_order(tube_loops(print, 3.).front());
print.apply(model, brim_tube_config(wall_generator, wall_sequence, change.to_type, change.to_width));
print.process();
const std::string after = wall_order(tube_loops(print, 3.).front());
Print fresh_print;
Model fresh_model;
round_tube(fresh_print, fresh_model, brim_tube_config(wall_generator, wall_sequence, change.to_type, change.to_width), 3.);
const std::string fresh = wall_order(tube_loops(fresh_print, 3.).front());
// The change does turn the first layer around.
REQUIRE(before != fresh);
CHECK(after == fresh);
}
+111 -33
View File
@@ -105,7 +105,8 @@ void require_vertex(const Polygon &polygon, const Point &point)
std::vector<SeamPlacerImpl::EnforcedBlockedSeamPoint> weak_candidate_types(
const Polygon &polygon, const std::vector<PreciseSeam::WeakModifierSegment> &segments,
const std::function<SeamPlacerImpl::EnforcedBlockedSeamPoint(const Point &)> &painted = {})
const std::function<SeamPlacerImpl::EnforcedBlockedSeamPoint(const Point &)> &painted = {},
PreciseSeam::PreciseSeamWarnings *warnings = nullptr)
{
// Use the same coordinate conversion as production when applying prepared boundaries.
// Optional painted types stand in for seam painting, which production assigns before weak zones.
@@ -120,7 +121,7 @@ std::vector<SeamPlacerImpl::EnforcedBlockedSeamPoint> weak_candidate_types(
}
loop.end_index = candidates.points.size();
bool enforced = false;
PreciseSeam::apply_weak_modifiers_to_perimeter(segments, candidates, loop, enforced);
PreciseSeam::apply_weak_modifiers_to_perimeter(segments, candidates, loop, enforced, warnings);
std::vector<SeamPlacerImpl::EnforcedBlockedSeamPoint> types;
for (const auto &candidate : candidates.points)
types.push_back(candidate.type);
@@ -664,7 +665,7 @@ TEST_CASE("Weak priority and enforcement refinement apply on both crossed sides"
check_square_boundary(perimeter);
}
TEST_CASE("Weak full containment follows painting: Enforced and Neutral type the whole perimeter, Blocked is skipped", "[PreciseSeam]")
TEST_CASE("Weak full containment follows painting: the whole perimeter takes the zone's type", "[PreciseSeam]")
{
const auto type = GENERATE(ModelVolumeType::PRECISE_SEAM_ENFORCED, ModelVolumeType::PRECISE_SEAM_BLOCKED,
ModelVolumeType::PRECISE_SEAM_NEUTRAL);
@@ -674,6 +675,8 @@ TEST_CASE("Weak full containment follows painting: Enforced and Neutral type the
const Points original = perimeter.points;
const auto *modifier = fixture.add(type, {rectangle(-2, -2, 22, 22)});
PreciseSeam::PreciseSeamWarnings warnings;
// Registered as SeamPlacer::init() does, so the per-modifier warning flag is tracked.
warnings.modifier_usage.try_emplace(modifier);
const auto segments = PreciseSeam::collect_weak_modifier_segments(
{modifier}, perimeter, PreciseSeam::PreparedPerimeter(perimeter), fixture.layer, fixture.cache, &warnings);
// Painting of both kinds, so that each type's effect on it is visible.
@@ -681,23 +684,17 @@ TEST_CASE("Weak full containment follows painting: Enforced and Neutral type the
return p.x() < scale_(10.) ? SeamPlacerImpl::EnforcedBlockedSeamPoint::Enforced :
SeamPlacerImpl::EnforcedBlockedSeamPoint::Blocked;
};
const auto types = weak_candidate_types(perimeter, segments, painted);
const auto types = weak_candidate_types(perimeter, segments, painted, &warnings);
CHECK_FALSE(warnings.multiple_intersections.load());
CHECK(warnings.failed_fragments.load() == 0);
if (type == ModelVolumeType::PRECISE_SEAM_BLOCKED) {
// Forbidding the seam all round cannot be honoured: skipped with a warning, painting stays.
CHECK(segments.empty());
CHECK(perimeter.points == original);
CHECK(warnings.full_containment.load() == PreciseSeam::PreciseSeamWarnings::type_bit(type));
for (size_t i = 0; i < perimeter.size(); ++i)
CHECK(types[i] == painted(perimeter[i]));
return;
}
REQUIRE(segments.size() == 1);
CHECK(segments.front().whole_perimeter);
CHECK(warnings.full_containment.load() == 0);
// Only a whole Blocked perimeter is reported, as the seam cannot avoid it.
CHECK(warnings.modifier_usage.at(modifier).fully_blocked.load() == (type == ModelVolumeType::PRECISE_SEAM_BLOCKED));
// Every candidate takes the zone's type, overriding painting.
const auto expected = type == ModelVolumeType::PRECISE_SEAM_ENFORCED ? SeamPlacerImpl::EnforcedBlockedSeamPoint::Enforced :
type == ModelVolumeType::PRECISE_SEAM_BLOCKED ? SeamPlacerImpl::EnforcedBlockedSeamPoint::Blocked :
SeamPlacerImpl::EnforcedBlockedSeamPoint::Neutral;
for (size_t i = 0; i < perimeter.size(); ++i)
CHECK(types[i] == expected);
@@ -706,29 +703,37 @@ TEST_CASE("Weak full containment follows painting: Enforced and Neutral type the
CHECK(perimeter.size() >= size_t(80.f / SeamPlacer::enforcer_oversampling_distance));
check_square_boundary(perimeter);
} else
CHECK(perimeter.points == original); // Like an unmarked perimeter: no subdivision.
CHECK(perimeter.points == original); // Like a perimeter unmarked or painted red all round: no subdivision.
}
TEST_CASE("Whole-perimeter weak zones take part in the usual priority order", "[PreciseSeam]")
{
// Volumes are listed low priority first; a later zone overwrites an earlier one.
const int scenario = GENERATE(0, 1, 2);
const int scenario = GENERATE(0, 1, 2, 3, 4, 5, 6);
CAPTURE(scenario);
SeamFixture fixture;
const auto *whole_enforced = fixture.add(ModelVolumeType::PRECISE_SEAM_ENFORCED, {rectangle(-2, -2, 22, 22)});
const auto *whole_neutral = fixture.add(ModelVolumeType::PRECISE_SEAM_NEUTRAL, {rectangle(-2, -2, 22, 22)});
const auto *whole_blocked = fixture.add(ModelVolumeType::PRECISE_SEAM_BLOCKED, {rectangle(-2, -2, 22, 22)});
const auto *lower_whole_blocked = fixture.add(ModelVolumeType::PRECISE_SEAM_BLOCKED, {rectangle(-2, -2, 22, 22)});
const auto *band_blocked = fixture.add(ModelVolumeType::PRECISE_SEAM_BLOCKED, {rectangle(8, -2, 12, 2)});
const auto *band_enforced = fixture.add(ModelVolumeType::PRECISE_SEAM_ENFORCED, {rectangle(8, -2, 12, 2)});
std::vector<const ModelVolume*> volumes;
if (scenario == 0) volumes = {whole_enforced, band_blocked}; // A band forbidden inside a green perimeter.
if (scenario == 1) volumes = {band_enforced, whole_neutral}; // A whole Neutral clears the lower band.
if (scenario == 2) volumes = {band_enforced, whole_blocked}; // A whole Blocked is skipped; the band stays.
if (scenario == 2) volumes = {band_enforced, whole_blocked}; // A whole Blocked overrides the lower band.
if (scenario == 3) volumes = {whole_blocked, band_enforced}; // A higher band unblocks part of a whole Blocked.
if (scenario == 4) volumes = {whole_blocked, band_blocked}; // A higher Blocked band keeps it whole Blocked.
if (scenario == 5) volumes = {lower_whole_blocked, whole_enforced, whole_blocked}; // The lower one is overwritten.
if (scenario == 6) volumes = {lower_whole_blocked, whole_blocked}; // Only the higher one is in effect.
Polygon perimeter = rectangle(0, 0, 20, 20);
PreciseSeam::PreciseSeamWarnings warnings;
// Registered as SeamPlacer::init() does, so the per-modifier warning flag is tracked.
warnings.modifier_usage.try_emplace(whole_blocked);
warnings.modifier_usage.try_emplace(lower_whole_blocked);
const auto segments = PreciseSeam::collect_weak_modifier_segments(
volumes, perimeter, PreciseSeam::PreparedPerimeter(perimeter), fixture.layer, fixture.cache, &warnings);
const auto types = weak_candidate_types(perimeter, segments);
const auto types = weak_candidate_types(perimeter, segments, {}, &warnings);
using Type = SeamPlacerImpl::EnforcedBlockedSeamPoint;
for (size_t i = 0; i < perimeter.size(); ++i) {
const Point &p = perimeter[i];
@@ -736,14 +741,38 @@ TEST_CASE("Whole-perimeter weak zones take part in the usual priority order", "[
const bool in_band = p.y() == 0 && p.x() >= scale_(8.) && p.x() <= scale_(12.);
const Type expected = scenario == 0 ? (in_band ? Type::Blocked : Type::Enforced) :
scenario == 1 ? Type::Neutral :
(in_band ? Type::Enforced : Type::Neutral);
scenario == 3 ? (in_band ? Type::Enforced : Type::Blocked) :
Type::Blocked;
CHECK(types[i] == expected);
}
CHECK(warnings.full_containment.load() ==
(scenario == 2 ? PreciseSeam::PreciseSeamWarnings::type_bit(ModelVolumeType::PRECISE_SEAM_BLOCKED) : 0u));
CHECK(warnings.full_containment.load() == 0);
// The warning follows the final typing: only a perimeter left whole Blocked is reported, through the
// highest whole zone.
const bool expected_fully_blocked = scenario == 2 || scenario == 4 || scenario == 5 || scenario == 6;
CHECK(warnings.modifier_usage.at(whole_blocked).fully_blocked.load() == expected_fully_blocked);
CHECK_FALSE(warnings.modifier_usage.at(lower_whole_blocked).fully_blocked.load());
check_square_boundary(perimeter);
}
TEST_CASE("A partial Blocked zone on a perimeter painted red all round gives no whole-perimeter warning", "[PreciseSeam]")
{
SeamFixture fixture;
const auto *band_blocked = fixture.add(ModelVolumeType::PRECISE_SEAM_BLOCKED, {rectangle(8, -2, 12, 2)});
Polygon perimeter = rectangle(0, 0, 20, 20);
PreciseSeam::PreciseSeamWarnings warnings;
warnings.modifier_usage.try_emplace(band_blocked);
const auto segments = PreciseSeam::collect_weak_modifier_segments(
{band_blocked}, perimeter, PreciseSeam::PreparedPerimeter(perimeter), fixture.layer, fixture.cache, &warnings);
REQUIRE(segments.size() == 1);
// Red painting makes every candidate Blocked, so the check reaches the whole-zone filter.
const auto painted = [](const Point &) { return SeamPlacerImpl::EnforcedBlockedSeamPoint::Blocked; };
const auto types = weak_candidate_types(perimeter, segments, painted, &warnings);
for (const auto type : types)
CHECK(type == SeamPlacerImpl::EnforcedBlockedSeamPoint::Blocked);
// Only a modifier that covers the whole perimeter is reported.
CHECK_FALSE(warnings.modifier_usage.at(band_blocked).fully_blocked.load());
}
TEST_CASE("Coverage short by a gap under 1 um is full containment while a narrow band keeps its point", "[PreciseSeam][Regression]")
{
// A tip pokes through the bottom edge at x = 10 mm by `depth` nm, so the perimeter line crosses it
@@ -829,19 +858,15 @@ TEST_CASE("A modifier touching the perimeter at one point keeps the full contain
else {
const auto segments = PreciseSeam::collect_weak_modifier_segments(
{modifier}, perimeter, PreciseSeam::PreparedPerimeter(perimeter), fixture.layer, fixture.cache, &warnings);
// Enforced and Neutral type the whole perimeter; Blocked is skipped.
if (type == ModelVolumeType::PRECISE_SEAM_BLOCKED)
CHECK(segments.empty());
else {
REQUIRE(segments.size() == 1);
CHECK(segments.front().whole_perimeter);
}
// A weak modifier gives its type to the whole perimeter.
REQUIRE(segments.size() == 1);
CHECK(segments.front().whole_perimeter);
}
// Only a whole-perimeter Enforced zone subdivides the edges.
if (type != ModelVolumeType::PRECISE_SEAM_ENFORCED)
CHECK(perimeter.points == original);
const bool skipped = is_precise_seam_strong(type) || type == ModelVolumeType::PRECISE_SEAM_BLOCKED;
CHECK(warnings.full_containment.load() == (skipped ? PreciseSeam::PreciseSeamWarnings::type_bit(type) : 0u));
// Only strong types are skipped with the full-containment warning.
CHECK(warnings.full_containment.load() == (is_precise_seam_strong(type) ? PreciseSeam::PreciseSeamWarnings::type_bit(type) : 0u));
CHECK_FALSE(warnings.multiple_intersections.load());
CHECK(warnings.failed_fragments.load() == 0);
}
@@ -1176,7 +1201,7 @@ TEST_CASE("Modifier usage marks evaluated modifiers that never reach a perimeter
CHECK(checked(short_weak));
CHECK_FALSE(reached(short_weak));
CHECK(reached(crossing_weak));
// Full containment reached the perimeter even though it is skipped.
// Full containment counts as reaching the perimeter.
CHECK(reached(containing_weak));
// Strong: evaluation stops at the first modifier with a segment. The one ending 0.5 mm before the
@@ -1393,6 +1418,24 @@ TEST_CASE("Weak modifiers move the exported seam into or out of their zone", "[P
CHECK(fixture.warning().empty());
}
TEST_CASE("A higher-priority Enforced band suppresses the whole-perimeter Blocked export warning", "[PreciseSeam][Regression]")
{
ExportFixture fixture("back");
// The earlier model volume has higher priority; weak application reverses this order.
fixture.add(ModelVolumeType::PRECISE_SEAM_ENFORCED, make_cube(2, 6, 4), Vec3d(4, -3, -1), "front enforcer");
fixture.add(ModelVolumeType::PRECISE_SEAM_BLOCKED, make_cube(30, 30, 4), Vec3d(-5, -5, -1), "whole blocker");
const auto loops = outer_wall_loops(fixture.export_gcode());
REQUIRE(loops.size() == 10);
for (const OuterLoop &loop : loops) {
CAPTURE(loop.seam.x(), loop.seam.y());
// The band wins on every layer, so the empty warning is not from modifiers that did nothing.
CHECK_THAT(loop.seam.y(), Catch::Matchers::WithinAbs(loop.bounds.min.y() - loop.bounds.center().y(), 0.01));
CHECK(loop.seam.x() >= -6.01);
CHECK(loop.seam.x() <= -3.99);
}
CHECK(fixture.warning().empty());
}
TEST_CASE("A strong modifier with a hole uses the left of two equal crossings", "[PreciseSeam]")
{
// The hole splits the front face crossing into x = 2..4 and x = 8..10 mm. The lengths tie, both
@@ -1422,14 +1465,49 @@ TEST_CASE("Full containment warns for Blocked but not for Enforced", "[PreciseSe
{
const auto type = GENERATE(ModelVolumeType::PRECISE_SEAM_ENFORCED, ModelVolumeType::PRECISE_SEAM_BLOCKED);
ExportFixture fixture("back");
fixture.add(type, make_cube(30, 30, 4), Vec3d(-5, -5, -1));
fixture.export_gcode();
fixture.add(type, make_cube(30, 30, 4), Vec3d(-5, -5, -1), "whole helper");
const auto loops = outer_wall_loops(fixture.export_gcode());
// The whole perimeter takes the type either way, so the seam still follows the back preference.
REQUIRE(loops.size() == 10);
for (const OuterLoop &loop : loops)
CHECK(loop.seam.y() > 7.);
if (type == ModelVolumeType::PRECISE_SEAM_BLOCKED)
CHECK(fixture.warning().find("Seam Blocked") != std::string::npos);
CHECK(fixture.warning().find("Seam Blocked modifier \"whole helper\" of") != std::string::npos);
else
CHECK(fixture.warning().empty());
}
TEST_CASE("A perimeter blocked all round gets the seam of an unmarked one", "[PreciseSeam][Regression]")
{
// Equal Blocked candidates compare like Neutral ones, so random and aligned seams still vary and align.
const std::string seam_position = GENERATE(as<std::string>{}, "random", "aligned", "aligned_back", "back", "nearest");
CAPTURE(seam_position);
ExportFixture unmarked(seam_position);
const auto expected = outer_wall_loops(unmarked.export_gcode());
ExportFixture blocked(seam_position);
blocked.add(ModelVolumeType::PRECISE_SEAM_BLOCKED, make_cube(30, 30, 4), Vec3d(-5, -5, -1));
const auto loops = outer_wall_loops(blocked.export_gcode());
REQUIRE(loops.size() == expected.size());
for (size_t i = 0; i < loops.size(); ++i) {
CAPTURE(i, loops[i].seam.x(), loops[i].seam.y(), expected[i].seam.x(), expected[i].seam.y());
CHECK((loops[i].seam - expected[i].seam).norm() < 0.01);
}
}
TEST_CASE("The whole-perimeter Blocked warning names the first modifier and counts them all", "[PreciseSeam]")
{
ExportFixture fixture("back");
// Separate layers: where both covered the same perimeter, only the higher-priority one would count.
fixture.add(ModelVolumeType::PRECISE_SEAM_BLOCKED, make_cube(30, 30, 1.4), Vec3d(-5, -5, -1), "first blocker");
fixture.add(ModelVolumeType::PRECISE_SEAM_BLOCKED, make_cube(30, 30, 2), Vec3d(-5, -5, 1), "second blocker");
fixture.export_gcode();
const std::string warning = fixture.warning();
CAPTURE(warning);
CHECK(warning.find("Seam Blocked modifier \"first blocker\" of") != std::string::npos);
CHECK(warning.find("(2 in total) covers an entire perimeter") != std::string::npos);
CHECK(warning.find("second blocker") == std::string::npos);
}
TEST_CASE("Belt printers slice Precise Seam modifiers in the frame the object was sliced in", "[PreciseSeam][belt]")
{
Model model;
+320
View File
@@ -26,6 +26,7 @@
#include <catch2/catch_all.hpp>
#include "libslic3r/Print.hpp"
#include "libslic3r/Slicing.hpp"
#include "libslic3r/Layer.hpp"
#include "libslic3r/BuildVolume.hpp"
#include "libslic3r/Support/TreeModelVolumes.hpp"
@@ -43,6 +44,7 @@
#include "libslic3r/GCode/GCodeProcessor.hpp"
#include "libslic3r/Exception.hpp"
#include "libslic3r/LifecycleEvents.hpp"
#include "libslic3r/Utils.hpp"
#include "test_helpers.hpp"
#include "test_utils.hpp"
@@ -61,6 +63,8 @@
#include "libslic3r/ObjectID.hpp"
#include "libslic3r/Point.hpp"
#include "libslic3r/SurfaceCollection.hpp"
#include "libslic3r/BoundingBox.hpp"
#include "libslic3r/ExPolygon.hpp"
using namespace Slic3r;
using namespace Slic3r::Test;
@@ -156,6 +160,81 @@ SCENARIO("Changing the number of solid shell layers does not make all surfaces i
}
}
// Two copies of one object share a single slice. When a copy that has sliced itself becomes identical to an
// earlier object, it keeps its own layers while that object slices and only then takes the shared layers over.
TEST_CASE("An object that starts sharing another object's slices keeps its own layers until it takes them over", "[Print][Regression]")
{
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
config.set_deserialize_strict({
{ "layer_height", 0.2 },
{ "initial_layer_print_height", 0.2 },
{ "raft_layers", 1 }, // each object gets support layers of its own
{ "sparse_infill_density", "20%" }, // object_a's override below differs from this
});
// Activates the slicing-pipeline hook installed for the second slice below.
config.set_key_value("slicing_pipeline_plugin", new ConfigOptionStrings({"probe"}));
Print print;
Model model;
init_print({cube(20)}, print, model, config);
ModelObject *object_a = model.objects.front();
// add_object() gives the copy extruder 1, and objects only share slices when their configs match.
object_a->config.set_key_value("extruder", new ConfigOptionInt(1));
ModelObject *object_b = model.add_object(*object_a); // shares object_a's mesh
for (ModelInstance *instance : object_b->instances)
instance->set_offset(instance->get_offset() + Vec3d(40., 0., 0.));
// A per-object override makes the objects differ, so each slices itself.
object_a->config.set_key_value("sparse_infill_density", new ConfigOptionPercent(40));
print.apply(model, config);
print.process();
REQUIRE(print.objects().size() == 2);
// The non-const accessors hand out the layer vectors themselves, which the checks below compare.
PrintObject *print_b = print.get_object(1);
REQUIRE(print_b->model_object()->id() == object_b->id()); // Print slices its own copy of the model
REQUIRE(print_b->get_shared_object() == nullptr);
const LayerPtrs own_layers = print_b->layers();
const SupportLayerPtrs own_support_layers = print_b->support_layers();
REQUIRE(!own_layers.empty());
REQUIRE(!own_support_layers.empty());
// Without the override the objects are identical again: print_a redoes the steps the setting invalidated,
// and print_b shares it.
object_a->config.erase("sparse_infill_density");
print.apply(model, config);
REQUIRE(print.objects().size() == 2);
PrintObject *print_a = print.get_object(0);
// Erasing the override invalidates print_a's posPrepareInfill and the steps after it. Its hook is the first one
// print_a fires, and it runs after print_b starts sharing but before print_b takes the shared layers over.
bool hook_fired = false;
bool shared_while_a_runs = false;
bool kept_layers = false;
bool kept_support_layers = false;
{
Print::set_slicing_pipeline_hook_fn([&](Print &, const PrintObject *object, SlicingPipelineStepPlugin) {
if (object != print_a || hook_fired)
return;
hook_fired = true;
shared_while_a_runs = print_b->get_shared_object() == print_a;
kept_layers = print_b->layers() == own_layers;
kept_support_layers = print_b->support_layers() == own_support_layers;
});
const ScopeGuard reset_hook([] { Print::set_slicing_pipeline_hook_fn(nullptr); });
print.process();
}
REQUIRE(hook_fired);
CHECK(print.objects()[1] == print_b);
CHECK(shared_while_a_runs);
CHECK(kept_layers);
CHECK(kept_support_layers);
CHECK(print_b->get_shared_object() == print_a);
REQUIRE(!print_a->layers().empty());
CHECK(print_b->layers() == print_a->layers());
CHECK(print_b->support_layers() == print_a->support_layers());
}
// ---------------------------------------------------------------------------
// Print::validate() warning collection
//
@@ -656,6 +735,65 @@ TEST_CASE("Custom G-code placeholders are substituted", "[Print]")
CHECK(per_layer.find(";Layer:399 ") != std::string::npos);
}
// The final print statistics are known only after the whole G-code is generated, yet every custom G-code,
// the file header included, gets the same values as filename_format.
TEST_CASE("Print statistics in custom G-code match the final print statistics", "[Print][Regression]")
{
const std::string gcode_key = GENERATE(as<std::string>{}, "file_start_gcode", "machine_start_gcode", "machine_end_gcode");
// Bambu printers use another tag table and G-code layout.
const bool is_bbl = GENERATE(false, true);
CAPTURE(gcode_key, is_bbl);
const std::vector<std::string> number_keys = { "total_weight", "used_filament", "total_cost", "total_wipe_tower_cost",
"total_wipe_tower_filament" };
const std::vector<std::string> time_keys = { "print_time", "normal_print_time", "silent_print_time" };
std::string templ;
for (const std::vector<std::string> *keys : { &number_keys, &time_keys })
for (const std::string &key : *keys)
templ += "; stat " + key + "={" + key + "}\n";
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
config.set_deserialize_strict({
{ gcode_key, templ },
{ "filament_density", "1.25" },
{ "filament_cost", "20" },
{ "time_cost", "2" },
});
Print print;
Model model;
init_print({ cube(20) }, print, model, config);
print.is_BBL_printer() = is_bbl;
std::string gcode;
REQUIRE_NOTHROW(gcode = Slic3r::Test::gcode(print));
// Match whole lines only: the config block repeats the template on a single line, and Bambu printers
// write that block before the start G-code.
const std::string lines = "\n" + gcode;
const auto value_of = [&lines](const std::string &key) {
const std::string prefix = "\n; stat " + key + "=";
const size_t start = lines.find(prefix);
REQUIRE(start != std::string::npos);
const size_t begin = start + prefix.size();
return lines.substr(begin, lines.find('\n', begin) - begin);
};
// The same statistics filename_format is expanded with.
DynamicConfig stats = print.print_statistics().config();
// A 20mm cube with filament_density 1.25 weighs something, unlike the running extruded_weight_total in the header.
CHECK(stats.opt_float("total_weight") > 0.);
for (const std::string &key : number_keys) {
CAPTURE(key);
const std::string value = value_of(key);
REQUIRE(value.find('@') == std::string::npos);
// Written with two decimals.
CHECK_THAT(std::stod(value), Catch::Matchers::WithinAbs(stats.opt_float(key), 0.006));
}
for (const std::string &key : time_keys) {
CAPTURE(key);
CHECK(value_of(key) == stats.opt_string(key));
}
}
TEST_CASE("export_gcode writes G-code without a result pointer", "[Print][export_gcode]")
{
Print print;
@@ -1520,3 +1658,185 @@ TEST_CASE("Belt supports reach the belt under a leading overhang", "[Print][belt
CHECK(lowest->print_z - floor_under_lowest > -0.2 - EPSILON);
}
}
TEST_CASE("The build height check counts the Z shrinkage compensation once", "[Print][validate][Regression]")
{
// printable_height is 100 mm and Z shrinks to 95 %, so the object is sliced 100 / 95 times taller.
// 92 mm become about 96.8 mm and fit; 97 mm become about 102.1 mm and only the compensation makes them too tall; 101 mm are too tall on their own.
const auto [height, expected] = GENERATE(table<double, std::string>({
{ 92., "" },
{ 97., "because of material shrinkage compensation" },
{ 101., "exceeds the maximum build volume height." },
}));
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
config.set_deserialize_strict({
{ "printable_height", 100. },
{ "filament_shrinkage_compensation_z", "95%" },
{ "layer_height", 0.2 },
{ "initial_layer_print_height", 0.2 },
{ "layer_change_gcode", "G92 E0\n" }, // validate() relative-E reset
});
Print print;
Model model;
init_print({ make_cube(20., 20., height) }, print, model, config);
const std::string error = print.validate().string;
INFO(error);
if (expected.empty())
CHECK(error.empty());
else
CHECK(error.find(expected) != std::string::npos);
}
TEST_CASE("The build height check counts the raft", "[Print][validate][Regression]")
{
// printable_height is 100 mm; three raft layers lift the object by 0.9 mm.
const auto [height, fits] = GENERATE(table<double, bool>({ { 98.8, true }, { 99.8, false } }));
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
config.set_deserialize_strict({
{ "printable_height", 100. },
{ "raft_layers", 3 },
{ "layer_height", 0.2 },
{ "initial_layer_print_height", 0.2 },
{ "layer_change_gcode", "G92 E0\n" }, // validate() relative-E reset
});
Print print;
Model model;
init_print({ make_cube(20., 20., height) }, print, model, config);
const SlicingParameters &slicing_params = print.objects().front()->slicing_parameters();
REQUIRE_THAT(slicing_params.object_print_z_min, Catch::Matchers::WithinAbs(0.9, EPSILON));
const std::string error = print.validate().string;
INFO(error);
if (fits)
CHECK(error.empty());
else
CHECK(error.find("maximum build volume height") != std::string::npos);
}
TEST_CASE("Filament shrinkage compensation scales the sliced object", "[Print][Regression]")
{
// A 20 mm cube printed with a filament that shrinks to xy % / z % is sliced 20 * 100 / xy wide and,
// up to one layer, 20 * 100 / z tall.
const auto [xy, z] = GENERATE(table<double, double>({ { 100., 100. }, { 90., 100. }, { 100., 95. } }));
const double layer_height = 0.2;
Print print;
init_and_process_print({ cube(20.) }, print, {
{ "filament_shrink", xy },
{ "filament_shrinkage_compensation_z", z },
{ "layer_height", layer_height },
{ "initial_layer_print_height", layer_height },
{ "precise_z_height", false },
{ "raft_layers", 0 },
{ "xy_contour_compensation", 0. },
});
const PrintObject &object = *print.objects().front();
const Layer &middle = *object.get_layer(int(object.layer_count() / 2));
const BoundingBox extents = get_extents(middle.lslices);
CHECK_THAT(unscale<double>(extents.size().x()), Catch::Matchers::WithinAbs(20. * 100. / xy, 0.01));
CHECK_THAT(unscale<double>(extents.size().y()), Catch::Matchers::WithinAbs(20. * 100. / xy, 0.01));
CHECK_THAT(object.layers().back()->print_z, Catch::Matchers::WithinAbs(20. * 100. / z, layer_height));
}
TEST_CASE("Filament shrinkage compensation follows the filaments the object prints with", "[Print][Regression]")
{
// Filament 1 shrinks to 90 %, filament 2 not at all. An unused filament does not matter; two used filaments
// that disagree switch the compensation off and validate() warns about it.
const auto [wall_filament, expected_width] = GENERATE(table<int, double>({ { 1, 20. * 100. / 90. }, { 2, 20. } }));
Print print;
init_and_process_print({ cube(20.) }, print, multifilament_config(2, {
{ "filament_shrink", "90%,100%" },
{ "outer_wall_filament_id", wall_filament },
{ "inner_wall_filament_id", wall_filament },
{ "xy_contour_compensation", 0. },
{ "layer_change_gcode", "G92 E0\n" }, // validate() relative-E reset
}));
const PrintObject &object = *print.objects().front();
const BoundingBox extents = get_extents(object.get_layer(int(object.layer_count() / 2))->lslices);
CHECK_THAT(unscale<double>(extents.size().x()), Catch::Matchers::WithinAbs(expected_width, 0.01));
std::vector<StringObjectException> warnings;
CHECK(print.validate(&warnings).string.empty());
const bool mismatch_warning = std::any_of(warnings.begin(), warnings.end(), [](const StringObjectException &w) {
return w.string.find("Filament shrinkage will not be used") != std::string::npos;
});
CHECK(mismatch_warning == (wall_filament == 2));
}
TEST_CASE("Applying the same model again keeps the filament shrinkage compensation", "[Print][Regression]")
{
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
config.set_deserialize_strict({ { "filament_shrink", "90%" }, { "xy_contour_compensation", 0. } });
Print print;
Model model;
init_print({ cube(20.) }, print, model, config);
const auto width = [&print]() {
print.process();
const PrintObject &object = *print.objects().front();
return unscale<double>(get_extents(object.get_layer(int(object.layer_count() / 2))->lslices).size().x());
};
CHECK_THAT(width(), Catch::Matchers::WithinAbs(20. * 100. / 90., 0.01));
print.apply(model, config);
CHECK_THAT(width(), Catch::Matchers::WithinAbs(20. * 100. / 90., 0.01));
}
TEST_CASE("Height ranges and modifiers count for the filament shrinkage compensation", "[Print][Regression]")
{
// The model is complete before the first apply(), so no PrintRegion tells yet which filaments print.
enum class Setup { RangeWall, ModifierInfill, ModifierInheritingObjectFilament };
const Setup setup = GENERATE(Setup::RangeWall, Setup::ModifierInfill, Setup::ModifierInheritingObjectFilament);
// The object prints with the filament that shrinks to 90 %, the other one does not shrink.
const bool object_on_filament_2 = setup == Setup::ModifierInheritingObjectFilament;
DynamicPrintConfig config = multifilament_config(2, {
{ "filament_shrink", object_on_filament_2 ? "100%,90%" : "90%,100%" },
{ "xy_contour_compensation", 0. },
{ "layer_change_gcode", "G92 E0\n" }, // validate() relative-E reset
});
Model model;
ModelObject *object = model.add_object();
object->add_volume(cube(20.));
DynamicPrintConfig override;
switch (setup) {
case Setup::RangeWall:
// The walls of a height range print with the other filament.
override.set_key_value("outer_wall_filament_id", new ConfigOptionInt(2));
// Every layer range must carry a layer_height (see layer_height_profile_from_ranges).
override.set_key_value("layer_height", new ConfigOptionFloat(0.2));
object->layer_config_ranges[{ 5., 10. }].assign_config(std::move(override));
break;
case Setup::ModifierInfill:
// The infill inside a modifier prints with the other filament.
override.set_key_value("sparse_infill_filament_id", new ConfigOptionInt(2));
object->add_volume(cube(10.), ModelVolumeType::PARAMETER_MODIFIER, false)->config.apply(override);
break;
case Setup::ModifierInheritingObjectFilament:
// The modifier only changes the infill density and keeps printing with the filament of the object.
object->config.set_key_value("extruder", new ConfigOptionInt(2));
override.set_key_value("sparse_infill_density", new ConfigOptionPercent(30));
object->add_volume(cube(10.), ModelVolumeType::PARAMETER_MODIFIER, false)->config.apply(override);
break;
}
object->add_instance()->set_offset(Vec3d(90., 90., 0.));
object->ensure_on_bed();
Print print;
print.apply(model, config);
print.process();
const PrintObject &print_object = *print.objects().front();
const BoundingBox extents = get_extents(print_object.get_layer(int(print_object.layer_count() / 2))->lslices);
const double expected_width = object_on_filament_2 ? 20. * 100. / 90. : 20.;
CHECK_THAT(unscale<double>(extents.size().x()), Catch::Matchers::WithinAbs(expected_width, 0.01));
std::vector<StringObjectException> warnings;
CHECK(print.validate(&warnings).string.empty());
const bool mismatch_warning = std::any_of(warnings.begin(), warnings.end(), [](const StringObjectException &w) {
return w.string.find("Filament shrinkage will not be used") != std::string::npos;
});
CHECK(mismatch_warning == ! object_on_filament_2);
}
+125
View File
@@ -30,6 +30,7 @@
#include "libslic3r/Line.hpp"
#include "libslic3r/ExtrusionEntity.hpp"
#include "libslic3r/BoundingBox.hpp"
#include <functional>
#include <map>
#include <set>
#include <string>
@@ -596,3 +597,127 @@ TEST_CASE("Body centering survives islands merging and splitting between layers"
}
}
}
TEST_CASE("Z contouring slices an object that has several instances", "[PrintObject]")
{
Print print;
Model model;
place_two_cube_instances_apart(10., { { "zaa_enabled", "1" } }, print, model);
REQUIRE(print.objects().size() == 1);
REQUIRE(print.objects().front()->instances().size() == 2);
CHECK_NOTHROW(print.process());
CHECK(print.objects().front()->is_step_done(posContouring));
}
TEST_CASE("Z contouring slices instances of one object that are turned differently", "[PrintObject]")
{
// An instance turned about Z is sliced as a PrintObject of its own, with the same ModelObject behind it.
Print print;
Model model;
place_two_cube_instances_apart(10., { { "zaa_enabled", "1" } }, print, model);
model.objects.front()->instances.back()->set_rotation(Z, PI / 4.);
print.apply(model, DynamicPrintConfig(print.full_print_config()));
REQUIRE(print.objects().size() == 2);
CHECK_NOTHROW(print.process());
for (const PrintObject *object : print.objects())
CHECK(object->is_step_done(posContouring));
}
// A box tilted about X, so that its top slopes along Y and Z contouring has a surface to follow, with
// one instance for each Z rotation given, laid out in a row.
static void slice_tilted_box_instances(const std::vector<double> &z_rotations, Print &print, Model &model)
{
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
config.set_deserialize_strict({ { "zaa_enabled", "1" } });
TriangleMesh box = make_cube(20, 20, 10);
box.rotate_x(float(PI / 12.));
ModelObject *object = model.add_object();
object->add_volume(std::move(box));
for (size_t idx = 0; idx < z_rotations.size(); ++idx) {
ModelInstance *instance = object->add_instance();
instance->set_offset(Vec3d(50. + 50. * double(idx), 100., 0.));
instance->set_rotation(Z, z_rotations[idx]);
}
object->ensure_on_bed();
print.auto_assign_extruders(object);
print.apply(model, config);
print.validate();
print.set_status_silent();
print.process();
}
// The Z that contouring gave each point of the object's extrusions, by layer and by XY in the
// PrintObject's own frame, which does not depend on where its instances stand on the bed.
static std::vector<std::map<std::pair<coord_t, coord_t>, coord_t>> contoured_heights(const PrintObject &object)
{
std::vector<std::map<std::pair<coord_t, coord_t>, coord_t>> heights(object.layer_count());
for (size_t layer_idx = 0; layer_idx < object.layer_count(); ++layer_idx) {
auto &layer_heights = heights[layer_idx];
std::function<void(const ExtrusionEntity &)> visit = [&](const ExtrusionEntity &entity) {
if (const auto *path = dynamic_cast<const ExtrusionPath *>(&entity)) {
if (path->z_contoured)
for (const Point3 &pt : path->polyline.points)
layer_heights[{ pt.x(), pt.y() }] = pt.z();
} else if (const auto *multipath = dynamic_cast<const ExtrusionMultiPath *>(&entity)) {
for (const ExtrusionPath &path : multipath->paths)
visit(path);
} else if (const auto *loop = dynamic_cast<const ExtrusionLoop *>(&entity)) {
for (const ExtrusionPath &path : loop->paths)
visit(path);
} else if (const auto *collection = dynamic_cast<const ExtrusionEntityCollection *>(&entity)) {
for (const ExtrusionEntity *child : collection->entities)
visit(*child);
}
};
for (const LayerRegion *region : object.get_layer(int(layer_idx))->regions()) {
visit(region->perimeters);
visit(region->fills);
}
}
return heights;
}
TEST_CASE("Z contouring follows the surface of each instance as it is turned", "[PrintObject]")
{
// Two instances of a box with a sloped top, the second turned a quarter turn about Z: its top slopes
// along X instead of Y. Each is contoured against the box turned as that instance is, so it has to
// match the same box placed alone with that turn.
Print print;
Model model;
slice_tilted_box_instances({ 0., PI / 2. }, print, model);
REQUIRE(print.objects().size() == 2);
for (const PrintObject *object : print.objects()) {
const double z_rotation = object->instances().front().model_instance->get_rotation().z();
Print reference;
Model reference_model;
slice_tilted_box_instances({ z_rotation }, reference, reference_model);
REQUIRE(reference.objects().size() == 1);
const auto heights = contoured_heights(*object);
const auto reference_heights = contoured_heights(*reference.objects().front());
REQUIRE(heights.size() == reference_heights.size());
size_t matched = 0, lifted = 0;
for (size_t layer_idx = 0; layer_idx < heights.size(); ++layer_idx) {
const coord_t print_z = scaled(object->get_layer(int(layer_idx))->print_z);
for (const auto &[xy, z] : heights[layer_idx]) {
const auto it = reference_heights[layer_idx].find(xy);
if (it == reference_heights[layer_idx].end())
continue;
INFO("layer " << layer_idx << ", rotation " << z_rotation);
CHECK(std::abs(z - it->second) <= scaled(0.001));
++matched;
if (std::abs(z - print_z) > scaled(0.01))
++lifted;
}
}
INFO("rotation " << z_rotation);
// Enough points in common to mean something, and contouring did move some of them.
CHECK(matched > 100);
CHECK(lifted > 0);
}
}
@@ -87,8 +87,8 @@ static std::string strip_nondeterministic_gcode_lines(const std::string& gcode)
while (std::getline(in, line)) {
if (line.compare(0, 15, "; generated by ") == 0) continue; // wall-clock timestamp
if (line.compare(0, 18, "; model label id: ") == 0) continue; // ObjectID-derived
// "; [stop] printing object <name> id:N copy M" / "... unique label id: N" (ObjectID-derived):
if (line.find("printing object") != std::string::npos && line.find(" id:") != std::string::npos) continue;
// "; start/stop printing object, unique label id: N" (ObjectID-derived):
if (line.find("printing object, unique label id: ") != std::string::npos) continue;
if (line.find("slicing_pipeline_plugin") != std::string::npos) continue; // config-dump plugin name
out += line; out += '\n';
}
+2 -1
View File
@@ -35,7 +35,7 @@ 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_adaptive_infill_field.cpp
test_geometry.cpp
test_kdtree.cpp
test_imex_arrange.cpp
@@ -54,6 +54,7 @@ add_executable(${_TEST_NAME}_tests
test_stl.cpp
test_triangle_selector.cpp
test_meshboolean.cpp
test_mesh_repair.cpp
test_trianglemesh_slicer.cpp
test_marchingsquares.cpp
test_lay_on_face.cpp
+548 -13
View File
@@ -7,6 +7,7 @@
#include "libslic3r/Model.hpp"
#include "libslic3r/Format/3mf.hpp"
#include "libslic3r/Format/bbs_3mf.hpp"
#include "libslic3r/GCode/ThumbnailData.hpp"
#include "libslic3r/Format/STL.hpp"
#include "libslic3r/Utils.hpp"
#include "libslic3r/miniz_extension.hpp"
@@ -28,16 +29,24 @@
#include <miniz.h>
#include "libslic3r/TriangleMesh.hpp"
#include "libslic3r/TriangleSelector.hpp"
#include "libslic3r/TextureDisplacement.hpp"
#include <memory>
#include <cstdint>
#include <ios>
#include <nlohmann/json.hpp>
#include <boost/filesystem/operations.hpp>
#include <boost/filesystem/file_status.hpp> // no_perms, owner_all
#include <boost/nowide/fstream.hpp>
#include <boost/algorithm/string/predicate.hpp>
#include <algorithm>
#include <functional>
#include <initializer_list>
#include <fstream>
#ifndef _WIN32
#include <unistd.h> // geteuid
#endif
#include <catch2/catch_tostring.hpp>
#include <Eigen/Core>
@@ -349,6 +358,24 @@ static bool replace_in_3mf_entry(const std::string& path, const std::string& suf
return replaced;
}
// Stores `model` as a one-plate project.
static void store_project(Model& model, const std::string& path, SaveStrategy strategy)
{
ScopedTemporaryDir backup_dir("orca_paint_src");
model.set_backup_path(backup_dir.string());
DynamicPrintConfig cfg;
PlateData plate;
plate.plate_index = 0;
StoreParams sp;
sp.path = path.c_str();
sp.model = &model;
sp.config = &cfg;
sp.strategy = strategy;
sp.plate_data_list.push_back(&plate);
REQUIRE(store_bbs_3mf(sp));
}
// Stores a one-plate project holding a cube whose first two facets are painted Extruder2 and
// Extruder3, which the exporter writes as paint_color="8" and paint_color="0C".
static void store_painted_cube(const std::string& path)
@@ -363,19 +390,7 @@ static void store_painted_cube(const std::string& path)
selector.set_facet(1, EnforcerBlockerType::Extruder3);
REQUIRE(volume->mmu_segmentation_facets.set(selector));
}
ScopedTemporaryDir backup_dir("orca_paint_src");
model.set_backup_path(backup_dir.string());
DynamicPrintConfig cfg;
PlateData plate;
plate.plate_index = 0;
StoreParams sp;
sp.path = path.c_str();
sp.model = &model;
sp.config = &cfg;
sp.strategy = SaveStrategy::Zip64 | SaveStrategy::Silence;
sp.plate_data_list.push_back(&plate);
REQUIRE(store_bbs_3mf(sp));
store_project(model, path, SaveStrategy::Zip64 | SaveStrategy::Silence);
}
// Loads `path` through the BBS importer into `model`, releasing the plates it returns. The
@@ -395,6 +410,44 @@ static bool load_project(const std::string& path, Model& model, const ScopedTemp
return loaded;
}
TEST_CASE("A plate's G-code statistics are stored next to its G-code", "[3mf]")
{
const bool with_statistics = GENERATE(false, true);
ScopedTemporaryFile gcode(".gcode");
save_string_file(gcode.path(), "G1 X10 Y10 E1\n");
Model model;
ModelObject* object = model.add_object();
object->add_volume(make_cube(10., 10., 10.));
object->add_instance();
ScopedTemporaryDir backup_dir("orca_statistics_src");
model.set_backup_path(backup_dir.string());
// A sliced plate's info reads the printer settings.
DynamicPrintConfig cfg = DynamicPrintConfig::full_print_config();
PlateData plate;
plate.plate_index = 0;
plate.gcode_file = gcode.string();
plate.is_sliced_valid = true;
if (with_statistics)
plate.statistics = R"({ "version": 1 })";
ScopedTemporaryFile file(".3mf");
const std::string path = file.string();
StoreParams sp;
sp.path = path.c_str();
sp.model = &model;
sp.config = &cfg;
sp.strategy = SaveStrategy::Zip64 | SaveStrategy::Silence | SaveStrategy::WithGcode;
sp.plate_data_list.push_back(&plate);
REQUIRE(store_bbs_3mf(sp));
std::string stored;
REQUIRE(read_cad_recipe_entry(path, stored, "Metadata/plate_1.gcode"));
CHECK(read_cad_recipe_entry(path, stored, "Metadata/plate_1.statistics.json") == with_statistics);
if (with_statistics)
CHECK(stored == plate.statistics);
}
TEST_CASE("A project with a plate id below 1 fails to load", "[3mf][Regression]")
{
const int plate_id = GENERATE(0, -1);
@@ -452,6 +505,28 @@ TEST_CASE("A project whose components reference themselves fails to load", "[3mf
REQUIRE_FALSE(loaded);
}
TEST_CASE("A project in the 2.4.x layout keeps auto drop off from its build item", "[3mf][Regression]")
{
// 2.4.x wrote auto_drop on the build item and left it off the object, so the object is re-laid out here.
ScopedTemporaryFile temp(".3mf");
{
Model model;
ModelObject* object = model.add_object();
object->add_volume(make_cube(10., 10., 10.));
object->add_instance();
object->auto_drop = false;
store_project(model, temp.string(), SaveStrategy::Zip64 | SaveStrategy::Silence);
}
REQUIRE(replace_in_3mf_entry(temp.string(), "model_settings.config", " auto_drop=\"0\"", ""));
REQUIRE(replace_in_3mf_entry(temp.string(), "3dmodel.model", "printable=\"1\"/>", "printable=\"1\" auto_drop=\"0\"/>"));
ScopedTemporaryDir backup_dir("orca_auto_drop_dst");
Model model;
REQUIRE(load_project(temp.string(), model, backup_dir));
REQUIRE(model.objects.size() == 1);
CHECK_FALSE(model.objects.front()->auto_drop);
}
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
@@ -501,6 +576,163 @@ TEST_CASE("A project with malformed paint data loads without the damaged facet",
REQUIRE(selector.num_facets(EnforcerBlockerType::Extruder3) == 1);
}
// Paints `triangles` of `volume` on texture displacement layer slot `slot`.
static void paint_texture_slot(ModelVolume& volume, int slot, std::initializer_list<int> triangles)
{
TriangleSelector selector(volume.mesh());
for (const int triangle : triangles)
selector.set_facet(triangle, EnforcerBlockerType::ENFORCER);
REQUIRE(volume.texture_displacement_facet(slot).set(selector));
}
// Every triangle's texture paint on every slot, in the form the exporter writes it, so two volumes
// compare equal only when the same triangles are painted on the same slots.
static std::vector<std::string> texture_paint(const ModelVolume& volume)
{
std::vector<std::string> paint;
for (int slot = 0; slot < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++slot)
for (size_t i = 0; i < volume.mesh().its.indices.size(); ++i)
paint.push_back(volume.texture_displacement_facet(slot).get_triangle_as_string(int(i)));
return paint;
}
// A cube with one texture displacement layer in slot 2 and texture paint on gapped triangles of two
// slots. The layer's image bytes need not decode: the archive stores them as they are.
static ModelVolume& add_texture_displaced_cube(Model& model)
{
ModelObject* object = model.add_object();
ModelVolume* volume = object->add_volume(make_cube(10., 10., 10.));
object->add_instance();
TextureDisplacementLayer layer;
layer.slot = 2;
layer.name = "Bark";
layer.depth_mm = 1.25f;
layer.image_data = std::make_shared<std::vector<unsigned char>>(std::vector<unsigned char>{ 0x89, 'P', 'N', 'G', 1, 2, 3 });
volume->texture_displacement_layers.push_back(layer);
volume->texture_displacement_options.active_slot = 2;
paint_texture_slot(*volume, 2, { 3, 7 });
paint_texture_slot(*volume, 5, { 10 });
return *volume;
}
TEST_CASE("Texture displacement layers, textures and paint survive a project round trip", "[3mf]")
{
// A single model file, and one file per object (read by a separate importer).
const SaveStrategy strategy = GENERATE(SaveStrategy::Zip64 | SaveStrategy::Silence,
SaveStrategy::Zip64 | SaveStrategy::Silence | SaveStrategy::SplitModel);
INFO("save strategy " << int(strategy));
ScopedTemporaryFile temp(".3mf");
Model source;
const ModelVolume& painted = add_texture_displaced_cube(source);
const std::vector<std::string> paint = texture_paint(painted);
REQUIRE(std::count_if(paint.begin(), paint.end(), [](const std::string& s) { return !s.empty(); }) == 3);
store_project(source, temp.string(), strategy);
ScopedTemporaryDir backup_dir("orca_texdisp_dst");
Model model;
REQUIRE(load_project(temp.string(), model, backup_dir));
REQUIRE(model.objects.size() == 1);
const ModelVolume& volume = *model.objects.front()->volumes.front();
REQUIRE(volume.texture_displacement_layers.size() == 1);
const TextureDisplacementLayer& layer = volume.texture_displacement_layers.front();
CHECK(layer.slot == 2);
CHECK(layer.name == "Bark");
CHECK_THAT(layer.depth_mm, Catch::Matchers::WithinAbs(1.25f, 1e-6f));
REQUIRE(layer.image_data);
CHECK(*layer.image_data == *painted.texture_displacement_layers.front().image_data);
CHECK(volume.texture_displacement_options.active_slot == 2);
CHECK(texture_paint(volume) == paint);
}
TEST_CASE("Volumes sharing a mesh keep their own texture displacement paint", "[3mf]")
{
Model source;
ModelObject* first_object = source.add_object();
ModelVolume* first = first_object->add_volume(make_cube(10., 10., 10.));
first_object->add_instance();
ModelVolume* second = source.add_object(*first_object)->volumes.front();
REQUIRE(second->mesh_ptr() == first->mesh_ptr());
paint_texture_slot(*first, 0, { 3 });
paint_texture_slot(*second, 0, { 7 });
// What a project save from the app uses: one file per object, an identical mesh written once.
ScopedTemporaryFile temp(".3mf");
store_project(source, temp.string(),
SaveStrategy::Zip64 | SaveStrategy::Silence | SaveStrategy::SplitModel | SaveStrategy::ShareMesh);
ScopedTemporaryDir backup_dir("orca_texdisp_dst");
Model model;
REQUIRE(load_project(temp.string(), model, backup_dir));
REQUIRE(model.objects.size() == 2);
CHECK(texture_paint(*model.objects[0]->volumes.front()) == texture_paint(*first));
CHECK(texture_paint(*model.objects[1]->volumes.front()) == texture_paint(*second));
}
TEST_CASE("A texture displacement layer never loads its image from outside the project", "[3mf]")
{
// A file the layer names, on the machine opening the project, that the project did not carry.
ScopedTemporaryFile texture(".png");
{
boost::nowide::ofstream file(texture.string(), std::ios::binary);
file << "not part of the project";
}
REQUIRE(boost::filesystem::file_size(texture.path()) > 0);
Model source;
ModelObject* object = source.add_object();
ModelVolume* volume = object->add_volume(make_cube(10., 10., 10.));
object->add_instance();
TextureDisplacementLayer layer;
layer.slot = 0;
layer.name = "Elsewhere";
layer.path = texture.string();
layer.depth_mm = 0.75f;
volume->texture_displacement_layers.push_back(layer);
ScopedTemporaryFile temp(".3mf");
store_project(source, temp.string(), SaveStrategy::Zip64 | SaveStrategy::Silence);
ScopedTemporaryDir backup_dir("orca_texdisp_dst");
Model model;
REQUIRE(load_project(temp.string(), model, backup_dir));
REQUIRE(model.objects.size() == 1);
const std::vector<TextureDisplacementLayer>& layers = model.objects.front()->volumes.front()->texture_displacement_layers;
REQUIRE(layers.size() == 1);
CHECK(layers.front().name == "Elsewhere");
CHECK_THAT(layers.front().depth_mm, Catch::Matchers::WithinAbs(0.75f, 1e-6f));
CHECK_FALSE(layers.front().image_data);
}
TEST_CASE("A project with a malformed texture displacement value still loads", "[3mf][Regression]")
{
const auto [from, to] = GENERATE(table<std::string, std::string>({
{ "\"islands\":[]", "\"islands\":[[null,0,0,1]]" }, // a number that is null
{ "\"slot\":2", "\"slot\":8" }, // a slot past the last
}));
INFO(from << " -> " << to);
ScopedTemporaryFile temp(".3mf");
Model source;
const ModelVolume& painted = add_texture_displaced_cube(source);
store_project(source, temp.string(), SaveStrategy::Zip64 | SaveStrategy::Silence);
REQUIRE(replace_in_3mf_entry(temp.string(), ".json", from, to));
ScopedTemporaryDir backup_dir("orca_texdisp_dst");
Model model;
bool loaded = false;
REQUIRE_NOTHROW(loaded = load_project(temp.string(), model, backup_dir));
REQUIRE(loaded);
REQUIRE(model.objects.size() == 1);
const ModelVolume& volume = *model.objects.front()->volumes.front();
// The layer stack is dropped as a whole; the paint, stored apart from it, still loads.
CHECK(volume.texture_displacement_layers.empty());
CHECK(texture_paint(volume) == texture_paint(painted));
}
// The recipe lives only in the BBS-native backend, because that is the only one that runs:
// store_bbs_3mf is the sole exporter the app calls, and 3mf.cpp's load_3mf is reached only for
// files fingerprinted as PrusaSlicer's, which never carry a recipe. This locks in both halves:
@@ -2043,3 +2275,306 @@ 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);
}
// A relationship must name a part the package actually contains. The writer used to fall
// back to /Metadata/plate_1.png whenever no cover image was set, so an export that wrote no
// plate thumbnail still declared one -- a dangling relationship a strict OPC reader may reject.
SCENARIO("thumbnail relationships are declared only for thumbnails that were written", "[3mf]")
{
GIVEN("a project with one plate") {
Model model;
const std::string src_file = std::string(TEST_DATA_DIR) + "/test_3mf/Prusa.stl";
REQUIRE(load_stl(src_file.c_str(), &model));
model.add_default_instances();
ScopedTemporaryDir backup_dir("orca_rels");
model.set_backup_path(backup_dir.string());
PlateData plate;
plate.plate_index = 0;
PlateDataPtrs plates{ &plate };
auto store = [&](const std::string &path, std::vector<ThumbnailData *> thumbnails) {
DynamicPrintConfig cfg;
StoreParams sp;
sp.path = path.c_str();
sp.model = &model;
sp.config = &cfg;
sp.plate_data_list = plates;
sp.thumbnail_data = std::move(thumbnails);
sp.strategy = SaveStrategy::Zip64 | SaveStrategy::Silence;
REQUIRE(store_bbs_3mf(sp));
};
WHEN("no thumbnail is rendered") {
ScopedTemporaryFile temp(".3mf");
ThumbnailData none; // left invalid: nothing was rendered
REQUIRE_FALSE(none.is_valid());
store(temp.string(), { &none });
THEN("the package declares no thumbnail relationship") {
std::string rels;
REQUIRE(read_cad_recipe_entry(temp.string(), rels, "_rels/.rels"));
CHECK(rels.find("3dmodel") != std::string::npos); // the model is still declared
CHECK(rels.find("plate_1.png") == std::string::npos);
CHECK(rels.find("thumbnail") == std::string::npos);
}
}
WHEN("a plate thumbnail is rendered") {
ScopedTemporaryFile temp(".3mf");
ThumbnailData rendered;
rendered.set(16, 16);
rendered.pixels.assign(16 * 16 * 4, static_cast<unsigned char>(0xff));
REQUIRE(rendered.is_valid());
store(temp.string(), { &rendered });
THEN("the relationships name it, and the parts are in the package") {
std::string rels;
REQUIRE(read_cad_recipe_entry(temp.string(), rels, "_rels/.rels"));
CHECK(rels.find("/Metadata/plate_1.png") != std::string::npos);
CHECK(rels.find("/Metadata/plate_1_small.png") != std::string::npos);
std::string part;
CHECK(read_cad_recipe_entry(temp.string(), part, "Metadata/plate_1.png"));
CHECK(read_cad_recipe_entry(temp.string(), part, "Metadata/plate_1_small.png"));
}
}
}
}
// has_restore_data() runs from the queued EVT_RESTORE_PROJECT handler, whose try starts after
// the call, so a filesystem_error from probing the stored backup path aborts the application.
// The realistic trigger is an absolute path from another user's config whose home is not
// searchable, so the parent, not the backup directory, is what blocks.
#ifndef _WIN32
TEST_CASE("has_restore_data reports no restore data for a path it cannot probe", "[3mf]") {
if (::geteuid() == 0)
SKIP("root ignores the directory permissions this test depends on");
ScopedTemporaryDir outer("orca_restore");
boost::filesystem::path backup = outer.path() / "backup";
REQUIRE(boost::filesystem::create_directory(backup));
std::ofstream((backup / ".3mf").string()) << "stand-in for the backed-up project";
std::ofstream((backup / "origin.txt").string()) << "project.3mf";
// Control: while the path is readable the session is found, so the false below really is
// the probe failing and not a mis-built fixture.
std::string readable = backup.string(), origin;
REQUIRE(has_restore_data(readable, origin));
REQUIRE(readable == (backup / ".3mf").string());
REQUIRE(origin == "project.3mf");
// Puts the permissions back before ~ScopedTemporaryDir needs them to clean up, and does so
// even if an assertion below throws.
struct Searchable {
boost::filesystem::path dir;
~Searchable() { boost::system::error_code ec;
boost::filesystem::permissions(dir, boost::filesystem::owner_all, ec); }
} restore{ outer.path() };
// Non-throwing here too: a filesystem that refuses the mode change has to reach the skip
// below rather than fail the test. The probe then still succeeds, which is that same case.
boost::system::error_code probe_ec;
boost::filesystem::permissions(outer.path(), boost::filesystem::no_perms, probe_ec);
if (boost::filesystem::exists(backup / ".3mf", probe_ec) || !probe_ec)
SKIP("the backup path could not be made unprobeable here");
std::string unprobeable = backup.string(), origin2;
bool has = true;
REQUIRE_NOTHROW(has = has_restore_data(unprobeable, origin2));
CHECK_FALSE(has);
CHECK(unprobeable == backup.string()); // not rewritten to the .3mf it could not confirm
CHECK(origin2 == "<lock>"); // and the caller is told not to remove it
}
#endif // _WIN32
// Every "return false" that leaves origin empty is an instruction to the caller to
// remove_all() the backup directory (Plater.cpp's EVT_RESTORE_PROJECT handler), so the
// distinction these cases draw is between a backup that is spent and one whose state could
// not be established. Only the first may be collected.
TEST_CASE("has_restore_data claims the lock when lock.txt cannot be interpreted", "[3mf]") {
ScopedTemporaryDir outer("orca_lock");
boost::filesystem::path backup = outer.path() / "backup";
REQUIRE(boost::filesystem::create_directory(backup));
std::ofstream((backup / ".3mf").string()) << "stand-in for the backed-up project";
SECTION("an empty lock.txt holds no pid") {
std::ofstream lock((backup / "lock.txt").string());
lock.close(); // created, left zero length
REQUIRE(boost::filesystem::file_size(backup / "lock.txt") == 0);
std::string path = backup.string(), origin;
bool has = true;
REQUIRE_NOTHROW(has = has_restore_data(path, origin));
CHECK_FALSE(has);
CHECK(origin == "<lock>");
}
SECTION("lock.txt holds something that is not a pid") {
std::ofstream((backup / "lock.txt").string()) << "not-a-pid";
std::string path = backup.string(), origin;
bool has = true;
REQUIRE_NOTHROW(has = has_restore_data(path, origin));
CHECK_FALSE(has);
CHECK(origin == "<lock>");
}
}
#ifndef _WIN32
TEST_CASE("has_restore_data claims the lock when lock.txt cannot be read", "[3mf]") {
if (::geteuid() == 0)
SKIP("root ignores the file permissions this test depends on");
ScopedTemporaryDir outer("orca_lock");
boost::filesystem::path backup = outer.path() / "backup";
REQUIRE(boost::filesystem::create_directory(backup));
std::ofstream((backup / ".3mf").string()) << "stand-in for the backed-up project";
std::ofstream((backup / "lock.txt").string()) << "1";
boost::system::error_code mode_ec; // a filesystem that refuses the mode change must skip below
boost::filesystem::permissions(backup / "lock.txt", boost::filesystem::no_perms, mode_ec);
boost::nowide::ifstream probe((backup / "lock.txt").string());
if (probe.good())
SKIP("this filesystem does not enforce file read permissions");
// load_string_file() throws here.
std::string path = backup.string(), origin;
bool has = true;
REQUIRE_NOTHROW(has = has_restore_data(path, origin));
CHECK_FALSE(has);
CHECK(origin == "<lock>");
}
#endif // _WIN32
// The negative control for the three cases above: claiming the lock whenever anything is
// amiss would strand every spent backup on disk forever, so a readable directory that
// simply has nothing to restore must still come back collectable.
TEST_CASE("has_restore_data leaves a spent backup collectable", "[3mf]") {
ScopedTemporaryDir outer("orca_spent");
boost::filesystem::path backup = outer.path() / "backup";
REQUIRE(boost::filesystem::create_directory(backup));
SECTION("no lock and no project") {
std::string path = backup.string(), origin;
CHECK_FALSE(has_restore_data(path, origin));
CHECK(origin.empty());
}
SECTION("a lock from a process that is not us, and no project") {
// get_process_name() compares executable names and answers empty for a pid it cannot
// read, so pid 1 never matches this process and the lock must not be claimed.
std::ofstream((backup / "lock.txt").string()) << "1";
std::string path = backup.string(), origin;
CHECK_FALSE(has_restore_data(path, origin));
CHECK(origin.empty());
}
SECTION("the directory does not exist at all") {
std::string path = (outer.path() / "gone").string(), origin;
CHECK_FALSE(has_restore_data(path, origin));
CHECK(origin.empty());
}
}
// The no-light pass must not mark the plate thumbnail as written.
SCENARIO("a rendered no-light thumbnail does not suppress the plate thumbnail", "[3mf]")
{
GIVEN("a plate whose no-light thumbnail is rendered and whose plate thumbnail is only on disk") {
Model model;
const std::string src_file = std::string(TEST_DATA_DIR) + "/test_3mf/Prusa.stl";
REQUIRE(load_stl(src_file.c_str(), &model));
model.add_default_instances();
ScopedTemporaryDir backup_dir("orca_no_light");
model.set_backup_path(backup_dir.string());
// Only the bytes matter: the exporter copies this file into the archive verbatim.
ScopedTemporaryFile png(".png");
const std::string png_path = png.string();
const std::string png_body = "\x89PNG\r\n\x1a\n" "plate-1-thumbnail-from-disk";
{
boost::nowide::ofstream out(png_path.c_str(), std::ios::binary);
out << png_body;
}
REQUIRE(boost::filesystem::exists(png_path));
PlateData plate;
plate.plate_index = 0;
plate.thumbnail_file = png_path;
PlateDataPtrs plates{ &plate };
ThumbnailData plate_thumbnail; // nothing was rendered for the plate itself
ThumbnailData no_light_thumbnail;
no_light_thumbnail.set(16, 16);
no_light_thumbnail.pixels.assign(16 * 16 * 4, static_cast<unsigned char>(0xff));
REQUIRE_FALSE(plate_thumbnail.is_valid());
REQUIRE(no_light_thumbnail.is_valid());
WHEN("the project is written") {
ScopedTemporaryFile temp(".3mf");
const std::string test_file = temp.string();
DynamicPrintConfig cfg;
StoreParams sp;
sp.path = test_file.c_str();
sp.model = &model;
sp.config = &cfg;
sp.plate_data_list = plates;
sp.thumbnail_data = { &plate_thumbnail };
sp.no_light_thumbnail_data = { &no_light_thumbnail };
sp.strategy = SaveStrategy::Zip64 | SaveStrategy::Silence;
REQUIRE(store_bbs_3mf(sp));
THEN("the plate thumbnail is still copied in from disk") {
std::string got;
REQUIRE(read_cad_recipe_entry(test_file, got, "Metadata/plate_1.png"));
REQUIRE(got == png_body);
}
THEN("the rendered no-light thumbnail is written as well") {
std::string got;
REQUIRE(read_cad_recipe_entry(test_file, got, "Metadata/plate_no_light_1.png"));
REQUIRE_FALSE(got.empty());
}
}
}
}
@@ -13,7 +13,7 @@
#include "libslic3r/BoundingBox.hpp"
#include "libslic3r/ClipperUtils.hpp"
#include "libslic3r/ExPolygon.hpp"
#include "libslic3r/Fill/FillTpmsAdaptive.hpp"
#include "libslic3r/Fill/AdaptiveInfillField.hpp"
#include "libslic3r/Point.hpp"
#include "libslic3r/Polygon.hpp"
#include "libslic3r/PrintConfig.hpp"
@@ -30,24 +30,24 @@ ExPolygon rectangle(double x0, double y0, double x1, double 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)
AdaptiveInfillField radial_field(const ExPolygons &expolygons, double height, AdaptiveInfillMode mode = AdaptiveInfillMode::Lobes)
{
std::vector<TpmsRadialField::Slice> slices;
std::vector<AdaptiveInfillField::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, [] {});
return AdaptiveInfillField(slices, get_extents(expolygons), mode, [] {});
}
double radial(const TpmsRadialField &field, const Vec3d &pt)
double radial(const AdaptiveInfillField &field, const Vec3d &pt)
{
TpmsRadialField::Radials radials;
AdaptiveInfillField::Radials radials;
field.radial(pt, radials);
return radials[0].t;
}
Vec3d center(const TpmsRadialField &field, const Vec3d &pt)
Vec3d center(const AdaptiveInfillField &field, const Vec3d &pt)
{
TpmsRadialField::Radials radials;
AdaptiveInfillField::Radials radials;
field.radial(pt, radials);
return radials[0].center;
}
@@ -57,11 +57,11 @@ 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]")
TEST_CASE("Adaptive infill field is zero at the center of a cube and one at its faces", "[AdaptiveInfillField]")
{
// 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 AdaptiveInfillField field = radial_field(square, 20.);
const Vec3d c = center(field, {10., 10., 10.});
CHECK_THAT(c.x(), WithinAbs(10., 0.5));
@@ -74,10 +74,10 @@ TEST_CASE("TPMS radial field is zero at the center of a cube and one at its face
}
}
TEST_CASE("TPMS radial field grows linearly from the center of a cube to its faces", "[FillTpmsAdaptive]")
TEST_CASE("Adaptive infill field grows linearly from the center of a cube to its faces", "[AdaptiveInfillField]")
{
const ExPolygons square{rectangle(0., 0., 20., 20.)};
const TpmsRadialField field = radial_field(square, 20.);
const AdaptiveInfillField 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));
@@ -85,11 +85,11 @@ TEST_CASE("TPMS radial field grows linearly from the center of a cube to its fac
}
}
TEST_CASE("TPMS radial field of a tall box is centered at its middle height", "[FillTpmsAdaptive]")
TEST_CASE("Adaptive infill field of a tall box is centered at its middle height", "[AdaptiveInfillField]")
{
// 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.);
const AdaptiveInfillField 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));
@@ -97,10 +97,10 @@ TEST_CASE("TPMS radial field of a tall box is centered at its middle height", "[
CHECK_THAT(radial(field, {15., 10., 30.}), WithinAbs(0.5, Tolerance));
}
TEST_CASE("TPMS radial field grades every body towards its own center", "[FillTpmsAdaptive]")
TEST_CASE("Adaptive infill field grades every body towards its own center", "[AdaptiveInfillField]")
{
const ExPolygons squares{rectangle(0., 0., 20., 20.), rectangle(30., 0., 50., 20.)};
const TpmsRadialField field = radial_field(squares, 20.);
const AdaptiveInfillField 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));
@@ -108,20 +108,20 @@ TEST_CASE("TPMS radial field grades every body towards its own center", "[FillTp
CHECK_THAT(radial(field, {30., 10., 10.}), WithinAbs(1., Tolerance));
}
TEST_CASE("TPMS radial field is beyond one outside of the object", "[FillTpmsAdaptive]")
TEST_CASE("Adaptive infill field is beyond one outside of the object", "[AdaptiveInfillField]")
{
const ExPolygons square{rectangle(0., 0., 20., 20.)};
const TpmsRadialField field = radial_field(square, 20.);
const AdaptiveInfillField 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]")
TEST_CASE("Adaptive infill field grades every lobe of a body towards its own center", "[AdaptiveInfillField]")
{
// 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;
std::vector<AdaptiveInfillField::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;
@@ -134,7 +134,7 @@ TEST_CASE("TPMS radial field grades every lobe of a body towards its own center"
}
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, [] {});
const AdaptiveInfillField field(slices, get_extents(layers[50]), AdaptiveInfillMode::Lobes, [] {});
for (const Vec3d &c : {c1, c2}) {
CAPTURE(c.x());
@@ -143,7 +143,7 @@ TEST_CASE("TPMS radial field grades every lobe of a body towards its own center"
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;
AdaptiveInfillField::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));
@@ -151,12 +151,12 @@ TEST_CASE("TPMS radial field grades every lobe of a body towards its own center"
}
}
TEST_CASE("TPMS radial field blends the lobes meeting at a junction continuously", "[FillTpmsAdaptive]")
TEST_CASE("Adaptive infill field blends the lobes meeting at a junction continuously", "[AdaptiveInfillField]")
{
// 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;
std::vector<AdaptiveInfillField::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;
@@ -169,7 +169,7 @@ TEST_CASE("TPMS radial field blends the lobes meeting at a junction continuously
}
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, [] {});
const AdaptiveInfillField field(slices, get_extents(layers[50]), AdaptiveInfillMode::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.;
@@ -179,7 +179,7 @@ TEST_CASE("TPMS radial field blends the lobes meeting at a junction continuously
for (int row = 0; row <= 100; ++row) {
std::array<float, 3> previous{};
for (int step = 0; step <= 200; ++step) {
TpmsRadialField::Radials radials;
AdaptiveInfillField::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{};
@@ -201,23 +201,23 @@ TEST_CASE("TPMS radial field blends the lobes meeting at a junction continuously
CHECK(max_jump < 0.05);
}
TEST_CASE("TPMS radial field is empty when the object is thinner than the grid cells", "[FillTpmsAdaptive]")
TEST_CASE("Adaptive infill field is empty when the object is thinner than the grid cells", "[AdaptiveInfillField]")
{
// 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;
std::vector<AdaptiveInfillField::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, [] {});
const AdaptiveInfillField field(slices, BoundingBox(Point::new_scale(0., 0.), Point::new_scale(200., 200.)),
AdaptiveInfillMode::Lobes, [] {});
CHECK(field.empty());
}
TEST_CASE("TPMS depth follows the distance to the surface relative to the deepest point", "[FillTpmsAdaptive]")
TEST_CASE("Adaptive infill depth follows the distance to the surface relative to the deepest point", "[AdaptiveInfillField]")
{
// 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);
const AdaptiveInfillField field = radial_field(square, 60., AdaptiveInfillMode::SmoothBlend);
for (double z : {15., 30., 45.}) {
CAPTURE(z);
CHECK_THAT(field.depth({10., 10., z}), WithinAbs(1., Tolerance));
@@ -227,18 +227,18 @@ TEST_CASE("TPMS depth follows the distance to the surface relative to the deepes
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]")
TEST_CASE("Adaptive infill field in Distance warp mode follows the distance to the surface", "[AdaptiveInfillField]")
{
// 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);
const AdaptiveInfillField cube = radial_field(square, 20., AdaptiveInfillMode::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);
const AdaptiveInfillField lobes = radial_field(square, 60.);
const AdaptiveInfillField warp = radial_field(square, 60., AdaptiveInfillMode::DistanceWarp);
for (double z : {15., 45.}) {
CAPTURE(z);
CHECK_THAT(radial(lobes, {10., 10., z}), WithinAbs(0.5, Tolerance));
@@ -247,12 +247,12 @@ TEST_CASE("TPMS radial field in Distance warp mode follows the distance to the s
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]")
TEST_CASE("Adaptive infill stepped shells split a layer by depth from the surface inwards", "[AdaptiveInfillField]")
{
// 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);
const AdaptiveInfillField field = radial_field(square, 40., AdaptiveInfillMode::SteppedShells);
const std::vector<AdaptiveInfillShell> shells = make_adaptive_infill_shells(field, square.front(), 20., 0.2f, 0.05f, AdaptiveInfillGradient::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));
@@ -270,11 +270,11 @@ TEST_CASE("TPMS stepped shells split a layer by depth from the surface inwards",
[&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]")
TEST_CASE("Adaptive infill field in 2D grades every section normal to the axis on its own", "[AdaptiveInfillField]")
{
// 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);
const AdaptiveInfillField normal_z = radial_field(square, 60., AdaptiveInfillMode::NormalZ);
for (double z : {5., 30., 55.}) {
CAPTURE(z);
CHECK_THAT(radial(normal_z, {10., 10., z}), WithinAbs(0., Tolerance));
@@ -283,7 +283,7 @@ TEST_CASE("TPMS radial field in 2D grades every section normal to the axis on it
}
// 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);
const AdaptiveInfillField normal_x = radial_field(square, 60., AdaptiveInfillMode::NormalX);
for (double x : {3., 10., 17.}) {
CAPTURE(x);
CHECK_THAT(radial(normal_x, {x, 15., 30.}), WithinAbs(0.5, Tolerance));
+101
View File
@@ -3,6 +3,8 @@
#include <catch2/catch_test_macros.hpp>
#include <catch2/generators/catch_generators.hpp>
#include "libslic3r/AppConfig.hpp"
#include "libslic3r/Thread.hpp"
#include "../slic3rutils/plugin_test_utils.hpp"
using namespace Slic3r;
@@ -46,6 +48,105 @@ TEST_CASE("AppConfig network version helpers", "[AppConfig]") {
}
}
TEST_CASE("Local machine agent replacements survive save and reload", "[AppConfig]")
{
ScopedDataDir data_dir("local-machine");
save_main_thread_id();
AppConfig config;
BBLocalMachine saved;
saved.dev_id = "127.0.0.1:7125";
saved.dev_ip = saved.dev_id;
saved.dev_name = "Test printer";
saved.printer_type = "test-model";
saved.printer_agent_id = "orca";
saved.access_code = "old-key";
config.update_local_machine(saved);
config.save();
REQUIRE_FALSE(config.dirty());
BBLocalMachine replacement = saved;
replacement.printer_agent_id = "moonraker";
replacement.access_code = GENERATE("", "new-key", "88888888");
config.update_local_machine(replacement);
REQUIRE(config.dirty());
REQUIRE(config.get_local_machines().at(saved.dev_id) == replacement);
config.save();
AppConfig reloaded;
REQUIRE(reloaded.load().empty());
REQUIRE(reloaded.get_local_machines().at(saved.dev_id) == replacement);
REQUIRE_FALSE(reloaded.dirty());
reloaded.update_local_machine(replacement);
REQUIRE_FALSE(reloaded.dirty());
}
TEST_CASE("Clearing local credentials preserves another agent's record", "[AppConfig]")
{
AppConfig config;
BBLocalMachine saved;
saved.dev_id = "test_dev";
saved.printer_agent_id = GENERATE("orca", "qidi", "bbl", "");
saved.access_code = "saved-key";
config.update_local_machine(saved);
config.set_str("access_code", saved.dev_id, "legacy-key");
config.set_str("user_access_code", saved.dev_id, "legacy-user-key");
config.clear_local_machine_access_code(saved.dev_id, "moonraker");
REQUIRE(config.get_local_machines().at(saved.dev_id) == saved);
REQUIRE(config.get("access_code", saved.dev_id) == "legacy-key");
REQUIRE(config.get("user_access_code", saved.dev_id) == "legacy-user-key");
}
TEST_CASE("Clearing local credentials changes only the owning agent's key", "[AppConfig]")
{
ScopedDataDir data_dir("local-machine-clear");
save_main_thread_id();
AppConfig config;
BBLocalMachine saved;
saved.dev_id = "test_dev";
saved.dev_name = "Test printer";
saved.printer_agent_id = GENERATE("moonraker", "qidi");
saved.access_code = "saved-key";
config.update_local_machine(saved);
config.save();
REQUIRE_FALSE(config.dirty());
config.clear_local_machine_access_code(saved.dev_id, saved.printer_agent_id);
REQUIRE(config.dirty());
config.save();
AppConfig reloaded;
REQUIRE(reloaded.load().empty());
saved.access_code.clear();
REQUIRE(reloaded.get_local_machines().at(saved.dev_id) == saved);
REQUIRE_FALSE(reloaded.dirty());
reloaded.clear_local_machine_access_code(saved.dev_id, saved.printer_agent_id);
reloaded.clear_local_machine_access_code("unknown_dev", saved.printer_agent_id);
REQUIRE(reloaded.get_local_machines().size() == 1);
REQUIRE_FALSE(reloaded.dirty());
}
TEST_CASE("BBL credential clearing removes legacy fallback without changing foreign records", "[AppConfig]")
{
AppConfig config;
BBLocalMachine saved;
saved.dev_id = "test_dev";
saved.printer_agent_id = GENERATE("bbl", "", "moonraker");
saved.access_code = "saved-key";
config.update_local_machine(saved);
config.set_str("access_code", saved.dev_id, "legacy-key");
config.set_str("user_access_code", saved.dev_id, "legacy-user-key");
config.clear_local_machine_access_code(saved.dev_id, GENERATE("bbl", ""));
if (saved.printer_agent_id != "moonraker")
saved.access_code.clear();
REQUIRE(config.get_local_machines().at(saved.dev_id) == saved);
REQUIRE(config.get("access_code", saved.dev_id).empty());
REQUIRE(config.get("user_access_code", saved.dev_id).empty());
}
TEST_CASE("Remembered checkbox settings retain both selections", "[AppConfig][Regression]") {
AppConfig config;
const bool checked = GENERATE(false, true);
+63 -13
View File
@@ -261,17 +261,67 @@ TEST_CASE("Arrange without final alignment keeps items disjoint", "[Arrange]")
require_no_overlap(items);
}
TEST_CASE("Arrange aligns the pile to a custom center", "[Arrange]")
TEST_CASE("Arrange centers a pile that fits on the custom alignment point", "[Arrange]")
{
// align_center != (0.5, 0.5) selects Alignment::USER_DEFINED.
ArrangePolygons items = squares(5, 30.);
ArrangePolygons items = squares(4, 30.);
ArrangeParams params = quiet_params(scaled(2.));
params.align_center = Vec2d(0.3, 0.7);
arrange(items, bed(250, 250), params);
for (const ArrangePolygon &ap : items)
BoundingBox pile;
for (const ArrangePolygon &ap : items) {
REQUIRE(ap.bed_idx == 0);
pile.merge(ap.transformed_poly().contour.bounding_box());
}
// best_object_pos is align_center scaled to the bed.
const Point expected(scaled(250. * 0.3), scaled(250. * 0.7));
REQUIRE(std::abs(pile.center().x() - expected.x()) <= scaled(0.5));
REQUIRE(std::abs(pile.center().y() - expected.y()) <= scaled(0.5));
require_no_overlap(items);
}
// A row of four 40 mm parts centred on the 30% mark of a 200 x 50 mm bed would start 24 mm
// left of it (#15727). The pile stops at the left edge instead.
TEST_CASE("Arrange keeps a custom-aligned pile within the bed", "[Arrange]")
{
const BoundingBox bed_ = bed(200, 50);
ArrangePolygons items = squares(4, 40.);
ArrangeParams params = quiet_params(scaled(2.));
params.align_center = Vec2d(0.3, 0.5);
arrange(items, bed_, params);
coord_t leftmost = 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(bed_.contains(bb));
leftmost = std::min(leftmost, bb.min.x());
}
// Stopped at the edge it was aimed at, not re-centred.
CHECK(leftmost < scaled(10.));
require_no_overlap(items);
}
// Arranging a selection leaves the unselected parts where they are, but the final alignment
// centres the box around both, so the selection does not move by its own centring. The check
// against the bed and the fixed part must test where the selection actually lands.
TEST_CASE("Arrange keeps a selection on the bed and clear of a fixed part", "[Arrange]")
{
ArrangePolygons fixed = squares(1, 40.);
fixed.front().translation = {scaled(80.), scaled(80.)};
ArrangePolygons items = squares(3, 40.);
for (ArrangePolygon &ap : items)
ap.inflation = scaled(1.);
arrange(items, fixed, bed(200, 200), quiet_params());
for (const ArrangePolygon &ap : items) {
REQUIRE(ap.bed_idx == 0);
REQUIRE(bed(200, 200).contains(ap.transformed_poly().contour.bounding_box()));
}
items.insert(items.end(), fixed.begin(), fixed.end());
require_no_overlap(items);
}
@@ -303,10 +353,9 @@ TEST_CASE("Arrange keeps a pile aligned near an edge on the bed", "[Arrange][bel
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]")
// Flat-bed printers with an off-centre best_object_pos (the A1 mini and the H2 family) get
// the same clamp: centring the pile on that point used to push it off the bed (#15727).
TEST_CASE("Arrange keeps a flat-bed pile aligned near an edge on the bed", "[Arrange]")
{
const BoundingBox bed_ = bed(95, 500);
ArrangePolygons items = squares(4, 90.);
@@ -315,14 +364,15 @@ TEST_CASE("Arrange leaves the final alignment of a flat bed unclamped", "[Arrang
arrange(items, bed_, params);
BoundingBox pile;
coord_t lowest = std::numeric_limits<coord_t>::max();
for (const ArrangePolygon &ap : items) {
REQUIRE(ap.bed_idx == 0);
pile.merge(ap.transformed_poly().contour.bounding_box());
const BoundingBox bb = ap.transformed_poly().contour.bounding_box();
CHECK(bed_.contains(bb));
lowest = std::min(lowest, bb.min.y());
}
// 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);
// Stopped at the edge it was aimed at, not re-centred.
CHECK(lowest < scaled(10.));
require_no_overlap(items);
}
+13
View File
@@ -1457,6 +1457,19 @@ TEST_CASE("min_object_distance yields no floor when an FFF config lacks the opti
}
}
TEST_CASE("handle_legacy migrates the adaptive TPMS keys", "[Config]") {
ConfigSubstitutionContext ctxt(ForwardCompatibilitySubstitutionRule::Enable);
DynamicPrintConfig config;
config.set_deserialize("tpms_adaptive", "stepped_shells", ctxt);
config.set_deserialize("tpms_interior_density", "12%", ctxt);
config.set_deserialize("tpms_adaptive_gradient", "quadratic", ctxt);
CHECK(config.opt_enum<AdaptiveInfillMode>("adaptive_infill_mode") == AdaptiveInfillMode::SteppedShells);
CHECK(config.option<ConfigOptionPercent>("adaptive_infill_interior_density")->value == Catch::Approx(12.));
CHECK(config.opt_enum<AdaptiveInfillGradient>("adaptive_infill_gradient") == AdaptiveInfillGradient::Quadratic);
CHECK(ctxt.substitutions.empty());
}
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
+1 -1
View File
@@ -283,7 +283,7 @@ TEST_CASE("A Span mode refuses a color routed to a tool outside the primary's ga
auto pem = make_pem({0, 1, 2, 3});
const std::string reason = imex_multicolor_block_reason("mc-copy", "0:P,1:S,2:C,3:C", 2, {0, 1, 3}, pem);
// The message names the color tools and where the map sends the offending filament (1-based).
REQUIRE_THAT(reason, Catch::Matchers::ContainsSubstring("only T0/T1 print colors, but filament 4 is routed to T3"));
REQUIRE_THAT(reason, Catch::Matchers::ContainsSubstring("colors are printed only by T0/T1, but filament 4 is routed to T3"));
REQUIRE_THAT(imex_multicolor_block_reason("mc-copy", "0:P,1:S,2:C,3:C", 2, {0, 4}, pem),
Catch::Matchers::ContainsSubstring("filament 5 isn't routed to any tool"));
// A head the mode leaves inactive is no better, and neither is no head at all.
+255
View File
@@ -0,0 +1,255 @@
#include <catch2/catch_test_macros.hpp>
#include <catch2/matchers/catch_matchers.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp>
#include <algorithm>
#include <cmath>
#include <cstddef>
#include <string>
#include <utility>
#include <vector>
#include <libslic3r/MeshBoolean.hpp>
#include <libslic3r/MeshRepair.hpp>
#include <libslic3r/Point.hpp>
#include <libslic3r/libslic3r.h>
#include <libslic3r/TriangleMesh.hpp>
using namespace Slic3r;
using Catch::Matchers::WithinRel;
namespace {
indexed_triangle_set cube(double size, const Vec3d &offset = Vec3d::Zero())
{
TriangleMesh mesh = make_cube(size, size, size);
mesh.translate(offset.cast<float>());
return mesh.its;
}
indexed_triangle_set merged(const std::vector<indexed_triangle_set> &parts)
{
indexed_triangle_set out;
for (const indexed_triangle_set &part : parts)
its_merge(out, part);
return out;
}
// Every triangle with its own three vertices, as some exporters write meshes.
indexed_triangle_set soup(const indexed_triangle_set &its)
{
indexed_triangle_set out;
for (const stl_triangle_vertex_indices &f : its.indices) {
const int first = int(out.vertices.size());
for (int k = 0; k < 3; ++k)
out.vertices.push_back(its.vertices[f[k]]);
out.indices.emplace_back(first, first + 1, first + 2);
}
return out;
}
double regular_polygon_area(int sides, double radius) { return 0.5 * sides * radius * radius * std::sin(2. * PI / sides); }
// Closed by vertex indices, outward oriented and free of self-intersections.
void check_solid(const TriangleMesh &mesh, double volume)
{
CHECK(its_num_open_edges(mesh.its) == 0);
CHECK_THAT(its_volume(mesh.its), WithinRel(volume, 1e-4));
CHECK_FALSE(MeshBoolean::cgal::does_self_intersect(mesh));
}
} // namespace
TEST_CASE("A triangle soup repairs into the solid it describes", "[MeshRepair]")
{
TriangleMesh mesh(soup(cube(20.)));
MeshRepairStats stats;
REQUIRE(repair_mesh(mesh, &stats));
CHECK(stats.modified);
check_solid(mesh, 20. * 20. * 20.);
CHECK(mesh.its.vertices.size() == 8);
}
TEST_CASE("Flipped triangles take the orientation of their neighbours", "[MeshRepair]")
{
indexed_triangle_set its = cube(20.);
std::swap(its.indices[1][1], its.indices[1][2]);
std::swap(its.indices[5][1], its.indices[5][2]);
TriangleMesh mesh(std::move(its));
REQUIRE(repair_mesh(mesh));
check_solid(mesh, 20. * 20. * 20.);
}
TEST_CASE("An inside-out mesh is turned outward", "[MeshRepair]")
{
indexed_triangle_set its = cube(20.);
its_flip_triangles(its);
TriangleMesh mesh(std::move(its));
REQUIRE(repair_mesh(mesh));
check_solid(mesh, 20. * 20. * 20.);
}
TEST_CASE("Holes are filled", "[MeshRepair]")
{
indexed_triangle_set its = cube(20.);
its.indices.erase(its.indices.begin(), its.indices.begin() + 3);
TriangleMesh mesh(std::move(its));
MeshRepairStats stats;
REQUIRE(repair_mesh(mesh, &stats));
CHECK(stats.filled_holes > 0);
check_solid(mesh, 20. * 20. * 20.);
}
TEST_CASE("Cracks between triangles that should share corners are closed", "[MeshRepair]")
{
indexed_triangle_set its = soup(cube(20.));
// Shift the corners of two triangles by far less than a micron, as float rounding in an exporter would.
for (std::size_t v = 0; v < 6; ++v)
its.vertices[v] += Vec3f(1e-5f, -1e-5f, 1e-5f);
TriangleMesh mesh(std::move(its));
MeshRepairStats stats;
REQUIRE(repair_mesh(mesh, &stats));
CHECK(stats.welded_vertices > 0);
check_solid(mesh, 20. * 20. * 20.);
}
TEST_CASE("A T-junction is closed", "[MeshRepair]")
{
// The top of the cube is split at the middle of its front edge, which the front side does not share.
indexed_triangle_set its;
its.vertices = {{0, 0, 0}, {20, 0, 0}, {20, 20, 0}, {0, 20, 0}, {0, 0, 20}, {20, 0, 20}, {20, 20, 20}, {0, 20, 20}, {10, 0, 20}};
its.indices = {{0, 2, 1}, {0, 3, 2}, {4, 8, 7}, {8, 6, 7}, {8, 5, 6}, {0, 1, 5}, {0, 5, 4},
{1, 2, 6}, {1, 6, 5}, {2, 3, 7}, {2, 7, 6}, {3, 0, 4}, {3, 4, 7}};
TriangleMesh mesh(std::move(its));
REQUIRE(repair_mesh(mesh));
check_solid(mesh, 20. * 20. * 20.);
}
TEST_CASE("Crossing shells stay separate parts and are made solid", "[MeshRepair]")
{
indexed_triangle_set inside_out = cube(20., {10., 10., 10.});
its_flip_triangles(inside_out);
TriangleMesh mesh(merged({cube(20.), inside_out}));
MeshRepairStats stats;
REQUIRE(repair_mesh(mesh, &stats));
CHECK(stats.reversed_shells == 1);
CHECK(its_num_open_edges(mesh.its) == 0);
CHECK_THAT(its_volume(mesh.its), WithinRel(2 * 8000., 1e-4));
CHECK(mesh.split().size() == 2);
}
TEST_CASE("Solids touching face to face in a soup lose the wall between them", "[MeshRepair]")
{
// The mirrored copy shares the wall at x = 10 with exactly the same triangles, reversed.
TriangleMesh right = make_cube(10., 10., 10.);
right.mirror(X);
right.translate(20.f, 0.f, 0.f);
TriangleMesh mesh(soup(merged({cube(10.), right.its})));
REQUIRE(repair_mesh(mesh));
check_solid(mesh, 2 * 1000.);
CHECK(mesh.split().size() == 1);
}
TEST_CASE("A shell inside a solid follows the nonzero winding rule of slicing", "[MeshRepair]")
{
SECTION("Oriented like the outer shell, it adds nothing and is removed")
{
TriangleMesh mesh(merged({cube(20.), cube(10., {5., 5., 5.})}));
REQUIRE(repair_mesh(mesh));
check_solid(mesh, 8000.);
CHECK(mesh.its.indices.size() == 12);
}
SECTION("Oriented inward, it is a cavity and stays")
{
indexed_triangle_set cavity = cube(10., {5., 5., 5.});
its_flip_triangles(cavity);
TriangleMesh mesh(merged({cube(20.), cavity}));
MeshRepairStats stats;
REQUIRE(repair_mesh(mesh, &stats));
CHECK_FALSE(stats.modified);
CHECK_THAT(its_volume(mesh.its), WithinRel(8000. - 1000., 1e-4));
}
}
TEST_CASE("A pipe missing its end caps gets annular caps", "[MeshRepair]")
{
const int n = 64;
const double h = 20., r_out = 10., r_in = 7.;
indexed_triangle_set its;
for (const double r : {r_out, r_in})
for (int k = 0; k < n; ++k) {
const double a = 2. * PI * k / n;
its.vertices.emplace_back(float(r * std::cos(a)), float(r * std::sin(a)), 0.f);
its.vertices.emplace_back(float(r * std::cos(a)), float(r * std::sin(a)), float(h));
}
for (int k = 0; k < n; ++k) {
const int a0 = 2 * k, b0 = a0 + 1, a1 = 2 * ((k + 1) % n), b1 = a1 + 1;
its.indices.emplace_back(a0, a1, b1);
its.indices.emplace_back(a0, b1, b0);
its.indices.emplace_back(2 * n + a0, 2 * n + b1, 2 * n + a1);
its.indices.emplace_back(2 * n + a0, 2 * n + b0, 2 * n + b1);
}
TriangleMesh mesh(std::move(its));
REQUIRE(repair_mesh(mesh));
check_solid(mesh, h * (regular_polygon_area(n, r_out) - regular_polygon_area(n, r_in)));
}
TEST_CASE("A stray flat triangle is dropped", "[MeshRepair]")
{
indexed_triangle_set its = cube(20.);
its.vertices.insert(its.vertices.end(), {{30, 0, 0}, {40, 0, 0}, {30, 10, 0}});
its.indices.emplace_back(8, 9, 10);
TriangleMesh mesh(std::move(its));
MeshRepairStats stats;
REQUIRE(repair_mesh(mesh, &stats));
CHECK(stats.removed_shells == 1);
check_solid(mesh, 8000.);
}
TEST_CASE("A two-sided export keeps one side of each triangle", "[MeshRepair]")
{
indexed_triangle_set back = cube(20.);
its_flip_triangles(back);
TriangleMesh mesh(soup(merged({cube(20.), back})));
REQUIRE(repair_mesh(mesh));
check_solid(mesh, 8000.);
}
TEST_CASE("A valid mesh is left untouched", "[MeshRepair]")
{
TriangleMesh mesh = make_sphere(10., 2. * PI / 36.);
const TriangleMesh original = mesh;
MeshRepairStats stats;
REQUIRE(repair_mesh(mesh, &stats));
CHECK_FALSE(stats.modified);
CHECK(mesh.its.vertices == original.its.vertices);
CHECK(mesh.its.indices == original.its.indices);
}
TEST_CASE("A mesh enclosing no volume is reported and left untouched", "[MeshRepair]")
{
indexed_triangle_set its;
its.vertices = {{0, 0, 0}, {10, 0, 0}, {10, 10, 0}, {0, 10, 0}};
its.indices = {{0, 1, 2}, {0, 2, 3}};
TriangleMesh mesh(its);
std::string error;
CHECK_FALSE(repair_mesh(mesh, nullptr, &error));
CHECK_FALSE(error.empty());
CHECK(mesh.its.indices.size() == 2);
}
TEST_CASE("Progress is reported in order and throwing from it cancels the repair", "[MeshRepair]")
{
TriangleMesh mesh(soup(cube(20.)));
std::vector<int> reported;
REQUIRE(repair_mesh(mesh, nullptr, nullptr, [&reported](int percent) { reported.push_back(percent); }));
CHECK_FALSE(reported.empty());
CHECK(std::is_sorted(reported.begin(), reported.end()));
struct Canceled {};
TriangleMesh canceled(soup(cube(20.)));
const TriangleMesh original = canceled;
CHECK_THROWS_AS(repair_mesh(canceled, nullptr, nullptr, [](int) { throw Canceled(); }), Canceled);
CHECK(canceled.its.vertices == original.its.vertices);
CHECK(canceled.its.indices == original.its.indices);
}
+50
View File
@@ -18,6 +18,7 @@
#include <fstream>
#include <ios>
#include <iterator>
#include <libslic3r/Utils.hpp>
#include <miniz.h>
#include <string>
#include <utility>
@@ -176,6 +177,55 @@ TEST_CASE("Confined extraction writes nothing outside the target for Windows-spe
CHECK(list_dir(tmp.path()) == std::vector<std::string>{"bundle.zip", "cache"});
}
TEST_CASE("Confined extraction writes files under a target path longer than MAX_PATH", "[MinizExtension]")
{
ScopedTemporaryDir tmp;
const fs::path zip_file = tmp.path() / "bundle.zip";
// Directory entries are explicit: extract_archive_confined recreates directories only for them.
write_zip(zip_file, {{"vendor/", ""}, {"vendor/machine/", ""}, {"vendor.json", "{\"a\":1}"},
{"vendor/machine/printer.json", "{\"b\":2}"}});
// One 240-char component keeps every name below the NTFS 255 limit while pushing the full
// target path past the 260-char MAX_PATH the Win32 APIs enforce when LongPathsEnabled is off.
const fs::path target = tmp.path() / std::string(240, 'd');
REQUIRE(target.string().size() > 260);
fs::create_directories(extended_path(target));
REQUIRE(extract_archive_confined(zip_file.string(), target.string()));
// Verify through extended-length paths, otherwise the checks themselves are bounded by MAX_PATH.
CHECK(fs::is_directory(extended_path(target / "vendor")));
CHECK(read_file(extended_path(target / "vendor.json")) == "{\"a\":1}");
CHECK(read_file(extended_path(target / "vendor" / "machine" / "printer.json")) == "{\"b\":2}");
// The temporary dir guard cannot remove a deep tree on Windows, so drop it here.
boost::system::error_code ec;
fs::remove_all(extended_path(target), ec);
CHECK_FALSE(ec);
}
TEST_CASE("A zip archive under a path longer than MAX_PATH can be written and read", "[MinizExtension]")
{
ScopedTemporaryDir tmp;
// Create the long directory with the extended-length path, then exercise the archive
// reader/writer against an archive whose own path is past MAX_PATH.
const fs::path long_dir = tmp.path() / std::string(240, 'z');
fs::create_directories(extended_path(long_dir));
const fs::path zip_file = long_dir / "bundle.zip";
REQUIRE(zip_file.string().size() > 260);
write_zip(zip_file, {{"vendor.json", "{\"a\":1}"}});
const fs::path target = tmp.path() / "out";
fs::create_directories(target);
REQUIRE(extract_archive_confined(zip_file.string(), target.string()));
CHECK(read_file(target / "vendor.json") == "{\"a\":1}");
boost::system::error_code ec;
fs::remove_all(extended_path(long_dir), ec);
CHECK_FALSE(ec);
}
#ifndef _WIN32
TEST_CASE("Confined extraction replaces a symlink at the destination instead of writing through it", "[MinizExtension]")
{
+103
View File
@@ -12,7 +12,9 @@
#include <catch2/generators/catch_generators.hpp>
#include <catch2/matchers/catch_matchers.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp>
#include "libslic3r/MeshRepair.hpp"
#include "libslic3r/Model.hpp"
#include "libslic3r/ObjectID.hpp"
#include "libslic3r/Geometry.hpp"
#include "libslic3r/libslic3r.h"
@@ -97,3 +99,104 @@ TEST_CASE("An instance added from another's scale, rotation and mirror matches i
original->get_rotation(), original->get_mirror());
CHECK(copy->get_matrix().linear().isApprox(original->get_matrix().linear(), 1e-9));
}
namespace {
// A flat triangle first, then a cube for each offset, all in one mesh.
TriangleMesh flat_triangle_then_cubes(const std::vector<double> &cube_offsets)
{
indexed_triangle_set its;
its.vertices = {{-20.f, 0.f, 0.f}, {-10.f, 0.f, 0.f}, {-15.f, 5.f, 0.f}};
its.indices = {{0, 1, 2}};
for (double offset : cube_offsets) {
TriangleMesh cube = make_cube(10, 10, 10);
cube.translate(float(offset), 0.f, 0.f);
its_merge(its, cube.its);
}
return TriangleMesh(its);
}
} // namespace
TEST_CASE("Splitting a part drops pieces enclosing no volume and keeps the part itself", "[Model]")
{
Model model;
ModelObject *object = model.add_object();
ModelVolume *volume = object->add_volume(flat_triangle_then_cubes({0., 30.}), ModelVolumeType::MODEL_PART, false);
REQUIRE(volume->is_splittable());
CHECK(volume->split(1, false) == 2);
REQUIRE(object->volumes.size() == 2);
CHECK(std::find(object->volumes.begin(), object->volumes.end(), volume) != object->volumes.end());
for (const ModelVolume *part : object->volumes) {
CHECK(part->mesh().facets_count() == 12);
CHECK_FALSE(part->is_splittable());
}
}
TEST_CASE("Splitting a part with a single solid piece keeps just that piece", "[Model]")
{
Model model;
ModelObject *object = model.add_object();
ModelVolume *volume = object->add_volume(flat_triangle_then_cubes({0.}), ModelVolumeType::MODEL_PART, false);
CHECK(volume->split(1, false) == 1);
REQUIRE(object->volumes.size() == 1);
CHECK(object->volumes.front() == volume);
CHECK(volume->mesh().facets_count() == 12);
}
TEST_CASE("Splitting a part made only of pieces enclosing no volume leaves it whole", "[Model]")
{
Model model;
ModelObject *object = model.add_object();
indexed_triangle_set its;
its.vertices = {{0.f, 0.f, 0.f}, {10.f, 0.f, 0.f}, {5.f, 5.f, 0.f}, {20.f, 0.f, 0.f}, {30.f, 0.f, 0.f}, {25.f, 5.f, 0.f}};
its.indices = {{0, 1, 2}, {3, 4, 5}};
ModelVolume *volume = object->add_volume(TriangleMesh(its), ModelVolumeType::MODEL_PART, false);
CHECK(volume->split(1, false) == 1);
REQUIRE(object->volumes.size() == 1);
CHECK(object->volumes.front() == volume);
CHECK(volume->mesh().facets_count() == 2);
}
TEST_CASE("Replacing a part's mesh updates whether it can be split", "[Model]")
{
Model model;
ModelObject *object = model.add_object();
TriangleMesh two_cubes = make_cube(10, 10, 10);
TriangleMesh second = make_cube(10, 10, 10);
second.translate(30.f, 0.f, 0.f);
two_cubes.merge(second);
ModelVolume *volume = object->add_volume(std::move(two_cubes), ModelVolumeType::MODEL_PART, false);
REQUIRE(volume->is_splittable());
volume->set_mesh(make_cube(10, 10, 10));
CHECK_FALSE(volume->is_splittable());
}
TEST_CASE("Repairing a part replaces its mesh only when the repair changes it", "[Model]")
{
Model model;
ModelObject *object = model.add_object();
TriangleMesh flipped = make_cube(10, 10, 10);
std::swap(flipped.its.indices.front()[1], flipped.its.indices.front()[2]);
ModelVolume *broken = object->add_volume(std::move(flipped), ModelVolumeType::MODEL_PART, false);
TriangleMesh cube = make_cube(10, 10, 10);
cube.translate(30.f, 0.f, 0.f);
ModelVolume *valid = object->add_volume(std::move(cube), ModelVolumeType::MODEL_PART, false);
const ObjectID broken_id = broken->id();
const ObjectID valid_id = valid->id();
MeshRepairStats stats;
REQUIRE(broken->repair(false, &stats));
CHECK(stats.modified);
CHECK(broken->id() != broken_id);
CHECK_THAT(its_volume(broken->mesh().its), Catch::Matchers::WithinRel(1000., 1e-6));
CHECK_FALSE(broken->get_convex_hull().empty());
REQUIRE(valid->repair(false, &stats));
CHECK_FALSE(stats.modified);
CHECK(valid->id() == valid_id);
}
File diff suppressed because it is too large. Load diff
+21
View File
@@ -4,6 +4,7 @@
#include <string>
#include <ios>
#include <catch2/catch_message.hpp>
#include <catch2/catch_test_macros.hpp>
#include <catch2/generators/catch_generators.hpp>
#include "libslic3r/Model.hpp"
@@ -91,6 +92,26 @@ TEST_CASE("A cone with a slightly tilted seam imports as a closed mesh", "[Step]
CHECK(its_num_open_edges(model.objects.front()->volumes.front()->mesh().its) == 0);
}
// The fixture holds two trimmed planes in a geometric set and no shells or solids, as some CAD exporters write
// surface models, so OCCT reads it as a compound of loose faces.
TEST_CASE("A surface model imports whether or not compounds are split", "[Step]")
{
const std::string path = std::string(TEST_DATA_DIR) + PATH_SEPARATOR "surface_model_geometric_set.step";
const bool split = GENERATE(false, true);
CAPTURE(split);
Model model;
bool cancel = false;
Step step(path);
REQUIRE(step.load() == Step::Step_Status::LOAD_SUCCESS);
REQUIRE(step.mesh(&model, cancel, split) == Step::Step_Status::MESH_SUCCESS);
REQUIRE(model.objects.size() == 1);
REQUIRE(model.objects.front()->volumes.size() == 1);
CHECK(model.objects.front()->volumes.front()->mesh().facets_count() > 0);
}
TEST_CASE("isUtf8 recognises two, three and four byte sequences", "[Step]")
{
CHECK(StepPreProcessor::isUtf8("\xC3\xA9")); // U+00E9
File diff suppressed because it is too large. Load diff
+46
View File
@@ -521,3 +521,49 @@ TEST_CASE("ascii_iequals compares ASCII letters regardless of case", "[Utils]")
CHECK_FALSE(ascii_iequals("[", "{"));
CHECK_FALSE(ascii_iequals("@", "`"));
}
TEST_CASE("extended_path turns a Windows path into its long-path form", "[Utils]") {
#ifdef _WIN32
namespace fs = boost::filesystem;
SECTION("a normal absolute drive path is prefixed") {
CHECK(extended_path(fs::path(R"(C:\models\model.3mf)")).wstring() == LR"(\\?\C:\models\model.3mf)");
}
SECTION("separators are made native") {
CHECK(extended_path(fs::path("C:/models/model.3mf")).wstring() == LR"(\\?\C:\models\model.3mf)");
}
SECTION("dot segments are collapsed before prefixing") {
// The "\\?\" prefix disables Win32 normalisation, so "." and ".." must be gone beforehand.
CHECK(extended_path(fs::path(R"(C:\models\..\model.3mf)")).wstring() == LR"(\\?\C:\model.3mf)");
}
SECTION("a trailing separator is dropped, not turned into a dot element") {
CHECK(extended_path(fs::path(R"(C:\models\)")).wstring() == LR"(\\?\C:\models)");
}
SECTION("a UNC path becomes an extended UNC path") {
CHECK(extended_path(fs::path(R"(\\server\share\model.3mf)")).wstring() == LR"(\\?\UNC\server\share\model.3mf)");
}
SECTION("an already extended path is returned unchanged") {
const fs::path extended(R"(\\?\C:\models\model.3mf)");
CHECK(extended_path(extended) == extended);
}
SECTION("a device path is returned unchanged") {
const fs::path device(R"(\\.\C:\models\model.3mf)");
CHECK(extended_path(device) == device);
}
SECTION("a bare device path without a root directory is returned unchanged") {
// Not "absolute" by Boost's rules, so it must be recognized before absolutizing.
const fs::path device(R"(\\.\PhysicalDrive0)");
CHECK(extended_path(device) == device);
}
SECTION("a relative path is completed against the working directory") {
const fs::path result = extended_path(fs::path("model.3mf"));
CHECK(result.wstring().rfind(L"\\\\?\\", 0) == 0);
CHECK(result.filename() == fs::path("model.3mf"));
}
#else
SECTION("non-Windows returns the path unchanged") {
const boost::filesystem::path p = boost::filesystem::temp_directory_path() / "model.3mf";
CHECK(extended_path(p) == p);
}
#endif
}
@@ -133,6 +133,28 @@ TEST_CASE("Type1 sizes the tower from each filament's own prime volume", "[WipeT
CHECK_THAT(rib.depth, WithinAbs(WipeTower::estimate_rib_tower_bbox_side({{30.f, 0}, {45.f, 0}}, 35.f, 0.21f, 0.4f, 1.5f, 8.f, 0.f, 5.f), 1e-4));
}
TEST_CASE("A mixed slot is purged as its physical components", "[WipeTowerEstimate]") {
// Slots 4 and 5 blend 1+2 and 1+3: every layer prints one component per mixed slot, so a
// plate using all five slots purges the three physical filaments and nothing more.
const WipeTowerType type = GENERATE(WipeTowerType::Type1, WipeTowerType::Type2);
DynamicPrintConfig config = make_config();
config.set_key_value("filament_prime_volume", new ConfigOptionFloats({100., 100., 100., 100., 100.}));
config.set_key_value("filament_adhesiveness_category", new ConfigOptionInts({0, 0, 0, 0, 0}));
config.set_key_value("filament_is_mixed", new ConfigOptionBools(std::vector<unsigned char>{0, 0, 0, 1, 1}));
config.set_key_value("filament_mixed_components", new ConfigOptionStrings({"", "", "", "1,2", "1,3"}));
const double physical = estimate(config, 3, 0.2, 5., type).depth;
REQUIRE(physical > estimate(config, 2, 0.2, 5., type).depth);
CHECK_THAT(estimate(config, 5, 0.2, 5., type).depth, WithinAbs(physical, 1e-9));
// The same slots with the mixed flags cleared are five filaments, which is what sized the
// tower before and refused positions the printed tower fits.
DynamicPrintConfig unmixed = config;
unmixed.set_key_value("filament_is_mixed", new ConfigOptionBools(std::vector<unsigned char>{0, 0, 0, 0, 0}));
CHECK(estimate(unmixed, 5, 0.2, 5., type).depth > physical);
// Mixed slots alone still purge their components, and a single one already changes tools.
CHECK_THAT(estimate_wipe_tower_footprint(config, type, {3, 4}, 0.2, 5.).depth, WithinAbs(physical, 1e-9));
CHECK_THAT(estimate_wipe_tower_footprint(config, type, {3}, 0.2, 5.).depth, WithinAbs(estimate(config, 2, 0.2, 5., type).depth, 1e-9));
}
TEST_CASE("A second nozzle adds the ramming of one nozzle change per layer", "[WipeTowerEstimate]") {
// Two filaments on two nozzles: the tool order crosses once per layer, and Type1 rams 10 mm
// of filament as three 1.0 mm nozzle-change lines (see the WipeTower case).
+9
View File
@@ -5,7 +5,12 @@ add_executable(${_TEST_NAME}_tests
test_config_value_formatter.cpp
test_creality_cfs_match.cpp
test_dev_mapping.cpp
test_device_access.cpp
test_moonraker_printer_agent.cpp
test_network_discovery.cpp
test_filament_bitmap_utils.cpp
test_colour_picker_field.cpp
test_filament_color_picker.cpp
test_device_progress.cpp
test_device_manager_integration.cpp
test_web_media_controller.cpp
@@ -27,6 +32,7 @@ add_executable(${_TEST_NAME}_tests
test_printer_agent.cpp
test_plugin_install.cpp
test_plugin_lifecycle.cpp
test_plugin_resource_texture.cpp
test_plugin_printer_agent.cpp
test_printhost.cpp
test_slicing_pipeline_bindings.cpp
@@ -37,8 +43,11 @@ add_executable(${_TEST_NAME}_tests
test_plugin_json_depth.cpp
test_shortcuts.cpp
test_texture_color_palette.cpp
test_texture_island_view.cpp
test_file_url.cpp
test_imgui_wrapper.cpp
test_user_manager.cpp
test_sequential_clearance.cpp
../fff_print/test_helpers.cpp
)
+1 -1
View File
@@ -255,7 +255,7 @@ TEST_CASE("Native command catalog includes the Help and developer-mode commands"
const Slic3r::GUI::NativeCommand* first = find("help_keyboard_shortcuts");
REQUIRE(first != nullptr);
for (const char* key : {"help_setup_wizard", "help_open_config_folder", "help_troubleshoot", "help_network_test",
for (const char* key : {"help_setup_wizard", "help_open_config_folder", "help_migrate_filament_presets", "help_troubleshoot", "help_network_test",
"help_benchmark_3d_scene", "help_tip_of_the_day", "help_check_updates", "help_about", "open_wiki",
"open_youtube"}) {
const Slic3r::GUI::NativeCommand* c = find(key);
@@ -0,0 +1,47 @@
#include "slic3r/GUI/Field.hpp"
#include "libslic3r/Config.hpp"
#include <boost/any.hpp>
#include <catch2/catch_test_macros.hpp>
#include <string>
#include <wx/colour.h>
#include <wx/string.h>
using namespace Slic3r;
using namespace Slic3r::GUI;
namespace {
class TestColourPicker : public ColourPicker {
public:
TestColourPicker() : ColourPicker(ConfigOptionDef{}, "filament_colour") {}
std::string value() { return boost::any_cast<std::string>(get_value()); }
};
}
TEST_CASE("Undefined color fields remain distinct from opaque black", "[ColourPickerField]")
{
TestColourPicker field;
REQUIRE(field.value().empty());
field.set_value(std::string("#000000"));
REQUIRE(field.value() == "#000000");
field.set_value(std::string("invalid color"));
REQUIRE(field.value().empty());
field.set_value(boost::any(wxString("#123456")));
REQUIRE(field.value() == "#123456");
field.set_value(std::string());
REQUIRE(field.value().empty());
}
TEST_CASE("Programmatic color field assignment stays silent", "[ColourPickerField]")
{
TestColourPicker field;
int changes = 0;
field.m_on_change = [&](const std::string&, const boost::any&) { ++changes; };
field.set_value(std::string("#123456"), false);
field.set_value(boost::any(wxString("#654321")), true);
REQUIRE(changes == 0);
REQUIRE(field.value() == "#654321");
field.m_disable_change_event = true;
field.set_value(std::string("#FF0000"));
REQUIRE(field.value() == "#FF0000");
REQUIRE(changes == 0);
}
+128
View File
@@ -0,0 +1,128 @@
#ifdef WIN32
#ifndef WIN32_LEAN_AND_MEAN
#define WIN32_LEAN_AND_MEAN
#endif
#ifndef NOMINMAX
#define NOMINMAX
#endif
#include <Windows.h>
#endif
#include <catch2/catch_test_macros.hpp>
#include <catch2/generators/catch_generators.hpp>
#include <memory>
#include "libslic3r/AppConfig.hpp"
#include "slic3r/GUI/DeviceCore/DevManager.h"
#include "slic3r/GUI/DeviceManager.hpp"
#include "slic3r/Utils/IPrinterAgent.hpp"
#include "slic3r/Utils/NetworkAgentFactory.hpp"
#include "slic3r/Utils/MoonrakerPrinterAgent.hpp"
#include "slic3r/Utils/NetworkAgent.hpp"
using namespace Slic3r;
TEST_CASE("Moonraker permits an empty access code", "[DeviceAccess]")
{
MachineObject machine(nullptr, nullptr, "test", "test_dev", "127.0.0.1");
machine.printer_agent_id = MOONRAKER_PRINTER_AGENT_ID;
machine.set_access_code("", false);
REQUIRE(machine.has_access_right());
}
TEST_CASE("Moonraker logout revokes access until explicitly rebound", "[DeviceAccess]")
{
MachineObject machine(nullptr, nullptr, "test", "test_dev", "127.0.0.1");
machine.printer_agent_id = MOONRAKER_PRINTER_AGENT_ID;
const std::string code = GENERATE("", "configured-key");
machine.set_access_code(code, false);
REQUIRE(machine.has_access_right());
machine.revoke_access();
REQUIRE(machine.get_access_code().empty());
REQUIRE_FALSE(machine.has_access_right());
machine.set_access_code(code, false);
REQUIRE(machine.has_access_right());
REQUIRE(machine.get_access_code() == code);
}
TEST_CASE("Revoked access prevents calls to the printer agent", "[DeviceAccess]")
{
class RecordingAgent : public MoonrakerPrinterAgent
{
public:
RecordingAgent() : MoonrakerPrinterAgent("") {}
int connections = 0;
int connect_printer(const PrinterConnectionParams&) override
{
++connections;
return 0;
}
int disconnect_printer() override { return 0; }
};
auto agent = std::make_shared<RecordingAgent>();
NetworkAgent network(nullptr, agent);
MachineObject machine(nullptr, &network, "test", "test_dev", "127.0.0.1");
machine.printer_agent_id = GENERATE(BBL_PRINTER_AGENT_ID, ORCA_PRINTER_AGENT_ID, MOONRAKER_PRINTER_AGENT_ID);
machine.set_access_code("configured-key", false);
REQUIRE(machine.connect() == 0);
REQUIRE(agent->connections == 1);
machine.revoke_access();
REQUIRE(machine.connect() == -1);
REQUIRE(agent->connections == 1);
machine.set_access_code("configured-key", false);
REQUIRE(machine.connect() == 0);
REQUIRE(agent->connections == 2);
}
TEST_CASE("Other printer agents require an access code", "[DeviceAccess]")
{
MachineObject machine(nullptr, nullptr, "test", "test_dev", "127.0.0.1");
machine.printer_agent_id = GENERATE(
BBL_PRINTER_AGENT_ID, ORCA_PRINTER_AGENT_ID, "qidi", "snapmaker", "crealityprint", "plugin", "unknown");
machine.set_access_code("", false);
REQUIRE_FALSE(machine.has_access_right());
machine.set_access_code("88888888", false);
REQUIRE(machine.has_access_right());
}
TEST_CASE("Rebinding Moonraker with an empty key saves the machine", "[DeviceAccess]")
{
AppConfig config;
DeviceManager manager(nullptr, false, &config);
MachineObject machine(&manager, nullptr, "test", "test_dev", "127.0.0.1");
machine.printer_agent_id = MOONRAKER_PRINTER_AGENT_ID;
machine.dev_connection_type = "lan";
machine.revoke_access();
machine.set_access_code("");
const auto& machines = config.get_local_machines();
REQUIRE(machines.count("test_dev") == 1);
REQUIRE(machines.at("test_dev").printer_agent_id == MOONRAKER_PRINTER_AGENT_ID);
REQUIRE(machines.at("test_dev").access_code.empty());
}
TEST_CASE("Moonraker discovery leaves another agent's saved machine alone", "[DeviceAccess]")
{
AppConfig config;
NetworkAgent network(nullptr, std::make_shared<MoonrakerPrinterAgent>(""));
DeviceManager manager(&network, false, &config);
const std::string owner = GENERATE(as<std::string>{}, ORCA_PRINTER_AGENT_ID, MOONRAKER_PRINTER_AGENT_ID);
BBLocalMachine saved;
saved.dev_id = "192.0.2.10:7125";
saved.dev_ip = saved.dev_id;
saved.printer_agent_id = owner;
config.update_local_machine(saved);
manager.on_machine_alive(R"({"dev_name":"Klipper","dev_id":"192.0.2.10:7125","dev_ip":"192.0.2.10:7125",
"dev_type":"","dev_signal":"0","connect_type":"lan","bind_state":"free"})");
REQUIRE((manager.get_local_machine(saved.dev_id) != nullptr) == (owner == MOONRAKER_PRINTER_AGENT_ID));
}
@@ -1,5 +1,6 @@
#include <catch2/catch_test_macros.hpp>
#include <catch2/catch_all.hpp>
#include <catch2/generators/catch_generators.hpp>
#include <slic3r/GUI/DeviceCore/DevManager.h>
#include <slic3r/GUI/DeviceManager.hpp>
@@ -16,6 +17,7 @@
#include <cstdint>
#include <string>
#include <utility>
#include <vector>
using namespace Slic3r;
using json = nlohmann::json;
@@ -49,6 +51,21 @@ public:
AgentInfo get_agent_info() override { return m_info; }
int send_message(std::string dev_id, std::string json_str, int, int) override
{
published_device = std::move(dev_id);
published_message = json::parse(json_str);
return 0;
}
int send_message_to_printer(std::string dev_id, std::string json_str, int qos, int flag) override
{
return send_message(std::move(dev_id), std::move(json_str), qos, flag);
}
std::string published_device;
json published_message;
private:
AgentInfo m_info;
};
@@ -160,3 +177,62 @@ TEST_CASE("Device manager filters and rehomes devices by printer-agent ownership
CHECK(manager.get_my_machine_list("integration-agent-a").empty());
CHECK(manager.get_my_machine_list("integration-agent-b").count(machine.dev_id) == 1);
}
TEST_CASE("AMS filament settings keep legacy single-color commands compatible", "[DeviceManager][AMSMaterialsSetting][integration]")
{
ScopedAppConfig app_config;
auto agent = std::make_shared<TestPrinterAgent>("integration-agent");
NetworkAgent network(nullptr, agent);
DeviceManager manager(&network, false, &app_config.config);
MachineObject object(&manager, &network, "Test printer", "integration-device", "192.0.2.10");
object.dev_connection_type = GENERATE("lan", "cloud");
REQUIRE(object.command_ams_filament_settings(1, 2, "filament-id", "setting-id", "FF000080", "PLA", 190, 220) == 0);
const json& payload = agent->published_message.at("print");
CHECK(agent->published_device == object.get_dev_id());
CHECK(payload.at("command") == "ams_filament_setting");
CHECK(payload.at("ams_id") == 1);
CHECK(payload.at("slot_id") == 2);
CHECK(payload.at("tray_color") == "FF000080");
CHECK_FALSE(payload.contains("cols"));
CHECK_FALSE(payload.contains("ctype"));
}
TEST_CASE("AMS filament settings preserve ordered gradient and multicolor RGBA components", "[DeviceManager][AMSMaterialsSetting][integration]")
{
ScopedAppConfig app_config;
auto agent = std::make_shared<TestPrinterAgent>("integration-agent");
NetworkAgent network(nullptr, agent);
DeviceManager manager(&network, false, &app_config.config);
MachineObject object(&manager, &network, "Test printer", "integration-device", "192.0.2.10");
object.dev_connection_type = GENERATE("lan", "cloud");
const int color_type = GENERATE(0, 1);
const std::vector<std::string> colors{"FF000080", "0000FF40", "00FF00FF"};
REQUIRE(object.command_ams_filament_settings(1, 2, "filament-id", "setting-id", colors.front(), "PLA", 190, 220,
colors, color_type) == 0);
const json& payload = agent->published_message.at("print");
CHECK(payload.at("tray_color") == colors.front());
CHECK(payload.at("cols").get<std::vector<std::string>>() == colors);
CHECK(payload.at("ctype") == color_type);
}
TEST_CASE("AMS filament settings default a supplied single-color list to type two", "[DeviceManager][AMSMaterialsSetting][integration]")
{
ScopedAppConfig app_config;
auto agent = std::make_shared<TestPrinterAgent>("integration-agent");
NetworkAgent network(nullptr, agent);
DeviceManager manager(&network, false, &app_config.config);
MachineObject object(&manager, &network, "Test printer", "integration-device", "192.0.2.10");
object.dev_connection_type = GENERATE("lan", "cloud");
const std::vector<std::string> colors{"FF000080"};
REQUIRE(object.command_ams_filament_settings(1, 2, "filament-id", "setting-id", colors.front(), "PLA", 190, 220,
colors) == 0);
const json& payload = agent->published_message.at("print");
CHECK(payload.at("cols").get<std::vector<std::string>>() == colors);
CHECK(payload.at("ctype") == 2);
}
@@ -22,6 +22,13 @@
#include <cmath>
#include <wx/colour.h>
#ifdef __WXMSW__
#include <wx/bitmap.h>
#include <wx/image.h>
#include <cstdlib>
#include <utility>
#include <catch2/generators/catch_generators.hpp>
#endif
#include <wx/string.h>
#include "libslic3r/FilamentMixer.hpp"
@@ -260,3 +267,71 @@ TEST_CASE("sample_gradient_ramp returns nothing without a usable curve or step c
curve.points = {{0.0, kGradientMaxRatio, NAN, NAN}, {1.0, kGradientMinRatio, NAN, NAN}};
REQUIRE(Slic3r::GUI::sample_gradient_ramp(wxColour(255, 0, 0), wxColour(0, 0, 255), curve, 0).empty());
}
// The headless GTK/macOS suite does not initialize GUI display/font stock objects.
// Exercise native bitmap pixels on Windows without adding that dependency elsewhere.
#ifdef __WXMSW__
TEST_CASE("Filament gradient pixels follow both endpoint directions without changing input", "[FilamentBitmapUtils]")
{
const bool reverse = GENERATE(false, true);
const std::vector<wxColour> colors = reverse ? std::vector<wxColour>{wxColour(0, 0, 255), wxColour(255, 0, 0)}
: std::vector<wxColour>{wxColour(255, 0, 0), wxColour(0, 0, 255)};
const auto original = colors;
const wxBitmap bitmap = Slic3r::GUI::create_filament_bitmap(colors, wxSize(64, 16), true);
REQUIRE(bitmap.IsOk());
const wxImage image = bitmap.ConvertToImage();
REQUIRE(image.IsOk());
// Interior pixels avoid backend-specific rasterization at the gradient boundary.
for (const auto& sample : {std::pair<int, wxColour>{2, colors.front()}, {61, colors.back()}}) {
REQUIRE(std::abs(int(image.GetRed(sample.first, 8)) - int(sample.second.Red())) <= 16);
REQUIRE(std::abs(int(image.GetBlue(sample.first, 8)) - int(sample.second.Blue())) <= 16);
}
REQUIRE(colors == original);
}
TEST_CASE("Filament halves preserve the same caller color order as gradients", "[FilamentBitmapUtils]")
{
const bool reverse = GENERATE(false, true);
const std::vector<wxColour> colors = reverse ? std::vector<wxColour>{wxColour(0, 0, 255), wxColour(255, 0, 0)}
: std::vector<wxColour>{wxColour(255, 0, 0), wxColour(0, 0, 255)};
const auto original = colors;
const wxBitmap bitmap = Slic3r::GUI::create_filament_bitmap(colors, wxSize(64, 16), false);
REQUIRE(bitmap.IsOk());
const wxImage image = bitmap.ConvertToImage();
REQUIRE(image.IsOk());
require_same_rgb(wxColour(image.GetRed(16, 8), image.GetGreen(16, 8), image.GetBlue(16, 8)), colors.front());
require_same_rgb(wxColour(image.GetRed(48, 8), image.GetGreen(48, 8), image.GetBlue(48, 8)), colors.back());
REQUIRE(colors == original);
}
TEST_CASE("Three color filament stripes retain caller order", "[FilamentBitmapUtils]")
{
const std::vector<wxColour> colors{wxColour(0, 0, 255), wxColour(0, 255, 0), wxColour(255, 0, 0)};
const auto original = colors;
const wxBitmap bitmap = Slic3r::GUI::create_filament_bitmap(colors, wxSize(72, 24), false);
REQUIRE(bitmap.IsOk());
const wxImage image = bitmap.ConvertToImage();
REQUIRE(image.IsOk());
for (int i = 0; i < 3; ++i) {
const int x = 12 + i * 24;
require_same_rgb(wxColour(image.GetRed(x, 12), image.GetGreen(x, 12), image.GetBlue(x, 12)), colors[i]);
}
REQUIRE(colors == original);
}
TEST_CASE("Four color filament quadrants retain caller order", "[FilamentBitmapUtils]")
{
const std::vector<wxColour> colors{wxColour(0, 0, 255), wxColour(0, 255, 0), wxColour(255, 0, 0), wxColour(255, 255, 0)};
const auto original = colors;
const wxBitmap bitmap = Slic3r::GUI::create_filament_bitmap(colors, wxSize(64, 32), false);
REQUIRE(bitmap.IsOk());
const wxImage image = bitmap.ConvertToImage();
REQUIRE(image.IsOk());
for (int i = 0; i < 4; ++i) {
const int x = i % 2 ? 48 : 16, y = i / 2 ? 24 : 8;
require_same_rgb(wxColour(image.GetRed(x, y), image.GetGreen(x, y), image.GetBlue(x, y)), colors[i]);
}
REQUIRE(colors == original);
}
#endif // __WXMSW__
@@ -0,0 +1,179 @@
#include "slic3r/GUI/ColorPickerDialog.hpp"
#include "libslic3r/Color.hpp"
#include <algorithm>
#include <limits>
#include <stdexcept>
#include <optional>
#include <string>
#include <vector>
#include <cstddef>
#include <catch2/catch_test_macros.hpp>
#include <catch2/generators/catch_generators.hpp>
using namespace Slic3r;
using namespace Slic3r::GUI;
// One color more than an ordered gradient may hold.
static std::vector<std::string> too_many_gradient_colors()
{
return std::vector<std::string>(COLOR_PICKER_MAX_GRADIENT_COLORS + 1, "#FF0000");
}
TEST_CASE("Project gradient direction survives editing and RGB serialization", "[FilamentColorPicker]")
{
const std::vector<std::string> endpoints = {"#FF0000", "#0000FF"};
const FilamentColorPickerValue stored{endpoints, true};
const auto initial = filament_color_picker_selection(stored);
REQUIRE(initial);
REQUIRE(filament_color_picker_result(*initial).colors == endpoints);
REQUIRE_FALSE(filament_color_picker_changed(*initial, initial));
REQUIRE_FALSE(filament_color_picker_changed(*initial, std::nullopt));
const auto reverse = filament_color_picker_selection({{endpoints[1], endpoints[0]}, true});
REQUIRE(filament_color_picker_changed(*initial, reverse));
REQUIRE(filament_color_picker_result(*reverse).colors.front() == endpoints.back());
}
TEST_CASE("Unrepresentable project colors remain unchanged until a different selection is confirmed", "[FilamentColorPicker]")
{
const auto value = GENERATE(FilamentColorPickerValue{too_many_gradient_colors(), true},
FilamentColorPickerValue{{"#FF0000", "#0000FF"}, false});
REQUIRE_FALSE(filament_color_picker_selection(value));
const auto seed = filament_color_picker_initial(value);
REQUIRE_FALSE(filament_color_picker_changed(seed, seed));
REQUIRE_FALSE(filament_color_picker_changed(seed, std::nullopt));
const auto changed = filament_color_picker_selection({{"#FE0000"}, false});
REQUIRE(filament_color_picker_changed(seed, changed));
REQUIRE(filament_color_picker_result(*changed).colors == std::vector<std::string>{"#FE0000"});
}
TEST_CASE("Filament seeds preserve primary alpha when colors are invalid or absent", "[FilamentColorPicker]")
{
const FilamentColorPickerValue stale{{"invalid", "#00FF00"}, false, "#12345680"};
REQUIRE_FALSE(filament_color_picker_selection(stale));
REQUIRE(filament_color_picker_result(filament_color_picker_initial(stale)).colors == std::vector<std::string>{"#12345680"});
const auto value = filament_color_picker_selection({{"#12345680", "#ABCDEF01"}, true});
REQUIRE(value);
REQUIRE(filament_color_picker_result(*value).colors == std::vector<std::string>{"#12345680", "#ABCDEF01"});
const auto missing = filament_color_picker_initial({});
REQUIRE(filament_color_picker_result(missing).colors == std::vector<std::string>{"#000000"});
}
TEST_CASE("Filament project colors round trip every alpha boundary with compatible opaque spelling", "[FilamentColorPicker]")
{
const auto hex = GENERATE(std::string("#12345600"), std::string("#12345601"),
std::string("#12345680"), std::string("#123456FE"),
std::string("#123456FF"), std::string("#123456"));
const FilamentColorPickerValue stored{{hex}, false};
const auto selection = filament_color_picker_selection(stored);
REQUIRE(selection);
REQUIRE_FALSE(filament_color_picker_changed(*selection, selection));
REQUIRE_FALSE(filament_color_picker_changed(*selection, std::nullopt));
const auto result = filament_color_picker_result(*selection);
const std::string expected = hex == "#123456FF" ? "#123456" : hex;
REQUIRE(result.colors == std::vector<std::string>{expected});
ColorRGBA decoded;
REQUIRE(decode_color(result.colors.front(), decoded));
REQUIRE(filament_color_picker_result(ColorSelection{decoded}).colors == result.colors);
const auto reloaded = filament_color_picker_selection(result);
REQUIRE(reloaded);
REQUIRE(filament_color_picker_result(*reloaded).colors == result.colors);
}
TEST_CASE("Alpha only filament edits preserve ordered independent gradient endpoint alpha", "[FilamentColorPicker]")
{
const auto original = filament_color_picker_selection({{"#FF000000", "#0000FF80"}, true});
const auto edited = filament_color_picker_selection({{"#FF000001", "#0000FFFE"}, true});
REQUIRE(original);
REQUIRE(edited);
REQUIRE(filament_color_picker_changed(*original, edited));
const auto result = filament_color_picker_result(*edited);
REQUIRE(result.gradient);
REQUIRE(result.colors == std::vector<std::string>{"#FF000001", "#0000FFFE"});
const auto reloaded = filament_color_picker_selection(result);
REQUIRE(reloaded);
REQUIRE_FALSE(filament_color_picker_changed(*edited, reloaded));
const auto reversed = filament_color_picker_selection({{result.colors[1], result.colors[0]}, true});
REQUIRE(filament_color_picker_changed(*edited, reversed));
const auto solid = filament_color_picker_selection({{"#12345680"}, false});
const auto alpha_only = filament_color_picker_selection({{"#12345681"}, false});
REQUIRE(solid);
REQUIRE(alpha_only);
REQUIRE(filament_color_picker_changed(*solid, alpha_only));
REQUIRE(filament_color_picker_result(*alpha_only).colors == std::vector<std::string>{"#12345681"});
const auto mixed = filament_color_picker_selection({{"#FF0000FF", "#0000FF00"}, true});
REQUIRE(mixed);
REQUIRE(filament_color_picker_result(*mixed).colors == std::vector<std::string>{"#FF0000", "#0000FF00"});
}
TEST_CASE("Unrepresentable filament seeds retain first endpoint or valid primary alpha", "[FilamentColorPicker]")
{
auto too_many = too_many_gradient_colors();
too_many.front() = "#FF000000";
const auto value = GENERATE_COPY(FilamentColorPickerValue{too_many, true},
FilamentColorPickerValue{{"#FF000000", "#0000FF80"}, false},
FilamentColorPickerValue{{"invalid", "#FF0000"}, true, "#FF000000"});
REQUIRE_FALSE(filament_color_picker_selection(value));
const auto initial = filament_color_picker_initial(value);
REQUIRE(filament_color_picker_result(initial).colors == std::vector<std::string>{"#FF000000"});
REQUIRE_FALSE(filament_color_picker_changed(initial, initial));
REQUIRE_FALSE(filament_color_picker_changed(initial, std::nullopt));
const auto edited = filament_color_picker_selection({{"#FF000001"}, false});
REQUIRE(filament_color_picker_changed(initial, edited));
}
TEST_CASE("Ordered multi-color gradients round trip up to the color limit", "[FilamentColorPicker]")
{
const std::vector<std::string> all = {"#FF0000", "#FFFF0080", "#00FF00", "#00FFFF01", "#0000FF", "#FF00FF00", "#123456"};
for (std::size_t count = 2; count <= std::min(all.size(), COLOR_PICKER_MAX_GRADIENT_COLORS); ++count) {
const std::vector<std::string> colors(all.begin(), all.begin() + count);
const auto selection = filament_color_picker_selection({colors, true});
REQUIRE(selection);
const auto result = filament_color_picker_result(*selection);
REQUIRE(result.gradient);
REQUIRE(result.colors == colors);
REQUIRE_FALSE(filament_color_picker_changed(*selection, filament_color_picker_selection(result)));
std::vector<std::string> reordered = colors;
std::swap(reordered.front(), reordered.back());
REQUIRE(filament_color_picker_changed(*selection, filament_color_picker_selection({reordered, true})));
}
REQUIRE_FALSE(filament_color_picker_selection({too_many_gradient_colors(), true}));
}
TEST_CASE("Filament selections require complete hexadecimal colors and exact endpoint counts", "[FilamentColorPicker]")
{
const std::string hex = GENERATE("", "112233", "#ABC", "#1234567", "#123456789", "#GG2233", "#11223Z", "#112233G0", " #112233", "#112233 ");
REQUIRE_FALSE(filament_color_picker_selection({{hex}, false}));
REQUIRE_FALSE(filament_color_picker_selection({{"#112233", hex}, true}));
const auto malformed = GENERATE(FilamentColorPickerValue{{}, false},
FilamentColorPickerValue{{}, true},
FilamentColorPickerValue{{"#112233"}, true});
REQUIRE_FALSE(filament_color_picker_selection(malformed));
}
TEST_CASE("Every RGBA byte survives the public filament conversion round trip", "[FilamentColorPicker]")
{
constexpr char digits[] = "0123456789ABCDEF";
for (int byte = 0; byte < 256; ++byte) {
std::string hex = "#";
for (int channel = 0; channel < 4; ++channel) {
hex += digits[byte >> 4];
hex += digits[byte & 15];
}
const auto selection = filament_color_picker_selection({{hex}, false});
REQUIRE(selection);
const auto result = filament_color_picker_result(*selection);
REQUIRE(result.colors == std::vector<std::string>{byte == 255 ? hex.substr(0, 7) : hex});
REQUIRE_FALSE(filament_color_picker_changed(*selection, filament_color_picker_selection(result)));
}
}
TEST_CASE("Filament serialization rejects invalid native RGBA channels", "[FilamentColorPicker]")
{
const float invalid = GENERATE(-0.1f, 1.1f, std::numeric_limits<float>::infinity(), std::numeric_limits<float>::quiet_NaN());
const int channel = GENERATE(0, 1, 2, 3);
ColorRGBA color;
color[channel] = invalid;
REQUIRE_THROWS_AS(filament_color_picker_result(color), std::invalid_argument);
REQUIRE_THROWS_AS(filament_color_picker_result(ColorGradient{ColorRGBA::WHITE(), color}), std::invalid_argument);
}
+163
View File
@@ -0,0 +1,163 @@
// How ImGuiWrapper turns wx mouse events into ImGui input, and when it reports that ImGui owns a key.
// ImGuiWrapper lives in libslic3r_gui; this is the suite that links it. Same Windows include prologue
// as test_texture_color_palette.cpp (wx pulls in <windows.h>; keep WIN32_LEAN_AND_MEAN / NOMINMAX
// ahead of it).
#ifdef WIN32
#ifndef WIN32_LEAN_AND_MEAN
#define WIN32_LEAN_AND_MEAN
#endif
#ifndef NOMINMAX
#define NOMINMAX
#endif
#include <Windows.h>
#endif
#include <catch2/catch_test_macros.hpp>
#include <catch2/generators/catch_generators.hpp>
#include <catch2/matchers/catch_matchers.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp>
#include <imgui/imgui.h>
#include <imgui/imgui_internal.h>
#include <wx/event.h>
#include "slic3r/GUI/ImGuiWrapper.hpp"
#include "slic3r/GUI/KeyChord.hpp"
using Catch::Matchers::WithinAbs;
using Slic3r::GUI::ImGuiWrapper;
using Slic3r::GUI::KeyChord;
namespace {
// ImGui's wheel after one wheel event, as wx on macOS builds it: a delta of 10 and the rotation in whole
// points of trackpad travel.
float wheel_after(int rotation, wxMouseWheelAxis axis)
{
ImGuiWrapper imgui;
imgui.set_display_size(800.f, 600.f); // update_mouse_data() ignores input until the display has a size
wxMouseEvent evt(wxEVT_MOUSEWHEEL);
evt.m_wheelRotation = rotation;
evt.m_wheelDelta = 10;
evt.m_wheelAxis = axis;
imgui.update_mouse_data(evt);
return ImGui::GetIO().MouseWheel;
}
// Runs ImGui frames without OpenGL by building the font atlas on the CPU.
struct ImGuiFrames
{
ImGuiWrapper imgui;
ImGuiFrames()
{
imgui.set_display_size(800.f, 600.f);
unsigned char* pixels = nullptr;
int width = 0, height = 0;
ImGui::GetIO().Fonts->GetTexDataAsRGBA32(&pixels, &width, &height);
}
template<class Draw> void frame(Draw draw)
{
ImGui::NewFrame();
draw();
ImGui::EndFrame();
}
// SetKeyboardFocusHere() activates the field in the next frame that draws it.
void draw_input(bool focus)
{
ImGui::Begin("window");
if (focus)
ImGui::SetKeyboardFocusHere();
ImGui::InputText("##text", text, sizeof(text));
ImGui::End();
}
char text[16] = "";
};
void draw_modal()
{
if (ImGui::BeginPopupModal("modal"))
ImGui::EndPopup();
}
} // namespace
TEST_CASE("a vertical wheel event steps ImGui's wheel in its own direction", "[ImGuiWrapper]")
{
const int rotation = GENERATE(1, 7, 120, -1, -7, -120);
CHECK_THAT(wheel_after(rotation, wxMOUSE_WHEEL_VERTICAL), WithinAbs(rotation > 0 ? 1.0 : -1.0, 0.0));
}
// Both kinds arrive from a macOS trackpad while scrolling; see ImGuiWrapper::update_mouse_data().
TEST_CASE("a wheel event with no rotation or on the horizontal axis does not step ImGui's wheel", "[ImGuiWrapper][Regression]")
{
const auto [rotation, axis] = GENERATE(table<int, wxMouseWheelAxis>({
{ 0, wxMOUSE_WHEEL_VERTICAL },
{ 5, wxMOUSE_WHEEL_HORIZONTAL },
{ -5, wxMOUSE_WHEEL_HORIZONTAL },
}));
CHECK_THAT(wheel_after(rotation, axis), WithinAbs(0.0, 0.0));
}
TEST_CASE("an InputText counts as active only while it holds the keyboard", "[ImGuiWrapper]")
{
ImGuiFrames f;
f.frame([&] { f.draw_input(false); });
CHECK_FALSE(f.imgui.text_input_active());
f.frame([&] { f.draw_input(true); });
f.frame([&] { f.draw_input(false); });
CHECK(f.imgui.text_input_active());
f.frame([] {});
f.frame([] {});
CHECK_FALSE(f.imgui.text_input_active());
}
TEST_CASE("a modal counts as open from its first frame until a frame no longer draws it", "[ImGuiWrapper]")
{
ImGuiFrames f;
CHECK_FALSE(f.imgui.modal_popup_open());
f.frame([] {
ImGui::OpenPopup("modal");
draw_modal();
});
CHECK(f.imgui.modal_popup_open());
// A frame opened by input, before anything is drawn in it.
ImGui::NewFrame();
CHECK(f.imgui.modal_popup_open());
draw_modal();
ImGui::EndFrame();
CHECK(f.imgui.modal_popup_open());
// A frame that no longer draws it, as when its canvas is hidden.
f.frame([] {});
REQUIRE(ImGui::GetTopMostPopupModal() != nullptr);
CHECK_FALSE(f.imgui.modal_popup_open());
}
TEST_CASE("ImGui owns every key under a modal and text-editing keys in an active InputText", "[ImGuiWrapper]")
{
ImGuiFrames f;
const KeyChord letter{ 'A' };
const KeyChord save{ 'S', wxMOD_CONTROL };
CHECK_FALSE(f.imgui.owns_key(letter));
CHECK_FALSE(f.imgui.owns_key(save));
f.frame([&] { f.draw_input(true); });
f.frame([&] { f.draw_input(false); });
CHECK(f.imgui.owns_key(letter));
CHECK_FALSE(f.imgui.owns_key(save));
f.frame([] {
ImGui::OpenPopup("modal");
draw_modal();
});
CHECK(f.imgui.owns_key(save));
}
@@ -0,0 +1,589 @@
#include <catch2/catch_message.hpp>
#include <catch2/catch_test_macros.hpp>
#include <catch2/generators/catch_generators.hpp>
#include <atomic>
#include <chrono>
#include <condition_variable>
#include <cstddef>
#include <functional>
#include <future>
#include <memory>
#include <mutex>
#include <string>
#include <thread>
#include <utility>
#include <vector>
#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/beast/core/error.hpp>
#include <boost/beast/core/flat_buffer.hpp>
#include <boost/beast/http/message.hpp>
#include <boost/beast/http/read.hpp>
#include <boost/beast/http/status.hpp>
#include <boost/beast/http/string_body.hpp>
#include <boost/beast/http/write.hpp>
#include <boost/beast/websocket/error.hpp>
#include <boost/beast/websocket/rfc6455.hpp>
#include <boost/beast/websocket/stream.hpp>
#include <nlohmann/json.hpp>
#include "slic3r/Utils/IPrinterAgent.hpp"
#include "slic3r/Utils/MoonrakerPrinterAgent.hpp"
using namespace Slic3r;
namespace {
namespace beast = boost::beast;
namespace http = beast::http;
namespace websocket = beast::websocket;
namespace net = boost::asio;
using tcp = net::ip::tcp;
using namespace std::chrono_literals;
class DiscoveryAgent : public MoonrakerPrinterAgent
{
public:
using MoonrakerPrinterAgent::MoonrakerPrinterAgent;
using MoonrakerPrinterAgent::device_info;
mutable unsigned fetch_count = 0;
protected:
bool fetch_device_info(const std::string&, const std::string&, MoonrakerDeviceInfo& info, std::string&) const override
{
++fetch_count;
info.dev_name = "Test printer";
return true;
}
};
class StreamAgent : public MoonrakerPrinterAgent
{
public:
using MoonrakerPrinterAgent::MoonrakerPrinterAgent;
using MoonrakerPrinterAgent::start_status_stream;
using MoonrakerPrinterAgent::stop_status_stream;
using MoonrakerPrinterAgent::ws_timings;
protected:
bool init_device_info(
const std::string& dev_id, const std::string& dev_ip, const std::string&, const std::string&, bool, const std::string& port) override
{
device_info = MoonrakerDeviceInfo{};
device_info.dev_id = dev_id;
device_info.base_url = "http://" + dev_ip + ":" + port;
return true;
}
bool fetch_device_info(const std::string&, const std::string&, MoonrakerDeviceInfo& info, std::string&) const override
{
info.dev_name = "Test printer";
return true;
}
};
enum class Behavior {
SilentBeforeHandshake, // Accepts the connection and never answers
Silent, // Completes the upgrade, then never reads or sends
AnswersPings, // Reports a status, then answers pings and sends nothing else
AnswersPingsWithoutStatus, // Answers pings but never reports a status
Chatters, // Reports a status, then answers pings and sends notifications that carry no status
HangsUp, // Reports a status, then closes the websocket
HangsUpWithoutAnswer, // Closes the websocket without sending anything
IgnoresClose, // Reports Klippy disconnected, then never answers the close
StallsObjectList, // Never answers the stream's object list request
StallsStatusQuery, // Never answers HTTP requests until released, then hangs up on them
};
class FakePrinter
{
public:
explicit FakePrinter(Behavior behavior) : m_behavior(behavior)
{
m_accept_thread = std::thread([this] { accept_loop(); });
}
~FakePrinter()
{
release();
m_stopping = true;
// Wakes the blocking accept.
tcp::socket wake{m_accept_ioc};
beast::error_code ec;
wake.connect(m_acceptor.local_endpoint(), ec);
m_accept_thread.join();
for (std::thread& connection : m_connections)
connection.join();
}
std::string host() const { return "127.0.0.1"; }
std::string port() const { return std::to_string(m_acceptor.local_endpoint().port()); }
std::string base_url() const { return "http://" + host() + ":" + port(); }
// A connection is ready once it reaches the state its behavior waits in.
bool wait_for_ready_connections(int count, std::chrono::milliseconds timeout = 10s)
{
std::unique_lock<std::mutex> lock(m_mutex);
return m_changed.wait_for(lock, timeout, [&] { return m_ready >= count; });
}
bool wait_for_http_requests(int count)
{
std::unique_lock<std::mutex> lock(m_mutex);
return m_changed.wait_for(lock, 10s, [&] { return m_http_requests >= count; });
}
// Waits until the printer, once released, has hung up on `count` stalled HTTP requests.
bool wait_for_http_hang_ups(int count)
{
std::unique_lock<std::mutex> lock(m_mutex);
return m_changed.wait_for(lock, 10s, [&] { return m_http_hung_up >= count; });
}
int close_frames()
{
std::lock_guard<std::mutex> lock(m_mutex);
return m_close_frames;
}
// The printer answers a close frame before its own read completes, so the agent's stop can return first.
bool wait_for_close_frames(int count)
{
std::unique_lock<std::mutex> lock(m_mutex);
return m_changed.wait_for(lock, 10s, [&] { return m_close_frames >= count; });
}
// Hangs up every connection that is waiting.
void release()
{
std::lock_guard<std::mutex> lock(m_mutex);
m_released = true;
m_changed.notify_all();
}
private:
void accept_loop()
{
for (;;) {
// Each connection gets its own io_context, so a connection that runs one never runs another's handlers.
auto ioc = std::make_unique<net::io_context>();
tcp::socket socket{*ioc};
beast::error_code ec;
m_acceptor.accept(socket, ec);
if (ec || m_stopping)
return;
m_connections.emplace_back(
[this, ioc = std::move(ioc), socket = std::move(socket)]() mutable { serve(*ioc, std::move(socket)); });
}
}
void serve(net::io_context& ioc, tcp::socket socket)
{
if (m_behavior == Behavior::SilentBeforeHandshake) {
mark_ready();
wait_for_release();
return;
}
beast::flat_buffer buffer;
http::request<http::string_body> request;
beast::error_code ec;
http::read(socket, buffer, request, ec);
if (ec)
return;
// The agent also queries the printer's status and object list over plain HTTP.
if (!websocket::is_upgrade(request)) {
serve_http(socket, request);
return;
}
websocket::stream<tcp::socket> ws{std::move(socket)};
ws.accept(request, ec);
// Without its object list the stream never sends the subscribe request.
const int requests = m_behavior == Behavior::StallsObjectList ? 1 : 2;
for (int i = 0; i < requests && !ec; ++i) {
beast::flat_buffer message;
ws.read(message, ec);
}
if (!ec && (m_behavior == Behavior::AnswersPings || m_behavior == Behavior::Chatters || m_behavior == Behavior::HangsUp)) {
const std::string status =
nlohmann::json{{"jsonrpc", "2.0"}, {"id", 1}, {"result", {{"status", {{"print_stats", {{"state", "standby"}}}}}}}}.dump();
ws.write(net::buffer(status), ec);
}
if (ec)
return;
switch (m_behavior) {
case Behavior::AnswersPings:
case Behavior::AnswersPingsWithoutStatus:
case Behavior::Chatters:
mark_ready();
answer_pings(ioc, ws, m_behavior == Behavior::Chatters);
break;
case Behavior::HangsUp:
case Behavior::HangsUpWithoutAnswer:
ws.close(websocket::close_code::normal, ec);
mark_ready();
break;
case Behavior::IgnoresClose: {
const std::string notification = nlohmann::json{{"jsonrpc", "2.0"}, {"method", "notify_klippy_disconnected"}}.dump();
ws.write(net::buffer(notification), ec);
mark_ready();
wait_for_release();
break;
}
case Behavior::StallsObjectList: answer_pings(ioc, ws, false); break;
default: mark_ready(); wait_for_release();
}
}
void serve_http(tcp::socket& socket, const http::request<http::string_body>& request)
{
{
std::lock_guard<std::mutex> lock(m_mutex);
++m_http_requests;
m_changed.notify_all();
}
if (m_behavior == Behavior::StallsObjectList) {
mark_ready();
wait_for_release();
return;
}
if (m_behavior == Behavior::StallsStatusQuery) {
wait_for_release();
beast::error_code ec;
socket.close(ec);
std::lock_guard<std::mutex> lock(m_mutex);
++m_http_hung_up;
m_changed.notify_all();
return;
}
http::response<http::string_body> response{http::status::not_found, request.version()};
response.prepare_payload();
beast::error_code ec;
http::write(socket, response, ec);
}
// Keeps a read pending, which answers pings, until released or closed, and with `chatter` also sends a notification every 50 ms.
void answer_pings(net::io_context& ioc, websocket::stream<tcp::socket>& ws, bool chatter)
{
const std::string notification = nlohmann::json{{"jsonrpc", "2.0"},
{"method", "notify_proc_stat_update"},
{"params", nlohmann::json::array({nlohmann::json::object()})}}
.dump();
beast::flat_buffer message;
bool reading = true;
bool writing = false;
std::function<void()> read_next = [&] {
ws.async_read(message, [&](beast::error_code ec, std::size_t) {
message.clear();
if (ec == websocket::error::closed) {
std::lock_guard<std::mutex> lock(m_mutex);
++m_close_frames;
m_changed.notify_all();
}
if (ec)
reading = false;
else
read_next();
});
};
read_next();
auto next_notification = std::chrono::steady_clock::now();
while (reading && !wait_for_release(10ms)) {
if (chatter && !writing && std::chrono::steady_clock::now() >= next_notification) {
writing = true;
next_notification += 50ms;
ws.async_write(net::buffer(notification), [&](beast::error_code, std::size_t) { writing = false; });
}
ioc.restart();
ioc.run_for(20ms);
}
beast::error_code ignored;
ws.next_layer().close(ignored);
while (reading || writing) {
ioc.restart();
ioc.run_for(20ms);
}
}
void mark_ready()
{
std::lock_guard<std::mutex> lock(m_mutex);
++m_ready;
m_changed.notify_all();
}
void wait_for_release()
{
std::unique_lock<std::mutex> lock(m_mutex);
m_changed.wait(lock, [this] { return m_released; });
}
bool wait_for_release(std::chrono::milliseconds timeout)
{
std::unique_lock<std::mutex> lock(m_mutex);
return m_changed.wait_for(lock, timeout, [this] { return m_released; });
}
const Behavior m_behavior;
net::io_context m_accept_ioc;
tcp::acceptor m_acceptor{m_accept_ioc, {net::ip::make_address("127.0.0.1"), 0}};
std::atomic<bool> m_stopping{false};
std::thread m_accept_thread;
std::vector<std::thread> m_connections;
std::mutex m_mutex;
std::condition_variable m_changed;
int m_ready = 0;
int m_http_requests = 0;
int m_http_hung_up = 0;
int m_close_frames = 0;
bool m_released = false;
};
// Records when the agent dispatches a status report.
class Reports
{
public:
void attach(StreamAgent& agent)
{
agent.set_on_local_message_fn([this](std::string, std::string) {
std::lock_guard<std::mutex> lock(m_mutex);
m_times.push_back(std::chrono::steady_clock::now());
m_changed.notify_all();
});
}
bool wait_for(std::size_t count)
{
std::unique_lock<std::mutex> lock(m_mutex);
return m_changed.wait_for(lock, 10s, [&] { return m_times.size() >= count; });
}
std::vector<std::chrono::steady_clock::time_point> times()
{
std::lock_guard<std::mutex> lock(m_mutex);
return m_times;
}
private:
std::mutex m_mutex;
std::condition_variable m_changed;
std::vector<std::chrono::steady_clock::time_point> m_times;
};
// A printer and a stream agent connected to it.
struct StreamFixture
{
explicit StreamFixture(Behavior behavior) : printer(behavior) {}
// Runs before the members are destroyed, so the agent's destructor never joins a stream the printer still holds open.
~StreamFixture() { printer.release(); }
void start() { agent.start_status_stream("printer", printer.base_url(), ""); }
// Stops the stream, giving it `limit` to return before the printer hangs up on it.
bool stops_within(std::chrono::milliseconds limit)
{
auto stopped = std::async(std::launch::async, [this] { agent.stop_status_stream(); });
const bool in_time = stopped.wait_for(limit) == std::future_status::ready;
printer.release();
stopped.wait();
return in_time;
}
FakePrinter printer;
StreamAgent agent{""};
};
} // namespace
TEST_CASE("Moonraker discovery waits for a complete device identity", "[MoonrakerPrinterAgent][Discovery]")
{
DiscoveryAgent agent("");
unsigned announcements = 0;
nlohmann::json announcement;
agent.set_on_ssdp_msg_fn([&](std::string payload) {
++announcements;
announcement = nlohmann::json::parse(payload);
});
agent.start_discovery(true, false);
REQUIRE(announcements == 0);
REQUIRE(agent.fetch_count == 0);
agent.device_info.dev_id = "127.0.0.1:7125";
agent.device_info.dev_ip = agent.device_info.dev_id;
agent.device_info.base_url = "http://127.0.0.1:7125";
agent.start_discovery(true, false);
REQUIRE(announcements == 1);
REQUIRE(agent.fetch_count == 1);
REQUIRE(announcement.at("dev_id") == agent.device_info.dev_id);
REQUIRE(announcement.at("dev_ip") == agent.device_info.dev_ip);
REQUIRE(announcement.at("dev_name") == "Test printer");
}
TEST_CASE("Stopping the status stream gives a silent printer no more than its timeouts", "[MoonrakerPrinterAgent][StatusStream]")
{
// An open websocket gets the 200 ms close timeout, and a stalled object list request up to 1 s of curl progress to see the stop.
const auto [behavior, limit] = GENERATE(table<Behavior, std::chrono::milliseconds>({
{Behavior::SilentBeforeHandshake, 500ms},
{Behavior::Silent, 1000ms},
{Behavior::StallsObjectList, 2000ms},
}));
CAPTURE(static_cast<int>(behavior));
StreamFixture f(behavior);
f.agent.ws_timings.close = 200ms;
f.start();
REQUIRE(f.printer.wait_for_ready_connections(1));
CHECK(f.stops_within(limit));
}
TEST_CASE("Stopping the status stream sends the printer a close frame", "[MoonrakerPrinterAgent][StatusStream]")
{
// A stop while the stream reads, and one while it waits on the object list request.
const Behavior behavior = GENERATE(Behavior::AnswersPings, Behavior::StallsObjectList);
CAPTURE(static_cast<int>(behavior));
StreamFixture f(behavior);
f.start();
REQUIRE(f.printer.wait_for_ready_connections(1));
CHECK(f.stops_within(3s));
CHECK(f.printer.wait_for_close_frames(1));
CHECK(f.printer.close_frames() == 1);
}
TEST_CASE("Stopping the status stream does not wait out the reconnect delay", "[MoonrakerPrinterAgent][StatusStream]")
{
StreamFixture f(Behavior::HangsUp);
f.agent.ws_timings.reconnect_delay = 10s;
f.start();
REQUIRE(f.printer.wait_for_ready_connections(1));
// Lets the stream reach its reconnect wait.
std::this_thread::sleep_for(200ms);
CHECK(f.stops_within(3s));
}
TEST_CASE("The status stream reconnects when the printer stops answering", "[MoonrakerPrinterAgent][StatusStream]")
{
// An unanswered close ends at whichever of the handshake and close timeouts is shorter.
const auto [behavior, handshake, close] = GENERATE(table<Behavior, std::chrono::milliseconds, std::chrono::milliseconds>({
{Behavior::Silent, 300ms, 1000ms},
{Behavior::IgnoresClose, 300ms, 1000ms},
{Behavior::IgnoresClose, 1000ms, 200ms},
}));
CAPTURE(static_cast<int>(behavior), handshake.count(), close.count());
StreamFixture f(behavior);
f.agent.ws_timings.handshake = handshake;
f.agent.ws_timings.close = close;
f.agent.ws_timings.idle = 400ms;
f.agent.ws_timings.reconnect_delay = 20ms;
f.start();
CHECK(f.printer.wait_for_ready_connections(2));
CHECK(f.stops_within(3s));
}
TEST_CASE("The status stream reconnects at the shortest delay while the printer answers", "[MoonrakerPrinterAgent][StatusStream]")
{
StreamFixture f(Behavior::HangsUp);
f.agent.ws_timings.reconnect_delay = 50ms;
f.start();
// Twelve 50 ms waits; a backoff that kept doubling would need over 10 s.
CHECK(f.printer.wait_for_ready_connections(12, 3s));
CHECK(f.stops_within(3s));
}
TEST_CASE("The status stream keeps retrying a printer that never answers", "[MoonrakerPrinterAgent][StatusStream]")
{
StreamFixture f(Behavior::HangsUpWithoutAnswer);
f.agent.ws_timings.reconnect_delay = 1ms;
f.start();
// The backoff stops doubling at 32 ms, so 20 attempts take well under the wait.
CHECK(f.printer.wait_for_ready_connections(20));
CHECK(f.stops_within(3s));
}
TEST_CASE("A callback on the stream thread can stop the status stream", "[MoonrakerPrinterAgent][StatusStream]")
{
std::promise<void> stopped;
std::once_flag stopped_once;
StreamFixture f(Behavior::AnswersPings);
f.agent.set_on_local_message_fn([&](std::string, std::string) {
std::call_once(stopped_once, [&] {
f.agent.stop_status_stream();
stopped.set_value();
});
});
f.start();
REQUIRE(stopped.get_future().wait_for(5s) == std::future_status::ready);
CHECK(f.stops_within(3s));
}
TEST_CASE("A quiet printer that is still up keeps reporting its status", "[MoonrakerPrinterAgent][StatusStream]")
{
Reports reports;
StreamFixture f(Behavior::AnswersPings);
f.agent.ws_timings.idle = 400ms;
f.agent.ws_timings.heartbeat = 300ms;
reports.attach(f.agent);
f.start();
// The status the printer sent, then two heartbeats.
REQUIRE(reports.wait_for(3));
const auto times = reports.times();
// The steady clock is compared in whole milliseconds, so allow 1 ms of rounding.
CHECK(times[1] - times[0] >= 299ms);
CHECK(times[2] - times[1] >= 299ms);
CHECK(f.stops_within(3s));
}
TEST_CASE("The status stream sends no heartbeat while the printer has nothing new to report", "[MoonrakerPrinterAgent][StatusStream]")
{
const Behavior behavior = GENERATE(Behavior::Chatters, Behavior::AnswersPingsWithoutStatus);
CAPTURE(static_cast<int>(behavior));
Reports reports;
StreamFixture f(behavior);
f.agent.ws_timings.idle = 400ms;
f.agent.ws_timings.heartbeat = 800ms;
reports.attach(f.agent);
f.start();
REQUIRE(f.printer.wait_for_ready_connections(1));
// The chattering printer's status, and nothing from the one that never sent a status.
const std::size_t expected = behavior == Behavior::Chatters ? 1 : 0;
std::this_thread::sleep_for(1500ms);
CHECK(reports.times().size() == expected);
CHECK(f.stops_within(3s));
}
TEST_CASE("A disconnect during the connection queries keeps the status stream from starting", "[MoonrakerPrinterAgent][StatusStream]")
{
StreamFixture f(Behavior::StallsStatusQuery);
PrinterConnectionParams params;
params.dev_id = "printer";
params.host = f.printer.host();
params.port = f.printer.port();
f.agent.connect_printer(params);
REQUIRE(f.printer.wait_for_http_requests(1));
f.agent.disconnect_printer();
f.printer.release();
// The query has returned once the printer hangs up on it, and a stream would connect right after.
REQUIRE(f.printer.wait_for_http_hang_ups(1));
CHECK_FALSE(f.printer.wait_for_ready_connections(1, 500ms));
}
@@ -0,0 +1,111 @@
#include <catch2/catch_test_macros.hpp>
#include <utility>
#include <memory>
#include <vector>
#include <functional>
#include "slic3r/Utils/bambu_networking.hpp"
#include "slic3r/Utils/MoonrakerPrinterAgent.hpp"
#include "slic3r/Utils/NetworkAgent.hpp"
using namespace Slic3r;
namespace {
class DiscoverySource : public MoonrakerPrinterAgent
{
public:
DiscoverySource() : MoonrakerPrinterAgent("") {}
OnMsgArrivedFn discovery_callback;
int set_on_ssdp_msg_fn(OnMsgArrivedFn fn) override
{
discovery_callback = std::move(fn);
return 0;
}
};
} // namespace
TEST_CASE("Queued discovery cannot cross printer agent sessions", "[NetworkDiscovery]")
{
auto source = std::make_shared<DiscoverySource>();
NetworkAgent network(nullptr, source);
std::vector<std::function<void()>> pending;
std::vector<std::string> delivered;
network.set_queue_on_main_fn([&](std::function<void()> fn) { pending.push_back(std::move(fn)); });
network.set_on_ssdp_msg_fn([&](std::string payload) { delivered.push_back(std::move(payload)); });
auto outgoing_callback = source->discovery_callback;
outgoing_callback("unsaved-device");
outgoing_callback("saved-moonraker-device");
REQUIRE(delivered.empty());
REQUIRE(pending.size() == 2);
bool expect_active = true;
SECTION("Switch to another agent")
{
network.set_printer_agent(std::make_shared<DiscoverySource>());
}
SECTION("Clear the live agent")
{
network.set_printer_agent(nullptr);
expect_active = false;
}
SECTION("Switch away and back to the cached agent")
{
network.set_printer_agent(std::make_shared<DiscoverySource>());
network.set_printer_agent(source);
}
SECTION("Unregister discovery")
{
network.set_on_ssdp_msg_fn(nullptr);
expect_active = false;
}
SECTION("Replace the discovery listener")
{
network.set_on_ssdp_msg_fn([&](std::string payload) { delivered.push_back(std::move(payload)); });
}
for (auto& fn : pending)
fn();
pending.clear();
REQUIRE(delivered.empty());
outgoing_callback("late-outgoing-device");
REQUIRE(pending.empty());
REQUIRE(delivered.empty());
if (expect_active) {
auto active = std::dynamic_pointer_cast<DiscoverySource>(network.get_printer_agent());
REQUIRE(active);
REQUIRE(active->discovery_callback);
active->discovery_callback("active-device");
REQUIRE(delivered.empty());
REQUIRE(pending.size() == 1);
pending.front()();
REQUIRE(delivered == std::vector<std::string>{"active-device"});
}
}
TEST_CASE("Discovery callbacks expire when networking is destroyed", "[NetworkDiscovery]")
{
auto source = std::make_shared<DiscoverySource>();
std::vector<std::function<void()>> pending;
unsigned delivered = 0;
{
NetworkAgent network(nullptr, source);
network.set_queue_on_main_fn([&](std::function<void()> fn) { pending.push_back(std::move(fn)); });
network.set_on_ssdp_msg_fn([&](std::string) { ++delivered; });
source->discovery_callback("queued-device");
}
REQUIRE(pending.size() == 1);
pending.front()();
pending.clear();
source->discovery_callback("late-device");
REQUIRE(pending.empty());
REQUIRE(delivered == 0);
}
@@ -12,7 +12,6 @@
#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>
@@ -23,7 +22,6 @@
#include <istream>
#include <memory>
#include <mutex>
#include <optional>
#include <string>
#include <string_view>
#include <thread>
@@ -196,29 +194,6 @@ private:
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
@@ -56,6 +56,14 @@ TEST_CASE("Plugin host API exposes host-owned bundle and preset surface to Pytho
REQUIRE(has_attr(host, "Model"));
REQUIRE(has_attr(host, "ModelObject"));
REQUIRE(has_attr(host, "Plater"));
REQUIRE(has_attr(host, "preset"));
py::object preset = host.attr("preset");
REQUIRE(has_attr(preset, "bundles"));
py::object bundles = preset.attr("bundles");
CHECK(has_attr(bundles, "list_ids"));
CHECK(has_attr(bundles, "reload_local"));
CHECK_FALSE(has_attr(host, "reload_local_bundle"));
CHECK_FALSE(has_attr(host, "list_bundle_ids"));
py::object preset_bundle_type = host.attr("PresetBundle");
CHECK(has_attr(preset_bundle_type, "prints"));
@@ -132,6 +140,7 @@ TEST_CASE("Plugin host API exposes host-owned bundle and preset surface to Pytho
TEST_CASE("Plugin host API reports unavailable GUI objects before Orca app initialization", "[PluginHost][Python]")
{
py::object host = import_orca_module().attr("host");
py::object bundles = host.attr("preset").attr("bundles");
for (const char* function_name : { "preset_bundle", "plater", "model", "app_info", "selected_printer" }) {
CAPTURE(function_name);
@@ -143,6 +152,22 @@ TEST_CASE("Plugin host API reports unavailable GUI objects before Orca app initi
CHECK(std::string(error.what()).find("OrcaSlicer application is not initialized") != std::string::npos);
}
}
try {
bundles.attr("reload_local")("test-bundle");
FAIL("host.preset.bundles.reload_local unexpectedly succeeded without a wx application");
} catch (const py::error_already_set& error) {
CHECK(error.matches(PyExc_RuntimeError));
CHECK(std::string(error.what()).find("OrcaSlicer application is not initialized") != std::string::npos);
}
try {
bundles.attr("list_ids")();
FAIL("host.preset.bundles.list_ids unexpectedly succeeded without a wx application");
} catch (const py::error_already_set& error) {
CHECK(error.matches(PyExc_RuntimeError));
CHECK(std::string(error.what()).find("OrcaSlicer application is not initialized") != std::string::npos);
}
}
TEST_CASE("Plugin host API reports no GL details before the 3D view exists", "[PluginHost][Python]")
+69
View File
@@ -3,6 +3,7 @@
#include <catch2/catch_all.hpp>
#include <ios>
#include <libslic3r/Utils.hpp>
#include <slic3r/plugin/PluginLoader.hpp>
#include <slic3r/plugin/PluginDescriptor.hpp>
#include <slic3r/plugin/PluginFsUtils.hpp>
@@ -10,13 +11,17 @@
#include <catch2/catch_test_macros.hpp>
#include <catch2/matchers/catch_matchers.hpp>
#include <catch2/matchers/catch_matchers_string.hpp>
#include "catch2/catch_message.hpp"
#include "plugin_test_utils.hpp"
#include "test_utils.hpp"
#include <boost/filesystem.hpp>
#include <fstream>
#include <string>
#include <libslic3r/Zipper.hpp>
using namespace Slic3r;
namespace fs = boost::filesystem;
@@ -31,6 +36,14 @@ fs::path write_py_file(const fs::path& dir, const std::string& filename, const s
return p;
}
// The shared production helper: makes the path absolute and lexically normalized and, on
// Windows, prefixes it with the extended-length "\\?\" form, so the test can create, verify and
// clean up a tree past MAX_PATH even with LongPathsEnabled off. Identity on other platforms.
fs::path to_extended_path(const fs::path& p)
{
return Slic3r::extended_path(p);
}
} // namespace
TEST_CASE("install_plugin rejects a cloud UUID containing path traversal", "[PluginInstall]")
@@ -184,3 +197,59 @@ TEST_CASE("an unsubscribed cloud plugin follows its header version", "[PluginIns
REQUIRE(read_install_state(plugin_dir, scanned));
CHECK(scanned.installed_version == "1.0.0");
}
TEST_CASE("extract_zip_to_directory writes entries under a destination longer than MAX_PATH",
"[PluginInstall][PluginFsUtils]")
{
ScopedTemporaryDir dir("deep-extract");
const fs::path wheel = dir.path() / "deep_pkg-1.0-py3-none-any.whl";
const std::string payload = "print('hello from a deep path')\n";
{
Zipper zipper(wheel.string(), Zipper::NO_COMPRESSION);
zipper.add_entry("pkg/", "", 0); // explicit directory entry
zipper.add_entry("pkg/module.py", payload.data(), payload.size());
zipper.finalize();
}
// A single 240-char component keeps every filename below the NTFS 255 limit while pushing the
// full destination (temp dir + component) past the 260-char MAX_PATH the Win32 APIs enforce
// when LongPathsEnabled is off.
const fs::path destination = dir.path() / std::string(240, 'd');
REQUIRE(destination.string().size() > 260);
std::string error;
REQUIRE(extract_zip_to_directory(wheel, destination, error));
INFO("extract error: " << error);
CHECK(error.empty());
// Verify through a long-path-capable path, otherwise the check itself is bounded by MAX_PATH.
const fs::path module_py = destination / "pkg" / "module.py";
REQUIRE(fs::exists(to_extended_path(module_py)));
REQUIRE(fs::is_regular_file(to_extended_path(module_py)));
// Remove the deep subtree here: ScopedTemporaryDir's plain remove_all cannot reach past
// MAX_PATH on Windows.
boost::system::error_code ec;
fs::remove_all(to_extended_path(destination), ec);
CHECK_FALSE(ec);
}
TEST_CASE("extract_zip_to_directory rejects an entry that escapes the destination",
"[PluginInstall][PluginFsUtils]")
{
ScopedTemporaryDir dir("escape-extract");
const fs::path wheel = dir.path() / "evil-1.0-py3-none-any.whl";
{
Zipper zipper(wheel.string(), Zipper::NO_COMPRESSION);
zipper.add_entry("pkg/module.py", "print('ok')\n", 12);
zipper.add_entry("../escape.txt", "escaped\n", 8);
zipper.finalize();
}
std::string error;
CHECK_FALSE(extract_zip_to_directory(wheel, dir.path() / "out", error));
CHECK_THAT(error, Catch::Matchers::ContainsSubstring("escapes the plugin package"));
CHECK_FALSE(fs::exists(dir.path() / "escape.txt"));
}
+62 -2
View File
@@ -5,6 +5,7 @@
#include <ios>
#include <libslic3r/LifecycleEvents.hpp>
#include <memory>
#include <slic3r/Utils/Http.hpp>
#include <slic3r/Utils/NetworkAgentFactory.hpp>
#include <slic3r/plugin/PluginDescriptor.hpp>
#include <slic3r/plugin/PluginManager.hpp>
@@ -14,8 +15,13 @@
#include <catch2/catch_test_macros.hpp>
#include <catch2/catch_message.hpp>
#include "plugin_test_utils.hpp"
#include "test_utils.hpp"
#include <boost/filesystem.hpp>
#include <pybind11/cast.h>
#include <pybind11/gil.h>
#include <pybind11/pybind11.h>
#include <pybind11/pytypes.h>
#include <algorithm>
#include <chrono>
@@ -615,8 +621,22 @@ TEST_CASE("Python module release removes package submodules and owned sys.path",
// Ensure the next import executes the new submodule rather than reusing a stale package child.
write_package("'new'");
boost::system::error_code ec;
fs::remove_all(package_root / "__pycache__", ec);
{
// The rewrite can keep the old mtime and size, so drop the bytecode wherever the
// interpreter caches it.
PythonGILState gil;
REQUIRE(gil);
PyObjectPtr util(PyImport_ImportModule("importlib.util"));
REQUIRE(util);
for (const char* file : {"__init__.py", "sub.py"}) {
PyObjectPtr source(PyUnicode_DecodeFSDefault((package_root / file).string().c_str()));
REQUIRE(source);
PyObjectPtr cached(PyObject_CallMethod(util.get(), "cache_from_source", "O", source.get()));
REQUIRE(cached);
boost::system::error_code ec;
fs::remove(fs::path(PyUnicode_AsUTF8(cached.get())), ec);
}
}
paths.clear();
modules.clear();
@@ -1054,3 +1074,43 @@ TEST_CASE("Unloading a plugin that is not loaded is a no-op", "[PluginLifecycle]
CHECK(manager.unload_plugin("No_Such_Plugin"));
CHECK_FALSE(manager.is_plugin_loaded("No_Such_Plugin"));
}
namespace {
// CA certificates in the default client context, as a plugin's urllib or http.client request would
// verify against.
int default_context_ca_count()
{
pybind11::gil_scoped_acquire gil;
const pybind11::dict stats = pybind11::module_::import("ssl").attr("create_default_context")().attr("cert_store_stats")();
return stats["x509_ca"].cast<int>();
}
} // namespace
TEST_CASE("The bundled Python trusts the system CA certificates", "[PluginLifecycle][Python]")
{
// The app runs this before plugins load; on Linux it is what points OpenSSL at the distro bundle.
Http::tls_global_init();
ScopedPluginManager plugin_system;
if (!plugin_system.initialized)
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
CHECK(default_context_ca_count() > 0);
}
#ifndef _WIN32 // Windows adds its system store whatever SSL_CERT_FILE names.
TEST_CASE("SSL_CERT_FILE still overrides the bundled Python's default CA certificates", "[PluginLifecycle][Python]")
{
// Existing but empty: Linux's CurlGlobalInit replaces an SSL_CERT_FILE that names a missing file.
ScopedTemporaryFile empty_bundle(".pem");
std::ofstream(empty_bundle.string()).close();
ScopedEnvVar ssl_cert_file{"SSL_CERT_FILE", empty_bundle.string()};
ScopedPluginManager plugin_system;
if (!plugin_system.initialized)
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
CHECK(default_context_ca_count() == 0);
}
#endif
@@ -0,0 +1,941 @@
// ResourceTexture plugin type: directory validation, the host registry's lifecycle, the texture
// listing's plugin section, hostile get_texture_dir() overrides, decode parity and the .3mf round
// trip without the pack installed.
//
// The GUI wiring lives in GUI_App::init_plugin_gui_wiring; the test binary has no app, so the same
// registry callbacks are installed here. As in test_plugin_lifecycle.cpp, each Catch2 test case runs
// in its own process (catch_discover_tests), so the PluginManager and interpreter come up at most once
// per test.
//
// Same Windows include prologue as test_texture_color_palette.cpp: wx pulls in <windows.h>, so keep
// WIN32_LEAN_AND_MEAN / NOMINMAX ahead of the Catch2 headers.
#include "test_utils.hpp"
#include <slic3r/plugin/PluginDescriptor.hpp>
#ifdef WIN32
#ifndef WIN32_LEAN_AND_MEAN
#define WIN32_LEAN_AND_MEAN
#endif
#ifndef NOMINMAX
#define NOMINMAX
#endif
#include <Windows.h>
#endif
#include <boost/filesystem.hpp>
#include <boost/filesystem/operations.hpp>
#include <boost/filesystem/path.hpp>
#include <boost/system/error_code.hpp>
#include <catch2/catch_all.hpp>
#include <catch2/catch_message.hpp>
#include <catch2/catch_test_macros.hpp>
#include <algorithm>
#include <chrono>
#include <cstddef>
#include <cstdint>
#include <fstream>
#include <initializer_list>
#include <ios>
#include <iterator>
#include <memory>
#include <string>
#include <vector>
#include <wx/image.h>
#include "libslic3r/Config.hpp"
#include "libslic3r/Format/bbs_3mf.hpp"
#include "libslic3r/Model.hpp"
#include "libslic3r/PNGReadWrite.hpp"
#include "libslic3r/Preset.hpp"
#include "libslic3r/PrintConfig.hpp"
#include "libslic3r/Semver.hpp"
#include "libslic3r/TextureDisplacement.hpp"
#include "libslic3r/TriangleMesh.hpp"
#include "libslic3r/Utils.hpp"
#include "libslic3r/Zipper.hpp"
#include "slic3r/GUI/TextureLibrary.hpp"
#include "slic3r/plugin/PluginFsUtils.hpp"
#include "slic3r/plugin/PluginManager.hpp"
#include "slic3r/plugin/PythonInterpreter.hpp"
#include "slic3r/plugin/PythonPluginInterface.hpp"
#include "slic3r/plugin/host/PluginResourceTextures.hpp"
#include "plugin_test_utils.hpp"
using namespace Slic3r;
namespace fs = boost::filesystem;
namespace {
// Brings the plugin system up, and tears it down explicitly at the end of the test, as
// test_plugin_lifecycle.cpp does. Declare this FIRST in a test so it is destroyed last.
struct ScopedPluginManager
{
// Before initialize(): the interpreter creates {data_dir}/python/packages and {data_dir}/log.
ScopedDataDir python_data_dir{"texture-python"};
bool initialized = false;
ScopedPluginManager() { initialized = PluginManager::instance().initialize(); }
~ScopedPluginManager()
{
PluginManager::instance().shutdown();
PythonInterpreter::instance().shutdown();
}
};
// The app installs these in GUI_App::init_plugin_gui_wiring. Without an app the callbacks run inline
// on the load worker, which is what lets the tests observe the registry right after the wait.
void install_resource_texture_wiring()
{
static bool installed = false;
if (installed)
return;
installed = true;
PluginManager& manager = PluginManager::instance();
manager.subscribe_on_load_callback([](const std::string& plugin_key) {
PluginResourceTextures::instance().on_plugin_register(plugin_key);
GUI::bump_texture_library_generation();
});
manager.subscribe_on_unload_callback([](const std::string& plugin_key) {
PluginResourceTextures::instance().on_plugin_deregister(plugin_key);
GUI::bump_texture_library_generation();
});
manager.subscribe_on_capability_load_callback([](const PluginCapabilityId& capability) {
if (capability.type != PluginCapabilityType::ResourceTexture)
return;
PluginResourceTextures::instance().on_cap_register(capability);
GUI::bump_texture_library_generation();
});
manager.subscribe_on_capability_unload_callback([](const PluginCapabilityId& capability) {
if (capability.type != PluginCapabilityType::ResourceTexture)
return;
PluginResourceTextures::instance().on_cap_deregister(capability);
GUI::bump_texture_library_generation();
});
}
// A minimal resource-texture plugin. `display_name` is the PEP723 package name and, unless
// `capability_name` is given, the capability's get_name() too; pass `capability_name` to keep the
// package name and the capability name independent.
std::string make_resource_texture_source(const std::string& display_name,
const std::string& get_texture_dir_body,
const std::string& capability_name = "")
{
const std::string cap_name = capability_name.empty() ? display_name : capability_name;
return std::string{}
+ "# /// script\n"
+ "# requires-python = \">=3.12\"\n"
+ "# dependencies = []\n"
+ "#\n"
+ "# [tool.orcaslicer.plugin]\n"
+ "# name = \"" + display_name + "\"\n"
+ "# description = \"Resource texture pack fixture.\"\n"
+ "# author = \"tests\"\n"
+ "# version = \"1.0.0\"\n"
+ "# type = \"resource-texture\"\n"
+ "# ///\n"
+ "import os\n"
+ "import orca\n"
+ "\n\n"
+ "class PackTextures(orca.resource_texture.ResourceTexturePluginCapabilityBase):\n"
+ " def get_name(self):\n"
+ " return \"" + cap_name + "\"\n"
+ "\n"
+ " def get_texture_dir(self):\n"
+ " " + get_texture_dir_body + "\n"
+ "\n\n"
+ "@orca.plugin\n"
+ "class TexturePack(orca.base):\n"
+ " def register_capabilities(self):\n"
+ " orca.register_capability(PackTextures)\n";
}
std::string default_texture_dir_body()
{
return "return os.path.join(os.path.dirname(__file__), \"textures\")";
}
// Writes {data_dir}/orca_plugins/<stem>/<stem>.py and returns the plugin directory.
fs::path write_plugin(const ScopedDataDir& data_dir_guard, const std::string& stem, const std::string& source)
{
const fs::path plugin_dir = data_dir_guard.plugins_dir() / stem;
fs::create_directories(plugin_dir);
std::ofstream out((plugin_dir / (stem + ".py")).string(), std::ios::binary);
out << source;
out.close();
return plugin_dir;
}
bool load_and_wait(PluginManager& manager, const std::string& plugin_key, std::string& error)
{
manager.load_plugin(plugin_key, /*skip_deps=*/true);
return manager.wait_for_plugin_load(plugin_key, std::chrono::seconds(120), error);
}
// The Plugin-sourced slice of the texture listing, which is exactly what the picker's third heading
// draws. Pass force to call texture_library(true); without it the listing is reused unless a pack
// callback moved the generation.
std::vector<GUI::TextureLibraryEntry> plugin_textures(bool force)
{
std::vector<GUI::TextureLibraryEntry> found;
for (const GUI::TextureLibraryEntry& entry : GUI::texture_library(force))
if (entry.source == GUI::TextureSource::Plugin)
found.push_back(entry);
return found;
}
void write_text_file(const fs::path& path, const std::string& content = "x")
{
fs::create_directories(path.parent_path());
std::ofstream out(path.string(), std::ios::binary);
out << content;
}
std::vector<unsigned char> read_file_bytes(const fs::path& path)
{
std::ifstream ifs(path.string(), std::ios::binary);
return std::vector<unsigned char>(std::istreambuf_iterator<char>(ifs), std::istreambuf_iterator<char>());
}
void write_bytes(const fs::path& path, const std::vector<unsigned char>& bytes)
{
fs::create_directories(path.parent_path());
std::ofstream out(path.string(), std::ios::binary);
out.write(reinterpret_cast<const char*>(bytes.data()), std::streamsize(bytes.size()));
}
void write_gray_png(const fs::path& path, uint8_t value, size_t w = 4, size_t h = 4)
{
fs::create_directories(path.parent_path());
const std::vector<uint8_t> pixels(w * h, value);
REQUIRE(Slic3r::png::write_gray_to_file(path.string(), w, h, pixels));
}
// A 24-bit gray BMP (R == G == B), synthesised without an image toolkit. Rows are bottom-up and
// padded to four bytes; the image is uniform, so the orientation does not matter.
std::vector<unsigned char> make_gray_bmp(int width, int height, uint8_t value)
{
const int row_bytes = ((width * 3 + 3) / 4) * 4;
const uint32_t pixel_bytes = uint32_t(row_bytes) * uint32_t(height);
const uint32_t file_size = 14 + 40 + pixel_bytes;
std::vector<unsigned char> bmp(file_size, 0);
const auto put16 = [&bmp](size_t at, uint16_t v) {
bmp[at] = uint8_t(v);
bmp[at + 1] = uint8_t(v >> 8);
};
const auto put32 = [&bmp](size_t at, uint32_t v) {
bmp[at] = uint8_t(v);
bmp[at + 1] = uint8_t(v >> 8);
bmp[at + 2] = uint8_t(v >> 16);
bmp[at + 3] = uint8_t(v >> 24);
};
bmp[0] = 'B';
bmp[1] = 'M';
put32(2, file_size);
put32(10, 54); // pixel data offset
put32(14, 40); // BITMAPINFOHEADER size
put32(18, uint32_t(width));
put32(22, uint32_t(height));
put16(26, 1); // planes
put16(28, 24); // bits per pixel
put32(34, pixel_bytes);
for (int y = 0; y < height; ++y) {
unsigned char* row = bmp.data() + 54 + size_t(y) * size_t(row_bytes);
for (int x = 0; x < width; ++x) {
row[x * 3] = value;
row[x * 3 + 1] = value;
row[x * 3 + 2] = value;
}
}
return bmp;
}
// The app calls wxInitAllImageHandlers() during startup and, before that, wxEntryStart() runs the
// wxImageModule, which registers the standard handlers. wxInitAllImageHandlers() adds PNG, JPEG and
// the rest but NOT the BMP handler, so a test binary without a wxApp must add the standard set too or
// a .bmp texture cannot be loaded here at all.
void ensure_wx_image_handlers()
{
static bool initialized = false;
if (initialized)
return;
initialized = true;
wxImage::InitStandardHandlers();
wxInitAllImageHandlers();
}
// Stores `model` as a one-plate project, mirroring tests/libslic3r/test_3mf.cpp.
void store_project(Model& model, const std::string& path)
{
ScopedTemporaryDir backup_dir("orca_tex_pack_src");
model.set_backup_path(backup_dir.string());
DynamicPrintConfig cfg;
PlateData plate;
plate.plate_index = 0;
StoreParams sp;
sp.path = path.c_str();
sp.model = &model;
sp.config = &cfg;
sp.strategy = SaveStrategy::Zip64 | SaveStrategy::Silence;
sp.plate_data_list.push_back(&plate);
REQUIRE(store_bbs_3mf(sp));
}
bool load_project(const std::string& path, Model& model, const ScopedTemporaryDir& backup_dir)
{
model.set_backup_path(backup_dir.string());
DynamicPrintConfig config;
ConfigSubstitutionContext ctxt{ ForwardCompatibilitySubstitutionRule::Enable };
PlateDataPtrs plates;
std::vector<Preset*> project_presets;
bool is_bbl_3mf = false;
bool is_orca_3mf = false;
Semver file_version;
const bool loaded = load_bbs_3mf(path.c_str(), &config, &ctxt, &model, &plates, &project_presets,
&is_bbl_3mf, &is_orca_3mf, &file_version, nullptr,
LoadStrategy::LoadModel | LoadStrategy::LoadConfig);
release_PlateData_list(plates);
return loaded;
}
} // namespace
// ── Validation (collect_resource_texture_files, no Python) ──────────────────────────────────────
TEST_CASE("A texture file directly inside the plugin folder is listed", "[PluginResourceTexture]")
{
ScopedTemporaryDir root("tex-root");
write_text_file(root.path() / "textures" / "grain.png");
std::string error;
const std::vector<std::string> files =
collect_resource_texture_files((root.path() / "textures").string(), root.string(), error);
CHECK(error.empty());
REQUIRE(files.size() == 1);
CHECK(fs::path(files.front()).filename() == "grain.png");
}
TEST_CASE("A directory reached with a parent-directory escape is refused", "[PluginResourceTexture]")
{
ScopedTemporaryDir parent("tex-escape");
const fs::path root = parent.path() / "pack";
const fs::path outside = parent.path() / "outside";
fs::create_directories(root);
write_text_file(outside / "leak.png");
std::string error;
const std::vector<std::string> files =
collect_resource_texture_files((root / ".." / "outside").string(), root.string(), error);
CHECK(files.empty());
CHECK_FALSE(error.empty());
}
TEST_CASE("An absolute directory outside the plugin folder is refused", "[PluginResourceTexture]")
{
ScopedTemporaryDir root("tex-abs-root");
ScopedTemporaryDir elsewhere("tex-abs-elsewhere");
write_text_file(elsewhere.path() / "leak.png");
std::string error;
const std::vector<std::string> files = collect_resource_texture_files(elsewhere.string(), root.string(), error);
CHECK(files.empty());
CHECK_FALSE(error.empty());
}
TEST_CASE("A symlink pointing outside the plugin folder is skipped, not reported as an error", "[PluginResourceTexture]")
{
ScopedTemporaryDir root("tex-symlink-root");
ScopedTemporaryDir target("tex-symlink-target");
write_text_file(target.path() / "secret.png");
const fs::path link = root.path() / "textures" / "link.png";
fs::create_directories(link.parent_path());
boost::system::error_code ec;
fs::create_symlink(target.path() / "secret.png", link, ec);
if (ec)
SKIP("symlinks unavailable on this filesystem: " + ec.message());
std::string error;
const std::vector<std::string> files =
collect_resource_texture_files(link.parent_path().string(), root.string(), error);
// The directory itself is legitimate; the escaping link is dropped, not an error.
CHECK(error.empty());
CHECK(files.empty());
}
TEST_CASE("Files in a subdirectory of the texture directory are not listed", "[PluginResourceTexture]")
{
ScopedTemporaryDir root("tex-recursive");
write_text_file(root.path() / "top.png");
write_text_file(root.path() / "nested" / "deep.png");
std::string error;
const std::vector<std::string> files = collect_resource_texture_files(root.string(), root.string(), error);
CHECK(error.empty());
REQUIRE(files.size() == 1);
CHECK(fs::path(files.front()).filename() == "top.png");
}
TEST_CASE("An entry that is not a regular file is skipped", "[PluginResourceTexture]")
{
ScopedTemporaryDir root("tex-nonregular");
write_text_file(root.path() / "real.png");
fs::create_directories(root.path() / "fake.png"); // a directory named like an image
std::string error;
const std::vector<std::string> files = collect_resource_texture_files(root.string(), root.string(), error);
CHECK(error.empty());
REQUIRE(files.size() == 1);
CHECK(fs::path(files.front()).filename() == "real.png");
}
TEST_CASE("Image extensions are matched case-insensitively and other extensions are ignored",
"[PluginResourceTexture]")
{
ScopedTemporaryDir root("tex-extensions");
for (const char* name : { "a.png", "b.PNG", "c.jpg", "d.JPG", "e.jpeg", "f.JPEG", "g.bmp", "h.BMP" })
write_text_file(root.path() / name);
for (const char* name : { "i.txt", "j.gif", "k.pngx", "l.pn", "m.png.bak" })
write_text_file(root.path() / name);
std::string error;
const std::vector<std::string> files = collect_resource_texture_files(root.string(), root.string(), error);
CHECK(error.empty());
CHECK(files.size() == 8);
std::vector<std::string> names;
for (const std::string& file : files)
names.push_back(fs::path(file).filename().string());
for (const char* accepted : { "a.png", "b.PNG", "c.jpg", "d.JPG", "e.jpeg", "f.JPEG", "g.bmp", "h.BMP" })
CHECK(std::find(names.begin(), names.end(), accepted) != names.end());
for (const char* rejected : { "i.txt", "j.gif", "k.pngx", "l.pn", "m.png.bak" })
CHECK(std::find(names.begin(), names.end(), rejected) == names.end());
}
TEST_CASE("The listing stops at 64 image files", "[PluginResourceTexture]")
{
ScopedTemporaryDir root("tex-cap");
for (int i = 0; i < 70; ++i)
write_text_file(root.path() / ("file" + std::to_string(i) + ".png"));
std::string error;
const std::vector<std::string> files = collect_resource_texture_files(root.string(), root.string(), error);
CHECK(error.empty());
CHECK(files.size() == 64);
}
TEST_CASE("A missing texture directory yields no files and an error", "[PluginResourceTexture]")
{
ScopedTemporaryDir root("tex-missing");
std::string error;
const std::vector<std::string> files =
collect_resource_texture_files((root.path() / "textures").string(), root.string(), error);
CHECK(files.empty());
CHECK_FALSE(error.empty());
}
// ── Lifecycle through the real listing ──────────────────────────────────────────────────────────
TEST_CASE("A resource-texture pack's images appear in the listing and clear on disable and unload",
"[PluginResourceTexture][PluginLifecycle][Python]")
{
ScopedPluginManager plugin_system;
if (!plugin_system.initialized)
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
ScopedDataDir data_dir_guard("texture-listing");
const std::string plugin_key = "Texture_Pack";
const fs::path plugin_dir =
write_plugin(data_dir_guard, plugin_key, make_resource_texture_source("Texture Pack", default_texture_dir_body()));
const fs::path textures = plugin_dir / "textures";
write_gray_png(textures / "alpha.png", 0x20);
write_gray_png(textures / "beta.png", 0x80);
write_text_file(textures / "gamma.jpg"); // the listing only checks the extension; decode is covered below
write_text_file(textures / "notes.txt");
// A user-owned texture, so the listing has a non-pack entry whose provenance must be empty.
write_gray_png(fs::path(GUI::user_texture_dir()) / "user_made.png", 0x40);
PluginManager& manager = PluginManager::instance();
install_resource_texture_wiring();
manager.discover_plugins(/*async=*/false, /*clear=*/true);
std::string error;
REQUIRE(load_and_wait(manager, plugin_key, error));
INFO("load error: " << error);
CHECK(error.empty());
CHECK(PluginResourceTextures::instance().entries().size() == 1);
const uint64_t loaded_generation = GUI::texture_library_generation();
const auto loaded = plugin_textures(true);
REQUIRE(loaded.size() == 3);
std::vector<std::string> names;
for (const GUI::TextureLibraryEntry& entry : loaded)
names.push_back(entry.name);
CHECK(std::find(names.begin(), names.end(), "alpha") != names.end());
CHECK(std::find(names.begin(), names.end(), "beta") != names.end());
CHECK(std::find(names.begin(), names.end(), "gamma") != names.end());
CHECK(std::find(names.begin(), names.end(), "notes") == names.end());
// Every pack entry names its contributing pack; shipped and user entries carry no pack.
for (const GUI::TextureLibraryEntry& entry : GUI::texture_library(true)) {
if (entry.source == GUI::TextureSource::Plugin) {
CHECK_FALSE(entry.pack_name.empty());
CHECK(entry.pack_name == "Texture Pack");
} else {
CHECK(entry.pack_name.empty());
}
}
// A no-argument call reuses the cached listing until a pack callback moves the generation.
CHECK(plugin_textures(false).size() == 3);
manager.set_capability_enabled({ PluginCapabilityType::ResourceTexture, "Texture Pack", plugin_key }, false);
CHECK(GUI::texture_library_generation() > loaded_generation);
CHECK(PluginResourceTextures::instance().entries().empty());
CHECK(plugin_textures(false).empty());
manager.set_capability_enabled({ PluginCapabilityType::ResourceTexture, "Texture Pack", plugin_key }, true);
CHECK(plugin_textures(false).size() == 3);
REQUIRE(manager.unload_plugin(plugin_key));
CHECK(PluginResourceTextures::instance().entries().empty());
CHECK(plugin_textures(false).empty());
}
TEST_CASE("Two distinctly named packs each list as one contiguous run",
"[PluginResourceTexture][Python]")
{
ScopedPluginManager plugin_system;
if (!plugin_system.initialized)
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
ScopedDataDir data_dir_guard("texture-contiguous");
// Loaded out of name order, so it is the listing's pack-name sort that orders the runs.
const std::string key_zeta = "Pack_Zeta";
const std::string key_alpha = "Pack_Alpha";
const fs::path dir_zeta = write_plugin(
data_dir_guard, key_zeta, make_resource_texture_source("Zeta Pack", default_texture_dir_body()));
const fs::path dir_alpha = write_plugin(
data_dir_guard, key_alpha, make_resource_texture_source("Alpha Pack", default_texture_dir_body()));
write_gray_png(dir_zeta / "textures" / "zeta_one.png", 0x20);
write_gray_png(dir_zeta / "textures" / "zeta_two.png", 0x40);
write_gray_png(dir_alpha / "textures" / "alpha_one.png", 0x60);
write_gray_png(dir_alpha / "textures" / "alpha_two.png", 0x80);
PluginManager& manager = PluginManager::instance();
install_resource_texture_wiring();
manager.discover_plugins(/*async=*/false, /*clear=*/true);
std::string error;
REQUIRE(load_and_wait(manager, key_zeta, error));
REQUIRE(load_and_wait(manager, key_alpha, error));
std::vector<GUI::TextureLibraryEntry> pack_entries;
for (const GUI::TextureLibraryEntry& entry : GUI::texture_library(true))
if (entry.source == GUI::TextureSource::Plugin)
pack_entries.push_back(entry);
REQUIRE(pack_entries.size() == 4);
const auto count_pack = [&pack_entries](const std::string& name) {
return std::count_if(pack_entries.begin(), pack_entries.end(),
[&name](const GUI::TextureLibraryEntry& e) { return e.pack_name == name; });
};
CHECK(count_pack("Zeta Pack") == 2);
CHECK(count_pack("Alpha Pack") == 2);
// Collapsing consecutive entries by pack name must leave one run per pack: a name never reappears
// after another name intervenes, which is what lets the picker draw one section per pack.
std::vector<std::string> runs;
for (const GUI::TextureLibraryEntry& entry : pack_entries)
if (runs.empty() || runs.back() != entry.pack_name)
runs.push_back(entry.pack_name);
std::vector<std::string> sorted_runs = runs;
std::sort(sorted_runs.begin(), sorted_runs.end());
CHECK(std::unique(sorted_runs.begin(), sorted_runs.end()) == sorted_runs.end());
CHECK(runs == std::vector<std::string>({ "Alpha Pack", "Zeta Pack" }));
manager.unload_plugin(key_zeta);
manager.unload_plugin(key_alpha);
}
TEST_CASE("A second pack declaring a loaded capability name is refused",
"[PluginResourceTexture][Python]")
{
ScopedPluginManager plugin_system;
if (!plugin_system.initialized)
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
ScopedDataDir data_dir_guard("texture-capability-clash");
// Distinct package names, one capability name: the loader allows only the first.
const std::string key_first = "Clash_First";
const std::string key_second = "Clash_Second";
const std::string capability = "Shared Textures";
const fs::path dir_first = write_plugin(
data_dir_guard, key_first,
make_resource_texture_source("Clash Package One", default_texture_dir_body(), capability));
write_plugin(data_dir_guard, key_second,
make_resource_texture_source("Clash Package Two", default_texture_dir_body(), capability));
write_gray_png(dir_first / "textures" / "one.png", 0x20);
write_gray_png(dir_first / "textures" / "two.png", 0x40);
PluginManager& manager = PluginManager::instance();
install_resource_texture_wiring();
manager.discover_plugins(/*async=*/false, /*clear=*/true);
std::string error;
REQUIRE(load_and_wait(manager, key_first, error));
REQUIRE(manager.is_plugin_loaded(key_first));
const std::vector<GUI::TextureLibraryEntry> before = plugin_textures(true);
REQUIRE(before.size() == 2);
for (const GUI::TextureLibraryEntry& entry : before)
CHECK(entry.pack_name == capability);
// The second package claims the capability name the first already owns, so the load is refused.
std::string second_error;
CHECK_FALSE(load_and_wait(manager, key_second, second_error));
CHECK_FALSE(manager.is_plugin_loaded(key_second));
// The first pack's textures are untouched by the refused load.
const std::vector<GUI::TextureLibraryEntry> after = plugin_textures(true);
REQUIRE(after.size() == before.size());
for (size_t i = 0; i < before.size(); ++i) {
CHECK(after[i].name == before[i].name);
CHECK(after[i].path == before[i].path);
CHECK(after[i].pack_name == before[i].pack_name);
}
manager.unload_plugin(key_first);
}
TEST_CASE("A resource-texture pack with no images contributes no textures", "[PluginResourceTexture][Python]")
{
ScopedPluginManager plugin_system;
if (!plugin_system.initialized)
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
ScopedDataDir data_dir_guard("texture-empty");
const std::string plugin_key = "Empty_Pack";
const fs::path plugin_dir =
write_plugin(data_dir_guard, plugin_key, make_resource_texture_source("Empty Pack", default_texture_dir_body()));
fs::create_directories(plugin_dir / "textures"); // valid, but empty
PluginManager& manager = PluginManager::instance();
install_resource_texture_wiring();
manager.discover_plugins(/*async=*/false, /*clear=*/true);
std::string error;
REQUIRE(load_and_wait(manager, plugin_key, error));
CHECK(manager.is_plugin_loaded(plugin_key));
CHECK(plugin_textures(true).empty());
manager.unload_plugin(plugin_key);
}
// ── Hostile get_texture_dir() overrides ─────────────────────────────────────────────────────────
TEST_CASE("A texture directory outside the plugin root contributes no textures", "[PluginResourceTexture][Python]")
{
ScopedPluginManager plugin_system;
if (!plugin_system.initialized)
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
ScopedDataDir data_dir_guard("texture-hostile");
ScopedResourcesDir resources_dir_guard("texture-hostile-res");
write_text_file(fs::path(resources_dir_guard.dir) / "sentinel.png"); // would list if it were scanned
const std::string data_pack = "Data_Dir_Pack";
write_plugin(data_dir_guard, data_pack,
make_resource_texture_source("Data Dir Pack", "return r\"" + data_dir_guard.dir.string() + "\""));
const std::string resources_pack = "Resources_Dir_Pack";
write_plugin(data_dir_guard, resources_pack,
make_resource_texture_source("Resources Dir Pack", "return r\"" + resources_dir_guard.dir.string() + "\""));
PluginManager& manager = PluginManager::instance();
install_resource_texture_wiring();
manager.discover_plugins(/*async=*/false, /*clear=*/true);
std::string error;
REQUIRE(load_and_wait(manager, data_pack, error));
REQUIRE(load_and_wait(manager, resources_pack, error));
// The packs load; their out-of-root directories are simply refused.
CHECK(manager.is_plugin_loaded(data_pack));
CHECK(manager.is_plugin_loaded(resources_pack));
CHECK(PluginResourceTextures::instance().entries().empty());
CHECK(plugin_textures(true).empty());
manager.unload_plugin(data_pack);
manager.unload_plugin(resources_pack);
}
TEST_CASE("A raising get_texture_dir leaves the pack loaded with no textures", "[PluginResourceTexture][Python]")
{
ScopedPluginManager plugin_system;
if (!plugin_system.initialized)
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
ScopedDataDir data_dir_guard("texture-raising");
const std::string plugin_key = "Raising_Pack";
write_plugin(data_dir_guard, plugin_key,
make_resource_texture_source("Raising Pack", "raise RuntimeError(\"no textures today\")"));
PluginManager& manager = PluginManager::instance();
install_resource_texture_wiring();
manager.discover_plugins(/*async=*/false, /*clear=*/true);
std::string error;
REQUIRE(load_and_wait(manager, plugin_key, error));
CHECK(error.empty());
CHECK(manager.is_plugin_loaded(plugin_key));
CHECK(PluginResourceTextures::instance().entries().empty());
CHECK(plugin_textures(true).empty());
manager.unload_plugin(plugin_key);
}
// ── Decoding parity ─────────────────────────────────────────────────────────────────────────────
TEST_CASE("Pack images decode through the same path as hand-picked files", "[PluginResourceTexture]")
{
ensure_wx_image_handlers();
ScopedTemporaryDir pack_root("tex-pack");
ScopedTemporaryDir hand_root("tex-hand");
const fs::path pack_dir = pack_root.path() / "textures";
fs::create_directories(pack_dir);
// PNG: grayscale, so load_texture_image_data passes it through verbatim.
const fs::path pack_png = pack_dir / "grain.png";
write_gray_png(pack_png, 0x40);
const std::vector<unsigned char> png_bytes = read_file_bytes(pack_png);
REQUIRE_FALSE(png_bytes.empty());
write_bytes(hand_root.path() / "grain.png", png_bytes);
// BMP: not a PNG, so it goes through wxImage and is re-encoded; the same file must give the same
// bytes wherever it sits.
const std::vector<unsigned char> bmp_bytes = make_gray_bmp(4, 4, 0x40);
const fs::path pack_bmp = pack_dir / "bump.bmp";
write_bytes(pack_bmp, bmp_bytes);
const fs::path hand_bmp = hand_root.path() / "bump.bmp";
write_bytes(hand_bmp, bmp_bytes);
// The collector lists both pack files, so these are the paths a pack would serve.
std::string collect_error;
const std::vector<std::string> pack_files =
collect_resource_texture_files(pack_dir.string(), pack_root.string(), collect_error);
REQUIRE(collect_error.empty());
CHECK(pack_files.size() == 2);
std::string pack_error;
std::string hand_error;
const auto pack_png_decoded = GUI::load_texture_image_data(pack_png.string(), pack_error);
const auto hand_png_decoded = GUI::load_texture_image_data((hand_root.path() / "grain.png").string(), hand_error);
REQUIRE(pack_png_decoded != nullptr);
REQUIRE(hand_png_decoded != nullptr);
CHECK(*pack_png_decoded == png_bytes); // supported PNG is passed through unchanged
CHECK(*pack_png_decoded == *hand_png_decoded);
const auto pack_bmp_decoded = GUI::load_texture_image_data(pack_bmp.string(), pack_error);
const auto hand_bmp_decoded = GUI::load_texture_image_data(hand_bmp.string(), hand_error);
REQUIRE(pack_bmp_decoded != nullptr);
REQUIRE(hand_bmp_decoded != nullptr);
REQUIRE_FALSE(pack_bmp_decoded->empty());
CHECK(*pack_bmp_decoded == *hand_bmp_decoded);
}
TEST_CASE("Reinstalling a pack with different bytes at the same path changes the decoded content",
"[PluginResourceTexture][Python]")
{
ScopedPluginManager plugin_system;
if (!plugin_system.initialized)
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
ScopedDataDir data_dir_guard("texture-reinstall");
const std::string plugin_key = "Reinstall_Pack";
const fs::path plugin_dir =
write_plugin(data_dir_guard, plugin_key, make_resource_texture_source("Reinstall Pack", default_texture_dir_body()));
const fs::path texture = plugin_dir / "textures" / "grain.png";
write_gray_png(texture, 0x10);
PluginManager& manager = PluginManager::instance();
install_resource_texture_wiring();
manager.discover_plugins(/*async=*/false, /*clear=*/true);
std::string error;
REQUIRE(load_and_wait(manager, plugin_key, error));
auto loaded = plugin_textures(true);
REQUIRE(loaded.size() == 1);
const std::string path = loaded.front().path;
std::string decode_error;
const auto first = GUI::load_texture_image_data(path, decode_error);
REQUIRE(first != nullptr);
REQUIRE(manager.unload_plugin(plugin_key));
// The reinstalled pack writes different bytes to the same file path.
write_gray_png(texture, 0xE0);
REQUIRE(load_and_wait(manager, plugin_key, error));
loaded = plugin_textures(true);
REQUIRE(loaded.size() == 1);
CHECK(loaded.front().path == path);
const auto second = GUI::load_texture_image_data(path, decode_error);
REQUIRE(second != nullptr);
CHECK(*first != *second);
}
// ── Wheel metadata ──────────────────────────────────────────────────────────────────────────────
TEST_CASE("A minimal resource-texture wheel is accepted", "[PluginResourceTexture][Wheel]")
{
ScopedTemporaryDir dir("texture-wheel");
const fs::path wheel = dir.path() / "texture_pack-1.0-py3-none-any.whl";
const std::string dist_info = "texture_pack-1.0.dist-info";
const std::string metadata =
"Metadata-Version: 2.1\n"
"Name: texture_pack\n"
"Version: 1.0\n"
"Summary: Resource texture pack fixture\n"
"Author: tests\n";
const std::string wheel_metadata =
"Wheel-Version: 1.0\n"
"Generator: manual\n"
"Root-Is-Purelib: true\n"
"Tag: py3-none-any\n";
const std::string record = dist_info + "/METADATA,,\n" + dist_info + "/WHEEL,,\n" + dist_info + "/RECORD,,\n";
const std::string top_level = "texture_pack\n";
{
Zipper zipper(wheel.string(), Zipper::NO_COMPRESSION);
zipper.add_entry(dist_info + "/METADATA", metadata.data(), metadata.size());
zipper.add_entry(dist_info + "/WHEEL", wheel_metadata.data(), wheel_metadata.size());
zipper.add_entry(dist_info + "/RECORD", record.data(), record.size());
// read_wheel_plugin_metadata leaves `error` set when an optional top_level.txt is missing.
zipper.add_entry(dist_info + "/top_level.txt", top_level.data(), top_level.size());
zipper.finalize();
}
PluginDescriptor descriptor;
std::string error;
REQUIRE(read_wheel_plugin_metadata(wheel, descriptor, error));
INFO("wheel metadata error: " << error);
CHECK(error.empty());
CHECK(descriptor.name == "texture_pack");
CHECK(descriptor.version == "1.0");
CHECK(descriptor.entry_package == "texture_pack");
}
// ── .3mf round trip ─────────────────────────────────────────────────────────────────────────────
TEST_CASE("A project keeps a pack texture after the pack is uninstalled",
"[PluginResourceTexture][PluginLifecycle][Python][3mf]")
{
ScopedPluginManager plugin_system;
if (!plugin_system.initialized)
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
ScopedDataDir data_dir_guard("texture-3mf");
const std::string plugin_key = "Texture_Pack_3mf";
const fs::path plugin_dir =
write_plugin(data_dir_guard, plugin_key, make_resource_texture_source("Texture Pack", default_texture_dir_body()));
write_gray_png(plugin_dir / "textures" / "grain.png", 0x60);
PluginManager& manager = PluginManager::instance();
install_resource_texture_wiring();
manager.discover_plugins(/*async=*/false, /*clear=*/true);
std::string error;
REQUIRE(load_and_wait(manager, plugin_key, error));
const auto loaded = plugin_textures(true);
REQUIRE(loaded.size() == 1);
std::string decode_error;
const auto pack_bytes = GUI::load_texture_image_data(loaded.front().path, decode_error);
REQUIRE(pack_bytes != nullptr);
REQUIRE_FALSE(pack_bytes->empty());
// Build a project the way the gizmo would: the layer carries the pack texture's bytes.
Model model;
ModelObject* object = model.add_object();
ModelVolume* volume = object->add_volume(make_cube(10., 10., 10.));
object->add_instance();
TextureDisplacementLayer layer;
layer.slot = 0;
layer.name = loaded.front().name;
layer.path = loaded.front().path;
layer.image_data = pack_bytes;
volume->texture_displacement_layers.push_back(layer);
ScopedTemporaryFile first(".3mf");
store_project(model, first.string());
// Uninstall the pack; the project still has to open and re-save with the same image bytes.
REQUIRE(manager.unload_plugin(plugin_key));
CHECK(plugin_textures(true).empty());
ScopedTemporaryDir backup_dir("orca_tex_pack_dst");
Model reloaded;
REQUIRE(load_project(first.string(), reloaded, backup_dir));
REQUIRE(reloaded.objects.size() == 1);
REQUIRE(reloaded.objects.front()->volumes.size() == 1);
const std::vector<TextureDisplacementLayer>& reloaded_layers =
reloaded.objects.front()->volumes.front()->texture_displacement_layers;
REQUIRE(reloaded_layers.size() == 1);
REQUIRE(reloaded_layers.front().image_data);
CHECK(*reloaded_layers.front().image_data == *pack_bytes);
ScopedTemporaryFile second(".3mf");
store_project(reloaded, second.string());
ScopedTemporaryDir backup_dir2("orca_tex_pack_dst2");
Model reloaded_again;
REQUIRE(load_project(second.string(), reloaded_again, backup_dir2));
REQUIRE(reloaded_again.objects.size() == 1);
REQUIRE(reloaded_again.objects.front()->volumes.size() == 1);
const std::vector<TextureDisplacementLayer>& again_layers =
reloaded_again.objects.front()->volumes.front()->texture_displacement_layers;
REQUIRE(again_layers.size() == 1);
REQUIRE(again_layers.front().image_data);
CHECK(*again_layers.front().image_data == *pack_bytes);
}
@@ -0,0 +1,162 @@
#include <catch2/catch_test_macros.hpp>
#include <catch2/generators/catch_generators.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp>
#include "catch2/matchers/catch_matchers.hpp"
#include "fff_print/test_helpers.hpp"
#include "slic3r/GUI/SequentialPrintClearance.hpp"
#include <libslic3r/libslic3r.h>
#include <libslic3r/Model.hpp>
#include <libslic3r/TriangleMesh.hpp>
#include <libslic3r/PrintConfig.hpp>
#include <libslic3r/Print.hpp>
#include <libslic3r/Polygon.hpp>
#include <libslic3r/ObjectID.hpp>
#include <cstddef>
#include <map>
#include <vector>
#include <utility>
using namespace Slic3r;
using namespace Slic3r::GUI;
using Catch::Matchers::WithinAbs;
namespace {
SequentialClearanceInstance instance(coord_t x, coord_t y, double height)
{
return {height, BoundingBox(Point(x, y), Point(x + 5, y + 10)),
Polygon{Point(x, y), Point(x + 5, y), Point(x + 5, y + 10), Point(x, y + 10)}};
}
} // namespace
TEST_CASE("Sequential clearance preserves model print order when objects move", "[SequentialClearance][Regression]")
{
const coord_t last_y = GENERATE(-20, 20);
std::vector<SequentialClearanceInstance> instances{instance(20, 0, 50.), instance(0, last_y, 50.)};
const auto warnings = sequential_clearance_height_polygons(instances, 200., 30., 10.);
REQUIRE(warnings.size() == 1);
CHECK(warnings.front().first == instance(20, 0, 50.).hull_polygon);
CHECK_THAT(warnings.front().second, WithinAbs(30., 1e-6));
}
TEST_CASE("Sequential clearance handles chains of overlapping Y ranges", "[SequentialClearance][Regression]")
{
// A overlaps B, B overlaps C, but A does not overlap C. Combining X order
// for overlaps with Y order otherwise formed the cycle B < A < C < B.
std::vector<SequentialClearanceInstance> instances{
instance(20, 0, 50.), instance(10, 8, 50.), instance(0, 16, 50.)};
const auto warnings = sequential_clearance_height_polygons(instances, 200., 30., 10.);
REQUIRE(warnings.size() == 2);
CHECK(warnings[0].first == instance(20, 0, 50.).hull_polygon);
CHECK(warnings[1].first == instance(10, 8, 50.).hull_polygon);
CHECK_THAT(warnings[0].second, WithinAbs(10., 1e-6));
CHECK_THAT(warnings[1].second, WithinAbs(10., 1e-6));
}
TEST_CASE("Sequential clearance applies rod height only to positive Y overlap", "[SequentialClearance]")
{
const coord_t next_y = GENERATE(9, 10, 11);
std::vector<SequentialClearanceInstance> instances{instance(0, 0, 50.), instance(20, next_y, 50.)};
const auto warnings = sequential_clearance_height_polygons(instances, 200., 30., 10.);
REQUIRE(warnings.size() == 1);
CHECK(warnings.front().first == instances.front().hull_polygon);
CHECK_THAT(warnings.front().second, WithinAbs(next_y < 10 ? 10. : 30., 1e-6));
}
TEST_CASE("Sequential clearance allows the last object up to printable height", "[SequentialClearance]")
{
std::vector<SequentialClearanceInstance> instances;
CHECK(sequential_clearance_height_polygons(instances, 200., 30., 10.).empty());
instances.push_back(instance(0, 0, 200.));
CHECK(sequential_clearance_height_polygons(instances, 200., 30., 10.).empty());
instances.front().instance_height = 201.;
const auto warnings = sequential_clearance_height_polygons(instances, 200., 30., 10.);
REQUIRE(warnings.size() == 1);
CHECK_THAT(warnings.front().second, WithinAbs(200., 1e-6));
}
TEST_CASE("Sequential clearance warns the same rotated copy as print validation", "[SequentialClearance][Regression]")
{
constexpr double rod_height = 10.;
constexpr double lid_height = 30.;
constexpr double printable_height = 200.;
Model model = Test::model("rotated copies", make_cube(10., 10., 50.));
ModelObject* object = model.objects.front();
object->instances.front()->set_offset(Vec3d(150., 80., 0.));
ModelInstance* rotated = object->add_instance();
rotated->set_offset(Vec3d(50., 80., 0.));
rotated->set_rotation(Z, PI / 2.);
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
config.set_deserialize_strict({{"print_sequence", "by object"}, {"extruder_clearance_radius", 20.},
{"extruder_clearance_height_to_lid", lid_height}, {"extruder_clearance_height_to_rod", rod_height},
{"printable_height", printable_height}});
Print print;
print.auto_assign_extruders(object);
print.apply(model, config);
Polygons polygons;
std::vector<std::pair<Polygon, float>> validation_warnings;
Print::sequential_print_clearance_valid(print, &polygons, &validation_warnings);
std::map<ObjectID, int> print_order;
for (const PrintObject* print_object : print.objects())
for (const PrintInstance& print_instance : print_object->instances())
print_order.emplace(print_instance.model_instance->id(), print_instance.model_instance->arrange_order);
std::vector<SequentialClearanceInstance> instances;
for (size_t i = 0; i < object->instances.size(); ++i) {
Polygon hull = object->convex_hull_2d(object->instances[i]->get_matrix());
instances.push_back({object->get_instance_max_z(i), hull.bounding_box(), hull, object->instances[i]->id()});
}
const Polygon expected_warning = instances[1].hull_polygon;
REQUIRE(validation_warnings.size() == 1);
CHECK(validation_warnings.front().first.bounding_box().contains(instances[1].bounding_box.center()));
CHECK_FALSE(validation_warnings.front().first.bounding_box().contains(instances[0].bounding_box.center()));
sort_sequential_clearance_instances(instances.begin(), instances.end(), print_order);
const auto warnings = sequential_clearance_height_polygons(instances, printable_height, lid_height, rod_height);
REQUIRE(warnings.size() == 1);
CHECK(warnings.front().first == expected_warning);
CHECK_THAT(warnings.front().second, WithinAbs(rod_height, 1e-6));
}
TEST_CASE("Sequential clearance keeps copy order when print order is unavailable", "[SequentialClearance]")
{
std::vector<SequentialClearanceInstance> instances{instance(20, 0, 50.), instance(0, 0, 50.)};
instances[0].instance_id = ObjectID(1);
instances[1].instance_id = ObjectID(2);
std::map<ObjectID, int> print_order{{ObjectID(1), 2}, {ObjectID(2), 1}};
SECTION("No validation order") { print_order.clear(); }
SECTION("New copy missing from print") { print_order.erase(ObjectID(2)); }
SECTION("Copy has not been ordered") { print_order[ObjectID(2)] = 0; }
SECTION("Copy has an invalid order") { print_order[ObjectID(2)] = -1; }
sort_sequential_clearance_instances(instances.begin(), instances.end(), print_order);
CHECK(instances[0].instance_id == ObjectID(1));
CHECK(instances[1].instance_id == ObjectID(2));
}
TEST_CASE("Sequential clearance keeps object order when sorting copies", "[SequentialClearance]")
{
std::vector<SequentialClearanceInstance> instances(4, instance(0, 0, 50.));
for (size_t i = 0; i < instances.size(); ++i)
instances[i].instance_id = ObjectID(instances.size() - i);
const std::map<ObjectID, int> print_order{{ObjectID(1), 1}, {ObjectID(2), 2}, {ObjectID(3), 3}, {ObjectID(4), 4}};
// The two objects were reordered in the model since the last validation.
sort_sequential_clearance_instances(instances.begin(), instances.begin() + 2, print_order);
sort_sequential_clearance_instances(instances.begin() + 2, instances.end(), print_order);
CHECK(instances[0].instance_id == ObjectID(3));
CHECK(instances[1].instance_id == ObjectID(4));
CHECK(instances[2].instance_id == ObjectID(1));
CHECK(instances[3].instance_id == ObjectID(2));
}
+64
View File
@@ -126,6 +126,55 @@ TEST_CASE("Only modified or non-printable chords qualify as menu accelerators",
CHECK(registry.accelerator(Shortcut::KeyboardShortcuts).empty());
}
TEST_CASE("Typing, caret, deletion and clipboard chords edit text", "[Shortcuts]")
{
CHECK(KeyChord{ 'A' }.edits_text());
CHECK(KeyChord{ 'A', wxMOD_SHIFT }.edits_text());
CHECK(KeyChord{ WXK_SPACE }.edits_text());
CHECK(KeyChord{ WXK_BACK }.edits_text());
CHECK(KeyChord{ WXK_DELETE }.edits_text());
CHECK(KeyChord{ WXK_BACK, wxMOD_CONTROL }.edits_text());
CHECK(KeyChord{ WXK_LEFT, wxMOD_SHIFT }.edits_text());
CHECK(KeyChord{ WXK_RIGHT, wxMOD_CONTROL | wxMOD_SHIFT }.edits_text());
CHECK(KeyChord{ WXK_HOME }.edits_text());
CHECK(KeyChord{ WXK_END, wxMOD_CONTROL }.edits_text());
CHECK(KeyChord{ WXK_DELETE, wxMOD_SHIFT }.edits_text());
CHECK(KeyChord{ WXK_INSERT }.edits_text());
CHECK(KeyChord{ WXK_INSERT, wxMOD_SHIFT }.edits_text());
CHECK(KeyChord{ WXK_INSERT, wxMOD_CONTROL }.edits_text());
CHECK(KeyChord{ WXK_PAGEUP, wxMOD_SHIFT }.edits_text());
CHECK(KeyChord{ WXK_RETURN }.edits_text());
CHECK(KeyChord{ WXK_TAB, wxMOD_SHIFT }.edits_text());
CHECK(KeyChord{ WXK_ESCAPE }.edits_text());
CHECK(KeyChord{ WXK_PAGEDOWN }.edits_text());
for (int key : { 'A', 'C', 'V', 'X', 'Y', 'Z' })
CHECK(KeyChord{ key, wxMOD_CONTROL }.edits_text());
CHECK(KeyChord{ 'Z', wxMOD_CONTROL | wxMOD_SHIFT }.edits_text());
CHECK_FALSE(KeyChord{ WXK_TAB, wxMOD_CONTROL }.edits_text());
CHECK_FALSE(KeyChord{ WXK_RETURN, wxMOD_CONTROL }.edits_text());
CHECK_FALSE(KeyChord{ WXK_PAGEDOWN, wxMOD_CONTROL }.edits_text());
CHECK_FALSE(KeyChord{ WXK_ESCAPE, wxMOD_CONTROL }.edits_text());
CHECK_FALSE(KeyChord{ 'S', wxMOD_CONTROL }.edits_text());
CHECK_FALSE(KeyChord{ 'S', wxMOD_CONTROL | wxMOD_SHIFT }.edits_text());
CHECK_FALSE(KeyChord{ '0', wxMOD_CONTROL }.edits_text());
CHECK_FALSE(KeyChord{ WXK_F5 }.edits_text());
CHECK_FALSE(KeyChord{}.edits_text());
#ifdef __APPLE__
CHECK(KeyChord{ 'E', wxMOD_ALT }.edits_text());
CHECK(KeyChord{ 'K', wxMOD_RAW_CONTROL }.edits_text());
CHECK(KeyChord{ WXK_LEFT, wxMOD_ALT }.edits_text());
CHECK(KeyChord{ WXK_LEFT, wxMOD_CONTROL }.edits_text());
CHECK_FALSE(KeyChord{ WXK_LEFT, wxMOD_RAW_CONTROL }.edits_text());
CHECK_FALSE(KeyChord{ 'E', wxMOD_CONTROL | wxMOD_ALT }.edits_text());
#else
CHECK(KeyChord{ 'Q', wxMOD_CONTROL | wxMOD_ALT }.edits_text());
CHECK_FALSE(KeyChord{ 'E', wxMOD_ALT }.edits_text());
CHECK_FALSE(KeyChord{ WXK_LEFT, wxMOD_ALT }.edits_text());
CHECK_FALSE(KeyChord{ WXK_LEFT, wxMOD_CONTROL | wxMOD_ALT }.edits_text());
#endif
}
TEST_CASE("Chords the desktop keeps for itself are recognized", "[Shortcuts]")
{
#ifdef _WIN32
@@ -186,6 +235,21 @@ TEST_CASE("Default bindings never collide inside a context", "[Shortcuts]")
}
}
TEST_CASE("No default Global binding except the Speed Dial's Space edits text", "[Shortcuts]")
{
ShortcutRegistry registry;
size_t checked = 0;
for (size_t i = 0; i < size_t(Shortcut::Count); ++i) {
const ShortcutInfo& info = shortcut_info(Shortcut(i));
if ((info.contexts & context_bit(ShortcutContext::Global)) == 0 || info.id == Shortcut::SpeedDial)
continue;
CAPTURE(info.key);
CHECK_FALSE(registry.binding(info.id).edits_text());
++checked;
}
CHECK(checked > 0);
}
TEST_CASE("Shift and Ctrl variants of stepping shortcuts are left unbound", "[Shortcuts]")
{
ShortcutRegistry registry;
@@ -0,0 +1,99 @@
// How the texture displacement gizmo's view follows the UV editor's island highlight. The rules live in
// libslic3r_gui; this is the suite that links it. Same Windows include prologue as
// test_texture_color_palette.cpp (wx pulls in <windows.h>; keep WIN32_LEAN_AND_MEAN / NOMINMAX ahead of it).
#ifdef WIN32
#ifndef WIN32_LEAN_AND_MEAN
#define WIN32_LEAN_AND_MEAN
#endif
#ifndef NOMINMAX
#define NOMINMAX
#endif
#include <Windows.h>
#endif
#include <catch2/catch_test_macros.hpp>
#include <catch2/generators/catch_generators.hpp>
#include "slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp"
using Follow = Slic3r::GUI::GLGizmoTextureDisplacement::IslandViewFollow;
namespace {
// The View row's indices, and step()'s "apply nothing".
constexpr int NORMAL = 0, FAST = 1, CHECKER = 2, DISTORTION = 3, NONE = -1;
} // namespace
TEST_CASE("a Normal view gives way to Fast while islands are selected and comes back once none are", "[TextureIslandView][TextureDisplacement]")
{
Follow f;
CHECK(f.step(false, NORMAL, false) == NONE);
CHECK(f.step(true, NORMAL, false) == FAST);
CHECK(f.step(true, FAST, false) == NONE);
CHECK(f.step(false, FAST, false) == NORMAL);
CHECK(f.step(false, NORMAL, false) == NONE);
}
TEST_CASE("a Fast, Checker or Distortion view is left alone by the island highlight", "[TextureIslandView][TextureDisplacement]")
{
const int view = GENERATE(FAST, CHECKER, DISTORTION);
Follow f;
CHECK(f.step(true, view, false) == NONE);
CHECK(f.step(false, view, false) == NONE);
}
TEST_CASE("arriving in Normal with islands already selected switches to Fast", "[TextureIslandView][TextureDisplacement]")
{
// The pane's Height button over Checker lands in Normal with the islands still selected.
Follow f;
CHECK(f.step(true, CHECKER, false) == NONE);
CHECK(f.step(true, NORMAL, false) == FAST);
CHECK(f.step(false, FAST, false) == NORMAL);
}
TEST_CASE("Normal picked while islands are selected stays until they are deselected", "[TextureIslandView][TextureDisplacement]")
{
Follow f;
REQUIRE(f.step(true, NORMAL, false) == FAST);
f.picked(NORMAL);
CHECK(f.step(true, NORMAL, false) == NONE);
// A debug stage opening and closing over it changes nothing.
CHECK(f.step(true, NORMAL, true) == NONE);
CHECK(f.step(true, NORMAL, false) == NONE);
CHECK(f.step(false, NORMAL, false) == NONE);
CHECK(f.step(true, NORMAL, false) == FAST);
}
TEST_CASE("Fast picked while it was switched on stays after deselecting", "[TextureIslandView][TextureDisplacement]")
{
Follow f;
REQUIRE(f.step(true, NORMAL, false) == FAST);
f.picked(FAST);
CHECK(f.step(false, FAST, false) == NONE);
}
TEST_CASE("Normal picked with nothing selected does not hold off the next switch", "[TextureIslandView][TextureDisplacement]")
{
Follow f;
CHECK(f.step(false, NORMAL, false) == NONE);
f.picked(NORMAL);
CHECK(f.step(true, NORMAL, false) == FAST);
}
TEST_CASE("a debug stage holds Normal while it is open, and the switch follows once it closes", "[TextureIslandView][TextureDisplacement]")
{
Follow f;
REQUIRE(f.step(true, NORMAL, false) == FAST);
// The stage takes Normal itself, with the islands still selected.
CHECK(f.step(true, NORMAL, true) == NONE);
CHECK(f.step(true, NORMAL, false) == FAST);
}
TEST_CASE("islands deselected while a debug stage is open apply no view over it", "[TextureIslandView][TextureDisplacement]")
{
Follow f;
REQUIRE(f.step(true, NORMAL, false) == FAST);
CHECK(f.step(false, NORMAL, true) == NONE);
CHECK(f.step(false, NORMAL, false) == NONE);
}
+26
View File
@@ -10,10 +10,13 @@
#include <libslic3r/Utils.hpp>
#include <boost/filesystem.hpp>
#include <boost/nowide/cstdlib.hpp>
#include <cstdio>
#include <fstream>
#include <optional>
#include <string>
#include <utility>
#if defined(WIN32) || defined(_WIN32)
#define PATH_SEPARATOR R"(\)"
@@ -197,4 +200,27 @@ private:
boost::filesystem::path m_previous;
};
// 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;
};
#endif // SLIC3R_TEST_UTILS