From f81a24abfb3ad3c1901d850979eef05f90f424ca Mon Sep 17 00:00:00 2001 From: SoftFever Date: Wed, 8 Jul 2026 00:05:28 +0800 Subject: [PATCH] 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); }