343 lines
14 KiB
Python
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()
|