diff --git a/core/slice.py b/core/slice.py index 83120fd..89ac72f 100644 --- a/core/slice.py +++ b/core/slice.py @@ -2063,6 +2063,26 @@ def _walk_along_path( return clean_pts +def _get_anchor_walk_dir(c: np.ndarray, reverse: bool = False) -> int: + """ + Returns the step direction along c (+1 or -1) to follow the path clockwise. + If c is closed: + - CCW polygon (signed area > 0): index -1 is clockwise. + - CW polygon (signed area < 0): index +1 is clockwise. + If reverse is True, inverts the direction (counter-clockwise). + If c is open, defaults to +1 (or -1 if reverse). + """ + if not is_closed_path(c) or len(c) < 3: + return -1 if reverse else 1 + + pts_2d = c[:, :2] + signed_area = np.sum(pts_2d[:-1, 0] * pts_2d[1:, 1] - pts_2d[1:, 0] * pts_2d[:-1, 1]) + is_ccw = signed_area > 0 + + cw_dir = -1 if is_ccw else 1 + return -cw_dir if reverse else cw_dir + + def anchor_paths( paths: SlicePaths, anchor_paths: SlicePaths | list[np.ndarray], @@ -2072,8 +2092,10 @@ def anchor_paths( ) -> 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. + by following the anchor path in the clockwise direction for the specified distance. + If reverse is True, follows the anchor path in the counter-clockwise direction. + Both start and end vertices extend clockwise along the touched path, ignoring the + internal orientation of the open path. Closed paths and open paths that do not touch anchor_paths remain untouched. """ if not paths or not anchor_paths or distance <= 1e-7: @@ -2110,8 +2132,8 @@ def anchor_paths( 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) + dir_walk = _get_anchor_walk_dir(c, reverse=reverse) + walked = _walk_along_path(c, seg_idx, t_val, p_touch, distance, dir_walk, 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] @@ -2125,8 +2147,8 @@ def anchor_paths( 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) + dir_walk = _get_anchor_walk_dir(c, reverse=reverse) + walked = _walk_along_path(c, seg_idx, t_val, p_touch, distance, dir_walk, 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 diff --git a/tests/test_anchor_paths.py b/tests/test_anchor_paths.py index ec78da9..17e99c1 100644 --- a/tests/test_anchor_paths.py +++ b/tests/test_anchor_paths.py @@ -94,16 +94,16 @@ class TestAnchorPaths(unittest.TestCase): self.assertEqual(len(res), 1) p = res[0] - # reverse=False follows anchor in forward (+1) direction: + # reverse=False follows anchor clockwise for both endpoints: # Start vertex (5,0) touches bottom edge (0->10). - # Forward is +X, so nozzle enters from (3,0) to (5,0). + # Clockwise is -X (towards 0,0), so extension goes to (3,0). # End vertex (5,10) touches top edge (10->0). - # Forward is -X, so nozzle exits from (5,10) to (3,10). + # Clockwise is +X (towards 10,10), so extension goes to (7,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) + np.testing.assert_allclose(p[3], [7.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) @@ -113,14 +113,14 @@ class TestAnchorPaths(unittest.TestCase): 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). + # reverse=True follows anchor counter-clockwise for both endpoints: + # Start vertex (5,0): counter-clockwise is +X (towards 10,0) -> (7,0). + # End vertex (5,10): counter-clockwise is -X (towards 0,10) -> (3,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) + np.testing.assert_allclose(p[3], [3.0, 10.0, 0.2], atol=1e-5) def test_anchor_start_only(self): # Infill touching only at start: (5,0) to (5,5) @@ -130,7 +130,7 @@ class TestAnchorPaths(unittest.TestCase): res = sp.anchor(self.anchor_sp, distance=2.0, reverse=False) self.assertEqual(len(res), 1) p = res[0] - # Only start prepended + # Only start prepended (clockwise on bottom edge -> (3,0)) 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) @@ -144,11 +144,11 @@ class TestAnchorPaths(unittest.TestCase): res = sp.anchor(self.anchor_sp, distance=2.0, reverse=False) self.assertEqual(len(res), 1) p = res[0] - # Only end appended + # Only end appended (clockwise on top edge -> (7,10)) 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) + np.testing.assert_allclose(p[2], [7.0, 10.0, 0.2], atol=1e-5) def test_anchor_closed_path_ignored(self): # A closed square inside @@ -168,19 +168,39 @@ class TestAnchorPaths(unittest.TestCase): # 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)] + # Clockwise is -X: from (1,0) reaches (0,0) after 1.0mm. + # Remaining 2.0mm continues onto left edge towards (0,10) (North, +Y), reaching (0, 2, 0.2). + # Top edge is (10,10) -> (0,10). + # Clockwise is +X: from (1,10) reaches (4,10) after 3.0mm. 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] + self.assertEqual(len(p), 5) 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) + np.testing.assert_allclose(p[3], [1.0, 10.0, 0.2], atol=1e-5) + np.testing.assert_allclose(p[4], [4.0, 10.0, 0.2], atol=1e-5) + + def test_anchor_open_path_direction_invariant(self): + # Verifies that both A->B and B->A extend in the exact same clockwise direction + line_fwd = np.array([[5.0, 0.0, 0.2], [5.0, 10.0, 0.2]], dtype=np.float64) + line_rev = np.array([[5.0, 10.0, 0.2], [5.0, 0.0, 0.2]], dtype=np.float64) + + res_fwd = SlicePaths([line_fwd]).anchor(self.anchor_sp, distance=2.0, reverse=False) + res_rev = SlicePaths([line_rev]).anchor(self.anchor_sp, distance=2.0, reverse=False) + + # For fwd: start at (5,0) extends to (3,0); end at (5,10) extends to (7,10) + p_fwd = res_fwd[0] + np.testing.assert_allclose(p_fwd[0], [3.0, 0.0, 0.2], atol=1e-5) + np.testing.assert_allclose(p_fwd[-1], [7.0, 10.0, 0.2], atol=1e-5) + + # For rev: start at (5,10) extends to (7,10); end at (5,0) extends to (3,0) + p_rev = res_rev[0] + np.testing.assert_allclose(p_rev[0], [7.0, 10.0, 0.2], atol=1e-5) + np.testing.assert_allclose(p_rev[-1], [3.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 diff --git a/tests/visual/images/anchor_paths_comparison.png b/tests/visual/images/anchor_paths_comparison.png index 4cb3b5c..105fb3f 100644 Binary files a/tests/visual/images/anchor_paths_comparison.png 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 index 37852c6..eb1d7d9 100644 Binary files a/tests/visual/images/anchor_paths_corners.png 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 index 9fa046c..a0f09e5 100644 Binary files a/tests/visual/images/anchor_paths_infill_hatch.png 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 index a2262c9..c0f0bab 100644 Binary files a/tests/visual/images/anchor_paths_partial_touch.png 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 index 9673043..f223fd9 100644 --- a/tests/visual/visualize_anchor_paths.py +++ b/tests/visual/visualize_anchor_paths.py @@ -116,17 +116,17 @@ def generate_single_line_comparison() -> str: 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.set_title("2. Anchored (Reverse = False)\nBoth Ends Follow Clockwise 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.plot(p_fwd[:2, 0], p_fwd[:2, 1], color='#9467bd', linewidth=3.5, label="Start Anchor (Clockwise)") + ax2.plot(p_fwd[-2:, 0], p_fwd[-2:, 1], color='#d62728', linewidth=3.5, label="End Anchor (Clockwise)") + ax2.plot(p_fwd[0, 0], p_fwd[0, 1], 'g^', markersize=10, label="New Start (2.5mm CW)") + ax2.plot(p_fwd[-1, 0], p_fwd[-1, 1], 'rs', markersize=9, label="New End (2.5mm CW)") ax2.set_xlim(-1, 11) ax2.set_ylim(-1, 11) ax2.set_aspect('equal') @@ -137,7 +137,7 @@ def generate_single_line_comparison() -> str: 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.set_title("3. Anchored (Reverse = True)\nBoth Ends Follow Counter-Clockwise 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)