Compare commits

..

20 Commits

Author SHA1 Message Date
SoftFever
d6285e967c Merge branch 'feat/plugin-feature' into feature/plugin-slicing 2026-07-06 01:12:05 +08:00
SoftFever
64df25c1c5 test(slicing-pipeline): active no-op byte-identical guard, gating negatives, ValueError for malformed holes
- Byte-identical test extended with an ACTIVE no-op hook variant (option {probe}
  + no-op hook firing at every seam): normalized gcode must equal the inactive
  baseline. normalize() strips the slicing_pipeline_plugin config-dump comment
  that legitimately differs when a plugin is selected.
- New gating negatives: empty option + call-counting hook fires 0 times; two
  ModelObjects sharing one mesh_ptr take the is_print_object_the_same duplicate
  skip so Slice/Perimeters each fire exactly once.
- set_slices bindings test: [contour, 42] now asserts ValueError (numpy-guarded,
  since reaching the holes check needs a real ndarray contour).
- use_cache=true path left uncovered (needs pre-sliced-3MF cache machinery).
2026-07-04 04:33:20 +08:00
SoftFever
6464ed9cb0 fix(slicing-pipeline): read plugins manifest from full config; correct set_slices persistence contract
- GUI dispatcher read 'plugins' (a dynamic-only manifest key) off the static
  PrintConfig, always getting nullptr and skipping every capability, leaving the
  feature inert. Read it from print.full_print_config() instead, mirroring the
  post-process path. slicing_pipeline_plugin stays on print.config() (it is a
  PrintConfig member).
- set_slices docstring + docs promised the mutation persists across incremental
  re-slices; in fact make_perimeters() restores region->slices from the pre-hook
  raw_slices backup, so a perimeters-invalidating config change reverts it while
  posSlice stays cached. Corrected both; marked raw_slices propagation a v1 limit.
- Added the standard lifetime note to SurfaceView.expolygon.
- parse_expolygon now type-checks the holes element and raises ValueError (not a
  bare TypeError) for a malformed (non-sequence) holes slot.
2026-07-04 04:33:04 +08:00
SoftFever
8fb0e3259d docs(slicing-pipeline): correct Step firing order (WipeTower/SkirtBrim precede SimplifyPath) 2026-07-04 03:45:10 +08:00
SoftFever
64a4869943 docs(slicing-pipeline): InsetEverySlice sample plugin + author notes 2026-07-04 03:33:21 +08:00
SoftFever
427509336b feat(slicing-pipeline): 2D-geometry mutators (set_slices/fill_surfaces/lslices/type) with cascade + cache-invariant refresh 2026-07-04 03:13:54 +08:00
SoftFever
3c3a057fa8 feat(slicing-pipeline): register GUI dispatcher (GIL, errors->slicing error, cancel), picker, parameterized dispatch logs 2026-07-04 02:53:10 +08:00
SoftFever
1a98c8f746 feat(slicing-pipeline): read graph — flattened toolpath PathData (N,3 int64) over perimeters/fills 2026-07-04 02:33:02 +08:00
SoftFever
400d620d2a feat(slicing-pipeline): read graph — geometry views with zero-copy int64 contour/holes + unscale 2026-07-04 02:19:05 +08:00
SoftFever
25649a427b fix(slicing-pipeline): bind get_type/execute on SlicingPipelineCapabilityBase to match sibling capabilities 2026-07-04 02:07:12 +08:00
SoftFever
6420f34093 feat(slicing-pipeline): capability base + trampoline + orca.slicing submodule with Step enum 2026-07-04 01:56:26 +08:00
SoftFever
bcacc50473 test(slicing-pipeline): use Catch2 SKIP for numpy-absent guard so --warn NoAssertions passes 2026-07-04 01:46:13 +08:00
SoftFever
b2c7d477c7 feat(slicing-pipeline): generalized read-only int64 zero-copy numpy helper + packing asserts 2026-07-04 01:36:37 +08:00
SoftFever
0f88bc4396 feat(slicing-pipeline): add SlicingPipeline capability type + orca.PluginType value 2026-07-04 01:24:21 +08:00
SoftFever
6aa0b4b3aa test(slicing-pipeline): normalize nondeterministic gcode comments in inactive-hook guard test 2026-07-04 01:13:54 +08:00
SoftFever
a29db9800a feat(slicing-pipeline): activate on non-empty option + invalidate posSlice (follow existing cache; no forced re-slice) 2026-07-04 01:09:48 +08:00
SoftFever
604e0fbbfe fix(slicing-pipeline): never fire SimplifyPath hook on the use_cache path 2026-07-04 00:59:08 +08:00
SoftFever
3192715e1f feat(slicing-pipeline): fire pipeline hook at each Print::process step seam on genuine (re)computation (split slice loop; support post-parallel loop) 2026-07-04 00:50:43 +08:00
SoftFever
9740844ef8 feat(slicing-pipeline): add injected SlicingPipeline hook plumbing to Print (no-op when unset) 2026-07-04 00:39:26 +08:00
SoftFever
fecfc0d6fa feat(slicing-pipeline): add slicing_pipeline_plugin config option + manifest serialization 2026-07-04 00:30:53 +08:00
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.

View File

@@ -0,0 +1,120 @@
# /// 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 = "1.0.0"
# 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
LayerRegionView.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.
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, which is what the manual test in the design
docs uses) 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.
numpy is declared as a dependency: the read views hand back zero-copy int64
ndarrays, and set_slices() requires genuine ndarrays back (not plain lists),
so building the modified contour needs numpy.
"""
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"
def execute(self, ctx):
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.
inset_scaled = int(round(INSET_MM / orca.slicing.unscale(1)))
regions_touched = 0
for layer in ctx.object.layers():
if ctx.cancelled():
break
for region in layer.regions():
surfaces = region.slices()
if not surfaces:
continue # set_slices() rejects an empty list
new_surfaces = []
for surface in surfaces:
expoly = surface.expolygon
contour = expoly.contour()
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, expoly.holes()])
region.set_slices(new_surfaces)
regions_touched += 1
return orca.ExecutionResult.success(f"inset applied to {regions_touched} region(s)")
@orca.plugin
class InsetEverySlicePackage(orca.base):
def register_capabilities(self):
orca.register_capability(InsetEverySlice)

View File

@@ -1626,6 +1626,11 @@ void ConfigBase::save_plugin_collection(const std::string& opt_key, const Config
append_ref(val, "post-processing");
} else if (opt_key == "printer_agent") {
append_ref((dynamic_cast<const ConfigOptionString *>(opt))->value, "printer-connection");
} else if (opt_key == "slicing_pipeline_plugin") {
if (const auto* vec = dynamic_cast<const ConfigOptionStrings*>(opt)) {
for (const std::string& val : vec->vserialize())
append_ref(val, "slicing-pipeline");
}
}
// Extend for other plugin-backed settings as needed.
}

View File

@@ -50,6 +50,8 @@ using namespace nlohmann;
namespace Slic3r {
Print::SlicingPipelineHookFn Print::s_slicing_pipeline_hook_fn = nullptr;
template class PrintState<PrintStep, psCount>;
template class PrintState<PrintObjectStep, posCount>;
@@ -277,7 +279,8 @@ bool Print::invalidate_state_by_config_options(const ConfigOptionResolver & /* n
|| opt_key == "wipe_tower_rotation_angle") {
steps.emplace_back(psSkirtBrim);
} else if (
opt_key == "initial_layer_print_height"
opt_key == "slicing_pipeline_plugin"
|| opt_key == "initial_layer_print_height"
|| opt_key == "nozzle_diameter"
|| opt_key == "filament_shrink"
|| opt_key == "filament_shrinkage_compensation_z"
@@ -2201,6 +2204,11 @@ void Print::process(long long *time_cost_with_cache, bool use_cache)
if (time_cost_with_cache)
*time_cost_with_cache = 0;
{
const auto* sp = this->config().option<ConfigOptionStrings>("slicing_pipeline_plugin");
m_pipeline_plugin_active = s_slicing_pipeline_hook_fn && sp && !sp->values.empty();
}
name_tbb_thread_pool_threads_set_locale();
//compute the PrintObject with the same geometries
@@ -2310,20 +2318,36 @@ void Print::process(long long *time_cost_with_cache, bool use_cache)
BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << boost::format(": total object counts %1% in current print, need to slice %2%")%m_objects.size()%need_slicing_objects.size();
BOOST_LOG_TRIVIAL(info) << "Starting the slicing process." << log_memory_info();
if (!use_cache) {
// Fire the SlicingPipeline hook for `obj` iff it just (re)computed `pstep` this pass.
auto hook_after = [this](PrintObject* obj, bool was_done, PrintObjectStep pstep, SlicingPipelineStep sstep) {
if (m_pipeline_plugin_active && !was_done && obj->is_step_done(pstep))
run_pipeline_hook(sstep, obj);
};
// SlicingPipeline: dedicated slice loop so the Slice boundary is hookable before perimeters.
for (PrintObject *obj : m_objects) {
if (need_slicing_objects.count(obj) != 0) {
obj->make_perimeters();
}
else {
if (obj->set_started(posSlice))
obj->set_done(posSlice);
if (obj->set_started(posPerimeters))
obj->set_done(posPerimeters);
const bool was_done = obj->is_step_done(posSlice);
obj->slice();
hook_after(obj, was_done, posSlice, SlicingPipelineStep::Slice);
} else {
if (obj->set_started(posSlice)) obj->set_done(posSlice); // shared/duplicate — no hook
}
}
for (PrintObject *obj : m_objects) {
if (need_slicing_objects.count(obj) != 0) {
const bool was_done = obj->is_step_done(posPerimeters);
obj->make_perimeters(); // slice() inside is a no-op: posSlice already DONE
hook_after(obj, was_done, posPerimeters, SlicingPipelineStep::Perimeters);
} else {
if (obj->set_started(posPerimeters)) obj->set_done(posPerimeters);
}
}
for (PrintObject *obj : m_objects) {
if (need_slicing_objects.count(obj) != 0) {
const bool was_done = obj->is_step_done(posEstimateCurledExtrusions);
obj->estimate_curled_extrusions();
hook_after(obj, was_done, posEstimateCurledExtrusions, SlicingPipelineStep::EstimateCurledExtrusions);
}
else {
if (obj->set_started(posEstimateCurledExtrusions))
@@ -2332,7 +2356,9 @@ void Print::process(long long *time_cost_with_cache, bool use_cache)
}
for (PrintObject *obj : m_objects) {
if (need_slicing_objects.count(obj) != 0) {
const bool was_done = obj->is_step_done(posInfill);
obj->infill();
hook_after(obj, was_done, posInfill, SlicingPipelineStep::Infill);
}
else {
if (obj->set_started(posPrepareInfill))
@@ -2343,7 +2369,9 @@ void Print::process(long long *time_cost_with_cache, bool use_cache)
}
for (PrintObject *obj : m_objects) {
if (need_slicing_objects.count(obj) != 0) {
const bool was_done = obj->is_step_done(posIroning);
obj->ironing();
hook_after(obj, was_done, posIroning, SlicingPipelineStep::Ironing);
}
else {
if (obj->set_started(posIroning))
@@ -2355,13 +2383,22 @@ void Print::process(long long *time_cost_with_cache, bool use_cache)
for (PrintObject *obj : m_objects) {
bool need_contouring = need_slicing_objects.count(obj) != 0 && obj->need_z_contouring();
if (need_contouring) {
const bool was_done = obj->is_step_done(posContouring);
obj->contour_z();
hook_after(obj, was_done, posContouring, SlicingPipelineStep::Contouring);
} else {
if (obj->set_started(posContouring))
obj->set_done(posContouring);
}
}
// SlicingPipeline: support runs in the parallel block below; the hook must fire in a
// sequential loop afterward. Snapshot per-object done-state just before the parallel_for.
std::vector<char> sup_was_done(m_objects.size(), 1);
if (m_pipeline_plugin_active)
for (size_t i = 0; i < m_objects.size(); ++i)
sup_was_done[i] = m_objects[i]->is_step_done(posSupportMaterial) ? 1 : 0;
tbb::parallel_for(tbb::blocked_range<int>(0, int(m_objects.size())),
[this, need_slicing_objects](const tbb::blocked_range<int>& range) {
for (int i = range.begin(); i < range.end(); i++) {
@@ -2377,9 +2414,17 @@ void Print::process(long long *time_cost_with_cache, bool use_cache)
}
);
if (m_pipeline_plugin_active)
for (size_t i = 0; i < m_objects.size(); ++i)
if (need_slicing_objects.count(m_objects[i]) != 0 && !sup_was_done[i]
&& m_objects[i]->is_step_done(posSupportMaterial))
run_pipeline_hook(SlicingPipelineStep::SupportMaterial, m_objects[i]);
for (PrintObject* obj : m_objects) {
if (need_slicing_objects.count(obj) != 0) {
const bool was_done = obj->is_step_done(posDetectOverhangsForLift);
obj->detect_overhangs_for_lift();
hook_after(obj, was_done, posDetectOverhangsForLift, SlicingPipelineStep::DetectOverhangsForLift);
}
else {
if (obj->set_started(posDetectOverhangsForLift))
@@ -2456,6 +2501,7 @@ void Print::process(long long *time_cost_with_cache, bool use_cache)
}
this->set_done(psWipeTower);
if (m_pipeline_plugin_active) run_pipeline_hook(SlicingPipelineStep::WipeTower, nullptr);
}
if (this->has_wipe_tower()) {
@@ -2581,6 +2627,7 @@ void Print::process(long long *time_cost_with_cache, bool use_cache)
this->finalize_first_layer_convex_hull();
this->set_done(psSkirtBrim);
if (m_pipeline_plugin_active) run_pipeline_hook(SlicingPipelineStep::SkirtBrim, nullptr);
if (time_cost_with_cache) {
end_time = (long long)Slic3r::Utils::get_current_time_utc();
@@ -2591,7 +2638,13 @@ void Print::process(long long *time_cost_with_cache, bool use_cache)
for (PrintObject *obj : m_objects) {
if (((!use_cache)&&(need_slicing_objects.count(obj) != 0))
|| (use_cache &&(re_slicing_objects.count(obj) != 0))){
const bool was_done = obj->is_step_done(posSimplifyPath);
obj->simplify_extrusion_path();
// Unlike every other seam (all inside the `if (!use_cache)` block above), this loop is
// shared with the use_cache path (re_slicing_objects), so `!use_cache` must be checked
// explicitly here to keep hooks from ever firing on cache-loaded (plugin-final) objects.
if (!use_cache && m_pipeline_plugin_active && !was_done && obj->is_step_done(posSimplifyPath))
run_pipeline_hook(SlicingPipelineStep::SimplifyPath, obj);
}
else {
if (obj->set_started(posSimplifyPath))

View File

@@ -882,6 +882,11 @@ enum FilamentCompatibilityType {
InvalidTemperatureRange
};
enum class SlicingPipelineStep {
Slice, Perimeters, EstimateCurledExtrusions, Infill, Ironing, Contouring,
SupportMaterial, DetectOverhangsForLift, SimplifyPath, WipeTower, SkirtBrim
};
// The complete print tray with possibly multiple objects.
class Print : public PrintBaseWithState<PrintStep, psCount>
{
@@ -891,6 +896,11 @@ private: // Prevents erroneous use by other classes.
typedef std::pair<PrintObject *, bool> PrintObjectInfo;
public:
using SlicingPipelineHookFn = std::function<void(Print&, const PrintObject*, SlicingPipelineStep)>;
// Cross-layer injection (mirrors ConfigBase::set_resolve_capability_fn): the GUI/plugin
// layer registers a dispatcher; libslic3r stays free of any plugin/Python dependency.
static void set_slicing_pipeline_hook_fn(SlicingPipelineHookFn fn) { s_slicing_pipeline_hook_fn = std::move(fn); }
Print() = default;
virtual ~Print() { this->clear(); }
@@ -1147,6 +1157,13 @@ private:
// Islands of objects and their supports extruded at the 1st layer.
Polygons first_layer_islands() const;
static SlicingPipelineHookFn s_slicing_pipeline_hook_fn;
bool m_pipeline_plugin_active { false };
void run_pipeline_hook(SlicingPipelineStep step, const PrintObject* object) {
if (m_pipeline_plugin_active && s_slicing_pipeline_hook_fn)
s_slicing_pipeline_hook_fn(*this, object, step);
}
PrintConfig m_config;
PrintObjectConfig m_default_object_config;
PrintRegionConfig m_default_region_config;

View File

@@ -5123,6 +5123,16 @@ void PrintConfigDef::init_fff_params()
def->mode = comAdvanced;
def->set_default_value(new ConfigOptionStrings());
def = this->add("slicing_pipeline_plugin", coStrings);
def->label = L("Slicing Pipeline Plugin");
def->tooltip = L("Python plugin(s) invoked at each slicing pipeline step to read and modify intermediate slicing data. Research/experimental.");
def->gui_type = ConfigOptionDef::GUIType::plugin_picker;
def->plugin_type = "slicing-pipeline";
def->support_plugin = true;
def->full_width = true;
def->mode = comAdvanced;
def->set_default_value(new ConfigOptionStrings());
def = this->add("printer_model", coString);
def->label = L("Printer type");
def->tooltip = L("Type of the printer.");

View File

@@ -1571,6 +1571,7 @@ PRINT_CONFIG_CLASS_DERIVED_DEFINE(
((ConfigOptionString, filename_format))
((ConfigOptionStrings, post_process))
((ConfigOptionStrings, post_process_plugin))
((ConfigOptionStrings, slicing_pipeline_plugin))
((ConfigOptionString, printer_model))
((ConfigOptionFloat, resolution))
((ConfigOptionFloats, retraction_minimum_travel))

View File

@@ -621,6 +621,10 @@ set(SLIC3R_GUI_SOURCES
plugin/pluginTypes/script/ScriptPluginCapability.hpp
plugin/pluginTypes/script/ScriptPluginCapability.cpp
plugin/pluginTypes/script/ScriptPluginCapabilityTrampoline.hpp
plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.hpp
plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.cpp
plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapabilityTrampoline.hpp
plugin/pluginTypes/slicingPipeline/SlicingNumpy.hpp
pchheader.cpp
pchheader.hpp
Utils/ASCIIFolding.cpp

View File

@@ -81,6 +81,7 @@
#include "slic3r/plugin/PluginManager.hpp"
#include "slic3r/plugin/PluginHostUi.hpp"
#include "slic3r/plugin/PythonInterpreter.hpp"
#include "slic3r/plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.hpp"
#include "GUI.hpp"
#include "GUI_Utils.hpp"
@@ -3122,6 +3123,61 @@ bool GUI_App::on_init_inner()
return identity + ';' + descriptor.cloud_uuid() + ';' + cap_name;
});
// Orca: register the slicing-pipeline plugin dispatcher (mirrors set_resolve_capability_fn: the
// GUI/plugin layer supplies the Python bridge so libslic3r stays free of any plugin dependency).
// Print::process() fires this hook at each pipeline seam on the slicing worker thread; here we run
// the picker-selected SlicingPipeline capabilities. Per capability we acquire the GIL, honor
// cancellation, and convert a plugin failure into a (non-critical) SlicingError so it surfaces as a
// slicing-error notification rather than the fatal-crash dialog.
Slic3r::Print::set_slicing_pipeline_hook_fn(
[](Slic3r::Print& print, const Slic3r::PrintObject* object, Slic3r::SlicingPipelineStep step) {
const auto* caps = print.config().option<ConfigOptionStrings>("slicing_pipeline_plugin");
// `plugins` is a dynamic-only manifest key (not a static PrintConfig member), so it
// must be read from the full/dynamic config -- reading it off print.config() (the
// static PrintConfig) always yields nullptr and skips every capability. Mirrors the
// post-process path (PostProcessor.cpp, via BackgroundSlicingProcess::full_print_config()).
const auto* plugs = print.full_print_config().option<ConfigOptionStrings>("plugins");
if (caps == nullptr || caps->values.empty())
return;
Slic3r::execute_capabilities_from_refs<Slic3r::SlicingPipelinePluginCapability>(
*caps, plugs, Slic3r::PluginCapabilityType::SlicingPipeline,
[&](std::shared_ptr<Slic3r::SlicingPipelinePluginCapability> cap, const Slic3r::PluginCapabilityRef& ref) {
Slic3r::ExecutionResult r;
try {
// GIL is acquired per capability (not once for the whole dispatch) so it is
// released between capabilities. ctx is built inside this scope because
// ctx.owner is a py::capsule: it must be created and destroyed while the GIL
// is held (ctx destructs before `gil`, so its capsule is decref'd under GIL).
PythonGILState gil;
// throw_if_canceled() is protected on PrintBase; canceled() is the public
// equivalent check (same cancel flag), so honor cancellation via it.
if (print.canceled())
throw Slic3r::CanceledException();
Slic3r::SlicingPipelineContext ctx;
ctx.orca_version = SoftFever_VERSION;
ctx.step = step;
ctx.print = &print;
ctx.object = object;
// No-op-destructor capsule threaded into every zero-copy numpy array as its
// base. It references `print` but frees nothing: `print` is owned by libslic3r
// and outlives the hook, and arrays are valid only during this execute() call.
ctx.owner = pybind11::capsule(&print, [](void*) {});
r = cap->execute(ctx);
} catch (const Slic3r::CanceledException&) {
throw; // cancellation must reach process(), never become a slicing error
} catch (const std::exception& ex) {
// A Python raise reaches here as pybind11::error_already_set; surface it as a
// (non-critical) slicing error instead of a crash.
throw Slic3r::SlicingError(std::string("Slicing pipeline plugin '") +
ref.capability_name + "' error: " + ex.what());
}
if (r.status == Slic3r::PluginResult::FatalError)
throw Slic3r::SlicingError(std::string("Slicing pipeline plugin '") +
ref.capability_name + "' error: " + r.message);
});
});
// Set cloud plugin directory from previous session so cloud-installed
// plugins are discovered even before the network agent is ready.
const std::string preset_folder = app_config->get("preset_folder");

View File

@@ -3079,6 +3079,12 @@ void TabPrint::build()
option.opt.full_width = true;
optgroup->append_single_option_line(option, "others_settings_plugin_picker");
optgroup = page->new_optgroup(L("Slicing Pipeline Plugin"), L"param_gcode", 0);
optgroup->hide_labels();
option = optgroup->get_option("slicing_pipeline_plugin");
option.opt.full_width = true;
optgroup->append_single_option_line(option, "others_settings_plugin_picker");
optgroup = page->new_optgroup(L("Notes"), "note", 0);
option = optgroup->get_option("notes");
option.opt.full_width = true;

View File

@@ -102,10 +102,14 @@ void execute_capabilities_from_refs(const ConfigOptionStrings& capabilities,
{
PluginManager& plugin_mgr = PluginManager::instance();
// Log prefix derived from the capability type so each capability family (Post-processing,
// Slicing Pipeline, ...) tags its dispatch diagnostics with its own display name.
const std::string tag = plugin_capability_type_display_name(type);
const bool has_any = std::any_of(capabilities.values.begin(), capabilities.values.end(),
[](const std::string& s) { return !s.empty(); });
if (has_any && !plugin_mgr.get_loader().wait_for_all_plugin_loads(std::chrono::seconds(10))) {
BOOST_LOG_TRIVIAL(warning) << "Post-process: timed out waiting for plugin loads; unresolved capabilities will be skipped";
BOOST_LOG_TRIVIAL(warning) << tag << ": timed out waiting for plugin loads; unresolved capabilities will be skipped";
}
for (const std::string& capability : capabilities.values) {
@@ -127,7 +131,7 @@ void execute_capabilities_from_refs(const ConfigOptionStrings& capabilities,
}
if (!ref) {
BOOST_LOG_TRIVIAL(warning) << "Post-processing: no plugin reference found for capability '" << capability << "'; skipping";
BOOST_LOG_TRIVIAL(warning) << tag << ": no plugin reference found for capability '" << capability << "'; skipping";
continue;
}
@@ -136,19 +140,19 @@ void execute_capabilities_from_refs(const ConfigOptionStrings& capabilities,
cap = plugin_mgr.get_loader().get_plugin_capability_by_name(plugin_key, type, cap_name);
if (!cap) {
BOOST_LOG_TRIVIAL(warning) << "Post-processing: no loaded capability '" << cap_name
BOOST_LOG_TRIVIAL(warning) << tag << ": no loaded capability '" << cap_name
<< "' for plugin '" << plugin_key << "'; skipping";
continue;
}
if (!cap->enabled) {
BOOST_LOG_TRIVIAL(warning) << "Post-processing: capability '" << cap_name
BOOST_LOG_TRIVIAL(warning) << tag << ": capability '" << cap_name
<< "' for plugin '" << plugin_key << "' is disabled; skipping";
continue;
}
auto plugin_capability = std::dynamic_pointer_cast<T>(cap->instance);
if (!plugin_capability) {
BOOST_LOG_TRIVIAL(warning) << "Post-processing: capability '" << cap_name
BOOST_LOG_TRIVIAL(warning) << tag << ": capability '" << cap_name
<< "' (plugin_key=" << cap->plugin_key
<< ") is not a " << plugin_capability_type_to_string(type) << "; skipping";
continue;

View File

@@ -16,6 +16,7 @@
#include "pluginTypes/gcode/GCodePluginCapability.hpp"
#include "pluginTypes/printerAgent/PrinterAgentPluginCapability.hpp"
#include "pluginTypes/script/ScriptPluginCapability.hpp"
#include "pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.hpp"
namespace py = pybind11;
@@ -294,6 +295,7 @@ void bind_python_api(pybind11::module_& m)
.value("Exporter", PluginCapabilityType::Exporter)
.value("Visualization", PluginCapabilityType::Visualization)
.value("Script", PluginCapabilityType::Script)
.value("SlicingPipeline", PluginCapabilityType::SlicingPipeline)
.value("Unknown", PluginCapabilityType::Unknown)
.export_values();
@@ -337,6 +339,7 @@ void bind_python_api(pybind11::module_& m)
GCodePluginCapability::RegisterBindings(m, pluginTypes);
PrinterAgentPluginCapability::RegisterBindings(m, pluginTypes);
ScriptPluginCapability::RegisterBindings(m, pluginTypes);
SlicingPipelinePluginCapability::RegisterBindings(m, pluginTypes);
PluginHostApi::RegisterBindings(m);
BOOST_LOG_TRIVIAL(debug) << "Registered ScriptPluginCapability Python bindings";

View File

@@ -10,7 +10,7 @@
namespace Slic3r {
enum class PluginCapabilityType { PostProcessing = 0, PrinterConnection, Automation, Analysis, Importer, Exporter, Visualization, Script, Unknown };
enum class PluginCapabilityType { PostProcessing = 0, PrinterConnection, Automation, Analysis, Importer, Exporter, Visualization, Script, SlicingPipeline, Unknown };
inline std::string plugin_capability_type_to_string(PluginCapabilityType type)
{
@@ -23,6 +23,7 @@ inline std::string plugin_capability_type_to_string(PluginCapabilityType type)
case PluginCapabilityType::Exporter: return "exporter";
case PluginCapabilityType::Visualization: return "visualization";
case PluginCapabilityType::Script: return "script";
case PluginCapabilityType::SlicingPipeline: return "slicing-pipeline";
default: return "unknown";
}
}
@@ -38,6 +39,7 @@ inline std::string plugin_capability_type_display_name(PluginCapabilityType type
case PluginCapabilityType::Exporter: return "Exporter";
case PluginCapabilityType::Visualization: return "Visualization";
case PluginCapabilityType::Script: return "Script";
case PluginCapabilityType::SlicingPipeline: return "Slicing Pipeline";
default: return "Unknown";
}
}
@@ -67,6 +69,8 @@ inline PluginCapabilityType plugin_capability_type_from_string(std::string_view
return PluginCapabilityType::Visualization;
if (lowered == "script")
return PluginCapabilityType::Script;
if (lowered == "slicing-pipeline")
return PluginCapabilityType::SlicingPipeline;
return PluginCapabilityType::Unknown;
}

View File

@@ -0,0 +1,27 @@
#pragma once
#include <pybind11/pybind11.h>
#include <pybind11/numpy.h>
#include "libslic3r/Point.hpp"
namespace Slic3r {
// Point/Point3 must be tightly packed for zero-copy views. coord_t = int64_t.
static_assert(sizeof(Point) == 2 * sizeof(coord_t), "Point must be 2 packed coord_t");
static_assert(sizeof(Point3) == 3 * sizeof(coord_t), "Point3 must be 3 packed coord_t");
// Zero-copy, read-only (rows, N) numpy view over `data`, pinned alive by `owner`.
// T is the element scalar (coord_t=int64 for slicing coords). Mirrors PluginHostApi's
// capsule + setflags(write=false) pattern, generalized over column count and owner.
template<typename T, int N>
pybind11::array make_readonly_rows(pybind11::capsule owner, const T* data, pybind11::ssize_t rows)
{
namespace py = pybind11;
py::array_t<T> arr(
{ rows, (py::ssize_t)N },
{ (py::ssize_t)(N * sizeof(T)), (py::ssize_t)sizeof(T) },
data, owner);
arr.attr("setflags")(py::arg("write") = false);
return std::move(arr);
}
} // namespace Slic3r

View File

@@ -0,0 +1,372 @@
#include "SlicingPipelinePluginCapability.hpp"
#include "SlicingPipelinePluginCapabilityTrampoline.hpp"
#include "SlicingNumpy.hpp" // make_readonly_rows
#include "libslic3r/libslic3r.h" // unscale<>, live SCALING_FACTOR
#include "libslic3r/ExtrusionEntity.hpp" // ExtrusionPath/Loop/MultiPath, role_to_string
#include "libslic3r/ExtrusionEntityCollection.hpp" // ExtrusionEntityCollection
#include <pybind11/stl.h>
#include <vector>
namespace py = pybind11;
namespace Slic3r {
bool SlicingPipelineContext::cancelled() const { return print && print->canceled(); }
namespace {
// Zero-copy read-only int64 (N,2) view over a Polygon's points, pinned by `owner`.
// coord_t == int64; Point is asserted tightly packed in SlicingNumpy.hpp.
static py::array polygon_rows(const py::capsule& owner, const Polygon& poly)
{
const Points& p = poly.points;
return make_readonly_rows<coord_t, 2>(
owner, p.empty() ? nullptr : p.front().data(), (py::ssize_t) p.size());
}
// Flatten an extrusion graph into a list of leaf ExtrusionPath* while walking the
// ORIGINAL Print-owned tree (never a temporary copy): the returned pointers stay
// valid for the execute(ctx) lifetime pinned by `owner`, so points() can hand out
// zero-copy views into path->polyline.points.
//
// This is deliberately NOT ExtrusionEntityCollection::flatten(): flatten() only
// unwraps nested collections (is_collection() is true solely for collections) and
// returns them by value, so it would (a) dangle if we viewed into the copy and
// (b) leave ExtrusionLoop/ExtrusionMultiPath intact — dropping every perimeter
// loop, since dynamic_cast<ExtrusionPath*> fails on a loop. We descend into
// loops/multipaths here to reach their contained paths.
static void collect_extrusion_paths(const ExtrusionEntity* ee, std::vector<const ExtrusionPath*>& out)
{
if (ee == nullptr)
return;
if (const auto* coll = dynamic_cast<const ExtrusionEntityCollection*>(ee)) {
for (const ExtrusionEntity* child : coll->entities)
collect_extrusion_paths(child, out);
} else if (const auto* loop = dynamic_cast<const ExtrusionLoop*>(ee)) {
for (const ExtrusionPath& p : loop->paths)
out.push_back(&p);
} else if (const auto* mp = dynamic_cast<const ExtrusionMultiPath*>(ee)) {
for (const ExtrusionPath& p : mp->paths)
out.push_back(&p);
} else if (const auto* path = dynamic_cast<const ExtrusionPath*>(ee)) {
// Catches ExtrusionPath and its subclasses (Sloped/Contoured/Oriented) last,
// after the composite types above have been ruled out.
out.push_back(path);
}
}
// Build a Python list of PathData over an extrusion collection, each entry pinned by `owner`.
static py::list path_data_list(const py::capsule& owner, const ExtrusionEntityCollection& coll)
{
std::vector<const ExtrusionPath*> paths;
collect_extrusion_paths(&coll, paths);
py::list out;
for (const ExtrusionPath* p : paths)
out.append(PathData{ p, owner });
return out;
}
// --- Task 11 input path: Python geometry -> C++ ExPolygon/Surface, with validation. -------
// The mutators take scaled integer coords (the same units the read views hand out). A Python
// raise here surfaces as ValueError (pybind translates) so malformed input is rejected up
// front rather than silently corrupting the slicing graph.
// One (N,2) int64 ndarray -> Polygon. Rejects wrong dtype/shape and degenerate (<3 pt) rings.
// Float / NaN / inf are rejected implicitly: only a signed-integer, 8-byte (coord_t==int64)
// dtype is accepted, and integer arrays cannot hold NaN/inf.
static Polygon parse_polygon(py::handle h, const char* who)
{
if (!py::isinstance<py::array>(h))
throw py::value_error(std::string(who) + ": each contour/hole must be an (N,2) int64 ndarray");
py::array a = py::reinterpret_borrow<py::array>(h);
if (a.dtype().kind() != 'i' || a.itemsize() != (py::ssize_t) sizeof(coord_t))
throw py::value_error(std::string(who) + ": polygon coordinates must be int64 (scaled coords)");
if (a.ndim() != 2 || a.shape(1) != 2)
throw py::value_error(std::string(who) + ": each polygon array must have shape (N,2)");
if (a.shape(0) < 3)
throw py::value_error(std::string(who) + ": a polygon needs at least 3 points");
// dtype already validated as int64; forcecast here only guarantees a C-contiguous buffer.
auto arr = py::array_t<coord_t, py::array::c_style | py::array::forcecast>::ensure(a);
if (!arr)
throw py::value_error(std::string(who) + ": could not read polygon as a contiguous int64 array");
auto r = arr.unchecked<2>();
Polygon poly;
poly.points.reserve((size_t) arr.shape(0));
for (py::ssize_t i = 0; i < arr.shape(0); ++i)
poly.points.emplace_back((coord_t) r(i, 0), (coord_t) r(i, 1));
return poly;
}
// One Python entry -> ExPolygon. Accepts either a bare (N,2) ndarray (contour only) or a
// [contour, [hole, ...]] sequence. Orientation is normalized (contour CCW, holes CW) so
// downstream area/offset math is correct regardless of the caller's winding.
static ExPolygon parse_expolygon(py::handle entry, const char* who)
{
ExPolygon ex;
if (py::isinstance<py::array>(entry)) {
ex.contour = parse_polygon(entry, who);
} else if (py::isinstance<py::sequence>(entry) && !py::isinstance<py::str>(entry)) {
py::sequence seq = py::reinterpret_borrow<py::sequence>(entry);
if (py::len(seq) < 1)
throw py::value_error(std::string(who) + ": a [contour, holes] entry needs a contour");
ex.contour = parse_polygon(seq[0], who);
if (py::len(seq) >= 2) {
// Type-check the holes element up front: a non-sequence (e.g. an int) would otherwise
// reach reinterpret_borrow<py::sequence> and raise a bare Python TypeError on iteration,
// whereas the API contract is ValueError for malformed input (str is excluded because it
// is iterable but never a valid holes container).
py::object holes_obj = seq[1];
if (!py::isinstance<py::sequence>(holes_obj) || py::isinstance<py::str>(holes_obj))
throw py::value_error(std::string(who) + ": the holes element must be a list of (N,2) int64 ndarrays");
for (py::handle hh : py::reinterpret_borrow<py::sequence>(holes_obj)) {
Polygon hole = parse_polygon(hh, who);
hole.make_clockwise();
ex.holes.emplace_back(std::move(hole));
}
}
} else {
throw py::value_error(std::string(who) + ": each entry must be an (N,2) ndarray or a [contour, holes] pair");
}
ex.contour.make_counter_clockwise();
return ex;
}
// A non-empty Python list of entries -> ExPolygons (each entry parsed + oriented).
static ExPolygons parse_expolygon_list(py::handle list_h, const char* who)
{
if (!py::isinstance<py::sequence>(list_h) || py::isinstance<py::str>(list_h))
throw py::value_error(std::string(who) + ": expected a list of polygons");
ExPolygons out;
for (py::handle entry : py::reinterpret_borrow<py::sequence>(list_h))
out.emplace_back(parse_expolygon(entry, who));
if (out.empty())
throw py::value_error(std::string(who) + ": expected a non-empty list of polygons");
return out;
}
// Build Surfaces from a Python list, carrying surface_type (and the other per-surface
// attributes) forward from the collection being replaced, or defaulting to stInternal when
// the region had no prior surfaces.
static Surfaces surfaces_from_py(py::handle list_h, const SurfaceCollection& replaced, const char* who)
{
ExPolygons ex = parse_expolygon_list(list_h, who);
const Surface tmpl = replaced.surfaces.empty() ? Surface(stInternal) : replaced.surfaces.front();
Surfaces out;
out.reserve(ex.size());
for (ExPolygon& e : ex)
out.emplace_back(Surface(tmpl, std::move(e)));
return out;
}
} // namespace
void SlicingPipelinePluginCapability::RegisterBindings(py::module_& module, py::enum_<PluginCapabilityType>& pluginTypes) {
(void) pluginTypes; // matches gcode/script/printerAgent; Step is a fresh enum below.
auto slicing = module.def_submodule("slicing", "Slicing pipeline API (research/experimental).");
py::enum_<SlicingPipelineStep>(slicing, "Step")
.value("Slice", SlicingPipelineStep::Slice)
.value("Perimeters", SlicingPipelineStep::Perimeters)
.value("EstimateCurledExtrusions", SlicingPipelineStep::EstimateCurledExtrusions)
.value("Infill", SlicingPipelineStep::Infill) // fires after prepare+infill
.value("Ironing", SlicingPipelineStep::Ironing)
.value("Contouring", SlicingPipelineStep::Contouring)
.value("SupportMaterial", SlicingPipelineStep::SupportMaterial)
.value("DetectOverhangsForLift", SlicingPipelineStep::DetectOverhangsForLift)
.value("SimplifyPath", SlicingPipelineStep::SimplifyPath) // covers all simplify sub-steps
.value("WipeTower", SlicingPipelineStep::WipeTower)
.value("SkirtBrim", SlicingPipelineStep::SkirtBrim)
.export_values();
// --- Read-graph geometry views (see header for the mandatory lifetime rule). ---
// Every array/view below is valid ONLY during the execute(ctx) call that produced it.
py::enum_<SurfaceType>(slicing, "SurfaceType")
.value("stTop", stTop)
.value("stBottom", stBottom)
.value("stBottomBridge", stBottomBridge)
.value("stInternalAfterExternalBridge", stInternalAfterExternalBridge)
.value("stInternal", stInternal)
.value("stInternalSolid", stInternalSolid)
.value("stInternalBridge", stInternalBridge)
.value("stSecondInternalBridge", stSecondInternalBridge)
.value("stInternalVoid", stInternalVoid)
.value("stPerimeter", stPerimeter)
.value("stCount", stCount)
.export_values();
// Scaled integer coordinate -> millimeters. Reads the live SCALING_FACTOR at call
// time (1e-6 normal, 1e-5 for beds > 2147mm), so it is never cached.
slicing.def("unscale", [](coord_t v) { return unscale<double>(v); }, py::arg("coord"),
"Convert a scaled integer coordinate to millimeters (reads the live SCALING_FACTOR).");
py::class_<ExPolygonView>(slicing, "ExPolygonView")
.def("contour", [](const ExPolygonView& v) { return polygon_rows(v.owner, v.ex->contour); },
"Outer contour as a read-only int64 (N,2) numpy view in scaled coords. "
"Valid only during the execute(ctx) call.")
.def("holes", [](const ExPolygonView& v) {
py::list out;
for (const Polygon& h : v.ex->holes)
out.append(polygon_rows(v.owner, h));
return out;
}, "List of hole contours (CW), each a read-only int64 (N,2) numpy view. "
"Valid only during the execute(ctx) call.");
py::class_<SurfaceView>(slicing, "SurfaceView")
.def_property_readonly("surface_type", [](const SurfaceView& v) { return v.s->surface_type; })
.def_property_readonly("thickness", [](const SurfaceView& v) { return v.s->thickness; })
.def_property_readonly("bridge_angle", [](const SurfaceView& v) { return v.s->bridge_angle; })
.def_property_readonly("extra_perimeters", [](const SurfaceView& v) { return v.s->extra_perimeters; })
.def_property_readonly("expolygon", [](const SurfaceView& v) {
return ExPolygonView{ &v.s->expolygon, v.owner };
}, "This surface's geometry as an ExPolygonView. Valid only during the execute(ctx) call.")
// MUTATOR (Task 11). Reclassify this surface's type (e.g. SurfaceType.stInternalSolid).
// set_type reassigns surface_type ONLY — it does not replace the geometry. Writes through
// the const view by const_cast (the Surface is non-const in the live slicing graph).
// Valid only during the execute(ctx) call.
.def("set_type", [](const SurfaceView& v, SurfaceType type) {
const_cast<Surface*>(v.s)->surface_type = type;
}, py::arg("surface_type"),
"Reclassify this surface's SurfaceType (reassigns surface_type only; the geometry "
"is unchanged). Valid only during the execute(ctx) call.");
// A flattened toolpath. Read-only in v1 (mutation is a later phase). role/width/
// height/mm3_per_mm are plain scalars; points() materializes a zero-copy array.
py::class_<PathData>(slicing, "PathData")
.def("points", [](const PathData& p) {
const Points3& pts = p.path->polyline.points;
return make_readonly_rows<coord_t, 3>(
p.owner, pts.empty() ? nullptr : pts.front().data(), (py::ssize_t) pts.size());
}, "Path vertices as a read-only int64 (N,3) numpy view in scaled coords "
"(the polyline is natively 3D on this branch). Valid only during the execute(ctx) call.")
.def_property_readonly("role", [](const PathData& p) {
return ExtrusionEntity::role_to_string(p.path->role());
}, "Extrusion role as a human-readable string (e.g. \"Outer wall\", \"Sparse infill\").")
.def_property_readonly("width", [](const PathData& p) { return p.path->width; })
.def_property_readonly("height", [](const PathData& p) { return p.path->height; })
.def_property_readonly("mm3_per_mm", [](const PathData& p) { return p.path->mm3_per_mm; });
py::class_<LayerRegionView>(slicing, "LayerRegionView")
.def("slices", [](const LayerRegionView& v) {
py::list out;
for (const Surface& s : v.r->slices.surfaces)
out.append(SurfaceView{ &s, v.owner });
return out;
}, "Sliced surfaces (typed top/bottom/internal) as [SurfaceView]. "
"Valid only during the execute(ctx) call.")
.def("fill_surfaces", [](const LayerRegionView& v) {
py::list out;
for (const Surface& s : v.r->fill_surfaces.surfaces)
out.append(SurfaceView{ &s, v.owner });
return out;
}, "Surfaces prepared for infill as [SurfaceView]. "
"Valid only during the execute(ctx) call.")
.def("perimeters", [](const LayerRegionView& v) {
return path_data_list(v.owner, v.r->perimeters);
}, "Perimeter toolpaths flattened to [PathData] (nested collections and "
"loops decomposed into their paths). Valid only during the execute(ctx) call.")
.def("fills", [](const LayerRegionView& v) {
return path_data_list(v.owner, v.r->fills);
}, "Infill toolpaths flattened to [PathData] (nested collections and loops "
"decomposed into their paths). Valid only during the execute(ctx) call.")
// MUTATOR (Task 11). Replace this region's sliced surfaces. `polygons` is a list of
// (N,2) int64 ndarrays (scaled coords) or [contour, [holes...]] pairs; orientation is
// normalized (contour CCW, holes CW) and surface_type is carried forward from the
// replaced surfaces (else stInternal). Writes through the const view by const_cast.
.def("set_slices", [](const LayerRegionView& v, py::object polygons) {
auto* region = const_cast<LayerRegion*>(v.r);
region->slices.set(surfaces_from_py(polygons, region->slices, "set_slices"));
}, py::arg("polygons"),
"Replace this region's sliced surfaces from a list of (N,2) int64 ndarrays (scaled "
"coords) or [contour, [holes...]] pairs (orientation normalized: contour CCW / holes "
"CW; surface_type carried forward from the replaced surfaces, else stInternal).\n"
"MUTATION-CASCADE: at the Slice boundary this is the primary, fully-supported entry "
"point -- the split slice loop runs make_perimeters() afterward, so the change cascades "
"into perimeters and everything downstream (final G-code).\n"
"PERSISTENCE (v1 limitation): the mutation is written into region->slices, but the "
"pre-hook geometry is also retained in each Layer's raw_slices backup (taken by "
"slice() BEFORE this hook fires). The mutation therefore survives only while posSlice "
"stays cached AND perimeters are not re-run from those restored raw slices: "
"make_perimeters() calls restore_untyped_slices(), which overwrites slices from "
"raw_slices, so a config change that re-runs perimeters without re-slicing (e.g. "
"wall_loops) silently reverts to the original geometry while posSlice stays cached "
"(this hook does NOT re-fire). Re-selecting the plugin -- or any other "
"posSlice-invalidating change -- re-fires this hook and re-applies the mutation. "
"Propagating the mutation into raw_slices is a known v1 limitation.\n"
"DUPLICATES: identical objects share Layer*, so the mutation on the object that slices "
"is automatically seen by its duplicates; objects that must mutate independently must "
"not be identical.\n"
"Raises ValueError on malformed input. Valid only during the execute(ctx) call.")
// MUTATOR (Task 11). Replace this region's fill (infill-prep) surfaces; identical input
// format and validation to set_slices.
.def("set_fill_surfaces", [](const LayerRegionView& v, py::object polygons) {
auto* region = const_cast<LayerRegion*>(v.r);
region->fill_surfaces.set(surfaces_from_py(polygons, region->fill_surfaces, "set_fill_surfaces"));
}, py::arg("polygons"),
"Replace this region's fill (infill-prep) surfaces; same input format/validation as "
"set_slices.\n"
"MUTATION-CASCADE: at the Infill boundary this changes the stored surfaces but does NOT "
"regenerate the already-built `fills` toolpaths in v1.\n"
"Raises ValueError on malformed input. Valid only during the execute(ctx) call.");
py::class_<LayerView>(slicing, "LayerView")
.def_property_readonly("slice_z", [](const LayerView& v) { return v.l->slice_z; })
.def_property_readonly("print_z", [](const LayerView& v) { return v.l->print_z; })
.def_property_readonly("height", [](const LayerView& v) { return v.l->height; })
.def("lslices", [](const LayerView& v) {
py::list out;
for (const ExPolygon& e : v.l->lslices)
out.append(ExPolygonView{ &e, v.owner });
return out;
}, "Merged per-layer islands as [ExPolygonView]. "
"Valid only during the execute(ctx) call.")
.def("regions", [](const LayerView& v) {
py::list out;
for (const LayerRegion* r : v.l->regions())
out.append(LayerRegionView{ r, v.owner });
return out;
}, "Per-region views as [LayerRegionView]. "
"Valid only during the execute(ctx) call.")
// MUTATOR (Task 11). Replace this layer's merged islands (lslices) and refresh the
// cache-invariant `lslices_bboxes` (one BoundingBox per island via get_extents). Same
// input format/validation as LayerRegionView.set_slices. Writes through the const view
// by const_cast.
.def("set_lslices", [](const LayerView& v, py::object islands) {
auto* layer = const_cast<Layer*>(v.l);
layer->lslices = parse_expolygon_list(islands, "set_lslices");
layer->lslices_bboxes.clear();
layer->lslices_bboxes.reserve(layer->lslices.size());
for (const ExPolygon& island : layer->lslices)
layer->lslices_bboxes.emplace_back(get_extents(island));
}, py::arg("islands"),
"Replace this layer's merged islands (lslices) from a list of (N,2) int64 ndarrays "
"(scaled coords) or [contour, [holes...]] pairs, and refresh lslices_bboxes (one "
"bounding box per island via get_extents) so the bbox cache stays consistent. Same "
"input format/validation as LayerRegionView.set_slices. Raises ValueError on malformed "
"input. Valid only during the execute(ctx) call.");
py::class_<PrintObjectView>(slicing, "PrintObjectView")
.def("layers", [](const PrintObjectView& v) {
py::list out;
for (const Layer* l : v.o->layers())
out.append(LayerView{ l, v.owner });
return out;
}, "Object layers as [LayerView]. Valid only during the execute(ctx) call.");
py::class_<SlicingPipelineContext>(slicing, "SlicingPipelineContext")
.def_readonly("orca_version", &SlicingPipelineContext::orca_version)
.def_readonly("step", &SlicingPipelineContext::step)
.def_property_readonly("object", [](const SlicingPipelineContext& ctx) -> py::object {
if (ctx.object == nullptr)
return py::none();
return py::cast(PrintObjectView{ ctx.object, ctx.owner });
}, "PrintObjectView for object-scoped steps, or None for print-wide steps. "
"Valid only during the execute(ctx) call.")
.def("cancelled", &SlicingPipelineContext::cancelled);
py::class_<SlicingPipelinePluginCapability, PluginCapabilityInterface,
PySlicingPipelinePluginCapabilityTrampoline,
std::shared_ptr<SlicingPipelinePluginCapability>>(slicing, "SlicingPipelineCapabilityBase")
.def(py::init<>())
.def("get_type", &SlicingPipelinePluginCapability::get_type)
.def("execute", &SlicingPipelinePluginCapability::execute);
}
} // namespace Slic3r

View File

@@ -0,0 +1,65 @@
#pragma once
#include "slic3r/plugin/PythonPluginInterface.hpp"
#include "libslic3r/Print.hpp" // SlicingPipelineStep, PrintObject
#include "libslic3r/Layer.hpp" // Layer, LayerRegion, SurfaceCollection
#include "libslic3r/Surface.hpp" // Surface, SurfaceType
#include "libslic3r/ExPolygon.hpp" // ExPolygon, Polygon
#include <pybind11/pybind11.h>
#include <string>
namespace Slic3r {
// ---------------------------------------------------------------------------
// Read-graph geometry views (Task 8).
//
// LIFETIME (mandatory): each view is a thin, non-owning wrapper holding a raw
// pointer into a buffer owned by the Print / PrintObject that the slicing
// pipeline mutates and frees between steps. A view — and every numpy array a
// view hands out (ExPolygonView::contour()/holes()) — is valid ONLY for the
// duration of the execute(ctx) call that produced it. The `owner` capsule pins
// the owning SlicingPipelineContext's Print* alive for the array's lifetime,
// but the underlying std::vector storage may be reallocated by the next
// pipeline step, so a Python plugin MUST NOT stash a view or an array across
// execute() calls or read one after execute() returns. Read now, copy what you
// need, and let the views go.
//
// Read accessors are zero-copy and non-owning as described above. The 2D-geometry
// mutators added in Task 11 (LayerRegionView.set_slices/set_fill_surfaces,
// LayerView.set_lslices, SurfaceView.set_type) write THROUGH these const views by
// const_cast: the pointed-to Layer/LayerRegion/Surface are genuinely non-const
// (owned mutably by the Print; the dispatcher merely hands them out as const), the
// same pattern the C++ slicing-pipeline hook uses. Mutations take effect on the live
// slicing graph and cascade per the per-method contract documented in the bindings.
// ---------------------------------------------------------------------------
struct ExPolygonView { const ExPolygon* ex; pybind11::capsule owner; };
struct SurfaceView { const Surface* s; pybind11::capsule owner; };
struct LayerRegionView { const LayerRegion* r; pybind11::capsule owner; };
struct LayerView { const Layer* l; pybind11::capsule owner; };
struct PrintObjectView { const PrintObject* o; pybind11::capsule owner; };
// A single flattened toolpath (Task 9). `path` points into a Print-owned
// ExtrusionEntityCollection (a LayerRegion's `perimeters`/`fills`); like every
// view above it is non-owning and valid ONLY during the producing execute(ctx)
// call, with `owner` pinning that Print* alive for any array points() hands out.
struct PathData { const ExtrusionPath* path; pybind11::capsule owner; };
struct SlicingPipelineContext {
std::string orca_version;
SlicingPipelineStep step { SlicingPipelineStep::Slice };
Print* print { nullptr }; // always present
const PrintObject* object { nullptr }; // null for print-wide steps
// Capsule pinning `print` alive for any zero-copy array a view hands out.
// Populated by Task 10's dispatcher; a default (empty) capsule is fine for
// print-wide steps and for unit tests exercising views over static data.
pybind11::capsule owner;
bool cancelled() const; // -> print->canceled()
};
class SlicingPipelinePluginCapability : public PluginCapabilityInterface {
public:
PluginCapabilityType get_type() const override { return PluginCapabilityType::SlicingPipeline; }
virtual ExecutionResult execute(SlicingPipelineContext& ctx) = 0;
static void RegisterBindings(pybind11::module_& module, pybind11::enum_<PluginCapabilityType>& pluginTypes);
};
} // namespace Slic3r

View File

@@ -0,0 +1,16 @@
#pragma once
#include "SlicingPipelinePluginCapability.hpp"
#include "slic3r/plugin/PyPluginTrampoline.hpp"
namespace Slic3r {
class PySlicingPipelinePluginCapabilityTrampoline : public PyPluginCommonTrampoline<SlicingPipelinePluginCapability> {
public:
using PyPluginCommonTrampoline<SlicingPipelinePluginCapability>::PyPluginCommonTrampoline;
ExecutionResult execute(SlicingPipelineContext& ctx) override {
ORCA_PY_OVERRIDE_AUDITED(
::Slic3r::PluginAuditManager::AuditMode::Loading,
[]{}, PYBIND11_OVERRIDE_PURE,
ExecutionResult, SlicingPipelinePluginCapability, execute, ctx);
}
};
} // namespace Slic3r

View File

@@ -13,6 +13,7 @@ add_executable(${_TEST_NAME}_tests
test_printgcode.cpp
test_printobject.cpp
test_skirt_brim.cpp
test_slicing_pipeline_hook.cpp
test_support_material.cpp
test_trianglemesh.cpp
)

View File

@@ -0,0 +1,215 @@
#include <catch2/catch_test_macros.hpp>
#include "libslic3r/PrintConfig.hpp"
using namespace Slic3r;
TEST_CASE("slicing_pipeline_plugin option exists and defaults empty", "[slicing_pipeline]") {
DynamicPrintConfig cfg = DynamicPrintConfig::full_print_config();
const ConfigOptionStrings* opt = cfg.option<ConfigOptionStrings>("slicing_pipeline_plugin");
REQUIRE(opt != nullptr);
CHECK(opt->values.empty());
const ConfigOptionDef* def = cfg.def()->get("slicing_pipeline_plugin");
REQUIRE(def != nullptr);
CHECK(def->support_plugin == true);
CHECK(def->gui_type == ConfigOptionDef::GUIType::plugin_picker);
}
#include "libslic3r/Print.hpp"
TEST_CASE("slicing pipeline hook setter is a no-op-safe injection", "[slicing_pipeline]") {
int calls = 0;
Slic3r::Print::set_slicing_pipeline_hook_fn(
[&](Slic3r::Print&, const Slic3r::PrintObject*, Slic3r::SlicingPipelineStep){ ++calls; });
Slic3r::Print::set_slicing_pipeline_hook_fn(nullptr); // reset — must be legal
CHECK(calls == 0);
}
#include "test_data.hpp"
#include <vector>
#include <algorithm>
using namespace Slic3r::Test;
TEST_CASE("SlicingPipeline hook fires once per step per object in order", "[slicing_pipeline]") {
struct Call { const Slic3r::PrintObject* obj; Slic3r::SlicingPipelineStep step; };
std::vector<Call> calls;
Slic3r::Print::set_slicing_pipeline_hook_fn(
[&](Slic3r::Print&, const Slic3r::PrintObject* o, Slic3r::SlicingPipelineStep s){ calls.push_back({o, s}); });
Slic3r::Print print; Slic3r::Model model;
Slic3r::DynamicPrintConfig config = Slic3r::DynamicPrintConfig::full_print_config();
config.set_key_value("slicing_pipeline_plugin", new Slic3r::ConfigOptionStrings({"probe"})); // activate
init_print({TestMesh::cube_20x20x20}, print, model, config);
print.process();
Slic3r::Print::set_slicing_pipeline_hook_fn(nullptr);
using S = Slic3r::SlicingPipelineStep;
auto count = [&](S s){ return std::count_if(calls.begin(), calls.end(), [&](const Call& c){ return c.step == s; }); };
CHECK(count(S::Slice) == 1);
CHECK(count(S::Perimeters) == 1);
CHECK(count(S::Infill) == 1);
CHECK(count(S::WipeTower) == 1);
CHECK(count(S::SkirtBrim) == 1);
// print-wide steps carry a null object:
for (const auto& c : calls)
if (c.step == S::WipeTower || c.step == S::SkirtBrim) CHECK(c.obj == nullptr);
// Slice must fire before Perimeters for the same object:
auto idx = [&](S s){ for (size_t i=0;i<calls.size();++i) if (calls[i].step==s) return (int)i; return -1; };
CHECK(idx(S::Slice) < idx(S::Perimeters));
}
#include <sstream>
TEST_CASE("Inactive hook: process output is byte-identical (no-op hook == unset)", "[slicing_pipeline]") {
// Three configurations must all normalize to the same G-code:
// (activate=false, hook=none) baseline -- feature entirely absent.
// (activate=false, hook=noop) hook registered but option empty -> gated off, never fires.
// (activate=true, hook=noop) hook ACTIVE and firing at every pipeline seam, mutating
// nothing. This is the real backward-compat claim: an active
// but non-mutating hook must not perturb the output.
auto run = [](bool activate, bool set_noop_hook) {
Slic3r::Print print; Slic3r::Model model;
auto config = Slic3r::DynamicPrintConfig::full_print_config();
// Activating requires BOTH a non-empty option and a registered hook (see Print::apply).
if (activate)
config.set_key_value("slicing_pipeline_plugin", new Slic3r::ConfigOptionStrings({"probe"}));
if (set_noop_hook)
Slic3r::Print::set_slicing_pipeline_hook_fn([](Slic3r::Print&, const Slic3r::PrintObject*, Slic3r::SlicingPipelineStep){});
else
Slic3r::Print::set_slicing_pipeline_hook_fn(nullptr);
init_print({TestMesh::cube_20x20x20}, print, model, config);
std::string g = Slic3r::Test::gcode(print);
Slic3r::Print::set_slicing_pipeline_hook_fn(nullptr);
return g;
};
// Pre-existing nondeterminism unrelated to the hook makes a raw string compare
// impossible: exported gcode embeds a wall-clock timestamp and ids derived from
// the process-global ObjectID counter (never reset between runs) in a handful of
// comment lines. Strip exactly those comment lines; every other byte -- all
// motion/extrusion/temperature commands and all remaining comments -- is still
// compared, so the assertion still proves the inactive hook leaves all
// machine-meaningful output byte-identical.
auto normalize = [](const std::string& gcode) {
std::string out; out.reserve(gcode.size());
std::istringstream in(gcode);
std::string line;
while (std::getline(in, line)) {
if (line.compare(0, 15, "; generated by ") == 0) continue; // wall-clock timestamp
if (line.compare(0, 18, "; model label id: ") == 0) continue; // ObjectID-derived
// "; [stop] printing object <name> id:N copy M" and
// "; start/stop printing object, unique label id: N" (ObjectID-derived):
if (line.find("printing object") != std::string::npos && line.find(" id:") != std::string::npos) continue;
// Config-dump comment: the active run legitimately records the selected plugin
// ("; slicing_pipeline_plugin = probe") while the baseline leaves it empty. This
// is a machine-irrelevant comment, not motion -- strip it so the comparison isolates
// whether the active-but-non-mutating hook perturbs the real toolpath.
if (line.find("slicing_pipeline_plugin") != std::string::npos) continue;
out += line; out += '\n';
}
return out;
};
const std::string baseline = normalize(run(false, false)); // feature absent
CHECK(normalize(run(false, true)) == baseline); // gated off: hook never fires
CHECK(normalize(run(true, true)) == baseline); // active no-op hook fires everywhere, mutates nothing
}
// Fix 4(a): gating negative path. With the option EMPTY the plugin is inactive, so a
// registered hook must NOT fire even once across a full slice (m_pipeline_plugin_active
// stays false in Print::apply). Distinct from the byte-identical test above: this asserts
// the gate directly by counting invocations rather than comparing output.
TEST_CASE("Empty option: registered hook is gated off and never fires", "[slicing_pipeline]") {
int calls = 0;
Slic3r::Print::set_slicing_pipeline_hook_fn(
[&](Slic3r::Print&, const Slic3r::PrintObject*, Slic3r::SlicingPipelineStep){ ++calls; });
Slic3r::Print print; Slic3r::Model model;
auto config = Slic3r::DynamicPrintConfig::full_print_config();
// option left EMPTY -> inactive regardless of the registered hook.
init_print({TestMesh::cube_20x20x20}, print, model, config);
print.process();
Slic3r::Print::set_slicing_pipeline_hook_fn(nullptr);
CHECK(calls == 0);
}
// Fix 4(b): duplicate-skip gating. Two ModelObjects that share one mesh_ptr are detected as
// identical by Print::process()'s is_print_object_the_same(); the second becomes a shared
// (duplicate) object and is NOT re-sliced, so the Slice hook must fire exactly once even
// though there are two print objects. The clone shares mesh_ptr and copies the volume
// transformation/config (ModelVolume copy ctor), which the equality check requires.
TEST_CASE("Duplicate objects share a slice: Slice hook fires exactly once", "[slicing_pipeline]") {
int slice_calls = 0, perim_calls = 0;
Slic3r::Print::set_slicing_pipeline_hook_fn(
[&](Slic3r::Print&, const Slic3r::PrintObject*, Slic3r::SlicingPipelineStep s){
if (s == Slic3r::SlicingPipelineStep::Slice) ++slice_calls;
if (s == Slic3r::SlicingPipelineStep::Perimeters) ++perim_calls;
});
Slic3r::Print print; Slic3r::Model model;
auto config = Slic3r::DynamicPrintConfig::full_print_config();
config.set_key_value("slicing_pipeline_plugin", new Slic3r::ConfigOptionStrings({"probe"})); // activate
// init_print builds one arranged, on-bed cube object (o1).
init_print({TestMesh::cube_20x20x20}, print, model, config);
Slic3r::ModelObject* o1 = model.objects.front();
// Model::add_object(const ModelObject&) force-sets object extruder=1 on the clone; give o1
// the same so the two objects' configs match (is_print_object_the_same compares config).
if (!o1->config.has("extruder"))
o1->config.set_key_value("extruder", new Slic3r::ConfigOptionInt(1));
// Clone o1: shares mesh_ptr and copies the volume transformation + config (genuine duplicate).
Slic3r::ModelObject* o2 = model.add_object(*o1);
// Shift the clone in X so validate() sees no collision (20mm cubes -> 40mm centres = 20mm gap).
for (Slic3r::ModelInstance* inst : o2->instances)
inst->set_offset(inst->get_offset() + Slic3r::Vec3d(40.0, 0.0, 0.0));
print.apply(model, config);
print.validate();
print.set_status_silent();
print.process();
Slic3r::Print::set_slicing_pipeline_hook_fn(nullptr);
REQUIRE(print.objects().size() == 2); // two print objects present...
CHECK(slice_calls == 1); // ...but the duplicate is skipped -> one slice
CHECK(perim_calls == 1); // and one perimeters pass (the sliced object)
}
#include "libslic3r/Layer.hpp" // Layer, LayerRegion (full defs for the cascade hook)
#include "libslic3r/ClipperUtils.hpp" // offset_ex
// Task 11: the correctness heart of the mutation feature. A C++ hook insets every
// region's `slices` at the Slice boundary (via SurfaceCollection::set with offset
// polygons); because make_perimeters() derives fill_surfaces from slices AFTER the
// Slice hook fires (see Print::process's split slice loop), the downstream
// fill_surfaces area must shrink relative to a baseline (un-inset) run. This proves
// the mutation cascade end-to-end using the same C++ APIs the Python mutators wrap.
TEST_CASE("Mutating slices at the Slice boundary cascades downstream", "[slicing_pipeline]") {
auto fill_area = [](bool inset) {
Slic3r::Print print; Slic3r::Model model;
auto config = Slic3r::DynamicPrintConfig::full_print_config();
config.set_key_value("slicing_pipeline_plugin", new Slic3r::ConfigOptionStrings({"probe"}));
if (inset) Slic3r::Print::set_slicing_pipeline_hook_fn(
[](Slic3r::Print&, const Slic3r::PrintObject* o, Slic3r::SlicingPipelineStep s){
if (s != Slic3r::SlicingPipelineStep::Slice || !o) return;
for (Slic3r::Layer* l : const_cast<Slic3r::PrintObject*>(o)->layers())
for (Slic3r::LayerRegion* r : l->regions()) {
Slic3r::Surfaces in = r->slices.surfaces;
for (auto& sf : in) sf.expolygon = offset_ex(sf.expolygon, -scale_(1.0)).front();
r->slices.set(std::move(in));
}
});
else Slic3r::Print::set_slicing_pipeline_hook_fn(nullptr);
init_print({TestMesh::cube_20x20x20}, print, model, config);
print.process();
double a = 0; for (auto* l : print.objects().front()->layers()) for (auto* r : l->regions()) for (auto& s : r->fill_surfaces.surfaces) a += s.expolygon.area();
Slic3r::Print::set_slicing_pipeline_hook_fn(nullptr);
return a;
};
CHECK(fill_area(true) < fill_area(false));
}
TEST_CASE("Changing slicing_pipeline_plugin invalidates posSlice", "[slicing_pipeline]") {
Slic3r::Print print; Slic3r::Model model;
auto config = Slic3r::DynamicPrintConfig::full_print_config();
init_print({TestMesh::cube_20x20x20}, print, model, config);
print.process();
REQUIRE(print.objects().front()->is_step_done(posSlice));
config.set_key_value("slicing_pipeline_plugin", new Slic3r::ConfigOptionStrings({"probe"}));
print.apply(model, config);
CHECK_FALSE(print.objects().front()->is_step_done(posSlice)); // re-slice required
}

View File

@@ -4,6 +4,7 @@ add_executable(${_TEST_NAME}_tests
test_plugin_host_api.cpp
test_plugin_capability_identifier.cpp
test_plugin_install.cpp
test_slicing_pipeline_bindings.cpp
)
if (MSVC)

View File

@@ -0,0 +1,38 @@
#pragma once
// Shared embedded-interpreter bootstrap for slic3rutils tests that need a live Python
// interpreter (test_plugin_host_api.cpp, test_slicing_pipeline_bindings.cpp, ...).
#include <pybind11/embed.h>
#include <pybind11/pybind11.h>
#include <slic3r/plugin/PythonPluginBridge.hpp>
namespace {
void ensure_python_initialized()
{
// Deliberately a bare scoped_interpreter rather than Slic3r::PythonInterpreter:
// `orca` is a PYBIND11_EMBEDDED_MODULE compiled into this test binary, so importing
// it needs no bundled stdlib/sys.path, and the deterministic assertions are
// independent of the host's Python. PythonInterpreter::initialize() expects the
// bundled Python home laid out next to the app bundle (lib/python3.12/encodings),
// which is not deployed beside the test binary, so using it here would fail to find
// a home on macOS/Linux. The optional numpy-backed assertions are guarded at runtime.
if (!Py_IsInitialized()) {
static pybind11::scoped_interpreter interpreter;
(void) interpreter;
}
}
pybind11::module_ import_orca_module()
{
ensure_python_initialized();
// Force PythonPluginBridge.cpp into the test binary so the embedded
// PYBIND11_EMBEDDED_MODULE(orca, ...) registration is available.
(void) Slic3r::PythonPluginBridge::instance();
return pybind11::module_::import("orca");
}
} // namespace

View File

@@ -5,6 +5,8 @@
#include <libslic3r/TriangleMesh.hpp>
#include <slic3r/plugin/PythonPluginBridge.hpp>
#include "python_test_support.hpp"
#include <pybind11/embed.h>
#include <pybind11/pybind11.h>
@@ -14,30 +16,8 @@ namespace py = pybind11;
namespace {
void ensure_python_initialized()
{
// Deliberately a bare scoped_interpreter rather than Slic3r::PythonInterpreter:
// `orca` is a PYBIND11_EMBEDDED_MODULE compiled into this test binary, so importing
// it needs no bundled stdlib/sys.path, and the deterministic assertions are
// independent of the host's Python. PythonInterpreter::initialize() expects the
// bundled Python home laid out next to the app bundle (lib/python3.12/encodings),
// which is not deployed beside the test binary, so using it here would fail to find
// a home on macOS/Linux. The optional numpy-backed assertions are guarded at runtime.
if (!Py_IsInitialized()) {
static py::scoped_interpreter interpreter;
(void) interpreter;
}
}
py::module_ import_orca_module()
{
ensure_python_initialized();
// Force PythonPluginBridge.cpp into the test binary so the embedded
// PYBIND11_EMBEDDED_MODULE(orca, ...) registration is available.
(void) Slic3r::PythonPluginBridge::instance();
return py::module_::import("orca");
}
// import_orca_module() lives in python_test_support.hpp (shared with
// test_slicing_pipeline_bindings.cpp).
bool has_attr(const py::handle& object, const char* name)
{

View File

@@ -0,0 +1,443 @@
#include <catch2/catch_test_macros.hpp>
#include "slic3r/plugin/PythonPluginInterface.hpp"
using namespace Slic3r;
TEST_CASE("SlicingPipeline capability-type string maps round-trip", "[slicing_pipeline]") {
CHECK(plugin_capability_type_to_string(PluginCapabilityType::SlicingPipeline) == "slicing-pipeline");
CHECK(plugin_capability_type_display_name(PluginCapabilityType::SlicingPipeline) == "Slicing Pipeline");
CHECK(plugin_capability_type_from_string("slicing-pipeline") == PluginCapabilityType::SlicingPipeline);
CHECK(plugin_capability_type_from_string("SLICING-PIPELINE") == PluginCapabilityType::SlicingPipeline);
CHECK(plugin_capability_type_from_string("nope") == PluginCapabilityType::Unknown);
}
#include "python_test_support.hpp"
#include "slic3r/plugin/pluginTypes/slicingPipeline/SlicingNumpy.hpp"
#include "slic3r/plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.hpp"
#include "libslic3r/Point.hpp"
#include "libslic3r/ExPolygon.hpp"
#include "libslic3r/Surface.hpp"
#include "libslic3r/Layer.hpp"
#include "libslic3r/ExtrusionEntity.hpp"
#include "libslic3r/ExtrusionEntityCollection.hpp"
#include <catch2/matchers/catch_matchers_floating_point.hpp>
#include <pybind11/embed.h>
#include <pybind11/numpy.h>
namespace py = pybind11;
TEST_CASE("make_readonly_rows builds a read-only (N,2) int64 view", "[slicing_pipeline]") {
ensure_python_initialized(); // helper already used by test_plugin_host_api.cpp
py::gil_scoped_acquire gil;
// make_readonly_rows() constructs a py::array_t, which requires numpy to be
// importable in the embedded interpreter. The unit-test interpreter ships no
// site-packages (same condition test_plugin_host_api.cpp's TriangleMesh numpy
// test guards against), so skip the array-backed assertions when numpy is
// unavailable there rather than fail on an environment quirk.
bool have_numpy = false;
try {
py::module_::import("numpy");
have_numpy = true;
} catch (const py::error_already_set&) {
have_numpy = false;
}
if (!have_numpy) {
SKIP("numpy unavailable in unit-test interpreter");
}
static Slic3r::Points pts = { Slic3r::Point(10, 20), Slic3r::Point(30, 40) };
py::capsule keepalive(&pts, [](void*){});
py::array a = Slic3r::make_readonly_rows<coord_t, 2>(keepalive, pts.front().data(), (py::ssize_t)pts.size());
CHECK(a.dtype().kind() == 'i');
CHECK(a.itemsize() == 8); // int64
CHECK(a.shape(0) == 2);
CHECK(a.shape(1) == 2);
CHECK_FALSE(a.writeable());
auto r = a.unchecked<coord_t, 2>();
CHECK(r(0,0) == 10); CHECK(r(1,1) == 40);
}
TEST_CASE("orca.slicing module: Step enum, context, and a Python capability can execute", "[slicing_pipeline]") {
ensure_python_initialized();
import_orca_module(); // forces PythonPluginBridge::instance() (see test_plugin_host_api.cpp:32-40)
py::gil_scoped_acquire gil;
py::module_ orca = py::module_::import("orca");
REQUIRE(py::hasattr(orca, "slicing"));
py::object slicing = orca.attr("slicing");
CHECK(py::hasattr(slicing, "Step"));
CHECK(py::hasattr(slicing.attr("Step"), "Slice"));
CHECK(py::hasattr(slicing, "SlicingPipelineContext"));
CHECK(py::hasattr(slicing, "SlicingPipelineCapabilityBase"));
// A trivial Python subclass whose execute() reports success, invoked via the C++ trampoline.
py::exec(R"(
import orca
class Probe(orca.slicing.SlicingPipelineCapabilityBase):
def get_name(self): return "probe"
def execute(self, ctx): return orca.ExecutionResult.success("ok")
_probe = Probe()
)");
// (Full C++ trampoline invocation with a real context is exercised in Task 8's tests.)
}
// Numpy-free half of Task 8: type registration, the SurfaceType enum, the module-level
// unscale() helper, and every non-array read accessor (surface_type / thickness /
// bridge_angle / extra_perimeters / expolygon / empty holes()). None of these
// materialize a py::array, so they run unconditionally (no numpy guard needed).
TEST_CASE("orca.slicing geometry views: types, SurfaceType, unscale, non-array accessors", "[slicing_pipeline]") {
using Catch::Matchers::WithinRel;
using Catch::Matchers::WithinAbs;
ensure_python_initialized();
import_orca_module();
py::gil_scoped_acquire gil;
py::object slicing = py::module_::import("orca").attr("slicing");
// All view types are registered in the submodule.
for (const char* name : { "ExPolygonView", "SurfaceView", "LayerRegionView",
"LayerView", "PrintObjectView", "SurfaceType" })
CHECK(py::hasattr(slicing, name));
// Read-graph traversal methods exist on the class objects (verified without a
// full Print, which slic3rutils cannot build).
CHECK(py::hasattr(slicing.attr("ExPolygonView"), "contour"));
CHECK(py::hasattr(slicing.attr("ExPolygonView"), "holes"));
CHECK(py::hasattr(slicing.attr("LayerRegionView"), "slices"));
CHECK(py::hasattr(slicing.attr("LayerRegionView"), "fill_surfaces"));
CHECK(py::hasattr(slicing.attr("LayerView"), "regions"));
CHECK(py::hasattr(slicing.attr("LayerView"), "lslices"));
CHECK(py::hasattr(slicing.attr("PrintObjectView"), "layers"));
CHECK(py::hasattr(slicing.attr("SlicingPipelineContext"), "object"));
// SurfaceType enum values round-trip to the C++ enumerators.
py::object ST = slicing.attr("SurfaceType");
CHECK(ST.attr("stTop").cast<Slic3r::SurfaceType>() == Slic3r::stTop);
CHECK(ST.attr("stInternalSolid").cast<Slic3r::SurfaceType>() == Slic3r::stInternalSolid);
CHECK(ST.attr("stPerimeter").cast<Slic3r::SurfaceType>() == Slic3r::stPerimeter);
CHECK(ST.attr("stCount").cast<Slic3r::SurfaceType>() == Slic3r::stCount);
// unscale() reads the live SCALING_FACTOR both when scaling and unscaling.
const coord_t scaled10 = (coord_t) scale_(10.0);
double mm = slicing.attr("unscale")(scaled10).cast<double>();
CHECK_THAT(mm, WithinRel(10.0, 1e-9));
// SurfaceView non-array accessors against a hand-built Surface.
Slic3r::Surface surf(Slic3r::stInternalSolid);
surf.thickness = 0.4;
surf.bridge_angle = -1.0;
surf.extra_perimeters = 2;
py::capsule owner(&surf, [](void*){}); // no-op owner (data outlives the view here)
py::object sv = py::cast(Slic3r::SurfaceView{ &surf, owner });
CHECK(sv.attr("surface_type").cast<Slic3r::SurfaceType>() == Slic3r::stInternalSolid);
CHECK_THAT(sv.attr("thickness").cast<double>(), WithinRel(0.4, 1e-9));
CHECK_THAT(sv.attr("bridge_angle").cast<double>(), WithinAbs(-1.0, 1e-12));
CHECK(sv.attr("extra_perimeters").cast<int>() == 2);
// expolygon accessor yields an ExPolygonView; holes() on an empty ExPolygon is an
// empty list and materializes no array (so it stays outside the numpy guard).
py::object exv = sv.attr("expolygon");
CHECK(py::hasattr(exv, "contour"));
CHECK(exv.attr("holes")().cast<py::list>().size() == 0);
}
TEST_CASE("ExPolygonView.contour()/holes() are read-only int64 (N,2) views in scaled coords", "[slicing_pipeline]") {
ensure_python_initialized();
import_orca_module();
py::gil_scoped_acquire gil;
// make_readonly_rows() constructs a py::array, which needs numpy at runtime; the
// unit-test interpreter ships none. Skip the array-backed assertions when numpy is
// unavailable (same convention as the make_readonly_rows test above).
bool have_numpy = false;
try {
py::module_::import("numpy");
have_numpy = true;
} catch (const py::error_already_set&) {
have_numpy = false;
}
if (!have_numpy) {
SKIP("numpy unavailable in unit-test interpreter");
}
const coord_t s = (coord_t) scale_(10.0);
Slic3r::ExPolygon ex;
ex.contour.points = { Slic3r::Point(0, 0), Slic3r::Point(s, 0),
Slic3r::Point(s, s), Slic3r::Point(0, s) };
Slic3r::Polygon hole;
hole.points = { Slic3r::Point(1, 1), Slic3r::Point(2, 1), Slic3r::Point(2, 2) };
ex.holes = { hole };
py::capsule owner(&ex, [](void*){});
py::object view = py::cast(Slic3r::ExPolygonView{ &ex, owner });
py::array c = view.attr("contour")().cast<py::array>();
CHECK(c.dtype().kind() == 'i');
CHECK(c.itemsize() == 8); // int64
CHECK(c.shape(0) == 4);
CHECK(c.shape(1) == 2);
CHECK_FALSE(c.writeable());
auto rc = c.cast<py::array_t<coord_t>>().unchecked<2>();
CHECK(rc(0, 0) == 0);
CHECK(rc(1, 0) == s);
CHECK(rc(2, 1) == s);
// holes() -> list of read-only (N,2) int64 views.
py::list holes = view.attr("holes")().cast<py::list>();
CHECK(holes.size() == 1);
py::array h0 = holes[0].cast<py::array>();
CHECK(h0.shape(0) == 3);
CHECK(h0.shape(1) == 2);
CHECK_FALSE(h0.writeable());
}
// ---------------------------------------------------------------------------
// Task 9: toolpaths (PathData over perimeters/fills).
//
// LayerRegion's ctor is protected (constructed only by Layer/PrintObject). A
// trivial derived struct lets a unit test build one with null layer/region
// pointers — perimeters()/fills() only read the public `perimeters`/`fills`
// collections, never the layer/region back-pointers.
// ---------------------------------------------------------------------------
namespace {
struct TestLayerRegion : Slic3r::LayerRegion {
TestLayerRegion() : Slic3r::LayerRegion(nullptr, nullptr) {}
};
// Build a realistic nested perimeters collection into `region.perimeters`:
// perimeters (outer) -> inner collection -> [ ExtrusionLoop(pathA), ExtrusionPath(pathB) ]
// This exercises both the recursive descent through nested collections and the
// decomposition of an ExtrusionLoop into its contained ExtrusionPath (flatten()
// does NOT decompose loops, hence the hand-rolled recursive walk).
static void build_nested_perimeters(TestLayerRegion& region) {
using namespace Slic3r;
ExtrusionPath pathA(erExternalPerimeter); // -> "Outer wall"
pathA.mm3_per_mm = 0.05; pathA.width = 0.45f; pathA.height = 0.20f;
pathA.polyline.points = { Point3(0, 0, 0), Point3(10, 0, 0), Point3(10, 10, 0) };
ExtrusionPath pathB(erInternalInfill); // -> "Sparse infill"
pathB.mm3_per_mm = 0.03; pathB.width = 0.40f; pathB.height = 0.20f;
pathB.polyline.points = { Point3(1, 1, 0), Point3(2, 1, 0), Point3(2, 2, 0) };
ExtrusionEntityCollection inner;
inner.append(ExtrusionLoop(pathA)); // clone_move
inner.append(pathB); // clone
region.perimeters.append(inner); // nested (deep clone)
}
} // namespace
// Numpy-free half: perimeters() flattens the nested graph (descending through
// collections and decomposing loops) into a [PathData] list; role/width/height/
// mm3_per_mm are plain scalars, so these assertions run unconditionally.
TEST_CASE("orca.slicing LayerRegionView.perimeters()/fills(): PathData scalars over a nested graph", "[slicing_pipeline]") {
using Catch::Matchers::WithinRel;
ensure_python_initialized();
import_orca_module();
py::gil_scoped_acquire gil;
py::object slicing = py::module_::import("orca").attr("slicing");
CHECK(py::hasattr(slicing, "PathData"));
CHECK(py::hasattr(slicing.attr("LayerRegionView"), "perimeters"));
CHECK(py::hasattr(slicing.attr("LayerRegionView"), "fills"));
TestLayerRegion region;
build_nested_perimeters(region);
py::capsule owner(&region, [](void*){}); // no-op: region outlives the view
py::object lrv = py::cast(Slic3r::LayerRegionView{ &region, owner });
py::list ps = lrv.attr("perimeters")().cast<py::list>();
REQUIRE(ps.size() == 2); // loop's path + bare path
py::object pd0 = ps[0]; // pathA, from the loop
CHECK(pd0.attr("role").cast<std::string>() == "Outer wall");
CHECK_THAT(pd0.attr("width").cast<double>(), WithinRel(0.45, 1e-6));
CHECK_THAT(pd0.attr("height").cast<double>(), WithinRel(0.20, 1e-6));
CHECK_THAT(pd0.attr("mm3_per_mm").cast<double>(), WithinRel(0.05, 1e-9));
py::object pd1 = ps[1]; // pathB, bare
CHECK(pd1.attr("role").cast<std::string>() == "Sparse infill");
CHECK_THAT(pd1.attr("width").cast<double>(), WithinRel(0.40, 1e-6));
// fills is empty on this hand-built region.
CHECK(lrv.attr("fills")().cast<py::list>().size() == 0);
}
// Numpy-backed half: PathData.points() materializes a read-only (N,3) int64 view.
TEST_CASE("orca.slicing PathData.points() is a read-only (N,3) int64 view", "[slicing_pipeline]") {
ensure_python_initialized();
import_orca_module();
py::gil_scoped_acquire gil;
// make_readonly_rows() needs numpy at runtime; the unit-test interpreter ships
// none. Skip the array-backed assertions when numpy is unavailable (same
// convention as the make_readonly_rows / ExPolygonView tests above).
bool have_numpy = false;
try {
py::module_::import("numpy");
have_numpy = true;
} catch (const py::error_already_set&) {
have_numpy = false;
}
if (!have_numpy) {
SKIP("numpy unavailable in unit-test interpreter");
}
TestLayerRegion region;
build_nested_perimeters(region);
py::capsule owner(&region, [](void*){});
py::object lrv = py::cast(Slic3r::LayerRegionView{ &region, owner });
py::list ps = lrv.attr("perimeters")().cast<py::list>();
REQUIRE(ps.size() == 2);
// pathB has 3 points: (1,1,0), (2,1,0), (2,2,0).
py::array pts = ps[1].attr("points")().cast<py::array>();
CHECK(pts.dtype().kind() == 'i');
CHECK(pts.itemsize() == 8); // int64
CHECK(pts.shape(0) == 3);
CHECK(pts.shape(1) == 3);
CHECK_FALSE(pts.writeable());
auto r = pts.cast<py::array_t<coord_t>>().unchecked<2>();
CHECK(r(0, 0) == 1); CHECK(r(0, 1) == 1); CHECK(r(0, 2) == 0);
CHECK(r(1, 0) == 2);
CHECK(r(2, 1) == 2);
}
// ---------------------------------------------------------------------------
// Task 11: 2D-geometry mutators (set_slices / set_fill_surfaces / set_lslices / set_type).
//
// Numpy-free half: the four mutators are registered, set_type reclassifies a surface
// end-to-end (read back from C++), and the input validators raise ValueError on garbage.
// None of this materializes a py::array, so it runs unconditionally.
// ---------------------------------------------------------------------------
TEST_CASE("orca.slicing mutators: registration, set_type reclassify, and ValueError on garbage", "[slicing_pipeline]") {
ensure_python_initialized();
import_orca_module();
py::gil_scoped_acquire gil;
py::object slicing = py::module_::import("orca").attr("slicing");
// All four mutators are registered on their view classes.
CHECK(py::hasattr(slicing.attr("LayerRegionView"), "set_slices"));
CHECK(py::hasattr(slicing.attr("LayerRegionView"), "set_fill_surfaces"));
CHECK(py::hasattr(slicing.attr("LayerView"), "set_lslices"));
CHECK(py::hasattr(slicing.attr("SurfaceView"), "set_type"));
// set_type reclassifies a surface in place (reassigns surface_type; geometry untouched).
TestLayerRegion region;
region.slices.surfaces.emplace_back(Slic3r::Surface(Slic3r::stInternal));
py::capsule owner(&region, [](void*){}); // no-op: region outlives the view
py::object lrv = py::cast(Slic3r::LayerRegionView{ &region, owner });
py::list sl = lrv.attr("slices")().cast<py::list>();
REQUIRE(sl.size() == 1);
py::object sv = sl[0];
CHECK(sv.attr("surface_type").cast<Slic3r::SurfaceType>() == Slic3r::stInternal);
sv.attr("set_type")(py::cast(Slic3r::stTop)); // reclassify -> stTop
CHECK(region.slices.surfaces.front().surface_type == Slic3r::stTop); // C++ side reflects it
CHECK(sv.attr("surface_type").cast<Slic3r::SurfaceType>() == Slic3r::stTop); // and via the view
// Malformed inputs raise ValueError (pybind-translated), never corrupt geometry. These
// paths are rejected before any numpy array is materialized, so they need no numpy guard.
auto raises_value_error = [](py::object callable, py::object arg) {
try { callable(arg); return false; }
catch (py::error_already_set& e) { return e.matches(PyExc_ValueError); }
};
CHECK(raises_value_error(lrv.attr("set_slices"), py::list())); // empty list
CHECK(raises_value_error(lrv.attr("set_slices"), py::int_(42))); // not a sequence
CHECK(raises_value_error(lrv.attr("set_slices"), py::str("nope"))); // string rejected
// set_slices is guaranteed to have left the original single surface untouched on failure.
CHECK(region.slices.surfaces.size() == 1);
}
// Numpy-backed half: set_slices with real (N,2) int64 ndarrays replaces the region's
// surfaces, carries surface_type forward from the replaced surfaces, normalizes orientation
// (a CW contour becomes CCW), and the change is visible both from C++ and back through the view.
TEST_CASE("orca.slicing set_slices: ndarray input mutates the slice geometry (read back both ways)", "[slicing_pipeline]") {
using Catch::Matchers::WithinRel;
ensure_python_initialized();
import_orca_module();
py::gil_scoped_acquire gil;
// set_slices parses (N,2) int64 ndarrays, which requires numpy in the embedded
// interpreter; the unit-test interpreter ships none, so skip the array-backed
// assertions when numpy is unavailable (same convention as the read-view tests above).
bool have_numpy = false;
try {
py::module_::import("numpy");
have_numpy = true;
} catch (const py::error_already_set&) {
have_numpy = false;
}
if (!have_numpy) {
SKIP("numpy unavailable in unit-test interpreter");
}
py::module_ np = py::module_::import("numpy");
py::object i64 = np.attr("int64");
const coord_t s = (coord_t) scale_(10.0);
// Seed one stInternalSolid surface so surface_type carry-forward is observable.
TestLayerRegion region;
region.slices.surfaces.emplace_back(Slic3r::Surface(Slic3r::stInternalSolid));
py::capsule owner(&region, [](void*){});
py::object lrv = py::cast(Slic3r::LayerRegionView{ &region, owner });
// A CW square contour (points wound clockwise) -> the mutator must re-orient it CCW.
auto make_arr = [&](std::initializer_list<std::pair<coord_t,coord_t>> pts) {
py::list rows;
for (auto& p : pts) rows.append(py::make_tuple(p.first, p.second));
return np.attr("array")(rows, py::arg("dtype") = i64);
};
py::list polys;
polys.append(make_arr({ {0,0}, {0,s}, {s,s}, {s,0} })); // clockwise winding
lrv.attr("set_slices")(polys);
// C++ side reflects the replacement.
REQUIRE(region.slices.surfaces.size() == 1);
const Slic3r::Surface& out = region.slices.surfaces.front();
CHECK(out.surface_type == Slic3r::stInternalSolid); // carried forward from the template
REQUIRE(out.expolygon.contour.points.size() == 4);
CHECK(out.expolygon.contour.is_counter_clockwise()); // orientation normalized (input was CW)
CHECK_THAT(out.expolygon.area(), WithinRel((double) s * (double) s, 1e-9)); // s x s square
// Read back through the view: slices()[0].expolygon.contour() is a (4,2) array.
py::list sl = lrv.attr("slices")().cast<py::list>();
REQUIRE(sl.size() == 1);
py::array c = sl[0].attr("expolygon").attr("contour")().cast<py::array>();
CHECK(c.shape(0) == 4);
CHECK(c.shape(1) == 2);
// [contour, [holes...]] form: a hole is accepted and normalized to CW.
TestLayerRegion region2;
py::capsule owner2(&region2, [](void*){});
py::object lrv2 = py::cast(Slic3r::LayerRegionView{ &region2, owner2 });
py::list contour_and_holes;
contour_and_holes.append(make_arr({ {0,0}, {s,0}, {s,s}, {0,s} })); // CCW contour
py::list holes;
holes.append(make_arr({ {s/4,s/4}, {s/2,s/4}, {s/2,s/2} })); // CCW hole -> must flip CW
contour_and_holes.append(holes);
py::list polys2;
polys2.append(contour_and_holes);
lrv2.attr("set_slices")(polys2);
REQUIRE(region2.slices.surfaces.size() == 1);
const Slic3r::ExPolygon& ex = region2.slices.surfaces.front().expolygon;
CHECK(ex.contour.is_counter_clockwise());
REQUIRE(ex.holes.size() == 1);
CHECK(ex.holes.front().is_clockwise()); // hole re-oriented CW
CHECK(region2.slices.surfaces.front().surface_type == Slic3r::stInternal); // default (no template)
// Fix 6: a malformed holes element (a [contour, holes] entry whose holes slot is not a
// sequence, e.g. an int) must raise ValueError, not a bare Python TypeError from iterating a
// non-iterable. This lives in the numpy-guarded section because reaching the holes check
// requires a real ndarray contour as the first element.
auto raises_value_error = [](py::object callable, py::object arg) {
try { callable(arg); return false; }
catch (py::error_already_set& e) { return e.matches(PyExc_ValueError); }
};
py::list bad_entry;
bad_entry.append(make_arr({ {0,0}, {s,0}, {s,s}, {0,s} })); // valid CCW contour
bad_entry.append(py::int_(42)); // holes slot is an int -> invalid
py::list bad_polys;
bad_polys.append(bad_entry);
CHECK(raises_value_error(lrv2.attr("set_slices"), bad_polys));
// The failed call left the previously-set single surface untouched.
CHECK(region2.slices.surfaces.size() == 1);
}