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)
|
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"
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
217
core/slice.py
217
core/slice.py
|
|
@ -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]]:
|
||||||
|
|
||||||
|
|
|
||||||
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.
|
- **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.
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue