anchors
This commit is contained in:
parent
1604e6da23
commit
2693a54ef1
10 changed files with 960 additions and 0 deletions
|
|
@ -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(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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="Overhang Mask").type = "SLICENODE_OverhangMask"
|
||||||
layout.operator("node.add_node", text="Path Operations").type = "SLICENODE_PathOperations"
|
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="Flatten Path List").type = "SLICENODE_FlattenPathList"
|
||||||
|
layout.operator("node.add_node", text="Anchor Paths").type = "SLICENODE_AnchorPaths"
|
||||||
layout.separator()
|
layout.separator()
|
||||||
layout.operator("node.add_node", text="Math").type = "SLICENODE_FloatMath"
|
layout.operator("node.add_node", text="Math").type = "SLICENODE_FloatMath"
|
||||||
layout.operator("node.add_node", text="Integer Math").type = "SLICENODE_IntMath"
|
layout.operator("node.add_node", text="Integer Math").type = "SLICENODE_IntMath"
|
||||||
|
|
|
||||||
282
core/slice.py
282
core/slice.py
|
|
@ -1903,6 +1903,288 @@ class SlicePaths(list[np.ndarray]):
|
||||||
extra_attributes=res_extra # type: ignore[arg-type]
|
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]:
|
def _slice_single_mesh(mesh_array: np.ndarray, z_values: np.ndarray | list[float], ignore_holes: bool = False) -> list[np.ndarray]:
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -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.
|
- **Infill Rotation**: Base rotation of the infill pattern.
|
||||||
- **Layer Rotation**: Rotation of the entire slice to align infill with specific planes.
|
- **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.
|
- **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`.
|
- **Path Operations**: Multi-input joiner node performing composite geometric, boolean, and interleaving operations on `SlicePaths`.
|
||||||
- **Operations**:
|
- **Operations**:
|
||||||
- **Zip paths**: Interleaves paths from all inputs layer by layer.
|
- **Zip paths**: Interleaves paths from all inputs layer by layer.
|
||||||
|
|
|
||||||
269
tests/test_anchor_paths.py
Normal file
269
tests/test_anchor_paths.py
Normal file
|
|
@ -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()
|
||||||
|
|
||||||
BIN
tests/visual/images/anchor_paths_comparison.png
Normal file
BIN
tests/visual/images/anchor_paths_comparison.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 152 KiB |
BIN
tests/visual/images/anchor_paths_corners.png
Normal file
BIN
tests/visual/images/anchor_paths_corners.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 91 KiB |
BIN
tests/visual/images/anchor_paths_infill_hatch.png
Normal file
BIN
tests/visual/images/anchor_paths_infill_hatch.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 141 KiB |
BIN
tests/visual/images/anchor_paths_partial_touch.png
Normal file
BIN
tests/visual/images/anchor_paths_partial_touch.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 112 KiB |
343
tests/visual/visualize_anchor_paths.py
Normal file
343
tests/visual/visualize_anchor_paths.py
Normal file
|
|
@ -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()
|
||||||
Loading…
Add table
Reference in a new issue