diff --git a/core/nodeslicer/nodes/path.py b/core/nodeslicer/nodes/path.py index ddd0a53..0705ced 100644 --- a/core/nodeslicer/nodes/path.py +++ b/core/nodeslicer/nodes/path.py @@ -2,7 +2,7 @@ from __future__ import annotations from typing import Any import numpy as np -from ..bpy_ns_types import NfEnumProperty, notify_tree_changed +from ..bpy_ns_types import NfEnumProperty, NfBoolProperty, notify_tree_changed from ....registry import register_class from .base import SliceNode, SlicePaths, SlicingObject, SlicingCollection, slice_at_range, SliceNodeJoiner @@ -248,9 +248,17 @@ class SLICENODE_JoinSlices(SliceNodeJoiner): default='UNION' ) # pyright: ignore[reportInvalidTypeForm] + connect_along_boundary: NfBoolProperty( + name="Connect along boundary", + description="Connect clipped segments along the boundary of the clipping polygon", + default=False + ) # pyright: ignore[reportInvalidTypeForm] + def draw_buttons(self, context, layout): assert layout layout.prop(self, "operation", text="") + if self.operation in ['INTERSECTION', 'DIFFERENCE']: + 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 @@ -268,7 +276,7 @@ class SLICENODE_JoinSlices(SliceNodeJoiner): # Boolean operation loses specific per-segment grouping as it merges polygons. # We return a new SlicePaths from the result. - accumulated = accumulated.boolean(current, self.operation) + accumulated = accumulated.boolean(current, self.operation, connect_along_boundary=self.connect_along_boundary) pass diff --git a/core/slice.py b/core/slice.py index e2e6f4a..23a4ab2 100644 --- a/core/slice.py +++ b/core/slice.py @@ -102,8 +102,8 @@ class SlicePaths(list[np.ndarray]): return new_paths - def boolean(self, other: SlicePaths, operation: str = 'UNION') -> SlicePaths: - return boolean_slice(self, other, operation) + 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) def extend_with_groups(self, other: SlicePaths | list[np.ndarray]): if isinstance(other, SlicePaths): @@ -311,8 +311,78 @@ def segments_to_slice(segments: np.ndarray) -> list[np.ndarray]: res.append(fpath_3d) 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: + # Pyclipper paths are usually not closed in the array (last != first) if ET_CLOSEDPOLYGON + # but NodeSlicer usually keeps them closed. Ensure it's closed for logic. + working_loop = loop + if not np.allclose(loop[0], loop[-1]): + 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 + @ndarray_lru_cache(maxsize=128) -def boolean_slice(subject: SlicePaths, object: SlicePaths, operation: str = 'UNION') -> SlicePaths: +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]] = {} @@ -331,56 +401,164 @@ def boolean_slice(subject: SlicePaths, object: SlicePaths, operation: str = 'UNI group = 0 for k in sorted(all_z_keys): - subject_slices = subject_dict.get(k) - object_slices = object_dict.get(k) + subject_slices = subject_dict.get(k, []) + object_slices = object_dict.get(k, []) - if subject_slices is None: + if not subject_slices: if object_slices and operation == 'UNION': - for s in object_slices: results.append(s) + results.extend(object_slices) + results.groups.extend([group] * len(object_slices)) continue - if object_slices is None: + if not object_slices: if operation != 'INTERSECTION': - for s in subject_slices: results.append(s) + results.extend(subject_slices) + results.groups.extend([group] * len(subject_slices)) continue z = subject_slices[0][0, 2] pc = pyclipper.Pyclipper() # pyright: ignore[reportAttributeAccessIssue] - # Subject - for s in subject_slices: - path_pts = s[:, :2] - scaled = (path_pts * SCALE).astype(np.int64).tolist() - pc.AddPath(scaled, pyclipper.PT_SUBJECT, True) # 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 = len(path_pts) > 2 and np.allclose(path_pts[0], path_pts[-1]) + if is_closed: + path_pts = path_pts[:-1] + scaled = (path_pts * SCALE).astype(np.int64).tolist() + pc.AddPath(scaled, role, is_closed) # pyright: ignore[reportAttributeAccessIssue] - # Object - for s in object_slices: - path_pts = s[:, :2] - scaled = (path_pts * SCALE).astype(np.int64).tolist() - pc.AddPath(scaled, pyclipper.PT_CLIP, True) # pyright: ignore[reportAttributeAccessIssue] - - # Determine operation type - op_type = pyclipper.CT_UNION # pyright: ignore[reportAttributeAccessIssue] - if operation == 'UNION': op_type = pyclipper.CT_UNION # pyright: ignore[reportAttributeAccessIssue] - elif operation == 'INTERSECTION': op_type = pyclipper.CT_INTERSECTION # pyright: ignore[reportAttributeAccessIssue] - elif operation == 'DIFFERENCE': op_type = pyclipper.CT_DIFFERENCE # pyright: ignore[reportAttributeAccessIssue] - elif operation == 'XOR': op_type = pyclipper.CT_XOR # pyright: ignore[reportAttributeAccessIssue] + layer_results = [] - solution = pc.Execute(op_type, pyclipper.PFT_EVENODD, pyclipper.PFT_EVENODD) # pyright: ignore[reportAttributeAccessIssue] + def process_op(subs, clips_scaled, clips_pts, op_type): + for s in subs: + path_pts = s[:, :2] + is_closed = len(path_pts) > 2 and np.allclose(path_pts[0], path_pts[-1]) + + if not connect_along_boundary or is_closed: + pc.Clear() + if is_closed: path_pts = path_pts[:-1] + pc.AddPath((path_pts * SCALE).astype(np.int64).tolist(), pyclipper.PT_SUBJECT, is_closed) + 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.ClosedPathsFromPolyTree(solution): + fpath = np.array(path) / 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): + fpath = np.array(path) / SCALE + layer_results.append(np.column_stack([fpath, np.full(len(fpath), z)])) + else: + pc.Clear() + pc.AddPath((path_pts * SCALE).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) / 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 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 len(s) > 2 and np.allclose(s[0], s[-1])] + obj_closed_scaled = [(pts[:-1] * SCALE).astype(np.int64).tolist() for pts in obj_closed_pts] + process_op(subject_slices, obj_closed_scaled, obj_closed_pts, pyclipper.CT_INTERSECTION) + + # Pass 2 for B_open ∩ A_closed + b_open = [s for s in object_slices if not (len(s) > 2 and np.allclose(s[0], s[-1]))] + if b_open: + sub_closed_pts = [s[:, :2] for s in subject_slices if len(s) > 2 and np.allclose(s[0], s[-1])] + sub_closed_scaled = [(pts[:-1] * SCALE).astype(np.int64).tolist() for pts in sub_closed_pts] + process_op(b_open, sub_closed_scaled, sub_closed_pts, pyclipper.CT_INTERSECTION) - for path in solution: - if not path: - continue - fpath = np.array(path) / SCALE - fpath_3d = np.column_stack([fpath, np.full(len(fpath), z)]) - fpath_3d = np.vstack([fpath_3d, fpath_3d[0]]) - results.append(fpath_3d) - - results.groups += [group] * len(solution) - group +=1 - - pass + elif operation == 'DIFFERENCE': + obj_closed_pts = [s[:, :2] for s in object_slices if len(s) > 2 and np.allclose(s[0], s[-1])] + obj_closed_scaled = [(pts[:-1] * SCALE).astype(np.int64).tolist() for pts in obj_closed_pts] + process_op(subject_slices, obj_closed_scaled, obj_closed_pts, pyclipper.CT_DIFFERENCE) + # DIFFERENCE is not symmetric for open paths in the same way, usually A - B means A is the main focus. + + else: + # UNION, XOR + add_paths_to_clipper(subject_slices, pyclipper.PT_SUBJECT) + obj_closed = [] + obj_open = [] + for s in object_slices: + if len(s) > 2 and np.allclose(s[0], s[-1]): obj_closed.append(s) + else: obj_open.append(s) + + if operation in ['UNION', 'XOR']: + add_paths_to_clipper(obj_open, pyclipper.PT_SUBJECT) + + for s in obj_closed: + path_pts = s[:, :2] + scaled = (path_pts[:-1] * SCALE).astype(np.int64).tolist() + pc.AddPath(scaled, pyclipper.PT_CLIP, True) + + op_type = pyclipper.CT_UNION + if operation == 'UNION': op_type = pyclipper.CT_UNION + elif operation == 'XOR': op_type = pyclipper.CT_XOR + + solution = pc.Execute2(op_type, pyclipper.PFT_EVENODD, pyclipper.PFT_EVENODD) + for path in pyclipper.ClosedPathsFromPolyTree(solution): + fpath = np.array(path) / 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): + fpath = np.array(path) / 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 return results