mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-09-10 18:56:13 +00:00
Merge branch 'main' into 'feat/printer-agent-impl'
This commit is contained in:
172
tests/data/wipe_tower_temperature_trace_main.txt
Normal file
172
tests/data/wipe_tower_temperature_trace_main.txt
Normal file
@@ -0,0 +1,172 @@
|
||||
# Temperature and tool-change commands of a wait_for_temp_on_wipe_tower-off slice,
|
||||
# captured from the main branch at a10d9e77cf. Regeneration is described
|
||||
# at the test that reads this file: "Toolchange temperature commands are unchanged
|
||||
# when the wipe tower wait is off" in tests/fff_print/test_multifilament.cpp.
|
||||
#
|
||||
# The "time:" and "lead" values are toolchain-specific -- GCC, Clang and MSVC each produce
|
||||
# slightly different estimates from an identical toolpath -- so they are compared with a
|
||||
# tolerance, not exactly. Do not regenerate this file to resolve a mismatch in them: no single
|
||||
# capture satisfies all three, and recapturing just moves the failure to other platforms.
|
||||
M104 S215 T0 ; set nozzle temperature
|
||||
M104 S215 T1 ; set nozzle temperature
|
||||
; CP PRIMING START
|
||||
T1 ; change extruder
|
||||
M109 S215 T1 ; set nozzle temperature and wait for it to be reached
|
||||
M104 S175 T1 ; set nozzle temperature ;cooldown
|
||||
T0 ; change extruder
|
||||
M109 S215 T0 ; set nozzle temperature and wait for it to be reached
|
||||
; CP PRIMING END
|
||||
M104 S215 T1 ; preheat T1 time: 30s lead 30.2s
|
||||
; CP TOOLCHANGE START
|
||||
M104 S175 T0 ; set nozzle temperature ;cooldown
|
||||
T1 ; change extruder
|
||||
M109 S215 T1 ; set nozzle temperature and wait for it to be reached
|
||||
; CP TOOLCHANGE END
|
||||
M104 S240 T1 ; set nozzle temperature
|
||||
M104 S240 T0 ; preheat T0 time: 30s lead 30.0s
|
||||
; CP TOOLCHANGE START
|
||||
M104 S200 T1 ; set nozzle temperature ;cooldown
|
||||
T0 ; change extruder
|
||||
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.4s
|
||||
; CP TOOLCHANGE START
|
||||
M104 S200 T0 ; set nozzle temperature ;cooldown
|
||||
T1 ; change extruder
|
||||
M109 S240 T1 ; set nozzle temperature and wait for it to be reached
|
||||
; CP TOOLCHANGE END
|
||||
M104 S240 T0 ; preheat T0 time: 31s lead 30.9s
|
||||
; CP TOOLCHANGE START
|
||||
M104 S200 T1 ; set nozzle temperature ;cooldown
|
||||
T0 ; change extruder
|
||||
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.2s
|
||||
; CP TOOLCHANGE START
|
||||
M104 S200 T0 ; set nozzle temperature ;cooldown
|
||||
T1 ; change extruder
|
||||
M109 S240 T1 ; set nozzle temperature and wait for it to be reached
|
||||
; CP TOOLCHANGE END
|
||||
M104 S240 T0 ; preheat T0 time: 30s lead 30.2s
|
||||
; CP TOOLCHANGE START
|
||||
M104 S200 T1 ; set nozzle temperature ;cooldown
|
||||
T0 ; change extruder
|
||||
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.2s
|
||||
; CP TOOLCHANGE START
|
||||
M104 S200 T0 ; set nozzle temperature ;cooldown
|
||||
T1 ; change extruder
|
||||
M109 S240 T1 ; set nozzle temperature and wait for it to be reached
|
||||
; CP TOOLCHANGE END
|
||||
M104 S240 T0 ; preheat T0 time: 30s lead 30.2s
|
||||
; CP TOOLCHANGE START
|
||||
M104 S200 T1 ; set nozzle temperature ;cooldown
|
||||
T0 ; change extruder
|
||||
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.2s
|
||||
; CP TOOLCHANGE START
|
||||
M104 S200 T0 ; set nozzle temperature ;cooldown
|
||||
T1 ; change extruder
|
||||
M109 S240 T1 ; set nozzle temperature and wait for it to be reached
|
||||
; CP TOOLCHANGE END
|
||||
M104 S240 T0 ; preheat T0 time: 30s lead 30.2s
|
||||
; CP TOOLCHANGE START
|
||||
M104 S200 T1 ; set nozzle temperature ;cooldown
|
||||
T0 ; change extruder
|
||||
M109 S240 T0 ; set nozzle temperature and wait for it to be reached
|
||||
; CP TOOLCHANGE END
|
||||
M104 S240 T1 ; preheat T1 time: 31s lead 30.7s
|
||||
; CP TOOLCHANGE START
|
||||
M104 S200 T0 ; set nozzle temperature ;cooldown
|
||||
T1 ; change extruder
|
||||
M109 S240 T1 ; set nozzle temperature and wait for it to be reached
|
||||
; CP TOOLCHANGE END
|
||||
M104 S240 T0 ; preheat T0 time: 31s lead 30.6s
|
||||
; CP TOOLCHANGE START
|
||||
M104 S200 T1 ; set nozzle temperature ;cooldown
|
||||
T0 ; change extruder
|
||||
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.3s
|
||||
; CP TOOLCHANGE START
|
||||
M104 S200 T0 ; set nozzle temperature ;cooldown
|
||||
T1 ; change extruder
|
||||
M109 S240 T1 ; set nozzle temperature and wait for it to be reached
|
||||
; CP TOOLCHANGE END
|
||||
M104 S240 T0 ; preheat T0 time: 31s lead 30.6s
|
||||
; CP TOOLCHANGE START
|
||||
M104 S200 T1 ; set nozzle temperature ;cooldown
|
||||
T0 ; change extruder
|
||||
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
|
||||
T1 ; change extruder
|
||||
M109 S240 T1 ; set nozzle temperature and wait for it to be reached
|
||||
; CP TOOLCHANGE END
|
||||
M104 S240 T0 ; preheat T0 time: 30s lead 30.2s
|
||||
; CP TOOLCHANGE START
|
||||
M104 S200 T1 ; set nozzle temperature ;cooldown
|
||||
T0 ; change extruder
|
||||
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.2s
|
||||
; CP TOOLCHANGE START
|
||||
M104 S200 T0 ; set nozzle temperature ;cooldown
|
||||
T1 ; change extruder
|
||||
M109 S240 T1 ; set nozzle temperature and wait for it to be reached
|
||||
; CP TOOLCHANGE END
|
||||
M104 S240 T0 ; preheat T0 time: 30s lead 30.2s
|
||||
; CP TOOLCHANGE START
|
||||
M104 S200 T1 ; set nozzle temperature ;cooldown
|
||||
T0 ; change extruder
|
||||
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.2s
|
||||
; CP TOOLCHANGE START
|
||||
M104 S200 T0 ; set nozzle temperature ;cooldown
|
||||
T1 ; change extruder
|
||||
M109 S240 T1 ; set nozzle temperature and wait for it to be reached
|
||||
; CP TOOLCHANGE END
|
||||
M104 S240 T0 ; preheat T0 time: 30s lead 30.2s
|
||||
; CP TOOLCHANGE START
|
||||
M104 S200 T1 ; set nozzle temperature ;cooldown
|
||||
T0 ; change extruder
|
||||
M109 S240 T0 ; set nozzle temperature and wait for it to be reached
|
||||
; CP TOOLCHANGE END
|
||||
M104 S240 T1 ; preheat T1 time: 31s lead 30.7s
|
||||
; CP TOOLCHANGE START
|
||||
M104 S200 T0 ; set nozzle temperature ;cooldown
|
||||
T1 ; change extruder
|
||||
M109 S240 T1 ; set nozzle temperature and wait for it to be reached
|
||||
; CP TOOLCHANGE END
|
||||
M104 S240 T0 ; preheat T0 time: 30s lead 30.4s
|
||||
; CP TOOLCHANGE START
|
||||
M104 S200 T1 ; set nozzle temperature ;cooldown
|
||||
T0 ; change extruder
|
||||
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.4s
|
||||
; CP TOOLCHANGE START
|
||||
M104 S200 T0 ; set nozzle temperature ;cooldown
|
||||
T1 ; change extruder
|
||||
M109 S240 T1 ; set nozzle temperature and wait for it to be reached
|
||||
; CP TOOLCHANGE END
|
||||
M104 S240 T0 ; preheat T0 time: 30s lead 30.0s
|
||||
; CP TOOLCHANGE START
|
||||
M104 S200 T1 ; set nozzle temperature ;cooldown
|
||||
T0 ; change extruder
|
||||
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
|
||||
T1 ; change extruder
|
||||
M109 S240 T1 ; set nozzle temperature and wait for it to be reached
|
||||
; CP TOOLCHANGE END
|
||||
; CP TOOLCHANGE START
|
||||
; CP TOOLCHANGE END
|
||||
M104 S0 ; turn off temperature
|
||||
@@ -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")
|
||||
|
||||
441
tests/fff_print/test_extrusion_processor.cpp
Normal file
441
tests/fff_print/test_extrusion_processor.cpp
Normal file
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -338,6 +338,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]")
|
||||
{
|
||||
|
||||
184
tests/fff_print/test_wipe_tower.cpp
Normal file
184
tests/fff_print/test_wipe_tower.cpp
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,7 @@ add_executable(${_TEST_NAME}_tests
|
||||
test_stl.cpp
|
||||
test_meshboolean.cpp
|
||||
test_marchingsquares.cpp
|
||||
test_model.cpp
|
||||
test_utils.cpp
|
||||
test_timeutils.cpp
|
||||
test_voronoi.cpp
|
||||
|
||||
@@ -155,10 +155,8 @@ SCENARIO("H2C multi-nozzle .3mf round-trip", "[3mf][MultiNozzle]") {
|
||||
|
||||
// store_bbs_3mf stages Metadata/project_settings.config through the model's backup path;
|
||||
// point it at a writable temp dir (the default lives under a read-only root in CI).
|
||||
std::string backup_dir =
|
||||
(boost::filesystem::temp_directory_path() / boost::filesystem::unique_path("orca_mn_%%%%%%%%")).string();
|
||||
boost::filesystem::create_directories(backup_dir);
|
||||
model.set_backup_path(backup_dir);
|
||||
ScopedTemporaryDir backup_dir("orca_mn");
|
||||
model.set_backup_path(backup_dir.string());
|
||||
|
||||
// Global (printer) config: give nozzle_volume_type a non-default value so the slice_info
|
||||
// read-back is a meaningful assertion (High Flow == 1).
|
||||
@@ -180,7 +178,8 @@ SCENARIO("H2C multi-nozzle .3mf round-trip", "[3mf][MultiNozzle]") {
|
||||
plate->config.set_key_value("enable_filament_dynamic_map", new ConfigOptionBool(true));
|
||||
|
||||
WHEN("stored to and reloaded from a .3mf") {
|
||||
std::string test_file = std::string(TEST_DATA_DIR) + "/test_3mf/mn_roundtrip.3mf";
|
||||
ScopedTemporaryFile temp(".3mf");
|
||||
const std::string test_file = temp.string();
|
||||
|
||||
StoreParams store_params;
|
||||
store_params.path = test_file.c_str();
|
||||
@@ -202,8 +201,6 @@ SCENARIO("H2C multi-nozzle .3mf round-trip", "[3mf][MultiNozzle]") {
|
||||
bool loaded = load_bbs_3mf(test_file.c_str(), &dst_config, &ctxt, &dst_model, &dst_plates,
|
||||
&project_presets, &is_bbl_3mf, &is_orca_3mf, &file_version, nullptr,
|
||||
LoadStrategy::LoadModel | LoadStrategy::LoadConfig);
|
||||
boost::filesystem::remove(test_file);
|
||||
|
||||
THEN("every multi-nozzle key round-trips as expected") {
|
||||
REQUIRE(loaded);
|
||||
REQUIRE(dst_plates.size() >= 1);
|
||||
@@ -233,7 +230,6 @@ SCENARIO("H2C multi-nozzle .3mf round-trip", "[3mf][MultiNozzle]") {
|
||||
release_PlateData_list(dst_plates);
|
||||
}
|
||||
delete plate; // store_bbs_3mf does not take ownership of the source plate
|
||||
boost::filesystem::remove_all(backup_dir);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -250,10 +246,8 @@ SCENARIO("Non-standard nozzle diameter survives .3mf save on a single-nozzle pri
|
||||
REQUIRE(load_stl(src_file.c_str(), &model));
|
||||
model.add_default_instances();
|
||||
|
||||
std::string backup_dir =
|
||||
(boost::filesystem::temp_directory_path() / boost::filesystem::unique_path("orca_nd_%%%%%%%%")).string();
|
||||
boost::filesystem::create_directories(backup_dir);
|
||||
model.set_backup_path(backup_dir);
|
||||
ScopedTemporaryDir backup_dir("orca_nd");
|
||||
model.set_backup_path(backup_dir.string());
|
||||
|
||||
// Single extruder with a non-standard 0.5 mm nozzle; extruder_max_nozzle_count stays at its
|
||||
// default (no nozzle cluster), so the writer must emit the exact config diameter.
|
||||
@@ -276,7 +270,8 @@ SCENARIO("Non-standard nozzle diameter survives .3mf save on a single-nozzle pri
|
||||
plate->slice_filaments_info.push_back(fi);
|
||||
|
||||
WHEN("stored to and reloaded from a .3mf") {
|
||||
std::string test_file = std::string(TEST_DATA_DIR) + "/test_3mf/nd_roundtrip.3mf";
|
||||
ScopedTemporaryFile temp(".3mf");
|
||||
const std::string test_file = temp.string();
|
||||
|
||||
StoreParams store_params;
|
||||
store_params.path = test_file.c_str();
|
||||
@@ -296,8 +291,6 @@ SCENARIO("Non-standard nozzle diameter survives .3mf save on a single-nozzle pri
|
||||
bool loaded = load_bbs_3mf(test_file.c_str(), &dst_config, &ctxt, &dst_model, &dst_plates,
|
||||
&project_presets, &is_bbl_3mf, &is_orca_3mf, &file_version, nullptr,
|
||||
LoadStrategy::LoadModel | LoadStrategy::LoadConfig);
|
||||
boost::filesystem::remove(test_file);
|
||||
|
||||
THEN("the saved nozzle diameter is the exact 0.5, not the rounded 0.4") {
|
||||
REQUIRE(loaded);
|
||||
REQUIRE(dst_plates.size() >= 1);
|
||||
@@ -315,7 +308,6 @@ SCENARIO("Non-standard nozzle diameter survives .3mf save on a single-nozzle pri
|
||||
release_PlateData_list(dst_plates);
|
||||
}
|
||||
delete plate; // store_bbs_3mf does not take ownership of the source plate
|
||||
boost::filesystem::remove_all(backup_dir);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -436,10 +428,8 @@ SCENARIO("Nozzle-group metadata .3mf round-trip", "[3mf][MultiNozzle]") {
|
||||
REQUIRE(load_stl(src_file.c_str(), &model));
|
||||
model.add_default_instances();
|
||||
|
||||
std::string backup_dir =
|
||||
(boost::filesystem::temp_directory_path() / boost::filesystem::unique_path("orca_ng_%%%%%%%%")).string();
|
||||
boost::filesystem::create_directories(backup_dir);
|
||||
model.set_backup_path(backup_dir);
|
||||
ScopedTemporaryDir backup_dir("orca_ng");
|
||||
model.set_backup_path(backup_dir.string());
|
||||
|
||||
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
||||
|
||||
@@ -459,7 +449,8 @@ SCENARIO("Nozzle-group metadata .3mf round-trip", "[3mf][MultiNozzle]") {
|
||||
plate->config.set_key_value("filament_map", new ConfigOptionInts({ 1, 2, 1 }));
|
||||
|
||||
WHEN("stored to and reloaded from a .3mf") {
|
||||
std::string test_file = std::string(TEST_DATA_DIR) + "/test_3mf/ng_roundtrip.3mf";
|
||||
ScopedTemporaryFile temp(".3mf");
|
||||
const std::string test_file = temp.string();
|
||||
|
||||
StoreParams store_params;
|
||||
store_params.path = test_file.c_str();
|
||||
@@ -479,8 +470,6 @@ SCENARIO("Nozzle-group metadata .3mf round-trip", "[3mf][MultiNozzle]") {
|
||||
bool loaded = load_bbs_3mf(test_file.c_str(), &dst_config, &ctxt, &dst_model, &dst_plates,
|
||||
&project_presets, &is_bbl_3mf, &is_orca_3mf, &file_version, nullptr,
|
||||
LoadStrategy::LoadModel | LoadStrategy::LoadConfig);
|
||||
boost::filesystem::remove(test_file);
|
||||
|
||||
THEN("the <nozzle> tags round-trip into the loaded plate's nozzles_info") {
|
||||
REQUIRE(loaded);
|
||||
REQUIRE(dst_plates.size() >= 1);
|
||||
@@ -506,67 +495,5 @@ SCENARIO("Nozzle-group metadata .3mf round-trip", "[3mf][MultiNozzle]") {
|
||||
release_PlateData_list(dst_plates);
|
||||
}
|
||||
delete plate;
|
||||
boost::filesystem::remove_all(backup_dir);
|
||||
}
|
||||
}
|
||||
|
||||
SCENARIO("2D convex hull of sinking object", "[3mf][.]") {
|
||||
GIVEN("model") {
|
||||
// load a model
|
||||
Model model;
|
||||
std::string src_file = std::string(TEST_DATA_DIR) + "/test_3mf/Prusa.stl";
|
||||
REQUIRE(load_stl(src_file.c_str(), &model));
|
||||
model.add_default_instances();
|
||||
|
||||
WHEN("model is rotated, scaled and set as sinking") {
|
||||
ModelObject* object = model.objects[0];
|
||||
object->center_around_origin(false);
|
||||
|
||||
// This outputs the same exact data as the Prusaslicer test
|
||||
write_debug_stl("3mf/orca.ascii", object->volumes[0]->mesh());
|
||||
|
||||
// set instance's attitude so that it is rotated, scaled (and sinking? how is it sinking? the rotation? does it matter if it's sinking?)
|
||||
ModelInstance* instance = object->instances[0];
|
||||
instance->set_rotation(X, -M_PI / 4.0);
|
||||
instance->set_offset(Vec3d::Zero());
|
||||
instance->set_scaling_factor({ 2.0, 2.0, 2.0 });
|
||||
|
||||
// calculate 2D convex hull
|
||||
auto trafo = instance->get_transformation().get_matrix();
|
||||
|
||||
// This matrix is the same exact matrix as the Prusaslicer test
|
||||
CAPTURE(trafo);
|
||||
Polygon hull_2d = object->convex_hull_2d(trafo);
|
||||
|
||||
// But we get different hull_2d.points here (and somehow decimal numbers despite being int64_t values, but that's probabaly printing configuration somewhere -- Prusaslicer's prints out with newlines between the X&Y and not one between coordinates, which is about the worse possible output).
|
||||
// I think it's something to do with PrusaSlicer ignoring everything under the Z plane, which makes sense from the results.
|
||||
// See the comments added to ModelObject::convex_hull_2d for more information.
|
||||
|
||||
// verify result
|
||||
Points result = {
|
||||
{ -91501496, -15914144 },
|
||||
{ 91501496, -15914144 },
|
||||
{ 91501496, 4243 },
|
||||
{ 78229680, 4246883 },
|
||||
{ 56898100, 4246883 },
|
||||
{ -85501496, 4242641 },
|
||||
{ -91501496, 4243 }
|
||||
};
|
||||
|
||||
THEN("2D convex hull should match with reference") {
|
||||
// Allow 1um error due to floating point rounding.
|
||||
bool res = hull_2d.points.size() == result.size();
|
||||
if (res) {
|
||||
for (size_t i = 0; i < result.size(); ++ i) {
|
||||
const Point &p1 = result[i];
|
||||
const Point &p2 = hull_2d.points[i];
|
||||
CHECK((std::abs(p1.x() - p2.x()) > 1 || std::abs(p1.y() - p2.y()) > 1));
|
||||
}
|
||||
}
|
||||
|
||||
CAPTURE(hull_2d.points);
|
||||
REQUIRE(res);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
#include "libslic3r/PrintConfigConstants.hpp"
|
||||
#include "libslic3r/LocalesUtils.hpp"
|
||||
|
||||
#include "test_utils.hpp"
|
||||
|
||||
#include <cereal/types/polymorphic.hpp>
|
||||
#include <cereal/types/string.hpp>
|
||||
#include <cereal/types/vector.hpp>
|
||||
@@ -233,6 +235,56 @@ SCENARIO("Config ini load/save interface", "[Config]") {
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("Flush-volume warning predicate respects used filament transitions", "[Config][Regression]")
|
||||
{
|
||||
const std::vector<double> multipliers = {1.0};
|
||||
|
||||
SECTION("Single used filament does not trigger warning with zero transition entries")
|
||||
{
|
||||
const std::vector<double> matrix = {
|
||||
0.0, 0.0,
|
||||
0.0, 0.0
|
||||
};
|
||||
const std::vector<int> used_filaments = {1};
|
||||
|
||||
REQUIRE_FALSE(has_zero_flush_volume_for_used_filaments(matrix, multipliers, used_filaments));
|
||||
}
|
||||
|
||||
SECTION("Two used filaments trigger warning when transition flush entry is zero")
|
||||
{
|
||||
const std::vector<double> matrix = {
|
||||
0.0, 0.0,
|
||||
0.0, 0.0
|
||||
};
|
||||
const std::vector<int> used_filaments = {1, 2};
|
||||
|
||||
REQUIRE(has_zero_flush_volume_for_used_filaments(matrix, multipliers, used_filaments));
|
||||
}
|
||||
|
||||
SECTION("Two used filaments do not trigger warning when transitions are non-zero")
|
||||
{
|
||||
const std::vector<double> matrix = {
|
||||
0.0, 280.0,
|
||||
280.0, 0.0
|
||||
};
|
||||
const std::vector<int> used_filaments = {1, 2};
|
||||
|
||||
REQUIRE_FALSE(has_zero_flush_volume_for_used_filaments(matrix, multipliers, used_filaments));
|
||||
}
|
||||
|
||||
SECTION("Zero multiplier still triggers warning when multiple filaments are used")
|
||||
{
|
||||
const std::vector<double> matrix = {
|
||||
0.0, 280.0,
|
||||
280.0, 0.0
|
||||
};
|
||||
const std::vector<double> zero_multiplier = {0.0};
|
||||
const std::vector<int> used_filaments = {1, 2};
|
||||
|
||||
REQUIRE(has_zero_flush_volume_for_used_filaments(matrix, zero_multiplier, used_filaments));
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: https://github.com/SoftFever/OrcaSlicer/issues/11269 - Is this test still relevant? Delete if not.
|
||||
// It was failing so at least "nozzle_type" and "extruder_printable_area" could not be serialized
|
||||
// and an exception was thrown, but "nozzle_type" has been around for at least 3 months now.
|
||||
@@ -407,8 +459,7 @@ SCENARIO("update_diff_values_to_child_config tolerates legacy machine-limit vect
|
||||
// }
|
||||
|
||||
TEST_CASE("save_to_json round-trips plugin capability references as strings", "[Config][plugins]") {
|
||||
namespace fs = boost::filesystem;
|
||||
const fs::path tmp = fs::temp_directory_path() / fs::unique_path("orca_plugins_%%%%-%%%%.json");
|
||||
ScopedTemporaryFile tmp(".json");
|
||||
const std::vector<std::string> refs = {
|
||||
"local_plugin;;inset",
|
||||
"cloud_plugin;550e8400-e29b-41d4-a716-446655440000;inset"
|
||||
@@ -435,8 +486,6 @@ TEST_CASE("save_to_json round-trips plugin capability references as strings", "[
|
||||
REQUIRE(reloaded.load_from_json(tmp.string(), substitutions, true, key_values, reason) == 0);
|
||||
CHECK(reason.empty());
|
||||
CHECK(reloaded.option<ConfigOptionStrings>("slicing_pipeline_plugin")->values == refs);
|
||||
|
||||
fs::remove(tmp);
|
||||
}
|
||||
|
||||
TEST_CASE("plugin capability references survive string-map serialization", "[Config][plugins]") {
|
||||
|
||||
40
tests/libslic3r/test_model.cpp
Normal file
40
tests/libslic3r/test_model.cpp
Normal file
@@ -0,0 +1,40 @@
|
||||
#include <catch2/catch_all.hpp>
|
||||
|
||||
#include "libslic3r/Model.hpp"
|
||||
|
||||
using namespace Slic3r;
|
||||
|
||||
// convex_hull_2d does not clip geometry below the bed, so these cases avoid
|
||||
// sinking transforms.
|
||||
TEST_CASE("A part's 2D convex hull is its footprint projected onto the bed", "[Model]")
|
||||
{
|
||||
Model model;
|
||||
ModelObject* object = model.add_object();
|
||||
// Keep the cube's raw coordinates ([0,20] on every axis): the default
|
||||
// add_volume re-centers the geometry, which would move the footprint.
|
||||
object->add_volume(make_cube(20, 20, 20), ModelVolumeType::MODEL_PART, false);
|
||||
|
||||
SECTION("identity transform yields the 20 mm square") {
|
||||
const Polygon hull = object->convex_hull_2d(Geometry::Transformation{}.get_matrix());
|
||||
const BoundingBox bb = hull.bounding_box();
|
||||
CHECK(hull.size() == 4);
|
||||
CHECK(bb.min.x() == scaled(0.));
|
||||
CHECK(bb.min.y() == scaled(0.));
|
||||
CHECK(bb.max.x() == scaled(20.));
|
||||
CHECK(bb.max.y() == scaled(20.));
|
||||
}
|
||||
|
||||
SECTION("scaling and offset move and grow the footprint") {
|
||||
Geometry::Transformation t;
|
||||
t.set_scaling_factor({2, 2, 2}); // cube now spans [0,40]
|
||||
t.set_offset({10, 5, 0}); // then shift +10 in X, +5 in Y
|
||||
|
||||
const Polygon hull = object->convex_hull_2d(t.get_matrix());
|
||||
const BoundingBox bb = hull.bounding_box();
|
||||
CHECK(hull.size() == 4);
|
||||
CHECK(bb.min.x() == scaled(10.));
|
||||
CHECK(bb.min.y() == scaled(5.));
|
||||
CHECK(bb.max.x() == scaled(50.));
|
||||
CHECK(bb.max.y() == scaled(45.));
|
||||
}
|
||||
}
|
||||
@@ -5,28 +5,14 @@
|
||||
#include "libslic3r/PresetBundle.hpp"
|
||||
#include "libslic3r/AppConfig.hpp"
|
||||
|
||||
#include "test_utils.hpp"
|
||||
|
||||
using namespace Slic3r;
|
||||
|
||||
namespace {
|
||||
|
||||
namespace fs = boost::filesystem;
|
||||
|
||||
struct TempPresetDir {
|
||||
fs::path path;
|
||||
|
||||
TempPresetDir()
|
||||
{
|
||||
path = fs::temp_directory_path() / fs::unique_path("orcaslicer-preset-%%%%-%%%%-%%%%");
|
||||
fs::create_directories(path);
|
||||
}
|
||||
|
||||
~TempPresetDir()
|
||||
{
|
||||
boost::system::error_code ec;
|
||||
fs::remove_all(path, ec);
|
||||
}
|
||||
};
|
||||
|
||||
void write_print_preset(const DynamicPrintConfig &default_config, const fs::path &file, const std::string &name, const std::string &inherits = {})
|
||||
{
|
||||
DynamicPrintConfig config(default_config);
|
||||
@@ -82,17 +68,17 @@ struct RenameTestCollection : public PresetCollection
|
||||
|
||||
TEST_CASE("Preset identity is canonicalized from load path", "[Preset][Identity]")
|
||||
{
|
||||
TempPresetDir temp_dir;
|
||||
ScopedTemporaryDir temp_dir;
|
||||
PresetBundle bundle;
|
||||
PresetsConfigSubstitutions substitutions;
|
||||
|
||||
write_print_preset(bundle.prints.default_preset().config, temp_dir.path / PRESET_PRINT_NAME / "User.json", "User");
|
||||
write_print_preset(bundle.prints.default_preset().config, temp_dir.path / PRESET_LOCAL_DIR / "bundle-1" / PRESET_PRINT_NAME / "LocalBundle.json", "LocalBundle");
|
||||
write_print_preset(bundle.prints.default_preset().config, temp_dir.path / PRESET_SUBSCRIBED_DIR / "remote-1" / PRESET_PRINT_NAME / "Subscribed.json", "Subscribed");
|
||||
write_print_preset(bundle.prints.default_preset().config, temp_dir.path() / PRESET_PRINT_NAME / "User.json", "User");
|
||||
write_print_preset(bundle.prints.default_preset().config, temp_dir.path() / PRESET_LOCAL_DIR / "bundle-1" / PRESET_PRINT_NAME / "LocalBundle.json", "LocalBundle");
|
||||
write_print_preset(bundle.prints.default_preset().config, temp_dir.path() / PRESET_SUBSCRIBED_DIR / "remote-1" / PRESET_PRINT_NAME / "Subscribed.json", "Subscribed");
|
||||
|
||||
bundle.prints.load_presets(temp_dir.path.string(), PRESET_PRINT_NAME, substitutions, ForwardCompatibilitySubstitutionRule::Disable);
|
||||
bundle.prints.load_presets((temp_dir.path / PRESET_LOCAL_DIR / "bundle-1").string(), PRESET_PRINT_NAME, substitutions, ForwardCompatibilitySubstitutionRule::Disable);
|
||||
bundle.prints.load_presets((temp_dir.path / PRESET_SUBSCRIBED_DIR / "remote-1").string(), PRESET_PRINT_NAME, substitutions, ForwardCompatibilitySubstitutionRule::Disable);
|
||||
bundle.prints.load_presets(temp_dir.path().string(), PRESET_PRINT_NAME, substitutions, ForwardCompatibilitySubstitutionRule::Disable);
|
||||
bundle.prints.load_presets((temp_dir.path() / PRESET_LOCAL_DIR / "bundle-1").string(), PRESET_PRINT_NAME, substitutions, ForwardCompatibilitySubstitutionRule::Disable);
|
||||
bundle.prints.load_presets((temp_dir.path() / PRESET_SUBSCRIBED_DIR / "remote-1").string(), PRESET_PRINT_NAME, substitutions, ForwardCompatibilitySubstitutionRule::Disable);
|
||||
|
||||
const Preset *root_user = bundle.prints.find_preset("User");
|
||||
REQUIRE(root_user != nullptr);
|
||||
@@ -112,14 +98,14 @@ TEST_CASE("Preset identity is canonicalized from load path", "[Preset][Identity]
|
||||
|
||||
TEST_CASE("Legacy bundle import without bundle metadata stays in the user preset directory", "[Preset][Identity]")
|
||||
{
|
||||
TempPresetDir temp_dir;
|
||||
ScopedTemporaryDir temp_dir;
|
||||
PresetBundle bundle;
|
||||
|
||||
PresetsConfigSubstitutions substitutions;
|
||||
std::vector<std::string> result;
|
||||
int overwrite = 0;
|
||||
std::string file = (temp_dir.path / "legacy-bundle" / "Imported.json").string();
|
||||
const fs::path user_root = temp_dir.path / "user";
|
||||
std::string file = (temp_dir.path() / "legacy-bundle" / "Imported.json").string();
|
||||
const fs::path user_root = temp_dir.path() / "user";
|
||||
|
||||
write_print_preset(bundle.prints.default_preset().config, file, "Imported");
|
||||
fs::create_directories(user_root);
|
||||
@@ -252,7 +238,7 @@ TEST_CASE("find_preset2 auto-matches removed Generic vendor profiles to the libr
|
||||
|
||||
TEST_CASE("Renamed parent is normalized into a loaded preset's inherits", "[Preset][Rename]")
|
||||
{
|
||||
TempPresetDir temp_dir;
|
||||
ScopedTemporaryDir temp_dir;
|
||||
RenameTestCollection coll;
|
||||
|
||||
// Current parent, renamed from "Old Process".
|
||||
@@ -262,10 +248,10 @@ TEST_CASE("Renamed parent is normalized into a loaded preset's inherits", "[Pres
|
||||
|
||||
// A user preset on disk that still inherits the OLD name.
|
||||
write_preset_with_inherits(coll.default_preset().config,
|
||||
temp_dir.path / PRESET_PRINT_NAME / "Child.json", "Child", "Old Process");
|
||||
temp_dir.path() / PRESET_PRINT_NAME / "Child.json", "Child", "Old Process");
|
||||
|
||||
PresetsConfigSubstitutions substitutions;
|
||||
coll.load_presets(temp_dir.path.string(), PRESET_PRINT_NAME, substitutions,
|
||||
coll.load_presets(temp_dir.path().string(), PRESET_PRINT_NAME, substitutions,
|
||||
ForwardCompatibilitySubstitutionRule::Disable);
|
||||
|
||||
const Preset *child = coll.find_preset("Child");
|
||||
@@ -279,17 +265,17 @@ TEST_CASE("Renamed parent is normalized into a loaded preset's inherits", "[Pres
|
||||
|
||||
TEST_CASE("Removed Generic parent is normalized into a loaded filament's inherits", "[Preset][Rename]")
|
||||
{
|
||||
TempPresetDir temp_dir;
|
||||
ScopedTemporaryDir temp_dir;
|
||||
PresetBundle bundle;
|
||||
|
||||
add_inmemory_preset(bundle.filaments, "Generic PLA @System");
|
||||
|
||||
// A user filament that still inherits a removed "<vendor> Generic PLA" profile.
|
||||
write_preset_with_inherits(bundle.filaments.default_preset().config,
|
||||
temp_dir.path / PRESET_FILAMENT_NAME / "MyPLA.json", "MyPLA", "Voron Generic PLA");
|
||||
temp_dir.path() / PRESET_FILAMENT_NAME / "MyPLA.json", "MyPLA", "Voron Generic PLA");
|
||||
|
||||
PresetsConfigSubstitutions substitutions;
|
||||
bundle.filaments.load_presets(temp_dir.path.string(), PRESET_FILAMENT_NAME, substitutions,
|
||||
bundle.filaments.load_presets(temp_dir.path().string(), PRESET_FILAMENT_NAME, substitutions,
|
||||
ForwardCompatibilitySubstitutionRule::Disable);
|
||||
|
||||
const Preset *child = bundle.filaments.find_preset("MyPLA");
|
||||
@@ -488,3 +474,69 @@ TEST_CASE("Plugin capability override keys are scoped per preset type", "[Preset
|
||||
}
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
// A standalone filament collection that exposes the protected library masking builder, so the Orca
|
||||
// Filament Library scenario can be set up without the full system-profile load pipeline.
|
||||
struct LibraryFilamentTestCollection : public PresetCollection
|
||||
{
|
||||
LibraryFilamentTestCollection()
|
||||
: PresetCollection(Preset::TYPE_FILAMENT, Preset::filament_options(),
|
||||
static_cast<const PrintRegionConfig &>(FullPrintConfig::defaults()))
|
||||
{}
|
||||
using PresetCollection::update_library_profile_excluded_from;
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
// Orca: a filament in the Orca Filament Library that names its compatible printers has to hide the generic
|
||||
// library filament sharing its alias, the same way a vendor owned filament does. Otherwise both are compatible
|
||||
// with that printer and the plater combo box lists the shared alias twice.
|
||||
TEST_CASE("A printer specific filament supersedes the generic library filament with the same alias", "[Preset][Bundle]")
|
||||
{
|
||||
LibraryFilamentTestCollection filaments;
|
||||
PresetCollection printers(Preset::TYPE_PRINTER, Preset::printer_options(),
|
||||
static_cast<const PrintRegionConfig &>(FullPrintConfig::defaults()));
|
||||
// The masking keys off the vendor name, which VendorProfile's constructor does not derive from the id.
|
||||
VendorProfile library(PresetBundle::ORCA_FILAMENT_LIBRARY);
|
||||
VendorProfile vendor("Vendor");
|
||||
library.name = PresetBundle::ORCA_FILAMENT_LIBRARY;
|
||||
vendor.name = "Vendor";
|
||||
|
||||
auto add_filament = [&filaments](const VendorProfile &owner, const std::string &name, std::vector<std::string> compatible_printers) {
|
||||
Preset &preset = add_inmemory_preset(filaments, name);
|
||||
preset.alias = "Generic ABS";
|
||||
preset.vendor = &owner;
|
||||
preset.config.option<ConfigOptionStrings>("compatible_printers", true)->values = std::move(compatible_printers);
|
||||
};
|
||||
|
||||
add_filament(library, "Generic ABS @System", {});
|
||||
add_filament(library, "Generic ABS @Printer A", { "Printer A" });
|
||||
add_filament(vendor, "Generic ABS @Printer B", { "Printer B" });
|
||||
|
||||
filaments.update_library_profile_excluded_from();
|
||||
|
||||
const Preset *generic = filaments.find_preset("Generic ABS @System");
|
||||
REQUIRE(generic != nullptr);
|
||||
CHECK(generic->m_excluded_from.count("Printer A") == 1);
|
||||
CHECK(generic->m_excluded_from.count("Printer B") == 1);
|
||||
CHECK(generic->m_excluded_from.size() == 2);
|
||||
|
||||
// A printer specific profile names printers, so it is never the one being hidden - not even by itself.
|
||||
const Preset *specific = filaments.find_preset("Generic ABS @Printer A");
|
||||
REQUIRE(specific != nullptr);
|
||||
CHECK(specific->m_excluded_from.empty());
|
||||
|
||||
// ...and the generic profile really drops out of the compatible set on the printer it is hidden from.
|
||||
add_inmemory_preset(printers, "Printer A");
|
||||
add_inmemory_preset(printers, "Printer C");
|
||||
const Preset *printer_a = printers.find_preset("Printer A");
|
||||
const Preset *printer_c = printers.find_preset("Printer C");
|
||||
REQUIRE(printer_a != nullptr);
|
||||
REQUIRE(printer_c != nullptr);
|
||||
|
||||
const PresetWithVendorProfile generic_lib(*generic, &library);
|
||||
CHECK_FALSE(is_compatible_with_printer(generic_lib, PresetWithVendorProfile(*printer_a, nullptr)));
|
||||
CHECK(is_compatible_with_printer(generic_lib, PresetWithVendorProfile(*printer_c, nullptr)));
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
#include "libslic3r/Print.hpp"
|
||||
#include "libslic3r/TriangleMesh.hpp"
|
||||
|
||||
#include "test_utils.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <map>
|
||||
#include <set>
|
||||
@@ -708,10 +710,9 @@ TEST_CASE("Sequential selector prints publish a stitched result and cache the pl
|
||||
REQUIRE(print.config().filament_self_index.values.size() >= print.config().filament_map.values.size());
|
||||
|
||||
// Export must consume the cached plans and produce g-code without throwing.
|
||||
boost::filesystem::path gcode_path = boost::filesystem::temp_directory_path() / "orca_seq_dynamic_publish_test.gcode";
|
||||
REQUIRE_NOTHROW(print.export_gcode(gcode_path.string(), nullptr, nullptr));
|
||||
REQUIRE(boost::filesystem::exists(gcode_path));
|
||||
boost::filesystem::remove(gcode_path);
|
||||
ScopedTemporaryFile gcode(".gcode");
|
||||
REQUIRE_NOTHROW(print.export_gcode(gcode.string(), nullptr, nullptr));
|
||||
REQUIRE(boost::filesystem::exists(gcode.path()));
|
||||
}
|
||||
|
||||
TEST_CASE("Per-variant expansion gives migrating filaments one slot per variant", "[PrintConfig][H2C][Dynamic]")
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
|
||||
#include <string>
|
||||
|
||||
#include "test_utils.hpp"
|
||||
|
||||
namespace Slic3r {
|
||||
|
||||
// Point data_dir() at a throwaway directory for the lifetime of a test and
|
||||
@@ -13,24 +15,20 @@ namespace Slic3r {
|
||||
// disposable tree and tests don't leak state into each other.
|
||||
struct ScopedDataDir
|
||||
{
|
||||
ScopedTemporaryDir tmp; // owns the temp dir (create + recursive remove)
|
||||
boost::filesystem::path dir; // = tmp.path(); kept as a member for callers
|
||||
std::string previous;
|
||||
boost::filesystem::path dir;
|
||||
|
||||
explicit ScopedDataDir(const std::string& tag)
|
||||
: tmp("orca-" + tag), dir(tmp.path()), previous(data_dir())
|
||||
{
|
||||
namespace fs = boost::filesystem;
|
||||
previous = data_dir();
|
||||
dir = fs::temp_directory_path() / fs::unique_path("orca-" + tag + "-%%%%-%%%%");
|
||||
fs::create_directories(dir);
|
||||
set_data_dir(dir.string());
|
||||
}
|
||||
|
||||
~ScopedDataDir()
|
||||
{
|
||||
set_data_dir(previous);
|
||||
boost::system::error_code ec;
|
||||
boost::filesystem::remove_all(dir, ec);
|
||||
}
|
||||
~ScopedDataDir() { set_data_dir(previous); } // tmp removes the directory
|
||||
|
||||
// The plugin manager scans {data_dir}/orca_plugins.
|
||||
boost::filesystem::path plugins_dir() const { return dir / "orca_plugins"; }
|
||||
|
||||
ScopedDataDir(const ScopedDataDir&) = delete;
|
||||
ScopedDataDir& operator=(const ScopedDataDir&) = delete;
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
#include "libslic3r/Utils.hpp"
|
||||
#include "slic3r/Utils/bambu_networking.hpp"
|
||||
|
||||
#include "plugin_test_utils.hpp"
|
||||
|
||||
using namespace Slic3r;
|
||||
namespace fs = boost::filesystem;
|
||||
|
||||
@@ -25,27 +27,16 @@ static const char* PLUGIN_EXT = ".so";
|
||||
|
||||
struct PluginFolderFixture
|
||||
{
|
||||
fs::path root;
|
||||
std::string previous_data_dir;
|
||||
ScopedDataDir data{"netver"};
|
||||
|
||||
PluginFolderFixture()
|
||||
{
|
||||
previous_data_dir = data_dir();
|
||||
root = fs::temp_directory_path() / fs::unique_path("orca-netver-%%%%%%%%");
|
||||
fs::create_directories(root / "plugins");
|
||||
set_data_dir(root.string());
|
||||
}
|
||||
|
||||
~PluginFolderFixture()
|
||||
{
|
||||
set_data_dir(previous_data_dir);
|
||||
boost::system::error_code ec;
|
||||
fs::remove_all(root, ec);
|
||||
fs::create_directories(data.dir / "plugins");
|
||||
}
|
||||
|
||||
void add_plugin(const std::string& version)
|
||||
{
|
||||
boost::nowide::ofstream f((root / "plugins" / (PLUGIN_PREFIX + version + PLUGIN_EXT)).string());
|
||||
boost::nowide::ofstream f((data.dir / "plugins" / (PLUGIN_PREFIX + version + PLUGIN_EXT)).string());
|
||||
f << "stub";
|
||||
}
|
||||
};
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
#include <slic3r/plugin/PluginFsUtils.hpp>
|
||||
#include <slic3r/plugin/PythonInterpreter.hpp>
|
||||
|
||||
#include "plugin_test_utils.hpp"
|
||||
|
||||
#include <boost/filesystem.hpp>
|
||||
|
||||
#include <algorithm>
|
||||
@@ -25,32 +27,6 @@ namespace fs = boost::filesystem;
|
||||
|
||||
namespace {
|
||||
|
||||
// Point data_dir() at a throwaway directory for the lifetime of a test and restore the previous
|
||||
// value afterwards, so discovery scans a disposable {data_dir}/orca_plugins tree and tests don't
|
||||
// leak state into each other.
|
||||
struct ScopedDataDir
|
||||
{
|
||||
std::string previous;
|
||||
fs::path dir;
|
||||
|
||||
explicit ScopedDataDir(const std::string& tag)
|
||||
{
|
||||
previous = data_dir();
|
||||
dir = fs::temp_directory_path() / fs::unique_path("orca-" + tag + "-%%%%-%%%%");
|
||||
fs::create_directories(dir);
|
||||
set_data_dir(dir.string());
|
||||
}
|
||||
|
||||
~ScopedDataDir()
|
||||
{
|
||||
set_data_dir(previous);
|
||||
boost::system::error_code ec;
|
||||
fs::remove_all(dir, ec);
|
||||
}
|
||||
|
||||
fs::path plugins_dir() const { return dir / "orca_plugins"; }
|
||||
};
|
||||
|
||||
// Brings the plugin system up, and tears it down explicitly at the end of the test.
|
||||
//
|
||||
// Shutting the interpreter down here, rather than leaving it to PythonInterpreter's static
|
||||
|
||||
@@ -16,6 +16,8 @@ TEST_CASE("SlicingPipeline capability-type string maps round-trip", "[slicing_pi
|
||||
#include "libslic3r/Point.hpp"
|
||||
#include "libslic3r/ExPolygon.hpp"
|
||||
#include "libslic3r/Surface.hpp"
|
||||
|
||||
#include "test_utils.hpp"
|
||||
#include "libslic3r/Layer.hpp"
|
||||
#include "libslic3r/ExtrusionEntity.hpp"
|
||||
#include "libslic3r/ExtrusionEntityCollection.hpp"
|
||||
@@ -142,7 +144,7 @@ TEST_CASE("orca.slicing psGCodePostProcess context: file edit in place + config
|
||||
import_orca_module();
|
||||
py::gil_scoped_acquire gil;
|
||||
|
||||
const fs::path gpath = fs::temp_directory_path() / fs::unique_path("orca_pp_%%%%-%%%%.gcode");
|
||||
ScopedTemporaryFile gpath(".gcode");
|
||||
{
|
||||
boost::nowide::ofstream ofs(gpath.string());
|
||||
ofs << "; header\nG1 X0 Y0\n";
|
||||
@@ -196,9 +198,7 @@ _pp_result = Stamp().execute(_pp_ctx)
|
||||
boost::nowide::ifstream ifs(gpath.string());
|
||||
std::stringstream ss; ss << ifs.rdbuf(); contents = ss.str();
|
||||
}
|
||||
CHECK(contents.find("; stamped by File") != std::string::npos);
|
||||
fs::remove(gpath);
|
||||
}
|
||||
CHECK(contents.find("; stamped by File") != std::string::npos);}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Toolpath helpers for the raw-graph tests.
|
||||
|
||||
@@ -27,26 +27,47 @@ inline Slic3r::TriangleMesh load_model(const std::string &obj_filename)
|
||||
return mesh;
|
||||
}
|
||||
|
||||
// RAII holder for a unique temporary file path, removed when the guard goes out
|
||||
// of scope so a failing assertion never leaks it. Uses the system temp dir with
|
||||
// a unique name (parallel-safe, cross-platform). The file itself is created by
|
||||
// whoever writes to path()/string(); this only reserves the name and cleans up.
|
||||
class ScopedTemporaryFile
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scoped temporary paths
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Owns a unique path under the system temp dir, "<prefix>-<unique>[<extension>]"
|
||||
// (parallel-safe, cross-platform). Shared base for the two RAII temp guards below.
|
||||
class ScopedTemporaryPath
|
||||
{
|
||||
public:
|
||||
const boost::filesystem::path &path() const { return m_path; }
|
||||
std::string string() const { return m_path.string(); }
|
||||
ScopedTemporaryPath(const ScopedTemporaryPath &) = delete;
|
||||
ScopedTemporaryPath &operator=(const ScopedTemporaryPath &) = delete;
|
||||
|
||||
protected:
|
||||
ScopedTemporaryPath(const std::string &prefix, const std::string &extension)
|
||||
: m_path(boost::filesystem::temp_directory_path()
|
||||
/ boost::filesystem::unique_path(prefix + "-%%%%-%%%%-%%%%" + extension))
|
||||
{}
|
||||
~ScopedTemporaryPath() = default; // non-virtual: never deleted through a base pointer
|
||||
|
||||
boost::filesystem::path m_path;
|
||||
};
|
||||
|
||||
// A temp file the caller creates by writing to path()/string(); the guard only
|
||||
// reserves the name and removes the file on scope exit.
|
||||
class ScopedTemporaryFile : public ScopedTemporaryPath
|
||||
{
|
||||
public:
|
||||
explicit ScopedTemporaryFile(const std::string &extension = ".tmp")
|
||||
: m_path(boost::filesystem::temp_directory_path()
|
||||
/ boost::filesystem::unique_path("orca-%%%%-%%%%-%%%%" + extension))
|
||||
{}
|
||||
: ScopedTemporaryPath("orca", extension) {}
|
||||
~ScopedTemporaryFile() { boost::system::error_code ec; boost::filesystem::remove(m_path, ec); }
|
||||
ScopedTemporaryFile(const ScopedTemporaryFile &) = delete;
|
||||
ScopedTemporaryFile &operator=(const ScopedTemporaryFile &) = delete;
|
||||
};
|
||||
|
||||
const boost::filesystem::path &path() const { return m_path; }
|
||||
std::string string() const { return m_path.string(); }
|
||||
|
||||
private:
|
||||
boost::filesystem::path m_path;
|
||||
// A temp directory created on construction and removed recursively on scope exit.
|
||||
class ScopedTemporaryDir : public ScopedTemporaryPath
|
||||
{
|
||||
public:
|
||||
explicit ScopedTemporaryDir(const std::string &prefix = "orca")
|
||||
: ScopedTemporaryPath(prefix, "") { boost::filesystem::create_directories(m_path); }
|
||||
~ScopedTemporaryDir() { boost::system::error_code ec; boost::filesystem::remove_all(m_path, ec); }
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -66,7 +87,7 @@ inline std::string debug_artifact_path(const std::string &name)
|
||||
boost::filesystem::path dir = boost::filesystem::temp_directory_path()
|
||||
/ boost::filesystem::unique_path("orca-test-artifacts-%%%%-%%%%");
|
||||
boost::filesystem::create_directories(dir);
|
||||
std::printf("Debug test artifacts will be written to %s\n", dir.string().c_str());
|
||||
std::fprintf(stderr, "Debug test artifacts will be written to %s\n", dir.string().c_str());
|
||||
return dir;
|
||||
}();
|
||||
boost::filesystem::path full = root / name;
|
||||
@@ -75,57 +96,52 @@ inline std::string debug_artifact_path(const std::string &name)
|
||||
}
|
||||
|
||||
// Dump a mesh as OBJ.
|
||||
inline void write_debug_obj(const std::string &name, const Slic3r::TriangleMesh &mesh)
|
||||
inline void write_debug_obj([[maybe_unused]] const std::string &name,
|
||||
[[maybe_unused]] const Slic3r::TriangleMesh &mesh)
|
||||
{
|
||||
#ifndef NDEBUG
|
||||
mesh.WriteOBJFile(debug_artifact_path(name).c_str());
|
||||
#else
|
||||
(void) name; (void) mesh;
|
||||
#endif
|
||||
}
|
||||
|
||||
inline void write_debug_obj(const std::string &name, const indexed_triangle_set &its)
|
||||
inline void write_debug_obj([[maybe_unused]] const std::string &name,
|
||||
[[maybe_unused]] const indexed_triangle_set &its)
|
||||
{
|
||||
#ifndef NDEBUG
|
||||
its_write_obj(its, debug_artifact_path(name).c_str());
|
||||
#else
|
||||
(void) name; (void) its;
|
||||
#endif
|
||||
}
|
||||
|
||||
// Dump a mesh as ASCII STL.
|
||||
inline void write_debug_stl(const std::string &name, const Slic3r::TriangleMesh &mesh)
|
||||
inline void write_debug_stl([[maybe_unused]] const std::string &name,
|
||||
[[maybe_unused]] const Slic3r::TriangleMesh &mesh)
|
||||
{
|
||||
#ifndef NDEBUG
|
||||
mesh.write_ascii(debug_artifact_path(name).c_str());
|
||||
#else
|
||||
(void) name; (void) mesh;
|
||||
#endif
|
||||
}
|
||||
|
||||
// Draw an SVG artifact through a callback that receives the open SVG. Second
|
||||
// overload takes a BoundingBox when the drawing needs one.
|
||||
template<class Draw>
|
||||
inline void write_debug_svg(const std::string &name, Draw &&draw)
|
||||
inline void write_debug_svg([[maybe_unused]] const std::string &name, [[maybe_unused]] Draw &&draw)
|
||||
{
|
||||
#ifndef NDEBUG
|
||||
Slic3r::SVG svg(debug_artifact_path(name));
|
||||
draw(svg);
|
||||
svg.Close();
|
||||
#else
|
||||
(void) name; (void) draw;
|
||||
#endif
|
||||
}
|
||||
|
||||
template<class Draw>
|
||||
inline void write_debug_svg(const std::string &name, const Slic3r::BoundingBox &bbox, Draw &&draw)
|
||||
inline void write_debug_svg([[maybe_unused]] const std::string &name,
|
||||
[[maybe_unused]] const Slic3r::BoundingBox &bbox,
|
||||
[[maybe_unused]] Draw &&draw)
|
||||
{
|
||||
#ifndef NDEBUG
|
||||
Slic3r::SVG svg(debug_artifact_path(name), bbox);
|
||||
draw(svg);
|
||||
svg.Close();
|
||||
#else
|
||||
(void) name; (void) bbox; (void) draw;
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -133,13 +149,11 @@ inline void write_debug_svg(const std::string &name, const Slic3r::BoundingBox &
|
||||
// artifact. operator<< is resolved by ADL at the call site, so this header needn't
|
||||
// include the producer's headers.
|
||||
template<class Produce>
|
||||
inline void write_debug_stream(const std::string &name, Produce &&produce)
|
||||
inline void write_debug_stream([[maybe_unused]] const std::string &name, [[maybe_unused]] Produce &&produce)
|
||||
{
|
||||
#ifndef NDEBUG
|
||||
std::ofstream out(debug_artifact_path(name), std::ios::out | std::ios::binary);
|
||||
out << produce();
|
||||
#else
|
||||
(void) name; (void) produce;
|
||||
#endif
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user