feat(plugin): expose the slicing print-graph as raw orca.host classes + Twistify sample

Adds PluginHostSlicing, which registers the print-graph data model (Print,
PrintObject, Layer, LayerRegion, Surface, ExPolygon, extrusions, ...) into the
orca.host submodule in the same raw-class style as PluginHostApi's Model/Preset
graph, with shared helpers in PluginBindingUtils. SlicingPipelinePluginCapability
is trimmed to the capability surface (the standalone SlicingNumpy helper is folded
away). Adds the Twistify example plugin next to Inset and broadens the binding,
hook, and plugin-install tests.
This commit is contained in:
SoftFever
2026-07-08 00:05:28 +08:00
parent aafcccc83c
commit f81a24abfb
29 changed files with 1718 additions and 962 deletions
-143
View File
@@ -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.
@@ -6,14 +6,14 @@
# name = "Inset Every Slice" # name = "Inset Every Slice"
# description = "Insets every layer's slices by 1mm at the Slice boundary (demo)." # description = "Insets every layer's slices by 1mm at the Slice boundary (demo)."
# author = "OrcaSlicer" # author = "OrcaSlicer"
# version = "1.0.0" # version = "0.01"
# type = "slicing-pipeline" # type = "slicing-pipeline"
# /// # ///
"""Inset Every Slice -- a small, WORKING SlicingPipeline sample plugin. """Inset Every Slice -- a small, WORKING SlicingPipeline sample plugin.
At Step.Slice, for every layer/region of the sliced object, this shrinks each At Step.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 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 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 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 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 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 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 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 cross-section of a plain cube) but it is NOT a general polygon offset -- it
docs uses) but it is NOT a general polygon offset -- it will distort a will distort a rotated or non-rectangular contour. A real plugin should
rotated or non-rectangular contour. A real plugin should reach for a proper reach for a proper offset library (e.g. Shapely's buffer(), or Clipper)
offset library (e.g. Shapely's buffer(), or Clipper) instead. instead.
- Holes are passed through unchanged. A correct hole inset needs an - Holes are passed through unchanged. A correct hole inset needs an
*outward* offset plus re-validating containment against the shrunk outer *outward* offset plus re-validating containment against the shrunk outer
contour, which is more than a short demo should attempt. 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 inset without inverting) are left unmodified rather than mutated into
garbage. garbage.
numpy is declared as a dependency: the read views hand back zero-copy int64 numpy is declared as a dependency: the geometry accessors hand back zero-copy
ndarrays, and set_slices() requires genuine ndarrays back (not plain lists), int64 ndarrays, and set_slices() requires genuine ndarrays back (not plain lists),
so building the modified contour needs numpy. so building the modified contour needs numpy.
""" """
import numpy as np import numpy as np
@@ -94,19 +94,19 @@ class InsetEverySlice(orca.slicing.SlicingPipelineCapabilityBase):
if ctx.cancelled(): if ctx.cancelled():
break break
for region in layer.regions(): for region in layer.regions():
surfaces = region.slices() surfaces = region.slices.surfaces
if not surfaces: if not surfaces:
continue # set_slices() rejects an empty list continue # an empty region has nothing to inset
new_surfaces = [] new_surfaces = []
for surface in surfaces: for surface in surfaces:
expoly = surface.expolygon expoly = surface.expolygon
contour = expoly.contour() contour = expoly.contour.points()
inset = _inset_contour(contour, inset_scaled) inset = _inset_contour(contour, inset_scaled)
if inset is not None: if inset is not None:
contour = inset contour = inset
# Holes are passed through unchanged -- see module docstring. # 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) region.set_slices(new_surfaces)
regions_touched += 1 regions_touched += 1
@@ -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)
+53 -34
View File
@@ -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_NAME] = name;
j[BBL_JSON_KEY_FROM] = from; j[BBL_JSON_KEY_FROM] = from;
std::vector<std::string> plugin_refs;
//record all the key-values //record all the key-values
for (const std::string &opt_key : this->keys()) 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); json j_array(string_values);
j[opt_key] = j_array; 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 // Serialize the top-level "plugins" manifest: the individual plugin-backed options keep bare
// bare capability names, and the full "name;uuid;capability" references are derived here from // capability names; the full "name;uuid;capability" references are derived here (same helper as
// those options via the registered resolver. Only do this when a resolver is available (GUI); // update_plugin_manifest). Only with a resolver (GUI); without one (CLI/headless) leave whatever
// without one (CLI/headless) leave whatever the "plugins" option already serialized above, so a // the "plugins" option already serialized above, so a round-trip never drops the manifest.
// round-trip never drops the manifest. De-duplicate while preserving order and skip empties.
if (resolve_capability_fn) { if (resolve_capability_fn) {
std::vector<std::string> unique_refs; std::vector<std::string> unique_refs = this->collect_plugin_manifest();
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));
}
if (unique_refs.empty()) if (unique_refs.empty())
j.erase("plugins"); j.erase("plugins");
else else
@@ -1610,29 +1598,60 @@ void ConfigBase::save_plugin_collection(const std::string& opt_key, const Config
if (!resolve_capability_fn) if (!resolve_capability_fn)
return; return;
// Resolve a single bare capability value into its full reference and append it, skipping // A plugin-backed option declares its capability type via ConfigOptionDef::plugin_type (the same
// unset values and capabilities that could not be resolved (resolver returns ""). // metadata PluginResolver::find_option_for_capability scans). Deriving off the def rather than a
const auto append_ref = [&plugin_refs](const std::string& capability_value, const std::string& type) { // 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()) if (capability_value.empty())
return; return;
std::string ref = resolve_capability_fn(capability_value, type); 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)); plugin_refs.emplace_back(std::move(ref));
}; };
if (opt_key == "post_process_plugin") { // Scalar options carry a single capability name; vector options carry a list. Same scalar/vector
const ConfigOptionVectorBase* vec = static_cast<const ConfigOptionVectorBase*>(opt); // dispatch as PluginResolver::find_option_for_capability.
for (const std::string& val : vec->vserialize()) if (const auto* string_option = dynamic_cast<const ConfigOptionString*>(opt))
append_ref(val, "post-processing"); append_ref(string_option->value);
} else if (opt_key == "printer_agent") { else if (const auto* vector_option = dynamic_cast<const ConfigOptionVectorBase*>(opt))
append_ref((dynamic_cast<const ConfigOptionString *>(opt))->value, "printer-connection"); for (const std::string& val : vector_option->vserialize())
} else if (opt_key == "slicing_pipeline_plugin") { append_ref(val);
if (const auto* vec = dynamic_cast<const ConfigOptionStrings*>(opt)) { }
for (const std::string& val : vec->vserialize())
append_ref(val, "slicing-pipeline"); std::vector<std::string> ConfigBase::collect_plugin_manifest() const
} {
} std::vector<std::string> refs;
// Extend for other plugin-backed settings as needed. 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<ConfigOptionStrings>("plugins", true))
manifest->values = this->collect_plugin_manifest();
} }
DynamicConfig::DynamicConfig(const ConfigBase& rhs, const t_config_option_keys& keys) DynamicConfig::DynamicConfig(const ConfigBase& rhs, const t_config_option_keys& keys)
+18 -3
View File
@@ -2444,10 +2444,13 @@ public:
// "serialized" - vector valued option is entered in a single edit field. Values are separated by a semicolon. // "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. // "show_value" - even if enum_values / enum_labels are set, still display the value, not the enum label.
std::string gui_flags; 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; std::string plugin_type;
// Indicate whether the option support plugin. // Whether this option holds plugin capability name(s) that feed the "plugins" manifest -- true
bool support_plugin { false }; // 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. // 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, // 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. // while full_label contains a label of a stand-alone field.
@@ -2761,6 +2764,13 @@ public:
//BBS: add json support //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; 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. // Set all the nullable values to nils.
void null_nullables(); void null_nullables();
@@ -2770,6 +2780,11 @@ private:
// Set a configuration value from a string. // 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); 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<std::string>& plugin_refs) const; void save_plugin_collection(const std::string& opt_key, const ConfigOption* opt, std::vector<std::string>& 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<std::string> collect_plugin_manifest() const;
static std::function<std::string(std::string, std::string)> resolve_capability_fn; static std::function<std::string(std::string, std::string)> resolve_capability_fn;
}; };
+1
View File
@@ -1193,6 +1193,7 @@ static std::vector<std::string> s_Preset_print_options{
"min_bead_width", "min_bead_width",
"post_process", "post_process",
"post_process_plugin", "post_process_plugin",
"slicing_pipeline_plugin",
"plugins", "plugins",
"process_change_extrusion_role_gcode", "process_change_extrusion_role_gcode",
"min_length_factor", "min_length_factor",
+19
View File
@@ -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); const bool was_done = obj->is_step_done(posSlice);
obj->slice(); obj->slice();
hook_after(obj, was_done, posSlice, SlicingPipelineStep::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 { } else {
if (obj->set_started(posSlice)) obj->set_done(posSlice); // shared/duplicate — no hook 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) { for (PrintObject *obj : m_objects) {
if (need_slicing_objects.count(obj) != 0) { 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); const bool was_done = obj->is_step_done(posInfill);
obj->infill(); obj->infill();
hook_after(obj, was_done, posInfill, SlicingPipelineStep::Infill); hook_after(obj, was_done, posInfill, SlicingPipelineStep::Infill);
+1 -1
View File
@@ -883,7 +883,7 @@ enum FilamentCompatibilityType {
}; };
enum class SlicingPipelineStep { enum class SlicingPipelineStep {
Slice, Perimeters, EstimateCurledExtrusions, Infill, Ironing, Contouring, Slice, Perimeters, EstimateCurledExtrusions, PrepareInfill, Infill, Ironing, Contouring,
SupportMaterial, DetectOverhangsForLift, SimplifyPath, WipeTower, SkirtBrim SupportMaterial, DetectOverhangsForLift, SimplifyPath, WipeTower, SkirtBrim
}; };
+4 -3
View File
@@ -828,7 +828,10 @@ void PrintConfigDef::init_common_params()
def->tooltip = L("Select the network agent implementation for printer communication."); def->tooltip = L("Select the network agent implementation for printer communication.");
def->mode = comAdvanced; def->mode = comAdvanced;
def->cli = ConfigOptionDef::nocli; 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->set_default_value(new ConfigOptionString(""));
def = this->add("print_host", coString); 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."); "The plugin will receive the G-code file path and can modify it in place.");
def->gui_type = ConfigOptionDef::GUIType::plugin_picker; def->gui_type = ConfigOptionDef::GUIType::plugin_picker;
def->plugin_type = "post-processing"; def->plugin_type = "post-processing";
def->support_plugin = true;
def->full_width = true; def->full_width = true;
def->mode = comAdvanced; def->mode = comAdvanced;
def->set_default_value(new ConfigOptionStrings()); 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->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->gui_type = ConfigOptionDef::GUIType::plugin_picker;
def->plugin_type = "slicing-pipeline"; def->plugin_type = "slicing-pipeline";
def->support_plugin = true;
def->full_width = true; def->full_width = true;
def->mode = comAdvanced; def->mode = comAdvanced;
def->set_default_value(new ConfigOptionStrings()); def->set_default_value(new ConfigOptionStrings());
+3 -1
View File
@@ -593,10 +593,13 @@ set(SLIC3R_GUI_SOURCES
plugin/PythonPluginBridge.hpp plugin/PythonPluginBridge.hpp
plugin/PythonPluginInterface.hpp plugin/PythonPluginInterface.hpp
plugin/PyPluginPackage.hpp plugin/PyPluginPackage.hpp
plugin/PluginBindingUtils.hpp
plugin/PluginHostApi.cpp plugin/PluginHostApi.cpp
plugin/PluginHostApi.hpp plugin/PluginHostApi.hpp
plugin/PluginHostUi.cpp plugin/PluginHostUi.cpp
plugin/PluginHostUi.hpp plugin/PluginHostUi.hpp
plugin/PluginHostSlicing.cpp
plugin/PluginHostSlicing.hpp
plugin/CloudPluginService.cpp plugin/CloudPluginService.cpp
plugin/CloudPluginService.hpp plugin/CloudPluginService.hpp
plugin/PluginFsUtils.cpp plugin/PluginFsUtils.cpp
@@ -624,7 +627,6 @@ set(SLIC3R_GUI_SOURCES
plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.hpp plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.hpp
plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.cpp plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.cpp
plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapabilityTrampoline.hpp plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapabilityTrampoline.hpp
plugin/pluginTypes/slicingPipeline/SlicingNumpy.hpp
pchheader.cpp pchheader.cpp
pchheader.hpp pchheader.hpp
Utils/ASCIIFolding.cpp Utils/ASCIIFolding.cpp
+22 -8
View File
@@ -3145,10 +3145,8 @@ bool GUI_App::on_init_inner()
[&](std::shared_ptr<Slic3r::SlicingPipelinePluginCapability> cap, const Slic3r::PluginCapabilityRef& ref) { [&](std::shared_ptr<Slic3r::SlicingPipelinePluginCapability> cap, const Slic3r::PluginCapabilityRef& ref) {
Slic3r::ExecutionResult r; Slic3r::ExecutionResult r;
try { try {
// GIL is acquired per capability (not once for the whole dispatch) so it is // GIL is acquired per capability (not once for the whole dispatch) so it
// released between capabilities. ctx is built inside this scope because // is released between capabilities.
// ctx.owner is a py::capsule: it must be created and destroyed while the GIL
// is held (ctx destructs before `gil`, so its capsule is decref'd under GIL).
PythonGILState gil; PythonGILState gil;
// throw_if_canceled() is protected on PrintBase; canceled() is the public // throw_if_canceled() is protected on PrintBase; canceled() is the public
// equivalent check (same cancel flag), so honor cancellation via it. // equivalent check (same cancel flag), so honor cancellation via it.
@@ -3159,10 +3157,10 @@ bool GUI_App::on_init_inner()
ctx.step = step; ctx.step = step;
ctx.print = &print; ctx.print = &print;
ctx.object = object; ctx.object = object;
// No-op-destructor capsule threaded into every zero-copy numpy array as its // hand the plugin its own [tool.orcaslicer.plugin.settings] as ctx.params
// base. It references `print` but frees nothing: `print` is owned by libslic3r // (same plugin_key the capability was resolved by, so it always matches).
// and outlives the hook, and arrays are valid only during this execute() call. const std::string plugin_key = ref.uuid.empty() ? ref.name : ref.uuid;
ctx.owner = pybind11::capsule(&print, [](void*) {}); ctx.params = Slic3r::PluginManager::instance().get_loader().get_plugin_settings(plugin_key);
r = cap->execute(ctx); r = cap->execute(ctx);
} catch (const Slic3r::CanceledException&) { } catch (const Slic3r::CanceledException&) {
throw; // cancellation must reach process(), never become a slicing error 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) if (r.status == Slic3r::PluginResult::FatalError)
throw Slic3r::SlicingError(std::string("Slicing pipeline plugin '") + throw Slic3r::SlicingError(std::string("Slicing pipeline plugin '") +
ref.capability_name + "' error: " + r.message); 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<size_t>(step) < sizeof(kStepNames) / sizeof(kStepNames[0])
? kStepNames[static_cast<int>(step)] : "Unknown";
BOOST_LOG_TRIVIAL(info) << "Slicing pipeline plugin '" << ref.capability_name
<< "' [" << step_name << "]: " << r.message;
}
}); });
}); });
+3 -3
View File
@@ -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, // 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 // 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 // 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 // a run. The trade-off is that a slow execute() freezes the UI, so the contract is to keep
// plugin_development.md) is to keep execute() quick and offload heavy work to the plugin's own // execute() quick and offload heavy work to the plugin's own threading.Thread. orca.host.ui
// threading.Thread. orca.host.ui calls already no-op their main-thread marshaling here. // calls already no-op their main-thread marshaling here.
{ {
wxBusyCursor busy; wxBusyCursor busy;
try { try {
+7
View File
@@ -1790,6 +1790,13 @@ void Tab::on_value_change(const std::string& opt_key, const boost::any& value)
return; 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 (opt_key == "gcode_flavor" && m_type == Preset::TYPE_PRINTER) {
if (auto printer_tab = dynamic_cast<TabPrinter*>(this)) if (auto printer_tab = dynamic_cast<TabPrinter*>(this))
printer_tab->on_gcode_flavor_changed(); printer_tab->on_gcode_flavor_changed();
+89
View File
@@ -0,0 +1,89 @@
#pragma once
#include <pybind11/pybind11.h>
#include <pybind11/numpy.h>
#include "libslic3r/Config.hpp" // ConfigBase
#include "libslic3r/Point.hpp" // Point/Point3 packing asserts, Vec3d, Transform3d
#include <string>
#include <utility>
#include <vector>
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<typename Builder>
pybind11::object with_numpy(Builder&& build)
{
namespace py = pybind11;
try {
return std::forward<Builder>(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<typename T, int N>
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<T> empty(std::vector<py::ssize_t>{ 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<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<double> 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
+2
View File
@@ -4,6 +4,7 @@
#include <algorithm> #include <algorithm>
#include <cctype> #include <cctype>
#include <map>
#include <optional> #include <optional>
#include <string> #include <string>
#include <utility> #include <utility>
@@ -61,6 +62,7 @@ struct PluginDescriptor
std::string entry_path; // Full path to the installed plugin entry file 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::string entry_package; // Import package/module used for package-based loading
std::vector<std::string> dependencies; // Python dependency requirements declared by plugin package metadata std::vector<std::string> dependencies; // Python dependency requirements declared by plugin package metadata
std::map<std::string, std::string> settings; // [tool.orcaslicer.plugin.settings] table -> per-plugin params (ctx.params)
std::vector<PluginChangelog> changelog; // Cloud release changelog, sorted newest-first when available. std::vector<PluginChangelog> 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. std::string error; // Blocking error message. Non-empty means the plugin is in an error state.
+9 -57
View File
@@ -1,5 +1,7 @@
#include "PluginHostApi.hpp" #include "PluginHostApi.hpp"
#include "PluginHostUi.hpp" #include "PluginHostUi.hpp"
#include "PluginHostSlicing.hpp"
#include "PluginBindingUtils.hpp"
#include <libslic3r/BoundingBox.hpp> #include <libslic3r/BoundingBox.hpp>
#include <libslic3r/Model.hpp> #include <libslic3r/Model.hpp>
@@ -46,20 +48,6 @@ PresetBundle* current_preset_bundle()
return 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. // Build a BoundingBoxf3 from precomputed (float) triangle-mesh stats min/max.
BoundingBoxf3 bbox_from_stats(const TriangleMeshStats& stats) BoundingBoxf3 bbox_from_stats(const TriangleMeshStats& stats)
{ {
@@ -86,59 +74,20 @@ struct HostTriangleMesh
const indexed_triangle_set& its() const { return mesh->its; } 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<typename Builder>
py::object with_numpy(Builder&& build)
{
try {
return std::forward<Builder>(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. // 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<typename T> template<typename T>
py::array make_readonly_rows3(const std::shared_ptr<const TriangleMesh>& mesh, py::array make_readonly_rows3(const std::shared_ptr<const TriangleMesh>& mesh,
const T* data, py::ssize_t rows) const T* data, py::ssize_t rows)
{ {
if (rows == 0 || data == nullptr) if (rows == 0 || data == nullptr)
return py::array_t<T>(std::vector<py::ssize_t>{0, 3}); return py::array_t<T>(std::vector<py::ssize_t>{ 0, 3 });
auto* owner = new std::shared_ptr<const TriangleMesh>(mesh); auto* owner = new std::shared_ptr<const TriangleMesh>(mesh);
py::capsule base(owner, [](void* p) { py::capsule base(owner, [](void* p) {
delete reinterpret_cast<std::shared_ptr<const TriangleMesh>*>(p); delete reinterpret_cast<std::shared_ptr<const TriangleMesh>*>(p);
}); });
return make_readonly_rows<T, 3>(base, data, rows);
py::array_t<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<double> 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));
});
} }
py::list current_filament_presets(PresetBundle& bundle) 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. // UI: native dialogs and interactive HTML windows for plugins.
PluginHostUi::RegisterBindings(host); PluginHostUi::RegisterBindings(host);
// Slicing print-graph data model (Print, Layer, Surface, ...).
PluginHostSlicing::RegisterBindings(host);
} }
} // namespace Slic3r } // namespace Slic3r
+512
View File
@@ -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 <pybind11/stl.h>
#include <memory>
#include <optional>
#include <vector>
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<py::array>(h))
throw py::value_error(std::string(who) + ": each contour/hole must be an (N,2) int64 ndarray");
py::array a = py::reinterpret_borrow<py::array>(h);
if (a.dtype().kind() != 'i' || a.itemsize() != (py::ssize_t) sizeof(coord_t))
throw py::value_error(std::string(who) + ": polygon coordinates must be int64 (scaled coords)");
if (a.ndim() != 2 || a.shape(1) != 2)
throw py::value_error(std::string(who) + ": each polygon array must have shape (N,2)");
if (a.shape(0) < 3)
throw py::value_error(std::string(who) + ": a polygon needs at least 3 points");
// dtype already validated as int64; forcecast here only guarantees a C-contiguous buffer.
auto arr = py::array_t<coord_t, py::array::c_style | py::array::forcecast>::ensure(a);
if (!arr)
throw py::value_error(std::string(who) + ": could not read polygon as a contiguous int64 array");
auto r = arr.unchecked<2>();
Polygon poly;
poly.points.reserve((size_t) arr.shape(0));
for (py::ssize_t i = 0; i < arr.shape(0); ++i)
poly.points.emplace_back((coord_t) r(i, 0), (coord_t) r(i, 1));
return poly;
}
// One Python entry -> ExPolygon. Accepts 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<SurfaceType>* out_type = nullptr)
{
ExPolygon ex;
if (py::isinstance<py::array>(entry)) {
ex.contour = parse_polygon(entry, who);
} else if (py::isinstance<py::sequence>(entry) && !py::isinstance<py::str>(entry)) {
py::sequence seq = py::reinterpret_borrow<py::sequence>(entry);
if (py::len(seq) < 1)
throw py::value_error(std::string(who) + ": a [contour, holes] entry needs a contour");
ex.contour = parse_polygon(seq[0], who);
if (py::len(seq) >= 2) {
// Type-check the holes element up front: a non-sequence (e.g. an int) would otherwise
// reach reinterpret_borrow<py::sequence> and raise a bare Python TypeError on iteration,
// whereas the API contract is ValueError for malformed input (str is excluded because it
// is iterable but never a valid holes container).
py::object holes_obj = seq[1];
if (!py::isinstance<py::sequence>(holes_obj) || py::isinstance<py::str>(holes_obj))
throw py::value_error(std::string(who) + ": the holes element must be a list of (N,2) int64 ndarrays");
for (py::handle hh : py::reinterpret_borrow<py::sequence>(holes_obj)) {
Polygon hole = parse_polygon(hh, who);
hole.make_clockwise();
ex.holes.emplace_back(std::move(hole));
}
}
// 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<SurfaceType>(); }
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<py::sequence>(list_h) || py::isinstance<py::str>(list_h))
throw py::value_error(std::string(who) + ": expected a list of polygons");
ExPolygons out;
for (py::handle entry : py::reinterpret_borrow<py::sequence>(list_h))
out.emplace_back(parse_expolygon(entry, who));
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<py::sequence>(list_h) || py::isinstance<py::str>(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<py::sequence>(list_h)) {
std::optional<SurfaceType> 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<ExtrusionPath*> fails on a loop. We descend into
// loops/multipaths here to reach their contained paths.
static void collect_extrusion_paths(const ExtrusionEntity* ee, std::vector<const ExtrusionPath*>& out)
{
if (ee == nullptr)
return;
if (const auto* coll = dynamic_cast<const ExtrusionEntityCollection*>(ee)) {
for (const ExtrusionEntity* child : coll->entities)
collect_extrusion_paths(child, out);
} else if (const auto* loop = dynamic_cast<const ExtrusionLoop*>(ee)) {
for (const ExtrusionPath& p : loop->paths)
out.push_back(&p);
} else if (const auto* mp = dynamic_cast<const ExtrusionMultiPath*>(ee)) {
for (const ExtrusionPath& p : mp->paths)
out.push_back(&p);
} else if (const auto* path = dynamic_cast<const ExtrusionPath*>(ee)) {
// Catches ExtrusionPath and its subclasses (Sloped/Contoured/Oriented) last,
// after the composite types above have been ruled out.
out.push_back(path);
}
}
} // 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_<SurfaceType>(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_<Polygon, std::unique_ptr<Polygon, py::nodelete>>(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<const Polygon&>();
return with_numpy([&] {
return py::object(make_readonly_rows<coord_t, 2>(
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_<ExPolygon, std::unique_ptr<ExPolygon, py::nodelete>>(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<ExPolygon&>();
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_<Surface, std::unique_ptr<Surface, py::nodelete>>(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_<SurfaceCollection, std::unique_ptr<SurfaceCollection, py::nodelete>>(host, "SurfaceCollection")
.def("size", [](const SurfaceCollection& c) { return c.surfaces.size(); })
.def_property_readonly("surfaces", [](py::object self) {
SurfaceCollection& c = self.cast<SurfaceCollection&>();
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_<ExtrusionEntity, std::unique_ptr<ExtrusionEntity, py::nodelete>>(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_<ExtrusionPath, ExtrusionEntity, std::unique_ptr<ExtrusionPath, py::nodelete>>(host, "ExtrusionPath")
.def("points", [](py::object self) {
const ExtrusionPath& p = self.cast<const ExtrusionPath&>();
const Points3& pts = p.polyline.points;
return with_numpy([&] {
return py::object(make_readonly_rows<coord_t, 3>(
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_<ExtrusionLoop, ExtrusionEntity, std::unique_ptr<ExtrusionLoop, py::nodelete>>(host, "ExtrusionLoop")
.def_property_readonly("paths", [](py::object self) {
ExtrusionLoop& l = self.cast<ExtrusionLoop&>();
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_<ExtrusionMultiPath, ExtrusionEntity, std::unique_ptr<ExtrusionMultiPath, py::nodelete>>(host, "ExtrusionMultiPath")
.def_property_readonly("paths", [](py::object self) {
ExtrusionMultiPath& m = self.cast<ExtrusionMultiPath&>();
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_<ExtrusionEntityCollection, ExtrusionEntity,
std::unique_ptr<ExtrusionEntityCollection, py::nodelete>>(host, "ExtrusionEntityCollection")
.def("size", [](const ExtrusionEntityCollection& c) { return c.entities.size(); })
.def_property_readonly("entities", [](py::object self) {
ExtrusionEntityCollection& c = self.cast<ExtrusionEntityCollection&>();
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<const ExtrusionEntityCollection&>();
std::vector<const ExtrusionPath*> paths;
collect_extrusion_paths(&c, paths);
py::list out;
for (const ExtrusionPath* p : paths)
out.append(py::cast(const_cast<ExtrusionPath*>(p),
py::return_value_policy::reference_internal, self));
return out;
}, "Every leaf ExtrusionPath under this tree (collections recursed into, "
"loops/multipaths decomposed).");
py::class_<PrintRegion, std::unique_ptr<PrintRegion, py::nodelete>>(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_<LayerRegion, std::unique_ptr<LayerRegion, py::nodelete>>(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_<Layer, std::unique_ptr<Layer, py::nodelete>>(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<Layer&>();
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<Layer&>();
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_<PrintObject, std::unique_ptr<PrintObject, py::nodelete>>(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<PrintObject&>();
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<PrintObject&>();
py::list out;
for (SupportLayer* sl : o.support_layers())
out.append(py::cast(static_cast<Layer*>(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_<Print, std::unique_ptr<Print, py::nodelete>>(host, "Print")
.def("objects", [](py::object self) {
Print& p = self.cast<Print&>();
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<Model&>(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
+16
View File
@@ -0,0 +1,16 @@
#pragma once
#include <pybind11/pybind11.h>
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
+7
View File
@@ -261,6 +261,13 @@ std::shared_ptr<LoadedPluginCapability> PluginLoader::get_plugin_capability_by_n
return nullptr; return nullptr;
} }
std::map<std::string, std::string> PluginLoader::get_plugin_settings(const std::string& plugin_key) const
{
std::lock_guard<std::mutex> lock(m_mutex);
const auto it = m_plugins.find(plugin_key);
return it != m_plugins.end() ? it->second.descriptor.settings : std::map<std::string, std::string>{};
}
std::vector<std::shared_ptr<LoadedPluginCapability>> PluginLoader::get_loaded_plugin_capabilities(const std::string& plugin_key) const std::vector<std::shared_ptr<LoadedPluginCapability>> PluginLoader::get_loaded_plugin_capabilities(const std::string& plugin_key) const
{ {
std::lock_guard<std::mutex> lock(m_mutex); std::lock_guard<std::mutex> lock(m_mutex);
+2
View File
@@ -104,6 +104,8 @@ public:
std::chrono::milliseconds timeout, std::chrono::milliseconds timeout,
std::string& error) const; std::string& error) const;
std::vector<PluginDescriptor> get_all_loaded_plugin_descriptors() const; std::vector<PluginDescriptor> get_all_loaded_plugin_descriptors() const;
// the plugin's [tool.orcaslicer.plugin.settings] table (empty if the plugin is unknown).
std::map<std::string, std::string> get_plugin_settings(const std::string& plugin_key) const;
// Package descriptor accessor; returns nullptr when the package is not loaded. // Package descriptor accessor; returns nullptr when the package is not loaded.
+4 -4
View File
@@ -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) if (type != Preset::TYPE_PRINT && type != Preset::TYPE_PRINTER && type != Preset::TYPE_FILAMENT)
return {}; return {};
// Plugin-bearing options opt in via ConfigOptionDef::support_plugin, so scan the preset's // Plugin-bearing options opt in via ConfigOptionDef::is_plugin_backed (a non-empty plugin_type),
// definition rather than maintaining a hardcoded per-type field list. A typed preset's config // so scan the preset's definition rather than maintaining a hardcoded per-type field list. A typed
// only contains keys for its own type, so this naturally stays scoped to `type`. // preset's config only contains keys for its own type, so this naturally stays scoped to `type`.
const ConfigDef* def = preset.config.def(); const ConfigDef* def = preset.config.def();
if (def == nullptr) if (def == nullptr)
return {}; 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()) { for (const std::string& field : preset.config.keys()) {
const ConfigOptionDef* opt_def = def->get(field); 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; continue;
const ConfigOption* option = preset.config.option(field); const ConfigOption* option = preset.config.option(field);
+10 -1
View File
@@ -128,7 +128,7 @@ bool read_zip_text_file(mz_zip_archive& archive, const char* filename, std::stri
} }
// TOML section parsing states. // 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. // Strip a quoted string value: "foo" → foo, 'foo' → foo.
// Returns the unquoted value or the input unchanged if not quoted. // 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_description,
std::string& out_author, std::string& out_author,
std::string& out_version, std::string& out_version,
std::map<std::string, std::string>& out_settings,
std::string& error) std::string& error)
{ {
out_deps.clear(); out_deps.clear();
@@ -195,6 +196,7 @@ bool parse_pep723_toml(const std::string& toml_content,
out_description.clear(); out_description.clear();
out_author.clear(); out_author.clear();
out_version.clear(); out_version.clear();
out_settings.clear();
TomlSection section = TomlSection::Root; TomlSection section = TomlSection::Root;
@@ -218,6 +220,8 @@ bool parse_pep723_toml(const std::string& toml_content,
if (trimmed[0] == '[') { if (trimmed[0] == '[') {
if (trimmed == "[tool.orcaslicer.plugin]") { if (trimmed == "[tool.orcaslicer.plugin]") {
section = TomlSection::OrcaPlugin; section = TomlSection::OrcaPlugin;
} else if (trimmed == "[tool.orcaslicer.plugin.settings]") {
section = TomlSection::OrcaPluginSettings; // per-plugin params table
} else { } else {
section = TomlSection::Root; // Unknown section — skip. 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 == "description") out_description = unquote_toml_string(val);
else if (key == "author") out_author = 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 (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_desc,
pep_author, pep_author,
pep_version, pep_version,
descriptor.settings,
pep723_error)) { pep723_error)) {
error = "Failed to parse PEP 723 metadata: " + pep723_error; error = "Failed to parse PEP 723 metadata: " + pep723_error;
return false; return false;
@@ -1,27 +0,0 @@
#pragma once
#include <pybind11/pybind11.h>
#include <pybind11/numpy.h>
#include "libslic3r/Point.hpp"
namespace Slic3r {
// Point/Point3 must be tightly packed for zero-copy views. coord_t = int64_t.
static_assert(sizeof(Point) == 2 * sizeof(coord_t), "Point must be 2 packed coord_t");
static_assert(sizeof(Point3) == 3 * sizeof(coord_t), "Point3 must be 3 packed coord_t");
// Zero-copy, read-only (rows, N) numpy view over `data`, pinned alive by `owner`.
// T is the element scalar (coord_t=int64 for slicing coords). Mirrors PluginHostApi's
// capsule + setflags(write=false) pattern, generalized over column count and owner.
template<typename T, int N>
pybind11::array make_readonly_rows(pybind11::capsule owner, const T* data, pybind11::ssize_t rows)
{
namespace py = pybind11;
py::array_t<T> arr(
{ rows, (py::ssize_t)N },
{ (py::ssize_t)(N * sizeof(T)), (py::ssize_t)sizeof(T) },
data, owner);
arr.attr("setflags")(py::arg("write") = false);
return std::move(arr);
}
} // namespace Slic3r
@@ -1,162 +1,14 @@
#include "SlicingPipelinePluginCapability.hpp" #include "SlicingPipelinePluginCapability.hpp"
#include "SlicingPipelinePluginCapabilityTrampoline.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/libslic3r.h" // unscale<>, live SCALING_FACTOR
#include "libslic3r/ExtrusionEntity.hpp" // ExtrusionPath/Loop/MultiPath, role_to_string #include <pybind11/stl.h> // std::map<std::string,std::string> -> dict for ctx.params
#include "libslic3r/ExtrusionEntityCollection.hpp" // ExtrusionEntityCollection
#include <pybind11/stl.h>
#include <vector>
namespace py = pybind11; namespace py = pybind11;
namespace Slic3r { namespace Slic3r {
bool SlicingPipelineContext::cancelled() const { return print && print->canceled(); } bool SlicingPipelineContext::cancelled() const { return print && print->canceled(); }
namespace {
// Zero-copy read-only int64 (N,2) view over a Polygon's points, pinned by `owner`.
// coord_t == int64; Point is asserted tightly packed in SlicingNumpy.hpp.
static py::array polygon_rows(const py::capsule& owner, const Polygon& poly)
{
const Points& p = poly.points;
return make_readonly_rows<coord_t, 2>(
owner, p.empty() ? nullptr : p.front().data(), (py::ssize_t) p.size());
}
// Flatten an extrusion graph into a list of leaf ExtrusionPath* while walking the
// ORIGINAL Print-owned tree (never a temporary copy): the returned pointers stay
// valid for the execute(ctx) lifetime pinned by `owner`, so points() can hand out
// zero-copy views into path->polyline.points.
//
// This is deliberately NOT ExtrusionEntityCollection::flatten(): flatten() only
// unwraps nested collections (is_collection() is true solely for collections) and
// returns them by value, so it would (a) dangle if we viewed into the copy and
// (b) leave ExtrusionLoop/ExtrusionMultiPath intact — dropping every perimeter
// loop, since dynamic_cast<ExtrusionPath*> fails on a loop. We descend into
// loops/multipaths here to reach their contained paths.
static void collect_extrusion_paths(const ExtrusionEntity* ee, std::vector<const ExtrusionPath*>& out)
{
if (ee == nullptr)
return;
if (const auto* coll = dynamic_cast<const ExtrusionEntityCollection*>(ee)) {
for (const ExtrusionEntity* child : coll->entities)
collect_extrusion_paths(child, out);
} else if (const auto* loop = dynamic_cast<const ExtrusionLoop*>(ee)) {
for (const ExtrusionPath& p : loop->paths)
out.push_back(&p);
} else if (const auto* mp = dynamic_cast<const ExtrusionMultiPath*>(ee)) {
for (const ExtrusionPath& p : mp->paths)
out.push_back(&p);
} else if (const auto* path = dynamic_cast<const ExtrusionPath*>(ee)) {
// Catches ExtrusionPath and its subclasses (Sloped/Contoured/Oriented) last,
// after the composite types above have been ruled out.
out.push_back(path);
}
}
// Build a Python list of PathData over an extrusion collection, each entry pinned by `owner`.
static py::list path_data_list(const py::capsule& owner, const ExtrusionEntityCollection& coll)
{
std::vector<const ExtrusionPath*> paths;
collect_extrusion_paths(&coll, paths);
py::list out;
for (const ExtrusionPath* p : paths)
out.append(PathData{ p, owner });
return out;
}
// --- Task 11 input path: Python geometry -> C++ ExPolygon/Surface, with validation. -------
// The mutators take scaled integer coords (the same units the read views hand out). A Python
// raise here surfaces as ValueError (pybind translates) so malformed input is rejected up
// front rather than silently corrupting the slicing graph.
// One (N,2) int64 ndarray -> Polygon. Rejects wrong dtype/shape and degenerate (<3 pt) rings.
// Float / NaN / inf are rejected implicitly: only a signed-integer, 8-byte (coord_t==int64)
// dtype is accepted, and integer arrays cannot hold NaN/inf.
static Polygon parse_polygon(py::handle h, const char* who)
{
if (!py::isinstance<py::array>(h))
throw py::value_error(std::string(who) + ": each contour/hole must be an (N,2) int64 ndarray");
py::array a = py::reinterpret_borrow<py::array>(h);
if (a.dtype().kind() != 'i' || a.itemsize() != (py::ssize_t) sizeof(coord_t))
throw py::value_error(std::string(who) + ": polygon coordinates must be int64 (scaled coords)");
if (a.ndim() != 2 || a.shape(1) != 2)
throw py::value_error(std::string(who) + ": each polygon array must have shape (N,2)");
if (a.shape(0) < 3)
throw py::value_error(std::string(who) + ": a polygon needs at least 3 points");
// dtype already validated as int64; forcecast here only guarantees a C-contiguous buffer.
auto arr = py::array_t<coord_t, py::array::c_style | py::array::forcecast>::ensure(a);
if (!arr)
throw py::value_error(std::string(who) + ": could not read polygon as a contiguous int64 array");
auto r = arr.unchecked<2>();
Polygon poly;
poly.points.reserve((size_t) arr.shape(0));
for (py::ssize_t i = 0; i < arr.shape(0); ++i)
poly.points.emplace_back((coord_t) r(i, 0), (coord_t) r(i, 1));
return poly;
}
// One Python entry -> ExPolygon. Accepts either a bare (N,2) ndarray (contour only) or a
// [contour, [hole, ...]] sequence. Orientation is normalized (contour CCW, holes CW) so
// downstream area/offset math is correct regardless of the caller's winding.
static ExPolygon parse_expolygon(py::handle entry, const char* who)
{
ExPolygon ex;
if (py::isinstance<py::array>(entry)) {
ex.contour = parse_polygon(entry, who);
} else if (py::isinstance<py::sequence>(entry) && !py::isinstance<py::str>(entry)) {
py::sequence seq = py::reinterpret_borrow<py::sequence>(entry);
if (py::len(seq) < 1)
throw py::value_error(std::string(who) + ": a [contour, holes] entry needs a contour");
ex.contour = parse_polygon(seq[0], who);
if (py::len(seq) >= 2) {
// Type-check the holes element up front: a non-sequence (e.g. an int) would otherwise
// reach reinterpret_borrow<py::sequence> and raise a bare Python TypeError on iteration,
// whereas the API contract is ValueError for malformed input (str is excluded because it
// is iterable but never a valid holes container).
py::object holes_obj = seq[1];
if (!py::isinstance<py::sequence>(holes_obj) || py::isinstance<py::str>(holes_obj))
throw py::value_error(std::string(who) + ": the holes element must be a list of (N,2) int64 ndarrays");
for (py::handle hh : py::reinterpret_borrow<py::sequence>(holes_obj)) {
Polygon hole = parse_polygon(hh, who);
hole.make_clockwise();
ex.holes.emplace_back(std::move(hole));
}
}
} else {
throw py::value_error(std::string(who) + ": each entry must be an (N,2) ndarray or a [contour, holes] pair");
}
ex.contour.make_counter_clockwise();
return ex;
}
// A non-empty Python list of entries -> ExPolygons (each entry parsed + oriented).
static ExPolygons parse_expolygon_list(py::handle list_h, const char* who)
{
if (!py::isinstance<py::sequence>(list_h) || py::isinstance<py::str>(list_h))
throw py::value_error(std::string(who) + ": expected a list of polygons");
ExPolygons out;
for (py::handle entry : py::reinterpret_borrow<py::sequence>(list_h))
out.emplace_back(parse_expolygon(entry, who));
if (out.empty())
throw py::value_error(std::string(who) + ": expected a non-empty list of polygons");
return out;
}
// Build Surfaces from a Python list, carrying surface_type (and the other per-surface
// attributes) forward from the collection being replaced, or defaulting to stInternal when
// the region had no prior surfaces.
static Surfaces surfaces_from_py(py::handle list_h, const SurfaceCollection& replaced, const char* who)
{
ExPolygons ex = parse_expolygon_list(list_h, who);
const Surface tmpl = replaced.surfaces.empty() ? Surface(stInternal) : replaced.surfaces.front();
Surfaces out;
out.reserve(ex.size());
for (ExPolygon& e : ex)
out.emplace_back(Surface(tmpl, std::move(e)));
return out;
}
} // namespace
void SlicingPipelinePluginCapability::RegisterBindings(py::module_& module, py::enum_<PluginCapabilityType>& pluginTypes) { void SlicingPipelinePluginCapability::RegisterBindings(py::module_& module, py::enum_<PluginCapabilityType>& pluginTypes) {
(void) pluginTypes; // matches gcode/script/printerAgent; Step is a fresh enum below. (void) pluginTypes; // matches gcode/script/printerAgent; Step is a fresh enum below.
auto slicing = module.def_submodule("slicing", "Slicing pipeline API (research/experimental)."); 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("Slice", SlicingPipelineStep::Slice)
.value("Perimeters", SlicingPipelineStep::Perimeters) .value("Perimeters", SlicingPipelineStep::Perimeters)
.value("EstimateCurledExtrusions", SlicingPipelineStep::EstimateCurledExtrusions) .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("Ironing", SlicingPipelineStep::Ironing)
.value("Contouring", SlicingPipelineStep::Contouring) .value("Contouring", SlicingPipelineStep::Contouring)
.value("SupportMaterial", SlicingPipelineStep::SupportMaterial) .value("SupportMaterial", SlicingPipelineStep::SupportMaterial)
@@ -175,190 +28,45 @@ void SlicingPipelinePluginCapability::RegisterBindings(py::module_& module, py::
.value("SkirtBrim", SlicingPipelineStep::SkirtBrim) .value("SkirtBrim", SlicingPipelineStep::SkirtBrim)
.export_values(); .export_values();
// --- Read-graph geometry views (see header for the mandatory lifetime rule). --- // The read-graph data model (Surface / ExPolygon / the extrusion tree / LayerRegion /
// Every array/view below is valid ONLY during the execute(ctx) call that produced it. // 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
py::enum_<SurfaceType>(slicing, "SurfaceType") // the capability base. See PluginHostSlicing.cpp for the mandatory reference-lifetime rule.
.value("stTop", stTop)
.value("stBottom", stBottom)
.value("stBottomBridge", stBottomBridge)
.value("stInternalAfterExternalBridge", stInternalAfterExternalBridge)
.value("stInternal", stInternal)
.value("stInternalSolid", stInternalSolid)
.value("stInternalBridge", stInternalBridge)
.value("stSecondInternalBridge", stSecondInternalBridge)
.value("stInternalVoid", stInternalVoid)
.value("stPerimeter", stPerimeter)
.value("stCount", stCount)
.export_values();
// Scaled integer coordinate -> millimeters. Reads the live SCALING_FACTOR at call // 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. // time (1e-6 normal, 1e-5 for beds > 2147mm), so it is never cached.
slicing.def("unscale", [](coord_t v) { return unscale<double>(v); }, py::arg("coord"), slicing.def("unscale", [](coord_t v) { return unscale<double>(v); }, py::arg("coord"),
"Convert a scaled integer coordinate to millimeters (reads the live SCALING_FACTOR)."); "Convert a scaled integer coordinate to millimeters (reads the live SCALING_FACTOR).");
py::class_<ExPolygonView>(slicing, "ExPolygonView")
.def("contour", [](const ExPolygonView& v) { return polygon_rows(v.owner, v.ex->contour); },
"Outer contour as a read-only int64 (N,2) numpy view in scaled coords. "
"Valid only during the execute(ctx) call.")
.def("holes", [](const ExPolygonView& v) {
py::list out;
for (const Polygon& h : v.ex->holes)
out.append(polygon_rows(v.owner, h));
return out;
}, "List of hole contours (CW), each a read-only int64 (N,2) numpy view. "
"Valid only during the execute(ctx) call.");
py::class_<SurfaceView>(slicing, "SurfaceView")
.def_property_readonly("surface_type", [](const SurfaceView& v) { return v.s->surface_type; })
.def_property_readonly("thickness", [](const SurfaceView& v) { return v.s->thickness; })
.def_property_readonly("bridge_angle", [](const SurfaceView& v) { return v.s->bridge_angle; })
.def_property_readonly("extra_perimeters", [](const SurfaceView& v) { return v.s->extra_perimeters; })
.def_property_readonly("expolygon", [](const SurfaceView& v) {
return ExPolygonView{ &v.s->expolygon, v.owner };
}, "This surface's geometry as an ExPolygonView. Valid only during the execute(ctx) call.")
// MUTATOR (Task 11). Reclassify this surface's type (e.g. SurfaceType.stInternalSolid).
// set_type reassigns surface_type ONLY — it does not replace the geometry. Writes through
// the const view by const_cast (the Surface is non-const in the live slicing graph).
// Valid only during the execute(ctx) call.
.def("set_type", [](const SurfaceView& v, SurfaceType type) {
const_cast<Surface*>(v.s)->surface_type = type;
}, py::arg("surface_type"),
"Reclassify this surface's SurfaceType (reassigns surface_type only; the geometry "
"is unchanged). Valid only during the execute(ctx) call.");
// A flattened toolpath. Read-only in v1 (mutation is a later phase). role/width/
// height/mm3_per_mm are plain scalars; points() materializes a zero-copy array.
py::class_<PathData>(slicing, "PathData")
.def("points", [](const PathData& p) {
const Points3& pts = p.path->polyline.points;
return make_readonly_rows<coord_t, 3>(
p.owner, pts.empty() ? nullptr : pts.front().data(), (py::ssize_t) pts.size());
}, "Path vertices as a read-only int64 (N,3) numpy view in scaled coords "
"(the polyline is natively 3D on this branch). Valid only during the execute(ctx) call.")
.def_property_readonly("role", [](const PathData& p) {
return ExtrusionEntity::role_to_string(p.path->role());
}, "Extrusion role as a human-readable string (e.g. \"Outer wall\", \"Sparse infill\").")
.def_property_readonly("width", [](const PathData& p) { return p.path->width; })
.def_property_readonly("height", [](const PathData& p) { return p.path->height; })
.def_property_readonly("mm3_per_mm", [](const PathData& p) { return p.path->mm3_per_mm; });
py::class_<LayerRegionView>(slicing, "LayerRegionView")
.def("slices", [](const LayerRegionView& v) {
py::list out;
for (const Surface& s : v.r->slices.surfaces)
out.append(SurfaceView{ &s, v.owner });
return out;
}, "Sliced surfaces (typed top/bottom/internal) as [SurfaceView]. "
"Valid only during the execute(ctx) call.")
.def("fill_surfaces", [](const LayerRegionView& v) {
py::list out;
for (const Surface& s : v.r->fill_surfaces.surfaces)
out.append(SurfaceView{ &s, v.owner });
return out;
}, "Surfaces prepared for infill as [SurfaceView]. "
"Valid only during the execute(ctx) call.")
.def("perimeters", [](const LayerRegionView& v) {
return path_data_list(v.owner, v.r->perimeters);
}, "Perimeter toolpaths flattened to [PathData] (nested collections and "
"loops decomposed into their paths). Valid only during the execute(ctx) call.")
.def("fills", [](const LayerRegionView& v) {
return path_data_list(v.owner, v.r->fills);
}, "Infill toolpaths flattened to [PathData] (nested collections and loops "
"decomposed into their paths). Valid only during the execute(ctx) call.")
// MUTATOR (Task 11). Replace this region's sliced surfaces. `polygons` is a list of
// (N,2) int64 ndarrays (scaled coords) or [contour, [holes...]] pairs; orientation is
// normalized (contour CCW, holes CW) and surface_type is carried forward from the
// replaced surfaces (else stInternal). Writes through the const view by const_cast.
.def("set_slices", [](const LayerRegionView& v, py::object polygons) {
auto* region = const_cast<LayerRegion*>(v.r);
region->slices.set(surfaces_from_py(polygons, region->slices, "set_slices"));
}, py::arg("polygons"),
"Replace this region's sliced surfaces from a list of (N,2) int64 ndarrays (scaled "
"coords) or [contour, [holes...]] pairs (orientation normalized: contour CCW / holes "
"CW; surface_type carried forward from the replaced surfaces, else stInternal).\n"
"MUTATION-CASCADE: at the Slice boundary this is the primary, fully-supported entry "
"point -- the split slice loop runs make_perimeters() afterward, so the change cascades "
"into perimeters and everything downstream (final G-code).\n"
"PERSISTENCE (v1 limitation): the mutation is written into region->slices, but the "
"pre-hook geometry is also retained in each Layer's raw_slices backup (taken by "
"slice() BEFORE this hook fires). The mutation therefore survives only while posSlice "
"stays cached AND perimeters are not re-run from those restored raw slices: "
"make_perimeters() calls restore_untyped_slices(), which overwrites slices from "
"raw_slices, so a config change that re-runs perimeters without re-slicing (e.g. "
"wall_loops) silently reverts to the original geometry while posSlice stays cached "
"(this hook does NOT re-fire). Re-selecting the plugin -- or any other "
"posSlice-invalidating change -- re-fires this hook and re-applies the mutation. "
"Propagating the mutation into raw_slices is a known v1 limitation.\n"
"DUPLICATES: identical objects share Layer*, so the mutation on the object that slices "
"is automatically seen by its duplicates; objects that must mutate independently must "
"not be identical.\n"
"Raises ValueError on malformed input. Valid only during the execute(ctx) call.")
// MUTATOR (Task 11). Replace this region's fill (infill-prep) surfaces; identical input
// format and validation to set_slices.
.def("set_fill_surfaces", [](const LayerRegionView& v, py::object polygons) {
auto* region = const_cast<LayerRegion*>(v.r);
region->fill_surfaces.set(surfaces_from_py(polygons, region->fill_surfaces, "set_fill_surfaces"));
}, py::arg("polygons"),
"Replace this region's fill (infill-prep) surfaces; same input format/validation as "
"set_slices.\n"
"MUTATION-CASCADE: at the Infill boundary this changes the stored surfaces but does NOT "
"regenerate the already-built `fills` toolpaths in v1.\n"
"Raises ValueError on malformed input. Valid only during the execute(ctx) call.");
py::class_<LayerView>(slicing, "LayerView")
.def_property_readonly("slice_z", [](const LayerView& v) { return v.l->slice_z; })
.def_property_readonly("print_z", [](const LayerView& v) { return v.l->print_z; })
.def_property_readonly("height", [](const LayerView& v) { return v.l->height; })
.def("lslices", [](const LayerView& v) {
py::list out;
for (const ExPolygon& e : v.l->lslices)
out.append(ExPolygonView{ &e, v.owner });
return out;
}, "Merged per-layer islands as [ExPolygonView]. "
"Valid only during the execute(ctx) call.")
.def("regions", [](const LayerView& v) {
py::list out;
for (const LayerRegion* r : v.l->regions())
out.append(LayerRegionView{ r, v.owner });
return out;
}, "Per-region views as [LayerRegionView]. "
"Valid only during the execute(ctx) call.")
// MUTATOR (Task 11). Replace this layer's merged islands (lslices) and refresh the
// cache-invariant `lslices_bboxes` (one BoundingBox per island via get_extents). Same
// input format/validation as LayerRegionView.set_slices. Writes through the const view
// by const_cast.
.def("set_lslices", [](const LayerView& v, py::object islands) {
auto* layer = const_cast<Layer*>(v.l);
layer->lslices = parse_expolygon_list(islands, "set_lslices");
layer->lslices_bboxes.clear();
layer->lslices_bboxes.reserve(layer->lslices.size());
for (const ExPolygon& island : layer->lslices)
layer->lslices_bboxes.emplace_back(get_extents(island));
}, py::arg("islands"),
"Replace this layer's merged islands (lslices) from a list of (N,2) int64 ndarrays "
"(scaled coords) or [contour, [holes...]] pairs, and refresh lslices_bboxes (one "
"bounding box per island via get_extents) so the bbox cache stays consistent. Same "
"input format/validation as LayerRegionView.set_slices. Raises ValueError on malformed "
"input. Valid only during the execute(ctx) call.");
py::class_<PrintObjectView>(slicing, "PrintObjectView")
.def("layers", [](const PrintObjectView& v) {
py::list out;
for (const Layer* l : v.o->layers())
out.append(LayerView{ l, v.owner });
return out;
}, "Object layers as [LayerView]. Valid only during the execute(ctx) call.");
py::class_<SlicingPipelineContext>(slicing, "SlicingPipelineContext") py::class_<SlicingPipelineContext>(slicing, "SlicingPipelineContext")
.def_readonly("orca_version", &SlicingPipelineContext::orca_version) .def_readonly("orca_version", &SlicingPipelineContext::orca_version)
.def_readonly("step", &SlicingPipelineContext::step) .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 { .def_property_readonly("object", [](const SlicingPipelineContext& ctx) -> py::object {
if (ctx.object == nullptr) if (ctx.object == nullptr)
return py::none(); return py::none();
return py::cast(PrintObjectView{ ctx.object, ctx.owner }); // The hook signature hands objects out as const; they are genuinely mutable
}, "PrintObjectView for object-scoped steps, or None for print-wide steps. " // (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<PrintObject*>(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.") "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); .def("cancelled", &SlicingPipelineContext::cancelled);
py::class_<SlicingPipelinePluginCapability, PluginCapabilityInterface, py::class_<SlicingPipelinePluginCapability, PluginCapabilityInterface,
@@ -1,57 +1,27 @@
#pragma once #pragma once
#include "slic3r/plugin/PythonPluginInterface.hpp" #include "slic3r/plugin/PythonPluginInterface.hpp"
#include "libslic3r/Print.hpp" // SlicingPipelineStep, PrintObject #include "libslic3r/Print.hpp" // SlicingPipelineStep, Print, PrintObject
#include "libslic3r/Layer.hpp" // Layer, LayerRegion, SurfaceCollection
#include "libslic3r/Surface.hpp" // Surface, SurfaceType
#include "libslic3r/ExPolygon.hpp" // ExPolygon, Polygon
#include <pybind11/pybind11.h> #include <pybind11/pybind11.h>
#include <map>
#include <string> #include <string>
namespace Slic3r { namespace Slic3r {
// --------------------------------------------------------------------------- // Workflow context handed to SlicingPipeline plugins. ctx.print / ctx.object
// Read-graph geometry views (Task 8). // are RAW references into the live slicing graph — the same objects the C++
// // pipeline mutates. The data-model bindings and the mandatory lifetime rule
// LIFETIME (mandatory): each view is a thin, non-owning wrapper holding a raw // (valid only during execute(ctx); mutators invalidate references into replaced
// pointer into a buffer owned by the Print / PrintObject that the slicing // containers, like std::vector iterators) live in
// pipeline mutates and frees between steps. A view — and every numpy array a // src/slic3r/plugin/PluginHostSlicing.cpp.
// view hands out (ExPolygonView::contour()/holes()) — is valid ONLY for the
// duration of the execute(ctx) call that produced it. The `owner` capsule pins
// the owning SlicingPipelineContext's Print* alive for the array's lifetime,
// but the underlying std::vector storage may be reallocated by the next
// pipeline step, so a Python plugin MUST NOT stash a view or an array across
// execute() calls or read one after execute() returns. Read now, copy what you
// need, and let the views go.
//
// Read accessors are zero-copy and non-owning as described above. The 2D-geometry
// mutators added in Task 11 (LayerRegionView.set_slices/set_fill_surfaces,
// LayerView.set_lslices, SurfaceView.set_type) write THROUGH these const views by
// const_cast: the pointed-to Layer/LayerRegion/Surface are genuinely non-const
// (owned mutably by the Print; the dispatcher merely hands them out as const), the
// same pattern the C++ slicing-pipeline hook uses. Mutations take effect on the live
// slicing graph and cascade per the per-method contract documented in the bindings.
// ---------------------------------------------------------------------------
struct ExPolygonView { const ExPolygon* ex; pybind11::capsule owner; };
struct SurfaceView { const Surface* s; pybind11::capsule owner; };
struct LayerRegionView { const LayerRegion* r; pybind11::capsule owner; };
struct LayerView { const Layer* l; pybind11::capsule owner; };
struct PrintObjectView { const PrintObject* o; pybind11::capsule owner; };
// A single flattened toolpath (Task 9). `path` points into a Print-owned
// ExtrusionEntityCollection (a LayerRegion's `perimeters`/`fills`); like every
// view above it is non-owning and valid ONLY during the producing execute(ctx)
// call, with `owner` pinning that Print* alive for any array points() hands out.
struct PathData { const ExtrusionPath* path; pybind11::capsule owner; };
struct SlicingPipelineContext { struct SlicingPipelineContext {
std::string orca_version; std::string orca_version;
SlicingPipelineStep step { SlicingPipelineStep::Slice }; 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 const PrintObject* object { nullptr }; // null for print-wide steps
// Capsule pinning `print` alive for any zero-copy array a view hands out. // read-only per-plugin settings, populated by the dispatcher from the
// Populated by Task 10's dispatcher; a default (empty) capsule is fine for // plugin's [tool.orcaslicer.plugin.settings] PEP-723 table. Exposed as
// print-wide steps and for unit tests exercising views over static data. // ctx.params (dict of string->string).
pybind11::capsule owner; std::map<std::string, std::string> params;
bool cancelled() const; // -> print->canceled() bool cancelled() const; // -> print->canceled()
}; };
+286 -31
View File
@@ -9,7 +9,8 @@ TEST_CASE("slicing_pipeline_plugin option exists and defaults empty", "[slicing_
CHECK(opt->values.empty()); CHECK(opt->values.empty());
const ConfigOptionDef* def = cfg.def()->get("slicing_pipeline_plugin"); const ConfigOptionDef* def = cfg.def()->get("slicing_pipeline_plugin");
REQUIRE(def != nullptr); 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); 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; }); }; 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::Slice) == 1);
CHECK(count(S::Perimeters) == 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::Infill) == 1);
CHECK(count(S::WipeTower) == 1); CHECK(count(S::WipeTower) == 1);
CHECK(count(S::SkirtBrim) == 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: // Slice must fire before Perimeters for the same object:
auto idx = [&](S s){ for (size_t i=0;i<calls.size();++i) if (calls[i].step==s) return (int)i; return -1; }; auto idx = [&](S s){ for (size_t i=0;i<calls.size();++i) if (calls[i].step==s) return (int)i; return -1; };
CHECK(idx(S::Slice) < idx(S::Perimeters)); CHECK(idx(S::Slice) < idx(S::Perimeters));
CHECK(idx(S::Perimeters) < idx(S::PrepareInfill)); // prepare-infill fires after perimeters...
CHECK(idx(S::PrepareInfill) < idx(S::Infill)); // ...and before the fills are built
} }
#include <sstream> #include <sstream>
#include <cmath>
// 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 <name> 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]") { 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: // 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); Slic3r::Print::set_slicing_pipeline_hook_fn(nullptr);
return g; return g;
}; };
// Pre-existing nondeterminism unrelated to the hook makes a raw string compare // Compare only machine-meaningful output (see strip_nondeterministic_gcode_lines): every
// impossible: exported gcode embeds a wall-clock timestamp and ids derived from // motion/extrusion byte is still compared, so this proves the inactive hook -- and the
// the process-global ObjectID counter (never reset between runs) in a handful of // active-but-non-mutating hook -- leave the real toolpath byte-identical.
// comment lines. Strip exactly those comment lines; every other byte -- all const std::string baseline = strip_nondeterministic_gcode_lines(run(false, false)); // feature absent
// motion/extrusion/temperature commands and all remaining comments -- is still CHECK(strip_nondeterministic_gcode_lines(run(false, true)) == baseline); // gated off: hook never fires
// compared, so the assertion still proves the inactive hook leaves all CHECK(strip_nondeterministic_gcode_lines(run(true, true)) == baseline); // active no-op hook fires everywhere, mutates nothing
// machine-meaningful output byte-identical.
auto normalize = [](const std::string& gcode) {
std::string out; out.reserve(gcode.size());
std::istringstream in(gcode);
std::string line;
while (std::getline(in, line)) {
if (line.compare(0, 15, "; generated by ") == 0) continue; // wall-clock timestamp
if (line.compare(0, 18, "; model label id: ") == 0) continue; // ObjectID-derived
// "; [stop] printing object <name> id:N copy M" and
// "; start/stop printing object, unique label id: N" (ObjectID-derived):
if (line.find("printing object") != std::string::npos && line.find(" id:") != std::string::npos) continue;
// Config-dump comment: the active run legitimately records the selected plugin
// ("; slicing_pipeline_plugin = probe") while the baseline leaves it empty. This
// is a machine-irrelevant comment, not motion -- strip it so the comparison isolates
// whether the active-but-non-mutating hook perturbs the real toolpath.
if (line.find("slicing_pipeline_plugin") != std::string::npos) continue;
out += line; out += '\n';
}
return out;
};
const std::string baseline = normalize(run(false, false)); // feature absent
CHECK(normalize(run(false, true)) == baseline); // gated off: hook never fires
CHECK(normalize(run(true, true)) == baseline); // active no-op hook fires everywhere, mutates nothing
} }
// Fix 4(a): gating negative path. With the option EMPTY the plugin is inactive, so a // 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/Layer.hpp" // Layer, LayerRegion (full defs for the cascade hook)
#include "libslic3r/ClipperUtils.hpp" // offset_ex #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 // region's `slices` at the Slice boundary (via SurfaceCollection::set with offset
// polygons); because make_perimeters() derives fill_surfaces from slices AFTER the // polygons); because make_perimeters() derives fill_surfaces from slices AFTER the
// Slice hook fires (see Print::process's split slice loop), the downstream // 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); print.apply(model, config);
CHECK_FALSE(print.objects().front()->is_step_done(posSlice)); // re-slice required CHECK_FALSE(print.objects().front()->is_step_done(posSlice)); // re-slice required
} }
#include <catch2/matchers/catch_matchers_floating_point.hpp>
// §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<Slic3r::PrintObject*>(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<Slic3r::PrintObject*>(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<const Slic3r::ExtrusionEntityCollection*>(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<Slic3r::PrintObject*>(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<Slic3r::PrintObject*>(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<Slic3r::PrintObject*>(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
}
+54
View File
@@ -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;;").has_value());
CHECK_FALSE(Slic3r::parse_capability_ref("plugin;uuid;").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<DynamicPrintConfig> config_ptr(DynamicPrintConfig::new_from_defaults_keys(
{"post_process_plugin", "slicing_pipeline_plugin", "printer_agent"}));
DynamicPrintConfig config = std::move(*config_ptr);
config.option<ConfigOptionStrings>("post_process_plugin", true)->values = {"pp"};
config.option<ConfigOptionStrings>("slicing_pipeline_plugin", true)->values = {"sp"};
config.option<ConfigOptionString>("printer_agent", true)->value = "agent";
config.update_plugin_manifest();
const std::vector<std::string> manifest = config.option<ConfigOptionStrings>("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<DynamicPrintConfig> config_ptr(DynamicPrintConfig::new_from_defaults_keys(
{"post_process_plugin", "printer_agent"}));
DynamicPrintConfig config = std::move(*config_ptr);
config.option<ConfigOptionStrings>("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<std::string> manifest = config.option<ConfigOptionStrings>("plugins")->values;
CHECK(manifest == std::vector<std::string>{"x;;post-processing"});
}
+35
View File
@@ -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); read_install_state(plugin_dir, scanned);
CHECK(scanned.installed_version == "1.2.0"); 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);
}
@@ -11,7 +11,7 @@ TEST_CASE("SlicingPipeline capability-type string maps round-trip", "[slicing_pi
} }
#include "python_test_support.hpp" #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 "slic3r/plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.hpp"
#include "libslic3r/Point.hpp" #include "libslic3r/Point.hpp"
#include "libslic3r/ExPolygon.hpp" #include "libslic3r/ExPolygon.hpp"
@@ -76,124 +76,45 @@ class Probe(orca.slicing.SlicingPipelineCapabilityBase):
def execute(self, ctx): return orca.ExecutionResult.success("ok") def execute(self, ctx): return orca.ExecutionResult.success("ok")
_probe = Probe() _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 TEST_CASE("orca.slicing is workflow-only: context exposes raw print/object; view classes are gone", "[slicing_pipeline]") {
// unscale() helper, and every non-array read accessor (surface_type / thickness /
// bridge_angle / extra_perimeters / expolygon / empty holes()). None of these
// materialize a py::array, so they run unconditionally (no numpy guard needed).
TEST_CASE("orca.slicing geometry views: types, SurfaceType, unscale, non-array accessors", "[slicing_pipeline]") {
using Catch::Matchers::WithinRel; using Catch::Matchers::WithinRel;
using Catch::Matchers::WithinAbs;
ensure_python_initialized(); ensure_python_initialized();
import_orca_module(); import_orca_module();
py::gil_scoped_acquire gil; 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. // Context surface: raw graph entry points + workflow accessors.
for (const char* name : { "ExPolygonView", "SurfaceView", "LayerRegionView", for (const char* name : { "print", "object", "params", "config_value", "cancelled",
"LayerView", "PrintObjectView", "SurfaceType" }) "orca_version", "step" })
CHECK(py::hasattr(slicing, name)); CHECK(py::hasattr(slicing.attr("SlicingPipelineContext"), name));
// Read-graph traversal methods exist on the class objects (verified without a // The wrapper layer is gone.
// full Print, which slic3rutils cannot build). for (const char* legacy : { "ExPolygonView", "SurfaceView", "LayerRegionView",
CHECK(py::hasattr(slicing.attr("ExPolygonView"), "contour")); "LayerView", "PrintObjectView", "PathData", "SurfaceType" })
CHECK(py::hasattr(slicing.attr("ExPolygonView"), "holes")); CHECK_FALSE(py::hasattr(slicing, legacy));
CHECK(py::hasattr(slicing.attr("LayerRegionView"), "slices"));
CHECK(py::hasattr(slicing.attr("LayerRegionView"), "fill_surfaces"));
CHECK(py::hasattr(slicing.attr("LayerView"), "regions"));
CHECK(py::hasattr(slicing.attr("LayerView"), "lslices"));
CHECK(py::hasattr(slicing.attr("PrintObjectView"), "layers"));
CHECK(py::hasattr(slicing.attr("SlicingPipelineContext"), "object"));
// SurfaceType enum values round-trip to the C++ enumerators. // unscale() stays in orca.slicing and reads the live SCALING_FACTOR.
py::object ST = slicing.attr("SurfaceType");
CHECK(ST.attr("stTop").cast<Slic3r::SurfaceType>() == Slic3r::stTop);
CHECK(ST.attr("stInternalSolid").cast<Slic3r::SurfaceType>() == Slic3r::stInternalSolid);
CHECK(ST.attr("stPerimeter").cast<Slic3r::SurfaceType>() == Slic3r::stPerimeter);
CHECK(ST.attr("stCount").cast<Slic3r::SurfaceType>() == Slic3r::stCount);
// unscale() reads the live SCALING_FACTOR both when scaling and unscaling.
const coord_t scaled10 = (coord_t) scale_(10.0); const coord_t scaled10 = (coord_t) scale_(10.0);
double mm = slicing.attr("unscale")(scaled10).cast<double>(); double mm = slicing.attr("unscale")(scaled10).cast<double>();
CHECK_THAT(mm, WithinRel(10.0, 1e-9)); CHECK_THAT(mm, WithinRel(10.0, 1e-9));
// SurfaceView non-array accessors against a hand-built Surface. // A default context casts print/object to None (no dangling wrapper).
Slic3r::Surface surf(Slic3r::stInternalSolid); Slic3r::SlicingPipelineContext ctx;
surf.thickness = 0.4; py::object pyctx = py::cast(&ctx, py::return_value_policy::reference);
surf.bridge_angle = -1.0; CHECK(pyctx.attr("print").is_none());
surf.extra_perimeters = 2; CHECK(pyctx.attr("object").is_none());
py::capsule owner(&surf, [](void*){}); // no-op owner (data outlives the view here)
py::object sv = py::cast(Slic3r::SurfaceView{ &surf, owner });
CHECK(sv.attr("surface_type").cast<Slic3r::SurfaceType>() == Slic3r::stInternalSolid);
CHECK_THAT(sv.attr("thickness").cast<double>(), WithinRel(0.4, 1e-9));
CHECK_THAT(sv.attr("bridge_angle").cast<double>(), WithinAbs(-1.0, 1e-12));
CHECK(sv.attr("extra_perimeters").cast<int>() == 2);
// expolygon accessor yields an ExPolygonView; holes() on an empty ExPolygon is an
// empty list and materializes no array (so it stays outside the numpy guard).
py::object exv = sv.attr("expolygon");
CHECK(py::hasattr(exv, "contour"));
CHECK(exv.attr("holes")().cast<py::list>().size() == 0);
}
TEST_CASE("ExPolygonView.contour()/holes() are read-only int64 (N,2) views in scaled coords", "[slicing_pipeline]") {
ensure_python_initialized();
import_orca_module();
py::gil_scoped_acquire gil;
// make_readonly_rows() constructs a py::array, which needs numpy at runtime; the
// unit-test interpreter ships none. Skip the array-backed assertions when numpy is
// unavailable (same convention as the make_readonly_rows test above).
bool have_numpy = false;
try {
py::module_::import("numpy");
have_numpy = true;
} catch (const py::error_already_set&) {
have_numpy = false;
}
if (!have_numpy) {
SKIP("numpy unavailable in unit-test interpreter");
}
const coord_t s = (coord_t) scale_(10.0);
Slic3r::ExPolygon ex;
ex.contour.points = { Slic3r::Point(0, 0), Slic3r::Point(s, 0),
Slic3r::Point(s, s), Slic3r::Point(0, s) };
Slic3r::Polygon hole;
hole.points = { Slic3r::Point(1, 1), Slic3r::Point(2, 1), Slic3r::Point(2, 2) };
ex.holes = { hole };
py::capsule owner(&ex, [](void*){});
py::object view = py::cast(Slic3r::ExPolygonView{ &ex, owner });
py::array c = view.attr("contour")().cast<py::array>();
CHECK(c.dtype().kind() == 'i');
CHECK(c.itemsize() == 8); // int64
CHECK(c.shape(0) == 4);
CHECK(c.shape(1) == 2);
CHECK_FALSE(c.writeable());
auto rc = c.cast<py::array_t<coord_t>>().unchecked<2>();
CHECK(rc(0, 0) == 0);
CHECK(rc(1, 0) == s);
CHECK(rc(2, 1) == s);
// holes() -> list of read-only (N,2) int64 views.
py::list holes = view.attr("holes")().cast<py::list>();
CHECK(holes.size() == 1);
py::array h0 = holes[0].cast<py::array>();
CHECK(h0.shape(0) == 3);
CHECK(h0.shape(1) == 2);
CHECK_FALSE(h0.writeable());
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Task 9: toolpaths (PathData over perimeters/fills). // Toolpath helpers for the raw-graph tests.
// //
// LayerRegion's ctor is protected (constructed only by Layer/PrintObject). A // LayerRegion's ctor is protected (constructed only by Layer/PrintObject). A
// trivial derived struct lets a unit test build one with null layer/region // 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. // collections, never the layer/region back-pointers.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
namespace { namespace {
@@ -223,221 +144,314 @@ static void build_nested_perimeters(TestLayerRegion& region) {
} }
} // namespace } // namespace
// Numpy-free half: perimeters() flattens the nested graph (descending through // ---------------------------------------------------------------------------
// collections and decomposing loops) into a [PathData] list; role/width/height/ // Raw Print-graph data model (orca.host) — replaces the *View wrapper API.
// mm3_per_mm are plain scalars, so these assertions run unconditionally. // LIFETIME: raw bindings follow C++ semantics — references into the slicing
TEST_CASE("orca.slicing LayerRegionView.perimeters()/fills(): PathData scalars over a nested graph", "[slicing_pipeline]") { // 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::SurfaceType>() == Slic3r::stTop);
CHECK(ST.attr("stInternalSolid").cast<Slic3r::SurfaceType>() == Slic3r::stInternalSolid);
CHECK(ST.attr("stPerimeter").cast<Slic3r::SurfaceType>() == 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::SurfaceType>() == Slic3r::stInternalSolid);
CHECK_THAT(sv.attr("thickness").cast<double>(), WithinRel(0.4, 1e-9));
CHECK_THAT(sv.attr("bridge_angle").cast<double>(), WithinAbs(-1.0, 1e-12));
CHECK(sv.attr("extra_perimeters").cast<int>() == 2);
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<py::list>().size() == 0);
CHECK(exv.attr("contour").attr("size")().cast<size_t>() == 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<py::array>();
CHECK(c.dtype().kind() == 'i');
CHECK(c.itemsize() == 8); // int64
CHECK(c.shape(0) == 4);
CHECK(c.shape(1) == 2);
CHECK_FALSE(c.writeable());
auto rc = c.cast<py::array_t<coord_t>>().unchecked<2>();
CHECK(rc(0, 0) == 0);
CHECK(rc(1, 0) == s);
CHECK(rc(2, 1) == s);
py::list holes = view.attr("holes").cast<py::list>();
REQUIRE(holes.size() == 1);
py::array h0 = holes[0].attr("points")().cast<py::array>();
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; using Catch::Matchers::WithinRel;
ensure_python_initialized(); ensure_python_initialized();
import_orca_module(); import_orca_module();
py::gil_scoped_acquire gil; 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")); Slic3r::ExtrusionEntityCollection outer = build_nested_collection();
CHECK(py::hasattr(slicing.attr("LayerRegionView"), "perimeters")); py::object coll = py::cast(&outer, py::return_value_policy::reference);
CHECK(py::hasattr(slicing.attr("LayerRegionView"), "fills"));
TestLayerRegion region; // .entities downcasts: the single child is a collection; ITS children are a loop + a path.
build_nested_perimeters(region); py::list kids = coll.attr("entities").cast<py::list>();
py::capsule owner(&region, [](void*){}); // no-op: region outlives the view REQUIRE(kids.size() == 1);
py::object lrv = py::cast(Slic3r::LayerRegionView{ &region, owner }); py::list inner_kids = kids[0].attr("entities").cast<py::list>();
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<py::list>(); // flatten_paths: loop decomposed, scalars readable.
REQUIRE(ps.size() == 2); // loop's path + bare path py::list ps = coll.attr("flatten_paths")().cast<py::list>();
REQUIRE(ps.size() == 2);
py::object pd0 = ps[0]; // pathA, from the loop CHECK(ps[0].attr("role").cast<std::string>() == "Outer wall");
CHECK(pd0.attr("role").cast<std::string>() == "Outer wall"); CHECK_THAT(ps[0].attr("width").cast<double>(), WithinRel(0.45, 1e-6));
CHECK_THAT(pd0.attr("width").cast<double>(), WithinRel(0.45, 1e-6)); CHECK_THAT(ps[0].attr("mm3_per_mm").cast<double>(), WithinRel(0.05, 1e-9));
CHECK_THAT(pd0.attr("height").cast<double>(), WithinRel(0.20, 1e-6)); CHECK(ps[1].attr("role").cast<std::string>() == "Sparse infill");
CHECK_THAT(pd0.attr("mm3_per_mm").cast<double>(), WithinRel(0.05, 1e-9));
py::object pd1 = ps[1]; // pathB, bare
CHECK(pd1.attr("role").cast<std::string>() == "Sparse infill");
CHECK_THAT(pd1.attr("width").cast<double>(), WithinRel(0.40, 1e-6));
// fills is empty on this hand-built region.
CHECK(lrv.attr("fills")().cast<py::list>().size() == 0);
} }
// Numpy-backed half: PathData.points() materializes a read-only (N,3) int64 view. TEST_CASE("orca.host ExtrusionPath.points() is a read-only (N,3) int64 view", "[slicing_pipeline]") {
TEST_CASE("orca.slicing PathData.points() is a read-only (N,3) int64 view", "[slicing_pipeline]") {
ensure_python_initialized(); ensure_python_initialized();
import_orca_module(); import_orca_module();
py::gil_scoped_acquire gil; 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; bool have_numpy = false;
try { try { py::module_::import("numpy"); have_numpy = true; }
py::module_::import("numpy"); catch (const py::error_already_set&) { have_numpy = false; }
have_numpy = true; if (!have_numpy) SKIP("numpy unavailable in unit-test interpreter");
} catch (const py::error_already_set&) {
have_numpy = false;
}
if (!have_numpy) {
SKIP("numpy unavailable in unit-test interpreter");
}
TestLayerRegion region; Slic3r::ExtrusionEntityCollection outer = build_nested_collection();
build_nested_perimeters(region); py::object coll = py::cast(&outer, py::return_value_policy::reference);
py::capsule owner(&region, [](void*){}); py::list ps = coll.attr("flatten_paths")().cast<py::list>();
py::object lrv = py::cast(Slic3r::LayerRegionView{ &region, owner });
py::list ps = lrv.attr("perimeters")().cast<py::list>();
REQUIRE(ps.size() == 2); REQUIRE(ps.size() == 2);
py::array pts = ps[1].attr("points")().cast<py::array>(); // pathB: (1,1,0),(2,1,0),(2,2,0)
// pathB has 3 points: (1,1,0), (2,1,0), (2,2,0).
py::array pts = ps[1].attr("points")().cast<py::array>();
CHECK(pts.dtype().kind() == 'i'); CHECK(pts.dtype().kind() == 'i');
CHECK(pts.itemsize() == 8); // int64 CHECK(pts.itemsize() == 8);
CHECK(pts.shape(0) == 3); CHECK(pts.shape(0) == 3);
CHECK(pts.shape(1) == 3); CHECK(pts.shape(1) == 3);
CHECK_FALSE(pts.writeable()); CHECK_FALSE(pts.writeable());
auto r = pts.cast<py::array_t<coord_t>>().unchecked<2>(); auto r = pts.cast<py::array_t<coord_t>>().unchecked<2>();
CHECK(r(0, 0) == 1); CHECK(r(0, 1) == 1); CHECK(r(0, 2) == 0); CHECK(r(0, 0) == 1); CHECK(r(1, 0) == 2); CHECK(r(2, 1) == 2);
CHECK(r(1, 0) == 2);
CHECK(r(2, 1) == 2);
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Task 11: 2D-geometry mutators (set_slices / set_fill_surfaces / set_lslices / set_type). // Raw Print-graph spine (orca.host): LayerRegion / Layer / PrintObject / Print,
// // read side. LayerRegion/Layer ctors are protected (friend class PrintObject),
// Numpy-free half: the four mutators are registered, set_type reclassifies a surface // so the tests use tiny derived structs -- the pattern TestLayerRegion above
// end-to-end (read back from C++), and the input validators raise ValueError on garbage. // already establishes; TestLayer is its Layer counterpart.
// None of this materializes a py::array, so it runs unconditionally.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
TEST_CASE("orca.slicing mutators: registration, set_type reclassify, and ValueError on garbage", "[slicing_pipeline]") { namespace {
ensure_python_initialized(); struct TestLayer : Slic3r::Layer {
import_orca_module(); // id=0, no owning PrintObject, height/print_z/slice_z suitable for assertions.
py::gil_scoped_acquire gil; TestLayer() : Slic3r::Layer(0, nullptr, 0.2, 0.45, 0.35) {}
py::object slicing = py::module_::import("orca").attr("slicing"); };
} // namespace
// All four mutators are registered on their view classes. TEST_CASE("orca.host graph classes: LayerRegion/Layer raw traversal; Print/PrintObject registered", "[slicing_pipeline]") {
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(&region, [](void*){}); // no-op: region outlives the view
py::object lrv = py::cast(Slic3r::LayerRegionView{ &region, owner });
py::list sl = lrv.attr("slices")().cast<py::list>();
REQUIRE(sl.size() == 1);
py::object sv = sl[0];
CHECK(sv.attr("surface_type").cast<Slic3r::SurfaceType>() == Slic3r::stInternal);
sv.attr("set_type")(py::cast(Slic3r::stTop)); // reclassify -> stTop
CHECK(region.slices.surfaces.front().surface_type == Slic3r::stTop); // C++ side reflects it
CHECK(sv.attr("surface_type").cast<Slic3r::SurfaceType>() == Slic3r::stTop); // and via the view
// Malformed inputs raise ValueError (pybind-translated), never corrupt geometry. These
// paths are rejected before any numpy array is materialized, so they need no numpy guard.
auto raises_value_error = [](py::object callable, py::object arg) {
try { callable(arg); return false; }
catch (py::error_already_set& e) { return e.matches(PyExc_ValueError); }
};
CHECK(raises_value_error(lrv.attr("set_slices"), py::list())); // empty list
CHECK(raises_value_error(lrv.attr("set_slices"), py::int_(42))); // not a sequence
CHECK(raises_value_error(lrv.attr("set_slices"), py::str("nope"))); // string rejected
// set_slices is guaranteed to have left the original single surface untouched on failure.
CHECK(region.slices.surfaces.size() == 1);
}
// Numpy-backed half: set_slices with real (N,2) int64 ndarrays replaces the region's
// surfaces, carries surface_type forward from the replaced surfaces, normalizes orientation
// (a CW contour becomes CCW), and the change is visible both from C++ and back through the view.
TEST_CASE("orca.slicing set_slices: ndarray input mutates the slice geometry (read back both ways)", "[slicing_pipeline]") {
using Catch::Matchers::WithinRel; using Catch::Matchers::WithinRel;
ensure_python_initialized(); ensure_python_initialized();
import_orca_module(); import_orca_module();
py::gil_scoped_acquire gil; 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 for (const char* name : { "LayerRegion", "Layer", "PrintObject", "Print" })
// interpreter; the unit-test interpreter ships none, so skip the array-backed CHECK(py::hasattr(host, name));
// assertions when numpy is unavailable (same convention as the read-view tests above). // 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<Slic3r::LayerRegion*>(&region),
py::return_value_policy::reference);
CHECK(lr.attr("slices").attr("size")().cast<size_t>() == 1);
CHECK(lr.attr("slices").attr("surfaces").cast<py::list>().size() == 1);
CHECK(lr.attr("perimeters").attr("flatten_paths")().cast<py::list>().size() == 2);
CHECK(lr.attr("fills").attr("size")().cast<size_t>() == 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<Slic3r::Layer*>(&layer),
py::return_value_policy::reference);
CHECK_THAT(ly.attr("print_z").cast<double>(), WithinRel(0.45, 1e-9));
CHECK_THAT(ly.attr("slice_z").cast<double>(), WithinRel(0.35, 1e-9));
CHECK_THAT(ly.attr("height").cast<double>(), WithinRel(0.2, 1e-9));
CHECK(ly.attr("regions")().cast<py::list>().size() == 0);
CHECK(ly.attr("lslices")().cast<py::list>().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<Slic3r::LayerRegion*>(&region),
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; bool have_numpy = false;
try { try { py::module_::import("numpy"); have_numpy = true; }
py::module_::import("numpy"); catch (const py::error_already_set&) { have_numpy = false; }
have_numpy = true; if (!have_numpy) SKIP("numpy unavailable in unit-test interpreter");
} 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::module_ np = py::module_::import("numpy");
py::object i64 = np.attr("int64"); py::object i64 = np.attr("int64");
const coord_t s = (coord_t) scale_(10.0); 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(&region, [](void*){});
py::object lrv = py::cast(Slic3r::LayerRegionView{ &region, owner });
// A CW square contour (points wound clockwise) -> the mutator must re-orient it CCW.
auto make_arr = [&](std::initializer_list<std::pair<coord_t,coord_t>> pts) { auto make_arr = [&](std::initializer_list<std::pair<coord_t,coord_t>> pts) {
py::list rows; py::list rows;
for (auto& p : pts) rows.append(py::make_tuple(p.first, p.second)); for (auto& p : pts) rows.append(py::make_tuple(p.first, p.second));
return np.attr("array")(rows, py::arg("dtype") = i64); 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<Slic3r::LayerRegion*>(&region),
py::return_value_policy::reference);
py::list polys; py::list polys;
polys.append(make_arr({ {0,0}, {0,s}, {s,s}, {s,0} })); // clockwise winding polys.append(make_arr({ {0,0}, {0,s}, {s,s}, {s,0} })); // clockwise winding
lrv.attr("set_slices")(polys); lr.attr("set_slices")(polys);
// C++ side reflects the replacement.
REQUIRE(region.slices.surfaces.size() == 1); REQUIRE(region.slices.surfaces.size() == 1);
const Slic3r::Surface& out = region.slices.surfaces.front(); const Slic3r::Surface& out = region.slices.surfaces.front();
CHECK(out.surface_type == Slic3r::stInternalSolid); // carried forward from the template CHECK(out.surface_type == Slic3r::stInternalSolid);
REQUIRE(out.expolygon.contour.points.size() == 4); CHECK(out.expolygon.contour.is_counter_clockwise());
CHECK(out.expolygon.contour.is_counter_clockwise()); // orientation normalized (input was CW) CHECK_THAT(out.expolygon.area(), WithinRel((double) s * (double) s, 1e-9));
CHECK_THAT(out.expolygon.area(), WithinRel((double) s * (double) s, 1e-9)); // s x s square py::list sl = lr.attr("slices").attr("surfaces").cast<py::list>();
// Read back through the view: slices()[0].expolygon.contour() is a (4,2) array.
py::list sl = lrv.attr("slices")().cast<py::list>();
REQUIRE(sl.size() == 1); REQUIRE(sl.size() == 1);
py::array c = sl[0].attr("expolygon").attr("contour")().cast<py::array>(); py::array c = sl[0].attr("expolygon").attr("contour").attr("points")().cast<py::array>();
CHECK(c.shape(0) == 4); CHECK(c.shape(0) == 4);
CHECK(c.shape(1) == 2);
// [contour, [holes...]] form: a hole is accepted and normalized to CW. // G9: per-entry SurfaceType override via [contour, holes, SurfaceType] triple.
TestLayerRegion region2; py::list entry;
py::capsule owner2(&region2, [](void*){}); entry.append(make_arr({ {0,0}, {s,0}, {s,s}, {0,s} }));
py::object lrv2 = py::cast(Slic3r::LayerRegionView{ &region2, owner2 }); entry.append(py::list());
py::list contour_and_holes; entry.append(host.attr("SurfaceType").attr("stTop"));
contour_and_holes.append(make_arr({ {0,0}, {s,0}, {s,s}, {0,s} })); // CCW contour py::list polys2; polys2.append(entry);
py::list holes; lr.attr("set_slices")(polys2, py::bool_(false)); // refresh_lslices=False path
holes.append(make_arr({ {s/4,s/4}, {s/2,s/4}, {s/2,s/2} })); // CCW hole -> must flip CW REQUIRE(region.slices.surfaces.size() == 1);
contour_and_holes.append(holes); CHECK(region.slices.surfaces.front().surface_type == Slic3r::stTop);
py::list polys2;
polys2.append(contour_and_holes);
lrv2.attr("set_slices")(polys2);
REQUIRE(region2.slices.surfaces.size() == 1); // Negative: a valid contour paired with a non-list holes slot must raise ValueError.
const Slic3r::ExPolygon& ex = region2.slices.surfaces.front().expolygon; // (Regression guard for a malformed holes slot; the retired view-layer suite covered
CHECK(ex.contour.is_counter_clockwise()); // this, and the raw layer needs a numpy-built valid contour to exercise the same path.)
REQUIRE(ex.holes.size() == 1); {
CHECK(ex.holes.front().is_clockwise()); // hole re-oriented CW
CHECK(region2.slices.surfaces.front().surface_type == Slic3r::stInternal); // default (no template)
// Fix 6: a malformed holes element (a [contour, holes] entry whose holes slot is not a
// sequence, e.g. an int) must raise ValueError, not a bare Python TypeError from iterating a
// non-iterable. This lives in the numpy-guarded section because reaching the holes check
// requires a real ndarray contour as the first element.
auto raises_value_error = [](py::object callable, py::object arg) {
try { callable(arg); return false; }
catch (py::error_already_set& e) { return e.matches(PyExc_ValueError); }
};
py::list bad_entry; py::list bad_entry;
bad_entry.append(make_arr({ {0,0}, {s,0}, {s,s}, {0,s} })); // valid CCW contour bad_entry.append(make_arr({ {0,0}, {s,0}, {s,s}, {0,s} })); // valid contour
bad_entry.append(py::int_(42)); // holes slot is an int -> invalid bad_entry.append(py::int_(42)); // holes slot is not a list
py::list bad_polys; py::list bad_polys; bad_polys.append(bad_entry);
bad_polys.append(bad_entry); bool raised = false;
CHECK(raises_value_error(lrv2.attr("set_slices"), bad_polys)); try { lr.attr("set_slices")(bad_polys); }
// The failed call left the previously-set single surface untouched. catch (py::error_already_set& e) { raised = e.matches(PyExc_ValueError); }
CHECK(region2.slices.surfaces.size() == 1); CHECK(raised);
}
// Layer.set_lslices round-trip on a hand-built layer (empty regions -> null-safe).
TestLayer layer;
py::object ly = py::cast(static_cast<Slic3r::Layer*>(&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<py::list>().size() == 1);
} }