This commit is contained in:
Nicolas Predella 2026-10-15 11:01:23 +03:00
parent 2693a54ef1
commit 8099c5eba8
7 changed files with 70 additions and 28 deletions

View file

@ -2063,6 +2063,26 @@ def _walk_along_path(
return clean_pts 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( def anchor_paths(
paths: SlicePaths, paths: SlicePaths,
anchor_paths: SlicePaths | list[np.ndarray], anchor_paths: SlicePaths | list[np.ndarray],
@ -2072,8 +2092,10 @@ def anchor_paths(
) -> SlicePaths: ) -> SlicePaths:
""" """
Extends open paths in paths where their start/end vertices touch anchor_paths Extends open paths in paths where their start/end vertices touch anchor_paths
by following the anchor path for the specified distance. by following the anchor path in the clockwise direction for the specified distance.
If reverse is True, follows the anchor path in the reverse direction. 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. Closed paths and open paths that do not touch anchor_paths remain untouched.
""" """
if not paths or not anchor_paths or distance <= 1e-7: 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 z_val = float(p[0, 2]) if p.shape[1] > 2 else 0.0
p_touch[2] = z_val p_touch[2] = z_val
p[0] = p_touch.copy() p[0] = p_touch.copy()
dir_from_p = 1 if reverse else -1 dir_walk = _get_anchor_walk_dir(c, reverse=reverse)
walked = _walk_along_path(c, seg_idx, t_val, p_touch, distance, dir_from_p, is_c_closed, z_val) 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] walked = [pt for pt in walked if np.linalg.norm(pt[:2] - p[0, :2]) > 1e-5]
if walked: if walked:
prefix = walked[::-1] prefix = walked[::-1]
@ -2125,8 +2147,8 @@ def anchor_paths(
z_val = float(p[-1, 2]) if p.shape[1] > 2 else 0.0 z_val = float(p[-1, 2]) if p.shape[1] > 2 else 0.0
p_touch[2] = z_val p_touch[2] = z_val
p[-1] = p_touch.copy() p[-1] = p_touch.copy()
dir_from_p = -1 if reverse else 1 dir_walk = _get_anchor_walk_dir(c, reverse=reverse)
walked = _walk_along_path(c, seg_idx, t_val, p_touch, distance, dir_from_p, is_c_closed, z_val) 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] walked = [pt for pt in walked if np.linalg.norm(pt[:2] - p[-1, :2]) > 1e-5]
if walked: if walked:
postfix = walked postfix = walked

View file

@ -94,16 +94,16 @@ class TestAnchorPaths(unittest.TestCase):
self.assertEqual(len(res), 1) self.assertEqual(len(res), 1)
p = res[0] 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). # 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). # 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) 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[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[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[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): 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) 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) self.assertEqual(len(res), 1)
p = res[0] p = res[0]
# reverse=True follows anchor in opposite (-1) direction: # reverse=True follows anchor counter-clockwise for both endpoints:
# Start vertex (5,0): opposite of +X is -X, so nozzle enters from (7,0) to (5,0). # Start vertex (5,0): counter-clockwise is +X (towards 10,0) -> (7,0).
# End vertex (5,10): opposite of -X is +X, so nozzle exits from (5,10) to (7,10). # End vertex (5,10): counter-clockwise is -X (towards 0,10) -> (3,10).
self.assertEqual(len(p), 4) 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[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[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[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): def test_anchor_start_only(self):
# Infill touching only at start: (5,0) to (5,5) # 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) res = sp.anchor(self.anchor_sp, distance=2.0, reverse=False)
self.assertEqual(len(res), 1) self.assertEqual(len(res), 1)
p = res[0] p = res[0]
# Only start prepended # Only start prepended (clockwise on bottom edge -> (3,0))
self.assertEqual(len(p), 3) 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[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[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) res = sp.anchor(self.anchor_sp, distance=2.0, reverse=False)
self.assertEqual(len(res), 1) self.assertEqual(len(res), 1)
p = res[0] p = res[0]
# Only end appended # Only end appended (clockwise on top edge -> (7,10))
self.assertEqual(len(p), 3) 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[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[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): def test_anchor_closed_path_ignored(self):
# A closed square inside # 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) # Infill starts at (1, 0, 0.2) and goes to (1, 10, 0.2)
# Distance = 3.0, reverse = False # Distance = 3.0, reverse = False
# Bottom edge is (0,0) -> (10,0). # Bottom edge is (0,0) -> (10,0).
# Walking backward (-1) from (1,0) reaches (0,0) after 1.0mm. # Clockwise is -X: from (1,0) reaches (0,0) after 1.0mm.
# Remaining 2.0mm continues onto left edge (0,10) -> (0,0). # Remaining 2.0mm continues onto left edge towards (0,10) (North, +Y), reaching (0, 2, 0.2).
# Walking backward along left edge means going South from (0,0) towards (0,10), # Top edge is (10,10) -> (0,10).
# which reaches (0, 2, 0.2). # Clockwise is +X: from (1,10) reaches (4,10) after 3.0mm.
# Prefix reversed: [(0, 2), (0, 0)]
infill = np.array([[1.0, 0.0, 0.2], [1.0, 10.0, 0.2]], dtype=np.float64) infill = np.array([[1.0, 0.0, 0.2], [1.0, 10.0, 0.2]], dtype=np.float64)
sp = SlicePaths([infill]) sp = SlicePaths([infill])
res = sp.anchor(self.anchor_sp, distance=3.0, reverse=False) res = sp.anchor(self.anchor_sp, distance=3.0, reverse=False)
p = res[0] 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[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[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[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): def test_anchor_layer_mismatch_ignored(self):
# Infill on layer Z = 0.4, anchor on layer Z = 0.2 # Infill on layer Z = 0.4, anchor on layer Z = 0.2

Binary file not shown.

Before

Width:  |  Height:  |  Size: 152 KiB

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 141 KiB

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 112 KiB

View file

@ -116,17 +116,17 @@ def generate_single_line_comparison() -> str:
res_fwd = infill_sp.anchor(boundary_sp, distance=2.5, reverse=False) res_fwd = infill_sp.anchor(boundary_sp, distance=2.5, reverse=False)
p_fwd = res_fwd[0] p_fwd = res_fwd[0]
ax2 = axes[1] 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") ax2.plot(square[:, 0], square[:, 1], color='#1f77b4', linestyle='--', linewidth=1.5, alpha=0.5, label="Anchor Perimeter")
# Draw original in light grey # Draw original in light grey
ax2.plot(infill[:, 0], infill[:, 1], color='gray', linestyle=':', linewidth=1.5, alpha=0.6) ax2.plot(infill[:, 0], infill[:, 1], color='gray', linestyle=':', linewidth=1.5, alpha=0.6)
# Draw anchored path with directional arrows # Draw anchored path with directional arrows
draw_path_with_arrows(ax2, p_fwd, color='#2ca02c', label="Anchored Path", linewidth=2.5) draw_path_with_arrows(ax2, p_fwd, color='#2ca02c', label="Anchored Path", linewidth=2.5)
# Highlight start anchor and end anchor # 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='#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 (Appended)") 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 before)") 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 after)") 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_xlim(-1, 11)
ax2.set_ylim(-1, 11) ax2.set_ylim(-1, 11)
ax2.set_aspect('equal') 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) res_rev = infill_sp.anchor(boundary_sp, distance=2.5, reverse=True)
p_rev = res_rev[0] p_rev = res_rev[0]
ax3 = axes[2] 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(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) 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) draw_path_with_arrows(ax3, p_rev, color='#e377c2', label="Anchored Path", linewidth=2.5)