This commit is contained in:
Nicolas Predella 2026-06-10 16:49:27 +03:00
parent f20e11fff6
commit 3c186aae9f
2 changed files with 227 additions and 41 deletions

View file

@ -2,7 +2,7 @@ from __future__ import annotations
from typing import Any from typing import Any
import numpy as np 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 ....registry import register_class
from .base import SliceNode, SlicePaths, SlicingObject, SlicingCollection, slice_at_range, SliceNodeJoiner from .base import SliceNode, SlicePaths, SlicingObject, SlicingCollection, slice_at_range, SliceNodeJoiner
@ -248,9 +248,17 @@ class SLICENODE_JoinSlices(SliceNodeJoiner):
default='UNION' default='UNION'
) # pyright: ignore[reportInvalidTypeForm] ) # 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): def draw_buttons(self, context, layout):
assert layout assert layout
layout.prop(self, "operation", text="") 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: def evaluate_output(self, socket_name: str, **_kwargs) -> SlicePaths:
from ....infra.blender.blender_slice_nodes_generic import resolve_input 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. # Boolean operation loses specific per-segment grouping as it merges polygons.
# We return a new SlicePaths from the result. # 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 pass

View file

@ -102,8 +102,8 @@ class SlicePaths(list[np.ndarray]):
return new_paths return new_paths
def boolean(self, other: SlicePaths, operation: str = 'UNION') -> SlicePaths: def boolean(self, other: SlicePaths, operation: str = 'UNION', connect_along_boundary: bool = False) -> SlicePaths:
return boolean_slice(self, other, operation) return boolean_slice(self, other, operation, connect_along_boundary=connect_along_boundary)
def extend_with_groups(self, other: SlicePaths | list[np.ndarray]): def extend_with_groups(self, other: SlicePaths | list[np.ndarray]):
if isinstance(other, SlicePaths): if isinstance(other, SlicePaths):
@ -311,8 +311,78 @@ def segments_to_slice(segments: np.ndarray) -> list[np.ndarray]:
res.append(fpath_3d) res.append(fpath_3d)
return res 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) @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() all_z_keys: set[int] = set()
subject_dict: dict[int, list[np.ndarray]] = {} subject_dict: dict[int, list[np.ndarray]] = {}
object_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 group = 0
for k in sorted(all_z_keys): for k in sorted(all_z_keys):
subject_slices = subject_dict.get(k) subject_slices = subject_dict.get(k, [])
object_slices = object_dict.get(k) object_slices = object_dict.get(k, [])
if subject_slices is None: if not subject_slices:
if object_slices and operation == 'UNION': 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 continue
if object_slices is None: if not object_slices:
if operation != 'INTERSECTION': if operation != 'INTERSECTION':
for s in subject_slices: results.append(s) results.extend(subject_slices)
results.groups.extend([group] * len(subject_slices))
continue continue
z = subject_slices[0][0, 2] z = subject_slices[0][0, 2]
pc = pyclipper.Pyclipper() # pyright: ignore[reportAttributeAccessIssue] pc = pyclipper.Pyclipper() # pyright: ignore[reportAttributeAccessIssue]
# Subject # Function to process and add paths to Clipper
for s in subject_slices: def add_paths_to_clipper(paths, role):
path_pts = s[:, :2] for s in paths:
scaled = (path_pts * SCALE).astype(np.int64).tolist() path_pts = s[:, :2]
pc.AddPath(scaled, pyclipper.PT_SUBJECT, True) # pyright: ignore[reportAttributeAccessIssue] 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 layer_results = []
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]
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: elif operation == 'DIFFERENCE':
if not path: obj_closed_pts = [s[:, :2] for s in object_slices if len(s) > 2 and np.allclose(s[0], s[-1])]
continue obj_closed_scaled = [(pts[:-1] * SCALE).astype(np.int64).tolist() for pts in obj_closed_pts]
fpath = np.array(path) / SCALE process_op(subject_slices, obj_closed_scaled, obj_closed_pts, pyclipper.CT_DIFFERENCE)
fpath_3d = np.column_stack([fpath, np.full(len(fpath), z)]) # DIFFERENCE is not symmetric for open paths in the same way, usually A - B means A is the main focus.
fpath_3d = np.vstack([fpath_3d, fpath_3d[0]])
results.append(fpath_3d) else:
# UNION, XOR
results.groups += [group] * len(solution) add_paths_to_clipper(subject_slices, pyclipper.PT_SUBJECT)
group +=1 obj_closed = []
obj_open = []
pass 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 return results