mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-09-27 19:01:02 +00:00
Merge branch 'main' into weilun/speed_dial
This commit is contained in:
@@ -21,6 +21,7 @@ add_executable(${_TEST_NAME}_tests
|
||||
test_support_material.cpp
|
||||
test_tree_support.cpp
|
||||
test_trianglemesh.cpp
|
||||
test_wipe.cpp
|
||||
test_wipe_tower.cpp
|
||||
)
|
||||
target_link_libraries(${_TEST_NAME}_tests test_common libslic3r Catch2::Catch2WithMain)
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
#include "libslic3r/Geometry.hpp"
|
||||
#include "libslic3r/Layer.hpp"
|
||||
#include "libslic3r/Print.hpp"
|
||||
#include "libslic3r/PrintConfig.hpp"
|
||||
#include "libslic3r/SVG.hpp"
|
||||
#include "libslic3r/libslic3r.h"
|
||||
|
||||
@@ -676,6 +677,73 @@ TEST_CASE("Ironing follows the solid infill rotation template", "[Fill]")
|
||||
REQUIRE(compared > int(ironing.size()) / 2);
|
||||
}
|
||||
|
||||
|
||||
namespace {
|
||||
|
||||
PrintRegionConfig ironing_config(IroningType type,
|
||||
int top_surface_filament_id = 1,
|
||||
int top_shell_layers = 3,
|
||||
int bottom_shell_layers = 1)
|
||||
{
|
||||
PrintRegionConfig cfg;
|
||||
cfg.ironing_type.value = type;
|
||||
cfg.top_surface_filament_id.value = top_surface_filament_id;
|
||||
cfg.top_shell_layers.value = top_shell_layers;
|
||||
cfg.bottom_shell_layers.value = bottom_shell_layers;
|
||||
cfg.outer_wall_filament_id.value = 1;
|
||||
cfg.wall_loops.value = 2;
|
||||
return cfg;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("Ironing an all-solid region uses the top surface filament on every layer", "[Fill]")
|
||||
{
|
||||
const PrintRegionConfig cfg = ironing_config(IroningType::AllSolid, /*top_surface_filament_id=*/2);
|
||||
const bool is_topmost_layer = GENERATE(false, true);
|
||||
CAPTURE(is_topmost_layer);
|
||||
REQUIRE(Layer::choose_ironing_extruder(cfg, /*spiral_mode=*/false, is_topmost_layer) == 2);
|
||||
}
|
||||
|
||||
TEST_CASE("Ironing top surfaces uses the top surface filament when the region has top shells", "[Fill]")
|
||||
{
|
||||
const PrintRegionConfig cfg = ironing_config(IroningType::TopSurfaces,
|
||||
/*top_surface_filament_id=*/3,
|
||||
/*top_shell_layers=*/2);
|
||||
REQUIRE(Layer::choose_ironing_extruder(cfg, /*spiral_mode=*/false, /*is_topmost_layer=*/false) == 3);
|
||||
}
|
||||
|
||||
TEST_CASE("Ironing top surfaces without top shells needs spiral mode and more than one bottom shell", "[Fill]")
|
||||
{
|
||||
const PrintRegionConfig one_bottom_shell = ironing_config(IroningType::TopSurfaces,
|
||||
/*top_surface_filament_id=*/1,
|
||||
/*top_shell_layers=*/0,
|
||||
/*bottom_shell_layers=*/1);
|
||||
const PrintRegionConfig two_bottom_shells = ironing_config(IroningType::TopSurfaces,
|
||||
/*top_surface_filament_id=*/1,
|
||||
/*top_shell_layers=*/0,
|
||||
/*bottom_shell_layers=*/2);
|
||||
|
||||
REQUIRE(Layer::choose_ironing_extruder(two_bottom_shells, /*spiral_mode=*/true, /*is_topmost_layer=*/false) == 1);
|
||||
REQUIRE(Layer::choose_ironing_extruder(one_bottom_shell, /*spiral_mode=*/true, /*is_topmost_layer=*/false) == -1);
|
||||
REQUIRE(Layer::choose_ironing_extruder(two_bottom_shells, /*spiral_mode=*/false, /*is_topmost_layer=*/false) == -1);
|
||||
}
|
||||
|
||||
TEST_CASE("Ironing the topmost surface only applies to the topmost layer", "[Fill]")
|
||||
{
|
||||
const PrintRegionConfig cfg = ironing_config(IroningType::TopmostOnly, /*top_surface_filament_id=*/4);
|
||||
REQUIRE(Layer::choose_ironing_extruder(cfg, /*spiral_mode=*/false, /*is_topmost_layer=*/true) == 4);
|
||||
REQUIRE(Layer::choose_ironing_extruder(cfg, /*spiral_mode=*/false, /*is_topmost_layer=*/false) == -1);
|
||||
}
|
||||
|
||||
TEST_CASE("A region with ironing turned off is never ironed", "[Fill]")
|
||||
{
|
||||
const PrintRegionConfig cfg = ironing_config(IroningType::NoIroning);
|
||||
const bool spiral_mode = GENERATE(false, true);
|
||||
CAPTURE(spiral_mode);
|
||||
REQUIRE(Layer::choose_ironing_extruder(cfg, spiral_mode, /*is_topmost_layer=*/true) == -1);
|
||||
}
|
||||
|
||||
TEST_CASE("Solid infill direction offsets every layer when no template is set", "[Fill]")
|
||||
{
|
||||
auto angles_for = [](int direction) {
|
||||
|
||||
@@ -0,0 +1,653 @@
|
||||
#include <catch2/catch_all.hpp>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <map>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
#include "libslic3r/GCode/GCodeProcessor.hpp"
|
||||
#include "libslic3r/GCodeReader.hpp"
|
||||
#include "libslic3r/PrintConfig.hpp"
|
||||
#include "libslic3r/Layer.hpp"
|
||||
|
||||
#include "test_helpers.hpp"
|
||||
|
||||
using namespace Slic3r;
|
||||
using namespace Slic3r::Test;
|
||||
|
||||
namespace {
|
||||
|
||||
DynamicPrintConfig wipe_config(const char *wall_generator, bool wipe_inward,
|
||||
const char *wipe_inward_distance = "50%",
|
||||
const char *seam_gap = "10%", bool wipe_on_loops = false,
|
||||
const char *wall_loops = "2",
|
||||
const char *wall_sequence = "inner wall/outer wall",
|
||||
bool alternate_extra_wall = false,
|
||||
const char *sparse_infill_density = "0%",
|
||||
const char *seam_position = "aligned")
|
||||
{
|
||||
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
||||
config.set_deserialize_strict({
|
||||
{ "nozzle_diameter", "0.4" },
|
||||
{ "layer_height", "0.2" },
|
||||
{ "initial_layer_print_height", "0.2" },
|
||||
{ "line_width", "0.45" },
|
||||
{ "outer_wall_line_width", "0" }, // Orca: Auto must use the actual path width.
|
||||
{ "wall_loops", wall_loops },
|
||||
{ "wall_generator", wall_generator },
|
||||
{ "wall_sequence", wall_sequence },
|
||||
{ "top_shell_layers", "0" },
|
||||
{ "bottom_shell_layers", "0" },
|
||||
{ "sparse_infill_density", sparse_infill_density },
|
||||
{ "seam_position", seam_position },
|
||||
{ "seam_gap", seam_gap },
|
||||
{ "wipe", "1" },
|
||||
{ "wipe_distance", "2" },
|
||||
{ "retraction_length", "0.8" },
|
||||
{ "retract_when_changing_layer", "1" },
|
||||
{ "wipe_inward", wipe_inward ? "1" : "0" },
|
||||
{ "wipe_inward_distance", wipe_inward_distance },
|
||||
{ "wipe_on_loops", wipe_on_loops ? "1" : "0" },
|
||||
{ "alternate_extra_wall", alternate_extra_wall ? "1" : "0" },
|
||||
{ "gcode_comments", "1" },
|
||||
{ "machine_start_gcode", "" },
|
||||
{ "machine_end_gcode", "" },
|
||||
});
|
||||
return config;
|
||||
}
|
||||
|
||||
struct WipeTrajectory {
|
||||
Vec2d start;
|
||||
double z;
|
||||
std::vector<Vec2d> destinations;
|
||||
};
|
||||
|
||||
std::vector<WipeTrajectory> wipe_trajectories(const std::string &gcode)
|
||||
{
|
||||
const std::string &start_tag = GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Wipe_Start);
|
||||
const std::string &end_tag = GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Wipe_End);
|
||||
std::vector<WipeTrajectory> trajectories;
|
||||
bool in_wipe = false;
|
||||
|
||||
GCodeReader parser;
|
||||
parser.parse_buffer(gcode, [&](GCodeReader &self, const GCodeReader::GCodeLine &line) {
|
||||
const std::string_view comment = line.comment();
|
||||
if (comment.find(start_tag) != std::string_view::npos) {
|
||||
in_wipe = true;
|
||||
trajectories.push_back({Vec2d(self.x(), self.y()), self.z(), {}});
|
||||
return;
|
||||
}
|
||||
if (comment.find(end_tag) != std::string_view::npos) {
|
||||
in_wipe = false;
|
||||
return;
|
||||
}
|
||||
if (in_wipe && line.dist_XY(self) > EPSILON)
|
||||
trajectories.back().destinations.emplace_back(line.new_X(self), line.new_Y(self));
|
||||
});
|
||||
return trajectories;
|
||||
}
|
||||
|
||||
std::vector<Vec2d> wipe_destinations(const std::string &gcode)
|
||||
{
|
||||
std::vector<Vec2d> destinations;
|
||||
for (const WipeTrajectory &trajectory : wipe_trajectories(gcode))
|
||||
destinations.insert(destinations.end(), trajectory.destinations.begin(), trajectory.destinations.end());
|
||||
return destinations;
|
||||
}
|
||||
|
||||
bool trajectories_differ(const std::vector<Vec2d> &lhs, const std::vector<Vec2d> &rhs)
|
||||
{
|
||||
if (lhs.size() != rhs.size())
|
||||
return true;
|
||||
for (size_t i = 0; i < lhs.size(); ++i)
|
||||
if ((lhs[i] - rhs[i]).norm() > 0.01)
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
double trajectory_length(const WipeTrajectory &trajectory)
|
||||
{
|
||||
double length = 0.;
|
||||
Vec2d previous = trajectory.start;
|
||||
for (const Vec2d &destination : trajectory.destinations) {
|
||||
length += (destination - previous).norm();
|
||||
previous = destination;
|
||||
}
|
||||
return length;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("Wipe retraction preserves fractional speed with inward wipe disabled", "[Wipe][Regression]")
|
||||
{
|
||||
const char *retraction_speed = GENERATE("25.25", "25.5", "25.75");
|
||||
const char *relative_e = GENERATE("0", "1");
|
||||
INFO("retraction speed: " << retraction_speed);
|
||||
INFO("relative E: " << relative_e);
|
||||
DynamicPrintConfig config = wipe_config("classic", false);
|
||||
config.set_deserialize_strict({
|
||||
{"gcode_flavor", "marlin2"},
|
||||
{"use_relative_e_distances", relative_e},
|
||||
{"retraction_speed", retraction_speed},
|
||||
{"retraction_length", "0.8"},
|
||||
{"retract_before_wipe", "0%"},
|
||||
{"retract_after_wipe", "0%"},
|
||||
{"role_based_wipe_speed", "0"},
|
||||
{"wipe_speed", "100"},
|
||||
{"wipe_distance", "2"},
|
||||
});
|
||||
const std::string output = slice({make_cube(10., 10., 1.)}, config);
|
||||
const auto &start_tag = GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Wipe_Start);
|
||||
const auto &end_tag = GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Wipe_End);
|
||||
double before_wipe = 0.;
|
||||
double during_wipe = 0.;
|
||||
bool in_wipe = false;
|
||||
bool complete = false;
|
||||
GCodeReader parser;
|
||||
parser.apply_config(config);
|
||||
parser.parse_buffer(output, [&](GCodeReader &self, const GCodeReader::GCodeLine &line) {
|
||||
if (complete)
|
||||
return;
|
||||
if (line.comment().find(start_tag) != std::string_view::npos) {
|
||||
in_wipe = true;
|
||||
} else if (in_wipe && line.comment().find(end_tag) != std::string_view::npos) {
|
||||
complete = true;
|
||||
} else if (line.retracting(self)) {
|
||||
(in_wipe ? during_wipe : before_wipe) -= line.dist_E(self);
|
||||
} else if (line.extruding(self)) {
|
||||
before_wipe = 0.;
|
||||
}
|
||||
});
|
||||
|
||||
REQUIRE(complete);
|
||||
// At 100 mm/s, the 2 mm wipe lasts 0.02 seconds. The remaining part of
|
||||
// the configured 0.8 mm retraction must be emitted before that wipe.
|
||||
const double expected_during = std::stod(retraction_speed) * 2. / 100.;
|
||||
CHECK_THAT(during_wipe, Catch::Matchers::WithinAbs(expected_during, 0.00005));
|
||||
CHECK_THAT(before_wipe, Catch::Matchers::WithinAbs(0.8 - expected_during, 0.00005));
|
||||
}
|
||||
|
||||
TEST_CASE("Inward wipe respects the minimum travel for retraction and Z hop", "[Wipe][Regression]")
|
||||
{
|
||||
const char *wall_generator = GENERATE("classic", "arachne");
|
||||
const char *relative_e = GENERATE("0", "1");
|
||||
const char *reduce_crossing_wall = GENERATE("0", "1");
|
||||
const char *minimum_travel = GENERATE("5", "0");
|
||||
CAPTURE(wall_generator, relative_e, reduce_crossing_wall, minimum_travel);
|
||||
DynamicPrintConfig config = wipe_config(
|
||||
wall_generator, true, "50%", "10%", false, "3", "inner-outer-inner wall");
|
||||
config.set_deserialize_strict({
|
||||
{"gcode_flavor", "marlin2"},
|
||||
{"use_relative_e_distances", relative_e},
|
||||
{"reduce_crossing_wall", reduce_crossing_wall},
|
||||
{"retraction_minimum_travel", minimum_travel},
|
||||
{"retract_when_changing_layer", "0"},
|
||||
{"use_firmware_retraction", "0"},
|
||||
{"retract_before_wipe", "0%"},
|
||||
{"retract_after_wipe", "0%"},
|
||||
{"retraction_speed", "25.5"},
|
||||
{"role_based_wipe_speed", "0"},
|
||||
{"wipe_speed", "100"},
|
||||
{"z_hop", "0.4"},
|
||||
{"retract_lift_above", "0"},
|
||||
{"retract_lift_below", "0"},
|
||||
});
|
||||
config.set_key_value("z_hop_types", new ConfigOptionEnumsGeneric{zhtNormal});
|
||||
config.set_key_value("retract_lift_enforce", new ConfigOptionEnumsGeneric{rletAllSurfaces});
|
||||
const std::string output = slice({make_cube(10., 10., 1.)}, config);
|
||||
const auto &role_tag = GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Role);
|
||||
const auto &start_tag = GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Wipe_Start);
|
||||
const auto &end_tag = GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Wipe_End);
|
||||
ExtrusionRole role = erNone;
|
||||
bool after_outer_wall = false;
|
||||
bool in_wipe = false;
|
||||
size_t transitions = 0;
|
||||
size_t same_layer_transitions = 0;
|
||||
size_t inward_wipes = 0;
|
||||
double retraction = 0.;
|
||||
double lift = 0.;
|
||||
double outer_z = 0.;
|
||||
GCodeReader parser;
|
||||
parser.apply_config(config);
|
||||
parser.parse_buffer(output, [&](GCodeReader &self, const GCodeReader::GCodeLine &line) {
|
||||
if (line.comment().find(role_tag) == 0)
|
||||
role = ExtrusionEntity::string_to_role(line.comment().substr(role_tag.size()));
|
||||
if (line.comment().find(start_tag) == 0) {
|
||||
in_wipe = true;
|
||||
if (after_outer_wall)
|
||||
++inward_wipes;
|
||||
} else if (line.comment().find(end_tag) == 0) {
|
||||
in_wipe = false;
|
||||
}
|
||||
if (line.extruding(self) && line.dist_XY(self) > EPSILON) {
|
||||
if (role == erExternalPerimeter) {
|
||||
after_outer_wall = true;
|
||||
retraction = lift = 0.;
|
||||
outer_z = line.new_Z(self);
|
||||
} else if (after_outer_wall) {
|
||||
REQUIRE(role == erPerimeter);
|
||||
++transitions;
|
||||
const double layer_rise = std::max(0., double(self.z()) - outer_z);
|
||||
if (layer_rise < EPSILON)
|
||||
++same_layer_transitions;
|
||||
// A 5 mm threshold suppresses retraction across a few wall widths.
|
||||
// A zero threshold still permits the ordinary retract and lift.
|
||||
const bool retract = std::stod(minimum_travel) == 0.;
|
||||
CHECK_THAT(retraction, Catch::Matchers::WithinAbs(retract ? 0.8 : 0., 0.00005));
|
||||
// Exclude an ordinary layer change from the accumulated upward motion.
|
||||
CHECK_THAT(lift - layer_rise, Catch::Matchers::WithinAbs(retract ? 0.4 : 0., 0.001));
|
||||
after_outer_wall = false;
|
||||
}
|
||||
} else if (after_outer_wall) {
|
||||
if (line.retracting(self))
|
||||
retraction -= line.dist_E(self);
|
||||
lift += std::max(0., double(line.dist_Z(self)));
|
||||
if (in_wipe)
|
||||
CHECK_THAT(line.dist_E(self), Catch::Matchers::WithinAbs(0., 0.00005));
|
||||
}
|
||||
});
|
||||
// The 1 mm cube has five 0.2 mm layers: every outer wall must still wipe.
|
||||
REQUIRE(transitions == 5);
|
||||
REQUIRE(same_layer_transitions >= 4);
|
||||
REQUIRE(inward_wipes == transitions);
|
||||
}
|
||||
|
||||
TEST_CASE("Changing inward wipe settings preserves the sliced geometry", "[Wipe][Regression]")
|
||||
{
|
||||
const char *key = GENERATE("wipe_inward", "wipe_inward_distance");
|
||||
DynamicPrintConfig config = wipe_config("classic", false);
|
||||
Print print;
|
||||
Model model;
|
||||
init_print({make_cube(10., 10., 1.)}, print, model, config);
|
||||
gcode(print);
|
||||
const PrintObject &object = *print.objects().front();
|
||||
REQUIRE(object.is_step_done(posPerimeters));
|
||||
REQUIRE(object.is_step_done(posInfill));
|
||||
REQUIRE(print.is_step_done(psWipeTower));
|
||||
REQUIRE(print.is_step_done(psGCodeExport));
|
||||
|
||||
DynamicPrintConfig changed = config;
|
||||
changed.set_deserialize_strict({{key, std::string(key) == "wipe_inward" ? "1" : "75%"}});
|
||||
print.apply(model, changed);
|
||||
|
||||
CHECK(print.objects().front()->is_step_done(posPerimeters));
|
||||
CHECK(print.objects().front()->is_step_done(posInfill));
|
||||
CHECK(print.is_step_done(psWipeTower));
|
||||
CHECK_FALSE(print.is_step_done(psGCodeExport));
|
||||
}
|
||||
|
||||
TEST_CASE("Retraction and pressure advance calibration suppress inward wipe overrides", "[Wipe][Regression]")
|
||||
{
|
||||
const auto mode = GENERATE(CalibMode::Calib_None, CalibMode::Calib_PA_Tower,
|
||||
CalibMode::Calib_Auto_PA_Line, CalibMode::Calib_Retraction_tower,
|
||||
CalibMode::Calib_Flow_Rate);
|
||||
const char *wall_generator = GENERATE("classic", "arachne");
|
||||
const bool per_object = GENERATE(false, true);
|
||||
INFO("calibration mode: " << int(mode) << ", wall generator: " << wall_generator
|
||||
<< ", per-object override: " << per_object);
|
||||
|
||||
const auto trajectories = [&](bool inward) {
|
||||
DynamicPrintConfig config = wipe_config(wall_generator, inward && !per_object);
|
||||
const std::vector<std::vector<ConfigBase::SetDeserializeItem>> overrides{
|
||||
{{"wipe_inward", inward ? "1" : "0"}}
|
||||
};
|
||||
Print print;
|
||||
Model model;
|
||||
init_print({make_cube(10., 10., 1.)}, print, model, config, per_object ? &overrides : nullptr);
|
||||
Calib_Params params;
|
||||
params.mode = mode;
|
||||
params.start = 0.2;
|
||||
params.end = 0.4;
|
||||
params.step = 0.1;
|
||||
print.set_calib_params(params);
|
||||
return wipe_destinations(gcode(print));
|
||||
};
|
||||
|
||||
const auto regular = trajectories(false);
|
||||
const auto inward = trajectories(true);
|
||||
REQUIRE_FALSE(regular.empty());
|
||||
REQUIRE_FALSE(inward.empty());
|
||||
// Other calibration modes and ordinary prints must still honor the option.
|
||||
const bool should_differ = mode == CalibMode::Calib_None || mode == CalibMode::Calib_Flow_Rate;
|
||||
CHECK(trajectories_differ(regular, inward) == should_differ);
|
||||
}
|
||||
|
||||
TEST_CASE("Inactive inward wipe settings preserve the exported trajectory", "[Wipe][Regression]")
|
||||
{
|
||||
const char *wall_generator = GENERATE("classic", "arachne");
|
||||
const bool disable_wiping = GENERATE(false, true);
|
||||
DynamicPrintConfig regular = wipe_config(wall_generator, false);
|
||||
DynamicPrintConfig inward = wipe_config(wall_generator, true, disable_wiping ? "50%" : "0");
|
||||
if (disable_wiping) {
|
||||
regular.set_deserialize_strict({{"wipe", "0"}});
|
||||
inward.set_deserialize_strict({{"wipe", "0"}});
|
||||
}
|
||||
const auto regular_paths = wipe_destinations(slice({make_cube(10., 10., 1.)}, regular));
|
||||
const auto inward_paths = wipe_destinations(slice({make_cube(10., 10., 1.)}, inward));
|
||||
if (!disable_wiping)
|
||||
REQUIRE_FALSE(regular_paths.empty());
|
||||
CHECK_FALSE(trajectories_differ(regular_paths, inward_paths));
|
||||
}
|
||||
|
||||
TEST_CASE("Inward wipe changes the exported trajectory when outer wall width is Auto", "[Wipe][Regression]")
|
||||
{
|
||||
const char *wall_generator = GENERATE("classic", "arachne");
|
||||
INFO("wall generator: " << wall_generator);
|
||||
|
||||
const std::vector<Vec2d> regular = wipe_destinations(
|
||||
slice({make_cube(10., 10., 1.)}, wipe_config(wall_generator, false)));
|
||||
const std::vector<Vec2d> inward = wipe_destinations(
|
||||
slice({make_cube(10., 10., 1.)}, wipe_config(wall_generator, true)));
|
||||
|
||||
REQUIRE_FALSE(regular.empty());
|
||||
REQUIRE_FALSE(inward.empty());
|
||||
REQUIRE(trajectories_differ(regular, inward));
|
||||
}
|
||||
|
||||
TEST_CASE("Inward wipe recognizes an external wall starting on an overhang", "[Wipe][Regression]")
|
||||
{
|
||||
const char *wall_generator = GENERATE("classic", "arachne");
|
||||
const bool inward = GENERATE(false, true);
|
||||
CAPTURE(wall_generator, inward);
|
||||
const auto config = wipe_config(wall_generator, inward, "50%", "0%", false,
|
||||
"3", "inner-outer-inner wall", false, "0%", "back");
|
||||
Print print;
|
||||
Model model;
|
||||
init_print({make_cube(10., 10., 1.)}, print, model, config);
|
||||
print.process();
|
||||
size_t mixed_loops = 0;
|
||||
const auto mark_overhangs = [&](auto &&self, ExtrusionEntity *entity) -> void {
|
||||
if (auto *collection = dynamic_cast<ExtrusionEntityCollection *>(entity)) {
|
||||
for (ExtrusionEntity *child : collection->entities)
|
||||
self(self, child);
|
||||
} else if (auto *loop = dynamic_cast<ExtrusionLoop *>(entity); loop && is_external_perimeter(loop->role())) {
|
||||
// Keep the printed geometry intact and give the back seam overhang
|
||||
// roles. The front edge remains an ordinary external-wall segment.
|
||||
ExtrusionPaths paths;
|
||||
bool has_overhang = false;
|
||||
bool has_external = false;
|
||||
for (const ExtrusionPath &source : loop->paths) {
|
||||
for (size_t i = 1; i < source.polyline.points.size(); ++i) {
|
||||
ExtrusionPath path = source;
|
||||
path.polyline.points = {source.polyline.points[i - 1], source.polyline.points[i]};
|
||||
const bool overhang = path.polyline.points.front().y() > 0 || path.polyline.points.back().y() > 0;
|
||||
path.set_extrusion_role(overhang ? erOverhangPerimeter : erExternalPerimeter);
|
||||
has_overhang |= overhang;
|
||||
has_external |= !overhang;
|
||||
paths.push_back(std::move(path));
|
||||
}
|
||||
}
|
||||
REQUIRE(has_overhang);
|
||||
REQUIRE(has_external);
|
||||
loop->paths = std::move(paths);
|
||||
++mixed_loops;
|
||||
}
|
||||
};
|
||||
for (const PrintObject *object : print.objects())
|
||||
for (Layer *layer : object->layers())
|
||||
for (LayerRegion *region : layer->regions())
|
||||
mark_overhangs(mark_overhangs, ®ion->perimeters);
|
||||
REQUIRE(mixed_loops > 0);
|
||||
|
||||
bool has_inward_wipe = false;
|
||||
for (const WipeTrajectory &trajectory : wipe_trajectories(gcode(print))) {
|
||||
if (trajectory.destinations.empty())
|
||||
continue;
|
||||
const Vec2d move = trajectory.destinations.front() - trajectory.start;
|
||||
if (trajectory.start.x() > 4. && trajectory.start.y() > 4. && move.x() < -0.05 && move.y() < -0.05)
|
||||
has_inward_wipe = true;
|
||||
}
|
||||
CHECK(has_inward_wipe == inward);
|
||||
}
|
||||
|
||||
TEST_CASE("Inward wipe keeps its offset when seam gap is zero", "[Wipe][Regression]")
|
||||
{
|
||||
const char *wall_generator = GENERATE("classic", "arachne");
|
||||
INFO("wall generator: " << wall_generator);
|
||||
|
||||
const std::vector<Vec2d> regular = wipe_destinations(
|
||||
slice({make_cube(10., 10., 1.)}, wipe_config(wall_generator, false, "50%", "0%")));
|
||||
const std::vector<Vec2d> inward = wipe_destinations(
|
||||
slice({make_cube(10., 10., 1.)}, wipe_config(wall_generator, true, "50%", "0%")));
|
||||
|
||||
REQUIRE_FALSE(regular.empty());
|
||||
REQUIRE_FALSE(inward.empty());
|
||||
REQUIRE(trajectories_differ(regular, inward));
|
||||
}
|
||||
|
||||
TEST_CASE("Inward wipe is retained across layers with a back seam", "[Wipe][Regression]")
|
||||
{
|
||||
const char *wall_generator = GENERATE("classic", "arachne");
|
||||
INFO("wall generator: " << wall_generator);
|
||||
|
||||
const DynamicPrintConfig inward_config = wipe_config(
|
||||
wall_generator, true, "50%", "0%", false, "3", "inner-outer-inner wall", false, "0%", "back");
|
||||
const std::vector<WipeTrajectory> inward = wipe_trajectories(slice({make_cube(27., 27., 1.)}, inward_config));
|
||||
|
||||
REQUIRE_FALSE(inward.empty());
|
||||
std::map<double, bool> inward_wipe_by_layer;
|
||||
for (const WipeTrajectory &trajectory : inward) {
|
||||
bool &has_inward_wipe = inward_wipe_by_layer[trajectory.z];
|
||||
if (trajectory.destinations.empty())
|
||||
continue;
|
||||
const Vec2d first_move = trajectory.destinations.front() - trajectory.start;
|
||||
// Orca: a back seam lands on the cube's positive-X/positive-Y corner.
|
||||
// Its inward wipe must move diagonally away from both external faces.
|
||||
has_inward_wipe = has_inward_wipe ||
|
||||
(trajectory.start.x() > 13. && trajectory.start.y() > 13. &&
|
||||
first_move.x() < -0.05 && first_move.y() < -0.05);
|
||||
}
|
||||
REQUIRE(inward_wipe_by_layer.size() == 5);
|
||||
for (const auto &[z, has_inward_wipe] : inward_wipe_by_layer) {
|
||||
INFO("layer Z: " << z);
|
||||
REQUIRE(has_inward_wipe);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("Literal inward wipe distance is clamped to the outer wall width", "[Wipe][Regression]")
|
||||
{
|
||||
const char *wall_generator = GENERATE("classic", "arachne");
|
||||
INFO("wall generator: " << wall_generator);
|
||||
|
||||
const std::vector<Vec2d> regular = wipe_destinations(
|
||||
slice({make_cube(10., 10., 1.)}, wipe_config(wall_generator, false)));
|
||||
const std::vector<Vec2d> full_width = wipe_destinations(
|
||||
slice({make_cube(10., 10., 1.)}, wipe_config(wall_generator, true, "100%")));
|
||||
const std::vector<Vec2d> oversized = wipe_destinations(
|
||||
slice({make_cube(10., 10., 1.)}, wipe_config(wall_generator, true, "2")));
|
||||
|
||||
REQUIRE_FALSE(full_width.empty());
|
||||
REQUIRE(trajectories_differ(regular, full_width));
|
||||
REQUIRE(oversized.size() == full_width.size());
|
||||
for (size_t i = 0; i < full_width.size(); ++i)
|
||||
REQUIRE_THAT((oversized[i] - full_width[i]).norm(), Catch::Matchers::WithinAbs(0., 0.01));
|
||||
}
|
||||
|
||||
TEST_CASE("Inward wipe is not applied without an adjacent wall", "[Wipe][Regression]")
|
||||
{
|
||||
const char *wall_generator = GENERATE("classic", "arachne");
|
||||
INFO("wall generator: " << wall_generator);
|
||||
|
||||
const std::vector<Vec2d> regular = wipe_destinations(
|
||||
slice({make_cube(10., 10., 1.)}, wipe_config(wall_generator, false, "50%", "10%", false, "1")));
|
||||
const std::vector<Vec2d> inward = wipe_destinations(
|
||||
slice({make_cube(10., 10., 1.)}, wipe_config(wall_generator, true, "50%", "10%", false, "1")));
|
||||
|
||||
REQUIRE_FALSE(regular.empty());
|
||||
REQUIRE_FALSE(trajectories_differ(regular, inward));
|
||||
}
|
||||
|
||||
TEST_CASE("Inward wipe uses an alternate extra wall when the configured wall count is one", "[Wipe][Regression]")
|
||||
{
|
||||
const char *wall_generator = GENERATE("classic", "arachne");
|
||||
INFO("wall generator: " << wall_generator);
|
||||
|
||||
const DynamicPrintConfig regular_config = wipe_config(
|
||||
wall_generator, false, "50%", "10%", false, "1", "inner wall/outer wall", true, "15%");
|
||||
const DynamicPrintConfig inward_config = wipe_config(
|
||||
wall_generator, true, "50%", "10%", false, "1", "inner wall/outer wall", true, "15%");
|
||||
const std::vector<Vec2d> regular = wipe_destinations(
|
||||
slice({make_cube(10., 10., 1.)}, regular_config));
|
||||
const std::vector<Vec2d> inward = wipe_destinations(
|
||||
slice({make_cube(10., 10., 1.)}, inward_config));
|
||||
|
||||
REQUIRE_FALSE(regular.empty());
|
||||
REQUIRE_FALSE(inward.empty());
|
||||
REQUIRE(trajectories_differ(regular, inward));
|
||||
}
|
||||
|
||||
TEST_CASE("Inward wipe is not applied before the adjacent wall is printed", "[Wipe][Regression]")
|
||||
{
|
||||
const char *wall_generator = GENERATE("classic", "arachne");
|
||||
INFO("wall generator: " << wall_generator);
|
||||
|
||||
const std::vector<Vec2d> regular = wipe_destinations(
|
||||
slice({make_cube(10., 10., 1.)}, wipe_config(
|
||||
wall_generator, false, "50%", "10%", false, "2", "outer wall/inner wall")));
|
||||
const std::vector<Vec2d> inward = wipe_destinations(
|
||||
slice({make_cube(10., 10., 1.)}, wipe_config(
|
||||
wall_generator, true, "50%", "10%", false, "2", "outer wall/inner wall")));
|
||||
|
||||
REQUIRE_FALSE(regular.empty());
|
||||
REQUIRE_FALSE(trajectories_differ(regular, inward));
|
||||
}
|
||||
|
||||
TEST_CASE("Wipe on loops preserves the corner move with inward wipe disabled", "[Wipe][Regression]")
|
||||
{
|
||||
const char *wall_generator = GENERATE("classic", "arachne");
|
||||
const char *nozzle_diameter = GENERATE("0.4", "0.8");
|
||||
const char *comments = GENERATE("0", "1");
|
||||
CAPTURE(comments);
|
||||
INFO("wall generator: " << wall_generator << ", nozzle diameter: " << nozzle_diameter);
|
||||
// A closed square gives a 90-degree material-side corner at the seam.
|
||||
DynamicPrintConfig config = wipe_config(wall_generator, false, "50%", "0", true);
|
||||
config.set_deserialize_strict({{"nozzle_diameter", nozzle_diameter}, {"seam_position", "nearest"},
|
||||
{"gcode_comments", comments}});
|
||||
const std::string output = slice({make_cube(10., 10., 1.)}, config);
|
||||
const auto &role_tag = GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Role);
|
||||
const auto &wipe_tag = GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Wipe_Start);
|
||||
ExtrusionRole role = erNone;
|
||||
std::vector<Vec2d> loop;
|
||||
bool after_extrusion = false;
|
||||
size_t moves = 0;
|
||||
GCodeReader parser;
|
||||
parser.apply_config(config);
|
||||
parser.parse_buffer(output, [&](GCodeReader &self, const GCodeReader::GCodeLine &line) {
|
||||
if (line.comment().find(role_tag) == 0) {
|
||||
role = ExtrusionEntity::string_to_role(line.comment().substr(role_tag.size()));
|
||||
loop.clear();
|
||||
after_extrusion = false;
|
||||
}
|
||||
if (line.comment().find(wipe_tag) == 0)
|
||||
after_extrusion = false;
|
||||
if (role != erExternalPerimeter)
|
||||
return;
|
||||
if (line.extruding(self) && line.dist_XY(self) > EPSILON) {
|
||||
if (loop.empty())
|
||||
loop.emplace_back(self.x(), self.y());
|
||||
loop.emplace_back(line.new_X(self), line.new_Y(self));
|
||||
after_extrusion = true;
|
||||
return;
|
||||
}
|
||||
// The loop move is the first non-extruding XY move after the external
|
||||
// wall and before the reserved wipe marker, regardless of comment text.
|
||||
if (!after_extrusion || line.dist_XY(self) <= EPSILON)
|
||||
return;
|
||||
after_extrusion = false;
|
||||
|
||||
++moves;
|
||||
INFO("layer Z: " << self.z());
|
||||
REQUIRE(loop.size() >= 4);
|
||||
const Vec2d seam = loop.front();
|
||||
REQUIRE_THAT((loop.back() - seam).norm(), Catch::Matchers::WithinAbs(0., 0.003));
|
||||
const Vec2d outgoing = (loop[1] - seam).normalized();
|
||||
const Vec2d into_corner = (loop[loop.size() - 2] - seam).normalized();
|
||||
REQUIRE_THAT(outgoing.dot(into_corner), Catch::Matchers::WithinAbs(0., 0.01));
|
||||
const Vec2d move = Vec2d(line.new_X(self), line.new_Y(self)) - seam;
|
||||
// The legacy corner move is 20% of the nozzle diameter, turned 30 degrees
|
||||
// from the outgoing edge into the square. Check both components independently.
|
||||
const double distance = 0.2 * std::stod(nozzle_diameter);
|
||||
CHECK_THAT(move.dot(outgoing), Catch::Matchers::WithinAbs(distance * std::sqrt(3.) / 2., 0.003));
|
||||
CHECK_THAT(move.dot(into_corner), Catch::Matchers::WithinAbs(distance / 2., 0.003));
|
||||
});
|
||||
REQUIRE(moves == 5);
|
||||
}
|
||||
|
||||
TEST_CASE("Inward wipe remains valid after wipe on loops moves the nozzle", "[Wipe][Regression]")
|
||||
{
|
||||
const char *wall_generator = GENERATE("classic", "arachne");
|
||||
const char *comments = GENERATE("0", "1");
|
||||
CAPTURE(comments);
|
||||
INFO("wall generator: " << wall_generator);
|
||||
|
||||
DynamicPrintConfig config = wipe_config(wall_generator, false, "50%", "10%", true);
|
||||
config.set_deserialize_strict({{"gcode_comments", comments}});
|
||||
const std::string loop_move = slice({make_cube(10., 10., 1.)}, config);
|
||||
config.set_deserialize_strict({{"wipe_inward", "1"}});
|
||||
const std::string combined = slice({make_cube(10., 10., 1.)}, config);
|
||||
config.set_deserialize_strict({{"wipe_on_loops", "0"}});
|
||||
const std::string inward_only = slice({make_cube(10., 10., 1.)}, config);
|
||||
|
||||
for (const std::string *output : {&loop_move, &combined}) {
|
||||
INFO("wipe_inward: " << (output == &combined));
|
||||
std::map<double, std::vector<Vec2d>> loop_moves_by_layer;
|
||||
const auto &role_tag = GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Role);
|
||||
const auto &wipe_tag = GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Wipe_Start);
|
||||
ExtrusionRole role = erNone;
|
||||
bool after_extrusion = false;
|
||||
GCodeReader parser;
|
||||
parser.apply_config(config);
|
||||
parser.parse_buffer(*output, [&](GCodeReader &self, const GCodeReader::GCodeLine &line) {
|
||||
if (line.comment().find(role_tag) == 0) {
|
||||
role = ExtrusionEntity::string_to_role(line.comment().substr(role_tag.size()));
|
||||
after_extrusion = false;
|
||||
}
|
||||
if (line.comment().find(wipe_tag) == 0)
|
||||
after_extrusion = false;
|
||||
if (role != erExternalPerimeter || line.dist_XY(self) <= EPSILON)
|
||||
return;
|
||||
if (line.extruding(self)) {
|
||||
after_extrusion = true;
|
||||
} else if (after_extrusion) {
|
||||
loop_moves_by_layer[line.new_Z(self)].emplace_back(line.new_X(self), line.new_Y(self));
|
||||
after_extrusion = false;
|
||||
}
|
||||
});
|
||||
|
||||
// The 1 mm cube at 0.2 mm layer height has one external loop on each of five layers.
|
||||
const auto trajectories = wipe_trajectories(*output);
|
||||
REQUIRE(loop_moves_by_layer.size() == 5);
|
||||
for (size_t layer = 1; layer <= 5; ++layer) {
|
||||
const double z = layer * 0.2;
|
||||
const auto moves = std::find_if(loop_moves_by_layer.begin(), loop_moves_by_layer.end(),
|
||||
[z](const auto &entry) { return std::abs(entry.first - z) < 0.001; });
|
||||
REQUIRE(moves != loop_moves_by_layer.end());
|
||||
REQUIRE(moves->second.size() == 1);
|
||||
const auto wipe = std::find_if(trajectories.begin(), trajectories.end(), [&](const WipeTrajectory &trajectory) {
|
||||
return std::abs(trajectory.z - z) < 0.001 &&
|
||||
(trajectory.start - moves->second.front()).norm() < 0.001;
|
||||
});
|
||||
REQUIRE(wipe != trajectories.end());
|
||||
// The configured 2 mm wipe must be measured from the inward move's
|
||||
// endpoint, including when wipe_inward is off (set_last_pos regression).
|
||||
CHECK_THAT(trajectory_length(*wipe), Catch::Matchers::WithinAbs(2., 0.003));
|
||||
}
|
||||
}
|
||||
|
||||
const std::vector<WipeTrajectory> combined_trajectories = wipe_trajectories(combined);
|
||||
const std::vector<WipeTrajectory> inward_trajectories = wipe_trajectories(inward_only);
|
||||
REQUIRE_FALSE(combined_trajectories.empty());
|
||||
REQUIRE(combined_trajectories.size() == inward_trajectories.size());
|
||||
REQUIRE(trajectories_differ(wipe_destinations(combined), wipe_destinations(loop_move)));
|
||||
|
||||
bool start_changed = false;
|
||||
for (size_t i = 0; i < combined_trajectories.size(); ++i) {
|
||||
start_changed = start_changed ||
|
||||
(combined_trajectories[i].start - inward_trajectories[i].start).norm() > 0.01;
|
||||
REQUIRE_THAT(trajectory_length(combined_trajectories[i]),
|
||||
Catch::Matchers::WithinAbs(trajectory_length(inward_trajectories[i]), 0.01));
|
||||
}
|
||||
REQUIRE(start_changed);
|
||||
}
|
||||
@@ -43,6 +43,7 @@ add_executable(${_TEST_NAME}_tests
|
||||
test_voronoi.cpp
|
||||
test_wipe_tower_estimate.cpp
|
||||
test_wipe_tower.cpp
|
||||
test_wipe_path.cpp
|
||||
test_optimizers.cpp
|
||||
test_ordering_strategies.cpp
|
||||
# test_png_io.cpp
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
#include <boost/nowide/fstream.hpp>
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <sstream>
|
||||
|
||||
using namespace Slic3r;
|
||||
|
||||
SCENARIO("Generic config validation performs as expected.", "[Config]") {
|
||||
@@ -488,6 +490,59 @@ TEST_CASE("save_to_json round-trips plugin capability references as strings", "[
|
||||
CHECK(reloaded.option<ConfigOptionStrings>("slicing_pipeline_plugin")->values == refs);
|
||||
}
|
||||
|
||||
TEST_CASE("save_to_json writes the same document to a stream as to a file", "[Config]") {
|
||||
DynamicPrintConfig config;
|
||||
config.set_key_value("layer_height", new ConfigOptionFloat(0.2));
|
||||
config.set_key_value("wall_loops", new ConfigOptionInt(3));
|
||||
config.set_key_value("filament_type", new ConfigOptionStrings({ "PLA", "PETG" }));
|
||||
config.set_key_value("machine_start_gcode", new ConfigOptionString("G28\nG1 Z5"));
|
||||
|
||||
ScopedTemporaryFile tmp(".json");
|
||||
config.save_to_json(tmp.string(), "test_preset", "User", "1.0.0.0");
|
||||
std::string file_contents;
|
||||
{
|
||||
boost::nowide::ifstream ifs(tmp.string());
|
||||
file_contents.assign(std::istreambuf_iterator<char>(ifs), std::istreambuf_iterator<char>());
|
||||
}
|
||||
// The file format: one tab per nesting level and a trailing newline.
|
||||
REQUIRE_FALSE(file_contents.empty());
|
||||
CHECK(file_contents.rfind("{\n\t\"", 0) == 0);
|
||||
CHECK(file_contents.back() == '\n');
|
||||
|
||||
std::ostringstream strict, replaced;
|
||||
config.save_to_json(strict, "test_preset", "User", "1.0.0.0");
|
||||
config.save_to_json(replaced, "test_preset", "User", "1.0.0.0", true);
|
||||
CHECK(strict.str() == file_contents);
|
||||
CHECK(replaced.str() == file_contents);
|
||||
CHECK(nlohmann::json::parse(strict.str())["machine_start_gcode"] == "G28\nG1 Z5");
|
||||
}
|
||||
|
||||
TEST_CASE("save_to_json replaces invalid UTF-8 in a stream only when asked", "[Config]") {
|
||||
DynamicPrintConfig config;
|
||||
config.set_key_value("machine_start_gcode", new ConfigOptionString("G28 ; \xff"));
|
||||
|
||||
std::ostringstream strict, replaced;
|
||||
CHECK_THROWS_AS(config.save_to_json(strict, "test_preset", "User", "1.0.0.0"), nlohmann::json::type_error);
|
||||
REQUIRE_NOTHROW(config.save_to_json(replaced, "test_preset", "User", "1.0.0.0", true));
|
||||
CHECK(nlohmann::json::parse(replaced.str())["machine_start_gcode"] == "G28 ; \xEF\xBF\xBD");
|
||||
}
|
||||
|
||||
TEST_CASE("save_to_json leaves an existing file untouched when the config cannot be serialized", "[Config]") {
|
||||
DynamicPrintConfig config;
|
||||
config.set_key_value("machine_start_gcode", new ConfigOptionString("G28 ; \xff"));
|
||||
|
||||
ScopedTemporaryFile tmp(".json");
|
||||
{
|
||||
boost::nowide::ofstream ofs(tmp.string());
|
||||
ofs << "previous";
|
||||
}
|
||||
CHECK_THROWS_AS(config.save_to_json(tmp.string(), "test_preset", "User", "1.0.0.0"), nlohmann::json::type_error);
|
||||
|
||||
boost::nowide::ifstream ifs(tmp.string());
|
||||
const std::string contents((std::istreambuf_iterator<char>(ifs)), std::istreambuf_iterator<char>());
|
||||
CHECK(contents == "previous");
|
||||
}
|
||||
|
||||
TEST_CASE("plugin capability references survive string-map serialization", "[Config][plugins]") {
|
||||
const std::vector<std::string> refs = {
|
||||
"master_plugin;;header-stamp",
|
||||
|
||||
@@ -987,6 +987,193 @@ TEST_CASE("Resolution terminates when no vendor manifest exists", "[Preset][Bund
|
||||
CHECK(error == "Preset was not found in the loaded bundle");
|
||||
}
|
||||
|
||||
TEST_CASE("Manifest-backed resolution reuses the vendor tree it already loaded", "[Preset][Bundle][Regression]")
|
||||
{
|
||||
ScopedTemporaryDir dir;
|
||||
const fs::path process_dir = dir.path() / "Acme" / "process";
|
||||
fs::create_directories(process_dir);
|
||||
std::ofstream((dir.path() / "Acme.json").string())
|
||||
<< R"({"version":"1.0.0","name":"Acme","process_list":[)"
|
||||
<< R"({"name":"fdm_process_common","sub_path":"process/base.json"},)"
|
||||
<< R"({"name":"Acme First","sub_path":"process/first.json"},)"
|
||||
<< R"({"name":"Acme Second","sub_path":"process/second.json"}]})";
|
||||
auto write_base = [&](double travel_speed) {
|
||||
std::ofstream((process_dir / "base.json").string())
|
||||
<< R"({"type":"process","name":"fdm_process_common","from":"system",)"
|
||||
<< R"("instantiation":"false","travel_speed":[")" << travel_speed << R"("]})";
|
||||
};
|
||||
auto write_child = [&](const std::string &file, const std::string &name) {
|
||||
std::ofstream((process_dir / file).string())
|
||||
<< R"({"type":"process","name":")" << name << R"(","from":"system",)"
|
||||
<< R"("instantiation":"true","inherits":"fdm_process_common"})";
|
||||
};
|
||||
write_base(111.0);
|
||||
write_child("first.json", "Acme First");
|
||||
write_child("second.json", "Acme Second");
|
||||
|
||||
auto travel_speed = [&](PresetBundle &bundle, const std::string &file) {
|
||||
DynamicPrintConfig raw;
|
||||
raw.option<ConfigOptionString>(BBL_JSON_KEY_INHERITS, true)->value = "fdm_process_common";
|
||||
std::string error;
|
||||
REQUIRE(bundle.resolve_preset_config(raw, Preset::TYPE_PRINT, (process_dir / file).string(),
|
||||
ForwardCompatibilitySubstitutionRule::EnableSilent, error));
|
||||
return raw.option<ConfigOptionFloats>("travel_speed")->values.front();
|
||||
};
|
||||
|
||||
PresetBundle bundle;
|
||||
CHECK_THAT(travel_speed(bundle, "first.json"), Catch::Matchers::WithinAbs(111.0, 1e-6));
|
||||
|
||||
// Only a reload would see this change.
|
||||
write_base(222.0);
|
||||
CHECK_THAT(travel_speed(bundle, "second.json"), Catch::Matchers::WithinAbs(111.0, 1e-6));
|
||||
|
||||
PresetBundle fresh;
|
||||
CHECK_THAT(travel_speed(fresh, "second.json"), Catch::Matchers::WithinAbs(222.0, 1e-6));
|
||||
}
|
||||
|
||||
TEST_CASE("Manifest-backed resolution does not keep a vendor tree that failed to load", "[Preset][Bundle][Regression]")
|
||||
{
|
||||
ScopedTemporaryDir dir;
|
||||
const fs::path child_file = dir.path() / "Acme" / "process" / "child.json";
|
||||
auto write_manifest = [&](const std::string &leading_entry) {
|
||||
std::ofstream((dir.path() / "Acme.json").string())
|
||||
<< R"({"version":"1.0.0","name":"Acme","process_list":[)" << leading_entry
|
||||
<< R"({"name":"Acme Process","sub_path":"process/child.json"}]})";
|
||||
};
|
||||
write_manifest("123,");
|
||||
fs::create_directories(child_file.parent_path());
|
||||
std::ofstream(child_file.string())
|
||||
<< R"({"type":"process","name":"Acme Process","from":"system",)"
|
||||
<< R"("instantiation":"true","layer_height":"0.2"})";
|
||||
|
||||
PresetBundle bundle;
|
||||
auto resolve = [&](std::string &error) {
|
||||
DynamicPrintConfig raw;
|
||||
raw.option<ConfigOptionString>(BBL_JSON_KEY_INHERITS, true)->value = "fdm_process_common";
|
||||
return bundle.resolve_preset_config(raw, Preset::TYPE_PRINT, child_file.string(),
|
||||
ForwardCompatibilitySubstitutionRule::EnableSilent, error);
|
||||
};
|
||||
|
||||
std::string error;
|
||||
CHECK_FALSE(resolve(error));
|
||||
CHECK_FALSE(error.empty());
|
||||
|
||||
write_manifest("");
|
||||
error.clear();
|
||||
CHECK(resolve(error));
|
||||
CHECK(error.empty());
|
||||
}
|
||||
|
||||
TEST_CASE("Manifest-backed resolution reuses the library base for type-probed files", "[Preset][Bundle][Regression]")
|
||||
{
|
||||
ScopedTemporaryDir dir;
|
||||
const fs::path library_pet = dir.path() / PresetBundle::ORCA_FILAMENT_LIBRARY / "filament" / "pet.json";
|
||||
const fs::path filament_dir = dir.path() / "Acme" / "filament";
|
||||
|
||||
std::ofstream((dir.path() / (std::string(PresetBundle::ORCA_FILAMENT_LIBRARY) + ".json")).string())
|
||||
<< R"({"version":"1.0.0","name":"OrcaFilamentLibrary","filament_list":[)"
|
||||
<< R"({"name":"fdm_filament_pet","sub_path":"filament/pet.json","filament_id":"GFL99"}]})";
|
||||
fs::create_directories(library_pet.parent_path());
|
||||
auto write_library_pet = [&](double density) {
|
||||
std::ofstream(library_pet.string())
|
||||
<< R"({"type":"filament","name":"fdm_filament_pet","from":"system",)"
|
||||
<< R"("filament_id":"GFL99","instantiation":"false",)"
|
||||
<< R"("filament_type":["PETG"],"filament_density":[")" << density << R"("]})";
|
||||
};
|
||||
write_library_pet(1.27);
|
||||
|
||||
std::ofstream((dir.path() / "Acme.json").string())
|
||||
<< R"({"version":"1.0.0","name":"Acme","filament_list":[)"
|
||||
<< R"({"name":"Acme PETG","sub_path":"filament/petg.json","filament_id":"GFA00"},)"
|
||||
<< R"({"name":"Acme PETG Matte","sub_path":"filament/petg_matte.json","filament_id":"GFA01"}]})";
|
||||
fs::create_directories(filament_dir);
|
||||
auto write_child = [&](const std::string &file, const std::string &name, const std::string &filament_id) {
|
||||
std::ofstream((filament_dir / file).string())
|
||||
<< R"({"type":"filament","name":")" << name << R"(","from":"system",)"
|
||||
<< R"("filament_id":")" << filament_id << R"(","instantiation":"true","inherits":"fdm_filament_pet"})";
|
||||
};
|
||||
write_child("petg.json", "Acme PETG", "GFA00");
|
||||
write_child("petg_matte.json", "Acme PETG Matte", "GFA01");
|
||||
|
||||
auto density = [](const DynamicPrintConfig &config) {
|
||||
return config.option<ConfigOptionFloats>("filament_density")->values.front();
|
||||
};
|
||||
|
||||
PresetBundle bundle;
|
||||
DynamicPrintConfig first;
|
||||
first.option<ConfigOptionString>(BBL_JSON_KEY_INHERITS, true)->value = "fdm_filament_pet";
|
||||
std::string error;
|
||||
REQUIRE(bundle.resolve_preset_config(first, Preset::TYPE_FILAMENT, (filament_dir / "petg.json").string(),
|
||||
ForwardCompatibilitySubstitutionRule::EnableSilent, error));
|
||||
CHECK_THAT(density(first), Catch::Matchers::WithinAbs(1.27, 1e-6));
|
||||
|
||||
// Only a reload would see this change.
|
||||
write_library_pet(1.5);
|
||||
|
||||
DynamicPrintConfig second;
|
||||
Preset::Type type = Preset::TYPE_INVALID;
|
||||
REQUIRE(bundle.resolve_preset_config_type(second, type, (filament_dir / "petg_matte.json").string(),
|
||||
ForwardCompatibilitySubstitutionRule::EnableSilent, error));
|
||||
CHECK(type == Preset::TYPE_FILAMENT);
|
||||
CHECK_THAT(density(second), Catch::Matchers::WithinAbs(1.27, 1e-6));
|
||||
}
|
||||
|
||||
TEST_CASE("Manifest-backed resolution shares the library between vendors under one root", "[Preset][Bundle][Regression]")
|
||||
{
|
||||
ScopedTemporaryDir dir;
|
||||
const fs::path library_dir = dir.path() / PresetBundle::ORCA_FILAMENT_LIBRARY / "filament";
|
||||
|
||||
std::ofstream((dir.path() / (std::string(PresetBundle::ORCA_FILAMENT_LIBRARY) + ".json")).string())
|
||||
<< R"({"version":"1.0.0","name":"OrcaFilamentLibrary","filament_list":[)"
|
||||
<< R"({"name":"fdm_filament_pet","sub_path":"filament/pet.json","filament_id":"GFL99"},)"
|
||||
<< R"({"name":"Generic PETG","sub_path":"filament/generic_petg.json","filament_id":"GFL98"}]})";
|
||||
fs::create_directories(library_dir);
|
||||
auto write_library_pet = [&](double density) {
|
||||
std::ofstream((library_dir / "pet.json").string())
|
||||
<< R"({"type":"filament","name":"fdm_filament_pet","from":"system",)"
|
||||
<< R"("filament_id":"GFL99","instantiation":"false",)"
|
||||
<< R"("filament_type":["PETG"],"filament_density":[")" << density << R"("]})";
|
||||
};
|
||||
write_library_pet(1.27);
|
||||
std::ofstream((library_dir / "generic_petg.json").string())
|
||||
<< R"({"type":"filament","name":"Generic PETG","from":"system",)"
|
||||
<< R"("filament_id":"GFL98","instantiation":"true","inherits":"fdm_filament_pet"})";
|
||||
|
||||
auto write_vendor = [&](const std::string &vendor, const std::string &filament_id) {
|
||||
const fs::path filament_dir = dir.path() / vendor / "filament";
|
||||
fs::create_directories(filament_dir);
|
||||
std::ofstream((dir.path() / (vendor + ".json")).string())
|
||||
<< R"({"version":"1.0.0","name":")" << vendor << R"(","filament_list":[)"
|
||||
<< R"({"name":")" << vendor << R"( PETG","sub_path":"filament/petg.json","filament_id":")" << filament_id << R"("}]})";
|
||||
std::ofstream((filament_dir / "petg.json").string())
|
||||
<< R"({"type":"filament","name":")" << vendor << R"( PETG","from":"system",)"
|
||||
<< R"("filament_id":")" << filament_id << R"(","instantiation":"true","inherits":"fdm_filament_pet"})";
|
||||
return filament_dir / "petg.json";
|
||||
};
|
||||
const fs::path acme_petg = write_vendor("Acme", "GFA00");
|
||||
const fs::path beta_petg = write_vendor("Beta", "GFB00");
|
||||
|
||||
auto density = [&](PresetBundle &bundle, const fs::path &file) {
|
||||
DynamicPrintConfig raw;
|
||||
raw.option<ConfigOptionString>(BBL_JSON_KEY_INHERITS, true)->value = "fdm_filament_pet";
|
||||
std::string error;
|
||||
REQUIRE(bundle.resolve_preset_config(raw, Preset::TYPE_FILAMENT, file.string(),
|
||||
ForwardCompatibilitySubstitutionRule::EnableSilent, error));
|
||||
return raw.option<ConfigOptionFloats>("filament_density")->values.front();
|
||||
};
|
||||
|
||||
PresetBundle bundle;
|
||||
CHECK_THAT(density(bundle, acme_petg), Catch::Matchers::WithinAbs(1.27, 1e-6));
|
||||
|
||||
// Only a reload would see this change.
|
||||
write_library_pet(1.5);
|
||||
CHECK_THAT(density(bundle, beta_petg), Catch::Matchers::WithinAbs(1.27, 1e-6));
|
||||
CHECK_THAT(density(bundle, library_dir / "generic_petg.json"), Catch::Matchers::WithinAbs(1.27, 1e-6));
|
||||
|
||||
PresetBundle fresh;
|
||||
CHECK_THAT(density(fresh, beta_petg), Catch::Matchers::WithinAbs(1.5, 1e-6));
|
||||
}
|
||||
|
||||
// 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.
|
||||
|
||||
@@ -163,6 +163,50 @@ TEST_CASE("H2C multi-nozzle: filaments get distinct nozzles on the 6-nozzle extr
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("Grouping context spans the filament count with mis-sized config arrays", "[ToolOrdering][H2C]")
|
||||
{
|
||||
// FilamentGroup indexes the grouping context's filament_info by filament id, so a short
|
||||
// per-filament array must not shorten it: the reads run off the end.
|
||||
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
||||
// Single 6-nozzle extruder: opens the grouping engine without needing a BBL multi-extruder.
|
||||
config.option<ConfigOptionFloats>("nozzle_diameter", true)->values = {0.4};
|
||||
config.option<ConfigOptionIntsNullable>("extruder_max_nozzle_count", true)->values = {6};
|
||||
config.option<ConfigOptionStrings>("extruder_nozzle_stats", true)->values = {"Standard#6"};
|
||||
|
||||
// Four filaments, with filament_type / filament_is_support left short on purpose.
|
||||
config.option<ConfigOptionStrings>("filament_colour", true)->values = {"#FF0000", "#00FF00", "#0000FF", "#FFFF00"};
|
||||
config.option<ConfigOptionStrings>("filament_type", true)->values = {"PLA"};
|
||||
config.option<ConfigOptionBools>("filament_is_support", true)->values = {0};
|
||||
config.option<ConfigOptionFloats>("filament_diameter", true)->values = {1.75, 1.75, 1.75, 1.75};
|
||||
config.option<ConfigOptionInts>("filament_map", true)->values = {1, 1, 1, 1};
|
||||
config.option<ConfigOptionFloats>("flush_volumes_matrix", true)->values = std::vector<double>(16, 140.);
|
||||
config.option<ConfigOptionFloats>("flush_multiplier", true)->values = {1.};
|
||||
|
||||
Model model;
|
||||
model.add_object("cube", "", make_cube(20, 20, 20))->add_instance();
|
||||
|
||||
Print print;
|
||||
print.apply(model, config);
|
||||
// apply() does not pad the per-filament arrays, so the mis-sizing survives into the engine.
|
||||
REQUIRE(print.config().filament_type.values.size() < print.config().filament_colour.values.size());
|
||||
|
||||
std::vector<std::vector<unsigned int>> layer_filaments = {{0, 1}, {1, 2}, {2, 3}};
|
||||
|
||||
SECTION("short per-filament arrays still yield one entry per filament") {
|
||||
auto result = ToolOrdering::get_recommended_filament_maps(layer_filaments, &print, FilamentMapMode::fmmAutoForFlush, {}, {});
|
||||
REQUIRE(result.get_extruder_map(false).size() == 4);
|
||||
for (int f = 0; f < 4; ++f)
|
||||
REQUIRE(result.get_extruder_id(f) == 0);
|
||||
}
|
||||
|
||||
SECTION("filament_ids longer than the filament count is truncated, not paired past the end") {
|
||||
config.option<ConfigOptionStrings>("filament_ids", true)->values = {"a", "b", "c", "d", "e", "f"};
|
||||
print.apply(model, config);
|
||||
auto result = ToolOrdering::get_recommended_filament_maps(layer_filaments, &print, FilamentMapMode::fmmAutoForFlush, {}, {});
|
||||
REQUIRE(result.get_extruder_map(false).size() == 4);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("H2C dynamic selector: per-layer nozzle ids reach the g-code surface", "[ToolOrdering][H2C][Dynamic]")
|
||||
{
|
||||
// The per-layer regroup engine
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
#include "test_utils.hpp"
|
||||
|
||||
#include <boost/filesystem.hpp>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <fstream>
|
||||
@@ -88,3 +90,65 @@ TEST_CASE("copy_file reports the OS error when the destination cannot be written
|
||||
REQUIRE(std::all_of(code.begin(), code.end(), [](unsigned char c) { return std::isdigit(c) != 0; }));
|
||||
#endif // _WIN32
|
||||
}
|
||||
|
||||
TEST_CASE("A resolved input path still names the same file after the working directory changes", "[utils]") {
|
||||
ScopedTemporaryFile model(".3mf");
|
||||
{ std::ofstream out(model.string()); out << "3mf"; }
|
||||
const std::string name = model.path().filename().string();
|
||||
|
||||
// Resolve the bare name from the directory holding the file, then move away from it. The guard
|
||||
// restores the directory the test started in, wherever this leaves it.
|
||||
ScopedWorkingDirectory cwd(model.path().parent_path());
|
||||
const std::string resolved = resolve_cli_input_path(name);
|
||||
boost::filesystem::current_path(boost::filesystem::path(TEST_DATA_DIR));
|
||||
|
||||
REQUIRE(boost::filesystem::exists(resolved));
|
||||
REQUIRE(boost::filesystem::equivalent(resolved, model.path()));
|
||||
// Control: the bare name finds nothing from here, so resolving it this late would have failed.
|
||||
REQUIRE_FALSE(boost::filesystem::exists(name));
|
||||
}
|
||||
|
||||
TEST_CASE("resolve_cli_input_path completes a relative path against the working directory", "[utils]") {
|
||||
ScopedWorkingDirectory cwd(boost::filesystem::temp_directory_path());
|
||||
// Read back rather than reusing temp_directory_path(): changing to it resolves any symlink.
|
||||
const boost::filesystem::path here = boost::filesystem::current_path();
|
||||
|
||||
SECTION("a bare name") {
|
||||
REQUIRE(resolve_cli_input_path("model.3mf") == (here / "model.3mf").make_preferred().string());
|
||||
}
|
||||
SECTION("a ./ prefix is dropped") {
|
||||
REQUIRE(resolve_cli_input_path("./model.3mf") == (here / "model.3mf").make_preferred().string());
|
||||
}
|
||||
SECTION("a ../ traversal is collapsed") {
|
||||
REQUIRE(resolve_cli_input_path("../model.3mf") == (here.parent_path() / "model.3mf").make_preferred().string());
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("resolve_cli_input_path leaves inputs that must not be completed unchanged", "[utils]") {
|
||||
SECTION("an absolute path") {
|
||||
const boost::filesystem::path absolute = (boost::filesystem::temp_directory_path() / "model.3mf").make_preferred();
|
||||
REQUIRE(resolve_cli_input_path(absolute.string()) == absolute.string());
|
||||
}
|
||||
#ifdef _WIN32
|
||||
// Every absolute form Windows accepts opens today, so each must come back byte for byte:
|
||||
// normalizing them would rewrite the forward slashes and rebuild the \\?\ and UNC prefixes.
|
||||
SECTION("an absolute Windows path of any form") {
|
||||
for (const std::string absolute : {R"(C:\models\model.3mf)",
|
||||
R"(C:/models/model.3mf)",
|
||||
R"(\\server\share\model.3mf)",
|
||||
R"(\\?\C:\models\model.3mf)"})
|
||||
REQUIRE(resolve_cli_input_path(absolute) == absolute);
|
||||
}
|
||||
#endif
|
||||
// These are downloaded rather than opened, and completing one would produce a path, not a URL.
|
||||
SECTION("a custom open protocol URL") {
|
||||
for (const std::string url : {"orcaslicer://open/?file=https://example.com/model.3mf",
|
||||
"prusaslicer://open/?file=https://example.com/model.3mf",
|
||||
"bambustudio://open/?file=https://example.com/model.3mf",
|
||||
"cura://open/?file=https://example.com/model.3mf"})
|
||||
REQUIRE(resolve_cli_input_path(url) == url);
|
||||
}
|
||||
SECTION("an empty argument") {
|
||||
REQUIRE(resolve_cli_input_path("").empty());
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -55,6 +55,18 @@ elseif (APPLE)
|
||||
COMMENT "Copying Python runtime for macOS plugin host API tests"
|
||||
VERBATIM
|
||||
)
|
||||
elseif (FLATPAK)
|
||||
# Same <testdir>/python home as WIN32/APPLE; symlink since /app/libpython
|
||||
# already ships in the flatpak (the test exe links libpython3.12.so from it).
|
||||
add_custom_command(TARGET ${_TEST_NAME}_tests POST_BUILD
|
||||
COMMAND ${CMAKE_COMMAND} -E rm -rf
|
||||
"$<TARGET_FILE_DIR:${_TEST_NAME}_tests>/python"
|
||||
COMMAND ${CMAKE_COMMAND} -E create_symlink
|
||||
"${CMAKE_PREFIX_PATH}/libpython"
|
||||
"$<TARGET_FILE_DIR:${_TEST_NAME}_tests>/python"
|
||||
COMMENT "Linking Python runtime for flatpak plugin host API tests"
|
||||
VERBATIM
|
||||
)
|
||||
endif()
|
||||
|
||||
orcaslicer_discover_tests(${_TEST_NAME}_tests)
|
||||
|
||||
@@ -176,4 +176,22 @@ inline void write_debug_stream([[maybe_unused]] const std::string &name, [[maybe
|
||||
#endif
|
||||
}
|
||||
|
||||
// Changes the working directory and restores the previous one on scope exit, including when an
|
||||
// assertion throws. It is process wide state shared with every other test.
|
||||
class ScopedWorkingDirectory
|
||||
{
|
||||
public:
|
||||
explicit ScopedWorkingDirectory(const boost::filesystem::path &dir)
|
||||
: m_previous(boost::filesystem::current_path())
|
||||
{
|
||||
boost::filesystem::current_path(dir);
|
||||
}
|
||||
~ScopedWorkingDirectory() { boost::system::error_code ec; boost::filesystem::current_path(m_previous, ec); }
|
||||
ScopedWorkingDirectory(const ScopedWorkingDirectory &) = delete;
|
||||
ScopedWorkingDirectory &operator=(const ScopedWorkingDirectory &) = delete;
|
||||
|
||||
private:
|
||||
boost::filesystem::path m_previous;
|
||||
};
|
||||
|
||||
#endif // SLIC3R_TEST_UTILS
|
||||
|
||||
Reference in New Issue
Block a user