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); }