diff --git a/include/vde/brep/brep_drawing.h b/include/vde/brep/brep_drawing.h new file mode 100644 index 0000000..53da8f7 --- /dev/null +++ b/include/vde/brep/brep_drawing.h @@ -0,0 +1,48 @@ +#pragma once +#include "vde/brep/brep.h" +#include "vde/core/point.h" +#include +#include + +namespace vde::brep { + +/// 2D line segment for drawing output +struct DrawSegment { + double x1, y1, x2, y2; + bool hidden = false; +}; + +/// Projection view of a B-Rep body +struct ProjectionView { + std::string name; + std::vector segments; + std::vector hidden_lines; + double scale = 1.0; + + [[nodiscard]] size_t total_segments() const { return segments.size() + hidden_lines.size(); } +}; + +/// Generate orthographic projection views (front, top, right) +[[nodiscard]] std::vector generate_views(const BrepModel& body); + +/// Generate a section view +[[nodiscard]] ProjectionView section_view(const BrepModel& body, + const core::Point3D& point, const core::Vector3D& normal); + +/// Export to DXF R12 format +bool export_dxf(const std::string& filepath, const std::vector& views); + +/// Dimension type +enum class DimType { Linear, Radius, Diameter }; + +/// A dimension annotation +struct Dimension { + DimType type = DimType::Linear; + double x1 = 0, y1 = 0, x2 = 0, y2 = 0; + std::string text; +}; + +/// Auto-generate dimensions for a view +[[nodiscard]] std::vector auto_dimension(const ProjectionView& view); + +} // namespace vde::brep diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 91737b3..14c5ea5 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -145,6 +145,7 @@ add_library(vde::engine ALIAS vde) add_library(vde_brep STATIC brep/brep.cpp brep/modeling.cpp + brep/brep_drawing.cpp brep/step_export.cpp brep/step_import.cpp brep/iges_import.cpp diff --git a/src/brep/brep_drawing.cpp b/src/brep/brep_drawing.cpp new file mode 100644 index 0000000..5ae7222 --- /dev/null +++ b/src/brep/brep_drawing.cpp @@ -0,0 +1,152 @@ +#include "vde/brep/brep_drawing.h" +#include +#include +#include + +namespace vde::brep { +using core::Point3D; +using core::Vector3D; + +namespace { + +// Project a 3D point to 2D based on view direction +std::pair project(const Point3D& p, const Vector3D& dir) { + // Front view (look down -Z): project to XY + if (std::abs(dir.z() + 1.0) < 0.1) return {p.x(), p.y()}; + // Top view (look down -Y): project to XZ, map Z→Y + if (std::abs(dir.y() + 1.0) < 0.1) return {p.x(), -p.z()}; + // Right view (look from +X): project to YZ, map Z→X, Y→Y + if (std::abs(dir.x() - 1.0) < 0.1) return {p.z(), p.y()}; + // Default: XY plane + return {p.x(), p.y()}; +} + +void add_edge_segments(const BrepModel& body, ProjectionView& view, const Vector3D& dir) { + for (size_t ei = 0; ei < body.num_edges(); ++ei) { + const auto& e = body.edge(static_cast(ei)); + // Get vertex positions + Point3D p0(0,0,0), p1(0,0,0); + bool found0 = false, found1 = false; + for (size_t vi = 0; vi < body.num_vertices(); ++vi) { + const auto& v = body.vertex(static_cast(vi)); + if (v.id == e.v_start) { p0 = v.point; found0 = true; } + if (v.id == e.v_end) { p1 = v.point; found1 = true; } + } + if (!found0 || !found1) continue; + + auto [x1, y1] = project(p0, dir); + auto [x2, y2] = project(p1, dir); + view.segments.push_back({x1, y1, x2, y2, false}); + } +} + +} // namespace + +std::vector generate_views(const BrepModel& body) { + std::vector views; + + // Front view: looking from -Z + ProjectionView front; + front.name = "Front"; + front.view_direction = Vector3D(0, 0, -1); + add_edge_segments(body, front, front.view_direction); + views.push_back(std::move(front)); + + // Top view: looking from -Y + ProjectionView top; + top.name = "Top"; + top.view_direction = Vector3D(0, -1, 0); + add_edge_segments(body, top, top.view_direction); + views.push_back(std::move(top)); + + // Right view: looking from +X + ProjectionView right; + right.name = "Right"; + right.view_direction = Vector3D(1, 0, 0); + add_edge_segments(body, right, right.view_direction); + views.push_back(std::move(right)); + + return views; +} + +ProjectionView section_view(const BrepModel& body, + const Point3D& plane_pt, const Vector3D& plane_n) { + ProjectionView view; + view.name = "Section"; + view.view_direction = plane_n; + Vector3D n = plane_n.normalized(); + + for (size_t ei = 0; ei < body.num_edges(); ++ei) { + const auto& e = body.edge(static_cast(ei)); + Point3D p0(0,0,0), p1(0,0,0); + bool f0 = false, f1 = false; + for (size_t vi = 0; vi < body.num_vertices(); ++vi) { + const auto& v = body.vertex(static_cast(vi)); + if (v.id == e.v_start) { p0 = v.point; f0 = true; } + if (v.id == e.v_end) { p1 = v.point; f1 = true; } + } + if (!f0 || !f1) continue; + + double d0 = (p0 - plane_pt).dot(n); + double d1 = (p1 - plane_pt).dot(n); + + // Edge crosses the section plane + if (d0 * d1 < 0 || std::abs(d0) < 1e-9 || std::abs(d1) < 1e-9) { + double t = (std::abs(d0) < 1e-9) ? 0.0 : d0 / (d0 - d1); + t = std::clamp(t, 0.0, 1.0); + Point3D isect = p0 + t * (p1 - p0); + auto [sx, sy] = project(isect, plane_n); + // Store as point (degenerate segment marking intersection location) + view.segments.push_back({sx, sy, sx, sy, false}); + } + } + return view; +} + +bool export_dxf(const std::string& filepath, const std::vector& views) { + std::ofstream f(filepath); + if (!f) return false; + + f << "0\nSECTION\n2\nENTITIES\n"; + + for (const auto& view : views) { + for (const auto& seg : view.segments) { + f << "0\nLINE\n8\n" << view.name << "\n"; + f << "10\n" << seg.x1 << "\n20\n" << seg.y1 << "\n"; + f << "11\n" << seg.x2 << "\n21\n" << seg.y2 << "\n"; + } + } + + f << "0\nENDSEC\n0\nEOF\n"; + return true; +} + +std::vector auto_dimension(const ProjectionView& view) { + if (view.segments.empty()) return {}; + + // Find bounding box + double xmin = view.segments[0].x1, xmax = view.segments[0].x1; + double ymin = view.segments[0].y1, ymax = view.segments[0].y1; + for (auto& s : view.segments) { + xmin = std::min({xmin, s.x1, s.x2}); + xmax = std::max({xmax, s.x1, s.x2}); + ymin = std::min({ymin, s.y1, s.y2}); + ymax = std::max({ymax, s.y1, s.y2}); + } + + std::vector dims; + double offset = 10.0; + + // Overall width + char buf[64]; + snprintf(buf, sizeof(buf), "%.1f", xmax - xmin); + dims.push_back({DimType::Linear, xmin, ymin - offset, xmax, ymin - offset, buf}); + + // Overall height + snprintf(buf, sizeof(buf), "%.1f", ymax - ymin); + dims.push_back({DimType::Linear, xmin - offset, ymin, xmin - offset, ymax, buf}); + + return dims; +} + +} // namespace vde::brep diff --git a/tests/brep/CMakeLists.txt b/tests/brep/CMakeLists.txt index 360b3ef..3193c52 100644 --- a/tests/brep/CMakeLists.txt +++ b/tests/brep/CMakeLists.txt @@ -12,3 +12,4 @@ add_vde_test(test_measure) add_vde_test(test_assembly_constraints) add_vde_test(test_trimmed_surface) add_vde_test(test_brep_heal) +add_vde_test(test_brep_drawing) diff --git a/tests/brep/test_brep_drawing.cpp b/tests/brep/test_brep_drawing.cpp new file mode 100644 index 0000000..99929a8 --- /dev/null +++ b/tests/brep/test_brep_drawing.cpp @@ -0,0 +1,244 @@ +#include +#include "vde/brep/brep_drawing.h" +#include "vde/brep/modeling.h" +#include +#include +#include + +using namespace vde::brep; +using namespace vde::core; + +// ═══════════════════════════════════════════════════════════ +// generate_views — 基本测试 +// ═══════════════════════════════════════════════════════════ + +TEST(BrepDrawingTest, GenerateViews_Box_ReturnsFourViews) { + auto box = make_box(2, 3, 4); + auto views = generate_views(box); + + EXPECT_EQ(views.size(), 4u); + EXPECT_EQ(views[0].name, "front"); + EXPECT_EQ(views[1].name, "top"); + EXPECT_EQ(views[2].name, "right"); + EXPECT_EQ(views[3].name, "iso"); +} + +TEST(BrepDrawingTest, GenerateViews_Box_EachViewHasSegments) { + auto box = make_box(2, 3, 4); + auto views = generate_views(box); + + for (const auto& v : views) { + EXPECT_GT(v.total_segments(), 0u) + << "View " << v.name << " should have segments"; + } +} + +TEST(BrepDrawingTest, GenerateViews_Box_FrontViewHasExpectedContent) { + auto box = make_box(2, 2, 2); + auto views = generate_views(box); + + const auto& front = views[0]; + EXPECT_EQ(front.name, "front"); + // Front view: XY plane projection, should show X=[-1,1], Y=[-1,1] + EXPECT_GT(front.total_segments(), 0u); +} + +TEST(BrepDrawingTest, GenerateViews_Box_ViewDirections) { + auto box = make_box(1, 1, 1); + auto views = generate_views(box); + + // Front: looking down -Z + EXPECT_NEAR(views[0].view_direction.z(), -1.0, 1e-6); + + // Top: looking down -Y + EXPECT_NEAR(views[1].view_direction.y(), -1.0, 1e-6); + + // Right: looking down -X + EXPECT_NEAR(views[2].view_direction.x(), -1.0, 1e-6); +} + +TEST(BrepDrawingTest, GenerateViews_EmptyBody_ReturnsEmpty) { + BrepModel empty; + auto views = generate_views(empty); + EXPECT_TRUE(views.empty()); +} + +TEST(BrepDrawingTest, GenerateViews_Cylinder_ReturnsFourViews) { + auto cyl = make_cylinder(1.0, 3.0, 16); + auto views = generate_views(cyl); + + EXPECT_EQ(views.size(), 4u); + for (const auto& v : views) { + EXPECT_GT(v.total_segments(), 0u); + } +} + +TEST(BrepDrawingTest, GenerateViews_Sphere_ReturnsFourViews) { + auto sphere = make_sphere(2.0, 16, 8); + auto views = generate_views(sphere); + + EXPECT_EQ(views.size(), 4u); + // 球所有视图的主要轮廓应类似 + for (const auto& v : views) { + ASSERT_GT(v.total_segments(), 0u); + } +} + +// ═══════════════════════════════════════════════════════════ +// section_view — 测试 +// ═══════════════════════════════════════════════════════════ + +TEST(BrepDrawingTest, SectionView_Box_AtCenter) { + auto box = make_box(4, 4, 4); + // 截平面 z = 0(XY 平面,法向量沿 Z) + auto view = section_view(box, Point3D(0, 0, 0), Vector3D(0, 0, 1)); + + EXPECT_EQ(view.name, "section"); + // 截面应为矩形轮廓,至少有 4 个交点形成轮廓线段 + // + hatch 线,总数应 > 4 + ASSERT_GT(view.total_segments(), 4u); +} + +TEST(BrepDrawingTest, SectionView_Box_HorizontalPlane) { + auto box = make_box(4, 4, 4); + // z = 1 的水平截面(穿过盒子) + auto view = section_view(box, Point3D(0, 0, 1), Vector3D(0, 0, 1)); + + ASSERT_GT(view.total_segments(), 4u); +} + +TEST(BrepDrawingTest, SectionView_Box_PlaneOutside) { + auto box = make_box(2, 2, 2); + // 截平面在盒子之外(z = 5) + auto view = section_view(box, Point3D(0, 0, 5), Vector3D(0, 0, 1)); + + // 交点不足 3 个 → 无法形成截面 + EXPECT_LT(view.total_segments(), 3u); +} + +TEST(BrepDrawingTest, SectionView_EmptyBody_ReturnsEmpty) { + BrepModel empty; + auto view = section_view(empty, Point3D(0, 0, 0), Vector3D(0, 0, 1)); + + EXPECT_EQ(view.name, "section"); + EXPECT_EQ(view.total_segments(), 0u); +} + +TEST(BrepDrawingTest, SectionView_Cylinder_Midplane) { + auto cyl = make_cylinder(2.0, 6.0, 32); + // 垂直圆柱的 z=0 截面 → 圆形截面 + auto view = section_view(cyl, Point3D(0, 0, 0), Vector3D(0, 0, 1)); + + ASSERT_GT(view.segments.size(), 3u); +} + +// ═══════════════════════════════════════════════════════════ +// DXF 导出 — 测试 +// ═══════════════════════════════════════════════════════════ + +TEST(BrepDrawingTest, ExportDxf_CreatesFile) { + auto box = make_box(2, 2, 2); + auto views = generate_views(box); + + const char* filepath = "test_output.dxf"; + std::remove(filepath); // 清理旧文件 + + bool ok = export_dxf(filepath, views); + EXPECT_TRUE(ok); + + // 验证文件存在 + std::ifstream in(filepath); + EXPECT_TRUE(in.is_open()); + std::string content((std::istreambuf_iterator(in)), + std::istreambuf_iterator()); + in.close(); + + // 验证关键结构 + EXPECT_NE(content.find("SECTION"), std::string::npos); + EXPECT_NE(content.find("ENTITIES"), std::string::npos); + EXPECT_NE(content.find("LINE"), std::string::npos); + EXPECT_NE(content.find("ENDSEC"), std::string::npos); + EXPECT_NE(content.find("EOF"), std::string::npos); + + // 验证有至少一条 LINE 实体 + EXPECT_NE(content.find("LINE"), std::string::npos); + + std::remove(filepath); +} + +TEST(BrepDrawingTest, ExportDxf_HasLayers) { + auto box = make_box(2, 2, 2); + auto views = generate_views(box); + + const char* filepath = "test_layers.dxf"; + std::remove(filepath); + export_dxf(filepath, views); + + std::ifstream in(filepath); + std::string content((std::istreambuf_iterator(in)), + std::istreambuf_iterator()); + in.close(); + + EXPECT_NE(content.find("visible"), std::string::npos); + EXPECT_NE(content.find("DASHED"), std::string::npos); + + std::remove(filepath); +} + +TEST(BrepDrawingTest, ExportDxf_EmptyViews_StillWritesSkeleton) { + std::vector empty_views; + + const char* filepath = "test_empty.dxf"; + std::remove(filepath); + bool ok = export_dxf(filepath, empty_views); + EXPECT_TRUE(ok); + + std::ifstream in(filepath); + std::string content((std::istreambuf_iterator(in)), + std::istreambuf_iterator()); + in.close(); + + EXPECT_NE(content.find("SECTION"), std::string::npos); + EXPECT_NE(content.find("EOF"), std::string::npos); + + std::remove(filepath); +} + +TEST(BrepDrawingTest, ExportDxf_InvalidPath_ReturnsFalse) { + auto box = make_box(1, 1, 1); + auto views = generate_views(box); + bool ok = export_dxf("/nonexistent_dir_xyz/test.dxf", views); + EXPECT_FALSE(ok); +} + +// ═══════════════════════════════════════════════════════════ +// 隐藏线 — 测试 +// ═══════════════════════════════════════════════════════════ + +TEST(BrepDrawingTest, HiddenLines_Box_HasHiddenEdges) { + auto box = make_box(2, 2, 2); + auto views = generate_views(box); + + // 每个标准视图均应有隐藏边 + for (size_t i = 0; i < std::min(views.size(), 3u); ++i) { + // 前/俯/右视图都应该有隐藏线 + EXPECT_GE(views[i].hidden_lines.size() + views[i].segments.size(), 1u); + } +} + +TEST(BrepDrawingTest, DrawSegment_DefaultNotHidden) { + DrawSegment seg{0, 0, 1, 1}; + EXPECT_FALSE(seg.hidden); +} + +TEST(BrepDrawingTest, DrawSegment_Hidden) { + DrawSegment seg{0, 0, 1, 1, true}; + EXPECT_TRUE(seg.hidden); +} + +TEST(BrepDrawingTest, ProjectionView_TotalSegments) { + ProjectionView v; + v.segments = {{0,0,1,0}, {1,0,1,1}}; + v.hidden_lines = {{0,1,0,0, true}}; + EXPECT_EQ(v.total_segments(), 3u); +}