Merge remote-tracking branch 'public/feat/plugin-feature' into feature/speed-dial

# Conflicts:
#	src/slic3r/GUI/PluginsDialog.cpp
This commit is contained in:
Andrew
2026-07-13 19:39:42 +08:00
59 changed files with 3814 additions and 846 deletions

View File

@@ -13,6 +13,7 @@ add_executable(${_TEST_NAME}_tests
test_printgcode.cpp
test_printobject.cpp
test_skirt_brim.cpp
test_slicing_pipeline_hook.cpp
test_support_material.cpp
test_trianglemesh.cpp
)

View File

@@ -0,0 +1,559 @@
#include <catch2/catch_test_macros.hpp>
#include "libslic3r/PrintConfig.hpp"
using namespace Slic3r;
TEST_CASE("slicing_pipeline_plugin option exists and defaults empty", "[slicing_pipeline]") {
DynamicPrintConfig cfg = DynamicPrintConfig::full_print_config();
const ConfigOptionStrings* opt = cfg.option<ConfigOptionStrings>("slicing_pipeline_plugin");
REQUIRE(opt != nullptr);
CHECK(opt->values.empty());
const ConfigOptionDef* def = cfg.def()->get("slicing_pipeline_plugin");
REQUIRE(def != nullptr);
CHECK(def->plugin_type == "slicing-pipeline");
CHECK(def->is_plugin_backed());
CHECK(def->gui_type == ConfigOptionDef::GUIType::plugin_picker);
}
#include "libslic3r/Print.hpp"
TEST_CASE("slicing pipeline hook setter is a no-op-safe injection", "[slicing_pipeline]") {
int calls = 0;
Slic3r::Print::set_slicing_pipeline_hook_fn(
[&](Slic3r::Print&, const Slic3r::PrintObject*, Slic3r::SlicingPipelineStepPlugin){ ++calls; });
Slic3r::Print::set_slicing_pipeline_hook_fn(nullptr); // reset — must be legal
CHECK(calls == 0);
}
#include "test_data.hpp"
#include <vector>
#include <algorithm>
using namespace Slic3r::Test;
TEST_CASE("SlicingPipeline hook fires once per step per object in order", "[slicing_pipeline]") {
struct Call { const Slic3r::PrintObject* obj; Slic3r::SlicingPipelineStepPlugin step; };
std::vector<Call> calls;
Slic3r::Print::set_slicing_pipeline_hook_fn(
[&](Slic3r::Print&, const Slic3r::PrintObject* o, Slic3r::SlicingPipelineStepPlugin s){ calls.push_back({o, s}); });
Slic3r::Print print; Slic3r::Model model;
Slic3r::DynamicPrintConfig config = Slic3r::DynamicPrintConfig::full_print_config();
config.set_key_value("slicing_pipeline_plugin", new Slic3r::ConfigOptionStrings({"probe"})); // activate
init_print({TestMesh::cube_20x20x20}, print, model, config);
print.process();
Slic3r::Print::set_slicing_pipeline_hook_fn(nullptr);
using S = Slic3r::SlicingPipelineStepPlugin;
auto count = [&](S s){ return std::count_if(calls.begin(), calls.end(), [&](const Call& c){ return c.step == s; }); };
CHECK(count(S::posSlice) == 1);
CHECK(count(S::posPerimeters) == 1);
CHECK(count(S::posPrepareInfill) == 1); // the prepare-infill seam fires once per object
CHECK(count(S::posInfill) == 1);
CHECK(count(S::psWipeTower) == 1);
CHECK(count(S::psSkirtBrim) == 1);
// psGCodePostProcess fires from the GUI export path, never from process():
CHECK(count(S::psGCodePostProcess) == 0);
// print-wide steps carry a null object:
for (const auto& c : calls)
if (c.step == S::psWipeTower || c.step == S::psSkirtBrim) CHECK(c.obj == nullptr);
// Slice must fire before Perimeters for the same object:
auto idx = [&](S s){ for (size_t i=0;i<calls.size();++i) if (calls[i].step==s) return (int)i; return -1; };
CHECK(idx(S::posSlice) < idx(S::posPerimeters));
CHECK(idx(S::posPerimeters) < idx(S::posPrepareInfill)); // prepare-infill fires after perimeters...
CHECK(idx(S::posPrepareInfill) < idx(S::posInfill)); // ...and before the fills are built
}
#include <sstream>
#include <cmath>
// Exported G-code carries a few nondeterministic comment lines unrelated to toolpaths: a
// wall-clock timestamp ("; generated by ..."), ObjectID-derived ids (from a process-global
// counter never reset between runs), and a config-dump line naming the selected plugin (an
// active run records it, the absent baseline does not). Strip exactly those lines so a raw
// byte-compare isolates the real motion/extrusion output; every other byte is still compared.
static std::string strip_nondeterministic_gcode_lines(const std::string& gcode) {
std::string out; out.reserve(gcode.size());
std::istringstream in(gcode);
std::string line;
while (std::getline(in, line)) {
if (line.compare(0, 15, "; generated by ") == 0) continue; // wall-clock timestamp
if (line.compare(0, 18, "; model label id: ") == 0) continue; // ObjectID-derived
// "; [stop] printing object <name> id:N copy M" / "... unique label id: N" (ObjectID-derived):
if (line.find("printing object") != std::string::npos && line.find(" id:") != std::string::npos) continue;
if (line.find("slicing_pipeline_plugin") != std::string::npos) continue; // config-dump plugin name
out += line; out += '\n';
}
return out;
}
TEST_CASE("Inactive hook: process output is byte-identical (no-op hook == unset)", "[slicing_pipeline]") {
// Three configurations must all normalize to the same G-code:
// (activate=false, hook=none) baseline -- feature entirely absent.
// (activate=false, hook=noop) hook registered but option empty -> gated off, never fires.
// (activate=true, hook=noop) hook ACTIVE and firing at every pipeline seam, mutating
// nothing. This is the real backward-compat claim: an active
// but non-mutating hook must not perturb the output.
auto run = [](bool activate, bool set_noop_hook) {
Slic3r::Print print; Slic3r::Model model;
auto config = Slic3r::DynamicPrintConfig::full_print_config();
// Activating requires BOTH a non-empty option and a registered hook (see Print::apply).
if (activate)
config.set_key_value("slicing_pipeline_plugin", new Slic3r::ConfigOptionStrings({"probe"}));
if (set_noop_hook)
Slic3r::Print::set_slicing_pipeline_hook_fn([](Slic3r::Print&, const Slic3r::PrintObject*, Slic3r::SlicingPipelineStepPlugin){});
else
Slic3r::Print::set_slicing_pipeline_hook_fn(nullptr);
init_print({TestMesh::cube_20x20x20}, print, model, config);
std::string g = Slic3r::Test::gcode(print);
Slic3r::Print::set_slicing_pipeline_hook_fn(nullptr);
return g;
};
// Compare only machine-meaningful output (see strip_nondeterministic_gcode_lines): every
// motion/extrusion byte is still compared, so this proves the inactive hook -- and the
// active-but-non-mutating hook -- leave the real toolpath byte-identical.
const std::string baseline = strip_nondeterministic_gcode_lines(run(false, false)); // feature absent
CHECK(strip_nondeterministic_gcode_lines(run(false, true)) == baseline); // gated off: hook never fires
CHECK(strip_nondeterministic_gcode_lines(run(true, true)) == baseline); // active no-op hook fires everywhere, mutates nothing
}
// Gating negative path. With the option EMPTY the plugin is inactive, so a
// registered hook must NOT fire even once across a full slice (m_pipeline_plugin_active
// stays false in Print::apply). Distinct from the byte-identical test above: this asserts
// the gate directly by counting invocations rather than comparing output.
TEST_CASE("Empty option: registered hook is gated off and never fires", "[slicing_pipeline]") {
int calls = 0;
Slic3r::Print::set_slicing_pipeline_hook_fn(
[&](Slic3r::Print&, const Slic3r::PrintObject*, Slic3r::SlicingPipelineStepPlugin){ ++calls; });
Slic3r::Print print; Slic3r::Model model;
auto config = Slic3r::DynamicPrintConfig::full_print_config();
// option left EMPTY -> inactive regardless of the registered hook.
init_print({TestMesh::cube_20x20x20}, print, model, config);
print.process();
Slic3r::Print::set_slicing_pipeline_hook_fn(nullptr);
CHECK(calls == 0);
}
// Duplicate-skip gating. Two ModelObjects that share one mesh_ptr are detected as
// identical by Print::process()'s is_print_object_the_same(); the second becomes a shared
// (duplicate) object and is NOT re-sliced, so the Slice hook must fire exactly once even
// though there are two print objects. The clone shares mesh_ptr and copies the volume
// transformation/config (ModelVolume copy ctor), which the equality check requires.
TEST_CASE("Duplicate objects share a slice: Slice hook fires exactly once", "[slicing_pipeline]") {
int slice_calls = 0, perim_calls = 0;
Slic3r::Print::set_slicing_pipeline_hook_fn(
[&](Slic3r::Print&, const Slic3r::PrintObject*, Slic3r::SlicingPipelineStepPlugin s){
if (s == Slic3r::SlicingPipelineStepPlugin::posSlice) ++slice_calls;
if (s == Slic3r::SlicingPipelineStepPlugin::posPerimeters) ++perim_calls;
});
Slic3r::Print print; Slic3r::Model model;
auto config = Slic3r::DynamicPrintConfig::full_print_config();
config.set_key_value("slicing_pipeline_plugin", new Slic3r::ConfigOptionStrings({"probe"})); // activate
// init_print builds one arranged, on-bed cube object (o1).
init_print({TestMesh::cube_20x20x20}, print, model, config);
Slic3r::ModelObject* o1 = model.objects.front();
// Model::add_object(const ModelObject&) force-sets object extruder=1 on the clone; give o1
// the same so the two objects' configs match (is_print_object_the_same compares config).
if (!o1->config.has("extruder"))
o1->config.set_key_value("extruder", new Slic3r::ConfigOptionInt(1));
// Clone o1: shares mesh_ptr and copies the volume transformation + config (genuine duplicate).
Slic3r::ModelObject* o2 = model.add_object(*o1);
// Shift the clone in X so validate() sees no collision (20mm cubes -> 40mm centres = 20mm gap).
for (Slic3r::ModelInstance* inst : o2->instances)
inst->set_offset(inst->get_offset() + Slic3r::Vec3d(40.0, 0.0, 0.0));
print.apply(model, config);
print.validate();
print.set_status_silent();
print.process();
Slic3r::Print::set_slicing_pipeline_hook_fn(nullptr);
REQUIRE(print.objects().size() == 2); // two print objects present...
CHECK(slice_calls == 1); // ...but the duplicate is skipped -> one slice
CHECK(perim_calls == 1); // and one perimeters pass (the sliced object)
}
#include "libslic3r/Layer.hpp" // Layer, LayerRegion (full defs for the cascade hook)
#include "libslic3r/ClipperUtils.hpp" // offset_ex
// The correctness heart of the mutation feature. A C++ hook insets every
// region's `slices` at the Slice boundary (via SurfaceCollection::set with offset
// polygons); because make_perimeters() derives fill_surfaces from slices AFTER the
// Slice hook fires (see Print::process's split slice loop), the downstream
// fill_surfaces area must shrink relative to a baseline (un-inset) run. This proves
// the mutation cascade end-to-end using the same C++ APIs the Python mutators wrap.
TEST_CASE("Mutating slices at the Slice boundary cascades downstream", "[slicing_pipeline]") {
auto fill_area = [](bool inset) {
Slic3r::Print print; Slic3r::Model model;
auto config = Slic3r::DynamicPrintConfig::full_print_config();
config.set_key_value("slicing_pipeline_plugin", new Slic3r::ConfigOptionStrings({"probe"}));
if (inset) Slic3r::Print::set_slicing_pipeline_hook_fn(
[](Slic3r::Print&, const Slic3r::PrintObject* o, Slic3r::SlicingPipelineStepPlugin s){
if (s != Slic3r::SlicingPipelineStepPlugin::posSlice || !o) return;
for (Slic3r::Layer* l : const_cast<Slic3r::PrintObject*>(o)->layers())
for (Slic3r::LayerRegion* r : l->regions()) {
Slic3r::Surfaces in = r->slices.surfaces;
for (auto& sf : in) sf.expolygon = offset_ex(sf.expolygon, -scale_(1.0)).front();
r->slices.set(std::move(in));
}
});
else Slic3r::Print::set_slicing_pipeline_hook_fn(nullptr);
init_print({TestMesh::cube_20x20x20}, print, model, config);
print.process();
double a = 0; for (auto* l : print.objects().front()->layers()) for (auto* r : l->regions()) for (auto& s : r->fill_surfaces.surfaces) a += s.expolygon.area();
Slic3r::Print::set_slicing_pipeline_hook_fn(nullptr);
return a;
};
CHECK(fill_area(true) < fill_area(false));
}
TEST_CASE("Changing slicing_pipeline_plugin invalidates posSlice", "[slicing_pipeline]") {
Slic3r::Print print; Slic3r::Model model;
auto config = Slic3r::DynamicPrintConfig::full_print_config();
init_print({TestMesh::cube_20x20x20}, print, model, config);
print.process();
REQUIRE(print.objects().front()->is_step_done(posSlice));
config.set_key_value("slicing_pipeline_plugin", new Slic3r::ConfigOptionStrings({"probe"}));
print.apply(model, config);
CHECK_FALSE(print.objects().front()->is_step_done(posSlice)); // re-slice required
}
#include <catch2/matchers/catch_matchers_floating_point.hpp>
// A similarity transform (rotate + uniform scale) applied to slices at Step.posSlice, matching
// what the Twistify sample (sandboxes/orca_twistify_plugin_example_any.py) does. This C++ analogue
// rotates every region's slices a fixed 45 deg about the object's base-footprint center -- the same
// seam and cascade the sample drives through the slices.set() + Layer::make_slices() path. Two
// end-to-end invariants after process() confirm the approach:
// (1) a pure rotation is a similarity with scale 1, so total fill area is preserved, and
// (2) the mutation genuinely cascaded into make_perimeters' fill_surfaces -- a 20mm square
// rotated 45 deg becomes a diamond whose bbox is ~sqrt(2)x wider (it did not stay
// axis-aligned), proving downstream geometry was rebuilt from the twisted slices.
TEST_CASE("Rotating slices at the Slice boundary cascades (area preserved, bbox rotated)", "[slicing_pipeline]") {
using Catch::Matchers::WithinRel;
struct Measure { double area; double width; double height; };
auto measure = [](bool rotate) -> Measure {
Slic3r::Print print; Slic3r::Model model;
auto config = Slic3r::DynamicPrintConfig::full_print_config();
config.set_key_value("slicing_pipeline_plugin", new Slic3r::ConfigOptionStrings({"probe"}));
if (rotate) Slic3r::Print::set_slicing_pipeline_hook_fn(
[](Slic3r::Print&, const Slic3r::PrintObject* o, Slic3r::SlicingPipelineStepPlugin s){
if (s != Slic3r::SlicingPipelineStepPlugin::posSlice || !o) return;
auto* obj = const_cast<Slic3r::PrintObject*>(o);
// Twist axis = center of the first sliced layer's footprint (Twistify's anchor).
coord_t nx=0, xx=0, ny=0, xy=0; bool seeded=false;
for (Slic3r::Layer* l : obj->layers()) {
for (Slic3r::LayerRegion* r : l->regions())
for (const Slic3r::Surface& sf : r->slices.surfaces)
for (const Slic3r::Point& p : sf.expolygon.contour.points) {
if (!seeded) { nx=xx=p.x(); ny=xy=p.y(); seeded=true; }
else { nx=std::min(nx,p.x()); xx=std::max(xx,p.x());
ny=std::min(ny,p.y()); xy=std::max(xy,p.y()); }
}
if (seeded) break;
}
const double cx = 0.5*((double)nx+(double)xx), cy = 0.5*((double)ny+(double)xy);
const double ct = 0.7071067811865476, st = 0.7071067811865476; // cos/sin 45 deg
auto rot = [&](const Slic3r::Point& p) {
const double dx = (double)p.x()-cx, dy = (double)p.y()-cy;
return Slic3r::Point((coord_t)std::llround(dx*ct - dy*st + cx),
(coord_t)std::llround(dx*st + dy*ct + cy));
};
for (Slic3r::Layer* l : obj->layers())
for (Slic3r::LayerRegion* r : l->regions()) {
Slic3r::Surfaces in = r->slices.surfaces;
for (auto& sf : in) {
for (auto& pt : sf.expolygon.contour.points) pt = rot(pt);
for (auto& h : sf.expolygon.holes)
for (auto& pt : h.points) pt = rot(pt);
}
r->slices.set(std::move(in));
}
});
else Slic3r::Print::set_slicing_pipeline_hook_fn(nullptr);
init_print({TestMesh::cube_20x20x20}, print, model, config);
print.process();
double area = 0;
coord_t nx=0, xx=0, ny=0, xy=0; bool seeded=false;
for (auto* l : print.objects().front()->layers())
for (auto* r : l->regions())
for (auto& sf : r->fill_surfaces.surfaces) {
area += sf.expolygon.area();
for (const Slic3r::Point& p : sf.expolygon.contour.points) {
if (!seeded) { nx=xx=p.x(); ny=xy=p.y(); seeded=true; }
else { nx=std::min(nx,p.x()); xx=std::max(xx,p.x());
ny=std::min(ny,p.y()); xy=std::max(xy,p.y()); }
}
}
Slic3r::Print::set_slicing_pipeline_hook_fn(nullptr);
return { area, (double)(xx-nx), (double)(xy-ny) };
};
const Measure base = measure(false);
const Measure rot = measure(true);
// (1) A pure rotation preserves area (similarity, scale 1): fills add up to the same area.
CHECK_THAT(rot.area, WithinRel(base.area, 0.05));
// (2) The rotation cascaded downstream: the square's fill bbox grew toward the sqrt(2)
// diagonal (diamond) instead of staying axis-aligned.
CHECK(rot.width > 1.3 * base.width);
CHECK(rot.width < 1.5 * base.width);
CHECK(rot.height > 1.3 * base.height);
CHECK(rot.height < 1.5 * base.height);
}
// The Twistify sample skips exact-identity layers entirely, but every transformed layer invokes
// the slices.set() write-back + make_perimeters re-run. This proves that write path is lossless
// for already-normalized (CCW contour / CW hole) input -- an active hook that re-sets every
// region's slices to their CURRENT geometry (the identity similarity transform) produces output
// byte-identical to an active hook that mutates nothing. Both runs are active (same config dump);
// the only difference is whether the write path ran, so equality isolates it.
TEST_CASE("Identity round-trip through slices.set() is byte-identical", "[slicing_pipeline]") {
auto run = [](bool roundtrip) {
Slic3r::Print print; Slic3r::Model model;
auto config = Slic3r::DynamicPrintConfig::full_print_config();
config.set_key_value("slicing_pipeline_plugin", new Slic3r::ConfigOptionStrings({"probe"})); // active in both runs
Slic3r::Print::set_slicing_pipeline_hook_fn(
[roundtrip](Slic3r::Print&, const Slic3r::PrintObject* o, Slic3r::SlicingPipelineStepPlugin s){
if (!roundtrip || s != Slic3r::SlicingPipelineStepPlugin::posSlice || !o) return;
for (Slic3r::Layer* l : const_cast<Slic3r::PrintObject*>(o)->layers())
for (Slic3r::LayerRegion* r : l->regions()) {
Slic3r::Surfaces in = r->slices.surfaces; // copy current (already-normalized) geometry
r->slices.set(std::move(in)); // write back unchanged: identity transform
}
});
init_print({TestMesh::cube_20x20x20}, print, model, config);
std::string g = Slic3r::Test::gcode(print);
Slic3r::Print::set_slicing_pipeline_hook_fn(nullptr);
return g;
};
CHECK(strip_nondeterministic_gcode_lines(run(true)) == strip_nondeterministic_gcode_lines(run(false)));
}
#include "libslic3r/ExtrusionEntityCollection.hpp" // count fill paths in the fill-surface cascade test
// Total leaf ExtrusionPath count under an extrusion (sub)tree (collections recursed into).
static size_t count_leaf_paths(const Slic3r::ExtrusionEntity* ee) {
if (ee == nullptr) return 0;
if (const auto* coll = dynamic_cast<const Slic3r::ExtrusionEntityCollection*>(ee)) {
size_t n = 0;
for (const Slic3r::ExtrusionEntity* e : coll->entities) n += count_leaf_paths(e);
return n;
}
return 1;
}
// Width (scaled) of the object-wide bounding box over every region's sliced contour.
static double outer_slices_width(const Slic3r::Print& print) {
coord_t min_x = 0, max_x = 0; bool seeded = false;
for (auto* l : print.objects().front()->layers())
for (auto* r : l->regions())
for (const Slic3r::Surface& sf : r->slices.surfaces)
for (const Slic3r::Point& p : sf.expolygon.contour.points) {
if (!seeded) { min_x = max_x = p.x(); seeded = true; }
else { min_x = std::min(min_x, p.x()); max_x = std::max(max_x, p.x()); }
}
return (double)(max_x - min_x);
}
// After the Slice hook mutates slices, raw_slices must be re-snapshotted so the mutation
// becomes the untyped baseline. make_perimeters() restores untyped slices from raw_slices on
// any perimeter re-run; invoking that restore directly must reproduce the mutation, not revert
// to the pre-hook geometry (which is what happened before this fix).
TEST_CASE("raw_slices captures post-hook geometry so a perimeter re-run keeps the mutation", "[slicing_pipeline]") {
using Catch::Matchers::WithinRel;
Slic3r::Print::set_slicing_pipeline_hook_fn(
[](Slic3r::Print&, const Slic3r::PrintObject* o, Slic3r::SlicingPipelineStepPlugin s){
if (s != Slic3r::SlicingPipelineStepPlugin::posSlice || !o) return;
for (Slic3r::Layer* l : const_cast<Slic3r::PrintObject*>(o)->layers())
for (Slic3r::LayerRegion* r : l->regions()) {
Slic3r::Surfaces in = r->slices.surfaces;
for (auto& sf : in) {
Slic3r::ExPolygons e = offset_ex(sf.expolygon, -scale_(1.0));
if (!e.empty()) sf.expolygon = e.front();
}
r->slices.set(std::move(in));
}
});
Slic3r::Print print; Slic3r::Model model;
auto config = Slic3r::DynamicPrintConfig::full_print_config();
config.set_key_value("slicing_pipeline_plugin", new Slic3r::ConfigOptionStrings({"probe"}));
init_print({TestMesh::cube_20x20x20}, print, model, config);
print.process();
const double w_mutated = outer_slices_width(print); // inset applied at the Slice hook
// The same restore make_perimeters() runs on a perimeter-only re-slice. With the post-hook
// backup this reproduces the inset; without it this reverts to the wider original outline.
for (Slic3r::Layer* l : print.objects().front()->layers())
l->restore_untyped_slices();
const double w_restored = outer_slices_width(print);
Slic3r::Print::set_slicing_pipeline_hook_fn(nullptr);
CHECK_THAT(w_restored, WithinRel(w_mutated, 0.02)); // mutation survived the restore
}
// A plugin can mutate fill_surfaces at the new PrepareInfill seam and have make_fills consume
// them, whereas the pre-existing Infill seam fires after the fills are already built.
// All three runs register a hook (active path) so the comparison isolates only the mutation.
TEST_CASE("fill_surfaces mutation cascades at PrepareInfill but not at Infill", "[slicing_pipeline]") {
auto fill_paths = [](bool shrink, Slic3r::SlicingPipelineStepPlugin at) {
Slic3r::Print print; Slic3r::Model model;
auto config = Slic3r::DynamicPrintConfig::full_print_config();
config.set_key_value("slicing_pipeline_plugin", new Slic3r::ConfigOptionStrings({"probe"}));
Slic3r::Print::set_slicing_pipeline_hook_fn(
[shrink, at](Slic3r::Print&, const Slic3r::PrintObject* o, Slic3r::SlicingPipelineStepPlugin s){
if (!shrink || s != at || !o) return;
for (Slic3r::Layer* l : const_cast<Slic3r::PrintObject*>(o)->layers())
for (Slic3r::LayerRegion* r : l->regions()) {
Slic3r::Surfaces in = r->fill_surfaces.surfaces, out;
for (const Slic3r::Surface& sf : in)
for (const Slic3r::ExPolygon& e : offset_ex(sf.expolygon, -scale_(3.0))) {
Slic3r::Surface s2 = sf; s2.expolygon = e; out.push_back(std::move(s2));
}
r->fill_surfaces.set(std::move(out));
}
});
init_print({TestMesh::cube_20x20x20}, print, model, config);
print.process();
size_t n = 0;
for (auto* l : print.objects().front()->layers())
for (auto* r : l->regions())
n += count_leaf_paths(&r->fills);
Slic3r::Print::set_slicing_pipeline_hook_fn(nullptr);
return n;
};
using S = Slic3r::SlicingPipelineStepPlugin;
const size_t base = fill_paths(false, S::posPrepareInfill); // active hook, no mutation
CHECK(base > 0);
CHECK(fill_paths(true, S::posPrepareInfill) < base); // mutation before make_fills cascades
CHECK(fill_paths(true, S::posInfill) == base); // mutation after make_fills is a no-op
}
// lslices (the layer's merged islands) are built once in slice() and never rebuilt by
// make_perimeters, so mutating region slices leaves them stale. The slices.set() + Layer::make_slices()
// path re-derives them; this C++ analogue proves the mechanism -- without the
// refresh the islands keep the original 20mm footprint, with it they track the 18mm inset.
TEST_CASE("refreshing lslices after a slice mutation makes islands track the geometry", "[slicing_pipeline]") {
auto lslices_width = [](bool refresh) {
Slic3r::Print print; Slic3r::Model model;
auto config = Slic3r::DynamicPrintConfig::full_print_config();
config.set_key_value("slicing_pipeline_plugin", new Slic3r::ConfigOptionStrings({"probe"}));
Slic3r::Print::set_slicing_pipeline_hook_fn(
[refresh](Slic3r::Print&, const Slic3r::PrintObject* o, Slic3r::SlicingPipelineStepPlugin s){
if (s != Slic3r::SlicingPipelineStepPlugin::posSlice || !o) return;
for (Slic3r::Layer* l : const_cast<Slic3r::PrintObject*>(o)->layers()) {
for (Slic3r::LayerRegion* r : l->regions()) {
Slic3r::Surfaces in = r->slices.surfaces;
for (auto& sf : in) {
Slic3r::ExPolygons e = offset_ex(sf.expolygon, -scale_(1.0));
if (!e.empty()) sf.expolygon = e.front();
}
r->slices.set(std::move(in));
}
if (refresh) // the load-bearing half of the slices.set() + Layer::make_slices() path
l->make_slices();
}
});
init_print({TestMesh::cube_20x20x20}, print, model, config);
print.process();
coord_t min_x = 0, max_x = 0; bool seeded = false;
for (auto* l : print.objects().front()->layers())
for (const Slic3r::ExPolygon& island : l->lslices)
for (const Slic3r::Point& p : island.contour.points) {
if (!seeded) { min_x = max_x = p.x(); seeded = true; }
else { min_x = std::min(min_x, p.x()); max_x = std::max(max_x, p.x()); }
}
Slic3r::Print::set_slicing_pipeline_hook_fn(nullptr);
return (double)(max_x - min_x);
};
using Catch::Matchers::WithinRel;
const double stale = lslices_width(false); // islands keep the original ~20 mm footprint
const double fresh = lslices_width(true); // islands track the ~18 mm inset region slices
CHECK(fresh < stale);
CHECK_THAT(stale, WithinRel((double) scale_(20.0), 0.05)); // stale islands = original outline
CHECK_THAT(fresh, WithinRel((double) scale_(18.0), 0.05)); // refreshed islands = inset outline
}
#include <random> // deterministic RNG for the fuzzy-skin analogue below
// Fuzzy skin applied to the slice contours at the Slice boundary, matching what the Fuzzy
// Slices sample (sandboxes/orca_fuzzy_slices_plugin_any.py) does: resample every ring at
// 3/4..5/4 * point_distance and displace each new vertex +/-thickness along the segment
// normal (libslic3r's fuzzy_polyline with uniform noise). Unlike the count-preserving rotate
// test above, this is a count-CHANGING rebuild -- each ring is replaced by one with a
// different vertex count. Three end-to-end invariants after process() confirm the cascade:
// (1) the jitter is zero-mean, so total fill area is preserved within a few %,
// (2) the fuzz genuinely cascaded into make_perimeters' fill_surfaces -- their contours
// carry far more vertices than the crisp baseline square's,
// (3) displacement is bounded: the sliced footprint grows by at most ~2*thickness.
TEST_CASE("Fuzzing slice contours at the Slice boundary cascades with bounded displacement", "[slicing_pipeline]") {
using Catch::Matchers::WithinRel;
static constexpr double kThickness = 0.3, kPointDist = 0.8; // mm; the built-in fuzzy-skin defaults
struct Measure { double area; size_t verts; double width; };
auto measure = [](bool fuzz) -> Measure {
Slic3r::Print print; Slic3r::Model model;
auto config = Slic3r::DynamicPrintConfig::full_print_config();
config.set_key_value("slicing_pipeline_plugin", new Slic3r::ConfigOptionStrings({"probe"})); // active in both runs
if (fuzz) Slic3r::Print::set_slicing_pipeline_hook_fn(
[](Slic3r::Print&, const Slic3r::PrintObject* o, Slic3r::SlicingPipelineStepPlugin s){
if (s != Slic3r::SlicingPipelineStepPlugin::posSlice || !o) return;
const double thickness = scale_(kThickness);
const double min_dist = scale_(kPointDist) * 0.75;
const double rand_range = scale_(kPointDist) * 0.5;
std::mt19937 rng(0x5EED); // fixed seed: the run is deterministic
std::uniform_real_distribution<double> uni(0.0, 1.0);
auto fuzz_ring = [&](Slic3r::Points& pts) {
if (pts.size() < 3) return;
Slic3r::Points out;
double dist_left_over = uni(rng) * (min_dist / 2.0);
const Slic3r::Point* p0 = &pts.back();
for (const Slic3r::Point& p1 : pts) {
const Slic3r::Vec2d v = (p1 - *p0).cast<double>();
const double seg = v.norm();
if (seg > 0.0) {
double d = dist_left_over;
for (; d < seg; d += min_dist + uni(rng) * rand_range) {
const double r = (uni(rng) * 2.0 - 1.0) * thickness;
const Slic3r::Vec2d pa = p0->cast<double>() + v * (d / seg);
const Slic3r::Vec2d n = Slic3r::Vec2d(-v.y(), v.x()) / seg;
out.emplace_back((coord_t) std::llround(pa.x() + n.x() * r),
(coord_t) std::llround(pa.y() + n.y() * r));
}
dist_left_over = d - seg;
}
p0 = &p1;
}
if (out.size() >= 3) pts = std::move(out); // else: ring too short, keep it crisp
};
for (Slic3r::Layer* l : const_cast<Slic3r::PrintObject*>(o)->layers())
for (Slic3r::LayerRegion* r : l->regions()) {
Slic3r::Surfaces in = r->slices.surfaces;
for (auto& sf : in) {
fuzz_ring(sf.expolygon.contour.points);
for (auto& h : sf.expolygon.holes) fuzz_ring(h.points);
}
r->slices.set(std::move(in));
}
});
else Slic3r::Print::set_slicing_pipeline_hook_fn(nullptr);
init_print({TestMesh::cube_20x20x20}, print, model, config);
print.process();
Measure m { 0.0, 0, outer_slices_width(print) };
for (auto* l : print.objects().front()->layers())
for (auto* r : l->regions())
for (auto& sf : r->fill_surfaces.surfaces) {
m.area += sf.expolygon.area();
m.verts += sf.expolygon.contour.points.size();
}
Slic3r::Print::set_slicing_pipeline_hook_fn(nullptr);
return m;
};
const Measure base = measure(false);
const Measure fz = measure(true);
// (1) Zero-mean jitter: the fills add up to (nearly) the same area.
CHECK_THAT(fz.area, WithinRel(base.area, 0.05));
// (2) The resample cascaded downstream: fill boundaries derived from the fuzzed slices
// carry far more vertices than the baseline square's.
CHECK(fz.verts > 4 * base.verts);
// (3) Displacement is bounded by the +/-thickness jitter: the footprint widened, but by
// no more than ~2*thickness (one thickness per side, plus rounding slack).
CHECK(fz.width > base.width);
CHECK(fz.width < base.width + 2.5 * scale_(kThickness));
}

View File

@@ -410,14 +410,14 @@ TEST_CASE("save_to_json round-trips plugin capability references as strings", "[
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"
"local_plugin;;inset",
"cloud_plugin;550e8400-e29b-41d4-a716-446655440000;inset"
};
std::unique_ptr<DynamicPrintConfig> config_ptr(
DynamicPrintConfig::new_from_defaults_keys({"post_process_plugin"}));
DynamicPrintConfig::new_from_defaults_keys({"slicing_pipeline_plugin"}));
DynamicPrintConfig config = std::move(*config_ptr);
config.option<ConfigOptionStrings>("post_process_plugin", true)->values = refs;
config.option<ConfigOptionStrings>("slicing_pipeline_plugin", true)->values = refs;
config.save_to_json(tmp.string(), "test_preset", "User", "1.0.0.0");
nlohmann::json j;
@@ -425,7 +425,7 @@ TEST_CASE("save_to_json round-trips plugin capability references as strings", "[
boost::nowide::ifstream ifs(tmp.string());
ifs >> j;
}
REQUIRE(j["post_process_plugin"] == nlohmann::json(refs));
REQUIRE(j["slicing_pipeline_plugin"] == nlohmann::json(refs));
CHECK_FALSE(j.contains("plugins"));
DynamicPrintConfig reloaded = DynamicPrintConfig::full_print_config();
@@ -434,7 +434,7 @@ TEST_CASE("save_to_json round-trips plugin capability references as strings", "[
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);
CHECK(reloaded.option<ConfigOptionStrings>("slicing_pipeline_plugin")->values == refs);
fs::remove(tmp);
}
@@ -446,17 +446,17 @@ TEST_CASE("plugin capability references survive string-map serialization", "[Con
};
DynamicPrintConfig original = DynamicPrintConfig::full_print_config();
original.option<ConfigOptionStrings>("post_process_plugin", true)->values = refs;
original.option<ConfigOptionStrings>("slicing_pipeline_plugin", true)->values = refs;
std::map<std::string, std::string> serialized{
{"post_process_plugin", original.option<ConfigOptionStrings>("post_process_plugin")->serialize()}
{"slicing_pipeline_plugin", original.option<ConfigOptionStrings>("slicing_pipeline_plugin")->serialize()}
};
CHECK(serialized["post_process_plugin"].find("\"master_plugin;;header-stamp\"") != std::string::npos);
CHECK(serialized["slicing_pipeline_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);
CHECK(reloaded.option<ConfigOptionStrings>("slicing_pipeline_plugin")->values == refs);
}
TEST_CASE("parse_capability_ref parses local and cloud references", "[Config][plugin]") {
@@ -483,3 +483,55 @@ TEST_CASE("parse_capability_ref rejects malformed input", "[Config][plugin]") {
CHECK_FALSE(Slic3r::parse_capability_ref("plugin;;").has_value());
CHECK_FALSE(Slic3r::parse_capability_ref("plugin;uuid;").has_value());
}
namespace {
// Installs a stub capability resolver that echoes the capability type into the reference, so tests
// can assert each plugin-backed option resolved with its own ConfigOptionDef::plugin_type. Resets
// the global resolver on teardown -- tests run in random order and other cases assert the
// no-resolver behavior (an absent "plugins" manifest).
struct PluginResolverFixture {
PluginResolverFixture() {
ConfigBase::set_resolve_capability_fn([](const std::string& name, const std::string& type) {
return name.empty() ? std::string() : name + ";;" + type;
});
}
~PluginResolverFixture() { ConfigBase::set_resolve_capability_fn(nullptr); }
};
} // namespace
TEST_CASE_METHOD(PluginResolverFixture,
"update_plugin_manifest derives references generically from plugin-backed options",
"[Config][plugins]") {
// Both scalar (printer_agent) and vector (slicing_pipeline_plugin) options opt in via a non-empty
// ConfigOptionDef::plugin_type (is_plugin_backed) and are resolved with it -- there is no hardcoded
// per-option switch. printer_agent in particular relies on its plugin_type metadata being wired up
// (it is edited via a dedicated widget, not the plugin_picker).
std::unique_ptr<DynamicPrintConfig> config_ptr(DynamicPrintConfig::new_from_defaults_keys(
{"slicing_pipeline_plugin", "printer_agent"}));
DynamicPrintConfig config = std::move(*config_ptr);
config.option<ConfigOptionStrings>("slicing_pipeline_plugin", true)->values = {"sp"};
config.option<ConfigOptionString>("printer_agent", true)->value = "agent";
config.update_plugin_manifest();
const std::vector<std::string> manifest = config.option<ConfigOptionStrings>("plugins")->values;
using Catch::Matchers::VectorContains;
REQUIRE_THAT(manifest, VectorContains(std::string("sp;;slicing-pipeline")));
REQUIRE_THAT(manifest, VectorContains(std::string("agent;;printer-connection")));
CHECK(manifest.size() == 2);
}
TEST_CASE_METHOD(PluginResolverFixture,
"update_plugin_manifest de-duplicates references and skips unset options",
"[Config][plugins]") {
std::unique_ptr<DynamicPrintConfig> config_ptr(DynamicPrintConfig::new_from_defaults_keys(
{"slicing_pipeline_plugin", "printer_agent"}));
DynamicPrintConfig config = std::move(*config_ptr);
config.option<ConfigOptionStrings>("slicing_pipeline_plugin", true)->values = {"x", "x"}; // duplicate
// printer_agent stays at its default empty value -> contributes nothing to the manifest.
config.update_plugin_manifest();
const std::vector<std::string> manifest = config.option<ConfigOptionStrings>("plugins")->values;
CHECK(manifest == std::vector<std::string>{"x;;slicing-pipeline"});
}

View File

@@ -6,6 +6,7 @@ add_executable(${_TEST_NAME}_tests
test_plugin_callback_list.cpp
test_plugin_capability_identifier.cpp
test_plugin_install.cpp
test_slicing_pipeline_bindings.cpp
test_plugin_sort.cpp
test_speed_dial_action_id.cpp
)

View File

@@ -0,0 +1,38 @@
#pragma once
// Shared embedded-interpreter bootstrap for slic3rutils tests that need a live Python
// interpreter (test_plugin_host_api.cpp, test_slicing_pipeline_bindings.cpp, ...).
#include <pybind11/embed.h>
#include <pybind11/pybind11.h>
#include <slic3r/plugin/PythonPluginBridge.hpp>
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 pybind11::scoped_interpreter interpreter;
(void) interpreter;
}
}
pybind11::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 pybind11::module_::import("orca");
}
} // namespace

View File

@@ -8,9 +8,9 @@ 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"};
PluginCapabilityIdentifier a{PluginCapabilityType::SlicingPipeline, "Cleanup", "a.py"};
PluginCapabilityIdentifier b{PluginCapabilityType::SlicingPipeline, "Cleanup", "b.py"};
PluginCapabilityIdentifier a2{PluginCapabilityType::SlicingPipeline, "Cleanup", "a.py"};
CHECK(a == a2);
CHECK_FALSE(a == b); // same (type,name), different plugin_key -> distinct
@@ -18,8 +18,8 @@ TEST_CASE("PluginCapabilityIdentifier equality includes plugin_key", "[plugin][i
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
m[{PluginCapabilityType::SlicingPipeline, "Cleanup", "a.py"}] = 1;
m[{PluginCapabilityType::SlicingPipeline, "Cleanup", "b.py"}] = 2; // no collision
CHECK(m.size() == 2);
CHECK(m.at({PluginCapabilityType::PostProcessing, "Cleanup", "a.py"}) == 1);
CHECK(m.at({PluginCapabilityType::SlicingPipeline, "Cleanup", "a.py"}) == 1);
}

View File

@@ -5,6 +5,8 @@
#include <libslic3r/TriangleMesh.hpp>
#include <slic3r/plugin/PythonPluginBridge.hpp>
#include "python_test_support.hpp"
#include <pybind11/embed.h>
#include <pybind11/pybind11.h>
@@ -14,30 +16,8 @@ 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");
}
// import_orca_module() lives in python_test_support.hpp (shared with
// test_slicing_pipeline_bindings.cpp).
bool has_attr(const py::handle& object, const char* name)
{
@@ -46,7 +26,7 @@ bool has_attr(const py::handle& object, const char* name)
} // namespace
TEST_CASE("Plugin host API exposes host-owned bundle and preset surface to Python", "[PluginHostApi][Python]")
TEST_CASE("Plugin host API exposes host-owned bundle and preset surface to Python", "[PluginHost][Python]")
{
py::module_ orca = import_orca_module();
REQUIRE(has_attr(orca, "host"));
@@ -131,7 +111,7 @@ TEST_CASE("Plugin host API exposes host-owned bundle and preset surface to Pytho
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]")
TEST_CASE("Plugin host API reports unavailable GUI objects before Orca app initialization", "[PluginHost][Python]")
{
py::object host = import_orca_module().attr("host");
@@ -147,7 +127,7 @@ TEST_CASE("Plugin host API reports unavailable GUI objects before Orca app initi
}
}
TEST_CASE("Plugin host API exposes the UI module and guards it before Orca app initialization", "[PluginHostApi][Python]")
TEST_CASE("Plugin host API exposes the UI module and guards it before Orca app initialization", "[PluginHost][Python]")
{
py::object host = import_orca_module().attr("host");
REQUIRE(has_attr(host, "ui"));
@@ -169,7 +149,7 @@ TEST_CASE("Plugin host API exposes the UI module and guards it before Orca app i
}
}
TEST_CASE("Plugin host API exposes model geometry and structure to Python", "[PluginHostApi][Python]")
TEST_CASE("Plugin host API exposes model geometry and structure to Python", "[PluginHost][Python]")
{
using Catch::Matchers::WithinAbs;
using Catch::Matchers::WithinRel;
@@ -248,7 +228,7 @@ TEST_CASE("Plugin host API exposes model geometry and structure to Python", "[Pl
CHECK(py_modifier.attr("type")().cast<Slic3r::ModelVolumeType>() == Slic3r::ModelVolumeType::PARAMETER_MODIFIER);
}
TEST_CASE("Plugin host API exposes TriangleMesh geometry to Python", "[PluginHostApi][Python]")
TEST_CASE("Plugin host API exposes TriangleMesh geometry to Python", "[PluginHost][Python]")
{
using Catch::Matchers::WithinAbs;
using Catch::Matchers::WithinRel;

View File

@@ -146,3 +146,38 @@ TEST_CASE("install-state sidecar is the source of truth for a cloud plugin's ins
read_install_state(plugin_dir, scanned);
CHECK(scanned.installed_version == "1.2.0");
}
TEST_CASE("install_plugin parses [tool.orcaslicer.plugin.settings] into descriptor.settings", "[PluginInstall]")
{
ScopedDataDir data_dir_guard("plugin-settings");
// A PEP-723 header with a per-plugin settings sub-table. Values stay strings; the plugin
// parses what it needs (ctx.params). This is the source Twistify reads its knobs from.
const std::string contents =
"# /// script\n"
"# requires-python = \">=3.12\"\n"
"#\n"
"# [tool.orcaslicer.plugin]\n"
"# name = \"Settings Plugin\"\n"
"# type = \"slicing-pipeline\"\n"
"#\n"
"# [tool.orcaslicer.plugin.settings]\n"
"# twist_deg_per_mm = \"1.5\"\n"
"# taper_per_mm = \"-0.004\"\n"
"# ///\n"
"print('ok')\n";
const fs::path py = write_py_file(data_dir_guard.dir / "src", "settings.py", contents);
PluginLoader loader; // non-cloud
PluginDescriptor descriptor;
std::string error;
const bool installed = loader.install_plugin(py, descriptor, error);
REQUIRE(installed);
CHECK(error.empty());
REQUIRE(descriptor.settings.count("twist_deg_per_mm") == 1);
CHECK(descriptor.settings.at("twist_deg_per_mm") == "1.5");
CHECK(descriptor.settings.at("taper_per_mm") == "-0.004");
// Identity keys are NOT captured as settings (they belong to [tool.orcaslicer.plugin]).
CHECK(descriptor.settings.count("name") == 0);
}

View File

@@ -0,0 +1,682 @@
#include <catch2/catch_test_macros.hpp>
#include "slic3r/plugin/PythonPluginInterface.hpp"
using namespace Slic3r;
TEST_CASE("SlicingPipeline capability-type string maps round-trip", "[slicing_pipeline]") {
CHECK(plugin_capability_type_to_string(PluginCapabilityType::SlicingPipeline) == "slicing-pipeline");
CHECK(plugin_capability_type_display_name(PluginCapabilityType::SlicingPipeline) == "Slicing Pipeline");
CHECK(plugin_capability_type_from_string("slicing-pipeline") == PluginCapabilityType::SlicingPipeline);
CHECK(plugin_capability_type_from_string("SLICING-PIPELINE") == PluginCapabilityType::SlicingPipeline);
CHECK(plugin_capability_type_from_string("nope") == PluginCapabilityType::Unknown);
}
#include "python_test_support.hpp"
#include "slic3r/plugin/PluginBindingUtils.hpp"
#include "slic3r/plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.hpp"
#include "libslic3r/Point.hpp"
#include "libslic3r/ExPolygon.hpp"
#include "libslic3r/Surface.hpp"
#include "libslic3r/Layer.hpp"
#include "libslic3r/ExtrusionEntity.hpp"
#include "libslic3r/ExtrusionEntityCollection.hpp"
#include <catch2/matchers/catch_matchers_floating_point.hpp>
#include <pybind11/embed.h>
#include <pybind11/numpy.h>
namespace py = pybind11;
TEST_CASE("make_readonly_rows builds a read-only (N,2) int64 view", "[slicing_pipeline]") {
ensure_python_initialized(); // helper already used by test_plugin_host_api.cpp
py::gil_scoped_acquire gil;
// make_readonly_rows() constructs a py::array_t, which requires numpy to be
// importable in the embedded interpreter. The unit-test interpreter ships no
// site-packages (same condition test_plugin_host_api.cpp's TriangleMesh numpy
// test guards against), so skip the array-backed assertions when numpy is
// unavailable there rather than fail on an environment quirk.
bool have_numpy = false;
try {
py::module_::import("numpy");
have_numpy = true;
} catch (const py::error_already_set&) {
have_numpy = false;
}
if (!have_numpy) {
SKIP("numpy unavailable in unit-test interpreter");
}
static Slic3r::Points pts = { Slic3r::Point(10, 20), Slic3r::Point(30, 40) };
py::capsule keepalive(&pts, [](void*){});
py::array a = Slic3r::make_readonly_rows<coord_t, 2>(keepalive, pts.front().data(), (py::ssize_t)pts.size());
CHECK(a.dtype().kind() == 'i');
CHECK(a.itemsize() == 8); // int64
CHECK(a.shape(0) == 2);
CHECK(a.shape(1) == 2);
CHECK_FALSE(a.writeable());
auto r = a.unchecked<coord_t, 2>();
CHECK(r(0,0) == 10); CHECK(r(1,1) == 40);
}
TEST_CASE("make_writable_rows builds a writable (N,2) int64 view that aliases the buffer", "[slicing_pipeline]") {
ensure_python_initialized();
py::gil_scoped_acquire gil;
bool have_numpy = false;
try { py::module_::import("numpy"); have_numpy = true; }
catch (const py::error_already_set&) { have_numpy = false; }
if (!have_numpy) SKIP("numpy unavailable in unit-test interpreter");
static Slic3r::Points pts = { Slic3r::Point(10, 20), Slic3r::Point(30, 40) };
py::capsule keepalive(&pts, [](void*){});
py::array a = Slic3r::make_writable_rows<coord_t, 2>(keepalive, pts.front().data(), (py::ssize_t)pts.size());
CHECK(a.writeable());
// Writing through the view mutates the C++ buffer (zero-copy alias).
a.attr("__setitem__")(py::make_tuple(0, 0), py::int_(99));
CHECK(pts.front().x() == 99);
}
TEST_CASE("orca.slicing module: Step enum, context, and a Python capability can execute", "[slicing_pipeline]") {
ensure_python_initialized();
import_orca_module(); // forces PythonPluginBridge::instance() (see import_orca_module in python_test_support.hpp)
py::gil_scoped_acquire gil;
py::module_ orca = py::module_::import("orca");
REQUIRE(py::hasattr(orca, "slicing"));
py::object slicing = orca.attr("slicing");
CHECK(py::hasattr(slicing, "Step"));
CHECK(py::hasattr(slicing.attr("Step"), "posSlice"));
CHECK(py::hasattr(slicing.attr("Step"), "psGCodePostProcess"));
CHECK(py::hasattr(slicing, "SlicingPipelineContext"));
CHECK(py::hasattr(slicing, "SlicingPipelineCapabilityBase"));
// A trivial Python subclass whose execute() reports success, invoked via the C++ trampoline.
py::exec(R"(
import orca
class Probe(orca.slicing.SlicingPipelineCapabilityBase):
def get_name(self): return "probe"
def execute(self, ctx): return orca.ExecutionResult.success("ok")
_probe = Probe()
)");
// (Full C++ trampoline invocation with a real context is exercised elsewhere.)
}
TEST_CASE("orca.slicing is workflow-only: context exposes raw print/object; view classes are gone", "[slicing_pipeline]") {
using Catch::Matchers::WithinRel;
ensure_python_initialized();
import_orca_module();
py::gil_scoped_acquire gil;
py::module_ orca = py::module_::import("orca");
py::object slicing = orca.attr("slicing");
// Context surface: raw graph entry points + workflow accessors.
for (const char* name : { "print", "object", "params", "config_value", "cancelled",
"orca_version", "step" })
CHECK(py::hasattr(slicing.attr("SlicingPipelineContext"), name));
// The wrapper layer is gone.
for (const char* legacy : { "ExPolygonView", "SurfaceView", "LayerRegionView",
"LayerView", "PrintObjectView", "PathData", "SurfaceType" })
CHECK_FALSE(py::hasattr(slicing, legacy));
// unscale() stays in orca.slicing and reads the live SCALING_FACTOR.
const coord_t scaled10 = (coord_t) scale_(10.0);
double mm = slicing.attr("unscale")(scaled10).cast<double>();
CHECK_THAT(mm, WithinRel(10.0, 1e-9));
// A default context casts print/object to None (no dangling wrapper).
Slic3r::SlicingPipelineContext ctx;
py::object pyctx = py::cast(&ctx, py::return_value_policy::reference);
CHECK(pyctx.attr("print").is_none());
CHECK(pyctx.attr("object").is_none());
}
#include "libslic3r/PrintConfig.hpp" // DynamicPrintConfig for the psGCodePostProcess context
#include <boost/filesystem.hpp>
#include <boost/nowide/fstream.hpp>
#include <sstream>
// psGCodePostProcess is the merged post-processing seam: no live Print (print/object are None), the
// plugin edits the file at ctx.gcode_path in place, and ctx.config_value() falls back to the config
// the export path handed in. Exercising the real bindings by calling the Python execute() directly
// (not the C++ audit trampoline) keeps this a pure binding-surface test.
TEST_CASE("orca.slicing psGCodePostProcess context: file edit in place + config fallback", "[slicing_pipeline]") {
namespace fs = boost::filesystem;
ensure_python_initialized();
import_orca_module();
py::gil_scoped_acquire gil;
const fs::path gpath = fs::temp_directory_path() / fs::unique_path("orca_pp_%%%%-%%%%.gcode");
{
boost::nowide::ofstream ofs(gpath.string());
ofs << "; header\nG1 X0 Y0\n";
}
// Config the plugin reads back through ctx.config_value() (there is no live Print at this step).
Slic3r::DynamicPrintConfig config = Slic3r::DynamicPrintConfig::full_print_config();
config.set_key_value("layer_height", new Slic3r::ConfigOptionFloat(0.2));
Slic3r::SlicingPipelineContext ctx;
ctx.orca_version = "test";
ctx.step = Slic3r::SlicingPipelineStepPlugin::psGCodePostProcess;
ctx.gcode_path = gpath.string();
ctx.host = "File";
ctx.output_name = "final.gcode";
ctx.full_config = &config; // print stays null
py::object pyctx = py::cast(&ctx, py::return_value_policy::reference);
CHECK(pyctx.attr("gcode_path").cast<std::string>() == gpath.string());
CHECK(pyctx.attr("host").cast<std::string>() == "File");
CHECK(pyctx.attr("output_name").cast<std::string>() == "final.gcode");
CHECK(pyctx.attr("print").is_none());
CHECK(pyctx.attr("object").is_none());
CHECK(pyctx.attr("step").cast<Slic3r::SlicingPipelineStepPlugin>()
== Slic3r::SlicingPipelineStepPlugin::psGCodePostProcess);
CHECK_FALSE(pyctx.attr("cancelled")().cast<bool>()); // null print -> not cancelled
// config_value() resolves from full_config when print is null; unknown keys are None.
CHECK_FALSE(pyctx.attr("config_value")("layer_height").is_none());
CHECK(pyctx.attr("config_value")("this_key_does_not_exist").is_none());
// A Python capability edits the file in place through ctx.gcode_path. Calling execute() directly
// in Python dispatches to the Python method (no C++ trampoline), so this needs no audit context.
py::module_ main = py::module_::import("__main__");
main.attr("_pp_ctx") = pyctx;
py::exec(R"(
import orca
class Stamp(orca.slicing.SlicingPipelineCapabilityBase):
def get_name(self): return "stamp"
def execute(self, ctx):
assert ctx.step == orca.slicing.Step.psGCodePostProcess
assert ctx.print is None and ctx.object is None
with open(ctx.gcode_path, "a") as f:
f.write("; stamped by " + ctx.host + "\n")
return orca.ExecutionResult.success("ok")
_pp_result = Stamp().execute(_pp_ctx)
)");
CHECK(main.attr("_pp_result").attr("message").cast<std::string>() == std::string("ok"));
std::string contents;
{
boost::nowide::ifstream ifs(gpath.string());
std::stringstream ss; ss << ifs.rdbuf(); contents = ss.str();
}
CHECK(contents.find("; stamped by File") != std::string::npos);
fs::remove(gpath);
}
// ---------------------------------------------------------------------------
// Toolpath helpers for the raw-graph tests.
//
// LayerRegion's ctor is protected (constructed only by Layer/PrintObject). A
// trivial derived struct lets a unit test build one with null layer/region
// pointers — the extrusion accessors only read the public `perimeters`/`fills`
// collections, never the layer/region back-pointers.
// ---------------------------------------------------------------------------
namespace {
struct TestLayerRegion : Slic3r::LayerRegion {
TestLayerRegion() : Slic3r::LayerRegion(nullptr, nullptr) {}
};
// Build a realistic nested perimeters collection into `region.perimeters`:
// perimeters (outer) -> inner collection -> [ ExtrusionLoop(pathA), ExtrusionPath(pathB) ]
// This exercises both the recursive descent through nested collections and the
// decomposition of an ExtrusionLoop into its contained ExtrusionPath (flatten()
// does NOT decompose loops, hence the hand-rolled recursive walk).
static void build_nested_perimeters(TestLayerRegion& region) {
using namespace Slic3r;
ExtrusionPath pathA(erExternalPerimeter); // -> "Outer wall"
pathA.mm3_per_mm = 0.05; pathA.width = 0.45f; pathA.height = 0.20f;
pathA.polyline.points = { Point3(0, 0, 0), Point3(10, 0, 0), Point3(10, 10, 0) };
ExtrusionPath pathB(erInternalInfill); // -> "Sparse infill"
pathB.mm3_per_mm = 0.03; pathB.width = 0.40f; pathB.height = 0.20f;
pathB.polyline.points = { Point3(1, 1, 0), Point3(2, 1, 0), Point3(2, 2, 0) };
ExtrusionEntityCollection inner;
inner.append(ExtrusionLoop(pathA)); // clone_move
inner.append(pathB); // clone
region.perimeters.append(inner); // nested (deep clone)
}
} // namespace
// ---------------------------------------------------------------------------
// Raw Print-graph data model (orca.host) — replaces the *View wrapper API.
// LIFETIME: raw bindings follow C++ semantics — references into the slicing
// graph are valid during execute(ctx) and invalidated by container-replacing
// mutators, exactly like std::vector iterators.
// ---------------------------------------------------------------------------
TEST_CASE("orca.host leaf geometry: Surface/ExPolygon/Polygon raw bindings", "[slicing_pipeline]") {
using Catch::Matchers::WithinRel;
using Catch::Matchers::WithinAbs;
ensure_python_initialized();
import_orca_module();
py::gil_scoped_acquire gil;
py::object host = py::module_::import("orca").attr("host");
for (const char* name : { "SurfaceType", "Polygon", "ExPolygon", "Surface", "SurfaceCollection" })
CHECK(py::hasattr(host, name));
// SurfaceType enum values round-trip to the C++ enumerators (moved from orca.slicing).
py::object ST = host.attr("SurfaceType");
CHECK(ST.attr("stTop").cast<Slic3r::SurfaceType>() == Slic3r::stTop);
CHECK(ST.attr("stInternalSolid").cast<Slic3r::SurfaceType>() == Slic3r::stInternalSolid);
CHECK(ST.attr("stPerimeter").cast<Slic3r::SurfaceType>() == Slic3r::stPerimeter);
// Raw Surface: scalar reads + WRITABLE surface_type (replaces SurfaceView.set_type).
Slic3r::Surface surf(Slic3r::stInternalSolid);
surf.thickness = 0.4;
surf.bridge_angle = -1.0;
surf.extra_perimeters = 2;
py::object sv = py::cast(&surf, py::return_value_policy::reference);
CHECK(sv.attr("surface_type").cast<Slic3r::SurfaceType>() == Slic3r::stInternalSolid);
CHECK_THAT(sv.attr("thickness").cast<double>(), WithinRel(0.4, 1e-9));
CHECK_THAT(sv.attr("bridge_angle").cast<double>(), WithinAbs(-1.0, 1e-12));
CHECK(sv.attr("extra_perimeters").cast<int>() == 2);
sv.attr("surface_type") = host.attr("SurfaceType").attr("stTop");
CHECK(surf.surface_type == Slic3r::stTop); // C++ side reflects the assignment
// ExPolygon navigation without numpy: contour is a Polygon, holes an empty list.
py::object exv = sv.attr("expolygon");
CHECK(py::hasattr(exv, "contour"));
CHECK(exv.attr("holes").cast<py::list>().size() == 0);
CHECK(exv.attr("contour").attr("size")().cast<size_t>() == 0);
}
TEST_CASE("orca.host Surface/SurfaceCollection: construct, writable members, set()", "[slicing_pipeline]") {
using Catch::Matchers::WithinRel;
ensure_python_initialized();
import_orca_module();
py::gil_scoped_acquire gil;
py::object host = py::module_::import("orca").attr("host");
py::object ST = host.attr("SurfaceType");
const coord_t s = (coord_t) scale_(10.0);
// Build an ExPolygon (Point idiom) and a Surface from it.
py::object P = host.attr("Polygon")();
P.attr("append")(host.attr("Point")(0, 0));
P.attr("append")(host.attr("Point")(s, 0));
P.attr("append")(host.attr("Point")(s, s));
P.attr("append")(host.attr("Point")(0, s));
py::object ex = host.attr("ExPolygon")(P);
py::object surf = host.attr("Surface")(ST.attr("stTop"), ex);
CHECK(surf.attr("surface_type").cast<Slic3r::SurfaceType>() == Slic3r::stTop);
CHECK(surf.attr("is_top")().cast<bool>());
CHECK_THAT(surf.attr("area")().cast<double>(), WithinRel((double) s * (double) s, 1e-9));
surf.attr("thickness") = py::float_(0.3);
CHECK_THAT(surf.attr("thickness").cast<double>(), WithinRel(0.3, 1e-9));
// SurfaceCollection.set(expolys, type): replace all surfaces from a list of ExPolygon tagged with one SurfaceType.
Slic3r::SurfaceCollection coll;
py::object cv = py::cast(&coll, py::return_value_policy::reference);
py::list expolys; expolys.append(ex);
cv.attr("set")(expolys, ST.attr("stInternalSolid"));
REQUIRE(coll.surfaces.size() == 1);
CHECK(coll.surfaces.front().surface_type == Slic3r::stInternalSolid);
CHECK(cv.attr("has")(ST.attr("stInternalSolid")).cast<bool>());
cv.attr("clear")();
CHECK(coll.surfaces.empty());
}
TEST_CASE("orca.host Point: construct, read/write coords, arithmetic", "[slicing_pipeline]") {
ensure_python_initialized();
import_orca_module();
py::gil_scoped_acquire gil;
py::object host = py::module_::import("orca").attr("host");
REQUIRE(py::hasattr(host, "Point"));
py::object p = host.attr("Point")(3, 4);
CHECK(p.attr("x").cast<coord_t>() == 3);
CHECK(p.attr("y").cast<coord_t>() == 4);
p.attr("x") = py::int_(7);
CHECK(p.attr("x").cast<coord_t>() == 7);
py::object q = host.attr("Point")(1, 2);
py::object sum = p.attr("__add__")(q);
CHECK(sum.attr("x").cast<coord_t>() == 8);
CHECK(sum.attr("y").cast<coord_t>() == 6);
// __mul__ must scale as a double, not truncate to int64 before multiplying.
py::object h = host.attr("Point")(10, 20).attr("__mul__")(py::float_(0.5));
CHECK(h.attr("x").cast<coord_t>() == 5);
CHECK(h.attr("y").cast<coord_t>() == 10);
}
TEST_CASE("orca.host Polygon: writable as_array aliases buffer; Point refs; set_points; offset", "[slicing_pipeline]") {
using Catch::Matchers::WithinRel;
ensure_python_initialized();
import_orca_module();
py::gil_scoped_acquire gil;
py::object host = py::module_::import("orca").attr("host");
const coord_t s = (coord_t) scale_(10.0);
Slic3r::Polygon poly;
poly.points = { Slic3r::Point(0, 0), Slic3r::Point(s, 0), Slic3r::Point(s, s), Slic3r::Point(0, s) };
py::object pv = py::cast(&poly, py::return_value_policy::reference);
// Non-array surface works without numpy.
CHECK(pv.attr("size")().cast<size_t>() == 4);
CHECK(pv.attr("is_counter_clockwise")().cast<bool>());
CHECK_THAT(pv.attr("area")().cast<double>(), WithinRel((double) s * (double) s, 1e-9));
// Point-object idiom: editing a returned Point ref mutates the buffer in place.
py::list pts = pv.attr("points").cast<py::list>();
REQUIRE(pts.size() == 4);
pts[0].attr("x") = py::int_(5);
CHECK(poly.points[0].x() == 5);
poly.points[0].x() = 0; // restore
// offset() returns new geometry (ClipperUtils bound as a method).
py::list shrunk = pv.attr("offset")(py::int_(-(coord_t)scale_(1.0))).cast<py::list>();
CHECK(shrunk.size() >= 1);
bool have_numpy = false;
try { py::module_::import("numpy"); have_numpy = true; }
catch (const py::error_already_set&) { have_numpy = false; }
if (!have_numpy) SKIP("numpy unavailable: array-backed assertions skipped");
py::module_ np = py::module_::import("numpy");
py::array a = pv.attr("as_array")().cast<py::array>();
CHECK(a.dtype().kind() == 'i');
CHECK(a.itemsize() == 8);
CHECK(a.shape(0) == 4);
CHECK(a.shape(1) == 2);
CHECK(a.writeable()); // writable now
a.attr("__setitem__")(py::make_tuple(0, 0), py::int_(123));
CHECK(poly.points[0].x() == 123); // in-place bulk edit
// set_points replaces contents (count-changing).
py::object i64 = np.attr("int64");
py::list rows;
rows.append(py::make_tuple(0, 0)); rows.append(py::make_tuple(s, 0)); rows.append(py::make_tuple(s, s));
pv.attr("set_points")(np.attr("array")(rows, py::arg("dtype") = i64));
CHECK(poly.points.size() == 3);
}
TEST_CASE("orca.host ExPolygon: construct, writable contour/holes, transforms, boolean ops", "[slicing_pipeline]") {
using Catch::Matchers::WithinRel;
ensure_python_initialized();
import_orca_module();
py::gil_scoped_acquire gil;
py::object host = py::module_::import("orca").attr("host");
const coord_t s = (coord_t) scale_(10.0);
// Construct from Polygon objects (Point idiom, no numpy).
py::object P = host.attr("Polygon")();
P.attr("append")(host.attr("Point")(0, 0));
P.attr("append")(host.attr("Point")(s, 0));
P.attr("append")(host.attr("Point")(s, s));
P.attr("append")(host.attr("Point")(0, s));
py::object ex = host.attr("ExPolygon")(P);
CHECK_THAT(ex.attr("area")().cast<double>(), WithinRel((double) s * (double) s, 1e-9));
CHECK(ex.attr("num_contours")().cast<size_t>() == 1);
CHECK(ex.attr("contour").attr("size")().cast<size_t>() == 4);
// In-place transform mutates the geometry.
ex.attr("translate")(py::float_(1000.0), py::float_(0.0));
// Boolean op returns new geometry: A minus a smaller inset of A is a non-empty ring set.
py::list inset = ex.attr("offset")(py::int_(-(coord_t)scale_(1.0))).cast<py::list>();
REQUIRE(inset.size() >= 1);
py::list ring = ex.attr("diff_ex")(inset[0]).cast<py::list>();
CHECK(ring.size() >= 1);
}
namespace {
// Nested collection: outer -> inner -> [ ExtrusionLoop(pathA), ExtrusionPath(pathB) ].
// Exercises polymorphic downcast of .entities and loop decomposition in flatten_paths().
static Slic3r::ExtrusionEntityCollection build_nested_collection() {
using namespace Slic3r;
ExtrusionPath pathA(erExternalPerimeter); // -> "Outer wall"
pathA.mm3_per_mm = 0.05; pathA.width = 0.45f; pathA.height = 0.20f;
pathA.polyline.points = { Point3(0, 0, 0), Point3(10, 0, 0), Point3(10, 10, 0) };
ExtrusionPath pathB(erInternalInfill); // -> "Sparse infill"
pathB.mm3_per_mm = 0.03; pathB.width = 0.40f; pathB.height = 0.20f;
pathB.polyline.points = { Point3(1, 1, 0), Point3(2, 1, 0), Point3(2, 2, 0) };
ExtrusionEntityCollection inner;
inner.append(ExtrusionLoop(pathA));
inner.append(pathB);
ExtrusionEntityCollection outer;
outer.append(inner);
return outer;
}
} // namespace
TEST_CASE("orca.host extrusion tree: polymorphic entities + flatten_paths", "[slicing_pipeline]") {
using Catch::Matchers::WithinRel;
ensure_python_initialized();
import_orca_module();
py::gil_scoped_acquire gil;
py::object host = py::module_::import("orca").attr("host");
for (const char* name : { "ExtrusionEntity", "ExtrusionPath", "ExtrusionLoop",
"ExtrusionMultiPath", "ExtrusionEntityCollection", "PrintRegion" })
CHECK(py::hasattr(host, name));
Slic3r::ExtrusionEntityCollection outer = build_nested_collection();
py::object coll = py::cast(&outer, py::return_value_policy::reference);
// .entities downcasts: the single child is a collection; ITS children are a loop + a path.
py::list kids = coll.attr("entities").cast<py::list>();
REQUIRE(kids.size() == 1);
py::list inner_kids = kids[0].attr("entities").cast<py::list>();
REQUIRE(inner_kids.size() == 2);
CHECK(py::hasattr(inner_kids[0], "paths")); // ExtrusionLoop binding
CHECK(py::hasattr(inner_kids[1], "width")); // ExtrusionPath binding
// flatten_paths: loop decomposed, scalars readable.
py::list ps = coll.attr("flatten_paths")().cast<py::list>();
REQUIRE(ps.size() == 2);
CHECK(ps[0].attr("role").cast<std::string>() == "Outer wall");
CHECK_THAT(ps[0].attr("width").cast<double>(), WithinRel(0.45, 1e-6));
CHECK_THAT(ps[0].attr("mm3_per_mm").cast<double>(), WithinRel(0.05, 1e-9));
CHECK(ps[1].attr("role").cast<std::string>() == "Sparse infill");
}
TEST_CASE("orca.host ExtrusionPath.points() is a read-only (N,3) int64 view", "[slicing_pipeline]") {
ensure_python_initialized();
import_orca_module();
py::gil_scoped_acquire gil;
bool have_numpy = false;
try { py::module_::import("numpy"); have_numpy = true; }
catch (const py::error_already_set&) { have_numpy = false; }
if (!have_numpy) SKIP("numpy unavailable in unit-test interpreter");
Slic3r::ExtrusionEntityCollection outer = build_nested_collection();
py::object coll = py::cast(&outer, py::return_value_policy::reference);
py::list ps = coll.attr("flatten_paths")().cast<py::list>();
REQUIRE(ps.size() == 2);
py::array pts = ps[1].attr("points")().cast<py::array>(); // pathB: (1,1,0),(2,1,0),(2,2,0)
CHECK(pts.dtype().kind() == 'i');
CHECK(pts.itemsize() == 8);
CHECK(pts.shape(0) == 3);
CHECK(pts.shape(1) == 3);
CHECK_FALSE(pts.writeable());
auto r = pts.cast<py::array_t<coord_t>>().unchecked<2>();
CHECK(r(0, 0) == 1); CHECK(r(1, 0) == 2); CHECK(r(2, 1) == 2);
}
// ---------------------------------------------------------------------------
// Raw Print-graph spine (orca.host): LayerRegion / Layer / PrintObject / Print,
// read side. LayerRegion/Layer ctors are protected (friend class PrintObject),
// so the tests use tiny derived structs -- the pattern TestLayerRegion above
// already establishes; TestLayer is its Layer counterpart.
// ---------------------------------------------------------------------------
namespace {
struct TestLayer : Slic3r::Layer {
// id=0, no owning PrintObject, height/print_z/slice_z suitable for assertions.
TestLayer() : Slic3r::Layer(0, nullptr, 0.2, 0.45, 0.35) {}
};
} // namespace
TEST_CASE("orca.host graph classes: LayerRegion/Layer raw traversal; Print/PrintObject registered", "[slicing_pipeline]") {
using Catch::Matchers::WithinRel;
ensure_python_initialized();
import_orca_module();
py::gil_scoped_acquire gil;
py::object host = py::module_::import("orca").attr("host");
for (const char* name : { "LayerRegion", "Layer", "PrintObject", "Print" })
CHECK(py::hasattr(host, name));
// Members needing a live Print are verified by registration only (slic3rutils
// cannot build a Print; the fff_print C++ suite covers live-graph behavior).
for (const char* name : { "layers", "support_layers", "model_object", "id",
"bounding_box", "trafo", "config_value", "config_keys" })
CHECK(py::hasattr(host.attr("PrintObject"), name));
for (const char* name : { "objects", "model", "config_value", "config_keys", "canceled" })
CHECK(py::hasattr(host.attr("Print"), name));
// Raw LayerRegion traversal over a hand-built region.
TestLayerRegion region;
region.slices.surfaces.emplace_back(Slic3r::Surface(Slic3r::stInternal));
build_nested_perimeters(region); // helper defined earlier in this file
py::object lr = py::cast(static_cast<Slic3r::LayerRegion*>(&region),
py::return_value_policy::reference);
CHECK(lr.attr("slices").attr("size")().cast<size_t>() == 1);
CHECK(lr.attr("slices").attr("surfaces").cast<py::list>().size() == 1);
CHECK(lr.attr("perimeters").attr("flatten_paths")().cast<py::list>().size() == 2);
CHECK(lr.attr("fills").attr("size")().cast<size_t>() == 0);
CHECK(lr.attr("layer")().is_none()); // hand-built region has no owning layer
// Raw Layer scalars + empty traversals on a hand-built layer.
TestLayer layer;
py::object ly = py::cast(static_cast<Slic3r::Layer*>(&layer),
py::return_value_policy::reference);
CHECK_THAT(ly.attr("print_z").cast<double>(), WithinRel(0.45, 1e-9));
CHECK_THAT(ly.attr("slice_z").cast<double>(), WithinRel(0.35, 1e-9));
CHECK_THAT(ly.attr("height").cast<double>(), WithinRel(0.2, 1e-9));
CHECK(ly.attr("regions")().cast<py::list>().size() == 0);
CHECK(ly.attr("lslices")().cast<py::list>().size() == 0);
CHECK(ly.attr("upper_layer").is_none());
CHECK(ly.attr("lower_layer").is_none());
}
TEST_CASE("orca.host: plugin-only mutators are gone; class-API editing works", "[slicing_pipeline]") {
ensure_python_initialized();
import_orca_module();
py::gil_scoped_acquire gil;
py::object host = py::module_::import("orca").attr("host");
// The three plugin-only mutators were removed in the raw-API realignment.
CHECK_FALSE(py::hasattr(host.attr("LayerRegion"), "set_slices"));
CHECK_FALSE(py::hasattr(host.attr("LayerRegion"), "set_fill_surfaces"));
CHECK_FALSE(py::hasattr(host.attr("Layer"), "set_lslices"));
// The faithful surface is present.
CHECK(py::hasattr(host.attr("SurfaceCollection"), "set"));
CHECK(py::hasattr(host.attr("Layer"), "make_slices"));
// clear() via the collection on a hand-built region (null owning layer is null-safe).
TestLayerRegion region;
region.slices.surfaces.emplace_back(Slic3r::Surface(Slic3r::stInternal));
py::object lr = py::cast(static_cast<Slic3r::LayerRegion*>(&region), py::return_value_policy::reference);
lr.attr("slices").attr("clear")();
CHECK(region.slices.surfaces.empty());
}
TEST_CASE("orca.host: SurfaceCollection.set mutates geometry; lslices via make_slices", "[slicing_pipeline]") {
using Catch::Matchers::WithinRel;
ensure_python_initialized();
import_orca_module();
py::gil_scoped_acquire gil;
bool have_numpy = false;
try { py::module_::import("numpy"); have_numpy = true; }
catch (const py::error_already_set&) { have_numpy = false; }
if (!have_numpy) SKIP("numpy unavailable in unit-test interpreter");
py::object host = py::module_::import("orca").attr("host");
py::module_ np = py::module_::import("numpy");
py::object i64 = np.attr("int64");
py::object ST = host.attr("SurfaceType");
const coord_t s = (coord_t) scale_(10.0);
auto arr = [&](std::initializer_list<std::pair<coord_t,coord_t>> pts) {
py::list rows; for (auto& p : pts) rows.append(py::make_tuple(p.first, p.second));
return np.attr("array")(rows, py::arg("dtype") = i64);
};
// Build an ExPolygon from a CW ndarray; the ctor normalizes to CCW.
py::object ex = host.attr("ExPolygon")(arr({ {0,0}, {0,s}, {s,s}, {s,0} }));
CHECK(ex.attr("contour").attr("is_counter_clockwise")().cast<bool>());
TestLayerRegion region;
py::object lr = py::cast(static_cast<Slic3r::LayerRegion*>(&region), py::return_value_policy::reference);
py::list expolys; expolys.append(ex);
lr.attr("slices").attr("set")(expolys, ST.attr("stInternalSolid"));
REQUIRE(region.slices.surfaces.size() == 1);
const Slic3r::Surface& out = region.slices.surfaces.front();
CHECK(out.surface_type == Slic3r::stInternalSolid);
CHECK_THAT(out.expolygon.area(), WithinRel((double) s * (double) s, 1e-9));
// Read geometry back through the class API.
py::array c = lr.attr("slices").attr("surfaces").cast<py::list>()[0]
.attr("expolygon").attr("contour").attr("as_array")().cast<py::array>();
CHECK(c.shape(0) == 4);
// lslices are derived: make_slices() re-derives them + refreshes the bbox cache.
TestLayer layer;
py::object ly = py::cast(static_cast<Slic3r::Layer*>(&layer), py::return_value_policy::reference);
// (A hand-built layer has no regions, so make_slices() yields empty lslices — still null-safe.)
ly.attr("make_slices")();
CHECK(layer.lslices_bboxes.size() == layer.lslices.size());
}
TEST_CASE("orca.host ExPolygon in-place transforms + SurfaceCollection.append (sample ops)", "[slicing_pipeline]") {
using Catch::Matchers::WithinRel;
ensure_python_initialized();
import_orca_module();
py::gil_scoped_acquire gil;
py::object host = py::module_::import("orca").attr("host");
const coord_t s = (coord_t) scale_(10.0);
auto make_square = [&]() {
py::object P = host.attr("Polygon")();
P.attr("append")(host.attr("Point")(0, 0));
P.attr("append")(host.attr("Point")(s, 0));
P.attr("append")(host.attr("Point")(s, s));
P.attr("append")(host.attr("Point")(0, s));
return host.attr("ExPolygon")(P);
};
const double area0 = (double) s * (double) s;
// rotate about the square's center preserves area
py::object ex = make_square();
py::object center = host.attr("Point")(s / 2, s / 2);
ex.attr("rotate")(py::float_(1.5707963267948966), center); // pi/2
CHECK_THAT(ex.attr("area")().cast<double>(), WithinRel(area0, 1e-6));
// uniform scale by 2 quadruples area (scale is about the origin)
py::object ex2 = make_square();
ex2.attr("scale")(py::float_(2.0));
CHECK_THAT(ex2.attr("area")().cast<double>(), WithinRel(4.0 * area0, 1e-6));
// translate preserves area
py::object ex3 = make_square();
ex3.attr("translate")(py::float_(1000.0), py::float_(-500.0));
CHECK_THAT(ex3.attr("area")().cast<double>(), WithinRel(area0, 1e-6));
// SurfaceCollection.append accumulates surfaces of a second type (the sample write-back path)
Slic3r::SurfaceCollection coll;
py::object cv = py::cast(&coll, py::return_value_policy::reference);
py::list g1; g1.append(make_square());
cv.attr("set")(g1, host.attr("SurfaceType").attr("stInternalSolid"));
py::list g2; g2.append(make_square());
cv.attr("append")(g2, host.attr("SurfaceType").attr("stTop"));
REQUIRE(coll.surfaces.size() == 2);
CHECK(coll.surfaces[0].surface_type == Slic3r::stInternalSolid);
CHECK(coll.surfaces[1].surface_type == Slic3r::stTop);
}
TEST_CASE("orca.host: in-place edit of surface.expolygon through a live collection persists to C++", "[slicing_pipeline]") {
using Catch::Matchers::WithinRel;
ensure_python_initialized();
import_orca_module();
py::gil_scoped_acquire gil;
const coord_t s = (coord_t) scale_(10.0);
// Live LayerRegion holding one surface (a 10mm square at the origin).
TestLayerRegion region;
Slic3r::ExPolygon sq;
sq.contour.points = { Slic3r::Point(0, 0), Slic3r::Point(s, 0),
Slic3r::Point(s, s), Slic3r::Point(0, s) };
region.slices.surfaces.emplace_back(Slic3r::Surface(Slic3r::stInternal, sq));
py::object lr = py::cast(static_cast<Slic3r::LayerRegion*>(&region),
py::return_value_policy::reference);
// Twistify's path: get the Surface through the live collection, mutate its expolygon in place.
py::object surf = lr.attr("slices").attr("surfaces").cast<py::list>()[0];
surf.attr("expolygon").attr("translate")(py::float_(1000.0), py::float_(0.0));
// The C++-side surface geometry reflects the Python in-place edit (proves the live ref).
const Slic3r::Surface& out = region.slices.surfaces.front();
CHECK(out.expolygon.contour.points[0].x() == 1000); // was 0
CHECK(out.expolygon.contour.points[0].y() == 0);
CHECK_THAT(out.expolygon.area(), WithinRel((double) s * (double) s, 1e-9)); // translate preserves area
}