Files
OrcaSlicer/tests/fff_print/test_slicing_pipeline_hook.cpp
SoftFever f81a24abfb 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.
2026-07-08 00:05:28 +08:00

471 lines
27 KiB
C++

#include <catch2/catch_test_macros.hpp>
#include "libslic3r/PrintConfig.hpp"
using namespace Slic3r;
TEST_CASE("slicing_pipeline_plugin option exists and defaults empty", "[slicing_pipeline]") {
DynamicPrintConfig cfg = DynamicPrintConfig::full_print_config();
const ConfigOptionStrings* opt = cfg.option<ConfigOptionStrings>("slicing_pipeline_plugin");
REQUIRE(opt != nullptr);
CHECK(opt->values.empty());
const ConfigOptionDef* def = cfg.def()->get("slicing_pipeline_plugin");
REQUIRE(def != nullptr);
CHECK(def->plugin_type == "slicing-pipeline");
CHECK(def->is_plugin_backed());
CHECK(def->gui_type == ConfigOptionDef::GUIType::plugin_picker);
}
#include "libslic3r/Print.hpp"
TEST_CASE("slicing pipeline hook setter is a no-op-safe injection", "[slicing_pipeline]") {
int calls = 0;
Slic3r::Print::set_slicing_pipeline_hook_fn(
[&](Slic3r::Print&, const Slic3r::PrintObject*, Slic3r::SlicingPipelineStep){ ++calls; });
Slic3r::Print::set_slicing_pipeline_hook_fn(nullptr); // reset — must be legal
CHECK(calls == 0);
}
#include "test_data.hpp"
#include <vector>
#include <algorithm>
using namespace Slic3r::Test;
TEST_CASE("SlicingPipeline hook fires once per step per object in order", "[slicing_pipeline]") {
struct Call { const Slic3r::PrintObject* obj; Slic3r::SlicingPipelineStep step; };
std::vector<Call> calls;
Slic3r::Print::set_slicing_pipeline_hook_fn(
[&](Slic3r::Print&, const Slic3r::PrintObject* o, Slic3r::SlicingPipelineStep s){ calls.push_back({o, s}); });
Slic3r::Print print; Slic3r::Model model;
Slic3r::DynamicPrintConfig config = Slic3r::DynamicPrintConfig::full_print_config();
config.set_key_value("slicing_pipeline_plugin", new Slic3r::ConfigOptionStrings({"probe"})); // activate
init_print({TestMesh::cube_20x20x20}, print, model, config);
print.process();
Slic3r::Print::set_slicing_pipeline_hook_fn(nullptr);
using S = Slic3r::SlicingPipelineStep;
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);
// print-wide steps carry a null object:
for (const auto& c : calls)
if (c.step == S::WipeTower || c.step == S::SkirtBrim) CHECK(c.obj == nullptr);
// Slice must fire before Perimeters for the same object:
auto idx = [&](S s){ for (size_t i=0;i<calls.size();++i) if (calls[i].step==s) return (int)i; return -1; };
CHECK(idx(S::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:
// (activate=false, hook=none) baseline -- feature entirely absent.
// (activate=false, hook=noop) hook registered but option empty -> gated off, never fires.
// (activate=true, hook=noop) hook ACTIVE and firing at every pipeline seam, mutating
// nothing. This is the real backward-compat claim: an active
// but non-mutating hook must not perturb the output.
auto run = [](bool activate, bool set_noop_hook) {
Slic3r::Print print; Slic3r::Model model;
auto config = Slic3r::DynamicPrintConfig::full_print_config();
// Activating requires BOTH a non-empty option and a registered hook (see Print::apply).
if (activate)
config.set_key_value("slicing_pipeline_plugin", new Slic3r::ConfigOptionStrings({"probe"}));
if (set_noop_hook)
Slic3r::Print::set_slicing_pipeline_hook_fn([](Slic3r::Print&, const Slic3r::PrintObject*, Slic3r::SlicingPipelineStep){});
else
Slic3r::Print::set_slicing_pipeline_hook_fn(nullptr);
init_print({TestMesh::cube_20x20x20}, print, model, config);
std::string g = Slic3r::Test::gcode(print);
Slic3r::Print::set_slicing_pipeline_hook_fn(nullptr);
return g;
};
// Compare only machine-meaningful output (see strip_nondeterministic_gcode_lines): every
// motion/extrusion byte is still compared, so this proves the inactive hook -- and the
// active-but-non-mutating hook -- leave the real toolpath byte-identical.
const std::string baseline = strip_nondeterministic_gcode_lines(run(false, false)); // feature absent
CHECK(strip_nondeterministic_gcode_lines(run(false, true)) == baseline); // gated off: hook never fires
CHECK(strip_nondeterministic_gcode_lines(run(true, true)) == baseline); // active no-op hook fires everywhere, mutates nothing
}
// Fix 4(a): gating negative path. With the option EMPTY the plugin is inactive, so a
// registered hook must NOT fire even once across a full slice (m_pipeline_plugin_active
// stays false in Print::apply). Distinct from the byte-identical test above: this asserts
// the gate directly by counting invocations rather than comparing output.
TEST_CASE("Empty option: registered hook is gated off and never fires", "[slicing_pipeline]") {
int calls = 0;
Slic3r::Print::set_slicing_pipeline_hook_fn(
[&](Slic3r::Print&, const Slic3r::PrintObject*, Slic3r::SlicingPipelineStep){ ++calls; });
Slic3r::Print print; Slic3r::Model model;
auto config = Slic3r::DynamicPrintConfig::full_print_config();
// option left EMPTY -> inactive regardless of the registered hook.
init_print({TestMesh::cube_20x20x20}, print, model, config);
print.process();
Slic3r::Print::set_slicing_pipeline_hook_fn(nullptr);
CHECK(calls == 0);
}
// Fix 4(b): duplicate-skip gating. Two ModelObjects that share one mesh_ptr are detected as
// identical by Print::process()'s is_print_object_the_same(); the second becomes a shared
// (duplicate) object and is NOT re-sliced, so the Slice hook must fire exactly once even
// though there are two print objects. The clone shares mesh_ptr and copies the volume
// transformation/config (ModelVolume copy ctor), which the equality check requires.
TEST_CASE("Duplicate objects share a slice: Slice hook fires exactly once", "[slicing_pipeline]") {
int slice_calls = 0, perim_calls = 0;
Slic3r::Print::set_slicing_pipeline_hook_fn(
[&](Slic3r::Print&, const Slic3r::PrintObject*, Slic3r::SlicingPipelineStep s){
if (s == Slic3r::SlicingPipelineStep::Slice) ++slice_calls;
if (s == Slic3r::SlicingPipelineStep::Perimeters) ++perim_calls;
});
Slic3r::Print print; Slic3r::Model model;
auto config = Slic3r::DynamicPrintConfig::full_print_config();
config.set_key_value("slicing_pipeline_plugin", new Slic3r::ConfigOptionStrings({"probe"})); // activate
// init_print builds one arranged, on-bed cube object (o1).
init_print({TestMesh::cube_20x20x20}, print, model, config);
Slic3r::ModelObject* o1 = model.objects.front();
// Model::add_object(const ModelObject&) force-sets object extruder=1 on the clone; give o1
// the same so the two objects' configs match (is_print_object_the_same compares config).
if (!o1->config.has("extruder"))
o1->config.set_key_value("extruder", new Slic3r::ConfigOptionInt(1));
// Clone o1: shares mesh_ptr and copies the volume transformation + config (genuine duplicate).
Slic3r::ModelObject* o2 = model.add_object(*o1);
// Shift the clone in X so validate() sees no collision (20mm cubes -> 40mm centres = 20mm gap).
for (Slic3r::ModelInstance* inst : o2->instances)
inst->set_offset(inst->get_offset() + Slic3r::Vec3d(40.0, 0.0, 0.0));
print.apply(model, config);
print.validate();
print.set_status_silent();
print.process();
Slic3r::Print::set_slicing_pipeline_hook_fn(nullptr);
REQUIRE(print.objects().size() == 2); // two print objects present...
CHECK(slice_calls == 1); // ...but the duplicate is skipped -> one slice
CHECK(perim_calls == 1); // and one perimeters pass (the sliced object)
}
#include "libslic3r/Layer.hpp" // Layer, LayerRegion (full defs for the cascade hook)
#include "libslic3r/ClipperUtils.hpp" // offset_ex
// The correctness heart of the mutation feature. A C++ hook insets every
// region's `slices` at the Slice boundary (via SurfaceCollection::set with offset
// polygons); because make_perimeters() derives fill_surfaces from slices AFTER the
// Slice hook fires (see Print::process's split slice loop), the downstream
// fill_surfaces area must shrink relative to a baseline (un-inset) run. This proves
// the mutation cascade end-to-end using the same C++ APIs the Python mutators wrap.
TEST_CASE("Mutating slices at the Slice boundary cascades downstream", "[slicing_pipeline]") {
auto fill_area = [](bool inset) {
Slic3r::Print print; Slic3r::Model model;
auto config = Slic3r::DynamicPrintConfig::full_print_config();
config.set_key_value("slicing_pipeline_plugin", new Slic3r::ConfigOptionStrings({"probe"}));
if (inset) Slic3r::Print::set_slicing_pipeline_hook_fn(
[](Slic3r::Print&, const Slic3r::PrintObject* o, Slic3r::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) sf.expolygon = offset_ex(sf.expolygon, -scale_(1.0)).front();
r->slices.set(std::move(in));
}
});
else Slic3r::Print::set_slicing_pipeline_hook_fn(nullptr);
init_print({TestMesh::cube_20x20x20}, print, model, config);
print.process();
double a = 0; for (auto* l : print.objects().front()->layers()) for (auto* r : l->regions()) for (auto& s : r->fill_surfaces.surfaces) a += s.expolygon.area();
Slic3r::Print::set_slicing_pipeline_hook_fn(nullptr);
return a;
};
CHECK(fill_area(true) < fill_area(false));
}
TEST_CASE("Changing slicing_pipeline_plugin invalidates posSlice", "[slicing_pipeline]") {
Slic3r::Print print; Slic3r::Model model;
auto config = Slic3r::DynamicPrintConfig::full_print_config();
init_print({TestMesh::cube_20x20x20}, print, model, config);
print.process();
REQUIRE(print.objects().front()->is_step_done(posSlice));
config.set_key_value("slicing_pipeline_plugin", new Slic3r::ConfigOptionStrings({"probe"}));
print.apply(model, config);
CHECK_FALSE(print.objects().front()->is_step_done(posSlice)); // re-slice required
}
#include <catch2/matchers/catch_matchers_floating_point.hpp>
// §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
}