diff --git a/docs/plugins/slicing_pipeline_plugin.md b/docs/plugins/slicing_pipeline_plugin.md new file mode 100644 index 0000000000..5b8a81f4e2 --- /dev/null +++ b/docs/plugins/slicing_pipeline_plugin.md @@ -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. diff --git a/resources/orca_plugins/InsetEverySlice.py b/resources/orca_plugins/InsetEverySlice.py new file mode 100644 index 0000000000..3a5894033e --- /dev/null +++ b/resources/orca_plugins/InsetEverySlice.py @@ -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) diff --git a/src/libslic3r/Config.cpp b/src/libslic3r/Config.cpp index ecf453aa68..381555ab3c 100644 --- a/src/libslic3r/Config.cpp +++ b/src/libslic3r/Config.cpp @@ -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(opt))->value, "printer-connection"); + } else if (opt_key == "slicing_pipeline_plugin") { + if (const auto* vec = dynamic_cast(opt)) { + for (const std::string& val : vec->vserialize()) + append_ref(val, "slicing-pipeline"); + } } // Extend for other plugin-backed settings as needed. } diff --git a/src/libslic3r/Print.cpp b/src/libslic3r/Print.cpp index acc55efe72..ece8201267 100644 --- a/src/libslic3r/Print.cpp +++ b/src/libslic3r/Print.cpp @@ -50,6 +50,8 @@ using namespace nlohmann; namespace Slic3r { +Print::SlicingPipelineHookFn Print::s_slicing_pipeline_hook_fn = nullptr; + template class PrintState; template class PrintState; @@ -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("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 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(0, int(m_objects.size())), [this, need_slicing_objects](const tbb::blocked_range& 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)) diff --git a/src/libslic3r/Print.hpp b/src/libslic3r/Print.hpp index af144ee821..99d47c7bcf 100644 --- a/src/libslic3r/Print.hpp +++ b/src/libslic3r/Print.hpp @@ -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 { @@ -891,6 +896,11 @@ private: // Prevents erroneous use by other classes. typedef std::pair PrintObjectInfo; public: + using SlicingPipelineHookFn = std::function; + // 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; diff --git a/src/libslic3r/PrintConfig.cpp b/src/libslic3r/PrintConfig.cpp index 8945a11f67..c049445309 100644 --- a/src/libslic3r/PrintConfig.cpp +++ b/src/libslic3r/PrintConfig.cpp @@ -5122,6 +5122,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."); diff --git a/src/libslic3r/PrintConfig.hpp b/src/libslic3r/PrintConfig.hpp index 394af09392..104e4a9c3d 100644 --- a/src/libslic3r/PrintConfig.hpp +++ b/src/libslic3r/PrintConfig.hpp @@ -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)) diff --git a/src/slic3r/CMakeLists.txt b/src/slic3r/CMakeLists.txt index 16636517d7..a1702ab8b4 100644 --- a/src/slic3r/CMakeLists.txt +++ b/src/slic3r/CMakeLists.txt @@ -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 diff --git a/src/slic3r/GUI/GUI_App.cpp b/src/slic3r/GUI/GUI_App.cpp index f6e15f8d08..dd0bba5578 100644 --- a/src/slic3r/GUI/GUI_App.cpp +++ b/src/slic3r/GUI/GUI_App.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("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("plugins"); + if (caps == nullptr || caps->values.empty()) + return; + + Slic3r::execute_capabilities_from_refs( + *caps, plugs, Slic3r::PluginCapabilityType::SlicingPipeline, + [&](std::shared_ptr 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"); diff --git a/src/slic3r/GUI/Tab.cpp b/src/slic3r/GUI/Tab.cpp index e9c9216a5e..6b49c6d534 100644 --- a/src/slic3r/GUI/Tab.cpp +++ b/src/slic3r/GUI/Tab.cpp @@ -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; diff --git a/src/slic3r/plugin/PluginManager.hpp b/src/slic3r/plugin/PluginManager.hpp index 5655835757..45d116e99f 100644 --- a/src/slic3r/plugin/PluginManager.hpp +++ b/src/slic3r/plugin/PluginManager.hpp @@ -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(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; diff --git a/src/slic3r/plugin/PythonPluginBridge.cpp b/src/slic3r/plugin/PythonPluginBridge.cpp index dfc78d14f7..2c12e04975 100644 --- a/src/slic3r/plugin/PythonPluginBridge.cpp +++ b/src/slic3r/plugin/PythonPluginBridge.cpp @@ -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"; diff --git a/src/slic3r/plugin/PythonPluginInterface.hpp b/src/slic3r/plugin/PythonPluginInterface.hpp index e169ddda55..abb552f8e3 100644 --- a/src/slic3r/plugin/PythonPluginInterface.hpp +++ b/src/slic3r/plugin/PythonPluginInterface.hpp @@ -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; } diff --git a/src/slic3r/plugin/pluginTypes/slicingPipeline/SlicingNumpy.hpp b/src/slic3r/plugin/pluginTypes/slicingPipeline/SlicingNumpy.hpp new file mode 100644 index 0000000000..22ebf32be6 --- /dev/null +++ b/src/slic3r/plugin/pluginTypes/slicingPipeline/SlicingNumpy.hpp @@ -0,0 +1,27 @@ +#pragma once +#include +#include +#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 +pybind11::array make_readonly_rows(pybind11::capsule owner, const T* data, pybind11::ssize_t rows) +{ + namespace py = pybind11; + py::array_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 diff --git a/src/slic3r/plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.cpp b/src/slic3r/plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.cpp new file mode 100644 index 0000000000..82e8ee0db7 --- /dev/null +++ b/src/slic3r/plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.cpp @@ -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 +#include + +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( + 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 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& out) +{ + if (ee == nullptr) + return; + if (const auto* coll = dynamic_cast(ee)) { + for (const ExtrusionEntity* child : coll->entities) + collect_extrusion_paths(child, out); + } else if (const auto* loop = dynamic_cast(ee)) { + for (const ExtrusionPath& p : loop->paths) + out.push_back(&p); + } else if (const auto* mp = dynamic_cast(ee)) { + for (const ExtrusionPath& p : mp->paths) + out.push_back(&p); + } else if (const auto* path = dynamic_cast(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 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(h)) + throw py::value_error(std::string(who) + ": each contour/hole must be an (N,2) int64 ndarray"); + py::array a = py::reinterpret_borrow(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::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(entry)) { + ex.contour = parse_polygon(entry, who); + } else if (py::isinstance(entry) && !py::isinstance(entry)) { + py::sequence seq = py::reinterpret_borrow(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 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(holes_obj) || py::isinstance(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(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(list_h) || py::isinstance(list_h)) + throw py::value_error(std::string(who) + ": expected a list of polygons"); + ExPolygons out; + for (py::handle entry : py::reinterpret_borrow(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_& 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_(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_(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(v); }, py::arg("coord"), + "Convert a scaled integer coordinate to millimeters (reads the live SCALING_FACTOR)."); + + py::class_(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_(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(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_(slicing, "PathData") + .def("points", [](const PathData& p) { + const Points3& pts = p.path->polyline.points; + return make_readonly_rows( + 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_(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(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(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_(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(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_(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_(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_>(slicing, "SlicingPipelineCapabilityBase") + .def(py::init<>()) + .def("get_type", &SlicingPipelinePluginCapability::get_type) + .def("execute", &SlicingPipelinePluginCapability::execute); +} + +} // namespace Slic3r diff --git a/src/slic3r/plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.hpp b/src/slic3r/plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.hpp new file mode 100644 index 0000000000..9cbe3a606f --- /dev/null +++ b/src/slic3r/plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.hpp @@ -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 +#include + +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_& pluginTypes); +}; + +} // namespace Slic3r diff --git a/src/slic3r/plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapabilityTrampoline.hpp b/src/slic3r/plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapabilityTrampoline.hpp new file mode 100644 index 0000000000..c979e4f086 --- /dev/null +++ b/src/slic3r/plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapabilityTrampoline.hpp @@ -0,0 +1,16 @@ +#pragma once +#include "SlicingPipelinePluginCapability.hpp" +#include "slic3r/plugin/PyPluginTrampoline.hpp" + +namespace Slic3r { +class PySlicingPipelinePluginCapabilityTrampoline : public PyPluginCommonTrampoline { +public: + using PyPluginCommonTrampoline::PyPluginCommonTrampoline; + ExecutionResult execute(SlicingPipelineContext& ctx) override { + ORCA_PY_OVERRIDE_AUDITED( + ::Slic3r::PluginAuditManager::AuditMode::Loading, + []{}, PYBIND11_OVERRIDE_PURE, + ExecutionResult, SlicingPipelinePluginCapability, execute, ctx); + } +}; +} // namespace Slic3r diff --git a/tests/fff_print/CMakeLists.txt b/tests/fff_print/CMakeLists.txt index 1467c1397f..e07fde0593 100644 --- a/tests/fff_print/CMakeLists.txt +++ b/tests/fff_print/CMakeLists.txt @@ -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 ) diff --git a/tests/fff_print/test_slicing_pipeline_hook.cpp b/tests/fff_print/test_slicing_pipeline_hook.cpp new file mode 100644 index 0000000000..22d78f3b40 --- /dev/null +++ b/tests/fff_print/test_slicing_pipeline_hook.cpp @@ -0,0 +1,215 @@ +#include +#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("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 +#include +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 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 + +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 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(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 +} diff --git a/tests/slic3rutils/CMakeLists.txt b/tests/slic3rutils/CMakeLists.txt index 626ae128e1..046dfb6909 100644 --- a/tests/slic3rutils/CMakeLists.txt +++ b/tests/slic3rutils/CMakeLists.txt @@ -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) diff --git a/tests/slic3rutils/python_test_support.hpp b/tests/slic3rutils/python_test_support.hpp new file mode 100644 index 0000000000..1920b2ce2a --- /dev/null +++ b/tests/slic3rutils/python_test_support.hpp @@ -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 +#include + +#include + +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 diff --git a/tests/slic3rutils/test_plugin_host_api.cpp b/tests/slic3rutils/test_plugin_host_api.cpp index 8d6d349a3a..426ea8d2ff 100644 --- a/tests/slic3rutils/test_plugin_host_api.cpp +++ b/tests/slic3rutils/test_plugin_host_api.cpp @@ -5,6 +5,8 @@ #include #include +#include "python_test_support.hpp" + #include #include @@ -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) { diff --git a/tests/slic3rutils/test_slicing_pipeline_bindings.cpp b/tests/slic3rutils/test_slicing_pipeline_bindings.cpp new file mode 100644 index 0000000000..158413e32c --- /dev/null +++ b/tests/slic3rutils/test_slicing_pipeline_bindings.cpp @@ -0,0 +1,443 @@ +#include +#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 +#include +#include +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(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(); + 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::stTop); + CHECK(ST.attr("stInternalSolid").cast() == Slic3r::stInternalSolid); + CHECK(ST.attr("stPerimeter").cast() == Slic3r::stPerimeter); + CHECK(ST.attr("stCount").cast() == 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(); + 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::stInternalSolid); + CHECK_THAT(sv.attr("thickness").cast(), WithinRel(0.4, 1e-9)); + CHECK_THAT(sv.attr("bridge_angle").cast(), WithinAbs(-1.0, 1e-12)); + CHECK(sv.attr("extra_perimeters").cast() == 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().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(); + 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>().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(); + CHECK(holes.size() == 1); + py::array h0 = holes[0].cast(); + 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(); + REQUIRE(ps.size() == 2); // loop's path + bare path + + py::object pd0 = ps[0]; // pathA, from the loop + CHECK(pd0.attr("role").cast() == "Outer wall"); + CHECK_THAT(pd0.attr("width").cast(), WithinRel(0.45, 1e-6)); + CHECK_THAT(pd0.attr("height").cast(), WithinRel(0.20, 1e-6)); + CHECK_THAT(pd0.attr("mm3_per_mm").cast(), WithinRel(0.05, 1e-9)); + + py::object pd1 = ps[1]; // pathB, bare + CHECK(pd1.attr("role").cast() == "Sparse infill"); + CHECK_THAT(pd1.attr("width").cast(), WithinRel(0.40, 1e-6)); + + // fills is empty on this hand-built region. + CHECK(lrv.attr("fills")().cast().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(); + 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(); + 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>().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(); + REQUIRE(sl.size() == 1); + py::object sv = sl[0]; + CHECK(sv.attr("surface_type").cast() == 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::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> 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(); + REQUIRE(sl.size() == 1); + py::array c = sl[0].attr("expolygon").attr("contour")().cast(); + 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); +}