mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-07-29 13:52:07 +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,81 +1,35 @@
|
||||
# /// 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"
|
||||
# version = "0.02"
|
||||
# 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.
|
||||
sliced surface by INSET_MM using a real polygon offset (ExPolygon.offset) and
|
||||
writes the result back with SurfaceCollection.set(). After the per-region edits,
|
||||
layer.make_slices() re-derives the layer's merged islands (lslices) so
|
||||
overhang/bridge detection, skirt/brim and support stay coherent with the inset
|
||||
geometry. At Step.Slice the split slice loop runs make_perimeters() right after
|
||||
the hook, so the change cascades into perimeters, infill and the final G-code
|
||||
-- the toolpath preview 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.
|
||||
Unlike the old axis-aligned demo, ExPolygon.offset() is a correct inward offset
|
||||
for any contour (it is Clipper under the hood), and it naturally handles holes.
|
||||
A surface may split into several islands or vanish when shrunk; both are handled.
|
||||
|
||||
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.
|
||||
No numpy required: the whole edit is expressed with the host geometry classes.
|
||||
"""
|
||||
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"
|
||||
@@ -84,32 +38,41 @@ class InsetEverySlice(orca.slicing.SlicingPipelineCapabilityBase):
|
||||
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.
|
||||
# Millimeters -> scaled integer units via the *live* scale (never hardcode 1e6).
|
||||
inset_scaled = int(round(INSET_MM / orca.slicing.unscale(1)))
|
||||
|
||||
regions_touched = 0
|
||||
for layer in ctx.object.layers():
|
||||
if ctx.cancelled():
|
||||
break
|
||||
layer_touched = False
|
||||
for region in layer.regions():
|
||||
surfaces = region.slices.surfaces
|
||||
if not surfaces:
|
||||
continue # an empty region has nothing to inset
|
||||
continue
|
||||
|
||||
new_surfaces = []
|
||||
# Group the inward-offset geometry by surface type so each type is
|
||||
# preserved when written back (set() tags all its expolygons one type).
|
||||
by_type = {}
|
||||
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]])
|
||||
shrunk = surface.expolygon.offset(-inset_scaled) # [ExPolygon], may be empty
|
||||
if shrunk:
|
||||
by_type.setdefault(surface.surface_type, []).extend(shrunk)
|
||||
|
||||
region.set_slices(new_surfaces)
|
||||
if not by_type:
|
||||
continue # every surface collapsed: leave the region untouched this demo
|
||||
|
||||
# Rebuild the collection type-by-type: first set(), then append() the rest.
|
||||
items = list(by_type.items())
|
||||
first_type, first_expolys = items[0]
|
||||
region.slices.set(first_expolys, first_type)
|
||||
for st, expolys in items[1:]:
|
||||
region.slices.append(expolys, st)
|
||||
regions_touched += 1
|
||||
layer_touched = True
|
||||
if layer_touched:
|
||||
# Re-derive the merged islands from the inset region slices.
|
||||
layer.make_slices()
|
||||
|
||||
return orca.ExecutionResult.success(f"inset applied to {regions_touched} region(s)")
|
||||
|
||||
|
||||
@@ -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