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

@@ -0,0 +1,89 @@
#pragma once
#include <pybind11/pybind11.h>
#include <pybind11/numpy.h>
#include "libslic3r/Config.hpp" // ConfigBase
#include "libslic3r/Point.hpp" // Point/Point3 packing asserts, Vec3d, Transform3d
#include <string>
#include <utility>
#include <vector>
namespace Slic3r {
// Point/Point3 must be tightly packed for zero-copy views. coord_t = int64_t.
static_assert(sizeof(Point) == 2 * sizeof(coord_t), "Point must be 2 packed coord_t");
static_assert(sizeof(Point3) == 3 * sizeof(coord_t), "Point3 must be 3 packed coord_t");
// Run a builder that constructs numpy objects, translating the "numpy missing"
// ImportError into an actionable message (plugins must declare numpy as a dep).
template<typename Builder>
pybind11::object with_numpy(Builder&& build)
{
namespace py = pybind11;
try {
return std::forward<Builder>(build)();
} catch (py::error_already_set& err) {
if (err.matches(PyExc_ImportError))
throw py::import_error("numpy is required to access geometry/mesh arrays; "
"add dependencies = [\"numpy\"] to your plugin metadata");
throw;
}
}
// Zero-copy, read-only (rows, N) numpy view over `data`, whose lifetime is tied
// to `base` (the array's base object). T is the element scalar (coord_t = int64
// for slicing coords, float for mesh vertices). rows == 0 / null data yields a
// fresh empty (0, N) array with no base.
template<typename T, int N>
pybind11::array make_readonly_rows(pybind11::handle base, const T* data, pybind11::ssize_t rows)
{
namespace py = pybind11;
if (rows == 0 || data == nullptr) {
py::array_t<T> empty(std::vector<py::ssize_t>{ 0, (py::ssize_t) N });
// Keep behavior-preserving: the pre-refactor helper returned read-only
// arrays on every path, so mark the fresh empty array read-only too.
empty.attr("setflags")(py::arg("write") = false);
return std::move(empty);
}
py::array_t<T> arr(
{ rows, (py::ssize_t) N },
{ (py::ssize_t)(N * sizeof(T)), (py::ssize_t) sizeof(T) },
data, base);
// A base-carrying array is writable by default in pybind11; force read-only.
arr.attr("setflags")(py::arg("write") = false);
return std::move(arr);
}
// Serialize one config key to a Python string, or None if the key is absent.
// Works on any ConfigBase (resolved DynamicPrintConfig snapshots,
// PrintObjectConfig, PrintRegionConfig, preset configs).
inline pybind11::object config_value_or_none(const ConfigBase& config, const std::string& key)
{
if (!config.has(key))
return pybind11::none();
return pybind11::cast(config.opt_serialize(key));
}
// Plugins receive 3D vectors as plain Python tuples (x, y, z) so the API stays
// Pythonic and free of an Eigen/numpy runtime dependency.
inline pybind11::tuple vec3_to_tuple(const Vec3d& v)
{
return pybind11::make_tuple(v.x(), v.y(), v.z());
}
// 4x4 row-major float64 copy of an affine transform. Eigen stores column-major,
// so fill element-wise to produce correct C-order data. Requires numpy.
inline pybind11::object mat4_to_numpy(const Transform3d& transform)
{
namespace py = pybind11;
return with_numpy([&] {
py::array_t<double> array({ py::ssize_t(4), py::ssize_t(4) });
auto view = array.mutable_unchecked<2>();
const auto& matrix = transform.matrix();
for (int i = 0; i < 4; ++i)
for (int j = 0; j < 4; ++j)
view(i, j) = matrix(i, j);
return py::object(std::move(array));
});
}
} // namespace Slic3r

View File

@@ -4,6 +4,7 @@
#include <algorithm>
#include <cctype>
#include <map>
#include <optional>
#include <string>
#include <utility>
@@ -61,6 +62,7 @@ struct PluginDescriptor
std::string entry_path; // Full path to the installed plugin entry file
std::string entry_package; // Import package/module used for package-based loading
std::vector<std::string> dependencies; // Python dependency requirements declared by plugin package metadata
std::map<std::string, std::string> settings; // [tool.orcaslicer.plugin.settings] table -> per-plugin params (ctx.params)
std::vector<PluginChangelog> changelog; // Cloud release changelog, sorted newest-first when available.
std::string error; // Blocking error message. Non-empty means the plugin is in an error state.

View File

@@ -1,5 +1,7 @@
#include "PluginHostApi.hpp"
#include "PluginHostUi.hpp"
#include "PluginHostSlicing.hpp"
#include "PluginBindingUtils.hpp"
#include <libslic3r/BoundingBox.hpp>
#include <libslic3r/Model.hpp>
@@ -46,20 +48,6 @@ PresetBundle* current_preset_bundle()
return preset_bundle;
}
py::object config_value_or_none(const DynamicPrintConfig& config, const std::string& key)
{
if (!config.has(key))
return py::none();
return py::cast(config.opt_serialize(key));
}
// Plugins receive 3D vectors as plain Python tuples (x, y, z) so the API stays
// Pythonic and free of an Eigen/numpy runtime dependency.
py::tuple vec3_to_tuple(const Vec3d& v)
{
return py::make_tuple(v.x(), v.y(), v.z());
}
// Build a BoundingBoxf3 from precomputed (float) triangle-mesh stats min/max.
BoundingBoxf3 bbox_from_stats(const TriangleMeshStats& stats)
{
@@ -86,59 +74,20 @@ struct HostTriangleMesh
const indexed_triangle_set& its() const { return mesh->its; }
};
// Run a builder that constructs numpy objects, translating the "numpy missing"
// ImportError into an actionable message (plugins must declare numpy as a dep).
template<typename Builder>
py::object with_numpy(Builder&& build)
{
try {
return std::forward<Builder>(build)();
} catch (py::error_already_set& err) {
if (err.matches(PyExc_ImportError))
throw py::import_error("numpy is required to access mesh arrays/matrices; "
"add dependencies = [\"numpy\"] to your plugin metadata");
throw;
}
}
// Read-only, zero-copy (rows, 3) numpy view over a packed T[rows][3] buffer.
// The array owns a capsule that pins `mesh` alive for the view's lifetime.
// The array's base is a capsule owning a strong ref to `mesh`, so the view
// stays valid even if the volume's mesh is later replaced on the main thread.
template<typename T>
py::array make_readonly_rows3(const std::shared_ptr<const TriangleMesh>& mesh,
const T* data, py::ssize_t rows)
{
if (rows == 0 || data == nullptr)
return py::array_t<T>(std::vector<py::ssize_t>{0, 3});
return py::array_t<T>(std::vector<py::ssize_t>{ 0, 3 });
auto* owner = new std::shared_ptr<const TriangleMesh>(mesh);
py::capsule base(owner, [](void* p) {
delete reinterpret_cast<std::shared_ptr<const TriangleMesh>*>(p);
});
py::array_t<T> array(
{ rows, py::ssize_t(3) },
{ py::ssize_t(3 * sizeof(T)), py::ssize_t(sizeof(T)) },
data,
base);
// A capsule-based array is writable by default in pybind11; the underlying
// mesh is const, so force the view read-only.
array.attr("setflags")(py::arg("write") = false);
return array;
}
// 4x4 row-major float64 copy of an affine transform. Eigen stores column-major,
// so fill element-wise to produce correct C-order data.
py::object mat4_to_numpy(const Transform3d& transform)
{
return with_numpy([&] {
py::array_t<double> array({ py::ssize_t(4), py::ssize_t(4) });
auto view = array.mutable_unchecked<2>();
const auto& matrix = transform.matrix();
for (int i = 0; i < 4; ++i)
for (int j = 0; j < 4; ++j)
view(i, j) = matrix(i, j);
return py::object(std::move(array));
});
return make_readonly_rows<T, 3>(base, data, rows);
}
py::list current_filament_presets(PresetBundle& bundle)
@@ -530,6 +479,9 @@ void PluginHostApi::RegisterBindings(pybind11::module_& module)
// UI: native dialogs and interactive HTML windows for plugins.
PluginHostUi::RegisterBindings(host);
// Slicing print-graph data model (Print, Layer, Surface, ...).
PluginHostSlicing::RegisterBindings(host);
}
} // namespace Slic3r

View File

@@ -0,0 +1,512 @@
#include "PluginHostSlicing.hpp"
#include "PluginBindingUtils.hpp"
#include "libslic3r/libslic3r.h" // unscale<>, scale_
#include "libslic3r/BoundingBox.hpp"
#include "libslic3r/ExPolygon.hpp"
#include "libslic3r/Surface.hpp"
#include "libslic3r/SurfaceCollection.hpp"
#include "libslic3r/ExtrusionEntity.hpp"
#include "libslic3r/ExtrusionEntityCollection.hpp"
#include "libslic3r/Layer.hpp" // LayerRegion, Layer, SupportLayer
#include "libslic3r/Print.hpp" // PrintRegion, PrintObject, Print
#include <pybind11/stl.h>
#include <memory>
#include <optional>
#include <vector>
namespace py = pybind11;
namespace Slic3r {
namespace {
// --- Input path: Python geometry -> C++ ExPolygon/Surface, with validation. ---------------
// The mutators take scaled integer coords (the same units the read views hand out). A Python
// raise here surfaces as ValueError (pybind translates) so malformed input is rejected up
// front rather than silently corrupting the slicing graph.
// One (N,2) int64 ndarray -> Polygon. Rejects wrong dtype/shape and degenerate (<3 pt) rings.
// Float / NaN / inf are rejected implicitly: only a signed-integer, 8-byte (coord_t==int64)
// dtype is accepted, and integer arrays cannot hold NaN/inf.
static Polygon parse_polygon(py::handle h, const char* who)
{
if (!py::isinstance<py::array>(h))
throw py::value_error(std::string(who) + ": each contour/hole must be an (N,2) int64 ndarray");
py::array a = py::reinterpret_borrow<py::array>(h);
if (a.dtype().kind() != 'i' || a.itemsize() != (py::ssize_t) sizeof(coord_t))
throw py::value_error(std::string(who) + ": polygon coordinates must be int64 (scaled coords)");
if (a.ndim() != 2 || a.shape(1) != 2)
throw py::value_error(std::string(who) + ": each polygon array must have shape (N,2)");
if (a.shape(0) < 3)
throw py::value_error(std::string(who) + ": a polygon needs at least 3 points");
// dtype already validated as int64; forcecast here only guarantees a C-contiguous buffer.
auto arr = py::array_t<coord_t, py::array::c_style | py::array::forcecast>::ensure(a);
if (!arr)
throw py::value_error(std::string(who) + ": could not read polygon as a contiguous int64 array");
auto r = arr.unchecked<2>();
Polygon poly;
poly.points.reserve((size_t) arr.shape(0));
for (py::ssize_t i = 0; i < arr.shape(0); ++i)
poly.points.emplace_back((coord_t) r(i, 0), (coord_t) r(i, 1));
return poly;
}
// One Python entry -> ExPolygon. Accepts a bare (N,2) ndarray (contour only), a
// [contour, [hole, ...]] sequence, or (G9) a [contour, [hole, ...], SurfaceType] triple whose
// third element overrides the surface type for set_slices/set_fill_surfaces. When `out_type` is
// null (geometry-only consumers such as set_lslices) any third element is ignored. Orientation
// is normalized (contour CCW, holes CW) so downstream area/offset math is correct regardless of
// the caller's winding.
static ExPolygon parse_expolygon(py::handle entry, const char* who,
std::optional<SurfaceType>* out_type = nullptr)
{
ExPolygon ex;
if (py::isinstance<py::array>(entry)) {
ex.contour = parse_polygon(entry, who);
} else if (py::isinstance<py::sequence>(entry) && !py::isinstance<py::str>(entry)) {
py::sequence seq = py::reinterpret_borrow<py::sequence>(entry);
if (py::len(seq) < 1)
throw py::value_error(std::string(who) + ": a [contour, holes] entry needs a contour");
ex.contour = parse_polygon(seq[0], who);
if (py::len(seq) >= 2) {
// Type-check the holes element up front: a non-sequence (e.g. an int) would otherwise
// reach reinterpret_borrow<py::sequence> and raise a bare Python TypeError on iteration,
// whereas the API contract is ValueError for malformed input (str is excluded because it
// is iterable but never a valid holes container).
py::object holes_obj = seq[1];
if (!py::isinstance<py::sequence>(holes_obj) || py::isinstance<py::str>(holes_obj))
throw py::value_error(std::string(who) + ": the holes element must be a list of (N,2) int64 ndarrays");
for (py::handle hh : py::reinterpret_borrow<py::sequence>(holes_obj)) {
Polygon hole = parse_polygon(hh, who);
hole.make_clockwise();
ex.holes.emplace_back(std::move(hole));
}
}
// G9: optional third element -> per-surface SurfaceType override (None keeps the
// carried-forward type). A wrong type raises ValueError, matching the API contract.
if (out_type != nullptr && py::len(seq) >= 3) {
py::object t = seq[2];
if (!t.is_none()) {
try { *out_type = t.cast<SurfaceType>(); }
catch (const py::cast_error&) {
throw py::value_error(std::string(who) + ": the third entry element must be an orca.host.SurfaceType");
}
}
}
} else {
throw py::value_error(std::string(who) + ": each entry must be an (N,2) ndarray or a [contour, holes] pair");
}
ex.contour.make_counter_clockwise();
return ex;
}
// A Python list of entries -> ExPolygons (each entry parsed + oriented). G7: an empty list is
// legal and means "no geometry" (clears the target collection). Per-entry types are ignored
// here (geometry-only consumers such as set_lslices).
static ExPolygons parse_expolygon_list(py::handle list_h, const char* who)
{
if (!py::isinstance<py::sequence>(list_h) || py::isinstance<py::str>(list_h))
throw py::value_error(std::string(who) + ": expected a list of polygons");
ExPolygons out;
for (py::handle entry : py::reinterpret_borrow<py::sequence>(list_h))
out.emplace_back(parse_expolygon(entry, who));
return out;
}
// Build Surfaces from a Python list, carrying surface_type (and the other per-surface
// attributes) forward from the collection being replaced, or defaulting to stInternal when the
// region had none. G9: a per-entry SurfaceType (optional third element) overrides that default.
// G7: an empty list is legal and yields an empty Surfaces (clears the collection).
static Surfaces surfaces_from_py(py::handle list_h, const SurfaceCollection& replaced, const char* who)
{
if (!py::isinstance<py::sequence>(list_h) || py::isinstance<py::str>(list_h))
throw py::value_error(std::string(who) + ": expected a list of polygons");
const Surface tmpl = replaced.surfaces.empty() ? Surface(stInternal) : replaced.surfaces.front();
Surfaces out;
for (py::handle entry : py::reinterpret_borrow<py::sequence>(list_h)) {
std::optional<SurfaceType> type;
ExPolygon e = parse_expolygon(entry, who, &type);
Surface s(tmpl, std::move(e));
if (type)
s.surface_type = *type;
out.emplace_back(std::move(s));
}
return out;
}
// Flatten an extrusion graph into a list of leaf ExtrusionPath* while walking the
// ORIGINAL Print-owned tree (never a temporary copy): the returned pointers stay
// valid for the execute(ctx) lifetime pinned by `owner`, so points() can hand out
// zero-copy views into path->polyline.points.
//
// This is deliberately NOT ExtrusionEntityCollection::flatten(): flatten() only
// unwraps nested collections (is_collection() is true solely for collections) and
// returns them by value, so it would (a) dangle if we viewed into the copy and
// (b) leave ExtrusionLoop/ExtrusionMultiPath intact — dropping every perimeter
// loop, since dynamic_cast<ExtrusionPath*> fails on a loop. We descend into
// loops/multipaths here to reach their contained paths.
static void collect_extrusion_paths(const ExtrusionEntity* ee, std::vector<const ExtrusionPath*>& out)
{
if (ee == nullptr)
return;
if (const auto* coll = dynamic_cast<const ExtrusionEntityCollection*>(ee)) {
for (const ExtrusionEntity* child : coll->entities)
collect_extrusion_paths(child, out);
} else if (const auto* loop = dynamic_cast<const ExtrusionLoop*>(ee)) {
for (const ExtrusionPath& p : loop->paths)
out.push_back(&p);
} else if (const auto* mp = dynamic_cast<const ExtrusionMultiPath*>(ee)) {
for (const ExtrusionPath& p : mp->paths)
out.push_back(&p);
} else if (const auto* path = dynamic_cast<const ExtrusionPath*>(ee)) {
// Catches ExtrusionPath and its subclasses (Sloped/Contoured/Oriented) last,
// after the composite types above have been ruled out.
out.push_back(path);
}
}
} // namespace
void PluginHostSlicing::RegisterBindings(py::module_& host)
{
// ------------------------------------------------------------------
// Slicing print-graph data model — raw bindings of the classes the C++
// pipeline itself uses, same nodelete/reference style as the Model and
// Preset graphs above.
//
// LIFETIME (C++ semantics, the one rule of this API): every object handed
// out below is a non-owning reference into the live slicing graph owned by
// the Print. References — and every numpy view they hand out — are valid
// only while the plugin hook (execute(ctx)) runs, and a container-replacing
// mutator (LayerRegion.set_slices / set_fill_surfaces, Layer.set_lslices)
// invalidates previously obtained references into that container, exactly
// as std::vector operations invalidate C++ iterators. Do not stash
// references or arrays across execute() calls; copy what you need.
// ------------------------------------------------------------------
py::enum_<SurfaceType>(host, "SurfaceType")
.value("stTop", stTop)
.value("stBottom", stBottom)
.value("stBottomBridge", stBottomBridge)
.value("stInternalAfterExternalBridge", stInternalAfterExternalBridge)
.value("stInternal", stInternal)
.value("stInternalSolid", stInternalSolid)
.value("stInternalBridge", stInternalBridge)
.value("stSecondInternalBridge", stSecondInternalBridge)
.value("stInternalVoid", stInternalVoid)
.value("stPerimeter", stPerimeter)
.value("stCount", stCount)
.export_values();
py::class_<Polygon, std::unique_ptr<Polygon, py::nodelete>>(host, "Polygon")
.def("size", [](const Polygon& p) { return p.points.size(); })
.def("is_counter_clockwise", [](const Polygon& p) { return p.is_counter_clockwise(); })
.def("points", [](py::object self) {
const Polygon& p = self.cast<const Polygon&>();
return with_numpy([&] {
return py::object(make_readonly_rows<coord_t, 2>(
self, p.points.empty() ? nullptr : p.points.front().data(),
(py::ssize_t) p.points.size()));
});
}, "Vertices as a read-only int64 (N,2) numpy view in scaled coords. "
"Valid only during the execute(ctx) call. Requires numpy.");
py::class_<ExPolygon, std::unique_ptr<ExPolygon, py::nodelete>>(host, "ExPolygon")
.def_property_readonly("contour", [](ExPolygon& e) -> Polygon& { return e.contour; },
py::return_value_policy::reference_internal,
"Outer contour (CCW) as a Polygon.")
.def_property_readonly("holes", [](py::object self) {
ExPolygon& e = self.cast<ExPolygon&>();
py::list out;
for (Polygon& h : e.holes)
out.append(py::cast(&h, py::return_value_policy::reference_internal, self));
return out;
}, "Hole contours (CW) as [Polygon].");
py::class_<Surface, std::unique_ptr<Surface, py::nodelete>>(host, "Surface")
.def_readwrite("surface_type", &Surface::surface_type,
"This surface's SurfaceType. Writable: assigning reclassifies the "
"surface in place on the live slicing graph (geometry unchanged).")
.def_readonly("thickness", &Surface::thickness)
.def_readonly("bridge_angle", &Surface::bridge_angle)
.def_readonly("extra_perimeters", &Surface::extra_perimeters)
.def_property_readonly("expolygon", [](Surface& s) -> ExPolygon& { return s.expolygon; },
py::return_value_policy::reference_internal,
"This surface's geometry.");
py::class_<SurfaceCollection, std::unique_ptr<SurfaceCollection, py::nodelete>>(host, "SurfaceCollection")
.def("size", [](const SurfaceCollection& c) { return c.surfaces.size(); })
.def_property_readonly("surfaces", [](py::object self) {
SurfaceCollection& c = self.cast<SurfaceCollection&>();
py::list out;
for (Surface& s : c.surfaces)
out.append(py::cast(&s, py::return_value_policy::reference_internal, self));
return out;
}, "Surfaces as [Surface] references into the live collection. Invalidated "
"by set_slices/set_fill_surfaces on the owning region (C++ vector semantics).");
// --- Extrusion tree (read-only in v1). Registered polymorphically: when a returned
// ExtrusionEntity*'s dynamic type IS one of the classes registered below, pybind
// hands the plugin that concrete type, so plugins walk the same tree shape C++ does.
// When the dynamic type is NOT registered (e.g. ExtrusionLoopSloped, produced with
// scarf seams), pybind falls back to the STATIC type at the cast site -- so such a
// `.entities` child surfaces as a bare ExtrusionEntity (only .role is available).
// flatten_paths() (a dynamic_cast walk) still yields proper ExtrusionPath leaves and
// is the robust way to extract toolpaths.
py::class_<ExtrusionEntity, std::unique_ptr<ExtrusionEntity, py::nodelete>>(host, "ExtrusionEntity")
.def_property_readonly("role", [](const ExtrusionEntity& e) {
return ExtrusionEntity::role_to_string(e.role());
}, "Extrusion role as a human-readable string (e.g. \"Outer wall\", \"Sparse infill\").");
py::class_<ExtrusionPath, ExtrusionEntity, std::unique_ptr<ExtrusionPath, py::nodelete>>(host, "ExtrusionPath")
.def("points", [](py::object self) {
const ExtrusionPath& p = self.cast<const ExtrusionPath&>();
const Points3& pts = p.polyline.points;
return with_numpy([&] {
return py::object(make_readonly_rows<coord_t, 3>(
self, pts.empty() ? nullptr : pts.front().data(), (py::ssize_t) pts.size()));
});
}, "Path vertices as a read-only int64 (N,3) numpy view in scaled coords "
"(the polyline is natively 3D on this branch). Requires numpy.")
.def_readonly("width", &ExtrusionPath::width)
.def_readonly("height", &ExtrusionPath::height)
.def_readonly("mm3_per_mm", &ExtrusionPath::mm3_per_mm);
py::class_<ExtrusionLoop, ExtrusionEntity, std::unique_ptr<ExtrusionLoop, py::nodelete>>(host, "ExtrusionLoop")
.def_property_readonly("paths", [](py::object self) {
ExtrusionLoop& l = self.cast<ExtrusionLoop&>();
py::list out;
for (ExtrusionPath& p : l.paths)
out.append(py::cast(&p, py::return_value_policy::reference_internal, self));
return out;
}, "The loop's constituent paths as [ExtrusionPath].");
py::class_<ExtrusionMultiPath, ExtrusionEntity, std::unique_ptr<ExtrusionMultiPath, py::nodelete>>(host, "ExtrusionMultiPath")
.def_property_readonly("paths", [](py::object self) {
ExtrusionMultiPath& m = self.cast<ExtrusionMultiPath&>();
py::list out;
for (ExtrusionPath& p : m.paths)
out.append(py::cast(&p, py::return_value_policy::reference_internal, self));
return out;
}, "The multipath's constituent paths as [ExtrusionPath].");
py::class_<ExtrusionEntityCollection, ExtrusionEntity,
std::unique_ptr<ExtrusionEntityCollection, py::nodelete>>(host, "ExtrusionEntityCollection")
.def("size", [](const ExtrusionEntityCollection& c) { return c.entities.size(); })
.def_property_readonly("entities", [](py::object self) {
ExtrusionEntityCollection& c = self.cast<ExtrusionEntityCollection&>();
py::list out;
for (ExtrusionEntity* e : c.entities)
out.append(py::cast(e, py::return_value_policy::reference_internal, self));
return out;
}, "Child entities. Each is handed to you as its concrete type only when that type "
"is registered; a child whose concrete type is unregistered (e.g. a scarf-seam "
"ExtrusionLoopSloped) surfaces as a bare ExtrusionEntity exposing only .role. Use "
"flatten_paths() to robustly reach every ExtrusionPath leaf.")
.def("flatten_paths", [](py::object self) {
const ExtrusionEntityCollection& c = self.cast<const ExtrusionEntityCollection&>();
std::vector<const ExtrusionPath*> paths;
collect_extrusion_paths(&c, paths);
py::list out;
for (const ExtrusionPath* p : paths)
out.append(py::cast(const_cast<ExtrusionPath*>(p),
py::return_value_policy::reference_internal, self));
return out;
}, "Every leaf ExtrusionPath under this tree (collections recursed into, "
"loops/multipaths decomposed).");
py::class_<PrintRegion, std::unique_ptr<PrintRegion, py::nodelete>>(host, "PrintRegion")
.def("config_keys", [](const PrintRegion& r) { return r.config().keys(); })
.def("config_value", [](const PrintRegion& r, const std::string& key) {
return config_value_or_none(r.config(), key);
}, py::arg("key"),
"Serialized value of this region's resolved config option, or None if absent.");
auto layer_region = py::class_<LayerRegion, std::unique_ptr<LayerRegion, py::nodelete>>(host, "LayerRegion");
layer_region
.def_readonly("slices", &LayerRegion::slices,
"Sliced, typed surfaces (SurfaceCollection). At Step.Slice this is the "
"primary mutation target via set_slices().")
.def_readonly("fill_surfaces", &LayerRegion::fill_surfaces,
"Surfaces prepared for infill (SurfaceCollection).")
.def_readonly("perimeters", &LayerRegion::perimeters,
"Perimeter toolpaths (ExtrusionEntityCollection).")
.def_readonly("fills", &LayerRegion::fills,
"Infill toolpaths (ExtrusionEntityCollection).")
.def("layer", [](LayerRegion& r) -> py::object {
Layer* l = r.layer();
if (l == nullptr)
return py::none();
return py::cast(l, py::return_value_policy::reference);
}, "Owning Layer, or None.")
.def("region", [](LayerRegion& r) -> const PrintRegion& { return r.region(); },
py::return_value_policy::reference,
"This region's PrintRegion (resolved per-region settings).")
.def("config_value", [](const LayerRegion& r, const std::string& key) {
return config_value_or_none(r.region().config(), key);
}, py::arg("key"),
"Serialized value of this region's resolved config option, or None if absent.")
// MUTATOR (G1/G3/G9). Replace this region's sliced surfaces. `polygons` is a list of
// (N,2) int64 ndarrays (scaled coords), [contour, [holes...]] pairs, or (G9)
// [contour, [holes...], SurfaceType] triples; orientation is normalized (contour CCW,
// holes CW) and surface_type is carried forward from the replaced surfaces (else
// stInternal) unless a per-entry type is given.
.def("set_slices", [](LayerRegion& region, py::object polygons, bool refresh_lslices) {
region.slices.set(surfaces_from_py(polygons, region.slices, "set_slices"));
// G1: rebuild the owning layer's merged islands (lslices) + bbox cache from the
// mutated region slices so downstream consumers (detect_surfaces_type neighbor
// diffs, overhang/bridge detection, brim/skirt/support) see coherent islands.
// Skipped when the region has no owning layer (unit-test regions).
if (refresh_lslices) {
if (Layer* layer = region.layer()) {
layer->make_slices();
layer->lslices_bboxes.clear();
layer->lslices_bboxes.reserve(layer->lslices.size());
for (const ExPolygon& island : layer->lslices)
layer->lslices_bboxes.emplace_back(get_extents(island));
}
}
}, py::arg("polygons"), py::arg("refresh_lslices") = true,
"Replace this region's sliced surfaces from a list of (N,2) int64 ndarrays (scaled "
"coords), [contour, [holes...]] pairs, or [contour, [holes...], SurfaceType] triples "
"(orientation normalized: contour CCW / holes CW; surface_type carried forward from the "
"replaced surfaces, else stInternal, unless a per-entry SurfaceType is supplied). An "
"empty list clears this region's slices.\n"
"MUTATION-CASCADE: at the Slice boundary this is the primary, fully-supported entry "
"point -- the split slice loop runs make_perimeters() afterward, so the change cascades "
"into perimeters and everything downstream (final G-code).\n"
"LSLICES (G1): refresh_lslices=True (default) re-derives the owning layer's merged "
"islands and bbox cache from the new slices so overhang/bridge/skirt/support stay "
"coherent; pass False only if you manage lslices yourself via Layer.set_lslices.\n"
"PERSISTENCE (G3): the Slice hook re-snapshots raw_slices after it returns, so the "
"mutation survives a later perimeter-only re-run (restore_untyped_slices) instead of "
"silently reverting; it still does not persist across a full re-slice unless the hook "
"re-fires (re-select the plugin, or any posSlice-invalidating change).\n"
"DUPLICATES: identical objects share Layer*, so the mutation on the object that slices "
"is automatically seen by its duplicates; objects that must mutate independently must "
"not be identical.\n"
"Raises ValueError on malformed input. Valid only during the execute(ctx) call.")
// MUTATOR. Replace this region's fill (infill-prep) surfaces; identical input format and
// validation to set_slices.
.def("set_fill_surfaces", [](LayerRegion& region, py::object polygons) {
region.fill_surfaces.set(surfaces_from_py(polygons, region.fill_surfaces, "set_fill_surfaces"));
}, py::arg("polygons"),
"Replace this region's fill (infill-prep) surfaces; same input format/validation as "
"set_slices (per-entry SurfaceType supported; an empty list clears them).\n"
"MUTATION-CASCADE: at the PrepareInfill boundary (G4) make_fills runs afterward, so this "
"cascades into the generated infill. At the Infill boundary it changes the stored "
"surfaces but does NOT regenerate the already-built `fills` toolpaths (v1).\n"
"Raises ValueError on malformed input. Valid only during the execute(ctx) call.");
auto layer = py::class_<Layer, std::unique_ptr<Layer, py::nodelete>>(host, "Layer");
layer
.def_readonly("print_z", &Layer::print_z)
.def_readonly("slice_z", &Layer::slice_z)
.def_readonly("height", &Layer::height)
.def_property_readonly("upper_layer", [](Layer& l) -> py::object {
if (l.upper_layer == nullptr) return py::none();
return py::cast(l.upper_layer, py::return_value_policy::reference);
}, "The layer above, or None (graph navigation, like C++).")
.def_property_readonly("lower_layer", [](Layer& l) -> py::object {
if (l.lower_layer == nullptr) return py::none();
return py::cast(l.lower_layer, py::return_value_policy::reference);
}, "The layer below, or None.")
.def("regions", [](py::object self) {
Layer& l = self.cast<Layer&>();
py::list out;
for (LayerRegion* r : l.regions())
out.append(py::cast(r, py::return_value_policy::reference_internal, self));
return out;
}, "Per-region data as [LayerRegion].")
.def("lslices", [](py::object self) {
Layer& l = self.cast<Layer&>();
py::list out;
for (ExPolygon& e : l.lslices)
out.append(py::cast(&e, py::return_value_policy::reference_internal, self));
return out;
}, "Merged per-layer islands as [ExPolygon] references. Invalidated by "
"set_lslices/make_slices (C++ vector semantics).")
.def("make_slices", [](Layer& l) {
l.make_slices();
l.lslices_bboxes.clear();
l.lslices_bboxes.reserve(l.lslices.size());
for (const ExPolygon& island : l.lslices)
l.lslices_bboxes.emplace_back(get_extents(island));
}, "Re-derive lslices (merged islands) from the region slices and refresh the "
"bbox cache — the C++ invariant-maintenance call after in-place geometry edits. "
"set_slices(refresh_lslices=True) runs this for you.")
// MUTATOR. Replace this layer's merged islands (lslices) and refresh the cache-invariant
// `lslices_bboxes` (one BoundingBox per island via get_extents). Same input format and
// validation as LayerRegion.set_slices.
.def("set_lslices", [](Layer& l, py::object islands) {
l.lslices = parse_expolygon_list(islands, "set_lslices");
l.lslices_bboxes.clear();
l.lslices_bboxes.reserve(l.lslices.size());
for (const ExPolygon& island : l.lslices)
l.lslices_bboxes.emplace_back(get_extents(island));
}, py::arg("islands"),
"Replace this layer's merged islands (lslices) from a list of (N,2) int64 ndarrays "
"(scaled coords) or [contour, [holes...]] pairs, and refresh lslices_bboxes (one "
"bounding box per island via get_extents) so the bbox cache stays consistent. Same "
"input format/validation as LayerRegion.set_slices. Raises ValueError on malformed "
"input. Valid only during the execute(ctx) call.");
py::class_<PrintObject, std::unique_ptr<PrintObject, py::nodelete>>(host, "PrintObject")
.def("id", [](const PrintObject& o) { return o.id().id; },
"Stable numeric object id (ObjectBase::id()).")
.def("layers", [](py::object self) {
PrintObject& o = self.cast<PrintObject&>();
py::list out;
for (Layer* l : o.layers())
out.append(py::cast(l, py::return_value_policy::reference_internal, self));
return out;
}, "Object layers, bottom-up, as [Layer].")
.def("support_layers", [](py::object self) {
PrintObject& o = self.cast<PrintObject&>();
py::list out;
for (SupportLayer* sl : o.support_layers())
out.append(py::cast(static_cast<Layer*>(sl),
py::return_value_policy::reference_internal, self));
return out;
}, "Support layers as [Layer] (support-specific fields are not exposed in v1).")
.def("model_object", [](PrintObject& o) -> py::object {
// The Print's model SNAPSHOT (worker-thread stable), reusing the
// orca.host.ModelObject bindings — mesh access for slicing plugins.
// o is non-const here, so model_object() already returns a non-const ModelObject*.
return py::cast(o.model_object(), py::return_value_policy::reference);
}, "The source orca.host.ModelObject from the Print's own model snapshot.")
.def("bounding_box", [](const PrintObject& o) {
const BoundingBox bb = o.bounding_box();
return py::make_tuple(bb.min.x(), bb.min.y(), bb.max.x(), bb.max.y());
}, "Object XY bounding box in scaled coords as (min_x, min_y, max_x, max_y). The "
"sliced polygons live in this same frame, so its midpoint is the footprint center.")
.def("trafo", [](const PrintObject& o) { return mat4_to_numpy(o.trafo()); },
"Object-to-print 4x4 float64 affine matrix (copy). Requires numpy.")
.def("config_keys", [](const PrintObject& o) { return o.config().keys(); })
.def("config_value", [](const PrintObject& o, const std::string& key) {
return config_value_or_none(o.config(), key);
}, py::arg("key"),
"Serialized value of a resolved per-object config option, or None if absent.");
py::class_<Print, std::unique_ptr<Print, py::nodelete>>(host, "Print")
.def("objects", [](py::object self) {
Print& p = self.cast<Print&>();
py::list out;
for (PrintObject* o : p.objects())
out.append(py::cast(o, py::return_value_policy::reference_internal, self));
return out;
}, "The print's objects as [PrintObject].")
.def("model", [](Print& p) -> Model& { return const_cast<Model&>(p.model()); },
py::return_value_policy::reference_internal,
"The Print's own Model snapshot (worker-thread stable). Inside slicing "
"hooks use THIS — never orca.host.model(), which is the live GUI model "
"owned by another thread.")
.def("config_keys", [](const Print& p) { return p.full_print_config().keys(); })
.def("config_value", [](const Print& p, const std::string& key) {
return config_value_or_none(p.full_print_config(), key);
}, py::arg("key"),
"Serialized value of the resolved (full) print config for this slice, or None.")
.def("canceled", [](const Print& p) { return p.canceled(); },
"True once cancellation was requested (prefer ctx.cancelled()).");
}
} // namespace Slic3r

View File

@@ -0,0 +1,16 @@
#pragma once
#include <pybind11/pybind11.h>
namespace Slic3r {
// Registers the slicing 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. Called from PluginHostApi::RegisterBindings.
class PluginHostSlicing
{
public:
static void RegisterBindings(pybind11::module_& host);
};
} // namespace Slic3r

View File

@@ -261,6 +261,13 @@ std::shared_ptr<LoadedPluginCapability> PluginLoader::get_plugin_capability_by_n
return nullptr;
}
std::map<std::string, std::string> PluginLoader::get_plugin_settings(const std::string& plugin_key) const
{
std::lock_guard<std::mutex> lock(m_mutex);
const auto it = m_plugins.find(plugin_key);
return it != m_plugins.end() ? it->second.descriptor.settings : std::map<std::string, std::string>{};
}
std::vector<std::shared_ptr<LoadedPluginCapability>> PluginLoader::get_loaded_plugin_capabilities(const std::string& plugin_key) const
{
std::lock_guard<std::mutex> lock(m_mutex);

View File

@@ -104,6 +104,8 @@ public:
std::chrono::milliseconds timeout,
std::string& error) const;
std::vector<PluginDescriptor> get_all_loaded_plugin_descriptors() const;
// the plugin's [tool.orcaslicer.plugin.settings] table (empty if the plugin is unknown).
std::map<std::string, std::string> get_plugin_settings(const std::string& plugin_key) const;
// Package descriptor accessor; returns nullptr when the package is not loaded.

View File

@@ -35,9 +35,9 @@ std::string find_option_for_capability(Preset::Type type, const Preset& preset,
if (type != Preset::TYPE_PRINT && type != Preset::TYPE_PRINTER && type != Preset::TYPE_FILAMENT)
return {};
// Plugin-bearing options opt in via ConfigOptionDef::support_plugin, so scan the preset's
// definition rather than maintaining a hardcoded per-type field list. A typed preset's config
// only contains keys for its own type, so this naturally stays scoped to `type`.
// Plugin-bearing options opt in via ConfigOptionDef::is_plugin_backed (a non-empty plugin_type),
// so scan the preset's definition rather than maintaining a hardcoded per-type field list. A typed
// preset's config only contains keys for its own type, so this naturally stays scoped to `type`.
const ConfigDef* def = preset.config.def();
if (def == nullptr)
return {};
@@ -48,7 +48,7 @@ std::string find_option_for_capability(Preset::Type type, const Preset& preset,
for (const std::string& field : preset.config.keys()) {
const ConfigOptionDef* opt_def = def->get(field);
if (opt_def == nullptr || !opt_def->support_plugin)
if (opt_def == nullptr || !opt_def->is_plugin_backed())
continue;
const ConfigOption* option = preset.config.option(field);

View File

@@ -128,7 +128,7 @@ bool read_zip_text_file(mz_zip_archive& archive, const char* filename, std::stri
}
// TOML section parsing states.
enum class TomlSection { Root, OrcaPlugin, InDepsArray };
enum class TomlSection { Root, OrcaPlugin, OrcaPluginSettings, InDepsArray };
// Strip a quoted string value: "foo" → foo, 'foo' → foo.
// Returns the unquoted value or the input unchanged if not quoted.
@@ -187,6 +187,7 @@ bool parse_pep723_toml(const std::string& toml_content,
std::string& out_description,
std::string& out_author,
std::string& out_version,
std::map<std::string, std::string>& out_settings,
std::string& error)
{
out_deps.clear();
@@ -195,6 +196,7 @@ bool parse_pep723_toml(const std::string& toml_content,
out_description.clear();
out_author.clear();
out_version.clear();
out_settings.clear();
TomlSection section = TomlSection::Root;
@@ -218,6 +220,8 @@ bool parse_pep723_toml(const std::string& toml_content,
if (trimmed[0] == '[') {
if (trimmed == "[tool.orcaslicer.plugin]") {
section = TomlSection::OrcaPlugin;
} else if (trimmed == "[tool.orcaslicer.plugin.settings]") {
section = TomlSection::OrcaPluginSettings; // per-plugin params table
} else {
section = TomlSection::Root; // Unknown section — skip.
}
@@ -270,6 +274,10 @@ bool parse_pep723_toml(const std::string& toml_content,
else if (key == "description") out_description = unquote_toml_string(val);
else if (key == "author") out_author = unquote_toml_string(val);
else if (key == "version") out_version = unquote_toml_string(val);
} else if (section == TomlSection::OrcaPluginSettings) {
// collect every key as a string; the plugin parses (int/float/...) what it needs.
if (!key.empty())
out_settings[key] = unquote_toml_string(val);
}
}
@@ -673,6 +681,7 @@ bool read_python_plugin_metadata(const boost::filesystem::path& py_path, PluginD
pep_desc,
pep_author,
pep_version,
descriptor.settings,
pep723_error)) {
error = "Failed to parse PEP 723 metadata: " + pep723_error;
return false;

View File

@@ -1,27 +0,0 @@
#pragma once
#include <pybind11/pybind11.h>
#include <pybind11/numpy.h>
#include "libslic3r/Point.hpp"
namespace Slic3r {
// Point/Point3 must be tightly packed for zero-copy views. coord_t = int64_t.
static_assert(sizeof(Point) == 2 * sizeof(coord_t), "Point must be 2 packed coord_t");
static_assert(sizeof(Point3) == 3 * sizeof(coord_t), "Point3 must be 3 packed coord_t");
// Zero-copy, read-only (rows, N) numpy view over `data`, pinned alive by `owner`.
// T is the element scalar (coord_t=int64 for slicing coords). Mirrors PluginHostApi's
// capsule + setflags(write=false) pattern, generalized over column count and owner.
template<typename T, int N>
pybind11::array make_readonly_rows(pybind11::capsule owner, const T* data, pybind11::ssize_t rows)
{
namespace py = pybind11;
py::array_t<T> arr(
{ rows, (py::ssize_t)N },
{ (py::ssize_t)(N * sizeof(T)), (py::ssize_t)sizeof(T) },
data, owner);
arr.attr("setflags")(py::arg("write") = false);
return std::move(arr);
}
} // namespace Slic3r

View File

@@ -1,162 +1,14 @@
#include "SlicingPipelinePluginCapability.hpp"
#include "SlicingPipelinePluginCapabilityTrampoline.hpp"
#include "SlicingNumpy.hpp" // make_readonly_rows
#include "slic3r/plugin/PluginBindingUtils.hpp" // config_value_or_none
#include "libslic3r/libslic3r.h" // unscale<>, live SCALING_FACTOR
#include "libslic3r/ExtrusionEntity.hpp" // ExtrusionPath/Loop/MultiPath, role_to_string
#include "libslic3r/ExtrusionEntityCollection.hpp" // ExtrusionEntityCollection
#include <pybind11/stl.h>
#include <vector>
#include <pybind11/stl.h> // std::map<std::string,std::string> -> dict for ctx.params
namespace py = pybind11;
namespace Slic3r {
bool SlicingPipelineContext::cancelled() const { return print && print->canceled(); }
namespace {
// Zero-copy read-only int64 (N,2) view over a Polygon's points, pinned by `owner`.
// coord_t == int64; Point is asserted tightly packed in SlicingNumpy.hpp.
static py::array polygon_rows(const py::capsule& owner, const Polygon& poly)
{
const Points& p = poly.points;
return make_readonly_rows<coord_t, 2>(
owner, p.empty() ? nullptr : p.front().data(), (py::ssize_t) p.size());
}
// Flatten an extrusion graph into a list of leaf ExtrusionPath* while walking the
// ORIGINAL Print-owned tree (never a temporary copy): the returned pointers stay
// valid for the execute(ctx) lifetime pinned by `owner`, so points() can hand out
// zero-copy views into path->polyline.points.
//
// This is deliberately NOT ExtrusionEntityCollection::flatten(): flatten() only
// unwraps nested collections (is_collection() is true solely for collections) and
// returns them by value, so it would (a) dangle if we viewed into the copy and
// (b) leave ExtrusionLoop/ExtrusionMultiPath intact — dropping every perimeter
// loop, since dynamic_cast<ExtrusionPath*> fails on a loop. We descend into
// loops/multipaths here to reach their contained paths.
static void collect_extrusion_paths(const ExtrusionEntity* ee, std::vector<const ExtrusionPath*>& out)
{
if (ee == nullptr)
return;
if (const auto* coll = dynamic_cast<const ExtrusionEntityCollection*>(ee)) {
for (const ExtrusionEntity* child : coll->entities)
collect_extrusion_paths(child, out);
} else if (const auto* loop = dynamic_cast<const ExtrusionLoop*>(ee)) {
for (const ExtrusionPath& p : loop->paths)
out.push_back(&p);
} else if (const auto* mp = dynamic_cast<const ExtrusionMultiPath*>(ee)) {
for (const ExtrusionPath& p : mp->paths)
out.push_back(&p);
} else if (const auto* path = dynamic_cast<const ExtrusionPath*>(ee)) {
// Catches ExtrusionPath and its subclasses (Sloped/Contoured/Oriented) last,
// after the composite types above have been ruled out.
out.push_back(path);
}
}
// Build a Python list of PathData over an extrusion collection, each entry pinned by `owner`.
static py::list path_data_list(const py::capsule& owner, const ExtrusionEntityCollection& coll)
{
std::vector<const ExtrusionPath*> paths;
collect_extrusion_paths(&coll, paths);
py::list out;
for (const ExtrusionPath* p : paths)
out.append(PathData{ p, owner });
return out;
}
// --- Task 11 input path: Python geometry -> C++ ExPolygon/Surface, with validation. -------
// The mutators take scaled integer coords (the same units the read views hand out). A Python
// raise here surfaces as ValueError (pybind translates) so malformed input is rejected up
// front rather than silently corrupting the slicing graph.
// One (N,2) int64 ndarray -> Polygon. Rejects wrong dtype/shape and degenerate (<3 pt) rings.
// Float / NaN / inf are rejected implicitly: only a signed-integer, 8-byte (coord_t==int64)
// dtype is accepted, and integer arrays cannot hold NaN/inf.
static Polygon parse_polygon(py::handle h, const char* who)
{
if (!py::isinstance<py::array>(h))
throw py::value_error(std::string(who) + ": each contour/hole must be an (N,2) int64 ndarray");
py::array a = py::reinterpret_borrow<py::array>(h);
if (a.dtype().kind() != 'i' || a.itemsize() != (py::ssize_t) sizeof(coord_t))
throw py::value_error(std::string(who) + ": polygon coordinates must be int64 (scaled coords)");
if (a.ndim() != 2 || a.shape(1) != 2)
throw py::value_error(std::string(who) + ": each polygon array must have shape (N,2)");
if (a.shape(0) < 3)
throw py::value_error(std::string(who) + ": a polygon needs at least 3 points");
// dtype already validated as int64; forcecast here only guarantees a C-contiguous buffer.
auto arr = py::array_t<coord_t, py::array::c_style | py::array::forcecast>::ensure(a);
if (!arr)
throw py::value_error(std::string(who) + ": could not read polygon as a contiguous int64 array");
auto r = arr.unchecked<2>();
Polygon poly;
poly.points.reserve((size_t) arr.shape(0));
for (py::ssize_t i = 0; i < arr.shape(0); ++i)
poly.points.emplace_back((coord_t) r(i, 0), (coord_t) r(i, 1));
return poly;
}
// One Python entry -> ExPolygon. Accepts either a bare (N,2) ndarray (contour only) or a
// [contour, [hole, ...]] sequence. Orientation is normalized (contour CCW, holes CW) so
// downstream area/offset math is correct regardless of the caller's winding.
static ExPolygon parse_expolygon(py::handle entry, const char* who)
{
ExPolygon ex;
if (py::isinstance<py::array>(entry)) {
ex.contour = parse_polygon(entry, who);
} else if (py::isinstance<py::sequence>(entry) && !py::isinstance<py::str>(entry)) {
py::sequence seq = py::reinterpret_borrow<py::sequence>(entry);
if (py::len(seq) < 1)
throw py::value_error(std::string(who) + ": a [contour, holes] entry needs a contour");
ex.contour = parse_polygon(seq[0], who);
if (py::len(seq) >= 2) {
// Type-check the holes element up front: a non-sequence (e.g. an int) would otherwise
// reach reinterpret_borrow<py::sequence> and raise a bare Python TypeError on iteration,
// whereas the API contract is ValueError for malformed input (str is excluded because it
// is iterable but never a valid holes container).
py::object holes_obj = seq[1];
if (!py::isinstance<py::sequence>(holes_obj) || py::isinstance<py::str>(holes_obj))
throw py::value_error(std::string(who) + ": the holes element must be a list of (N,2) int64 ndarrays");
for (py::handle hh : py::reinterpret_borrow<py::sequence>(holes_obj)) {
Polygon hole = parse_polygon(hh, who);
hole.make_clockwise();
ex.holes.emplace_back(std::move(hole));
}
}
} else {
throw py::value_error(std::string(who) + ": each entry must be an (N,2) ndarray or a [contour, holes] pair");
}
ex.contour.make_counter_clockwise();
return ex;
}
// A non-empty Python list of entries -> ExPolygons (each entry parsed + oriented).
static ExPolygons parse_expolygon_list(py::handle list_h, const char* who)
{
if (!py::isinstance<py::sequence>(list_h) || py::isinstance<py::str>(list_h))
throw py::value_error(std::string(who) + ": expected a list of polygons");
ExPolygons out;
for (py::handle entry : py::reinterpret_borrow<py::sequence>(list_h))
out.emplace_back(parse_expolygon(entry, who));
if (out.empty())
throw py::value_error(std::string(who) + ": expected a non-empty list of polygons");
return out;
}
// Build Surfaces from a Python list, carrying surface_type (and the other per-surface
// attributes) forward from the collection being replaced, or defaulting to stInternal when
// the region had no prior surfaces.
static Surfaces surfaces_from_py(py::handle list_h, const SurfaceCollection& replaced, const char* who)
{
ExPolygons ex = parse_expolygon_list(list_h, who);
const Surface tmpl = replaced.surfaces.empty() ? Surface(stInternal) : replaced.surfaces.front();
Surfaces out;
out.reserve(ex.size());
for (ExPolygon& e : ex)
out.emplace_back(Surface(tmpl, std::move(e)));
return out;
}
} // namespace
void SlicingPipelinePluginCapability::RegisterBindings(py::module_& module, py::enum_<PluginCapabilityType>& pluginTypes) {
(void) pluginTypes; // matches gcode/script/printerAgent; Step is a fresh enum below.
auto slicing = module.def_submodule("slicing", "Slicing pipeline API (research/experimental).");
@@ -165,7 +17,8 @@ void SlicingPipelinePluginCapability::RegisterBindings(py::module_& module, py::
.value("Slice", SlicingPipelineStep::Slice)
.value("Perimeters", SlicingPipelineStep::Perimeters)
.value("EstimateCurledExtrusions", SlicingPipelineStep::EstimateCurledExtrusions)
.value("Infill", SlicingPipelineStep::Infill) // fires after prepare+infill
.value("PrepareInfill", SlicingPipelineStep::PrepareInfill) // after prepare_infill, before make_fills: set_fill_surfaces here CASCADES
.value("Infill", SlicingPipelineStep::Infill) // after make_fills: set_fill_surfaces here does NOT regenerate fills (v1)
.value("Ironing", SlicingPipelineStep::Ironing)
.value("Contouring", SlicingPipelineStep::Contouring)
.value("SupportMaterial", SlicingPipelineStep::SupportMaterial)
@@ -175,190 +28,45 @@ void SlicingPipelinePluginCapability::RegisterBindings(py::module_& module, py::
.value("SkirtBrim", SlicingPipelineStep::SkirtBrim)
.export_values();
// --- Read-graph geometry views (see header for the mandatory lifetime rule). ---
// Every array/view below is valid ONLY during the execute(ctx) call that produced it.
py::enum_<SurfaceType>(slicing, "SurfaceType")
.value("stTop", stTop)
.value("stBottom", stBottom)
.value("stBottomBridge", stBottomBridge)
.value("stInternalAfterExternalBridge", stInternalAfterExternalBridge)
.value("stInternal", stInternal)
.value("stInternalSolid", stInternalSolid)
.value("stInternalBridge", stInternalBridge)
.value("stSecondInternalBridge", stSecondInternalBridge)
.value("stInternalVoid", stInternalVoid)
.value("stPerimeter", stPerimeter)
.value("stCount", stCount)
.export_values();
// The read-graph data model (Surface / ExPolygon / the extrusion tree / LayerRegion /
// Layer / PrintObject / Print) and the 2D-geometry mutators live in orca.host, registered
// by PluginHostSlicing.cpp. orca.slicing is workflow-only: Step, unscale, the context, and
// the capability base. See PluginHostSlicing.cpp for the mandatory reference-lifetime rule.
// Scaled integer coordinate -> millimeters. Reads the live SCALING_FACTOR at call
// time (1e-6 normal, 1e-5 for beds > 2147mm), so it is never cached.
slicing.def("unscale", [](coord_t v) { return unscale<double>(v); }, py::arg("coord"),
"Convert a scaled integer coordinate to millimeters (reads the live SCALING_FACTOR).");
py::class_<ExPolygonView>(slicing, "ExPolygonView")
.def("contour", [](const ExPolygonView& v) { return polygon_rows(v.owner, v.ex->contour); },
"Outer contour as a read-only int64 (N,2) numpy view in scaled coords. "
"Valid only during the execute(ctx) call.")
.def("holes", [](const ExPolygonView& v) {
py::list out;
for (const Polygon& h : v.ex->holes)
out.append(polygon_rows(v.owner, h));
return out;
}, "List of hole contours (CW), each a read-only int64 (N,2) numpy view. "
"Valid only during the execute(ctx) call.");
py::class_<SurfaceView>(slicing, "SurfaceView")
.def_property_readonly("surface_type", [](const SurfaceView& v) { return v.s->surface_type; })
.def_property_readonly("thickness", [](const SurfaceView& v) { return v.s->thickness; })
.def_property_readonly("bridge_angle", [](const SurfaceView& v) { return v.s->bridge_angle; })
.def_property_readonly("extra_perimeters", [](const SurfaceView& v) { return v.s->extra_perimeters; })
.def_property_readonly("expolygon", [](const SurfaceView& v) {
return ExPolygonView{ &v.s->expolygon, v.owner };
}, "This surface's geometry as an ExPolygonView. Valid only during the execute(ctx) call.")
// MUTATOR (Task 11). Reclassify this surface's type (e.g. SurfaceType.stInternalSolid).
// set_type reassigns surface_type ONLY — it does not replace the geometry. Writes through
// the const view by const_cast (the Surface is non-const in the live slicing graph).
// Valid only during the execute(ctx) call.
.def("set_type", [](const SurfaceView& v, SurfaceType type) {
const_cast<Surface*>(v.s)->surface_type = type;
}, py::arg("surface_type"),
"Reclassify this surface's SurfaceType (reassigns surface_type only; the geometry "
"is unchanged). Valid only during the execute(ctx) call.");
// A flattened toolpath. Read-only in v1 (mutation is a later phase). role/width/
// height/mm3_per_mm are plain scalars; points() materializes a zero-copy array.
py::class_<PathData>(slicing, "PathData")
.def("points", [](const PathData& p) {
const Points3& pts = p.path->polyline.points;
return make_readonly_rows<coord_t, 3>(
p.owner, pts.empty() ? nullptr : pts.front().data(), (py::ssize_t) pts.size());
}, "Path vertices as a read-only int64 (N,3) numpy view in scaled coords "
"(the polyline is natively 3D on this branch). Valid only during the execute(ctx) call.")
.def_property_readonly("role", [](const PathData& p) {
return ExtrusionEntity::role_to_string(p.path->role());
}, "Extrusion role as a human-readable string (e.g. \"Outer wall\", \"Sparse infill\").")
.def_property_readonly("width", [](const PathData& p) { return p.path->width; })
.def_property_readonly("height", [](const PathData& p) { return p.path->height; })
.def_property_readonly("mm3_per_mm", [](const PathData& p) { return p.path->mm3_per_mm; });
py::class_<LayerRegionView>(slicing, "LayerRegionView")
.def("slices", [](const LayerRegionView& v) {
py::list out;
for (const Surface& s : v.r->slices.surfaces)
out.append(SurfaceView{ &s, v.owner });
return out;
}, "Sliced surfaces (typed top/bottom/internal) as [SurfaceView]. "
"Valid only during the execute(ctx) call.")
.def("fill_surfaces", [](const LayerRegionView& v) {
py::list out;
for (const Surface& s : v.r->fill_surfaces.surfaces)
out.append(SurfaceView{ &s, v.owner });
return out;
}, "Surfaces prepared for infill as [SurfaceView]. "
"Valid only during the execute(ctx) call.")
.def("perimeters", [](const LayerRegionView& v) {
return path_data_list(v.owner, v.r->perimeters);
}, "Perimeter toolpaths flattened to [PathData] (nested collections and "
"loops decomposed into their paths). Valid only during the execute(ctx) call.")
.def("fills", [](const LayerRegionView& v) {
return path_data_list(v.owner, v.r->fills);
}, "Infill toolpaths flattened to [PathData] (nested collections and loops "
"decomposed into their paths). Valid only during the execute(ctx) call.")
// MUTATOR (Task 11). Replace this region's sliced surfaces. `polygons` is a list of
// (N,2) int64 ndarrays (scaled coords) or [contour, [holes...]] pairs; orientation is
// normalized (contour CCW, holes CW) and surface_type is carried forward from the
// replaced surfaces (else stInternal). Writes through the const view by const_cast.
.def("set_slices", [](const LayerRegionView& v, py::object polygons) {
auto* region = const_cast<LayerRegion*>(v.r);
region->slices.set(surfaces_from_py(polygons, region->slices, "set_slices"));
}, py::arg("polygons"),
"Replace this region's sliced surfaces from a list of (N,2) int64 ndarrays (scaled "
"coords) or [contour, [holes...]] pairs (orientation normalized: contour CCW / holes "
"CW; surface_type carried forward from the replaced surfaces, else stInternal).\n"
"MUTATION-CASCADE: at the Slice boundary this is the primary, fully-supported entry "
"point -- the split slice loop runs make_perimeters() afterward, so the change cascades "
"into perimeters and everything downstream (final G-code).\n"
"PERSISTENCE (v1 limitation): the mutation is written into region->slices, but the "
"pre-hook geometry is also retained in each Layer's raw_slices backup (taken by "
"slice() BEFORE this hook fires). The mutation therefore survives only while posSlice "
"stays cached AND perimeters are not re-run from those restored raw slices: "
"make_perimeters() calls restore_untyped_slices(), which overwrites slices from "
"raw_slices, so a config change that re-runs perimeters without re-slicing (e.g. "
"wall_loops) silently reverts to the original geometry while posSlice stays cached "
"(this hook does NOT re-fire). Re-selecting the plugin -- or any other "
"posSlice-invalidating change -- re-fires this hook and re-applies the mutation. "
"Propagating the mutation into raw_slices is a known v1 limitation.\n"
"DUPLICATES: identical objects share Layer*, so the mutation on the object that slices "
"is automatically seen by its duplicates; objects that must mutate independently must "
"not be identical.\n"
"Raises ValueError on malformed input. Valid only during the execute(ctx) call.")
// MUTATOR (Task 11). Replace this region's fill (infill-prep) surfaces; identical input
// format and validation to set_slices.
.def("set_fill_surfaces", [](const LayerRegionView& v, py::object polygons) {
auto* region = const_cast<LayerRegion*>(v.r);
region->fill_surfaces.set(surfaces_from_py(polygons, region->fill_surfaces, "set_fill_surfaces"));
}, py::arg("polygons"),
"Replace this region's fill (infill-prep) surfaces; same input format/validation as "
"set_slices.\n"
"MUTATION-CASCADE: at the Infill boundary this changes the stored surfaces but does NOT "
"regenerate the already-built `fills` toolpaths in v1.\n"
"Raises ValueError on malformed input. Valid only during the execute(ctx) call.");
py::class_<LayerView>(slicing, "LayerView")
.def_property_readonly("slice_z", [](const LayerView& v) { return v.l->slice_z; })
.def_property_readonly("print_z", [](const LayerView& v) { return v.l->print_z; })
.def_property_readonly("height", [](const LayerView& v) { return v.l->height; })
.def("lslices", [](const LayerView& v) {
py::list out;
for (const ExPolygon& e : v.l->lslices)
out.append(ExPolygonView{ &e, v.owner });
return out;
}, "Merged per-layer islands as [ExPolygonView]. "
"Valid only during the execute(ctx) call.")
.def("regions", [](const LayerView& v) {
py::list out;
for (const LayerRegion* r : v.l->regions())
out.append(LayerRegionView{ r, v.owner });
return out;
}, "Per-region views as [LayerRegionView]. "
"Valid only during the execute(ctx) call.")
// MUTATOR (Task 11). Replace this layer's merged islands (lslices) and refresh the
// cache-invariant `lslices_bboxes` (one BoundingBox per island via get_extents). Same
// input format/validation as LayerRegionView.set_slices. Writes through the const view
// by const_cast.
.def("set_lslices", [](const LayerView& v, py::object islands) {
auto* layer = const_cast<Layer*>(v.l);
layer->lslices = parse_expolygon_list(islands, "set_lslices");
layer->lslices_bboxes.clear();
layer->lslices_bboxes.reserve(layer->lslices.size());
for (const ExPolygon& island : layer->lslices)
layer->lslices_bboxes.emplace_back(get_extents(island));
}, py::arg("islands"),
"Replace this layer's merged islands (lslices) from a list of (N,2) int64 ndarrays "
"(scaled coords) or [contour, [holes...]] pairs, and refresh lslices_bboxes (one "
"bounding box per island via get_extents) so the bbox cache stays consistent. Same "
"input format/validation as LayerRegionView.set_slices. Raises ValueError on malformed "
"input. Valid only during the execute(ctx) call.");
py::class_<PrintObjectView>(slicing, "PrintObjectView")
.def("layers", [](const PrintObjectView& v) {
py::list out;
for (const Layer* l : v.o->layers())
out.append(LayerView{ l, v.owner });
return out;
}, "Object layers as [LayerView]. Valid only during the execute(ctx) call.");
py::class_<SlicingPipelineContext>(slicing, "SlicingPipelineContext")
.def_readonly("orca_version", &SlicingPipelineContext::orca_version)
.def_readonly("step", &SlicingPipelineContext::step)
.def_readonly("params", &SlicingPipelineContext::params,
"read-only dict of this plugin's [tool.orcaslicer.plugin.settings] values "
"(string->string). Parse the values you need, e.g. float(ctx.params['rate']).")
.def_property_readonly("print", [](const SlicingPipelineContext& ctx) -> py::object {
if (ctx.print == nullptr)
return py::none();
return py::cast(ctx.print, py::return_value_policy::reference);
}, "The orca.host.Print being sliced — the raw slicing graph, exactly what the "
"C++ pipeline walks. Valid only during the execute(ctx) call. For mesh access "
"use ctx.print.model() (the Print's snapshot), never orca.host.model().")
.def_property_readonly("object", [](const SlicingPipelineContext& ctx) -> py::object {
if (ctx.object == nullptr)
return py::none();
return py::cast(PrintObjectView{ ctx.object, ctx.owner });
}, "PrintObjectView for object-scoped steps, or None for print-wide steps. "
// The hook signature hands objects out as const; they are genuinely mutable
// (owned by the Print) — the same const_cast the old view mutators used,
// done once here at the graph entry point.
return py::cast(const_cast<PrintObject*>(ctx.object), py::return_value_policy::reference);
}, "orca.host.PrintObject for object-scoped steps, or None for print-wide steps. "
"Valid only during the execute(ctx) call.")
.def("config_value", [](const SlicingPipelineContext& ctx, const std::string& key) -> py::object {
if (ctx.print == nullptr)
return py::none();
return config_value_or_none(ctx.print->full_print_config(), key);
}, py::arg("key"),
"serialized value of a resolved (full) print config option for this slice, or "
"None if absent. Shorthand for ctx.print.config_value(key).")
.def("cancelled", &SlicingPipelineContext::cancelled);
py::class_<SlicingPipelinePluginCapability, PluginCapabilityInterface,

View File

@@ -1,58 +1,28 @@
#pragma once
#include "slic3r/plugin/PythonPluginInterface.hpp"
#include "libslic3r/Print.hpp" // SlicingPipelineStep, PrintObject
#include "libslic3r/Layer.hpp" // Layer, LayerRegion, SurfaceCollection
#include "libslic3r/Surface.hpp" // Surface, SurfaceType
#include "libslic3r/ExPolygon.hpp" // ExPolygon, Polygon
#include "libslic3r/Print.hpp" // SlicingPipelineStep, Print, PrintObject
#include <pybind11/pybind11.h>
#include <map>
#include <string>
namespace Slic3r {
// ---------------------------------------------------------------------------
// Read-graph geometry views (Task 8).
//
// LIFETIME (mandatory): each view is a thin, non-owning wrapper holding a raw
// pointer into a buffer owned by the Print / PrintObject that the slicing
// pipeline mutates and frees between steps. A view — and every numpy array a
// view hands out (ExPolygonView::contour()/holes()) — is valid ONLY for the
// duration of the execute(ctx) call that produced it. The `owner` capsule pins
// the owning SlicingPipelineContext's Print* alive for the array's lifetime,
// but the underlying std::vector storage may be reallocated by the next
// pipeline step, so a Python plugin MUST NOT stash a view or an array across
// execute() calls or read one after execute() returns. Read now, copy what you
// need, and let the views go.
//
// Read accessors are zero-copy and non-owning as described above. The 2D-geometry
// mutators added in Task 11 (LayerRegionView.set_slices/set_fill_surfaces,
// LayerView.set_lslices, SurfaceView.set_type) write THROUGH these const views by
// const_cast: the pointed-to Layer/LayerRegion/Surface are genuinely non-const
// (owned mutably by the Print; the dispatcher merely hands them out as const), the
// same pattern the C++ slicing-pipeline hook uses. Mutations take effect on the live
// slicing graph and cascade per the per-method contract documented in the bindings.
// ---------------------------------------------------------------------------
struct ExPolygonView { const ExPolygon* ex; pybind11::capsule owner; };
struct SurfaceView { const Surface* s; pybind11::capsule owner; };
struct LayerRegionView { const LayerRegion* r; pybind11::capsule owner; };
struct LayerView { const Layer* l; pybind11::capsule owner; };
struct PrintObjectView { const PrintObject* o; pybind11::capsule owner; };
// A single flattened toolpath (Task 9). `path` points into a Print-owned
// ExtrusionEntityCollection (a LayerRegion's `perimeters`/`fills`); like every
// view above it is non-owning and valid ONLY during the producing execute(ctx)
// call, with `owner` pinning that Print* alive for any array points() hands out.
struct PathData { const ExtrusionPath* path; pybind11::capsule owner; };
// Workflow context handed to SlicingPipeline plugins. ctx.print / ctx.object
// are RAW references into the live slicing graph — the same objects the C++
// pipeline mutates. The data-model bindings and the mandatory lifetime rule
// (valid only during execute(ctx); mutators invalidate references into replaced
// containers, like std::vector iterators) live in
// src/slic3r/plugin/PluginHostSlicing.cpp.
struct SlicingPipelineContext {
std::string orca_version;
SlicingPipelineStep step { SlicingPipelineStep::Slice };
Print* print { nullptr }; // always present
Print* print { nullptr }; // always present when dispatched
const PrintObject* object { nullptr }; // null for print-wide steps
// Capsule pinning `print` alive for any zero-copy array a view hands out.
// Populated by Task 10's dispatcher; a default (empty) capsule is fine for
// print-wide steps and for unit tests exercising views over static data.
pybind11::capsule owner;
bool cancelled() const; // -> print->canceled()
// read-only per-plugin settings, populated by the dispatcher from the
// plugin's [tool.orcaslicer.plugin.settings] PEP-723 table. Exposed as
// ctx.params (dict of string->string).
std::map<std::string, std::string> params;
bool cancelled() const; // -> print->canceled()
};
class SlicingPipelinePluginCapability : public PluginCapabilityInterface {