mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-07-16 07:22:10 +00:00
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.
224 lines
9.9 KiB
Python
224 lines
9.9 KiB
Python
# /// 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)
|