new buffer funcs
This commit is contained in:
parent
0a8910d9cb
commit
6f5e74523a
5 changed files with 446 additions and 0 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
217
core/slice.py
217
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]]:
|
||||
|
||||
|
|
|
|||
10
doc/NODES.md
10
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.
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue