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/convex_hull.cpp
|
||||
core/icp.cpp
|
||||
core/cam_toolpath.cpp
|
||||
)
|
||||
target_include_directories(vde_core
|
||||
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_distance)
|
||||
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