feat(v3.4): CAM toolpath — contour + pocket + G-code
CI / Build & Test (push) Failing after 31s
CI / Release Build (push) Failing after 31s
Build & Test / build-and-test (push) Has been cancelled
Build & Test / python-bindings (push) Has been cancelled

This commit is contained in:
茂之钳
2026-07-24 13:23:02 +00:00
parent 212c6a02c5
commit 00a71c4573
5 changed files with 779 additions and 0 deletions
+99
View File
@@ -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
+1
View File
@@ -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
+418
View File
@@ -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
+1
View File
@@ -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)
+260
View File
@@ -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";
}