mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-07-15 15:03:49 +00:00
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:
176
sandboxes/orca_fuzzy_slices_plugin_any.py
Normal file
176
sandboxes/orca_fuzzy_slices_plugin_any.py
Normal 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)
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user