nodeslicer/tests/visual/visualize_path_operations.py

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}")