mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-07-15 23:12:08 +00:00
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:
@@ -9,7 +9,8 @@ TEST_CASE("slicing_pipeline_plugin option exists and defaults empty", "[slicing_
|
||||
CHECK(opt->values.empty());
|
||||
const ConfigOptionDef* def = cfg.def()->get("slicing_pipeline_plugin");
|
||||
REQUIRE(def != nullptr);
|
||||
CHECK(def->support_plugin == true);
|
||||
CHECK(def->plugin_type == "slicing-pipeline");
|
||||
CHECK(def->is_plugin_backed());
|
||||
CHECK(def->gui_type == ConfigOptionDef::GUIType::plugin_picker);
|
||||
}
|
||||
|
||||
@@ -45,6 +46,7 @@ TEST_CASE("SlicingPipeline hook fires once per step per object in order", "[slic
|
||||
auto count = [&](S s){ return std::count_if(calls.begin(), calls.end(), [&](const Call& c){ return c.step == s; }); };
|
||||
CHECK(count(S::Slice) == 1);
|
||||
CHECK(count(S::Perimeters) == 1);
|
||||
CHECK(count(S::PrepareInfill) == 1); // the prepare-infill seam fires once per object
|
||||
CHECK(count(S::Infill) == 1);
|
||||
CHECK(count(S::WipeTower) == 1);
|
||||
CHECK(count(S::SkirtBrim) == 1);
|
||||
@@ -54,9 +56,32 @@ TEST_CASE("SlicingPipeline hook fires once per step per object in order", "[slic
|
||||
// Slice must fire before Perimeters for the same object:
|
||||
auto idx = [&](S s){ for (size_t i=0;i<calls.size();++i) if (calls[i].step==s) return (int)i; return -1; };
|
||||
CHECK(idx(S::Slice) < idx(S::Perimeters));
|
||||
CHECK(idx(S::Perimeters) < idx(S::PrepareInfill)); // prepare-infill fires after perimeters...
|
||||
CHECK(idx(S::PrepareInfill) < idx(S::Infill)); // ...and before the fills are built
|
||||
}
|
||||
|
||||
#include <sstream>
|
||||
#include <cmath>
|
||||
|
||||
// Exported G-code carries a few nondeterministic comment lines unrelated to toolpaths: a
|
||||
// wall-clock timestamp ("; generated by ..."), ObjectID-derived ids (from a process-global
|
||||
// counter never reset between runs), and a config-dump line naming the selected plugin (an
|
||||
// active run records it, the absent baseline does not). Strip exactly those lines so a raw
|
||||
// byte-compare isolates the real motion/extrusion output; every other byte is still compared.
|
||||
static std::string strip_nondeterministic_gcode_lines(const std::string& gcode) {
|
||||
std::string out; out.reserve(gcode.size());
|
||||
std::istringstream in(gcode);
|
||||
std::string line;
|
||||
while (std::getline(in, line)) {
|
||||
if (line.compare(0, 15, "; generated by ") == 0) continue; // wall-clock timestamp
|
||||
if (line.compare(0, 18, "; model label id: ") == 0) continue; // ObjectID-derived
|
||||
// "; [stop] printing object <name> id:N copy M" / "... unique label id: N" (ObjectID-derived):
|
||||
if (line.find("printing object") != std::string::npos && line.find(" id:") != std::string::npos) continue;
|
||||
if (line.find("slicing_pipeline_plugin") != std::string::npos) continue; // config-dump plugin name
|
||||
out += line; out += '\n';
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
TEST_CASE("Inactive hook: process output is byte-identical (no-op hook == unset)", "[slicing_pipeline]") {
|
||||
// Three configurations must all normalize to the same G-code:
|
||||
@@ -80,35 +105,12 @@ TEST_CASE("Inactive hook: process output is byte-identical (no-op hook == unset)
|
||||
Slic3r::Print::set_slicing_pipeline_hook_fn(nullptr);
|
||||
return g;
|
||||
};
|
||||
// Pre-existing nondeterminism unrelated to the hook makes a raw string compare
|
||||
// impossible: exported gcode embeds a wall-clock timestamp and ids derived from
|
||||
// the process-global ObjectID counter (never reset between runs) in a handful of
|
||||
// comment lines. Strip exactly those comment lines; every other byte -- all
|
||||
// motion/extrusion/temperature commands and all remaining comments -- is still
|
||||
// compared, so the assertion still proves the inactive hook leaves all
|
||||
// machine-meaningful output byte-identical.
|
||||
auto normalize = [](const std::string& gcode) {
|
||||
std::string out; out.reserve(gcode.size());
|
||||
std::istringstream in(gcode);
|
||||
std::string line;
|
||||
while (std::getline(in, line)) {
|
||||
if (line.compare(0, 15, "; generated by ") == 0) continue; // wall-clock timestamp
|
||||
if (line.compare(0, 18, "; model label id: ") == 0) continue; // ObjectID-derived
|
||||
// "; [stop] printing object <name> id:N copy M" and
|
||||
// "; start/stop printing object, unique label id: N" (ObjectID-derived):
|
||||
if (line.find("printing object") != std::string::npos && line.find(" id:") != std::string::npos) continue;
|
||||
// Config-dump comment: the active run legitimately records the selected plugin
|
||||
// ("; slicing_pipeline_plugin = probe") while the baseline leaves it empty. This
|
||||
// is a machine-irrelevant comment, not motion -- strip it so the comparison isolates
|
||||
// whether the active-but-non-mutating hook perturbs the real toolpath.
|
||||
if (line.find("slicing_pipeline_plugin") != std::string::npos) continue;
|
||||
out += line; out += '\n';
|
||||
}
|
||||
return out;
|
||||
};
|
||||
const std::string baseline = normalize(run(false, false)); // feature absent
|
||||
CHECK(normalize(run(false, true)) == baseline); // gated off: hook never fires
|
||||
CHECK(normalize(run(true, true)) == baseline); // active no-op hook fires everywhere, mutates nothing
|
||||
// Compare only machine-meaningful output (see strip_nondeterministic_gcode_lines): every
|
||||
// motion/extrusion byte is still compared, so this proves the inactive hook -- and the
|
||||
// active-but-non-mutating hook -- leave the real toolpath byte-identical.
|
||||
const std::string baseline = strip_nondeterministic_gcode_lines(run(false, false)); // feature absent
|
||||
CHECK(strip_nondeterministic_gcode_lines(run(false, true)) == baseline); // gated off: hook never fires
|
||||
CHECK(strip_nondeterministic_gcode_lines(run(true, true)) == baseline); // active no-op hook fires everywhere, mutates nothing
|
||||
}
|
||||
|
||||
// Fix 4(a): gating negative path. With the option EMPTY the plugin is inactive, so a
|
||||
@@ -172,7 +174,7 @@ TEST_CASE("Duplicate objects share a slice: Slice hook fires exactly once", "[sl
|
||||
#include "libslic3r/Layer.hpp" // Layer, LayerRegion (full defs for the cascade hook)
|
||||
#include "libslic3r/ClipperUtils.hpp" // offset_ex
|
||||
|
||||
// Task 11: the correctness heart of the mutation feature. A C++ hook insets every
|
||||
// The correctness heart of the mutation feature. A C++ hook insets every
|
||||
// region's `slices` at the Slice boundary (via SurfaceCollection::set with offset
|
||||
// polygons); because make_perimeters() derives fill_surfaces from slices AFTER the
|
||||
// Slice hook fires (see Print::process's split slice loop), the downstream
|
||||
@@ -213,3 +215,256 @@ TEST_CASE("Changing slicing_pipeline_plugin invalidates posSlice", "[slicing_pip
|
||||
print.apply(model, config);
|
||||
CHECK_FALSE(print.objects().front()->is_step_done(posSlice)); // re-slice required
|
||||
}
|
||||
|
||||
#include <catch2/matchers/catch_matchers_floating_point.hpp>
|
||||
|
||||
// §3.6 (Twistify design): Twistify's effect is a similarity transform (rotate + uniform
|
||||
// scale) applied to slices at Step.Slice. This C++ analogue rotates every region's slices a
|
||||
// fixed 45 deg about the object's base-footprint center -- the same seam and cascade that
|
||||
// Twistify.py drives through the pybind set_slices binding. Two end-to-end invariants after
|
||||
// process() confirm the approach:
|
||||
// (1) a pure rotation is a similarity with scale 1, so total fill area is preserved, and
|
||||
// (2) the mutation genuinely cascaded into make_perimeters' fill_surfaces -- a 20mm square
|
||||
// rotated 45 deg becomes a diamond whose bbox is ~sqrt(2)x wider (it did not stay
|
||||
// axis-aligned), proving downstream geometry was rebuilt from the twisted slices.
|
||||
TEST_CASE("Rotating slices at the Slice boundary cascades (area preserved, bbox rotated)", "[slicing_pipeline]") {
|
||||
using Catch::Matchers::WithinRel;
|
||||
struct Measure { double area; double width; double height; };
|
||||
auto measure = [](bool rotate) -> Measure {
|
||||
Slic3r::Print print; Slic3r::Model model;
|
||||
auto config = Slic3r::DynamicPrintConfig::full_print_config();
|
||||
config.set_key_value("slicing_pipeline_plugin", new Slic3r::ConfigOptionStrings({"probe"}));
|
||||
if (rotate) Slic3r::Print::set_slicing_pipeline_hook_fn(
|
||||
[](Slic3r::Print&, const Slic3r::PrintObject* o, Slic3r::SlicingPipelineStep s){
|
||||
if (s != Slic3r::SlicingPipelineStep::Slice || !o) return;
|
||||
auto* obj = const_cast<Slic3r::PrintObject*>(o);
|
||||
// Twist axis = center of the first sliced layer's footprint (Twistify's anchor).
|
||||
coord_t nx=0, xx=0, ny=0, xy=0; bool seeded=false;
|
||||
for (Slic3r::Layer* l : obj->layers()) {
|
||||
for (Slic3r::LayerRegion* r : l->regions())
|
||||
for (const Slic3r::Surface& sf : r->slices.surfaces)
|
||||
for (const Slic3r::Point& p : sf.expolygon.contour.points) {
|
||||
if (!seeded) { nx=xx=p.x(); ny=xy=p.y(); seeded=true; }
|
||||
else { nx=std::min(nx,p.x()); xx=std::max(xx,p.x());
|
||||
ny=std::min(ny,p.y()); xy=std::max(xy,p.y()); }
|
||||
}
|
||||
if (seeded) break;
|
||||
}
|
||||
const double cx = 0.5*((double)nx+(double)xx), cy = 0.5*((double)ny+(double)xy);
|
||||
const double ct = 0.7071067811865476, st = 0.7071067811865476; // cos/sin 45 deg
|
||||
auto rot = [&](const Slic3r::Point& p) {
|
||||
const double dx = (double)p.x()-cx, dy = (double)p.y()-cy;
|
||||
return Slic3r::Point((coord_t)std::llround(dx*ct - dy*st + cx),
|
||||
(coord_t)std::llround(dx*st + dy*ct + cy));
|
||||
};
|
||||
for (Slic3r::Layer* l : obj->layers())
|
||||
for (Slic3r::LayerRegion* r : l->regions()) {
|
||||
Slic3r::Surfaces in = r->slices.surfaces;
|
||||
for (auto& sf : in) {
|
||||
for (auto& pt : sf.expolygon.contour.points) pt = rot(pt);
|
||||
for (auto& h : sf.expolygon.holes)
|
||||
for (auto& pt : h.points) pt = rot(pt);
|
||||
}
|
||||
r->slices.set(std::move(in));
|
||||
}
|
||||
});
|
||||
else Slic3r::Print::set_slicing_pipeline_hook_fn(nullptr);
|
||||
init_print({TestMesh::cube_20x20x20}, print, model, config);
|
||||
print.process();
|
||||
double area = 0;
|
||||
coord_t nx=0, xx=0, ny=0, xy=0; bool seeded=false;
|
||||
for (auto* l : print.objects().front()->layers())
|
||||
for (auto* r : l->regions())
|
||||
for (auto& sf : r->fill_surfaces.surfaces) {
|
||||
area += sf.expolygon.area();
|
||||
for (const Slic3r::Point& p : sf.expolygon.contour.points) {
|
||||
if (!seeded) { nx=xx=p.x(); ny=xy=p.y(); seeded=true; }
|
||||
else { nx=std::min(nx,p.x()); xx=std::max(xx,p.x());
|
||||
ny=std::min(ny,p.y()); xy=std::max(xy,p.y()); }
|
||||
}
|
||||
}
|
||||
Slic3r::Print::set_slicing_pipeline_hook_fn(nullptr);
|
||||
return { area, (double)(xx-nx), (double)(xy-ny) };
|
||||
};
|
||||
const Measure base = measure(false);
|
||||
const Measure rot = measure(true);
|
||||
// (1) A pure rotation preserves area (similarity, scale 1): fills add up to the same area.
|
||||
CHECK_THAT(rot.area, WithinRel(base.area, 0.05));
|
||||
// (2) The rotation cascaded downstream: the square's fill bbox grew toward the sqrt(2)
|
||||
// diagonal (diamond) instead of staying axis-aligned.
|
||||
CHECK(rot.width > 1.3 * base.width);
|
||||
CHECK(rot.width < 1.5 * base.width);
|
||||
CHECK(rot.height > 1.3 * base.height);
|
||||
CHECK(rot.height < 1.5 * base.height);
|
||||
}
|
||||
|
||||
// §3.6 (Twistify design): Twistify skips exact-identity layers entirely, but every transformed
|
||||
// layer invokes the set_slices write-back + make_perimeters re-run. This proves that write path
|
||||
// is lossless for already-normalized (CCW contour / CW hole) input -- an active hook that
|
||||
// re-sets every region's slices to their CURRENT geometry (the identity similarity transform)
|
||||
// produces output byte-identical to an active hook that mutates nothing. Both runs are active
|
||||
// (same config dump); the only difference is whether the write path ran, so equality isolates it.
|
||||
TEST_CASE("Identity round-trip through set_slices is byte-identical", "[slicing_pipeline]") {
|
||||
auto run = [](bool roundtrip) {
|
||||
Slic3r::Print print; Slic3r::Model model;
|
||||
auto config = Slic3r::DynamicPrintConfig::full_print_config();
|
||||
config.set_key_value("slicing_pipeline_plugin", new Slic3r::ConfigOptionStrings({"probe"})); // active in both runs
|
||||
Slic3r::Print::set_slicing_pipeline_hook_fn(
|
||||
[roundtrip](Slic3r::Print&, const Slic3r::PrintObject* o, Slic3r::SlicingPipelineStep s){
|
||||
if (!roundtrip || s != Slic3r::SlicingPipelineStep::Slice || !o) return;
|
||||
for (Slic3r::Layer* l : const_cast<Slic3r::PrintObject*>(o)->layers())
|
||||
for (Slic3r::LayerRegion* r : l->regions()) {
|
||||
Slic3r::Surfaces in = r->slices.surfaces; // copy current (already-normalized) geometry
|
||||
r->slices.set(std::move(in)); // write back unchanged: identity transform
|
||||
}
|
||||
});
|
||||
init_print({TestMesh::cube_20x20x20}, print, model, config);
|
||||
std::string g = Slic3r::Test::gcode(print);
|
||||
Slic3r::Print::set_slicing_pipeline_hook_fn(nullptr);
|
||||
return g;
|
||||
};
|
||||
CHECK(strip_nondeterministic_gcode_lines(run(true)) == strip_nondeterministic_gcode_lines(run(false)));
|
||||
}
|
||||
|
||||
#include "libslic3r/ExtrusionEntityCollection.hpp" // count fill paths in the fill-surface cascade test
|
||||
|
||||
// Total leaf ExtrusionPath count under an extrusion (sub)tree (collections recursed into).
|
||||
static size_t count_leaf_paths(const Slic3r::ExtrusionEntity* ee) {
|
||||
if (ee == nullptr) return 0;
|
||||
if (const auto* coll = dynamic_cast<const Slic3r::ExtrusionEntityCollection*>(ee)) {
|
||||
size_t n = 0;
|
||||
for (const Slic3r::ExtrusionEntity* e : coll->entities) n += count_leaf_paths(e);
|
||||
return n;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Width (scaled) of the object-wide bounding box over every region's sliced contour.
|
||||
static double outer_slices_width(const Slic3r::Print& print) {
|
||||
coord_t min_x = 0, max_x = 0; bool seeded = false;
|
||||
for (auto* l : print.objects().front()->layers())
|
||||
for (auto* r : l->regions())
|
||||
for (const Slic3r::Surface& sf : r->slices.surfaces)
|
||||
for (const Slic3r::Point& p : sf.expolygon.contour.points) {
|
||||
if (!seeded) { min_x = max_x = p.x(); seeded = true; }
|
||||
else { min_x = std::min(min_x, p.x()); max_x = std::max(max_x, p.x()); }
|
||||
}
|
||||
return (double)(max_x - min_x);
|
||||
}
|
||||
|
||||
// After the Slice hook mutates slices, raw_slices must be re-snapshotted so the mutation
|
||||
// becomes the untyped baseline. make_perimeters() restores untyped slices from raw_slices on
|
||||
// any perimeter re-run; invoking that restore directly must reproduce the mutation, not revert
|
||||
// to the pre-hook geometry (which is what happened before this fix).
|
||||
TEST_CASE("raw_slices captures post-hook geometry so a perimeter re-run keeps the mutation", "[slicing_pipeline]") {
|
||||
using Catch::Matchers::WithinRel;
|
||||
Slic3r::Print::set_slicing_pipeline_hook_fn(
|
||||
[](Slic3r::Print&, const Slic3r::PrintObject* o, Slic3r::SlicingPipelineStep s){
|
||||
if (s != Slic3r::SlicingPipelineStep::Slice || !o) return;
|
||||
for (Slic3r::Layer* l : const_cast<Slic3r::PrintObject*>(o)->layers())
|
||||
for (Slic3r::LayerRegion* r : l->regions()) {
|
||||
Slic3r::Surfaces in = r->slices.surfaces;
|
||||
for (auto& sf : in) {
|
||||
Slic3r::ExPolygons e = offset_ex(sf.expolygon, -scale_(1.0));
|
||||
if (!e.empty()) sf.expolygon = e.front();
|
||||
}
|
||||
r->slices.set(std::move(in));
|
||||
}
|
||||
});
|
||||
Slic3r::Print print; Slic3r::Model model;
|
||||
auto config = Slic3r::DynamicPrintConfig::full_print_config();
|
||||
config.set_key_value("slicing_pipeline_plugin", new Slic3r::ConfigOptionStrings({"probe"}));
|
||||
init_print({TestMesh::cube_20x20x20}, print, model, config);
|
||||
print.process();
|
||||
const double w_mutated = outer_slices_width(print); // inset applied at the Slice hook
|
||||
|
||||
// The same restore make_perimeters() runs on a perimeter-only re-slice. With the post-hook
|
||||
// backup this reproduces the inset; without it this reverts to the wider original outline.
|
||||
for (Slic3r::Layer* l : print.objects().front()->layers())
|
||||
l->restore_untyped_slices();
|
||||
const double w_restored = outer_slices_width(print);
|
||||
Slic3r::Print::set_slicing_pipeline_hook_fn(nullptr);
|
||||
CHECK_THAT(w_restored, WithinRel(w_mutated, 0.02)); // mutation survived the restore
|
||||
}
|
||||
|
||||
// A plugin can mutate fill_surfaces at the new PrepareInfill seam and have make_fills consume
|
||||
// them, whereas the pre-existing Infill seam fires after the fills are already built (v1 limit).
|
||||
// All three runs register a hook (active path) so the comparison isolates only the mutation.
|
||||
TEST_CASE("fill_surfaces mutation cascades at PrepareInfill but not at Infill", "[slicing_pipeline]") {
|
||||
auto fill_paths = [](bool shrink, Slic3r::SlicingPipelineStep at) {
|
||||
Slic3r::Print print; Slic3r::Model model;
|
||||
auto config = Slic3r::DynamicPrintConfig::full_print_config();
|
||||
config.set_key_value("slicing_pipeline_plugin", new Slic3r::ConfigOptionStrings({"probe"}));
|
||||
Slic3r::Print::set_slicing_pipeline_hook_fn(
|
||||
[shrink, at](Slic3r::Print&, const Slic3r::PrintObject* o, Slic3r::SlicingPipelineStep s){
|
||||
if (!shrink || s != at || !o) return;
|
||||
for (Slic3r::Layer* l : const_cast<Slic3r::PrintObject*>(o)->layers())
|
||||
for (Slic3r::LayerRegion* r : l->regions()) {
|
||||
Slic3r::Surfaces in = r->fill_surfaces.surfaces, out;
|
||||
for (const Slic3r::Surface& sf : in)
|
||||
for (const Slic3r::ExPolygon& e : offset_ex(sf.expolygon, -scale_(3.0))) {
|
||||
Slic3r::Surface s2 = sf; s2.expolygon = e; out.push_back(std::move(s2));
|
||||
}
|
||||
r->fill_surfaces.set(std::move(out));
|
||||
}
|
||||
});
|
||||
init_print({TestMesh::cube_20x20x20}, print, model, config);
|
||||
print.process();
|
||||
size_t n = 0;
|
||||
for (auto* l : print.objects().front()->layers())
|
||||
for (auto* r : l->regions())
|
||||
n += count_leaf_paths(&r->fills);
|
||||
Slic3r::Print::set_slicing_pipeline_hook_fn(nullptr);
|
||||
return n;
|
||||
};
|
||||
using S = Slic3r::SlicingPipelineStep;
|
||||
const size_t base = fill_paths(false, S::PrepareInfill); // active hook, no mutation
|
||||
CHECK(base > 0);
|
||||
CHECK(fill_paths(true, S::PrepareInfill) < base); // mutation before make_fills cascades
|
||||
CHECK(fill_paths(true, S::Infill) == base); // mutation after make_fills is a no-op (v1)
|
||||
}
|
||||
|
||||
// lslices (the layer's merged islands) are built once in slice() and never rebuilt by
|
||||
// make_perimeters, so mutating region slices leaves them stale. set_slices(refresh_lslices=True)
|
||||
// re-derives them via Layer::make_slices(); this C++ analogue proves the mechanism -- without the
|
||||
// refresh the islands keep the original 20mm footprint, with it they track the 18mm inset.
|
||||
TEST_CASE("refreshing lslices after a slice mutation makes islands track the geometry", "[slicing_pipeline]") {
|
||||
auto lslices_width = [](bool refresh) {
|
||||
Slic3r::Print print; Slic3r::Model model;
|
||||
auto config = Slic3r::DynamicPrintConfig::full_print_config();
|
||||
config.set_key_value("slicing_pipeline_plugin", new Slic3r::ConfigOptionStrings({"probe"}));
|
||||
Slic3r::Print::set_slicing_pipeline_hook_fn(
|
||||
[refresh](Slic3r::Print&, const Slic3r::PrintObject* o, Slic3r::SlicingPipelineStep s){
|
||||
if (s != Slic3r::SlicingPipelineStep::Slice || !o) return;
|
||||
for (Slic3r::Layer* l : const_cast<Slic3r::PrintObject*>(o)->layers()) {
|
||||
for (Slic3r::LayerRegion* r : l->regions()) {
|
||||
Slic3r::Surfaces in = r->slices.surfaces;
|
||||
for (auto& sf : in) {
|
||||
Slic3r::ExPolygons e = offset_ex(sf.expolygon, -scale_(1.0));
|
||||
if (!e.empty()) sf.expolygon = e.front();
|
||||
}
|
||||
r->slices.set(std::move(in));
|
||||
}
|
||||
if (refresh) // the load-bearing half of set_slices(refresh_lslices=True)
|
||||
l->make_slices();
|
||||
}
|
||||
});
|
||||
init_print({TestMesh::cube_20x20x20}, print, model, config);
|
||||
print.process();
|
||||
coord_t min_x = 0, max_x = 0; bool seeded = false;
|
||||
for (auto* l : print.objects().front()->layers())
|
||||
for (const Slic3r::ExPolygon& island : l->lslices)
|
||||
for (const Slic3r::Point& p : island.contour.points) {
|
||||
if (!seeded) { min_x = max_x = p.x(); seeded = true; }
|
||||
else { min_x = std::min(min_x, p.x()); max_x = std::max(max_x, p.x()); }
|
||||
}
|
||||
Slic3r::Print::set_slicing_pipeline_hook_fn(nullptr);
|
||||
return (double)(max_x - min_x);
|
||||
};
|
||||
using Catch::Matchers::WithinRel;
|
||||
const double stale = lslices_width(false); // islands keep the original ~20 mm footprint
|
||||
const double fresh = lslices_width(true); // islands track the ~18 mm inset region slices
|
||||
CHECK(fresh < stale);
|
||||
CHECK_THAT(stale, WithinRel((double) scale_(20.0), 0.05)); // stale islands = original outline
|
||||
CHECK_THAT(fresh, WithinRel((double) scale_(18.0), 0.05)); // refreshed islands = inset outline
|
||||
}
|
||||
|
||||
@@ -483,3 +483,57 @@ TEST_CASE("parse_capability_ref rejects malformed input", "[Config][plugin]") {
|
||||
CHECK_FALSE(Slic3r::parse_capability_ref("plugin;;").has_value());
|
||||
CHECK_FALSE(Slic3r::parse_capability_ref("plugin;uuid;").has_value());
|
||||
}
|
||||
|
||||
namespace {
|
||||
// Installs a stub capability resolver that echoes the capability type into the reference, so tests
|
||||
// can assert each plugin-backed option resolved with its own ConfigOptionDef::plugin_type. Resets
|
||||
// the global resolver on teardown -- tests run in random order and other cases assert the
|
||||
// no-resolver behavior (an absent "plugins" manifest).
|
||||
struct PluginResolverFixture {
|
||||
PluginResolverFixture() {
|
||||
ConfigBase::set_resolve_capability_fn([](const std::string& name, const std::string& type) {
|
||||
return name.empty() ? std::string() : name + ";;" + type;
|
||||
});
|
||||
}
|
||||
~PluginResolverFixture() { ConfigBase::set_resolve_capability_fn(nullptr); }
|
||||
};
|
||||
} // namespace
|
||||
|
||||
TEST_CASE_METHOD(PluginResolverFixture,
|
||||
"update_plugin_manifest derives references generically from plugin-backed options",
|
||||
"[Config][plugins]") {
|
||||
// Both scalar (printer_agent) and vector (post_process_plugin, slicing_pipeline_plugin) options
|
||||
// opt in via a non-empty ConfigOptionDef::plugin_type (is_plugin_backed) and are resolved with it
|
||||
// -- there is no hardcoded per-option switch. printer_agent in particular relies on its plugin_type
|
||||
// metadata being wired up (it is edited via a dedicated widget, not the plugin_picker).
|
||||
std::unique_ptr<DynamicPrintConfig> config_ptr(DynamicPrintConfig::new_from_defaults_keys(
|
||||
{"post_process_plugin", "slicing_pipeline_plugin", "printer_agent"}));
|
||||
DynamicPrintConfig config = std::move(*config_ptr);
|
||||
config.option<ConfigOptionStrings>("post_process_plugin", true)->values = {"pp"};
|
||||
config.option<ConfigOptionStrings>("slicing_pipeline_plugin", true)->values = {"sp"};
|
||||
config.option<ConfigOptionString>("printer_agent", true)->value = "agent";
|
||||
|
||||
config.update_plugin_manifest();
|
||||
const std::vector<std::string> manifest = config.option<ConfigOptionStrings>("plugins")->values;
|
||||
|
||||
using Catch::Matchers::VectorContains;
|
||||
REQUIRE_THAT(manifest, VectorContains(std::string("pp;;post-processing")));
|
||||
REQUIRE_THAT(manifest, VectorContains(std::string("sp;;slicing-pipeline")));
|
||||
REQUIRE_THAT(manifest, VectorContains(std::string("agent;;printer-connection")));
|
||||
CHECK(manifest.size() == 3);
|
||||
}
|
||||
|
||||
TEST_CASE_METHOD(PluginResolverFixture,
|
||||
"update_plugin_manifest de-duplicates references and skips unset options",
|
||||
"[Config][plugins]") {
|
||||
std::unique_ptr<DynamicPrintConfig> config_ptr(DynamicPrintConfig::new_from_defaults_keys(
|
||||
{"post_process_plugin", "printer_agent"}));
|
||||
DynamicPrintConfig config = std::move(*config_ptr);
|
||||
config.option<ConfigOptionStrings>("post_process_plugin", true)->values = {"x", "x"}; // duplicate
|
||||
// printer_agent stays at its default empty value -> contributes nothing to the manifest.
|
||||
|
||||
config.update_plugin_manifest();
|
||||
const std::vector<std::string> manifest = config.option<ConfigOptionStrings>("plugins")->values;
|
||||
|
||||
CHECK(manifest == std::vector<std::string>{"x;;post-processing"});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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(®ion, [](void*){}); // no-op: region outlives the view
|
||||
py::object lrv = py::cast(Slic3r::LayerRegionView{ ®ion, 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(®ion, [](void*){});
|
||||
py::object lrv = py::cast(Slic3r::LayerRegionView{ ®ion, 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(®ion, [](void*){}); // no-op: region outlives the view
|
||||
py::object lrv = py::cast(Slic3r::LayerRegionView{ ®ion, 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*>(®ion),
|
||||
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*>(®ion),
|
||||
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(®ion, [](void*){});
|
||||
py::object lrv = py::cast(Slic3r::LayerRegionView{ ®ion, 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*>(®ion),
|
||||
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(®ion2, [](void*){});
|
||||
py::object lrv2 = py::cast(Slic3r::LayerRegionView{ ®ion2, 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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user