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**:
|
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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -64,17 +64,16 @@ 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_height = self.resolve("First Layer Height")
|
fl_val = self.resolve("First Layer Height")
|
||||||
except Exception:
|
if fl_val is not None:
|
||||||
fl_height = None
|
fl_height = float(fl_val)
|
||||||
else:
|
|
||||||
try:
|
|
||||||
fl_height = self.resolve("First Layer Height")
|
|
||||||
except Exception:
|
except Exception:
|
||||||
fl_height = None
|
fl_height = None
|
||||||
if fl_height is not None and fl_height <= 0:
|
if fl_height is not None and fl_height <= 0:
|
||||||
|
|
@ -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_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
|
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
|
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:
|
|
||||||
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:
|
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
|
||||||
|
|
||||||
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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
647
core/slice.py
647
core/slice.py
|
|
@ -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,59 +1035,52 @@ 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)
|
|
||||||
|
|
||||||
if not connect_along_boundary or is_closed:
|
|
||||||
pc.Clear()
|
pc.Clear()
|
||||||
if is_closed: path_pts = path_pts[:-1]
|
if not connect_along_boundary:
|
||||||
pc.AddPath((path_pts * SCALE).round().astype(np.int64).tolist(), pyclipper.PT_SUBJECT, is_closed) # pyright: ignore[reportAttributeAccessIssue]
|
pc.AddPath((path_pts * SCALE).round().astype(np.int64).tolist(), pyclipper.PT_SUBJECT, False)
|
||||||
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]
|
||||||
|
|
@ -990,7 +1107,6 @@ def boolean_slice(subject: SlicePaths, object: SlicePaths, operation: str = 'UNI
|
||||||
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
|
||||||
|
|
@ -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(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]]
|
||||||
|
|
@ -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)]))
|
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)
|
||||||
|
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
|
if sub_open and obj_closed_scaled:
|
||||||
b_open = [s for s in object_slices if not is_closed_path(s)]
|
process_open_op(sub_open, obj_closed_scaled, obj_closed_pts, pyclipper.CT_INTERSECTION)
|
||||||
if b_open:
|
if obj_open and sub_closed_scaled:
|
||||||
sub_closed_pts = [s[:, :2] for s in subject_slices if is_closed_path(s)]
|
process_open_op(obj_open, sub_closed_scaled, sub_closed_pts, pyclipper.CT_INTERSECTION)
|
||||||
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]
|
|
||||||
|
|
||||||
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]
|
for c in obj_closed_scaled:
|
||||||
if operation == 'UNION': op_type = pyclipper.CT_UNION # pyright: ignore[reportAttributeAccessIssue]
|
pc.AddPath(c, pyclipper.PT_CLIP, True)
|
||||||
elif operation == 'XOR': op_type = pyclipper.CT_XOR # pyright: ignore[reportAttributeAccessIssue]
|
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]
|
op_type = pyclipper.CT_UNION if operation == 'UNION' else pyclipper.CT_XOR
|
||||||
for path in pyclipper.ClosedPathsFromPolyTree(solution): # pyright: ignore[reportAttributeAccessIssue]
|
solution = pc.Execute2(op_type, pyclipper.PFT_EVENODD, pyclipper.PFT_EVENODD)
|
||||||
fpath = np.array(path) / SCALE
|
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)
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
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.
|
- **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.
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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():
|
||||||
|
|
|
||||||
|
|
@ -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([
|
||||||
|
|
@ -251,12 +250,6 @@ class TestSlice(unittest.TestCase):
|
||||||
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()
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue