Merge branch 'main' into feat/generic-n-extruders

This commit is contained in:
Ian Chua
2026-10-15 16:04:30 +08:00
473 changed files with 127462 additions and 21603 deletions
+1
View File
@@ -9,6 +9,7 @@ add_executable(${_TEST_NAME}_tests
test_fill.cpp
test_flow.cpp
test_gcode_processor.cpp
test_gcode_statistics.cpp
test_gcode_timing.cpp
test_gcode.cpp
test_gcodeprocessor.cpp
+169
View File
@@ -3,17 +3,26 @@
#include <catch2/catch_message.hpp>
#include "libslic3r/BoundingBox.hpp"
#include "libslic3r/Config.hpp"
#include "libslic3r/Model.hpp"
#include "libslic3r/ModelArrange.hpp"
#include "libslic3r/Print.hpp"
#include "libslic3r/PrintConfig.hpp"
#include "libslic3r/Preset.hpp"
#include "libslic3r/PresetBundle.hpp"
#include "test_helpers.hpp"
#include "test_utils.hpp"
#include "libslic3r/TriangleMesh.hpp"
#include "libslic3r/Point.hpp"
#include <cstddef>
#include <fstream>
#include <ios>
#include <string>
#include <vector>
#include <catch2/matchers/catch_matchers.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp>
using namespace Slic3r;
@@ -41,3 +50,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);
}
+73
View File
@@ -2,14 +2,19 @@
#include "libslic3r/libslic3r.h"
#include "libslic3r/GCode/GCodeProcessor.hpp"
#include "libslic3r/Model.hpp"
#include "libslic3r/Print.hpp"
#include "libslic3r/PrintConfig.hpp"
#include "test_utils.hpp"
#include <cstddef>
#include <fstream>
#include <initializer_list>
#include <string>
#include <catch2/catch_message.hpp>
#include <catch2/catch_test_macros.hpp>
#include <catch2/generators/catch_generators.hpp>
#include <catch2/matchers/catch_matchers.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp>
@@ -49,6 +54,74 @@ TEST_CASE("The belt header's angle marks G-code as belt G-code", "[GCodeProcesso
CHECK_THAT(processed_belt_tilt(gcode), WithinAbs(45., 1e-6));
}
TEST_CASE("A loaded G-code's belt settings and bed replace the selected printer's in the preview", "[GCodeProcessor][belt][Regression]")
{
// Loading a G-code applies the selected printer's config to the Print, then the file's render
// config on top, so the preview's belt view and back-transform follow the file. The selected
// printer is always the other kind, and every belt and bed key differs between the two.
const bool file_is_belt = GENERATE(false, true);
CAPTURE(file_is_belt);
DynamicPrintConfig file = DynamicPrintConfig::full_print_config();
file.set_deserialize_strict({
{ "belt_printer", file_is_belt },
{ "belt_slice_rotation", "y" },
{ "belt_slice_rotation_angle", -30 },
{ "gcode_remap_x", "rev_x" },
{ "gcode_remap_y", "pos_z" },
{ "gcode_remap_z", "pos_y" },
{ "belt_frame_tilt_decouple", 1 },
{ "belt_frame_tilt_angle", 35 },
{ "printable_area", "0x0,300x0,300x250,0x250" },
{ "printable_height", 123 },
});
DynamicPrintConfig selected = DynamicPrintConfig::full_print_config();
selected.set_deserialize_strict({
{ "belt_printer", !file_is_belt },
{ "belt_slice_rotation", "x" },
{ "belt_slice_rotation_angle", 45 },
{ "gcode_remap_x", "pos_x" },
{ "gcode_remap_y", "pos_y" },
{ "gcode_remap_z", "pos_z" },
{ "belt_frame_tilt_decouple", 0 },
{ "belt_frame_tilt_angle", 50 },
{ "printable_area", "0x0,180x0,180x180,0x180" },
{ "printable_height", 200 },
});
ScopedTemporaryFile temp(".gcode");
{
std::ofstream os(temp.string());
REQUIRE(os.is_open());
// A full config block, as the loader rejects one with fewer than 80 values.
os << "; generated by OrcaSlicer\n" << body << "; CONFIG_BLOCK_START\n";
for (const std::string &key : file.keys())
os << "; " << key << " = " << file.opt_serialize(key) << '\n';
os << "; CONFIG_BLOCK_END\n";
REQUIRE(os.good());
}
GCodeProcessor processor;
REQUIRE_NOTHROW(processor.process_file(temp.string()));
Model model;
Print print;
print.apply(model, selected);
REQUIRE_NOTHROW(print.apply_config_for_render(processor.export_config_for_render()));
// The file's belt settings win either way, so a non-belt file turns a selected belt printer's view off.
for (const char *key : { "belt_printer", "belt_slice_rotation", "belt_slice_rotation_angle", "gcode_remap_x",
"gcode_remap_y", "gcode_remap_z", "belt_frame_tilt_decouple", "belt_frame_tilt_angle" }) {
CAPTURE(key);
CHECK(print.config().opt_serialize(key) == file.opt_serialize(key));
}
// Only a belt file brings the bed it was sliced for.
const DynamicPrintConfig &bed_source = file_is_belt ? file : selected;
for (const char *key : { "printable_area", "printable_height" }) {
CAPTURE(key);
CHECK(print.config().opt_serialize(key) == bed_source.opt_serialize(key));
}
}
TEST_CASE("Non-belt start G-code moves keep the first-layer Z in the processor", "[GCodeProcessor]")
{
// The belt path (GCodeWriter tests: "start-gcode prepare-stage moves keep their real Z")
+124
View File
@@ -0,0 +1,124 @@
#include <catch2/catch_test_macros.hpp>
#include <catch2/matchers/catch_matchers.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp>
#include "libslic3r/Config.hpp"
#include "libslic3r/GCode/GCodeProcessor.hpp"
#include "libslic3r/GCode/GCodeStatistics.hpp"
#include "libslic3r/Model.hpp"
#include "libslic3r/Point.hpp"
#include "libslic3r/Print.hpp"
#include "libslic3r/PrintConfig.hpp"
#include "libslic3r/Utils.hpp"
#include "test_helpers.hpp"
#include "test_utils.hpp"
#include <nlohmann/json.hpp>
#include <cstddef>
#include <initializer_list>
#include <set>
#include <string>
#include <vector>
using namespace Slic3r;
TEST_CASE("The statistics of a sliced plate add up per layer, feature and object instance", "[GCodeStatistics]")
{
// A 20 mm cube at 0.2 mm layers prints 100 of them; without skirt or brim all its filament goes to its instances.
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
config.set_deserialize_strict({ { "layer_height", 0.2 },
{ "initial_layer_print_height", 0.2 },
{ "skirt_loops", 0 },
{ "brim_type", "no_brim" },
{ "filament_density", 1.25 } });
Print print;
Model model;
Test::init_print({ Test::cube(20) }, print, model, config, nullptr, true, 2);
GCodeProcessorResult result;
Test::gcode(print, &result);
ScopedTemporaryFile file(".json");
export_gcode_statistics(result, file.string());
std::string text;
load_string_file(file.path(), text);
const nlohmann::json statistics = nlohmann::json::parse(text);
const nlohmann::json &summary = statistics["summary"];
const nlohmann::json &layers = statistics["layers"];
REQUIRE(layers.size() == 100);
CHECK(summary["layer_count"] == 100);
CHECK_THAT(layers.front()["z"].get<double>(), Catch::Matchers::WithinAbs(0.2, 1e-3));
CHECK_THAT(layers.back()["z"].get<double>(), Catch::Matchers::WithinAbs(20., 1e-3));
double time = 0., volume = 0.;
for (const nlohmann::json &layer : layers) {
time += layer["time"].get<double>();
volume += layer["volume"].get<double>();
double features_time = 0.;
for (const auto &[name, feature] : layer["features"].items())
features_time += feature["time"].get<double>();
CHECK(features_time <= layer["time"].get<double>() + 1e-3);
}
// Each value is rounded to the millisecond and the cubic millimeter.
CHECK_THAT(time, Catch::Matchers::WithinAbs(summary["time"].get<double>(), 0.1));
CHECK_THAT(volume, Catch::Matchers::WithinAbs(summary["volume"].get<double>(), 0.1));
CHECK_THAT(summary["weight"].get<double>(), Catch::Matchers::WithinRel(summary["volume"].get<double>() * 1.25 / 1000., 1e-4));
CHECK(summary.contains("filament_cost"));
// The filament splits by use as the preview's table does.
REQUIRE(summary["filaments"].size() == 1);
const nlohmann::json &filament = summary["filaments"].front();
double used = 0.;
for (const char *use : { "model", "support", "prime_tower", "flush" })
used += filament[use]["volume"].get<double>();
CHECK_THAT(used, Catch::Matchers::WithinAbs(filament["volume"].get<double>(), 0.01));
const nlohmann::json &features = summary["features"];
CHECK(features.contains("Outer wall"));
CHECK(features.contains("Sparse infill"));
double model_volume = 0.;
for (const auto &[name, feature] : features.items())
if (name != "Custom")
model_volume += feature["volume"].get<double>();
const nlohmann::json &objects = statistics["objects"];
REQUIRE(objects.size() == 2);
double objects_volume = 0.;
for (size_t i = 0; i < objects.size(); ++i) {
CHECK(objects[i]["name"] == model.objects.front()->name);
CHECK(objects[i]["instance"] == int(i));
CHECK(objects[i]["support_volume"].get<double>() == 0.);
objects_volume += objects[i]["volume"].get<double>();
}
CHECK_THAT(objects_volume, Catch::Matchers::WithinAbs(model_volume, 0.1));
}
TEST_CASE("Copies sliced as objects of their own keep their number in the model object", "[GCodeStatistics]")
{
// A copy of another scale slices as a print object of its own.
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
config.set_deserialize_strict({ { "skirt_loops", 0 }, { "brim_type", "no_brim" } });
Print print;
Model model;
Test::init_print({ Test::cube(20) }, print, model, config, nullptr, true, 2);
model.objects.front()->instances.back()->set_scaling_factor(Vec3d(0.5, 0.5, 0.5));
model.objects.front()->ensure_on_bed();
print.apply(model, config);
REQUIRE(print.objects().size() == 2);
GCodeProcessorResult result;
Test::gcode(print, &result);
const nlohmann::json objects = nlohmann::json::parse(gcode_statistics_json(result))["objects"];
std::set<int> instances;
for (const nlohmann::json &object : objects)
instances.insert(object["instance"].get<int>());
CHECK(instances == std::set<int>{ 0, 1 });
}
TEST_CASE("The statistics file takes the G-code's name with the statistics extension", "[GCodeStatistics]")
{
CHECK(gcode_statistics_path("plate_1.gcode") == "plate_1.statistics.json");
CHECK(gcode_statistics_path("out/Cube_PLA_1h.gcode") == "out/Cube_PLA_1h.statistics.json");
}
+49 -4
View File
@@ -7,6 +7,7 @@
#include "libslic3r/GCode/GCodeProcessor.hpp"
#include "libslic3r/MultiNozzleUtils.hpp"
#include "libslic3r/PrintConfig.hpp"
#include "libslic3r/Utils.hpp"
#include "test_utils.hpp"
@@ -21,6 +22,7 @@
#include <memory>
#include <sstream>
#include <string>
#include <utility>
#include <vector>
#include "libslic3r/Config.hpp"
@@ -52,12 +54,16 @@ FullPrintConfig make_config(double load_time, double unload_time, double tool_ch
return config;
}
void run_processor(GCodeProcessor& proc, const FullPrintConfig& config, const char* gcode)
void run_processor(GCodeProcessor& proc, const FullPrintConfig& config, const char* gcode, bool is_bbl_printer = true)
{
// reserved_tag() selects between two tag tables based on this shared static, and
// other tests in the binary mutate it -- pin it so our "; FEATURE:" role tags are
// parsed deterministically regardless of test execution order.
GCodeProcessor::s_IsBBLPrinter = true;
// other tests in the binary mutate it -- pin it so role tags are parsed
// deterministically regardless of test execution order.
const bool previous_is_bbl_printer = GCodeProcessor::s_IsBBLPrinter;
GCodeProcessor::s_IsBBLPrinter = is_bbl_printer;
const ScopeGuard restore_flag([previous_is_bbl_printer] {
GCodeProcessor::s_IsBBLPrinter = previous_is_bbl_printer;
});
ScopedTemporaryFile temp(".gcode");
{
std::ofstream os(temp.string());
@@ -99,6 +105,45 @@ double filament_change_delay(const GCodeProcessorResult& r)
} // namespace
TEST_CASE("G29 preparation time is added only once", "[GCodeTiming][Regression]")
{
const FullPrintConfig config = make_config(0.0, 0.0, 0.0);
auto estimate = [&config](bool is_bbl_printer, const char* commands) {
const auto mode = PrintEstimatedStatistics::ETimeMode::Normal;
std::string gcode = is_bbl_printer ? "; FEATURE: Custom\n" : ";TYPE:Custom\n";
gcode += "G1 X10 Y10 Z0.2 F600\nG1 X20 Y10 F600\n";
if (is_bbl_printer)
gcode += "M622 J1\n";
gcode += commands;
if (is_bbl_printer)
gcode += "M623\n";
gcode += "G1 X20 Y20 F600\nG1 X10 Y20 F600\n";
GCodeProcessor processor;
run_processor(processor, config, gcode.c_str(), is_bbl_printer);
return std::make_pair(processor.get_time(mode), processor.get_prepare_time(mode));
};
const auto [baseline_time, baseline_prepare] = estimate(false, "G4 S0\n");
const auto [one_time, one_prepare] = estimate(false, "G29\n");
const auto [repeated_time, repeated_prepare] = estimate(false,
"G29 P9 X10 Y-4 W32 H4\nG29 P1\nG29 P1 X0 Y0 W50 H20 C\nG29 P3.2\nG29 P3.13\nG29 A\n");
REQUIRE_THAT(one_time - baseline_time, WithinAbs(260.0, 1e-2));
REQUIRE_THAT(one_prepare - baseline_prepare, WithinAbs(260.0, 1e-2));
REQUIRE_THAT(repeated_time, WithinAbs(one_time, 1e-2));
REQUIRE_THAT(repeated_prepare, WithinAbs(one_prepare, 1e-2));
const auto [baseline_bbl_time, baseline_bbl_prepare] = estimate(true, "G4 S0\n");
const auto [one_bbl_time, one_bbl_prepare] = estimate(true, "G29\n");
const auto [repeated_bbl_time, repeated_bbl_prepare] = estimate(true, "G29\nG29\n");
REQUIRE_THAT(one_bbl_time - baseline_bbl_time, WithinAbs(260.0, 1e-2));
REQUIRE_THAT(one_bbl_prepare - baseline_bbl_prepare, WithinAbs(260.0, 1e-2));
REQUIRE_THAT(repeated_bbl_time, WithinAbs(one_bbl_time, 1e-2));
REQUIRE_THAT(repeated_bbl_prepare, WithinAbs(one_bbl_prepare, 1e-2));
}
TEST_CASE("Filament-change time is attributed to tool-change moves, not extrusion roles", "[GCodeTiming]")
{
// Relative extrusion (M83) so every "E5" is a real 5mm extrusion move rather
+75
View File
@@ -11,9 +11,15 @@
#include <catch2/catch_test_macros.hpp>
#include <catch2/catch_message.hpp>
#include <catch2/matchers/catch_matchers.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp>
#include "libslic3r/GCode/ToolOrdering.hpp"
#include "libslic3r/MultiNozzleUtils.hpp"
#include "libslic3r/Print.hpp"
#include "libslic3r/BoundingBox.hpp"
#include "libslic3r/ExPolygon.hpp"
#include "libslic3r/Layer.hpp"
#include "libslic3r/libslic3r.h"
#include "test_helpers.hpp"
@@ -333,3 +339,72 @@ TEST_CASE("Print::validate warns when a gradient mixed filament is used without
CHECK(count_opt(print, "enable_mixed_color_sublayer") == 0);
}
}
TEST_CASE("Filament shrinkage compensation compares the physical filaments of a mixed slot", "[MixedFilament][Print][Regression]")
{
// The mixed slot 3 blends filament 1 (90 %) and filament 2 (100 %); its own 90 % does not count.
DynamicPrintConfig config = mixed_config(false);
config.set_deserialize_strict({
{ "filament_shrink", "90%,100%,90%" },
{ "xy_contour_compensation", 0. },
{ "layer_change_gcode", "G92 E0\n" }, // validate() relative-E reset
});
Print print;
init_and_process_print({ cube(20.) }, print, config);
const PrintObject &object = *print.objects().front();
const BoundingBox extents = get_extents(object.get_layer(int(object.layer_count() / 2))->lslices);
CHECK_THAT(unscale<double>(extents.size().x()), Catch::Matchers::WithinAbs(20., 0.01));
std::vector<StringObjectException> warnings;
CHECK(print.validate(&warnings).string.empty());
CHECK(std::any_of(warnings.begin(), warnings.end(), [](const StringObjectException &w) {
return w.string.find("Filament shrinkage will not be used") != std::string::npos;
}));
}
TEST_CASE("Filament shrinkage compensation counts the wipe tower filament next to a mixed slot", "[MixedFilament][Print][Regression]")
{
// The object prints only with the mixed slot 4 of filaments 1 and 2 (both 90 %). The wipe tower filament 3 (100 %)
// may be flushed into the object, so the compensation is off.
DynamicPrintConfig config = multifilament_config(4);
config.set_deserialize_strict({
{"filament_is_mixed", "0,0,0,1"},
{"filament_mixed_components", ";;;1,2"},
{"filament_mixed_sublayer_ratios", ";;;0.5,0.5"},
{"filament_mixed_gradient", "0,0,0,0"},
{"filament_mixed_gradient_range", ";;;"},
{"filament_mixed_gradient_curve", ";;;"},
{"filament_mixed_gradient_per_part","0,0,0,0"},
{"outer_wall_filament_id", "4"},
{"inner_wall_filament_id", "4"},
{"sparse_infill_filament_id", "4"},
{"internal_solid_filament_id", "4"},
{"top_surface_filament_id", "4"},
{"bottom_surface_filament_id", "4"},
{"enable_prime_tower", "1"},
{"wipe_tower_filament", "3"},
{"prime_tower_width", "35"},
{"wipe_tower_x", "50"}, // inside the 200x200 test bed
{"wipe_tower_y", "50"},
{"filament_shrink", "90%,90%,100%,90%"},
{"xy_contour_compensation", "0"},
{"layer_change_gcode", "G92 E0\n"}, // validate() relative-E reset
});
// The object itself is on the mixed slot too, so no other filament is assigned.
const std::vector<std::vector<ConfigBase::SetDeserializeItem>> object_overrides { { { "extruder", 4 } } };
Print print;
Model model;
init_print({ cube(20.) }, print, model, config, &object_overrides);
print.process();
const PrintObject &object = *print.objects().front();
const BoundingBox extents = get_extents(object.get_layer(int(object.layer_count() / 2))->lslices);
CHECK_THAT(unscale<double>(extents.size().x()), Catch::Matchers::WithinAbs(20., 0.01));
std::vector<StringObjectException> warnings;
CHECK(print.validate(&warnings).string.empty());
CHECK(std::any_of(warnings.begin(), warnings.end(), [](const StringObjectException &w) {
return w.string.find("Filament shrinkage will not be used") != std::string::npos;
}));
}
+111 -33
View File
@@ -105,7 +105,8 @@ void require_vertex(const Polygon &polygon, const Point &point)
std::vector<SeamPlacerImpl::EnforcedBlockedSeamPoint> weak_candidate_types(
const Polygon &polygon, const std::vector<PreciseSeam::WeakModifierSegment> &segments,
const std::function<SeamPlacerImpl::EnforcedBlockedSeamPoint(const Point &)> &painted = {})
const std::function<SeamPlacerImpl::EnforcedBlockedSeamPoint(const Point &)> &painted = {},
PreciseSeam::PreciseSeamWarnings *warnings = nullptr)
{
// Use the same coordinate conversion as production when applying prepared boundaries.
// Optional painted types stand in for seam painting, which production assigns before weak zones.
@@ -120,7 +121,7 @@ std::vector<SeamPlacerImpl::EnforcedBlockedSeamPoint> weak_candidate_types(
}
loop.end_index = candidates.points.size();
bool enforced = false;
PreciseSeam::apply_weak_modifiers_to_perimeter(segments, candidates, loop, enforced);
PreciseSeam::apply_weak_modifiers_to_perimeter(segments, candidates, loop, enforced, warnings);
std::vector<SeamPlacerImpl::EnforcedBlockedSeamPoint> types;
for (const auto &candidate : candidates.points)
types.push_back(candidate.type);
@@ -664,7 +665,7 @@ TEST_CASE("Weak priority and enforcement refinement apply on both crossed sides"
check_square_boundary(perimeter);
}
TEST_CASE("Weak full containment follows painting: Enforced and Neutral type the whole perimeter, Blocked is skipped", "[PreciseSeam]")
TEST_CASE("Weak full containment follows painting: the whole perimeter takes the zone's type", "[PreciseSeam]")
{
const auto type = GENERATE(ModelVolumeType::PRECISE_SEAM_ENFORCED, ModelVolumeType::PRECISE_SEAM_BLOCKED,
ModelVolumeType::PRECISE_SEAM_NEUTRAL);
@@ -674,6 +675,8 @@ TEST_CASE("Weak full containment follows painting: Enforced and Neutral type the
const Points original = perimeter.points;
const auto *modifier = fixture.add(type, {rectangle(-2, -2, 22, 22)});
PreciseSeam::PreciseSeamWarnings warnings;
// Registered as SeamPlacer::init() does, so the per-modifier warning flag is tracked.
warnings.modifier_usage.try_emplace(modifier);
const auto segments = PreciseSeam::collect_weak_modifier_segments(
{modifier}, perimeter, PreciseSeam::PreparedPerimeter(perimeter), fixture.layer, fixture.cache, &warnings);
// Painting of both kinds, so that each type's effect on it is visible.
@@ -681,23 +684,17 @@ TEST_CASE("Weak full containment follows painting: Enforced and Neutral type the
return p.x() < scale_(10.) ? SeamPlacerImpl::EnforcedBlockedSeamPoint::Enforced :
SeamPlacerImpl::EnforcedBlockedSeamPoint::Blocked;
};
const auto types = weak_candidate_types(perimeter, segments, painted);
const auto types = weak_candidate_types(perimeter, segments, painted, &warnings);
CHECK_FALSE(warnings.multiple_intersections.load());
CHECK(warnings.failed_fragments.load() == 0);
if (type == ModelVolumeType::PRECISE_SEAM_BLOCKED) {
// Forbidding the seam all round cannot be honoured: skipped with a warning, painting stays.
CHECK(segments.empty());
CHECK(perimeter.points == original);
CHECK(warnings.full_containment.load() == PreciseSeam::PreciseSeamWarnings::type_bit(type));
for (size_t i = 0; i < perimeter.size(); ++i)
CHECK(types[i] == painted(perimeter[i]));
return;
}
REQUIRE(segments.size() == 1);
CHECK(segments.front().whole_perimeter);
CHECK(warnings.full_containment.load() == 0);
// Only a whole Blocked perimeter is reported, as the seam cannot avoid it.
CHECK(warnings.modifier_usage.at(modifier).fully_blocked.load() == (type == ModelVolumeType::PRECISE_SEAM_BLOCKED));
// Every candidate takes the zone's type, overriding painting.
const auto expected = type == ModelVolumeType::PRECISE_SEAM_ENFORCED ? SeamPlacerImpl::EnforcedBlockedSeamPoint::Enforced :
type == ModelVolumeType::PRECISE_SEAM_BLOCKED ? SeamPlacerImpl::EnforcedBlockedSeamPoint::Blocked :
SeamPlacerImpl::EnforcedBlockedSeamPoint::Neutral;
for (size_t i = 0; i < perimeter.size(); ++i)
CHECK(types[i] == expected);
@@ -706,29 +703,37 @@ TEST_CASE("Weak full containment follows painting: Enforced and Neutral type the
CHECK(perimeter.size() >= size_t(80.f / SeamPlacer::enforcer_oversampling_distance));
check_square_boundary(perimeter);
} else
CHECK(perimeter.points == original); // Like an unmarked perimeter: no subdivision.
CHECK(perimeter.points == original); // Like a perimeter unmarked or painted red all round: no subdivision.
}
TEST_CASE("Whole-perimeter weak zones take part in the usual priority order", "[PreciseSeam]")
{
// Volumes are listed low priority first; a later zone overwrites an earlier one.
const int scenario = GENERATE(0, 1, 2);
const int scenario = GENERATE(0, 1, 2, 3, 4, 5, 6);
CAPTURE(scenario);
SeamFixture fixture;
const auto *whole_enforced = fixture.add(ModelVolumeType::PRECISE_SEAM_ENFORCED, {rectangle(-2, -2, 22, 22)});
const auto *whole_neutral = fixture.add(ModelVolumeType::PRECISE_SEAM_NEUTRAL, {rectangle(-2, -2, 22, 22)});
const auto *whole_blocked = fixture.add(ModelVolumeType::PRECISE_SEAM_BLOCKED, {rectangle(-2, -2, 22, 22)});
const auto *lower_whole_blocked = fixture.add(ModelVolumeType::PRECISE_SEAM_BLOCKED, {rectangle(-2, -2, 22, 22)});
const auto *band_blocked = fixture.add(ModelVolumeType::PRECISE_SEAM_BLOCKED, {rectangle(8, -2, 12, 2)});
const auto *band_enforced = fixture.add(ModelVolumeType::PRECISE_SEAM_ENFORCED, {rectangle(8, -2, 12, 2)});
std::vector<const ModelVolume*> volumes;
if (scenario == 0) volumes = {whole_enforced, band_blocked}; // A band forbidden inside a green perimeter.
if (scenario == 1) volumes = {band_enforced, whole_neutral}; // A whole Neutral clears the lower band.
if (scenario == 2) volumes = {band_enforced, whole_blocked}; // A whole Blocked is skipped; the band stays.
if (scenario == 2) volumes = {band_enforced, whole_blocked}; // A whole Blocked overrides the lower band.
if (scenario == 3) volumes = {whole_blocked, band_enforced}; // A higher band unblocks part of a whole Blocked.
if (scenario == 4) volumes = {whole_blocked, band_blocked}; // A higher Blocked band keeps it whole Blocked.
if (scenario == 5) volumes = {lower_whole_blocked, whole_enforced, whole_blocked}; // The lower one is overwritten.
if (scenario == 6) volumes = {lower_whole_blocked, whole_blocked}; // Only the higher one is in effect.
Polygon perimeter = rectangle(0, 0, 20, 20);
PreciseSeam::PreciseSeamWarnings warnings;
// Registered as SeamPlacer::init() does, so the per-modifier warning flag is tracked.
warnings.modifier_usage.try_emplace(whole_blocked);
warnings.modifier_usage.try_emplace(lower_whole_blocked);
const auto segments = PreciseSeam::collect_weak_modifier_segments(
volumes, perimeter, PreciseSeam::PreparedPerimeter(perimeter), fixture.layer, fixture.cache, &warnings);
const auto types = weak_candidate_types(perimeter, segments);
const auto types = weak_candidate_types(perimeter, segments, {}, &warnings);
using Type = SeamPlacerImpl::EnforcedBlockedSeamPoint;
for (size_t i = 0; i < perimeter.size(); ++i) {
const Point &p = perimeter[i];
@@ -736,14 +741,38 @@ TEST_CASE("Whole-perimeter weak zones take part in the usual priority order", "[
const bool in_band = p.y() == 0 && p.x() >= scale_(8.) && p.x() <= scale_(12.);
const Type expected = scenario == 0 ? (in_band ? Type::Blocked : Type::Enforced) :
scenario == 1 ? Type::Neutral :
(in_band ? Type::Enforced : Type::Neutral);
scenario == 3 ? (in_band ? Type::Enforced : Type::Blocked) :
Type::Blocked;
CHECK(types[i] == expected);
}
CHECK(warnings.full_containment.load() ==
(scenario == 2 ? PreciseSeam::PreciseSeamWarnings::type_bit(ModelVolumeType::PRECISE_SEAM_BLOCKED) : 0u));
CHECK(warnings.full_containment.load() == 0);
// The warning follows the final typing: only a perimeter left whole Blocked is reported, through the
// highest whole zone.
const bool expected_fully_blocked = scenario == 2 || scenario == 4 || scenario == 5 || scenario == 6;
CHECK(warnings.modifier_usage.at(whole_blocked).fully_blocked.load() == expected_fully_blocked);
CHECK_FALSE(warnings.modifier_usage.at(lower_whole_blocked).fully_blocked.load());
check_square_boundary(perimeter);
}
TEST_CASE("A partial Blocked zone on a perimeter painted red all round gives no whole-perimeter warning", "[PreciseSeam]")
{
SeamFixture fixture;
const auto *band_blocked = fixture.add(ModelVolumeType::PRECISE_SEAM_BLOCKED, {rectangle(8, -2, 12, 2)});
Polygon perimeter = rectangle(0, 0, 20, 20);
PreciseSeam::PreciseSeamWarnings warnings;
warnings.modifier_usage.try_emplace(band_blocked);
const auto segments = PreciseSeam::collect_weak_modifier_segments(
{band_blocked}, perimeter, PreciseSeam::PreparedPerimeter(perimeter), fixture.layer, fixture.cache, &warnings);
REQUIRE(segments.size() == 1);
// Red painting makes every candidate Blocked, so the check reaches the whole-zone filter.
const auto painted = [](const Point &) { return SeamPlacerImpl::EnforcedBlockedSeamPoint::Blocked; };
const auto types = weak_candidate_types(perimeter, segments, painted, &warnings);
for (const auto type : types)
CHECK(type == SeamPlacerImpl::EnforcedBlockedSeamPoint::Blocked);
// Only a modifier that covers the whole perimeter is reported.
CHECK_FALSE(warnings.modifier_usage.at(band_blocked).fully_blocked.load());
}
TEST_CASE("Coverage short by a gap under 1 um is full containment while a narrow band keeps its point", "[PreciseSeam][Regression]")
{
// A tip pokes through the bottom edge at x = 10 mm by `depth` nm, so the perimeter line crosses it
@@ -829,19 +858,15 @@ TEST_CASE("A modifier touching the perimeter at one point keeps the full contain
else {
const auto segments = PreciseSeam::collect_weak_modifier_segments(
{modifier}, perimeter, PreciseSeam::PreparedPerimeter(perimeter), fixture.layer, fixture.cache, &warnings);
// Enforced and Neutral type the whole perimeter; Blocked is skipped.
if (type == ModelVolumeType::PRECISE_SEAM_BLOCKED)
CHECK(segments.empty());
else {
REQUIRE(segments.size() == 1);
CHECK(segments.front().whole_perimeter);
}
// A weak modifier gives its type to the whole perimeter.
REQUIRE(segments.size() == 1);
CHECK(segments.front().whole_perimeter);
}
// Only a whole-perimeter Enforced zone subdivides the edges.
if (type != ModelVolumeType::PRECISE_SEAM_ENFORCED)
CHECK(perimeter.points == original);
const bool skipped = is_precise_seam_strong(type) || type == ModelVolumeType::PRECISE_SEAM_BLOCKED;
CHECK(warnings.full_containment.load() == (skipped ? PreciseSeam::PreciseSeamWarnings::type_bit(type) : 0u));
// Only strong types are skipped with the full-containment warning.
CHECK(warnings.full_containment.load() == (is_precise_seam_strong(type) ? PreciseSeam::PreciseSeamWarnings::type_bit(type) : 0u));
CHECK_FALSE(warnings.multiple_intersections.load());
CHECK(warnings.failed_fragments.load() == 0);
}
@@ -1176,7 +1201,7 @@ TEST_CASE("Modifier usage marks evaluated modifiers that never reach a perimeter
CHECK(checked(short_weak));
CHECK_FALSE(reached(short_weak));
CHECK(reached(crossing_weak));
// Full containment reached the perimeter even though it is skipped.
// Full containment counts as reaching the perimeter.
CHECK(reached(containing_weak));
// Strong: evaluation stops at the first modifier with a segment. The one ending 0.5 mm before the
@@ -1393,6 +1418,24 @@ TEST_CASE("Weak modifiers move the exported seam into or out of their zone", "[P
CHECK(fixture.warning().empty());
}
TEST_CASE("A higher-priority Enforced band suppresses the whole-perimeter Blocked export warning", "[PreciseSeam][Regression]")
{
ExportFixture fixture("back");
// The earlier model volume has higher priority; weak application reverses this order.
fixture.add(ModelVolumeType::PRECISE_SEAM_ENFORCED, make_cube(2, 6, 4), Vec3d(4, -3, -1), "front enforcer");
fixture.add(ModelVolumeType::PRECISE_SEAM_BLOCKED, make_cube(30, 30, 4), Vec3d(-5, -5, -1), "whole blocker");
const auto loops = outer_wall_loops(fixture.export_gcode());
REQUIRE(loops.size() == 10);
for (const OuterLoop &loop : loops) {
CAPTURE(loop.seam.x(), loop.seam.y());
// The band wins on every layer, so the empty warning is not from modifiers that did nothing.
CHECK_THAT(loop.seam.y(), Catch::Matchers::WithinAbs(loop.bounds.min.y() - loop.bounds.center().y(), 0.01));
CHECK(loop.seam.x() >= -6.01);
CHECK(loop.seam.x() <= -3.99);
}
CHECK(fixture.warning().empty());
}
TEST_CASE("A strong modifier with a hole uses the left of two equal crossings", "[PreciseSeam]")
{
// The hole splits the front face crossing into x = 2..4 and x = 8..10 mm. The lengths tie, both
@@ -1422,14 +1465,49 @@ TEST_CASE("Full containment warns for Blocked but not for Enforced", "[PreciseSe
{
const auto type = GENERATE(ModelVolumeType::PRECISE_SEAM_ENFORCED, ModelVolumeType::PRECISE_SEAM_BLOCKED);
ExportFixture fixture("back");
fixture.add(type, make_cube(30, 30, 4), Vec3d(-5, -5, -1));
fixture.export_gcode();
fixture.add(type, make_cube(30, 30, 4), Vec3d(-5, -5, -1), "whole helper");
const auto loops = outer_wall_loops(fixture.export_gcode());
// The whole perimeter takes the type either way, so the seam still follows the back preference.
REQUIRE(loops.size() == 10);
for (const OuterLoop &loop : loops)
CHECK(loop.seam.y() > 7.);
if (type == ModelVolumeType::PRECISE_SEAM_BLOCKED)
CHECK(fixture.warning().find("Seam Blocked") != std::string::npos);
CHECK(fixture.warning().find("Seam Blocked modifier \"whole helper\" of") != std::string::npos);
else
CHECK(fixture.warning().empty());
}
TEST_CASE("A perimeter blocked all round gets the seam of an unmarked one", "[PreciseSeam][Regression]")
{
// Equal Blocked candidates compare like Neutral ones, so random and aligned seams still vary and align.
const std::string seam_position = GENERATE(as<std::string>{}, "random", "aligned", "aligned_back", "back", "nearest");
CAPTURE(seam_position);
ExportFixture unmarked(seam_position);
const auto expected = outer_wall_loops(unmarked.export_gcode());
ExportFixture blocked(seam_position);
blocked.add(ModelVolumeType::PRECISE_SEAM_BLOCKED, make_cube(30, 30, 4), Vec3d(-5, -5, -1));
const auto loops = outer_wall_loops(blocked.export_gcode());
REQUIRE(loops.size() == expected.size());
for (size_t i = 0; i < loops.size(); ++i) {
CAPTURE(i, loops[i].seam.x(), loops[i].seam.y(), expected[i].seam.x(), expected[i].seam.y());
CHECK((loops[i].seam - expected[i].seam).norm() < 0.01);
}
}
TEST_CASE("The whole-perimeter Blocked warning names the first modifier and counts them all", "[PreciseSeam]")
{
ExportFixture fixture("back");
// Separate layers: where both covered the same perimeter, only the higher-priority one would count.
fixture.add(ModelVolumeType::PRECISE_SEAM_BLOCKED, make_cube(30, 30, 1.4), Vec3d(-5, -5, -1), "first blocker");
fixture.add(ModelVolumeType::PRECISE_SEAM_BLOCKED, make_cube(30, 30, 2), Vec3d(-5, -5, 1), "second blocker");
fixture.export_gcode();
const std::string warning = fixture.warning();
CAPTURE(warning);
CHECK(warning.find("Seam Blocked modifier \"first blocker\" of") != std::string::npos);
CHECK(warning.find("(2 in total) covers an entire perimeter") != std::string::npos);
CHECK(warning.find("second blocker") == std::string::npos);
}
TEST_CASE("Belt printers slice Precise Seam modifiers in the frame the object was sliced in", "[PreciseSeam][belt]")
{
Model model;
+185
View File
@@ -26,6 +26,7 @@
#include <catch2/catch_all.hpp>
#include "libslic3r/Print.hpp"
#include "libslic3r/Slicing.hpp"
#include "libslic3r/Layer.hpp"
#include "libslic3r/BuildVolume.hpp"
#include "libslic3r/Support/TreeModelVolumes.hpp"
@@ -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';
}
+311
View File
@@ -7,6 +7,7 @@
#include "libslic3r/Model.hpp"
#include "libslic3r/Format/3mf.hpp"
#include "libslic3r/Format/bbs_3mf.hpp"
#include "libslic3r/GCode/ThumbnailData.hpp"
#include "libslic3r/Format/STL.hpp"
#include "libslic3r/Utils.hpp"
#include "libslic3r/miniz_extension.hpp"
@@ -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());
}
}
}
}
+101
View File
@@ -3,6 +3,8 @@
#include <catch2/catch_test_macros.hpp>
#include <catch2/generators/catch_generators.hpp>
#include "libslic3r/AppConfig.hpp"
#include "libslic3r/Thread.hpp"
#include "../slic3rutils/plugin_test_utils.hpp"
using namespace Slic3r;
@@ -46,6 +48,105 @@ TEST_CASE("AppConfig network version helpers", "[AppConfig]") {
}
}
TEST_CASE("Local machine agent replacements survive save and reload", "[AppConfig]")
{
ScopedDataDir data_dir("local-machine");
save_main_thread_id();
AppConfig config;
BBLocalMachine saved;
saved.dev_id = "127.0.0.1:7125";
saved.dev_ip = saved.dev_id;
saved.dev_name = "Test printer";
saved.printer_type = "test-model";
saved.printer_agent_id = "orca";
saved.access_code = "old-key";
config.update_local_machine(saved);
config.save();
REQUIRE_FALSE(config.dirty());
BBLocalMachine replacement = saved;
replacement.printer_agent_id = "moonraker";
replacement.access_code = GENERATE("", "new-key", "88888888");
config.update_local_machine(replacement);
REQUIRE(config.dirty());
REQUIRE(config.get_local_machines().at(saved.dev_id) == replacement);
config.save();
AppConfig reloaded;
REQUIRE(reloaded.load().empty());
REQUIRE(reloaded.get_local_machines().at(saved.dev_id) == replacement);
REQUIRE_FALSE(reloaded.dirty());
reloaded.update_local_machine(replacement);
REQUIRE_FALSE(reloaded.dirty());
}
TEST_CASE("Clearing local credentials preserves another agent's record", "[AppConfig]")
{
AppConfig config;
BBLocalMachine saved;
saved.dev_id = "test_dev";
saved.printer_agent_id = GENERATE("orca", "qidi", "bbl", "");
saved.access_code = "saved-key";
config.update_local_machine(saved);
config.set_str("access_code", saved.dev_id, "legacy-key");
config.set_str("user_access_code", saved.dev_id, "legacy-user-key");
config.clear_local_machine_access_code(saved.dev_id, "moonraker");
REQUIRE(config.get_local_machines().at(saved.dev_id) == saved);
REQUIRE(config.get("access_code", saved.dev_id) == "legacy-key");
REQUIRE(config.get("user_access_code", saved.dev_id) == "legacy-user-key");
}
TEST_CASE("Clearing local credentials changes only the owning agent's key", "[AppConfig]")
{
ScopedDataDir data_dir("local-machine-clear");
save_main_thread_id();
AppConfig config;
BBLocalMachine saved;
saved.dev_id = "test_dev";
saved.dev_name = "Test printer";
saved.printer_agent_id = GENERATE("moonraker", "qidi");
saved.access_code = "saved-key";
config.update_local_machine(saved);
config.save();
REQUIRE_FALSE(config.dirty());
config.clear_local_machine_access_code(saved.dev_id, saved.printer_agent_id);
REQUIRE(config.dirty());
config.save();
AppConfig reloaded;
REQUIRE(reloaded.load().empty());
saved.access_code.clear();
REQUIRE(reloaded.get_local_machines().at(saved.dev_id) == saved);
REQUIRE_FALSE(reloaded.dirty());
reloaded.clear_local_machine_access_code(saved.dev_id, saved.printer_agent_id);
reloaded.clear_local_machine_access_code("unknown_dev", saved.printer_agent_id);
REQUIRE(reloaded.get_local_machines().size() == 1);
REQUIRE_FALSE(reloaded.dirty());
}
TEST_CASE("BBL credential clearing removes legacy fallback without changing foreign records", "[AppConfig]")
{
AppConfig config;
BBLocalMachine saved;
saved.dev_id = "test_dev";
saved.printer_agent_id = GENERATE("bbl", "", "moonraker");
saved.access_code = "saved-key";
config.update_local_machine(saved);
config.set_str("access_code", saved.dev_id, "legacy-key");
config.set_str("user_access_code", saved.dev_id, "legacy-user-key");
config.clear_local_machine_access_code(saved.dev_id, GENERATE("bbl", ""));
if (saved.printer_agent_id != "moonraker")
saved.access_code.clear();
REQUIRE(config.get_local_machines().at(saved.dev_id) == saved);
REQUIRE(config.get("access_code", saved.dev_id).empty());
REQUIRE(config.get("user_access_code", saved.dev_id).empty());
}
TEST_CASE("Remembered checkbox settings retain both selections", "[AppConfig][Regression]") {
AppConfig config;
const bool checked = GENERATE(false, true);
+63 -13
View File
@@ -261,17 +261,67 @@ TEST_CASE("Arrange without final alignment keeps items disjoint", "[Arrange]")
require_no_overlap(items);
}
TEST_CASE("Arrange aligns the pile to a custom center", "[Arrange]")
TEST_CASE("Arrange centers a pile that fits on the custom alignment point", "[Arrange]")
{
// align_center != (0.5, 0.5) selects Alignment::USER_DEFINED.
ArrangePolygons items = squares(5, 30.);
ArrangePolygons items = squares(4, 30.);
ArrangeParams params = quiet_params(scaled(2.));
params.align_center = Vec2d(0.3, 0.7);
arrange(items, bed(250, 250), params);
for (const ArrangePolygon &ap : items)
BoundingBox pile;
for (const ArrangePolygon &ap : items) {
REQUIRE(ap.bed_idx == 0);
pile.merge(ap.transformed_poly().contour.bounding_box());
}
// best_object_pos is align_center scaled to the bed.
const Point expected(scaled(250. * 0.3), scaled(250. * 0.7));
REQUIRE(std::abs(pile.center().x() - expected.x()) <= scaled(0.5));
REQUIRE(std::abs(pile.center().y() - expected.y()) <= scaled(0.5));
require_no_overlap(items);
}
// A row of four 40 mm parts centred on the 30% mark of a 200 x 50 mm bed would start 24 mm
// left of it (#15727). The pile stops at the left edge instead.
TEST_CASE("Arrange keeps a custom-aligned pile within the bed", "[Arrange]")
{
const BoundingBox bed_ = bed(200, 50);
ArrangePolygons items = squares(4, 40.);
ArrangeParams params = quiet_params(scaled(2.));
params.align_center = Vec2d(0.3, 0.5);
arrange(items, bed_, params);
coord_t leftmost = std::numeric_limits<coord_t>::max();
for (const ArrangePolygon &ap : items) {
REQUIRE(ap.bed_idx == 0);
const BoundingBox bb = ap.transformed_poly().contour.bounding_box();
CHECK(bed_.contains(bb));
leftmost = std::min(leftmost, bb.min.x());
}
// Stopped at the edge it was aimed at, not re-centred.
CHECK(leftmost < scaled(10.));
require_no_overlap(items);
}
// Arranging a selection leaves the unselected parts where they are, but the final alignment
// centres the box around both, so the selection does not move by its own centring. The check
// against the bed and the fixed part must test where the selection actually lands.
TEST_CASE("Arrange keeps a selection on the bed and clear of a fixed part", "[Arrange]")
{
ArrangePolygons fixed = squares(1, 40.);
fixed.front().translation = {scaled(80.), scaled(80.)};
ArrangePolygons items = squares(3, 40.);
for (ArrangePolygon &ap : items)
ap.inflation = scaled(1.);
arrange(items, fixed, bed(200, 200), quiet_params());
for (const ArrangePolygon &ap : items) {
REQUIRE(ap.bed_idx == 0);
REQUIRE(bed(200, 200).contains(ap.transformed_poly().contour.bounding_box()));
}
items.insert(items.end(), fixed.begin(), fixed.end());
require_no_overlap(items);
}
@@ -303,10 +353,9 @@ TEST_CASE("Arrange keeps a pile aligned near an edge on the bed", "[Arrange][bel
require_no_overlap(items);
}
// The clamp is a belt feature. Printers whose best_object_pos is off-centre (the A1 mini
// and the H2 family) keep their final alignment: the pile is centred on that point, even
// when that puts part of it outside the bed.
TEST_CASE("Arrange leaves the final alignment of a flat bed unclamped", "[Arrange]")
// Flat-bed printers with an off-centre best_object_pos (the A1 mini and the H2 family) get
// the same clamp: centring the pile on that point used to push it off the bed (#15727).
TEST_CASE("Arrange keeps a flat-bed pile aligned near an edge on the bed", "[Arrange]")
{
const BoundingBox bed_ = bed(95, 500);
ArrangePolygons items = squares(4, 90.);
@@ -315,14 +364,15 @@ TEST_CASE("Arrange leaves the final alignment of a flat bed unclamped", "[Arrang
arrange(items, bed_, params);
BoundingBox pile;
coord_t lowest = std::numeric_limits<coord_t>::max();
for (const ArrangePolygon &ap : items) {
REQUIRE(ap.bed_idx == 0);
pile.merge(ap.transformed_poly().contour.bounding_box());
const BoundingBox bb = ap.transformed_poly().contour.bounding_box();
CHECK(bed_.contains(bb));
lowest = std::min(lowest, bb.min.y());
}
// Centred on the 5% mark of the bed's length, not pushed inside it.
CHECK_THAT(unscaled<double>(pile.center().y()), Catch::Matchers::WithinAbs(0.05 * 500., 15.));
CHECK(pile.min.y() < 0);
// Stopped at the edge it was aimed at, not re-centred.
CHECK(lowest < scaled(10.));
require_no_overlap(items);
}
+1 -1
View File
@@ -283,7 +283,7 @@ TEST_CASE("A Span mode refuses a color routed to a tool outside the primary's ga
auto pem = make_pem({0, 1, 2, 3});
const std::string reason = imex_multicolor_block_reason("mc-copy", "0:P,1:S,2:C,3:C", 2, {0, 1, 3}, pem);
// The message names the color tools and where the map sends the offending filament (1-based).
REQUIRE_THAT(reason, Catch::Matchers::ContainsSubstring("only T0/T1 print colors, but filament 4 is routed to T3"));
REQUIRE_THAT(reason, Catch::Matchers::ContainsSubstring("colors are printed only by T0/T1, but filament 4 is routed to T3"));
REQUIRE_THAT(imex_multicolor_block_reason("mc-copy", "0:P,1:S,2:C,3:C", 2, {0, 4}, pem),
Catch::Matchers::ContainsSubstring("filament 5 isn't routed to any tool"));
// A head the mode leaves inactive is no better, and neither is no head at all.
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).
+5
View File
@@ -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
+1 -1
View File
@@ -255,7 +255,7 @@ TEST_CASE("Native command catalog includes the Help and developer-mode commands"
const Slic3r::GUI::NativeCommand* first = find("help_keyboard_shortcuts");
REQUIRE(first != nullptr);
for (const char* key : {"help_setup_wizard", "help_open_config_folder", "help_troubleshoot", "help_network_test",
for (const char* key : {"help_setup_wizard", "help_open_config_folder", "help_migrate_filament_presets", "help_troubleshoot", "help_network_test",
"help_benchmark_3d_scene", "help_tip_of_the_day", "help_check_updates", "help_about", "open_wiki",
"open_youtube"}) {
const Slic3r::GUI::NativeCommand* c = find(key);
@@ -0,0 +1,47 @@
#include "slic3r/GUI/Field.hpp"
#include "libslic3r/Config.hpp"
#include <boost/any.hpp>
#include <catch2/catch_test_macros.hpp>
#include <string>
#include <wx/colour.h>
#include <wx/string.h>
using namespace Slic3r;
using namespace Slic3r::GUI;
namespace {
class TestColourPicker : public ColourPicker {
public:
TestColourPicker() : ColourPicker(ConfigOptionDef{}, "filament_colour") {}
std::string value() { return boost::any_cast<std::string>(get_value()); }
};
}
TEST_CASE("Undefined color fields remain distinct from opaque black", "[ColourPickerField]")
{
TestColourPicker field;
REQUIRE(field.value().empty());
field.set_value(std::string("#000000"));
REQUIRE(field.value() == "#000000");
field.set_value(std::string("invalid color"));
REQUIRE(field.value().empty());
field.set_value(boost::any(wxString("#123456")));
REQUIRE(field.value() == "#123456");
field.set_value(std::string());
REQUIRE(field.value().empty());
}
TEST_CASE("Programmatic color field assignment stays silent", "[ColourPickerField]")
{
TestColourPicker field;
int changes = 0;
field.m_on_change = [&](const std::string&, const boost::any&) { ++changes; };
field.set_value(std::string("#123456"), false);
field.set_value(boost::any(wxString("#654321")), true);
REQUIRE(changes == 0);
REQUIRE(field.value() == "#654321");
field.m_disable_change_event = true;
field.set_value(std::string("#FF0000"));
REQUIRE(field.value() == "#FF0000");
REQUIRE(changes == 0);
}
+128
View File
@@ -0,0 +1,128 @@
#ifdef WIN32
#ifndef WIN32_LEAN_AND_MEAN
#define WIN32_LEAN_AND_MEAN
#endif
#ifndef NOMINMAX
#define NOMINMAX
#endif
#include <Windows.h>
#endif
#include <catch2/catch_test_macros.hpp>
#include <catch2/generators/catch_generators.hpp>
#include <memory>
#include "libslic3r/AppConfig.hpp"
#include "slic3r/GUI/DeviceCore/DevManager.h"
#include "slic3r/GUI/DeviceManager.hpp"
#include "slic3r/Utils/IPrinterAgent.hpp"
#include "slic3r/Utils/NetworkAgentFactory.hpp"
#include "slic3r/Utils/MoonrakerPrinterAgent.hpp"
#include "slic3r/Utils/NetworkAgent.hpp"
using namespace Slic3r;
TEST_CASE("Moonraker permits an empty access code", "[DeviceAccess]")
{
MachineObject machine(nullptr, nullptr, "test", "test_dev", "127.0.0.1");
machine.printer_agent_id = MOONRAKER_PRINTER_AGENT_ID;
machine.set_access_code("", false);
REQUIRE(machine.has_access_right());
}
TEST_CASE("Moonraker logout revokes access until explicitly rebound", "[DeviceAccess]")
{
MachineObject machine(nullptr, nullptr, "test", "test_dev", "127.0.0.1");
machine.printer_agent_id = MOONRAKER_PRINTER_AGENT_ID;
const std::string code = GENERATE("", "configured-key");
machine.set_access_code(code, false);
REQUIRE(machine.has_access_right());
machine.revoke_access();
REQUIRE(machine.get_access_code().empty());
REQUIRE_FALSE(machine.has_access_right());
machine.set_access_code(code, false);
REQUIRE(machine.has_access_right());
REQUIRE(machine.get_access_code() == code);
}
TEST_CASE("Revoked access prevents calls to the printer agent", "[DeviceAccess]")
{
class RecordingAgent : public MoonrakerPrinterAgent
{
public:
RecordingAgent() : MoonrakerPrinterAgent("") {}
int connections = 0;
int connect_printer(const PrinterConnectionParams&) override
{
++connections;
return 0;
}
int disconnect_printer() override { return 0; }
};
auto agent = std::make_shared<RecordingAgent>();
NetworkAgent network(nullptr, agent);
MachineObject machine(nullptr, &network, "test", "test_dev", "127.0.0.1");
machine.printer_agent_id = GENERATE(BBL_PRINTER_AGENT_ID, ORCA_PRINTER_AGENT_ID, MOONRAKER_PRINTER_AGENT_ID);
machine.set_access_code("configured-key", false);
REQUIRE(machine.connect() == 0);
REQUIRE(agent->connections == 1);
machine.revoke_access();
REQUIRE(machine.connect() == -1);
REQUIRE(agent->connections == 1);
machine.set_access_code("configured-key", false);
REQUIRE(machine.connect() == 0);
REQUIRE(agent->connections == 2);
}
TEST_CASE("Other printer agents require an access code", "[DeviceAccess]")
{
MachineObject machine(nullptr, nullptr, "test", "test_dev", "127.0.0.1");
machine.printer_agent_id = GENERATE(
BBL_PRINTER_AGENT_ID, ORCA_PRINTER_AGENT_ID, "qidi", "snapmaker", "crealityprint", "plugin", "unknown");
machine.set_access_code("", false);
REQUIRE_FALSE(machine.has_access_right());
machine.set_access_code("88888888", false);
REQUIRE(machine.has_access_right());
}
TEST_CASE("Rebinding Moonraker with an empty key saves the machine", "[DeviceAccess]")
{
AppConfig config;
DeviceManager manager(nullptr, false, &config);
MachineObject machine(&manager, nullptr, "test", "test_dev", "127.0.0.1");
machine.printer_agent_id = MOONRAKER_PRINTER_AGENT_ID;
machine.dev_connection_type = "lan";
machine.revoke_access();
machine.set_access_code("");
const auto& machines = config.get_local_machines();
REQUIRE(machines.count("test_dev") == 1);
REQUIRE(machines.at("test_dev").printer_agent_id == MOONRAKER_PRINTER_AGENT_ID);
REQUIRE(machines.at("test_dev").access_code.empty());
}
TEST_CASE("Moonraker discovery leaves another agent's saved machine alone", "[DeviceAccess]")
{
AppConfig config;
NetworkAgent network(nullptr, std::make_shared<MoonrakerPrinterAgent>(""));
DeviceManager manager(&network, false, &config);
const std::string owner = GENERATE(as<std::string>{}, ORCA_PRINTER_AGENT_ID, MOONRAKER_PRINTER_AGENT_ID);
BBLocalMachine saved;
saved.dev_id = "192.0.2.10:7125";
saved.dev_ip = saved.dev_id;
saved.printer_agent_id = owner;
config.update_local_machine(saved);
manager.on_machine_alive(R"({"dev_name":"Klipper","dev_id":"192.0.2.10:7125","dev_ip":"192.0.2.10:7125",
"dev_type":"","dev_signal":"0","connect_type":"lan","bind_state":"free"})");
REQUIRE((manager.get_local_machine(saved.dev_id) != nullptr) == (owner == MOONRAKER_PRINTER_AGENT_ID));
}
@@ -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]")