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:
SoftFever
2026-07-08 00:05:28 +08:00
parent aafcccc83c
commit f81a24abfb
29 changed files with 1718 additions and 962 deletions

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

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