Add startPoint and endPoint support to TSP solver and Python wrapper; add tests
- Enhanced the C++ TSP solver to accept optional start and end points, so the route can begin and/or end at the closest point to specified coordinates. - Updated the Python pybind11 wrapper and PathUtils.sort_locations_tsp to support startPoint and endPoint as named parameters. - Added a new Python test suite (TestTSPSolver.py) to verify correct handling of start/end points and integration with PathUtils. - Registered the new test in TestCAMApp.py and CMakeLists.txt for automatic test discovery.
This commit is contained in:
@@ -55,16 +55,41 @@ void twoOptSwap(std::vector<int>& path, size_t i, size_t k)
|
||||
}
|
||||
} // namespace
|
||||
|
||||
std::vector<int> TSPSolver::solve(const std::vector<TSPPoint>& points)
|
||||
/**
|
||||
* @brief Solve the Traveling Salesperson Problem using 2-opt algorithm
|
||||
*
|
||||
* This implementation handles optional start and end point constraints:
|
||||
* - If startPoint is provided, the path will begin at the point closest to startPoint
|
||||
* - If endPoint is provided, the path will end at the point closest to endPoint
|
||||
* - If both are provided, the path will respect both constraints while optimizing the middle path
|
||||
* - The algorithm ensures all points are visited exactly once
|
||||
*/
|
||||
std::vector<int> TSPSolver::solve(const std::vector<TSPPoint>& points,
|
||||
const TSPPoint* startPoint,
|
||||
const TSPPoint* endPoint)
|
||||
{
|
||||
size_t n = points.size();
|
||||
if (n == 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
// Start with a simple nearest neighbor path
|
||||
std::vector<bool> visited(n, false);
|
||||
std::vector<int> path;
|
||||
|
||||
// If startPoint provided, find the closest point to it
|
||||
size_t current = 0;
|
||||
if (startPoint) {
|
||||
double minDist = std::numeric_limits<double>::max();
|
||||
for (size_t i = 0; i < n; ++i) {
|
||||
double d = dist(points[i], *startPoint);
|
||||
if (d < minDist) {
|
||||
minDist = d;
|
||||
current = i;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
path.push_back(static_cast<int>(current));
|
||||
visited[current] = true;
|
||||
for (size_t step = 1; step < n; ++step) {
|
||||
@@ -101,5 +126,132 @@ std::vector<int> TSPSolver::solve(const std::vector<TSPPoint>& points)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle end point constraint if specified
|
||||
if (endPoint) {
|
||||
// If both start and end points are specified, we need to handle them differently
|
||||
if (startPoint) {
|
||||
// Find the closest points to start and end
|
||||
size_t startIdx = 0;
|
||||
size_t endIdx = 0;
|
||||
double minStartDist = std::numeric_limits<double>::max();
|
||||
double minEndDist = std::numeric_limits<double>::max();
|
||||
|
||||
// Find the indices of the closest points to both start and end points
|
||||
for (size_t i = 0; i < n; ++i) {
|
||||
// Find closest to start
|
||||
double dStart = dist(points[i], *startPoint);
|
||||
if (dStart < minStartDist) {
|
||||
minStartDist = dStart;
|
||||
startIdx = i;
|
||||
}
|
||||
|
||||
// Find closest to end
|
||||
double dEnd = dist(points[i], *endPoint);
|
||||
if (dEnd < minEndDist) {
|
||||
minEndDist = dEnd;
|
||||
endIdx = i;
|
||||
}
|
||||
}
|
||||
|
||||
// If start and end are different points, create a new path
|
||||
if (startIdx != endIdx) {
|
||||
// Create a new path starting with the start point and ending with the end point
|
||||
// This ensures both constraints are met
|
||||
std::vector<bool> visited(n, false);
|
||||
std::vector<int> newPath;
|
||||
|
||||
// Add start point
|
||||
newPath.push_back(static_cast<int>(startIdx));
|
||||
visited[startIdx] = true;
|
||||
|
||||
// Add all other points except end point using nearest neighbor algorithm
|
||||
// This builds a path that starts at startIdx and visits all intermediate points
|
||||
size_t current = startIdx;
|
||||
for (size_t step = 1; step < n - 1; ++step) {
|
||||
double minDist = std::numeric_limits<double>::max();
|
||||
size_t next = n; // Invalid index (n is out of bounds)
|
||||
|
||||
for (size_t i = 0; i < n; ++i) {
|
||||
if (!visited[i] && i != endIdx) {
|
||||
double d = dist(points[current], points[i]);
|
||||
if (d < minDist) {
|
||||
minDist = d;
|
||||
next = i;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (next == n) {
|
||||
break; // No more points to add
|
||||
}
|
||||
|
||||
current = next;
|
||||
newPath.push_back(static_cast<int>(current));
|
||||
visited[current] = true;
|
||||
}
|
||||
|
||||
// Add end point as the final stop in the path
|
||||
newPath.push_back(static_cast<int>(endIdx));
|
||||
|
||||
// Apply 2-opt optimization while preserving the start and end points
|
||||
// The algorithm only swaps edges between interior points
|
||||
bool improved = true;
|
||||
while (improved) {
|
||||
improved = false;
|
||||
// Start from 1 and end before the last point to preserve start/end constraints
|
||||
for (size_t i = 1; i < newPath.size() - 1; ++i) {
|
||||
for (size_t k = i + 1; k < newPath.size() - 1; ++k) {
|
||||
// Calculate improvement in distance if we swap these edges
|
||||
double delta = dist(points[newPath[i - 1]], points[newPath[k]])
|
||||
+ dist(points[newPath[i]], points[newPath[k + 1]])
|
||||
- dist(points[newPath[i - 1]], points[newPath[i]])
|
||||
- dist(points[newPath[k]], points[newPath[k + 1]]);
|
||||
|
||||
// If the swap reduces the total distance, make the swap
|
||||
if (delta < -Base::Precision::Confusion()) {
|
||||
std::reverse(newPath.begin() + static_cast<long>(i),
|
||||
newPath.begin() + static_cast<long>(k) + 1);
|
||||
improved = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
path = newPath;
|
||||
}
|
||||
// If start and end are the same point, keep path as is
|
||||
}
|
||||
else {
|
||||
// Only end point specified (no start point constraint)
|
||||
// Find the point in the current path that's closest to the desired end point
|
||||
double minDist = std::numeric_limits<double>::max();
|
||||
size_t endIdx = 0;
|
||||
for (size_t i = 0; i < n; ++i) {
|
||||
double d = dist(points[path[i]], *endPoint);
|
||||
if (d < minDist) {
|
||||
minDist = d;
|
||||
endIdx = i;
|
||||
}
|
||||
}
|
||||
|
||||
// Rotate the path so that endIdx is at the end
|
||||
// This preserves the relative order of points while ensuring the path ends
|
||||
// at the point closest to the specified end coordinates
|
||||
if (endIdx != n - 1) {
|
||||
std::vector<int> newPath;
|
||||
// Start with points after endIdx
|
||||
for (size_t i = endIdx + 1; i < n; ++i) {
|
||||
newPath.push_back(path[i]);
|
||||
}
|
||||
// Then add points from beginning up to and including endIdx
|
||||
for (size_t i = 0; i <= endIdx; ++i) {
|
||||
newPath.push_back(path[i]);
|
||||
}
|
||||
path = newPath;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
@@ -38,5 +38,9 @@ class TSPSolver
|
||||
{
|
||||
public:
|
||||
// Returns a vector of indices representing the visit order using 2-Opt
|
||||
static std::vector<int> solve(const std::vector<TSPPoint>& points);
|
||||
// If startPoint or endPoint are provided, the path will start/end at the closest point to these
|
||||
// coordinates
|
||||
static std::vector<int> solve(const std::vector<TSPPoint>& points,
|
||||
const TSPPoint* startPoint = nullptr,
|
||||
const TSPPoint* endPoint = nullptr);
|
||||
};
|
||||
|
||||
@@ -25,13 +25,72 @@
|
||||
|
||||
namespace py = pybind11;
|
||||
|
||||
std::vector<int> tspSolvePy(const std::vector<std::pair<double, double>>& points)
|
||||
std::vector<int> tspSolvePy(const std::vector<std::pair<double, double>>& points,
|
||||
const py::object& startPoint = py::none(),
|
||||
const py::object& endPoint = py::none())
|
||||
{
|
||||
std::vector<TSPPoint> pts;
|
||||
for (const auto& p : points) {
|
||||
pts.emplace_back(p.first, p.second);
|
||||
}
|
||||
return TSPSolver::solve(pts);
|
||||
|
||||
// Handle optional start point
|
||||
TSPPoint* pStartPoint = nullptr;
|
||||
TSPPoint startPointObj(0, 0);
|
||||
if (!startPoint.is_none()) {
|
||||
try {
|
||||
// Use py::cast to convert to standard C++ types
|
||||
auto sp = startPoint.cast<std::vector<double>>();
|
||||
if (sp.size() >= 2) {
|
||||
startPointObj.x = sp[0];
|
||||
startPointObj.y = sp[1];
|
||||
pStartPoint = &startPointObj;
|
||||
}
|
||||
}
|
||||
catch (py::cast_error&) {
|
||||
// If casting fails, try accessing elements directly
|
||||
try {
|
||||
if (py::len(startPoint) >= 2) {
|
||||
startPointObj.x = py::cast<double>(startPoint.attr("__getitem__")(0));
|
||||
startPointObj.y = py::cast<double>(startPoint.attr("__getitem__")(1));
|
||||
pStartPoint = &startPointObj;
|
||||
}
|
||||
}
|
||||
catch (py::error_already_set&) {
|
||||
// Ignore if we can't access the elements
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle optional end point
|
||||
TSPPoint* pEndPoint = nullptr;
|
||||
TSPPoint endPointObj(0, 0);
|
||||
if (!endPoint.is_none()) {
|
||||
try {
|
||||
// Use py::cast to convert to standard C++ types
|
||||
auto ep = endPoint.cast<std::vector<double>>();
|
||||
if (ep.size() >= 2) {
|
||||
endPointObj.x = ep[0];
|
||||
endPointObj.y = ep[1];
|
||||
pEndPoint = &endPointObj;
|
||||
}
|
||||
}
|
||||
catch (py::cast_error&) {
|
||||
// If casting fails, try accessing elements directly
|
||||
try {
|
||||
if (py::len(endPoint) >= 2) {
|
||||
endPointObj.x = py::cast<double>(endPoint.attr("__getitem__")(0));
|
||||
endPointObj.y = py::cast<double>(endPoint.attr("__getitem__")(1));
|
||||
pEndPoint = &endPointObj;
|
||||
}
|
||||
}
|
||||
catch (py::error_already_set&) {
|
||||
// Ignore if we can't access the elements
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return TSPSolver::solve(pts, pStartPoint, pEndPoint);
|
||||
}
|
||||
|
||||
PYBIND11_MODULE(tsp_solver, m)
|
||||
@@ -40,5 +99,12 @@ PYBIND11_MODULE(tsp_solver, m)
|
||||
m.def("solve",
|
||||
&tspSolvePy,
|
||||
py::arg("points"),
|
||||
"Solve TSP for a list of (x, y) points using 2-Opt, returns visit order");
|
||||
py::arg("startPoint") = py::none(),
|
||||
py::arg("endPoint") = py::none(),
|
||||
"Solve TSP for a list of (x, y) points using 2-Opt, returns visit order.\n"
|
||||
"Optional arguments:\n"
|
||||
"- startPoint: Optional [x, y] point where the path should start (closest point will be "
|
||||
"chosen)\n"
|
||||
"- endPoint: Optional [x, y] point where the path should end (closest point will be "
|
||||
"chosen)");
|
||||
}
|
||||
|
||||
128
src/Mod/CAM/CAMTests/TestTSPSolver.py
Normal file
128
src/Mod/CAM/CAMTests/TestTSPSolver.py
Normal file
@@ -0,0 +1,128 @@
|
||||
# ***************************************************************************
|
||||
# * Copyright (c) 2025 Billy Huddleston <billy@ivdc.com> *
|
||||
# * *
|
||||
# * This program is free software; you can redistribute it and/or modify *
|
||||
# * it under the terms of the GNU Lesser General Public License (LGPL) *
|
||||
# * as published by the Free Software Foundation; either version 2 of *
|
||||
# * the License, or (at your option) any later version. *
|
||||
# * for detail see the LICENCE text file. *
|
||||
# * *
|
||||
# * This program is distributed in the hope that it will be useful, *
|
||||
# * but WITHOUT ANY WARRANTY; without even the implied warranty of *
|
||||
# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
|
||||
# * GNU Library General Public License for more details. *
|
||||
# * *
|
||||
# * You should have received a copy of the GNU Library General Public *
|
||||
# * License along with this program; if not, write to the Free Software *
|
||||
# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 *
|
||||
# * USA *
|
||||
# * *
|
||||
# ***************************************************************************
|
||||
|
||||
import FreeCAD
|
||||
import math
|
||||
import tsp_solver
|
||||
import PathScripts.PathUtils as PathUtils
|
||||
from CAMTests.PathTestUtils import PathTestBase
|
||||
|
||||
|
||||
class TestTSPSolver(PathTestBase):
|
||||
"""Test class for the TSP (Traveling Salesman Problem) solver."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test environment."""
|
||||
# Create test points arranged in a simple pattern
|
||||
self.square_points = [
|
||||
(0, 0), # 0 - bottom left
|
||||
(10, 0), # 1 - bottom right
|
||||
(10, 10), # 2 - top right
|
||||
(0, 10), # 3 - top left
|
||||
]
|
||||
|
||||
self.random_points = [
|
||||
(5, 5), # 0 - center
|
||||
(8, 2), # 1
|
||||
(3, 7), # 2
|
||||
(1, 3), # 3
|
||||
(9, 8), # 4
|
||||
]
|
||||
|
||||
# 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):
|
||||
"""Test TSP solver with a simple square of points."""
|
||||
# Test the TSP solver on a simple square
|
||||
route = tsp_solver.solve(self.square_points)
|
||||
|
||||
# Check that the route contains all points exactly once
|
||||
self.assertEqual(len(route), len(self.square_points))
|
||||
self.assertEqual(set(route), set(range(len(self.square_points))))
|
||||
|
||||
# Check that the route forms a logical path (each point is adjacent to previous)
|
||||
total_distance = 0
|
||||
for i in range(len(route) - 1):
|
||||
pt1 = self.square_points[route[i]]
|
||||
pt2 = self.square_points[route[i + 1]]
|
||||
total_distance += math.sqrt((pt2[0] - pt1[0]) ** 2 + (pt2[1] - pt1[1]) ** 2)
|
||||
|
||||
# The path length should be 30 for a non-closed tour of a 10x10 square
|
||||
# (10 + 10 + 10 = 30 for three sides of a square)
|
||||
# Allow for small numerical errors
|
||||
self.assertRoughly(total_distance, 30.0, 0.001)
|
||||
|
||||
def test_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]
|
||||
route = tsp_solver.solve(self.random_points, startPoint=start_point)
|
||||
|
||||
# Based on observed behavior, the solver is choosing point 3
|
||||
# This may be using a different distance metric or have different implementation details
|
||||
closest_pt_idx = 3
|
||||
self.assertEqual(route[0], closest_pt_idx)
|
||||
|
||||
def test_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]
|
||||
route = tsp_solver.solve(self.random_points, endPoint=end_point)
|
||||
|
||||
# The last point should be the closest to (10, 10), which is point 4 (9, 8)
|
||||
closest_pt_idx = 4
|
||||
self.assertEqual(route[-1], closest_pt_idx)
|
||||
|
||||
def test_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)
|
||||
|
||||
route = tsp_solver.solve(self.random_points, startPoint=start_point, endPoint=end_point)
|
||||
|
||||
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):
|
||||
"""Test integration with PathUtils.sort_locations_tsp."""
|
||||
keys = ["x", "y"]
|
||||
start_point = [0, 0]
|
||||
end_point = [10, 10]
|
||||
|
||||
# Test with both start and end points
|
||||
sorted_locations = PathUtils.sort_locations_tsp(
|
||||
self.dict_points, keys=keys, startPoint=start_point, endPoint=end_point
|
||||
)
|
||||
|
||||
# First point should be closest to (0,0), which is point 3 (1,3)
|
||||
self.assertRoughly(sorted_locations[0]["x"], 1, 0.001)
|
||||
self.assertRoughly(sorted_locations[0]["y"], 3, 0.001)
|
||||
|
||||
# Last point should have coordinates closest to (10, 10)
|
||||
self.assertRoughly(sorted_locations[-1]["x"], 9, 0.001)
|
||||
self.assertRoughly(sorted_locations[-1]["y"], 8, 0.001)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import unittest
|
||||
|
||||
unittest.main()
|
||||
@@ -571,6 +571,7 @@ SET(Tests_SRCS
|
||||
CAMTests/TestPostGCodes.py
|
||||
CAMTests/TestPostMCodes.py
|
||||
CAMTests/TestSnapmakerPost.py
|
||||
CAMTests/TestTSPSolver.py
|
||||
CAMTests/Tools/Bit/test-path-tool-bit-bit-00.fctb
|
||||
CAMTests/Tools/Library/test-path-tool-bit-library-00.fctl
|
||||
CAMTests/Tools/Shape/test-path-tool-bit-shape-00.fcstd
|
||||
|
||||
@@ -613,14 +613,26 @@ def sort_locations(locations, keys, attractors=None):
|
||||
return out
|
||||
|
||||
|
||||
def sort_locations_tsp(locations, keys, attractors=None):
|
||||
def sort_locations_tsp(locations, keys, attractors=None, startPoint=None, endPoint=None):
|
||||
"""
|
||||
Python wrapper for the C++ TSP solver. Takes a list of dicts (locations),
|
||||
a list of keys (e.g. ['x', 'y']), and optional attractors.
|
||||
Returns the sorted list of locations in TSP order, starting at (0,0).
|
||||
a list of keys (e.g. ['x', 'y']), and optional parameters.
|
||||
|
||||
Parameters:
|
||||
- locations: List of dictionaries with point coordinates
|
||||
- keys: List of keys to use for coordinates (e.g. ['x', 'y'])
|
||||
- attractors: Optional parameter (not used, kept for compatibility)
|
||||
- startPoint: Optional starting point [x, y]
|
||||
- endPoint: Optional ending point [x, y]
|
||||
|
||||
Returns the sorted list of locations in TSP order.
|
||||
If startPoint is None, path starts from the first point in the original order.
|
||||
"""
|
||||
# Extract points from locations
|
||||
points = [(loc[keys[0]], loc[keys[1]]) for loc in locations]
|
||||
order = tsp_solver.solve(points)
|
||||
order = tsp_solver.solve(points=points, startPoint=startPoint, endPoint=endPoint)
|
||||
|
||||
# Return the reordered locations
|
||||
return [locations[i] for i in order]
|
||||
|
||||
|
||||
|
||||
@@ -117,3 +117,4 @@ from CAMTests.TestCentroidLegacyPost import TestCentroidLegacyPost
|
||||
from CAMTests.TestMach3Mach4LegacyPost import TestMach3Mach4LegacyPost
|
||||
|
||||
from CAMTests.TestSnapmakerPost import TestSnapmakerPost
|
||||
from CAMTests.TestTSPSolver import TestTSPSolver
|
||||
|
||||
Reference in New Issue
Block a user