diff --git a/sandboxes/orca_inset_plugin_any.py b/sandboxes/orca_inset_plugin_any.py index 42bebec684..784e5d86f7 100644 --- a/sandboxes/orca_inset_plugin_any.py +++ b/sandboxes/orca_inset_plugin_any.py @@ -1,81 +1,35 @@ # /// script # requires-python = ">=3.12" -# dependencies = ["numpy"] # # [tool.orcaslicer.plugin] # name = "Inset Every Slice" # description = "Insets every layer's slices by 1mm at the Slice boundary (demo)." # author = "OrcaSlicer" -# version = "0.01" +# version = "0.02" # type = "slicing-pipeline" # /// """Inset Every Slice -- a small, WORKING SlicingPipeline sample plugin. At Step.Slice, for every layer/region of the sliced object, this shrinks each -sliced surface's outer contour by INSET_MM and writes the result back with -LayerRegion.set_slices(). set_slices() at Step.Slice is the fully-supported -mutation-cascade entry point (see docs/plugins/slicing_pipeline_plugin.md next -to this file): the split slice loop runs make_perimeters() right after the -Slice hook, so the change cascades into perimeters, infill and the final -G-code -- the toolpath preview visibly shrinks. +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.Slice 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. -This is a *teaching* sample, not a production-grade offset: - - The inset is a per-axis contraction toward the contour's bounding-box - center: each vertex coordinate is pulled toward the center by up to - INSET_MM, independently on X and Y, and never crosses the center. That is - an exact inward offset for a convex, axis-aligned contour (e.g. the square - cross-section of a plain cube) but it is NOT a general polygon offset -- it - will distort a rotated or non-rectangular contour. A real plugin should - reach for a proper offset library (e.g. Shapely's buffer(), or Clipper) - instead. - - Holes are passed through unchanged. A correct hole inset needs an - *outward* offset plus re-validating containment against the shrunk outer - contour, which is more than a short demo should attempt. - - Degenerate contours (fewer than 3 points, or a shape too small for a 1mm - inset without inverting) are left unmodified rather than mutated into - garbage. +Unlike the old axis-aligned demo, 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. -numpy is declared as a dependency: the geometry accessors hand back zero-copy -int64 ndarrays, and set_slices() requires genuine ndarrays back (not plain lists), -so building the modified contour needs numpy. +No numpy required: the whole edit is expressed with the host geometry classes. """ -import numpy as np import orca INSET_MM = 1.0 -def _pull(value, center, amount): - """Move `value` toward `center` by up to `amount`, never crossing it.""" - if value > center: - return max(center, value - amount) - if value < center: - return min(center, value + amount) - return center - - -def _inset_contour(contour, inset_scaled): - """Axis-aligned inward contraction of an (N,2) int64 contour. - - Returns a new (N,2) int64 array, or None if the contour is degenerate - (fewer than 3 points) or too small for `inset_scaled` without inverting. - """ - if contour.shape[0] < 3: - return None - xs, ys = contour[:, 0], contour[:, 1] - min_x, max_x = int(xs.min()), int(xs.max()) - min_y, max_y = int(ys.min()), int(ys.max()) - if (max_x - min_x) <= 2 * inset_scaled or (max_y - min_y) <= 2 * inset_scaled: - return None # shape too small on at least one axis: inset would invert it - cx, cy = (min_x + max_x) // 2, (min_y + max_y) // 2 - - out = contour.copy() - for i in range(contour.shape[0]): - out[i, 0] = _pull(int(contour[i, 0]), cx, inset_scaled) - out[i, 1] = _pull(int(contour[i, 1]), cy, inset_scaled) - return out - - class InsetEverySlice(orca.slicing.SlicingPipelineCapabilityBase): def get_name(self): return "Inset Every Slice" @@ -84,32 +38,41 @@ class InsetEverySlice(orca.slicing.SlicingPipelineCapabilityBase): if ctx.step != orca.slicing.Step.Slice or ctx.object is None: return orca.ExecutionResult.success() - # Millimeters -> scaled integer units via the *live* scale. SCALING_FACTOR - # is not a fixed constant (large beds use a coarser scale), so this must be - # read at call time -- never hardcode 1e6/1e-6. + # 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 # an empty region has nothing to inset + continue - new_surfaces = [] + # 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: - expoly = surface.expolygon - contour = expoly.contour.points() - inset = _inset_contour(contour, inset_scaled) - if inset is not None: - contour = inset - # Holes are passed through unchanged -- see module docstring. - new_surfaces.append([contour, [h.points() for h in expoly.holes]]) + shrunk = surface.expolygon.offset(-inset_scaled) # [ExPolygon], may be empty + if shrunk: + by_type.setdefault(surface.surface_type, []).extend(shrunk) - region.set_slices(new_surfaces) + 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)") diff --git a/sandboxes/orca_twistify_plugin_example_any.py b/sandboxes/orca_twistify_plugin_example_any.py index e58666db6f..6dadf6febe 100644 --- a/sandboxes/orca_twistify_plugin_example_any.py +++ b/sandboxes/orca_twistify_plugin_example_any.py @@ -1,12 +1,11 @@ # /// script # requires-python = ">=3.12" -# dependencies = ["numpy"] # # [tool.orcaslicer.plugin] # name = "Twistify" # description = "Twists, tapers, and wobbles every layer's slice polygons as a function of Z (demo)." # author = "OrcaSlicer" -# version = "0.01" +# version = "0.02" # type = "slicing-pipeline" # # [tool.orcaslicer.plugin.settings] @@ -18,68 +17,40 @@ # /// """Twistify -- twist/taper/wobble any model at slice time. -At Step.Slice (the one fully-supported mutation seam -- see -docs/plugins/slicing_pipeline_plugin.md), every layer's sliced surfaces are -rotated, uniformly scaled, and optionally swayed about the object's center as a -function of Z, then written back with LayerRegion.set_slices(). The -dedicated slice loop runs make_perimeters() right after this hook, so the -transform cascades into perimeters, infill, and the final G-code -- the toolpath -preview visibly corkscrews, and unlike G-code post-processing hacks the printed -part keeps correct multi-wall perimeters, infill, and flow. +At Step.Slice, 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. -Parameters come from ctx.params -- the [tool.orcaslicer.plugin.settings] table in -the PEP-723 header above. Edit them there (and re-slice) to change the effect; no -code edit or plugin reload is needed. Recipes: twisted vase -(twist 1.0), tapered spire (twist 0.3, taper -0.006), wobbling tower -(twist 0, wobble_ampl 0.8). - -The transform uses three of the gap-closing APIs so the plugin stays small and -correct: - * ctx.object.bounding_box() gives the twist axis (each object twists about its - own center) -- no footprint reconstruction. - * set_slices(refresh_lslices=True) re-derives the layer's merged islands, so - overhang/bridge/skirt/support stay coherent -- no manual set_lslices(). - * a per-entry SurfaceType (third set_slices element) preserves each surface's - type -- no replace-then-reassign-surface_type two-step. -Because the Slice hook re-snapshots raw_slices afterward, the twist also survives -a later perimeter-only re-slice (e.g. changing wall_loops) instead of reverting. - -numpy is REQUIRED at slice time (declared above): the host's geometry accessors -return numpy arrays. The pure-Python fallback in _transform_ring exists only so this -module still imports on numpy-less interpreters (the unit-test harness); it is -unreachable in production. Outputs are built by .copy()-ing the host's zero-copy -read arrays (dtype/shape inherited -- int64 on every platform, immune to Windows' -numpy int32 default), never constructed from scratch. - -Physical-print caveats: keep the twist modest (horizontal shift per layer at the -part's outer radius should stay under ~1.4x layer height) or the real print grows -unsupported overhangs -- the preview looks great regardless. The first object -layer is untouched (z_rel = 0), so bed adhesion is unaffected. Twists EVERY -object on the plate (each about its own center). +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 -try: # required in production; guard keeps module importable in the test harness - import numpy as _np -except ImportError: - _np = None - -# Fallback defaults, overridden per-slice by ctx.params (the settings table in the header). _DEFAULTS = { - "twist_deg_per_mm": 1.0, # signed twist rate; 1 deg/mm corkscrews a 100mm cube by 100 deg - "taper_per_mm": 0.0, # relative XY scale change per mm of Z (-0.004 = shrink 0.4%/mm) - "wobble_ampl_mm": 0.0, # X sway amplitude in mm (0 disables) - "wobble_period_mm": 20.0, # full sway period in mm of Z - "min_scale": 0.05, # taper clamp: polygons shrink but can never collapse to a point + "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): - """Resolve parameters from ctx.params (string values), falling back to _DEFAULTS.""" try: - src = dict(ctx.params) # ctx.params is a read-only dict of str -> str + src = dict(ctx.params) except (AttributeError, TypeError): src = {} out = {} @@ -96,58 +67,13 @@ def _is_identity(p): def _layer_params(z_rel, mm_to_scaled, p): - """(cos, sin, scale, x_offset_scaled) for one layer. Exact identity at z_rel == 0.""" + """(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 math.cos(theta), math.sin(theta), s, ox - - -def _transform_ring(ring, cos_t, sin_t, s, cx, cy, ox): - """Similarity-transform one int64 (N,2) ring about (cx, cy), then shift X by ox. - - Returns a NEW writable int64 (N,2) ndarray with the same point count, or None - if the ring is degenerate (< 3 points; the host's parse_polygon would reject it). - Rotation + uniform positive scale preserves orientation and hole containment and - cannot self-intersect; the host re-normalizes winding on write-back anyway. - """ - n = ring.shape[0] - if n < 3: - return None - if _np is not None: # production path (numpy is a declared dependency) - pts = ring.astype(_np.float64) - dx = pts[:, 0] - cx - dy = pts[:, 1] - cy - out = _np.empty_like(ring) # inherits int64 -- immune to Windows' int32 default - out[:, 0] = _np.rint((dx * cos_t - dy * sin_t) * s + cx + ox) - out[:, 1] = _np.rint((dx * sin_t + dy * cos_t) * s + cy) - return out - out = ring.copy() # defensive fallback; unreachable when the host supplied `ring` - for i in range(n): - dx = float(ring[i, 0]) - cx - dy = float(ring[i, 1]) - cy - out[i, 0] = int(round((dx * cos_t - dy * sin_t) * s + cx + ox)) - out[i, 1] = int(round((dx * sin_t + dy * cos_t) * s + cy)) - return out - - -def _transform_expoly(expoly, cos_t, sin_t, s, cx, cy, ox): - """ExPolygon -> [contour, [holes...]] entry for set_slices. - - Returns None if the outer contour is degenerate; degenerate holes are dropped - (a <3-point ring is meaningless and would make the host raise ValueError). - """ - contour = _transform_ring(expoly.contour.points(), cos_t, sin_t, s, cx, cy, ox) - if contour is None: - return None - holes = [] - for hole in expoly.holes: - th = _transform_ring(hole.points(), cos_t, sin_t, s, cx, cy, ox) - if th is not None: - holes.append(th) - return [contour, holes] + return theta, s, ox class Twistify(orca.slicing.SlicingPipelineCapabilityBase): @@ -155,27 +81,27 @@ class Twistify(orca.slicing.SlicingPipelineCapabilityBase): return "Twistify" def execute(self, ctx): - # Standard guard: Step.Slice is per-object and the only fully-wired mutation seam. if ctx.step != orca.slicing.Step.Slice or ctx.object is None: return orca.ExecutionResult.success() p = _params(ctx) - # Exact no-op parameters -> leave the pipeline byte-identical by construction. if _is_identity(p): return orca.ExecutionResult.success("Twistify: identity parameters, nothing to do") - # Millimeters -> scaled units via the LIVE scale (never hardcode 1e6/1e-6). mm_to_scaled = 1.0 / orca.slicing.unscale(1) layers = ctx.object.layers() if not layers: return orca.ExecutionResult.success("Twistify: object has no layers") - # Twist axis = the object's bounding-box center (scaled coords, same frame as the - # slice polygons), so each object on the plate twists about its own center. + # 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 @@ -183,32 +109,29 @@ class Twistify(orca.slicing.SlicingPipelineCapabilityBase): if ctx.cancelled(): break z_rel = float(layer.print_z) - z0 - cos_t, sin_t, s, ox = _layer_params(z_rel, mm_to_scaled, p) - if cos_t == 1.0 and sin_t == 0.0 and s == 1.0 and ox == 0.0: - continue # exact identity (always the first layer): skip set_slices entirely + 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(): - surfaces = region.slices.surfaces - if not surfaces: - continue # set_slices() rejects nothing now, but an empty region has nothing to do - new_surfaces = [] - for surface in surfaces: - entry = _transform_expoly(surface.expolygon, cos_t, sin_t, s, cx, cy, ox) - if entry is None: - continue # degenerate outer contour: drop this surface - # Carry this surface's type as the third entry element so it is preserved - # per surface. The plain enum value is read out BEFORE set_slices, since the - # Surface reference dangles once the collection is replaced. - entry.append(surface.surface_type) - new_surfaces.append(entry) - if not new_surfaces: - continue # every surface degenerate: leave the region untouched - # refresh_lslices=True re-derives the layer's merged islands + bbox cache from - # the twisted slices, so overhang/bridge detection and brim/skirt/support stay - # coherent -- no separate Layer.set_lslices() pass needed. - region.set_slices(new_surfaces, refresh_lslices=True) - - layers_touched += 1 + 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( diff --git a/src/slic3r/plugin/PluginBindingUtils.hpp b/src/slic3r/plugin/PluginBindingUtils.hpp index dfb546d87f..3f17b5668a 100644 --- a/src/slic3r/plugin/PluginBindingUtils.hpp +++ b/src/slic3r/plugin/PluginBindingUtils.hpp @@ -53,6 +53,23 @@ pybind11::array make_readonly_rows(pybind11::handle base, const T* data, pybind1 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). diff --git a/src/slic3r/plugin/PluginHostSlicing.cpp b/src/slic3r/plugin/PluginHostSlicing.cpp index f1a94c7658..57aa02962e 100644 --- a/src/slic3r/plugin/PluginHostSlicing.cpp +++ b/src/slic3r/plugin/PluginHostSlicing.cpp @@ -3,6 +3,7 @@ #include "libslic3r/libslic3r.h" // unscale<>, scale_ #include "libslic3r/BoundingBox.hpp" +#include "libslic3r/ClipperUtils.hpp" // offset/offset_ex/union_ex/diff_ex/intersection_ex #include "libslic3r/ExPolygon.hpp" #include "libslic3r/Surface.hpp" #include "libslic3r/SurfaceCollection.hpp" @@ -13,7 +14,6 @@ #include #include -#include #include namespace py = pybind11; @@ -51,87 +51,14 @@ static Polygon parse_polygon(py::handle h, const char* who) return poly; } -// One Python entry -> ExPolygon. Accepts a bare (N,2) ndarray (contour only), a -// [contour, [hole, ...]] sequence, or (G9) a [contour, [hole, ...], SurfaceType] triple whose -// third element overrides the surface type for set_slices/set_fill_surfaces. When `out_type` is -// null (geometry-only consumers such as set_lslices) any third element is ignored. Orientation -// is normalized (contour CCW, holes CW) so downstream area/offset math is correct regardless of -// the caller's winding. -static ExPolygon parse_expolygon(py::handle entry, const char* who, - std::optional* out_type = nullptr) +// 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. +static Polygon as_polygon(py::handle h, const char* who) { - ExPolygon ex; - if (py::isinstance(entry)) { - ex.contour = parse_polygon(entry, who); - } else if (py::isinstance(entry) && !py::isinstance(entry)) { - py::sequence seq = py::reinterpret_borrow(entry); - if (py::len(seq) < 1) - throw py::value_error(std::string(who) + ": a [contour, holes] entry needs a contour"); - ex.contour = parse_polygon(seq[0], who); - if (py::len(seq) >= 2) { - // Type-check the holes element up front: a non-sequence (e.g. an int) would otherwise - // reach reinterpret_borrow and raise a bare Python TypeError on iteration, - // whereas the API contract is ValueError for malformed input (str is excluded because it - // is iterable but never a valid holes container). - py::object holes_obj = seq[1]; - if (!py::isinstance(holes_obj) || py::isinstance(holes_obj)) - throw py::value_error(std::string(who) + ": the holes element must be a list of (N,2) int64 ndarrays"); - for (py::handle hh : py::reinterpret_borrow(holes_obj)) { - Polygon hole = parse_polygon(hh, who); - hole.make_clockwise(); - ex.holes.emplace_back(std::move(hole)); - } - } - // G9: optional third element -> per-surface SurfaceType override (None keeps the - // carried-forward type). A wrong type raises ValueError, matching the API contract. - if (out_type != nullptr && py::len(seq) >= 3) { - py::object t = seq[2]; - if (!t.is_none()) { - try { *out_type = t.cast(); } - catch (const py::cast_error&) { - throw py::value_error(std::string(who) + ": the third entry element must be an orca.host.SurfaceType"); - } - } - } - } else { - throw py::value_error(std::string(who) + ": each entry must be an (N,2) ndarray or a [contour, holes] pair"); - } - ex.contour.make_counter_clockwise(); - return ex; -} - -// A Python list of entries -> ExPolygons (each entry parsed + oriented). G7: an empty list is -// legal and means "no geometry" (clears the target collection). Per-entry types are ignored -// here (geometry-only consumers such as set_lslices). -static ExPolygons parse_expolygon_list(py::handle list_h, const char* who) -{ - if (!py::isinstance(list_h) || py::isinstance(list_h)) - throw py::value_error(std::string(who) + ": expected a list of polygons"); - ExPolygons out; - for (py::handle entry : py::reinterpret_borrow(list_h)) - out.emplace_back(parse_expolygon(entry, who)); - return out; -} - -// Build Surfaces from a Python list, carrying surface_type (and the other per-surface -// attributes) forward from the collection being replaced, or defaulting to stInternal when the -// region had none. G9: a per-entry SurfaceType (optional third element) overrides that default. -// G7: an empty list is legal and yields an empty Surfaces (clears the collection). -static Surfaces surfaces_from_py(py::handle list_h, const SurfaceCollection& replaced, const char* who) -{ - if (!py::isinstance(list_h) || py::isinstance(list_h)) - throw py::value_error(std::string(who) + ": expected a list of polygons"); - const Surface tmpl = replaced.surfaces.empty() ? Surface(stInternal) : replaced.surfaces.front(); - Surfaces out; - for (py::handle entry : py::reinterpret_borrow(list_h)) { - std::optional type; - ExPolygon e = parse_expolygon(entry, who, &type); - Surface s(tmpl, std::move(e)); - if (type) - s.surface_type = *type; - out.emplace_back(std::move(s)); - } - return out; + if (py::isinstance(h)) + return h.cast(); + return parse_polygon(h, who); } // Flatten an extrusion graph into a list of leaf ExtrusionPath* while walking the @@ -164,6 +91,17 @@ static void collect_extrusion_paths(const ExtrusionEntity* ee, std::vector(host, "SurfaceType") @@ -197,52 +135,203 @@ void PluginHostSlicing::RegisterBindings(py::module_& host) .value("stCount", stCount) .export_values(); - py::class_>(host, "Polygon") + // 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("points", [](py::object self) { - const Polygon& p = self.cast(); + .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_readonly_rows( + return py::object(make_writable_rows( self, p.points.empty() ? nullptr : p.points.front().data(), (py::ssize_t) p.points.size())); }); - }, "Vertices as a read-only int64 (N,2) numpy view in scaled coords. " - "Valid only during the execute(ctx) call. Requires numpy."); + }, "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."); - py::class_>(host, "ExPolygon") - .def_property_readonly("contour", [](ExPolygon& e) -> Polygon& { return e.contour; }, + // 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 above. + 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) as a Polygon.") + "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]."); + }, "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]."); - py::class_>(host, "Surface") + // 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 Polygon/ExPolygon above. + 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. Writable: assigning reclassifies the " - "surface in place on the live slicing graph (geometry unchanged).") - .def_readonly("thickness", &Surface::thickness) - .def_readonly("bridge_angle", &Surface::bridge_angle) - .def_readonly("extra_perimeters", &Surface::extra_perimeters) - .def_property_readonly("expolygon", [](Surface& s) -> ExPolygon& { return s.expolygon; }, + "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."); + "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`. " + "This is the faithful replacement for the retired set_slices().") + .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; + // SurfacesPtr (SurfaceCollection::filter_by_type's return type) is + // std::vector (see Surface.hpp); the brief's note describing it + // as std::vector does not match the header, so this iterates 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_slices/set_fill_surfaces on the owning region (C++ vector semantics)."); + }, "Surfaces as [Surface] references into the live collection. Invalidated by " + "set()/append()/clear() on this collection (C++ vector semantics)."); // --- Extrusion tree (read-only in v1). Registered polymorphically: when a returned // ExtrusionEntity*'s dynamic type IS one of the classes registered below, pybind @@ -324,14 +413,15 @@ void PluginHostSlicing::RegisterBindings(py::module_& host) auto layer_region = py::class_>(host, "LayerRegion"); layer_region .def_readonly("slices", &LayerRegion::slices, - "Sliced, typed surfaces (SurfaceCollection). At Step.Slice this is the " - "primary mutation target via set_slices().") + "Sliced, typed surfaces (SurfaceCollection). Edit in place, or replace with " + "slices.set(expolygons, surface_type). At Step.Slice 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).") + "Surfaces prepared for infill (SurfaceCollection). Edit in place or via fill_surfaces.set(...).") .def_readonly("perimeters", &LayerRegion::perimeters, - "Perimeter toolpaths (ExtrusionEntityCollection).") + "Perimeter toolpaths (ExtrusionEntityCollection, read-only in v1).") .def_readonly("fills", &LayerRegion::fills, - "Infill toolpaths (ExtrusionEntityCollection).") + "Infill toolpaths (ExtrusionEntityCollection, read-only in v1).") .def("layer", [](LayerRegion& r) -> py::object { Layer* l = r.layer(); if (l == nullptr) @@ -344,58 +434,7 @@ void PluginHostSlicing::RegisterBindings(py::module_& host) .def("config_value", [](const LayerRegion& r, const std::string& key) { return config_value_or_none(r.region().config(), key); }, py::arg("key"), - "Serialized value of this region's resolved config option, or None if absent.") - // MUTATOR (G1/G3/G9). Replace this region's sliced surfaces. `polygons` is a list of - // (N,2) int64 ndarrays (scaled coords), [contour, [holes...]] pairs, or (G9) - // [contour, [holes...], SurfaceType] triples; orientation is normalized (contour CCW, - // holes CW) and surface_type is carried forward from the replaced surfaces (else - // stInternal) unless a per-entry type is given. - .def("set_slices", [](LayerRegion& region, py::object polygons, bool refresh_lslices) { - region.slices.set(surfaces_from_py(polygons, region.slices, "set_slices")); - // G1: rebuild the owning layer's merged islands (lslices) + bbox cache from the - // mutated region slices so downstream consumers (detect_surfaces_type neighbor - // diffs, overhang/bridge detection, brim/skirt/support) see coherent islands. - // Skipped when the region has no owning layer (unit-test regions). - if (refresh_lslices) { - if (Layer* layer = region.layer()) { - layer->make_slices(); - layer->lslices_bboxes.clear(); - layer->lslices_bboxes.reserve(layer->lslices.size()); - for (const ExPolygon& island : layer->lslices) - layer->lslices_bboxes.emplace_back(get_extents(island)); - } - } - }, py::arg("polygons"), py::arg("refresh_lslices") = true, - "Replace this region's sliced surfaces from a list of (N,2) int64 ndarrays (scaled " - "coords), [contour, [holes...]] pairs, or [contour, [holes...], SurfaceType] triples " - "(orientation normalized: contour CCW / holes CW; surface_type carried forward from the " - "replaced surfaces, else stInternal, unless a per-entry SurfaceType is supplied). An " - "empty list clears this region's slices.\n" - "MUTATION-CASCADE: at the Slice boundary this is the primary, fully-supported entry " - "point -- the split slice loop runs make_perimeters() afterward, so the change cascades " - "into perimeters and everything downstream (final G-code).\n" - "LSLICES (G1): refresh_lslices=True (default) re-derives the owning layer's merged " - "islands and bbox cache from the new slices so overhang/bridge/skirt/support stay " - "coherent; pass False only if you manage lslices yourself via Layer.set_lslices.\n" - "PERSISTENCE (G3): the Slice hook re-snapshots raw_slices after it returns, so the " - "mutation survives a later perimeter-only re-run (restore_untyped_slices) instead of " - "silently reverting; it still does not persist across a full re-slice unless the hook " - "re-fires (re-select the plugin, or any posSlice-invalidating change).\n" - "DUPLICATES: identical objects share Layer*, so the mutation on the object that slices " - "is automatically seen by its duplicates; objects that must mutate independently must " - "not be identical.\n" - "Raises ValueError on malformed input. Valid only during the execute(ctx) call.") - // MUTATOR. Replace this region's fill (infill-prep) surfaces; identical input format and - // validation to set_slices. - .def("set_fill_surfaces", [](LayerRegion& region, py::object polygons) { - region.fill_surfaces.set(surfaces_from_py(polygons, region.fill_surfaces, "set_fill_surfaces")); - }, py::arg("polygons"), - "Replace this region's fill (infill-prep) surfaces; same input format/validation as " - "set_slices (per-entry SurfaceType supported; an empty list clears them).\n" - "MUTATION-CASCADE: at the PrepareInfill boundary (G4) make_fills runs afterward, so this " - "cascades into the generated infill. At the Infill boundary it changes the stored " - "surfaces but does NOT regenerate the already-built `fills` toolpaths (v1).\n" - "Raises ValueError on malformed input. Valid only during the execute(ctx) call."); + "Serialized value of this region's resolved config option, or None if absent."); auto layer = py::class_>(host, "Layer"); layer @@ -417,38 +456,19 @@ void PluginHostSlicing::RegisterBindings(py::module_& host) 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] references. Invalidated by " - "set_lslices/make_slices (C++ vector semantics).") - .def("make_slices", [](Layer& l) { - l.make_slices(); - l.lslices_bboxes.clear(); - l.lslices_bboxes.reserve(l.lslices.size()); - for (const ExPolygon& island : l.lslices) - l.lslices_bboxes.emplace_back(get_extents(island)); - }, "Re-derive lslices (merged islands) from the region slices and refresh the " - "bbox cache — the C++ invariant-maintenance call after in-place geometry edits. " - "set_slices(refresh_lslices=True) runs this for you.") - // MUTATOR. Replace this layer's merged islands (lslices) and refresh the cache-invariant - // `lslices_bboxes` (one BoundingBox per island via get_extents). Same input format and - // validation as LayerRegion.set_slices. - .def("set_lslices", [](Layer& l, py::object islands) { - l.lslices = parse_expolygon_list(islands, "set_lslices"); - l.lslices_bboxes.clear(); - l.lslices_bboxes.reserve(l.lslices.size()); - for (const ExPolygon& island : l.lslices) - l.lslices_bboxes.emplace_back(get_extents(island)); - }, py::arg("islands"), - "Replace this layer's merged islands (lslices) from a list of (N,2) int64 ndarrays " - "(scaled coords) or [contour, [holes...]] pairs, and refresh lslices_bboxes (one " - "bounding box per island via get_extents) so the bbox cache stays consistent. Same " - "input format/validation as LayerRegion.set_slices. Raises ValueError on malformed " - "input. Valid only during the execute(ctx) call."); + }, "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; }, diff --git a/src/slic3r/plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.cpp b/src/slic3r/plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.cpp index 3ee5539af1..6cc1d1bde9 100644 --- a/src/slic3r/plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.cpp +++ b/src/slic3r/plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.cpp @@ -17,8 +17,8 @@ void SlicingPipelinePluginCapability::RegisterBindings(py::module_& module, py:: .value("Slice", SlicingPipelineStep::Slice) .value("Perimeters", SlicingPipelineStep::Perimeters) .value("EstimateCurledExtrusions", SlicingPipelineStep::EstimateCurledExtrusions) - .value("PrepareInfill", SlicingPipelineStep::PrepareInfill) // after prepare_infill, before make_fills: set_fill_surfaces here CASCADES - .value("Infill", SlicingPipelineStep::Infill) // after make_fills: set_fill_surfaces here does NOT regenerate fills (v1) + .value("PrepareInfill", SlicingPipelineStep::PrepareInfill) // after prepare_infill, before make_fills: editing fill_surfaces here CASCADES + .value("Infill", SlicingPipelineStep::Infill) // after make_fills: editing fill_surfaces here does NOT regenerate fills (v1) .value("Ironing", SlicingPipelineStep::Ironing) .value("Contouring", SlicingPipelineStep::Contouring) .value("SupportMaterial", SlicingPipelineStep::SupportMaterial) diff --git a/tests/fff_print/test_slicing_pipeline_hook.cpp b/tests/fff_print/test_slicing_pipeline_hook.cpp index 28869b0c36..fcb1802b8c 100644 --- a/tests/fff_print/test_slicing_pipeline_hook.cpp +++ b/tests/fff_print/test_slicing_pipeline_hook.cpp @@ -221,7 +221,7 @@ TEST_CASE("Changing slicing_pipeline_plugin invalidates posSlice", "[slicing_pip // §3.6 (Twistify design): Twistify's effect is a similarity transform (rotate + uniform // scale) applied to slices at Step.Slice. This C++ analogue rotates every region's slices a // fixed 45 deg about the object's base-footprint center -- the same seam and cascade that -// Twistify.py drives through the pybind set_slices binding. Two end-to-end invariants after +// Twistify.py 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 @@ -299,7 +299,7 @@ TEST_CASE("Rotating slices at the Slice boundary cascades (area preserved, bbox } // §3.6 (Twistify design): Twistify skips exact-identity layers entirely, but every transformed -// layer invokes the set_slices write-back + make_perimeters re-run. This proves that write path +// 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 @@ -425,8 +425,8 @@ TEST_CASE("fill_surfaces mutation cascades at PrepareInfill but not at Infill", } // lslices (the layer's merged islands) are built once in slice() and never rebuilt by -// make_perimeters, so mutating region slices leaves them stale. set_slices(refresh_lslices=True) -// re-derives them via Layer::make_slices(); this C++ analogue proves the mechanism -- without the +// 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) { @@ -445,7 +445,7 @@ TEST_CASE("refreshing lslices after a slice mutation makes islands track the geo } r->slices.set(std::move(in)); } - if (refresh) // the load-bearing half of set_slices(refresh_lslices=True) + if (refresh) // the load-bearing half of the slices.set() + Layer::make_slices() path l->make_slices(); } }); diff --git a/tests/slic3rutils/test_slicing_pipeline_bindings.cpp b/tests/slic3rutils/test_slicing_pipeline_bindings.cpp index 34af185e4f..4a6d36ff01 100644 --- a/tests/slic3rutils/test_slicing_pipeline_bindings.cpp +++ b/tests/slic3rutils/test_slicing_pipeline_bindings.cpp @@ -56,6 +56,23 @@ TEST_CASE("make_readonly_rows builds a read-only (N,2) int64 view", "[slicing_pi 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 test_plugin_host_api.cpp:32-40) @@ -187,41 +204,138 @@ TEST_CASE("orca.host leaf geometry: Surface/ExPolygon/Polygon raw bindings", "[s CHECK(exv.attr("contour").attr("size")().cast() == 0); } -TEST_CASE("orca.host Polygon.points() is a read-only int64 (N,2) view in scaled coords", "[slicing_pipeline]") { +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) — the faithful replacement for set_slices' body. + 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 in unit-test interpreter"); + 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); - Slic3r::ExPolygon ex; - ex.contour.points = { Slic3r::Point(0, 0), Slic3r::Point(s, 0), - Slic3r::Point(s, s), Slic3r::Point(0, s) }; - Slic3r::Polygon hole; - hole.points = { Slic3r::Point(1, 1), Slic3r::Point(2, 1), Slic3r::Point(2, 2) }; - ex.holes = { hole }; - py::object view = py::cast(&ex, py::return_value_policy::reference); - py::array c = view.attr("contour").attr("points")().cast(); - CHECK(c.dtype().kind() == 'i'); - CHECK(c.itemsize() == 8); // int64 - CHECK(c.shape(0) == 4); - CHECK(c.shape(1) == 2); - CHECK_FALSE(c.writeable()); - auto rc = c.cast>().unchecked<2>(); - CHECK(rc(0, 0) == 0); - CHECK(rc(1, 0) == s); - CHECK(rc(2, 1) == s); + // 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); - py::list holes = view.attr("holes").cast(); - REQUIRE(holes.size() == 1); - py::array h0 = holes[0].attr("points")().cast(); - CHECK(h0.shape(0) == 3); - CHECK_FALSE(h0.writeable()); + // 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 { @@ -354,34 +468,29 @@ TEST_CASE("orca.host graph classes: LayerRegion/Layer raw traversal; Print/Print CHECK(ly.attr("lower_layer").is_none()); } -TEST_CASE("orca.host mutators: registration, ValueError on garbage, empty-clears", "[slicing_pipeline]") { +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"); - CHECK(py::hasattr(host.attr("LayerRegion"), "set_slices")); - CHECK(py::hasattr(host.attr("LayerRegion"), "set_fill_surfaces")); - CHECK(py::hasattr(host.attr("Layer"), "set_lslices")); + // 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); - - auto raises_value_error = [](py::object callable, py::object arg) { - try { callable(arg); return false; } - catch (py::error_already_set& e) { return e.matches(PyExc_ValueError); } - }; - CHECK(raises_value_error(lr.attr("set_slices"), py::int_(42))); // not a sequence - CHECK(raises_value_error(lr.attr("set_slices"), py::str("nope"))); // string rejected - CHECK(region.slices.surfaces.size() == 1); // failures mutate nothing - // G7: an empty list is legal and clears the region (refresh_lslices defaults True; - // the null owning-layer on this hand-built region exercises the null guard). - lr.attr("set_slices")(py::list()); + 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 set_slices/set_lslices: ndarray input mutates geometry (read back both ways)", "[slicing_pipeline]") { +TEST_CASE("orca.host: SurfaceCollection.set mutates geometry; lslices via make_slices", "[slicing_pipeline]") { using Catch::Matchers::WithinRel; ensure_python_initialized(); import_orca_module(); @@ -394,64 +503,106 @@ TEST_CASE("orca.host set_slices/set_lslices: ndarray input mutates geometry (rea 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 make_arr = [&](std::initializer_list> pts) { - py::list rows; - for (auto& p : pts) rows.append(py::make_tuple(p.first, p.second)); + 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); }; - // set_slices: CW input normalized CCW; surface_type carried forward; readable back raw. + // 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; - region.slices.surfaces.emplace_back(Slic3r::Surface(Slic3r::stInternalSolid)); - py::object lr = py::cast(static_cast(®ion), - py::return_value_policy::reference); - py::list polys; - polys.append(make_arr({ {0,0}, {0,s}, {s,s}, {s,0} })); // clockwise winding - lr.attr("set_slices")(polys); + 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(out.expolygon.contour.is_counter_clockwise()); CHECK_THAT(out.expolygon.area(), WithinRel((double) s * (double) s, 1e-9)); - py::list sl = lr.attr("slices").attr("surfaces").cast(); - REQUIRE(sl.size() == 1); - py::array c = sl[0].attr("expolygon").attr("contour").attr("points")().cast(); + // 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); - // G9: per-entry SurfaceType override via [contour, holes, SurfaceType] triple. - py::list entry; - entry.append(make_arr({ {0,0}, {s,0}, {s,s}, {0,s} })); - entry.append(py::list()); - entry.append(host.attr("SurfaceType").attr("stTop")); - py::list polys2; polys2.append(entry); - lr.attr("set_slices")(polys2, py::bool_(false)); // refresh_lslices=False path - REQUIRE(region.slices.surfaces.size() == 1); - CHECK(region.slices.surfaces.front().surface_type == Slic3r::stTop); - - // Negative: a valid contour paired with a non-list holes slot must raise ValueError. - // (Regression guard for a malformed holes slot; the retired view-layer suite covered - // this, and the raw layer needs a numpy-built valid contour to exercise the same path.) - { - py::list bad_entry; - bad_entry.append(make_arr({ {0,0}, {s,0}, {s,s}, {0,s} })); // valid contour - bad_entry.append(py::int_(42)); // holes slot is not a list - py::list bad_polys; bad_polys.append(bad_entry); - bool raised = false; - try { lr.attr("set_slices")(bad_polys); } - catch (py::error_already_set& e) { raised = e.matches(PyExc_ValueError); } - CHECK(raised); - } - - // Layer.set_lslices round-trip on a hand-built layer (empty regions -> null-safe). + // 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); - py::list islands; - islands.append(make_arr({ {0,0}, {s,0}, {s,s}, {0,s} })); - ly.attr("set_lslices")(islands); - REQUIRE(layer.lslices.size() == 1); - CHECK(layer.lslices.front().contour.is_counter_clockwise()); - REQUIRE(layer.lslices_bboxes.size() == 1); // bbox cache refreshed - CHECK(ly.attr("lslices")().cast().size() == 1); + 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 }