diff --git a/core/nodeslicer/nodes/base.py b/core/nodeslicer/nodes/base.py index c4d15a4..cb05f1a 100644 --- a/core/nodeslicer/nodes/base.py +++ b/core/nodeslicer/nodes/base.py @@ -20,8 +20,9 @@ class SliceIntrinsics: extrusion_rate: float retraction: float lift: float + retraction_feedrate: float - def __init__(self, bed_x: float = 250.0, bed_y: float = 250.0, feedrate: float = 1200.0, travel_feedrate: float = 2400.0, extrusion_rate: float = 0.017, retraction: float = 0.0, lift: float = 0.0): + def __init__(self, bed_x: float = 250.0, bed_y: float = 250.0, feedrate: float = 1200.0, travel_feedrate: float = 2400.0, extrusion_rate: float = 0.017, retraction: float = 0.0, lift: float = 0.0, retraction_feedrate: float = 0.0): self.bed_x = bed_x self.bed_y = bed_y self.feedrate = feedrate @@ -29,6 +30,7 @@ class SliceIntrinsics: self.extrusion_rate = extrusion_rate self.retraction = retraction self.lift = lift + self.retraction_feedrate = retraction_feedrate class InputNode(SliceNode): input_type: str = "" diff --git a/core/nodeslicer/nodes/gcode.py b/core/nodeslicer/nodes/gcode.py index 78e507c..fd6e30a 100644 --- a/core/nodeslicer/nodes/gcode.py +++ b/core/nodeslicer/nodes/gcode.py @@ -42,6 +42,7 @@ class SLICENODE_PathsToGcode(SliceNode): pather.travel_feedrate = intrinsics.travel_feedrate pather.extrusion_rate = intrinsics.extrusion_rate pather.retraction = intrinsics.retraction + pather.retraction_feedrate = getattr(intrinsics, "retraction_feedrate", 0.0) pather.lift = intrinsics.lift gcode: str = pather.to_gcode(include_header=self.use_header) diff --git a/core/nodeslicer/nodes/inputs.py b/core/nodeslicer/nodes/inputs.py index 97f66fc..a500469 100644 --- a/core/nodeslicer/nodes/inputs.py +++ b/core/nodeslicer/nodes/inputs.py @@ -286,9 +286,16 @@ class SLICENODE_InputIntrinsics(SliceNode): self.add_input("SLICESOCK_Float", "Travel Feedrate").value = 2400.0 self.add_input("SLICESOCK_Float", "Extrusion Rate").value = 0.035 self.add_input("SLICESOCK_Float", "Retraction").value = 0.0 + self.add_input("SLICESOCK_Float", "Retraction Feedrate").value = 0.0 self.add_input("SLICESOCK_Float", "Lift").value = 0.0 def evaluate_output(self, socket_name: str, **_kwargs) -> SliceIntrinsics: + ret_fr = 0.0 + try: + ret_fr = self.resolve("Retraction Feedrate") or 0.0 + except Exception: + ret_fr = 0.0 + return SliceIntrinsics( bed_x=self.resolve("Bed X"), bed_y=self.resolve("Bed Y"), @@ -296,5 +303,6 @@ class SLICENODE_InputIntrinsics(SliceNode): travel_feedrate=self.resolve("Travel Feedrate"), extrusion_rate=self.resolve("Extrusion Rate"), retraction=self.resolve("Retraction"), - lift=self.resolve("Lift") + lift=self.resolve("Lift"), + retraction_feedrate=ret_fr ) diff --git a/core/nodeslicer/nodes/path.py b/core/nodeslicer/nodes/path.py index e065fc7..1c044d5 100644 --- a/core/nodeslicer/nodes/path.py +++ b/core/nodeslicer/nodes/path.py @@ -117,6 +117,8 @@ class SLICENODE_SliceShells(SliceNode): mat_inv = np.array(Euler(rotation).to_matrix()) slices = slices.transform(mat_inv) + slices = slices.align_seams(mode="sharpest") + return slices @register_class diff --git a/core/slice.py b/core/slice.py index b2b16d5..f16aaf7 100644 --- a/core/slice.py +++ b/core/slice.py @@ -88,6 +88,70 @@ def _transfer_group_attributes(src_sp: SlicePaths, res_paths: list[np.ndarray], return out_ext, out_feed, out_extra +def align_path_seam_sharpest(path: np.ndarray) -> np.ndarray: + """ + Reorders a closed 2D/3D polygon path (N, 3) or (N, 2) so that its starting vertex (seam) + is located at the sharpest concave angle (inner corner). If no concave angles exist, + falls back to the sharpest angle overall. + + If path is open or has fewer than 3 distinct vertices, returns path unchanged. + Uses vectorized NumPy operations for high performance. + """ + if len(path) < 4: + return path + + if not np.allclose(path[0], path[-1]): + return path + + pts = path[:-1] + M = len(pts) + if M < 3: + return path + + pts2d = pts[:, :2] + x = pts2d[:, 0] + y = pts2d[:, 1] + x_next = np.roll(x, -1) + y_next = np.roll(y, -1) + signed_area = 0.5 * np.sum(x * y_next - x_next * y) + if abs(signed_area) < 1e-9: + return path + + prev_pts = np.roll(pts2d, 1, axis=0) + next_pts = np.roll(pts2d, -1, axis=0) + + v_in = pts2d - prev_pts + v_out = next_pts - pts2d + + l_in = np.linalg.norm(v_in, axis=1) + l_out = np.linalg.norm(v_out, axis=1) + + valid = (l_in > 1e-6) & (l_out > 1e-6) + if not np.any(valid): + return path + + cross_2d = v_in[:, 0] * v_out[:, 1] - v_in[:, 1] * v_out[:, 0] + if signed_area > 0: + concave_mask = valid & (cross_2d < -1e-6) + else: + concave_mask = valid & (cross_2d > 1e-6) + + cos_angles = np.full(M, 1.0) + dot_prods = np.sum(v_in[valid] * v_out[valid], axis=1) + cos_angles[valid] = dot_prods / (l_in[valid] * l_out[valid]) + + if np.any(concave_mask): + concave_indices = np.where(concave_mask)[0] + best_idx = int(concave_indices[np.argmin(cos_angles[concave_mask])]) + else: + best_idx = int(np.argmin(cos_angles)) + + if best_idx == 0: + return path + + return np.vstack([path[best_idx:-1], path[:best_idx + 1]]) + + class SlicePaths(list[np.ndarray]): groups: list[int] extrusion_rate: list[np.ndarray] @@ -398,6 +462,105 @@ class SlicePaths(list[np.ndarray]): def normalize_groups(self) -> None: self.groups = [m.setdefault(x, len(m)) for m in [{}] for x in self.groups] + def align_seams(self, mode: str = "sharpest") -> SlicePaths: + """ + Aligns the seams (starting point of closed paths) in SlicePaths. + Supported modes: 'sharpest'. + """ + if not self or mode != "sharpest": + return self + + new_paths: list[np.ndarray] = [] + new_ext: list[np.ndarray] = [] + new_feed: list[np.ndarray] = [] + new_extra: dict[str, list[np.ndarray]] = {k: [] for k in self.extra_attributes} + + for i, p in enumerate(self): + if len(p) < 4 or not np.allclose(p[0], p[-1]): + new_paths.append(p) + new_ext.append(self.extrusion_rate[i]) + new_feed.append(self.feedrate[i]) + for k in self.extra_attributes: + new_extra[k].append(self.extra_attributes[k][i]) + continue + + pts = p[:-1] + M = len(pts) + if M < 3: + new_paths.append(p) + new_ext.append(self.extrusion_rate[i]) + new_feed.append(self.feedrate[i]) + for k in self.extra_attributes: + new_extra[k].append(self.extra_attributes[k][i]) + continue + + pts2d = pts[:, :2] + x = pts2d[:, 0] + y = pts2d[:, 1] + x_next = np.roll(x, -1) + y_next = np.roll(y, -1) + signed_area = 0.5 * np.sum(x * y_next - x_next * y) + + prev_pts = np.roll(pts2d, 1, axis=0) + next_pts = np.roll(pts2d, -1, axis=0) + + v_in = pts2d - prev_pts + v_out = next_pts - pts2d + + l_in = np.linalg.norm(v_in, axis=1) + l_out = np.linalg.norm(v_out, axis=1) + + valid = (l_in > 1e-6) & (l_out > 1e-6) + if not np.any(valid) or abs(signed_area) < 1e-9: + best_idx = 0 + else: + cross_2d = v_in[:, 0] * v_out[:, 1] - v_in[:, 1] * v_out[:, 0] + if signed_area > 0: + concave_mask = valid & (cross_2d < -1e-6) + else: + concave_mask = valid & (cross_2d > 1e-6) + + cos_angles = np.full(M, 1.0) + dot_prods = np.sum(v_in[valid] * v_out[valid], axis=1) + cos_angles[valid] = dot_prods / (l_in[valid] * l_out[valid]) + + if np.any(concave_mask): + concave_indices = np.where(concave_mask)[0] + best_idx = int(concave_indices[np.argmin(cos_angles[concave_mask])]) + else: + best_idx = int(np.argmin(cos_angles)) + + if best_idx == 0: + new_paths.append(p) + new_ext.append(self.extrusion_rate[i]) + new_feed.append(self.feedrate[i]) + for k in self.extra_attributes: + new_extra[k].append(self.extra_attributes[k][i]) + else: + new_p = np.vstack([p[best_idx:-1], p[:best_idx + 1]]) + new_paths.append(new_p) + + ext = self.extrusion_rate[i] + if len(ext) == len(p): + new_ext.append(np.concatenate([ext[best_idx:-1], ext[:best_idx + 1]])) + else: + new_ext.append(ext) + + feed = self.feedrate[i] + if len(feed) == len(p): + new_feed.append(np.concatenate([feed[best_idx:-1], feed[:best_idx + 1]])) + else: + new_feed.append(feed) + + for k in self.extra_attributes: + attr = self.extra_attributes[k][i] + if len(attr) == len(p): + new_extra[k].append(np.concatenate([attr[best_idx:-1], attr[:best_idx + 1]])) + else: + new_extra[k].append(attr) + + return self.copy_with(new_paths, extrusion_rate=new_ext, feedrate=new_feed, extra_attributes=new_extra) + def slice_at_range(models, z_values) -> list[list[np.ndarray]]: if not models: return [] @@ -568,6 +731,7 @@ def segments_to_slice(segments: np.ndarray) -> list[np.ndarray]: fpath_3d = np.column_stack([fpath, np.full(len(fpath), z)]) # Close loop by appending the first point for internal consistency fpath_3d = np.vstack([fpath_3d, fpath_3d[0]]) + fpath_3d = align_path_seam_sharpest(fpath_3d) res.append(fpath_3d) res.extend(open_paths) @@ -1227,6 +1391,7 @@ def buffer_slice(slice_paths: list[np.ndarray], distance: float, miter='round', fpath_3d = np.column_stack([fpath, np.full(len(fpath), z)]) # Re-close loop fpath_3d = np.vstack([fpath_3d, fpath_3d[0]]) + fpath_3d = align_path_seam_sharpest(fpath_3d) paths_list.append(fpath_3d) groups_list.append(group_id) @@ -1391,6 +1556,7 @@ class Pather(object): self.first_layer_height: float | None = None self._layer_width: float | None = None self.retraction: float = 0.0 + self.retraction_feedrate: float = 0.0 self.lift: float = 0.0 @property @@ -1434,6 +1600,14 @@ class Pather(object): dz = p2[2] - p1[2] return (dx * dx + dy * dy + dz * dz) ** 0.5 + try: + retraction_fr_val = float(self.retraction_feedrate) if self.retraction_feedrate is not None else 0.0 + except (TypeError, ValueError): + retraction_fr_val = 0.0 + + if retraction_fr_val <= 0.0: + retraction_fr_val = self.feedrate + gcode: list[str] = [] if include_header: @@ -1442,6 +1616,7 @@ class Pather(object): f";TYPE:Custom", f";DEFAULT_FEEDRATE={self.feedrate}", f";DEFAULT_TRAVEL_FEEDRATE={self.travel_feedrate}", + f";DEFAULT_RETRACTION_FEEDRATE={retraction_fr_val}", f";DEFAULT_EXTRUSION_RATE={self.extrusion_rate}", f";DEFAULT_LAYER_HEIGHT={self.layer_height:.6f}", ] @@ -1489,8 +1664,8 @@ class Pather(object): if i > 0: # 1. Retract if retraction is enabled if retraction_val > 0.0: - gcode.append(f"G1 E-{retraction_val:.5f} F1800") - active_feedrate = 1800.0 + gcode.append(f"G1 E-{retraction_val:.5f} F{retraction_fr_val:.3f}") + active_feedrate = retraction_fr_val # 2. Lift Z if lift is enabled if lift_val > 0.0: @@ -1515,8 +1690,8 @@ class Pather(object): # 5. Un-retract if retraction was enabled if retraction_val > 0.0: - gcode.append(f"G1 E{retraction_val:.5f} F1800") - active_feedrate = 1800.0 + 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: gcode.append(f";BEFORE_LAYER_CHANGE") diff --git a/doc/MODULES.md b/doc/MODULES.md index fe2936e..6402c8e 100644 --- a/doc/MODULES.md +++ b/doc/MODULES.md @@ -28,6 +28,8 @@ This file provides a mapping of modules to their primary responsibilities and ke - `slice.py`: - `slice_at_range()`: Top-level slicing function that iterates through Z-heights. - `slice_at_z()`: Vectorized plane-mesh intersection using NumPy. + - `align_path_seam_sharpest()`: High-performance vectorized heuristic that positions the start vertex (seam) of closed polygon loops at the sharpest concave angle (inner corner), with fallback to sharpest angle overall. + - `SlicePaths.align_seams()`: Method on `SlicePaths` to align path seams to sharpest concave angles while preserving per-vertex attributes and metadata. - `Pather`: Class that converts toolpaths into G-code strings. Supports per-segment extrusion rates, feedrates, and custom extra attributes. - `segments_to_slice()`: Utility to convert raw intersection segments into continuous paths (closed polygons are processed via Pyclipper, open paths remain open). - `link_segments()`: Logic for joining loose segments into continuous paths. diff --git a/doc/NODES.md b/doc/NODES.md index 298f485..e2ae202 100644 --- a/doc/NODES.md +++ b/doc/NODES.md @@ -55,7 +55,7 @@ All nodes are found in the **Add (Shift+A)** menu under the **SliceNodes** categ - **Offset X**: X translation offset for the build item. - **Offset Y**: Y translation offset for the build item. - **Offset Z**: Z translation offset for the build item. -- **Printer Intrinsics**: Configures bed size, extrusion/travel rates, retraction, and Z-lift. +- **Printer Intrinsics**: Configures bed size, extrusion/travel rates, retraction distance & feedrate, and Z-lift. - **Gcode Block**: Manual text area for entering raw G-code. ### Geometry Processing @@ -76,7 +76,7 @@ All nodes are found in the **Add (Shift+A)** menu under the **SliceNodes** categ - **Rotate Path**: Rotates `SlicePaths` around the Z-axis. - **Angle**: Rotation angle in degrees. - **Center**: Optional center of rotation. If not provided, it defaults to the midpoint of the first layer. -- **Slice Shells**: Slices a `SlicingObject` into 2D `SlicePaths` at a given layer height. Supports rotation for slicing at an angle. +- **Slice Shells**: Slices a `SlicingObject` into 2D `SlicePaths` at a given layer height. Automatically aligns path seams to the sharpest concave angle (inner corner) of each layer. Supports rotation for slicing at an angle. - **Buffer Shells**: Offsets/buffers 2D `SlicePaths`. Supports rotated offsetting and multiple miter types. - **Buffer Curve**: Buffers open/closed curves using Shapely, allowing open path offsetting. - **Cap Style**: Style of the curve ends (Round, Flat, Square). diff --git a/tests/test_slice.py b/tests/test_slice.py index 152dec2..51c403a 100644 --- a/tests/test_slice.py +++ b/tests/test_slice.py @@ -401,20 +401,28 @@ class TestSlice(unittest.TestCase): self.assertNotIn("E-", gcode) self.assertNotIn("Z0.700", gcode) # no lift - # Test with retraction and lift + # Test with retraction and lift (default retraction feedrate uses default feedrate 1200) pather = Pather([path1, path2]) pather.retraction = 1.2 pather.lift = 0.5 gcode = pather.to_gcode(include_header=False) - # Retraction should be present before travel - self.assertIn("G1 E-1.20000 F1800", gcode) + # Retraction should be present before travel using default feedrate 1200.0 + self.assertIn("G1 E-1.20000 F1200.000", gcode) # Lift should be present: 0.2 (last_nozzle_z) + 0.5 (lift) = 0.7 self.assertIn("G0 Z0.700 F720", gcode) # Un-lift back to 0.2 should be present self.assertIn("G0 Z0.200 F720", gcode) # Un-retraction should be present - self.assertIn("G1 E1.20000 F1800", gcode) + self.assertIn("G1 E1.20000 F1200.000", gcode) + + # Test with explicit retraction_feedrate + pather = Pather([path1, path2]) + pather.retraction = 1.2 + pather.retraction_feedrate = 1800.0 + gcode_rf = pather.to_gcode(include_header=False) + self.assertIn("G1 E-1.20000 F1800.000", gcode_rf) + self.assertIn("G1 E1.20000 F1800.000", gcode_rf) # Test with lift only (no retraction) pather = Pather([path1, path2]) @@ -594,5 +602,109 @@ class TestSlice(unittest.TestCase): self.assertEqual(getattr(extended, "first_layer_height", None), 0.3) self.assertEqual(getattr(extended, "custom_meta", None), "test_value") + def test_align_seam_sharpest_rectangle(self): + from NodeSlicer.core.slice import align_path_seam_sharpest + # Rectangle where index 0 is a midpoint on a flat edge (5, 0, 0) + path = np.array([ + [5.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, 0.0], + [5.0, 0.0, 0.0] + ]) + aligned = align_path_seam_sharpest(path) + self.assertEqual(len(aligned), len(path)) + self.assertTrue(np.allclose(aligned[0], aligned[-1])) + # Start point should be moved from flat edge (5,0,0) to one of the 90-degree corners + self.assertFalse(np.allclose(aligned[0, :2], [5.0, 0.0])) + corners = np.array([[10.0, 0.0], [10.0, 10.0], [0.0, 10.0], [0.0, 0.0]]) + is_corner = any(np.allclose(aligned[0, :2], c) for c in corners) + self.assertTrue(is_corner) + + def test_align_seam_sharpest_triangle(self): + from NodeSlicer.core.slice import align_path_seam_sharpest + # Right triangle with acute 30-degree vertex at (10, 0) + # Vertices: 90 deg at (0,0), 60 deg at (0, 5.7735), 30 deg at (10,0) + # Start at 90 deg vertex (0,0) + path = np.array([ + [0.0, 0.0, 0.0], + [0.0, 5.7735, 0.0], + [10.0, 0.0, 0.0], + [0.0, 0.0, 0.0] + ]) + aligned = align_path_seam_sharpest(path) + self.assertTrue(np.allclose(aligned[0], aligned[-1])) + # Sharpest angle is 30 degrees at (10.0, 0.0) + np.testing.assert_allclose(aligned[0, :2], [10.0, 0.0], atol=1e-3) + + def test_slicepaths_align_seams(self): + from NodeSlicer.core.slice import SlicePaths + path = np.array([ + [5.0, 0.0, 0.2], + [10.0, 0.0, 0.2], + [10.0, 10.0, 0.2], + [0.0, 10.0, 0.2], + [0.0, 0.0, 0.2], + [5.0, 0.0, 0.2] + ]) + ext_rates = np.array([1.0, 1.1, 1.2, 1.3, 1.4, 1.0]) + sp = SlicePaths([path], groups=[0], extrusion_rate=[ext_rates]) + sp.layer_height = 0.2 + + aligned_sp = sp.align_seams(mode="sharpest") + self.assertEqual(getattr(aligned_sp, "layer_height", None), 0.2) + self.assertEqual(len(aligned_sp[0]), len(path)) + self.assertTrue(np.allclose(aligned_sp[0][0], aligned_sp[0][-1])) + self.assertFalse(np.allclose(aligned_sp[0][0, :2], [5.0, 0.0])) + self.assertEqual(len(aligned_sp.extrusion_rate[0]), len(aligned_sp[0])) + + def test_align_seam_sharpest_concave_l_shape(self): + from NodeSlicer.core.slice import align_path_seam_sharpest + # L-shaped polygon in CCW order: + # Outer convex corners: (0,0), (10,0), (10,5), (5,10), (0,10) + # Inner concave corner: (5,5) + path = np.array([ + [0.0, 0.0, 0.0], + [10.0, 0.0, 0.0], + [10.0, 5.0, 0.0], + [5.0, 5.0, 0.0], + [5.0, 10.0, 0.0], + [0.0, 10.0, 0.0], + [0.0, 0.0, 0.0] + ]) + aligned = align_path_seam_sharpest(path) + self.assertEqual(len(aligned), len(path)) + self.assertTrue(np.allclose(aligned[0], aligned[-1])) + # Seam should be placed at the concave inner corner (5.0, 5.0) + np.testing.assert_allclose(aligned[0, :2], [5.0, 5.0]) + + def test_intrinsics_retraction_feedrate(self): + from NodeSlicer.core.nodeslicer.nodes.base import SliceIntrinsics + from NodeSlicer.core.nodeslicer.nodes.inputs import SLICENODE_InputIntrinsics + + # Test SliceIntrinsics default and custom retraction feedrate + intr = SliceIntrinsics(retraction_feedrate=1800.0) + self.assertEqual(intr.retraction_feedrate, 1800.0) + + intr_default = SliceIntrinsics() + self.assertEqual(intr_default.retraction_feedrate, 0.0) + + # Test Node evaluation + node = SLICENODE_InputIntrinsics() + values = { + "Bed X": 200.0, + "Bed Y": 200.0, + "Feedrate": 1500.0, + "Travel Feedrate": 3000.0, + "Extrusion Rate": 0.02, + "Retraction": 1.0, + "Retraction Feedrate": 1800.0, + "Lift": 0.2 + } + node.resolve = lambda name: values.get(name, 0.0) + res_intr = node.evaluate_output("SLICESOCK_Intrinsics") + self.assertEqual(res_intr.retraction_feedrate, 1800.0) + if __name__ == '__main__': unittest.main()