new buffer funcs

This commit is contained in:
Nicolas Predella 2026-07-02 17:55:26 +03:00
parent 0a8910d9cb
commit 6f5e74523a
5 changed files with 446 additions and 0 deletions

View file

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

View file

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

View file

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

View file

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

View file

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