830 lines
36 KiB
Python
830 lines
36 KiB
Python
"""
|
|
NodeSlicer Path Operations Visualizer and Test Suite Generator.
|
|
Generates comprehensive 2D/3D visualizations for all Path Operations modes:
|
|
- Maximum Coverage (Union)
|
|
- Minimum Coverage (Multi-layer Intersection with empty-layer ignoring)
|
|
- Intersection (Boolean Intersection across all inputs)
|
|
- Subtract (Boolean Difference)
|
|
- Exclusive Or (XOR)
|
|
- Zip Paths (Layer-by-layer interleaving)
|
|
- Extend Paths (Sequential concatenation)
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
# Add project root to sys.path
|
|
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
|
|
if str(PROJECT_ROOT) not in sys.path:
|
|
sys.path.insert(0, str(PROJECT_ROOT))
|
|
if str(PROJECT_ROOT.parent) not in sys.path:
|
|
sys.path.insert(0, str(PROJECT_ROOT.parent))
|
|
|
|
# Mock bpy if running outside Blender
|
|
if 'bpy' not in sys.modules or not hasattr(sys.modules['bpy'], 'types'):
|
|
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()
|
|
|
|
import copy
|
|
import numpy as np
|
|
import matplotlib
|
|
matplotlib.use('Agg')
|
|
import matplotlib.pyplot as plt
|
|
from matplotlib.patches import PathPatch
|
|
from matplotlib.path import Path as MplPath
|
|
from shapely.geometry import Polygon, MultiPolygon
|
|
|
|
try:
|
|
from NodeSlicer.core.slice import SlicePaths, is_closed_path, _group_closed_paths_into_shapes
|
|
except ImportError:
|
|
from core.slice import SlicePaths, is_closed_path, _group_closed_paths_into_shapes
|
|
|
|
OUTPUT_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
IMAGES_DIR = os.path.join(OUTPUT_DIR, "images")
|
|
|
|
|
|
def _paths_to_mpl_patch(paths: list[np.ndarray], facecolor: str, edgecolor: str, alpha: float = 0.4, label: str = "") -> list:
|
|
"""Converts a list of 2D/3D paths (outer boundaries + holes) into matplotlib artist patches."""
|
|
closed_paths = [p for p in paths if is_closed_path(p)]
|
|
open_paths = [p for p in paths if not is_closed_path(p)]
|
|
|
|
patches = []
|
|
|
|
if closed_paths:
|
|
shapes = _group_closed_paths_into_shapes(closed_paths)
|
|
for shape in shapes:
|
|
if not shape:
|
|
continue
|
|
vertices = []
|
|
codes = []
|
|
|
|
# Outer boundary
|
|
outer_pts = shape[0][:, :2]
|
|
if np.allclose(outer_pts[0], outer_pts[-1]):
|
|
outer_pts = outer_pts[:-1]
|
|
for i, pt in enumerate(outer_pts):
|
|
vertices.append(pt)
|
|
codes.append(MplPath.MOVETO if i == 0 else MplPath.LINETO)
|
|
vertices.append(outer_pts[0])
|
|
codes.append(MplPath.CLOSEPOLY)
|
|
|
|
# Holes
|
|
for hole in shape[1:]:
|
|
hole_pts = hole[:, :2]
|
|
if np.allclose(hole_pts[0], hole_pts[-1]):
|
|
hole_pts = hole_pts[:-1]
|
|
for i, pt in enumerate(hole_pts):
|
|
vertices.append(pt)
|
|
codes.append(MplPath.MOVETO if i == 0 else MplPath.LINETO)
|
|
vertices.append(hole_pts[0])
|
|
codes.append(MplPath.CLOSEPOLY)
|
|
|
|
mpl_path = MplPath(vertices, codes)
|
|
patch = PathPatch(mpl_path, facecolor=facecolor, edgecolor=edgecolor, linewidth=2.0, alpha=alpha, label=label)
|
|
patches.append(patch)
|
|
|
|
return patches, open_paths
|
|
|
|
|
|
def plot_operation_comparison(
|
|
title: str,
|
|
description: str,
|
|
inputs: list[tuple[str, SlicePaths, str]], # (name, SlicePaths, color)
|
|
result: SlicePaths,
|
|
operation_name: str,
|
|
filename: str
|
|
) -> str:
|
|
"""
|
|
Renders a multi-layer side-by-side visualization of inputs vs output.
|
|
"""
|
|
os.makedirs(IMAGES_DIR, exist_ok=True)
|
|
out_path = os.path.join(IMAGES_DIR, filename)
|
|
|
|
# Collect all unique Z levels
|
|
all_zs = set()
|
|
for _, sp, _ in inputs:
|
|
for p in sp:
|
|
if len(p) > 0:
|
|
all_zs.add(round(float(p[0, 2]), 3))
|
|
for p in result:
|
|
if len(p) > 0:
|
|
all_zs.add(round(float(p[0, 2]), 3))
|
|
|
|
sorted_zs = sorted(list(all_zs)) if all_zs else [0.0]
|
|
num_layers = len(sorted_zs)
|
|
|
|
fig, axes = plt.subplots(num_layers, 2, figsize=(14, 5 * num_layers), squeeze=False)
|
|
fig.suptitle(f"{title}\nOperation: {operation_name}", fontsize=15, fontweight='bold', y=0.98)
|
|
|
|
# Global bounding box for consistent scaling
|
|
all_pts = []
|
|
for _, sp, _ in inputs:
|
|
for p in sp:
|
|
if len(p) > 0:
|
|
all_pts.append(p[:, :2])
|
|
for p in result:
|
|
if len(p) > 0:
|
|
all_pts.append(p[:, :2])
|
|
|
|
if all_pts:
|
|
all_pts_arr = np.vstack(all_pts)
|
|
min_x, min_y = all_pts_arr.min(axis=0) - 5
|
|
max_x, max_y = all_pts_arr.max(axis=0) + 5
|
|
else:
|
|
min_x, min_y, max_x, max_y = -10, -10, 50, 50
|
|
|
|
for row_idx, z in enumerate(sorted_zs):
|
|
ax_in = axes[row_idx, 0]
|
|
ax_out = axes[row_idx, 1]
|
|
|
|
# 1. Left Panel: Inputs on layer Z
|
|
ax_in.set_title(f"Inputs (Layer Z = {z:.2f} mm)", fontsize=12, fontweight='semibold')
|
|
ax_in.set_xlim(min_x, max_x)
|
|
ax_in.set_ylim(min_y, max_y)
|
|
ax_in.set_aspect('equal')
|
|
ax_in.grid(True, linestyle='--', alpha=0.5)
|
|
|
|
has_in_geom = False
|
|
for inp_name, sp, color in inputs:
|
|
layer_paths = [p for p in sp if len(p) > 0 and abs(p[0, 2] - z) < 1e-4]
|
|
if layer_paths:
|
|
has_in_geom = True
|
|
patches, open_p = _paths_to_mpl_patch(layer_paths, facecolor=color, edgecolor=color, alpha=0.35, label=inp_name)
|
|
for patch in patches:
|
|
ax_in.add_patch(copy.copy(patch))
|
|
for op in open_p:
|
|
ax_in.plot(op[:, 0], op[:, 1], color=color, linewidth=2.5, linestyle='-', label=f"{inp_name} (Open)")
|
|
ax_in.scatter(op[:, 0], op[:, 1], color=color, s=20)
|
|
# Draw outer line markers
|
|
for p in layer_paths:
|
|
ax_in.scatter(p[:, 0], p[:, 1], color=color, s=15, alpha=0.7)
|
|
|
|
if not has_in_geom:
|
|
ax_in.text(0.5, 0.5, "No paths on this layer", transform=ax_in.transAxes, ha='center', va='center', color='gray', fontsize=12)
|
|
|
|
# 2. Right Panel: Result on layer Z
|
|
ax_out.set_title(f"Result: {operation_name} (Layer Z = {z:.2f} mm)", fontsize=12, fontweight='semibold')
|
|
ax_out.set_xlim(min_x, max_x)
|
|
ax_out.set_ylim(min_y, max_y)
|
|
ax_out.set_aspect('equal')
|
|
ax_out.grid(True, linestyle='--', alpha=0.5)
|
|
|
|
res_layer_paths = [p for p in result if len(p) > 0 and abs(p[0, 2] - z) < 1e-4]
|
|
if res_layer_paths:
|
|
res_color = "#9467bd" # Purple
|
|
patches, open_p = _paths_to_mpl_patch(res_layer_paths, facecolor=res_color, edgecolor="#5c2d91", alpha=0.5, label="Result Paths")
|
|
for patch in patches:
|
|
ax_out.add_patch(copy.copy(patch))
|
|
for op in open_p:
|
|
ax_out.plot(op[:, 0], op[:, 1], color="#5c2d91", linewidth=3.0, linestyle='-', label="Result (Open)")
|
|
ax_out.scatter(op[:, 0], op[:, 1], color="#5c2d91", s=25)
|
|
for p in res_layer_paths:
|
|
ax_out.plot(p[:, 0], p[:, 1], color="#5c2d91", linewidth=2.0)
|
|
ax_out.scatter(p[:, 0], p[:, 1], color="#5c2d91", s=20)
|
|
# Mark seam / start vertex
|
|
ax_out.scatter([p[0, 0]], [p[0, 1]], color="#2ca02c", s=60, zorder=5, marker='o', label="Seam Start" if p is res_layer_paths[0] else "")
|
|
else:
|
|
ax_out.text(0.5, 0.5, "Empty (0 paths)", transform=ax_out.transAxes, ha='center', va='center', color='#d62728', fontsize=12, fontweight='bold')
|
|
|
|
# Formatting legends
|
|
handles_in, labels_in = ax_in.get_legend_handles_labels()
|
|
if handles_in:
|
|
ax_in.legend(handles_in, labels_in, loc='upper right', fontsize=9)
|
|
|
|
handles_out, labels_out = ax_out.get_legend_handles_labels()
|
|
if handles_out:
|
|
ax_out.legend(handles_out, labels_out, loc='upper right', fontsize=9)
|
|
|
|
plt.tight_layout(rect=[0, 0.03, 1, 0.95])
|
|
plt.savefig(out_path, dpi=120)
|
|
plt.close(fig)
|
|
return out_path
|
|
|
|
|
|
def run_all_visualizations() -> list[dict]:
|
|
"""Generates all visual test cases for Path Operations and returns catalog of results."""
|
|
test_results = []
|
|
|
|
# =========================================================================
|
|
# Test 1: Maximum Coverage - Overlapping Rectangles (Boolean Union)
|
|
# =========================================================================
|
|
p1 = np.array([[0, 0, 1.0], [20, 0, 1.0], [20, 20, 1.0], [0, 20, 1.0], [0, 0, 1.0]])
|
|
p2 = np.array([[10, 10, 1.0], [30, 10, 1.0], [30, 30, 1.0], [10, 30, 1.0], [10, 10, 1.0]])
|
|
sp1 = SlicePaths([p1], groups=[0], name="Square A")
|
|
sp2 = SlicePaths([p2], groups=[0], name="Square B")
|
|
|
|
res1 = SlicePaths.path_operations([sp1, sp2], operation='MAX_COVERAGE')
|
|
fn1 = "01_max_coverage_overlapping_squares.png"
|
|
plot_operation_comparison(
|
|
title="1. Maximum Coverage: Overlapping Squares (Boolean Union)",
|
|
description="Two overlapping squares merged into a single outer contour without overlapping interior edges.",
|
|
inputs=[("Square A (0-20)", sp1, "#1f77b4"), ("Square B (10-30)", sp2, "#ff7f0e")],
|
|
result=res1,
|
|
operation_name="MAX_COVERAGE (Union)",
|
|
filename=fn1
|
|
)
|
|
test_results.append({
|
|
"id": "TC01",
|
|
"title": "Maximum Coverage: Overlapping Squares",
|
|
"operation": "MAX_COVERAGE",
|
|
"image": fn1,
|
|
"description": "Calculates the boolean union of two overlapping polygons into a single continuous boundary.",
|
|
"input_count": 2,
|
|
"output_paths": len(res1),
|
|
"status": "PASS" if len(res1) == 1 and res1[0][:, 0].min() == 0 and res1[0][:, 0].max() == 30 else "FAIL"
|
|
})
|
|
|
|
# =========================================================================
|
|
# Test 2: Maximum Coverage - Donut with Hole + Overlapping Square
|
|
# =========================================================================
|
|
outer_donut = np.array([[0, 0, 1.0], [30, 0, 1.0], [30, 30, 1.0], [0, 30, 1.0], [0, 0, 1.0]])
|
|
hole_donut = np.array([[8, 8, 1.0], [8, 22, 1.0], [22, 22, 1.0], [22, 8, 1.0], [8, 8, 1.0]])
|
|
donut_sp = SlicePaths([outer_donut, hole_donut], groups=[0, 0], name="Donut")
|
|
|
|
box_overlap = np.array([[15, 15, 1.0], [40, 15, 1.0], [40, 40, 1.0], [15, 40, 1.0], [15, 15, 1.0]])
|
|
box_sp = SlicePaths([box_overlap], groups=[0], name="Solid Box")
|
|
|
|
res2 = SlicePaths.path_operations([donut_sp, box_sp], operation='MAX_COVERAGE')
|
|
fn2 = "02_max_coverage_donut_and_box.png"
|
|
plot_operation_comparison(
|
|
title="2. Maximum Coverage: Donut with Hole + Overlapping Solid Box",
|
|
description="Donut shape with an interior hole unioned with a solid box partially covering the hole.",
|
|
inputs=[("Donut (Outer + Hole)", donut_sp, "#1f77b4"), ("Solid Box (15-40)", box_sp, "#ff7f0e")],
|
|
result=res2,
|
|
operation_name="MAX_COVERAGE (Union)",
|
|
filename=fn2
|
|
)
|
|
test_results.append({
|
|
"id": "TC02",
|
|
"title": "Maximum Coverage: Donut with Hole + Solid Box",
|
|
"operation": "MAX_COVERAGE",
|
|
"image": fn2,
|
|
"description": "Correctly preserves hole topology during union, partially clipping the interior hole.",
|
|
"input_count": 2,
|
|
"output_paths": len(res2),
|
|
"status": "PASS" if len(res2) == 2 else "FAIL"
|
|
})
|
|
|
|
# =========================================================================
|
|
# Test 3: Minimum Coverage - Multi-Layer Staggered Objects (Empty Layers Ignored)
|
|
# =========================================================================
|
|
# Object A on Z=1.0 and Z=2.0
|
|
pA1 = np.array([[0, 0, 1.0], [20, 0, 1.0], [20, 20, 1.0], [0, 20, 1.0], [0, 0, 1.0]])
|
|
pA2 = np.array([[0, 0, 2.0], [20, 0, 2.0], [20, 20, 2.0], [0, 20, 2.0], [0, 0, 2.0]])
|
|
spA = SlicePaths([pA1, pA2], groups=[0, 1], name="Object A")
|
|
|
|
# Object B on Z=2.0 and Z=3.0
|
|
pB2 = np.array([[10, 0, 2.0], [30, 0, 2.0], [30, 20, 2.0], [10, 20, 2.0], [10, 0, 2.0]])
|
|
pB3 = np.array([[10, 10, 3.0], [30, 10, 3.0], [30, 30, 3.0], [10, 30, 3.0], [10, 10, 3.0]])
|
|
spB = SlicePaths([pB2, pB3], groups=[0, 1], name="Object B")
|
|
|
|
res3 = SlicePaths.path_operations([spA, spB], operation='MIN_COVERAGE')
|
|
fn3 = "03_min_coverage_staggered_layers.png"
|
|
plot_operation_comparison(
|
|
title="3. Minimum Coverage: Staggered Objects (Empty Layers Ignored)",
|
|
description="Layer 1: only Object A exists -> Object A preserved. Layer 2: A and B exist -> Intersection computed. Layer 3: only Object B exists -> Object B preserved.",
|
|
inputs=[("Object A (Z=1, 2)", spA, "#1f77b4"), ("Object B (Z=2, 3)", spB, "#2ca02c")],
|
|
result=res3,
|
|
operation_name="MIN_COVERAGE (Layer Intersection)",
|
|
filename=fn3
|
|
)
|
|
test_results.append({
|
|
"id": "TC03",
|
|
"title": "Minimum Coverage: Multi-Layer Staggered Objects",
|
|
"operation": "MIN_COVERAGE",
|
|
"image": fn3,
|
|
"description": "Verifies that empty paths on layers where only 1 object exists do NOT zero out the layer, while overlapping layers are properly intersected.",
|
|
"input_count": 2,
|
|
"output_paths": len(res3),
|
|
"status": "PASS" if len(res3) == 3 else "FAIL"
|
|
})
|
|
|
|
# =========================================================================
|
|
# Test 4: Minimum Coverage - Two Overlapping Boxes (Single Layer Intersection)
|
|
# =========================================================================
|
|
res4 = SlicePaths.path_operations([sp1, sp2], operation='MIN_COVERAGE')
|
|
fn4 = "04_min_coverage_overlapping_squares.png"
|
|
plot_operation_comparison(
|
|
title="4. Minimum Coverage: Overlapping Squares on Same Layer",
|
|
description="Two overlapping squares on Z=1.0. Minimum coverage intersects them into the [10, 20] overlap region.",
|
|
inputs=[("Square A (0-20)", sp1, "#1f77b4"), ("Square B (10-30)", sp2, "#ff7f0e")],
|
|
result=res4,
|
|
operation_name="MIN_COVERAGE (Intersection)",
|
|
filename=fn4
|
|
)
|
|
test_results.append({
|
|
"id": "TC04",
|
|
"title": "Minimum Coverage: Two Overlapping Squares",
|
|
"operation": "MIN_COVERAGE",
|
|
"image": fn4,
|
|
"description": "Calculates the intersection region [10, 20] x [10, 20] between two active inputs.",
|
|
"input_count": 2,
|
|
"output_paths": len(res4),
|
|
"status": "PASS" if len(res4) == 1 and np.isclose(res4[0][:, 0].min(), 10.0) and np.isclose(res4[0][:, 0].max(), 20.0) else "FAIL"
|
|
})
|
|
|
|
# =========================================================================
|
|
# Test 5: Minimum Coverage - Single Object with Multiple Separate Islands
|
|
# =========================================================================
|
|
island1 = np.array([[0, 0, 1.0], [10, 0, 1.0], [10, 10, 1.0], [0, 10, 1.0], [0, 0, 1.0]])
|
|
island2 = np.array([[30, 0, 1.0], [40, 0, 1.0], [40, 10, 1.0], [30, 10, 1.0], [30, 0, 1.0]])
|
|
sp_islands = SlicePaths([island1, island2], groups=[0, 0], name="Dual Islands")
|
|
|
|
res5 = SlicePaths.path_operations([sp_islands], operation='MIN_COVERAGE')
|
|
fn5 = "05_min_coverage_single_object_islands.png"
|
|
plot_operation_comparison(
|
|
title="5. Minimum Coverage: Single Object with Disjoint Islands",
|
|
description="Single object with two separate pillars/islands on Z=1.0. Preserves all islands intact without self-intersection destruction.",
|
|
inputs=[("Dual Islands Object", sp_islands, "#1f77b4")],
|
|
result=res5,
|
|
operation_name="MIN_COVERAGE (Single Input)",
|
|
filename=fn5
|
|
)
|
|
test_results.append({
|
|
"id": "TC05",
|
|
"title": "Minimum Coverage: Single Object with Multiple Islands",
|
|
"operation": "MIN_COVERAGE",
|
|
"image": fn5,
|
|
"description": "Ensures disjoint shapes within a single object are preserved intact rather than annihilated by self-intersection.",
|
|
"input_count": 1,
|
|
"output_paths": len(res5),
|
|
"status": "PASS" if len(res5) == 2 else "FAIL"
|
|
})
|
|
|
|
# =========================================================================
|
|
# Test 6: Standard Boolean Intersection - Staggered Objects (Non-Overlapping Layers Zeroed)
|
|
# =========================================================================
|
|
res6 = SlicePaths.path_operations([spA, spB], operation='INTERSECTION')
|
|
fn6 = "06_intersection_staggered_layers.png"
|
|
plot_operation_comparison(
|
|
title="6. Boolean Intersection: Staggered Objects",
|
|
description="Standard Boolean Intersection: Only layers where ALL inputs are present produce output (Z=2.0 only; Z=1.0 and Z=3.0 are empty).",
|
|
inputs=[("Object A (Z=1, 2)", spA, "#1f77b4"), ("Object B (Z=2, 3)", spB, "#2ca02c")],
|
|
result=res6,
|
|
operation_name="INTERSECTION",
|
|
filename=fn6
|
|
)
|
|
test_results.append({
|
|
"id": "TC06",
|
|
"title": "Boolean Intersection: Multi-Layer Staggered",
|
|
"operation": "INTERSECTION",
|
|
"image": fn6,
|
|
"description": "Standard intersection requires all operands to be present on each layer, producing output only on layer 2.",
|
|
"input_count": 2,
|
|
"output_paths": len(res6),
|
|
"status": "PASS" if len(res6) == 1 and np.allclose(res6[0][:, 2], 2.0) else "FAIL"
|
|
})
|
|
|
|
# =========================================================================
|
|
# Test 7: Subtract - Outer Square Minus Inner Square (Hole Creation)
|
|
# =========================================================================
|
|
outer_box = np.array([[0, 0, 1.0], [30, 0, 1.0], [30, 30, 1.0], [0, 30, 1.0], [0, 0, 1.0]])
|
|
inner_box = np.array([[10, 10, 1.0], [20, 10, 1.0], [20, 20, 1.0], [10, 20, 1.0], [10, 10, 1.0]])
|
|
sp_outer = SlicePaths([outer_box], groups=[0], name="Outer Square")
|
|
sp_inner = SlicePaths([inner_box], groups=[0], name="Subtracted Inner")
|
|
|
|
res7 = SlicePaths.path_operations([sp_outer, sp_inner], operation='SUBTRACT')
|
|
fn7 = "07_subtract_hole_creation.png"
|
|
plot_operation_comparison(
|
|
title="7. Subtract: Difference Creating Interior Hole",
|
|
description="Subtracting an inner square from an outer square creates a donut polygon with 1 outer boundary and 1 hole.",
|
|
inputs=[("Outer Square (0-30)", sp_outer, "#1f77b4"), ("Inner Square (10-20)", sp_inner, "#d62728")],
|
|
result=res7,
|
|
operation_name="SUBTRACT (Difference)",
|
|
filename=fn7
|
|
)
|
|
test_results.append({
|
|
"id": "TC07",
|
|
"title": "Subtract: Difference Creating Interior Hole",
|
|
"operation": "SUBTRACT",
|
|
"image": fn7,
|
|
"description": "Performs geometric subtraction producing outer boundary + interior hole contour.",
|
|
"input_count": 2,
|
|
"output_paths": len(res7),
|
|
"status": "PASS" if len(res7) == 2 else "FAIL"
|
|
})
|
|
|
|
# =========================================================================
|
|
# Test 8: Exclusive Or (XOR) - Two Partially Overlapping Squares
|
|
# =========================================================================
|
|
res8 = SlicePaths.path_operations([sp1, sp2], operation='XOR')
|
|
fn8 = "08_xor_overlapping_squares.png"
|
|
plot_operation_comparison(
|
|
title="8. Exclusive Or (XOR): Symmetric Difference",
|
|
description="Symmetric difference of two overlapping squares removes the overlap [10, 20] and produces two L-shaped polygons.",
|
|
inputs=[("Square A (0-20)", sp1, "#1f77b4"), ("Square B (10-30)", sp2, "#ff7f0e")],
|
|
result=res8,
|
|
operation_name="XOR (Symmetric Difference)",
|
|
filename=fn8
|
|
)
|
|
test_results.append({
|
|
"id": "TC08",
|
|
"title": "Exclusive Or (XOR): Symmetric Difference",
|
|
"operation": "XOR",
|
|
"image": fn8,
|
|
"description": "Calculates symmetric difference, removing the overlapping intersection.",
|
|
"input_count": 2,
|
|
"output_paths": len(res8),
|
|
"status": "PASS" if len(res8) == 2 else "FAIL"
|
|
})
|
|
|
|
# =========================================================================
|
|
# Test 9: Zip Paths - Perimeter and Infill Interleaving
|
|
# =========================================================================
|
|
perim1 = np.array([[0, 0, 1.0], [20, 0, 1.0], [20, 20, 1.0], [0, 20, 1.0], [0, 0, 1.0]])
|
|
infill1 = np.array([[2, 2, 1.0], [18, 18, 1.0]])
|
|
perim2 = np.array([[0, 0, 2.0], [20, 0, 2.0], [20, 20, 2.0], [0, 20, 2.0], [0, 0, 2.0]])
|
|
infill2 = np.array([[2, 18, 2.0], [18, 2, 2.0]])
|
|
|
|
sp_perims = SlicePaths([perim1, perim2], groups=[0, 1], name="Perimeters")
|
|
sp_infills = SlicePaths([infill1, infill2], groups=[0, 1], name="Infills")
|
|
|
|
res9 = SlicePaths.path_operations([sp_perims, sp_infills], operation='ZIP')
|
|
fn9 = "09_zip_paths_layer_interleaving.png"
|
|
plot_operation_comparison(
|
|
title="9. Zip Paths: Layer-by-Layer Interleaving",
|
|
description="Interleaves perimeters and infills layer by layer: Layer 1 Perimeter -> Layer 1 Infill -> Layer 2 Perimeter -> Layer 2 Infill.",
|
|
inputs=[("Perimeter Paths", sp_perims, "#1f77b4"), ("Infill Paths", sp_infills, "#2ca02c")],
|
|
result=res9,
|
|
operation_name="ZIP (Layer Interleave)",
|
|
filename=fn9
|
|
)
|
|
test_results.append({
|
|
"id": "TC09",
|
|
"title": "Zip Paths: Layer-by-Layer Interleaving",
|
|
"operation": "ZIP",
|
|
"image": fn9,
|
|
"description": "Interleaves toolpath streams layer-by-layer conserving metadata and extrusion attributes.",
|
|
"input_count": 2,
|
|
"output_paths": len(res9),
|
|
"status": "PASS" if len(res9) == 4 and res9.groups == [0, 0, 1, 1] else "FAIL"
|
|
})
|
|
|
|
# =========================================================================
|
|
# Test 10: Extend Paths - Sequential Appending
|
|
# =========================================================================
|
|
res10 = SlicePaths.path_operations([sp_perims, sp_infills], operation='EXTEND')
|
|
fn10 = "10_extend_paths_sequential.png"
|
|
plot_operation_comparison(
|
|
title="10. Extend Paths: Sequential Concatenation",
|
|
description="Appends paths sequentially: all perimeter layers followed by all infill layers.",
|
|
inputs=[("Perimeter Paths", sp_perims, "#1f77b4"), ("Infill Paths", sp_infills, "#2ca02c")],
|
|
result=res10,
|
|
operation_name="EXTEND (Sequential Append)",
|
|
filename=fn10
|
|
)
|
|
test_results.append({
|
|
"id": "TC10",
|
|
"title": "Extend Paths: Sequential Concatenation",
|
|
"operation": "EXTEND",
|
|
"image": fn10,
|
|
"description": "Concatenates multiple toolpath streams sequentially preserving all group IDs and attributes.",
|
|
"input_count": 2,
|
|
"output_paths": len(res10),
|
|
"status": "PASS" if len(res10) == 4 else "FAIL"
|
|
})
|
|
|
|
# =========================================================================
|
|
# Test 11: Multiple Perimeters (2 Separate Inputs) - Outer vs Inner Perimeter
|
|
# =========================================================================
|
|
p_out = np.array([[0, 0, 1.0], [20, 0, 1.0], [20, 20, 1.0], [0, 20, 1.0], [0, 0, 1.0]])
|
|
p_in = np.array([[2, 2, 1.0], [18, 2, 1.0], [18, 18, 1.0], [2, 18, 1.0], [2, 2, 1.0]])
|
|
sp_outer_shell = SlicePaths([p_out], groups=[0], name="Outer Perimeter (0-20)")
|
|
sp_inner_shell = SlicePaths([p_in], groups=[0], name="Inner Perimeter (2-18)")
|
|
|
|
res11_max = SlicePaths.path_operations([sp_outer_shell, sp_inner_shell], operation='MAX_COVERAGE')
|
|
res11_min = SlicePaths.path_operations([sp_outer_shell, sp_inner_shell], operation='MIN_COVERAGE')
|
|
|
|
fn11_max = "11_max_coverage_concentric_perimeters.png"
|
|
plot_operation_comparison(
|
|
title="11. Maximum Coverage: Multiple Concentric Perimeters (2 Inputs)",
|
|
description="Two concentric perimeters. Maximum Coverage yields the outsidemost perimeter (0-20).",
|
|
inputs=[("Outer Perimeter (0-20)", sp_outer_shell, "#1f77b4"), ("Inner Perimeter (2-18)", sp_inner_shell, "#ff7f0e")],
|
|
result=res11_max,
|
|
operation_name="MAX_COVERAGE (Outsidemost Perimeter)",
|
|
filename=fn11_max
|
|
)
|
|
test_results.append({
|
|
"id": "TC11",
|
|
"title": "Maximum Coverage: Concentric Perimeters (2 Inputs)",
|
|
"operation": "MAX_COVERAGE",
|
|
"image": fn11_max,
|
|
"description": "Calculates boolean union across concentric shells yielding the outsidemost perimeter boundary.",
|
|
"input_count": 2,
|
|
"output_paths": len(res11_max),
|
|
"status": "PASS" if len(res11_max) == 1 and np.isclose(res11_max[0][:, 0].min(), 0.0) and np.isclose(res11_max[0][:, 0].max(), 20.0) else "FAIL"
|
|
})
|
|
|
|
fn11_min = "12_min_coverage_concentric_perimeters.png"
|
|
plot_operation_comparison(
|
|
title="12. Minimum Coverage: Multiple Concentric Perimeters (2 Inputs)",
|
|
description="Two concentric perimeters. Minimum Coverage yields the insidemost perimeter (2-18).",
|
|
inputs=[("Outer Perimeter (0-20)", sp_outer_shell, "#1f77b4"), ("Inner Perimeter (2-18)", sp_inner_shell, "#ff7f0e")],
|
|
result=res11_min,
|
|
operation_name="MIN_COVERAGE (Insidemost Perimeter)",
|
|
filename=fn11_min
|
|
)
|
|
test_results.append({
|
|
"id": "TC12",
|
|
"title": "Minimum Coverage: Concentric Perimeters (2 Inputs)",
|
|
"operation": "MIN_COVERAGE",
|
|
"image": fn11_min,
|
|
"description": "Calculates boolean intersection across concentric shells yielding the insidemost perimeter boundary.",
|
|
"input_count": 2,
|
|
"output_paths": len(res11_min),
|
|
"status": "PASS" if len(res11_min) == 1 and np.isclose(res11_min[0][:, 0].min(), 2.0) and np.isclose(res11_min[0][:, 0].max(), 18.0) else "FAIL"
|
|
})
|
|
|
|
# =========================================================================
|
|
# Test 13: Multiple Perimeters in a Single Input
|
|
# =========================================================================
|
|
sp_concentric_single = SlicePaths([p_out, p_in], groups=[0, 0], name="Concentric Shells Object")
|
|
res13_max = SlicePaths.path_operations([sp_concentric_single], operation='MAX_COVERAGE')
|
|
res13_min = SlicePaths.path_operations([sp_concentric_single], operation='MIN_COVERAGE')
|
|
|
|
fn13_max = "13_max_coverage_single_input_perimeters.png"
|
|
plot_operation_comparison(
|
|
title="13. Maximum Coverage: Single Input with Multiple Perimeters",
|
|
description="Single object containing both outer and inner perimeter loops. Max coverage yields the outsidemost perimeter.",
|
|
inputs=[("Concentric Shells Object", sp_concentric_single, "#1f77b4")],
|
|
result=res13_max,
|
|
operation_name="MAX_COVERAGE (Single Input)",
|
|
filename=fn13_max
|
|
)
|
|
test_results.append({
|
|
"id": "TC13",
|
|
"title": "Maximum Coverage: Single Input with Concentric Shells",
|
|
"operation": "MAX_COVERAGE",
|
|
"image": fn13_max,
|
|
"description": "Unions concentric perimeter loops within a single input into the outermost boundary.",
|
|
"input_count": 1,
|
|
"output_paths": len(res13_max),
|
|
"status": "PASS" if len(res13_max) == 1 and np.isclose(res13_max[0][:, 0].min(), 0.0) else "FAIL"
|
|
})
|
|
|
|
fn13_min = "14_min_coverage_single_input_perimeters.png"
|
|
plot_operation_comparison(
|
|
title="14. Minimum Coverage: Single Input with Multiple Perimeters",
|
|
description="Single object containing both outer and inner perimeter loops. Min coverage yields the insidemost perimeter.",
|
|
inputs=[("Concentric Shells Object", sp_concentric_single, "#1f77b4")],
|
|
result=res13_min,
|
|
operation_name="MIN_COVERAGE (Single Input)",
|
|
filename=fn13_min
|
|
)
|
|
test_results.append({
|
|
"id": "TC14",
|
|
"title": "Minimum Coverage: Single Input with Concentric Shells",
|
|
"operation": "MIN_COVERAGE",
|
|
"image": fn13_min,
|
|
"description": "Intersects concentric perimeter loops within a single input into the innermost boundary.",
|
|
"input_count": 1,
|
|
"output_paths": len(res13_min),
|
|
"status": "PASS" if len(res13_min) == 1 and np.isclose(res13_min[0][:, 0].min(), 2.0) else "FAIL"
|
|
})
|
|
|
|
# Generate HTML Gallery
|
|
generate_html_report(test_results)
|
|
generate_markdown_readme(test_results)
|
|
|
|
return test_results
|
|
|
|
|
|
def generate_html_report(test_results: list[dict]):
|
|
"""Generates a modern HTML gallery showcasing all visual test comparisons."""
|
|
html_file = os.path.join(OUTPUT_DIR, "index.html")
|
|
|
|
rows_html = ""
|
|
for r in test_results:
|
|
badge_class = "badge-pass" if r["status"] == "PASS" else "badge-fail"
|
|
rows_html += f"""
|
|
<div class="test-card">
|
|
<div class="test-header">
|
|
<h3>{r['id']}: {r['title']}</h3>
|
|
<span class="badge {badge_class}">{r['status']}</span>
|
|
</div>
|
|
<p class="test-desc">{r['description']}</p>
|
|
<div class="test-meta">
|
|
<span><strong>Operation:</strong> <code>{r['operation']}</code></span>
|
|
<span><strong>Inputs:</strong> {r['input_count']}</span>
|
|
<span><strong>Output Paths:</strong> {r['output_paths']}</span>
|
|
</div>
|
|
<div class="test-image">
|
|
<a href="images/{r['image']}" target="_blank">
|
|
<img src="images/{r['image']}" alt="{r['title']}" loading="lazy" />
|
|
</a>
|
|
</div>
|
|
</div>
|
|
"""
|
|
|
|
html_content = f"""<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>NodeSlicer - Path Operations Visual Test Suite</title>
|
|
<style>
|
|
:root {{
|
|
--bg-color: #0f141c;
|
|
--card-bg: #1a2230;
|
|
--text-color: #e2e8f0;
|
|
--text-muted: #94a3b8;
|
|
--accent-color: #38bdf8;
|
|
--border-color: #334155;
|
|
--pass-color: #22c55e;
|
|
--fail-color: #ef4444;
|
|
}}
|
|
body {{
|
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
background-color: var(--bg-color);
|
|
color: var(--text-color);
|
|
margin: 0;
|
|
padding: 24px;
|
|
}}
|
|
.container {{
|
|
max-width: 1400px;
|
|
margin: 0 auto;
|
|
}}
|
|
header {{
|
|
border-bottom: 1px solid var(--border-color);
|
|
padding-bottom: 16px;
|
|
margin-bottom: 24px;
|
|
}}
|
|
h1 {{
|
|
color: var(--accent-color);
|
|
margin: 0 0 8px 0;
|
|
}}
|
|
p.subtitle {{
|
|
color: var(--text-muted);
|
|
margin: 0;
|
|
font-size: 1.1rem;
|
|
}}
|
|
.grid {{
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(650px, 1fr));
|
|
gap: 24px;
|
|
}}
|
|
.test-card {{
|
|
background: var(--card-bg);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 8px;
|
|
padding: 20px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}}
|
|
.test-header {{
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 8px;
|
|
}}
|
|
.test-header h3 {{
|
|
margin: 0;
|
|
font-size: 1.2rem;
|
|
color: #f8fafc;
|
|
}}
|
|
.badge {{
|
|
padding: 4px 10px;
|
|
border-radius: 12px;
|
|
font-size: 0.8rem;
|
|
font-weight: bold;
|
|
text-transform: uppercase;
|
|
}}
|
|
.badge-pass {{
|
|
background: rgba(34, 197, 94, 0.2);
|
|
color: var(--pass-color);
|
|
border: 1px solid var(--pass-color);
|
|
}}
|
|
.badge-fail {{
|
|
background: rgba(239, 68, 68, 0.2);
|
|
color: var(--fail-color);
|
|
border: 1px solid var(--fail-color);
|
|
}}
|
|
.test-desc {{
|
|
color: var(--text-muted);
|
|
margin: 0 0 12px 0;
|
|
font-size: 0.95rem;
|
|
}}
|
|
.test-meta {{
|
|
display: flex;
|
|
gap: 16px;
|
|
font-size: 0.85rem;
|
|
color: var(--text-muted);
|
|
margin-bottom: 16px;
|
|
padding: 8px 12px;
|
|
background: rgba(0, 0, 0, 0.2);
|
|
border-radius: 4px;
|
|
}}
|
|
code {{
|
|
background: #0f172a;
|
|
color: var(--accent-color);
|
|
padding: 2px 6px;
|
|
border-radius: 4px;
|
|
font-family: monospace;
|
|
}}
|
|
.test-image img {{
|
|
width: 100%;
|
|
height: auto;
|
|
border-radius: 6px;
|
|
border: 1px solid var(--border-color);
|
|
transition: transform 0.2s;
|
|
}}
|
|
.test-image img:hover {{
|
|
transform: scale(1.01);
|
|
}}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<header>
|
|
<h1>NodeSlicer - Path Operations Visual Test Suite</h1>
|
|
<p class="subtitle">Side-by-side verification gallery for Boolean Union, Multi-layer Minimum Coverage, Difference, XOR, Zip, and Extend operations.</p>
|
|
</header>
|
|
<div class="grid">
|
|
{rows_html}
|
|
</div>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
"""
|
|
with open(html_file, "w", encoding="utf-8") as f:
|
|
f.write(html_content)
|
|
|
|
|
|
def generate_markdown_readme(test_results: list[dict]):
|
|
"""Generates README.md summary in tests/visual directory."""
|
|
md_file = os.path.join(OUTPUT_DIR, "README.md")
|
|
lines = [
|
|
"# NodeSlicer Path Operations Visual Test Suite",
|
|
"",
|
|
"This directory contains visual verifications and plots for all operations in the `Path Operations` node:",
|
|
"- **Maximum Coverage (Union)**: Boolean union of all polygons layer-by-layer.",
|
|
"- **Minimum Coverage**: Per-layer intersection of all objects having paths on that layer (ignoring empty layers so single-object layers are preserved intact).",
|
|
"- **Intersection**: Standard boolean intersection across all inputs.",
|
|
"- **Subtract**: Difference subtracting subsequent inputs from first input.",
|
|
"- **Exclusive Or (XOR)**: Symmetric difference across inputs.",
|
|
"- **Zip Paths**: Layer-by-layer toolpath stream interleaving.",
|
|
"- **Extend Paths**: Sequential toolpath concatenation.",
|
|
"",
|
|
"## Visual Gallery",
|
|
"Open `index.html` in your web browser to view the complete visual report with side-by-side before/after plots.",
|
|
"",
|
|
"## Summary of Test Cases",
|
|
"| ID | Operation | Test Title | Status | Image |",
|
|
"|---|---|---|---|---|"
|
|
]
|
|
for r in test_results:
|
|
lines.append(f"| {r['id']} | `{r['operation']}` | {r['title']} | **{r['status']}** | [{r['image']}](images/{r['image']}) |")
|
|
|
|
lines.extend([
|
|
"",
|
|
"## Running Visual Tests",
|
|
"To regenerate all images and the HTML gallery, run:",
|
|
"```bash",
|
|
".venv/bin/python -m unittest tests.test_visual_operations",
|
|
"```"
|
|
])
|
|
|
|
with open(md_file, "w", encoding="utf-8") as f:
|
|
f.write("\n".join(lines) + "\n")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
results = run_all_visualizations()
|
|
print(f"Generated {len(results)} visual tests in {OUTPUT_DIR}")
|