feat: Python Plugins

This commit is contained in:
Ian Chua
2026-07-02 17:49:36 +08:00
parent 395e070a0e
commit ecddf3d18f
183 changed files with 49955 additions and 2120 deletions

View File

@@ -14,6 +14,7 @@ add_executable(${_TEST_NAME}_tests
test_config.cpp
test_preset_bundle_loading.cpp
test_preset_setting_id.cpp
test_preset_diff.cpp
test_elephant_foot_compensation.cpp
test_geometry.cpp
test_placeholder_parser.cpp

View File

@@ -5,10 +5,14 @@
#include "libslic3r/LocalesUtils.hpp"
#include <cereal/types/polymorphic.hpp>
#include <cereal/types/string.hpp>
#include <cereal/types/vector.hpp>
#include <cereal/types/string.hpp>
#include <cereal/types/vector.hpp>
#include <cereal/archives/binary.hpp>
#include <boost/filesystem.hpp>
#include <boost/nowide/fstream.hpp>
#include <nlohmann/json.hpp>
using namespace Slic3r;
SCENARIO("Generic config validation performs as expected.", "[Config]") {
@@ -401,3 +405,81 @@ SCENARIO("update_diff_values_to_child_config tolerates legacy machine-limit vect
// }
// }
// }
TEST_CASE("save_to_json round-trips plugin capability references as strings", "[Config][plugins]") {
namespace fs = boost::filesystem;
const fs::path tmp = fs::temp_directory_path() / fs::unique_path("orca_plugins_%%%%-%%%%.json");
const std::vector<std::string> refs = {
"local_plugin;;post_process",
"cloud_plugin;550e8400-e29b-41d4-a716-446655440000;post_process"
};
std::unique_ptr<DynamicPrintConfig> config_ptr(
DynamicPrintConfig::new_from_defaults_keys({"post_process_plugin"}));
DynamicPrintConfig config = std::move(*config_ptr);
config.option<ConfigOptionStrings>("post_process_plugin", true)->values = refs;
config.save_to_json(tmp.string(), "test_preset", "User", "1.0.0.0");
nlohmann::json j;
{
boost::nowide::ifstream ifs(tmp.string());
ifs >> j;
}
REQUIRE(j["post_process_plugin"] == nlohmann::json(refs));
CHECK_FALSE(j.contains("plugins"));
DynamicPrintConfig reloaded = DynamicPrintConfig::full_print_config();
ConfigSubstitutionContext substitutions(ForwardCompatibilitySubstitutionRule::Disable);
std::map<std::string, std::string> key_values;
std::string reason;
REQUIRE(reloaded.load_from_json(tmp.string(), substitutions, true, key_values, reason) == 0);
CHECK(reason.empty());
CHECK(reloaded.option<ConfigOptionStrings>("post_process_plugin")->values == refs);
fs::remove(tmp);
}
TEST_CASE("plugin capability references survive string-map serialization", "[Config][plugins]") {
const std::vector<std::string> refs = {
"master_plugin;;header-stamp",
"Sample Plugin;1f998ea9-0183-4cc5-957f-4eef659ba4e6;G-code Benchmark (.py)"
};
DynamicPrintConfig original = DynamicPrintConfig::full_print_config();
original.option<ConfigOptionStrings>("post_process_plugin", true)->values = refs;
std::map<std::string, std::string> serialized{
{"post_process_plugin", original.option<ConfigOptionStrings>("post_process_plugin")->serialize()}
};
CHECK(serialized["post_process_plugin"].find("\"master_plugin;;header-stamp\"") != std::string::npos);
DynamicPrintConfig reloaded = DynamicPrintConfig::full_print_config();
reloaded.load_string_map(serialized, ForwardCompatibilitySubstitutionRule::Disable);
CHECK(reloaded.option<ConfigOptionStrings>("post_process_plugin")->values == refs);
}
TEST_CASE("parse_capability_ref parses local and cloud references", "[Config][plugin]") {
const auto local = Slic3r::parse_capability_ref("local_plugin;;post_process");
REQUIRE(local.has_value());
CHECK(local->name == "local_plugin");
CHECK(local->capability_name == "post_process");
CHECK(local->uuid.empty());
const auto cloud = Slic3r::parse_capability_ref(
"cloud_plugin;550e8400-e29b-41d4-a716-446655440000;post_process");
REQUIRE(cloud.has_value());
CHECK(cloud->name == "cloud_plugin");
CHECK(cloud->capability_name == "post_process");
CHECK(cloud->uuid == "550e8400-e29b-41d4-a716-446655440000");
}
TEST_CASE("parse_capability_ref rejects malformed input", "[Config][plugin]") {
CHECK_FALSE(Slic3r::parse_capability_ref("").has_value());
CHECK_FALSE(Slic3r::parse_capability_ref("plugin").has_value());
CHECK_FALSE(Slic3r::parse_capability_ref("plugin;uuid").has_value());
CHECK_FALSE(Slic3r::parse_capability_ref(";;capability").has_value());
CHECK_FALSE(Slic3r::parse_capability_ref(";uuid;capability").has_value());
CHECK_FALSE(Slic3r::parse_capability_ref("plugin;;").has_value());
CHECK_FALSE(Slic3r::parse_capability_ref("plugin;uuid;").has_value());
}

View File

@@ -0,0 +1,35 @@
#include <catch2/catch_all.hpp>
#include "libslic3r/Preset.hpp"
#include "libslic3r/PrintConfig.hpp"
#include <algorithm>
using namespace Slic3r;
// Regression test for the python-plugin branch's intentional divergence from
// upstream in add_correct_opts_to_diff() (src/libslic3r/Preset.cpp): a vector
// option entry whose index is beyond the reference vector's length is reported
// dirty even when it duplicates an existing value. On main these duplicates
// were NOT flagged. See the comment on add_correct_opts_to_diff() in src/libslic3r/Preset.cpp.
TEST_CASE("deep_diff flags new vector entries that duplicate values[0]", "[PresetDiff][Config]")
{
// reference: single-extruder vector (one entry)
Preset reference(Preset::TYPE_PRINTER, "ref");
reference.config.set_key_value("nozzle_diameter", new ConfigOptionFloats{0.4});
// edited: a second extruder entry was added whose value duplicates the first
Preset edited(Preset::TYPE_PRINTER, "edited");
edited.config.set_key_value("nozzle_diameter", new ConfigOptionFloats{0.4, 0.4});
// deep_compare = true routes through deep_diff() -> add_correct_opts_to_diff()
std::vector<std::string> diff =
PresetCollection::dirty_options(&edited, &reference, /*deep_compare=*/true);
// The new index #1 is reported dirty even though 0.4 == values[0] (0.4).
REQUIRE(std::find(diff.begin(), diff.end(), "nozzle_diameter#1") != diff.end());
// Sanity: the unchanged existing index #0 is NOT reported, so the rule is
// specific to new indices rather than flagging the whole vector.
REQUIRE(std::find(diff.begin(), diff.end(), "nozzle_diameter#0") == diff.end());
}

View File

@@ -1,15 +1,32 @@
get_filename_component(_TEST_NAME ${CMAKE_CURRENT_LIST_DIR} NAME)
add_executable(${_TEST_NAME}_tests
${_TEST_NAME}_tests_main.cpp
test_plugin_host_api.cpp
test_plugin_capability_identifier.cpp
test_plugin_install.cpp
)
if (MSVC)
target_link_libraries(${_TEST_NAME}_tests Setupapi.lib)
endif ()
target_link_libraries(${_TEST_NAME}_tests test_common libslic3r_gui libslic3r Catch2::Catch2WithMain)
target_link_libraries(${_TEST_NAME}_tests test_common libslic3r_gui libslic3r pybind11::embed Catch2::Catch2WithMain)
set_property(TARGET ${_TEST_NAME}_tests PROPERTY FOLDER "tests")
orcaslicer_copy_test_dlls()
if (WIN32)
add_custom_command(TARGET ${_TEST_NAME}_tests POST_BUILD
COMMAND ${CMAKE_COMMAND} -E make_directory "$<TARGET_FILE_DIR:${_TEST_NAME}_tests>/python"
COMMAND ${CMAKE_COMMAND} -E copy_directory "${CMAKE_PREFIX_PATH}/libpython" "$<TARGET_FILE_DIR:${_TEST_NAME}_tests>/python"
COMMAND ${CMAKE_COMMAND} -E copy_if_different
"${CMAKE_PREFIX_PATH}/libpython/python${_bundled_python_abi}.dll"
"${CMAKE_PREFIX_PATH}/libpython/vcruntime140.dll"
"${CMAKE_PREFIX_PATH}/libpython/vcruntime140_1.dll"
"$<TARGET_FILE_DIR:${_TEST_NAME}_tests>"
COMMENT "Copying Python runtime for slic3rutils plugin host API tests"
VERBATIM
)
endif()
orcaslicer_discover_tests(${_TEST_NAME}_tests)

View File

@@ -0,0 +1,25 @@
#include <catch2/catch_all.hpp>
#include <slic3r/plugin/PluginLoader.hpp>
#include <unordered_map>
using Slic3r::PluginCapabilityIdentifier;
using Slic3r::PluginCapabilityType;
TEST_CASE("PluginCapabilityIdentifier equality includes plugin_key", "[plugin][identifier]") {
PluginCapabilityIdentifier a{PluginCapabilityType::PostProcessing, "Cleanup", "a.py"};
PluginCapabilityIdentifier b{PluginCapabilityType::PostProcessing, "Cleanup", "b.py"};
PluginCapabilityIdentifier a2{PluginCapabilityType::PostProcessing, "Cleanup", "a.py"};
CHECK(a == a2);
CHECK_FALSE(a == b); // same (type,name), different plugin_key -> distinct
}
TEST_CASE("PluginCapabilityIdentifier is usable as a hash-map key", "[plugin][identifier]") {
std::unordered_map<PluginCapabilityIdentifier, int> m;
m[{PluginCapabilityType::PostProcessing, "Cleanup", "a.py"}] = 1;
m[{PluginCapabilityType::PostProcessing, "Cleanup", "b.py"}] = 2; // no collision
CHECK(m.size() == 2);
CHECK(m.at({PluginCapabilityType::PostProcessing, "Cleanup", "a.py"}) == 1);
}

View File

@@ -0,0 +1,354 @@
#include <catch2/catch_all.hpp>
#include <libslic3r/Model.hpp>
#include <libslic3r/PresetBundle.hpp>
#include <libslic3r/TriangleMesh.hpp>
#include <slic3r/plugin/PythonPluginBridge.hpp>
#include <pybind11/embed.h>
#include <pybind11/pybind11.h>
#include <string>
namespace py = pybind11;
namespace {
void ensure_python_initialized()
{
// Deliberately a bare scoped_interpreter rather than Slic3r::PythonInterpreter:
// `orca` is a PYBIND11_EMBEDDED_MODULE compiled into this test binary, so importing
// it needs no bundled stdlib/sys.path, and the deterministic assertions are
// independent of the host's Python. PythonInterpreter::initialize() expects the
// bundled Python home laid out next to the app bundle (lib/python3.12/encodings),
// which is not deployed beside the test binary, so using it here would fail to find
// a home on macOS/Linux. The optional numpy-backed assertions are guarded at runtime.
if (!Py_IsInitialized()) {
static py::scoped_interpreter interpreter;
(void) interpreter;
}
}
py::module_ import_orca_module()
{
ensure_python_initialized();
// Force PythonPluginBridge.cpp into the test binary so the embedded
// PYBIND11_EMBEDDED_MODULE(orca, ...) registration is available.
(void) Slic3r::PythonPluginBridge::instance();
return py::module_::import("orca");
}
bool has_attr(const py::handle& object, const char* name)
{
return py::hasattr(object, name);
}
} // namespace
TEST_CASE("Plugin host API exposes host-owned bundle and preset surface to Python", "[PluginHostApi][Python]")
{
py::module_ orca = import_orca_module();
REQUIRE(has_attr(orca, "host"));
py::object host = orca.attr("host");
REQUIRE(has_attr(host, "PresetBundle"));
REQUIRE(has_attr(host, "Preset"));
REQUIRE(has_attr(host, "PresetCollection"));
REQUIRE(has_attr(host, "Model"));
REQUIRE(has_attr(host, "ModelObject"));
REQUIRE(has_attr(host, "Plater"));
py::object preset_bundle_type = host.attr("PresetBundle");
CHECK(has_attr(preset_bundle_type, "prints"));
CHECK(has_attr(preset_bundle_type, "printers"));
CHECK(has_attr(preset_bundle_type, "filaments"));
CHECK(has_attr(preset_bundle_type, "current_process_preset"));
CHECK(has_attr(preset_bundle_type, "current_printer_preset"));
CHECK(has_attr(preset_bundle_type, "current_filament_preset_names"));
CHECK(has_attr(preset_bundle_type, "current_filament_presets"));
CHECK(has_attr(preset_bundle_type, "full_config_value"));
py::object preset_collection_type = host.attr("PresetCollection");
CHECK(has_attr(preset_collection_type, "get_edited_preset"));
CHECK(has_attr(preset_collection_type, "get_selected_preset"));
CHECK(has_attr(preset_collection_type, "get_selected_preset_name"));
CHECK(has_attr(preset_collection_type, "edited_preset"));
CHECK(has_attr(preset_collection_type, "selected_preset"));
CHECK(has_attr(preset_collection_type, "selected_preset_name"));
py::object preset_type = host.attr("Preset");
CHECK(has_attr(preset_type, "name"));
CHECK(has_attr(preset_type, "type"));
CHECK(has_attr(preset_type, "is_default"));
CHECK(has_attr(preset_type, "is_system"));
CHECK(has_attr(preset_type, "is_user"));
CHECK(has_attr(preset_type, "is_from_bundle"));
CHECK(has_attr(preset_type, "config_value"));
Slic3r::PresetBundle bundle;
Slic3r::Preset& printer_preset = bundle.printers.get_edited_preset();
Slic3r::Preset& process_preset = bundle.prints.get_edited_preset();
Slic3r::Preset& filament_preset = bundle.filaments.get_edited_preset();
printer_preset.config.set("printer_model", "Plugin Host Test Printer", true);
bundle.filament_presets = { filament_preset.name, filament_preset.name, "missing filament preset" };
py::object py_bundle = py::cast(&bundle, py::return_value_policy::reference);
CHECK(py_bundle.attr("current_printer_preset")().attr("name").cast<std::string>() == printer_preset.name);
CHECK(py_bundle.attr("current_print_preset")().attr("name").cast<std::string>() == process_preset.name);
CHECK(py_bundle.attr("current_process_preset")().attr("name").cast<std::string>() == process_preset.name);
CHECK(py_bundle.attr("current_printer_preset")().attr("is_default").cast<bool>() == printer_preset.is_default);
CHECK(py_bundle.attr("current_printer_preset")().attr("is_user")().cast<bool>() == printer_preset.is_user());
CHECK(py_bundle.attr("current_printer_preset")().attr("config_value")("printer_model").cast<std::string>() == "Plugin Host Test Printer");
CHECK(py_bundle.attr("current_printer_preset")().attr("config_value")("missing_test_key").is_none());
py::list filament_names = py_bundle.attr("current_filament_preset_names")();
REQUIRE(py::len(filament_names) == 3);
CHECK(filament_names[0].cast<std::string>() == filament_preset.name);
CHECK(filament_names[1].cast<std::string>() == filament_preset.name);
CHECK(filament_names[2].cast<std::string>() == "missing filament preset");
py::list filament_presets = py_bundle.attr("current_filament_presets")();
REQUIRE(py::len(filament_presets) == 3);
CHECK_FALSE(filament_presets[0].is_none());
CHECK(filament_presets[0].attr("name").cast<std::string>() == filament_preset.name);
CHECK_FALSE(filament_presets[1].is_none());
CHECK(filament_presets[1].attr("name").cast<std::string>() == filament_preset.name);
CHECK(filament_presets[2].is_none());
py::object printers = py_bundle.attr("printers");
py::object prints = py_bundle.attr("prints");
py::object filaments = py_bundle.attr("filaments");
CHECK(printers.attr("get_edited_preset")().attr("name").cast<std::string>() == printer_preset.name);
CHECK(prints.attr("get_edited_preset")().attr("name").cast<std::string>() == process_preset.name);
CHECK(filaments.attr("get_edited_preset")().attr("name").cast<std::string>() == filament_preset.name);
CHECK(printers.attr("get_selected_preset_name")().cast<std::string>() == bundle.printers.get_selected_preset_name());
CHECK(printers.attr("get_selected_preset")().attr("name").cast<std::string>() == bundle.printers.get_selected_preset().name);
CHECK(printers.attr("selected_preset_name")().cast<std::string>() == bundle.printers.get_selected_preset_name());
CHECK(printers.attr("edited_preset")().attr("name").cast<std::string>() == printer_preset.name);
CHECK(printers.attr("find_preset")(printer_preset.name).attr("name").cast<std::string>() == printer_preset.name);
}
TEST_CASE("Plugin host API reports unavailable GUI objects before Orca app initialization", "[PluginHostApi][Python]")
{
py::object host = import_orca_module().attr("host");
for (const char* function_name : { "preset_bundle", "plater", "model" }) {
CAPTURE(function_name);
try {
host.attr(function_name)();
FAIL("host accessor 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 exposes the UI module and guards it before Orca app initialization", "[PluginHostApi][Python]")
{
py::object host = import_orca_module().attr("host");
REQUIRE(has_attr(host, "ui"));
py::object ui = host.attr("ui");
CHECK(has_attr(ui, "message"));
CHECK(has_attr(ui, "show_dialog"));
CHECK(has_attr(ui, "create_window"));
CHECK(has_attr(ui, "UiWindow"));
// With no wx application the UI calls marshal to a main thread that does not
// exist here; they must fail cleanly with a clear error, not crash.
try {
ui.attr("message")("hello");
FAIL("orca.host.ui.message 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 exposes model geometry and structure to Python", "[PluginHostApi][Python]")
{
using Catch::Matchers::WithinAbs;
using Catch::Matchers::WithinRel;
py::object host = import_orca_module().attr("host");
REQUIRE(has_attr(host, "BoundingBox"));
REQUIRE(has_attr(host, "Model"));
REQUIRE(has_attr(host, "ModelInstance"));
REQUIRE(has_attr(host, "ModelVolume"));
REQUIRE(has_attr(host, "ModelVolumeType"));
py::object volume_type_enum = host.attr("ModelVolumeType");
CHECK(has_attr(volume_type_enum, "ModelPart"));
CHECK(has_attr(volume_type_enum, "ParameterModifier"));
CHECK(has_attr(volume_type_enum, "SupportEnforcer"));
// Build a model in C++: one object with a 10x20x30 mm printable part, a small
// modifier volume, and a single instance shifted on the bed.
Slic3r::Model model;
Slic3r::ModelObject* object = model.add_object();
object->name = "Plugin Host Test Cube";
Slic3r::ModelVolume* part = object->add_volume(Slic3r::make_cube(10.0, 20.0, 30.0));
part->name = "cube part";
Slic3r::ModelVolume* modifier = object->add_volume(Slic3r::make_cube(2.0, 2.0, 2.0),
Slic3r::ModelVolumeType::PARAMETER_MODIFIER);
modifier->name = "fit modifier";
Slic3r::ModelInstance* instance = object->add_instance();
instance->set_offset(Slic3r::Vec3d(5.0, 6.0, 0.0));
py::object py_model = py::cast(&model, py::return_value_policy::reference);
// Model surface.
CHECK(py_model.attr("object_count")().cast<size_t>() == 1);
CHECK(py_model.attr("id")().cast<size_t>() == model.id().id);
CHECK(py_model.attr("bounding_box")().attr("defined").cast<bool>());
// Object surface.
py::object py_object = py_model.attr("object")(0);
CHECK(py_object.attr("name").cast<std::string>() == "Plugin Host Test Cube");
CHECK(py_object.attr("id")().cast<size_t>() == object->id().id);
CHECK(py_object.attr("instance_count")().cast<size_t>() == 1);
CHECK(py_object.attr("volume_count")().cast<size_t>() == 2);
CHECK(py::len(py_object.attr("instances")()) == 1);
CHECK(py::len(py_object.attr("volumes")()) == 2);
CHECK(py_object.attr("is_multiparts")().cast<bool>());
// Intrinsic (untransformed) object size must match the printable part's dimensions.
py::object obj_size = py_object.attr("raw_mesh_bounding_box")().attr("size");
REQUIRE_THAT(obj_size[py::int_(0)].cast<double>(), WithinAbs(10.0, 1e-3));
REQUIRE_THAT(obj_size[py::int_(1)].cast<double>(), WithinAbs(20.0, 1e-3));
REQUIRE_THAT(obj_size[py::int_(2)].cast<double>(), WithinAbs(30.0, 1e-3));
// Instance surface.
py::object py_instance = py_object.attr("instance")(0);
py::object inst_offset = py_instance.attr("offset")();
REQUIRE_THAT(inst_offset[py::int_(0)].cast<double>(), WithinAbs(5.0, 1e-6));
REQUIRE_THAT(inst_offset[py::int_(1)].cast<double>(), WithinAbs(6.0, 1e-6));
REQUIRE_THAT(inst_offset[py::int_(2)].cast<double>(), WithinAbs(0.0, 1e-6));
CHECK(py_instance.attr("id")().cast<size_t>() == instance->id().id);
// Volume surface — part.
py::object py_part = py_object.attr("volume")(0);
CHECK(py_part.attr("name").cast<std::string>() == "cube part");
CHECK(py_part.attr("is_model_part")().cast<bool>());
CHECK_FALSE(py_part.attr("is_modifier")().cast<bool>());
CHECK(py_part.attr("type")().cast<Slic3r::ModelVolumeType>() == Slic3r::ModelVolumeType::MODEL_PART);
CHECK(py_part.attr("facets_count")().cast<size_t>() == 12);
REQUIRE_THAT(py_part.attr("volume")().cast<double>(), WithinRel(6000.0, 1e-2));
// Volume surface — modifier.
py::object py_modifier = py_object.attr("volume")(1);
CHECK(py_modifier.attr("is_modifier")().cast<bool>());
CHECK_FALSE(py_modifier.attr("is_model_part")().cast<bool>());
CHECK(py_modifier.attr("type")().cast<Slic3r::ModelVolumeType>() == Slic3r::ModelVolumeType::PARAMETER_MODIFIER);
}
TEST_CASE("Plugin host API exposes TriangleMesh geometry to Python", "[PluginHostApi][Python]")
{
using Catch::Matchers::WithinAbs;
using Catch::Matchers::WithinRel;
py::object host = import_orca_module().attr("host");
REQUIRE(has_attr(host, "TriangleMesh"));
py::object mesh_type = host.attr("TriangleMesh");
for (const char* member : { "vertex_count", "triangle_count", "facets_count", "is_empty",
"vertices", "triangles", "face_normals", "vertex", "triangle",
"volume", "bounding_box", "is_manifold" }) {
CAPTURE(member);
CHECK(has_attr(mesh_type, member));
}
// A 10 x 20 x 30 mm box: 8 vertices, 12 triangles.
Slic3r::Model model;
Slic3r::ModelObject* object = model.add_object();
object->add_volume(Slic3r::make_cube(10.0, 20.0, 30.0));
Slic3r::ModelInstance* instance = object->add_instance();
instance->set_offset(Slic3r::Vec3d(5.0, 6.0, 0.0));
py::object py_object = py::cast(object, py::return_value_policy::reference);
py::object py_volume = py_object.attr("volume")(0);
py::object mesh = py_volume.attr("mesh")();
// Deterministic, numpy-free surface.
CHECK(mesh.attr("vertex_count")().cast<size_t>() == 8);
CHECK(mesh.attr("triangle_count")().cast<size_t>() == 12);
CHECK(mesh.attr("facets_count")().cast<size_t>() == 12);
CHECK_FALSE(mesh.attr("is_empty")().cast<bool>());
CHECK(mesh.attr("is_manifold")().cast<bool>());
REQUIRE_THAT(mesh.attr("volume")().cast<double>(), WithinRel(6000.0, 1e-2));
py::object bbox_size = mesh.attr("bounding_box")().attr("size");
REQUIRE_THAT(bbox_size[py::int_(0)].cast<double>(), WithinAbs(10.0, 1e-3));
REQUIRE_THAT(bbox_size[py::int_(1)].cast<double>(), WithinAbs(20.0, 1e-3));
REQUIRE_THAT(bbox_size[py::int_(2)].cast<double>(), WithinAbs(30.0, 1e-3));
py::object vertex0 = mesh.attr("vertex")(0);
REQUIRE(py::len(vertex0) == 3);
py::object triangle0 = mesh.attr("triangle")(0);
REQUIRE(py::len(triangle0) == 3);
for (int k = 0; k < 3; ++k) {
int idx = triangle0[py::int_(k)].cast<int>();
CHECK(idx >= 0);
CHECK(idx < 8);
}
CHECK_THROWS_AS(mesh.attr("vertex")(8), py::error_already_set);
CHECK_THROWS_AS(mesh.attr("triangle")(12), py::error_already_set);
// numpy path: exercised when numpy is importable, otherwise assert the clear
// "numpy required" error so the absent path is itself covered.
bool have_numpy = false;
try {
py::module_::import("numpy");
have_numpy = true;
} catch (const py::error_already_set&) {
have_numpy = false;
}
if (!have_numpy) {
WARN("numpy unavailable in unit-test interpreter; asserting the numpy-absent error path");
try {
mesh.attr("vertices")();
FAIL("vertices() must raise ImportError when numpy is unavailable");
} catch (const py::error_already_set& error) {
CHECK(error.matches(PyExc_ImportError));
CHECK(std::string(error.what()).find("numpy is required") != std::string::npos);
}
return;
}
py::object vertices = mesh.attr("vertices")();
CHECK(vertices.attr("shape").cast<py::tuple>()[py::int_(0)].cast<size_t>() == 8);
CHECK(vertices.attr("shape").cast<py::tuple>()[py::int_(1)].cast<size_t>() == 3);
CHECK(vertices.attr("dtype").attr("name").cast<std::string>() == "float32");
CHECK_FALSE(vertices.attr("flags").attr("writeable").cast<bool>());
CHECK_FALSE(vertices.attr("base").is_none()); // zero-copy view keeps an owner alive
CHECK_THROWS_AS(vertices.attr("__setitem__")(py::make_tuple(0, 0), py::float_(1.0)), py::error_already_set);
py::object triangles = mesh.attr("triangles")();
CHECK(triangles.attr("shape").cast<py::tuple>()[py::int_(0)].cast<size_t>() == 12);
CHECK(triangles.attr("shape").cast<py::tuple>()[py::int_(1)].cast<size_t>() == 3);
CHECK(triangles.attr("dtype").attr("name").cast<std::string>() == "int32");
CHECK_FALSE(triangles.attr("flags").attr("writeable").cast<bool>());
py::object face_normals = mesh.attr("face_normals")();
CHECK(face_normals.attr("shape").cast<py::tuple>()[py::int_(0)].cast<size_t>() == 12);
CHECK(face_normals.attr("dtype").attr("name").cast<std::string>() == "float32");
// World-space transform matrices.
py::object volume_matrix = py_volume.attr("matrix")();
CHECK(volume_matrix.attr("shape").cast<py::tuple>()[py::int_(0)].cast<size_t>() == 4);
CHECK(volume_matrix.attr("shape").cast<py::tuple>()[py::int_(1)].cast<size_t>() == 4);
CHECK(volume_matrix.attr("dtype").attr("name").cast<std::string>() == "float64");
py::object instance_matrix = py_object.attr("instance")(0).attr("matrix")();
CHECK(instance_matrix.attr("shape").cast<py::tuple>()[py::int_(0)].cast<size_t>() == 4);
// Instance offset (5, 6, 0) must land in the matrix translation column.
REQUIRE_THAT(instance_matrix.attr("__getitem__")(py::make_tuple(0, 3)).cast<double>(), WithinAbs(5.0, 1e-6));
REQUIRE_THAT(instance_matrix.attr("__getitem__")(py::make_tuple(1, 3)).cast<double>(), WithinAbs(6.0, 1e-6));
}

View File

@@ -0,0 +1,148 @@
#include <catch2/catch_all.hpp>
#include <libslic3r/Utils.hpp>
#include <slic3r/plugin/PluginLoader.hpp>
#include <slic3r/plugin/PluginDescriptor.hpp>
#include <slic3r/plugin/PythonFileUtils.hpp>
#include <boost/filesystem.hpp>
#include <fstream>
#include <string>
using namespace Slic3r;
namespace fs = boost::filesystem;
namespace {
// Point data_dir() at a throwaway directory for the lifetime of a test and
// restore the previous value afterwards, so install_plugin() writes into a
// disposable tree and tests don't leak state into each other.
struct ScopedDataDir
{
std::string previous;
fs::path dir;
explicit ScopedDataDir(const std::string& tag)
{
previous = data_dir();
dir = fs::temp_directory_path() / fs::unique_path("orca-" + tag + "-%%%%-%%%%");
fs::create_directories(dir);
set_data_dir(dir.string());
}
~ScopedDataDir()
{
set_data_dir(previous);
boost::system::error_code ec;
fs::remove_all(dir, ec);
}
};
fs::path write_py_file(const fs::path& dir, const std::string& filename, const std::string& contents)
{
fs::create_directories(dir);
const fs::path p = dir / filename;
std::ofstream out(p.string(), std::ios::binary);
out << contents;
return p;
}
} // namespace
TEST_CASE("install_plugin rejects a cloud UUID containing path traversal", "[PluginInstall]")
{
ScopedDataDir data_dir_guard("cor2");
// Package contents are irrelevant: the UUID is validated before metadata is read.
const fs::path py = write_py_file(data_dir_guard.dir / "src", "evil.py", "print('hi')\n");
PluginLoader loader;
loader.set_cloud_user_id("test-user");
PluginDescriptor descriptor;
// is_cloud_plugin() -> true; cloud_uuid() -> the traversal string.
descriptor.cloud = CloudPluginState{"../../escape", true, false, false, false};
std::string error;
const bool installed = loader.install_plugin(py, descriptor, error);
REQUIRE_FALSE(installed);
CHECK_THAT(error, Catch::Matchers::ContainsSubstring("valid identifier"));
}
TEST_CASE("install_plugin rejects a side-loaded .py with no PEP 723 metadata", "[PluginInstall]")
{
ScopedDataDir data_dir_guard("cor3-bad");
// No `# /// script` block -> name stays empty and type stays Unknown.
const fs::path py = write_py_file(data_dir_guard.dir / "src", "nameless.py", "print('no metadata here')\n");
PluginLoader loader; // non-cloud: no cloud user id, descriptor has no cloud state
PluginDescriptor descriptor;
std::string error;
const bool installed = loader.install_plugin(py, descriptor, error);
REQUIRE_FALSE(installed);
CHECK_THAT(error, Catch::Matchers::ContainsSubstring("PEP 723"));
}
TEST_CASE("install_plugin accepts a side-loaded .py with complete PEP 723 metadata", "[PluginInstall]")
{
ScopedDataDir data_dir_guard("cor3-good");
const std::string contents =
"# /// script\n"
"# requires-python = \">=3.12\"\n"
"#\n"
"# [tool.orcaslicer.plugin]\n"
"# name = \"Test Plugin\"\n"
"# type = \"script\"\n"
"# ///\n"
"print('ok')\n";
const fs::path py = write_py_file(data_dir_guard.dir / "src", "good.py", contents);
PluginLoader loader; // non-cloud
PluginDescriptor descriptor;
std::string error;
const bool installed = loader.install_plugin(py, descriptor, error);
// Positive control: a complete side-loaded .py must still install (guards against over-rejection).
REQUIRE(installed);
CHECK(error.empty());
}
TEST_CASE("install-state sidecar is the source of truth for a cloud plugin's installed version", "[PluginInstall]")
{
ScopedDataDir data_dir_guard("installed-version");
const fs::path plugin_dir = data_dir_guard.dir / "plugin";
fs::create_directories(plugin_dir);
// A cloud plugin whose local manifest/PEP723 header lags the version actually fetched from
// the cloud: the user bumped the version on the cloud without touching the local header.
PluginDescriptor descriptor;
descriptor.name = "Versioned Plugin";
descriptor.version = "1.0.0"; // stale header version
descriptor.installed_version = "1.2.0"; // version fetched from the cloud at install time
descriptor.cloud = CloudPluginState{"uuid-1", true, false, false, false};
REQUIRE(write_install_state(plugin_dir, descriptor));
// The writer must persist the installed_version (1.2.0), not the header version (1.0.0),
// so a subsequent re-write from a freshly-scanned descriptor cannot clobber it.
PluginInstallState state;
REQUIRE(read_install_state(plugin_dir, state));
CHECK(state.installed_version == "1.2.0");
// Reading the sidecar back onto a freshly-scanned descriptor (whose header version is still
// 1.0.0) must surface the cloud-installed 1.2.0. This is what lets update_cloud_catalog compare
// the cloud's latest version against the installed version instead of the stale header, so an
// already-updated plugin no longer looks perpetually out of date.
PluginDescriptor scanned;
scanned.version = "1.0.0"; // as parsed from the unchanged PEP723 header
read_install_state(plugin_dir, scanned);
CHECK(scanned.installed_version == "1.2.0");
}