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
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

View file

@ -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

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)
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)