Merge pull request #24726 from Connor9220/TwoOptTSPSolver
CAM: 2-Opt TSP solver
This commit is contained in:
@@ -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})
|
||||
# -------------------------------------------
|
||||
|
||||
586
src/Mod/CAM/App/tsp_solver.cpp
Normal file
586
src/Mod/CAM/App/tsp_solver.cpp
Normal 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;
|
||||
}
|
||||
79
src/Mod/CAM/App/tsp_solver.h
Normal file
79
src/Mod/CAM/App/tsp_solver.h
Normal 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
|
||||
);
|
||||
};
|
||||
226
src/Mod/CAM/App/tsp_solver_pybind.cpp
Normal file
226
src/Mod/CAM/App/tsp_solver_pybind.cpp
Normal 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"
|
||||
);
|
||||
}
|
||||
361
src/Mod/CAM/CAMTests/TestTSPSolver.py
Normal file
361
src/Mod/CAM/CAMTests/TestTSPSolver.py
Normal 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()
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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