Files
OrcaSlicer/docs/plugins/slicing_pipeline_plugin.md
SoftFever b0bacdd00b feat(plugin): add the slicing-pipeline plugin capability
Introduces a plugin capability that runs Python at the seams of Print::process(),
letting a plugin read and rewrite slicing state as it is computed.

- New slicing_pipeline_plugin config option; selected plugin refs are serialized
  into the print manifest.
- Print gains an injectable hook fired at each pipeline step (posSlice,
  posPerimeters, posInfill, ...). It is a no-op when unset, fires only on genuine
  (re)computation, and never on the use-cache path.
- orca.slicing submodule: SlicingPipelineCapabilityBase plus a trampoline and a
  Step enum. Capabilities read the live graph through zero-copy int64 numpy views
  (contour/holes geometry with unscaled coordinates, flattened toolpath data) and
  edit it through 2D-geometry mutators with cache-invariant refresh.
- GUI dispatcher runs capabilities during slicing under the GIL, turns plugin
  errors into slicing errors, honors cancellation, and adds the plugin picker.
- Ships the InsetEverySlice sample plugin and binding/hook tests.
2026-07-04 04:33:20 +08:00

8.6 KiB

Slicing Pipeline Plugins

This note is a companion to the general Python plugin documentation (see the OrcaSlicer wiki for plugin_development.md / plugin_system.md / plugin_audit_hook.md — the plugin-doc set was migrated there and no longer lives under docs/ in this repository). It covers only what is specific to the SlicingPipeline capability: orca.slicing.SlicingPipelineCapabilityBase. Read it alongside the worked sample at resources/orca_plugins/InsetEverySlice.py.

A SlicingPipeline capability is invoked by OrcaSlicer at several seams inside Print::process(), on the slicing worker thread, so it can read — and in one case, mutate — the intermediate data the slicer produces between the raw mesh and the final G-code. It is research/experimental: the read graph is broad, but only one mutation is fully wired through to the toolpath output today.

class MyCapability(orca.slicing.SlicingPipelineCapabilityBase):
    def get_name(self):
        return "My Capability"
    def execute(self, ctx: orca.slicing.SlicingPipelineContext):
        ...
        return orca.ExecutionResult.success()

When execute() fires, and what ctx.object is

ctx.step is one of the orca.slicing.Step values, in the order they occur inside one Print::process() run: Slice, Perimeters, EstimateCurledExtrusions, Infill, Ironing, Contouring, SupportMaterial, DetectOverhangsForLift, WipeTower, SkirtBrim, SimplifyPath. Note that SimplifyPath is declared before WipeTower and SkirtBrim in the Step enum, but fires after them at runtime.

Most steps are per-object: execute() runs once per PrintObject that just (re)computed that step, and ctx.object is a PrintObjectView for it. WipeTower and SkirtBrim are print-wide: they run once per slice, and ctx.object is None. Always check both ctx.step and ctx.object before touching object data — see InsetEverySlice.execute() for the standard guard:

if ctx.step != orca.slicing.Step.Slice or ctx.object is None:
    return orca.ExecutionResult.success()

The hook fires only on genuine recomputation of that step for that object — an incremental re-slice that finds a step already cached does not re-invoke execute() for it (see "Persistence and duplicates" below).

Supported mutations, per step

The read graph (PrintObjectViewLayerViewLayerRegionViewSurfaceView/PathData) is available at every step. Mutation is narrower:

Mutator Step it makes sense at Cascade
LayerRegionView.set_slices(polygons) Step.Slice Fully supported. The split slice loop calls make_perimeters() immediately after the Slice hook, so the new geometry flows into perimeters, infill and the final G-code — the toolpath preview visibly changes. This is the primary, recommended mutation entry point.
LayerRegionView.set_fill_surfaces(polygons) Step.Infill Limited. Replaces the stored fill-prep surfaces but does not regenerate the fills toolpaths already built for that region in v1 — the surface data changes, the rendered infill does not (yet).
LayerView.set_lslices(islands) any step where a LayerView is reachable Limited / read-oriented. Replaces the layer's merged islands and refreshes the lslices_bboxes cache so that invariant stays consistent, but no further cascade is documented — treat it as advanced/diagnostic, not a way to redirect downstream computation.
SurfaceView.set_type(surface_type) any step where a SurfaceView is reachable Limited. Reassigns surface_type only; the geometry is untouched, and nothing downstream is automatically regenerated as a result.

Every other step (Perimeters, EstimateCurledExtrusions, Ironing, Contouring, SupportMaterial, DetectOverhangsForLift, SimplifyPath, WipeTower, SkirtBrim) exposes read-only access in practice: the views are there, but nothing calls back into a not-yet-run earlier step, so writes there have no guaranteed effect on the final output. Treat non-Slice steps as inspection points, and do real geometry edits through set_slices() at Step.Slice.

Gotcha: set_slices()/set_fill_surfaces() build every replacement Surface from the first surface in the collection being replaced (or stInternal if the region had none) — per-surface surface_type distinctions among the surfaces you pass in are not preserved individually. If a region's slices mix top/bottom/ internal surfaces and you need to keep that distinction, mutate contours, then restore per-surface types with SurfaceView.set_type() afterward.

Scaled coordinates are int64, and the scale is live

Every point (ExPolygonView.contour()/holes(), PathData.points()) is a read-only int64 NumPy array of internal scaled units, not millimeters. Convert with orca.slicing.unscale(coord)never hardcode 1e-6/1e6. The scale factor is not a fixed constant in this codebase (larger beds use a coarser scale), so it must be read at call time:

mm_per_unit = orca.slicing.unscale(1)          # read the live scale
one_mm_scaled = int(round(1.0 / mm_per_unit))  # -> scaled-unit equivalent of 1mm

InsetEverySlice follows exactly this pattern for its 1mm inset.

Lifetime: every view and array is valid only during execute(ctx)

PrintObjectView, LayerView, LayerRegionView, SurfaceView, ExPolygonView, and PathData are thin, non-owning wrappers over memory owned by the Print being sliced. The NumPy arrays they hand out are zero-copy: they alias that same memory. All of it is valid only for the duration of the execute(ctx) call that produced it — the underlying std::vector storage can be reallocated by the very next pipeline step. Do not stash a view, a SurfaceView, or an array in self.* and read it from a later execute() call, and do not return one from execute(). Read what you need, copy any plain Python values out (int(), .tolist(), etc. — never the array itself) if you must keep them, and let the rest go when the call returns.

Persistence and duplicates

A set_slices() mutation is written directly into the PrintObject's Layer data, not into some separate plugin-owned overlay:

  • It survives across steps within the same slice — that's what makes the cascade into perimeters/infill/G-code work.
  • It survives an incremental re-slice only while posSlice stays cached and perimeters are not re-run (v1 limitation). slice() backs up the pre-hook geometry into each layer's raw_slices before the Slice hook fires, and make_perimeters() calls restore_untyped_slices(), which overwrites slices from that backup. So a config change that only invalidates a later step but still re-runs perimeters (e.g. wall_loops) silently reverts the mutation to the original geometry, while posSlice stays cached so the Slice hook does not fire again to re-apply it. Propagating the mutation into raw_slices so it survives a perimeter re-run is a known v1 limitation; for now, force a genuine re-slice (see below) if you need the mutation reapplied.
  • Toggling which plugins are selected always gets a clean slice. Changing the Slicing Pipeline Plugin picker selection itself invalidates posSlice, so selecting or deselecting a plugin forces a genuine re-slice (and re-fires the hook, or stops firing it) rather than leaving stale mutated geometry behind.
  • Duplicated (identical) objects share the same Layer*. Mutating the instance that actually slices is automatically visible on every duplicate of it. An object that must diverge from its duplicates cannot be an exact duplicate of them.

Errors, FatalError, and cancellation

execute() runs under the GIL, inside a try/catch on the host side. Any uncaught Python exception, or returning orca.ExecutionResult.failure(orca.PluginResult.FatalError, message), is converted into a Slic3r::SlicingError tagged with the plugin's capability name and your message. That surfaces to the user as a normal (non-fatal) slicing-error notification — it aborts that slice, but it does not crash the app. Prefer this over letting exceptions propagate silently, and put anything you need the user to see in the message.

Check ctx.cancelled() if you are doing meaningfully expensive work in a loop (e.g. a large multi-object print) so a user-initiated cancel is honored promptly instead of only at the next step boundary; InsetEverySlice demonstrates the check on its per-layer loop even though its own work is cheap.