retraction feedrate + seam alignment

This commit is contained in:
Nicolas Predella 2026-07-24 14:56:38 +03:00
parent e3e2335754
commit 98c9032f8f
8 changed files with 314 additions and 12 deletions

View file

@ -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 = ""

View file

@ -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)

View file

@ -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
)

View file

@ -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

View file

@ -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")

View file

@ -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.

View file

@ -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).

View file

@ -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()