Merge pull request #24726 from Connor9220/TwoOptTSPSolver

CAM: 2-Opt TSP solver
This commit is contained in:
sliptonic
2025-12-15 11:55:20 -06:00
committed by GitHub
8 changed files with 1322 additions and 0 deletions

View File

@@ -138,3 +138,15 @@ SET_BIN_DIR(Path PathApp /Mod/CAM)
SET_PYTHON_PREFIX_SUFFIX(Path)
INSTALL(TARGETS Path DESTINATION ${CMAKE_INSTALL_LIBDIR})
# --- TSP Solver Python module (pybind11) ---
find_package(pybind11 REQUIRED)
pybind11_add_module(tsp_solver MODULE tsp_solver_pybind.cpp tsp_solver.cpp)
target_include_directories(tsp_solver PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
SET_BIN_DIR(tsp_solver tsp_solver /Mod/CAM)
SET_PYTHON_PREFIX_SUFFIX(tsp_solver)
INSTALL(TARGETS tsp_solver DESTINATION ${CMAKE_INSTALL_LIBDIR})
# -------------------------------------------

View File

@@ -0,0 +1,586 @@
// SPDX-License-Identifier: LGPL-2.1-or-later
/***************************************************************************
* 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 *
* *
***************************************************************************/
#include "tsp_solver.h"
#include <vector>
#include <limits>
#include <cmath>
#include <algorithm>
#include <Base/Precision.h>
namespace
{
/**
* @brief Calculate Euclidean distance between two points
*
* Used for 2-opt and relocation steps where actual distance matters for path length optimization.
*
* @param a First point
* @param b Second point
* @return Actual distance: sqrt(dx² + dy²)
*/
double dist(const TSPPoint& a, const TSPPoint& b)
{
double dx = a.x - b.x;
double dy = a.y - b.y;
return std::sqrt(dx * dx + dy * dy);
}
/**
* @brief Calculate squared distance between two points (no sqrt)
*
* Used for nearest neighbor selection for performance (avoids expensive sqrt operation).
* Since we only need to compare distances, squared distance preserves ordering:
* if dist(A,B) < dist(A,C), then distSquared(A,B) < distSquared(A,C)
*
* @param a First point
* @param b Second point
* @return Squared distance: dx² + dy²
*/
double distSquared(const TSPPoint& a, const TSPPoint& b)
{
double dx = a.x - b.x;
double dy = a.y - b.y;
return dx * dx + dy * dy;
}
/**
* @brief Core TSP solver implementation using nearest neighbor + iterative improvement
*
* Algorithm steps:
* 1. Add temporary start/end points if specified
* 2. Build initial route using nearest neighbor heuristic
* 3. Optimize route with 2-opt and relocation moves
* 4. Remove temporary points and map back to original indices
*
* @param points Input points to visit
* @param startPoint Optional starting location constraint
* @param endPoint Optional ending location constraint
* @return Vector of indices representing optimized visit order
*/
std::vector<int> solve_impl(
const std::vector<TSPPoint>& points,
const TSPPoint* startPoint,
const TSPPoint* endPoint
)
{
// ========================================================================
// STEP 1: Prepare point set with temporary start/end markers
// ========================================================================
// We insert temporary points to enforce start/end constraints.
// These will be removed after optimization and won't appear in final result.
std::vector<TSPPoint> pts = points;
int tempStartIdx = -1, tempEndIdx = -1;
if (startPoint) {
// Insert user-specified start point at beginning
pts.insert(pts.begin(), TSPPoint(startPoint->x, startPoint->y));
tempStartIdx = 0;
}
else if (!pts.empty()) {
// No start specified: duplicate first point as anchor
pts.insert(pts.begin(), TSPPoint(pts[0].x, pts[0].y));
tempStartIdx = 0;
}
if (endPoint) {
// Add user-specified end point at the end
pts.push_back(TSPPoint(endPoint->x, endPoint->y));
tempEndIdx = static_cast<int>(pts.size()) - 1;
}
// ========================================================================
// STEP 2: Build initial route using Nearest Neighbor algorithm
// ========================================================================
// Greedy approach: always visit the closest unvisited point next.
// This gives a decent initial solution quickly (O(n²) complexity).
//
// Tie-breaking rule:
// - If distances are within ±0.1, prefer point with y-value closer to start
// - This provides deterministic results when points are nearly equidistant
std::vector<int> route;
std::vector<bool> visited(pts.size(), false);
route.push_back(0); // Start from temp start point (index 0)
visited[0] = true;
for (size_t step = 1; step < pts.size(); ++step) {
double minDist = std::numeric_limits<double>::max();
int next = -1;
double nextYDiff = std::numeric_limits<double>::max();
// Find nearest unvisited neighbor
for (size_t i = 0; i < pts.size(); ++i) {
if (!visited[i]) {
// Use squared distance for speed (no sqrt needed for comparison)
double d = distSquared(pts[route.back()], pts[i]);
double yDiff = std::abs(pts[route.front()].y - pts[i].y);
// Tie-breaking logic:
if (d > minDist + 0.1) {
continue; // Clearly farther, skip
}
else if (d < minDist - 0.1) {
// Clearly closer, use it
minDist = d;
next = static_cast<int>(i);
nextYDiff = yDiff;
}
else if (yDiff < nextYDiff) {
// Tie: prefer point closer to start in Y-axis
minDist = d;
next = static_cast<int>(i);
nextYDiff = yDiff;
}
}
}
if (next == -1) {
break; // No more unvisited points
}
route.push_back(next);
visited[next] = true;
}
// Ensure temporary end point is at the end of route
if (tempEndIdx != -1 && route.back() != tempEndIdx) {
auto it = std::find(route.begin(), route.end(), tempEndIdx);
if (it != route.end()) {
route.erase(it);
}
route.push_back(tempEndIdx);
}
// ========================================================================
// STEP 3: Iterative improvement using 2-Opt and Relocation
// ========================================================================
// Repeatedly apply local optimizations until no improvement is possible.
// This typically converges quickly (a few iterations) to a near-optimal solution.
//
// Two optimization techniques:
// 1. 2-Opt: Reverse segments of the route to eliminate crossing paths
// 2. Relocation: Move individual points to better positions in the route
bool improvementFound = true;
while (improvementFound) {
improvementFound = false;
// --- 2-Opt Optimization ---
// Try reversing every possible segment of the route.
// If reversing segment [i+1...j-1] reduces total distance, keep it.
//
// Example: Route A-B-C-D-E becomes A-D-C-B-E if reversing B-C-D is better
bool reorderFound = true;
while (reorderFound) {
reorderFound = false;
for (size_t i = 0; i + 3 < route.size(); ++i) {
for (size_t j = i + 3; j < route.size(); ++j) {
// Current edges: i→(i+1) and (j-1)→j
double curLen = dist(pts[route[i]], pts[route[i + 1]])
+ dist(pts[route[j - 1]], pts[route[j]]);
// New edges after reversal: (i+1)→j and i→(j-1)
// Add epsilon to prevent cycles from floating point errors
double newLen = dist(pts[route[i + 1]], pts[route[j]])
+ dist(pts[route[i]], pts[route[j - 1]]) + 1e-5;
if (newLen < curLen) {
// Reverse the segment between i+1 and j (exclusive)
std::reverse(route.begin() + i + 1, route.begin() + j);
reorderFound = true;
improvementFound = true;
}
}
}
}
// --- Relocation Optimization ---
// Try moving each point to a different position in the route.
// If moving point i to position j improves the route, do it.
bool relocateFound = true;
while (relocateFound) {
relocateFound = false;
for (size_t i = 1; i + 1 < route.size(); ++i) {
// Try moving point i backward (to positions before i)
for (size_t j = 1; j + 2 < i; ++j) {
// Current cost: edges around point i and edge j→(j+1)
double curLen = dist(pts[route[i - 1]], pts[route[i]])
+ dist(pts[route[i]], pts[route[i + 1]])
+ dist(pts[route[j]], pts[route[j + 1]]);
// New cost: bypass i, insert i after j
double newLen = dist(pts[route[i - 1]], pts[route[i + 1]])
+ dist(pts[route[j]], pts[route[i]])
+ dist(pts[route[i]], pts[route[j + 1]]) + 1e-5;
if (newLen < curLen) {
// Move point i to position after j
int node = route[i];
route.erase(route.begin() + i);
route.insert(route.begin() + j + 1, node);
relocateFound = true;
improvementFound = true;
}
}
// Try moving point i forward (to positions after i)
for (size_t j = i + 1; j + 1 < route.size(); ++j) {
double curLen = dist(pts[route[i - 1]], pts[route[i]])
+ dist(pts[route[i]], pts[route[i + 1]])
+ dist(pts[route[j]], pts[route[j + 1]]);
double newLen = dist(pts[route[i - 1]], pts[route[i + 1]])
+ dist(pts[route[j]], pts[route[i]])
+ dist(pts[route[i]], pts[route[j + 1]]) + 1e-5;
if (newLen < curLen) {
int node = route[i];
route.erase(route.begin() + i);
route.insert(route.begin() + j, node);
relocateFound = true;
improvementFound = true;
}
}
}
}
}
// ========================================================================
// STEP 4: Remove temporary start/end points
// ========================================================================
// The temporary markers served their purpose during optimization.
// Now remove them so they don't appear in the final result.
if (tempEndIdx != -1 && !route.empty() && route.back() == tempEndIdx) {
route.pop_back();
}
if (tempStartIdx != -1 && !route.empty() && route.front() == tempStartIdx) {
route.erase(route.begin());
}
// ========================================================================
// STEP 5: Map route indices back to original point array
// ========================================================================
// Since we inserted a temp start point at index 0, all subsequent indices
// are offset by 1. Adjust them back to match the original points array.
std::vector<int> result;
for (int idx : route) {
// Adjust for temp start offset
if (tempStartIdx != -1) {
--idx;
}
// Only include valid indices from the original points array
if (idx >= 0 && idx < static_cast<int>(points.size())) {
result.push_back(idx);
}
}
return result;
}
} // namespace
/**
* @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
)
{
return solve_impl(points, startPoint, endPoint);
}
std::vector<TSPTunnel> TSPSolver::solveTunnels(
std::vector<TSPTunnel> tunnels,
bool allowFlipping,
const TSPPoint* routeStartPoint,
const TSPPoint* routeEndPoint
)
{
if (tunnels.empty()) {
return tunnels;
}
// Set original indices
for (size_t i = 0; i < tunnels.size(); ++i) {
tunnels[i].index = static_cast<int>(i);
}
// STEP 1: Add the routeStartPoint (will be deleted at the end)
if (routeStartPoint) {
tunnels.insert(
tunnels.begin(),
TSPTunnel(routeStartPoint->x, routeStartPoint->y, routeStartPoint->x, routeStartPoint->y, false)
);
}
else {
tunnels.insert(tunnels.begin(), TSPTunnel(0.0, 0.0, 0.0, 0.0, false));
}
// STEP 2: Apply nearest neighbor algorithm
std::vector<TSPTunnel> potentialNeighbours(tunnels.begin() + 1, tunnels.end());
std::vector<TSPTunnel> route;
route.push_back(tunnels[0]);
while (!potentialNeighbours.empty()) {
double costCurrent = std::numeric_limits<double>::max();
bool toBeFlipped = false;
auto nearestNeighbour = potentialNeighbours.begin();
// Check normal orientation
for (auto it = potentialNeighbours.begin(); it != potentialNeighbours.end(); ++it) {
double dx = route.back().endX - it->startX;
double dy = route.back().endY - it->startY;
double costNew = dx * dx + dy * dy;
if (costNew < costCurrent) {
costCurrent = costNew;
toBeFlipped = false;
nearestNeighbour = it;
}
}
// Check flipped orientation if allowed
if (allowFlipping) {
for (auto it = potentialNeighbours.begin(); it != potentialNeighbours.end(); ++it) {
if (it->isOpen) {
double dx = route.back().endX - it->endX;
double dy = route.back().endY - it->endY;
double costNew = dx * dx + dy * dy;
if (costNew < costCurrent) {
costCurrent = costNew;
toBeFlipped = true;
nearestNeighbour = it;
}
}
}
}
// Apply flipping if needed
if (toBeFlipped) {
nearestNeighbour->flipped = !nearestNeighbour->flipped;
std::swap(nearestNeighbour->startX, nearestNeighbour->endX);
std::swap(nearestNeighbour->startY, nearestNeighbour->endY);
}
route.push_back(*nearestNeighbour);
potentialNeighbours.erase(nearestNeighbour);
}
// STEP 3: Add the routeEndPoint (will be deleted at the end)
if (routeEndPoint) {
route.push_back(
TSPTunnel(routeEndPoint->x, routeEndPoint->y, routeEndPoint->x, routeEndPoint->y, false)
);
}
// STEP 4: Additional improvement of the route
bool improvementFound = true;
while (improvementFound) {
improvementFound = false;
if (allowFlipping) {
// STEP 4.1: Apply 2-opt
bool improvementReorderFound = true;
while (improvementReorderFound) {
improvementReorderFound = false;
for (size_t i = 0; i + 3 < route.size(); ++i) {
for (size_t j = i + 3; j < route.size(); ++j) {
double subRouteLengthCurrent = std::sqrt(
std::pow(route[i].endX - route[i + 1].startX, 2)
+ std::pow(route[i].endY - route[i + 1].startY, 2)
);
subRouteLengthCurrent += std::sqrt(
std::pow(route[j - 1].endX - route[j].startX, 2)
+ std::pow(route[j - 1].endY - route[j].startY, 2)
);
double subRouteLengthNew = std::sqrt(
std::pow(route[i + 1].startX - route[j].startX, 2)
+ std::pow(route[i + 1].startY - route[j].startY, 2)
);
subRouteLengthNew += std::sqrt(
std::pow(route[i].endX - route[j - 1].endX, 2)
+ std::pow(route[i].endY - route[j - 1].endY, 2)
);
subRouteLengthNew += 1e-6;
if (subRouteLengthNew < subRouteLengthCurrent) {
// Flip direction of each tunnel between i-th and j-th element
for (size_t k = i + 1; k < j; ++k) {
if (route[k].isOpen) {
route[k].flipped = !route[k].flipped;
std::swap(route[k].startX, route[k].endX);
std::swap(route[k].startY, route[k].endY);
}
}
// Reverse the order of tunnels between i-th and j-th element
std::reverse(route.begin() + i + 1, route.begin() + j);
improvementReorderFound = true;
improvementFound = true;
}
}
}
}
// STEP 4.2: Apply flipping
bool improvementFlipFound = true;
while (improvementFlipFound) {
improvementFlipFound = false;
for (size_t i = 1; i + 1 < route.size(); ++i) {
if (route[i].isOpen) {
double subRouteLengthCurrent = std::sqrt(
std::pow(route[i - 1].endX - route[i].startX, 2)
+ std::pow(route[i - 1].endY - route[i].startY, 2)
);
subRouteLengthCurrent += std::sqrt(
std::pow(route[i].endX - route[i + 1].startX, 2)
+ std::pow(route[i].endY - route[i + 1].startY, 2)
);
double subRouteLengthNew = std::sqrt(
std::pow(route[i - 1].endX - route[i].endX, 2)
+ std::pow(route[i - 1].endY - route[i].endY, 2)
);
subRouteLengthNew += std::sqrt(
std::pow(route[i].startX - route[i + 1].startX, 2)
+ std::pow(route[i].startY - route[i + 1].startY, 2)
);
subRouteLengthNew += 1e-6;
if (subRouteLengthNew < subRouteLengthCurrent) {
// Flip direction of i-th tunnel
route[i].flipped = !route[i].flipped;
std::swap(route[i].startX, route[i].endX);
std::swap(route[i].startY, route[i].endY);
improvementFlipFound = true;
improvementFound = true;
}
}
}
}
}
// STEP 4.3: Apply relocation
bool improvementRelocateFound = true;
while (improvementRelocateFound) {
improvementRelocateFound = false;
for (size_t i = 1; i + 1 < route.size(); ++i) {
// Try relocating backward
for (size_t j = 1; j + 2 < i; ++j) {
double subRouteLengthCurrent = std::sqrt(
std::pow(route[i - 1].endX - route[i].startX, 2)
+ std::pow(route[i - 1].endY - route[i].startY, 2)
);
subRouteLengthCurrent += std::sqrt(
std::pow(route[i].endX - route[i + 1].startX, 2)
+ std::pow(route[i].endY - route[i + 1].startY, 2)
);
subRouteLengthCurrent += std::sqrt(
std::pow(route[j].endX - route[j + 1].startX, 2)
+ std::pow(route[j].endY - route[j + 1].startY, 2)
);
double subRouteLengthNew = std::sqrt(
std::pow(route[i - 1].endX - route[i + 1].startX, 2)
+ std::pow(route[i - 1].endY - route[i + 1].startY, 2)
);
subRouteLengthNew += std::sqrt(
std::pow(route[j].endX - route[i].startX, 2)
+ std::pow(route[j].endY - route[i].startY, 2)
);
subRouteLengthNew += std::sqrt(
std::pow(route[i].endX - route[j + 1].startX, 2)
+ std::pow(route[i].endY - route[j + 1].startY, 2)
);
subRouteLengthNew += 1e-6;
if (subRouteLengthNew < subRouteLengthCurrent) {
// Relocate the i-th tunnel backward (after j-th element)
TSPTunnel temp = route[i];
route.erase(route.begin() + i);
route.insert(route.begin() + j + 1, temp);
improvementRelocateFound = true;
improvementFound = true;
}
}
// Try relocating forward
for (size_t j = i + 1; j + 1 < route.size(); ++j) {
double subRouteLengthCurrent = std::sqrt(
std::pow(route[i - 1].endX - route[i].startX, 2)
+ std::pow(route[i - 1].endY - route[i].startY, 2)
);
subRouteLengthCurrent += std::sqrt(
std::pow(route[i].endX - route[i + 1].startX, 2)
+ std::pow(route[i].endY - route[i + 1].startY, 2)
);
subRouteLengthCurrent += std::sqrt(
std::pow(route[j].endX - route[j + 1].startX, 2)
+ std::pow(route[j].endY - route[j + 1].startY, 2)
);
double subRouteLengthNew = std::sqrt(
std::pow(route[i - 1].endX - route[i + 1].startX, 2)
+ std::pow(route[i - 1].endY - route[i + 1].startY, 2)
);
subRouteLengthNew += std::sqrt(
std::pow(route[j].endX - route[i].startX, 2)
+ std::pow(route[j].endY - route[i].startY, 2)
);
subRouteLengthNew += std::sqrt(
std::pow(route[i].endX - route[j + 1].startX, 2)
+ std::pow(route[i].endY - route[j + 1].startY, 2)
);
subRouteLengthNew += 1e-6;
if (subRouteLengthNew < subRouteLengthCurrent) {
// Relocate the i-th tunnel forward (after j-th element)
TSPTunnel temp = route[i];
route.erase(route.begin() + i);
route.insert(route.begin() + j, temp);
improvementRelocateFound = true;
improvementFound = true;
}
}
}
}
}
// STEP 5: Delete temporary start and end point
if (!route.empty()) {
route.erase(route.begin()); // Remove temp start
}
if (routeEndPoint && !route.empty()) {
route.pop_back(); // Remove temp end
}
return route;
}

View File

@@ -0,0 +1,79 @@
// SPDX-License-Identifier: LGPL-2.1-or-later
/***************************************************************************
* 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 *
* *
***************************************************************************/
#pragma once
#include <vector>
#include <utility>
#include <limits>
#include <cmath>
struct TSPPoint
{
double x, y;
TSPPoint(double x_, double y_)
: x(x_)
, y(y_)
{}
};
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 index; // Original index in input array
TSPTunnel(double sx, double sy, double ex, double ey, bool open = true)
: startX(sx)
, startY(sy)
, endX(ex)
, endY(ey)
, isOpen(open)
, flipped(false)
, index(-1)
{}
};
class TSPSolver
{
public:
// Returns a vector of indices representing the visit order using 2-Opt
// 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
);
// Solves TSP for tunnels (path segments with entry/exit points)
// allowFlipping: whether tunnels can be reversed (entry becomes exit)
// Returns vector of tunnels in optimized order (tunnels may be flipped)
static std::vector<TSPTunnel> solveTunnels(
std::vector<TSPTunnel> tunnels,
bool allowFlipping = false,
const TSPPoint* routeStartPoint = nullptr,
const TSPPoint* routeEndPoint = nullptr
);
};

View File

@@ -0,0 +1,226 @@
// SPDX-License-Identifier: LGPL-2.1-or-later
/***************************************************************************
* 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 *
* *
***************************************************************************/
#include <pybind11/pybind11.h>
#include <pybind11/stl.h>
#include "tsp_solver.h"
namespace py = pybind11;
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);
}
// 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);
}
// Python wrapper for solveTunnels function
std::vector<py::dict> tspSolveTunnelsPy(
const std::vector<py::dict>& tunnels,
bool allowFlipping = false,
const py::object& routeStartPoint = py::none(),
const py::object& routeEndPoint = py::none()
)
{
std::vector<TSPTunnel> cppTunnels;
// Convert Python dictionaries to C++ TSPTunnel objects
for (const auto& tunnel : tunnels) {
double startX = py::cast<double>(tunnel["startX"]);
double startY = py::cast<double>(tunnel["startY"]);
double endX = py::cast<double>(tunnel["endX"]);
double endY = py::cast<double>(tunnel["endY"]);
bool isOpen = tunnel.contains("isOpen") ? py::cast<bool>(tunnel["isOpen"]) : true;
cppTunnels.emplace_back(startX, startY, endX, endY, isOpen);
}
// Handle optional start point
TSPPoint* pStartPoint = nullptr;
TSPPoint startPointObj(0, 0);
if (!routeStartPoint.is_none()) {
try {
auto sp = routeStartPoint.cast<std::vector<double>>();
if (sp.size() >= 2) {
startPointObj.x = sp[0];
startPointObj.y = sp[1];
pStartPoint = &startPointObj;
}
}
catch (py::cast_error&) {
try {
if (py::len(routeStartPoint) >= 2) {
startPointObj.x = py::cast<double>(routeStartPoint.attr("__getitem__")(0));
startPointObj.y = py::cast<double>(routeStartPoint.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 (!routeEndPoint.is_none()) {
try {
auto ep = routeEndPoint.cast<std::vector<double>>();
if (ep.size() >= 2) {
endPointObj.x = ep[0];
endPointObj.y = ep[1];
pEndPoint = &endPointObj;
}
}
catch (py::cast_error&) {
try {
if (py::len(routeEndPoint) >= 2) {
endPointObj.x = py::cast<double>(routeEndPoint.attr("__getitem__")(0));
endPointObj.y = py::cast<double>(routeEndPoint.attr("__getitem__")(1));
pEndPoint = &endPointObj;
}
}
catch (py::error_already_set&) {
// Ignore if we can't access the elements
}
}
}
// Solve the tunnel TSP
auto result = TSPSolver::solveTunnels(cppTunnels, allowFlipping, pStartPoint, pEndPoint);
// Convert result back to Python dictionaries
std::vector<py::dict> pyResult;
for (const auto& tunnel : result) {
py::dict tunnelDict;
tunnelDict["startX"] = tunnel.startX;
tunnelDict["startY"] = tunnel.startY;
tunnelDict["endX"] = tunnel.endX;
tunnelDict["endY"] = tunnel.endY;
tunnelDict["isOpen"] = tunnel.isOpen;
tunnelDict["flipped"] = tunnel.flipped;
tunnelDict["index"] = tunnel.index;
pyResult.push_back(tunnelDict);
}
return pyResult;
}
PYBIND11_MODULE(tsp_solver, m)
{
m.doc() = "Simple TSP solver (2-Opt) for FreeCAD";
m.def(
"solve",
&tspSolvePy,
py::arg("points"),
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)"
);
m.def(
"solveTunnels",
&tspSolveTunnelsPy,
py::arg("tunnels"),
py::arg("allowFlipping") = false,
py::arg("routeStartPoint") = py::none(),
py::arg("routeEndPoint") = py::none(),
"Solve TSP for tunnels (path segments with entry/exit points).\n"
"Arguments:\n"
"- tunnels: List of dictionaries with keys: startX, startY, endX, endY, isOpen (optional)\n"
"- allowFlipping: Whether tunnels can be reversed (entry becomes exit)\n"
"- routeStartPoint: Optional [x, y] point where route should start\n"
"- routeEndPoint: Optional [x, y] point where route should end\n"
"Returns: List of tunnel dictionaries in optimized order with flipped status"
);
}

View File

@@ -0,0 +1,361 @@
# SPDX-License-Identifier: LGPL-2.1-or-later
# ***************************************************************************
# * 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 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)
# 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_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]
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_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]
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_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)
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_05_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)
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
unittest.main()

View File

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

View File

@@ -2,6 +2,7 @@
# ***************************************************************************
# * Copyright (c) 2014 Dan Falck <ddfalck@gmail.com> *
# * 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) *
@@ -29,6 +30,7 @@ import Path
import Path.Main.Job as PathJob
import math
from numpy import linspace
import tsp_solver
# lazily loaded modules
from lazy_loader.lazy_loader import LazyLoader
@@ -611,6 +613,60 @@ def sort_locations(locations, keys, attractors=None):
return out
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 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, the path is optimized to start near the first point in the original list,
but may not start exactly at that point.
"""
# Extract points from locations
points = [(loc[keys[0]], loc[keys[1]]) for loc in locations]
order = tsp_solver.solve(points=points, startPoint=startPoint, endPoint=endPoint)
# Return the reordered locations
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

View File

@@ -117,3 +117,4 @@ from CAMTests.TestCentroidLegacyPost import TestCentroidLegacyPost
from CAMTests.TestMach3Mach4LegacyPost import TestMach3Mach4LegacyPost
from CAMTests.TestSnapmakerPost import TestSnapmakerPost
from CAMTests.TestTSPSolver import TestTSPSolver