From e02c5980b4d0475a080ef6e1c8b598335c0b497b 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 16:14:09 +0000 Subject: [PATCH] feat: LOD mesh generation + incremental BVH --- include/vde/mesh/mesh_lod.h | 79 ++++++++++++++ include/vde/spatial/incremental_bvh.h | 52 +++++++++ src/CMakeLists.txt | 2 + src/mesh/mesh_lod.cpp | 55 ++++++++++ src/spatial/incremental_bvh.cpp | 115 ++++++++++++++++++++ tests/mesh/CMakeLists.txt | 1 + tests/mesh/test_mesh_lod.cpp | 151 ++++++++++++++++++++++++++ 7 files changed, 455 insertions(+) create mode 100644 include/vde/mesh/mesh_lod.h create mode 100644 include/vde/spatial/incremental_bvh.h create mode 100644 src/mesh/mesh_lod.cpp create mode 100644 src/spatial/incremental_bvh.cpp create mode 100644 tests/mesh/test_mesh_lod.cpp diff --git a/include/vde/mesh/mesh_lod.h b/include/vde/mesh/mesh_lod.h new file mode 100644 index 0000000..ffa878f --- /dev/null +++ b/include/vde/mesh/mesh_lod.h @@ -0,0 +1,79 @@ +#pragma once +#include "vde/mesh/halfedge_mesh.h" +#include +#include + +namespace vde::brep { class BrepModel; } + +namespace vde::mesh { + +/** + * @brief Level-of-detail mesh with multiple detail levels + * + * Stores a sequence of simplified meshes from highest detail (index 0) + * to lowest. Screen-space error thresholds determine which level to + * use for rendering. + * + * @ingroup mesh + */ +struct LODMesh { + std::string name; + /// Meshes from highest detail (index 0) to lowest + std::vector levels; + /// Screen-space error thresholds for each level (in pixels) + std::vector thresholds; + + /** + * @brief Get the appropriate LOD level for a given screen-space error + * + * Selects the lowest-detail level whose threshold is ≤ error. + * Walks thresholds from coarsest to finest. + * + * @param error Screen-space error in pixels + * @return LOD level index (0 = highest detail) + */ + [[nodiscard]] int level_for_error(double error) const; + + /** + * @brief Get the mesh at a specific LOD level (clamped) + * + * @param level Desired LOD level + * @return Reference to the mesh at that level + */ + [[nodiscard]] const HalfedgeMesh& mesh_at(int level) const; +}; + +/** + * @brief Generate LOD meshes from a high-detail mesh + * + * Iteratively applies QEM simplification, reducing face count + * by `reduction_ratio` at each level. + * + * @param mesh High-detail input mesh + * @param num_levels Number of LOD levels (including base) + * @param reduction_ratio Ratio of faces to keep at each level (0.5 = half) + * @return LOD meshes with thresholds proportional to level index + * + * @code{.cpp} + * auto lod = generate_lod(high_res, 4, 0.5); + * int level = lod.level_for_error(screen_error); + * render(lod.mesh_at(level)); + * @endcode + */ +[[nodiscard]] LODMesh generate_lod(const HalfedgeMesh& mesh, + int num_levels = 3, double reduction_ratio = 0.5); + +/** + * @brief Generate LOD from B-Rep body (tessellate + simplify) + * + * Tessellates the B-Rep body with default deflection, then + * generates LOD levels via QEM simplification. + * + * @param body B-Rep model + * @param num_levels Number of LOD levels + * @return LOD meshes + */ +[[nodiscard]] LODMesh generate_brep_lod(const brep::BrepModel& body, + int num_levels = 3); + +} // namespace vde::mesh diff --git a/include/vde/spatial/incremental_bvh.h b/include/vde/spatial/incremental_bvh.h new file mode 100644 index 0000000..d4938ba --- /dev/null +++ b/include/vde/spatial/incremental_bvh.h @@ -0,0 +1,52 @@ +#pragma once +#include "vde/spatial/bvh.h" +#include +#include + +namespace vde::spatial { + +/** + * @brief Incremental BVH that supports insert/remove without full rebuild + * + * Wraps the standard BVH with a dirty flag. Triangles can be inserted, + * removed, or updated incrementally. The tree is lazily rebuilt on the + * next query when dirty. Suitable for moderately dynamic scenes where + * queries are less frequent than modifications. + * + * @ingroup spatial + */ +class IncrementalBVH { +public: + /// @brief Build from initial set of triangles + void build(const std::vector& triangles); + + /// @brief Insert a new triangle (marks dirty, no immediate rebuild) + void insert(const core::Triangle3D& tri); + + /// @brief Remove a triangle by index (marks dirty) + void remove(size_t index); + + /// @brief Update a triangle's geometry in-place (marks dirty) + void update(size_t index, const core::Triangle3D& tri); + + /** + * @brief Ray query (rebuilds if dirty, then delegates to BVH) + * + * @param ray Query ray + * @return All hit results (t, triangle, hit point) + */ + std::vector ray_query(const Ray3Dd& ray) const; + + /// @brief Number of triangles + [[nodiscard]] size_t size() const { return triangles_.size(); } + +private: + std::vector triangles_; + std::vector bboxes_; + BVH bvh_; + bool dirty_ = true; + + void rebuild_if_dirty(); +}; + +} // namespace vde::spatial diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index ab64e16..a80609e 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -72,6 +72,7 @@ add_library(vde_mesh STATIC mesh/geodesic.cpp mesh/mesh_curvature.cpp mesh/marching_cubes.cpp + mesh/mesh_lod.cpp ) target_include_directories(vde_mesh PUBLIC ${CMAKE_SOURCE_DIR}/include @@ -87,6 +88,7 @@ add_library(vde_spatial STATIC spatial/octree.cpp spatial/kd_tree.cpp spatial/r_tree.cpp + spatial/incremental_bvh.cpp ) target_include_directories(vde_spatial PUBLIC ${CMAKE_SOURCE_DIR}/include diff --git a/src/mesh/mesh_lod.cpp b/src/mesh/mesh_lod.cpp new file mode 100644 index 0000000..b423a4c --- /dev/null +++ b/src/mesh/mesh_lod.cpp @@ -0,0 +1,55 @@ +#include "vde/mesh/mesh_lod.h" +#include "vde/mesh/mesh_simplify.h" +#include "vde/brep/brep.h" +#include + +namespace vde::mesh { + +LODMesh generate_lod(const HalfedgeMesh& mesh, int num_levels, double reduction_ratio) { + LODMesh result; + result.name = "lod"; + result.levels.push_back(mesh); + result.thresholds.push_back(0.0); // base level: always use + + HalfedgeMesh current = mesh; + for (int i = 1; i < num_levels; ++i) { + int current_faces = static_cast(current.num_faces()); + int target_faces = static_cast(current_faces * reduction_ratio); + if (target_faces < 4) break; + + SimplifyOptions opts; + opts.target_ratio = static_cast(target_faces) / current_faces; + opts.max_iterations = 100; + opts.preserve_boundary = true; + + current = simplify_mesh(current, opts); + result.levels.push_back(current); + result.thresholds.push_back(static_cast(i) * 100.0); + } + + return result; +} + +LODMesh generate_brep_lod(const brep::BrepModel& body, int num_levels) { + auto mesh = body.to_mesh(0.1); + return generate_lod(mesh, num_levels); +} + +int LODMesh::level_for_error(double error) const { + for (int i = static_cast(thresholds.size()) - 1; i >= 0; --i) { + if (error >= thresholds[i]) return i; + } + return 0; +} + +const HalfedgeMesh& LODMesh::mesh_at(int level) const { + if (levels.empty()) { + static HalfedgeMesh empty; + return empty; + } + if (level < 0) level = 0; + if (level >= static_cast(levels.size())) level = static_cast(levels.size()) - 1; + return levels[level]; +} + +} // namespace vde::mesh diff --git a/src/spatial/incremental_bvh.cpp b/src/spatial/incremental_bvh.cpp new file mode 100644 index 0000000..036efe3 --- /dev/null +++ b/src/spatial/incremental_bvh.cpp @@ -0,0 +1,115 @@ +#include "vde/spatial/incremental_bvh.h" +#include "vde/core/line.h" +#include +#include + +namespace vde::spatial { + +using core::Point3D; +using core::Vector3D; + +namespace { + +/// Compute AABB from triangle vertices +AABB3D triangle_bounds(const Triangle3D& tri) { + double min_x = std::min({tri.v(0).x(), tri.v(1).x(), tri.v(2).x()}); + double min_y = std::min({tri.v(0).y(), tri.v(1).y(), tri.v(2).y()}); + double min_z = std::min({tri.v(0).z(), tri.v(1).z(), tri.v(2).z()}); + double max_x = std::max({tri.v(0).x(), tri.v(1).x(), tri.v(2).x()}); + double max_y = std::max({tri.v(0).y(), tri.v(1).y(), tri.v(2).y()}); + double max_z = std::max({tri.v(0).z(), tri.v(1).z(), tri.v(2).z()}); + return AABB3D(Point3D(min_x, min_y, min_z), Point3D(max_x, max_y, max_z)); +} + +/// Möller–Trumbore ray-triangle intersection +bool mt_intersect(const Ray3Dd& ray, const Triangle3D& tri, + double& t_out, Point3D& point_out) { + const auto& v0 = tri.v(0); + const auto& v1 = tri.v(1); + const auto& v2 = tri.v(2); + + Vector3D e1 = v1 - v0; + Vector3D e2 = v2 - v0; + Vector3D pvec = ray.direction().cross(e2); + double det = e1.dot(pvec); + + if (std::abs(det) < 1e-12) return false; + + double inv_det = 1.0 / det; + Vector3D tvec = ray.origin() - v0; + double u = tvec.dot(pvec) * inv_det; + if (u < 0.0 || u > 1.0) return false; + + Vector3D qvec = tvec.cross(e1); + double v = ray.direction().dot(qvec) * inv_det; + if (v < 0.0 || u + v > 1.0) return false; + + t_out = e2.dot(qvec) * inv_det; + if (t_out <= 0.0) return false; + + point_out = ray.origin() + ray.direction() * t_out; + return true; +} + +} // anonymous namespace + +void IncrementalBVH::build(const std::vector& triangles) { + triangles_ = triangles; + bboxes_.resize(triangles.size()); + for (size_t i = 0; i < triangles.size(); ++i) { + bboxes_[i] = triangle_bounds(triangles_[i]); + } + bvh_.build(triangles_); + dirty_ = false; +} + +void IncrementalBVH::insert(const Triangle3D& tri) { + triangles_.push_back(tri); + bboxes_.push_back(triangle_bounds(tri)); + dirty_ = true; +} + +void IncrementalBVH::remove(size_t index) { + if (index < triangles_.size()) { + triangles_.erase(triangles_.begin() + static_cast(index)); + bboxes_.erase(bboxes_.begin() + static_cast(index)); + dirty_ = true; + } +} + +void IncrementalBVH::update(size_t index, const Triangle3D& tri) { + if (index < triangles_.size()) { + triangles_[index] = tri; + bboxes_[index] = triangle_bounds(tri); + dirty_ = true; + } +} + +void IncrementalBVH::rebuild_if_dirty() { + if (dirty_ && !triangles_.empty()) { + bvh_ = BVH(); + bvh_.build(triangles_); + dirty_ = false; + } +} + +std::vector IncrementalBVH::ray_query(const Ray3Dd& ray) const { + // Rebuild tree if modifications have occurred since last query + const_cast(this)->rebuild_if_dirty(); + + // Use BVH to find candidate triangles + auto candidates = bvh_.query_ray(ray); + + // Compute exact intersections for each candidate + std::vector hits; + for (const auto& tri : candidates) { + double t; + Point3D point; + if (mt_intersect(ray, tri, t, point)) { + hits.push_back({t, tri, point}); + } + } + return hits; +} + +} // namespace vde::spatial diff --git a/tests/mesh/CMakeLists.txt b/tests/mesh/CMakeLists.txt index 6775e8a..dcb71a5 100644 --- a/tests/mesh/CMakeLists.txt +++ b/tests/mesh/CMakeLists.txt @@ -3,3 +3,4 @@ add_vde_test(test_delaunay) add_vde_test(test_quality) add_vde_test(test_smooth) add_vde_test(test_delaunay_3d) +add_vde_test(test_mesh_lod) diff --git a/tests/mesh/test_mesh_lod.cpp b/tests/mesh/test_mesh_lod.cpp new file mode 100644 index 0000000..6298e69 --- /dev/null +++ b/tests/mesh/test_mesh_lod.cpp @@ -0,0 +1,151 @@ +#include +#include "vde/mesh/mesh_lod.h" +#include "vde/mesh/halfedge_mesh.h" +#include + +using namespace vde::mesh; + +/// Create a simple box mesh (12 triangles, 8 vertices) +static HalfedgeMesh make_box_mesh() { + // Unit cube vertices + std::vector verts = { + {0, 0, 0}, {1, 0, 0}, {1, 1, 0}, {0, 1, 0}, // bottom + {0, 0, 1}, {1, 0, 1}, {1, 1, 1}, {0, 1, 1}, // top + }; + // 12 triangles (2 per face) + std::vector> tris = { + {0, 2, 1}, {0, 3, 2}, // front + {1, 2, 6}, {1, 6, 5}, // right + {5, 6, 7}, {5, 7, 4}, // back + {4, 7, 3}, {4, 3, 0}, // left + {3, 7, 6}, {3, 6, 2}, // top + {4, 0, 1}, {4, 1, 5}, // bottom + }; + + HalfedgeMesh mesh; + mesh.build_from_triangles(verts, tris); + return mesh; +} + +// ────────────────────────────────────────────────────── +// LOD generation +// ────────────────────────────────────────────────────── + +TEST(LODMeshTest, GenerateLOD_DefaultLevels) { + auto box = make_box_mesh(); + auto lod = generate_lod(box, 3, 0.5); + + // Default: 3 levels (base + 2 simplified) + EXPECT_EQ(lod.levels.size(), 3u); + EXPECT_EQ(lod.thresholds.size(), 3u); + + // Level 0 is the original mesh (12 faces) + EXPECT_GE(lod.levels[0].num_faces(), 8u); + // Each level should have ≤ faces than previous + EXPECT_LE(lod.levels[1].num_faces(), lod.levels[0].num_faces()); + EXPECT_LE(lod.levels[2].num_faces(), lod.levels[1].num_faces()); +} + +TEST(LODMeshTest, GenerateLOD_SingleLevel) { + auto box = make_box_mesh(); + auto lod = generate_lod(box, 1, 0.5); + + // Only 1 level = base (no simplification) + EXPECT_EQ(lod.levels.size(), 1u); + EXPECT_EQ(lod.levels[0].num_faces(), 12u); +} + +TEST(LODMeshTest, GenerateLOD_AggressiveReduction) { + auto box = make_box_mesh(); + auto lod = generate_lod(box, 5, 0.1); // keep only 10% each level + + // Should stop early when < 4 faces remain + EXPECT_GE(lod.levels.size(), 1u); + EXPECT_LE(lod.levels.size(), 5u); +} + +TEST(LODMeshTest, GenerateLOD_EmptyMesh) { + HalfedgeMesh empty; + auto lod = generate_lod(empty, 3, 0.5); + + EXPECT_EQ(lod.levels.size(), 1u); // only base + EXPECT_EQ(lod.levels[0].num_faces(), 0u); +} + +// ────────────────────────────────────────────────────── +// level_for_error +// ────────────────────────────────────────────────────── + +TEST(LODMeshTest, LevelForError_ZeroError) { + auto box = make_box_mesh(); + auto lod = generate_lod(box, 3, 0.5); + + // error 0 → base level (threshold 0.0) + EXPECT_EQ(lod.level_for_error(0.0), 0); +} + +TEST(LODMeshTest, LevelForError_ThresholdBoundary) { + auto box = make_box_mesh(); + auto lod = generate_lod(box, 3, 0.5); + + // thresholds: [0.0, 100.0, 200.0] + EXPECT_EQ(lod.level_for_error(50.0), 0); + EXPECT_EQ(lod.level_for_error(100.0), 1); // ≥ 100 → level 1 + EXPECT_EQ(lod.level_for_error(150.0), 1); + EXPECT_EQ(lod.level_for_error(200.0), 2); // ≥ 200 → level 2 + EXPECT_EQ(lod.level_for_error(999.0), 2); +} + +TEST(LODMeshTest, LevelForError_NegativeError) { + auto box = make_box_mesh(); + auto lod = generate_lod(box, 3, 0.5); + + // Negative error → level 0 (base) + EXPECT_EQ(lod.level_for_error(-1.0), 0); +} + +// ────────────────────────────────────────────────────── +// mesh_at +// ────────────────────────────────────────────────────── + +TEST(LODMeshTest, MeshAt_ValidLevels) { + auto box = make_box_mesh(); + auto lod = generate_lod(box, 3, 0.5); + + EXPECT_EQ(lod.mesh_at(0).num_faces(), 12u); + EXPECT_LE(lod.mesh_at(1).num_faces(), 12u); + EXPECT_LE(lod.mesh_at(2).num_faces(), lod.mesh_at(1).num_faces()); +} + +TEST(LODMeshTest, MeshAt_ClampNegative) { + auto box = make_box_mesh(); + auto lod = generate_lod(box, 3, 0.5); + + // Negative level → clamped to 0 + EXPECT_EQ(lod.mesh_at(-1).num_faces(), lod.mesh_at(0).num_faces()); + EXPECT_EQ(lod.mesh_at(-100).num_faces(), lod.mesh_at(0).num_faces()); +} + +TEST(LODMeshTest, MeshAt_ClampTooHigh) { + auto box = make_box_mesh(); + auto lod = generate_lod(box, 3, 0.5); + + int last = static_cast(lod.levels.size()) - 1; + EXPECT_EQ(lod.mesh_at(999).num_faces(), lod.mesh_at(last).num_faces()); +} + +TEST(LODMeshTest, MeshAt_EmptyLOD) { + LODMesh empty_lod; + // mesh_at returns a static empty mesh for empty LOD + EXPECT_EQ(empty_lod.mesh_at(0).num_faces(), 0u); + EXPECT_EQ(empty_lod.mesh_at(5).num_faces(), 0u); +} + +// ────────────────────────────────────────────────────── +// generate_brep_lod (integration) +// ────────────────────────────────────────────────────── + +// Forward-declare for linking only (no brep::make_box in this TU's link deps) +// This test is compile-time only; runtime needs brep linked. +// We test through the LOD API directly above; brep path is tested +// in the brep test suite.