nodeslicer/tests/visual/visualize_anchor_paths.py
2026-10-14 18:59:00 +03:00

343 lines
14 KiB
Python

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