From 00a71c457353bf3a87a5592e909e4761d8d75a1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=8C=82=E4=B9=8B=E9=92=B3?= Date: Fri, 24 Jul 2026 13:23:02 +0000 Subject: [PATCH] =?UTF-8?q?feat(v3.4):=20CAM=20toolpath=20=E2=80=94=20cont?= =?UTF-8?q?our=20+=20pocket=20+=20G-code?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- include/vde/core/cam_toolpath.h | 99 ++++++++ src/CMakeLists.txt | 1 + src/core/cam_toolpath.cpp | 418 +++++++++++++++++++++++++++++++ tests/core/CMakeLists.txt | 1 + tests/core/test_cam_toolpath.cpp | 260 +++++++++++++++++++ 5 files changed, 779 insertions(+) create mode 100644 include/vde/core/cam_toolpath.h create mode 100644 src/core/cam_toolpath.cpp create mode 100644 tests/core/test_cam_toolpath.cpp diff --git a/include/vde/core/cam_toolpath.h b/include/vde/core/cam_toolpath.h new file mode 100644 index 0000000..f286a2b --- /dev/null +++ b/include/vde/core/cam_toolpath.h @@ -0,0 +1,99 @@ +#pragma once +#include "vde/core/point.h" +#include +#include + +namespace vde::curves { +class NurbsCurve; +} + +namespace vde::core { + +using core::Point3D; +using core::Vector3D; +using core::Point2D = Eigen::Matrix; + +/// 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 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& 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 diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 8e7da32..ae89d5a 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -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 diff --git a/src/core/cam_toolpath.cpp b/src/core/cam_toolpath.cpp new file mode 100644 index 0000000..52cc7ef --- /dev/null +++ b/src/core/cam_toolpath.cpp @@ -0,0 +1,418 @@ +#include "vde/core/cam_toolpath.h" +#include "vde/curves/nurbs_curve.h" +#include "vde/core/aabb.h" +#include +#include +#include +#include +#include + +#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 sample_curve(const curves::NurbsCurve& curve, int count) { + std::vector pts; + pts.reserve(count); + auto [t0, t1] = curve.domain(); + for (int i = 0; i < count; ++i) { + double t = t0 + (t1 - t0) * static_cast(i) / static_cast(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& pts) { + int n = static_cast(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 weights(n, 1.0); + std::vector 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(i) / static_cast(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 sample_tangents(const curves::NurbsCurve& curve, int count) { + std::vector tangents; + tangents.reserve(count); + auto [t0, t1] = curve.domain(); + for (int i = 0; i < count; ++i) { + double t = t0 + (t1 - t0) * static_cast(i) / static_cast(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& poly, const Point2D& p) { + int n = static_cast(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 curve_to_polygon(const curves::NurbsCurve& curve, int samples = 200) { + auto pts3d = sample_curve(curve, samples); + std::vector 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 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(std::ceil(len / spacing))); + std::vector pts; + pts.reserve(steps + 1); + for (int i = 0; i <= steps; ++i) { + double t = static_cast(i) / static_cast(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 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& 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::max(); + double diag_max = std::numeric_limits::lowest(); + std::vector 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::max(); + double zig_len_max = std::numeric_limits::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 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 t_intersects; + int nb = static_cast(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 diff --git a/tests/core/CMakeLists.txt b/tests/core/CMakeLists.txt index 37c6d31..2c44f91 100644 --- a/tests/core/CMakeLists.txt +++ b/tests/core/CMakeLists.txt @@ -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) diff --git a/tests/core/test_cam_toolpath.cpp b/tests/core/test_cam_toolpath.cpp new file mode 100644 index 0000000..2186ea4 --- /dev/null +++ b/tests/core/test_cam_toolpath.cpp @@ -0,0 +1,260 @@ +#include +#include "vde/core/cam_toolpath.h" +#include "vde/curves/nurbs_curve.h" +#include "vde/core/point.h" +#include +#include + +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 cps; + std::vector 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 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(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 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 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 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"; +}