feat/vtk-3d-view #7

Merged
gaozheng merged 301 commits from feat/vtk-3d-view into main 2026-06-27 18:43:52 +08:00
5 changed files with 503 additions and 0 deletions
Showing only changes of commit fa348a2a9f - Show all commits

View File

@ -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 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 interact/SlicePlaneMath.cpp interact/SliceTool.cpp interact/PickInteractorStyle.cpp interact/InteractionManager.cpp interact/AnomalyDrawTool.cpp
ground/TileMath.cpp ground/TileMath.cpp
lod/ViewAdaptiveLodPolicy.cpp
source/WholeVolumeSource.cpp source/BrickPager.cpp source/OutOfCoreSource.cpp) source/WholeVolumeSource.cpp source/BrickPager.cpp source/OutOfCoreSource.cpp)
target_include_directories(geopro_render PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) target_include_directories(geopro_render PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
target_link_libraries(geopro_render PUBLIC geopro_core geopro_store ${VTK_LIBRARIES} GDAL::GDAL) target_link_libraries(geopro_render PUBLIC geopro_core geopro_store ${VTK_LIBRARIES} GDAL::GDAL)

View File

@ -0,0 +1,235 @@
#include "lod/ViewAdaptiveLodPolicy.hpp"
#include <algorithm>
#include <array>
#include <cmath>
#include <cstdint>
namespace geopro::render {
namespace {
constexpr double kPi = 3.14159265358979323846;
using Vec3 = std::array<double, 3>;
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 - rtanH·f + rtanV·f - utanV·f + u均过 pos
// 近f过 pos+near·f。
std::array<Plane, 5> 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<Plane, 5> 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<Plane, 5>& 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<Plane, 5>& 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<double>(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<Plane, 5> 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<double>(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<int>(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

View File

@ -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/nzlevel 0 体素维度brick块边长levels总层数含 level 0
// origin/spacinglevel 0 世界几何spacing 已含 exagg 于 y/zexagg垂向夸张仅记录
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

View File

@ -124,6 +124,8 @@ target_sources(geopro_tests PRIVATE render/test_whole_volume_source.cpp)
# BrickPager(C) brick LRU budget # BrickPager(C) brick LRU budget
target_sources(geopro_tests PRIVATE render/test_brick_pager.cpp) target_sources(geopro_tests PRIVATE render/test_brick_pager.cpp)
target_sources(geopro_tests PRIVATE render/test_outofcore_source.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}) target_link_libraries(geopro_tests PRIVATE geopro_render ${VTK_LIBRARIES})
vtk_module_autoinit(TARGETS geopro_tests MODULES ${VTK_LIBRARIES}) vtk_module_autoinit(TARGETS geopro_tests MODULES ${VTK_LIBRARIES})

View File

@ -0,0 +1,201 @@
#include <gtest/gtest.h>
#include <cmath>
#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=4L0..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<b1落在块数范围内────────────────────────────────
TEST(ViewAdaptiveLod, IntervalsAreValidHalfOpen) {
const VolumeView v = makeVol();
const CameraView c = lookFromX(v, 1500.0);
const LodSelection sel = selectLod(v, c);
ASSERT_FALSE(sel.empty);
const int dimL = (512 + (1 << sel.level) - 1) >> 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);
}