feat(plugin): expose the slicing print-graph as raw orca.host classes + Twistify sample

Adds PluginHostSlicing, which registers the print-graph data model (Print,
PrintObject, Layer, LayerRegion, Surface, ExPolygon, extrusions, ...) into the
orca.host submodule in the same raw-class style as PluginHostApi's Model/Preset
graph, with shared helpers in PluginBindingUtils. SlicingPipelinePluginCapability
is trimmed to the capability surface (the standalone SlicingNumpy helper is folded
away). Adds the Twistify example plugin next to Inset and broadens the binding,
hook, and plugin-install tests.
This commit is contained in:
SoftFever
2026-07-08 00:05:28 +08:00
parent aafcccc83c
commit f81a24abfb
29 changed files with 1718 additions and 962 deletions

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

@@ -11,7 +11,7 @@ TEST_CASE("SlicingPipeline capability-type string maps round-trip", "[slicing_pi
}
#include "python_test_support.hpp"
#include "slic3r/plugin/pluginTypes/slicingPipeline/SlicingNumpy.hpp"
#include "slic3r/plugin/PluginBindingUtils.hpp"
#include "slic3r/plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.hpp"
#include "libslic3r/Point.hpp"
#include "libslic3r/ExPolygon.hpp"
@@ -76,124 +76,45 @@ class Probe(orca.slicing.SlicingPipelineCapabilityBase):
def execute(self, ctx): return orca.ExecutionResult.success("ok")
_probe = Probe()
)");
// (Full C++ trampoline invocation with a real context is exercised in Task 8's tests.)
// (Full C++ trampoline invocation with a real context is exercised elsewhere.)
}
// Numpy-free half of Task 8: type registration, the SurfaceType enum, the module-level
// unscale() helper, and every non-array read accessor (surface_type / thickness /
// bridge_angle / extra_perimeters / expolygon / empty holes()). None of these
// materialize a py::array, so they run unconditionally (no numpy guard needed).
TEST_CASE("orca.slicing geometry views: types, SurfaceType, unscale, non-array accessors", "[slicing_pipeline]") {
TEST_CASE("orca.slicing is workflow-only: context exposes raw print/object; view classes are gone", "[slicing_pipeline]") {
using Catch::Matchers::WithinRel;
using Catch::Matchers::WithinAbs;
ensure_python_initialized();
import_orca_module();
py::gil_scoped_acquire gil;
py::object slicing = py::module_::import("orca").attr("slicing");
py::module_ orca = py::module_::import("orca");
py::object slicing = orca.attr("slicing");
// All view types are registered in the submodule.
for (const char* name : { "ExPolygonView", "SurfaceView", "LayerRegionView",
"LayerView", "PrintObjectView", "SurfaceType" })
CHECK(py::hasattr(slicing, name));
// 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));
// Read-graph traversal methods exist on the class objects (verified without a
// full Print, which slic3rutils cannot build).
CHECK(py::hasattr(slicing.attr("ExPolygonView"), "contour"));
CHECK(py::hasattr(slicing.attr("ExPolygonView"), "holes"));
CHECK(py::hasattr(slicing.attr("LayerRegionView"), "slices"));
CHECK(py::hasattr(slicing.attr("LayerRegionView"), "fill_surfaces"));
CHECK(py::hasattr(slicing.attr("LayerView"), "regions"));
CHECK(py::hasattr(slicing.attr("LayerView"), "lslices"));
CHECK(py::hasattr(slicing.attr("PrintObjectView"), "layers"));
CHECK(py::hasattr(slicing.attr("SlicingPipelineContext"), "object"));
// The wrapper layer is gone.
for (const char* legacy : { "ExPolygonView", "SurfaceView", "LayerRegionView",
"LayerView", "PrintObjectView", "PathData", "SurfaceType" })
CHECK_FALSE(py::hasattr(slicing, legacy));
// SurfaceType enum values round-trip to the C++ enumerators.
py::object ST = slicing.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);
CHECK(ST.attr("stCount").cast<Slic3r::SurfaceType>() == Slic3r::stCount);
// unscale() reads the live SCALING_FACTOR both when scaling and unscaling.
// 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));
// SurfaceView non-array accessors against a hand-built Surface.
Slic3r::Surface surf(Slic3r::stInternalSolid);
surf.thickness = 0.4;
surf.bridge_angle = -1.0;
surf.extra_perimeters = 2;
py::capsule owner(&surf, [](void*){}); // no-op owner (data outlives the view here)
py::object sv = py::cast(Slic3r::SurfaceView{ &surf, owner });
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);
// expolygon accessor yields an ExPolygonView; holes() on an empty ExPolygon is an
// empty list and materializes no array (so it stays outside the numpy guard).
py::object exv = sv.attr("expolygon");
CHECK(py::hasattr(exv, "contour"));
CHECK(exv.attr("holes")().cast<py::list>().size() == 0);
}
TEST_CASE("ExPolygonView.contour()/holes() are read-only int64 (N,2) views in scaled coords", "[slicing_pipeline]") {
ensure_python_initialized();
import_orca_module();
py::gil_scoped_acquire gil;
// make_readonly_rows() constructs a py::array, which needs numpy at runtime; the
// unit-test interpreter ships none. Skip the array-backed assertions when numpy is
// unavailable (same convention as the make_readonly_rows test above).
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");
}
const coord_t s = (coord_t) scale_(10.0);
Slic3r::ExPolygon ex;
ex.contour.points = { Slic3r::Point(0, 0), Slic3r::Point(s, 0),
Slic3r::Point(s, s), Slic3r::Point(0, s) };
Slic3r::Polygon hole;
hole.points = { Slic3r::Point(1, 1), Slic3r::Point(2, 1), Slic3r::Point(2, 2) };
ex.holes = { hole };
py::capsule owner(&ex, [](void*){});
py::object view = py::cast(Slic3r::ExPolygonView{ &ex, owner });
py::array c = view.attr("contour")().cast<py::array>();
CHECK(c.dtype().kind() == 'i');
CHECK(c.itemsize() == 8); // int64
CHECK(c.shape(0) == 4);
CHECK(c.shape(1) == 2);
CHECK_FALSE(c.writeable());
auto rc = c.cast<py::array_t<coord_t>>().unchecked<2>();
CHECK(rc(0, 0) == 0);
CHECK(rc(1, 0) == s);
CHECK(rc(2, 1) == s);
// holes() -> list of read-only (N,2) int64 views.
py::list holes = view.attr("holes")().cast<py::list>();
CHECK(holes.size() == 1);
py::array h0 = holes[0].cast<py::array>();
CHECK(h0.shape(0) == 3);
CHECK(h0.shape(1) == 2);
CHECK_FALSE(h0.writeable());
// 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());
}
// ---------------------------------------------------------------------------
// Task 9: toolpaths (PathData over perimeters/fills).
// 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 — perimeters()/fills() only read the public `perimeters`/`fills`
// pointers — the extrusion accessors only read the public `perimeters`/`fills`
// collections, never the layer/region back-pointers.
// ---------------------------------------------------------------------------
namespace {
@@ -223,221 +144,314 @@ static void build_nested_perimeters(TestLayerRegion& region) {
}
} // namespace
// Numpy-free half: perimeters() flattens the nested graph (descending through
// collections and decomposing loops) into a [PathData] list; role/width/height/
// mm3_per_mm are plain scalars, so these assertions run unconditionally.
TEST_CASE("orca.slicing LayerRegionView.perimeters()/fills(): PathData scalars over a nested graph", "[slicing_pipeline]") {
// ---------------------------------------------------------------------------
// 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 Polygon.points() is a read-only int64 (N,2) view in scaled coords", "[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");
const coord_t s = (coord_t) scale_(10.0);
Slic3r::ExPolygon ex;
ex.contour.points = { Slic3r::Point(0, 0), Slic3r::Point(s, 0),
Slic3r::Point(s, s), Slic3r::Point(0, s) };
Slic3r::Polygon hole;
hole.points = { Slic3r::Point(1, 1), Slic3r::Point(2, 1), Slic3r::Point(2, 2) };
ex.holes = { hole };
py::object view = py::cast(&ex, py::return_value_policy::reference);
py::array c = view.attr("contour").attr("points")().cast<py::array>();
CHECK(c.dtype().kind() == 'i');
CHECK(c.itemsize() == 8); // int64
CHECK(c.shape(0) == 4);
CHECK(c.shape(1) == 2);
CHECK_FALSE(c.writeable());
auto rc = c.cast<py::array_t<coord_t>>().unchecked<2>();
CHECK(rc(0, 0) == 0);
CHECK(rc(1, 0) == s);
CHECK(rc(2, 1) == s);
py::list holes = view.attr("holes").cast<py::list>();
REQUIRE(holes.size() == 1);
py::array h0 = holes[0].attr("points")().cast<py::array>();
CHECK(h0.shape(0) == 3);
CHECK_FALSE(h0.writeable());
}
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 slicing = py::module_::import("orca").attr("slicing");
py::object host = py::module_::import("orca").attr("host");
for (const char* name : { "ExtrusionEntity", "ExtrusionPath", "ExtrusionLoop",
"ExtrusionMultiPath", "ExtrusionEntityCollection", "PrintRegion" })
CHECK(py::hasattr(host, name));
CHECK(py::hasattr(slicing, "PathData"));
CHECK(py::hasattr(slicing.attr("LayerRegionView"), "perimeters"));
CHECK(py::hasattr(slicing.attr("LayerRegionView"), "fills"));
Slic3r::ExtrusionEntityCollection outer = build_nested_collection();
py::object coll = py::cast(&outer, py::return_value_policy::reference);
TestLayerRegion region;
build_nested_perimeters(region);
py::capsule owner(&region, [](void*){}); // no-op: region outlives the view
py::object lrv = py::cast(Slic3r::LayerRegionView{ &region, owner });
// .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
py::list ps = lrv.attr("perimeters")().cast<py::list>();
REQUIRE(ps.size() == 2); // loop's path + bare path
py::object pd0 = ps[0]; // pathA, from the loop
CHECK(pd0.attr("role").cast<std::string>() == "Outer wall");
CHECK_THAT(pd0.attr("width").cast<double>(), WithinRel(0.45, 1e-6));
CHECK_THAT(pd0.attr("height").cast<double>(), WithinRel(0.20, 1e-6));
CHECK_THAT(pd0.attr("mm3_per_mm").cast<double>(), WithinRel(0.05, 1e-9));
py::object pd1 = ps[1]; // pathB, bare
CHECK(pd1.attr("role").cast<std::string>() == "Sparse infill");
CHECK_THAT(pd1.attr("width").cast<double>(), WithinRel(0.40, 1e-6));
// fills is empty on this hand-built region.
CHECK(lrv.attr("fills")().cast<py::list>().size() == 0);
// 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");
}
// Numpy-backed half: PathData.points() materializes a read-only (N,3) int64 view.
TEST_CASE("orca.slicing PathData.points() is a read-only (N,3) int64 view", "[slicing_pipeline]") {
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;
// make_readonly_rows() needs numpy at runtime; the unit-test interpreter ships
// none. Skip the array-backed assertions when numpy is unavailable (same
// convention as the make_readonly_rows / ExPolygonView tests above).
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");
}
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");
TestLayerRegion region;
build_nested_perimeters(region);
py::capsule owner(&region, [](void*){});
py::object lrv = py::cast(Slic3r::LayerRegionView{ &region, owner });
py::list ps = lrv.attr("perimeters")().cast<py::list>();
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);
// pathB has 3 points: (1,1,0), (2,1,0), (2,2,0).
py::array pts = ps[1].attr("points")().cast<py::array>();
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); // int64
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(0, 1) == 1); CHECK(r(0, 2) == 0);
CHECK(r(1, 0) == 2);
CHECK(r(2, 1) == 2);
CHECK(r(0, 0) == 1); CHECK(r(1, 0) == 2); CHECK(r(2, 1) == 2);
}
// ---------------------------------------------------------------------------
// Task 11: 2D-geometry mutators (set_slices / set_fill_surfaces / set_lslices / set_type).
//
// Numpy-free half: the four mutators are registered, set_type reclassifies a surface
// end-to-end (read back from C++), and the input validators raise ValueError on garbage.
// None of this materializes a py::array, so it runs unconditionally.
// 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.
// ---------------------------------------------------------------------------
TEST_CASE("orca.slicing mutators: registration, set_type reclassify, and ValueError on garbage", "[slicing_pipeline]") {
ensure_python_initialized();
import_orca_module();
py::gil_scoped_acquire gil;
py::object slicing = py::module_::import("orca").attr("slicing");
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
// All four mutators are registered on their view classes.
CHECK(py::hasattr(slicing.attr("LayerRegionView"), "set_slices"));
CHECK(py::hasattr(slicing.attr("LayerRegionView"), "set_fill_surfaces"));
CHECK(py::hasattr(slicing.attr("LayerView"), "set_lslices"));
CHECK(py::hasattr(slicing.attr("SurfaceView"), "set_type"));
// set_type reclassifies a surface in place (reassigns surface_type; geometry untouched).
TestLayerRegion region;
region.slices.surfaces.emplace_back(Slic3r::Surface(Slic3r::stInternal));
py::capsule owner(&region, [](void*){}); // no-op: region outlives the view
py::object lrv = py::cast(Slic3r::LayerRegionView{ &region, owner });
py::list sl = lrv.attr("slices")().cast<py::list>();
REQUIRE(sl.size() == 1);
py::object sv = sl[0];
CHECK(sv.attr("surface_type").cast<Slic3r::SurfaceType>() == Slic3r::stInternal);
sv.attr("set_type")(py::cast(Slic3r::stTop)); // reclassify -> stTop
CHECK(region.slices.surfaces.front().surface_type == Slic3r::stTop); // C++ side reflects it
CHECK(sv.attr("surface_type").cast<Slic3r::SurfaceType>() == Slic3r::stTop); // and via the view
// Malformed inputs raise ValueError (pybind-translated), never corrupt geometry. These
// paths are rejected before any numpy array is materialized, so they need no numpy guard.
auto raises_value_error = [](py::object callable, py::object arg) {
try { callable(arg); return false; }
catch (py::error_already_set& e) { return e.matches(PyExc_ValueError); }
};
CHECK(raises_value_error(lrv.attr("set_slices"), py::list())); // empty list
CHECK(raises_value_error(lrv.attr("set_slices"), py::int_(42))); // not a sequence
CHECK(raises_value_error(lrv.attr("set_slices"), py::str("nope"))); // string rejected
// set_slices is guaranteed to have left the original single surface untouched on failure.
CHECK(region.slices.surfaces.size() == 1);
}
// Numpy-backed half: set_slices with real (N,2) int64 ndarrays replaces the region's
// surfaces, carries surface_type forward from the replaced surfaces, normalizes orientation
// (a CW contour becomes CCW), and the change is visible both from C++ and back through the view.
TEST_CASE("orca.slicing set_slices: ndarray input mutates the slice geometry (read back both ways)", "[slicing_pipeline]") {
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");
// set_slices parses (N,2) int64 ndarrays, which requires numpy in the embedded
// interpreter; the unit-test interpreter ships none, so skip the array-backed
// assertions when numpy is unavailable (same convention as the read-view tests above).
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 mutators: registration, ValueError on garbage, empty-clears", "[slicing_pipeline]") {
ensure_python_initialized();
import_orca_module();
py::gil_scoped_acquire gil;
py::object host = py::module_::import("orca").attr("host");
CHECK(py::hasattr(host.attr("LayerRegion"), "set_slices"));
CHECK(py::hasattr(host.attr("LayerRegion"), "set_fill_surfaces"));
CHECK(py::hasattr(host.attr("Layer"), "set_lslices"));
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);
auto raises_value_error = [](py::object callable, py::object arg) {
try { callable(arg); return false; }
catch (py::error_already_set& e) { return e.matches(PyExc_ValueError); }
};
CHECK(raises_value_error(lr.attr("set_slices"), py::int_(42))); // not a sequence
CHECK(raises_value_error(lr.attr("set_slices"), py::str("nope"))); // string rejected
CHECK(region.slices.surfaces.size() == 1); // failures mutate nothing
// G7: an empty list is legal and clears the region (refresh_lslices defaults True;
// the null owning-layer on this hand-built region exercises the null guard).
lr.attr("set_slices")(py::list());
CHECK(region.slices.surfaces.empty());
}
TEST_CASE("orca.host set_slices/set_lslices: ndarray input mutates geometry (read back both ways)", "[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");
}
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");
const coord_t s = (coord_t) scale_(10.0);
// Seed one stInternalSolid surface so surface_type carry-forward is observable.
TestLayerRegion region;
region.slices.surfaces.emplace_back(Slic3r::Surface(Slic3r::stInternalSolid));
py::capsule owner(&region, [](void*){});
py::object lrv = py::cast(Slic3r::LayerRegionView{ &region, owner });
// A CW square contour (points wound clockwise) -> the mutator must re-orient it CCW.
auto make_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);
};
// set_slices: CW input normalized CCW; surface_type carried forward; readable back raw.
TestLayerRegion region;
region.slices.surfaces.emplace_back(Slic3r::Surface(Slic3r::stInternalSolid));
py::object lr = py::cast(static_cast<Slic3r::LayerRegion*>(&region),
py::return_value_policy::reference);
py::list polys;
polys.append(make_arr({ {0,0}, {0,s}, {s,s}, {s,0} })); // clockwise winding
lrv.attr("set_slices")(polys);
// C++ side reflects the replacement.
lr.attr("set_slices")(polys);
REQUIRE(region.slices.surfaces.size() == 1);
const Slic3r::Surface& out = region.slices.surfaces.front();
CHECK(out.surface_type == Slic3r::stInternalSolid); // carried forward from the template
REQUIRE(out.expolygon.contour.points.size() == 4);
CHECK(out.expolygon.contour.is_counter_clockwise()); // orientation normalized (input was CW)
CHECK_THAT(out.expolygon.area(), WithinRel((double) s * (double) s, 1e-9)); // s x s square
// Read back through the view: slices()[0].expolygon.contour() is a (4,2) array.
py::list sl = lrv.attr("slices")().cast<py::list>();
CHECK(out.surface_type == Slic3r::stInternalSolid);
CHECK(out.expolygon.contour.is_counter_clockwise());
CHECK_THAT(out.expolygon.area(), WithinRel((double) s * (double) s, 1e-9));
py::list sl = lr.attr("slices").attr("surfaces").cast<py::list>();
REQUIRE(sl.size() == 1);
py::array c = sl[0].attr("expolygon").attr("contour")().cast<py::array>();
py::array c = sl[0].attr("expolygon").attr("contour").attr("points")().cast<py::array>();
CHECK(c.shape(0) == 4);
CHECK(c.shape(1) == 2);
// [contour, [holes...]] form: a hole is accepted and normalized to CW.
TestLayerRegion region2;
py::capsule owner2(&region2, [](void*){});
py::object lrv2 = py::cast(Slic3r::LayerRegionView{ &region2, owner2 });
py::list contour_and_holes;
contour_and_holes.append(make_arr({ {0,0}, {s,0}, {s,s}, {0,s} })); // CCW contour
py::list holes;
holes.append(make_arr({ {s/4,s/4}, {s/2,s/4}, {s/2,s/2} })); // CCW hole -> must flip CW
contour_and_holes.append(holes);
py::list polys2;
polys2.append(contour_and_holes);
lrv2.attr("set_slices")(polys2);
// G9: per-entry SurfaceType override via [contour, holes, SurfaceType] triple.
py::list entry;
entry.append(make_arr({ {0,0}, {s,0}, {s,s}, {0,s} }));
entry.append(py::list());
entry.append(host.attr("SurfaceType").attr("stTop"));
py::list polys2; polys2.append(entry);
lr.attr("set_slices")(polys2, py::bool_(false)); // refresh_lslices=False path
REQUIRE(region.slices.surfaces.size() == 1);
CHECK(region.slices.surfaces.front().surface_type == Slic3r::stTop);
REQUIRE(region2.slices.surfaces.size() == 1);
const Slic3r::ExPolygon& ex = region2.slices.surfaces.front().expolygon;
CHECK(ex.contour.is_counter_clockwise());
REQUIRE(ex.holes.size() == 1);
CHECK(ex.holes.front().is_clockwise()); // hole re-oriented CW
CHECK(region2.slices.surfaces.front().surface_type == Slic3r::stInternal); // default (no template)
// Negative: a valid contour paired with a non-list holes slot must raise ValueError.
// (Regression guard for a malformed holes slot; the retired view-layer suite covered
// this, and the raw layer needs a numpy-built valid contour to exercise the same path.)
{
py::list bad_entry;
bad_entry.append(make_arr({ {0,0}, {s,0}, {s,s}, {0,s} })); // valid contour
bad_entry.append(py::int_(42)); // holes slot is not a list
py::list bad_polys; bad_polys.append(bad_entry);
bool raised = false;
try { lr.attr("set_slices")(bad_polys); }
catch (py::error_already_set& e) { raised = e.matches(PyExc_ValueError); }
CHECK(raised);
}
// Fix 6: a malformed holes element (a [contour, holes] entry whose holes slot is not a
// sequence, e.g. an int) must raise ValueError, not a bare Python TypeError from iterating a
// non-iterable. This lives in the numpy-guarded section because reaching the holes check
// requires a real ndarray contour as the first element.
auto raises_value_error = [](py::object callable, py::object arg) {
try { callable(arg); return false; }
catch (py::error_already_set& e) { return e.matches(PyExc_ValueError); }
};
py::list bad_entry;
bad_entry.append(make_arr({ {0,0}, {s,0}, {s,s}, {0,s} })); // valid CCW contour
bad_entry.append(py::int_(42)); // holes slot is an int -> invalid
py::list bad_polys;
bad_polys.append(bad_entry);
CHECK(raises_value_error(lrv2.attr("set_slices"), bad_polys));
// The failed call left the previously-set single surface untouched.
CHECK(region2.slices.surfaces.size() == 1);
// Layer.set_lslices round-trip on a hand-built layer (empty regions -> null-safe).
TestLayer layer;
py::object ly = py::cast(static_cast<Slic3r::Layer*>(&layer),
py::return_value_policy::reference);
py::list islands;
islands.append(make_arr({ {0,0}, {s,0}, {s,s}, {0,s} }));
ly.attr("set_lslices")(islands);
REQUIRE(layer.lslices.size() == 1);
CHECK(layer.lslices.front().contour.is_counter_clockwise());
REQUIRE(layer.lslices_bboxes.size() == 1); // bbox cache refreshed
CHECK(ly.attr("lslices")().cast<py::list>().size() == 1);
}