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.
This commit is contained in:
SoftFever
2026-07-04 04:33:20 +08:00
parent 43bc76d9a9
commit b0bacdd00b
23 changed files with 1622 additions and 38 deletions

View File

@@ -0,0 +1,143 @@
# 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`](../../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.
```python
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:
```python
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 (`PrintObjectView``LayerView``LayerRegionView`
`SurfaceView`/`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:
```python
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.