mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-07-17 07:52:08 +00:00
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:
@@ -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)")
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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; },
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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*>(®ion),
|
||||
py::return_value_policy::reference);
|
||||
|
||||
auto raises_value_error = [](py::object callable, py::object arg) {
|
||||
try { callable(arg); return false; }
|
||||
catch (py::error_already_set& e) { return e.matches(PyExc_ValueError); }
|
||||
};
|
||||
CHECK(raises_value_error(lr.attr("set_slices"), py::int_(42))); // not a sequence
|
||||
CHECK(raises_value_error(lr.attr("set_slices"), py::str("nope"))); // string rejected
|
||||
CHECK(region.slices.surfaces.size() == 1); // failures mutate nothing
|
||||
// G7: an empty list is legal and clears the region (refresh_lslices defaults True;
|
||||
// the null owning-layer on this hand-built region exercises the null guard).
|
||||
lr.attr("set_slices")(py::list());
|
||||
py::object lr = py::cast(static_cast<Slic3r::LayerRegion*>(®ion), py::return_value_policy::reference);
|
||||
lr.attr("slices").attr("clear")();
|
||||
CHECK(region.slices.surfaces.empty());
|
||||
}
|
||||
|
||||
TEST_CASE("orca.host set_slices/set_lslices: ndarray input mutates geometry (read back both ways)", "[slicing_pipeline]") {
|
||||
TEST_CASE("orca.host: SurfaceCollection.set mutates geometry; lslices via make_slices", "[slicing_pipeline]") {
|
||||
using Catch::Matchers::WithinRel;
|
||||
ensure_python_initialized();
|
||||
import_orca_module();
|
||||
@@ -394,64 +503,106 @@ TEST_CASE("orca.host set_slices/set_lslices: ndarray input mutates geometry (rea
|
||||
py::object host = py::module_::import("orca").attr("host");
|
||||
py::module_ np = py::module_::import("numpy");
|
||||
py::object i64 = np.attr("int64");
|
||||
py::object ST = host.attr("SurfaceType");
|
||||
const coord_t s = (coord_t) scale_(10.0);
|
||||
auto make_arr = [&](std::initializer_list<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*>(®ion),
|
||||
py::return_value_policy::reference);
|
||||
py::list polys;
|
||||
polys.append(make_arr({ {0,0}, {0,s}, {s,s}, {s,0} })); // clockwise winding
|
||||
lr.attr("set_slices")(polys);
|
||||
py::object lr = py::cast(static_cast<Slic3r::LayerRegion*>(®ion), py::return_value_policy::reference);
|
||||
py::list expolys; expolys.append(ex);
|
||||
lr.attr("slices").attr("set")(expolys, ST.attr("stInternalSolid"));
|
||||
REQUIRE(region.slices.surfaces.size() == 1);
|
||||
const Slic3r::Surface& out = region.slices.surfaces.front();
|
||||
CHECK(out.surface_type == Slic3r::stInternalSolid);
|
||||
CHECK(out.expolygon.contour.is_counter_clockwise());
|
||||
CHECK_THAT(out.expolygon.area(), WithinRel((double) s * (double) s, 1e-9));
|
||||
py::list sl = lr.attr("slices").attr("surfaces").cast<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*>(®ion),
|
||||
py::return_value_policy::reference);
|
||||
|
||||
// Twistify's path: get the Surface through the live collection, mutate its expolygon in place.
|
||||
py::object surf = lr.attr("slices").attr("surfaces").cast<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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user