From 6f5e74523a1f0d3d40e3b82f9da78dec7e007536 Mon Sep 17 00:00:00 2001 From: Nicolas Predella Date: Thu, 2 Jul 2026 17:55:26 +0300 Subject: [PATCH] new buffer funcs --- core/nodeslicer/nodes/path.py | 98 +++++++++++++++ core/nodeslicer/tree.py | 2 + core/slice.py | 217 ++++++++++++++++++++++++++++++++++ doc/NODES.md | 10 ++ tests/test_slice.py | 119 +++++++++++++++++++ 5 files changed, 446 insertions(+) diff --git a/core/nodeslicer/nodes/path.py b/core/nodeslicer/nodes/path.py index 719247d..1174048 100644 --- a/core/nodeslicer/nodes/path.py +++ b/core/nodeslicer/nodes/path.py @@ -122,6 +122,104 @@ class SLICENODE_BufferShells(SliceNode): return paths.buffer(distance, miter=self.shape_option, rotation=rotation) +@register_class +class SLICENODE_BufferCurve(SliceNode): + bl_label = "Buffer Curve" + + cap_style: NfEnumProperty( + name="Cap Style", + items=[ + ('round', "Round", ""), + ('flat', "Flat", ""), + ('square', "Square", "") + ], + default='round' + ) # pyright: ignore[reportInvalidTypeForm] + + join_style: NfEnumProperty( + name="Join Style", + items=[ + ('round', "Round", ""), + ('mitre', "Mitre", ""), + ('bevel', "Bevel", "") + ], + default='round' + ) # pyright: ignore[reportInvalidTypeForm] + + single_sided: NfBoolProperty( + name="Single Sided", + description="Buffer only on one side of the curve", + default=False + ) # pyright: ignore[reportInvalidTypeForm] + + def init(self, context): + self.add_input("SLICESOCK_Paths") + self.add_input("SLICESOCK_Float", "Distance").value = 0.0 + self.add_input("SLICESOCK_VectorRotation", "Rotation") + self.add_output("SLICESOCK_Paths") + + def draw_buttons(self, context, layout): + assert layout + layout.prop(self, "cap_style") + layout.prop(self, "join_style") + layout.prop(self, "single_sided") + + def evaluate_output(self, socket_name: str, **_kwargs) -> SlicePaths: + if socket_name != "Paths": return SlicePaths() + + paths: SlicePaths = self.resolve("Paths") + distance: float = self.resolve("Distance") or 0.0 + rotation: Any = self.resolve("Rotation") or (0.0, 0.0, 0.0) + + if not paths: return SlicePaths() + + return paths.buffer_curve( + distance, + cap_style=self.cap_style, + join_style=self.join_style, + single_sided=self.single_sided, + rotation=rotation + ) + +@register_class +class SLICENODE_OffsetCurve(SliceNode): + bl_label = "Offset Curve" + + join_style: NfEnumProperty( + name="Join Style", + items=[ + ('round', "Round", ""), + ('mitre', "Mitre", ""), + ('bevel', "Bevel", "") + ], + default='round' + ) # pyright: ignore[reportInvalidTypeForm] + + def init(self, context): + self.add_input("SLICESOCK_Paths") + self.add_input("SLICESOCK_Float", "Distance").value = 0.0 + self.add_input("SLICESOCK_VectorRotation", "Rotation") + self.add_output("SLICESOCK_Paths") + + def draw_buttons(self, context, layout): + assert layout + layout.prop(self, "join_style") + + def evaluate_output(self, socket_name: str, **_kwargs) -> SlicePaths: + if socket_name != "Paths": return SlicePaths() + + paths: SlicePaths = self.resolve("Paths") + distance: float = self.resolve("Distance") or 0.0 + rotation: Any = self.resolve("Rotation") or (0.0, 0.0, 0.0) + + if not paths: return SlicePaths() + + return paths.offset_curve( + distance, + join_style=self.join_style, + rotation=rotation + ) + @register_class class SLICENODE_PathOffset(SliceNode): bl_label = "Path Offset" diff --git a/core/nodeslicer/tree.py b/core/nodeslicer/tree.py index 9aaed42..d1fba50 100644 --- a/core/nodeslicer/tree.py +++ b/core/nodeslicer/tree.py @@ -506,6 +506,8 @@ class SLICENODE_MT_Geometry(Menu): layout.operator("node.add_node", text="Slice Shells").type = "SLICENODE_SliceShells" layout.operator("node.add_node", text="Buffer Shells").type = "SLICENODE_BufferShells" layout.operator("node.add_node", text="Buffer Shells Multiple").type = "SLICENODE_BufferShellsMultiple" + layout.operator("node.add_node", text="Buffer Curve").type = "SLICENODE_BufferCurve" + layout.operator("node.add_node", text="Offset Curve").type = "SLICENODE_OffsetCurve" layout.operator("node.add_node", text="Path Offset").type = "SLICENODE_PathOffset" layout.operator("node.add_node", text="Rotate Path").type = "SLICENODE_RotatePath" layout.operator("node.add_node", text="Simplify Paths").type = "SLICENODE_SimplifyPaths" diff --git a/core/slice.py b/core/slice.py index 74c64ca..a027132 100644 --- a/core/slice.py +++ b/core/slice.py @@ -3,6 +3,7 @@ from numpy import float64 import numpy as np import pyclipper from typing import Any, Literal +from shapely.geometry.base import BaseGeometry from .geometry import ndarray_lru_cache @@ -84,6 +85,42 @@ class SlicePaths(list[np.ndarray]): return new_paths + def buffer_curve(self, distance: float, cap_style: str = 'round', join_style: str = 'round', single_sided: bool = False, rotation: Any = None) -> SlicePaths: + """Buffers open curves using shapely. Handles optional rotation for non-planar buffering.""" + p = self + if rotation and any(rotation): + from mathutils import Euler + mat = Euler(rotation).to_matrix().inverted() + p = self.transform(np.array(mat)) + + res_paths, res_groups = buffer_curve_slice(p, distance, cap_style=cap_style, join_style=join_style, single_sided=single_sided) + new_paths = self.copy_with(res_paths, res_groups) + + if rotation and any(rotation): + from mathutils import Euler + mat_inv = Euler(rotation).to_matrix() + new_paths = new_paths.transform(np.array(mat_inv)) + + return new_paths + + def offset_curve(self, distance: float, join_style: str = 'round', rotation: Any = None) -> SlicePaths: + """Offsets open/closed curves by a distance using shapely. Handles optional rotation.""" + p = self + if rotation and any(rotation): + from mathutils import Euler + mat = Euler(rotation).to_matrix().inverted() + p = self.transform(np.array(mat)) + + res_paths, res_groups = offset_curve_slice(p, distance, join_style=join_style) + new_paths = self.copy_with(res_paths, res_groups) + + if rotation and any(rotation): + from mathutils import Euler + mat_inv = Euler(rotation).to_matrix() + new_paths = new_paths.transform(np.array(mat_inv)) + + return new_paths + def infill(self, density: float, rotation_deg: float = 0.0, infill_type: str = 'GRID', layer_rotation: Any = None) -> SlicePaths: """Generates infill for the paths.""" p = self @@ -619,6 +656,186 @@ def boolean_slice(subject: SlicePaths, object: SlicePaths, operation: str = 'UNI return results +def geometry_to_paths(geom: BaseGeometry, z: float) -> list[np.ndarray]: + """Helper to convert shapely geometries to a list of 3D numpy arrays with a given Z height.""" + paths = [] + if geom.is_empty: + return paths + + if geom.geom_type == 'Polygon': + ext_coords = np.array(geom.exterior.coords) + ext_coords_3d = np.column_stack([ext_coords, np.full(len(ext_coords), z)]) + paths.append(ext_coords_3d) + for interior in geom.interiors: + int_coords = np.array(interior.coords) + int_coords_3d = np.column_stack([int_coords, np.full(len(int_coords), z)]) + paths.append(int_coords_3d) + elif geom.geom_type == 'MultiPolygon': + for poly in geom.geoms: + paths.extend(geometry_to_paths(poly, z)) + elif geom.geom_type == 'GeometryCollection': + for sub_geom in geom.geoms: + paths.extend(geometry_to_paths(sub_geom, z)) + + return paths + +@ndarray_lru_cache(maxsize=64) +def buffer_curve_slice( + slice_paths: list[np.ndarray], + distance: float, + cap_style: str = 'round', + join_style: str = 'round', + single_sided: bool = False +) -> tuple[list[np.ndarray], list[int]]: + if not slice_paths or distance <= 0: + return [], [] + + from shapely.geometry import LineString + + # Group paths by Z height to avoid flattening the entire model into 2D + groups: dict[int, list[np.ndarray]] = {} + for p in slice_paths: + z = p[0, 2] + # Use scaled integer for stable dictionary grouping of floats + z_key = int(round(z * 1000)) + groups.setdefault(z_key, []).append(p) + + paths_list: list[np.ndarray] = [] + groups_list: list[int] = [] + + group_id = 0 + for z_key in sorted(groups.keys()): + layer_paths = groups[z_key] + z = layer_paths[0][0, 2] + + for p in layer_paths: + if len(p) < 2: + continue + + pts_2d = p[:, :2] + line = LineString(pts_2d) + + poly = line.buffer( + distance, + cap_style=cap_style, + join_style=join_style, + single_sided=single_sided, + quad_segs=16 + ) + + layer_result_paths = geometry_to_paths(poly, z) + + for path in layer_result_paths: + paths_list.append(path) + groups_list.append(group_id) + + group_id += 1 + + return paths_list, groups_list + +def offset_geometry_to_paths(geom: BaseGeometry, z: float) -> list[np.ndarray]: + """Helper to convert shapely line geometries to a list of 3D numpy arrays with a given Z height.""" + paths = [] + if geom.is_empty: + return paths + + if geom.geom_type in ('LineString', 'LinearRing'): + coords = np.array(geom.coords) + coords_3d = np.column_stack([coords, np.full(len(coords), z)]) + paths.append(coords_3d) + elif geom.geom_type == 'MultiLineString': + for line in geom.geoms: + coords = np.array(line.coords) + coords_3d = np.column_stack([coords, np.full(len(coords), z)]) + paths.append(coords_3d) + elif geom.geom_type == 'GeometryCollection': + for sub_geom in geom.geoms: + paths.extend(offset_geometry_to_paths(sub_geom, z)) + elif geom.geom_type in ('Polygon', 'MultiPolygon'): + boundary = geom.boundary + paths.extend(offset_geometry_to_paths(boundary, z)) + return paths + +@ndarray_lru_cache(maxsize=64) +def offset_curve_slice( + slice_paths: list[np.ndarray], + distance: float, + join_style: str = 'round' +) -> tuple[list[np.ndarray], list[int]]: + if not slice_paths: + return [], [] + + from shapely.geometry import LineString, Polygon, LinearRing + + # Group paths by Z height + groups: dict[int, list[np.ndarray]] = {} + for p in slice_paths: + z = p[0, 2] + z_key = int(round(z * 1000)) + groups.setdefault(z_key, []).append(p) + + paths_list: list[np.ndarray] = [] + groups_list: list[int] = [] + + group_id = 0 + for z_key in sorted(groups.keys()): + layer_paths = groups[z_key] + z = layer_paths[0][0, 2] + + for p in layer_paths: + if len(p) < 2: + continue + + pts_2d = p[:, :2] + + # Check if path is closed + is_closed = len(pts_2d) > 2 and np.allclose(pts_2d[0], pts_2d[-1]) + + if is_closed: + try: + # Check direction/orientation + ccw = LinearRing(pts_2d).is_ccw + poly = Polygon(pts_2d) + if poly.is_valid: + # Use Polygon buffer which keeps boundary closed + buf_dist = distance if ccw else -distance + offset_geom = poly.buffer( + buf_dist, + join_style=join_style, + quad_segs=16 + ) + else: + line = LineString(pts_2d) + offset_geom = line.offset_curve( + distance, + join_style=join_style, + quad_segs=16 + ) + except Exception: + line = LineString(pts_2d) + offset_geom = line.offset_curve( + distance, + join_style=join_style, + quad_segs=16 + ) + else: + line = LineString(pts_2d) + offset_geom = line.offset_curve( + distance, + join_style=join_style, + quad_segs=16 + ) + + layer_result_paths = offset_geometry_to_paths(offset_geom, z) + + for path in layer_result_paths: + paths_list.append(path) + groups_list.append(group_id) + + group_id += 1 + + return paths_list, groups_list + @ndarray_lru_cache(maxsize=64) def buffer_slice(slice_paths: list[np.ndarray], distance: float, miter='round', tolerance=.01) -> tuple[list[np.ndarray], list[int]]: diff --git a/doc/NODES.md b/doc/NODES.md index 1b2849d..7e32d73 100644 --- a/doc/NODES.md +++ b/doc/NODES.md @@ -71,6 +71,16 @@ All nodes are found in the **Add (Shift+A)** menu under the **SliceNodes** categ - **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. - **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). + - **Join Style**: Style of corner joins (Round, Mitre, Bevel). + - **Single Sided**: Buffers only on one side of the curve if enabled. + - **Distance**: The buffer distance. + - **Rotation**: Optional Euler rotation for non-planar offsetting. +- **Offset Curve**: Offsets/shifts open/closed curves by a distance using Shapely, producing parallel curves. + - **Join Style**: Style of corner joins (Round, Mitre, Bevel). + - **Distance**: The offset distance (positive for left side, negative for right side). + - **Rotation**: Optional Euler rotation for non-planar offsetting. - **Infill**: Generates a cross-hatch grid or parallel lines clipped by boundary paths. - **Infill Type**: Switch between **Grid** (cross-hatch) and **Rectilinear** (alternating parallel lines). - **Density**: Distance between lines in units. diff --git a/tests/test_slice.py b/tests/test_slice.py index ba9799c..6866842 100644 --- a/tests/test_slice.py +++ b/tests/test_slice.py @@ -89,5 +89,124 @@ class TestSlice(unittest.TestCase): self.assertFalse(np.allclose(sliced_path[0], sliced_path[-1]), f"Path was incorrectly closed: {sliced_path}") + def test_buffer_curve_basic(self): + # Open path (line segment from (0,0,10) to (10,0,10)) + path = np.array([[0.0, 0.0, 10.0], [10.0, 0.0, 10.0]]) + from NodeSlicer.core.slice import SlicePaths + slice_paths = SlicePaths([path]) + + # Buffer curve with distance 1.0 + buffered = slice_paths.buffer_curve(1.0, cap_style='flat') + + # Output should be a SlicePaths instance + self.assertIsInstance(buffered, SlicePaths) + self.assertEqual(len(buffered), 1) + + # Check that the path is now closed (buffered area boundary) + buffered_path = buffered[0] + self.assertTrue(np.allclose(buffered_path[0], buffered_path[-1])) + + # Flat cap with distance 1.0 should result in a rectangle: + # width = 10 (from 0 to 10), height = 2 (from -1 to 1) -> area = 20 + # Let's compute 2D polygon area of the resulting path + xs = buffered_path[:, 0] + ys = buffered_path[:, 1] + # Shoelace formula for area + area = 0.5 * np.abs(np.dot(xs, np.roll(ys, 1)) - np.dot(ys, np.roll(xs, 1))) + self.assertAlmostEqual(area, 20.0, places=3) + + # Check Z coordinate is preserved + self.assertTrue(np.allclose(buffered_path[:, 2], 10.0)) + + def test_buffer_curve_cap_styles(self): + path = np.array([[0.0, 0.0, 5.0], [10.0, 0.0, 5.0]]) + from NodeSlicer.core.slice import SlicePaths + slice_paths = SlicePaths([path]) + + # Round cap + buffered_round = slice_paths.buffer_curve(1.0, cap_style='round') + # Flat cap + buffered_flat = slice_paths.buffer_curve(1.0, cap_style='flat') + # Square cap + buffered_square = slice_paths.buffer_curve(1.0, cap_style='square') + + # Area comparison: flat (20.0) < round (20.0 + pi) < square (24.0) + def get_area(p): + xs, ys = p[:, 0], p[:, 1] + return 0.5 * np.abs(np.dot(xs, np.roll(ys, 1)) - np.dot(ys, np.roll(xs, 1))) + + area_flat = get_area(buffered_flat[0]) + area_round = get_area(buffered_round[0]) + area_square = get_area(buffered_square[0]) + + self.assertAlmostEqual(area_flat, 20.0, places=3) + self.assertAlmostEqual(area_square, 24.0, places=3) + self.assertGreater(area_round, area_flat) + self.assertLess(area_round, area_square) + + def test_buffer_curve_single_sided(self): + path = np.array([[0.0, 0.0, 0.0], [10.0, 0.0, 0.0]]) + from NodeSlicer.core.slice import SlicePaths + slice_paths = SlicePaths([path]) + + buffered = slice_paths.buffer_curve(1.0, cap_style='flat', single_sided=True) + # For a single sided flat cap buffer, the rectangle area is distance * length = 1.0 * 10.0 = 10.0 + xs, ys = buffered[0][:, 0], buffered[0][:, 1] + area = 0.5 * np.abs(np.dot(xs, np.roll(ys, 1)) - np.dot(ys, np.roll(xs, 1))) + self.assertAlmostEqual(area, 10.0, places=3) + + def test_offset_curve_basic(self): + # Open path (line segment from (0,0,10) to (10,0,10)) + path = np.array([[0.0, 0.0, 10.0], [10.0, 0.0, 10.0]]) + from NodeSlicer.core.slice import SlicePaths + slice_paths = SlicePaths([path]) + + # Offset curve by 1.0 (should shift it to (0, 1, 10) -> (10, 1, 10)) + offset = slice_paths.offset_curve(1.0) + + self.assertIsInstance(offset, SlicePaths) + self.assertEqual(len(offset), 1) + + offset_path = offset[0] + # Should remain an open path (first and last coordinate not equal) + self.assertFalse(np.allclose(offset_path[0], offset_path[-1])) + + # Verify the shifted coordinates + self.assertTrue(np.allclose(offset_path[:, 1], 1.0)) + self.assertTrue(np.allclose(offset_path[:, 2], 10.0)) + self.assertTrue(np.allclose(offset_path[0, 0], 0.0)) + self.assertTrue(np.allclose(offset_path[1, 0], 10.0)) + + def test_offset_curve_closed_loop(self): + # Closed square path: (0,0) -> (0,10) -> (10,10) -> (10,0) -> (0,0) at Z=5.0 + path = np.array([ + [0.0, 0.0, 5.0], + [0.0, 10.0, 5.0], + [10.0, 10.0, 5.0], + [10.0, 0.0, 5.0], + [0.0, 0.0, 5.0] + ]) + from NodeSlicer.core.slice import SlicePaths + slice_paths = SlicePaths([path]) + + # Offset curve by +1.0 + # Positive distance in a clockwise loop should shift it inward (to a square of size 8.0) + offset_in = slice_paths.offset_curve(1.0, join_style='mitre') + + self.assertIsInstance(offset_in, SlicePaths) + self.assertEqual(len(offset_in), 1) + + offset_path = offset_in[0] + # Should remain perfectly closed (first and last coordinate equal) + self.assertTrue(np.allclose(offset_path[0], offset_path[-1]), f"Offset path was not closed: {offset_path}") + + # Calculate the area of the offset path + xs, ys = offset_path[:, 0], offset_path[:, 1] + area = 0.5 * np.abs(np.dot(xs, np.roll(ys, 1)) - np.dot(ys, np.roll(xs, 1))) + self.assertAlmostEqual(area, 64.0, places=3) + + # Check Z coordinate is preserved + self.assertTrue(np.allclose(offset_path[:, 2], 5.0)) + if __name__ == '__main__': unittest.main()