mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-10-16 12:21:11 +00:00
Merge branch 'main' into feat/generic-n-extruders
This commit is contained in:
@@ -9,6 +9,7 @@ add_executable(${_TEST_NAME}_tests
|
||||
test_fill.cpp
|
||||
test_flow.cpp
|
||||
test_gcode_processor.cpp
|
||||
test_gcode_statistics.cpp
|
||||
test_gcode_timing.cpp
|
||||
test_gcode.cpp
|
||||
test_gcodeprocessor.cpp
|
||||
|
||||
@@ -3,17 +3,26 @@
|
||||
|
||||
#include <catch2/catch_message.hpp>
|
||||
#include "libslic3r/BoundingBox.hpp"
|
||||
#include "libslic3r/Config.hpp"
|
||||
#include "libslic3r/Model.hpp"
|
||||
#include "libslic3r/ModelArrange.hpp"
|
||||
#include "libslic3r/Print.hpp"
|
||||
#include "libslic3r/PrintConfig.hpp"
|
||||
#include "libslic3r/Preset.hpp"
|
||||
#include "libslic3r/PresetBundle.hpp"
|
||||
|
||||
#include "test_helpers.hpp"
|
||||
#include "test_utils.hpp"
|
||||
|
||||
#include "libslic3r/TriangleMesh.hpp"
|
||||
#include "libslic3r/Point.hpp"
|
||||
#include <cstddef>
|
||||
#include <fstream>
|
||||
#include <ios>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <catch2/matchers/catch_matchers.hpp>
|
||||
#include <catch2/matchers/catch_matchers_floating_point.hpp>
|
||||
|
||||
using namespace Slic3r;
|
||||
|
||||
@@ -41,3 +50,163 @@ TEST_CASE("Klipper object labels name each copy without the characters Klipper c
|
||||
CHECK(gcode.find("EXCLUDE_OBJECT_START NAME=" + instance_label + "\n") != std::string::npos);
|
||||
}
|
||||
}
|
||||
|
||||
// Firmwares read the compatibility header lines (Anycubic's GoKlipper heats the bed with
|
||||
// first_layer_bed_temperature), so they must describe the filament the plate prints, not filament 1.
|
||||
TEST_CASE("The G-code header names the first layer temperatures of the filament the plate prints", "[GCode]")
|
||||
{
|
||||
const std::string formula = GENERATE("by_first_filament", "by_highest_temp");
|
||||
DYNAMIC_SECTION("bed_temperature_formula " << formula) {
|
||||
// Filament 1: bed 60, nozzle 200. Filament 2: bed 100, nozzle 250. Everything prints with filament 2.
|
||||
DynamicPrintConfig config = Test::multifilament_config(2, {
|
||||
{"bed_temperature_formula", formula},
|
||||
// Authored 1..n as PresetBundle writes it; the all-1s default collapses every per-filament value
|
||||
// to filament 1.
|
||||
{"filament_self_index", "1,2"},
|
||||
{"filament_extruder_variant", "Direct Drive Standard;Direct Drive Standard"},
|
||||
{"enable_prime_tower", 0},
|
||||
{"nozzle_temperature_initial_layer", "200,250"},
|
||||
{"nozzle_temperature", "200,250"},
|
||||
{"supertack_plate_temp_initial_layer", "60,100"},
|
||||
{"cool_plate_temp_initial_layer", "60,100"},
|
||||
{"textured_cool_plate_temp_initial_layer", "60,100"},
|
||||
{"eng_plate_temp_initial_layer", "60,100"},
|
||||
{"hot_plate_temp_initial_layer", "60,100"},
|
||||
{"textured_plate_temp_initial_layer", "60,100"},
|
||||
{"sparse_infill_filament_id", 2},
|
||||
{"internal_solid_filament_id", 2},
|
||||
{"top_surface_filament_id", 2},
|
||||
{"bottom_surface_filament_id", 2},
|
||||
{"outer_wall_filament_id", 2},
|
||||
{"inner_wall_filament_id", 2},
|
||||
{"skirt_loops", 0},
|
||||
{"brim_type", "no_brim"},
|
||||
});
|
||||
const std::string gcode = Test::slice({Test::cube(20)}, config);
|
||||
|
||||
const auto header_value = [&gcode](const std::string& key) {
|
||||
const std::string token = "; " + key + " = ";
|
||||
const size_t pos = gcode.find(token);
|
||||
REQUIRE(pos != std::string::npos);
|
||||
return std::stoi(gcode.substr(pos + token.size()));
|
||||
};
|
||||
CHECK(header_value("first_layer_bed_temperature") == 100);
|
||||
CHECK(header_value("first_layer_temperature") == 250);
|
||||
}
|
||||
}
|
||||
|
||||
// Printing by object reassigns the initial filament to each object's first filament while it emits the objects, so
|
||||
// the header must still name the filament the print starts with: the firmware heats the bed for the first object.
|
||||
TEST_CASE("The G-code header names the first object's filament when printing by object", "[GCode]")
|
||||
{
|
||||
// Filament 1: bed 60, nozzle 200. Filament 2: bed 100, nozzle 250. Object 1 prints with filament 2, object 2
|
||||
// with filament 1.
|
||||
DynamicPrintConfig config = Test::multifilament_config(2, {
|
||||
{"print_sequence", "by object"},
|
||||
{"bed_temperature_formula", "by_first_filament"},
|
||||
{"filament_self_index", "1,2"},
|
||||
{"filament_extruder_variant", "Direct Drive Standard;Direct Drive Standard"},
|
||||
{"enable_prime_tower", 0},
|
||||
{"nozzle_temperature_initial_layer", "200,250"},
|
||||
{"nozzle_temperature", "200,250"},
|
||||
{"supertack_plate_temp_initial_layer", "60,100"},
|
||||
{"cool_plate_temp_initial_layer", "60,100"},
|
||||
{"textured_cool_plate_temp_initial_layer", "60,100"},
|
||||
{"eng_plate_temp_initial_layer", "60,100"},
|
||||
{"hot_plate_temp_initial_layer", "60,100"},
|
||||
{"textured_plate_temp_initial_layer", "60,100"},
|
||||
{"skirt_loops", 0},
|
||||
{"brim_type", "no_brim"},
|
||||
});
|
||||
const std::string gcode = Test::slice_with_object_overrides({Test::cube(20), Test::cube(20)}, config,
|
||||
{{{"extruder", "2"}}, {{"extruder", "1"}}});
|
||||
|
||||
// Filament 2 starts and filament 1 follows, so the last object's filament differs from the first one's.
|
||||
const size_t first_t1 = gcode.find("\nT1");
|
||||
const size_t first_t0 = gcode.find("\nT0");
|
||||
REQUIRE(first_t1 != std::string::npos);
|
||||
REQUIRE(first_t0 != std::string::npos);
|
||||
REQUIRE(first_t1 < first_t0);
|
||||
|
||||
const auto header_value = [&gcode](const std::string& key) {
|
||||
const std::string token = "; " + key + " = ";
|
||||
const size_t pos = gcode.find(token);
|
||||
REQUIRE(pos != std::string::npos);
|
||||
return std::stoi(gcode.substr(pos + token.size()));
|
||||
};
|
||||
CHECK(header_value("first_layer_bed_temperature") == 100);
|
||||
CHECK(header_value("first_layer_temperature") == 250);
|
||||
}
|
||||
|
||||
TEST_CASE("Object label comments number each object and copy with or without exclude object", "[GCode]")
|
||||
{
|
||||
// repetier has no exclude-object commands, so set_object_info() writes nothing for it.
|
||||
// BBL printers write M624/M625 skip blocks whatever exclude_object is set to.
|
||||
const auto [flavor, exclude_object, bbl] = GENERATE(table<std::string, std::string, bool>({
|
||||
{"marlin2", "0", false},
|
||||
{"klipper", "0", false},
|
||||
{"klipper", "1", false},
|
||||
{"repetier", "1", false},
|
||||
{"marlin2", "0", true},
|
||||
}));
|
||||
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
||||
config.set_deserialize_strict({{"gcode_flavor", flavor}, {"exclude_object", exclude_object}, {"gcode_label_objects", "1"}});
|
||||
Print print;
|
||||
print.is_BBL_printer() = bbl;
|
||||
Model model;
|
||||
Test::init_print(std::vector<TriangleMesh>{Test::cube(20.), Test::cube(20.)}, print, model, config, nullptr, false, 2);
|
||||
arrange_objects(model, BoundingBox{Point::new_scale(0., 0.), Point::new_scale(500., 500.)},
|
||||
ArrangeParams{scaled(min_object_distance(config))});
|
||||
print.apply(model, config);
|
||||
|
||||
const std::string gcode = Test::gcode(print);
|
||||
INFO(flavor << ", exclude_object " << exclude_object << ", BBL " << bbl);
|
||||
for (const char *label : {"id:0 copy 0", "id:0 copy 1", "id:1 copy 0", "id:1 copy 1"}) {
|
||||
INFO(label);
|
||||
CHECK(gcode.find("; printing object object.stl " + std::string(label) + "\n") != std::string::npos);
|
||||
}
|
||||
if (bbl)
|
||||
for (const PrintObject *object : print.objects())
|
||||
for (const PrintInstance &instance : object->instances()) {
|
||||
const std::string label_id = std::to_string(instance.model_instance->get_labeled_id());
|
||||
INFO("label id " << label_id);
|
||||
CHECK(gcode.find("; start printing object, unique label id: " + label_id + "\nM624 ") != std::string::npos);
|
||||
CHECK(gcode.find("; stop printing object, unique label id: " + label_id + "\nM625\n") != std::string::npos);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("An exported G-code's settings load back as the settings it was sliced with", "[GCode]")
|
||||
{
|
||||
// Two filaments, so the config block carries the flush matrix already multiplied by flush_multiplier.
|
||||
const DynamicPrintConfig config = Test::multifilament_config(2, {
|
||||
{ "flush_multiplier", "0.5" },
|
||||
{ "printable_area", "0x0,250x0,250x210,0x210" },
|
||||
{ "printable_height", "180" },
|
||||
});
|
||||
Print print;
|
||||
Model model;
|
||||
Test::init_print(std::vector<TriangleMesh>{ Test::cube(20.) }, print, model, config, nullptr, false);
|
||||
ScopedTemporaryFile file(".gcode");
|
||||
{
|
||||
std::ofstream os(file.string(), std::ios::binary);
|
||||
os << Test::gcode(print);
|
||||
REQUIRE(os.good());
|
||||
}
|
||||
|
||||
PresetBundle bundle;
|
||||
// The config block leaves prime_volume_mode out, so the receiver's choice has to survive the load.
|
||||
bundle.project_config.option<ConfigOptionEnum<PrimeVolumeMode>>("prime_volume_mode")->value = PrimeVolumeMode::pvmFast;
|
||||
ConfigSubstitutions substitutions;
|
||||
bundle.load_config_model(file.string(), bundle.config_from_gcode(file.string(), ForwardCompatibilitySubstitutionRule::Enable, substitutions));
|
||||
|
||||
const DynamicPrintConfig &printer = bundle.printers.get_edited_preset().config;
|
||||
CHECK(printer.opt_serialize("printable_area") == config.opt_serialize("printable_area"));
|
||||
CHECK_THAT(printer.opt_float("printable_height"), Catch::Matchers::WithinAbs(180., 1e-6));
|
||||
// The purge between the two filaments is what the slice used: 280 at a 0.5 multiplier.
|
||||
const std::vector<double> &matrix = bundle.project_config.option<ConfigOptionFloats>("flush_volumes_matrix")->values;
|
||||
const std::vector<double> &multiplier = bundle.project_config.option<ConfigOptionFloats>("flush_multiplier")->values;
|
||||
REQUIRE(matrix.size() == 4);
|
||||
REQUIRE(multiplier.size() == 1);
|
||||
CHECK_THAT(matrix[1] * multiplier[0], Catch::Matchers::WithinAbs(140., 1e-6));
|
||||
CHECK(bundle.project_config.option<ConfigOptionEnum<PrimeVolumeMode>>("prime_volume_mode")->value == PrimeVolumeMode::pvmFast);
|
||||
}
|
||||
|
||||
@@ -2,14 +2,19 @@
|
||||
|
||||
#include "libslic3r/libslic3r.h"
|
||||
#include "libslic3r/GCode/GCodeProcessor.hpp"
|
||||
#include "libslic3r/Model.hpp"
|
||||
#include "libslic3r/Print.hpp"
|
||||
#include "libslic3r/PrintConfig.hpp"
|
||||
|
||||
#include "test_utils.hpp"
|
||||
|
||||
#include <cstddef>
|
||||
#include <fstream>
|
||||
#include <initializer_list>
|
||||
#include <string>
|
||||
#include <catch2/catch_message.hpp>
|
||||
#include <catch2/catch_test_macros.hpp>
|
||||
#include <catch2/generators/catch_generators.hpp>
|
||||
#include <catch2/matchers/catch_matchers.hpp>
|
||||
#include <catch2/matchers/catch_matchers_floating_point.hpp>
|
||||
|
||||
@@ -49,6 +54,74 @@ TEST_CASE("The belt header's angle marks G-code as belt G-code", "[GCodeProcesso
|
||||
CHECK_THAT(processed_belt_tilt(gcode), WithinAbs(45., 1e-6));
|
||||
}
|
||||
|
||||
TEST_CASE("A loaded G-code's belt settings and bed replace the selected printer's in the preview", "[GCodeProcessor][belt][Regression]")
|
||||
{
|
||||
// Loading a G-code applies the selected printer's config to the Print, then the file's render
|
||||
// config on top, so the preview's belt view and back-transform follow the file. The selected
|
||||
// printer is always the other kind, and every belt and bed key differs between the two.
|
||||
const bool file_is_belt = GENERATE(false, true);
|
||||
CAPTURE(file_is_belt);
|
||||
|
||||
DynamicPrintConfig file = DynamicPrintConfig::full_print_config();
|
||||
file.set_deserialize_strict({
|
||||
{ "belt_printer", file_is_belt },
|
||||
{ "belt_slice_rotation", "y" },
|
||||
{ "belt_slice_rotation_angle", -30 },
|
||||
{ "gcode_remap_x", "rev_x" },
|
||||
{ "gcode_remap_y", "pos_z" },
|
||||
{ "gcode_remap_z", "pos_y" },
|
||||
{ "belt_frame_tilt_decouple", 1 },
|
||||
{ "belt_frame_tilt_angle", 35 },
|
||||
{ "printable_area", "0x0,300x0,300x250,0x250" },
|
||||
{ "printable_height", 123 },
|
||||
});
|
||||
DynamicPrintConfig selected = DynamicPrintConfig::full_print_config();
|
||||
selected.set_deserialize_strict({
|
||||
{ "belt_printer", !file_is_belt },
|
||||
{ "belt_slice_rotation", "x" },
|
||||
{ "belt_slice_rotation_angle", 45 },
|
||||
{ "gcode_remap_x", "pos_x" },
|
||||
{ "gcode_remap_y", "pos_y" },
|
||||
{ "gcode_remap_z", "pos_z" },
|
||||
{ "belt_frame_tilt_decouple", 0 },
|
||||
{ "belt_frame_tilt_angle", 50 },
|
||||
{ "printable_area", "0x0,180x0,180x180,0x180" },
|
||||
{ "printable_height", 200 },
|
||||
});
|
||||
|
||||
ScopedTemporaryFile temp(".gcode");
|
||||
{
|
||||
std::ofstream os(temp.string());
|
||||
REQUIRE(os.is_open());
|
||||
// A full config block, as the loader rejects one with fewer than 80 values.
|
||||
os << "; generated by OrcaSlicer\n" << body << "; CONFIG_BLOCK_START\n";
|
||||
for (const std::string &key : file.keys())
|
||||
os << "; " << key << " = " << file.opt_serialize(key) << '\n';
|
||||
os << "; CONFIG_BLOCK_END\n";
|
||||
REQUIRE(os.good());
|
||||
}
|
||||
GCodeProcessor processor;
|
||||
REQUIRE_NOTHROW(processor.process_file(temp.string()));
|
||||
|
||||
Model model;
|
||||
Print print;
|
||||
print.apply(model, selected);
|
||||
REQUIRE_NOTHROW(print.apply_config_for_render(processor.export_config_for_render()));
|
||||
|
||||
// The file's belt settings win either way, so a non-belt file turns a selected belt printer's view off.
|
||||
for (const char *key : { "belt_printer", "belt_slice_rotation", "belt_slice_rotation_angle", "gcode_remap_x",
|
||||
"gcode_remap_y", "gcode_remap_z", "belt_frame_tilt_decouple", "belt_frame_tilt_angle" }) {
|
||||
CAPTURE(key);
|
||||
CHECK(print.config().opt_serialize(key) == file.opt_serialize(key));
|
||||
}
|
||||
// Only a belt file brings the bed it was sliced for.
|
||||
const DynamicPrintConfig &bed_source = file_is_belt ? file : selected;
|
||||
for (const char *key : { "printable_area", "printable_height" }) {
|
||||
CAPTURE(key);
|
||||
CHECK(print.config().opt_serialize(key) == bed_source.opt_serialize(key));
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("Non-belt start G-code moves keep the first-layer Z in the processor", "[GCodeProcessor]")
|
||||
{
|
||||
// The belt path (GCodeWriter tests: "start-gcode prepare-stage moves keep their real Z")
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
#include <catch2/catch_test_macros.hpp>
|
||||
#include <catch2/matchers/catch_matchers.hpp>
|
||||
#include <catch2/matchers/catch_matchers_floating_point.hpp>
|
||||
|
||||
#include "libslic3r/Config.hpp"
|
||||
#include "libslic3r/GCode/GCodeProcessor.hpp"
|
||||
#include "libslic3r/GCode/GCodeStatistics.hpp"
|
||||
#include "libslic3r/Model.hpp"
|
||||
#include "libslic3r/Point.hpp"
|
||||
#include "libslic3r/Print.hpp"
|
||||
#include "libslic3r/PrintConfig.hpp"
|
||||
#include "libslic3r/Utils.hpp"
|
||||
|
||||
#include "test_helpers.hpp"
|
||||
#include "test_utils.hpp"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <cstddef>
|
||||
#include <initializer_list>
|
||||
#include <set>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
using namespace Slic3r;
|
||||
|
||||
TEST_CASE("The statistics of a sliced plate add up per layer, feature and object instance", "[GCodeStatistics]")
|
||||
{
|
||||
// A 20 mm cube at 0.2 mm layers prints 100 of them; without skirt or brim all its filament goes to its instances.
|
||||
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
||||
config.set_deserialize_strict({ { "layer_height", 0.2 },
|
||||
{ "initial_layer_print_height", 0.2 },
|
||||
{ "skirt_loops", 0 },
|
||||
{ "brim_type", "no_brim" },
|
||||
{ "filament_density", 1.25 } });
|
||||
Print print;
|
||||
Model model;
|
||||
Test::init_print({ Test::cube(20) }, print, model, config, nullptr, true, 2);
|
||||
GCodeProcessorResult result;
|
||||
Test::gcode(print, &result);
|
||||
|
||||
ScopedTemporaryFile file(".json");
|
||||
export_gcode_statistics(result, file.string());
|
||||
std::string text;
|
||||
load_string_file(file.path(), text);
|
||||
const nlohmann::json statistics = nlohmann::json::parse(text);
|
||||
|
||||
const nlohmann::json &summary = statistics["summary"];
|
||||
const nlohmann::json &layers = statistics["layers"];
|
||||
REQUIRE(layers.size() == 100);
|
||||
CHECK(summary["layer_count"] == 100);
|
||||
CHECK_THAT(layers.front()["z"].get<double>(), Catch::Matchers::WithinAbs(0.2, 1e-3));
|
||||
CHECK_THAT(layers.back()["z"].get<double>(), Catch::Matchers::WithinAbs(20., 1e-3));
|
||||
|
||||
double time = 0., volume = 0.;
|
||||
for (const nlohmann::json &layer : layers) {
|
||||
time += layer["time"].get<double>();
|
||||
volume += layer["volume"].get<double>();
|
||||
double features_time = 0.;
|
||||
for (const auto &[name, feature] : layer["features"].items())
|
||||
features_time += feature["time"].get<double>();
|
||||
CHECK(features_time <= layer["time"].get<double>() + 1e-3);
|
||||
}
|
||||
// Each value is rounded to the millisecond and the cubic millimeter.
|
||||
CHECK_THAT(time, Catch::Matchers::WithinAbs(summary["time"].get<double>(), 0.1));
|
||||
CHECK_THAT(volume, Catch::Matchers::WithinAbs(summary["volume"].get<double>(), 0.1));
|
||||
CHECK_THAT(summary["weight"].get<double>(), Catch::Matchers::WithinRel(summary["volume"].get<double>() * 1.25 / 1000., 1e-4));
|
||||
CHECK(summary.contains("filament_cost"));
|
||||
|
||||
// The filament splits by use as the preview's table does.
|
||||
REQUIRE(summary["filaments"].size() == 1);
|
||||
const nlohmann::json &filament = summary["filaments"].front();
|
||||
double used = 0.;
|
||||
for (const char *use : { "model", "support", "prime_tower", "flush" })
|
||||
used += filament[use]["volume"].get<double>();
|
||||
CHECK_THAT(used, Catch::Matchers::WithinAbs(filament["volume"].get<double>(), 0.01));
|
||||
|
||||
const nlohmann::json &features = summary["features"];
|
||||
CHECK(features.contains("Outer wall"));
|
||||
CHECK(features.contains("Sparse infill"));
|
||||
double model_volume = 0.;
|
||||
for (const auto &[name, feature] : features.items())
|
||||
if (name != "Custom")
|
||||
model_volume += feature["volume"].get<double>();
|
||||
|
||||
const nlohmann::json &objects = statistics["objects"];
|
||||
REQUIRE(objects.size() == 2);
|
||||
double objects_volume = 0.;
|
||||
for (size_t i = 0; i < objects.size(); ++i) {
|
||||
CHECK(objects[i]["name"] == model.objects.front()->name);
|
||||
CHECK(objects[i]["instance"] == int(i));
|
||||
CHECK(objects[i]["support_volume"].get<double>() == 0.);
|
||||
objects_volume += objects[i]["volume"].get<double>();
|
||||
}
|
||||
CHECK_THAT(objects_volume, Catch::Matchers::WithinAbs(model_volume, 0.1));
|
||||
}
|
||||
|
||||
TEST_CASE("Copies sliced as objects of their own keep their number in the model object", "[GCodeStatistics]")
|
||||
{
|
||||
// A copy of another scale slices as a print object of its own.
|
||||
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
||||
config.set_deserialize_strict({ { "skirt_loops", 0 }, { "brim_type", "no_brim" } });
|
||||
Print print;
|
||||
Model model;
|
||||
Test::init_print({ Test::cube(20) }, print, model, config, nullptr, true, 2);
|
||||
model.objects.front()->instances.back()->set_scaling_factor(Vec3d(0.5, 0.5, 0.5));
|
||||
model.objects.front()->ensure_on_bed();
|
||||
print.apply(model, config);
|
||||
REQUIRE(print.objects().size() == 2);
|
||||
GCodeProcessorResult result;
|
||||
Test::gcode(print, &result);
|
||||
|
||||
const nlohmann::json objects = nlohmann::json::parse(gcode_statistics_json(result))["objects"];
|
||||
std::set<int> instances;
|
||||
for (const nlohmann::json &object : objects)
|
||||
instances.insert(object["instance"].get<int>());
|
||||
CHECK(instances == std::set<int>{ 0, 1 });
|
||||
}
|
||||
|
||||
TEST_CASE("The statistics file takes the G-code's name with the statistics extension", "[GCodeStatistics]")
|
||||
{
|
||||
CHECK(gcode_statistics_path("plate_1.gcode") == "plate_1.statistics.json");
|
||||
CHECK(gcode_statistics_path("out/Cube_PLA_1h.gcode") == "out/Cube_PLA_1h.statistics.json");
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
#include "libslic3r/GCode/GCodeProcessor.hpp"
|
||||
#include "libslic3r/MultiNozzleUtils.hpp"
|
||||
#include "libslic3r/PrintConfig.hpp"
|
||||
#include "libslic3r/Utils.hpp"
|
||||
|
||||
#include "test_utils.hpp"
|
||||
|
||||
@@ -21,6 +22,7 @@
|
||||
#include <memory>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
#include "libslic3r/Config.hpp"
|
||||
|
||||
@@ -52,12 +54,16 @@ FullPrintConfig make_config(double load_time, double unload_time, double tool_ch
|
||||
return config;
|
||||
}
|
||||
|
||||
void run_processor(GCodeProcessor& proc, const FullPrintConfig& config, const char* gcode)
|
||||
void run_processor(GCodeProcessor& proc, const FullPrintConfig& config, const char* gcode, bool is_bbl_printer = true)
|
||||
{
|
||||
// reserved_tag() selects between two tag tables based on this shared static, and
|
||||
// other tests in the binary mutate it -- pin it so our "; FEATURE:" role tags are
|
||||
// parsed deterministically regardless of test execution order.
|
||||
GCodeProcessor::s_IsBBLPrinter = true;
|
||||
// other tests in the binary mutate it -- pin it so role tags are parsed
|
||||
// deterministically regardless of test execution order.
|
||||
const bool previous_is_bbl_printer = GCodeProcessor::s_IsBBLPrinter;
|
||||
GCodeProcessor::s_IsBBLPrinter = is_bbl_printer;
|
||||
const ScopeGuard restore_flag([previous_is_bbl_printer] {
|
||||
GCodeProcessor::s_IsBBLPrinter = previous_is_bbl_printer;
|
||||
});
|
||||
ScopedTemporaryFile temp(".gcode");
|
||||
{
|
||||
std::ofstream os(temp.string());
|
||||
@@ -99,6 +105,45 @@ double filament_change_delay(const GCodeProcessorResult& r)
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("G29 preparation time is added only once", "[GCodeTiming][Regression]")
|
||||
{
|
||||
const FullPrintConfig config = make_config(0.0, 0.0, 0.0);
|
||||
auto estimate = [&config](bool is_bbl_printer, const char* commands) {
|
||||
const auto mode = PrintEstimatedStatistics::ETimeMode::Normal;
|
||||
std::string gcode = is_bbl_printer ? "; FEATURE: Custom\n" : ";TYPE:Custom\n";
|
||||
gcode += "G1 X10 Y10 Z0.2 F600\nG1 X20 Y10 F600\n";
|
||||
if (is_bbl_printer)
|
||||
gcode += "M622 J1\n";
|
||||
gcode += commands;
|
||||
if (is_bbl_printer)
|
||||
gcode += "M623\n";
|
||||
gcode += "G1 X20 Y20 F600\nG1 X10 Y20 F600\n";
|
||||
|
||||
GCodeProcessor processor;
|
||||
run_processor(processor, config, gcode.c_str(), is_bbl_printer);
|
||||
return std::make_pair(processor.get_time(mode), processor.get_prepare_time(mode));
|
||||
};
|
||||
|
||||
const auto [baseline_time, baseline_prepare] = estimate(false, "G4 S0\n");
|
||||
const auto [one_time, one_prepare] = estimate(false, "G29\n");
|
||||
const auto [repeated_time, repeated_prepare] = estimate(false,
|
||||
"G29 P9 X10 Y-4 W32 H4\nG29 P1\nG29 P1 X0 Y0 W50 H20 C\nG29 P3.2\nG29 P3.13\nG29 A\n");
|
||||
|
||||
REQUIRE_THAT(one_time - baseline_time, WithinAbs(260.0, 1e-2));
|
||||
REQUIRE_THAT(one_prepare - baseline_prepare, WithinAbs(260.0, 1e-2));
|
||||
REQUIRE_THAT(repeated_time, WithinAbs(one_time, 1e-2));
|
||||
REQUIRE_THAT(repeated_prepare, WithinAbs(one_prepare, 1e-2));
|
||||
|
||||
const auto [baseline_bbl_time, baseline_bbl_prepare] = estimate(true, "G4 S0\n");
|
||||
const auto [one_bbl_time, one_bbl_prepare] = estimate(true, "G29\n");
|
||||
const auto [repeated_bbl_time, repeated_bbl_prepare] = estimate(true, "G29\nG29\n");
|
||||
|
||||
REQUIRE_THAT(one_bbl_time - baseline_bbl_time, WithinAbs(260.0, 1e-2));
|
||||
REQUIRE_THAT(one_bbl_prepare - baseline_bbl_prepare, WithinAbs(260.0, 1e-2));
|
||||
REQUIRE_THAT(repeated_bbl_time, WithinAbs(one_bbl_time, 1e-2));
|
||||
REQUIRE_THAT(repeated_bbl_prepare, WithinAbs(one_bbl_prepare, 1e-2));
|
||||
}
|
||||
|
||||
TEST_CASE("Filament-change time is attributed to tool-change moves, not extrusion roles", "[GCodeTiming]")
|
||||
{
|
||||
// Relative extrusion (M83) so every "E5" is a real 5mm extrusion move rather
|
||||
|
||||
@@ -11,9 +11,15 @@
|
||||
|
||||
#include <catch2/catch_test_macros.hpp>
|
||||
#include <catch2/catch_message.hpp>
|
||||
#include <catch2/matchers/catch_matchers.hpp>
|
||||
#include <catch2/matchers/catch_matchers_floating_point.hpp>
|
||||
#include "libslic3r/GCode/ToolOrdering.hpp"
|
||||
#include "libslic3r/MultiNozzleUtils.hpp"
|
||||
#include "libslic3r/Print.hpp"
|
||||
#include "libslic3r/BoundingBox.hpp"
|
||||
#include "libslic3r/ExPolygon.hpp"
|
||||
#include "libslic3r/Layer.hpp"
|
||||
#include "libslic3r/libslic3r.h"
|
||||
|
||||
#include "test_helpers.hpp"
|
||||
|
||||
@@ -333,3 +339,72 @@ TEST_CASE("Print::validate warns when a gradient mixed filament is used without
|
||||
CHECK(count_opt(print, "enable_mixed_color_sublayer") == 0);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("Filament shrinkage compensation compares the physical filaments of a mixed slot", "[MixedFilament][Print][Regression]")
|
||||
{
|
||||
// The mixed slot 3 blends filament 1 (90 %) and filament 2 (100 %); its own 90 % does not count.
|
||||
DynamicPrintConfig config = mixed_config(false);
|
||||
config.set_deserialize_strict({
|
||||
{ "filament_shrink", "90%,100%,90%" },
|
||||
{ "xy_contour_compensation", 0. },
|
||||
{ "layer_change_gcode", "G92 E0\n" }, // validate() relative-E reset
|
||||
});
|
||||
|
||||
Print print;
|
||||
init_and_process_print({ cube(20.) }, print, config);
|
||||
|
||||
const PrintObject &object = *print.objects().front();
|
||||
const BoundingBox extents = get_extents(object.get_layer(int(object.layer_count() / 2))->lslices);
|
||||
CHECK_THAT(unscale<double>(extents.size().x()), Catch::Matchers::WithinAbs(20., 0.01));
|
||||
std::vector<StringObjectException> warnings;
|
||||
CHECK(print.validate(&warnings).string.empty());
|
||||
CHECK(std::any_of(warnings.begin(), warnings.end(), [](const StringObjectException &w) {
|
||||
return w.string.find("Filament shrinkage will not be used") != std::string::npos;
|
||||
}));
|
||||
}
|
||||
|
||||
TEST_CASE("Filament shrinkage compensation counts the wipe tower filament next to a mixed slot", "[MixedFilament][Print][Regression]")
|
||||
{
|
||||
// The object prints only with the mixed slot 4 of filaments 1 and 2 (both 90 %). The wipe tower filament 3 (100 %)
|
||||
// may be flushed into the object, so the compensation is off.
|
||||
DynamicPrintConfig config = multifilament_config(4);
|
||||
config.set_deserialize_strict({
|
||||
{"filament_is_mixed", "0,0,0,1"},
|
||||
{"filament_mixed_components", ";;;1,2"},
|
||||
{"filament_mixed_sublayer_ratios", ";;;0.5,0.5"},
|
||||
{"filament_mixed_gradient", "0,0,0,0"},
|
||||
{"filament_mixed_gradient_range", ";;;"},
|
||||
{"filament_mixed_gradient_curve", ";;;"},
|
||||
{"filament_mixed_gradient_per_part","0,0,0,0"},
|
||||
{"outer_wall_filament_id", "4"},
|
||||
{"inner_wall_filament_id", "4"},
|
||||
{"sparse_infill_filament_id", "4"},
|
||||
{"internal_solid_filament_id", "4"},
|
||||
{"top_surface_filament_id", "4"},
|
||||
{"bottom_surface_filament_id", "4"},
|
||||
{"enable_prime_tower", "1"},
|
||||
{"wipe_tower_filament", "3"},
|
||||
{"prime_tower_width", "35"},
|
||||
{"wipe_tower_x", "50"}, // inside the 200x200 test bed
|
||||
{"wipe_tower_y", "50"},
|
||||
{"filament_shrink", "90%,90%,100%,90%"},
|
||||
{"xy_contour_compensation", "0"},
|
||||
{"layer_change_gcode", "G92 E0\n"}, // validate() relative-E reset
|
||||
});
|
||||
|
||||
// The object itself is on the mixed slot too, so no other filament is assigned.
|
||||
const std::vector<std::vector<ConfigBase::SetDeserializeItem>> object_overrides { { { "extruder", 4 } } };
|
||||
Print print;
|
||||
Model model;
|
||||
init_print({ cube(20.) }, print, model, config, &object_overrides);
|
||||
print.process();
|
||||
|
||||
const PrintObject &object = *print.objects().front();
|
||||
const BoundingBox extents = get_extents(object.get_layer(int(object.layer_count() / 2))->lslices);
|
||||
CHECK_THAT(unscale<double>(extents.size().x()), Catch::Matchers::WithinAbs(20., 0.01));
|
||||
std::vector<StringObjectException> warnings;
|
||||
CHECK(print.validate(&warnings).string.empty());
|
||||
CHECK(std::any_of(warnings.begin(), warnings.end(), [](const StringObjectException &w) {
|
||||
return w.string.find("Filament shrinkage will not be used") != std::string::npos;
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -105,7 +105,8 @@ void require_vertex(const Polygon &polygon, const Point &point)
|
||||
|
||||
std::vector<SeamPlacerImpl::EnforcedBlockedSeamPoint> weak_candidate_types(
|
||||
const Polygon &polygon, const std::vector<PreciseSeam::WeakModifierSegment> &segments,
|
||||
const std::function<SeamPlacerImpl::EnforcedBlockedSeamPoint(const Point &)> &painted = {})
|
||||
const std::function<SeamPlacerImpl::EnforcedBlockedSeamPoint(const Point &)> &painted = {},
|
||||
PreciseSeam::PreciseSeamWarnings *warnings = nullptr)
|
||||
{
|
||||
// Use the same coordinate conversion as production when applying prepared boundaries.
|
||||
// Optional painted types stand in for seam painting, which production assigns before weak zones.
|
||||
@@ -120,7 +121,7 @@ std::vector<SeamPlacerImpl::EnforcedBlockedSeamPoint> weak_candidate_types(
|
||||
}
|
||||
loop.end_index = candidates.points.size();
|
||||
bool enforced = false;
|
||||
PreciseSeam::apply_weak_modifiers_to_perimeter(segments, candidates, loop, enforced);
|
||||
PreciseSeam::apply_weak_modifiers_to_perimeter(segments, candidates, loop, enforced, warnings);
|
||||
std::vector<SeamPlacerImpl::EnforcedBlockedSeamPoint> types;
|
||||
for (const auto &candidate : candidates.points)
|
||||
types.push_back(candidate.type);
|
||||
@@ -664,7 +665,7 @@ TEST_CASE("Weak priority and enforcement refinement apply on both crossed sides"
|
||||
check_square_boundary(perimeter);
|
||||
}
|
||||
|
||||
TEST_CASE("Weak full containment follows painting: Enforced and Neutral type the whole perimeter, Blocked is skipped", "[PreciseSeam]")
|
||||
TEST_CASE("Weak full containment follows painting: the whole perimeter takes the zone's type", "[PreciseSeam]")
|
||||
{
|
||||
const auto type = GENERATE(ModelVolumeType::PRECISE_SEAM_ENFORCED, ModelVolumeType::PRECISE_SEAM_BLOCKED,
|
||||
ModelVolumeType::PRECISE_SEAM_NEUTRAL);
|
||||
@@ -674,6 +675,8 @@ TEST_CASE("Weak full containment follows painting: Enforced and Neutral type the
|
||||
const Points original = perimeter.points;
|
||||
const auto *modifier = fixture.add(type, {rectangle(-2, -2, 22, 22)});
|
||||
PreciseSeam::PreciseSeamWarnings warnings;
|
||||
// Registered as SeamPlacer::init() does, so the per-modifier warning flag is tracked.
|
||||
warnings.modifier_usage.try_emplace(modifier);
|
||||
const auto segments = PreciseSeam::collect_weak_modifier_segments(
|
||||
{modifier}, perimeter, PreciseSeam::PreparedPerimeter(perimeter), fixture.layer, fixture.cache, &warnings);
|
||||
// Painting of both kinds, so that each type's effect on it is visible.
|
||||
@@ -681,23 +684,17 @@ TEST_CASE("Weak full containment follows painting: Enforced and Neutral type the
|
||||
return p.x() < scale_(10.) ? SeamPlacerImpl::EnforcedBlockedSeamPoint::Enforced :
|
||||
SeamPlacerImpl::EnforcedBlockedSeamPoint::Blocked;
|
||||
};
|
||||
const auto types = weak_candidate_types(perimeter, segments, painted);
|
||||
const auto types = weak_candidate_types(perimeter, segments, painted, &warnings);
|
||||
CHECK_FALSE(warnings.multiple_intersections.load());
|
||||
CHECK(warnings.failed_fragments.load() == 0);
|
||||
if (type == ModelVolumeType::PRECISE_SEAM_BLOCKED) {
|
||||
// Forbidding the seam all round cannot be honoured: skipped with a warning, painting stays.
|
||||
CHECK(segments.empty());
|
||||
CHECK(perimeter.points == original);
|
||||
CHECK(warnings.full_containment.load() == PreciseSeam::PreciseSeamWarnings::type_bit(type));
|
||||
for (size_t i = 0; i < perimeter.size(); ++i)
|
||||
CHECK(types[i] == painted(perimeter[i]));
|
||||
return;
|
||||
}
|
||||
REQUIRE(segments.size() == 1);
|
||||
CHECK(segments.front().whole_perimeter);
|
||||
CHECK(warnings.full_containment.load() == 0);
|
||||
// Only a whole Blocked perimeter is reported, as the seam cannot avoid it.
|
||||
CHECK(warnings.modifier_usage.at(modifier).fully_blocked.load() == (type == ModelVolumeType::PRECISE_SEAM_BLOCKED));
|
||||
// Every candidate takes the zone's type, overriding painting.
|
||||
const auto expected = type == ModelVolumeType::PRECISE_SEAM_ENFORCED ? SeamPlacerImpl::EnforcedBlockedSeamPoint::Enforced :
|
||||
type == ModelVolumeType::PRECISE_SEAM_BLOCKED ? SeamPlacerImpl::EnforcedBlockedSeamPoint::Blocked :
|
||||
SeamPlacerImpl::EnforcedBlockedSeamPoint::Neutral;
|
||||
for (size_t i = 0; i < perimeter.size(); ++i)
|
||||
CHECK(types[i] == expected);
|
||||
@@ -706,29 +703,37 @@ TEST_CASE("Weak full containment follows painting: Enforced and Neutral type the
|
||||
CHECK(perimeter.size() >= size_t(80.f / SeamPlacer::enforcer_oversampling_distance));
|
||||
check_square_boundary(perimeter);
|
||||
} else
|
||||
CHECK(perimeter.points == original); // Like an unmarked perimeter: no subdivision.
|
||||
CHECK(perimeter.points == original); // Like a perimeter unmarked or painted red all round: no subdivision.
|
||||
}
|
||||
|
||||
TEST_CASE("Whole-perimeter weak zones take part in the usual priority order", "[PreciseSeam]")
|
||||
{
|
||||
// Volumes are listed low priority first; a later zone overwrites an earlier one.
|
||||
const int scenario = GENERATE(0, 1, 2);
|
||||
const int scenario = GENERATE(0, 1, 2, 3, 4, 5, 6);
|
||||
CAPTURE(scenario);
|
||||
SeamFixture fixture;
|
||||
const auto *whole_enforced = fixture.add(ModelVolumeType::PRECISE_SEAM_ENFORCED, {rectangle(-2, -2, 22, 22)});
|
||||
const auto *whole_neutral = fixture.add(ModelVolumeType::PRECISE_SEAM_NEUTRAL, {rectangle(-2, -2, 22, 22)});
|
||||
const auto *whole_blocked = fixture.add(ModelVolumeType::PRECISE_SEAM_BLOCKED, {rectangle(-2, -2, 22, 22)});
|
||||
const auto *lower_whole_blocked = fixture.add(ModelVolumeType::PRECISE_SEAM_BLOCKED, {rectangle(-2, -2, 22, 22)});
|
||||
const auto *band_blocked = fixture.add(ModelVolumeType::PRECISE_SEAM_BLOCKED, {rectangle(8, -2, 12, 2)});
|
||||
const auto *band_enforced = fixture.add(ModelVolumeType::PRECISE_SEAM_ENFORCED, {rectangle(8, -2, 12, 2)});
|
||||
std::vector<const ModelVolume*> volumes;
|
||||
if (scenario == 0) volumes = {whole_enforced, band_blocked}; // A band forbidden inside a green perimeter.
|
||||
if (scenario == 1) volumes = {band_enforced, whole_neutral}; // A whole Neutral clears the lower band.
|
||||
if (scenario == 2) volumes = {band_enforced, whole_blocked}; // A whole Blocked is skipped; the band stays.
|
||||
if (scenario == 2) volumes = {band_enforced, whole_blocked}; // A whole Blocked overrides the lower band.
|
||||
if (scenario == 3) volumes = {whole_blocked, band_enforced}; // A higher band unblocks part of a whole Blocked.
|
||||
if (scenario == 4) volumes = {whole_blocked, band_blocked}; // A higher Blocked band keeps it whole Blocked.
|
||||
if (scenario == 5) volumes = {lower_whole_blocked, whole_enforced, whole_blocked}; // The lower one is overwritten.
|
||||
if (scenario == 6) volumes = {lower_whole_blocked, whole_blocked}; // Only the higher one is in effect.
|
||||
Polygon perimeter = rectangle(0, 0, 20, 20);
|
||||
PreciseSeam::PreciseSeamWarnings warnings;
|
||||
// Registered as SeamPlacer::init() does, so the per-modifier warning flag is tracked.
|
||||
warnings.modifier_usage.try_emplace(whole_blocked);
|
||||
warnings.modifier_usage.try_emplace(lower_whole_blocked);
|
||||
const auto segments = PreciseSeam::collect_weak_modifier_segments(
|
||||
volumes, perimeter, PreciseSeam::PreparedPerimeter(perimeter), fixture.layer, fixture.cache, &warnings);
|
||||
const auto types = weak_candidate_types(perimeter, segments);
|
||||
const auto types = weak_candidate_types(perimeter, segments, {}, &warnings);
|
||||
using Type = SeamPlacerImpl::EnforcedBlockedSeamPoint;
|
||||
for (size_t i = 0; i < perimeter.size(); ++i) {
|
||||
const Point &p = perimeter[i];
|
||||
@@ -736,14 +741,38 @@ TEST_CASE("Whole-perimeter weak zones take part in the usual priority order", "[
|
||||
const bool in_band = p.y() == 0 && p.x() >= scale_(8.) && p.x() <= scale_(12.);
|
||||
const Type expected = scenario == 0 ? (in_band ? Type::Blocked : Type::Enforced) :
|
||||
scenario == 1 ? Type::Neutral :
|
||||
(in_band ? Type::Enforced : Type::Neutral);
|
||||
scenario == 3 ? (in_band ? Type::Enforced : Type::Blocked) :
|
||||
Type::Blocked;
|
||||
CHECK(types[i] == expected);
|
||||
}
|
||||
CHECK(warnings.full_containment.load() ==
|
||||
(scenario == 2 ? PreciseSeam::PreciseSeamWarnings::type_bit(ModelVolumeType::PRECISE_SEAM_BLOCKED) : 0u));
|
||||
CHECK(warnings.full_containment.load() == 0);
|
||||
// The warning follows the final typing: only a perimeter left whole Blocked is reported, through the
|
||||
// highest whole zone.
|
||||
const bool expected_fully_blocked = scenario == 2 || scenario == 4 || scenario == 5 || scenario == 6;
|
||||
CHECK(warnings.modifier_usage.at(whole_blocked).fully_blocked.load() == expected_fully_blocked);
|
||||
CHECK_FALSE(warnings.modifier_usage.at(lower_whole_blocked).fully_blocked.load());
|
||||
check_square_boundary(perimeter);
|
||||
}
|
||||
|
||||
TEST_CASE("A partial Blocked zone on a perimeter painted red all round gives no whole-perimeter warning", "[PreciseSeam]")
|
||||
{
|
||||
SeamFixture fixture;
|
||||
const auto *band_blocked = fixture.add(ModelVolumeType::PRECISE_SEAM_BLOCKED, {rectangle(8, -2, 12, 2)});
|
||||
Polygon perimeter = rectangle(0, 0, 20, 20);
|
||||
PreciseSeam::PreciseSeamWarnings warnings;
|
||||
warnings.modifier_usage.try_emplace(band_blocked);
|
||||
const auto segments = PreciseSeam::collect_weak_modifier_segments(
|
||||
{band_blocked}, perimeter, PreciseSeam::PreparedPerimeter(perimeter), fixture.layer, fixture.cache, &warnings);
|
||||
REQUIRE(segments.size() == 1);
|
||||
// Red painting makes every candidate Blocked, so the check reaches the whole-zone filter.
|
||||
const auto painted = [](const Point &) { return SeamPlacerImpl::EnforcedBlockedSeamPoint::Blocked; };
|
||||
const auto types = weak_candidate_types(perimeter, segments, painted, &warnings);
|
||||
for (const auto type : types)
|
||||
CHECK(type == SeamPlacerImpl::EnforcedBlockedSeamPoint::Blocked);
|
||||
// Only a modifier that covers the whole perimeter is reported.
|
||||
CHECK_FALSE(warnings.modifier_usage.at(band_blocked).fully_blocked.load());
|
||||
}
|
||||
|
||||
TEST_CASE("Coverage short by a gap under 1 um is full containment while a narrow band keeps its point", "[PreciseSeam][Regression]")
|
||||
{
|
||||
// A tip pokes through the bottom edge at x = 10 mm by `depth` nm, so the perimeter line crosses it
|
||||
@@ -829,19 +858,15 @@ TEST_CASE("A modifier touching the perimeter at one point keeps the full contain
|
||||
else {
|
||||
const auto segments = PreciseSeam::collect_weak_modifier_segments(
|
||||
{modifier}, perimeter, PreciseSeam::PreparedPerimeter(perimeter), fixture.layer, fixture.cache, &warnings);
|
||||
// Enforced and Neutral type the whole perimeter; Blocked is skipped.
|
||||
if (type == ModelVolumeType::PRECISE_SEAM_BLOCKED)
|
||||
CHECK(segments.empty());
|
||||
else {
|
||||
REQUIRE(segments.size() == 1);
|
||||
CHECK(segments.front().whole_perimeter);
|
||||
}
|
||||
// A weak modifier gives its type to the whole perimeter.
|
||||
REQUIRE(segments.size() == 1);
|
||||
CHECK(segments.front().whole_perimeter);
|
||||
}
|
||||
// Only a whole-perimeter Enforced zone subdivides the edges.
|
||||
if (type != ModelVolumeType::PRECISE_SEAM_ENFORCED)
|
||||
CHECK(perimeter.points == original);
|
||||
const bool skipped = is_precise_seam_strong(type) || type == ModelVolumeType::PRECISE_SEAM_BLOCKED;
|
||||
CHECK(warnings.full_containment.load() == (skipped ? PreciseSeam::PreciseSeamWarnings::type_bit(type) : 0u));
|
||||
// Only strong types are skipped with the full-containment warning.
|
||||
CHECK(warnings.full_containment.load() == (is_precise_seam_strong(type) ? PreciseSeam::PreciseSeamWarnings::type_bit(type) : 0u));
|
||||
CHECK_FALSE(warnings.multiple_intersections.load());
|
||||
CHECK(warnings.failed_fragments.load() == 0);
|
||||
}
|
||||
@@ -1176,7 +1201,7 @@ TEST_CASE("Modifier usage marks evaluated modifiers that never reach a perimeter
|
||||
CHECK(checked(short_weak));
|
||||
CHECK_FALSE(reached(short_weak));
|
||||
CHECK(reached(crossing_weak));
|
||||
// Full containment reached the perimeter even though it is skipped.
|
||||
// Full containment counts as reaching the perimeter.
|
||||
CHECK(reached(containing_weak));
|
||||
|
||||
// Strong: evaluation stops at the first modifier with a segment. The one ending 0.5 mm before the
|
||||
@@ -1393,6 +1418,24 @@ TEST_CASE("Weak modifiers move the exported seam into or out of their zone", "[P
|
||||
CHECK(fixture.warning().empty());
|
||||
}
|
||||
|
||||
TEST_CASE("A higher-priority Enforced band suppresses the whole-perimeter Blocked export warning", "[PreciseSeam][Regression]")
|
||||
{
|
||||
ExportFixture fixture("back");
|
||||
// The earlier model volume has higher priority; weak application reverses this order.
|
||||
fixture.add(ModelVolumeType::PRECISE_SEAM_ENFORCED, make_cube(2, 6, 4), Vec3d(4, -3, -1), "front enforcer");
|
||||
fixture.add(ModelVolumeType::PRECISE_SEAM_BLOCKED, make_cube(30, 30, 4), Vec3d(-5, -5, -1), "whole blocker");
|
||||
const auto loops = outer_wall_loops(fixture.export_gcode());
|
||||
REQUIRE(loops.size() == 10);
|
||||
for (const OuterLoop &loop : loops) {
|
||||
CAPTURE(loop.seam.x(), loop.seam.y());
|
||||
// The band wins on every layer, so the empty warning is not from modifiers that did nothing.
|
||||
CHECK_THAT(loop.seam.y(), Catch::Matchers::WithinAbs(loop.bounds.min.y() - loop.bounds.center().y(), 0.01));
|
||||
CHECK(loop.seam.x() >= -6.01);
|
||||
CHECK(loop.seam.x() <= -3.99);
|
||||
}
|
||||
CHECK(fixture.warning().empty());
|
||||
}
|
||||
|
||||
TEST_CASE("A strong modifier with a hole uses the left of two equal crossings", "[PreciseSeam]")
|
||||
{
|
||||
// The hole splits the front face crossing into x = 2..4 and x = 8..10 mm. The lengths tie, both
|
||||
@@ -1422,14 +1465,49 @@ TEST_CASE("Full containment warns for Blocked but not for Enforced", "[PreciseSe
|
||||
{
|
||||
const auto type = GENERATE(ModelVolumeType::PRECISE_SEAM_ENFORCED, ModelVolumeType::PRECISE_SEAM_BLOCKED);
|
||||
ExportFixture fixture("back");
|
||||
fixture.add(type, make_cube(30, 30, 4), Vec3d(-5, -5, -1));
|
||||
fixture.export_gcode();
|
||||
fixture.add(type, make_cube(30, 30, 4), Vec3d(-5, -5, -1), "whole helper");
|
||||
const auto loops = outer_wall_loops(fixture.export_gcode());
|
||||
// The whole perimeter takes the type either way, so the seam still follows the back preference.
|
||||
REQUIRE(loops.size() == 10);
|
||||
for (const OuterLoop &loop : loops)
|
||||
CHECK(loop.seam.y() > 7.);
|
||||
if (type == ModelVolumeType::PRECISE_SEAM_BLOCKED)
|
||||
CHECK(fixture.warning().find("Seam Blocked") != std::string::npos);
|
||||
CHECK(fixture.warning().find("Seam Blocked modifier \"whole helper\" of") != std::string::npos);
|
||||
else
|
||||
CHECK(fixture.warning().empty());
|
||||
}
|
||||
|
||||
TEST_CASE("A perimeter blocked all round gets the seam of an unmarked one", "[PreciseSeam][Regression]")
|
||||
{
|
||||
// Equal Blocked candidates compare like Neutral ones, so random and aligned seams still vary and align.
|
||||
const std::string seam_position = GENERATE(as<std::string>{}, "random", "aligned", "aligned_back", "back", "nearest");
|
||||
CAPTURE(seam_position);
|
||||
ExportFixture unmarked(seam_position);
|
||||
const auto expected = outer_wall_loops(unmarked.export_gcode());
|
||||
ExportFixture blocked(seam_position);
|
||||
blocked.add(ModelVolumeType::PRECISE_SEAM_BLOCKED, make_cube(30, 30, 4), Vec3d(-5, -5, -1));
|
||||
const auto loops = outer_wall_loops(blocked.export_gcode());
|
||||
REQUIRE(loops.size() == expected.size());
|
||||
for (size_t i = 0; i < loops.size(); ++i) {
|
||||
CAPTURE(i, loops[i].seam.x(), loops[i].seam.y(), expected[i].seam.x(), expected[i].seam.y());
|
||||
CHECK((loops[i].seam - expected[i].seam).norm() < 0.01);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("The whole-perimeter Blocked warning names the first modifier and counts them all", "[PreciseSeam]")
|
||||
{
|
||||
ExportFixture fixture("back");
|
||||
// Separate layers: where both covered the same perimeter, only the higher-priority one would count.
|
||||
fixture.add(ModelVolumeType::PRECISE_SEAM_BLOCKED, make_cube(30, 30, 1.4), Vec3d(-5, -5, -1), "first blocker");
|
||||
fixture.add(ModelVolumeType::PRECISE_SEAM_BLOCKED, make_cube(30, 30, 2), Vec3d(-5, -5, 1), "second blocker");
|
||||
fixture.export_gcode();
|
||||
const std::string warning = fixture.warning();
|
||||
CAPTURE(warning);
|
||||
CHECK(warning.find("Seam Blocked modifier \"first blocker\" of") != std::string::npos);
|
||||
CHECK(warning.find("(2 in total) covers an entire perimeter") != std::string::npos);
|
||||
CHECK(warning.find("second blocker") == std::string::npos);
|
||||
}
|
||||
|
||||
TEST_CASE("Belt printers slice Precise Seam modifiers in the frame the object was sliced in", "[PreciseSeam][belt]")
|
||||
{
|
||||
Model model;
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
#include <catch2/catch_all.hpp>
|
||||
|
||||
#include "libslic3r/Print.hpp"
|
||||
#include "libslic3r/Slicing.hpp"
|
||||
#include "libslic3r/Layer.hpp"
|
||||
#include "libslic3r/BuildVolume.hpp"
|
||||
#include "libslic3r/Support/TreeModelVolumes.hpp"
|
||||
@@ -62,6 +63,8 @@
|
||||
#include "libslic3r/ObjectID.hpp"
|
||||
#include "libslic3r/Point.hpp"
|
||||
#include "libslic3r/SurfaceCollection.hpp"
|
||||
#include "libslic3r/BoundingBox.hpp"
|
||||
#include "libslic3r/ExPolygon.hpp"
|
||||
|
||||
using namespace Slic3r;
|
||||
using namespace Slic3r::Test;
|
||||
@@ -1596,3 +1599,185 @@ TEST_CASE("Belt supports reach the belt under a leading overhang", "[Print][belt
|
||||
CHECK(lowest->print_z - floor_under_lowest > -0.2 - EPSILON);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("The build height check counts the Z shrinkage compensation once", "[Print][validate][Regression]")
|
||||
{
|
||||
// printable_height is 100 mm and Z shrinks to 95 %, so the object is sliced 100 / 95 times taller.
|
||||
// 92 mm become about 96.8 mm and fit; 97 mm become about 102.1 mm and only the compensation makes them too tall; 101 mm are too tall on their own.
|
||||
const auto [height, expected] = GENERATE(table<double, std::string>({
|
||||
{ 92., "" },
|
||||
{ 97., "because of material shrinkage compensation" },
|
||||
{ 101., "exceeds the maximum build volume height." },
|
||||
}));
|
||||
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
||||
config.set_deserialize_strict({
|
||||
{ "printable_height", 100. },
|
||||
{ "filament_shrinkage_compensation_z", "95%" },
|
||||
{ "layer_height", 0.2 },
|
||||
{ "initial_layer_print_height", 0.2 },
|
||||
{ "layer_change_gcode", "G92 E0\n" }, // validate() relative-E reset
|
||||
});
|
||||
|
||||
Print print;
|
||||
Model model;
|
||||
init_print({ make_cube(20., 20., height) }, print, model, config);
|
||||
const std::string error = print.validate().string;
|
||||
INFO(error);
|
||||
if (expected.empty())
|
||||
CHECK(error.empty());
|
||||
else
|
||||
CHECK(error.find(expected) != std::string::npos);
|
||||
}
|
||||
|
||||
TEST_CASE("The build height check counts the raft", "[Print][validate][Regression]")
|
||||
{
|
||||
// printable_height is 100 mm; three raft layers lift the object by 0.9 mm.
|
||||
const auto [height, fits] = GENERATE(table<double, bool>({ { 98.8, true }, { 99.8, false } }));
|
||||
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
||||
config.set_deserialize_strict({
|
||||
{ "printable_height", 100. },
|
||||
{ "raft_layers", 3 },
|
||||
{ "layer_height", 0.2 },
|
||||
{ "initial_layer_print_height", 0.2 },
|
||||
{ "layer_change_gcode", "G92 E0\n" }, // validate() relative-E reset
|
||||
});
|
||||
|
||||
Print print;
|
||||
Model model;
|
||||
init_print({ make_cube(20., 20., height) }, print, model, config);
|
||||
const SlicingParameters &slicing_params = print.objects().front()->slicing_parameters();
|
||||
REQUIRE_THAT(slicing_params.object_print_z_min, Catch::Matchers::WithinAbs(0.9, EPSILON));
|
||||
const std::string error = print.validate().string;
|
||||
INFO(error);
|
||||
if (fits)
|
||||
CHECK(error.empty());
|
||||
else
|
||||
CHECK(error.find("maximum build volume height") != std::string::npos);
|
||||
}
|
||||
|
||||
TEST_CASE("Filament shrinkage compensation scales the sliced object", "[Print][Regression]")
|
||||
{
|
||||
// A 20 mm cube printed with a filament that shrinks to xy % / z % is sliced 20 * 100 / xy wide and,
|
||||
// up to one layer, 20 * 100 / z tall.
|
||||
const auto [xy, z] = GENERATE(table<double, double>({ { 100., 100. }, { 90., 100. }, { 100., 95. } }));
|
||||
const double layer_height = 0.2;
|
||||
|
||||
Print print;
|
||||
init_and_process_print({ cube(20.) }, print, {
|
||||
{ "filament_shrink", xy },
|
||||
{ "filament_shrinkage_compensation_z", z },
|
||||
{ "layer_height", layer_height },
|
||||
{ "initial_layer_print_height", layer_height },
|
||||
{ "precise_z_height", false },
|
||||
{ "raft_layers", 0 },
|
||||
{ "xy_contour_compensation", 0. },
|
||||
});
|
||||
|
||||
const PrintObject &object = *print.objects().front();
|
||||
const Layer &middle = *object.get_layer(int(object.layer_count() / 2));
|
||||
const BoundingBox extents = get_extents(middle.lslices);
|
||||
CHECK_THAT(unscale<double>(extents.size().x()), Catch::Matchers::WithinAbs(20. * 100. / xy, 0.01));
|
||||
CHECK_THAT(unscale<double>(extents.size().y()), Catch::Matchers::WithinAbs(20. * 100. / xy, 0.01));
|
||||
CHECK_THAT(object.layers().back()->print_z, Catch::Matchers::WithinAbs(20. * 100. / z, layer_height));
|
||||
}
|
||||
|
||||
TEST_CASE("Filament shrinkage compensation follows the filaments the object prints with", "[Print][Regression]")
|
||||
{
|
||||
// Filament 1 shrinks to 90 %, filament 2 not at all. An unused filament does not matter; two used filaments
|
||||
// that disagree switch the compensation off and validate() warns about it.
|
||||
const auto [wall_filament, expected_width] = GENERATE(table<int, double>({ { 1, 20. * 100. / 90. }, { 2, 20. } }));
|
||||
|
||||
Print print;
|
||||
init_and_process_print({ cube(20.) }, print, multifilament_config(2, {
|
||||
{ "filament_shrink", "90%,100%" },
|
||||
{ "outer_wall_filament_id", wall_filament },
|
||||
{ "inner_wall_filament_id", wall_filament },
|
||||
{ "xy_contour_compensation", 0. },
|
||||
{ "layer_change_gcode", "G92 E0\n" }, // validate() relative-E reset
|
||||
}));
|
||||
|
||||
const PrintObject &object = *print.objects().front();
|
||||
const BoundingBox extents = get_extents(object.get_layer(int(object.layer_count() / 2))->lslices);
|
||||
CHECK_THAT(unscale<double>(extents.size().x()), Catch::Matchers::WithinAbs(expected_width, 0.01));
|
||||
|
||||
std::vector<StringObjectException> warnings;
|
||||
CHECK(print.validate(&warnings).string.empty());
|
||||
const bool mismatch_warning = std::any_of(warnings.begin(), warnings.end(), [](const StringObjectException &w) {
|
||||
return w.string.find("Filament shrinkage will not be used") != std::string::npos;
|
||||
});
|
||||
CHECK(mismatch_warning == (wall_filament == 2));
|
||||
}
|
||||
|
||||
TEST_CASE("Applying the same model again keeps the filament shrinkage compensation", "[Print][Regression]")
|
||||
{
|
||||
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
||||
config.set_deserialize_strict({ { "filament_shrink", "90%" }, { "xy_contour_compensation", 0. } });
|
||||
|
||||
Print print;
|
||||
Model model;
|
||||
init_print({ cube(20.) }, print, model, config);
|
||||
const auto width = [&print]() {
|
||||
print.process();
|
||||
const PrintObject &object = *print.objects().front();
|
||||
return unscale<double>(get_extents(object.get_layer(int(object.layer_count() / 2))->lslices).size().x());
|
||||
};
|
||||
CHECK_THAT(width(), Catch::Matchers::WithinAbs(20. * 100. / 90., 0.01));
|
||||
print.apply(model, config);
|
||||
CHECK_THAT(width(), Catch::Matchers::WithinAbs(20. * 100. / 90., 0.01));
|
||||
}
|
||||
|
||||
TEST_CASE("Height ranges and modifiers count for the filament shrinkage compensation", "[Print][Regression]")
|
||||
{
|
||||
// The model is complete before the first apply(), so no PrintRegion tells yet which filaments print.
|
||||
enum class Setup { RangeWall, ModifierInfill, ModifierInheritingObjectFilament };
|
||||
const Setup setup = GENERATE(Setup::RangeWall, Setup::ModifierInfill, Setup::ModifierInheritingObjectFilament);
|
||||
// The object prints with the filament that shrinks to 90 %, the other one does not shrink.
|
||||
const bool object_on_filament_2 = setup == Setup::ModifierInheritingObjectFilament;
|
||||
DynamicPrintConfig config = multifilament_config(2, {
|
||||
{ "filament_shrink", object_on_filament_2 ? "100%,90%" : "90%,100%" },
|
||||
{ "xy_contour_compensation", 0. },
|
||||
{ "layer_change_gcode", "G92 E0\n" }, // validate() relative-E reset
|
||||
});
|
||||
|
||||
Model model;
|
||||
ModelObject *object = model.add_object();
|
||||
object->add_volume(cube(20.));
|
||||
DynamicPrintConfig override;
|
||||
switch (setup) {
|
||||
case Setup::RangeWall:
|
||||
// The walls of a height range print with the other filament.
|
||||
override.set_key_value("outer_wall_filament_id", new ConfigOptionInt(2));
|
||||
// Every layer range must carry a layer_height (see layer_height_profile_from_ranges).
|
||||
override.set_key_value("layer_height", new ConfigOptionFloat(0.2));
|
||||
object->layer_config_ranges[{ 5., 10. }].assign_config(std::move(override));
|
||||
break;
|
||||
case Setup::ModifierInfill:
|
||||
// The infill inside a modifier prints with the other filament.
|
||||
override.set_key_value("sparse_infill_filament_id", new ConfigOptionInt(2));
|
||||
object->add_volume(cube(10.), ModelVolumeType::PARAMETER_MODIFIER, false)->config.apply(override);
|
||||
break;
|
||||
case Setup::ModifierInheritingObjectFilament:
|
||||
// The modifier only changes the infill density and keeps printing with the filament of the object.
|
||||
object->config.set_key_value("extruder", new ConfigOptionInt(2));
|
||||
override.set_key_value("sparse_infill_density", new ConfigOptionPercent(30));
|
||||
object->add_volume(cube(10.), ModelVolumeType::PARAMETER_MODIFIER, false)->config.apply(override);
|
||||
break;
|
||||
}
|
||||
object->add_instance()->set_offset(Vec3d(90., 90., 0.));
|
||||
object->ensure_on_bed();
|
||||
|
||||
Print print;
|
||||
print.apply(model, config);
|
||||
print.process();
|
||||
|
||||
const PrintObject &print_object = *print.objects().front();
|
||||
const BoundingBox extents = get_extents(print_object.get_layer(int(print_object.layer_count() / 2))->lslices);
|
||||
const double expected_width = object_on_filament_2 ? 20. * 100. / 90. : 20.;
|
||||
CHECK_THAT(unscale<double>(extents.size().x()), Catch::Matchers::WithinAbs(expected_width, 0.01));
|
||||
std::vector<StringObjectException> warnings;
|
||||
CHECK(print.validate(&warnings).string.empty());
|
||||
const bool mismatch_warning = std::any_of(warnings.begin(), warnings.end(), [](const StringObjectException &w) {
|
||||
return w.string.find("Filament shrinkage will not be used") != std::string::npos;
|
||||
});
|
||||
CHECK(mismatch_warning == ! object_on_filament_2);
|
||||
}
|
||||
|
||||
@@ -87,8 +87,8 @@ static std::string strip_nondeterministic_gcode_lines(const std::string& gcode)
|
||||
while (std::getline(in, line)) {
|
||||
if (line.compare(0, 15, "; generated by ") == 0) continue; // wall-clock timestamp
|
||||
if (line.compare(0, 18, "; model label id: ") == 0) continue; // ObjectID-derived
|
||||
// "; [stop] printing object <name> id:N copy M" / "... unique label id: N" (ObjectID-derived):
|
||||
if (line.find("printing object") != std::string::npos && line.find(" id:") != std::string::npos) continue;
|
||||
// "; start/stop printing object, unique label id: N" (ObjectID-derived):
|
||||
if (line.find("printing object, unique label id: ") != std::string::npos) continue;
|
||||
if (line.find("slicing_pipeline_plugin") != std::string::npos) continue; // config-dump plugin name
|
||||
out += line; out += '\n';
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
#include "libslic3r/Model.hpp"
|
||||
#include "libslic3r/Format/3mf.hpp"
|
||||
#include "libslic3r/Format/bbs_3mf.hpp"
|
||||
#include "libslic3r/GCode/ThumbnailData.hpp"
|
||||
#include "libslic3r/Format/STL.hpp"
|
||||
#include "libslic3r/Utils.hpp"
|
||||
#include "libslic3r/miniz_extension.hpp"
|
||||
@@ -35,12 +36,18 @@
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <boost/filesystem/operations.hpp>
|
||||
#include <boost/filesystem/file_status.hpp> // no_perms, owner_all
|
||||
#include <boost/nowide/fstream.hpp>
|
||||
#include <boost/algorithm/string/predicate.hpp>
|
||||
#include <algorithm>
|
||||
#include <functional>
|
||||
#include <initializer_list>
|
||||
|
||||
#include <fstream>
|
||||
#ifndef _WIN32
|
||||
#include <unistd.h> // geteuid
|
||||
#endif
|
||||
|
||||
#include <catch2/catch_tostring.hpp>
|
||||
#include <Eigen/Core>
|
||||
#include <Eigen/Geometry>
|
||||
@@ -403,6 +410,44 @@ static bool load_project(const std::string& path, Model& model, const ScopedTemp
|
||||
return loaded;
|
||||
}
|
||||
|
||||
TEST_CASE("A plate's G-code statistics are stored next to its G-code", "[3mf]")
|
||||
{
|
||||
const bool with_statistics = GENERATE(false, true);
|
||||
ScopedTemporaryFile gcode(".gcode");
|
||||
save_string_file(gcode.path(), "G1 X10 Y10 E1\n");
|
||||
|
||||
Model model;
|
||||
ModelObject* object = model.add_object();
|
||||
object->add_volume(make_cube(10., 10., 10.));
|
||||
object->add_instance();
|
||||
ScopedTemporaryDir backup_dir("orca_statistics_src");
|
||||
model.set_backup_path(backup_dir.string());
|
||||
|
||||
// A sliced plate's info reads the printer settings.
|
||||
DynamicPrintConfig cfg = DynamicPrintConfig::full_print_config();
|
||||
PlateData plate;
|
||||
plate.plate_index = 0;
|
||||
plate.gcode_file = gcode.string();
|
||||
plate.is_sliced_valid = true;
|
||||
if (with_statistics)
|
||||
plate.statistics = R"({ "version": 1 })";
|
||||
ScopedTemporaryFile file(".3mf");
|
||||
const std::string path = file.string();
|
||||
StoreParams sp;
|
||||
sp.path = path.c_str();
|
||||
sp.model = &model;
|
||||
sp.config = &cfg;
|
||||
sp.strategy = SaveStrategy::Zip64 | SaveStrategy::Silence | SaveStrategy::WithGcode;
|
||||
sp.plate_data_list.push_back(&plate);
|
||||
REQUIRE(store_bbs_3mf(sp));
|
||||
|
||||
std::string stored;
|
||||
REQUIRE(read_cad_recipe_entry(path, stored, "Metadata/plate_1.gcode"));
|
||||
CHECK(read_cad_recipe_entry(path, stored, "Metadata/plate_1.statistics.json") == with_statistics);
|
||||
if (with_statistics)
|
||||
CHECK(stored == plate.statistics);
|
||||
}
|
||||
|
||||
TEST_CASE("A project with a plate id below 1 fails to load", "[3mf][Regression]")
|
||||
{
|
||||
const int plate_id = GENERATE(0, -1);
|
||||
@@ -2245,3 +2290,269 @@ TEST_CASE("Loading a gcode.3mf from a stream keeps the plate's pick and pattern
|
||||
CHECK(dst_plates.front()->pattern_bbox_file == "Metadata/plate_1.json");
|
||||
release_PlateData_list(dst_plates);
|
||||
}
|
||||
|
||||
// A relationship must name a part the package actually contains. The writer used to fall
|
||||
// back to /Metadata/plate_1.png whenever no cover image was set, so an export that wrote no
|
||||
// plate thumbnail still declared one -- a dangling relationship a strict OPC reader may reject.
|
||||
SCENARIO("thumbnail relationships are declared only for thumbnails that were written", "[3mf]")
|
||||
{
|
||||
GIVEN("a project with one plate") {
|
||||
Model model;
|
||||
const std::string src_file = std::string(TEST_DATA_DIR) + "/test_3mf/Prusa.stl";
|
||||
REQUIRE(load_stl(src_file.c_str(), &model));
|
||||
model.add_default_instances();
|
||||
|
||||
ScopedTemporaryDir backup_dir("orca_rels");
|
||||
model.set_backup_path(backup_dir.string());
|
||||
|
||||
PlateData plate;
|
||||
plate.plate_index = 0;
|
||||
PlateDataPtrs plates{ &plate };
|
||||
|
||||
auto store = [&](const std::string &path, std::vector<ThumbnailData *> thumbnails) {
|
||||
DynamicPrintConfig cfg;
|
||||
StoreParams sp;
|
||||
sp.path = path.c_str();
|
||||
sp.model = &model;
|
||||
sp.config = &cfg;
|
||||
sp.plate_data_list = plates;
|
||||
sp.thumbnail_data = std::move(thumbnails);
|
||||
sp.strategy = SaveStrategy::Zip64 | SaveStrategy::Silence;
|
||||
REQUIRE(store_bbs_3mf(sp));
|
||||
};
|
||||
|
||||
WHEN("no thumbnail is rendered") {
|
||||
ScopedTemporaryFile temp(".3mf");
|
||||
ThumbnailData none; // left invalid: nothing was rendered
|
||||
REQUIRE_FALSE(none.is_valid());
|
||||
store(temp.string(), { &none });
|
||||
|
||||
THEN("the package declares no thumbnail relationship") {
|
||||
std::string rels;
|
||||
REQUIRE(read_cad_recipe_entry(temp.string(), rels, "_rels/.rels"));
|
||||
CHECK(rels.find("3dmodel") != std::string::npos); // the model is still declared
|
||||
CHECK(rels.find("plate_1.png") == std::string::npos);
|
||||
CHECK(rels.find("thumbnail") == std::string::npos);
|
||||
}
|
||||
}
|
||||
|
||||
WHEN("a plate thumbnail is rendered") {
|
||||
ScopedTemporaryFile temp(".3mf");
|
||||
ThumbnailData rendered;
|
||||
rendered.set(16, 16);
|
||||
rendered.pixels.assign(16 * 16 * 4, static_cast<unsigned char>(0xff));
|
||||
REQUIRE(rendered.is_valid());
|
||||
store(temp.string(), { &rendered });
|
||||
|
||||
THEN("the relationships name it, and the parts are in the package") {
|
||||
std::string rels;
|
||||
REQUIRE(read_cad_recipe_entry(temp.string(), rels, "_rels/.rels"));
|
||||
CHECK(rels.find("/Metadata/plate_1.png") != std::string::npos);
|
||||
CHECK(rels.find("/Metadata/plate_1_small.png") != std::string::npos);
|
||||
|
||||
std::string part;
|
||||
CHECK(read_cad_recipe_entry(temp.string(), part, "Metadata/plate_1.png"));
|
||||
CHECK(read_cad_recipe_entry(temp.string(), part, "Metadata/plate_1_small.png"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// has_restore_data() runs from the queued EVT_RESTORE_PROJECT handler, whose try starts after
|
||||
// the call, so a filesystem_error from probing the stored backup path aborts the application.
|
||||
// The realistic trigger is an absolute path from another user's config whose home is not
|
||||
// searchable, so the parent, not the backup directory, is what blocks.
|
||||
#ifndef _WIN32
|
||||
TEST_CASE("has_restore_data reports no restore data for a path it cannot probe", "[3mf]") {
|
||||
if (::geteuid() == 0)
|
||||
SKIP("root ignores the directory permissions this test depends on");
|
||||
|
||||
ScopedTemporaryDir outer("orca_restore");
|
||||
boost::filesystem::path backup = outer.path() / "backup";
|
||||
REQUIRE(boost::filesystem::create_directory(backup));
|
||||
std::ofstream((backup / ".3mf").string()) << "stand-in for the backed-up project";
|
||||
std::ofstream((backup / "origin.txt").string()) << "project.3mf";
|
||||
|
||||
// Control: while the path is readable the session is found, so the false below really is
|
||||
// the probe failing and not a mis-built fixture.
|
||||
std::string readable = backup.string(), origin;
|
||||
REQUIRE(has_restore_data(readable, origin));
|
||||
REQUIRE(readable == (backup / ".3mf").string());
|
||||
REQUIRE(origin == "project.3mf");
|
||||
|
||||
// Puts the permissions back before ~ScopedTemporaryDir needs them to clean up, and does so
|
||||
// even if an assertion below throws.
|
||||
struct Searchable {
|
||||
boost::filesystem::path dir;
|
||||
~Searchable() { boost::system::error_code ec;
|
||||
boost::filesystem::permissions(dir, boost::filesystem::owner_all, ec); }
|
||||
} restore{ outer.path() };
|
||||
|
||||
// Non-throwing here too: a filesystem that refuses the mode change has to reach the skip
|
||||
// below rather than fail the test. The probe then still succeeds, which is that same case.
|
||||
boost::system::error_code probe_ec;
|
||||
boost::filesystem::permissions(outer.path(), boost::filesystem::no_perms, probe_ec);
|
||||
if (boost::filesystem::exists(backup / ".3mf", probe_ec) || !probe_ec)
|
||||
SKIP("the backup path could not be made unprobeable here");
|
||||
|
||||
std::string unprobeable = backup.string(), origin2;
|
||||
bool has = true;
|
||||
REQUIRE_NOTHROW(has = has_restore_data(unprobeable, origin2));
|
||||
CHECK_FALSE(has);
|
||||
CHECK(unprobeable == backup.string()); // not rewritten to the .3mf it could not confirm
|
||||
CHECK(origin2 == "<lock>"); // and the caller is told not to remove it
|
||||
}
|
||||
#endif // _WIN32
|
||||
|
||||
// Every "return false" that leaves origin empty is an instruction to the caller to
|
||||
// remove_all() the backup directory (Plater.cpp's EVT_RESTORE_PROJECT handler), so the
|
||||
// distinction these cases draw is between a backup that is spent and one whose state could
|
||||
// not be established. Only the first may be collected.
|
||||
TEST_CASE("has_restore_data claims the lock when lock.txt cannot be interpreted", "[3mf]") {
|
||||
ScopedTemporaryDir outer("orca_lock");
|
||||
boost::filesystem::path backup = outer.path() / "backup";
|
||||
REQUIRE(boost::filesystem::create_directory(backup));
|
||||
std::ofstream((backup / ".3mf").string()) << "stand-in for the backed-up project";
|
||||
|
||||
SECTION("an empty lock.txt holds no pid") {
|
||||
std::ofstream lock((backup / "lock.txt").string());
|
||||
lock.close(); // created, left zero length
|
||||
REQUIRE(boost::filesystem::file_size(backup / "lock.txt") == 0);
|
||||
|
||||
std::string path = backup.string(), origin;
|
||||
bool has = true;
|
||||
REQUIRE_NOTHROW(has = has_restore_data(path, origin));
|
||||
CHECK_FALSE(has);
|
||||
CHECK(origin == "<lock>");
|
||||
}
|
||||
|
||||
SECTION("lock.txt holds something that is not a pid") {
|
||||
std::ofstream((backup / "lock.txt").string()) << "not-a-pid";
|
||||
|
||||
std::string path = backup.string(), origin;
|
||||
bool has = true;
|
||||
REQUIRE_NOTHROW(has = has_restore_data(path, origin));
|
||||
CHECK_FALSE(has);
|
||||
CHECK(origin == "<lock>");
|
||||
}
|
||||
}
|
||||
|
||||
#ifndef _WIN32
|
||||
TEST_CASE("has_restore_data claims the lock when lock.txt cannot be read", "[3mf]") {
|
||||
if (::geteuid() == 0)
|
||||
SKIP("root ignores the file permissions this test depends on");
|
||||
|
||||
ScopedTemporaryDir outer("orca_lock");
|
||||
boost::filesystem::path backup = outer.path() / "backup";
|
||||
REQUIRE(boost::filesystem::create_directory(backup));
|
||||
std::ofstream((backup / ".3mf").string()) << "stand-in for the backed-up project";
|
||||
std::ofstream((backup / "lock.txt").string()) << "1";
|
||||
boost::system::error_code mode_ec; // a filesystem that refuses the mode change must skip below
|
||||
boost::filesystem::permissions(backup / "lock.txt", boost::filesystem::no_perms, mode_ec);
|
||||
|
||||
boost::nowide::ifstream probe((backup / "lock.txt").string());
|
||||
if (probe.good())
|
||||
SKIP("this filesystem does not enforce file read permissions");
|
||||
|
||||
// load_string_file() throws here.
|
||||
std::string path = backup.string(), origin;
|
||||
bool has = true;
|
||||
REQUIRE_NOTHROW(has = has_restore_data(path, origin));
|
||||
CHECK_FALSE(has);
|
||||
CHECK(origin == "<lock>");
|
||||
}
|
||||
#endif // _WIN32
|
||||
|
||||
// The negative control for the three cases above: claiming the lock whenever anything is
|
||||
// amiss would strand every spent backup on disk forever, so a readable directory that
|
||||
// simply has nothing to restore must still come back collectable.
|
||||
TEST_CASE("has_restore_data leaves a spent backup collectable", "[3mf]") {
|
||||
ScopedTemporaryDir outer("orca_spent");
|
||||
boost::filesystem::path backup = outer.path() / "backup";
|
||||
REQUIRE(boost::filesystem::create_directory(backup));
|
||||
|
||||
SECTION("no lock and no project") {
|
||||
std::string path = backup.string(), origin;
|
||||
CHECK_FALSE(has_restore_data(path, origin));
|
||||
CHECK(origin.empty());
|
||||
}
|
||||
|
||||
SECTION("a lock from a process that is not us, and no project") {
|
||||
// get_process_name() compares executable names and answers empty for a pid it cannot
|
||||
// read, so pid 1 never matches this process and the lock must not be claimed.
|
||||
std::ofstream((backup / "lock.txt").string()) << "1";
|
||||
|
||||
std::string path = backup.string(), origin;
|
||||
CHECK_FALSE(has_restore_data(path, origin));
|
||||
CHECK(origin.empty());
|
||||
}
|
||||
|
||||
SECTION("the directory does not exist at all") {
|
||||
std::string path = (outer.path() / "gone").string(), origin;
|
||||
CHECK_FALSE(has_restore_data(path, origin));
|
||||
CHECK(origin.empty());
|
||||
}
|
||||
}
|
||||
|
||||
// The no-light pass must not mark the plate thumbnail as written.
|
||||
SCENARIO("a rendered no-light thumbnail does not suppress the plate thumbnail", "[3mf]")
|
||||
{
|
||||
GIVEN("a plate whose no-light thumbnail is rendered and whose plate thumbnail is only on disk") {
|
||||
Model model;
|
||||
const std::string src_file = std::string(TEST_DATA_DIR) + "/test_3mf/Prusa.stl";
|
||||
REQUIRE(load_stl(src_file.c_str(), &model));
|
||||
model.add_default_instances();
|
||||
|
||||
ScopedTemporaryDir backup_dir("orca_no_light");
|
||||
model.set_backup_path(backup_dir.string());
|
||||
|
||||
// Only the bytes matter: the exporter copies this file into the archive verbatim.
|
||||
ScopedTemporaryFile png(".png");
|
||||
const std::string png_path = png.string();
|
||||
const std::string png_body = "\x89PNG\r\n\x1a\n" "plate-1-thumbnail-from-disk";
|
||||
{
|
||||
boost::nowide::ofstream out(png_path.c_str(), std::ios::binary);
|
||||
out << png_body;
|
||||
}
|
||||
REQUIRE(boost::filesystem::exists(png_path));
|
||||
|
||||
PlateData plate;
|
||||
plate.plate_index = 0;
|
||||
plate.thumbnail_file = png_path;
|
||||
PlateDataPtrs plates{ &plate };
|
||||
|
||||
ThumbnailData plate_thumbnail; // nothing was rendered for the plate itself
|
||||
ThumbnailData no_light_thumbnail;
|
||||
no_light_thumbnail.set(16, 16);
|
||||
no_light_thumbnail.pixels.assign(16 * 16 * 4, static_cast<unsigned char>(0xff));
|
||||
REQUIRE_FALSE(plate_thumbnail.is_valid());
|
||||
REQUIRE(no_light_thumbnail.is_valid());
|
||||
|
||||
WHEN("the project is written") {
|
||||
ScopedTemporaryFile temp(".3mf");
|
||||
const std::string test_file = temp.string();
|
||||
|
||||
DynamicPrintConfig cfg;
|
||||
StoreParams sp;
|
||||
sp.path = test_file.c_str();
|
||||
sp.model = &model;
|
||||
sp.config = &cfg;
|
||||
sp.plate_data_list = plates;
|
||||
sp.thumbnail_data = { &plate_thumbnail };
|
||||
sp.no_light_thumbnail_data = { &no_light_thumbnail };
|
||||
sp.strategy = SaveStrategy::Zip64 | SaveStrategy::Silence;
|
||||
REQUIRE(store_bbs_3mf(sp));
|
||||
|
||||
THEN("the plate thumbnail is still copied in from disk") {
|
||||
std::string got;
|
||||
REQUIRE(read_cad_recipe_entry(test_file, got, "Metadata/plate_1.png"));
|
||||
REQUIRE(got == png_body);
|
||||
}
|
||||
|
||||
THEN("the rendered no-light thumbnail is written as well") {
|
||||
std::string got;
|
||||
REQUIRE(read_cad_recipe_entry(test_file, got, "Metadata/plate_no_light_1.png"));
|
||||
REQUIRE_FALSE(got.empty());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
#include <catch2/catch_test_macros.hpp>
|
||||
#include <catch2/generators/catch_generators.hpp>
|
||||
#include "libslic3r/AppConfig.hpp"
|
||||
#include "libslic3r/Thread.hpp"
|
||||
#include "../slic3rutils/plugin_test_utils.hpp"
|
||||
|
||||
using namespace Slic3r;
|
||||
|
||||
@@ -46,6 +48,105 @@ TEST_CASE("AppConfig network version helpers", "[AppConfig]") {
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("Local machine agent replacements survive save and reload", "[AppConfig]")
|
||||
{
|
||||
ScopedDataDir data_dir("local-machine");
|
||||
save_main_thread_id();
|
||||
AppConfig config;
|
||||
BBLocalMachine saved;
|
||||
saved.dev_id = "127.0.0.1:7125";
|
||||
saved.dev_ip = saved.dev_id;
|
||||
saved.dev_name = "Test printer";
|
||||
saved.printer_type = "test-model";
|
||||
saved.printer_agent_id = "orca";
|
||||
saved.access_code = "old-key";
|
||||
config.update_local_machine(saved);
|
||||
config.save();
|
||||
REQUIRE_FALSE(config.dirty());
|
||||
|
||||
BBLocalMachine replacement = saved;
|
||||
replacement.printer_agent_id = "moonraker";
|
||||
replacement.access_code = GENERATE("", "new-key", "88888888");
|
||||
config.update_local_machine(replacement);
|
||||
REQUIRE(config.dirty());
|
||||
REQUIRE(config.get_local_machines().at(saved.dev_id) == replacement);
|
||||
config.save();
|
||||
|
||||
AppConfig reloaded;
|
||||
REQUIRE(reloaded.load().empty());
|
||||
REQUIRE(reloaded.get_local_machines().at(saved.dev_id) == replacement);
|
||||
REQUIRE_FALSE(reloaded.dirty());
|
||||
reloaded.update_local_machine(replacement);
|
||||
REQUIRE_FALSE(reloaded.dirty());
|
||||
}
|
||||
|
||||
TEST_CASE("Clearing local credentials preserves another agent's record", "[AppConfig]")
|
||||
{
|
||||
AppConfig config;
|
||||
BBLocalMachine saved;
|
||||
saved.dev_id = "test_dev";
|
||||
saved.printer_agent_id = GENERATE("orca", "qidi", "bbl", "");
|
||||
saved.access_code = "saved-key";
|
||||
config.update_local_machine(saved);
|
||||
config.set_str("access_code", saved.dev_id, "legacy-key");
|
||||
config.set_str("user_access_code", saved.dev_id, "legacy-user-key");
|
||||
|
||||
config.clear_local_machine_access_code(saved.dev_id, "moonraker");
|
||||
|
||||
REQUIRE(config.get_local_machines().at(saved.dev_id) == saved);
|
||||
REQUIRE(config.get("access_code", saved.dev_id) == "legacy-key");
|
||||
REQUIRE(config.get("user_access_code", saved.dev_id) == "legacy-user-key");
|
||||
}
|
||||
|
||||
TEST_CASE("Clearing local credentials changes only the owning agent's key", "[AppConfig]")
|
||||
{
|
||||
ScopedDataDir data_dir("local-machine-clear");
|
||||
save_main_thread_id();
|
||||
AppConfig config;
|
||||
BBLocalMachine saved;
|
||||
saved.dev_id = "test_dev";
|
||||
saved.dev_name = "Test printer";
|
||||
saved.printer_agent_id = GENERATE("moonraker", "qidi");
|
||||
saved.access_code = "saved-key";
|
||||
config.update_local_machine(saved);
|
||||
config.save();
|
||||
REQUIRE_FALSE(config.dirty());
|
||||
|
||||
config.clear_local_machine_access_code(saved.dev_id, saved.printer_agent_id);
|
||||
REQUIRE(config.dirty());
|
||||
config.save();
|
||||
|
||||
AppConfig reloaded;
|
||||
REQUIRE(reloaded.load().empty());
|
||||
saved.access_code.clear();
|
||||
REQUIRE(reloaded.get_local_machines().at(saved.dev_id) == saved);
|
||||
REQUIRE_FALSE(reloaded.dirty());
|
||||
reloaded.clear_local_machine_access_code(saved.dev_id, saved.printer_agent_id);
|
||||
reloaded.clear_local_machine_access_code("unknown_dev", saved.printer_agent_id);
|
||||
REQUIRE(reloaded.get_local_machines().size() == 1);
|
||||
REQUIRE_FALSE(reloaded.dirty());
|
||||
}
|
||||
|
||||
TEST_CASE("BBL credential clearing removes legacy fallback without changing foreign records", "[AppConfig]")
|
||||
{
|
||||
AppConfig config;
|
||||
BBLocalMachine saved;
|
||||
saved.dev_id = "test_dev";
|
||||
saved.printer_agent_id = GENERATE("bbl", "", "moonraker");
|
||||
saved.access_code = "saved-key";
|
||||
config.update_local_machine(saved);
|
||||
config.set_str("access_code", saved.dev_id, "legacy-key");
|
||||
config.set_str("user_access_code", saved.dev_id, "legacy-user-key");
|
||||
|
||||
config.clear_local_machine_access_code(saved.dev_id, GENERATE("bbl", ""));
|
||||
|
||||
if (saved.printer_agent_id != "moonraker")
|
||||
saved.access_code.clear();
|
||||
REQUIRE(config.get_local_machines().at(saved.dev_id) == saved);
|
||||
REQUIRE(config.get("access_code", saved.dev_id).empty());
|
||||
REQUIRE(config.get("user_access_code", saved.dev_id).empty());
|
||||
}
|
||||
|
||||
TEST_CASE("Remembered checkbox settings retain both selections", "[AppConfig][Regression]") {
|
||||
AppConfig config;
|
||||
const bool checked = GENERATE(false, true);
|
||||
|
||||
@@ -261,17 +261,67 @@ TEST_CASE("Arrange without final alignment keeps items disjoint", "[Arrange]")
|
||||
require_no_overlap(items);
|
||||
}
|
||||
|
||||
TEST_CASE("Arrange aligns the pile to a custom center", "[Arrange]")
|
||||
TEST_CASE("Arrange centers a pile that fits on the custom alignment point", "[Arrange]")
|
||||
{
|
||||
// align_center != (0.5, 0.5) selects Alignment::USER_DEFINED.
|
||||
ArrangePolygons items = squares(5, 30.);
|
||||
ArrangePolygons items = squares(4, 30.);
|
||||
ArrangeParams params = quiet_params(scaled(2.));
|
||||
params.align_center = Vec2d(0.3, 0.7);
|
||||
|
||||
arrange(items, bed(250, 250), params);
|
||||
|
||||
for (const ArrangePolygon &ap : items)
|
||||
BoundingBox pile;
|
||||
for (const ArrangePolygon &ap : items) {
|
||||
REQUIRE(ap.bed_idx == 0);
|
||||
pile.merge(ap.transformed_poly().contour.bounding_box());
|
||||
}
|
||||
// best_object_pos is align_center scaled to the bed.
|
||||
const Point expected(scaled(250. * 0.3), scaled(250. * 0.7));
|
||||
REQUIRE(std::abs(pile.center().x() - expected.x()) <= scaled(0.5));
|
||||
REQUIRE(std::abs(pile.center().y() - expected.y()) <= scaled(0.5));
|
||||
require_no_overlap(items);
|
||||
}
|
||||
|
||||
// A row of four 40 mm parts centred on the 30% mark of a 200 x 50 mm bed would start 24 mm
|
||||
// left of it (#15727). The pile stops at the left edge instead.
|
||||
TEST_CASE("Arrange keeps a custom-aligned pile within the bed", "[Arrange]")
|
||||
{
|
||||
const BoundingBox bed_ = bed(200, 50);
|
||||
ArrangePolygons items = squares(4, 40.);
|
||||
ArrangeParams params = quiet_params(scaled(2.));
|
||||
params.align_center = Vec2d(0.3, 0.5);
|
||||
|
||||
arrange(items, bed_, params);
|
||||
|
||||
coord_t leftmost = std::numeric_limits<coord_t>::max();
|
||||
for (const ArrangePolygon &ap : items) {
|
||||
REQUIRE(ap.bed_idx == 0);
|
||||
const BoundingBox bb = ap.transformed_poly().contour.bounding_box();
|
||||
CHECK(bed_.contains(bb));
|
||||
leftmost = std::min(leftmost, bb.min.x());
|
||||
}
|
||||
// Stopped at the edge it was aimed at, not re-centred.
|
||||
CHECK(leftmost < scaled(10.));
|
||||
require_no_overlap(items);
|
||||
}
|
||||
|
||||
// Arranging a selection leaves the unselected parts where they are, but the final alignment
|
||||
// centres the box around both, so the selection does not move by its own centring. The check
|
||||
// against the bed and the fixed part must test where the selection actually lands.
|
||||
TEST_CASE("Arrange keeps a selection on the bed and clear of a fixed part", "[Arrange]")
|
||||
{
|
||||
ArrangePolygons fixed = squares(1, 40.);
|
||||
fixed.front().translation = {scaled(80.), scaled(80.)};
|
||||
ArrangePolygons items = squares(3, 40.);
|
||||
for (ArrangePolygon &ap : items)
|
||||
ap.inflation = scaled(1.);
|
||||
|
||||
arrange(items, fixed, bed(200, 200), quiet_params());
|
||||
|
||||
for (const ArrangePolygon &ap : items) {
|
||||
REQUIRE(ap.bed_idx == 0);
|
||||
REQUIRE(bed(200, 200).contains(ap.transformed_poly().contour.bounding_box()));
|
||||
}
|
||||
items.insert(items.end(), fixed.begin(), fixed.end());
|
||||
require_no_overlap(items);
|
||||
}
|
||||
|
||||
@@ -303,10 +353,9 @@ TEST_CASE("Arrange keeps a pile aligned near an edge on the bed", "[Arrange][bel
|
||||
require_no_overlap(items);
|
||||
}
|
||||
|
||||
// The clamp is a belt feature. Printers whose best_object_pos is off-centre (the A1 mini
|
||||
// and the H2 family) keep their final alignment: the pile is centred on that point, even
|
||||
// when that puts part of it outside the bed.
|
||||
TEST_CASE("Arrange leaves the final alignment of a flat bed unclamped", "[Arrange]")
|
||||
// Flat-bed printers with an off-centre best_object_pos (the A1 mini and the H2 family) get
|
||||
// the same clamp: centring the pile on that point used to push it off the bed (#15727).
|
||||
TEST_CASE("Arrange keeps a flat-bed pile aligned near an edge on the bed", "[Arrange]")
|
||||
{
|
||||
const BoundingBox bed_ = bed(95, 500);
|
||||
ArrangePolygons items = squares(4, 90.);
|
||||
@@ -315,14 +364,15 @@ TEST_CASE("Arrange leaves the final alignment of a flat bed unclamped", "[Arrang
|
||||
|
||||
arrange(items, bed_, params);
|
||||
|
||||
BoundingBox pile;
|
||||
coord_t lowest = std::numeric_limits<coord_t>::max();
|
||||
for (const ArrangePolygon &ap : items) {
|
||||
REQUIRE(ap.bed_idx == 0);
|
||||
pile.merge(ap.transformed_poly().contour.bounding_box());
|
||||
const BoundingBox bb = ap.transformed_poly().contour.bounding_box();
|
||||
CHECK(bed_.contains(bb));
|
||||
lowest = std::min(lowest, bb.min.y());
|
||||
}
|
||||
// Centred on the 5% mark of the bed's length, not pushed inside it.
|
||||
CHECK_THAT(unscaled<double>(pile.center().y()), Catch::Matchers::WithinAbs(0.05 * 500., 15.));
|
||||
CHECK(pile.min.y() < 0);
|
||||
// Stopped at the edge it was aimed at, not re-centred.
|
||||
CHECK(lowest < scaled(10.));
|
||||
require_no_overlap(items);
|
||||
}
|
||||
|
||||
|
||||
@@ -283,7 +283,7 @@ TEST_CASE("A Span mode refuses a color routed to a tool outside the primary's ga
|
||||
auto pem = make_pem({0, 1, 2, 3});
|
||||
const std::string reason = imex_multicolor_block_reason("mc-copy", "0:P,1:S,2:C,3:C", 2, {0, 1, 3}, pem);
|
||||
// The message names the color tools and where the map sends the offending filament (1-based).
|
||||
REQUIRE_THAT(reason, Catch::Matchers::ContainsSubstring("only T0/T1 print colors, but filament 4 is routed to T3"));
|
||||
REQUIRE_THAT(reason, Catch::Matchers::ContainsSubstring("colors are printed only by T0/T1, but filament 4 is routed to T3"));
|
||||
REQUIRE_THAT(imex_multicolor_block_reason("mc-copy", "0:P,1:S,2:C,3:C", 2, {0, 4}, pem),
|
||||
Catch::Matchers::ContainsSubstring("filament 5 isn't routed to any tool"));
|
||||
// A head the mode leaves inactive is no better, and neither is no head at all.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -133,6 +133,28 @@ TEST_CASE("Type1 sizes the tower from each filament's own prime volume", "[WipeT
|
||||
CHECK_THAT(rib.depth, WithinAbs(WipeTower::estimate_rib_tower_bbox_side({{30.f, 0}, {45.f, 0}}, 35.f, 0.21f, 0.4f, 1.5f, 8.f, 0.f, 5.f), 1e-4));
|
||||
}
|
||||
|
||||
TEST_CASE("A mixed slot is purged as its physical components", "[WipeTowerEstimate]") {
|
||||
// Slots 4 and 5 blend 1+2 and 1+3: every layer prints one component per mixed slot, so a
|
||||
// plate using all five slots purges the three physical filaments and nothing more.
|
||||
const WipeTowerType type = GENERATE(WipeTowerType::Type1, WipeTowerType::Type2);
|
||||
DynamicPrintConfig config = make_config();
|
||||
config.set_key_value("filament_prime_volume", new ConfigOptionFloats({100., 100., 100., 100., 100.}));
|
||||
config.set_key_value("filament_adhesiveness_category", new ConfigOptionInts({0, 0, 0, 0, 0}));
|
||||
config.set_key_value("filament_is_mixed", new ConfigOptionBools(std::vector<unsigned char>{0, 0, 0, 1, 1}));
|
||||
config.set_key_value("filament_mixed_components", new ConfigOptionStrings({"", "", "", "1,2", "1,3"}));
|
||||
const double physical = estimate(config, 3, 0.2, 5., type).depth;
|
||||
REQUIRE(physical > estimate(config, 2, 0.2, 5., type).depth);
|
||||
CHECK_THAT(estimate(config, 5, 0.2, 5., type).depth, WithinAbs(physical, 1e-9));
|
||||
// The same slots with the mixed flags cleared are five filaments, which is what sized the
|
||||
// tower before and refused positions the printed tower fits.
|
||||
DynamicPrintConfig unmixed = config;
|
||||
unmixed.set_key_value("filament_is_mixed", new ConfigOptionBools(std::vector<unsigned char>{0, 0, 0, 0, 0}));
|
||||
CHECK(estimate(unmixed, 5, 0.2, 5., type).depth > physical);
|
||||
// Mixed slots alone still purge their components, and a single one already changes tools.
|
||||
CHECK_THAT(estimate_wipe_tower_footprint(config, type, {3, 4}, 0.2, 5.).depth, WithinAbs(physical, 1e-9));
|
||||
CHECK_THAT(estimate_wipe_tower_footprint(config, type, {3}, 0.2, 5.).depth, WithinAbs(estimate(config, 2, 0.2, 5., type).depth, 1e-9));
|
||||
}
|
||||
|
||||
TEST_CASE("A second nozzle adds the ramming of one nozzle change per layer", "[WipeTowerEstimate]") {
|
||||
// Two filaments on two nozzles: the tool order crosses once per layer, and Type1 rams 10 mm
|
||||
// of filament as three 1.0 mm nozzle-change lines (see the WipeTower case).
|
||||
|
||||
@@ -6,7 +6,12 @@ add_executable(${_TEST_NAME}_tests
|
||||
test_creality_cfs_match.cpp
|
||||
test_dev_mapping.cpp
|
||||
test_dev_extder_system.cpp
|
||||
test_device_access.cpp
|
||||
test_moonraker_discovery.cpp
|
||||
test_network_discovery.cpp
|
||||
test_filament_bitmap_utils.cpp
|
||||
test_colour_picker_field.cpp
|
||||
test_filament_color_picker.cpp
|
||||
test_device_progress.cpp
|
||||
test_device_manager_integration.cpp
|
||||
test_web_media_controller.cpp
|
||||
|
||||
@@ -255,7 +255,7 @@ TEST_CASE("Native command catalog includes the Help and developer-mode commands"
|
||||
|
||||
const Slic3r::GUI::NativeCommand* first = find("help_keyboard_shortcuts");
|
||||
REQUIRE(first != nullptr);
|
||||
for (const char* key : {"help_setup_wizard", "help_open_config_folder", "help_troubleshoot", "help_network_test",
|
||||
for (const char* key : {"help_setup_wizard", "help_open_config_folder", "help_migrate_filament_presets", "help_troubleshoot", "help_network_test",
|
||||
"help_benchmark_3d_scene", "help_tip_of_the_day", "help_check_updates", "help_about", "open_wiki",
|
||||
"open_youtube"}) {
|
||||
const Slic3r::GUI::NativeCommand* c = find(key);
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
#include "slic3r/GUI/Field.hpp"
|
||||
#include "libslic3r/Config.hpp"
|
||||
#include <boost/any.hpp>
|
||||
#include <catch2/catch_test_macros.hpp>
|
||||
#include <string>
|
||||
#include <wx/colour.h>
|
||||
#include <wx/string.h>
|
||||
|
||||
using namespace Slic3r;
|
||||
using namespace Slic3r::GUI;
|
||||
|
||||
namespace {
|
||||
class TestColourPicker : public ColourPicker {
|
||||
public:
|
||||
TestColourPicker() : ColourPicker(ConfigOptionDef{}, "filament_colour") {}
|
||||
std::string value() { return boost::any_cast<std::string>(get_value()); }
|
||||
};
|
||||
}
|
||||
|
||||
TEST_CASE("Undefined color fields remain distinct from opaque black", "[ColourPickerField]")
|
||||
{
|
||||
TestColourPicker field;
|
||||
REQUIRE(field.value().empty());
|
||||
field.set_value(std::string("#000000"));
|
||||
REQUIRE(field.value() == "#000000");
|
||||
field.set_value(std::string("invalid color"));
|
||||
REQUIRE(field.value().empty());
|
||||
field.set_value(boost::any(wxString("#123456")));
|
||||
REQUIRE(field.value() == "#123456");
|
||||
field.set_value(std::string());
|
||||
REQUIRE(field.value().empty());
|
||||
}
|
||||
|
||||
TEST_CASE("Programmatic color field assignment stays silent", "[ColourPickerField]")
|
||||
{
|
||||
TestColourPicker field;
|
||||
int changes = 0;
|
||||
field.m_on_change = [&](const std::string&, const boost::any&) { ++changes; };
|
||||
field.set_value(std::string("#123456"), false);
|
||||
field.set_value(boost::any(wxString("#654321")), true);
|
||||
REQUIRE(changes == 0);
|
||||
REQUIRE(field.value() == "#654321");
|
||||
field.m_disable_change_event = true;
|
||||
field.set_value(std::string("#FF0000"));
|
||||
REQUIRE(field.value() == "#FF0000");
|
||||
REQUIRE(changes == 0);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
#ifdef WIN32
|
||||
#ifndef WIN32_LEAN_AND_MEAN
|
||||
#define WIN32_LEAN_AND_MEAN
|
||||
#endif
|
||||
#ifndef NOMINMAX
|
||||
#define NOMINMAX
|
||||
#endif
|
||||
#include <Windows.h>
|
||||
#endif
|
||||
|
||||
#include <catch2/catch_test_macros.hpp>
|
||||
#include <catch2/generators/catch_generators.hpp>
|
||||
#include <memory>
|
||||
|
||||
#include "libslic3r/AppConfig.hpp"
|
||||
#include "slic3r/GUI/DeviceCore/DevManager.h"
|
||||
#include "slic3r/GUI/DeviceManager.hpp"
|
||||
#include "slic3r/Utils/IPrinterAgent.hpp"
|
||||
#include "slic3r/Utils/NetworkAgentFactory.hpp"
|
||||
#include "slic3r/Utils/MoonrakerPrinterAgent.hpp"
|
||||
#include "slic3r/Utils/NetworkAgent.hpp"
|
||||
|
||||
using namespace Slic3r;
|
||||
|
||||
TEST_CASE("Moonraker permits an empty access code", "[DeviceAccess]")
|
||||
{
|
||||
MachineObject machine(nullptr, nullptr, "test", "test_dev", "127.0.0.1");
|
||||
machine.printer_agent_id = MOONRAKER_PRINTER_AGENT_ID;
|
||||
machine.set_access_code("", false);
|
||||
|
||||
REQUIRE(machine.has_access_right());
|
||||
}
|
||||
|
||||
TEST_CASE("Moonraker logout revokes access until explicitly rebound", "[DeviceAccess]")
|
||||
{
|
||||
MachineObject machine(nullptr, nullptr, "test", "test_dev", "127.0.0.1");
|
||||
machine.printer_agent_id = MOONRAKER_PRINTER_AGENT_ID;
|
||||
const std::string code = GENERATE("", "configured-key");
|
||||
machine.set_access_code(code, false);
|
||||
REQUIRE(machine.has_access_right());
|
||||
|
||||
machine.revoke_access();
|
||||
REQUIRE(machine.get_access_code().empty());
|
||||
REQUIRE_FALSE(machine.has_access_right());
|
||||
|
||||
machine.set_access_code(code, false);
|
||||
REQUIRE(machine.has_access_right());
|
||||
REQUIRE(machine.get_access_code() == code);
|
||||
}
|
||||
|
||||
TEST_CASE("Revoked access prevents calls to the printer agent", "[DeviceAccess]")
|
||||
{
|
||||
class RecordingAgent : public MoonrakerPrinterAgent
|
||||
{
|
||||
public:
|
||||
RecordingAgent() : MoonrakerPrinterAgent("") {}
|
||||
int connections = 0;
|
||||
int connect_printer(const PrinterConnectionParams&) override
|
||||
{
|
||||
++connections;
|
||||
return 0;
|
||||
}
|
||||
int disconnect_printer() override { return 0; }
|
||||
};
|
||||
|
||||
auto agent = std::make_shared<RecordingAgent>();
|
||||
NetworkAgent network(nullptr, agent);
|
||||
MachineObject machine(nullptr, &network, "test", "test_dev", "127.0.0.1");
|
||||
machine.printer_agent_id = GENERATE(BBL_PRINTER_AGENT_ID, ORCA_PRINTER_AGENT_ID, MOONRAKER_PRINTER_AGENT_ID);
|
||||
machine.set_access_code("configured-key", false);
|
||||
REQUIRE(machine.connect() == 0);
|
||||
REQUIRE(agent->connections == 1);
|
||||
|
||||
machine.revoke_access();
|
||||
REQUIRE(machine.connect() == -1);
|
||||
REQUIRE(agent->connections == 1);
|
||||
|
||||
machine.set_access_code("configured-key", false);
|
||||
REQUIRE(machine.connect() == 0);
|
||||
REQUIRE(agent->connections == 2);
|
||||
}
|
||||
|
||||
TEST_CASE("Other printer agents require an access code", "[DeviceAccess]")
|
||||
{
|
||||
MachineObject machine(nullptr, nullptr, "test", "test_dev", "127.0.0.1");
|
||||
machine.printer_agent_id = GENERATE(
|
||||
BBL_PRINTER_AGENT_ID, ORCA_PRINTER_AGENT_ID, "qidi", "snapmaker", "crealityprint", "plugin", "unknown");
|
||||
|
||||
machine.set_access_code("", false);
|
||||
REQUIRE_FALSE(machine.has_access_right());
|
||||
|
||||
machine.set_access_code("88888888", false);
|
||||
REQUIRE(machine.has_access_right());
|
||||
}
|
||||
|
||||
TEST_CASE("Rebinding Moonraker with an empty key saves the machine", "[DeviceAccess]")
|
||||
{
|
||||
AppConfig config;
|
||||
DeviceManager manager(nullptr, false, &config);
|
||||
MachineObject machine(&manager, nullptr, "test", "test_dev", "127.0.0.1");
|
||||
machine.printer_agent_id = MOONRAKER_PRINTER_AGENT_ID;
|
||||
machine.dev_connection_type = "lan";
|
||||
machine.revoke_access();
|
||||
|
||||
machine.set_access_code("");
|
||||
const auto& machines = config.get_local_machines();
|
||||
REQUIRE(machines.count("test_dev") == 1);
|
||||
REQUIRE(machines.at("test_dev").printer_agent_id == MOONRAKER_PRINTER_AGENT_ID);
|
||||
REQUIRE(machines.at("test_dev").access_code.empty());
|
||||
}
|
||||
|
||||
TEST_CASE("Moonraker discovery leaves another agent's saved machine alone", "[DeviceAccess]")
|
||||
{
|
||||
AppConfig config;
|
||||
NetworkAgent network(nullptr, std::make_shared<MoonrakerPrinterAgent>(""));
|
||||
DeviceManager manager(&network, false, &config);
|
||||
|
||||
const std::string owner = GENERATE(as<std::string>{}, ORCA_PRINTER_AGENT_ID, MOONRAKER_PRINTER_AGENT_ID);
|
||||
BBLocalMachine saved;
|
||||
saved.dev_id = "192.0.2.10:7125";
|
||||
saved.dev_ip = saved.dev_id;
|
||||
saved.printer_agent_id = owner;
|
||||
config.update_local_machine(saved);
|
||||
|
||||
manager.on_machine_alive(R"({"dev_name":"Klipper","dev_id":"192.0.2.10:7125","dev_ip":"192.0.2.10:7125",
|
||||
"dev_type":"","dev_signal":"0","connect_type":"lan","bind_state":"free"})");
|
||||
REQUIRE((manager.get_local_machine(saved.dev_id) != nullptr) == (owner == MOONRAKER_PRINTER_AGENT_ID));
|
||||
}
|
||||
@@ -22,6 +22,13 @@
|
||||
#include <cmath>
|
||||
|
||||
#include <wx/colour.h>
|
||||
#ifdef __WXMSW__
|
||||
#include <wx/bitmap.h>
|
||||
#include <wx/image.h>
|
||||
#include <cstdlib>
|
||||
#include <utility>
|
||||
#include <catch2/generators/catch_generators.hpp>
|
||||
#endif
|
||||
#include <wx/string.h>
|
||||
|
||||
#include "libslic3r/FilamentMixer.hpp"
|
||||
@@ -260,3 +267,71 @@ TEST_CASE("sample_gradient_ramp returns nothing without a usable curve or step c
|
||||
curve.points = {{0.0, kGradientMaxRatio, NAN, NAN}, {1.0, kGradientMinRatio, NAN, NAN}};
|
||||
REQUIRE(Slic3r::GUI::sample_gradient_ramp(wxColour(255, 0, 0), wxColour(0, 0, 255), curve, 0).empty());
|
||||
}
|
||||
|
||||
// The headless GTK/macOS suite does not initialize GUI display/font stock objects.
|
||||
// Exercise native bitmap pixels on Windows without adding that dependency elsewhere.
|
||||
#ifdef __WXMSW__
|
||||
TEST_CASE("Filament gradient pixels follow both endpoint directions without changing input", "[FilamentBitmapUtils]")
|
||||
{
|
||||
const bool reverse = GENERATE(false, true);
|
||||
const std::vector<wxColour> colors = reverse ? std::vector<wxColour>{wxColour(0, 0, 255), wxColour(255, 0, 0)}
|
||||
: std::vector<wxColour>{wxColour(255, 0, 0), wxColour(0, 0, 255)};
|
||||
const auto original = colors;
|
||||
const wxBitmap bitmap = Slic3r::GUI::create_filament_bitmap(colors, wxSize(64, 16), true);
|
||||
REQUIRE(bitmap.IsOk());
|
||||
const wxImage image = bitmap.ConvertToImage();
|
||||
REQUIRE(image.IsOk());
|
||||
// Interior pixels avoid backend-specific rasterization at the gradient boundary.
|
||||
for (const auto& sample : {std::pair<int, wxColour>{2, colors.front()}, {61, colors.back()}}) {
|
||||
REQUIRE(std::abs(int(image.GetRed(sample.first, 8)) - int(sample.second.Red())) <= 16);
|
||||
REQUIRE(std::abs(int(image.GetBlue(sample.first, 8)) - int(sample.second.Blue())) <= 16);
|
||||
}
|
||||
REQUIRE(colors == original);
|
||||
}
|
||||
|
||||
TEST_CASE("Filament halves preserve the same caller color order as gradients", "[FilamentBitmapUtils]")
|
||||
{
|
||||
const bool reverse = GENERATE(false, true);
|
||||
const std::vector<wxColour> colors = reverse ? std::vector<wxColour>{wxColour(0, 0, 255), wxColour(255, 0, 0)}
|
||||
: std::vector<wxColour>{wxColour(255, 0, 0), wxColour(0, 0, 255)};
|
||||
const auto original = colors;
|
||||
const wxBitmap bitmap = Slic3r::GUI::create_filament_bitmap(colors, wxSize(64, 16), false);
|
||||
REQUIRE(bitmap.IsOk());
|
||||
const wxImage image = bitmap.ConvertToImage();
|
||||
REQUIRE(image.IsOk());
|
||||
require_same_rgb(wxColour(image.GetRed(16, 8), image.GetGreen(16, 8), image.GetBlue(16, 8)), colors.front());
|
||||
require_same_rgb(wxColour(image.GetRed(48, 8), image.GetGreen(48, 8), image.GetBlue(48, 8)), colors.back());
|
||||
REQUIRE(colors == original);
|
||||
}
|
||||
|
||||
TEST_CASE("Three color filament stripes retain caller order", "[FilamentBitmapUtils]")
|
||||
{
|
||||
const std::vector<wxColour> colors{wxColour(0, 0, 255), wxColour(0, 255, 0), wxColour(255, 0, 0)};
|
||||
const auto original = colors;
|
||||
const wxBitmap bitmap = Slic3r::GUI::create_filament_bitmap(colors, wxSize(72, 24), false);
|
||||
REQUIRE(bitmap.IsOk());
|
||||
const wxImage image = bitmap.ConvertToImage();
|
||||
REQUIRE(image.IsOk());
|
||||
for (int i = 0; i < 3; ++i) {
|
||||
const int x = 12 + i * 24;
|
||||
require_same_rgb(wxColour(image.GetRed(x, 12), image.GetGreen(x, 12), image.GetBlue(x, 12)), colors[i]);
|
||||
}
|
||||
REQUIRE(colors == original);
|
||||
}
|
||||
|
||||
TEST_CASE("Four color filament quadrants retain caller order", "[FilamentBitmapUtils]")
|
||||
{
|
||||
const std::vector<wxColour> colors{wxColour(0, 0, 255), wxColour(0, 255, 0), wxColour(255, 0, 0), wxColour(255, 255, 0)};
|
||||
const auto original = colors;
|
||||
const wxBitmap bitmap = Slic3r::GUI::create_filament_bitmap(colors, wxSize(64, 32), false);
|
||||
REQUIRE(bitmap.IsOk());
|
||||
const wxImage image = bitmap.ConvertToImage();
|
||||
REQUIRE(image.IsOk());
|
||||
for (int i = 0; i < 4; ++i) {
|
||||
const int x = i % 2 ? 48 : 16, y = i / 2 ? 24 : 8;
|
||||
require_same_rgb(wxColour(image.GetRed(x, y), image.GetGreen(x, y), image.GetBlue(x, y)), colors[i]);
|
||||
}
|
||||
REQUIRE(colors == original);
|
||||
}
|
||||
|
||||
#endif // __WXMSW__
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
#include "slic3r/GUI/ColorPickerDialog.hpp"
|
||||
#include "libslic3r/Color.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <limits>
|
||||
#include <stdexcept>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <cstddef>
|
||||
#include <catch2/catch_test_macros.hpp>
|
||||
#include <catch2/generators/catch_generators.hpp>
|
||||
|
||||
using namespace Slic3r;
|
||||
using namespace Slic3r::GUI;
|
||||
|
||||
// One color more than an ordered gradient may hold.
|
||||
static std::vector<std::string> too_many_gradient_colors()
|
||||
{
|
||||
return std::vector<std::string>(COLOR_PICKER_MAX_GRADIENT_COLORS + 1, "#FF0000");
|
||||
}
|
||||
|
||||
TEST_CASE("Project gradient direction survives editing and RGB serialization", "[FilamentColorPicker]")
|
||||
{
|
||||
const std::vector<std::string> endpoints = {"#FF0000", "#0000FF"};
|
||||
const FilamentColorPickerValue stored{endpoints, true};
|
||||
const auto initial = filament_color_picker_selection(stored);
|
||||
REQUIRE(initial);
|
||||
REQUIRE(filament_color_picker_result(*initial).colors == endpoints);
|
||||
REQUIRE_FALSE(filament_color_picker_changed(*initial, initial));
|
||||
REQUIRE_FALSE(filament_color_picker_changed(*initial, std::nullopt));
|
||||
const auto reverse = filament_color_picker_selection({{endpoints[1], endpoints[0]}, true});
|
||||
REQUIRE(filament_color_picker_changed(*initial, reverse));
|
||||
REQUIRE(filament_color_picker_result(*reverse).colors.front() == endpoints.back());
|
||||
}
|
||||
|
||||
TEST_CASE("Unrepresentable project colors remain unchanged until a different selection is confirmed", "[FilamentColorPicker]")
|
||||
{
|
||||
const auto value = GENERATE(FilamentColorPickerValue{too_many_gradient_colors(), true},
|
||||
FilamentColorPickerValue{{"#FF0000", "#0000FF"}, false});
|
||||
REQUIRE_FALSE(filament_color_picker_selection(value));
|
||||
const auto seed = filament_color_picker_initial(value);
|
||||
REQUIRE_FALSE(filament_color_picker_changed(seed, seed));
|
||||
REQUIRE_FALSE(filament_color_picker_changed(seed, std::nullopt));
|
||||
const auto changed = filament_color_picker_selection({{"#FE0000"}, false});
|
||||
REQUIRE(filament_color_picker_changed(seed, changed));
|
||||
REQUIRE(filament_color_picker_result(*changed).colors == std::vector<std::string>{"#FE0000"});
|
||||
}
|
||||
|
||||
TEST_CASE("Filament seeds preserve primary alpha when colors are invalid or absent", "[FilamentColorPicker]")
|
||||
{
|
||||
const FilamentColorPickerValue stale{{"invalid", "#00FF00"}, false, "#12345680"};
|
||||
REQUIRE_FALSE(filament_color_picker_selection(stale));
|
||||
REQUIRE(filament_color_picker_result(filament_color_picker_initial(stale)).colors == std::vector<std::string>{"#12345680"});
|
||||
const auto value = filament_color_picker_selection({{"#12345680", "#ABCDEF01"}, true});
|
||||
REQUIRE(value);
|
||||
REQUIRE(filament_color_picker_result(*value).colors == std::vector<std::string>{"#12345680", "#ABCDEF01"});
|
||||
const auto missing = filament_color_picker_initial({});
|
||||
REQUIRE(filament_color_picker_result(missing).colors == std::vector<std::string>{"#000000"});
|
||||
}
|
||||
|
||||
TEST_CASE("Filament project colors round trip every alpha boundary with compatible opaque spelling", "[FilamentColorPicker]")
|
||||
{
|
||||
const auto hex = GENERATE(std::string("#12345600"), std::string("#12345601"),
|
||||
std::string("#12345680"), std::string("#123456FE"),
|
||||
std::string("#123456FF"), std::string("#123456"));
|
||||
const FilamentColorPickerValue stored{{hex}, false};
|
||||
const auto selection = filament_color_picker_selection(stored);
|
||||
REQUIRE(selection);
|
||||
REQUIRE_FALSE(filament_color_picker_changed(*selection, selection));
|
||||
REQUIRE_FALSE(filament_color_picker_changed(*selection, std::nullopt));
|
||||
const auto result = filament_color_picker_result(*selection);
|
||||
const std::string expected = hex == "#123456FF" ? "#123456" : hex;
|
||||
REQUIRE(result.colors == std::vector<std::string>{expected});
|
||||
ColorRGBA decoded;
|
||||
REQUIRE(decode_color(result.colors.front(), decoded));
|
||||
REQUIRE(filament_color_picker_result(ColorSelection{decoded}).colors == result.colors);
|
||||
const auto reloaded = filament_color_picker_selection(result);
|
||||
REQUIRE(reloaded);
|
||||
REQUIRE(filament_color_picker_result(*reloaded).colors == result.colors);
|
||||
}
|
||||
|
||||
TEST_CASE("Alpha only filament edits preserve ordered independent gradient endpoint alpha", "[FilamentColorPicker]")
|
||||
{
|
||||
const auto original = filament_color_picker_selection({{"#FF000000", "#0000FF80"}, true});
|
||||
const auto edited = filament_color_picker_selection({{"#FF000001", "#0000FFFE"}, true});
|
||||
REQUIRE(original);
|
||||
REQUIRE(edited);
|
||||
REQUIRE(filament_color_picker_changed(*original, edited));
|
||||
const auto result = filament_color_picker_result(*edited);
|
||||
REQUIRE(result.gradient);
|
||||
REQUIRE(result.colors == std::vector<std::string>{"#FF000001", "#0000FFFE"});
|
||||
const auto reloaded = filament_color_picker_selection(result);
|
||||
REQUIRE(reloaded);
|
||||
REQUIRE_FALSE(filament_color_picker_changed(*edited, reloaded));
|
||||
const auto reversed = filament_color_picker_selection({{result.colors[1], result.colors[0]}, true});
|
||||
REQUIRE(filament_color_picker_changed(*edited, reversed));
|
||||
const auto solid = filament_color_picker_selection({{"#12345680"}, false});
|
||||
const auto alpha_only = filament_color_picker_selection({{"#12345681"}, false});
|
||||
REQUIRE(solid);
|
||||
REQUIRE(alpha_only);
|
||||
REQUIRE(filament_color_picker_changed(*solid, alpha_only));
|
||||
REQUIRE(filament_color_picker_result(*alpha_only).colors == std::vector<std::string>{"#12345681"});
|
||||
const auto mixed = filament_color_picker_selection({{"#FF0000FF", "#0000FF00"}, true});
|
||||
REQUIRE(mixed);
|
||||
REQUIRE(filament_color_picker_result(*mixed).colors == std::vector<std::string>{"#FF0000", "#0000FF00"});
|
||||
}
|
||||
|
||||
TEST_CASE("Unrepresentable filament seeds retain first endpoint or valid primary alpha", "[FilamentColorPicker]")
|
||||
{
|
||||
auto too_many = too_many_gradient_colors();
|
||||
too_many.front() = "#FF000000";
|
||||
const auto value = GENERATE_COPY(FilamentColorPickerValue{too_many, true},
|
||||
FilamentColorPickerValue{{"#FF000000", "#0000FF80"}, false},
|
||||
FilamentColorPickerValue{{"invalid", "#FF0000"}, true, "#FF000000"});
|
||||
REQUIRE_FALSE(filament_color_picker_selection(value));
|
||||
const auto initial = filament_color_picker_initial(value);
|
||||
REQUIRE(filament_color_picker_result(initial).colors == std::vector<std::string>{"#FF000000"});
|
||||
REQUIRE_FALSE(filament_color_picker_changed(initial, initial));
|
||||
REQUIRE_FALSE(filament_color_picker_changed(initial, std::nullopt));
|
||||
const auto edited = filament_color_picker_selection({{"#FF000001"}, false});
|
||||
REQUIRE(filament_color_picker_changed(initial, edited));
|
||||
}
|
||||
|
||||
TEST_CASE("Ordered multi-color gradients round trip up to the color limit", "[FilamentColorPicker]")
|
||||
{
|
||||
const std::vector<std::string> all = {"#FF0000", "#FFFF0080", "#00FF00", "#00FFFF01", "#0000FF", "#FF00FF00", "#123456"};
|
||||
for (std::size_t count = 2; count <= std::min(all.size(), COLOR_PICKER_MAX_GRADIENT_COLORS); ++count) {
|
||||
const std::vector<std::string> colors(all.begin(), all.begin() + count);
|
||||
const auto selection = filament_color_picker_selection({colors, true});
|
||||
REQUIRE(selection);
|
||||
const auto result = filament_color_picker_result(*selection);
|
||||
REQUIRE(result.gradient);
|
||||
REQUIRE(result.colors == colors);
|
||||
REQUIRE_FALSE(filament_color_picker_changed(*selection, filament_color_picker_selection(result)));
|
||||
std::vector<std::string> reordered = colors;
|
||||
std::swap(reordered.front(), reordered.back());
|
||||
REQUIRE(filament_color_picker_changed(*selection, filament_color_picker_selection({reordered, true})));
|
||||
}
|
||||
REQUIRE_FALSE(filament_color_picker_selection({too_many_gradient_colors(), true}));
|
||||
}
|
||||
|
||||
TEST_CASE("Filament selections require complete hexadecimal colors and exact endpoint counts", "[FilamentColorPicker]")
|
||||
{
|
||||
const std::string hex = GENERATE("", "112233", "#ABC", "#1234567", "#123456789", "#GG2233", "#11223Z", "#112233G0", " #112233", "#112233 ");
|
||||
REQUIRE_FALSE(filament_color_picker_selection({{hex}, false}));
|
||||
REQUIRE_FALSE(filament_color_picker_selection({{"#112233", hex}, true}));
|
||||
const auto malformed = GENERATE(FilamentColorPickerValue{{}, false},
|
||||
FilamentColorPickerValue{{}, true},
|
||||
FilamentColorPickerValue{{"#112233"}, true});
|
||||
REQUIRE_FALSE(filament_color_picker_selection(malformed));
|
||||
}
|
||||
|
||||
TEST_CASE("Every RGBA byte survives the public filament conversion round trip", "[FilamentColorPicker]")
|
||||
{
|
||||
constexpr char digits[] = "0123456789ABCDEF";
|
||||
for (int byte = 0; byte < 256; ++byte) {
|
||||
std::string hex = "#";
|
||||
for (int channel = 0; channel < 4; ++channel) {
|
||||
hex += digits[byte >> 4];
|
||||
hex += digits[byte & 15];
|
||||
}
|
||||
const auto selection = filament_color_picker_selection({{hex}, false});
|
||||
REQUIRE(selection);
|
||||
const auto result = filament_color_picker_result(*selection);
|
||||
REQUIRE(result.colors == std::vector<std::string>{byte == 255 ? hex.substr(0, 7) : hex});
|
||||
REQUIRE_FALSE(filament_color_picker_changed(*selection, filament_color_picker_selection(result)));
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("Filament serialization rejects invalid native RGBA channels", "[FilamentColorPicker]")
|
||||
{
|
||||
const float invalid = GENERATE(-0.1f, 1.1f, std::numeric_limits<float>::infinity(), std::numeric_limits<float>::quiet_NaN());
|
||||
const int channel = GENERATE(0, 1, 2, 3);
|
||||
ColorRGBA color;
|
||||
color[channel] = invalid;
|
||||
REQUIRE_THROWS_AS(filament_color_picker_result(color), std::invalid_argument);
|
||||
REQUIRE_THROWS_AS(filament_color_picker_result(ColorGradient{ColorRGBA::WHITE(), color}), std::invalid_argument);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
#include <catch2/catch_test_macros.hpp>
|
||||
|
||||
#include <string>
|
||||
#include "slic3r/Utils/MoonrakerPrinterAgent.hpp"
|
||||
|
||||
using namespace Slic3r;
|
||||
|
||||
namespace {
|
||||
|
||||
class DiscoveryAgent : public MoonrakerPrinterAgent
|
||||
{
|
||||
public:
|
||||
using MoonrakerPrinterAgent::MoonrakerPrinterAgent;
|
||||
using MoonrakerPrinterAgent::device_info;
|
||||
mutable unsigned fetch_count = 0;
|
||||
|
||||
protected:
|
||||
bool fetch_device_info(const std::string&, const std::string&, MoonrakerDeviceInfo& info, std::string&) const override
|
||||
{
|
||||
++fetch_count;
|
||||
info.dev_name = "Test printer";
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("Moonraker discovery waits for a complete device identity", "[MoonrakerDiscovery]")
|
||||
{
|
||||
DiscoveryAgent agent("");
|
||||
unsigned announcements = 0;
|
||||
nlohmann::json announcement;
|
||||
agent.set_on_ssdp_msg_fn([&](std::string payload) {
|
||||
++announcements;
|
||||
announcement = nlohmann::json::parse(payload);
|
||||
});
|
||||
|
||||
agent.start_discovery(true, false);
|
||||
REQUIRE(announcements == 0);
|
||||
REQUIRE(agent.fetch_count == 0);
|
||||
|
||||
agent.device_info.dev_id = "127.0.0.1:7125";
|
||||
agent.device_info.dev_ip = agent.device_info.dev_id;
|
||||
agent.device_info.base_url = "http://127.0.0.1:7125";
|
||||
agent.start_discovery(true, false);
|
||||
REQUIRE(announcements == 1);
|
||||
REQUIRE(agent.fetch_count == 1);
|
||||
REQUIRE(announcement.at("dev_id") == agent.device_info.dev_id);
|
||||
REQUIRE(announcement.at("dev_ip") == agent.device_info.dev_ip);
|
||||
REQUIRE(announcement.at("dev_name") == "Test printer");
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
#include <catch2/catch_test_macros.hpp>
|
||||
|
||||
#include <utility>
|
||||
#include <memory>
|
||||
#include <vector>
|
||||
#include <functional>
|
||||
|
||||
#include "slic3r/Utils/bambu_networking.hpp"
|
||||
#include "slic3r/Utils/MoonrakerPrinterAgent.hpp"
|
||||
#include "slic3r/Utils/NetworkAgent.hpp"
|
||||
|
||||
using namespace Slic3r;
|
||||
|
||||
namespace {
|
||||
|
||||
class DiscoverySource : public MoonrakerPrinterAgent
|
||||
{
|
||||
public:
|
||||
DiscoverySource() : MoonrakerPrinterAgent("") {}
|
||||
|
||||
OnMsgArrivedFn discovery_callback;
|
||||
|
||||
int set_on_ssdp_msg_fn(OnMsgArrivedFn fn) override
|
||||
{
|
||||
discovery_callback = std::move(fn);
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("Queued discovery cannot cross printer agent sessions", "[NetworkDiscovery]")
|
||||
{
|
||||
auto source = std::make_shared<DiscoverySource>();
|
||||
NetworkAgent network(nullptr, source);
|
||||
std::vector<std::function<void()>> pending;
|
||||
std::vector<std::string> delivered;
|
||||
network.set_queue_on_main_fn([&](std::function<void()> fn) { pending.push_back(std::move(fn)); });
|
||||
network.set_on_ssdp_msg_fn([&](std::string payload) { delivered.push_back(std::move(payload)); });
|
||||
|
||||
auto outgoing_callback = source->discovery_callback;
|
||||
outgoing_callback("unsaved-device");
|
||||
outgoing_callback("saved-moonraker-device");
|
||||
REQUIRE(delivered.empty());
|
||||
REQUIRE(pending.size() == 2);
|
||||
|
||||
bool expect_active = true;
|
||||
SECTION("Switch to another agent")
|
||||
{
|
||||
network.set_printer_agent(std::make_shared<DiscoverySource>());
|
||||
}
|
||||
SECTION("Clear the live agent")
|
||||
{
|
||||
network.set_printer_agent(nullptr);
|
||||
expect_active = false;
|
||||
}
|
||||
SECTION("Switch away and back to the cached agent")
|
||||
{
|
||||
network.set_printer_agent(std::make_shared<DiscoverySource>());
|
||||
network.set_printer_agent(source);
|
||||
}
|
||||
SECTION("Unregister discovery")
|
||||
{
|
||||
network.set_on_ssdp_msg_fn(nullptr);
|
||||
expect_active = false;
|
||||
}
|
||||
SECTION("Replace the discovery listener")
|
||||
{
|
||||
network.set_on_ssdp_msg_fn([&](std::string payload) { delivered.push_back(std::move(payload)); });
|
||||
}
|
||||
|
||||
for (auto& fn : pending)
|
||||
fn();
|
||||
pending.clear();
|
||||
REQUIRE(delivered.empty());
|
||||
|
||||
outgoing_callback("late-outgoing-device");
|
||||
REQUIRE(pending.empty());
|
||||
REQUIRE(delivered.empty());
|
||||
|
||||
if (expect_active) {
|
||||
auto active = std::dynamic_pointer_cast<DiscoverySource>(network.get_printer_agent());
|
||||
REQUIRE(active);
|
||||
REQUIRE(active->discovery_callback);
|
||||
active->discovery_callback("active-device");
|
||||
REQUIRE(delivered.empty());
|
||||
REQUIRE(pending.size() == 1);
|
||||
pending.front()();
|
||||
REQUIRE(delivered == std::vector<std::string>{"active-device"});
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("Discovery callbacks expire when networking is destroyed", "[NetworkDiscovery]")
|
||||
{
|
||||
auto source = std::make_shared<DiscoverySource>();
|
||||
std::vector<std::function<void()>> pending;
|
||||
unsigned delivered = 0;
|
||||
{
|
||||
NetworkAgent network(nullptr, source);
|
||||
network.set_queue_on_main_fn([&](std::function<void()> fn) { pending.push_back(std::move(fn)); });
|
||||
network.set_on_ssdp_msg_fn([&](std::string) { ++delivered; });
|
||||
source->discovery_callback("queued-device");
|
||||
}
|
||||
|
||||
REQUIRE(pending.size() == 1);
|
||||
pending.front()();
|
||||
pending.clear();
|
||||
source->discovery_callback("late-device");
|
||||
REQUIRE(pending.empty());
|
||||
REQUIRE(delivered == 0);
|
||||
}
|
||||
@@ -56,6 +56,14 @@ TEST_CASE("Plugin host API exposes host-owned bundle and preset surface to Pytho
|
||||
REQUIRE(has_attr(host, "Model"));
|
||||
REQUIRE(has_attr(host, "ModelObject"));
|
||||
REQUIRE(has_attr(host, "Plater"));
|
||||
REQUIRE(has_attr(host, "preset"));
|
||||
py::object preset = host.attr("preset");
|
||||
REQUIRE(has_attr(preset, "bundles"));
|
||||
py::object bundles = preset.attr("bundles");
|
||||
CHECK(has_attr(bundles, "list_ids"));
|
||||
CHECK(has_attr(bundles, "reload_local"));
|
||||
CHECK_FALSE(has_attr(host, "reload_local_bundle"));
|
||||
CHECK_FALSE(has_attr(host, "list_bundle_ids"));
|
||||
|
||||
py::object preset_bundle_type = host.attr("PresetBundle");
|
||||
CHECK(has_attr(preset_bundle_type, "prints"));
|
||||
@@ -132,6 +140,7 @@ TEST_CASE("Plugin host API exposes host-owned bundle and preset surface to Pytho
|
||||
TEST_CASE("Plugin host API reports unavailable GUI objects before Orca app initialization", "[PluginHost][Python]")
|
||||
{
|
||||
py::object host = import_orca_module().attr("host");
|
||||
py::object bundles = host.attr("preset").attr("bundles");
|
||||
|
||||
for (const char* function_name : { "preset_bundle", "plater", "model", "app_info", "selected_printer" }) {
|
||||
CAPTURE(function_name);
|
||||
@@ -143,6 +152,22 @@ TEST_CASE("Plugin host API reports unavailable GUI objects before Orca app initi
|
||||
CHECK(std::string(error.what()).find("OrcaSlicer application is not initialized") != std::string::npos);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
bundles.attr("reload_local")("test-bundle");
|
||||
FAIL("host.preset.bundles.reload_local unexpectedly succeeded without a wx application");
|
||||
} catch (const py::error_already_set& error) {
|
||||
CHECK(error.matches(PyExc_RuntimeError));
|
||||
CHECK(std::string(error.what()).find("OrcaSlicer application is not initialized") != std::string::npos);
|
||||
}
|
||||
|
||||
try {
|
||||
bundles.attr("list_ids")();
|
||||
FAIL("host.preset.bundles.list_ids unexpectedly succeeded without a wx application");
|
||||
} catch (const py::error_already_set& error) {
|
||||
CHECK(error.matches(PyExc_RuntimeError));
|
||||
CHECK(std::string(error.what()).find("OrcaSlicer application is not initialized") != std::string::npos);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("Plugin host API reports no GL details before the 3D view exists", "[PluginHost][Python]")
|
||||
|
||||
Reference in New Issue
Block a user