From b0943f2ca9ceb2f6d9147823ca89a5f1408f7a57 Mon Sep 17 00:00:00 2001 From: Nicolas Predella Date: Sun, 27 Sep 2026 16:55:42 +0300 Subject: [PATCH] float --- core/nodeslicer/nodes/path.py | 10 ++- core/slice.py | 135 ++++++++++++++++++++-------------- tests/test_slice.py | 124 +++++++++++++++++++++++++++++++ 3 files changed, 210 insertions(+), 59 deletions(-) diff --git a/core/nodeslicer/nodes/path.py b/core/nodeslicer/nodes/path.py index 62aa170..2f78325 100644 --- a/core/nodeslicer/nodes/path.py +++ b/core/nodeslicer/nodes/path.py @@ -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: diff --git a/core/slice.py b/core/slice.py index 19c3ddb..a060843 100644 --- a/core/slice.py +++ b/core/slice.py @@ -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 diff --git a/tests/test_slice.py b/tests/test_slice.py index 765f12f..5d2402a 100644 --- a/tests/test_slice.py +++ b/tests/test_slice.py @@ -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):