mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-07-06 00:47:35 +03:00
Compare commits
20 Commits
feat/plugi
...
feature/pl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d6285e967c | ||
|
|
64df25c1c5 | ||
|
|
6464ed9cb0 | ||
|
|
8fb0e3259d | ||
|
|
64a4869943 | ||
|
|
427509336b | ||
|
|
3c3a057fa8 | ||
|
|
1a98c8f746 | ||
|
|
400d620d2a | ||
|
|
25649a427b | ||
|
|
6420f34093 | ||
|
|
bcacc50473 | ||
|
|
b2c7d477c7 | ||
|
|
0f88bc4396 | ||
|
|
6aa0b4b3aa | ||
|
|
a29db9800a | ||
|
|
604e0fbbfe | ||
|
|
3192715e1f | ||
|
|
9740844ef8 | ||
|
|
fecfc0d6fa |
143
docs/plugins/slicing_pipeline_plugin.md
Normal file
143
docs/plugins/slicing_pipeline_plugin.md
Normal 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.
|
||||
120
resources/orca_plugins/InsetEverySlice.py
Normal file
120
resources/orca_plugins/InsetEverySlice.py
Normal 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)
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.");
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
)
|
||||
|
||||
215
tests/fff_print/test_slicing_pipeline_hook.cpp
Normal file
215
tests/fff_print/test_slicing_pipeline_hook.cpp
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
38
tests/slic3rutils/python_test_support.hpp
Normal file
38
tests/slic3rutils/python_test_support.hpp
Normal 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
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
443
tests/slic3rutils/test_slicing_pipeline_bindings.cpp
Normal file
443
tests/slic3rutils/test_slicing_pipeline_bindings.cpp
Normal 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(®ion, [](void*){}); // no-op: region outlives the view
|
||||
py::object lrv = py::cast(Slic3r::LayerRegionView{ ®ion, 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(®ion, [](void*){});
|
||||
py::object lrv = py::cast(Slic3r::LayerRegionView{ ®ion, 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(®ion, [](void*){}); // no-op: region outlives the view
|
||||
py::object lrv = py::cast(Slic3r::LayerRegionView{ ®ion, 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(®ion, [](void*){});
|
||||
py::object lrv = py::cast(Slic3r::LayerRegionView{ ®ion, 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(®ion2, [](void*){});
|
||||
py::object lrv2 = py::cast(Slic3r::LayerRegionView{ ®ion2, 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);
|
||||
}
|
||||
Reference in New Issue
Block a user