mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-07-15 23:12:08 +00:00
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.
This commit is contained in:
120
sandboxes/orca_inset_plugin_any.py
Normal file
120
sandboxes/orca_inset_plugin_any.py
Normal file
@@ -0,0 +1,120 @@
|
||||
# /// script
|
||||
# requires-python = ">=3.12"
|
||||
# dependencies = ["numpy"]
|
||||
#
|
||||
# [tool.orcaslicer.plugin]
|
||||
# name = "Inset Every Slice"
|
||||
# description = "Insets every layer's slices by 1mm at the Slice boundary (demo)."
|
||||
# author = "OrcaSlicer"
|
||||
# version = "0.01"
|
||||
# type = "slicing-pipeline"
|
||||
# ///
|
||||
"""Inset Every Slice -- a small, WORKING SlicingPipeline sample plugin.
|
||||
|
||||
At Step.Slice, for every layer/region of the sliced object, this shrinks each
|
||||
sliced surface's outer contour by INSET_MM and writes the result back with
|
||||
LayerRegion.set_slices(). set_slices() at Step.Slice is the fully-supported
|
||||
mutation-cascade entry point (see docs/plugins/slicing_pipeline_plugin.md next
|
||||
to this file): the split slice loop runs make_perimeters() right after the
|
||||
Slice hook, so the change cascades into perimeters, infill and the final
|
||||
G-code -- the toolpath preview visibly shrinks.
|
||||
|
||||
This is a *teaching* sample, not a production-grade offset:
|
||||
- The inset is a per-axis contraction toward the contour's bounding-box
|
||||
center: each vertex coordinate is pulled toward the center by up to
|
||||
INSET_MM, independently on X and Y, and never crosses the center. That is
|
||||
an exact inward offset for a convex, axis-aligned contour (e.g. the square
|
||||
cross-section of a plain cube) but it is NOT a general polygon offset -- it
|
||||
will distort a rotated or non-rectangular contour. A real plugin should
|
||||
reach for a proper offset library (e.g. Shapely's buffer(), or Clipper)
|
||||
instead.
|
||||
- Holes are passed through unchanged. A correct hole inset needs an
|
||||
*outward* offset plus re-validating containment against the shrunk outer
|
||||
contour, which is more than a short demo should attempt.
|
||||
- Degenerate contours (fewer than 3 points, or a shape too small for a 1mm
|
||||
inset without inverting) are left unmodified rather than mutated into
|
||||
garbage.
|
||||
|
||||
numpy is declared as a dependency: the geometry accessors hand back zero-copy
|
||||
int64 ndarrays, and set_slices() requires genuine ndarrays back (not plain lists),
|
||||
so building the modified contour needs numpy.
|
||||
"""
|
||||
import numpy as np
|
||||
import orca
|
||||
|
||||
INSET_MM = 1.0
|
||||
|
||||
|
||||
def _pull(value, center, amount):
|
||||
"""Move `value` toward `center` by up to `amount`, never crossing it."""
|
||||
if value > center:
|
||||
return max(center, value - amount)
|
||||
if value < center:
|
||||
return min(center, value + amount)
|
||||
return center
|
||||
|
||||
|
||||
def _inset_contour(contour, inset_scaled):
|
||||
"""Axis-aligned inward contraction of an (N,2) int64 contour.
|
||||
|
||||
Returns a new (N,2) int64 array, or None if the contour is degenerate
|
||||
(fewer than 3 points) or too small for `inset_scaled` without inverting.
|
||||
"""
|
||||
if contour.shape[0] < 3:
|
||||
return None
|
||||
xs, ys = contour[:, 0], contour[:, 1]
|
||||
min_x, max_x = int(xs.min()), int(xs.max())
|
||||
min_y, max_y = int(ys.min()), int(ys.max())
|
||||
if (max_x - min_x) <= 2 * inset_scaled or (max_y - min_y) <= 2 * inset_scaled:
|
||||
return None # shape too small on at least one axis: inset would invert it
|
||||
cx, cy = (min_x + max_x) // 2, (min_y + max_y) // 2
|
||||
|
||||
out = contour.copy()
|
||||
for i in range(contour.shape[0]):
|
||||
out[i, 0] = _pull(int(contour[i, 0]), cx, inset_scaled)
|
||||
out[i, 1] = _pull(int(contour[i, 1]), cy, inset_scaled)
|
||||
return out
|
||||
|
||||
|
||||
class InsetEverySlice(orca.slicing.SlicingPipelineCapabilityBase):
|
||||
def get_name(self):
|
||||
return "Inset Every Slice"
|
||||
|
||||
def execute(self, ctx):
|
||||
if ctx.step != orca.slicing.Step.Slice or ctx.object is None:
|
||||
return orca.ExecutionResult.success()
|
||||
|
||||
# Millimeters -> scaled integer units via the *live* scale. SCALING_FACTOR
|
||||
# is not a fixed constant (large beds use a coarser scale), so this must be
|
||||
# read at call time -- never hardcode 1e6/1e-6.
|
||||
inset_scaled = int(round(INSET_MM / orca.slicing.unscale(1)))
|
||||
|
||||
regions_touched = 0
|
||||
for layer in ctx.object.layers():
|
||||
if ctx.cancelled():
|
||||
break
|
||||
for region in layer.regions():
|
||||
surfaces = region.slices.surfaces
|
||||
if not surfaces:
|
||||
continue # an empty region has nothing to inset
|
||||
|
||||
new_surfaces = []
|
||||
for surface in surfaces:
|
||||
expoly = surface.expolygon
|
||||
contour = expoly.contour.points()
|
||||
inset = _inset_contour(contour, inset_scaled)
|
||||
if inset is not None:
|
||||
contour = inset
|
||||
# Holes are passed through unchanged -- see module docstring.
|
||||
new_surfaces.append([contour, [h.points() for h in expoly.holes]])
|
||||
|
||||
region.set_slices(new_surfaces)
|
||||
regions_touched += 1
|
||||
|
||||
return orca.ExecutionResult.success(f"inset applied to {regions_touched} region(s)")
|
||||
|
||||
|
||||
@orca.plugin
|
||||
class InsetEverySlicePackage(orca.base):
|
||||
def register_capabilities(self):
|
||||
orca.register_capability(InsetEverySlice)
|
||||
223
sandboxes/orca_twistify_plugin_example_any.py
Normal file
223
sandboxes/orca_twistify_plugin_example_any.py
Normal file
@@ -0,0 +1,223 @@
|
||||
# /// script
|
||||
# requires-python = ">=3.12"
|
||||
# dependencies = ["numpy"]
|
||||
#
|
||||
# [tool.orcaslicer.plugin]
|
||||
# name = "Twistify"
|
||||
# description = "Twists, tapers, and wobbles every layer's slice polygons as a function of Z (demo)."
|
||||
# author = "OrcaSlicer"
|
||||
# version = "0.01"
|
||||
# type = "slicing-pipeline"
|
||||
#
|
||||
# [tool.orcaslicer.plugin.settings]
|
||||
# twist_deg_per_mm = "1.0"
|
||||
# taper_per_mm = "0.0"
|
||||
# wobble_ampl_mm = "0.0"
|
||||
# wobble_period_mm = "20.0"
|
||||
# min_scale = "0.05"
|
||||
# ///
|
||||
"""Twistify -- twist/taper/wobble any model at slice time.
|
||||
|
||||
At Step.Slice (the one fully-supported mutation seam -- see
|
||||
docs/plugins/slicing_pipeline_plugin.md), every layer's sliced surfaces are
|
||||
rotated, uniformly scaled, and optionally swayed about the object's center as a
|
||||
function of Z, then written back with LayerRegion.set_slices(). The
|
||||
dedicated slice loop runs make_perimeters() right after this hook, so the
|
||||
transform cascades into perimeters, infill, and the final G-code -- the toolpath
|
||||
preview visibly corkscrews, and unlike G-code post-processing hacks the printed
|
||||
part keeps correct multi-wall perimeters, infill, and flow.
|
||||
|
||||
Parameters come from ctx.params -- the [tool.orcaslicer.plugin.settings] table in
|
||||
the PEP-723 header above. Edit them there (and re-slice) to change the effect; no
|
||||
code edit or plugin reload is needed. Recipes: twisted vase
|
||||
(twist 1.0), tapered spire (twist 0.3, taper -0.006), wobbling tower
|
||||
(twist 0, wobble_ampl 0.8).
|
||||
|
||||
The transform uses three of the gap-closing APIs so the plugin stays small and
|
||||
correct:
|
||||
* ctx.object.bounding_box() gives the twist axis (each object twists about its
|
||||
own center) -- no footprint reconstruction.
|
||||
* set_slices(refresh_lslices=True) re-derives the layer's merged islands, so
|
||||
overhang/bridge/skirt/support stay coherent -- no manual set_lslices().
|
||||
* a per-entry SurfaceType (third set_slices element) preserves each surface's
|
||||
type -- no replace-then-reassign-surface_type two-step.
|
||||
Because the Slice hook re-snapshots raw_slices afterward, the twist also survives
|
||||
a later perimeter-only re-slice (e.g. changing wall_loops) instead of reverting.
|
||||
|
||||
numpy is REQUIRED at slice time (declared above): the host's geometry accessors
|
||||
return numpy arrays. The pure-Python fallback in _transform_ring exists only so this
|
||||
module still imports on numpy-less interpreters (the unit-test harness); it is
|
||||
unreachable in production. Outputs are built by .copy()-ing the host's zero-copy
|
||||
read arrays (dtype/shape inherited -- int64 on every platform, immune to Windows'
|
||||
numpy int32 default), never constructed from scratch.
|
||||
|
||||
Physical-print caveats: keep the twist modest (horizontal shift per layer at the
|
||||
part's outer radius should stay under ~1.4x layer height) or the real print grows
|
||||
unsupported overhangs -- the preview looks great regardless. The first object
|
||||
layer is untouched (z_rel = 0), so bed adhesion is unaffected. Twists EVERY
|
||||
object on the plate (each about its own center).
|
||||
"""
|
||||
import math
|
||||
|
||||
import orca
|
||||
|
||||
try: # required in production; guard keeps module importable in the test harness
|
||||
import numpy as _np
|
||||
except ImportError:
|
||||
_np = None
|
||||
|
||||
# Fallback defaults, overridden per-slice by ctx.params (the settings table in the header).
|
||||
_DEFAULTS = {
|
||||
"twist_deg_per_mm": 1.0, # signed twist rate; 1 deg/mm corkscrews a 100mm cube by 100 deg
|
||||
"taper_per_mm": 0.0, # relative XY scale change per mm of Z (-0.004 = shrink 0.4%/mm)
|
||||
"wobble_ampl_mm": 0.0, # X sway amplitude in mm (0 disables)
|
||||
"wobble_period_mm": 20.0, # full sway period in mm of Z
|
||||
"min_scale": 0.05, # taper clamp: polygons shrink but can never collapse to a point
|
||||
}
|
||||
|
||||
|
||||
def _params(ctx):
|
||||
"""Resolve parameters from ctx.params (string values), falling back to _DEFAULTS."""
|
||||
try:
|
||||
src = dict(ctx.params) # ctx.params is a read-only dict of str -> str
|
||||
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 _is_identity(p):
|
||||
return p["twist_deg_per_mm"] == 0.0 and p["taper_per_mm"] == 0.0 and p["wobble_ampl_mm"] == 0.0
|
||||
|
||||
|
||||
def _layer_params(z_rel, mm_to_scaled, p):
|
||||
"""(cos, sin, scale, x_offset_scaled) for one layer. Exact identity at z_rel == 0."""
|
||||
theta = math.radians(p["twist_deg_per_mm"] * z_rel)
|
||||
s = max(p["min_scale"], 1.0 + p["taper_per_mm"] * z_rel)
|
||||
ox = 0.0
|
||||
if p["wobble_ampl_mm"] != 0.0 and p["wobble_period_mm"] > 0.0:
|
||||
ox = p["wobble_ampl_mm"] * math.sin(2.0 * math.pi * z_rel / p["wobble_period_mm"]) * mm_to_scaled
|
||||
return math.cos(theta), math.sin(theta), s, ox
|
||||
|
||||
|
||||
def _transform_ring(ring, cos_t, sin_t, s, cx, cy, ox):
|
||||
"""Similarity-transform one int64 (N,2) ring about (cx, cy), then shift X by ox.
|
||||
|
||||
Returns a NEW writable int64 (N,2) ndarray with the same point count, or None
|
||||
if the ring is degenerate (< 3 points; the host's parse_polygon would reject it).
|
||||
Rotation + uniform positive scale preserves orientation and hole containment and
|
||||
cannot self-intersect; the host re-normalizes winding on write-back anyway.
|
||||
"""
|
||||
n = ring.shape[0]
|
||||
if n < 3:
|
||||
return None
|
||||
if _np is not None: # production path (numpy is a declared dependency)
|
||||
pts = ring.astype(_np.float64)
|
||||
dx = pts[:, 0] - cx
|
||||
dy = pts[:, 1] - cy
|
||||
out = _np.empty_like(ring) # inherits int64 -- immune to Windows' int32 default
|
||||
out[:, 0] = _np.rint((dx * cos_t - dy * sin_t) * s + cx + ox)
|
||||
out[:, 1] = _np.rint((dx * sin_t + dy * cos_t) * s + cy)
|
||||
return out
|
||||
out = ring.copy() # defensive fallback; unreachable when the host supplied `ring`
|
||||
for i in range(n):
|
||||
dx = float(ring[i, 0]) - cx
|
||||
dy = float(ring[i, 1]) - cy
|
||||
out[i, 0] = int(round((dx * cos_t - dy * sin_t) * s + cx + ox))
|
||||
out[i, 1] = int(round((dx * sin_t + dy * cos_t) * s + cy))
|
||||
return out
|
||||
|
||||
|
||||
def _transform_expoly(expoly, cos_t, sin_t, s, cx, cy, ox):
|
||||
"""ExPolygon -> [contour, [holes...]] entry for set_slices.
|
||||
|
||||
Returns None if the outer contour is degenerate; degenerate holes are dropped
|
||||
(a <3-point ring is meaningless and would make the host raise ValueError).
|
||||
"""
|
||||
contour = _transform_ring(expoly.contour.points(), cos_t, sin_t, s, cx, cy, ox)
|
||||
if contour is None:
|
||||
return None
|
||||
holes = []
|
||||
for hole in expoly.holes:
|
||||
th = _transform_ring(hole.points(), cos_t, sin_t, s, cx, cy, ox)
|
||||
if th is not None:
|
||||
holes.append(th)
|
||||
return [contour, holes]
|
||||
|
||||
|
||||
class Twistify(orca.slicing.SlicingPipelineCapabilityBase):
|
||||
def get_name(self):
|
||||
return "Twistify"
|
||||
|
||||
def execute(self, ctx):
|
||||
# Standard guard: Step.Slice is per-object and the only fully-wired mutation seam.
|
||||
if ctx.step != orca.slicing.Step.Slice or ctx.object is None:
|
||||
return orca.ExecutionResult.success()
|
||||
|
||||
p = _params(ctx)
|
||||
# Exact no-op parameters -> leave the pipeline byte-identical by construction.
|
||||
if _is_identity(p):
|
||||
return orca.ExecutionResult.success("Twistify: identity parameters, nothing to do")
|
||||
|
||||
# Millimeters -> scaled units via the LIVE scale (never hardcode 1e6/1e-6).
|
||||
mm_to_scaled = 1.0 / orca.slicing.unscale(1)
|
||||
|
||||
layers = ctx.object.layers()
|
||||
if not layers:
|
||||
return orca.ExecutionResult.success("Twistify: object has no layers")
|
||||
|
||||
# Twist axis = the object's bounding-box center (scaled coords, same frame as the
|
||||
# slice polygons), so each object on the plate twists about its own center.
|
||||
min_x, min_y, max_x, max_y = ctx.object.bounding_box()
|
||||
cx = (min_x + max_x) / 2.0
|
||||
cy = (min_y + max_y) / 2.0
|
||||
z0 = float(layers[0].print_z) # z_rel = 0 on the first layer -> footprint untouched
|
||||
|
||||
layers_touched = 0
|
||||
for layer in layers:
|
||||
if ctx.cancelled():
|
||||
break
|
||||
z_rel = float(layer.print_z) - z0
|
||||
cos_t, sin_t, s, ox = _layer_params(z_rel, mm_to_scaled, p)
|
||||
if cos_t == 1.0 and sin_t == 0.0 and s == 1.0 and ox == 0.0:
|
||||
continue # exact identity (always the first layer): skip set_slices entirely
|
||||
|
||||
for region in layer.regions():
|
||||
surfaces = region.slices.surfaces
|
||||
if not surfaces:
|
||||
continue # set_slices() rejects nothing now, but an empty region has nothing to do
|
||||
new_surfaces = []
|
||||
for surface in surfaces:
|
||||
entry = _transform_expoly(surface.expolygon, cos_t, sin_t, s, cx, cy, ox)
|
||||
if entry is None:
|
||||
continue # degenerate outer contour: drop this surface
|
||||
# Carry this surface's type as the third entry element so it is preserved
|
||||
# per surface. The plain enum value is read out BEFORE set_slices, since the
|
||||
# Surface reference dangles once the collection is replaced.
|
||||
entry.append(surface.surface_type)
|
||||
new_surfaces.append(entry)
|
||||
if not new_surfaces:
|
||||
continue # every surface degenerate: leave the region untouched
|
||||
# refresh_lslices=True re-derives the layer's merged islands + bbox cache from
|
||||
# the twisted slices, so overhang/bridge detection and brim/skirt/support stay
|
||||
# coherent -- no separate Layer.set_lslices() pass needed.
|
||||
region.set_slices(new_surfaces, refresh_lslices=True)
|
||||
|
||||
layers_touched += 1
|
||||
|
||||
name = ctx.object.model_object().name or "object"
|
||||
return orca.ExecutionResult.success(
|
||||
f"Twistify: transformed {layers_touched} layer(s) of '{name}' "
|
||||
f"(twist {p['twist_deg_per_mm']} deg/mm, taper {p['taper_per_mm']}/mm, "
|
||||
f"wobble {p['wobble_ampl_mm']} mm)")
|
||||
|
||||
|
||||
@orca.plugin
|
||||
class TwistifyPackage(orca.base):
|
||||
def register_capabilities(self):
|
||||
orca.register_capability(Twistify)
|
||||
Reference in New Issue
Block a user