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) 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 @register_class
class SLICENODE_PathOffset(SliceNode): class SLICENODE_PathOffset(SliceNode):
bl_label = "Path Offset" 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="Slice Shells").type = "SLICENODE_SliceShells"
layout.operator("node.add_node", text="Buffer Shells").type = "SLICENODE_BufferShells" 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 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="Path Offset").type = "SLICENODE_PathOffset"
layout.operator("node.add_node", text="Rotate Path").type = "SLICENODE_RotatePath" layout.operator("node.add_node", text="Rotate Path").type = "SLICENODE_RotatePath"
layout.operator("node.add_node", text="Simplify Paths").type = "SLICENODE_SimplifyPaths" 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 numpy as np
import pyclipper import pyclipper
from typing import Any, Literal from typing import Any, Literal
from shapely.geometry.base import BaseGeometry
from .geometry import ndarray_lru_cache from .geometry import ndarray_lru_cache
@ -84,6 +85,42 @@ class SlicePaths(list[np.ndarray]):
return new_paths 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: def infill(self, density: float, rotation_deg: float = 0.0, infill_type: str = 'GRID', layer_rotation: Any = None) -> SlicePaths:
"""Generates infill for the paths.""" """Generates infill for the paths."""
p = self p = self
@ -619,6 +656,186 @@ def boolean_slice(subject: SlicePaths, object: SlicePaths, operation: str = 'UNI
return results 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) @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]]: 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. - **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. Supports rotation for slicing at an angle.
- **Buffer Shells**: Offsets/buffers 2D `SlicePaths`. Supports rotated offsetting and multiple miter types. - **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**: Generates a cross-hatch grid or parallel lines clipped by boundary paths.
- **Infill Type**: Switch between **Grid** (cross-hatch) and **Rectilinear** (alternating parallel lines). - **Infill Type**: Switch between **Grid** (cross-hatch) and **Rectilinear** (alternating parallel lines).
- **Density**: Distance between lines in units. - **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]), self.assertFalse(np.allclose(sliced_path[0], sliced_path[-1]),
f"Path was incorrectly closed: {sliced_path}") 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__': if __name__ == '__main__':
unittest.main() unittest.main()