Compare commits

..

13 Commits

Author SHA1 Message Date
SoftFever
4e9bf47741 Plugin: Slicing-pipeline support and expose new APIs and refactoring (#14721)
# Slicing-pipeline plugins: Python hooks inside `Print::process()` with
an editable geometry API

This branch adds a new **slicing-pipeline** plugin capability on top of
the plugin framework: Python plugins can now run at defined points
inside the slicing pipeline and edit the live slicing data, with changes
cascading into perimeters, infill, and the final G-code.

## The capability

- New plugin type `slicing-pipeline`, selectable per print profile via a
new picker option (`slicing_pipeline_plugin`).
- `Print::process()` fires a hook at 13 pipeline steps (`posSlice`,
`posPerimeters`, … `psSkirtBrim`, `psGCodePostProcess`). Selected
plugins run per step with cancellation honored and failures surfaced as
ordinary slicing errors, never crashes.
- G-code post-processing is folded into this capability as the final
step (`psGCodePostProcess`); the separate "post-processing" plugin type
and its option are removed. Breaking only for the unreleased plugin API
— migrated plugins gain settings and config access in return.

## The Python API (`orca.host`)

- The live print graph is exposed as raw host classes — `Print`,
`PrintObject`, `Layer`, `LayerRegion`, `SurfaceCollection`, `Surface`,
`ExPolygon`, `Polygon`, `Point`, plus `Model`/`TriangleMesh` with
zero-copy numpy views.
- Geometry is genuinely editable through the class API: in-place
transforms, whole-surface replacement, and vertex-level rebuilds, with
`layer.make_slices()` re-deriving the C++ invariants after edits. Write
paths validate input, so malformed data raises in Python instead of
corrupting the slice.
- Bindings are organized under `src/slic3r/plugin/host/` by domain.

## Architecture

- All libslic3r hook seams (capability resolver, pipeline dispatcher)
are installed and uninstalled by one composition root,
`plugin/PluginHooks`, from `PluginManager::initialize()`/`shutdown()`.
GUI_App no longer accumulates per-capability wiring, and hooks detach
before the Python interpreter finalizes.

## Samples (`sandboxes/`) — one per editing idiom

| Sample | Step | Demonstrates |
| --- | --- | --- |
| **Inset Every Slice** | `posSlice` | Shrink every slice via polygon
offset + whole-surface replacement (`slices.set`) |
| **Twistify** | `posSlice` | Twist/taper/wobble via count-preserving
in-place transforms |
| **Fuzzy Slices** | `posSlice` | Fuzzy skin applied to the slice
contours themselves (vertex-level rebuild) — walls, infill, and the
preview all inherit it |
| **G-code Stamp** | `psGCodePostProcess` | Editing the exported G-code
file in place |

# Screenshots/Recordings/Graphs
1. Fuzzy skin example:
Orca's built-in fuzzy skin perturbs the outer-wall EXTRUSION PATHS
during
perimeter generation, so only the printed wall is fuzzy. This sample
instead
perturbs the sliced outline itself at sliced geometry:
<img width="1230" height="902" alt="image"
src="https://github.com/user-attachments/assets/bcf8b0c7-f932-4e6a-985d-7c9cc2f3d7cb"
/>

2. Twistify -- twist/taper/wobble any model at slice time
every layer's sliced surfaces are transformed by a similarity
about the object's bounding-box center as a function of Z


https://github.com/user-attachments/assets/d1309ea8-b01c-4708-adf1-821b3b00a4cc

3. Inset Every Slice -- a small, WORKING SlicingPipeline sample plugin
For every layer/region of the sliced object, this shrinks each
sliced surface by INSET_MM using a real polygon offset
<img width="1225" height="896" alt="image"
src="https://github.com/user-attachments/assets/5f2028a9-ae2a-4aea-8b38-a3d6e24f5cb4"
/>
2026-07-12 01:41:12 +08:00
SoftFever
579e58c528 Add Fuzzy Slices sample (fuzzy skin at posSlice) + coverage test
Experimental fuzzy on geometry
Mirrors libslic3r's fuzzy_polyline on the slice contours at Step.posSlice,
demonstrating the count-changing mutation idiom (rebuild ring via
Polygon.append, write back via ex.contour / ex.set_holes). C++ analogue
test proves area preservation, cascade, and bounded displacement.
2026-07-12 01:19:54 +08:00
SoftFever
03094df10e refactor(plugin): move libslic3r hook wiring out of GUI_App into PluginHooks
GUI_App::on_init_inner() carried the plugin dispatch policy inline (the
capability resolver and the slicing-pipeline dispatcher) and would grow
with every capability that fires from inside libslic3r.

plugin/PluginHooks.{hpp,cpp} now owns one file-local installer per hook,
aggregated by plugin_hooks::install() -- called from
PluginManager::initialize(), reset in shutdown() so no hook can enter
Python after the interpreter finalizes. The wx-side loader subscriptions
move into GUI_App::init_plugin_gui_wiring().

No behavior change; dispatch bodies moved verbatim.
2026-07-12 00:20:39 +08:00
SoftFever
c17d9732be refactor(plugin): bind raw TriangleMesh instead of HostTriangleMesh wrapper
Bind libslic3r's TriangleMesh directly with a shared_ptr holder rather than
wrapping it in a HostTriangleMesh snapshot struct. ModelVolume.mesh() hands
out the volume's own shared_ptr (via const_pointer_cast, which only serves
the holder type), so the Python object pins the snapshot exactly as the
wrapper did, and the zero-copy views now use the Python object as their
array base — deleting the capsule machinery.

The wrapper's type-level constness becomes a documented rule instead:
handed-out meshes are copy-on-write snapshots shared across threads, so the
binding exposes only const methods; a future mutable-mesh API must operate
on plugin-owned copies handed back via ModelVolume::set_mesh.

No Python-visible change (orca.host.TriangleMesh, same methods/docstrings),
and plugins now hold the real class a future set_mesh() will accept.
Verified with slic3rutils and fff_print suites.
2026-07-11 18:54:07 +08:00
SoftFever
126e4d5445 refactor(plugin): split orca.host bindings into host/ by domain
PluginHostApi.cpp had grown into one TU holding the module entry point plus
three unrelated domains (presets, model/mesh graph, app access), and
PluginHostSlicing.cpp mixed ownable geometry value types with the
non-owning live print graph. Reorganize the orca.host surface into
plugin/host/ with one registrar per domain:

- PluginHost.hpp/.cpp        entry point (replaces PluginHostApi)
- PluginHostBindings.hpp     internal per-domain registrar declarations
- PluginHostGeometry.cpp     BoundingBox, Point, Polygon, ExPolygon + ndarray parsing
- PluginHostMesh.hpp/.cpp    TriangleMesh snapshot (own TU ahead of planned
                             mesh construct/mutate APIs)
- PluginHostPresets.cpp      Preset, PresetCollection, PresetBundle
- PluginHostModel.cpp        scene graph: Model, ModelObject, ModelInstance, ModelVolume
- PluginHostApp.cpp          Plater + plater()/model()/preset_bundle() accessors
- PluginHostSlicing.cpp      live print graph only, now with a single lifetime story
- PluginHostUi.hpp/.cpp      moved unchanged

PluginBindingUtils.hpp stays at plugin/ root: it is shared with pluginTypes/
and tests, not host/-specific.

No Python-visible change: same submodules, class names and docstrings.
Verified with slic3rutils and fff_print suites.
2026-07-11 16:18:59 +08:00
SoftFever
a04ce5f81e docs(plugin): make comments self-contained; drop design-doc and dead-code references
Review the slicing-pipeline plugin comments for context a reader of the source
alone cannot follow, and rewrite them to stand on their own:

- drop pointers to uncommitted design/plan material ("§3.6 (Twistify design)",
  "the brief's note", "Fix 4(a)/4(b)")
- fix dangling references to code this branch removed: the retired set_slices()
  and view mutators, the former G-code post-processing capability/trampoline,
  the "Post-processing" capability family, the pre-refactor array helper
- drop "v1"/"in v1" phase labels, keeping the behavior they described
- correct stale cross-references: Twistify.py -> the real sample path;
  test_plugin_host_api.cpp:32-40 -> import_orca_module in python_test_support.hpp;
  "the binding"/"graphs above" -> the named source

Comment/string-only; no code behavior change.
2026-07-11 03:30:05 +08:00
SoftFever
19352215da feat(plugin)!: merge G-code post-processing into the slicing pipeline as psGCodePostProcess
G-code post-processing is now a step of the slicing-pipeline plugin rather than a
separate capability type. One capability class can transform slices at the geometry
seams AND edit the final G-code, behind a single picker/option.

- Add SlicingPipelineStepPlugin::psGCodePostProcess (bound as
  orca.slicing.Step.psGCodePostProcess). Unlike the geometry steps it fires from the
  GUI export path in PostProcessor.cpp, not from Print::process(): ctx.print/ctx.object
  are None and the plugin edits the file at ctx.gcode_path in place. It may run more
  than once per slice (file export and/or upload) and its output is not shown in the
  preview.
- Extend SlicingPipelineContext with gcode_path/host/output_name and a C++-only
  full_config; config_value() falls back to it when there is no live Print.
- PostProcessor.cpp dispatches SlicingPipelinePluginCapability at psGCodePostProcess,
  driven by the existing slicing_pipeline_plugin option.
- The exported G-code lives outside data_dir(), so the plugin audit sandbox would
  block the write; the trampoline's audit setup grants ctx.gcode_path's folder as a
  scoped allowed root, gated on a non-empty gcode_path so the geometry-step hooks gain
  no extra filesystem access.

BREAKING CHANGE: the separate G-code post-processing capability type is removed.
- orca.gcode.GCodePluginCapabilityBase and orca.PluginType.PostProcessing are gone;
  post-processing plugins migrate to orca.slicing.SlicingPipelineCapabilityBase +
  Step.psGCodePostProcess (and gain ctx.params / ctx.config_value()).
- The post_process_plugin config option is removed; use slicing_pipeline_plugin.
  Presets carrying the old key degrade to the standard unknown-key warning.
- Manifest type = "post-processing" now maps to Unknown (advisory only; the loader
  dispatches on the C++ get_type()).

Also repairs two latent build breaks the branch carried: stale Step enum value usages
in test_slicing_pipeline_hook.cpp and a reference to the removed
ConfigOptionDef::PluginType::None in Tab::on_value_change (now is_plugin_backed()).
Adds the orca_gcode_stamp sample plugin and a psGCodePostProcess binding test.
2026-07-10 19:57:35 +08:00
SoftFever
21ed68963f refactor(plugin): prefix SlicingPipelineStepPlugin values with pos/ps to mirror Print steps 2026-07-10 17:28:26 +08:00
SoftFever
11dd078f64 feat(plugin)!: faithful mutable geometry bindings; edit slices via the class API
Replaces the plugin-only set_slices/set_fill_surfaces/set_lslices mutators with a
faithful, mutable binding of the core geometry types, so a plugin edits the slicing
graph through the same object model the C++ code uses.

- Point, Polygon, ExPolygon, Surface and SurfaceCollection gain constructors,
  writable accessors (contour/holes, set/append/clear, filter_by_type), transforms
  (rotate/scale/translate), boolean ops and offset. Polygon exposes a zero-copy
  writable numpy view via a make_writable_rows helper.
- LayerRegion.slices/fill_surfaces stay read-only refs but are now live,
  in-place-editable SurfaceCollections; Layer.make_slices() re-derives the islands
  and refreshes lslice bounding boxes.
- Rewrites the Inset and Twistify samples on the new API (in-place ExPolygon
  transforms, ExPolygon.offset, SurfaceCollection.set), dropping their numpy
  dependency; each touched layer calls make_slices() so downstream steps see the
  edited footprint. Adds tests covering in-place edits through a live collection.

BREAKING CHANGE: set_slices/set_fill_surfaces/set_lslices and the internal
parse_expolygon(_list)/surfaces_from_py helpers are removed. Plugins mutate through
the class API (SurfaceCollection.set/append/clear, Polygon.set_points/append,
ExPolygon.set_holes) instead.
2026-07-08 15:04:40 +08:00
SoftFever
fd2a489980 Merge branch 'feat/plugin-feature' into feature/plugin-slicing
Brings in the Plugins dialog as-you-type search with fuzzy match highlighting
and clickable column-header sorting (name, version, source, status) — PRs
#14610 and #14611.
2026-07-08 01:03:34 +08:00
SoftFever
f81a24abfb 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.
2026-07-08 00:05:28 +08:00
SoftFever
aafcccc83c Merge branch 'feat/plugin-feature' into feature/plugin-slicing 2026-07-06 01:12:05 +08:00
SoftFever
b0bacdd00b feat(plugin): add the slicing-pipeline plugin capability
Introduces a plugin capability that runs Python at the seams of Print::process(),
letting a plugin read and rewrite slicing state as it is computed.

- New slicing_pipeline_plugin config option; selected plugin refs are serialized
  into the print manifest.
- Print gains an injectable hook fired at each pipeline step (posSlice,
  posPerimeters, posInfill, ...). It is a no-op when unset, fires only on genuine
  (re)computation, and never on the use-cache path.
- orca.slicing submodule: SlicingPipelineCapabilityBase plus a trampoline and a
  Step enum. Capabilities read the live graph through zero-copy int64 numpy views
  (contour/holes geometry with unscaled coordinates, flattened toolpath data) and
  edit it through 2D-geometry mutators with cache-invariant refresh.
- GUI dispatcher runs capabilities during slicing under the GIL, turns plugin
  errors into slicing errors, honors cancellation, and adds the plugin picker.
- Ships the InsetEverySlice sample plugin and binding/hook tests.
2026-07-04 04:33:20 +08:00
67 changed files with 3831 additions and 1290 deletions

View File

@@ -0,0 +1,176 @@
# /// script
# requires-python = ">=3.12"
#
# [tool.orcaslicer.plugin]
# name = "Fuzzy Slices"
# description = "Applies the fuzzy-skin jitter to the slice contours themselves at the Slice boundary (demo)."
# author = "OrcaSlicer"
# version = "0.01"
# type = "slicing-pipeline"
#
# [tool.orcaslicer.plugin.settings]
# thickness_mm = "0.3"
# point_distance_mm = "0.8"
# fuzz_holes = "1"
# skip_first_layer = "1"
# ///
"""Fuzzy Slices -- the fuzzy-skin effect applied at slice time.
Orca's built-in fuzzy skin perturbs the outer-wall EXTRUSION PATHS during
perimeter generation, so only the printed wall is fuzzy. This sample instead
perturbs the sliced outline itself at Step.posSlice, using the same
resample-and-jitter algorithm as libslic3r's fuzzy_polyline (uniform noise):
walk each ring, drop a new vertex every 3/4..5/4 * point_distance_mm of
perimeter, and displace it by a random +/- thickness_mm along the segment
normal. Because the slice contour itself changes, everything derived from it
(perimeters, infill boundaries, overhang detection) inherits the noise and
the fuzz shows in the toolpath preview.
Mechanically this demonstrates the count-CHANGING mutation idiom: a fuzzed
ring has a different vertex count, so it is rebuilt as a fresh
orca.host.Polygon (append() per vertex) and written back by assigning
ex.contour / calling ex.set_holes() on the live ExPolygon. The in-place edit
persists through the surface collection and leaves surface types untouched;
layer.make_slices() then re-derives the merged islands. Compare the Inset
sample (whole-surface offset + slices.set) and Twistify (count-preserving
in-place transforms).
The jitter preserves vertex order, so the contour keeps its CCW winding
(contour assignment does not re-normalize); set_holes() re-normalizes holes
to CW. The RNG is seeded per layer, so re-slicing reproduces the same fuzz.
The first layer is skipped by default for bed adhesion (like the built-in
fuzzy_skin_first_layer = off). No numpy required; for very dense models the
Polygon.as_array()/set_points numpy path would be the faster route.
"""
import math
import random
import orca
_DEFAULTS = {
"thickness_mm": 0.3, # max normal displacement (built-in fuzzy_skin_thickness default)
"point_distance_mm": 0.8, # target resample spacing (built-in fuzzy_skin_point_dist default)
"fuzz_holes": 1.0, # nonzero: jitter hole rings too, not just the outer contour
"skip_first_layer": 1.0, # nonzero: keep layer 0 crisp for bed adhesion
}
def _params(ctx):
try:
src = dict(ctx.params)
except (AttributeError, TypeError):
src = {}
out = {}
for key, default in _DEFAULTS.items():
try:
out[key] = float(src[key])
except (KeyError, TypeError, ValueError):
out[key] = default
return out
def _fuzz_ring(points, thickness, min_dist, rand_range, rng):
"""Resample + jitter one closed ring (list of Point refs).
Returns a new orca.host.Polygon, or None to keep the original ring (too
small to resample). Mirrors libslic3r's fuzzy_polyline: new vertices every
min_dist + rand*rand_range of arc length, each displaced +/-thickness
along the segment's left-hand normal.
"""
if len(points) < 3:
return None
out = []
dist_left_over = rng.random() * (min_dist / 2.0) # arc length before the first new vertex
p0x = float(points[-1].x)
p0y = float(points[-1].y)
for p1 in points:
p1x = float(p1.x)
p1y = float(p1.y)
dx = p1x - p0x
dy = p1y - p0y
seg = math.hypot(dx, dy)
if seg > 0.0:
d = dist_left_over
while d < seg:
t = d / seg
r = (rng.random() * 2.0 - 1.0) * thickness
out.append((p0x + dx * t - dy / seg * r,
p0y + dy * t + dx / seg * r))
d += min_dist + rng.random() * rand_range
dist_left_over = d - seg # carry the remainder into the next segment
p0x, p0y = p1x, p1y
if len(out) < 3:
return None # ring shorter than ~2 resample steps: leave it crisp
poly = orca.host.Polygon()
for x, y in out:
poly.append(orca.host.Point(int(round(x)), int(round(y))))
return poly
class FuzzySlices(orca.slicing.SlicingPipelineCapabilityBase):
def get_name(self):
return "Fuzzy Slices"
def execute(self, ctx):
if ctx.step != orca.slicing.Step.posSlice or ctx.object is None:
return orca.ExecutionResult.success()
p = _params(ctx)
if p["thickness_mm"] <= 0.0 or p["point_distance_mm"] <= 0.0:
return orca.ExecutionResult.success("Fuzzy Slices: zero thickness/point distance, nothing to do")
# Millimeters -> scaled integer units via the *live* scale (never hardcode 1e6).
mm = 1.0 / orca.slicing.unscale(1)
thickness = p["thickness_mm"] * mm
# The spacing between new vertices varies between 3/4 and 5/4 the supplied
# value, same as the built-in fuzzy skin.
min_dist = p["point_distance_mm"] * mm * 0.75
rand_range = p["point_distance_mm"] * mm * 0.5
fuzz_holes = p["fuzz_holes"] != 0.0
first = 1 if p["skip_first_layer"] != 0.0 else 0
rings = 0
layers_touched = 0
for idx, layer in enumerate(ctx.object.layers()):
if ctx.cancelled():
break
if idx < first:
continue
rng = random.Random(0x5EED + idx) # per-layer seed: re-slices reproduce the same fuzz
edited = False
for region in layer.regions():
for surface in region.slices.surfaces:
ex = surface.expolygon
contour = _fuzz_ring(ex.contour.points, thickness, min_dist, rand_range, rng)
if contour is not None:
ex.contour = contour # vertex order preserved, so CCW winding survives
rings += 1
edited = True
if fuzz_holes and ex.holes:
new_holes = []
changed = False
for hole in ex.holes:
fuzzed = _fuzz_ring(hole.points, thickness, min_dist, rand_range, rng)
if fuzzed is not None:
new_holes.append(fuzzed)
changed = True
rings += 1
else:
new_holes.append(hole) # untouched rings pass through unchanged
if changed:
ex.set_holes(new_holes) # copies each ring and re-normalizes to CW
edited = True
if edited:
# Re-derive the merged islands from the fuzzed region slices.
layer.make_slices()
layers_touched += 1
return orca.ExecutionResult.success(
f"Fuzzy Slices: fuzzed {rings} ring(s) on {layers_touched} layer(s) "
f"(+/-{p['thickness_mm']} mm @ {p['point_distance_mm']} mm)")
@orca.plugin
class FuzzySlicesPackage(orca.base):
def register_capabilities(self):
orca.register_capability(FuzzySlices)

View File

@@ -0,0 +1,74 @@
# /// script
# requires-python = ">=3.12"
#
# [tool.orcaslicer.plugin]
# name = "G-code Stamp"
# description = "Stamps a comment line into the exported G-code at the post-process step (demo)."
# author = "OrcaSlicer"
# version = "0.01"
# type = "slicing-pipeline"
#
# [tool.orcaslicer.plugin.settings]
# stamp_text = "processed by the OrcaSlicer G-code Stamp plugin"
# ///
"""G-code Stamp -- the post-processing half of the slicing-pipeline plugin.
Post-processing is now a step of the slicing pipeline: Step.psGCodePostProcess.
It fires from the G-code export path AFTER the classic post_process scripts, on the
exported G-code file -- NOT from Print::process(). So unlike the geometry steps
(posSlice, posPerimeters, ...) there is no live slicing graph here: ctx.print and
ctx.object are None. Instead the context carries ctx.gcode_path (the working G-code
file on disk, edited IN PLACE), ctx.host ("File", "OctoPrint", ...) and
ctx.output_name (the final file name). ctx.params and ctx.config_value() still work.
This sample inserts a single comment line near the top of the file. Because the same
capability class can also implement the geometry steps, one plugin can transform slices
AND stamp the final G-code; a geometry-only plugin just returns success here.
The step may fire more than once per slice (file export and/or upload each run it on a
separate working copy), and its output is not reflected in the G-code preview -- the
viewer maps the pre-post-process file.
"""
import orca
_DEFAULT_STAMP = "processed by the OrcaSlicer G-code Stamp plugin"
def _stamp_text(ctx):
try:
text = dict(ctx.params).get("stamp_text", _DEFAULT_STAMP)
except (AttributeError, TypeError):
text = _DEFAULT_STAMP
return str(text).replace("\n", " ").strip() or _DEFAULT_STAMP
class GCodeStamp(orca.slicing.SlicingPipelineCapabilityBase):
def get_name(self):
return "G-code Stamp"
def execute(self, ctx):
# Only act at the post-process seam; at every geometry step this is a no-op.
if ctx.step != orca.slicing.Step.psGCodePostProcess:
return orca.ExecutionResult.success()
if not ctx.gcode_path:
return orca.ExecutionResult.success("G-code Stamp: no gcode_path, nothing to do")
comment = "; " + _stamp_text(ctx) + " (host=" + (ctx.host or "?") + ")\n"
# Edit the exported G-code in place: keep the original first line first (some flavors
# expect a specific leading line), then insert the stamp right after it.
with open(ctx.gcode_path, "r", encoding="utf-8", errors="replace") as f:
lines = f.readlines()
insert_at = 1 if lines else 0
lines.insert(insert_at, comment)
with open(ctx.gcode_path, "w", encoding="utf-8") as f:
f.writelines(lines)
return orca.ExecutionResult.success(
"G-code Stamp: stamped '" + (ctx.output_name or ctx.gcode_path) + "'")
@orca.plugin
class GCodeStampPackage(orca.base):
def register_capabilities(self):
orca.register_capability(GCodeStamp)

View File

@@ -0,0 +1,83 @@
# /// script
# requires-python = ">=3.12"
#
# [tool.orcaslicer.plugin]
# name = "Inset Every Slice"
# description = "Insets every layer's slices by 1mm at the Slice boundary (demo)."
# author = "OrcaSlicer"
# version = "0.02"
# type = "slicing-pipeline"
# ///
"""Inset Every Slice -- a small, WORKING SlicingPipeline sample plugin.
At Step.posSlice, for every layer/region of the sliced object, this shrinks each
sliced surface by INSET_MM using a real polygon offset (ExPolygon.offset) and
writes the result back with SurfaceCollection.set(). After the per-region edits,
layer.make_slices() re-derives the layer's merged islands (lslices) so
overhang/bridge detection, skirt/brim and support stay coherent with the inset
geometry. At Step.posSlice the split slice loop runs make_perimeters() right after
the hook, so the change cascades into perimeters, infill and the final G-code
-- the toolpath preview shrinks.
ExPolygon.offset() is a correct inward offset for any contour (it is Clipper
under the hood), and it naturally handles holes.
A surface may split into several islands or vanish when shrunk; both are handled.
No numpy required: the whole edit is expressed with the host geometry classes.
"""
import orca
INSET_MM = 1.0
class InsetEverySlice(orca.slicing.SlicingPipelineCapabilityBase):
def get_name(self):
return "Inset Every Slice"
def execute(self, ctx):
if ctx.step != orca.slicing.Step.posSlice or ctx.object is None:
return orca.ExecutionResult.success()
# Millimeters -> scaled integer units via the *live* scale (never hardcode 1e6).
inset_scaled = int(round(INSET_MM / orca.slicing.unscale(1)))
regions_touched = 0
for layer in ctx.object.layers():
if ctx.cancelled():
break
layer_touched = False
for region in layer.regions():
surfaces = region.slices.surfaces
if not surfaces:
continue
# Group the inward-offset geometry by surface type so each type is
# preserved when written back (set() tags all its expolygons one type).
by_type = {}
for surface in surfaces:
shrunk = surface.expolygon.offset(-inset_scaled) # [ExPolygon], may be empty
if shrunk:
by_type.setdefault(surface.surface_type, []).extend(shrunk)
if not by_type:
continue # every surface collapsed: leave the region untouched this demo
# Rebuild the collection type-by-type: first set(), then append() the rest.
items = list(by_type.items())
first_type, first_expolys = items[0]
region.slices.set(first_expolys, first_type)
for st, expolys in items[1:]:
region.slices.append(expolys, st)
regions_touched += 1
layer_touched = True
if layer_touched:
# Re-derive the merged islands from the inset region slices.
layer.make_slices()
return orca.ExecutionResult.success(f"inset applied to {regions_touched} region(s)")
@orca.plugin
class InsetEverySlicePackage(orca.base):
def register_capabilities(self):
orca.register_capability(InsetEverySlice)

View File

@@ -0,0 +1,146 @@
# /// script
# requires-python = ">=3.12"
#
# [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.02"
# 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.posSlice, every layer's sliced surfaces are transformed by a similarity
about the object's bounding-box center as a function of Z -- edited IN PLACE
through the host geometry classes (ExPolygon.rotate/scale/translate). Each
surface is rotated about the center, then (if tapering) translated to the
origin, uniformly scaled, and translated back, so the taper stays centered on
the object instead of drifting toward the coordinate origin. An optional X
wobble is applied last. After the per-region edits, layer.make_slices()
re-derives the layer's merged islands so overhang/bridge/skirt/support stay
coherent. The split slice loop runs make_perimeters() right after the hook, so
the transform cascades into perimeters, infill, and the final G-code -- the
preview corkscrews and the print keeps correct walls/infill/flow.
Because we edit geometry in place, surface types are preserved automatically
(no per-surface type carry needed), and no numpy is required --
rotate/scale/translate are host methods. Parameters come from ctx.params (the
settings table above). The first object layer is untouched (z_rel = 0), so bed
adhesion is unaffected.
"""
import math
import orca
_DEFAULTS = {
"twist_deg_per_mm": 1.0,
"taper_per_mm": 0.0,
"wobble_ampl_mm": 0.0,
"wobble_period_mm": 20.0,
"min_scale": 0.05,
}
def _params(ctx):
try:
src = dict(ctx.params)
except (AttributeError, TypeError):
src = {}
out = {}
for key, default in _DEFAULTS.items():
try:
out[key] = float(src[key])
except (KeyError, TypeError, ValueError):
out[key] = default
return out
def _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):
"""(angle_rad, scale, x_offset_scaled) for one layer. Exact identity at z_rel == 0."""
theta = math.radians(p["twist_deg_per_mm"] * z_rel)
s = max(p["min_scale"], 1.0 + p["taper_per_mm"] * z_rel)
ox = 0.0
if p["wobble_ampl_mm"] != 0.0 and p["wobble_period_mm"] > 0.0:
ox = p["wobble_ampl_mm"] * math.sin(2.0 * math.pi * z_rel / p["wobble_period_mm"]) * mm_to_scaled
return theta, s, ox
class Twistify(orca.slicing.SlicingPipelineCapabilityBase):
def get_name(self):
return "Twistify"
def execute(self, ctx):
if ctx.step != orca.slicing.Step.posSlice or ctx.object is None:
return orca.ExecutionResult.success()
p = _params(ctx)
if _is_identity(p):
return orca.ExecutionResult.success("Twistify: identity parameters, nothing to do")
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/taper axis = the object's bounding-box center (scaled coords, same frame
# as the slice polygons), so each object on the plate transforms about its own
# center. Keep the float center for translate-to-origin/back around scale(), and
# a rounded-to-Point center for rotate() (which takes an integer Point).
min_x, min_y, max_x, max_y = ctx.object.bounding_box()
cx = (min_x + max_x) / 2.0
cy = (min_y + max_y) / 2.0
center = orca.host.Point(int(round(cx)), int(round(cy)))
z0 = float(layers[0].print_z) # z_rel = 0 on the first layer -> footprint untouched
layers_touched = 0
for layer in layers:
if ctx.cancelled():
break
z_rel = float(layer.print_z) - z0
theta, s, ox = _layer_params(z_rel, mm_to_scaled, p)
if theta == 0.0 and s == 1.0 and ox == 0.0:
continue # exact identity (always the first layer)
edited = False
for region in layer.regions():
for surface in region.slices.surfaces:
ex = surface.expolygon
ex.rotate(theta, center) # rotate about the object center (in place)
if s != 1.0:
# scale() scales about the coordinate ORIGIN, so re-center the
# geometry on the origin first and translate back after, making
# this a true similarity transform about the object's center.
ex.translate(-cx, -cy)
ex.scale(s)
ex.translate(cx, cy)
if ox != 0.0:
ex.translate(ox, 0.0) # wobble in X
edited = True
if edited:
# Re-derive the merged islands from the twisted region slices.
layer.make_slices()
layers_touched += 1
name = ctx.object.model_object().name or "object"
return orca.ExecutionResult.success(
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)

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_FROM] = from;
std::vector<std::string> plugin_refs;
//record all the key-values
for (const std::string &opt_key : this->keys())
{
@@ -1548,24 +1546,14 @@ void ConfigBase::save_to_json(const std::string &file, const std::string &name,
json j_array(string_values);
j[opt_key] = j_array;
}
this->save_plugin_collection(opt_key, opt, plugin_refs);
}
// Lazily serialize the top-level "plugins" manifest: the individual plugin-backed options keep
// bare capability names, and the full "name;uuid;capability" references are derived here from
// those options via the registered resolver. Only do this when a resolver is available (GUI);
// without one (CLI/headless) leave whatever the "plugins" option already serialized above, so a
// round-trip never drops the manifest. De-duplicate while preserving order and skip empties.
// Serialize the top-level "plugins" manifest: the individual plugin-backed options keep bare
// capability names; the full "name;uuid;capability" references are derived here (same helper as
// update_plugin_manifest). Only with a resolver (GUI); without one (CLI/headless) leave whatever
// the "plugins" option already serialized above, so a round-trip never drops the manifest.
if (resolve_capability_fn) {
std::vector<std::string> unique_refs;
unique_refs.reserve(plugin_refs.size());
for (std::string& ref : plugin_refs) {
if (ref.empty())
continue;
if (std::find(unique_refs.begin(), unique_refs.end(), ref) == unique_refs.end())
unique_refs.emplace_back(std::move(ref));
}
std::vector<std::string> unique_refs = this->collect_plugin_manifest();
if (unique_refs.empty())
j.erase("plugins");
else
@@ -1610,24 +1598,60 @@ void ConfigBase::save_plugin_collection(const std::string& opt_key, const Config
if (!resolve_capability_fn)
return;
// Resolve a single bare capability value into its full reference and append it, skipping
// unset values and capabilities that could not be resolved (resolver returns "").
const auto append_ref = [&plugin_refs](const std::string& capability_value, const std::string& type) {
// A plugin-backed option declares its capability type via ConfigOptionDef::plugin_type (the same
// metadata PluginResolver::find_option_for_capability scans). Deriving off the def rather than a
// per-key branch keeps this generic across every plugin-backed option.
const ConfigDef* def = this->def();
const ConfigOptionDef* opt_def = def ? def->get(opt_key) : nullptr;
if (opt_def == nullptr || !opt_def->is_plugin_backed())
return;
const std::string& type = opt_def->plugin_type;
// Resolve a single bare capability value into its full reference and append it, skipping unset
// values, capabilities that could not be resolved (resolver returns ""), and duplicates already
// collected (preserving insertion order).
const auto append_ref = [&plugin_refs, &type](const std::string& capability_value) {
if (capability_value.empty())
return;
std::string ref = resolve_capability_fn(capability_value, type);
if (!ref.empty())
if (!ref.empty() && std::find(plugin_refs.begin(), plugin_refs.end(), ref) == plugin_refs.end())
plugin_refs.emplace_back(std::move(ref));
};
if (opt_key == "post_process_plugin") {
const ConfigOptionVectorBase* vec = static_cast<const ConfigOptionVectorBase*>(opt);
for (const std::string& val : vec->vserialize())
append_ref(val, "post-processing");
} else if (opt_key == "printer_agent") {
append_ref((dynamic_cast<const ConfigOptionString *>(opt))->value, "printer-connection");
}
// Extend for other plugin-backed settings as needed.
// Scalar options carry a single capability name; vector options carry a list. Same scalar/vector
// dispatch as PluginResolver::find_option_for_capability.
if (const auto* string_option = dynamic_cast<const ConfigOptionString*>(opt))
append_ref(string_option->value);
else if (const auto* vector_option = dynamic_cast<const ConfigOptionVectorBase*>(opt))
for (const std::string& val : vector_option->vserialize())
append_ref(val);
}
std::vector<std::string> ConfigBase::collect_plugin_manifest() const
{
std::vector<std::string> refs;
if (!resolve_capability_fn)
return refs;
// Each plugin-backed option (ConfigOptionDef::is_plugin_backed) contributes its resolved
// reference(s) via save_plugin_collection, which appends in order and skips duplicates, so no
// second de-duplication pass is needed here.
for (const std::string& opt_key : this->keys())
if (const ConfigOption* opt = this->option(opt_key))
this->save_plugin_collection(opt_key, opt, refs);
return refs;
}
void ConfigBase::update_plugin_manifest()
{
// Writes the derived manifest back into this config's "plugins" option (save_to_json writes the
// same manifest into a JSON document instead), so an in-memory backend config carries a resolved
// manifest even when the source preset was never serialized (picked-but-unsaved). Without a
// resolver (CLI/headless) leave whatever manifest was loaded from disk untouched.
if (!resolve_capability_fn)
return;
if (auto* manifest = this->option<ConfigOptionStrings>("plugins", true))
manifest->values = this->collect_plugin_manifest();
}
DynamicConfig::DynamicConfig(const ConfigBase& rhs, const t_config_option_keys& keys)

View File

@@ -2444,10 +2444,13 @@ public:
// "serialized" - vector valued option is entered in a single edit field. Values are separated by a semicolon.
// "show_value" - even if enum_values / enum_labels are set, still display the value, not the enum label.
std::string gui_flags;
// Optional plugin type used by GUIType::plugin_picker for filtering plugins.
// Capability type of a plugin-backed option, e.g. "slicing-pipeline" / "printer-connection"
// (empty for ordinary options). GUIType::plugin_picker filters the plugin list by it, and it
// resolves the option's "plugins" manifest reference; see is_plugin_backed().
std::string plugin_type;
// Indicate whether the option support plugin.
bool support_plugin { false };
// Whether this option holds plugin capability name(s) that feed the "plugins" manifest -- true
// iff it declares a plugin_type. Setting plugin_type is the only step needed to add one.
bool is_plugin_backed() const { return !plugin_type.empty(); }
// Label of the GUI input field.
// In case the GUI input fields are grouped in some views, the label defines a short label of a grouped value,
// while full_label contains a label of a stand-alone field.
@@ -2761,6 +2764,13 @@ public:
//BBS: add json support
void save_to_json(const std::string &file, const std::string &name, const std::string &from, const std::string &version) const;
// Rebuild the in-memory "plugins" manifest (the "name;uuid;capability" references the plugin
// dispatchers consume) from the plugin-backed options via the registered resolver. save_to_json()
// derives the same manifest, but only when a preset is written to disk; a config assembled in
// memory for the backend (PresetBundle::full_config -> Print::apply) must refresh it here or a
// picked-but-unsaved plugin never resolves at slice/export time. No-op without a resolver.
void update_plugin_manifest();
// Set all the nullable values to nils.
void null_nullables();
@@ -2770,6 +2780,11 @@ private:
// Set a configuration value from a string.
bool set_deserialize_raw(const t_config_option_key& opt_key_src, const std::string& value, ConfigSubstitutionContext& substitutions, bool append);
void save_plugin_collection(const std::string& opt_key, const ConfigOption* opt, std::vector<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;
};

View File

@@ -1192,7 +1192,7 @@ static std::vector<std::string> s_Preset_print_options{
"min_feature_size",
"min_bead_width",
"post_process",
"post_process_plugin",
"slicing_pipeline_plugin",
"plugins",
"process_change_extrusion_role_gcode",
"min_length_factor",

View File

@@ -50,6 +50,8 @@ using namespace nlohmann;
namespace Slic3r {
Print::SlicingPipelineHookFn Print::s_slicing_pipeline_hook_fn = nullptr;
template class PrintState<PrintStep, psCount>;
template class PrintState<PrintObjectStep, posCount>;
@@ -123,8 +125,8 @@ bool Print::invalidate_state_by_config_options(const ConfigOptionResolver & /* n
"printing_by_object_gcode",
"filament_end_gcode",
"post_process",
"post_process_plugin",
// "plugins" is the manifest backing post_process_plugin; like it, it only affects G-code export.
// "plugins" is the derived manifest backing the plugin-picker options; on its own it only
// affects G-code export. The specific option (e.g. slicing_pipeline_plugin) drives any re-slice.
"plugins",
"extruder_clearance_height_to_rod",
"extruder_clearance_height_to_lid",
@@ -277,7 +279,8 @@ bool Print::invalidate_state_by_config_options(const ConfigOptionResolver & /* n
|| opt_key == "wipe_tower_rotation_angle") {
steps.emplace_back(psSkirtBrim);
} else if (
opt_key == "initial_layer_print_height"
opt_key == "slicing_pipeline_plugin"
|| opt_key == "initial_layer_print_height"
|| opt_key == "nozzle_diameter"
|| opt_key == "filament_shrink"
|| opt_key == "filament_shrinkage_compensation_z"
@@ -2201,6 +2204,11 @@ void Print::process(long long *time_cost_with_cache, bool use_cache)
if (time_cost_with_cache)
*time_cost_with_cache = 0;
{
const auto* sp = this->config().option<ConfigOptionStrings>("slicing_pipeline_plugin");
m_pipeline_plugin_active = s_slicing_pipeline_hook_fn && sp && !sp->values.empty();
}
name_tbb_thread_pool_threads_set_locale();
//compute the PrintObject with the same geometries
@@ -2310,20 +2318,47 @@ void Print::process(long long *time_cost_with_cache, bool use_cache)
BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << boost::format(": total object counts %1% in current print, need to slice %2%")%m_objects.size()%need_slicing_objects.size();
BOOST_LOG_TRIVIAL(info) << "Starting the slicing process." << log_memory_info();
if (!use_cache) {
// Fire the SlicingPipeline hook for `obj` iff it just (re)computed `pstep` this pass.
auto hook_after = [this](PrintObject* obj, bool was_done, PrintObjectStep pstep, SlicingPipelineStepPlugin sstep) {
if (m_pipeline_plugin_active && !was_done && obj->is_step_done(pstep))
run_pipeline_hook(sstep, obj);
};
// SlicingPipeline: dedicated slice loop so the Slice boundary is hookable before perimeters.
for (PrintObject *obj : m_objects) {
if (need_slicing_objects.count(obj) != 0) {
obj->make_perimeters();
}
else {
if (obj->set_started(posSlice))
obj->set_done(posSlice);
if (obj->set_started(posPerimeters))
obj->set_done(posPerimeters);
const bool was_done = obj->is_step_done(posSlice);
obj->slice();
hook_after(obj, was_done, posSlice, SlicingPipelineStepPlugin::posSlice);
// re-snapshot each layer's raw_slices AFTER the Slice hook ran, so the
// plugin's mutation becomes the untyped baseline. Without this, a later
// perimeter-only re-run (make_perimeters -> restore_untyped_slices) reverts
// slices to the PRE-hook geometry while posSlice stays cached (the hook does
// not re-fire), silently un-applying the mutation; raw_slices consumers
// (sharp-tail support, ToolOrdering) also read this backup directly. Gated on
// an active plugin AND a genuine (re)slice, so the inactive path is untouched
// and re-backing-up an unmutated layer is a harmless identical copy.
if (m_pipeline_plugin_active && !was_done && obj->is_step_done(posSlice))
for (Layer *layer : obj->layers())
layer->backup_untyped_slices();
} else {
if (obj->set_started(posSlice)) obj->set_done(posSlice); // shared/duplicate — no hook
}
}
for (PrintObject *obj : m_objects) {
if (need_slicing_objects.count(obj) != 0) {
const bool was_done = obj->is_step_done(posPerimeters);
obj->make_perimeters(); // slice() inside is a no-op: posSlice already DONE
hook_after(obj, was_done, posPerimeters, SlicingPipelineStepPlugin::posPerimeters);
} else {
if (obj->set_started(posPerimeters)) obj->set_done(posPerimeters);
}
}
for (PrintObject *obj : m_objects) {
if (need_slicing_objects.count(obj) != 0) {
const bool was_done = obj->is_step_done(posEstimateCurledExtrusions);
obj->estimate_curled_extrusions();
hook_after(obj, was_done, posEstimateCurledExtrusions, SlicingPipelineStepPlugin::posEstimateCurledExtrusions);
}
else {
if (obj->set_started(posEstimateCurledExtrusions))
@@ -2332,7 +2367,17 @@ void Print::process(long long *time_cost_with_cache, bool use_cache)
}
for (PrintObject *obj : m_objects) {
if (need_slicing_objects.count(obj) != 0) {
// split prepare_infill (fill-surface prep) from infill (make_fills) so a
// plugin can mutate fill surfaces at the PrepareInfill seam and have make_fills
// consume them (unlike the Infill seam, which fires after the fills are already
// built). infill() re-invokes prepare_infill() as a no-op once posPrepareInfill
// is DONE, so this is a mechanical split mirroring the slice/perimeters loop.
const bool prepare_was_done = obj->is_step_done(posPrepareInfill);
obj->prepare_infill();
hook_after(obj, prepare_was_done, posPrepareInfill, SlicingPipelineStepPlugin::posPrepareInfill);
const bool was_done = obj->is_step_done(posInfill);
obj->infill();
hook_after(obj, was_done, posInfill, SlicingPipelineStepPlugin::posInfill);
}
else {
if (obj->set_started(posPrepareInfill))
@@ -2343,7 +2388,9 @@ void Print::process(long long *time_cost_with_cache, bool use_cache)
}
for (PrintObject *obj : m_objects) {
if (need_slicing_objects.count(obj) != 0) {
const bool was_done = obj->is_step_done(posIroning);
obj->ironing();
hook_after(obj, was_done, posIroning, SlicingPipelineStepPlugin::posIroning);
}
else {
if (obj->set_started(posIroning))
@@ -2355,13 +2402,22 @@ void Print::process(long long *time_cost_with_cache, bool use_cache)
for (PrintObject *obj : m_objects) {
bool need_contouring = need_slicing_objects.count(obj) != 0 && obj->need_z_contouring();
if (need_contouring) {
const bool was_done = obj->is_step_done(posContouring);
obj->contour_z();
hook_after(obj, was_done, posContouring, SlicingPipelineStepPlugin::posContouring);
} else {
if (obj->set_started(posContouring))
obj->set_done(posContouring);
}
}
// SlicingPipeline: support runs in the parallel block below; the hook must fire in a
// sequential loop afterward. Snapshot per-object done-state just before the parallel_for.
std::vector<char> sup_was_done(m_objects.size(), 1);
if (m_pipeline_plugin_active)
for (size_t i = 0; i < m_objects.size(); ++i)
sup_was_done[i] = m_objects[i]->is_step_done(posSupportMaterial) ? 1 : 0;
tbb::parallel_for(tbb::blocked_range<int>(0, int(m_objects.size())),
[this, need_slicing_objects](const tbb::blocked_range<int>& range) {
for (int i = range.begin(); i < range.end(); i++) {
@@ -2377,9 +2433,17 @@ void Print::process(long long *time_cost_with_cache, bool use_cache)
}
);
if (m_pipeline_plugin_active)
for (size_t i = 0; i < m_objects.size(); ++i)
if (need_slicing_objects.count(m_objects[i]) != 0 && !sup_was_done[i]
&& m_objects[i]->is_step_done(posSupportMaterial))
run_pipeline_hook(SlicingPipelineStepPlugin::posSupportMaterial, m_objects[i]);
for (PrintObject* obj : m_objects) {
if (need_slicing_objects.count(obj) != 0) {
const bool was_done = obj->is_step_done(posDetectOverhangsForLift);
obj->detect_overhangs_for_lift();
hook_after(obj, was_done, posDetectOverhangsForLift, SlicingPipelineStepPlugin::posDetectOverhangsForLift);
}
else {
if (obj->set_started(posDetectOverhangsForLift))
@@ -2456,6 +2520,7 @@ void Print::process(long long *time_cost_with_cache, bool use_cache)
}
this->set_done(psWipeTower);
if (m_pipeline_plugin_active) run_pipeline_hook(SlicingPipelineStepPlugin::psWipeTower, nullptr);
}
if (this->has_wipe_tower()) {
@@ -2581,6 +2646,7 @@ void Print::process(long long *time_cost_with_cache, bool use_cache)
this->finalize_first_layer_convex_hull();
this->set_done(psSkirtBrim);
if (m_pipeline_plugin_active) run_pipeline_hook(SlicingPipelineStepPlugin::psSkirtBrim, nullptr);
if (time_cost_with_cache) {
end_time = (long long)Slic3r::Utils::get_current_time_utc();
@@ -2591,7 +2657,13 @@ void Print::process(long long *time_cost_with_cache, bool use_cache)
for (PrintObject *obj : m_objects) {
if (((!use_cache)&&(need_slicing_objects.count(obj) != 0))
|| (use_cache &&(re_slicing_objects.count(obj) != 0))){
const bool was_done = obj->is_step_done(posSimplifyPath);
obj->simplify_extrusion_path();
// Unlike every other seam (all inside the `if (!use_cache)` block above), this loop is
// shared with the use_cache path (re_slicing_objects), so `!use_cache` must be checked
// explicitly here to keep hooks from ever firing on cache-loaded (plugin-final) objects.
if (!use_cache && m_pipeline_plugin_active && !was_done && obj->is_step_done(posSimplifyPath))
run_pipeline_hook(SlicingPipelineStepPlugin::posSimplifyPath, obj);
}
else {
if (obj->set_started(posSimplifyPath))

View File

@@ -99,6 +99,14 @@ enum PrintObjectStep {
posCount,
};
enum class SlicingPipelineStepPlugin {
posSlice, posPerimeters, posEstimateCurledExtrusions, posPrepareInfill, posInfill, posIroning, posContouring,
posSupportMaterial, posDetectOverhangsForLift, posSimplifyPath, psWipeTower, psSkirtBrim,
// Fires from the GUI G-code export/post-process seam (PostProcessor.cpp), NOT from Print::process().
// At this step the plugin edits the exported G-code file in place; see SlicingPipelinePluginCapability for the full contract.
psGCodePostProcess
};
// A PrintRegion object represents a group of volumes to print
// sharing the same config (including the same assigned extruder(s))
class PrintRegion
@@ -891,6 +899,11 @@ private: // Prevents erroneous use by other classes.
typedef std::pair<PrintObject *, bool> PrintObjectInfo;
public:
using SlicingPipelineHookFn = std::function<void(Print&, const PrintObject*, SlicingPipelineStepPlugin)>;
// Cross-layer injection (mirrors ConfigBase::set_resolve_capability_fn): the GUI/plugin
// layer registers a dispatcher; libslic3r stays free of any plugin/Python dependency.
static void set_slicing_pipeline_hook_fn(SlicingPipelineHookFn fn) { s_slicing_pipeline_hook_fn = std::move(fn); }
Print() = default;
virtual ~Print() { this->clear(); }
@@ -1147,6 +1160,13 @@ private:
// Islands of objects and their supports extruded at the 1st layer.
Polygons first_layer_islands() const;
static SlicingPipelineHookFn s_slicing_pipeline_hook_fn;
bool m_pipeline_plugin_active { false };
void run_pipeline_hook(SlicingPipelineStepPlugin step, const PrintObject* object) {
if (m_pipeline_plugin_active && s_slicing_pipeline_hook_fn)
s_slicing_pipeline_hook_fn(*this, object, step);
}
PrintConfig m_config;
PrintObjectConfig m_default_object_config;
PrintRegionConfig m_default_region_config;

View File

@@ -828,7 +828,10 @@ void PrintConfigDef::init_common_params()
def->tooltip = L("Select the network agent implementation for printer communication.");
def->mode = comAdvanced;
def->cli = ConfigOptionDef::nocli;
def->support_plugin = true;
// Plugin-backed like the pickers, but edited via a dedicated Choice widget rather than a
// plugin_picker field. plugin_type marks it plugin-backed and names its capability type, so its
// "plugins" manifest reference is derived generically (see ConfigOptionDef::is_plugin_backed).
def->plugin_type = "printer-connection";
def->set_default_value(new ConfigOptionString(""));
def = this->add("print_host", coString);
@@ -5111,14 +5114,12 @@ void PrintConfigDef::init_fff_params()
def->mode = comDevelop;
def->set_default_value(new ConfigOptionStrings());
def = this->add("post_process_plugin", coStrings);
def->label = L("Post-processing Plugin");
def->tooltip = L("Select a Python plugin to process the output G-code. "
"Plugins are loaded from the orca_plugins directory in your data folder. "
"The plugin will receive the G-code file path and can modify it in place.");
def = this->add("slicing_pipeline_plugin", coStrings);
def->label = L("Slicing Pipeline Plugin");
def->tooltip = L("Python plugin(s) invoked at each slicing pipeline step to read and modify intermediate slicing data, "
"including a final G-code post-processing step. Research/experimental.");
def->gui_type = ConfigOptionDef::GUIType::plugin_picker;
def->plugin_type = "post-processing";
def->support_plugin = true;
def->plugin_type = "slicing-pipeline";
def->full_width = true;
def->mode = comAdvanced;
def->set_default_value(new ConfigOptionStrings());

View File

@@ -1570,7 +1570,7 @@ PRINT_CONFIG_CLASS_DERIVED_DEFINE(
((ConfigOptionBool, ooze_prevention))
((ConfigOptionString, filename_format))
((ConfigOptionStrings, post_process))
((ConfigOptionStrings, post_process_plugin))
((ConfigOptionStrings, slicing_pipeline_plugin))
((ConfigOptionString, printer_model))
((ConfigOptionFloat, resolution))
((ConfigOptionFloats, retraction_minimum_travel))

View File

@@ -596,36 +596,45 @@ set(SLIC3R_GUI_SOURCES
plugin/PythonPluginBridge.hpp
plugin/PythonPluginInterface.hpp
plugin/PyPluginPackage.hpp
plugin/PluginHostApi.cpp
plugin/PluginHostApi.hpp
plugin/PluginHostUi.cpp
plugin/PluginHostUi.hpp
plugin/PluginBindingUtils.hpp
plugin/host/PluginHost.cpp
plugin/host/PluginHost.hpp
plugin/host/PluginHostBindings.hpp
plugin/host/PluginHostApp.cpp
plugin/host/PluginHostGeometry.cpp
plugin/host/PluginHostMesh.cpp
plugin/host/PluginHostMesh.hpp
plugin/host/PluginHostModel.cpp
plugin/host/PluginHostPresets.cpp
plugin/host/PluginHostSlicing.cpp
plugin/host/PluginHostUi.cpp
plugin/host/PluginHostUi.hpp
plugin/CloudPluginService.cpp
plugin/CloudPluginService.hpp
plugin/PluginFsUtils.cpp
plugin/PluginFsUtils.hpp
plugin/PluginConfig.cpp
plugin/PluginConfig.hpp
plugin/PluginCatalog.cpp
plugin/PluginCatalog.hpp
plugin/PluginLoader.cpp
plugin/PluginLoader.hpp
plugin/PluginDescriptor.hpp
plugin/PluginHooks.cpp
plugin/PluginHooks.hpp
plugin/PluginManager.cpp
plugin/PluginManager.hpp
plugin/PluginAuditManager.cpp
plugin/PluginAuditManager.hpp
plugin/PluginResolver.cpp
plugin/PluginResolver.hpp
plugin/pluginTypes/gcode/GCodePluginCapability.hpp
plugin/pluginTypes/gcode/GCodePluginCapability.cpp
plugin/pluginTypes/gcode/GCodePluginCapabilityTrampoline.hpp
plugin/pluginTypes/printerAgent/PrinterAgentPluginCapability.hpp
plugin/pluginTypes/printerAgent/PrinterAgentPluginCapability.cpp
plugin/pluginTypes/printerAgent/PrinterAgentPluginCapabilityTrampoline.hpp
plugin/pluginTypes/script/ScriptPluginCapability.hpp
plugin/pluginTypes/script/ScriptPluginCapability.cpp
plugin/pluginTypes/script/ScriptPluginCapabilityTrampoline.hpp
plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.hpp
plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.cpp
plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapabilityTrampoline.hpp
pchheader.cpp
pchheader.hpp
Utils/ASCIIFolding.cpp

View File

@@ -79,7 +79,7 @@
#include "libslic3r/Utils.hpp"
#include "libslic3r/Color.hpp"
#include "slic3r/plugin/PluginManager.hpp"
#include "slic3r/plugin/PluginHostUi.hpp"
#include "slic3r/plugin/host/PluginHostUi.hpp"
#include "slic3r/plugin/PythonInterpreter.hpp"
#include "GUI.hpp"
@@ -2700,6 +2700,54 @@ std::string get_system_info()
return out.str();
}
// wx/app-level plugin wiring, kept in one place: subscriptions to plugin
// loader events that drive GUI policy (plugins dialog refresh, network-agent
// registration, plate revalidation). The libslic3r dispatch hooks are NOT
// wired here -- PluginManager::initialize() installs those via
// plugin_hooks::install().
void GUI_App::init_plugin_gui_wiring()
{
PluginManager& plugin_mgr = PluginManager::instance();
auto refresh_plugins_dialog = [] {
if (!wxTheApp)
return;
GUI_App* app = &GUI::wxGetApp();
if (app->is_closing())
return;
app->CallAfter([app] {
if (!app->is_closing() && app->m_plugins_dlg)
app->m_plugins_dlg->update_plugin_dialog_ui();
});
};
plugin_mgr.get_loader().subscribe_on_load_callback([refresh_plugins_dialog](const std::string&) { refresh_plugins_dialog(); });
plugin_mgr.get_loader().subscribe_on_unload_callback([refresh_plugins_dialog](const std::string&) { refresh_plugins_dialog(); });
plugin_mgr.get_loader().subscribe_on_load_callback(NetworkAgentFactory::register_python_plugin);
plugin_mgr.get_loader().subscribe_on_unload_callback(NetworkAgentFactory::deregister_python_plugin);
plugin_mgr.get_loader().subscribe_on_capability_load_callback(
[refresh_plugins_dialog](const PluginCapabilityIdentifier& capability) {
if (capability.type == PluginCapabilityType::PrinterConnection)
NetworkAgentFactory::register_python_printer_agent(capability.plugin_key, capability.name);
refresh_plugins_dialog();
// A newly loaded capability may satisfy a missing-plugin notification; re-validate the
// current plate (on the UI thread) so the notification clears once its plugin is available.
if (wxTheApp && !wxGetApp().is_closing())
wxGetApp().CallAfter([]() {
if (Plater* plater = wxGetApp().plater())
plater->revalidate_current_plate_if_plugins_missing();
});
});
plugin_mgr.get_loader().subscribe_on_capability_unload_callback(
[refresh_plugins_dialog](const PluginCapabilityIdentifier& capability) {
if (capability.type == PluginCapabilityType::PrinterConnection)
NetworkAgentFactory::deregister_python_printer_agent(capability.plugin_key, capability.name);
refresh_plugins_dialog();
});
}
bool GUI_App::on_init_inner()
{
wxLog::SetActiveTarget(new wxBoostLog());
@@ -3103,25 +3151,12 @@ bool GUI_App::on_init_inner()
on_init_network();
// Initialize plugins after network then register on_load callbacks so once the plugin loads finish, it gets registered automatically.
// initialize() also installs the libslic3r hooks (capability resolver,
// slicing-pipeline dispatcher) via plugin_hooks::install() -- no
// per-capability wiring belongs here.
PluginManager& plugin_mgr = PluginManager::instance();
plugin_mgr.initialize();
ConfigBase::set_resolve_capability_fn([&plugin_mgr](const std::string& cap_name, const std::string& cap_type) {
auto plugin_cap = plugin_mgr.get_loader().try_get_plugin_capability_by_name_and_type(cap_name, plugin_capability_type_from_string(cap_type));
if (!plugin_cap)
return std::string();
PluginDescriptor descriptor;
if (!plugin_mgr.get_catalog().try_get_plugin_descriptor(plugin_cap->plugin_key, descriptor))
return std::string();
// Cloud plugins are resolved at runtime via the UUID in the middle field, so the first
// field keeps the friendly display name. Local plugins are looked up by plugin_key (the
// first field, with an empty UUID), so emit the plugin_key to keep them resolvable.
const std::string identity = descriptor.is_cloud_plugin() ? descriptor.name : descriptor.plugin_key;
return identity + ';' + descriptor.cloud_uuid() + ';' + cap_name;
});
// Set cloud plugin directory from previous session so cloud-installed
// plugins are discovered even before the network agent is ready.
const std::string preset_folder = app_config->get("preset_folder");
@@ -3132,43 +3167,7 @@ bool GUI_App::on_init_inner()
plugin_mgr.discover_plugins(false, true);
auto refresh_plugins_dialog = [] {
if (!wxTheApp)
return;
GUI_App* app = &GUI::wxGetApp();
if (app->is_closing())
return;
app->CallAfter([app] {
if (!app->is_closing() && app->m_plugins_dlg)
app->m_plugins_dlg->update_plugin_dialog_ui();
});
};
plugin_mgr.get_loader().subscribe_on_load_callback([refresh_plugins_dialog](const std::string&) { refresh_plugins_dialog(); });
plugin_mgr.get_loader().subscribe_on_unload_callback([refresh_plugins_dialog](const std::string&) { refresh_plugins_dialog(); });
plugin_mgr.get_loader().subscribe_on_load_callback(NetworkAgentFactory::register_python_plugin);
plugin_mgr.get_loader().subscribe_on_unload_callback(NetworkAgentFactory::deregister_python_plugin);
plugin_mgr.get_loader().subscribe_on_capability_load_callback(
[refresh_plugins_dialog](const PluginCapabilityIdentifier& capability) {
if (capability.type == PluginCapabilityType::PrinterConnection)
NetworkAgentFactory::register_python_printer_agent(capability.plugin_key, capability.name);
refresh_plugins_dialog();
// A newly loaded capability may satisfy a missing-plugin notification; re-validate the
// current plate (on the UI thread) so the notification clears once its plugin is available.
if (wxTheApp && !wxGetApp().is_closing())
wxGetApp().CallAfter([]() {
if (Plater* plater = wxGetApp().plater())
plater->revalidate_current_plate_if_plugins_missing();
});
});
plugin_mgr.get_loader().subscribe_on_capability_unload_callback(
[refresh_plugins_dialog](const PluginCapabilityIdentifier& capability) {
if (capability.type == PluginCapabilityType::PrinterConnection)
NetworkAgentFactory::deregister_python_printer_agent(capability.plugin_key, capability.name);
refresh_plugins_dialog();
});
init_plugin_gui_wiring();
for (const std::string& plugin_key : plugin_mgr.get_catalog().get_enabled_plugin_keys()) {
if (!plugin_mgr.get_loader().is_plugin_loaded(plugin_key)) {

View File

@@ -772,6 +772,9 @@ private:
bool on_init_network(bool try_backup = false);
void init_networking_callbacks();
void init_app_config();
// GUI-side subscriptions to plugin loader events (dialog refresh,
// network-agent registration, plate revalidation).
void init_plugin_gui_wiring();
void remove_old_networking_plugins();
void drain_pending_events(int timeout_ms);
bool wait_for_network_idle(int timeout_ms);

View File

@@ -996,9 +996,9 @@ void PluginsDialog::run_script_plugin(const std::string& plugin_key, const std::
// ModelObject*/ModelVolume*/ModelInstance* aliases into host data and can mint ObjectIDs,
// which libslic3r requires on the main thread (ObjectID.hpp's non-atomic s_last_id). Running
// here makes those reads/instantiations legal and means nothing mutates the model underneath
// a run. The trade-off is that a slow execute() freezes the UI: the contract (see
// plugin_development.md) is to keep execute() quick and offload heavy work to the plugin's own
// threading.Thread. orca.host.ui calls already no-op their main-thread marshaling here.
// a run. The trade-off is that a slow execute() freezes the UI, so the contract is to keep
// execute() quick and offload heavy work to the plugin's own threading.Thread. orca.host.ui
// calls already no-op their main-thread marshaling here.
{
wxBusyCursor busy;
try {

View File

@@ -9,7 +9,7 @@
// file lives in the GUI layer (libslic3r must not depend on pybind11 / PluginManager).
#include "libslic3r/Config.hpp"
#include "slic3r/plugin/PluginManager.hpp"
#include "slic3r/plugin/pluginTypes/gcode/GCodePluginCapability.hpp"
#include "slic3r/plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.hpp"
#include "slic3r/plugin/PythonInterpreter.hpp"
#include <boost/algorithm/string.hpp>
@@ -241,27 +241,39 @@ void gcode_add_line_number(const std::string& path, const DynamicPrintConfig& co
fs.close();
}
// Run the configured post-processing plugins on `gcode_path` in place. Plugins are executed in-process
// through the embedded Python interpreter. Throws Slic3r::RuntimeError on any failure; the caller is
// responsible for removing the working copy (see run_post_process_scripts' catch block).
// Entries are bare capability names; the top-level plugins manifest carries the full plugin refs.
// Run the configured slicing-pipeline plugins on `gcode_path` in place, at their Step.psGCodePostProcess
// seam. This is the same capability that runs at the geometry seams inside Print::process(); here it is
// dispatched a final time on the exported G-code, so a plugin can edit slices AND the final G-code from
// one class. Plugins are executed in-process through the embedded Python interpreter. Throws
// Slic3r::RuntimeError on any failure; the caller removes the working copy (see run_post_process_scripts'
// catch block). Entries are bare capability names; the top-level plugins manifest carries the full refs.
// A geometry-only plugin simply returns success here (it filters on ctx.step), so it costs nothing beyond
// one no-op call, but note any configured pipeline plugin still engages this post-process path (i.e. the
// non-BBL ".pp" working copy) even if it does no G-code work.
static void run_post_process_plugins(const ConfigOptionStrings& capabilities,
const ConfigOptionStrings* plugins,
const std::string& gcode_path,
const std::string& host,
const std::string& output_name)
const std::string& output_name,
const DynamicPrintConfig& config)
{
// Let plugins observe the (possibly script-updated) target file name, mirroring the script env.
boost::nowide::setenv("SLIC3R_PP_OUTPUT_NAME", output_name.c_str(), 1);
const boost::filesystem::path gcode_file(gcode_path);
auto execute_fn = [&](std::shared_ptr<GCodePluginCapability> cap, const PluginCapabilityRef& ref) {
GCodePluginContext ctx;
auto execute_fn = [&](std::shared_ptr<SlicingPipelinePluginCapability> cap, const PluginCapabilityRef& ref) {
SlicingPipelineContext ctx;
ctx.orca_version = SoftFever_VERSION;
ctx.step = SlicingPipelineStepPlugin::psGCodePostProcess;
ctx.gcode_path = gcode_path;
ctx.host = host;
ctx.output_name = output_name;
ctx.full_config = &config; // no live Print here; config_value() reads this
// Hand the plugin its own [tool.orcaslicer.plugin.settings] as ctx.params (same plugin_key the
// capability was resolved by), mirroring the in-pipeline dispatcher in GUI_App.cpp.
const std::string plugin_key = ref.uuid.empty() ? ref.name : ref.uuid;
ctx.params = PluginManager::instance().get_loader().get_plugin_settings(plugin_key);
ExecutionResult exec_result;
try {
@@ -298,11 +310,12 @@ static void run_post_process_plugins(const ConfigOptionStrings& capabilities,
BOOST_LOG_TRIVIAL(info) << "Post-processing plugin " << ref.capability_name << " completed successfully";
};
execute_capabilities_from_refs<GCodePluginCapability>(capabilities, plugins, PluginCapabilityType::PostProcessing, execute_fn);
execute_capabilities_from_refs<SlicingPipelinePluginCapability>(capabilities, plugins, PluginCapabilityType::SlicingPipeline, execute_fn);
}
// Run post-processing scripts ("post_process") and/or post-processing plugins ("post_process_plugin")
// if defined. Both run on the same working copy of the G-code (the ".pp" temp when make_copy), so a
// Run post-processing scripts ("post_process") and/or the slicing-pipeline plugins' psGCodePostProcess
// step ("slicing_pipeline_plugin") if defined. Both run on the same working copy of the G-code (the
// ".pp" temp when make_copy), so a
// plugin never opens the original file the G-code viewer keeps memory-mapped (a writable open of the
// mapped file fails on Windows with a sharing violation).
// Returns true if a script or plugin was executed.
@@ -317,11 +330,13 @@ static void run_post_process_plugins(const ConfigOptionStrings& capabilities,
bool run_post_process_scripts(
std::string& src_path, bool make_copy, const std::string& host, std::string& output_name, const DynamicPrintConfig& config)
{
// post_process / post_process_plugin are absent in SLA mode, hence the null checks.
const auto* post_process = config.opt<ConfigOptionStrings>("post_process");
const auto* post_process_plugin = config.opt<ConfigOptionStrings>("post_process_plugin");
const bool have_scripts = post_process != nullptr && !post_process->values.empty();
const bool have_plugins = post_process_plugin != nullptr && !post_process_plugin->values.empty();
// post_process / slicing_pipeline_plugin are absent in SLA mode, hence the null checks. G-code
// post-processing is now the psGCodePostProcess step of the slicing-pipeline plugin, so the same
// slicing_pipeline_plugin option drives both the geometry seams and this final G-code seam.
const auto* post_process = config.opt<ConfigOptionStrings>("post_process");
const auto* slicing_pipeline_plugin = config.opt<ConfigOptionStrings>("slicing_pipeline_plugin");
const bool have_scripts = post_process != nullptr && !post_process->values.empty();
const bool have_plugins = slicing_pipeline_plugin != nullptr && !slicing_pipeline_plugin->values.empty();
if (!have_scripts && !have_plugins)
return false;
@@ -469,7 +484,7 @@ bool run_post_process_scripts(
// Run plugins after the scripts so they observe any output_name the scripts produced. A thrown
// exception is handled by the catch below, which removes the temp copy.
if (have_plugins) {
run_post_process_plugins(*post_process_plugin, config.opt<ConfigOptionStrings>("plugins"), path, host, output_name);
run_post_process_plugins(*slicing_pipeline_plugin, config.opt<ConfigOptionStrings>("plugins"), path, host, output_name, config);
}
} catch (...) {
remove_output_name_file();

View File

@@ -9,9 +9,10 @@
namespace Slic3r {
// Run post-processing scripts (the "post_process" option) and/or post-processing plugins (the
// "post_process_plugin" option) if defined. Lives in the GUI layer because plugins are executed
// through the embedded-Python PluginManager, which libslic3r must not depend on.
// Run post-processing scripts (the "post_process" option) and/or the slicing-pipeline plugins'
// Step.psGCodePostProcess seam (the "slicing_pipeline_plugin" option) if defined. Lives in the GUI
// layer because plugins are executed through the embedded-Python PluginManager, which libslic3r must
// not depend on.
// Returns true if a script or plugin was executed.
// Returns false if neither a post-processing script nor plugin was defined.
// Throws an exception on error.

View File

@@ -1790,6 +1790,13 @@ void Tab::on_value_change(const std::string& opt_key, const boost::any& value)
return;
}
// Keep this preset's "plugins" manifest in sync when a plugin picker changes, so the edited preset
// always carries resolved "name;uuid;capability" references that full_config() and save_to_json()
// then pass downstream as-is -- no separate rebuild anywhere else.
if (const ConfigOptionDef* opt_def = m_config->def()->get(opt_key);
opt_def && opt_def->is_plugin_backed())
m_config->update_plugin_manifest();
if (opt_key == "gcode_flavor" && m_type == Preset::TYPE_PRINTER) {
if (auto printer_tab = dynamic_cast<TabPrinter*>(this))
printer_tab->on_gcode_flavor_changed();
@@ -3073,9 +3080,9 @@ void TabPrint::build()
option.opt.height = 15;
optgroup->append_single_option_line(option, "others_settings_post_processing_scripts");
optgroup = page->new_optgroup(L("Post-processing Plugin"), L"param_gcode", 0);
optgroup = page->new_optgroup(L("Slicing Pipeline Plugin"), L"param_gcode", 0);
optgroup->hide_labels();
option = optgroup->get_option("post_process_plugin");
option = optgroup->get_option("slicing_pipeline_plugin");
option.opt.full_width = true;
optgroup->append_single_option_line(option, "others_settings_plugin_picker");

View File

@@ -68,22 +68,17 @@ bool is_inside_allowed_root(const std::filesystem::path& candidate, const std::f
// ---------------------------------------------------------------------------
thread_local std::string PluginAuditManager::m_current_plugin_key = "";
thread_local std::string PluginAuditManager::m_current_capability_name = "";
thread_local PluginAuditManager::AuditMode PluginAuditManager::m_audit_mode = PluginAuditManager::AuditMode::Loading;
thread_local std::vector<std::filesystem::path> PluginAuditManager::m_scoped_allowed_roots;
thread_local bool PluginAuditManager::m_has_last_violation = false;
thread_local AuditViolation PluginAuditManager::m_last_violation;
ScopedPluginAuditContext::ScopedPluginAuditContext(const std::string& plugin_key,
const std::string& capability_name,
PluginAuditManager::AuditMode mode)
ScopedPluginAuditContext::ScopedPluginAuditContext(const std::string& plugin_key, PluginAuditManager::AuditMode mode)
: m_previous_id(PluginAuditManager::instance().current_plugin())
, m_previous_capability(PluginAuditManager::instance().current_capability())
, m_previous_mode(PluginAuditManager::instance().audit_mode())
, m_previous_scoped_roots(PluginAuditManager::m_scoped_allowed_roots)
{
PluginAuditManager::instance().set_current_plugin(plugin_key);
PluginAuditManager::instance().set_current_capability(capability_name);
PluginAuditManager::instance().set_audit_mode(mode);
PluginAuditManager::m_scoped_allowed_roots.clear();
}
@@ -91,7 +86,6 @@ ScopedPluginAuditContext::ScopedPluginAuditContext(const std::string& plugin_key
ScopedPluginAuditContext::~ScopedPluginAuditContext()
{
PluginAuditManager::instance().set_current_plugin(m_previous_id);
PluginAuditManager::instance().set_current_capability(m_previous_capability);
PluginAuditManager::instance().set_audit_mode(m_previous_mode);
PluginAuditManager::m_scoped_allowed_roots = std::move(m_previous_scoped_roots);
}
@@ -112,12 +106,6 @@ std::string PluginAuditManager::current_plugin() const { return m_current_plugin
void PluginAuditManager::clear_current_plugin() { m_current_plugin_key.clear(); }
void PluginAuditManager::set_current_capability(const std::string& capability_name) { m_current_capability_name = capability_name; }
std::string PluginAuditManager::current_capability() const { return m_current_capability_name; }
void PluginAuditManager::clear_current_capability() { m_current_capability_name.clear(); }
void PluginAuditManager::add_global_allowed_root(const std::filesystem::path& root)
{
if (root.empty())

View File

@@ -39,14 +39,6 @@ public:
std::string current_plugin() const;
void clear_current_plugin();
// --- current-capability context (thread_local) ---
// The capability whose method is currently executing, within the current plugin. Empty
// while a plugin-wide call runs, and during capture (get_name/get_type), where the
// capability has no cached name yet.
void set_current_capability(const std::string& capability_name);
std::string current_capability() const;
void clear_current_capability();
// --- allowed-roots registry ---
void add_global_allowed_root(const std::filesystem::path& root);
void add_scoped_allowed_root(const std::filesystem::path& root);
@@ -83,7 +75,6 @@ private:
static int audit_hook(const char* event, PyObject* args, void* user_data);
static thread_local std::string m_current_plugin_key;
static thread_local std::string m_current_capability_name;
static thread_local AuditMode m_audit_mode;
static thread_local std::vector<std::filesystem::path> m_scoped_allowed_roots;
static thread_local bool m_has_last_violation;
@@ -93,15 +84,12 @@ private:
std::vector<std::filesystem::path> m_global_allowed_roots;
};
// RAII guard that sets the current plugin key and capability name, restoring the previous
// pair on scope exit. `capability_name` may be empty for calls that are not scoped to a
// single capability.
// RAII guard that sets the current plugin key and restores the previous one.
class ScopedPluginAuditContext
{
public:
explicit ScopedPluginAuditContext(
const std::string& plugin_key,
const std::string& capability_name = {},
PluginAuditManager::AuditMode mode = PluginAuditManager::AuditMode::Loading);
~ScopedPluginAuditContext();
@@ -111,7 +99,6 @@ public:
private:
std::string m_previous_id;
std::string m_previous_capability;
PluginAuditManager::AuditMode m_previous_mode;
std::vector<std::filesystem::path> m_previous_scoped_roots;
};

View File

@@ -0,0 +1,106 @@
#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 });
// Mark the fresh empty array read-only so every return path of this
// helper yields a read-only view.
empty.attr("setflags")(py::arg("write") = false);
return std::move(empty);
}
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);
}
// Zero-copy, WRITABLE (rows, N) numpy view over `data`, lifetime tied to `base`.
// Twin of make_readonly_rows: a base-carrying pybind array is writable by default,
// so we simply do not clear the write flag. Writing through the view mutates the
// underlying C++ buffer in place. rows == 0 / null data yields a fresh empty (0, N)
// array (writable, no base).
template<typename T, int N>
pybind11::array make_writable_rows(pybind11::handle base, T* data, pybind11::ssize_t rows)
{
namespace py = pybind11;
if (rows == 0 || data == nullptr)
return py::array_t<T>(std::vector<py::ssize_t>{ 0, (py::ssize_t) N });
return py::array_t<T>(
{ rows, (py::ssize_t) N },
{ (py::ssize_t)(N * sizeof(T)), (py::ssize_t) sizeof(T) },
data, base);
}
// Serialize one config key to a Python string, or None if the key is absent.
// Works on any ConfigBase (resolved DynamicPrintConfig snapshots,
// PrintObjectConfig, PrintRegionConfig, preset configs).
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

View File

@@ -279,7 +279,7 @@ std::vector<std::string> PluginCatalog::get_plugin_directories() const
};
// Local plugins: {data_dir}/orca_plugins/
add_or_create_dir(get_orca_plugins_dir());
add_or_create_dir(fs::path(data_dir()) / "orca_plugins");
// Cloud plugins: {data_dir}/orca_plugins/_subscribed/{user_id}/
if (!cloud_plugin_dir_name.empty())

View File

@@ -1,255 +0,0 @@
#include "PluginConfig.hpp"
#include <pybind11/pybind11.h>
#include <boost/format.hpp>
#include <boost/log/trivial.hpp>
#include <boost/nowide/fstream.hpp>
#include <slic3r/plugin/PluginAuditManager.hpp>
#include <slic3r/plugin/PluginManager.hpp>
#include <stdexcept>
#include <utility>
namespace Slic3r {
namespace {
constexpr const char* KEY_ENTRIES = "config";
constexpr const char* KEY_PLUGIN = "plugin_key";
constexpr const char* KEY_CAPABILITY = "capability";
constexpr const char* KEY_VERSION = "plugin_version";
constexpr const char* KEY_CAP_CONFIG = "cap_config";
std::string string_field(const nlohmann::json& entry, const char* key)
{
const auto it = entry.find(key);
return it != entry.end() && it->is_string() ? it->get<std::string>() : std::string();
}
// Rejects entries missing an identity, which could never be looked up again.
bool entry_to_config(const nlohmann::json& entry, BaseConfig& out)
{
if (!entry.is_object())
return false;
out.plugin_key = string_field(entry, KEY_PLUGIN);
out.capability_name = string_field(entry, KEY_CAPABILITY);
out.plugin_version = string_field(entry, KEY_VERSION);
if (out.empty())
return false;
const auto cap_config = entry.find(KEY_CAP_CONFIG);
out.config = cap_config != entry.end() ? *cap_config : nlohmann::json::object();
return true;
}
nlohmann::json config_to_entry(const BaseConfig& config)
{
return nlohmann::json{
{KEY_PLUGIN, config.plugin_key},
{KEY_CAPABILITY, config.capability_name},
{KEY_VERSION, config.plugin_version},
{KEY_CAP_CONFIG, config.config},
};
}
// The version of the plugin package currently running. PluginDescriptor::version is
// overwritten with the latest cloud version when a cloud merge happens, so it can name a
// version that is not the one on disk; installed_version is what actually loaded.
std::string running_plugin_version(const std::string& plugin_key)
{
PluginDescriptor descriptor;
if (!PluginManager::instance().get_catalog().try_get_valid_plugin_descriptor(plugin_key, descriptor))
return {};
return descriptor.installed_version.empty() ? descriptor.version : descriptor.installed_version;
}
// Identifies the capability whose Python method is currently on the stack. Both halves are
// published by ScopedPluginAuditContext, which every C++ -> Python trampoline call opens.
// A call arriving without them did not come through a capability, so it has no config to
// address and we refuse it rather than reading or clobbering some other capability's entry.
std::pair<std::string, std::string> calling_capability(const char* api_name)
{
const PluginAuditManager& audit = PluginAuditManager::instance();
std::pair<std::string, std::string> id{audit.current_plugin(), audit.current_capability()};
if (id.first.empty() || id.second.empty())
throw std::runtime_error(std::string(api_name) + "() must be called from a plugin capability method");
return id;
}
} // namespace
void PluginConfig::load()
{
const std::string path = plugin_config_file();
std::lock_guard<std::mutex> lock(m_mutex);
m_storage.clear();
m_dirty = false;
boost::system::error_code ec;
if (!boost::filesystem::exists(path, ec))
return;
nlohmann::json root;
try {
boost::nowide::ifstream ifs(path.c_str());
ifs >> root;
} catch (const std::exception& err) {
BOOST_LOG_TRIVIAL(error) << "PluginConfig: cannot read " << path << ": " << err.what() << "; starting with an empty config";
return;
}
const auto entries = root.find(KEY_ENTRIES);
if (entries == root.end() || !entries->is_array()) {
BOOST_LOG_TRIVIAL(warning) << "PluginConfig: " << path << " has no \"" << KEY_ENTRIES << "\" array; starting with an empty config";
return;
}
for (const auto& entry : *entries) {
BaseConfig config;
if (!entry_to_config(entry, config)) {
BOOST_LOG_TRIVIAL(warning) << "PluginConfig: skipping entry without a plugin key and capability name";
continue;
}
m_storage[{config.plugin_key, config.capability_name}] = std::move(config);
}
}
void PluginConfig::save()
{
const std::string path = plugin_config_file();
std::lock_guard<std::mutex> lock(m_mutex);
nlohmann::json root;
root[KEY_ENTRIES] = nlohmann::json::array();
for (const auto& [id, config] : m_storage)
root[KEY_ENTRIES].push_back(config_to_entry(config));
boost::system::error_code ec;
boost::filesystem::create_directories(boost::filesystem::path(path).parent_path(), ec);
if (ec) {
BOOST_LOG_TRIVIAL(error) << "PluginConfig: cannot create the plugin directory: " << ec.message();
return;
}
// Write to a PID-suffixed file and rename it into place, so a crash mid-write cannot
// truncate an existing config. Same approach as AppConfig::save().
const std::string path_pid = (boost::format("%1%.%2%") % path % get_current_pid()).str();
boost::nowide::ofstream file;
file.open(path_pid, std::ios::out | std::ios::trunc);
file << root.dump(1, '\t') << std::endl;
file.close();
if (file.fail()) {
BOOST_LOG_TRIVIAL(error) << "PluginConfig: failed to write " << path_pid << "; keeping the existing config";
return;
}
if (const std::error_code rename_ec = rename_file(path_pid, path)) {
BOOST_LOG_TRIVIAL(error) << "PluginConfig: failed to move " << path_pid << " onto " << path << ": " << rename_ec.message();
return;
}
m_dirty = false;
}
void PluginConfig::save_config(const std::string& plugin_key,
const std::string& capability_name,
const std::string& version,
const nlohmann::json& config)
{ save_config({plugin_key, capability_name, version, config}); }
bool PluginConfig::save_config_text(const std::string& plugin_key,
const std::string& capability_name,
const std::string& version,
const std::string& config)
{
nlohmann::json parsed = nlohmann::json::parse(config, nullptr, /* allow_exceptions */ false);
if (parsed.is_discarded()) {
BOOST_LOG_TRIVIAL(error) << "PluginConfig: capability '" << capability_name << "' of plugin '" << plugin_key
<< "' supplied malformed JSON; config not saved";
return false;
}
save_config({plugin_key, capability_name, version, std::move(parsed)});
return true;
}
void PluginConfig::save_config(const BaseConfig& config)
{
if (config.empty()) {
BOOST_LOG_TRIVIAL(error) << "PluginConfig: refusing to store a config without a plugin key and capability name";
return;
}
std::lock_guard<std::mutex> lock(m_mutex);
m_storage[{config.plugin_key, config.capability_name}] = config;
m_dirty = true;
}
BaseConfig PluginConfig::get_config(const std::string& plugin_key, const std::string& capability_name) const
{
std::lock_guard<std::mutex> lock(m_mutex);
const auto it = m_storage.find({plugin_key, capability_name});
return it != m_storage.end() ? it->second : BaseConfig();
}
bool PluginConfig::has_config(const std::string& plugin_key, const std::string& capability_name) const
{
std::lock_guard<std::mutex> lock(m_mutex);
return m_storage.count({plugin_key, capability_name}) != 0;
}
bool PluginConfig::dirty() const
{
std::lock_guard<std::mutex> lock(m_mutex);
return m_dirty;
}
void PluginConfig::RegisterBindings(pybind11::module_& m)
{
namespace py = pybind11;
auto config_submodule = m.def_submodule("config", "Per-capability configuration storage");
// Config crosses this boundary as a JSON string, not a dict: the host does not care about
// the shape of cap_config, so it never builds Python objects out of it. Plugins hand the
// string to json.loads/json.dumps themselves.
config_submodule.def(
"get_config",
[]() -> py::object {
const auto [plugin_key, capability] = calling_capability("get_config");
const BaseConfig config = PluginManager::instance().get_config().get_config(plugin_key, capability);
if (config.empty())
return py::none();
return py::str(config_to_entry(config).dump());
},
R"pbdoc(Return this capability's stored config as a JSON string, or None if it has never been saved.
The object mirrors the on-disk entry: "plugin_key", "capability", "plugin_version" and
"cap_config". "plugin_version" is the version that last wrote the entry, so a plugin can
compare it against its own and migrate "cap_config" before use.)pbdoc");
config_submodule.def(
"save_config",
[](const std::string& cap_config) {
const auto [plugin_key, capability] = calling_capability("save_config");
return PluginManager::instance().get_config().save_config_text(plugin_key, capability, running_plugin_version(plugin_key),
cap_config);
},
py::arg("cap_config"),
R"pbdoc(Store this capability's config, given as a JSON string.
The plugin key, capability name and plugin version are supplied by the host. Returns False
without storing anything if `cap_config` is not valid JSON.)pbdoc");
}
} // namespace Slic3r

View File

@@ -1,117 +0,0 @@
#pragma once
#include <libslic3r/Utils.hpp>
#include <boost/filesystem.hpp>
#include <nlohmann/json.hpp>
#include <slic3r/plugin/PluginFsUtils.hpp>
#include <map>
#include <mutex>
#include <string>
#include <utility>
#define PLUGIN_CONFIG_DIR "config.json"
namespace pybind11 {
class module_;
}
namespace Slic3r {
/*
Example config.json shape
{
"config": [
{
"plugin_key": "some_name",
"capability": "capability_name",
"plugin_version": "1.0.0",
"cap_config": {
"some": "plugin",
"capability": "specific",
"stuff": "here"
}
},
{
"plugin_key": "some_name",
"capability": "capability_name",
"plugin_version": "1.0.0",
"cap_config": {
"some": "plugin",
"capability": "specific",
"stuff": "here"
}
},
{
"plugin_key": "some_name",
"capability": "capability_name",
"plugin_version": "1.0.0",
"cap_config": {
"some": "plugin",
"capability": "specific",
"stuff": "here"
}
},
]
}
*/
struct BaseConfig {
std::string plugin_key;
std::string capability_name;
std::string plugin_version;
nlohmann::json config;
// True for the default-constructed instance returned by get_config() on a miss.
bool empty() const { return plugin_key.empty() || capability_name.empty(); }
};
// Consolidated store for every plugin capability's configuration, persisted as a single
// config.json alongside the installed plugins. The shape of `cap_config` belongs to the
// plugin; this class only round-trips it.
//
// A capability is identified by (plugin_key, capability_name). `plugin_version` is metadata
// recording which version last wrote the entry, letting an upgraded plugin spot a stale
// config and migrate it. Version is deliberately not part of the identity, so upgrading a
// plugin does not silently reset the user's settings.
//
// Plugin code runs on worker threads, so every entry point is mutex-guarded.
class PluginConfig
{
public:
static const std::string plugin_config_file() { return (boost::filesystem::path(get_orca_plugins_dir()) / PLUGIN_CONFIG_DIR).string(); }
// Replaces the in-memory store with what is on disk. A missing or malformed file leaves
// the store empty rather than throwing: a bad plugin config must not block startup.
void load();
// Rewrites config.json atomically. Clears the dirty flag only once the file is in place.
void save();
void save_config(const std::string& plugin_key, const std::string& capability_name, const std::string& version, const nlohmann::json& config);
void save_config(const BaseConfig& config);
// Parses `config` as a JSON document, storing nothing and returning false if it is
// malformed. Spelled differently from save_config() on purpose: nlohmann::json converts
// implicitly from const char*, so a `save_config(..., "{}")` overload pair would be
// ambiguous, and a raw string would silently store as a JSON string rather than an object.
bool save_config_text(const std::string& plugin_key, const std::string& capability_name, const std::string& version, const std::string& config);
// Returns a default-constructed BaseConfig (see BaseConfig::empty) when the capability has
// no stored config.
BaseConfig get_config(const std::string& plugin_key, const std::string& capability_name) const;
bool has_config(const std::string& plugin_key, const std::string& capability_name) const;
bool dirty() const;
static void RegisterBindings(pybind11::module_& module);
private:
// (plugin_key, capability_name) -> entry. Ordered, so config.json serializes stably.
using CapabilityId = std::pair<std::string, std::string>;
mutable std::mutex m_mutex;
std::map<CapabilityId, BaseConfig> m_storage;
bool m_dirty = false;
};
} // namespace Slic3r

View File

@@ -4,6 +4,7 @@
#include <algorithm>
#include <cctype>
#include <map>
#include <optional>
#include <string>
#include <utility>
@@ -61,6 +62,7 @@ struct PluginDescriptor
std::string entry_path; // Full path to the installed plugin entry file
std::string entry_package; // Import package/module used for package-based loading
std::vector<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::string error; // Blocking error message. Non-empty means the plugin is in an error state.

View File

@@ -13,16 +13,10 @@ namespace Slic3r {
const char* const INSTALL_STATE_FILE = ".install_state.json";
std::string get_orca_plugins_dir()
{
namespace fs = boost::filesystem;
return (fs::path(data_dir()) / "orca_plugins").string();
}
std::string get_cloud_plugin_dir(const std::string& user_id)
{
namespace fs = boost::filesystem;
return (fs::path(get_orca_plugins_dir()) / PLUGIN_SUBSCRIBED_DIR / user_id).string();
return (fs::path(data_dir()) / "orca_plugins" / PLUGIN_SUBSCRIBED_DIR / user_id).string();
}
boost::filesystem::path resolve_plugin_root_from_descriptor(const PluginDescriptor& descriptor)
@@ -36,7 +30,8 @@ boost::filesystem::path resolve_plugin_root_from_descriptor(const PluginDescript
return {};
}
bool is_plugin_root_allowed(const boost::filesystem::path& candidate_root, const std::vector<std::string>& allowed_dirs)
bool is_plugin_root_allowed(const boost::filesystem::path& candidate_root,
const std::vector<std::string>& allowed_dirs)
{
boost::system::error_code ec;
boost::filesystem::path resolved_root = boost::filesystem::weakly_canonical(candidate_root, ec);
@@ -49,7 +44,8 @@ bool is_plugin_root_allowed(const boost::filesystem::path& candidate_root, const
return false;
for (const auto& allowed_dir : allowed_dirs) {
if (is_inside_allowed_root(std::filesystem::path(resolved_root.string()), std::filesystem::path(allowed_dir)))
if (is_inside_allowed_root(std::filesystem::path(resolved_root.string()),
std::filesystem::path(allowed_dir)))
return true;
}
@@ -89,7 +85,9 @@ bool resolve_allowed_plugin_root(const PluginDescriptor& descriptor,
return true;
}
bool delete_plugin_root(const boost::filesystem::path& resolved_root, const std::string& plugin_id, std::string& error)
bool delete_plugin_root(const boost::filesystem::path& resolved_root,
const std::string& plugin_id,
std::string& error)
{
namespace fs = boost::filesystem;

View File

@@ -17,8 +17,6 @@ extern const char* const INSTALL_STATE_FILE;
// Path: {data_dir}/orca_plugins/_subscribed/{user_id}/
std::string get_cloud_plugin_dir(const std::string& user_id);
std::string get_orca_plugins_dir();
boost::filesystem::path resolve_plugin_root_from_descriptor(const PluginDescriptor& descriptor);
bool is_plugin_root_allowed(const boost::filesystem::path& candidate_root,

View File

@@ -0,0 +1,129 @@
#include "PluginHooks.hpp"
#include "PluginManager.hpp"
#include "PythonInterpreter.hpp"
#include "PythonPluginInterface.hpp"
#include "pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.hpp"
#include "libslic3r/Config.hpp"
#include "libslic3r/Exception.hpp"
#include "libslic3r/Print.hpp"
#include "libslic3r_version.h"
#include <boost/log/trivial.hpp>
#include <memory>
#include <string>
namespace Slic3r::plugin_hooks {
namespace {
// Manifest resolver: turns the bare capability name a preset stores into the full
// "name;uuid;capability" reference the dispatchers consume (see
// ConfigBase::collect_plugin_manifest / update_plugin_manifest).
void install_capability_resolver()
{
ConfigBase::set_resolve_capability_fn([](const std::string& cap_name, const std::string& cap_type) {
PluginManager& plugin_mgr = PluginManager::instance();
auto plugin_cap = plugin_mgr.get_loader().try_get_plugin_capability_by_name_and_type(cap_name, plugin_capability_type_from_string(cap_type));
if (!plugin_cap)
return std::string();
PluginDescriptor descriptor;
if (!plugin_mgr.get_catalog().try_get_plugin_descriptor(plugin_cap->plugin_key, descriptor))
return std::string();
// Cloud plugins are resolved at runtime via the UUID in the middle field, so the first
// field keeps the friendly display name. Local plugins are looked up by plugin_key (the
// first field, with an empty UUID), so emit the plugin_key to keep them resolvable.
const std::string identity = descriptor.is_cloud_plugin() ? descriptor.name : descriptor.plugin_key;
return identity + ';' + descriptor.cloud_uuid() + ';' + cap_name;
});
}
// Print::process() fires this hook at each pipeline seam on the slicing worker
// thread; here we run the picker-selected SlicingPipeline capabilities. Per
// capability we acquire the GIL, honor cancellation, and convert a plugin
// failure into a (non-critical) SlicingError so it surfaces as a slicing-error
// notification rather than the fatal-crash dialog.
void install_slicing_pipeline_hook()
{
Print::set_slicing_pipeline_hook_fn(
[](Print& print, const PrintObject* object, SlicingPipelineStepPlugin step) {
const auto* caps = print.config().option<ConfigOptionStrings>("slicing_pipeline_plugin");
// `plugins` is a dynamic-only manifest key (not a static PrintConfig member), so it
// must be read from the full/dynamic config -- reading it off print.config() (the
// static PrintConfig) always yields nullptr and skips every capability. Mirrors the
// post-process path (PostProcessor.cpp, via BackgroundSlicingProcess::full_print_config()).
const auto* plugs = print.full_print_config().option<ConfigOptionStrings>("plugins");
if (caps == nullptr || caps->values.empty())
return;
execute_capabilities_from_refs<SlicingPipelinePluginCapability>(
*caps, plugs, PluginCapabilityType::SlicingPipeline,
[&](std::shared_ptr<SlicingPipelinePluginCapability> cap, const PluginCapabilityRef& ref) {
ExecutionResult r;
try {
// GIL is acquired per capability (not once for the whole dispatch) so it
// is released between capabilities.
PythonGILState gil;
// throw_if_canceled() is protected on PrintBase; canceled() is the public
// equivalent check (same cancel flag), so honor cancellation via it.
if (print.canceled())
throw CanceledException();
SlicingPipelineContext ctx;
ctx.orca_version = SoftFever_VERSION;
ctx.step = step;
ctx.print = &print;
ctx.object = object;
// hand the plugin its own [tool.orcaslicer.plugin.settings] as ctx.params
// (same plugin_key the capability was resolved by, so it always matches).
const std::string plugin_key = ref.uuid.empty() ? ref.name : ref.uuid;
ctx.params = PluginManager::instance().get_loader().get_plugin_settings(plugin_key);
r = cap->execute(ctx);
} catch (const CanceledException&) {
throw; // cancellation must reach process(), never become a slicing error
} catch (const std::exception& ex) {
// A Python raise reaches here as pybind11::error_already_set; surface it as a
// (non-critical) slicing error instead of a crash.
throw SlicingError(std::string("Slicing pipeline plugin '") +
ref.capability_name + "' error: " + ex.what());
}
if (r.status == PluginResult::FatalError)
throw SlicingError(std::string("Slicing pipeline plugin '") +
ref.capability_name + "' error: " + r.message);
// log a non-empty success/skipped message instead of dropping it. This is
// log-only by design: every pipeline hook fires AFTER set_done() (see Print.cpp),
// so the Print-level m_step_active is -1 here. Calling active_step_add_warning()
// would then index m_state[-1] (out-of-bounds; the guarding assert is compiled
// out in Release), so it must NOT be called from a pipeline hook.
if (!r.message.empty()) {
static const char* const kStepNames[] = {
"posSlice", "posPerimeters", "posEstimateCurledExtrusions", "posPrepareInfill", "posInfill",
"posIroning", "posContouring", "posSupportMaterial", "posDetectOverhangsForLift",
"posSimplifyPath", "psWipeTower", "psSkirtBrim", "psGCodePostProcess"
}; // order must match SlicingPipelineStepPlugin
const char* step_name = static_cast<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;
}
});
});
}
} // namespace
void install()
{
install_capability_resolver();
install_slicing_pipeline_hook();
}
void uninstall()
{
ConfigBase::set_resolve_capability_fn(nullptr);
Print::set_slicing_pipeline_hook_fn(nullptr);
}
} // namespace Slic3r::plugin_hooks

View File

@@ -0,0 +1,21 @@
#pragma once
// The plugin layer's installers for the hooks libslic3r exposes. libslic3r
// stays free of any plugin/Python dependency: it exposes static setter seams
// (ConfigBase::set_resolve_capability_fn, Print::set_slicing_pipeline_hook_fn,
// ...) and this unit injects the dispatchers -- one file-local installer per
// hook, aggregated by install(). Capabilities dispatched from the GUI layer
// (e.g. PostProcessor.cpp) call execute_capabilities_from_refs at their own
// call site and need no hook here.
namespace Slic3r::plugin_hooks {
// Install every hook. Called once from PluginManager::initialize().
void install();
// Reset every hook to null so none can enter Python after the interpreter
// finalizes. Called from PluginManager::shutdown(); callers must have stopped
// background slicing first (resetting a hook while process() runs is a race).
void uninstall();
} // namespace Slic3r::plugin_hooks

View File

@@ -1,535 +0,0 @@
#include "PluginHostApi.hpp"
#include "PluginHostUi.hpp"
#include <libslic3r/BoundingBox.hpp>
#include <libslic3r/Model.hpp>
#include <libslic3r/Preset.hpp>
#include <libslic3r/PresetBundle.hpp>
#include <libslic3r/TriangleMesh.hpp>
#include <slic3r/GUI/GUI_App.hpp>
#include <slic3r/GUI/Plater.hpp>
#include <pybind11/numpy.h>
#include <pybind11/stl.h>
#include <cstdint>
#include <memory>
#include <stdexcept>
#include <vector>
namespace py = pybind11;
namespace Slic3r {
namespace {
GUI::Plater* current_plater()
{
if (wxTheApp == nullptr)
throw std::runtime_error("OrcaSlicer application is not initialized");
GUI::Plater* plater = GUI::wxGetApp().plater();
if (plater == nullptr)
throw std::runtime_error("Plater is not available");
return plater;
}
PresetBundle* current_preset_bundle()
{
if (wxTheApp == nullptr)
throw std::runtime_error("OrcaSlicer application is not initialized");
PresetBundle* preset_bundle = GUI::wxGetApp().preset_bundle;
if (preset_bundle == nullptr)
throw std::runtime_error("Preset bundle is not available");
return preset_bundle;
}
py::object config_value_or_none(const DynamicPrintConfig& config, const std::string& key)
{
if (!config.has(key))
return py::none();
return py::cast(config.opt_serialize(key));
}
// Plugins receive 3D vectors as plain Python tuples (x, y, z) so the API stays
// Pythonic and free of an Eigen/numpy runtime dependency.
py::tuple vec3_to_tuple(const Vec3d& v)
{
return py::make_tuple(v.x(), v.y(), v.z());
}
// Build a BoundingBoxf3 from precomputed (float) triangle-mesh stats min/max.
BoundingBoxf3 bbox_from_stats(const TriangleMeshStats& stats)
{
if (stats.number_of_facets == 0)
return BoundingBoxf3();
return BoundingBoxf3(stats.min.cast<double>(), stats.max.cast<double>());
}
// --- Mesh geometry helpers -------------------------------------------------
// Zero-copy export of its.vertices / its.indices relies on these Eigen
// row-vectors being tightly packed (no padding between the 3 components).
static_assert(sizeof(stl_vertex) == 3 * sizeof(float),
"stl_vertex must be a packed float[3] for zero-copy numpy export");
static_assert(sizeof(stl_triangle_vertex_indices) == 3 * sizeof(std::int32_t),
"triangle index must be a packed int32[3] for zero-copy numpy export");
// Immutable snapshot of a ModelVolume's mesh. Holding a strong reference to the
// const mesh keeps any zero-copy numpy views valid even if the volume's mesh is
// later replaced on the main thread.
struct HostTriangleMesh
{
std::shared_ptr<const TriangleMesh> mesh;
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.
// The array owns a capsule that pins `mesh` alive for the view's lifetime.
template<typename T>
py::array make_readonly_rows3(const std::shared_ptr<const TriangleMesh>& mesh,
const T* data, py::ssize_t rows)
{
if (rows == 0 || data == nullptr)
return py::array_t<T>(std::vector<py::ssize_t>{0, 3});
auto* owner = new std::shared_ptr<const TriangleMesh>(mesh);
py::capsule base(owner, [](void* p) {
delete reinterpret_cast<std::shared_ptr<const TriangleMesh>*>(p);
});
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 presets;
for (const std::string& preset_name : bundle.filament_presets) {
Preset* preset = bundle.filaments.find_preset(preset_name);
if (preset == nullptr)
presets.append(py::none());
else
presets.append(py::cast(preset, py::return_value_policy::reference));
}
return presets;
}
PresetCollection& printer_presets(PresetBundle& bundle)
{
return static_cast<PresetCollection&>(bundle.printers);
}
} // namespace
void PluginHostApi::RegisterBindings(pybind11::module_& module)
{
auto host = module.def_submodule("host", "Host application API");
py::enum_<Preset::Type>(host, "PresetType")
.value("Invalid", Preset::TYPE_INVALID)
.value("Print", Preset::TYPE_PRINT)
.value("SlaPrint", Preset::TYPE_SLA_PRINT)
.value("Filament", Preset::TYPE_FILAMENT)
.value("SlaMaterial", Preset::TYPE_SLA_MATERIAL)
.value("Printer", Preset::TYPE_PRINTER)
.value("PhysicalPrinter", Preset::TYPE_PHYSICAL_PRINTER)
.value("Plate", Preset::TYPE_PLATE)
.value("Model", Preset::TYPE_MODEL);
py::class_<Preset, std::unique_ptr<Preset, py::nodelete>>(host, "Preset")
.def_readonly("type", &Preset::type)
.def_readonly("name", &Preset::name)
.def_readonly("alias", &Preset::alias)
.def_readonly("file", &Preset::file)
.def_readonly("is_default", &Preset::is_default)
.def_readonly("is_external", &Preset::is_external)
.def_readonly("is_system", &Preset::is_system)
.def_readonly("is_visible", &Preset::is_visible)
.def_readonly("is_dirty", &Preset::is_dirty)
.def_readonly("is_compatible", &Preset::is_compatible)
.def_readonly("is_project_embedded", &Preset::is_project_embedded)
.def_readonly("bundle_id", &Preset::bundle_id)
.def("is_user", &Preset::is_user)
.def("is_from_bundle", &Preset::is_from_bundle)
.def("label", &Preset::label, py::arg("no_alias") = false)
.def("config_keys", [](const Preset& preset) { return preset.config.keys(); })
.def("config_value", [](const Preset& preset, const std::string& key) {
return config_value_or_none(preset.config, key);
});
py::class_<PresetCollection, std::unique_ptr<PresetCollection, py::nodelete>>(host, "PresetCollection")
.def("size", &PresetCollection::size)
.def("get_selected_preset", [](PresetCollection& collection) -> Preset& {
return collection.get_selected_preset();
}, py::return_value_policy::reference_internal)
.def("selected_preset", [](PresetCollection& collection) -> Preset& {
return collection.get_selected_preset();
}, py::return_value_policy::reference_internal)
.def("get_selected_preset_name", &PresetCollection::get_selected_preset_name)
.def("selected_preset_name", &PresetCollection::get_selected_preset_name)
.def("get_edited_preset", [](PresetCollection& collection) -> Preset& {
return collection.get_edited_preset();
}, py::return_value_policy::reference_internal)
.def("edited_preset", [](PresetCollection& collection) -> Preset& {
return collection.get_edited_preset();
}, py::return_value_policy::reference_internal)
.def("preset", [](PresetCollection& collection, size_t index) -> Preset& {
if (index >= collection.size())
throw py::index_error("preset index out of range");
return collection.preset(index);
}, py::return_value_policy::reference_internal)
.def("find_preset", [](PresetCollection& collection, const std::string& name) -> Preset* {
return collection.find_preset(name);
}, py::return_value_policy::reference_internal)
.def("preset_names", [](const PresetCollection& collection) {
std::vector<std::string> names;
names.reserve(collection.get_presets().size());
for (const Preset& preset : collection.get_presets())
names.push_back(preset.name);
return names;
});
py::class_<PresetBundle, std::unique_ptr<PresetBundle, py::nodelete>>(host, "PresetBundle")
.def_property_readonly("prints", [](PresetBundle& bundle) -> PresetCollection& {
return bundle.prints;
}, py::return_value_policy::reference_internal)
.def_property_readonly("printers", &printer_presets, py::return_value_policy::reference_internal)
.def_property_readonly("filaments", [](PresetBundle& bundle) -> PresetCollection& {
return bundle.filaments;
}, py::return_value_policy::reference_internal)
.def_property_readonly("sla_prints", [](PresetBundle& bundle) -> PresetCollection& {
return bundle.sla_prints;
}, py::return_value_policy::reference_internal)
.def_property_readonly("sla_materials", [](PresetBundle& bundle) -> PresetCollection& {
return bundle.sla_materials;
}, py::return_value_policy::reference_internal)
.def("current_process_preset", [](PresetBundle& bundle) -> Preset& {
return bundle.prints.get_edited_preset();
}, py::return_value_policy::reference_internal)
.def("current_print_preset", [](PresetBundle& bundle) -> Preset& {
return bundle.prints.get_edited_preset();
}, py::return_value_policy::reference_internal)
.def("current_printer_preset", [](PresetBundle& bundle) -> Preset& {
return bundle.printers.get_edited_preset();
}, py::return_value_policy::reference_internal)
.def("current_filament_preset_names", [](PresetBundle& bundle) {
return bundle.filament_presets;
})
.def("current_filament_presets", &current_filament_presets)
.def("full_config_keys", [](const PresetBundle& bundle) {
return bundle.full_config().keys();
})
.def("full_config_value", [](const PresetBundle& bundle, const std::string& key) {
return config_value_or_none(bundle.full_config(), key);
});
// Axis-aligned bounding box, returned by value (a copy) so its lifetime is
// independent of the model object it was computed from. Coordinates are in mm.
py::class_<BoundingBoxf3>(host, "BoundingBox", "Axis-aligned bounding box in millimetres")
.def_property_readonly("defined", [](const BoundingBoxf3& bb) { return bb.defined; })
.def_property_readonly("min", [](const BoundingBoxf3& bb) { return vec3_to_tuple(bb.min); })
.def_property_readonly("max", [](const BoundingBoxf3& bb) { return vec3_to_tuple(bb.max); })
.def_property_readonly("size", [](const BoundingBoxf3& bb) { return vec3_to_tuple(bb.size()); })
.def_property_readonly("center", [](const BoundingBoxf3& bb) { return vec3_to_tuple(bb.center()); })
.def_property_readonly("radius", [](const BoundingBoxf3& bb) { return bb.radius(); });
py::class_<HostTriangleMesh>(host, "TriangleMesh",
"Immutable snapshot of a ModelVolume's mesh in local (untransformed) coordinates, mm.")
.def("vertex_count", [](const HostTriangleMesh& mesh) { return mesh.its().vertices.size(); })
.def("triangle_count", [](const HostTriangleMesh& mesh) { return mesh.its().indices.size(); })
.def("facets_count", [](const HostTriangleMesh& mesh) { return mesh.its().indices.size(); })
.def("is_empty", [](const HostTriangleMesh& mesh) { return mesh.its().indices.empty(); })
// Read-only, zero-copy (N, 3) float32 view of vertex positions. Requires numpy.
.def("vertices", [](const HostTriangleMesh& mesh) {
return with_numpy([&] {
const indexed_triangle_set& its = mesh.its();
return make_readonly_rows3<float>(
mesh.mesh,
its.vertices.empty() ? nullptr : its.vertices.front().data(),
static_cast<py::ssize_t>(its.vertices.size()));
});
}, "Read-only zero-copy (N, 3) float32 ndarray of vertex positions (local mm). Requires numpy.")
// Read-only, zero-copy (M, 3) int32 view of triangle vertex indices. Requires numpy.
.def("triangles", [](const HostTriangleMesh& mesh) {
return with_numpy([&] {
const indexed_triangle_set& its = mesh.its();
return make_readonly_rows3<std::int32_t>(
mesh.mesh,
its.indices.empty() ? nullptr : its.indices.front().data(),
static_cast<py::ssize_t>(its.indices.size()));
});
}, "Read-only zero-copy (M, 3) int32 ndarray of triangle vertex indices. Requires numpy.")
// One normalized normal per triangle as an (M, 3) float32 copy. Requires numpy.
.def("face_normals", [](const HostTriangleMesh& mesh) {
return with_numpy([&] {
std::vector<Vec3f> normals = its_face_normals(mesh.its());
py::array_t<float> array({ static_cast<py::ssize_t>(normals.size()), py::ssize_t(3) });
if (!normals.empty()) {
auto view = array.mutable_unchecked<2>();
for (size_t i = 0; i < normals.size(); ++i) {
view(i, 0) = normals[i].x();
view(i, 1) = normals[i].y();
view(i, 2) = normals[i].z();
}
}
return py::object(std::move(array));
});
}, "Per-triangle normalized normals as an (M, 3) float32 ndarray (copy). Requires numpy.")
// numpy-free element access, bounds-checked.
.def("vertex", [](const HostTriangleMesh& mesh, size_t index) {
const std::vector<stl_vertex>& vertices = mesh.its().vertices;
if (index >= vertices.size())
throw py::index_error("vertex index out of range");
const stl_vertex& vertex = vertices[index];
return py::make_tuple(vertex.x(), vertex.y(), vertex.z());
})
.def("triangle", [](const HostTriangleMesh& mesh, size_t index) {
const std::vector<stl_triangle_vertex_indices>& indices = mesh.its().indices;
if (index >= indices.size())
throw py::index_error("triangle index out of range");
const stl_triangle_vertex_indices& triangle = indices[index];
return py::make_tuple(triangle[0], triangle[1], triangle[2]);
})
.def("volume", [](const HostTriangleMesh& mesh) { return mesh.mesh->stats().volume; })
.def("bounding_box", [](const HostTriangleMesh& mesh) { return bbox_from_stats(mesh.mesh->stats()); })
.def("is_manifold", [](const HostTriangleMesh& mesh) { return mesh.mesh->stats().manifold(); });
py::enum_<ModelVolumeType>(host, "ModelVolumeType")
.value("Invalid", ModelVolumeType::INVALID)
.value("ModelPart", ModelVolumeType::MODEL_PART)
.value("NegativeVolume", ModelVolumeType::NEGATIVE_VOLUME)
.value("ParameterModifier", ModelVolumeType::PARAMETER_MODIFIER)
.value("SupportBlocker", ModelVolumeType::SUPPORT_BLOCKER)
.value("SupportEnforcer", ModelVolumeType::SUPPORT_ENFORCER);
py::class_<ModelVolume, std::unique_ptr<ModelVolume, py::nodelete>>(host, "ModelVolume")
.def("id", [](const ModelVolume& volume) { return volume.id().id; })
.def_readonly("name", &ModelVolume::name)
.def("type", &ModelVolume::type)
.def("is_model_part", &ModelVolume::is_model_part)
.def("is_modifier", &ModelVolume::is_modifier)
.def("is_negative_volume", &ModelVolume::is_negative_volume)
.def("is_support_enforcer", &ModelVolume::is_support_enforcer)
.def("is_support_blocker", &ModelVolume::is_support_blocker)
.def("is_support_modifier", &ModelVolume::is_support_modifier)
// Extruder ID is 1-based for FFF, -1 for SLA or support volumes.
.def("extruder_id", &ModelVolume::extruder_id)
.def("offset", [](const ModelVolume& volume) { return vec3_to_tuple(volume.get_offset()); })
.def("rotation", [](const ModelVolume& volume) { return vec3_to_tuple(volume.get_rotation()); })
.def("scaling_factor", [](const ModelVolume& volume) { return vec3_to_tuple(volume.get_scaling_factor()); })
.def("mirror", [](const ModelVolume& volume) { return vec3_to_tuple(volume.get_mirror()); })
// 4x4 float64 affine matrix mapping this volume into its parent object frame. Requires numpy.
.def("matrix", [](const ModelVolume& volume) { return mat4_to_numpy(volume.get_matrix()); },
"Volume-to-object 4x4 float64 affine matrix (copy). Requires numpy.")
.def("facets_count", [](const ModelVolume& volume) { return volume.mesh().facets_count(); })
// Raw (untransformed) mesh volume in mm^3; -1 if it was never computed.
.def("volume", [](const ModelVolume& volume) { return volume.mesh().stats().volume; })
// Bounding box of the raw (untransformed) mesh, in the volume's local frame.
.def("bounding_box", [](const ModelVolume& volume) { return bbox_from_stats(volume.mesh().stats()); })
.def("is_manifold", [](const ModelVolume& volume) { return volume.mesh().stats().manifold(); })
// Full mesh geometry (vertices/triangles) as an immutable snapshot.
.def("mesh", [](const ModelVolume& volume) {
return HostTriangleMesh{ volume.get_mesh_shared_ptr() };
}, "Return the volume's TriangleMesh (local coordinates) for vertex/triangle access.")
.def("mesh_errors_count", [](const ModelVolume& volume) { return volume.get_repaired_errors_count(); })
.def("is_fdm_support_painted", &ModelVolume::is_fdm_support_painted)
.def("is_seam_painted", &ModelVolume::is_seam_painted)
.def("is_mm_painted", &ModelVolume::is_mm_painted)
.def("is_fuzzy_skin_painted", &ModelVolume::is_fuzzy_skin_painted)
.def("config_keys", [](const ModelVolume& volume) { return volume.config.keys(); })
.def("config_value", [](const ModelVolume& volume, const std::string& key) {
return config_value_or_none(volume.config.get(), key);
});
py::class_<ModelInstance, std::unique_ptr<ModelInstance, py::nodelete>>(host, "ModelInstance")
.def("id", [](const ModelInstance& instance) { return instance.id().id; })
.def_readonly("printable", &ModelInstance::printable)
// True only if the object is printable, this instance is printable and it
// currently sits fully inside the print volume (set during slicing).
.def("is_printable", &ModelInstance::is_printable)
.def("offset", [](const ModelInstance& instance) { return vec3_to_tuple(instance.get_offset()); })
.def("rotation", [](const ModelInstance& instance) { return vec3_to_tuple(instance.get_rotation()); })
.def("scaling_factor", [](const ModelInstance& instance) { return vec3_to_tuple(instance.get_scaling_factor()); })
.def("mirror", [](const ModelInstance& instance) { return vec3_to_tuple(instance.get_mirror()); })
// 4x4 float64 affine matrix mapping the object into world space. Requires numpy.
// World vertices = instance.matrix() @ volume.matrix() applied to mesh vertices.
.def("matrix", [](const ModelInstance& instance) { return mat4_to_numpy(instance.get_matrix()); },
"Object-to-world 4x4 float64 affine matrix (copy). Requires numpy.")
.def("is_left_handed", &ModelInstance::is_left_handed)
// Assemble-view placement. Each instance carries a second transform used only by
// the Assemble view, set from stored 3mf assemble data or derived from the regular
// transform. Until then (is_assemble_initialized() false) it is identity.
.def("is_assemble_initialized", [](ModelInstance& instance) { return instance.is_assemble_initialized(); })
.def("assemble_offset", [](const ModelInstance& instance) {
return vec3_to_tuple(instance.get_assemble_transformation().get_offset());
})
.def("assemble_rotation", [](const ModelInstance& instance) {
return vec3_to_tuple(instance.get_assemble_transformation().get_rotation());
})
// 4x4 float64 affine matrix placing the object in the Assemble view. Requires numpy.
.def("assemble_matrix", [](const ModelInstance& instance) {
return mat4_to_numpy(instance.get_assemble_transformation().get_matrix());
}, "Assemble-view 4x4 float64 affine matrix (copy). Requires numpy.")
// Offset from the instance origin to its position within the source assembly,
// recorded at import time (e.g. from a STEP assembly).
.def("offset_to_assembly", [](const ModelInstance& instance) {
return vec3_to_tuple(instance.get_offset_to_assembly());
})
// World-space bounding box of this instance.
.def("bounding_box", [](ModelInstance& instance) {
const ModelObject* object = instance.get_object();
if (object == nullptr)
return BoundingBoxf3();
return object->instance_bounding_box(instance);
});
py::class_<ModelObject, std::unique_ptr<ModelObject, py::nodelete>>(host, "ModelObject")
.def("id", [](const ModelObject& object) { return object.id().id; })
.def_readonly("name", &ModelObject::name)
.def_readonly("module_name", &ModelObject::module_name)
.def_readonly("input_file", &ModelObject::input_file)
// Import-time flag only: the GUI's printable toggle writes the per-instance
// ModelInstance::printable and never updates this field, so derive an
// object's effective state from its instances.
.def_readonly("printable", &ModelObject::printable)
.def("instance_count", [](const ModelObject& object) {
return object.instances.size();
})
.def("volume_count", [](const ModelObject& object) {
return object.volumes.size();
})
.def("instances", [](ModelObject& object) {
py::list instances;
for (ModelInstance* instance : object.instances)
instances.append(py::cast(instance, py::return_value_policy::reference));
return instances;
})
.def("instance", [](ModelObject& object, size_t index) -> ModelInstance* {
if (index >= object.instances.size())
throw py::index_error("instance index out of range");
return object.instances[index];
}, py::return_value_policy::reference_internal)
.def("volumes", [](ModelObject& object) {
py::list volumes;
for (ModelVolume* volume : object.volumes)
volumes.append(py::cast(volume, py::return_value_policy::reference));
return volumes;
})
.def("volume", [](ModelObject& object, size_t index) -> ModelVolume* {
if (index >= object.volumes.size())
throw py::index_error("volume index out of range");
return object.volumes[index];
}, py::return_value_policy::reference_internal)
// World-space bounding box over all instances of this object.
.def("bounding_box", [](const ModelObject& object) { return object.bounding_box_exact(); })
// Bounding box of the object's raw (untransformed) part meshes — its intrinsic size.
.def("raw_mesh_bounding_box", [](const ModelObject& object) { return object.raw_mesh_bounding_box(); })
.def("min_z", &ModelObject::min_z)
.def("max_z", &ModelObject::max_z)
.def("facets_count", [](const ModelObject& object) { return object.facets_count(); })
.def("parts_count", [](const ModelObject& object) { return object.parts_count(); })
.def("materials_count", [](const ModelObject& object) { return object.materials_count(); })
.def("mesh_errors_count", [](const ModelObject& object) { return object.get_repaired_errors_count(); })
.def("is_multiparts", &ModelObject::is_multiparts)
.def("is_cut", &ModelObject::is_cut)
.def("has_custom_layering", &ModelObject::has_custom_layering)
.def("is_fdm_support_painted", &ModelObject::is_fdm_support_painted)
.def("is_seam_painted", &ModelObject::is_seam_painted)
.def("is_mm_painted", &ModelObject::is_mm_painted)
.def("is_fuzzy_skin_painted", &ModelObject::is_fuzzy_skin_painted)
.def("config_keys", [](const ModelObject& object) {
return object.config.keys();
})
.def("config_value", [](const ModelObject& object, const std::string& key) {
return config_value_or_none(object.config.get(), key);
});
py::class_<Model, std::unique_ptr<Model, py::nodelete>>(host, "Model")
.def("id", [](const Model& model) { return model.id().id; })
.def("object_count", [](const Model& model) {
return model.objects.size();
})
.def("object", [](Model& model, size_t index) -> ModelObject* {
if (index >= model.objects.size())
throw py::index_error("model object index out of range");
return model.objects[index];
}, py::return_value_policy::reference_internal)
.def("objects", [](Model& model) {
py::list objects;
for (ModelObject* object : model.objects)
objects.append(py::cast(object, py::return_value_policy::reference));
return objects;
})
// World-space bounding box of the whole model. bounding_box() is exact;
// bounding_box_approx() is faster and cached.
.def("bounding_box", [](const Model& model) { return model.bounding_box_exact(); })
.def("bounding_box_approx", [](const Model& model) { return model.bounding_box_approx(); })
.def("max_z", &Model::max_z)
.def("material_count", [](const Model& model) { return model.materials.size(); })
.def("is_fdm_support_painted", &Model::is_fdm_support_painted)
.def("is_seam_painted", &Model::is_seam_painted)
.def("is_mm_painted", &Model::is_mm_painted)
.def("is_fuzzy_skin_painted", &Model::is_fuzzy_skin_painted)
.def("current_plate_index", [](const Model& model) { return model.curr_plate_index; })
.def("designer", [](const Model& model) {
return model.design_info ? model.design_info->Designer : std::string();
})
.def("design_id", [](const Model& model) { return model.stl_design_id; });
py::class_<GUI::Plater, std::unique_ptr<GUI::Plater, py::nodelete>>(host, "Plater")
.def("model", static_cast<Model& (GUI::Plater::*)()>(&GUI::Plater::model), py::return_value_policy::reference_internal)
.def("is_project_dirty", &GUI::Plater::is_project_dirty)
.def("is_presets_dirty", &GUI::Plater::is_presets_dirty)
.def("inside_snapshot_capture", &GUI::Plater::inside_snapshot_capture);
host.def("plater", &current_plater, py::return_value_policy::reference);
host.def("model", []() -> Model& {
return current_plater()->model();
}, py::return_value_policy::reference);
host.def("preset_bundle", &current_preset_bundle, py::return_value_policy::reference);
// UI: native dialogs and interactive HTML windows for plugins.
PluginHostUi::RegisterBindings(host);
}
} // namespace Slic3r

View File

@@ -1,13 +0,0 @@
#pragma once
#include <pybind11/pybind11.h>
namespace Slic3r {
class PluginHostApi
{
public:
static void RegisterBindings(pybind11::module_& module);
};
} // namespace Slic3r

View File

@@ -59,7 +59,7 @@ std::string plugin_package_extension(const boost::filesystem::path& path)
boost::filesystem::path local_plugin_root()
{
return boost::filesystem::path(get_orca_plugins_dir());
return boost::filesystem::path(data_dir()) / "orca_plugins";
}
boost::filesystem::path local_plugin_install_dir(const boost::filesystem::path& source_path)
@@ -261,6 +261,13 @@ std::shared_ptr<LoadedPluginCapability> PluginLoader::get_plugin_capability_by_n
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::lock_guard<std::mutex> lock(m_mutex);
@@ -603,7 +610,6 @@ bool PluginLoader::unload_plugin(const std::string& plugin_key, PluginCapability
if (!torn_down_types.insert(cap_type).second)
continue;
switch (cap_type) {
case PluginCapabilityType::PostProcessing: break;
case PluginCapabilityType::PrinterConnection: NetworkAgentFactory::deregister_python_plugin(plugin_key); break;
default: break;
}
@@ -973,7 +979,6 @@ void PluginLoader::load_plugin_impl(PluginCatalog& catalog, const std::string& p
}
loaded_cap->instance->set_audit_plugin_key(descriptor.plugin_key);
loaded_cap->instance->set_audit_capability_name(loaded_cap->name);
capability_types.push_back(loaded_cap->type);
loaded.capabilities.push_back(capability_id);
capabilities.emplace_back(std::move(loaded_cap));

View File

@@ -104,6 +104,8 @@ public:
std::chrono::milliseconds timeout,
std::string& error) 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.

View File

@@ -1,12 +1,11 @@
#include "PluginManager.hpp"
#include "slic3r/GUI/GUI_App.hpp"
#include <boost/filesystem/path.hpp>
#include <libslic3r/Utils.hpp>
#include <pybind11/embed.h>
#include "PythonPluginBridge.hpp"
#include "PluginFsUtils.hpp"
#include "PluginHooks.hpp"
#include "PythonFileUtils.hpp"
#include <boost/log/trivial.hpp>
@@ -122,6 +121,10 @@ bool PluginManager::initialize()
m_initialized = true;
// Install the libslic3r hooks (capability resolver, slicing-pipeline
// dispatcher). Uninstalled in shutdown() before the interpreter finalizes.
plugin_hooks::install();
// Persist auto-load / capability state to each plugin's .install_state.json sidecar.
// On load: write enabled=true plus current capability flags. On unload: flip enabled=false.
// The on-unload callback is skipped during shutdown (run_on_unload_callbacks is gated by
@@ -156,6 +159,10 @@ void PluginManager::shutdown()
BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << ": PluginManager shutdown enter";
// Detach the libslic3r hooks first so nothing dispatches into Python while
// (or after) plugins unload. Callers stop background slicing before this.
plugin_hooks::uninstall();
// Signal the loader to reject new plugin loads before we drain.
m_loader.set_shutting_down();

View File

@@ -19,7 +19,6 @@
#include "PluginCatalog.hpp"
#include "PluginLoader.hpp"
#include "PluginDescriptor.hpp"
#include "PluginConfig.hpp"
namespace Slic3r {
@@ -59,8 +58,6 @@ public:
const PluginCatalog& get_catalog() const { return m_catalog; }
PluginLoader& get_loader() { return m_loader; }
const PluginLoader& get_loader() const { return m_loader; }
PluginConfig& get_config() { return m_config; }
const PluginConfig& get_config() const { return m_config; }
void set_cloud_agent(std::shared_ptr<OrcaCloudServiceAgent> agent) { m_cloud_service.set_cloud_agent(std::move(agent)); }
@@ -91,7 +88,6 @@ private:
CloudPluginService m_cloud_service;
PluginCatalog m_catalog;
PluginLoader m_loader;
PluginConfig m_config;
mutable std::mutex m_mutex;
@@ -106,10 +102,14 @@ void execute_capabilities_from_refs(const ConfigOptionStrings& capabilities,
{
PluginManager& plugin_mgr = PluginManager::instance();
// Log prefix derived from the capability type so each capability family (Printer connection,
// Slicing Pipeline, ...) tags its dispatch diagnostics with its own display name.
const std::string tag = plugin_capability_type_display_name(type);
const bool has_any = std::any_of(capabilities.values.begin(), capabilities.values.end(),
[](const std::string& s) { return !s.empty(); });
if (has_any && !plugin_mgr.get_loader().wait_for_all_plugin_loads(std::chrono::seconds(10))) {
BOOST_LOG_TRIVIAL(warning) << "Post-process: timed out waiting for plugin loads; unresolved capabilities will be skipped";
BOOST_LOG_TRIVIAL(warning) << tag << ": timed out waiting for plugin loads; unresolved capabilities will be skipped";
}
for (const std::string& capability : capabilities.values) {
@@ -131,7 +131,7 @@ void execute_capabilities_from_refs(const ConfigOptionStrings& capabilities,
}
if (!ref) {
BOOST_LOG_TRIVIAL(warning) << "Post-processing: no plugin reference found for capability '" << capability << "'; skipping";
BOOST_LOG_TRIVIAL(warning) << tag << ": no plugin reference found for capability '" << capability << "'; skipping";
continue;
}
@@ -140,19 +140,19 @@ void execute_capabilities_from_refs(const ConfigOptionStrings& capabilities,
cap = plugin_mgr.get_loader().get_plugin_capability_by_name(plugin_key, type, cap_name);
if (!cap) {
BOOST_LOG_TRIVIAL(warning) << "Post-processing: no loaded capability '" << cap_name
BOOST_LOG_TRIVIAL(warning) << tag << ": no loaded capability '" << cap_name
<< "' for plugin '" << plugin_key << "'; skipping";
continue;
}
if (!cap->enabled) {
BOOST_LOG_TRIVIAL(warning) << "Post-processing: capability '" << cap_name
BOOST_LOG_TRIVIAL(warning) << tag << ": capability '" << cap_name
<< "' for plugin '" << plugin_key << "' is disabled; skipping";
continue;
}
auto plugin_capability = std::dynamic_pointer_cast<T>(cap->instance);
if (!plugin_capability) {
BOOST_LOG_TRIVIAL(warning) << "Post-processing: capability '" << cap_name
BOOST_LOG_TRIVIAL(warning) << tag << ": capability '" << cap_name
<< "' (plugin_key=" << cap->plugin_key
<< ") is not a " << plugin_capability_type_to_string(type) << "; skipping";
continue;

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)
return {};
// Plugin-bearing options opt in via ConfigOptionDef::support_plugin, so scan the preset's
// definition rather than maintaining a hardcoded per-type field list. A typed preset's config
// only contains keys for its own type, so this naturally stays scoped to `type`.
// Plugin-bearing options opt in via ConfigOptionDef::is_plugin_backed (a non-empty plugin_type),
// so scan the preset's definition rather than maintaining a hardcoded per-type field list. A typed
// preset's config only contains keys for its own type, so this naturally stays scoped to `type`.
const ConfigDef* def = preset.config.def();
if (def == nullptr)
return {};
@@ -48,7 +48,7 @@ std::string find_option_for_capability(Preset::Type type, const Preset& preset,
for (const std::string& field : preset.config.keys()) {
const ConfigOptionDef* opt_def = def->get(field);
if (opt_def == nullptr || !opt_def->support_plugin)
if (opt_def == nullptr || !opt_def->is_plugin_backed())
continue;
const ConfigOption* option = preset.config.option(field);

View File

@@ -29,14 +29,13 @@
}
// Opens the plugin's filesystem audit scope for the duration of a C++ -> Python call
// when this trampoline instance carries a non-empty audit plugin key. Also publishes the
// calling capability's name, so host APIs invoked from Python can tell which capability
// they are serving. Declares a local `_orca_audit_scope`.
// when this trampoline instance carries a non-empty audit plugin key. Declares a local
// `_orca_audit_scope`.
#define ORCA_PY_AUDIT_SCOPE(mode) \
std::optional<::Slic3r::ScopedPluginAuditContext> _orca_audit_scope; \
if (const std::string& _orca_audit_key = this->audit_plugin_key(); \
!_orca_audit_key.empty()) \
_orca_audit_scope.emplace(_orca_audit_key, this->audit_capability_name(), mode)
_orca_audit_scope.emplace(_orca_audit_key, mode)
#define ORCA_PY_OVERRIDE_AUDITED(mode, audit_setup, override_macro, ret, base, name, ...) \
do { \

View File

@@ -128,7 +128,7 @@ bool read_zip_text_file(mz_zip_archive& archive, const char* filename, std::stri
}
// TOML section parsing states.
enum class TomlSection { Root, OrcaPlugin, InDepsArray };
enum class TomlSection { Root, OrcaPlugin, OrcaPluginSettings, InDepsArray };
// Strip a quoted string value: "foo" → foo, 'foo' → foo.
// Returns the unquoted value or the input unchanged if not quoted.
@@ -187,6 +187,7 @@ bool parse_pep723_toml(const std::string& toml_content,
std::string& out_description,
std::string& out_author,
std::string& out_version,
std::map<std::string, std::string>& out_settings,
std::string& error)
{
out_deps.clear();
@@ -195,6 +196,7 @@ bool parse_pep723_toml(const std::string& toml_content,
out_description.clear();
out_author.clear();
out_version.clear();
out_settings.clear();
TomlSection section = TomlSection::Root;
@@ -218,6 +220,8 @@ bool parse_pep723_toml(const std::string& toml_content,
if (trimmed[0] == '[') {
if (trimmed == "[tool.orcaslicer.plugin]") {
section = TomlSection::OrcaPlugin;
} else if (trimmed == "[tool.orcaslicer.plugin.settings]") {
section = TomlSection::OrcaPluginSettings; // per-plugin params table
} else {
section = TomlSection::Root; // Unknown section — skip.
}
@@ -270,6 +274,10 @@ bool parse_pep723_toml(const std::string& toml_content,
else if (key == "description") out_description = unquote_toml_string(val);
else if (key == "author") out_author = unquote_toml_string(val);
else if (key == "version") out_version = unquote_toml_string(val);
} else if (section == TomlSection::OrcaPluginSettings) {
// collect every key as a string; the plugin parses (int/float/...) what it needs.
if (!key.empty())
out_settings[key] = unquote_toml_string(val);
}
}
@@ -673,6 +681,7 @@ bool read_python_plugin_metadata(const boost::filesystem::path& py_path, PluginD
pep_desc,
pep_author,
pep_version,
descriptor.settings,
pep723_error)) {
error = "Failed to parse PEP 723 metadata: " + pep723_error;
return false;

View File

@@ -3,7 +3,6 @@
#include <boost/log/trivial.hpp>
#include <memory>
#include <mutex>
#include <slic3r/plugin/PluginAuditManager.hpp>
#include <unordered_map>
#include <pybind11/embed.h>
@@ -11,13 +10,12 @@
#include <pybind11/stl.h>
#include "PythonInterpreter.hpp"
#include "PluginConfig.hpp"
#include "PluginHostApi.hpp"
#include "host/PluginHost.hpp"
#include "PyPluginPackage.hpp"
#include "PyPluginTrampoline.hpp"
#include "pluginTypes/gcode/GCodePluginCapability.hpp"
#include "pluginTypes/printerAgent/PrinterAgentPluginCapability.hpp"
#include "pluginTypes/script/ScriptPluginCapability.hpp"
#include "pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.hpp"
namespace py = pybind11;
@@ -288,7 +286,6 @@ void bind_python_api(pybind11::module_& m)
m.doc() = "OrcaSlicer plugin API";
auto pluginTypes = py::enum_<PluginCapabilityType>(m, "PluginType", "Available plugin capability groups")
.value("PostProcessing", PluginCapabilityType::PostProcessing)
.value("PrinterConnection", PluginCapabilityType::PrinterConnection)
.value("Automation", PluginCapabilityType::Automation)
.value("Analysis", PluginCapabilityType::Analysis)
@@ -296,6 +293,7 @@ void bind_python_api(pybind11::module_& m)
.value("Exporter", PluginCapabilityType::Exporter)
.value("Visualization", PluginCapabilityType::Visualization)
.value("Script", PluginCapabilityType::Script)
.value("SlicingPipeline", PluginCapabilityType::SlicingPipeline)
.value("Unknown", PluginCapabilityType::Unknown)
.export_values();
@@ -336,11 +334,10 @@ void bind_python_api(pybind11::module_& m)
BOOST_LOG_TRIVIAL(debug) << "Registering embedded Python plugin type bindings";
// Make sure you register your bindings here
GCodePluginCapability::RegisterBindings(m, pluginTypes);
PrinterAgentPluginCapability::RegisterBindings(m, pluginTypes);
ScriptPluginCapability::RegisterBindings(m, pluginTypes);
PluginHostApi::RegisterBindings(m);
PluginConfig::RegisterBindings(m);
SlicingPipelinePluginCapability::RegisterBindings(m, pluginTypes);
PluginHost::RegisterBindings(m);
BOOST_LOG_TRIVIAL(debug) << "Registered ScriptPluginCapability Python bindings";
m.def(

View File

@@ -10,12 +10,11 @@
namespace Slic3r {
enum class PluginCapabilityType { PostProcessing = 0, PrinterConnection, Automation, Analysis, Importer, Exporter, Visualization, Script, Unknown };
enum class PluginCapabilityType { PrinterConnection = 0, Automation, Analysis, Importer, Exporter, Visualization, Script, SlicingPipeline, Unknown };
inline std::string plugin_capability_type_to_string(PluginCapabilityType type)
{
switch (type) {
case PluginCapabilityType::PostProcessing: return "post-processing";
case PluginCapabilityType::PrinterConnection: return "printer-connection";
case PluginCapabilityType::Automation: return "automation";
case PluginCapabilityType::Analysis: return "analysis";
@@ -23,6 +22,7 @@ inline std::string plugin_capability_type_to_string(PluginCapabilityType type)
case PluginCapabilityType::Exporter: return "exporter";
case PluginCapabilityType::Visualization: return "visualization";
case PluginCapabilityType::Script: return "script";
case PluginCapabilityType::SlicingPipeline: return "slicing-pipeline";
default: return "unknown";
}
}
@@ -30,7 +30,6 @@ inline std::string plugin_capability_type_to_string(PluginCapabilityType type)
inline std::string plugin_capability_type_display_name(PluginCapabilityType type)
{
switch (type) {
case PluginCapabilityType::PostProcessing: return "Post-processing";
case PluginCapabilityType::PrinterConnection: return "Printer connection";
case PluginCapabilityType::Automation: return "Automation";
case PluginCapabilityType::Analysis: return "Analysis";
@@ -38,6 +37,7 @@ inline std::string plugin_capability_type_display_name(PluginCapabilityType type
case PluginCapabilityType::Exporter: return "Exporter";
case PluginCapabilityType::Visualization: return "Visualization";
case PluginCapabilityType::Script: return "Script";
case PluginCapabilityType::SlicingPipeline: return "Slicing Pipeline";
default: return "Unknown";
}
}
@@ -51,8 +51,6 @@ inline PluginCapabilityType plugin_capability_type_from_string(std::string_view
lowered.push_back(to_lower(ch));
}
if (lowered == "post-processing")
return PluginCapabilityType::PostProcessing;
if (lowered == "printer-connection")
return PluginCapabilityType::PrinterConnection;
if (lowered == "automation")
@@ -67,6 +65,8 @@ inline PluginCapabilityType plugin_capability_type_from_string(std::string_view
return PluginCapabilityType::Visualization;
if (lowered == "script")
return PluginCapabilityType::Script;
if (lowered == "slicing-pipeline")
return PluginCapabilityType::SlicingPipeline;
return PluginCapabilityType::Unknown;
}
@@ -98,13 +98,8 @@ class PluginCapabilityInterface
public:
virtual ~PluginCapabilityInterface() = default;
// Required APIs
virtual std::string get_name() const = 0;
// Optional APIs
virtual PluginCapabilityType get_type() const { return PluginCapabilityType::Unknown; }
virtual bool has_config() const { return false; }
virtual std::string embed_config_ui() const { return ""; }
virtual std::string get_name() const = 0; // required — overridden in Python
virtual PluginCapabilityType get_type() const { return PluginCapabilityType::Unknown; } // optional — typed bases override
virtual void on_load() {}
virtual void on_unload() {}
@@ -115,16 +110,8 @@ public:
void set_audit_plugin_key(std::string key) { m_audit_plugin_key = std::move(key); }
const std::string& audit_plugin_key() const { return m_audit_plugin_key; }
// The cached get_name() captured at load, paired with the audit plugin key to identify
// which capability a trampoline call belongs to. Cached rather than read live: get_name()
// is itself a trampoline call, so calling it from inside a trampoline would recurse.
// Empty until PluginLoader materializes the capability.
void set_audit_capability_name(std::string name) { m_audit_capability_name = std::move(name); }
const std::string& audit_capability_name() const { return m_audit_capability_name; }
private:
std::string m_audit_plugin_key;
std::string m_audit_capability_name;
};
} // namespace Slic3r

View File

@@ -0,0 +1,26 @@
#include "PluginHost.hpp"
#include "PluginHostBindings.hpp"
#include "PluginHostUi.hpp"
namespace Slic3r {
void PluginHost::RegisterBindings(pybind11::module_& module)
{
auto host = module.def_submodule("host", "Host application API");
// Value types first so the docstring signatures of later registrars
// resolve to the bound Python names.
host_bindings::register_geometry(host);
host_bindings::register_mesh(host);
host_bindings::register_presets(host);
host_bindings::register_model(host);
host_bindings::register_app(host);
// UI: native dialogs and interactive HTML windows for plugins.
PluginHostUi::RegisterBindings(host);
// Slicing print-graph data model (Print, Layer, Surface, ...).
host_bindings::register_slicing(host);
}
} // namespace Slic3r

View File

@@ -0,0 +1,17 @@
#pragma once
#include <pybind11/pybind11.h>
namespace Slic3r {
// Entry point of the `orca.host` Python API surface. Each domain of the
// surface (geometry, mesh, presets, model, app access, ui, slicing graph)
// lives in its own translation unit in this directory; RegisterBindings
// creates the submodule and runs the per-domain registrars.
class PluginHost
{
public:
static void RegisterBindings(pybind11::module_& module);
};
} // namespace Slic3r

View File

@@ -0,0 +1,60 @@
#include "PluginHostBindings.hpp"
#include <libslic3r/Model.hpp>
#include <libslic3r/PresetBundle.hpp>
#include <slic3r/GUI/GUI_App.hpp>
#include <slic3r/GUI/Plater.hpp>
#include <memory>
#include <stdexcept>
namespace py = pybind11;
namespace Slic3r {
namespace {
GUI::Plater* current_plater()
{
if (wxTheApp == nullptr)
throw std::runtime_error("OrcaSlicer application is not initialized");
GUI::Plater* plater = GUI::wxGetApp().plater();
if (plater == nullptr)
throw std::runtime_error("Plater is not available");
return plater;
}
PresetBundle* current_preset_bundle()
{
if (wxTheApp == nullptr)
throw std::runtime_error("OrcaSlicer application is not initialized");
PresetBundle* preset_bundle = GUI::wxGetApp().preset_bundle;
if (preset_bundle == nullptr)
throw std::runtime_error("Preset bundle is not available");
return preset_bundle;
}
} // namespace
// Access to the live GUI application: the Plater and the module-level
// plater()/model()/preset_bundle() accessors. Everything here is owned by the
// app and only reachable once the GUI is up (the accessors throw before that).
void host_bindings::register_app(py::module_& host)
{
py::class_<GUI::Plater, std::unique_ptr<GUI::Plater, py::nodelete>>(host, "Plater")
.def("model", static_cast<Model& (GUI::Plater::*)()>(&GUI::Plater::model), py::return_value_policy::reference_internal)
.def("is_project_dirty", &GUI::Plater::is_project_dirty)
.def("is_presets_dirty", &GUI::Plater::is_presets_dirty)
.def("inside_snapshot_capture", &GUI::Plater::inside_snapshot_capture);
host.def("plater", &current_plater, py::return_value_policy::reference);
host.def("model", []() -> Model& {
return current_plater()->model();
}, py::return_value_policy::reference);
host.def("preset_bundle", &current_preset_bundle, py::return_value_policy::reference);
}
} // namespace Slic3r

View File

@@ -0,0 +1,16 @@
#pragma once
#include <pybind11/pybind11.h>
// Internal to plugin/host/: the per-domain registrars of the `orca.host`
// surface, one per translation unit, called by PluginHost::RegisterBindings.
namespace Slic3r::host_bindings {
void register_geometry(pybind11::module_& host); // PluginHostGeometry.cpp
void register_mesh(pybind11::module_& host); // PluginHostMesh.cpp
void register_presets(pybind11::module_& host); // PluginHostPresets.cpp
void register_model(pybind11::module_& host); // PluginHostModel.cpp
void register_app(pybind11::module_& host); // PluginHostApp.cpp
void register_slicing(pybind11::module_& host); // PluginHostSlicing.cpp
} // namespace Slic3r::host_bindings

View File

@@ -0,0 +1,216 @@
#include "PluginHostBindings.hpp"
#include "slic3r/plugin/PluginBindingUtils.hpp"
#include <libslic3r/BoundingBox.hpp>
#include <libslic3r/ClipperUtils.hpp> // offset/offset_ex/union_ex/diff_ex/intersection_ex
#include <libslic3r/ExPolygon.hpp>
#include <pybind11/stl.h>
#include <string>
#include <utility>
#include <vector>
namespace py = pybind11;
namespace Slic3r {
namespace {
// --- Input path: Python geometry -> C++ Polygon/ExPolygon, with validation. ---------------
// The mutators take scaled integer coords (the same units the read views hand out). A Python
// raise here surfaces as ValueError (pybind translates) so malformed input is rejected up
// front rather than silently corrupting the slicing graph.
// One (N,2) int64 ndarray -> Polygon. Rejects wrong dtype/shape and degenerate (<3 pt) rings.
// Float / NaN / inf are rejected implicitly: only a signed-integer, 8-byte (coord_t==int64)
// dtype is accepted, and integer arrays cannot hold NaN/inf.
Polygon parse_polygon(py::handle h, const char* who)
{
if (!py::isinstance<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;
}
// Accept a bound orca.host.Polygon (copied) or an (N,2) int64 ndarray. Used by the ExPolygon
// binding, whose constructor/contour-setter/set_holes must accept the Polygon it itself hands
// out (e.g. `ExPolygon(some_polygon_ref)`) in addition to the ndarray-only parse_polygon() path.
Polygon as_polygon(py::handle h, const char* who)
{
if (py::isinstance<Polygon>(h))
return h.cast<Polygon>();
return parse_polygon(h, who);
}
} // namespace
void host_bindings::register_geometry(py::module_& host)
{
// ------------------------------------------------------------------
// Geometry value types of the `orca.host` surface. All use pybind's
// default holder, so plugins can construct and own instances. When
// obtained from the live slicing graph they are non-owning references
// instead — see the lifetime rule in PluginHostSlicing.cpp.
// ------------------------------------------------------------------
// Axis-aligned bounding box, returned by value (a copy) so its lifetime is
// independent of the model object it was computed from. Coordinates are in mm.
py::class_<BoundingBoxf3>(host, "BoundingBox", "Axis-aligned bounding box in millimetres")
.def_property_readonly("defined", [](const BoundingBoxf3& bb) { return bb.defined; })
.def_property_readonly("min", [](const BoundingBoxf3& bb) { return vec3_to_tuple(bb.min); })
.def_property_readonly("max", [](const BoundingBoxf3& bb) { return vec3_to_tuple(bb.max); })
.def_property_readonly("size", [](const BoundingBoxf3& bb) { return vec3_to_tuple(bb.size()); })
.def_property_readonly("center", [](const BoundingBoxf3& bb) { return vec3_to_tuple(bb.center()); })
.def_property_readonly("radius", [](const BoundingBoxf3& bb) { return bb.radius(); });
// Point: a constructible value type (default holder, so Python-owned instances
// are freed). Returned-by-reference from Polygon.points, it aliases the buffer;
// x()/y() are Eigen lvalues, so the properties are read/write. p+q / p-q go
// through Eigen expression templates, wrapped back into a Point.
py::class_<Point>(host, "Point")
.def(py::init([](coord_t x, coord_t y) { return Point(x, y); }), py::arg("x"), py::arg("y"))
.def_property("x", [](const Point& p) { return p.x(); },
[](Point& p, coord_t v) { p.x() = v; })
.def_property("y", [](const Point& p) { return p.y(); },
[](Point& p, coord_t v) { p.y() = v; })
.def("__add__", [](const Point& a, const Point& b) { return Point(a + b); }, py::is_operator())
.def("__sub__", [](const Point& a, const Point& b) { return Point(a - b); }, py::is_operator())
.def("__mul__", [](const Point& a, double s) { return Point(a.x() * s, a.y() * s); }, py::is_operator())
.def("__repr__", [](const Point& p) {
return "orca.host.Point(" + std::to_string(p.x()) + ", " + std::to_string(p.y()) + ")";
});
py::class_<Polygon>(host, "Polygon")
.def(py::init<>())
.def("size", [](const Polygon& p) { return p.points.size(); })
.def("is_valid", [](const Polygon& p) { return p.is_valid(); })
.def("is_counter_clockwise", [](const Polygon& p) { return p.is_counter_clockwise(); })
.def("is_clockwise", [](const Polygon& p) { return p.is_clockwise(); })
.def("make_counter_clockwise", [](Polygon& p) { return p.make_counter_clockwise(); },
"Reorient to CCW in place. Returns True if it reversed the winding.")
.def("make_clockwise", [](Polygon& p) { return p.make_clockwise(); })
.def("area", [](const Polygon& p) { return p.area(); })
.def("centroid", [](const Polygon& p) { return p.centroid(); })
.def("contains", [](const Polygon& p, const Point& pt) { return p.contains(pt); }, py::arg("point"))
.def("translate", [](Polygon& p, double x, double y) { p.translate(x, y); }, py::arg("x"), py::arg("y"))
.def("rotate", [](Polygon& p, double angle) { p.rotate(angle); }, py::arg("angle"))
.def("rotate", [](Polygon& p, double angle, const Point& c) { p.rotate(angle, c); },
py::arg("angle"), py::arg("center"))
.def("douglas_peucker", [](Polygon& p, double tol) { p.douglas_peucker(tol); }, py::arg("tolerance"))
.def("simplify", [](const Polygon& p, double tol) { return p.simplify(tol); }, py::arg("tolerance"),
"Return simplified geometry as a list of Polygon (may split into several).")
.def("offset", [](const Polygon& p, coord_t delta) { return offset(p, (float) delta); }, py::arg("delta"),
"Clipper offset by `delta` scaled units (negative shrinks). Returns [Polygon].")
// --- Point-object idiom: references into the buffer (in-place element edit). ---
.def_property_readonly("points", [](py::object self) {
Polygon& p = self.cast<Polygon&>();
py::list out;
for (Point& pt : p.points)
out.append(py::cast(&pt, py::return_value_policy::reference_internal, self));
return out;
}, "Vertices as [Point] references into this polygon. Editing a Point mutates the "
"buffer in place. Structural changes (count) go through set_points/append, which "
"invalidate previously returned Point refs and array views (C++ vector semantics).")
.def("append", [](Polygon& p, const Point& pt) { p.points.push_back(pt); }, py::arg("point"),
"Append a vertex. Structural change (count): invalidates previously returned "
"Point refs and array views into this polygon (C++ vector semantics).")
// --- numpy idiom: writable zero-copy (N,2) view (bulk affine edits). ---
.def("as_array", [](py::object self) {
Polygon& p = self.cast<Polygon&>();
return with_numpy([&] {
return py::object(make_writable_rows<coord_t, 2>(
self, p.points.empty() ? nullptr : p.points.front().data(),
(py::ssize_t) p.points.size()));
});
}, "Vertices as a WRITABLE int64 (N,2) numpy view in scaled coords, aliasing the "
"buffer. Count-preserving in-place edits only; valid during execute(ctx). Requires numpy.")
.def("set_points", [](Polygon& p, py::handle src) { p = parse_polygon(src, "Polygon.set_points"); },
py::arg("points"),
"Replace all vertices from an (N,2) int64 ndarray (scaled coords). Count-changing; "
"invalidates prior Point refs and array views. Raises ValueError on malformed input.");
// ExPolygon: default holder (Python-owned instances are freed) so plugins can construct
// their own geometry, not just navigate the live slicing graph. contour/holes accessors
// still use reference_internal, so refs into a graph-owned ExPolygon stay non-owning views
// tied to that owner's lifetime, same as Polygon/Surface.
py::class_<ExPolygon>(host, "ExPolygon")
.def(py::init([](py::handle contour, py::handle holes) {
// Accept bound Polygons or (N,2) ndarrays for both contour and each hole.
ExPolygon ex;
ex.contour = as_polygon(contour, "ExPolygon.contour");
if (!holes.is_none()) {
if (!py::isinstance<py::sequence>(holes) || py::isinstance<py::str>(holes))
throw py::value_error("ExPolygon: holes must be a list of Polygon or (N,2) ndarrays");
for (py::handle h : py::reinterpret_borrow<py::sequence>(holes)) {
Polygon hole = as_polygon(h, "ExPolygon.hole");
hole.make_clockwise();
ex.holes.emplace_back(std::move(hole));
}
}
ex.contour.make_counter_clockwise();
return ex;
}), py::arg("contour"), py::arg("holes") = py::none(),
"Construct from a Polygon/ndarray contour and optional list of hole Polygons/ndarrays. "
"Orientation is normalized (contour CCW, holes CW).")
.def_property("contour",
[](ExPolygon& e) -> Polygon& { return e.contour; },
[](ExPolygon& e, py::handle v) { e.contour = as_polygon(v, "ExPolygon.contour"); },
py::return_value_policy::reference_internal,
"Outer contour (CCW). Read returns a live Polygon ref; assign a Polygon/ndarray to replace it.")
.def_property_readonly("holes", [](py::object self) {
ExPolygon& e = self.cast<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] references (in-place editable). set_holes replaces them.")
.def("set_holes", [](ExPolygon& e, py::handle holes) {
ExPolygon tmp;
if (!py::isinstance<py::sequence>(holes) || py::isinstance<py::str>(holes))
throw py::value_error("set_holes: expected a list of Polygon or (N,2) ndarrays");
for (py::handle h : py::reinterpret_borrow<py::sequence>(holes)) {
Polygon hole = as_polygon(h, "ExPolygon.set_holes");
hole.make_clockwise();
tmp.holes.emplace_back(std::move(hole));
}
e.holes = std::move(tmp.holes);
}, py::arg("holes"), "Replace all holes. Invalidates prior hole refs (C++ vector semantics).")
.def("translate", [](ExPolygon& e, double x, double y) { e.translate(x, y); }, py::arg("x"), py::arg("y"))
.def("rotate", [](ExPolygon& e, double a) { e.rotate(a); }, py::arg("angle"))
.def("rotate", [](ExPolygon& e, double a, const Point& c) { e.rotate(a, c); },
py::arg("angle"), py::arg("center"))
.def("scale", [](ExPolygon& e, double f) { e.scale(f); }, py::arg("factor"))
.def("douglas_peucker", [](ExPolygon& e, double t) { e.douglas_peucker(t); }, py::arg("tolerance"))
.def("area", [](const ExPolygon& e) { return e.area(); })
.def("is_valid", [](const ExPolygon& e) { return e.is_valid(); })
.def("contains", [](const ExPolygon& e, const Point& p) { return e.contains(p); }, py::arg("point"))
.def("num_contours", [](const ExPolygon& e) { return e.num_contours(); })
.def("simplify", [](const ExPolygon& e, double t) { return e.simplify(t); }, py::arg("tolerance"),
"Return simplified geometry as [ExPolygon].")
.def("offset", [](const ExPolygon& e, coord_t delta) { return offset_ex(e, (float) delta); },
py::arg("delta"), "Clipper offset by `delta` scaled units (negative shrinks). Returns [ExPolygon].")
.def("union_ex", [](const ExPolygon& a, const ExPolygon& b) {
return union_ex(ExPolygons{ a, b });
}, py::arg("other"), "Union with another ExPolygon. Returns [ExPolygon].")
.def("diff_ex", [](const ExPolygon& a, const ExPolygon& b) {
return diff_ex(ExPolygons{ a }, ExPolygons{ b });
}, py::arg("other"), "This minus `other`. Returns [ExPolygon].")
.def("intersection_ex", [](const ExPolygon& a, const ExPolygon& b) {
return intersection_ex(ExPolygons{ a }, ExPolygons{ b });
}, py::arg("other"), "Intersection with `other`. Returns [ExPolygon].");
}
} // namespace Slic3r

View File

@@ -0,0 +1,101 @@
#include "PluginHostBindings.hpp"
#include "PluginHostMesh.hpp"
#include "slic3r/plugin/PluginBindingUtils.hpp"
#include <pybind11/numpy.h>
#include <cstdint>
#include <memory>
#include <vector>
namespace py = pybind11;
namespace Slic3r {
namespace {
// Zero-copy export of its.vertices / its.indices relies on these Eigen
// row-vectors being tightly packed (no padding between the 3 components).
static_assert(sizeof(stl_vertex) == 3 * sizeof(float),
"stl_vertex must be a packed float[3] for zero-copy numpy export");
static_assert(sizeof(stl_triangle_vertex_indices) == 3 * sizeof(std::int32_t),
"triangle index must be a packed int32[3] for zero-copy numpy export");
} // namespace
void host_bindings::register_mesh(py::module_& host)
{
// The raw libslic3r TriangleMesh, bound with a shared_ptr holder:
// ModelVolume.mesh() hands out the volume's own shared_ptr, so the Python
// object pins this snapshot even if the volume's mesh is later replaced on
// the main thread. The zero-copy views below use the Python object as their
// array base, which keeps the buffer alive for each array's lifetime.
//
// IMMUTABLE BY RULE: handed-out meshes are copy-on-write snapshots SHARED
// across threads (a Print's model snapshot and the live GUI model share the
// same instance), reached through a const_pointer_cast that only serves the
// holder type. Bind only const/read-only methods here. A future mutable-mesh
// API must operate on plugin-owned copies handed back via
// ModelVolume::set_mesh — never mutate a mesh obtained from the graph.
py::class_<TriangleMesh, std::shared_ptr<TriangleMesh>>(host, "TriangleMesh",
"Immutable snapshot of a ModelVolume's mesh in local (untransformed) coordinates, mm.")
.def("vertex_count", [](const TriangleMesh& mesh) { return mesh.its.vertices.size(); })
.def("triangle_count", [](const TriangleMesh& mesh) { return mesh.its.indices.size(); })
.def("facets_count", [](const TriangleMesh& mesh) { return mesh.its.indices.size(); })
.def("is_empty", [](const TriangleMesh& mesh) { return mesh.its.indices.empty(); })
// Read-only, zero-copy (N, 3) float32 view of vertex positions. Requires numpy.
.def("vertices", [](py::object self) {
const TriangleMesh& mesh = self.cast<const TriangleMesh&>();
return with_numpy([&] {
const std::vector<stl_vertex>& vertices = mesh.its.vertices;
return py::object(make_readonly_rows<float, 3>(
self, vertices.empty() ? nullptr : vertices.front().data(),
static_cast<py::ssize_t>(vertices.size())));
});
}, "Read-only zero-copy (N, 3) float32 ndarray of vertex positions (local mm). Requires numpy.")
// Read-only, zero-copy (M, 3) int32 view of triangle vertex indices. Requires numpy.
.def("triangles", [](py::object self) {
const TriangleMesh& mesh = self.cast<const TriangleMesh&>();
return with_numpy([&] {
const std::vector<stl_triangle_vertex_indices>& indices = mesh.its.indices;
return py::object(make_readonly_rows<std::int32_t, 3>(
self, indices.empty() ? nullptr : indices.front().data(),
static_cast<py::ssize_t>(indices.size())));
});
}, "Read-only zero-copy (M, 3) int32 ndarray of triangle vertex indices. Requires numpy.")
// One normalized normal per triangle as an (M, 3) float32 copy. Requires numpy.
.def("face_normals", [](const TriangleMesh& mesh) {
return with_numpy([&] {
std::vector<Vec3f> normals = its_face_normals(mesh.its);
py::array_t<float> array({ static_cast<py::ssize_t>(normals.size()), py::ssize_t(3) });
if (!normals.empty()) {
auto view = array.mutable_unchecked<2>();
for (size_t i = 0; i < normals.size(); ++i) {
view(i, 0) = normals[i].x();
view(i, 1) = normals[i].y();
view(i, 2) = normals[i].z();
}
}
return py::object(std::move(array));
});
}, "Per-triangle normalized normals as an (M, 3) float32 ndarray (copy). Requires numpy.")
// numpy-free element access, bounds-checked.
.def("vertex", [](const TriangleMesh& mesh, size_t index) {
const std::vector<stl_vertex>& vertices = mesh.its.vertices;
if (index >= vertices.size())
throw py::index_error("vertex index out of range");
const stl_vertex& vertex = vertices[index];
return py::make_tuple(vertex.x(), vertex.y(), vertex.z());
})
.def("triangle", [](const TriangleMesh& mesh, size_t index) {
const std::vector<stl_triangle_vertex_indices>& indices = mesh.its.indices;
if (index >= indices.size())
throw py::index_error("triangle index out of range");
const stl_triangle_vertex_indices& triangle = indices[index];
return py::make_tuple(triangle[0], triangle[1], triangle[2]);
})
.def("volume", [](const TriangleMesh& mesh) { return mesh.stats().volume; })
.def("bounding_box", [](const TriangleMesh& mesh) { return bbox_from_stats(mesh.stats()); })
.def("is_manifold", [](const TriangleMesh& mesh) { return mesh.stats().manifold(); });
}
} // namespace Slic3r

View File

@@ -0,0 +1,18 @@
#pragma once
#include <libslic3r/BoundingBox.hpp>
#include <libslic3r/TriangleMesh.hpp>
namespace Slic3r {
// Build a BoundingBoxf3 from precomputed (float) triangle-mesh stats min/max.
// Shared by the TriangleMesh binding (PluginHostMesh.cpp) and the mesh-derived
// ModelVolume accessors (PluginHostModel.cpp).
inline BoundingBoxf3 bbox_from_stats(const TriangleMeshStats& stats)
{
if (stats.number_of_facets == 0)
return BoundingBoxf3();
return BoundingBoxf3(stats.min.cast<double>(), stats.max.cast<double>());
}
} // namespace Slic3r

View File

@@ -0,0 +1,208 @@
#include "PluginHostBindings.hpp"
#include "PluginHostMesh.hpp"
#include "slic3r/plugin/PluginBindingUtils.hpp"
#include <libslic3r/Model.hpp>
#include <pybind11/stl.h>
#include <memory>
#include <string>
namespace py = pybind11;
namespace Slic3r {
// The scene/document graph: Model -> ModelObject -> ModelInstance/ModelVolume.
// Everything is bound py::nodelete — non-owning references into a graph owned
// by the app (the live Plater model) or by a Print's model snapshot.
void host_bindings::register_model(py::module_& host)
{
py::enum_<ModelVolumeType>(host, "ModelVolumeType")
.value("Invalid", ModelVolumeType::INVALID)
.value("ModelPart", ModelVolumeType::MODEL_PART)
.value("NegativeVolume", ModelVolumeType::NEGATIVE_VOLUME)
.value("ParameterModifier", ModelVolumeType::PARAMETER_MODIFIER)
.value("SupportBlocker", ModelVolumeType::SUPPORT_BLOCKER)
.value("SupportEnforcer", ModelVolumeType::SUPPORT_ENFORCER);
py::class_<ModelVolume, std::unique_ptr<ModelVolume, py::nodelete>>(host, "ModelVolume")
.def("id", [](const ModelVolume& volume) { return volume.id().id; })
.def_readonly("name", &ModelVolume::name)
.def("type", &ModelVolume::type)
.def("is_model_part", &ModelVolume::is_model_part)
.def("is_modifier", &ModelVolume::is_modifier)
.def("is_negative_volume", &ModelVolume::is_negative_volume)
.def("is_support_enforcer", &ModelVolume::is_support_enforcer)
.def("is_support_blocker", &ModelVolume::is_support_blocker)
.def("is_support_modifier", &ModelVolume::is_support_modifier)
// Extruder ID is 1-based for FFF, -1 for SLA or support volumes.
.def("extruder_id", &ModelVolume::extruder_id)
.def("offset", [](const ModelVolume& volume) { return vec3_to_tuple(volume.get_offset()); })
.def("rotation", [](const ModelVolume& volume) { return vec3_to_tuple(volume.get_rotation()); })
.def("scaling_factor", [](const ModelVolume& volume) { return vec3_to_tuple(volume.get_scaling_factor()); })
.def("mirror", [](const ModelVolume& volume) { return vec3_to_tuple(volume.get_mirror()); })
// 4x4 float64 affine matrix mapping this volume into its parent object frame. Requires numpy.
.def("matrix", [](const ModelVolume& volume) { return mat4_to_numpy(volume.get_matrix()); },
"Volume-to-object 4x4 float64 affine matrix (copy). Requires numpy.")
.def("facets_count", [](const ModelVolume& volume) { return volume.mesh().facets_count(); })
// Raw (untransformed) mesh volume in mm^3; -1 if it was never computed.
.def("volume", [](const ModelVolume& volume) { return volume.mesh().stats().volume; })
// Bounding box of the raw (untransformed) mesh, in the volume's local frame.
.def("bounding_box", [](const ModelVolume& volume) { return bbox_from_stats(volume.mesh().stats()); })
.def("is_manifold", [](const ModelVolume& volume) { return volume.mesh().stats().manifold(); })
// Full mesh geometry (vertices/triangles) as an immutable snapshot.
.def("mesh", [](const ModelVolume& volume) {
// The volume stores its mesh as shared_ptr<const TriangleMesh> (a
// copy-on-write snapshot); the const_pointer_cast only serves the
// binding's shared_ptr holder — the TriangleMesh binding exposes
// read-only methods (see the immutability rule in PluginHostMesh.cpp).
return std::const_pointer_cast<TriangleMesh>(volume.get_mesh_shared_ptr());
}, "Return the volume's TriangleMesh (local coordinates) for vertex/triangle access.")
.def("mesh_errors_count", [](const ModelVolume& volume) { return volume.get_repaired_errors_count(); })
.def("is_fdm_support_painted", &ModelVolume::is_fdm_support_painted)
.def("is_seam_painted", &ModelVolume::is_seam_painted)
.def("is_mm_painted", &ModelVolume::is_mm_painted)
.def("is_fuzzy_skin_painted", &ModelVolume::is_fuzzy_skin_painted)
.def("config_keys", [](const ModelVolume& volume) { return volume.config.keys(); })
.def("config_value", [](const ModelVolume& volume, const std::string& key) {
return config_value_or_none(volume.config.get(), key);
});
py::class_<ModelInstance, std::unique_ptr<ModelInstance, py::nodelete>>(host, "ModelInstance")
.def("id", [](const ModelInstance& instance) { return instance.id().id; })
.def_readonly("printable", &ModelInstance::printable)
// True only if the object is printable, this instance is printable and it
// currently sits fully inside the print volume (set during slicing).
.def("is_printable", &ModelInstance::is_printable)
.def("offset", [](const ModelInstance& instance) { return vec3_to_tuple(instance.get_offset()); })
.def("rotation", [](const ModelInstance& instance) { return vec3_to_tuple(instance.get_rotation()); })
.def("scaling_factor", [](const ModelInstance& instance) { return vec3_to_tuple(instance.get_scaling_factor()); })
.def("mirror", [](const ModelInstance& instance) { return vec3_to_tuple(instance.get_mirror()); })
// 4x4 float64 affine matrix mapping the object into world space. Requires numpy.
// World vertices = instance.matrix() @ volume.matrix() applied to mesh vertices.
.def("matrix", [](const ModelInstance& instance) { return mat4_to_numpy(instance.get_matrix()); },
"Object-to-world 4x4 float64 affine matrix (copy). Requires numpy.")
.def("is_left_handed", &ModelInstance::is_left_handed)
// Assemble-view placement. Each instance carries a second transform used only by
// the Assemble view, set from stored 3mf assemble data or derived from the regular
// transform. Until then (is_assemble_initialized() false) it is identity.
.def("is_assemble_initialized", [](ModelInstance& instance) { return instance.is_assemble_initialized(); })
.def("assemble_offset", [](const ModelInstance& instance) {
return vec3_to_tuple(instance.get_assemble_transformation().get_offset());
})
.def("assemble_rotation", [](const ModelInstance& instance) {
return vec3_to_tuple(instance.get_assemble_transformation().get_rotation());
})
// 4x4 float64 affine matrix placing the object in the Assemble view. Requires numpy.
.def("assemble_matrix", [](const ModelInstance& instance) {
return mat4_to_numpy(instance.get_assemble_transformation().get_matrix());
}, "Assemble-view 4x4 float64 affine matrix (copy). Requires numpy.")
// Offset from the instance origin to its position within the source assembly,
// recorded at import time (e.g. from a STEP assembly).
.def("offset_to_assembly", [](const ModelInstance& instance) {
return vec3_to_tuple(instance.get_offset_to_assembly());
})
// World-space bounding box of this instance.
.def("bounding_box", [](ModelInstance& instance) {
const ModelObject* object = instance.get_object();
if (object == nullptr)
return BoundingBoxf3();
return object->instance_bounding_box(instance);
});
py::class_<ModelObject, std::unique_ptr<ModelObject, py::nodelete>>(host, "ModelObject")
.def("id", [](const ModelObject& object) { return object.id().id; })
.def_readonly("name", &ModelObject::name)
.def_readonly("module_name", &ModelObject::module_name)
.def_readonly("input_file", &ModelObject::input_file)
// Import-time flag only: the GUI's printable toggle writes the per-instance
// ModelInstance::printable and never updates this field, so derive an
// object's effective state from its instances.
.def_readonly("printable", &ModelObject::printable)
.def("instance_count", [](const ModelObject& object) {
return object.instances.size();
})
.def("volume_count", [](const ModelObject& object) {
return object.volumes.size();
})
.def("instances", [](ModelObject& object) {
py::list instances;
for (ModelInstance* instance : object.instances)
instances.append(py::cast(instance, py::return_value_policy::reference));
return instances;
})
.def("instance", [](ModelObject& object, size_t index) -> ModelInstance* {
if (index >= object.instances.size())
throw py::index_error("instance index out of range");
return object.instances[index];
}, py::return_value_policy::reference_internal)
.def("volumes", [](ModelObject& object) {
py::list volumes;
for (ModelVolume* volume : object.volumes)
volumes.append(py::cast(volume, py::return_value_policy::reference));
return volumes;
})
.def("volume", [](ModelObject& object, size_t index) -> ModelVolume* {
if (index >= object.volumes.size())
throw py::index_error("volume index out of range");
return object.volumes[index];
}, py::return_value_policy::reference_internal)
// World-space bounding box over all instances of this object.
.def("bounding_box", [](const ModelObject& object) { return object.bounding_box_exact(); })
// Bounding box of the object's raw (untransformed) part meshes — its intrinsic size.
.def("raw_mesh_bounding_box", [](const ModelObject& object) { return object.raw_mesh_bounding_box(); })
.def("min_z", &ModelObject::min_z)
.def("max_z", &ModelObject::max_z)
.def("facets_count", [](const ModelObject& object) { return object.facets_count(); })
.def("parts_count", [](const ModelObject& object) { return object.parts_count(); })
.def("materials_count", [](const ModelObject& object) { return object.materials_count(); })
.def("mesh_errors_count", [](const ModelObject& object) { return object.get_repaired_errors_count(); })
.def("is_multiparts", &ModelObject::is_multiparts)
.def("is_cut", &ModelObject::is_cut)
.def("has_custom_layering", &ModelObject::has_custom_layering)
.def("is_fdm_support_painted", &ModelObject::is_fdm_support_painted)
.def("is_seam_painted", &ModelObject::is_seam_painted)
.def("is_mm_painted", &ModelObject::is_mm_painted)
.def("is_fuzzy_skin_painted", &ModelObject::is_fuzzy_skin_painted)
.def("config_keys", [](const ModelObject& object) {
return object.config.keys();
})
.def("config_value", [](const ModelObject& object, const std::string& key) {
return config_value_or_none(object.config.get(), key);
});
py::class_<Model, std::unique_ptr<Model, py::nodelete>>(host, "Model")
.def("id", [](const Model& model) { return model.id().id; })
.def("object_count", [](const Model& model) {
return model.objects.size();
})
.def("object", [](Model& model, size_t index) -> ModelObject* {
if (index >= model.objects.size())
throw py::index_error("model object index out of range");
return model.objects[index];
}, py::return_value_policy::reference_internal)
.def("objects", [](Model& model) {
py::list objects;
for (ModelObject* object : model.objects)
objects.append(py::cast(object, py::return_value_policy::reference));
return objects;
})
// World-space bounding box of the whole model. bounding_box() is exact;
// bounding_box_approx() is faster and cached.
.def("bounding_box", [](const Model& model) { return model.bounding_box_exact(); })
.def("bounding_box_approx", [](const Model& model) { return model.bounding_box_approx(); })
.def("max_z", &Model::max_z)
.def("material_count", [](const Model& model) { return model.materials.size(); })
.def("is_fdm_support_painted", &Model::is_fdm_support_painted)
.def("is_seam_painted", &Model::is_seam_painted)
.def("is_mm_painted", &Model::is_mm_painted)
.def("is_fuzzy_skin_painted", &Model::is_fuzzy_skin_painted)
.def("current_plate_index", [](const Model& model) { return model.curr_plate_index; })
.def("designer", [](const Model& model) {
return model.design_info ? model.design_info->Designer : std::string();
})
.def("design_id", [](const Model& model) { return model.stl_design_id; });
}
} // namespace Slic3r

View File

@@ -0,0 +1,138 @@
#include "PluginHostBindings.hpp"
#include "slic3r/plugin/PluginBindingUtils.hpp"
#include <libslic3r/Preset.hpp>
#include <libslic3r/PresetBundle.hpp>
#include <pybind11/stl.h>
#include <string>
#include <vector>
namespace py = pybind11;
namespace Slic3r {
namespace {
py::list current_filament_presets(PresetBundle& bundle)
{
py::list presets;
for (const std::string& preset_name : bundle.filament_presets) {
Preset* preset = bundle.filaments.find_preset(preset_name);
if (preset == nullptr)
presets.append(py::none());
else
presets.append(py::cast(preset, py::return_value_policy::reference));
}
return presets;
}
PresetCollection& printer_presets(PresetBundle& bundle)
{
return static_cast<PresetCollection&>(bundle.printers);
}
} // namespace
void host_bindings::register_presets(py::module_& host)
{
py::enum_<Preset::Type>(host, "PresetType")
.value("Invalid", Preset::TYPE_INVALID)
.value("Print", Preset::TYPE_PRINT)
.value("SlaPrint", Preset::TYPE_SLA_PRINT)
.value("Filament", Preset::TYPE_FILAMENT)
.value("SlaMaterial", Preset::TYPE_SLA_MATERIAL)
.value("Printer", Preset::TYPE_PRINTER)
.value("PhysicalPrinter", Preset::TYPE_PHYSICAL_PRINTER)
.value("Plate", Preset::TYPE_PLATE)
.value("Model", Preset::TYPE_MODEL);
py::class_<Preset, std::unique_ptr<Preset, py::nodelete>>(host, "Preset")
.def_readonly("type", &Preset::type)
.def_readonly("name", &Preset::name)
.def_readonly("alias", &Preset::alias)
.def_readonly("file", &Preset::file)
.def_readonly("is_default", &Preset::is_default)
.def_readonly("is_external", &Preset::is_external)
.def_readonly("is_system", &Preset::is_system)
.def_readonly("is_visible", &Preset::is_visible)
.def_readonly("is_dirty", &Preset::is_dirty)
.def_readonly("is_compatible", &Preset::is_compatible)
.def_readonly("is_project_embedded", &Preset::is_project_embedded)
.def_readonly("bundle_id", &Preset::bundle_id)
.def("is_user", &Preset::is_user)
.def("is_from_bundle", &Preset::is_from_bundle)
.def("label", &Preset::label, py::arg("no_alias") = false)
.def("config_keys", [](const Preset& preset) { return preset.config.keys(); })
.def("config_value", [](const Preset& preset, const std::string& key) {
return config_value_or_none(preset.config, key);
});
py::class_<PresetCollection, std::unique_ptr<PresetCollection, py::nodelete>>(host, "PresetCollection")
.def("size", &PresetCollection::size)
.def("get_selected_preset", [](PresetCollection& collection) -> Preset& {
return collection.get_selected_preset();
}, py::return_value_policy::reference_internal)
.def("selected_preset", [](PresetCollection& collection) -> Preset& {
return collection.get_selected_preset();
}, py::return_value_policy::reference_internal)
.def("get_selected_preset_name", &PresetCollection::get_selected_preset_name)
.def("selected_preset_name", &PresetCollection::get_selected_preset_name)
.def("get_edited_preset", [](PresetCollection& collection) -> Preset& {
return collection.get_edited_preset();
}, py::return_value_policy::reference_internal)
.def("edited_preset", [](PresetCollection& collection) -> Preset& {
return collection.get_edited_preset();
}, py::return_value_policy::reference_internal)
.def("preset", [](PresetCollection& collection, size_t index) -> Preset& {
if (index >= collection.size())
throw py::index_error("preset index out of range");
return collection.preset(index);
}, py::return_value_policy::reference_internal)
.def("find_preset", [](PresetCollection& collection, const std::string& name) -> Preset* {
return collection.find_preset(name);
}, py::return_value_policy::reference_internal)
.def("preset_names", [](const PresetCollection& collection) {
std::vector<std::string> names;
names.reserve(collection.get_presets().size());
for (const Preset& preset : collection.get_presets())
names.push_back(preset.name);
return names;
});
py::class_<PresetBundle, std::unique_ptr<PresetBundle, py::nodelete>>(host, "PresetBundle")
.def_property_readonly("prints", [](PresetBundle& bundle) -> PresetCollection& {
return bundle.prints;
}, py::return_value_policy::reference_internal)
.def_property_readonly("printers", &printer_presets, py::return_value_policy::reference_internal)
.def_property_readonly("filaments", [](PresetBundle& bundle) -> PresetCollection& {
return bundle.filaments;
}, py::return_value_policy::reference_internal)
.def_property_readonly("sla_prints", [](PresetBundle& bundle) -> PresetCollection& {
return bundle.sla_prints;
}, py::return_value_policy::reference_internal)
.def_property_readonly("sla_materials", [](PresetBundle& bundle) -> PresetCollection& {
return bundle.sla_materials;
}, py::return_value_policy::reference_internal)
.def("current_process_preset", [](PresetBundle& bundle) -> Preset& {
return bundle.prints.get_edited_preset();
}, py::return_value_policy::reference_internal)
.def("current_print_preset", [](PresetBundle& bundle) -> Preset& {
return bundle.prints.get_edited_preset();
}, py::return_value_policy::reference_internal)
.def("current_printer_preset", [](PresetBundle& bundle) -> Preset& {
return bundle.printers.get_edited_preset();
}, py::return_value_policy::reference_internal)
.def("current_filament_preset_names", [](PresetBundle& bundle) {
return bundle.filament_presets;
})
.def("current_filament_presets", &current_filament_presets)
.def("full_config_keys", [](const PresetBundle& bundle) {
return bundle.full_config().keys();
})
.def("full_config_value", [](const PresetBundle& bundle, const std::string& key) {
return config_value_or_none(bundle.full_config(), key);
});
}
} // namespace Slic3r

View File

@@ -0,0 +1,352 @@
#include "PluginHostBindings.hpp"
#include "slic3r/plugin/PluginBindingUtils.hpp"
#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 <vector>
namespace py = pybind11;
namespace Slic3r {
namespace {
// 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);
}
}
// Rebuild a layer's per-island bbox cache from lslices — the same inline pattern
// every C++ call site uses (PrintObjectSlice.cpp, Print.cpp, TreeSupport.cpp); no
// libslic3r helper exists to reuse.
static void refresh_lslices_bboxes(Layer& l)
{
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));
}
} // namespace
void host_bindings::register_slicing(py::module_& host)
{
// ------------------------------------------------------------------
// Slicing print-graph data model — raw bindings of the classes the C++
// pipeline itself uses, same nodelete/reference style as the Model and
// Preset graphs in PluginHostModel.cpp / PluginHostPresets.cpp.
//
// LIFETIME (C++ semantics, the one rule of this API): every object handed
// out below is a non-owning reference into the live slicing graph owned by
// 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 (SurfaceCollection.set / append / clear, Polygon.set_points / append,
// ExPolygon.set_holes) 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();
// Surface: default holder (Python-owned instances are freed), so plugins can construct
// their own Surface(surface_type, expolygon) — not just navigate the live slicing graph.
// expolygon is a reference_internal property, same idiom as the Polygon/ExPolygon
// accessors in PluginHostGeometry.cpp.
py::class_<Surface>(host, "Surface")
.def(py::init([](SurfaceType t, const ExPolygon& e) { return Surface(t, e); }),
py::arg("surface_type"), py::arg("expolygon"))
.def(py::init([](SurfaceType t) { return Surface(t); }), py::arg("surface_type"))
.def_readwrite("surface_type", &Surface::surface_type,
"This surface's SurfaceType. Assigning reclassifies it in place (geometry unchanged).")
.def_readwrite("thickness", &Surface::thickness)
.def_readwrite("bridge_angle", &Surface::bridge_angle)
.def_readwrite("extra_perimeters", &Surface::extra_perimeters)
.def_property("expolygon",
[](Surface& s) -> ExPolygon& { return s.expolygon; },
[](Surface& s, const ExPolygon& e) { s.expolygon = e; },
py::return_value_policy::reference_internal,
"This surface's geometry. Read returns a live ExPolygon ref; assign to replace it.")
.def("area", [](const Surface& s) { return s.area(); })
.def("is_top", [](const Surface& s) { return s.is_top(); })
.def("is_bottom", [](const Surface& s) { return s.is_bottom(); })
.def("is_bridge", [](const Surface& s) { return s.is_bridge(); })
.def("is_internal", [](const Surface& s) { return s.is_internal(); })
.def("is_external", [](const Surface& s) { return s.is_external(); })
.def("is_solid", [](const Surface& s) { return s.is_solid(); });
// SurfaceCollection: kept on py::nodelete — it is only ever a reference into the live
// slicing graph (LayerRegion::slices/fill_surfaces), never constructed by a plugin.
py::class_<SurfaceCollection, std::unique_ptr<SurfaceCollection, py::nodelete>>(host, "SurfaceCollection")
.def("size", [](const SurfaceCollection& c) { return c.surfaces.size(); })
.def("empty", [](const SurfaceCollection& c) { return c.empty(); })
.def("clear", [](SurfaceCollection& c) { c.clear(); })
.def("has", [](const SurfaceCollection& c, SurfaceType t) { return c.has(t); }, py::arg("surface_type"))
.def("set_type", [](SurfaceCollection& c, SurfaceType t) { c.set_type(t); }, py::arg("surface_type"))
.def("set", [](SurfaceCollection& c, const std::vector<ExPolygon>& src, SurfaceType t) { c.set(src, t); },
py::arg("expolygons"), py::arg("surface_type"),
"Replace all surfaces from a list of ExPolygon, all tagged `surface_type`.")
.def("set", [](SurfaceCollection& c, const std::vector<Surface>& src) { c.set(src); },
py::arg("surfaces"), "Replace all surfaces from a list of Surface (types preserved per surface).")
.def("append", [](SurfaceCollection& c, const std::vector<ExPolygon>& src, SurfaceType t) { c.append(src, t); },
py::arg("expolygons"), py::arg("surface_type"))
.def("filter_by_type", [](py::object self, SurfaceType t) {
SurfaceCollection& c = self.cast<SurfaceCollection&>();
py::list out;
// SurfaceCollection::filter_by_type returns SurfacesPtr, which is
// std::vector<const Surface*> (see Surface.hpp), so iterate by const
// pointer (py::cast accepts `const itype*` directly, see cast.h cast(const itype*)).
for (const Surface* s : c.filter_by_type(t))
out.append(py::cast(s, py::return_value_policy::reference_internal, self));
return out;
}, py::arg("surface_type"), "Surfaces of a given type as [Surface] refs. Invalidated by "
"set()/append()/clear() on this collection (C++ vector semantics), same as .surfaces.")
.def_property_readonly("surfaces", [](py::object self) {
SurfaceCollection& c = self.cast<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()/append()/clear() on this collection (C++ vector semantics).");
// --- Extrusion tree (read-only). Registered polymorphically: when a returned
// ExtrusionEntity*'s dynamic type IS one of the classes registered below, pybind
// hands the plugin that concrete type, so plugins walk the same tree shape C++ does.
// When the dynamic type is NOT registered (e.g. ExtrusionLoopSloped, produced with
// 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). Edit in place, or replace with "
"slices.set(expolygons, surface_type). At Step.posSlice this is the primary mutation "
"target; the split slice loop runs make_perimeters() afterward so edits cascade downstream.")
.def_readonly("fill_surfaces", &LayerRegion::fill_surfaces,
"Surfaces prepared for infill (SurfaceCollection). Edit in place or via fill_surfaces.set(...).")
.def_readonly("perimeters", &LayerRegion::perimeters,
"Perimeter toolpaths (ExtrusionEntityCollection, read-only).")
.def_readonly("fills", &LayerRegion::fills,
"Infill toolpaths (ExtrusionEntityCollection, read-only).")
.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.");
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("make_slices", [](Layer& l) {
l.make_slices();
refresh_lslices_bboxes(l);
}, "Re-derive lslices (merged islands) from the region slices and refresh the bbox "
"cache — the C++ invariant-maintenance call after in-place slice edits.")
.def("lslices", [](py::object self) {
Layer& l = self.cast<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] refs (in-place editable). Derived from the "
"region slices; call make_slices() to re-derive after edits. Invalidated by make_slices().");
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).")
.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

View File

@@ -1,7 +1,7 @@
#include "PluginHostUi.hpp"
#include "PluginAuditManager.hpp"
#include "PythonInterpreter.hpp" // PythonGILState
#include "slic3r/plugin/PluginAuditManager.hpp"
#include "slic3r/plugin/PythonInterpreter.hpp" // PythonGILState
#include <slic3r/GUI/GUI_App.hpp>
#include <slic3r/GUI/MainFrame.hpp>

View File

@@ -1,30 +0,0 @@
#include "GCodePluginCapability.hpp"
#include "GCodePluginCapabilityTrampoline.hpp"
#include <pybind11/pybind11.h>
#include <pybind11/stl.h>
namespace py = pybind11;
namespace Slic3r {
void GCodePluginCapability::RegisterBindings(pybind11::module_& module, pybind11::enum_<PluginCapabilityType>& pluginTypes)
{
(void) pluginTypes;
auto gcode = module.def_submodule("gcode", "G-code API");
py::class_<GCodePluginContext, PluginContext>(gcode, "GCodePluginContext", "Context shared with G-code plugins")
.def(py::init<>())
.def_readwrite("gcode_path", &GCodePluginContext::gcode_path)
.def_readwrite("host", &GCodePluginContext::host)
.def_readwrite("output_name", &GCodePluginContext::output_name);
py::class_<GCodePluginCapability, PluginCapabilityInterface, PyGCodePluginCapabilityTrampoline, std::shared_ptr<GCodePluginCapability>>(gcode, "GCodePluginCapabilityBase")
.def(py::init<>())
.def("get_type", &GCodePluginCapability::get_type)
.def("execute", &GCodePluginCapability::execute);
}
} // namespace Slic3r

View File

@@ -1,27 +0,0 @@
#ifndef slic3r_GCodePluginCapability_hpp_
#define slic3r_GCodePluginCapability_hpp_
#include "../../PythonPluginInterface.hpp"
namespace Slic3r {
struct GCodePluginContext : public PluginContext {
std::string gcode_path;
std::string host;
std::string output_name;
};
class GCodePluginCapability : public PluginCapabilityInterface
{
public:
PluginCapabilityType get_type() const override { return PluginCapabilityType::PostProcessing; }
virtual ExecutionResult execute(const GCodePluginContext& ctx) = 0;
static void RegisterBindings(pybind11::module_ &module,
pybind11::enum_<PluginCapabilityType> &pluginTypes);
};
} // namespace Slic3r
#endif /* slic3r_GCodePluginCapability_hpp_ */

View File

@@ -1,35 +0,0 @@
#ifndef slic3r_GCodePluginCapabilityTrampoline_hpp_
#define slic3r_GCodePluginCapabilityTrampoline_hpp_
#include <filesystem>
#include "../../PyPluginTrampoline.hpp"
#include "../../PluginAuditManager.hpp"
#include "GCodePluginCapability.hpp"
namespace Slic3r {
class PyGCodePluginCapabilityTrampoline : public PyPluginCommonTrampoline<GCodePluginCapability>
{
public:
using PyPluginCommonTrampoline<GCodePluginCapability>::PyPluginCommonTrampoline;
ExecutionResult execute(const GCodePluginContext& ctx) override
{
ORCA_PY_OVERRIDE_AUDITED(
::Slic3r::PluginAuditManager::AuditMode::Loading,
[&] {
// G-code post-processing plugins may also write into the folder holding the
// current temp G-code file, in addition to the globally-allowed data_dir().
// The setup callback runs AFTER the context is constructed so the scoped root
// is not cleared by ScopedPluginAuditContext's constructor.
if (!ctx.gcode_path.empty())
::Slic3r::PluginAuditManager::instance().add_scoped_allowed_root(
std::filesystem::path(ctx.gcode_path).parent_path());
},
PYBIND11_OVERRIDE_PURE, ExecutionResult, GCodePluginCapability, execute, ctx);
}
};
} // namespace Slic3r
#endif

View File

@@ -0,0 +1,99 @@
#include "SlicingPipelinePluginCapability.hpp"
#include "SlicingPipelinePluginCapabilityTrampoline.hpp"
#include "slic3r/plugin/PluginBindingUtils.hpp" // config_value_or_none
#include "libslic3r/libslic3r.h" // unscale<>, live SCALING_FACTOR
#include <pybind11/stl.h> // std::map<std::string,std::string> -> dict for ctx.params
namespace py = pybind11;
namespace Slic3r {
bool SlicingPipelineContext::cancelled() const { return print && print->canceled(); }
void SlicingPipelinePluginCapability::RegisterBindings(py::module_& module, py::enum_<PluginCapabilityType>& pluginTypes) {
(void) pluginTypes; // unused: this capability defines its own Step enum (below) rather than extending the shared PluginCapabilityType enum.
auto slicing = module.def_submodule("slicing", "Slicing pipeline API (research/experimental).");
py::enum_<SlicingPipelineStepPlugin>(slicing, "Step")
.value("posSlice", SlicingPipelineStepPlugin::posSlice)
.value("posPerimeters", SlicingPipelineStepPlugin::posPerimeters)
.value("posEstimateCurledExtrusions", SlicingPipelineStepPlugin::posEstimateCurledExtrusions)
.value("posPrepareInfill", SlicingPipelineStepPlugin::posPrepareInfill) // after prepare_infill, before make_fills: editing fill_surfaces here CASCADES
.value("posInfill", SlicingPipelineStepPlugin::posInfill) // after make_fills: editing fill_surfaces here does NOT regenerate the fills
.value("posIroning", SlicingPipelineStepPlugin::posIroning)
.value("posContouring", SlicingPipelineStepPlugin::posContouring)
.value("posSupportMaterial", SlicingPipelineStepPlugin::posSupportMaterial)
.value("posDetectOverhangsForLift", SlicingPipelineStepPlugin::posDetectOverhangsForLift)
.value("posSimplifyPath", SlicingPipelineStepPlugin::posSimplifyPath) // covers all simplify sub-steps
.value("psWipeTower", SlicingPipelineStepPlugin::psWipeTower)
.value("psSkirtBrim", SlicingPipelineStepPlugin::psSkirtBrim)
// Post-process seam: fires in the GUI export path AFTER the classic post_process scripts, on the
// exported G-code file. Unlike every step above it is NOT fired by Print::process(): ctx.print and
// ctx.object are None; instead ctx.gcode_path / ctx.host / ctx.output_name are set and the plugin
// edits the file at ctx.gcode_path IN PLACE. May fire more than once per slice (file export and/or
// upload each fire once, on separate working copies) and its output is not reflected in the G-code
// preview (the viewer maps the pre-post-process file). ctx.config_value()/ctx.params still work.
.value("psGCodePostProcess", SlicingPipelineStepPlugin::psGCodePostProcess)
.export_values();
// The read-graph data model (Surface / ExPolygon / the extrusion tree / LayerRegion /
// Layer / PrintObject / Print) and the 2D-geometry mutators live in orca.host, registered
// by PluginHostSlicing.cpp. orca.slicing is workflow-only: Step, unscale, the context, and
// the capability base. See PluginHostSlicing.cpp for the mandatory reference-lifetime rule.
// Scaled integer coordinate -> millimeters. Reads the live SCALING_FACTOR at call
// time (1e-6 normal, 1e-5 for beds > 2147mm), so it is never cached.
slicing.def("unscale", [](coord_t v) { return unscale<double>(v); }, py::arg("coord"),
"Convert a scaled integer coordinate to millimeters (reads the live SCALING_FACTOR).");
py::class_<SlicingPipelineContext>(slicing, "SlicingPipelineContext")
.def_readonly("orca_version", &SlicingPipelineContext::orca_version)
.def_readonly("step", &SlicingPipelineContext::step)
.def_readonly("params", &SlicingPipelineContext::params,
"read-only dict of this plugin's [tool.orcaslicer.plugin.settings] values "
"(string->string). Parse the values you need, e.g. float(ctx.params['rate']).")
.def_readonly("gcode_path", &SlicingPipelineContext::gcode_path,
"Path to the working G-code file, set ONLY at Step.psGCodePostProcess. Edit it in "
"place; empty at every other step.")
.def_readonly("host", &SlicingPipelineContext::host,
"Target host at Step.psGCodePostProcess (\"File\", \"OctoPrint\", ...); empty otherwise.")
.def_readonly("output_name", &SlicingPipelineContext::output_name,
"Final output G-code name at Step.psGCodePostProcess (mirrors SLIC3R_PP_OUTPUT_NAME); "
"empty otherwise.")
.def_property_readonly("print", [](const SlicingPipelineContext& ctx) -> py::object {
if (ctx.print == nullptr)
return py::none();
return py::cast(ctx.print, py::return_value_policy::reference);
}, "The orca.host.Print being sliced — the raw slicing graph, exactly what the "
"C++ pipeline walks. Valid only during the execute(ctx) call. For mesh access "
"use ctx.print.model() (the Print's snapshot), never orca.host.model().")
.def_property_readonly("object", [](const SlicingPipelineContext& ctx) -> py::object {
if (ctx.object == nullptr)
return py::none();
// The hook signature hands objects out as const; they are genuinely mutable
// (owned by the Print), so the const_cast is safe — done once here at the
// graph entry point so Python steps receive a mutable PrintObject.
return py::cast(const_cast<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.")
.def("config_value", [](const SlicingPipelineContext& ctx, const std::string& key) -> py::object {
// In-pipeline steps read the live Print's full config; at psGCodePostProcess (print == null)
// fall back to the config the export path handed in.
if (ctx.print != nullptr)
return config_value_or_none(ctx.print->full_print_config(), key);
if (ctx.full_config != nullptr)
return config_value_or_none(*ctx.full_config, key);
return py::none();
}, py::arg("key"),
"serialized value of a resolved (full) print config option for this slice, or "
"None if absent. Shorthand for ctx.print.config_value(key).")
.def("cancelled", &SlicingPipelineContext::cancelled);
py::class_<SlicingPipelinePluginCapability, PluginCapabilityInterface,
PySlicingPipelinePluginCapabilityTrampoline,
std::shared_ptr<SlicingPipelinePluginCapability>>(slicing, "SlicingPipelineCapabilityBase")
.def(py::init<>())
.def("get_type", &SlicingPipelinePluginCapability::get_type)
.def("execute", &SlicingPipelinePluginCapability::execute);
}
} // namespace Slic3r

View File

@@ -0,0 +1,45 @@
#pragma once
#include "slic3r/plugin/PythonPluginInterface.hpp"
#include "libslic3r/Print.hpp" // SlicingPipelineStepPlugin, Print, PrintObject
#include <pybind11/pybind11.h>
#include <map>
#include <string>
namespace Slic3r {
// Workflow context handed to SlicingPipeline plugins. ctx.print / ctx.object
// are RAW references into the live slicing graph — the same objects the C++
// pipeline mutates. The data-model bindings and the mandatory lifetime rule
// (valid only during execute(ctx); mutators invalidate references into replaced
// containers, like std::vector iterators) live in
// src/slic3r/plugin/host/PluginHostSlicing.cpp.
struct SlicingPipelineContext {
std::string orca_version;
SlicingPipelineStepPlugin step { SlicingPipelineStepPlugin::posSlice };
Print* print { nullptr }; // present for in-pipeline steps; null at psGCodePostProcess
const PrintObject* object { nullptr }; // null for print-wide steps and psGCodePostProcess
// read-only per-plugin settings, populated by the dispatcher from the
// plugin's [tool.orcaslicer.plugin.settings] PEP-723 table. Exposed as
// ctx.params (dict of string->string).
std::map<std::string, std::string> params;
// Populated ONLY at Step.psGCodePostProcess (the GUI G-code export/post-process seam,
// PostProcessor.cpp). gcode_path is the working G-code file on disk that the plugin edits
// in place; host is the target ("File", "OctoPrint", ...); output_name mirrors
// SLIC3R_PP_OUTPUT_NAME. Empty at every other step.
std::string gcode_path;
std::string host;
std::string output_name;
// C++-only config fallback for psGCodePostProcess (no live Print graph there): config_value()
// reads it when `print` is null. Not exposed to Python directly. Never dereferenced elsewhere.
const DynamicPrintConfig* full_config { nullptr };
bool cancelled() const; // -> print->canceled() (false when print is null)
};
class SlicingPipelinePluginCapability : public PluginCapabilityInterface {
public:
PluginCapabilityType get_type() const override { return PluginCapabilityType::SlicingPipeline; }
virtual ExecutionResult execute(SlicingPipelineContext& ctx) = 0;
static void RegisterBindings(pybind11::module_& module, pybind11::enum_<PluginCapabilityType>& pluginTypes);
};
} // namespace Slic3r

View File

@@ -0,0 +1,29 @@
#pragma once
#include "SlicingPipelinePluginCapability.hpp"
#include "slic3r/plugin/PyPluginTrampoline.hpp"
#include "slic3r/plugin/PluginAuditManager.hpp"
#include <filesystem>
namespace Slic3r {
class PySlicingPipelinePluginCapabilityTrampoline : public PyPluginCommonTrampoline<SlicingPipelinePluginCapability> {
public:
using PyPluginCommonTrampoline<SlicingPipelinePluginCapability>::PyPluginCommonTrampoline;
ExecutionResult execute(SlicingPipelineContext& ctx) override {
ORCA_PY_OVERRIDE_AUDITED(
::Slic3r::PluginAuditManager::AuditMode::Loading,
[&]{
// At Step.psGCodePostProcess the plugin edits the exported G-code file, which lives
// outside data_dir() (a temp/output folder), so writing to it would otherwise be
// blocked by the audit sandbox. Grant that folder as a scoped allowed root. The setup
// callback runs AFTER the audit context is constructed, so the scoped root is not
// cleared by its constructor. Empty at every other step, so no extra access is
// granted to the geometry hooks.
if (!ctx.gcode_path.empty())
::Slic3r::PluginAuditManager::instance().add_scoped_allowed_root(
std::filesystem::path(ctx.gcode_path).parent_path());
},
PYBIND11_OVERRIDE_PURE,
ExecutionResult, SlicingPipelinePluginCapability, execute, ctx);
}
};
} // namespace Slic3r

View File

@@ -13,6 +13,7 @@ add_executable(${_TEST_NAME}_tests
test_printgcode.cpp
test_printobject.cpp
test_skirt_brim.cpp
test_slicing_pipeline_hook.cpp
test_support_material.cpp
test_trianglemesh.cpp
)

View File

@@ -0,0 +1,559 @@
#include <catch2/catch_test_macros.hpp>
#include "libslic3r/PrintConfig.hpp"
using namespace Slic3r;
TEST_CASE("slicing_pipeline_plugin option exists and defaults empty", "[slicing_pipeline]") {
DynamicPrintConfig cfg = DynamicPrintConfig::full_print_config();
const ConfigOptionStrings* opt = cfg.option<ConfigOptionStrings>("slicing_pipeline_plugin");
REQUIRE(opt != nullptr);
CHECK(opt->values.empty());
const ConfigOptionDef* def = cfg.def()->get("slicing_pipeline_plugin");
REQUIRE(def != nullptr);
CHECK(def->plugin_type == "slicing-pipeline");
CHECK(def->is_plugin_backed());
CHECK(def->gui_type == ConfigOptionDef::GUIType::plugin_picker);
}
#include "libslic3r/Print.hpp"
TEST_CASE("slicing pipeline hook setter is a no-op-safe injection", "[slicing_pipeline]") {
int calls = 0;
Slic3r::Print::set_slicing_pipeline_hook_fn(
[&](Slic3r::Print&, const Slic3r::PrintObject*, Slic3r::SlicingPipelineStepPlugin){ ++calls; });
Slic3r::Print::set_slicing_pipeline_hook_fn(nullptr); // reset — must be legal
CHECK(calls == 0);
}
#include "test_data.hpp"
#include <vector>
#include <algorithm>
using namespace Slic3r::Test;
TEST_CASE("SlicingPipeline hook fires once per step per object in order", "[slicing_pipeline]") {
struct Call { const Slic3r::PrintObject* obj; Slic3r::SlicingPipelineStepPlugin step; };
std::vector<Call> calls;
Slic3r::Print::set_slicing_pipeline_hook_fn(
[&](Slic3r::Print&, const Slic3r::PrintObject* o, Slic3r::SlicingPipelineStepPlugin s){ calls.push_back({o, s}); });
Slic3r::Print print; Slic3r::Model model;
Slic3r::DynamicPrintConfig config = Slic3r::DynamicPrintConfig::full_print_config();
config.set_key_value("slicing_pipeline_plugin", new Slic3r::ConfigOptionStrings({"probe"})); // activate
init_print({TestMesh::cube_20x20x20}, print, model, config);
print.process();
Slic3r::Print::set_slicing_pipeline_hook_fn(nullptr);
using S = Slic3r::SlicingPipelineStepPlugin;
auto count = [&](S s){ return std::count_if(calls.begin(), calls.end(), [&](const Call& c){ return c.step == s; }); };
CHECK(count(S::posSlice) == 1);
CHECK(count(S::posPerimeters) == 1);
CHECK(count(S::posPrepareInfill) == 1); // the prepare-infill seam fires once per object
CHECK(count(S::posInfill) == 1);
CHECK(count(S::psWipeTower) == 1);
CHECK(count(S::psSkirtBrim) == 1);
// psGCodePostProcess fires from the GUI export path, never from process():
CHECK(count(S::psGCodePostProcess) == 0);
// print-wide steps carry a null object:
for (const auto& c : calls)
if (c.step == S::psWipeTower || c.step == S::psSkirtBrim) CHECK(c.obj == nullptr);
// Slice must fire before Perimeters for the same object:
auto idx = [&](S s){ for (size_t i=0;i<calls.size();++i) if (calls[i].step==s) return (int)i; return -1; };
CHECK(idx(S::posSlice) < idx(S::posPerimeters));
CHECK(idx(S::posPerimeters) < idx(S::posPrepareInfill)); // prepare-infill fires after perimeters...
CHECK(idx(S::posPrepareInfill) < idx(S::posInfill)); // ...and before the fills are built
}
#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]") {
// Three configurations must all normalize to the same G-code:
// (activate=false, hook=none) baseline -- feature entirely absent.
// (activate=false, hook=noop) hook registered but option empty -> gated off, never fires.
// (activate=true, hook=noop) hook ACTIVE and firing at every pipeline seam, mutating
// nothing. This is the real backward-compat claim: an active
// but non-mutating hook must not perturb the output.
auto run = [](bool activate, bool set_noop_hook) {
Slic3r::Print print; Slic3r::Model model;
auto config = Slic3r::DynamicPrintConfig::full_print_config();
// Activating requires BOTH a non-empty option and a registered hook (see Print::apply).
if (activate)
config.set_key_value("slicing_pipeline_plugin", new Slic3r::ConfigOptionStrings({"probe"}));
if (set_noop_hook)
Slic3r::Print::set_slicing_pipeline_hook_fn([](Slic3r::Print&, const Slic3r::PrintObject*, Slic3r::SlicingPipelineStepPlugin){});
else
Slic3r::Print::set_slicing_pipeline_hook_fn(nullptr);
init_print({TestMesh::cube_20x20x20}, print, model, config);
std::string g = Slic3r::Test::gcode(print);
Slic3r::Print::set_slicing_pipeline_hook_fn(nullptr);
return g;
};
// Compare only machine-meaningful output (see strip_nondeterministic_gcode_lines): every
// motion/extrusion byte is still compared, so this proves the inactive hook -- and the
// active-but-non-mutating hook -- leave the real toolpath byte-identical.
const std::string baseline = strip_nondeterministic_gcode_lines(run(false, false)); // feature absent
CHECK(strip_nondeterministic_gcode_lines(run(false, true)) == baseline); // gated off: hook never fires
CHECK(strip_nondeterministic_gcode_lines(run(true, true)) == baseline); // active no-op hook fires everywhere, mutates nothing
}
// Gating negative path. With the option EMPTY the plugin is inactive, so a
// registered hook must NOT fire even once across a full slice (m_pipeline_plugin_active
// stays false in Print::apply). Distinct from the byte-identical test above: this asserts
// the gate directly by counting invocations rather than comparing output.
TEST_CASE("Empty option: registered hook is gated off and never fires", "[slicing_pipeline]") {
int calls = 0;
Slic3r::Print::set_slicing_pipeline_hook_fn(
[&](Slic3r::Print&, const Slic3r::PrintObject*, Slic3r::SlicingPipelineStepPlugin){ ++calls; });
Slic3r::Print print; Slic3r::Model model;
auto config = Slic3r::DynamicPrintConfig::full_print_config();
// option left EMPTY -> inactive regardless of the registered hook.
init_print({TestMesh::cube_20x20x20}, print, model, config);
print.process();
Slic3r::Print::set_slicing_pipeline_hook_fn(nullptr);
CHECK(calls == 0);
}
// Duplicate-skip gating. Two ModelObjects that share one mesh_ptr are detected as
// identical by Print::process()'s is_print_object_the_same(); the second becomes a shared
// (duplicate) object and is NOT re-sliced, so the Slice hook must fire exactly once even
// though there are two print objects. The clone shares mesh_ptr and copies the volume
// transformation/config (ModelVolume copy ctor), which the equality check requires.
TEST_CASE("Duplicate objects share a slice: Slice hook fires exactly once", "[slicing_pipeline]") {
int slice_calls = 0, perim_calls = 0;
Slic3r::Print::set_slicing_pipeline_hook_fn(
[&](Slic3r::Print&, const Slic3r::PrintObject*, Slic3r::SlicingPipelineStepPlugin s){
if (s == Slic3r::SlicingPipelineStepPlugin::posSlice) ++slice_calls;
if (s == Slic3r::SlicingPipelineStepPlugin::posPerimeters) ++perim_calls;
});
Slic3r::Print print; Slic3r::Model model;
auto config = Slic3r::DynamicPrintConfig::full_print_config();
config.set_key_value("slicing_pipeline_plugin", new Slic3r::ConfigOptionStrings({"probe"})); // activate
// init_print builds one arranged, on-bed cube object (o1).
init_print({TestMesh::cube_20x20x20}, print, model, config);
Slic3r::ModelObject* o1 = model.objects.front();
// Model::add_object(const ModelObject&) force-sets object extruder=1 on the clone; give o1
// the same so the two objects' configs match (is_print_object_the_same compares config).
if (!o1->config.has("extruder"))
o1->config.set_key_value("extruder", new Slic3r::ConfigOptionInt(1));
// Clone o1: shares mesh_ptr and copies the volume transformation + config (genuine duplicate).
Slic3r::ModelObject* o2 = model.add_object(*o1);
// Shift the clone in X so validate() sees no collision (20mm cubes -> 40mm centres = 20mm gap).
for (Slic3r::ModelInstance* inst : o2->instances)
inst->set_offset(inst->get_offset() + Slic3r::Vec3d(40.0, 0.0, 0.0));
print.apply(model, config);
print.validate();
print.set_status_silent();
print.process();
Slic3r::Print::set_slicing_pipeline_hook_fn(nullptr);
REQUIRE(print.objects().size() == 2); // two print objects present...
CHECK(slice_calls == 1); // ...but the duplicate is skipped -> one slice
CHECK(perim_calls == 1); // and one perimeters pass (the sliced object)
}
#include "libslic3r/Layer.hpp" // Layer, LayerRegion (full defs for the cascade hook)
#include "libslic3r/ClipperUtils.hpp" // offset_ex
// The correctness heart of the mutation feature. A C++ hook insets every
// region's `slices` at the Slice boundary (via SurfaceCollection::set with offset
// polygons); because make_perimeters() derives fill_surfaces from slices AFTER the
// Slice hook fires (see Print::process's split slice loop), the downstream
// fill_surfaces area must shrink relative to a baseline (un-inset) run. This proves
// the mutation cascade end-to-end using the same C++ APIs the Python mutators wrap.
TEST_CASE("Mutating slices at the Slice boundary cascades downstream", "[slicing_pipeline]") {
auto fill_area = [](bool inset) {
Slic3r::Print print; Slic3r::Model model;
auto config = Slic3r::DynamicPrintConfig::full_print_config();
config.set_key_value("slicing_pipeline_plugin", new Slic3r::ConfigOptionStrings({"probe"}));
if (inset) Slic3r::Print::set_slicing_pipeline_hook_fn(
[](Slic3r::Print&, const Slic3r::PrintObject* o, Slic3r::SlicingPipelineStepPlugin s){
if (s != Slic3r::SlicingPipelineStepPlugin::posSlice || !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) sf.expolygon = offset_ex(sf.expolygon, -scale_(1.0)).front();
r->slices.set(std::move(in));
}
});
else Slic3r::Print::set_slicing_pipeline_hook_fn(nullptr);
init_print({TestMesh::cube_20x20x20}, print, model, config);
print.process();
double a = 0; for (auto* l : print.objects().front()->layers()) for (auto* r : l->regions()) for (auto& s : r->fill_surfaces.surfaces) a += s.expolygon.area();
Slic3r::Print::set_slicing_pipeline_hook_fn(nullptr);
return a;
};
CHECK(fill_area(true) < fill_area(false));
}
TEST_CASE("Changing slicing_pipeline_plugin invalidates posSlice", "[slicing_pipeline]") {
Slic3r::Print print; Slic3r::Model model;
auto config = Slic3r::DynamicPrintConfig::full_print_config();
init_print({TestMesh::cube_20x20x20}, print, model, config);
print.process();
REQUIRE(print.objects().front()->is_step_done(posSlice));
config.set_key_value("slicing_pipeline_plugin", new Slic3r::ConfigOptionStrings({"probe"}));
print.apply(model, config);
CHECK_FALSE(print.objects().front()->is_step_done(posSlice)); // re-slice required
}
#include <catch2/matchers/catch_matchers_floating_point.hpp>
// A similarity transform (rotate + uniform scale) applied to slices at Step.posSlice, matching
// what the Twistify sample (sandboxes/orca_twistify_plugin_example_any.py) does. This C++ analogue
// rotates every region's slices a fixed 45 deg about the object's base-footprint center -- the same
// seam and cascade the sample drives through the slices.set() + Layer::make_slices() path. Two
// end-to-end invariants after process() confirm the approach:
// (1) a pure rotation is a similarity with scale 1, so total fill area is preserved, and
// (2) the mutation genuinely cascaded into make_perimeters' fill_surfaces -- a 20mm square
// rotated 45 deg becomes a diamond whose bbox is ~sqrt(2)x wider (it did not stay
// 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::SlicingPipelineStepPlugin s){
if (s != Slic3r::SlicingPipelineStepPlugin::posSlice || !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);
}
// The Twistify sample skips exact-identity layers entirely, but every transformed layer invokes
// the slices.set() write-back + make_perimeters re-run. This proves that write path is lossless
// for already-normalized (CCW contour / CW hole) input -- an active hook that re-sets every
// region's slices to their CURRENT geometry (the identity similarity transform) produces output
// byte-identical to an active hook that mutates nothing. Both runs are active (same config dump);
// the only difference is whether the write path ran, so equality isolates it.
TEST_CASE("Identity round-trip through slices.set() is byte-identical", "[slicing_pipeline]") {
auto run = [](bool roundtrip) {
Slic3r::Print print; Slic3r::Model model;
auto config = Slic3r::DynamicPrintConfig::full_print_config();
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::SlicingPipelineStepPlugin s){
if (!roundtrip || s != Slic3r::SlicingPipelineStepPlugin::posSlice || !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::SlicingPipelineStepPlugin s){
if (s != Slic3r::SlicingPipelineStepPlugin::posSlice || !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.
// All three runs register a hook (active path) so the comparison isolates only the mutation.
TEST_CASE("fill_surfaces mutation cascades at PrepareInfill but not at Infill", "[slicing_pipeline]") {
auto fill_paths = [](bool shrink, Slic3r::SlicingPipelineStepPlugin at) {
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::SlicingPipelineStepPlugin 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::SlicingPipelineStepPlugin;
const size_t base = fill_paths(false, S::posPrepareInfill); // active hook, no mutation
CHECK(base > 0);
CHECK(fill_paths(true, S::posPrepareInfill) < base); // mutation before make_fills cascades
CHECK(fill_paths(true, S::posInfill) == base); // mutation after make_fills is a no-op
}
// lslices (the layer's merged islands) are built once in slice() and never rebuilt by
// make_perimeters, so mutating region slices leaves them stale. The slices.set() + Layer::make_slices()
// path re-derives them; this C++ analogue proves the mechanism -- without the
// refresh the islands keep the original 20mm footprint, with it they track the 18mm inset.
TEST_CASE("refreshing lslices after a slice mutation makes islands track the geometry", "[slicing_pipeline]") {
auto lslices_width = [](bool refresh) {
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::SlicingPipelineStepPlugin s){
if (s != Slic3r::SlicingPipelineStepPlugin::posSlice || !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 the slices.set() + Layer::make_slices() path
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
}
#include <random> // deterministic RNG for the fuzzy-skin analogue below
// Fuzzy skin applied to the slice contours at the Slice boundary, matching what the Fuzzy
// Slices sample (sandboxes/orca_fuzzy_slices_plugin_any.py) does: resample every ring at
// 3/4..5/4 * point_distance and displace each new vertex +/-thickness along the segment
// normal (libslic3r's fuzzy_polyline with uniform noise). Unlike the count-preserving rotate
// test above, this is a count-CHANGING rebuild -- each ring is replaced by one with a
// different vertex count. Three end-to-end invariants after process() confirm the cascade:
// (1) the jitter is zero-mean, so total fill area is preserved within a few %,
// (2) the fuzz genuinely cascaded into make_perimeters' fill_surfaces -- their contours
// carry far more vertices than the crisp baseline square's,
// (3) displacement is bounded: the sliced footprint grows by at most ~2*thickness.
TEST_CASE("Fuzzing slice contours at the Slice boundary cascades with bounded displacement", "[slicing_pipeline]") {
using Catch::Matchers::WithinRel;
static constexpr double kThickness = 0.3, kPointDist = 0.8; // mm; the built-in fuzzy-skin defaults
struct Measure { double area; size_t verts; double width; };
auto measure = [](bool fuzz) -> Measure {
Slic3r::Print print; Slic3r::Model model;
auto config = Slic3r::DynamicPrintConfig::full_print_config();
config.set_key_value("slicing_pipeline_plugin", new Slic3r::ConfigOptionStrings({"probe"})); // active in both runs
if (fuzz) Slic3r::Print::set_slicing_pipeline_hook_fn(
[](Slic3r::Print&, const Slic3r::PrintObject* o, Slic3r::SlicingPipelineStepPlugin s){
if (s != Slic3r::SlicingPipelineStepPlugin::posSlice || !o) return;
const double thickness = scale_(kThickness);
const double min_dist = scale_(kPointDist) * 0.75;
const double rand_range = scale_(kPointDist) * 0.5;
std::mt19937 rng(0x5EED); // fixed seed: the run is deterministic
std::uniform_real_distribution<double> uni(0.0, 1.0);
auto fuzz_ring = [&](Slic3r::Points& pts) {
if (pts.size() < 3) return;
Slic3r::Points out;
double dist_left_over = uni(rng) * (min_dist / 2.0);
const Slic3r::Point* p0 = &pts.back();
for (const Slic3r::Point& p1 : pts) {
const Slic3r::Vec2d v = (p1 - *p0).cast<double>();
const double seg = v.norm();
if (seg > 0.0) {
double d = dist_left_over;
for (; d < seg; d += min_dist + uni(rng) * rand_range) {
const double r = (uni(rng) * 2.0 - 1.0) * thickness;
const Slic3r::Vec2d pa = p0->cast<double>() + v * (d / seg);
const Slic3r::Vec2d n = Slic3r::Vec2d(-v.y(), v.x()) / seg;
out.emplace_back((coord_t) std::llround(pa.x() + n.x() * r),
(coord_t) std::llround(pa.y() + n.y() * r));
}
dist_left_over = d - seg;
}
p0 = &p1;
}
if (out.size() >= 3) pts = std::move(out); // else: ring too short, keep it crisp
};
for (Slic3r::Layer* l : const_cast<Slic3r::PrintObject*>(o)->layers())
for (Slic3r::LayerRegion* r : l->regions()) {
Slic3r::Surfaces in = r->slices.surfaces;
for (auto& sf : in) {
fuzz_ring(sf.expolygon.contour.points);
for (auto& h : sf.expolygon.holes) fuzz_ring(h.points);
}
r->slices.set(std::move(in));
}
});
else Slic3r::Print::set_slicing_pipeline_hook_fn(nullptr);
init_print({TestMesh::cube_20x20x20}, print, model, config);
print.process();
Measure m { 0.0, 0, outer_slices_width(print) };
for (auto* l : print.objects().front()->layers())
for (auto* r : l->regions())
for (auto& sf : r->fill_surfaces.surfaces) {
m.area += sf.expolygon.area();
m.verts += sf.expolygon.contour.points.size();
}
Slic3r::Print::set_slicing_pipeline_hook_fn(nullptr);
return m;
};
const Measure base = measure(false);
const Measure fz = measure(true);
// (1) Zero-mean jitter: the fills add up to (nearly) the same area.
CHECK_THAT(fz.area, WithinRel(base.area, 0.05));
// (2) The resample cascaded downstream: fill boundaries derived from the fuzzed slices
// carry far more vertices than the baseline square's.
CHECK(fz.verts > 4 * base.verts);
// (3) Displacement is bounded by the +/-thickness jitter: the footprint widened, but by
// no more than ~2*thickness (one thickness per side, plus rounding slack).
CHECK(fz.width > base.width);
CHECK(fz.width < base.width + 2.5 * scale_(kThickness));
}

View File

@@ -410,14 +410,14 @@ TEST_CASE("save_to_json round-trips plugin capability references as strings", "[
namespace fs = boost::filesystem;
const fs::path tmp = fs::temp_directory_path() / fs::unique_path("orca_plugins_%%%%-%%%%.json");
const std::vector<std::string> refs = {
"local_plugin;;post_process",
"cloud_plugin;550e8400-e29b-41d4-a716-446655440000;post_process"
"local_plugin;;inset",
"cloud_plugin;550e8400-e29b-41d4-a716-446655440000;inset"
};
std::unique_ptr<DynamicPrintConfig> config_ptr(
DynamicPrintConfig::new_from_defaults_keys({"post_process_plugin"}));
DynamicPrintConfig::new_from_defaults_keys({"slicing_pipeline_plugin"}));
DynamicPrintConfig config = std::move(*config_ptr);
config.option<ConfigOptionStrings>("post_process_plugin", true)->values = refs;
config.option<ConfigOptionStrings>("slicing_pipeline_plugin", true)->values = refs;
config.save_to_json(tmp.string(), "test_preset", "User", "1.0.0.0");
nlohmann::json j;
@@ -425,7 +425,7 @@ TEST_CASE("save_to_json round-trips plugin capability references as strings", "[
boost::nowide::ifstream ifs(tmp.string());
ifs >> j;
}
REQUIRE(j["post_process_plugin"] == nlohmann::json(refs));
REQUIRE(j["slicing_pipeline_plugin"] == nlohmann::json(refs));
CHECK_FALSE(j.contains("plugins"));
DynamicPrintConfig reloaded = DynamicPrintConfig::full_print_config();
@@ -434,7 +434,7 @@ TEST_CASE("save_to_json round-trips plugin capability references as strings", "[
std::string reason;
REQUIRE(reloaded.load_from_json(tmp.string(), substitutions, true, key_values, reason) == 0);
CHECK(reason.empty());
CHECK(reloaded.option<ConfigOptionStrings>("post_process_plugin")->values == refs);
CHECK(reloaded.option<ConfigOptionStrings>("slicing_pipeline_plugin")->values == refs);
fs::remove(tmp);
}
@@ -446,17 +446,17 @@ TEST_CASE("plugin capability references survive string-map serialization", "[Con
};
DynamicPrintConfig original = DynamicPrintConfig::full_print_config();
original.option<ConfigOptionStrings>("post_process_plugin", true)->values = refs;
original.option<ConfigOptionStrings>("slicing_pipeline_plugin", true)->values = refs;
std::map<std::string, std::string> serialized{
{"post_process_plugin", original.option<ConfigOptionStrings>("post_process_plugin")->serialize()}
{"slicing_pipeline_plugin", original.option<ConfigOptionStrings>("slicing_pipeline_plugin")->serialize()}
};
CHECK(serialized["post_process_plugin"].find("\"master_plugin;;header-stamp\"") != std::string::npos);
CHECK(serialized["slicing_pipeline_plugin"].find("\"master_plugin;;header-stamp\"") != std::string::npos);
DynamicPrintConfig reloaded = DynamicPrintConfig::full_print_config();
reloaded.load_string_map(serialized, ForwardCompatibilitySubstitutionRule::Disable);
CHECK(reloaded.option<ConfigOptionStrings>("post_process_plugin")->values == refs);
CHECK(reloaded.option<ConfigOptionStrings>("slicing_pipeline_plugin")->values == refs);
}
TEST_CASE("parse_capability_ref parses local and cloud references", "[Config][plugin]") {
@@ -483,3 +483,55 @@ TEST_CASE("parse_capability_ref rejects malformed input", "[Config][plugin]") {
CHECK_FALSE(Slic3r::parse_capability_ref("plugin;;").has_value());
CHECK_FALSE(Slic3r::parse_capability_ref("plugin;uuid;").has_value());
}
namespace {
// Installs a stub capability resolver that echoes the capability type into the reference, so tests
// can assert each plugin-backed option resolved with its own ConfigOptionDef::plugin_type. Resets
// the global resolver on teardown -- tests run in random order and other cases assert the
// no-resolver behavior (an absent "plugins" manifest).
struct PluginResolverFixture {
PluginResolverFixture() {
ConfigBase::set_resolve_capability_fn([](const std::string& name, const std::string& type) {
return name.empty() ? std::string() : name + ";;" + type;
});
}
~PluginResolverFixture() { ConfigBase::set_resolve_capability_fn(nullptr); }
};
} // namespace
TEST_CASE_METHOD(PluginResolverFixture,
"update_plugin_manifest derives references generically from plugin-backed options",
"[Config][plugins]") {
// Both scalar (printer_agent) and vector (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(
{"slicing_pipeline_plugin", "printer_agent"}));
DynamicPrintConfig config = std::move(*config_ptr);
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("sp;;slicing-pipeline")));
REQUIRE_THAT(manifest, VectorContains(std::string("agent;;printer-connection")));
CHECK(manifest.size() == 2);
}
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(
{"slicing_pipeline_plugin", "printer_agent"}));
DynamicPrintConfig config = std::move(*config_ptr);
config.option<ConfigOptionStrings>("slicing_pipeline_plugin", true)->values = {"x", "x"}; // duplicate
// printer_agent stays at its default empty value -> contributes nothing to the manifest.
config.update_plugin_manifest();
const std::vector<std::string> manifest = config.option<ConfigOptionStrings>("plugins")->values;
CHECK(manifest == std::vector<std::string>{"x;;slicing-pipeline"});
}

View File

@@ -4,6 +4,7 @@ add_executable(${_TEST_NAME}_tests
test_plugin_host_api.cpp
test_plugin_capability_identifier.cpp
test_plugin_install.cpp
test_slicing_pipeline_bindings.cpp
test_plugin_sort.cpp
)

View File

@@ -0,0 +1,38 @@
#pragma once
// Shared embedded-interpreter bootstrap for slic3rutils tests that need a live Python
// interpreter (test_plugin_host_api.cpp, test_slicing_pipeline_bindings.cpp, ...).
#include <pybind11/embed.h>
#include <pybind11/pybind11.h>
#include <slic3r/plugin/PythonPluginBridge.hpp>
namespace {
void ensure_python_initialized()
{
// Deliberately a bare scoped_interpreter rather than Slic3r::PythonInterpreter:
// `orca` is a PYBIND11_EMBEDDED_MODULE compiled into this test binary, so importing
// it needs no bundled stdlib/sys.path, and the deterministic assertions are
// independent of the host's Python. PythonInterpreter::initialize() expects the
// bundled Python home laid out next to the app bundle (lib/python3.12/encodings),
// which is not deployed beside the test binary, so using it here would fail to find
// a home on macOS/Linux. The optional numpy-backed assertions are guarded at runtime.
if (!Py_IsInitialized()) {
static pybind11::scoped_interpreter interpreter;
(void) interpreter;
}
}
pybind11::module_ import_orca_module()
{
ensure_python_initialized();
// Force PythonPluginBridge.cpp into the test binary so the embedded
// PYBIND11_EMBEDDED_MODULE(orca, ...) registration is available.
(void) Slic3r::PythonPluginBridge::instance();
return pybind11::module_::import("orca");
}
} // namespace

View File

@@ -8,9 +8,9 @@ using Slic3r::PluginCapabilityIdentifier;
using Slic3r::PluginCapabilityType;
TEST_CASE("PluginCapabilityIdentifier equality includes plugin_key", "[plugin][identifier]") {
PluginCapabilityIdentifier a{PluginCapabilityType::PostProcessing, "Cleanup", "a.py"};
PluginCapabilityIdentifier b{PluginCapabilityType::PostProcessing, "Cleanup", "b.py"};
PluginCapabilityIdentifier a2{PluginCapabilityType::PostProcessing, "Cleanup", "a.py"};
PluginCapabilityIdentifier a{PluginCapabilityType::SlicingPipeline, "Cleanup", "a.py"};
PluginCapabilityIdentifier b{PluginCapabilityType::SlicingPipeline, "Cleanup", "b.py"};
PluginCapabilityIdentifier a2{PluginCapabilityType::SlicingPipeline, "Cleanup", "a.py"};
CHECK(a == a2);
CHECK_FALSE(a == b); // same (type,name), different plugin_key -> distinct
@@ -18,8 +18,8 @@ TEST_CASE("PluginCapabilityIdentifier equality includes plugin_key", "[plugin][i
TEST_CASE("PluginCapabilityIdentifier is usable as a hash-map key", "[plugin][identifier]") {
std::unordered_map<PluginCapabilityIdentifier, int> m;
m[{PluginCapabilityType::PostProcessing, "Cleanup", "a.py"}] = 1;
m[{PluginCapabilityType::PostProcessing, "Cleanup", "b.py"}] = 2; // no collision
m[{PluginCapabilityType::SlicingPipeline, "Cleanup", "a.py"}] = 1;
m[{PluginCapabilityType::SlicingPipeline, "Cleanup", "b.py"}] = 2; // no collision
CHECK(m.size() == 2);
CHECK(m.at({PluginCapabilityType::PostProcessing, "Cleanup", "a.py"}) == 1);
CHECK(m.at({PluginCapabilityType::SlicingPipeline, "Cleanup", "a.py"}) == 1);
}

View File

@@ -5,6 +5,8 @@
#include <libslic3r/TriangleMesh.hpp>
#include <slic3r/plugin/PythonPluginBridge.hpp>
#include "python_test_support.hpp"
#include <pybind11/embed.h>
#include <pybind11/pybind11.h>
@@ -14,30 +16,8 @@ namespace py = pybind11;
namespace {
void ensure_python_initialized()
{
// Deliberately a bare scoped_interpreter rather than Slic3r::PythonInterpreter:
// `orca` is a PYBIND11_EMBEDDED_MODULE compiled into this test binary, so importing
// it needs no bundled stdlib/sys.path, and the deterministic assertions are
// independent of the host's Python. PythonInterpreter::initialize() expects the
// bundled Python home laid out next to the app bundle (lib/python3.12/encodings),
// which is not deployed beside the test binary, so using it here would fail to find
// a home on macOS/Linux. The optional numpy-backed assertions are guarded at runtime.
if (!Py_IsInitialized()) {
static py::scoped_interpreter interpreter;
(void) interpreter;
}
}
py::module_ import_orca_module()
{
ensure_python_initialized();
// Force PythonPluginBridge.cpp into the test binary so the embedded
// PYBIND11_EMBEDDED_MODULE(orca, ...) registration is available.
(void) Slic3r::PythonPluginBridge::instance();
return py::module_::import("orca");
}
// import_orca_module() lives in python_test_support.hpp (shared with
// test_slicing_pipeline_bindings.cpp).
bool has_attr(const py::handle& object, const char* name)
{
@@ -46,7 +26,7 @@ bool has_attr(const py::handle& object, const char* name)
} // namespace
TEST_CASE("Plugin host API exposes host-owned bundle and preset surface to Python", "[PluginHostApi][Python]")
TEST_CASE("Plugin host API exposes host-owned bundle and preset surface to Python", "[PluginHost][Python]")
{
py::module_ orca = import_orca_module();
REQUIRE(has_attr(orca, "host"));
@@ -131,7 +111,7 @@ TEST_CASE("Plugin host API exposes host-owned bundle and preset surface to Pytho
CHECK(printers.attr("find_preset")(printer_preset.name).attr("name").cast<std::string>() == printer_preset.name);
}
TEST_CASE("Plugin host API reports unavailable GUI objects before Orca app initialization", "[PluginHostApi][Python]")
TEST_CASE("Plugin host API reports unavailable GUI objects before Orca app initialization", "[PluginHost][Python]")
{
py::object host = import_orca_module().attr("host");
@@ -147,7 +127,7 @@ TEST_CASE("Plugin host API reports unavailable GUI objects before Orca app initi
}
}
TEST_CASE("Plugin host API exposes the UI module and guards it before Orca app initialization", "[PluginHostApi][Python]")
TEST_CASE("Plugin host API exposes the UI module and guards it before Orca app initialization", "[PluginHost][Python]")
{
py::object host = import_orca_module().attr("host");
REQUIRE(has_attr(host, "ui"));
@@ -169,7 +149,7 @@ TEST_CASE("Plugin host API exposes the UI module and guards it before Orca app i
}
}
TEST_CASE("Plugin host API exposes model geometry and structure to Python", "[PluginHostApi][Python]")
TEST_CASE("Plugin host API exposes model geometry and structure to Python", "[PluginHost][Python]")
{
using Catch::Matchers::WithinAbs;
using Catch::Matchers::WithinRel;
@@ -248,7 +228,7 @@ TEST_CASE("Plugin host API exposes model geometry and structure to Python", "[Pl
CHECK(py_modifier.attr("type")().cast<Slic3r::ModelVolumeType>() == Slic3r::ModelVolumeType::PARAMETER_MODIFIER);
}
TEST_CASE("Plugin host API exposes TriangleMesh geometry to Python", "[PluginHostApi][Python]")
TEST_CASE("Plugin host API exposes TriangleMesh geometry to Python", "[PluginHost][Python]")
{
using Catch::Matchers::WithinAbs;
using Catch::Matchers::WithinRel;

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

View File

@@ -0,0 +1,682 @@
#include <catch2/catch_test_macros.hpp>
#include "slic3r/plugin/PythonPluginInterface.hpp"
using namespace Slic3r;
TEST_CASE("SlicingPipeline capability-type string maps round-trip", "[slicing_pipeline]") {
CHECK(plugin_capability_type_to_string(PluginCapabilityType::SlicingPipeline) == "slicing-pipeline");
CHECK(plugin_capability_type_display_name(PluginCapabilityType::SlicingPipeline) == "Slicing Pipeline");
CHECK(plugin_capability_type_from_string("slicing-pipeline") == PluginCapabilityType::SlicingPipeline);
CHECK(plugin_capability_type_from_string("SLICING-PIPELINE") == PluginCapabilityType::SlicingPipeline);
CHECK(plugin_capability_type_from_string("nope") == PluginCapabilityType::Unknown);
}
#include "python_test_support.hpp"
#include "slic3r/plugin/PluginBindingUtils.hpp"
#include "slic3r/plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.hpp"
#include "libslic3r/Point.hpp"
#include "libslic3r/ExPolygon.hpp"
#include "libslic3r/Surface.hpp"
#include "libslic3r/Layer.hpp"
#include "libslic3r/ExtrusionEntity.hpp"
#include "libslic3r/ExtrusionEntityCollection.hpp"
#include <catch2/matchers/catch_matchers_floating_point.hpp>
#include <pybind11/embed.h>
#include <pybind11/numpy.h>
namespace py = pybind11;
TEST_CASE("make_readonly_rows builds a read-only (N,2) int64 view", "[slicing_pipeline]") {
ensure_python_initialized(); // helper already used by test_plugin_host_api.cpp
py::gil_scoped_acquire gil;
// make_readonly_rows() constructs a py::array_t, which requires numpy to be
// importable in the embedded interpreter. The unit-test interpreter ships no
// site-packages (same condition test_plugin_host_api.cpp's TriangleMesh numpy
// test guards against), so skip the array-backed assertions when numpy is
// unavailable there rather than fail on an environment quirk.
bool have_numpy = false;
try {
py::module_::import("numpy");
have_numpy = true;
} catch (const py::error_already_set&) {
have_numpy = false;
}
if (!have_numpy) {
SKIP("numpy unavailable in unit-test interpreter");
}
static Slic3r::Points pts = { Slic3r::Point(10, 20), Slic3r::Point(30, 40) };
py::capsule keepalive(&pts, [](void*){});
py::array a = Slic3r::make_readonly_rows<coord_t, 2>(keepalive, pts.front().data(), (py::ssize_t)pts.size());
CHECK(a.dtype().kind() == 'i');
CHECK(a.itemsize() == 8); // int64
CHECK(a.shape(0) == 2);
CHECK(a.shape(1) == 2);
CHECK_FALSE(a.writeable());
auto r = a.unchecked<coord_t, 2>();
CHECK(r(0,0) == 10); CHECK(r(1,1) == 40);
}
TEST_CASE("make_writable_rows builds a writable (N,2) int64 view that aliases the buffer", "[slicing_pipeline]") {
ensure_python_initialized();
py::gil_scoped_acquire gil;
bool have_numpy = false;
try { py::module_::import("numpy"); have_numpy = true; }
catch (const py::error_already_set&) { have_numpy = false; }
if (!have_numpy) SKIP("numpy unavailable in unit-test interpreter");
static Slic3r::Points pts = { Slic3r::Point(10, 20), Slic3r::Point(30, 40) };
py::capsule keepalive(&pts, [](void*){});
py::array a = Slic3r::make_writable_rows<coord_t, 2>(keepalive, pts.front().data(), (py::ssize_t)pts.size());
CHECK(a.writeable());
// Writing through the view mutates the C++ buffer (zero-copy alias).
a.attr("__setitem__")(py::make_tuple(0, 0), py::int_(99));
CHECK(pts.front().x() == 99);
}
TEST_CASE("orca.slicing module: Step enum, context, and a Python capability can execute", "[slicing_pipeline]") {
ensure_python_initialized();
import_orca_module(); // forces PythonPluginBridge::instance() (see import_orca_module in python_test_support.hpp)
py::gil_scoped_acquire gil;
py::module_ orca = py::module_::import("orca");
REQUIRE(py::hasattr(orca, "slicing"));
py::object slicing = orca.attr("slicing");
CHECK(py::hasattr(slicing, "Step"));
CHECK(py::hasattr(slicing.attr("Step"), "posSlice"));
CHECK(py::hasattr(slicing.attr("Step"), "psGCodePostProcess"));
CHECK(py::hasattr(slicing, "SlicingPipelineContext"));
CHECK(py::hasattr(slicing, "SlicingPipelineCapabilityBase"));
// A trivial Python subclass whose execute() reports success, invoked via the C++ trampoline.
py::exec(R"(
import orca
class Probe(orca.slicing.SlicingPipelineCapabilityBase):
def get_name(self): return "probe"
def execute(self, ctx): return orca.ExecutionResult.success("ok")
_probe = Probe()
)");
// (Full C++ trampoline invocation with a real context is exercised elsewhere.)
}
TEST_CASE("orca.slicing is workflow-only: context exposes raw print/object; view classes are gone", "[slicing_pipeline]") {
using Catch::Matchers::WithinRel;
ensure_python_initialized();
import_orca_module();
py::gil_scoped_acquire gil;
py::module_ orca = py::module_::import("orca");
py::object slicing = orca.attr("slicing");
// Context surface: raw graph entry points + workflow accessors.
for (const char* name : { "print", "object", "params", "config_value", "cancelled",
"orca_version", "step" })
CHECK(py::hasattr(slicing.attr("SlicingPipelineContext"), name));
// The wrapper layer is gone.
for (const char* legacy : { "ExPolygonView", "SurfaceView", "LayerRegionView",
"LayerView", "PrintObjectView", "PathData", "SurfaceType" })
CHECK_FALSE(py::hasattr(slicing, legacy));
// unscale() stays in orca.slicing and reads the live SCALING_FACTOR.
const coord_t scaled10 = (coord_t) scale_(10.0);
double mm = slicing.attr("unscale")(scaled10).cast<double>();
CHECK_THAT(mm, WithinRel(10.0, 1e-9));
// A default context casts print/object to None (no dangling wrapper).
Slic3r::SlicingPipelineContext ctx;
py::object pyctx = py::cast(&ctx, py::return_value_policy::reference);
CHECK(pyctx.attr("print").is_none());
CHECK(pyctx.attr("object").is_none());
}
#include "libslic3r/PrintConfig.hpp" // DynamicPrintConfig for the psGCodePostProcess context
#include <boost/filesystem.hpp>
#include <boost/nowide/fstream.hpp>
#include <sstream>
// psGCodePostProcess is the merged post-processing seam: no live Print (print/object are None), the
// plugin edits the file at ctx.gcode_path in place, and ctx.config_value() falls back to the config
// the export path handed in. Exercising the real bindings by calling the Python execute() directly
// (not the C++ audit trampoline) keeps this a pure binding-surface test.
TEST_CASE("orca.slicing psGCodePostProcess context: file edit in place + config fallback", "[slicing_pipeline]") {
namespace fs = boost::filesystem;
ensure_python_initialized();
import_orca_module();
py::gil_scoped_acquire gil;
const fs::path gpath = fs::temp_directory_path() / fs::unique_path("orca_pp_%%%%-%%%%.gcode");
{
boost::nowide::ofstream ofs(gpath.string());
ofs << "; header\nG1 X0 Y0\n";
}
// Config the plugin reads back through ctx.config_value() (there is no live Print at this step).
Slic3r::DynamicPrintConfig config = Slic3r::DynamicPrintConfig::full_print_config();
config.set_key_value("layer_height", new Slic3r::ConfigOptionFloat(0.2));
Slic3r::SlicingPipelineContext ctx;
ctx.orca_version = "test";
ctx.step = Slic3r::SlicingPipelineStepPlugin::psGCodePostProcess;
ctx.gcode_path = gpath.string();
ctx.host = "File";
ctx.output_name = "final.gcode";
ctx.full_config = &config; // print stays null
py::object pyctx = py::cast(&ctx, py::return_value_policy::reference);
CHECK(pyctx.attr("gcode_path").cast<std::string>() == gpath.string());
CHECK(pyctx.attr("host").cast<std::string>() == "File");
CHECK(pyctx.attr("output_name").cast<std::string>() == "final.gcode");
CHECK(pyctx.attr("print").is_none());
CHECK(pyctx.attr("object").is_none());
CHECK(pyctx.attr("step").cast<Slic3r::SlicingPipelineStepPlugin>()
== Slic3r::SlicingPipelineStepPlugin::psGCodePostProcess);
CHECK_FALSE(pyctx.attr("cancelled")().cast<bool>()); // null print -> not cancelled
// config_value() resolves from full_config when print is null; unknown keys are None.
CHECK_FALSE(pyctx.attr("config_value")("layer_height").is_none());
CHECK(pyctx.attr("config_value")("this_key_does_not_exist").is_none());
// A Python capability edits the file in place through ctx.gcode_path. Calling execute() directly
// in Python dispatches to the Python method (no C++ trampoline), so this needs no audit context.
py::module_ main = py::module_::import("__main__");
main.attr("_pp_ctx") = pyctx;
py::exec(R"(
import orca
class Stamp(orca.slicing.SlicingPipelineCapabilityBase):
def get_name(self): return "stamp"
def execute(self, ctx):
assert ctx.step == orca.slicing.Step.psGCodePostProcess
assert ctx.print is None and ctx.object is None
with open(ctx.gcode_path, "a") as f:
f.write("; stamped by " + ctx.host + "\n")
return orca.ExecutionResult.success("ok")
_pp_result = Stamp().execute(_pp_ctx)
)");
CHECK(main.attr("_pp_result").attr("message").cast<std::string>() == std::string("ok"));
std::string contents;
{
boost::nowide::ifstream ifs(gpath.string());
std::stringstream ss; ss << ifs.rdbuf(); contents = ss.str();
}
CHECK(contents.find("; stamped by File") != std::string::npos);
fs::remove(gpath);
}
// ---------------------------------------------------------------------------
// Toolpath helpers for the raw-graph tests.
//
// LayerRegion's ctor is protected (constructed only by Layer/PrintObject). A
// trivial derived struct lets a unit test build one with null layer/region
// pointers — the extrusion accessors only read the public `perimeters`/`fills`
// collections, never the layer/region back-pointers.
// ---------------------------------------------------------------------------
namespace {
struct TestLayerRegion : Slic3r::LayerRegion {
TestLayerRegion() : Slic3r::LayerRegion(nullptr, nullptr) {}
};
// Build a realistic nested perimeters collection into `region.perimeters`:
// perimeters (outer) -> inner collection -> [ ExtrusionLoop(pathA), ExtrusionPath(pathB) ]
// This exercises both the recursive descent through nested collections and the
// decomposition of an ExtrusionLoop into its contained ExtrusionPath (flatten()
// does NOT decompose loops, hence the hand-rolled recursive walk).
static void build_nested_perimeters(TestLayerRegion& region) {
using namespace Slic3r;
ExtrusionPath pathA(erExternalPerimeter); // -> "Outer wall"
pathA.mm3_per_mm = 0.05; pathA.width = 0.45f; pathA.height = 0.20f;
pathA.polyline.points = { Point3(0, 0, 0), Point3(10, 0, 0), Point3(10, 10, 0) };
ExtrusionPath pathB(erInternalInfill); // -> "Sparse infill"
pathB.mm3_per_mm = 0.03; pathB.width = 0.40f; pathB.height = 0.20f;
pathB.polyline.points = { Point3(1, 1, 0), Point3(2, 1, 0), Point3(2, 2, 0) };
ExtrusionEntityCollection inner;
inner.append(ExtrusionLoop(pathA)); // clone_move
inner.append(pathB); // clone
region.perimeters.append(inner); // nested (deep clone)
}
} // namespace
// ---------------------------------------------------------------------------
// Raw Print-graph data model (orca.host) — replaces the *View wrapper API.
// LIFETIME: raw bindings follow C++ semantics — references into the slicing
// graph are valid during execute(ctx) and invalidated by container-replacing
// mutators, exactly like std::vector iterators.
// ---------------------------------------------------------------------------
TEST_CASE("orca.host leaf geometry: Surface/ExPolygon/Polygon raw bindings", "[slicing_pipeline]") {
using Catch::Matchers::WithinRel;
using Catch::Matchers::WithinAbs;
ensure_python_initialized();
import_orca_module();
py::gil_scoped_acquire gil;
py::object host = py::module_::import("orca").attr("host");
for (const char* name : { "SurfaceType", "Polygon", "ExPolygon", "Surface", "SurfaceCollection" })
CHECK(py::hasattr(host, name));
// SurfaceType enum values round-trip to the C++ enumerators (moved from orca.slicing).
py::object ST = host.attr("SurfaceType");
CHECK(ST.attr("stTop").cast<Slic3r::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 Surface/SurfaceCollection: construct, writable members, set()", "[slicing_pipeline]") {
using Catch::Matchers::WithinRel;
ensure_python_initialized();
import_orca_module();
py::gil_scoped_acquire gil;
py::object host = py::module_::import("orca").attr("host");
py::object ST = host.attr("SurfaceType");
const coord_t s = (coord_t) scale_(10.0);
// Build an ExPolygon (Point idiom) and a Surface from it.
py::object P = host.attr("Polygon")();
P.attr("append")(host.attr("Point")(0, 0));
P.attr("append")(host.attr("Point")(s, 0));
P.attr("append")(host.attr("Point")(s, s));
P.attr("append")(host.attr("Point")(0, s));
py::object ex = host.attr("ExPolygon")(P);
py::object surf = host.attr("Surface")(ST.attr("stTop"), ex);
CHECK(surf.attr("surface_type").cast<Slic3r::SurfaceType>() == Slic3r::stTop);
CHECK(surf.attr("is_top")().cast<bool>());
CHECK_THAT(surf.attr("area")().cast<double>(), WithinRel((double) s * (double) s, 1e-9));
surf.attr("thickness") = py::float_(0.3);
CHECK_THAT(surf.attr("thickness").cast<double>(), WithinRel(0.3, 1e-9));
// SurfaceCollection.set(expolys, type): replace all surfaces from a list of ExPolygon tagged with one SurfaceType.
Slic3r::SurfaceCollection coll;
py::object cv = py::cast(&coll, py::return_value_policy::reference);
py::list expolys; expolys.append(ex);
cv.attr("set")(expolys, ST.attr("stInternalSolid"));
REQUIRE(coll.surfaces.size() == 1);
CHECK(coll.surfaces.front().surface_type == Slic3r::stInternalSolid);
CHECK(cv.attr("has")(ST.attr("stInternalSolid")).cast<bool>());
cv.attr("clear")();
CHECK(coll.surfaces.empty());
}
TEST_CASE("orca.host Point: construct, read/write coords, arithmetic", "[slicing_pipeline]") {
ensure_python_initialized();
import_orca_module();
py::gil_scoped_acquire gil;
py::object host = py::module_::import("orca").attr("host");
REQUIRE(py::hasattr(host, "Point"));
py::object p = host.attr("Point")(3, 4);
CHECK(p.attr("x").cast<coord_t>() == 3);
CHECK(p.attr("y").cast<coord_t>() == 4);
p.attr("x") = py::int_(7);
CHECK(p.attr("x").cast<coord_t>() == 7);
py::object q = host.attr("Point")(1, 2);
py::object sum = p.attr("__add__")(q);
CHECK(sum.attr("x").cast<coord_t>() == 8);
CHECK(sum.attr("y").cast<coord_t>() == 6);
// __mul__ must scale as a double, not truncate to int64 before multiplying.
py::object h = host.attr("Point")(10, 20).attr("__mul__")(py::float_(0.5));
CHECK(h.attr("x").cast<coord_t>() == 5);
CHECK(h.attr("y").cast<coord_t>() == 10);
}
TEST_CASE("orca.host Polygon: writable as_array aliases buffer; Point refs; set_points; offset", "[slicing_pipeline]") {
using Catch::Matchers::WithinRel;
ensure_python_initialized();
import_orca_module();
py::gil_scoped_acquire gil;
py::object host = py::module_::import("orca").attr("host");
const coord_t s = (coord_t) scale_(10.0);
Slic3r::Polygon poly;
poly.points = { Slic3r::Point(0, 0), Slic3r::Point(s, 0), Slic3r::Point(s, s), Slic3r::Point(0, s) };
py::object pv = py::cast(&poly, py::return_value_policy::reference);
// Non-array surface works without numpy.
CHECK(pv.attr("size")().cast<size_t>() == 4);
CHECK(pv.attr("is_counter_clockwise")().cast<bool>());
CHECK_THAT(pv.attr("area")().cast<double>(), WithinRel((double) s * (double) s, 1e-9));
// Point-object idiom: editing a returned Point ref mutates the buffer in place.
py::list pts = pv.attr("points").cast<py::list>();
REQUIRE(pts.size() == 4);
pts[0].attr("x") = py::int_(5);
CHECK(poly.points[0].x() == 5);
poly.points[0].x() = 0; // restore
// offset() returns new geometry (ClipperUtils bound as a method).
py::list shrunk = pv.attr("offset")(py::int_(-(coord_t)scale_(1.0))).cast<py::list>();
CHECK(shrunk.size() >= 1);
bool have_numpy = false;
try { py::module_::import("numpy"); have_numpy = true; }
catch (const py::error_already_set&) { have_numpy = false; }
if (!have_numpy) SKIP("numpy unavailable: array-backed assertions skipped");
py::module_ np = py::module_::import("numpy");
py::array a = pv.attr("as_array")().cast<py::array>();
CHECK(a.dtype().kind() == 'i');
CHECK(a.itemsize() == 8);
CHECK(a.shape(0) == 4);
CHECK(a.shape(1) == 2);
CHECK(a.writeable()); // writable now
a.attr("__setitem__")(py::make_tuple(0, 0), py::int_(123));
CHECK(poly.points[0].x() == 123); // in-place bulk edit
// set_points replaces contents (count-changing).
py::object i64 = np.attr("int64");
py::list rows;
rows.append(py::make_tuple(0, 0)); rows.append(py::make_tuple(s, 0)); rows.append(py::make_tuple(s, s));
pv.attr("set_points")(np.attr("array")(rows, py::arg("dtype") = i64));
CHECK(poly.points.size() == 3);
}
TEST_CASE("orca.host ExPolygon: construct, writable contour/holes, transforms, boolean ops", "[slicing_pipeline]") {
using Catch::Matchers::WithinRel;
ensure_python_initialized();
import_orca_module();
py::gil_scoped_acquire gil;
py::object host = py::module_::import("orca").attr("host");
const coord_t s = (coord_t) scale_(10.0);
// Construct from Polygon objects (Point idiom, no numpy).
py::object P = host.attr("Polygon")();
P.attr("append")(host.attr("Point")(0, 0));
P.attr("append")(host.attr("Point")(s, 0));
P.attr("append")(host.attr("Point")(s, s));
P.attr("append")(host.attr("Point")(0, s));
py::object ex = host.attr("ExPolygon")(P);
CHECK_THAT(ex.attr("area")().cast<double>(), WithinRel((double) s * (double) s, 1e-9));
CHECK(ex.attr("num_contours")().cast<size_t>() == 1);
CHECK(ex.attr("contour").attr("size")().cast<size_t>() == 4);
// In-place transform mutates the geometry.
ex.attr("translate")(py::float_(1000.0), py::float_(0.0));
// Boolean op returns new geometry: A minus a smaller inset of A is a non-empty ring set.
py::list inset = ex.attr("offset")(py::int_(-(coord_t)scale_(1.0))).cast<py::list>();
REQUIRE(inset.size() >= 1);
py::list ring = ex.attr("diff_ex")(inset[0]).cast<py::list>();
CHECK(ring.size() >= 1);
}
namespace {
// Nested collection: outer -> inner -> [ ExtrusionLoop(pathA), ExtrusionPath(pathB) ].
// Exercises polymorphic downcast of .entities and loop decomposition in flatten_paths().
static Slic3r::ExtrusionEntityCollection build_nested_collection() {
using namespace Slic3r;
ExtrusionPath pathA(erExternalPerimeter); // -> "Outer wall"
pathA.mm3_per_mm = 0.05; pathA.width = 0.45f; pathA.height = 0.20f;
pathA.polyline.points = { Point3(0, 0, 0), Point3(10, 0, 0), Point3(10, 10, 0) };
ExtrusionPath pathB(erInternalInfill); // -> "Sparse infill"
pathB.mm3_per_mm = 0.03; pathB.width = 0.40f; pathB.height = 0.20f;
pathB.polyline.points = { Point3(1, 1, 0), Point3(2, 1, 0), Point3(2, 2, 0) };
ExtrusionEntityCollection inner;
inner.append(ExtrusionLoop(pathA));
inner.append(pathB);
ExtrusionEntityCollection outer;
outer.append(inner);
return outer;
}
} // namespace
TEST_CASE("orca.host extrusion tree: polymorphic entities + flatten_paths", "[slicing_pipeline]") {
using Catch::Matchers::WithinRel;
ensure_python_initialized();
import_orca_module();
py::gil_scoped_acquire gil;
py::object host = py::module_::import("orca").attr("host");
for (const char* name : { "ExtrusionEntity", "ExtrusionPath", "ExtrusionLoop",
"ExtrusionMultiPath", "ExtrusionEntityCollection", "PrintRegion" })
CHECK(py::hasattr(host, name));
Slic3r::ExtrusionEntityCollection outer = build_nested_collection();
py::object coll = py::cast(&outer, py::return_value_policy::reference);
// .entities downcasts: the single child is a collection; ITS children are a loop + a path.
py::list kids = coll.attr("entities").cast<py::list>();
REQUIRE(kids.size() == 1);
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
// flatten_paths: loop decomposed, scalars readable.
py::list ps = coll.attr("flatten_paths")().cast<py::list>();
REQUIRE(ps.size() == 2);
CHECK(ps[0].attr("role").cast<std::string>() == "Outer wall");
CHECK_THAT(ps[0].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(ps[1].attr("role").cast<std::string>() == "Sparse infill");
}
TEST_CASE("orca.host ExtrusionPath.points() is a read-only (N,3) int64 view", "[slicing_pipeline]") {
ensure_python_initialized();
import_orca_module();
py::gil_scoped_acquire gil;
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");
Slic3r::ExtrusionEntityCollection outer = build_nested_collection();
py::object coll = py::cast(&outer, py::return_value_policy::reference);
py::list ps = coll.attr("flatten_paths")().cast<py::list>();
REQUIRE(ps.size() == 2);
py::array pts = ps[1].attr("points")().cast<py::array>(); // pathB: (1,1,0),(2,1,0),(2,2,0)
CHECK(pts.dtype().kind() == 'i');
CHECK(pts.itemsize() == 8);
CHECK(pts.shape(0) == 3);
CHECK(pts.shape(1) == 3);
CHECK_FALSE(pts.writeable());
auto r = pts.cast<py::array_t<coord_t>>().unchecked<2>();
CHECK(r(0, 0) == 1); CHECK(r(1, 0) == 2); CHECK(r(2, 1) == 2);
}
// ---------------------------------------------------------------------------
// Raw Print-graph spine (orca.host): LayerRegion / Layer / PrintObject / Print,
// read side. LayerRegion/Layer ctors are protected (friend class PrintObject),
// so the tests use tiny derived structs -- the pattern TestLayerRegion above
// already establishes; TestLayer is its Layer counterpart.
// ---------------------------------------------------------------------------
namespace {
struct TestLayer : Slic3r::Layer {
// id=0, no owning PrintObject, height/print_z/slice_z suitable for assertions.
TestLayer() : Slic3r::Layer(0, nullptr, 0.2, 0.45, 0.35) {}
};
} // namespace
TEST_CASE("orca.host graph classes: LayerRegion/Layer raw traversal; Print/PrintObject registered", "[slicing_pipeline]") {
using Catch::Matchers::WithinRel;
ensure_python_initialized();
import_orca_module();
py::gil_scoped_acquire gil;
py::object host = py::module_::import("orca").attr("host");
for (const char* name : { "LayerRegion", "Layer", "PrintObject", "Print" })
CHECK(py::hasattr(host, name));
// Members needing a live Print are verified by registration only (slic3rutils
// cannot build a Print; the fff_print C++ suite covers live-graph behavior).
for (const char* name : { "layers", "support_layers", "model_object", "id",
"bounding_box", "trafo", "config_value", "config_keys" })
CHECK(py::hasattr(host.attr("PrintObject"), name));
for (const char* name : { "objects", "model", "config_value", "config_keys", "canceled" })
CHECK(py::hasattr(host.attr("Print"), name));
// Raw LayerRegion traversal over a hand-built region.
TestLayerRegion region;
region.slices.surfaces.emplace_back(Slic3r::Surface(Slic3r::stInternal));
build_nested_perimeters(region); // helper defined earlier in this file
py::object lr = py::cast(static_cast<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: plugin-only mutators are gone; class-API editing works", "[slicing_pipeline]") {
ensure_python_initialized();
import_orca_module();
py::gil_scoped_acquire gil;
py::object host = py::module_::import("orca").attr("host");
// The three plugin-only mutators were removed in the raw-API realignment.
CHECK_FALSE(py::hasattr(host.attr("LayerRegion"), "set_slices"));
CHECK_FALSE(py::hasattr(host.attr("LayerRegion"), "set_fill_surfaces"));
CHECK_FALSE(py::hasattr(host.attr("Layer"), "set_lslices"));
// The faithful surface is present.
CHECK(py::hasattr(host.attr("SurfaceCollection"), "set"));
CHECK(py::hasattr(host.attr("Layer"), "make_slices"));
// clear() via the collection on a hand-built region (null owning layer is null-safe).
TestLayerRegion region;
region.slices.surfaces.emplace_back(Slic3r::Surface(Slic3r::stInternal));
py::object lr = py::cast(static_cast<Slic3r::LayerRegion*>(&region), py::return_value_policy::reference);
lr.attr("slices").attr("clear")();
CHECK(region.slices.surfaces.empty());
}
TEST_CASE("orca.host: SurfaceCollection.set mutates geometry; lslices via make_slices", "[slicing_pipeline]") {
using Catch::Matchers::WithinRel;
ensure_python_initialized();
import_orca_module();
py::gil_scoped_acquire gil;
bool have_numpy = false;
try { py::module_::import("numpy"); have_numpy = true; }
catch (const py::error_already_set&) { have_numpy = false; }
if (!have_numpy) SKIP("numpy unavailable in unit-test interpreter");
py::object host = py::module_::import("orca").attr("host");
py::module_ np = py::module_::import("numpy");
py::object i64 = np.attr("int64");
py::object ST = host.attr("SurfaceType");
const coord_t s = (coord_t) scale_(10.0);
auto arr = [&](std::initializer_list<std::pair<coord_t,coord_t>> pts) {
py::list rows; for (auto& p : pts) rows.append(py::make_tuple(p.first, p.second));
return np.attr("array")(rows, py::arg("dtype") = i64);
};
// Build an ExPolygon from a CW ndarray; the ctor normalizes to CCW.
py::object ex = host.attr("ExPolygon")(arr({ {0,0}, {0,s}, {s,s}, {s,0} }));
CHECK(ex.attr("contour").attr("is_counter_clockwise")().cast<bool>());
TestLayerRegion region;
py::object lr = py::cast(static_cast<Slic3r::LayerRegion*>(&region), py::return_value_policy::reference);
py::list expolys; expolys.append(ex);
lr.attr("slices").attr("set")(expolys, ST.attr("stInternalSolid"));
REQUIRE(region.slices.surfaces.size() == 1);
const Slic3r::Surface& out = region.slices.surfaces.front();
CHECK(out.surface_type == Slic3r::stInternalSolid);
CHECK_THAT(out.expolygon.area(), WithinRel((double) s * (double) s, 1e-9));
// Read geometry back through the class API.
py::array c = lr.attr("slices").attr("surfaces").cast<py::list>()[0]
.attr("expolygon").attr("contour").attr("as_array")().cast<py::array>();
CHECK(c.shape(0) == 4);
// lslices are derived: make_slices() re-derives them + refreshes the bbox cache.
TestLayer layer;
py::object ly = py::cast(static_cast<Slic3r::Layer*>(&layer), py::return_value_policy::reference);
// (A hand-built layer has no regions, so make_slices() yields empty lslices — still null-safe.)
ly.attr("make_slices")();
CHECK(layer.lslices_bboxes.size() == layer.lslices.size());
}
TEST_CASE("orca.host ExPolygon in-place transforms + SurfaceCollection.append (sample ops)", "[slicing_pipeline]") {
using Catch::Matchers::WithinRel;
ensure_python_initialized();
import_orca_module();
py::gil_scoped_acquire gil;
py::object host = py::module_::import("orca").attr("host");
const coord_t s = (coord_t) scale_(10.0);
auto make_square = [&]() {
py::object P = host.attr("Polygon")();
P.attr("append")(host.attr("Point")(0, 0));
P.attr("append")(host.attr("Point")(s, 0));
P.attr("append")(host.attr("Point")(s, s));
P.attr("append")(host.attr("Point")(0, s));
return host.attr("ExPolygon")(P);
};
const double area0 = (double) s * (double) s;
// rotate about the square's center preserves area
py::object ex = make_square();
py::object center = host.attr("Point")(s / 2, s / 2);
ex.attr("rotate")(py::float_(1.5707963267948966), center); // pi/2
CHECK_THAT(ex.attr("area")().cast<double>(), WithinRel(area0, 1e-6));
// uniform scale by 2 quadruples area (scale is about the origin)
py::object ex2 = make_square();
ex2.attr("scale")(py::float_(2.0));
CHECK_THAT(ex2.attr("area")().cast<double>(), WithinRel(4.0 * area0, 1e-6));
// translate preserves area
py::object ex3 = make_square();
ex3.attr("translate")(py::float_(1000.0), py::float_(-500.0));
CHECK_THAT(ex3.attr("area")().cast<double>(), WithinRel(area0, 1e-6));
// SurfaceCollection.append accumulates surfaces of a second type (the sample write-back path)
Slic3r::SurfaceCollection coll;
py::object cv = py::cast(&coll, py::return_value_policy::reference);
py::list g1; g1.append(make_square());
cv.attr("set")(g1, host.attr("SurfaceType").attr("stInternalSolid"));
py::list g2; g2.append(make_square());
cv.attr("append")(g2, host.attr("SurfaceType").attr("stTop"));
REQUIRE(coll.surfaces.size() == 2);
CHECK(coll.surfaces[0].surface_type == Slic3r::stInternalSolid);
CHECK(coll.surfaces[1].surface_type == Slic3r::stTop);
}
TEST_CASE("orca.host: in-place edit of surface.expolygon through a live collection persists to C++", "[slicing_pipeline]") {
using Catch::Matchers::WithinRel;
ensure_python_initialized();
import_orca_module();
py::gil_scoped_acquire gil;
const coord_t s = (coord_t) scale_(10.0);
// Live LayerRegion holding one surface (a 10mm square at the origin).
TestLayerRegion region;
Slic3r::ExPolygon sq;
sq.contour.points = { Slic3r::Point(0, 0), Slic3r::Point(s, 0),
Slic3r::Point(s, s), Slic3r::Point(0, s) };
region.slices.surfaces.emplace_back(Slic3r::Surface(Slic3r::stInternal, sq));
py::object lr = py::cast(static_cast<Slic3r::LayerRegion*>(&region),
py::return_value_policy::reference);
// Twistify's path: get the Surface through the live collection, mutate its expolygon in place.
py::object surf = lr.attr("slices").attr("surfaces").cast<py::list>()[0];
surf.attr("expolygon").attr("translate")(py::float_(1000.0), py::float_(0.0));
// The C++-side surface geometry reflects the Python in-place edit (proves the live ref).
const Slic3r::Surface& out = region.slices.surfaces.front();
CHECK(out.expolygon.contour.points[0].x() == 1000); // was 0
CHECK(out.expolygon.contour.points[0].y() == 0);
CHECK_THAT(out.expolygon.area(), WithinRel((double) s * (double) s, 1e-9)); // translate preserves area
}