mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-10-20 06:01:16 +00:00
Merge branch 'main' into feat/preset-plugin-apis
This commit is contained in:
63 files changed
+2975
-386
No files matched your search
@@ -163,7 +163,7 @@ M109 S240 T0 ; set nozzle temperature and wait for it to be reached
|
||||
; CP TOOLCHANGE END
|
||||
M104 S240 T1 ; preheat T1 time: 30s lead 30.0s
|
||||
; CP TOOLCHANGE START
|
||||
M104 S200 T0 ; set nozzle temperature ;cooldown
|
||||
M104 S0 T0 ; set nozzle temperature ;cooldown
|
||||
T1 ; change extruder
|
||||
M109 S240 T1 ; set nozzle temperature and wait for it to be reached
|
||||
; CP TOOLCHANGE END
|
||||
|
||||
@@ -1742,3 +1742,112 @@ TEST_CASE("Each filament cools with the fan speeds of its extruder variant", "[M
|
||||
CHECK(gcode.find("; start range high " + std::to_string(range_high) + "\n") != std::string::npos);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("Shared heater on IMEX printer stays hot until all filaments on it are done", "[MultiFilament]")
|
||||
{
|
||||
DynamicPrintConfig cfg = multifilament_config(7);
|
||||
cfg.set_deserialize_strict({
|
||||
{"ooze_prevention", 1},
|
||||
{"preheat_time", 0},
|
||||
{"single_extruder_multi_material", 0},
|
||||
});
|
||||
imex_7x4_printer(cfg);
|
||||
|
||||
const std::string gcode = slice_with_object_overrides({make_cube(20., 20., 10.), make_cube(20., 20., 4.), make_cube(20., 20., 10.)},
|
||||
cfg,
|
||||
{{{"extruder", 1}, {"layer_height", 0.30}},
|
||||
{{"extruder", 2}, {"layer_height", 0.15}},
|
||||
{{"extruder", 5}}});
|
||||
|
||||
bool premature_s0_t0 = false;
|
||||
double current_z = 0.0;
|
||||
|
||||
GCodeReader reader;
|
||||
reader.parse_buffer(gcode, [&](GCodeReader& /*self*/, const GCodeReader::GCodeLine& line) {
|
||||
if (line.has_z())
|
||||
current_z = line.z();
|
||||
|
||||
const std::string raw = line.raw();
|
||||
if (raw.find("M104 S0 T0") != std::string::npos && raw.find(";cooldown") != std::string::npos) {
|
||||
if (current_z < 9.0)
|
||||
premature_s0_t0 = true;
|
||||
}
|
||||
});
|
||||
|
||||
// Filaments 1 and 2 share head 0, and filament 1 prints up to 10mm.
|
||||
// T0 must not shut down at ~4mm when filament 2 finishes.
|
||||
CHECK_FALSE(premature_s0_t0);
|
||||
}
|
||||
|
||||
TEST_CASE("Unused extruder turns off after its final layer on multi-object print", "[MultiFilament]")
|
||||
{
|
||||
const std::string gcode = slice_with_object_overrides(
|
||||
{ make_cube(20., 20., 10.), make_cube(20., 20., 4.) },
|
||||
multifilament_config(2, {
|
||||
{ "single_extruder_multi_material", 0 },
|
||||
{ "ooze_prevention", 1 },
|
||||
{ "standby_temperature_delta", -40 },
|
||||
{ "nozzle_temperature", "240,240" },
|
||||
}),
|
||||
{
|
||||
{ { "extruder", 1 }, { "layer_height", 0.20 } },
|
||||
{ { "extruder", 2 }, { "layer_height", 0.15 } }
|
||||
});
|
||||
|
||||
int t1_s0_cooldown_count = 0;
|
||||
int t0_s0_cooldown_count = 0;
|
||||
|
||||
std::istringstream stream(gcode);
|
||||
for (std::string line; std::getline(stream, line);) {
|
||||
if (line.find(";cooldown") != std::string::npos) {
|
||||
if (line.find("M104 S0 T1") != std::string::npos)
|
||||
++t1_s0_cooldown_count;
|
||||
if (line.find("M104 S0 T0") != std::string::npos)
|
||||
++t0_s0_cooldown_count;
|
||||
}
|
||||
}
|
||||
|
||||
// T1 (extruder 2, 4mm cube) gets exactly one S0 after its last layer.
|
||||
// T0 (extruder 1, 10mm cube) never gets S0 mid-print.
|
||||
CHECK(t1_s0_cooldown_count == 1);
|
||||
CHECK(t0_s0_cooldown_count == 0);
|
||||
}
|
||||
|
||||
TEST_CASE("Unused extruder turns off on non-IMEX three-tool print", "[MultiFilament]")
|
||||
{
|
||||
const std::string gcode = slice_with_object_overrides(
|
||||
{ make_cube(20., 20., 4.), make_cube(20., 20., 10.), make_cube(20., 20., 10.) },
|
||||
multifilament_config(3, {
|
||||
{ "single_extruder_multi_material", 0 },
|
||||
{ "ooze_prevention", 1 },
|
||||
{ "standby_temperature_delta", -40 },
|
||||
{ "nozzle_temperature", "240,240,240" },
|
||||
}),
|
||||
{
|
||||
{ { "extruder", 1 } },
|
||||
{ { "extruder", 2 } },
|
||||
{ { "extruder", 3 } }
|
||||
});
|
||||
|
||||
bool premature_s0_t1 = false;
|
||||
bool premature_s0_t2 = false;
|
||||
double current_z = 0.0;
|
||||
|
||||
GCodeReader reader;
|
||||
reader.parse_buffer(gcode, [&](GCodeReader& /*self*/, const GCodeReader::GCodeLine& line) {
|
||||
if (line.has_z())
|
||||
current_z = line.z();
|
||||
|
||||
const std::string raw = line.raw();
|
||||
if (raw.find(";cooldown") != std::string::npos && current_z < 9.0) {
|
||||
if (raw.find("M104 S0 T1") != std::string::npos)
|
||||
premature_s0_t1 = true;
|
||||
if (raw.find("M104 S0 T2") != std::string::npos)
|
||||
premature_s0_t2 = true;
|
||||
}
|
||||
});
|
||||
|
||||
// T1 and T2 print up to 10mm — neither should get S0 before that.
|
||||
CHECK_FALSE(premature_s0_t1);
|
||||
CHECK_FALSE(premature_s0_t2);
|
||||
}
|
||||
@@ -18,6 +18,7 @@
|
||||
#include <fstream>
|
||||
#include <ios>
|
||||
#include <iterator>
|
||||
#include <libslic3r/Utils.hpp>
|
||||
#include <miniz.h>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
@@ -176,6 +177,55 @@ TEST_CASE("Confined extraction writes nothing outside the target for Windows-spe
|
||||
CHECK(list_dir(tmp.path()) == std::vector<std::string>{"bundle.zip", "cache"});
|
||||
}
|
||||
|
||||
TEST_CASE("Confined extraction writes files under a target path longer than MAX_PATH", "[MinizExtension]")
|
||||
{
|
||||
ScopedTemporaryDir tmp;
|
||||
const fs::path zip_file = tmp.path() / "bundle.zip";
|
||||
// Directory entries are explicit: extract_archive_confined recreates directories only for them.
|
||||
write_zip(zip_file, {{"vendor/", ""}, {"vendor/machine/", ""}, {"vendor.json", "{\"a\":1}"},
|
||||
{"vendor/machine/printer.json", "{\"b\":2}"}});
|
||||
|
||||
// One 240-char component keeps every name below the NTFS 255 limit while pushing the full
|
||||
// target path past the 260-char MAX_PATH the Win32 APIs enforce when LongPathsEnabled is off.
|
||||
const fs::path target = tmp.path() / std::string(240, 'd');
|
||||
REQUIRE(target.string().size() > 260);
|
||||
fs::create_directories(extended_path(target));
|
||||
|
||||
REQUIRE(extract_archive_confined(zip_file.string(), target.string()));
|
||||
// Verify through extended-length paths, otherwise the checks themselves are bounded by MAX_PATH.
|
||||
CHECK(fs::is_directory(extended_path(target / "vendor")));
|
||||
CHECK(read_file(extended_path(target / "vendor.json")) == "{\"a\":1}");
|
||||
CHECK(read_file(extended_path(target / "vendor" / "machine" / "printer.json")) == "{\"b\":2}");
|
||||
|
||||
// The temporary dir guard cannot remove a deep tree on Windows, so drop it here.
|
||||
boost::system::error_code ec;
|
||||
fs::remove_all(extended_path(target), ec);
|
||||
CHECK_FALSE(ec);
|
||||
}
|
||||
|
||||
TEST_CASE("A zip archive under a path longer than MAX_PATH can be written and read", "[MinizExtension]")
|
||||
{
|
||||
ScopedTemporaryDir tmp;
|
||||
|
||||
// Create the long directory with the extended-length path, then exercise the archive
|
||||
// reader/writer against an archive whose own path is past MAX_PATH.
|
||||
const fs::path long_dir = tmp.path() / std::string(240, 'z');
|
||||
fs::create_directories(extended_path(long_dir));
|
||||
const fs::path zip_file = long_dir / "bundle.zip";
|
||||
REQUIRE(zip_file.string().size() > 260);
|
||||
|
||||
write_zip(zip_file, {{"vendor.json", "{\"a\":1}"}});
|
||||
|
||||
const fs::path target = tmp.path() / "out";
|
||||
fs::create_directories(target);
|
||||
REQUIRE(extract_archive_confined(zip_file.string(), target.string()));
|
||||
CHECK(read_file(target / "vendor.json") == "{\"a\":1}");
|
||||
|
||||
boost::system::error_code ec;
|
||||
fs::remove_all(extended_path(long_dir), ec);
|
||||
CHECK_FALSE(ec);
|
||||
}
|
||||
|
||||
#ifndef _WIN32
|
||||
TEST_CASE("Confined extraction replaces a symlink at the destination instead of writing through it", "[MinizExtension]")
|
||||
{
|
||||
|
||||
@@ -521,3 +521,49 @@ TEST_CASE("ascii_iequals compares ASCII letters regardless of case", "[Utils]")
|
||||
CHECK_FALSE(ascii_iequals("[", "{"));
|
||||
CHECK_FALSE(ascii_iequals("@", "`"));
|
||||
}
|
||||
|
||||
TEST_CASE("extended_path turns a Windows path into its long-path form", "[Utils]") {
|
||||
#ifdef _WIN32
|
||||
namespace fs = boost::filesystem;
|
||||
|
||||
SECTION("a normal absolute drive path is prefixed") {
|
||||
CHECK(extended_path(fs::path(R"(C:\models\model.3mf)")).wstring() == LR"(\\?\C:\models\model.3mf)");
|
||||
}
|
||||
SECTION("separators are made native") {
|
||||
CHECK(extended_path(fs::path("C:/models/model.3mf")).wstring() == LR"(\\?\C:\models\model.3mf)");
|
||||
}
|
||||
SECTION("dot segments are collapsed before prefixing") {
|
||||
// The "\\?\" prefix disables Win32 normalisation, so "." and ".." must be gone beforehand.
|
||||
CHECK(extended_path(fs::path(R"(C:\models\..\model.3mf)")).wstring() == LR"(\\?\C:\model.3mf)");
|
||||
}
|
||||
SECTION("a trailing separator is dropped, not turned into a dot element") {
|
||||
CHECK(extended_path(fs::path(R"(C:\models\)")).wstring() == LR"(\\?\C:\models)");
|
||||
}
|
||||
SECTION("a UNC path becomes an extended UNC path") {
|
||||
CHECK(extended_path(fs::path(R"(\\server\share\model.3mf)")).wstring() == LR"(\\?\UNC\server\share\model.3mf)");
|
||||
}
|
||||
SECTION("an already extended path is returned unchanged") {
|
||||
const fs::path extended(R"(\\?\C:\models\model.3mf)");
|
||||
CHECK(extended_path(extended) == extended);
|
||||
}
|
||||
SECTION("a device path is returned unchanged") {
|
||||
const fs::path device(R"(\\.\C:\models\model.3mf)");
|
||||
CHECK(extended_path(device) == device);
|
||||
}
|
||||
SECTION("a bare device path without a root directory is returned unchanged") {
|
||||
// Not "absolute" by Boost's rules, so it must be recognized before absolutizing.
|
||||
const fs::path device(R"(\\.\PhysicalDrive0)");
|
||||
CHECK(extended_path(device) == device);
|
||||
}
|
||||
SECTION("a relative path is completed against the working directory") {
|
||||
const fs::path result = extended_path(fs::path("model.3mf"));
|
||||
CHECK(result.wstring().rfind(L"\\\\?\\", 0) == 0);
|
||||
CHECK(result.filename() == fs::path("model.3mf"));
|
||||
}
|
||||
#else
|
||||
SECTION("non-Windows returns the path unchanged") {
|
||||
const boost::filesystem::path p = boost::filesystem::temp_directory_path() / "model.3mf";
|
||||
CHECK(extended_path(p) == p);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -32,6 +32,7 @@ add_executable(${_TEST_NAME}_tests
|
||||
test_printer_agent.cpp
|
||||
test_plugin_install.cpp
|
||||
test_plugin_lifecycle.cpp
|
||||
test_plugin_resource_texture.cpp
|
||||
test_plugin_printer_agent.cpp
|
||||
test_printhost.cpp
|
||||
test_slicing_pipeline_bindings.cpp
|
||||
@@ -44,7 +45,9 @@ add_executable(${_TEST_NAME}_tests
|
||||
test_texture_color_palette.cpp
|
||||
test_texture_island_view.cpp
|
||||
test_file_url.cpp
|
||||
test_imgui_wrapper.cpp
|
||||
test_user_manager.cpp
|
||||
test_sequential_clearance.cpp
|
||||
../fff_print/test_helpers.cpp
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
// How ImGuiWrapper turns wx mouse events into ImGui input. ImGuiWrapper lives in libslic3r_gui; this is
|
||||
// the suite that links it. Same Windows include prologue as test_texture_color_palette.cpp (wx pulls in
|
||||
// <windows.h>; keep WIN32_LEAN_AND_MEAN / NOMINMAX ahead of it).
|
||||
#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 <catch2/matchers/catch_matchers.hpp>
|
||||
#include <catch2/matchers/catch_matchers_floating_point.hpp>
|
||||
|
||||
#include <imgui/imgui.h>
|
||||
#include <wx/event.h>
|
||||
|
||||
#include "slic3r/GUI/ImGuiWrapper.hpp"
|
||||
|
||||
using Catch::Matchers::WithinAbs;
|
||||
using Slic3r::GUI::ImGuiWrapper;
|
||||
|
||||
namespace {
|
||||
|
||||
// ImGui's wheel after one wheel event, as wx on macOS builds it: a delta of 10 and the rotation in whole
|
||||
// points of trackpad travel.
|
||||
float wheel_after(int rotation, wxMouseWheelAxis axis)
|
||||
{
|
||||
ImGuiWrapper imgui;
|
||||
imgui.set_display_size(800.f, 600.f); // update_mouse_data() ignores input until the display has a size
|
||||
|
||||
wxMouseEvent evt(wxEVT_MOUSEWHEEL);
|
||||
evt.m_wheelRotation = rotation;
|
||||
evt.m_wheelDelta = 10;
|
||||
evt.m_wheelAxis = axis;
|
||||
imgui.update_mouse_data(evt);
|
||||
return ImGui::GetIO().MouseWheel;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("a vertical wheel event steps ImGui's wheel in its own direction", "[ImGuiWrapper]")
|
||||
{
|
||||
const int rotation = GENERATE(1, 7, 120, -1, -7, -120);
|
||||
CHECK_THAT(wheel_after(rotation, wxMOUSE_WHEEL_VERTICAL), WithinAbs(rotation > 0 ? 1.0 : -1.0, 0.0));
|
||||
}
|
||||
|
||||
// Both kinds arrive from a macOS trackpad while scrolling; see ImGuiWrapper::update_mouse_data().
|
||||
TEST_CASE("a wheel event with no rotation or on the horizontal axis does not step ImGui's wheel", "[ImGuiWrapper][Regression]")
|
||||
{
|
||||
const auto [rotation, axis] = GENERATE(table<int, wxMouseWheelAxis>({
|
||||
{ 0, wxMOUSE_WHEEL_VERTICAL },
|
||||
{ 5, wxMOUSE_WHEEL_HORIZONTAL },
|
||||
{ -5, wxMOUSE_WHEEL_HORIZONTAL },
|
||||
}));
|
||||
CHECK_THAT(wheel_after(rotation, axis), WithinAbs(0.0, 0.0));
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
#include <catch2/catch_all.hpp>
|
||||
|
||||
#include <ios>
|
||||
#include <libslic3r/Utils.hpp>
|
||||
#include <slic3r/plugin/PluginLoader.hpp>
|
||||
#include <slic3r/plugin/PluginDescriptor.hpp>
|
||||
#include <slic3r/plugin/PluginFsUtils.hpp>
|
||||
@@ -10,13 +11,17 @@
|
||||
#include <catch2/catch_test_macros.hpp>
|
||||
#include <catch2/matchers/catch_matchers.hpp>
|
||||
#include <catch2/matchers/catch_matchers_string.hpp>
|
||||
#include "catch2/catch_message.hpp"
|
||||
#include "plugin_test_utils.hpp"
|
||||
#include "test_utils.hpp"
|
||||
|
||||
#include <boost/filesystem.hpp>
|
||||
|
||||
#include <fstream>
|
||||
#include <string>
|
||||
|
||||
#include <libslic3r/Zipper.hpp>
|
||||
|
||||
using namespace Slic3r;
|
||||
namespace fs = boost::filesystem;
|
||||
|
||||
@@ -31,6 +36,14 @@ fs::path write_py_file(const fs::path& dir, const std::string& filename, const s
|
||||
return p;
|
||||
}
|
||||
|
||||
// The shared production helper: makes the path absolute and lexically normalized and, on
|
||||
// Windows, prefixes it with the extended-length "\\?\" form, so the test can create, verify and
|
||||
// clean up a tree past MAX_PATH even with LongPathsEnabled off. Identity on other platforms.
|
||||
fs::path to_extended_path(const fs::path& p)
|
||||
{
|
||||
return Slic3r::extended_path(p);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("install_plugin rejects a cloud UUID containing path traversal", "[PluginInstall]")
|
||||
@@ -184,3 +197,59 @@ TEST_CASE("an unsubscribed cloud plugin follows its header version", "[PluginIns
|
||||
REQUIRE(read_install_state(plugin_dir, scanned));
|
||||
CHECK(scanned.installed_version == "1.0.0");
|
||||
}
|
||||
|
||||
TEST_CASE("extract_zip_to_directory writes entries under a destination longer than MAX_PATH",
|
||||
"[PluginInstall][PluginFsUtils]")
|
||||
{
|
||||
ScopedTemporaryDir dir("deep-extract");
|
||||
|
||||
const fs::path wheel = dir.path() / "deep_pkg-1.0-py3-none-any.whl";
|
||||
const std::string payload = "print('hello from a deep path')\n";
|
||||
{
|
||||
Zipper zipper(wheel.string(), Zipper::NO_COMPRESSION);
|
||||
zipper.add_entry("pkg/", "", 0); // explicit directory entry
|
||||
zipper.add_entry("pkg/module.py", payload.data(), payload.size());
|
||||
zipper.finalize();
|
||||
}
|
||||
|
||||
// A single 240-char component keeps every filename below the NTFS 255 limit while pushing the
|
||||
// full destination (temp dir + component) past the 260-char MAX_PATH the Win32 APIs enforce
|
||||
// when LongPathsEnabled is off.
|
||||
const fs::path destination = dir.path() / std::string(240, 'd');
|
||||
REQUIRE(destination.string().size() > 260);
|
||||
|
||||
std::string error;
|
||||
REQUIRE(extract_zip_to_directory(wheel, destination, error));
|
||||
INFO("extract error: " << error);
|
||||
CHECK(error.empty());
|
||||
|
||||
// Verify through a long-path-capable path, otherwise the check itself is bounded by MAX_PATH.
|
||||
const fs::path module_py = destination / "pkg" / "module.py";
|
||||
REQUIRE(fs::exists(to_extended_path(module_py)));
|
||||
REQUIRE(fs::is_regular_file(to_extended_path(module_py)));
|
||||
|
||||
// Remove the deep subtree here: ScopedTemporaryDir's plain remove_all cannot reach past
|
||||
// MAX_PATH on Windows.
|
||||
boost::system::error_code ec;
|
||||
fs::remove_all(to_extended_path(destination), ec);
|
||||
CHECK_FALSE(ec);
|
||||
}
|
||||
|
||||
TEST_CASE("extract_zip_to_directory rejects an entry that escapes the destination",
|
||||
"[PluginInstall][PluginFsUtils]")
|
||||
{
|
||||
ScopedTemporaryDir dir("escape-extract");
|
||||
|
||||
const fs::path wheel = dir.path() / "evil-1.0-py3-none-any.whl";
|
||||
{
|
||||
Zipper zipper(wheel.string(), Zipper::NO_COMPRESSION);
|
||||
zipper.add_entry("pkg/module.py", "print('ok')\n", 12);
|
||||
zipper.add_entry("../escape.txt", "escaped\n", 8);
|
||||
zipper.finalize();
|
||||
}
|
||||
|
||||
std::string error;
|
||||
CHECK_FALSE(extract_zip_to_directory(wheel, dir.path() / "out", error));
|
||||
CHECK_THAT(error, Catch::Matchers::ContainsSubstring("escapes the plugin package"));
|
||||
CHECK_FALSE(fs::exists(dir.path() / "escape.txt"));
|
||||
}
|
||||
@@ -0,0 +1,941 @@
|
||||
// ResourceTexture plugin type: directory validation, the host registry's lifecycle, the texture
|
||||
// listing's plugin section, hostile get_texture_dir() overrides, decode parity and the .3mf round
|
||||
// trip without the pack installed.
|
||||
//
|
||||
// The GUI wiring lives in GUI_App::init_plugin_gui_wiring; the test binary has no app, so the same
|
||||
// registry callbacks are installed here. As in test_plugin_lifecycle.cpp, each Catch2 test case runs
|
||||
// in its own process (catch_discover_tests), so the PluginManager and interpreter come up at most once
|
||||
// per test.
|
||||
//
|
||||
// Same Windows include prologue as test_texture_color_palette.cpp: wx pulls in <windows.h>, so keep
|
||||
// WIN32_LEAN_AND_MEAN / NOMINMAX ahead of the Catch2 headers.
|
||||
#include "test_utils.hpp"
|
||||
#include <slic3r/plugin/PluginDescriptor.hpp>
|
||||
#ifdef WIN32
|
||||
#ifndef WIN32_LEAN_AND_MEAN
|
||||
#define WIN32_LEAN_AND_MEAN
|
||||
#endif
|
||||
#ifndef NOMINMAX
|
||||
#define NOMINMAX
|
||||
#endif
|
||||
#include <Windows.h>
|
||||
#endif
|
||||
|
||||
#include <boost/filesystem.hpp>
|
||||
#include <boost/filesystem/operations.hpp>
|
||||
#include <boost/filesystem/path.hpp>
|
||||
#include <boost/system/error_code.hpp>
|
||||
#include <catch2/catch_all.hpp>
|
||||
#include <catch2/catch_message.hpp>
|
||||
#include <catch2/catch_test_macros.hpp>
|
||||
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <fstream>
|
||||
#include <initializer_list>
|
||||
#include <ios>
|
||||
#include <iterator>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include <wx/image.h>
|
||||
|
||||
#include "libslic3r/Config.hpp"
|
||||
#include "libslic3r/Format/bbs_3mf.hpp"
|
||||
#include "libslic3r/Model.hpp"
|
||||
#include "libslic3r/PNGReadWrite.hpp"
|
||||
#include "libslic3r/Preset.hpp"
|
||||
#include "libslic3r/PrintConfig.hpp"
|
||||
#include "libslic3r/Semver.hpp"
|
||||
#include "libslic3r/TextureDisplacement.hpp"
|
||||
#include "libslic3r/TriangleMesh.hpp"
|
||||
#include "libslic3r/Utils.hpp"
|
||||
#include "libslic3r/Zipper.hpp"
|
||||
|
||||
#include "slic3r/GUI/TextureLibrary.hpp"
|
||||
#include "slic3r/plugin/PluginFsUtils.hpp"
|
||||
#include "slic3r/plugin/PluginManager.hpp"
|
||||
#include "slic3r/plugin/PythonInterpreter.hpp"
|
||||
#include "slic3r/plugin/PythonPluginInterface.hpp"
|
||||
#include "slic3r/plugin/host/PluginResourceTextures.hpp"
|
||||
|
||||
#include "plugin_test_utils.hpp"
|
||||
|
||||
using namespace Slic3r;
|
||||
namespace fs = boost::filesystem;
|
||||
|
||||
namespace {
|
||||
|
||||
// Brings the plugin system up, and tears it down explicitly at the end of the test, as
|
||||
// test_plugin_lifecycle.cpp does. Declare this FIRST in a test so it is destroyed last.
|
||||
struct ScopedPluginManager
|
||||
{
|
||||
// Before initialize(): the interpreter creates {data_dir}/python/packages and {data_dir}/log.
|
||||
ScopedDataDir python_data_dir{"texture-python"};
|
||||
bool initialized = false;
|
||||
|
||||
ScopedPluginManager() { initialized = PluginManager::instance().initialize(); }
|
||||
~ScopedPluginManager()
|
||||
{
|
||||
PluginManager::instance().shutdown();
|
||||
PythonInterpreter::instance().shutdown();
|
||||
}
|
||||
};
|
||||
|
||||
// The app installs these in GUI_App::init_plugin_gui_wiring. Without an app the callbacks run inline
|
||||
// on the load worker, which is what lets the tests observe the registry right after the wait.
|
||||
void install_resource_texture_wiring()
|
||||
{
|
||||
static bool installed = false;
|
||||
if (installed)
|
||||
return;
|
||||
installed = true;
|
||||
|
||||
PluginManager& manager = PluginManager::instance();
|
||||
manager.subscribe_on_load_callback([](const std::string& plugin_key) {
|
||||
PluginResourceTextures::instance().on_plugin_register(plugin_key);
|
||||
GUI::bump_texture_library_generation();
|
||||
});
|
||||
manager.subscribe_on_unload_callback([](const std::string& plugin_key) {
|
||||
PluginResourceTextures::instance().on_plugin_deregister(plugin_key);
|
||||
GUI::bump_texture_library_generation();
|
||||
});
|
||||
manager.subscribe_on_capability_load_callback([](const PluginCapabilityId& capability) {
|
||||
if (capability.type != PluginCapabilityType::ResourceTexture)
|
||||
return;
|
||||
PluginResourceTextures::instance().on_cap_register(capability);
|
||||
GUI::bump_texture_library_generation();
|
||||
});
|
||||
manager.subscribe_on_capability_unload_callback([](const PluginCapabilityId& capability) {
|
||||
if (capability.type != PluginCapabilityType::ResourceTexture)
|
||||
return;
|
||||
PluginResourceTextures::instance().on_cap_deregister(capability);
|
||||
GUI::bump_texture_library_generation();
|
||||
});
|
||||
}
|
||||
|
||||
// A minimal resource-texture plugin. `display_name` is the PEP723 package name and, unless
|
||||
// `capability_name` is given, the capability's get_name() too; pass `capability_name` to keep the
|
||||
// package name and the capability name independent.
|
||||
std::string make_resource_texture_source(const std::string& display_name,
|
||||
const std::string& get_texture_dir_body,
|
||||
const std::string& capability_name = "")
|
||||
{
|
||||
const std::string cap_name = capability_name.empty() ? display_name : capability_name;
|
||||
return std::string{}
|
||||
+ "# /// script\n"
|
||||
+ "# requires-python = \">=3.12\"\n"
|
||||
+ "# dependencies = []\n"
|
||||
+ "#\n"
|
||||
+ "# [tool.orcaslicer.plugin]\n"
|
||||
+ "# name = \"" + display_name + "\"\n"
|
||||
+ "# description = \"Resource texture pack fixture.\"\n"
|
||||
+ "# author = \"tests\"\n"
|
||||
+ "# version = \"1.0.0\"\n"
|
||||
+ "# type = \"resource-texture\"\n"
|
||||
+ "# ///\n"
|
||||
+ "import os\n"
|
||||
+ "import orca\n"
|
||||
+ "\n\n"
|
||||
+ "class PackTextures(orca.resource_texture.ResourceTexturePluginCapabilityBase):\n"
|
||||
+ " def get_name(self):\n"
|
||||
+ " return \"" + cap_name + "\"\n"
|
||||
+ "\n"
|
||||
+ " def get_texture_dir(self):\n"
|
||||
+ " " + get_texture_dir_body + "\n"
|
||||
+ "\n\n"
|
||||
+ "@orca.plugin\n"
|
||||
+ "class TexturePack(orca.base):\n"
|
||||
+ " def register_capabilities(self):\n"
|
||||
+ " orca.register_capability(PackTextures)\n";
|
||||
}
|
||||
|
||||
std::string default_texture_dir_body()
|
||||
{
|
||||
return "return os.path.join(os.path.dirname(__file__), \"textures\")";
|
||||
}
|
||||
|
||||
// Writes {data_dir}/orca_plugins/<stem>/<stem>.py and returns the plugin directory.
|
||||
fs::path write_plugin(const ScopedDataDir& data_dir_guard, const std::string& stem, const std::string& source)
|
||||
{
|
||||
const fs::path plugin_dir = data_dir_guard.plugins_dir() / stem;
|
||||
fs::create_directories(plugin_dir);
|
||||
|
||||
std::ofstream out((plugin_dir / (stem + ".py")).string(), std::ios::binary);
|
||||
out << source;
|
||||
out.close();
|
||||
|
||||
return plugin_dir;
|
||||
}
|
||||
|
||||
bool load_and_wait(PluginManager& manager, const std::string& plugin_key, std::string& error)
|
||||
{
|
||||
manager.load_plugin(plugin_key, /*skip_deps=*/true);
|
||||
return manager.wait_for_plugin_load(plugin_key, std::chrono::seconds(120), error);
|
||||
}
|
||||
|
||||
// The Plugin-sourced slice of the texture listing, which is exactly what the picker's third heading
|
||||
// draws. Pass force to call texture_library(true); without it the listing is reused unless a pack
|
||||
// callback moved the generation.
|
||||
std::vector<GUI::TextureLibraryEntry> plugin_textures(bool force)
|
||||
{
|
||||
std::vector<GUI::TextureLibraryEntry> found;
|
||||
for (const GUI::TextureLibraryEntry& entry : GUI::texture_library(force))
|
||||
if (entry.source == GUI::TextureSource::Plugin)
|
||||
found.push_back(entry);
|
||||
return found;
|
||||
}
|
||||
|
||||
void write_text_file(const fs::path& path, const std::string& content = "x")
|
||||
{
|
||||
fs::create_directories(path.parent_path());
|
||||
std::ofstream out(path.string(), std::ios::binary);
|
||||
out << content;
|
||||
}
|
||||
|
||||
std::vector<unsigned char> read_file_bytes(const fs::path& path)
|
||||
{
|
||||
std::ifstream ifs(path.string(), std::ios::binary);
|
||||
return std::vector<unsigned char>(std::istreambuf_iterator<char>(ifs), std::istreambuf_iterator<char>());
|
||||
}
|
||||
|
||||
void write_bytes(const fs::path& path, const std::vector<unsigned char>& bytes)
|
||||
{
|
||||
fs::create_directories(path.parent_path());
|
||||
std::ofstream out(path.string(), std::ios::binary);
|
||||
out.write(reinterpret_cast<const char*>(bytes.data()), std::streamsize(bytes.size()));
|
||||
}
|
||||
|
||||
void write_gray_png(const fs::path& path, uint8_t value, size_t w = 4, size_t h = 4)
|
||||
{
|
||||
fs::create_directories(path.parent_path());
|
||||
const std::vector<uint8_t> pixels(w * h, value);
|
||||
REQUIRE(Slic3r::png::write_gray_to_file(path.string(), w, h, pixels));
|
||||
}
|
||||
|
||||
// A 24-bit gray BMP (R == G == B), synthesised without an image toolkit. Rows are bottom-up and
|
||||
// padded to four bytes; the image is uniform, so the orientation does not matter.
|
||||
std::vector<unsigned char> make_gray_bmp(int width, int height, uint8_t value)
|
||||
{
|
||||
const int row_bytes = ((width * 3 + 3) / 4) * 4;
|
||||
const uint32_t pixel_bytes = uint32_t(row_bytes) * uint32_t(height);
|
||||
const uint32_t file_size = 14 + 40 + pixel_bytes;
|
||||
|
||||
std::vector<unsigned char> bmp(file_size, 0);
|
||||
const auto put16 = [&bmp](size_t at, uint16_t v) {
|
||||
bmp[at] = uint8_t(v);
|
||||
bmp[at + 1] = uint8_t(v >> 8);
|
||||
};
|
||||
const auto put32 = [&bmp](size_t at, uint32_t v) {
|
||||
bmp[at] = uint8_t(v);
|
||||
bmp[at + 1] = uint8_t(v >> 8);
|
||||
bmp[at + 2] = uint8_t(v >> 16);
|
||||
bmp[at + 3] = uint8_t(v >> 24);
|
||||
};
|
||||
|
||||
bmp[0] = 'B';
|
||||
bmp[1] = 'M';
|
||||
put32(2, file_size);
|
||||
put32(10, 54); // pixel data offset
|
||||
put32(14, 40); // BITMAPINFOHEADER size
|
||||
put32(18, uint32_t(width));
|
||||
put32(22, uint32_t(height));
|
||||
put16(26, 1); // planes
|
||||
put16(28, 24); // bits per pixel
|
||||
put32(34, pixel_bytes);
|
||||
|
||||
for (int y = 0; y < height; ++y) {
|
||||
unsigned char* row = bmp.data() + 54 + size_t(y) * size_t(row_bytes);
|
||||
for (int x = 0; x < width; ++x) {
|
||||
row[x * 3] = value;
|
||||
row[x * 3 + 1] = value;
|
||||
row[x * 3 + 2] = value;
|
||||
}
|
||||
}
|
||||
return bmp;
|
||||
}
|
||||
|
||||
// The app calls wxInitAllImageHandlers() during startup and, before that, wxEntryStart() runs the
|
||||
// wxImageModule, which registers the standard handlers. wxInitAllImageHandlers() adds PNG, JPEG and
|
||||
// the rest but NOT the BMP handler, so a test binary without a wxApp must add the standard set too or
|
||||
// a .bmp texture cannot be loaded here at all.
|
||||
void ensure_wx_image_handlers()
|
||||
{
|
||||
static bool initialized = false;
|
||||
if (initialized)
|
||||
return;
|
||||
initialized = true;
|
||||
wxImage::InitStandardHandlers();
|
||||
wxInitAllImageHandlers();
|
||||
}
|
||||
|
||||
// Stores `model` as a one-plate project, mirroring tests/libslic3r/test_3mf.cpp.
|
||||
void store_project(Model& model, const std::string& path)
|
||||
{
|
||||
ScopedTemporaryDir backup_dir("orca_tex_pack_src");
|
||||
model.set_backup_path(backup_dir.string());
|
||||
|
||||
DynamicPrintConfig cfg;
|
||||
PlateData plate;
|
||||
plate.plate_index = 0;
|
||||
StoreParams sp;
|
||||
sp.path = path.c_str();
|
||||
sp.model = &model;
|
||||
sp.config = &cfg;
|
||||
sp.strategy = SaveStrategy::Zip64 | SaveStrategy::Silence;
|
||||
sp.plate_data_list.push_back(&plate);
|
||||
REQUIRE(store_bbs_3mf(sp));
|
||||
}
|
||||
|
||||
bool load_project(const std::string& path, Model& model, const ScopedTemporaryDir& backup_dir)
|
||||
{
|
||||
model.set_backup_path(backup_dir.string());
|
||||
DynamicPrintConfig config;
|
||||
ConfigSubstitutionContext ctxt{ ForwardCompatibilitySubstitutionRule::Enable };
|
||||
PlateDataPtrs plates;
|
||||
std::vector<Preset*> project_presets;
|
||||
bool is_bbl_3mf = false;
|
||||
bool is_orca_3mf = false;
|
||||
Semver file_version;
|
||||
const bool loaded = load_bbs_3mf(path.c_str(), &config, &ctxt, &model, &plates, &project_presets,
|
||||
&is_bbl_3mf, &is_orca_3mf, &file_version, nullptr,
|
||||
LoadStrategy::LoadModel | LoadStrategy::LoadConfig);
|
||||
release_PlateData_list(plates);
|
||||
return loaded;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// ── Validation (collect_resource_texture_files, no Python) ──────────────────────────────────────
|
||||
|
||||
TEST_CASE("A texture file directly inside the plugin folder is listed", "[PluginResourceTexture]")
|
||||
{
|
||||
ScopedTemporaryDir root("tex-root");
|
||||
write_text_file(root.path() / "textures" / "grain.png");
|
||||
|
||||
std::string error;
|
||||
const std::vector<std::string> files =
|
||||
collect_resource_texture_files((root.path() / "textures").string(), root.string(), error);
|
||||
|
||||
CHECK(error.empty());
|
||||
REQUIRE(files.size() == 1);
|
||||
CHECK(fs::path(files.front()).filename() == "grain.png");
|
||||
}
|
||||
|
||||
TEST_CASE("A directory reached with a parent-directory escape is refused", "[PluginResourceTexture]")
|
||||
{
|
||||
ScopedTemporaryDir parent("tex-escape");
|
||||
const fs::path root = parent.path() / "pack";
|
||||
const fs::path outside = parent.path() / "outside";
|
||||
fs::create_directories(root);
|
||||
write_text_file(outside / "leak.png");
|
||||
|
||||
std::string error;
|
||||
const std::vector<std::string> files =
|
||||
collect_resource_texture_files((root / ".." / "outside").string(), root.string(), error);
|
||||
|
||||
CHECK(files.empty());
|
||||
CHECK_FALSE(error.empty());
|
||||
}
|
||||
|
||||
TEST_CASE("An absolute directory outside the plugin folder is refused", "[PluginResourceTexture]")
|
||||
{
|
||||
ScopedTemporaryDir root("tex-abs-root");
|
||||
ScopedTemporaryDir elsewhere("tex-abs-elsewhere");
|
||||
write_text_file(elsewhere.path() / "leak.png");
|
||||
|
||||
std::string error;
|
||||
const std::vector<std::string> files = collect_resource_texture_files(elsewhere.string(), root.string(), error);
|
||||
|
||||
CHECK(files.empty());
|
||||
CHECK_FALSE(error.empty());
|
||||
}
|
||||
|
||||
TEST_CASE("A symlink pointing outside the plugin folder is skipped, not reported as an error", "[PluginResourceTexture]")
|
||||
{
|
||||
ScopedTemporaryDir root("tex-symlink-root");
|
||||
ScopedTemporaryDir target("tex-symlink-target");
|
||||
write_text_file(target.path() / "secret.png");
|
||||
|
||||
const fs::path link = root.path() / "textures" / "link.png";
|
||||
fs::create_directories(link.parent_path());
|
||||
boost::system::error_code ec;
|
||||
fs::create_symlink(target.path() / "secret.png", link, ec);
|
||||
if (ec)
|
||||
SKIP("symlinks unavailable on this filesystem: " + ec.message());
|
||||
|
||||
std::string error;
|
||||
const std::vector<std::string> files =
|
||||
collect_resource_texture_files(link.parent_path().string(), root.string(), error);
|
||||
|
||||
// The directory itself is legitimate; the escaping link is dropped, not an error.
|
||||
CHECK(error.empty());
|
||||
CHECK(files.empty());
|
||||
}
|
||||
|
||||
TEST_CASE("Files in a subdirectory of the texture directory are not listed", "[PluginResourceTexture]")
|
||||
{
|
||||
ScopedTemporaryDir root("tex-recursive");
|
||||
write_text_file(root.path() / "top.png");
|
||||
write_text_file(root.path() / "nested" / "deep.png");
|
||||
|
||||
std::string error;
|
||||
const std::vector<std::string> files = collect_resource_texture_files(root.string(), root.string(), error);
|
||||
|
||||
CHECK(error.empty());
|
||||
REQUIRE(files.size() == 1);
|
||||
CHECK(fs::path(files.front()).filename() == "top.png");
|
||||
}
|
||||
|
||||
TEST_CASE("An entry that is not a regular file is skipped", "[PluginResourceTexture]")
|
||||
{
|
||||
ScopedTemporaryDir root("tex-nonregular");
|
||||
write_text_file(root.path() / "real.png");
|
||||
fs::create_directories(root.path() / "fake.png"); // a directory named like an image
|
||||
|
||||
std::string error;
|
||||
const std::vector<std::string> files = collect_resource_texture_files(root.string(), root.string(), error);
|
||||
|
||||
CHECK(error.empty());
|
||||
REQUIRE(files.size() == 1);
|
||||
CHECK(fs::path(files.front()).filename() == "real.png");
|
||||
}
|
||||
|
||||
TEST_CASE("Image extensions are matched case-insensitively and other extensions are ignored",
|
||||
"[PluginResourceTexture]")
|
||||
{
|
||||
ScopedTemporaryDir root("tex-extensions");
|
||||
for (const char* name : { "a.png", "b.PNG", "c.jpg", "d.JPG", "e.jpeg", "f.JPEG", "g.bmp", "h.BMP" })
|
||||
write_text_file(root.path() / name);
|
||||
for (const char* name : { "i.txt", "j.gif", "k.pngx", "l.pn", "m.png.bak" })
|
||||
write_text_file(root.path() / name);
|
||||
|
||||
std::string error;
|
||||
const std::vector<std::string> files = collect_resource_texture_files(root.string(), root.string(), error);
|
||||
|
||||
CHECK(error.empty());
|
||||
CHECK(files.size() == 8);
|
||||
|
||||
std::vector<std::string> names;
|
||||
for (const std::string& file : files)
|
||||
names.push_back(fs::path(file).filename().string());
|
||||
for (const char* accepted : { "a.png", "b.PNG", "c.jpg", "d.JPG", "e.jpeg", "f.JPEG", "g.bmp", "h.BMP" })
|
||||
CHECK(std::find(names.begin(), names.end(), accepted) != names.end());
|
||||
for (const char* rejected : { "i.txt", "j.gif", "k.pngx", "l.pn", "m.png.bak" })
|
||||
CHECK(std::find(names.begin(), names.end(), rejected) == names.end());
|
||||
}
|
||||
|
||||
TEST_CASE("The listing stops at 64 image files", "[PluginResourceTexture]")
|
||||
{
|
||||
ScopedTemporaryDir root("tex-cap");
|
||||
for (int i = 0; i < 70; ++i)
|
||||
write_text_file(root.path() / ("file" + std::to_string(i) + ".png"));
|
||||
|
||||
std::string error;
|
||||
const std::vector<std::string> files = collect_resource_texture_files(root.string(), root.string(), error);
|
||||
|
||||
CHECK(error.empty());
|
||||
CHECK(files.size() == 64);
|
||||
}
|
||||
|
||||
TEST_CASE("A missing texture directory yields no files and an error", "[PluginResourceTexture]")
|
||||
{
|
||||
ScopedTemporaryDir root("tex-missing");
|
||||
|
||||
std::string error;
|
||||
const std::vector<std::string> files =
|
||||
collect_resource_texture_files((root.path() / "textures").string(), root.string(), error);
|
||||
|
||||
CHECK(files.empty());
|
||||
CHECK_FALSE(error.empty());
|
||||
}
|
||||
|
||||
// ── Lifecycle through the real listing ──────────────────────────────────────────────────────────
|
||||
|
||||
TEST_CASE("A resource-texture pack's images appear in the listing and clear on disable and unload",
|
||||
"[PluginResourceTexture][PluginLifecycle][Python]")
|
||||
{
|
||||
ScopedPluginManager plugin_system;
|
||||
if (!plugin_system.initialized)
|
||||
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
||||
|
||||
ScopedDataDir data_dir_guard("texture-listing");
|
||||
|
||||
const std::string plugin_key = "Texture_Pack";
|
||||
const fs::path plugin_dir =
|
||||
write_plugin(data_dir_guard, plugin_key, make_resource_texture_source("Texture Pack", default_texture_dir_body()));
|
||||
|
||||
const fs::path textures = plugin_dir / "textures";
|
||||
write_gray_png(textures / "alpha.png", 0x20);
|
||||
write_gray_png(textures / "beta.png", 0x80);
|
||||
write_text_file(textures / "gamma.jpg"); // the listing only checks the extension; decode is covered below
|
||||
write_text_file(textures / "notes.txt");
|
||||
|
||||
// A user-owned texture, so the listing has a non-pack entry whose provenance must be empty.
|
||||
write_gray_png(fs::path(GUI::user_texture_dir()) / "user_made.png", 0x40);
|
||||
|
||||
PluginManager& manager = PluginManager::instance();
|
||||
install_resource_texture_wiring();
|
||||
manager.discover_plugins(/*async=*/false, /*clear=*/true);
|
||||
|
||||
std::string error;
|
||||
REQUIRE(load_and_wait(manager, plugin_key, error));
|
||||
INFO("load error: " << error);
|
||||
CHECK(error.empty());
|
||||
|
||||
CHECK(PluginResourceTextures::instance().entries().size() == 1);
|
||||
|
||||
const uint64_t loaded_generation = GUI::texture_library_generation();
|
||||
const auto loaded = plugin_textures(true);
|
||||
REQUIRE(loaded.size() == 3);
|
||||
std::vector<std::string> names;
|
||||
for (const GUI::TextureLibraryEntry& entry : loaded)
|
||||
names.push_back(entry.name);
|
||||
CHECK(std::find(names.begin(), names.end(), "alpha") != names.end());
|
||||
CHECK(std::find(names.begin(), names.end(), "beta") != names.end());
|
||||
CHECK(std::find(names.begin(), names.end(), "gamma") != names.end());
|
||||
CHECK(std::find(names.begin(), names.end(), "notes") == names.end());
|
||||
|
||||
// Every pack entry names its contributing pack; shipped and user entries carry no pack.
|
||||
for (const GUI::TextureLibraryEntry& entry : GUI::texture_library(true)) {
|
||||
if (entry.source == GUI::TextureSource::Plugin) {
|
||||
CHECK_FALSE(entry.pack_name.empty());
|
||||
CHECK(entry.pack_name == "Texture Pack");
|
||||
} else {
|
||||
CHECK(entry.pack_name.empty());
|
||||
}
|
||||
}
|
||||
|
||||
// A no-argument call reuses the cached listing until a pack callback moves the generation.
|
||||
CHECK(plugin_textures(false).size() == 3);
|
||||
|
||||
manager.set_capability_enabled({ PluginCapabilityType::ResourceTexture, "Texture Pack", plugin_key }, false);
|
||||
CHECK(GUI::texture_library_generation() > loaded_generation);
|
||||
CHECK(PluginResourceTextures::instance().entries().empty());
|
||||
CHECK(plugin_textures(false).empty());
|
||||
|
||||
manager.set_capability_enabled({ PluginCapabilityType::ResourceTexture, "Texture Pack", plugin_key }, true);
|
||||
CHECK(plugin_textures(false).size() == 3);
|
||||
|
||||
REQUIRE(manager.unload_plugin(plugin_key));
|
||||
CHECK(PluginResourceTextures::instance().entries().empty());
|
||||
CHECK(plugin_textures(false).empty());
|
||||
}
|
||||
|
||||
TEST_CASE("Two distinctly named packs each list as one contiguous run",
|
||||
"[PluginResourceTexture][Python]")
|
||||
{
|
||||
ScopedPluginManager plugin_system;
|
||||
if (!plugin_system.initialized)
|
||||
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
||||
|
||||
ScopedDataDir data_dir_guard("texture-contiguous");
|
||||
|
||||
// Loaded out of name order, so it is the listing's pack-name sort that orders the runs.
|
||||
const std::string key_zeta = "Pack_Zeta";
|
||||
const std::string key_alpha = "Pack_Alpha";
|
||||
|
||||
const fs::path dir_zeta = write_plugin(
|
||||
data_dir_guard, key_zeta, make_resource_texture_source("Zeta Pack", default_texture_dir_body()));
|
||||
const fs::path dir_alpha = write_plugin(
|
||||
data_dir_guard, key_alpha, make_resource_texture_source("Alpha Pack", default_texture_dir_body()));
|
||||
|
||||
write_gray_png(dir_zeta / "textures" / "zeta_one.png", 0x20);
|
||||
write_gray_png(dir_zeta / "textures" / "zeta_two.png", 0x40);
|
||||
write_gray_png(dir_alpha / "textures" / "alpha_one.png", 0x60);
|
||||
write_gray_png(dir_alpha / "textures" / "alpha_two.png", 0x80);
|
||||
|
||||
PluginManager& manager = PluginManager::instance();
|
||||
install_resource_texture_wiring();
|
||||
manager.discover_plugins(/*async=*/false, /*clear=*/true);
|
||||
|
||||
std::string error;
|
||||
REQUIRE(load_and_wait(manager, key_zeta, error));
|
||||
REQUIRE(load_and_wait(manager, key_alpha, error));
|
||||
|
||||
std::vector<GUI::TextureLibraryEntry> pack_entries;
|
||||
for (const GUI::TextureLibraryEntry& entry : GUI::texture_library(true))
|
||||
if (entry.source == GUI::TextureSource::Plugin)
|
||||
pack_entries.push_back(entry);
|
||||
REQUIRE(pack_entries.size() == 4);
|
||||
|
||||
const auto count_pack = [&pack_entries](const std::string& name) {
|
||||
return std::count_if(pack_entries.begin(), pack_entries.end(),
|
||||
[&name](const GUI::TextureLibraryEntry& e) { return e.pack_name == name; });
|
||||
};
|
||||
CHECK(count_pack("Zeta Pack") == 2);
|
||||
CHECK(count_pack("Alpha Pack") == 2);
|
||||
|
||||
// Collapsing consecutive entries by pack name must leave one run per pack: a name never reappears
|
||||
// after another name intervenes, which is what lets the picker draw one section per pack.
|
||||
std::vector<std::string> runs;
|
||||
for (const GUI::TextureLibraryEntry& entry : pack_entries)
|
||||
if (runs.empty() || runs.back() != entry.pack_name)
|
||||
runs.push_back(entry.pack_name);
|
||||
|
||||
std::vector<std::string> sorted_runs = runs;
|
||||
std::sort(sorted_runs.begin(), sorted_runs.end());
|
||||
CHECK(std::unique(sorted_runs.begin(), sorted_runs.end()) == sorted_runs.end());
|
||||
CHECK(runs == std::vector<std::string>({ "Alpha Pack", "Zeta Pack" }));
|
||||
|
||||
manager.unload_plugin(key_zeta);
|
||||
manager.unload_plugin(key_alpha);
|
||||
}
|
||||
|
||||
TEST_CASE("A second pack declaring a loaded capability name is refused",
|
||||
"[PluginResourceTexture][Python]")
|
||||
{
|
||||
ScopedPluginManager plugin_system;
|
||||
if (!plugin_system.initialized)
|
||||
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
||||
|
||||
ScopedDataDir data_dir_guard("texture-capability-clash");
|
||||
|
||||
// Distinct package names, one capability name: the loader allows only the first.
|
||||
const std::string key_first = "Clash_First";
|
||||
const std::string key_second = "Clash_Second";
|
||||
const std::string capability = "Shared Textures";
|
||||
|
||||
const fs::path dir_first = write_plugin(
|
||||
data_dir_guard, key_first,
|
||||
make_resource_texture_source("Clash Package One", default_texture_dir_body(), capability));
|
||||
write_plugin(data_dir_guard, key_second,
|
||||
make_resource_texture_source("Clash Package Two", default_texture_dir_body(), capability));
|
||||
|
||||
write_gray_png(dir_first / "textures" / "one.png", 0x20);
|
||||
write_gray_png(dir_first / "textures" / "two.png", 0x40);
|
||||
|
||||
PluginManager& manager = PluginManager::instance();
|
||||
install_resource_texture_wiring();
|
||||
manager.discover_plugins(/*async=*/false, /*clear=*/true);
|
||||
|
||||
std::string error;
|
||||
REQUIRE(load_and_wait(manager, key_first, error));
|
||||
REQUIRE(manager.is_plugin_loaded(key_first));
|
||||
|
||||
const std::vector<GUI::TextureLibraryEntry> before = plugin_textures(true);
|
||||
REQUIRE(before.size() == 2);
|
||||
for (const GUI::TextureLibraryEntry& entry : before)
|
||||
CHECK(entry.pack_name == capability);
|
||||
|
||||
// The second package claims the capability name the first already owns, so the load is refused.
|
||||
std::string second_error;
|
||||
CHECK_FALSE(load_and_wait(manager, key_second, second_error));
|
||||
CHECK_FALSE(manager.is_plugin_loaded(key_second));
|
||||
|
||||
// The first pack's textures are untouched by the refused load.
|
||||
const std::vector<GUI::TextureLibraryEntry> after = plugin_textures(true);
|
||||
REQUIRE(after.size() == before.size());
|
||||
for (size_t i = 0; i < before.size(); ++i) {
|
||||
CHECK(after[i].name == before[i].name);
|
||||
CHECK(after[i].path == before[i].path);
|
||||
CHECK(after[i].pack_name == before[i].pack_name);
|
||||
}
|
||||
|
||||
manager.unload_plugin(key_first);
|
||||
}
|
||||
|
||||
TEST_CASE("A resource-texture pack with no images contributes no textures", "[PluginResourceTexture][Python]")
|
||||
{
|
||||
ScopedPluginManager plugin_system;
|
||||
if (!plugin_system.initialized)
|
||||
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
||||
|
||||
ScopedDataDir data_dir_guard("texture-empty");
|
||||
|
||||
const std::string plugin_key = "Empty_Pack";
|
||||
const fs::path plugin_dir =
|
||||
write_plugin(data_dir_guard, plugin_key, make_resource_texture_source("Empty Pack", default_texture_dir_body()));
|
||||
fs::create_directories(plugin_dir / "textures"); // valid, but empty
|
||||
|
||||
PluginManager& manager = PluginManager::instance();
|
||||
install_resource_texture_wiring();
|
||||
manager.discover_plugins(/*async=*/false, /*clear=*/true);
|
||||
|
||||
std::string error;
|
||||
REQUIRE(load_and_wait(manager, plugin_key, error));
|
||||
CHECK(manager.is_plugin_loaded(plugin_key));
|
||||
CHECK(plugin_textures(true).empty());
|
||||
|
||||
manager.unload_plugin(plugin_key);
|
||||
}
|
||||
|
||||
// ── Hostile get_texture_dir() overrides ─────────────────────────────────────────────────────────
|
||||
|
||||
TEST_CASE("A texture directory outside the plugin root contributes no textures", "[PluginResourceTexture][Python]")
|
||||
{
|
||||
ScopedPluginManager plugin_system;
|
||||
if (!plugin_system.initialized)
|
||||
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
||||
|
||||
ScopedDataDir data_dir_guard("texture-hostile");
|
||||
ScopedResourcesDir resources_dir_guard("texture-hostile-res");
|
||||
write_text_file(fs::path(resources_dir_guard.dir) / "sentinel.png"); // would list if it were scanned
|
||||
|
||||
const std::string data_pack = "Data_Dir_Pack";
|
||||
write_plugin(data_dir_guard, data_pack,
|
||||
make_resource_texture_source("Data Dir Pack", "return r\"" + data_dir_guard.dir.string() + "\""));
|
||||
const std::string resources_pack = "Resources_Dir_Pack";
|
||||
write_plugin(data_dir_guard, resources_pack,
|
||||
make_resource_texture_source("Resources Dir Pack", "return r\"" + resources_dir_guard.dir.string() + "\""));
|
||||
|
||||
PluginManager& manager = PluginManager::instance();
|
||||
install_resource_texture_wiring();
|
||||
manager.discover_plugins(/*async=*/false, /*clear=*/true);
|
||||
|
||||
std::string error;
|
||||
REQUIRE(load_and_wait(manager, data_pack, error));
|
||||
REQUIRE(load_and_wait(manager, resources_pack, error));
|
||||
|
||||
// The packs load; their out-of-root directories are simply refused.
|
||||
CHECK(manager.is_plugin_loaded(data_pack));
|
||||
CHECK(manager.is_plugin_loaded(resources_pack));
|
||||
CHECK(PluginResourceTextures::instance().entries().empty());
|
||||
CHECK(plugin_textures(true).empty());
|
||||
|
||||
manager.unload_plugin(data_pack);
|
||||
manager.unload_plugin(resources_pack);
|
||||
}
|
||||
|
||||
TEST_CASE("A raising get_texture_dir leaves the pack loaded with no textures", "[PluginResourceTexture][Python]")
|
||||
{
|
||||
ScopedPluginManager plugin_system;
|
||||
if (!plugin_system.initialized)
|
||||
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
||||
|
||||
ScopedDataDir data_dir_guard("texture-raising");
|
||||
|
||||
const std::string plugin_key = "Raising_Pack";
|
||||
write_plugin(data_dir_guard, plugin_key,
|
||||
make_resource_texture_source("Raising Pack", "raise RuntimeError(\"no textures today\")"));
|
||||
|
||||
PluginManager& manager = PluginManager::instance();
|
||||
install_resource_texture_wiring();
|
||||
manager.discover_plugins(/*async=*/false, /*clear=*/true);
|
||||
|
||||
std::string error;
|
||||
REQUIRE(load_and_wait(manager, plugin_key, error));
|
||||
CHECK(error.empty());
|
||||
CHECK(manager.is_plugin_loaded(plugin_key));
|
||||
CHECK(PluginResourceTextures::instance().entries().empty());
|
||||
CHECK(plugin_textures(true).empty());
|
||||
|
||||
manager.unload_plugin(plugin_key);
|
||||
}
|
||||
|
||||
// ── Decoding parity ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
TEST_CASE("Pack images decode through the same path as hand-picked files", "[PluginResourceTexture]")
|
||||
{
|
||||
ensure_wx_image_handlers();
|
||||
|
||||
ScopedTemporaryDir pack_root("tex-pack");
|
||||
ScopedTemporaryDir hand_root("tex-hand");
|
||||
const fs::path pack_dir = pack_root.path() / "textures";
|
||||
fs::create_directories(pack_dir);
|
||||
|
||||
// PNG: grayscale, so load_texture_image_data passes it through verbatim.
|
||||
const fs::path pack_png = pack_dir / "grain.png";
|
||||
write_gray_png(pack_png, 0x40);
|
||||
const std::vector<unsigned char> png_bytes = read_file_bytes(pack_png);
|
||||
REQUIRE_FALSE(png_bytes.empty());
|
||||
write_bytes(hand_root.path() / "grain.png", png_bytes);
|
||||
|
||||
// BMP: not a PNG, so it goes through wxImage and is re-encoded; the same file must give the same
|
||||
// bytes wherever it sits.
|
||||
const std::vector<unsigned char> bmp_bytes = make_gray_bmp(4, 4, 0x40);
|
||||
const fs::path pack_bmp = pack_dir / "bump.bmp";
|
||||
write_bytes(pack_bmp, bmp_bytes);
|
||||
const fs::path hand_bmp = hand_root.path() / "bump.bmp";
|
||||
write_bytes(hand_bmp, bmp_bytes);
|
||||
|
||||
// The collector lists both pack files, so these are the paths a pack would serve.
|
||||
std::string collect_error;
|
||||
const std::vector<std::string> pack_files =
|
||||
collect_resource_texture_files(pack_dir.string(), pack_root.string(), collect_error);
|
||||
REQUIRE(collect_error.empty());
|
||||
CHECK(pack_files.size() == 2);
|
||||
|
||||
std::string pack_error;
|
||||
std::string hand_error;
|
||||
|
||||
const auto pack_png_decoded = GUI::load_texture_image_data(pack_png.string(), pack_error);
|
||||
const auto hand_png_decoded = GUI::load_texture_image_data((hand_root.path() / "grain.png").string(), hand_error);
|
||||
REQUIRE(pack_png_decoded != nullptr);
|
||||
REQUIRE(hand_png_decoded != nullptr);
|
||||
CHECK(*pack_png_decoded == png_bytes); // supported PNG is passed through unchanged
|
||||
CHECK(*pack_png_decoded == *hand_png_decoded);
|
||||
|
||||
const auto pack_bmp_decoded = GUI::load_texture_image_data(pack_bmp.string(), pack_error);
|
||||
const auto hand_bmp_decoded = GUI::load_texture_image_data(hand_bmp.string(), hand_error);
|
||||
REQUIRE(pack_bmp_decoded != nullptr);
|
||||
REQUIRE(hand_bmp_decoded != nullptr);
|
||||
REQUIRE_FALSE(pack_bmp_decoded->empty());
|
||||
CHECK(*pack_bmp_decoded == *hand_bmp_decoded);
|
||||
}
|
||||
|
||||
TEST_CASE("Reinstalling a pack with different bytes at the same path changes the decoded content",
|
||||
"[PluginResourceTexture][Python]")
|
||||
{
|
||||
ScopedPluginManager plugin_system;
|
||||
if (!plugin_system.initialized)
|
||||
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
||||
|
||||
ScopedDataDir data_dir_guard("texture-reinstall");
|
||||
|
||||
const std::string plugin_key = "Reinstall_Pack";
|
||||
const fs::path plugin_dir =
|
||||
write_plugin(data_dir_guard, plugin_key, make_resource_texture_source("Reinstall Pack", default_texture_dir_body()));
|
||||
const fs::path texture = plugin_dir / "textures" / "grain.png";
|
||||
write_gray_png(texture, 0x10);
|
||||
|
||||
PluginManager& manager = PluginManager::instance();
|
||||
install_resource_texture_wiring();
|
||||
manager.discover_plugins(/*async=*/false, /*clear=*/true);
|
||||
|
||||
std::string error;
|
||||
REQUIRE(load_and_wait(manager, plugin_key, error));
|
||||
|
||||
auto loaded = plugin_textures(true);
|
||||
REQUIRE(loaded.size() == 1);
|
||||
const std::string path = loaded.front().path;
|
||||
|
||||
std::string decode_error;
|
||||
const auto first = GUI::load_texture_image_data(path, decode_error);
|
||||
REQUIRE(first != nullptr);
|
||||
|
||||
REQUIRE(manager.unload_plugin(plugin_key));
|
||||
|
||||
// The reinstalled pack writes different bytes to the same file path.
|
||||
write_gray_png(texture, 0xE0);
|
||||
REQUIRE(load_and_wait(manager, plugin_key, error));
|
||||
|
||||
loaded = plugin_textures(true);
|
||||
REQUIRE(loaded.size() == 1);
|
||||
CHECK(loaded.front().path == path);
|
||||
|
||||
const auto second = GUI::load_texture_image_data(path, decode_error);
|
||||
REQUIRE(second != nullptr);
|
||||
CHECK(*first != *second);
|
||||
}
|
||||
|
||||
// ── Wheel metadata ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
TEST_CASE("A minimal resource-texture wheel is accepted", "[PluginResourceTexture][Wheel]")
|
||||
{
|
||||
ScopedTemporaryDir dir("texture-wheel");
|
||||
const fs::path wheel = dir.path() / "texture_pack-1.0-py3-none-any.whl";
|
||||
|
||||
const std::string dist_info = "texture_pack-1.0.dist-info";
|
||||
const std::string metadata =
|
||||
"Metadata-Version: 2.1\n"
|
||||
"Name: texture_pack\n"
|
||||
"Version: 1.0\n"
|
||||
"Summary: Resource texture pack fixture\n"
|
||||
"Author: tests\n";
|
||||
const std::string wheel_metadata =
|
||||
"Wheel-Version: 1.0\n"
|
||||
"Generator: manual\n"
|
||||
"Root-Is-Purelib: true\n"
|
||||
"Tag: py3-none-any\n";
|
||||
const std::string record = dist_info + "/METADATA,,\n" + dist_info + "/WHEEL,,\n" + dist_info + "/RECORD,,\n";
|
||||
const std::string top_level = "texture_pack\n";
|
||||
|
||||
{
|
||||
Zipper zipper(wheel.string(), Zipper::NO_COMPRESSION);
|
||||
zipper.add_entry(dist_info + "/METADATA", metadata.data(), metadata.size());
|
||||
zipper.add_entry(dist_info + "/WHEEL", wheel_metadata.data(), wheel_metadata.size());
|
||||
zipper.add_entry(dist_info + "/RECORD", record.data(), record.size());
|
||||
// read_wheel_plugin_metadata leaves `error` set when an optional top_level.txt is missing.
|
||||
zipper.add_entry(dist_info + "/top_level.txt", top_level.data(), top_level.size());
|
||||
zipper.finalize();
|
||||
}
|
||||
|
||||
PluginDescriptor descriptor;
|
||||
std::string error;
|
||||
REQUIRE(read_wheel_plugin_metadata(wheel, descriptor, error));
|
||||
INFO("wheel metadata error: " << error);
|
||||
CHECK(error.empty());
|
||||
CHECK(descriptor.name == "texture_pack");
|
||||
CHECK(descriptor.version == "1.0");
|
||||
CHECK(descriptor.entry_package == "texture_pack");
|
||||
}
|
||||
|
||||
// ── .3mf round trip ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
TEST_CASE("A project keeps a pack texture after the pack is uninstalled",
|
||||
"[PluginResourceTexture][PluginLifecycle][Python][3mf]")
|
||||
{
|
||||
ScopedPluginManager plugin_system;
|
||||
if (!plugin_system.initialized)
|
||||
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
||||
|
||||
ScopedDataDir data_dir_guard("texture-3mf");
|
||||
|
||||
const std::string plugin_key = "Texture_Pack_3mf";
|
||||
const fs::path plugin_dir =
|
||||
write_plugin(data_dir_guard, plugin_key, make_resource_texture_source("Texture Pack", default_texture_dir_body()));
|
||||
write_gray_png(plugin_dir / "textures" / "grain.png", 0x60);
|
||||
|
||||
PluginManager& manager = PluginManager::instance();
|
||||
install_resource_texture_wiring();
|
||||
manager.discover_plugins(/*async=*/false, /*clear=*/true);
|
||||
|
||||
std::string error;
|
||||
REQUIRE(load_and_wait(manager, plugin_key, error));
|
||||
|
||||
const auto loaded = plugin_textures(true);
|
||||
REQUIRE(loaded.size() == 1);
|
||||
|
||||
std::string decode_error;
|
||||
const auto pack_bytes = GUI::load_texture_image_data(loaded.front().path, decode_error);
|
||||
REQUIRE(pack_bytes != nullptr);
|
||||
REQUIRE_FALSE(pack_bytes->empty());
|
||||
|
||||
// Build a project the way the gizmo would: the layer carries the pack texture's bytes.
|
||||
Model model;
|
||||
ModelObject* object = model.add_object();
|
||||
ModelVolume* volume = object->add_volume(make_cube(10., 10., 10.));
|
||||
object->add_instance();
|
||||
TextureDisplacementLayer layer;
|
||||
layer.slot = 0;
|
||||
layer.name = loaded.front().name;
|
||||
layer.path = loaded.front().path;
|
||||
layer.image_data = pack_bytes;
|
||||
volume->texture_displacement_layers.push_back(layer);
|
||||
|
||||
ScopedTemporaryFile first(".3mf");
|
||||
store_project(model, first.string());
|
||||
|
||||
// Uninstall the pack; the project still has to open and re-save with the same image bytes.
|
||||
REQUIRE(manager.unload_plugin(plugin_key));
|
||||
CHECK(plugin_textures(true).empty());
|
||||
|
||||
ScopedTemporaryDir backup_dir("orca_tex_pack_dst");
|
||||
Model reloaded;
|
||||
REQUIRE(load_project(first.string(), reloaded, backup_dir));
|
||||
REQUIRE(reloaded.objects.size() == 1);
|
||||
REQUIRE(reloaded.objects.front()->volumes.size() == 1);
|
||||
const std::vector<TextureDisplacementLayer>& reloaded_layers =
|
||||
reloaded.objects.front()->volumes.front()->texture_displacement_layers;
|
||||
REQUIRE(reloaded_layers.size() == 1);
|
||||
REQUIRE(reloaded_layers.front().image_data);
|
||||
CHECK(*reloaded_layers.front().image_data == *pack_bytes);
|
||||
|
||||
ScopedTemporaryFile second(".3mf");
|
||||
store_project(reloaded, second.string());
|
||||
|
||||
ScopedTemporaryDir backup_dir2("orca_tex_pack_dst2");
|
||||
Model reloaded_again;
|
||||
REQUIRE(load_project(second.string(), reloaded_again, backup_dir2));
|
||||
REQUIRE(reloaded_again.objects.size() == 1);
|
||||
REQUIRE(reloaded_again.objects.front()->volumes.size() == 1);
|
||||
const std::vector<TextureDisplacementLayer>& again_layers =
|
||||
reloaded_again.objects.front()->volumes.front()->texture_displacement_layers;
|
||||
REQUIRE(again_layers.size() == 1);
|
||||
REQUIRE(again_layers.front().image_data);
|
||||
CHECK(*again_layers.front().image_data == *pack_bytes);
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
#include <catch2/catch_test_macros.hpp>
|
||||
#include <catch2/generators/catch_generators.hpp>
|
||||
#include <catch2/matchers/catch_matchers_floating_point.hpp>
|
||||
|
||||
#include "catch2/matchers/catch_matchers.hpp"
|
||||
#include "fff_print/test_helpers.hpp"
|
||||
#include "slic3r/GUI/SequentialPrintClearance.hpp"
|
||||
|
||||
#include <libslic3r/libslic3r.h>
|
||||
#include <libslic3r/Model.hpp>
|
||||
#include <libslic3r/TriangleMesh.hpp>
|
||||
#include <libslic3r/PrintConfig.hpp>
|
||||
#include <libslic3r/Print.hpp>
|
||||
#include <libslic3r/Polygon.hpp>
|
||||
#include <libslic3r/ObjectID.hpp>
|
||||
#include <cstddef>
|
||||
#include <map>
|
||||
#include <vector>
|
||||
#include <utility>
|
||||
|
||||
using namespace Slic3r;
|
||||
using namespace Slic3r::GUI;
|
||||
using Catch::Matchers::WithinAbs;
|
||||
|
||||
namespace {
|
||||
|
||||
SequentialClearanceInstance instance(coord_t x, coord_t y, double height)
|
||||
{
|
||||
return {height, BoundingBox(Point(x, y), Point(x + 5, y + 10)),
|
||||
Polygon{Point(x, y), Point(x + 5, y), Point(x + 5, y + 10), Point(x, y + 10)}};
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("Sequential clearance preserves model print order when objects move", "[SequentialClearance][Regression]")
|
||||
{
|
||||
const coord_t last_y = GENERATE(-20, 20);
|
||||
std::vector<SequentialClearanceInstance> instances{instance(20, 0, 50.), instance(0, last_y, 50.)};
|
||||
const auto warnings = sequential_clearance_height_polygons(instances, 200., 30., 10.);
|
||||
|
||||
REQUIRE(warnings.size() == 1);
|
||||
CHECK(warnings.front().first == instance(20, 0, 50.).hull_polygon);
|
||||
CHECK_THAT(warnings.front().second, WithinAbs(30., 1e-6));
|
||||
}
|
||||
|
||||
TEST_CASE("Sequential clearance handles chains of overlapping Y ranges", "[SequentialClearance][Regression]")
|
||||
{
|
||||
// A overlaps B, B overlaps C, but A does not overlap C. Combining X order
|
||||
// for overlaps with Y order otherwise formed the cycle B < A < C < B.
|
||||
std::vector<SequentialClearanceInstance> instances{
|
||||
instance(20, 0, 50.), instance(10, 8, 50.), instance(0, 16, 50.)};
|
||||
const auto warnings = sequential_clearance_height_polygons(instances, 200., 30., 10.);
|
||||
|
||||
REQUIRE(warnings.size() == 2);
|
||||
CHECK(warnings[0].first == instance(20, 0, 50.).hull_polygon);
|
||||
CHECK(warnings[1].first == instance(10, 8, 50.).hull_polygon);
|
||||
CHECK_THAT(warnings[0].second, WithinAbs(10., 1e-6));
|
||||
CHECK_THAT(warnings[1].second, WithinAbs(10., 1e-6));
|
||||
}
|
||||
|
||||
TEST_CASE("Sequential clearance applies rod height only to positive Y overlap", "[SequentialClearance]")
|
||||
{
|
||||
const coord_t next_y = GENERATE(9, 10, 11);
|
||||
std::vector<SequentialClearanceInstance> instances{instance(0, 0, 50.), instance(20, next_y, 50.)};
|
||||
const auto warnings = sequential_clearance_height_polygons(instances, 200., 30., 10.);
|
||||
|
||||
REQUIRE(warnings.size() == 1);
|
||||
CHECK(warnings.front().first == instances.front().hull_polygon);
|
||||
CHECK_THAT(warnings.front().second, WithinAbs(next_y < 10 ? 10. : 30., 1e-6));
|
||||
}
|
||||
|
||||
TEST_CASE("Sequential clearance allows the last object up to printable height", "[SequentialClearance]")
|
||||
{
|
||||
std::vector<SequentialClearanceInstance> instances;
|
||||
CHECK(sequential_clearance_height_polygons(instances, 200., 30., 10.).empty());
|
||||
|
||||
instances.push_back(instance(0, 0, 200.));
|
||||
CHECK(sequential_clearance_height_polygons(instances, 200., 30., 10.).empty());
|
||||
|
||||
instances.front().instance_height = 201.;
|
||||
const auto warnings = sequential_clearance_height_polygons(instances, 200., 30., 10.);
|
||||
REQUIRE(warnings.size() == 1);
|
||||
CHECK_THAT(warnings.front().second, WithinAbs(200., 1e-6));
|
||||
}
|
||||
|
||||
TEST_CASE("Sequential clearance warns the same rotated copy as print validation", "[SequentialClearance][Regression]")
|
||||
{
|
||||
constexpr double rod_height = 10.;
|
||||
constexpr double lid_height = 30.;
|
||||
constexpr double printable_height = 200.;
|
||||
Model model = Test::model("rotated copies", make_cube(10., 10., 50.));
|
||||
ModelObject* object = model.objects.front();
|
||||
object->instances.front()->set_offset(Vec3d(150., 80., 0.));
|
||||
ModelInstance* rotated = object->add_instance();
|
||||
rotated->set_offset(Vec3d(50., 80., 0.));
|
||||
rotated->set_rotation(Z, PI / 2.);
|
||||
|
||||
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
||||
config.set_deserialize_strict({{"print_sequence", "by object"}, {"extruder_clearance_radius", 20.},
|
||||
{"extruder_clearance_height_to_lid", lid_height}, {"extruder_clearance_height_to_rod", rod_height},
|
||||
{"printable_height", printable_height}});
|
||||
Print print;
|
||||
print.auto_assign_extruders(object);
|
||||
print.apply(model, config);
|
||||
Polygons polygons;
|
||||
std::vector<std::pair<Polygon, float>> validation_warnings;
|
||||
Print::sequential_print_clearance_valid(print, &polygons, &validation_warnings);
|
||||
|
||||
std::map<ObjectID, int> print_order;
|
||||
for (const PrintObject* print_object : print.objects())
|
||||
for (const PrintInstance& print_instance : print_object->instances())
|
||||
print_order.emplace(print_instance.model_instance->id(), print_instance.model_instance->arrange_order);
|
||||
|
||||
std::vector<SequentialClearanceInstance> instances;
|
||||
for (size_t i = 0; i < object->instances.size(); ++i) {
|
||||
Polygon hull = object->convex_hull_2d(object->instances[i]->get_matrix());
|
||||
instances.push_back({object->get_instance_max_z(i), hull.bounding_box(), hull, object->instances[i]->id()});
|
||||
}
|
||||
const Polygon expected_warning = instances[1].hull_polygon;
|
||||
REQUIRE(validation_warnings.size() == 1);
|
||||
CHECK(validation_warnings.front().first.bounding_box().contains(instances[1].bounding_box.center()));
|
||||
CHECK_FALSE(validation_warnings.front().first.bounding_box().contains(instances[0].bounding_box.center()));
|
||||
|
||||
sort_sequential_clearance_instances(instances.begin(), instances.end(), print_order);
|
||||
const auto warnings = sequential_clearance_height_polygons(instances, printable_height, lid_height, rod_height);
|
||||
REQUIRE(warnings.size() == 1);
|
||||
CHECK(warnings.front().first == expected_warning);
|
||||
CHECK_THAT(warnings.front().second, WithinAbs(rod_height, 1e-6));
|
||||
}
|
||||
|
||||
TEST_CASE("Sequential clearance keeps copy order when print order is unavailable", "[SequentialClearance]")
|
||||
{
|
||||
std::vector<SequentialClearanceInstance> instances{instance(20, 0, 50.), instance(0, 0, 50.)};
|
||||
instances[0].instance_id = ObjectID(1);
|
||||
instances[1].instance_id = ObjectID(2);
|
||||
std::map<ObjectID, int> print_order{{ObjectID(1), 2}, {ObjectID(2), 1}};
|
||||
|
||||
SECTION("No validation order") { print_order.clear(); }
|
||||
SECTION("New copy missing from print") { print_order.erase(ObjectID(2)); }
|
||||
SECTION("Copy has not been ordered") { print_order[ObjectID(2)] = 0; }
|
||||
SECTION("Copy has an invalid order") { print_order[ObjectID(2)] = -1; }
|
||||
|
||||
sort_sequential_clearance_instances(instances.begin(), instances.end(), print_order);
|
||||
CHECK(instances[0].instance_id == ObjectID(1));
|
||||
CHECK(instances[1].instance_id == ObjectID(2));
|
||||
}
|
||||
|
||||
TEST_CASE("Sequential clearance keeps object order when sorting copies", "[SequentialClearance]")
|
||||
{
|
||||
std::vector<SequentialClearanceInstance> instances(4, instance(0, 0, 50.));
|
||||
for (size_t i = 0; i < instances.size(); ++i)
|
||||
instances[i].instance_id = ObjectID(instances.size() - i);
|
||||
const std::map<ObjectID, int> print_order{{ObjectID(1), 1}, {ObjectID(2), 2}, {ObjectID(3), 3}, {ObjectID(4), 4}};
|
||||
|
||||
// The two objects were reordered in the model since the last validation.
|
||||
sort_sequential_clearance_instances(instances.begin(), instances.begin() + 2, print_order);
|
||||
sort_sequential_clearance_instances(instances.begin() + 2, instances.end(), print_order);
|
||||
CHECK(instances[0].instance_id == ObjectID(3));
|
||||
CHECK(instances[1].instance_id == ObjectID(4));
|
||||
CHECK(instances[2].instance_id == ObjectID(1));
|
||||
CHECK(instances[3].instance_id == ObjectID(2));
|
||||
}
|
||||
Reference in new issue
Block a user