feat(plugin)!: faithful mutable geometry bindings; edit slices via the class API

Replaces the plugin-only set_slices/set_fill_surfaces/set_lslices mutators with a
faithful, mutable binding of the core geometry types, so a plugin edits the slicing
graph through the same object model the C++ code uses.

- Point, Polygon, ExPolygon, Surface and SurfaceCollection gain constructors,
  writable accessors (contour/holes, set/append/clear, filter_by_type), transforms
  (rotate/scale/translate), boolean ops and offset. Polygon exposes a zero-copy
  writable numpy view via a make_writable_rows helper.
- LayerRegion.slices/fill_surfaces stay read-only refs but are now live,
  in-place-editable SurfaceCollections; Layer.make_slices() re-derives the islands
  and refreshes lslice bounding boxes.
- Rewrites the Inset and Twistify samples on the new API (in-place ExPolygon
  transforms, ExPolygon.offset, SurfaceCollection.set), dropping their numpy
  dependency; each touched layer calls make_slices() so downstream steps see the
  edited footprint. Adds tests covering in-place edits through a live collection.

BREAKING CHANGE: set_slices/set_fill_surfaces/set_lslices and the internal
parse_expolygon(_list)/surfaces_from_py helpers are removed. Plugins mutate through
the class API (SurfaceCollection.set/append/clear, Polygon.set_points/append,
ExPolygon.set_holes) instead.
This commit is contained in:
SoftFever
2026-07-08 15:04:40 +08:00
parent fd2a489980
commit 11dd078f64
7 changed files with 558 additions and 484 deletions

View File

@@ -1,12 +1,11 @@
# /// 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"
# version = "0.02"
# type = "slicing-pipeline"
#
# [tool.orcaslicer.plugin.settings]
@@ -18,68 +17,40 @@
# ///
"""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.
At Step.Slice, every layer's sliced surfaces are transformed by a similarity
about the object's bounding-box center as a function of Z -- edited IN PLACE
through the host geometry classes (ExPolygon.rotate/scale/translate). Each
surface is rotated about the center, then (if tapering) translated to the
origin, uniformly scaled, and translated back, so the taper stays centered on
the object instead of drifting toward the coordinate origin. An optional X
wobble is applied last. After the per-region edits, layer.make_slices()
re-derives the layer's merged islands so overhang/bridge/skirt/support stay
coherent. The split slice loop runs make_perimeters() right after the hook, so
the transform cascades into perimeters, infill, and the final G-code -- the
preview corkscrews and the print keeps correct walls/infill/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).
Because we edit geometry in place, surface types are preserved automatically
(no per-surface type carry needed), and no numpy is required --
rotate/scale/translate are host methods. Parameters come from ctx.params (the
settings table above). The first object layer is untouched (z_rel = 0), so bed
adhesion is unaffected.
"""
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
"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,
}
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
src = dict(ctx.params)
except (AttributeError, TypeError):
src = {}
out = {}
@@ -96,58 +67,13 @@ def _is_identity(p):
def _layer_params(z_rel, mm_to_scaled, p):
"""(cos, sin, scale, x_offset_scaled) for one layer. Exact identity at z_rel == 0."""
"""(angle_rad, 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]
return theta, s, ox
class Twistify(orca.slicing.SlicingPipelineCapabilityBase):
@@ -155,27 +81,27 @@ class Twistify(orca.slicing.SlicingPipelineCapabilityBase):
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.
# Twist/taper axis = the object's bounding-box center (scaled coords, same frame
# as the slice polygons), so each object on the plate transforms about its own
# center. Keep the float center for translate-to-origin/back around scale(), and
# a rounded-to-Point center for rotate() (which takes an integer Point).
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
center = orca.host.Point(int(round(cx)), int(round(cy)))
z0 = float(layers[0].print_z) # z_rel = 0 on the first layer -> footprint untouched
layers_touched = 0
@@ -183,32 +109,29 @@ class Twistify(orca.slicing.SlicingPipelineCapabilityBase):
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
theta, s, ox = _layer_params(z_rel, mm_to_scaled, p)
if theta == 0.0 and s == 1.0 and ox == 0.0:
continue # exact identity (always the first layer)
edited = False
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
for surface in region.slices.surfaces:
ex = surface.expolygon
ex.rotate(theta, center) # rotate about the object center (in place)
if s != 1.0:
# scale() scales about the coordinate ORIGIN, so re-center the
# geometry on the origin first and translate back after, making
# this a true similarity transform about the object's center.
ex.translate(-cx, -cy)
ex.scale(s)
ex.translate(cx, cy)
if ox != 0.0:
ex.translate(ox, 0.0) # wobble in X
edited = True
if edited:
# Re-derive the merged islands from the twisted region slices.
layer.make_slices()
layers_touched += 1
name = ctx.object.model_object().name or "object"
return orca.ExecutionResult.success(