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.
This commit is contained in:
SoftFever
2026-07-12 00:59:13 +08:00
parent 03094df10e
commit 579e58c528
2 changed files with 263 additions and 0 deletions

View File

@@ -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)

View File

@@ -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 <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));
}