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
|
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
|
||||||
|
|
||||||
|
|
|
||||||
256
core/slice.py
256
core/slice.py
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue