From 579e58c528cc8ee2aba0a37097f2ec73dc389b50 Mon Sep 17 00:00:00 2001 From: SoftFever Date: Sun, 12 Jul 2026 00:59:13 +0800 Subject: [PATCH] Add Fuzzy Slices sample (fuzzy skin at posSlice) + coverage test 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. --- sandboxes/orca_fuzzy_slices_plugin_any.py | 176 ++++++++++++++++++ .../fff_print/test_slicing_pipeline_hook.cpp | 87 +++++++++ 2 files changed, 263 insertions(+) create mode 100644 sandboxes/orca_fuzzy_slices_plugin_any.py diff --git a/sandboxes/orca_fuzzy_slices_plugin_any.py b/sandboxes/orca_fuzzy_slices_plugin_any.py new file mode 100644 index 0000000000..ecff14ca28 --- /dev/null +++ b/sandboxes/orca_fuzzy_slices_plugin_any.py @@ -0,0 +1,176 @@ +# /// script +# requires-python = ">=3.12" +# +# [tool.orcaslicer.plugin] +# name = "Fuzzy Slices" +# description = "Applies the fuzzy-skin jitter to the slice contours themselves at the Slice boundary (demo)." +# author = "OrcaSlicer" +# version = "0.01" +# type = "slicing-pipeline" +# +# [tool.orcaslicer.plugin.settings] +# thickness_mm = "0.3" +# point_distance_mm = "0.8" +# fuzz_holes = "1" +# skip_first_layer = "1" +# /// +"""Fuzzy Slices -- the fuzzy-skin effect applied at slice time. + +Orca's built-in fuzzy skin perturbs the outer-wall EXTRUSION PATHS during +perimeter generation, so only the printed wall is fuzzy. This sample instead +perturbs the sliced outline itself at Step.posSlice, using the same +resample-and-jitter algorithm as libslic3r's fuzzy_polyline (uniform noise): +walk each ring, drop a new vertex every 3/4..5/4 * point_distance_mm of +perimeter, and displace it by a random +/- thickness_mm along the segment +normal. Because the slice contour itself changes, everything derived from it +(perimeters, infill boundaries, overhang detection) inherits the noise and +the fuzz shows in the toolpath preview. + +Mechanically this demonstrates the count-CHANGING mutation idiom: a fuzzed +ring has a different vertex count, so it is rebuilt as a fresh +orca.host.Polygon (append() per vertex) and written back by assigning +ex.contour / calling ex.set_holes() on the live ExPolygon. The in-place edit +persists through the surface collection and leaves surface types untouched; +layer.make_slices() then re-derives the merged islands. Compare the Inset +sample (whole-surface offset + slices.set) and Twistify (count-preserving +in-place transforms). + +The jitter preserves vertex order, so the contour keeps its CCW winding +(contour assignment does not re-normalize); set_holes() re-normalizes holes +to CW. The RNG is seeded per layer, so re-slicing reproduces the same fuzz. +The first layer is skipped by default for bed adhesion (like the built-in +fuzzy_skin_first_layer = off). No numpy required; for very dense models the +Polygon.as_array()/set_points numpy path would be the faster route. +""" +import math +import random + +import orca + +_DEFAULTS = { + "thickness_mm": 0.3, # max normal displacement (built-in fuzzy_skin_thickness default) + "point_distance_mm": 0.8, # target resample spacing (built-in fuzzy_skin_point_dist default) + "fuzz_holes": 1.0, # nonzero: jitter hole rings too, not just the outer contour + "skip_first_layer": 1.0, # nonzero: keep layer 0 crisp for bed adhesion +} + + +def _params(ctx): + try: + src = dict(ctx.params) + except (AttributeError, TypeError): + src = {} + out = {} + for key, default in _DEFAULTS.items(): + try: + out[key] = float(src[key]) + except (KeyError, TypeError, ValueError): + out[key] = default + return out + + +def _fuzz_ring(points, thickness, min_dist, rand_range, rng): + """Resample + jitter one closed ring (list of Point refs). + + Returns a new orca.host.Polygon, or None to keep the original ring (too + small to resample). Mirrors libslic3r's fuzzy_polyline: new vertices every + min_dist + rand*rand_range of arc length, each displaced +/-thickness + along the segment's left-hand normal. + """ + if len(points) < 3: + return None + out = [] + dist_left_over = rng.random() * (min_dist / 2.0) # arc length before the first new vertex + p0x = float(points[-1].x) + p0y = float(points[-1].y) + for p1 in points: + p1x = float(p1.x) + p1y = float(p1.y) + dx = p1x - p0x + dy = p1y - p0y + seg = math.hypot(dx, dy) + if seg > 0.0: + d = dist_left_over + while d < seg: + t = d / seg + r = (rng.random() * 2.0 - 1.0) * thickness + out.append((p0x + dx * t - dy / seg * r, + p0y + dy * t + dx / seg * r)) + d += min_dist + rng.random() * rand_range + dist_left_over = d - seg # carry the remainder into the next segment + p0x, p0y = p1x, p1y + if len(out) < 3: + return None # ring shorter than ~2 resample steps: leave it crisp + poly = orca.host.Polygon() + for x, y in out: + poly.append(orca.host.Point(int(round(x)), int(round(y)))) + return poly + + +class FuzzySlices(orca.slicing.SlicingPipelineCapabilityBase): + def get_name(self): + return "Fuzzy Slices" + + def execute(self, ctx): + if ctx.step != orca.slicing.Step.posSlice or ctx.object is None: + return orca.ExecutionResult.success() + + p = _params(ctx) + if p["thickness_mm"] <= 0.0 or p["point_distance_mm"] <= 0.0: + return orca.ExecutionResult.success("Fuzzy Slices: zero thickness/point distance, nothing to do") + + # Millimeters -> scaled integer units via the *live* scale (never hardcode 1e6). + mm = 1.0 / orca.slicing.unscale(1) + thickness = p["thickness_mm"] * mm + # The spacing between new vertices varies between 3/4 and 5/4 the supplied + # value, same as the built-in fuzzy skin. + min_dist = p["point_distance_mm"] * mm * 0.75 + rand_range = p["point_distance_mm"] * mm * 0.5 + fuzz_holes = p["fuzz_holes"] != 0.0 + first = 1 if p["skip_first_layer"] != 0.0 else 0 + + rings = 0 + layers_touched = 0 + for idx, layer in enumerate(ctx.object.layers()): + if ctx.cancelled(): + break + if idx < first: + continue + rng = random.Random(0x5EED + idx) # per-layer seed: re-slices reproduce the same fuzz + edited = False + for region in layer.regions(): + for surface in region.slices.surfaces: + ex = surface.expolygon + contour = _fuzz_ring(ex.contour.points, thickness, min_dist, rand_range, rng) + if contour is not None: + ex.contour = contour # vertex order preserved, so CCW winding survives + rings += 1 + edited = True + if fuzz_holes and ex.holes: + new_holes = [] + changed = False + for hole in ex.holes: + fuzzed = _fuzz_ring(hole.points, thickness, min_dist, rand_range, rng) + if fuzzed is not None: + new_holes.append(fuzzed) + changed = True + rings += 1 + else: + new_holes.append(hole) # untouched rings pass through unchanged + if changed: + ex.set_holes(new_holes) # copies each ring and re-normalizes to CW + edited = True + if edited: + # Re-derive the merged islands from the fuzzed region slices. + layer.make_slices() + layers_touched += 1 + + return orca.ExecutionResult.success( + f"Fuzzy Slices: fuzzed {rings} ring(s) on {layers_touched} layer(s) " + f"(+/-{p['thickness_mm']} mm @ {p['point_distance_mm']} mm)") + + +@orca.plugin +class FuzzySlicesPackage(orca.base): + def register_capabilities(self): + orca.register_capability(FuzzySlices) diff --git a/tests/fff_print/test_slicing_pipeline_hook.cpp b/tests/fff_print/test_slicing_pipeline_hook.cpp index ceb17108d1..66f7813503 100644 --- a/tests/fff_print/test_slicing_pipeline_hook.cpp +++ b/tests/fff_print/test_slicing_pipeline_hook.cpp @@ -470,3 +470,90 @@ TEST_CASE("refreshing lslices after a slice mutation makes islands track the geo 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 // 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 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(); + 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() + 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(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)); +}