This commit is contained in:
Nicolas Predella 2026-09-27 16:08:38 +03:00
parent 5ed9836f21
commit 978dfcbe1d
11 changed files with 1253 additions and 232 deletions

View file

@ -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.

View file

@ -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:

View file

@ -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

View file

@ -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

View file

@ -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"

View file

@ -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:

View file

@ -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.

View file

@ -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.

View file

@ -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]

View file

@ -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():

View file

@ -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()