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() 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 @register_class
class SLICENODE_RotatePath(SliceNode): class SLICENODE_RotatePath(SliceNode):
@ -373,7 +373,7 @@ class SLICENODE_RotatePath(SliceNode):
if not paths: return SlicePaths() 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) return paths.rotate(angle, center=center_vec)
@ -517,8 +517,10 @@ class SLICENODE_PathOperations(SliceNodeJoiner):
inputs_data: list[SlicePaths] = [] inputs_data: list[SlicePaths] = []
for input_socket in self.inputs: for input_socket in self.inputs:
paths: SlicePaths | None = resolve_input(input_socket) if hasattr(input_socket, "is_linked") and not input_socket.is_linked:
if paths is not None: continue
paths: Any = resolve_input(input_socket)
if isinstance(paths, SlicePaths) and len(paths) > 0:
inputs_data.append(paths) inputs_data.append(paths)
if not inputs_data: 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) M = len(p)
src_indices = src_group_map.get(g, []) src_indices = src_group_map.get(g, [])
if not src_indices and len(src_sp) > 0: if not src_indices and len(src_sp) > 0:
z = p[0, 2] if len(p) > 0 else 0.0 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(src_sp[i][0, 2] - z) if len(src_sp[i]) > 0 else float('inf'))] 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: if src_indices:
src_i = src_indices[0] src_i = src_indices[0]
@ -336,11 +336,13 @@ class SlicePaths(list[np.ndarray]):
if not paths_list: if not paths_list:
return SlicePaths() 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 = base.copy_with([])
merged_transforms = [] merged_transforms = []
for sp in paths_list: for sp in paths_list:
if not sp:
continue
merged.extend_with_groups(sp) merged.extend_with_groups(sp)
for t in getattr(sp, "transforms", []): for t in getattr(sp, "transforms", []):
if t not in merged_transforms: if t not in merged_transforms:
@ -355,18 +357,25 @@ class SlicePaths(list[np.ndarray]):
if not paths_list: if not paths_list:
return SlicePaths() 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() all_groups = set()
grouped_inputs = [] grouped_inputs = []
for p_list in paths_list: group_z: dict[int, int] = {}
for p_list in non_empty:
d = {} d = {}
for i, g in enumerate(p_list.groups): for i, g in enumerate(p_list.groups):
all_groups.add(g) 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()})) 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) 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([]) merged = base.copy_with([])
for g in sorted_groups: for g in sorted_groups:
@ -378,7 +387,7 @@ class SlicePaths(list[np.ndarray]):
merged_transforms = [] merged_transforms = []
merged_names = [] merged_names = []
merged_objs = [] merged_objs = []
for sp in paths_list: for sp in non_empty:
obj_info = getattr(sp, "objects_info", None) obj_info = getattr(sp, "objects_info", None)
if obj_info: if obj_info:
for obj_entry in obj_info.get("objects", []): for obj_entry in obj_info.get("objects", []):
@ -443,15 +452,17 @@ class SlicePaths(list[np.ndarray]):
angle_rad = np.radians(angle_deg) angle_rad = np.radians(angle_deg)
c, s = np.cos(angle_rad), np.sin(angle_rad) c, s = np.cos(angle_rad), np.sin(angle_rad)
rot_mat = np.array([ rot_mat = np.array([
[c, -s, 0], [c, -s, 0.0],
[s, c, 0], [s, c, 0.0],
[0, 0, 1] [0.0, 0.0, 1.0]
], dtype=np.float32) ], dtype=np.float64)
new_paths = [] new_paths = []
for p in self: 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 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) new_paths.append(new_p)
res = self.copy_with(new_paths) res = self.copy_with(new_paths)
@ -467,18 +478,16 @@ class SlicePaths(list[np.ndarray]):
if not self: if not self:
return np.zeros(3) return np.zeros(3)
# Find minimum group index # Find true minimum Z layer in integer microns across non-empty paths
min_g = min(self.groups) min_z_um = min((int(round(p[0, 2] * SCALE)) for p in self if len(p) > 0), default=None)
first_layer_indices = [i for i, g in enumerate(self.groups) if g == min_g] if min_z_um is None:
return np.zeros(3)
all_pts = [] first_layer_pts = [p for p in self if len(p) > 0 and int(round(p[0, 2] * SCALE)) == min_z_um]
for i in first_layer_indices: if not first_layer_pts:
all_pts.append(self[i])
if not all_pts:
return np.zeros(3) return np.zeros(3)
pts = np.concatenate(all_pts) pts = np.concatenate(first_layer_pts)
min_pts = np.min(pts, axis=0) min_pts = np.min(pts, axis=0)
max_pts = np.max(pts, axis=0) max_pts = np.max(pts, axis=0)
return (min_pts + max_pts) / 2.0 return (min_pts + max_pts) / 2.0
@ -1045,7 +1054,7 @@ def boolean_slice(subject: SlicePaths, object: SlicePaths, operation: str = 'UNI
group += 1 group += 1
continue continue
z = subject_slices[0][0, 2] z = float(k) / SCALE
pc = pyclipper.Pyclipper() # pyright: ignore[reportAttributeAccessIssue] 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, paths in that layer. If only one path (or single shape with holes) exists on a layer,
it preserves that path/shape intact. 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() return SlicePaths()
primary = next((sp for sp in paths_list if len(sp) > 0), paths_list[0]) paths_list = valid_paths
if not any(len(sp) > 0 for sp in paths_list): primary = paths_list[0]
return SlicePaths()
all_z_keys: set[int] = set() all_z_keys: set[int] = set()
input_layer_maps: list[dict[int, list[np.ndarray]]] = [] 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). 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. 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() return SlicePaths()
primary = next((sp for sp in paths_list if len(sp) > 0), paths_list[0]) paths_list = valid_paths
non_empty = [sp for sp in paths_list if len(sp) > 0] primary = paths_list[0]
if not non_empty:
return SlicePaths()
if len(non_empty) == 1: if len(paths_list) == 1:
sp = non_empty[0] sp = paths_list[0]
layer_map: dict[int, list[np.ndarray]] = {} layer_map: dict[int, list[np.ndarray]] = {}
for p in sp: for p in sp:
if len(p) < 2: if len(p) < 2:
@ -1464,8 +1472,8 @@ def maximum_coverage_slice(
return results return results
accumulated = non_empty[0] accumulated = paths_list[0]
for sp in non_empty[1:]: for sp in paths_list[1:]:
accumulated = boolean_slice( accumulated = boolean_slice(
accumulated, accumulated,
sp, sp,
@ -1486,7 +1494,7 @@ def intersection_slice(
accumulated = paths_list[0] accumulated = paths_list[0]
for sp in paths_list[1:]: for sp in paths_list[1:]:
if not accumulated: if not sp or not accumulated:
return SlicePaths() return SlicePaths()
accumulated = boolean_slice( accumulated = boolean_slice(
accumulated, accumulated,
@ -1494,6 +1502,8 @@ def intersection_slice(
operation='INTERSECTION', operation='INTERSECTION',
connect_along_boundary=connect_along_boundary connect_along_boundary=connect_along_boundary
) )
if not accumulated:
return SlicePaths()
return accumulated return accumulated
def subtract_slice( def subtract_slice(
@ -1510,6 +1520,8 @@ def subtract_slice(
for sp in paths_list[1:]: for sp in paths_list[1:]:
if not accumulated: if not accumulated:
return SlicePaths() return SlicePaths()
if not sp:
continue
accumulated = boolean_slice( accumulated = boolean_slice(
accumulated, accumulated,
sp, sp,
@ -1530,6 +1542,11 @@ def xor_slice(
accumulated = paths_list[0] accumulated = paths_list[0]
for sp in paths_list[1:]: for sp in paths_list[1:]:
if not sp:
continue
if not accumulated:
accumulated = sp
continue
accumulated = boolean_slice( accumulated = boolean_slice(
accumulated, accumulated,
sp, sp,
@ -1611,7 +1628,7 @@ def buffer_curve_slice(
group_id = 0 group_id = 0
for z_key in sorted(groups.keys()): for z_key in sorted(groups.keys()):
layer_paths = groups[z_key] layer_paths = groups[z_key]
z = layer_paths[0][0, 2] z = float(z_key) / SCALE
for p in layer_paths: for p in layer_paths:
if len(p) < 2: if len(p) < 2:
@ -1685,7 +1702,7 @@ def offset_curve_slice(
group_id = 0 group_id = 0
for z_key in sorted(groups.keys()): for z_key in sorted(groups.keys()):
layer_paths = groups[z_key] layer_paths = groups[z_key]
z = layer_paths[0][0, 2] z = float(z_key) / SCALE
for p in layer_paths: for p in layer_paths:
if len(p) < 2: 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: if not layer_paths:
continue 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) # Convert layer paths to a single Shapely geometry (handling holes and multiple islands)
geom = paths_to_shapely_polygon(layer_paths) 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): for p, g in zip(slice_paths, groups):
group_map.setdefault(g, []).append(p) 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 len(sorted_groups) < 2:
# If there's only 1 layer, there are no overhangs under any layer # If there's only 1 layer, there are no overhangs under any layer
return [], [] 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 # Iterate from top group down to bottom group
for i in range(len(sorted_groups) - 1, -1, -1): for i in range(len(sorted_groups) - 1, -1, -1):
g = sorted_groups[i] 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] geom_i = layer_geoms[g]
# Calculate raw support needed at layer g: union of all layers above g, minus geom_i. # 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 group_id = 0
for z_key in sorted(groups.keys()): for z_key in sorted(groups.keys()):
layer_paths = groups[z_key] layer_paths = groups[z_key]
z = layer_paths[0][0, 2] z = float(z_key) / SCALE
pco.Clear() pco.Clear()
scaled_paths = [] scaled_paths = []
@ -2017,11 +2037,14 @@ def infill_slice(slice_paths: 'SlicePaths', density: float, rotation_deg: float
output_paths: list[np.ndarray] = [] output_paths: list[np.ndarray] = []
output_groups: list[int] = [] output_groups: list[int] = []
# Sort groups by Z to determine layer index for alternating rectilinear infill # Sort groups by Z in integer microns to determine layer index for alternating rectilinear infill
unique_groups = sorted(groups.keys()) 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)} 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] layer_idx = group_to_layer_index[g]
if infill_type == 'GRID': if infill_type == 'GRID':
@ -2034,7 +2057,7 @@ def infill_slice(slice_paths: 'SlicePaths', density: float, rotation_deg: float
current_grid_paths = [] current_grid_paths = []
layer_paths = groups[g] 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 # De-rotate layer paths
offset_2d = np.array([global_center[0], global_center[1], 0]) 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 return output_paths, output_groups
def group_segments_by_z(segments: np.ndarray) -> dict[int, np.ndarray]: def group_segments_by_z(segments: np.ndarray) -> dict[int, np.ndarray]:
z_values: np.ndarray = segments[:, 0, 2] if len(segments) == 0:
unique_z: np.ndarray = np.unique(z_values) return {}
groups: dict[int, np.ndarray] = {int(round(z * SCALE)): segments[z_values == z] for z in unique_z} z_um = np.round(segments[:, 0, 2] * SCALE).astype(np.int64)
return groups 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): except (TypeError, ValueError):
lift_val = 0.0 lift_val = 0.0
last_z = -1.0 last_z_um: int | None = None
max_z = 0 max_z = 0
last_nozzle_z = 0.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()} extras = {prefix: vals[i] for prefix, vals in self.extra_attributes.items()}
x0, y0, z0 = coords[0] x0, y0, z0 = coords[0]
z0_um = int(round(z0 * SCALE))
active_feedrate = self.feedrate active_feedrate = self.feedrate
if i > 0: if i > 0:
@ -2279,11 +2304,11 @@ class Pather(object):
active_feedrate = travel_fr active_feedrate = travel_fr
# 4. Handle Z alignment (layer change and/or un-lift) # 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";BEFORE_LAYER_CHANGE")
gcode.append(f"G0 Z{z0:.3f} F720") gcode.append(f"G0 Z{z0:.3f} F720")
gcode.append(f";AFTER_LAYER_CHANGE") gcode.append(f";AFTER_LAYER_CHANGE")
last_z = z0 last_z_um = z0_um
active_feedrate = 720.0 active_feedrate = 720.0
elif lift_val > 0.0: elif lift_val > 0.0:
gcode.append(f"G0 Z{z0:.3f} F720") 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}") gcode.append(f"G1 E{retraction_val:.5f} F{retraction_fr_val:.3f}")
active_feedrate = retraction_fr_val active_feedrate = retraction_fr_val
else: 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";BEFORE_LAYER_CHANGE")
gcode.append(f"G0 Z{z0:.3f} F720") gcode.append(f"G0 Z{z0:.3f} F720")
gcode.append(f";AFTER_LAYER_CHANGE") gcode.append(f";AFTER_LAYER_CHANGE")
last_z = z0 last_z_um = z0_um
active_feedrate = 720.0 active_feedrate = 720.0
old_fr = f_rate[0] if f_rate[0] > 0 else self.travel_feedrate 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() node = SLICENODE_PathOperations()
mock_socket1 = MagicMock() mock_socket1 = MagicMock()
mock_socket1.is_linked = True
mock_socket2 = MagicMock() mock_socket2 = MagicMock()
mock_socket2.is_linked = True
node.inputs = [mock_socket1, mock_socket2] node.inputs = [mock_socket1, mock_socket2]
# Mock resolve_input to return sp1 and sp2 # Mock resolve_input to return sp1 and sp2
@ -1207,6 +1209,84 @@ class TestSlice(unittest.TestCase):
node.operation = 'XOR' node.operation = 'XOR'
self.assertEqual(len(node.evaluate_output("Paths")), 2) 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): def test_path_operations_single_input_min_coverage(self):
from NodeSlicer.core.slice import SlicePaths from NodeSlicer.core.slice import SlicePaths
# Single input with 1 path per layer (Z=1.0 and Z=2.0) # 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.first_layer_height, 0.3)
self.assertEqual(infilled.name, "Part1") 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): class TestCodeReviewBugFixes(unittest.TestCase):
def test_ndarray_lru_cache_empty_list_and_dict(self): def test_ndarray_lru_cache_empty_list_and_dict(self):