Merge remote-tracking branch 'upstream/main' into haryr/aug25-rebase

# Conflicts:
#	resources/profiles/Custom.json
#	src/libslic3r/Brim.cpp
#	src/libslic3r/GCode.cpp
#	src/libslic3r/GCode.hpp
#	src/libslic3r/Preset.cpp
#	src/slic3r/GUI/3DScene.cpp
#	src/slic3r/GUI/ConfigManipulation.cpp
#	src/slic3r/GUI/GLCanvas3D.cpp
#	src/slic3r/GUI/Plater.cpp
This commit is contained in:
harrierpigeon
2026-08-25 06:50:46 -05:00
861 changed files with 39688 additions and 7012 deletions
+2
View File
@@ -5,6 +5,7 @@ add_executable(${_TEST_NAME}_tests
test_helpers.hpp
test_cooling.cpp
test_extrusion_entity.cpp
test_extrusion_processor.cpp
test_fill.cpp
test_flow.cpp
test_gcode_timing.cpp
@@ -18,6 +19,7 @@ add_executable(${_TEST_NAME}_tests
test_slicing_pipeline_hook.cpp
test_support_material.cpp
test_trianglemesh.cpp
test_wipe_tower.cpp
)
target_link_libraries(${_TEST_NAME}_tests test_common libslic3r Catch2::Catch2WithMain)
set_property(TARGET ${_TEST_NAME}_tests PROPERTY FOLDER "tests")
@@ -0,0 +1,441 @@
#include <catch2/catch_all.hpp>
#include "libslic3r/AABBTreeLines.hpp"
#include "libslic3r/GCode/ExtrusionProcessor.hpp"
#include "libslic3r/GCodeReader.hpp"
#include "libslic3r/TriangleMesh.hpp"
#include "test_helpers.hpp"
#include <algorithm>
#include <cmath>
#include <string>
#include <vector>
using namespace Slic3r;
using namespace Slic3r::Test;
namespace {
// Print settings the assertions below are derived from.
constexpr double caged_layer_height = 0.2; // mm
constexpr double caged_wall_width = 0.42; // mm, outer wall line width
constexpr double caged_outer_wall_speed = 200.; // mm/s
constexpr double caged_slow_speed = 100.; // mm/s, between every configured overhang speed (<= 50) and the wall speed
// A wall running 0.2mm out over a previous layer whose edge dishes 0.03mm away from it in the middle,
// standing in for the endpoint readings a caged overhang perimeter takes: enough of a difference to
// print at another speed, but only a fraction of the distance at which slowdown begins.
constexpr double dished_wall_gap = 0.2; // mm, how far the wall runs out past the previous layer's edge
constexpr double dished_layer_depth = 0.03; // mm, how much further out the middle of it reads
constexpr double dished_min_distance = 0.042; // mm, the reading at which the configured speeds begin to slow down
// Every reading here is past that, so the whole wall is slowed and only the amount is in question.
constexpr float dished_end_reading = float(dished_wall_gap + 0.5 * caged_wall_width);
constexpr float dished_mid_reading = float(dished_end_reading + dished_layer_depth);
// The two readings are dished_layer_depth apart, so half of that tells them apart while still allowing
// for the points the passes after sampling add, which read a little further out than the ends do.
constexpr double dished_reading_tolerance = 0.5 * dished_layer_depth;
// A 40 x 20 x 20 mm box with a 45 degree overhang cut into the y = 0 side. The sloped face spans
// x = 5.086 .. 34.914 only, so the full-height walls of the box cage both ends of every overhang
// perimeter: the endpoints look supported even though the span between them is not.
TriangleMesh caged_overhang_mesh()
{
return TriangleMesh(
{
{5.0859987f, 10.167065f, 5.711731f}, {34.914257f, 10.167065f, 5.711731f},
{34.914257f, 0.f, 15.878796f}, {5.0859995f, 0.f, 15.878796f},
{0.f, 0.f, 0.f}, {0.f, 0.f, 20.f},
{0.f, 20.f, 20.f}, {0.f, 20.f, 0.f},
{40.f, 20.f, 20.f}, {40.f, 20.f, 0.f},
{40.f, 0.f, 20.f}, {40.f, 0.f, 0.f},
{34.914257f, 0.f, 0.f}, {5.0859995f, 0.f, 0.f},
{34.914257f, 10.167065f, 0.f}, {5.0859995f, 10.167065f, 0.f},
},
{
{0, 1, 2}, {0, 2, 3}, {4, 5, 6}, {4, 6, 7}, {7, 6, 8}, {7, 8, 9},
{9, 8, 10}, {9, 10, 11}, {12, 11, 10}, {5, 4, 13}, {5, 13, 3}, {2, 12, 10},
{5, 3, 2}, {10, 5, 2}, {9, 11, 12}, {9, 12, 14}, {13, 4, 7}, {9, 14, 15},
{15, 13, 7}, {7, 9, 15}, {8, 6, 5}, {8, 5, 10}, {14, 1, 0}, {14, 0, 15},
{2, 1, 14}, {2, 14, 12}, {15, 0, 3}, {15, 3, 13},
});
}
// Mesh geometry the wall filters below are derived from.
constexpr double caged_box_depth = 20.; // mm, the box spans y = 0 .. 20
constexpr double caged_slope_face_sum = 15.878796; // mm, y + z of the sloped face, from its corners
// The sloped face spans this x range; outside it the box walls run full height.
constexpr double caged_slope_x_min = 5.0859995;
constexpr double caged_slope_x_max = 34.914257;
constexpr double caged_slope_span = caged_slope_x_max - caged_slope_x_min; // ~29.8 mm
// The z range the sloped face occupies, from the same fixture vertices.
constexpr double caged_slope_z_min = 5.711731;
constexpr double caged_slope_z_max = 15.878796;
// The lowest slope layer still sits on the solid body below the notch, so it is fully supported and
// runs at the outer wall speed by design. The caged span proper begins one layer above it.
constexpr double caged_span_z_min = caged_slope_z_min + caged_layer_height;
// A layer printed at z is sliced at z - layer_height / 2, and the outer wall centreline sits half a
// line width inside the contour, so the wall on the slope satisfies y + z = 16.189.
constexpr double caged_slope_wall_sum = caged_slope_face_sum + 0.5 * caged_layer_height + 0.5 * caged_wall_width;
// Same inset on the fully supported y = 20 face, vertical over the whole height.
constexpr double caged_back_wall_y = caged_box_depth - 0.5 * caged_wall_width;
// And on the y = 0 face, which runs full height only outside the slope's x range.
constexpr double caged_front_wall_y = 0.5 * caged_wall_width;
// Arachne varies the wall width along a face, and the centreline inset is half that width, so a
// wall sits within about half a line width of where the nominal inset alone would put it. The
// faces being selected are millimetres apart, so this stays far from ambiguous.
constexpr double caged_wall_tolerance = 0.5 * caged_wall_width;
// Feed rates in mm/min of the long outer wall extrusions `keep_line` selects.
template<typename KeepLine> std::vector<double> outer_wall_feed_rates(const std::string& gcode, KeepLine keep_line)
{
std::vector<double> feed_rates;
bool outer_wall = false;
GCodeReader parser;
parser.parse_buffer(gcode, [&feed_rates, &outer_wall, &keep_line](GCodeReader& self, const GCodeReader::GCodeLine& line) {
const std::string_view comment = line.comment();
if (comment.find("FEATURE:") != std::string_view::npos || comment.find("TYPE:") != std::string_view::npos)
outer_wall = comment.find("Outer wall") != std::string_view::npos ||
comment.find("External perimeter") != std::string_view::npos;
if (outer_wall && line.extruding(self) && line.dist_XY(self) > 1.0 && keep_line(self, line))
feed_rates.push_back(line.new_F(self));
});
return feed_rates;
}
// The caged 45 degree overhang: outer walls crossing the sloped face for most of its width, on the
// layers where the face genuinely overhangs.
// Both ends are tested against the slope plane rather than requiring a constant Y. Arachne's
// variable-width walls drift slightly in Y along the same slope (Y6.186 -> Y6.189 on one move), so
// a constant-Y filter matches almost nothing under Arachne and silently reduces its coverage.
// The length test excludes the cage walls: they are only as wide as the box is either side of the
// slope, but being vertical their y + z sweeps through the slope plane as z rises, so a couple of
// their fully supported moves would otherwise be counted as part of the span.
std::vector<double> caged_slope_feed_rates(const std::string& gcode)
{
return outer_wall_feed_rates(gcode, [](const GCodeReader& self, const GCodeReader::GCodeLine& line) {
const double z = line.new_Z(self);
return z > caged_span_z_min && z < caged_slope_z_max &&
line.dist_XY(self) > 0.5 * caged_slope_span &&
std::abs(self.y() + z - caged_slope_wall_sum) < caged_wall_tolerance &&
std::abs(line.new_Y(self) + z - caged_slope_wall_sum) < caged_wall_tolerance;
});
}
// The opposite, fully supported face, skipping the initial layer and its own speed settings.
std::vector<double> back_wall_feed_rates(const std::string& gcode)
{
return outer_wall_feed_rates(gcode, [](const GCodeReader& self, const GCodeReader::GCodeLine& line) {
return line.new_Z(self) > 1.5 * caged_layer_height &&
std::abs(self.y() - caged_back_wall_y) < caged_wall_tolerance &&
std::abs(line.new_Y(self) - caged_back_wall_y) < caged_wall_tolerance;
});
}
// The first layer printed entirely above the slope. Its y = 0 wall runs the full width of the box.
const double caged_layer_above_slope_z = std::ceil(caged_slope_z_max / caged_layer_height) * caged_layer_height;
// The parts of that wall standing on the cage rather than the slope, so on a contour identical to their own.
// Where the support changes is found by bisection, which stops at spans of 2mm, so the move spanning each end of
// the slope reaches a little way into the cage. Taking only the moves lying wholly outside the slope's x range
// leaves the wall that is unambiguously supported, without asserting how closely the bisection converged.
std::vector<double> cage_shoulder_feed_rates(const std::string& gcode)
{
return outer_wall_feed_rates(gcode, [](const GCodeReader& self, const GCodeReader::GCodeLine& line) {
return std::abs(line.new_Z(self) - caged_layer_above_slope_z) < 0.5 * caged_layer_height &&
std::abs(self.y() - caged_front_wall_y) < caged_wall_tolerance &&
std::abs(line.new_Y(self) - caged_front_wall_y) < caged_wall_tolerance &&
(std::max(self.x(), line.new_X(self)) <= caged_slope_x_min ||
std::min(self.x(), line.new_X(self)) >= caged_slope_x_max);
});
}
// The readings a 40mm wall takes over a previous layer whose edge falls away by 0.03mm towards the
// middle: both ends read the same, and the middle reads slightly further out over air. Whether that
// middle reading survives is what decides the speed the wall is printed at.
std::vector<ExtendedPoint<2>> sampled_wall_over_dished_layer(const std::function<float(float)>& distance_to_speed)
{
const AABBTreeLines::LinesDistancer<Linef> prev_layer(std::vector<Linef>{
{{0., 0.}, {20., -dished_layer_depth}},
{{20., -dished_layer_depth}, {40., 0.}},
{{40., 0.}, {40., -10.}},
{{40., -10.}, {0., -10.}},
{{0., -10.}, {0., 0.}},
});
const Points wall{Point::new_scale(0., dished_wall_gap), Point::new_scale(40., dished_wall_gap)};
return estimate_points_properties<true, true, true, true>(wall, prev_layer, caged_wall_width, -1.f,
dished_min_distance, distance_to_speed);
}
// A straight, otherwise supported wall over a previous-layer boundary with a 2mm-wide pocket. Moving the
// pocket between x = 10 and x = 20 covers both discovery away from the wall's midpoint and refinement around
// a midpoint that has already been discovered. The current wall is inset half its width from the flat boundary,
// so its supported readings are zero after the estimator applies its boundary offset.
constexpr double narrow_pocket_wall_length = 40.;
constexpr double narrow_pocket_width = 2.;
constexpr double narrow_pocket_depth = 0.3;
std::vector<ExtendedPoint<2>> sampled_wall_over_narrow_pocket(
double pocket_center, const std::function<float(float)>& distance_to_speed)
{
const double pocket_left = pocket_center - 0.5 * narrow_pocket_width;
const double pocket_right = pocket_center + 0.5 * narrow_pocket_width;
const AABBTreeLines::LinesDistancer<Linef> prev_layer(std::vector<Linef>{
{{0., 0.}, {pocket_left, 0.}},
{{pocket_left, 0.}, {pocket_left, -narrow_pocket_depth}},
{{pocket_left, -narrow_pocket_depth}, {pocket_right, -narrow_pocket_depth}},
{{pocket_right, -narrow_pocket_depth}, {pocket_right, 0.}},
{{pocket_right, 0.}, {narrow_pocket_wall_length, 0.}},
{{narrow_pocket_wall_length, 0.}, {narrow_pocket_wall_length, -10.}},
{{narrow_pocket_wall_length, -10.}, {0., -10.}},
{{0., -10.}, {0., 0.}},
});
const double wall_y = -0.5 * caged_wall_width;
const Points wall{Point::new_scale(0., wall_y), Point::new_scale(narrow_pocket_wall_length, wall_y)};
return estimate_points_properties<true, true, true, true>(wall, prev_layer, caged_wall_width, -1.f,
dished_min_distance, distance_to_speed);
}
// A cross section that grows a layer's worth on the two faces meeting at either end of a wall, as any
// 45 degree overhang does. The wall itself stands on a contour identical to its own, but its ends sit
// where the growing faces cut the corners off, and the previous layer's edge there is nearer than the
// half line width the centreline is inset by. Both ends therefore read an overhang while everything
// between them reads supported: the reverse of the caged span, and the case the sampling above must
// leave to the passes after it.
constexpr double stepped_wall_inset = 0.5 * caged_wall_width; // mm, centreline inset from the contour
constexpr double stepped_end_gap = stepped_wall_inset - caged_layer_height; // mm, how far inside the corner ends up
constexpr double stepped_wall_span = 30.; // mm, the length of the wall
std::vector<ExtendedPoint<2>> sampled_wall_between_growing_corners(const std::function<float(float)>& distance_to_speed)
{
const AABBTreeLines::LinesDistancer<Linef> prev_layer(std::vector<Linef>{
{{0., 0.}, {32., 0.}},
{{32., 0.}, {32., -stepped_wall_span}},
{{32., -stepped_wall_span}, {0., -stepped_wall_span}},
{{0., -stepped_wall_span}, {0., 0.}},
});
const Points wall{Point::new_scale(stepped_wall_inset, -stepped_end_gap),
Point::new_scale(stepped_wall_inset, stepped_end_gap - stepped_wall_span)};
return estimate_points_properties<true, true, true, true>(wall, prev_layer, caged_wall_width, -1.f,
dished_min_distance, distance_to_speed);
}
// How much of a path is printed below the speed a fully supported reading gives. A segment is printed
// at the lower of the speeds its ends read.
double slowed_length(const std::vector<ExtendedPoint<2>>& points, const std::function<float(float)>& distance_to_speed)
{
double length = 0.;
for (size_t i = 0; i + 1 < points.size(); ++i)
if (std::min(distance_to_speed(points[i].distance), distance_to_speed(points[i + 1].distance)) < distance_to_speed(0.f))
length += (points[i + 1].position - points[i].position).norm();
return length;
}
float furthest_reading(const std::vector<ExtendedPoint<2>>& points)
{
return std::max_element(points.begin(), points.end(), [](const ExtendedPoint<2>& l, const ExtendedPoint<2>& r) {
return l.distance < r.distance;
})->distance;
}
DynamicPrintConfig caged_overhang_config(const char* wall_generator){
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
config.set_deserialize_strict({
{"nozzle_diameter", "0.4"},
{"initial_layer_print_height", caged_layer_height},
{"layer_height", caged_layer_height},
{"line_width", caged_wall_width},
{"outer_wall_line_width", caged_wall_width},
{"inner_wall_line_width", "0.45"},
{"wall_loops", "2"},
{"wall_generator", wall_generator},
{"wall_sequence", "inner wall/outer wall"},
{"sparse_infill_density", "15%"},
{"detect_overhang_wall", "1"},
{"enable_overhang_speed", "1"},
{"slowdown_for_curled_perimeters", "0"},
{"zaa_enabled", "0"},
{"outer_wall_speed", caged_outer_wall_speed},
{"inner_wall_speed", "300"},
{"overhang_1_4_speed", "0"},
{"overhang_2_4_speed", "50"},
{"overhang_3_4_speed", "30"},
{"overhang_4_4_speed", "10"},
{"bridge_speed", "50"},
{"filament_max_volumetric_speed", "22"},
{"slow_down_for_layer_cooling", "0"},
{"slow_down_layers", "0"}, // Nothing but the overhang settings may lower a wall speed
});
return config;
}
std::string caged_overhang_gcode(const char* wall_generator)
{
Print print;
Model model;
init_print(std::vector<TriangleMesh>{caged_overhang_mesh()}, print, model, caged_overhang_config(wall_generator), nullptr,
false);
return gcode(print);
}
// Reports the matched move count alongside the extremes, so a filter that selected nothing is
// distinguishable from a span that simply was not slowed.
void info_feed_rates(const char* span, const std::vector<double>& feed_rates)
{
UNSCOPED_INFO("matched " << feed_rates.size() << " " << span << " moves");
if (!feed_rates.empty()) {
const auto extremes = std::minmax_element(feed_rates.begin(), feed_rates.end());
UNSCOPED_INFO("slowest " << *extremes.first / MM_PER_MIN << " mm/s, fastest " << *extremes.second / MM_PER_MIN << " mm/s");
}
}
} // namespace
// Classic reproduces the endpoint-sampling bug: it emits the span as one long move whose endpoints
// both read as supported, so endpoint-only sampling never slows it. Arachne's endpoints already read
// as overhanging, but their placement near the cage makes the inferred support vary by layer. Arachne
// parity is therefore part of this regression's scope: both generators must classify the unsupported
// interior of the same 45-degree span consistently.
TEST_CASE("Caged external overhangs are slowed along their span", "[ExtrusionProcessor][Regression]")
{
const char* wall_generator = GENERATE("classic", "arachne");
INFO("wall generator: " << wall_generator);
const std::vector<double> feed_rates = caged_slope_feed_rates(caged_overhang_gcode(wall_generator));
info_feed_rates("caged slope", feed_rates);
REQUIRE_FALSE(feed_rates.empty());
// The endpoint bug left Classic at the full wall speed, while Arachne's cage-adjacent endpoint
// samples selected much faster bands on some layers. The whole span must stay in the slowed range
// for both generators, without requiring their different path segmentations to match.
const double fastest = *std::max_element(feed_rates.begin(), feed_rates.end());
REQUIRE(fastest < caged_slow_speed * MM_PER_MIN);
}
// The other side of the fix: the midpoint probe fires on every long external perimeter, so a
// regression that over-slows would leave the test above green. A fully supported wall must keep the
// speed it was configured with.
TEST_CASE("Supported vertical walls keep their normal speed", "[ExtrusionProcessor][Regression]")
{
const char* wall_generator = GENERATE("classic", "arachne");
INFO("wall generator: " << wall_generator);
const std::vector<double> feed_rates = back_wall_feed_rates(caged_overhang_gcode(wall_generator));
info_feed_rates("back wall", feed_rates);
REQUIRE_FALSE(feed_rates.empty());
const double slowest = *std::min_element(feed_rates.begin(), feed_rates.end());
REQUIRE(slowest >= caged_slow_speed * MM_PER_MIN);
}
// The slope's top edge falls mid layer, so the first layer above it still stands 0.179mm proud of the layer
// below wherever that layer was still on the slope. That is a real overhang and is slowed, but it ends with the
// slope: outside the slope's x range the box runs full height, so the same wall stands on a contour identical to
// its own. Sampling the interior of that wall at a single point reported one support reading for all of it and
// slowed these fully supported ends along with the rest.
TEST_CASE("Wall sections beside a caged overhang keep their normal speed", "[ExtrusionProcessor][Regression]")
{
const char* wall_generator = GENERATE("classic", "arachne");
INFO("wall generator: " << wall_generator);
const std::vector<double> feed_rates = cage_shoulder_feed_rates(caged_overhang_gcode(wall_generator));
info_feed_rates("cage shoulder", feed_rates);
REQUIRE_FALSE(feed_rates.empty());
const double slowest = *std::min_element(feed_rates.begin(), feed_rates.end());
REQUIRE_THAT(slowest / MM_PER_MIN, Catch::Matchers::WithinRel(caged_outer_wall_speed, 0.01));
}
// A wall is printed at the lower of the speeds its ends read, so a reading only earns a point in the
// path where it prints at a different speed from the readings around it. Judging that on the readings
// themselves rather than the speeds they produce was too coarse: the configured speeds interpolate
// between their sections, so readings a fraction of the slowdown threshold apart still print more than
// 10% apart, and a real 45 degree overhang had its true reading dropped as if it agreed with its ends.
// The ends then chose the speed on their own, and being next to the walls either side of the overhang
// they read differently from layer to layer, banding an overhang that should have been uniform.
TEST_CASE("An overhang reading is kept whenever it changes the speed", "[ExtrusionProcessor][Regression]")
{
// A steep speed curve, of the kind the configured overhang speeds interpolate across.
const std::vector<ExtendedPoint<2>> points =
sampled_wall_over_dished_layer([](float distance) { return std::round(200.f - 400.f * distance); });
REQUIRE_THAT(furthest_reading(points), Catch::Matchers::WithinAbs(dished_mid_reading, dished_reading_tolerance));
}
// The complement, and why the readings alone were tempting: a reading that prints at the same speed as
// its neighbours cannot change the G-code, so sampling must leave the path alone however far out it is.
TEST_CASE("An overhang reading is dropped when the speed is unchanged", "[ExtrusionProcessor]")
{
// A flat speed curve, of the kind a single configured overhang speed produces.
const std::vector<ExtendedPoint<2>> points = sampled_wall_over_dished_layer([](float) { return 50.f; });
REQUIRE_THAT(furthest_reading(points), Catch::Matchers::WithinAbs(dished_end_reading, dished_reading_tolerance));
}
TEST_CASE("Coarse probing detects an unsupported pocket away from the wall midpoint",
"[ExtrusionProcessor][Regression]")
{
const std::function<float(float)> distance_to_speed = [](float distance) { return distance <= 0.2f ? 100.f : 50.f; };
const std::vector<ExtendedPoint<2>> points =
sampled_wall_over_narrow_pocket(0.25 * narrow_pocket_wall_length, distance_to_speed);
const double slowed = slowed_length(points, distance_to_speed);
REQUIRE(slowed > 0.);
REQUIRE(slowed < 5.);
}
TEST_CASE("Coarse probing brackets a narrow slowdown at the wall midpoint",
"[ExtrusionProcessor][Regression]")
{
// Half of the pocket reading still maps to full speed. A matching probe in either half therefore must not
// prune that half before a supported point has been found close enough to bracket the slow midpoint.
const std::function<float(float)> distance_to_speed = [](float distance) { return distance <= 0.2f ? 100.f : 50.f; };
const std::vector<ExtendedPoint<2>> points =
sampled_wall_over_narrow_pocket(0.5 * narrow_pocket_wall_length, distance_to_speed);
const double slowed = slowed_length(points, distance_to_speed);
REQUIRE(slowed > 0.);
REQUIRE(slowed < 5.);
}
// Sampling probes the interior, so it must not answer for the ends. On a supported wall between two
// corners that read an overhang, the reading that differs is the end's own, and the pass that ends a
// slowdown an end reads places its point from how far out that end is. Sampling took the difference as
// its own to report and put a point at the nearest position bisection had reached instead, which both
// sits further along the wall and leaves too little of it for that pass to run on, so the corner
// slowdown ran millimetres up an otherwise supported wall. Its length grows with the wall, so on a
// model whose cross section keeps growing it reads as a stair stepped band up the corner.
TEST_CASE("A supported wall between overhanging corners is slowed no further than its ends require",
"[ExtrusionProcessor][Regression]")
{
// A steep speed curve, so the ends and the interior between them print at clearly different speeds.
const std::function<float(float)> distance_to_speed = [](float distance) {
return std::round(float(caged_outer_wall_speed) - 400.f * distance);
};
const double sampled = slowed_length(sampled_wall_between_growing_corners(distance_to_speed), distance_to_speed);
// The same wall with sampling switched off: what the endpoint driven passes alone make of the corners.
const double unsampled = slowed_length(sampled_wall_between_growing_corners({}), distance_to_speed);
// The corners do read an overhang, so there is a slowdown for sampling to have lengthened.
REQUIRE(unsampled > 0.);
REQUIRE(sampled <= unsampled);
}
TEST_CASE("Benchmark caged overhang interior sampling", "[ExtrusionProcessor][!benchmark]"){
const char* wall_generator = GENERATE("classic", "arachne");
BENCHMARK(wall_generator)
{
return caged_overhang_gcode(wall_generator);
};
}
+324
View File
@@ -698,3 +698,327 @@ TEST_CASE("Solid infill direction offsets every layer when no template is set",
CHECK(delta == 30);
}
}
TEST_CASE("Honeycomb infill rounds its cell corners with the smooth factor", "[Fill]")
{
// A cell whose sides are several times the line width, so that the corners have room to be rounded.
const double spacing = 0.45;
const double density = 0.1;
auto fill = [spacing, density](double smooth_factor) {
std::unique_ptr<Slic3r::Fill> filler(Slic3r::Fill::new_from_type("honeycomb"));
filler->spacing = spacing;
FillParams params;
params.density = float(density);
params.dont_adjust = true;
// Keep the fragments apart, so that only the turns of the pattern itself are measured.
params.anchor_length_max = 0.f;
params.smooth_factor = smooth_factor;
Slic3r::ExPolygon square{ Slic3r::Points{
Point::new_scale(0., 0.), Point::new_scale(50., 0.), Point::new_scale(50., 50.), Point::new_scale(0., 50.) } };
Slic3r::Surface surface(stInternal, square);
return filler->fill_surface(&surface, params);
};
// Cosine of the sharpest turn of any of the paths, 1 meaning none of them turns at all.
auto sharpest_turn_cosine = [](const Slic3r::Polylines &polylines) {
double sharpest = 1.;
for (const Polyline &polyline : polylines)
for (size_t i = 1; i + 1 < polyline.size(); ++i) {
const Vec2d incoming = (polyline[i] - polyline[i - 1]).cast<double>().normalized();
const Vec2d outgoing = (polyline[i + 1] - polyline[i]).cast<double>().normalized();
sharpest = std::min(sharpest, incoming.dot(outgoing));
}
return sharpest;
};
auto point_count = [](const Slic3r::Polylines &polylines) {
return std::accumulate(polylines.begin(), polylines.end(), size_t(0),
[](size_t count, const Polyline &polyline) { return count + polyline.size(); });
};
const Slic3r::Polylines sharp = fill(0.);
const Slic3r::Polylines smooth = fill(1.);
REQUIRE(!sharp.empty());
REQUIRE(smooth.size() == sharp.size());
REQUIRE(point_count(smooth) > point_count(sharp));
// The cell corners turn by 60 degrees; smoothing replaces them by gentle curves.
REQUIRE(sharpest_turn_cosine(sharp) < 0.6);
REQUIRE(sharpest_turn_cosine(smooth) > 0.9);
}
// Point count, number of turns sharper than 25 degrees and length of the sparse infill of a print.
// A rounded corner is a run of much gentler turns, so smoothing shows up as fewer sharp ones.
struct SparseInfillShape {
size_t point_count { 0 };
size_t sharp_turns { 0 };
size_t path_count { 0 };
double length { 0. };
// Digest of every point in the order it is printed. The counts above all survive the same
// extrusions being joined into different polylines, so only this tells two such fills apart.
uint64_t sequence { 14695981039346656037ull };
};
static SparseInfillShape sparse_infill_shape(const Print &print)
{
SparseInfillShape shape;
auto account = [&shape](const ExtrusionPath &path) {
if (!sparse_role(path.role()))
return;
const Points3 &pts = path.polyline.points;
++shape.path_count;
shape.point_count += pts.size();
for (const auto &pt : pts)
for (const coord_t coordinate : {pt.x(), pt.y(), pt.z()})
shape.sequence = (shape.sequence ^ uint64_t(coordinate)) * 1099511628211ull;
for (size_t i = 1; i < pts.size(); ++i)
shape.length += (pts[i] - pts[i - 1]).head<2>().cast<double>().norm();
for (size_t i = 1; i + 1 < pts.size(); ++i) {
const Vec2d incoming = (pts[i] - pts[i - 1]).head<2>().cast<double>();
const Vec2d outgoing = (pts[i + 1] - pts[i]).head<2>().cast<double>();
if (incoming.squaredNorm() > 0. && outgoing.squaredNorm() > 0. &&
incoming.normalized().dot(outgoing.normalized()) < 0.9)
++shape.sharp_turns;
}
};
for (const Layer *layer : print.objects().front()->layers())
for (const LayerRegion *region : layer->regions())
for (const ExtrusionEntity *entity : region->fills.flatten().entities) {
if (auto *path = dynamic_cast<const ExtrusionPath *>(entity))
account(*path);
else if (auto *multi = dynamic_cast<const ExtrusionMultiPath *>(entity))
for (const ExtrusionPath &p : multi->paths)
account(p);
else if (auto *loop = dynamic_cast<const ExtrusionLoop *>(entity))
for (const ExtrusionPath &p : loop->paths)
account(p);
}
return shape;
}
TEST_CASE("Lightning infill slices the same model the same way twice", "[Fill][Regression]")
{
// Slicing twice in one process catches a generator that carries state from one slice to the
// next, or whose result depends on how the parallel layer fill interleaves.
auto shape = [] {
Print print;
Slic3r::Test::init_and_process_print({Slic3r::Test::cube(20)}, print,
{{"sparse_infill_pattern", "lightning"},
{"sparse_infill_density", "50%"},
{"layer_height", 0.2}});
return sparse_infill_shape(print);
};
const SparseInfillShape first = shape();
const SparseInfillShape second = shape();
REQUIRE(first.path_count > 0);
REQUIRE(second.path_count == first.path_count);
REQUIRE(second.point_count == first.point_count);
REQUIRE(second.sharp_turns == first.sharp_turns);
// No tolerance: the same extrusions in the same order add up to the very same number.
REQUIRE_THAT(second.length, Catch::Matchers::WithinAbs(first.length, 0.));
// All of the above agree when the same branches are joined into different polylines, so the
// point sequence is what actually decides whether the two slices produced the same infill.
REQUIRE(second.sequence == first.sequence);
}
TEST_CASE("Lightning infill rounds the turns of its branches with the smooth factor", "[Fill]")
{
auto shape_for = [](const std::string &smooth_factor) {
Print print;
Slic3r::Test::init_and_process_print({Slic3r::Test::cube(20)}, print,
{{"sparse_infill_pattern", "lightning"},
{"sparse_infill_density", "15%"},
{"sparse_infill_smooth_factor", smooth_factor},
{"layer_height", 0.2}});
return sparse_infill_shape(print);
};
const SparseInfillShape sharp = shape_for("0%");
const SparseInfillShape smooth = shape_for("100%");
REQUIRE(sharp.point_count > 0);
// The branch turns are replaced by curves, which cut the corners off and take more points to
// describe. The turns where two branches are joined into one path stay sharp.
REQUIRE(smooth.point_count > sharp.point_count);
REQUIRE(smooth.sharp_turns < sharp.sharp_turns);
REQUIRE(smooth.length < sharp.length);
}
TEST_CASE("Concentric infill rounds its loops with the smooth factor", "[Fill]")
{
auto shape_for = [](const std::string &smooth_factor) {
Print print;
Slic3r::Test::init_and_process_print({Slic3r::Test::cube(20)}, print,
{{"sparse_infill_pattern", "concentric"},
{"sparse_infill_density", "20%"},
{"sparse_infill_smooth_factor", smooth_factor},
{"layer_height", 0.2}});
return sparse_infill_shape(print);
};
const SparseInfillShape sharp = shape_for("0%");
const SparseInfillShape smooth = shape_for("100%");
REQUIRE(sharp.point_count > 0);
REQUIRE(smooth.point_count > sharp.point_count);
REQUIRE(smooth.sharp_turns < sharp.sharp_turns);
REQUIRE(smooth.length < sharp.length);
}
TEST_CASE("Cross hatch infill rounds its transition layers with the smooth factor", "[Fill]")
{
auto shape_for = [](const std::string &smooth_factor) {
Print print;
Slic3r::Test::init_and_process_print({Slic3r::Test::cube(20)}, print,
{{"sparse_infill_pattern", "crosshatch"},
{"sparse_infill_density", "20%"},
{"sparse_infill_smooth_factor", smooth_factor},
{"layer_height", 0.2}});
return sparse_infill_shape(print);
};
const SparseInfillShape sharp = shape_for("0%");
const SparseInfillShape smooth = shape_for("100%");
REQUIRE(sharp.point_count > 0);
REQUIRE(smooth.point_count > sharp.point_count);
REQUIRE(smooth.sharp_turns < sharp.sharp_turns);
REQUIRE(smooth.length < sharp.length);
}
TEST_CASE("Trapezoidal grid infill rounds its corners only with more than one line", "[Fill]")
{
auto shape_for = [](int multiline, const std::string &smooth_factor) {
Print print;
Slic3r::Test::init_and_process_print({Slic3r::Test::cube(20)}, print,
{{"sparse_infill_pattern", "grid"},
{"sparse_infill_density", "20%"},
{"fill_multiline", multiline},
{"sparse_infill_smooth_factor", smooth_factor},
{"layer_height", 0.2}});
return sparse_infill_shape(print);
};
const SparseInfillShape sharp = shape_for(2, "0%");
const SparseInfillShape smooth = shape_for(2, "100%");
REQUIRE(sharp.point_count > 0);
REQUIRE(smooth.point_count > sharp.point_count);
REQUIRE(smooth.sharp_turns < sharp.sharp_turns);
REQUIRE(smooth.length < sharp.length);
// A single line per infill wall is the plain crossing line grid, which has no corner of its own.
const SparseInfillShape single_sharp = shape_for(1, "0%");
const SparseInfillShape single_smooth = shape_for(1, "100%");
REQUIRE(single_sharp.point_count > 0);
REQUIRE(single_smooth.point_count == single_sharp.point_count);
REQUIRE(single_smooth.length == single_sharp.length);
}
TEST_CASE("3D honeycomb infill rounds its octahedral waves with the smooth factor", "[Fill]")
{
auto shape_for = [](const std::string &smooth_factor) {
Print print;
Slic3r::Test::init_and_process_print({Slic3r::Test::cube(20)}, print,
{{"sparse_infill_pattern", "3dhoneycomb"},
{"sparse_infill_density", "20%"},
{"sparse_infill_smooth_factor", smooth_factor},
{"layer_height", 0.2}});
return sparse_infill_shape(print);
};
const SparseInfillShape sharp = shape_for("0%");
const SparseInfillShape smooth = shape_for("100%");
REQUIRE(sharp.point_count > 0);
REQUIRE(smooth.point_count > sharp.point_count);
REQUIRE(smooth.sharp_turns < sharp.sharp_turns);
REQUIRE(smooth.length < sharp.length);
}
TEST_CASE("Smoothed concentric infill stays inside the fill region", "[Fill][Regression]")
{
// The concentric loops are offsets of the fill region and are never clipped to it, so a corner
// rounded across its boundary ends up in a hole or over a wall. Rounding cuts toward the inside of
// the turn, which leaves the region at every corner of a hole, and in a region thinner than the
// curve even at a corner turning inwards.
const bool thin_region = GENERATE(false, true);
ExPolygon region;
if (thin_region) {
// An L of two 1.2mm wide arms: cutting the corner they meet at crosses both of them.
region = ExPolygon{ Slic3r::Points{
Point::new_scale(0., 0.), Point::new_scale(20., 0.), Point::new_scale(20., 1.2),
Point::new_scale(1.2, 1.2), Point::new_scale(1.2, 20.), Point::new_scale(0., 20.) } };
} else {
region = ExPolygon{ Slic3r::Points{ Point::new_scale(0., 0.), Point::new_scale(50., 0.),
Point::new_scale(50., 50.), Point::new_scale(0., 50.) },
Slic3r::Points{ Point::new_scale(30., 20.), Point::new_scale(30., 30.),
Point::new_scale(20., 30.), Point::new_scale(20., 20.) } };
}
CAPTURE(thin_region);
auto fill = [&region](double smooth_factor) {
std::unique_ptr<Slic3r::Fill> filler(Slic3r::Fill::new_from_type("concentric"));
filler->spacing = 0.45;
FillParams params;
params.density = 0.1f;
params.dont_adjust = true;
params.smooth_factor = smooth_factor;
Slic3r::Surface surface(stInternal, region);
return filler->fill_surface(&surface, params);
};
auto point_count = [](const Slic3r::Polylines &polylines) {
return std::accumulate(polylines.begin(), polylines.end(), size_t(0),
[](size_t count, const Polyline &polyline) { return count + polyline.size(); });
};
const Slic3r::Polylines sharp = fill(0.);
const Slic3r::Polylines smooth = fill(1.);
REQUIRE(!sharp.empty());
// Nothing leaves the fill region, which the unrounded loops already touch from the inside.
const ExPolygons bounds = offset_ex(region, float(SCALED_EPSILON));
REQUIRE(diff_pl(sharp, bounds).empty());
REQUIRE(diff_pl(smooth, bounds).empty());
// The corners that the region has room for are still rounded.
if (!thin_region)
REQUIRE(point_count(smooth) > point_count(sharp));
}
TEST_CASE("Smoothing multiline lightning infill keeps its outlines connected", "[Fill][Regression]")
{
// With more than one line per infill wall, the branches are printed as outlines drawn around them,
// and the outlines of branches that run close to each other merge into one. Rounding the branches
// before those outlines are built moves them apart, which breaks the merged outlines up into
// separate loops - many more of them, each needing its own travel move.
auto shape_for = [](const std::string &smooth_factor) {
Print print;
Slic3r::Test::init_and_process_print({Slic3r::Test::cube(20)}, print,
{{"sparse_infill_pattern", "lightning"},
{"sparse_infill_density", "50%"},
{"fill_multiline", 2},
{"sparse_infill_smooth_factor", smooth_factor},
{"layer_height", 0.2}});
return sparse_infill_shape(print);
};
const SparseInfillShape sharp = shape_for("0%");
const SparseInfillShape smooth = shape_for("100%");
REQUIRE(sharp.path_count > 0);
// The loop count varies by a loop or two between platforms and between runs, so this is not an
// exact comparison. Smoothing should leave it about where it was; uncapping the smoothing
// reach, the regression this guards against, adds about 10%.
const size_t allowed_extra = sharp.path_count / 50; // 2%
REQUIRE(smooth.path_count <= sharp.path_count + allowed_extra);
// The outlines are still rounded.
REQUIRE(smooth.point_count > sharp.point_count);
REQUIRE(smooth.sharp_turns < sharp.sharp_turns);
}
+196
View File
@@ -7,9 +7,14 @@
#include "test_utils.hpp"
#include <cmath>
#include <fstream>
#include <iomanip>
#include <map>
#include <memory>
#include <sstream>
#include <string>
#include <vector>
using namespace Slic3r;
using Catch::Matchers::WithinAbs;
@@ -418,3 +423,194 @@ TEST_CASE("Per-slot machine limits follow the active nozzle", "[GCodeTiming][Mul
REQUIRE_THAT(times[2], Catch::Matchers::WithinRel(101.0 / 200.0, 0.10));
}
}
// Junction planning decides the speeds the "actual speed" / "actual flow" preview shows. Per-axis
// jerk limits a corner by the largest single-axis component of the velocity change, allowing sqrt(2)
// more speed on a diagonal than on an axis -- a four-lobed ripple around every circle. Klipper and
// Marlin 2 with M205 J plan with junction deviation instead, which sees only the corner angle.
namespace {
// One acceleration everywhere and axis limits far above it, so only the junction model under test
// can slow a corner down.
FullPrintConfig make_junction_config(GCodeFlavor flavor, double corner_velocity, double junction_deviation)
{
FullPrintConfig config;
config.gcode_flavor.value = flavor;
config.filament_diameter.values = {1.75};
config.filament_map.values = {1};
const std::vector<double> accel = {1000.0, 1000.0};
const std::vector<double> axis = {20000.0, 20000.0};
const std::vector<double> speed = {500.0, 500.0};
config.machine_max_acceleration_extruding.values = accel;
config.machine_max_acceleration_travel.values = accel;
config.machine_max_acceleration_retracting.values = accel;
config.machine_max_acceleration_x.values = axis;
config.machine_max_acceleration_y.values = axis;
config.machine_max_acceleration_z.values = axis;
config.machine_max_acceleration_e.values = axis;
config.machine_max_speed_x.values = speed;
config.machine_max_speed_y.values = speed;
config.machine_max_speed_z.values = speed;
config.machine_max_speed_e.values = speed;
// Klipper reads this as the square corner velocity, Marlin as classic jerk.
config.machine_max_jerk_x.values = {corner_velocity, corner_velocity};
config.machine_max_jerk_y.values = {corner_velocity, corner_velocity};
config.machine_max_jerk_z.values = {corner_velocity, corner_velocity};
// Kept out of the way so it never binds in the classic-jerk comparisons.
config.machine_max_jerk_e.values = {100.0, 100.0};
config.machine_max_junction_deviation.values = {junction_deviation, junction_deviation};
config.machine_min_extruding_rate.values = {0.0, 0.0};
config.machine_min_travel_rate.values = {0.0, 0.0};
return config;
}
constexpr double junction_x = 60.0;
constexpr double junction_y = 60.0;
// Two 40mm moves meeting at (junction_x, junction_y) with the given turn, rotated by `orientation`.
// 40mm is long enough to reach the commanded 150mm/s and brake back to any corner speed these tests
// produce. `e_per_mm` of zero makes them travels, which keeps the junction vector purely geometric
// as the formulas below assume.
std::string corner_gcode(double turn_deg, double orientation_deg, double e_per_mm = 0.0)
{
const double len = 40.0;
const double a_in = orientation_deg * M_PI / 180.0;
const double a_out = (orientation_deg + turn_deg) * M_PI / 180.0;
std::ostringstream extrude;
if (e_per_mm > 0.0)
extrude << std::fixed << std::setprecision(4) << " E" << len * e_per_mm;
std::ostringstream os;
os << std::fixed << std::setprecision(4)
<< "M83\n"
<< "G1 Z0.2 F1200\n"
<< "G1 X" << junction_x - len * std::cos(a_in) << " Y" << junction_y - len * std::sin(a_in) << " F6000\n"
<< "G1 X" << junction_x << " Y" << junction_y << extrude.str() << " F9000\n"
<< "G1 X" << junction_x + len * std::cos(a_out) << " Y" << junction_y + len * std::sin(a_out)
<< extrude.str() << " F9000\n";
return os.str();
}
// Speed allowed through the corner: the vertex ending the incoming move carries that block's exit
// speed, and the vertices the actual-speed pass inserts are all strictly interior.
double corner_speed(const GCodeProcessorResult& r)
{
for (const auto& mv : r.moves)
if ((mv.type == EMoveType::Travel || mv.type == EMoveType::Extrude) &&
std::abs(mv.position.x() - junction_x) < 1e-3 &&
std::abs(mv.position.y() - junction_y) < 1e-3)
return mv.actual_feedrate;
return -1.0;
}
double planned_corner_speed(GCodeFlavor flavor, double corner_velocity, double junction_deviation,
double turn_deg, double orientation_deg = 0.0, double e_per_mm = 0.0)
{
GCodeProcessor proc;
run_processor(proc, make_junction_config(flavor, corner_velocity, junction_deviation),
corner_gcode(turn_deg, orientation_deg, e_per_mm).c_str());
return corner_speed(proc.get_result());
}
} // namespace
TEST_CASE("Klipper corners are planned with junction deviation derived from the square corner velocity",
"[GCodeTiming][JunctionDeviation]")
{
// jd = scv^2 * (sqrt(2) - 1) / max_accel, then v^2 = jd * accel * sin(t/2) / (1 - sin(t/2)).
// The acceleration cancels: the corner speed depends only on the scv and the angle.
const double scv = 5.0;
SECTION("a right angle is taken at exactly the square corner velocity") {
// sin(t/2) = sqrt(0.5) at 90 degrees, so v == scv -- the definition of the square corner
// velocity, and what makes the mapping above the right one.
REQUIRE_THAT(planned_corner_speed(gcfKlipper, scv, 0.0, 90.0), Catch::Matchers::WithinRel(scv, 0.02));
}
SECTION("a shallow corner is taken far faster than the per-axis jerk model allows") {
// 6 degrees: sin(t/2) = cos(3 deg), so v = 5 * sqrt((sqrt(2) - 1) * 728.68) = 86.9mm/s. Per-axis
// jerk ignores the angle and caps the velocity *change* (2v*sin(3 deg)), giving 47.8mm/s.
const double jd_speed = planned_corner_speed(gcfKlipper, scv, 0.0, 6.0);
const double jerk_speed = planned_corner_speed(gcfMarlinLegacy, scv, 0.0, 6.0);
REQUIRE_THAT(jd_speed, Catch::Matchers::WithinRel(86.87, 0.02));
REQUIRE_THAT(jerk_speed, Catch::Matchers::WithinRel(47.75, 0.02));
}
}
TEST_CASE("Junction deviation limits a corner by its angle alone, not by its orientation",
"[GCodeTiming][JunctionDeviation]")
{
// The four-lobed ripple on circular walls is per-axis jerk being anisotropic: a velocity change
// lying on an axis gets sqrt(2) less headroom than the same change on the diagonal.
const double scv = 5.0;
const double turn = 6.0;
SECTION("Klipper plans both orientations identically") {
const double on_axis = planned_corner_speed(gcfKlipper, scv, 0.0, turn, 0.0);
const double diagonal = planned_corner_speed(gcfKlipper, scv, 0.0, turn, 45.0);
REQUIRE(on_axis > 0.0);
REQUIRE_THAT(diagonal, Catch::Matchers::WithinRel(on_axis, 0.02));
}
SECTION("the classic jerk model keeps its orientation dependence") {
const double on_axis = planned_corner_speed(gcfMarlinLegacy, scv, 0.0, turn, 0.0);
const double diagonal = planned_corner_speed(gcfMarlinLegacy, scv, 0.0, turn, 45.0);
REQUIRE(on_axis > 0.0);
REQUIRE(diagonal / on_axis > 1.2);
}
}
TEST_CASE("Junction deviation is only used where the firmware actually plans with it",
"[GCodeTiming][JunctionDeviation]")
{
const double jerk = 5.0;
SECTION("Marlin 2 with M205 J disabled keeps the classic jerk planning") {
// machine_max_junction_deviation == 0 is how a Marlin 2 printer says it runs classic jerk.
const double classic = planned_corner_speed(gcfMarlinLegacy, jerk, 0.0, 90.0);
REQUIRE(classic > 0.0);
REQUIRE_THAT(planned_corner_speed(gcfMarlinFirmware, jerk, 0.0, 90.0),
Catch::Matchers::WithinRel(classic, 1e-4));
}
SECTION("Marlin 2 with M205 J enabled switches to junction deviation") {
// sqrt(1000 * 0.05 * 2.4142136) = 11.0mm/s, independent of the jerk values it no longer reads.
REQUIRE_THAT(planned_corner_speed(gcfMarlinFirmware, jerk, 0.05, 90.0),
Catch::Matchers::WithinRel(10.99, 0.02));
}
SECTION("machines without junction deviation are untouched by the jerk values it would ignore") {
// A flavor that never enters the junction deviation path must ignore the setting entirely.
const double without = planned_corner_speed(gcfMarlinLegacy, jerk, 0.0, 90.0);
REQUIRE_THAT(planned_corner_speed(gcfMarlinLegacy, jerk, 0.05, 90.0),
Catch::Matchers::WithinRel(without, 1e-4));
}
}
TEST_CASE("How fast a corner is taken does not depend on how much is extruded through it",
"[GCodeTiming][JunctionDeviation]")
{
// The junction cosine is taken over XYZE, so the direction vectors have to be unit length or the
// E term makes the two paths look more parallel than they are and the corner comes out too fast,
// the more so the higher the flow. Marlin normalizes over XYZE on any extruding move
// (planner.cpp, esteps > 0) and Klipper leaves E out of the cosine altogether
// (toolhead.py::Move.calc_junction); on both, this corner is planned by its geometry alone.
const double scv = 5.0;
const double turn = 6.0;
const double geometric = planned_corner_speed(gcfKlipper, scv, 0.0, turn);
REQUIRE(geometric > 0.0);
// 0.029mm/mm is an ordinary 0.42 x 0.2 line on 1.75mm filament; 0.1 is a fat large-nozzle one.
// Unnormalized these came out at 94.4 and 150.0mm/s against a geometric 86.9.
for (double e_per_mm : {0.029, 0.1})
REQUIRE_THAT(planned_corner_speed(gcfKlipper, scv, 0.0, turn, 0.0, e_per_mm),
Catch::Matchers::WithinRel(geometric, 0.02));
SECTION("and the same holds on Marlin 2") {
const double marlin = planned_corner_speed(gcfMarlinFirmware, scv, 0.05, turn);
REQUIRE(marlin > 0.0);
REQUIRE_THAT(planned_corner_speed(gcfMarlinFirmware, scv, 0.05, turn, 0.0, 0.029),
Catch::Matchers::WithinRel(marlin, 0.02));
}
}
+611
View File
@@ -1,12 +1,25 @@
#include <catch2/catch_all.hpp>
#include "libslic3r/GCode/GCodeProcessor.hpp"
#include "libslic3r/GCodeReader.hpp"
#include "test_helpers.hpp"
#include "test_utils.hpp"
#include <algorithm>
#include <cctype>
#include <cmath>
#include <cstdio>
#include <cstdlib>
#include <fstream>
#include <limits>
#include <optional>
#include <set>
#include <sstream>
#include <string>
#include <string_view>
#include <utility>
#include <vector>
using namespace Slic3r;
using namespace Slic3r::Test;
@@ -27,6 +40,210 @@ static std::set<int> tools_for_role(const std::string& gcode, const std::string&
return tools;
}
// X where the nozzle sits while each tagged _WAIT_FOR_TEMP_ON_WIPE_TOWER M109 blocks:
// the nearest preceding G1 carrying an X (the park travel emitted just before the wait).
static std::vector<double> wait_park_xs(const std::string& gcode)
{
std::vector<std::string> lines;
std::istringstream stream(gcode);
for (std::string line; std::getline(stream, line);)
lines.emplace_back(std::move(line));
std::vector<double> xs;
for (size_t i = 0; i < lines.size(); ++i) {
if (lines[i].rfind("M109", 0) != 0 || lines[i].find("_WAIT_FOR_TEMP_ON_WIPE_TOWER") == std::string::npos)
continue;
for (size_t j = i; j-- > 0;) {
if (lines[j].rfind("G1 ", 0) != 0)
continue;
const size_t x_pos = lines[j].find('X');
if (x_pos == std::string::npos)
continue;
xs.push_back(std::stod(lines[j].substr(x_pos + 1)));
break;
}
}
return xs;
}
// Estimated print time at each 1-based line of an exported G-code file, from a second
// GCodeProcessor pass over it. MoveVertex::time is the duration of one move and gcode_id is the
// line it came from (already rebased past the M73 insertions), so the running sum before the first
// move of a line is the elapsed time at that line. The file carries its own config footer, so
// process_file configures the processor -- including the shared s_IsBBLPrinter static that other
// tests in this binary mutate -- from the settings the export itself used.
static std::vector<double> elapsed_time_by_line(const std::string& gcode)
{
ScopedTemporaryFile temp_gcode(".gcode");
{
std::ofstream os(temp_gcode.string());
os << gcode;
}
GCodeProcessor processor;
processor.process_file(temp_gcode.string());
constexpr size_t NORMAL = size_t(PrintEstimatedStatistics::ETimeMode::Normal);
const size_t n_lines = size_t(std::count(gcode.begin(), gcode.end(), '\n')) + 2;
std::vector<double> elapsed(n_lines, 0.);
double running = 0.;
size_t next = 0;
for (const auto& move : processor.get_result().moves) {
const size_t id = std::min<size_t>(move.gcode_id, n_lines - 1);
while (next <= id)
elapsed[next++] = running;
running += move.time[NORMAL];
}
while (next < n_lines)
elapsed[next++] = running;
return elapsed;
}
// The temperature-relevant projection of `gcode`: every M104/M109/Tn line, plus the toolchange and
// priming markers that anchor them, in order. A preheat -- an M104 the GCodeProcessor backtrace
// inserts mid-object, outside any block, naming a tool other than the one currently loaded -- also
// carries "lead <n>s", the estimated time from there to the tool change it heats for, which is the
// property preheat_time controls. No other temperature command gets one: for an M104 retargeting
// the active tool (the first-layer-to-other-layers bump) or one inside a block, the distance to the
// next Tn is a layer time or a handful of moves and says nothing about preheat_time. Everything
// else is dropped, so the trace does not move when travel, tower geometry or line numbering do.
static std::vector<std::string> temperature_trace(const std::string& gcode)
{
std::vector<std::string> lines;
std::istringstream stream(gcode);
for (std::string line; std::getline(stream, line);) {
line.erase(0, line.find_first_not_of(" \t"));
while (!line.empty() && (line.back() == '\r' || line.back() == ' ' || line.back() == '\t'))
line.pop_back();
lines.emplace_back(std::move(line));
}
const std::vector<double> elapsed = elapsed_time_by_line(gcode);
const auto is_tool = [](const std::string& l) { return l.size() >= 2 && l[0] == 'T' && std::isdigit((unsigned char) l[1]); };
const auto is_temp = [](const std::string& l) { return l.rfind("M104", 0) == 0 || l.rfind("M109", 0) == 0; };
const auto marker = [](const std::string& l) -> const char* {
for (const char* m : { "; CP TOOLCHANGE START", "; CP TOOLCHANGE END", "; CP PRIMING START", "; CP PRIMING END" })
if (l.find(m) != std::string::npos)
return m;
return nullptr;
};
// Tool a "T<n>" line, or the "T<n>" argument of an M104, names -- or -1 when it names none.
const auto tool_of = [&is_tool](const std::string& l) -> int {
size_t t = std::string::npos; // index of the 'T'
if (is_tool(l))
t = 0;
else if (l.rfind("M104", 0) == 0 && l.find(" T") != std::string::npos)
t = l.find(" T") + 1;
if (t == std::string::npos || t + 1 >= l.size() || !std::isdigit((unsigned char) l[t + 1]))
return -1;
return std::stoi(l.substr(t + 1));
};
std::vector<std::string> trace;
bool in_block = false;
int current_tool = -1;
for (size_t i = 0; i < lines.size(); ++i) {
if (const char* m = marker(lines[i])) {
in_block = std::string(m).find("START") != std::string::npos;
trace.emplace_back(m); // the marker alone: some carry a trailing tool id, some do not
} else if (is_tool(lines[i]) || is_temp(lines[i])) {
std::string entry = lines[i];
const int named = tool_of(lines[i]);
if (!in_block && lines[i].rfind("M104", 0) == 0 && current_tool != -1 && named != -1 && named != current_tool) {
size_t tn = i;
while (tn < lines.size() && !is_tool(lines[tn]))
++tn;
if (tn < lines.size()) {
char lead[32];
std::snprintf(lead, sizeof(lead), "\tlead %.1fs", elapsed[tn + 1] - elapsed[i + 1]);
entry += lead;
}
}
if (is_tool(lines[i]))
current_tool = named;
trace.emplace_back(std::move(entry));
}
}
return trace;
}
// "M104 S240 T0 ; preheat T0 time: 31s<TAB>lead 30.9s" carries the same quantity twice, and both
// vary by toolchain: the backtrace picks the first line at least preheat_time out, so a sub-tenth
// difference in the estimate selects a neighbouring move and "lead" steps by that move's duration.
// Tolerate "lead", still far below the tens of seconds a displaced preheat would shift it. Check
// "time:" against its own entry's "lead" instead of across runs -- being a rounding of it, that
// still catches a change in how it is derived without tracking the absolute estimate.
static constexpr double TRACE_TIME_TOLERANCE_S = 1.5;
static constexpr double TRACE_ROUNDING_SLACK_S = 0.05; // correct rounding keeps |time - lead| <= 0.5
struct TraceEntry
{
std::string text; // timing values replaced by a placeholder
std::optional<double> time_s;
std::optional<double> lead_s;
};
static TraceEntry parse_trace_entry(const std::string& entry)
{
TraceEntry out;
std::string text = entry;
// Split off the tail only when it really is a "lead <n>s", so an unexpected one still compares.
const size_t tab = text.find('\t');
if (tab != std::string::npos) {
const std::string tail = text.substr(tab + 1); // "lead 30.2s"
const size_t sp = tail.find(' ');
if (sp != std::string::npos && sp + 1 < tail.size()
&& std::isdigit(static_cast<unsigned char>(tail[sp + 1]))) {
out.lead_s = std::stod(tail.substr(sp + 1));
text.erase(tab);
}
}
static constexpr std::string_view k_time = "time: ";
const size_t at = text.find(k_time);
// Require a digit first: a dots-only run would otherwise reach std::stod and throw.
if (at != std::string::npos && at + k_time.size() < text.size()
&& std::isdigit(static_cast<unsigned char>(text[at + k_time.size()]))) {
const size_t first = at + k_time.size();
size_t last = first;
while (last < text.size() && (std::isdigit(static_cast<unsigned char>(text[last])) || text[last] == '.'))
++last;
out.time_s = std::stod(text.substr(first, last - first));
text.replace(first, last - first, "<n>"); // surrounding text, incl. the "s", still compared
}
out.text = std::move(text);
return out;
}
static bool timings_match(const std::optional<double>& a, const std::optional<double>& b)
{
if (a.has_value() != b.has_value())
return false;
return !a.has_value() || std::abs(*a - *b) <= TRACE_TIME_TOLERANCE_S;
}
// "time:" must be its own entry's "lead" rounded to a whole second.
static bool time_is_rounded_lead(const TraceEntry& e)
{
if (!e.time_s.has_value() || !e.lead_s.has_value())
return true; // nothing to cross-check
return std::abs(*e.time_s - *e.lead_s) <= 0.5 + TRACE_ROUNDING_SLACK_S;
}
// `a` is the slice under test, `b` the recorded golden.
static bool trace_entries_match(const std::string& a, const std::string& b)
{
const auto x = parse_trace_entry(a);
const auto y = parse_trace_entry(b);
if (x.text != y.text)
return false;
// A field appearing or disappearing is a real change even though the values are tolerated.
if (x.time_s.has_value() != y.time_s.has_value())
return false;
return timings_match(x.lead_s, y.lead_s) && time_is_rounded_lead(x);
}
// Tool index = filament id - 1; brim and skirt follow the wall filament.
TEST_CASE("Each feature prints with its assigned filament", "[MultiFilament]")
{
@@ -86,6 +303,399 @@ 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
}
// 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
// repositioning move and the first extrusion of the purge. The restore that used to block there
// demotes to a non-blocking M104 and moves ahead of the Tn, so the incoming tool heats up over
// the change itself. Ordering and the off-tower stop are the contract here.
TEST_CASE("Toolchange temperature wait moves to the wipe tower when enabled", "[MultiFilament]")
{
const bool wait_on_tower = GENERATE(false, true);
DYNAMIC_SECTION("wait_for_temp_on_wipe_tower " << (wait_on_tower ? 1 : 0)) {
const std::string gcode = slice_with_object_overrides(
{ cube(20), cube(20) },
multifilament_config(2, {
{ "nozzle_diameter", "0.4,0.4" },
{ "printer_extruder_id", "1,2" },
{ "printer_extruder_variant", "Direct Drive Standard,Direct Drive Standard" },
{ "extruder_printable_height", "0,0" },
{ "single_extruder_multi_material", 0 },
{ "enable_prime_tower", 1 },
{ "prime_tower_width", 35 },
{ "wipe_tower_x", "50" },
{ "wipe_tower_y", "50" },
{ "ooze_prevention", 1 },
{ "standby_temperature_delta", -40 },
// The post-processor's own preheat pass also inserts an M104 for the incoming
// filament ahead of the Tn; switch it off so the temperature commands under test
// are the only ones in the toolchange block.
{ "preheat_time", 0 },
{ "wait_for_temp_on_wipe_tower", wait_on_tower ? 1 : 0 },
}),
// One filament per object -> a toolchange on every layer. Assigned at the object
// level: the used-filament count that gates the prime tower is derived from
// object/volume configs on the harness's single apply (region filament ids such
// as sparse_infill_filament_id are not counted there and the tower would be
// silently disabled).
{ { { "extruder", 1 } }, { { "extruder", 2 } } });
// Split into lines and scan the "; CP TOOLCHANGE START".."; CP TOOLCHANGE END" blocks.
std::vector<std::string> lines;
std::istringstream gcode_stream(gcode);
for (std::string line; std::getline(gcode_stream, line);)
lines.emplace_back(std::move(line));
const auto is_tool_line = [](const std::string& l) { return l.size() >= 2 && l[0] == 'T' && std::isdigit((unsigned char)l[1]); };
const auto is_m109_line = [](const std::string& l) { return l.rfind("M109", 0) == 0; };
// A non-blocking set-temperature naming one specific tool, e.g. "M104 S255 T1".
const auto is_m104_for_tool = [](const std::string& l, int tool) {
if (l.rfind("M104", 0) != 0)
return false;
const std::string token = " T" + std::to_string(tool);
const size_t at = l.find(token);
return at != std::string::npos && !std::isdigit((unsigned char)l[at + token.size()]);
};
const auto is_tagged_wait = [](const std::string& l) { return l.find("_WAIT_FOR_TEMP_ON_WIPE_TOWER") != std::string::npos; };
const auto is_extruding = [](const std::string& l) {
if (l.rfind("G1 ", 0) != 0)
return false;
const size_t e = l.find(" E");
return e != std::string::npos && l.find_first_of("XY") != std::string::npos && l[e + 2] != '-';
};
int checked_blocks = 0;
for (size_t i = 0; i < lines.size(); ++i) {
if (lines[i].find("; CP TOOLCHANGE START") == std::string::npos)
continue;
size_t block_end = i;
while (block_end < lines.size() && lines[block_end].find("; CP TOOLCHANGE END") == std::string::npos)
++block_end;
size_t tool_line = block_end;
for (size_t j = i; j < block_end; ++j)
if (is_tool_line(lines[j])) { tool_line = j; break; }
if (tool_line == block_end)
continue; // final unload block, no toolchange
++checked_blocks;
// Where the incoming tool's target temperature is raised, relative to its Tn.
const int new_tool = std::stoi(lines[tool_line].substr(1));
size_t preheat = tool_line, restore = block_end;
for (size_t j = i; j < tool_line; ++j)
if (is_m104_for_tool(lines[j], new_tool)) { preheat = j; break; }
for (size_t j = tool_line + 1; j < block_end; ++j)
if (is_m104_for_tool(lines[j], new_tool)) { restore = j; break; }
size_t tagged_wait = block_end, untagged_m109 = block_end, first_extrusion = block_end;
for (size_t j = tool_line + 1; j < block_end; ++j) {
if (is_m109_line(lines[j]) && tagged_wait == block_end && is_tagged_wait(lines[j]))
tagged_wait = j;
if (is_m109_line(lines[j]) && untagged_m109 == block_end && !is_tagged_wait(lines[j]))
untagged_m109 = j;
if (first_extrusion == block_end && is_extruding(lines[j]))
first_extrusion = j;
}
INFO("toolchange block at line " << i + 1);
if (wait_on_tower) {
// The only blocking wait is the tagged one, parked beside the tower before the purge.
REQUIRE(tagged_wait < block_end);
CHECK(untagged_m109 == block_end);
// The target is raised ahead of the toolchange, so the incoming tool heats up
// while it is picked up, and nothing sets it again afterwards.
CHECK(preheat < tool_line);
CHECK(restore == block_end);
REQUIRE(first_extrusion < block_end);
CHECK(tagged_wait < first_extrusion);
// The travel preceding the wait parks outside the tower footprint. The tower
// auto-sizes, so derive its extent from the purge extrusions of this block.
size_t stop_line = block_end;
for (size_t j = tagged_wait; j-- > tool_line;)
if (lines[j].rfind("G1 ", 0) == 0 && lines[j].find('X') != std::string::npos) { stop_line = j; break; }
REQUIRE(stop_line < block_end);
const double stop_x = std::stod(lines[stop_line].substr(lines[stop_line].find('X') + 1));
double purge_min_x = std::numeric_limits<double>::max(), purge_max_x = std::numeric_limits<double>::lowest();
for (size_t j = tagged_wait; j < block_end; ++j) {
const size_t x_pos = lines[j].find('X');
if (!is_extruding(lines[j]) || x_pos == std::string::npos)
continue;
const double x = std::stod(lines[j].substr(x_pos + 1));
purge_min_x = std::min(purge_min_x, x);
purge_max_x = std::max(purge_max_x, x);
}
REQUIRE(purge_min_x <= purge_max_x);
INFO("stop travel: " << lines[stop_line] << " purge x range: " << purge_min_x << ".." << purge_max_x);
const bool beside_tower = stop_x < purge_min_x - 0.5 || stop_x > purge_max_x + 0.5;
CHECK(beside_tower);
} else {
// Stock behavior: the blocking wait follows the toolchange command directly, and
// nothing raises the incoming tool's target before it.
REQUIRE(untagged_m109 < block_end);
CHECK(tagged_wait == block_end);
CHECK(preheat == tool_line);
if (first_extrusion < block_end)
CHECK(untagged_m109 < first_extrusion);
}
i = block_end;
}
REQUIRE(checked_blocks > 0);
if (!wait_on_tower)
CHECK(gcode.find("_WAIT_FOR_TEMP_ON_WIPE_TOWER") == std::string::npos);
}
}
// Priming runs before the first layer is set up, so set_extruder sees no layer at all: its
// on_first_layer() test is false and print_z is the initial layer height rather than 0. The
// tower nonetheless blocks on the first layer temperature there, so the pre-heat raised ahead
// of each priming Tn has to name that same temperature — pre-heating to the "other layers"
// value instead leaves the tagged M109 asking the firmware to cool back down before the
// priming lines are extruded.
TEST_CASE("Wipe tower priming pre-heats to the first layer temperature", "[MultiFilament]")
{
const std::string gcode = slice_with_object_overrides(
{ cube(20), cube(20) },
multifilament_config(2, {
{ "nozzle_diameter", "0.4,0.4" },
{ "printer_extruder_id", "1,2" },
{ "printer_extruder_variant", "Direct Drive Standard,Direct Drive Standard" },
{ "extruder_printable_height", "0,0" },
{ "single_extruder_multi_material", 0 },
{ "single_extruder_multi_material_priming", 1 },
{ "enable_prime_tower", 1 },
{ "prime_tower_width", 35 },
{ "wipe_tower_x", "50" },
{ "wipe_tower_y", "50" },
{ "preheat_time", 0 }, // see the wait test above
// Distinct enough that picking the wrong one is unambiguous.
{ "nozzle_temperature_initial_layer", "215,215" },
{ "nozzle_temperature", "240,240" },
{ "wait_for_temp_on_wipe_tower", 1 },
}),
{ { { "extruder", 1 } }, { { "extruder", 2 } } });
std::vector<std::string> lines;
std::istringstream gcode_stream(gcode);
for (std::string line; std::getline(gcode_stream, line);)
lines.emplace_back(std::move(line));
// Temperature of an M104/M109, or -1 when the line is neither.
const auto temp_of = [](const std::string& l) {
if (l.rfind("M104", 0) != 0 && l.rfind("M109", 0) != 0)
return -1;
const size_t s = l.find('S');
return s == std::string::npos ? -1 : std::stoi(l.substr(s + 1));
};
size_t start = lines.size(), end = lines.size();
for (size_t i = 0; i < lines.size(); ++i) {
if (start == lines.size() && lines[i].find("; CP PRIMING START") != std::string::npos)
start = i;
else if (start < lines.size() && lines[i].find("; CP PRIMING END") != std::string::npos) {
end = i;
break;
}
}
REQUIRE(start < end);
int checked_waits = 0;
for (size_t i = start; i < end; ++i) {
if (lines[i].find("_WAIT_FOR_TEMP_ON_WIPE_TOWER") == std::string::npos)
continue;
++checked_waits;
INFO("priming wait at line " << i + 1 << ": " << lines[i]);
CHECK(temp_of(lines[i]) == 215); // the tower waits on the first layer temperature
// The most recent set-temperature before it is the pre-heat, and must agree with it.
int preheat = -1;
for (size_t j = i; j-- > start;)
if ((preheat = temp_of(lines[j])) != -1)
break;
CHECK(preheat == 215);
}
REQUIRE(checked_waits > 0); // the feature under test is active
}
// The temperature-wait park picks its side of the tower by testing bed containment with the
// tower position at psWipeTower generation time, while WipeTowerIntegration shifts the cached
// moves by the CURRENT position at export. Moving the tower normally invalidates only
// psSkirtBrim (tower gcode is position-independent), but the park makes it bed-relative, so a
// GUI-style move-and-reslice on the same Print must regenerate the tower — otherwise the stale
// park prints outside the bed. Contract: every tagged wait parks inside the printable area.
TEST_CASE("Wipe tower temperature-wait park is regenerated when the tower moves", "[MultiFilament]")
{
// Two objects, one filament each: a toolchange (and a tagged wait) on every layer, like
// the wait test above — but on a single-extruder machine profile: the synthetic
// dual-extruder keys would drag in the extruder-variant expansion, which is not
// idempotent on the default machine profile and would pollute the re-apply diff below.
// Rectangle wall and no brim keep the tower-local footprint inside [0, 35], so the park
// sits at the generator's 2mm side gap: local -2 or 37.
DynamicPrintConfig config = multifilament_config(2, {
{ "single_extruder_multi_material", 0 },
{ "enable_prime_tower", 1 },
{ "prime_tower_width", 35 },
{ "wipe_tower_wall_type", "rectangle" }, // the default rib bulges past the width
{ "prime_tower_brim_width", 0 }, // the default 3 widens the first-layer envelope
{ "printable_area", "0x0,200x0,200x200,0x200" },
{ "wipe_tower_x", "0" },
{ "wipe_tower_y", "50" },
{ "ooze_prevention", 1 },
{ "standby_temperature_delta", -40 },
{ "wait_for_temp_on_wipe_tower", 1 },
});
// init_print force-sets this on its own copy; set it here too so the re-apply below
// diffs in wipe_tower_x ONLY — the exact GUI increment under test.
config.set_key_value("gcode_comments", new ConfigOptionBool(true));
Print print;
Model model;
const std::vector<std::vector<ConfigBase::SetDeserializeItem>> overrides{
{ { "extruder", 1 } }, { { "extruder", 2 } } }; // object-level, see the wait test above
init_print(std::vector<TriangleMesh>{ cube(20), cube(20) }, print, model, config, &overrides);
const std::string at_edge = gcode(print);
const std::vector<double> at_edge_parks = wait_park_xs(at_edge);
REQUIRE(!at_edge_parks.empty()); // the feature under test is active
for (double x : at_edge_parks) {
INFO("wait park X " << x << " with the tower at x=0 on a 200mm bed");
CHECK(x >= -0.05);
CHECK(x <= 200.05);
}
REQUIRE(print.is_step_done(psWipeTower));
// Move the tower to the right bed edge (164 + 35 = 199 keeps the body printable) and
// re-apply on the SAME Print, as the GUI does. Base the re-apply on the print's own
// resolved config so the diff is wipe_tower_x alone — re-applying the caller's config
// would also diff the apply-time extruder normalization write-backs, and those keys
// regenerate the tower for the wrong reason. The cached right-side park would export
// at 164 + 37 = 201, off the bed; regeneration clamps the park against the bed edge.
// Assemble the moved config exactly the way init_print assembled the first one — the
// apply-time normalization is only idempotent when both applies start from the same
// derivation, and any stray diff key would regenerate the tower for the wrong reason.
config.set_deserialize_strict({ { "wipe_tower_x", "164" } });
DynamicPrintConfig moved_config = DynamicPrintConfig::full_print_config();
moved_config.apply(config);
moved_config.set_key_value("gcode_comments", new ConfigOptionBool(true));
print.apply(model, moved_config);
CHECK_FALSE(print.is_step_done(psWipeTower)); // the move must re-generate the tower
const std::string moved = gcode(print);
const std::vector<double> moved_parks = wait_park_xs(moved);
REQUIRE(!moved_parks.empty()); // the waits must survive the re-slice
for (double x : moved_parks) {
INFO("wait park X " << x << " with the tower at x=164 on a 200mm bed");
CHECK(x >= -0.05);
CHECK(x <= 200.05);
}
}
// The flag-off half of the three tests above. Every site wait_for_temp_on_wipe_tower touches is
// guarded -- set_extruder's pre-toolchange preheat block and its post_toolchange skip,
// toolchange_Change's park, the interface-temp guard in WipeTower2::tool_change, and append_tcr2's
// tagged-M109 filter -- so with the option off the feature has to be inert and temperature emission
// has to stay exactly as it was before the option existed. That is pinned against a trace captured
// from main rather than against expectations written from the current code, which would be
// re-derived from the very code they are meant to guard.
//
// Note what main emits here, since it is easy to misread as a missing wait: with preheat_time set,
// the toolchange carries no blocking M109 at all. GCodeProcessor's backtrace moves the heat-up to
// an M104 preheat_time seconds earlier and demotes the in-place command, which is the entire point
// of preheating. The lead times below are what pin that placement.
TEST_CASE("Toolchange temperature commands are unchanged when the wipe tower wait is off", "[MultiFilament][Regression]")
{
// 20x20x5 cubes at the default 0.2mm layer height are 25 layers, one filament each, so there is
// a toolchange -- and a preheat ahead of it -- on every layer.
const std::string gcode = slice_with_object_overrides(
{ make_cube(20., 20., 5.), make_cube(20., 20., 5.) },
multifilament_config(2, {
{ "nozzle_diameter", "0.4,0.4" },
{ "printer_extruder_id", "1,2" },
{ "printer_extruder_variant", "Direct Drive Standard,Direct Drive Standard" },
{ "extruder_printable_height", "0,0" },
{ "single_extruder_multi_material", 0 },
{ "single_extruder_multi_material_priming", 1 }, // reaches toolchange_Change's priming path
{ "enable_prime_tower", 1 },
{ "prime_tower_width", 35 },
{ "wipe_tower_x", "50" },
{ "wipe_tower_y", "50" },
// GCodeProcessor::apply_config enables the preheat backtrace on
// ooze_prevention && preheat_time > 0 && !SEMM && filaments > 1. That is what puts an
// M104 preheat_time seconds ahead of every Tn, and it also gives set_extruder's
// standby/restore pair, which the option demotes and moves when it is on.
{ "ooze_prevention", 1 },
{ "standby_temperature_delta", -40 },
{ "preheat_time", 30 },
{ "preheat_steps", 1 },
// enable_tower_interface_features is deliberately left off: the interface temperature
// is observable only through a change_filament_gcode template that reads
// new_filament_temp, since append_tcr2 strips the tower's own M109 for it, and the
// default template here has none. The option's interface-temp guard is covered by the
// enabled-path tests above instead.
//
// Distinct enough that a wrong pick between the two is unambiguous in the trace.
{ "nozzle_temperature_initial_layer", "215,215" },
{ "nozzle_temperature", "240,240" },
{ "wait_for_temp_on_wipe_tower", 0 },
}),
// Object-level, so the used-filament count that gates the prime tower is derived from it.
{ { { "extruder", 1 } }, { { "extruder", 2 } } });
const std::vector<std::string> trace = temperature_trace(gcode);
REQUIRE(trace.size() > 1);
CHECK(gcode.find("_WAIT_FOR_TEMP_ON_WIPE_TOWER") == std::string::npos);
const std::string golden_path = std::string(TEST_DATA_DIR PATH_SEPARATOR "wipe_tower_temperature_trace_main.txt");
// Regenerate by appending this test and its helpers to the same file on main (dropping the
// wait_for_temp_on_wipe_tower key, which main's config does not know), rebuilding
// fff_print_tests there, running it with ORCA_UPDATE_WIPE_TOWER_TEMP_TRACE=1, copying the file
// it writes back here, and filling in the commit it was captured from.
if (std::getenv("ORCA_UPDATE_WIPE_TOWER_TEMP_TRACE") != nullptr) {
std::ofstream out(golden_path);
REQUIRE(out.good());
out << "# Temperature and tool-change commands of a wait_for_temp_on_wipe_tower-off slice,\n"
"# captured from the main branch at <fill in the commit>. Regeneration is described\n"
"# at the test that reads this file: \"Toolchange temperature commands are unchanged\n"
"# when the wipe tower wait is off\" in tests/fff_print/test_multifilament.cpp.\n";
for (const std::string& entry : trace)
out << entry << "\n";
WARN("Rewrote " << golden_path << " from this run; it no longer reflects main.");
return;
}
std::vector<std::string> golden;
{
std::ifstream in(golden_path);
INFO("reading " << golden_path);
REQUIRE(in.good());
for (std::string line; std::getline(in, line);) {
if (!line.empty() && line.back() == '\r')
line.pop_back();
if (!line.empty() && line[0] != '#')
golden.push_back(std::move(line));
}
}
REQUIRE(!golden.empty());
// Reported separately from the golden comparison below: it is a different failure.
for (size_t i = 0; i < trace.size(); ++i) {
const auto entry = parse_trace_entry(trace[i]);
if (time_is_rounded_lead(entry))
continue;
INFO("at trace entry " << i + 1);
INFO(" " << trace[i]);
FAIL("\"time:\" is not its entry's \"lead\" rounded to a whole second");
}
const size_t common = std::min(trace.size(), golden.size());
for (size_t i = 0; i < common; ++i) {
if (trace_entries_match(trace[i], golden[i]))
continue;
// Report the first difference only: past it the two are misaligned and every later entry
// would be reported as a difference too.
INFO("first difference at trace entry " << i + 1);
INFO(" main: " << golden[i]);
INFO(" branch: " << trace[i]);
FAIL("temperature emission differs from main with wait_for_temp_on_wipe_tower off");
}
CHECK(trace.size() == golden.size());
}
// max_layer_height can be shorter than the extruder count (normalization sizes it to the
// filament count under single_extruder_multi_material). calc_max_layer_height() in ToolOrdering
// indexed it per-nozzle and read past the end. Shortened directly here to isolate that read;
@@ -104,3 +714,4 @@ TEST_CASE("Multi-extruder slice stays in bounds with a short max_layer_height",
init_and_process_print({ cube(20) }, print, config);
REQUIRE_FALSE(print.objects().front()->layers().empty());
}
+16
View File
@@ -390,6 +390,22 @@ TEST_CASE("G-code lists the resolved extrusion-width settings", "[Print]")
CHECK(with_first_layer.find("; first layer extrusion width") != std::string::npos);
}
// gcode_skip_config_block suppresses the resolved-settings block while leaving the
// header and executable blocks intact.
TEST_CASE("gcode_skip_config_block omits the resolved-settings comment block", "[Print]")
{
const std::string gcode = slice({ cube(20) }, {
{ "gcode_skip_config_block", true },
{ "gcode_comments", true },
});
CHECK(gcode.find("; CONFIG_BLOCK_START") == std::string::npos);
CHECK(gcode.find("; CONFIG_BLOCK_END") == std::string::npos);
CHECK(gcode.find("; layer_height =") == std::string::npos);
CHECK(gcode.find("; fill_density =") == std::string::npos);
CHECK(gcode.find("; HEADER_BLOCK_START") != std::string::npos);
CHECK(gcode.find("; EXECUTABLE_BLOCK_START") != std::string::npos);
}
// Custom G-code templates substitute placeholders during export.
TEST_CASE("Custom G-code placeholders are substituted", "[Print]")
{
+150
View File
@@ -6,6 +6,7 @@
#include "libslic3r/Config.hpp"
#include "libslic3r/Geometry.hpp"
#include "libslic3r/Geometry/ConvexHull.hpp"
#include "libslic3r/Layer.hpp"
#include <boost/algorithm/string.hpp>
@@ -39,6 +40,30 @@ static size_t brim_loop_count(Print &print)
return n;
}
static bool brim_enters_first_layer_hole(Print &print)
{
const PrintObject *object = print.get_object(0);
Polygons holes;
for (const ExPolygon &slice : object->layers().front()->lslices)
holes.insert(holes.end(), slice.holes.begin(), slice.holes.end());
const Vec3d plate_origin = print.get_plate_origin();
Point shift = object->instances().front().shift_without_plate_offset();
shift += Point(scaled(plate_origin.x()), scaled(plate_origin.y()));
for (Polygon &hole : holes)
hole.translate(shift);
for (const auto &kv : print.get_brimMap()) {
Polylines brim_paths;
kv.second.collect_polylines(brim_paths);
for (const Polyline &path : brim_paths)
for (const Point &point : path.points)
if (contains(holes, point, false))
return true;
}
return false;
}
// The span is skirt_height layers, or every layer when a draft shield is on (forced even at
// height 0); per-object skirts are rejected in By object printing (no room between objects).
TEST_CASE("Skirt is emitted once per layer it spans", "[SkirtBrim]")
@@ -232,6 +257,131 @@ TEST_CASE("Brim ears appear only at corners within the max angle", "[SkirtBrim]"
}
}
TEST_CASE("Outer-only brim ears stay out of model holes", "[SkirtBrim]")
{
const bool outer_only = GENERATE(false, true);
DYNAMIC_SECTION("brim_ears_outer_only=" << outer_only) {
Print print;
init_and_process_print({ TestMesh::cube_with_concave_hole }, print, {
{ "skirt_loops", 0 },
{ "brim_type", "brim_ears" },
{ "brim_width", 2 },
{ "brim_ears_max_angle", 125 },
{ "brim_ears_detection_length", 0 },
{ "brim_ears_outer_only", outer_only },
{ "initial_layer_line_width", 0.5 },
});
REQUIRE(brim_loop_count(print) > 0);
CHECK(brim_enters_first_layer_hole(print) != outer_only);
}
}
TEST_CASE("Painted brim ear radius controls sliced size", "[SkirtBrim]")
{
constexpr double ear_radius = 10.0;
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
config.set_deserialize_strict({
{ "skirt_loops", 0 },
{ "brim_type", "painted" },
{ "brim_width", 15 },
{ "brim_object_gap", 0.1 },
{ "brim_ears_outer_only", true },
{ "initial_layer_line_width", 0.5 },
});
Print print;
Model model;
init_print({ cube(20) }, print, model, config);
print.process();
const PrintObject *object = print.get_object(0);
REQUIRE(!object->layers().front()->lslices.empty());
const Point ear_center = object->layers().front()->lslices.front().contour.points.front();
Transform3d model_transform = model.objects.front()->instances.front()->get_transformation().get_matrix_no_offset();
const Point &center_offset = object->center_offset();
model_transform = model_transform.pretranslate(
Vec3d(-unscale<double>(center_offset.x()), -unscale<double>(center_offset.y()), 0));
Vec3d model_pos = model_transform.inverse() *
Vec3d(unscale<double>(ear_center.x()), unscale<double>(ear_center.y()), 0);
model_pos.z() = model.objects.front()->raw_mesh_bounding_box().min.z() - 0.0001;
model.objects.front()->brim_points = {
BrimPoint(model_pos.cast<float>(), float(ear_radius)),
};
print.apply(model, config);
print.process();
const Vec3d plate_origin = print.get_plate_origin();
Point path_center = ear_center + object->instances().front().shift_without_plate_offset();
path_center += Point(scaled(plate_origin.x()), scaled(plate_origin.y()));
double max_path_radius = 0.0;
for (const auto &kv : print.get_brimMap()) {
Polylines brim_paths;
kv.second.collect_polylines(brim_paths);
for (const Polyline &path : brim_paths)
for (const Point &point : path.points)
max_path_radius = std::max(max_path_radius, unscale<double>((point - path_center).cast<double>().norm()));
}
REQUIRE(max_path_radius > 0.0);
INFO("Outermost painted-ear path radius: " << max_path_radius << " mm");
CHECK(max_path_radius > ear_radius - 0.5);
CHECK(max_path_radius < ear_radius);
}
TEST_CASE("Outer-only painted brim ears stay out of model holes", "[SkirtBrim]")
{
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
config.set_deserialize_strict({
{ "skirt_loops", 0 },
{ "brim_type", "painted" },
{ "brim_ears_outer_only", true },
{ "initial_layer_line_width", 0.5 },
});
Print print;
Model model;
init_print({ TestMesh::cube_with_concave_hole }, print, model, config);
// Slice once to obtain exact outer and inner contour points in print
// coordinates, then express them in the model coordinates painted ears store.
print.process();
const PrintObject *object = print.get_object(0);
REQUIRE(!object->layers().front()->lslices.empty());
REQUIRE(!object->layers().front()->lslices.front().holes.empty());
Transform3d model_transform = model.objects.front()->instances.front()->get_transformation().get_matrix_no_offset();
const Point &center_offset = object->center_offset();
model_transform = model_transform.pretranslate(
Vec3d(-unscale<double>(center_offset.x()), -unscale<double>(center_offset.y()), 0));
const double bottom_z = model.objects.front()->raw_mesh_bounding_box().min.z() - 0.0001;
auto painted_point = [&model_transform, bottom_z](const Point &point) {
Vec3d model_pos = model_transform.inverse() *
Vec3d(unscale<double>(point.x()), unscale<double>(point.y()), 0);
model_pos.z() = bottom_z;
return BrimPoint(model_pos.cast<float>(), 3.f);
};
const ExPolygon &first_slice = object->layers().front()->lslices.front();
Polygon inner_contour = first_slice.holes.front();
inner_contour.reverse();
const Points inner_ear_points = inner_contour.concave_points(55. * PI / 180.);
REQUIRE(!inner_ear_points.empty());
model.objects.front()->brim_points = {
painted_point(first_slice.contour.points.front()),
painted_point(inner_ear_points.front()),
};
print.apply(model, config);
print.process();
REQUIRE(brim_loop_count(print) > 0);
CHECK_FALSE(brim_enters_first_layer_hole(print));
}
SCENARIO("Skirt has the configured number of loops", "[SkirtBrim]") {
GIVEN("20mm cube and default config") {
WHEN("skirt_loops is set to 2") {
+184
View File
@@ -0,0 +1,184 @@
#include <catch2/catch_all.hpp>
#include <string>
#include <vector>
#include "libslic3r/BoundingBox.hpp"
#include "libslic3r/ClipperUtils.hpp"
#include "libslic3r/GCode/GCodeProcessor.hpp"
#include "libslic3r/GCode/WipeTower.hpp"
#include "libslic3r/PrintConfig.hpp"
#include "test_helpers.hpp"
using namespace Slic3r;
using namespace Slic3r::Test;
// Taken from the config enum map rather than hand-listed, so a flavor added to GCodeFlavor later
// is covered here without editing this file.
static std::vector<GCodeFlavor> non_klipper_flavors()
{
std::vector<GCodeFlavor> flavors;
for (const auto &[name, value] : ConfigOptionEnum<GCodeFlavor>::get_enum_values())
if (GCodeFlavor(value) != gcfKlipper)
flavors.push_back(GCodeFlavor(value));
return flavors;
}
static std::string flavor_name(GCodeFlavor flavor)
{
return ConfigOptionEnum<GCodeFlavor>::get_enum_names()[int(flavor)];
}
TEST_CASE("Klipper flushes the wipe tower planner queue with M400", "[WipeTower]")
{
CHECK(std::string(flush_planner_queue_command(gcfKlipper)) == "M400\n");
}
TEST_CASE("Other flavors flush the wipe tower planner queue with a zero dwell", "[WipeTower]")
{
const GCodeFlavor flavor = GENERATE(from_range(non_klipper_flavors()));
INFO("gcode flavor: " << flavor_name(flavor));
CHECK(std::string(flush_planner_queue_command(flavor)) == "G4 S0\n");
}
// 1.5s is exactly representable as a float, so neither form can drift when rounded.
TEST_CASE("Klipper waits in the wipe tower with a millisecond dwell", "[WipeTower]")
{
CHECK(wait_command(gcfKlipper, 1.5f) == "G4 P1500\n");
}
TEST_CASE("Other flavors wait in the wipe tower with a seconds dwell", "[WipeTower]")
{
const GCodeFlavor flavor = GENERATE(from_range(non_klipper_flavors()));
INFO("gcode flavor: " << flavor_name(flavor));
CHECK(wait_command(flavor, 1.5f) == "G4 S1.500\n");
}
// The prime tower is validated against the real printable outline, so the placement clamps have to
// agree with it wherever that outline is not a rectangle. A regular hexagon inscribed in a 200mm
// circle stands in for the shipped delta beds.
TEST_CASE("The wipe tower placement clamp follows a non-rectangular bed outline", "[WipeTower]")
{
const coord_t margin = scaled<coord_t>(1.);
auto square_at = [](double x, double y, double side) {
return BoundingBox(Point::new_scale(x, y), Point::new_scale(x + side, y + side));
};
// Does the footprint, padded by pad, sit inside the outline once the returned move is applied?
auto lands_inside = [](BoundingBox box, const Polygons &bed, const Vec2f &move, coord_t pad) {
box.translate(Point::new_scale(move.x(), move.y()));
return diff(Polygons{box.inflated(pad).polygon()}, bed).empty();
};
const Polygons hex_bed{make_circle_num_segments(scaled<double>(100.), 6)};
const Polygons square_bed{Polygon::new_scale(Pointfs{{0., 0.}, {200., 0.}, {200., 200.}, {0., 200.}})};
SECTION("a rectangular bed is left to the bounding box clamp") {
const Vec2f move = WipeTower::move_box_inside_polygon(square_at(50., 50., 30.), square_bed, margin);
CHECK_THAT(move.x(), Catch::Matchers::WithinAbs(0., 1e-6));
CHECK_THAT(move.y(), Catch::Matchers::WithinAbs(0., 1e-6));
}
// Dragging the tower off one edge may not pull it away from the other, or it would jump out from
// under the cursor instead of sliding along the edge.
SECTION("only the violated axis is clamped") {
const Vec2f move = WipeTower::move_box_inside_polygon(square_at(185., 50., 30.), square_bed, margin);
CHECK_THAT(move.x(), Catch::Matchers::WithinAbs(-16., 1e-6));
CHECK_THAT(move.y(), Catch::Matchers::WithinAbs(0., 1e-6));
}
SECTION("a footprint already inside the outline is left alone") {
const Vec2f move = WipeTower::move_box_inside_polygon(square_at(-15., -15., 30.), hex_bed, margin);
CHECK_THAT(move.x(), Catch::Matchers::WithinAbs(0., 1e-6));
CHECK_THAT(move.y(), Catch::Matchers::WithinAbs(0., 1e-6));
}
SECTION("a footprint in the bounding box corner is pulled onto the bed") {
const BoundingBox box = square_at(55., 50., 30.);
REQUIRE_FALSE(lands_inside(box, hex_bed, Vec2f::Zero(), margin)); // in the bbox, off the hexagon
CHECK(lands_inside(box, hex_bed, WipeTower::move_box_inside_polygon(box, hex_bed, margin), margin));
}
// An unresolved auto brim width reaches the drag clamp as a negative margin. Padding by it would
// shrink the footprint and hand back a position the slice validation still rejects.
SECTION("a negative margin still lands the footprint inside the outline") {
const BoundingBox box = square_at(55., 50., 30.);
const coord_t brim = scaled<coord_t>(-0.5);
CHECK(lands_inside(box, hex_bed, WipeTower::move_box_inside_polygon(box, hex_bed, brim), 0));
}
SECTION("a footprint too large for the bed is left alone") {
const Vec2f move = WipeTower::move_box_inside_polygon(square_at(-200., -200., 400.), hex_bed, margin);
CHECK_THAT(move.x(), Catch::Matchers::WithinAbs(0., 1e-6));
CHECK_THAT(move.y(), Catch::Matchers::WithinAbs(0., 1e-6));
}
}
// The cases above only exercise the helpers in isolation. The one below slices a real
// two-filament print, so it also covers the binding constraint of both changes: that the
// configured `gcode_flavor` reaches the wipe tower writer and lands in the exported G-code.
// The G-code inside each WIPE_TOWER_START/WIPE_TOWER_END pair, concatenated, so an M400 emitted
// outside the tower (e.g. GCodeProcessor's pre-heat injector) cannot create a false match.
static std::string wipe_tower_regions(const std::string &gcode)
{
const std::string &start_tag = GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Wipe_Tower_Start);
const std::string &end_tag = GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Wipe_Tower_End);
std::string regions;
size_t pos = 0;
while (true) {
size_t start = gcode.find(start_tag, pos);
if (start == std::string::npos)
break;
size_t end = gcode.find(end_tag, start);
if (end == std::string::npos)
break;
regions.append(gcode, start, end - start);
pos = end + 1;
}
return regions;
}
// A per-layer toolchange between the wall and infill filaments, same shape as
// test_multifilament.cpp's "Each feature prints with its assigned filament", so the wipe tower
// runs its toolchange path (and so `flush_planner_queue()`) on every layer.
static DynamicPrintConfig wipe_tower_toolchange_config(const std::string &gcode_flavor)
{
return multifilament_config(2, {
{ "sparse_infill_filament_id", 1 },
{ "internal_solid_filament_id", 1 },
{ "top_surface_filament_id", 1 },
{ "bottom_surface_filament_id", 1 },
{ "outer_wall_filament_id", 2 },
{ "inner_wall_filament_id", 2 },
{ "enable_prime_tower", true },
{ "layer_height", 0.3 },
{ "gcode_flavor", gcode_flavor },
});
}
// Slices a 10mm cube under `config`. Not plain Test::slice: a brand-new Print's first `apply()`
// counts one filament in use, and DynamicPrintConfig::normalize_fdm_2's single-filament rule then
// clears `enable_prime_tower`. A second apply, once init_print's regions have settled, sees both
// filaments and the tower survives.
static std::string slice_with_prime_tower(const DynamicPrintConfig &config)
{
Print print;
Model model;
init_print({ cube(10) }, print, model, config);
print.apply(model, config);
return gcode(print);
}
TEST_CASE("The wipe tower's toolchange planner flush follows the gcode flavor", "[WipeTower]")
{
auto [flavor, expected, unexpected] = GENERATE(table<std::string, std::string, std::string>({
{ "klipper", "M400", "G4 S0" },
{ "marlin", "G4 S0", "M400" } }));
DYNAMIC_SECTION(flavor) {
const std::string tower = wipe_tower_regions(slice_with_prime_tower(wipe_tower_toolchange_config(flavor)));
REQUIRE_FALSE(tower.empty());
CHECK_THAT(tower, Catch::Matchers::ContainsSubstring(expected));
CHECK_THAT(tower, !Catch::Matchers::ContainsSubstring(unexpected));
}
}