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