anchors
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 152 KiB After Width: | Height: | Size: 147 KiB |
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 141 KiB After Width: | Height: | Size: 141 KiB |
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 112 KiB |
|
|
@ -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)
|
||||
|
|
|
|||