From b0bacdd00b8748062b60d56bade4ed9c352e9544 Mon Sep 17 00:00:00 2001 From: SoftFever Date: Sat, 4 Jul 2026 04:33:20 +0800 Subject: [PATCH 01/10] feat(plugin): add the slicing-pipeline plugin capability Introduces a plugin capability that runs Python at the seams of Print::process(), letting a plugin read and rewrite slicing state as it is computed. - New slicing_pipeline_plugin config option; selected plugin refs are serialized into the print manifest. - Print gains an injectable hook fired at each pipeline step (posSlice, posPerimeters, posInfill, ...). It is a no-op when unset, fires only on genuine (re)computation, and never on the use-cache path. - orca.slicing submodule: SlicingPipelineCapabilityBase plus a trampoline and a Step enum. Capabilities read the live graph through zero-copy int64 numpy views (contour/holes geometry with unscaled coordinates, flattened toolpath data) and edit it through 2D-geometry mutators with cache-invariant refresh. - GUI dispatcher runs capabilities during slicing under the GIL, turns plugin errors into slicing errors, honors cancellation, and adds the plugin picker. - Ships the InsetEverySlice sample plugin and binding/hook tests. --- docs/plugins/slicing_pipeline_plugin.md | 143 ++++++ resources/orca_plugins/InsetEverySlice.py | 120 +++++ src/libslic3r/Config.cpp | 5 + src/libslic3r/Print.cpp | 69 ++- src/libslic3r/Print.hpp | 17 + src/libslic3r/PrintConfig.cpp | 10 + src/libslic3r/PrintConfig.hpp | 1 + src/slic3r/CMakeLists.txt | 4 + src/slic3r/GUI/GUI_App.cpp | 56 +++ src/slic3r/GUI/Tab.cpp | 6 + src/slic3r/plugin/PluginManager.hpp | 14 +- src/slic3r/plugin/PythonPluginBridge.cpp | 3 + src/slic3r/plugin/PythonPluginInterface.hpp | 6 +- .../slicingPipeline/SlicingNumpy.hpp | 27 ++ .../SlicingPipelinePluginCapability.cpp | 372 +++++++++++++++ .../SlicingPipelinePluginCapability.hpp | 65 +++ ...cingPipelinePluginCapabilityTrampoline.hpp | 16 + tests/fff_print/CMakeLists.txt | 1 + .../fff_print/test_slicing_pipeline_hook.cpp | 215 +++++++++ tests/slic3rutils/CMakeLists.txt | 1 + tests/slic3rutils/python_test_support.hpp | 38 ++ tests/slic3rutils/test_plugin_host_api.cpp | 28 +- .../test_slicing_pipeline_bindings.cpp | 443 ++++++++++++++++++ 23 files changed, 1622 insertions(+), 38 deletions(-) create mode 100644 docs/plugins/slicing_pipeline_plugin.md create mode 100644 resources/orca_plugins/InsetEverySlice.py create mode 100644 src/slic3r/plugin/pluginTypes/slicingPipeline/SlicingNumpy.hpp create mode 100644 src/slic3r/plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.cpp create mode 100644 src/slic3r/plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.hpp create mode 100644 src/slic3r/plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapabilityTrampoline.hpp create mode 100644 tests/fff_print/test_slicing_pipeline_hook.cpp create mode 100644 tests/slic3rutils/python_test_support.hpp create mode 100644 tests/slic3rutils/test_slicing_pipeline_bindings.cpp 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); +} From f81a24abfb3ad3c1901d850979eef05f90f424ca Mon Sep 17 00:00:00 2001 From: SoftFever Date: Wed, 8 Jul 2026 00:05:28 +0800 Subject: [PATCH 02/10] feat(plugin): expose the slicing print-graph as raw orca.host classes + Twistify sample Adds PluginHostSlicing, which registers the print-graph data model (Print, PrintObject, Layer, LayerRegion, Surface, ExPolygon, extrusions, ...) into the orca.host submodule in the same raw-class style as PluginHostApi's Model/Preset graph, with shared helpers in PluginBindingUtils. SlicingPipelinePluginCapability is trimmed to the capability surface (the standalone SlicingNumpy helper is folded away). Adds the Twistify example plugin next to Inset and broadens the binding, hook, and plugin-install tests. --- docs/plugins/slicing_pipeline_plugin.md | 143 ----- .../orca_inset_plugin_any.py | 24 +- sandboxes/orca_twistify_plugin_example_any.py | 223 +++++++ src/libslic3r/Config.cpp | 87 +-- src/libslic3r/Config.hpp | 21 +- src/libslic3r/Preset.cpp | 1 + src/libslic3r/Print.cpp | 19 + src/libslic3r/Print.hpp | 2 +- src/libslic3r/PrintConfig.cpp | 7 +- src/slic3r/CMakeLists.txt | 4 +- src/slic3r/GUI/GUI_App.cpp | 30 +- src/slic3r/GUI/PluginsDialog.cpp | 6 +- src/slic3r/GUI/Tab.cpp | 7 + src/slic3r/plugin/PluginBindingUtils.hpp | 89 +++ src/slic3r/plugin/PluginDescriptor.hpp | 2 + src/slic3r/plugin/PluginHostApi.cpp | 66 +-- src/slic3r/plugin/PluginHostSlicing.cpp | 512 ++++++++++++++++ src/slic3r/plugin/PluginHostSlicing.hpp | 16 + src/slic3r/plugin/PluginLoader.cpp | 7 + src/slic3r/plugin/PluginLoader.hpp | 2 + src/slic3r/plugin/PluginResolver.cpp | 8 +- src/slic3r/plugin/PythonFileUtils.cpp | 11 +- .../slicingPipeline/SlicingNumpy.hpp | 27 - .../SlicingPipelinePluginCapability.cpp | 352 +---------- .../SlicingPipelinePluginCapability.hpp | 58 +- .../fff_print/test_slicing_pipeline_hook.cpp | 317 +++++++++- tests/libslic3r/test_config.cpp | 54 ++ tests/slic3rutils/test_plugin_install.cpp | 35 ++ .../test_slicing_pipeline_bindings.cpp | 550 +++++++++--------- 29 files changed, 1718 insertions(+), 962 deletions(-) delete mode 100644 docs/plugins/slicing_pipeline_plugin.md rename resources/orca_plugins/InsetEverySlice.py => sandboxes/orca_inset_plugin_any.py (84%) create mode 100644 sandboxes/orca_twistify_plugin_example_any.py create mode 100644 src/slic3r/plugin/PluginBindingUtils.hpp create mode 100644 src/slic3r/plugin/PluginHostSlicing.cpp create mode 100644 src/slic3r/plugin/PluginHostSlicing.hpp delete mode 100644 src/slic3r/plugin/pluginTypes/slicingPipeline/SlicingNumpy.hpp diff --git a/docs/plugins/slicing_pipeline_plugin.md b/docs/plugins/slicing_pipeline_plugin.md deleted file mode 100644 index 5b8a81f4e2..0000000000 --- a/docs/plugins/slicing_pipeline_plugin.md +++ /dev/null @@ -1,143 +0,0 @@ -# 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/sandboxes/orca_inset_plugin_any.py similarity index 84% rename from resources/orca_plugins/InsetEverySlice.py rename to sandboxes/orca_inset_plugin_any.py index 3a5894033e..42bebec684 100644 --- a/resources/orca_plugins/InsetEverySlice.py +++ b/sandboxes/orca_inset_plugin_any.py @@ -6,14 +6,14 @@ # name = "Inset Every Slice" # description = "Insets every layer's slices by 1mm at the Slice boundary (demo)." # author = "OrcaSlicer" -# version = "1.0.0" +# version = "0.01" # 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 +LayerRegion.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 @@ -24,10 +24,10 @@ This is a *teaching* sample, not a production-grade offset: 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. + cross-section of a plain cube) 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. @@ -35,8 +35,8 @@ This is a *teaching* sample, not a production-grade offset: 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), +numpy is declared as a dependency: the geometry accessors 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 @@ -94,19 +94,19 @@ class InsetEverySlice(orca.slicing.SlicingPipelineCapabilityBase): if ctx.cancelled(): break for region in layer.regions(): - surfaces = region.slices() + surfaces = region.slices.surfaces if not surfaces: - continue # set_slices() rejects an empty list + continue # an empty region has nothing to inset new_surfaces = [] for surface in surfaces: expoly = surface.expolygon - contour = expoly.contour() + contour = expoly.contour.points() 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()]) + new_surfaces.append([contour, [h.points() for h in expoly.holes]]) region.set_slices(new_surfaces) regions_touched += 1 diff --git a/sandboxes/orca_twistify_plugin_example_any.py b/sandboxes/orca_twistify_plugin_example_any.py new file mode 100644 index 0000000000..e58666db6f --- /dev/null +++ b/sandboxes/orca_twistify_plugin_example_any.py @@ -0,0 +1,223 @@ +# /// script +# requires-python = ">=3.12" +# dependencies = ["numpy"] +# +# [tool.orcaslicer.plugin] +# name = "Twistify" +# description = "Twists, tapers, and wobbles every layer's slice polygons as a function of Z (demo)." +# author = "OrcaSlicer" +# version = "0.01" +# type = "slicing-pipeline" +# +# [tool.orcaslicer.plugin.settings] +# twist_deg_per_mm = "1.0" +# taper_per_mm = "0.0" +# wobble_ampl_mm = "0.0" +# wobble_period_mm = "20.0" +# min_scale = "0.05" +# /// +"""Twistify -- twist/taper/wobble any model at slice time. + +At Step.Slice (the one fully-supported mutation seam -- see +docs/plugins/slicing_pipeline_plugin.md), every layer's sliced surfaces are +rotated, uniformly scaled, and optionally swayed about the object's center as a +function of Z, then written back with LayerRegion.set_slices(). The +dedicated slice loop runs make_perimeters() right after this hook, so the +transform cascades into perimeters, infill, and the final G-code -- the toolpath +preview visibly corkscrews, and unlike G-code post-processing hacks the printed +part keeps correct multi-wall perimeters, infill, and flow. + +Parameters come from ctx.params -- the [tool.orcaslicer.plugin.settings] table in +the PEP-723 header above. Edit them there (and re-slice) to change the effect; no +code edit or plugin reload is needed. Recipes: twisted vase +(twist 1.0), tapered spire (twist 0.3, taper -0.006), wobbling tower +(twist 0, wobble_ampl 0.8). + +The transform uses three of the gap-closing APIs so the plugin stays small and +correct: + * ctx.object.bounding_box() gives the twist axis (each object twists about its + own center) -- no footprint reconstruction. + * set_slices(refresh_lslices=True) re-derives the layer's merged islands, so + overhang/bridge/skirt/support stay coherent -- no manual set_lslices(). + * a per-entry SurfaceType (third set_slices element) preserves each surface's + type -- no replace-then-reassign-surface_type two-step. +Because the Slice hook re-snapshots raw_slices afterward, the twist also survives +a later perimeter-only re-slice (e.g. changing wall_loops) instead of reverting. + +numpy is REQUIRED at slice time (declared above): the host's geometry accessors +return numpy arrays. The pure-Python fallback in _transform_ring exists only so this +module still imports on numpy-less interpreters (the unit-test harness); it is +unreachable in production. Outputs are built by .copy()-ing the host's zero-copy +read arrays (dtype/shape inherited -- int64 on every platform, immune to Windows' +numpy int32 default), never constructed from scratch. + +Physical-print caveats: keep the twist modest (horizontal shift per layer at the +part's outer radius should stay under ~1.4x layer height) or the real print grows +unsupported overhangs -- the preview looks great regardless. The first object +layer is untouched (z_rel = 0), so bed adhesion is unaffected. Twists EVERY +object on the plate (each about its own center). +""" +import math + +import orca + +try: # required in production; guard keeps module importable in the test harness + import numpy as _np +except ImportError: + _np = None + +# Fallback defaults, overridden per-slice by ctx.params (the settings table in the header). +_DEFAULTS = { + "twist_deg_per_mm": 1.0, # signed twist rate; 1 deg/mm corkscrews a 100mm cube by 100 deg + "taper_per_mm": 0.0, # relative XY scale change per mm of Z (-0.004 = shrink 0.4%/mm) + "wobble_ampl_mm": 0.0, # X sway amplitude in mm (0 disables) + "wobble_period_mm": 20.0, # full sway period in mm of Z + "min_scale": 0.05, # taper clamp: polygons shrink but can never collapse to a point +} + + +def _params(ctx): + """Resolve parameters from ctx.params (string values), falling back to _DEFAULTS.""" + try: + src = dict(ctx.params) # ctx.params is a read-only dict of str -> str + except (AttributeError, TypeError): + src = {} + out = {} + for key, default in _DEFAULTS.items(): + try: + out[key] = float(src[key]) + except (KeyError, TypeError, ValueError): + out[key] = default + return out + + +def _is_identity(p): + return p["twist_deg_per_mm"] == 0.0 and p["taper_per_mm"] == 0.0 and p["wobble_ampl_mm"] == 0.0 + + +def _layer_params(z_rel, mm_to_scaled, p): + """(cos, sin, scale, x_offset_scaled) for one layer. Exact identity at z_rel == 0.""" + theta = math.radians(p["twist_deg_per_mm"] * z_rel) + s = max(p["min_scale"], 1.0 + p["taper_per_mm"] * z_rel) + ox = 0.0 + if p["wobble_ampl_mm"] != 0.0 and p["wobble_period_mm"] > 0.0: + ox = p["wobble_ampl_mm"] * math.sin(2.0 * math.pi * z_rel / p["wobble_period_mm"]) * mm_to_scaled + return math.cos(theta), math.sin(theta), s, ox + + +def _transform_ring(ring, cos_t, sin_t, s, cx, cy, ox): + """Similarity-transform one int64 (N,2) ring about (cx, cy), then shift X by ox. + + Returns a NEW writable int64 (N,2) ndarray with the same point count, or None + if the ring is degenerate (< 3 points; the host's parse_polygon would reject it). + Rotation + uniform positive scale preserves orientation and hole containment and + cannot self-intersect; the host re-normalizes winding on write-back anyway. + """ + n = ring.shape[0] + if n < 3: + return None + if _np is not None: # production path (numpy is a declared dependency) + pts = ring.astype(_np.float64) + dx = pts[:, 0] - cx + dy = pts[:, 1] - cy + out = _np.empty_like(ring) # inherits int64 -- immune to Windows' int32 default + out[:, 0] = _np.rint((dx * cos_t - dy * sin_t) * s + cx + ox) + out[:, 1] = _np.rint((dx * sin_t + dy * cos_t) * s + cy) + return out + out = ring.copy() # defensive fallback; unreachable when the host supplied `ring` + for i in range(n): + dx = float(ring[i, 0]) - cx + dy = float(ring[i, 1]) - cy + out[i, 0] = int(round((dx * cos_t - dy * sin_t) * s + cx + ox)) + out[i, 1] = int(round((dx * sin_t + dy * cos_t) * s + cy)) + return out + + +def _transform_expoly(expoly, cos_t, sin_t, s, cx, cy, ox): + """ExPolygon -> [contour, [holes...]] entry for set_slices. + + Returns None if the outer contour is degenerate; degenerate holes are dropped + (a <3-point ring is meaningless and would make the host raise ValueError). + """ + contour = _transform_ring(expoly.contour.points(), cos_t, sin_t, s, cx, cy, ox) + if contour is None: + return None + holes = [] + for hole in expoly.holes: + th = _transform_ring(hole.points(), cos_t, sin_t, s, cx, cy, ox) + if th is not None: + holes.append(th) + return [contour, holes] + + +class Twistify(orca.slicing.SlicingPipelineCapabilityBase): + def get_name(self): + return "Twistify" + + def execute(self, ctx): + # Standard guard: Step.Slice is per-object and the only fully-wired mutation seam. + if ctx.step != orca.slicing.Step.Slice or ctx.object is None: + return orca.ExecutionResult.success() + + p = _params(ctx) + # Exact no-op parameters -> leave the pipeline byte-identical by construction. + if _is_identity(p): + return orca.ExecutionResult.success("Twistify: identity parameters, nothing to do") + + # Millimeters -> scaled units via the LIVE scale (never hardcode 1e6/1e-6). + mm_to_scaled = 1.0 / orca.slicing.unscale(1) + + layers = ctx.object.layers() + if not layers: + return orca.ExecutionResult.success("Twistify: object has no layers") + + # Twist axis = the object's bounding-box center (scaled coords, same frame as the + # slice polygons), so each object on the plate twists about its own center. + min_x, min_y, max_x, max_y = ctx.object.bounding_box() + cx = (min_x + max_x) / 2.0 + cy = (min_y + max_y) / 2.0 + z0 = float(layers[0].print_z) # z_rel = 0 on the first layer -> footprint untouched + + layers_touched = 0 + for layer in layers: + if ctx.cancelled(): + break + z_rel = float(layer.print_z) - z0 + cos_t, sin_t, s, ox = _layer_params(z_rel, mm_to_scaled, p) + if cos_t == 1.0 and sin_t == 0.0 and s == 1.0 and ox == 0.0: + continue # exact identity (always the first layer): skip set_slices entirely + + for region in layer.regions(): + surfaces = region.slices.surfaces + if not surfaces: + continue # set_slices() rejects nothing now, but an empty region has nothing to do + new_surfaces = [] + for surface in surfaces: + entry = _transform_expoly(surface.expolygon, cos_t, sin_t, s, cx, cy, ox) + if entry is None: + continue # degenerate outer contour: drop this surface + # Carry this surface's type as the third entry element so it is preserved + # per surface. The plain enum value is read out BEFORE set_slices, since the + # Surface reference dangles once the collection is replaced. + entry.append(surface.surface_type) + new_surfaces.append(entry) + if not new_surfaces: + continue # every surface degenerate: leave the region untouched + # refresh_lslices=True re-derives the layer's merged islands + bbox cache from + # the twisted slices, so overhang/bridge detection and brim/skirt/support stay + # coherent -- no separate Layer.set_lslices() pass needed. + region.set_slices(new_surfaces, refresh_lslices=True) + + layers_touched += 1 + + name = ctx.object.model_object().name or "object" + return orca.ExecutionResult.success( + f"Twistify: transformed {layers_touched} layer(s) of '{name}' " + f"(twist {p['twist_deg_per_mm']} deg/mm, taper {p['taper_per_mm']}/mm, " + f"wobble {p['wobble_ampl_mm']} mm)") + + +@orca.plugin +class TwistifyPackage(orca.base): + def register_capabilities(self): + orca.register_capability(Twistify) diff --git a/src/libslic3r/Config.cpp b/src/libslic3r/Config.cpp index 381555ab3c..a43f659be6 100644 --- a/src/libslic3r/Config.cpp +++ b/src/libslic3r/Config.cpp @@ -1521,8 +1521,6 @@ void ConfigBase::save_to_json(const std::string &file, const std::string &name, j[BBL_JSON_KEY_NAME] = name; j[BBL_JSON_KEY_FROM] = from; - std::vector plugin_refs; - //record all the key-values for (const std::string &opt_key : this->keys()) { @@ -1548,24 +1546,14 @@ void ConfigBase::save_to_json(const std::string &file, const std::string &name, json j_array(string_values); j[opt_key] = j_array; } - - this->save_plugin_collection(opt_key, opt, plugin_refs); } - // Lazily serialize the top-level "plugins" manifest: the individual plugin-backed options keep - // bare capability names, and the full "name;uuid;capability" references are derived here from - // those options via the registered resolver. Only do this when a resolver is available (GUI); - // without one (CLI/headless) leave whatever the "plugins" option already serialized above, so a - // round-trip never drops the manifest. De-duplicate while preserving order and skip empties. + // Serialize the top-level "plugins" manifest: the individual plugin-backed options keep bare + // capability names; the full "name;uuid;capability" references are derived here (same helper as + // update_plugin_manifest). Only with a resolver (GUI); without one (CLI/headless) leave whatever + // the "plugins" option already serialized above, so a round-trip never drops the manifest. if (resolve_capability_fn) { - std::vector unique_refs; - unique_refs.reserve(plugin_refs.size()); - for (std::string& ref : plugin_refs) { - if (ref.empty()) - continue; - if (std::find(unique_refs.begin(), unique_refs.end(), ref) == unique_refs.end()) - unique_refs.emplace_back(std::move(ref)); - } + std::vector unique_refs = this->collect_plugin_manifest(); if (unique_refs.empty()) j.erase("plugins"); else @@ -1610,29 +1598,60 @@ void ConfigBase::save_plugin_collection(const std::string& opt_key, const Config if (!resolve_capability_fn) return; - // Resolve a single bare capability value into its full reference and append it, skipping - // unset values and capabilities that could not be resolved (resolver returns ""). - const auto append_ref = [&plugin_refs](const std::string& capability_value, const std::string& type) { + // A plugin-backed option declares its capability type via ConfigOptionDef::plugin_type (the same + // metadata PluginResolver::find_option_for_capability scans). Deriving off the def rather than a + // per-key branch keeps this generic across every plugin-backed option. + const ConfigDef* def = this->def(); + const ConfigOptionDef* opt_def = def ? def->get(opt_key) : nullptr; + if (opt_def == nullptr || !opt_def->is_plugin_backed()) + return; + const std::string& type = opt_def->plugin_type; + + // Resolve a single bare capability value into its full reference and append it, skipping unset + // values, capabilities that could not be resolved (resolver returns ""), and duplicates already + // collected (preserving insertion order). + const auto append_ref = [&plugin_refs, &type](const std::string& capability_value) { if (capability_value.empty()) return; std::string ref = resolve_capability_fn(capability_value, type); - if (!ref.empty()) + if (!ref.empty() && std::find(plugin_refs.begin(), plugin_refs.end(), ref) == plugin_refs.end()) plugin_refs.emplace_back(std::move(ref)); }; - if (opt_key == "post_process_plugin") { - const ConfigOptionVectorBase* vec = static_cast(opt); - for (const std::string& val : vec->vserialize()) - 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. + // Scalar options carry a single capability name; vector options carry a list. Same scalar/vector + // dispatch as PluginResolver::find_option_for_capability. + if (const auto* string_option = dynamic_cast(opt)) + append_ref(string_option->value); + else if (const auto* vector_option = dynamic_cast(opt)) + for (const std::string& val : vector_option->vserialize()) + append_ref(val); +} + +std::vector ConfigBase::collect_plugin_manifest() const +{ + std::vector refs; + if (!resolve_capability_fn) + return refs; + + // Each plugin-backed option (ConfigOptionDef::is_plugin_backed) contributes its resolved + // reference(s) via save_plugin_collection, which appends in order and skips duplicates, so no + // second de-duplication pass is needed here. + for (const std::string& opt_key : this->keys()) + if (const ConfigOption* opt = this->option(opt_key)) + this->save_plugin_collection(opt_key, opt, refs); + return refs; +} + +void ConfigBase::update_plugin_manifest() +{ + // Writes the derived manifest back into this config's "plugins" option (save_to_json writes the + // same manifest into a JSON document instead), so an in-memory backend config carries a resolved + // manifest even when the source preset was never serialized (picked-but-unsaved). Without a + // resolver (CLI/headless) leave whatever manifest was loaded from disk untouched. + if (!resolve_capability_fn) + return; + if (auto* manifest = this->option("plugins", true)) + manifest->values = this->collect_plugin_manifest(); } DynamicConfig::DynamicConfig(const ConfigBase& rhs, const t_config_option_keys& keys) diff --git a/src/libslic3r/Config.hpp b/src/libslic3r/Config.hpp index 3c50e401db..07d71a52c6 100644 --- a/src/libslic3r/Config.hpp +++ b/src/libslic3r/Config.hpp @@ -2444,10 +2444,13 @@ public: // "serialized" - vector valued option is entered in a single edit field. Values are separated by a semicolon. // "show_value" - even if enum_values / enum_labels are set, still display the value, not the enum label. std::string gui_flags; - // Optional plugin type used by GUIType::plugin_picker for filtering plugins. + // Capability type of a plugin-backed option, e.g. "post-processing" / "slicing-pipeline" / + // "printer-connection" (empty for ordinary options). GUIType::plugin_picker filters the plugin + // list by it, and it resolves the option's "plugins" manifest reference; see is_plugin_backed(). std::string plugin_type; - // Indicate whether the option support plugin. - bool support_plugin { false }; + // Whether this option holds plugin capability name(s) that feed the "plugins" manifest -- true + // iff it declares a plugin_type. Setting plugin_type is the only step needed to add one. + bool is_plugin_backed() const { return !plugin_type.empty(); } // Label of the GUI input field. // In case the GUI input fields are grouped in some views, the label defines a short label of a grouped value, // while full_label contains a label of a stand-alone field. @@ -2761,6 +2764,13 @@ public: //BBS: add json support void save_to_json(const std::string &file, const std::string &name, const std::string &from, const std::string &version) const; + // Rebuild the in-memory "plugins" manifest (the "name;uuid;capability" references the plugin + // dispatchers consume) from the plugin-backed options via the registered resolver. save_to_json() + // derives the same manifest, but only when a preset is written to disk; a config assembled in + // memory for the backend (PresetBundle::full_config -> Print::apply) must refresh it here or a + // picked-but-unsaved plugin never resolves at slice/export time. No-op without a resolver. + void update_plugin_manifest(); + // Set all the nullable values to nils. void null_nullables(); @@ -2770,6 +2780,11 @@ private: // Set a configuration value from a string. bool set_deserialize_raw(const t_config_option_key& opt_key_src, const std::string& value, ConfigSubstitutionContext& substitutions, bool append); void save_plugin_collection(const std::string& opt_key, const ConfigOption* opt, std::vector& plugin_refs) const; + // Collect the de-duplicated "name;uuid;capability" plugin references derived from this config's + // plugin-backed options via the resolver. Shared by save_to_json (serializes them into the JSON + // manifest) and update_plugin_manifest (writes them back into the "plugins" option). Order is + // preserved and empties are dropped; returns empty without a resolver (CLI/headless). + std::vector collect_plugin_manifest() const; static std::function resolve_capability_fn; }; diff --git a/src/libslic3r/Preset.cpp b/src/libslic3r/Preset.cpp index 76e714aa01..23d006b70d 100644 --- a/src/libslic3r/Preset.cpp +++ b/src/libslic3r/Preset.cpp @@ -1193,6 +1193,7 @@ static std::vector s_Preset_print_options{ "min_bead_width", "post_process", "post_process_plugin", + "slicing_pipeline_plugin", "plugins", "process_change_extrusion_role_gcode", "min_length_factor", diff --git a/src/libslic3r/Print.cpp b/src/libslic3r/Print.cpp index ece8201267..16e056d3c9 100644 --- a/src/libslic3r/Print.cpp +++ b/src/libslic3r/Print.cpp @@ -2330,6 +2330,17 @@ void Print::process(long long *time_cost_with_cache, bool use_cache) const bool was_done = obj->is_step_done(posSlice); obj->slice(); hook_after(obj, was_done, posSlice, SlicingPipelineStep::Slice); + // re-snapshot each layer's raw_slices AFTER the Slice hook ran, so the + // plugin's mutation becomes the untyped baseline. Without this, a later + // perimeter-only re-run (make_perimeters -> restore_untyped_slices) reverts + // slices to the PRE-hook geometry while posSlice stays cached (the hook does + // not re-fire), silently un-applying the mutation; raw_slices consumers + // (sharp-tail support, ToolOrdering) also read this backup directly. Gated on + // an active plugin AND a genuine (re)slice, so the inactive path is untouched + // and re-backing-up an unmutated layer is a harmless identical copy. + if (m_pipeline_plugin_active && !was_done && obj->is_step_done(posSlice)) + for (Layer *layer : obj->layers()) + layer->backup_untyped_slices(); } else { if (obj->set_started(posSlice)) obj->set_done(posSlice); // shared/duplicate — no hook } @@ -2356,6 +2367,14 @@ void Print::process(long long *time_cost_with_cache, bool use_cache) } for (PrintObject *obj : m_objects) { if (need_slicing_objects.count(obj) != 0) { + // split prepare_infill (fill-surface prep) from infill (make_fills) so a + // plugin can mutate fill surfaces at the PrepareInfill seam and have make_fills + // consume them (unlike the Infill seam, which fires after the fills are already + // built). infill() re-invokes prepare_infill() as a no-op once posPrepareInfill + // is DONE, so this is a mechanical split mirroring the slice/perimeters loop. + const bool prepare_was_done = obj->is_step_done(posPrepareInfill); + obj->prepare_infill(); + hook_after(obj, prepare_was_done, posPrepareInfill, SlicingPipelineStep::PrepareInfill); const bool was_done = obj->is_step_done(posInfill); obj->infill(); hook_after(obj, was_done, posInfill, SlicingPipelineStep::Infill); diff --git a/src/libslic3r/Print.hpp b/src/libslic3r/Print.hpp index 99d47c7bcf..0402b31614 100644 --- a/src/libslic3r/Print.hpp +++ b/src/libslic3r/Print.hpp @@ -883,7 +883,7 @@ enum FilamentCompatibilityType { }; enum class SlicingPipelineStep { - Slice, Perimeters, EstimateCurledExtrusions, Infill, Ironing, Contouring, + Slice, Perimeters, EstimateCurledExtrusions, PrepareInfill, Infill, Ironing, Contouring, SupportMaterial, DetectOverhangsForLift, SimplifyPath, WipeTower, SkirtBrim }; diff --git a/src/libslic3r/PrintConfig.cpp b/src/libslic3r/PrintConfig.cpp index d805a94450..f021af0478 100644 --- a/src/libslic3r/PrintConfig.cpp +++ b/src/libslic3r/PrintConfig.cpp @@ -828,7 +828,10 @@ void PrintConfigDef::init_common_params() def->tooltip = L("Select the network agent implementation for printer communication."); def->mode = comAdvanced; def->cli = ConfigOptionDef::nocli; - def->support_plugin = true; + // Plugin-backed like the pickers, but edited via a dedicated Choice widget rather than a + // plugin_picker field. plugin_type marks it plugin-backed and names its capability type, so its + // "plugins" manifest reference is derived generically (see ConfigOptionDef::is_plugin_backed). + def->plugin_type = "printer-connection"; def->set_default_value(new ConfigOptionString("")); def = this->add("print_host", coString); @@ -5118,7 +5121,6 @@ void PrintConfigDef::init_fff_params() "The plugin will receive the G-code file path and can modify it in place."); def->gui_type = ConfigOptionDef::GUIType::plugin_picker; def->plugin_type = "post-processing"; - def->support_plugin = true; def->full_width = true; def->mode = comAdvanced; def->set_default_value(new ConfigOptionStrings()); @@ -5128,7 +5130,6 @@ void PrintConfigDef::init_fff_params() 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()); diff --git a/src/slic3r/CMakeLists.txt b/src/slic3r/CMakeLists.txt index a1702ab8b4..b821504516 100644 --- a/src/slic3r/CMakeLists.txt +++ b/src/slic3r/CMakeLists.txt @@ -593,10 +593,13 @@ set(SLIC3R_GUI_SOURCES plugin/PythonPluginBridge.hpp plugin/PythonPluginInterface.hpp plugin/PyPluginPackage.hpp + plugin/PluginBindingUtils.hpp plugin/PluginHostApi.cpp plugin/PluginHostApi.hpp plugin/PluginHostUi.cpp plugin/PluginHostUi.hpp + plugin/PluginHostSlicing.cpp + plugin/PluginHostSlicing.hpp plugin/CloudPluginService.cpp plugin/CloudPluginService.hpp plugin/PluginFsUtils.cpp @@ -624,7 +627,6 @@ set(SLIC3R_GUI_SOURCES 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 dd0bba5578..0ec1a616e5 100644 --- a/src/slic3r/GUI/GUI_App.cpp +++ b/src/slic3r/GUI/GUI_App.cpp @@ -3145,10 +3145,8 @@ bool GUI_App::on_init_inner() [&](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). + // GIL is acquired per capability (not once for the whole dispatch) so it + // is released between capabilities. PythonGILState gil; // throw_if_canceled() is protected on PrintBase; canceled() is the public // equivalent check (same cancel flag), so honor cancellation via it. @@ -3159,10 +3157,10 @@ bool GUI_App::on_init_inner() 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*) {}); + // hand the plugin its own [tool.orcaslicer.plugin.settings] as ctx.params + // (same plugin_key the capability was resolved by, so it always matches). + const std::string plugin_key = ref.uuid.empty() ? ref.name : ref.uuid; + ctx.params = Slic3r::PluginManager::instance().get_loader().get_plugin_settings(plugin_key); r = cap->execute(ctx); } catch (const Slic3r::CanceledException&) { throw; // cancellation must reach process(), never become a slicing error @@ -3175,6 +3173,22 @@ bool GUI_App::on_init_inner() if (r.status == Slic3r::PluginResult::FatalError) throw Slic3r::SlicingError(std::string("Slicing pipeline plugin '") + ref.capability_name + "' error: " + r.message); + // log a non-empty success/skipped message instead of dropping it. This is + // log-only by design: every pipeline hook fires AFTER set_done() (see Print.cpp), + // so the Print-level m_step_active is -1 here. Calling active_step_add_warning() + // would then index m_state[-1] (out-of-bounds; the guarding assert is compiled + // out in Release), so it must NOT be called from a pipeline hook. + if (!r.message.empty()) { + static const char* const kStepNames[] = { + "Slice", "Perimeters", "EstimateCurledExtrusions", "PrepareInfill", "Infill", + "Ironing", "Contouring", "SupportMaterial", "DetectOverhangsForLift", + "SimplifyPath", "WipeTower", "SkirtBrim" + }; // order must match Slic3r::SlicingPipelineStep + const char* step_name = static_cast(step) < sizeof(kStepNames) / sizeof(kStepNames[0]) + ? kStepNames[static_cast(step)] : "Unknown"; + BOOST_LOG_TRIVIAL(info) << "Slicing pipeline plugin '" << ref.capability_name + << "' [" << step_name << "]: " << r.message; + } }); }); diff --git a/src/slic3r/GUI/PluginsDialog.cpp b/src/slic3r/GUI/PluginsDialog.cpp index 9b0b253698..68bb886aa6 100644 --- a/src/slic3r/GUI/PluginsDialog.cpp +++ b/src/slic3r/GUI/PluginsDialog.cpp @@ -1007,9 +1007,9 @@ void PluginsDialog::run_script_plugin(const std::string& plugin_key, const std:: // ModelObject*/ModelVolume*/ModelInstance* aliases into host data and can mint ObjectIDs, // which libslic3r requires on the main thread (ObjectID.hpp's non-atomic s_last_id). Running // here makes those reads/instantiations legal and means nothing mutates the model underneath - // a run. The trade-off is that a slow execute() freezes the UI: the contract (see - // plugin_development.md) is to keep execute() quick and offload heavy work to the plugin's own - // threading.Thread. orca.host.ui calls already no-op their main-thread marshaling here. + // a run. The trade-off is that a slow execute() freezes the UI, so the contract is to keep + // execute() quick and offload heavy work to the plugin's own threading.Thread. orca.host.ui + // calls already no-op their main-thread marshaling here. { wxBusyCursor busy; try { diff --git a/src/slic3r/GUI/Tab.cpp b/src/slic3r/GUI/Tab.cpp index 6b49c6d534..a765c6fcdd 100644 --- a/src/slic3r/GUI/Tab.cpp +++ b/src/slic3r/GUI/Tab.cpp @@ -1790,6 +1790,13 @@ void Tab::on_value_change(const std::string& opt_key, const boost::any& value) return; } + // Keep this preset's "plugins" manifest in sync when a plugin picker changes, so the edited preset + // always carries resolved "name;uuid;capability" references that full_config() and save_to_json() + // then pass downstream as-is -- no separate rebuild anywhere else. + if (const ConfigOptionDef* opt_def = m_config->def()->get(opt_key); + opt_def && opt_def->gui_type == ConfigOptionDef::GUIType::plugin_picker) + m_config->update_plugin_manifest(); + if (opt_key == "gcode_flavor" && m_type == Preset::TYPE_PRINTER) { if (auto printer_tab = dynamic_cast(this)) printer_tab->on_gcode_flavor_changed(); diff --git a/src/slic3r/plugin/PluginBindingUtils.hpp b/src/slic3r/plugin/PluginBindingUtils.hpp new file mode 100644 index 0000000000..dfb546d87f --- /dev/null +++ b/src/slic3r/plugin/PluginBindingUtils.hpp @@ -0,0 +1,89 @@ +#pragma once +#include +#include +#include "libslic3r/Config.hpp" // ConfigBase +#include "libslic3r/Point.hpp" // Point/Point3 packing asserts, Vec3d, Transform3d +#include +#include +#include + +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"); + +// Run a builder that constructs numpy objects, translating the "numpy missing" +// ImportError into an actionable message (plugins must declare numpy as a dep). +template +pybind11::object with_numpy(Builder&& build) +{ + namespace py = pybind11; + try { + return std::forward(build)(); + } catch (py::error_already_set& err) { + if (err.matches(PyExc_ImportError)) + throw py::import_error("numpy is required to access geometry/mesh arrays; " + "add dependencies = [\"numpy\"] to your plugin metadata"); + throw; + } +} + +// Zero-copy, read-only (rows, N) numpy view over `data`, whose lifetime is tied +// to `base` (the array's base object). T is the element scalar (coord_t = int64 +// for slicing coords, float for mesh vertices). rows == 0 / null data yields a +// fresh empty (0, N) array with no base. +template +pybind11::array make_readonly_rows(pybind11::handle base, const T* data, pybind11::ssize_t rows) +{ + namespace py = pybind11; + if (rows == 0 || data == nullptr) { + py::array_t empty(std::vector{ 0, (py::ssize_t) N }); + // Keep behavior-preserving: the pre-refactor helper returned read-only + // arrays on every path, so mark the fresh empty array read-only too. + empty.attr("setflags")(py::arg("write") = false); + return std::move(empty); + } + py::array_t arr( + { rows, (py::ssize_t) N }, + { (py::ssize_t)(N * sizeof(T)), (py::ssize_t) sizeof(T) }, + data, base); + // A base-carrying array is writable by default in pybind11; force read-only. + arr.attr("setflags")(py::arg("write") = false); + return std::move(arr); +} + +// Serialize one config key to a Python string, or None if the key is absent. +// Works on any ConfigBase (resolved DynamicPrintConfig snapshots, +// PrintObjectConfig, PrintRegionConfig, preset configs). +inline pybind11::object config_value_or_none(const ConfigBase& config, const std::string& key) +{ + if (!config.has(key)) + return pybind11::none(); + return pybind11::cast(config.opt_serialize(key)); +} + +// Plugins receive 3D vectors as plain Python tuples (x, y, z) so the API stays +// Pythonic and free of an Eigen/numpy runtime dependency. +inline pybind11::tuple vec3_to_tuple(const Vec3d& v) +{ + return pybind11::make_tuple(v.x(), v.y(), v.z()); +} + +// 4x4 row-major float64 copy of an affine transform. Eigen stores column-major, +// so fill element-wise to produce correct C-order data. Requires numpy. +inline pybind11::object mat4_to_numpy(const Transform3d& transform) +{ + namespace py = pybind11; + return with_numpy([&] { + py::array_t array({ py::ssize_t(4), py::ssize_t(4) }); + auto view = array.mutable_unchecked<2>(); + const auto& matrix = transform.matrix(); + for (int i = 0; i < 4; ++i) + for (int j = 0; j < 4; ++j) + view(i, j) = matrix(i, j); + return py::object(std::move(array)); + }); +} + +} // namespace Slic3r diff --git a/src/slic3r/plugin/PluginDescriptor.hpp b/src/slic3r/plugin/PluginDescriptor.hpp index afdb502570..79b967f693 100644 --- a/src/slic3r/plugin/PluginDescriptor.hpp +++ b/src/slic3r/plugin/PluginDescriptor.hpp @@ -4,6 +4,7 @@ #include #include +#include #include #include #include @@ -61,6 +62,7 @@ struct PluginDescriptor std::string entry_path; // Full path to the installed plugin entry file std::string entry_package; // Import package/module used for package-based loading std::vector dependencies; // Python dependency requirements declared by plugin package metadata + std::map settings; // [tool.orcaslicer.plugin.settings] table -> per-plugin params (ctx.params) std::vector changelog; // Cloud release changelog, sorted newest-first when available. std::string error; // Blocking error message. Non-empty means the plugin is in an error state. diff --git a/src/slic3r/plugin/PluginHostApi.cpp b/src/slic3r/plugin/PluginHostApi.cpp index 14debd52c1..4a217f67f2 100644 --- a/src/slic3r/plugin/PluginHostApi.cpp +++ b/src/slic3r/plugin/PluginHostApi.cpp @@ -1,5 +1,7 @@ #include "PluginHostApi.hpp" #include "PluginHostUi.hpp" +#include "PluginHostSlicing.hpp" +#include "PluginBindingUtils.hpp" #include #include @@ -46,20 +48,6 @@ PresetBundle* current_preset_bundle() return preset_bundle; } -py::object config_value_or_none(const DynamicPrintConfig& config, const std::string& key) -{ - if (!config.has(key)) - return py::none(); - return py::cast(config.opt_serialize(key)); -} - -// Plugins receive 3D vectors as plain Python tuples (x, y, z) so the API stays -// Pythonic and free of an Eigen/numpy runtime dependency. -py::tuple vec3_to_tuple(const Vec3d& v) -{ - return py::make_tuple(v.x(), v.y(), v.z()); -} - // Build a BoundingBoxf3 from precomputed (float) triangle-mesh stats min/max. BoundingBoxf3 bbox_from_stats(const TriangleMeshStats& stats) { @@ -86,59 +74,20 @@ struct HostTriangleMesh const indexed_triangle_set& its() const { return mesh->its; } }; -// Run a builder that constructs numpy objects, translating the "numpy missing" -// ImportError into an actionable message (plugins must declare numpy as a dep). -template -py::object with_numpy(Builder&& build) -{ - try { - return std::forward(build)(); - } catch (py::error_already_set& err) { - if (err.matches(PyExc_ImportError)) - throw py::import_error("numpy is required to access mesh arrays/matrices; " - "add dependencies = [\"numpy\"] to your plugin metadata"); - throw; - } -} - // Read-only, zero-copy (rows, 3) numpy view over a packed T[rows][3] buffer. -// The array owns a capsule that pins `mesh` alive for the view's lifetime. +// The array's base is a capsule owning a strong ref to `mesh`, so the view +// stays valid even if the volume's mesh is later replaced on the main thread. template py::array make_readonly_rows3(const std::shared_ptr& mesh, const T* data, py::ssize_t rows) { if (rows == 0 || data == nullptr) - return py::array_t(std::vector{0, 3}); - + return py::array_t(std::vector{ 0, 3 }); auto* owner = new std::shared_ptr(mesh); py::capsule base(owner, [](void* p) { delete reinterpret_cast*>(p); }); - - py::array_t array( - { rows, py::ssize_t(3) }, - { py::ssize_t(3 * sizeof(T)), py::ssize_t(sizeof(T)) }, - data, - base); - // A capsule-based array is writable by default in pybind11; the underlying - // mesh is const, so force the view read-only. - array.attr("setflags")(py::arg("write") = false); - return array; -} - -// 4x4 row-major float64 copy of an affine transform. Eigen stores column-major, -// so fill element-wise to produce correct C-order data. -py::object mat4_to_numpy(const Transform3d& transform) -{ - return with_numpy([&] { - py::array_t array({ py::ssize_t(4), py::ssize_t(4) }); - auto view = array.mutable_unchecked<2>(); - const auto& matrix = transform.matrix(); - for (int i = 0; i < 4; ++i) - for (int j = 0; j < 4; ++j) - view(i, j) = matrix(i, j); - return py::object(std::move(array)); - }); + return make_readonly_rows(base, data, rows); } py::list current_filament_presets(PresetBundle& bundle) @@ -530,6 +479,9 @@ void PluginHostApi::RegisterBindings(pybind11::module_& module) // UI: native dialogs and interactive HTML windows for plugins. PluginHostUi::RegisterBindings(host); + + // Slicing print-graph data model (Print, Layer, Surface, ...). + PluginHostSlicing::RegisterBindings(host); } } // namespace Slic3r diff --git a/src/slic3r/plugin/PluginHostSlicing.cpp b/src/slic3r/plugin/PluginHostSlicing.cpp new file mode 100644 index 0000000000..f1a94c7658 --- /dev/null +++ b/src/slic3r/plugin/PluginHostSlicing.cpp @@ -0,0 +1,512 @@ +#include "PluginHostSlicing.hpp" +#include "PluginBindingUtils.hpp" + +#include "libslic3r/libslic3r.h" // unscale<>, scale_ +#include "libslic3r/BoundingBox.hpp" +#include "libslic3r/ExPolygon.hpp" +#include "libslic3r/Surface.hpp" +#include "libslic3r/SurfaceCollection.hpp" +#include "libslic3r/ExtrusionEntity.hpp" +#include "libslic3r/ExtrusionEntityCollection.hpp" +#include "libslic3r/Layer.hpp" // LayerRegion, Layer, SupportLayer +#include "libslic3r/Print.hpp" // PrintRegion, PrintObject, Print + +#include +#include +#include +#include + +namespace py = pybind11; + +namespace Slic3r { +namespace { +// --- 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 a bare (N,2) ndarray (contour only), a +// [contour, [hole, ...]] sequence, or (G9) a [contour, [hole, ...], SurfaceType] triple whose +// third element overrides the surface type for set_slices/set_fill_surfaces. When `out_type` is +// null (geometry-only consumers such as set_lslices) any third element is ignored. 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, + std::optional* out_type = nullptr) +{ + 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)); + } + } + // G9: optional third element -> per-surface SurfaceType override (None keeps the + // carried-forward type). A wrong type raises ValueError, matching the API contract. + if (out_type != nullptr && py::len(seq) >= 3) { + py::object t = seq[2]; + if (!t.is_none()) { + try { *out_type = t.cast(); } + catch (const py::cast_error&) { + throw py::value_error(std::string(who) + ": the third entry element must be an orca.host.SurfaceType"); + } + } + } + } 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 Python list of entries -> ExPolygons (each entry parsed + oriented). G7: an empty list is +// legal and means "no geometry" (clears the target collection). Per-entry types are ignored +// here (geometry-only consumers such as set_lslices). +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)); + 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 none. G9: a per-entry SurfaceType (optional third element) overrides that default. +// G7: an empty list is legal and yields an empty Surfaces (clears the collection). +static Surfaces surfaces_from_py(py::handle list_h, const SurfaceCollection& replaced, const char* who) +{ + if (!py::isinstance(list_h) || py::isinstance(list_h)) + throw py::value_error(std::string(who) + ": expected a list of polygons"); + const Surface tmpl = replaced.surfaces.empty() ? Surface(stInternal) : replaced.surfaces.front(); + Surfaces out; + for (py::handle entry : py::reinterpret_borrow(list_h)) { + std::optional type; + ExPolygon e = parse_expolygon(entry, who, &type); + Surface s(tmpl, std::move(e)); + if (type) + s.surface_type = *type; + out.emplace_back(std::move(s)); + } + return out; +} + +// 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); + } +} +} // namespace + +void PluginHostSlicing::RegisterBindings(py::module_& host) +{ + // ------------------------------------------------------------------ + // Slicing print-graph data model — raw bindings of the classes the C++ + // pipeline itself uses, same nodelete/reference style as the Model and + // Preset graphs above. + // + // LIFETIME (C++ semantics, the one rule of this API): every object handed + // out below is a non-owning reference into the live slicing graph owned by + // the Print. References — and every numpy view they hand out — are valid + // only while the plugin hook (execute(ctx)) runs, and a container-replacing + // mutator (LayerRegion.set_slices / set_fill_surfaces, Layer.set_lslices) + // invalidates previously obtained references into that container, exactly + // as std::vector operations invalidate C++ iterators. Do not stash + // references or arrays across execute() calls; copy what you need. + // ------------------------------------------------------------------ + + py::enum_(host, "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(); + + py::class_>(host, "Polygon") + .def("size", [](const Polygon& p) { return p.points.size(); }) + .def("is_counter_clockwise", [](const Polygon& p) { return p.is_counter_clockwise(); }) + .def("points", [](py::object self) { + const Polygon& p = self.cast(); + return with_numpy([&] { + return py::object(make_readonly_rows( + self, p.points.empty() ? nullptr : p.points.front().data(), + (py::ssize_t) p.points.size())); + }); + }, "Vertices as a read-only int64 (N,2) numpy view in scaled coords. " + "Valid only during the execute(ctx) call. Requires numpy."); + + py::class_>(host, "ExPolygon") + .def_property_readonly("contour", [](ExPolygon& e) -> Polygon& { return e.contour; }, + py::return_value_policy::reference_internal, + "Outer contour (CCW) as a Polygon.") + .def_property_readonly("holes", [](py::object self) { + ExPolygon& e = self.cast(); + py::list out; + for (Polygon& h : e.holes) + out.append(py::cast(&h, py::return_value_policy::reference_internal, self)); + return out; + }, "Hole contours (CW) as [Polygon]."); + + py::class_>(host, "Surface") + .def_readwrite("surface_type", &Surface::surface_type, + "This surface's SurfaceType. Writable: assigning reclassifies the " + "surface in place on the live slicing graph (geometry unchanged).") + .def_readonly("thickness", &Surface::thickness) + .def_readonly("bridge_angle", &Surface::bridge_angle) + .def_readonly("extra_perimeters", &Surface::extra_perimeters) + .def_property_readonly("expolygon", [](Surface& s) -> ExPolygon& { return s.expolygon; }, + py::return_value_policy::reference_internal, + "This surface's geometry."); + + py::class_>(host, "SurfaceCollection") + .def("size", [](const SurfaceCollection& c) { return c.surfaces.size(); }) + .def_property_readonly("surfaces", [](py::object self) { + SurfaceCollection& c = self.cast(); + py::list out; + for (Surface& s : c.surfaces) + out.append(py::cast(&s, py::return_value_policy::reference_internal, self)); + return out; + }, "Surfaces as [Surface] references into the live collection. Invalidated " + "by set_slices/set_fill_surfaces on the owning region (C++ vector semantics)."); + + // --- Extrusion tree (read-only in v1). Registered polymorphically: when a returned + // ExtrusionEntity*'s dynamic type IS one of the classes registered below, pybind + // hands the plugin that concrete type, so plugins walk the same tree shape C++ does. + // When the dynamic type is NOT registered (e.g. ExtrusionLoopSloped, produced with + // scarf seams), pybind falls back to the STATIC type at the cast site -- so such a + // `.entities` child surfaces as a bare ExtrusionEntity (only .role is available). + // flatten_paths() (a dynamic_cast walk) still yields proper ExtrusionPath leaves and + // is the robust way to extract toolpaths. + py::class_>(host, "ExtrusionEntity") + .def_property_readonly("role", [](const ExtrusionEntity& e) { + return ExtrusionEntity::role_to_string(e.role()); + }, "Extrusion role as a human-readable string (e.g. \"Outer wall\", \"Sparse infill\")."); + + py::class_>(host, "ExtrusionPath") + .def("points", [](py::object self) { + const ExtrusionPath& p = self.cast(); + const Points3& pts = p.polyline.points; + return with_numpy([&] { + return py::object(make_readonly_rows( + self, 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). Requires numpy.") + .def_readonly("width", &ExtrusionPath::width) + .def_readonly("height", &ExtrusionPath::height) + .def_readonly("mm3_per_mm", &ExtrusionPath::mm3_per_mm); + + py::class_>(host, "ExtrusionLoop") + .def_property_readonly("paths", [](py::object self) { + ExtrusionLoop& l = self.cast(); + py::list out; + for (ExtrusionPath& p : l.paths) + out.append(py::cast(&p, py::return_value_policy::reference_internal, self)); + return out; + }, "The loop's constituent paths as [ExtrusionPath]."); + + py::class_>(host, "ExtrusionMultiPath") + .def_property_readonly("paths", [](py::object self) { + ExtrusionMultiPath& m = self.cast(); + py::list out; + for (ExtrusionPath& p : m.paths) + out.append(py::cast(&p, py::return_value_policy::reference_internal, self)); + return out; + }, "The multipath's constituent paths as [ExtrusionPath]."); + + py::class_>(host, "ExtrusionEntityCollection") + .def("size", [](const ExtrusionEntityCollection& c) { return c.entities.size(); }) + .def_property_readonly("entities", [](py::object self) { + ExtrusionEntityCollection& c = self.cast(); + py::list out; + for (ExtrusionEntity* e : c.entities) + out.append(py::cast(e, py::return_value_policy::reference_internal, self)); + return out; + }, "Child entities. Each is handed to you as its concrete type only when that type " + "is registered; a child whose concrete type is unregistered (e.g. a scarf-seam " + "ExtrusionLoopSloped) surfaces as a bare ExtrusionEntity exposing only .role. Use " + "flatten_paths() to robustly reach every ExtrusionPath leaf.") + .def("flatten_paths", [](py::object self) { + const ExtrusionEntityCollection& c = self.cast(); + std::vector paths; + collect_extrusion_paths(&c, paths); + py::list out; + for (const ExtrusionPath* p : paths) + out.append(py::cast(const_cast(p), + py::return_value_policy::reference_internal, self)); + return out; + }, "Every leaf ExtrusionPath under this tree (collections recursed into, " + "loops/multipaths decomposed)."); + + py::class_>(host, "PrintRegion") + .def("config_keys", [](const PrintRegion& r) { return r.config().keys(); }) + .def("config_value", [](const PrintRegion& r, const std::string& key) { + return config_value_or_none(r.config(), key); + }, py::arg("key"), + "Serialized value of this region's resolved config option, or None if absent."); + + auto layer_region = py::class_>(host, "LayerRegion"); + layer_region + .def_readonly("slices", &LayerRegion::slices, + "Sliced, typed surfaces (SurfaceCollection). At Step.Slice this is the " + "primary mutation target via set_slices().") + .def_readonly("fill_surfaces", &LayerRegion::fill_surfaces, + "Surfaces prepared for infill (SurfaceCollection).") + .def_readonly("perimeters", &LayerRegion::perimeters, + "Perimeter toolpaths (ExtrusionEntityCollection).") + .def_readonly("fills", &LayerRegion::fills, + "Infill toolpaths (ExtrusionEntityCollection).") + .def("layer", [](LayerRegion& r) -> py::object { + Layer* l = r.layer(); + if (l == nullptr) + return py::none(); + return py::cast(l, py::return_value_policy::reference); + }, "Owning Layer, or None.") + .def("region", [](LayerRegion& r) -> const PrintRegion& { return r.region(); }, + py::return_value_policy::reference, + "This region's PrintRegion (resolved per-region settings).") + .def("config_value", [](const LayerRegion& r, const std::string& key) { + return config_value_or_none(r.region().config(), key); + }, py::arg("key"), + "Serialized value of this region's resolved config option, or None if absent.") + // MUTATOR (G1/G3/G9). Replace this region's sliced surfaces. `polygons` is a list of + // (N,2) int64 ndarrays (scaled coords), [contour, [holes...]] pairs, or (G9) + // [contour, [holes...], SurfaceType] triples; orientation is normalized (contour CCW, + // holes CW) and surface_type is carried forward from the replaced surfaces (else + // stInternal) unless a per-entry type is given. + .def("set_slices", [](LayerRegion& region, py::object polygons, bool refresh_lslices) { + region.slices.set(surfaces_from_py(polygons, region.slices, "set_slices")); + // G1: rebuild the owning layer's merged islands (lslices) + bbox cache from the + // mutated region slices so downstream consumers (detect_surfaces_type neighbor + // diffs, overhang/bridge detection, brim/skirt/support) see coherent islands. + // Skipped when the region has no owning layer (unit-test regions). + if (refresh_lslices) { + if (Layer* layer = region.layer()) { + layer->make_slices(); + 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("polygons"), py::arg("refresh_lslices") = true, + "Replace this region's sliced surfaces from a list of (N,2) int64 ndarrays (scaled " + "coords), [contour, [holes...]] pairs, or [contour, [holes...], SurfaceType] triples " + "(orientation normalized: contour CCW / holes CW; surface_type carried forward from the " + "replaced surfaces, else stInternal, unless a per-entry SurfaceType is supplied). An " + "empty list clears this region's slices.\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" + "LSLICES (G1): refresh_lslices=True (default) re-derives the owning layer's merged " + "islands and bbox cache from the new slices so overhang/bridge/skirt/support stay " + "coherent; pass False only if you manage lslices yourself via Layer.set_lslices.\n" + "PERSISTENCE (G3): the Slice hook re-snapshots raw_slices after it returns, so the " + "mutation survives a later perimeter-only re-run (restore_untyped_slices) instead of " + "silently reverting; it still does not persist across a full re-slice unless the hook " + "re-fires (re-select the plugin, or any posSlice-invalidating change).\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. Replace this region's fill (infill-prep) surfaces; identical input format and + // validation to set_slices. + .def("set_fill_surfaces", [](LayerRegion& region, py::object polygons) { + 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 (per-entry SurfaceType supported; an empty list clears them).\n" + "MUTATION-CASCADE: at the PrepareInfill boundary (G4) make_fills runs afterward, so this " + "cascades into the generated infill. At the Infill boundary it changes the stored " + "surfaces but does NOT regenerate the already-built `fills` toolpaths (v1).\n" + "Raises ValueError on malformed input. Valid only during the execute(ctx) call."); + + auto layer = py::class_>(host, "Layer"); + layer + .def_readonly("print_z", &Layer::print_z) + .def_readonly("slice_z", &Layer::slice_z) + .def_readonly("height", &Layer::height) + .def_property_readonly("upper_layer", [](Layer& l) -> py::object { + if (l.upper_layer == nullptr) return py::none(); + return py::cast(l.upper_layer, py::return_value_policy::reference); + }, "The layer above, or None (graph navigation, like C++).") + .def_property_readonly("lower_layer", [](Layer& l) -> py::object { + if (l.lower_layer == nullptr) return py::none(); + return py::cast(l.lower_layer, py::return_value_policy::reference); + }, "The layer below, or None.") + .def("regions", [](py::object self) { + Layer& l = self.cast(); + py::list out; + for (LayerRegion* r : l.regions()) + out.append(py::cast(r, py::return_value_policy::reference_internal, self)); + return out; + }, "Per-region data as [LayerRegion].") + .def("lslices", [](py::object self) { + Layer& l = self.cast(); + py::list out; + for (ExPolygon& e : l.lslices) + out.append(py::cast(&e, py::return_value_policy::reference_internal, self)); + return out; + }, "Merged per-layer islands as [ExPolygon] references. Invalidated by " + "set_lslices/make_slices (C++ vector semantics).") + .def("make_slices", [](Layer& l) { + l.make_slices(); + l.lslices_bboxes.clear(); + l.lslices_bboxes.reserve(l.lslices.size()); + for (const ExPolygon& island : l.lslices) + l.lslices_bboxes.emplace_back(get_extents(island)); + }, "Re-derive lslices (merged islands) from the region slices and refresh the " + "bbox cache — the C++ invariant-maintenance call after in-place geometry edits. " + "set_slices(refresh_lslices=True) runs this for you.") + // MUTATOR. Replace this layer's merged islands (lslices) and refresh the cache-invariant + // `lslices_bboxes` (one BoundingBox per island via get_extents). Same input format and + // validation as LayerRegion.set_slices. + .def("set_lslices", [](Layer& l, py::object islands) { + l.lslices = parse_expolygon_list(islands, "set_lslices"); + l.lslices_bboxes.clear(); + l.lslices_bboxes.reserve(l.lslices.size()); + for (const ExPolygon& island : l.lslices) + l.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 LayerRegion.set_slices. Raises ValueError on malformed " + "input. Valid only during the execute(ctx) call."); + + py::class_>(host, "PrintObject") + .def("id", [](const PrintObject& o) { return o.id().id; }, + "Stable numeric object id (ObjectBase::id()).") + .def("layers", [](py::object self) { + PrintObject& o = self.cast(); + py::list out; + for (Layer* l : o.layers()) + out.append(py::cast(l, py::return_value_policy::reference_internal, self)); + return out; + }, "Object layers, bottom-up, as [Layer].") + .def("support_layers", [](py::object self) { + PrintObject& o = self.cast(); + py::list out; + for (SupportLayer* sl : o.support_layers()) + out.append(py::cast(static_cast(sl), + py::return_value_policy::reference_internal, self)); + return out; + }, "Support layers as [Layer] (support-specific fields are not exposed in v1).") + .def("model_object", [](PrintObject& o) -> py::object { + // The Print's model SNAPSHOT (worker-thread stable), reusing the + // orca.host.ModelObject bindings — mesh access for slicing plugins. + // o is non-const here, so model_object() already returns a non-const ModelObject*. + return py::cast(o.model_object(), py::return_value_policy::reference); + }, "The source orca.host.ModelObject from the Print's own model snapshot.") + .def("bounding_box", [](const PrintObject& o) { + const BoundingBox bb = o.bounding_box(); + return py::make_tuple(bb.min.x(), bb.min.y(), bb.max.x(), bb.max.y()); + }, "Object XY bounding box in scaled coords as (min_x, min_y, max_x, max_y). The " + "sliced polygons live in this same frame, so its midpoint is the footprint center.") + .def("trafo", [](const PrintObject& o) { return mat4_to_numpy(o.trafo()); }, + "Object-to-print 4x4 float64 affine matrix (copy). Requires numpy.") + .def("config_keys", [](const PrintObject& o) { return o.config().keys(); }) + .def("config_value", [](const PrintObject& o, const std::string& key) { + return config_value_or_none(o.config(), key); + }, py::arg("key"), + "Serialized value of a resolved per-object config option, or None if absent."); + + py::class_>(host, "Print") + .def("objects", [](py::object self) { + Print& p = self.cast(); + py::list out; + for (PrintObject* o : p.objects()) + out.append(py::cast(o, py::return_value_policy::reference_internal, self)); + return out; + }, "The print's objects as [PrintObject].") + .def("model", [](Print& p) -> Model& { return const_cast(p.model()); }, + py::return_value_policy::reference_internal, + "The Print's own Model snapshot (worker-thread stable). Inside slicing " + "hooks use THIS — never orca.host.model(), which is the live GUI model " + "owned by another thread.") + .def("config_keys", [](const Print& p) { return p.full_print_config().keys(); }) + .def("config_value", [](const Print& p, const std::string& key) { + return config_value_or_none(p.full_print_config(), key); + }, py::arg("key"), + "Serialized value of the resolved (full) print config for this slice, or None.") + .def("canceled", [](const Print& p) { return p.canceled(); }, + "True once cancellation was requested (prefer ctx.cancelled())."); +} + +} // namespace Slic3r diff --git a/src/slic3r/plugin/PluginHostSlicing.hpp b/src/slic3r/plugin/PluginHostSlicing.hpp new file mode 100644 index 0000000000..3fdbb7bd4e --- /dev/null +++ b/src/slic3r/plugin/PluginHostSlicing.hpp @@ -0,0 +1,16 @@ +#pragma once +#include + +namespace Slic3r { + +// Registers the slicing print-graph data model (Print, PrintObject, Layer, +// LayerRegion, Surface, ExPolygon, extrusions, ...) into the `orca.host` +// submodule, in the same raw-class style as PluginHostApi's Model/Preset +// graph. Called from PluginHostApi::RegisterBindings. +class PluginHostSlicing +{ +public: + static void RegisterBindings(pybind11::module_& host); +}; + +} // namespace Slic3r diff --git a/src/slic3r/plugin/PluginLoader.cpp b/src/slic3r/plugin/PluginLoader.cpp index e5a96b87d3..6d3f90651c 100644 --- a/src/slic3r/plugin/PluginLoader.cpp +++ b/src/slic3r/plugin/PluginLoader.cpp @@ -261,6 +261,13 @@ std::shared_ptr PluginLoader::get_plugin_capability_by_n return nullptr; } +std::map PluginLoader::get_plugin_settings(const std::string& plugin_key) const +{ + std::lock_guard lock(m_mutex); + const auto it = m_plugins.find(plugin_key); + return it != m_plugins.end() ? it->second.descriptor.settings : std::map{}; +} + std::vector> PluginLoader::get_loaded_plugin_capabilities(const std::string& plugin_key) const { std::lock_guard lock(m_mutex); diff --git a/src/slic3r/plugin/PluginLoader.hpp b/src/slic3r/plugin/PluginLoader.hpp index e3903001e4..77bb0b4747 100644 --- a/src/slic3r/plugin/PluginLoader.hpp +++ b/src/slic3r/plugin/PluginLoader.hpp @@ -104,6 +104,8 @@ public: std::chrono::milliseconds timeout, std::string& error) const; std::vector get_all_loaded_plugin_descriptors() const; + // the plugin's [tool.orcaslicer.plugin.settings] table (empty if the plugin is unknown). + std::map get_plugin_settings(const std::string& plugin_key) const; // Package descriptor accessor; returns nullptr when the package is not loaded. diff --git a/src/slic3r/plugin/PluginResolver.cpp b/src/slic3r/plugin/PluginResolver.cpp index 0bf47858f4..d7808b6a5b 100644 --- a/src/slic3r/plugin/PluginResolver.cpp +++ b/src/slic3r/plugin/PluginResolver.cpp @@ -35,9 +35,9 @@ std::string find_option_for_capability(Preset::Type type, const Preset& preset, if (type != Preset::TYPE_PRINT && type != Preset::TYPE_PRINTER && type != Preset::TYPE_FILAMENT) return {}; - // Plugin-bearing options opt in via ConfigOptionDef::support_plugin, so scan the preset's - // definition rather than maintaining a hardcoded per-type field list. A typed preset's config - // only contains keys for its own type, so this naturally stays scoped to `type`. + // Plugin-bearing options opt in via ConfigOptionDef::is_plugin_backed (a non-empty plugin_type), + // so scan the preset's definition rather than maintaining a hardcoded per-type field list. A typed + // preset's config only contains keys for its own type, so this naturally stays scoped to `type`. const ConfigDef* def = preset.config.def(); if (def == nullptr) return {}; @@ -48,7 +48,7 @@ std::string find_option_for_capability(Preset::Type type, const Preset& preset, for (const std::string& field : preset.config.keys()) { const ConfigOptionDef* opt_def = def->get(field); - if (opt_def == nullptr || !opt_def->support_plugin) + if (opt_def == nullptr || !opt_def->is_plugin_backed()) continue; const ConfigOption* option = preset.config.option(field); diff --git a/src/slic3r/plugin/PythonFileUtils.cpp b/src/slic3r/plugin/PythonFileUtils.cpp index 53afb115de..9fec2ee13f 100644 --- a/src/slic3r/plugin/PythonFileUtils.cpp +++ b/src/slic3r/plugin/PythonFileUtils.cpp @@ -128,7 +128,7 @@ bool read_zip_text_file(mz_zip_archive& archive, const char* filename, std::stri } // TOML section parsing states. -enum class TomlSection { Root, OrcaPlugin, InDepsArray }; +enum class TomlSection { Root, OrcaPlugin, OrcaPluginSettings, InDepsArray }; // Strip a quoted string value: "foo" → foo, 'foo' → foo. // Returns the unquoted value or the input unchanged if not quoted. @@ -187,6 +187,7 @@ bool parse_pep723_toml(const std::string& toml_content, std::string& out_description, std::string& out_author, std::string& out_version, + std::map& out_settings, std::string& error) { out_deps.clear(); @@ -195,6 +196,7 @@ bool parse_pep723_toml(const std::string& toml_content, out_description.clear(); out_author.clear(); out_version.clear(); + out_settings.clear(); TomlSection section = TomlSection::Root; @@ -218,6 +220,8 @@ bool parse_pep723_toml(const std::string& toml_content, if (trimmed[0] == '[') { if (trimmed == "[tool.orcaslicer.plugin]") { section = TomlSection::OrcaPlugin; + } else if (trimmed == "[tool.orcaslicer.plugin.settings]") { + section = TomlSection::OrcaPluginSettings; // per-plugin params table } else { section = TomlSection::Root; // Unknown section — skip. } @@ -270,6 +274,10 @@ bool parse_pep723_toml(const std::string& toml_content, else if (key == "description") out_description = unquote_toml_string(val); else if (key == "author") out_author = unquote_toml_string(val); else if (key == "version") out_version = unquote_toml_string(val); + } else if (section == TomlSection::OrcaPluginSettings) { + // collect every key as a string; the plugin parses (int/float/...) what it needs. + if (!key.empty()) + out_settings[key] = unquote_toml_string(val); } } @@ -673,6 +681,7 @@ bool read_python_plugin_metadata(const boost::filesystem::path& py_path, PluginD pep_desc, pep_author, pep_version, + descriptor.settings, pep723_error)) { error = "Failed to parse PEP 723 metadata: " + pep723_error; return false; diff --git a/src/slic3r/plugin/pluginTypes/slicingPipeline/SlicingNumpy.hpp b/src/slic3r/plugin/pluginTypes/slicingPipeline/SlicingNumpy.hpp deleted file mode 100644 index 22ebf32be6..0000000000 --- a/src/slic3r/plugin/pluginTypes/slicingPipeline/SlicingNumpy.hpp +++ /dev/null @@ -1,27 +0,0 @@ -#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 index 82e8ee0db7..3ee5539af1 100644 --- a/src/slic3r/plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.cpp +++ b/src/slic3r/plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.cpp @@ -1,162 +1,14 @@ #include "SlicingPipelinePluginCapability.hpp" #include "SlicingPipelinePluginCapabilityTrampoline.hpp" -#include "SlicingNumpy.hpp" // make_readonly_rows +#include "slic3r/plugin/PluginBindingUtils.hpp" // config_value_or_none #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 +#include // std::map -> dict for ctx.params 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)."); @@ -165,7 +17,8 @@ void SlicingPipelinePluginCapability::RegisterBindings(py::module_& module, py:: .value("Slice", SlicingPipelineStep::Slice) .value("Perimeters", SlicingPipelineStep::Perimeters) .value("EstimateCurledExtrusions", SlicingPipelineStep::EstimateCurledExtrusions) - .value("Infill", SlicingPipelineStep::Infill) // fires after prepare+infill + .value("PrepareInfill", SlicingPipelineStep::PrepareInfill) // after prepare_infill, before make_fills: set_fill_surfaces here CASCADES + .value("Infill", SlicingPipelineStep::Infill) // after make_fills: set_fill_surfaces here does NOT regenerate fills (v1) .value("Ironing", SlicingPipelineStep::Ironing) .value("Contouring", SlicingPipelineStep::Contouring) .value("SupportMaterial", SlicingPipelineStep::SupportMaterial) @@ -175,190 +28,45 @@ void SlicingPipelinePluginCapability::RegisterBindings(py::module_& module, py:: .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(); + // The read-graph data model (Surface / ExPolygon / the extrusion tree / LayerRegion / + // Layer / PrintObject / Print) and the 2D-geometry mutators live in orca.host, registered + // by PluginHostSlicing.cpp. orca.slicing is workflow-only: Step, unscale, the context, and + // the capability base. See PluginHostSlicing.cpp for the mandatory reference-lifetime rule. // 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_readonly("params", &SlicingPipelineContext::params, + "read-only dict of this plugin's [tool.orcaslicer.plugin.settings] values " + "(string->string). Parse the values you need, e.g. float(ctx.params['rate']).") + .def_property_readonly("print", [](const SlicingPipelineContext& ctx) -> py::object { + if (ctx.print == nullptr) + return py::none(); + return py::cast(ctx.print, py::return_value_policy::reference); + }, "The orca.host.Print being sliced — the raw slicing graph, exactly what the " + "C++ pipeline walks. Valid only during the execute(ctx) call. For mesh access " + "use ctx.print.model() (the Print's snapshot), never orca.host.model().") .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. " + // The hook signature hands objects out as const; they are genuinely mutable + // (owned by the Print) — the same const_cast the old view mutators used, + // done once here at the graph entry point. + return py::cast(const_cast(ctx.object), py::return_value_policy::reference); + }, "orca.host.PrintObject for object-scoped steps, or None for print-wide steps. " "Valid only during the execute(ctx) call.") + .def("config_value", [](const SlicingPipelineContext& ctx, const std::string& key) -> py::object { + if (ctx.print == nullptr) + return py::none(); + return config_value_or_none(ctx.print->full_print_config(), key); + }, py::arg("key"), + "serialized value of a resolved (full) print config option for this slice, or " + "None if absent. Shorthand for ctx.print.config_value(key).") .def("cancelled", &SlicingPipelineContext::cancelled); py::class_ +#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; }; - +// Workflow context handed to SlicingPipeline plugins. ctx.print / ctx.object +// are RAW references into the live slicing graph — the same objects the C++ +// pipeline mutates. The data-model bindings and the mandatory lifetime rule +// (valid only during execute(ctx); mutators invalidate references into replaced +// containers, like std::vector iterators) live in +// src/slic3r/plugin/PluginHostSlicing.cpp. struct SlicingPipelineContext { std::string orca_version; SlicingPipelineStep step { SlicingPipelineStep::Slice }; - Print* print { nullptr }; // always present + Print* print { nullptr }; // always present when dispatched 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() + // read-only per-plugin settings, populated by the dispatcher from the + // plugin's [tool.orcaslicer.plugin.settings] PEP-723 table. Exposed as + // ctx.params (dict of string->string). + std::map params; + bool cancelled() const; // -> print->canceled() }; class SlicingPipelinePluginCapability : public PluginCapabilityInterface { diff --git a/tests/fff_print/test_slicing_pipeline_hook.cpp b/tests/fff_print/test_slicing_pipeline_hook.cpp index 22d78f3b40..28869b0c36 100644 --- a/tests/fff_print/test_slicing_pipeline_hook.cpp +++ b/tests/fff_print/test_slicing_pipeline_hook.cpp @@ -9,7 +9,8 @@ TEST_CASE("slicing_pipeline_plugin option exists and defaults empty", "[slicing_ CHECK(opt->values.empty()); const ConfigOptionDef* def = cfg.def()->get("slicing_pipeline_plugin"); REQUIRE(def != nullptr); - CHECK(def->support_plugin == true); + CHECK(def->plugin_type == "slicing-pipeline"); + CHECK(def->is_plugin_backed()); CHECK(def->gui_type == ConfigOptionDef::GUIType::plugin_picker); } @@ -45,6 +46,7 @@ TEST_CASE("SlicingPipeline hook fires once per step per object in order", "[slic 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::PrepareInfill) == 1); // the prepare-infill seam fires once per object CHECK(count(S::Infill) == 1); CHECK(count(S::WipeTower) == 1); CHECK(count(S::SkirtBrim) == 1); @@ -54,9 +56,32 @@ TEST_CASE("SlicingPipeline hook fires once per step per object in order", "[slic // Slice must fire before Perimeters for the same object: auto idx = [&](S s){ for (size_t i=0;i +#include + +// Exported G-code carries a few nondeterministic comment lines unrelated to toolpaths: a +// wall-clock timestamp ("; generated by ..."), ObjectID-derived ids (from a process-global +// counter never reset between runs), and a config-dump line naming the selected plugin (an +// active run records it, the absent baseline does not). Strip exactly those lines so a raw +// byte-compare isolates the real motion/extrusion output; every other byte is still compared. +static std::string strip_nondeterministic_gcode_lines(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" / "... unique label id: N" (ObjectID-derived): + if (line.find("printing object") != std::string::npos && line.find(" id:") != std::string::npos) continue; + if (line.find("slicing_pipeline_plugin") != std::string::npos) continue; // config-dump plugin name + out += line; out += '\n'; + } + return out; +} 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: @@ -80,35 +105,12 @@ TEST_CASE("Inactive hook: process output is byte-identical (no-op hook == unset) 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 + // Compare only machine-meaningful output (see strip_nondeterministic_gcode_lines): every + // motion/extrusion byte is still compared, so this proves the inactive hook -- and the + // active-but-non-mutating hook -- leave the real toolpath byte-identical. + const std::string baseline = strip_nondeterministic_gcode_lines(run(false, false)); // feature absent + CHECK(strip_nondeterministic_gcode_lines(run(false, true)) == baseline); // gated off: hook never fires + CHECK(strip_nondeterministic_gcode_lines(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 @@ -172,7 +174,7 @@ TEST_CASE("Duplicate objects share a slice: Slice hook fires exactly once", "[sl #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 +// 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 @@ -213,3 +215,256 @@ TEST_CASE("Changing slicing_pipeline_plugin invalidates posSlice", "[slicing_pip print.apply(model, config); CHECK_FALSE(print.objects().front()->is_step_done(posSlice)); // re-slice required } + +#include + +// §3.6 (Twistify design): Twistify's effect is a similarity transform (rotate + uniform +// scale) applied to slices at Step.Slice. This C++ analogue rotates every region's slices a +// fixed 45 deg about the object's base-footprint center -- the same seam and cascade that +// Twistify.py drives through the pybind set_slices binding. Two end-to-end invariants after +// process() confirm the approach: +// (1) a pure rotation is a similarity with scale 1, so total fill area is preserved, and +// (2) the mutation genuinely cascaded into make_perimeters' fill_surfaces -- a 20mm square +// rotated 45 deg becomes a diamond whose bbox is ~sqrt(2)x wider (it did not stay +// axis-aligned), proving downstream geometry was rebuilt from the twisted slices. +TEST_CASE("Rotating slices at the Slice boundary cascades (area preserved, bbox rotated)", "[slicing_pipeline]") { + using Catch::Matchers::WithinRel; + struct Measure { double area; double width; double height; }; + auto measure = [](bool rotate) -> Measure { + 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 (rotate) Slic3r::Print::set_slicing_pipeline_hook_fn( + [](Slic3r::Print&, const Slic3r::PrintObject* o, Slic3r::SlicingPipelineStep s){ + if (s != Slic3r::SlicingPipelineStep::Slice || !o) return; + auto* obj = const_cast(o); + // Twist axis = center of the first sliced layer's footprint (Twistify's anchor). + coord_t nx=0, xx=0, ny=0, xy=0; bool seeded=false; + for (Slic3r::Layer* l : obj->layers()) { + for (Slic3r::LayerRegion* r : l->regions()) + for (const Slic3r::Surface& sf : r->slices.surfaces) + for (const Slic3r::Point& p : sf.expolygon.contour.points) { + if (!seeded) { nx=xx=p.x(); ny=xy=p.y(); seeded=true; } + else { nx=std::min(nx,p.x()); xx=std::max(xx,p.x()); + ny=std::min(ny,p.y()); xy=std::max(xy,p.y()); } + } + if (seeded) break; + } + const double cx = 0.5*((double)nx+(double)xx), cy = 0.5*((double)ny+(double)xy); + const double ct = 0.7071067811865476, st = 0.7071067811865476; // cos/sin 45 deg + auto rot = [&](const Slic3r::Point& p) { + const double dx = (double)p.x()-cx, dy = (double)p.y()-cy; + return Slic3r::Point((coord_t)std::llround(dx*ct - dy*st + cx), + (coord_t)std::llround(dx*st + dy*ct + cy)); + }; + for (Slic3r::Layer* l : obj->layers()) + for (Slic3r::LayerRegion* r : l->regions()) { + Slic3r::Surfaces in = r->slices.surfaces; + for (auto& sf : in) { + for (auto& pt : sf.expolygon.contour.points) pt = rot(pt); + for (auto& h : sf.expolygon.holes) + for (auto& pt : h.points) pt = rot(pt); + } + 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 area = 0; + coord_t nx=0, xx=0, ny=0, xy=0; bool seeded=false; + for (auto* l : print.objects().front()->layers()) + for (auto* r : l->regions()) + for (auto& sf : r->fill_surfaces.surfaces) { + area += sf.expolygon.area(); + for (const Slic3r::Point& p : sf.expolygon.contour.points) { + if (!seeded) { nx=xx=p.x(); ny=xy=p.y(); seeded=true; } + else { nx=std::min(nx,p.x()); xx=std::max(xx,p.x()); + ny=std::min(ny,p.y()); xy=std::max(xy,p.y()); } + } + } + Slic3r::Print::set_slicing_pipeline_hook_fn(nullptr); + return { area, (double)(xx-nx), (double)(xy-ny) }; + }; + const Measure base = measure(false); + const Measure rot = measure(true); + // (1) A pure rotation preserves area (similarity, scale 1): fills add up to the same area. + CHECK_THAT(rot.area, WithinRel(base.area, 0.05)); + // (2) The rotation cascaded downstream: the square's fill bbox grew toward the sqrt(2) + // diagonal (diamond) instead of staying axis-aligned. + CHECK(rot.width > 1.3 * base.width); + CHECK(rot.width < 1.5 * base.width); + CHECK(rot.height > 1.3 * base.height); + CHECK(rot.height < 1.5 * base.height); +} + +// §3.6 (Twistify design): Twistify skips exact-identity layers entirely, but every transformed +// layer invokes the set_slices write-back + make_perimeters re-run. This proves that write path +// is lossless for already-normalized (CCW contour / CW hole) input -- an active hook that +// re-sets every region's slices to their CURRENT geometry (the identity similarity transform) +// produces output byte-identical to an active hook that mutates nothing. Both runs are active +// (same config dump); the only difference is whether the write path ran, so equality isolates it. +TEST_CASE("Identity round-trip through set_slices is byte-identical", "[slicing_pipeline]") { + auto run = [](bool roundtrip) { + Slic3r::Print print; Slic3r::Model model; + auto config = Slic3r::DynamicPrintConfig::full_print_config(); + config.set_key_value("slicing_pipeline_plugin", new Slic3r::ConfigOptionStrings({"probe"})); // active in both runs + Slic3r::Print::set_slicing_pipeline_hook_fn( + [roundtrip](Slic3r::Print&, const Slic3r::PrintObject* o, Slic3r::SlicingPipelineStep s){ + if (!roundtrip || 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; // copy current (already-normalized) geometry + r->slices.set(std::move(in)); // write back unchanged: identity transform + } + }); + 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; + }; + CHECK(strip_nondeterministic_gcode_lines(run(true)) == strip_nondeterministic_gcode_lines(run(false))); +} + +#include "libslic3r/ExtrusionEntityCollection.hpp" // count fill paths in the fill-surface cascade test + +// Total leaf ExtrusionPath count under an extrusion (sub)tree (collections recursed into). +static size_t count_leaf_paths(const Slic3r::ExtrusionEntity* ee) { + if (ee == nullptr) return 0; + if (const auto* coll = dynamic_cast(ee)) { + size_t n = 0; + for (const Slic3r::ExtrusionEntity* e : coll->entities) n += count_leaf_paths(e); + return n; + } + return 1; +} + +// Width (scaled) of the object-wide bounding box over every region's sliced contour. +static double outer_slices_width(const Slic3r::Print& print) { + coord_t min_x = 0, max_x = 0; bool seeded = false; + for (auto* l : print.objects().front()->layers()) + for (auto* r : l->regions()) + for (const Slic3r::Surface& sf : r->slices.surfaces) + for (const Slic3r::Point& p : sf.expolygon.contour.points) { + if (!seeded) { min_x = max_x = p.x(); seeded = true; } + else { min_x = std::min(min_x, p.x()); max_x = std::max(max_x, p.x()); } + } + return (double)(max_x - min_x); +} + +// After the Slice hook mutates slices, raw_slices must be re-snapshotted so the mutation +// becomes the untyped baseline. make_perimeters() restores untyped slices from raw_slices on +// any perimeter re-run; invoking that restore directly must reproduce the mutation, not revert +// to the pre-hook geometry (which is what happened before this fix). +TEST_CASE("raw_slices captures post-hook geometry so a perimeter re-run keeps the mutation", "[slicing_pipeline]") { + using Catch::Matchers::WithinRel; + 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) { + Slic3r::ExPolygons e = offset_ex(sf.expolygon, -scale_(1.0)); + if (!e.empty()) sf.expolygon = e.front(); + } + r->slices.set(std::move(in)); + } + }); + Slic3r::Print print; Slic3r::Model model; + auto config = Slic3r::DynamicPrintConfig::full_print_config(); + config.set_key_value("slicing_pipeline_plugin", new Slic3r::ConfigOptionStrings({"probe"})); + init_print({TestMesh::cube_20x20x20}, print, model, config); + print.process(); + const double w_mutated = outer_slices_width(print); // inset applied at the Slice hook + + // The same restore make_perimeters() runs on a perimeter-only re-slice. With the post-hook + // backup this reproduces the inset; without it this reverts to the wider original outline. + for (Slic3r::Layer* l : print.objects().front()->layers()) + l->restore_untyped_slices(); + const double w_restored = outer_slices_width(print); + Slic3r::Print::set_slicing_pipeline_hook_fn(nullptr); + CHECK_THAT(w_restored, WithinRel(w_mutated, 0.02)); // mutation survived the restore +} + +// A plugin can mutate fill_surfaces at the new PrepareInfill seam and have make_fills consume +// them, whereas the pre-existing Infill seam fires after the fills are already built (v1 limit). +// All three runs register a hook (active path) so the comparison isolates only the mutation. +TEST_CASE("fill_surfaces mutation cascades at PrepareInfill but not at Infill", "[slicing_pipeline]") { + auto fill_paths = [](bool shrink, Slic3r::SlicingPipelineStep at) { + Slic3r::Print print; Slic3r::Model model; + auto config = Slic3r::DynamicPrintConfig::full_print_config(); + config.set_key_value("slicing_pipeline_plugin", new Slic3r::ConfigOptionStrings({"probe"})); + Slic3r::Print::set_slicing_pipeline_hook_fn( + [shrink, at](Slic3r::Print&, const Slic3r::PrintObject* o, Slic3r::SlicingPipelineStep s){ + if (!shrink || s != at || !o) return; + for (Slic3r::Layer* l : const_cast(o)->layers()) + for (Slic3r::LayerRegion* r : l->regions()) { + Slic3r::Surfaces in = r->fill_surfaces.surfaces, out; + for (const Slic3r::Surface& sf : in) + for (const Slic3r::ExPolygon& e : offset_ex(sf.expolygon, -scale_(3.0))) { + Slic3r::Surface s2 = sf; s2.expolygon = e; out.push_back(std::move(s2)); + } + r->fill_surfaces.set(std::move(out)); + } + }); + init_print({TestMesh::cube_20x20x20}, print, model, config); + print.process(); + size_t n = 0; + for (auto* l : print.objects().front()->layers()) + for (auto* r : l->regions()) + n += count_leaf_paths(&r->fills); + Slic3r::Print::set_slicing_pipeline_hook_fn(nullptr); + return n; + }; + using S = Slic3r::SlicingPipelineStep; + const size_t base = fill_paths(false, S::PrepareInfill); // active hook, no mutation + CHECK(base > 0); + CHECK(fill_paths(true, S::PrepareInfill) < base); // mutation before make_fills cascades + CHECK(fill_paths(true, S::Infill) == base); // mutation after make_fills is a no-op (v1) +} + +// lslices (the layer's merged islands) are built once in slice() and never rebuilt by +// make_perimeters, so mutating region slices leaves them stale. set_slices(refresh_lslices=True) +// re-derives them via Layer::make_slices(); this C++ analogue proves the mechanism -- without the +// refresh the islands keep the original 20mm footprint, with it they track the 18mm inset. +TEST_CASE("refreshing lslices after a slice mutation makes islands track the geometry", "[slicing_pipeline]") { + auto lslices_width = [](bool refresh) { + Slic3r::Print print; Slic3r::Model model; + auto config = Slic3r::DynamicPrintConfig::full_print_config(); + config.set_key_value("slicing_pipeline_plugin", new Slic3r::ConfigOptionStrings({"probe"})); + Slic3r::Print::set_slicing_pipeline_hook_fn( + [refresh](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) { + Slic3r::ExPolygons e = offset_ex(sf.expolygon, -scale_(1.0)); + if (!e.empty()) sf.expolygon = e.front(); + } + r->slices.set(std::move(in)); + } + if (refresh) // the load-bearing half of set_slices(refresh_lslices=True) + l->make_slices(); + } + }); + init_print({TestMesh::cube_20x20x20}, print, model, config); + print.process(); + coord_t min_x = 0, max_x = 0; bool seeded = false; + for (auto* l : print.objects().front()->layers()) + for (const Slic3r::ExPolygon& island : l->lslices) + for (const Slic3r::Point& p : island.contour.points) { + if (!seeded) { min_x = max_x = p.x(); seeded = true; } + else { min_x = std::min(min_x, p.x()); max_x = std::max(max_x, p.x()); } + } + Slic3r::Print::set_slicing_pipeline_hook_fn(nullptr); + return (double)(max_x - min_x); + }; + using Catch::Matchers::WithinRel; + const double stale = lslices_width(false); // islands keep the original ~20 mm footprint + const double fresh = lslices_width(true); // islands track the ~18 mm inset region slices + CHECK(fresh < stale); + CHECK_THAT(stale, WithinRel((double) scale_(20.0), 0.05)); // stale islands = original outline + CHECK_THAT(fresh, WithinRel((double) scale_(18.0), 0.05)); // refreshed islands = inset outline +} diff --git a/tests/libslic3r/test_config.cpp b/tests/libslic3r/test_config.cpp index 016cdee437..38d857c765 100644 --- a/tests/libslic3r/test_config.cpp +++ b/tests/libslic3r/test_config.cpp @@ -483,3 +483,57 @@ TEST_CASE("parse_capability_ref rejects malformed input", "[Config][plugin]") { CHECK_FALSE(Slic3r::parse_capability_ref("plugin;;").has_value()); CHECK_FALSE(Slic3r::parse_capability_ref("plugin;uuid;").has_value()); } + +namespace { +// Installs a stub capability resolver that echoes the capability type into the reference, so tests +// can assert each plugin-backed option resolved with its own ConfigOptionDef::plugin_type. Resets +// the global resolver on teardown -- tests run in random order and other cases assert the +// no-resolver behavior (an absent "plugins" manifest). +struct PluginResolverFixture { + PluginResolverFixture() { + ConfigBase::set_resolve_capability_fn([](const std::string& name, const std::string& type) { + return name.empty() ? std::string() : name + ";;" + type; + }); + } + ~PluginResolverFixture() { ConfigBase::set_resolve_capability_fn(nullptr); } +}; +} // namespace + +TEST_CASE_METHOD(PluginResolverFixture, + "update_plugin_manifest derives references generically from plugin-backed options", + "[Config][plugins]") { + // Both scalar (printer_agent) and vector (post_process_plugin, slicing_pipeline_plugin) options + // opt in via a non-empty ConfigOptionDef::plugin_type (is_plugin_backed) and are resolved with it + // -- there is no hardcoded per-option switch. printer_agent in particular relies on its plugin_type + // metadata being wired up (it is edited via a dedicated widget, not the plugin_picker). + std::unique_ptr config_ptr(DynamicPrintConfig::new_from_defaults_keys( + {"post_process_plugin", "slicing_pipeline_plugin", "printer_agent"})); + DynamicPrintConfig config = std::move(*config_ptr); + config.option("post_process_plugin", true)->values = {"pp"}; + config.option("slicing_pipeline_plugin", true)->values = {"sp"}; + config.option("printer_agent", true)->value = "agent"; + + config.update_plugin_manifest(); + const std::vector manifest = config.option("plugins")->values; + + using Catch::Matchers::VectorContains; + REQUIRE_THAT(manifest, VectorContains(std::string("pp;;post-processing"))); + REQUIRE_THAT(manifest, VectorContains(std::string("sp;;slicing-pipeline"))); + REQUIRE_THAT(manifest, VectorContains(std::string("agent;;printer-connection"))); + CHECK(manifest.size() == 3); +} + +TEST_CASE_METHOD(PluginResolverFixture, + "update_plugin_manifest de-duplicates references and skips unset options", + "[Config][plugins]") { + std::unique_ptr config_ptr(DynamicPrintConfig::new_from_defaults_keys( + {"post_process_plugin", "printer_agent"})); + DynamicPrintConfig config = std::move(*config_ptr); + config.option("post_process_plugin", true)->values = {"x", "x"}; // duplicate + // printer_agent stays at its default empty value -> contributes nothing to the manifest. + + config.update_plugin_manifest(); + const std::vector manifest = config.option("plugins")->values; + + CHECK(manifest == std::vector{"x;;post-processing"}); +} diff --git a/tests/slic3rutils/test_plugin_install.cpp b/tests/slic3rutils/test_plugin_install.cpp index 5f48b07179..c671943ba4 100644 --- a/tests/slic3rutils/test_plugin_install.cpp +++ b/tests/slic3rutils/test_plugin_install.cpp @@ -146,3 +146,38 @@ TEST_CASE("install-state sidecar is the source of truth for a cloud plugin's ins read_install_state(plugin_dir, scanned); CHECK(scanned.installed_version == "1.2.0"); } + +TEST_CASE("install_plugin parses [tool.orcaslicer.plugin.settings] into descriptor.settings", "[PluginInstall]") +{ + ScopedDataDir data_dir_guard("plugin-settings"); + + // A PEP-723 header with a per-plugin settings sub-table. Values stay strings; the plugin + // parses what it needs (ctx.params). This is the source Twistify reads its knobs from. + const std::string contents = + "# /// script\n" + "# requires-python = \">=3.12\"\n" + "#\n" + "# [tool.orcaslicer.plugin]\n" + "# name = \"Settings Plugin\"\n" + "# type = \"slicing-pipeline\"\n" + "#\n" + "# [tool.orcaslicer.plugin.settings]\n" + "# twist_deg_per_mm = \"1.5\"\n" + "# taper_per_mm = \"-0.004\"\n" + "# ///\n" + "print('ok')\n"; + const fs::path py = write_py_file(data_dir_guard.dir / "src", "settings.py", contents); + + PluginLoader loader; // non-cloud + PluginDescriptor descriptor; + std::string error; + const bool installed = loader.install_plugin(py, descriptor, error); + + REQUIRE(installed); + CHECK(error.empty()); + REQUIRE(descriptor.settings.count("twist_deg_per_mm") == 1); + CHECK(descriptor.settings.at("twist_deg_per_mm") == "1.5"); + CHECK(descriptor.settings.at("taper_per_mm") == "-0.004"); + // Identity keys are NOT captured as settings (they belong to [tool.orcaslicer.plugin]). + CHECK(descriptor.settings.count("name") == 0); +} diff --git a/tests/slic3rutils/test_slicing_pipeline_bindings.cpp b/tests/slic3rutils/test_slicing_pipeline_bindings.cpp index 158413e32c..34af185e4f 100644 --- a/tests/slic3rutils/test_slicing_pipeline_bindings.cpp +++ b/tests/slic3rutils/test_slicing_pipeline_bindings.cpp @@ -11,7 +11,7 @@ TEST_CASE("SlicingPipeline capability-type string maps round-trip", "[slicing_pi } #include "python_test_support.hpp" -#include "slic3r/plugin/pluginTypes/slicingPipeline/SlicingNumpy.hpp" +#include "slic3r/plugin/PluginBindingUtils.hpp" #include "slic3r/plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.hpp" #include "libslic3r/Point.hpp" #include "libslic3r/ExPolygon.hpp" @@ -76,124 +76,45 @@ class Probe(orca.slicing.SlicingPipelineCapabilityBase): 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.) + // (Full C++ trampoline invocation with a real context is exercised elsewhere.) } -// 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]") { +TEST_CASE("orca.slicing is workflow-only: context exposes raw print/object; view classes are gone", "[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"); + py::module_ orca = py::module_::import("orca"); + py::object slicing = 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)); + // Context surface: raw graph entry points + workflow accessors. + for (const char* name : { "print", "object", "params", "config_value", "cancelled", + "orca_version", "step" }) + CHECK(py::hasattr(slicing.attr("SlicingPipelineContext"), 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")); + // The wrapper layer is gone. + for (const char* legacy : { "ExPolygonView", "SurfaceView", "LayerRegionView", + "LayerView", "PrintObjectView", "PathData", "SurfaceType" }) + CHECK_FALSE(py::hasattr(slicing, legacy)); - // 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. + // unscale() stays in orca.slicing and reads the live SCALING_FACTOR. 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()); + // A default context casts print/object to None (no dangling wrapper). + Slic3r::SlicingPipelineContext ctx; + py::object pyctx = py::cast(&ctx, py::return_value_policy::reference); + CHECK(pyctx.attr("print").is_none()); + CHECK(pyctx.attr("object").is_none()); } // --------------------------------------------------------------------------- -// Task 9: toolpaths (PathData over perimeters/fills). +// Toolpath helpers for the raw-graph tests. // // 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` +// pointers — the extrusion accessors only read the public `perimeters`/`fills` // collections, never the layer/region back-pointers. // --------------------------------------------------------------------------- namespace { @@ -223,221 +144,314 @@ static void build_nested_perimeters(TestLayerRegion& region) { } } // 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]") { +// --------------------------------------------------------------------------- +// Raw Print-graph data model (orca.host) — replaces the *View wrapper API. +// LIFETIME: raw bindings follow C++ semantics — references into the slicing +// graph are valid during execute(ctx) and invalidated by container-replacing +// mutators, exactly like std::vector iterators. +// --------------------------------------------------------------------------- +TEST_CASE("orca.host leaf geometry: Surface/ExPolygon/Polygon raw bindings", "[slicing_pipeline]") { + using Catch::Matchers::WithinRel; + using Catch::Matchers::WithinAbs; + ensure_python_initialized(); + import_orca_module(); + py::gil_scoped_acquire gil; + py::object host = py::module_::import("orca").attr("host"); + + for (const char* name : { "SurfaceType", "Polygon", "ExPolygon", "Surface", "SurfaceCollection" }) + CHECK(py::hasattr(host, name)); + + // SurfaceType enum values round-trip to the C++ enumerators (moved from orca.slicing). + py::object ST = host.attr("SurfaceType"); + CHECK(ST.attr("stTop").cast() == Slic3r::stTop); + CHECK(ST.attr("stInternalSolid").cast() == Slic3r::stInternalSolid); + CHECK(ST.attr("stPerimeter").cast() == Slic3r::stPerimeter); + + // Raw Surface: scalar reads + WRITABLE surface_type (replaces SurfaceView.set_type). + Slic3r::Surface surf(Slic3r::stInternalSolid); + surf.thickness = 0.4; + surf.bridge_angle = -1.0; + surf.extra_perimeters = 2; + py::object sv = py::cast(&surf, py::return_value_policy::reference); + 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); + sv.attr("surface_type") = host.attr("SurfaceType").attr("stTop"); + CHECK(surf.surface_type == Slic3r::stTop); // C++ side reflects the assignment + + // ExPolygon navigation without numpy: contour is a Polygon, holes an empty list. + py::object exv = sv.attr("expolygon"); + CHECK(py::hasattr(exv, "contour")); + CHECK(exv.attr("holes").cast().size() == 0); + CHECK(exv.attr("contour").attr("size")().cast() == 0); +} + +TEST_CASE("orca.host Polygon.points() is a read-only int64 (N,2) view in scaled coords", "[slicing_pipeline]") { + ensure_python_initialized(); + import_orca_module(); + py::gil_scoped_acquire gil; + + 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::object view = py::cast(&ex, py::return_value_policy::reference); + py::array c = view.attr("contour").attr("points")().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); + + py::list holes = view.attr("holes").cast(); + REQUIRE(holes.size() == 1); + py::array h0 = holes[0].attr("points")().cast(); + CHECK(h0.shape(0) == 3); + CHECK_FALSE(h0.writeable()); +} + +namespace { +// Nested collection: outer -> inner -> [ ExtrusionLoop(pathA), ExtrusionPath(pathB) ]. +// Exercises polymorphic downcast of .entities and loop decomposition in flatten_paths(). +static Slic3r::ExtrusionEntityCollection build_nested_collection() { + 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)); + inner.append(pathB); + ExtrusionEntityCollection outer; + outer.append(inner); + return outer; +} +} // namespace + +TEST_CASE("orca.host extrusion tree: polymorphic entities + flatten_paths", "[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"); + py::object host = py::module_::import("orca").attr("host"); + for (const char* name : { "ExtrusionEntity", "ExtrusionPath", "ExtrusionLoop", + "ExtrusionMultiPath", "ExtrusionEntityCollection", "PrintRegion" }) + CHECK(py::hasattr(host, name)); - CHECK(py::hasattr(slicing, "PathData")); - CHECK(py::hasattr(slicing.attr("LayerRegionView"), "perimeters")); - CHECK(py::hasattr(slicing.attr("LayerRegionView"), "fills")); + Slic3r::ExtrusionEntityCollection outer = build_nested_collection(); + py::object coll = py::cast(&outer, py::return_value_policy::reference); - 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 }); + // .entities downcasts: the single child is a collection; ITS children are a loop + a path. + py::list kids = coll.attr("entities").cast(); + REQUIRE(kids.size() == 1); + py::list inner_kids = kids[0].attr("entities").cast(); + REQUIRE(inner_kids.size() == 2); + CHECK(py::hasattr(inner_kids[0], "paths")); // ExtrusionLoop binding + CHECK(py::hasattr(inner_kids[1], "width")); // ExtrusionPath binding - 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); + // flatten_paths: loop decomposed, scalars readable. + py::list ps = coll.attr("flatten_paths")().cast(); + REQUIRE(ps.size() == 2); + CHECK(ps[0].attr("role").cast() == "Outer wall"); + CHECK_THAT(ps[0].attr("width").cast(), WithinRel(0.45, 1e-6)); + CHECK_THAT(ps[0].attr("mm3_per_mm").cast(), WithinRel(0.05, 1e-9)); + CHECK(ps[1].attr("role").cast() == "Sparse infill"); } -// 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]") { +TEST_CASE("orca.host ExtrusionPath.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"); - } + 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(); + Slic3r::ExtrusionEntityCollection outer = build_nested_collection(); + py::object coll = py::cast(&outer, py::return_value_policy::reference); + py::list ps = coll.attr("flatten_paths")().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(); + py::array pts = ps[1].attr("points")().cast(); // pathB: (1,1,0),(2,1,0),(2,2,0) CHECK(pts.dtype().kind() == 'i'); - CHECK(pts.itemsize() == 8); // int64 + CHECK(pts.itemsize() == 8); 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); + CHECK(r(0, 0) == 1); 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. +// Raw Print-graph spine (orca.host): LayerRegion / Layer / PrintObject / Print, +// read side. LayerRegion/Layer ctors are protected (friend class PrintObject), +// so the tests use tiny derived structs -- the pattern TestLayerRegion above +// already establishes; TestLayer is its Layer counterpart. // --------------------------------------------------------------------------- -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"); +namespace { +struct TestLayer : Slic3r::Layer { + // id=0, no owning PrintObject, height/print_z/slice_z suitable for assertions. + TestLayer() : Slic3r::Layer(0, nullptr, 0.2, 0.45, 0.35) {} +}; +} // namespace - // 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]") { +TEST_CASE("orca.host graph classes: LayerRegion/Layer raw traversal; Print/PrintObject registered", "[slicing_pipeline]") { using Catch::Matchers::WithinRel; ensure_python_initialized(); import_orca_module(); py::gil_scoped_acquire gil; + py::object host = py::module_::import("orca").attr("host"); - // 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). + for (const char* name : { "LayerRegion", "Layer", "PrintObject", "Print" }) + CHECK(py::hasattr(host, name)); + // Members needing a live Print are verified by registration only (slic3rutils + // cannot build a Print; the fff_print C++ suite covers live-graph behavior). + for (const char* name : { "layers", "support_layers", "model_object", "id", + "bounding_box", "trafo", "config_value", "config_keys" }) + CHECK(py::hasattr(host.attr("PrintObject"), name)); + for (const char* name : { "objects", "model", "config_value", "config_keys", "canceled" }) + CHECK(py::hasattr(host.attr("Print"), name)); + + // Raw LayerRegion traversal over a hand-built region. + TestLayerRegion region; + region.slices.surfaces.emplace_back(Slic3r::Surface(Slic3r::stInternal)); + build_nested_perimeters(region); // helper defined earlier in this file + py::object lr = py::cast(static_cast(®ion), + py::return_value_policy::reference); + CHECK(lr.attr("slices").attr("size")().cast() == 1); + CHECK(lr.attr("slices").attr("surfaces").cast().size() == 1); + CHECK(lr.attr("perimeters").attr("flatten_paths")().cast().size() == 2); + CHECK(lr.attr("fills").attr("size")().cast() == 0); + CHECK(lr.attr("layer")().is_none()); // hand-built region has no owning layer + + // Raw Layer scalars + empty traversals on a hand-built layer. + TestLayer layer; + py::object ly = py::cast(static_cast(&layer), + py::return_value_policy::reference); + CHECK_THAT(ly.attr("print_z").cast(), WithinRel(0.45, 1e-9)); + CHECK_THAT(ly.attr("slice_z").cast(), WithinRel(0.35, 1e-9)); + CHECK_THAT(ly.attr("height").cast(), WithinRel(0.2, 1e-9)); + CHECK(ly.attr("regions")().cast().size() == 0); + CHECK(ly.attr("lslices")().cast().size() == 0); + CHECK(ly.attr("upper_layer").is_none()); + CHECK(ly.attr("lower_layer").is_none()); +} + +TEST_CASE("orca.host mutators: registration, ValueError on garbage, empty-clears", "[slicing_pipeline]") { + ensure_python_initialized(); + import_orca_module(); + py::gil_scoped_acquire gil; + py::object host = py::module_::import("orca").attr("host"); + CHECK(py::hasattr(host.attr("LayerRegion"), "set_slices")); + CHECK(py::hasattr(host.attr("LayerRegion"), "set_fill_surfaces")); + CHECK(py::hasattr(host.attr("Layer"), "set_lslices")); + + TestLayerRegion region; + region.slices.surfaces.emplace_back(Slic3r::Surface(Slic3r::stInternal)); + py::object lr = py::cast(static_cast(®ion), + py::return_value_policy::reference); + + 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(lr.attr("set_slices"), py::int_(42))); // not a sequence + CHECK(raises_value_error(lr.attr("set_slices"), py::str("nope"))); // string rejected + CHECK(region.slices.surfaces.size() == 1); // failures mutate nothing + // G7: an empty list is legal and clears the region (refresh_lslices defaults True; + // the null owning-layer on this hand-built region exercises the null guard). + lr.attr("set_slices")(py::list()); + CHECK(region.slices.surfaces.empty()); +} + +TEST_CASE("orca.host set_slices/set_lslices: ndarray input mutates geometry (read back both ways)", "[slicing_pipeline]") { + using Catch::Matchers::WithinRel; + ensure_python_initialized(); + import_orca_module(); + py::gil_scoped_acquire gil; 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"); - } + 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::object host = py::module_::import("orca").attr("host"); 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); }; + + // set_slices: CW input normalized CCW; surface_type carried forward; readable back raw. + TestLayerRegion region; + region.slices.surfaces.emplace_back(Slic3r::Surface(Slic3r::stInternalSolid)); + py::object lr = py::cast(static_cast(®ion), + py::return_value_policy::reference); 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. + lr.attr("set_slices")(polys); 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(); + CHECK(out.surface_type == Slic3r::stInternalSolid); + CHECK(out.expolygon.contour.is_counter_clockwise()); + CHECK_THAT(out.expolygon.area(), WithinRel((double) s * (double) s, 1e-9)); + py::list sl = lr.attr("slices").attr("surfaces").cast(); REQUIRE(sl.size() == 1); - py::array c = sl[0].attr("expolygon").attr("contour")().cast(); + py::array c = sl[0].attr("expolygon").attr("contour").attr("points")().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); + // G9: per-entry SurfaceType override via [contour, holes, SurfaceType] triple. + py::list entry; + entry.append(make_arr({ {0,0}, {s,0}, {s,s}, {0,s} })); + entry.append(py::list()); + entry.append(host.attr("SurfaceType").attr("stTop")); + py::list polys2; polys2.append(entry); + lr.attr("set_slices")(polys2, py::bool_(false)); // refresh_lslices=False path + REQUIRE(region.slices.surfaces.size() == 1); + CHECK(region.slices.surfaces.front().surface_type == Slic3r::stTop); - 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) + // Negative: a valid contour paired with a non-list holes slot must raise ValueError. + // (Regression guard for a malformed holes slot; the retired view-layer suite covered + // this, and the raw layer needs a numpy-built valid contour to exercise the same path.) + { + py::list bad_entry; + bad_entry.append(make_arr({ {0,0}, {s,0}, {s,s}, {0,s} })); // valid contour + bad_entry.append(py::int_(42)); // holes slot is not a list + py::list bad_polys; bad_polys.append(bad_entry); + bool raised = false; + try { lr.attr("set_slices")(bad_polys); } + catch (py::error_already_set& e) { raised = e.matches(PyExc_ValueError); } + CHECK(raised); + } - // 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); + // Layer.set_lslices round-trip on a hand-built layer (empty regions -> null-safe). + TestLayer layer; + py::object ly = py::cast(static_cast(&layer), + py::return_value_policy::reference); + py::list islands; + islands.append(make_arr({ {0,0}, {s,0}, {s,s}, {0,s} })); + ly.attr("set_lslices")(islands); + REQUIRE(layer.lslices.size() == 1); + CHECK(layer.lslices.front().contour.is_counter_clockwise()); + REQUIRE(layer.lslices_bboxes.size() == 1); // bbox cache refreshed + CHECK(ly.attr("lslices")().cast().size() == 1); } From 11dd078f649725b88e07e161b3b55261a43be02b Mon Sep 17 00:00:00 2001 From: SoftFever Date: Wed, 8 Jul 2026 15:04:40 +0800 Subject: [PATCH 03/10] feat(plugin)!: faithful mutable geometry bindings; edit slices via the class API Replaces the plugin-only set_slices/set_fill_surfaces/set_lslices mutators with a faithful, mutable binding of the core geometry types, so a plugin edits the slicing graph through the same object model the C++ code uses. - Point, Polygon, ExPolygon, Surface and SurfaceCollection gain constructors, writable accessors (contour/holes, set/append/clear, filter_by_type), transforms (rotate/scale/translate), boolean ops and offset. Polygon exposes a zero-copy writable numpy view via a make_writable_rows helper. - LayerRegion.slices/fill_surfaces stay read-only refs but are now live, in-place-editable SurfaceCollections; Layer.make_slices() re-derives the islands and refreshes lslice bounding boxes. - Rewrites the Inset and Twistify samples on the new API (in-place ExPolygon transforms, ExPolygon.offset, SurfaceCollection.set), dropping their numpy dependency; each touched layer calls make_slices() so downstream steps see the edited footprint. Adds tests covering in-place edits through a live collection. BREAKING CHANGE: set_slices/set_fill_surfaces/set_lslices and the internal parse_expolygon(_list)/surfaces_from_py helpers are removed. Plugins mutate through the class API (SurfaceCollection.set/append/clear, Polygon.set_points/append, ExPolygon.set_holes) instead. --- sandboxes/orca_inset_plugin_any.py | 105 ++--- sandboxes/orca_twistify_plugin_example_any.py | 179 +++----- src/slic3r/plugin/PluginBindingUtils.hpp | 17 + src/slic3r/plugin/PluginHostSlicing.cpp | 396 +++++++++--------- .../SlicingPipelinePluginCapability.cpp | 4 +- .../fff_print/test_slicing_pipeline_hook.cpp | 10 +- .../test_slicing_pipeline_bindings.cpp | 331 +++++++++++---- 7 files changed, 558 insertions(+), 484 deletions(-) diff --git a/sandboxes/orca_inset_plugin_any.py b/sandboxes/orca_inset_plugin_any.py index 42bebec684..784e5d86f7 100644 --- a/sandboxes/orca_inset_plugin_any.py +++ b/sandboxes/orca_inset_plugin_any.py @@ -1,81 +1,35 @@ # /// 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 = "0.01" +# version = "0.02" # 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 -LayerRegion.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. +sliced surface by INSET_MM using a real polygon offset (ExPolygon.offset) and +writes the result back with SurfaceCollection.set(). After the per-region edits, +layer.make_slices() re-derives the layer's merged islands (lslices) so +overhang/bridge detection, skirt/brim and support stay coherent with the inset +geometry. At Step.Slice the split slice loop runs make_perimeters() right after +the hook, so the change cascades into perimeters, infill and the final G-code +-- the toolpath preview 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) 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. +Unlike the old axis-aligned demo, ExPolygon.offset() is a correct inward offset +for any contour (it is Clipper under the hood), and it naturally handles holes. +A surface may split into several islands or vanish when shrunk; both are handled. -numpy is declared as a dependency: the geometry accessors hand back zero-copy -int64 ndarrays, and set_slices() requires genuine ndarrays back (not plain lists), -so building the modified contour needs numpy. +No numpy required: the whole edit is expressed with the host geometry classes. """ -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" @@ -84,32 +38,41 @@ class InsetEverySlice(orca.slicing.SlicingPipelineCapabilityBase): 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. + # Millimeters -> scaled integer units via the *live* scale (never hardcode 1e6). inset_scaled = int(round(INSET_MM / orca.slicing.unscale(1))) regions_touched = 0 for layer in ctx.object.layers(): if ctx.cancelled(): break + layer_touched = False for region in layer.regions(): surfaces = region.slices.surfaces if not surfaces: - continue # an empty region has nothing to inset + continue - new_surfaces = [] + # Group the inward-offset geometry by surface type so each type is + # preserved when written back (set() tags all its expolygons one type). + by_type = {} for surface in surfaces: - expoly = surface.expolygon - contour = expoly.contour.points() - 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, [h.points() for h in expoly.holes]]) + shrunk = surface.expolygon.offset(-inset_scaled) # [ExPolygon], may be empty + if shrunk: + by_type.setdefault(surface.surface_type, []).extend(shrunk) - region.set_slices(new_surfaces) + if not by_type: + continue # every surface collapsed: leave the region untouched this demo + + # Rebuild the collection type-by-type: first set(), then append() the rest. + items = list(by_type.items()) + first_type, first_expolys = items[0] + region.slices.set(first_expolys, first_type) + for st, expolys in items[1:]: + region.slices.append(expolys, st) regions_touched += 1 + layer_touched = True + if layer_touched: + # Re-derive the merged islands from the inset region slices. + layer.make_slices() return orca.ExecutionResult.success(f"inset applied to {regions_touched} region(s)") diff --git a/sandboxes/orca_twistify_plugin_example_any.py b/sandboxes/orca_twistify_plugin_example_any.py index e58666db6f..6dadf6febe 100644 --- a/sandboxes/orca_twistify_plugin_example_any.py +++ b/sandboxes/orca_twistify_plugin_example_any.py @@ -1,12 +1,11 @@ # /// script # requires-python = ">=3.12" -# dependencies = ["numpy"] # # [tool.orcaslicer.plugin] # name = "Twistify" # description = "Twists, tapers, and wobbles every layer's slice polygons as a function of Z (demo)." # author = "OrcaSlicer" -# version = "0.01" +# version = "0.02" # type = "slicing-pipeline" # # [tool.orcaslicer.plugin.settings] @@ -18,68 +17,40 @@ # /// """Twistify -- twist/taper/wobble any model at slice time. -At Step.Slice (the one fully-supported mutation seam -- see -docs/plugins/slicing_pipeline_plugin.md), every layer's sliced surfaces are -rotated, uniformly scaled, and optionally swayed about the object's center as a -function of Z, then written back with LayerRegion.set_slices(). The -dedicated slice loop runs make_perimeters() right after this hook, so the -transform cascades into perimeters, infill, and the final G-code -- the toolpath -preview visibly corkscrews, and unlike G-code post-processing hacks the printed -part keeps correct multi-wall perimeters, infill, and flow. +At Step.Slice, every layer's sliced surfaces are transformed by a similarity +about the object's bounding-box center as a function of Z -- edited IN PLACE +through the host geometry classes (ExPolygon.rotate/scale/translate). Each +surface is rotated about the center, then (if tapering) translated to the +origin, uniformly scaled, and translated back, so the taper stays centered on +the object instead of drifting toward the coordinate origin. An optional X +wobble is applied last. After the per-region edits, layer.make_slices() +re-derives the layer's merged islands so overhang/bridge/skirt/support stay +coherent. The split slice loop runs make_perimeters() right after the hook, so +the transform cascades into perimeters, infill, and the final G-code -- the +preview corkscrews and the print keeps correct walls/infill/flow. -Parameters come from ctx.params -- the [tool.orcaslicer.plugin.settings] table in -the PEP-723 header above. Edit them there (and re-slice) to change the effect; no -code edit or plugin reload is needed. Recipes: twisted vase -(twist 1.0), tapered spire (twist 0.3, taper -0.006), wobbling tower -(twist 0, wobble_ampl 0.8). - -The transform uses three of the gap-closing APIs so the plugin stays small and -correct: - * ctx.object.bounding_box() gives the twist axis (each object twists about its - own center) -- no footprint reconstruction. - * set_slices(refresh_lslices=True) re-derives the layer's merged islands, so - overhang/bridge/skirt/support stay coherent -- no manual set_lslices(). - * a per-entry SurfaceType (third set_slices element) preserves each surface's - type -- no replace-then-reassign-surface_type two-step. -Because the Slice hook re-snapshots raw_slices afterward, the twist also survives -a later perimeter-only re-slice (e.g. changing wall_loops) instead of reverting. - -numpy is REQUIRED at slice time (declared above): the host's geometry accessors -return numpy arrays. The pure-Python fallback in _transform_ring exists only so this -module still imports on numpy-less interpreters (the unit-test harness); it is -unreachable in production. Outputs are built by .copy()-ing the host's zero-copy -read arrays (dtype/shape inherited -- int64 on every platform, immune to Windows' -numpy int32 default), never constructed from scratch. - -Physical-print caveats: keep the twist modest (horizontal shift per layer at the -part's outer radius should stay under ~1.4x layer height) or the real print grows -unsupported overhangs -- the preview looks great regardless. The first object -layer is untouched (z_rel = 0), so bed adhesion is unaffected. Twists EVERY -object on the plate (each about its own center). +Because we edit geometry in place, surface types are preserved automatically +(no per-surface type carry needed), and no numpy is required -- +rotate/scale/translate are host methods. Parameters come from ctx.params (the +settings table above). The first object layer is untouched (z_rel = 0), so bed +adhesion is unaffected. """ import math import orca -try: # required in production; guard keeps module importable in the test harness - import numpy as _np -except ImportError: - _np = None - -# Fallback defaults, overridden per-slice by ctx.params (the settings table in the header). _DEFAULTS = { - "twist_deg_per_mm": 1.0, # signed twist rate; 1 deg/mm corkscrews a 100mm cube by 100 deg - "taper_per_mm": 0.0, # relative XY scale change per mm of Z (-0.004 = shrink 0.4%/mm) - "wobble_ampl_mm": 0.0, # X sway amplitude in mm (0 disables) - "wobble_period_mm": 20.0, # full sway period in mm of Z - "min_scale": 0.05, # taper clamp: polygons shrink but can never collapse to a point + "twist_deg_per_mm": 1.0, + "taper_per_mm": 0.0, + "wobble_ampl_mm": 0.0, + "wobble_period_mm": 20.0, + "min_scale": 0.05, } def _params(ctx): - """Resolve parameters from ctx.params (string values), falling back to _DEFAULTS.""" try: - src = dict(ctx.params) # ctx.params is a read-only dict of str -> str + src = dict(ctx.params) except (AttributeError, TypeError): src = {} out = {} @@ -96,58 +67,13 @@ def _is_identity(p): def _layer_params(z_rel, mm_to_scaled, p): - """(cos, sin, scale, x_offset_scaled) for one layer. Exact identity at z_rel == 0.""" + """(angle_rad, scale, x_offset_scaled) for one layer. Exact identity at z_rel == 0.""" theta = math.radians(p["twist_deg_per_mm"] * z_rel) s = max(p["min_scale"], 1.0 + p["taper_per_mm"] * z_rel) ox = 0.0 if p["wobble_ampl_mm"] != 0.0 and p["wobble_period_mm"] > 0.0: ox = p["wobble_ampl_mm"] * math.sin(2.0 * math.pi * z_rel / p["wobble_period_mm"]) * mm_to_scaled - return math.cos(theta), math.sin(theta), s, ox - - -def _transform_ring(ring, cos_t, sin_t, s, cx, cy, ox): - """Similarity-transform one int64 (N,2) ring about (cx, cy), then shift X by ox. - - Returns a NEW writable int64 (N,2) ndarray with the same point count, or None - if the ring is degenerate (< 3 points; the host's parse_polygon would reject it). - Rotation + uniform positive scale preserves orientation and hole containment and - cannot self-intersect; the host re-normalizes winding on write-back anyway. - """ - n = ring.shape[0] - if n < 3: - return None - if _np is not None: # production path (numpy is a declared dependency) - pts = ring.astype(_np.float64) - dx = pts[:, 0] - cx - dy = pts[:, 1] - cy - out = _np.empty_like(ring) # inherits int64 -- immune to Windows' int32 default - out[:, 0] = _np.rint((dx * cos_t - dy * sin_t) * s + cx + ox) - out[:, 1] = _np.rint((dx * sin_t + dy * cos_t) * s + cy) - return out - out = ring.copy() # defensive fallback; unreachable when the host supplied `ring` - for i in range(n): - dx = float(ring[i, 0]) - cx - dy = float(ring[i, 1]) - cy - out[i, 0] = int(round((dx * cos_t - dy * sin_t) * s + cx + ox)) - out[i, 1] = int(round((dx * sin_t + dy * cos_t) * s + cy)) - return out - - -def _transform_expoly(expoly, cos_t, sin_t, s, cx, cy, ox): - """ExPolygon -> [contour, [holes...]] entry for set_slices. - - Returns None if the outer contour is degenerate; degenerate holes are dropped - (a <3-point ring is meaningless and would make the host raise ValueError). - """ - contour = _transform_ring(expoly.contour.points(), cos_t, sin_t, s, cx, cy, ox) - if contour is None: - return None - holes = [] - for hole in expoly.holes: - th = _transform_ring(hole.points(), cos_t, sin_t, s, cx, cy, ox) - if th is not None: - holes.append(th) - return [contour, holes] + return theta, s, ox class Twistify(orca.slicing.SlicingPipelineCapabilityBase): @@ -155,27 +81,27 @@ class Twistify(orca.slicing.SlicingPipelineCapabilityBase): return "Twistify" def execute(self, ctx): - # Standard guard: Step.Slice is per-object and the only fully-wired mutation seam. if ctx.step != orca.slicing.Step.Slice or ctx.object is None: return orca.ExecutionResult.success() p = _params(ctx) - # Exact no-op parameters -> leave the pipeline byte-identical by construction. if _is_identity(p): return orca.ExecutionResult.success("Twistify: identity parameters, nothing to do") - # Millimeters -> scaled units via the LIVE scale (never hardcode 1e6/1e-6). mm_to_scaled = 1.0 / orca.slicing.unscale(1) layers = ctx.object.layers() if not layers: return orca.ExecutionResult.success("Twistify: object has no layers") - # Twist axis = the object's bounding-box center (scaled coords, same frame as the - # slice polygons), so each object on the plate twists about its own center. + # Twist/taper axis = the object's bounding-box center (scaled coords, same frame + # as the slice polygons), so each object on the plate transforms about its own + # center. Keep the float center for translate-to-origin/back around scale(), and + # a rounded-to-Point center for rotate() (which takes an integer Point). min_x, min_y, max_x, max_y = ctx.object.bounding_box() cx = (min_x + max_x) / 2.0 cy = (min_y + max_y) / 2.0 + center = orca.host.Point(int(round(cx)), int(round(cy))) z0 = float(layers[0].print_z) # z_rel = 0 on the first layer -> footprint untouched layers_touched = 0 @@ -183,32 +109,29 @@ class Twistify(orca.slicing.SlicingPipelineCapabilityBase): if ctx.cancelled(): break z_rel = float(layer.print_z) - z0 - cos_t, sin_t, s, ox = _layer_params(z_rel, mm_to_scaled, p) - if cos_t == 1.0 and sin_t == 0.0 and s == 1.0 and ox == 0.0: - continue # exact identity (always the first layer): skip set_slices entirely + theta, s, ox = _layer_params(z_rel, mm_to_scaled, p) + if theta == 0.0 and s == 1.0 and ox == 0.0: + continue # exact identity (always the first layer) + edited = False for region in layer.regions(): - surfaces = region.slices.surfaces - if not surfaces: - continue # set_slices() rejects nothing now, but an empty region has nothing to do - new_surfaces = [] - for surface in surfaces: - entry = _transform_expoly(surface.expolygon, cos_t, sin_t, s, cx, cy, ox) - if entry is None: - continue # degenerate outer contour: drop this surface - # Carry this surface's type as the third entry element so it is preserved - # per surface. The plain enum value is read out BEFORE set_slices, since the - # Surface reference dangles once the collection is replaced. - entry.append(surface.surface_type) - new_surfaces.append(entry) - if not new_surfaces: - continue # every surface degenerate: leave the region untouched - # refresh_lslices=True re-derives the layer's merged islands + bbox cache from - # the twisted slices, so overhang/bridge detection and brim/skirt/support stay - # coherent -- no separate Layer.set_lslices() pass needed. - region.set_slices(new_surfaces, refresh_lslices=True) - - layers_touched += 1 + for surface in region.slices.surfaces: + ex = surface.expolygon + ex.rotate(theta, center) # rotate about the object center (in place) + if s != 1.0: + # scale() scales about the coordinate ORIGIN, so re-center the + # geometry on the origin first and translate back after, making + # this a true similarity transform about the object's center. + ex.translate(-cx, -cy) + ex.scale(s) + ex.translate(cx, cy) + if ox != 0.0: + ex.translate(ox, 0.0) # wobble in X + edited = True + if edited: + # Re-derive the merged islands from the twisted region slices. + layer.make_slices() + layers_touched += 1 name = ctx.object.model_object().name or "object" return orca.ExecutionResult.success( diff --git a/src/slic3r/plugin/PluginBindingUtils.hpp b/src/slic3r/plugin/PluginBindingUtils.hpp index dfb546d87f..3f17b5668a 100644 --- a/src/slic3r/plugin/PluginBindingUtils.hpp +++ b/src/slic3r/plugin/PluginBindingUtils.hpp @@ -53,6 +53,23 @@ pybind11::array make_readonly_rows(pybind11::handle base, const T* data, pybind1 return std::move(arr); } +// Zero-copy, WRITABLE (rows, N) numpy view over `data`, lifetime tied to `base`. +// Twin of make_readonly_rows: a base-carrying pybind array is writable by default, +// so we simply do not clear the write flag. Writing through the view mutates the +// underlying C++ buffer in place. rows == 0 / null data yields a fresh empty (0, N) +// array (writable, no base). +template +pybind11::array make_writable_rows(pybind11::handle base, T* data, pybind11::ssize_t rows) +{ + namespace py = pybind11; + if (rows == 0 || data == nullptr) + return py::array_t(std::vector{ 0, (py::ssize_t) N }); + return py::array_t( + { rows, (py::ssize_t) N }, + { (py::ssize_t)(N * sizeof(T)), (py::ssize_t) sizeof(T) }, + data, base); +} + // Serialize one config key to a Python string, or None if the key is absent. // Works on any ConfigBase (resolved DynamicPrintConfig snapshots, // PrintObjectConfig, PrintRegionConfig, preset configs). diff --git a/src/slic3r/plugin/PluginHostSlicing.cpp b/src/slic3r/plugin/PluginHostSlicing.cpp index f1a94c7658..57aa02962e 100644 --- a/src/slic3r/plugin/PluginHostSlicing.cpp +++ b/src/slic3r/plugin/PluginHostSlicing.cpp @@ -3,6 +3,7 @@ #include "libslic3r/libslic3r.h" // unscale<>, scale_ #include "libslic3r/BoundingBox.hpp" +#include "libslic3r/ClipperUtils.hpp" // offset/offset_ex/union_ex/diff_ex/intersection_ex #include "libslic3r/ExPolygon.hpp" #include "libslic3r/Surface.hpp" #include "libslic3r/SurfaceCollection.hpp" @@ -13,7 +14,6 @@ #include #include -#include #include namespace py = pybind11; @@ -51,87 +51,14 @@ static Polygon parse_polygon(py::handle h, const char* who) return poly; } -// One Python entry -> ExPolygon. Accepts a bare (N,2) ndarray (contour only), a -// [contour, [hole, ...]] sequence, or (G9) a [contour, [hole, ...], SurfaceType] triple whose -// third element overrides the surface type for set_slices/set_fill_surfaces. When `out_type` is -// null (geometry-only consumers such as set_lslices) any third element is ignored. 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, - std::optional* out_type = nullptr) +// Accept a bound orca.host.Polygon (copied) or an (N,2) int64 ndarray. Used by the ExPolygon +// binding, whose constructor/contour-setter/set_holes must accept the Polygon it itself hands +// out (e.g. `ExPolygon(some_polygon_ref)`) in addition to the ndarray-only parse_polygon() path. +static Polygon as_polygon(py::handle h, 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)); - } - } - // G9: optional third element -> per-surface SurfaceType override (None keeps the - // carried-forward type). A wrong type raises ValueError, matching the API contract. - if (out_type != nullptr && py::len(seq) >= 3) { - py::object t = seq[2]; - if (!t.is_none()) { - try { *out_type = t.cast(); } - catch (const py::cast_error&) { - throw py::value_error(std::string(who) + ": the third entry element must be an orca.host.SurfaceType"); - } - } - } - } 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 Python list of entries -> ExPolygons (each entry parsed + oriented). G7: an empty list is -// legal and means "no geometry" (clears the target collection). Per-entry types are ignored -// here (geometry-only consumers such as set_lslices). -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)); - 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 none. G9: a per-entry SurfaceType (optional third element) overrides that default. -// G7: an empty list is legal and yields an empty Surfaces (clears the collection). -static Surfaces surfaces_from_py(py::handle list_h, const SurfaceCollection& replaced, const char* who) -{ - if (!py::isinstance(list_h) || py::isinstance(list_h)) - throw py::value_error(std::string(who) + ": expected a list of polygons"); - const Surface tmpl = replaced.surfaces.empty() ? Surface(stInternal) : replaced.surfaces.front(); - Surfaces out; - for (py::handle entry : py::reinterpret_borrow(list_h)) { - std::optional type; - ExPolygon e = parse_expolygon(entry, who, &type); - Surface s(tmpl, std::move(e)); - if (type) - s.surface_type = *type; - out.emplace_back(std::move(s)); - } - return out; + if (py::isinstance(h)) + return h.cast(); + return parse_polygon(h, who); } // Flatten an extrusion graph into a list of leaf ExtrusionPath* while walking the @@ -164,6 +91,17 @@ static void collect_extrusion_paths(const ExtrusionEntity* ee, std::vector(host, "SurfaceType") @@ -197,52 +135,203 @@ void PluginHostSlicing::RegisterBindings(py::module_& host) .value("stCount", stCount) .export_values(); - py::class_>(host, "Polygon") + // Point: a constructible value type (default holder, so Python-owned instances + // are freed). Returned-by-reference from Polygon.points, it aliases the buffer; + // x()/y() are Eigen lvalues, so the properties are read/write. p+q / p-q go + // through Eigen expression templates, wrapped back into a Point. + py::class_(host, "Point") + .def(py::init([](coord_t x, coord_t y) { return Point(x, y); }), py::arg("x"), py::arg("y")) + .def_property("x", [](const Point& p) { return p.x(); }, + [](Point& p, coord_t v) { p.x() = v; }) + .def_property("y", [](const Point& p) { return p.y(); }, + [](Point& p, coord_t v) { p.y() = v; }) + .def("__add__", [](const Point& a, const Point& b) { return Point(a + b); }, py::is_operator()) + .def("__sub__", [](const Point& a, const Point& b) { return Point(a - b); }, py::is_operator()) + .def("__mul__", [](const Point& a, double s) { return Point(a.x() * s, a.y() * s); }, py::is_operator()) + .def("__repr__", [](const Point& p) { + return "orca.host.Point(" + std::to_string(p.x()) + ", " + std::to_string(p.y()) + ")"; + }); + + py::class_(host, "Polygon") + .def(py::init<>()) .def("size", [](const Polygon& p) { return p.points.size(); }) + .def("is_valid", [](const Polygon& p) { return p.is_valid(); }) .def("is_counter_clockwise", [](const Polygon& p) { return p.is_counter_clockwise(); }) - .def("points", [](py::object self) { - const Polygon& p = self.cast(); + .def("is_clockwise", [](const Polygon& p) { return p.is_clockwise(); }) + .def("make_counter_clockwise", [](Polygon& p) { return p.make_counter_clockwise(); }, + "Reorient to CCW in place. Returns True if it reversed the winding.") + .def("make_clockwise", [](Polygon& p) { return p.make_clockwise(); }) + .def("area", [](const Polygon& p) { return p.area(); }) + .def("centroid", [](const Polygon& p) { return p.centroid(); }) + .def("contains", [](const Polygon& p, const Point& pt) { return p.contains(pt); }, py::arg("point")) + .def("translate", [](Polygon& p, double x, double y) { p.translate(x, y); }, py::arg("x"), py::arg("y")) + .def("rotate", [](Polygon& p, double angle) { p.rotate(angle); }, py::arg("angle")) + .def("rotate", [](Polygon& p, double angle, const Point& c) { p.rotate(angle, c); }, + py::arg("angle"), py::arg("center")) + .def("douglas_peucker", [](Polygon& p, double tol) { p.douglas_peucker(tol); }, py::arg("tolerance")) + .def("simplify", [](const Polygon& p, double tol) { return p.simplify(tol); }, py::arg("tolerance"), + "Return simplified geometry as a list of Polygon (may split into several).") + .def("offset", [](const Polygon& p, coord_t delta) { return offset(p, (float) delta); }, py::arg("delta"), + "Clipper offset by `delta` scaled units (negative shrinks). Returns [Polygon].") + // --- Point-object idiom: references into the buffer (in-place element edit). --- + .def_property_readonly("points", [](py::object self) { + Polygon& p = self.cast(); + py::list out; + for (Point& pt : p.points) + out.append(py::cast(&pt, py::return_value_policy::reference_internal, self)); + return out; + }, "Vertices as [Point] references into this polygon. Editing a Point mutates the " + "buffer in place. Structural changes (count) go through set_points/append, which " + "invalidate previously returned Point refs and array views (C++ vector semantics).") + .def("append", [](Polygon& p, const Point& pt) { p.points.push_back(pt); }, py::arg("point"), + "Append a vertex. Structural change (count): invalidates previously returned " + "Point refs and array views into this polygon (C++ vector semantics).") + // --- numpy idiom: writable zero-copy (N,2) view (bulk affine edits). --- + .def("as_array", [](py::object self) { + Polygon& p = self.cast(); return with_numpy([&] { - return py::object(make_readonly_rows( + return py::object(make_writable_rows( self, p.points.empty() ? nullptr : p.points.front().data(), (py::ssize_t) p.points.size())); }); - }, "Vertices as a read-only int64 (N,2) numpy view in scaled coords. " - "Valid only during the execute(ctx) call. Requires numpy."); + }, "Vertices as a WRITABLE int64 (N,2) numpy view in scaled coords, aliasing the " + "buffer. Count-preserving in-place edits only; valid during execute(ctx). Requires numpy.") + .def("set_points", [](Polygon& p, py::handle src) { p = parse_polygon(src, "Polygon.set_points"); }, + py::arg("points"), + "Replace all vertices from an (N,2) int64 ndarray (scaled coords). Count-changing; " + "invalidates prior Point refs and array views. Raises ValueError on malformed input."); - py::class_>(host, "ExPolygon") - .def_property_readonly("contour", [](ExPolygon& e) -> Polygon& { return e.contour; }, + // ExPolygon: default holder (Python-owned instances are freed) so plugins can construct + // their own geometry, not just navigate the live slicing graph. contour/holes accessors + // still use reference_internal, so refs into a graph-owned ExPolygon stay non-owning views + // tied to that owner's lifetime, same as Polygon/Surface above. + py::class_(host, "ExPolygon") + .def(py::init([](py::handle contour, py::handle holes) { + // Accept bound Polygons or (N,2) ndarrays for both contour and each hole. + ExPolygon ex; + ex.contour = as_polygon(contour, "ExPolygon.contour"); + if (!holes.is_none()) { + if (!py::isinstance(holes) || py::isinstance(holes)) + throw py::value_error("ExPolygon: holes must be a list of Polygon or (N,2) ndarrays"); + for (py::handle h : py::reinterpret_borrow(holes)) { + Polygon hole = as_polygon(h, "ExPolygon.hole"); + hole.make_clockwise(); + ex.holes.emplace_back(std::move(hole)); + } + } + ex.contour.make_counter_clockwise(); + return ex; + }), py::arg("contour"), py::arg("holes") = py::none(), + "Construct from a Polygon/ndarray contour and optional list of hole Polygons/ndarrays. " + "Orientation is normalized (contour CCW, holes CW).") + .def_property("contour", + [](ExPolygon& e) -> Polygon& { return e.contour; }, + [](ExPolygon& e, py::handle v) { e.contour = as_polygon(v, "ExPolygon.contour"); }, py::return_value_policy::reference_internal, - "Outer contour (CCW) as a Polygon.") + "Outer contour (CCW). Read returns a live Polygon ref; assign a Polygon/ndarray to replace it.") .def_property_readonly("holes", [](py::object self) { ExPolygon& e = self.cast(); py::list out; for (Polygon& h : e.holes) out.append(py::cast(&h, py::return_value_policy::reference_internal, self)); return out; - }, "Hole contours (CW) as [Polygon]."); + }, "Hole contours (CW) as [Polygon] references (in-place editable). set_holes replaces them.") + .def("set_holes", [](ExPolygon& e, py::handle holes) { + ExPolygon tmp; + if (!py::isinstance(holes) || py::isinstance(holes)) + throw py::value_error("set_holes: expected a list of Polygon or (N,2) ndarrays"); + for (py::handle h : py::reinterpret_borrow(holes)) { + Polygon hole = as_polygon(h, "ExPolygon.set_holes"); + hole.make_clockwise(); + tmp.holes.emplace_back(std::move(hole)); + } + e.holes = std::move(tmp.holes); + }, py::arg("holes"), "Replace all holes. Invalidates prior hole refs (C++ vector semantics).") + .def("translate", [](ExPolygon& e, double x, double y) { e.translate(x, y); }, py::arg("x"), py::arg("y")) + .def("rotate", [](ExPolygon& e, double a) { e.rotate(a); }, py::arg("angle")) + .def("rotate", [](ExPolygon& e, double a, const Point& c) { e.rotate(a, c); }, + py::arg("angle"), py::arg("center")) + .def("scale", [](ExPolygon& e, double f) { e.scale(f); }, py::arg("factor")) + .def("douglas_peucker", [](ExPolygon& e, double t) { e.douglas_peucker(t); }, py::arg("tolerance")) + .def("area", [](const ExPolygon& e) { return e.area(); }) + .def("is_valid", [](const ExPolygon& e) { return e.is_valid(); }) + .def("contains", [](const ExPolygon& e, const Point& p) { return e.contains(p); }, py::arg("point")) + .def("num_contours", [](const ExPolygon& e) { return e.num_contours(); }) + .def("simplify", [](const ExPolygon& e, double t) { return e.simplify(t); }, py::arg("tolerance"), + "Return simplified geometry as [ExPolygon].") + .def("offset", [](const ExPolygon& e, coord_t delta) { return offset_ex(e, (float) delta); }, + py::arg("delta"), "Clipper offset by `delta` scaled units (negative shrinks). Returns [ExPolygon].") + .def("union_ex", [](const ExPolygon& a, const ExPolygon& b) { + return union_ex(ExPolygons{ a, b }); + }, py::arg("other"), "Union with another ExPolygon. Returns [ExPolygon].") + .def("diff_ex", [](const ExPolygon& a, const ExPolygon& b) { + return diff_ex(ExPolygons{ a }, ExPolygons{ b }); + }, py::arg("other"), "This minus `other`. Returns [ExPolygon].") + .def("intersection_ex", [](const ExPolygon& a, const ExPolygon& b) { + return intersection_ex(ExPolygons{ a }, ExPolygons{ b }); + }, py::arg("other"), "Intersection with `other`. Returns [ExPolygon]."); - py::class_>(host, "Surface") + // Surface: default holder (Python-owned instances are freed), so plugins can construct + // their own Surface(surface_type, expolygon) — not just navigate the live slicing graph. + // expolygon is a reference_internal property, same idiom as Polygon/ExPolygon above. + py::class_(host, "Surface") + .def(py::init([](SurfaceType t, const ExPolygon& e) { return Surface(t, e); }), + py::arg("surface_type"), py::arg("expolygon")) + .def(py::init([](SurfaceType t) { return Surface(t); }), py::arg("surface_type")) .def_readwrite("surface_type", &Surface::surface_type, - "This surface's SurfaceType. Writable: assigning reclassifies the " - "surface in place on the live slicing graph (geometry unchanged).") - .def_readonly("thickness", &Surface::thickness) - .def_readonly("bridge_angle", &Surface::bridge_angle) - .def_readonly("extra_perimeters", &Surface::extra_perimeters) - .def_property_readonly("expolygon", [](Surface& s) -> ExPolygon& { return s.expolygon; }, + "This surface's SurfaceType. Assigning reclassifies it in place (geometry unchanged).") + .def_readwrite("thickness", &Surface::thickness) + .def_readwrite("bridge_angle", &Surface::bridge_angle) + .def_readwrite("extra_perimeters", &Surface::extra_perimeters) + .def_property("expolygon", + [](Surface& s) -> ExPolygon& { return s.expolygon; }, + [](Surface& s, const ExPolygon& e) { s.expolygon = e; }, py::return_value_policy::reference_internal, - "This surface's geometry."); + "This surface's geometry. Read returns a live ExPolygon ref; assign to replace it.") + .def("area", [](const Surface& s) { return s.area(); }) + .def("is_top", [](const Surface& s) { return s.is_top(); }) + .def("is_bottom", [](const Surface& s) { return s.is_bottom(); }) + .def("is_bridge", [](const Surface& s) { return s.is_bridge(); }) + .def("is_internal", [](const Surface& s) { return s.is_internal(); }) + .def("is_external", [](const Surface& s) { return s.is_external(); }) + .def("is_solid", [](const Surface& s) { return s.is_solid(); }); + // SurfaceCollection: kept on py::nodelete — it is only ever a reference into the live + // slicing graph (LayerRegion::slices/fill_surfaces), never constructed by a plugin. py::class_>(host, "SurfaceCollection") .def("size", [](const SurfaceCollection& c) { return c.surfaces.size(); }) + .def("empty", [](const SurfaceCollection& c) { return c.empty(); }) + .def("clear", [](SurfaceCollection& c) { c.clear(); }) + .def("has", [](const SurfaceCollection& c, SurfaceType t) { return c.has(t); }, py::arg("surface_type")) + .def("set_type", [](SurfaceCollection& c, SurfaceType t) { c.set_type(t); }, py::arg("surface_type")) + .def("set", [](SurfaceCollection& c, const std::vector& src, SurfaceType t) { c.set(src, t); }, + py::arg("expolygons"), py::arg("surface_type"), + "Replace all surfaces from a list of ExPolygon, all tagged `surface_type`. " + "This is the faithful replacement for the retired set_slices().") + .def("set", [](SurfaceCollection& c, const std::vector& src) { c.set(src); }, + py::arg("surfaces"), "Replace all surfaces from a list of Surface (types preserved per surface).") + .def("append", [](SurfaceCollection& c, const std::vector& src, SurfaceType t) { c.append(src, t); }, + py::arg("expolygons"), py::arg("surface_type")) + .def("filter_by_type", [](py::object self, SurfaceType t) { + SurfaceCollection& c = self.cast(); + py::list out; + // SurfacesPtr (SurfaceCollection::filter_by_type's return type) is + // std::vector (see Surface.hpp); the brief's note describing it + // as std::vector does not match the header, so this iterates by const + // pointer (py::cast accepts `const itype*` directly, see cast.h cast(const itype*)). + for (const Surface* s : c.filter_by_type(t)) + out.append(py::cast(s, py::return_value_policy::reference_internal, self)); + return out; + }, py::arg("surface_type"), "Surfaces of a given type as [Surface] refs. Invalidated by " + "set()/append()/clear() on this collection (C++ vector semantics), same as .surfaces.") .def_property_readonly("surfaces", [](py::object self) { SurfaceCollection& c = self.cast(); py::list out; for (Surface& s : c.surfaces) out.append(py::cast(&s, py::return_value_policy::reference_internal, self)); return out; - }, "Surfaces as [Surface] references into the live collection. Invalidated " - "by set_slices/set_fill_surfaces on the owning region (C++ vector semantics)."); + }, "Surfaces as [Surface] references into the live collection. Invalidated by " + "set()/append()/clear() on this collection (C++ vector semantics)."); // --- Extrusion tree (read-only in v1). Registered polymorphically: when a returned // ExtrusionEntity*'s dynamic type IS one of the classes registered below, pybind @@ -324,14 +413,15 @@ void PluginHostSlicing::RegisterBindings(py::module_& host) auto layer_region = py::class_>(host, "LayerRegion"); layer_region .def_readonly("slices", &LayerRegion::slices, - "Sliced, typed surfaces (SurfaceCollection). At Step.Slice this is the " - "primary mutation target via set_slices().") + "Sliced, typed surfaces (SurfaceCollection). Edit in place, or replace with " + "slices.set(expolygons, surface_type). At Step.Slice this is the primary mutation " + "target; the split slice loop runs make_perimeters() afterward so edits cascade downstream.") .def_readonly("fill_surfaces", &LayerRegion::fill_surfaces, - "Surfaces prepared for infill (SurfaceCollection).") + "Surfaces prepared for infill (SurfaceCollection). Edit in place or via fill_surfaces.set(...).") .def_readonly("perimeters", &LayerRegion::perimeters, - "Perimeter toolpaths (ExtrusionEntityCollection).") + "Perimeter toolpaths (ExtrusionEntityCollection, read-only in v1).") .def_readonly("fills", &LayerRegion::fills, - "Infill toolpaths (ExtrusionEntityCollection).") + "Infill toolpaths (ExtrusionEntityCollection, read-only in v1).") .def("layer", [](LayerRegion& r) -> py::object { Layer* l = r.layer(); if (l == nullptr) @@ -344,58 +434,7 @@ void PluginHostSlicing::RegisterBindings(py::module_& host) .def("config_value", [](const LayerRegion& r, const std::string& key) { return config_value_or_none(r.region().config(), key); }, py::arg("key"), - "Serialized value of this region's resolved config option, or None if absent.") - // MUTATOR (G1/G3/G9). Replace this region's sliced surfaces. `polygons` is a list of - // (N,2) int64 ndarrays (scaled coords), [contour, [holes...]] pairs, or (G9) - // [contour, [holes...], SurfaceType] triples; orientation is normalized (contour CCW, - // holes CW) and surface_type is carried forward from the replaced surfaces (else - // stInternal) unless a per-entry type is given. - .def("set_slices", [](LayerRegion& region, py::object polygons, bool refresh_lslices) { - region.slices.set(surfaces_from_py(polygons, region.slices, "set_slices")); - // G1: rebuild the owning layer's merged islands (lslices) + bbox cache from the - // mutated region slices so downstream consumers (detect_surfaces_type neighbor - // diffs, overhang/bridge detection, brim/skirt/support) see coherent islands. - // Skipped when the region has no owning layer (unit-test regions). - if (refresh_lslices) { - if (Layer* layer = region.layer()) { - layer->make_slices(); - 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("polygons"), py::arg("refresh_lslices") = true, - "Replace this region's sliced surfaces from a list of (N,2) int64 ndarrays (scaled " - "coords), [contour, [holes...]] pairs, or [contour, [holes...], SurfaceType] triples " - "(orientation normalized: contour CCW / holes CW; surface_type carried forward from the " - "replaced surfaces, else stInternal, unless a per-entry SurfaceType is supplied). An " - "empty list clears this region's slices.\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" - "LSLICES (G1): refresh_lslices=True (default) re-derives the owning layer's merged " - "islands and bbox cache from the new slices so overhang/bridge/skirt/support stay " - "coherent; pass False only if you manage lslices yourself via Layer.set_lslices.\n" - "PERSISTENCE (G3): the Slice hook re-snapshots raw_slices after it returns, so the " - "mutation survives a later perimeter-only re-run (restore_untyped_slices) instead of " - "silently reverting; it still does not persist across a full re-slice unless the hook " - "re-fires (re-select the plugin, or any posSlice-invalidating change).\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. Replace this region's fill (infill-prep) surfaces; identical input format and - // validation to set_slices. - .def("set_fill_surfaces", [](LayerRegion& region, py::object polygons) { - 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 (per-entry SurfaceType supported; an empty list clears them).\n" - "MUTATION-CASCADE: at the PrepareInfill boundary (G4) make_fills runs afterward, so this " - "cascades into the generated infill. At the Infill boundary it changes the stored " - "surfaces but does NOT regenerate the already-built `fills` toolpaths (v1).\n" - "Raises ValueError on malformed input. Valid only during the execute(ctx) call."); + "Serialized value of this region's resolved config option, or None if absent."); auto layer = py::class_>(host, "Layer"); layer @@ -417,38 +456,19 @@ void PluginHostSlicing::RegisterBindings(py::module_& host) out.append(py::cast(r, py::return_value_policy::reference_internal, self)); return out; }, "Per-region data as [LayerRegion].") + .def("make_slices", [](Layer& l) { + l.make_slices(); + refresh_lslices_bboxes(l); + }, "Re-derive lslices (merged islands) from the region slices and refresh the bbox " + "cache — the C++ invariant-maintenance call after in-place slice edits.") .def("lslices", [](py::object self) { Layer& l = self.cast(); py::list out; for (ExPolygon& e : l.lslices) out.append(py::cast(&e, py::return_value_policy::reference_internal, self)); return out; - }, "Merged per-layer islands as [ExPolygon] references. Invalidated by " - "set_lslices/make_slices (C++ vector semantics).") - .def("make_slices", [](Layer& l) { - l.make_slices(); - l.lslices_bboxes.clear(); - l.lslices_bboxes.reserve(l.lslices.size()); - for (const ExPolygon& island : l.lslices) - l.lslices_bboxes.emplace_back(get_extents(island)); - }, "Re-derive lslices (merged islands) from the region slices and refresh the " - "bbox cache — the C++ invariant-maintenance call after in-place geometry edits. " - "set_slices(refresh_lslices=True) runs this for you.") - // MUTATOR. Replace this layer's merged islands (lslices) and refresh the cache-invariant - // `lslices_bboxes` (one BoundingBox per island via get_extents). Same input format and - // validation as LayerRegion.set_slices. - .def("set_lslices", [](Layer& l, py::object islands) { - l.lslices = parse_expolygon_list(islands, "set_lslices"); - l.lslices_bboxes.clear(); - l.lslices_bboxes.reserve(l.lslices.size()); - for (const ExPolygon& island : l.lslices) - l.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 LayerRegion.set_slices. Raises ValueError on malformed " - "input. Valid only during the execute(ctx) call."); + }, "Merged per-layer islands as [ExPolygon] refs (in-place editable). Derived from the " + "region slices; call make_slices() to re-derive after edits. Invalidated by make_slices()."); py::class_>(host, "PrintObject") .def("id", [](const PrintObject& o) { return o.id().id; }, diff --git a/src/slic3r/plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.cpp b/src/slic3r/plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.cpp index 3ee5539af1..6cc1d1bde9 100644 --- a/src/slic3r/plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.cpp +++ b/src/slic3r/plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.cpp @@ -17,8 +17,8 @@ void SlicingPipelinePluginCapability::RegisterBindings(py::module_& module, py:: .value("Slice", SlicingPipelineStep::Slice) .value("Perimeters", SlicingPipelineStep::Perimeters) .value("EstimateCurledExtrusions", SlicingPipelineStep::EstimateCurledExtrusions) - .value("PrepareInfill", SlicingPipelineStep::PrepareInfill) // after prepare_infill, before make_fills: set_fill_surfaces here CASCADES - .value("Infill", SlicingPipelineStep::Infill) // after make_fills: set_fill_surfaces here does NOT regenerate fills (v1) + .value("PrepareInfill", SlicingPipelineStep::PrepareInfill) // after prepare_infill, before make_fills: editing fill_surfaces here CASCADES + .value("Infill", SlicingPipelineStep::Infill) // after make_fills: editing fill_surfaces here does NOT regenerate fills (v1) .value("Ironing", SlicingPipelineStep::Ironing) .value("Contouring", SlicingPipelineStep::Contouring) .value("SupportMaterial", SlicingPipelineStep::SupportMaterial) diff --git a/tests/fff_print/test_slicing_pipeline_hook.cpp b/tests/fff_print/test_slicing_pipeline_hook.cpp index 28869b0c36..fcb1802b8c 100644 --- a/tests/fff_print/test_slicing_pipeline_hook.cpp +++ b/tests/fff_print/test_slicing_pipeline_hook.cpp @@ -221,7 +221,7 @@ TEST_CASE("Changing slicing_pipeline_plugin invalidates posSlice", "[slicing_pip // §3.6 (Twistify design): Twistify's effect is a similarity transform (rotate + uniform // scale) applied to slices at Step.Slice. This C++ analogue rotates every region's slices a // fixed 45 deg about the object's base-footprint center -- the same seam and cascade that -// Twistify.py drives through the pybind set_slices binding. Two end-to-end invariants after +// Twistify.py drives through the slices.set() + Layer::make_slices() path. Two end-to-end invariants after // process() confirm the approach: // (1) a pure rotation is a similarity with scale 1, so total fill area is preserved, and // (2) the mutation genuinely cascaded into make_perimeters' fill_surfaces -- a 20mm square @@ -299,7 +299,7 @@ TEST_CASE("Rotating slices at the Slice boundary cascades (area preserved, bbox } // §3.6 (Twistify design): Twistify skips exact-identity layers entirely, but every transformed -// layer invokes the set_slices write-back + make_perimeters re-run. This proves that write path +// layer invokes the slices.set() write-back + make_perimeters re-run. This proves that write path // is lossless for already-normalized (CCW contour / CW hole) input -- an active hook that // re-sets every region's slices to their CURRENT geometry (the identity similarity transform) // produces output byte-identical to an active hook that mutates nothing. Both runs are active @@ -425,8 +425,8 @@ TEST_CASE("fill_surfaces mutation cascades at PrepareInfill but not at Infill", } // lslices (the layer's merged islands) are built once in slice() and never rebuilt by -// make_perimeters, so mutating region slices leaves them stale. set_slices(refresh_lslices=True) -// re-derives them via Layer::make_slices(); this C++ analogue proves the mechanism -- without the +// make_perimeters, so mutating region slices leaves them stale. The slices.set() + Layer::make_slices() +// path re-derives them; this C++ analogue proves the mechanism -- without the // refresh the islands keep the original 20mm footprint, with it they track the 18mm inset. TEST_CASE("refreshing lslices after a slice mutation makes islands track the geometry", "[slicing_pipeline]") { auto lslices_width = [](bool refresh) { @@ -445,7 +445,7 @@ TEST_CASE("refreshing lslices after a slice mutation makes islands track the geo } r->slices.set(std::move(in)); } - if (refresh) // the load-bearing half of set_slices(refresh_lslices=True) + if (refresh) // the load-bearing half of the slices.set() + Layer::make_slices() path l->make_slices(); } }); diff --git a/tests/slic3rutils/test_slicing_pipeline_bindings.cpp b/tests/slic3rutils/test_slicing_pipeline_bindings.cpp index 34af185e4f..4a6d36ff01 100644 --- a/tests/slic3rutils/test_slicing_pipeline_bindings.cpp +++ b/tests/slic3rutils/test_slicing_pipeline_bindings.cpp @@ -56,6 +56,23 @@ TEST_CASE("make_readonly_rows builds a read-only (N,2) int64 view", "[slicing_pi CHECK(r(0,0) == 10); CHECK(r(1,1) == 40); } +TEST_CASE("make_writable_rows builds a writable (N,2) int64 view that aliases the buffer", "[slicing_pipeline]") { + ensure_python_initialized(); + py::gil_scoped_acquire gil; + 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_writable_rows(keepalive, pts.front().data(), (py::ssize_t)pts.size()); + CHECK(a.writeable()); + // Writing through the view mutates the C++ buffer (zero-copy alias). + a.attr("__setitem__")(py::make_tuple(0, 0), py::int_(99)); + CHECK(pts.front().x() == 99); +} + 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) @@ -187,41 +204,138 @@ TEST_CASE("orca.host leaf geometry: Surface/ExPolygon/Polygon raw bindings", "[s CHECK(exv.attr("contour").attr("size")().cast() == 0); } -TEST_CASE("orca.host Polygon.points() is a read-only int64 (N,2) view in scaled coords", "[slicing_pipeline]") { +TEST_CASE("orca.host Surface/SurfaceCollection: construct, writable members, set()", "[slicing_pipeline]") { + using Catch::Matchers::WithinRel; ensure_python_initialized(); import_orca_module(); py::gil_scoped_acquire gil; + py::object host = py::module_::import("orca").attr("host"); + py::object ST = host.attr("SurfaceType"); + const coord_t s = (coord_t) scale_(10.0); + + // Build an ExPolygon (Point idiom) and a Surface from it. + py::object P = host.attr("Polygon")(); + P.attr("append")(host.attr("Point")(0, 0)); + P.attr("append")(host.attr("Point")(s, 0)); + P.attr("append")(host.attr("Point")(s, s)); + P.attr("append")(host.attr("Point")(0, s)); + py::object ex = host.attr("ExPolygon")(P); + py::object surf = host.attr("Surface")(ST.attr("stTop"), ex); + CHECK(surf.attr("surface_type").cast() == Slic3r::stTop); + CHECK(surf.attr("is_top")().cast()); + CHECK_THAT(surf.attr("area")().cast(), WithinRel((double) s * (double) s, 1e-9)); + surf.attr("thickness") = py::float_(0.3); + CHECK_THAT(surf.attr("thickness").cast(), WithinRel(0.3, 1e-9)); + + // SurfaceCollection.set(expolys, type) — the faithful replacement for set_slices' body. + Slic3r::SurfaceCollection coll; + py::object cv = py::cast(&coll, py::return_value_policy::reference); + py::list expolys; expolys.append(ex); + cv.attr("set")(expolys, ST.attr("stInternalSolid")); + REQUIRE(coll.surfaces.size() == 1); + CHECK(coll.surfaces.front().surface_type == Slic3r::stInternalSolid); + CHECK(cv.attr("has")(ST.attr("stInternalSolid")).cast()); + cv.attr("clear")(); + CHECK(coll.surfaces.empty()); +} + +TEST_CASE("orca.host Point: construct, read/write coords, arithmetic", "[slicing_pipeline]") { + ensure_python_initialized(); + import_orca_module(); + py::gil_scoped_acquire gil; + py::object host = py::module_::import("orca").attr("host"); + REQUIRE(py::hasattr(host, "Point")); + py::object p = host.attr("Point")(3, 4); + CHECK(p.attr("x").cast() == 3); + CHECK(p.attr("y").cast() == 4); + p.attr("x") = py::int_(7); + CHECK(p.attr("x").cast() == 7); + py::object q = host.attr("Point")(1, 2); + py::object sum = p.attr("__add__")(q); + CHECK(sum.attr("x").cast() == 8); + CHECK(sum.attr("y").cast() == 6); + + // __mul__ must scale as a double, not truncate to int64 before multiplying. + py::object h = host.attr("Point")(10, 20).attr("__mul__")(py::float_(0.5)); + CHECK(h.attr("x").cast() == 5); + CHECK(h.attr("y").cast() == 10); +} + +TEST_CASE("orca.host Polygon: writable as_array aliases buffer; Point refs; set_points; offset", "[slicing_pipeline]") { + using Catch::Matchers::WithinRel; + ensure_python_initialized(); + import_orca_module(); + py::gil_scoped_acquire gil; + py::object host = py::module_::import("orca").attr("host"); + + const coord_t s = (coord_t) scale_(10.0); + Slic3r::Polygon poly; + poly.points = { Slic3r::Point(0, 0), Slic3r::Point(s, 0), Slic3r::Point(s, s), Slic3r::Point(0, s) }; + py::object pv = py::cast(&poly, py::return_value_policy::reference); + + // Non-array surface works without numpy. + CHECK(pv.attr("size")().cast() == 4); + CHECK(pv.attr("is_counter_clockwise")().cast()); + CHECK_THAT(pv.attr("area")().cast(), WithinRel((double) s * (double) s, 1e-9)); + // Point-object idiom: editing a returned Point ref mutates the buffer in place. + py::list pts = pv.attr("points").cast(); + REQUIRE(pts.size() == 4); + pts[0].attr("x") = py::int_(5); + CHECK(poly.points[0].x() == 5); + poly.points[0].x() = 0; // restore + + // offset() returns new geometry (ClipperUtils bound as a method). + py::list shrunk = pv.attr("offset")(py::int_(-(coord_t)scale_(1.0))).cast(); + CHECK(shrunk.size() >= 1); 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"); + if (!have_numpy) SKIP("numpy unavailable: array-backed assertions skipped"); + py::module_ np = py::module_::import("numpy"); + py::array a = pv.attr("as_array")().cast(); + CHECK(a.dtype().kind() == 'i'); + CHECK(a.itemsize() == 8); + CHECK(a.shape(0) == 4); + CHECK(a.shape(1) == 2); + CHECK(a.writeable()); // writable now + a.attr("__setitem__")(py::make_tuple(0, 0), py::int_(123)); + CHECK(poly.points[0].x() == 123); // in-place bulk edit + // set_points replaces contents (count-changing). + py::object i64 = np.attr("int64"); + py::list rows; + rows.append(py::make_tuple(0, 0)); rows.append(py::make_tuple(s, 0)); rows.append(py::make_tuple(s, s)); + pv.attr("set_points")(np.attr("array")(rows, py::arg("dtype") = i64)); + CHECK(poly.points.size() == 3); +} + +TEST_CASE("orca.host ExPolygon: construct, writable contour/holes, transforms, boolean ops", "[slicing_pipeline]") { + using Catch::Matchers::WithinRel; + ensure_python_initialized(); + import_orca_module(); + py::gil_scoped_acquire gil; + py::object host = py::module_::import("orca").attr("host"); 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::object view = py::cast(&ex, py::return_value_policy::reference); - py::array c = view.attr("contour").attr("points")().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); + // Construct from Polygon objects (Point idiom, no numpy). + py::object P = host.attr("Polygon")(); + P.attr("append")(host.attr("Point")(0, 0)); + P.attr("append")(host.attr("Point")(s, 0)); + P.attr("append")(host.attr("Point")(s, s)); + P.attr("append")(host.attr("Point")(0, s)); + py::object ex = host.attr("ExPolygon")(P); + CHECK_THAT(ex.attr("area")().cast(), WithinRel((double) s * (double) s, 1e-9)); + CHECK(ex.attr("num_contours")().cast() == 1); + CHECK(ex.attr("contour").attr("size")().cast() == 4); - py::list holes = view.attr("holes").cast(); - REQUIRE(holes.size() == 1); - py::array h0 = holes[0].attr("points")().cast(); - CHECK(h0.shape(0) == 3); - CHECK_FALSE(h0.writeable()); + // In-place transform mutates the geometry. + ex.attr("translate")(py::float_(1000.0), py::float_(0.0)); + // Boolean op returns new geometry: A minus a smaller inset of A is a non-empty ring set. + py::list inset = ex.attr("offset")(py::int_(-(coord_t)scale_(1.0))).cast(); + REQUIRE(inset.size() >= 1); + py::list ring = ex.attr("diff_ex")(inset[0]).cast(); + CHECK(ring.size() >= 1); } namespace { @@ -354,34 +468,29 @@ TEST_CASE("orca.host graph classes: LayerRegion/Layer raw traversal; Print/Print CHECK(ly.attr("lower_layer").is_none()); } -TEST_CASE("orca.host mutators: registration, ValueError on garbage, empty-clears", "[slicing_pipeline]") { +TEST_CASE("orca.host: plugin-only mutators are gone; class-API editing works", "[slicing_pipeline]") { ensure_python_initialized(); import_orca_module(); py::gil_scoped_acquire gil; py::object host = py::module_::import("orca").attr("host"); - CHECK(py::hasattr(host.attr("LayerRegion"), "set_slices")); - CHECK(py::hasattr(host.attr("LayerRegion"), "set_fill_surfaces")); - CHECK(py::hasattr(host.attr("Layer"), "set_lslices")); + // The three plugin-only mutators were removed in the raw-API realignment. + CHECK_FALSE(py::hasattr(host.attr("LayerRegion"), "set_slices")); + CHECK_FALSE(py::hasattr(host.attr("LayerRegion"), "set_fill_surfaces")); + CHECK_FALSE(py::hasattr(host.attr("Layer"), "set_lslices")); + // The faithful surface is present. + CHECK(py::hasattr(host.attr("SurfaceCollection"), "set")); + CHECK(py::hasattr(host.attr("Layer"), "make_slices")); + + // clear() via the collection on a hand-built region (null owning layer is null-safe). TestLayerRegion region; region.slices.surfaces.emplace_back(Slic3r::Surface(Slic3r::stInternal)); - py::object lr = py::cast(static_cast(®ion), - py::return_value_policy::reference); - - 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(lr.attr("set_slices"), py::int_(42))); // not a sequence - CHECK(raises_value_error(lr.attr("set_slices"), py::str("nope"))); // string rejected - CHECK(region.slices.surfaces.size() == 1); // failures mutate nothing - // G7: an empty list is legal and clears the region (refresh_lslices defaults True; - // the null owning-layer on this hand-built region exercises the null guard). - lr.attr("set_slices")(py::list()); + py::object lr = py::cast(static_cast(®ion), py::return_value_policy::reference); + lr.attr("slices").attr("clear")(); CHECK(region.slices.surfaces.empty()); } -TEST_CASE("orca.host set_slices/set_lslices: ndarray input mutates geometry (read back both ways)", "[slicing_pipeline]") { +TEST_CASE("orca.host: SurfaceCollection.set mutates geometry; lslices via make_slices", "[slicing_pipeline]") { using Catch::Matchers::WithinRel; ensure_python_initialized(); import_orca_module(); @@ -394,64 +503,106 @@ TEST_CASE("orca.host set_slices/set_lslices: ndarray input mutates geometry (rea py::object host = py::module_::import("orca").attr("host"); py::module_ np = py::module_::import("numpy"); py::object i64 = np.attr("int64"); + py::object ST = host.attr("SurfaceType"); const coord_t s = (coord_t) scale_(10.0); - auto make_arr = [&](std::initializer_list> pts) { - py::list rows; - for (auto& p : pts) rows.append(py::make_tuple(p.first, p.second)); + auto 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); }; - // set_slices: CW input normalized CCW; surface_type carried forward; readable back raw. + // Build an ExPolygon from a CW ndarray; the ctor normalizes to CCW. + py::object ex = host.attr("ExPolygon")(arr({ {0,0}, {0,s}, {s,s}, {s,0} })); + CHECK(ex.attr("contour").attr("is_counter_clockwise")().cast()); + TestLayerRegion region; - region.slices.surfaces.emplace_back(Slic3r::Surface(Slic3r::stInternalSolid)); - py::object lr = py::cast(static_cast(®ion), - py::return_value_policy::reference); - py::list polys; - polys.append(make_arr({ {0,0}, {0,s}, {s,s}, {s,0} })); // clockwise winding - lr.attr("set_slices")(polys); + py::object lr = py::cast(static_cast(®ion), py::return_value_policy::reference); + py::list expolys; expolys.append(ex); + lr.attr("slices").attr("set")(expolys, ST.attr("stInternalSolid")); REQUIRE(region.slices.surfaces.size() == 1); const Slic3r::Surface& out = region.slices.surfaces.front(); CHECK(out.surface_type == Slic3r::stInternalSolid); - CHECK(out.expolygon.contour.is_counter_clockwise()); CHECK_THAT(out.expolygon.area(), WithinRel((double) s * (double) s, 1e-9)); - py::list sl = lr.attr("slices").attr("surfaces").cast(); - REQUIRE(sl.size() == 1); - py::array c = sl[0].attr("expolygon").attr("contour").attr("points")().cast(); + // Read geometry back through the class API. + py::array c = lr.attr("slices").attr("surfaces").cast()[0] + .attr("expolygon").attr("contour").attr("as_array")().cast(); CHECK(c.shape(0) == 4); - // G9: per-entry SurfaceType override via [contour, holes, SurfaceType] triple. - py::list entry; - entry.append(make_arr({ {0,0}, {s,0}, {s,s}, {0,s} })); - entry.append(py::list()); - entry.append(host.attr("SurfaceType").attr("stTop")); - py::list polys2; polys2.append(entry); - lr.attr("set_slices")(polys2, py::bool_(false)); // refresh_lslices=False path - REQUIRE(region.slices.surfaces.size() == 1); - CHECK(region.slices.surfaces.front().surface_type == Slic3r::stTop); - - // Negative: a valid contour paired with a non-list holes slot must raise ValueError. - // (Regression guard for a malformed holes slot; the retired view-layer suite covered - // this, and the raw layer needs a numpy-built valid contour to exercise the same path.) - { - py::list bad_entry; - bad_entry.append(make_arr({ {0,0}, {s,0}, {s,s}, {0,s} })); // valid contour - bad_entry.append(py::int_(42)); // holes slot is not a list - py::list bad_polys; bad_polys.append(bad_entry); - bool raised = false; - try { lr.attr("set_slices")(bad_polys); } - catch (py::error_already_set& e) { raised = e.matches(PyExc_ValueError); } - CHECK(raised); - } - - // Layer.set_lslices round-trip on a hand-built layer (empty regions -> null-safe). + // lslices are derived: make_slices() re-derives them + refreshes the bbox cache. TestLayer layer; - py::object ly = py::cast(static_cast(&layer), - py::return_value_policy::reference); - py::list islands; - islands.append(make_arr({ {0,0}, {s,0}, {s,s}, {0,s} })); - ly.attr("set_lslices")(islands); - REQUIRE(layer.lslices.size() == 1); - CHECK(layer.lslices.front().contour.is_counter_clockwise()); - REQUIRE(layer.lslices_bboxes.size() == 1); // bbox cache refreshed - CHECK(ly.attr("lslices")().cast().size() == 1); + py::object ly = py::cast(static_cast(&layer), py::return_value_policy::reference); + // (A hand-built layer has no regions, so make_slices() yields empty lslices — still null-safe.) + ly.attr("make_slices")(); + CHECK(layer.lslices_bboxes.size() == layer.lslices.size()); +} + +TEST_CASE("orca.host ExPolygon in-place transforms + SurfaceCollection.append (sample ops)", "[slicing_pipeline]") { + using Catch::Matchers::WithinRel; + ensure_python_initialized(); + import_orca_module(); + py::gil_scoped_acquire gil; + py::object host = py::module_::import("orca").attr("host"); + const coord_t s = (coord_t) scale_(10.0); + auto make_square = [&]() { + py::object P = host.attr("Polygon")(); + P.attr("append")(host.attr("Point")(0, 0)); + P.attr("append")(host.attr("Point")(s, 0)); + P.attr("append")(host.attr("Point")(s, s)); + P.attr("append")(host.attr("Point")(0, s)); + return host.attr("ExPolygon")(P); + }; + const double area0 = (double) s * (double) s; + + // rotate about the square's center preserves area + py::object ex = make_square(); + py::object center = host.attr("Point")(s / 2, s / 2); + ex.attr("rotate")(py::float_(1.5707963267948966), center); // pi/2 + CHECK_THAT(ex.attr("area")().cast(), WithinRel(area0, 1e-6)); + + // uniform scale by 2 quadruples area (scale is about the origin) + py::object ex2 = make_square(); + ex2.attr("scale")(py::float_(2.0)); + CHECK_THAT(ex2.attr("area")().cast(), WithinRel(4.0 * area0, 1e-6)); + + // translate preserves area + py::object ex3 = make_square(); + ex3.attr("translate")(py::float_(1000.0), py::float_(-500.0)); + CHECK_THAT(ex3.attr("area")().cast(), WithinRel(area0, 1e-6)); + + // SurfaceCollection.append accumulates surfaces of a second type (the sample write-back path) + Slic3r::SurfaceCollection coll; + py::object cv = py::cast(&coll, py::return_value_policy::reference); + py::list g1; g1.append(make_square()); + cv.attr("set")(g1, host.attr("SurfaceType").attr("stInternalSolid")); + py::list g2; g2.append(make_square()); + cv.attr("append")(g2, host.attr("SurfaceType").attr("stTop")); + REQUIRE(coll.surfaces.size() == 2); + CHECK(coll.surfaces[0].surface_type == Slic3r::stInternalSolid); + CHECK(coll.surfaces[1].surface_type == Slic3r::stTop); +} + +TEST_CASE("orca.host: in-place edit of surface.expolygon through a live collection persists to C++", "[slicing_pipeline]") { + using Catch::Matchers::WithinRel; + ensure_python_initialized(); + import_orca_module(); + py::gil_scoped_acquire gil; + + const coord_t s = (coord_t) scale_(10.0); + // Live LayerRegion holding one surface (a 10mm square at the origin). + TestLayerRegion region; + Slic3r::ExPolygon sq; + sq.contour.points = { Slic3r::Point(0, 0), Slic3r::Point(s, 0), + Slic3r::Point(s, s), Slic3r::Point(0, s) }; + region.slices.surfaces.emplace_back(Slic3r::Surface(Slic3r::stInternal, sq)); + py::object lr = py::cast(static_cast(®ion), + py::return_value_policy::reference); + + // Twistify's path: get the Surface through the live collection, mutate its expolygon in place. + py::object surf = lr.attr("slices").attr("surfaces").cast()[0]; + surf.attr("expolygon").attr("translate")(py::float_(1000.0), py::float_(0.0)); + + // The C++-side surface geometry reflects the Python in-place edit (proves the live ref). + const Slic3r::Surface& out = region.slices.surfaces.front(); + CHECK(out.expolygon.contour.points[0].x() == 1000); // was 0 + CHECK(out.expolygon.contour.points[0].y() == 0); + CHECK_THAT(out.expolygon.area(), WithinRel((double) s * (double) s, 1e-9)); // translate preserves area } From 21ed68963f4979e518170d92cc8a2e897a472222 Mon Sep 17 00:00:00 2001 From: SoftFever Date: Fri, 10 Jul 2026 17:28:26 +0800 Subject: [PATCH 04/10] refactor(plugin): prefix SlicingPipelineStepPlugin values with pos/ps to mirror Print steps --- sandboxes/orca_inset_plugin_any.py | 6 +-- sandboxes/orca_twistify_plugin_example_any.py | 4 +- src/libslic3r/Print.cpp | 26 +++++------ src/libslic3r/Print.hpp | 14 +++--- src/slic3r/GUI/GUI_App.cpp | 10 ++-- src/slic3r/GUI/Tab.cpp | 2 +- src/slic3r/plugin/PluginHostSlicing.cpp | 2 +- .../SlicingPipelinePluginCapability.cpp | 26 +++++------ .../SlicingPipelinePluginCapability.hpp | 4 +- .../fff_print/test_slicing_pipeline_hook.cpp | 46 +++++++++---------- 10 files changed, 70 insertions(+), 70 deletions(-) diff --git a/sandboxes/orca_inset_plugin_any.py b/sandboxes/orca_inset_plugin_any.py index 784e5d86f7..ebcbc8563a 100644 --- a/sandboxes/orca_inset_plugin_any.py +++ b/sandboxes/orca_inset_plugin_any.py @@ -10,12 +10,12 @@ # /// """Inset Every Slice -- a small, WORKING SlicingPipeline sample plugin. -At Step.Slice, for every layer/region of the sliced object, this shrinks each +At Step.posSlice, for every layer/region of the sliced object, this shrinks each sliced surface by INSET_MM using a real polygon offset (ExPolygon.offset) and writes the result back with SurfaceCollection.set(). After the per-region edits, layer.make_slices() re-derives the layer's merged islands (lslices) so overhang/bridge detection, skirt/brim and support stay coherent with the inset -geometry. At Step.Slice the split slice loop runs make_perimeters() right after +geometry. At Step.posSlice the split slice loop runs make_perimeters() right after the hook, so the change cascades into perimeters, infill and the final G-code -- the toolpath preview shrinks. @@ -35,7 +35,7 @@ class InsetEverySlice(orca.slicing.SlicingPipelineCapabilityBase): return "Inset Every Slice" def execute(self, ctx): - if ctx.step != orca.slicing.Step.Slice or ctx.object is None: + if ctx.step != orca.slicing.Step.posSlice or ctx.object is None: return orca.ExecutionResult.success() # Millimeters -> scaled integer units via the *live* scale (never hardcode 1e6). diff --git a/sandboxes/orca_twistify_plugin_example_any.py b/sandboxes/orca_twistify_plugin_example_any.py index 6dadf6febe..72c746d6b0 100644 --- a/sandboxes/orca_twistify_plugin_example_any.py +++ b/sandboxes/orca_twistify_plugin_example_any.py @@ -17,7 +17,7 @@ # /// """Twistify -- twist/taper/wobble any model at slice time. -At Step.Slice, every layer's sliced surfaces are transformed by a similarity +At Step.posSlice, every layer's sliced surfaces are transformed by a similarity about the object's bounding-box center as a function of Z -- edited IN PLACE through the host geometry classes (ExPolygon.rotate/scale/translate). Each surface is rotated about the center, then (if tapering) translated to the @@ -81,7 +81,7 @@ class Twistify(orca.slicing.SlicingPipelineCapabilityBase): return "Twistify" def execute(self, ctx): - if ctx.step != orca.slicing.Step.Slice or ctx.object is None: + if ctx.step != orca.slicing.Step.posSlice or ctx.object is None: return orca.ExecutionResult.success() p = _params(ctx) diff --git a/src/libslic3r/Print.cpp b/src/libslic3r/Print.cpp index 16e056d3c9..dbbca5ab28 100644 --- a/src/libslic3r/Print.cpp +++ b/src/libslic3r/Print.cpp @@ -2319,7 +2319,7 @@ void Print::process(long long *time_cost_with_cache, bool use_cache) 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) { + auto hook_after = [this](PrintObject* obj, bool was_done, PrintObjectStep pstep, SlicingPipelineStepPlugin sstep) { if (m_pipeline_plugin_active && !was_done && obj->is_step_done(pstep)) run_pipeline_hook(sstep, obj); }; @@ -2329,7 +2329,7 @@ void Print::process(long long *time_cost_with_cache, bool use_cache) if (need_slicing_objects.count(obj) != 0) { const bool was_done = obj->is_step_done(posSlice); obj->slice(); - hook_after(obj, was_done, posSlice, SlicingPipelineStep::Slice); + hook_after(obj, was_done, posSlice, SlicingPipelineStepPlugin::posSlice); // re-snapshot each layer's raw_slices AFTER the Slice hook ran, so the // plugin's mutation becomes the untyped baseline. Without this, a later // perimeter-only re-run (make_perimeters -> restore_untyped_slices) reverts @@ -2349,7 +2349,7 @@ void Print::process(long long *time_cost_with_cache, bool use_cache) 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); + hook_after(obj, was_done, posPerimeters, SlicingPipelineStepPlugin::posPerimeters); } else { if (obj->set_started(posPerimeters)) obj->set_done(posPerimeters); } @@ -2358,7 +2358,7 @@ void Print::process(long long *time_cost_with_cache, bool use_cache) 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); + hook_after(obj, was_done, posEstimateCurledExtrusions, SlicingPipelineStepPlugin::posEstimateCurledExtrusions); } else { if (obj->set_started(posEstimateCurledExtrusions)) @@ -2374,10 +2374,10 @@ void Print::process(long long *time_cost_with_cache, bool use_cache) // is DONE, so this is a mechanical split mirroring the slice/perimeters loop. const bool prepare_was_done = obj->is_step_done(posPrepareInfill); obj->prepare_infill(); - hook_after(obj, prepare_was_done, posPrepareInfill, SlicingPipelineStep::PrepareInfill); + hook_after(obj, prepare_was_done, posPrepareInfill, SlicingPipelineStepPlugin::posPrepareInfill); const bool was_done = obj->is_step_done(posInfill); obj->infill(); - hook_after(obj, was_done, posInfill, SlicingPipelineStep::Infill); + hook_after(obj, was_done, posInfill, SlicingPipelineStepPlugin::posInfill); } else { if (obj->set_started(posPrepareInfill)) @@ -2390,7 +2390,7 @@ void Print::process(long long *time_cost_with_cache, bool use_cache) 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); + hook_after(obj, was_done, posIroning, SlicingPipelineStepPlugin::posIroning); } else { if (obj->set_started(posIroning)) @@ -2404,7 +2404,7 @@ void Print::process(long long *time_cost_with_cache, bool use_cache) if (need_contouring) { const bool was_done = obj->is_step_done(posContouring); obj->contour_z(); - hook_after(obj, was_done, posContouring, SlicingPipelineStep::Contouring); + hook_after(obj, was_done, posContouring, SlicingPipelineStepPlugin::posContouring); } else { if (obj->set_started(posContouring)) obj->set_done(posContouring); @@ -2437,13 +2437,13 @@ void Print::process(long long *time_cost_with_cache, bool use_cache) 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]); + run_pipeline_hook(SlicingPipelineStepPlugin::posSupportMaterial, 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); + hook_after(obj, was_done, posDetectOverhangsForLift, SlicingPipelineStepPlugin::posDetectOverhangsForLift); } else { if (obj->set_started(posDetectOverhangsForLift)) @@ -2520,7 +2520,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 (m_pipeline_plugin_active) run_pipeline_hook(SlicingPipelineStepPlugin::psWipeTower, nullptr); } if (this->has_wipe_tower()) { @@ -2646,7 +2646,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 (m_pipeline_plugin_active) run_pipeline_hook(SlicingPipelineStepPlugin::psSkirtBrim, nullptr); if (time_cost_with_cache) { end_time = (long long)Slic3r::Utils::get_current_time_utc(); @@ -2663,7 +2663,7 @@ void Print::process(long long *time_cost_with_cache, bool use_cache) // 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); + run_pipeline_hook(SlicingPipelineStepPlugin::posSimplifyPath, obj); } else { if (obj->set_started(posSimplifyPath)) diff --git a/src/libslic3r/Print.hpp b/src/libslic3r/Print.hpp index 0402b31614..a3521dcc8e 100644 --- a/src/libslic3r/Print.hpp +++ b/src/libslic3r/Print.hpp @@ -99,6 +99,11 @@ enum PrintObjectStep { posCount, }; +enum class SlicingPipelineStepPlugin { + posSlice, posPerimeters, posEstimateCurledExtrusions, posPrepareInfill, posInfill, posIroning, posContouring, + posSupportMaterial, posDetectOverhangsForLift, posSimplifyPath, psWipeTower, psSkirtBrim +}; + // A PrintRegion object represents a group of volumes to print // sharing the same config (including the same assigned extruder(s)) class PrintRegion @@ -882,11 +887,6 @@ enum FilamentCompatibilityType { InvalidTemperatureRange }; -enum class SlicingPipelineStep { - Slice, Perimeters, EstimateCurledExtrusions, PrepareInfill, Infill, Ironing, Contouring, - SupportMaterial, DetectOverhangsForLift, SimplifyPath, WipeTower, SkirtBrim -}; - // The complete print tray with possibly multiple objects. class Print : public PrintBaseWithState { @@ -896,7 +896,7 @@ private: // Prevents erroneous use by other classes. typedef std::pair PrintObjectInfo; public: - using SlicingPipelineHookFn = std::function; + 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); } @@ -1159,7 +1159,7 @@ private: static SlicingPipelineHookFn s_slicing_pipeline_hook_fn; bool m_pipeline_plugin_active { false }; - void run_pipeline_hook(SlicingPipelineStep step, const PrintObject* object) { + void run_pipeline_hook(SlicingPipelineStepPlugin step, const PrintObject* object) { if (m_pipeline_plugin_active && s_slicing_pipeline_hook_fn) s_slicing_pipeline_hook_fn(*this, object, step); } diff --git a/src/slic3r/GUI/GUI_App.cpp b/src/slic3r/GUI/GUI_App.cpp index 0ec1a616e5..3150eda1c8 100644 --- a/src/slic3r/GUI/GUI_App.cpp +++ b/src/slic3r/GUI/GUI_App.cpp @@ -3130,7 +3130,7 @@ bool GUI_App::on_init_inner() // 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) { + [](Slic3r::Print& print, const Slic3r::PrintObject* object, Slic3r::SlicingPipelineStepPlugin 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 @@ -3180,10 +3180,10 @@ bool GUI_App::on_init_inner() // out in Release), so it must NOT be called from a pipeline hook. if (!r.message.empty()) { static const char* const kStepNames[] = { - "Slice", "Perimeters", "EstimateCurledExtrusions", "PrepareInfill", "Infill", - "Ironing", "Contouring", "SupportMaterial", "DetectOverhangsForLift", - "SimplifyPath", "WipeTower", "SkirtBrim" - }; // order must match Slic3r::SlicingPipelineStep + "posSlice", "posPerimeters", "posEstimateCurledExtrusions", "posPrepareInfill", "posInfill", + "posIroning", "posContouring", "posSupportMaterial", "posDetectOverhangsForLift", + "posSimplifyPath", "psWipeTower", "psSkirtBrim" + }; // order must match Slic3r::SlicingPipelineStepPlugin const char* step_name = static_cast(step) < sizeof(kStepNames) / sizeof(kStepNames[0]) ? kStepNames[static_cast(step)] : "Unknown"; BOOST_LOG_TRIVIAL(info) << "Slicing pipeline plugin '" << ref.capability_name diff --git a/src/slic3r/GUI/Tab.cpp b/src/slic3r/GUI/Tab.cpp index a765c6fcdd..fcb5c575ea 100644 --- a/src/slic3r/GUI/Tab.cpp +++ b/src/slic3r/GUI/Tab.cpp @@ -1794,7 +1794,7 @@ void Tab::on_value_change(const std::string& opt_key, const boost::any& value) // always carries resolved "name;uuid;capability" references that full_config() and save_to_json() // then pass downstream as-is -- no separate rebuild anywhere else. if (const ConfigOptionDef* opt_def = m_config->def()->get(opt_key); - opt_def && opt_def->gui_type == ConfigOptionDef::GUIType::plugin_picker) + opt_def && opt_def->plugin_type != ConfigOptionDef::PluginType::None) m_config->update_plugin_manifest(); if (opt_key == "gcode_flavor" && m_type == Preset::TYPE_PRINTER) { diff --git a/src/slic3r/plugin/PluginHostSlicing.cpp b/src/slic3r/plugin/PluginHostSlicing.cpp index 57aa02962e..01739420f0 100644 --- a/src/slic3r/plugin/PluginHostSlicing.cpp +++ b/src/slic3r/plugin/PluginHostSlicing.cpp @@ -414,7 +414,7 @@ void PluginHostSlicing::RegisterBindings(py::module_& host) layer_region .def_readonly("slices", &LayerRegion::slices, "Sliced, typed surfaces (SurfaceCollection). Edit in place, or replace with " - "slices.set(expolygons, surface_type). At Step.Slice this is the primary mutation " + "slices.set(expolygons, surface_type). At Step.posSlice this is the primary mutation " "target; the split slice loop runs make_perimeters() afterward so edits cascade downstream.") .def_readonly("fill_surfaces", &LayerRegion::fill_surfaces, "Surfaces prepared for infill (SurfaceCollection). Edit in place or via fill_surfaces.set(...).") diff --git a/src/slic3r/plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.cpp b/src/slic3r/plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.cpp index 6cc1d1bde9..bd2bca71c7 100644 --- a/src/slic3r/plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.cpp +++ b/src/slic3r/plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.cpp @@ -13,19 +13,19 @@ void SlicingPipelinePluginCapability::RegisterBindings(py::module_& module, py:: (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("PrepareInfill", SlicingPipelineStep::PrepareInfill) // after prepare_infill, before make_fills: editing fill_surfaces here CASCADES - .value("Infill", SlicingPipelineStep::Infill) // after make_fills: editing fill_surfaces here does NOT regenerate fills (v1) - .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) + py::enum_(slicing, "Step") + .value("posSlice", SlicingPipelineStepPlugin::posSlice) + .value("posPerimeters", SlicingPipelineStepPlugin::posPerimeters) + .value("posEstimateCurledExtrusions", SlicingPipelineStepPlugin::posEstimateCurledExtrusions) + .value("posPrepareInfill", SlicingPipelineStepPlugin::posPrepareInfill) // after prepare_infill, before make_fills: editing fill_surfaces here CASCADES + .value("posInfill", SlicingPipelineStepPlugin::posInfill) // after make_fills: editing fill_surfaces here does NOT regenerate fills (v1) + .value("posIroning", SlicingPipelineStepPlugin::posIroning) + .value("posContouring", SlicingPipelineStepPlugin::posContouring) + .value("posSupportMaterial", SlicingPipelineStepPlugin::posSupportMaterial) + .value("posDetectOverhangsForLift", SlicingPipelineStepPlugin::posDetectOverhangsForLift) + .value("posSimplifyPath", SlicingPipelineStepPlugin::posSimplifyPath) // covers all simplify sub-steps + .value("psWipeTower", SlicingPipelineStepPlugin::psWipeTower) + .value("psSkirtBrim", SlicingPipelineStepPlugin::psSkirtBrim) .export_values(); // The read-graph data model (Surface / ExPolygon / the extrusion tree / LayerRegion / diff --git a/src/slic3r/plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.hpp b/src/slic3r/plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.hpp index 7f770913e7..d3c30ba92c 100644 --- a/src/slic3r/plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.hpp +++ b/src/slic3r/plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.hpp @@ -1,6 +1,6 @@ #pragma once #include "slic3r/plugin/PythonPluginInterface.hpp" -#include "libslic3r/Print.hpp" // SlicingPipelineStep, Print, PrintObject +#include "libslic3r/Print.hpp" // SlicingPipelineStepPlugin, Print, PrintObject #include #include #include @@ -15,7 +15,7 @@ namespace Slic3r { // src/slic3r/plugin/PluginHostSlicing.cpp. struct SlicingPipelineContext { std::string orca_version; - SlicingPipelineStep step { SlicingPipelineStep::Slice }; + SlicingPipelineStepPlugin step { SlicingPipelineStepPlugin::posSlice }; Print* print { nullptr }; // always present when dispatched const PrintObject* object { nullptr }; // null for print-wide steps // read-only per-plugin settings, populated by the dispatcher from the diff --git a/tests/fff_print/test_slicing_pipeline_hook.cpp b/tests/fff_print/test_slicing_pipeline_hook.cpp index fcb1802b8c..1f7db91e4c 100644 --- a/tests/fff_print/test_slicing_pipeline_hook.cpp +++ b/tests/fff_print/test_slicing_pipeline_hook.cpp @@ -19,7 +19,7 @@ TEST_CASE("slicing_pipeline_plugin option exists and defaults empty", "[slicing_ 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&, const Slic3r::PrintObject*, Slic3r::SlicingPipelineStepPlugin){ ++calls; }); Slic3r::Print::set_slicing_pipeline_hook_fn(nullptr); // reset — must be legal CHECK(calls == 0); } @@ -30,10 +30,10 @@ TEST_CASE("slicing pipeline hook setter is a no-op-safe injection", "[slicing_pi 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; }; + struct Call { const Slic3r::PrintObject* obj; Slic3r::SlicingPipelineStepPlugin 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&, const Slic3r::PrintObject* o, Slic3r::SlicingPipelineStepPlugin s){ calls.push_back({o, s}); }); Slic3r::Print print; Slic3r::Model model; Slic3r::DynamicPrintConfig config = Slic3r::DynamicPrintConfig::full_print_config(); @@ -42,7 +42,7 @@ TEST_CASE("SlicingPipeline hook fires once per step per object in order", "[slic print.process(); Slic3r::Print::set_slicing_pipeline_hook_fn(nullptr); - using S = Slic3r::SlicingPipelineStep; + using S = Slic3r::SlicingPipelineStepPlugin; 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); @@ -97,7 +97,7 @@ TEST_CASE("Inactive hook: process output is byte-identical (no-op hook == unset) 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){}); + Slic3r::Print::set_slicing_pipeline_hook_fn([](Slic3r::Print&, const Slic3r::PrintObject*, Slic3r::SlicingPipelineStepPlugin){}); else Slic3r::Print::set_slicing_pipeline_hook_fn(nullptr); init_print({TestMesh::cube_20x20x20}, print, model, config); @@ -120,7 +120,7 @@ TEST_CASE("Inactive hook: process output is byte-identical (no-op hook == unset) 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&, const Slic3r::PrintObject*, Slic3r::SlicingPipelineStepPlugin){ ++calls; }); Slic3r::Print print; Slic3r::Model model; auto config = Slic3r::DynamicPrintConfig::full_print_config(); // option left EMPTY -> inactive regardless of the registered hook. @@ -138,9 +138,9 @@ TEST_CASE("Empty option: registered hook is gated off and never fires", "[slicin 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&, const Slic3r::PrintObject*, Slic3r::SlicingPipelineStepPlugin s){ + if (s == Slic3r::SlicingPipelineStepPlugin::posSlice) ++slice_calls; + if (s == Slic3r::SlicingPipelineStepPlugin::posPerimeters) ++perim_calls; }); Slic3r::Print print; Slic3r::Model model; @@ -186,8 +186,8 @@ TEST_CASE("Mutating slices at the Slice boundary cascades downstream", "[slicing 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; + [](Slic3r::Print&, const Slic3r::PrintObject* o, Slic3r::SlicingPipelineStepPlugin s){ + if (s != Slic3r::SlicingPipelineStepPlugin::posSlice || !o) return; for (Slic3r::Layer* l : const_cast(o)->layers()) for (Slic3r::LayerRegion* r : l->regions()) { Slic3r::Surfaces in = r->slices.surfaces; @@ -219,7 +219,7 @@ TEST_CASE("Changing slicing_pipeline_plugin invalidates posSlice", "[slicing_pip #include // §3.6 (Twistify design): Twistify's effect is a similarity transform (rotate + uniform -// scale) applied to slices at Step.Slice. This C++ analogue rotates every region's slices a +// scale) applied to slices at Step.posSlice. This C++ analogue rotates every region's slices a // fixed 45 deg about the object's base-footprint center -- the same seam and cascade that // Twistify.py drives through the slices.set() + Layer::make_slices() path. Two end-to-end invariants after // process() confirm the approach: @@ -235,8 +235,8 @@ TEST_CASE("Rotating slices at the Slice boundary cascades (area preserved, bbox auto config = Slic3r::DynamicPrintConfig::full_print_config(); config.set_key_value("slicing_pipeline_plugin", new Slic3r::ConfigOptionStrings({"probe"})); if (rotate) Slic3r::Print::set_slicing_pipeline_hook_fn( - [](Slic3r::Print&, const Slic3r::PrintObject* o, Slic3r::SlicingPipelineStep s){ - if (s != Slic3r::SlicingPipelineStep::Slice || !o) return; + [](Slic3r::Print&, const Slic3r::PrintObject* o, Slic3r::SlicingPipelineStepPlugin s){ + if (s != Slic3r::SlicingPipelineStepPlugin::posSlice || !o) return; auto* obj = const_cast(o); // Twist axis = center of the first sliced layer's footprint (Twistify's anchor). coord_t nx=0, xx=0, ny=0, xy=0; bool seeded=false; @@ -310,8 +310,8 @@ TEST_CASE("Identity round-trip through set_slices is byte-identical", "[slicing_ auto config = Slic3r::DynamicPrintConfig::full_print_config(); config.set_key_value("slicing_pipeline_plugin", new Slic3r::ConfigOptionStrings({"probe"})); // active in both runs Slic3r::Print::set_slicing_pipeline_hook_fn( - [roundtrip](Slic3r::Print&, const Slic3r::PrintObject* o, Slic3r::SlicingPipelineStep s){ - if (!roundtrip || s != Slic3r::SlicingPipelineStep::Slice || !o) return; + [roundtrip](Slic3r::Print&, const Slic3r::PrintObject* o, Slic3r::SlicingPipelineStepPlugin s){ + if (!roundtrip || s != Slic3r::SlicingPipelineStepPlugin::posSlice || !o) return; for (Slic3r::Layer* l : const_cast(o)->layers()) for (Slic3r::LayerRegion* r : l->regions()) { Slic3r::Surfaces in = r->slices.surfaces; // copy current (already-normalized) geometry @@ -359,8 +359,8 @@ static double outer_slices_width(const Slic3r::Print& print) { TEST_CASE("raw_slices captures post-hook geometry so a perimeter re-run keeps the mutation", "[slicing_pipeline]") { using Catch::Matchers::WithinRel; Slic3r::Print::set_slicing_pipeline_hook_fn( - [](Slic3r::Print&, const Slic3r::PrintObject* o, Slic3r::SlicingPipelineStep s){ - if (s != Slic3r::SlicingPipelineStep::Slice || !o) return; + [](Slic3r::Print&, const Slic3r::PrintObject* o, Slic3r::SlicingPipelineStepPlugin s){ + if (s != Slic3r::SlicingPipelineStepPlugin::posSlice || !o) return; for (Slic3r::Layer* l : const_cast(o)->layers()) for (Slic3r::LayerRegion* r : l->regions()) { Slic3r::Surfaces in = r->slices.surfaces; @@ -391,12 +391,12 @@ TEST_CASE("raw_slices captures post-hook geometry so a perimeter re-run keeps th // them, whereas the pre-existing Infill seam fires after the fills are already built (v1 limit). // All three runs register a hook (active path) so the comparison isolates only the mutation. TEST_CASE("fill_surfaces mutation cascades at PrepareInfill but not at Infill", "[slicing_pipeline]") { - auto fill_paths = [](bool shrink, Slic3r::SlicingPipelineStep at) { + auto fill_paths = [](bool shrink, Slic3r::SlicingPipelineStepPlugin at) { Slic3r::Print print; Slic3r::Model model; auto config = Slic3r::DynamicPrintConfig::full_print_config(); config.set_key_value("slicing_pipeline_plugin", new Slic3r::ConfigOptionStrings({"probe"})); Slic3r::Print::set_slicing_pipeline_hook_fn( - [shrink, at](Slic3r::Print&, const Slic3r::PrintObject* o, Slic3r::SlicingPipelineStep s){ + [shrink, at](Slic3r::Print&, const Slic3r::PrintObject* o, Slic3r::SlicingPipelineStepPlugin s){ if (!shrink || s != at || !o) return; for (Slic3r::Layer* l : const_cast(o)->layers()) for (Slic3r::LayerRegion* r : l->regions()) { @@ -417,7 +417,7 @@ TEST_CASE("fill_surfaces mutation cascades at PrepareInfill but not at Infill", Slic3r::Print::set_slicing_pipeline_hook_fn(nullptr); return n; }; - using S = Slic3r::SlicingPipelineStep; + using S = Slic3r::SlicingPipelineStepPlugin; const size_t base = fill_paths(false, S::PrepareInfill); // active hook, no mutation CHECK(base > 0); CHECK(fill_paths(true, S::PrepareInfill) < base); // mutation before make_fills cascades @@ -434,8 +434,8 @@ TEST_CASE("refreshing lslices after a slice mutation makes islands track the geo auto config = Slic3r::DynamicPrintConfig::full_print_config(); config.set_key_value("slicing_pipeline_plugin", new Slic3r::ConfigOptionStrings({"probe"})); Slic3r::Print::set_slicing_pipeline_hook_fn( - [refresh](Slic3r::Print&, const Slic3r::PrintObject* o, Slic3r::SlicingPipelineStep s){ - if (s != Slic3r::SlicingPipelineStep::Slice || !o) return; + [refresh](Slic3r::Print&, const Slic3r::PrintObject* o, Slic3r::SlicingPipelineStepPlugin s){ + if (s != Slic3r::SlicingPipelineStepPlugin::posSlice || !o) return; for (Slic3r::Layer* l : const_cast(o)->layers()) { for (Slic3r::LayerRegion* r : l->regions()) { Slic3r::Surfaces in = r->slices.surfaces; From 19352215daa29b6388035ce3d8d2e7108cd44695 Mon Sep 17 00:00:00 2001 From: SoftFever Date: Fri, 10 Jul 2026 19:57:35 +0800 Subject: [PATCH 05/10] feat(plugin)!: merge G-code post-processing into the slicing pipeline as psGCodePostProcess G-code post-processing is now a step of the slicing-pipeline plugin rather than a separate capability type. One capability class can transform slices at the geometry seams AND edit the final G-code, behind a single picker/option. - Add SlicingPipelineStepPlugin::psGCodePostProcess (bound as orca.slicing.Step.psGCodePostProcess). Unlike the geometry steps it fires from the GUI export path in PostProcessor.cpp, not from Print::process(): ctx.print/ctx.object are None and the plugin edits the file at ctx.gcode_path in place. It may run more than once per slice (file export and/or upload) and its output is not shown in the preview. - Extend SlicingPipelineContext with gcode_path/host/output_name and a C++-only full_config; config_value() falls back to it when there is no live Print. - PostProcessor.cpp dispatches SlicingPipelinePluginCapability at psGCodePostProcess, driven by the existing slicing_pipeline_plugin option. - The exported G-code lives outside data_dir(), so the plugin audit sandbox would block the write; the trampoline's audit setup grants ctx.gcode_path's folder as a scoped allowed root, gated on a non-empty gcode_path so the geometry-step hooks gain no extra filesystem access. BREAKING CHANGE: the separate G-code post-processing capability type is removed. - orca.gcode.GCodePluginCapabilityBase and orca.PluginType.PostProcessing are gone; post-processing plugins migrate to orca.slicing.SlicingPipelineCapabilityBase + Step.psGCodePostProcess (and gain ctx.params / ctx.config_value()). - The post_process_plugin config option is removed; use slicing_pipeline_plugin. Presets carrying the old key degrade to the standard unknown-key warning. - Manifest type = "post-processing" now maps to Unknown (advisory only; the loader dispatches on the C++ get_type()). Also repairs two latent build breaks the branch carried: stale Step enum value usages in test_slicing_pipeline_hook.cpp and a reference to the removed ConfigOptionDef::PluginType::None in Tab::on_value_change (now is_plugin_backed()). Adds the orca_gcode_stamp sample plugin and a psGCodePostProcess binding test. --- sandboxes/orca_gcode_stamp_plugin_any.py | 74 ++++++++++++++++++ src/libslic3r/Config.hpp | 6 +- src/libslic3r/Preset.cpp | 1 - src/libslic3r/Print.cpp | 4 +- src/libslic3r/Print.hpp | 5 +- src/libslic3r/PrintConfig.cpp | 14 +--- src/libslic3r/PrintConfig.hpp | 1 - src/slic3r/CMakeLists.txt | 3 - src/slic3r/GUI/GUI_App.cpp | 2 +- src/slic3r/GUI/PostProcessor.cpp | 49 +++++++----- src/slic3r/GUI/PostProcessor.hpp | 7 +- src/slic3r/GUI/Tab.cpp | 8 +- src/slic3r/plugin/PluginLoader.cpp | 1 - src/slic3r/plugin/PythonPluginBridge.cpp | 3 - src/slic3r/plugin/PythonPluginInterface.hpp | 6 +- .../gcode/GCodePluginCapability.cpp | 30 -------- .../gcode/GCodePluginCapability.hpp | 27 ------- .../gcode/GCodePluginCapabilityTrampoline.hpp | 35 --------- .../SlicingPipelinePluginCapability.cpp | 25 +++++- .../SlicingPipelinePluginCapability.hpp | 16 +++- ...cingPipelinePluginCapabilityTrampoline.hpp | 15 +++- .../fff_print/test_slicing_pipeline_hook.cpp | 28 +++---- tests/libslic3r/test_config.cpp | 40 +++++----- .../test_plugin_capability_identifier.cpp | 12 +-- .../test_slicing_pipeline_bindings.cpp | 76 ++++++++++++++++++- 25 files changed, 288 insertions(+), 200 deletions(-) create mode 100644 sandboxes/orca_gcode_stamp_plugin_any.py delete mode 100644 src/slic3r/plugin/pluginTypes/gcode/GCodePluginCapability.cpp delete mode 100644 src/slic3r/plugin/pluginTypes/gcode/GCodePluginCapability.hpp delete mode 100644 src/slic3r/plugin/pluginTypes/gcode/GCodePluginCapabilityTrampoline.hpp diff --git a/sandboxes/orca_gcode_stamp_plugin_any.py b/sandboxes/orca_gcode_stamp_plugin_any.py new file mode 100644 index 0000000000..08273d189a --- /dev/null +++ b/sandboxes/orca_gcode_stamp_plugin_any.py @@ -0,0 +1,74 @@ +# /// script +# requires-python = ">=3.12" +# +# [tool.orcaslicer.plugin] +# name = "G-code Stamp" +# description = "Stamps a comment line into the exported G-code at the post-process step (demo)." +# author = "OrcaSlicer" +# version = "0.01" +# type = "slicing-pipeline" +# +# [tool.orcaslicer.plugin.settings] +# stamp_text = "processed by the OrcaSlicer G-code Stamp plugin" +# /// +"""G-code Stamp -- the post-processing half of the slicing-pipeline plugin. + +Post-processing is now a step of the slicing pipeline: Step.psGCodePostProcess. +It fires from the G-code export path AFTER the classic post_process scripts, on the +exported G-code file -- NOT from Print::process(). So unlike the geometry steps +(posSlice, posPerimeters, ...) there is no live slicing graph here: ctx.print and +ctx.object are None. Instead the context carries ctx.gcode_path (the working G-code +file on disk, edited IN PLACE), ctx.host ("File", "OctoPrint", ...) and +ctx.output_name (the final file name). ctx.params and ctx.config_value() still work. + +This sample inserts a single comment line near the top of the file. Because the same +capability class can also implement the geometry steps, one plugin can transform slices +AND stamp the final G-code; a geometry-only plugin just returns success here. + +The step may fire more than once per slice (file export and/or upload each run it on a +separate working copy), and its output is not reflected in the G-code preview -- the +viewer maps the pre-post-process file. +""" +import orca + +_DEFAULT_STAMP = "processed by the OrcaSlicer G-code Stamp plugin" + + +def _stamp_text(ctx): + try: + text = dict(ctx.params).get("stamp_text", _DEFAULT_STAMP) + except (AttributeError, TypeError): + text = _DEFAULT_STAMP + return str(text).replace("\n", " ").strip() or _DEFAULT_STAMP + + +class GCodeStamp(orca.slicing.SlicingPipelineCapabilityBase): + def get_name(self): + return "G-code Stamp" + + def execute(self, ctx): + # Only act at the post-process seam; at every geometry step this is a no-op. + if ctx.step != orca.slicing.Step.psGCodePostProcess: + return orca.ExecutionResult.success() + if not ctx.gcode_path: + return orca.ExecutionResult.success("G-code Stamp: no gcode_path, nothing to do") + + comment = "; " + _stamp_text(ctx) + " (host=" + (ctx.host or "?") + ")\n" + + # Edit the exported G-code in place: keep the original first line first (some flavors + # expect a specific leading line), then insert the stamp right after it. + with open(ctx.gcode_path, "r", encoding="utf-8", errors="replace") as f: + lines = f.readlines() + insert_at = 1 if lines else 0 + lines.insert(insert_at, comment) + with open(ctx.gcode_path, "w", encoding="utf-8") as f: + f.writelines(lines) + + return orca.ExecutionResult.success( + "G-code Stamp: stamped '" + (ctx.output_name or ctx.gcode_path) + "'") + + +@orca.plugin +class GCodeStampPackage(orca.base): + def register_capabilities(self): + orca.register_capability(GCodeStamp) diff --git a/src/libslic3r/Config.hpp b/src/libslic3r/Config.hpp index 07d71a52c6..0d776c7599 100644 --- a/src/libslic3r/Config.hpp +++ b/src/libslic3r/Config.hpp @@ -2444,9 +2444,9 @@ public: // "serialized" - vector valued option is entered in a single edit field. Values are separated by a semicolon. // "show_value" - even if enum_values / enum_labels are set, still display the value, not the enum label. std::string gui_flags; - // Capability type of a plugin-backed option, e.g. "post-processing" / "slicing-pipeline" / - // "printer-connection" (empty for ordinary options). GUIType::plugin_picker filters the plugin - // list by it, and it resolves the option's "plugins" manifest reference; see is_plugin_backed(). + // Capability type of a plugin-backed option, e.g. "slicing-pipeline" / "printer-connection" + // (empty for ordinary options). GUIType::plugin_picker filters the plugin list by it, and it + // resolves the option's "plugins" manifest reference; see is_plugin_backed(). std::string plugin_type; // Whether this option holds plugin capability name(s) that feed the "plugins" manifest -- true // iff it declares a plugin_type. Setting plugin_type is the only step needed to add one. diff --git a/src/libslic3r/Preset.cpp b/src/libslic3r/Preset.cpp index 23d006b70d..be48fc2730 100644 --- a/src/libslic3r/Preset.cpp +++ b/src/libslic3r/Preset.cpp @@ -1192,7 +1192,6 @@ static std::vector s_Preset_print_options{ "min_feature_size", "min_bead_width", "post_process", - "post_process_plugin", "slicing_pipeline_plugin", "plugins", "process_change_extrusion_role_gcode", diff --git a/src/libslic3r/Print.cpp b/src/libslic3r/Print.cpp index dbbca5ab28..066a8a287f 100644 --- a/src/libslic3r/Print.cpp +++ b/src/libslic3r/Print.cpp @@ -125,8 +125,8 @@ bool Print::invalidate_state_by_config_options(const ConfigOptionResolver & /* n "printing_by_object_gcode", "filament_end_gcode", "post_process", - "post_process_plugin", - // "plugins" is the manifest backing post_process_plugin; like it, it only affects G-code export. + // "plugins" is the derived manifest backing the plugin-picker options; on its own it only + // affects G-code export. The specific option (e.g. slicing_pipeline_plugin) drives any re-slice. "plugins", "extruder_clearance_height_to_rod", "extruder_clearance_height_to_lid", diff --git a/src/libslic3r/Print.hpp b/src/libslic3r/Print.hpp index a3521dcc8e..9830878a1c 100644 --- a/src/libslic3r/Print.hpp +++ b/src/libslic3r/Print.hpp @@ -101,7 +101,10 @@ enum PrintObjectStep { enum class SlicingPipelineStepPlugin { posSlice, posPerimeters, posEstimateCurledExtrusions, posPrepareInfill, posInfill, posIroning, posContouring, - posSupportMaterial, posDetectOverhangsForLift, posSimplifyPath, psWipeTower, psSkirtBrim + posSupportMaterial, posDetectOverhangsForLift, posSimplifyPath, psWipeTower, psSkirtBrim, + // Fires from the GUI G-code export/post-process seam (PostProcessor.cpp), NOT from Print::process(). + // At this step the plugin edits the exported G-code file in place; see the binding for the full contract. + psGCodePostProcess }; // A PrintRegion object represents a group of volumes to print diff --git a/src/libslic3r/PrintConfig.cpp b/src/libslic3r/PrintConfig.cpp index f021af0478..7cd82a355c 100644 --- a/src/libslic3r/PrintConfig.cpp +++ b/src/libslic3r/PrintConfig.cpp @@ -5114,20 +5114,10 @@ void PrintConfigDef::init_fff_params() def->mode = comDevelop; def->set_default_value(new ConfigOptionStrings()); - def = this->add("post_process_plugin", coStrings); - def->label = L("Post-processing Plugin"); - def->tooltip = L("Select a Python plugin to process the output G-code. " - "Plugins are loaded from the orca_plugins directory in your data folder. " - "The plugin will receive the G-code file path and can modify it in place."); - def->gui_type = ConfigOptionDef::GUIType::plugin_picker; - def->plugin_type = "post-processing"; - def->full_width = true; - 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->tooltip = L("Python plugin(s) invoked at each slicing pipeline step to read and modify intermediate slicing data, " + "including a final G-code post-processing step. Research/experimental."); def->gui_type = ConfigOptionDef::GUIType::plugin_picker; def->plugin_type = "slicing-pipeline"; def->full_width = true; diff --git a/src/libslic3r/PrintConfig.hpp b/src/libslic3r/PrintConfig.hpp index 104e4a9c3d..192ea662bd 100644 --- a/src/libslic3r/PrintConfig.hpp +++ b/src/libslic3r/PrintConfig.hpp @@ -1570,7 +1570,6 @@ PRINT_CONFIG_CLASS_DERIVED_DEFINE( ((ConfigOptionBool, ooze_prevention)) ((ConfigOptionString, filename_format)) ((ConfigOptionStrings, post_process)) - ((ConfigOptionStrings, post_process_plugin)) ((ConfigOptionStrings, slicing_pipeline_plugin)) ((ConfigOptionString, printer_model)) ((ConfigOptionFloat, resolution)) diff --git a/src/slic3r/CMakeLists.txt b/src/slic3r/CMakeLists.txt index 29fb286cf1..f621e63d67 100644 --- a/src/slic3r/CMakeLists.txt +++ b/src/slic3r/CMakeLists.txt @@ -618,9 +618,6 @@ set(SLIC3R_GUI_SOURCES plugin/PluginAuditManager.hpp plugin/PluginResolver.cpp plugin/PluginResolver.hpp - plugin/pluginTypes/gcode/GCodePluginCapability.hpp - plugin/pluginTypes/gcode/GCodePluginCapability.cpp - plugin/pluginTypes/gcode/GCodePluginCapabilityTrampoline.hpp plugin/pluginTypes/printerAgent/PrinterAgentPluginCapability.hpp plugin/pluginTypes/printerAgent/PrinterAgentPluginCapability.cpp plugin/pluginTypes/printerAgent/PrinterAgentPluginCapabilityTrampoline.hpp diff --git a/src/slic3r/GUI/GUI_App.cpp b/src/slic3r/GUI/GUI_App.cpp index 3150eda1c8..8513af0f86 100644 --- a/src/slic3r/GUI/GUI_App.cpp +++ b/src/slic3r/GUI/GUI_App.cpp @@ -3182,7 +3182,7 @@ bool GUI_App::on_init_inner() static const char* const kStepNames[] = { "posSlice", "posPerimeters", "posEstimateCurledExtrusions", "posPrepareInfill", "posInfill", "posIroning", "posContouring", "posSupportMaterial", "posDetectOverhangsForLift", - "posSimplifyPath", "psWipeTower", "psSkirtBrim" + "posSimplifyPath", "psWipeTower", "psSkirtBrim", "psGCodePostProcess" }; // order must match Slic3r::SlicingPipelineStepPlugin const char* step_name = static_cast(step) < sizeof(kStepNames) / sizeof(kStepNames[0]) ? kStepNames[static_cast(step)] : "Unknown"; diff --git a/src/slic3r/GUI/PostProcessor.cpp b/src/slic3r/GUI/PostProcessor.cpp index 2e63835529..afb7932eb3 100644 --- a/src/slic3r/GUI/PostProcessor.cpp +++ b/src/slic3r/GUI/PostProcessor.cpp @@ -9,7 +9,7 @@ // file lives in the GUI layer (libslic3r must not depend on pybind11 / PluginManager). #include "libslic3r/Config.hpp" #include "slic3r/plugin/PluginManager.hpp" -#include "slic3r/plugin/pluginTypes/gcode/GCodePluginCapability.hpp" +#include "slic3r/plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.hpp" #include "slic3r/plugin/PythonInterpreter.hpp" #include @@ -241,27 +241,39 @@ void gcode_add_line_number(const std::string& path, const DynamicPrintConfig& co fs.close(); } -// Run the configured post-processing plugins on `gcode_path` in place. Plugins are executed in-process -// through the embedded Python interpreter. Throws Slic3r::RuntimeError on any failure; the caller is -// responsible for removing the working copy (see run_post_process_scripts' catch block). -// Entries are bare capability names; the top-level plugins manifest carries the full plugin refs. +// Run the configured slicing-pipeline plugins on `gcode_path` in place, at their Step.psGCodePostProcess +// seam. This is the same capability that runs at the geometry seams inside Print::process(); here it is +// dispatched a final time on the exported G-code, so a plugin can edit slices AND the final G-code from +// one class. Plugins are executed in-process through the embedded Python interpreter. Throws +// Slic3r::RuntimeError on any failure; the caller removes the working copy (see run_post_process_scripts' +// catch block). Entries are bare capability names; the top-level plugins manifest carries the full refs. +// A geometry-only plugin simply returns success here (it filters on ctx.step), so it costs nothing beyond +// one no-op call, but note any configured pipeline plugin still engages this post-process path (i.e. the +// non-BBL ".pp" working copy) even if it does no G-code work. static void run_post_process_plugins(const ConfigOptionStrings& capabilities, const ConfigOptionStrings* plugins, const std::string& gcode_path, const std::string& host, - const std::string& output_name) + const std::string& output_name, + const DynamicPrintConfig& config) { // Let plugins observe the (possibly script-updated) target file name, mirroring the script env. boost::nowide::setenv("SLIC3R_PP_OUTPUT_NAME", output_name.c_str(), 1); const boost::filesystem::path gcode_file(gcode_path); - auto execute_fn = [&](std::shared_ptr cap, const PluginCapabilityRef& ref) { - GCodePluginContext ctx; + auto execute_fn = [&](std::shared_ptr cap, const PluginCapabilityRef& ref) { + SlicingPipelineContext ctx; ctx.orca_version = SoftFever_VERSION; + ctx.step = SlicingPipelineStepPlugin::psGCodePostProcess; ctx.gcode_path = gcode_path; ctx.host = host; ctx.output_name = output_name; + ctx.full_config = &config; // no live Print here; config_value() reads this + // Hand the plugin its own [tool.orcaslicer.plugin.settings] as ctx.params (same plugin_key the + // capability was resolved by), mirroring the in-pipeline dispatcher in GUI_App.cpp. + const std::string plugin_key = ref.uuid.empty() ? ref.name : ref.uuid; + ctx.params = PluginManager::instance().get_loader().get_plugin_settings(plugin_key); ExecutionResult exec_result; try { @@ -298,11 +310,12 @@ static void run_post_process_plugins(const ConfigOptionStrings& capabilities, BOOST_LOG_TRIVIAL(info) << "Post-processing plugin " << ref.capability_name << " completed successfully"; }; - execute_capabilities_from_refs(capabilities, plugins, PluginCapabilityType::PostProcessing, execute_fn); + execute_capabilities_from_refs(capabilities, plugins, PluginCapabilityType::SlicingPipeline, execute_fn); } -// Run post-processing scripts ("post_process") and/or post-processing plugins ("post_process_plugin") -// if defined. Both run on the same working copy of the G-code (the ".pp" temp when make_copy), so a +// Run post-processing scripts ("post_process") and/or the slicing-pipeline plugins' psGCodePostProcess +// step ("slicing_pipeline_plugin") if defined. Both run on the same working copy of the G-code (the +// ".pp" temp when make_copy), so a // plugin never opens the original file the G-code viewer keeps memory-mapped (a writable open of the // mapped file fails on Windows with a sharing violation). // Returns true if a script or plugin was executed. @@ -317,11 +330,13 @@ static void run_post_process_plugins(const ConfigOptionStrings& capabilities, bool run_post_process_scripts( std::string& src_path, bool make_copy, const std::string& host, std::string& output_name, const DynamicPrintConfig& config) { - // post_process / post_process_plugin are absent in SLA mode, hence the null checks. - const auto* post_process = config.opt("post_process"); - const auto* post_process_plugin = config.opt("post_process_plugin"); - const bool have_scripts = post_process != nullptr && !post_process->values.empty(); - const bool have_plugins = post_process_plugin != nullptr && !post_process_plugin->values.empty(); + // post_process / slicing_pipeline_plugin are absent in SLA mode, hence the null checks. G-code + // post-processing is now the psGCodePostProcess step of the slicing-pipeline plugin, so the same + // slicing_pipeline_plugin option drives both the geometry seams and this final G-code seam. + const auto* post_process = config.opt("post_process"); + const auto* slicing_pipeline_plugin = config.opt("slicing_pipeline_plugin"); + const bool have_scripts = post_process != nullptr && !post_process->values.empty(); + const bool have_plugins = slicing_pipeline_plugin != nullptr && !slicing_pipeline_plugin->values.empty(); if (!have_scripts && !have_plugins) return false; @@ -469,7 +484,7 @@ bool run_post_process_scripts( // Run plugins after the scripts so they observe any output_name the scripts produced. A thrown // exception is handled by the catch below, which removes the temp copy. if (have_plugins) { - run_post_process_plugins(*post_process_plugin, config.opt("plugins"), path, host, output_name); + run_post_process_plugins(*slicing_pipeline_plugin, config.opt("plugins"), path, host, output_name, config); } } catch (...) { remove_output_name_file(); diff --git a/src/slic3r/GUI/PostProcessor.hpp b/src/slic3r/GUI/PostProcessor.hpp index c85c5a511c..1c0fc09240 100644 --- a/src/slic3r/GUI/PostProcessor.hpp +++ b/src/slic3r/GUI/PostProcessor.hpp @@ -9,9 +9,10 @@ namespace Slic3r { -// Run post-processing scripts (the "post_process" option) and/or post-processing plugins (the -// "post_process_plugin" option) if defined. Lives in the GUI layer because plugins are executed -// through the embedded-Python PluginManager, which libslic3r must not depend on. +// Run post-processing scripts (the "post_process" option) and/or the slicing-pipeline plugins' +// Step.psGCodePostProcess seam (the "slicing_pipeline_plugin" option) if defined. Lives in the GUI +// layer because plugins are executed through the embedded-Python PluginManager, which libslic3r must +// not depend on. // Returns true if a script or plugin was executed. // Returns false if neither a post-processing script nor plugin was defined. // Throws an exception on error. diff --git a/src/slic3r/GUI/Tab.cpp b/src/slic3r/GUI/Tab.cpp index fcb5c575ea..b8e469ea92 100644 --- a/src/slic3r/GUI/Tab.cpp +++ b/src/slic3r/GUI/Tab.cpp @@ -1794,7 +1794,7 @@ void Tab::on_value_change(const std::string& opt_key, const boost::any& value) // always carries resolved "name;uuid;capability" references that full_config() and save_to_json() // then pass downstream as-is -- no separate rebuild anywhere else. if (const ConfigOptionDef* opt_def = m_config->def()->get(opt_key); - opt_def && opt_def->plugin_type != ConfigOptionDef::PluginType::None) + opt_def && opt_def->is_plugin_backed()) m_config->update_plugin_manifest(); if (opt_key == "gcode_flavor" && m_type == Preset::TYPE_PRINTER) { @@ -3080,12 +3080,6 @@ void TabPrint::build() option.opt.height = 15; optgroup->append_single_option_line(option, "others_settings_post_processing_scripts"); - optgroup = page->new_optgroup(L("Post-processing Plugin"), L"param_gcode", 0); - optgroup->hide_labels(); - option = optgroup->get_option("post_process_plugin"); - 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"); diff --git a/src/slic3r/plugin/PluginLoader.cpp b/src/slic3r/plugin/PluginLoader.cpp index 6d3f90651c..3f0257d6c0 100644 --- a/src/slic3r/plugin/PluginLoader.cpp +++ b/src/slic3r/plugin/PluginLoader.cpp @@ -610,7 +610,6 @@ bool PluginLoader::unload_plugin(const std::string& plugin_key, PluginCapability if (!torn_down_types.insert(cap_type).second) continue; switch (cap_type) { - case PluginCapabilityType::PostProcessing: break; case PluginCapabilityType::PrinterConnection: NetworkAgentFactory::deregister_python_plugin(plugin_key); break; default: break; } diff --git a/src/slic3r/plugin/PythonPluginBridge.cpp b/src/slic3r/plugin/PythonPluginBridge.cpp index 2c12e04975..89b9f8eb6f 100644 --- a/src/slic3r/plugin/PythonPluginBridge.cpp +++ b/src/slic3r/plugin/PythonPluginBridge.cpp @@ -13,7 +13,6 @@ #include "PluginHostApi.hpp" #include "PyPluginPackage.hpp" #include "PyPluginTrampoline.hpp" -#include "pluginTypes/gcode/GCodePluginCapability.hpp" #include "pluginTypes/printerAgent/PrinterAgentPluginCapability.hpp" #include "pluginTypes/script/ScriptPluginCapability.hpp" #include "pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.hpp" @@ -287,7 +286,6 @@ void bind_python_api(pybind11::module_& m) m.doc() = "OrcaSlicer plugin API"; auto pluginTypes = py::enum_(m, "PluginType", "Available plugin capability groups") - .value("PostProcessing", PluginCapabilityType::PostProcessing) .value("PrinterConnection", PluginCapabilityType::PrinterConnection) .value("Automation", PluginCapabilityType::Automation) .value("Analysis", PluginCapabilityType::Analysis) @@ -336,7 +334,6 @@ void bind_python_api(pybind11::module_& m) BOOST_LOG_TRIVIAL(debug) << "Registering embedded Python plugin type bindings"; // Make sure you register your bindings here - GCodePluginCapability::RegisterBindings(m, pluginTypes); PrinterAgentPluginCapability::RegisterBindings(m, pluginTypes); ScriptPluginCapability::RegisterBindings(m, pluginTypes); SlicingPipelinePluginCapability::RegisterBindings(m, pluginTypes); diff --git a/src/slic3r/plugin/PythonPluginInterface.hpp b/src/slic3r/plugin/PythonPluginInterface.hpp index abb552f8e3..c3232c3b98 100644 --- a/src/slic3r/plugin/PythonPluginInterface.hpp +++ b/src/slic3r/plugin/PythonPluginInterface.hpp @@ -10,12 +10,11 @@ namespace Slic3r { -enum class PluginCapabilityType { PostProcessing = 0, PrinterConnection, Automation, Analysis, Importer, Exporter, Visualization, Script, SlicingPipeline, Unknown }; +enum class PluginCapabilityType { PrinterConnection = 0, Automation, Analysis, Importer, Exporter, Visualization, Script, SlicingPipeline, Unknown }; inline std::string plugin_capability_type_to_string(PluginCapabilityType type) { switch (type) { - case PluginCapabilityType::PostProcessing: return "post-processing"; case PluginCapabilityType::PrinterConnection: return "printer-connection"; case PluginCapabilityType::Automation: return "automation"; case PluginCapabilityType::Analysis: return "analysis"; @@ -31,7 +30,6 @@ inline std::string plugin_capability_type_to_string(PluginCapabilityType type) inline std::string plugin_capability_type_display_name(PluginCapabilityType type) { switch (type) { - case PluginCapabilityType::PostProcessing: return "Post-processing"; case PluginCapabilityType::PrinterConnection: return "Printer connection"; case PluginCapabilityType::Automation: return "Automation"; case PluginCapabilityType::Analysis: return "Analysis"; @@ -53,8 +51,6 @@ inline PluginCapabilityType plugin_capability_type_from_string(std::string_view lowered.push_back(to_lower(ch)); } - if (lowered == "post-processing") - return PluginCapabilityType::PostProcessing; if (lowered == "printer-connection") return PluginCapabilityType::PrinterConnection; if (lowered == "automation") diff --git a/src/slic3r/plugin/pluginTypes/gcode/GCodePluginCapability.cpp b/src/slic3r/plugin/pluginTypes/gcode/GCodePluginCapability.cpp deleted file mode 100644 index 0639bdb774..0000000000 --- a/src/slic3r/plugin/pluginTypes/gcode/GCodePluginCapability.cpp +++ /dev/null @@ -1,30 +0,0 @@ -#include "GCodePluginCapability.hpp" - -#include "GCodePluginCapabilityTrampoline.hpp" - -#include -#include - -namespace py = pybind11; - -namespace Slic3r { - -void GCodePluginCapability::RegisterBindings(pybind11::module_& module, pybind11::enum_& pluginTypes) -{ - (void) pluginTypes; - - auto gcode = module.def_submodule("gcode", "G-code API"); - - py::class_(gcode, "GCodePluginContext", "Context shared with G-code plugins") - .def(py::init<>()) - .def_readwrite("gcode_path", &GCodePluginContext::gcode_path) - .def_readwrite("host", &GCodePluginContext::host) - .def_readwrite("output_name", &GCodePluginContext::output_name); - - py::class_>(gcode, "GCodePluginCapabilityBase") - .def(py::init<>()) - .def("get_type", &GCodePluginCapability::get_type) - .def("execute", &GCodePluginCapability::execute); -} - -} // namespace Slic3r diff --git a/src/slic3r/plugin/pluginTypes/gcode/GCodePluginCapability.hpp b/src/slic3r/plugin/pluginTypes/gcode/GCodePluginCapability.hpp deleted file mode 100644 index d935fcff1d..0000000000 --- a/src/slic3r/plugin/pluginTypes/gcode/GCodePluginCapability.hpp +++ /dev/null @@ -1,27 +0,0 @@ -#ifndef slic3r_GCodePluginCapability_hpp_ -#define slic3r_GCodePluginCapability_hpp_ - -#include "../../PythonPluginInterface.hpp" - -namespace Slic3r { - -struct GCodePluginContext : public PluginContext { - std::string gcode_path; - std::string host; - std::string output_name; -}; - -class GCodePluginCapability : public PluginCapabilityInterface -{ -public: - PluginCapabilityType get_type() const override { return PluginCapabilityType::PostProcessing; } - - virtual ExecutionResult execute(const GCodePluginContext& ctx) = 0; - - static void RegisterBindings(pybind11::module_ &module, - pybind11::enum_ &pluginTypes); -}; - -} // namespace Slic3r - -#endif /* slic3r_GCodePluginCapability_hpp_ */ diff --git a/src/slic3r/plugin/pluginTypes/gcode/GCodePluginCapabilityTrampoline.hpp b/src/slic3r/plugin/pluginTypes/gcode/GCodePluginCapabilityTrampoline.hpp deleted file mode 100644 index 83bcdc1a70..0000000000 --- a/src/slic3r/plugin/pluginTypes/gcode/GCodePluginCapabilityTrampoline.hpp +++ /dev/null @@ -1,35 +0,0 @@ -#ifndef slic3r_GCodePluginCapabilityTrampoline_hpp_ -#define slic3r_GCodePluginCapabilityTrampoline_hpp_ - -#include - -#include "../../PyPluginTrampoline.hpp" -#include "../../PluginAuditManager.hpp" -#include "GCodePluginCapability.hpp" - -namespace Slic3r { -class PyGCodePluginCapabilityTrampoline : public PyPluginCommonTrampoline -{ -public: - using PyPluginCommonTrampoline::PyPluginCommonTrampoline; - - ExecutionResult execute(const GCodePluginContext& ctx) override - { - ORCA_PY_OVERRIDE_AUDITED( - ::Slic3r::PluginAuditManager::AuditMode::Loading, - [&] { - // G-code post-processing plugins may also write into the folder holding the - // current temp G-code file, in addition to the globally-allowed data_dir(). - // The setup callback runs AFTER the context is constructed so the scoped root - // is not cleared by ScopedPluginAuditContext's constructor. - - if (!ctx.gcode_path.empty()) - ::Slic3r::PluginAuditManager::instance().add_scoped_allowed_root( - std::filesystem::path(ctx.gcode_path).parent_path()); - }, - PYBIND11_OVERRIDE_PURE, ExecutionResult, GCodePluginCapability, execute, ctx); - } -}; -} // namespace Slic3r - -#endif diff --git a/src/slic3r/plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.cpp b/src/slic3r/plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.cpp index bd2bca71c7..2828c0496e 100644 --- a/src/slic3r/plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.cpp +++ b/src/slic3r/plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.cpp @@ -26,6 +26,13 @@ void SlicingPipelinePluginCapability::RegisterBindings(py::module_& module, py:: .value("posSimplifyPath", SlicingPipelineStepPlugin::posSimplifyPath) // covers all simplify sub-steps .value("psWipeTower", SlicingPipelineStepPlugin::psWipeTower) .value("psSkirtBrim", SlicingPipelineStepPlugin::psSkirtBrim) + // Post-process seam: fires in the GUI export path AFTER the classic post_process scripts, on the + // exported G-code file. Unlike every step above it is NOT fired by Print::process(): ctx.print and + // ctx.object are None; instead ctx.gcode_path / ctx.host / ctx.output_name are set and the plugin + // edits the file at ctx.gcode_path IN PLACE. May fire more than once per slice (file export and/or + // upload each fire once, on separate working copies) and its output is not reflected in the G-code + // preview (the viewer maps the pre-post-process file). ctx.config_value()/ctx.params still work. + .value("psGCodePostProcess", SlicingPipelineStepPlugin::psGCodePostProcess) .export_values(); // The read-graph data model (Surface / ExPolygon / the extrusion tree / LayerRegion / @@ -44,6 +51,14 @@ void SlicingPipelinePluginCapability::RegisterBindings(py::module_& module, py:: .def_readonly("params", &SlicingPipelineContext::params, "read-only dict of this plugin's [tool.orcaslicer.plugin.settings] values " "(string->string). Parse the values you need, e.g. float(ctx.params['rate']).") + .def_readonly("gcode_path", &SlicingPipelineContext::gcode_path, + "Path to the working G-code file, set ONLY at Step.psGCodePostProcess. Edit it in " + "place; empty at every other step.") + .def_readonly("host", &SlicingPipelineContext::host, + "Target host at Step.psGCodePostProcess (\"File\", \"OctoPrint\", ...); empty otherwise.") + .def_readonly("output_name", &SlicingPipelineContext::output_name, + "Final output G-code name at Step.psGCodePostProcess (mirrors SLIC3R_PP_OUTPUT_NAME); " + "empty otherwise.") .def_property_readonly("print", [](const SlicingPipelineContext& ctx) -> py::object { if (ctx.print == nullptr) return py::none(); @@ -61,9 +76,13 @@ void SlicingPipelinePluginCapability::RegisterBindings(py::module_& module, py:: }, "orca.host.PrintObject for object-scoped steps, or None for print-wide steps. " "Valid only during the execute(ctx) call.") .def("config_value", [](const SlicingPipelineContext& ctx, const std::string& key) -> py::object { - if (ctx.print == nullptr) - return py::none(); - return config_value_or_none(ctx.print->full_print_config(), key); + // In-pipeline steps read the live Print's full config; at psGCodePostProcess (print == null) + // fall back to the config the export path handed in. + if (ctx.print != nullptr) + return config_value_or_none(ctx.print->full_print_config(), key); + if (ctx.full_config != nullptr) + return config_value_or_none(*ctx.full_config, key); + return py::none(); }, py::arg("key"), "serialized value of a resolved (full) print config option for this slice, or " "None if absent. Shorthand for ctx.print.config_value(key).") diff --git a/src/slic3r/plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.hpp b/src/slic3r/plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.hpp index d3c30ba92c..c9a504f6be 100644 --- a/src/slic3r/plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.hpp +++ b/src/slic3r/plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.hpp @@ -16,13 +16,23 @@ namespace Slic3r { struct SlicingPipelineContext { std::string orca_version; SlicingPipelineStepPlugin step { SlicingPipelineStepPlugin::posSlice }; - Print* print { nullptr }; // always present when dispatched - const PrintObject* object { nullptr }; // null for print-wide steps + Print* print { nullptr }; // present for in-pipeline steps; null at psGCodePostProcess + const PrintObject* object { nullptr }; // null for print-wide steps and psGCodePostProcess // read-only per-plugin settings, populated by the dispatcher from the // plugin's [tool.orcaslicer.plugin.settings] PEP-723 table. Exposed as // ctx.params (dict of string->string). std::map params; - bool cancelled() const; // -> print->canceled() + // Populated ONLY at Step.psGCodePostProcess (the GUI G-code export/post-process seam, + // PostProcessor.cpp). gcode_path is the working G-code file on disk that the plugin edits + // in place; host is the target ("File", "OctoPrint", ...); output_name mirrors + // SLIC3R_PP_OUTPUT_NAME. Empty at every other step. + std::string gcode_path; + std::string host; + std::string output_name; + // C++-only config fallback for psGCodePostProcess (no live Print graph there): config_value() + // reads it when `print` is null. Not exposed to Python directly. Never dereferenced elsewhere. + const DynamicPrintConfig* full_config { nullptr }; + bool cancelled() const; // -> print->canceled() (false when print is null) }; class SlicingPipelinePluginCapability : public PluginCapabilityInterface { diff --git a/src/slic3r/plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapabilityTrampoline.hpp b/src/slic3r/plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapabilityTrampoline.hpp index c979e4f086..5dc14c74b9 100644 --- a/src/slic3r/plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapabilityTrampoline.hpp +++ b/src/slic3r/plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapabilityTrampoline.hpp @@ -1,6 +1,8 @@ #pragma once #include "SlicingPipelinePluginCapability.hpp" #include "slic3r/plugin/PyPluginTrampoline.hpp" +#include "slic3r/plugin/PluginAuditManager.hpp" +#include namespace Slic3r { class PySlicingPipelinePluginCapabilityTrampoline : public PyPluginCommonTrampoline { @@ -9,7 +11,18 @@ public: ExecutionResult execute(SlicingPipelineContext& ctx) override { ORCA_PY_OVERRIDE_AUDITED( ::Slic3r::PluginAuditManager::AuditMode::Loading, - []{}, PYBIND11_OVERRIDE_PURE, + [&]{ + // At Step.psGCodePostProcess the plugin edits the exported G-code file, which lives + // outside data_dir() (a temp/output folder), so writing to it would otherwise be + // blocked by the audit sandbox. Grant that folder as a scoped allowed root, mirroring + // the former G-code post-processing trampoline. The setup callback runs AFTER the + // audit context is constructed, so the scoped root is not cleared by its constructor. + // Empty at every other step, so no extra access is granted to the geometry hooks. + if (!ctx.gcode_path.empty()) + ::Slic3r::PluginAuditManager::instance().add_scoped_allowed_root( + std::filesystem::path(ctx.gcode_path).parent_path()); + }, + PYBIND11_OVERRIDE_PURE, ExecutionResult, SlicingPipelinePluginCapability, execute, ctx); } }; diff --git a/tests/fff_print/test_slicing_pipeline_hook.cpp b/tests/fff_print/test_slicing_pipeline_hook.cpp index 1f7db91e4c..9e035afea4 100644 --- a/tests/fff_print/test_slicing_pipeline_hook.cpp +++ b/tests/fff_print/test_slicing_pipeline_hook.cpp @@ -44,20 +44,22 @@ TEST_CASE("SlicingPipeline hook fires once per step per object in order", "[slic using S = Slic3r::SlicingPipelineStepPlugin; 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::PrepareInfill) == 1); // the prepare-infill seam fires once per object - CHECK(count(S::Infill) == 1); - CHECK(count(S::WipeTower) == 1); - CHECK(count(S::SkirtBrim) == 1); + CHECK(count(S::posSlice) == 1); + CHECK(count(S::posPerimeters) == 1); + CHECK(count(S::posPrepareInfill) == 1); // the prepare-infill seam fires once per object + CHECK(count(S::posInfill) == 1); + CHECK(count(S::psWipeTower) == 1); + CHECK(count(S::psSkirtBrim) == 1); + // psGCodePostProcess fires from the GUI export path, never from process(): + CHECK(count(S::psGCodePostProcess) == 0); // 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); + if (c.step == S::psWipeTower || c.step == S::psSkirtBrim) CHECK(c.obj == nullptr); // Slice must fire before Perimeters for the same object: auto idx = [&](S s){ for (size_t i=0;i @@ -418,10 +420,10 @@ TEST_CASE("fill_surfaces mutation cascades at PrepareInfill but not at Infill", return n; }; using S = Slic3r::SlicingPipelineStepPlugin; - const size_t base = fill_paths(false, S::PrepareInfill); // active hook, no mutation + const size_t base = fill_paths(false, S::posPrepareInfill); // active hook, no mutation CHECK(base > 0); - CHECK(fill_paths(true, S::PrepareInfill) < base); // mutation before make_fills cascades - CHECK(fill_paths(true, S::Infill) == base); // mutation after make_fills is a no-op (v1) + CHECK(fill_paths(true, S::posPrepareInfill) < base); // mutation before make_fills cascades + CHECK(fill_paths(true, S::posInfill) == base); // mutation after make_fills is a no-op (v1) } // lslices (the layer's merged islands) are built once in slice() and never rebuilt by diff --git a/tests/libslic3r/test_config.cpp b/tests/libslic3r/test_config.cpp index 38d857c765..071f4e359a 100644 --- a/tests/libslic3r/test_config.cpp +++ b/tests/libslic3r/test_config.cpp @@ -410,14 +410,14 @@ TEST_CASE("save_to_json round-trips plugin capability references as strings", "[ namespace fs = boost::filesystem; const fs::path tmp = fs::temp_directory_path() / fs::unique_path("orca_plugins_%%%%-%%%%.json"); const std::vector refs = { - "local_plugin;;post_process", - "cloud_plugin;550e8400-e29b-41d4-a716-446655440000;post_process" + "local_plugin;;inset", + "cloud_plugin;550e8400-e29b-41d4-a716-446655440000;inset" }; std::unique_ptr config_ptr( - DynamicPrintConfig::new_from_defaults_keys({"post_process_plugin"})); + DynamicPrintConfig::new_from_defaults_keys({"slicing_pipeline_plugin"})); DynamicPrintConfig config = std::move(*config_ptr); - config.option("post_process_plugin", true)->values = refs; + config.option("slicing_pipeline_plugin", true)->values = refs; config.save_to_json(tmp.string(), "test_preset", "User", "1.0.0.0"); nlohmann::json j; @@ -425,7 +425,7 @@ TEST_CASE("save_to_json round-trips plugin capability references as strings", "[ boost::nowide::ifstream ifs(tmp.string()); ifs >> j; } - REQUIRE(j["post_process_plugin"] == nlohmann::json(refs)); + REQUIRE(j["slicing_pipeline_plugin"] == nlohmann::json(refs)); CHECK_FALSE(j.contains("plugins")); DynamicPrintConfig reloaded = DynamicPrintConfig::full_print_config(); @@ -434,7 +434,7 @@ TEST_CASE("save_to_json round-trips plugin capability references as strings", "[ std::string reason; REQUIRE(reloaded.load_from_json(tmp.string(), substitutions, true, key_values, reason) == 0); CHECK(reason.empty()); - CHECK(reloaded.option("post_process_plugin")->values == refs); + CHECK(reloaded.option("slicing_pipeline_plugin")->values == refs); fs::remove(tmp); } @@ -446,17 +446,17 @@ TEST_CASE("plugin capability references survive string-map serialization", "[Con }; DynamicPrintConfig original = DynamicPrintConfig::full_print_config(); - original.option("post_process_plugin", true)->values = refs; + original.option("slicing_pipeline_plugin", true)->values = refs; std::map serialized{ - {"post_process_plugin", original.option("post_process_plugin")->serialize()} + {"slicing_pipeline_plugin", original.option("slicing_pipeline_plugin")->serialize()} }; - CHECK(serialized["post_process_plugin"].find("\"master_plugin;;header-stamp\"") != std::string::npos); + CHECK(serialized["slicing_pipeline_plugin"].find("\"master_plugin;;header-stamp\"") != std::string::npos); DynamicPrintConfig reloaded = DynamicPrintConfig::full_print_config(); reloaded.load_string_map(serialized, ForwardCompatibilitySubstitutionRule::Disable); - CHECK(reloaded.option("post_process_plugin")->values == refs); + CHECK(reloaded.option("slicing_pipeline_plugin")->values == refs); } TEST_CASE("parse_capability_ref parses local and cloud references", "[Config][plugin]") { @@ -502,14 +502,13 @@ struct PluginResolverFixture { TEST_CASE_METHOD(PluginResolverFixture, "update_plugin_manifest derives references generically from plugin-backed options", "[Config][plugins]") { - // Both scalar (printer_agent) and vector (post_process_plugin, slicing_pipeline_plugin) options - // opt in via a non-empty ConfigOptionDef::plugin_type (is_plugin_backed) and are resolved with it - // -- there is no hardcoded per-option switch. printer_agent in particular relies on its plugin_type - // metadata being wired up (it is edited via a dedicated widget, not the plugin_picker). + // Both scalar (printer_agent) and vector (slicing_pipeline_plugin) options opt in via a non-empty + // ConfigOptionDef::plugin_type (is_plugin_backed) and are resolved with it -- there is no hardcoded + // per-option switch. printer_agent in particular relies on its plugin_type metadata being wired up + // (it is edited via a dedicated widget, not the plugin_picker). std::unique_ptr config_ptr(DynamicPrintConfig::new_from_defaults_keys( - {"post_process_plugin", "slicing_pipeline_plugin", "printer_agent"})); + {"slicing_pipeline_plugin", "printer_agent"})); DynamicPrintConfig config = std::move(*config_ptr); - config.option("post_process_plugin", true)->values = {"pp"}; config.option("slicing_pipeline_plugin", true)->values = {"sp"}; config.option("printer_agent", true)->value = "agent"; @@ -517,23 +516,22 @@ TEST_CASE_METHOD(PluginResolverFixture, const std::vector manifest = config.option("plugins")->values; using Catch::Matchers::VectorContains; - REQUIRE_THAT(manifest, VectorContains(std::string("pp;;post-processing"))); REQUIRE_THAT(manifest, VectorContains(std::string("sp;;slicing-pipeline"))); REQUIRE_THAT(manifest, VectorContains(std::string("agent;;printer-connection"))); - CHECK(manifest.size() == 3); + CHECK(manifest.size() == 2); } TEST_CASE_METHOD(PluginResolverFixture, "update_plugin_manifest de-duplicates references and skips unset options", "[Config][plugins]") { std::unique_ptr config_ptr(DynamicPrintConfig::new_from_defaults_keys( - {"post_process_plugin", "printer_agent"})); + {"slicing_pipeline_plugin", "printer_agent"})); DynamicPrintConfig config = std::move(*config_ptr); - config.option("post_process_plugin", true)->values = {"x", "x"}; // duplicate + config.option("slicing_pipeline_plugin", true)->values = {"x", "x"}; // duplicate // printer_agent stays at its default empty value -> contributes nothing to the manifest. config.update_plugin_manifest(); const std::vector manifest = config.option("plugins")->values; - CHECK(manifest == std::vector{"x;;post-processing"}); + CHECK(manifest == std::vector{"x;;slicing-pipeline"}); } diff --git a/tests/slic3rutils/test_plugin_capability_identifier.cpp b/tests/slic3rutils/test_plugin_capability_identifier.cpp index fe9c94d3e7..575ac4c8a5 100644 --- a/tests/slic3rutils/test_plugin_capability_identifier.cpp +++ b/tests/slic3rutils/test_plugin_capability_identifier.cpp @@ -8,9 +8,9 @@ using Slic3r::PluginCapabilityIdentifier; using Slic3r::PluginCapabilityType; TEST_CASE("PluginCapabilityIdentifier equality includes plugin_key", "[plugin][identifier]") { - PluginCapabilityIdentifier a{PluginCapabilityType::PostProcessing, "Cleanup", "a.py"}; - PluginCapabilityIdentifier b{PluginCapabilityType::PostProcessing, "Cleanup", "b.py"}; - PluginCapabilityIdentifier a2{PluginCapabilityType::PostProcessing, "Cleanup", "a.py"}; + PluginCapabilityIdentifier a{PluginCapabilityType::SlicingPipeline, "Cleanup", "a.py"}; + PluginCapabilityIdentifier b{PluginCapabilityType::SlicingPipeline, "Cleanup", "b.py"}; + PluginCapabilityIdentifier a2{PluginCapabilityType::SlicingPipeline, "Cleanup", "a.py"}; CHECK(a == a2); CHECK_FALSE(a == b); // same (type,name), different plugin_key -> distinct @@ -18,8 +18,8 @@ TEST_CASE("PluginCapabilityIdentifier equality includes plugin_key", "[plugin][i TEST_CASE("PluginCapabilityIdentifier is usable as a hash-map key", "[plugin][identifier]") { std::unordered_map m; - m[{PluginCapabilityType::PostProcessing, "Cleanup", "a.py"}] = 1; - m[{PluginCapabilityType::PostProcessing, "Cleanup", "b.py"}] = 2; // no collision + m[{PluginCapabilityType::SlicingPipeline, "Cleanup", "a.py"}] = 1; + m[{PluginCapabilityType::SlicingPipeline, "Cleanup", "b.py"}] = 2; // no collision CHECK(m.size() == 2); - CHECK(m.at({PluginCapabilityType::PostProcessing, "Cleanup", "a.py"}) == 1); + CHECK(m.at({PluginCapabilityType::SlicingPipeline, "Cleanup", "a.py"}) == 1); } diff --git a/tests/slic3rutils/test_slicing_pipeline_bindings.cpp b/tests/slic3rutils/test_slicing_pipeline_bindings.cpp index 4a6d36ff01..35f49241b6 100644 --- a/tests/slic3rutils/test_slicing_pipeline_bindings.cpp +++ b/tests/slic3rutils/test_slicing_pipeline_bindings.cpp @@ -81,7 +81,8 @@ TEST_CASE("orca.slicing module: Step enum, context, and a Python capability can 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.attr("Step"), "posSlice")); + CHECK(py::hasattr(slicing.attr("Step"), "psGCodePostProcess")); CHECK(py::hasattr(slicing, "SlicingPipelineContext")); CHECK(py::hasattr(slicing, "SlicingPipelineCapabilityBase")); @@ -126,6 +127,79 @@ TEST_CASE("orca.slicing is workflow-only: context exposes raw print/object; view CHECK(pyctx.attr("object").is_none()); } +#include "libslic3r/PrintConfig.hpp" // DynamicPrintConfig for the psGCodePostProcess context +#include +#include +#include + +// psGCodePostProcess is the merged post-processing seam: no live Print (print/object are None), the +// plugin edits the file at ctx.gcode_path in place, and ctx.config_value() falls back to the config +// the export path handed in. Exercising the real bindings by calling the Python execute() directly +// (not the C++ audit trampoline) keeps this a pure binding-surface test. +TEST_CASE("orca.slicing psGCodePostProcess context: file edit in place + config fallback", "[slicing_pipeline]") { + namespace fs = boost::filesystem; + ensure_python_initialized(); + import_orca_module(); + py::gil_scoped_acquire gil; + + const fs::path gpath = fs::temp_directory_path() / fs::unique_path("orca_pp_%%%%-%%%%.gcode"); + { + boost::nowide::ofstream ofs(gpath.string()); + ofs << "; header\nG1 X0 Y0\n"; + } + + // Config the plugin reads back through ctx.config_value() (there is no live Print at this step). + Slic3r::DynamicPrintConfig config = Slic3r::DynamicPrintConfig::full_print_config(); + config.set_key_value("layer_height", new Slic3r::ConfigOptionFloat(0.2)); + + Slic3r::SlicingPipelineContext ctx; + ctx.orca_version = "test"; + ctx.step = Slic3r::SlicingPipelineStepPlugin::psGCodePostProcess; + ctx.gcode_path = gpath.string(); + ctx.host = "File"; + ctx.output_name = "final.gcode"; + ctx.full_config = &config; // print stays null + + py::object pyctx = py::cast(&ctx, py::return_value_policy::reference); + CHECK(pyctx.attr("gcode_path").cast() == gpath.string()); + CHECK(pyctx.attr("host").cast() == "File"); + CHECK(pyctx.attr("output_name").cast() == "final.gcode"); + CHECK(pyctx.attr("print").is_none()); + CHECK(pyctx.attr("object").is_none()); + CHECK(pyctx.attr("step").cast() + == Slic3r::SlicingPipelineStepPlugin::psGCodePostProcess); + CHECK_FALSE(pyctx.attr("cancelled")().cast()); // null print -> not cancelled + // config_value() resolves from full_config when print is null; unknown keys are None. + CHECK_FALSE(pyctx.attr("config_value")("layer_height").is_none()); + CHECK(pyctx.attr("config_value")("this_key_does_not_exist").is_none()); + + // A Python capability edits the file in place through ctx.gcode_path. Calling execute() directly + // in Python dispatches to the Python method (no C++ trampoline), so this needs no audit context. + py::module_ main = py::module_::import("__main__"); + main.attr("_pp_ctx") = pyctx; + py::exec(R"( +import orca +class Stamp(orca.slicing.SlicingPipelineCapabilityBase): + def get_name(self): return "stamp" + def execute(self, ctx): + assert ctx.step == orca.slicing.Step.psGCodePostProcess + assert ctx.print is None and ctx.object is None + with open(ctx.gcode_path, "a") as f: + f.write("; stamped by " + ctx.host + "\n") + return orca.ExecutionResult.success("ok") +_pp_result = Stamp().execute(_pp_ctx) + )"); + CHECK(main.attr("_pp_result").attr("message").cast() == std::string("ok")); + + std::string contents; + { + boost::nowide::ifstream ifs(gpath.string()); + std::stringstream ss; ss << ifs.rdbuf(); contents = ss.str(); + } + CHECK(contents.find("; stamped by File") != std::string::npos); + fs::remove(gpath); +} + // --------------------------------------------------------------------------- // Toolpath helpers for the raw-graph tests. // From a04ce5f81eeb61d650aa3b37397025a307795ffd Mon Sep 17 00:00:00 2001 From: SoftFever Date: Sat, 11 Jul 2026 03:30:05 +0800 Subject: [PATCH 06/10] docs(plugin): make comments self-contained; drop design-doc and dead-code references MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review the slicing-pipeline plugin comments for context a reader of the source alone cannot follow, and rewrite them to stand on their own: - drop pointers to uncommitted design/plan material ("§3.6 (Twistify design)", "the brief's note", "Fix 4(a)/4(b)") - fix dangling references to code this branch removed: the retired set_slices() and view mutators, the former G-code post-processing capability/trampoline, the "Post-processing" capability family, the pre-refactor array helper - drop "v1"/"in v1" phase labels, keeping the behavior they described - correct stale cross-references: Twistify.py -> the real sample path; test_plugin_host_api.cpp:32-40 -> import_orca_module in python_test_support.hpp; "the binding"/"graphs above" -> the named source Comment/string-only; no code behavior change. --- sandboxes/orca_inset_plugin_any.py | 4 +-- src/libslic3r/Print.hpp | 2 +- src/slic3r/plugin/PluginBindingUtils.hpp | 4 +-- src/slic3r/plugin/PluginHostSlicing.cpp | 18 +++++------ src/slic3r/plugin/PluginManager.hpp | 2 +- .../SlicingPipelinePluginCapability.cpp | 8 ++--- ...cingPipelinePluginCapabilityTrampoline.hpp | 8 ++--- .../fff_print/test_slicing_pipeline_hook.cpp | 32 +++++++++---------- .../test_slicing_pipeline_bindings.cpp | 4 +-- 9 files changed, 40 insertions(+), 42 deletions(-) diff --git a/sandboxes/orca_inset_plugin_any.py b/sandboxes/orca_inset_plugin_any.py index ebcbc8563a..23daf8271d 100644 --- a/sandboxes/orca_inset_plugin_any.py +++ b/sandboxes/orca_inset_plugin_any.py @@ -19,8 +19,8 @@ geometry. At Step.posSlice the split slice loop runs make_perimeters() right aft the hook, so the change cascades into perimeters, infill and the final G-code -- the toolpath preview shrinks. -Unlike the old axis-aligned demo, ExPolygon.offset() is a correct inward offset -for any contour (it is Clipper under the hood), and it naturally handles holes. +ExPolygon.offset() is a correct inward offset for any contour (it is Clipper +under the hood), and it naturally handles holes. A surface may split into several islands or vanish when shrunk; both are handled. No numpy required: the whole edit is expressed with the host geometry classes. diff --git a/src/libslic3r/Print.hpp b/src/libslic3r/Print.hpp index 9830878a1c..9481498cc6 100644 --- a/src/libslic3r/Print.hpp +++ b/src/libslic3r/Print.hpp @@ -103,7 +103,7 @@ enum class SlicingPipelineStepPlugin { posSlice, posPerimeters, posEstimateCurledExtrusions, posPrepareInfill, posInfill, posIroning, posContouring, posSupportMaterial, posDetectOverhangsForLift, posSimplifyPath, psWipeTower, psSkirtBrim, // Fires from the GUI G-code export/post-process seam (PostProcessor.cpp), NOT from Print::process(). - // At this step the plugin edits the exported G-code file in place; see the binding for the full contract. + // At this step the plugin edits the exported G-code file in place; see SlicingPipelinePluginCapability for the full contract. psGCodePostProcess }; diff --git a/src/slic3r/plugin/PluginBindingUtils.hpp b/src/slic3r/plugin/PluginBindingUtils.hpp index 3f17b5668a..52050ed561 100644 --- a/src/slic3r/plugin/PluginBindingUtils.hpp +++ b/src/slic3r/plugin/PluginBindingUtils.hpp @@ -39,8 +39,8 @@ pybind11::array make_readonly_rows(pybind11::handle base, const T* data, pybind1 namespace py = pybind11; if (rows == 0 || data == nullptr) { py::array_t empty(std::vector{ 0, (py::ssize_t) N }); - // Keep behavior-preserving: the pre-refactor helper returned read-only - // arrays on every path, so mark the fresh empty array read-only too. + // Mark the fresh empty array read-only so every return path of this + // helper yields a read-only view. empty.attr("setflags")(py::arg("write") = false); return std::move(empty); } diff --git a/src/slic3r/plugin/PluginHostSlicing.cpp b/src/slic3r/plugin/PluginHostSlicing.cpp index 01739420f0..1db8120eb7 100644 --- a/src/slic3r/plugin/PluginHostSlicing.cpp +++ b/src/slic3r/plugin/PluginHostSlicing.cpp @@ -109,7 +109,7 @@ void PluginHostSlicing::RegisterBindings(py::module_& host) // ------------------------------------------------------------------ // Slicing print-graph data model — raw bindings of the classes the C++ // pipeline itself uses, same nodelete/reference style as the Model and - // Preset graphs above. + // Preset graphs in PluginHostApi.cpp. // // LIFETIME (C++ semantics, the one rule of this API): every object handed // out below is a non-owning reference into the live slicing graph owned by @@ -306,8 +306,7 @@ void PluginHostSlicing::RegisterBindings(py::module_& host) .def("set_type", [](SurfaceCollection& c, SurfaceType t) { c.set_type(t); }, py::arg("surface_type")) .def("set", [](SurfaceCollection& c, const std::vector& src, SurfaceType t) { c.set(src, t); }, py::arg("expolygons"), py::arg("surface_type"), - "Replace all surfaces from a list of ExPolygon, all tagged `surface_type`. " - "This is the faithful replacement for the retired set_slices().") + "Replace all surfaces from a list of ExPolygon, all tagged `surface_type`.") .def("set", [](SurfaceCollection& c, const std::vector& src) { c.set(src); }, py::arg("surfaces"), "Replace all surfaces from a list of Surface (types preserved per surface).") .def("append", [](SurfaceCollection& c, const std::vector& src, SurfaceType t) { c.append(src, t); }, @@ -315,9 +314,8 @@ void PluginHostSlicing::RegisterBindings(py::module_& host) .def("filter_by_type", [](py::object self, SurfaceType t) { SurfaceCollection& c = self.cast(); py::list out; - // SurfacesPtr (SurfaceCollection::filter_by_type's return type) is - // std::vector (see Surface.hpp); the brief's note describing it - // as std::vector does not match the header, so this iterates by const + // SurfaceCollection::filter_by_type returns SurfacesPtr, which is + // std::vector (see Surface.hpp), so iterate by const // pointer (py::cast accepts `const itype*` directly, see cast.h cast(const itype*)). for (const Surface* s : c.filter_by_type(t)) out.append(py::cast(s, py::return_value_policy::reference_internal, self)); @@ -333,7 +331,7 @@ void PluginHostSlicing::RegisterBindings(py::module_& host) }, "Surfaces as [Surface] references into the live collection. Invalidated by " "set()/append()/clear() on this collection (C++ vector semantics)."); - // --- Extrusion tree (read-only in v1). Registered polymorphically: when a returned + // --- Extrusion tree (read-only). Registered polymorphically: when a returned // ExtrusionEntity*'s dynamic type IS one of the classes registered below, pybind // hands the plugin that concrete type, so plugins walk the same tree shape C++ does. // When the dynamic type is NOT registered (e.g. ExtrusionLoopSloped, produced with @@ -419,9 +417,9 @@ void PluginHostSlicing::RegisterBindings(py::module_& host) .def_readonly("fill_surfaces", &LayerRegion::fill_surfaces, "Surfaces prepared for infill (SurfaceCollection). Edit in place or via fill_surfaces.set(...).") .def_readonly("perimeters", &LayerRegion::perimeters, - "Perimeter toolpaths (ExtrusionEntityCollection, read-only in v1).") + "Perimeter toolpaths (ExtrusionEntityCollection, read-only).") .def_readonly("fills", &LayerRegion::fills, - "Infill toolpaths (ExtrusionEntityCollection, read-only in v1).") + "Infill toolpaths (ExtrusionEntityCollection, read-only).") .def("layer", [](LayerRegion& r) -> py::object { Layer* l = r.layer(); if (l == nullptr) @@ -487,7 +485,7 @@ void PluginHostSlicing::RegisterBindings(py::module_& host) out.append(py::cast(static_cast(sl), py::return_value_policy::reference_internal, self)); return out; - }, "Support layers as [Layer] (support-specific fields are not exposed in v1).") + }, "Support layers as [Layer] (support-specific fields are not exposed).") .def("model_object", [](PrintObject& o) -> py::object { // The Print's model SNAPSHOT (worker-thread stable), reusing the // orca.host.ModelObject bindings — mesh access for slicing plugins. diff --git a/src/slic3r/plugin/PluginManager.hpp b/src/slic3r/plugin/PluginManager.hpp index 45d116e99f..6d45b5b577 100644 --- a/src/slic3r/plugin/PluginManager.hpp +++ b/src/slic3r/plugin/PluginManager.hpp @@ -102,7 +102,7 @@ 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, + // Log prefix derived from the capability type so each capability family (Printer connection, // Slicing Pipeline, ...) tags its dispatch diagnostics with its own display name. const std::string tag = plugin_capability_type_display_name(type); diff --git a/src/slic3r/plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.cpp b/src/slic3r/plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.cpp index 2828c0496e..19e32118aa 100644 --- a/src/slic3r/plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.cpp +++ b/src/slic3r/plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.cpp @@ -10,7 +10,7 @@ namespace Slic3r { bool SlicingPipelineContext::cancelled() const { return print && print->canceled(); } void SlicingPipelinePluginCapability::RegisterBindings(py::module_& module, py::enum_& pluginTypes) { - (void) pluginTypes; // matches gcode/script/printerAgent; Step is a fresh enum below. + (void) pluginTypes; // unused: this capability defines its own Step enum (below) rather than extending the shared PluginCapabilityType enum. auto slicing = module.def_submodule("slicing", "Slicing pipeline API (research/experimental)."); py::enum_(slicing, "Step") @@ -18,7 +18,7 @@ void SlicingPipelinePluginCapability::RegisterBindings(py::module_& module, py:: .value("posPerimeters", SlicingPipelineStepPlugin::posPerimeters) .value("posEstimateCurledExtrusions", SlicingPipelineStepPlugin::posEstimateCurledExtrusions) .value("posPrepareInfill", SlicingPipelineStepPlugin::posPrepareInfill) // after prepare_infill, before make_fills: editing fill_surfaces here CASCADES - .value("posInfill", SlicingPipelineStepPlugin::posInfill) // after make_fills: editing fill_surfaces here does NOT regenerate fills (v1) + .value("posInfill", SlicingPipelineStepPlugin::posInfill) // after make_fills: editing fill_surfaces here does NOT regenerate the fills .value("posIroning", SlicingPipelineStepPlugin::posIroning) .value("posContouring", SlicingPipelineStepPlugin::posContouring) .value("posSupportMaterial", SlicingPipelineStepPlugin::posSupportMaterial) @@ -70,8 +70,8 @@ void SlicingPipelinePluginCapability::RegisterBindings(py::module_& module, py:: if (ctx.object == nullptr) return py::none(); // The hook signature hands objects out as const; they are genuinely mutable - // (owned by the Print) — the same const_cast the old view mutators used, - // done once here at the graph entry point. + // (owned by the Print), so the const_cast is safe — done once here at the + // graph entry point so Python steps receive a mutable PrintObject. return py::cast(const_cast(ctx.object), py::return_value_policy::reference); }, "orca.host.PrintObject for object-scoped steps, or None for print-wide steps. " "Valid only during the execute(ctx) call.") diff --git a/src/slic3r/plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapabilityTrampoline.hpp b/src/slic3r/plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapabilityTrampoline.hpp index 5dc14c74b9..350f8be506 100644 --- a/src/slic3r/plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapabilityTrampoline.hpp +++ b/src/slic3r/plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapabilityTrampoline.hpp @@ -14,10 +14,10 @@ public: [&]{ // At Step.psGCodePostProcess the plugin edits the exported G-code file, which lives // outside data_dir() (a temp/output folder), so writing to it would otherwise be - // blocked by the audit sandbox. Grant that folder as a scoped allowed root, mirroring - // the former G-code post-processing trampoline. The setup callback runs AFTER the - // audit context is constructed, so the scoped root is not cleared by its constructor. - // Empty at every other step, so no extra access is granted to the geometry hooks. + // blocked by the audit sandbox. Grant that folder as a scoped allowed root. The setup + // callback runs AFTER the audit context is constructed, so the scoped root is not + // cleared by its constructor. Empty at every other step, so no extra access is + // granted to the geometry hooks. if (!ctx.gcode_path.empty()) ::Slic3r::PluginAuditManager::instance().add_scoped_allowed_root( std::filesystem::path(ctx.gcode_path).parent_path()); diff --git a/tests/fff_print/test_slicing_pipeline_hook.cpp b/tests/fff_print/test_slicing_pipeline_hook.cpp index 9e035afea4..ceb17108d1 100644 --- a/tests/fff_print/test_slicing_pipeline_hook.cpp +++ b/tests/fff_print/test_slicing_pipeline_hook.cpp @@ -115,7 +115,7 @@ TEST_CASE("Inactive hook: process output is byte-identical (no-op hook == unset) CHECK(strip_nondeterministic_gcode_lines(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 +// 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. @@ -132,7 +132,7 @@ TEST_CASE("Empty option: registered hook is gated off and never fires", "[slicin CHECK(calls == 0); } -// Fix 4(b): duplicate-skip gating. Two ModelObjects that share one mesh_ptr are detected as +// 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 @@ -220,11 +220,11 @@ TEST_CASE("Changing slicing_pipeline_plugin invalidates posSlice", "[slicing_pip #include -// §3.6 (Twistify design): Twistify's effect is a similarity transform (rotate + uniform -// scale) applied to slices at Step.posSlice. This C++ analogue rotates every region's slices a -// fixed 45 deg about the object's base-footprint center -- the same seam and cascade that -// Twistify.py drives through the slices.set() + Layer::make_slices() path. Two end-to-end invariants after -// process() confirm the approach: +// A similarity transform (rotate + uniform scale) applied to slices at Step.posSlice, matching +// what the Twistify sample (sandboxes/orca_twistify_plugin_example_any.py) does. This C++ analogue +// rotates every region's slices a fixed 45 deg about the object's base-footprint center -- the same +// seam and cascade the sample drives through the slices.set() + Layer::make_slices() path. Two +// end-to-end invariants after process() confirm the approach: // (1) a pure rotation is a similarity with scale 1, so total fill area is preserved, and // (2) the mutation genuinely cascaded into make_perimeters' fill_surfaces -- a 20mm square // rotated 45 deg becomes a diamond whose bbox is ~sqrt(2)x wider (it did not stay @@ -300,13 +300,13 @@ TEST_CASE("Rotating slices at the Slice boundary cascades (area preserved, bbox CHECK(rot.height < 1.5 * base.height); } -// §3.6 (Twistify design): Twistify skips exact-identity layers entirely, but every transformed -// layer invokes the slices.set() write-back + make_perimeters re-run. This proves that write path -// is lossless for already-normalized (CCW contour / CW hole) input -- an active hook that -// re-sets every region's slices to their CURRENT geometry (the identity similarity transform) -// produces output byte-identical to an active hook that mutates nothing. Both runs are active -// (same config dump); the only difference is whether the write path ran, so equality isolates it. -TEST_CASE("Identity round-trip through set_slices is byte-identical", "[slicing_pipeline]") { +// The Twistify sample skips exact-identity layers entirely, but every transformed layer invokes +// the slices.set() write-back + make_perimeters re-run. This proves that write path is lossless +// for already-normalized (CCW contour / CW hole) input -- an active hook that re-sets every +// region's slices to their CURRENT geometry (the identity similarity transform) produces output +// byte-identical to an active hook that mutates nothing. Both runs are active (same config dump); +// the only difference is whether the write path ran, so equality isolates it. +TEST_CASE("Identity round-trip through slices.set() is byte-identical", "[slicing_pipeline]") { auto run = [](bool roundtrip) { Slic3r::Print print; Slic3r::Model model; auto config = Slic3r::DynamicPrintConfig::full_print_config(); @@ -390,7 +390,7 @@ TEST_CASE("raw_slices captures post-hook geometry so a perimeter re-run keeps th } // A plugin can mutate fill_surfaces at the new PrepareInfill seam and have make_fills consume -// them, whereas the pre-existing Infill seam fires after the fills are already built (v1 limit). +// them, whereas the pre-existing Infill seam fires after the fills are already built. // All three runs register a hook (active path) so the comparison isolates only the mutation. TEST_CASE("fill_surfaces mutation cascades at PrepareInfill but not at Infill", "[slicing_pipeline]") { auto fill_paths = [](bool shrink, Slic3r::SlicingPipelineStepPlugin at) { @@ -423,7 +423,7 @@ TEST_CASE("fill_surfaces mutation cascades at PrepareInfill but not at Infill", const size_t base = fill_paths(false, S::posPrepareInfill); // active hook, no mutation CHECK(base > 0); CHECK(fill_paths(true, S::posPrepareInfill) < base); // mutation before make_fills cascades - CHECK(fill_paths(true, S::posInfill) == base); // mutation after make_fills is a no-op (v1) + CHECK(fill_paths(true, S::posInfill) == base); // mutation after make_fills is a no-op } // lslices (the layer's merged islands) are built once in slice() and never rebuilt by diff --git a/tests/slic3rutils/test_slicing_pipeline_bindings.cpp b/tests/slic3rutils/test_slicing_pipeline_bindings.cpp index 35f49241b6..893e00ab04 100644 --- a/tests/slic3rutils/test_slicing_pipeline_bindings.cpp +++ b/tests/slic3rutils/test_slicing_pipeline_bindings.cpp @@ -75,7 +75,7 @@ TEST_CASE("make_writable_rows builds a writable (N,2) int64 view that aliases th 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) + import_orca_module(); // forces PythonPluginBridge::instance() (see import_orca_module in python_test_support.hpp) py::gil_scoped_acquire gil; py::module_ orca = py::module_::import("orca"); REQUIRE(py::hasattr(orca, "slicing")); @@ -301,7 +301,7 @@ TEST_CASE("orca.host Surface/SurfaceCollection: construct, writable members, set surf.attr("thickness") = py::float_(0.3); CHECK_THAT(surf.attr("thickness").cast(), WithinRel(0.3, 1e-9)); - // SurfaceCollection.set(expolys, type) — the faithful replacement for set_slices' body. + // SurfaceCollection.set(expolys, type): replace all surfaces from a list of ExPolygon tagged with one SurfaceType. Slic3r::SurfaceCollection coll; py::object cv = py::cast(&coll, py::return_value_policy::reference); py::list expolys; expolys.append(ex); From 126e4d5445976e2f138d7648f12ca7f93a41ea81 Mon Sep 17 00:00:00 2001 From: SoftFever Date: Sat, 11 Jul 2026 16:18:59 +0800 Subject: [PATCH 07/10] refactor(plugin): split orca.host bindings into host/ by domain PluginHostApi.cpp had grown into one TU holding the module entry point plus three unrelated domains (presets, model/mesh graph, app access), and PluginHostSlicing.cpp mixed ownable geometry value types with the non-owning live print graph. Reorganize the orca.host surface into plugin/host/ with one registrar per domain: - PluginHost.hpp/.cpp entry point (replaces PluginHostApi) - PluginHostBindings.hpp internal per-domain registrar declarations - PluginHostGeometry.cpp BoundingBox, Point, Polygon, ExPolygon + ndarray parsing - PluginHostMesh.hpp/.cpp TriangleMesh snapshot (own TU ahead of planned mesh construct/mutate APIs) - PluginHostPresets.cpp Preset, PresetCollection, PresetBundle - PluginHostModel.cpp scene graph: Model, ModelObject, ModelInstance, ModelVolume - PluginHostApp.cpp Plater + plater()/model()/preset_bundle() accessors - PluginHostSlicing.cpp live print graph only, now with a single lifetime story - PluginHostUi.hpp/.cpp moved unchanged PluginBindingUtils.hpp stays at plugin/ root: it is shared with pluginTypes/ and tests, not host/-specific. No Python-visible change: same submodules, class names and docstrings. Verified with slic3rutils and fff_print suites. --- src/slic3r/CMakeLists.txt | 18 +- src/slic3r/GUI/GUI_App.cpp | 2 +- src/slic3r/plugin/PluginHostApi.cpp | 487 ------------------ src/slic3r/plugin/PluginHostApi.hpp | 13 - src/slic3r/plugin/PluginHostSlicing.hpp | 16 - src/slic3r/plugin/PythonPluginBridge.cpp | 4 +- src/slic3r/plugin/host/PluginHost.cpp | 26 + src/slic3r/plugin/host/PluginHost.hpp | 17 + src/slic3r/plugin/host/PluginHostApp.cpp | 60 +++ src/slic3r/plugin/host/PluginHostBindings.hpp | 16 + src/slic3r/plugin/host/PluginHostGeometry.cpp | 216 ++++++++ src/slic3r/plugin/host/PluginHostMesh.cpp | 105 ++++ src/slic3r/plugin/host/PluginHostMesh.hpp | 28 + src/slic3r/plugin/host/PluginHostModel.cpp | 203 ++++++++ src/slic3r/plugin/host/PluginHostPresets.cpp | 138 +++++ .../plugin/{ => host}/PluginHostSlicing.cpp | 190 +------ src/slic3r/plugin/{ => host}/PluginHostUi.cpp | 4 +- src/slic3r/plugin/{ => host}/PluginHostUi.hpp | 0 .../SlicingPipelinePluginCapability.hpp | 2 +- tests/slic3rutils/test_plugin_host_api.cpp | 10 +- 20 files changed, 838 insertions(+), 717 deletions(-) delete mode 100644 src/slic3r/plugin/PluginHostApi.cpp delete mode 100644 src/slic3r/plugin/PluginHostApi.hpp delete mode 100644 src/slic3r/plugin/PluginHostSlicing.hpp create mode 100644 src/slic3r/plugin/host/PluginHost.cpp create mode 100644 src/slic3r/plugin/host/PluginHost.hpp create mode 100644 src/slic3r/plugin/host/PluginHostApp.cpp create mode 100644 src/slic3r/plugin/host/PluginHostBindings.hpp create mode 100644 src/slic3r/plugin/host/PluginHostGeometry.cpp create mode 100644 src/slic3r/plugin/host/PluginHostMesh.cpp create mode 100644 src/slic3r/plugin/host/PluginHostMesh.hpp create mode 100644 src/slic3r/plugin/host/PluginHostModel.cpp create mode 100644 src/slic3r/plugin/host/PluginHostPresets.cpp rename src/slic3r/plugin/{ => host}/PluginHostSlicing.cpp (62%) rename src/slic3r/plugin/{ => host}/PluginHostUi.cpp (99%) rename src/slic3r/plugin/{ => host}/PluginHostUi.hpp (100%) diff --git a/src/slic3r/CMakeLists.txt b/src/slic3r/CMakeLists.txt index f621e63d67..619723de0b 100644 --- a/src/slic3r/CMakeLists.txt +++ b/src/slic3r/CMakeLists.txt @@ -597,12 +597,18 @@ set(SLIC3R_GUI_SOURCES plugin/PythonPluginInterface.hpp plugin/PyPluginPackage.hpp plugin/PluginBindingUtils.hpp - plugin/PluginHostApi.cpp - plugin/PluginHostApi.hpp - plugin/PluginHostUi.cpp - plugin/PluginHostUi.hpp - plugin/PluginHostSlicing.cpp - plugin/PluginHostSlicing.hpp + plugin/host/PluginHost.cpp + plugin/host/PluginHost.hpp + plugin/host/PluginHostBindings.hpp + plugin/host/PluginHostApp.cpp + plugin/host/PluginHostGeometry.cpp + plugin/host/PluginHostMesh.cpp + plugin/host/PluginHostMesh.hpp + plugin/host/PluginHostModel.cpp + plugin/host/PluginHostPresets.cpp + plugin/host/PluginHostSlicing.cpp + plugin/host/PluginHostUi.cpp + plugin/host/PluginHostUi.hpp plugin/CloudPluginService.cpp plugin/CloudPluginService.hpp plugin/PluginFsUtils.cpp diff --git a/src/slic3r/GUI/GUI_App.cpp b/src/slic3r/GUI/GUI_App.cpp index 8513af0f86..b593563ad4 100644 --- a/src/slic3r/GUI/GUI_App.cpp +++ b/src/slic3r/GUI/GUI_App.cpp @@ -79,7 +79,7 @@ #include "libslic3r/Utils.hpp" #include "libslic3r/Color.hpp" #include "slic3r/plugin/PluginManager.hpp" -#include "slic3r/plugin/PluginHostUi.hpp" +#include "slic3r/plugin/host/PluginHostUi.hpp" #include "slic3r/plugin/PythonInterpreter.hpp" #include "slic3r/plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.hpp" diff --git a/src/slic3r/plugin/PluginHostApi.cpp b/src/slic3r/plugin/PluginHostApi.cpp deleted file mode 100644 index 4a217f67f2..0000000000 --- a/src/slic3r/plugin/PluginHostApi.cpp +++ /dev/null @@ -1,487 +0,0 @@ -#include "PluginHostApi.hpp" -#include "PluginHostUi.hpp" -#include "PluginHostSlicing.hpp" -#include "PluginBindingUtils.hpp" - -#include -#include -#include -#include -#include -#include -#include - -#include -#include - -#include -#include -#include -#include - -namespace py = pybind11; - -namespace Slic3r { -namespace { - -GUI::Plater* current_plater() -{ - if (wxTheApp == nullptr) - throw std::runtime_error("OrcaSlicer application is not initialized"); - - GUI::Plater* plater = GUI::wxGetApp().plater(); - if (plater == nullptr) - throw std::runtime_error("Plater is not available"); - - return plater; -} - -PresetBundle* current_preset_bundle() -{ - if (wxTheApp == nullptr) - throw std::runtime_error("OrcaSlicer application is not initialized"); - - PresetBundle* preset_bundle = GUI::wxGetApp().preset_bundle; - if (preset_bundle == nullptr) - throw std::runtime_error("Preset bundle is not available"); - - return preset_bundle; -} - -// Build a BoundingBoxf3 from precomputed (float) triangle-mesh stats min/max. -BoundingBoxf3 bbox_from_stats(const TriangleMeshStats& stats) -{ - if (stats.number_of_facets == 0) - return BoundingBoxf3(); - return BoundingBoxf3(stats.min.cast(), stats.max.cast()); -} - -// --- Mesh geometry helpers ------------------------------------------------- - -// Zero-copy export of its.vertices / its.indices relies on these Eigen -// row-vectors being tightly packed (no padding between the 3 components). -static_assert(sizeof(stl_vertex) == 3 * sizeof(float), - "stl_vertex must be a packed float[3] for zero-copy numpy export"); -static_assert(sizeof(stl_triangle_vertex_indices) == 3 * sizeof(std::int32_t), - "triangle index must be a packed int32[3] for zero-copy numpy export"); - -// Immutable snapshot of a ModelVolume's mesh. Holding a strong reference to the -// const mesh keeps any zero-copy numpy views valid even if the volume's mesh is -// later replaced on the main thread. -struct HostTriangleMesh -{ - std::shared_ptr mesh; - const indexed_triangle_set& its() const { return mesh->its; } -}; - -// Read-only, zero-copy (rows, 3) numpy view over a packed T[rows][3] buffer. -// The array's base is a capsule owning a strong ref to `mesh`, so the view -// stays valid even if the volume's mesh is later replaced on the main thread. -template -py::array make_readonly_rows3(const std::shared_ptr& mesh, - const T* data, py::ssize_t rows) -{ - if (rows == 0 || data == nullptr) - return py::array_t(std::vector{ 0, 3 }); - auto* owner = new std::shared_ptr(mesh); - py::capsule base(owner, [](void* p) { - delete reinterpret_cast*>(p); - }); - return make_readonly_rows(base, data, rows); -} - -py::list current_filament_presets(PresetBundle& bundle) -{ - py::list presets; - for (const std::string& preset_name : bundle.filament_presets) { - Preset* preset = bundle.filaments.find_preset(preset_name); - if (preset == nullptr) - presets.append(py::none()); - else - presets.append(py::cast(preset, py::return_value_policy::reference)); - } - return presets; -} - -PresetCollection& printer_presets(PresetBundle& bundle) -{ - return static_cast(bundle.printers); -} - -} // namespace - -void PluginHostApi::RegisterBindings(pybind11::module_& module) -{ - auto host = module.def_submodule("host", "Host application API"); - - py::enum_(host, "PresetType") - .value("Invalid", Preset::TYPE_INVALID) - .value("Print", Preset::TYPE_PRINT) - .value("SlaPrint", Preset::TYPE_SLA_PRINT) - .value("Filament", Preset::TYPE_FILAMENT) - .value("SlaMaterial", Preset::TYPE_SLA_MATERIAL) - .value("Printer", Preset::TYPE_PRINTER) - .value("PhysicalPrinter", Preset::TYPE_PHYSICAL_PRINTER) - .value("Plate", Preset::TYPE_PLATE) - .value("Model", Preset::TYPE_MODEL); - - py::class_>(host, "Preset") - .def_readonly("type", &Preset::type) - .def_readonly("name", &Preset::name) - .def_readonly("alias", &Preset::alias) - .def_readonly("file", &Preset::file) - .def_readonly("is_default", &Preset::is_default) - .def_readonly("is_external", &Preset::is_external) - .def_readonly("is_system", &Preset::is_system) - .def_readonly("is_visible", &Preset::is_visible) - .def_readonly("is_dirty", &Preset::is_dirty) - .def_readonly("is_compatible", &Preset::is_compatible) - .def_readonly("is_project_embedded", &Preset::is_project_embedded) - .def_readonly("bundle_id", &Preset::bundle_id) - .def("is_user", &Preset::is_user) - .def("is_from_bundle", &Preset::is_from_bundle) - .def("label", &Preset::label, py::arg("no_alias") = false) - .def("config_keys", [](const Preset& preset) { return preset.config.keys(); }) - .def("config_value", [](const Preset& preset, const std::string& key) { - return config_value_or_none(preset.config, key); - }); - - py::class_>(host, "PresetCollection") - .def("size", &PresetCollection::size) - .def("get_selected_preset", [](PresetCollection& collection) -> Preset& { - return collection.get_selected_preset(); - }, py::return_value_policy::reference_internal) - .def("selected_preset", [](PresetCollection& collection) -> Preset& { - return collection.get_selected_preset(); - }, py::return_value_policy::reference_internal) - .def("get_selected_preset_name", &PresetCollection::get_selected_preset_name) - .def("selected_preset_name", &PresetCollection::get_selected_preset_name) - .def("get_edited_preset", [](PresetCollection& collection) -> Preset& { - return collection.get_edited_preset(); - }, py::return_value_policy::reference_internal) - .def("edited_preset", [](PresetCollection& collection) -> Preset& { - return collection.get_edited_preset(); - }, py::return_value_policy::reference_internal) - .def("preset", [](PresetCollection& collection, size_t index) -> Preset& { - if (index >= collection.size()) - throw py::index_error("preset index out of range"); - return collection.preset(index); - }, py::return_value_policy::reference_internal) - .def("find_preset", [](PresetCollection& collection, const std::string& name) -> Preset* { - return collection.find_preset(name); - }, py::return_value_policy::reference_internal) - .def("preset_names", [](const PresetCollection& collection) { - std::vector names; - names.reserve(collection.get_presets().size()); - for (const Preset& preset : collection.get_presets()) - names.push_back(preset.name); - return names; - }); - - py::class_>(host, "PresetBundle") - .def_property_readonly("prints", [](PresetBundle& bundle) -> PresetCollection& { - return bundle.prints; - }, py::return_value_policy::reference_internal) - .def_property_readonly("printers", &printer_presets, py::return_value_policy::reference_internal) - .def_property_readonly("filaments", [](PresetBundle& bundle) -> PresetCollection& { - return bundle.filaments; - }, py::return_value_policy::reference_internal) - .def_property_readonly("sla_prints", [](PresetBundle& bundle) -> PresetCollection& { - return bundle.sla_prints; - }, py::return_value_policy::reference_internal) - .def_property_readonly("sla_materials", [](PresetBundle& bundle) -> PresetCollection& { - return bundle.sla_materials; - }, py::return_value_policy::reference_internal) - .def("current_process_preset", [](PresetBundle& bundle) -> Preset& { - return bundle.prints.get_edited_preset(); - }, py::return_value_policy::reference_internal) - .def("current_print_preset", [](PresetBundle& bundle) -> Preset& { - return bundle.prints.get_edited_preset(); - }, py::return_value_policy::reference_internal) - .def("current_printer_preset", [](PresetBundle& bundle) -> Preset& { - return bundle.printers.get_edited_preset(); - }, py::return_value_policy::reference_internal) - .def("current_filament_preset_names", [](PresetBundle& bundle) { - return bundle.filament_presets; - }) - .def("current_filament_presets", ¤t_filament_presets) - .def("full_config_keys", [](const PresetBundle& bundle) { - return bundle.full_config().keys(); - }) - .def("full_config_value", [](const PresetBundle& bundle, const std::string& key) { - return config_value_or_none(bundle.full_config(), key); - }); - - // Axis-aligned bounding box, returned by value (a copy) so its lifetime is - // independent of the model object it was computed from. Coordinates are in mm. - py::class_(host, "BoundingBox", "Axis-aligned bounding box in millimetres") - .def_property_readonly("defined", [](const BoundingBoxf3& bb) { return bb.defined; }) - .def_property_readonly("min", [](const BoundingBoxf3& bb) { return vec3_to_tuple(bb.min); }) - .def_property_readonly("max", [](const BoundingBoxf3& bb) { return vec3_to_tuple(bb.max); }) - .def_property_readonly("size", [](const BoundingBoxf3& bb) { return vec3_to_tuple(bb.size()); }) - .def_property_readonly("center", [](const BoundingBoxf3& bb) { return vec3_to_tuple(bb.center()); }) - .def_property_readonly("radius", [](const BoundingBoxf3& bb) { return bb.radius(); }); - - py::class_(host, "TriangleMesh", - "Immutable snapshot of a ModelVolume's mesh in local (untransformed) coordinates, mm.") - .def("vertex_count", [](const HostTriangleMesh& mesh) { return mesh.its().vertices.size(); }) - .def("triangle_count", [](const HostTriangleMesh& mesh) { return mesh.its().indices.size(); }) - .def("facets_count", [](const HostTriangleMesh& mesh) { return mesh.its().indices.size(); }) - .def("is_empty", [](const HostTriangleMesh& mesh) { return mesh.its().indices.empty(); }) - // Read-only, zero-copy (N, 3) float32 view of vertex positions. Requires numpy. - .def("vertices", [](const HostTriangleMesh& mesh) { - return with_numpy([&] { - const indexed_triangle_set& its = mesh.its(); - return make_readonly_rows3( - mesh.mesh, - its.vertices.empty() ? nullptr : its.vertices.front().data(), - static_cast(its.vertices.size())); - }); - }, "Read-only zero-copy (N, 3) float32 ndarray of vertex positions (local mm). Requires numpy.") - // Read-only, zero-copy (M, 3) int32 view of triangle vertex indices. Requires numpy. - .def("triangles", [](const HostTriangleMesh& mesh) { - return with_numpy([&] { - const indexed_triangle_set& its = mesh.its(); - return make_readonly_rows3( - mesh.mesh, - its.indices.empty() ? nullptr : its.indices.front().data(), - static_cast(its.indices.size())); - }); - }, "Read-only zero-copy (M, 3) int32 ndarray of triangle vertex indices. Requires numpy.") - // One normalized normal per triangle as an (M, 3) float32 copy. Requires numpy. - .def("face_normals", [](const HostTriangleMesh& mesh) { - return with_numpy([&] { - std::vector normals = its_face_normals(mesh.its()); - py::array_t array({ static_cast(normals.size()), py::ssize_t(3) }); - if (!normals.empty()) { - auto view = array.mutable_unchecked<2>(); - for (size_t i = 0; i < normals.size(); ++i) { - view(i, 0) = normals[i].x(); - view(i, 1) = normals[i].y(); - view(i, 2) = normals[i].z(); - } - } - return py::object(std::move(array)); - }); - }, "Per-triangle normalized normals as an (M, 3) float32 ndarray (copy). Requires numpy.") - // numpy-free element access, bounds-checked. - .def("vertex", [](const HostTriangleMesh& mesh, size_t index) { - const std::vector& vertices = mesh.its().vertices; - if (index >= vertices.size()) - throw py::index_error("vertex index out of range"); - const stl_vertex& vertex = vertices[index]; - return py::make_tuple(vertex.x(), vertex.y(), vertex.z()); - }) - .def("triangle", [](const HostTriangleMesh& mesh, size_t index) { - const std::vector& indices = mesh.its().indices; - if (index >= indices.size()) - throw py::index_error("triangle index out of range"); - const stl_triangle_vertex_indices& triangle = indices[index]; - return py::make_tuple(triangle[0], triangle[1], triangle[2]); - }) - .def("volume", [](const HostTriangleMesh& mesh) { return mesh.mesh->stats().volume; }) - .def("bounding_box", [](const HostTriangleMesh& mesh) { return bbox_from_stats(mesh.mesh->stats()); }) - .def("is_manifold", [](const HostTriangleMesh& mesh) { return mesh.mesh->stats().manifold(); }); - - py::enum_(host, "ModelVolumeType") - .value("Invalid", ModelVolumeType::INVALID) - .value("ModelPart", ModelVolumeType::MODEL_PART) - .value("NegativeVolume", ModelVolumeType::NEGATIVE_VOLUME) - .value("ParameterModifier", ModelVolumeType::PARAMETER_MODIFIER) - .value("SupportBlocker", ModelVolumeType::SUPPORT_BLOCKER) - .value("SupportEnforcer", ModelVolumeType::SUPPORT_ENFORCER); - - py::class_>(host, "ModelVolume") - .def("id", [](const ModelVolume& volume) { return volume.id().id; }) - .def_readonly("name", &ModelVolume::name) - .def("type", &ModelVolume::type) - .def("is_model_part", &ModelVolume::is_model_part) - .def("is_modifier", &ModelVolume::is_modifier) - .def("is_negative_volume", &ModelVolume::is_negative_volume) - .def("is_support_enforcer", &ModelVolume::is_support_enforcer) - .def("is_support_blocker", &ModelVolume::is_support_blocker) - .def("is_support_modifier", &ModelVolume::is_support_modifier) - // Extruder ID is 1-based for FFF, -1 for SLA or support volumes. - .def("extruder_id", &ModelVolume::extruder_id) - .def("offset", [](const ModelVolume& volume) { return vec3_to_tuple(volume.get_offset()); }) - .def("rotation", [](const ModelVolume& volume) { return vec3_to_tuple(volume.get_rotation()); }) - .def("scaling_factor", [](const ModelVolume& volume) { return vec3_to_tuple(volume.get_scaling_factor()); }) - .def("mirror", [](const ModelVolume& volume) { return vec3_to_tuple(volume.get_mirror()); }) - // 4x4 float64 affine matrix mapping this volume into its parent object frame. Requires numpy. - .def("matrix", [](const ModelVolume& volume) { return mat4_to_numpy(volume.get_matrix()); }, - "Volume-to-object 4x4 float64 affine matrix (copy). Requires numpy.") - .def("facets_count", [](const ModelVolume& volume) { return volume.mesh().facets_count(); }) - // Raw (untransformed) mesh volume in mm^3; -1 if it was never computed. - .def("volume", [](const ModelVolume& volume) { return volume.mesh().stats().volume; }) - // Bounding box of the raw (untransformed) mesh, in the volume's local frame. - .def("bounding_box", [](const ModelVolume& volume) { return bbox_from_stats(volume.mesh().stats()); }) - .def("is_manifold", [](const ModelVolume& volume) { return volume.mesh().stats().manifold(); }) - // Full mesh geometry (vertices/triangles) as an immutable snapshot. - .def("mesh", [](const ModelVolume& volume) { - return HostTriangleMesh{ volume.get_mesh_shared_ptr() }; - }, "Return the volume's TriangleMesh (local coordinates) for vertex/triangle access.") - .def("mesh_errors_count", [](const ModelVolume& volume) { return volume.get_repaired_errors_count(); }) - .def("is_fdm_support_painted", &ModelVolume::is_fdm_support_painted) - .def("is_seam_painted", &ModelVolume::is_seam_painted) - .def("is_mm_painted", &ModelVolume::is_mm_painted) - .def("is_fuzzy_skin_painted", &ModelVolume::is_fuzzy_skin_painted) - .def("config_keys", [](const ModelVolume& volume) { return volume.config.keys(); }) - .def("config_value", [](const ModelVolume& volume, const std::string& key) { - return config_value_or_none(volume.config.get(), key); - }); - - py::class_>(host, "ModelInstance") - .def("id", [](const ModelInstance& instance) { return instance.id().id; }) - .def_readonly("printable", &ModelInstance::printable) - // True only if the object is printable, this instance is printable and it - // currently sits fully inside the print volume (set during slicing). - .def("is_printable", &ModelInstance::is_printable) - .def("offset", [](const ModelInstance& instance) { return vec3_to_tuple(instance.get_offset()); }) - .def("rotation", [](const ModelInstance& instance) { return vec3_to_tuple(instance.get_rotation()); }) - .def("scaling_factor", [](const ModelInstance& instance) { return vec3_to_tuple(instance.get_scaling_factor()); }) - .def("mirror", [](const ModelInstance& instance) { return vec3_to_tuple(instance.get_mirror()); }) - // 4x4 float64 affine matrix mapping the object into world space. Requires numpy. - // World vertices = instance.matrix() @ volume.matrix() applied to mesh vertices. - .def("matrix", [](const ModelInstance& instance) { return mat4_to_numpy(instance.get_matrix()); }, - "Object-to-world 4x4 float64 affine matrix (copy). Requires numpy.") - .def("is_left_handed", &ModelInstance::is_left_handed) - // Assemble-view placement. Each instance carries a second transform used only by - // the Assemble view, set from stored 3mf assemble data or derived from the regular - // transform. Until then (is_assemble_initialized() false) it is identity. - .def("is_assemble_initialized", [](ModelInstance& instance) { return instance.is_assemble_initialized(); }) - .def("assemble_offset", [](const ModelInstance& instance) { - return vec3_to_tuple(instance.get_assemble_transformation().get_offset()); - }) - .def("assemble_rotation", [](const ModelInstance& instance) { - return vec3_to_tuple(instance.get_assemble_transformation().get_rotation()); - }) - // 4x4 float64 affine matrix placing the object in the Assemble view. Requires numpy. - .def("assemble_matrix", [](const ModelInstance& instance) { - return mat4_to_numpy(instance.get_assemble_transformation().get_matrix()); - }, "Assemble-view 4x4 float64 affine matrix (copy). Requires numpy.") - // Offset from the instance origin to its position within the source assembly, - // recorded at import time (e.g. from a STEP assembly). - .def("offset_to_assembly", [](const ModelInstance& instance) { - return vec3_to_tuple(instance.get_offset_to_assembly()); - }) - // World-space bounding box of this instance. - .def("bounding_box", [](ModelInstance& instance) { - const ModelObject* object = instance.get_object(); - if (object == nullptr) - return BoundingBoxf3(); - return object->instance_bounding_box(instance); - }); - - py::class_>(host, "ModelObject") - .def("id", [](const ModelObject& object) { return object.id().id; }) - .def_readonly("name", &ModelObject::name) - .def_readonly("module_name", &ModelObject::module_name) - .def_readonly("input_file", &ModelObject::input_file) - // Import-time flag only: the GUI's printable toggle writes the per-instance - // ModelInstance::printable and never updates this field, so derive an - // object's effective state from its instances. - .def_readonly("printable", &ModelObject::printable) - .def("instance_count", [](const ModelObject& object) { - return object.instances.size(); - }) - .def("volume_count", [](const ModelObject& object) { - return object.volumes.size(); - }) - .def("instances", [](ModelObject& object) { - py::list instances; - for (ModelInstance* instance : object.instances) - instances.append(py::cast(instance, py::return_value_policy::reference)); - return instances; - }) - .def("instance", [](ModelObject& object, size_t index) -> ModelInstance* { - if (index >= object.instances.size()) - throw py::index_error("instance index out of range"); - return object.instances[index]; - }, py::return_value_policy::reference_internal) - .def("volumes", [](ModelObject& object) { - py::list volumes; - for (ModelVolume* volume : object.volumes) - volumes.append(py::cast(volume, py::return_value_policy::reference)); - return volumes; - }) - .def("volume", [](ModelObject& object, size_t index) -> ModelVolume* { - if (index >= object.volumes.size()) - throw py::index_error("volume index out of range"); - return object.volumes[index]; - }, py::return_value_policy::reference_internal) - // World-space bounding box over all instances of this object. - .def("bounding_box", [](const ModelObject& object) { return object.bounding_box_exact(); }) - // Bounding box of the object's raw (untransformed) part meshes — its intrinsic size. - .def("raw_mesh_bounding_box", [](const ModelObject& object) { return object.raw_mesh_bounding_box(); }) - .def("min_z", &ModelObject::min_z) - .def("max_z", &ModelObject::max_z) - .def("facets_count", [](const ModelObject& object) { return object.facets_count(); }) - .def("parts_count", [](const ModelObject& object) { return object.parts_count(); }) - .def("materials_count", [](const ModelObject& object) { return object.materials_count(); }) - .def("mesh_errors_count", [](const ModelObject& object) { return object.get_repaired_errors_count(); }) - .def("is_multiparts", &ModelObject::is_multiparts) - .def("is_cut", &ModelObject::is_cut) - .def("has_custom_layering", &ModelObject::has_custom_layering) - .def("is_fdm_support_painted", &ModelObject::is_fdm_support_painted) - .def("is_seam_painted", &ModelObject::is_seam_painted) - .def("is_mm_painted", &ModelObject::is_mm_painted) - .def("is_fuzzy_skin_painted", &ModelObject::is_fuzzy_skin_painted) - .def("config_keys", [](const ModelObject& object) { - return object.config.keys(); - }) - .def("config_value", [](const ModelObject& object, const std::string& key) { - return config_value_or_none(object.config.get(), key); - }); - - py::class_>(host, "Model") - .def("id", [](const Model& model) { return model.id().id; }) - .def("object_count", [](const Model& model) { - return model.objects.size(); - }) - .def("object", [](Model& model, size_t index) -> ModelObject* { - if (index >= model.objects.size()) - throw py::index_error("model object index out of range"); - return model.objects[index]; - }, py::return_value_policy::reference_internal) - .def("objects", [](Model& model) { - py::list objects; - for (ModelObject* object : model.objects) - objects.append(py::cast(object, py::return_value_policy::reference)); - return objects; - }) - // World-space bounding box of the whole model. bounding_box() is exact; - // bounding_box_approx() is faster and cached. - .def("bounding_box", [](const Model& model) { return model.bounding_box_exact(); }) - .def("bounding_box_approx", [](const Model& model) { return model.bounding_box_approx(); }) - .def("max_z", &Model::max_z) - .def("material_count", [](const Model& model) { return model.materials.size(); }) - .def("is_fdm_support_painted", &Model::is_fdm_support_painted) - .def("is_seam_painted", &Model::is_seam_painted) - .def("is_mm_painted", &Model::is_mm_painted) - .def("is_fuzzy_skin_painted", &Model::is_fuzzy_skin_painted) - .def("current_plate_index", [](const Model& model) { return model.curr_plate_index; }) - .def("designer", [](const Model& model) { - return model.design_info ? model.design_info->Designer : std::string(); - }) - .def("design_id", [](const Model& model) { return model.stl_design_id; }); - - py::class_>(host, "Plater") - .def("model", static_cast(&GUI::Plater::model), py::return_value_policy::reference_internal) - .def("is_project_dirty", &GUI::Plater::is_project_dirty) - .def("is_presets_dirty", &GUI::Plater::is_presets_dirty) - .def("inside_snapshot_capture", &GUI::Plater::inside_snapshot_capture); - - host.def("plater", ¤t_plater, py::return_value_policy::reference); - host.def("model", []() -> Model& { - return current_plater()->model(); - }, py::return_value_policy::reference); - host.def("preset_bundle", ¤t_preset_bundle, py::return_value_policy::reference); - - // UI: native dialogs and interactive HTML windows for plugins. - PluginHostUi::RegisterBindings(host); - - // Slicing print-graph data model (Print, Layer, Surface, ...). - PluginHostSlicing::RegisterBindings(host); -} - -} // namespace Slic3r diff --git a/src/slic3r/plugin/PluginHostApi.hpp b/src/slic3r/plugin/PluginHostApi.hpp deleted file mode 100644 index 5aaf5882df..0000000000 --- a/src/slic3r/plugin/PluginHostApi.hpp +++ /dev/null @@ -1,13 +0,0 @@ -#pragma once - -#include - -namespace Slic3r { - -class PluginHostApi -{ -public: - static void RegisterBindings(pybind11::module_& module); -}; - -} // namespace Slic3r diff --git a/src/slic3r/plugin/PluginHostSlicing.hpp b/src/slic3r/plugin/PluginHostSlicing.hpp deleted file mode 100644 index 3fdbb7bd4e..0000000000 --- a/src/slic3r/plugin/PluginHostSlicing.hpp +++ /dev/null @@ -1,16 +0,0 @@ -#pragma once -#include - -namespace Slic3r { - -// Registers the slicing print-graph data model (Print, PrintObject, Layer, -// LayerRegion, Surface, ExPolygon, extrusions, ...) into the `orca.host` -// submodule, in the same raw-class style as PluginHostApi's Model/Preset -// graph. Called from PluginHostApi::RegisterBindings. -class PluginHostSlicing -{ -public: - static void RegisterBindings(pybind11::module_& host); -}; - -} // namespace Slic3r diff --git a/src/slic3r/plugin/PythonPluginBridge.cpp b/src/slic3r/plugin/PythonPluginBridge.cpp index 89b9f8eb6f..2711e196bb 100644 --- a/src/slic3r/plugin/PythonPluginBridge.cpp +++ b/src/slic3r/plugin/PythonPluginBridge.cpp @@ -10,7 +10,7 @@ #include #include "PythonInterpreter.hpp" -#include "PluginHostApi.hpp" +#include "host/PluginHost.hpp" #include "PyPluginPackage.hpp" #include "PyPluginTrampoline.hpp" #include "pluginTypes/printerAgent/PrinterAgentPluginCapability.hpp" @@ -337,7 +337,7 @@ void bind_python_api(pybind11::module_& m) PrinterAgentPluginCapability::RegisterBindings(m, pluginTypes); ScriptPluginCapability::RegisterBindings(m, pluginTypes); SlicingPipelinePluginCapability::RegisterBindings(m, pluginTypes); - PluginHostApi::RegisterBindings(m); + PluginHost::RegisterBindings(m); BOOST_LOG_TRIVIAL(debug) << "Registered ScriptPluginCapability Python bindings"; m.def( diff --git a/src/slic3r/plugin/host/PluginHost.cpp b/src/slic3r/plugin/host/PluginHost.cpp new file mode 100644 index 0000000000..524f07f12c --- /dev/null +++ b/src/slic3r/plugin/host/PluginHost.cpp @@ -0,0 +1,26 @@ +#include "PluginHost.hpp" +#include "PluginHostBindings.hpp" +#include "PluginHostUi.hpp" + +namespace Slic3r { + +void PluginHost::RegisterBindings(pybind11::module_& module) +{ + auto host = module.def_submodule("host", "Host application API"); + + // Value types first so the docstring signatures of later registrars + // resolve to the bound Python names. + host_bindings::register_geometry(host); + host_bindings::register_mesh(host); + host_bindings::register_presets(host); + host_bindings::register_model(host); + host_bindings::register_app(host); + + // UI: native dialogs and interactive HTML windows for plugins. + PluginHostUi::RegisterBindings(host); + + // Slicing print-graph data model (Print, Layer, Surface, ...). + host_bindings::register_slicing(host); +} + +} // namespace Slic3r diff --git a/src/slic3r/plugin/host/PluginHost.hpp b/src/slic3r/plugin/host/PluginHost.hpp new file mode 100644 index 0000000000..4385454cc3 --- /dev/null +++ b/src/slic3r/plugin/host/PluginHost.hpp @@ -0,0 +1,17 @@ +#pragma once + +#include + +namespace Slic3r { + +// Entry point of the `orca.host` Python API surface. Each domain of the +// surface (geometry, mesh, presets, model, app access, ui, slicing graph) +// lives in its own translation unit in this directory; RegisterBindings +// creates the submodule and runs the per-domain registrars. +class PluginHost +{ +public: + static void RegisterBindings(pybind11::module_& module); +}; + +} // namespace Slic3r diff --git a/src/slic3r/plugin/host/PluginHostApp.cpp b/src/slic3r/plugin/host/PluginHostApp.cpp new file mode 100644 index 0000000000..a17f160bb7 --- /dev/null +++ b/src/slic3r/plugin/host/PluginHostApp.cpp @@ -0,0 +1,60 @@ +#include "PluginHostBindings.hpp" + +#include +#include +#include +#include + +#include +#include + +namespace py = pybind11; + +namespace Slic3r { +namespace { + +GUI::Plater* current_plater() +{ + if (wxTheApp == nullptr) + throw std::runtime_error("OrcaSlicer application is not initialized"); + + GUI::Plater* plater = GUI::wxGetApp().plater(); + if (plater == nullptr) + throw std::runtime_error("Plater is not available"); + + return plater; +} + +PresetBundle* current_preset_bundle() +{ + if (wxTheApp == nullptr) + throw std::runtime_error("OrcaSlicer application is not initialized"); + + PresetBundle* preset_bundle = GUI::wxGetApp().preset_bundle; + if (preset_bundle == nullptr) + throw std::runtime_error("Preset bundle is not available"); + + return preset_bundle; +} + +} // namespace + +// Access to the live GUI application: the Plater and the module-level +// plater()/model()/preset_bundle() accessors. Everything here is owned by the +// app and only reachable once the GUI is up (the accessors throw before that). +void host_bindings::register_app(py::module_& host) +{ + py::class_>(host, "Plater") + .def("model", static_cast(&GUI::Plater::model), py::return_value_policy::reference_internal) + .def("is_project_dirty", &GUI::Plater::is_project_dirty) + .def("is_presets_dirty", &GUI::Plater::is_presets_dirty) + .def("inside_snapshot_capture", &GUI::Plater::inside_snapshot_capture); + + host.def("plater", ¤t_plater, py::return_value_policy::reference); + host.def("model", []() -> Model& { + return current_plater()->model(); + }, py::return_value_policy::reference); + host.def("preset_bundle", ¤t_preset_bundle, py::return_value_policy::reference); +} + +} // namespace Slic3r diff --git a/src/slic3r/plugin/host/PluginHostBindings.hpp b/src/slic3r/plugin/host/PluginHostBindings.hpp new file mode 100644 index 0000000000..0f206d5992 --- /dev/null +++ b/src/slic3r/plugin/host/PluginHostBindings.hpp @@ -0,0 +1,16 @@ +#pragma once + +#include + +// Internal to plugin/host/: the per-domain registrars of the `orca.host` +// surface, one per translation unit, called by PluginHost::RegisterBindings. +namespace Slic3r::host_bindings { + +void register_geometry(pybind11::module_& host); // PluginHostGeometry.cpp +void register_mesh(pybind11::module_& host); // PluginHostMesh.cpp +void register_presets(pybind11::module_& host); // PluginHostPresets.cpp +void register_model(pybind11::module_& host); // PluginHostModel.cpp +void register_app(pybind11::module_& host); // PluginHostApp.cpp +void register_slicing(pybind11::module_& host); // PluginHostSlicing.cpp + +} // namespace Slic3r::host_bindings diff --git a/src/slic3r/plugin/host/PluginHostGeometry.cpp b/src/slic3r/plugin/host/PluginHostGeometry.cpp new file mode 100644 index 0000000000..646dffb5d4 --- /dev/null +++ b/src/slic3r/plugin/host/PluginHostGeometry.cpp @@ -0,0 +1,216 @@ +#include "PluginHostBindings.hpp" +#include "slic3r/plugin/PluginBindingUtils.hpp" + +#include +#include // offset/offset_ex/union_ex/diff_ex/intersection_ex +#include + +#include + +#include +#include +#include + +namespace py = pybind11; + +namespace Slic3r { +namespace { +// --- Input path: Python geometry -> C++ Polygon/ExPolygon, 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. +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; +} + +// Accept a bound orca.host.Polygon (copied) or an (N,2) int64 ndarray. Used by the ExPolygon +// binding, whose constructor/contour-setter/set_holes must accept the Polygon it itself hands +// out (e.g. `ExPolygon(some_polygon_ref)`) in addition to the ndarray-only parse_polygon() path. +Polygon as_polygon(py::handle h, const char* who) +{ + if (py::isinstance(h)) + return h.cast(); + return parse_polygon(h, who); +} +} // namespace + +void host_bindings::register_geometry(py::module_& host) +{ + // ------------------------------------------------------------------ + // Geometry value types of the `orca.host` surface. All use pybind's + // default holder, so plugins can construct and own instances. When + // obtained from the live slicing graph they are non-owning references + // instead — see the lifetime rule in PluginHostSlicing.cpp. + // ------------------------------------------------------------------ + + // Axis-aligned bounding box, returned by value (a copy) so its lifetime is + // independent of the model object it was computed from. Coordinates are in mm. + py::class_(host, "BoundingBox", "Axis-aligned bounding box in millimetres") + .def_property_readonly("defined", [](const BoundingBoxf3& bb) { return bb.defined; }) + .def_property_readonly("min", [](const BoundingBoxf3& bb) { return vec3_to_tuple(bb.min); }) + .def_property_readonly("max", [](const BoundingBoxf3& bb) { return vec3_to_tuple(bb.max); }) + .def_property_readonly("size", [](const BoundingBoxf3& bb) { return vec3_to_tuple(bb.size()); }) + .def_property_readonly("center", [](const BoundingBoxf3& bb) { return vec3_to_tuple(bb.center()); }) + .def_property_readonly("radius", [](const BoundingBoxf3& bb) { return bb.radius(); }); + + // Point: a constructible value type (default holder, so Python-owned instances + // are freed). Returned-by-reference from Polygon.points, it aliases the buffer; + // x()/y() are Eigen lvalues, so the properties are read/write. p+q / p-q go + // through Eigen expression templates, wrapped back into a Point. + py::class_(host, "Point") + .def(py::init([](coord_t x, coord_t y) { return Point(x, y); }), py::arg("x"), py::arg("y")) + .def_property("x", [](const Point& p) { return p.x(); }, + [](Point& p, coord_t v) { p.x() = v; }) + .def_property("y", [](const Point& p) { return p.y(); }, + [](Point& p, coord_t v) { p.y() = v; }) + .def("__add__", [](const Point& a, const Point& b) { return Point(a + b); }, py::is_operator()) + .def("__sub__", [](const Point& a, const Point& b) { return Point(a - b); }, py::is_operator()) + .def("__mul__", [](const Point& a, double s) { return Point(a.x() * s, a.y() * s); }, py::is_operator()) + .def("__repr__", [](const Point& p) { + return "orca.host.Point(" + std::to_string(p.x()) + ", " + std::to_string(p.y()) + ")"; + }); + + py::class_(host, "Polygon") + .def(py::init<>()) + .def("size", [](const Polygon& p) { return p.points.size(); }) + .def("is_valid", [](const Polygon& p) { return p.is_valid(); }) + .def("is_counter_clockwise", [](const Polygon& p) { return p.is_counter_clockwise(); }) + .def("is_clockwise", [](const Polygon& p) { return p.is_clockwise(); }) + .def("make_counter_clockwise", [](Polygon& p) { return p.make_counter_clockwise(); }, + "Reorient to CCW in place. Returns True if it reversed the winding.") + .def("make_clockwise", [](Polygon& p) { return p.make_clockwise(); }) + .def("area", [](const Polygon& p) { return p.area(); }) + .def("centroid", [](const Polygon& p) { return p.centroid(); }) + .def("contains", [](const Polygon& p, const Point& pt) { return p.contains(pt); }, py::arg("point")) + .def("translate", [](Polygon& p, double x, double y) { p.translate(x, y); }, py::arg("x"), py::arg("y")) + .def("rotate", [](Polygon& p, double angle) { p.rotate(angle); }, py::arg("angle")) + .def("rotate", [](Polygon& p, double angle, const Point& c) { p.rotate(angle, c); }, + py::arg("angle"), py::arg("center")) + .def("douglas_peucker", [](Polygon& p, double tol) { p.douglas_peucker(tol); }, py::arg("tolerance")) + .def("simplify", [](const Polygon& p, double tol) { return p.simplify(tol); }, py::arg("tolerance"), + "Return simplified geometry as a list of Polygon (may split into several).") + .def("offset", [](const Polygon& p, coord_t delta) { return offset(p, (float) delta); }, py::arg("delta"), + "Clipper offset by `delta` scaled units (negative shrinks). Returns [Polygon].") + // --- Point-object idiom: references into the buffer (in-place element edit). --- + .def_property_readonly("points", [](py::object self) { + Polygon& p = self.cast(); + py::list out; + for (Point& pt : p.points) + out.append(py::cast(&pt, py::return_value_policy::reference_internal, self)); + return out; + }, "Vertices as [Point] references into this polygon. Editing a Point mutates the " + "buffer in place. Structural changes (count) go through set_points/append, which " + "invalidate previously returned Point refs and array views (C++ vector semantics).") + .def("append", [](Polygon& p, const Point& pt) { p.points.push_back(pt); }, py::arg("point"), + "Append a vertex. Structural change (count): invalidates previously returned " + "Point refs and array views into this polygon (C++ vector semantics).") + // --- numpy idiom: writable zero-copy (N,2) view (bulk affine edits). --- + .def("as_array", [](py::object self) { + Polygon& p = self.cast(); + return with_numpy([&] { + return py::object(make_writable_rows( + self, p.points.empty() ? nullptr : p.points.front().data(), + (py::ssize_t) p.points.size())); + }); + }, "Vertices as a WRITABLE int64 (N,2) numpy view in scaled coords, aliasing the " + "buffer. Count-preserving in-place edits only; valid during execute(ctx). Requires numpy.") + .def("set_points", [](Polygon& p, py::handle src) { p = parse_polygon(src, "Polygon.set_points"); }, + py::arg("points"), + "Replace all vertices from an (N,2) int64 ndarray (scaled coords). Count-changing; " + "invalidates prior Point refs and array views. Raises ValueError on malformed input."); + + // ExPolygon: default holder (Python-owned instances are freed) so plugins can construct + // their own geometry, not just navigate the live slicing graph. contour/holes accessors + // still use reference_internal, so refs into a graph-owned ExPolygon stay non-owning views + // tied to that owner's lifetime, same as Polygon/Surface. + py::class_(host, "ExPolygon") + .def(py::init([](py::handle contour, py::handle holes) { + // Accept bound Polygons or (N,2) ndarrays for both contour and each hole. + ExPolygon ex; + ex.contour = as_polygon(contour, "ExPolygon.contour"); + if (!holes.is_none()) { + if (!py::isinstance(holes) || py::isinstance(holes)) + throw py::value_error("ExPolygon: holes must be a list of Polygon or (N,2) ndarrays"); + for (py::handle h : py::reinterpret_borrow(holes)) { + Polygon hole = as_polygon(h, "ExPolygon.hole"); + hole.make_clockwise(); + ex.holes.emplace_back(std::move(hole)); + } + } + ex.contour.make_counter_clockwise(); + return ex; + }), py::arg("contour"), py::arg("holes") = py::none(), + "Construct from a Polygon/ndarray contour and optional list of hole Polygons/ndarrays. " + "Orientation is normalized (contour CCW, holes CW).") + .def_property("contour", + [](ExPolygon& e) -> Polygon& { return e.contour; }, + [](ExPolygon& e, py::handle v) { e.contour = as_polygon(v, "ExPolygon.contour"); }, + py::return_value_policy::reference_internal, + "Outer contour (CCW). Read returns a live Polygon ref; assign a Polygon/ndarray to replace it.") + .def_property_readonly("holes", [](py::object self) { + ExPolygon& e = self.cast(); + py::list out; + for (Polygon& h : e.holes) + out.append(py::cast(&h, py::return_value_policy::reference_internal, self)); + return out; + }, "Hole contours (CW) as [Polygon] references (in-place editable). set_holes replaces them.") + .def("set_holes", [](ExPolygon& e, py::handle holes) { + ExPolygon tmp; + if (!py::isinstance(holes) || py::isinstance(holes)) + throw py::value_error("set_holes: expected a list of Polygon or (N,2) ndarrays"); + for (py::handle h : py::reinterpret_borrow(holes)) { + Polygon hole = as_polygon(h, "ExPolygon.set_holes"); + hole.make_clockwise(); + tmp.holes.emplace_back(std::move(hole)); + } + e.holes = std::move(tmp.holes); + }, py::arg("holes"), "Replace all holes. Invalidates prior hole refs (C++ vector semantics).") + .def("translate", [](ExPolygon& e, double x, double y) { e.translate(x, y); }, py::arg("x"), py::arg("y")) + .def("rotate", [](ExPolygon& e, double a) { e.rotate(a); }, py::arg("angle")) + .def("rotate", [](ExPolygon& e, double a, const Point& c) { e.rotate(a, c); }, + py::arg("angle"), py::arg("center")) + .def("scale", [](ExPolygon& e, double f) { e.scale(f); }, py::arg("factor")) + .def("douglas_peucker", [](ExPolygon& e, double t) { e.douglas_peucker(t); }, py::arg("tolerance")) + .def("area", [](const ExPolygon& e) { return e.area(); }) + .def("is_valid", [](const ExPolygon& e) { return e.is_valid(); }) + .def("contains", [](const ExPolygon& e, const Point& p) { return e.contains(p); }, py::arg("point")) + .def("num_contours", [](const ExPolygon& e) { return e.num_contours(); }) + .def("simplify", [](const ExPolygon& e, double t) { return e.simplify(t); }, py::arg("tolerance"), + "Return simplified geometry as [ExPolygon].") + .def("offset", [](const ExPolygon& e, coord_t delta) { return offset_ex(e, (float) delta); }, + py::arg("delta"), "Clipper offset by `delta` scaled units (negative shrinks). Returns [ExPolygon].") + .def("union_ex", [](const ExPolygon& a, const ExPolygon& b) { + return union_ex(ExPolygons{ a, b }); + }, py::arg("other"), "Union with another ExPolygon. Returns [ExPolygon].") + .def("diff_ex", [](const ExPolygon& a, const ExPolygon& b) { + return diff_ex(ExPolygons{ a }, ExPolygons{ b }); + }, py::arg("other"), "This minus `other`. Returns [ExPolygon].") + .def("intersection_ex", [](const ExPolygon& a, const ExPolygon& b) { + return intersection_ex(ExPolygons{ a }, ExPolygons{ b }); + }, py::arg("other"), "Intersection with `other`. Returns [ExPolygon]."); +} + +} // namespace Slic3r diff --git a/src/slic3r/plugin/host/PluginHostMesh.cpp b/src/slic3r/plugin/host/PluginHostMesh.cpp new file mode 100644 index 0000000000..3661f964ed --- /dev/null +++ b/src/slic3r/plugin/host/PluginHostMesh.cpp @@ -0,0 +1,105 @@ +#include "PluginHostBindings.hpp" +#include "PluginHostMesh.hpp" +#include "slic3r/plugin/PluginBindingUtils.hpp" + +#include + +#include +#include +#include + +namespace py = pybind11; + +namespace Slic3r { +namespace { + +// Zero-copy export of its.vertices / its.indices relies on these Eigen +// row-vectors being tightly packed (no padding between the 3 components). +static_assert(sizeof(stl_vertex) == 3 * sizeof(float), + "stl_vertex must be a packed float[3] for zero-copy numpy export"); +static_assert(sizeof(stl_triangle_vertex_indices) == 3 * sizeof(std::int32_t), + "triangle index must be a packed int32[3] for zero-copy numpy export"); + +// Read-only, zero-copy (rows, 3) numpy view over a packed T[rows][3] buffer. +// The array's base is a capsule owning a strong ref to `mesh`, so the view +// stays valid even if the volume's mesh is later replaced on the main thread. +template +py::array make_readonly_rows3(const std::shared_ptr& mesh, + const T* data, py::ssize_t rows) +{ + if (rows == 0 || data == nullptr) + return py::array_t(std::vector{ 0, 3 }); + auto* owner = new std::shared_ptr(mesh); + py::capsule base(owner, [](void* p) { + delete reinterpret_cast*>(p); + }); + return make_readonly_rows(base, data, rows); +} + +} // namespace + +void host_bindings::register_mesh(py::module_& host) +{ + py::class_(host, "TriangleMesh", + "Immutable snapshot of a ModelVolume's mesh in local (untransformed) coordinates, mm.") + .def("vertex_count", [](const HostTriangleMesh& mesh) { return mesh.its().vertices.size(); }) + .def("triangle_count", [](const HostTriangleMesh& mesh) { return mesh.its().indices.size(); }) + .def("facets_count", [](const HostTriangleMesh& mesh) { return mesh.its().indices.size(); }) + .def("is_empty", [](const HostTriangleMesh& mesh) { return mesh.its().indices.empty(); }) + // Read-only, zero-copy (N, 3) float32 view of vertex positions. Requires numpy. + .def("vertices", [](const HostTriangleMesh& mesh) { + return with_numpy([&] { + const indexed_triangle_set& its = mesh.its(); + return make_readonly_rows3( + mesh.mesh, + its.vertices.empty() ? nullptr : its.vertices.front().data(), + static_cast(its.vertices.size())); + }); + }, "Read-only zero-copy (N, 3) float32 ndarray of vertex positions (local mm). Requires numpy.") + // Read-only, zero-copy (M, 3) int32 view of triangle vertex indices. Requires numpy. + .def("triangles", [](const HostTriangleMesh& mesh) { + return with_numpy([&] { + const indexed_triangle_set& its = mesh.its(); + return make_readonly_rows3( + mesh.mesh, + its.indices.empty() ? nullptr : its.indices.front().data(), + static_cast(its.indices.size())); + }); + }, "Read-only zero-copy (M, 3) int32 ndarray of triangle vertex indices. Requires numpy.") + // One normalized normal per triangle as an (M, 3) float32 copy. Requires numpy. + .def("face_normals", [](const HostTriangleMesh& mesh) { + return with_numpy([&] { + std::vector normals = its_face_normals(mesh.its()); + py::array_t array({ static_cast(normals.size()), py::ssize_t(3) }); + if (!normals.empty()) { + auto view = array.mutable_unchecked<2>(); + for (size_t i = 0; i < normals.size(); ++i) { + view(i, 0) = normals[i].x(); + view(i, 1) = normals[i].y(); + view(i, 2) = normals[i].z(); + } + } + return py::object(std::move(array)); + }); + }, "Per-triangle normalized normals as an (M, 3) float32 ndarray (copy). Requires numpy.") + // numpy-free element access, bounds-checked. + .def("vertex", [](const HostTriangleMesh& mesh, size_t index) { + const std::vector& vertices = mesh.its().vertices; + if (index >= vertices.size()) + throw py::index_error("vertex index out of range"); + const stl_vertex& vertex = vertices[index]; + return py::make_tuple(vertex.x(), vertex.y(), vertex.z()); + }) + .def("triangle", [](const HostTriangleMesh& mesh, size_t index) { + const std::vector& indices = mesh.its().indices; + if (index >= indices.size()) + throw py::index_error("triangle index out of range"); + const stl_triangle_vertex_indices& triangle = indices[index]; + return py::make_tuple(triangle[0], triangle[1], triangle[2]); + }) + .def("volume", [](const HostTriangleMesh& mesh) { return mesh.mesh->stats().volume; }) + .def("bounding_box", [](const HostTriangleMesh& mesh) { return bbox_from_stats(mesh.mesh->stats()); }) + .def("is_manifold", [](const HostTriangleMesh& mesh) { return mesh.mesh->stats().manifold(); }); +} + +} // namespace Slic3r diff --git a/src/slic3r/plugin/host/PluginHostMesh.hpp b/src/slic3r/plugin/host/PluginHostMesh.hpp new file mode 100644 index 0000000000..9aca05c59d --- /dev/null +++ b/src/slic3r/plugin/host/PluginHostMesh.hpp @@ -0,0 +1,28 @@ +#pragma once + +#include +#include + +#include + +namespace Slic3r { + +// Immutable snapshot of a ModelVolume's mesh. Holding a strong reference to the +// const mesh keeps any zero-copy numpy views valid even if the volume's mesh is +// later replaced on the main thread. Bound as `orca.host.TriangleMesh` in +// PluginHostMesh.cpp; constructed by ModelVolume.mesh() in PluginHostModel.cpp. +struct HostTriangleMesh +{ + std::shared_ptr mesh; + const indexed_triangle_set& its() const { return mesh->its; } +}; + +// Build a BoundingBoxf3 from precomputed (float) triangle-mesh stats min/max. +inline BoundingBoxf3 bbox_from_stats(const TriangleMeshStats& stats) +{ + if (stats.number_of_facets == 0) + return BoundingBoxf3(); + return BoundingBoxf3(stats.min.cast(), stats.max.cast()); +} + +} // namespace Slic3r diff --git a/src/slic3r/plugin/host/PluginHostModel.cpp b/src/slic3r/plugin/host/PluginHostModel.cpp new file mode 100644 index 0000000000..51fdb180e7 --- /dev/null +++ b/src/slic3r/plugin/host/PluginHostModel.cpp @@ -0,0 +1,203 @@ +#include "PluginHostBindings.hpp" +#include "PluginHostMesh.hpp" +#include "slic3r/plugin/PluginBindingUtils.hpp" + +#include + +#include + +#include + +namespace py = pybind11; + +namespace Slic3r { + +// The scene/document graph: Model -> ModelObject -> ModelInstance/ModelVolume. +// Everything is bound py::nodelete — non-owning references into a graph owned +// by the app (the live Plater model) or by a Print's model snapshot. +void host_bindings::register_model(py::module_& host) +{ + py::enum_(host, "ModelVolumeType") + .value("Invalid", ModelVolumeType::INVALID) + .value("ModelPart", ModelVolumeType::MODEL_PART) + .value("NegativeVolume", ModelVolumeType::NEGATIVE_VOLUME) + .value("ParameterModifier", ModelVolumeType::PARAMETER_MODIFIER) + .value("SupportBlocker", ModelVolumeType::SUPPORT_BLOCKER) + .value("SupportEnforcer", ModelVolumeType::SUPPORT_ENFORCER); + + py::class_>(host, "ModelVolume") + .def("id", [](const ModelVolume& volume) { return volume.id().id; }) + .def_readonly("name", &ModelVolume::name) + .def("type", &ModelVolume::type) + .def("is_model_part", &ModelVolume::is_model_part) + .def("is_modifier", &ModelVolume::is_modifier) + .def("is_negative_volume", &ModelVolume::is_negative_volume) + .def("is_support_enforcer", &ModelVolume::is_support_enforcer) + .def("is_support_blocker", &ModelVolume::is_support_blocker) + .def("is_support_modifier", &ModelVolume::is_support_modifier) + // Extruder ID is 1-based for FFF, -1 for SLA or support volumes. + .def("extruder_id", &ModelVolume::extruder_id) + .def("offset", [](const ModelVolume& volume) { return vec3_to_tuple(volume.get_offset()); }) + .def("rotation", [](const ModelVolume& volume) { return vec3_to_tuple(volume.get_rotation()); }) + .def("scaling_factor", [](const ModelVolume& volume) { return vec3_to_tuple(volume.get_scaling_factor()); }) + .def("mirror", [](const ModelVolume& volume) { return vec3_to_tuple(volume.get_mirror()); }) + // 4x4 float64 affine matrix mapping this volume into its parent object frame. Requires numpy. + .def("matrix", [](const ModelVolume& volume) { return mat4_to_numpy(volume.get_matrix()); }, + "Volume-to-object 4x4 float64 affine matrix (copy). Requires numpy.") + .def("facets_count", [](const ModelVolume& volume) { return volume.mesh().facets_count(); }) + // Raw (untransformed) mesh volume in mm^3; -1 if it was never computed. + .def("volume", [](const ModelVolume& volume) { return volume.mesh().stats().volume; }) + // Bounding box of the raw (untransformed) mesh, in the volume's local frame. + .def("bounding_box", [](const ModelVolume& volume) { return bbox_from_stats(volume.mesh().stats()); }) + .def("is_manifold", [](const ModelVolume& volume) { return volume.mesh().stats().manifold(); }) + // Full mesh geometry (vertices/triangles) as an immutable snapshot. + .def("mesh", [](const ModelVolume& volume) { + return HostTriangleMesh{ volume.get_mesh_shared_ptr() }; + }, "Return the volume's TriangleMesh (local coordinates) for vertex/triangle access.") + .def("mesh_errors_count", [](const ModelVolume& volume) { return volume.get_repaired_errors_count(); }) + .def("is_fdm_support_painted", &ModelVolume::is_fdm_support_painted) + .def("is_seam_painted", &ModelVolume::is_seam_painted) + .def("is_mm_painted", &ModelVolume::is_mm_painted) + .def("is_fuzzy_skin_painted", &ModelVolume::is_fuzzy_skin_painted) + .def("config_keys", [](const ModelVolume& volume) { return volume.config.keys(); }) + .def("config_value", [](const ModelVolume& volume, const std::string& key) { + return config_value_or_none(volume.config.get(), key); + }); + + py::class_>(host, "ModelInstance") + .def("id", [](const ModelInstance& instance) { return instance.id().id; }) + .def_readonly("printable", &ModelInstance::printable) + // True only if the object is printable, this instance is printable and it + // currently sits fully inside the print volume (set during slicing). + .def("is_printable", &ModelInstance::is_printable) + .def("offset", [](const ModelInstance& instance) { return vec3_to_tuple(instance.get_offset()); }) + .def("rotation", [](const ModelInstance& instance) { return vec3_to_tuple(instance.get_rotation()); }) + .def("scaling_factor", [](const ModelInstance& instance) { return vec3_to_tuple(instance.get_scaling_factor()); }) + .def("mirror", [](const ModelInstance& instance) { return vec3_to_tuple(instance.get_mirror()); }) + // 4x4 float64 affine matrix mapping the object into world space. Requires numpy. + // World vertices = instance.matrix() @ volume.matrix() applied to mesh vertices. + .def("matrix", [](const ModelInstance& instance) { return mat4_to_numpy(instance.get_matrix()); }, + "Object-to-world 4x4 float64 affine matrix (copy). Requires numpy.") + .def("is_left_handed", &ModelInstance::is_left_handed) + // Assemble-view placement. Each instance carries a second transform used only by + // the Assemble view, set from stored 3mf assemble data or derived from the regular + // transform. Until then (is_assemble_initialized() false) it is identity. + .def("is_assemble_initialized", [](ModelInstance& instance) { return instance.is_assemble_initialized(); }) + .def("assemble_offset", [](const ModelInstance& instance) { + return vec3_to_tuple(instance.get_assemble_transformation().get_offset()); + }) + .def("assemble_rotation", [](const ModelInstance& instance) { + return vec3_to_tuple(instance.get_assemble_transformation().get_rotation()); + }) + // 4x4 float64 affine matrix placing the object in the Assemble view. Requires numpy. + .def("assemble_matrix", [](const ModelInstance& instance) { + return mat4_to_numpy(instance.get_assemble_transformation().get_matrix()); + }, "Assemble-view 4x4 float64 affine matrix (copy). Requires numpy.") + // Offset from the instance origin to its position within the source assembly, + // recorded at import time (e.g. from a STEP assembly). + .def("offset_to_assembly", [](const ModelInstance& instance) { + return vec3_to_tuple(instance.get_offset_to_assembly()); + }) + // World-space bounding box of this instance. + .def("bounding_box", [](ModelInstance& instance) { + const ModelObject* object = instance.get_object(); + if (object == nullptr) + return BoundingBoxf3(); + return object->instance_bounding_box(instance); + }); + + py::class_>(host, "ModelObject") + .def("id", [](const ModelObject& object) { return object.id().id; }) + .def_readonly("name", &ModelObject::name) + .def_readonly("module_name", &ModelObject::module_name) + .def_readonly("input_file", &ModelObject::input_file) + // Import-time flag only: the GUI's printable toggle writes the per-instance + // ModelInstance::printable and never updates this field, so derive an + // object's effective state from its instances. + .def_readonly("printable", &ModelObject::printable) + .def("instance_count", [](const ModelObject& object) { + return object.instances.size(); + }) + .def("volume_count", [](const ModelObject& object) { + return object.volumes.size(); + }) + .def("instances", [](ModelObject& object) { + py::list instances; + for (ModelInstance* instance : object.instances) + instances.append(py::cast(instance, py::return_value_policy::reference)); + return instances; + }) + .def("instance", [](ModelObject& object, size_t index) -> ModelInstance* { + if (index >= object.instances.size()) + throw py::index_error("instance index out of range"); + return object.instances[index]; + }, py::return_value_policy::reference_internal) + .def("volumes", [](ModelObject& object) { + py::list volumes; + for (ModelVolume* volume : object.volumes) + volumes.append(py::cast(volume, py::return_value_policy::reference)); + return volumes; + }) + .def("volume", [](ModelObject& object, size_t index) -> ModelVolume* { + if (index >= object.volumes.size()) + throw py::index_error("volume index out of range"); + return object.volumes[index]; + }, py::return_value_policy::reference_internal) + // World-space bounding box over all instances of this object. + .def("bounding_box", [](const ModelObject& object) { return object.bounding_box_exact(); }) + // Bounding box of the object's raw (untransformed) part meshes — its intrinsic size. + .def("raw_mesh_bounding_box", [](const ModelObject& object) { return object.raw_mesh_bounding_box(); }) + .def("min_z", &ModelObject::min_z) + .def("max_z", &ModelObject::max_z) + .def("facets_count", [](const ModelObject& object) { return object.facets_count(); }) + .def("parts_count", [](const ModelObject& object) { return object.parts_count(); }) + .def("materials_count", [](const ModelObject& object) { return object.materials_count(); }) + .def("mesh_errors_count", [](const ModelObject& object) { return object.get_repaired_errors_count(); }) + .def("is_multiparts", &ModelObject::is_multiparts) + .def("is_cut", &ModelObject::is_cut) + .def("has_custom_layering", &ModelObject::has_custom_layering) + .def("is_fdm_support_painted", &ModelObject::is_fdm_support_painted) + .def("is_seam_painted", &ModelObject::is_seam_painted) + .def("is_mm_painted", &ModelObject::is_mm_painted) + .def("is_fuzzy_skin_painted", &ModelObject::is_fuzzy_skin_painted) + .def("config_keys", [](const ModelObject& object) { + return object.config.keys(); + }) + .def("config_value", [](const ModelObject& object, const std::string& key) { + return config_value_or_none(object.config.get(), key); + }); + + py::class_>(host, "Model") + .def("id", [](const Model& model) { return model.id().id; }) + .def("object_count", [](const Model& model) { + return model.objects.size(); + }) + .def("object", [](Model& model, size_t index) -> ModelObject* { + if (index >= model.objects.size()) + throw py::index_error("model object index out of range"); + return model.objects[index]; + }, py::return_value_policy::reference_internal) + .def("objects", [](Model& model) { + py::list objects; + for (ModelObject* object : model.objects) + objects.append(py::cast(object, py::return_value_policy::reference)); + return objects; + }) + // World-space bounding box of the whole model. bounding_box() is exact; + // bounding_box_approx() is faster and cached. + .def("bounding_box", [](const Model& model) { return model.bounding_box_exact(); }) + .def("bounding_box_approx", [](const Model& model) { return model.bounding_box_approx(); }) + .def("max_z", &Model::max_z) + .def("material_count", [](const Model& model) { return model.materials.size(); }) + .def("is_fdm_support_painted", &Model::is_fdm_support_painted) + .def("is_seam_painted", &Model::is_seam_painted) + .def("is_mm_painted", &Model::is_mm_painted) + .def("is_fuzzy_skin_painted", &Model::is_fuzzy_skin_painted) + .def("current_plate_index", [](const Model& model) { return model.curr_plate_index; }) + .def("designer", [](const Model& model) { + return model.design_info ? model.design_info->Designer : std::string(); + }) + .def("design_id", [](const Model& model) { return model.stl_design_id; }); +} + +} // namespace Slic3r diff --git a/src/slic3r/plugin/host/PluginHostPresets.cpp b/src/slic3r/plugin/host/PluginHostPresets.cpp new file mode 100644 index 0000000000..fffe9e64cf --- /dev/null +++ b/src/slic3r/plugin/host/PluginHostPresets.cpp @@ -0,0 +1,138 @@ +#include "PluginHostBindings.hpp" +#include "slic3r/plugin/PluginBindingUtils.hpp" + +#include +#include + +#include + +#include +#include + +namespace py = pybind11; + +namespace Slic3r { +namespace { + +py::list current_filament_presets(PresetBundle& bundle) +{ + py::list presets; + for (const std::string& preset_name : bundle.filament_presets) { + Preset* preset = bundle.filaments.find_preset(preset_name); + if (preset == nullptr) + presets.append(py::none()); + else + presets.append(py::cast(preset, py::return_value_policy::reference)); + } + return presets; +} + +PresetCollection& printer_presets(PresetBundle& bundle) +{ + return static_cast(bundle.printers); +} + +} // namespace + +void host_bindings::register_presets(py::module_& host) +{ + py::enum_(host, "PresetType") + .value("Invalid", Preset::TYPE_INVALID) + .value("Print", Preset::TYPE_PRINT) + .value("SlaPrint", Preset::TYPE_SLA_PRINT) + .value("Filament", Preset::TYPE_FILAMENT) + .value("SlaMaterial", Preset::TYPE_SLA_MATERIAL) + .value("Printer", Preset::TYPE_PRINTER) + .value("PhysicalPrinter", Preset::TYPE_PHYSICAL_PRINTER) + .value("Plate", Preset::TYPE_PLATE) + .value("Model", Preset::TYPE_MODEL); + + py::class_>(host, "Preset") + .def_readonly("type", &Preset::type) + .def_readonly("name", &Preset::name) + .def_readonly("alias", &Preset::alias) + .def_readonly("file", &Preset::file) + .def_readonly("is_default", &Preset::is_default) + .def_readonly("is_external", &Preset::is_external) + .def_readonly("is_system", &Preset::is_system) + .def_readonly("is_visible", &Preset::is_visible) + .def_readonly("is_dirty", &Preset::is_dirty) + .def_readonly("is_compatible", &Preset::is_compatible) + .def_readonly("is_project_embedded", &Preset::is_project_embedded) + .def_readonly("bundle_id", &Preset::bundle_id) + .def("is_user", &Preset::is_user) + .def("is_from_bundle", &Preset::is_from_bundle) + .def("label", &Preset::label, py::arg("no_alias") = false) + .def("config_keys", [](const Preset& preset) { return preset.config.keys(); }) + .def("config_value", [](const Preset& preset, const std::string& key) { + return config_value_or_none(preset.config, key); + }); + + py::class_>(host, "PresetCollection") + .def("size", &PresetCollection::size) + .def("get_selected_preset", [](PresetCollection& collection) -> Preset& { + return collection.get_selected_preset(); + }, py::return_value_policy::reference_internal) + .def("selected_preset", [](PresetCollection& collection) -> Preset& { + return collection.get_selected_preset(); + }, py::return_value_policy::reference_internal) + .def("get_selected_preset_name", &PresetCollection::get_selected_preset_name) + .def("selected_preset_name", &PresetCollection::get_selected_preset_name) + .def("get_edited_preset", [](PresetCollection& collection) -> Preset& { + return collection.get_edited_preset(); + }, py::return_value_policy::reference_internal) + .def("edited_preset", [](PresetCollection& collection) -> Preset& { + return collection.get_edited_preset(); + }, py::return_value_policy::reference_internal) + .def("preset", [](PresetCollection& collection, size_t index) -> Preset& { + if (index >= collection.size()) + throw py::index_error("preset index out of range"); + return collection.preset(index); + }, py::return_value_policy::reference_internal) + .def("find_preset", [](PresetCollection& collection, const std::string& name) -> Preset* { + return collection.find_preset(name); + }, py::return_value_policy::reference_internal) + .def("preset_names", [](const PresetCollection& collection) { + std::vector names; + names.reserve(collection.get_presets().size()); + for (const Preset& preset : collection.get_presets()) + names.push_back(preset.name); + return names; + }); + + py::class_>(host, "PresetBundle") + .def_property_readonly("prints", [](PresetBundle& bundle) -> PresetCollection& { + return bundle.prints; + }, py::return_value_policy::reference_internal) + .def_property_readonly("printers", &printer_presets, py::return_value_policy::reference_internal) + .def_property_readonly("filaments", [](PresetBundle& bundle) -> PresetCollection& { + return bundle.filaments; + }, py::return_value_policy::reference_internal) + .def_property_readonly("sla_prints", [](PresetBundle& bundle) -> PresetCollection& { + return bundle.sla_prints; + }, py::return_value_policy::reference_internal) + .def_property_readonly("sla_materials", [](PresetBundle& bundle) -> PresetCollection& { + return bundle.sla_materials; + }, py::return_value_policy::reference_internal) + .def("current_process_preset", [](PresetBundle& bundle) -> Preset& { + return bundle.prints.get_edited_preset(); + }, py::return_value_policy::reference_internal) + .def("current_print_preset", [](PresetBundle& bundle) -> Preset& { + return bundle.prints.get_edited_preset(); + }, py::return_value_policy::reference_internal) + .def("current_printer_preset", [](PresetBundle& bundle) -> Preset& { + return bundle.printers.get_edited_preset(); + }, py::return_value_policy::reference_internal) + .def("current_filament_preset_names", [](PresetBundle& bundle) { + return bundle.filament_presets; + }) + .def("current_filament_presets", ¤t_filament_presets) + .def("full_config_keys", [](const PresetBundle& bundle) { + return bundle.full_config().keys(); + }) + .def("full_config_value", [](const PresetBundle& bundle, const std::string& key) { + return config_value_or_none(bundle.full_config(), key); + }); +} + +} // namespace Slic3r diff --git a/src/slic3r/plugin/PluginHostSlicing.cpp b/src/slic3r/plugin/host/PluginHostSlicing.cpp similarity index 62% rename from src/slic3r/plugin/PluginHostSlicing.cpp rename to src/slic3r/plugin/host/PluginHostSlicing.cpp index 1db8120eb7..30e7b45138 100644 --- a/src/slic3r/plugin/PluginHostSlicing.cpp +++ b/src/slic3r/plugin/host/PluginHostSlicing.cpp @@ -1,9 +1,7 @@ -#include "PluginHostSlicing.hpp" -#include "PluginBindingUtils.hpp" +#include "PluginHostBindings.hpp" +#include "slic3r/plugin/PluginBindingUtils.hpp" -#include "libslic3r/libslic3r.h" // unscale<>, scale_ #include "libslic3r/BoundingBox.hpp" -#include "libslic3r/ClipperUtils.hpp" // offset/offset_ex/union_ex/diff_ex/intersection_ex #include "libslic3r/ExPolygon.hpp" #include "libslic3r/Surface.hpp" #include "libslic3r/SurfaceCollection.hpp" @@ -20,47 +18,6 @@ namespace py = pybind11; namespace Slic3r { namespace { -// --- 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; -} - -// Accept a bound orca.host.Polygon (copied) or an (N,2) int64 ndarray. Used by the ExPolygon -// binding, whose constructor/contour-setter/set_holes must accept the Polygon it itself hands -// out (e.g. `ExPolygon(some_polygon_ref)`) in addition to the ndarray-only parse_polygon() path. -static Polygon as_polygon(py::handle h, const char* who) -{ - if (py::isinstance(h)) - return h.cast(); - return parse_polygon(h, who); -} - // 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 @@ -104,12 +61,12 @@ static void refresh_lslices_bboxes(Layer& l) } } // namespace -void PluginHostSlicing::RegisterBindings(py::module_& host) +void host_bindings::register_slicing(py::module_& host) { // ------------------------------------------------------------------ // Slicing print-graph data model — raw bindings of the classes the C++ // pipeline itself uses, same nodelete/reference style as the Model and - // Preset graphs in PluginHostApi.cpp. + // Preset graphs in PluginHostModel.cpp / PluginHostPresets.cpp. // // LIFETIME (C++ semantics, the one rule of this API): every object handed // out below is a non-owning reference into the live slicing graph owned by @@ -135,145 +92,10 @@ void PluginHostSlicing::RegisterBindings(py::module_& host) .value("stCount", stCount) .export_values(); - // Point: a constructible value type (default holder, so Python-owned instances - // are freed). Returned-by-reference from Polygon.points, it aliases the buffer; - // x()/y() are Eigen lvalues, so the properties are read/write. p+q / p-q go - // through Eigen expression templates, wrapped back into a Point. - py::class_(host, "Point") - .def(py::init([](coord_t x, coord_t y) { return Point(x, y); }), py::arg("x"), py::arg("y")) - .def_property("x", [](const Point& p) { return p.x(); }, - [](Point& p, coord_t v) { p.x() = v; }) - .def_property("y", [](const Point& p) { return p.y(); }, - [](Point& p, coord_t v) { p.y() = v; }) - .def("__add__", [](const Point& a, const Point& b) { return Point(a + b); }, py::is_operator()) - .def("__sub__", [](const Point& a, const Point& b) { return Point(a - b); }, py::is_operator()) - .def("__mul__", [](const Point& a, double s) { return Point(a.x() * s, a.y() * s); }, py::is_operator()) - .def("__repr__", [](const Point& p) { - return "orca.host.Point(" + std::to_string(p.x()) + ", " + std::to_string(p.y()) + ")"; - }); - - py::class_(host, "Polygon") - .def(py::init<>()) - .def("size", [](const Polygon& p) { return p.points.size(); }) - .def("is_valid", [](const Polygon& p) { return p.is_valid(); }) - .def("is_counter_clockwise", [](const Polygon& p) { return p.is_counter_clockwise(); }) - .def("is_clockwise", [](const Polygon& p) { return p.is_clockwise(); }) - .def("make_counter_clockwise", [](Polygon& p) { return p.make_counter_clockwise(); }, - "Reorient to CCW in place. Returns True if it reversed the winding.") - .def("make_clockwise", [](Polygon& p) { return p.make_clockwise(); }) - .def("area", [](const Polygon& p) { return p.area(); }) - .def("centroid", [](const Polygon& p) { return p.centroid(); }) - .def("contains", [](const Polygon& p, const Point& pt) { return p.contains(pt); }, py::arg("point")) - .def("translate", [](Polygon& p, double x, double y) { p.translate(x, y); }, py::arg("x"), py::arg("y")) - .def("rotate", [](Polygon& p, double angle) { p.rotate(angle); }, py::arg("angle")) - .def("rotate", [](Polygon& p, double angle, const Point& c) { p.rotate(angle, c); }, - py::arg("angle"), py::arg("center")) - .def("douglas_peucker", [](Polygon& p, double tol) { p.douglas_peucker(tol); }, py::arg("tolerance")) - .def("simplify", [](const Polygon& p, double tol) { return p.simplify(tol); }, py::arg("tolerance"), - "Return simplified geometry as a list of Polygon (may split into several).") - .def("offset", [](const Polygon& p, coord_t delta) { return offset(p, (float) delta); }, py::arg("delta"), - "Clipper offset by `delta` scaled units (negative shrinks). Returns [Polygon].") - // --- Point-object idiom: references into the buffer (in-place element edit). --- - .def_property_readonly("points", [](py::object self) { - Polygon& p = self.cast(); - py::list out; - for (Point& pt : p.points) - out.append(py::cast(&pt, py::return_value_policy::reference_internal, self)); - return out; - }, "Vertices as [Point] references into this polygon. Editing a Point mutates the " - "buffer in place. Structural changes (count) go through set_points/append, which " - "invalidate previously returned Point refs and array views (C++ vector semantics).") - .def("append", [](Polygon& p, const Point& pt) { p.points.push_back(pt); }, py::arg("point"), - "Append a vertex. Structural change (count): invalidates previously returned " - "Point refs and array views into this polygon (C++ vector semantics).") - // --- numpy idiom: writable zero-copy (N,2) view (bulk affine edits). --- - .def("as_array", [](py::object self) { - Polygon& p = self.cast(); - return with_numpy([&] { - return py::object(make_writable_rows( - self, p.points.empty() ? nullptr : p.points.front().data(), - (py::ssize_t) p.points.size())); - }); - }, "Vertices as a WRITABLE int64 (N,2) numpy view in scaled coords, aliasing the " - "buffer. Count-preserving in-place edits only; valid during execute(ctx). Requires numpy.") - .def("set_points", [](Polygon& p, py::handle src) { p = parse_polygon(src, "Polygon.set_points"); }, - py::arg("points"), - "Replace all vertices from an (N,2) int64 ndarray (scaled coords). Count-changing; " - "invalidates prior Point refs and array views. Raises ValueError on malformed input."); - - // ExPolygon: default holder (Python-owned instances are freed) so plugins can construct - // their own geometry, not just navigate the live slicing graph. contour/holes accessors - // still use reference_internal, so refs into a graph-owned ExPolygon stay non-owning views - // tied to that owner's lifetime, same as Polygon/Surface above. - py::class_(host, "ExPolygon") - .def(py::init([](py::handle contour, py::handle holes) { - // Accept bound Polygons or (N,2) ndarrays for both contour and each hole. - ExPolygon ex; - ex.contour = as_polygon(contour, "ExPolygon.contour"); - if (!holes.is_none()) { - if (!py::isinstance(holes) || py::isinstance(holes)) - throw py::value_error("ExPolygon: holes must be a list of Polygon or (N,2) ndarrays"); - for (py::handle h : py::reinterpret_borrow(holes)) { - Polygon hole = as_polygon(h, "ExPolygon.hole"); - hole.make_clockwise(); - ex.holes.emplace_back(std::move(hole)); - } - } - ex.contour.make_counter_clockwise(); - return ex; - }), py::arg("contour"), py::arg("holes") = py::none(), - "Construct from a Polygon/ndarray contour and optional list of hole Polygons/ndarrays. " - "Orientation is normalized (contour CCW, holes CW).") - .def_property("contour", - [](ExPolygon& e) -> Polygon& { return e.contour; }, - [](ExPolygon& e, py::handle v) { e.contour = as_polygon(v, "ExPolygon.contour"); }, - py::return_value_policy::reference_internal, - "Outer contour (CCW). Read returns a live Polygon ref; assign a Polygon/ndarray to replace it.") - .def_property_readonly("holes", [](py::object self) { - ExPolygon& e = self.cast(); - py::list out; - for (Polygon& h : e.holes) - out.append(py::cast(&h, py::return_value_policy::reference_internal, self)); - return out; - }, "Hole contours (CW) as [Polygon] references (in-place editable). set_holes replaces them.") - .def("set_holes", [](ExPolygon& e, py::handle holes) { - ExPolygon tmp; - if (!py::isinstance(holes) || py::isinstance(holes)) - throw py::value_error("set_holes: expected a list of Polygon or (N,2) ndarrays"); - for (py::handle h : py::reinterpret_borrow(holes)) { - Polygon hole = as_polygon(h, "ExPolygon.set_holes"); - hole.make_clockwise(); - tmp.holes.emplace_back(std::move(hole)); - } - e.holes = std::move(tmp.holes); - }, py::arg("holes"), "Replace all holes. Invalidates prior hole refs (C++ vector semantics).") - .def("translate", [](ExPolygon& e, double x, double y) { e.translate(x, y); }, py::arg("x"), py::arg("y")) - .def("rotate", [](ExPolygon& e, double a) { e.rotate(a); }, py::arg("angle")) - .def("rotate", [](ExPolygon& e, double a, const Point& c) { e.rotate(a, c); }, - py::arg("angle"), py::arg("center")) - .def("scale", [](ExPolygon& e, double f) { e.scale(f); }, py::arg("factor")) - .def("douglas_peucker", [](ExPolygon& e, double t) { e.douglas_peucker(t); }, py::arg("tolerance")) - .def("area", [](const ExPolygon& e) { return e.area(); }) - .def("is_valid", [](const ExPolygon& e) { return e.is_valid(); }) - .def("contains", [](const ExPolygon& e, const Point& p) { return e.contains(p); }, py::arg("point")) - .def("num_contours", [](const ExPolygon& e) { return e.num_contours(); }) - .def("simplify", [](const ExPolygon& e, double t) { return e.simplify(t); }, py::arg("tolerance"), - "Return simplified geometry as [ExPolygon].") - .def("offset", [](const ExPolygon& e, coord_t delta) { return offset_ex(e, (float) delta); }, - py::arg("delta"), "Clipper offset by `delta` scaled units (negative shrinks). Returns [ExPolygon].") - .def("union_ex", [](const ExPolygon& a, const ExPolygon& b) { - return union_ex(ExPolygons{ a, b }); - }, py::arg("other"), "Union with another ExPolygon. Returns [ExPolygon].") - .def("diff_ex", [](const ExPolygon& a, const ExPolygon& b) { - return diff_ex(ExPolygons{ a }, ExPolygons{ b }); - }, py::arg("other"), "This minus `other`. Returns [ExPolygon].") - .def("intersection_ex", [](const ExPolygon& a, const ExPolygon& b) { - return intersection_ex(ExPolygons{ a }, ExPolygons{ b }); - }, py::arg("other"), "Intersection with `other`. Returns [ExPolygon]."); - // Surface: default holder (Python-owned instances are freed), so plugins can construct // their own Surface(surface_type, expolygon) — not just navigate the live slicing graph. - // expolygon is a reference_internal property, same idiom as Polygon/ExPolygon above. + // expolygon is a reference_internal property, same idiom as the Polygon/ExPolygon + // accessors in PluginHostGeometry.cpp. py::class_(host, "Surface") .def(py::init([](SurfaceType t, const ExPolygon& e) { return Surface(t, e); }), py::arg("surface_type"), py::arg("expolygon")) diff --git a/src/slic3r/plugin/PluginHostUi.cpp b/src/slic3r/plugin/host/PluginHostUi.cpp similarity index 99% rename from src/slic3r/plugin/PluginHostUi.cpp rename to src/slic3r/plugin/host/PluginHostUi.cpp index 7750b8d641..ac54c38337 100644 --- a/src/slic3r/plugin/PluginHostUi.cpp +++ b/src/slic3r/plugin/host/PluginHostUi.cpp @@ -1,7 +1,7 @@ #include "PluginHostUi.hpp" -#include "PluginAuditManager.hpp" -#include "PythonInterpreter.hpp" // PythonGILState +#include "slic3r/plugin/PluginAuditManager.hpp" +#include "slic3r/plugin/PythonInterpreter.hpp" // PythonGILState #include #include diff --git a/src/slic3r/plugin/PluginHostUi.hpp b/src/slic3r/plugin/host/PluginHostUi.hpp similarity index 100% rename from src/slic3r/plugin/PluginHostUi.hpp rename to src/slic3r/plugin/host/PluginHostUi.hpp diff --git a/src/slic3r/plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.hpp b/src/slic3r/plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.hpp index c9a504f6be..eda2c70324 100644 --- a/src/slic3r/plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.hpp +++ b/src/slic3r/plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.hpp @@ -12,7 +12,7 @@ namespace Slic3r { // pipeline mutates. The data-model bindings and the mandatory lifetime rule // (valid only during execute(ctx); mutators invalidate references into replaced // containers, like std::vector iterators) live in -// src/slic3r/plugin/PluginHostSlicing.cpp. +// src/slic3r/plugin/host/PluginHostSlicing.cpp. struct SlicingPipelineContext { std::string orca_version; SlicingPipelineStepPlugin step { SlicingPipelineStepPlugin::posSlice }; diff --git a/tests/slic3rutils/test_plugin_host_api.cpp b/tests/slic3rutils/test_plugin_host_api.cpp index 426ea8d2ff..e31887cfba 100644 --- a/tests/slic3rutils/test_plugin_host_api.cpp +++ b/tests/slic3rutils/test_plugin_host_api.cpp @@ -26,7 +26,7 @@ bool has_attr(const py::handle& object, const char* name) } // namespace -TEST_CASE("Plugin host API exposes host-owned bundle and preset surface to Python", "[PluginHostApi][Python]") +TEST_CASE("Plugin host API exposes host-owned bundle and preset surface to Python", "[PluginHost][Python]") { py::module_ orca = import_orca_module(); REQUIRE(has_attr(orca, "host")); @@ -111,7 +111,7 @@ TEST_CASE("Plugin host API exposes host-owned bundle and preset surface to Pytho CHECK(printers.attr("find_preset")(printer_preset.name).attr("name").cast() == printer_preset.name); } -TEST_CASE("Plugin host API reports unavailable GUI objects before Orca app initialization", "[PluginHostApi][Python]") +TEST_CASE("Plugin host API reports unavailable GUI objects before Orca app initialization", "[PluginHost][Python]") { py::object host = import_orca_module().attr("host"); @@ -127,7 +127,7 @@ TEST_CASE("Plugin host API reports unavailable GUI objects before Orca app initi } } -TEST_CASE("Plugin host API exposes the UI module and guards it before Orca app initialization", "[PluginHostApi][Python]") +TEST_CASE("Plugin host API exposes the UI module and guards it before Orca app initialization", "[PluginHost][Python]") { py::object host = import_orca_module().attr("host"); REQUIRE(has_attr(host, "ui")); @@ -149,7 +149,7 @@ TEST_CASE("Plugin host API exposes the UI module and guards it before Orca app i } } -TEST_CASE("Plugin host API exposes model geometry and structure to Python", "[PluginHostApi][Python]") +TEST_CASE("Plugin host API exposes model geometry and structure to Python", "[PluginHost][Python]") { using Catch::Matchers::WithinAbs; using Catch::Matchers::WithinRel; @@ -228,7 +228,7 @@ TEST_CASE("Plugin host API exposes model geometry and structure to Python", "[Pl CHECK(py_modifier.attr("type")().cast() == Slic3r::ModelVolumeType::PARAMETER_MODIFIER); } -TEST_CASE("Plugin host API exposes TriangleMesh geometry to Python", "[PluginHostApi][Python]") +TEST_CASE("Plugin host API exposes TriangleMesh geometry to Python", "[PluginHost][Python]") { using Catch::Matchers::WithinAbs; using Catch::Matchers::WithinRel; From c17d9732be2215b9e9353d3c6897a9ad958e7467 Mon Sep 17 00:00:00 2001 From: SoftFever Date: Sat, 11 Jul 2026 18:54:07 +0800 Subject: [PATCH 08/10] refactor(plugin): bind raw TriangleMesh instead of HostTriangleMesh wrapper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bind libslic3r's TriangleMesh directly with a shared_ptr holder rather than wrapping it in a HostTriangleMesh snapshot struct. ModelVolume.mesh() hands out the volume's own shared_ptr (via const_pointer_cast, which only serves the holder type), so the Python object pins the snapshot exactly as the wrapper did, and the zero-copy views now use the Python object as their array base — deleting the capsule machinery. The wrapper's type-level constness becomes a documented rule instead: handed-out meshes are copy-on-write snapshots shared across threads, so the binding exposes only const methods; a future mutable-mesh API must operate on plugin-owned copies handed back via ModelVolume::set_mesh. No Python-visible change (orca.host.TriangleMesh, same methods/docstrings), and plugins now hold the real class a future set_mesh() will accept. Verified with slic3rutils and fff_print suites. --- src/slic3r/plugin/host/PluginHostMesh.cpp | 80 ++++++++++------------ src/slic3r/plugin/host/PluginHostMesh.hpp | 14 +--- src/slic3r/plugin/host/PluginHostModel.cpp | 7 +- 3 files changed, 46 insertions(+), 55 deletions(-) diff --git a/src/slic3r/plugin/host/PluginHostMesh.cpp b/src/slic3r/plugin/host/PluginHostMesh.cpp index 3661f964ed..28da5c3afc 100644 --- a/src/slic3r/plugin/host/PluginHostMesh.cpp +++ b/src/slic3r/plugin/host/PluginHostMesh.cpp @@ -20,56 +20,52 @@ static_assert(sizeof(stl_vertex) == 3 * sizeof(float), static_assert(sizeof(stl_triangle_vertex_indices) == 3 * sizeof(std::int32_t), "triangle index must be a packed int32[3] for zero-copy numpy export"); -// Read-only, zero-copy (rows, 3) numpy view over a packed T[rows][3] buffer. -// The array's base is a capsule owning a strong ref to `mesh`, so the view -// stays valid even if the volume's mesh is later replaced on the main thread. -template -py::array make_readonly_rows3(const std::shared_ptr& mesh, - const T* data, py::ssize_t rows) -{ - if (rows == 0 || data == nullptr) - return py::array_t(std::vector{ 0, 3 }); - auto* owner = new std::shared_ptr(mesh); - py::capsule base(owner, [](void* p) { - delete reinterpret_cast*>(p); - }); - return make_readonly_rows(base, data, rows); -} - } // namespace void host_bindings::register_mesh(py::module_& host) { - py::class_(host, "TriangleMesh", + // The raw libslic3r TriangleMesh, bound with a shared_ptr holder: + // ModelVolume.mesh() hands out the volume's own shared_ptr, so the Python + // object pins this snapshot even if the volume's mesh is later replaced on + // the main thread. The zero-copy views below use the Python object as their + // array base, which keeps the buffer alive for each array's lifetime. + // + // IMMUTABLE BY RULE: handed-out meshes are copy-on-write snapshots SHARED + // across threads (a Print's model snapshot and the live GUI model share the + // same instance), reached through a const_pointer_cast that only serves the + // holder type. Bind only const/read-only methods here. A future mutable-mesh + // API must operate on plugin-owned copies handed back via + // ModelVolume::set_mesh — never mutate a mesh obtained from the graph. + py::class_>(host, "TriangleMesh", "Immutable snapshot of a ModelVolume's mesh in local (untransformed) coordinates, mm.") - .def("vertex_count", [](const HostTriangleMesh& mesh) { return mesh.its().vertices.size(); }) - .def("triangle_count", [](const HostTriangleMesh& mesh) { return mesh.its().indices.size(); }) - .def("facets_count", [](const HostTriangleMesh& mesh) { return mesh.its().indices.size(); }) - .def("is_empty", [](const HostTriangleMesh& mesh) { return mesh.its().indices.empty(); }) + .def("vertex_count", [](const TriangleMesh& mesh) { return mesh.its.vertices.size(); }) + .def("triangle_count", [](const TriangleMesh& mesh) { return mesh.its.indices.size(); }) + .def("facets_count", [](const TriangleMesh& mesh) { return mesh.its.indices.size(); }) + .def("is_empty", [](const TriangleMesh& mesh) { return mesh.its.indices.empty(); }) // Read-only, zero-copy (N, 3) float32 view of vertex positions. Requires numpy. - .def("vertices", [](const HostTriangleMesh& mesh) { + .def("vertices", [](py::object self) { + const TriangleMesh& mesh = self.cast(); return with_numpy([&] { - const indexed_triangle_set& its = mesh.its(); - return make_readonly_rows3( - mesh.mesh, - its.vertices.empty() ? nullptr : its.vertices.front().data(), - static_cast(its.vertices.size())); + const std::vector& vertices = mesh.its.vertices; + return py::object(make_readonly_rows( + self, vertices.empty() ? nullptr : vertices.front().data(), + static_cast(vertices.size()))); }); }, "Read-only zero-copy (N, 3) float32 ndarray of vertex positions (local mm). Requires numpy.") // Read-only, zero-copy (M, 3) int32 view of triangle vertex indices. Requires numpy. - .def("triangles", [](const HostTriangleMesh& mesh) { + .def("triangles", [](py::object self) { + const TriangleMesh& mesh = self.cast(); return with_numpy([&] { - const indexed_triangle_set& its = mesh.its(); - return make_readonly_rows3( - mesh.mesh, - its.indices.empty() ? nullptr : its.indices.front().data(), - static_cast(its.indices.size())); + const std::vector& indices = mesh.its.indices; + return py::object(make_readonly_rows( + self, indices.empty() ? nullptr : indices.front().data(), + static_cast(indices.size()))); }); }, "Read-only zero-copy (M, 3) int32 ndarray of triangle vertex indices. Requires numpy.") // One normalized normal per triangle as an (M, 3) float32 copy. Requires numpy. - .def("face_normals", [](const HostTriangleMesh& mesh) { + .def("face_normals", [](const TriangleMesh& mesh) { return with_numpy([&] { - std::vector normals = its_face_normals(mesh.its()); + std::vector normals = its_face_normals(mesh.its); py::array_t array({ static_cast(normals.size()), py::ssize_t(3) }); if (!normals.empty()) { auto view = array.mutable_unchecked<2>(); @@ -83,23 +79,23 @@ void host_bindings::register_mesh(py::module_& host) }); }, "Per-triangle normalized normals as an (M, 3) float32 ndarray (copy). Requires numpy.") // numpy-free element access, bounds-checked. - .def("vertex", [](const HostTriangleMesh& mesh, size_t index) { - const std::vector& vertices = mesh.its().vertices; + .def("vertex", [](const TriangleMesh& mesh, size_t index) { + const std::vector& vertices = mesh.its.vertices; if (index >= vertices.size()) throw py::index_error("vertex index out of range"); const stl_vertex& vertex = vertices[index]; return py::make_tuple(vertex.x(), vertex.y(), vertex.z()); }) - .def("triangle", [](const HostTriangleMesh& mesh, size_t index) { - const std::vector& indices = mesh.its().indices; + .def("triangle", [](const TriangleMesh& mesh, size_t index) { + const std::vector& indices = mesh.its.indices; if (index >= indices.size()) throw py::index_error("triangle index out of range"); const stl_triangle_vertex_indices& triangle = indices[index]; return py::make_tuple(triangle[0], triangle[1], triangle[2]); }) - .def("volume", [](const HostTriangleMesh& mesh) { return mesh.mesh->stats().volume; }) - .def("bounding_box", [](const HostTriangleMesh& mesh) { return bbox_from_stats(mesh.mesh->stats()); }) - .def("is_manifold", [](const HostTriangleMesh& mesh) { return mesh.mesh->stats().manifold(); }); + .def("volume", [](const TriangleMesh& mesh) { return mesh.stats().volume; }) + .def("bounding_box", [](const TriangleMesh& mesh) { return bbox_from_stats(mesh.stats()); }) + .def("is_manifold", [](const TriangleMesh& mesh) { return mesh.stats().manifold(); }); } } // namespace Slic3r diff --git a/src/slic3r/plugin/host/PluginHostMesh.hpp b/src/slic3r/plugin/host/PluginHostMesh.hpp index 9aca05c59d..05e8130973 100644 --- a/src/slic3r/plugin/host/PluginHostMesh.hpp +++ b/src/slic3r/plugin/host/PluginHostMesh.hpp @@ -3,21 +3,11 @@ #include #include -#include - namespace Slic3r { -// Immutable snapshot of a ModelVolume's mesh. Holding a strong reference to the -// const mesh keeps any zero-copy numpy views valid even if the volume's mesh is -// later replaced on the main thread. Bound as `orca.host.TriangleMesh` in -// PluginHostMesh.cpp; constructed by ModelVolume.mesh() in PluginHostModel.cpp. -struct HostTriangleMesh -{ - std::shared_ptr mesh; - const indexed_triangle_set& its() const { return mesh->its; } -}; - // Build a BoundingBoxf3 from precomputed (float) triangle-mesh stats min/max. +// Shared by the TriangleMesh binding (PluginHostMesh.cpp) and the mesh-derived +// ModelVolume accessors (PluginHostModel.cpp). inline BoundingBoxf3 bbox_from_stats(const TriangleMeshStats& stats) { if (stats.number_of_facets == 0) diff --git a/src/slic3r/plugin/host/PluginHostModel.cpp b/src/slic3r/plugin/host/PluginHostModel.cpp index 51fdb180e7..3b571da230 100644 --- a/src/slic3r/plugin/host/PluginHostModel.cpp +++ b/src/slic3r/plugin/host/PluginHostModel.cpp @@ -6,6 +6,7 @@ #include +#include #include namespace py = pybind11; @@ -52,7 +53,11 @@ void host_bindings::register_model(py::module_& host) .def("is_manifold", [](const ModelVolume& volume) { return volume.mesh().stats().manifold(); }) // Full mesh geometry (vertices/triangles) as an immutable snapshot. .def("mesh", [](const ModelVolume& volume) { - return HostTriangleMesh{ volume.get_mesh_shared_ptr() }; + // The volume stores its mesh as shared_ptr (a + // copy-on-write snapshot); the const_pointer_cast only serves the + // binding's shared_ptr holder — the TriangleMesh binding exposes + // read-only methods (see the immutability rule in PluginHostMesh.cpp). + return std::const_pointer_cast(volume.get_mesh_shared_ptr()); }, "Return the volume's TriangleMesh (local coordinates) for vertex/triangle access.") .def("mesh_errors_count", [](const ModelVolume& volume) { return volume.get_repaired_errors_count(); }) .def("is_fdm_support_painted", &ModelVolume::is_fdm_support_painted) From 03094df10eab5c63c271cb3141d2b89710c9f7eb Mon Sep 17 00:00:00 2001 From: SoftFever Date: Sun, 12 Jul 2026 00:20:39 +0800 Subject: [PATCH 09/10] refactor(plugin): move libslic3r hook wiring out of GUI_App into PluginHooks GUI_App::on_init_inner() carried the plugin dispatch policy inline (the capability resolver and the slicing-pipeline dispatcher) and would grow with every capability that fires from inside libslic3r. plugin/PluginHooks.{hpp,cpp} now owns one file-local installer per hook, aggregated by plugin_hooks::install() -- called from PluginManager::initialize(), reset in shutdown() so no hook can enter Python after the interpreter finalizes. The wx-side loader subscriptions move into GUI_App::init_plugin_gui_wiring(). No behavior change; dispatch bodies moved verbatim. --- src/slic3r/CMakeLists.txt | 2 + src/slic3r/GUI/GUI_App.cpp | 175 +++++++++------------------- src/slic3r/GUI/GUI_App.hpp | 3 + src/slic3r/plugin/PluginHooks.cpp | 129 ++++++++++++++++++++ src/slic3r/plugin/PluginHooks.hpp | 21 ++++ src/slic3r/plugin/PluginManager.cpp | 9 ++ 6 files changed, 216 insertions(+), 123 deletions(-) create mode 100644 src/slic3r/plugin/PluginHooks.cpp create mode 100644 src/slic3r/plugin/PluginHooks.hpp diff --git a/src/slic3r/CMakeLists.txt b/src/slic3r/CMakeLists.txt index 619723de0b..4bc1889c07 100644 --- a/src/slic3r/CMakeLists.txt +++ b/src/slic3r/CMakeLists.txt @@ -618,6 +618,8 @@ set(SLIC3R_GUI_SOURCES plugin/PluginLoader.cpp plugin/PluginLoader.hpp plugin/PluginDescriptor.hpp + plugin/PluginHooks.cpp + plugin/PluginHooks.hpp plugin/PluginManager.cpp plugin/PluginManager.hpp plugin/PluginAuditManager.cpp diff --git a/src/slic3r/GUI/GUI_App.cpp b/src/slic3r/GUI/GUI_App.cpp index b593563ad4..04bb7b5fe1 100644 --- a/src/slic3r/GUI/GUI_App.cpp +++ b/src/slic3r/GUI/GUI_App.cpp @@ -81,7 +81,6 @@ #include "slic3r/plugin/PluginManager.hpp" #include "slic3r/plugin/host/PluginHostUi.hpp" #include "slic3r/plugin/PythonInterpreter.hpp" -#include "slic3r/plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.hpp" #include "GUI.hpp" #include "GUI_Utils.hpp" @@ -2701,6 +2700,54 @@ std::string get_system_info() return out.str(); } +// wx/app-level plugin wiring, kept in one place: subscriptions to plugin +// loader events that drive GUI policy (plugins dialog refresh, network-agent +// registration, plate revalidation). The libslic3r dispatch hooks are NOT +// wired here -- PluginManager::initialize() installs those via +// plugin_hooks::install(). +void GUI_App::init_plugin_gui_wiring() +{ + PluginManager& plugin_mgr = PluginManager::instance(); + + auto refresh_plugins_dialog = [] { + if (!wxTheApp) + return; + + GUI_App* app = &GUI::wxGetApp(); + if (app->is_closing()) + return; + + app->CallAfter([app] { + if (!app->is_closing() && app->m_plugins_dlg) + app->m_plugins_dlg->update_plugin_dialog_ui(); + }); + }; + + plugin_mgr.get_loader().subscribe_on_load_callback([refresh_plugins_dialog](const std::string&) { refresh_plugins_dialog(); }); + plugin_mgr.get_loader().subscribe_on_unload_callback([refresh_plugins_dialog](const std::string&) { refresh_plugins_dialog(); }); + plugin_mgr.get_loader().subscribe_on_load_callback(NetworkAgentFactory::register_python_plugin); + plugin_mgr.get_loader().subscribe_on_unload_callback(NetworkAgentFactory::deregister_python_plugin); + plugin_mgr.get_loader().subscribe_on_capability_load_callback( + [refresh_plugins_dialog](const PluginCapabilityIdentifier& capability) { + if (capability.type == PluginCapabilityType::PrinterConnection) + NetworkAgentFactory::register_python_printer_agent(capability.plugin_key, capability.name); + refresh_plugins_dialog(); + // A newly loaded capability may satisfy a missing-plugin notification; re-validate the + // current plate (on the UI thread) so the notification clears once its plugin is available. + if (wxTheApp && !wxGetApp().is_closing()) + wxGetApp().CallAfter([]() { + if (Plater* plater = wxGetApp().plater()) + plater->revalidate_current_plate_if_plugins_missing(); + }); + }); + plugin_mgr.get_loader().subscribe_on_capability_unload_callback( + [refresh_plugins_dialog](const PluginCapabilityIdentifier& capability) { + if (capability.type == PluginCapabilityType::PrinterConnection) + NetworkAgentFactory::deregister_python_printer_agent(capability.plugin_key, capability.name); + refresh_plugins_dialog(); + }); +} + bool GUI_App::on_init_inner() { wxLog::SetActiveTarget(new wxBoostLog()); @@ -3104,94 +3151,12 @@ bool GUI_App::on_init_inner() on_init_network(); // Initialize plugins after network then register on_load callbacks so once the plugin loads finish, it gets registered automatically. + // initialize() also installs the libslic3r hooks (capability resolver, + // slicing-pipeline dispatcher) via plugin_hooks::install() -- no + // per-capability wiring belongs here. PluginManager& plugin_mgr = PluginManager::instance(); plugin_mgr.initialize(); - ConfigBase::set_resolve_capability_fn([&plugin_mgr](const std::string& cap_name, const std::string& cap_type) { - auto plugin_cap = plugin_mgr.get_loader().try_get_plugin_capability_by_name_and_type(cap_name, plugin_capability_type_from_string(cap_type)); - if (!plugin_cap) - return std::string(); - - PluginDescriptor descriptor; - if (!plugin_mgr.get_catalog().try_get_plugin_descriptor(plugin_cap->plugin_key, descriptor)) - return std::string(); - - // Cloud plugins are resolved at runtime via the UUID in the middle field, so the first - // field keeps the friendly display name. Local plugins are looked up by plugin_key (the - // first field, with an empty UUID), so emit the plugin_key to keep them resolvable. - const std::string identity = descriptor.is_cloud_plugin() ? descriptor.name : descriptor.plugin_key; - 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::SlicingPipelineStepPlugin 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. - 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; - // hand the plugin its own [tool.orcaslicer.plugin.settings] as ctx.params - // (same plugin_key the capability was resolved by, so it always matches). - const std::string plugin_key = ref.uuid.empty() ? ref.name : ref.uuid; - ctx.params = Slic3r::PluginManager::instance().get_loader().get_plugin_settings(plugin_key); - 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); - // log a non-empty success/skipped message instead of dropping it. This is - // log-only by design: every pipeline hook fires AFTER set_done() (see Print.cpp), - // so the Print-level m_step_active is -1 here. Calling active_step_add_warning() - // would then index m_state[-1] (out-of-bounds; the guarding assert is compiled - // out in Release), so it must NOT be called from a pipeline hook. - if (!r.message.empty()) { - static const char* const kStepNames[] = { - "posSlice", "posPerimeters", "posEstimateCurledExtrusions", "posPrepareInfill", "posInfill", - "posIroning", "posContouring", "posSupportMaterial", "posDetectOverhangsForLift", - "posSimplifyPath", "psWipeTower", "psSkirtBrim", "psGCodePostProcess" - }; // order must match Slic3r::SlicingPipelineStepPlugin - const char* step_name = static_cast(step) < sizeof(kStepNames) / sizeof(kStepNames[0]) - ? kStepNames[static_cast(step)] : "Unknown"; - BOOST_LOG_TRIVIAL(info) << "Slicing pipeline plugin '" << ref.capability_name - << "' [" << step_name << "]: " << 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"); @@ -3202,43 +3167,7 @@ bool GUI_App::on_init_inner() plugin_mgr.discover_plugins(false, true); - auto refresh_plugins_dialog = [] { - if (!wxTheApp) - return; - - GUI_App* app = &GUI::wxGetApp(); - if (app->is_closing()) - return; - - app->CallAfter([app] { - if (!app->is_closing() && app->m_plugins_dlg) - app->m_plugins_dlg->update_plugin_dialog_ui(); - }); - }; - - plugin_mgr.get_loader().subscribe_on_load_callback([refresh_plugins_dialog](const std::string&) { refresh_plugins_dialog(); }); - plugin_mgr.get_loader().subscribe_on_unload_callback([refresh_plugins_dialog](const std::string&) { refresh_plugins_dialog(); }); - plugin_mgr.get_loader().subscribe_on_load_callback(NetworkAgentFactory::register_python_plugin); - plugin_mgr.get_loader().subscribe_on_unload_callback(NetworkAgentFactory::deregister_python_plugin); - plugin_mgr.get_loader().subscribe_on_capability_load_callback( - [refresh_plugins_dialog](const PluginCapabilityIdentifier& capability) { - if (capability.type == PluginCapabilityType::PrinterConnection) - NetworkAgentFactory::register_python_printer_agent(capability.plugin_key, capability.name); - refresh_plugins_dialog(); - // A newly loaded capability may satisfy a missing-plugin notification; re-validate the - // current plate (on the UI thread) so the notification clears once its plugin is available. - if (wxTheApp && !wxGetApp().is_closing()) - wxGetApp().CallAfter([]() { - if (Plater* plater = wxGetApp().plater()) - plater->revalidate_current_plate_if_plugins_missing(); - }); - }); - plugin_mgr.get_loader().subscribe_on_capability_unload_callback( - [refresh_plugins_dialog](const PluginCapabilityIdentifier& capability) { - if (capability.type == PluginCapabilityType::PrinterConnection) - NetworkAgentFactory::deregister_python_printer_agent(capability.plugin_key, capability.name); - refresh_plugins_dialog(); - }); + init_plugin_gui_wiring(); for (const std::string& plugin_key : plugin_mgr.get_catalog().get_enabled_plugin_keys()) { if (!plugin_mgr.get_loader().is_plugin_loaded(plugin_key)) { diff --git a/src/slic3r/GUI/GUI_App.hpp b/src/slic3r/GUI/GUI_App.hpp index 19aeef17b4..ede7743f75 100644 --- a/src/slic3r/GUI/GUI_App.hpp +++ b/src/slic3r/GUI/GUI_App.hpp @@ -772,6 +772,9 @@ private: bool on_init_network(bool try_backup = false); void init_networking_callbacks(); void init_app_config(); + // GUI-side subscriptions to plugin loader events (dialog refresh, + // network-agent registration, plate revalidation). + void init_plugin_gui_wiring(); void remove_old_networking_plugins(); void drain_pending_events(int timeout_ms); bool wait_for_network_idle(int timeout_ms); diff --git a/src/slic3r/plugin/PluginHooks.cpp b/src/slic3r/plugin/PluginHooks.cpp new file mode 100644 index 0000000000..428c79223e --- /dev/null +++ b/src/slic3r/plugin/PluginHooks.cpp @@ -0,0 +1,129 @@ +#include "PluginHooks.hpp" + +#include "PluginManager.hpp" +#include "PythonInterpreter.hpp" +#include "PythonPluginInterface.hpp" +#include "pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.hpp" + +#include "libslic3r/Config.hpp" +#include "libslic3r/Exception.hpp" +#include "libslic3r/Print.hpp" +#include "libslic3r_version.h" + +#include + +#include +#include + +namespace Slic3r::plugin_hooks { +namespace { + +// Manifest resolver: turns the bare capability name a preset stores into the full +// "name;uuid;capability" reference the dispatchers consume (see +// ConfigBase::collect_plugin_manifest / update_plugin_manifest). +void install_capability_resolver() +{ + ConfigBase::set_resolve_capability_fn([](const std::string& cap_name, const std::string& cap_type) { + PluginManager& plugin_mgr = PluginManager::instance(); + auto plugin_cap = plugin_mgr.get_loader().try_get_plugin_capability_by_name_and_type(cap_name, plugin_capability_type_from_string(cap_type)); + if (!plugin_cap) + return std::string(); + + PluginDescriptor descriptor; + if (!plugin_mgr.get_catalog().try_get_plugin_descriptor(plugin_cap->plugin_key, descriptor)) + return std::string(); + + // Cloud plugins are resolved at runtime via the UUID in the middle field, so the first + // field keeps the friendly display name. Local plugins are looked up by plugin_key (the + // first field, with an empty UUID), so emit the plugin_key to keep them resolvable. + const std::string identity = descriptor.is_cloud_plugin() ? descriptor.name : descriptor.plugin_key; + return identity + ';' + descriptor.cloud_uuid() + ';' + cap_name; + }); +} + +// 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. +void install_slicing_pipeline_hook() +{ + Print::set_slicing_pipeline_hook_fn( + [](Print& print, const PrintObject* object, SlicingPipelineStepPlugin 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; + + execute_capabilities_from_refs( + *caps, plugs, PluginCapabilityType::SlicingPipeline, + [&](std::shared_ptr cap, const PluginCapabilityRef& ref) { + ExecutionResult r; + try { + // GIL is acquired per capability (not once for the whole dispatch) so it + // is released between capabilities. + 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 CanceledException(); + SlicingPipelineContext ctx; + ctx.orca_version = SoftFever_VERSION; + ctx.step = step; + ctx.print = &print; + ctx.object = object; + // hand the plugin its own [tool.orcaslicer.plugin.settings] as ctx.params + // (same plugin_key the capability was resolved by, so it always matches). + const std::string plugin_key = ref.uuid.empty() ? ref.name : ref.uuid; + ctx.params = PluginManager::instance().get_loader().get_plugin_settings(plugin_key); + r = cap->execute(ctx); + } catch (const 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 SlicingError(std::string("Slicing pipeline plugin '") + + ref.capability_name + "' error: " + ex.what()); + } + if (r.status == PluginResult::FatalError) + throw SlicingError(std::string("Slicing pipeline plugin '") + + ref.capability_name + "' error: " + r.message); + // log a non-empty success/skipped message instead of dropping it. This is + // log-only by design: every pipeline hook fires AFTER set_done() (see Print.cpp), + // so the Print-level m_step_active is -1 here. Calling active_step_add_warning() + // would then index m_state[-1] (out-of-bounds; the guarding assert is compiled + // out in Release), so it must NOT be called from a pipeline hook. + if (!r.message.empty()) { + static const char* const kStepNames[] = { + "posSlice", "posPerimeters", "posEstimateCurledExtrusions", "posPrepareInfill", "posInfill", + "posIroning", "posContouring", "posSupportMaterial", "posDetectOverhangsForLift", + "posSimplifyPath", "psWipeTower", "psSkirtBrim", "psGCodePostProcess" + }; // order must match SlicingPipelineStepPlugin + const char* step_name = static_cast(step) < sizeof(kStepNames) / sizeof(kStepNames[0]) + ? kStepNames[static_cast(step)] : "Unknown"; + BOOST_LOG_TRIVIAL(info) << "Slicing pipeline plugin '" << ref.capability_name + << "' [" << step_name << "]: " << r.message; + } + }); + }); +} + +} // namespace + +void install() +{ + install_capability_resolver(); + install_slicing_pipeline_hook(); +} + +void uninstall() +{ + ConfigBase::set_resolve_capability_fn(nullptr); + Print::set_slicing_pipeline_hook_fn(nullptr); +} + +} // namespace Slic3r::plugin_hooks diff --git a/src/slic3r/plugin/PluginHooks.hpp b/src/slic3r/plugin/PluginHooks.hpp new file mode 100644 index 0000000000..bff276c5df --- /dev/null +++ b/src/slic3r/plugin/PluginHooks.hpp @@ -0,0 +1,21 @@ +#pragma once + +// The plugin layer's installers for the hooks libslic3r exposes. libslic3r +// stays free of any plugin/Python dependency: it exposes static setter seams +// (ConfigBase::set_resolve_capability_fn, Print::set_slicing_pipeline_hook_fn, +// ...) and this unit injects the dispatchers -- one file-local installer per +// hook, aggregated by install(). Capabilities dispatched from the GUI layer +// (e.g. PostProcessor.cpp) call execute_capabilities_from_refs at their own +// call site and need no hook here. + +namespace Slic3r::plugin_hooks { + +// Install every hook. Called once from PluginManager::initialize(). +void install(); + +// Reset every hook to null so none can enter Python after the interpreter +// finalizes. Called from PluginManager::shutdown(); callers must have stopped +// background slicing first (resetting a hook while process() runs is a race). +void uninstall(); + +} // namespace Slic3r::plugin_hooks diff --git a/src/slic3r/plugin/PluginManager.cpp b/src/slic3r/plugin/PluginManager.cpp index 7062dfda29..33b692c2df 100644 --- a/src/slic3r/plugin/PluginManager.cpp +++ b/src/slic3r/plugin/PluginManager.cpp @@ -5,6 +5,7 @@ #include "PythonPluginBridge.hpp" #include "PluginFsUtils.hpp" +#include "PluginHooks.hpp" #include "PythonFileUtils.hpp" #include @@ -120,6 +121,10 @@ bool PluginManager::initialize() m_initialized = true; + // Install the libslic3r hooks (capability resolver, slicing-pipeline + // dispatcher). Uninstalled in shutdown() before the interpreter finalizes. + plugin_hooks::install(); + // Persist auto-load / capability state to each plugin's .install_state.json sidecar. // On load: write enabled=true plus current capability flags. On unload: flip enabled=false. // The on-unload callback is skipped during shutdown (run_on_unload_callbacks is gated by @@ -154,6 +159,10 @@ void PluginManager::shutdown() BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << ": PluginManager shutdown enter"; + // Detach the libslic3r hooks first so nothing dispatches into Python while + // (or after) plugins unload. Callers stop background slicing before this. + plugin_hooks::uninstall(); + // Signal the loader to reject new plugin loads before we drain. m_loader.set_shutting_down(); From 579e58c528cc8ee2aba0a37097f2ec73dc389b50 Mon Sep 17 00:00:00 2001 From: SoftFever Date: Sun, 12 Jul 2026 00:59:13 +0800 Subject: [PATCH 10/10] Add Fuzzy Slices sample (fuzzy skin at posSlice) + coverage test Experimental fuzzy on geometry Mirrors libslic3r's fuzzy_polyline on the slice contours at Step.posSlice, demonstrating the count-changing mutation idiom (rebuild ring via Polygon.append, write back via ex.contour / ex.set_holes). C++ analogue test proves area preservation, cascade, and bounded displacement. --- sandboxes/orca_fuzzy_slices_plugin_any.py | 176 ++++++++++++++++++ .../fff_print/test_slicing_pipeline_hook.cpp | 87 +++++++++ 2 files changed, 263 insertions(+) create mode 100644 sandboxes/orca_fuzzy_slices_plugin_any.py diff --git a/sandboxes/orca_fuzzy_slices_plugin_any.py b/sandboxes/orca_fuzzy_slices_plugin_any.py new file mode 100644 index 0000000000..ecff14ca28 --- /dev/null +++ b/sandboxes/orca_fuzzy_slices_plugin_any.py @@ -0,0 +1,176 @@ +# /// script +# requires-python = ">=3.12" +# +# [tool.orcaslicer.plugin] +# name = "Fuzzy Slices" +# description = "Applies the fuzzy-skin jitter to the slice contours themselves at the Slice boundary (demo)." +# author = "OrcaSlicer" +# version = "0.01" +# type = "slicing-pipeline" +# +# [tool.orcaslicer.plugin.settings] +# thickness_mm = "0.3" +# point_distance_mm = "0.8" +# fuzz_holes = "1" +# skip_first_layer = "1" +# /// +"""Fuzzy Slices -- the fuzzy-skin effect applied at slice time. + +Orca's built-in fuzzy skin perturbs the outer-wall EXTRUSION PATHS during +perimeter generation, so only the printed wall is fuzzy. This sample instead +perturbs the sliced outline itself at Step.posSlice, using the same +resample-and-jitter algorithm as libslic3r's fuzzy_polyline (uniform noise): +walk each ring, drop a new vertex every 3/4..5/4 * point_distance_mm of +perimeter, and displace it by a random +/- thickness_mm along the segment +normal. Because the slice contour itself changes, everything derived from it +(perimeters, infill boundaries, overhang detection) inherits the noise and +the fuzz shows in the toolpath preview. + +Mechanically this demonstrates the count-CHANGING mutation idiom: a fuzzed +ring has a different vertex count, so it is rebuilt as a fresh +orca.host.Polygon (append() per vertex) and written back by assigning +ex.contour / calling ex.set_holes() on the live ExPolygon. The in-place edit +persists through the surface collection and leaves surface types untouched; +layer.make_slices() then re-derives the merged islands. Compare the Inset +sample (whole-surface offset + slices.set) and Twistify (count-preserving +in-place transforms). + +The jitter preserves vertex order, so the contour keeps its CCW winding +(contour assignment does not re-normalize); set_holes() re-normalizes holes +to CW. The RNG is seeded per layer, so re-slicing reproduces the same fuzz. +The first layer is skipped by default for bed adhesion (like the built-in +fuzzy_skin_first_layer = off). No numpy required; for very dense models the +Polygon.as_array()/set_points numpy path would be the faster route. +""" +import math +import random + +import orca + +_DEFAULTS = { + "thickness_mm": 0.3, # max normal displacement (built-in fuzzy_skin_thickness default) + "point_distance_mm": 0.8, # target resample spacing (built-in fuzzy_skin_point_dist default) + "fuzz_holes": 1.0, # nonzero: jitter hole rings too, not just the outer contour + "skip_first_layer": 1.0, # nonzero: keep layer 0 crisp for bed adhesion +} + + +def _params(ctx): + try: + src = dict(ctx.params) + except (AttributeError, TypeError): + src = {} + out = {} + for key, default in _DEFAULTS.items(): + try: + out[key] = float(src[key]) + except (KeyError, TypeError, ValueError): + out[key] = default + return out + + +def _fuzz_ring(points, thickness, min_dist, rand_range, rng): + """Resample + jitter one closed ring (list of Point refs). + + Returns a new orca.host.Polygon, or None to keep the original ring (too + small to resample). Mirrors libslic3r's fuzzy_polyline: new vertices every + min_dist + rand*rand_range of arc length, each displaced +/-thickness + along the segment's left-hand normal. + """ + if len(points) < 3: + return None + out = [] + dist_left_over = rng.random() * (min_dist / 2.0) # arc length before the first new vertex + p0x = float(points[-1].x) + p0y = float(points[-1].y) + for p1 in points: + p1x = float(p1.x) + p1y = float(p1.y) + dx = p1x - p0x + dy = p1y - p0y + seg = math.hypot(dx, dy) + if seg > 0.0: + d = dist_left_over + while d < seg: + t = d / seg + r = (rng.random() * 2.0 - 1.0) * thickness + out.append((p0x + dx * t - dy / seg * r, + p0y + dy * t + dx / seg * r)) + d += min_dist + rng.random() * rand_range + dist_left_over = d - seg # carry the remainder into the next segment + p0x, p0y = p1x, p1y + if len(out) < 3: + return None # ring shorter than ~2 resample steps: leave it crisp + poly = orca.host.Polygon() + for x, y in out: + poly.append(orca.host.Point(int(round(x)), int(round(y)))) + return poly + + +class FuzzySlices(orca.slicing.SlicingPipelineCapabilityBase): + def get_name(self): + return "Fuzzy Slices" + + def execute(self, ctx): + if ctx.step != orca.slicing.Step.posSlice or ctx.object is None: + return orca.ExecutionResult.success() + + p = _params(ctx) + if p["thickness_mm"] <= 0.0 or p["point_distance_mm"] <= 0.0: + return orca.ExecutionResult.success("Fuzzy Slices: zero thickness/point distance, nothing to do") + + # Millimeters -> scaled integer units via the *live* scale (never hardcode 1e6). + mm = 1.0 / orca.slicing.unscale(1) + thickness = p["thickness_mm"] * mm + # The spacing between new vertices varies between 3/4 and 5/4 the supplied + # value, same as the built-in fuzzy skin. + min_dist = p["point_distance_mm"] * mm * 0.75 + rand_range = p["point_distance_mm"] * mm * 0.5 + fuzz_holes = p["fuzz_holes"] != 0.0 + first = 1 if p["skip_first_layer"] != 0.0 else 0 + + rings = 0 + layers_touched = 0 + for idx, layer in enumerate(ctx.object.layers()): + if ctx.cancelled(): + break + if idx < first: + continue + rng = random.Random(0x5EED + idx) # per-layer seed: re-slices reproduce the same fuzz + edited = False + for region in layer.regions(): + for surface in region.slices.surfaces: + ex = surface.expolygon + contour = _fuzz_ring(ex.contour.points, thickness, min_dist, rand_range, rng) + if contour is not None: + ex.contour = contour # vertex order preserved, so CCW winding survives + rings += 1 + edited = True + if fuzz_holes and ex.holes: + new_holes = [] + changed = False + for hole in ex.holes: + fuzzed = _fuzz_ring(hole.points, thickness, min_dist, rand_range, rng) + if fuzzed is not None: + new_holes.append(fuzzed) + changed = True + rings += 1 + else: + new_holes.append(hole) # untouched rings pass through unchanged + if changed: + ex.set_holes(new_holes) # copies each ring and re-normalizes to CW + edited = True + if edited: + # Re-derive the merged islands from the fuzzed region slices. + layer.make_slices() + layers_touched += 1 + + return orca.ExecutionResult.success( + f"Fuzzy Slices: fuzzed {rings} ring(s) on {layers_touched} layer(s) " + f"(+/-{p['thickness_mm']} mm @ {p['point_distance_mm']} mm)") + + +@orca.plugin +class FuzzySlicesPackage(orca.base): + def register_capabilities(self): + orca.register_capability(FuzzySlices) diff --git a/tests/fff_print/test_slicing_pipeline_hook.cpp b/tests/fff_print/test_slicing_pipeline_hook.cpp index ceb17108d1..66f7813503 100644 --- a/tests/fff_print/test_slicing_pipeline_hook.cpp +++ b/tests/fff_print/test_slicing_pipeline_hook.cpp @@ -470,3 +470,90 @@ TEST_CASE("refreshing lslices after a slice mutation makes islands track the geo CHECK_THAT(stale, WithinRel((double) scale_(20.0), 0.05)); // stale islands = original outline CHECK_THAT(fresh, WithinRel((double) scale_(18.0), 0.05)); // refreshed islands = inset outline } + +#include // deterministic RNG for the fuzzy-skin analogue below + +// Fuzzy skin applied to the slice contours at the Slice boundary, matching what the Fuzzy +// Slices sample (sandboxes/orca_fuzzy_slices_plugin_any.py) does: resample every ring at +// 3/4..5/4 * point_distance and displace each new vertex +/-thickness along the segment +// normal (libslic3r's fuzzy_polyline with uniform noise). Unlike the count-preserving rotate +// test above, this is a count-CHANGING rebuild -- each ring is replaced by one with a +// different vertex count. Three end-to-end invariants after process() confirm the cascade: +// (1) the jitter is zero-mean, so total fill area is preserved within a few %, +// (2) the fuzz genuinely cascaded into make_perimeters' fill_surfaces -- their contours +// carry far more vertices than the crisp baseline square's, +// (3) displacement is bounded: the sliced footprint grows by at most ~2*thickness. +TEST_CASE("Fuzzing slice contours at the Slice boundary cascades with bounded displacement", "[slicing_pipeline]") { + using Catch::Matchers::WithinRel; + static constexpr double kThickness = 0.3, kPointDist = 0.8; // mm; the built-in fuzzy-skin defaults + struct Measure { double area; size_t verts; double width; }; + auto measure = [](bool fuzz) -> Measure { + Slic3r::Print print; Slic3r::Model model; + auto config = Slic3r::DynamicPrintConfig::full_print_config(); + config.set_key_value("slicing_pipeline_plugin", new Slic3r::ConfigOptionStrings({"probe"})); // active in both runs + if (fuzz) Slic3r::Print::set_slicing_pipeline_hook_fn( + [](Slic3r::Print&, const Slic3r::PrintObject* o, Slic3r::SlicingPipelineStepPlugin s){ + if (s != Slic3r::SlicingPipelineStepPlugin::posSlice || !o) return; + const double thickness = scale_(kThickness); + const double min_dist = scale_(kPointDist) * 0.75; + const double rand_range = scale_(kPointDist) * 0.5; + std::mt19937 rng(0x5EED); // fixed seed: the run is deterministic + std::uniform_real_distribution uni(0.0, 1.0); + auto fuzz_ring = [&](Slic3r::Points& pts) { + if (pts.size() < 3) return; + Slic3r::Points out; + double dist_left_over = uni(rng) * (min_dist / 2.0); + const Slic3r::Point* p0 = &pts.back(); + for (const Slic3r::Point& p1 : pts) { + const Slic3r::Vec2d v = (p1 - *p0).cast(); + const double seg = v.norm(); + if (seg > 0.0) { + double d = dist_left_over; + for (; d < seg; d += min_dist + uni(rng) * rand_range) { + const double r = (uni(rng) * 2.0 - 1.0) * thickness; + const Slic3r::Vec2d pa = p0->cast() + v * (d / seg); + const Slic3r::Vec2d n = Slic3r::Vec2d(-v.y(), v.x()) / seg; + out.emplace_back((coord_t) std::llround(pa.x() + n.x() * r), + (coord_t) std::llround(pa.y() + n.y() * r)); + } + dist_left_over = d - seg; + } + p0 = &p1; + } + if (out.size() >= 3) pts = std::move(out); // else: ring too short, keep it crisp + }; + for (Slic3r::Layer* l : const_cast(o)->layers()) + for (Slic3r::LayerRegion* r : l->regions()) { + Slic3r::Surfaces in = r->slices.surfaces; + for (auto& sf : in) { + fuzz_ring(sf.expolygon.contour.points); + for (auto& h : sf.expolygon.holes) fuzz_ring(h.points); + } + 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(); + Measure m { 0.0, 0, outer_slices_width(print) }; + for (auto* l : print.objects().front()->layers()) + for (auto* r : l->regions()) + for (auto& sf : r->fill_surfaces.surfaces) { + m.area += sf.expolygon.area(); + m.verts += sf.expolygon.contour.points.size(); + } + Slic3r::Print::set_slicing_pipeline_hook_fn(nullptr); + return m; + }; + const Measure base = measure(false); + const Measure fz = measure(true); + // (1) Zero-mean jitter: the fills add up to (nearly) the same area. + CHECK_THAT(fz.area, WithinRel(base.area, 0.05)); + // (2) The resample cascaded downstream: fill boundaries derived from the fuzzed slices + // carry far more vertices than the baseline square's. + CHECK(fz.verts > 4 * base.verts); + // (3) Displacement is bounded by the +/-thickness jitter: the footprint widened, but by + // no more than ~2*thickness (one thickness per side, plus rounding slack). + CHECK(fz.width > base.width); + CHECK(fz.width < base.width + 2.5 * scale_(kThickness)); +}