up
This commit is contained in:
parent
5ed9836f21
commit
978dfcbe1d
11 changed files with 1253 additions and 232 deletions
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
for idx, input_socket in enumerate(self.inputs):
|
||||
coll = resolve_input(input_socket)
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -64,17 +64,16 @@ 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")
|
||||
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:
|
||||
|
|
@ -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))
|
||||
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
|
||||
elif z_first_um < max_z_um:
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
647
core/slice.py
647
core/slice.py
|
|
@ -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,59 +1035,52 @@ 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]
|
||||
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]
|
||||
|
|
@ -990,7 +1107,6 @@ def boolean_slice(subject: SlicePaths, object: SlicePaths, operation: str = 'UNI
|
|||
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
|
||||
|
|
@ -1003,7 +1119,6 @@ def boolean_slice(subject: SlicePaths, object: SlicePaths, operation: str = 'UNI
|
|||
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]]
|
||||
|
|
@ -1012,51 +1127,77 @@ def boolean_slice(subject: SlicePaths, object: SlicePaths, operation: str = 'UNI
|
|||
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]
|
||||
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)]))
|
||||
|
||||
# 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_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]
|
||||
scaled = (path_pts * SCALE).round().astype(np.int64).tolist()
|
||||
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]
|
||||
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)
|
||||
|
||||
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
|
||||
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)
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
17
doc/NODES.md
17
doc/NODES.md
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
||||
|
|
|
|||
|
|
@ -73,7 +73,10 @@ def blender_unregister_timers():
|
|||
|
||||
import os
|
||||
|
||||
try:
|
||||
from bpy.utils.previews import ImagePreviewCollection
|
||||
except ImportError:
|
||||
ImagePreviewCollection = Any # type: ignore
|
||||
_icons_pcoll: ImagePreviewCollection | None = None
|
||||
|
||||
def blender_register_icons():
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
@ -251,12 +250,6 @@ class TestSlice(unittest.TestCase):
|
|||
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()
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue