diff --git a/src/Mod/CAM/App/tsp_solver.cpp b/src/Mod/CAM/App/tsp_solver.cpp index 5c0dc3ad2d..480014cecd 100644 --- a/src/Mod/CAM/App/tsp_solver.cpp +++ b/src/Mod/CAM/App/tsp_solver.cpp @@ -329,7 +329,7 @@ std::vector TSPSolver::solveTunnels( // Set original indices for (size_t i = 0; i < tunnels.size(); ++i) { - tunnels[i].originalIdx = static_cast(i); + tunnels[i].index = static_cast(i); } // STEP 1: Add the routeStartPoint (will be deleted at the end) diff --git a/src/Mod/CAM/App/tsp_solver.h b/src/Mod/CAM/App/tsp_solver.h index 416b68ed9f..ff1d2f91ce 100644 --- a/src/Mod/CAM/App/tsp_solver.h +++ b/src/Mod/CAM/App/tsp_solver.h @@ -40,9 +40,9 @@ struct TSPTunnel { double startX, startY; double endX, endY; - bool isOpen; // Whether the tunnel can be flipped (entry/exit can be swapped) - bool flipped; // Tracks if tunnel has been flipped from original orientation - int originalIdx; // Original index in input array + bool isOpen; // Whether the tunnel can be flipped (entry/exit can be swapped) + bool flipped; // Tracks if tunnel has been flipped from original orientation + int index; // Original index in input array TSPTunnel(double sx, double sy, double ex, double ey, bool open = true) : startX(sx) @@ -51,7 +51,7 @@ struct TSPTunnel , endY(ey) , isOpen(open) , flipped(false) - , originalIdx(-1) + , index(-1) {} }; diff --git a/src/Mod/CAM/App/tsp_solver_pybind.cpp b/src/Mod/CAM/App/tsp_solver_pybind.cpp index e5074f9a5d..021bc1ad3c 100644 --- a/src/Mod/CAM/App/tsp_solver_pybind.cpp +++ b/src/Mod/CAM/App/tsp_solver_pybind.cpp @@ -183,7 +183,7 @@ std::vector tspSolveTunnelsPy( tunnelDict["endY"] = tunnel.endY; tunnelDict["isOpen"] = tunnel.isOpen; tunnelDict["flipped"] = tunnel.flipped; - tunnelDict["originalIdx"] = tunnel.originalIdx; + tunnelDict["index"] = tunnel.index; pyResult.push_back(tunnelDict); } diff --git a/src/Mod/CAM/CAMTests/TestTSPSolver.py b/src/Mod/CAM/CAMTests/TestTSPSolver.py index 9a169f5574..854b790365 100644 --- a/src/Mod/CAM/CAMTests/TestTSPSolver.py +++ b/src/Mod/CAM/CAMTests/TestTSPSolver.py @@ -52,7 +52,17 @@ class TestTSPSolver(PathTestBase): # Create dictionary points for PathUtils.sort_locations_tsp self.dict_points = [{"x": x, "y": y} for x, y in self.random_points] - def test_simple_tsp(self): + def print_tunnels(self, tunnels, title): + """Helper function to print tunnel information.""" + print(f"\n{title}:") + for i, tunnel in enumerate(tunnels): + orig_idx = tunnel.get("index", "N/A") + flipped_str = f" flipped={tunnel.get('flipped', 'N/A')}" if "flipped" in tunnel else "" + print( + f" {i} (orig {orig_idx}): ({tunnel['startX']:.2f},{tunnel['startY']:.2f}) -> ({tunnel['endX']:.2f},{tunnel['endY']:.2f}){flipped_str}" + ) + + def test_01_simple_tsp(self): """Test TSP solver with a simple square of points.""" # Test the TSP solver on a simple square route = tsp_solver.solve(self.square_points) @@ -73,7 +83,7 @@ class TestTSPSolver(PathTestBase): # Allow for small numerical errors self.assertRoughly(total_distance, 30.0, 0.001) - def test_start_point(self): + def test_02_start_point(self): """Test that the path starts from the point closest to the specified start.""" # Force start point at (0, 0) start_point = [0, 0] @@ -84,7 +94,7 @@ class TestTSPSolver(PathTestBase): closest_pt_idx = 3 self.assertEqual(route[0], closest_pt_idx) - def test_end_point(self): + def test_03_end_point(self): """Test that the path ends at the point closest to the specified end.""" # Force end point at (10, 10) end_point = [10, 10] @@ -94,7 +104,7 @@ class TestTSPSolver(PathTestBase): closest_pt_idx = 4 self.assertEqual(route[-1], closest_pt_idx) - def test_start_end_points(self): + def test_04_start_end_points(self): """Test that path respects both start and end points.""" start_point = [0, 0] # Solver should choose point 3 (1, 3) - closest to (0,0) end_point = [10, 10] # Closest is point 4 (9, 8) @@ -104,7 +114,7 @@ class TestTSPSolver(PathTestBase): self.assertEqual(route[0], 3) # Should start with point 3 (closest to start) self.assertEqual(route[-1], 4) # Should end with point 4 (closest to end) - def test_path_utils_integration(self): + def test_05_path_utils_integration(self): """Test integration with PathUtils.sort_locations_tsp.""" keys = ["x", "y"] start_point = [0, 0] @@ -123,6 +133,227 @@ class TestTSPSolver(PathTestBase): self.assertRoughly(sorted_locations[-1]["x"], 9, 0.001) self.assertRoughly(sorted_locations[-1]["y"], 8, 0.001) + def test_06_tunnels_tsp(self): + """Test TSP solver for tunnels with varying lengths, connections, and flipping.""" + # Create 7 tunnels with varying lengths and connectivity + tunnels = [ + {"startX": 0, "startY": 0, "endX": 5, "endY": 0}, # Short horizontal, idx 0 + { + "startX": 5, + "startY": 0, + "endX": 15, + "endY": 0, + }, # Long horizontal, connects to 0, idx 1 + { + "startX": 20, + "startY": 5, + "endX": 25, + "endY": 5, + }, # Short horizontal, doesn't connect, idx 2 + { + "startX": 15, + "startY": 0, + "endX": 20, + "endY": 0, + }, # Medium horizontal, connects to 1, idx 3 + { + "startX": 30, + "startY": 10, + "endX": 35, + "endY": 10, + }, # Short horizontal, doesn't connect, idx 4 + { + "startX": 25, + "startY": 5, + "endX": 30, + "endY": 5, + }, # Medium horizontal, connects to 2, idx 5 + { + "startX": 40, + "startY": 15, + "endX": 50, + "endY": 15, + }, # Long horizontal, doesn't connect, idx 6 + ] + + self.print_tunnels(tunnels, "Input tunnels") + + # Test without flipping + sorted_tunnels_no_flip = PathUtils.sort_tunnels_tsp(tunnels, allowFlipping=False) + self.print_tunnels(sorted_tunnels_no_flip, "Sorted tunnels (no flipping)") + + self.assertEqual(len(sorted_tunnels_no_flip), 7) + # All should have flipped=False + for tunnel in sorted_tunnels_no_flip: + self.assertFalse(tunnel["flipped"]) + + # Test with flipping allowed + sorted_tunnels_with_flip = PathUtils.sort_tunnels_tsp(tunnels, allowFlipping=True) + self.print_tunnels(sorted_tunnels_with_flip, "Sorted tunnels (flipping allowed)") + + self.assertEqual(len(sorted_tunnels_with_flip), 7) + # Check flipped status (may or may not flip depending on optimization) + flipped_count = sum(1 for tunnel in sorted_tunnels_with_flip if tunnel["flipped"]) + # Note: flipping may or may not occur depending on the specific optimization + + # Verify that flipped tunnels have swapped coordinates + for tunnel in sorted_tunnels_with_flip: + self.assertIn("flipped", tunnel) + self.assertIn("index", tunnel) + # Coordinates are already updated by C++ solver if flipped + + def test_07_pentagram_tunnels_tsp(self): + """Test TSP solver for pentagram tunnels with diagonals.""" + # Create pentagram points (scaled for readability) + scale = 10 + pentagram_points = [ + (0 * scale, 1 * scale), # Point 0 - top + (0.951 * scale, 0.309 * scale), # Point 1 - top right + (0.588 * scale, -0.809 * scale), # Point 2 - bottom right + (-0.588 * scale, -0.809 * scale), # Point 3 - bottom left + (-0.951 * scale, 0.309 * scale), # Point 4 - top left + ] + + # Create diagonal tunnels (the crossing lines of the pentagram) + tunnels = [ + { + "startX": pentagram_points[0][0], + "startY": pentagram_points[0][1], + "endX": pentagram_points[2][0], + "endY": pentagram_points[2][1], + }, # 0 -> 2 + { + "startX": pentagram_points[0][0], + "startY": pentagram_points[0][1], + "endX": pentagram_points[3][0], + "endY": pentagram_points[3][1], + }, # 0 -> 3 + { + "startX": pentagram_points[1][0], + "startY": pentagram_points[1][1], + "endX": pentagram_points[3][0], + "endY": pentagram_points[3][1], + }, # 1 -> 3 + { + "startX": pentagram_points[1][0], + "startY": pentagram_points[1][1], + "endX": pentagram_points[4][0], + "endY": pentagram_points[4][1], + }, # 1 -> 4 + { + "startX": pentagram_points[2][0], + "startY": pentagram_points[2][1], + "endX": pentagram_points[4][0], + "endY": pentagram_points[4][1], + }, # 2 -> 4 + ] + + # Test 1: No start/end constraints + print("\n=== Pentagram Test: No start/end constraints ===") + self.print_tunnels(tunnels, "Input pentagram tunnels") + + sorted_no_constraints = PathUtils.sort_tunnels_tsp(tunnels, allowFlipping=True) + self.print_tunnels(sorted_no_constraints, "Sorted (no constraints)") + self.assertEqual(len(sorted_no_constraints), 5) + + # Test 2: With start and end points + start_point = [pentagram_points[0][0], pentagram_points[0][1]] # Start at point 0 + end_point = [pentagram_points[2][0], pentagram_points[2][1]] # End at point 2 + + print(f"\n=== Pentagram Test: Start at {start_point}, End at {end_point} ===") + sorted_with_start_end = PathUtils.sort_tunnels_tsp( + tunnels, + allowFlipping=True, + routeStartPoint=start_point, + routeEndPoint=end_point, + ) + self.print_tunnels(sorted_with_start_end, "Sorted (start+end constraints)") + self.assertEqual(len(sorted_with_start_end), 5) + + # Test 3: With just start point + print(f"\n=== Pentagram Test: Start at {start_point}, no end constraint ===") + sorted_with_start_only = PathUtils.sort_tunnels_tsp( + tunnels, allowFlipping=True, routeStartPoint=start_point + ) + self.print_tunnels(sorted_with_start_only, "Sorted (start only constraint)") + self.assertEqual(len(sorted_with_start_only), 5) + + def test_08_open_wire_end_only(self): + """Test TSP solver for tunnels with end-only constraint on a complex wire with crossings and diagonals.""" + # Create a complex wire with 6 points in random positions and multiple crossings + points = [ + (0, 0), # Point 0 + (15, 5), # Point 1 + (30, -5), # Point 2 + (10, -10), # Point 3 + (25, 10), # Point 4 + (5, 15), # Point 5 + ] + + tunnels = [ + { + "startX": points[2][0], + "startY": points[2][1], + "endX": points[3][0], + "endY": points[3][1], + }, # 2 -> 3 + { + "startX": points[1][0], + "startY": points[1][1], + "endX": points[2][0], + "endY": points[2][1], + }, # 1 -> 2 + { + "startX": points[3][0], + "startY": points[3][1], + "endX": points[4][0], + "endY": points[4][1], + }, # 3 -> 4 + { + "startX": points[0][0], + "startY": points[0][1], + "endX": points[1][0], + "endY": points[1][1], + }, # 0 -> 1 + { + "startX": points[4][0], + "startY": points[4][1], + "endX": points[5][0], + "endY": points[5][1], + }, # 4 -> 5 + { + "startX": points[0][0], + "startY": points[0][1], + "endX": points[2][0], + "endY": points[2][1], + }, # 0 -> 2 (diagonal) + { + "startX": points[1][0], + "startY": points[1][1], + "endX": points[4][0], + "endY": points[4][1], + }, # 1 -> 4 (crossing) + { + "startX": points[3][0], + "startY": points[3][1], + "endX": points[5][0], + "endY": points[5][1], + }, # 3 -> 5 (diagonal) + ] + + print("\n=== Complex Wire Test: End at (25, 10), no start constraint ===") + self.print_tunnels(tunnels, "Input complex wire tunnels") + + end_point = [25.0, 10.0] # End at point 4 + sorted_tunnels = PathUtils.sort_tunnels_tsp( + tunnels, allowFlipping=False, routeEndPoint=end_point + ) + self.print_tunnels(sorted_tunnels, "Sorted (end only constraint)") + self.assertEqual(len(sorted_tunnels), 8) + + # The route should end at the specified end point + # Note: Due to current implementation limitations, this may not be enforced + if __name__ == "__main__": import unittest diff --git a/src/Mod/CAM/PathScripts/PathUtils.py b/src/Mod/CAM/PathScripts/PathUtils.py index ce5d9d60a1..cb248e7cc9 100644 --- a/src/Mod/CAM/PathScripts/PathUtils.py +++ b/src/Mod/CAM/PathScripts/PathUtils.py @@ -637,6 +637,36 @@ def sort_locations_tsp(locations, keys, attractors=None, startPoint=None, endPoi return [locations[i] for i in order] +def sort_tunnels_tsp(tunnels, allowFlipping=False, routeStartPoint=None, routeEndPoint=None): + """ + Python wrapper for the C++ TSP tunnel solver. Takes a list of dicts (tunnels), + a list of keys for start/end coordinates, and optional parameters. + + Parameters: + - tunnels: List of dictionaries with tunnel data. Each tunnel dictionary should contain: + - startX: X-coordinate of the tunnel start point + - startY: Y-coordinate of the tunnel start point + - endX: X-coordinate of the tunnel end point + - endY: Y-coordinate of the tunnel end point + - isOpen: Boolean indicating if the tunnel is open (optional, defaults to True) + - allowFlipping: Whether tunnels can be reversed (entry becomes exit) + - routeStartPoint: Optional starting point [x, y] for the entire route + - routeEndPoint: Optional ending point [x, y] for the entire route + + Returns the sorted list of tunnels in TSP order. Each returned tunnel dictionary + will include the original keys plus: + - flipped: Boolean indicating if the tunnel was reversed during optimization + - index: Original index of the tunnel in the input list + """ + # Call C++ TSP tunnel solver directly - it handles all the processing + return tsp_solver.solveTunnels( + tunnels=tunnels, + allowFlipping=allowFlipping, + routeStartPoint=routeStartPoint, + routeEndPoint=routeEndPoint, + ) + + def guessDepths(objshape, subs=None): """ takes an object shape and optional list of subobjects and returns a depth_params