feat(plugin)!: faithful mutable geometry bindings; edit slices via the class API

Replaces the plugin-only set_slices/set_fill_surfaces/set_lslices mutators with a
faithful, mutable binding of the core geometry types, so a plugin edits the slicing
graph through the same object model the C++ code uses.

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

BREAKING CHANGE: set_slices/set_fill_surfaces/set_lslices and the internal
parse_expolygon(_list)/surfaces_from_py helpers are removed. Plugins mutate through
the class API (SurfaceCollection.set/append/clear, Polygon.set_points/append,
ExPolygon.set_holes) instead.
This commit is contained in:
SoftFever
2026-07-08 15:04:40 +08:00
parent fd2a489980
commit 11dd078f64
7 changed files with 558 additions and 484 deletions

View File

@@ -1,81 +1,35 @@
# /// script # /// script
# requires-python = ">=3.12" # requires-python = ">=3.12"
# dependencies = ["numpy"]
# #
# [tool.orcaslicer.plugin] # [tool.orcaslicer.plugin]
# name = "Inset Every Slice" # name = "Inset Every Slice"
# description = "Insets every layer's slices by 1mm at the Slice boundary (demo)." # description = "Insets every layer's slices by 1mm at the Slice boundary (demo)."
# author = "OrcaSlicer" # author = "OrcaSlicer"
# version = "0.01" # version = "0.02"
# type = "slicing-pipeline" # type = "slicing-pipeline"
# /// # ///
"""Inset Every Slice -- a small, WORKING SlicingPipeline sample plugin. """Inset Every Slice -- a small, WORKING SlicingPipeline sample plugin.
At Step.Slice, for every layer/region of the sliced object, this shrinks each At Step.Slice, for every layer/region of the sliced object, this shrinks each
sliced surface's outer contour by INSET_MM and writes the result back with sliced surface by INSET_MM using a real polygon offset (ExPolygon.offset) and
LayerRegion.set_slices(). set_slices() at Step.Slice is the fully-supported writes the result back with SurfaceCollection.set(). After the per-region edits,
mutation-cascade entry point (see docs/plugins/slicing_pipeline_plugin.md next layer.make_slices() re-derives the layer's merged islands (lslices) so
to this file): the split slice loop runs make_perimeters() right after the overhang/bridge detection, skirt/brim and support stay coherent with the inset
Slice hook, so the change cascades into perimeters, infill and the final geometry. At Step.Slice the split slice loop runs make_perimeters() right after
G-code -- the toolpath preview visibly shrinks. 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: Unlike the old axis-aligned demo, ExPolygon.offset() is a correct inward offset
- The inset is a per-axis contraction toward the contour's bounding-box for any contour (it is Clipper under the hood), and it naturally handles holes.
center: each vertex coordinate is pulled toward the center by up to A surface may split into several islands or vanish when shrunk; both are handled.
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.
numpy is declared as a dependency: the geometry accessors hand back zero-copy No numpy required: the whole edit is expressed with the host geometry classes.
int64 ndarrays, and set_slices() requires genuine ndarrays back (not plain lists),
so building the modified contour needs numpy.
""" """
import numpy as np
import orca import orca
INSET_MM = 1.0 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): class InsetEverySlice(orca.slicing.SlicingPipelineCapabilityBase):
def get_name(self): def get_name(self):
return "Inset Every Slice" 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: if ctx.step != orca.slicing.Step.Slice or ctx.object is None:
return orca.ExecutionResult.success() return orca.ExecutionResult.success()
# Millimeters -> scaled integer units via the *live* scale. SCALING_FACTOR # Millimeters -> scaled integer units via the *live* scale (never hardcode 1e6).
# is not a fixed constant (large beds use a coarser scale), so this must be
# read at call time -- never hardcode 1e6/1e-6.
inset_scaled = int(round(INSET_MM / orca.slicing.unscale(1))) inset_scaled = int(round(INSET_MM / orca.slicing.unscale(1)))
regions_touched = 0 regions_touched = 0
for layer in ctx.object.layers(): for layer in ctx.object.layers():
if ctx.cancelled(): if ctx.cancelled():
break break
layer_touched = False
for region in layer.regions(): for region in layer.regions():
surfaces = region.slices.surfaces surfaces = region.slices.surfaces
if not 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: for surface in surfaces:
expoly = surface.expolygon shrunk = surface.expolygon.offset(-inset_scaled) # [ExPolygon], may be empty
contour = expoly.contour.points() if shrunk:
inset = _inset_contour(contour, inset_scaled) by_type.setdefault(surface.surface_type, []).extend(shrunk)
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]])
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 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)") return orca.ExecutionResult.success(f"inset applied to {regions_touched} region(s)")

View File

@@ -1,12 +1,11 @@
# /// script # /// script
# requires-python = ">=3.12" # requires-python = ">=3.12"
# dependencies = ["numpy"]
# #
# [tool.orcaslicer.plugin] # [tool.orcaslicer.plugin]
# name = "Twistify" # name = "Twistify"
# description = "Twists, tapers, and wobbles every layer's slice polygons as a function of Z (demo)." # description = "Twists, tapers, and wobbles every layer's slice polygons as a function of Z (demo)."
# author = "OrcaSlicer" # author = "OrcaSlicer"
# version = "0.01" # version = "0.02"
# type = "slicing-pipeline" # type = "slicing-pipeline"
# #
# [tool.orcaslicer.plugin.settings] # [tool.orcaslicer.plugin.settings]
@@ -18,68 +17,40 @@
# /// # ///
"""Twistify -- twist/taper/wobble any model at slice time. """Twistify -- twist/taper/wobble any model at slice time.
At Step.Slice (the one fully-supported mutation seam -- see At Step.Slice, every layer's sliced surfaces are transformed by a similarity
docs/plugins/slicing_pipeline_plugin.md), every layer's sliced surfaces are about the object's bounding-box center as a function of Z -- edited IN PLACE
rotated, uniformly scaled, and optionally swayed about the object's center as a through the host geometry classes (ExPolygon.rotate/scale/translate). Each
function of Z, then written back with LayerRegion.set_slices(). The surface is rotated about the center, then (if tapering) translated to the
dedicated slice loop runs make_perimeters() right after this hook, so the origin, uniformly scaled, and translated back, so the taper stays centered on
transform cascades into perimeters, infill, and the final G-code -- the toolpath the object instead of drifting toward the coordinate origin. An optional X
preview visibly corkscrews, and unlike G-code post-processing hacks the printed wobble is applied last. After the per-region edits, layer.make_slices()
part keeps correct multi-wall perimeters, infill, and flow. 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 Because we edit geometry in place, surface types are preserved automatically
the PEP-723 header above. Edit them there (and re-slice) to change the effect; no (no per-surface type carry needed), and no numpy is required --
code edit or plugin reload is needed. Recipes: twisted vase rotate/scale/translate are host methods. Parameters come from ctx.params (the
(twist 1.0), tapered spire (twist 0.3, taper -0.006), wobbling tower settings table above). The first object layer is untouched (z_rel = 0), so bed
(twist 0, wobble_ampl 0.8). adhesion is unaffected.
The transform uses three of the gap-closing APIs so the plugin stays small and
correct:
* ctx.object.bounding_box() gives the twist axis (each object twists about its
own center) -- no footprint reconstruction.
* set_slices(refresh_lslices=True) re-derives the layer's merged islands, so
overhang/bridge/skirt/support stay coherent -- no manual set_lslices().
* a per-entry SurfaceType (third set_slices element) preserves each surface's
type -- no replace-then-reassign-surface_type two-step.
Because the Slice hook re-snapshots raw_slices afterward, the twist also survives
a later perimeter-only re-slice (e.g. changing wall_loops) instead of reverting.
numpy is REQUIRED at slice time (declared above): the host's geometry accessors
return numpy arrays. The pure-Python fallback in _transform_ring exists only so this
module still imports on numpy-less interpreters (the unit-test harness); it is
unreachable in production. Outputs are built by .copy()-ing the host's zero-copy
read arrays (dtype/shape inherited -- int64 on every platform, immune to Windows'
numpy int32 default), never constructed from scratch.
Physical-print caveats: keep the twist modest (horizontal shift per layer at the
part's outer radius should stay under ~1.4x layer height) or the real print grows
unsupported overhangs -- the preview looks great regardless. The first object
layer is untouched (z_rel = 0), so bed adhesion is unaffected. Twists EVERY
object on the plate (each about its own center).
""" """
import math import math
import orca 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 = { _DEFAULTS = {
"twist_deg_per_mm": 1.0, # signed twist rate; 1 deg/mm corkscrews a 100mm cube by 100 deg "twist_deg_per_mm": 1.0,
"taper_per_mm": 0.0, # relative XY scale change per mm of Z (-0.004 = shrink 0.4%/mm) "taper_per_mm": 0.0,
"wobble_ampl_mm": 0.0, # X sway amplitude in mm (0 disables) "wobble_ampl_mm": 0.0,
"wobble_period_mm": 20.0, # full sway period in mm of Z "wobble_period_mm": 20.0,
"min_scale": 0.05, # taper clamp: polygons shrink but can never collapse to a point "min_scale": 0.05,
} }
def _params(ctx): def _params(ctx):
"""Resolve parameters from ctx.params (string values), falling back to _DEFAULTS."""
try: try:
src = dict(ctx.params) # ctx.params is a read-only dict of str -> str src = dict(ctx.params)
except (AttributeError, TypeError): except (AttributeError, TypeError):
src = {} src = {}
out = {} out = {}
@@ -96,58 +67,13 @@ def _is_identity(p):
def _layer_params(z_rel, mm_to_scaled, 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) theta = math.radians(p["twist_deg_per_mm"] * z_rel)
s = max(p["min_scale"], 1.0 + p["taper_per_mm"] * z_rel) s = max(p["min_scale"], 1.0 + p["taper_per_mm"] * z_rel)
ox = 0.0 ox = 0.0
if p["wobble_ampl_mm"] != 0.0 and p["wobble_period_mm"] > 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 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 return theta, s, ox
def _transform_ring(ring, cos_t, sin_t, s, cx, cy, ox):
"""Similarity-transform one int64 (N,2) ring about (cx, cy), then shift X by ox.
Returns a NEW writable int64 (N,2) ndarray with the same point count, or None
if the ring is degenerate (< 3 points; the host's parse_polygon would reject it).
Rotation + uniform positive scale preserves orientation and hole containment and
cannot self-intersect; the host re-normalizes winding on write-back anyway.
"""
n = ring.shape[0]
if n < 3:
return None
if _np is not None: # production path (numpy is a declared dependency)
pts = ring.astype(_np.float64)
dx = pts[:, 0] - cx
dy = pts[:, 1] - cy
out = _np.empty_like(ring) # inherits int64 -- immune to Windows' int32 default
out[:, 0] = _np.rint((dx * cos_t - dy * sin_t) * s + cx + ox)
out[:, 1] = _np.rint((dx * sin_t + dy * cos_t) * s + cy)
return out
out = ring.copy() # defensive fallback; unreachable when the host supplied `ring`
for i in range(n):
dx = float(ring[i, 0]) - cx
dy = float(ring[i, 1]) - cy
out[i, 0] = int(round((dx * cos_t - dy * sin_t) * s + cx + ox))
out[i, 1] = int(round((dx * sin_t + dy * cos_t) * s + cy))
return out
def _transform_expoly(expoly, cos_t, sin_t, s, cx, cy, ox):
"""ExPolygon -> [contour, [holes...]] entry for set_slices.
Returns None if the outer contour is degenerate; degenerate holes are dropped
(a <3-point ring is meaningless and would make the host raise ValueError).
"""
contour = _transform_ring(expoly.contour.points(), cos_t, sin_t, s, cx, cy, ox)
if contour is None:
return None
holes = []
for hole in expoly.holes:
th = _transform_ring(hole.points(), cos_t, sin_t, s, cx, cy, ox)
if th is not None:
holes.append(th)
return [contour, holes]
class Twistify(orca.slicing.SlicingPipelineCapabilityBase): class Twistify(orca.slicing.SlicingPipelineCapabilityBase):
@@ -155,27 +81,27 @@ class Twistify(orca.slicing.SlicingPipelineCapabilityBase):
return "Twistify" return "Twistify"
def execute(self, ctx): 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: if ctx.step != orca.slicing.Step.Slice or ctx.object is None:
return orca.ExecutionResult.success() return orca.ExecutionResult.success()
p = _params(ctx) p = _params(ctx)
# Exact no-op parameters -> leave the pipeline byte-identical by construction.
if _is_identity(p): if _is_identity(p):
return orca.ExecutionResult.success("Twistify: identity parameters, nothing to do") 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) mm_to_scaled = 1.0 / orca.slicing.unscale(1)
layers = ctx.object.layers() layers = ctx.object.layers()
if not layers: if not layers:
return orca.ExecutionResult.success("Twistify: object has no layers") return orca.ExecutionResult.success("Twistify: object has no layers")
# Twist axis = the object's bounding-box center (scaled coords, same frame as the # Twist/taper axis = the object's bounding-box center (scaled coords, same frame
# slice polygons), so each object on the plate twists about its own center. # 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() min_x, min_y, max_x, max_y = ctx.object.bounding_box()
cx = (min_x + max_x) / 2.0 cx = (min_x + max_x) / 2.0
cy = (min_y + max_y) / 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 z0 = float(layers[0].print_z) # z_rel = 0 on the first layer -> footprint untouched
layers_touched = 0 layers_touched = 0
@@ -183,32 +109,29 @@ class Twistify(orca.slicing.SlicingPipelineCapabilityBase):
if ctx.cancelled(): if ctx.cancelled():
break break
z_rel = float(layer.print_z) - z0 z_rel = float(layer.print_z) - z0
cos_t, sin_t, s, ox = _layer_params(z_rel, mm_to_scaled, p) theta, 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: if theta == 0.0 and s == 1.0 and ox == 0.0:
continue # exact identity (always the first layer): skip set_slices entirely continue # exact identity (always the first layer)
edited = False
for region in layer.regions(): for region in layer.regions():
surfaces = region.slices.surfaces for surface in region.slices.surfaces:
if not surfaces: ex = surface.expolygon
continue # set_slices() rejects nothing now, but an empty region has nothing to do ex.rotate(theta, center) # rotate about the object center (in place)
new_surfaces = [] if s != 1.0:
for surface in surfaces: # scale() scales about the coordinate ORIGIN, so re-center the
entry = _transform_expoly(surface.expolygon, cos_t, sin_t, s, cx, cy, ox) # geometry on the origin first and translate back after, making
if entry is None: # this a true similarity transform about the object's center.
continue # degenerate outer contour: drop this surface ex.translate(-cx, -cy)
# Carry this surface's type as the third entry element so it is preserved ex.scale(s)
# per surface. The plain enum value is read out BEFORE set_slices, since the ex.translate(cx, cy)
# Surface reference dangles once the collection is replaced. if ox != 0.0:
entry.append(surface.surface_type) ex.translate(ox, 0.0) # wobble in X
new_surfaces.append(entry) edited = True
if not new_surfaces: if edited:
continue # every surface degenerate: leave the region untouched # Re-derive the merged islands from the twisted region slices.
# refresh_lslices=True re-derives the layer's merged islands + bbox cache from layer.make_slices()
# the twisted slices, so overhang/bridge detection and brim/skirt/support stay layers_touched += 1
# coherent -- no separate Layer.set_lslices() pass needed.
region.set_slices(new_surfaces, refresh_lslices=True)
layers_touched += 1
name = ctx.object.model_object().name or "object" name = ctx.object.model_object().name or "object"
return orca.ExecutionResult.success( return orca.ExecutionResult.success(

View File

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

View File

@@ -3,6 +3,7 @@
#include "libslic3r/libslic3r.h" // unscale<>, scale_ #include "libslic3r/libslic3r.h" // unscale<>, scale_
#include "libslic3r/BoundingBox.hpp" #include "libslic3r/BoundingBox.hpp"
#include "libslic3r/ClipperUtils.hpp" // offset/offset_ex/union_ex/diff_ex/intersection_ex
#include "libslic3r/ExPolygon.hpp" #include "libslic3r/ExPolygon.hpp"
#include "libslic3r/Surface.hpp" #include "libslic3r/Surface.hpp"
#include "libslic3r/SurfaceCollection.hpp" #include "libslic3r/SurfaceCollection.hpp"
@@ -13,7 +14,6 @@
#include <pybind11/stl.h> #include <pybind11/stl.h>
#include <memory> #include <memory>
#include <optional>
#include <vector> #include <vector>
namespace py = pybind11; namespace py = pybind11;
@@ -51,87 +51,14 @@ static Polygon parse_polygon(py::handle h, const char* who)
return poly; return poly;
} }
// One Python entry -> ExPolygon. Accepts a bare (N,2) ndarray (contour only), a // Accept a bound orca.host.Polygon (copied) or an (N,2) int64 ndarray. Used by the ExPolygon
// [contour, [hole, ...]] sequence, or (G9) a [contour, [hole, ...], SurfaceType] triple whose // binding, whose constructor/contour-setter/set_holes must accept the Polygon it itself hands
// third element overrides the surface type for set_slices/set_fill_surfaces. When `out_type` is // out (e.g. `ExPolygon(some_polygon_ref)`) in addition to the ndarray-only parse_polygon() path.
// null (geometry-only consumers such as set_lslices) any third element is ignored. Orientation static Polygon as_polygon(py::handle h, const char* who)
// is normalized (contour CCW, holes CW) so downstream area/offset math is correct regardless of
// the caller's winding.
static ExPolygon parse_expolygon(py::handle entry, const char* who,
std::optional<SurfaceType>* out_type = nullptr)
{ {
ExPolygon ex; if (py::isinstance<Polygon>(h))
if (py::isinstance<py::array>(entry)) { return h.cast<Polygon>();
ex.contour = parse_polygon(entry, who); return parse_polygon(h, who);
} else if (py::isinstance<py::sequence>(entry) && !py::isinstance<py::str>(entry)) {
py::sequence seq = py::reinterpret_borrow<py::sequence>(entry);
if (py::len(seq) < 1)
throw py::value_error(std::string(who) + ": a [contour, holes] entry needs a contour");
ex.contour = parse_polygon(seq[0], who);
if (py::len(seq) >= 2) {
// Type-check the holes element up front: a non-sequence (e.g. an int) would otherwise
// reach reinterpret_borrow<py::sequence> and raise a bare Python TypeError on iteration,
// whereas the API contract is ValueError for malformed input (str is excluded because it
// is iterable but never a valid holes container).
py::object holes_obj = seq[1];
if (!py::isinstance<py::sequence>(holes_obj) || py::isinstance<py::str>(holes_obj))
throw py::value_error(std::string(who) + ": the holes element must be a list of (N,2) int64 ndarrays");
for (py::handle hh : py::reinterpret_borrow<py::sequence>(holes_obj)) {
Polygon hole = parse_polygon(hh, who);
hole.make_clockwise();
ex.holes.emplace_back(std::move(hole));
}
}
// G9: optional third element -> per-surface SurfaceType override (None keeps the
// carried-forward type). A wrong type raises ValueError, matching the API contract.
if (out_type != nullptr && py::len(seq) >= 3) {
py::object t = seq[2];
if (!t.is_none()) {
try { *out_type = t.cast<SurfaceType>(); }
catch (const py::cast_error&) {
throw py::value_error(std::string(who) + ": the third entry element must be an orca.host.SurfaceType");
}
}
}
} else {
throw py::value_error(std::string(who) + ": each entry must be an (N,2) ndarray or a [contour, holes] pair");
}
ex.contour.make_counter_clockwise();
return ex;
}
// A Python list of entries -> ExPolygons (each entry parsed + oriented). G7: an empty list is
// legal and means "no geometry" (clears the target collection). Per-entry types are ignored
// here (geometry-only consumers such as set_lslices).
static ExPolygons parse_expolygon_list(py::handle list_h, const char* who)
{
if (!py::isinstance<py::sequence>(list_h) || py::isinstance<py::str>(list_h))
throw py::value_error(std::string(who) + ": expected a list of polygons");
ExPolygons out;
for (py::handle entry : py::reinterpret_borrow<py::sequence>(list_h))
out.emplace_back(parse_expolygon(entry, who));
return out;
}
// Build Surfaces from a Python list, carrying surface_type (and the other per-surface
// attributes) forward from the collection being replaced, or defaulting to stInternal when the
// region had none. G9: a per-entry SurfaceType (optional third element) overrides that default.
// G7: an empty list is legal and yields an empty Surfaces (clears the collection).
static Surfaces surfaces_from_py(py::handle list_h, const SurfaceCollection& replaced, const char* who)
{
if (!py::isinstance<py::sequence>(list_h) || py::isinstance<py::str>(list_h))
throw py::value_error(std::string(who) + ": expected a list of polygons");
const Surface tmpl = replaced.surfaces.empty() ? Surface(stInternal) : replaced.surfaces.front();
Surfaces out;
for (py::handle entry : py::reinterpret_borrow<py::sequence>(list_h)) {
std::optional<SurfaceType> type;
ExPolygon e = parse_expolygon(entry, who, &type);
Surface s(tmpl, std::move(e));
if (type)
s.surface_type = *type;
out.emplace_back(std::move(s));
}
return out;
} }
// Flatten an extrusion graph into a list of leaf ExtrusionPath* while walking the // 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<const
out.push_back(path); 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 } // namespace
void PluginHostSlicing::RegisterBindings(py::module_& host) void PluginHostSlicing::RegisterBindings(py::module_& host)
@@ -177,10 +115,10 @@ void PluginHostSlicing::RegisterBindings(py::module_& host)
// out below is a non-owning reference into the live slicing graph owned by // 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 // the Print. References — and every numpy view they hand out — are valid
// only while the plugin hook (execute(ctx)) runs, and a container-replacing // only while the plugin hook (execute(ctx)) runs, and a container-replacing
// mutator (LayerRegion.set_slices / set_fill_surfaces, Layer.set_lslices) // mutator (SurfaceCollection.set / append / clear, Polygon.set_points / append,
// invalidates previously obtained references into that container, exactly // ExPolygon.set_holes) invalidates previously obtained references into that
// as std::vector operations invalidate C++ iterators. Do not stash // container, exactly as std::vector operations invalidate C++ iterators. Do
// references or arrays across execute() calls; copy what you need. // not stash references or arrays across execute() calls; copy what you need.
// ------------------------------------------------------------------ // ------------------------------------------------------------------
py::enum_<SurfaceType>(host, "SurfaceType") py::enum_<SurfaceType>(host, "SurfaceType")
@@ -197,52 +135,203 @@ void PluginHostSlicing::RegisterBindings(py::module_& host)
.value("stCount", stCount) .value("stCount", stCount)
.export_values(); .export_values();
py::class_<Polygon, std::unique_ptr<Polygon, py::nodelete>>(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_<Point>(host, "Point")
.def(py::init([](coord_t x, coord_t y) { return Point(x, y); }), py::arg("x"), py::arg("y"))
.def_property("x", [](const Point& p) { return p.x(); },
[](Point& p, coord_t v) { p.x() = v; })
.def_property("y", [](const Point& p) { return p.y(); },
[](Point& p, coord_t v) { p.y() = v; })
.def("__add__", [](const Point& a, const Point& b) { return Point(a + b); }, py::is_operator())
.def("__sub__", [](const Point& a, const Point& b) { return Point(a - b); }, py::is_operator())
.def("__mul__", [](const Point& a, double s) { return Point(a.x() * s, a.y() * s); }, py::is_operator())
.def("__repr__", [](const Point& p) {
return "orca.host.Point(" + std::to_string(p.x()) + ", " + std::to_string(p.y()) + ")";
});
py::class_<Polygon>(host, "Polygon")
.def(py::init<>())
.def("size", [](const Polygon& p) { return p.points.size(); }) .def("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_counter_clockwise", [](const Polygon& p) { return p.is_counter_clockwise(); })
.def("points", [](py::object self) { .def("is_clockwise", [](const Polygon& p) { return p.is_clockwise(); })
const Polygon& p = self.cast<const Polygon&>(); .def("make_counter_clockwise", [](Polygon& p) { return p.make_counter_clockwise(); },
"Reorient to CCW in place. Returns True if it reversed the winding.")
.def("make_clockwise", [](Polygon& p) { return p.make_clockwise(); })
.def("area", [](const Polygon& p) { return p.area(); })
.def("centroid", [](const Polygon& p) { return p.centroid(); })
.def("contains", [](const Polygon& p, const Point& pt) { return p.contains(pt); }, py::arg("point"))
.def("translate", [](Polygon& p, double x, double y) { p.translate(x, y); }, py::arg("x"), py::arg("y"))
.def("rotate", [](Polygon& p, double angle) { p.rotate(angle); }, py::arg("angle"))
.def("rotate", [](Polygon& p, double angle, const Point& c) { p.rotate(angle, c); },
py::arg("angle"), py::arg("center"))
.def("douglas_peucker", [](Polygon& p, double tol) { p.douglas_peucker(tol); }, py::arg("tolerance"))
.def("simplify", [](const Polygon& p, double tol) { return p.simplify(tol); }, py::arg("tolerance"),
"Return simplified geometry as a list of Polygon (may split into several).")
.def("offset", [](const Polygon& p, coord_t delta) { return offset(p, (float) delta); }, py::arg("delta"),
"Clipper offset by `delta` scaled units (negative shrinks). Returns [Polygon].")
// --- Point-object idiom: references into the buffer (in-place element edit). ---
.def_property_readonly("points", [](py::object self) {
Polygon& p = self.cast<Polygon&>();
py::list out;
for (Point& pt : p.points)
out.append(py::cast(&pt, py::return_value_policy::reference_internal, self));
return out;
}, "Vertices as [Point] references into this polygon. Editing a Point mutates the "
"buffer in place. Structural changes (count) go through set_points/append, which "
"invalidate previously returned Point refs and array views (C++ vector semantics).")
.def("append", [](Polygon& p, const Point& pt) { p.points.push_back(pt); }, py::arg("point"),
"Append a vertex. Structural change (count): invalidates previously returned "
"Point refs and array views into this polygon (C++ vector semantics).")
// --- numpy idiom: writable zero-copy (N,2) view (bulk affine edits). ---
.def("as_array", [](py::object self) {
Polygon& p = self.cast<Polygon&>();
return with_numpy([&] { return with_numpy([&] {
return py::object(make_readonly_rows<coord_t, 2>( return py::object(make_writable_rows<coord_t, 2>(
self, p.points.empty() ? nullptr : p.points.front().data(), self, p.points.empty() ? nullptr : p.points.front().data(),
(py::ssize_t) p.points.size())); (py::ssize_t) p.points.size()));
}); });
}, "Vertices as a read-only int64 (N,2) numpy view in scaled coords. " }, "Vertices as a WRITABLE int64 (N,2) numpy view in scaled coords, aliasing the "
"Valid only during the execute(ctx) call. Requires numpy."); "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_<ExPolygon, std::unique_ptr<ExPolygon, py::nodelete>>(host, "ExPolygon") // ExPolygon: default holder (Python-owned instances are freed) so plugins can construct
.def_property_readonly("contour", [](ExPolygon& e) -> Polygon& { return e.contour; }, // 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_<ExPolygon>(host, "ExPolygon")
.def(py::init([](py::handle contour, py::handle holes) {
// Accept bound Polygons or (N,2) ndarrays for both contour and each hole.
ExPolygon ex;
ex.contour = as_polygon(contour, "ExPolygon.contour");
if (!holes.is_none()) {
if (!py::isinstance<py::sequence>(holes) || py::isinstance<py::str>(holes))
throw py::value_error("ExPolygon: holes must be a list of Polygon or (N,2) ndarrays");
for (py::handle h : py::reinterpret_borrow<py::sequence>(holes)) {
Polygon hole = as_polygon(h, "ExPolygon.hole");
hole.make_clockwise();
ex.holes.emplace_back(std::move(hole));
}
}
ex.contour.make_counter_clockwise();
return ex;
}), py::arg("contour"), py::arg("holes") = py::none(),
"Construct from a Polygon/ndarray contour and optional list of hole Polygons/ndarrays. "
"Orientation is normalized (contour CCW, holes CW).")
.def_property("contour",
[](ExPolygon& e) -> Polygon& { return e.contour; },
[](ExPolygon& e, py::handle v) { e.contour = as_polygon(v, "ExPolygon.contour"); },
py::return_value_policy::reference_internal, 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) { .def_property_readonly("holes", [](py::object self) {
ExPolygon& e = self.cast<ExPolygon&>(); ExPolygon& e = self.cast<ExPolygon&>();
py::list out; py::list out;
for (Polygon& h : e.holes) for (Polygon& h : e.holes)
out.append(py::cast(&h, py::return_value_policy::reference_internal, self)); out.append(py::cast(&h, py::return_value_policy::reference_internal, self));
return out; 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<py::sequence>(holes) || py::isinstance<py::str>(holes))
throw py::value_error("set_holes: expected a list of Polygon or (N,2) ndarrays");
for (py::handle h : py::reinterpret_borrow<py::sequence>(holes)) {
Polygon hole = as_polygon(h, "ExPolygon.set_holes");
hole.make_clockwise();
tmp.holes.emplace_back(std::move(hole));
}
e.holes = std::move(tmp.holes);
}, py::arg("holes"), "Replace all holes. Invalidates prior hole refs (C++ vector semantics).")
.def("translate", [](ExPolygon& e, double x, double y) { e.translate(x, y); }, py::arg("x"), py::arg("y"))
.def("rotate", [](ExPolygon& e, double a) { e.rotate(a); }, py::arg("angle"))
.def("rotate", [](ExPolygon& e, double a, const Point& c) { e.rotate(a, c); },
py::arg("angle"), py::arg("center"))
.def("scale", [](ExPolygon& e, double f) { e.scale(f); }, py::arg("factor"))
.def("douglas_peucker", [](ExPolygon& e, double t) { e.douglas_peucker(t); }, py::arg("tolerance"))
.def("area", [](const ExPolygon& e) { return e.area(); })
.def("is_valid", [](const ExPolygon& e) { return e.is_valid(); })
.def("contains", [](const ExPolygon& e, const Point& p) { return e.contains(p); }, py::arg("point"))
.def("num_contours", [](const ExPolygon& e) { return e.num_contours(); })
.def("simplify", [](const ExPolygon& e, double t) { return e.simplify(t); }, py::arg("tolerance"),
"Return simplified geometry as [ExPolygon].")
.def("offset", [](const ExPolygon& e, coord_t delta) { return offset_ex(e, (float) delta); },
py::arg("delta"), "Clipper offset by `delta` scaled units (negative shrinks). Returns [ExPolygon].")
.def("union_ex", [](const ExPolygon& a, const ExPolygon& b) {
return union_ex(ExPolygons{ a, b });
}, py::arg("other"), "Union with another ExPolygon. Returns [ExPolygon].")
.def("diff_ex", [](const ExPolygon& a, const ExPolygon& b) {
return diff_ex(ExPolygons{ a }, ExPolygons{ b });
}, py::arg("other"), "This minus `other`. Returns [ExPolygon].")
.def("intersection_ex", [](const ExPolygon& a, const ExPolygon& b) {
return intersection_ex(ExPolygons{ a }, ExPolygons{ b });
}, py::arg("other"), "Intersection with `other`. Returns [ExPolygon].");
py::class_<Surface, std::unique_ptr<Surface, py::nodelete>>(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_<Surface>(host, "Surface")
.def(py::init([](SurfaceType t, const ExPolygon& e) { return Surface(t, e); }),
py::arg("surface_type"), py::arg("expolygon"))
.def(py::init([](SurfaceType t) { return Surface(t); }), py::arg("surface_type"))
.def_readwrite("surface_type", &Surface::surface_type, .def_readwrite("surface_type", &Surface::surface_type,
"This surface's SurfaceType. Writable: assigning reclassifies the " "This surface's SurfaceType. Assigning reclassifies it in place (geometry unchanged).")
"surface in place on the live slicing graph (geometry unchanged).") .def_readwrite("thickness", &Surface::thickness)
.def_readonly("thickness", &Surface::thickness) .def_readwrite("bridge_angle", &Surface::bridge_angle)
.def_readonly("bridge_angle", &Surface::bridge_angle) .def_readwrite("extra_perimeters", &Surface::extra_perimeters)
.def_readonly("extra_perimeters", &Surface::extra_perimeters) .def_property("expolygon",
.def_property_readonly("expolygon", [](Surface& s) -> ExPolygon& { return s.expolygon; }, [](Surface& s) -> ExPolygon& { return s.expolygon; },
[](Surface& s, const ExPolygon& e) { s.expolygon = e; },
py::return_value_policy::reference_internal, 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_<SurfaceCollection, std::unique_ptr<SurfaceCollection, py::nodelete>>(host, "SurfaceCollection") py::class_<SurfaceCollection, std::unique_ptr<SurfaceCollection, py::nodelete>>(host, "SurfaceCollection")
.def("size", [](const SurfaceCollection& c) { return c.surfaces.size(); }) .def("size", [](const SurfaceCollection& c) { return c.surfaces.size(); })
.def("empty", [](const SurfaceCollection& c) { return c.empty(); })
.def("clear", [](SurfaceCollection& c) { c.clear(); })
.def("has", [](const SurfaceCollection& c, SurfaceType t) { return c.has(t); }, py::arg("surface_type"))
.def("set_type", [](SurfaceCollection& c, SurfaceType t) { c.set_type(t); }, py::arg("surface_type"))
.def("set", [](SurfaceCollection& c, const std::vector<ExPolygon>& src, SurfaceType t) { c.set(src, t); },
py::arg("expolygons"), py::arg("surface_type"),
"Replace all surfaces from a list of ExPolygon, all tagged `surface_type`. "
"This is the faithful replacement for the retired set_slices().")
.def("set", [](SurfaceCollection& c, const std::vector<Surface>& src) { c.set(src); },
py::arg("surfaces"), "Replace all surfaces from a list of Surface (types preserved per surface).")
.def("append", [](SurfaceCollection& c, const std::vector<ExPolygon>& src, SurfaceType t) { c.append(src, t); },
py::arg("expolygons"), py::arg("surface_type"))
.def("filter_by_type", [](py::object self, SurfaceType t) {
SurfaceCollection& c = self.cast<SurfaceCollection&>();
py::list out;
// SurfacesPtr (SurfaceCollection::filter_by_type's return type) is
// std::vector<const Surface*> (see Surface.hpp); the brief's note describing it
// as std::vector<Surface*> 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) { .def_property_readonly("surfaces", [](py::object self) {
SurfaceCollection& c = self.cast<SurfaceCollection&>(); SurfaceCollection& c = self.cast<SurfaceCollection&>();
py::list out; py::list out;
for (Surface& s : c.surfaces) for (Surface& s : c.surfaces)
out.append(py::cast(&s, py::return_value_policy::reference_internal, self)); out.append(py::cast(&s, py::return_value_policy::reference_internal, self));
return out; return out;
}, "Surfaces as [Surface] references into the live collection. Invalidated " }, "Surfaces as [Surface] references into the live collection. Invalidated by "
"by set_slices/set_fill_surfaces on the owning region (C++ vector semantics)."); "set()/append()/clear() on this collection (C++ vector semantics).");
// --- Extrusion tree (read-only in v1). Registered polymorphically: when a returned // --- Extrusion tree (read-only in v1). Registered polymorphically: when a returned
// ExtrusionEntity*'s dynamic type IS one of the classes registered below, pybind // 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_<LayerRegion, std::unique_ptr<LayerRegion, py::nodelete>>(host, "LayerRegion"); auto layer_region = py::class_<LayerRegion, std::unique_ptr<LayerRegion, py::nodelete>>(host, "LayerRegion");
layer_region layer_region
.def_readonly("slices", &LayerRegion::slices, .def_readonly("slices", &LayerRegion::slices,
"Sliced, typed surfaces (SurfaceCollection). At Step.Slice this is the " "Sliced, typed surfaces (SurfaceCollection). Edit in place, or replace with "
"primary mutation target via set_slices().") "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, .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, .def_readonly("perimeters", &LayerRegion::perimeters,
"Perimeter toolpaths (ExtrusionEntityCollection).") "Perimeter toolpaths (ExtrusionEntityCollection, read-only in v1).")
.def_readonly("fills", &LayerRegion::fills, .def_readonly("fills", &LayerRegion::fills,
"Infill toolpaths (ExtrusionEntityCollection).") "Infill toolpaths (ExtrusionEntityCollection, read-only in v1).")
.def("layer", [](LayerRegion& r) -> py::object { .def("layer", [](LayerRegion& r) -> py::object {
Layer* l = r.layer(); Layer* l = r.layer();
if (l == nullptr) if (l == nullptr)
@@ -344,58 +434,7 @@ void PluginHostSlicing::RegisterBindings(py::module_& host)
.def("config_value", [](const LayerRegion& r, const std::string& key) { .def("config_value", [](const LayerRegion& r, const std::string& key) {
return config_value_or_none(r.region().config(), key); return config_value_or_none(r.region().config(), key);
}, py::arg("key"), }, py::arg("key"),
"Serialized value of this region's resolved config option, or None if absent.") "Serialized value of this region's resolved config option, or None if absent.");
// MUTATOR (G1/G3/G9). Replace this region's sliced surfaces. `polygons` is a list of
// (N,2) int64 ndarrays (scaled coords), [contour, [holes...]] pairs, or (G9)
// [contour, [holes...], SurfaceType] triples; orientation is normalized (contour CCW,
// holes CW) and surface_type is carried forward from the replaced surfaces (else
// stInternal) unless a per-entry type is given.
.def("set_slices", [](LayerRegion& region, py::object polygons, bool refresh_lslices) {
region.slices.set(surfaces_from_py(polygons, region.slices, "set_slices"));
// G1: rebuild the owning layer's merged islands (lslices) + bbox cache from the
// mutated region slices so downstream consumers (detect_surfaces_type neighbor
// diffs, overhang/bridge detection, brim/skirt/support) see coherent islands.
// Skipped when the region has no owning layer (unit-test regions).
if (refresh_lslices) {
if (Layer* layer = region.layer()) {
layer->make_slices();
layer->lslices_bboxes.clear();
layer->lslices_bboxes.reserve(layer->lslices.size());
for (const ExPolygon& island : layer->lslices)
layer->lslices_bboxes.emplace_back(get_extents(island));
}
}
}, py::arg("polygons"), py::arg("refresh_lslices") = true,
"Replace this region's sliced surfaces from a list of (N,2) int64 ndarrays (scaled "
"coords), [contour, [holes...]] pairs, or [contour, [holes...], SurfaceType] triples "
"(orientation normalized: contour CCW / holes CW; surface_type carried forward from the "
"replaced surfaces, else stInternal, unless a per-entry SurfaceType is supplied). An "
"empty list clears this region's slices.\n"
"MUTATION-CASCADE: at the Slice boundary this is the primary, fully-supported entry "
"point -- the split slice loop runs make_perimeters() afterward, so the change cascades "
"into perimeters and everything downstream (final G-code).\n"
"LSLICES (G1): refresh_lslices=True (default) re-derives the owning layer's merged "
"islands and bbox cache from the new slices so overhang/bridge/skirt/support stay "
"coherent; pass False only if you manage lslices yourself via Layer.set_lslices.\n"
"PERSISTENCE (G3): the Slice hook re-snapshots raw_slices after it returns, so the "
"mutation survives a later perimeter-only re-run (restore_untyped_slices) instead of "
"silently reverting; it still does not persist across a full re-slice unless the hook "
"re-fires (re-select the plugin, or any posSlice-invalidating change).\n"
"DUPLICATES: identical objects share Layer*, so the mutation on the object that slices "
"is automatically seen by its duplicates; objects that must mutate independently must "
"not be identical.\n"
"Raises ValueError on malformed input. Valid only during the execute(ctx) call.")
// MUTATOR. Replace this region's fill (infill-prep) surfaces; identical input format and
// validation to set_slices.
.def("set_fill_surfaces", [](LayerRegion& region, py::object polygons) {
region.fill_surfaces.set(surfaces_from_py(polygons, region.fill_surfaces, "set_fill_surfaces"));
}, py::arg("polygons"),
"Replace this region's fill (infill-prep) surfaces; same input format/validation as "
"set_slices (per-entry SurfaceType supported; an empty list clears them).\n"
"MUTATION-CASCADE: at the PrepareInfill boundary (G4) make_fills runs afterward, so this "
"cascades into the generated infill. At the Infill boundary it changes the stored "
"surfaces but does NOT regenerate the already-built `fills` toolpaths (v1).\n"
"Raises ValueError on malformed input. Valid only during the execute(ctx) call.");
auto layer = py::class_<Layer, std::unique_ptr<Layer, py::nodelete>>(host, "Layer"); auto layer = py::class_<Layer, std::unique_ptr<Layer, py::nodelete>>(host, "Layer");
layer layer
@@ -417,38 +456,19 @@ void PluginHostSlicing::RegisterBindings(py::module_& host)
out.append(py::cast(r, py::return_value_policy::reference_internal, self)); out.append(py::cast(r, py::return_value_policy::reference_internal, self));
return out; return out;
}, "Per-region data as [LayerRegion].") }, "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) { .def("lslices", [](py::object self) {
Layer& l = self.cast<Layer&>(); Layer& l = self.cast<Layer&>();
py::list out; py::list out;
for (ExPolygon& e : l.lslices) for (ExPolygon& e : l.lslices)
out.append(py::cast(&e, py::return_value_policy::reference_internal, self)); out.append(py::cast(&e, py::return_value_policy::reference_internal, self));
return out; return out;
}, "Merged per-layer islands as [ExPolygon] references. Invalidated by " }, "Merged per-layer islands as [ExPolygon] refs (in-place editable). Derived from the "
"set_lslices/make_slices (C++ vector semantics).") "region slices; call make_slices() to re-derive after edits. Invalidated by make_slices().");
.def("make_slices", [](Layer& l) {
l.make_slices();
l.lslices_bboxes.clear();
l.lslices_bboxes.reserve(l.lslices.size());
for (const ExPolygon& island : l.lslices)
l.lslices_bboxes.emplace_back(get_extents(island));
}, "Re-derive lslices (merged islands) from the region slices and refresh the "
"bbox cache — the C++ invariant-maintenance call after in-place geometry edits. "
"set_slices(refresh_lslices=True) runs this for you.")
// MUTATOR. Replace this layer's merged islands (lslices) and refresh the cache-invariant
// `lslices_bboxes` (one BoundingBox per island via get_extents). Same input format and
// validation as LayerRegion.set_slices.
.def("set_lslices", [](Layer& l, py::object islands) {
l.lslices = parse_expolygon_list(islands, "set_lslices");
l.lslices_bboxes.clear();
l.lslices_bboxes.reserve(l.lslices.size());
for (const ExPolygon& island : l.lslices)
l.lslices_bboxes.emplace_back(get_extents(island));
}, py::arg("islands"),
"Replace this layer's merged islands (lslices) from a list of (N,2) int64 ndarrays "
"(scaled coords) or [contour, [holes...]] pairs, and refresh lslices_bboxes (one "
"bounding box per island via get_extents) so the bbox cache stays consistent. Same "
"input format/validation as LayerRegion.set_slices. Raises ValueError on malformed "
"input. Valid only during the execute(ctx) call.");
py::class_<PrintObject, std::unique_ptr<PrintObject, py::nodelete>>(host, "PrintObject") py::class_<PrintObject, std::unique_ptr<PrintObject, py::nodelete>>(host, "PrintObject")
.def("id", [](const PrintObject& o) { return o.id().id; }, .def("id", [](const PrintObject& o) { return o.id().id; },

View File

@@ -17,8 +17,8 @@ void SlicingPipelinePluginCapability::RegisterBindings(py::module_& module, py::
.value("Slice", SlicingPipelineStep::Slice) .value("Slice", SlicingPipelineStep::Slice)
.value("Perimeters", SlicingPipelineStep::Perimeters) .value("Perimeters", SlicingPipelineStep::Perimeters)
.value("EstimateCurledExtrusions", SlicingPipelineStep::EstimateCurledExtrusions) .value("EstimateCurledExtrusions", SlicingPipelineStep::EstimateCurledExtrusions)
.value("PrepareInfill", SlicingPipelineStep::PrepareInfill) // after prepare_infill, before make_fills: set_fill_surfaces here CASCADES .value("PrepareInfill", SlicingPipelineStep::PrepareInfill) // after prepare_infill, before make_fills: editing fill_surfaces here CASCADES
.value("Infill", SlicingPipelineStep::Infill) // after make_fills: set_fill_surfaces here does NOT regenerate fills (v1) .value("Infill", SlicingPipelineStep::Infill) // after make_fills: editing fill_surfaces here does NOT regenerate fills (v1)
.value("Ironing", SlicingPipelineStep::Ironing) .value("Ironing", SlicingPipelineStep::Ironing)
.value("Contouring", SlicingPipelineStep::Contouring) .value("Contouring", SlicingPipelineStep::Contouring)
.value("SupportMaterial", SlicingPipelineStep::SupportMaterial) .value("SupportMaterial", SlicingPipelineStep::SupportMaterial)

View File

@@ -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 // §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 // 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 // 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: // process() confirm the approach:
// (1) a pure rotation is a similarity with scale 1, so total fill area is preserved, and // (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 // (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 // §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 // 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) // 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 // 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 // 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) // make_perimeters, so mutating region slices leaves them stale. The slices.set() + Layer::make_slices()
// re-derives them via Layer::make_slices(); this C++ analogue proves the mechanism -- without the // 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. // 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]") { TEST_CASE("refreshing lslices after a slice mutation makes islands track the geometry", "[slicing_pipeline]") {
auto lslices_width = [](bool refresh) { 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)); 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(); l->make_slices();
} }
}); });

View File

@@ -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); CHECK(r(0,0) == 10); CHECK(r(1,1) == 40);
} }
TEST_CASE("make_writable_rows builds a writable (N,2) int64 view that aliases the buffer", "[slicing_pipeline]") {
ensure_python_initialized();
py::gil_scoped_acquire gil;
bool have_numpy = false;
try { py::module_::import("numpy"); have_numpy = true; }
catch (const py::error_already_set&) { have_numpy = false; }
if (!have_numpy) SKIP("numpy unavailable in unit-test interpreter");
static Slic3r::Points pts = { Slic3r::Point(10, 20), Slic3r::Point(30, 40) };
py::capsule keepalive(&pts, [](void*){});
py::array a = Slic3r::make_writable_rows<coord_t, 2>(keepalive, pts.front().data(), (py::ssize_t)pts.size());
CHECK(a.writeable());
// Writing through the view mutates the C++ buffer (zero-copy alias).
a.attr("__setitem__")(py::make_tuple(0, 0), py::int_(99));
CHECK(pts.front().x() == 99);
}
TEST_CASE("orca.slicing module: Step enum, context, and a Python capability can execute", "[slicing_pipeline]") { TEST_CASE("orca.slicing module: Step enum, context, and a Python capability can execute", "[slicing_pipeline]") {
ensure_python_initialized(); ensure_python_initialized();
import_orca_module(); // forces PythonPluginBridge::instance() (see test_plugin_host_api.cpp:32-40) 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<size_t>() == 0); CHECK(exv.attr("contour").attr("size")().cast<size_t>() == 0);
} }
TEST_CASE("orca.host Polygon.points() is a read-only int64 (N,2) view in scaled coords", "[slicing_pipeline]") { TEST_CASE("orca.host Surface/SurfaceCollection: construct, writable members, set()", "[slicing_pipeline]") {
using Catch::Matchers::WithinRel;
ensure_python_initialized(); ensure_python_initialized();
import_orca_module(); import_orca_module();
py::gil_scoped_acquire gil; py::gil_scoped_acquire gil;
py::object host = py::module_::import("orca").attr("host");
py::object ST = host.attr("SurfaceType");
const coord_t s = (coord_t) scale_(10.0);
// Build an ExPolygon (Point idiom) and a Surface from it.
py::object P = host.attr("Polygon")();
P.attr("append")(host.attr("Point")(0, 0));
P.attr("append")(host.attr("Point")(s, 0));
P.attr("append")(host.attr("Point")(s, s));
P.attr("append")(host.attr("Point")(0, s));
py::object ex = host.attr("ExPolygon")(P);
py::object surf = host.attr("Surface")(ST.attr("stTop"), ex);
CHECK(surf.attr("surface_type").cast<Slic3r::SurfaceType>() == Slic3r::stTop);
CHECK(surf.attr("is_top")().cast<bool>());
CHECK_THAT(surf.attr("area")().cast<double>(), WithinRel((double) s * (double) s, 1e-9));
surf.attr("thickness") = py::float_(0.3);
CHECK_THAT(surf.attr("thickness").cast<double>(), WithinRel(0.3, 1e-9));
// SurfaceCollection.set(expolys, type) — 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<bool>());
cv.attr("clear")();
CHECK(coll.surfaces.empty());
}
TEST_CASE("orca.host Point: construct, read/write coords, arithmetic", "[slicing_pipeline]") {
ensure_python_initialized();
import_orca_module();
py::gil_scoped_acquire gil;
py::object host = py::module_::import("orca").attr("host");
REQUIRE(py::hasattr(host, "Point"));
py::object p = host.attr("Point")(3, 4);
CHECK(p.attr("x").cast<coord_t>() == 3);
CHECK(p.attr("y").cast<coord_t>() == 4);
p.attr("x") = py::int_(7);
CHECK(p.attr("x").cast<coord_t>() == 7);
py::object q = host.attr("Point")(1, 2);
py::object sum = p.attr("__add__")(q);
CHECK(sum.attr("x").cast<coord_t>() == 8);
CHECK(sum.attr("y").cast<coord_t>() == 6);
// __mul__ must scale as a double, not truncate to int64 before multiplying.
py::object h = host.attr("Point")(10, 20).attr("__mul__")(py::float_(0.5));
CHECK(h.attr("x").cast<coord_t>() == 5);
CHECK(h.attr("y").cast<coord_t>() == 10);
}
TEST_CASE("orca.host Polygon: writable as_array aliases buffer; Point refs; set_points; offset", "[slicing_pipeline]") {
using Catch::Matchers::WithinRel;
ensure_python_initialized();
import_orca_module();
py::gil_scoped_acquire gil;
py::object host = py::module_::import("orca").attr("host");
const coord_t s = (coord_t) scale_(10.0);
Slic3r::Polygon poly;
poly.points = { Slic3r::Point(0, 0), Slic3r::Point(s, 0), Slic3r::Point(s, s), Slic3r::Point(0, s) };
py::object pv = py::cast(&poly, py::return_value_policy::reference);
// Non-array surface works without numpy.
CHECK(pv.attr("size")().cast<size_t>() == 4);
CHECK(pv.attr("is_counter_clockwise")().cast<bool>());
CHECK_THAT(pv.attr("area")().cast<double>(), WithinRel((double) s * (double) s, 1e-9));
// Point-object idiom: editing a returned Point ref mutates the buffer in place.
py::list pts = pv.attr("points").cast<py::list>();
REQUIRE(pts.size() == 4);
pts[0].attr("x") = py::int_(5);
CHECK(poly.points[0].x() == 5);
poly.points[0].x() = 0; // restore
// offset() returns new geometry (ClipperUtils bound as a method).
py::list shrunk = pv.attr("offset")(py::int_(-(coord_t)scale_(1.0))).cast<py::list>();
CHECK(shrunk.size() >= 1);
bool have_numpy = false; bool have_numpy = false;
try { py::module_::import("numpy"); have_numpy = true; } try { py::module_::import("numpy"); have_numpy = true; }
catch (const py::error_already_set&) { have_numpy = false; } 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<py::array>();
CHECK(a.dtype().kind() == 'i');
CHECK(a.itemsize() == 8);
CHECK(a.shape(0) == 4);
CHECK(a.shape(1) == 2);
CHECK(a.writeable()); // writable now
a.attr("__setitem__")(py::make_tuple(0, 0), py::int_(123));
CHECK(poly.points[0].x() == 123); // in-place bulk edit
// set_points replaces contents (count-changing).
py::object i64 = np.attr("int64");
py::list rows;
rows.append(py::make_tuple(0, 0)); rows.append(py::make_tuple(s, 0)); rows.append(py::make_tuple(s, s));
pv.attr("set_points")(np.attr("array")(rows, py::arg("dtype") = i64));
CHECK(poly.points.size() == 3);
}
TEST_CASE("orca.host ExPolygon: construct, writable contour/holes, transforms, boolean ops", "[slicing_pipeline]") {
using Catch::Matchers::WithinRel;
ensure_python_initialized();
import_orca_module();
py::gil_scoped_acquire gil;
py::object host = py::module_::import("orca").attr("host");
const coord_t s = (coord_t) scale_(10.0); 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); // Construct from Polygon objects (Point idiom, no numpy).
py::array c = view.attr("contour").attr("points")().cast<py::array>(); py::object P = host.attr("Polygon")();
CHECK(c.dtype().kind() == 'i'); P.attr("append")(host.attr("Point")(0, 0));
CHECK(c.itemsize() == 8); // int64 P.attr("append")(host.attr("Point")(s, 0));
CHECK(c.shape(0) == 4); P.attr("append")(host.attr("Point")(s, s));
CHECK(c.shape(1) == 2); P.attr("append")(host.attr("Point")(0, s));
CHECK_FALSE(c.writeable()); py::object ex = host.attr("ExPolygon")(P);
auto rc = c.cast<py::array_t<coord_t>>().unchecked<2>(); CHECK_THAT(ex.attr("area")().cast<double>(), WithinRel((double) s * (double) s, 1e-9));
CHECK(rc(0, 0) == 0); CHECK(ex.attr("num_contours")().cast<size_t>() == 1);
CHECK(rc(1, 0) == s); CHECK(ex.attr("contour").attr("size")().cast<size_t>() == 4);
CHECK(rc(2, 1) == s);
py::list holes = view.attr("holes").cast<py::list>(); // In-place transform mutates the geometry.
REQUIRE(holes.size() == 1); ex.attr("translate")(py::float_(1000.0), py::float_(0.0));
py::array h0 = holes[0].attr("points")().cast<py::array>(); // Boolean op returns new geometry: A minus a smaller inset of A is a non-empty ring set.
CHECK(h0.shape(0) == 3); py::list inset = ex.attr("offset")(py::int_(-(coord_t)scale_(1.0))).cast<py::list>();
CHECK_FALSE(h0.writeable()); REQUIRE(inset.size() >= 1);
py::list ring = ex.attr("diff_ex")(inset[0]).cast<py::list>();
CHECK(ring.size() >= 1);
} }
namespace { namespace {
@@ -354,34 +468,29 @@ TEST_CASE("orca.host graph classes: LayerRegion/Layer raw traversal; Print/Print
CHECK(ly.attr("lower_layer").is_none()); 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(); ensure_python_initialized();
import_orca_module(); import_orca_module();
py::gil_scoped_acquire gil; py::gil_scoped_acquire gil;
py::object host = py::module_::import("orca").attr("host"); 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; TestLayerRegion region;
region.slices.surfaces.emplace_back(Slic3r::Surface(Slic3r::stInternal)); region.slices.surfaces.emplace_back(Slic3r::Surface(Slic3r::stInternal));
py::object lr = py::cast(static_cast<Slic3r::LayerRegion*>(&region), py::object lr = py::cast(static_cast<Slic3r::LayerRegion*>(&region), py::return_value_policy::reference);
py::return_value_policy::reference); lr.attr("slices").attr("clear")();
auto raises_value_error = [](py::object callable, py::object arg) {
try { callable(arg); return false; }
catch (py::error_already_set& e) { return e.matches(PyExc_ValueError); }
};
CHECK(raises_value_error(lr.attr("set_slices"), py::int_(42))); // not a sequence
CHECK(raises_value_error(lr.attr("set_slices"), py::str("nope"))); // string rejected
CHECK(region.slices.surfaces.size() == 1); // failures mutate nothing
// G7: an empty list is legal and clears the region (refresh_lslices defaults True;
// the null owning-layer on this hand-built region exercises the null guard).
lr.attr("set_slices")(py::list());
CHECK(region.slices.surfaces.empty()); 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; using Catch::Matchers::WithinRel;
ensure_python_initialized(); ensure_python_initialized();
import_orca_module(); 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::object host = py::module_::import("orca").attr("host");
py::module_ np = py::module_::import("numpy"); py::module_ np = py::module_::import("numpy");
py::object i64 = np.attr("int64"); py::object i64 = np.attr("int64");
py::object ST = host.attr("SurfaceType");
const coord_t s = (coord_t) scale_(10.0); const coord_t s = (coord_t) scale_(10.0);
auto make_arr = [&](std::initializer_list<std::pair<coord_t,coord_t>> pts) { auto arr = [&](std::initializer_list<std::pair<coord_t,coord_t>> pts) {
py::list rows; py::list rows; for (auto& p : pts) rows.append(py::make_tuple(p.first, p.second));
for (auto& p : pts) rows.append(py::make_tuple(p.first, p.second));
return np.attr("array")(rows, py::arg("dtype") = i64); return np.attr("array")(rows, py::arg("dtype") = i64);
}; };
// set_slices: CW input normalized CCW; surface_type carried forward; readable back raw. // Build an ExPolygon from a CW ndarray; the ctor normalizes to CCW.
py::object ex = host.attr("ExPolygon")(arr({ {0,0}, {0,s}, {s,s}, {s,0} }));
CHECK(ex.attr("contour").attr("is_counter_clockwise")().cast<bool>());
TestLayerRegion region; TestLayerRegion region;
region.slices.surfaces.emplace_back(Slic3r::Surface(Slic3r::stInternalSolid)); py::object lr = py::cast(static_cast<Slic3r::LayerRegion*>(&region), py::return_value_policy::reference);
py::object lr = py::cast(static_cast<Slic3r::LayerRegion*>(&region), py::list expolys; expolys.append(ex);
py::return_value_policy::reference); lr.attr("slices").attr("set")(expolys, ST.attr("stInternalSolid"));
py::list polys;
polys.append(make_arr({ {0,0}, {0,s}, {s,s}, {s,0} })); // clockwise winding
lr.attr("set_slices")(polys);
REQUIRE(region.slices.surfaces.size() == 1); REQUIRE(region.slices.surfaces.size() == 1);
const Slic3r::Surface& out = region.slices.surfaces.front(); const Slic3r::Surface& out = region.slices.surfaces.front();
CHECK(out.surface_type == Slic3r::stInternalSolid); 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)); CHECK_THAT(out.expolygon.area(), WithinRel((double) s * (double) s, 1e-9));
py::list sl = lr.attr("slices").attr("surfaces").cast<py::list>(); // Read geometry back through the class API.
REQUIRE(sl.size() == 1); py::array c = lr.attr("slices").attr("surfaces").cast<py::list>()[0]
py::array c = sl[0].attr("expolygon").attr("contour").attr("points")().cast<py::array>(); .attr("expolygon").attr("contour").attr("as_array")().cast<py::array>();
CHECK(c.shape(0) == 4); CHECK(c.shape(0) == 4);
// G9: per-entry SurfaceType override via [contour, holes, SurfaceType] triple. // lslices are derived: make_slices() re-derives them + refreshes the bbox cache.
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).
TestLayer layer; TestLayer layer;
py::object ly = py::cast(static_cast<Slic3r::Layer*>(&layer), py::object ly = py::cast(static_cast<Slic3r::Layer*>(&layer), py::return_value_policy::reference);
py::return_value_policy::reference); // (A hand-built layer has no regions, so make_slices() yields empty lslices — still null-safe.)
py::list islands; ly.attr("make_slices")();
islands.append(make_arr({ {0,0}, {s,0}, {s,s}, {0,s} })); CHECK(layer.lslices_bboxes.size() == layer.lslices.size());
ly.attr("set_lslices")(islands); }
REQUIRE(layer.lslices.size() == 1);
CHECK(layer.lslices.front().contour.is_counter_clockwise()); TEST_CASE("orca.host ExPolygon in-place transforms + SurfaceCollection.append (sample ops)", "[slicing_pipeline]") {
REQUIRE(layer.lslices_bboxes.size() == 1); // bbox cache refreshed using Catch::Matchers::WithinRel;
CHECK(ly.attr("lslices")().cast<py::list>().size() == 1); ensure_python_initialized();
import_orca_module();
py::gil_scoped_acquire gil;
py::object host = py::module_::import("orca").attr("host");
const coord_t s = (coord_t) scale_(10.0);
auto make_square = [&]() {
py::object P = host.attr("Polygon")();
P.attr("append")(host.attr("Point")(0, 0));
P.attr("append")(host.attr("Point")(s, 0));
P.attr("append")(host.attr("Point")(s, s));
P.attr("append")(host.attr("Point")(0, s));
return host.attr("ExPolygon")(P);
};
const double area0 = (double) s * (double) s;
// rotate about the square's center preserves area
py::object ex = make_square();
py::object center = host.attr("Point")(s / 2, s / 2);
ex.attr("rotate")(py::float_(1.5707963267948966), center); // pi/2
CHECK_THAT(ex.attr("area")().cast<double>(), WithinRel(area0, 1e-6));
// uniform scale by 2 quadruples area (scale is about the origin)
py::object ex2 = make_square();
ex2.attr("scale")(py::float_(2.0));
CHECK_THAT(ex2.attr("area")().cast<double>(), WithinRel(4.0 * area0, 1e-6));
// translate preserves area
py::object ex3 = make_square();
ex3.attr("translate")(py::float_(1000.0), py::float_(-500.0));
CHECK_THAT(ex3.attr("area")().cast<double>(), WithinRel(area0, 1e-6));
// SurfaceCollection.append accumulates surfaces of a second type (the sample write-back path)
Slic3r::SurfaceCollection coll;
py::object cv = py::cast(&coll, py::return_value_policy::reference);
py::list g1; g1.append(make_square());
cv.attr("set")(g1, host.attr("SurfaceType").attr("stInternalSolid"));
py::list g2; g2.append(make_square());
cv.attr("append")(g2, host.attr("SurfaceType").attr("stTop"));
REQUIRE(coll.surfaces.size() == 2);
CHECK(coll.surfaces[0].surface_type == Slic3r::stInternalSolid);
CHECK(coll.surfaces[1].surface_type == Slic3r::stTop);
}
TEST_CASE("orca.host: in-place edit of surface.expolygon through a live collection persists to C++", "[slicing_pipeline]") {
using Catch::Matchers::WithinRel;
ensure_python_initialized();
import_orca_module();
py::gil_scoped_acquire gil;
const coord_t s = (coord_t) scale_(10.0);
// Live LayerRegion holding one surface (a 10mm square at the origin).
TestLayerRegion region;
Slic3r::ExPolygon sq;
sq.contour.points = { Slic3r::Point(0, 0), Slic3r::Point(s, 0),
Slic3r::Point(s, s), Slic3r::Point(0, s) };
region.slices.surfaces.emplace_back(Slic3r::Surface(Slic3r::stInternal, sq));
py::object lr = py::cast(static_cast<Slic3r::LayerRegion*>(&region),
py::return_value_policy::reference);
// Twistify's path: get the Surface through the live collection, mutate its expolygon in place.
py::object surf = lr.attr("slices").attr("surfaces").cast<py::list>()[0];
surf.attr("expolygon").attr("translate")(py::float_(1000.0), py::float_(0.0));
// The C++-side surface geometry reflects the Python in-place edit (proves the live ref).
const Slic3r::Surface& out = region.slices.surfaces.front();
CHECK(out.expolygon.contour.points[0].x() == 1000); // was 0
CHECK(out.expolygon.contour.points[0].y() == 0);
CHECK_THAT(out.expolygon.area(), WithinRel((double) s * (double) s, 1e-9)); // translate preserves area
} }