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
# 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)")

View File

@@ -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(

View File

@@ -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<typename T, int N>
pybind11::array make_writable_rows(pybind11::handle base, T* data, pybind11::ssize_t rows)
{
namespace py = pybind11;
if (rows == 0 || data == nullptr)
return py::array_t<T>(std::vector<py::ssize_t>{ 0, (py::ssize_t) N });
return py::array_t<T>(
{ rows, (py::ssize_t) N },
{ (py::ssize_t)(N * sizeof(T)), (py::ssize_t) sizeof(T) },
data, base);
}
// Serialize one config key to a Python string, or None if the key is absent.
// Works on any ConfigBase (resolved DynamicPrintConfig snapshots,
// PrintObjectConfig, PrintRegionConfig, preset configs).

View File

@@ -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 <pybind11/stl.h>
#include <memory>
#include <optional>
#include <vector>
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<SurfaceType>* 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<py::array>(entry)) {
ex.contour = parse_polygon(entry, who);
} else if (py::isinstance<py::sequence>(entry) && !py::isinstance<py::str>(entry)) {
py::sequence seq = py::reinterpret_borrow<py::sequence>(entry);
if (py::len(seq) < 1)
throw py::value_error(std::string(who) + ": a [contour, holes] entry needs a contour");
ex.contour = parse_polygon(seq[0], who);
if (py::len(seq) >= 2) {
// Type-check the holes element up front: a non-sequence (e.g. an int) would otherwise
// reach reinterpret_borrow<py::sequence> and raise a bare Python TypeError on iteration,
// whereas the API contract is ValueError for malformed input (str is excluded because it
// is iterable but never a valid holes container).
py::object holes_obj = seq[1];
if (!py::isinstance<py::sequence>(holes_obj) || py::isinstance<py::str>(holes_obj))
throw py::value_error(std::string(who) + ": the holes element must be a list of (N,2) int64 ndarrays");
for (py::handle hh : py::reinterpret_borrow<py::sequence>(holes_obj)) {
Polygon hole = parse_polygon(hh, who);
hole.make_clockwise();
ex.holes.emplace_back(std::move(hole));
}
}
// G9: optional third element -> per-surface SurfaceType override (None keeps the
// carried-forward type). A wrong type raises ValueError, matching the API contract.
if (out_type != nullptr && py::len(seq) >= 3) {
py::object t = seq[2];
if (!t.is_none()) {
try { *out_type = t.cast<SurfaceType>(); }
catch (const py::cast_error&) {
throw py::value_error(std::string(who) + ": the third entry element must be an orca.host.SurfaceType");
}
}
}
} else {
throw py::value_error(std::string(who) + ": each entry must be an (N,2) ndarray or a [contour, holes] pair");
}
ex.contour.make_counter_clockwise();
return ex;
}
// A Python list of entries -> ExPolygons (each entry parsed + oriented). G7: an empty list is
// legal and means "no geometry" (clears the target collection). Per-entry types are ignored
// here (geometry-only consumers such as set_lslices).
static ExPolygons parse_expolygon_list(py::handle list_h, const char* who)
{
if (!py::isinstance<py::sequence>(list_h) || py::isinstance<py::str>(list_h))
throw py::value_error(std::string(who) + ": expected a list of polygons");
ExPolygons out;
for (py::handle entry : py::reinterpret_borrow<py::sequence>(list_h))
out.emplace_back(parse_expolygon(entry, who));
return out;
}
// Build Surfaces from a Python list, carrying surface_type (and the other per-surface
// attributes) forward from the collection being replaced, or defaulting to stInternal when the
// region had none. G9: a per-entry SurfaceType (optional third element) overrides that default.
// G7: an empty list is legal and yields an empty Surfaces (clears the collection).
static Surfaces surfaces_from_py(py::handle list_h, const SurfaceCollection& replaced, const char* who)
{
if (!py::isinstance<py::sequence>(list_h) || py::isinstance<py::str>(list_h))
throw py::value_error(std::string(who) + ": expected a list of polygons");
const Surface tmpl = replaced.surfaces.empty() ? Surface(stInternal) : replaced.surfaces.front();
Surfaces out;
for (py::handle entry : py::reinterpret_borrow<py::sequence>(list_h)) {
std::optional<SurfaceType> type;
ExPolygon e = parse_expolygon(entry, who, &type);
Surface s(tmpl, std::move(e));
if (type)
s.surface_type = *type;
out.emplace_back(std::move(s));
}
return out;
if (py::isinstance<Polygon>(h))
return h.cast<Polygon>();
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<const
out.push_back(path);
}
}
// Rebuild a layer's per-island bbox cache from lslices — the same inline pattern
// every C++ call site uses (PrintObjectSlice.cpp, Print.cpp, TreeSupport.cpp); no
// libslic3r helper exists to reuse.
static void refresh_lslices_bboxes(Layer& l)
{
l.lslices_bboxes.clear();
l.lslices_bboxes.reserve(l.lslices.size());
for (const ExPolygon& island : l.lslices)
l.lslices_bboxes.emplace_back(get_extents(island));
}
} // namespace
void 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
// the Print. References — and every numpy view they hand out — are valid
// only while the plugin hook (execute(ctx)) runs, and a container-replacing
// mutator (LayerRegion.set_slices / set_fill_surfaces, Layer.set_lslices)
// invalidates previously obtained references into that container, exactly
// as std::vector operations invalidate C++ iterators. Do not stash
// references or arrays across execute() calls; copy what you need.
// mutator (SurfaceCollection.set / append / clear, Polygon.set_points / append,
// ExPolygon.set_holes) invalidates previously obtained references into that
// container, exactly as std::vector operations invalidate C++ iterators. Do
// not stash references or arrays across execute() calls; copy what you need.
// ------------------------------------------------------------------
py::enum_<SurfaceType>(host, "SurfaceType")
@@ -197,52 +135,203 @@ void PluginHostSlicing::RegisterBindings(py::module_& host)
.value("stCount", stCount)
.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("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<const Polygon&>();
.def("is_clockwise", [](const Polygon& p) { return p.is_clockwise(); })
.def("make_counter_clockwise", [](Polygon& p) { return p.make_counter_clockwise(); },
"Reorient to CCW in place. Returns True if it reversed the winding.")
.def("make_clockwise", [](Polygon& p) { return p.make_clockwise(); })
.def("area", [](const Polygon& p) { return p.area(); })
.def("centroid", [](const Polygon& p) { return p.centroid(); })
.def("contains", [](const Polygon& p, const Point& pt) { return p.contains(pt); }, py::arg("point"))
.def("translate", [](Polygon& p, double x, double y) { p.translate(x, y); }, py::arg("x"), py::arg("y"))
.def("rotate", [](Polygon& p, double angle) { p.rotate(angle); }, py::arg("angle"))
.def("rotate", [](Polygon& p, double angle, const Point& c) { p.rotate(angle, c); },
py::arg("angle"), py::arg("center"))
.def("douglas_peucker", [](Polygon& p, double tol) { p.douglas_peucker(tol); }, py::arg("tolerance"))
.def("simplify", [](const Polygon& p, double tol) { return p.simplify(tol); }, py::arg("tolerance"),
"Return simplified geometry as a list of Polygon (may split into several).")
.def("offset", [](const Polygon& p, coord_t delta) { return offset(p, (float) delta); }, py::arg("delta"),
"Clipper offset by `delta` scaled units (negative shrinks). Returns [Polygon].")
// --- Point-object idiom: references into the buffer (in-place element edit). ---
.def_property_readonly("points", [](py::object self) {
Polygon& p = self.cast<Polygon&>();
py::list out;
for (Point& pt : p.points)
out.append(py::cast(&pt, py::return_value_policy::reference_internal, self));
return out;
}, "Vertices as [Point] references into this polygon. Editing a Point mutates the "
"buffer in place. Structural changes (count) go through set_points/append, which "
"invalidate previously returned Point refs and array views (C++ vector semantics).")
.def("append", [](Polygon& p, const Point& pt) { p.points.push_back(pt); }, py::arg("point"),
"Append a vertex. Structural change (count): invalidates previously returned "
"Point refs and array views into this polygon (C++ vector semantics).")
// --- numpy idiom: writable zero-copy (N,2) view (bulk affine edits). ---
.def("as_array", [](py::object self) {
Polygon& p = self.cast<Polygon&>();
return with_numpy([&] {
return py::object(make_readonly_rows<coord_t, 2>(
return py::object(make_writable_rows<coord_t, 2>(
self, p.points.empty() ? nullptr : p.points.front().data(),
(py::ssize_t) p.points.size()));
});
}, "Vertices as a 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_<ExPolygon, std::unique_ptr<ExPolygon, py::nodelete>>(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_<ExPolygon>(host, "ExPolygon")
.def(py::init([](py::handle contour, py::handle holes) {
// Accept bound Polygons or (N,2) ndarrays for both contour and each hole.
ExPolygon ex;
ex.contour = as_polygon(contour, "ExPolygon.contour");
if (!holes.is_none()) {
if (!py::isinstance<py::sequence>(holes) || py::isinstance<py::str>(holes))
throw py::value_error("ExPolygon: holes must be a list of Polygon or (N,2) ndarrays");
for (py::handle h : py::reinterpret_borrow<py::sequence>(holes)) {
Polygon hole = as_polygon(h, "ExPolygon.hole");
hole.make_clockwise();
ex.holes.emplace_back(std::move(hole));
}
}
ex.contour.make_counter_clockwise();
return ex;
}), py::arg("contour"), py::arg("holes") = py::none(),
"Construct from a Polygon/ndarray contour and optional list of hole Polygons/ndarrays. "
"Orientation is normalized (contour CCW, holes CW).")
.def_property("contour",
[](ExPolygon& e) -> Polygon& { return e.contour; },
[](ExPolygon& e, py::handle v) { e.contour = as_polygon(v, "ExPolygon.contour"); },
py::return_value_policy::reference_internal,
"Outer contour (CCW) 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<ExPolygon&>();
py::list out;
for (Polygon& h : e.holes)
out.append(py::cast(&h, py::return_value_policy::reference_internal, self));
return out;
}, "Hole contours (CW) as [Polygon].");
}, "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,
"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_<SurfaceCollection, std::unique_ptr<SurfaceCollection, py::nodelete>>(host, "SurfaceCollection")
.def("size", [](const SurfaceCollection& c) { return c.surfaces.size(); })
.def("empty", [](const SurfaceCollection& c) { return c.empty(); })
.def("clear", [](SurfaceCollection& c) { c.clear(); })
.def("has", [](const SurfaceCollection& c, SurfaceType t) { return c.has(t); }, py::arg("surface_type"))
.def("set_type", [](SurfaceCollection& c, SurfaceType t) { c.set_type(t); }, py::arg("surface_type"))
.def("set", [](SurfaceCollection& c, const std::vector<ExPolygon>& src, SurfaceType t) { c.set(src, t); },
py::arg("expolygons"), py::arg("surface_type"),
"Replace all surfaces from a list of ExPolygon, all tagged `surface_type`. "
"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) {
SurfaceCollection& c = self.cast<SurfaceCollection&>();
py::list out;
for (Surface& s : c.surfaces)
out.append(py::cast(&s, py::return_value_policy::reference_internal, self));
return out;
}, "Surfaces as [Surface] references into the live collection. Invalidated "
"by set_slices/set_fill_surfaces on the owning region (C++ vector semantics).");
}, "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_<LayerRegion, std::unique_ptr<LayerRegion, py::nodelete>>(host, "LayerRegion");
layer_region
.def_readonly("slices", &LayerRegion::slices,
"Sliced, typed surfaces (SurfaceCollection). At Step.Slice this is the "
"primary mutation target via set_slices().")
"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_<Layer, std::unique_ptr<Layer, py::nodelete>>(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<Layer&>();
py::list out;
for (ExPolygon& e : l.lslices)
out.append(py::cast(&e, py::return_value_policy::reference_internal, self));
return out;
}, "Merged per-layer islands as [ExPolygon] references. Invalidated by "
"set_lslices/make_slices (C++ vector semantics).")
.def("make_slices", [](Layer& l) {
l.make_slices();
l.lslices_bboxes.clear();
l.lslices_bboxes.reserve(l.lslices.size());
for (const ExPolygon& island : l.lslices)
l.lslices_bboxes.emplace_back(get_extents(island));
}, "Re-derive lslices (merged islands) from the region slices and refresh the "
"bbox cache — the C++ invariant-maintenance call after in-place geometry edits. "
"set_slices(refresh_lslices=True) runs this for you.")
// MUTATOR. Replace this layer's merged islands (lslices) and refresh the cache-invariant
// `lslices_bboxes` (one BoundingBox per island via get_extents). Same input format and
// validation as LayerRegion.set_slices.
.def("set_lslices", [](Layer& l, py::object islands) {
l.lslices = parse_expolygon_list(islands, "set_lslices");
l.lslices_bboxes.clear();
l.lslices_bboxes.reserve(l.lslices.size());
for (const ExPolygon& island : l.lslices)
l.lslices_bboxes.emplace_back(get_extents(island));
}, py::arg("islands"),
"Replace this layer's merged islands (lslices) from a list of (N,2) int64 ndarrays "
"(scaled coords) or [contour, [holes...]] pairs, and refresh lslices_bboxes (one "
"bounding box per island via get_extents) so the bbox cache stays consistent. Same "
"input format/validation as LayerRegion.set_slices. Raises ValueError on malformed "
"input. Valid only during the execute(ctx) call.");
}, "Merged per-layer islands as [ExPolygon] refs (in-place editable). Derived from the "
"region slices; call make_slices() to re-derive after edits. Invalidated by make_slices().");
py::class_<PrintObject, std::unique_ptr<PrintObject, py::nodelete>>(host, "PrintObject")
.def("id", [](const PrintObject& o) { return o.id().id; },

View File

@@ -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)

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

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);
}
TEST_CASE("make_writable_rows builds a writable (N,2) int64 view that aliases the buffer", "[slicing_pipeline]") {
ensure_python_initialized();
py::gil_scoped_acquire gil;
bool have_numpy = false;
try { py::module_::import("numpy"); have_numpy = true; }
catch (const py::error_already_set&) { have_numpy = false; }
if (!have_numpy) SKIP("numpy unavailable in unit-test interpreter");
static Slic3r::Points pts = { Slic3r::Point(10, 20), Slic3r::Point(30, 40) };
py::capsule keepalive(&pts, [](void*){});
py::array a = Slic3r::make_writable_rows<coord_t, 2>(keepalive, pts.front().data(), (py::ssize_t)pts.size());
CHECK(a.writeable());
// Writing through the view mutates the C++ buffer (zero-copy alias).
a.attr("__setitem__")(py::make_tuple(0, 0), py::int_(99));
CHECK(pts.front().x() == 99);
}
TEST_CASE("orca.slicing module: Step enum, context, and a Python capability can execute", "[slicing_pipeline]") {
ensure_python_initialized();
import_orca_module(); // forces PythonPluginBridge::instance() (see 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);
}
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::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;
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<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);
Slic3r::ExPolygon ex;
ex.contour.points = { Slic3r::Point(0, 0), Slic3r::Point(s, 0),
Slic3r::Point(s, s), Slic3r::Point(0, s) };
Slic3r::Polygon hole;
hole.points = { Slic3r::Point(1, 1), Slic3r::Point(2, 1), Slic3r::Point(2, 2) };
ex.holes = { hole };
py::object view = py::cast(&ex, py::return_value_policy::reference);
py::array c = view.attr("contour").attr("points")().cast<py::array>();
CHECK(c.dtype().kind() == 'i');
CHECK(c.itemsize() == 8); // int64
CHECK(c.shape(0) == 4);
CHECK(c.shape(1) == 2);
CHECK_FALSE(c.writeable());
auto rc = c.cast<py::array_t<coord_t>>().unchecked<2>();
CHECK(rc(0, 0) == 0);
CHECK(rc(1, 0) == s);
CHECK(rc(2, 1) == s);
// Construct from Polygon objects (Point idiom, no numpy).
py::object P = host.attr("Polygon")();
P.attr("append")(host.attr("Point")(0, 0));
P.attr("append")(host.attr("Point")(s, 0));
P.attr("append")(host.attr("Point")(s, s));
P.attr("append")(host.attr("Point")(0, s));
py::object ex = host.attr("ExPolygon")(P);
CHECK_THAT(ex.attr("area")().cast<double>(), WithinRel((double) s * (double) s, 1e-9));
CHECK(ex.attr("num_contours")().cast<size_t>() == 1);
CHECK(ex.attr("contour").attr("size")().cast<size_t>() == 4);
py::list holes = view.attr("holes").cast<py::list>();
REQUIRE(holes.size() == 1);
py::array h0 = holes[0].attr("points")().cast<py::array>();
CHECK(h0.shape(0) == 3);
CHECK_FALSE(h0.writeable());
// In-place transform mutates the geometry.
ex.attr("translate")(py::float_(1000.0), py::float_(0.0));
// Boolean op returns new geometry: A minus a smaller inset of A is a non-empty ring set.
py::list inset = ex.attr("offset")(py::int_(-(coord_t)scale_(1.0))).cast<py::list>();
REQUIRE(inset.size() >= 1);
py::list ring = ex.attr("diff_ex")(inset[0]).cast<py::list>();
CHECK(ring.size() >= 1);
}
namespace {
@@ -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<Slic3r::LayerRegion*>(&region),
py::return_value_policy::reference);
auto raises_value_error = [](py::object callable, py::object arg) {
try { callable(arg); return false; }
catch (py::error_already_set& e) { return e.matches(PyExc_ValueError); }
};
CHECK(raises_value_error(lr.attr("set_slices"), py::int_(42))); // not a sequence
CHECK(raises_value_error(lr.attr("set_slices"), py::str("nope"))); // string rejected
CHECK(region.slices.surfaces.size() == 1); // failures mutate nothing
// G7: an empty list is legal and clears the region (refresh_lslices defaults True;
// the null owning-layer on this hand-built region exercises the null guard).
lr.attr("set_slices")(py::list());
py::object lr = py::cast(static_cast<Slic3r::LayerRegion*>(&region), py::return_value_policy::reference);
lr.attr("slices").attr("clear")();
CHECK(region.slices.surfaces.empty());
}
TEST_CASE("orca.host 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<std::pair<coord_t,coord_t>> pts) {
py::list rows;
for (auto& p : pts) rows.append(py::make_tuple(p.first, p.second));
auto arr = [&](std::initializer_list<std::pair<coord_t,coord_t>> pts) {
py::list rows; for (auto& p : pts) rows.append(py::make_tuple(p.first, p.second));
return np.attr("array")(rows, py::arg("dtype") = i64);
};
// 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;
region.slices.surfaces.emplace_back(Slic3r::Surface(Slic3r::stInternalSolid));
py::object lr = py::cast(static_cast<Slic3r::LayerRegion*>(&region),
py::return_value_policy::reference);
py::list polys;
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<Slic3r::LayerRegion*>(&region), py::return_value_policy::reference);
py::list expolys; expolys.append(ex);
lr.attr("slices").attr("set")(expolys, ST.attr("stInternalSolid"));
REQUIRE(region.slices.surfaces.size() == 1);
const Slic3r::Surface& out = region.slices.surfaces.front();
CHECK(out.surface_type == Slic3r::stInternalSolid);
CHECK(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<py::list>();
REQUIRE(sl.size() == 1);
py::array c = sl[0].attr("expolygon").attr("contour").attr("points")().cast<py::array>();
// Read geometry back through the class API.
py::array c = lr.attr("slices").attr("surfaces").cast<py::list>()[0]
.attr("expolygon").attr("contour").attr("as_array")().cast<py::array>();
CHECK(c.shape(0) == 4);
// 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<Slic3r::Layer*>(&layer),
py::return_value_policy::reference);
py::list islands;
islands.append(make_arr({ {0,0}, {s,0}, {s,s}, {0,s} }));
ly.attr("set_lslices")(islands);
REQUIRE(layer.lslices.size() == 1);
CHECK(layer.lslices.front().contour.is_counter_clockwise());
REQUIRE(layer.lslices_bboxes.size() == 1); // bbox cache refreshed
CHECK(ly.attr("lslices")().cast<py::list>().size() == 1);
py::object ly = py::cast(static_cast<Slic3r::Layer*>(&layer), py::return_value_policy::reference);
// (A hand-built layer has no regions, so make_slices() yields empty lslices — still null-safe.)
ly.attr("make_slices")();
CHECK(layer.lslices_bboxes.size() == layer.lslices.size());
}
TEST_CASE("orca.host ExPolygon in-place transforms + SurfaceCollection.append (sample ops)", "[slicing_pipeline]") {
using Catch::Matchers::WithinRel;
ensure_python_initialized();
import_orca_module();
py::gil_scoped_acquire gil;
py::object host = py::module_::import("orca").attr("host");
const coord_t s = (coord_t) scale_(10.0);
auto make_square = [&]() {
py::object P = host.attr("Polygon")();
P.attr("append")(host.attr("Point")(0, 0));
P.attr("append")(host.attr("Point")(s, 0));
P.attr("append")(host.attr("Point")(s, s));
P.attr("append")(host.attr("Point")(0, s));
return host.attr("ExPolygon")(P);
};
const double area0 = (double) s * (double) s;
// rotate about the square's center preserves area
py::object ex = make_square();
py::object center = host.attr("Point")(s / 2, s / 2);
ex.attr("rotate")(py::float_(1.5707963267948966), center); // pi/2
CHECK_THAT(ex.attr("area")().cast<double>(), WithinRel(area0, 1e-6));
// uniform scale by 2 quadruples area (scale is about the origin)
py::object ex2 = make_square();
ex2.attr("scale")(py::float_(2.0));
CHECK_THAT(ex2.attr("area")().cast<double>(), WithinRel(4.0 * area0, 1e-6));
// translate preserves area
py::object ex3 = make_square();
ex3.attr("translate")(py::float_(1000.0), py::float_(-500.0));
CHECK_THAT(ex3.attr("area")().cast<double>(), WithinRel(area0, 1e-6));
// SurfaceCollection.append accumulates surfaces of a second type (the sample write-back path)
Slic3r::SurfaceCollection coll;
py::object cv = py::cast(&coll, py::return_value_policy::reference);
py::list g1; g1.append(make_square());
cv.attr("set")(g1, host.attr("SurfaceType").attr("stInternalSolid"));
py::list g2; g2.append(make_square());
cv.attr("append")(g2, host.attr("SurfaceType").attr("stTop"));
REQUIRE(coll.surfaces.size() == 2);
CHECK(coll.surfaces[0].surface_type == Slic3r::stInternalSolid);
CHECK(coll.surfaces[1].surface_type == Slic3r::stTop);
}
TEST_CASE("orca.host: in-place edit of surface.expolygon through a live collection persists to C++", "[slicing_pipeline]") {
using Catch::Matchers::WithinRel;
ensure_python_initialized();
import_orca_module();
py::gil_scoped_acquire gil;
const coord_t s = (coord_t) scale_(10.0);
// Live LayerRegion holding one surface (a 10mm square at the origin).
TestLayerRegion region;
Slic3r::ExPolygon sq;
sq.contour.points = { Slic3r::Point(0, 0), Slic3r::Point(s, 0),
Slic3r::Point(s, s), Slic3r::Point(0, s) };
region.slices.surfaces.emplace_back(Slic3r::Surface(Slic3r::stInternal, sq));
py::object lr = py::cast(static_cast<Slic3r::LayerRegion*>(&region),
py::return_value_policy::reference);
// Twistify's path: get the Surface through the live collection, mutate its expolygon in place.
py::object surf = lr.attr("slices").attr("surfaces").cast<py::list>()[0];
surf.attr("expolygon").attr("translate")(py::float_(1000.0), py::float_(0.0));
// The C++-side surface geometry reflects the Python in-place edit (proves the live ref).
const Slic3r::Surface& out = region.slices.surfaces.front();
CHECK(out.expolygon.contour.points[0].x() == 1000); // was 0
CHECK(out.expolygon.contour.points[0].y() == 0);
CHECK_THAT(out.expolygon.area(), WithinRel((double) s * (double) s, 1e-9)); // translate preserves area
}