From 978dfcbe1ddd146f94e12d4145452d48fe47cfaa Mon Sep 17 00:00:00 2001 From: Nicolas Predella Date: Sun, 27 Sep 2026 16:08:38 +0300 Subject: [PATCH] up --- README.md | 2 +- core/geometry.py | 31 +- core/nodeslicer/nodes/mesh.py | 24 +- core/nodeslicer/nodes/path.py | 172 +++---- core/nodeslicer/tree.py | 6 +- core/slice.py | 665 ++++++++++++++++++++++---- doc/MODULES.md | 5 +- doc/NODES.md | 17 +- infra/blender/blender_mesh_capture.py | 9 +- registry.py | 5 +- tests/test_slice.py | 549 ++++++++++++++++++++- 11 files changed, 1253 insertions(+), 232 deletions(-) diff --git a/README.md b/README.md index b93f318..a163fb5 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ NodeSlicer is a high-performance Blender extension that provides a custom node-b 4. **Process**: * Add perimeters with **Buffer Shells**. * Add internal structure with the **Infill** node. - * Combine or modify paths using **Join Paths** or **Boolean Paths**. + * Combine or modify paths using **Path Operations**. 5. **Generate**: Convert paths to G-code using **Paths to Gcode**. 6. **Export & Preview**: Use **Gcode To File** to save the result, and **Gcode to Preview Mesh** to visualize the final toolpaths in the viewport. diff --git a/core/geometry.py b/core/geometry.py index b455de7..12b4839 100644 --- a/core/geometry.py +++ b/core/geometry.py @@ -28,25 +28,28 @@ def euler_rotation_matrix(rx: float, ry: float, rz: float) -> np.ndarray: from collections import OrderedDict +def _make_hashable(val: Any) -> Any: + if isinstance(val, np.ndarray): + return crc32_array(val) + elif isinstance(val, (list, tuple)): + if not val: + return () + if isinstance(val[0], np.ndarray): + return crc32_list(list(val)) + return tuple(_make_hashable(v) for v in val) + elif isinstance(val, dict): + return tuple(sorted((k, _make_hashable(v)) for k, v in val.items())) + elif isinstance(val, set): + return tuple(sorted(_make_hashable(v) for v in val)) + return val + def ndarray_lru_cache(maxsize: int = 128) -> Callable[[Callable[..., Any]], Callable[..., Any]]: def decorator(func: Callable[..., Any]) -> Callable[..., Any]: cache: OrderedDict[tuple[Any, ...], Any] = OrderedDict() def wrapper(*args, **kwargs): - key_parts = [] - for arg in args: - if isinstance(arg, np.ndarray): - key_parts.append(crc32_array(arg)) - elif isinstance(arg, list) and arg and isinstance(arg[0], np.ndarray): - key_parts.append(crc32_list(arg)) - else: - key_parts.append(arg) + key_parts = [_make_hashable(arg) for arg in args] for k, v in sorted(kwargs.items()): - if isinstance(v, np.ndarray): - key_parts.append((k, crc32_array(v))) - elif isinstance(v, list) and v and isinstance(v[0], np.ndarray): - key_parts.append((k, crc32_list(v))) - else: - key_parts.append((k, v)) + key_parts.append((k, _make_hashable(v))) key = tuple(key_parts) if key in cache: diff --git a/core/nodeslicer/nodes/mesh.py b/core/nodeslicer/nodes/mesh.py index 8772102..cdaf156 100644 --- a/core/nodeslicer/nodes/mesh.py +++ b/core/nodeslicer/nodes/mesh.py @@ -41,12 +41,19 @@ class SLICENODE_JoinMesh(SliceNodeJoiner): def evaluate_output(self, socket_name: str, **_kwargs) -> SlicingCollection: from ....infra.blender.blender_slice_nodes_generic import resolve_input - merged_coll = resolve_input(self.inputs[0]) or SlicingCollection([],'') + first_coll = resolve_input(self.inputs[0]) if self.inputs else None + merged_coll = SlicingCollection([], first_coll.name if first_coll else '') + if first_coll: + merged_coll.objects = list(first_coll.objects) + merged_coll.transforms = [dict(t) for t in getattr(first_coll, "transforms", [])] for input in self.inputs[1:]: coll = resolve_input(input) if coll: - merged_coll.objects += coll.objects + merged_coll.objects += list(coll.objects) + for t in getattr(coll, "transforms", []): + if t not in merged_coll.transforms: + merged_coll.transforms.append(dict(t)) return merged_coll @@ -63,10 +70,15 @@ class SLICENODE_JoinMeshGroups(SliceNodeJoiner): from ....infra.blender.blender_slice_nodes_generic import resolve_input group: SlicingGroup = SlicingGroup([]) - for input in self.inputs: - coll = resolve_input(input) - if coll: - group.collections[coll.objects[0].name] = coll + for idx, input_socket in enumerate(self.inputs): + coll = resolve_input(input_socket) + if coll: + col_name = getattr(coll, "name", "") + if not col_name and coll.objects: + col_name = coll.objects[0].name + if not col_name: + col_name = f"collection_{idx}" + group.collections[col_name] = coll return group diff --git a/core/nodeslicer/nodes/path.py b/core/nodeslicer/nodes/path.py index 8233f7c..62aa170 100644 --- a/core/nodeslicer/nodes/path.py +++ b/core/nodeslicer/nodes/path.py @@ -64,19 +64,18 @@ class SLICENODE_SliceShells(SliceNode): def evaluate_output(self, socket_name: str, **_kwargs) -> SlicePaths: if socket_name != "Paths": return SlicePaths() - coll: SlicingCollection | SlicingGroup | None = self.resolve("Mesh") - layer_height: float = self.resolve("Layer Height") or 0.2 + coll: Any = self.resolve("Mesh") + layer_height: float = float(self.resolve("Layer Height") or 0.2) + if layer_height <= 0: + layer_height = 0.2 + fl_height: float | None = None - if hasattr(self, "inputs") and "First Layer Height" in self.inputs: - try: - fl_height = self.resolve("First Layer Height") - except Exception: - fl_height = None - else: - try: - fl_height = self.resolve("First Layer Height") - except Exception: - fl_height = None + try: + fl_val = self.resolve("First Layer Height") + if fl_val is not None: + fl_height = float(fl_val) + except Exception: + fl_height = None if fl_height is not None and fl_height <= 0: fl_height = None @@ -93,15 +92,31 @@ class SLICENODE_SliceShells(SliceNode): if not coll: return SlicePaths() - # Handle SlicingGroup flattening + # Handle SlicingGroup, SlicingCollection, SlicingObject, or list if isinstance(coll, SlicingGroup): objects = [] for c in coll.collections.values(): objects.extend(c.objects) + elif isinstance(coll, SlicingCollection): + objects = list(coll.objects) + elif isinstance(coll, SlicingObject): + objects = [coll] + elif isinstance(coll, list): + objects = [] + for item in coll: + if isinstance(item, SlicingGroup): + for c in item.collections.values(): + objects.extend(c.objects) + elif isinstance(item, SlicingCollection): + objects.extend(item.objects) + elif isinstance(item, SlicingObject): + objects.append(item) else: - objects = coll.objects + objects = getattr(coll, "objects", [coll] if hasattr(coll, "mesh") else []) merged_object = SlicingObject.merge(objects) + if merged_object.mesh is None or merged_object.mesh.size == 0: + return SlicePaths() if rotation and any(rotation): from mathutils import Euler @@ -110,8 +125,8 @@ class SLICENODE_SliceShells(SliceNode): merged_object.mesh[:, 3:4, :] = merged_object.mesh[:, 3:4, :] @ mat.T min_z = float(merged_object.mesh[:, 0:3, 2:3].min()) - from math import floor - min_z = floor(min_z / layer_height) * layer_height + if abs(min_z) < 1e-6: + min_z = 0.0 max_z = float(merged_object.mesh[:, 0:3, 2:3].max()) h_first = fl_height if fl_height is not None else layer_height @@ -124,21 +139,37 @@ class SLICENODE_SliceShells(SliceNode): z_first_um = min_z_um + flh_um z_rest_start_um = z_first_um + lh_um - if z_rest_start_um < max_z_um and lh_um > 0: - z_um_values = list(range(z_rest_start_um, max_z_um, lh_um)) - z_values = np.array([z_first_um] + z_um_values, dtype=np.float64) / 1000.0 - elif z_first_um < max_z_um: - z_values = np.array([z_first_um], dtype=np.float64) / 1000.0 + if z_first_um <= max_z_um: + if z_rest_start_um <= max_z_um and lh_um > 0: + z_um_values = list(range(z_rest_start_um, max_z_um + 1, lh_um)) + z_values = np.array([z_first_um] + z_um_values, dtype=np.float64) / 1000.0 + else: + z_values = np.array([z_first_um], dtype=np.float64) / 1000.0 else: - z_values = np.array([]) + z_values = np.array([], dtype=np.float64) slices_group: list[list[np.ndarray]] | None = slice_at_range( [merged_object.mesh], z_values, ignore_holes=ignore_holes ) - assert slices_group - slices = SlicePaths(slices_group[0]) + if not slices_group or not slices_group[0]: + return SlicePaths() + + paths = slices_group[0] + # Assign layer-consistent group indices based on Z height in integer microns + z_to_group: dict[int, int] = {} + groups = [] + for p in paths: + if len(p) > 0: + z_k = int(round(p[0, 2] * 1000)) + if z_k not in z_to_group: + z_to_group[z_k] = len(z_to_group) + groups.append(z_to_group[z_k]) + else: + groups.append(0) + + slices = SlicePaths(paths, groups=groups) slices.layer_height = layer_height if fl_height is not None: slices.first_layer_height = fl_height @@ -427,26 +458,6 @@ class SLICENODE_Infill(SliceNode): layer_rotation=rotation ) -@register_class -class SLICENODE_GetInfillMask(SliceNode): - bl_label = "Get Infill Mask" - - def init(self, context): - self.add_input("SLICESOCK_Paths") - self.add_output("SLICESOCK_Paths") - - def draw_buttons(self, context, layout): - pass - - def evaluate_output(self, socket_name: str, **_kwargs) -> SlicePaths: - if socket_name != "Paths": return SlicePaths() - - paths: SlicePaths = self.resolve("Paths") - - if not paths: return SlicePaths() - - return paths.get_infill_mask() - @register_class class SLICENODE_OverhangMask(SliceNode): bl_label = "Overhang Mask" @@ -470,19 +481,23 @@ class SLICENODE_OverhangMask(SliceNode): return paths.get_overhang_mask(tolerance=tolerance) @register_class -class SLICENODE_JoinSlices(SliceNodeJoiner): - bl_label = "Boolean Paths" +class SLICENODE_PathOperations(SliceNodeJoiner): + bl_label = "Path Operations" socket_type = "SLICESOCK_Paths" operation: str = NfEnumProperty( name="Operation", items=[ - ('UNION', "Union", "Union of the slices"), - ('INTERSECTION', "Intersection", "Intersection of the slices"), - ('DIFFERENCE', "Subtract", "Clipping of the first slice"), - ('XOR', "Exclusive or", "Exclusive or of the slices"), + ('ZIP', "Zip paths", "Interleave paths from all inputs layer by layer"), + ('EXTEND', "Extend paths", "Append paths sequentially"), + ('MAX_COVERAGE', "Maximum coverage", "Union of all paths"), + ('MIN_COVERAGE', "Minimum coverage", "Intersection of paths per layer (single paths preserved)"), + ('INTERSECTION', "Intersection", "Intersection of all paths"), + ('SUBTRACT', "Subtract", "Subtract subsequent paths from first input"), + ('XOR', "Exclusive or", "Exclusive or of all paths"), ], - default='UNION' + default='ZIP', + update=notify_tree_changed ) connect_along_boundary: bool = NfBoolProperty( @@ -494,63 +509,26 @@ class SLICENODE_JoinSlices(SliceNodeJoiner): def draw_buttons(self, context, layout): assert layout layout.prop(self, "operation", text="") - if self.operation in ['INTERSECTION', 'DIFFERENCE']: + if self.operation in ['MIN_COVERAGE', 'INTERSECTION', 'SUBTRACT']: layout.prop(self, "connect_along_boundary") def evaluate_output(self, socket_name: str, **_kwargs) -> SlicePaths: from ....infra.blender.blender_slice_nodes_generic import resolve_input - accumulated: SlicePaths = SlicePaths() - current: SlicePaths | None = SlicePaths() - for input in self.inputs: - current = resolve_input(input) - if not current: - continue - - if not accumulated: - accumulated = current - continue - - accumulated = accumulated.boolean(current, self.operation, connect_along_boundary=self.connect_along_boundary) - - return accumulated - -@register_class -class SLICENODE_JoinPaths(SliceNodeJoiner): - bl_label = "Join Paths" - socket_type = "SLICESOCK_Paths" - - mode: str = NfEnumProperty( - name="Mode", - items=[ - ('EXTEND', "Extend", "Append paths sequentially"), - ('ZIP', "Zip", "Interleave paths from all inputs"), - ], - default='EXTEND' - ) - - def draw_buttons(self, context, layout): - assert layout - layout.prop(self, "mode", text="") - - def evaluate_output(self, socket_name: str, **_kwargs) -> SlicePaths: - from ....infra.blender.blender_slice_nodes_generic import resolve_input - inputs_data: list[SlicePaths] = [] - for input in self.inputs: - paths: SlicePaths | None = resolve_input(input) - if paths: + for input_socket in self.inputs: + paths: SlicePaths | None = resolve_input(input_socket) + if paths is not None: inputs_data.append(paths) if not inputs_data: return SlicePaths() - if self.mode == 'EXTEND': - return SlicePaths.extend_paths(inputs_data) - elif self.mode == 'ZIP': - return SlicePaths.zip_paths(inputs_data) - - return SlicePaths() + return SlicePaths.path_operations( + inputs_data, + operation=self.operation, + connect_along_boundary=self.connect_along_boundary + ) @register_class diff --git a/core/nodeslicer/tree.py b/core/nodeslicer/tree.py index 436f266..887fd07 100644 --- a/core/nodeslicer/tree.py +++ b/core/nodeslicer/tree.py @@ -505,7 +505,6 @@ class SLICENODE_MT_Geometry(Menu): layout.operator("node.add_node", text="Get Paths").type = "SLICENODE_GetPaths" layout.separator() layout.operator("node.add_node", text="Slice Shells").type = "SLICENODE_SliceShells" - layout.operator("node.add_node", text="CUDA Slice Mesh").type = "SLICENODE_CudaSliceMesh" layout.operator("node.add_node", text="Buffer Shells").type = "SLICENODE_BufferShells" layout.operator("node.add_node", text="Buffer Shells Multiple").type = "SLICENODE_BufferShellsMultiple" layout.operator("node.add_node", text="Buffer Curve").type = "SLICENODE_BufferCurve" @@ -514,9 +513,8 @@ class SLICENODE_MT_Geometry(Menu): layout.operator("node.add_node", text="Rotate Path").type = "SLICENODE_RotatePath" layout.operator("node.add_node", text="Simplify Paths").type = "SLICENODE_SimplifyPaths" layout.operator("node.add_node", text="Infill").type = "SLICENODE_Infill" - layout.operator("node.add_node", text="Get Infill Mask").type = "SLICENODE_GetInfillMask" layout.operator("node.add_node", text="Overhang Mask").type = "SLICENODE_OverhangMask" - layout.operator("node.add_node", text="Boolean Paths").type = "SLICENODE_JoinSlices" + layout.operator("node.add_node", text="Path Operations").type = "SLICENODE_PathOperations" layout.separator() layout.operator("node.add_node", text="Math").type = "SLICENODE_FloatMath" layout.operator("node.add_node", text="Integer Math").type = "SLICENODE_IntMath" @@ -559,7 +557,7 @@ class SLICENODE_MT_Joiners(Menu): assert layout layout.operator("node.add_node", text="Join Mesh Parts").type = "SLICENODE_JoinMesh" layout.operator("node.add_node", text="Join Mesh Groups").type = "SLICENODE_JoinMeshGroups" - layout.operator("node.add_node", text="Join Paths").type = "SLICENODE_JoinPaths" + layout.operator("node.add_node", text="Path Operations").type = "SLICENODE_PathOperations" layout.operator("node.add_node", text="Join Gcode").type = "SLICENODE_JoinGcode" layout.operator("node.add_node", text="Join Settings").type = "SLICENODE_JoinSettings" layout.operator("node.add_node", text="Join Execution").type = "SLICENODE_JoinExecution" diff --git a/core/slice.py b/core/slice.py index a51343d..19c3ddb 100644 --- a/core/slice.py +++ b/core/slice.py @@ -5,8 +5,7 @@ import numpy as np import pyclipper from typing import Any, Literal, cast import copy -from shapely.geometry.base import BaseGeometry -from shapely.geometry import Polygon, MultiPolygon, LineString, LinearRing, MultiLineString, GeometryCollection +from shapely.geometry import Polygon, MultiPolygon, LineString, LinearRing, MultiLineString, GeometryCollection, Point from ..infra.blender.blender_mesh_capture import PdfProperties @@ -226,11 +225,23 @@ class SlicePaths(list[np.ndarray]): def offset(self, offset_vec: np.ndarray) -> SlicePaths: """Returns a new SlicePaths object shifted by the given vector.""" - return self.copy_with([p + offset_vec for p in self]) + res = self.copy_with([p + offset_vec for p in self]) + res.transforms = [dict(t) for t in getattr(self, "transforms", [])] + res.transforms.append({ + "type": "offset", + "offset": offset_vec.tolist() if isinstance(offset_vec, np.ndarray) else list(offset_vec) + }) + return res def transform(self, matrix: np.ndarray) -> SlicePaths: """Applies a transformation matrix to all paths.""" - return self.copy_with([p @ matrix.T for p in self]) + res = self.copy_with([p @ matrix.T for p in self]) + res.transforms = [dict(t) for t in getattr(self, "transforms", [])] + res.transforms.append({ + "type": "transform", + "matrix": matrix.tolist() if isinstance(matrix, np.ndarray) else list(matrix) + }) + 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.""" @@ -443,7 +454,14 @@ class SlicePaths(list[np.ndarray]): new_p = (p - center) @ rot_mat.T + center new_paths.append(new_p) - return self.copy_with(new_paths) + res = self.copy_with(new_paths) + res.transforms = [dict(t) for t in getattr(self, "transforms", [])] + res.transforms.append({ + "type": "rotate", + "angle_deg": float(angle_deg), + "center": center.tolist() if isinstance(center, np.ndarray) else list(center) + }) + return res def get_first_layer_midpoint(self) -> np.ndarray: if not self: @@ -468,6 +486,36 @@ class SlicePaths(list[np.ndarray]): 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) @@ -884,6 +932,78 @@ def get_boundary_path(pt1: np.ndarray, pt2: np.ndarray, loops: list[np.ndarray]) 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() @@ -891,11 +1011,15 @@ def boolean_slice(subject: SlicePaths, object: SlicePaths, operation: str = 'UNI 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) @@ -911,60 +1035,53 @@ def boolean_slice(subject: SlicePaths, object: SlicePaths, operation: str = 'UNI 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 = subject_slices[0][0, 2] pc = pyclipper.Pyclipper() # pyright: ignore[reportAttributeAccessIssue] - # Function to process and add paths to Clipper - def add_paths_to_clipper(paths, role): - for s in paths: - path_pts = s[:, :2] - is_closed = is_closed_path(s) - if is_closed: - path_pts = path_pts[:-1] - scaled = (path_pts * SCALE).round().astype(np.int64).tolist() - pc.AddPath(scaled, role, is_closed) # pyright: ignore[reportAttributeAccessIssue] - layer_results = [] - def process_op(subs, clips_scaled, clips_pts, op_type): - for s in subs: + 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] - is_closed = is_closed_path(s) - - if not connect_along_boundary or is_closed: - pc.Clear() - if is_closed: path_pts = path_pts[:-1] - pc.AddPath((path_pts * SCALE).round().astype(np.int64).tolist(), pyclipper.PT_SUBJECT, is_closed) # pyright: ignore[reportAttributeAccessIssue] + 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) # pyright: ignore[reportAttributeAccessIssue] - - solution = pc.Execute2(op_type, pyclipper.PFT_EVENODD, pyclipper.PFT_EVENODD) # pyright: ignore[reportAttributeAccessIssue] - for path in pyclipper.ClosedPathsFromPolyTree(solution): # pyright: ignore[reportAttributeAccessIssue] - fpath = np.array(path, dtype=np.float64) / SCALE - fpath_3d = np.column_stack([fpath, np.full(len(fpath), z)]) - layer_results.append(np.vstack([fpath_3d, fpath_3d[0]])) - for path in pyclipper.OpenPathsFromPolyTree(solution): # pyright: ignore[reportAttributeAccessIssue] + 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.Clear() - pc.AddPath((path_pts * SCALE).round().astype(np.int64).tolist(), pyclipper.PT_SUBJECT, False) # pyright: ignore[reportAttributeAccessIssue] + 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) # pyright: ignore[reportAttributeAccessIssue] - - solution = pc.Execute2(op_type, pyclipper.PFT_EVENODD, pyclipper.PFT_EVENODD) # pyright: ignore[reportAttributeAccessIssue] - open_results = pyclipper.OpenPathsFromPolyTree(solution) # pyright: ignore[reportAttributeAccessIssue] - if not open_results: continue - + 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:] @@ -988,81 +1105,105 @@ def boolean_slice(subject: SlicePaths, object: SlicePaths, operation: str = 'UNI 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 is not continuous along boundary, finalize current group 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': - obj_closed_pts = [s[:, :2] for s in object_slices if is_closed_path(s)] - obj_closed_scaled = [(pts[:-1] * SCALE).round().astype(np.int64).tolist() for pts in obj_closed_pts] - process_op(subject_slices, obj_closed_scaled, obj_closed_pts, pyclipper.CT_INTERSECTION) # pyright: ignore[reportAttributeAccessIssue] - - # Pass 2 for B_open ∩ A_closed - b_open = [s for s in object_slices if not is_closed_path(s)] - if b_open: - sub_closed_pts = [s[:, :2] for s in subject_slices if is_closed_path(s)] - sub_closed_scaled = [(pts[:-1] * SCALE).round().astype(np.int64).tolist() for pts in sub_closed_pts] - process_op(b_open, sub_closed_scaled, sub_closed_pts, pyclipper.CT_INTERSECTION) # pyright: ignore[reportAttributeAccessIssue] - + 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': - obj_closed_pts = [s[:, :2] for s in object_slices if is_closed_path(s)] - obj_closed_scaled = [(pts[:-1] * SCALE).round().astype(np.int64).tolist() for pts in obj_closed_pts] - process_op(subject_slices, obj_closed_scaled, obj_closed_pts, pyclipper.CT_DIFFERENCE) # pyright: ignore[reportAttributeAccessIssue] - # DIFFERENCE is not symmetric for open paths in the same way, usually A - B means A is the main focus. + 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 - add_paths_to_clipper(subject_slices, pyclipper.PT_SUBJECT) # pyright: ignore[reportAttributeAccessIssue] - obj_closed = [] - obj_open = [] - for s in object_slices: - if is_closed_path(s): obj_closed.append(s) - else: obj_open.append(s) - - if operation in ['UNION', 'XOR']: - add_paths_to_clipper(obj_open, pyclipper.PT_SUBJECT) # pyright: ignore[reportAttributeAccessIssue] - - for s in obj_closed: + 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[:-1] * SCALE).round().astype(np.int64).tolist() - pc.AddPath(scaled, pyclipper.PT_CLIP, True) # pyright: ignore[reportAttributeAccessIssue] - - op_type = pyclipper.CT_UNION # pyright: ignore[reportAttributeAccessIssue] - if operation == 'UNION': op_type = pyclipper.CT_UNION # pyright: ignore[reportAttributeAccessIssue] - elif operation == 'XOR': op_type = pyclipper.CT_XOR # pyright: ignore[reportAttributeAccessIssue] + scaled = (path_pts * SCALE).round().astype(np.int64).tolist() + pc.AddPath(scaled, pyclipper.PT_SUBJECT, False) - solution = pc.Execute2(op_type, pyclipper.PFT_EVENODD, pyclipper.PFT_EVENODD) # pyright: ignore[reportAttributeAccessIssue] - for path in pyclipper.ClosedPathsFromPolyTree(solution): # pyright: ignore[reportAttributeAccessIssue] - fpath = np.array(path) / SCALE + 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)]) - layer_results.append(np.vstack([fpath_3d, fpath_3d[0]])) - for path in pyclipper.OpenPathsFromPolyTree(solution): # pyright: ignore[reportAttributeAccessIssue] - fpath = np.array(path) / SCALE + 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 @@ -1070,15 +1211,360 @@ def boolean_slice(subject: SlicePaths, object: SlicePaths, operation: str = 'UNI results.extra_attributes = calc_extra for attr, val in subject.__dict__.items(): - if attr not in ["groups", "extrusion_rate", "feedrate", "extra_attributes"]: - setattr(results, attr, val) + 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"]: - if not hasattr(results, attr): - setattr(results, attr, val) + 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": []}) + + for t in getattr(sp, "transforms", []): + if t not in merged_transforms: + merged_transforms.append(dict(t)) + + results.transforms = merged_transforms + if merged_objs: + results.objects_info = {"objects": merged_objs} + results.name = "-".join(merged_names) 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 + paths in that layer. If only one path (or single shape with holes) exists on a layer, + it preserves that path/shape intact. + """ + if not paths_list: + return SlicePaths() + + primary = next((sp for sp in paths_list if len(sp) > 0), paths_list[0]) + if not any(len(sp) > 0 for sp in paths_list): + return SlicePaths() + + all_z_keys: set[int] = set() + input_layer_maps: list[dict[int, list[np.ndarray]]] = [] + + for sp in paths_list: + 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: + idx = active_indices[0] + layer_paths = input_layer_maps[idx][z_key] + if len(layer_paths) <= 1: + results.extend(layer_paths) + results.groups.extend([group] * len(layer_paths)) + group += 1 + else: + closed_paths = [p for p in layer_paths if is_closed_path(p)] + open_paths = [p for p in layer_paths if not is_closed_path(p)] + + shapes = _group_closed_paths_into_shapes(closed_paths) + if len(shapes) <= 1: + # Single shape with holes + open paths -> preserve intact + results.extend(layer_paths) + results.groups.extend([group] * len(layer_paths)) + group += 1 + else: + # Multiple distinct shapes on the same layer -> compute their intersection + curr_sp = paths_list[idx].copy_with(shapes[0]) + for s in shapes[1:]: + curr_sp = boolean_slice( + curr_sp, + paths_list[idx].copy_with(s), + operation='INTERSECTION', + connect_along_boundary=connect_along_boundary + ) + if not curr_sp: + break + if open_paths and curr_sp: + for op in open_paths: + curr_sp = boolean_slice( + paths_list[idx].copy_with([op]), + curr_sp, + operation='INTERSECTION', + connect_along_boundary=connect_along_boundary + ) + if curr_sp: + results.extend(curr_sp) + results.groups.extend([group] * len(curr_sp)) + group += 1 + continue + + # Multiple active inputs on this layer -> intersect across inputs + idx0 = active_indices[0] + curr_sp = paths_list[idx0].copy_with(input_layer_maps[idx0][z_key]) + + for next_idx in active_indices[1:]: + next_sp = paths_list[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_transforms = [] + merged_names = [] + merged_objs = [] + for sp in paths_list: + 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": []}) + + for t in getattr(sp, "transforms", []): + if t not in merged_transforms: + merged_transforms.append(dict(t)) + + results.transforms = merged_transforms + if merged_objs: + results.objects_info = {"objects": merged_objs} + results.name = "-".join(merged_names) + + return results + +def maximum_coverage_slice( + paths_list: list[SlicePaths], + connect_along_boundary: bool = False +) -> SlicePaths: + """ + Computes the Maximum Coverage (Union) across multiple SlicePaths inputs (or within a single SlicePaths input). + For each layer (grouped by integer micron Z), it calculates the union of all paths. + """ + if not paths_list: + return SlicePaths() + + primary = next((sp for sp in paths_list if len(sp) > 0), paths_list[0]) + non_empty = [sp for sp in paths_list if len(sp) > 0] + if not non_empty: + return SlicePaths() + + if len(non_empty) == 1: + sp = non_empty[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_paths = [p for p in l_paths if is_closed_path(p)] + open_paths = [p for p in l_paths if not is_closed_path(p)] + + shapes = _group_closed_paths_into_shapes(closed_paths) + if len(shapes) <= 1: + results.extend(l_paths) + results.groups.extend([group] * len(l_paths)) + group += 1 + else: + curr_sp = sp.copy_with(shapes[0]) + for s in shapes[1:]: + curr_sp = boolean_slice( + curr_sp, + sp.copy_with(s), + operation='UNION', + connect_along_boundary=connect_along_boundary + ) + if open_paths: + curr_sp.extend(open_paths) + curr_sp.groups.extend([group] * len(open_paths)) + 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) + + merged_transforms = [] + for t in getattr(sp, "transforms", []): + if t not in merged_transforms: + merged_transforms.append(dict(t)) + results.transforms = merged_transforms + + return results + + accumulated = non_empty[0] + for sp in non_empty[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 accumulated: + return SlicePaths() + accumulated = boolean_slice( + accumulated, + sp, + operation='INTERSECTION', + connect_along_boundary=connect_along_boundary + ) + 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() + 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:]: + 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 = [] @@ -1629,6 +2115,9 @@ class Pather(object): 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: diff --git a/doc/MODULES.md b/doc/MODULES.md index 08acf13..fc311ed 100644 --- a/doc/MODULES.md +++ b/doc/MODULES.md @@ -35,7 +35,10 @@ This file provides a mapping of modules to their primary responsibilities and ke - `link_segments()`: Logic for joining loose segments into continuous paths. - `buffer_slice()`: Offset utility using PyclipperOffset. Supports rotated offsetting. - `infill_slice()`: Generates rotated grid or rectilinear infill patterns, ensuring layer-to-layer consistency. - - `boolean_slice()`: Performs 2D boolean operations (Union, Intersection, etc.) on slice sets. + - `boolean_slice()`: Performs 2D boolean operations (Union, Intersection, Subtract, XOR) on slice sets with hole support. + - `path_operations_slice()`: Unified dispatcher for multi-input path operations (`ZIP`, `EXTEND`, `MAX_COVERAGE`, `MIN_COVERAGE`, `SUBTRACT`, `XOR`). + - `minimum_coverage_slice()`: Computes layer-by-layer intersection across multiple inputs while preserving single-path/input layers. + - `maximum_coverage_slice()`: Computes layer-by-layer union across multiple inputs. - `geometry.py`: - Geometry utility functions. diff --git a/doc/NODES.md b/doc/NODES.md index a7068a7..7e4dce2 100644 --- a/doc/NODES.md +++ b/doc/NODES.md @@ -104,9 +104,17 @@ All nodes are found in the **Add (Shift+A)** menu under the **SliceNodes** categ - **Density**: Distance between lines in units. - **Infill Rotation**: Base rotation of the infill pattern. - **Layer Rotation**: Rotation of the entire slice to align infill with specific planes. -- **Get Infill Mask**: Computes the solid 2D infill mask for each layer, resolving exterior boundaries and interior holes into closed boundary loops. - **Overhang Mask**: Computes the accumulated overhang mask for each layer, propagating support requirements from top to bottom. -- **Boolean Paths**: Performs 2D boolean operations (`UNION`, `INTERSECTION`, `DIFFERENCE`, `XOR`) on multiple slice sets. +- **Path Operations**: Multi-input joiner node performing composite geometric, boolean, and interleaving operations on `SlicePaths`. + - **Operations**: + - **Zip paths**: Interleaves paths from all inputs layer by layer. + - **Extend paths**: Appends paths sequentially. + - **Maximum coverage**: Computes the union of all input paths across each layer. + - **Minimum coverage**: Computes the intersection of all input paths per layer, preserving single-path/input layers. + - **Intersection**: Computes the standard boolean intersection of all input paths across each layer. + - **Subtract**: Subtracts subsequent inputs from the first input. + - **Exclusive or**: Computes symmetric difference across all inputs. + - **Connect along boundary**: Boolean option to connect clipped open segments along the boundary of the clipping polygon. - **Math**: Float math operations. - **Integer Math**: Simple integer operations (Add, Multiply, etc.). @@ -151,8 +159,9 @@ All nodes are found in the **Add (Shift+A)** menu under the **SliceNodes** categ - **Load Gcode**: Reads a G-code file from disk. ### Utility & Joiners -- **Join Mesh**: Merges multiple `SlicingObject` instances into one. -- **Join Paths**: Combines multiple `SlicePaths`. Supports Sequential or Interleaved modes. +- **Join Mesh Parts**: Merges multiple `SlicingObject` instances into one `SlicingCollection`. +- **Join Mesh Groups**: Combines multiple `SlicingCollection` instances into a `SlicingGroup`. +- **Path Operations**: Multi-input joiner performing Zip, Extend, Maximum coverage, Minimum coverage, Intersection, Subtract, and XOR operations. - **Join Gcode**: Concatenates multiple G-code strings. - **Join Settings**: Sequentially merges multiple override dictionaries. Later inputs overwrite earlier ones for matching keys. - **Join Execution**: Synchronizes sequential execution flows. diff --git a/infra/blender/blender_mesh_capture.py b/infra/blender/blender_mesh_capture.py index c40202a..9a10144 100644 --- a/infra/blender/blender_mesh_capture.py +++ b/infra/blender/blender_mesh_capture.py @@ -550,11 +550,10 @@ def _cached_objects_to_tris(obj_names: tuple[str, ...], matrices: tuple[tuple[fl tx_verts = homogeneous_verts @ trimesh.matrix_world tx_verts = (tx_verts[:, :3]) * scale - homogeneous_norm: NDArray[float64] = np.hstack((tris_v_n, np.ones((tris_v_n.shape[0], 1)))) # type: ignore - tx_norm = homogeneous_norm @ trimesh.matrix_world.T - tx_norm = tx_norm[:, :3] - tx_norm = tx_norm / np.linalg.norm(tx_norm, axis=1, keepdims=True) - tx_norm = tx_norm[:, np.newaxis, :] + tx_norm = tris_v_n @ trimesh.matrix_world[:3, :3] + norms_len = np.linalg.norm(tx_norm, axis=1, keepdims=True) + norms_len = np.where(norms_len == 0, 1.0, norms_len) + tx_norm = (tx_norm / norms_len)[:, np.newaxis, :] tx_tris = tx_verts[tris_v_i] diff --git a/registry.py b/registry.py index 9bb06ef..286f5af 100644 --- a/registry.py +++ b/registry.py @@ -73,7 +73,10 @@ def blender_unregister_timers(): import os -from bpy.utils.previews import ImagePreviewCollection +try: + from bpy.utils.previews import ImagePreviewCollection +except ImportError: + ImagePreviewCollection = Any # type: ignore _icons_pcoll: ImagePreviewCollection | None = None def blender_register_icons(): diff --git a/tests/test_slice.py b/tests/test_slice.py index 86315ce..765f12f 100644 --- a/tests/test_slice.py +++ b/tests/test_slice.py @@ -1,6 +1,6 @@ import numpy as np import unittest -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch import sys from pathlib import Path @@ -210,7 +210,6 @@ class TestSlice(unittest.TestCase): def test_get_infill_mask(self): from NodeSlicer.core.slice import SlicePaths - from NodeSlicer.core.nodeslicer.nodes.path import SLICENODE_GetInfillMask # Outer square: (0,0) to (10,10) at Z=3.0 (area 100) outer = np.array([ @@ -250,12 +249,6 @@ class TestSlice(unittest.TestCase): areas.sort() self.assertAlmostEqual(areas[0], 16.0, places=2) # Hole area self.assertAlmostEqual(areas[1], 100.0, places=2) # Outer area - - # Test with node evaluation - node = SLICENODE_GetInfillMask() - node.resolve = lambda key: slice_paths if key == "Paths" else None - node_mask = node.evaluate_output("Paths") - self.assertEqual(len(node_mask), 2) def test_get_infill_mask_multiple_holes_and_islands(self): from NodeSlicer.core.slice import SlicePaths @@ -586,6 +579,70 @@ class TestSlice(unittest.TestCase): # First layer extrusion rates should be multiplied by 0.3 / 0.2 = 1.5 np.testing.assert_allclose(slices.extrusion_rate[0], 1.5) + def test_slice_shells_comprehensive_validation(self): + from NodeSlicer.core.nodeslicer.nodes.path import SLICENODE_SliceShells + from NodeSlicer.infra.blender.blender_mesh_capture import SlicingObject, SlicingCollection, SlicingGroup + + # 10x10x1.0 box mesh + vertices = np.array([ + [0.0, 0.0, 0.0], [10.0, 0.0, 0.0], [10.0, 10.0, 0.0], [0.0, 10.0, 0.0], + [0.0, 0.0, 1.0], [10.0, 0.0, 1.0], [10.0, 10.0, 1.0], [0.0, 10.0, 1.0], + ]) + faces = [ + [0, 1, 2], [0, 2, 3], + [4, 6, 5], [4, 7, 6], + [0, 4, 5], [0, 5, 1], + [1, 5, 6], [1, 6, 2], + [2, 6, 7], [2, 7, 3], + [3, 7, 4], [3, 4, 0] + ] + triangles = np.zeros((len(faces), 4, 3)) + for i, face in enumerate(faces): + triangles[i, 0] = vertices[face[0]] + triangles[i, 1] = vertices[face[1]] + triangles[i, 2] = vertices[face[2]] + + sobj = SlicingObject() + sobj.name = "TestBox" + sobj.mesh = triangles + + # 1. Direct SlicingObject input support + node = SLICENODE_SliceShells() + node.resolve = lambda name: sobj if name == "Mesh" else (0.2 if name == "Layer Height" else (0.35 if name == "First Layer Height" else None)) + slices = node.evaluate_output("Paths") + + # Layer count: with min_z=0, max_z=1.0, flh=0.35, lh=0.2: + # Layers: 0.35, 0.55, 0.75, 0.95 -> 4 layers + self.assertEqual(len(slices), 4) + self.assertAlmostEqual(slices[0][0, 2], 0.35) + self.assertAlmostEqual(slices[1][0, 2], 0.55) + self.assertAlmostEqual(slices[2][0, 2], 0.75) + self.assertAlmostEqual(slices[3][0, 2], 0.95) + + # Groups must match layer index + self.assertEqual(slices.groups, [0, 1, 2, 3]) + + # Extrusion rate for first layer is 0.35 / 0.2 = 1.75 + np.testing.assert_allclose(slices.extrusion_rate[0], 1.75) + np.testing.assert_allclose(slices.extrusion_rate[1], 1.0) + self.assertEqual(slices.layer_height, 0.2) + self.assertEqual(slices.first_layer_height, 0.35) + + # 2. Equal first layer height (0.2 / 0.2) -> exactly 5 layers: 0.2, 0.4, 0.6, 0.8, 1.0 + node.resolve = lambda name: sobj if name == "Mesh" else (0.2 if name == "Layer Height" else (0.2 if name == "First Layer Height" else None)) + slices_eq = node.evaluate_output("Paths") + self.assertEqual(len(slices_eq), 5) + self.assertAlmostEqual(slices_eq[0][0, 2], 0.2) + self.assertAlmostEqual(slices_eq[-1][0, 2], 1.0) + np.testing.assert_allclose(slices_eq.extrusion_rate[0], 1.0) + + # 3. First layer height <= 0 fallback to layer_height + node.resolve = lambda name: sobj if name == "Mesh" else (0.2 if name == "Layer Height" else (0.0 if name == "First Layer Height" else None)) + slices_zero_fl = node.evaluate_output("Paths") + self.assertEqual(len(slices_zero_fl), 5) + self.assertAlmostEqual(slices_zero_fl[0][0, 2], 0.2) + self.assertIsNone(getattr(slices_zero_fl, "first_layer_height", None)) + def test_slicepaths_attribute_conservation(self): from NodeSlicer.core.slice import SlicePaths @@ -928,12 +985,12 @@ class TestSlice(unittest.TestCase): node.ignore_holes = False node.resolve = lambda name: scoll if name == "Mesh" else (0.5 if name == "Layer Height" else None) slices_normal = node.evaluate_output("Paths") - self.assertEqual(len(slices_normal), 2) # Outer + hole + self.assertEqual(len(slices_normal), 4) # 2 layers * (Outer + hole) = 4 # 2. Evaluation with ignore_holes=True node.ignore_holes = True slices_ignored = node.evaluate_output("Paths") - self.assertEqual(len(slices_ignored), 1) # Only outer + self.assertEqual(len(slices_ignored), 2) # 2 layers * (Only outer) = 2 # 3. Evaluation when node lacks ignore_holes property (simulating missing property / legacy tree) node_missing = SLICENODE_SliceShells.__new__(SLICENODE_SliceShells) @@ -941,7 +998,7 @@ class TestSlice(unittest.TestCase): node_missing.resolve = lambda name: scoll if name == "Mesh" else (0.5 if name == "Layer Height" else None) slices_missing = node_missing.evaluate_output("Paths") self.assertIsNotNone(slices_missing) - self.assertEqual(len(slices_missing), 2) + self.assertEqual(len(slices_missing), 4) # 4. draw_buttons with and without ignore_holes does not fail mock_layout = MagicMock() @@ -988,7 +1045,477 @@ class TestSlice(unittest.TestCase): self.assertTrue(is_closed_path(paths[0])) self.assertTrue(np.allclose(paths[0][0], paths[0][-1])) + def test_path_operations_zip(self): + from NodeSlicer.core.slice import SlicePaths + # Input 1: layers at Z=1.0 and Z=2.0 + p1 = np.array([[0, 0, 1.0], [10, 0, 1.0], [10, 10, 1.0], [0, 10, 1.0], [0, 0, 1.0]]) + p2 = np.array([[0, 0, 2.0], [10, 0, 2.0], [10, 10, 2.0], [0, 10, 2.0], [0, 0, 2.0]]) + sp1 = SlicePaths([p1, p2], groups=[0, 1]) + + # Input 2: layers at Z=1.0 and Z=2.0 + q1 = np.array([[5, 5, 1.0], [15, 5, 1.0], [15, 15, 1.0], [5, 15, 1.0], [5, 5, 1.0]]) + q2 = np.array([[5, 5, 2.0], [15, 5, 2.0], [15, 15, 2.0], [5, 15, 2.0], [5, 5, 2.0]]) + sp2 = SlicePaths([q1, q2], groups=[0, 1]) + + zipped = SlicePaths.path_operations([sp1, sp2], operation='ZIP') + self.assertEqual(len(zipped), 4) + # Verify layer interleaving: (p1, q1) for group 0, (p2, q2) for group 1 + self.assertEqual(zipped.groups, [0, 0, 1, 1]) + self.assertTrue(np.allclose(zipped[0], p1)) + self.assertTrue(np.allclose(zipped[1], q1)) + self.assertTrue(np.allclose(zipped[2], p2)) + self.assertTrue(np.allclose(zipped[3], q2)) + + def test_path_operations_extend(self): + from NodeSlicer.core.slice import SlicePaths + p1 = np.array([[0, 0, 1.0], [10, 0, 1.0], [10, 10, 1.0], [0, 10, 1.0], [0, 0, 1.0]]) + p2 = np.array([[0, 0, 2.0], [10, 0, 2.0], [10, 10, 2.0], [0, 10, 2.0], [0, 0, 2.0]]) + sp1 = SlicePaths([p1], groups=[0]) + sp2 = SlicePaths([p2], groups=[1]) + + extended = SlicePaths.path_operations([sp1, sp2], operation='EXTEND') + self.assertEqual(len(extended), 2) + self.assertTrue(np.allclose(extended[0], p1)) + self.assertTrue(np.allclose(extended[1], p2)) + + def test_path_operations_max_coverage(self): + from NodeSlicer.core.slice import SlicePaths + # Union of two overlapping squares at Z=1.0: (0,0)-(10,10) and (5,0)-(15,10) + p1 = np.array([[0, 0, 1.0], [10, 0, 1.0], [10, 10, 1.0], [0, 10, 1.0], [0, 0, 1.0]]) + p2 = np.array([[5, 0, 1.0], [15, 0, 1.0], [15, 10, 1.0], [5, 10, 1.0], [5, 0, 1.0]]) + sp1 = SlicePaths([p1], groups=[0]) + sp2 = SlicePaths([p2], groups=[0]) + + union_res = SlicePaths.path_operations([sp1, sp2], operation='MAX_COVERAGE') + self.assertEqual(len(union_res), 1) + # Bounding box should span (0,0) to (15,10) + self.assertAlmostEqual(union_res[0][:, 0].min(), 0.0) + self.assertAlmostEqual(union_res[0][:, 0].max(), 15.0) + + def test_path_operations_min_coverage_and_holes(self): + from NodeSlicer.core.slice import SlicePaths + # Input 1 has two layers: + # Layer 1 (Z=1.0): Donut: Outer (0,0)-(20,20) and Hole (5,5)-(15,15) + outer1 = np.array([[0, 0, 1.0], [20, 0, 1.0], [20, 20, 1.0], [0, 20, 1.0], [0, 0, 1.0]]) + hole1 = np.array([[5, 5, 1.0], [15, 5, 1.0], [15, 15, 1.0], [5, 15, 1.0], [5, 5, 1.0]]) + # Layer 2 (Z=2.0): Solid square (0,0)-(20,20) + square2 = np.array([[0, 0, 2.0], [20, 0, 2.0], [20, 20, 2.0], [0, 20, 2.0], [0, 0, 2.0]]) + sp1 = SlicePaths([outer1, hole1, square2], groups=[0, 0, 1]) + + # Input 2 exists ONLY on Layer 1 (Z=1.0): Cutting box (-5,-5) to (10,25) + cutter = np.array([[-5, -5, 1.0], [10, -5, 1.0], [10, 25, 1.0], [-5, 25, 1.0], [-5, -5, 1.0]]) + sp2 = SlicePaths([cutter], groups=[0]) + + min_cov = SlicePaths.path_operations([sp1, sp2], operation='MIN_COVERAGE') + + # Layer 1 (Z=1.0): Both inputs active -> intersection of donut with left cutter box -> 1 C-bracket contour + # Layer 2 (Z=2.0): ONLY Input 1 active -> preserves Input 1 path completely! + self.assertEqual(len(min_cov), 2) + + layer1_paths = [p for p in min_cov if np.allclose(p[:, 2], 1.0)] + layer2_paths = [p for p in min_cov if np.allclose(p[:, 2], 2.0)] + + self.assertEqual(len(layer1_paths), 1) + self.assertEqual(len(layer2_paths), 1) + # Layer 2 is exactly preserved + self.assertTrue(np.allclose(layer2_paths[0], square2)) + # Layer 1 max X is 10.0 (clipped by cutter) + self.assertAlmostEqual(layer1_paths[0][:, 0].max(), 10.0) + + def test_path_operations_subtract_and_xor(self): + from NodeSlicer.core.slice import SlicePaths + # Square at Z=1.0: (0,0)-(20,20) + outer = np.array([[0, 0, 1.0], [20, 0, 1.0], [20, 20, 1.0], [0, 20, 1.0], [0, 0, 1.0]]) + # Hole cutter: (5,5)-(15,15) + hole_cutter = np.array([[5, 5, 1.0], [15, 5, 1.0], [15, 15, 1.0], [5, 15, 1.0], [5, 5, 1.0]]) + sp_outer = SlicePaths([outer], groups=[0]) + sp_cutter = SlicePaths([hole_cutter], groups=[0]) + + # Test Subtract (creates donut: outer + hole) + sub_res = SlicePaths.path_operations([sp_outer, sp_cutter], operation='SUBTRACT') + self.assertEqual(len(sub_res), 2) + + # Test XOR + xor_res = SlicePaths.path_operations([sp_outer, sp_cutter], operation='XOR') + self.assertEqual(len(xor_res), 2) + + # Test Intersection + inter_res = SlicePaths.path_operations([sp_outer, sp_cutter], operation='INTERSECTION') + self.assertEqual(len(inter_res), 1) + # Should match sp_cutter bounds (5 to 15) + self.assertAlmostEqual(inter_res[0][:, 0].min(), 5.0) + self.assertAlmostEqual(inter_res[0][:, 0].max(), 15.0) + + def test_path_operations_intersection_vs_min_coverage(self): + from NodeSlicer.core.slice import SlicePaths + # Input 1 has layer at Z=1.0 and layer at Z=2.0 + p1_l1 = np.array([[0, 0, 1.0], [20, 0, 1.0], [20, 20, 1.0], [0, 20, 1.0], [0, 0, 1.0]]) + p1_l2 = np.array([[0, 0, 2.0], [20, 0, 2.0], [20, 20, 2.0], [0, 20, 2.0], [0, 0, 2.0]]) + sp1 = SlicePaths([p1_l1, p1_l2], groups=[0, 1]) + + # Input 2 has ONLY layer at Z=1.0 (from X=10 to 30) + p2_l1 = np.array([[10, 0, 1.0], [30, 0, 1.0], [30, 20, 1.0], [10, 20, 1.0], [10, 0, 1.0]]) + sp2 = SlicePaths([p2_l1], groups=[0]) + + # Intersection: Z=1.0 intersects to [10, 20], but Z=2.0 has no counterpart in sp2 so produces nothing + inter_res = SlicePaths.path_operations([sp1, sp2], operation='INTERSECTION') + self.assertEqual(len(inter_res), 1) + self.assertAlmostEqual(inter_res[0][0, 2], 1.0) + self.assertAlmostEqual(inter_res[0][:, 0].min(), 10.0) + self.assertAlmostEqual(inter_res[0][:, 0].max(), 20.0) + + # Minimum coverage: Z=1.0 intersects to [10, 20], AND Z=2.0 is preserved because only 1 input is present + min_cov = SlicePaths.path_operations([sp1, sp2], operation='MIN_COVERAGE') + self.assertEqual(len(min_cov), 2) + zs = [p[0, 2] for p in min_cov] + self.assertIn(1.0, zs) + self.assertIn(2.0, zs) + + def test_path_operations_node_evaluation(self): + from NodeSlicer.core.slice import SlicePaths + from NodeSlicer.core.nodeslicer.nodes.path import SLICENODE_PathOperations + p1 = np.array([[0, 0, 1.0], [10, 0, 1.0], [10, 10, 1.0], [0, 10, 1.0], [0, 0, 1.0]]) + p2 = np.array([[5, 0, 1.0], [15, 0, 1.0], [15, 10, 1.0], [5, 10, 1.0], [5, 0, 1.0]]) + sp1 = SlicePaths([p1], groups=[0]) + sp2 = SlicePaths([p2], groups=[0]) + + node = SLICENODE_PathOperations() + mock_socket1 = MagicMock() + mock_socket2 = MagicMock() + node.inputs = [mock_socket1, mock_socket2] + + # Mock resolve_input to return sp1 and sp2 + with patch("NodeSlicer.infra.blender.blender_slice_nodes_generic.resolve_input", side_effect=[sp1, sp2] * 7): + node.operation = 'ZIP' + self.assertEqual(len(node.evaluate_output("Paths")), 2) + + node.operation = 'EXTEND' + self.assertEqual(len(node.evaluate_output("Paths")), 2) + + node.operation = 'MAX_COVERAGE' + self.assertEqual(len(node.evaluate_output("Paths")), 1) + + node.operation = 'MIN_COVERAGE' + self.assertEqual(len(node.evaluate_output("Paths")), 1) + + node.operation = 'INTERSECTION' + self.assertEqual(len(node.evaluate_output("Paths")), 1) + + node.operation = 'SUBTRACT' + self.assertEqual(len(node.evaluate_output("Paths")), 1) + + node.operation = 'XOR' + self.assertEqual(len(node.evaluate_output("Paths")), 2) + + def test_path_operations_single_input_min_coverage(self): + from NodeSlicer.core.slice import SlicePaths + # Single input with 1 path per layer (Z=1.0 and Z=2.0) + p1 = np.array([[0, 0, 1.0], [10, 0, 1.0], [10, 10, 1.0], [0, 10, 1.0], [0, 0, 1.0]]) + p2 = np.array([[5, 5, 2.0], [15, 5, 2.0], [15, 15, 2.0], [5, 15, 2.0], [5, 5, 2.0]]) + sp = SlicePaths([p1, p2], groups=[0, 1]) + + res = SlicePaths.path_operations([sp], operation='MIN_COVERAGE') + self.assertEqual(len(res), 2) + self.assertTrue(np.allclose(res[0], p1)) + self.assertTrue(np.allclose(res[1], p2)) + + # Single input with 2 overlapping paths on Z=1.0 and 1 path on Z=2.0 + p1_b = np.array([[5, 0, 1.0], [15, 0, 1.0], [15, 10, 1.0], [5, 10, 1.0], [5, 0, 1.0]]) + sp_multi = SlicePaths([p1, p1_b, p2], groups=[0, 0, 1]) + res_multi = SlicePaths.path_operations([sp_multi], operation='MIN_COVERAGE') + self.assertEqual(len(res_multi), 2) + # Z=1.0 should be intersection [5, 10] in X + l1 = [p for p in res_multi if np.allclose(p[:, 2], 1.0)][0] + self.assertAlmostEqual(l1[:, 0].min(), 5.0) + self.assertAlmostEqual(l1[:, 0].max(), 10.0) + # Z=2.0 should be preserved intact + l2 = [p for p in res_multi if np.allclose(p[:, 2], 2.0)][0] + self.assertTrue(np.allclose(l2, p2)) + + def test_path_operations_single_input_max_coverage(self): + from NodeSlicer.core.slice import SlicePaths + # Single input with 2 overlapping squares on Z=1.0 + p1 = np.array([[0, 0, 1.0], [10, 0, 1.0], [10, 10, 1.0], [0, 10, 1.0], [0, 0, 1.0]]) + p2 = np.array([[5, 0, 1.0], [15, 0, 1.0], [15, 10, 1.0], [5, 10, 1.0], [5, 0, 1.0]]) + sp = SlicePaths([p1, p2], groups=[0, 0]) + + res = SlicePaths.path_operations([sp], operation='MAX_COVERAGE') + self.assertEqual(len(res), 1) + self.assertAlmostEqual(res[0][:, 0].min(), 0.0) + self.assertAlmostEqual(res[0][:, 0].max(), 15.0) + + def test_path_operations_single_input_donut_hole(self): + from NodeSlicer.core.slice import SlicePaths + # Donut: Outer CCW (0,0)-(20,20) and Hole CW (5,5)-(15,15) + outer = np.array([[0, 0, 1.0], [20, 0, 1.0], [20, 20, 1.0], [0, 20, 1.0], [0, 0, 1.0]]) + hole = np.array([[5, 5, 1.0], [5, 15, 1.0], [15, 15, 1.0], [15, 5, 1.0], [5, 5, 1.0]]) + sp = SlicePaths([outer, hole], groups=[0, 0]) + + # Min coverage on single input with hole preserves both outer and hole + min_res = SlicePaths.path_operations([sp], operation='MIN_COVERAGE') + self.assertEqual(len(min_res), 2) + + # Max coverage on single input with hole preserves both outer and hole + max_res = SlicePaths.path_operations([sp], operation='MAX_COVERAGE') + self.assertEqual(len(max_res), 2) + + def test_path_operations_edge_cases(self): + from NodeSlicer.core.slice import SlicePaths + # Empty inputs list + self.assertEqual(len(SlicePaths.path_operations([], operation='MIN_COVERAGE')), 0) + self.assertEqual(len(SlicePaths.path_operations([], operation='MAX_COVERAGE')), 0) + self.assertEqual(len(SlicePaths.path_operations([], operation='INTERSECTION')), 0) + self.assertEqual(len(SlicePaths.path_operations([], operation='SUBTRACT')), 0) + self.assertEqual(len(SlicePaths.path_operations([], operation='XOR')), 0) + self.assertEqual(len(SlicePaths.path_operations([], operation='ZIP')), 0) + self.assertEqual(len(SlicePaths.path_operations([], operation='EXTEND')), 0) + + # List of empty SlicePaths + empty_sp = SlicePaths() + self.assertEqual(len(SlicePaths.path_operations([empty_sp], operation='MIN_COVERAGE')), 0) + self.assertEqual(len(SlicePaths.path_operations([empty_sp, empty_sp], operation='MAX_COVERAGE')), 0) + self.assertEqual(len(SlicePaths.path_operations([empty_sp, empty_sp], operation='INTERSECTION')), 0) + self.assertEqual(len(SlicePaths.path_operations([empty_sp, empty_sp], operation='SUBTRACT')), 0) + self.assertEqual(len(SlicePaths.path_operations([empty_sp, empty_sp], operation='XOR')), 0) + + # Non-empty with empty + p1 = np.array([[0, 0, 1.0], [10, 0, 1.0], [10, 10, 1.0], [0, 10, 1.0], [0, 0, 1.0]]) + sp1 = SlicePaths([p1], groups=[0]) + + # Subtract empty from sp1 should return sp1 + sub_res = SlicePaths.path_operations([sp1, empty_sp], operation='SUBTRACT') + self.assertEqual(len(sub_res), 1) + + # Subtract sp1 from empty should return empty + sub_res_empty = SlicePaths.path_operations([empty_sp, sp1], operation='SUBTRACT') + self.assertEqual(len(sub_res_empty), 0) + + # Intersection with empty should return empty + inter_res = SlicePaths.path_operations([sp1, empty_sp], operation='INTERSECTION') + self.assertEqual(len(inter_res), 0) + + # Direct classmethods on SlicePaths + self.assertEqual(len(SlicePaths.minimum_coverage([sp1])), 1) + self.assertEqual(len(SlicePaths.maximum_coverage([sp1])), 1) + self.assertEqual(len(SlicePaths.intersection([sp1])), 1) + self.assertEqual(len(SlicePaths.subtract([sp1])), 1) + self.assertEqual(len(SlicePaths.xor([sp1])), 1) + + def test_intersection_with_holes_open_and_closed(self): + from NodeSlicer.core.slice import SlicePaths, boolean_slice + + # Donut: Outer (0,0)-(20,20) and Hole (5,5)-(15,15) + outer = np.array([[0, 0, 1.0], [20, 0, 1.0], [20, 20, 1.0], [0, 20, 1.0], [0, 0, 1.0]]) + hole = np.array([[5, 5, 1.0], [5, 15, 1.0], [15, 15, 1.0], [15, 5, 1.0], [5, 5, 1.0]]) + donut = SlicePaths([outer, hole], groups=[0, 0]) + + # Test 1: Open line crossing donut + line = np.array([[-5, 10, 1.0], [25, 10, 1.0]]) + line_sp = SlicePaths([line], groups=[0]) + + # Line as subject, donut as object + res_line_sub = boolean_slice(line_sp, donut, operation='INTERSECTION', connect_along_boundary=False) + self.assertEqual(len(res_line_sub), 2) + # Segments should be X: [0, 5] and [15, 20] + x_spans = sorted([(p[:, 0].min(), p[:, 0].max()) for p in res_line_sub], key=lambda s: s[0]) + self.assertAlmostEqual(x_spans[0][0], 0.0, places=2) + self.assertAlmostEqual(x_spans[0][1], 5.0, places=2) + self.assertAlmostEqual(x_spans[1][0], 15.0, places=2) + self.assertAlmostEqual(x_spans[1][1], 20.0, places=2) + + # Donut as subject, line as object (intersection is commutative for geometry) + res_donut_sub = boolean_slice(donut, line_sp, operation='INTERSECTION', connect_along_boundary=False) + self.assertEqual(len(res_donut_sub), 2) + x_spans2 = sorted([(p[:, 0].min(), p[:, 0].max()) for p in res_donut_sub], key=lambda s: s[0]) + self.assertAlmostEqual(x_spans2[0][0], 0.0, places=2) + self.assertAlmostEqual(x_spans2[0][1], 5.0, places=2) + self.assertAlmostEqual(x_spans2[1][0], 15.0, places=2) + self.assertAlmostEqual(x_spans2[1][1], 20.0, places=2) + + # Test 2: Closed square intersecting donut (cutting it into a C-shape) + half_square = np.array([[0, 0, 1.0], [10, 0, 1.0], [10, 20, 1.0], [0, 20, 1.0], [0, 0, 1.0]]) + half_sp = SlicePaths([half_square], groups=[0]) + res_c_shape = boolean_slice(donut, half_sp, operation='INTERSECTION') + self.assertEqual(len(res_c_shape), 1) + + # Test 3: Enclosing square intersecting donut (preserving the donut and hole) + enclosing = np.array([[-5, -5, 1.0], [25, -5, 1.0], [25, 25, 1.0], [-5, 25, 1.0], [-5, -5, 1.0]]) + enclosing_sp = SlicePaths([enclosing], groups=[0]) + res_donut_kept = boolean_slice(donut, enclosing_sp, operation='INTERSECTION') + self.assertEqual(len(res_donut_kept), 2) + + # Test 4: Path Operations INTERSECTION with donut + res_path_op = SlicePaths.path_operations([donut, enclosing_sp], operation='INTERSECTION') + self.assertEqual(len(res_path_op), 2) + + # Test 5: Open line with connect_along_boundary=True + res_connected = boolean_slice(line_sp, donut, operation='INTERSECTION', connect_along_boundary=True) + self.assertEqual(len(res_connected), 1) + + def test_metadata_and_transforms_preservation(self): + from NodeSlicer.core.slice import SlicePaths + + p1 = np.array([[0, 0, 1.0], [10, 0, 1.0], [10, 10, 1.0], [0, 10, 1.0], [0, 0, 1.0]]) + sp1 = SlicePaths([p1], groups=[0], name="ObjectA") + sp1.transforms = [{"type": "initial", "val": 1}] + + # Rotate + rotated = sp1.rotate(90.0, center=np.array([5.0, 5.0, 1.0])) + self.assertEqual(len(rotated.transforms), 2) + self.assertEqual(rotated.transforms[1]["type"], "rotate") + self.assertEqual(rotated.transforms[1]["angle_deg"], 90.0) + self.assertEqual(rotated.name, "ObjectA") + + # Offset + offset_sp = rotated.offset(np.array([10.0, 0.0, 0.0])) + self.assertEqual(len(offset_sp.transforms), 3) + self.assertEqual(offset_sp.transforms[2]["type"], "offset") + self.assertEqual(offset_sp.transforms[2]["offset"], [10.0, 0.0, 0.0]) + + # Path operations (Intersection / Union / Zip / Extend) merging metadata + p2 = np.array([[5, 0, 1.0], [15, 0, 1.0], [15, 10, 1.0], [5, 10, 1.0], [5, 0, 1.0]]) + sp2 = SlicePaths([p2], groups=[0], name="ObjectB") + sp2.transforms = [{"type": "scale", "factor": 2.0}] + + merged_zip = SlicePaths.zip_paths([sp1, sp2]) + self.assertIn("ObjectA", merged_zip.name) + self.assertIn("ObjectB", merged_zip.name) + self.assertEqual(len(merged_zip.transforms), 2) + + merged_inter = SlicePaths.path_operations([sp1, sp2], operation='INTERSECTION') + self.assertIn("ObjectA", merged_inter.name) + self.assertIn("ObjectB", merged_inter.name) + self.assertEqual(len(merged_inter.transforms), 2) + + def test_complex_donut_intersection_and_bullseye(self): + from NodeSlicer.core.slice import SlicePaths, _group_closed_paths_into_shapes, boolean_slice + + # Bullseye target: Outer (0,0)-(30,30), Hole (5,5)-(25,25), Island (10,10)-(20,20) + outer = np.array([[0, 0, 1.0], [30, 0, 1.0], [30, 30, 1.0], [0, 30, 1.0], [0, 0, 1.0]]) + hole = np.array([[5, 5, 1.0], [5, 25, 1.0], [25, 25, 1.0], [25, 5, 1.0], [5, 5, 1.0]]) + island = np.array([[10, 10, 1.0], [20, 10, 1.0], [20, 20, 1.0], [10, 20, 1.0], [10, 10, 1.0]]) + + shapes = _group_closed_paths_into_shapes([outer, hole, island]) + self.assertEqual(len(shapes), 2) + # Shape 1 is [outer, hole], Shape 2 is [island] + shapes_sorted = sorted(shapes, key=lambda s: len(s), reverse=True) + self.assertEqual(len(shapes_sorted[0]), 2) # Outer + Hole + self.assertEqual(len(shapes_sorted[1]), 1) # Island + + # Intersect bullseye with a vertical slice (0..15 in X) + cutter = np.array([[0, -5, 1.0], [15, -5, 1.0], [15, 35, 1.0], [0, 35, 1.0], [0, -5, 1.0]]) + bullseye_sp = SlicePaths([outer, hole, island], groups=[0, 0, 0]) + cutter_sp = SlicePaths([cutter], groups=[0]) + + inter_res = boolean_slice(bullseye_sp, cutter_sp, operation='INTERSECTION') + self.assertGreater(len(inter_res), 0) + # Verify all resulting points have X <= 15.001 + for p in inter_res: + self.assertLessEqual(p[:, 0].max(), 15.001) + + def test_full_metadata_lifecycle(self): + from NodeSlicer.core.slice import SlicePaths + + p = np.array([[0, 0, 1.0], [10, 0, 1.0], [10, 10, 1.0], [0, 10, 1.0], [0, 0, 1.0]]) + sp = SlicePaths( + [p], + groups=[0], + extrusion_rate=[np.array([1.5, 1.5, 1.5, 1.5, 1.5])], + feedrate=[np.array([1200.0, 1200.0, 1200.0, 1200.0, 1200.0])], + extra_attributes={"fan": [np.array([100.0, 100.0, 100.0, 100.0, 100.0])]}, + layer_height=0.25, + first_layer_height=0.3, + name="Part1", + objects_info={"objects": [{"name": "Part1", "polygon": []}]} + ) + + # Rotate and check metadata + rotated = sp.rotate(45.0) + self.assertEqual(rotated.layer_height, 0.25) + self.assertEqual(rotated.first_layer_height, 0.3) + self.assertEqual(rotated.name, "Part1") + self.assertEqual(len(rotated.transforms), 1) + self.assertEqual(rotated.transforms[0]["type"], "rotate") + self.assertAlmostEqual(rotated.transforms[0]["angle_deg"], 45.0) + + # Buffer and check metadata + buffered = sp.buffer(1.0) + self.assertEqual(buffered.layer_height, 0.25) + self.assertEqual(buffered.first_layer_height, 0.3) + self.assertEqual(buffered.name, "Part1") + + # Infill and check metadata + infilled = sp.infill(10.0, infill_type='RECTILINEAR') + self.assertEqual(infilled.layer_height, 0.25) + self.assertEqual(infilled.first_layer_height, 0.3) + self.assertEqual(infilled.name, "Part1") + + +class TestCodeReviewBugFixes(unittest.TestCase): + def test_ndarray_lru_cache_empty_list_and_dict(self): + from NodeSlicer.core.slice import buffer_slice + # buffer_slice is decorated with @ndarray_lru_cache + res_paths, res_groups = buffer_slice([], 1.0) + self.assertEqual(res_paths, []) + self.assertEqual(res_groups, []) + + def test_pather_kwargs(self): + from NodeSlicer.core.slice import Pather + path = np.array([[0, 0, 0], [10, 0, 0]], dtype=np.float64) + pather = Pather([path], feedrate=1500.0, retraction=1.2, bed_center_x=120.0) + self.assertEqual(pather.feedrate, 1500.0) + self.assertEqual(pather.retraction, 1.2) + self.assertEqual(pather.bed_center_x, 120.0) + + def test_join_mesh_immutability(self): + from NodeSlicer.core.nodeslicer.nodes.mesh import SLICENODE_JoinMesh + from NodeSlicer.infra.blender.blender_mesh_capture import SlicingCollection, SlicingObject + + so1 = SlicingObject() + so1.name = "obj1" + so2 = SlicingObject() + so2.name = "obj2" + + coll1 = SlicingCollection([], "coll1") + coll1.objects = [so1] + coll2 = SlicingCollection([], "coll2") + coll2.objects = [so2] + + node = SLICENODE_JoinMesh() + node.inputs = [MagicMock(), MagicMock()] + + with patch("NodeSlicer.infra.blender.blender_slice_nodes_generic.resolve_input", side_effect=[coll1, coll2]): + res = node.evaluate_output("Mesh") + self.assertEqual(len(res.objects), 2) + # Ensure coll1.objects was NOT mutated + self.assertEqual(len(coll1.objects), 1) + + def test_join_mesh_groups_empty_collection(self): + from NodeSlicer.core.nodeslicer.nodes.mesh import SLICENODE_JoinMeshGroups + from NodeSlicer.infra.blender.blender_mesh_capture import SlicingCollection + + coll_empty = SlicingCollection([], "empty_coll") + node = SLICENODE_JoinMeshGroups() + node.inputs = [MagicMock()] + + with patch("NodeSlicer.infra.blender.blender_slice_nodes_generic.resolve_input", return_value=coll_empty): + group = node.evaluate_output("Mesh") + self.assertIn("empty_coll", group.collections) + + def test_slice_shells_empty_mesh(self): + from NodeSlicer.core.nodeslicer.nodes.path import SLICENODE_SliceShells + from NodeSlicer.infra.blender.blender_mesh_capture import SlicingCollection + + empty_coll = SlicingCollection([], "empty") + node = SLICENODE_SliceShells() + node.inputs = [MagicMock(), MagicMock()] + + with patch.object(node, "resolve", side_effect=lambda name: empty_coll if name == "Mesh" else 0.2): + res = node.evaluate_output("Paths") + self.assertEqual(len(res), 0) + if __name__ == '__main__': unittest.main() +