diff --git a/core/nodeslicer/nodes/path.py b/core/nodeslicer/nodes/path.py index a0f1fbc..f292831 100644 --- a/core/nodeslicer/nodes/path.py +++ b/core/nodeslicer/nodes/path.py @@ -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(), + ) + + + diff --git a/core/nodeslicer/tree.py b/core/nodeslicer/tree.py index 0b597d6..6d9ead7 100644 --- a/core/nodeslicer/tree.py +++ b/core/nodeslicer/tree.py @@ -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" diff --git a/core/slice.py b/core/slice.py index f283217..83120fd 100644 --- a/core/slice.py +++ b/core/slice.py @@ -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]: """ diff --git a/doc/NODES.md b/doc/NODES.md index ecea8fc..bdae351 100644 --- a/doc/NODES.md +++ b/doc/NODES.md @@ -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. diff --git a/tests/test_anchor_paths.py b/tests/test_anchor_paths.py new file mode 100644 index 0000000..ec78da9 --- /dev/null +++ b/tests/test_anchor_paths.py @@ -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() + diff --git a/tests/visual/images/anchor_paths_comparison.png b/tests/visual/images/anchor_paths_comparison.png new file mode 100644 index 0000000..4cb3b5c Binary files /dev/null and b/tests/visual/images/anchor_paths_comparison.png differ diff --git a/tests/visual/images/anchor_paths_corners.png b/tests/visual/images/anchor_paths_corners.png new file mode 100644 index 0000000..37852c6 Binary files /dev/null and b/tests/visual/images/anchor_paths_corners.png differ diff --git a/tests/visual/images/anchor_paths_infill_hatch.png b/tests/visual/images/anchor_paths_infill_hatch.png new file mode 100644 index 0000000..9fa046c Binary files /dev/null and b/tests/visual/images/anchor_paths_infill_hatch.png differ diff --git a/tests/visual/images/anchor_paths_partial_touch.png b/tests/visual/images/anchor_paths_partial_touch.png new file mode 100644 index 0000000..a2262c9 Binary files /dev/null and b/tests/visual/images/anchor_paths_partial_touch.png differ diff --git a/tests/visual/visualize_anchor_paths.py b/tests/visual/visualize_anchor_paths.py new file mode 100644 index 0000000..9673043 --- /dev/null +++ b/tests/visual/visualize_anchor_paths.py @@ -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()