diff --git a/sandboxes/orca_fuzzy_slices_plugin_any.py b/sandboxes/orca_fuzzy_slices_plugin_any.py new file mode 100644 index 0000000000..ecff14ca28 --- /dev/null +++ b/sandboxes/orca_fuzzy_slices_plugin_any.py @@ -0,0 +1,176 @@ +# /// script +# requires-python = ">=3.12" +# +# [tool.orcaslicer.plugin] +# name = "Fuzzy Slices" +# description = "Applies the fuzzy-skin jitter to the slice contours themselves at the Slice boundary (demo)." +# author = "OrcaSlicer" +# version = "0.01" +# type = "slicing-pipeline" +# +# [tool.orcaslicer.plugin.settings] +# thickness_mm = "0.3" +# point_distance_mm = "0.8" +# fuzz_holes = "1" +# skip_first_layer = "1" +# /// +"""Fuzzy Slices -- the fuzzy-skin effect applied at slice time. + +Orca's built-in fuzzy skin perturbs the outer-wall EXTRUSION PATHS during +perimeter generation, so only the printed wall is fuzzy. This sample instead +perturbs the sliced outline itself at Step.posSlice, using the same +resample-and-jitter algorithm as libslic3r's fuzzy_polyline (uniform noise): +walk each ring, drop a new vertex every 3/4..5/4 * point_distance_mm of +perimeter, and displace it by a random +/- thickness_mm along the segment +normal. Because the slice contour itself changes, everything derived from it +(perimeters, infill boundaries, overhang detection) inherits the noise and +the fuzz shows in the toolpath preview. + +Mechanically this demonstrates the count-CHANGING mutation idiom: a fuzzed +ring has a different vertex count, so it is rebuilt as a fresh +orca.host.Polygon (append() per vertex) and written back by assigning +ex.contour / calling ex.set_holes() on the live ExPolygon. The in-place edit +persists through the surface collection and leaves surface types untouched; +layer.make_slices() then re-derives the merged islands. Compare the Inset +sample (whole-surface offset + slices.set) and Twistify (count-preserving +in-place transforms). + +The jitter preserves vertex order, so the contour keeps its CCW winding +(contour assignment does not re-normalize); set_holes() re-normalizes holes +to CW. The RNG is seeded per layer, so re-slicing reproduces the same fuzz. +The first layer is skipped by default for bed adhesion (like the built-in +fuzzy_skin_first_layer = off). No numpy required; for very dense models the +Polygon.as_array()/set_points numpy path would be the faster route. +""" +import math +import random + +import orca + +_DEFAULTS = { + "thickness_mm": 0.3, # max normal displacement (built-in fuzzy_skin_thickness default) + "point_distance_mm": 0.8, # target resample spacing (built-in fuzzy_skin_point_dist default) + "fuzz_holes": 1.0, # nonzero: jitter hole rings too, not just the outer contour + "skip_first_layer": 1.0, # nonzero: keep layer 0 crisp for bed adhesion +} + + +def _params(ctx): + try: + src = dict(ctx.params) + except (AttributeError, TypeError): + src = {} + out = {} + for key, default in _DEFAULTS.items(): + try: + out[key] = float(src[key]) + except (KeyError, TypeError, ValueError): + out[key] = default + return out + + +def _fuzz_ring(points, thickness, min_dist, rand_range, rng): + """Resample + jitter one closed ring (list of Point refs). + + Returns a new orca.host.Polygon, or None to keep the original ring (too + small to resample). Mirrors libslic3r's fuzzy_polyline: new vertices every + min_dist + rand*rand_range of arc length, each displaced +/-thickness + along the segment's left-hand normal. + """ + if len(points) < 3: + return None + out = [] + dist_left_over = rng.random() * (min_dist / 2.0) # arc length before the first new vertex + p0x = float(points[-1].x) + p0y = float(points[-1].y) + for p1 in points: + p1x = float(p1.x) + p1y = float(p1.y) + dx = p1x - p0x + dy = p1y - p0y + seg = math.hypot(dx, dy) + if seg > 0.0: + d = dist_left_over + while d < seg: + t = d / seg + r = (rng.random() * 2.0 - 1.0) * thickness + out.append((p0x + dx * t - dy / seg * r, + p0y + dy * t + dx / seg * r)) + d += min_dist + rng.random() * rand_range + dist_left_over = d - seg # carry the remainder into the next segment + p0x, p0y = p1x, p1y + if len(out) < 3: + return None # ring shorter than ~2 resample steps: leave it crisp + poly = orca.host.Polygon() + for x, y in out: + poly.append(orca.host.Point(int(round(x)), int(round(y)))) + return poly + + +class FuzzySlices(orca.slicing.SlicingPipelineCapabilityBase): + def get_name(self): + return "Fuzzy Slices" + + def execute(self, ctx): + if ctx.step != orca.slicing.Step.posSlice or ctx.object is None: + return orca.ExecutionResult.success() + + p = _params(ctx) + if p["thickness_mm"] <= 0.0 or p["point_distance_mm"] <= 0.0: + return orca.ExecutionResult.success("Fuzzy Slices: zero thickness/point distance, nothing to do") + + # Millimeters -> scaled integer units via the *live* scale (never hardcode 1e6). + mm = 1.0 / orca.slicing.unscale(1) + thickness = p["thickness_mm"] * mm + # The spacing between new vertices varies between 3/4 and 5/4 the supplied + # value, same as the built-in fuzzy skin. + min_dist = p["point_distance_mm"] * mm * 0.75 + rand_range = p["point_distance_mm"] * mm * 0.5 + fuzz_holes = p["fuzz_holes"] != 0.0 + first = 1 if p["skip_first_layer"] != 0.0 else 0 + + rings = 0 + layers_touched = 0 + for idx, layer in enumerate(ctx.object.layers()): + if ctx.cancelled(): + break + if idx < first: + continue + rng = random.Random(0x5EED + idx) # per-layer seed: re-slices reproduce the same fuzz + edited = False + for region in layer.regions(): + for surface in region.slices.surfaces: + ex = surface.expolygon + contour = _fuzz_ring(ex.contour.points, thickness, min_dist, rand_range, rng) + if contour is not None: + ex.contour = contour # vertex order preserved, so CCW winding survives + rings += 1 + edited = True + if fuzz_holes and ex.holes: + new_holes = [] + changed = False + for hole in ex.holes: + fuzzed = _fuzz_ring(hole.points, thickness, min_dist, rand_range, rng) + if fuzzed is not None: + new_holes.append(fuzzed) + changed = True + rings += 1 + else: + new_holes.append(hole) # untouched rings pass through unchanged + if changed: + ex.set_holes(new_holes) # copies each ring and re-normalizes to CW + edited = True + if edited: + # Re-derive the merged islands from the fuzzed region slices. + layer.make_slices() + layers_touched += 1 + + return orca.ExecutionResult.success( + f"Fuzzy Slices: fuzzed {rings} ring(s) on {layers_touched} layer(s) " + f"(+/-{p['thickness_mm']} mm @ {p['point_distance_mm']} mm)") + + +@orca.plugin +class FuzzySlicesPackage(orca.base): + def register_capabilities(self): + orca.register_capability(FuzzySlices) diff --git a/sandboxes/orca_gcode_stamp_plugin_any.py b/sandboxes/orca_gcode_stamp_plugin_any.py new file mode 100644 index 0000000000..08273d189a --- /dev/null +++ b/sandboxes/orca_gcode_stamp_plugin_any.py @@ -0,0 +1,74 @@ +# /// script +# requires-python = ">=3.12" +# +# [tool.orcaslicer.plugin] +# name = "G-code Stamp" +# description = "Stamps a comment line into the exported G-code at the post-process step (demo)." +# author = "OrcaSlicer" +# version = "0.01" +# type = "slicing-pipeline" +# +# [tool.orcaslicer.plugin.settings] +# stamp_text = "processed by the OrcaSlicer G-code Stamp plugin" +# /// +"""G-code Stamp -- the post-processing half of the slicing-pipeline plugin. + +Post-processing is now a step of the slicing pipeline: Step.psGCodePostProcess. +It fires from the G-code export path AFTER the classic post_process scripts, on the +exported G-code file -- NOT from Print::process(). So unlike the geometry steps +(posSlice, posPerimeters, ...) there is no live slicing graph here: ctx.print and +ctx.object are None. Instead the context carries ctx.gcode_path (the working G-code +file on disk, edited IN PLACE), ctx.host ("File", "OctoPrint", ...) and +ctx.output_name (the final file name). ctx.params and ctx.config_value() still work. + +This sample inserts a single comment line near the top of the file. Because the same +capability class can also implement the geometry steps, one plugin can transform slices +AND stamp the final G-code; a geometry-only plugin just returns success here. + +The step may fire more than once per slice (file export and/or upload each run it on a +separate working copy), and its output is not reflected in the G-code preview -- the +viewer maps the pre-post-process file. +""" +import orca + +_DEFAULT_STAMP = "processed by the OrcaSlicer G-code Stamp plugin" + + +def _stamp_text(ctx): + try: + text = dict(ctx.params).get("stamp_text", _DEFAULT_STAMP) + except (AttributeError, TypeError): + text = _DEFAULT_STAMP + return str(text).replace("\n", " ").strip() or _DEFAULT_STAMP + + +class GCodeStamp(orca.slicing.SlicingPipelineCapabilityBase): + def get_name(self): + return "G-code Stamp" + + def execute(self, ctx): + # Only act at the post-process seam; at every geometry step this is a no-op. + if ctx.step != orca.slicing.Step.psGCodePostProcess: + return orca.ExecutionResult.success() + if not ctx.gcode_path: + return orca.ExecutionResult.success("G-code Stamp: no gcode_path, nothing to do") + + comment = "; " + _stamp_text(ctx) + " (host=" + (ctx.host or "?") + ")\n" + + # Edit the exported G-code in place: keep the original first line first (some flavors + # expect a specific leading line), then insert the stamp right after it. + with open(ctx.gcode_path, "r", encoding="utf-8", errors="replace") as f: + lines = f.readlines() + insert_at = 1 if lines else 0 + lines.insert(insert_at, comment) + with open(ctx.gcode_path, "w", encoding="utf-8") as f: + f.writelines(lines) + + return orca.ExecutionResult.success( + "G-code Stamp: stamped '" + (ctx.output_name or ctx.gcode_path) + "'") + + +@orca.plugin +class GCodeStampPackage(orca.base): + def register_capabilities(self): + orca.register_capability(GCodeStamp) diff --git a/sandboxes/orca_inset_plugin_any.py b/sandboxes/orca_inset_plugin_any.py new file mode 100644 index 0000000000..23daf8271d --- /dev/null +++ b/sandboxes/orca_inset_plugin_any.py @@ -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) diff --git a/sandboxes/orca_twistify_plugin_example_any.py b/sandboxes/orca_twistify_plugin_example_any.py new file mode 100644 index 0000000000..72c746d6b0 --- /dev/null +++ b/sandboxes/orca_twistify_plugin_example_any.py @@ -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) diff --git a/src/libslic3r/Config.cpp b/src/libslic3r/Config.cpp index ecf453aa68..a43f659be6 100644 --- a/src/libslic3r/Config.cpp +++ b/src/libslic3r/Config.cpp @@ -1521,8 +1521,6 @@ void ConfigBase::save_to_json(const std::string &file, const std::string &name, j[BBL_JSON_KEY_NAME] = name; j[BBL_JSON_KEY_FROM] = from; - std::vector plugin_refs; - //record all the key-values for (const std::string &opt_key : this->keys()) { @@ -1548,24 +1546,14 @@ void ConfigBase::save_to_json(const std::string &file, const std::string &name, json j_array(string_values); j[opt_key] = j_array; } - - this->save_plugin_collection(opt_key, opt, plugin_refs); } - // Lazily serialize the top-level "plugins" manifest: the individual plugin-backed options keep - // bare capability names, and the full "name;uuid;capability" references are derived here from - // those options via the registered resolver. Only do this when a resolver is available (GUI); - // without one (CLI/headless) leave whatever the "plugins" option already serialized above, so a - // round-trip never drops the manifest. De-duplicate while preserving order and skip empties. + // Serialize the top-level "plugins" manifest: the individual plugin-backed options keep bare + // capability names; the full "name;uuid;capability" references are derived here (same helper as + // update_plugin_manifest). Only with a resolver (GUI); without one (CLI/headless) leave whatever + // the "plugins" option already serialized above, so a round-trip never drops the manifest. if (resolve_capability_fn) { - std::vector unique_refs; - unique_refs.reserve(plugin_refs.size()); - for (std::string& ref : plugin_refs) { - if (ref.empty()) - continue; - if (std::find(unique_refs.begin(), unique_refs.end(), ref) == unique_refs.end()) - unique_refs.emplace_back(std::move(ref)); - } + std::vector unique_refs = this->collect_plugin_manifest(); if (unique_refs.empty()) j.erase("plugins"); else @@ -1610,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(opt); - for (const std::string& val : vec->vserialize()) - append_ref(val, "post-processing"); - } else if (opt_key == "printer_agent") { - append_ref((dynamic_cast(opt))->value, "printer-connection"); - } - // Extend for other plugin-backed settings as needed. + // Scalar options carry a single capability name; vector options carry a list. Same scalar/vector + // dispatch as PluginResolver::find_option_for_capability. + if (const auto* string_option = dynamic_cast(opt)) + append_ref(string_option->value); + else if (const auto* vector_option = dynamic_cast(opt)) + for (const std::string& val : vector_option->vserialize()) + append_ref(val); +} + +std::vector ConfigBase::collect_plugin_manifest() const +{ + std::vector refs; + if (!resolve_capability_fn) + return refs; + + // Each plugin-backed option (ConfigOptionDef::is_plugin_backed) contributes its resolved + // reference(s) via save_plugin_collection, which appends in order and skips duplicates, so no + // second de-duplication pass is needed here. + for (const std::string& opt_key : this->keys()) + if (const ConfigOption* opt = this->option(opt_key)) + this->save_plugin_collection(opt_key, opt, refs); + return refs; +} + +void ConfigBase::update_plugin_manifest() +{ + // Writes the derived manifest back into this config's "plugins" option (save_to_json writes the + // same manifest into a JSON document instead), so an in-memory backend config carries a resolved + // manifest even when the source preset was never serialized (picked-but-unsaved). Without a + // resolver (CLI/headless) leave whatever manifest was loaded from disk untouched. + if (!resolve_capability_fn) + return; + if (auto* manifest = this->option("plugins", true)) + manifest->values = this->collect_plugin_manifest(); } DynamicConfig::DynamicConfig(const ConfigBase& rhs, const t_config_option_keys& keys) diff --git a/src/libslic3r/Config.hpp b/src/libslic3r/Config.hpp index 3c50e401db..0d776c7599 100644 --- a/src/libslic3r/Config.hpp +++ b/src/libslic3r/Config.hpp @@ -2444,10 +2444,13 @@ public: // "serialized" - vector valued option is entered in a single edit field. Values are separated by a semicolon. // "show_value" - even if enum_values / enum_labels are set, still display the value, not the enum label. std::string gui_flags; - // Optional plugin type used by GUIType::plugin_picker for filtering plugins. + // Capability type of a plugin-backed option, e.g. "slicing-pipeline" / "printer-connection" + // (empty for ordinary options). GUIType::plugin_picker filters the plugin list by it, and it + // resolves the option's "plugins" manifest reference; see is_plugin_backed(). std::string plugin_type; - // Indicate whether the option support plugin. - bool support_plugin { false }; + // Whether this option holds plugin capability name(s) that feed the "plugins" manifest -- true + // iff it declares a plugin_type. Setting plugin_type is the only step needed to add one. + bool is_plugin_backed() const { return !plugin_type.empty(); } // Label of the GUI input field. // In case the GUI input fields are grouped in some views, the label defines a short label of a grouped value, // while full_label contains a label of a stand-alone field. @@ -2761,6 +2764,13 @@ public: //BBS: add json support void save_to_json(const std::string &file, const std::string &name, const std::string &from, const std::string &version) const; + // Rebuild the in-memory "plugins" manifest (the "name;uuid;capability" references the plugin + // dispatchers consume) from the plugin-backed options via the registered resolver. save_to_json() + // derives the same manifest, but only when a preset is written to disk; a config assembled in + // memory for the backend (PresetBundle::full_config -> Print::apply) must refresh it here or a + // picked-but-unsaved plugin never resolves at slice/export time. No-op without a resolver. + void update_plugin_manifest(); + // Set all the nullable values to nils. void null_nullables(); @@ -2770,6 +2780,11 @@ private: // Set a configuration value from a string. bool set_deserialize_raw(const t_config_option_key& opt_key_src, const std::string& value, ConfigSubstitutionContext& substitutions, bool append); void save_plugin_collection(const std::string& opt_key, const ConfigOption* opt, std::vector& plugin_refs) const; + // Collect the de-duplicated "name;uuid;capability" plugin references derived from this config's + // plugin-backed options via the resolver. Shared by save_to_json (serializes them into the JSON + // manifest) and update_plugin_manifest (writes them back into the "plugins" option). Order is + // preserved and empties are dropped; returns empty without a resolver (CLI/headless). + std::vector collect_plugin_manifest() const; static std::function resolve_capability_fn; }; diff --git a/src/libslic3r/Preset.cpp b/src/libslic3r/Preset.cpp index 76e714aa01..be48fc2730 100644 --- a/src/libslic3r/Preset.cpp +++ b/src/libslic3r/Preset.cpp @@ -1192,7 +1192,7 @@ static std::vector s_Preset_print_options{ "min_feature_size", "min_bead_width", "post_process", - "post_process_plugin", + "slicing_pipeline_plugin", "plugins", "process_change_extrusion_role_gcode", "min_length_factor", diff --git a/src/libslic3r/Print.cpp b/src/libslic3r/Print.cpp index acc55efe72..066a8a287f 100644 --- a/src/libslic3r/Print.cpp +++ b/src/libslic3r/Print.cpp @@ -50,6 +50,8 @@ using namespace nlohmann; namespace Slic3r { +Print::SlicingPipelineHookFn Print::s_slicing_pipeline_hook_fn = nullptr; + template class PrintState; template class PrintState; @@ -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("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 sup_was_done(m_objects.size(), 1); + if (m_pipeline_plugin_active) + for (size_t i = 0; i < m_objects.size(); ++i) + sup_was_done[i] = m_objects[i]->is_step_done(posSupportMaterial) ? 1 : 0; + tbb::parallel_for(tbb::blocked_range(0, int(m_objects.size())), [this, need_slicing_objects](const tbb::blocked_range& range) { for (int i = range.begin(); i < range.end(); i++) { @@ -2377,9 +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)) diff --git a/src/libslic3r/Print.hpp b/src/libslic3r/Print.hpp index af144ee821..9481498cc6 100644 --- a/src/libslic3r/Print.hpp +++ b/src/libslic3r/Print.hpp @@ -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 PrintObjectInfo; public: + using SlicingPipelineHookFn = std::function; + // Cross-layer injection (mirrors ConfigBase::set_resolve_capability_fn): the GUI/plugin + // layer registers a dispatcher; libslic3r stays free of any plugin/Python dependency. + static void set_slicing_pipeline_hook_fn(SlicingPipelineHookFn fn) { s_slicing_pipeline_hook_fn = std::move(fn); } + Print() = default; virtual ~Print() { this->clear(); } @@ -1147,6 +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; diff --git a/src/libslic3r/PrintConfig.cpp b/src/libslic3r/PrintConfig.cpp index 73ba2d793a..7cd82a355c 100644 --- a/src/libslic3r/PrintConfig.cpp +++ b/src/libslic3r/PrintConfig.cpp @@ -828,7 +828,10 @@ void PrintConfigDef::init_common_params() def->tooltip = L("Select the network agent implementation for printer communication."); def->mode = comAdvanced; def->cli = ConfigOptionDef::nocli; - def->support_plugin = true; + // Plugin-backed like the pickers, but edited via a dedicated Choice widget rather than a + // plugin_picker field. plugin_type marks it plugin-backed and names its capability type, so its + // "plugins" manifest reference is derived generically (see ConfigOptionDef::is_plugin_backed). + def->plugin_type = "printer-connection"; def->set_default_value(new ConfigOptionString("")); def = this->add("print_host", coString); @@ -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()); diff --git a/src/libslic3r/PrintConfig.hpp b/src/libslic3r/PrintConfig.hpp index 394af09392..192ea662bd 100644 --- a/src/libslic3r/PrintConfig.hpp +++ b/src/libslic3r/PrintConfig.hpp @@ -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)) diff --git a/src/slic3r/CMakeLists.txt b/src/slic3r/CMakeLists.txt index 956a9a27ca..2c427e68a8 100644 --- a/src/slic3r/CMakeLists.txt +++ b/src/slic3r/CMakeLists.txt @@ -596,10 +596,19 @@ 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 @@ -612,21 +621,23 @@ set(SLIC3R_GUI_SOURCES plugin/PluginLoader.cpp plugin/PluginLoader.hpp plugin/PluginDescriptor.hpp + plugin/PluginHooks.cpp + plugin/PluginHooks.hpp plugin/PluginManager.cpp plugin/PluginManager.hpp plugin/PluginAuditManager.cpp 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 diff --git a/src/slic3r/GUI/GUI_App.cpp b/src/slic3r/GUI/GUI_App.cpp index f6e15f8d08..04bb7b5fe1 100644 --- a/src/slic3r/GUI/GUI_App.cpp +++ b/src/slic3r/GUI/GUI_App.cpp @@ -79,7 +79,7 @@ #include "libslic3r/Utils.hpp" #include "libslic3r/Color.hpp" #include "slic3r/plugin/PluginManager.hpp" -#include "slic3r/plugin/PluginHostUi.hpp" +#include "slic3r/plugin/host/PluginHostUi.hpp" #include "slic3r/plugin/PythonInterpreter.hpp" #include "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)) { diff --git a/src/slic3r/GUI/GUI_App.hpp b/src/slic3r/GUI/GUI_App.hpp index 19aeef17b4..ede7743f75 100644 --- a/src/slic3r/GUI/GUI_App.hpp +++ b/src/slic3r/GUI/GUI_App.hpp @@ -772,6 +772,9 @@ private: bool on_init_network(bool try_backup = false); void init_networking_callbacks(); void init_app_config(); + // GUI-side subscriptions to plugin loader events (dialog refresh, + // network-agent registration, plate revalidation). + void init_plugin_gui_wiring(); void remove_old_networking_plugins(); void drain_pending_events(int timeout_ms); bool wait_for_network_idle(int timeout_ms); diff --git a/src/slic3r/GUI/PluginsDialog.cpp b/src/slic3r/GUI/PluginsDialog.cpp index ee521a6254..6af5d2ceba 100644 --- a/src/slic3r/GUI/PluginsDialog.cpp +++ b/src/slic3r/GUI/PluginsDialog.cpp @@ -1217,9 +1217,9 @@ void PluginsDialog::run_script_plugin(const std::string& plugin_key, const std:: // ModelObject*/ModelVolume*/ModelInstance* aliases into host data and can mint ObjectIDs, // which libslic3r requires on the main thread (ObjectID.hpp's non-atomic s_last_id). Running // here makes those reads/instantiations legal and means nothing mutates the model underneath - // a run. The trade-off is that a slow execute() freezes the UI: the contract (see - // plugin_development.md) is to keep execute() quick and offload heavy work to the plugin's own - // threading.Thread. orca.host.ui calls already no-op their main-thread marshaling here. + // a run. The trade-off is that a slow execute() freezes the UI, so the contract is to keep + // execute() quick and offload heavy work to the plugin's own threading.Thread. orca.host.ui + // calls already no-op their main-thread marshaling here. { wxBusyCursor busy; try { diff --git a/src/slic3r/GUI/PostProcessor.cpp b/src/slic3r/GUI/PostProcessor.cpp index 2e63835529..afb7932eb3 100644 --- a/src/slic3r/GUI/PostProcessor.cpp +++ b/src/slic3r/GUI/PostProcessor.cpp @@ -9,7 +9,7 @@ // file lives in the GUI layer (libslic3r must not depend on pybind11 / PluginManager). #include "libslic3r/Config.hpp" #include "slic3r/plugin/PluginManager.hpp" -#include "slic3r/plugin/pluginTypes/gcode/GCodePluginCapability.hpp" +#include "slic3r/plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.hpp" #include "slic3r/plugin/PythonInterpreter.hpp" #include @@ -241,27 +241,39 @@ void gcode_add_line_number(const std::string& path, const DynamicPrintConfig& co fs.close(); } -// Run the configured post-processing plugins on `gcode_path` in place. Plugins are executed in-process -// through the embedded Python interpreter. Throws Slic3r::RuntimeError on any failure; the caller is -// responsible for removing the working copy (see run_post_process_scripts' catch block). -// Entries are bare capability names; the top-level plugins manifest carries the full plugin refs. +// Run the configured slicing-pipeline plugins on `gcode_path` in place, at their Step.psGCodePostProcess +// seam. This is the same capability that runs at the geometry seams inside Print::process(); here it is +// dispatched a final time on the exported G-code, so a plugin can edit slices AND the final G-code from +// one class. Plugins are executed in-process through the embedded Python interpreter. Throws +// Slic3r::RuntimeError on any failure; the caller removes the working copy (see run_post_process_scripts' +// catch block). Entries are bare capability names; the top-level plugins manifest carries the full refs. +// A geometry-only plugin simply returns success here (it filters on ctx.step), so it costs nothing beyond +// one no-op call, but note any configured pipeline plugin still engages this post-process path (i.e. the +// non-BBL ".pp" working copy) even if it does no G-code work. static void run_post_process_plugins(const ConfigOptionStrings& capabilities, const ConfigOptionStrings* plugins, const std::string& gcode_path, const std::string& host, - const std::string& output_name) + const std::string& output_name, + const DynamicPrintConfig& config) { // Let plugins observe the (possibly script-updated) target file name, mirroring the script env. boost::nowide::setenv("SLIC3R_PP_OUTPUT_NAME", output_name.c_str(), 1); const boost::filesystem::path gcode_file(gcode_path); - auto execute_fn = [&](std::shared_ptr cap, const PluginCapabilityRef& ref) { - GCodePluginContext ctx; + auto execute_fn = [&](std::shared_ptr cap, const PluginCapabilityRef& ref) { + SlicingPipelineContext ctx; ctx.orca_version = SoftFever_VERSION; + ctx.step = SlicingPipelineStepPlugin::psGCodePostProcess; ctx.gcode_path = gcode_path; ctx.host = host; ctx.output_name = output_name; + ctx.full_config = &config; // no live Print here; config_value() reads this + // Hand the plugin its own [tool.orcaslicer.plugin.settings] as ctx.params (same plugin_key the + // capability was resolved by), mirroring the in-pipeline dispatcher in GUI_App.cpp. + const std::string plugin_key = ref.uuid.empty() ? ref.name : ref.uuid; + ctx.params = PluginManager::instance().get_loader().get_plugin_settings(plugin_key); ExecutionResult exec_result; try { @@ -298,11 +310,12 @@ static void run_post_process_plugins(const ConfigOptionStrings& capabilities, BOOST_LOG_TRIVIAL(info) << "Post-processing plugin " << ref.capability_name << " completed successfully"; }; - execute_capabilities_from_refs(capabilities, plugins, PluginCapabilityType::PostProcessing, execute_fn); + execute_capabilities_from_refs(capabilities, plugins, PluginCapabilityType::SlicingPipeline, execute_fn); } -// Run post-processing scripts ("post_process") and/or post-processing plugins ("post_process_plugin") -// if defined. Both run on the same working copy of the G-code (the ".pp" temp when make_copy), so a +// Run post-processing scripts ("post_process") and/or the slicing-pipeline plugins' psGCodePostProcess +// step ("slicing_pipeline_plugin") if defined. Both run on the same working copy of the G-code (the +// ".pp" temp when make_copy), so a // plugin never opens the original file the G-code viewer keeps memory-mapped (a writable open of the // mapped file fails on Windows with a sharing violation). // Returns true if a script or plugin was executed. @@ -317,11 +330,13 @@ static void run_post_process_plugins(const ConfigOptionStrings& capabilities, bool run_post_process_scripts( std::string& src_path, bool make_copy, const std::string& host, std::string& output_name, const DynamicPrintConfig& config) { - // post_process / post_process_plugin are absent in SLA mode, hence the null checks. - const auto* post_process = config.opt("post_process"); - const auto* post_process_plugin = config.opt("post_process_plugin"); - const bool have_scripts = post_process != nullptr && !post_process->values.empty(); - const bool have_plugins = post_process_plugin != nullptr && !post_process_plugin->values.empty(); + // post_process / slicing_pipeline_plugin are absent in SLA mode, hence the null checks. G-code + // post-processing is now the psGCodePostProcess step of the slicing-pipeline plugin, so the same + // slicing_pipeline_plugin option drives both the geometry seams and this final G-code seam. + const auto* post_process = config.opt("post_process"); + const auto* slicing_pipeline_plugin = config.opt("slicing_pipeline_plugin"); + const bool have_scripts = post_process != nullptr && !post_process->values.empty(); + const bool have_plugins = slicing_pipeline_plugin != nullptr && !slicing_pipeline_plugin->values.empty(); if (!have_scripts && !have_plugins) return false; @@ -469,7 +484,7 @@ bool run_post_process_scripts( // Run plugins after the scripts so they observe any output_name the scripts produced. A thrown // exception is handled by the catch below, which removes the temp copy. if (have_plugins) { - run_post_process_plugins(*post_process_plugin, config.opt("plugins"), path, host, output_name); + run_post_process_plugins(*slicing_pipeline_plugin, config.opt("plugins"), path, host, output_name, config); } } catch (...) { remove_output_name_file(); diff --git a/src/slic3r/GUI/PostProcessor.hpp b/src/slic3r/GUI/PostProcessor.hpp index c85c5a511c..1c0fc09240 100644 --- a/src/slic3r/GUI/PostProcessor.hpp +++ b/src/slic3r/GUI/PostProcessor.hpp @@ -9,9 +9,10 @@ namespace Slic3r { -// Run post-processing scripts (the "post_process" option) and/or post-processing plugins (the -// "post_process_plugin" option) if defined. Lives in the GUI layer because plugins are executed -// through the embedded-Python PluginManager, which libslic3r must not depend on. +// Run post-processing scripts (the "post_process" option) and/or the slicing-pipeline plugins' +// Step.psGCodePostProcess seam (the "slicing_pipeline_plugin" option) if defined. Lives in the GUI +// layer because plugins are executed through the embedded-Python PluginManager, which libslic3r must +// not depend on. // Returns true if a script or plugin was executed. // Returns false if neither a post-processing script nor plugin was defined. // Throws an exception on error. diff --git a/src/slic3r/GUI/Tab.cpp b/src/slic3r/GUI/Tab.cpp index e9c9216a5e..b8e469ea92 100644 --- a/src/slic3r/GUI/Tab.cpp +++ b/src/slic3r/GUI/Tab.cpp @@ -1790,6 +1790,13 @@ void Tab::on_value_change(const std::string& opt_key, const boost::any& value) return; } + // Keep this preset's "plugins" manifest in sync when a plugin picker changes, so the edited preset + // always carries resolved "name;uuid;capability" references that full_config() and save_to_json() + // then pass downstream as-is -- no separate rebuild anywhere else. + if (const ConfigOptionDef* opt_def = m_config->def()->get(opt_key); + opt_def && opt_def->is_plugin_backed()) + m_config->update_plugin_manifest(); + if (opt_key == "gcode_flavor" && m_type == Preset::TYPE_PRINTER) { if (auto printer_tab = dynamic_cast(this)) printer_tab->on_gcode_flavor_changed(); @@ -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"); diff --git a/src/slic3r/plugin/PluginBindingUtils.hpp b/src/slic3r/plugin/PluginBindingUtils.hpp new file mode 100644 index 0000000000..52050ed561 --- /dev/null +++ b/src/slic3r/plugin/PluginBindingUtils.hpp @@ -0,0 +1,106 @@ +#pragma once +#include +#include +#include "libslic3r/Config.hpp" // ConfigBase +#include "libslic3r/Point.hpp" // Point/Point3 packing asserts, Vec3d, Transform3d +#include +#include +#include + +namespace Slic3r { + +// Point/Point3 must be tightly packed for zero-copy views. coord_t = int64_t. +static_assert(sizeof(Point) == 2 * sizeof(coord_t), "Point must be 2 packed coord_t"); +static_assert(sizeof(Point3) == 3 * sizeof(coord_t), "Point3 must be 3 packed coord_t"); + +// Run a builder that constructs numpy objects, translating the "numpy missing" +// ImportError into an actionable message (plugins must declare numpy as a dep). +template +pybind11::object with_numpy(Builder&& build) +{ + namespace py = pybind11; + try { + return std::forward(build)(); + } catch (py::error_already_set& err) { + if (err.matches(PyExc_ImportError)) + throw py::import_error("numpy is required to access geometry/mesh arrays; " + "add dependencies = [\"numpy\"] to your plugin metadata"); + throw; + } +} + +// Zero-copy, read-only (rows, N) numpy view over `data`, whose lifetime is tied +// to `base` (the array's base object). T is the element scalar (coord_t = int64 +// for slicing coords, float for mesh vertices). rows == 0 / null data yields a +// fresh empty (0, N) array with no base. +template +pybind11::array make_readonly_rows(pybind11::handle base, const T* data, pybind11::ssize_t rows) +{ + namespace py = pybind11; + if (rows == 0 || data == nullptr) { + py::array_t empty(std::vector{ 0, (py::ssize_t) N }); + // 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 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 +pybind11::array make_writable_rows(pybind11::handle base, T* data, pybind11::ssize_t rows) +{ + namespace py = pybind11; + if (rows == 0 || data == nullptr) + return py::array_t(std::vector{ 0, (py::ssize_t) N }); + return py::array_t( + { rows, (py::ssize_t) N }, + { (py::ssize_t)(N * sizeof(T)), (py::ssize_t) sizeof(T) }, + data, base); +} + +// Serialize one config key to a Python string, or None if the key is absent. +// Works on any ConfigBase (resolved DynamicPrintConfig snapshots, +// PrintObjectConfig, PrintRegionConfig, preset configs). +inline pybind11::object config_value_or_none(const ConfigBase& config, const std::string& key) +{ + if (!config.has(key)) + return pybind11::none(); + return pybind11::cast(config.opt_serialize(key)); +} + +// Plugins receive 3D vectors as plain Python tuples (x, y, z) so the API stays +// Pythonic and free of an Eigen/numpy runtime dependency. +inline pybind11::tuple vec3_to_tuple(const Vec3d& v) +{ + return pybind11::make_tuple(v.x(), v.y(), v.z()); +} + +// 4x4 row-major float64 copy of an affine transform. Eigen stores column-major, +// so fill element-wise to produce correct C-order data. Requires numpy. +inline pybind11::object mat4_to_numpy(const Transform3d& transform) +{ + namespace py = pybind11; + return with_numpy([&] { + py::array_t array({ py::ssize_t(4), py::ssize_t(4) }); + auto view = array.mutable_unchecked<2>(); + const auto& matrix = transform.matrix(); + for (int i = 0; i < 4; ++i) + for (int j = 0; j < 4; ++j) + view(i, j) = matrix(i, j); + return py::object(std::move(array)); + }); +} + +} // namespace Slic3r diff --git a/src/slic3r/plugin/PluginDescriptor.hpp b/src/slic3r/plugin/PluginDescriptor.hpp index afdb502570..79b967f693 100644 --- a/src/slic3r/plugin/PluginDescriptor.hpp +++ b/src/slic3r/plugin/PluginDescriptor.hpp @@ -4,6 +4,7 @@ #include #include +#include #include #include #include @@ -61,6 +62,7 @@ struct PluginDescriptor std::string entry_path; // Full path to the installed plugin entry file std::string entry_package; // Import package/module used for package-based loading std::vector dependencies; // Python dependency requirements declared by plugin package metadata + std::map settings; // [tool.orcaslicer.plugin.settings] table -> per-plugin params (ctx.params) std::vector changelog; // Cloud release changelog, sorted newest-first when available. std::string error; // Blocking error message. Non-empty means the plugin is in an error state. diff --git a/src/slic3r/plugin/PluginHooks.cpp b/src/slic3r/plugin/PluginHooks.cpp new file mode 100644 index 0000000000..428c79223e --- /dev/null +++ b/src/slic3r/plugin/PluginHooks.cpp @@ -0,0 +1,129 @@ +#include "PluginHooks.hpp" + +#include "PluginManager.hpp" +#include "PythonInterpreter.hpp" +#include "PythonPluginInterface.hpp" +#include "pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.hpp" + +#include "libslic3r/Config.hpp" +#include "libslic3r/Exception.hpp" +#include "libslic3r/Print.hpp" +#include "libslic3r_version.h" + +#include + +#include +#include + +namespace Slic3r::plugin_hooks { +namespace { + +// Manifest resolver: turns the bare capability name a preset stores into the full +// "name;uuid;capability" reference the dispatchers consume (see +// ConfigBase::collect_plugin_manifest / update_plugin_manifest). +void install_capability_resolver() +{ + ConfigBase::set_resolve_capability_fn([](const std::string& cap_name, const std::string& cap_type) { + PluginManager& plugin_mgr = PluginManager::instance(); + auto plugin_cap = plugin_mgr.get_loader().try_get_plugin_capability_by_name_and_type(cap_name, plugin_capability_type_from_string(cap_type)); + if (!plugin_cap) + return std::string(); + + PluginDescriptor descriptor; + if (!plugin_mgr.get_catalog().try_get_plugin_descriptor(plugin_cap->plugin_key, descriptor)) + return std::string(); + + // Cloud plugins are resolved at runtime via the UUID in the middle field, so the first + // field keeps the friendly display name. Local plugins are looked up by plugin_key (the + // first field, with an empty UUID), so emit the plugin_key to keep them resolvable. + const std::string identity = descriptor.is_cloud_plugin() ? descriptor.name : descriptor.plugin_key; + return identity + ';' + descriptor.cloud_uuid() + ';' + cap_name; + }); +} + +// Print::process() fires this hook at each pipeline seam on the slicing worker +// thread; here we run the picker-selected SlicingPipeline capabilities. Per +// capability we acquire the GIL, honor cancellation, and convert a plugin +// failure into a (non-critical) SlicingError so it surfaces as a slicing-error +// notification rather than the fatal-crash dialog. +void install_slicing_pipeline_hook() +{ + Print::set_slicing_pipeline_hook_fn( + [](Print& print, const PrintObject* object, SlicingPipelineStepPlugin step) { + const auto* caps = print.config().option("slicing_pipeline_plugin"); + // `plugins` is a dynamic-only manifest key (not a static PrintConfig member), so it + // must be read from the full/dynamic config -- reading it off print.config() (the + // static PrintConfig) always yields nullptr and skips every capability. Mirrors the + // post-process path (PostProcessor.cpp, via BackgroundSlicingProcess::full_print_config()). + const auto* plugs = print.full_print_config().option("plugins"); + if (caps == nullptr || caps->values.empty()) + return; + + execute_capabilities_from_refs( + *caps, plugs, PluginCapabilityType::SlicingPipeline, + [&](std::shared_ptr cap, const PluginCapabilityRef& ref) { + ExecutionResult r; + try { + // GIL is acquired per capability (not once for the whole dispatch) so it + // is released between capabilities. + PythonGILState gil; + // throw_if_canceled() is protected on PrintBase; canceled() is the public + // equivalent check (same cancel flag), so honor cancellation via it. + if (print.canceled()) + throw CanceledException(); + SlicingPipelineContext ctx; + ctx.orca_version = SoftFever_VERSION; + ctx.step = step; + ctx.print = &print; + ctx.object = object; + // hand the plugin its own [tool.orcaslicer.plugin.settings] as ctx.params + // (same plugin_key the capability was resolved by, so it always matches). + const std::string plugin_key = ref.uuid.empty() ? ref.name : ref.uuid; + ctx.params = PluginManager::instance().get_loader().get_plugin_settings(plugin_key); + r = cap->execute(ctx); + } catch (const CanceledException&) { + throw; // cancellation must reach process(), never become a slicing error + } catch (const std::exception& ex) { + // A Python raise reaches here as pybind11::error_already_set; surface it as a + // (non-critical) slicing error instead of a crash. + throw SlicingError(std::string("Slicing pipeline plugin '") + + ref.capability_name + "' error: " + ex.what()); + } + if (r.status == PluginResult::FatalError) + throw SlicingError(std::string("Slicing pipeline plugin '") + + ref.capability_name + "' error: " + r.message); + // log a non-empty success/skipped message instead of dropping it. This is + // log-only by design: every pipeline hook fires AFTER set_done() (see Print.cpp), + // so the Print-level m_step_active is -1 here. Calling active_step_add_warning() + // would then index m_state[-1] (out-of-bounds; the guarding assert is compiled + // out in Release), so it must NOT be called from a pipeline hook. + if (!r.message.empty()) { + static const char* const kStepNames[] = { + "posSlice", "posPerimeters", "posEstimateCurledExtrusions", "posPrepareInfill", "posInfill", + "posIroning", "posContouring", "posSupportMaterial", "posDetectOverhangsForLift", + "posSimplifyPath", "psWipeTower", "psSkirtBrim", "psGCodePostProcess" + }; // order must match SlicingPipelineStepPlugin + const char* step_name = static_cast(step) < sizeof(kStepNames) / sizeof(kStepNames[0]) + ? kStepNames[static_cast(step)] : "Unknown"; + BOOST_LOG_TRIVIAL(info) << "Slicing pipeline plugin '" << ref.capability_name + << "' [" << step_name << "]: " << r.message; + } + }); + }); +} + +} // namespace + +void install() +{ + install_capability_resolver(); + install_slicing_pipeline_hook(); +} + +void uninstall() +{ + ConfigBase::set_resolve_capability_fn(nullptr); + Print::set_slicing_pipeline_hook_fn(nullptr); +} + +} // namespace Slic3r::plugin_hooks diff --git a/src/slic3r/plugin/PluginHooks.hpp b/src/slic3r/plugin/PluginHooks.hpp new file mode 100644 index 0000000000..bff276c5df --- /dev/null +++ b/src/slic3r/plugin/PluginHooks.hpp @@ -0,0 +1,21 @@ +#pragma once + +// The plugin layer's installers for the hooks libslic3r exposes. libslic3r +// stays free of any plugin/Python dependency: it exposes static setter seams +// (ConfigBase::set_resolve_capability_fn, Print::set_slicing_pipeline_hook_fn, +// ...) and this unit injects the dispatchers -- one file-local installer per +// hook, aggregated by install(). Capabilities dispatched from the GUI layer +// (e.g. PostProcessor.cpp) call execute_capabilities_from_refs at their own +// call site and need no hook here. + +namespace Slic3r::plugin_hooks { + +// Install every hook. Called once from PluginManager::initialize(). +void install(); + +// Reset every hook to null so none can enter Python after the interpreter +// finalizes. Called from PluginManager::shutdown(); callers must have stopped +// background slicing first (resetting a hook while process() runs is a race). +void uninstall(); + +} // namespace Slic3r::plugin_hooks diff --git a/src/slic3r/plugin/PluginHostApi.cpp b/src/slic3r/plugin/PluginHostApi.cpp deleted file mode 100644 index 14debd52c1..0000000000 --- a/src/slic3r/plugin/PluginHostApi.cpp +++ /dev/null @@ -1,535 +0,0 @@ -#include "PluginHostApi.hpp" -#include "PluginHostUi.hpp" - -#include -#include -#include -#include -#include -#include -#include - -#include -#include - -#include -#include -#include -#include - -namespace py = pybind11; - -namespace Slic3r { -namespace { - -GUI::Plater* current_plater() -{ - if (wxTheApp == nullptr) - throw std::runtime_error("OrcaSlicer application is not initialized"); - - GUI::Plater* plater = GUI::wxGetApp().plater(); - if (plater == nullptr) - throw std::runtime_error("Plater is not available"); - - return plater; -} - -PresetBundle* current_preset_bundle() -{ - if (wxTheApp == nullptr) - throw std::runtime_error("OrcaSlicer application is not initialized"); - - PresetBundle* preset_bundle = GUI::wxGetApp().preset_bundle; - if (preset_bundle == nullptr) - throw std::runtime_error("Preset bundle is not available"); - - return preset_bundle; -} - -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(), stats.max.cast()); -} - -// --- Mesh geometry helpers ------------------------------------------------- - -// Zero-copy export of its.vertices / its.indices relies on these Eigen -// row-vectors being tightly packed (no padding between the 3 components). -static_assert(sizeof(stl_vertex) == 3 * sizeof(float), - "stl_vertex must be a packed float[3] for zero-copy numpy export"); -static_assert(sizeof(stl_triangle_vertex_indices) == 3 * sizeof(std::int32_t), - "triangle index must be a packed int32[3] for zero-copy numpy export"); - -// Immutable snapshot of a ModelVolume's mesh. Holding a strong reference to the -// const mesh keeps any zero-copy numpy views valid even if the volume's mesh is -// later replaced on the main thread. -struct HostTriangleMesh -{ - std::shared_ptr mesh; - const indexed_triangle_set& its() const { return mesh->its; } -}; - -// Run a builder that constructs numpy objects, translating the "numpy missing" -// ImportError into an actionable message (plugins must declare numpy as a dep). -template -py::object with_numpy(Builder&& build) -{ - try { - return std::forward(build)(); - } catch (py::error_already_set& err) { - if (err.matches(PyExc_ImportError)) - throw py::import_error("numpy is required to access mesh arrays/matrices; " - "add dependencies = [\"numpy\"] to your plugin metadata"); - throw; - } -} - -// Read-only, zero-copy (rows, 3) numpy view over a packed T[rows][3] buffer. -// The array owns a capsule that pins `mesh` alive for the view's lifetime. -template -py::array make_readonly_rows3(const std::shared_ptr& mesh, - const T* data, py::ssize_t rows) -{ - if (rows == 0 || data == nullptr) - return py::array_t(std::vector{0, 3}); - - auto* owner = new std::shared_ptr(mesh); - py::capsule base(owner, [](void* p) { - delete reinterpret_cast*>(p); - }); - - py::array_t array( - { rows, py::ssize_t(3) }, - { py::ssize_t(3 * sizeof(T)), py::ssize_t(sizeof(T)) }, - data, - base); - // A capsule-based array is writable by default in pybind11; the underlying - // mesh is const, so force the view read-only. - array.attr("setflags")(py::arg("write") = false); - return array; -} - -// 4x4 row-major float64 copy of an affine transform. Eigen stores column-major, -// so fill element-wise to produce correct C-order data. -py::object mat4_to_numpy(const Transform3d& transform) -{ - return with_numpy([&] { - py::array_t array({ py::ssize_t(4), py::ssize_t(4) }); - auto view = array.mutable_unchecked<2>(); - const auto& matrix = transform.matrix(); - for (int i = 0; i < 4; ++i) - for (int j = 0; j < 4; ++j) - view(i, j) = matrix(i, j); - return py::object(std::move(array)); - }); -} - -py::list current_filament_presets(PresetBundle& bundle) -{ - py::list presets; - for (const std::string& preset_name : bundle.filament_presets) { - Preset* preset = bundle.filaments.find_preset(preset_name); - if (preset == nullptr) - presets.append(py::none()); - else - presets.append(py::cast(preset, py::return_value_policy::reference)); - } - return presets; -} - -PresetCollection& printer_presets(PresetBundle& bundle) -{ - return static_cast(bundle.printers); -} - -} // namespace - -void PluginHostApi::RegisterBindings(pybind11::module_& module) -{ - auto host = module.def_submodule("host", "Host application API"); - - py::enum_(host, "PresetType") - .value("Invalid", Preset::TYPE_INVALID) - .value("Print", Preset::TYPE_PRINT) - .value("SlaPrint", Preset::TYPE_SLA_PRINT) - .value("Filament", Preset::TYPE_FILAMENT) - .value("SlaMaterial", Preset::TYPE_SLA_MATERIAL) - .value("Printer", Preset::TYPE_PRINTER) - .value("PhysicalPrinter", Preset::TYPE_PHYSICAL_PRINTER) - .value("Plate", Preset::TYPE_PLATE) - .value("Model", Preset::TYPE_MODEL); - - py::class_>(host, "Preset") - .def_readonly("type", &Preset::type) - .def_readonly("name", &Preset::name) - .def_readonly("alias", &Preset::alias) - .def_readonly("file", &Preset::file) - .def_readonly("is_default", &Preset::is_default) - .def_readonly("is_external", &Preset::is_external) - .def_readonly("is_system", &Preset::is_system) - .def_readonly("is_visible", &Preset::is_visible) - .def_readonly("is_dirty", &Preset::is_dirty) - .def_readonly("is_compatible", &Preset::is_compatible) - .def_readonly("is_project_embedded", &Preset::is_project_embedded) - .def_readonly("bundle_id", &Preset::bundle_id) - .def("is_user", &Preset::is_user) - .def("is_from_bundle", &Preset::is_from_bundle) - .def("label", &Preset::label, py::arg("no_alias") = false) - .def("config_keys", [](const Preset& preset) { return preset.config.keys(); }) - .def("config_value", [](const Preset& preset, const std::string& key) { - return config_value_or_none(preset.config, key); - }); - - py::class_>(host, "PresetCollection") - .def("size", &PresetCollection::size) - .def("get_selected_preset", [](PresetCollection& collection) -> Preset& { - return collection.get_selected_preset(); - }, py::return_value_policy::reference_internal) - .def("selected_preset", [](PresetCollection& collection) -> Preset& { - return collection.get_selected_preset(); - }, py::return_value_policy::reference_internal) - .def("get_selected_preset_name", &PresetCollection::get_selected_preset_name) - .def("selected_preset_name", &PresetCollection::get_selected_preset_name) - .def("get_edited_preset", [](PresetCollection& collection) -> Preset& { - return collection.get_edited_preset(); - }, py::return_value_policy::reference_internal) - .def("edited_preset", [](PresetCollection& collection) -> Preset& { - return collection.get_edited_preset(); - }, py::return_value_policy::reference_internal) - .def("preset", [](PresetCollection& collection, size_t index) -> Preset& { - if (index >= collection.size()) - throw py::index_error("preset index out of range"); - return collection.preset(index); - }, py::return_value_policy::reference_internal) - .def("find_preset", [](PresetCollection& collection, const std::string& name) -> Preset* { - return collection.find_preset(name); - }, py::return_value_policy::reference_internal) - .def("preset_names", [](const PresetCollection& collection) { - std::vector names; - names.reserve(collection.get_presets().size()); - for (const Preset& preset : collection.get_presets()) - names.push_back(preset.name); - return names; - }); - - py::class_>(host, "PresetBundle") - .def_property_readonly("prints", [](PresetBundle& bundle) -> PresetCollection& { - return bundle.prints; - }, py::return_value_policy::reference_internal) - .def_property_readonly("printers", &printer_presets, py::return_value_policy::reference_internal) - .def_property_readonly("filaments", [](PresetBundle& bundle) -> PresetCollection& { - return bundle.filaments; - }, py::return_value_policy::reference_internal) - .def_property_readonly("sla_prints", [](PresetBundle& bundle) -> PresetCollection& { - return bundle.sla_prints; - }, py::return_value_policy::reference_internal) - .def_property_readonly("sla_materials", [](PresetBundle& bundle) -> PresetCollection& { - return bundle.sla_materials; - }, py::return_value_policy::reference_internal) - .def("current_process_preset", [](PresetBundle& bundle) -> Preset& { - return bundle.prints.get_edited_preset(); - }, py::return_value_policy::reference_internal) - .def("current_print_preset", [](PresetBundle& bundle) -> Preset& { - return bundle.prints.get_edited_preset(); - }, py::return_value_policy::reference_internal) - .def("current_printer_preset", [](PresetBundle& bundle) -> Preset& { - return bundle.printers.get_edited_preset(); - }, py::return_value_policy::reference_internal) - .def("current_filament_preset_names", [](PresetBundle& bundle) { - return bundle.filament_presets; - }) - .def("current_filament_presets", ¤t_filament_presets) - .def("full_config_keys", [](const PresetBundle& bundle) { - return bundle.full_config().keys(); - }) - .def("full_config_value", [](const PresetBundle& bundle, const std::string& key) { - return config_value_or_none(bundle.full_config(), key); - }); - - // Axis-aligned bounding box, returned by value (a copy) so its lifetime is - // independent of the model object it was computed from. Coordinates are in mm. - py::class_(host, "BoundingBox", "Axis-aligned bounding box in millimetres") - .def_property_readonly("defined", [](const BoundingBoxf3& bb) { return bb.defined; }) - .def_property_readonly("min", [](const BoundingBoxf3& bb) { return vec3_to_tuple(bb.min); }) - .def_property_readonly("max", [](const BoundingBoxf3& bb) { return vec3_to_tuple(bb.max); }) - .def_property_readonly("size", [](const BoundingBoxf3& bb) { return vec3_to_tuple(bb.size()); }) - .def_property_readonly("center", [](const BoundingBoxf3& bb) { return vec3_to_tuple(bb.center()); }) - .def_property_readonly("radius", [](const BoundingBoxf3& bb) { return bb.radius(); }); - - py::class_(host, "TriangleMesh", - "Immutable snapshot of a ModelVolume's mesh in local (untransformed) coordinates, mm.") - .def("vertex_count", [](const HostTriangleMesh& mesh) { return mesh.its().vertices.size(); }) - .def("triangle_count", [](const HostTriangleMesh& mesh) { return mesh.its().indices.size(); }) - .def("facets_count", [](const HostTriangleMesh& mesh) { return mesh.its().indices.size(); }) - .def("is_empty", [](const HostTriangleMesh& mesh) { return mesh.its().indices.empty(); }) - // Read-only, zero-copy (N, 3) float32 view of vertex positions. Requires numpy. - .def("vertices", [](const HostTriangleMesh& mesh) { - return with_numpy([&] { - const indexed_triangle_set& its = mesh.its(); - return make_readonly_rows3( - mesh.mesh, - its.vertices.empty() ? nullptr : its.vertices.front().data(), - static_cast(its.vertices.size())); - }); - }, "Read-only zero-copy (N, 3) float32 ndarray of vertex positions (local mm). Requires numpy.") - // Read-only, zero-copy (M, 3) int32 view of triangle vertex indices. Requires numpy. - .def("triangles", [](const HostTriangleMesh& mesh) { - return with_numpy([&] { - const indexed_triangle_set& its = mesh.its(); - return make_readonly_rows3( - mesh.mesh, - its.indices.empty() ? nullptr : its.indices.front().data(), - static_cast(its.indices.size())); - }); - }, "Read-only zero-copy (M, 3) int32 ndarray of triangle vertex indices. Requires numpy.") - // One normalized normal per triangle as an (M, 3) float32 copy. Requires numpy. - .def("face_normals", [](const HostTriangleMesh& mesh) { - return with_numpy([&] { - std::vector normals = its_face_normals(mesh.its()); - py::array_t array({ static_cast(normals.size()), py::ssize_t(3) }); - if (!normals.empty()) { - auto view = array.mutable_unchecked<2>(); - for (size_t i = 0; i < normals.size(); ++i) { - view(i, 0) = normals[i].x(); - view(i, 1) = normals[i].y(); - view(i, 2) = normals[i].z(); - } - } - return py::object(std::move(array)); - }); - }, "Per-triangle normalized normals as an (M, 3) float32 ndarray (copy). Requires numpy.") - // numpy-free element access, bounds-checked. - .def("vertex", [](const HostTriangleMesh& mesh, size_t index) { - const std::vector& vertices = mesh.its().vertices; - if (index >= vertices.size()) - throw py::index_error("vertex index out of range"); - const stl_vertex& vertex = vertices[index]; - return py::make_tuple(vertex.x(), vertex.y(), vertex.z()); - }) - .def("triangle", [](const HostTriangleMesh& mesh, size_t index) { - const std::vector& indices = mesh.its().indices; - if (index >= indices.size()) - throw py::index_error("triangle index out of range"); - const stl_triangle_vertex_indices& triangle = indices[index]; - return py::make_tuple(triangle[0], triangle[1], triangle[2]); - }) - .def("volume", [](const HostTriangleMesh& mesh) { return mesh.mesh->stats().volume; }) - .def("bounding_box", [](const HostTriangleMesh& mesh) { return bbox_from_stats(mesh.mesh->stats()); }) - .def("is_manifold", [](const HostTriangleMesh& mesh) { return mesh.mesh->stats().manifold(); }); - - py::enum_(host, "ModelVolumeType") - .value("Invalid", ModelVolumeType::INVALID) - .value("ModelPart", ModelVolumeType::MODEL_PART) - .value("NegativeVolume", ModelVolumeType::NEGATIVE_VOLUME) - .value("ParameterModifier", ModelVolumeType::PARAMETER_MODIFIER) - .value("SupportBlocker", ModelVolumeType::SUPPORT_BLOCKER) - .value("SupportEnforcer", ModelVolumeType::SUPPORT_ENFORCER); - - py::class_>(host, "ModelVolume") - .def("id", [](const ModelVolume& volume) { return volume.id().id; }) - .def_readonly("name", &ModelVolume::name) - .def("type", &ModelVolume::type) - .def("is_model_part", &ModelVolume::is_model_part) - .def("is_modifier", &ModelVolume::is_modifier) - .def("is_negative_volume", &ModelVolume::is_negative_volume) - .def("is_support_enforcer", &ModelVolume::is_support_enforcer) - .def("is_support_blocker", &ModelVolume::is_support_blocker) - .def("is_support_modifier", &ModelVolume::is_support_modifier) - // Extruder ID is 1-based for FFF, -1 for SLA or support volumes. - .def("extruder_id", &ModelVolume::extruder_id) - .def("offset", [](const ModelVolume& volume) { return vec3_to_tuple(volume.get_offset()); }) - .def("rotation", [](const ModelVolume& volume) { return vec3_to_tuple(volume.get_rotation()); }) - .def("scaling_factor", [](const ModelVolume& volume) { return vec3_to_tuple(volume.get_scaling_factor()); }) - .def("mirror", [](const ModelVolume& volume) { return vec3_to_tuple(volume.get_mirror()); }) - // 4x4 float64 affine matrix mapping this volume into its parent object frame. Requires numpy. - .def("matrix", [](const ModelVolume& volume) { return mat4_to_numpy(volume.get_matrix()); }, - "Volume-to-object 4x4 float64 affine matrix (copy). Requires numpy.") - .def("facets_count", [](const ModelVolume& volume) { return volume.mesh().facets_count(); }) - // Raw (untransformed) mesh volume in mm^3; -1 if it was never computed. - .def("volume", [](const ModelVolume& volume) { return volume.mesh().stats().volume; }) - // Bounding box of the raw (untransformed) mesh, in the volume's local frame. - .def("bounding_box", [](const ModelVolume& volume) { return bbox_from_stats(volume.mesh().stats()); }) - .def("is_manifold", [](const ModelVolume& volume) { return volume.mesh().stats().manifold(); }) - // Full mesh geometry (vertices/triangles) as an immutable snapshot. - .def("mesh", [](const ModelVolume& volume) { - return HostTriangleMesh{ volume.get_mesh_shared_ptr() }; - }, "Return the volume's TriangleMesh (local coordinates) for vertex/triangle access.") - .def("mesh_errors_count", [](const ModelVolume& volume) { return volume.get_repaired_errors_count(); }) - .def("is_fdm_support_painted", &ModelVolume::is_fdm_support_painted) - .def("is_seam_painted", &ModelVolume::is_seam_painted) - .def("is_mm_painted", &ModelVolume::is_mm_painted) - .def("is_fuzzy_skin_painted", &ModelVolume::is_fuzzy_skin_painted) - .def("config_keys", [](const ModelVolume& volume) { return volume.config.keys(); }) - .def("config_value", [](const ModelVolume& volume, const std::string& key) { - return config_value_or_none(volume.config.get(), key); - }); - - py::class_>(host, "ModelInstance") - .def("id", [](const ModelInstance& instance) { return instance.id().id; }) - .def_readonly("printable", &ModelInstance::printable) - // True only if the object is printable, this instance is printable and it - // currently sits fully inside the print volume (set during slicing). - .def("is_printable", &ModelInstance::is_printable) - .def("offset", [](const ModelInstance& instance) { return vec3_to_tuple(instance.get_offset()); }) - .def("rotation", [](const ModelInstance& instance) { return vec3_to_tuple(instance.get_rotation()); }) - .def("scaling_factor", [](const ModelInstance& instance) { return vec3_to_tuple(instance.get_scaling_factor()); }) - .def("mirror", [](const ModelInstance& instance) { return vec3_to_tuple(instance.get_mirror()); }) - // 4x4 float64 affine matrix mapping the object into world space. Requires numpy. - // World vertices = instance.matrix() @ volume.matrix() applied to mesh vertices. - .def("matrix", [](const ModelInstance& instance) { return mat4_to_numpy(instance.get_matrix()); }, - "Object-to-world 4x4 float64 affine matrix (copy). Requires numpy.") - .def("is_left_handed", &ModelInstance::is_left_handed) - // Assemble-view placement. Each instance carries a second transform used only by - // the Assemble view, set from stored 3mf assemble data or derived from the regular - // transform. Until then (is_assemble_initialized() false) it is identity. - .def("is_assemble_initialized", [](ModelInstance& instance) { return instance.is_assemble_initialized(); }) - .def("assemble_offset", [](const ModelInstance& instance) { - return vec3_to_tuple(instance.get_assemble_transformation().get_offset()); - }) - .def("assemble_rotation", [](const ModelInstance& instance) { - return vec3_to_tuple(instance.get_assemble_transformation().get_rotation()); - }) - // 4x4 float64 affine matrix placing the object in the Assemble view. Requires numpy. - .def("assemble_matrix", [](const ModelInstance& instance) { - return mat4_to_numpy(instance.get_assemble_transformation().get_matrix()); - }, "Assemble-view 4x4 float64 affine matrix (copy). Requires numpy.") - // Offset from the instance origin to its position within the source assembly, - // recorded at import time (e.g. from a STEP assembly). - .def("offset_to_assembly", [](const ModelInstance& instance) { - return vec3_to_tuple(instance.get_offset_to_assembly()); - }) - // World-space bounding box of this instance. - .def("bounding_box", [](ModelInstance& instance) { - const ModelObject* object = instance.get_object(); - if (object == nullptr) - return BoundingBoxf3(); - return object->instance_bounding_box(instance); - }); - - py::class_>(host, "ModelObject") - .def("id", [](const ModelObject& object) { return object.id().id; }) - .def_readonly("name", &ModelObject::name) - .def_readonly("module_name", &ModelObject::module_name) - .def_readonly("input_file", &ModelObject::input_file) - // Import-time flag only: the GUI's printable toggle writes the per-instance - // ModelInstance::printable and never updates this field, so derive an - // object's effective state from its instances. - .def_readonly("printable", &ModelObject::printable) - .def("instance_count", [](const ModelObject& object) { - return object.instances.size(); - }) - .def("volume_count", [](const ModelObject& object) { - return object.volumes.size(); - }) - .def("instances", [](ModelObject& object) { - py::list instances; - for (ModelInstance* instance : object.instances) - instances.append(py::cast(instance, py::return_value_policy::reference)); - return instances; - }) - .def("instance", [](ModelObject& object, size_t index) -> ModelInstance* { - if (index >= object.instances.size()) - throw py::index_error("instance index out of range"); - return object.instances[index]; - }, py::return_value_policy::reference_internal) - .def("volumes", [](ModelObject& object) { - py::list volumes; - for (ModelVolume* volume : object.volumes) - volumes.append(py::cast(volume, py::return_value_policy::reference)); - return volumes; - }) - .def("volume", [](ModelObject& object, size_t index) -> ModelVolume* { - if (index >= object.volumes.size()) - throw py::index_error("volume index out of range"); - return object.volumes[index]; - }, py::return_value_policy::reference_internal) - // World-space bounding box over all instances of this object. - .def("bounding_box", [](const ModelObject& object) { return object.bounding_box_exact(); }) - // Bounding box of the object's raw (untransformed) part meshes — its intrinsic size. - .def("raw_mesh_bounding_box", [](const ModelObject& object) { return object.raw_mesh_bounding_box(); }) - .def("min_z", &ModelObject::min_z) - .def("max_z", &ModelObject::max_z) - .def("facets_count", [](const ModelObject& object) { return object.facets_count(); }) - .def("parts_count", [](const ModelObject& object) { return object.parts_count(); }) - .def("materials_count", [](const ModelObject& object) { return object.materials_count(); }) - .def("mesh_errors_count", [](const ModelObject& object) { return object.get_repaired_errors_count(); }) - .def("is_multiparts", &ModelObject::is_multiparts) - .def("is_cut", &ModelObject::is_cut) - .def("has_custom_layering", &ModelObject::has_custom_layering) - .def("is_fdm_support_painted", &ModelObject::is_fdm_support_painted) - .def("is_seam_painted", &ModelObject::is_seam_painted) - .def("is_mm_painted", &ModelObject::is_mm_painted) - .def("is_fuzzy_skin_painted", &ModelObject::is_fuzzy_skin_painted) - .def("config_keys", [](const ModelObject& object) { - return object.config.keys(); - }) - .def("config_value", [](const ModelObject& object, const std::string& key) { - return config_value_or_none(object.config.get(), key); - }); - - py::class_>(host, "Model") - .def("id", [](const Model& model) { return model.id().id; }) - .def("object_count", [](const Model& model) { - return model.objects.size(); - }) - .def("object", [](Model& model, size_t index) -> ModelObject* { - if (index >= model.objects.size()) - throw py::index_error("model object index out of range"); - return model.objects[index]; - }, py::return_value_policy::reference_internal) - .def("objects", [](Model& model) { - py::list objects; - for (ModelObject* object : model.objects) - objects.append(py::cast(object, py::return_value_policy::reference)); - return objects; - }) - // World-space bounding box of the whole model. bounding_box() is exact; - // bounding_box_approx() is faster and cached. - .def("bounding_box", [](const Model& model) { return model.bounding_box_exact(); }) - .def("bounding_box_approx", [](const Model& model) { return model.bounding_box_approx(); }) - .def("max_z", &Model::max_z) - .def("material_count", [](const Model& model) { return model.materials.size(); }) - .def("is_fdm_support_painted", &Model::is_fdm_support_painted) - .def("is_seam_painted", &Model::is_seam_painted) - .def("is_mm_painted", &Model::is_mm_painted) - .def("is_fuzzy_skin_painted", &Model::is_fuzzy_skin_painted) - .def("current_plate_index", [](const Model& model) { return model.curr_plate_index; }) - .def("designer", [](const Model& model) { - return model.design_info ? model.design_info->Designer : std::string(); - }) - .def("design_id", [](const Model& model) { return model.stl_design_id; }); - - py::class_>(host, "Plater") - .def("model", static_cast(&GUI::Plater::model), py::return_value_policy::reference_internal) - .def("is_project_dirty", &GUI::Plater::is_project_dirty) - .def("is_presets_dirty", &GUI::Plater::is_presets_dirty) - .def("inside_snapshot_capture", &GUI::Plater::inside_snapshot_capture); - - host.def("plater", ¤t_plater, py::return_value_policy::reference); - host.def("model", []() -> Model& { - return current_plater()->model(); - }, py::return_value_policy::reference); - host.def("preset_bundle", ¤t_preset_bundle, py::return_value_policy::reference); - - // UI: native dialogs and interactive HTML windows for plugins. - PluginHostUi::RegisterBindings(host); -} - -} // namespace Slic3r diff --git a/src/slic3r/plugin/PluginHostApi.hpp b/src/slic3r/plugin/PluginHostApi.hpp deleted file mode 100644 index 5aaf5882df..0000000000 --- a/src/slic3r/plugin/PluginHostApi.hpp +++ /dev/null @@ -1,13 +0,0 @@ -#pragma once - -#include - -namespace Slic3r { - -class PluginHostApi -{ -public: - static void RegisterBindings(pybind11::module_& module); -}; - -} // namespace Slic3r diff --git a/src/slic3r/plugin/PluginLoader.cpp b/src/slic3r/plugin/PluginLoader.cpp index 062ac23743..b524cea1d9 100644 --- a/src/slic3r/plugin/PluginLoader.cpp +++ b/src/slic3r/plugin/PluginLoader.cpp @@ -261,6 +261,13 @@ std::shared_ptr PluginLoader::get_plugin_capability_by_n return nullptr; } +std::map PluginLoader::get_plugin_settings(const std::string& plugin_key) const +{ + std::lock_guard lock(m_mutex); + const auto it = m_plugins.find(plugin_key); + return it != m_plugins.end() ? it->second.descriptor.settings : std::map{}; +} + std::vector> PluginLoader::get_loaded_plugin_capabilities(const std::string& plugin_key) const { std::lock_guard lock(m_mutex); @@ -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; } diff --git a/src/slic3r/plugin/PluginLoader.hpp b/src/slic3r/plugin/PluginLoader.hpp index 225b12d642..7872c07f21 100644 --- a/src/slic3r/plugin/PluginLoader.hpp +++ b/src/slic3r/plugin/PluginLoader.hpp @@ -105,6 +105,8 @@ public: std::chrono::milliseconds timeout, std::string& error) const; std::vector get_all_loaded_plugin_descriptors() const; + // the plugin's [tool.orcaslicer.plugin.settings] table (empty if the plugin is unknown). + std::map get_plugin_settings(const std::string& plugin_key) const; // Package descriptor accessor; returns nullptr when the package is not loaded. diff --git a/src/slic3r/plugin/PluginManager.cpp b/src/slic3r/plugin/PluginManager.cpp index c41607cf38..a34a9062b3 100644 --- a/src/slic3r/plugin/PluginManager.cpp +++ b/src/slic3r/plugin/PluginManager.cpp @@ -7,6 +7,7 @@ #include "PythonPluginBridge.hpp" #include "PluginFsUtils.hpp" +#include "PluginHooks.hpp" #include "PythonFileUtils.hpp" #include @@ -128,6 +129,10 @@ bool PluginManager::initialize() // leaves the store empty (see PluginConfig::load), never blocking startup. m_config.load(); + // 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 @@ -162,6 +167,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(); diff --git a/src/slic3r/plugin/PluginManager.hpp b/src/slic3r/plugin/PluginManager.hpp index 6290dd1c53..f752dcdd81 100644 --- a/src/slic3r/plugin/PluginManager.hpp +++ b/src/slic3r/plugin/PluginManager.hpp @@ -106,10 +106,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 +135,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 +144,19 @@ void execute_capabilities_from_refs(const ConfigOptionStrings& capabilities, cap = plugin_mgr.get_loader().get_plugin_capability_by_name(plugin_key, type, cap_name); if (!cap) { - BOOST_LOG_TRIVIAL(warning) << "Post-processing: no loaded capability '" << cap_name + BOOST_LOG_TRIVIAL(warning) << tag << ": no loaded capability '" << cap_name << "' for plugin '" << plugin_key << "'; skipping"; continue; } if (!cap->enabled) { - BOOST_LOG_TRIVIAL(warning) << "Post-processing: capability '" << cap_name + BOOST_LOG_TRIVIAL(warning) << tag << ": capability '" << cap_name << "' for plugin '" << plugin_key << "' is disabled; skipping"; continue; } auto plugin_capability = std::dynamic_pointer_cast(cap->instance); if (!plugin_capability) { - BOOST_LOG_TRIVIAL(warning) << "Post-processing: capability '" << cap_name + BOOST_LOG_TRIVIAL(warning) << tag << ": capability '" << cap_name << "' (plugin_key=" << cap->plugin_key << ") is not a " << plugin_capability_type_to_string(type) << "; skipping"; continue; diff --git a/src/slic3r/plugin/PluginResolver.cpp b/src/slic3r/plugin/PluginResolver.cpp index 0bf47858f4..d7808b6a5b 100644 --- a/src/slic3r/plugin/PluginResolver.cpp +++ b/src/slic3r/plugin/PluginResolver.cpp @@ -35,9 +35,9 @@ std::string find_option_for_capability(Preset::Type type, const Preset& preset, if (type != Preset::TYPE_PRINT && type != Preset::TYPE_PRINTER && type != Preset::TYPE_FILAMENT) return {}; - // Plugin-bearing options opt in via ConfigOptionDef::support_plugin, so scan the preset's - // definition rather than maintaining a hardcoded per-type field list. A typed preset's config - // only contains keys for its own type, so this naturally stays scoped to `type`. + // Plugin-bearing options opt in via ConfigOptionDef::is_plugin_backed (a non-empty plugin_type), + // so scan the preset's definition rather than maintaining a hardcoded per-type field list. A typed + // preset's config only contains keys for its own type, so this naturally stays scoped to `type`. const ConfigDef* def = preset.config.def(); if (def == nullptr) return {}; @@ -48,7 +48,7 @@ std::string find_option_for_capability(Preset::Type type, const Preset& preset, for (const std::string& field : preset.config.keys()) { const ConfigOptionDef* opt_def = def->get(field); - if (opt_def == nullptr || !opt_def->support_plugin) + if (opt_def == nullptr || !opt_def->is_plugin_backed()) continue; const ConfigOption* option = preset.config.option(field); diff --git a/src/slic3r/plugin/PythonFileUtils.cpp b/src/slic3r/plugin/PythonFileUtils.cpp index 53afb115de..9fec2ee13f 100644 --- a/src/slic3r/plugin/PythonFileUtils.cpp +++ b/src/slic3r/plugin/PythonFileUtils.cpp @@ -128,7 +128,7 @@ bool read_zip_text_file(mz_zip_archive& archive, const char* filename, std::stri } // TOML section parsing states. -enum class TomlSection { Root, OrcaPlugin, InDepsArray }; +enum class TomlSection { Root, OrcaPlugin, OrcaPluginSettings, InDepsArray }; // Strip a quoted string value: "foo" → foo, 'foo' → foo. // Returns the unquoted value or the input unchanged if not quoted. @@ -187,6 +187,7 @@ bool parse_pep723_toml(const std::string& toml_content, std::string& out_description, std::string& out_author, std::string& out_version, + std::map& out_settings, std::string& error) { out_deps.clear(); @@ -195,6 +196,7 @@ bool parse_pep723_toml(const std::string& toml_content, out_description.clear(); out_author.clear(); out_version.clear(); + out_settings.clear(); TomlSection section = TomlSection::Root; @@ -218,6 +220,8 @@ bool parse_pep723_toml(const std::string& toml_content, if (trimmed[0] == '[') { if (trimmed == "[tool.orcaslicer.plugin]") { section = TomlSection::OrcaPlugin; + } else if (trimmed == "[tool.orcaslicer.plugin.settings]") { + section = TomlSection::OrcaPluginSettings; // per-plugin params table } else { section = TomlSection::Root; // Unknown section — skip. } @@ -270,6 +274,10 @@ bool parse_pep723_toml(const std::string& toml_content, else if (key == "description") out_description = unquote_toml_string(val); else if (key == "author") out_author = unquote_toml_string(val); else if (key == "version") out_version = unquote_toml_string(val); + } else if (section == TomlSection::OrcaPluginSettings) { + // collect every key as a string; the plugin parses (int/float/...) what it needs. + if (!key.empty()) + out_settings[key] = unquote_toml_string(val); } } @@ -673,6 +681,7 @@ bool read_python_plugin_metadata(const boost::filesystem::path& py_path, PluginD pep_desc, pep_author, pep_version, + descriptor.settings, pep723_error)) { error = "Failed to parse PEP 723 metadata: " + pep723_error; return false; diff --git a/src/slic3r/plugin/PythonPluginBridge.cpp b/src/slic3r/plugin/PythonPluginBridge.cpp index 1aa23c57e0..7d8d829383 100644 --- a/src/slic3r/plugin/PythonPluginBridge.cpp +++ b/src/slic3r/plugin/PythonPluginBridge.cpp @@ -13,12 +13,12 @@ #include "PythonInterpreter.hpp" #include "PythonJsonUtils.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; @@ -289,7 +289,6 @@ void bind_python_api(pybind11::module_& m) m.doc() = "OrcaSlicer plugin API"; auto pluginTypes = py::enum_(m, "PluginType", "Available plugin capability groups") - .value("PostProcessing", PluginCapabilityType::PostProcessing) .value("PrinterConnection", PluginCapabilityType::PrinterConnection) .value("Automation", PluginCapabilityType::Automation) .value("Analysis", PluginCapabilityType::Analysis) @@ -297,6 +296,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(); @@ -380,10 +380,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); + SlicingPipelinePluginCapability::RegisterBindings(m, pluginTypes); + PluginHost::RegisterBindings(m); BOOST_LOG_TRIVIAL(debug) << "Registered ScriptPluginCapability Python bindings"; m.def( diff --git a/src/slic3r/plugin/PythonPluginInterface.hpp b/src/slic3r/plugin/PythonPluginInterface.hpp index 618445f503..d6c2ce6f5f 100644 --- a/src/slic3r/plugin/PythonPluginInterface.hpp +++ b/src/slic3r/plugin/PythonPluginInterface.hpp @@ -11,12 +11,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"; @@ -24,6 +23,7 @@ inline std::string plugin_capability_type_to_string(PluginCapabilityType type) case PluginCapabilityType::Exporter: return "exporter"; case PluginCapabilityType::Visualization: return "visualization"; case PluginCapabilityType::Script: return "script"; + case PluginCapabilityType::SlicingPipeline: return "slicing-pipeline"; default: return "unknown"; } } @@ -31,7 +31,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"; @@ -39,6 +38,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"; } } @@ -52,8 +52,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") @@ -68,6 +66,8 @@ inline PluginCapabilityType plugin_capability_type_from_string(std::string_view return PluginCapabilityType::Visualization; if (lowered == "script") return PluginCapabilityType::Script; + if (lowered == "slicing-pipeline") + return PluginCapabilityType::SlicingPipeline; return PluginCapabilityType::Unknown; } diff --git a/src/slic3r/plugin/host/PluginHost.cpp b/src/slic3r/plugin/host/PluginHost.cpp new file mode 100644 index 0000000000..524f07f12c --- /dev/null +++ b/src/slic3r/plugin/host/PluginHost.cpp @@ -0,0 +1,26 @@ +#include "PluginHost.hpp" +#include "PluginHostBindings.hpp" +#include "PluginHostUi.hpp" + +namespace Slic3r { + +void PluginHost::RegisterBindings(pybind11::module_& module) +{ + auto host = module.def_submodule("host", "Host application API"); + + // Value types first so the docstring signatures of later registrars + // resolve to the bound Python names. + host_bindings::register_geometry(host); + host_bindings::register_mesh(host); + host_bindings::register_presets(host); + host_bindings::register_model(host); + host_bindings::register_app(host); + + // UI: native dialogs and interactive HTML windows for plugins. + PluginHostUi::RegisterBindings(host); + + // Slicing print-graph data model (Print, Layer, Surface, ...). + host_bindings::register_slicing(host); +} + +} // namespace Slic3r diff --git a/src/slic3r/plugin/host/PluginHost.hpp b/src/slic3r/plugin/host/PluginHost.hpp new file mode 100644 index 0000000000..4385454cc3 --- /dev/null +++ b/src/slic3r/plugin/host/PluginHost.hpp @@ -0,0 +1,17 @@ +#pragma once + +#include + +namespace Slic3r { + +// Entry point of the `orca.host` Python API surface. Each domain of the +// surface (geometry, mesh, presets, model, app access, ui, slicing graph) +// lives in its own translation unit in this directory; RegisterBindings +// creates the submodule and runs the per-domain registrars. +class PluginHost +{ +public: + static void RegisterBindings(pybind11::module_& module); +}; + +} // namespace Slic3r diff --git a/src/slic3r/plugin/host/PluginHostApp.cpp b/src/slic3r/plugin/host/PluginHostApp.cpp new file mode 100644 index 0000000000..a17f160bb7 --- /dev/null +++ b/src/slic3r/plugin/host/PluginHostApp.cpp @@ -0,0 +1,60 @@ +#include "PluginHostBindings.hpp" + +#include +#include +#include +#include + +#include +#include + +namespace py = pybind11; + +namespace Slic3r { +namespace { + +GUI::Plater* current_plater() +{ + if (wxTheApp == nullptr) + throw std::runtime_error("OrcaSlicer application is not initialized"); + + GUI::Plater* plater = GUI::wxGetApp().plater(); + if (plater == nullptr) + throw std::runtime_error("Plater is not available"); + + return plater; +} + +PresetBundle* current_preset_bundle() +{ + if (wxTheApp == nullptr) + throw std::runtime_error("OrcaSlicer application is not initialized"); + + PresetBundle* preset_bundle = GUI::wxGetApp().preset_bundle; + if (preset_bundle == nullptr) + throw std::runtime_error("Preset bundle is not available"); + + return preset_bundle; +} + +} // namespace + +// Access to the live GUI application: the Plater and the module-level +// plater()/model()/preset_bundle() accessors. Everything here is owned by the +// app and only reachable once the GUI is up (the accessors throw before that). +void host_bindings::register_app(py::module_& host) +{ + py::class_>(host, "Plater") + .def("model", static_cast(&GUI::Plater::model), py::return_value_policy::reference_internal) + .def("is_project_dirty", &GUI::Plater::is_project_dirty) + .def("is_presets_dirty", &GUI::Plater::is_presets_dirty) + .def("inside_snapshot_capture", &GUI::Plater::inside_snapshot_capture); + + host.def("plater", ¤t_plater, py::return_value_policy::reference); + host.def("model", []() -> Model& { + return current_plater()->model(); + }, py::return_value_policy::reference); + host.def("preset_bundle", ¤t_preset_bundle, py::return_value_policy::reference); +} + +} // namespace Slic3r diff --git a/src/slic3r/plugin/host/PluginHostBindings.hpp b/src/slic3r/plugin/host/PluginHostBindings.hpp new file mode 100644 index 0000000000..0f206d5992 --- /dev/null +++ b/src/slic3r/plugin/host/PluginHostBindings.hpp @@ -0,0 +1,16 @@ +#pragma once + +#include + +// Internal to plugin/host/: the per-domain registrars of the `orca.host` +// surface, one per translation unit, called by PluginHost::RegisterBindings. +namespace Slic3r::host_bindings { + +void register_geometry(pybind11::module_& host); // PluginHostGeometry.cpp +void register_mesh(pybind11::module_& host); // PluginHostMesh.cpp +void register_presets(pybind11::module_& host); // PluginHostPresets.cpp +void register_model(pybind11::module_& host); // PluginHostModel.cpp +void register_app(pybind11::module_& host); // PluginHostApp.cpp +void register_slicing(pybind11::module_& host); // PluginHostSlicing.cpp + +} // namespace Slic3r::host_bindings diff --git a/src/slic3r/plugin/host/PluginHostGeometry.cpp b/src/slic3r/plugin/host/PluginHostGeometry.cpp new file mode 100644 index 0000000000..646dffb5d4 --- /dev/null +++ b/src/slic3r/plugin/host/PluginHostGeometry.cpp @@ -0,0 +1,216 @@ +#include "PluginHostBindings.hpp" +#include "slic3r/plugin/PluginBindingUtils.hpp" + +#include +#include // offset/offset_ex/union_ex/diff_ex/intersection_ex +#include + +#include + +#include +#include +#include + +namespace py = pybind11; + +namespace Slic3r { +namespace { +// --- Input path: Python geometry -> C++ Polygon/ExPolygon, with validation. --------------- +// The mutators take scaled integer coords (the same units the read views hand out). A Python +// raise here surfaces as ValueError (pybind translates) so malformed input is rejected up +// front rather than silently corrupting the slicing graph. + +// One (N,2) int64 ndarray -> Polygon. Rejects wrong dtype/shape and degenerate (<3 pt) rings. +// Float / NaN / inf are rejected implicitly: only a signed-integer, 8-byte (coord_t==int64) +// dtype is accepted, and integer arrays cannot hold NaN/inf. +Polygon parse_polygon(py::handle h, const char* who) +{ + if (!py::isinstance(h)) + throw py::value_error(std::string(who) + ": each contour/hole must be an (N,2) int64 ndarray"); + py::array a = py::reinterpret_borrow(h); + if (a.dtype().kind() != 'i' || a.itemsize() != (py::ssize_t) sizeof(coord_t)) + throw py::value_error(std::string(who) + ": polygon coordinates must be int64 (scaled coords)"); + if (a.ndim() != 2 || a.shape(1) != 2) + throw py::value_error(std::string(who) + ": each polygon array must have shape (N,2)"); + if (a.shape(0) < 3) + throw py::value_error(std::string(who) + ": a polygon needs at least 3 points"); + // dtype already validated as int64; forcecast here only guarantees a C-contiguous buffer. + auto arr = py::array_t::ensure(a); + if (!arr) + throw py::value_error(std::string(who) + ": could not read polygon as a contiguous int64 array"); + auto r = arr.unchecked<2>(); + Polygon poly; + poly.points.reserve((size_t) arr.shape(0)); + for (py::ssize_t i = 0; i < arr.shape(0); ++i) + poly.points.emplace_back((coord_t) r(i, 0), (coord_t) r(i, 1)); + return poly; +} + +// Accept a bound orca.host.Polygon (copied) or an (N,2) int64 ndarray. Used by the ExPolygon +// binding, whose constructor/contour-setter/set_holes must accept the Polygon it itself hands +// out (e.g. `ExPolygon(some_polygon_ref)`) in addition to the ndarray-only parse_polygon() path. +Polygon as_polygon(py::handle h, const char* who) +{ + if (py::isinstance(h)) + return h.cast(); + return parse_polygon(h, who); +} +} // namespace + +void host_bindings::register_geometry(py::module_& host) +{ + // ------------------------------------------------------------------ + // Geometry value types of the `orca.host` surface. All use pybind's + // default holder, so plugins can construct and own instances. When + // obtained from the live slicing graph they are non-owning references + // instead — see the lifetime rule in PluginHostSlicing.cpp. + // ------------------------------------------------------------------ + + // Axis-aligned bounding box, returned by value (a copy) so its lifetime is + // independent of the model object it was computed from. Coordinates are in mm. + py::class_(host, "BoundingBox", "Axis-aligned bounding box in millimetres") + .def_property_readonly("defined", [](const BoundingBoxf3& bb) { return bb.defined; }) + .def_property_readonly("min", [](const BoundingBoxf3& bb) { return vec3_to_tuple(bb.min); }) + .def_property_readonly("max", [](const BoundingBoxf3& bb) { return vec3_to_tuple(bb.max); }) + .def_property_readonly("size", [](const BoundingBoxf3& bb) { return vec3_to_tuple(bb.size()); }) + .def_property_readonly("center", [](const BoundingBoxf3& bb) { return vec3_to_tuple(bb.center()); }) + .def_property_readonly("radius", [](const BoundingBoxf3& bb) { return bb.radius(); }); + + // Point: a constructible value type (default holder, so Python-owned instances + // are freed). Returned-by-reference from Polygon.points, it aliases the buffer; + // x()/y() are Eigen lvalues, so the properties are read/write. p+q / p-q go + // through Eigen expression templates, wrapped back into a Point. + py::class_(host, "Point") + .def(py::init([](coord_t x, coord_t y) { return Point(x, y); }), py::arg("x"), py::arg("y")) + .def_property("x", [](const Point& p) { return p.x(); }, + [](Point& p, coord_t v) { p.x() = v; }) + .def_property("y", [](const Point& p) { return p.y(); }, + [](Point& p, coord_t v) { p.y() = v; }) + .def("__add__", [](const Point& a, const Point& b) { return Point(a + b); }, py::is_operator()) + .def("__sub__", [](const Point& a, const Point& b) { return Point(a - b); }, py::is_operator()) + .def("__mul__", [](const Point& a, double s) { return Point(a.x() * s, a.y() * s); }, py::is_operator()) + .def("__repr__", [](const Point& p) { + return "orca.host.Point(" + std::to_string(p.x()) + ", " + std::to_string(p.y()) + ")"; + }); + + py::class_(host, "Polygon") + .def(py::init<>()) + .def("size", [](const Polygon& p) { return p.points.size(); }) + .def("is_valid", [](const Polygon& p) { return p.is_valid(); }) + .def("is_counter_clockwise", [](const Polygon& p) { return p.is_counter_clockwise(); }) + .def("is_clockwise", [](const Polygon& p) { return p.is_clockwise(); }) + .def("make_counter_clockwise", [](Polygon& p) { return p.make_counter_clockwise(); }, + "Reorient to CCW in place. Returns True if it reversed the winding.") + .def("make_clockwise", [](Polygon& p) { return p.make_clockwise(); }) + .def("area", [](const Polygon& p) { return p.area(); }) + .def("centroid", [](const Polygon& p) { return p.centroid(); }) + .def("contains", [](const Polygon& p, const Point& pt) { return p.contains(pt); }, py::arg("point")) + .def("translate", [](Polygon& p, double x, double y) { p.translate(x, y); }, py::arg("x"), py::arg("y")) + .def("rotate", [](Polygon& p, double angle) { p.rotate(angle); }, py::arg("angle")) + .def("rotate", [](Polygon& p, double angle, const Point& c) { p.rotate(angle, c); }, + py::arg("angle"), py::arg("center")) + .def("douglas_peucker", [](Polygon& p, double tol) { p.douglas_peucker(tol); }, py::arg("tolerance")) + .def("simplify", [](const Polygon& p, double tol) { return p.simplify(tol); }, py::arg("tolerance"), + "Return simplified geometry as a list of Polygon (may split into several).") + .def("offset", [](const Polygon& p, coord_t delta) { return offset(p, (float) delta); }, py::arg("delta"), + "Clipper offset by `delta` scaled units (negative shrinks). Returns [Polygon].") + // --- Point-object idiom: references into the buffer (in-place element edit). --- + .def_property_readonly("points", [](py::object self) { + Polygon& p = self.cast(); + py::list out; + for (Point& pt : p.points) + out.append(py::cast(&pt, py::return_value_policy::reference_internal, self)); + return out; + }, "Vertices as [Point] references into this polygon. Editing a Point mutates the " + "buffer in place. Structural changes (count) go through set_points/append, which " + "invalidate previously returned Point refs and array views (C++ vector semantics).") + .def("append", [](Polygon& p, const Point& pt) { p.points.push_back(pt); }, py::arg("point"), + "Append a vertex. Structural change (count): invalidates previously returned " + "Point refs and array views into this polygon (C++ vector semantics).") + // --- numpy idiom: writable zero-copy (N,2) view (bulk affine edits). --- + .def("as_array", [](py::object self) { + Polygon& p = self.cast(); + return with_numpy([&] { + return py::object(make_writable_rows( + self, p.points.empty() ? nullptr : p.points.front().data(), + (py::ssize_t) p.points.size())); + }); + }, "Vertices as a WRITABLE int64 (N,2) numpy view in scaled coords, aliasing the " + "buffer. Count-preserving in-place edits only; valid during execute(ctx). Requires numpy.") + .def("set_points", [](Polygon& p, py::handle src) { p = parse_polygon(src, "Polygon.set_points"); }, + py::arg("points"), + "Replace all vertices from an (N,2) int64 ndarray (scaled coords). Count-changing; " + "invalidates prior Point refs and array views. Raises ValueError on malformed input."); + + // ExPolygon: default holder (Python-owned instances are freed) so plugins can construct + // their own geometry, not just navigate the live slicing graph. contour/holes accessors + // still use reference_internal, so refs into a graph-owned ExPolygon stay non-owning views + // tied to that owner's lifetime, same as Polygon/Surface. + py::class_(host, "ExPolygon") + .def(py::init([](py::handle contour, py::handle holes) { + // Accept bound Polygons or (N,2) ndarrays for both contour and each hole. + ExPolygon ex; + ex.contour = as_polygon(contour, "ExPolygon.contour"); + if (!holes.is_none()) { + if (!py::isinstance(holes) || py::isinstance(holes)) + throw py::value_error("ExPolygon: holes must be a list of Polygon or (N,2) ndarrays"); + for (py::handle h : py::reinterpret_borrow(holes)) { + Polygon hole = as_polygon(h, "ExPolygon.hole"); + hole.make_clockwise(); + ex.holes.emplace_back(std::move(hole)); + } + } + ex.contour.make_counter_clockwise(); + return ex; + }), py::arg("contour"), py::arg("holes") = py::none(), + "Construct from a Polygon/ndarray contour and optional list of hole Polygons/ndarrays. " + "Orientation is normalized (contour CCW, holes CW).") + .def_property("contour", + [](ExPolygon& e) -> Polygon& { return e.contour; }, + [](ExPolygon& e, py::handle v) { e.contour = as_polygon(v, "ExPolygon.contour"); }, + py::return_value_policy::reference_internal, + "Outer contour (CCW). Read returns a live Polygon ref; assign a Polygon/ndarray to replace it.") + .def_property_readonly("holes", [](py::object self) { + ExPolygon& e = self.cast(); + py::list out; + for (Polygon& h : e.holes) + out.append(py::cast(&h, py::return_value_policy::reference_internal, self)); + return out; + }, "Hole contours (CW) as [Polygon] references (in-place editable). set_holes replaces them.") + .def("set_holes", [](ExPolygon& e, py::handle holes) { + ExPolygon tmp; + if (!py::isinstance(holes) || py::isinstance(holes)) + throw py::value_error("set_holes: expected a list of Polygon or (N,2) ndarrays"); + for (py::handle h : py::reinterpret_borrow(holes)) { + Polygon hole = as_polygon(h, "ExPolygon.set_holes"); + hole.make_clockwise(); + tmp.holes.emplace_back(std::move(hole)); + } + e.holes = std::move(tmp.holes); + }, py::arg("holes"), "Replace all holes. Invalidates prior hole refs (C++ vector semantics).") + .def("translate", [](ExPolygon& e, double x, double y) { e.translate(x, y); }, py::arg("x"), py::arg("y")) + .def("rotate", [](ExPolygon& e, double a) { e.rotate(a); }, py::arg("angle")) + .def("rotate", [](ExPolygon& e, double a, const Point& c) { e.rotate(a, c); }, + py::arg("angle"), py::arg("center")) + .def("scale", [](ExPolygon& e, double f) { e.scale(f); }, py::arg("factor")) + .def("douglas_peucker", [](ExPolygon& e, double t) { e.douglas_peucker(t); }, py::arg("tolerance")) + .def("area", [](const ExPolygon& e) { return e.area(); }) + .def("is_valid", [](const ExPolygon& e) { return e.is_valid(); }) + .def("contains", [](const ExPolygon& e, const Point& p) { return e.contains(p); }, py::arg("point")) + .def("num_contours", [](const ExPolygon& e) { return e.num_contours(); }) + .def("simplify", [](const ExPolygon& e, double t) { return e.simplify(t); }, py::arg("tolerance"), + "Return simplified geometry as [ExPolygon].") + .def("offset", [](const ExPolygon& e, coord_t delta) { return offset_ex(e, (float) delta); }, + py::arg("delta"), "Clipper offset by `delta` scaled units (negative shrinks). Returns [ExPolygon].") + .def("union_ex", [](const ExPolygon& a, const ExPolygon& b) { + return union_ex(ExPolygons{ a, b }); + }, py::arg("other"), "Union with another ExPolygon. Returns [ExPolygon].") + .def("diff_ex", [](const ExPolygon& a, const ExPolygon& b) { + return diff_ex(ExPolygons{ a }, ExPolygons{ b }); + }, py::arg("other"), "This minus `other`. Returns [ExPolygon].") + .def("intersection_ex", [](const ExPolygon& a, const ExPolygon& b) { + return intersection_ex(ExPolygons{ a }, ExPolygons{ b }); + }, py::arg("other"), "Intersection with `other`. Returns [ExPolygon]."); +} + +} // namespace Slic3r diff --git a/src/slic3r/plugin/host/PluginHostMesh.cpp b/src/slic3r/plugin/host/PluginHostMesh.cpp new file mode 100644 index 0000000000..28da5c3afc --- /dev/null +++ b/src/slic3r/plugin/host/PluginHostMesh.cpp @@ -0,0 +1,101 @@ +#include "PluginHostBindings.hpp" +#include "PluginHostMesh.hpp" +#include "slic3r/plugin/PluginBindingUtils.hpp" + +#include + +#include +#include +#include + +namespace py = pybind11; + +namespace Slic3r { +namespace { + +// Zero-copy export of its.vertices / its.indices relies on these Eigen +// row-vectors being tightly packed (no padding between the 3 components). +static_assert(sizeof(stl_vertex) == 3 * sizeof(float), + "stl_vertex must be a packed float[3] for zero-copy numpy export"); +static_assert(sizeof(stl_triangle_vertex_indices) == 3 * sizeof(std::int32_t), + "triangle index must be a packed int32[3] for zero-copy numpy export"); + +} // 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_>(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(); + return with_numpy([&] { + const std::vector& vertices = mesh.its.vertices; + return py::object(make_readonly_rows( + self, vertices.empty() ? nullptr : vertices.front().data(), + static_cast(vertices.size()))); + }); + }, "Read-only zero-copy (N, 3) float32 ndarray of vertex positions (local mm). Requires numpy.") + // Read-only, zero-copy (M, 3) int32 view of triangle vertex indices. Requires numpy. + .def("triangles", [](py::object self) { + const TriangleMesh& mesh = self.cast(); + return with_numpy([&] { + const std::vector& indices = mesh.its.indices; + return py::object(make_readonly_rows( + self, indices.empty() ? nullptr : indices.front().data(), + static_cast(indices.size()))); + }); + }, "Read-only zero-copy (M, 3) int32 ndarray of triangle vertex indices. Requires numpy.") + // One normalized normal per triangle as an (M, 3) float32 copy. Requires numpy. + .def("face_normals", [](const TriangleMesh& mesh) { + return with_numpy([&] { + std::vector normals = its_face_normals(mesh.its); + py::array_t array({ static_cast(normals.size()), py::ssize_t(3) }); + if (!normals.empty()) { + auto view = array.mutable_unchecked<2>(); + for (size_t i = 0; i < normals.size(); ++i) { + view(i, 0) = normals[i].x(); + view(i, 1) = normals[i].y(); + view(i, 2) = normals[i].z(); + } + } + return py::object(std::move(array)); + }); + }, "Per-triangle normalized normals as an (M, 3) float32 ndarray (copy). Requires numpy.") + // numpy-free element access, bounds-checked. + .def("vertex", [](const TriangleMesh& mesh, size_t index) { + const std::vector& vertices = mesh.its.vertices; + if (index >= vertices.size()) + throw py::index_error("vertex index out of range"); + const stl_vertex& vertex = vertices[index]; + return py::make_tuple(vertex.x(), vertex.y(), vertex.z()); + }) + .def("triangle", [](const TriangleMesh& mesh, size_t index) { + const std::vector& indices = mesh.its.indices; + if (index >= indices.size()) + throw py::index_error("triangle index out of range"); + const stl_triangle_vertex_indices& triangle = indices[index]; + return py::make_tuple(triangle[0], triangle[1], triangle[2]); + }) + .def("volume", [](const TriangleMesh& mesh) { return mesh.stats().volume; }) + .def("bounding_box", [](const TriangleMesh& mesh) { return bbox_from_stats(mesh.stats()); }) + .def("is_manifold", [](const TriangleMesh& mesh) { return mesh.stats().manifold(); }); +} + +} // namespace Slic3r diff --git a/src/slic3r/plugin/host/PluginHostMesh.hpp b/src/slic3r/plugin/host/PluginHostMesh.hpp new file mode 100644 index 0000000000..05e8130973 --- /dev/null +++ b/src/slic3r/plugin/host/PluginHostMesh.hpp @@ -0,0 +1,18 @@ +#pragma once + +#include +#include + +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(), stats.max.cast()); +} + +} // namespace Slic3r diff --git a/src/slic3r/plugin/host/PluginHostModel.cpp b/src/slic3r/plugin/host/PluginHostModel.cpp new file mode 100644 index 0000000000..3b571da230 --- /dev/null +++ b/src/slic3r/plugin/host/PluginHostModel.cpp @@ -0,0 +1,208 @@ +#include "PluginHostBindings.hpp" +#include "PluginHostMesh.hpp" +#include "slic3r/plugin/PluginBindingUtils.hpp" + +#include + +#include + +#include +#include + +namespace py = pybind11; + +namespace Slic3r { + +// The scene/document graph: Model -> ModelObject -> ModelInstance/ModelVolume. +// Everything is bound py::nodelete — non-owning references into a graph owned +// by the app (the live Plater model) or by a Print's model snapshot. +void host_bindings::register_model(py::module_& host) +{ + py::enum_(host, "ModelVolumeType") + .value("Invalid", ModelVolumeType::INVALID) + .value("ModelPart", ModelVolumeType::MODEL_PART) + .value("NegativeVolume", ModelVolumeType::NEGATIVE_VOLUME) + .value("ParameterModifier", ModelVolumeType::PARAMETER_MODIFIER) + .value("SupportBlocker", ModelVolumeType::SUPPORT_BLOCKER) + .value("SupportEnforcer", ModelVolumeType::SUPPORT_ENFORCER); + + py::class_>(host, "ModelVolume") + .def("id", [](const ModelVolume& volume) { return volume.id().id; }) + .def_readonly("name", &ModelVolume::name) + .def("type", &ModelVolume::type) + .def("is_model_part", &ModelVolume::is_model_part) + .def("is_modifier", &ModelVolume::is_modifier) + .def("is_negative_volume", &ModelVolume::is_negative_volume) + .def("is_support_enforcer", &ModelVolume::is_support_enforcer) + .def("is_support_blocker", &ModelVolume::is_support_blocker) + .def("is_support_modifier", &ModelVolume::is_support_modifier) + // Extruder ID is 1-based for FFF, -1 for SLA or support volumes. + .def("extruder_id", &ModelVolume::extruder_id) + .def("offset", [](const ModelVolume& volume) { return vec3_to_tuple(volume.get_offset()); }) + .def("rotation", [](const ModelVolume& volume) { return vec3_to_tuple(volume.get_rotation()); }) + .def("scaling_factor", [](const ModelVolume& volume) { return vec3_to_tuple(volume.get_scaling_factor()); }) + .def("mirror", [](const ModelVolume& volume) { return vec3_to_tuple(volume.get_mirror()); }) + // 4x4 float64 affine matrix mapping this volume into its parent object frame. Requires numpy. + .def("matrix", [](const ModelVolume& volume) { return mat4_to_numpy(volume.get_matrix()); }, + "Volume-to-object 4x4 float64 affine matrix (copy). Requires numpy.") + .def("facets_count", [](const ModelVolume& volume) { return volume.mesh().facets_count(); }) + // Raw (untransformed) mesh volume in mm^3; -1 if it was never computed. + .def("volume", [](const ModelVolume& volume) { return volume.mesh().stats().volume; }) + // Bounding box of the raw (untransformed) mesh, in the volume's local frame. + .def("bounding_box", [](const ModelVolume& volume) { return bbox_from_stats(volume.mesh().stats()); }) + .def("is_manifold", [](const ModelVolume& volume) { return volume.mesh().stats().manifold(); }) + // Full mesh geometry (vertices/triangles) as an immutable snapshot. + .def("mesh", [](const ModelVolume& volume) { + // The volume stores its mesh as shared_ptr (a + // copy-on-write snapshot); the const_pointer_cast only serves the + // binding's shared_ptr holder — the TriangleMesh binding exposes + // read-only methods (see the immutability rule in PluginHostMesh.cpp). + return std::const_pointer_cast(volume.get_mesh_shared_ptr()); + }, "Return the volume's TriangleMesh (local coordinates) for vertex/triangle access.") + .def("mesh_errors_count", [](const ModelVolume& volume) { return volume.get_repaired_errors_count(); }) + .def("is_fdm_support_painted", &ModelVolume::is_fdm_support_painted) + .def("is_seam_painted", &ModelVolume::is_seam_painted) + .def("is_mm_painted", &ModelVolume::is_mm_painted) + .def("is_fuzzy_skin_painted", &ModelVolume::is_fuzzy_skin_painted) + .def("config_keys", [](const ModelVolume& volume) { return volume.config.keys(); }) + .def("config_value", [](const ModelVolume& volume, const std::string& key) { + return config_value_or_none(volume.config.get(), key); + }); + + py::class_>(host, "ModelInstance") + .def("id", [](const ModelInstance& instance) { return instance.id().id; }) + .def_readonly("printable", &ModelInstance::printable) + // True only if the object is printable, this instance is printable and it + // currently sits fully inside the print volume (set during slicing). + .def("is_printable", &ModelInstance::is_printable) + .def("offset", [](const ModelInstance& instance) { return vec3_to_tuple(instance.get_offset()); }) + .def("rotation", [](const ModelInstance& instance) { return vec3_to_tuple(instance.get_rotation()); }) + .def("scaling_factor", [](const ModelInstance& instance) { return vec3_to_tuple(instance.get_scaling_factor()); }) + .def("mirror", [](const ModelInstance& instance) { return vec3_to_tuple(instance.get_mirror()); }) + // 4x4 float64 affine matrix mapping the object into world space. Requires numpy. + // World vertices = instance.matrix() @ volume.matrix() applied to mesh vertices. + .def("matrix", [](const ModelInstance& instance) { return mat4_to_numpy(instance.get_matrix()); }, + "Object-to-world 4x4 float64 affine matrix (copy). Requires numpy.") + .def("is_left_handed", &ModelInstance::is_left_handed) + // Assemble-view placement. Each instance carries a second transform used only by + // the Assemble view, set from stored 3mf assemble data or derived from the regular + // transform. Until then (is_assemble_initialized() false) it is identity. + .def("is_assemble_initialized", [](ModelInstance& instance) { return instance.is_assemble_initialized(); }) + .def("assemble_offset", [](const ModelInstance& instance) { + return vec3_to_tuple(instance.get_assemble_transformation().get_offset()); + }) + .def("assemble_rotation", [](const ModelInstance& instance) { + return vec3_to_tuple(instance.get_assemble_transformation().get_rotation()); + }) + // 4x4 float64 affine matrix placing the object in the Assemble view. Requires numpy. + .def("assemble_matrix", [](const ModelInstance& instance) { + return mat4_to_numpy(instance.get_assemble_transformation().get_matrix()); + }, "Assemble-view 4x4 float64 affine matrix (copy). Requires numpy.") + // Offset from the instance origin to its position within the source assembly, + // recorded at import time (e.g. from a STEP assembly). + .def("offset_to_assembly", [](const ModelInstance& instance) { + return vec3_to_tuple(instance.get_offset_to_assembly()); + }) + // World-space bounding box of this instance. + .def("bounding_box", [](ModelInstance& instance) { + const ModelObject* object = instance.get_object(); + if (object == nullptr) + return BoundingBoxf3(); + return object->instance_bounding_box(instance); + }); + + py::class_>(host, "ModelObject") + .def("id", [](const ModelObject& object) { return object.id().id; }) + .def_readonly("name", &ModelObject::name) + .def_readonly("module_name", &ModelObject::module_name) + .def_readonly("input_file", &ModelObject::input_file) + // Import-time flag only: the GUI's printable toggle writes the per-instance + // ModelInstance::printable and never updates this field, so derive an + // object's effective state from its instances. + .def_readonly("printable", &ModelObject::printable) + .def("instance_count", [](const ModelObject& object) { + return object.instances.size(); + }) + .def("volume_count", [](const ModelObject& object) { + return object.volumes.size(); + }) + .def("instances", [](ModelObject& object) { + py::list instances; + for (ModelInstance* instance : object.instances) + instances.append(py::cast(instance, py::return_value_policy::reference)); + return instances; + }) + .def("instance", [](ModelObject& object, size_t index) -> ModelInstance* { + if (index >= object.instances.size()) + throw py::index_error("instance index out of range"); + return object.instances[index]; + }, py::return_value_policy::reference_internal) + .def("volumes", [](ModelObject& object) { + py::list volumes; + for (ModelVolume* volume : object.volumes) + volumes.append(py::cast(volume, py::return_value_policy::reference)); + return volumes; + }) + .def("volume", [](ModelObject& object, size_t index) -> ModelVolume* { + if (index >= object.volumes.size()) + throw py::index_error("volume index out of range"); + return object.volumes[index]; + }, py::return_value_policy::reference_internal) + // World-space bounding box over all instances of this object. + .def("bounding_box", [](const ModelObject& object) { return object.bounding_box_exact(); }) + // Bounding box of the object's raw (untransformed) part meshes — its intrinsic size. + .def("raw_mesh_bounding_box", [](const ModelObject& object) { return object.raw_mesh_bounding_box(); }) + .def("min_z", &ModelObject::min_z) + .def("max_z", &ModelObject::max_z) + .def("facets_count", [](const ModelObject& object) { return object.facets_count(); }) + .def("parts_count", [](const ModelObject& object) { return object.parts_count(); }) + .def("materials_count", [](const ModelObject& object) { return object.materials_count(); }) + .def("mesh_errors_count", [](const ModelObject& object) { return object.get_repaired_errors_count(); }) + .def("is_multiparts", &ModelObject::is_multiparts) + .def("is_cut", &ModelObject::is_cut) + .def("has_custom_layering", &ModelObject::has_custom_layering) + .def("is_fdm_support_painted", &ModelObject::is_fdm_support_painted) + .def("is_seam_painted", &ModelObject::is_seam_painted) + .def("is_mm_painted", &ModelObject::is_mm_painted) + .def("is_fuzzy_skin_painted", &ModelObject::is_fuzzy_skin_painted) + .def("config_keys", [](const ModelObject& object) { + return object.config.keys(); + }) + .def("config_value", [](const ModelObject& object, const std::string& key) { + return config_value_or_none(object.config.get(), key); + }); + + py::class_>(host, "Model") + .def("id", [](const Model& model) { return model.id().id; }) + .def("object_count", [](const Model& model) { + return model.objects.size(); + }) + .def("object", [](Model& model, size_t index) -> ModelObject* { + if (index >= model.objects.size()) + throw py::index_error("model object index out of range"); + return model.objects[index]; + }, py::return_value_policy::reference_internal) + .def("objects", [](Model& model) { + py::list objects; + for (ModelObject* object : model.objects) + objects.append(py::cast(object, py::return_value_policy::reference)); + return objects; + }) + // World-space bounding box of the whole model. bounding_box() is exact; + // bounding_box_approx() is faster and cached. + .def("bounding_box", [](const Model& model) { return model.bounding_box_exact(); }) + .def("bounding_box_approx", [](const Model& model) { return model.bounding_box_approx(); }) + .def("max_z", &Model::max_z) + .def("material_count", [](const Model& model) { return model.materials.size(); }) + .def("is_fdm_support_painted", &Model::is_fdm_support_painted) + .def("is_seam_painted", &Model::is_seam_painted) + .def("is_mm_painted", &Model::is_mm_painted) + .def("is_fuzzy_skin_painted", &Model::is_fuzzy_skin_painted) + .def("current_plate_index", [](const Model& model) { return model.curr_plate_index; }) + .def("designer", [](const Model& model) { + return model.design_info ? model.design_info->Designer : std::string(); + }) + .def("design_id", [](const Model& model) { return model.stl_design_id; }); +} + +} // namespace Slic3r diff --git a/src/slic3r/plugin/host/PluginHostPresets.cpp b/src/slic3r/plugin/host/PluginHostPresets.cpp new file mode 100644 index 0000000000..fffe9e64cf --- /dev/null +++ b/src/slic3r/plugin/host/PluginHostPresets.cpp @@ -0,0 +1,138 @@ +#include "PluginHostBindings.hpp" +#include "slic3r/plugin/PluginBindingUtils.hpp" + +#include +#include + +#include + +#include +#include + +namespace py = pybind11; + +namespace Slic3r { +namespace { + +py::list current_filament_presets(PresetBundle& bundle) +{ + py::list presets; + for (const std::string& preset_name : bundle.filament_presets) { + Preset* preset = bundle.filaments.find_preset(preset_name); + if (preset == nullptr) + presets.append(py::none()); + else + presets.append(py::cast(preset, py::return_value_policy::reference)); + } + return presets; +} + +PresetCollection& printer_presets(PresetBundle& bundle) +{ + return static_cast(bundle.printers); +} + +} // namespace + +void host_bindings::register_presets(py::module_& host) +{ + py::enum_(host, "PresetType") + .value("Invalid", Preset::TYPE_INVALID) + .value("Print", Preset::TYPE_PRINT) + .value("SlaPrint", Preset::TYPE_SLA_PRINT) + .value("Filament", Preset::TYPE_FILAMENT) + .value("SlaMaterial", Preset::TYPE_SLA_MATERIAL) + .value("Printer", Preset::TYPE_PRINTER) + .value("PhysicalPrinter", Preset::TYPE_PHYSICAL_PRINTER) + .value("Plate", Preset::TYPE_PLATE) + .value("Model", Preset::TYPE_MODEL); + + py::class_>(host, "Preset") + .def_readonly("type", &Preset::type) + .def_readonly("name", &Preset::name) + .def_readonly("alias", &Preset::alias) + .def_readonly("file", &Preset::file) + .def_readonly("is_default", &Preset::is_default) + .def_readonly("is_external", &Preset::is_external) + .def_readonly("is_system", &Preset::is_system) + .def_readonly("is_visible", &Preset::is_visible) + .def_readonly("is_dirty", &Preset::is_dirty) + .def_readonly("is_compatible", &Preset::is_compatible) + .def_readonly("is_project_embedded", &Preset::is_project_embedded) + .def_readonly("bundle_id", &Preset::bundle_id) + .def("is_user", &Preset::is_user) + .def("is_from_bundle", &Preset::is_from_bundle) + .def("label", &Preset::label, py::arg("no_alias") = false) + .def("config_keys", [](const Preset& preset) { return preset.config.keys(); }) + .def("config_value", [](const Preset& preset, const std::string& key) { + return config_value_or_none(preset.config, key); + }); + + py::class_>(host, "PresetCollection") + .def("size", &PresetCollection::size) + .def("get_selected_preset", [](PresetCollection& collection) -> Preset& { + return collection.get_selected_preset(); + }, py::return_value_policy::reference_internal) + .def("selected_preset", [](PresetCollection& collection) -> Preset& { + return collection.get_selected_preset(); + }, py::return_value_policy::reference_internal) + .def("get_selected_preset_name", &PresetCollection::get_selected_preset_name) + .def("selected_preset_name", &PresetCollection::get_selected_preset_name) + .def("get_edited_preset", [](PresetCollection& collection) -> Preset& { + return collection.get_edited_preset(); + }, py::return_value_policy::reference_internal) + .def("edited_preset", [](PresetCollection& collection) -> Preset& { + return collection.get_edited_preset(); + }, py::return_value_policy::reference_internal) + .def("preset", [](PresetCollection& collection, size_t index) -> Preset& { + if (index >= collection.size()) + throw py::index_error("preset index out of range"); + return collection.preset(index); + }, py::return_value_policy::reference_internal) + .def("find_preset", [](PresetCollection& collection, const std::string& name) -> Preset* { + return collection.find_preset(name); + }, py::return_value_policy::reference_internal) + .def("preset_names", [](const PresetCollection& collection) { + std::vector names; + names.reserve(collection.get_presets().size()); + for (const Preset& preset : collection.get_presets()) + names.push_back(preset.name); + return names; + }); + + py::class_>(host, "PresetBundle") + .def_property_readonly("prints", [](PresetBundle& bundle) -> PresetCollection& { + return bundle.prints; + }, py::return_value_policy::reference_internal) + .def_property_readonly("printers", &printer_presets, py::return_value_policy::reference_internal) + .def_property_readonly("filaments", [](PresetBundle& bundle) -> PresetCollection& { + return bundle.filaments; + }, py::return_value_policy::reference_internal) + .def_property_readonly("sla_prints", [](PresetBundle& bundle) -> PresetCollection& { + return bundle.sla_prints; + }, py::return_value_policy::reference_internal) + .def_property_readonly("sla_materials", [](PresetBundle& bundle) -> PresetCollection& { + return bundle.sla_materials; + }, py::return_value_policy::reference_internal) + .def("current_process_preset", [](PresetBundle& bundle) -> Preset& { + return bundle.prints.get_edited_preset(); + }, py::return_value_policy::reference_internal) + .def("current_print_preset", [](PresetBundle& bundle) -> Preset& { + return bundle.prints.get_edited_preset(); + }, py::return_value_policy::reference_internal) + .def("current_printer_preset", [](PresetBundle& bundle) -> Preset& { + return bundle.printers.get_edited_preset(); + }, py::return_value_policy::reference_internal) + .def("current_filament_preset_names", [](PresetBundle& bundle) { + return bundle.filament_presets; + }) + .def("current_filament_presets", ¤t_filament_presets) + .def("full_config_keys", [](const PresetBundle& bundle) { + return bundle.full_config().keys(); + }) + .def("full_config_value", [](const PresetBundle& bundle, const std::string& key) { + return config_value_or_none(bundle.full_config(), key); + }); +} + +} // namespace Slic3r diff --git a/src/slic3r/plugin/host/PluginHostSlicing.cpp b/src/slic3r/plugin/host/PluginHostSlicing.cpp new file mode 100644 index 0000000000..30e7b45138 --- /dev/null +++ b/src/slic3r/plugin/host/PluginHostSlicing.cpp @@ -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 +#include +#include + +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 fails on a loop. We descend into +// loops/multipaths here to reach their contained paths. +static void collect_extrusion_paths(const ExtrusionEntity* ee, std::vector& out) +{ + if (ee == nullptr) + return; + if (const auto* coll = dynamic_cast(ee)) { + for (const ExtrusionEntity* child : coll->entities) + collect_extrusion_paths(child, out); + } else if (const auto* loop = dynamic_cast(ee)) { + for (const ExtrusionPath& p : loop->paths) + out.push_back(&p); + } else if (const auto* mp = dynamic_cast(ee)) { + for (const ExtrusionPath& p : mp->paths) + out.push_back(&p); + } else if (const auto* path = dynamic_cast(ee)) { + // Catches ExtrusionPath and its subclasses (Sloped/Contoured/Oriented) last, + // after the composite types above have been ruled out. + out.push_back(path); + } +} + +// 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_(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_(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_>(host, "SurfaceCollection") + .def("size", [](const SurfaceCollection& c) { return c.surfaces.size(); }) + .def("empty", [](const SurfaceCollection& c) { return c.empty(); }) + .def("clear", [](SurfaceCollection& c) { c.clear(); }) + .def("has", [](const SurfaceCollection& c, SurfaceType t) { return c.has(t); }, py::arg("surface_type")) + .def("set_type", [](SurfaceCollection& c, SurfaceType t) { c.set_type(t); }, py::arg("surface_type")) + .def("set", [](SurfaceCollection& c, const std::vector& src, SurfaceType t) { c.set(src, t); }, + py::arg("expolygons"), py::arg("surface_type"), + "Replace all surfaces from a list of ExPolygon, all tagged `surface_type`.") + .def("set", [](SurfaceCollection& c, const std::vector& src) { c.set(src); }, + py::arg("surfaces"), "Replace all surfaces from a list of Surface (types preserved per surface).") + .def("append", [](SurfaceCollection& c, const std::vector& src, SurfaceType t) { c.append(src, t); }, + py::arg("expolygons"), py::arg("surface_type")) + .def("filter_by_type", [](py::object self, SurfaceType t) { + SurfaceCollection& c = self.cast(); + py::list out; + // SurfaceCollection::filter_by_type returns SurfacesPtr, which is + // std::vector (see Surface.hpp), so iterate by const + // pointer (py::cast accepts `const itype*` directly, see cast.h cast(const itype*)). + for (const Surface* s : c.filter_by_type(t)) + out.append(py::cast(s, py::return_value_policy::reference_internal, self)); + return out; + }, py::arg("surface_type"), "Surfaces of a given type as [Surface] refs. Invalidated by " + "set()/append()/clear() on this collection (C++ vector semantics), same as .surfaces.") + .def_property_readonly("surfaces", [](py::object self) { + SurfaceCollection& c = self.cast(); + py::list out; + for (Surface& s : c.surfaces) + out.append(py::cast(&s, py::return_value_policy::reference_internal, self)); + return out; + }, "Surfaces as [Surface] references into the live collection. Invalidated by " + "set()/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_>(host, "ExtrusionEntity") + .def_property_readonly("role", [](const ExtrusionEntity& e) { + return ExtrusionEntity::role_to_string(e.role()); + }, "Extrusion role as a human-readable string (e.g. \"Outer wall\", \"Sparse infill\")."); + + py::class_>(host, "ExtrusionPath") + .def("points", [](py::object self) { + const ExtrusionPath& p = self.cast(); + const Points3& pts = p.polyline.points; + return with_numpy([&] { + return py::object(make_readonly_rows( + self, pts.empty() ? nullptr : pts.front().data(), (py::ssize_t) pts.size())); + }); + }, "Path vertices as a read-only int64 (N,3) numpy view in scaled coords " + "(the polyline is natively 3D on this branch). Requires numpy.") + .def_readonly("width", &ExtrusionPath::width) + .def_readonly("height", &ExtrusionPath::height) + .def_readonly("mm3_per_mm", &ExtrusionPath::mm3_per_mm); + + py::class_>(host, "ExtrusionLoop") + .def_property_readonly("paths", [](py::object self) { + ExtrusionLoop& l = self.cast(); + py::list out; + for (ExtrusionPath& p : l.paths) + out.append(py::cast(&p, py::return_value_policy::reference_internal, self)); + return out; + }, "The loop's constituent paths as [ExtrusionPath]."); + + py::class_>(host, "ExtrusionMultiPath") + .def_property_readonly("paths", [](py::object self) { + ExtrusionMultiPath& m = self.cast(); + py::list out; + for (ExtrusionPath& p : m.paths) + out.append(py::cast(&p, py::return_value_policy::reference_internal, self)); + return out; + }, "The multipath's constituent paths as [ExtrusionPath]."); + + py::class_>(host, "ExtrusionEntityCollection") + .def("size", [](const ExtrusionEntityCollection& c) { return c.entities.size(); }) + .def_property_readonly("entities", [](py::object self) { + ExtrusionEntityCollection& c = self.cast(); + py::list out; + for (ExtrusionEntity* e : c.entities) + out.append(py::cast(e, py::return_value_policy::reference_internal, self)); + return out; + }, "Child entities. Each is handed to you as its concrete type only when that type " + "is registered; a child whose concrete type is unregistered (e.g. a scarf-seam " + "ExtrusionLoopSloped) surfaces as a bare ExtrusionEntity exposing only .role. Use " + "flatten_paths() to robustly reach every ExtrusionPath leaf.") + .def("flatten_paths", [](py::object self) { + const ExtrusionEntityCollection& c = self.cast(); + std::vector paths; + collect_extrusion_paths(&c, paths); + py::list out; + for (const ExtrusionPath* p : paths) + out.append(py::cast(const_cast(p), + py::return_value_policy::reference_internal, self)); + return out; + }, "Every leaf ExtrusionPath under this tree (collections recursed into, " + "loops/multipaths decomposed)."); + + py::class_>(host, "PrintRegion") + .def("config_keys", [](const PrintRegion& r) { return r.config().keys(); }) + .def("config_value", [](const PrintRegion& r, const std::string& key) { + return config_value_or_none(r.config(), key); + }, py::arg("key"), + "Serialized value of this region's resolved config option, or None if absent."); + + auto layer_region = py::class_>(host, "LayerRegion"); + layer_region + .def_readonly("slices", &LayerRegion::slices, + "Sliced, typed surfaces (SurfaceCollection). 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_>(host, "Layer"); + layer + .def_readonly("print_z", &Layer::print_z) + .def_readonly("slice_z", &Layer::slice_z) + .def_readonly("height", &Layer::height) + .def_property_readonly("upper_layer", [](Layer& l) -> py::object { + if (l.upper_layer == nullptr) return py::none(); + return py::cast(l.upper_layer, py::return_value_policy::reference); + }, "The layer above, or None (graph navigation, like C++).") + .def_property_readonly("lower_layer", [](Layer& l) -> py::object { + if (l.lower_layer == nullptr) return py::none(); + return py::cast(l.lower_layer, py::return_value_policy::reference); + }, "The layer below, or None.") + .def("regions", [](py::object self) { + Layer& l = self.cast(); + py::list out; + for (LayerRegion* r : l.regions()) + out.append(py::cast(r, py::return_value_policy::reference_internal, self)); + return out; + }, "Per-region data as [LayerRegion].") + .def("make_slices", [](Layer& l) { + l.make_slices(); + refresh_lslices_bboxes(l); + }, "Re-derive lslices (merged islands) from the region slices and refresh the bbox " + "cache — the C++ invariant-maintenance call after in-place slice edits.") + .def("lslices", [](py::object self) { + Layer& l = self.cast(); + py::list out; + for (ExPolygon& e : l.lslices) + out.append(py::cast(&e, py::return_value_policy::reference_internal, self)); + return out; + }, "Merged per-layer islands as [ExPolygon] refs (in-place editable). Derived from the " + "region slices; call make_slices() to re-derive after edits. Invalidated by make_slices()."); + + py::class_>(host, "PrintObject") + .def("id", [](const PrintObject& o) { return o.id().id; }, + "Stable numeric object id (ObjectBase::id()).") + .def("layers", [](py::object self) { + PrintObject& o = self.cast(); + py::list out; + for (Layer* l : o.layers()) + out.append(py::cast(l, py::return_value_policy::reference_internal, self)); + return out; + }, "Object layers, bottom-up, as [Layer].") + .def("support_layers", [](py::object self) { + PrintObject& o = self.cast(); + py::list out; + for (SupportLayer* sl : o.support_layers()) + out.append(py::cast(static_cast(sl), + py::return_value_policy::reference_internal, self)); + return out; + }, "Support layers as [Layer] (support-specific fields are not exposed).") + .def("model_object", [](PrintObject& o) -> py::object { + // The Print's model SNAPSHOT (worker-thread stable), reusing the + // orca.host.ModelObject bindings — mesh access for slicing plugins. + // o is non-const here, so model_object() already returns a non-const ModelObject*. + return py::cast(o.model_object(), py::return_value_policy::reference); + }, "The source orca.host.ModelObject from the Print's own model snapshot.") + .def("bounding_box", [](const PrintObject& o) { + const BoundingBox bb = o.bounding_box(); + return py::make_tuple(bb.min.x(), bb.min.y(), bb.max.x(), bb.max.y()); + }, "Object XY bounding box in scaled coords as (min_x, min_y, max_x, max_y). The " + "sliced polygons live in this same frame, so its midpoint is the footprint center.") + .def("trafo", [](const PrintObject& o) { return mat4_to_numpy(o.trafo()); }, + "Object-to-print 4x4 float64 affine matrix (copy). Requires numpy.") + .def("config_keys", [](const PrintObject& o) { return o.config().keys(); }) + .def("config_value", [](const PrintObject& o, const std::string& key) { + return config_value_or_none(o.config(), key); + }, py::arg("key"), + "Serialized value of a resolved per-object config option, or None if absent."); + + py::class_>(host, "Print") + .def("objects", [](py::object self) { + Print& p = self.cast(); + py::list out; + for (PrintObject* o : p.objects()) + out.append(py::cast(o, py::return_value_policy::reference_internal, self)); + return out; + }, "The print's objects as [PrintObject].") + .def("model", [](Print& p) -> Model& { return const_cast(p.model()); }, + py::return_value_policy::reference_internal, + "The Print's own Model snapshot (worker-thread stable). Inside slicing " + "hooks use THIS — never orca.host.model(), which is the live GUI model " + "owned by another thread.") + .def("config_keys", [](const Print& p) { return p.full_print_config().keys(); }) + .def("config_value", [](const Print& p, const std::string& key) { + return config_value_or_none(p.full_print_config(), key); + }, py::arg("key"), + "Serialized value of the resolved (full) print config for this slice, or None.") + .def("canceled", [](const Print& p) { return p.canceled(); }, + "True once cancellation was requested (prefer ctx.cancelled())."); +} + +} // namespace Slic3r diff --git a/src/slic3r/plugin/PluginHostUi.cpp b/src/slic3r/plugin/host/PluginHostUi.cpp similarity index 99% rename from src/slic3r/plugin/PluginHostUi.cpp rename to src/slic3r/plugin/host/PluginHostUi.cpp index 05978391b9..00e7a596b4 100644 --- a/src/slic3r/plugin/PluginHostUi.cpp +++ b/src/slic3r/plugin/host/PluginHostUi.cpp @@ -1,8 +1,8 @@ #include "PluginHostUi.hpp" -#include "PluginAuditManager.hpp" -#include "PythonInterpreter.hpp" // PythonGILState -#include "PythonJsonUtils.hpp" // json_to_py / py_to_json +#include "slic3r/plugin/PluginAuditManager.hpp" +#include "slic3r/plugin/PythonInterpreter.hpp" // PythonGILState +#include "slic3r/plugin/PythonJsonUtils.hpp" // json_to_py / py_to_json #include #include diff --git a/src/slic3r/plugin/PluginHostUi.hpp b/src/slic3r/plugin/host/PluginHostUi.hpp similarity index 100% rename from src/slic3r/plugin/PluginHostUi.hpp rename to src/slic3r/plugin/host/PluginHostUi.hpp diff --git a/src/slic3r/plugin/pluginTypes/gcode/GCodePluginCapability.cpp b/src/slic3r/plugin/pluginTypes/gcode/GCodePluginCapability.cpp deleted file mode 100644 index 0639bdb774..0000000000 --- a/src/slic3r/plugin/pluginTypes/gcode/GCodePluginCapability.cpp +++ /dev/null @@ -1,30 +0,0 @@ -#include "GCodePluginCapability.hpp" - -#include "GCodePluginCapabilityTrampoline.hpp" - -#include -#include - -namespace py = pybind11; - -namespace Slic3r { - -void GCodePluginCapability::RegisterBindings(pybind11::module_& module, pybind11::enum_& pluginTypes) -{ - (void) pluginTypes; - - auto gcode = module.def_submodule("gcode", "G-code API"); - - py::class_(gcode, "GCodePluginContext", "Context shared with G-code plugins") - .def(py::init<>()) - .def_readwrite("gcode_path", &GCodePluginContext::gcode_path) - .def_readwrite("host", &GCodePluginContext::host) - .def_readwrite("output_name", &GCodePluginContext::output_name); - - py::class_>(gcode, "GCodePluginCapabilityBase") - .def(py::init<>()) - .def("get_type", &GCodePluginCapability::get_type) - .def("execute", &GCodePluginCapability::execute); -} - -} // namespace Slic3r diff --git a/src/slic3r/plugin/pluginTypes/gcode/GCodePluginCapability.hpp b/src/slic3r/plugin/pluginTypes/gcode/GCodePluginCapability.hpp deleted file mode 100644 index d935fcff1d..0000000000 --- a/src/slic3r/plugin/pluginTypes/gcode/GCodePluginCapability.hpp +++ /dev/null @@ -1,27 +0,0 @@ -#ifndef slic3r_GCodePluginCapability_hpp_ -#define slic3r_GCodePluginCapability_hpp_ - -#include "../../PythonPluginInterface.hpp" - -namespace Slic3r { - -struct GCodePluginContext : public PluginContext { - std::string gcode_path; - std::string host; - std::string output_name; -}; - -class GCodePluginCapability : public PluginCapabilityInterface -{ -public: - PluginCapabilityType get_type() const override { return PluginCapabilityType::PostProcessing; } - - virtual ExecutionResult execute(const GCodePluginContext& ctx) = 0; - - static void RegisterBindings(pybind11::module_ &module, - pybind11::enum_ &pluginTypes); -}; - -} // namespace Slic3r - -#endif /* slic3r_GCodePluginCapability_hpp_ */ diff --git a/src/slic3r/plugin/pluginTypes/gcode/GCodePluginCapabilityTrampoline.hpp b/src/slic3r/plugin/pluginTypes/gcode/GCodePluginCapabilityTrampoline.hpp deleted file mode 100644 index 83bcdc1a70..0000000000 --- a/src/slic3r/plugin/pluginTypes/gcode/GCodePluginCapabilityTrampoline.hpp +++ /dev/null @@ -1,35 +0,0 @@ -#ifndef slic3r_GCodePluginCapabilityTrampoline_hpp_ -#define slic3r_GCodePluginCapabilityTrampoline_hpp_ - -#include - -#include "../../PyPluginTrampoline.hpp" -#include "../../PluginAuditManager.hpp" -#include "GCodePluginCapability.hpp" - -namespace Slic3r { -class PyGCodePluginCapabilityTrampoline : public PyPluginCommonTrampoline -{ -public: - using PyPluginCommonTrampoline::PyPluginCommonTrampoline; - - ExecutionResult execute(const GCodePluginContext& ctx) override - { - ORCA_PY_OVERRIDE_AUDITED( - ::Slic3r::PluginAuditManager::AuditMode::Loading, - [&] { - // G-code post-processing plugins may also write into the folder holding the - // current temp G-code file, in addition to the globally-allowed data_dir(). - // The setup callback runs AFTER the context is constructed so the scoped root - // is not cleared by ScopedPluginAuditContext's constructor. - - if (!ctx.gcode_path.empty()) - ::Slic3r::PluginAuditManager::instance().add_scoped_allowed_root( - std::filesystem::path(ctx.gcode_path).parent_path()); - }, - PYBIND11_OVERRIDE_PURE, ExecutionResult, GCodePluginCapability, execute, ctx); - } -}; -} // namespace Slic3r - -#endif diff --git a/src/slic3r/plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.cpp b/src/slic3r/plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.cpp new file mode 100644 index 0000000000..19e32118aa --- /dev/null +++ b/src/slic3r/plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.cpp @@ -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 // std::map -> 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_& 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_(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(v); }, py::arg("coord"), + "Convert a scaled integer coordinate to millimeters (reads the live SCALING_FACTOR)."); + + py::class_(slicing, "SlicingPipelineContext") + .def_readonly("orca_version", &SlicingPipelineContext::orca_version) + .def_readonly("step", &SlicingPipelineContext::step) + .def_readonly("params", &SlicingPipelineContext::params, + "read-only dict of this plugin's [tool.orcaslicer.plugin.settings] values " + "(string->string). Parse the values you need, e.g. float(ctx.params['rate']).") + .def_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(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_>(slicing, "SlicingPipelineCapabilityBase") + .def(py::init<>()) + .def("get_type", &SlicingPipelinePluginCapability::get_type) + .def("execute", &SlicingPipelinePluginCapability::execute); +} + +} // namespace Slic3r diff --git a/src/slic3r/plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.hpp b/src/slic3r/plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.hpp new file mode 100644 index 0000000000..eda2c70324 --- /dev/null +++ b/src/slic3r/plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.hpp @@ -0,0 +1,45 @@ +#pragma once +#include "slic3r/plugin/PythonPluginInterface.hpp" +#include "libslic3r/Print.hpp" // SlicingPipelineStepPlugin, Print, PrintObject +#include +#include +#include + +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 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_& pluginTypes); +}; + +} // namespace Slic3r diff --git a/src/slic3r/plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapabilityTrampoline.hpp b/src/slic3r/plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapabilityTrampoline.hpp new file mode 100644 index 0000000000..350f8be506 --- /dev/null +++ b/src/slic3r/plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapabilityTrampoline.hpp @@ -0,0 +1,29 @@ +#pragma once +#include "SlicingPipelinePluginCapability.hpp" +#include "slic3r/plugin/PyPluginTrampoline.hpp" +#include "slic3r/plugin/PluginAuditManager.hpp" +#include + +namespace Slic3r { +class PySlicingPipelinePluginCapabilityTrampoline : public PyPluginCommonTrampoline { +public: + using PyPluginCommonTrampoline::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 diff --git a/tests/fff_print/CMakeLists.txt b/tests/fff_print/CMakeLists.txt index 1467c1397f..e07fde0593 100644 --- a/tests/fff_print/CMakeLists.txt +++ b/tests/fff_print/CMakeLists.txt @@ -13,6 +13,7 @@ add_executable(${_TEST_NAME}_tests test_printgcode.cpp test_printobject.cpp test_skirt_brim.cpp + test_slicing_pipeline_hook.cpp test_support_material.cpp test_trianglemesh.cpp ) diff --git a/tests/fff_print/test_slicing_pipeline_hook.cpp b/tests/fff_print/test_slicing_pipeline_hook.cpp new file mode 100644 index 0000000000..66f7813503 --- /dev/null +++ b/tests/fff_print/test_slicing_pipeline_hook.cpp @@ -0,0 +1,559 @@ +#include +#include "libslic3r/PrintConfig.hpp" +using namespace Slic3r; + +TEST_CASE("slicing_pipeline_plugin option exists and defaults empty", "[slicing_pipeline]") { + DynamicPrintConfig cfg = DynamicPrintConfig::full_print_config(); + const ConfigOptionStrings* opt = cfg.option("slicing_pipeline_plugin"); + REQUIRE(opt != nullptr); + CHECK(opt->values.empty()); + const ConfigOptionDef* def = cfg.def()->get("slicing_pipeline_plugin"); + REQUIRE(def != nullptr); + CHECK(def->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 +#include +using namespace Slic3r::Test; + +TEST_CASE("SlicingPipeline hook fires once per step per object in order", "[slicing_pipeline]") { + struct Call { const Slic3r::PrintObject* obj; Slic3r::SlicingPipelineStepPlugin step; }; + std::vector 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 +#include + +// Exported G-code carries a few nondeterministic comment lines unrelated to toolpaths: a +// wall-clock timestamp ("; generated by ..."), ObjectID-derived ids (from a process-global +// counter never reset between runs), and a config-dump line naming the selected plugin (an +// active run records it, the absent baseline does not). Strip exactly those lines so a raw +// byte-compare isolates the real motion/extrusion output; every other byte is still compared. +static std::string strip_nondeterministic_gcode_lines(const std::string& gcode) { + std::string out; out.reserve(gcode.size()); + std::istringstream in(gcode); + std::string line; + while (std::getline(in, line)) { + if (line.compare(0, 15, "; generated by ") == 0) continue; // wall-clock timestamp + if (line.compare(0, 18, "; model label id: ") == 0) continue; // ObjectID-derived + // "; [stop] printing object id:N copy M" / "... unique label id: N" (ObjectID-derived): + if (line.find("printing object") != std::string::npos && line.find(" id:") != std::string::npos) continue; + if (line.find("slicing_pipeline_plugin") != std::string::npos) continue; // config-dump plugin name + out += line; out += '\n'; + } + return out; +} + +TEST_CASE("Inactive hook: process output is byte-identical (no-op hook == unset)", "[slicing_pipeline]") { + // Three configurations must all normalize to the same G-code: + // (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(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 + +// 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(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(o)->layers()) + for (Slic3r::LayerRegion* r : l->regions()) { + Slic3r::Surfaces in = r->slices.surfaces; // copy current (already-normalized) geometry + r->slices.set(std::move(in)); // write back unchanged: identity transform + } + }); + init_print({TestMesh::cube_20x20x20}, print, model, config); + std::string g = Slic3r::Test::gcode(print); + Slic3r::Print::set_slicing_pipeline_hook_fn(nullptr); + return g; + }; + CHECK(strip_nondeterministic_gcode_lines(run(true)) == strip_nondeterministic_gcode_lines(run(false))); +} + +#include "libslic3r/ExtrusionEntityCollection.hpp" // count fill paths in the fill-surface cascade test + +// Total leaf ExtrusionPath count under an extrusion (sub)tree (collections recursed into). +static size_t count_leaf_paths(const Slic3r::ExtrusionEntity* ee) { + if (ee == nullptr) return 0; + if (const auto* coll = dynamic_cast(ee)) { + size_t n = 0; + for (const Slic3r::ExtrusionEntity* e : coll->entities) n += count_leaf_paths(e); + return n; + } + return 1; +} + +// Width (scaled) of the object-wide bounding box over every region's sliced contour. +static double outer_slices_width(const Slic3r::Print& print) { + coord_t min_x = 0, max_x = 0; bool seeded = false; + for (auto* l : print.objects().front()->layers()) + for (auto* r : l->regions()) + for (const Slic3r::Surface& sf : r->slices.surfaces) + for (const Slic3r::Point& p : sf.expolygon.contour.points) { + if (!seeded) { min_x = max_x = p.x(); seeded = true; } + else { min_x = std::min(min_x, p.x()); max_x = std::max(max_x, p.x()); } + } + return (double)(max_x - min_x); +} + +// After the Slice hook mutates slices, raw_slices must be re-snapshotted so the mutation +// becomes the untyped baseline. make_perimeters() restores untyped slices from raw_slices on +// any perimeter re-run; invoking that restore directly must reproduce the mutation, not revert +// to the pre-hook geometry (which is what happened before this fix). +TEST_CASE("raw_slices captures post-hook geometry so a perimeter re-run keeps the mutation", "[slicing_pipeline]") { + using Catch::Matchers::WithinRel; + Slic3r::Print::set_slicing_pipeline_hook_fn( + [](Slic3r::Print&, const Slic3r::PrintObject* o, Slic3r::SlicingPipelineStepPlugin s){ + if (s != Slic3r::SlicingPipelineStepPlugin::posSlice || !o) return; + for (Slic3r::Layer* l : const_cast(o)->layers()) + for (Slic3r::LayerRegion* r : l->regions()) { + Slic3r::Surfaces in = r->slices.surfaces; + 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(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(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 // deterministic RNG for the fuzzy-skin analogue below + +// Fuzzy skin applied to the slice contours at the Slice boundary, matching what the Fuzzy +// Slices sample (sandboxes/orca_fuzzy_slices_plugin_any.py) does: resample every ring at +// 3/4..5/4 * point_distance and displace each new vertex +/-thickness along the segment +// normal (libslic3r's fuzzy_polyline with uniform noise). Unlike the count-preserving rotate +// test above, this is a count-CHANGING rebuild -- each ring is replaced by one with a +// different vertex count. Three end-to-end invariants after process() confirm the cascade: +// (1) the jitter is zero-mean, so total fill area is preserved within a few %, +// (2) the fuzz genuinely cascaded into make_perimeters' fill_surfaces -- their contours +// carry far more vertices than the crisp baseline square's, +// (3) displacement is bounded: the sliced footprint grows by at most ~2*thickness. +TEST_CASE("Fuzzing slice contours at the Slice boundary cascades with bounded displacement", "[slicing_pipeline]") { + using Catch::Matchers::WithinRel; + static constexpr double kThickness = 0.3, kPointDist = 0.8; // mm; the built-in fuzzy-skin defaults + struct Measure { double area; size_t verts; double width; }; + auto measure = [](bool fuzz) -> Measure { + Slic3r::Print print; Slic3r::Model model; + auto config = Slic3r::DynamicPrintConfig::full_print_config(); + config.set_key_value("slicing_pipeline_plugin", new Slic3r::ConfigOptionStrings({"probe"})); // active in both runs + if (fuzz) Slic3r::Print::set_slicing_pipeline_hook_fn( + [](Slic3r::Print&, const Slic3r::PrintObject* o, Slic3r::SlicingPipelineStepPlugin s){ + if (s != Slic3r::SlicingPipelineStepPlugin::posSlice || !o) return; + const double thickness = scale_(kThickness); + const double min_dist = scale_(kPointDist) * 0.75; + const double rand_range = scale_(kPointDist) * 0.5; + std::mt19937 rng(0x5EED); // fixed seed: the run is deterministic + std::uniform_real_distribution uni(0.0, 1.0); + auto fuzz_ring = [&](Slic3r::Points& pts) { + if (pts.size() < 3) return; + Slic3r::Points out; + double dist_left_over = uni(rng) * (min_dist / 2.0); + const Slic3r::Point* p0 = &pts.back(); + for (const Slic3r::Point& p1 : pts) { + const Slic3r::Vec2d v = (p1 - *p0).cast(); + const double seg = v.norm(); + if (seg > 0.0) { + double d = dist_left_over; + for (; d < seg; d += min_dist + uni(rng) * rand_range) { + const double r = (uni(rng) * 2.0 - 1.0) * thickness; + const Slic3r::Vec2d pa = p0->cast() + v * (d / seg); + const Slic3r::Vec2d n = Slic3r::Vec2d(-v.y(), v.x()) / seg; + out.emplace_back((coord_t) std::llround(pa.x() + n.x() * r), + (coord_t) std::llround(pa.y() + n.y() * r)); + } + dist_left_over = d - seg; + } + p0 = &p1; + } + if (out.size() >= 3) pts = std::move(out); // else: ring too short, keep it crisp + }; + for (Slic3r::Layer* l : const_cast(o)->layers()) + for (Slic3r::LayerRegion* r : l->regions()) { + Slic3r::Surfaces in = r->slices.surfaces; + for (auto& sf : in) { + fuzz_ring(sf.expolygon.contour.points); + for (auto& h : sf.expolygon.holes) fuzz_ring(h.points); + } + r->slices.set(std::move(in)); + } + }); + else Slic3r::Print::set_slicing_pipeline_hook_fn(nullptr); + init_print({TestMesh::cube_20x20x20}, print, model, config); + print.process(); + Measure m { 0.0, 0, outer_slices_width(print) }; + for (auto* l : print.objects().front()->layers()) + for (auto* r : l->regions()) + for (auto& sf : r->fill_surfaces.surfaces) { + m.area += sf.expolygon.area(); + m.verts += sf.expolygon.contour.points.size(); + } + Slic3r::Print::set_slicing_pipeline_hook_fn(nullptr); + return m; + }; + const Measure base = measure(false); + const Measure fz = measure(true); + // (1) Zero-mean jitter: the fills add up to (nearly) the same area. + CHECK_THAT(fz.area, WithinRel(base.area, 0.05)); + // (2) The resample cascaded downstream: fill boundaries derived from the fuzzed slices + // carry far more vertices than the baseline square's. + CHECK(fz.verts > 4 * base.verts); + // (3) Displacement is bounded by the +/-thickness jitter: the footprint widened, but by + // no more than ~2*thickness (one thickness per side, plus rounding slack). + CHECK(fz.width > base.width); + CHECK(fz.width < base.width + 2.5 * scale_(kThickness)); +} diff --git a/tests/libslic3r/test_config.cpp b/tests/libslic3r/test_config.cpp index 016cdee437..071f4e359a 100644 --- a/tests/libslic3r/test_config.cpp +++ b/tests/libslic3r/test_config.cpp @@ -410,14 +410,14 @@ TEST_CASE("save_to_json round-trips plugin capability references as strings", "[ namespace fs = boost::filesystem; const fs::path tmp = fs::temp_directory_path() / fs::unique_path("orca_plugins_%%%%-%%%%.json"); const std::vector refs = { - "local_plugin;;post_process", - "cloud_plugin;550e8400-e29b-41d4-a716-446655440000;post_process" + "local_plugin;;inset", + "cloud_plugin;550e8400-e29b-41d4-a716-446655440000;inset" }; std::unique_ptr config_ptr( - DynamicPrintConfig::new_from_defaults_keys({"post_process_plugin"})); + DynamicPrintConfig::new_from_defaults_keys({"slicing_pipeline_plugin"})); DynamicPrintConfig config = std::move(*config_ptr); - config.option("post_process_plugin", true)->values = refs; + config.option("slicing_pipeline_plugin", true)->values = refs; config.save_to_json(tmp.string(), "test_preset", "User", "1.0.0.0"); nlohmann::json j; @@ -425,7 +425,7 @@ TEST_CASE("save_to_json round-trips plugin capability references as strings", "[ boost::nowide::ifstream ifs(tmp.string()); ifs >> j; } - REQUIRE(j["post_process_plugin"] == nlohmann::json(refs)); + REQUIRE(j["slicing_pipeline_plugin"] == nlohmann::json(refs)); CHECK_FALSE(j.contains("plugins")); DynamicPrintConfig reloaded = DynamicPrintConfig::full_print_config(); @@ -434,7 +434,7 @@ TEST_CASE("save_to_json round-trips plugin capability references as strings", "[ std::string reason; REQUIRE(reloaded.load_from_json(tmp.string(), substitutions, true, key_values, reason) == 0); CHECK(reason.empty()); - CHECK(reloaded.option("post_process_plugin")->values == refs); + CHECK(reloaded.option("slicing_pipeline_plugin")->values == refs); fs::remove(tmp); } @@ -446,17 +446,17 @@ TEST_CASE("plugin capability references survive string-map serialization", "[Con }; DynamicPrintConfig original = DynamicPrintConfig::full_print_config(); - original.option("post_process_plugin", true)->values = refs; + original.option("slicing_pipeline_plugin", true)->values = refs; std::map serialized{ - {"post_process_plugin", original.option("post_process_plugin")->serialize()} + {"slicing_pipeline_plugin", original.option("slicing_pipeline_plugin")->serialize()} }; - CHECK(serialized["post_process_plugin"].find("\"master_plugin;;header-stamp\"") != std::string::npos); + CHECK(serialized["slicing_pipeline_plugin"].find("\"master_plugin;;header-stamp\"") != std::string::npos); DynamicPrintConfig reloaded = DynamicPrintConfig::full_print_config(); reloaded.load_string_map(serialized, ForwardCompatibilitySubstitutionRule::Disable); - CHECK(reloaded.option("post_process_plugin")->values == refs); + CHECK(reloaded.option("slicing_pipeline_plugin")->values == refs); } TEST_CASE("parse_capability_ref parses local and cloud references", "[Config][plugin]") { @@ -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 config_ptr(DynamicPrintConfig::new_from_defaults_keys( + {"slicing_pipeline_plugin", "printer_agent"})); + DynamicPrintConfig config = std::move(*config_ptr); + config.option("slicing_pipeline_plugin", true)->values = {"sp"}; + config.option("printer_agent", true)->value = "agent"; + + config.update_plugin_manifest(); + const std::vector manifest = config.option("plugins")->values; + + using Catch::Matchers::VectorContains; + REQUIRE_THAT(manifest, VectorContains(std::string("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 config_ptr(DynamicPrintConfig::new_from_defaults_keys( + {"slicing_pipeline_plugin", "printer_agent"})); + DynamicPrintConfig config = std::move(*config_ptr); + config.option("slicing_pipeline_plugin", true)->values = {"x", "x"}; // duplicate + // printer_agent stays at its default empty value -> contributes nothing to the manifest. + + config.update_plugin_manifest(); + const std::vector manifest = config.option("plugins")->values; + + CHECK(manifest == std::vector{"x;;slicing-pipeline"}); +} diff --git a/tests/slic3rutils/CMakeLists.txt b/tests/slic3rutils/CMakeLists.txt index f0c4570d98..685287be4e 100644 --- a/tests/slic3rutils/CMakeLists.txt +++ b/tests/slic3rutils/CMakeLists.txt @@ -6,6 +6,7 @@ add_executable(${_TEST_NAME}_tests test_plugin_capability_identifier.cpp test_plugin_config.cpp test_plugin_install.cpp + test_slicing_pipeline_bindings.cpp test_plugin_sort.cpp ) diff --git a/tests/slic3rutils/python_test_support.hpp b/tests/slic3rutils/python_test_support.hpp new file mode 100644 index 0000000000..1920b2ce2a --- /dev/null +++ b/tests/slic3rutils/python_test_support.hpp @@ -0,0 +1,38 @@ +#pragma once + +// Shared embedded-interpreter bootstrap for slic3rutils tests that need a live Python +// interpreter (test_plugin_host_api.cpp, test_slicing_pipeline_bindings.cpp, ...). + +#include +#include + +#include + +namespace { + +void ensure_python_initialized() +{ + // Deliberately a bare scoped_interpreter rather than Slic3r::PythonInterpreter: + // `orca` is a PYBIND11_EMBEDDED_MODULE compiled into this test binary, so importing + // it needs no bundled stdlib/sys.path, and the deterministic assertions are + // independent of the host's Python. PythonInterpreter::initialize() expects the + // bundled Python home laid out next to the app bundle (lib/python3.12/encodings), + // which is not deployed beside the test binary, so using it here would fail to find + // a home on macOS/Linux. The optional numpy-backed assertions are guarded at runtime. + if (!Py_IsInitialized()) { + static pybind11::scoped_interpreter interpreter; + (void) interpreter; + } +} + +pybind11::module_ import_orca_module() +{ + ensure_python_initialized(); + + // Force PythonPluginBridge.cpp into the test binary so the embedded + // PYBIND11_EMBEDDED_MODULE(orca, ...) registration is available. + (void) Slic3r::PythonPluginBridge::instance(); + return pybind11::module_::import("orca"); +} + +} // namespace diff --git a/tests/slic3rutils/test_plugin_capability_identifier.cpp b/tests/slic3rutils/test_plugin_capability_identifier.cpp index fe9c94d3e7..575ac4c8a5 100644 --- a/tests/slic3rutils/test_plugin_capability_identifier.cpp +++ b/tests/slic3rutils/test_plugin_capability_identifier.cpp @@ -8,9 +8,9 @@ using Slic3r::PluginCapabilityIdentifier; using Slic3r::PluginCapabilityType; TEST_CASE("PluginCapabilityIdentifier equality includes plugin_key", "[plugin][identifier]") { - PluginCapabilityIdentifier a{PluginCapabilityType::PostProcessing, "Cleanup", "a.py"}; - PluginCapabilityIdentifier b{PluginCapabilityType::PostProcessing, "Cleanup", "b.py"}; - PluginCapabilityIdentifier a2{PluginCapabilityType::PostProcessing, "Cleanup", "a.py"}; + PluginCapabilityIdentifier a{PluginCapabilityType::SlicingPipeline, "Cleanup", "a.py"}; + PluginCapabilityIdentifier b{PluginCapabilityType::SlicingPipeline, "Cleanup", "b.py"}; + PluginCapabilityIdentifier a2{PluginCapabilityType::SlicingPipeline, "Cleanup", "a.py"}; CHECK(a == a2); CHECK_FALSE(a == b); // same (type,name), different plugin_key -> distinct @@ -18,8 +18,8 @@ TEST_CASE("PluginCapabilityIdentifier equality includes plugin_key", "[plugin][i TEST_CASE("PluginCapabilityIdentifier is usable as a hash-map key", "[plugin][identifier]") { std::unordered_map m; - m[{PluginCapabilityType::PostProcessing, "Cleanup", "a.py"}] = 1; - m[{PluginCapabilityType::PostProcessing, "Cleanup", "b.py"}] = 2; // no collision + m[{PluginCapabilityType::SlicingPipeline, "Cleanup", "a.py"}] = 1; + m[{PluginCapabilityType::SlicingPipeline, "Cleanup", "b.py"}] = 2; // no collision CHECK(m.size() == 2); - CHECK(m.at({PluginCapabilityType::PostProcessing, "Cleanup", "a.py"}) == 1); + CHECK(m.at({PluginCapabilityType::SlicingPipeline, "Cleanup", "a.py"}) == 1); } diff --git a/tests/slic3rutils/test_plugin_host_api.cpp b/tests/slic3rutils/test_plugin_host_api.cpp index 8d6d349a3a..e31887cfba 100644 --- a/tests/slic3rutils/test_plugin_host_api.cpp +++ b/tests/slic3rutils/test_plugin_host_api.cpp @@ -5,6 +5,8 @@ #include #include +#include "python_test_support.hpp" + #include #include @@ -14,30 +16,8 @@ namespace py = pybind11; namespace { -void ensure_python_initialized() -{ - // Deliberately a bare scoped_interpreter rather than Slic3r::PythonInterpreter: - // `orca` is a PYBIND11_EMBEDDED_MODULE compiled into this test binary, so importing - // it needs no bundled stdlib/sys.path, and the deterministic assertions are - // independent of the host's Python. PythonInterpreter::initialize() expects the - // bundled Python home laid out next to the app bundle (lib/python3.12/encodings), - // which is not deployed beside the test binary, so using it here would fail to find - // a home on macOS/Linux. The optional numpy-backed assertions are guarded at runtime. - if (!Py_IsInitialized()) { - static py::scoped_interpreter interpreter; - (void) interpreter; - } -} - -py::module_ import_orca_module() -{ - ensure_python_initialized(); - - // Force PythonPluginBridge.cpp into the test binary so the embedded - // PYBIND11_EMBEDDED_MODULE(orca, ...) registration is available. - (void) Slic3r::PythonPluginBridge::instance(); - return py::module_::import("orca"); -} +// import_orca_module() lives in python_test_support.hpp (shared with +// test_slicing_pipeline_bindings.cpp). bool has_attr(const py::handle& object, const char* name) { @@ -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() == 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::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; diff --git a/tests/slic3rutils/test_plugin_install.cpp b/tests/slic3rutils/test_plugin_install.cpp index 04b6ff0d19..0991865930 100644 --- a/tests/slic3rutils/test_plugin_install.cpp +++ b/tests/slic3rutils/test_plugin_install.cpp @@ -124,3 +124,38 @@ TEST_CASE("install-state sidecar is the source of truth for a cloud plugin's ins read_install_state(plugin_dir, scanned); CHECK(scanned.installed_version == "1.2.0"); } + +TEST_CASE("install_plugin parses [tool.orcaslicer.plugin.settings] into descriptor.settings", "[PluginInstall]") +{ + ScopedDataDir data_dir_guard("plugin-settings"); + + // A PEP-723 header with a per-plugin settings sub-table. Values stay strings; the plugin + // parses what it needs (ctx.params). This is the source Twistify reads its knobs from. + const std::string contents = + "# /// script\n" + "# requires-python = \">=3.12\"\n" + "#\n" + "# [tool.orcaslicer.plugin]\n" + "# name = \"Settings Plugin\"\n" + "# type = \"slicing-pipeline\"\n" + "#\n" + "# [tool.orcaslicer.plugin.settings]\n" + "# twist_deg_per_mm = \"1.5\"\n" + "# taper_per_mm = \"-0.004\"\n" + "# ///\n" + "print('ok')\n"; + const fs::path py = write_py_file(data_dir_guard.dir / "src", "settings.py", contents); + + PluginLoader loader; // non-cloud + PluginDescriptor descriptor; + std::string error; + const bool installed = loader.install_plugin(py, descriptor, error); + + REQUIRE(installed); + CHECK(error.empty()); + REQUIRE(descriptor.settings.count("twist_deg_per_mm") == 1); + CHECK(descriptor.settings.at("twist_deg_per_mm") == "1.5"); + CHECK(descriptor.settings.at("taper_per_mm") == "-0.004"); + // Identity keys are NOT captured as settings (they belong to [tool.orcaslicer.plugin]). + CHECK(descriptor.settings.count("name") == 0); +} diff --git a/tests/slic3rutils/test_slicing_pipeline_bindings.cpp b/tests/slic3rutils/test_slicing_pipeline_bindings.cpp new file mode 100644 index 0000000000..893e00ab04 --- /dev/null +++ b/tests/slic3rutils/test_slicing_pipeline_bindings.cpp @@ -0,0 +1,682 @@ +#include +#include "slic3r/plugin/PythonPluginInterface.hpp" +using namespace Slic3r; + +TEST_CASE("SlicingPipeline capability-type string maps round-trip", "[slicing_pipeline]") { + CHECK(plugin_capability_type_to_string(PluginCapabilityType::SlicingPipeline) == "slicing-pipeline"); + CHECK(plugin_capability_type_display_name(PluginCapabilityType::SlicingPipeline) == "Slicing Pipeline"); + CHECK(plugin_capability_type_from_string("slicing-pipeline") == PluginCapabilityType::SlicingPipeline); + CHECK(plugin_capability_type_from_string("SLICING-PIPELINE") == PluginCapabilityType::SlicingPipeline); + CHECK(plugin_capability_type_from_string("nope") == PluginCapabilityType::Unknown); +} + +#include "python_test_support.hpp" +#include "slic3r/plugin/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 +#include +#include +namespace py = pybind11; + +TEST_CASE("make_readonly_rows builds a read-only (N,2) int64 view", "[slicing_pipeline]") { + ensure_python_initialized(); // helper already used by test_plugin_host_api.cpp + py::gil_scoped_acquire gil; + + // make_readonly_rows() constructs a py::array_t, which requires numpy to be + // importable in the embedded interpreter. The unit-test interpreter ships no + // site-packages (same condition test_plugin_host_api.cpp's TriangleMesh numpy + // test guards against), so skip the array-backed assertions when numpy is + // unavailable there rather than fail on an environment quirk. + bool have_numpy = false; + try { + py::module_::import("numpy"); + have_numpy = true; + } catch (const py::error_already_set&) { + have_numpy = false; + } + if (!have_numpy) { + SKIP("numpy unavailable in unit-test interpreter"); + } + + static Slic3r::Points pts = { Slic3r::Point(10, 20), Slic3r::Point(30, 40) }; + py::capsule keepalive(&pts, [](void*){}); + py::array a = Slic3r::make_readonly_rows(keepalive, pts.front().data(), (py::ssize_t)pts.size()); + CHECK(a.dtype().kind() == 'i'); + CHECK(a.itemsize() == 8); // int64 + CHECK(a.shape(0) == 2); + CHECK(a.shape(1) == 2); + CHECK_FALSE(a.writeable()); + auto r = a.unchecked(); + CHECK(r(0,0) == 10); CHECK(r(1,1) == 40); +} + +TEST_CASE("make_writable_rows builds a writable (N,2) int64 view that aliases the buffer", "[slicing_pipeline]") { + ensure_python_initialized(); + py::gil_scoped_acquire gil; + bool have_numpy = false; + try { py::module_::import("numpy"); have_numpy = true; } + catch (const py::error_already_set&) { have_numpy = false; } + if (!have_numpy) SKIP("numpy unavailable in unit-test interpreter"); + + static Slic3r::Points pts = { Slic3r::Point(10, 20), Slic3r::Point(30, 40) }; + py::capsule keepalive(&pts, [](void*){}); + py::array a = Slic3r::make_writable_rows(keepalive, pts.front().data(), (py::ssize_t)pts.size()); + CHECK(a.writeable()); + // Writing through the view mutates the C++ buffer (zero-copy alias). + a.attr("__setitem__")(py::make_tuple(0, 0), py::int_(99)); + CHECK(pts.front().x() == 99); +} + +TEST_CASE("orca.slicing module: Step enum, context, and a Python capability can execute", "[slicing_pipeline]") { + ensure_python_initialized(); + import_orca_module(); // forces PythonPluginBridge::instance() (see 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(); + 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 +#include +#include + +// psGCodePostProcess is the merged post-processing seam: no live Print (print/object are None), the +// plugin edits the file at ctx.gcode_path in place, and ctx.config_value() falls back to the config +// the export path handed in. Exercising the real bindings by calling the Python execute() directly +// (not the C++ audit trampoline) keeps this a pure binding-surface test. +TEST_CASE("orca.slicing psGCodePostProcess context: file edit in place + config fallback", "[slicing_pipeline]") { + namespace fs = boost::filesystem; + ensure_python_initialized(); + import_orca_module(); + py::gil_scoped_acquire gil; + + const fs::path gpath = fs::temp_directory_path() / fs::unique_path("orca_pp_%%%%-%%%%.gcode"); + { + boost::nowide::ofstream ofs(gpath.string()); + ofs << "; header\nG1 X0 Y0\n"; + } + + // Config the plugin reads back through ctx.config_value() (there is no live Print at this step). + Slic3r::DynamicPrintConfig config = Slic3r::DynamicPrintConfig::full_print_config(); + config.set_key_value("layer_height", new Slic3r::ConfigOptionFloat(0.2)); + + Slic3r::SlicingPipelineContext ctx; + ctx.orca_version = "test"; + ctx.step = Slic3r::SlicingPipelineStepPlugin::psGCodePostProcess; + ctx.gcode_path = gpath.string(); + ctx.host = "File"; + ctx.output_name = "final.gcode"; + ctx.full_config = &config; // print stays null + + py::object pyctx = py::cast(&ctx, py::return_value_policy::reference); + CHECK(pyctx.attr("gcode_path").cast() == gpath.string()); + CHECK(pyctx.attr("host").cast() == "File"); + CHECK(pyctx.attr("output_name").cast() == "final.gcode"); + CHECK(pyctx.attr("print").is_none()); + CHECK(pyctx.attr("object").is_none()); + CHECK(pyctx.attr("step").cast() + == Slic3r::SlicingPipelineStepPlugin::psGCodePostProcess); + CHECK_FALSE(pyctx.attr("cancelled")().cast()); // null print -> not cancelled + // config_value() resolves from full_config when print is null; unknown keys are None. + CHECK_FALSE(pyctx.attr("config_value")("layer_height").is_none()); + CHECK(pyctx.attr("config_value")("this_key_does_not_exist").is_none()); + + // A Python capability edits the file in place through ctx.gcode_path. Calling execute() directly + // in Python dispatches to the Python method (no C++ trampoline), so this needs no audit context. + py::module_ main = py::module_::import("__main__"); + main.attr("_pp_ctx") = pyctx; + py::exec(R"( +import orca +class Stamp(orca.slicing.SlicingPipelineCapabilityBase): + def get_name(self): return "stamp" + def execute(self, ctx): + assert ctx.step == orca.slicing.Step.psGCodePostProcess + assert ctx.print is None and ctx.object is None + with open(ctx.gcode_path, "a") as f: + f.write("; stamped by " + ctx.host + "\n") + return orca.ExecutionResult.success("ok") +_pp_result = Stamp().execute(_pp_ctx) + )"); + CHECK(main.attr("_pp_result").attr("message").cast() == std::string("ok")); + + std::string contents; + { + boost::nowide::ifstream ifs(gpath.string()); + std::stringstream ss; ss << ifs.rdbuf(); contents = ss.str(); + } + CHECK(contents.find("; stamped by File") != std::string::npos); + fs::remove(gpath); +} + +// --------------------------------------------------------------------------- +// Toolpath helpers for the raw-graph tests. +// +// 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::stTop); + CHECK(ST.attr("stInternalSolid").cast() == Slic3r::stInternalSolid); + CHECK(ST.attr("stPerimeter").cast() == Slic3r::stPerimeter); + + // Raw Surface: scalar reads + WRITABLE surface_type (replaces SurfaceView.set_type). + Slic3r::Surface surf(Slic3r::stInternalSolid); + surf.thickness = 0.4; + surf.bridge_angle = -1.0; + surf.extra_perimeters = 2; + py::object sv = py::cast(&surf, py::return_value_policy::reference); + CHECK(sv.attr("surface_type").cast() == Slic3r::stInternalSolid); + CHECK_THAT(sv.attr("thickness").cast(), WithinRel(0.4, 1e-9)); + CHECK_THAT(sv.attr("bridge_angle").cast(), WithinAbs(-1.0, 1e-12)); + CHECK(sv.attr("extra_perimeters").cast() == 2); + sv.attr("surface_type") = host.attr("SurfaceType").attr("stTop"); + CHECK(surf.surface_type == Slic3r::stTop); // C++ side reflects the assignment + + // ExPolygon navigation without numpy: contour is a Polygon, holes an empty list. + py::object exv = sv.attr("expolygon"); + CHECK(py::hasattr(exv, "contour")); + CHECK(exv.attr("holes").cast().size() == 0); + CHECK(exv.attr("contour").attr("size")().cast() == 0); +} + +TEST_CASE("orca.host Surface/SurfaceCollection: construct, writable members, set()", "[slicing_pipeline]") { + using Catch::Matchers::WithinRel; + ensure_python_initialized(); + import_orca_module(); + py::gil_scoped_acquire gil; + py::object host = py::module_::import("orca").attr("host"); + py::object ST = host.attr("SurfaceType"); + const coord_t s = (coord_t) scale_(10.0); + + // Build an ExPolygon (Point idiom) and a Surface from it. + py::object P = host.attr("Polygon")(); + P.attr("append")(host.attr("Point")(0, 0)); + P.attr("append")(host.attr("Point")(s, 0)); + P.attr("append")(host.attr("Point")(s, s)); + P.attr("append")(host.attr("Point")(0, s)); + py::object ex = host.attr("ExPolygon")(P); + py::object surf = host.attr("Surface")(ST.attr("stTop"), ex); + CHECK(surf.attr("surface_type").cast() == Slic3r::stTop); + CHECK(surf.attr("is_top")().cast()); + CHECK_THAT(surf.attr("area")().cast(), WithinRel((double) s * (double) s, 1e-9)); + surf.attr("thickness") = py::float_(0.3); + CHECK_THAT(surf.attr("thickness").cast(), WithinRel(0.3, 1e-9)); + + // SurfaceCollection.set(expolys, type): 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()); + cv.attr("clear")(); + CHECK(coll.surfaces.empty()); +} + +TEST_CASE("orca.host Point: construct, read/write coords, arithmetic", "[slicing_pipeline]") { + ensure_python_initialized(); + import_orca_module(); + py::gil_scoped_acquire gil; + py::object host = py::module_::import("orca").attr("host"); + REQUIRE(py::hasattr(host, "Point")); + py::object p = host.attr("Point")(3, 4); + CHECK(p.attr("x").cast() == 3); + CHECK(p.attr("y").cast() == 4); + p.attr("x") = py::int_(7); + CHECK(p.attr("x").cast() == 7); + py::object q = host.attr("Point")(1, 2); + py::object sum = p.attr("__add__")(q); + CHECK(sum.attr("x").cast() == 8); + CHECK(sum.attr("y").cast() == 6); + + // __mul__ must scale as a double, not truncate to int64 before multiplying. + py::object h = host.attr("Point")(10, 20).attr("__mul__")(py::float_(0.5)); + CHECK(h.attr("x").cast() == 5); + CHECK(h.attr("y").cast() == 10); +} + +TEST_CASE("orca.host Polygon: writable as_array aliases buffer; Point refs; set_points; offset", "[slicing_pipeline]") { + using Catch::Matchers::WithinRel; + ensure_python_initialized(); + import_orca_module(); + py::gil_scoped_acquire gil; + py::object host = py::module_::import("orca").attr("host"); + + const coord_t s = (coord_t) scale_(10.0); + Slic3r::Polygon poly; + poly.points = { Slic3r::Point(0, 0), Slic3r::Point(s, 0), Slic3r::Point(s, s), Slic3r::Point(0, s) }; + py::object pv = py::cast(&poly, py::return_value_policy::reference); + + // Non-array surface works without numpy. + CHECK(pv.attr("size")().cast() == 4); + CHECK(pv.attr("is_counter_clockwise")().cast()); + CHECK_THAT(pv.attr("area")().cast(), WithinRel((double) s * (double) s, 1e-9)); + // Point-object idiom: editing a returned Point ref mutates the buffer in place. + py::list pts = pv.attr("points").cast(); + REQUIRE(pts.size() == 4); + pts[0].attr("x") = py::int_(5); + CHECK(poly.points[0].x() == 5); + poly.points[0].x() = 0; // restore + + // offset() returns new geometry (ClipperUtils bound as a method). + py::list shrunk = pv.attr("offset")(py::int_(-(coord_t)scale_(1.0))).cast(); + CHECK(shrunk.size() >= 1); + + bool have_numpy = false; + try { py::module_::import("numpy"); have_numpy = true; } + catch (const py::error_already_set&) { have_numpy = false; } + if (!have_numpy) SKIP("numpy unavailable: array-backed assertions skipped"); + + py::module_ np = py::module_::import("numpy"); + py::array a = pv.attr("as_array")().cast(); + CHECK(a.dtype().kind() == 'i'); + CHECK(a.itemsize() == 8); + CHECK(a.shape(0) == 4); + CHECK(a.shape(1) == 2); + CHECK(a.writeable()); // writable now + a.attr("__setitem__")(py::make_tuple(0, 0), py::int_(123)); + CHECK(poly.points[0].x() == 123); // in-place bulk edit + // set_points replaces contents (count-changing). + py::object i64 = np.attr("int64"); + py::list rows; + rows.append(py::make_tuple(0, 0)); rows.append(py::make_tuple(s, 0)); rows.append(py::make_tuple(s, s)); + pv.attr("set_points")(np.attr("array")(rows, py::arg("dtype") = i64)); + CHECK(poly.points.size() == 3); +} + +TEST_CASE("orca.host ExPolygon: construct, writable contour/holes, transforms, boolean ops", "[slicing_pipeline]") { + using Catch::Matchers::WithinRel; + ensure_python_initialized(); + import_orca_module(); + py::gil_scoped_acquire gil; + py::object host = py::module_::import("orca").attr("host"); + const coord_t s = (coord_t) scale_(10.0); + + // Construct from Polygon objects (Point idiom, no numpy). + py::object P = host.attr("Polygon")(); + P.attr("append")(host.attr("Point")(0, 0)); + P.attr("append")(host.attr("Point")(s, 0)); + P.attr("append")(host.attr("Point")(s, s)); + P.attr("append")(host.attr("Point")(0, s)); + py::object ex = host.attr("ExPolygon")(P); + CHECK_THAT(ex.attr("area")().cast(), WithinRel((double) s * (double) s, 1e-9)); + CHECK(ex.attr("num_contours")().cast() == 1); + CHECK(ex.attr("contour").attr("size")().cast() == 4); + + // In-place transform mutates the geometry. + ex.attr("translate")(py::float_(1000.0), py::float_(0.0)); + // Boolean op returns new geometry: A minus a smaller inset of A is a non-empty ring set. + py::list inset = ex.attr("offset")(py::int_(-(coord_t)scale_(1.0))).cast(); + REQUIRE(inset.size() >= 1); + py::list ring = ex.attr("diff_ex")(inset[0]).cast(); + CHECK(ring.size() >= 1); +} + +namespace { +// 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(); + REQUIRE(kids.size() == 1); + py::list inner_kids = kids[0].attr("entities").cast(); + REQUIRE(inner_kids.size() == 2); + CHECK(py::hasattr(inner_kids[0], "paths")); // ExtrusionLoop binding + CHECK(py::hasattr(inner_kids[1], "width")); // ExtrusionPath binding + + // flatten_paths: loop decomposed, scalars readable. + py::list ps = coll.attr("flatten_paths")().cast(); + REQUIRE(ps.size() == 2); + CHECK(ps[0].attr("role").cast() == "Outer wall"); + CHECK_THAT(ps[0].attr("width").cast(), WithinRel(0.45, 1e-6)); + CHECK_THAT(ps[0].attr("mm3_per_mm").cast(), WithinRel(0.05, 1e-9)); + CHECK(ps[1].attr("role").cast() == "Sparse infill"); +} + +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(); + REQUIRE(ps.size() == 2); + py::array pts = ps[1].attr("points")().cast(); // pathB: (1,1,0),(2,1,0),(2,2,0) + CHECK(pts.dtype().kind() == 'i'); + CHECK(pts.itemsize() == 8); + CHECK(pts.shape(0) == 3); + CHECK(pts.shape(1) == 3); + CHECK_FALSE(pts.writeable()); + auto r = pts.cast>().unchecked<2>(); + CHECK(r(0, 0) == 1); CHECK(r(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(®ion), + py::return_value_policy::reference); + CHECK(lr.attr("slices").attr("size")().cast() == 1); + CHECK(lr.attr("slices").attr("surfaces").cast().size() == 1); + CHECK(lr.attr("perimeters").attr("flatten_paths")().cast().size() == 2); + CHECK(lr.attr("fills").attr("size")().cast() == 0); + CHECK(lr.attr("layer")().is_none()); // hand-built region has no owning layer + + // Raw Layer scalars + empty traversals on a hand-built layer. + TestLayer layer; + py::object ly = py::cast(static_cast(&layer), + py::return_value_policy::reference); + CHECK_THAT(ly.attr("print_z").cast(), WithinRel(0.45, 1e-9)); + CHECK_THAT(ly.attr("slice_z").cast(), WithinRel(0.35, 1e-9)); + CHECK_THAT(ly.attr("height").cast(), WithinRel(0.2, 1e-9)); + CHECK(ly.attr("regions")().cast().size() == 0); + CHECK(ly.attr("lslices")().cast().size() == 0); + CHECK(ly.attr("upper_layer").is_none()); + CHECK(ly.attr("lower_layer").is_none()); +} + +TEST_CASE("orca.host: 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(®ion), 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> 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()); + + TestLayerRegion region; + py::object lr = py::cast(static_cast(®ion), py::return_value_policy::reference); + py::list expolys; expolys.append(ex); + lr.attr("slices").attr("set")(expolys, ST.attr("stInternalSolid")); + REQUIRE(region.slices.surfaces.size() == 1); + const Slic3r::Surface& out = region.slices.surfaces.front(); + CHECK(out.surface_type == Slic3r::stInternalSolid); + CHECK_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()[0] + .attr("expolygon").attr("contour").attr("as_array")().cast(); + 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(&layer), py::return_value_policy::reference); + // (A hand-built layer has no regions, so make_slices() yields empty lslices — still null-safe.) + ly.attr("make_slices")(); + CHECK(layer.lslices_bboxes.size() == layer.lslices.size()); +} + +TEST_CASE("orca.host ExPolygon in-place transforms + SurfaceCollection.append (sample ops)", "[slicing_pipeline]") { + using Catch::Matchers::WithinRel; + ensure_python_initialized(); + import_orca_module(); + py::gil_scoped_acquire gil; + py::object host = py::module_::import("orca").attr("host"); + const coord_t s = (coord_t) scale_(10.0); + auto make_square = [&]() { + py::object P = host.attr("Polygon")(); + P.attr("append")(host.attr("Point")(0, 0)); + P.attr("append")(host.attr("Point")(s, 0)); + P.attr("append")(host.attr("Point")(s, s)); + P.attr("append")(host.attr("Point")(0, s)); + return host.attr("ExPolygon")(P); + }; + const double area0 = (double) s * (double) s; + + // rotate about the square's center preserves area + py::object ex = make_square(); + py::object center = host.attr("Point")(s / 2, s / 2); + ex.attr("rotate")(py::float_(1.5707963267948966), center); // pi/2 + CHECK_THAT(ex.attr("area")().cast(), WithinRel(area0, 1e-6)); + + // uniform scale by 2 quadruples area (scale is about the origin) + py::object ex2 = make_square(); + ex2.attr("scale")(py::float_(2.0)); + CHECK_THAT(ex2.attr("area")().cast(), WithinRel(4.0 * area0, 1e-6)); + + // translate preserves area + py::object ex3 = make_square(); + ex3.attr("translate")(py::float_(1000.0), py::float_(-500.0)); + CHECK_THAT(ex3.attr("area")().cast(), WithinRel(area0, 1e-6)); + + // SurfaceCollection.append accumulates surfaces of a second type (the sample write-back path) + Slic3r::SurfaceCollection coll; + py::object cv = py::cast(&coll, py::return_value_policy::reference); + py::list g1; g1.append(make_square()); + cv.attr("set")(g1, host.attr("SurfaceType").attr("stInternalSolid")); + py::list g2; g2.append(make_square()); + cv.attr("append")(g2, host.attr("SurfaceType").attr("stTop")); + REQUIRE(coll.surfaces.size() == 2); + CHECK(coll.surfaces[0].surface_type == Slic3r::stInternalSolid); + CHECK(coll.surfaces[1].surface_type == Slic3r::stTop); +} + +TEST_CASE("orca.host: in-place edit of surface.expolygon through a live collection persists to C++", "[slicing_pipeline]") { + using Catch::Matchers::WithinRel; + ensure_python_initialized(); + import_orca_module(); + py::gil_scoped_acquire gil; + + const coord_t s = (coord_t) scale_(10.0); + // Live LayerRegion holding one surface (a 10mm square at the origin). + TestLayerRegion region; + Slic3r::ExPolygon sq; + sq.contour.points = { Slic3r::Point(0, 0), Slic3r::Point(s, 0), + Slic3r::Point(s, s), Slic3r::Point(0, s) }; + region.slices.surfaces.emplace_back(Slic3r::Surface(Slic3r::stInternal, sq)); + py::object lr = py::cast(static_cast(®ion), + py::return_value_policy::reference); + + // Twistify's path: get the Surface through the live collection, mutate its expolygon in place. + py::object surf = lr.attr("slices").attr("surfaces").cast()[0]; + surf.attr("expolygon").attr("translate")(py::float_(1000.0), py::float_(0.0)); + + // The C++-side surface geometry reflects the Python in-place edit (proves the live ref). + const Slic3r::Surface& out = region.slices.surfaces.front(); + CHECK(out.expolygon.contour.points[0].x() == 1000); // was 0 + CHECK(out.expolygon.contour.points[0].y() == 0); + CHECK_THAT(out.expolygon.area(), WithinRel((double) s * (double) s, 1e-9)); // translate preserves area +}