2527 lines
No EOL
101 KiB
Python
2527 lines
No EOL
101 KiB
Python
from __future__ import annotations
|
|
import math
|
|
from numpy import float64
|
|
import numpy as np
|
|
import pyclipper
|
|
from typing import Any, Literal, cast
|
|
import copy
|
|
from shapely.geometry import Polygon, MultiPolygon, LineString, LinearRing, MultiLineString, GeometryCollection, Point
|
|
import manifold3d
|
|
import trimesh
|
|
|
|
from ..infra.blender.blender_mesh_capture import PdfProperties
|
|
|
|
from .geometry import ndarray_lru_cache, update_transform
|
|
|
|
# Scaling factor for 1 micron unit integer math (1 mm = 1000 microns)
|
|
SCALE = 1000
|
|
|
|
def is_closed_path(path: np.ndarray) -> bool:
|
|
"""Checks if a 2D or 3D path forms a closed loop in integer micron space (1 micron unit)."""
|
|
if len(path) < 3:
|
|
return False
|
|
return (int(round(float(path[0, 0]) * SCALE)) == int(round(float(path[-1, 0]) * SCALE)) and
|
|
int(round(float(path[0, 1]) * SCALE)) == int(round(float(path[-1, 1]) * SCALE)))
|
|
|
|
def _transfer_path_attr(src_attr: np.ndarray, orig_pts: np.ndarray, new_pts: np.ndarray) -> np.ndarray:
|
|
"""
|
|
Transfers 1D per-vertex attribute from orig_pts (shape N, 3) to new_pts (shape M, 3).
|
|
If src_attr is uniform (all values equal), returns array of shape (M,) filled with that value.
|
|
Otherwise, interpolates along normalized cumulative length.
|
|
"""
|
|
N = len(orig_pts)
|
|
M = len(new_pts)
|
|
if M == 0:
|
|
return np.zeros(0, dtype=src_attr.dtype if hasattr(src_attr, 'dtype') else float)
|
|
if N == 0:
|
|
return np.zeros(M, dtype=src_attr.dtype if hasattr(src_attr, 'dtype') else float)
|
|
if len(src_attr) > 0 and (len(src_attr) == 1 or np.all(src_attr == src_attr[0])):
|
|
return np.full(M, src_attr[0], dtype=src_attr.dtype if hasattr(src_attr, 'dtype') else float)
|
|
if N == M and is_closed_path(orig_pts) == is_closed_path(new_pts) and np.all(np.rint(orig_pts * SCALE) == np.rint(new_pts * SCALE)):
|
|
return src_attr.copy()
|
|
|
|
orig_diffs = np.diff(orig_pts[:, :3], axis=0)
|
|
orig_dists = np.sqrt((orig_diffs ** 2).sum(axis=1))
|
|
orig_cum = np.concatenate(([0.0], np.cumsum(orig_dists)))
|
|
total_orig = orig_cum[-1]
|
|
if total_orig > 1e-7:
|
|
orig_t = orig_cum / total_orig
|
|
else:
|
|
orig_t = np.linspace(0.0, 1.0, N)
|
|
|
|
new_diffs = np.diff(new_pts[:, :3], axis=0)
|
|
new_dists = np.sqrt((new_diffs ** 2).sum(axis=1))
|
|
new_cum = np.concatenate(([0.0], np.cumsum(new_dists)))
|
|
total_new = new_cum[-1]
|
|
if total_new > 1e-7:
|
|
new_t = new_cum / total_new
|
|
else:
|
|
new_t = np.linspace(0.0, 1.0, M)
|
|
|
|
return np.interp(new_t, orig_t, src_attr)
|
|
|
|
|
|
def _transfer_group_attributes(src_sp: SlicePaths, res_paths: list[np.ndarray], res_groups: list[int]) -> tuple[list[np.ndarray], list[np.ndarray], dict[str, list[np.ndarray]]]:
|
|
"""
|
|
Given input SlicePaths src_sp and output res_paths/res_groups,
|
|
constructs extrusion_rate, feedrate, and extra_attributes for res_paths
|
|
by matching groups or nearest paths in src_sp.
|
|
"""
|
|
out_ext: list[np.ndarray] = []
|
|
out_feed: list[np.ndarray] = []
|
|
out_extra: dict[str, list[np.ndarray]] = {k: [] for k in getattr(src_sp, "extra_attributes", {})}
|
|
|
|
src_groups = getattr(src_sp, "groups", [])
|
|
src_group_map: dict[int, list[int]] = {}
|
|
for idx, g in enumerate(src_groups):
|
|
src_group_map.setdefault(g, []).append(idx)
|
|
|
|
src_z_keys: list[int] | None = None
|
|
if len(src_sp) > 0:
|
|
src_z_keys = [int(round(float(src_sp[i][0, 2]) * SCALE)) if len(src_sp[i]) > 0 and src_sp[i].shape[1] > 2 else 0 for i in range(len(src_sp))]
|
|
|
|
for idx, (p, g) in enumerate(zip(res_paths, res_groups)):
|
|
M = len(p)
|
|
src_indices = src_group_map.get(g, [])
|
|
if not src_indices and len(src_sp) > 0 and src_z_keys is not None:
|
|
z_um = int(round(float(p[0, 2]) * SCALE)) if len(p) > 0 and p.shape[1] > 2 else 0
|
|
src_indices = [min(range(len(src_sp)), key=lambda i: abs(src_z_keys[i] - z_um))]
|
|
|
|
if src_indices:
|
|
src_i = src_indices[0]
|
|
ext = _transfer_path_attr(src_sp.extrusion_rate[src_i], src_sp[src_i], p)
|
|
feed = _transfer_path_attr(src_sp.feedrate[src_i], src_sp[src_i], p)
|
|
out_ext.append(ext)
|
|
out_feed.append(feed)
|
|
for k in src_sp.extra_attributes:
|
|
attr_arr = _transfer_path_attr(src_sp.extra_attributes[k][src_i], src_sp[src_i], p)
|
|
out_extra[k].append(attr_arr)
|
|
else:
|
|
out_ext.append(np.ones(M))
|
|
out_feed.append(np.zeros(M))
|
|
for k in src_sp.extra_attributes:
|
|
out_extra[k].append(np.zeros(M))
|
|
|
|
return out_ext, out_feed, out_extra
|
|
|
|
|
|
import ctypes
|
|
import os
|
|
|
|
_c_seam_lib = None
|
|
try:
|
|
_so_dir = os.path.dirname(os.path.abspath(__file__))
|
|
_so_path = os.path.join(_so_dir, "libseam_opt.so")
|
|
_c_src = os.path.join(_so_dir, "c_seam.c")
|
|
if not os.path.exists(_so_path) and os.path.exists(_c_src):
|
|
import subprocess
|
|
for compiler in ("gcc", "clang"):
|
|
try:
|
|
subprocess.run([compiler, "-O3", "-fPIC", "-shared", _c_src, "-o", _so_path, "-lm"], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
if os.path.exists(_so_path):
|
|
break
|
|
except Exception:
|
|
pass
|
|
if os.path.exists(_so_path):
|
|
_c_seam_lib = ctypes.CDLL(_so_path)
|
|
_c_seam_lib.find_sharpest_seam_c.argtypes = [
|
|
ctypes.POINTER(ctypes.c_double),
|
|
ctypes.c_int,
|
|
ctypes.c_int,
|
|
ctypes.c_double
|
|
]
|
|
_c_seam_lib.find_sharpest_seam_c.restype = ctypes.c_int
|
|
except Exception:
|
|
_c_seam_lib = None
|
|
|
|
|
|
def align_path_seam_sharpest(path: np.ndarray) -> np.ndarray:
|
|
"""
|
|
Reorders a closed 2D/3D polygon path (N, 3) or (N, 2) so that its starting vertex (seam)
|
|
is located at the sharpest concave angle (inner corner). If no concave angles exist,
|
|
falls back to the sharpest angle overall.
|
|
|
|
If path is open or has fewer than 3 distinct vertices, returns path unchanged.
|
|
Uses exact 1-micron integer arithmetic for orientation and angle selection.
|
|
"""
|
|
if len(path) < 4:
|
|
return path
|
|
|
|
if not is_closed_path(path):
|
|
return path
|
|
|
|
pts = path[:-1]
|
|
M = len(pts)
|
|
if M < 3:
|
|
return path
|
|
|
|
if _c_seam_lib is not None:
|
|
try:
|
|
c_pts = np.ascontiguousarray(pts, dtype=np.float64)
|
|
stride = int(c_pts.shape[1])
|
|
ptr = c_pts.ctypes.data_as(ctypes.POINTER(ctypes.c_double))
|
|
best_idx = int(_c_seam_lib.find_sharpest_seam_c(ptr, M, stride, float(SCALE)))
|
|
if best_idx == 0:
|
|
return path
|
|
res = np.empty_like(path)
|
|
split = M - best_idx
|
|
res[:split] = path[best_idx:-1]
|
|
res[split:] = path[:best_idx + 1]
|
|
return res
|
|
except Exception:
|
|
pass
|
|
|
|
pts_um = np.rint(pts[:, :2] * SCALE).astype(np.int64)
|
|
x = pts_um[:, 0]
|
|
y = pts_um[:, 1]
|
|
signed_area_um = np.sum(x[:-1] * y[1:] - x[1:] * y[:-1]) + (x[-1] * y[0] - x[0] * y[-1])
|
|
if signed_area_um == 0:
|
|
return path
|
|
|
|
v_in = np.empty((M, 2), dtype=np.int64)
|
|
v_in[0, 0] = x[0] - x[-1]
|
|
v_in[0, 1] = y[0] - y[-1]
|
|
v_in[1:, 0] = x[1:] - x[:-1]
|
|
v_in[1:, 1] = y[1:] - y[:-1]
|
|
|
|
l2_in = v_in[:, 0]**2 + v_in[:, 1]**2
|
|
valid_in = l2_in > 0
|
|
valid_out = np.empty_like(valid_in)
|
|
valid_out[:-1] = valid_in[1:]
|
|
valid_out[-1] = valid_in[0]
|
|
valid = valid_in & valid_out
|
|
if not np.any(valid):
|
|
return path
|
|
|
|
cross_2d = np.empty(M, dtype=np.int64)
|
|
cross_2d[:-1] = v_in[:-1, 0] * v_in[1:, 1] - v_in[:-1, 1] * v_in[1:, 0]
|
|
cross_2d[-1] = v_in[-1, 0] * v_in[0, 1] - v_in[-1, 1] * v_in[0, 0]
|
|
|
|
concave_mask = valid & (cross_2d < 0) if signed_area_um > 0 else valid & (cross_2d > 0)
|
|
|
|
l_in = np.sqrt(l2_in, dtype=np.float64)
|
|
l_out = np.empty_like(l_in)
|
|
l_out[:-1] = l_in[1:]
|
|
l_out[-1] = l_in[0]
|
|
|
|
dot_prods = np.empty(M, dtype=np.float64)
|
|
dot_prods[:-1] = v_in[:-1, 0] * v_in[1:, 0] + v_in[:-1, 1] * v_in[1:, 1]
|
|
dot_prods[-1] = v_in[-1, 0] * v_in[0, 0] + v_in[-1, 1] * v_in[0, 1]
|
|
|
|
cos_angles = np.full(M, 1.0)
|
|
denom = l_in[valid] * l_out[valid]
|
|
cos_angles[valid] = dot_prods[valid] / denom
|
|
|
|
if np.any(concave_mask):
|
|
concave_indices = np.where(concave_mask)[0]
|
|
best_idx = int(concave_indices[np.argmin(cos_angles[concave_mask])])
|
|
else:
|
|
best_idx = int(np.argmin(cos_angles))
|
|
|
|
if best_idx == 0:
|
|
return path
|
|
|
|
res = np.empty_like(path)
|
|
split = M - best_idx
|
|
res[:split] = path[best_idx:-1]
|
|
res[split:] = path[:best_idx + 1]
|
|
return res
|
|
|
|
|
|
class SlicePaths(list[np.ndarray]):
|
|
groups: list[int]
|
|
extrusion_rate: list[np.ndarray]
|
|
feedrate: list[np.ndarray]
|
|
extra_attributes: dict[str, list[np.ndarray]]
|
|
layer_height: float
|
|
first_layer_height: float | None
|
|
transforms: list[dict[str, Any]]
|
|
pdf_object_properties: list[tuple[str, str]]
|
|
pdf_properties: PdfProperties
|
|
name: str
|
|
objects_info: dict[str, Any] | None
|
|
|
|
def __init__(self, paths: list[np.ndarray] = [], groups: list[int] = [], extrusion_rate: list[np.ndarray] | None = None, feedrate: list[np.ndarray] | None = None, extra_attributes: dict[str, list[np.ndarray]] | None = None, layer_height: float = 0.2, first_layer_height: float | None = None, transforms: list[dict[str, Any]] | None = None, name: str = "", objects_info: dict[str, Any] | None = None):
|
|
super().__init__(paths)
|
|
|
|
self.groups = []
|
|
if groups: self.groups = list(groups)
|
|
else: self.groups = list(range(0,len(paths)))
|
|
|
|
self.extrusion_rate = []
|
|
if extrusion_rate:
|
|
self.extrusion_rate = list(extrusion_rate)
|
|
else:
|
|
self.extrusion_rate = [np.ones(len(p)) for p in paths]
|
|
|
|
self.feedrate = []
|
|
if feedrate:
|
|
self.feedrate = list(feedrate)
|
|
else:
|
|
self.feedrate = [np.zeros(len(p)) for p in paths]
|
|
|
|
self.extra_attributes = extra_attributes or {}
|
|
self.layer_height = layer_height
|
|
self.first_layer_height = first_layer_height
|
|
self.transforms = list(transforms) if transforms is not None else []
|
|
self.name = name
|
|
self.objects_info = copy.deepcopy(objects_info) if objects_info is not None else None
|
|
|
|
def copy_with(self, paths: list[np.ndarray], groups: list[int] | None = None, extrusion_rate: list[np.ndarray] | None = None, feedrate: list[np.ndarray] | None = None, extra_attributes: dict[str, list[np.ndarray]] | None = None) -> SlicePaths:
|
|
"""Returns a new SlicePaths object with new paths/groups, preserving all metadata and instance attributes."""
|
|
target_groups = groups if groups is not None else (self.groups if len(self.groups) == len(paths) else list(range(len(paths))))
|
|
|
|
if extrusion_rate is None or feedrate is None or extra_attributes is None:
|
|
calc_ext, calc_feed, calc_extra = _transfer_group_attributes(self, paths, target_groups)
|
|
if extrusion_rate is None:
|
|
extrusion_rate = calc_ext
|
|
if feedrate is None:
|
|
feedrate = calc_feed
|
|
if extra_attributes is None:
|
|
extra_attributes = calc_extra
|
|
|
|
new_obj = SlicePaths(paths, target_groups, extrusion_rate, feedrate, extra_attributes)
|
|
for k, v in self.__dict__.items():
|
|
if k not in ["groups", "extrusion_rate", "feedrate", "extra_attributes"]:
|
|
setattr(new_obj, k, v)
|
|
return new_obj
|
|
|
|
def offset(self, offset_vec: np.ndarray) -> SlicePaths:
|
|
"""Returns a new SlicePaths object shifted by the given vector."""
|
|
res = self.copy_with([p + offset_vec for p in self])
|
|
res.transforms = update_transform(getattr(self, "transforms", []), t_arr=offset_vec)
|
|
return res
|
|
|
|
def transform(self, matrix: np.ndarray) -> SlicePaths:
|
|
"""Applies a transformation matrix to all paths."""
|
|
res = self.copy_with([p @ matrix.T for p in self])
|
|
res.transforms = [dict(t) for t in getattr(self, "transforms", [])]
|
|
return res
|
|
|
|
def buffer(self, distance: float, miter: str = 'round', rotation: Any = None) -> SlicePaths:
|
|
"""Buffers (offsets) the paths. Handles optional rotation for non-planar buffering."""
|
|
p = self
|
|
if rotation and any(rotation):
|
|
from mathutils import Euler
|
|
mat = Euler(rotation).to_matrix().inverted()
|
|
p = self.transform(np.array(mat))
|
|
|
|
res_paths, res_groups = buffer_slice(p, distance, miter=miter)
|
|
new_paths = self.copy_with(res_paths, res_groups)
|
|
|
|
if rotation and any(rotation):
|
|
from mathutils import Euler
|
|
mat_inv = Euler(rotation).to_matrix()
|
|
new_paths = new_paths.transform(np.array(mat_inv))
|
|
|
|
return new_paths
|
|
|
|
def buffer_multiple(self, base_distance: float, count: int, width_step: float, miter: str = 'round', rotation: Any = None) -> SlicePaths:
|
|
"""Generates multiple concentric buffers and interleaves them by group (Z-layer), preserving all metadata."""
|
|
if not self or count <= 0:
|
|
return self.copy_with([])
|
|
|
|
p = self
|
|
if rotation and any(rotation):
|
|
from mathutils import Euler
|
|
mat = Euler(rotation).to_matrix().inverted()
|
|
p = self.transform(np.array(mat))
|
|
|
|
res_paths, res_groups = buffer_multiple_slice(p, base_distance, count, width_step, miter=miter)
|
|
new_paths = self.copy_with(res_paths, res_groups)
|
|
|
|
if rotation and any(rotation):
|
|
from mathutils import Euler
|
|
mat_inv = Euler(rotation).to_matrix()
|
|
new_paths = new_paths.transform(np.array(mat_inv))
|
|
|
|
return new_paths
|
|
|
|
def buffer_curve(self, distance: float, cap_style: str = 'round', join_style: str = 'round', single_sided: bool = False, rotation: Any = None) -> SlicePaths:
|
|
"""Buffers open curves using shapely. Handles optional rotation for non-planar buffering."""
|
|
p = self
|
|
if rotation and any(rotation):
|
|
from mathutils import Euler
|
|
mat = Euler(rotation).to_matrix().inverted()
|
|
p = self.transform(np.array(mat))
|
|
|
|
res_paths, res_groups = buffer_curve_slice(p, distance, cap_style=cap_style, join_style=join_style, single_sided=single_sided)
|
|
new_paths = self.copy_with(res_paths, res_groups)
|
|
|
|
if rotation and any(rotation):
|
|
from mathutils import Euler
|
|
mat_inv = Euler(rotation).to_matrix()
|
|
new_paths = new_paths.transform(np.array(mat_inv))
|
|
|
|
return new_paths
|
|
|
|
def offset_curve(self, distance: float, join_style: Literal['round', 'mitre'] = 'round', rotation: Any = None) -> SlicePaths:
|
|
"""Offsets open/closed curves by a distance using shapely. Handles optional rotation."""
|
|
p = self
|
|
if rotation and any(rotation):
|
|
from mathutils import Euler
|
|
mat = Euler(rotation).to_matrix().inverted()
|
|
p = self.transform(np.array(mat))
|
|
|
|
res_paths, res_groups = offset_curve_slice(p, distance, join_style=join_style)
|
|
new_paths = self.copy_with(res_paths, res_groups)
|
|
|
|
if rotation and any(rotation):
|
|
from mathutils import Euler
|
|
mat_inv = Euler(rotation).to_matrix()
|
|
new_paths = new_paths.transform(np.array(mat_inv))
|
|
|
|
return new_paths
|
|
|
|
def simplify(self, tolerance: float = 0.1, preserve_topology: bool = True) -> SlicePaths:
|
|
"""Simplifies paths using Shapely while conserving all per-path metadata and attributes."""
|
|
if not self or tolerance <= 0:
|
|
return self
|
|
|
|
from shapely.geometry import LineString
|
|
|
|
simplified_paths = []
|
|
for p in self:
|
|
if len(p) < 3:
|
|
simplified_paths.append(p)
|
|
continue
|
|
line = LineString(p)
|
|
simplified_line = line.simplify(tolerance, preserve_topology=preserve_topology)
|
|
if simplified_line.geom_type == 'LineString' and len(simplified_line.coords) >= 2:
|
|
simplified_paths.append(np.array(simplified_line.coords, dtype=p.dtype))
|
|
else:
|
|
simplified_paths.append(p)
|
|
|
|
return self.copy_with(simplified_paths)
|
|
|
|
@classmethod
|
|
def extend_paths(cls, paths_list: list[SlicePaths], primary_source: SlicePaths | None = None) -> SlicePaths:
|
|
"""Combines multiple SlicePaths sequentially into one SlicePaths, conserving all metadata and attributes."""
|
|
if not paths_list:
|
|
return SlicePaths()
|
|
|
|
base = primary_source if primary_source is not None else next((sp for sp in paths_list if len(sp) > 0), paths_list[0])
|
|
merged = base.copy_with([])
|
|
|
|
for sp in paths_list:
|
|
if not sp:
|
|
continue
|
|
merged.extend_with_groups(sp)
|
|
merged.transforms = [dict(t) for t in getattr(base, "transforms", [])]
|
|
|
|
return merged
|
|
|
|
@classmethod
|
|
def zip_paths(cls, paths_list: list[SlicePaths], primary_source: SlicePaths | None = None) -> SlicePaths:
|
|
"""Interleaves multiple SlicePaths layer by layer (by group ID), conserving all metadata and attributes."""
|
|
if not paths_list:
|
|
return SlicePaths()
|
|
|
|
non_empty = [sp for sp in paths_list if len(sp) > 0]
|
|
if not non_empty:
|
|
return SlicePaths()
|
|
|
|
base = primary_source if primary_source is not None else non_empty[0]
|
|
|
|
all_groups = set()
|
|
grouped_inputs = []
|
|
group_z: dict[int, int] = {}
|
|
for p_list in non_empty:
|
|
d = {}
|
|
for i, g in enumerate(p_list.groups):
|
|
all_groups.add(g)
|
|
d.setdefault(g, []).append((p_list[i], p_list.extrusion_rate[i], p_list.feedrate[i], {k: v[i] for k, v in p_list.extra_attributes.items()}))
|
|
if g not in group_z and len(p_list[i]) > 0:
|
|
group_z[g] = int(round(p_list[i][0, 2] * SCALE))
|
|
grouped_inputs.append(d)
|
|
|
|
sorted_groups = sorted(list(all_groups), key=lambda g: (group_z.get(g, 0), g))
|
|
|
|
merged = base.copy_with([])
|
|
for g in sorted_groups:
|
|
for d in grouped_inputs:
|
|
items = d.get(g, [])
|
|
for path, ext, feed, extra in items:
|
|
merged.append_with_group(path, group=g, extrusion_rate=ext, feedrate=feed, extra_attributes=extra)
|
|
|
|
merged_names = []
|
|
merged_objs = []
|
|
for sp in non_empty:
|
|
obj_info = getattr(sp, "objects_info", None)
|
|
if obj_info:
|
|
for obj_entry in obj_info.get("objects", []):
|
|
if obj_entry not in merged_objs:
|
|
merged_objs.append(dict(obj_entry))
|
|
n_str = obj_entry.get("name", "")
|
|
if n_str and n_str not in merged_names:
|
|
merged_names.append(n_str)
|
|
elif hasattr(sp, "name") and getattr(sp, "name"):
|
|
n_str = str(getattr(sp, "name"))
|
|
if n_str and n_str not in merged_names:
|
|
merged_names.append(n_str)
|
|
merged_objs.append({"name": n_str, "polygon": []})
|
|
|
|
merged.transforms = [dict(t) for t in getattr(base, "transforms", [])]
|
|
if merged_objs:
|
|
merged.objects_info = {"objects": merged_objs}
|
|
merged.name = merged_names[0] if merged_names else ""
|
|
|
|
return merged
|
|
|
|
def get_infill_mask(self) -> SlicePaths:
|
|
"""Computes the 2D solid infill mask for each layer, resolving exterior boundaries and interior holes."""
|
|
res_paths, res_groups = get_infill_mask_slice(self)
|
|
return self.copy_with(res_paths, res_groups)
|
|
|
|
def get_overhang_mask(self, tolerance: float = 0.05) -> SlicePaths:
|
|
"""Computes the overhang mask for each layer, propagating from top to bottom."""
|
|
res_paths, res_groups = get_overhang_mask_slice(self, tuple(self.groups), tolerance=tolerance)
|
|
return self.copy_with(res_paths, res_groups)
|
|
|
|
def infill(self, density: float, rotation_deg: float = 0.0, infill_type: str = 'GRID', layer_rotation: Any = None) -> SlicePaths:
|
|
"""Generates infill for the paths."""
|
|
p = self
|
|
if layer_rotation and any(layer_rotation):
|
|
from mathutils import Euler
|
|
mat = Euler(layer_rotation).to_matrix().inverted()
|
|
p = self.transform(np.array(mat))
|
|
|
|
res_paths, res_groups = infill_slice(p, density, rotation_deg=rotation_deg, infill_type=infill_type)
|
|
new_paths = self.copy_with(res_paths, res_groups)
|
|
|
|
if layer_rotation and any(layer_rotation):
|
|
from mathutils import Euler
|
|
mat_inv = Euler(layer_rotation).to_matrix()
|
|
new_paths = new_paths.transform(np.array(mat_inv))
|
|
|
|
return new_paths
|
|
|
|
def rotate(self, angle_deg: float, center: np.ndarray | None = None) -> SlicePaths:
|
|
"""Rotates the paths around Z-axis."""
|
|
if angle_deg == 0:
|
|
return self
|
|
|
|
if center is None:
|
|
center = self.get_first_layer_midpoint()
|
|
|
|
angle_rad = np.radians(angle_deg)
|
|
c, s = np.cos(angle_rad), np.sin(angle_rad)
|
|
rot_mat = np.array([
|
|
[c, -s, 0.0],
|
|
[s, c, 0.0],
|
|
[0.0, 0.0, 1.0]
|
|
], dtype=np.float64)
|
|
|
|
new_paths = []
|
|
for p in self:
|
|
# Shift to center, rotate, shift back, preserving exact original Z
|
|
new_p = (p - center) @ rot_mat.T + center
|
|
if len(p) > 0 and p.shape[1] >= 3:
|
|
new_p[:, 2] = p[:, 2]
|
|
new_paths.append(new_p)
|
|
|
|
res = self.copy_with(new_paths)
|
|
r_arr = np.array([0.0, 0.0, float(angle_rad)], dtype=np.float64)
|
|
res.transforms = update_transform(getattr(self, "transforms", []), r_arr=r_arr)
|
|
if res.transforms:
|
|
res.transforms[0]["angle_deg"] = float(angle_deg)
|
|
if center is not None:
|
|
res.transforms[0]["center"] = center.tolist() if isinstance(center, np.ndarray) else list(center)
|
|
return res
|
|
|
|
def get_first_layer_midpoint(self) -> np.ndarray:
|
|
if not self:
|
|
return np.zeros(3)
|
|
|
|
# Find true minimum Z layer in integer microns across non-empty paths
|
|
min_z_um = min((int(round(p[0, 2] * SCALE)) for p in self if len(p) > 0), default=None)
|
|
if min_z_um is None:
|
|
return np.zeros(3)
|
|
|
|
first_layer_pts = [p for p in self if len(p) > 0 and int(round(p[0, 2] * SCALE)) == min_z_um]
|
|
if not first_layer_pts:
|
|
return np.zeros(3)
|
|
|
|
pts = np.concatenate(first_layer_pts)
|
|
min_pts = np.min(pts, axis=0)
|
|
max_pts = np.max(pts, axis=0)
|
|
return (min_pts + max_pts) / 2.0
|
|
|
|
def boolean(self, other: SlicePaths, operation: str = 'UNION', connect_along_boundary: bool = False) -> SlicePaths:
|
|
return boolean_slice(self, other, operation, connect_along_boundary=connect_along_boundary)
|
|
|
|
@classmethod
|
|
def path_operations(cls, paths_list: list[SlicePaths], operation: str = 'ZIP', connect_along_boundary: bool = False) -> SlicePaths:
|
|
"""Executes Path Operations across multiple SlicePaths inputs."""
|
|
return path_operations_slice(paths_list, operation=operation, connect_along_boundary=connect_along_boundary)
|
|
|
|
@classmethod
|
|
def minimum_coverage(cls, paths_list: list[SlicePaths], connect_along_boundary: bool = False) -> SlicePaths:
|
|
"""Computes Minimum Coverage across multiple SlicePaths inputs."""
|
|
return minimum_coverage_slice(paths_list, connect_along_boundary=connect_along_boundary)
|
|
|
|
@classmethod
|
|
def maximum_coverage(cls, paths_list: list[SlicePaths], connect_along_boundary: bool = False) -> SlicePaths:
|
|
"""Computes Maximum Coverage across multiple SlicePaths inputs."""
|
|
return maximum_coverage_slice(paths_list, connect_along_boundary=connect_along_boundary)
|
|
|
|
@classmethod
|
|
def intersection(cls, paths_list: list[SlicePaths], connect_along_boundary: bool = False) -> SlicePaths:
|
|
"""Computes Intersection across multiple SlicePaths inputs."""
|
|
return intersection_slice(paths_list, connect_along_boundary=connect_along_boundary)
|
|
|
|
@classmethod
|
|
def subtract(cls, paths_list: list[SlicePaths], connect_along_boundary: bool = False) -> SlicePaths:
|
|
"""Computes Difference (Subtract) across multiple SlicePaths inputs."""
|
|
return subtract_slice(paths_list, connect_along_boundary=connect_along_boundary)
|
|
|
|
@classmethod
|
|
def xor(cls, paths_list: list[SlicePaths], connect_along_boundary: bool = False) -> SlicePaths:
|
|
"""Computes Exclusive Or (XOR) across multiple SlicePaths inputs."""
|
|
return xor_slice(paths_list, connect_along_boundary=connect_along_boundary)
|
|
|
|
def extend_with_groups(self, other: SlicePaths | list[np.ndarray]):
|
|
if isinstance(other, SlicePaths):
|
|
self.extend(other)
|
|
self.groups.extend(other.groups)
|
|
self.extrusion_rate.extend(other.extrusion_rate)
|
|
self.feedrate.extend(other.feedrate)
|
|
for k, v in other.extra_attributes.items():
|
|
if k in self.extra_attributes:
|
|
self.extra_attributes[k].extend(v)
|
|
else:
|
|
# Pad with zeros for existing paths if we didn't have this attribute
|
|
self.extra_attributes[k] = [np.zeros(len(p)) for p in self[:-len(other)]] + list(v)
|
|
# Pad existing attributes if 'other' didn't have them
|
|
for k in self.extra_attributes:
|
|
if k not in other.extra_attributes:
|
|
self.extra_attributes[k].extend([np.zeros(len(p)) for p in other])
|
|
else:
|
|
self.extend(other)
|
|
self.groups.extend(list(range(0,len(other))))
|
|
self.extrusion_rate.extend([np.ones(len(p)) for p in other])
|
|
self.feedrate.extend([np.zeros(len(p)) for p in other])
|
|
for k in self.extra_attributes:
|
|
self.extra_attributes[k].extend([np.zeros(len(p)) for p in other])
|
|
|
|
def append_with_group(self, path: np.ndarray, group: int = 0, extrusion_rate: np.ndarray | None = None, feedrate: np.ndarray | None = None, extra_attributes: dict[str, np.ndarray] | None = None):
|
|
self.append(path)
|
|
self.groups.append(group)
|
|
self.extrusion_rate.append(extrusion_rate if extrusion_rate is not None else np.ones(len(path)))
|
|
self.feedrate.append(feedrate if feedrate is not None else np.zeros(len(path)))
|
|
|
|
extra_attributes = extra_attributes or {}
|
|
for k, v in extra_attributes.items():
|
|
if k in self.extra_attributes:
|
|
self.extra_attributes[k].append(v)
|
|
else:
|
|
self.extra_attributes[k] = [np.zeros(len(p)) for p in self[:-1]] + [v]
|
|
|
|
for k in self.extra_attributes:
|
|
if k not in extra_attributes:
|
|
self.extra_attributes[k].append(np.zeros(len(path)))
|
|
|
|
def normalize_groups(self) -> None:
|
|
self.groups = [m.setdefault(x, len(m)) for m in [{}] for x in self.groups]
|
|
|
|
def align_seams(self, mode: str = "sharpest") -> SlicePaths:
|
|
"""
|
|
Aligns the seams (starting point of closed paths) in SlicePaths.
|
|
Supported modes: 'sharpest'.
|
|
Uses 1-micron integer math.
|
|
"""
|
|
if not self or mode != "sharpest":
|
|
return self
|
|
|
|
new_paths: list[np.ndarray] = []
|
|
new_ext: list[np.ndarray] = []
|
|
new_feed: list[np.ndarray] = []
|
|
new_extra: dict[str, list[np.ndarray]] = {k: [] for k in self.extra_attributes}
|
|
|
|
for i, p in enumerate(self):
|
|
if len(p) < 4 or not is_closed_path(p):
|
|
new_paths.append(p)
|
|
new_ext.append(self.extrusion_rate[i])
|
|
new_feed.append(self.feedrate[i])
|
|
for k in self.extra_attributes:
|
|
new_extra[k].append(self.extra_attributes[k][i])
|
|
continue
|
|
|
|
pts = p[:-1]
|
|
M = len(pts)
|
|
if M < 3:
|
|
new_paths.append(p)
|
|
new_ext.append(self.extrusion_rate[i])
|
|
new_feed.append(self.feedrate[i])
|
|
for k in self.extra_attributes:
|
|
new_extra[k].append(self.extra_attributes[k][i])
|
|
continue
|
|
|
|
pts_um = np.round(pts[:, :2] * SCALE).astype(np.int64)
|
|
x = pts_um[:, 0]
|
|
y = pts_um[:, 1]
|
|
x_next = np.roll(x, -1)
|
|
y_next = np.roll(y, -1)
|
|
signed_area_um = np.sum(x * y_next - x_next * y)
|
|
|
|
prev_pts = np.roll(pts_um, 1, axis=0)
|
|
next_pts = np.roll(pts_um, -1, axis=0)
|
|
|
|
v_in = pts_um - prev_pts
|
|
v_out = next_pts - pts_um
|
|
|
|
l2_in = np.sum(v_in**2, axis=1)
|
|
l2_out = np.sum(v_out**2, axis=1)
|
|
|
|
valid = (l2_in > 0) & (l2_out > 0)
|
|
if not np.any(valid) or signed_area_um == 0:
|
|
best_idx = 0
|
|
else:
|
|
cross_2d = v_in[:, 0] * v_out[:, 1] - v_in[:, 1] * v_out[:, 0]
|
|
if signed_area_um > 0:
|
|
concave_mask = valid & (cross_2d < 0)
|
|
else:
|
|
concave_mask = valid & (cross_2d > 0)
|
|
|
|
l_in = np.sqrt(l2_in.astype(np.float64))
|
|
l_out = np.sqrt(l2_out.astype(np.float64))
|
|
|
|
cos_angles = np.full(M, 1.0)
|
|
dot_prods = np.sum(v_in[valid] * v_out[valid], axis=1).astype(np.float64)
|
|
cos_angles[valid] = dot_prods / (l_in[valid] * l_out[valid])
|
|
|
|
if np.any(concave_mask):
|
|
concave_indices = np.where(concave_mask)[0]
|
|
best_idx = int(concave_indices[np.argmin(cos_angles[concave_mask])])
|
|
else:
|
|
best_idx = int(np.argmin(cos_angles))
|
|
|
|
if best_idx == 0:
|
|
new_paths.append(p)
|
|
new_ext.append(self.extrusion_rate[i])
|
|
new_feed.append(self.feedrate[i])
|
|
for k in self.extra_attributes:
|
|
new_extra[k].append(self.extra_attributes[k][i])
|
|
else:
|
|
new_p = np.vstack([p[best_idx:-1], p[:best_idx + 1]])
|
|
new_paths.append(new_p)
|
|
|
|
ext = self.extrusion_rate[i]
|
|
if len(ext) == len(p):
|
|
new_ext.append(np.concatenate([ext[best_idx:-1], ext[:best_idx + 1]]))
|
|
else:
|
|
new_ext.append(ext)
|
|
|
|
feed = self.feedrate[i]
|
|
if len(feed) == len(p):
|
|
new_feed.append(np.concatenate([feed[best_idx:-1], feed[:best_idx + 1]]))
|
|
else:
|
|
new_feed.append(feed)
|
|
|
|
for k in self.extra_attributes:
|
|
attr = self.extra_attributes[k][i]
|
|
if len(attr) == len(p):
|
|
new_extra[k].append(np.concatenate([attr[best_idx:-1], attr[:best_idx + 1]]))
|
|
else:
|
|
new_extra[k].append(attr)
|
|
|
|
return self.copy_with(new_paths, extrusion_rate=new_ext, feedrate=new_feed, extra_attributes=new_extra)
|
|
|
|
def _slice_single_mesh(mesh_array: np.ndarray, z_values: np.ndarray | list[float], ignore_holes: bool = False) -> list[np.ndarray]:
|
|
"""
|
|
Slices a single 3D triangle mesh (shape (F, 4, 3) or (F, 3, 3)) across multiple Z heights.
|
|
Uses manifold3d for robust solid slicing with exact coplanar/topological resolution,
|
|
and falls back to trimesh for open/non-manifold surface meshes.
|
|
"""
|
|
if len(mesh_array) == 0:
|
|
return []
|
|
|
|
z_arr = np.asarray(z_values, dtype=np.float64)
|
|
if len(z_arr) == 0:
|
|
return []
|
|
|
|
tris = mesh_array[:, :3, :] # Shape (F, 3, 3)
|
|
flat = np.ascontiguousarray(tris.reshape(-1, 3), dtype=np.float32)
|
|
void_dtype = np.dtype((np.void, flat.dtype.itemsize * flat.shape[1]))
|
|
flat_void = flat.view(void_dtype).ravel()
|
|
unique_voids, inv = np.unique(flat_void, return_inverse=True)
|
|
unique_verts = unique_voids.view(flat.dtype).reshape(-1, 3)
|
|
faces = inv.reshape(-1, 3).astype(np.uint32)
|
|
|
|
all_paths: list[np.ndarray] = []
|
|
|
|
# 1. Primary: manifold3d for solid meshes (fastest, guaranteed watertight topological output)
|
|
try:
|
|
mesh_m = manifold3d.Mesh(
|
|
vert_properties=np.ascontiguousarray(unique_verts),
|
|
tri_verts=np.ascontiguousarray(faces)
|
|
)
|
|
m = manifold3d.Manifold(mesh_m)
|
|
# If mesh has inverted normals (negative volume), flip triangle winding
|
|
if m.status() == manifold3d.Error.NoError and m.volume() < 0:
|
|
faces_flipped = faces[:, [0, 2, 1]]
|
|
mesh_m_flipped = manifold3d.Mesh(
|
|
vert_properties=np.ascontiguousarray(unique_verts),
|
|
tri_verts=np.ascontiguousarray(faces_flipped)
|
|
)
|
|
m = manifold3d.Manifold(mesh_m_flipped)
|
|
|
|
if m.status() == manifold3d.Error.NoError and not m.is_empty():
|
|
min_z = float(np.min(unique_verts[:, 2]))
|
|
max_z = float(np.max(unique_verts[:, 2]))
|
|
|
|
for z in z_arr:
|
|
zf = float(z)
|
|
if zf < min_z - 1e-4 or zf > max_z + 1e-4:
|
|
continue
|
|
# If slicing at boundary face, apply micro-epsilon so boundary is cleanly captured
|
|
slice_z = zf - 1e-5 if abs(zf - max_z) < 1e-5 else (zf + 1e-5 if abs(zf - min_z) < 1e-5 else zf)
|
|
cs = m.slice(slice_z)
|
|
polys = cs.to_polygons()
|
|
for poly in polys:
|
|
N = len(poly)
|
|
if N < 3:
|
|
continue
|
|
if ignore_holes:
|
|
p_x = poly[:, 0]
|
|
p_y = poly[:, 1]
|
|
signed_area = np.sum(p_x[:-1] * p_y[1:] - p_x[1:] * p_y[:-1]) + (p_x[-1] * p_y[0] - p_x[0] * p_y[-1])
|
|
if signed_area <= 0:
|
|
continue
|
|
p3d = np.empty((N + 1, 3), dtype=np.float64)
|
|
p3d[:N, :2] = poly
|
|
p3d[:N, 2] = zf
|
|
p3d[N] = p3d[0]
|
|
p3d = align_path_seam_sharpest(p3d)
|
|
all_paths.append(p3d)
|
|
return all_paths
|
|
except Exception:
|
|
pass
|
|
|
|
# 2. Fallback: trimesh multiplane slicing for open sheets / non-manifold meshes
|
|
try:
|
|
min_z = float(np.min(unique_verts[:, 2]))
|
|
max_z = float(np.max(unique_verts[:, 2]))
|
|
slice_heights = np.where(
|
|
np.abs(z_arr - max_z) < 1e-5,
|
|
z_arr - 1e-5,
|
|
np.where(np.abs(z_arr - min_z) < 1e-5, z_arr + 1e-5, z_arr)
|
|
)
|
|
tm = trimesh.Trimesh(vertices=unique_verts, faces=faces, process=False)
|
|
lines, _, _ = trimesh.intersections.mesh_multiplane(
|
|
mesh=tm,
|
|
plane_origin=[0.0, 0.0, 0.0],
|
|
plane_normal=[0.0, 0.0, 1.0],
|
|
heights=slice_heights
|
|
)
|
|
for z, line_segs in zip(z_arr, lines):
|
|
if line_segs is None or len(line_segs) == 0:
|
|
continue
|
|
zf = float(z)
|
|
seg2d = np.asarray(line_segs, dtype=np.float64)
|
|
z_col = np.full((len(seg2d), 2, 1), zf, dtype=np.float64)
|
|
seg3d = np.concatenate([seg2d, z_col], axis=-1)
|
|
slice_paths = segments_to_slice(seg3d, ignore_holes=ignore_holes)
|
|
all_paths.extend(slice_paths)
|
|
except Exception:
|
|
pass
|
|
|
|
return all_paths
|
|
|
|
|
|
def slice_at_range(models: list[np.ndarray], z_values: np.ndarray | list[float], ignore_holes: bool = False) -> list[list[np.ndarray]]:
|
|
"""
|
|
Top-level slicing function that slices multiple model meshes across given Z heights.
|
|
Returns a list of path lists (one per model).
|
|
"""
|
|
if not models:
|
|
return []
|
|
|
|
mmps: list[list[np.ndarray]] = []
|
|
for model in models:
|
|
model_paths = _slice_single_mesh(model, z_values, ignore_holes=ignore_holes)
|
|
mmps.append(model_paths)
|
|
return mmps
|
|
|
|
def link_segments(segments: np.ndarray) -> list[np.ndarray]:
|
|
"""Links segments (N, 2, 3) into continuous paths using 1-micron integer grid."""
|
|
if len(segments) == 0: return []
|
|
z = segments[0, 0, 2]
|
|
pts = segments[:, :, :2]
|
|
|
|
# Quantize to integer micron tuples: (x_um, y_um)
|
|
pts_um = np.round(pts * SCALE).astype(np.int64)
|
|
|
|
adj: dict[tuple[int, int], list[tuple[tuple[int, int], int]]] = {}
|
|
for i in range(len(segments)):
|
|
k1 = (int(pts_um[i, 0, 0]), int(pts_um[i, 0, 1]))
|
|
k2 = (int(pts_um[i, 1, 0]), int(pts_um[i, 1, 1]))
|
|
if k1 == k2:
|
|
continue
|
|
adj.setdefault(k1, []).append((k2, i))
|
|
adj.setdefault(k2, []).append((k1, i))
|
|
|
|
paths = []
|
|
used = set()
|
|
for i in range(len(segments)):
|
|
if i in used:
|
|
continue
|
|
k1 = (int(pts_um[i, 0, 0]), int(pts_um[i, 0, 1]))
|
|
k2 = (int(pts_um[i, 1, 0]), int(pts_um[i, 1, 1]))
|
|
if k1 == k2:
|
|
continue
|
|
|
|
path_pts = [pts[i, 0], pts[i, 1]]
|
|
used.add(i)
|
|
|
|
# Forward
|
|
curr_k = k2
|
|
while True:
|
|
found = False
|
|
for next_k, idx in adj.get(curr_k, []):
|
|
if idx not in used:
|
|
pk1 = (int(pts_um[idx, 0, 0]), int(pts_um[idx, 0, 1]))
|
|
other_pt = pts[idx, 1] if pk1 == curr_k else pts[idx, 0]
|
|
path_pts.append(other_pt)
|
|
used.add(idx)
|
|
curr_k = next_k
|
|
found = True
|
|
break
|
|
if not found:
|
|
break
|
|
|
|
# Backward
|
|
curr_k = k1
|
|
while True:
|
|
found = False
|
|
for next_k, idx in adj.get(curr_k, []):
|
|
if idx not in used:
|
|
pk1 = (int(pts_um[idx, 0, 0]), int(pts_um[idx, 0, 1]))
|
|
other_pt = pts[idx, 1] if pk1 == curr_k else pts[idx, 0]
|
|
path_pts.insert(0, other_pt)
|
|
used.add(idx)
|
|
curr_k = next_k
|
|
found = True
|
|
break
|
|
if not found:
|
|
break
|
|
|
|
p_arr = np.array(path_pts)
|
|
if len(p_arr) > 2 and is_closed_path(p_arr):
|
|
p_arr[-1] = p_arr[0]
|
|
|
|
res = np.column_stack([p_arr, np.full(len(p_arr), z)])
|
|
paths.append(res)
|
|
return paths
|
|
|
|
@ndarray_lru_cache(maxsize=128)
|
|
def segments_to_slice(segments: np.ndarray, ignore_holes: bool = False) -> list[np.ndarray]:
|
|
|
|
linked = link_segments(segments)
|
|
if not linked: return []
|
|
|
|
closed_paths = []
|
|
open_paths = []
|
|
for p in linked:
|
|
path_pts = p[:, :2]
|
|
if len(path_pts) > 3 and is_closed_path(p):
|
|
closed_paths.append(p)
|
|
else:
|
|
open_paths.append(p)
|
|
|
|
res = []
|
|
z = segments[0, 0, 2]
|
|
|
|
if closed_paths:
|
|
pc = pyclipper.Pyclipper() # pyright: ignore[reportAttributeAccessIssue]
|
|
for p in closed_paths:
|
|
path_pts = p[:, :2]
|
|
path_pts = path_pts[:-1]
|
|
scaled = (path_pts * SCALE).round().astype(np.int64).tolist()
|
|
if abs(pyclipper.Area(scaled)) < 1:
|
|
continue
|
|
pc.AddPath(scaled, pyclipper.PT_SUBJECT, closed = True) # pyright: ignore[reportAttributeAccessIssue]
|
|
|
|
if ignore_holes:
|
|
tree = pc.Execute2(pyclipper.CT_UNION, pyclipper.PFT_EVENODD, pyclipper.PFT_EVENODD) # pyright: ignore[reportAttributeAccessIssue]
|
|
def collect_outer_contours(node) -> list[list[int]]:
|
|
contours = []
|
|
if hasattr(node, 'Contour') and node.Contour and not getattr(node, 'IsHole', False):
|
|
contours.append(node.Contour)
|
|
if hasattr(node, 'Childs') and node.Childs:
|
|
for child in node.Childs:
|
|
contours.extend(collect_outer_contours(child))
|
|
return contours
|
|
|
|
solution = collect_outer_contours(tree)
|
|
else:
|
|
# Use EVENODD for robust hole detection.
|
|
solution = pc.Execute(pyclipper.CT_UNION, pyclipper.PFT_EVENODD, pyclipper.PFT_EVENODD) # pyright: ignore[reportAttributeAccessIssue]
|
|
|
|
for path in solution:
|
|
if not path: continue
|
|
fpath = np.array(path, dtype=np.float64) / SCALE
|
|
fpath_3d = np.column_stack([fpath, np.full(len(fpath), z)])
|
|
# Close loop by appending the first point for internal consistency
|
|
fpath_3d = np.vstack([fpath_3d, fpath_3d[0]])
|
|
fpath_3d = align_path_seam_sharpest(fpath_3d)
|
|
res.append(fpath_3d)
|
|
|
|
res.extend(open_paths)
|
|
return res
|
|
|
|
def find_point_on_loop(pt: np.ndarray, loop: np.ndarray, tol: float = 1.0) -> tuple[int, float]:
|
|
"""Finds the edge index and parameter u [0,1] for a point on a loop. loop is (N,2) and closed."""
|
|
p1 = loop[:-1]
|
|
p2 = loop[1:]
|
|
v = p2 - p1
|
|
w = pt - p1
|
|
|
|
l2 = np.sum(v**2, axis=1)
|
|
mask = l2 > 0
|
|
u = np.zeros(len(l2))
|
|
u[mask] = np.sum(w[mask] * v[mask], axis=1) / l2[mask]
|
|
u = np.clip(u, 0, 1)
|
|
|
|
dist_sq = np.sum((p1 + u[:, np.newaxis] * v - pt)**2, axis=1)
|
|
min_idx = np.argmin(dist_sq)
|
|
if dist_sq[min_idx] < tol**2:
|
|
return int(min_idx), float(u[min_idx])
|
|
return -1, 0.0
|
|
|
|
def get_boundary_path(pt1: np.ndarray, pt2: np.ndarray, loops: list[np.ndarray]) -> np.ndarray | None:
|
|
"""Returns the shortest path along one of the loops between pt1 and pt2."""
|
|
best_path = None
|
|
min_len = float('inf')
|
|
|
|
for loop in loops:
|
|
# Ensure it's closed for logic.
|
|
working_loop = loop
|
|
if not is_closed_path(loop):
|
|
working_loop = np.vstack([loop, loop[0]])
|
|
|
|
idx1, u1 = find_point_on_loop(pt1, working_loop)
|
|
if idx1 == -1: continue
|
|
idx2, u2 = find_point_on_loop(pt2, working_loop)
|
|
if idx2 == -1: continue
|
|
|
|
# Found on same loop. Try forward and backward.
|
|
num_verts = len(working_loop) - 1
|
|
|
|
# Forward path
|
|
fwd_pts = [pt1]
|
|
curr = idx1
|
|
while curr != idx2:
|
|
curr = (curr + 1) % num_verts
|
|
fwd_pts.append(working_loop[curr])
|
|
fwd_pts.append(pt2)
|
|
fwd_path = np.array(fwd_pts)
|
|
fwd_len = np.sum(np.sqrt(np.sum(np.diff(fwd_path, axis=0)**2, axis=1)))
|
|
|
|
# Backward path
|
|
bwd_pts = [pt1]
|
|
curr = idx1
|
|
while curr != (idx2 + 1) % num_verts:
|
|
bwd_pts.append(working_loop[curr])
|
|
curr = (curr - 1 + num_verts) % num_verts
|
|
bwd_pts.append(pt2)
|
|
bwd_path = np.array(bwd_pts)
|
|
bwd_len = np.sum(np.sqrt(np.sum(np.diff(bwd_path, axis=0)**2, axis=1)))
|
|
|
|
if fwd_len < bwd_len:
|
|
if fwd_len < min_len:
|
|
min_len = fwd_len
|
|
best_path = fwd_path
|
|
else:
|
|
if bwd_len < min_len:
|
|
min_len = bwd_len
|
|
best_path = bwd_path
|
|
|
|
return best_path
|
|
|
|
def _group_closed_paths_into_shapes(closed_paths: list[np.ndarray]) -> list[list[np.ndarray]]:
|
|
"""
|
|
Groups a list of closed paths on a layer into distinct polygon shapes,
|
|
where each shape consists of one outer boundary and zero or more inner holes.
|
|
Uses topological containment with representative points:
|
|
- Paths contained in an even number of enclosing paths (0, 2, ...) are Outer boundaries.
|
|
- Paths contained in an odd number of enclosing paths (1, 3, ...) are Holes belonging
|
|
to the immediately enclosing Outer boundary.
|
|
"""
|
|
if not closed_paths:
|
|
return []
|
|
if len(closed_paths) == 1:
|
|
return [[closed_paths[0]]]
|
|
|
|
polys: list[tuple[int, np.ndarray, Polygon, float]] = []
|
|
for i, p in enumerate(closed_paths):
|
|
pts = p[:, :2]
|
|
if len(pts) < 3:
|
|
continue
|
|
try:
|
|
poly = Polygon(pts)
|
|
if not poly.is_valid:
|
|
poly = poly.buffer(0)
|
|
if not poly.is_empty:
|
|
polys.append((i, p, poly, float(poly.area)))
|
|
except Exception:
|
|
continue
|
|
|
|
if not polys:
|
|
return [[p] for p in closed_paths]
|
|
if len(polys) == 1:
|
|
return [[polys[0][1]]]
|
|
|
|
# Sort descending by area so enclosing paths come before enclosed ones
|
|
polys.sort(key=lambda x: x[3], reverse=True)
|
|
|
|
# For each polygon, find all strictly larger polygons that contain its representative point
|
|
shapes_map: dict[int, list[np.ndarray]] = {}
|
|
outer_entries: list[tuple[int, Polygon, float]] = []
|
|
|
|
for idx, p, poly, area in polys:
|
|
rep_pt = poly.representative_point()
|
|
# Find all larger polygons containing this representative point
|
|
containers = [
|
|
(c_idx, c_poly, c_area)
|
|
for (c_idx, c_p, c_poly, c_area) in polys
|
|
if c_area > area and (c_poly.contains(rep_pt) or c_poly.buffer(1e-5).contains(rep_pt))
|
|
]
|
|
|
|
if len(containers) % 2 == 0:
|
|
# Even enclosing count -> Outer boundary
|
|
shapes_map[idx] = [p]
|
|
outer_entries.append((idx, poly, area))
|
|
else:
|
|
# Odd enclosing count -> Hole belonging to the smallest enclosing outer
|
|
# Find the enclosing outer with the smallest area
|
|
enclosing_outers = [
|
|
(o_idx, o_poly, o_area)
|
|
for (o_idx, o_poly, o_area) in outer_entries
|
|
if o_area > area and (o_poly.contains(rep_pt) or o_poly.buffer(1e-5).contains(rep_pt))
|
|
]
|
|
if enclosing_outers:
|
|
# Smallest area is the last one because outer_entries is in descending order
|
|
parent_idx = enclosing_outers[-1][0]
|
|
shapes_map[parent_idx].append(p)
|
|
elif outer_entries:
|
|
shapes_map[outer_entries[-1][0]].append(p)
|
|
else:
|
|
shapes_map[idx] = [p]
|
|
|
|
return list(shapes_map.values())
|
|
|
|
@ndarray_lru_cache(maxsize=128)
|
|
def boolean_slice(subject: SlicePaths, object: SlicePaths, operation: str = 'UNION', connect_along_boundary: bool = False) -> SlicePaths:
|
|
all_z_keys: set[int] = set()
|
|
subject_dict: dict[int, list[np.ndarray]] = {}
|
|
object_dict: dict[int, list[np.ndarray]] = {}
|
|
|
|
for s in subject:
|
|
if len(s) < 2:
|
|
continue
|
|
z = int(round(s[0, 2] * SCALE))
|
|
all_z_keys.add(z)
|
|
subject_dict.setdefault(z, []).append(s)
|
|
|
|
for s in object:
|
|
if len(s) < 2:
|
|
continue
|
|
z = int(round(s[0, 2] * SCALE))
|
|
all_z_keys.add(z)
|
|
object_dict.setdefault(z, []).append(s)
|
|
|
|
results: SlicePaths = SlicePaths()
|
|
|
|
group = 0
|
|
for k in sorted(all_z_keys):
|
|
subject_slices = subject_dict.get(k, [])
|
|
object_slices = object_dict.get(k, [])
|
|
|
|
if not subject_slices:
|
|
if object_slices and operation == 'UNION':
|
|
results.extend(object_slices)
|
|
results.groups.extend([group] * len(object_slices))
|
|
group += 1
|
|
continue
|
|
|
|
if not object_slices:
|
|
if operation != 'INTERSECTION':
|
|
results.extend(subject_slices)
|
|
results.groups.extend([group] * len(subject_slices))
|
|
group += 1
|
|
continue
|
|
|
|
z = float(k) / SCALE
|
|
|
|
pc = pyclipper.Pyclipper() # pyright: ignore[reportAttributeAccessIssue]
|
|
|
|
layer_results = []
|
|
|
|
sub_closed = [s for s in subject_slices if is_closed_path(s)]
|
|
sub_open = [s for s in subject_slices if not is_closed_path(s)]
|
|
obj_closed = [s for s in object_slices if is_closed_path(s)]
|
|
obj_open = [s for s in object_slices if not is_closed_path(s)]
|
|
|
|
sub_closed_scaled = [(s[:, :2][:-1] * SCALE).round().astype(np.int64).tolist() for s in sub_closed]
|
|
sub_closed_pts = [s[:, :2] for s in sub_closed]
|
|
obj_closed_scaled = [(s[:, :2][:-1] * SCALE).round().astype(np.int64).tolist() for s in obj_closed]
|
|
obj_closed_pts = [s[:, :2] for s in obj_closed]
|
|
|
|
def process_open_op(open_paths, clips_scaled, clips_pts, op_type):
|
|
for s in open_paths:
|
|
path_pts = s[:, :2]
|
|
pc.Clear()
|
|
if not connect_along_boundary:
|
|
pc.AddPath((path_pts * SCALE).round().astype(np.int64).tolist(), pyclipper.PT_SUBJECT, False)
|
|
for c in clips_scaled:
|
|
pc.AddPath(c, pyclipper.PT_CLIP, True)
|
|
solution = pc.Execute2(op_type, pyclipper.PFT_EVENODD, pyclipper.PFT_EVENODD)
|
|
for path in pyclipper.OpenPathsFromPolyTree(solution):
|
|
fpath = np.array(path, dtype=np.float64) / SCALE
|
|
layer_results.append(np.column_stack([fpath, np.full(len(fpath), z)]))
|
|
else:
|
|
pc.AddPath((path_pts * SCALE).round().astype(np.int64).tolist(), pyclipper.PT_SUBJECT, False)
|
|
for c in clips_scaled:
|
|
pc.AddPath(c, pyclipper.PT_CLIP, True)
|
|
solution = pc.Execute2(op_type, pyclipper.PFT_EVENODD, pyclipper.PFT_EVENODD)
|
|
open_results = pyclipper.OpenPathsFromPolyTree(solution)
|
|
if not open_results:
|
|
continue
|
|
|
|
def get_p(pt):
|
|
p1 = path_pts[:-1]
|
|
p2 = path_pts[1:]
|
|
v = p2 - p1
|
|
w = pt - p1
|
|
l2 = np.sum(v**2, axis=1)
|
|
mask = l2 > 0
|
|
u = np.zeros(len(l2))
|
|
u[mask] = np.sum(w[mask] * v[mask], axis=1) / l2[mask]
|
|
u = np.clip(u, 0, 1)
|
|
dist_sq = np.sum((p1 + u[:, np.newaxis] * v - pt)**2, axis=1)
|
|
i = np.argmin(dist_sq)
|
|
return i + u[i]
|
|
|
|
segments = []
|
|
for path in open_results:
|
|
fpath = np.array(path, dtype=np.float64) / SCALE
|
|
p_start = get_p(fpath[0])
|
|
p_end = get_p(fpath[-1])
|
|
if p_start > p_end:
|
|
fpath = fpath[::-1]
|
|
p_start, p_end = p_end, p_start
|
|
segments.append((p_start, fpath))
|
|
|
|
segments.sort(key=lambda x: x[0])
|
|
current_group = [segments[0][1]]
|
|
for i in range(1, len(segments)):
|
|
prev_end = current_group[-1][-1] * SCALE
|
|
curr_start = segments[i][1][0] * SCALE
|
|
scaled_loops = [(pts * SCALE) for pts in clips_pts]
|
|
b_path = get_boundary_path(prev_end, curr_start, scaled_loops)
|
|
|
|
if b_path is not None:
|
|
if len(b_path) > 2:
|
|
current_group.append(b_path[1:-1] / SCALE)
|
|
current_group.append(segments[i][1])
|
|
else:
|
|
path_to_add = np.vstack(current_group)
|
|
layer_results.append(np.column_stack([path_to_add, np.full(len(path_to_add), z)]))
|
|
current_group = [segments[i][1]]
|
|
|
|
path_to_add = np.vstack(current_group)
|
|
layer_results.append(np.column_stack([path_to_add, np.full(len(path_to_add), z)]))
|
|
|
|
if operation == 'INTERSECTION':
|
|
if sub_closed and obj_closed:
|
|
pc.Clear()
|
|
for c in sub_closed_scaled:
|
|
pc.AddPath(c, pyclipper.PT_SUBJECT, True)
|
|
for c in obj_closed_scaled:
|
|
pc.AddPath(c, pyclipper.PT_CLIP, True)
|
|
solution = pc.Execute2(pyclipper.CT_INTERSECTION, pyclipper.PFT_EVENODD, pyclipper.PFT_EVENODD)
|
|
for path in pyclipper.ClosedPathsFromPolyTree(solution):
|
|
fpath = np.array(path, dtype=np.float64) / SCALE
|
|
fpath_3d = np.column_stack([fpath, np.full(len(fpath), z)])
|
|
fpath_3d = np.vstack([fpath_3d, fpath_3d[0]])
|
|
layer_results.append(align_path_seam_sharpest(fpath_3d))
|
|
for path in pyclipper.OpenPathsFromPolyTree(solution):
|
|
fpath = np.array(path, dtype=np.float64) / SCALE
|
|
layer_results.append(np.column_stack([fpath, np.full(len(fpath), z)]))
|
|
|
|
if sub_open and obj_closed_scaled:
|
|
process_open_op(sub_open, obj_closed_scaled, obj_closed_pts, pyclipper.CT_INTERSECTION)
|
|
if obj_open and sub_closed_scaled:
|
|
process_open_op(obj_open, sub_closed_scaled, sub_closed_pts, pyclipper.CT_INTERSECTION)
|
|
|
|
elif operation == 'DIFFERENCE':
|
|
if sub_closed and obj_closed:
|
|
pc.Clear()
|
|
for c in sub_closed_scaled:
|
|
pc.AddPath(c, pyclipper.PT_SUBJECT, True)
|
|
for c in obj_closed_scaled:
|
|
pc.AddPath(c, pyclipper.PT_CLIP, True)
|
|
solution = pc.Execute2(pyclipper.CT_DIFFERENCE, pyclipper.PFT_EVENODD, pyclipper.PFT_EVENODD)
|
|
for path in pyclipper.ClosedPathsFromPolyTree(solution):
|
|
fpath = np.array(path, dtype=np.float64) / SCALE
|
|
fpath_3d = np.column_stack([fpath, np.full(len(fpath), z)])
|
|
fpath_3d = np.vstack([fpath_3d, fpath_3d[0]])
|
|
layer_results.append(align_path_seam_sharpest(fpath_3d))
|
|
for path in pyclipper.OpenPathsFromPolyTree(solution):
|
|
fpath = np.array(path, dtype=np.float64) / SCALE
|
|
layer_results.append(np.column_stack([fpath, np.full(len(fpath), z)]))
|
|
elif sub_closed and not obj_closed:
|
|
layer_results.extend(sub_closed)
|
|
|
|
if sub_open and obj_closed_scaled:
|
|
process_open_op(sub_open, obj_closed_scaled, obj_closed_pts, pyclipper.CT_DIFFERENCE)
|
|
elif sub_open and not obj_closed:
|
|
layer_results.extend(sub_open)
|
|
|
|
else:
|
|
# UNION, XOR
|
|
pc.Clear()
|
|
for c in sub_closed_scaled:
|
|
pc.AddPath(c, pyclipper.PT_SUBJECT, True)
|
|
for s in sub_open:
|
|
path_pts = s[:, :2]
|
|
scaled = (path_pts * SCALE).round().astype(np.int64).tolist()
|
|
pc.AddPath(scaled, pyclipper.PT_SUBJECT, False)
|
|
|
|
for c in obj_closed_scaled:
|
|
pc.AddPath(c, pyclipper.PT_CLIP, True)
|
|
for s in obj_open:
|
|
path_pts = s[:, :2]
|
|
scaled = (path_pts * SCALE).round().astype(np.int64).tolist()
|
|
pc.AddPath(scaled, pyclipper.PT_SUBJECT, False)
|
|
|
|
op_type = pyclipper.CT_UNION if operation == 'UNION' else pyclipper.CT_XOR
|
|
solution = pc.Execute2(op_type, pyclipper.PFT_EVENODD, pyclipper.PFT_EVENODD)
|
|
for path in pyclipper.ClosedPathsFromPolyTree(solution):
|
|
fpath = np.array(path, dtype=np.float64) / SCALE
|
|
fpath_3d = np.column_stack([fpath, np.full(len(fpath), z)])
|
|
fpath_3d = np.vstack([fpath_3d, fpath_3d[0]])
|
|
layer_results.append(align_path_seam_sharpest(fpath_3d))
|
|
for path in pyclipper.OpenPathsFromPolyTree(solution):
|
|
fpath = np.array(path, dtype=np.float64) / SCALE
|
|
layer_results.append(np.column_stack([fpath, np.full(len(fpath), z)]))
|
|
|
|
results.extend(layer_results)
|
|
results.groups.extend([group] * len(layer_results))
|
|
group += 1
|
|
|
|
# Transfer per-group/path metadata (extrusion_rate, feedrate, extra_attributes) from subject
|
|
calc_ext, calc_feed, calc_extra = _transfer_group_attributes(subject, results, results.groups)
|
|
results.extrusion_rate = calc_ext
|
|
results.feedrate = calc_feed
|
|
results.extra_attributes = calc_extra
|
|
|
|
for attr, val in subject.__dict__.items():
|
|
if attr not in ["groups", "extrusion_rate", "feedrate", "extra_attributes", "transforms", "objects_info", "name"]:
|
|
setattr(results, attr, copy.deepcopy(val) if isinstance(val, (list, dict)) else val)
|
|
for attr, val in object.__dict__.items():
|
|
if attr not in ["groups", "extrusion_rate", "feedrate", "extra_attributes", "transforms", "objects_info", "name"]:
|
|
if not hasattr(results, attr) or getattr(results, attr) is None or getattr(results, attr) == "" or getattr(results, attr) == []:
|
|
setattr(results, attr, copy.deepcopy(val) if isinstance(val, (list, dict)) else val)
|
|
|
|
merged_transforms = []
|
|
merged_names = []
|
|
merged_objs = []
|
|
for sp in [subject, object]:
|
|
obj_info = getattr(sp, "objects_info", None)
|
|
if obj_info:
|
|
for obj_entry in obj_info.get("objects", []):
|
|
if obj_entry not in merged_objs:
|
|
merged_objs.append(dict(obj_entry))
|
|
n_str = obj_entry.get("name", "")
|
|
if n_str and n_str not in merged_names:
|
|
merged_names.append(n_str)
|
|
elif hasattr(sp, "name") and getattr(sp, "name"):
|
|
n_str = str(getattr(sp, "name"))
|
|
if n_str and n_str not in merged_names:
|
|
merged_names.append(n_str)
|
|
merged_objs.append({"name": n_str, "polygon": []})
|
|
|
|
subj_trans = getattr(subject, "transforms", None)
|
|
obj_trans = getattr(object, "transforms", None)
|
|
results.transforms = [dict(t) for t in subj_trans] if subj_trans else ([dict(t) for t in obj_trans] if obj_trans else [])
|
|
if merged_objs:
|
|
results.objects_info = {"objects": merged_objs}
|
|
results.name = merged_names[0] if merged_names else ""
|
|
|
|
return results
|
|
|
|
def minimum_coverage_slice(
|
|
paths_list: list[SlicePaths],
|
|
connect_along_boundary: bool = False
|
|
) -> SlicePaths:
|
|
"""
|
|
Computes the Minimum Coverage across multiple SlicePaths inputs (or within a single SlicePaths input).
|
|
For each layer (grouped by integer micron Z), it calculates the intersection of all objects having paths on that layer.
|
|
In layers where some objects have no paths, the empty paths are ignored (so the result doesn't end up being zero for that layer).
|
|
"""
|
|
valid_paths = [sp for sp in paths_list if isinstance(sp, SlicePaths) and len(sp) > 0]
|
|
if not valid_paths:
|
|
return SlicePaths()
|
|
|
|
primary = valid_paths[0]
|
|
|
|
all_z_keys: set[int] = set()
|
|
input_layer_maps: list[dict[int, list[np.ndarray]]] = []
|
|
|
|
for sp in valid_paths:
|
|
layer_map: dict[int, list[np.ndarray]] = {}
|
|
for p in sp:
|
|
if len(p) < 2:
|
|
continue
|
|
z_key = int(round(p[0, 2] * SCALE))
|
|
all_z_keys.add(z_key)
|
|
layer_map.setdefault(z_key, []).append(p)
|
|
input_layer_maps.append(layer_map)
|
|
|
|
results = SlicePaths()
|
|
group = 0
|
|
|
|
for z_key in sorted(all_z_keys):
|
|
active_indices = [
|
|
i for i, lmap in enumerate(input_layer_maps) if z_key in lmap and len(lmap[z_key]) > 0
|
|
]
|
|
|
|
if not active_indices:
|
|
continue
|
|
|
|
if len(active_indices) == 1:
|
|
# Only one object has paths on this layer -> empty paths of other objects ignored
|
|
idx = active_indices[0]
|
|
layer_paths = input_layer_maps[idx][z_key]
|
|
|
|
# If this single object has multiple closed paths on this layer (e.g. concentric perimeters):
|
|
closed = [p for p in layer_paths if is_closed_path(p)]
|
|
open_p = [p for p in layer_paths if not is_closed_path(p)]
|
|
|
|
if len(closed) > 1 and len(valid_paths) == 1:
|
|
# Intersect concentric perimeters to extract the innermost perimeter
|
|
curr_sp = valid_paths[idx].copy_with([closed[0]])
|
|
for c in closed[1:]:
|
|
inter_res = boolean_slice(
|
|
curr_sp,
|
|
valid_paths[idx].copy_with([c]),
|
|
operation='INTERSECTION',
|
|
connect_along_boundary=connect_along_boundary
|
|
)
|
|
if inter_res:
|
|
curr_sp = inter_res
|
|
else:
|
|
# If disjoint islands, preserve both!
|
|
curr_sp.extend([c])
|
|
if open_p:
|
|
curr_sp.extend(open_p)
|
|
if curr_sp:
|
|
results.extend(curr_sp)
|
|
results.groups.extend([group] * len(curr_sp))
|
|
group += 1
|
|
else:
|
|
results.extend(layer_paths)
|
|
results.groups.extend([group] * len(layer_paths))
|
|
group += 1
|
|
continue
|
|
|
|
# Multiple active inputs on this layer -> intersect across active inputs
|
|
idx0 = active_indices[0]
|
|
curr_sp = valid_paths[idx0].copy_with(input_layer_maps[idx0][z_key])
|
|
|
|
for next_idx in active_indices[1:]:
|
|
next_sp = valid_paths[next_idx].copy_with(input_layer_maps[next_idx][z_key])
|
|
curr_sp = boolean_slice(
|
|
curr_sp,
|
|
next_sp,
|
|
operation='INTERSECTION',
|
|
connect_along_boundary=connect_along_boundary
|
|
)
|
|
if not curr_sp:
|
|
break
|
|
|
|
if curr_sp:
|
|
results.extend(curr_sp)
|
|
results.groups.extend([group] * len(curr_sp))
|
|
group += 1
|
|
|
|
calc_ext, calc_feed, calc_extra = _transfer_group_attributes(primary, results, results.groups)
|
|
results.extrusion_rate = calc_ext
|
|
results.feedrate = calc_feed
|
|
results.extra_attributes = calc_extra
|
|
|
|
for attr, val in primary.__dict__.items():
|
|
if attr not in ["groups", "extrusion_rate", "feedrate", "extra_attributes", "transforms", "objects_info", "name"]:
|
|
setattr(results, attr, copy.deepcopy(val) if isinstance(val, (list, dict)) else val)
|
|
|
|
merged_names = []
|
|
merged_objs = []
|
|
for sp in valid_paths:
|
|
obj_info = getattr(sp, "objects_info", None)
|
|
if obj_info:
|
|
for obj_entry in obj_info.get("objects", []):
|
|
if obj_entry not in merged_objs:
|
|
merged_objs.append(dict(obj_entry))
|
|
n_str = obj_entry.get("name", "")
|
|
if n_str and n_str not in merged_names:
|
|
merged_names.append(n_str)
|
|
elif hasattr(sp, "name") and getattr(sp, "name"):
|
|
n_str = str(getattr(sp, "name"))
|
|
if n_str and n_str not in merged_names:
|
|
merged_names.append(n_str)
|
|
merged_objs.append({"name": n_str, "polygon": []})
|
|
|
|
results.transforms = [dict(t) for t in getattr(primary, "transforms", [])]
|
|
if merged_objs:
|
|
results.objects_info = {"objects": merged_objs}
|
|
results.name = merged_names[0] if merged_names else ""
|
|
|
|
return results
|
|
|
|
def maximum_coverage_slice(
|
|
paths_list: list[SlicePaths],
|
|
connect_along_boundary: bool = False
|
|
) -> SlicePaths:
|
|
"""
|
|
Computes the Maximum Coverage across multiple SlicePaths inputs (or within a single SlicePaths input).
|
|
For each layer (grouped by integer micron Z), it calculates the boolean union of all polygons.
|
|
"""
|
|
valid_paths = [sp for sp in paths_list if isinstance(sp, SlicePaths) and len(sp) > 0]
|
|
if not valid_paths:
|
|
return SlicePaths()
|
|
|
|
if len(valid_paths) == 1:
|
|
# Single input: if it has multiple perimeters/shapes on a layer, union them
|
|
sp = valid_paths[0]
|
|
layer_map: dict[int, list[np.ndarray]] = {}
|
|
for p in sp:
|
|
if len(p) < 2:
|
|
continue
|
|
z_key = int(round(p[0, 2] * SCALE))
|
|
layer_map.setdefault(z_key, []).append(p)
|
|
|
|
results = SlicePaths()
|
|
group = 0
|
|
for z_key in sorted(layer_map.keys()):
|
|
l_paths = layer_map[z_key]
|
|
if len(l_paths) <= 1:
|
|
results.extend(l_paths)
|
|
results.groups.extend([group] * len(l_paths))
|
|
group += 1
|
|
else:
|
|
closed = [p for p in l_paths if is_closed_path(p)]
|
|
open_p = [p for p in l_paths if not is_closed_path(p)]
|
|
if len(closed) <= 1:
|
|
results.extend(l_paths)
|
|
results.groups.extend([group] * len(l_paths))
|
|
group += 1
|
|
else:
|
|
# Union all closed paths on this layer
|
|
curr_sp = sp.copy_with([closed[0]])
|
|
for c in closed[1:]:
|
|
curr_sp = boolean_slice(
|
|
curr_sp,
|
|
sp.copy_with([c]),
|
|
operation='UNION',
|
|
connect_along_boundary=connect_along_boundary
|
|
)
|
|
if open_p:
|
|
curr_sp.extend(open_p)
|
|
curr_sp.groups.extend([group] * len(open_p))
|
|
if curr_sp:
|
|
results.extend(curr_sp)
|
|
results.groups.extend([group] * len(curr_sp))
|
|
group += 1
|
|
|
|
calc_ext, calc_feed, calc_extra = _transfer_group_attributes(sp, results, results.groups)
|
|
results.extrusion_rate = calc_ext
|
|
results.feedrate = calc_feed
|
|
results.extra_attributes = calc_extra
|
|
for attr, val in sp.__dict__.items():
|
|
if attr not in ["groups", "extrusion_rate", "feedrate", "extra_attributes", "transforms", "objects_info", "name"]:
|
|
setattr(results, attr, copy.deepcopy(val) if isinstance(val, (list, dict)) else val)
|
|
|
|
results.transforms = [dict(t) for t in getattr(sp, "transforms", [])]
|
|
results.name = getattr(sp, "name", "")
|
|
results.objects_info = copy.deepcopy(getattr(sp, "objects_info", None))
|
|
return results
|
|
|
|
# Multiple inputs: sequentially union all inputs with boolean_slice
|
|
accumulated = valid_paths[0]
|
|
for sp in valid_paths[1:]:
|
|
accumulated = boolean_slice(
|
|
accumulated,
|
|
sp,
|
|
operation='UNION',
|
|
connect_along_boundary=connect_along_boundary
|
|
)
|
|
return accumulated
|
|
|
|
def intersection_slice(
|
|
paths_list: list[SlicePaths],
|
|
connect_along_boundary: bool = False
|
|
) -> SlicePaths:
|
|
"""Computes the Intersection across multiple SlicePaths inputs (same behavior as Boolean Paths)."""
|
|
if not paths_list:
|
|
return SlicePaths()
|
|
if len(paths_list) == 1:
|
|
return paths_list[0]
|
|
|
|
accumulated = paths_list[0]
|
|
for sp in paths_list[1:]:
|
|
if not sp or not accumulated:
|
|
return SlicePaths()
|
|
accumulated = boolean_slice(
|
|
accumulated,
|
|
sp,
|
|
operation='INTERSECTION',
|
|
connect_along_boundary=connect_along_boundary
|
|
)
|
|
if not accumulated:
|
|
return SlicePaths()
|
|
return accumulated
|
|
|
|
def subtract_slice(
|
|
paths_list: list[SlicePaths],
|
|
connect_along_boundary: bool = False
|
|
) -> SlicePaths:
|
|
"""Computes the Difference (Subtract) of subsequent SlicePaths inputs from the first input."""
|
|
if not paths_list:
|
|
return SlicePaths()
|
|
if len(paths_list) == 1:
|
|
return paths_list[0]
|
|
|
|
accumulated = paths_list[0]
|
|
for sp in paths_list[1:]:
|
|
if not accumulated:
|
|
return SlicePaths()
|
|
if not sp:
|
|
continue
|
|
accumulated = boolean_slice(
|
|
accumulated,
|
|
sp,
|
|
operation='DIFFERENCE',
|
|
connect_along_boundary=connect_along_boundary
|
|
)
|
|
return accumulated
|
|
|
|
def xor_slice(
|
|
paths_list: list[SlicePaths],
|
|
connect_along_boundary: bool = False
|
|
) -> SlicePaths:
|
|
"""Computes the Symmetric Difference (XOR) across multiple SlicePaths inputs."""
|
|
if not paths_list:
|
|
return SlicePaths()
|
|
if len(paths_list) == 1:
|
|
return paths_list[0]
|
|
|
|
accumulated = paths_list[0]
|
|
for sp in paths_list[1:]:
|
|
if not sp:
|
|
continue
|
|
if not accumulated:
|
|
accumulated = sp
|
|
continue
|
|
accumulated = boolean_slice(
|
|
accumulated,
|
|
sp,
|
|
operation='XOR',
|
|
connect_along_boundary=connect_along_boundary
|
|
)
|
|
return accumulated
|
|
|
|
def path_operations_slice(
|
|
paths_list: list[SlicePaths],
|
|
operation: str = 'ZIP',
|
|
connect_along_boundary: bool = False
|
|
) -> SlicePaths:
|
|
"""Dispatches Path Operations across multiple SlicePaths inputs."""
|
|
if not paths_list:
|
|
return SlicePaths()
|
|
|
|
op = str(operation).upper()
|
|
if op in ('ZIP', 'ZIP_PATHS'):
|
|
return SlicePaths.zip_paths(paths_list)
|
|
elif op in ('EXTEND', 'EXTEND_PATHS'):
|
|
return SlicePaths.extend_paths(paths_list)
|
|
elif op in ('MAX_COVERAGE', 'MAXIMUM_COVERAGE', 'UNION'):
|
|
return maximum_coverage_slice(paths_list, connect_along_boundary=connect_along_boundary)
|
|
elif op in ('MIN_COVERAGE', 'MINIMUM_COVERAGE'):
|
|
return minimum_coverage_slice(paths_list, connect_along_boundary=connect_along_boundary)
|
|
elif op in ('INTERSECTION', 'INTERSECT'):
|
|
return intersection_slice(paths_list, connect_along_boundary=connect_along_boundary)
|
|
elif op in ('SUBTRACT', 'DIFFERENCE'):
|
|
return subtract_slice(paths_list, connect_along_boundary=connect_along_boundary)
|
|
elif op == 'XOR':
|
|
return xor_slice(paths_list, connect_along_boundary=connect_along_boundary)
|
|
else:
|
|
return SlicePaths.zip_paths(paths_list)
|
|
|
|
def geometry_to_paths(geom: Any, z: float) -> list[np.ndarray]:
|
|
"""Helper to convert shapely geometries to a list of 3D numpy arrays with a given Z height."""
|
|
paths = []
|
|
if geom.is_empty:
|
|
return paths
|
|
|
|
if isinstance(geom, Polygon):
|
|
ext_coords = np.asarray(geom.exterior.coords, dtype=np.float64)
|
|
N = len(ext_coords)
|
|
ext_coords_3d = np.empty((N, 3), dtype=np.float64)
|
|
ext_coords_3d[:, :2] = ext_coords
|
|
ext_coords_3d[:, 2] = z
|
|
paths.append(ext_coords_3d)
|
|
for interior in geom.interiors:
|
|
int_coords = np.asarray(interior.coords, dtype=np.float64)
|
|
M = len(int_coords)
|
|
int_coords_3d = np.empty((M, 3), dtype=np.float64)
|
|
int_coords_3d[:, :2] = int_coords
|
|
int_coords_3d[:, 2] = z
|
|
paths.append(int_coords_3d)
|
|
elif isinstance(geom, (MultiPolygon, GeometryCollection)):
|
|
for poly in geom.geoms:
|
|
paths.extend(geometry_to_paths(poly, z))
|
|
|
|
return paths
|
|
|
|
@ndarray_lru_cache(maxsize=64)
|
|
def buffer_curve_slice(
|
|
slice_paths: list[np.ndarray],
|
|
distance: float,
|
|
cap_style: str = 'round',
|
|
join_style: str = 'round',
|
|
single_sided: bool = False
|
|
) -> tuple[list[np.ndarray], list[int]]:
|
|
if not slice_paths or distance <= 0:
|
|
return [], []
|
|
|
|
from shapely.geometry import LineString
|
|
|
|
# Group paths by Z height in integer microns
|
|
groups: dict[int, list[np.ndarray]] = {}
|
|
for p in slice_paths:
|
|
z = p[0, 2]
|
|
z_key = int(round(z * SCALE))
|
|
groups.setdefault(z_key, []).append(p)
|
|
|
|
paths_list: list[np.ndarray] = []
|
|
groups_list: list[int] = []
|
|
|
|
group_id = 0
|
|
for z_key in sorted(groups.keys()):
|
|
layer_paths = groups[z_key]
|
|
z = float(z_key) / SCALE
|
|
|
|
for p in layer_paths:
|
|
if len(p) < 2:
|
|
continue
|
|
|
|
pts_2d = p[:, :2]
|
|
line = LineString(pts_2d)
|
|
|
|
poly = line.buffer(
|
|
distance,
|
|
cap_style=cast(Literal['round', 'square', 'flat'], cap_style),
|
|
join_style=cast(Literal['round', 'mitre', 'bevel'], join_style),
|
|
single_sided=single_sided,
|
|
quad_segs=16
|
|
)
|
|
|
|
layer_result_paths = geometry_to_paths(poly, z)
|
|
|
|
for path in layer_result_paths:
|
|
paths_list.append(path)
|
|
groups_list.append(group_id)
|
|
|
|
group_id += 1
|
|
|
|
return paths_list, groups_list
|
|
|
|
def offset_geometry_to_paths(geom: Any, z: float) -> list[np.ndarray]:
|
|
"""Helper to convert shapely line geometries to a list of 3D numpy arrays with a given Z height."""
|
|
paths = []
|
|
if geom.is_empty:
|
|
return paths
|
|
|
|
if isinstance(geom, (LineString, LinearRing)):
|
|
coords = np.asarray(geom.coords, dtype=np.float64)
|
|
N = len(coords)
|
|
coords_3d = np.empty((N, 3), dtype=np.float64)
|
|
coords_3d[:, :2] = coords
|
|
coords_3d[:, 2] = z
|
|
paths.append(coords_3d)
|
|
elif isinstance(geom, MultiLineString):
|
|
for line in geom.geoms:
|
|
coords = np.asarray(line.coords, dtype=np.float64)
|
|
N = len(coords)
|
|
coords_3d = np.empty((N, 3), dtype=np.float64)
|
|
coords_3d[:, :2] = coords
|
|
coords_3d[:, 2] = z
|
|
paths.append(coords_3d)
|
|
elif isinstance(geom, GeometryCollection):
|
|
for sub_geom in geom.geoms:
|
|
paths.extend(offset_geometry_to_paths(sub_geom, z))
|
|
elif isinstance(geom, (Polygon, MultiPolygon)):
|
|
boundary = geom.boundary
|
|
paths.extend(offset_geometry_to_paths(boundary, z))
|
|
return paths
|
|
|
|
@ndarray_lru_cache(maxsize=64)
|
|
def offset_curve_slice(
|
|
slice_paths: list[np.ndarray],
|
|
distance: float,
|
|
join_style: Literal['round', 'mitre'] = 'round'
|
|
) -> tuple[list[np.ndarray], list[int]]:
|
|
if not slice_paths:
|
|
return [], []
|
|
|
|
from shapely.geometry import LineString, Polygon, LinearRing
|
|
|
|
# Group paths by Z height in integer microns
|
|
groups: dict[int, list[np.ndarray]] = {}
|
|
for p in slice_paths:
|
|
z = p[0, 2]
|
|
z_key = int(round(z * SCALE))
|
|
groups.setdefault(z_key, []).append(p)
|
|
|
|
paths_list: list[np.ndarray] = []
|
|
groups_list: list[int] = []
|
|
|
|
group_id = 0
|
|
for z_key in sorted(groups.keys()):
|
|
layer_paths = groups[z_key]
|
|
z = float(z_key) / SCALE
|
|
|
|
for p in layer_paths:
|
|
if len(p) < 2:
|
|
continue
|
|
|
|
pts_2d = p[:, :2]
|
|
is_closed = is_closed_path(p)
|
|
|
|
if is_closed:
|
|
try:
|
|
# Check direction/orientation
|
|
ccw = LinearRing(pts_2d).is_ccw
|
|
poly = Polygon(pts_2d)
|
|
if poly.is_valid:
|
|
# Use Polygon buffer which keeps boundary closed
|
|
buf_dist = distance if ccw else -distance
|
|
offset_geom = poly.buffer(
|
|
buf_dist,
|
|
join_style=join_style,
|
|
quad_segs=16
|
|
)
|
|
else:
|
|
line = LineString(pts_2d)
|
|
offset_geom = line.offset_curve(
|
|
distance,
|
|
join_style=join_style,
|
|
quad_segs=16
|
|
)
|
|
except Exception:
|
|
line = LineString(pts_2d)
|
|
offset_geom = line.offset_curve(
|
|
distance,
|
|
join_style=join_style,
|
|
quad_segs=16
|
|
)
|
|
else:
|
|
line = LineString(pts_2d)
|
|
offset_geom = line.offset_curve(
|
|
distance,
|
|
join_style=join_style,
|
|
quad_segs=16
|
|
)
|
|
|
|
layer_result_paths = offset_geometry_to_paths(offset_geom, z)
|
|
|
|
for path in layer_result_paths:
|
|
paths_list.append(path)
|
|
groups_list.append(group_id)
|
|
|
|
group_id += 1
|
|
|
|
return paths_list, groups_list
|
|
|
|
def paths_to_shapely_polygon(paths: list[np.ndarray]) -> BaseGeometry:
|
|
"""Converts a list of paths on a layer to a single Shapely geometry using pyclipper to resolve holes in 1-micron space."""
|
|
if not paths:
|
|
return Polygon()
|
|
|
|
import pyclipper
|
|
from shapely.ops import unary_union
|
|
|
|
pc = pyclipper.Pyclipper() # pyright: ignore[reportAttributeAccessIssue]
|
|
for p in paths:
|
|
if len(p) < 3:
|
|
continue
|
|
pts_2d = p[:, :2]
|
|
if is_closed_path(p):
|
|
pts_2d = pts_2d[:-1]
|
|
scaled = (pts_2d * SCALE).round().astype(np.int64).tolist()
|
|
pc.AddPath(scaled, pyclipper.PT_SUBJECT, True) # pyright: ignore[reportAttributeAccessIssue]
|
|
|
|
tree = pc.Execute2(pyclipper.CT_UNION, pyclipper.PFT_EVENODD, pyclipper.PFT_EVENODD) # pyright: ignore[reportAttributeAccessIssue]
|
|
|
|
def polytree_to_shapely(node) -> list[Polygon]:
|
|
polygons = []
|
|
if hasattr(node, 'Contour') and node.Contour and not node.IsHole:
|
|
if len(node.Contour) >= 3:
|
|
exterior = np.array(node.Contour, dtype=np.float64) / SCALE
|
|
interiors = []
|
|
child_islands = []
|
|
for child in node.Childs:
|
|
if child.IsHole:
|
|
if child.Contour and len(child.Contour) >= 3:
|
|
interiors.append(np.array(child.Contour, dtype=np.float64) / SCALE)
|
|
for grandchild in child.Childs:
|
|
child_islands.append(grandchild)
|
|
else:
|
|
child_islands.append(child)
|
|
poly = Polygon(exterior, interiors)
|
|
if not poly.is_valid:
|
|
poly = poly.buffer(0)
|
|
if not poly.is_empty:
|
|
polygons.append(poly)
|
|
for island in child_islands:
|
|
polygons.extend(polytree_to_shapely(island))
|
|
else:
|
|
for child in node.Childs:
|
|
polygons.extend(polytree_to_shapely(child))
|
|
return polygons
|
|
|
|
polys = polytree_to_shapely(tree)
|
|
return unary_union(polys)
|
|
|
|
@ndarray_lru_cache(maxsize=64)
|
|
def get_infill_mask_slice(slice_paths: list[np.ndarray]) -> tuple[list[np.ndarray], list[int]]:
|
|
if not slice_paths:
|
|
return [], []
|
|
|
|
# Group paths by their Z height in integer microns
|
|
groups: dict[int, list[np.ndarray]] = {}
|
|
for p in slice_paths:
|
|
if len(p) == 0:
|
|
continue
|
|
z = p[0, 2]
|
|
z_key = int(round(z * SCALE))
|
|
groups.setdefault(z_key, []).append(p)
|
|
|
|
paths_list: list[np.ndarray] = []
|
|
groups_list: list[int] = []
|
|
|
|
group_id = 0
|
|
for z_key in sorted(groups.keys()):
|
|
layer_paths = groups[z_key]
|
|
if not layer_paths:
|
|
continue
|
|
|
|
z = float(z_key) / SCALE
|
|
|
|
# Convert layer paths to a single Shapely geometry (handling holes and multiple islands)
|
|
geom = paths_to_shapely_polygon(layer_paths)
|
|
if geom.is_empty:
|
|
continue
|
|
|
|
# Convert resulting polygon back to 3D paths
|
|
result_paths = geometry_to_paths(geom, z)
|
|
|
|
for path in result_paths:
|
|
paths_list.append(path)
|
|
groups_list.append(group_id)
|
|
|
|
group_id += 1
|
|
|
|
return paths_list, groups_list
|
|
|
|
@ndarray_lru_cache(maxsize=32)
|
|
def get_overhang_mask_slice(slice_paths: list[np.ndarray], groups: tuple[int, ...], tolerance: float = 0.05) -> tuple[list[np.ndarray], list[int]]:
|
|
if not slice_paths or not groups:
|
|
return [], []
|
|
|
|
from shapely.geometry import Polygon
|
|
|
|
# Group paths by their group index
|
|
group_map: dict[int, list[np.ndarray]] = {}
|
|
for p, g in zip(slice_paths, groups):
|
|
group_map.setdefault(g, []).append(p)
|
|
|
|
sorted_groups = sorted(
|
|
group_map.keys(),
|
|
key=lambda g: int(round(group_map[g][0][0, 2] * SCALE)) if group_map[g] and len(group_map[g][0]) > 0 else g
|
|
)
|
|
if len(sorted_groups) < 2:
|
|
# If there's only 1 layer, there are no overhangs under any layer
|
|
return [], []
|
|
|
|
# Pre-build solid geometry for each group
|
|
layer_geoms = {g: paths_to_shapely_polygon(group_map[g]) for g in sorted_groups}
|
|
|
|
paths_list: list[np.ndarray] = []
|
|
groups_list: list[int] = []
|
|
|
|
accumulated = Polygon()
|
|
|
|
# Iterate from top group down to bottom group
|
|
for i in range(len(sorted_groups) - 1, -1, -1):
|
|
g = sorted_groups[i]
|
|
z = float(int(round(group_map[g][0][0, 2] * SCALE))) / SCALE if group_map[g] and len(group_map[g][0]) > 0 else 0.0
|
|
geom_i = layer_geoms[g]
|
|
|
|
# Calculate raw support needed at layer g: union of all layers above g, minus geom_i.
|
|
# This prevents boundary mismatches/slivers caused by subtracting and then unioning back the same layers.
|
|
if i < len(sorted_groups) - 1:
|
|
raw_support = accumulated.difference(geom_i)
|
|
if not raw_support.is_empty:
|
|
# Apply tolerance clearance only to the output of the current layer.
|
|
geom_i_sub = geom_i.buffer(tolerance) if tolerance > 0 else geom_i
|
|
output_geom = raw_support.difference(geom_i_sub)
|
|
if not output_geom.is_empty:
|
|
result_paths = geometry_to_paths(output_geom, z)
|
|
for path in result_paths:
|
|
paths_list.append(path)
|
|
groups_list.append(g)
|
|
|
|
# Accumulate the union of model layers for the layers below
|
|
accumulated = accumulated.union(geom_i)
|
|
|
|
# Sort output by group index so that paths are ordered bottom-to-top
|
|
sorted_indices = np.argsort(groups_list)
|
|
paths_list = [paths_list[idx] for idx in sorted_indices]
|
|
groups_list = [int(groups_list[idx]) for idx in sorted_indices]
|
|
|
|
return paths_list, groups_list
|
|
|
|
@ndarray_lru_cache(maxsize=64)
|
|
def buffer_slice(slice_paths: list[np.ndarray], distance: float, miter='round', tolerance=.01) -> tuple[list[np.ndarray], list[int]]:
|
|
|
|
if not slice_paths: return [], []
|
|
|
|
# Group paths by Z height in integer microns
|
|
groups: dict[int, list[np.ndarray]] = {}
|
|
for p in slice_paths:
|
|
z = p[0, 2]
|
|
z_key = int(round(z * SCALE))
|
|
groups.setdefault(z_key, []).append(p)
|
|
|
|
join_types = {
|
|
'round': pyclipper.JT_ROUND, # pyright: ignore[reportAttributeAccessIssue]
|
|
'square': pyclipper.JT_SQUARE, # pyright: ignore[reportAttributeAccessIssue]
|
|
'flat': pyclipper.JT_SQUARE, # pyright: ignore[reportAttributeAccessIssue]
|
|
'miter': pyclipper.JT_MITER # pyright: ignore[reportAttributeAccessIssue]
|
|
}
|
|
jt = join_types.get(miter, pyclipper.JT_ROUND) # pyright: ignore[reportAttributeAccessIssue]
|
|
|
|
paths_list: list[np.ndarray] = []
|
|
groups_list: list[int] = []
|
|
pco = pyclipper.PyclipperOffset(arc_tolerance=int(round(tolerance * SCALE))) # pyright: ignore[reportAttributeAccessIssue]
|
|
scale_inv = 1.0 / SCALE
|
|
delta_scaled = int(round(distance * SCALE))
|
|
|
|
# Buffer each layer independently
|
|
group_id = 0
|
|
for z_key in sorted(groups.keys()):
|
|
layer_paths = groups[z_key]
|
|
z = float(z_key) * scale_inv
|
|
|
|
pco.Clear()
|
|
scaled_paths = []
|
|
for p in layer_paths:
|
|
if len(p) < 3:
|
|
continue
|
|
path_pts = p[:-1, :2] if is_closed_path(p) else p[:, :2]
|
|
if len(path_pts) < 3:
|
|
continue
|
|
scaled_paths.append(np.rint(path_pts * SCALE).astype(np.int64).tolist())
|
|
|
|
if not scaled_paths:
|
|
continue
|
|
|
|
# Add all paths for this specific Z layer
|
|
pco.AddPaths(scaled_paths, jt, pyclipper.ET_CLOSEDPOLYGON) # pyright: ignore[reportAttributeAccessIssue]
|
|
solution = pco.Execute(delta_scaled)
|
|
|
|
for path in solution:
|
|
if not path:
|
|
continue
|
|
N = len(path)
|
|
fpath_3d = np.empty((N + 1, 3), dtype=np.float64)
|
|
arr = np.asarray(path, dtype=np.float64)
|
|
fpath_3d[:N, :2] = arr * scale_inv
|
|
fpath_3d[:N, 2] = z
|
|
fpath_3d[N] = fpath_3d[0]
|
|
fpath_3d = align_path_seam_sharpest(fpath_3d)
|
|
paths_list.append(fpath_3d)
|
|
groups_list.append(group_id)
|
|
|
|
group_id += 1
|
|
|
|
return paths_list, groups_list
|
|
|
|
|
|
@ndarray_lru_cache(maxsize=64)
|
|
def buffer_multiple_slice(
|
|
slice_paths: list[np.ndarray],
|
|
base_distance: float,
|
|
count: int,
|
|
width_step: float,
|
|
miter: str = 'round',
|
|
tolerance: float = 0.01
|
|
) -> tuple[list[np.ndarray], list[int]]:
|
|
"""Generates multiple concentric buffers per layer in a single pass."""
|
|
if not slice_paths or count <= 0:
|
|
return [], []
|
|
|
|
groups: dict[int, list[np.ndarray]] = {}
|
|
for p in slice_paths:
|
|
z = p[0, 2]
|
|
z_key = int(round(z * SCALE))
|
|
groups.setdefault(z_key, []).append(p)
|
|
|
|
join_types = {
|
|
'round': pyclipper.JT_ROUND, # pyright: ignore[reportAttributeAccessIssue]
|
|
'square': pyclipper.JT_SQUARE, # pyright: ignore[reportAttributeAccessIssue]
|
|
'flat': pyclipper.JT_SQUARE, # pyright: ignore[reportAttributeAccessIssue]
|
|
'miter': pyclipper.JT_MITER # pyright: ignore[reportAttributeAccessIssue]
|
|
}
|
|
jt = join_types.get(miter, pyclipper.JT_ROUND) # pyright: ignore[reportAttributeAccessIssue]
|
|
|
|
paths_list: list[np.ndarray] = []
|
|
groups_list: list[int] = []
|
|
pco = pyclipper.PyclipperOffset(arc_tolerance=int(round(tolerance * SCALE))) # pyright: ignore[reportAttributeAccessIssue]
|
|
|
|
scaled_dists = [int(round((base_distance + i * width_step) * SCALE)) for i in range(count)]
|
|
scale_inv = 1.0 / SCALE
|
|
|
|
group_id = 0
|
|
for z_key in sorted(groups.keys()):
|
|
layer_paths = groups[z_key]
|
|
z = float(z_key) * scale_inv
|
|
|
|
pco.Clear()
|
|
scaled_paths = []
|
|
for p in layer_paths:
|
|
if len(p) < 3:
|
|
continue
|
|
path_pts = p[:-1, :2] if is_closed_path(p) else p[:, :2]
|
|
if len(path_pts) < 3:
|
|
continue
|
|
scaled_paths.append(np.rint(path_pts * SCALE).astype(np.int64).tolist())
|
|
|
|
if not scaled_paths:
|
|
continue
|
|
|
|
pco.AddPaths(scaled_paths, jt, pyclipper.ET_CLOSEDPOLYGON) # pyright: ignore[reportAttributeAccessIssue]
|
|
|
|
for d_scaled in scaled_dists:
|
|
solution = pco.Execute(d_scaled)
|
|
for path in solution:
|
|
if not path:
|
|
continue
|
|
N = len(path)
|
|
fpath_3d = np.empty((N + 1, 3), dtype=np.float64)
|
|
arr = np.asarray(path, dtype=np.float64)
|
|
fpath_3d[:N, :2] = arr * scale_inv
|
|
fpath_3d[:N, 2] = z
|
|
fpath_3d[N] = fpath_3d[0]
|
|
fpath_3d = align_path_seam_sharpest(fpath_3d)
|
|
paths_list.append(fpath_3d)
|
|
groups_list.append(group_id)
|
|
|
|
group_id += 1
|
|
|
|
return paths_list, groups_list
|
|
|
|
@ndarray_lru_cache(maxsize=32)
|
|
def infill_slice(slice_paths: 'SlicePaths', density: float, rotation_deg: float = 0.0, infill_type: str = 'GRID') -> tuple[list[np.ndarray], list[int]]:
|
|
"""Generates a grid or rectilinear infill clipped by the provided paths. Consistent across layers using 1-micron integer grid."""
|
|
if not slice_paths or density <= 0: return [], []
|
|
|
|
rotation = np.radians(rotation_deg)
|
|
|
|
# Calculate global bounding box for the entire model to ensure grid alignment
|
|
all_pts_model = np.concatenate(slice_paths)
|
|
g_min_x, g_min_y = all_pts_model[:, 0].min(), all_pts_model[:, 1].min()
|
|
g_max_x, g_max_y = all_pts_model[:, 0].max(), all_pts_model[:, 1].max()
|
|
global_center = np.array([(g_min_x + g_max_x) / 2, (g_min_y + g_max_y) / 2, 0])
|
|
|
|
# Pre-calculate rotation matrices
|
|
c_inv, s_inv = np.cos(-rotation), np.sin(-rotation)
|
|
rot_mat_inv = np.array([[c_inv, -s_inv, 0], [s_inv, c_inv, 0], [0, 0, 1]])
|
|
|
|
c, s = np.cos(rotation), np.sin(rotation)
|
|
rot_mat = np.array([[c, -s, 0], [s, c, 0], [0, 0, 1]])
|
|
|
|
# Determine grid bounds in the rotated space to ensure full coverage
|
|
corners = np.array([
|
|
[g_min_x, g_min_y, 0], [g_max_x, g_min_y, 0],
|
|
[g_min_x, g_max_y, 0], [g_max_x, g_max_y, 0]
|
|
])
|
|
rotated_corners = (corners - global_center) @ rot_mat_inv.T + global_center
|
|
rg_min_x, rg_min_y = rotated_corners[:, 0].min(), rotated_corners[:, 1].min()
|
|
rg_max_x, rg_max_y = rotated_corners[:, 0].max(), rotated_corners[:, 1].max()
|
|
|
|
density_um = int(round(density * SCALE))
|
|
if density_um <= 0:
|
|
return [], []
|
|
|
|
# Generate the global static grid lines in de-rotated space using integer microns
|
|
rg_min_x_um = int(np.floor(rg_min_x * SCALE)) - density_um
|
|
rg_max_x_um = int(np.ceil(rg_max_x * SCALE)) + density_um
|
|
rg_min_y_um = int(np.floor(rg_min_y * SCALE)) - density_um
|
|
rg_max_y_um = int(np.ceil(rg_max_y * SCALE)) + density_um
|
|
|
|
vertical_lines = []
|
|
x_um = rg_min_x_um
|
|
while x_um <= rg_max_x_um:
|
|
vertical_lines.append([
|
|
(x_um, rg_min_y_um),
|
|
(x_um, rg_max_y_um)
|
|
])
|
|
x_um += density_um
|
|
|
|
horizontal_lines = []
|
|
y_um = rg_min_y_um
|
|
while y_um <= rg_max_y_um:
|
|
horizontal_lines.append([
|
|
(rg_min_x_um, y_um),
|
|
(rg_max_x_um, y_um)
|
|
])
|
|
y_um += density_um
|
|
|
|
# Group by Z in integer microns
|
|
groups: dict[int, list[np.ndarray]] = {}
|
|
if hasattr(slice_paths, 'groups') and slice_paths.groups:
|
|
for p, g in zip(slice_paths, slice_paths.groups):
|
|
groups.setdefault(g, []).append(p)
|
|
else:
|
|
# Fallback if it's just a list
|
|
for p in slice_paths:
|
|
z_key = int(round(p[0, 2] * SCALE))
|
|
groups.setdefault(z_key, []).append(p)
|
|
|
|
output_paths: list[np.ndarray] = []
|
|
output_groups: list[int] = []
|
|
|
|
# Sort groups by Z in integer microns to determine layer index for alternating rectilinear infill
|
|
unique_groups = sorted(
|
|
groups.keys(),
|
|
key=lambda g: int(round(groups[g][0][0, 2] * SCALE)) if groups[g] and len(groups[g][0]) > 0 else g
|
|
)
|
|
group_to_layer_index = {g: i for i, g in enumerate(unique_groups)}
|
|
|
|
for g in unique_groups:
|
|
layer_idx = group_to_layer_index[g]
|
|
|
|
if infill_type == 'GRID':
|
|
current_grid_paths = vertical_lines + horizontal_lines
|
|
elif infill_type == 'RECTILINEAR':
|
|
current_grid_paths = vertical_lines if layer_idx % 2 == 0 else horizontal_lines
|
|
elif infill_type == 'LINES':
|
|
current_grid_paths = vertical_lines
|
|
else:
|
|
current_grid_paths = []
|
|
|
|
layer_paths = groups[g]
|
|
z = float(int(round(layer_paths[0][0, 2] * SCALE))) / SCALE if layer_paths and len(layer_paths[0]) > 0 else 0.0
|
|
|
|
# De-rotate layer paths
|
|
offset_2d = np.array([global_center[0], global_center[1], 0])
|
|
active_paths = [(p - offset_2d) @ rot_mat_inv.T + offset_2d for p in layer_paths]
|
|
|
|
# Clip lines with active_paths in integer micron space
|
|
pc = pyclipper.Pyclipper() # pyright: ignore[reportAttributeAccessIssue]
|
|
|
|
for p in active_paths:
|
|
path_pts = p[:, :2]
|
|
if is_closed_path(p):
|
|
path_pts = path_pts[:-1]
|
|
if len(path_pts) < 3: continue
|
|
scaled = (path_pts * SCALE).round().astype(np.int64).tolist()
|
|
pc.AddPath(scaled, pyclipper.PT_CLIP, True) # pyright: ignore[reportAttributeAccessIssue]
|
|
|
|
if current_grid_paths:
|
|
pc.AddPaths(current_grid_paths, pyclipper.PT_SUBJECT, False) # pyright: ignore[reportAttributeAccessIssue]
|
|
|
|
solution = pc.Execute2(pyclipper.CT_INTERSECTION, pyclipper.PFT_EVENODD, pyclipper.PFT_EVENODD) # pyright: ignore[reportAttributeAccessIssue]
|
|
clipped_lines = pyclipper.OpenPathsFromPolyTree(solution) # pyright: ignore[reportAttributeAccessIssue]
|
|
|
|
# Rotate back
|
|
for path in clipped_lines:
|
|
if not path: continue
|
|
fpath = np.array(path, dtype=np.float64) / SCALE
|
|
fpath_3d = np.column_stack([fpath, np.full(len(fpath), z)])
|
|
rotated_fpath_3d = (fpath_3d - offset_2d) @ rot_mat.T + offset_2d
|
|
output_paths.append(rotated_fpath_3d)
|
|
output_groups.append(g)
|
|
|
|
return output_paths, output_groups
|
|
|
|
def group_segments_by_z(segments: np.ndarray) -> dict[int, np.ndarray]:
|
|
if len(segments) == 0:
|
|
return {}
|
|
z_um = np.round(segments[:, 0, 2] * SCALE).astype(np.int64)
|
|
unique_z_um = np.unique(z_um)
|
|
return {int(z): segments[z_um == z] for z in unique_z_um}
|
|
|
|
############################################################
|
|
|
|
class Pather(object):
|
|
|
|
def __init__(self, paths: list[np.ndarray], **kwargs) -> None:
|
|
self.paths = paths
|
|
|
|
# Robust initialization: ensure metadata lists match paths length
|
|
e_rate = getattr(paths, "extrusion_rate", None)
|
|
if e_rate is not None and len(e_rate) == len(paths):
|
|
self.extrusion_rates = e_rate
|
|
else:
|
|
self.extrusion_rates = [np.ones(len(p)) for p in paths]
|
|
|
|
f_rate = getattr(paths, "feedrate", None)
|
|
if f_rate is not None and len(f_rate) == len(paths):
|
|
self.feedrates = f_rate
|
|
else:
|
|
self.feedrates = [np.zeros(len(p)) for p in paths]
|
|
|
|
extra = getattr(paths, "extra_attributes", None)
|
|
if extra is not None and all(len(v) == len(paths) for v in extra.values()):
|
|
self.extra_attributes = extra
|
|
else:
|
|
self.extra_attributes = {}
|
|
|
|
self.bed_center_x: float = 250.
|
|
self.bed_center_y: float = 250.
|
|
self.feedrate: float = 1200.
|
|
self.travel_feedrate: float = 2400.
|
|
self.extrusion_rate: float = 0.017
|
|
self.layer_height: float = 0.2
|
|
self.first_layer_height: float | None = None
|
|
self._layer_width: float | None = None
|
|
self.retraction: float = 0.0
|
|
self.retraction_feedrate: float = 0.0
|
|
self.lift: float = 0.0
|
|
self.transforms: list[dict[str, Any]] = list(getattr(paths, "transforms", []))
|
|
self.objects_info: dict[str, Any] | None = getattr(paths, "objects_info", None)
|
|
self.name: str = str(getattr(paths, "name", ""))
|
|
|
|
for k, v in kwargs.items():
|
|
setattr(self, k, v)
|
|
|
|
@property
|
|
def layer_width(self) -> float:
|
|
if self._layer_width is not None:
|
|
return self._layer_width
|
|
if self.layer_height > 0:
|
|
import math
|
|
filament_area = math.pi * (1.75 / 2.0) ** 2
|
|
return (self.extrusion_rate * filament_area) / self.layer_height
|
|
return 0.4
|
|
|
|
@layer_width.setter
|
|
def layer_width(self, val: float | None):
|
|
self._layer_width = val
|
|
|
|
def to_gcode(self, include_header: bool = True) -> str:
|
|
if not self.paths:
|
|
return ""
|
|
|
|
# Centering logic using NumPy
|
|
all_pts = np.concatenate(self.paths)
|
|
min_xy = all_pts[:, :2].min(axis=0)
|
|
max_xy = all_pts[:, :2].max(axis=0)
|
|
center_xy = (min_xy + max_xy) / 2.0
|
|
|
|
offset_x = self.bed_center_x - center_xy[0]
|
|
offset_y = self.bed_center_y - center_xy[1]
|
|
|
|
# Apply offset to all paths
|
|
centered_paths = []
|
|
for path in self.paths:
|
|
new_path = path.copy()
|
|
new_path[:, 0] += offset_x
|
|
new_path[:, 1] += offset_y
|
|
centered_paths.append(new_path)
|
|
|
|
try:
|
|
retraction_fr_val = float(self.retraction_feedrate) if self.retraction_feedrate is not None else 0.0
|
|
except (TypeError, ValueError):
|
|
retraction_fr_val = 0.0
|
|
|
|
if retraction_fr_val <= 0.0:
|
|
retraction_fr_val = self.feedrate
|
|
|
|
gcode: list[str] = []
|
|
|
|
if include_header:
|
|
header: list[str] = [
|
|
"; Generated from NodeSlicer",
|
|
f";TYPE:Custom",
|
|
f";DEFAULT_FEEDRATE={self.feedrate}",
|
|
f";DEFAULT_TRAVEL_FEEDRATE={self.travel_feedrate}",
|
|
f";DEFAULT_RETRACTION_FEEDRATE={retraction_fr_val}",
|
|
f";DEFAULT_EXTRUSION_RATE={self.extrusion_rate}",
|
|
f";DEFAULT_LAYER_HEIGHT={self.layer_height:.6f}",
|
|
]
|
|
if self.first_layer_height is not None:
|
|
header.append(f";DEFAULT_FIRST_LAYER_HEIGHT={self.first_layer_height:.6f}")
|
|
header.extend([
|
|
f";DEFAULT_LAYER_WIDTH={self.layer_width:.6f}",
|
|
f";OFFSET_X={offset_x:.6f}",
|
|
f";OFFSET_Y={offset_y:.6f}",
|
|
])
|
|
|
|
if self.objects_info:
|
|
import json
|
|
header.append(f"; objects_info = {json.dumps(self.objects_info)}")
|
|
elif self.name:
|
|
import json
|
|
header.append(f'; objects_info = {json.dumps({"objects": [{"name": self.name, "polygon": []}]})}')
|
|
|
|
if hasattr(self, "transforms") and self.transforms:
|
|
import json
|
|
header.append(f";PREPARATION_TRANSFORMS={json.dumps(self.transforms)}")
|
|
|
|
total_tx, total_ty, total_tz = 0.0, 0.0, 0.0
|
|
total_rx, total_ry, total_rz = 0.0, 0.0, 0.0
|
|
total_sx, total_sy, total_sz = 1.0, 1.0, 1.0
|
|
|
|
for t in self.transforms:
|
|
if isinstance(t, dict):
|
|
tr = t.get("translation", (0.0, 0.0, 0.0))
|
|
rot = t.get("rotation", (0.0, 0.0, 0.0))
|
|
sc = t.get("scale", (1.0, 1.0, 1.0))
|
|
if "offset" in t:
|
|
tr = t["offset"]
|
|
|
|
total_tx += float(tr[0])
|
|
total_ty += float(tr[1])
|
|
total_tz += float(tr[2])
|
|
total_rx += float(rot[0])
|
|
total_ry += float(rot[1])
|
|
total_rz += float(rot[2])
|
|
total_sx *= float(sc[0])
|
|
total_sy *= float(sc[1])
|
|
total_sz *= float(sc[2])
|
|
|
|
header.append(f";PREPARATION_TOTAL_TRANSLATION={total_tx:.6f},{total_ty:.6f},{total_tz:.6f}")
|
|
header.append(f";PREPARATION_TOTAL_ROTATION={total_rx:.6f},{total_ry:.6f},{total_rz:.6f}")
|
|
header.append(f";PREPARATION_TOTAL_SCALE={total_sx:.6f},{total_sy:.6f},{total_sz:.6f}")
|
|
|
|
header.extend([
|
|
"G21 ; set units to mm",
|
|
"G90 ; absolute XYZ positioning",
|
|
"M83 ; relative extrusion mode",
|
|
"G92 E0",
|
|
])
|
|
gcode += header
|
|
|
|
# Safely parse retraction and lift fields.
|
|
# If they cannot be parsed or value is zero, we skip them.
|
|
try:
|
|
retraction_val = float(self.retraction) if self.retraction is not None else 0.0
|
|
except (TypeError, ValueError):
|
|
retraction_val = 0.0
|
|
|
|
try:
|
|
lift_val = float(self.lift) if self.lift is not None else 0.0
|
|
except (TypeError, ValueError):
|
|
lift_val = 0.0
|
|
|
|
last_z_um: int | None = None
|
|
max_z = 0
|
|
last_nozzle_z = 0.0
|
|
|
|
for i, line in enumerate(centered_paths):
|
|
coords = line.tolist()
|
|
if len(coords) < 2:
|
|
continue
|
|
|
|
e_rate = self.extrusion_rates[i]
|
|
f_rate = self.feedrates[i]
|
|
extras = {prefix: vals[i] for prefix, vals in self.extra_attributes.items()}
|
|
|
|
x0, y0, z0 = coords[0]
|
|
z0_um = int(round(z0 * SCALE))
|
|
|
|
active_feedrate = self.feedrate
|
|
if i > 0:
|
|
# 1. Retract if retraction is enabled
|
|
if retraction_val > 0.0:
|
|
gcode.append(f"G1 E-{retraction_val:.5f} F{retraction_fr_val:.3f}")
|
|
active_feedrate = retraction_fr_val
|
|
|
|
# 2. Lift Z if lift is enabled
|
|
if lift_val > 0.0:
|
|
gcode.append(f"G0 Z{last_nozzle_z + lift_val:.3f} F720")
|
|
active_feedrate = 720.0
|
|
|
|
# 3. Travel to the start of path `i` (x0, y0) in XY
|
|
travel_fr = f_rate[0] if f_rate[0] > 0 else self.travel_feedrate
|
|
gcode.append(f"G0 X{x0:.3f} Y{y0:.3f} F{travel_fr}")
|
|
active_feedrate = travel_fr
|
|
|
|
# 4. Handle Z alignment (layer change and/or un-lift)
|
|
if last_z_um is None or z0_um != last_z_um:
|
|
gcode.append(f";BEFORE_LAYER_CHANGE")
|
|
gcode.append(f"G0 Z{z0:.3f} F720")
|
|
gcode.append(f";AFTER_LAYER_CHANGE")
|
|
last_z_um = z0_um
|
|
active_feedrate = 720.0
|
|
elif lift_val > 0.0:
|
|
gcode.append(f"G0 Z{z0:.3f} F720")
|
|
active_feedrate = 720.0
|
|
|
|
# 5. Un-retract if retraction was enabled
|
|
if retraction_val > 0.0:
|
|
gcode.append(f"G1 E{retraction_val:.5f} F{retraction_fr_val:.3f}")
|
|
active_feedrate = retraction_fr_val
|
|
else:
|
|
if last_z_um is None or z0_um != last_z_um:
|
|
gcode.append(f";BEFORE_LAYER_CHANGE")
|
|
gcode.append(f"G0 Z{z0:.3f} F720")
|
|
gcode.append(f";AFTER_LAYER_CHANGE")
|
|
last_z_um = z0_um
|
|
active_feedrate = 720.0
|
|
|
|
old_fr = f_rate[0] if f_rate[0] > 0 else self.travel_feedrate
|
|
gcode.append(f"G0 X{x0:.3f} Y{y0:.3f} Z{z0:.3f} F{old_fr}")
|
|
active_feedrate = old_fr
|
|
|
|
old_fr = active_feedrate
|
|
old_x, old_y, old_z = x0, y0, z0
|
|
old_extras = {prefix: None for prefix in extras}
|
|
for j, (p1, p2) in enumerate(zip(coords, coords[1:])):
|
|
seg_len: float = math.dist(p1, p2)
|
|
e_segment = seg_len * self.extrusion_rate * e_rate[j]
|
|
|
|
# Per-segment feedrate value (not multiplier)
|
|
fr = f_rate[j] if f_rate[j] > 0 else self.feedrate
|
|
|
|
old_x, old_y, old_z = p1
|
|
x, y, z = p2
|
|
|
|
cur_gcode = "G1"
|
|
|
|
if not old_x == x: cur_gcode += f" X{x:.3f}"
|
|
if not old_y == y: cur_gcode += f" Y{y:.3f}"
|
|
if not old_z == z: cur_gcode += f" Z{z:.3f}"
|
|
if e_segment: cur_gcode += f" E{e_segment:.5f}"
|
|
if not old_fr == fr: cur_gcode += f" F{fr:.3f}"
|
|
|
|
for prefix, evals in extras.items():
|
|
val = evals[j]
|
|
if not old_extras[prefix] == val:
|
|
cur_gcode += f" {prefix}{val:.3f}"
|
|
old_extras[prefix] = val
|
|
|
|
gcode += [cur_gcode]
|
|
|
|
max_z = max(max_z, z)
|
|
old_fr = fr
|
|
|
|
last_nozzle_z = coords[-1][2]
|
|
|
|
return "\n".join(gcode) |