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**: 4. **Process**:
* Add perimeters with **Buffer Shells**. * Add perimeters with **Buffer Shells**.
* Add internal structure with the **Infill** node. * 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**. 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. 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 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 ndarray_lru_cache(maxsize: int = 128) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
def decorator(func: Callable[..., Any]) -> Callable[..., Any]: def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
cache: OrderedDict[tuple[Any, ...], Any] = OrderedDict() cache: OrderedDict[tuple[Any, ...], Any] = OrderedDict()
def wrapper(*args, **kwargs): def wrapper(*args, **kwargs):
key_parts = [] key_parts = [_make_hashable(arg) for arg in args]
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)
for k, v in sorted(kwargs.items()): for k, v in sorted(kwargs.items()):
if isinstance(v, np.ndarray): key_parts.append((k, _make_hashable(v)))
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 = tuple(key_parts) key = tuple(key_parts)
if key in cache: if key in cache:

View file

@ -41,12 +41,19 @@ class SLICENODE_JoinMesh(SliceNodeJoiner):
def evaluate_output(self, socket_name: str, **_kwargs) -> SlicingCollection: def evaluate_output(self, socket_name: str, **_kwargs) -> SlicingCollection:
from ....infra.blender.blender_slice_nodes_generic import resolve_input 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:]: for input in self.inputs[1:]:
coll = resolve_input(input) coll = resolve_input(input)
if coll: 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 return merged_coll
@ -63,10 +70,15 @@ class SLICENODE_JoinMeshGroups(SliceNodeJoiner):
from ....infra.blender.blender_slice_nodes_generic import resolve_input from ....infra.blender.blender_slice_nodes_generic import resolve_input
group: SlicingGroup = SlicingGroup([]) group: SlicingGroup = SlicingGroup([])
for input in self.inputs: for idx, input_socket in enumerate(self.inputs):
coll = resolve_input(input) coll = resolve_input(input_socket)
if coll: if coll:
group.collections[coll.objects[0].name] = 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 return group

View file

@ -64,19 +64,18 @@ class SLICENODE_SliceShells(SliceNode):
def evaluate_output(self, socket_name: str, **_kwargs) -> SlicePaths: def evaluate_output(self, socket_name: str, **_kwargs) -> SlicePaths:
if socket_name != "Paths": return SlicePaths() if socket_name != "Paths": return SlicePaths()
coll: SlicingCollection | SlicingGroup | None = self.resolve("Mesh") coll: Any = self.resolve("Mesh")
layer_height: float = self.resolve("Layer Height") or 0.2 layer_height: float = float(self.resolve("Layer Height") or 0.2)
if layer_height <= 0:
layer_height = 0.2
fl_height: float | None = None fl_height: float | None = None
if hasattr(self, "inputs") and "First Layer Height" in self.inputs: try:
try: fl_val = self.resolve("First Layer Height")
fl_height = self.resolve("First Layer Height") if fl_val is not None:
except Exception: fl_height = float(fl_val)
fl_height = None except Exception:
else: fl_height = None
try:
fl_height = self.resolve("First Layer Height")
except Exception:
fl_height = None
if fl_height is not None and fl_height <= 0: if fl_height is not None and fl_height <= 0:
fl_height = None fl_height = None
@ -93,15 +92,31 @@ class SLICENODE_SliceShells(SliceNode):
if not coll: return SlicePaths() if not coll: return SlicePaths()
# Handle SlicingGroup flattening # Handle SlicingGroup, SlicingCollection, SlicingObject, or list
if isinstance(coll, SlicingGroup): if isinstance(coll, SlicingGroup):
objects = [] objects = []
for c in coll.collections.values(): for c in coll.collections.values():
objects.extend(c.objects) 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: else:
objects = coll.objects objects = getattr(coll, "objects", [coll] if hasattr(coll, "mesh") else [])
merged_object = SlicingObject.merge(objects) merged_object = SlicingObject.merge(objects)
if merged_object.mesh is None or merged_object.mesh.size == 0:
return SlicePaths()
if rotation and any(rotation): if rotation and any(rotation):
from mathutils import Euler from mathutils import Euler
@ -110,8 +125,8 @@ class SLICENODE_SliceShells(SliceNode):
merged_object.mesh[:, 3:4, :] = merged_object.mesh[:, 3:4, :] @ mat.T merged_object.mesh[:, 3:4, :] = merged_object.mesh[:, 3:4, :] @ mat.T
min_z = float(merged_object.mesh[:, 0:3, 2:3].min()) min_z = float(merged_object.mesh[:, 0:3, 2:3].min())
from math import floor if abs(min_z) < 1e-6:
min_z = floor(min_z / layer_height) * layer_height min_z = 0.0
max_z = float(merged_object.mesh[:, 0:3, 2:3].max()) max_z = float(merged_object.mesh[:, 0:3, 2:3].max())
h_first = fl_height if fl_height is not None else layer_height 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_first_um = min_z_um + flh_um
z_rest_start_um = z_first_um + lh_um z_rest_start_um = z_first_um + lh_um
if z_rest_start_um < max_z_um and lh_um > 0: if z_first_um <= max_z_um:
z_um_values = list(range(z_rest_start_um, max_z_um, lh_um)) if z_rest_start_um <= max_z_um and lh_um > 0:
z_values = np.array([z_first_um] + z_um_values, dtype=np.float64) / 1000.0 z_um_values = list(range(z_rest_start_um, max_z_um + 1, lh_um))
elif z_first_um < max_z_um: z_values = np.array([z_first_um] + z_um_values, dtype=np.float64) / 1000.0
z_values = np.array([z_first_um], dtype=np.float64) / 1000.0 else:
z_values = np.array([z_first_um], dtype=np.float64) / 1000.0
else: else:
z_values = np.array([]) z_values = np.array([], dtype=np.float64)
slices_group: list[list[np.ndarray]] | None = slice_at_range( slices_group: list[list[np.ndarray]] | None = slice_at_range(
[merged_object.mesh], [merged_object.mesh],
z_values, z_values,
ignore_holes=ignore_holes ignore_holes=ignore_holes
) )
assert slices_group if not slices_group or not slices_group[0]:
slices = SlicePaths(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 slices.layer_height = layer_height
if fl_height is not None: if fl_height is not None:
slices.first_layer_height = fl_height slices.first_layer_height = fl_height
@ -427,26 +458,6 @@ class SLICENODE_Infill(SliceNode):
layer_rotation=rotation 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 @register_class
class SLICENODE_OverhangMask(SliceNode): class SLICENODE_OverhangMask(SliceNode):
bl_label = "Overhang Mask" bl_label = "Overhang Mask"
@ -470,19 +481,23 @@ class SLICENODE_OverhangMask(SliceNode):
return paths.get_overhang_mask(tolerance=tolerance) return paths.get_overhang_mask(tolerance=tolerance)
@register_class @register_class
class SLICENODE_JoinSlices(SliceNodeJoiner): class SLICENODE_PathOperations(SliceNodeJoiner):
bl_label = "Boolean Paths" bl_label = "Path Operations"
socket_type = "SLICESOCK_Paths" socket_type = "SLICESOCK_Paths"
operation: str = NfEnumProperty( operation: str = NfEnumProperty(
name="Operation", name="Operation",
items=[ items=[
('UNION', "Union", "Union of the slices"), ('ZIP', "Zip paths", "Interleave paths from all inputs layer by layer"),
('INTERSECTION', "Intersection", "Intersection of the slices"), ('EXTEND', "Extend paths", "Append paths sequentially"),
('DIFFERENCE', "Subtract", "Clipping of the first slice"), ('MAX_COVERAGE', "Maximum coverage", "Union of all paths"),
('XOR', "Exclusive or", "Exclusive or of the slices"), ('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( connect_along_boundary: bool = NfBoolProperty(
@ -494,63 +509,26 @@ class SLICENODE_JoinSlices(SliceNodeJoiner):
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']: if self.operation in ['MIN_COVERAGE', 'INTERSECTION', 'SUBTRACT']:
layout.prop(self, "connect_along_boundary") 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
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] = [] inputs_data: list[SlicePaths] = []
for input in self.inputs: for input_socket in self.inputs:
paths: SlicePaths | None = resolve_input(input) paths: SlicePaths | None = resolve_input(input_socket)
if paths: if paths is not None:
inputs_data.append(paths) inputs_data.append(paths)
if not inputs_data: if not inputs_data:
return SlicePaths() return SlicePaths()
if self.mode == 'EXTEND': return SlicePaths.path_operations(
return SlicePaths.extend_paths(inputs_data) inputs_data,
elif self.mode == 'ZIP': operation=self.operation,
return SlicePaths.zip_paths(inputs_data) connect_along_boundary=self.connect_along_boundary
)
return SlicePaths()
@register_class @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.operator("node.add_node", text="Get Paths").type = "SLICENODE_GetPaths"
layout.separator() layout.separator()
layout.operator("node.add_node", text="Slice Shells").type = "SLICENODE_SliceShells" 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").type = "SLICENODE_BufferShells"
layout.operator("node.add_node", text="Buffer Shells Multiple").type = "SLICENODE_BufferShellsMultiple" layout.operator("node.add_node", text="Buffer Shells Multiple").type = "SLICENODE_BufferShellsMultiple"
layout.operator("node.add_node", text="Buffer Curve").type = "SLICENODE_BufferCurve" 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="Rotate Path").type = "SLICENODE_RotatePath"
layout.operator("node.add_node", text="Simplify Paths").type = "SLICENODE_SimplifyPaths" 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="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="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.separator()
layout.operator("node.add_node", text="Math").type = "SLICENODE_FloatMath" layout.operator("node.add_node", text="Math").type = "SLICENODE_FloatMath"
layout.operator("node.add_node", text="Integer Math").type = "SLICENODE_IntMath" layout.operator("node.add_node", text="Integer Math").type = "SLICENODE_IntMath"
@ -559,7 +557,7 @@ class SLICENODE_MT_Joiners(Menu):
assert layout assert layout
layout.operator("node.add_node", text="Join Mesh Parts").type = "SLICENODE_JoinMesh" 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 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 Gcode").type = "SLICENODE_JoinGcode"
layout.operator("node.add_node", text="Join Settings").type = "SLICENODE_JoinSettings" layout.operator("node.add_node", text="Join Settings").type = "SLICENODE_JoinSettings"
layout.operator("node.add_node", text="Join Execution").type = "SLICENODE_JoinExecution" layout.operator("node.add_node", text="Join Execution").type = "SLICENODE_JoinExecution"

View file

@ -5,8 +5,7 @@ import numpy as np
import pyclipper import pyclipper
from typing import Any, Literal, cast from typing import Any, Literal, cast
import copy import copy
from shapely.geometry.base import BaseGeometry from shapely.geometry import Polygon, MultiPolygon, LineString, LinearRing, MultiLineString, GeometryCollection, Point
from shapely.geometry import Polygon, MultiPolygon, LineString, LinearRing, MultiLineString, GeometryCollection
from ..infra.blender.blender_mesh_capture import PdfProperties 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: def offset(self, offset_vec: np.ndarray) -> SlicePaths:
"""Returns a new SlicePaths object shifted by the given vector.""" """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: def transform(self, matrix: np.ndarray) -> SlicePaths:
"""Applies a transformation matrix to all paths.""" """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: def buffer(self, distance: float, miter: str = 'round', rotation: Any = None) -> SlicePaths:
"""Buffers (offsets) the paths. Handles optional rotation for non-planar buffering.""" """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_p = (p - center) @ rot_mat.T + center
new_paths.append(new_p) 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: def get_first_layer_midpoint(self) -> np.ndarray:
if not self: 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: 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) 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]): def extend_with_groups(self, other: SlicePaths | list[np.ndarray]):
if isinstance(other, SlicePaths): if isinstance(other, SlicePaths):
self.extend(other) self.extend(other)
@ -884,6 +932,78 @@ def get_boundary_path(pt1: np.ndarray, pt2: np.ndarray, loops: list[np.ndarray])
return best_path 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) @ndarray_lru_cache(maxsize=128)
def boolean_slice(subject: SlicePaths, object: SlicePaths, operation: str = 'UNION', connect_along_boundary: bool = False) -> 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()
@ -891,11 +1011,15 @@ def boolean_slice(subject: SlicePaths, object: SlicePaths, operation: str = 'UNI
object_dict: dict[int, list[np.ndarray]] = {} object_dict: dict[int, list[np.ndarray]] = {}
for s in subject: for s in subject:
if len(s) < 2:
continue
z = int(round(s[0, 2] * SCALE)) z = int(round(s[0, 2] * SCALE))
all_z_keys.add(z) all_z_keys.add(z)
subject_dict.setdefault(z, []).append(s) subject_dict.setdefault(z, []).append(s)
for s in object: for s in object:
if len(s) < 2:
continue
z = int(round(s[0, 2] * SCALE)) z = int(round(s[0, 2] * SCALE))
all_z_keys.add(z) all_z_keys.add(z)
object_dict.setdefault(z, []).append(s) 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': if object_slices and operation == 'UNION':
results.extend(object_slices) results.extend(object_slices)
results.groups.extend([group] * len(object_slices)) results.groups.extend([group] * len(object_slices))
group += 1
continue continue
if not object_slices: if not object_slices:
if operation != 'INTERSECTION': if operation != 'INTERSECTION':
results.extend(subject_slices) results.extend(subject_slices)
results.groups.extend([group] * len(subject_slices)) results.groups.extend([group] * len(subject_slices))
group += 1
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]
# 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 = [] layer_results = []
def process_op(subs, clips_scaled, clips_pts, op_type): sub_closed = [s for s in subject_slices if is_closed_path(s)]
for s in subs: 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] path_pts = s[:, :2]
is_closed = is_closed_path(s) pc.Clear()
if not connect_along_boundary:
if not connect_along_boundary or is_closed: pc.AddPath((path_pts * SCALE).round().astype(np.int64).tolist(), pyclipper.PT_SUBJECT, False)
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]
for c in clips_scaled: for c in clips_scaled:
pc.AddPath(c, pyclipper.PT_CLIP, True) # pyright: ignore[reportAttributeAccessIssue] pc.AddPath(c, pyclipper.PT_CLIP, True)
solution = pc.Execute2(op_type, pyclipper.PFT_EVENODD, pyclipper.PFT_EVENODD)
solution = pc.Execute2(op_type, pyclipper.PFT_EVENODD, pyclipper.PFT_EVENODD) # pyright: ignore[reportAttributeAccessIssue] for path in pyclipper.OpenPathsFromPolyTree(solution):
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]
fpath = np.array(path, dtype=np.float64) / SCALE fpath = np.array(path, dtype=np.float64) / SCALE
layer_results.append(np.column_stack([fpath, np.full(len(fpath), z)])) layer_results.append(np.column_stack([fpath, np.full(len(fpath), z)]))
else: else:
pc.Clear() pc.AddPath((path_pts * SCALE).round().astype(np.int64).tolist(), pyclipper.PT_SUBJECT, False)
pc.AddPath((path_pts * SCALE).round().astype(np.int64).tolist(), pyclipper.PT_SUBJECT, False) # pyright: ignore[reportAttributeAccessIssue]
for c in clips_scaled: for c in clips_scaled:
pc.AddPath(c, pyclipper.PT_CLIP, True) # pyright: ignore[reportAttributeAccessIssue] pc.AddPath(c, pyclipper.PT_CLIP, True)
solution = pc.Execute2(op_type, pyclipper.PFT_EVENODD, pyclipper.PFT_EVENODD)
solution = pc.Execute2(op_type, pyclipper.PFT_EVENODD, pyclipper.PFT_EVENODD) # pyright: ignore[reportAttributeAccessIssue] open_results = pyclipper.OpenPathsFromPolyTree(solution)
open_results = pyclipper.OpenPathsFromPolyTree(solution) # pyright: ignore[reportAttributeAccessIssue] if not open_results:
if not open_results: continue continue
def get_p(pt): def get_p(pt):
p1 = path_pts[:-1] p1 = path_pts[:-1]
p2 = path_pts[1:] p2 = path_pts[1:]
@ -988,81 +1105,105 @@ def boolean_slice(subject: SlicePaths, object: SlicePaths, operation: str = 'UNI
fpath = fpath[::-1] fpath = fpath[::-1]
p_start, p_end = p_end, p_start p_start, p_end = p_end, p_start
segments.append((p_start, fpath)) segments.append((p_start, fpath))
segments.sort(key=lambda x: x[0]) segments.sort(key=lambda x: x[0])
current_group = [segments[0][1]] current_group = [segments[0][1]]
for i in range(1, len(segments)): for i in range(1, len(segments)):
prev_end = current_group[-1][-1] * SCALE prev_end = current_group[-1][-1] * SCALE
curr_start = segments[i][1][0] * SCALE curr_start = segments[i][1][0] * SCALE
scaled_loops = [(pts * SCALE) for pts in clips_pts] scaled_loops = [(pts * SCALE) for pts in clips_pts]
b_path = get_boundary_path(prev_end, curr_start, scaled_loops) b_path = get_boundary_path(prev_end, curr_start, scaled_loops)
if b_path is not None: if b_path is not None:
if len(b_path) > 2: if len(b_path) > 2:
current_group.append(b_path[1:-1] / SCALE) current_group.append(b_path[1:-1] / SCALE)
current_group.append(segments[i][1]) current_group.append(segments[i][1])
else: else:
# Path is not continuous along boundary, finalize current group
path_to_add = np.vstack(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)])) layer_results.append(np.column_stack([path_to_add, np.full(len(path_to_add), z)]))
current_group = [segments[i][1]] current_group = [segments[i][1]]
path_to_add = np.vstack(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)])) layer_results.append(np.column_stack([path_to_add, np.full(len(path_to_add), z)]))
if operation == 'INTERSECTION': if operation == 'INTERSECTION':
obj_closed_pts = [s[:, :2] for s in object_slices if is_closed_path(s)] if sub_closed and obj_closed:
obj_closed_scaled = [(pts[:-1] * SCALE).round().astype(np.int64).tolist() for pts in obj_closed_pts] pc.Clear()
process_op(subject_slices, obj_closed_scaled, obj_closed_pts, pyclipper.CT_INTERSECTION) # pyright: ignore[reportAttributeAccessIssue] for c in sub_closed_scaled:
pc.AddPath(c, pyclipper.PT_SUBJECT, True)
# Pass 2 for B_open ∩ A_closed for c in obj_closed_scaled:
b_open = [s for s in object_slices if not is_closed_path(s)] pc.AddPath(c, pyclipper.PT_CLIP, True)
if b_open: solution = pc.Execute2(pyclipper.CT_INTERSECTION, pyclipper.PFT_EVENODD, pyclipper.PFT_EVENODD)
sub_closed_pts = [s[:, :2] for s in subject_slices if is_closed_path(s)] for path in pyclipper.ClosedPathsFromPolyTree(solution):
sub_closed_scaled = [(pts[:-1] * SCALE).round().astype(np.int64).tolist() for pts in sub_closed_pts] fpath = np.array(path, dtype=np.float64) / SCALE
process_op(b_open, sub_closed_scaled, sub_closed_pts, pyclipper.CT_INTERSECTION) # pyright: ignore[reportAttributeAccessIssue] 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': elif operation == 'DIFFERENCE':
obj_closed_pts = [s[:, :2] for s in object_slices if is_closed_path(s)] if sub_closed and obj_closed:
obj_closed_scaled = [(pts[:-1] * SCALE).round().astype(np.int64).tolist() for pts in obj_closed_pts] pc.Clear()
process_op(subject_slices, obj_closed_scaled, obj_closed_pts, pyclipper.CT_DIFFERENCE) # pyright: ignore[reportAttributeAccessIssue] for c in sub_closed_scaled:
# DIFFERENCE is not symmetric for open paths in the same way, usually A - B means A is the main focus. 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: else:
# UNION, XOR # UNION, XOR
add_paths_to_clipper(subject_slices, pyclipper.PT_SUBJECT) # pyright: ignore[reportAttributeAccessIssue] pc.Clear()
obj_closed = [] for c in sub_closed_scaled:
obj_open = [] pc.AddPath(c, pyclipper.PT_SUBJECT, True)
for s in object_slices: for s in sub_open:
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:
path_pts = s[:, :2] path_pts = s[:, :2]
scaled = (path_pts[:-1] * SCALE).round().astype(np.int64).tolist() scaled = (path_pts * SCALE).round().astype(np.int64).tolist()
pc.AddPath(scaled, pyclipper.PT_CLIP, True) # pyright: ignore[reportAttributeAccessIssue] pc.AddPath(scaled, pyclipper.PT_SUBJECT, False)
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]
solution = pc.Execute2(op_type, pyclipper.PFT_EVENODD, pyclipper.PFT_EVENODD) # pyright: ignore[reportAttributeAccessIssue] for c in obj_closed_scaled:
for path in pyclipper.ClosedPathsFromPolyTree(solution): # pyright: ignore[reportAttributeAccessIssue] pc.AddPath(c, pyclipper.PT_CLIP, True)
fpath = np.array(path) / SCALE 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)]) fpath_3d = np.column_stack([fpath, np.full(len(fpath), z)])
layer_results.append(np.vstack([fpath_3d, fpath_3d[0]])) fpath_3d = np.vstack([fpath_3d, fpath_3d[0]])
for path in pyclipper.OpenPathsFromPolyTree(solution): # pyright: ignore[reportAttributeAccessIssue] layer_results.append(align_path_seam_sharpest(fpath_3d))
fpath = np.array(path) / SCALE 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)])) layer_results.append(np.column_stack([fpath, np.full(len(fpath), z)]))
results.extend(layer_results) results.extend(layer_results)
results.groups.extend([group] * len(layer_results)) results.groups.extend([group] * len(layer_results))
group += 1 group += 1
# Transfer per-group/path metadata (extrusion_rate, feedrate, extra_attributes) from subject # 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) calc_ext, calc_feed, calc_extra = _transfer_group_attributes(subject, results, results.groups)
results.extrusion_rate = calc_ext results.extrusion_rate = calc_ext
@ -1070,15 +1211,360 @@ def boolean_slice(subject: SlicePaths, object: SlicePaths, operation: str = 'UNI
results.extra_attributes = calc_extra results.extra_attributes = calc_extra
for attr, val in subject.__dict__.items(): for attr, val in subject.__dict__.items():
if attr not in ["groups", "extrusion_rate", "feedrate", "extra_attributes"]: if attr not in ["groups", "extrusion_rate", "feedrate", "extra_attributes", "transforms", "objects_info", "name"]:
setattr(results, attr, val) setattr(results, attr, copy.deepcopy(val) if isinstance(val, (list, dict)) else val)
for attr, val in object.__dict__.items(): for attr, val in object.__dict__.items():
if attr not in ["groups", "extrusion_rate", "feedrate", "extra_attributes"]: if attr not in ["groups", "extrusion_rate", "feedrate", "extra_attributes", "transforms", "objects_info", "name"]:
if not hasattr(results, attr): if not hasattr(results, attr) or getattr(results, attr) is None or getattr(results, attr) == "" or getattr(results, attr) == []:
setattr(results, attr, val) 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 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]: 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.""" """Helper to convert shapely geometries to a list of 3D numpy arrays with a given Z height."""
paths = [] paths = []
@ -1629,6 +2115,9 @@ class Pather(object):
self.objects_info: dict[str, Any] | None = getattr(paths, "objects_info", None) self.objects_info: dict[str, Any] | None = getattr(paths, "objects_info", None)
self.name: str = str(getattr(paths, "name", "")) self.name: str = str(getattr(paths, "name", ""))
for k, v in kwargs.items():
setattr(self, k, v)
@property @property
def layer_width(self) -> float: def layer_width(self) -> float:
if self._layer_width is not None: 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. - `link_segments()`: Logic for joining loose segments into continuous paths.
- `buffer_slice()`: Offset utility using PyclipperOffset. Supports rotated offsetting. - `buffer_slice()`: Offset utility using PyclipperOffset. Supports rotated offsetting.
- `infill_slice()`: Generates rotated grid or rectilinear infill patterns, ensuring layer-to-layer consistency. - `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.py`:
- Geometry utility functions. - 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. - **Density**: Distance between lines in units.
- **Infill Rotation**: Base rotation of the infill pattern. - **Infill Rotation**: Base rotation of the infill pattern.
- **Layer Rotation**: Rotation of the entire slice to align infill with specific planes. - **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. - **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. - **Math**: Float math operations.
- **Integer Math**: Simple integer operations (Add, Multiply, etc.). - **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. - **Load Gcode**: Reads a G-code file from disk.
### Utility & Joiners ### Utility & Joiners
- **Join Mesh**: Merges multiple `SlicingObject` instances into one. - **Join Mesh Parts**: Merges multiple `SlicingObject` instances into one `SlicingCollection`.
- **Join Paths**: Combines multiple `SlicePaths`. Supports Sequential or Interleaved modes. - **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 Gcode**: Concatenates multiple G-code strings.
- **Join Settings**: Sequentially merges multiple override dictionaries. Later inputs overwrite earlier ones for matching keys. - **Join Settings**: Sequentially merges multiple override dictionaries. Later inputs overwrite earlier ones for matching keys.
- **Join Execution**: Synchronizes sequential execution flows. - **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 = homogeneous_verts @ trimesh.matrix_world
tx_verts = (tx_verts[:, :3]) * scale 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 = tris_v_n @ trimesh.matrix_world[:3, :3]
tx_norm = homogeneous_norm @ trimesh.matrix_world.T norms_len = np.linalg.norm(tx_norm, axis=1, keepdims=True)
tx_norm = tx_norm[:, :3] norms_len = np.where(norms_len == 0, 1.0, norms_len)
tx_norm = tx_norm / np.linalg.norm(tx_norm, axis=1, keepdims=True) tx_norm = (tx_norm / norms_len)[:, np.newaxis, :]
tx_norm = tx_norm[:, np.newaxis, :]
tx_tris = tx_verts[tris_v_i] tx_tris = tx_verts[tris_v_i]

View file

@ -73,7 +73,10 @@ def blender_unregister_timers():
import os 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 _icons_pcoll: ImagePreviewCollection | None = None
def blender_register_icons(): def blender_register_icons():

View file

@ -1,6 +1,6 @@
import numpy as np import numpy as np
import unittest import unittest
from unittest.mock import MagicMock from unittest.mock import MagicMock, patch
import sys import sys
from pathlib import Path from pathlib import Path
@ -210,7 +210,6 @@ class TestSlice(unittest.TestCase):
def test_get_infill_mask(self): def test_get_infill_mask(self):
from NodeSlicer.core.slice import SlicePaths 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 square: (0,0) to (10,10) at Z=3.0 (area 100)
outer = np.array([ outer = np.array([
@ -250,12 +249,6 @@ class TestSlice(unittest.TestCase):
areas.sort() areas.sort()
self.assertAlmostEqual(areas[0], 16.0, places=2) # Hole area self.assertAlmostEqual(areas[0], 16.0, places=2) # Hole area
self.assertAlmostEqual(areas[1], 100.0, places=2) # Outer 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): def test_get_infill_mask_multiple_holes_and_islands(self):
from NodeSlicer.core.slice import SlicePaths 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 # First layer extrusion rates should be multiplied by 0.3 / 0.2 = 1.5
np.testing.assert_allclose(slices.extrusion_rate[0], 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): def test_slicepaths_attribute_conservation(self):
from NodeSlicer.core.slice import SlicePaths from NodeSlicer.core.slice import SlicePaths
@ -928,12 +985,12 @@ class TestSlice(unittest.TestCase):
node.ignore_holes = False node.ignore_holes = False
node.resolve = lambda name: scoll if name == "Mesh" else (0.5 if name == "Layer Height" else None) node.resolve = lambda name: scoll if name == "Mesh" else (0.5 if name == "Layer Height" else None)
slices_normal = node.evaluate_output("Paths") 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 # 2. Evaluation with ignore_holes=True
node.ignore_holes = True node.ignore_holes = True
slices_ignored = node.evaluate_output("Paths") 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) # 3. Evaluation when node lacks ignore_holes property (simulating missing property / legacy tree)
node_missing = SLICENODE_SliceShells.__new__(SLICENODE_SliceShells) 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) 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") slices_missing = node_missing.evaluate_output("Paths")
self.assertIsNotNone(slices_missing) 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 # 4. draw_buttons with and without ignore_holes does not fail
mock_layout = MagicMock() mock_layout = MagicMock()
@ -988,7 +1045,477 @@ class TestSlice(unittest.TestCase):
self.assertTrue(is_closed_path(paths[0])) self.assertTrue(is_closed_path(paths[0]))
self.assertTrue(np.allclose(paths[0][0], paths[0][-1])) 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__': if __name__ == '__main__':
unittest.main() unittest.main()