This commit is contained in:
Nicolas Predella 2026-09-27 16:55:42 +03:00
parent 978dfcbe1d
commit b0943f2ca9
3 changed files with 210 additions and 59 deletions

View file

@ -354,7 +354,7 @@ class SLICENODE_PathOffset(SliceNode):
if not paths: return SlicePaths()
return paths.offset(np.array(offset, dtype=np.float32))
return paths.offset(np.array(offset, dtype=np.float64))
@register_class
class SLICENODE_RotatePath(SliceNode):
@ -373,7 +373,7 @@ class SLICENODE_RotatePath(SliceNode):
if not paths: return SlicePaths()
center_vec = np.array(center, dtype=np.float32) if center and any(center) else None
center_vec = np.array(center, dtype=np.float64) if center and any(center) else None
return paths.rotate(angle, center=center_vec)
@ -517,8 +517,10 @@ class SLICENODE_PathOperations(SliceNodeJoiner):
inputs_data: list[SlicePaths] = []
for input_socket in self.inputs:
paths: SlicePaths | None = resolve_input(input_socket)
if paths is not None:
if hasattr(input_socket, "is_linked") and not input_socket.is_linked:
continue
paths: Any = resolve_input(input_socket)
if isinstance(paths, SlicePaths) and len(paths) > 0:
inputs_data.append(paths)
if not inputs_data:

View file

@ -77,8 +77,8 @@ def _transfer_group_attributes(src_sp: SlicePaths, res_paths: list[np.ndarray],
M = len(p)
src_indices = src_group_map.get(g, [])
if not src_indices and len(src_sp) > 0:
z = p[0, 2] if len(p) > 0 else 0.0
src_indices = [min(range(len(src_sp)), key=lambda i: abs(src_sp[i][0, 2] - z) if len(src_sp[i]) > 0 else float('inf'))]
z_um = int(round(p[0, 2] * SCALE)) if len(p) > 0 else 0
src_indices = [min(range(len(src_sp)), key=lambda i: abs(int(round(src_sp[i][0, 2] * SCALE)) - z_um) if len(src_sp[i]) > 0 else float('inf'))]
if src_indices:
src_i = src_indices[0]
@ -336,11 +336,13 @@ class SlicePaths(list[np.ndarray]):
if not paths_list:
return SlicePaths()
base = primary_source if primary_source is not None else paths_list[0]
base = primary_source if primary_source is not None else next((sp for sp in paths_list if len(sp) > 0), paths_list[0])
merged = base.copy_with([])
merged_transforms = []
for sp in paths_list:
if not sp:
continue
merged.extend_with_groups(sp)
for t in getattr(sp, "transforms", []):
if t not in merged_transforms:
@ -355,18 +357,25 @@ class SlicePaths(list[np.ndarray]):
if not paths_list:
return SlicePaths()
base = primary_source if primary_source is not None else paths_list[0]
non_empty = [sp for sp in paths_list if len(sp) > 0]
if not non_empty:
return SlicePaths()
base = primary_source if primary_source is not None else non_empty[0]
all_groups = set()
grouped_inputs = []
for p_list in paths_list:
group_z: dict[int, int] = {}
for p_list in non_empty:
d = {}
for i, g in enumerate(p_list.groups):
all_groups.add(g)
d.setdefault(g, []).append((p_list[i], p_list.extrusion_rate[i], p_list.feedrate[i], {k: v[i] for k, v in p_list.extra_attributes.items()}))
if g not in group_z and len(p_list[i]) > 0:
group_z[g] = int(round(p_list[i][0, 2] * SCALE))
grouped_inputs.append(d)
sorted_groups = sorted(list(all_groups))
sorted_groups = sorted(list(all_groups), key=lambda g: (group_z.get(g, 0), g))
merged = base.copy_with([])
for g in sorted_groups:
@ -378,7 +387,7 @@ class SlicePaths(list[np.ndarray]):
merged_transforms = []
merged_names = []
merged_objs = []
for sp in paths_list:
for sp in non_empty:
obj_info = getattr(sp, "objects_info", None)
if obj_info:
for obj_entry in obj_info.get("objects", []):
@ -443,15 +452,17 @@ class SlicePaths(list[np.ndarray]):
angle_rad = np.radians(angle_deg)
c, s = np.cos(angle_rad), np.sin(angle_rad)
rot_mat = np.array([
[c, -s, 0],
[s, c, 0],
[0, 0, 1]
], dtype=np.float32)
[c, -s, 0.0],
[s, c, 0.0],
[0.0, 0.0, 1.0]
], dtype=np.float64)
new_paths = []
for p in self:
# Shift to center, rotate, shift back
# Shift to center, rotate, shift back, preserving exact original Z
new_p = (p - center) @ rot_mat.T + center
if len(p) > 0 and p.shape[1] >= 3:
new_p[:, 2] = p[:, 2]
new_paths.append(new_p)
res = self.copy_with(new_paths)
@ -467,18 +478,16 @@ class SlicePaths(list[np.ndarray]):
if not self:
return np.zeros(3)
# Find minimum group index
min_g = min(self.groups)
first_layer_indices = [i for i, g in enumerate(self.groups) if g == min_g]
# Find true minimum Z layer in integer microns across non-empty paths
min_z_um = min((int(round(p[0, 2] * SCALE)) for p in self if len(p) > 0), default=None)
if min_z_um is None:
return np.zeros(3)
all_pts = []
for i in first_layer_indices:
all_pts.append(self[i])
if not all_pts:
first_layer_pts = [p for p in self if len(p) > 0 and int(round(p[0, 2] * SCALE)) == min_z_um]
if not first_layer_pts:
return np.zeros(3)
pts = np.concatenate(all_pts)
pts = np.concatenate(first_layer_pts)
min_pts = np.min(pts, axis=0)
max_pts = np.max(pts, axis=0)
return (min_pts + max_pts) / 2.0
@ -1045,7 +1054,7 @@ def boolean_slice(subject: SlicePaths, object: SlicePaths, operation: str = 'UNI
group += 1
continue
z = subject_slices[0][0, 2]
z = float(k) / SCALE
pc = pyclipper.Pyclipper() # pyright: ignore[reportAttributeAccessIssue]
@ -1257,12 +1266,12 @@ def minimum_coverage_slice(
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:
valid_paths = [sp for sp in paths_list if isinstance(sp, SlicePaths) and len(sp) > 0]
if not valid_paths:
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()
paths_list = valid_paths
primary = paths_list[0]
all_z_keys: set[int] = set()
input_layer_maps: list[dict[int, list[np.ndarray]]] = []
@ -1397,16 +1406,15 @@ def maximum_coverage_slice(
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:
valid_paths = [sp for sp in paths_list if isinstance(sp, SlicePaths) and len(sp) > 0]
if not valid_paths:
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()
paths_list = valid_paths
primary = paths_list[0]
if len(non_empty) == 1:
sp = non_empty[0]
if len(paths_list) == 1:
sp = paths_list[0]
layer_map: dict[int, list[np.ndarray]] = {}
for p in sp:
if len(p) < 2:
@ -1464,8 +1472,8 @@ def maximum_coverage_slice(
return results
accumulated = non_empty[0]
for sp in non_empty[1:]:
accumulated = paths_list[0]
for sp in paths_list[1:]:
accumulated = boolean_slice(
accumulated,
sp,
@ -1486,7 +1494,7 @@ def intersection_slice(
accumulated = paths_list[0]
for sp in paths_list[1:]:
if not accumulated:
if not sp or not accumulated:
return SlicePaths()
accumulated = boolean_slice(
accumulated,
@ -1494,6 +1502,8 @@ def intersection_slice(
operation='INTERSECTION',
connect_along_boundary=connect_along_boundary
)
if not accumulated:
return SlicePaths()
return accumulated
def subtract_slice(
@ -1510,6 +1520,8 @@ def subtract_slice(
for sp in paths_list[1:]:
if not accumulated:
return SlicePaths()
if not sp:
continue
accumulated = boolean_slice(
accumulated,
sp,
@ -1530,6 +1542,11 @@ def xor_slice(
accumulated = paths_list[0]
for sp in paths_list[1:]:
if not sp:
continue
if not accumulated:
accumulated = sp
continue
accumulated = boolean_slice(
accumulated,
sp,
@ -1611,7 +1628,7 @@ def buffer_curve_slice(
group_id = 0
for z_key in sorted(groups.keys()):
layer_paths = groups[z_key]
z = layer_paths[0][0, 2]
z = float(z_key) / SCALE
for p in layer_paths:
if len(p) < 2:
@ -1685,7 +1702,7 @@ def offset_curve_slice(
group_id = 0
for z_key in sorted(groups.keys()):
layer_paths = groups[z_key]
z = layer_paths[0][0, 2]
z = float(z_key) / SCALE
for p in layer_paths:
if len(p) < 2:
@ -1812,7 +1829,7 @@ def get_infill_mask_slice(slice_paths: list[np.ndarray]) -> tuple[list[np.ndarra
if not layer_paths:
continue
z = layer_paths[0][0, 2]
z = float(z_key) / SCALE
# Convert layer paths to a single Shapely geometry (handling holes and multiple islands)
geom = paths_to_shapely_polygon(layer_paths)
@ -1842,7 +1859,10 @@ def get_overhang_mask_slice(slice_paths: list[np.ndarray], groups: tuple[int, ..
for p, g in zip(slice_paths, groups):
group_map.setdefault(g, []).append(p)
sorted_groups = sorted(group_map.keys())
sorted_groups = sorted(
group_map.keys(),
key=lambda g: int(round(group_map[g][0][0, 2] * SCALE)) if group_map[g] and len(group_map[g][0]) > 0 else g
)
if len(sorted_groups) < 2:
# If there's only 1 layer, there are no overhangs under any layer
return [], []
@ -1858,7 +1878,7 @@ def get_overhang_mask_slice(slice_paths: list[np.ndarray], groups: tuple[int, ..
# Iterate from top group down to bottom group
for i in range(len(sorted_groups) - 1, -1, -1):
g = sorted_groups[i]
z = group_map[g][0][0, 2] if group_map[g] and len(group_map[g][0]) > 0 else 0.0
z = float(int(round(group_map[g][0][0, 2] * SCALE))) / SCALE if group_map[g] and len(group_map[g][0]) > 0 else 0.0
geom_i = layer_geoms[g]
# Calculate raw support needed at layer g: union of all layers above g, minus geom_i.
@ -1913,7 +1933,7 @@ def buffer_slice(slice_paths: list[np.ndarray], distance: float, miter='round',
group_id = 0
for z_key in sorted(groups.keys()):
layer_paths = groups[z_key]
z = layer_paths[0][0, 2]
z = float(z_key) / SCALE
pco.Clear()
scaled_paths = []
@ -2017,11 +2037,14 @@ def infill_slice(slice_paths: 'SlicePaths', density: float, rotation_deg: float
output_paths: list[np.ndarray] = []
output_groups: list[int] = []
# Sort groups by Z to determine layer index for alternating rectilinear infill
unique_groups = sorted(groups.keys())
# Sort groups by Z in integer microns to determine layer index for alternating rectilinear infill
unique_groups = sorted(
groups.keys(),
key=lambda g: int(round(groups[g][0][0, 2] * SCALE)) if groups[g] and len(groups[g][0]) > 0 else g
)
group_to_layer_index = {g: i for i, g in enumerate(unique_groups)}
for g in groups.keys():
for g in unique_groups:
layer_idx = group_to_layer_index[g]
if infill_type == 'GRID':
@ -2034,7 +2057,7 @@ def infill_slice(slice_paths: 'SlicePaths', density: float, rotation_deg: float
current_grid_paths = []
layer_paths = groups[g]
z = layer_paths[0][0, 2]
z = float(int(round(layer_paths[0][0, 2] * SCALE))) / SCALE if layer_paths and len(layer_paths[0]) > 0 else 0.0
# De-rotate layer paths
offset_2d = np.array([global_center[0], global_center[1], 0])
@ -2069,10 +2092,11 @@ def infill_slice(slice_paths: 'SlicePaths', density: float, rotation_deg: float
return output_paths, output_groups
def group_segments_by_z(segments: np.ndarray) -> dict[int, np.ndarray]:
z_values: np.ndarray = segments[:, 0, 2]
unique_z: np.ndarray = np.unique(z_values)
groups: dict[int, np.ndarray] = {int(round(z * SCALE)): segments[z_values == z] for z in unique_z}
return groups
if len(segments) == 0:
return {}
z_um = np.round(segments[:, 0, 2] * SCALE).astype(np.int64)
unique_z_um = np.unique(z_um)
return {int(z): segments[z_um == z] for z in unique_z_um}
############################################################
@ -2246,7 +2270,7 @@ class Pather(object):
except (TypeError, ValueError):
lift_val = 0.0
last_z = -1.0
last_z_um: int | None = None
max_z = 0
last_nozzle_z = 0.0
@ -2260,6 +2284,7 @@ class Pather(object):
extras = {prefix: vals[i] for prefix, vals in self.extra_attributes.items()}
x0, y0, z0 = coords[0]
z0_um = int(round(z0 * SCALE))
active_feedrate = self.feedrate
if i > 0:
@ -2279,11 +2304,11 @@ class Pather(object):
active_feedrate = travel_fr
# 4. Handle Z alignment (layer change and/or un-lift)
if abs(z0 - last_z) > 0.001:
if last_z_um is None or z0_um != last_z_um:
gcode.append(f";BEFORE_LAYER_CHANGE")
gcode.append(f"G0 Z{z0:.3f} F720")
gcode.append(f";AFTER_LAYER_CHANGE")
last_z = z0
last_z_um = z0_um
active_feedrate = 720.0
elif lift_val > 0.0:
gcode.append(f"G0 Z{z0:.3f} F720")
@ -2294,11 +2319,11 @@ class Pather(object):
gcode.append(f"G1 E{retraction_val:.5f} F{retraction_fr_val:.3f}")
active_feedrate = retraction_fr_val
else:
if abs(z0 - last_z) > 0.001:
if last_z_um is None or z0_um != last_z_um:
gcode.append(f";BEFORE_LAYER_CHANGE")
gcode.append(f"G0 Z{z0:.3f} F720")
gcode.append(f";AFTER_LAYER_CHANGE")
last_z = z0
last_z_um = z0_um
active_feedrate = 720.0
old_fr = f_rate[0] if f_rate[0] > 0 else self.travel_feedrate

View file

@ -1181,7 +1181,9 @@ class TestSlice(unittest.TestCase):
node = SLICENODE_PathOperations()
mock_socket1 = MagicMock()
mock_socket1.is_linked = True
mock_socket2 = MagicMock()
mock_socket2.is_linked = True
node.inputs = [mock_socket1, mock_socket2]
# Mock resolve_input to return sp1 and sp2
@ -1207,6 +1209,84 @@ class TestSlice(unittest.TestCase):
node.operation = 'XOR'
self.assertEqual(len(node.evaluate_output("Paths")), 2)
def test_path_operations_node_ignores_unplugged_and_invalid_sockets(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()
# Case 1: 1 linked socket, 1 unlinked trailing socket (typical Blender node state)
sock_linked = MagicMock()
sock_linked.is_linked = True
sock_unlinked = MagicMock()
sock_unlinked.is_linked = False
node.inputs = [sock_linked, sock_unlinked]
with patch("NodeSlicer.infra.blender.blender_slice_nodes_generic.resolve_input", return_value=sp1):
# When only 1 socket is plugged in, operations should NOT be broken by the unlinked socket
node.operation = 'MIN_COVERAGE'
self.assertEqual(len(node.evaluate_output("Paths")), 1)
node.operation = 'MAX_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")), 1)
node.operation = 'ZIP'
self.assertEqual(len(node.evaluate_output("Paths")), 1)
node.operation = 'EXTEND'
self.assertEqual(len(node.evaluate_output("Paths")), 1)
# Case 2: Mixed sockets with unlinked and invalid (None, non-SlicePaths, empty SlicePaths)
sock1 = MagicMock(is_linked=True)
sock_unlinked_mid = MagicMock(is_linked=False)
sock_invalid_type = MagicMock(is_linked=True)
sock_empty_paths = MagicMock(is_linked=True)
sock2 = MagicMock(is_linked=True)
sock_unlinked_end = MagicMock(is_linked=False)
node.inputs = [sock1, sock_unlinked_mid, sock_invalid_type, sock_empty_paths, sock2, sock_unlinked_end]
def mock_resolve(sock):
if sock == sock1:
return sp1
elif sock == sock_invalid_type:
return "not_a_slice_path"
elif sock == sock_empty_paths:
return SlicePaths()
elif sock == sock2:
return sp2
return None
with patch("NodeSlicer.infra.blender.blender_slice_nodes_generic.resolve_input", side_effect=mock_resolve):
node.operation = 'INTERSECTION'
res = node.evaluate_output("Paths")
self.assertEqual(len(res), 1)
node.operation = 'ZIP'
res_zip = node.evaluate_output("Paths")
self.assertEqual(len(res_zip), 2)
# Case 3: All sockets unlinked
node.inputs = [sock_unlinked, sock_unlinked_end]
res_empty = node.evaluate_output("Paths")
self.assertIsInstance(res_empty, SlicePaths)
self.assertEqual(len(res_empty), 0)
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)
@ -1450,6 +1530,50 @@ class TestSlice(unittest.TestCase):
self.assertEqual(infilled.first_layer_height, 0.3)
self.assertEqual(infilled.name, "Part1")
def test_group_segments_by_z_float_drift(self):
from NodeSlicer.core.slice import group_segments_by_z
# Segments with slight float drift on the same layer
seg1 = np.array([[[0.0, 0.0, 0.20000000000000004], [10.0, 0.0, 0.20000000000000004]]])
seg2 = np.array([[[10.0, 0.0, 0.20000000000000007], [10.0, 10.0, 0.20000000000000007]]])
seg3 = np.array([[[0.0, 0.0, 0.40000000000000002], [10.0, 0.0, 0.40000000000000002]]])
all_segs = np.vstack([seg1, seg2, seg3])
groups = group_segments_by_z(all_segs)
self.assertEqual(len(groups), 2)
self.assertIn(200, groups)
self.assertIn(400, groups)
# Key 200 must have both seg1 and seg2
self.assertEqual(len(groups[200]), 2)
self.assertEqual(len(groups[400]), 1)
def test_pather_z_layer_change_drift(self):
from NodeSlicer.core.slice import SlicePaths, Pather
# 2 paths on layer 1 with tiny drift, 1 path on layer 2
p1 = np.array([[0, 0, 0.20000000000000004], [10, 0, 0.20000000000000004]])
p2 = np.array([[10, 0, 0.20000000000000007], [10, 10, 0.20000000000000007]])
p3 = np.array([[0, 0, 0.4], [10, 0, 0.4]])
sp = SlicePaths([p1, p2, p3], groups=[0, 0, 1])
pather = Pather(sp)
gcode = pather.to_gcode(include_header=False)
lines = gcode.splitlines()
before_layer_changes = [l for l in lines if l == ";BEFORE_LAYER_CHANGE"]
# Exactly 2 layer changes (one for Z=0.2, one for Z=0.4)
self.assertEqual(len(before_layer_changes), 2)
def test_get_first_layer_midpoint_out_of_order_groups(self):
from NodeSlicer.core.slice import SlicePaths
# Top layer first, bottom layer second
top_p = np.array([[0, 0, 1.0], [10, 0, 1.0], [10, 10, 1.0], [0, 10, 1.0], [0, 0, 1.0]])
bottom_p = np.array([[10, 10, 0.2], [20, 10, 0.2], [20, 20, 0.2], [10, 20, 0.2], [10, 10, 0.2]])
sp = SlicePaths([top_p, bottom_p], groups=[99, 10])
midpoint = sp.get_first_layer_midpoint()
# Midpoint of bottom_p is [15.0, 15.0, 0.2]
self.assertAlmostEqual(midpoint[0], 15.0)
self.assertAlmostEqual(midpoint[1], 15.0)
self.assertAlmostEqual(midpoint[2], 0.2)
class TestCodeReviewBugFixes(unittest.TestCase):
def test_ndarray_lru_cache_empty_list_and_dict(self):