mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-07-24 19:32:18 +00:00
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:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user