diff --git a/src/render/CMakeLists.txt b/src/render/CMakeLists.txt index 98b86f6..09f60b1 100644 --- a/src/render/CMakeLists.txt +++ b/src/render/CMakeLists.txt @@ -4,6 +4,7 @@ add_library(geopro_render STATIC Scene.cpp ColorLutBuilder.cpp CameraPreset.cpp VoxelFromScatters.cpp ContourBands.cpp actors/GridContourActor.cpp actors/VoxelActor.cpp actors/CurtainActor.cpp actors/MapLineActor.cpp actors/ScatterActor.cpp actors/AnomalyActor.cpp actors/ElectrodeActor.cpp actors/TerrainActor.cpp actors/AxesActor.cpp interact/SlicePlaneMath.cpp interact/SliceTool.cpp interact/PickInteractorStyle.cpp interact/InteractionManager.cpp interact/AnomalyDrawTool.cpp ground/TileMath.cpp + lod/ViewAdaptiveLodPolicy.cpp source/WholeVolumeSource.cpp source/BrickPager.cpp source/OutOfCoreSource.cpp) target_include_directories(geopro_render PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) target_link_libraries(geopro_render PUBLIC geopro_core geopro_store ${VTK_LIBRARIES} GDAL::GDAL) diff --git a/src/render/lod/ViewAdaptiveLodPolicy.cpp b/src/render/lod/ViewAdaptiveLodPolicy.cpp new file mode 100644 index 0000000..c7dcb26 --- /dev/null +++ b/src/render/lod/ViewAdaptiveLodPolicy.cpp @@ -0,0 +1,235 @@ +#include "lod/ViewAdaptiveLodPolicy.hpp" + +#include +#include +#include +#include + +namespace geopro::render { + +namespace { + +constexpr double kPi = 3.14159265358979323846; + +using Vec3 = std::array; + +Vec3 sub(const double a[3], const double b[3]) { + return {a[0] - b[0], a[1] - b[1], a[2] - b[2]}; +} +double dot(const Vec3& a, const Vec3& b) { + return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; +} +Vec3 cross(const Vec3& a, const Vec3& b) { + return {a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], + a[0] * b[1] - a[1] * b[0]}; +} +double norm(const Vec3& a) { return std::sqrt(dot(a, a)); } +Vec3 normalize(const Vec3& a) { + const double n = norm(a); + if (n <= 0.0) return {0, 0, 1}; + return {a[0] / n, a[1] / n, a[2] / n}; +} + +// 一个内侧法向平面:n·x + d ≥ 0 为内侧。 +struct Plane { + Vec3 n; + double d; +}; + +// 由透视相机参数构造 5 个内侧法向平面(左/右/上/下/近;远平面省略,不参与视野裁剪)。 +// +// 设 f=前向、r=右向、u=真上向(正交基);tanV=tan(fovY/2)、tanH=tanV*aspect。 +// 视空间内侧条件:|vx| ≤ tanH·vz、|vy| ≤ tanV·vz、vz ≥ near。 +// 将 vx=dot(rel,r)、vy=dot(rel,u)、vz=dot(rel,f)(rel=x-pos)代入得世界系线性平面: +// 右:tanH·f - r;左:tanH·f + r;上:tanV·f - u;下:tanV·f + u(均过 pos)。 +// 近:f,过 pos+near·f。 +std::array buildFrustumPlanes(const CameraView& cam) { + const Vec3 pos{cam.pos[0], cam.pos[1], cam.pos[2]}; + const Vec3 f = normalize(sub(cam.focal, cam.pos)); + const Vec3 upHint{cam.up[0], cam.up[1], cam.up[2]}; + Vec3 r = cross(f, upHint); + if (norm(r) <= 1e-12) { + // up 与视线共线:换一个参考轴。 + r = cross(f, Vec3{1, 0, 0}); + if (norm(r) <= 1e-12) r = cross(f, Vec3{0, 1, 0}); + } + r = normalize(r); + const Vec3 u = normalize(cross(r, f)); + + const double tanV = std::tan(0.5 * cam.fovYDeg * kPi / 180.0); + const double tanH = tanV * (cam.aspect > 0 ? cam.aspect : 1.0); + + auto makeThroughPos = [&](const Vec3& n) -> Plane { + return Plane{n, -dot(n, pos)}; + }; + + std::array planes{}; + planes[0] = makeThroughPos({tanH * f[0] - r[0], tanH * f[1] - r[1], + tanH * f[2] - r[2]}); // 右 + planes[1] = makeThroughPos({tanH * f[0] + r[0], tanH * f[1] + r[1], + tanH * f[2] + r[2]}); // 左 + planes[2] = makeThroughPos({tanV * f[0] - u[0], tanV * f[1] - u[1], + tanV * f[2] - u[2]}); // 上 + planes[3] = makeThroughPos({tanV * f[0] + u[0], tanV * f[1] + u[1], + tanV * f[2] + u[2]}); // 下 + // 近平面:取很小的正 near,避免把贴脸的体裁掉;只防身后。 + const double nearD = 1e-6; + planes[4] = Plane{f, -(dot(f, pos) + nearD)}; // 近(vz ≥ near) + return planes; +} + +// AABB[bmin,bmax] 是否与视锥相交(保守:p-vertex 测试,与 OutOfCoreSource 一致)。 +bool aabbInFrustum(const Vec3& bmin, const Vec3& bmax, + const std::array& planes) { + for (const Plane& pl : planes) { + const double px = (pl.n[0] >= 0) ? bmax[0] : bmin[0]; + const double py = (pl.n[1] >= 0) ? bmax[1] : bmin[1]; + const double pz = (pl.n[2] >= 0) ? bmax[2] : bmin[2]; + if (pl.n[0] * px + pl.n[1] * py + pl.n[2] * pz + pl.d < 0.0) { + return false; // 最正顶点都在该面外 → 整盒在外。 + } + } + return true; +} + +int ceilDiv(int a, int b) { return (a + b - 1) / b; } + +// level L 各轴体素维度 = ceil(n / 2^L),至少 1。 +int dimAtLevel(int n, int level) { + const int d = ceilDiv(n, 1 << level); + return d > 0 ? d : 1; +} + +// 某 level 某轴块数。 +int bricksAtLevel(int n, int brick, int level) { + return ceilDiv(dimAtLevel(n, level), brick); +} + +// 在给定 level 上做视锥裁剪,求可见 brick 区间(半开)。返回是否有可见块。 +bool visibleBrickRange(const VolumeView& vol, int level, + const std::array& planes, int& bx0, int& bx1, + int& by0, int& by1, int& bz0, int& bz1) { + const int bxN = bricksAtLevel(vol.nx, vol.brick, level); + const int byN = bricksAtLevel(vol.ny, vol.brick, level); + const int bzN = bricksAtLevel(vol.nz, vol.brick, level); + + const double scale = static_cast(std::int64_t(1) << level); // 2^L + const double sp[3] = {vol.spacing[0] * scale, vol.spacing[1] * scale, + vol.spacing[2] * scale}; + const int dimX = dimAtLevel(vol.nx, level); + const int dimY = dimAtLevel(vol.ny, level); + const int dimZ = dimAtLevel(vol.nz, level); + + bx0 = by0 = bz0 = 1 << 30; + bx1 = by1 = bz1 = 0; + bool any = false; + + for (int bz = 0; bz < bzN; ++bz) { + const int k0 = bz * vol.brick; + const int kd = std::min(vol.brick, dimZ - k0); + for (int by = 0; by < byN; ++by) { + const int j0 = by * vol.brick; + const int jd = std::min(vol.brick, dimY - j0); + for (int bx = 0; bx < bxN; ++bx) { + const int i0 = bx * vol.brick; + const int id = std::min(vol.brick, dimX - i0); + const Vec3 bmin{vol.origin[0] + i0 * sp[0], vol.origin[1] + j0 * sp[1], + vol.origin[2] + k0 * sp[2]}; + const Vec3 bmax{vol.origin[0] + (i0 + id) * sp[0], + vol.origin[1] + (j0 + jd) * sp[1], + vol.origin[2] + (k0 + kd) * sp[2]}; + if (aabbInFrustum(bmin, bmax, planes)) { + any = true; + bx0 = std::min(bx0, bx); + bx1 = std::max(bx1, bx + 1); + by0 = std::min(by0, by); + by1 = std::max(by1, by + 1); + bz0 = std::min(bz0, bz); + bz1 = std::max(bz1, bz + 1); + } + } + } + } + return any; +} + +// 区间在该 level 重组单纹理的某轴体素数(块数 × brick,截到该 level 维度)。 +int axisTexture(int b0, int b1, int brick, int dimL) { + const int start = b0 * brick; + const int end = std::min(b1 * brick, dimL); + return std::max(0, end - start); +} + +} // namespace + +LodSelection selectLod(const VolumeView& vol, const CameraView& cam, + int maxTextureDim) { + LodSelection out{}; + out.empty = true; + + const int maxLevel = std::max(0, vol.levels - 1); + const std::array planes = buildFrustumPlanes(cam); + + // ── ② 选层第一步:定"最细的不过采样层" Lmin。 ─────────────────────────── + // worldPerPixel = 2·tanV·dist / viewportH(透视下屏幕一像素对应的世界尺度)。 + // level L 体素世界尺寸 = minSpacing × 2^L。不过采样 ⇔ 体素尺寸 ≥ worldPerPixel, + // 即每体素投影 ≳1 像素。取满足该式的最细 L。 + const Vec3 f = normalize(sub(cam.focal, cam.pos)); + const Vec3 center{vol.origin[0] + 0.5 * vol.nx * vol.spacing[0], + vol.origin[1] + 0.5 * vol.ny * vol.spacing[1], + vol.origin[2] + 0.5 * vol.nz * vol.spacing[2]}; + const Vec3 toCenter = sub(center.data(), cam.pos); + const double dist = std::max(1e-9, dot(toCenter, f)); // 沿视线深度(≥0) + + const double tanV = std::tan(0.5 * cam.fovYDeg * kPi / 180.0); + const int vph = cam.viewportH > 0 ? cam.viewportH : 1; + const double worldPerPixel = 2.0 * tanV * dist / static_cast(vph); + + const double minSpacing = + std::min({std::abs(vol.spacing[0]), std::abs(vol.spacing[1]), + std::abs(vol.spacing[2])}); + + int lmin = 0; + if (minSpacing > 0.0 && worldPerPixel > 0.0) { + // 求最小 L 使 minSpacing·2^L ≥ worldPerPixel。 + const double ratio = worldPerPixel / minSpacing; + if (ratio > 1.0) { + lmin = static_cast(std::ceil(std::log2(ratio))); + } + } + lmin = std::clamp(lmin, 0, maxLevel); + + // ── ③ 从 Lmin 起逐级变粗,直到可见区间重组各轴 ≤ maxTextureDim(硬约束 a)。 ─ + for (int level = lmin; level <= maxLevel; ++level) { + int bx0, bx1, by0, by1, bz0, bz1; + if (!visibleBrickRange(vol, level, planes, bx0, bx1, by0, by1, bz0, bz1)) { + out.empty = true; // 该层全裁 → 体在视锥外(各 level 同一视锥,结论一致)。 + return out; + } + const int dimX = dimAtLevel(vol.nx, level); + const int dimY = dimAtLevel(vol.ny, level); + const int dimZ = dimAtLevel(vol.nz, level); + const int tx = axisTexture(bx0, bx1, vol.brick, dimX); + const int ty = axisTexture(by0, by1, vol.brick, dimY); + const int tz = axisTexture(bz0, bz1, vol.brick, dimZ); + + const bool fits = + tx <= maxTextureDim && ty <= maxTextureDim && tz <= maxTextureDim; + if (fits || level == maxLevel) { + out.level = level; + out.bx0 = bx0; + out.bx1 = bx1; + out.by0 = by0; + out.by1 = by1; + out.bz0 = bz0; + out.bz1 = bz1; + out.empty = false; + return out; + } + // 不满足 maxTextureDim 且非最粗层 → 继续变粗。 + } + + return out; // 理论不可达(循环必返回);保底 empty。 +} + +} // namespace geopro::render diff --git a/src/render/lod/ViewAdaptiveLodPolicy.hpp b/src/render/lod/ViewAdaptiveLodPolicy.hpp new file mode 100644 index 0000000..390240a --- /dev/null +++ b/src/render/lod/ViewAdaptiveLodPolicy.hpp @@ -0,0 +1,64 @@ +#pragma once + +namespace geopro::render { + +// ── C1:视野自适应 LOD 选层(纯逻辑,headless 可测,零 VTK/Qt 依赖)──────────── +// +// 把"渲视野内、远处自动粗、近处细且只取视野小块"的策略钉成纯数值函数: +// 相机参数用平面结构 CameraView 传(渲染层从 vtkCamera 填),不直接吃 vtkCamera。 +// 输出:选定 LOD level + 该层要渲的 brick 区间(半开)+ 是否整体在视锥外。 +// +// 与 OutOfCoreSource 的几何约定保持一致: +// - 金字塔 level 0 = 最细;level L 维度 = ceil(n / 2^L); +// - level L 世界间距 = 基础 spacing × 2^L;世界 origin 不随 level 变; +// - 块布局:每 brick 个体素一块,边缘块更小;i 最快、k 最慢。 + +// 平面相机参数(透视)。渲染层从 vtkCamera 填: +// pos/focal/up 世界系;fovYDeg/aspect 透视张角与宽高比;viewportH 像素高(定屏幕分辨率)。 +struct CameraView { + double pos[3]; + double focal[3]; + double up[3]; + double fovYDeg; // 垂直全张角(度) + double aspect; // 视口宽/高 + int viewportH; // 视口像素高(用于"体素投影 ≳1 像素/体素"判定) +}; + +// 体的元信息(从 StoreMeta + 垂向夸张 exagg)。 +// nx/ny/nz:level 0 体素维度;brick:块边长;levels:总层数(含 level 0)。 +// origin/spacing:level 0 世界几何(spacing 已含 exagg 于 y/z);exagg:垂向夸张(仅记录)。 +struct VolumeView { + int nx, ny, nz; + int brick; + int levels; + double origin[3]; + double spacing[3]; + double exagg; +}; + +// 选层结果。 +// level:选定 LOD 层(0=最细)。 +// [bx0,bx1) [by0,by1) [bz0,bz1):该 level 要渲的 brick 区间(半开;empty 时全 0)。 +// empty:体完全在视锥外(无块可渲)。 +struct LodSelection { + int level; + int bx0, bx1; + int by0, by1; + int bz0, bz1; + bool empty; +}; + +// 选层规则: +// ① 视锥裁剪求可见体素 AABB → 该层 brick 区间。 +// ② 选层: +// - 先按"体素投影 ≳1 像素/体素"定**最细的不过采样层** Lmin(近观 worldPerPixel 小 +// → Lmin 小 → 细;远观 → Lmin 大 → 粗); +// - 再从 Lmin 起逐级**变粗**直到可见区间重组单纹理各轴 ≤ maxTextureDim(硬约束 a), +// 或到达最粗层。 +// 这样:远观→粗层(区间≈全体)、近观→细层(区间是视锥内小块),且各轴恒 ≤ maxTextureDim。 +// 视距-层单调:相机拉近 → worldPerPixel 与视距均减 → Lmin 不增 → level 不增。 +// 体在视锥外 → empty=true。 +LodSelection selectLod(const VolumeView& vol, const CameraView& cam, + int maxTextureDim = 16384); + +} // namespace geopro::render diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 175cf04..3321b5a 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -124,6 +124,8 @@ target_sources(geopro_tests PRIVATE render/test_whole_volume_source.cpp) # BrickPager(C):内存恒定的 brick LRU 分页器,驻留 ≤ budget 个解压块,证明超大体浏览内存不爆。 target_sources(geopro_tests PRIVATE render/test_brick_pager.cpp) target_sources(geopro_tests PRIVATE render/test_outofcore_source.cpp) +# ViewAdaptiveLodPolicy(C1):视野自适应 LOD 选层(纯逻辑,零 VTK/Qt)——视锥裁剪求可见 brick 区间 + 视距/分辨率选层。 +target_sources(geopro_tests PRIVATE render/test_view_adaptive_lod.cpp) target_link_libraries(geopro_tests PRIVATE geopro_render ${VTK_LIBRARIES}) vtk_module_autoinit(TARGETS geopro_tests MODULES ${VTK_LIBRARIES}) diff --git a/tests/render/test_view_adaptive_lod.cpp b/tests/render/test_view_adaptive_lod.cpp new file mode 100644 index 0000000..7a27e3c --- /dev/null +++ b/tests/render/test_view_adaptive_lod.cpp @@ -0,0 +1,201 @@ +#include + +#include + +#include "lod/ViewAdaptiveLodPolicy.hpp" + +using geopro::render::CameraView; +using geopro::render::LodSelection; +using geopro::render::selectLod; +using geopro::render::VolumeView; + +namespace { + +// 构造一个规整体:nx=ny=nz=512、brick=64(每轴 8 块)、level0 间距=1、levels=4(L0..L3)。 +// 世界范围 [0,512]³(origin 0)。各 level 维度:512/256/128/64;间距 1/2/4/8。 +VolumeView makeVol(int n = 512, int brick = 64, int levels = 4, double sp = 1.0) { + VolumeView v{}; + v.nx = v.ny = v.nz = n; + v.brick = brick; + v.levels = levels; + v.origin[0] = v.origin[1] = v.origin[2] = 0.0; + v.spacing[0] = v.spacing[1] = v.spacing[2] = sp; + v.exagg = 1.0; + return v; +} + +// 相机:从 +X 方向看体中心。dist = 相机到中心距离;fov/viewportH 控分辨率密度。 +CameraView lookFromX(const VolumeView& v, double dist, double fovYDeg = 30.0, + int viewportH = 1080) { + CameraView c{}; + const double cx = v.origin[0] + 0.5 * v.nx * v.spacing[0]; + const double cy = v.origin[1] + 0.5 * v.ny * v.spacing[1]; + const double cz = v.origin[2] + 0.5 * v.nz * v.spacing[2]; + c.focal[0] = cx; + c.focal[1] = cy; + c.focal[2] = cz; + c.pos[0] = cx + dist; + c.pos[1] = cy; + c.pos[2] = cz; + c.up[0] = 0; + c.up[1] = 0; + c.up[2] = 1; + c.fovYDeg = fovYDeg; + c.aspect = 1.0; + c.viewportH = viewportH; + return c; +} + +} // namespace + +// ── empty:体完全在视锥外(相机背对体)→ empty ────────────────────────────── +TEST(ViewAdaptiveLod, VolumeBehindCameraIsEmpty) { + const VolumeView v = makeVol(); + CameraView c = lookFromX(v, 1000.0); + // 把焦点设到相机背后:相机仍在 +X 远处,但看向 +X 更远(体在身后)。 + c.focal[0] = c.pos[0] + 1000.0; // 视线朝 +X,体在 -X 侧 → 视锥外 + const LodSelection sel = selectLod(v, c); + EXPECT_TRUE(sel.empty); +} + +TEST(ViewAdaptiveLod, VolumeOffToSideIsEmpty) { + const VolumeView v = makeVol(); + // 相机看 +Z(体在 -X..+X、相机在远 +X,焦点正上方)→ 体偏出窄视锥。 + CameraView c{}; + c.pos[0] = 100000; + c.pos[1] = 0; + c.pos[2] = 0; + c.focal[0] = 100000; + c.focal[1] = 0; + c.focal[2] = 100000; // 看 +Z + c.up[0] = 1; + c.up[1] = 0; + c.up[2] = 0; + c.fovYDeg = 5.0; + c.aspect = 1.0; + c.viewportH = 1080; + const LodSelection sel = selectLod(v, c); + EXPECT_TRUE(sel.empty); +} + +// ── 远观整体 → 粗层、区间≈全体、各轴 ≤ maxTextureDim ─────────────────────── +TEST(ViewAdaptiveLod, FarViewPicksCoarseLevelWholeVolume) { + const VolumeView v = makeVol(); + // 很远 + 适中 fov:整体入视锥,worldPerPixel 大 → 粗层。 + const CameraView c = lookFromX(v, 8000.0); + const LodSelection sel = selectLod(v, c); + EXPECT_FALSE(sel.empty); + EXPECT_GT(sel.level, 0); // 远 → 粗(非最细) + // 区间≈全体:覆盖所有块(该 level 块数)。 + // 该 level 每轴块数 = ceil(ceil(512/2^L)/64)。 + const int dimL = (512 + (1 << sel.level) - 1) >> sel.level; + const int bN = (dimL + v.brick - 1) / v.brick; + EXPECT_EQ(sel.bx0, 0); + EXPECT_EQ(sel.bx1, bN); + EXPECT_EQ(sel.by0, 0); + EXPECT_EQ(sel.by1, bN); + EXPECT_EQ(sel.bz0, 0); + EXPECT_EQ(sel.bz1, bN); +} + +// ── 近观局部 → 细层、区间是视锥内小块、各轴 ≤ maxTextureDim ───────────────── +TEST(ViewAdaptiveLod, NearViewPicksFineLevelSmallRegion) { + const VolumeView v = makeVol(); + // 贴近 + 窄 fov:只看到体内一小块,worldPerPixel 小 → 细层(0)、小区间。 + const CameraView c = lookFromX(v, 60.0, 20.0, 1080); + const LodSelection sel = selectLod(v, c); + EXPECT_FALSE(sel.empty); + EXPECT_EQ(sel.level, 0); // 近 → 最细 + // 区间是子集(不是全 8 块):至少某一垂直于视线的轴被裁小。 + const int bNfull = (512 + v.brick - 1) / v.brick; // 8 + const bool yShrunk = (sel.by1 - sel.by0) < bNfull; + const bool zShrunk = (sel.bz1 - sel.bz0) < bNfull; + EXPECT_TRUE(yShrunk || zShrunk); +} + +// ── 选定层重组区间各轴恒 ≤ maxTextureDim(小 maxTextureDim 强制升层/缩区间)── +TEST(ViewAdaptiveLod, RespectsMaxTextureDimAlways) { + const VolumeView v = makeVol(); + // 多组视距 × 极小 maxTextureDim,断言选定层重组单纹理各轴 ≤ maxTextureDim。 + for (double dist : {60.0, 300.0, 1500.0, 8000.0, 40000.0}) { + const CameraView c = lookFromX(v, dist); + const LodSelection sel = selectLod(v, c, /*maxTextureDim=*/64); + if (sel.empty) continue; + // 选定 level 下,区间重组单纹理各轴体素数 = (块数 × brick) 截到该 level 维度。 + const int dimL = (512 + (1 << sel.level) - 1) >> sel.level; + auto axisTex = [&](int b0, int b1) { + const int start = b0 * v.brick; + const int end = std::min(b1 * v.brick, dimL); + return end - start; + }; + EXPECT_LE(axisTex(sel.bx0, sel.bx1), 64); + EXPECT_LE(axisTex(sel.by0, sel.by1), 64); + EXPECT_LE(axisTex(sel.bz0, sel.bz1), 64); + } +} + +TEST(ViewAdaptiveLod, RespectsDefaultMaxTextureDim) { + const VolumeView v = makeVol(/*n=*/4096, /*brick=*/256, /*levels=*/1); + // levels=1(只有 level0)→ 不能升层,只能靠缩区间满足 16384。4096 < 16384 必满足。 + const CameraView c = lookFromX(v, 100000.0); + const LodSelection sel = selectLod(v, c, 16384); + ASSERT_FALSE(sel.empty); + EXPECT_EQ(sel.level, 0); + const int texX = std::min((sel.bx1) * v.brick, 4096) - sel.bx0 * v.brick; + EXPECT_LE(texX, 16384); +} + +// ── 视距-层单调:拉近 level 不增 ──────────────────────────────────────────── +TEST(ViewAdaptiveLod, LevelMonotonicWithDistance) { + const VolumeView v = makeVol(); + int prev = 1 << 30; + for (double dist : {40000.0, 8000.0, 1500.0, 300.0, 60.0}) { // 由远到近 + const CameraView c = lookFromX(v, dist); + const LodSelection sel = selectLod(v, c); + if (sel.empty) continue; + EXPECT_LE(sel.level, prev) << "dist=" << dist; // 拉近 level 不增 + prev = sel.level; + } +} + +// ── 区间半开且合法(b0> sel.level; + const int bN = (dimL + v.brick - 1) / v.brick; + EXPECT_GE(sel.bx0, 0); + EXPECT_LT(sel.bx0, sel.bx1); + EXPECT_LE(sel.bx1, bN); + EXPECT_GE(sel.by0, 0); + EXPECT_LT(sel.by0, sel.by1); + EXPECT_LE(sel.by1, bN); + EXPECT_GE(sel.bz0, 0); + EXPECT_LT(sel.bz0, sel.bz1); + EXPECT_LE(sel.bz1, bN); +} + +// ── side 视角:从某一侧斜看,仍非空且区间合法 ───────────────────────────── +TEST(ViewAdaptiveLod, ObliqueSideViewNotEmpty) { + const VolumeView v = makeVol(); + CameraView c{}; + const double cx = 256, cy = 256, cz = 256; + c.pos[0] = cx + 1200; + c.pos[1] = cy + 1200; + c.pos[2] = cz + 800; + c.focal[0] = cx; + c.focal[1] = cy; + c.focal[2] = cz; + c.up[0] = 0; + c.up[1] = 0; + c.up[2] = 1; + c.fovYDeg = 45.0; + c.aspect = 1.6; + c.viewportH = 1080; + const LodSelection sel = selectLod(v, c); + EXPECT_FALSE(sel.empty); + EXPECT_GE(sel.level, 0); + EXPECT_LT(sel.bx0, sel.bx1); +}