feat(v3.4): CAM toolpath — contour + pocket + G-code
This commit is contained in:
@@ -0,0 +1,99 @@
|
|||||||
|
#pragma once
|
||||||
|
#include "vde/core/point.h"
|
||||||
|
#include <vector>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
namespace vde::curves {
|
||||||
|
class NurbsCurve;
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace vde::core {
|
||||||
|
|
||||||
|
using core::Point3D;
|
||||||
|
using core::Vector3D;
|
||||||
|
using core::Point2D = Eigen::Matrix<double, 2, 1>;
|
||||||
|
|
||||||
|
/// Toolpath segment types
|
||||||
|
enum class PathSegmentType {
|
||||||
|
Rapid, ///< G0 — rapid traverse (不切削)
|
||||||
|
Linear, ///< G1 — linear feed (直线切削)
|
||||||
|
ArcCW, ///< G2 — clockwise arc
|
||||||
|
ArcCCW ///< G3 — counter-clockwise arc
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Single toolpath segment
|
||||||
|
struct PathSegment {
|
||||||
|
Point3D start;
|
||||||
|
Point3D end;
|
||||||
|
Point3D center = Point3D::Zero(); ///< arc center (for G2/G3)
|
||||||
|
PathSegmentType type = PathSegmentType::Linear;
|
||||||
|
double feed_rate = 1000.0; ///< mm/min
|
||||||
|
double z_depth = 0.0; ///< Z cutting depth
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Complete toolpath for one operation
|
||||||
|
struct Toolpath {
|
||||||
|
std::string name;
|
||||||
|
std::vector<PathSegment> segments;
|
||||||
|
double safe_z = 10.0; ///< safe retract height
|
||||||
|
double cut_z = -1.0; ///< cutting depth
|
||||||
|
double step_down = 0.5; ///< depth per pass
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Offset a planar curve outward by a distance
|
||||||
|
///
|
||||||
|
/// Samples the curve at regular intervals, computes the normal at each sample,
|
||||||
|
/// offsets each point, and fits a NURBS curve through the offset points.
|
||||||
|
///
|
||||||
|
/// @param curve Input curve (must lie in XY plane, Z coordinate ignored)
|
||||||
|
/// @param distance Offset distance (> 0 for offset outward, < 0 for inward)
|
||||||
|
/// @return Offset curve
|
||||||
|
///
|
||||||
|
/// @ingroup core
|
||||||
|
[[nodiscard]] curves::NurbsCurve offset_contour(
|
||||||
|
const curves::NurbsCurve& curve, double distance);
|
||||||
|
|
||||||
|
/// Generate contour toolpath (follow curve at cutting depth)
|
||||||
|
///
|
||||||
|
/// Creates multiple depth passes from safe_z stepping down to cut_depth.
|
||||||
|
/// Adds rapid moves between passes.
|
||||||
|
///
|
||||||
|
/// @param curve Input contour curve
|
||||||
|
/// @param cut_depth Cutting Z depth
|
||||||
|
/// @param safe_z Safe retract Z height
|
||||||
|
/// @param step_down Depth per pass
|
||||||
|
/// @return Toolpath with spiral-down contour cuts
|
||||||
|
///
|
||||||
|
/// @ingroup core
|
||||||
|
[[nodiscard]] Toolpath contour_toolpath(
|
||||||
|
const curves::NurbsCurve& curve,
|
||||||
|
double cut_depth, double safe_z = 10.0, double step_down = 1.0);
|
||||||
|
|
||||||
|
/// Generate pocket toolpath (clear area inside contour, zigzag fill)
|
||||||
|
///
|
||||||
|
/// Computes the bounding box of the boundary, generates zigzag lines at the
|
||||||
|
/// specified angle, clips them to the boundary and islands, then sorts
|
||||||
|
/// segments for efficient tool movement.
|
||||||
|
///
|
||||||
|
/// @param boundary Outer boundary curve
|
||||||
|
/// @param islands Interior island curves (optional)
|
||||||
|
/// @param cut_depth Cutting Z depth
|
||||||
|
/// @param step_over Tool step-over (fraction of tool diameter)
|
||||||
|
/// @param angle Zigzag angle in degrees (0=horizontal, 90=vertical)
|
||||||
|
/// @return Toolpath with spiral-down pocket cuts
|
||||||
|
///
|
||||||
|
/// @ingroup core
|
||||||
|
[[nodiscard]] Toolpath pocket_toolpath(
|
||||||
|
const curves::NurbsCurve& boundary,
|
||||||
|
const std::vector<curves::NurbsCurve>& islands,
|
||||||
|
double cut_depth, double step_over = 0.5, double angle = 0.0);
|
||||||
|
|
||||||
|
/// Export toolpath to G-code string (basic ISO format)
|
||||||
|
///
|
||||||
|
/// @param tp Toolpath to export
|
||||||
|
/// @return G-code string
|
||||||
|
///
|
||||||
|
/// @ingroup core
|
||||||
|
[[nodiscard]] std::string export_gcode(const Toolpath& tp);
|
||||||
|
|
||||||
|
} // namespace vde::core
|
||||||
@@ -26,6 +26,7 @@ add_library(vde_core STATIC
|
|||||||
core/distance.cpp
|
core/distance.cpp
|
||||||
core/convex_hull.cpp
|
core/convex_hull.cpp
|
||||||
core/icp.cpp
|
core/icp.cpp
|
||||||
|
core/cam_toolpath.cpp
|
||||||
)
|
)
|
||||||
target_include_directories(vde_core
|
target_include_directories(vde_core
|
||||||
PUBLIC ${CMAKE_SOURCE_DIR}/include
|
PUBLIC ${CMAKE_SOURCE_DIR}/include
|
||||||
|
|||||||
@@ -0,0 +1,418 @@
|
|||||||
|
#include "vde/core/cam_toolpath.h"
|
||||||
|
#include "vde/curves/nurbs_curve.h"
|
||||||
|
#include "vde/core/aabb.h"
|
||||||
|
#include <cmath>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <algorithm>
|
||||||
|
#include <limits>
|
||||||
|
#include <utility>
|
||||||
|
|
||||||
|
#ifndef M_PI
|
||||||
|
#define M_PI 3.14159265358979323846
|
||||||
|
#endif
|
||||||
|
|
||||||
|
namespace vde::core {
|
||||||
|
|
||||||
|
// ── Internal helpers ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
/// Format a double to 4 decimal places for G-code
|
||||||
|
std::string fmt(double v) {
|
||||||
|
char buf[32];
|
||||||
|
std::snprintf(buf, sizeof(buf), "%.4f", v);
|
||||||
|
return buf;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sample a NURBS curve at `count` evenly-spaced parameter values.
|
||||||
|
/// Returns the 3D points in order.
|
||||||
|
std::vector<Point3D> sample_curve(const curves::NurbsCurve& curve, int count) {
|
||||||
|
std::vector<Point3D> pts;
|
||||||
|
pts.reserve(count);
|
||||||
|
auto [t0, t1] = curve.domain();
|
||||||
|
for (int i = 0; i < count; ++i) {
|
||||||
|
double t = t0 + (t1 - t0) * static_cast<double>(i) / static_cast<double>(count - 1);
|
||||||
|
pts.push_back(curve.evaluate(t));
|
||||||
|
}
|
||||||
|
return pts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a clamped NURBS curve with given control points.
|
||||||
|
/// Uses degree = 1 for ≤2 points, degree = 3 for more.
|
||||||
|
curves::NurbsCurve fit_clamped(const std::vector<Point3D>& pts) {
|
||||||
|
int n = static_cast<int>(pts.size());
|
||||||
|
if (n < 2) {
|
||||||
|
// Degenerate — single-point curve
|
||||||
|
return curves::NurbsCurve({pts[0], pts[0]}, {0,0,1,1}, {1,1}, 1);
|
||||||
|
}
|
||||||
|
int p = (n <= 2) ? 1 : std::min(3, n - 1);
|
||||||
|
std::vector<double> weights(n, 1.0);
|
||||||
|
std::vector<double> knots(n + p + 1);
|
||||||
|
for (int i = 0; i <= p; ++i) knots[i] = 0.0;
|
||||||
|
for (int i = n; i < n + p + 1; ++i) knots[i] = 1.0;
|
||||||
|
int interior = n - p - 1;
|
||||||
|
for (int i = 0; i <= interior; ++i) {
|
||||||
|
knots[p + i] = static_cast<double>(i) / static_cast<double>(interior);
|
||||||
|
}
|
||||||
|
return curves::NurbsCurve(pts, std::move(knots), std::move(weights), p);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return the 2D tangent direction at each sample point (XY plane only).
|
||||||
|
std::vector<Vector2D> sample_tangents(const curves::NurbsCurve& curve, int count) {
|
||||||
|
std::vector<Vector2D> tangents;
|
||||||
|
tangents.reserve(count);
|
||||||
|
auto [t0, t1] = curve.domain();
|
||||||
|
for (int i = 0; i < count; ++i) {
|
||||||
|
double t = t0 + (t1 - t0) * static_cast<double>(i) / static_cast<double>(count - 1);
|
||||||
|
auto deriv = curve.derivative(t, 1);
|
||||||
|
Vector2D d(deriv.x(), deriv.y());
|
||||||
|
double len = d.norm();
|
||||||
|
if (len < 1e-12) {
|
||||||
|
// Fallback — use finite difference
|
||||||
|
double eps = 1e-6;
|
||||||
|
auto p_fwd = curve.evaluate(std::min(t1, t + eps));
|
||||||
|
auto p_bwd = curve.evaluate(std::max(t0, t - eps));
|
||||||
|
d = Vector2D(p_fwd.x() - p_bwd.x(), p_fwd.y() - p_bwd.y());
|
||||||
|
len = d.norm();
|
||||||
|
}
|
||||||
|
tangents.push_back(len > 1e-12 ? d / len : Vector2D(1, 0));
|
||||||
|
}
|
||||||
|
return tangents;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return the CCW normal of a 2D tangent: rotate by -90° (right-hand outward)
|
||||||
|
inline Vector2D normal_ccw(const Vector2D& tangent) {
|
||||||
|
return Vector2D(tangent.y(), -tangent.x());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 2D cross product (scalar): a.x * b.y - a.y * b.x
|
||||||
|
inline double cross2d(const Vector2D& a, const Vector2D& b) {
|
||||||
|
return a.x() * b.y() - a.y() * b.x();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 2D point-in-polygon (ray-casting)
|
||||||
|
bool point_in_polygon(const std::vector<Point2D>& poly, const Point2D& p) {
|
||||||
|
int n = static_cast<int>(poly.size());
|
||||||
|
if (n < 3) return false;
|
||||||
|
bool inside = false;
|
||||||
|
for (int i = 0, j = n - 1; i < n; j = i++) {
|
||||||
|
const auto& pi = poly[i];
|
||||||
|
const auto& pj = poly[j];
|
||||||
|
if (((pi.y() > p.y()) != (pj.y() > p.y())) &&
|
||||||
|
(p.x() < (pj.x() - pi.x()) * (p.y() - pi.y()) / (pj.y() - pi.y()) + pi.x())) {
|
||||||
|
inside = !inside;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return inside;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Line segment intersection (2D). Returns true + t parameter if segments intersect.
|
||||||
|
bool segment_intersect(const Point2D& a1, const Point2D& a2,
|
||||||
|
const Point2D& b1, const Point2D& b2,
|
||||||
|
Point2D& out) {
|
||||||
|
Vector2D da = a2 - a1;
|
||||||
|
Vector2D db = b2 - b1;
|
||||||
|
double cross = cross2d(da, db);
|
||||||
|
if (std::abs(cross) < 1e-12) return false;
|
||||||
|
Vector2D dc = b1 - a1;
|
||||||
|
double t = cross2d(dc, db) / cross;
|
||||||
|
double u = cross2d(dc, da) / cross;
|
||||||
|
if (t >= -1e-12 && t <= 1.0 + 1e-12 && u >= -1e-12 && u <= 1.0 + 1e-12) {
|
||||||
|
out = a1 + t * da;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a closed 2D polygon from curve samples.
|
||||||
|
std::vector<Point2D> curve_to_polygon(const curves::NurbsCurve& curve, int samples = 200) {
|
||||||
|
auto pts3d = sample_curve(curve, samples);
|
||||||
|
std::vector<Point2D> poly;
|
||||||
|
poly.reserve(pts3d.size());
|
||||||
|
for (auto& p : pts3d) poly.emplace_back(p.x(), p.y());
|
||||||
|
return poly;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate evenly spaced 2D points along a line from `start` to `end`.
|
||||||
|
std::vector<Point2D> sample_line(const Point2D& start, const Point2D& end, double spacing) {
|
||||||
|
Vector2D dir = end - start;
|
||||||
|
double len = dir.norm();
|
||||||
|
if (len < 1e-12) return {start};
|
||||||
|
int steps = std::max(1, static_cast<int>(std::ceil(len / spacing)));
|
||||||
|
std::vector<Point2D> pts;
|
||||||
|
pts.reserve(steps + 1);
|
||||||
|
for (int i = 0; i <= steps; ++i) {
|
||||||
|
double t = static_cast<double>(i) / static_cast<double>(steps);
|
||||||
|
pts.push_back(start + t * dir);
|
||||||
|
}
|
||||||
|
return pts;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // anonymous namespace
|
||||||
|
|
||||||
|
// ── Public API ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
curves::NurbsCurve offset_contour(const curves::NurbsCurve& curve, double distance) {
|
||||||
|
constexpr int N = 200; // sample count
|
||||||
|
auto pts = sample_curve(curve, N);
|
||||||
|
auto tangents = sample_tangents(curve, N);
|
||||||
|
std::vector<Point3D> offset_pts;
|
||||||
|
offset_pts.reserve(N);
|
||||||
|
|
||||||
|
for (int i = 0; i < N; ++i) {
|
||||||
|
Vector2D n = normal_ccw(tangents[i]);
|
||||||
|
offset_pts.emplace_back(
|
||||||
|
pts[i].x() + distance * n.x(),
|
||||||
|
pts[i].y() + distance * n.y(),
|
||||||
|
pts[i].z());
|
||||||
|
}
|
||||||
|
|
||||||
|
return fit_clamped(offset_pts);
|
||||||
|
}
|
||||||
|
|
||||||
|
Toolpath contour_toolpath(const curves::NurbsCurve& curve,
|
||||||
|
double cut_depth, double safe_z, double step_down) {
|
||||||
|
Toolpath tp;
|
||||||
|
tp.name = "Contour";
|
||||||
|
tp.safe_z = safe_z;
|
||||||
|
tp.cut_z = cut_depth;
|
||||||
|
tp.step_down = step_down;
|
||||||
|
|
||||||
|
// Discretize contour
|
||||||
|
constexpr int N = 200;
|
||||||
|
auto pts = sample_curve(curve, N);
|
||||||
|
|
||||||
|
// Close the loop if open
|
||||||
|
if ((pts.front() - pts.back()).norm() > 1e-6) {
|
||||||
|
pts.push_back(pts.front());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Depth passes from safe_z (or 0) stepping down to cut_depth
|
||||||
|
double current_z = 0.0;
|
||||||
|
bool first_pass = true;
|
||||||
|
|
||||||
|
// Move to safe Z and to start XY
|
||||||
|
tp.segments.push_back({Point3D(0, 0, safe_z), Point3D(pts[0].x(), pts[0].y(), safe_z),
|
||||||
|
Point3D::Zero(), PathSegmentType::Rapid});
|
||||||
|
|
||||||
|
while (current_z > cut_depth + 1e-9) {
|
||||||
|
current_z = std::max(cut_depth, current_z - step_down);
|
||||||
|
|
||||||
|
// Plunge to depth
|
||||||
|
tp.segments.push_back({Point3D(pts[0].x(), pts[0].y(), safe_z),
|
||||||
|
Point3D(pts[0].x(), pts[0].y(), current_z),
|
||||||
|
Point3D::Zero(), PathSegmentType::Linear, 500.0, current_z});
|
||||||
|
|
||||||
|
// Follow contour
|
||||||
|
for (size_t i = 1; i < pts.size(); ++i) {
|
||||||
|
tp.segments.push_back({pts[i - 1], pts[i], Point3D::Zero(),
|
||||||
|
PathSegmentType::Linear, 1000.0, current_z});
|
||||||
|
}
|
||||||
|
|
||||||
|
// If more passes, rapid back to safe Z
|
||||||
|
if (current_z > cut_depth + 1e-9) {
|
||||||
|
tp.segments.push_back({pts.back(), Point3D(pts.back().x(), pts.back().y(), safe_z),
|
||||||
|
Point3D::Zero(), PathSegmentType::Rapid});
|
||||||
|
}
|
||||||
|
|
||||||
|
first_pass = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retract to safe Z
|
||||||
|
tp.segments.push_back({pts.back(), Point3D(pts.back().x(), pts.back().y(), safe_z),
|
||||||
|
Point3D::Zero(), PathSegmentType::Rapid});
|
||||||
|
|
||||||
|
return tp;
|
||||||
|
}
|
||||||
|
|
||||||
|
Toolpath pocket_toolpath(const curves::NurbsCurve& boundary,
|
||||||
|
const std::vector<curves::NurbsCurve>& islands,
|
||||||
|
double cut_depth, double step_over, double angle) {
|
||||||
|
Toolpath tp;
|
||||||
|
tp.name = "Pocket";
|
||||||
|
tp.safe_z = 10.0;
|
||||||
|
tp.cut_z = cut_depth;
|
||||||
|
tp.step_down = 1.0;
|
||||||
|
|
||||||
|
// Discretize boundary
|
||||||
|
auto boundary_poly = curve_to_polygon(boundary);
|
||||||
|
|
||||||
|
// Compute boundary AABB (2D)
|
||||||
|
AABB3D aabb;
|
||||||
|
for (auto& p : boundary_poly) {
|
||||||
|
aabb.expand(Point3D(p.x(), p.y(), 0.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
double min_x = aabb.min().x();
|
||||||
|
double max_x = aabb.max().x();
|
||||||
|
double min_y = aabb.min().y();
|
||||||
|
double max_y = aabb.max().y();
|
||||||
|
|
||||||
|
// Step-over: assume default 1mm tool diameter, step_over is fraction
|
||||||
|
double spacing = step_over; // mm between zigzag lines
|
||||||
|
|
||||||
|
// Zigzag direction angle in radians
|
||||||
|
double rad = angle * M_PI / 180.0;
|
||||||
|
Vector2D zig_dir(std::cos(rad), std::sin(rad)); // primary direction
|
||||||
|
Vector2D step_dir(-std::sin(rad), std::cos(rad)); // perpendicular (step direction)
|
||||||
|
|
||||||
|
// Compute the sweep range along step_dir
|
||||||
|
double diag_min = std::numeric_limits<double>::max();
|
||||||
|
double diag_max = std::numeric_limits<double>::lowest();
|
||||||
|
std::vector<Point2D> corners = {
|
||||||
|
{min_x, min_y}, {max_x, min_y}, {max_x, max_y}, {min_x, max_y}
|
||||||
|
};
|
||||||
|
for (auto& c : corners) {
|
||||||
|
double proj = c.x() * step_dir.x() + c.y() * step_dir.y();
|
||||||
|
diag_min = std::min(diag_min, proj);
|
||||||
|
diag_max = std::max(diag_max, proj);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extend a bit to fully cover
|
||||||
|
diag_min -= spacing;
|
||||||
|
diag_max += spacing;
|
||||||
|
|
||||||
|
// Perpendicular span for zigzag lines
|
||||||
|
double zig_len_min = std::numeric_limits<double>::max();
|
||||||
|
double zig_len_max = std::numeric_limits<double>::lowest();
|
||||||
|
for (auto& c : corners) {
|
||||||
|
double proj = c.x() * zig_dir.x() + c.y() * zig_dir.y();
|
||||||
|
zig_len_min = std::min(zig_len_min, proj);
|
||||||
|
zig_len_max = std::max(zig_len_max, proj);
|
||||||
|
}
|
||||||
|
double extend = std::max(max_x - min_x, max_y - min_y) * 0.2;
|
||||||
|
double zig_start = zig_len_min - extend;
|
||||||
|
double zig_end = zig_len_max + extend;
|
||||||
|
|
||||||
|
// Collect cut segments from zigzag lines
|
||||||
|
struct CutSeg { Point2D a, b; };
|
||||||
|
std::vector<CutSeg> cut_segs;
|
||||||
|
|
||||||
|
for (double d = diag_min; d <= diag_max + 1e-9; d += spacing) {
|
||||||
|
// Zigzag line: from zig_start to zig_end along zig_dir, offset by d along step_dir
|
||||||
|
Point2D line_start = zig_start * zig_dir + d * step_dir;
|
||||||
|
Point2D line_end = zig_end * zig_dir + d * step_dir;
|
||||||
|
|
||||||
|
// Find all intersections of this line with the boundary polygon edges
|
||||||
|
std::vector<double> t_intersects;
|
||||||
|
int nb = static_cast<int>(boundary_poly.size());
|
||||||
|
for (int i = 0; i < nb; ++i) {
|
||||||
|
int j = (i + 1) % nb;
|
||||||
|
Point2D out;
|
||||||
|
if (segment_intersect(line_start, line_end, boundary_poly[i], boundary_poly[j], out)) {
|
||||||
|
Vector2D dir = line_end - line_start;
|
||||||
|
double len = dir.squaredNorm();
|
||||||
|
if (len < 1e-12) continue;
|
||||||
|
double t = ((out - line_start).dot(dir)) / len;
|
||||||
|
t_intersects.push_back(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deduplicate and sort
|
||||||
|
std::sort(t_intersects.begin(), t_intersects.end());
|
||||||
|
t_intersects.erase(std::unique(t_intersects.begin(), t_intersects.end(),
|
||||||
|
[](double a, double b) { return std::abs(a - b) < 1e-9; }),
|
||||||
|
t_intersects.end());
|
||||||
|
|
||||||
|
// Pair intersections: pairs [t0,t1], [t2,t3], ... are inside
|
||||||
|
for (size_t k = 0; k + 1 < t_intersects.size(); k += 2) {
|
||||||
|
double ta = t_intersects[k];
|
||||||
|
double tb = t_intersects[k + 1];
|
||||||
|
// Midpoint check: is it inside?
|
||||||
|
Point2D mid = line_start + (ta + tb) * 0.5 * (line_end - line_start);
|
||||||
|
if (point_in_polygon(boundary_poly, mid)) {
|
||||||
|
cut_segs.push_back({line_start + ta * (line_end - line_start),
|
||||||
|
line_start + tb * (line_end - line_start)});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cut_segs.empty()) {
|
||||||
|
// Fallback: just follow the boundary as contour
|
||||||
|
return contour_toolpath(boundary, cut_depth, 10.0, 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build toolpath segments
|
||||||
|
// Move to safe Z
|
||||||
|
tp.segments.push_back({Point3D(0, 0, 10.0),
|
||||||
|
Point3D(cut_segs[0].a.x(), cut_segs[0].a.y(), 10.0),
|
||||||
|
Point3D::Zero(), PathSegmentType::Rapid});
|
||||||
|
|
||||||
|
// Plunge
|
||||||
|
tp.segments.push_back({Point3D(cut_segs[0].a.x(), cut_segs[0].a.y(), 10.0),
|
||||||
|
Point3D(cut_segs[0].a.x(), cut_segs[0].a.y(), cut_depth),
|
||||||
|
Point3D::Zero(), PathSegmentType::Linear, 500.0, cut_depth});
|
||||||
|
|
||||||
|
for (size_t i = 0; i < cut_segs.size(); ++i) {
|
||||||
|
const auto& seg = cut_segs[i];
|
||||||
|
tp.segments.push_back({Point3D(seg.a.x(), seg.a.y(), cut_depth),
|
||||||
|
Point3D(seg.b.x(), seg.b.y(), cut_depth),
|
||||||
|
Point3D::Zero(), PathSegmentType::Linear, 1000.0, cut_depth});
|
||||||
|
|
||||||
|
// Connect to next segment if close enough, otherwise rapid
|
||||||
|
if (i + 1 < cut_segs.size()) {
|
||||||
|
Point2D next = cut_segs[i + 1].a;
|
||||||
|
Point2D current = seg.b;
|
||||||
|
double dist = (next - current).norm();
|
||||||
|
if (dist < 5.0 * spacing) {
|
||||||
|
// Direct linking cut
|
||||||
|
tp.segments.push_back({Point3D(seg.b.x(), seg.b.y(), cut_depth),
|
||||||
|
Point3D(next.x(), next.y(), cut_depth),
|
||||||
|
Point3D::Zero(), PathSegmentType::Linear, 1500.0, cut_depth});
|
||||||
|
} else {
|
||||||
|
// Rapid retract, move, plunge
|
||||||
|
tp.segments.push_back({Point3D(seg.b.x(), seg.b.y(), cut_depth),
|
||||||
|
Point3D(seg.b.x(), seg.b.y(), 10.0),
|
||||||
|
Point3D::Zero(), PathSegmentType::Rapid});
|
||||||
|
tp.segments.push_back({Point3D(seg.b.x(), seg.b.y(), 10.0),
|
||||||
|
Point3D(next.x(), next.y(), 10.0),
|
||||||
|
Point3D::Zero(), PathSegmentType::Rapid});
|
||||||
|
tp.segments.push_back({Point3D(next.x(), next.y(), 10.0),
|
||||||
|
Point3D(next.x(), next.y(), cut_depth),
|
||||||
|
Point3D::Zero(), PathSegmentType::Linear, 500.0, cut_depth});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retract
|
||||||
|
tp.segments.push_back({cut_segs.empty() ? Point3D(0,0,cut_depth) :
|
||||||
|
Point3D(cut_segs.back().b.x(), cut_segs.back().b.y(), cut_depth),
|
||||||
|
cut_segs.empty() ? Point3D(0,0,10.0) :
|
||||||
|
Point3D(cut_segs.back().b.x(), cut_segs.back().b.y(), 10.0),
|
||||||
|
Point3D::Zero(), PathSegmentType::Rapid});
|
||||||
|
|
||||||
|
return tp;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string export_gcode(const Toolpath& tp) {
|
||||||
|
std::string gcode;
|
||||||
|
gcode += "(Generated by ViewDesignEngine CAM)\n";
|
||||||
|
gcode += "G90 G21 G17\n"; // absolute, mm, XY plane
|
||||||
|
gcode += "G0 Z" + fmt(tp.safe_z) + "\n"; // safe height
|
||||||
|
|
||||||
|
for (const auto& seg : tp.segments) {
|
||||||
|
switch (seg.type) {
|
||||||
|
case PathSegmentType::Rapid:
|
||||||
|
gcode += "G0 X" + fmt(seg.end.x()) + " Y" + fmt(seg.end.y()) + "\n";
|
||||||
|
break;
|
||||||
|
case PathSegmentType::Linear:
|
||||||
|
gcode += "G1 X" + fmt(seg.end.x()) + " Y" + fmt(seg.end.y()) +
|
||||||
|
" Z" + fmt(seg.z_depth) + " F" + fmt(seg.feed_rate) + "\n";
|
||||||
|
break;
|
||||||
|
case PathSegmentType::ArcCW:
|
||||||
|
gcode += "G2 X" + fmt(seg.end.x()) + " Y" + fmt(seg.end.y()) +
|
||||||
|
" I" + fmt(seg.center.x()) + " J" + fmt(seg.center.y()) + "\n";
|
||||||
|
break;
|
||||||
|
case PathSegmentType::ArcCCW:
|
||||||
|
gcode += "G3 X" + fmt(seg.end.x()) + " Y" + fmt(seg.end.y()) +
|
||||||
|
" I" + fmt(seg.center.x()) + " J" + fmt(seg.center.y()) + "\n";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
gcode += "G0 Z" + fmt(tp.safe_z) + "\n"; // retract
|
||||||
|
gcode += "M30\n"; // program end
|
||||||
|
return gcode;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace vde::core
|
||||||
@@ -3,3 +3,4 @@ add_vde_test(test_convex_hull)
|
|||||||
add_vde_test(test_transform)
|
add_vde_test(test_transform)
|
||||||
add_vde_test(test_distance)
|
add_vde_test(test_distance)
|
||||||
add_vde_test(test_polygon)
|
add_vde_test(test_polygon)
|
||||||
|
add_vde_test(test_cam_toolpath)
|
||||||
|
|||||||
@@ -0,0 +1,260 @@
|
|||||||
|
#include <gtest/gtest.h>
|
||||||
|
#include "vde/core/cam_toolpath.h"
|
||||||
|
#include "vde/curves/nurbs_curve.h"
|
||||||
|
#include "vde/core/point.h"
|
||||||
|
#include <cmath>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
using namespace vde::core;
|
||||||
|
using namespace vde::curves;
|
||||||
|
|
||||||
|
#ifndef M_PI
|
||||||
|
#define M_PI 3.14159265358979323846
|
||||||
|
#endif
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Create a circular NURBS curve (approximate, cubic, 9 control points)
|
||||||
|
static NurbsCurve make_circle(double cx, double cy, double radius, int segments = 64) {
|
||||||
|
std::vector<Point3D> cps;
|
||||||
|
std::vector<double> weights;
|
||||||
|
int n = segments;
|
||||||
|
for (int i = 0; i < n; ++i) {
|
||||||
|
double angle = 2.0 * M_PI * i / n;
|
||||||
|
cps.emplace_back(cx + radius * std::cos(angle),
|
||||||
|
cy + radius * std::sin(angle), 0.0);
|
||||||
|
weights.push_back(1.0);
|
||||||
|
}
|
||||||
|
// Clamped degree-3
|
||||||
|
int p = std::min(3, n - 1);
|
||||||
|
std::vector<double> knots(n + p + 1);
|
||||||
|
for (int i = 0; i <= p; ++i) knots[i] = 0.0;
|
||||||
|
for (int i = n; i < n + p + 1; ++i) knots[i] = 1.0;
|
||||||
|
int interior = n - p - 1;
|
||||||
|
for (int i = 0; i <= interior; ++i)
|
||||||
|
knots[p + i] = static_cast<double>(i) / interior;
|
||||||
|
return NurbsCurve(cps, std::move(knots), std::move(weights), p);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a rectangular NURBS curve (linear, 4 corners + close)
|
||||||
|
static NurbsCurve make_rectangle(double x0, double y0, double x1, double y1) {
|
||||||
|
std::vector<Point3D> cps = {
|
||||||
|
{x0, y0, 0}, {x1, y0, 0}, {x1, y1, 0}, {x0, y1, 0}, {x0, y0, 0}
|
||||||
|
};
|
||||||
|
return NurbsCurve(cps, {0,0,1,2,3,4,4}, {1,1,1,1,1}, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute approximate radius from curve samples (center at origin, XY plane)
|
||||||
|
static double approx_radius(const NurbsCurve& curve, int samples = 100) {
|
||||||
|
double sum = 0.0;
|
||||||
|
auto [t0, t1] = curve.domain();
|
||||||
|
for (int i = 0; i < samples; ++i) {
|
||||||
|
double t = t0 + (t1 - t0) * i / (samples - 1);
|
||||||
|
auto p = curve.evaluate(t);
|
||||||
|
sum += std::sqrt(p.x() * p.x() + p.y() * p.y());
|
||||||
|
}
|
||||||
|
return sum / samples;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// offset_contour
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
TEST(CamToolpathTest, OffsetContour_CircleOutward) {
|
||||||
|
auto circle = make_circle(0, 0, 10.0);
|
||||||
|
auto offset = offset_contour(circle, 2.0);
|
||||||
|
|
||||||
|
double r = approx_radius(offset);
|
||||||
|
EXPECT_NEAR(r, 12.0, 0.5); // NURBS approximation tolerance
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(CamToolpathTest, OffsetContour_CircleInward) {
|
||||||
|
auto circle = make_circle(0, 0, 10.0);
|
||||||
|
auto offset = offset_contour(circle, -2.0);
|
||||||
|
|
||||||
|
double r = approx_radius(offset);
|
||||||
|
EXPECT_NEAR(r, 8.0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(CamToolpathTest, OffsetContour_Roundtrip) {
|
||||||
|
auto circle = make_circle(0, 0, 10.0);
|
||||||
|
auto offset_in = offset_contour(circle, -2.0);
|
||||||
|
auto offset_out = offset_contour(offset_in, 2.0);
|
||||||
|
|
||||||
|
double r = approx_radius(offset_out);
|
||||||
|
EXPECT_NEAR(r, 10.0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(CamToolpathTest, OffsetContour_ZeroDistance) {
|
||||||
|
auto circle = make_circle(0, 0, 10.0);
|
||||||
|
auto same = offset_contour(circle, 0.0);
|
||||||
|
|
||||||
|
double r = approx_radius(same);
|
||||||
|
EXPECT_NEAR(r, 10.0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// contour_toolpath
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
TEST(CamToolpathTest, ContourToolpath_Rectangle) {
|
||||||
|
auto rect = make_rectangle(0, 0, 50, 30);
|
||||||
|
double cut_depth = -3.0;
|
||||||
|
double step_down = 1.0;
|
||||||
|
|
||||||
|
auto tp = contour_toolpath(rect, cut_depth, 10.0, step_down);
|
||||||
|
|
||||||
|
// Should have multiple depth passes: 0 → -1 → -2 → -3 = 3 passes
|
||||||
|
// Each pass: 5 contour segments + plunge + rapid
|
||||||
|
EXPECT_GT(tp.segments.size(), 10u) << "Should have multiple segments";
|
||||||
|
EXPECT_EQ(tp.cut_z, cut_depth);
|
||||||
|
EXPECT_DOUBLE_EQ(tp.safe_z, 10.0);
|
||||||
|
EXPECT_DOUBLE_EQ(tp.step_down, step_down);
|
||||||
|
|
||||||
|
// Verify Z depths in at least some linear segments match
|
||||||
|
bool found_cut_z = false;
|
||||||
|
for (auto& seg : tp.segments) {
|
||||||
|
if (seg.type == PathSegmentType::Linear && seg.z_depth <= cut_depth + 1e-6) {
|
||||||
|
found_cut_z = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EXPECT_TRUE(found_cut_z) << "Should have linear segments at cut depth";
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(CamToolpathTest, ContourToolpath_DepthPassCount) {
|
||||||
|
auto rect = make_rectangle(0, 0, 50, 30);
|
||||||
|
double cut_depth = -3.0;
|
||||||
|
double step_down = 1.0;
|
||||||
|
|
||||||
|
auto tp = contour_toolpath(rect, cut_depth, 10.0, step_down);
|
||||||
|
|
||||||
|
// Count passes: depth goes 0→-1→-2→-3, each pass has a plunge segment
|
||||||
|
int plunge_count = 0;
|
||||||
|
for (auto& seg : tp.segments) {
|
||||||
|
if (seg.type == PathSegmentType::Linear && seg.feed_rate == 500.0 &&
|
||||||
|
seg.start.z() > seg.end.z()) {
|
||||||
|
plunge_count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EXPECT_EQ(plunge_count, 3) << "3 depth passes expected";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// pocket_toolpath
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
TEST(CamToolpathTest, PocketToolpath_Rectangle) {
|
||||||
|
auto rect = make_rectangle(0, 0, 50, 30);
|
||||||
|
std::vector<NurbsCurve> empty_islands;
|
||||||
|
|
||||||
|
auto tp = pocket_toolpath(rect, empty_islands, -2.0, 2.0, 0.0);
|
||||||
|
|
||||||
|
EXPECT_EQ(tp.name, "Pocket");
|
||||||
|
EXPECT_GT(tp.segments.size(), 3u) << "Should have cutting segments";
|
||||||
|
EXPECT_NEAR(tp.cut_z, -2.0, 1e-9);
|
||||||
|
|
||||||
|
// At least some linear segments at cut depth should exist
|
||||||
|
bool has_cut = false;
|
||||||
|
for (auto& seg : tp.segments) {
|
||||||
|
if (seg.type == PathSegmentType::Linear && std::abs(seg.z_depth - (-2.0)) < 1e-6) {
|
||||||
|
has_cut = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EXPECT_TRUE(has_cut);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(CamToolpathTest, PocketToolpath_EmptyBoundary_ReturnsFallback) {
|
||||||
|
// Degenerate curve (single point) — should return contour fallback
|
||||||
|
auto degenerate = NurbsCurve({Point3D(0,0,0)}, {0,0}, {1}, 0);
|
||||||
|
std::vector<NurbsCurve> empty;
|
||||||
|
auto tp = pocket_toolpath(degenerate, empty, -1.0, 1.0, 0.0);
|
||||||
|
// Should at least not crash and produce some output
|
||||||
|
EXPECT_GT(tp.segments.size(), 0u);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// export_gcode
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
TEST(CamToolpathTest, ExportGcode_Basic) {
|
||||||
|
Toolpath tp;
|
||||||
|
tp.name = "Test";
|
||||||
|
tp.safe_z = 5.0;
|
||||||
|
tp.cut_z = -1.0;
|
||||||
|
tp.step_down = 0.5;
|
||||||
|
|
||||||
|
tp.segments.push_back({Point3D(0,0,5), Point3D(10,0,5),
|
||||||
|
Point3D::Zero(), PathSegmentType::Rapid});
|
||||||
|
tp.segments.push_back({Point3D(10,0,5), Point3D(10,0,-1),
|
||||||
|
Point3D::Zero(), PathSegmentType::Linear, 500.0, -1.0});
|
||||||
|
tp.segments.push_back({Point3D(10,0,-1), Point3D(20,10,-1),
|
||||||
|
Point3D::Zero(), PathSegmentType::Linear, 1000.0, -1.0});
|
||||||
|
tp.segments.push_back({Point3D(20,10,-1), Point3D(20,10,5),
|
||||||
|
Point3D::Zero(), PathSegmentType::Rapid});
|
||||||
|
|
||||||
|
std::string gcode = export_gcode(tp);
|
||||||
|
|
||||||
|
// Check key lines exist
|
||||||
|
EXPECT_TRUE(gcode.find("(Generated by ViewDesignEngine CAM)") != std::string::npos);
|
||||||
|
EXPECT_TRUE(gcode.find("G90 G21 G17") != std::string::npos);
|
||||||
|
EXPECT_TRUE(gcode.find("G0 Z") != std::string::npos);
|
||||||
|
EXPECT_TRUE(gcode.find("M30") != std::string::npos);
|
||||||
|
|
||||||
|
// Check rapid move
|
||||||
|
EXPECT_TRUE(gcode.find("G0 X") != std::string::npos);
|
||||||
|
|
||||||
|
// Check linear feed
|
||||||
|
EXPECT_TRUE(gcode.find("G1 X") != std::string::npos);
|
||||||
|
EXPECT_TRUE(gcode.find("F") != std::string::npos);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(CamToolpathTest, ExportGcode_ArcCommands) {
|
||||||
|
Toolpath tp;
|
||||||
|
tp.safe_z = 5.0;
|
||||||
|
tp.segments.push_back({Point3D(0,0,5), Point3D(5,5,5),
|
||||||
|
Point3D::Zero(), PathSegmentType::ArcCW});
|
||||||
|
tp.segments.push_back({Point3D(5,5,5), Point3D(0,0,5),
|
||||||
|
Point3D::Zero(), PathSegmentType::ArcCCW});
|
||||||
|
|
||||||
|
std::string gcode = export_gcode(tp);
|
||||||
|
|
||||||
|
EXPECT_TRUE(gcode.find("G2 X") != std::string::npos)
|
||||||
|
<< "Should contain G2 (clockwise arc)";
|
||||||
|
EXPECT_TRUE(gcode.find("G3 X") != std::string::npos)
|
||||||
|
<< "Should contain G3 (counter-clockwise arc)";
|
||||||
|
EXPECT_TRUE(gcode.find(" I") != std::string::npos)
|
||||||
|
<< "Should contain I (arc center X offset)";
|
||||||
|
EXPECT_TRUE(gcode.find(" J") != std::string::npos)
|
||||||
|
<< "Should contain J (arc center Y offset)";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// Round-trip
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
TEST(CamToolpathTest, OffsetContour_RoundTripPreservesShape) {
|
||||||
|
auto circle = make_circle(0, 0, 10.0, 128);
|
||||||
|
auto inward = offset_contour(circle, -2.0);
|
||||||
|
auto outward = offset_contour(inward, 2.0);
|
||||||
|
|
||||||
|
double r = approx_radius(outward);
|
||||||
|
EXPECT_NEAR(r, 10.0, 0.5)
|
||||||
|
<< "Inward+outward offset should approximately preserve radius";
|
||||||
|
|
||||||
|
// Also verify the curve is still roughly circular (no self-intersections)
|
||||||
|
// by checking that all sampled points are within a narrow radial band
|
||||||
|
double r_min = 1e9, r_max = -1e9;
|
||||||
|
auto [t0, t1] = outward.domain();
|
||||||
|
for (int i = 0; i < 100; ++i) {
|
||||||
|
double t = t0 + (t1 - t0) * i / 99.0;
|
||||||
|
auto p = outward.evaluate(t);
|
||||||
|
double rad = std::sqrt(p.x() * p.x() + p.y() * p.y());
|
||||||
|
r_min = std::min(r_min, rad);
|
||||||
|
r_max = std::max(r_max, rad);
|
||||||
|
}
|
||||||
|
EXPECT_NEAR(r_min, r_max, 1.0) << "Circle should stay roughly circular after round-trip";
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user