clipping
This commit is contained in:
parent
f20e11fff6
commit
3c186aae9f
2 changed files with 227 additions and 41 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
256
core/slice.py
256
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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue