This commit is contained in:
Nicolas Predella 2026-10-14 18:59:00 +03:00
parent 1604e6da23
commit 2693a54ef1
10 changed files with 960 additions and 0 deletions

View file

@ -936,3 +936,62 @@ class SLICENODE_SeamPlacement(SliceNode):
)
@register_class
class SLICENODE_AnchorPaths(SliceNode):
bl_label = "Anchor Paths"
list_socket_mapping = [("Paths", "Paths")]
reverse: bool = NfBoolProperty(
name="Reverse",
description="Follow anchor paths in the opposite direction",
default=False,
update=notify_tree_changed,
)
distance: float = NfFloatProperty(
name="Distance",
description="Distance to follow along anchor paths",
default=2.0,
min=0.0,
update=notify_tree_changed,
)
def init(self, context):
self.add_input("SLICESOCK_Paths", "Paths")
self.add_input("SLICESOCK_Paths", "Anchor Paths")
self.add_input("NodeSocketFloat", "Distance").value = 2.0
self.add_input("NodeSocketBool", "Reverse").value = False
self.add_output("SLICESOCK_Paths", "Paths")
def draw_buttons(self, context, layout):
assert layout
layout.prop(self, "reverse")
def evaluate_output(self, socket_name: str, **_kwargs) -> Any:
rev_prop = bool(getattr(self, "reverse", False))
def _anchor_one(paths: Any, anchor_paths: Any, dist_val: Any, rev_val: Any) -> SlicePaths:
if not paths:
return SlicePaths()
if not isinstance(paths, SlicePaths) and not getattr(paths, "is_slice_paths", False):
return SlicePaths()
if not anchor_paths:
return paths
d = float(dist_val if dist_val is not None else getattr(self, "distance", 2.0))
is_rev = bool(rev_val) if rev_val is not None else rev_prop
if hasattr(self, "inputs"):
rev_sock = self.inputs.get("Reverse")
if rev_sock is not None and not getattr(rev_sock, "is_linked", False):
is_rev = bool(getattr(rev_sock, "value", rev_prop)) or rev_prop
return paths.anchor(anchor_paths, distance=d, reverse=is_rev)
return self.eval_multivalue(
_anchor_one,
main_socket="Paths",
secondary_sockets=["Anchor Paths", "Distance", "Reverse"],
socket_name=socket_name,
default=SlicePaths(),
)

View file

@ -827,6 +827,7 @@ class SLICENODE_MT_Geometry(Menu):
layout.operator("node.add_node", text="Overhang Mask").type = "SLICENODE_OverhangMask"
layout.operator("node.add_node", text="Path Operations").type = "SLICENODE_PathOperations"
layout.operator("node.add_node", text="Flatten Path List").type = "SLICENODE_FlattenPathList"
layout.operator("node.add_node", text="Anchor Paths").type = "SLICENODE_AnchorPaths"
layout.separator()
layout.operator("node.add_node", text="Math").type = "SLICENODE_FloatMath"
layout.operator("node.add_node", text="Integer Math").type = "SLICENODE_IntMath"

View file

@ -1903,6 +1903,288 @@ class SlicePaths(list[np.ndarray]):
extra_attributes=res_extra # type: ignore[arg-type]
)
def anchor(self, target_paths: SlicePaths | list[np.ndarray], distance: float, reverse: bool = False, tolerance: float = 0.05) -> SlicePaths:
"""
Extends open paths in this SlicePaths where their start and/or end vertices touch target_paths
by following target_paths for the specified distance (in reverse if reverse=True).
"""
return anchor_paths(self, target_paths, distance=distance, reverse=reverse, tolerance=tolerance)
def _find_touching_path(
v: np.ndarray,
anchor_paths: list[np.ndarray],
tolerance: float = 0.05,
layer_tolerance: float = 0.02
) -> tuple[int, int, float, np.ndarray] | None:
best_dist = float("inf")
best_res = None
v_z = float(v[2]) if len(v) > 2 else 0.0
for c_idx, c in enumerate(anchor_paths):
if len(c) < 2:
continue
c_z = float(c[0, 2]) if c.shape[1] > 2 else 0.0
if len(v) > 2 and c.shape[1] > 2:
if abs(c_z - v_z) > layer_tolerance:
continue
A = c[:-1, :2]
B = c[1:, :2]
AB = B - A
L_sq = (AB[:, 0] ** 2 + AB[:, 1] ** 2)
valid = L_sq > 1e-12
if not np.any(valid):
continue
AV = v[:2] - A
t = np.clip((AV[:, 0] * AB[:, 0] + AV[:, 1] * AB[:, 1]) / np.maximum(L_sq, 1e-12), 0.0, 1.0)
P_proj = A + t[:, None] * AB
diff = v[:2] - P_proj
dists = np.sqrt(diff[:, 0] ** 2 + diff[:, 1] ** 2)
min_idx = int(np.argmin(dists))
min_d = float(dists[min_idx])
if min_d < best_dist:
best_dist = min_d
best_res = (
c_idx,
min_idx,
float(t[min_idx]),
np.array([P_proj[min_idx, 0], P_proj[min_idx, 1], v_z], dtype=np.float64)
)
if best_dist <= tolerance:
return best_res
return None
def _walk_along_path(
c: np.ndarray,
seg_idx: int,
t: float,
p_touch: np.ndarray,
distance: float,
dir_walk: int,
is_closed: bool,
z_val: float
) -> list[np.ndarray]:
K = len(c) - 1
if K <= 0 or distance <= 1e-7:
return []
D_rem = float(distance)
pts: list[np.ndarray] = []
curr_idx = seg_idx
max_iter = max(K * 3, 200)
iter_cnt = 0
if dir_walk == 1:
target = c[curr_idx + 1]
vec = target[:2] - p_touch[:2]
L = float(np.linalg.norm(vec))
if L > 1e-6:
if D_rem <= L:
q = p_touch[:2] + (D_rem / L) * vec
pts.append(np.array([q[0], q[1], z_val], dtype=np.float64))
D_rem = 0.0
else:
pts.append(np.array([target[0], target[1], z_val], dtype=np.float64))
D_rem -= L
curr_idx += 1
else:
curr_idx += 1
while D_rem > 1e-6 and iter_cnt < max_iter:
iter_cnt += 1
if is_closed:
curr_idx = curr_idx % K
else:
if curr_idx >= K:
break
p_start = c[curr_idx]
target = c[curr_idx + 1]
vec = target[:2] - p_start[:2]
L = float(np.linalg.norm(vec))
if L <= 1e-6:
curr_idx += 1
continue
if D_rem <= L:
q = p_start[:2] + (D_rem / L) * vec
pts.append(np.array([q[0], q[1], z_val], dtype=np.float64))
D_rem = 0.0
break
else:
pts.append(np.array([target[0], target[1], z_val], dtype=np.float64))
D_rem -= L
curr_idx += 1
else:
target = c[curr_idx]
vec = target[:2] - p_touch[:2]
L = float(np.linalg.norm(vec))
if L > 1e-6:
if D_rem <= L:
q = p_touch[:2] + (D_rem / L) * vec
pts.append(np.array([q[0], q[1], z_val], dtype=np.float64))
D_rem = 0.0
else:
pts.append(np.array([target[0], target[1], z_val], dtype=np.float64))
D_rem -= L
curr_idx -= 1
else:
curr_idx -= 1
while D_rem > 1e-6 and iter_cnt < max_iter:
iter_cnt += 1
if is_closed:
curr_idx = curr_idx % K
else:
if curr_idx < 0:
break
p_start = c[curr_idx + 1]
target = c[curr_idx]
vec = target[:2] - p_start[:2]
L = float(np.linalg.norm(vec))
if L <= 1e-6:
curr_idx -= 1
continue
if D_rem <= L:
q = p_start[:2] + (D_rem / L) * vec
pts.append(np.array([q[0], q[1], z_val], dtype=np.float64))
D_rem = 0.0
break
else:
pts.append(np.array([target[0], target[1], z_val], dtype=np.float64))
D_rem -= L
curr_idx -= 1
clean_pts: list[np.ndarray] = []
for pt in pts:
if not clean_pts or np.linalg.norm(pt[:2] - clean_pts[-1][:2]) > 1e-5:
clean_pts.append(pt)
return clean_pts
def anchor_paths(
paths: SlicePaths,
anchor_paths: SlicePaths | list[np.ndarray],
distance: float,
reverse: bool = False,
tolerance: float = 0.05
) -> SlicePaths:
"""
Extends open paths in paths where their start/end vertices touch anchor_paths
by following the anchor path for the specified distance.
If reverse is True, follows the anchor path in the reverse direction.
Closed paths and open paths that do not touch anchor_paths remain untouched.
"""
if not paths or not anchor_paths or distance <= 1e-7:
return paths if isinstance(paths, SlicePaths) else SlicePaths(paths or [])
raw_anchors = [p for p in anchor_paths if len(p) >= 2]
if not raw_anchors:
return paths
out_paths: list[np.ndarray] = []
out_extrusion: list[np.ndarray] = []
out_feedrate: list[np.ndarray] = []
out_extra: dict[str, list[np.ndarray]] = {k: [] for k in getattr(paths, "extra_attributes", {})}
for i, orig_p in enumerate(paths):
if len(orig_p) < 2 or is_closed_path(orig_p):
out_paths.append(orig_p)
out_extrusion.append(paths.extrusion_rate[i])
out_feedrate.append(paths.feedrate[i])
for k in out_extra:
out_extra[k].append(paths.extra_attributes[k][i])
continue
p = orig_p.copy()
prefix: list[np.ndarray] = []
postfix: list[np.ndarray] = []
# Check start vertex p[0]
touch_start = _find_touching_path(p[0], raw_anchors, tolerance=tolerance)
if touch_start is not None:
c_idx, seg_idx, t_val, p_touch = touch_start
c = raw_anchors[c_idx]
is_c_closed = is_closed_path(c)
z_val = float(p[0, 2]) if p.shape[1] > 2 else 0.0
p_touch[2] = z_val
p[0] = p_touch.copy()
dir_from_p = 1 if reverse else -1
walked = _walk_along_path(c, seg_idx, t_val, p_touch, distance, dir_from_p, is_c_closed, z_val)
walked = [pt for pt in walked if np.linalg.norm(pt[:2] - p[0, :2]) > 1e-5]
if walked:
prefix = walked[::-1]
# Check end vertex p[-1]
touch_end = _find_touching_path(p[-1], raw_anchors, tolerance=tolerance)
if touch_end is not None:
c_idx, seg_idx, t_val, p_touch = touch_end
c = raw_anchors[c_idx]
is_c_closed = is_closed_path(c)
z_val = float(p[-1, 2]) if p.shape[1] > 2 else 0.0
p_touch[2] = z_val
p[-1] = p_touch.copy()
dir_from_p = -1 if reverse else 1
walked = _walk_along_path(c, seg_idx, t_val, p_touch, distance, dir_from_p, is_c_closed, z_val)
walked = [pt for pt in walked if np.linalg.norm(pt[:2] - p[-1, :2]) > 1e-5]
if walked:
postfix = walked
if not prefix and not postfix:
out_paths.append(orig_p)
out_extrusion.append(paths.extrusion_rate[i])
out_feedrate.append(paths.feedrate[i])
for k in out_extra:
out_extra[k].append(paths.extra_attributes[k][i])
continue
pieces: list[np.ndarray] = []
if prefix:
pieces.append(np.array(prefix, dtype=orig_p.dtype))
pieces.append(p)
if postfix:
pieces.append(np.array(postfix, dtype=orig_p.dtype))
new_p = np.vstack(pieces)
K_pre = len(prefix)
K_post = len(postfix)
orig_ext = paths.extrusion_rate[i]
new_ext = np.concatenate([
np.full(K_pre, orig_ext[0], dtype=orig_ext.dtype),
orig_ext,
np.full(K_post, orig_ext[-1], dtype=orig_ext.dtype)
])
orig_feed = paths.feedrate[i]
new_feed = np.concatenate([
np.full(K_pre, orig_feed[0], dtype=orig_feed.dtype),
orig_feed,
np.full(K_post, orig_feed[-1], dtype=orig_feed.dtype)
])
out_paths.append(new_p)
out_extrusion.append(new_ext)
out_feedrate.append(new_feed)
for k in out_extra:
orig_attr = paths.extra_attributes[k][i]
new_attr = np.concatenate([
np.full(K_pre, orig_attr[0], dtype=orig_attr.dtype),
orig_attr,
np.full(K_post, orig_attr[-1], dtype=orig_attr.dtype)
])
out_extra[k].append(new_attr)
return paths.copy_with(
out_paths,
extrusion_rate=out_extrusion,
feedrate=out_feedrate,
extra_attributes=out_extra
)
def _slice_single_mesh(mesh_array: np.ndarray, z_values: np.ndarray | list[float], ignore_holes: bool = False) -> list[np.ndarray]:
"""

View file

@ -137,6 +137,12 @@ All nodes are found in the **Add (Shift+A)** menu under the **SliceNodes** categ
- **Infill Rotation**: Base rotation of the infill pattern.
- **Layer Rotation**: Rotation of the entire slice to align infill with specific planes.
- **Overhang Mask**: Computes the accumulated overhang mask for each layer, propagating support requirements from top to bottom.
- **Anchor Paths**: Anchors open toolpaths onto boundary or perimeter paths by extending endpoints along touching anchor paths.
- **Paths**: Input toolpaths (`SlicePaths` atom/list).
- **Anchor Paths**: Target boundary or perimeter paths (`SlicePaths` atom/list).
- **Distance**: Distance (float in mm) to follow along the anchor path at touching endpoints.
- **Reverse**: Boolean toggle to follow the anchor path in the reverse traversal direction.
- **Paths**: Output toolpaths with touching open endpoints extended along the anchor geometry.
- **Path Operations**: Multi-input joiner node performing composite geometric, boolean, and interleaving operations on `SlicePaths`.
- **Operations**:
- **Zip paths**: Interleaves paths from all inputs layer by layer.

269
tests/test_anchor_paths.py Normal file
View file

@ -0,0 +1,269 @@
import unittest
import numpy as np
import sys
from pathlib import Path
from unittest.mock import MagicMock
PROJECT_ROOT = Path(__file__).resolve().parent.parent
sys.path.append(str(PROJECT_ROOT.parent))
sys.path.append(str(PROJECT_ROOT))
class DummyNodeSocket:
def __init__(self, name="", identifier="", node=None, value=None):
self.name = name
self.identifier = identifier
self.node = node
self.value = value
self.is_linked = False
self.links = []
self.display_shape = "CIRCLE"
self.default_value = value
class DummyNode: pass
class DummyObject: pass
class DummyMesh: pass
class DummyNodeSocketShader: pass
class DummyNodeCustomGroup: pass
class DummyNodeTree: pass
class DummyOperator: pass
class DummyMenu: pass
class DummyPanel: pass
class DummyPropertyGroup: pass
mock_types = MagicMock()
mock_types.NodeSocket = DummyNodeSocket
mock_types.Node = DummyNode
mock_types.Object = DummyObject
mock_types.Mesh = DummyMesh
mock_types.NodeSocketShader = DummyNodeSocketShader
mock_types.NodeCustomGroup = DummyNodeCustomGroup
mock_types.NodeTree = DummyNodeTree
mock_types.Operator = DummyOperator
mock_types.Menu = DummyMenu
mock_types.Panel = DummyPanel
mock_types.PropertyGroup = DummyPropertyGroup
mock_bpy = MagicMock()
mock_bpy.context.scene = MagicMock()
mock_bpy.context.scene.unit_settings.scale_length = 0.001
mock_bpy.types = mock_types
sys.modules['bpy'] = mock_bpy
sys.modules['bpy.types'] = mock_types
sys.modules['bpy.props'] = MagicMock()
sys.modules['bpy.utils'] = MagicMock()
sys.modules['bpy.utils.previews'] = MagicMock()
sys.modules['bpy.app'] = MagicMock()
sys.modules['bpy.app.handlers'] = MagicMock()
from NodeSlicer.core.slice import SlicePaths, is_closed_path, anchor_paths
from NodeSlicer.core.slice import SlicePaths, is_closed_path, anchor_paths
from NodeSlicer.core.nodeslicer.nodes.path import SLICENODE_AnchorPaths
class DummySocketCollection(list):
def get(self, name, default=None):
for s in self:
if getattr(s, "name", "") == name:
return s
return default
class TestAnchorPaths(unittest.TestCase):
def setUp(self):
# 10x10 CCW square boundary:
# (0,0) -> (10,0) [bottom, East]
# (10,0) -> (10,10) [right, North]
# (10,10) -> (0,10) [top, West]
# (0,10) -> (0,0) [left, South]
self.square = np.array([
[0.0, 0.0, 0.2],
[10.0, 0.0, 0.2],
[10.0, 10.0, 0.2],
[0.0, 10.0, 0.2],
[0.0, 0.0, 0.2],
], dtype=np.float64)
self.anchor_sp = SlicePaths([self.square])
def test_anchor_both_ends_forward(self):
# Infill line from (5,0) to (5,10)
infill = np.array([[5.0, 0.0, 0.2], [5.0, 10.0, 0.2]], dtype=np.float64)
sp = SlicePaths([infill])
res = sp.anchor(self.anchor_sp, distance=2.0, reverse=False)
self.assertEqual(len(res), 1)
p = res[0]
# reverse=False follows anchor in forward (+1) direction:
# Start vertex (5,0) touches bottom edge (0->10).
# Forward is +X, so nozzle enters from (3,0) to (5,0).
# End vertex (5,10) touches top edge (10->0).
# Forward is -X, so nozzle exits from (5,10) to (3,10).
self.assertEqual(len(p), 4)
np.testing.assert_allclose(p[0], [3.0, 0.0, 0.2], atol=1e-5)
np.testing.assert_allclose(p[1], [5.0, 0.0, 0.2], atol=1e-5)
np.testing.assert_allclose(p[2], [5.0, 10.0, 0.2], atol=1e-5)
np.testing.assert_allclose(p[3], [3.0, 10.0, 0.2], atol=1e-5)
def test_anchor_both_ends_reverse(self):
infill = np.array([[5.0, 0.0, 0.2], [5.0, 10.0, 0.2]], dtype=np.float64)
sp = SlicePaths([infill])
res = sp.anchor(self.anchor_sp, distance=2.0, reverse=True)
self.assertEqual(len(res), 1)
p = res[0]
# reverse=True follows anchor in opposite (-1) direction:
# Start vertex (5,0): opposite of +X is -X, so nozzle enters from (7,0) to (5,0).
# End vertex (5,10): opposite of -X is +X, so nozzle exits from (5,10) to (7,10).
self.assertEqual(len(p), 4)
np.testing.assert_allclose(p[0], [7.0, 0.0, 0.2], atol=1e-5)
np.testing.assert_allclose(p[1], [5.0, 0.0, 0.2], atol=1e-5)
np.testing.assert_allclose(p[2], [5.0, 10.0, 0.2], atol=1e-5)
np.testing.assert_allclose(p[3], [7.0, 10.0, 0.2], atol=1e-5)
def test_anchor_start_only(self):
# Infill touching only at start: (5,0) to (5,5)
infill = np.array([[5.0, 0.0, 0.2], [5.0, 5.0, 0.2]], dtype=np.float64)
sp = SlicePaths([infill])
res = sp.anchor(self.anchor_sp, distance=2.0, reverse=False)
self.assertEqual(len(res), 1)
p = res[0]
# Only start prepended
self.assertEqual(len(p), 3)
np.testing.assert_allclose(p[0], [3.0, 0.0, 0.2], atol=1e-5)
np.testing.assert_allclose(p[1], [5.0, 0.0, 0.2], atol=1e-5)
np.testing.assert_allclose(p[2], [5.0, 5.0, 0.2], atol=1e-5)
def test_anchor_end_only(self):
# Infill touching only at end: (5,5) to (5,10)
infill = np.array([[5.0, 5.0, 0.2], [5.0, 10.0, 0.2]], dtype=np.float64)
sp = SlicePaths([infill])
res = sp.anchor(self.anchor_sp, distance=2.0, reverse=False)
self.assertEqual(len(res), 1)
p = res[0]
# Only end appended
self.assertEqual(len(p), 3)
np.testing.assert_allclose(p[0], [5.0, 5.0, 0.2], atol=1e-5)
np.testing.assert_allclose(p[1], [5.0, 10.0, 0.2], atol=1e-5)
np.testing.assert_allclose(p[2], [3.0, 10.0, 0.2], atol=1e-5)
def test_anchor_closed_path_ignored(self):
# A closed square inside
closed_box = np.array([
[2.0, 2.0, 0.2],
[4.0, 2.0, 0.2],
[4.0, 4.0, 0.2],
[2.0, 4.0, 0.2],
[2.0, 2.0, 0.2],
], dtype=np.float64)
sp = SlicePaths([closed_box])
res = sp.anchor(self.anchor_sp, distance=2.0)
self.assertEqual(len(res), 1)
np.testing.assert_allclose(res[0], closed_box)
def test_anchor_corner_turn(self):
# Infill starts at (1, 0, 0.2) and goes to (1, 10, 0.2)
# Distance = 3.0, reverse = False
# Bottom edge is (0,0) -> (10,0).
# Walking backward (-1) from (1,0) reaches (0,0) after 1.0mm.
# Remaining 2.0mm continues onto left edge (0,10) -> (0,0).
# Walking backward along left edge means going South from (0,0) towards (0,10),
# which reaches (0, 2, 0.2).
# Prefix reversed: [(0, 2), (0, 0)]
infill = np.array([[1.0, 0.0, 0.2], [1.0, 10.0, 0.2]], dtype=np.float64)
sp = SlicePaths([infill])
res = sp.anchor(self.anchor_sp, distance=3.0, reverse=False)
p = res[0]
np.testing.assert_allclose(p[0], [0.0, 2.0, 0.2], atol=1e-5)
np.testing.assert_allclose(p[1], [0.0, 0.0, 0.2], atol=1e-5)
np.testing.assert_allclose(p[2], [1.0, 0.0, 0.2], atol=1e-5)
def test_anchor_layer_mismatch_ignored(self):
# Infill on layer Z = 0.4, anchor on layer Z = 0.2
infill = np.array([[5.0, 0.0, 0.4], [5.0, 10.0, 0.4]], dtype=np.float64)
sp = SlicePaths([infill])
res = sp.anchor(self.anchor_sp, distance=2.0)
# Untouched because layer heights do not match
self.assertEqual(len(res[0]), 2)
np.testing.assert_allclose(res[0], infill)
def test_anchor_attributes_preservation(self):
infill = np.array([[5.0, 0.0, 0.2], [5.0, 10.0, 0.2]], dtype=np.float64)
ext = np.array([0.8, 1.2])
feed = np.array([1200.0, 1500.0])
extra = {"S": [np.array([10.0, 20.0])]}
sp = SlicePaths([infill], extrusion_rate=[ext], feedrate=[feed], extra_attributes=extra)
res = sp.anchor(self.anchor_sp, distance=2.0, reverse=False)
self.assertEqual(len(res[0]), 4)
self.assertEqual(len(res.extrusion_rate[0]), 4)
self.assertEqual(len(res.feedrate[0]), 4)
self.assertEqual(len(res.extra_attributes["S"][0]), 4)
# Start anchor inherited from start vertex
self.assertAlmostEqual(res.extrusion_rate[0][0], 0.8)
self.assertAlmostEqual(res.feedrate[0][0], 1200.0)
self.assertAlmostEqual(res.extra_attributes["S"][0][0], 10.0)
# End anchor inherited from end vertex
self.assertAlmostEqual(res.extrusion_rate[0][-1], 1.2)
self.assertAlmostEqual(res.feedrate[0][-1], 1500.0)
self.assertAlmostEqual(res.extra_attributes["S"][0][-1], 20.0)
def test_node_atomic_and_list_eval(self):
node = SLICENODE_AnchorPaths()
in_paths = DummyNodeSocket("Paths", "SLICESOCK_Paths", node=node)
in_anchors = DummyNodeSocket("Anchor Paths", "SLICESOCK_Paths", node=node)
in_dist = DummyNodeSocket("Distance", "NodeSocketFloat", node=node, value=2.0)
in_rev = DummyNodeSocket("Reverse", "NodeSocketBool", node=node, value=False)
out_paths = DummyNodeSocket("Paths", "SLICESOCK_Paths", node=node)
node.inputs = DummySocketCollection([in_paths, in_anchors, in_dist, in_rev])
node.outputs = DummySocketCollection([out_paths])
infill1 = np.array([[5.0, 0.0, 0.2], [5.0, 10.0, 0.2]], dtype=np.float64)
sp1 = SlicePaths([infill1])
# 1. Atomic eval
in_paths.value = sp1
in_anchors.value = self.anchor_sp
res_atom = node.evaluate_output("Paths")
self.assertIsInstance(res_atom, SlicePaths)
self.assertEqual(len(res_atom[0]), 4)
# 2. List eval (Paths is a list of 2 SlicePaths, Anchor is atomic)
infill2 = np.array([[3.0, 0.0, 0.2], [3.0, 10.0, 0.2]], dtype=np.float64)
sp2 = SlicePaths([infill2])
in_paths.value = [sp1, sp2]
res_list = node.evaluate_output("Paths")
self.assertIsInstance(res_list, list)
self.assertEqual(len(res_list), 2)
self.assertEqual(len(res_list[0][0]), 4)
self.assertEqual(len(res_list[1][0]), 4)
# 3. Reverse toggle via property
node.reverse = True
in_rev.value = True
res_rev = node.evaluate_output("Paths")
np.testing.assert_allclose(res_rev[0][0][0], [7.0, 0.0, 0.2], atol=1e-5)
def test_visual_generation(self):
import os
from NodeSlicer.tests.visual.visualize_anchor_paths import run_all_anchor_visualizations
images = run_all_anchor_visualizations()
self.assertEqual(len(images), 4)
for img in images:
self.assertTrue(os.path.exists(img), f"Missing image {img}")
self.assertGreater(os.path.getsize(img), 1000, f"Image {img} too small")
if __name__ == '__main__':
unittest.main()

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

View file

@ -0,0 +1,343 @@
import os
import sys
from pathlib import Path
import numpy as np
import matplotlib.pyplot as plt
from matplotlib.patches import FancyArrowPatch
# Add project root to sys.path
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
sys.path.append(str(PROJECT_ROOT.parent))
sys.path.append(str(PROJECT_ROOT))
# Setup dummy bpy classes before importing NodeSlicer
from unittest.mock import MagicMock
class DummyNodeSocket: pass
class DummyNode: pass
class DummyObject: pass
class DummyMesh: pass
class DummyNodeSocketShader: pass
class DummyNodeCustomGroup: pass
class DummyNodeTree: pass
class DummyOperator: pass
class DummyMenu: pass
class DummyPanel: pass
class DummyPropertyGroup: pass
mock_types = MagicMock()
mock_types.NodeSocket = DummyNodeSocket
mock_types.Node = DummyNode
mock_types.Object = DummyObject
mock_types.Mesh = DummyMesh
mock_types.NodeSocketShader = DummyNodeSocketShader
mock_types.NodeCustomGroup = DummyNodeCustomGroup
mock_types.NodeTree = DummyNodeTree
mock_types.Operator = DummyOperator
mock_types.Menu = DummyMenu
mock_types.Panel = DummyPanel
mock_types.PropertyGroup = DummyPropertyGroup
mock_bpy = MagicMock()
mock_bpy.context.scene = MagicMock()
mock_bpy.context.scene.unit_settings.scale_length = 0.001
mock_bpy.types = mock_types
sys.modules['bpy'] = mock_bpy
sys.modules['bpy.types'] = mock_types
sys.modules['bpy.props'] = MagicMock()
sys.modules['bpy.utils'] = MagicMock()
sys.modules['bpy.utils.previews'] = MagicMock()
sys.modules['bpy.app'] = MagicMock()
sys.modules['bpy.app.handlers'] = MagicMock()
from NodeSlicer.core.slice import SlicePaths, is_closed_path
VISUAL_DIR = Path(__file__).resolve().parent
IMAGES_DIR = VISUAL_DIR / "images"
IMAGES_DIR.mkdir(parents=True, exist_ok=True)
def draw_path_with_arrows(ax, path, color, label=None, linewidth=2.0, linestyle='-', marker='o', markersize=4, alpha=1.0):
xs = path[:, 0]
ys = path[:, 1]
line, = ax.plot(xs, ys, color=color, linewidth=linewidth, linestyle=linestyle, marker=marker, markersize=markersize, alpha=alpha, label=label)
# Add small directional arrows along segments
for i in range(len(path) - 1):
x0, y0 = path[i, 0], path[i, 1]
x1, y1 = path[i + 1, 0], path[i + 1, 1]
dx = x1 - x0
dy = y1 - y0
seg_len = np.hypot(dx, dy)
if seg_len > 0.5:
mid_x = (x0 + x1) / 2.0
mid_y = (y0 + y1) / 2.0
arrow_dx = (dx / seg_len) * 0.4
arrow_dy = (dy / seg_len) * 0.4
ax.annotate(
"",
xy=(mid_x + arrow_dx, mid_y + arrow_dy),
xytext=(mid_x - arrow_dx, mid_y - arrow_dy),
arrowprops=dict(arrowstyle="->", color=color, lw=1.5, mutation_scale=12)
)
return line
def generate_single_line_comparison() -> str:
fig, axes = plt.subplots(1, 3, figsize=(16, 5.5), dpi=150)
# 10x10 square boundary
square = np.array([
[0.0, 0.0, 0.2],
[10.0, 0.0, 0.2],
[10.0, 10.0, 0.2],
[0.0, 10.0, 0.2],
[0.0, 0.0, 0.2],
], dtype=np.float64)
boundary_sp = SlicePaths([square])
infill = np.array([[5.0, 0.0, 0.2], [5.0, 10.0, 0.2]], dtype=np.float64)
infill_sp = SlicePaths([infill])
# Subplot 1: Inputs
ax1 = axes[0]
ax1.set_title("1. Input Geometry\n(Open Infill Line + Closed Perimeter)", fontsize=11, fontweight='bold', pad=10)
draw_path_with_arrows(ax1, square, color='#1f77b4', label="Anchor Boundary (Input 2)", linewidth=2.0)
draw_path_with_arrows(ax1, infill, color='#ff7f0e', label="Open Path (Input 1)", linewidth=2.5)
ax1.plot(infill[0, 0], infill[0, 1], 'go', markersize=9, label="Start Vertex P[0]")
ax1.plot(infill[1, 0], infill[1, 1], 'ro', markersize=9, label="End Vertex P[-1]")
ax1.set_xlim(-1, 11)
ax1.set_ylim(-1, 11)
ax1.set_aspect('equal')
ax1.grid(True, linestyle=':', alpha=0.6)
ax1.legend(loc='lower center', fontsize=8)
# Subplot 2: Reverse = False
res_fwd = infill_sp.anchor(boundary_sp, distance=2.5, reverse=False)
p_fwd = res_fwd[0]
ax2 = axes[1]
ax2.set_title("2. Anchored (Reverse = False)\nFollows Forward (+1) Traversal", fontsize=11, fontweight='bold', pad=10)
ax2.plot(square[:, 0], square[:, 1], color='#1f77b4', linestyle='--', linewidth=1.5, alpha=0.5, label="Anchor Perimeter")
# Draw original in light grey
ax2.plot(infill[:, 0], infill[:, 1], color='gray', linestyle=':', linewidth=1.5, alpha=0.6)
# Draw anchored path with directional arrows
draw_path_with_arrows(ax2, p_fwd, color='#2ca02c', label="Anchored Path", linewidth=2.5)
# Highlight start anchor and end anchor
ax2.plot(p_fwd[:2, 0], p_fwd[:2, 1], color='#9467bd', linewidth=3.5, label="Start Anchor (Prepended)")
ax2.plot(p_fwd[-2:, 0], p_fwd[-2:, 1], color='#d62728', linewidth=3.5, label="End Anchor (Appended)")
ax2.plot(p_fwd[0, 0], p_fwd[0, 1], 'g^', markersize=10, label="New Start (2.5mm before)")
ax2.plot(p_fwd[-1, 0], p_fwd[-1, 1], 'rs', markersize=9, label="New End (2.5mm after)")
ax2.set_xlim(-1, 11)
ax2.set_ylim(-1, 11)
ax2.set_aspect('equal')
ax2.grid(True, linestyle=':', alpha=0.6)
ax2.legend(loc='lower center', fontsize=8)
# Subplot 3: Reverse = True
res_rev = infill_sp.anchor(boundary_sp, distance=2.5, reverse=True)
p_rev = res_rev[0]
ax3 = axes[2]
ax3.set_title("3. Anchored (Reverse = True)\nFollows Opposite (-1) Traversal", fontsize=11, fontweight='bold', pad=10)
ax3.plot(square[:, 0], square[:, 1], color='#1f77b4', linestyle='--', linewidth=1.5, alpha=0.5, label="Anchor Perimeter")
ax3.plot(infill[:, 0], infill[:, 1], color='gray', linestyle=':', linewidth=1.5, alpha=0.6)
draw_path_with_arrows(ax3, p_rev, color='#e377c2', label="Anchored Path", linewidth=2.5)
ax3.plot(p_rev[:2, 0], p_rev[:2, 1], color='#9467bd', linewidth=3.5, label="Start Anchor (Prepended)")
ax3.plot(p_rev[-2:, 0], p_rev[-2:, 1], color='#d62728', linewidth=3.5, label="End Anchor (Appended)")
ax3.plot(p_rev[0, 0], p_rev[0, 1], 'g^', markersize=10, label="New Start (2.5mm before)")
ax3.plot(p_rev[-1, 0], p_rev[-1, 1], 'rs', markersize=9, label="New End (2.5mm after)")
ax3.set_xlim(-1, 11)
ax3.set_ylim(-1, 11)
ax3.set_aspect('equal')
ax3.grid(True, linestyle=':', alpha=0.6)
ax3.legend(loc='lower center', fontsize=8)
plt.tight_layout()
out_path = IMAGES_DIR / "anchor_paths_comparison.png"
plt.savefig(out_path, dpi=150, bbox_inches='tight')
plt.close(fig)
return str(out_path)
def generate_infill_hatch_visualization() -> str:
fig, axes = plt.subplots(1, 2, figsize=(14, 6.5), dpi=150)
# Circular perimeter (radius 10)
angles = np.linspace(0, 2 * np.pi, 65)
circle_pts = np.column_stack([10 * np.cos(angles), 10 * np.sin(angles), np.full(len(angles), 0.2)])
boundary_sp = SlicePaths([circle_pts])
# Generate parallel horizontal infill lines clipped to circle
lines = []
for y in np.arange(-8.0, 9.0, 2.0):
half_w = np.sqrt(max(0.0, 100.0 - y * y))
lines.append(np.array([[-half_w, y, 0.2], [half_w, y, 0.2]], dtype=np.float64))
infill_sp = SlicePaths(lines)
# Subplot 1: Without anchor
ax1 = axes[0]
ax1.set_title("Standard Infill Hatch\nEndpoints touching circular perimeter", fontsize=11, fontweight='bold', pad=10)
ax1.plot(circle_pts[:, 0], circle_pts[:, 1], color='#1f77b4', linewidth=2.5, label="Boundary Perimeter")
for i, line in enumerate(lines):
ax1.plot(line[:, 0], line[:, 1], color='#ff7f0e', linewidth=1.8, label="Infill Lines" if i == 0 else None)
ax1.plot(line[0, 0], line[0, 1], 'g.', markersize=7)
ax1.plot(line[-1, 0], line[-1, 1], 'r.', markersize=7)
ax1.set_xlim(-12, 12)
ax1.set_ylim(-12, 12)
ax1.set_aspect('equal')
ax1.grid(True, linestyle=':', alpha=0.6)
ax1.legend(loc='upper right', fontsize=9)
# Subplot 2: With anchor (distance = 1.5mm)
res_anchored = infill_sp.anchor(boundary_sp, distance=1.5, reverse=False)
ax2 = axes[1]
ax2.set_title("Anchored Infill Hatch (Distance = 1.5mm)\nEndpoints follow circular perimeter", fontsize=11, fontweight='bold', pad=10)
ax2.plot(circle_pts[:, 0], circle_pts[:, 1], color='#1f77b4', linestyle='--', linewidth=1.5, alpha=0.5, label="Boundary Perimeter")
colors = ['#1f77b4', '#ff7f0e', '#2ca02c', '#d62728', '#9467bd', '#8c564b', '#e377c2', '#7f7f7f', '#bcbd22']
for i, p in enumerate(res_anchored):
c = colors[i % len(colors)]
draw_path_with_arrows(ax2, p, color=c, linewidth=2.0, markersize=3, label="Anchored Pass" if i == 0 else None)
ax2.set_xlim(-12, 12)
ax2.set_ylim(-12, 12)
ax2.set_aspect('equal')
ax2.grid(True, linestyle=':', alpha=0.6)
ax2.legend(loc='upper right', fontsize=9)
plt.tight_layout()
out_path = IMAGES_DIR / "anchor_paths_infill_hatch.png"
plt.savefig(out_path, dpi=150, bbox_inches='tight')
plt.close(fig)
return str(out_path)
def generate_corner_wrapping_visualization() -> str:
fig, ax = plt.subplots(figsize=(8, 7), dpi=150)
square = np.array([
[0.0, 0.0, 0.2],
[10.0, 0.0, 0.2],
[10.0, 10.0, 0.2],
[0.0, 10.0, 0.2],
[0.0, 0.0, 0.2],
], dtype=np.float64)
boundary_sp = SlicePaths([square])
# Infill starts at (1.0, 0.0) - only 1mm from corner (0,0) - and goes to (1.0, 10.0)
infill = np.array([[1.0, 0.0, 0.2], [1.0, 10.0, 0.2]], dtype=np.float64)
infill_sp = SlicePaths([infill])
res = infill_sp.anchor(boundary_sp, distance=3.5, reverse=False)
p = res[0]
ax.set_title("Anchor Paths: Corner Turning Verification\n(Distance = 3.5mm wraps around (0,0) corner)", fontsize=11, fontweight='bold', pad=10)
ax.plot(square[:, 0], square[:, 1], color='#1f77b4', linestyle='--', linewidth=2.0, label="Square Boundary Perimeter")
ax.plot(infill[:, 0], infill[:, 1], color='gray', linestyle=':', linewidth=2.0, label="Original Infill Line")
draw_path_with_arrows(ax, p, color='#2ca02c', linewidth=2.5, markersize=6, label="Anchored Toolpath")
# Highlight corner points
ax.plot(p[0, 0], p[0, 1], 'g^', markersize=12, label="Start Point on Left Edge (0, 2.5)")
ax.plot(p[1, 0], p[1, 1], 'mo', markersize=10, label="Corner Point Traversed (0, 0)")
ax.plot(p[2, 0], p[2, 1], 'ys', markersize=9, label="Original Start Point (1, 0)")
# Annotate distance
ax.annotate("Corner turned!\n2.5mm up left edge\n+ 1.0mm along bottom = 3.5mm", xy=(0.0, 1.25), xytext=(2.0, 2.5),
arrowprops=dict(facecolor='black', shrink=0.05, width=1, headwidth=6),
fontsize=9, fontweight='semibold')
ax.set_xlim(-1.5, 11)
ax.set_ylim(-1.5, 11)
ax.set_aspect('equal')
ax.grid(True, linestyle=':', alpha=0.6)
ax.legend(loc='upper right', fontsize=8.5)
plt.tight_layout()
out_path = IMAGES_DIR / "anchor_paths_corners.png"
plt.savefig(out_path, dpi=150, bbox_inches='tight')
plt.close(fig)
return str(out_path)
def generate_partial_touch_visualization() -> str:
fig, axes = plt.subplots(1, 3, figsize=(16, 5), dpi=150)
square = np.array([
[0.0, 0.0, 0.2],
[10.0, 0.0, 0.2],
[10.0, 10.0, 0.2],
[0.0, 10.0, 0.2],
[0.0, 0.0, 0.2],
], dtype=np.float64)
boundary_sp = SlicePaths([square])
# Case A: Start-only touch
infill_start = np.array([[5.0, 0.0, 0.2], [5.0, 5.0, 0.2]], dtype=np.float64)
sp_a = SlicePaths([infill_start])
res_a = sp_a.anchor(boundary_sp, distance=2.0)
ax1 = axes[0]
ax1.set_title("Start-Only Touch\n(Start touches bottom, end in interior)", fontsize=10.5, fontweight='bold')
ax1.plot(square[:, 0], square[:, 1], 'b--', alpha=0.5, label="Boundary")
draw_path_with_arrows(ax1, res_a[0], color='#2ca02c', label="Anchored Path")
ax1.plot(res_a[0][0, 0], res_a[0][0, 1], 'g^', markersize=9, label="Prepended Start")
ax1.plot(infill_start[-1, 0], infill_start[-1, 1], 'rx', markersize=9, label="Untouched End")
ax1.set_xlim(-1, 11)
ax1.set_ylim(-1, 11)
ax1.set_aspect('equal')
ax1.grid(True, linestyle=':', alpha=0.6)
ax1.legend(loc='lower center', fontsize=8)
# Case B: End-only touch
infill_end = np.array([[5.0, 5.0, 0.2], [5.0, 10.0, 0.2]], dtype=np.float64)
sp_b = SlicePaths([infill_end])
res_b = sp_b.anchor(boundary_sp, distance=2.0)
ax2 = axes[1]
ax2.set_title("End-Only Touch\n(Start in interior, end touches top)", fontsize=10.5, fontweight='bold')
ax2.plot(square[:, 0], square[:, 1], 'b--', alpha=0.5, label="Boundary")
draw_path_with_arrows(ax2, res_b[0], color='#ff7f0e', label="Anchored Path")
ax2.plot(infill_end[0, 0], infill_end[0, 1], 'gx', markersize=9, label="Untouched Start")
ax2.plot(res_b[0][-1, 0], res_b[0][-1, 1], 'rs', markersize=9, label="Appended End")
ax2.set_xlim(-1, 11)
ax2.set_ylim(-1, 11)
ax2.set_aspect('equal')
ax2.grid(True, linestyle=':', alpha=0.6)
ax2.legend(loc='lower center', fontsize=8)
# Case C: Closed path (ignored)
box = np.array([[3.0, 3.0, 0.2], [7.0, 3.0, 0.2], [7.0, 7.0, 0.2], [3.0, 7.0, 0.2], [3.0, 3.0, 0.2]], dtype=np.float64)
sp_c = SlicePaths([box])
res_c = sp_c.anchor(boundary_sp, distance=2.0)
ax3 = axes[2]
ax3.set_title("Closed Path Ignored\n(Closed loops remain untouched)", fontsize=10.5, fontweight='bold')
ax3.plot(square[:, 0], square[:, 1], 'b--', alpha=0.5, label="Boundary")
draw_path_with_arrows(ax3, res_c[0], color='#9467bd', label="Untouched Closed Loop")
ax3.set_xlim(-1, 11)
ax3.set_ylim(-1, 11)
ax3.set_aspect('equal')
ax3.grid(True, linestyle=':', alpha=0.6)
ax3.legend(loc='lower center', fontsize=8)
plt.tight_layout()
out_path = IMAGES_DIR / "anchor_paths_partial_touch.png"
plt.savefig(out_path, dpi=150, bbox_inches='tight')
plt.close(fig)
return str(out_path)
def run_all_anchor_visualizations() -> list[str]:
print("Generating visual examples for Anchor Paths...")
p1 = generate_single_line_comparison()
print("Generated:", p1)
p2 = generate_infill_hatch_visualization()
print("Generated:", p2)
p3 = generate_corner_wrapping_visualization()
print("Generated:", p3)
p4 = generate_partial_touch_visualization()
print("Generated:", p4)
return [p1, p2, p3, p4]
if __name__ == '__main__':
run_all_anchor_visualizations()