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") rebuild = False if os.path.exists(_c_src): if not os.path.exists(_so_path) or os.path.getmtime(_so_path) < os.path.getmtime(_c_src): rebuild = True if rebuild: 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 if hasattr(_c_seam_lib, "weld_vertices_c"): _c_seam_lib.weld_vertices_c.argtypes = [ ctypes.POINTER(ctypes.c_float), ctypes.c_int, ctypes.POINTER(ctypes.c_float), ctypes.POINTER(ctypes.c_uint32) ] _c_seam_lib.weld_vertices_c.restype = ctypes.c_int if hasattr(_c_seam_lib, "polygon_signed_area_2d_c"): _c_seam_lib.polygon_signed_area_2d_c.argtypes = [ ctypes.POINTER(ctypes.c_double), ctypes.c_int, ctypes.c_int ] _c_seam_lib.polygon_signed_area_2d_c.restype = ctypes.c_double except Exception: _c_seam_lib = None def weld_vertices(tris: np.ndarray) -> tuple[np.ndarray, np.ndarray]: """ Given an array of triangles of shape (F, 3, 3) or (F, 4, 3), welds identical vertices. Returns (unique_verts, faces) where unique_verts is (V, 3) float32 and faces is (F, 3) uint32. """ flat = np.ascontiguousarray(tris[:, :3, :].reshape(-1, 3), dtype=np.float32) num_verts = len(flat) if num_verts == 0: return np.empty((0, 3), dtype=np.float32), np.empty((0, 3), dtype=np.uint32) if _c_seam_lib is not None and hasattr(_c_seam_lib, "weld_vertices_c"): try: out_unique = np.empty((num_verts, 3), dtype=np.float32) out_inv = np.empty(num_verts, dtype=np.uint32) unique_count = int(_c_seam_lib.weld_vertices_c( flat.ctypes.data_as(ctypes.POINTER(ctypes.c_float)), num_verts, out_unique.ctypes.data_as(ctypes.POINTER(ctypes.c_float)), out_inv.ctypes.data_as(ctypes.POINTER(ctypes.c_uint32)) )) if unique_count >= 0: return out_unique[:unique_count], out_inv.reshape(-1, 3) except Exception: pass # NumPy fallback 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) return unique_verts, faces 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 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))) except Exception: best_idx = 0 else: 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.empty_like(p) split = M - best_idx new_p[:split] = p[best_idx:-1] new_p[split:] = p[:best_idx + 1] new_paths.append(new_p) ext = self.extrusion_rate[i] if len(ext) == len(p): new_e = np.empty_like(ext) new_e[:split] = ext[best_idx:-1] new_e[split:] = ext[:best_idx + 1] new_ext.append(new_e) else: new_ext.append(ext) feed = self.feedrate[i] if len(feed) == len(p): new_f = np.empty_like(feed) new_f[:split] = feed[best_idx:-1] new_f[split:] = feed[:best_idx + 1] new_feed.append(new_f) else: new_feed.append(feed) for k in self.extra_attributes: attr = self.extra_attributes[k][i] if len(attr) == len(p): new_a = np.empty_like(attr) new_a[:split] = attr[best_idx:-1] new_a[split:] = attr[:best_idx + 1] new_extra[k].append(new_a) 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 [] unique_verts, faces = weld_vertices(mesh_array) if len(unique_verts) == 0: return [] 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: if _c_seam_lib is not None and hasattr(_c_seam_lib, "polygon_signed_area_2d_c"): poly_c = np.ascontiguousarray(poly, dtype=np.float64) area = _c_seam_lib.polygon_signed_area_2d_c(poly_c.ctypes.data_as(ctypes.POINTER(ctypes.c_double)), N, 2) if area <= 0: continue else: 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: (x_um, y_um) packed into 64-bit scalar key pts_um = np.round(pts * SCALE).astype(np.int64) k1_arr = (pts_um[:, 0, 0] << 32) | (pts_um[:, 0, 1] & 0xFFFFFFFF) k2_arr = (pts_um[:, 1, 0] << 32) | (pts_um[:, 1, 1] & 0xFFFFFFFF) adj: dict[int, list[tuple[int, int]]] = {} for i in range(len(segments)): k1 = int(k1_arr[i]) k2 = int(k2_arr[i]) 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(k1_arr[i]) k2 = int(k2_arr[i]) if k1 == k2: continue fwd_pts = [pts[i, 0], pts[i, 1]] bwd_pts = [] 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(k1_arr[idx]) other_pt = pts[idx, 1] if pk1 == curr_k else pts[idx, 0] fwd_pts.append(other_pt) used.add(idx) curr_k = next_k found = True break if not found: break # Backward (O(1) append to bwd_pts) curr_k = k1 while True: found = False for next_k, idx in adj.get(curr_k, []): if idx not in used: pk1 = int(k1_arr[idx]) other_pt = pts[idx, 1] if pk1 == curr_k else pts[idx, 0] bwd_pts.append(other_pt) used.add(idx) curr_k = next_k found = True break if not found: break all_pts = bwd_pts[::-1] + fwd_pts if bwd_pts else fwd_pts p_arr = np.empty((len(all_pts), 3), dtype=np.float64) p_arr[:, :2] = all_pts p_arr[:, 2] = z if len(p_arr) > 2 and is_closed_path(p_arr): p_arr[-1] = p_arr[0] paths.append(p_arr) 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)