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

{r['id']}: {r['title']}

{r['status']}

{r['description']}

Operation: {r['operation']} Inputs: {r['input_count']} Output Paths: {r['output_paths']}
{r['title']}
""" html_content = f""" NodeSlicer - Path Operations Visual Test Suite

NodeSlicer - Path Operations Visual Test Suite

Side-by-side verification gallery for Boolean Union, Multi-layer Minimum Coverage, Difference, XOR, Zip, and Extend operations.

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