diff --git a/CMakeLists.txt b/CMakeLists.txt index b19b1ec..5b8a311 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -19,6 +19,8 @@ set(CMAKE_AUTORCC ON) set(CMAKE_AUTOUIC ON) if(MSVC) + # 显式钉死 MSVC 运行时库:Release=/MD,Debug=/MDd,杜绝静默漂移(ABI 一致性,见 ENV_SETUP §9.2)。 + set(CMAKE_MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>DLL") add_compile_options(/utf-8 /MP /W4 /permissive-) # 生成 PDB——即使 Release 优化构建也产出调试符号,使 minidump / 运行期崩溃栈可符号化分析 # (生产桌面端排障必需)。/Zi 编译期调试信息;/DEBUG 链接产 PDB;/OPT:REF,ICF 抵消 /DEBUG @@ -37,16 +39,61 @@ endif() # - 仅非 Qt 依赖(GDAL/PROJ/OpenSSL/Eigen/...)走 vcpkg # ===================================================================== +# ===================================================================== +# 构建环境护栏(把「配置错→运行时随机崩溃」变成「配置期一眼可查」) +# 校准基准:本机 Qt 6.11.1 msvc2022_64 + VTK 9.6.x + VS2026-preview 工具集。 +# 工具集只 WARN 不 FATAL(本机 VS2026-preview 与预编译 Qt v143 ABI 兼容、可正常构建)。 +# 详见 docs/ENV_SETUP_Windows.md §4/§5/§9。 +# ===================================================================== + +if(WIN32) + # QT_ROOT 环境变量必须已设且指向有效的官方 MSVC Qt kit(预设的 CMAKE_PREFIX_PATH 读它)。 + if(NOT DEFINED ENV{QT_ROOT} OR "$ENV{QT_ROOT}" STREQUAL "" + OR NOT EXISTS "$ENV{QT_ROOT}/lib/cmake/Qt6") + message(FATAL_ERROR + "QT_ROOT 未设置或无效。请 setx QT_ROOT \"<你的Qt>\\6.11.1\\msvc2022_64\" 后重开终端。见 docs/ENV_SETUP_Windows.md §4。") + endif() +endif() + find_package(Qt6 REQUIRED COMPONENTS Core Gui Widgets Network Sql Concurrent) + +# Qt 版本护栏:major.minor 必须是 6.11(任意补丁号可)。换大小版本会与源码编的 VTK/ADS ABI/兼容性错位。 +if(NOT Qt6_VERSION VERSION_LESS 6.11 AND Qt6_VERSION VERSION_LESS 6.12) + # 6.11.x —— 合规。 +else() + message(FATAL_ERROR + "Qt 版本不匹配:找到 ${Qt6_VERSION},需要 6.11.x(与源码编的 VTK/ADS 兼容)。请安装 6.11.1 msvc2022_64 并把 QT_ROOT 指向它。见 docs/ENV_SETUP_Windows.md §4。") +endif() + +# VTK 就位护栏(friendly):find_package REQUIRED 也会失败,但这里给出可操作的原因。 +if(NOT EXISTS "${CMAKE_SOURCE_DIR}/external/vtk-install/lib/cmake/vtk-9.6") + message(FATAL_ERROR + "VTK 未就位。请按 docs/ENV_SETUP_Windows.md §5.1 源码 Release 编 VTK 9.6.x 到 external/vtk-install。") +endif() + # VTK 9 必须指定 COMPONENTS,否则 VTK_LIBRARIES 为空、链接不到任何 VTK。 # 来自 VTK_DIR(external/vtk-install)。随渲染层增补模块(Volume/Filters 等)。 find_package(VTK REQUIRED COMPONENTS GUISupportQt RenderingOpenGL2 + RenderingFreeType # 导航 gizmo 轴标签(vtkBillboardTextActor3D)运行时字形渲染:注册 FreeType 工厂 InteractionStyle FiltersSources ) +# VTK 版本护栏:major.minor 须为 9.6(VTK_DIR 路径已带 vtk-9.6,越界基本是误装/误指)。 +if(VTK_VERSION VERSION_LESS 9.6 OR NOT VTK_VERSION VERSION_LESS 9.7) + message(FATAL_ERROR + "VTK 版本不匹配:找到 ${VTK_VERSION},需要 9.6.x。请按 docs/ENV_SETUP_Windows.md §5.1 用同一 Qt/工具集重编到 external/vtk-install。") +endif() + +# 工具集护栏(仅 WARN,绝不 FATAL):预编译 Qt 是 msvc2022(v143);VTK 必须与 app 用同一工具集编, +# 否则 std::map/std::string 跨界 ABI 崩溃(见 §10)。本机 VS2026-preview 与 v143 兼容、可正常构建。 +if(MSVC AND NOT MSVC_TOOLSET_VERSION STREQUAL "143") + message(WARNING + "MSVC 工具集为 v${MSVC_TOOLSET_VERSION}(非 v143)。预编译 Qt 为 msvc2022(v143);请确保 external/vtk-install 的 VTK 与本 app 用【同一工具集】编,避免 ABI 不匹配崩溃。见 docs/ENV_SETUP_Windows.md §9.2。") +endif() + # 非 Qt 依赖(vcpkg),随分层逐步启用: # find_package(GDAL CONFIG REQUIRED) # find_package(PROJ CONFIG REQUIRED) diff --git a/CMakePresets.json b/CMakePresets.json index 1143a2c..e3a5560 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -7,12 +7,12 @@ "displayName": "MSVC Debug (官方Qt + vcpkg 非Qt依赖)", "generator": "Ninja", "binaryDir": "${sourceDir}/build/debug", - "toolchainFile": "D:/vcpkg/scripts/buildsystems/vcpkg.cmake", "cacheVariables": { "CMAKE_BUILD_TYPE": "Debug", + "CMAKE_TOOLCHAIN_FILE": "$env{VCPKG_ROOT}/scripts/buildsystems/vcpkg.cmake", "VCPKG_TARGET_TRIPLET": "x64-windows", "CMAKE_EXPORT_COMPILE_COMMANDS": "ON", - "CMAKE_PREFIX_PATH": "D:/Qt/6.11.1/msvc2022_64", + "CMAKE_PREFIX_PATH": "$env{QT_ROOT}", "VTK_DIR": "${sourceDir}/external/vtk-install/lib/cmake/vtk-9.6", "VCPKG_INSTALL_OPTIONS": "--x-buildtrees-root=C:/vcpkg_b" } diff --git a/build.bat b/build.bat index f00e2f4..580b660 100644 --- a/build.bat +++ b/build.bat @@ -54,6 +54,16 @@ if /i "%~1"=="release" ( shift ) +REM --- environment guardrails (fail loud & early, before cmake configure) --- +if not defined QT_ROOT ( + echo [build] QT_ROOT is not set. Run: setx QT_ROOT "D:\Qt\6.11.1\msvc2022_64" ^(see docs/ENV_SETUP_Windows.md^), then reopen the terminal. + exit /b 1 +) +if not defined VCPKG_ROOT ( + echo [build] VCPKG_ROOT is not set. Run: setx VCPKG_ROOT "C:\dev\vcpkg" ^(see docs/ENV_SETUP_Windows.md^), then reopen the terminal. + exit /b 1 +) + set "CMD=%~1" if "%CMD%"=="" set "CMD=app" diff --git a/docs/ENV_SETUP_Windows.md b/docs/ENV_SETUP_Windows.md index 391290d..8b0dcb6 100644 --- a/docs/ENV_SETUP_Windows.md +++ b/docs/ENV_SETUP_Windows.md @@ -1,106 +1,87 @@ # Geopro 3.0 桌面客户端 — Windows 开发环境从零搭建指引 -适用:Windows 10 22H2 / Windows 11,64 位,MSVC 2022 工具链。 -目标:从一台干净的机器,搭到能 `cmake --build` 出可运行的 Qt6 + VTK9 桌面程序。 +适用:Windows 10 22H2 / Windows 11,64 位,**MSVC 2022(v143)工具链**。 +目标:从一台干净的机器,搭到 `build.bat app` 能编出可运行、且**不因 ABI 不匹配崩溃**的 Qt6 + VTK9 桌面程序。 -> 配套设计文档:`docs/superpowers/specs/2026-06-07-geopro-desktop-m1-design.md` +> 最后核对:2026-07-01,已对齐 `CMakePresets.json` / `vcpkg.json` / 根 `CMakeLists.txt` / `build.bat` 的真实配置。 +> ⚠️ 若你手里有更早版本的本文档:**§6.1/§7/§9 曾残留「全 vcpkg(Qt/VTK 也走 vcpkg)」的旧方案,与现状矛盾——以本版为准。** 现状是「方案②-修订」:**官方预编译 Qt + 源码编 VTK + vcpkg 只管非 Qt 依赖**。 --- -## 0. 总览 +## 0. 总览(方案②-修订) -> ⚠️ **构建方案已改定为「方案②-修订」**(经双专家评审 + 实机勘验)。本文档大部分步骤按此更新;**权威步骤以设计 §11 + `docs/superpowers/plans/2026-06-07-m1-phase0-spikes.md` 为准**。 -> -> **方案②-修订要点**:单一 Qt = **官方 MSVC 预编译 Qt**(`D:\Qt\6.11.1\msvc2022_64`)。**凡依赖 Qt 的组件(VTK/ADS/QtKeychain)都不走 vcpkg**(vcpkg 的 Qt 依赖端口会再编一份 qtbase = 双份冲突):VTK 用官方 Qt 源码编到 install 前缀;ADS/QtKeychain 走 FetchContent;仅非 Qt 依赖(GDAL/PROJ/OpenSSL/Eigen/...)走 vcpkg。 -> **关键事实**:① 用户原装 `D:\Qt\6.11.1` 是 **MinGW 版**(MSVC 不可链),须在 Qt 维护工具里补装 **MSVC 2022 64-bit** kit;② VTK 无 MSVC 预编译,三方案都必须源码编;③ 本机 VS18 = MSVC 14.51,链官方 Qt(v143)属"新链旧",ABI 安全。 +**单一 Qt = 官方 MSVC 预编译 Qt**(`D:\Qt\6.11.1\msvc2022_64`)。**凡依赖 Qt 的组件都不走 vcpkg**(vcpkg 的 Qt 端口会再编一份 qtbase = 双份 Qt 冲突): -四块:① 编译器(VS18 / MSVC 14.51)② Git ③ vcpkg(仅非 Qt 依赖)④ 官方 MSVC Qt + 源码 VTK。 +| 组件 | 来源 | 备注 | +|---|---|---| +| Qt 6.11.1(Core/Gui/Widgets/Network/Sql/Concurrent/OpenGL + tools) | **官方 MSVC 2022 64-bit kit**(§4) | 路径由预设 `CMAKE_PREFIX_PATH` 指定 | +| VTK 9.6.x(GUISupportQt/RenderingOpenGL2/RenderingFreeType/InteractionStyle/FiltersSources…) | **源码 Release 编 → `external/vtk-install`**(§5) | 必须对齐同一份 Qt + 同一工具集 + Release/`/MD` | +| ADS(Qt-Advanced-Docking-System 4.3.1) | **FetchContent 自动**(配置时拉,对接官方 Qt) | 无需手动 | +| QtKeychain v0.14.0 | **FetchContent 自动**(`BUILD_WITH_QT6`) | 无需手动 | +| Qwt 6.2(二维科学图表) | **手工克隆到 `external/qwt-src`**(§5.3,gitignored) | 缺失则详情页图表功能不编入 | +| vendored 3DGPRViewer(geopro_gpr3dv) | 仓库内 `external/gpr3dviewer`(随仓库) | 无需手动 | +| 非 Qt 依赖:eigen3 / gdal / gtest / nlohmann-json / openssl / proj | **vcpkg manifest**(`vcpkg.json`,有 baseline 锁版本) | 配置时自动拉 | + +**ABI 铁律(否则运行时崩溃,见 §10)**:Qt(预编译 msvc2022) / VTK(你自己编) / app / 所有 FetchContent 组件,**必须同一工具集(v143)、同一运行时(`/MD`)、同一配置(全 Release)**。任何一处混 Debug/Release 或换工具集,`std::map`/`std::string` 跨界就崩。 --- -## 1. Visual Studio 2022(MSVC + CMake + Ninja) +## 1. Visual Studio(MSVC v143 + CMake + Ninja) -1. 安装 **Visual Studio 2022 Community**(或更高)。 -2. 勾选工作负载 **「使用 C++ 的桌面开发」(Desktop development with C++)**,确保包含: +1. 安装 **Visual Studio 2022 Community**(或更高)。`build.bat` 也兼容 VS2026 preview,但**其工具集必须与预编译 Qt 的 msvc2022(v143) ABI 兼容**——若用 VS2026 的更新工具集,稳妥做法是**用同一工具集重编 VTK**(§5),别让 Qt(v143 预编译) 与 VTK/app(更新工具集) 混。 +2. 勾选工作负载 **「使用 C++ 的桌面开发」**,确保含: - MSVC v143 - VS 2022 C++ x64/x86 生成工具 - Windows 11 SDK(或 Windows 10 SDK) - - C++ CMake tools for Windows(自带 CMake + Ninja) - - C++ AddressSanitizer(用于 Debug Sanitizer,规约 §10.2) -3. 验证:开「x64 Native Tools Command Prompt for VS 2022」,运行: - ``` - cl - cmake --version # 应 ≥ 3.21 - ninja --version - ``` + - C++ CMake tools for Windows(自带 CMake ≥3.21 + Ninja) + - C++ AddressSanitizer(Debug Sanitizer 用) +3. 验证(开「x64 Native Tools Command Prompt for VS」):`cl` / `cmake --version`(≥3.21)/ `ninja --version`。 -> 之后所有 cmake/vcpkg 命令都在 **x64 Native Tools 命令行** 里跑(已设好 MSVC 环境变量)。 +> `build.bat` 会用 `vswhere` 自动定位 VS 并激活 MSVC 环境,所以**日常构建直接跑 `build.bat` 即可**,不必手动开 x64 命令行。但 `cmake/ninja/cl` **不在 PATH**,手动跑 cmake 前需先激活 vcvars64。 --- ## 2. Git -1. 安装 [Git for Windows](https://git-scm.com/download/win)。 -2. 验证:`git --version`。 -3. 本项目当前**尚未初始化 git 仓库**——首次提交前需 `git init`(见 §7)。 +1. 安装 [Git for Windows](https://git-scm.com/download/win),`git --version` 验证。 +2. 本项目**已是 git 仓库**(无需 `git init`);正常 `git clone` 即可。 --- -## 3. vcpkg(依赖管理) +## 3. vcpkg(仅非 Qt 依赖) ```powershell -# 选一个不含空格/中文的路径,例如 C:\dev -git clone https://github.com/microsoft/vcpkg C:\dev\vcpkg +git clone https://github.com/microsoft/vcpkg C:\dev\vcpkg # 路径不含空格/中文 C:\dev\vcpkg\bootstrap-vcpkg.bat +setx VCPKG_ROOT "C:\dev\vcpkg" # 永久;新开终端生效 ``` - -设环境变量(系统环境变量或当前会话): -```powershell -$env:VCPKG_ROOT = "C:\dev\vcpkg" -setx VCPKG_ROOT "C:\dev\vcpkg" # 永久(新开终端生效) -``` - -> 本项目用 **vcpkg manifest 模式**(`vcpkg.json`),不需要手动 `vcpkg install`;CMake 配置时按清单自动拉取。 +- **manifest 模式**:根 `vcpkg.json` 声明依赖 + `builtin-baseline` 锁版本,CMake 配置时自动拉取,**无需手动 `vcpkg install`**。 +- 实际依赖(勿加 Qt/VTK 进来):`eigen3, gdal, gtest, nlohmann-json, openssl, proj`。 +- **不要随意 `vcpkg x-update-baseline`**——baseline 已锁,改动会漂移依赖版本、可能引入 ABI 不一致。 +- 首次会编 GDAL/PROJ 等,较久;可选配 `VCPKG_BINARY_SOURCES` 二进制缓存加速。 --- -## 4. Qt(官方 MSVC 预编译 kit) +## 4. Qt(官方 MSVC 2022 64-bit kit) -**用官方安装器,但必须是 MSVC kit**(你原装的 `mingw_64` 在 MSVC 下不可用): +**必须是 MSVC kit**——若你原装的是 `mingw_64`,MSVC 下不可链: -1. `.\qt-online-installer-windows-x64-4.11.0.exe --mirror https://ftp.jaist.ac.jp/pub/qtproject` -2. 打开 `D:\Qt\MaintenanceTool.exe` → Add or remove components → 登录 Qt 账号。 -3. 展开 Qt → Qt 6.11.1,勾选 **MSVC 2022 64-bit**,安装。 -4. 完成后存在 `D:\Qt\6.11.1\msvc2022_64\lib\cmake\Qt6`(供 `find_package(Qt6)`)。 +1. 用官方在线安装器(或已装则开 `D:\Qt\MaintenanceTool.exe`)→ Add or remove components → 登录 Qt 账号。 +2. 展开 Qt → **Qt 6.11.1**,勾选 **MSVC 2022 64-bit**,安装。 +3. 完成后应存在 `D:\Qt\6.11.1\msvc2022_64\lib\cmake\Qt6`。 +4. **设 `QT_ROOT` 环境变量指向该 kit**(与 `VCPKG_ROOT` 同一套路,预设 `CMAKE_PREFIX_PATH=$env{QT_ROOT}` 读它,§6): + ```powershell + setx QT_ROOT "<你的路径>\6.11.1\msvc2022_64" # 永久;新开终端生效 + ``` + **全链路只此一份 Qt。** 版本仍须是 **6.11.1**(换版本可能与源码编的 VTK/ADS 不匹配)。 -CMake 经 `CMAKE_PREFIX_PATH=D:/Qt/6.11.1/msvc2022_64` 找到它(见 §6 预设)。**全链路只此一份 Qt**。 +> 若你的 Qt 装在别处/别的版本:**改 `QT_ROOT` 指向你的 `msvc2022_64` 即可**,无需动预设;但版本仍须是 6.11.1。 +> 未设 `QT_ROOT` 时:`build.bat` 会立即报 `[build] QT_ROOT is not set...` 并退出;直接跑 cmake 则配置期 `FATAL_ERROR: QT_ROOT 未设置或无效`。 --- -## 5. 依赖来源(方案②-修订) +## 5. 源码依赖(手工准备) -| 类别 | 组件 | 来源 | -|---|---|---| -| Qt | qtbase/widgets/network/sql/concurrent/opengl + tools | 官方 MSVC kit(§4) | -| VTK | vtk 9.3[qt,opengl](+gdal/proj 可选) | **源码编 → install 前缀**(§5.2) | -| Qt 依赖小件 | ADS、QtKeychain | **FetchContent 对接官方 Qt**(§6.2) | -| 非 Qt 依赖 | gdal/proj/openssl/eigen3/spdlog/fmt/nlohmann-json/gtest | **vcpkg**(下方 vcpkg.json) | - -### 5.1 `vcpkg.json`(仅非 Qt 依赖) - -```json -{ - "name": "geopro-desktop", - "version": "0.1.0", - "dependencies": ["gdal","proj","eigen3","spdlog","fmt","nlohmann-json","openssl","gtest"] -} -``` -- **凡依赖 Qt 的(vtk[qt]/qtkeychain/qt-advanced-docking-system)绝不放进 vcpkg**——否则 vcpkg 会再编一份 qtbase = 双份 Qt 冲突(已核 `ports/vtk/vcpkg.json`)。 -- `vcpkg x-update-baseline --add-initial-baseline` 锁版本(规约 §5.4)。 -- 先配 `VCPKG_BINARY_SOURCES` 二进制缓存(实测当前为空),省 GDAL/PROJ 重编。 - -### 5.2 VTK 源码编到 install 前缀(用官方 Qt) - -实机用 **VTK 9.6.2**(最新稳定,对 Qt 6.11 兼容最好),源码/构建全放 **D:**(C: 仅剩 ~1GB)。脚本见 `external/build_vtk.bat`(已 .gitignore),要点: +### 5.1 VTK 9.6.x 源码编到 `external/vtk-install`(用官方 Qt,Release) ```bat call "\VC\Auxiliary\Build\vcvars64.bat" @@ -114,18 +95,32 @@ cmake -S D:\dev\vtk-src -B D:\dev\vtk-build -G Ninja ^ -D CMAKE_INSTALL_PREFIX=D:/Git/lanbingtech/geopro/external/vtk-install cmake --build D:\dev\vtk-build --target install ``` -完成后 `external/vtk-install/lib/cmake/vtk-9.6` 供 `find_package(VTK)`(已在 `CMakePresets.json` 设 `VTK_DIR`)。 -> 注:VTK 用 **Release** 编。因此**冒烟程序也用 `msvc-release` 预设构建**,以匹配 Release VTK + Release Qt(避免 `/MD` vs `/MDd` 混链)。需要 Debug 调试 VTK 时再出一份 Debug VTK。 +- 完成后 `external/vtk-install/lib/cmake/vtk-9.6` 供 `find_package(VTK)`(`CMakePresets.json` 里 `VTK_DIR` 已指向它)。 +- **必须 Release + 与本项目相同的工具集(v143)编**;因此**本项目也用 `msvc-release` 构建**,避免 `/MD` vs `/MDd` 或 Debug/Release 混链(→ §10 崩溃)。需要调试 VTK 时另出一份 Debug VTK,且此时 app 也须整套 Debug。 +- 根 `CMakeLists.txt` 里 `find_package(VTK REQUIRED COMPONENTS ...)` **必须列组件**(否则 `VTK_LIBRARIES` 为空、链不到);当前组件:`GUISupportQt / RenderingOpenGL2 / RenderingFreeType / InteractionStyle / FiltersSources`(随渲染层增补)。 + +### 5.2 ADS / QtKeychain(FetchContent,自动) + +无需手动——根 `CMakeLists.txt` 用 `FetchContent` 拉 **ADS 4.3.1** 与 **QtKeychain v0.14.0**,对接同一份官方 Qt(`BUILD_WITH_QT6=ON`)。首次配置会 clone,需能访问 GitHub。 + +### 5.3 Qwt 6.2(手工克隆到 `external/qwt-src`) + +二维科学图表(数据集详情散点/等值线)依赖 Qwt。`external/qwt-src` 已 gitignore,须自备: + +```powershell +# 克隆/解压 Qwt 6.2 源码到 external/qwt-src,使 external/qwt-src/src 存在 +git clone --branch qwt-6.2 https://git.code.sf.net/p/qwt/git external/qwt-src +``` +- CMake 检测到 `external/qwt-src/src` 才 include `cmake/qwt.cmake` 编 Qwt;**缺失则相关图表功能不编入**(不报错,但详情图表缺失)。 --- -## 6. CMake 接线 +## 6. CMake 接线(现状,勿照旧文档) -### 6.1 `CMakePresets.json`(项目根) +### 6.1 `CMakePresets.json`(真实内容) ```json { - "version": 3, "configurePresets": [ { "name": "msvc-debug", @@ -134,112 +129,94 @@ cmake --build D:\dev\vtk-build --target install "cacheVariables": { "CMAKE_BUILD_TYPE": "Debug", "CMAKE_TOOLCHAIN_FILE": "$env{VCPKG_ROOT}/scripts/buildsystems/vcpkg.cmake", - "CMAKE_EXPORT_COMPILE_COMMANDS": "ON" + "VCPKG_TARGET_TRIPLET": "x64-windows", + "CMAKE_PREFIX_PATH": "$env{QT_ROOT}", + "VTK_DIR": "${sourceDir}/external/vtk-install/lib/cmake/vtk-9.6" } }, - { - "name": "msvc-release", - "inherits": "msvc-debug", + { "name": "msvc-release", "inherits": "msvc-debug", "binaryDir": "${sourceDir}/build/release", - "cacheVariables": { "CMAKE_BUILD_TYPE": "Release" } - } + "cacheVariables": { "CMAKE_BUILD_TYPE": "Release" } } ] } ``` -> 全 vcpkg 方案下**不设 `CMAKE_PREFIX_PATH` 指向官方 Qt**——vcpkg 工具链接管 Qt 查找(避免双 Qt)。 +- **预设确实设 `CMAKE_PREFIX_PATH`(官方 Qt,读 `$env{QT_ROOT}`)+ `VTK_DIR`(源码编的 VTK,仓库相对、无需环境变量)**——这是「方案②-修订」的核心,别按旧文档去掉它。`QT_ROOT` 未 setx 时配置期会 FATAL(§4)。 +- vcpkg 只经 `CMAKE_TOOLCHAIN_FILE` 管非 Qt 依赖;triplet `x64-windows`。 -### 6.2 顶层 `CMakeLists.txt`(骨架) +### 6.2 根 `CMakeLists.txt` 要点 -```cmake -cmake_minimum_required(VERSION 3.21) -project(geopro_desktop LANGUAGES CXX) -set(CMAKE_CXX_STANDARD 17) -set(CMAKE_CXX_STANDARD_REQUIRED ON) -set(CMAKE_AUTOMOC ON) -set(CMAKE_AUTORCC ON) -set(CMAKE_AUTOUIC ON) +- `find_package(Qt6 REQUIRED COMPONENTS Core Gui Widgets Network Sql Concurrent)`。 +- `find_package(VTK REQUIRED COMPONENTS GUISupportQt RenderingOpenGL2 RenderingFreeType InteractionStyle FiltersSources)`(**必须列组件**)。 +- MSVC flags:`/utf-8 /MP /W4 /permissive-`;非 Debug 配置产 PDB(`/Zi` + `/DEBUG` + `/OPT:REF,ICF`)。 +- ADS/QtKeychain 经 FetchContent;Qwt 经 `cmake/qwt.cmake`(存在才编)。 +- 视图层用 **`QVTKOpenGLStereoWidget`**(QOpenGLWidget 系,ADS reparent 友好)。 -find_package(Qt6 REQUIRED COMPONENTS Core Gui Widgets Network Sql Concurrent) -find_package(VTK REQUIRED) # 含 GUISupportQt(vtk[qt])→ QVTKOpenGLStereoWidget -find_package(GDAL CONFIG REQUIRED) -find_package(PROJ CONFIG REQUIRED) -find_package(Eigen3 CONFIG REQUIRED) -find_package(spdlog CONFIG REQUIRED) -find_package(nlohmann_json CONFIG REQUIRED) -find_package(OpenSSL REQUIRED) -# find_package(Qt6Keychain CONFIG REQUIRED) # qtkeychain -# ADS:vcpkg 端口验证通过后 find_package;否则用下方 FetchContent +--- -add_subdirectory(src) -enable_testing() -add_subdirectory(tests) +## 7. 首次配置、编译、运行、部署 + +**日常直接用 `build.bat`(推荐)**: ``` - -> VTK 链接用 `vtk_module_autoinit`;视图层用 **`QVTKOpenGLStereoWidget`**(QOpenGLWidget 系,ADS reparent 友好,见设计 §K-9),不用 native 版。 - -> **ADS 备选引入**(若 vcpkg 端口不可用,spike 阶段确定): -> ```cmake -> include(FetchContent) -> FetchContent_Declare(ads GIT_REPOSITORY https://github.com/githubuser0xFFFF/Qt-Advanced-Docking-System.git GIT_TAG ) -> FetchContent_MakeAvailable(ads) -> ``` - ---- - -## 7. 首次配置、编译、运行 - -```powershell -# 在 x64 Native Tools 命令行、项目根目录 -git init # 首次:初始化仓库 -vcpkg x-update-baseline --add-initial-baseline - -cmake --preset msvc-debug # 首次会编译 VTK 等,耗时较长 -cmake --build build/debug - -# 运行(VTK/Qt 的 dll 需在 PATH 或同目录) -.\build\debug\src\app\geopro_desktop.exe +build.bat app # 配置(首次)+ 增量编 geopro_desktop(Release) +build.bat test # 编 + ctest 跑单测 +build.bat run # 编 + 启动 +build.bat rebuild # 强制 --clean-first 全量重编(增量疑似漏编时用) +build.bat configure # CMakeLists 改动后强制重跑 configure ``` +- `build.bat` 固定 `--preset msvc-release`、`build/release`,自动激活 MSVC 环境。exe:`build/release/src/app/geopro_desktop.exe`。 +- 首次会拉 vcpkg 依赖 + FetchContent(ADS/QtKeychain),较久;VTK/Qt/Qwt 须已按 §4/§5 就位。 -**运行期 DLL 部署(单一 vcpkg 链路)**: -- 全部 dll(含 Qt)来自 vcpkg:`build/vcpkg_installed/x64-windows/(debug/)bin`。 -- 用 CMake `TARGET_RUNTIME_DLLS` + `add_custom_command(POST_BUILD)` 自动拷贝到 exe 目录(实现阶段加)。 -- **不混用官方 Qt 的 `windeployqt`**——本方案 Qt 来自 vcpkg,混用会拷错版本造成双 Qt 冲突。 -- Qt plugins(platforms/imageformats/sqldrivers 等)由 vcpkg 部署脚本处理;如缺再用 vcpkg 安装树里的 `windeployqt` 对齐同一份 Qt。 +**运行期 DLL 部署(官方 Qt,非 vcpkg)**: +- Qt6*.dll + plugins:用**官方 Qt 的 `windeployqt`**(`D:\Qt\6.11.1\msvc2022_64\bin\windeployqt.exe`)对齐**同一份官方 Qt** 到 exe 目录。 +- VTK*.dll:从 `external/vtk-install/bin` 拷到 exe 目录(或加进 PATH)。 +- **只保证 exe 目录一份 Qt6*.dll(无双 Qt)**;勿混入别处/vcpkg 的 Qt。 --- -## 8. AI 编码上下文基础设施(规约 §10.1,强烈建议先建) +## 8. AI/IDE 上下文(clangd) -项目根放: - -- `.clang-format`:统一风格(基于 LLVM/Google + 团队微调)。 -- `.clangd`: - ```yaml - CompileFlags: - CompilationDatabase: build/debug - ``` - 使 clangd 读取 `compile_commands.json`,给 AI/IDE 精确类型上下文。 -- VS Code 装 **clangd** 扩展(禁用微软 C++ IntelliSense 避免冲突),或 CLion 直接用 CMake。 +- `.clangd`:`CompileFlags.CompilationDatabase: build/release`(对齐 `build.bat` 的主构建目录;若你主要用 Debug 则指 `build/debug`)。`CMAKE_EXPORT_COMPILE_COMMANDS=ON` 会在该目录产 `compile_commands.json`。 +- VS Code 用 **clangd** 扩展(禁用微软 C++ IntelliSense 避免冲突)。 --- -## 9. 验证清单 +## 9. 构建环境兼容性检查清单(交付/接手前逐项核对) -- [ ] `cl` / `cmake` / `ninja` / `git` 命令可用 -- [ ] `VCPKG_ROOT` 已设 -- [ ] `vcpkg.json` 含 qtbase + vtk[qt](共用一份 Qt),preset **未**指向官方 Qt -- [ ] `cmake --preset msvc-debug` 成功(Qt+VTK 已拉取编译,首次较久) -- [ ] `cmake --build` 出 exe -- [ ] exe 能起一个空 Qt 窗 + 一个 `QVTKOpenGLStereoWidget` 渲染窗(冒烟测试) -- [ ] 部署后 exe 目录只有一份 Qt6*.dll(无双 Qt) -- [ ] `compile_commands.json` 生成,clangd 正常索引 +> 目的:把「配置错→运行时随机崩溃」变成「一眼可查」。**任何一项不满足都可能导致 §10 的 `std::map`/`std::string` ABI 崩溃。** + +### 9.1 前置就位 +- [ ] `cl` / `cmake`(≥3.21) / `ninja` / `git` 可用;`VCPKG_ROOT` 已设。 +- [ ] `QT_ROOT` 已 setx 指向 **6.11.1 `msvc2022_64`** kit(MSVC,非 MinGW);`$QT_ROOT/lib/cmake/Qt6` 在(预设 `CMAKE_PREFIX_PATH=$env{QT_ROOT}` 读它;未设则配置期 FATAL)。 +- [ ] `external/vtk-install/lib/cmake/vtk-9.6` 存在(VTK 已源码编 install)。 +- [ ] `external/qwt-src/src` 存在(如需详情页图表)。 +- [ ] `vcpkg.json` 的 `builtin-baseline` 未被改动。 + +### 9.2 ABI 一致性(最关键,防崩溃) +- [ ] **单一配置**:Qt(预编译) / VTK(你编) / app / ADS / QtKeychain / Qwt **全 Release**(或全 Debug),**绝不混**。日常一律 `build.bat`(msvc-release)。 +- [ ] **同一工具集**:VTK/app 用的 MSVC 工具集与预编译 Qt 的 **v143(msvc2022)** ABI 兼容;若用 VS2026-preview 工具集,**用它重编 VTK**,别让 Qt(v143) 与 VTK/app(更新集) 混。 +- [ ] **同一运行时**:全部 `/MD`(Release 动态 CRT),`_ITERATOR_DEBUG_LEVEL=0`。**绝不把 Release 的 Qt/VTK 链进 Debug 的 app**(Debug 是 `/MDd` + IDL=2,STL 布局不同)。 +- [ ] **VTK 来源正确**:是**你按 §5.1 源码 Release 编、对齐本 Qt** 的那份;**不是** vcpkg 的 VTK、不是别处/别配置的 VTK。 +- [ ] **单一 Qt**:exe 目录/PATH 只有一份 Qt6*.dll,无双 Qt。 + +### 9.3 干净构建验证 +- [ ] 删 `build/` 后 `build.bat app` 从零配置**无错**(尤其无「找不到 Qt6/VTK」「add_subdirectory 目录不存在」)。 +- [ ] `build.bat test` → ctest 全绿。 +- [ ] 启动 app、点对象树、渲染视图**不崩**(§10 的崩溃即出现在这里)。 --- -## 10. 与设计文档对应的 spike 门槛 +## 10. 已知崩溃签名 → 根因速查 -本指引服务于设计 §15 的第一周 spike:① 全 vcpkg 构建/部署打通(本文);② ADS + `QVTKOpenGLStereoWidget` 浮动/重停靠不黑屏;③ 真实样本跑通 banded contour。三者通过再进入完整实现计划。 +| 现象 | 根因 | 处理 | +|---|---|---| +| **点对象树/渲染时,崩在 `std::_Tree::_Find_lower_bound`(`std::map`/`set` 查找),`_Myhead` 是 `0xFFFF...` 之类垃圾值,读取访问冲突** | **STL ABI 不匹配**:Debug/Release 混链、`/MD` vs `/MDd`、`_ITERATOR_DEBUG_LEVEL` 不一致、或工具集不匹配(最常见:**别处/别配置的 VTK/Qt**,或 **Debug app 链 Release 依赖**) | 按 §9.2 逐项核对;**删 `build/` 全 Release 干净重建**;确认 VTK 是 §5.1 那份 | +| 配置期 `FATAL_ERROR: QT_ROOT 未设置或无效` / `build.bat` 报 `[build] QT_ROOT is not set` | 未 setx `QT_ROOT` 或指向的 kit 不含 `lib/cmake/Qt6` | `setx QT_ROOT "<你的Qt>\6.11.1\msvc2022_64"` 后**重开终端**(§4) | +| 配置期 `FATAL_ERROR: Qt 版本不匹配` / `VTK 版本不匹配` / `VTK 未就位` | 装了非 6.11.x Qt、非 9.6.x VTK,或 `external/vtk-install` 缺失 | 按 §4/§5 装对版本/就位 VTK | +| 链接期报「找不到 Qt6::/VTK::」 | 预设 `CMAKE_PREFIX_PATH`(`$env{QT_ROOT}`)/`VTK_DIR` 指向不存在 | 按 §4/§5 就位或修正 `QT_ROOT` | +| 配置报「add_subdirectory ... 不是已存在目录」 | 引用了未随仓库的目录(如误提交本地临时工具目录) | 从 `CMakeLists.txt` 去掉该引用,或补齐该目录 | +| 详情页图表缺失但不报错 | `external/qwt-src` 未就位(§5.3) | 克隆 Qwt 后重配置 | +| 双 Qt / 起不来找不到 platform 插件 | 混入了 vcpkg/别处的 Qt dll | 只用官方 `windeployqt` 对齐单一 Qt | --- -*遇到具体报错(VTK/Qt/GDAL 链接、ADS 端口、双 Qt)按规约 §10.4 由工程师复核;AI 协助按 build-error-resolver 流程逐条解决。* +*遇到具体报错按规约由工程师复核;AI 协助按 build-error-resolver 流程逐条解决。* diff --git a/docs/superpowers/plans/2026-06-30-vtk-merged-dataset-column.md b/docs/superpowers/plans/2026-06-30-vtk-merged-dataset-column.md new file mode 100644 index 0000000..94b3d29 --- /dev/null +++ b/docs/superpowers/plans/2026-06-30-vtk-merged-dataset-column.md @@ -0,0 +1,1098 @@ +# VTK 合并数据集单栏重构 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 把「三维分析 + 二维分析」两 tab 合并为一个无标题单列数据集栏,2D/3D 同列分段、按数据动态显隐、段操作改响应式图标工具条,2D 数据以「按类型一块平面 + 平面底图」与 3D 体/帘面在同一自由透视场景共存。 + +**Architecture:** 复用方案 A —— 将 `CategoryAnalysisTab` 升级为统一单列 `DatasetColumn`,用扩展的段配置(增 `dimension`)同时驱动 3D/2D 段;新增响应式 `SectionIconBar`;删 `Column2DDataset`/`QTabWidget`;2D 渲染改「按类型平面 z」并由 `TileBasemap` 多实例提供 N 个平面底图;3D 底图控件移渲染区工具栏、导入雷达移顶部设备菜单。 + +**Tech Stack:** C++17、Qt6(Widgets)、VTK 9.6.2、GoogleTest/CTest、CMake(经 `build.bat`)。 + +**Spec:** `docs/superpowers/specs/2026-06-30-vtk-merged-dataset-column-refactor-design.md` + +## Global Constraints + +- 构建:`build.bat app`(编 app)/ `build.bat test`(编+跑测试)。**cmake 不在 PATH**,用 VS 自带;**用户须关闭运行中的 app 才能重链**(LNK1104 是锁,提示用户关闭,不要自行重试绕过)。 +- 提交:按具体文件 `git add`(**绝不 `-A`**);**禁用署名**(全局规则,无 Co-Authored-By);中文 conventional commits(`feat`/`fix`/`refactor`/`docs`/`test`),无尾随空格。 +- `.bat`/脚本必须纯 ASCII。 +- 命名:类型 `PascalCase`、成员 `snake_case_` 尾下划线、常量 `kPascalCase`、命名空间 `geopro::app` 等(随现有文件)。 +- CJK 路径坑:`std::ifstream` 不吃中文路径(本计划不涉文件读,注意即可)。 +- 不可变/小文件/函数 <50 行/文件 <800 行原则;surgical changes,仅改与需求直接相关处。 + +--- + +## 文件结构(创建/修改一览) + +**新建(抽象层 —— spec §5)** +- `src/data/repo/CategoryDescriptor.hpp/.cpp` — `CategoryDescriptor` + `SceneKind`/`FilterKind`/`OpKind` 枚举 + `byDdCode`/`byDsTypeCode` 便捷器 + `categoryCatalog()`(取代 `CategoryConfig.hpp`/`categoryConfigs`)。 +- `src/controller/DatasetRenderStrategy.hpp/.cpp` — `IDatasetRenderStrategy` 接口 + 字符串键注册表 + 3 实现(`VolumeRenderStrategy`/`CurtainRenderStrategy`/`Plane2DRenderStrategy`)。`Plane2DRenderStrategy` 内含 `PlaneZRegistry`(平面 z 生命周期)+(Phase F)N 个 `TileBasemap` 平面底图。 +- `src/app/panels/columns/SectionIconBar.hpp/.cpp` — 段头响应式图标工具条(默认≤3,超出/挤压收进「…」)。 +- `tests/data/test_category_descriptor.cpp` — classify 路由 + catalog 完整性纯逻辑测试。 +- `tests/controller/test_plane_z_registry.cpp` — 平面 z 生命周期纯逻辑测试(`PlaneZRegistry`)。 +- `tests/app/test_section_icon_bar.cpp` — 图标溢出可见数纯逻辑测试。 + +**修改** +- `src/app/DatasetCategory.hpp/.cpp` — `splitByCategory` 改为遍历 `categoryCatalog()` 用 `classify` 路由(取代 ddCode/dsTypeCode 硬匹配)。 +- `src/app/panels/columns/CategoryAnalysisTab.hpp/.cpp` → 统一单列 `DatasetColumn`:动态显隐 + 空占位 + 遍历 catalog 建段。 +- `src/app/panels/columns/CategorySection.hpp/.cpp` — 构造改吃 `CategoryDescriptor`;段头按 `operations(OpKind)` 建 `SectionIconBar`(含 `OpKind→UI` 映射一处);按 `filters(FilterKind)` 建筛选器(`FilterKind→UI` 映射一处);筛选折叠;移除导入雷达。 +- `src/app/panels/columns/ColumnDrawer.hpp/.cpp` — 去 tab + 去 `Column2DDataset`,单列承载。 +- `src/app/TopBar.hpp/.cpp`(及 `buildDeviceMenu` 所在 TU)— 设备菜单加导入雷达。 +- `src/app/VtkViewToolbar.hpp/.cpp` — Gear 下加「地图」按钮 + popup;去 `setAnalysisMode2D` 6 向禁用。 +- `src/app/TileBasemap.hpp/.cpp` — `groundZ`/`opacity` 参数化、Street 纯平、多实例可用。 +- `src/controller/VtkSceneController.hpp/.cpp` — 主流程改「查描述符 → `renderStrategyId` → 策略 `add/remove` + 首勾/全消 `onTypeActivated/Deactivated`」;移除 `view2DMode`/`setAnalysisMode2D`/`set2DPlacement` 维度耦合。 +- `src/app/VtkSceneView.hpp/.cpp` — 移除 `setAnalysisMode2D` 显隐/相机耦合、逐 ds 拖 Z(`nudgeSelectedMapLinesZ`/`mapLineZOffset_`)。 +- `src/app/main.cpp` — 接线改造(单列喂数据、导入雷达接 TopBar、3D 底图接工具栏、删 col2D/analysisMode 链;勾选并集直接下发控制器,按描述符路由策略,无 dimOf 分判)。 +- `CMakeLists.txt`、`tests/CMakeLists.txt` — 源/测试登记。 + +**删除(评估后)** +- `src/app/panels/columns/Column2DDataset.hpp/.cpp` — 合并后退役(Phase B 末确认无引用再删)。 +- `src/data/repo/CategoryConfig.hpp`、`src/app/DatasetDimension.hpp/.cpp` — 由 `CategoryDescriptor`/catalog + 策略取代后退役(确认无引用再删)。 + +--- + +## 阶段总览 + +- **Phase A**:类型抽象基础 —— 描述符目录 `categoryCatalog`(A1)+ 渲染策略接口/注册表/3 骨架策略(A2)。纯逻辑 TDD。 +- **Phase B**:单列面板骨架(动态显隐 + 空占位 + 去 tab),勾选经描述符路由策略。构建后 2D/3D 同列可见。 +- **Phase C**:响应式图标工具条(由 `operations`/`filters` 驱动)+ 筛选折叠 + 新增三维体图标化。 +- **Phase D**:导入雷达移顶部设备菜单 + 3D 底图移渲染工具栏(含 `TileBasemap` 透明度参数化)。 +- **Phase E**:单一自由场景 + `Plane2DRenderStrategy` 装入平面 z 生命周期(纯逻辑 TDD + 接线)。 +- **Phase F**:2D 平面底图(`TileBasemap` 多实例并入 `Plane2DRenderStrategy` + 段底图 popup)。 + +每个 Phase 结束都应 `build.bat test` 绿 + `build.bat app` 可运行。 + +--- + +## Phase A:类型抽象基础(描述符目录 + 渲染策略注册表) + +### Task A1: CategoryDescriptor + categoryCatalog + classify(取代 CategorySpec/categoryConfigs) + +**Files:** +- Create: `src/data/repo/CategoryDescriptor.hpp`、`src/data/repo/CategoryDescriptor.cpp` +- Modify: `src/app/DatasetCategory.cpp:5-20`(`splitByCategory` 改遍历 catalog 用 classify) +- Modify: `CMakeLists.txt`(登记 `CategoryDescriptor.cpp`)、`tests/CMakeLists.txt` +- Create: `tests/data/test_category_descriptor.cpp` + +**Interfaces:** +- Produces(spec §5.1/§5.2):`namespace geopro::data` 内 `enum class SceneKind { Volume3D, Curtain3D, Plane2D };`、`enum class FilterKind { DateRange, ArrayType };`、`enum class OpKind { GenerateVolume, Filter, PlaneZ, Basemap };`、`struct CategoryDescriptor{ id,title,sceneKind,classify,filters,operations,renderStrategyId }`、`byDdCode(...)`/`byDsTypeCode(...)`、`const std::vector& categoryCatalog();`。 +- `splitByCategory(rows)` 返回 `CategoryBuckets`(与 catalog 同序同长),每行归入首个 `classify(row)==true` 的段。 + +- [ ] **Step 1: 写失败测试** — `tests/data/test_category_descriptor.cpp`: + +```cpp +#include +#include "repo/CategoryDescriptor.hpp" +#include "DatasetCategory.hpp" + +using namespace geopro::data; + +TEST(CategoryCatalog, HasFiveSegmentsInOrder) { + const auto& cat = categoryCatalog(); + ASSERT_EQ(cat.size(), 5u); + EXPECT_EQ(cat[0].id, "resistivity"); + EXPECT_EQ(cat[3].id, "voxel"); + EXPECT_EQ(cat[4].id, "trajectory"); + EXPECT_EQ(cat[4].sceneKind, SceneKind::Plane2D); + EXPECT_EQ(cat[4].renderStrategyId, "plane2d"); +} + +TEST(CategoryCatalog, TrajectoryClassifiesByDdCode) { + const auto& cat = categoryCatalog(); + DsRow traj; traj.id = "t1"; traj.ddCode = "dd_trajectory_data"; + EXPECT_TRUE(cat[4].classify(traj)); + DsRow vox; vox.ddCode = "dd_voxel"; + EXPECT_FALSE(cat[4].classify(vox)); +} + +TEST(SplitByCategory, RoutesRowToFirstMatchingDescriptor) { + DsRow traj; traj.id = "t1"; traj.ddCode = "dd_trajectory_data"; + DsRow ert; ert.id = "e1"; ert.dsTypeCode = "ERT platform inversion data"; + auto b = geopro::app::splitByCategory({traj, ert}); + const auto& cat = categoryCatalog(); + ASSERT_EQ(b.segments.size(), cat.size()); + EXPECT_EQ(b.segments[0].size(), 1u); // resistivity ← ert + EXPECT_EQ(b.segments[0][0].id, "e1"); + EXPECT_EQ(b.segments[4].size(), 1u); // trajectory ← traj + EXPECT_EQ(b.segments[4][0].id, "t1"); +} +``` + +- [ ] **Step 2: 跑测试确认失败** — `build.bat test`;预期编译失败(`CategoryDescriptor.hpp` 不存在)。 + +- [ ] **Step 3: 写 `CategoryDescriptor.hpp`**: + +```cpp +#pragma once +#include +#include +#include +#include +#include "repo/RepoTypes.hpp" // DsRow + +namespace geopro::data { + +enum class SceneKind { Volume3D, Curtain3D, Plane2D }; // 渲染语义/共存规则 +enum class FilterKind { DateRange, ArrayType }; // 筛选器契约(可扩展) +enum class OpKind { GenerateVolume, Filter, PlaneZ, Basemap }; // 段操作契约(可扩展) + +struct CategoryDescriptor { + std::string id; + std::string title; + SceneKind sceneKind; + std::function classify; // 轴1 数据来源/分类 + std::vector filters; // 轴2 筛选器 + std::vector operations; // 轴3 段头图标操作 + std::string renderStrategyId; // 轴4 渲染策略键 +}; + +// classify 便捷构造器(常见按 ddCode / dsTypeCode 接入) +std::function byDdCode(std::initializer_list codes); +std::function byDsTypeCode(std::initializer_list codes); + +const std::vector& categoryCatalog(); + +} // namespace geopro::data +``` + +- [ ] **Step 4: 写 `CategoryDescriptor.cpp`**: + +```cpp +#include "repo/CategoryDescriptor.hpp" +#include + +namespace geopro::data { + +std::function byDdCode(std::initializer_list codes) { + std::vector cs(codes); + return [cs](const DsRow& r) { + for (const auto& c : cs) if (r.ddCode == c) return true; + return false; + }; +} +std::function byDsTypeCode(std::initializer_list codes) { + std::vector cs(codes); + return [cs](const DsRow& r) { + for (const auto& c : cs) if (r.dsTypeCode == c) return true; + return false; + }; +} + +const std::vector& categoryCatalog() { + static const std::vector kCat = { + {"resistivity", "电阻率数据", SceneKind::Curtain3D, + byDsTypeCode({"ERT platform inversion data"}), + {FilterKind::DateRange, FilterKind::ArrayType}, + {OpKind::GenerateVolume, OpKind::Filter}, "curtain"}, + {"apparent", "视电阻率数据", SceneKind::Curtain3D, + byDsTypeCode({"visual resistivity data"}), + {FilterKind::DateRange, FilterKind::ArrayType}, + {OpKind::GenerateVolume, OpKind::Filter}, "curtain"}, + {"transient", "瞬变电磁数据", SceneKind::Curtain3D, + byDsTypeCode({"DD TRANSIENT ELECTROMAGNETIC INVERSION"}), + {FilterKind::DateRange}, + {OpKind::GenerateVolume, OpKind::Filter}, "curtain"}, + {"voxel", "三维体", SceneKind::Volume3D, + byDdCode({"dd_voxel"}), + {FilterKind::DateRange}, + {OpKind::Filter}, "volume"}, + {"trajectory", "轨迹数据", SceneKind::Plane2D, + byDdCode({"dd_trajectory_data"}), + {FilterKind::DateRange}, + {OpKind::PlaneZ, OpKind::Filter, OpKind::Basemap}, "plane2d"}, + }; + return kCat; +} + +} // namespace geopro::data +``` + +- [ ] **Step 5: 改 `splitByCategory`(`DatasetCategory.cpp`)遍历 catalog 用 classify**: + +```cpp +#include "DatasetCategory.hpp" +#include "repo/CategoryDescriptor.hpp" + +namespace geopro::app { + +CategoryBuckets splitByCategory(const std::vector& rows) { + const auto& cat = geopro::data::categoryCatalog(); + CategoryBuckets b; + b.segments.resize(cat.size()); + for (const auto& r : rows) + for (std::size_t i = 0; i < cat.size(); ++i) + if (cat[i].classify && cat[i].classify(r)) { b.segments[i].push_back(r); break; } + return b; +} + +} // namespace geopro::app +``` +(**过渡策略——加新不删旧**:本任务只让 `splitByCategory` 改用 `categoryCatalog()`(输出 `CategoryBuckets` 签名/顺序不变,因 catalog 与旧 `categoryConfigs` 同 id 同序)。**旧 `CategoryConfig.hpp`/`CategorySpec`/`categoryConfigs` 暂保留**——`CategoryAnalysisTab` 构造仍按 `categoryConfigs()` 建段、`CategorySection` 仍读 `canGenerateVolume`/`hasArrayTypeFilter`,故全程可编译。Phase C(Task C2)把 `CategorySection`/`CategoryAnalysisTab` 迁到吃 `CategoryDescriptor`(`operations`/`filters` 驱动)后,再删 `CategoryConfig.hpp`。`DatasetCategory.cpp` include 加 `repo/CategoryDescriptor.hpp`。) + +- [ ] **Step 6: 登记 + 跑测试确认通过** — `CMakeLists.txt` 加 `CategoryDescriptor.cpp`;`tests/CMakeLists.txt` 加 `test_category_descriptor.cpp`(参照 `test_dataset_category.cpp`)。`build.bat test`;预期新测试全 PASS。 + +- [ ] **Step 7: 提交** + +```bash +git add src/data/repo/CategoryDescriptor.hpp src/data/repo/CategoryDescriptor.cpp src/app/DatasetCategory.hpp src/app/DatasetCategory.cpp tests/data/test_category_descriptor.cpp tests/CMakeLists.txt CMakeLists.txt +git commit -m "feat(vtk): 类目描述符目录 categoryCatalog(classify谓词+扩展契约)取代 categoryConfigs" +``` + +### Task A2: 渲染策略接口 + 字符串键注册表 + 3 骨架策略 + +**Files:** +- Create: `src/controller/DatasetRenderStrategy.hpp`、`src/controller/DatasetRenderStrategy.cpp` +- Modify: `CMakeLists.txt` +- Test: 注册表解析逻辑随 Phase E 接入后由集成验证;本任务以编译 + 一个最小注册/解析单元测试佐证。 +- Create: `tests/controller/test_render_strategy_registry.cpp` + +**Interfaces:** +- Produces(spec §5.4):`namespace geopro::controller` 内 `class IDatasetRenderStrategy{ virtual add(typeId,dsId); remove(dsId); onTypeActivated(typeId); onTypeDeactivated(typeId); }`;`class RenderStrategyRegistry{ void registerStrategy(std::string id, std::unique_ptr); IDatasetRenderStrategy* get(const std::string& id) const; }`。 +- 3 骨架实现:`VolumeRenderStrategy` / `CurtainRenderStrategy` / `Plane2DRenderStrategy`(本任务仅建类与注册;`add/remove` 内部实现 Phase B(3D 包现有分支)与 Phase E/F(2D 平面+底图)填充)。 + +- [ ] **Step 1: 写失败测试** — `tests/controller/test_render_strategy_registry.cpp`: + +```cpp +#include +#include "controller/DatasetRenderStrategy.hpp" + +using namespace geopro::controller; + +namespace { +struct FakeStrategy : IDatasetRenderStrategy { + int added = 0; + void add(const std::string&, const std::string&) override { ++added; } + void remove(const std::string&) override {} +}; +} + +TEST(RenderStrategyRegistry, ResolvesById) { + RenderStrategyRegistry reg; + reg.registerStrategy("fake", std::make_unique()); + auto* s = reg.get("fake"); + ASSERT_NE(s, nullptr); + s->add("trajectory", "d1"); + EXPECT_EQ(static_cast(s)->added, 1); + EXPECT_EQ(reg.get("missing"), nullptr); +} +``` + +- [ ] **Step 2: 跑测试确认失败** — `build.bat test`;预期编译失败(头文件不存在)。 + +- [ ] **Step 3: 写 `DatasetRenderStrategy.hpp`**: + +```cpp +#pragma once +#include +#include +#include + +namespace geopro::controller { + +class IDatasetRenderStrategy { +public: + virtual ~IDatasetRenderStrategy() = default; + virtual void add(const std::string& typeId, const std::string& dsId) = 0; + virtual void remove(const std::string& dsId) = 0; + virtual void onTypeActivated(const std::string& /*typeId*/) {} + virtual void onTypeDeactivated(const std::string& /*typeId*/) {} +}; + +class RenderStrategyRegistry { +public: + void registerStrategy(std::string id, std::unique_ptr s) { + strategies_[std::move(id)] = std::move(s); + } + IDatasetRenderStrategy* get(const std::string& id) const { + auto it = strategies_.find(id); + return it == strategies_.end() ? nullptr : it->second.get(); + } +private: + std::map> strategies_; +}; + +} // namespace geopro::controller +``` + +- [ ] **Step 4: 写 3 骨架策略声明(`DatasetRenderStrategy.hpp` 续 / 或同目录头)** — 各持 `VtkSceneController&`(或所需 View/Repo 引用),`add/remove` 暂转调控制器现有方法(Phase B/E/F 内迁实现): + +```cpp +// 同文件内(或 controller 内部)声明,构造接 VtkSceneController& 以转调现有渲染: +class VolumeRenderStrategy : public IDatasetRenderStrategy { /* add→现 addDatasetAsync(volume 分支) */ }; +class CurtainRenderStrategy : public IDatasetRenderStrategy { /* add→现 addDatasetAsync(curtain 分支) */ }; +class Plane2DRenderStrategy : public IDatasetRenderStrategy { /* Phase E/F:平面 z + 底图 */ }; +``` +(具体成员/实现在 Phase E(Plane2D 装 PlaneZRegistry)与 Phase B(3D 两策略包现有分支)填;本步只需类存在并可注册,`.cpp` 给空/最小实现使链接通过。) + +- [ ] **Step 5: 登记 + 跑测试确认通过** — `CMakeLists.txt` 加 `DatasetRenderStrategy.cpp`;`tests/CMakeLists.txt` 加 `test_render_strategy_registry.cpp`。`build.bat test`;预期 PASS。 + +- [ ] **Step 6: 提交** + +```bash +git add src/controller/DatasetRenderStrategy.hpp src/controller/DatasetRenderStrategy.cpp tests/controller/test_render_strategy_registry.cpp tests/CMakeLists.txt CMakeLists.txt +git commit -m "feat(vtk): 渲染策略接口+字符串键注册表+3骨架策略(volume/curtain/plane2d)" +``` + +--- + +## Phase B:单列面板骨架(动态显隐 + 空占位 + 去 tab) + +### Task B1: DatasetColumn 动态显隐空段 + 空面板占位 + +**Files:** +- Modify: `src/app/panels/columns/CategoryAnalysisTab.hpp` +- Modify: `src/app/panels/columns/CategoryAnalysisTab.cpp:88-97`(`setBuckets`)、构造函数(加占位) + +**Interfaces:** +- Consumes: `categoryCatalog()`(含 trajectory 段,Task A1)。 +- Produces: 段在其 bucket 为空时 `hide()`、非空 `show()`;全空时显示占位 `QLabel`。`setBuckets` 现在也分发 trajectory 段(2D)。 + +> 说明:保持类名 `CategoryAnalysisTab` 不变(避免大范围改名风险),仅扩展职责为「统一单列」。Phase B 末在文档注释标注其新职责。 + +- [ ] **Step 1: 构造函数加占位 label** — 在 `CategoryAnalysisTab.cpp` 构造函数 `scroll->setWidget(content);` 之前,于 `content` 布局内(`col` 之外、`outer` 层)添加居中占位。改为在 `outer` 上叠一个占位 label,并存指针: + +`CategoryAnalysisTab.hpp` private 区加: +```cpp +QWidget* placeholder_ = nullptr; // 全空时显示的占位提示 +void updatePlaceholderAndVisibility(); // 据各段数据有无 显隐段 + 占位 +``` + +`CategoryAnalysisTab.cpp` 构造函数末尾(`scroll->setWidget(content);` 之后): +```cpp + placeholder_ = new QLabel(QStringLiteral("请在左侧对象树勾选测线 / 数据集"), this); + placeholder_->setAlignment(Qt::AlignCenter); + placeholder_->setWordWrap(true); + applyTokenizedStyleSheet(placeholder_, + QStringLiteral("QLabel{color:{{text/tertiary}};padding:24px;}")); + outer->addWidget(placeholder_, 0); // 与 scroll 同级;由 updatePlaceholderAndVisibility 切显隐 + placeholder_->hide(); +``` +(顶部 include 补 `#include `。) + +- [ ] **Step 2: 实现 `updatePlaceholderAndVisibility`** — 段有无数据按其 list 行数判定(容器节点不算)。在 `CategoryAnalysisTab.cpp` 加: + +```cpp +void CategoryAnalysisTab::updatePlaceholderAndVisibility() { + bool anyVisible = false; + for (auto* sec : ordered_) { + const bool has = sec->hasRenderableRows(); // Task B1-Step3 在 CategorySection 新增 + sec->setVisible(has); + if (has) anyVisible = true; + } + if (scroll_) scroll_->setVisible(anyVisible); + if (placeholder_) placeholder_->setVisible(!anyVisible); + relayoutSections(); +} +``` + +- [ ] **Step 3: CategorySection 加 `hasRenderableRows()`** — `CategorySection.hpp` public 加 `bool hasRenderableRows() const;`;`.cpp` 实现(数据行 = 非 container 的可勾选行存在): + +```cpp +bool CategorySection::hasRenderableRows() const { + if (!list_) return false; + for (QTreeWidgetItemIterator it(list_); *it; ++it) + if ((*it)->data(0, kDsDdCodeRole).toString() != QStringLiteral("container")) + return true; + return false; +} +``` + +- [ ] **Step 4: `setBuckets` 分发 trajectory + 触发显隐** — 改 `CategoryAnalysisTab.cpp:88-97`:去掉对 voxel 的 `continue` 之外保持,循环末尾调用显隐刷新。注意 trajectory 段(D2)数据来自 splitByCategory 第 5 桶,自然分发;voxel 仍跳过(mock 注入): + +```cpp +void CategoryAnalysisTab::setBuckets(const CategoryBuckets& b) { + const auto& cat = geopro::data::categoryCatalog(); + for (std::size_t i = 0; i < cat.size() && i < b.segments.size(); ++i) { + if (cat[i].id == "voxel") continue; // voxel 由外部单独 setDatasets 注入,勿覆盖 + if (auto* sec = section(cat[i].id)) sec->setDatasets(b.segments[i]); + } + updatePlaceholderAndVisibility(); +} +``` + +并在 `section("voxel")->setDatasets(...)` 的外部调用点之后也需刷新 —— 故在 `CategorySection::setDatasets` 触发后由信号驱动:Step 5。 + +- [ ] **Step 5: 数据变化驱动显隐** — 段内容重建后段的「有无行」可能变(如 voxel 注入、筛选)。在 `CategoryAnalysisTab` 构造的每段 connect 区,追加: + +```cpp + connect(sec, &CategorySection::checkedDatasetsChanged, this, + [this](const QStringList&) { updatePlaceholderAndVisibility(); }); +``` +(与已有 checkedDatasetsChanged lambda 并存;或在那个 lambda 末尾调用 `updatePlaceholderAndVisibility()`。)另外在 `CategorySection::setDatasets` 末尾发一个轻量信号或直接由 tab 在调用 `section("voxel")->setDatasets()` 后手动 `updatePlaceholderAndVisibility()`。最简:在 tab 暴露 `void refreshVisibility(){ updatePlaceholderAndVisibility(); }` 供 main 在注入 voxel 数据后调用。 + +- [ ] **Step 6: 构建 + 手动验收** — `build.bat app`。验收:无数据时面板显示占位语;勾选含轨迹/反演的测线后,对应段出现、占位消失;不含某类型时该段不显示。 + +- [ ] **Step 7: 提交** + +```bash +git add src/app/panels/columns/CategoryAnalysisTab.hpp src/app/panels/columns/CategoryAnalysisTab.cpp src/app/panels/columns/CategorySection.hpp src/app/panels/columns/CategorySection.cpp +git commit -m "feat(vtk): 数据集单栏 段按数据动态显隐 + 全空占位提示" +``` + +### Task B2: ColumnDrawer 去 tab + 单列承载 + 接 2D 数据 + +**Files:** +- Modify: `src/app/panels/columns/ColumnDrawer.hpp/.cpp` +- Modify: `src/app/main.cpp`(drawer 接线:`670`、`1388-1415`、`1710-1713`、`544-566`、`analysisModeChanged` 链) + +**Interfaces:** +- Consumes: `CategoryAnalysisTab*`(统一列)。 +- Produces: `ColumnDrawer` 不再有 `col2D()` / `analysisModeChanged`;只暴露 `analysisTab()`。main 把 2D 数据并入 `splitByCategory` 流。 + +- [ ] **Step 1: ColumnDrawer 去 QTabWidget/Column2DDataset** — `ColumnDrawer.hpp`:删 `Column2DDataset* col2D_`、`col2D()`、`analysisModeChanged`;`body_` 改为直接持 `CategoryAnalysisTab*`(不再是 QTabWidget)。`.cpp` 构造:把 `analysisTab_` 直接作为 body 内容(去掉 tab 添加 col2D 的代码、去掉 tab 切换发 `analysisModeChanged` 的 connect),折叠开关逻辑不变(折叠隐藏 body)。`resizeEvent` 里两 tab 平分逻辑删除(只剩单列)。 + +- [ ] **Step 2: main.cpp 删 col2D 接线** — 删除 `main.cpp:670` 的 `drawer->col2D()->setDatasets(...)`;删除 `1388-1415` 的 `basemapChanged`/`view2DModeChanged`/`customZChanged`/`checkedDatasetsChanged`(col2D) 接线(其去向在 Phase D/E/F 重接);删除 `544-566` 的 `selectedDatasetsChanged`/`setSelectedMapLines` 经 col2D 的链(2D 选中改由统一段 `datasetSelected` 承担,已存在);删除 `analysisModeChanged` → `setAnalysisMode2D` 的接线。 + +- [ ] **Step 3: main.cpp 2D 数据并入单列** — 找到喂 buckets 的位置(`setBuckets(splitByCategory(...))` 调用点),确认 `splitByCategory(*lastSourceRows)` 现已含 trajectory 段(Task A1),故 2D 轨迹随 `setBuckets` 自动进 trajectory 段,无需单独 col2D 注入。删 `drawer->col2D()->setDatasets(splitByDimension(...).dim2D)` 用法。渲染路由不再用 `dimOf`/`splitByDimension`(改走描述符→策略,见 Step 4);`DatasetDimension.*` 待 Phase F Step4 退役。 + +- [ ] **Step 4: 勾选 → 描述符路由策略(统一入口,无维度散判)** — 统一段的 `checkedDatasetsChanged` 上抛 2D+3D 勾选并集。main 用 `categoryCatalog().classify` 把每个 dsId 解析到其**描述符 id(=typeId)**,下发控制器统一入口 `setCheckedDatasets(dsId→typeId 列表)`;控制器 diff 后按 `catalog[typeId].renderStrategyId` 查注册表派给策略 `add/remove`,并维护「每 typeId 活跃集」在首勾/全消时调 `onTypeActivated/Deactivated`。 + +```cpp +// main.cpp:勾选并集 → (dsId, typeId) 列表 → 控制器统一入口 +QObject::connect(analysisTab, &geopro::app::CategoryAnalysisTab::checkedDatasetsChanged, &window, + [sceneCtrl, lastSourceRows](const QStringList& ids) { + std::vector> idType; // dsId, typeId(描述符 id) + const auto& cat = geopro::data::categoryCatalog(); + for (const QString& q : ids) { + const std::string id = q.toStdString(); + const geopro::data::DsRow* row = findRow(*lastSourceRows, id); // main lambda: 按 id 查行 + if (!row) continue; + for (const auto& d : cat) + if (d.classify && d.classify(*row)) { idType.push_back({id, d.id}); break; } + } + sceneCtrl->setCheckedDatasets(idType); + }); +``` + +控制器侧(`VtkSceneController`,本步落地统一路由 —— 取代旧 `setChecked`/`setChecked2DDatasets`): + +```cpp +void VtkSceneController::setCheckedDatasets( + const std::vector>& idType) { + std::map next; // dsId→typeId + for (auto& p : idType) next[p.first] = p.second; + const auto& cat = geopro::data::categoryCatalog(); + auto stratOf = [&](const std::string& typeId) -> IDatasetRenderStrategy* { + for (const auto& d : cat) if (d.id == typeId) return registry_.get(d.renderStrategyId); + return nullptr; + }; + // 移除:旧有新无 + for (auto& [id, typeId] : checked_) + if (!next.count(id)) { + if (auto* s = stratOf(typeId)) s->remove(id); + if (--typeActive_[typeId] == 0) if (auto* s = stratOf(typeId)) s->onTypeDeactivated(typeId); + } + // 新增:新有旧无 + for (auto& [id, typeId] : next) + if (!checked_.count(id)) { + if (typeActive_[typeId]++ == 0) if (auto* s = stratOf(typeId)) s->onTypeActivated(typeId); + if (auto* s = stratOf(typeId)) s->add(typeId, id); + } + checked_ = std::move(next); + view_.renderIncremental(); +} +``` +(`registry_`=Task A2 的 `RenderStrategyRegistry`,由控制器构造时注册 3 策略;`checked_`=`std::map`、`typeActive_`=`std::map` 为新成员。`VolumeRenderStrategy`/`CurtainRenderStrategy::add` 内部转调原 `addDatasetAsync` 的体/帘面分支;`Plane2DRenderStrategy::add` 本步暂转调原 `add2DDatasetAsync`(Phase E/F 改平面 z+底图)。旧 `setChecked`/`setChecked2DDatasets`/`set2DPlacement` 公有入口删除或内联进策略。) + +- [ ] **Step 5: 构建 + 手动验收** — `build.bat app`。验收:左侧只剩一个单列(无 tab);勾 3D 反演/体 → 渲染帘面/体(经 Curtain/Volume 策略);勾轨迹 → 渲染足迹折线(经 Plane2D 策略,落点暂沿用旧 placement,Phase E 改平面);无崩溃。 + +- [ ] **Step 6: 提交** + +```bash +git add src/app/panels/columns/ColumnDrawer.hpp src/app/panels/columns/ColumnDrawer.cpp src/app/main.cpp src/controller/VtkSceneController.hpp src/controller/VtkSceneController.cpp src/controller/DatasetRenderStrategy.hpp src/controller/DatasetRenderStrategy.cpp +git commit -m "refactor(vtk): 抽屉去tab单列; 勾选经描述符路由渲染策略(统一入口,无维度散判)" +``` + +### Task B3: 退役 Column2DDataset + +**Files:** +- Delete: `src/app/panels/columns/Column2DDataset.hpp/.cpp` +- Modify: `CMakeLists.txt`(移除其源文件登记)、其余 include 引用 + +- [ ] **Step 1: 确认无引用** — `grep -rn "Column2DDataset" src` 应只剩将删的文件自身。若 main/其他仍引用,先清掉。 +- [ ] **Step 2: 删文件 + CMake 登记** — 删两文件;在 `CMakeLists.txt` 移除 `Column2DDataset.cpp`。 +- [ ] **Step 3: 构建** — `build.bat app` 通过。 +- [ ] **Step 4: 提交** + +```bash +git add -u +git commit -m "refactor(vtk): 移除退役的 Column2DDataset(并入统一单列)" +``` + +--- + +## Phase C:响应式图标工具条 + 筛选折叠 + 新增三维体图标化 + +### Task C1: SectionIconBar 溢出计算(纯逻辑 TDD) + +**Files:** +- Create: `src/app/panels/columns/SectionIconBar.hpp/.cpp` +- Create: `tests/app/test_section_icon_bar.cpp` +- Modify: `tests/CMakeLists.txt`、`CMakeLists.txt` + +**Interfaces:** +- Produces: 自由函数 `int visibleIconCount(int totalIcons, int availablePx, int iconPx, int overflowPx, int maxIcons);` —— 返回在「最多 `maxIcons`、每图标 `iconPx`、溢出按钮 `overflowPx`、可用宽 `availablePx`」下能显示的图标数(其余收进「…」)。规则:先取 `min(totalIcons, maxIcons)` 个的理想数;若 `理想数 * iconPx > availablePx`,逐个减少直到 `n*iconPx + (有溢出时 overflowPx) <= availablePx`,至少 0;当有图标被折叠时需为「…」预留 `overflowPx`。 + +- [ ] **Step 1: 写失败测试** — `tests/app/test_section_icon_bar.cpp`: + +```cpp +#include +#include "panels/columns/SectionIconBar.hpp" + +using geopro::app::visibleIconCount; + +TEST(SectionIconBar, ShowsAllWhenWideEnoughAndUnderMax) { + // 3 图标, 宽 1000, 每个 30, 溢出 30, max 3 → 全显 + EXPECT_EQ(visibleIconCount(3, 1000, 30, 30, 3), 3); +} +TEST(SectionIconBar, CapsAtMaxIcons) { + // 5 图标但 max 3, 宽足够 → 显 3, 其余 2 进溢出(此时需留溢出位) + EXPECT_EQ(visibleIconCount(5, 1000, 30, 30, 3), 3); +} +TEST(SectionIconBar, FoldsRightWhenNarrow) { + // 3 图标, max 3, 但宽只够 2 个 + 溢出: 75px, 30 each, overflow 30 → 2*30+30=90>75 → 1*30+30=60<=75 → 1 + EXPECT_EQ(visibleIconCount(3, 75, 30, 30, 3), 1); +} +TEST(SectionIconBar, NoOverflowReserveWhenAllFit) { + // 2 图标全显且 <=max, 不需溢出位: 宽 60 恰好 2*30 + EXPECT_EQ(visibleIconCount(2, 60, 30, 30, 3), 2); +} +TEST(SectionIconBar, ZeroWhenTooNarrow) { + EXPECT_EQ(visibleIconCount(3, 20, 30, 30, 3), 0); +} +``` + +- [ ] **Step 2: 跑测试确认失败** — `build.bat test`;预期编译失败(头文件/函数不存在)。 + +- [ ] **Step 3: 实现 `SectionIconBar.hpp`(函数 + 组件声明)**: + +```cpp +#pragma once +#include +#include +#include +#include + +class QToolButton; +class QResizeEvent; + +namespace geopro::app { + +// 纯逻辑:给定约束返回可见图标数(其余收进「…」)。见 spec §6。 +int visibleIconCount(int totalIcons, int availablePx, int iconPx, int overflowPx, int maxIcons); + +// 段头响应式图标工具条:≤maxIcons 且宽度够则全显;否则右侧依次收进末尾「…」下拉。 +struct IconAction { + QString glyphKey; // 图标键(映射到 Glyph) + QString tooltip; + std::function onClick; // 直接动作;为空则用 popupBuilder + std::function popupBuilder; // 弹 popup(z值/底图/筛选用) +}; + +class SectionIconBar : public QWidget { + Q_OBJECT +public: + explicit SectionIconBar(QWidget* parent = nullptr); + void setActions(const std::vector& actions); // 重建按钮 + void setMaxIcons(int n) { maxIcons_ = n; relayout(); } +protected: + void resizeEvent(QResizeEvent* e) override; +private: + void relayout(); // 按当前宽度算可见数,多余进「…」菜单 + std::vector actions_; + std::vector btns_; + QToolButton* overflowBtn_ = nullptr; + int maxIcons_ = 3; + int iconPx_ = 30; + int overflowPx_ = 30; +}; + +} // namespace geopro::app +``` + +- [ ] **Step 4: 实现 `SectionIconBar.cpp` 的 `visibleIconCount`(先只够过测试)**: + +```cpp +#include "panels/columns/SectionIconBar.hpp" +#include + +namespace geopro::app { + +int visibleIconCount(int totalIcons, int availablePx, int iconPx, int overflowPx, int maxIcons) { + if (totalIcons <= 0 || iconPx <= 0) return 0; + const int ideal = std::min(totalIcons, std::max(0, maxIcons)); + const bool overflowFromCap = totalIcons > ideal; // 超 max → 必有溢出位 + // 先看理想数能否放下(若已因 cap 溢出,理想数也要含溢出位) + auto fits = [&](int n, bool withOverflow) { + return n * iconPx + (withOverflow ? overflowPx : 0) <= availablePx; + }; + int n = ideal; + bool overflow = overflowFromCap; + while (n > 0 && !fits(n, overflow || (totalIcons > n))) { + --n; + overflow = true; // 一旦减少必有「…」 + } + if (n < 0) n = 0; + return n; +} + +} // namespace geopro::app +``` + +- [ ] **Step 5: 跑测试确认通过** — `build.bat test`;预期 `SectionIconBar.*` PASS(登记测试见 Step 6)。 + +- [ ] **Step 6: 登记到 CMake** — `CMakeLists.txt` 加 `SectionIconBar.cpp` 到 app 源;`tests/CMakeLists.txt` 加 `test_section_icon_bar.cpp`(参照 `test_dataset_category.cpp` 登记法)。 + +- [ ] **Step 7: 实现组件 `relayout`/`setActions`/`resizeEvent`**(UI,构建验证): + +```cpp +SectionIconBar::SectionIconBar(QWidget* parent) : QWidget(parent) { + auto* lay = new QHBoxLayout(this); + lay->setContentsMargins(0, 0, 0, 0); + lay->setSpacing(0); +} +void SectionIconBar::setActions(const std::vector& a) { + actions_ = a; + // 清旧按钮、按 actions_ 建 QToolButton(autoRaise + glyph + tooltip + 连点击/popup), + // 末尾建 overflowBtn_(InstantPopup, 文本「…」);relayout()。 + relayout(); +} +void SectionIconBar::resizeEvent(QResizeEvent* e) { QWidget::resizeEvent(e); relayout(); } +void SectionIconBar::relayout() { + const int vis = visibleIconCount(static_cast(actions_.size()), width(), iconPx_, overflowPx_, maxIcons_); + // 前 vis 个按钮 show,其余 hide 并塞进 overflowBtn_ 的菜单;vis==actions_.size() 时 overflowBtn_ 隐藏。 +} +``` +(具体按钮构造参照 `VtkViewToolbar` 的 `iconBtn` 与 `Glyphs.hpp::makeGlyph`;popup 用 `QMenu`/`QWidgetAction`。) + +- [ ] **Step 8: 提交** + +```bash +git add src/app/panels/columns/SectionIconBar.hpp src/app/panels/columns/SectionIconBar.cpp tests/app/test_section_icon_bar.cpp tests/CMakeLists.txt CMakeLists.txt +git commit -m "feat(vtk): 段头响应式图标工具条 SectionIconBar(溢出折叠+单元测试)" +``` + +### Task C2: CategorySection/CategoryAnalysisTab 迁到描述符;段头图标条由 operations/filters 驱动 + +> 本任务是 A1「加新不删旧」的**消费方迁移点**:`CategorySection`/`CategoryAnalysisTab` 构造从吃 `CategorySpec` 改吃 `CategoryDescriptor`;段头图标由 `descriptor.operations(OpKind)` 驱动、筛选器由 `descriptor.filters(FilterKind)` 驱动。迁完后 `CategoryConfig.hpp`/`CategorySpec` 不再被引用(Phase F Step4 删)。 + +**Files:** +- Modify: `src/app/panels/columns/CategorySection.hpp/.cpp`(构造改吃 `CategoryDescriptor`;段头 + 筛选行) +- Modify: `src/app/panels/columns/CategoryAnalysisTab.cpp`(构造遍历 `categoryCatalog()` 建段、传描述符) + +**Interfaces:** +- Consumes: `geopro::data::CategoryDescriptor`(`operations`/`filters`/`id`/`title`)、`SectionIconBar`。 +- Produces: `CategorySection(const CategoryDescriptor& desc, dict, parent)`;段头右侧 `SectionIconBar` 按 `desc.operations` 装配;筛选器按 `desc.filters` 装配;筛选行默认折叠由 `OpKind::Filter` 图标 toggle。新增信号 `void zSliderRequested(QString typeId); void basemapPopupRequested(QString typeId);`。 + +- [ ] **Step 1: 构造改吃描述符 + `OpKind→UI` 映射建图标条** — `CategorySection` 成员 `spec_` 改为 `CategoryDescriptor desc_`。替换 `CategorySection.cpp:66-111`(旧文字按钮)为:建 `SectionIconBar* iconBar_`,遍历 `desc_.operations` 经**一处映射**生成 `IconAction`: + +```cpp +std::vector acts; +for (geopro::data::OpKind op : desc_.operations) { + switch (op) { + case geopro::data::OpKind::GenerateVolume: + acts.push_back({"plus", "新增三维体", + [this]{ emit generateVolumeRequested(QString(), checkedDsIds()); }, {}}); + break; // dsTypeCode 不再由段配置带,改由 generateVolumeRequested 接收方按 desc_.id 解析 + case geopro::data::OpKind::Filter: + acts.push_back({"filter", "筛选", + [this]{ filterRow_->setVisible(!filterRow_->isVisible()); }, {}}); + break; + case geopro::data::OpKind::PlaneZ: + acts.push_back({"layers", "z值", {}, + [this](QToolButton* b){ /* Task E3 建滑块 popup */ emit zSliderRequested(QString::fromStdString(desc_.id)); }}); + break; + case geopro::data::OpKind::Basemap: + acts.push_back({"map", "底图", {}, + [this](QToolButton* b){ /* Task F2 建底图 popup */ emit basemapPopupRequested(QString::fromStdString(desc_.id)); }}); + break; + } +} +iconBar_->setActions(acts); +hl->addWidget(iconBar_); +``` +(glyph 键 `"plus"/"filter"/"layers"/"map"` 映射到 `Glyphs.hpp` 现有键,缺则补近义键。`generateVolumeRequested` 第一参数原为 dsTypeCode——改由接收方按 `desc_.id` 经 catalog 反查,或保留 `desc_` 内冗余存一个 dsTypeCode 字段;本计划取「接收方按 id 解析」。) + +- [ ] **Step 2: `FilterKind→UI` 建筛选行(默认折叠)** — 替换 `CategorySection.cpp:119-132`:建 `QWidget* filterRow_`(默认 `hide()`),遍历 `desc_.filters`:`DateRange`→建 `DateRangeEdit`;`ArrayType`→建 `arrayCombo_`。`passesFilters`/`rebuildList` 据建出的控件判断(无该控件即不筛该维度)。 + +- [ ] **Step 3: hpp 改造** — `CategorySection.hpp`:构造签名改 `const geopro::data::CategoryDescriptor&`;成员 `desc_`;加 `SectionIconBar* iconBar_=nullptr; QWidget* filterRow_=nullptr;`;信号加 `zSliderRequested(QString)`/`basemapPopupRequested(QString)`;移除 `radarImportRequested`。`CategoryAnalysisTab.cpp` 构造改 `for (const auto& desc : geopro::data::categoryCatalog()) new CategorySection(desc, dict, content);`,`sections_[desc.id]`。 + +- [ ] **Step 4: 构建 + 手动验收** — `build.bat app`。验收:反演段头显示 [新增三维体, 筛选] 图标;轨迹段显示 [z值, 筛选, 底图];点筛选展开/收起筛选行;缩窄左栏宽度 → 右侧图标折进「…」、放宽弹回。 + +- [ ] **Step 5: 提交** + +```bash +git add src/app/panels/columns/CategorySection.hpp src/app/panels/columns/CategorySection.cpp src/app/panels/columns/CategoryAnalysisTab.cpp +git commit -m "feat(vtk): 段构造迁描述符;段头图标条由operations/filters驱动+筛选折叠" +``` + +--- + +## Phase D:导入雷达移顶部菜单 + 3D 底图移渲染工具栏 + +### Task D1: 导入雷达移到 TopBar 设备菜单 + +**Files:** +- Modify: `src/app/TopBar.cpp`(`buildDeviceMenu`)、`src/app/TopBar.hpp`(新信号) +- Modify: `src/app/main.cpp:1056-1059`(接线改源) +- Modify: `CategorySection.hpp/.cpp`、`CategoryAnalysisTab.hpp/.cpp`(移除 radarImportRequested 转发,若 Task C2 未删尽) + +**Interfaces:** +- Produces: `TopBar` 新信号 `void radarImportRequested(bool impulse);`;设备菜单含「导入雷达测线 → 规范化/Impulse」。 + +- [ ] **Step 1: TopBar 设备菜单加项** — 在 `buildDeviceMenu(this)`(定位其定义 TU)内追加子菜单: + +```cpp + QMenu* radar = menu->addMenu(QStringLiteral("导入雷达测线")); + radar->addAction(QStringLiteral("规范化测线目录(.head/.data)…"), this, + [this] { emit radarImportRequested(false); }); + radar->addAction(QStringLiteral("Impulse 测线目录(.iprb)…"), this, + [this] { emit radarImportRequested(true); }); +``` +(`TopBar.hpp` signals 区加 `void radarImportRequested(bool impulse);`。若 `buildDeviceMenu` 是自由函数无法 emit 成员信号,则改为 TopBar 成员方法 `buildDeviceMenu()`,参照已有 `buildViewMenu()` 成员法。) + +- [ ] **Step 2: main 接线改源** — `main.cpp:1056-1059` 把 `analysisTab.radarImportRequested` 改为 `topBar->radarImportRequested`(导入流程目标不变)。 + +- [ ] **Step 3: 删 CategorySection/Tab 的 radarImportRequested** — 移除 `CategorySection.cpp:88-111` 残留(Task C2 应已删按钮)、`CategorySection.hpp:54` 信号、`CategoryAnalysisTab.cpp:50-51` 与 `.hpp:42` 的转发。 + +- [ ] **Step 4: 构建 + 手动验收** — `build.bat app`。验收:顶部「设备」菜单出现「导入雷达测线」二级项,点击走原导入流程;三维体段头无导入雷达按钮。 + +- [ ] **Step 5: 提交** + +```bash +git add src/app/TopBar.hpp src/app/TopBar.cpp src/app/main.cpp src/app/panels/columns/CategorySection.hpp src/app/panels/columns/CategorySection.cpp src/app/panels/columns/CategoryAnalysisTab.hpp src/app/panels/columns/CategoryAnalysisTab.cpp +git commit -m "feat(vtk): 导入雷达入口移到顶部设备菜单(临时测试功能集中化)" +``` + +### Task D2: TileBasemap 透明度参数化 + 隐藏 API + +**Files:** +- Modify: `src/app/TileBasemap.hpp`、`src/app/TileBasemap.cpp:59`(`kTerrainOpacity`)、`buildFlat`/`buildWarped` opacity 行(~455、~569) + +**Interfaces:** +- Produces: `TileBasemap` 新增 `void setOpacity(double o);`(默认 0.5);现有 `show(Kind)`/`hide()` 不变。`buildFlat`/`buildWarped` 用成员 `opacity_` 而非常量。 + +- [ ] **Step 1: 加成员 + setter** — `TileBasemap.hpp` private 加 `double opacity_ = 0.5;`;public 加 `void setOpacity(double o);`。`.cpp` 实现: + +```cpp +void TileBasemap::setOpacity(double o) { + o = std::clamp(o, 0.0, 1.0); + if (o == opacity_) return; + opacity_ = o; + if (kind_ != Hidden) show(kind_); // 重建套用新透明度 +} +``` + +- [ ] **Step 2: 渲染用 opacity_** — `buildFlat`(~455)与 `buildWarped`(~569)的 `SetOpacity(kTerrainOpacity)` 改 `SetOpacity(opacity_)`;删除或保留 `kTerrainOpacity` 常量(改为默认初值来源,建议删常量、成员默认 0.5)。 + +- [ ] **Step 3: 构建** — `build.bat app` 通过(行为暂不变,opacity 默认 0.5 略比旧 0.55 透)。 + +- [ ] **Step 4: 提交** + +```bash +git add src/app/TileBasemap.hpp src/app/TileBasemap.cpp +git commit -m "refactor(vtk): TileBasemap 透明度参数化(默认0.5)备多实例与可调" +``` + +### Task D3: VtkViewToolbar 加「地图」按钮 + popup,接 3D 底图 + +**Files:** +- Modify: `src/app/VtkViewToolbar.hpp/.cpp:55-58`(Gear 之后) +- Modify: `src/app/main.cpp:1388-1394`(basemap 接线改到工具栏) + +**Interfaces:** +- Produces: `VtkViewToolbar` 新信号 `void basemapKindChanged(int kind); void basemapOpacityChanged(double o);`(kind: 0 天地图 / 1 无)。 + +- [ ] **Step 1: Gear 下加地图按钮 + popup** — `VtkViewToolbar.cpp` 在 `connect(iconBtn(Glyph::Gear,...))` 之后、`sep();` 之前,加: + +```cpp + { + auto* mapBtn = iconBtn(Glyph::Map, QStringLiteral("底图")); // 若无 Glyph::Map 用近义图标 + mapBtn->setPopupMode(QToolButton::InstantPopup); + auto* menu = new QMenu(mapBtn); + // 底图类型 + menu->addAction(QStringLiteral("天地图"), this, [this]{ emit basemapKindChanged(0); }); + menu->addAction(QStringLiteral("无"), this, [this]{ emit basemapKindChanged(1); }); + menu->addSeparator(); + // 透明度滑块(QWidgetAction 承载 QSlider 0..100 默认 50) + auto* wa = new QWidgetAction(menu); + auto* sld = new QSlider(Qt::Horizontal, menu); + sld->setRange(0, 100); sld->setValue(50); + connect(sld, &QSlider::valueChanged, this, [this](int v){ emit basemapOpacityChanged(v/100.0); }); + wa->setDefaultWidget(sld); + menu->addAction(wa); + mapBtn->setMenu(menu); + } +``` +(`VtkViewToolbar.hpp` signals 区加两信号;include ` `。`Glyph::Map` 不存在则在 `Glyphs.hpp` 选已有近义键。) + +- [ ] **Step 2: main 接线** — `main.cpp:1388-1394` 原 `col2D basemapChanged` 接线(Task B2 已删)在此重建为工具栏信号: + +```cpp + QObject::connect(toolbar, &geopro::app::VtkViewToolbar::basemapKindChanged, basemap, + [basemap, basemapKind](int kind) { + *basemapKind = (kind == 0) ? geopro::app::TileBasemap::Satellite + : geopro::app::TileBasemap::Hidden; + basemap->show(*basemapKind); + }); + QObject::connect(toolbar, &geopro::app::VtkViewToolbar::basemapOpacityChanged, basemap, + [basemap](double o) { basemap->setOpacity(o); }); +``` +(`toolbar` = 现有 `VtkViewToolbar` 实例指针,按 main 里实际变量名接。) + +- [ ] **Step 3: 去 setAnalysisMode2D 6 向禁用** — `VtkViewToolbar::setAnalysisMode2D` 不再被调用(Phase B 已断 analysisModeChanged),可保留空实现或删除声明+定义+`viewDirButtons_` 禁用逻辑(建议删,单一自由场景 6 向恒可用)。 + +- [ ] **Step 4: 构建 + 手动验收** — `build.bat app`。验收:渲染区工具栏 Gear 下方有「底图」按钮;点开可切天地图/无、拖透明度实时变化;3D 底图为全局唯一。 + +- [ ] **Step 5: 提交** + +```bash +git add src/app/VtkViewToolbar.hpp src/app/VtkViewToolbar.cpp src/app/main.cpp +git commit -m "feat(vtk): 3D 底图控件移渲染区工具栏(天地图/无+透明度滑块)" +``` + +--- + +## Phase E:单一自由场景 + 2D 按类型平面 z + +### Task E1: 移除维度相机/显隐耦合 + 废弃逐 ds 拖 Z + +**Files:** +- Modify: `src/app/VtkSceneView.hpp/.cpp:420-439`(`setAnalysisMode2D`)、`482-539`(`pickMapLineAt`/`nudgeSelectedMapLinesZ`) +- Modify: `src/controller/VtkSceneController.*`(残留的 `onAnalysisModeChanged`/`view2DMode`/`placement2dMode_`;`set2DPlacement` 公有入口已在 B2 随策略切换删除,此处清剩余私有状态) +- Modify: `src/app/main.cpp`(拖 Z 浮层 `498-505`、`544-557`) + +**Interfaces:** +- Produces: 场景恒自由透视;删除 `setAnalysisMode2D`、`nudgeSelectedMapLinesZ`、`mapLineZOffset_`、拖 Z 浮层、残留 `view2DMode`/`placement2dMode_` 状态。 + +- [ ] **Step 1: 删 VtkSceneView 维度显隐/相机** — 移除 `setAnalysisMode2D`(420-439) 的按维度翻可见 + 相机锁定;移除 `pickMapLineAt`/`nudgeSelectedMapLinesZ`/`mapLineZOffset_`(482-539 及成员)。`mapLineDs_` 是否保留视 Task E2 需要(仍需识别足迹 actor 归属 → 保留)。 + +- [ ] **Step 2: 删 controller analysisMode/view2DMode 残留** — 移除 `onAnalysisModeChanged`、`placement2dMode_`/`customZ2d_`/`placementZ()`/`view2DMode` 等残留状态(其渲染职责已在 B2 迁入 `Plane2DRenderStrategy`)。 + +- [ ] **Step 3: 删 main 拖 Z 浮层接线** — 移除 `main.cpp:498-505`(高程读数浮层)、`544-557`(pick/nudge 接线)。 + +- [ ] **Step 4: 构建 + 手动验收** — `build.bat app`。验收:无 tab 切换后场景恒自由透视;2D/3D 同时可见可旋转;无拖 Z 浮层;无崩溃/悬挂引用。 + +- [ ] **Step 5: 提交** + +```bash +git add src/app/VtkSceneView.hpp src/app/VtkSceneView.cpp src/controller/VtkSceneController.hpp src/controller/VtkSceneController.cpp src/app/main.cpp +git commit -m "refactor(vtk): 删维度相机/显隐耦合与逐ds拖Z(单一自由场景)" +``` + +### Task E2: Plane2DRenderStrategy 装入平面 z 生命周期(PlaneZRegistry,纯逻辑 TDD) + +**Files:** +- Create: `src/controller/PlaneZRegistry.hpp`(纯逻辑头,平面 z 生命周期) +- Create: `tests/controller/test_plane_z_registry.cpp` +- Modify: `tests/CMakeLists.txt`、`CMakeLists.txt` +- Modify: `src/controller/DatasetRenderStrategy.hpp/.cpp`(`Plane2DRenderStrategy` 持 `PlaneZRegistry`,`add/remove/onTypeDeactivated` 用之) + +**Interfaces:** +- Produces: `class PlaneZRegistry`(纯逻辑核,被 `Plane2DRenderStrategy` 持有): + - `double onChecked(const std::string& typeId, const std::string& dsId, double dsZ);` —— 某类型某 ds 勾选;首勾记录平面 z=dsZ;返回该类型当前平面 z。 + - `void onUnchecked(const std::string& typeId, const std::string& dsId);` —— 取消;该类型空集时清除(平面消失)。 + - `bool hasPlane(const std::string& typeId) const;` + - `double planeZ(const std::string& typeId) const;` + - `void setPlaneZ(const std::string& typeId, double z);` —— 滑块整体调。 + +- [ ] **Step 1: 写失败测试** — `tests/controller/test_plane_z_registry.cpp`: + +```cpp +#include +#include "controller/PlaneZRegistry.hpp" + +using geopro::controller::PlaneZRegistry; + +TEST(PlaneZRegistry, FirstCheckSetsPlaneZ) { + PlaneZRegistry r; + EXPECT_DOUBLE_EQ(r.onChecked("trajectory", "a", 12.0), 12.0); + EXPECT_TRUE(r.hasPlane("trajectory")); + EXPECT_DOUBLE_EQ(r.planeZ("trajectory"), 12.0); +} +TEST(PlaneZRegistry, SecondCheckKeepsFirstZ) { + PlaneZRegistry r; + r.onChecked("trajectory", "a", 12.0); + EXPECT_DOUBLE_EQ(r.onChecked("trajectory", "b", 99.0), 12.0); // 投影到首个 ds 的平面 +} +TEST(PlaneZRegistry, PlaneDisappearsWhenAllUnchecked) { + PlaneZRegistry r; + r.onChecked("trajectory", "a", 12.0); + r.onChecked("trajectory", "b", 99.0); + r.onUnchecked("trajectory", "a"); + EXPECT_TRUE(r.hasPlane("trajectory")); // 还有 b + r.onUnchecked("trajectory", "b"); + EXPECT_FALSE(r.hasPlane("trajectory")); // 全消 → 平面消失 +} +TEST(PlaneZRegistry, RecheckAfterEmptyResetsZ) { + PlaneZRegistry r; + r.onChecked("trajectory", "a", 12.0); + r.onUnchecked("trajectory", "a"); + EXPECT_DOUBLE_EQ(r.onChecked("trajectory", "c", 7.0), 7.0); // 重新首勾 → 新 z +} +TEST(PlaneZRegistry, SetPlaneZMovesPlane) { + PlaneZRegistry r; + r.onChecked("trajectory", "a", 12.0); + r.setPlaneZ("trajectory", 30.0); + EXPECT_DOUBLE_EQ(r.planeZ("trajectory"), 30.0); +} +``` + +- [ ] **Step 2: 跑测试确认失败** — `build.bat test`;预期编译失败。 + +- [ ] **Step 3: 实现 `PlaneZRegistry.hpp`**: + +```cpp +#pragma once +#include +#include +#include + +namespace geopro::controller { + +// 纯逻辑:按 2D 类型管理「平面 z + 成员集」。首勾定 z(之后固定); 全消则平面消失。见 spec §8.2。 +class PlaneZRegistry { +public: + double onChecked(const std::string& typeId, const std::string& dsId, double dsZ) { + auto& p = planes_[typeId]; + if (p.members.empty()) p.z = dsZ; // 首勾定 z + p.members.insert(dsId); + return p.z; + } + void onUnchecked(const std::string& typeId, const std::string& dsId) { + auto it = planes_.find(typeId); + if (it == planes_.end()) return; + it->second.members.erase(dsId); + if (it->second.members.empty()) planes_.erase(it); // 全消 → 平面消失 + } + bool hasPlane(const std::string& typeId) const { return planes_.count(typeId) > 0; } + double planeZ(const std::string& typeId) const { + auto it = planes_.find(typeId); + return it == planes_.end() ? 0.0 : it->second.z; + } + void setPlaneZ(const std::string& typeId, double z) { + auto it = planes_.find(typeId); + if (it != planes_.end()) it->second.z = z; + } +private: + struct Plane { double z = 0.0; std::set members; }; + std::map planes_; +}; + +} // namespace geopro::controller +``` + +- [ ] **Step 4: 跑测试确认通过** — `build.bat test`;登记测试见 Step 5;预期 `PlaneZRegistry.*` PASS。 + +- [ ] **Step 5: 登记 CMake** — `tests/CMakeLists.txt` 加 `test_plane_z_registry.cpp`(`PlaneZRegistry.hpp` 为纯头,无需加源)。 + +- [ ] **Step 6: `Plane2DRenderStrategy` 装入 `PlaneZRegistry`** — 让 A2 建的 `Plane2DRenderStrategy` 持 `PlaneZRegistry planeReg_;`,并把 Phase B 里它「转调 `add2DDatasetAsync`」的实现改为: + - `add(typeId, dsId)`:`dsZ` = 该 ds 既有 z(无则取场景地表基准 `view_.zRefElev()`)→ `double z = planeReg_.onChecked(typeId, dsId, dsZ)` → `repo_.loadMapLine(dsId, ...)` 落地时 `view_.addMapLine(dsId, line, z)`(z 取 `planeReg_.planeZ(typeId)` 当前值)。 + - `remove(dsId)`:`view_.removeDataset(dsId)`;并 `planeReg_.onUnchecked(typeId, dsId)`(typeId 由 strategy 持有的 dsId→typeId 小表或随 remove 传参得到——`IDatasetRenderStrategy::remove` 只有 dsId,故 strategy 内自存 `dsId→typeId`)。 + - `onTypeDeactivated(typeId)`:该类型全消(`PlaneZRegistry` 此时已 `hasPlane==false`)→ Phase F 在此销毁平面底图。 + - `typeId` 即描述符 id(控制器 `setCheckedDatasets` 传入,§B2)。 + +- [ ] **Step 7: 构建 + 手动验收** — `build.bat app`。验收:勾多条轨迹 → 全落同一平面 z(首条决定);取消首条 → 平面 z 不变;全取消 → 足迹消失。 + +- [ ] **Step 8: 提交** + +```bash +git add src/controller/PlaneZRegistry.hpp tests/controller/test_plane_z_registry.cpp tests/CMakeLists.txt src/controller/DatasetRenderStrategy.hpp src/controller/DatasetRenderStrategy.cpp +git commit -m "feat(vtk): Plane2D 策略装入平面 z 生命周期(首勾定z/全消消失)+单元测试" +``` + +### Task E3: 段「z值」滑块 popup 接平面 z + +**Files:** +- Modify: `src/app/panels/columns/CategorySection.cpp`(z值 action 的 popup) +- Modify: `src/app/panels/columns/CategoryAnalysisTab.*`、`src/app/main.cpp`(信号转发到 controller `setPlaneZ`) + +**Interfaces:** +- Produces: `CategorySection` 信号 `void planeZChanged(const QString& typeId, double z);`;controller `void setPlaneZ(const QString& typeId, double z)`(转交 `Plane2DRenderStrategy::setPlaneZ`)。`Plane2DRenderStrategy::setPlaneZ(typeId,z)` → `planeReg_.setPlaneZ` + 重摆该类型足迹。 + +- [ ] **Step 1: z值 popup** — Task C2 的 z值 action `popupBuilder` 内建 `QSlider`(范围按场景高程量级,如 -500..500 米,默认取当前 `planeZ`),`valueChanged` → `emit planeZChanged(typeIdOfSpec, v)`。`typeId` = `descriptor.id`。 + +- [ ] **Step 2: 转发链** — `CategoryAnalysisTab` 转发 `planeZChanged`;main 接到 → `sceneCtrl->setPlaneZ(typeId, z)`。 + +- [ ] **Step 3: 落到策略** — `VtkSceneController::setPlaneZ` 取 `Plane2DRenderStrategy`(`registry_.get("plane2d")` 下转型,或控制器持其指针)调 `setPlaneZ(typeId,z)`:内部 `planeReg_.setPlaneZ(typeId,z)` 后,对该类型已勾选足迹 `removeDataset`+`addMapLine(...,z)` 重摆(参照旧 `set2DPlacement` 重摆法)。 + +- [ ] **Step 4: 构建 + 手动验收** — `build.bat app`。验收:拖 z值滑块 → 该类型整块平面(含其上全部足迹)整体升降。 + +- [ ] **Step 5: 提交** + +```bash +git add src/app/panels/columns/CategorySection.hpp src/app/panels/columns/CategorySection.cpp src/app/panels/columns/CategoryAnalysisTab.hpp src/app/panels/columns/CategoryAnalysisTab.cpp src/app/main.cpp src/controller/VtkSceneController.hpp src/controller/VtkSceneController.cpp +git commit -m "feat(vtk): 2D 段 z值滑块整体升降类型平面" +``` + +--- + +## Phase F:2D 平面底图(TileBasemap 多实例) + +### Task F1: TileBasemap groundZ 参数化 + 多实例可用 + +**Files:** +- Modify: `src/app/TileBasemap.hpp/.cpp`(`kGroundZ` → 成员 `groundZ_`;构造可传初值) + +**Interfaces:** +- Produces: `TileBasemap` 构造增可选参 `double groundZ = 0.0`;内部所有 `kGroundZ` 用 `groundZ_`;`Street` 走 `buildFlat`(已是纯平矢量)不变。 + +- [ ] **Step 1: groundZ 成员化** — `TileBasemap.hpp` 加 `double groundZ_;` + 构造形参 `double groundZ = 0.0`;`.cpp` 把 `placeActor`/`buildFlat`/`buildWarped`/Z-fighting 偏移里出现的 `kGroundZ` 改 `groundZ_`(`gz = groundZ_ + (z-kMinZoom)*kZEps`)。 + +- [ ] **Step 2: 确认多实例无共享态** — review `TileBasemap` 成员:`nam_`/`placed_`/`desired_`/`demCache_`/`texCache_`/`observer_` 均 per-instance;`tileKey` static 纯函数 → 多实例安全。无需改。 + +- [ ] **Step 3: 构建** — `build.bat app`(3D 底图仍 groundZ=0,行为不变)。 + +- [ ] **Step 4: 提交** + +```bash +git add src/app/TileBasemap.hpp src/app/TileBasemap.cpp +git commit -m "refactor(vtk): TileBasemap groundZ 参数化支持多实例平面底图" +``` + +### Task F2: Plane2DRenderStrategy 持 N 个平面底图 + 段「底图」popup + +**Files:** +- Modify: `src/controller/DatasetRenderStrategy.hpp/.cpp`(`Plane2DRenderStrategy` 加底图实例管理) +- Modify: `src/controller/VtkSceneController.*`、`src/app/main.cpp`、`CategorySection.*`、`CategoryAnalysisTab.*` + +**Interfaces:** +- Consumes: `PlaneZRegistry`(已在 strategy 内)、`TileBasemap`(多实例,F1)、`Scene&`/`vtkRenderWindow*`/`GeoLocalFrame`、`dataRadiusProvider`(strategy 构造时注入)。 +- Produces: `Plane2DRenderStrategy` 内 `std::map> bms_;`(按 typeId);新方法 `setBasemapKind(typeId,kind)` / `setBasemapOpacity(typeId,o)`;`onTypeActivated/Deactivated` 建/销毁底图;`setPlaneZ` 同步重建底图于新 z。controller 转发 `setBasemapKind/Opacity`。 + +- [ ] **Step 1: strategy 持底图实例** — `Plane2DRenderStrategy` 内 `std::map> bms_;`。`onTypeActivated(typeId)`(该类型首勾,平面已建):`bms_[typeId] = std::make_unique(scene_, rw_, frame_, planeReg_.planeZ(typeId))` → `setDataRadiusProvider(provider_)` → `show(Street)`(纯平矢量)+ `setOpacity(0.5)`。`onTypeDeactivated(typeId)`(全消):`bms_.erase(typeId)`(析构移除瓦片,连同折线平面消失)。`setPlaneZ`:销毁旧底图、按新 z 重建(或 `TileBasemap::setGroundZ` 重铺——本步用重建,简单可靠)。 + +- [ ] **Step 2: 段「底图」popup** — Task C2 的底图 action `popupBuilder`:菜单【矢量平面(默认)/无】+ 透明度滑块(默认50);`CategorySection` 发 `basemapKindChanged(typeId,kind)`/`basemapOpacityChanged(typeId,o)` → `CategoryAnalysisTab` 转发 → main → `sceneCtrl->setBasemapKind/Opacity(typeId,...)` → `Plane2DRenderStrategy`。「无」= `bms_[typeId]->hide()`(保留实例,仅隐瓦片)。 + +- [ ] **Step 3: 构建 + 手动验收** — `build.bat app`。验收:勾首条轨迹 → 出现该类型平面矢量底图(贴平面 z、纯平无高程、范围≈数据范围);z值滑块带底图同升降;底图 popup 可切无/调透明度;全取消 → 平面+底图一并消失。 + +- [ ] **Step 4: 退役旧分类件** — 确认 `splitByDimension`/`DatasetDimension.*`、`CategoryConfig.hpp` 无引用后删除(`grep -rn` 核对);`CMakeLists.txt` 去登记。 + +- [ ] **Step 5: 提交** + +```bash +git add src/controller/DatasetRenderStrategy.hpp src/controller/DatasetRenderStrategy.cpp src/controller/VtkSceneController.hpp src/controller/VtkSceneController.cpp src/app/main.cpp src/app/panels/columns/CategorySection.hpp src/app/panels/columns/CategorySection.cpp src/app/panels/columns/CategoryAnalysisTab.hpp src/app/panels/columns/CategoryAnalysisTab.cpp +git commit -m "feat(vtk): Plane2D 策略持每类型平面矢量底图(多实例+生命周期+底图popup)" +``` + +--- + +## Self-Review(计划对照 spec) + +**Spec coverage:** +- §5 类型抽象(描述符目录 + classify 谓词 + 扩展契约)→ Task A1 ✓;渲染策略接口/注册表/3 策略 → Task A2 ✓ +- §4 面板单列/动态显隐/空占位 → Task B1/B2/B3 ✓ +- §5.3 消费方只认抽象(勾选经描述符路由策略,无维度散判)→ B2 ✓ +- §6 图标条由 `operations(OpKind)` 驱动 +「…」溢出(数量+宽度两分支) → Task C1/C2 ✓ +- §7.1 筛选(`Filter`,由 `filters` 驱动) → C2;§7.2 新增三维体(`GenerateVolume`) → C2;§7.3 z值(`PlaneZ`) → E3;§7.4 2D底图(`Basemap`) → F2;§7.5 3D底图移工具栏 → D2/D3;§7.6 导入雷达移设备菜单 → D1 ✓ +- §8 单一自由场景 + 经策略渲染 + 2D按类型平面(`Plane2DRenderStrategy`) + 废弃逐ds拖Z/view2DMode → E1/E2/E3 ✓ +- §9.1 3D共享底图(透明度可调/隐藏) → D2/D3;§9.2 N个2D平面底图(TileBasemap多实例,封装于 Plane2D 策略) → F1/F2 ✓ +- §10 默认勾选保留 + 2D信号迁段 → B2/E2/E3 ✓ +- §12.10 可扩展性(catalog+策略驱动、加描述符即接入) → A1/A2 + 全程消费方改造 ✓ + +**Placeholder scan:** UI 接线步骤给出真实代码骨架 + 参照锚点(`VtkViewToolbar::iconBtn`、`Glyphs.hpp`、旧 `set2DPlacement` 重摆法),非 TODO。纯逻辑任务(A1 描述符/分流、A2 注册表、C1 图标溢出、E2 平面 z)含完整测试与实现代码。 + +**Type consistency:** `CategoryDescriptor`/`categoryCatalog`/`SceneKind`/`FilterKind`/`OpKind`/`byDdCode`/`byDsTypeCode`(A1) / `IDatasetRenderStrategy`/`RenderStrategyRegistry`/`VolumeRenderStrategy`/`CurtainRenderStrategy`/`Plane2DRenderStrategy`/策略键 `"volume"/"curtain"/"plane2d"`(A2) / `setCheckedDatasets(dsId→typeId)`(B2) / `visibleIconCount`(C1) / `PlaneZRegistry`(E2,置 `Plane2DRenderStrategy` 内) / `setPlaneZ`/`setBasemapKind`/`setBasemapOpacity`(E3/F2) / `TileBasemap::setOpacity`(D2)·`groundZ`(F1) 跨任务名称一致。 + +**过渡策略:** A1「加新不删旧」——`CategoryDescriptor`/`categoryCatalog` 与旧 `CategorySpec`/`categoryConfigs` 同 id 同序并存,`splitByCategory` 先切到 catalog;Phase C 迁 `CategorySection`/`CategoryAnalysisTab` 到描述符后、Phase F Step4 删旧 `CategoryConfig.hpp`/`DatasetDimension.*`。全程可编译。 + +**已知待实现期细化(非占位,属合理实现细节):** z值滑块数值范围(E3,按场景高程量级);`Glyph::Map` 若不存在选近义键(D3);`buildDeviceMenu` 自由函数 vs 成员法(D1,按实际定义形态);`Plane2DRenderStrategy` 构造所需 `Scene&/rw/frame/repo/view` 引用的具体注入形态(A2/E2,按 `VtkSceneController` 现有持有)。 diff --git a/docs/superpowers/specs/2026-06-30-vtk-merged-dataset-column-refactor-design.md b/docs/superpowers/specs/2026-06-30-vtk-merged-dataset-column-refactor-design.md new file mode 100644 index 0000000..615b825 --- /dev/null +++ b/docs/superpowers/specs/2026-06-30-vtk-merged-dataset-column-refactor-design.md @@ -0,0 +1,256 @@ +# VTK 视图重构:合并数据集单栏 + 动态段 + 图标工具条 + 2D 平面底图 — Spec(2026-06-30) + +> 分支起点:`main`(PR #9 已合并)。职责范围:VTK 视图左侧数据集栏(面板结构 + 段交互 + 2D/3D 共存渲染 + 底图)。 +> 本 spec 替代 2026-06-26「二维分析:锁定俯视」模型的相机/显隐部分(见 §10 迁移说明)。 + +## 0. 一句话目标 + +把「三维分析 + 二维分析」两个 tab 合并成**一个无标题的单列数据集栏**:2D 与 3D 数据集用**一致的分段组织**同列呈现;段按数据有无**动态显隐**;段操作改**响应式图标工具条**;2D 数据以**按类型一块平面**的方式与 3D 体/帘面在**同一个自由透视场景**里共存。 + +--- + +## 1. 背景与现状(接手必读) + +- **面板**:`ColumnDrawer` = 左侧抽屉,`QTabWidget` 两 tab: + - 三维分析 `CategoryAnalysisTab`:`QScrollArea` 竖堆 `CategorySection`×4(电阻率/视电阻率/瞬变/三维体)。 + - 二维分析 `Column2DDataset`:平铺树 + 底图下拉 + 2D视图模式下拉 + 自定义Z 滑块。 + - 切 tab 发 `analysisModeChanged(bool is2D)`。 +- **段** `CategorySection`:段头(chevron+标题 | 新增三维体[反演类] | 导入雷达[voxel])+ 段体(**固定显示**的筛选行[日期范围+装置类型] + 可勾选数据树)。 +- **分类**:3D 用 `splitByCategory()`(`categoryConfigs()` 4 段);2D 用 `splitByDimension().dim2D`(当前仅 `dd_trajectory_data`)。 +- **渲染**: + - 2D 轨迹 → `MapLineActor`(橙色 `vtkPolyLine`),经 `VtkSceneController::set2DPlacement(mode,z)` 摆到**单一全局 Z**(5 模式:0关/1 Z=0/2顶+50/3底-50/4自定义)。已有逐 ds 的 Z 拖动偏移 `mapLineZOffset_`。 + - `VtkSceneView::setAnalysisMode2D(is2D)`:切 tab 时**按维度翻 actor 可见标志** + 相机锁定近俯视 + `VtkViewToolbar` 禁 6 向视图。 + - 底图 `TileBasemap`(单例):天地图 WMTS(卫星 `img_w`→`buildWarped` 带高程地形 / 矢量 `vec_w`→`buildFlat` 纯平面),透明度**固定 0.55**,瓦片范围 = `dataHorizontalRadius()×10` 钳 `[2000,30000]m`,置 Z=0。`buildFlat` 已能纯平贴矢量瓦片。 +- **顶部工具条** `TopBar`:视图/项目管理/业务工具/**设备** 四菜单。 +- **渲染区竖排工具栏** `VtkViewToolbar`:段1 `Gear`(坐标轴设置) → 分隔线 → 6向视图 → 缩放。 + +--- + +## 2. 已确认的关键决策(与用户逐条确认) + +1. **单一自由场景共存**:合并删 tab 后,**取消**「锁定近俯视相机 + 按维度自动显隐」。场景恒为自由透视,勾选的 2D 平面与 3D 体/帘面**同时可见**。 +2. **底图 = 1 个 3D 底图 + N 个 2D 底图**:所有三维数据**共用一个** 3D 底图(现状 `TileBasemap`);每个 **2D 类型(段)** 一块**独立平面底图**(N = 2D 段数,非每条 ds 一张)。 +3. **默认勾选**:沿用现有「直接挂项目下的 ds 默认进 VTK」逻辑,本次只保证动态显隐段时不破坏它。 +4. **2D z 值按类型一块平面**:每个 2D 类型一块平面 + 一个 z 滑块;平面初始 z = 该类型**第一个被勾选 ds** 的 z;同类型其余 ds 投影到此平面。 +5. **3D 底图控件移到 `VtkViewToolbar`**(Gear 之后),不在 3D 段上。 +6. **「导入雷达」移到 `TopBar` 设备菜单**(临时测试功能,后续整体移除)。 +7. **`view2DMode` 5 模式下拉废弃**,2D 高度完全由「z值」滑块替代。 + +--- + +## 3. 架构方案 + +**采用方案 A:统一单列 `DatasetColumn` + 类型抽象(§5)。** + +- 用**一份类目描述符目录 `categoryCatalog()`**(§5,每类型一份 `CategoryDescriptor`:分类/筛选/操作/渲染策略)同时驱动 3D/2D 段;`CategorySection` 按描述符建筛选器与图标条;`VtkSceneController` 按描述符的 `renderStrategyId` 查可插拔渲染策略(§5.4)渲染。**消费方不再 `if dimension/ddCode` 散判**。 +- 删除 `Column2DDataset` 与 `QTabWidget`;`ColumnDrawer` 承载单个 `DatasetColumn`,保留折叠开关。 +- 取舍:复用已验证的勾选保留/折叠/spinner/结构树逻辑;改动集中在描述符目录、段头工具条(OpKind 映射)、渲染策略注册表。 + +(备选 B「两 widget 去 tab 竖堆」因 2D/3D 段不一致、重复逻辑被否;C「全重写」工作量过大被否。) + +--- + +## 4. 面板结构(§1 重构) + +- `ColumnDrawer`:去 `QTabWidget` + `Column2DDataset`;改持 `DatasetColumn`;**移除** `analysisModeChanged` 信号链。折叠开关保留。 +- `DatasetColumn`(原 `CategoryAnalysisTab` 改名/改造):`QScrollArea` 竖堆 N 个 `CategorySection`,**无栏目标题**。 +- **动态显隐**:段 bucket 为空 → 段 `hide()`;非空 → `show()`。三维体段同理(默认空→默认不显示)。`relayoutSections()`/stretch 逻辑只对可见段生效。 +- **空面板占位**:所有段均空时,滚动区中央显示提示语占位(文案:「请在左侧对象树勾选测线 / 数据集」);任一段非空则隐藏占位。 + +--- + +## 5. 类型抽象(扩展契约 —— 本 spec 的架构基石) + +> 目标:**接入一个新 ds 类型 = 实现一份描述符(必要时再补一个渲染策略 / 一个操作 / 一个筛选器)**,无论它「按什么规则接入、有什么操作、怎么渲染」,UI 层与渲染层都只消费抽象、不再 `if dimension/ddCode` 散判。这是「数据集栏目」的统一规范,所有现存 5 类与未来新类都走它。 + +### 5.1 类目描述符 `CategoryDescriptor`(data 层,纯 C++,无 Qt/VTK) + +```cpp +namespace geopro::data { + +enum class SceneKind { Volume3D, Curtain3D, Plane2D }; // 渲染语义 / 共存规则 +enum class FilterKind { DateRange, ArrayType }; // 筛选器契约(可扩展) +enum class OpKind { GenerateVolume, Filter, PlaneZ, Basemap }; // 段操作契约(可扩展) + +struct CategoryDescriptor { + std::string id; // "resistivity"/"apparent"/"transient"/"voxel"/"trajectory" ... + std::string title; // 段标题 + SceneKind sceneKind; // 渲染语义 + std::function classify; // 轴1 数据来源/分类("无论按什么规则接入") + std::vector filters; // 轴2 本段筛选器(顺序=显示顺序) + std::vector operations; // 轴3 段头图标操作(顺序=显示顺序) + std::string renderStrategyId; // 轴4 渲染策略键(解析到注册表,见 §5.4) +}; + +// classify 便捷构造器(覆盖现有按 ddCode / dsTypeCode 接入的常见情形;任意复杂规则可直接写 lambda) +std::function byDdCode(std::initializer_list codes); +std::function byDsTypeCode(std::initializer_list codes); + +const std::vector& categoryCatalog(); // 有序目录,取代 categoryConfigs() +} // namespace geopro::data +``` + +### 5.2 目录定义(catalog,取代 `categoryConfigs()`) + +| id | title | sceneKind | classify | filters | operations | renderStrategyId | +|---|---|---|---|---|---|---| +| resistivity | 电阻率数据 | Curtain3D | `byDsTypeCode({"ERT platform inversion data"})` | DateRange,ArrayType | GenerateVolume,Filter | `"curtain"` | +| apparent | 视电阻率数据 | Curtain3D | `byDsTypeCode({"visual resistivity data"})` | DateRange,ArrayType | GenerateVolume,Filter | `"curtain"` | +| transient | 瞬变电磁数据 | Curtain3D | `byDsTypeCode({"DD TRANSIENT ELECTROMAGNETIC INVERSION"})` | DateRange | GenerateVolume,Filter | `"curtain"` | +| voxel | 三维体 | Volume3D | `byDdCode({"dd_voxel"})`(mock 注入,见 §10) | DateRange | Filter | `"volume"` | +| trajectory | 轨迹数据 | Plane2D | `byDdCode({"dd_trajectory_data"})` | DateRange | PlaneZ,Filter,Basemap | `"plane2d"` | + +段顺序即表序(电阻率→…→轨迹)。分流:`splitByCategory(rows)` 改为遍历 catalog,对每行命中**首个** `classify(row)==true` 的描述符即归入该段(保留原顺序)。`Column2DDataset` / `splitByDimension` 退役;旧 `categoryConfigs()`/`CategorySpec` 由 `categoryCatalog()`/`CategoryDescriptor` 取代。 + +### 5.3 消费方只认抽象 + +- `CategorySection(descriptor)`:按 `descriptor.filters` 建筛选器(`FilterKind→UI` 映射一处)、按 `descriptor.operations` 建图标条(`OpKind→按钮+信号` 映射一处,见 §6/§7)。 +- `DatasetColumn`:遍历 `categoryCatalog()` 建段、用 `descriptor.classify` 路由数据(即 `splitByCategory`)。 +- `VtkSceneController`:勾选某 ds → 查其所属描述符 → `renderStrategyId` → 渲染策略 `add/remove` + 首勾/全消 `onTypeActivated/Deactivated`(见 §5.4、§8)。 + +### 5.4 可插拔渲染策略 `IDatasetRenderStrategy`(controller/render 层) + +```cpp +namespace geopro::controller { +class IDatasetRenderStrategy { +public: + virtual ~IDatasetRenderStrategy() = default; + virtual void add(const std::string& typeId, const std::string& dsId) = 0; // 异步加载+入场 + virtual void remove(const std::string& dsId) = 0; + // 每类型场景资源生命周期(可选):本类型首个 ds 入场 / 全部离场 + virtual void onTypeActivated(const std::string& typeId) {} + virtual void onTypeDeactivated(const std::string& typeId) {} +}; +// 注册表:renderStrategyId(字符串键) → 策略实例。当前 3 实现: +// "volume" VolumeRenderStrategy —— 体素/雷达体(包现 isVolumeDataset 分支) +// "curtain" CurtainRenderStrategy —— 反演帘面(包现 dd_section 等分支) +// "plane2d" Plane2DRenderStrategy —— 2D 折线落类型平面 + 平面底图(封装 §8.2 平面 z + §9.2 底图) +} // namespace geopro::controller +``` + +**关键**:2D 的全部特殊性(按类型平面 z 生命周期 + N 个平面底图)**封死在 `Plane2DRenderStrategy` 一个类内**(内含 `PlaneZRegistry` + 平面底图管理);3D 两个策略只是包住现有渲染分支。控制器主流程不再含维度/ddCode 分支,只做「查描述符 → 取策略 → 调用」。 + +### 5.5 「接入一个新 ds 类型」标准动作(即本规范) + +| 场景 | 要做的 | +|---|---| +| 新类型、**沿用**已有筛选/操作/渲染 | **只加一条 `CategoryDescriptor`**(纯数据),完。 | +| 新类型、要**新渲染方式** | 加描述符 + 实现一个 `IDatasetRenderStrategy` 并注册(新 `renderStrategyId`)。 | +| 新类型、要**新操作** | `OpKind` 加一项 + `CategorySection` 加该 kind→UI 的**一处**映射;描述符 `operations` 列上。 | +| 新类型、要**新筛选器** | `FilterKind` 加一项 + `CategorySection` 加**一处**映射;描述符 `filters` 列上。 | + +数据驱动优先;只有真出现「新渲染/新操作/新筛选器」才动对应那**一个**扩展点,且改动收敛在单一位置,不扩散。 + +--- + +## 6. 响应式图标工具条(新建 `SectionIconBar`) + +- 段头右侧承载图标工具条:**默认最多显示 3 个图标**;图标总数超 3,**或段宽被挤压放不下时**,**右侧图标依次收进末尾「…」下拉菜单**。`resizeEvent` 动态重算可见数 —— 这是**必须实现并可验证**的行为:即使图标 ≤3,当栏位宽度收窄到放不下时,右侧图标也要实时折进「…」,栏位变宽再弹回。 +- 每个图标 = `QToolButton`(autoRaise + glyph + tooltip),点击触发对应操作(部分弹 popup)。 +- **图标集由描述符 `operations`(`OpKind` 列表)驱动**,不是按维度硬编码。`CategorySection` 内有一处 `OpKind→(图标 glyph + tooltip + 点击/popup)` 映射表;新增操作只加一项映射(§5.5)。当前 catalog 各段对应: + | 段 | `operations`(左→右,右侧优先收进…) | + |---|---| + | 3D 反演(电阻率/视电阻率/瞬变) | `GenerateVolume`、`Filter` | + | 3D 三维体 | `Filter` | + | 2D 轨迹 | `PlaneZ`、`Filter`、`Basemap` | +- 注:当前各段图标 ≤3,「数量超 3」分支暂不触发,但**「宽度挤压」分支必须工作**(窄栏即折叠);两个分支都要实现(后续图标会增加,数量分支随之生效)。 + +--- + +## 7. 段内操作行为(每个 = 一个 `OpKind`) + +> 下列每个操作对应 §5 的一个 `OpKind`;`CategorySection` 的 `OpKind→UI` 映射表据描述符 `operations` 装配。`PlaneZ`/`Basemap` 的渲染落点封装在 `Plane2DRenderStrategy`(§5.4/§8.2/§9.2)。 + +### 7.1 筛选 `OpKind::Filter`(通用,D2+D3) +- 现「固定显示的筛选行(日期范围 + 装置类型[仅 ERT])」改为**默认折叠**(段体内不占位)。 +- 点「筛选」图标 → 展开筛选行;再点 → 收起(toggle)。筛选逻辑(`passesFilters`/`rebuildList`)不变。 + +### 7.2 新增三维体 `OpKind::GenerateVolume`(仅 3D 反演段) +- 保持原功能(发 `generateVolumeRequested`),入口从文字按钮改图标。 + +### 7.3 z值 `OpKind::PlaneZ`(仅 2D 段) +- 点图标弹 popup:一个滑块,整体上下移动**该 2D 类型那块平面**的 z。 +- 初值 = 该类型第一个被勾选 ds 的 z(见 §8.2);范围按场景高程量级合理取(实现期定)。 + +### 7.4 底图 `OpKind::Basemap`(仅 2D 段) +- 点图标弹 popup:【底图类型:矢量平面(默认) / 无】+【透明度滑块(默认 50%)】。 +- 作用于**该 2D 类型自己那块平面底图**(§9.2,由 `Plane2DRenderStrategy` 持有)。 + +### 7.5 3D 底图(移到 `VtkViewToolbar`,非段操作) +- 在 `Gear`(坐标轴设置) 正下方新增「地图」图标按钮,点击弹 popup:【底图类型:天地图(默认) / 无】+【透明度滑块(默认 50%)】。 +- 控制**全局唯一**的 3D 底图(`TileBasemap`):「无」= 隐藏;透明度去掉固定 `0.55`、改默认 `0.5` 可调。 + +### 7.6 导入雷达(移到 `TopBar` 设备菜单,临时测试) +- `TopBar` 设备菜单加「导入雷达测线」→ 子项「规范化(.head/.data)…」「Impulse(.iprb)…」,发等价于现 `radarImportRequested(impulse)` 的信号到既有导入流程。 +- 从 `CategorySection`(voxel 段头) 移除该入口及 `radarImportRequested` 转发。 + +--- + +## 8. 渲染模型(经渲染策略 §5.4) + +> 控制器主流程:勾选 diff → 对每个新增/移除 ds 查其描述符 → 取 `renderStrategyId` 对应策略 → `add/remove`;某类型「首勾」「全消」时调 `onTypeActivated/Deactivated`。无维度/ddCode 分支。 + +### 8.1 删除维度耦合 +- 移除 `VtkSceneView::setAnalysisMode2D` 的「相机锁定俯视 + 按维度翻可见标志」与 `VtkViewToolbar::setAnalysisMode2D` 的 6 向禁用。场景恒自由透视;2D/3D actor 同时可见(各自由勾选控制显隐)。 +- 移除 `view2DMode` 5 模式与旧 `set2DPlacement` mode 维度(其职责并入 `Plane2DRenderStrategy`)。 + +### 8.2 2D 按类型平面(需求 5)—— 封装在 `Plane2DRenderStrategy` +- 同一 2D 类型(段)勾选的全部 ds 投影到**一块平面**: + - 平面 z = 该类型**第一个被勾选 ds** 的 z(首个勾选时确定后**固定不变**,仅由 z 滑块整体升降)。 + - 平面**纯平、不渲染高程**。 + - 同类型其余 ds 的折线全部落在此平面 z。 +- **平面生命周期**:该类型**首个 ds 被勾选** → 创建平面(定 z)+ 创建其平面底图(§9.2);期间 z 固定;该类型**全部 ds 取消勾选** → 平面**与其底图一并销毁**。 +- 逐 ds 独立拖动 Z(旧 `nudgeSelectedMapLinesZ` / `mapLineZOffset_`)**废弃**,统一到类型平面 z。 +- 渲染复用 `MapLineActor`(折线几何不变),仅 Z 落点改为「所属类型平面 z」。 + +--- + +## 9. 底图体系(§6) + +### 9.1 3D 共享底图(1 个) +- 沿用 `TileBasemap` 单例(带高程地形,Z=0)。透明度参数化(默认 0.5,由 §7.5 popup 调);支持隐藏。瓦片范围规则不变。 + +### 9.2 2D 平面底图(N 个,每类型一块)—— 实现选型已定 +- **决策:参数化 `TileBasemap` 支持多实例**(不另抽 `PlaneBasemap`)。依据:`TileBasemap` 的「相机驱动 LOD + 四叉树细分 + 视锥剔除 + 限并发下载 + GeoLocalFrame 配准」正是平面底图所需,且其状态全为 per-instance(无全局/静态状态,`tileKey` 为纯函数),多实例天然可行。新抽类要么重写数百行 LOD/网络逻辑,要么退化成单层无 LOD 大平面(缩放发虚、且违背「瓦片范围参考三维规则」)。 +- **需改的 3 处硬编码**: + | 改动 | 现状 | 改为 | + |---|---|---| + | 地面 Z | `kGroundZ=0` 常量 | 构造/setter 传 `groundZ`(2D 平面 = 类型平面 z) | + | 透明度 | `kTerrainOpacity=0.55` 固定 | 参数化,默认 0.5 可调 | + | 平面/矢量模式 | 由 `Kind` 隐含 | 复用 `Street`(vec_w)+`buildFlat`(已是纯平矢量路径,跳过 DEM/warp) | +- **最终布局**:1 个 `TileBasemap`(3D,Satellite/带高程,Z=0,§9.1,挂控制器)+ N 个 `TileBasemap`(每 2D 类型一个,Street/纯平矢量,`groundZ`=平面 z,**由 `Plane2DRenderStrategy` 持有**)。共享同一 `Scene` / `GeoLocalFrame`。 +- **生命周期**(在 `Plane2DRenderStrategy` 内,经 `onTypeActivated/Deactivated`):按 2D 类型持有 N 个实例 + 各自平面 z(`PlaneZRegistry`);该类型首勾 → 建实例(§8.2 定 z);该类型全消 → 销毁实例(连同折线平面)。 +- 瓦片范围**复用** `dataHorizontalRadius()×10` 钳 `[2000,30000]m` 规则(各实例共享同一 `dataRadiusProvider`)。 +- 坐标对齐沿用 `GeoLocalFrame`(经纬→局部);frame 重锚逻辑不变。 + +--- + +## 10. 信号 / 默认勾选(§7) + +- 2D 勾选/选中信号从 `Column2DDataset` 迁到 2D `CategorySection`(复用 `checkedDatasetsChanged` / `datasetSelected`)。`basemapChanged` / `view2DModeChanged` / `customZChanged` 退役,由 §7.4/§7.5 的 popup + §8.2 平面 z 替代。 +- 「直接挂项目下 ds 默认进 VTK」既有逻辑保留;动态显隐段时确保默认勾选的 ds 所属段被显示且勾选状态正确。 + +--- + +## 11. 迁移说明 / 取舍 / 待定 + +- **替代 2026-06-26 spec**:该 spec 的「一场景两相机 + 按维度显隐 + 高程拖动分层」中,**相机锁定 + 维度显隐被本 spec 推翻**(改单一自由场景共存)。其「2D 沿 Z 拖动分离」语义改由「按类型平面 + z 滑块」承担——逐 ds 独立拖动 `nudgeSelectedMapLinesZ` / `mapLineZOffset_`(及拾取拖动浮层读数)**废弃移除**,统一到类型平面 z。 +- **`dd_raster`** 仍未接(2026-06-26 §6 遗留),本 spec 不含;后续作为新的 2D 类型段加入时复用 §8.2/§9.2 平面+底图机制。 +- **3D 反演段 vs 三维体段**:均为 D3,但 3D 反演段渲染为帘面、三维体段为体素/雷达体——共用 3D 底图与自由场景,无需区分。 +- **责任拆分**:`CategoryDescriptor`/`categoryCatalog`(描述符)、`IDatasetRenderStrategy` 注册表(渲染)、`SectionIconBar`(响应式工具条)、`TileBasemap` 多实例、`DatasetColumn`(动态显隐)相互独立,可分别实现与测试。 +- **抽象的 YAGNI 边界**:`FilterKind`/`OpKind`/`SceneKind` 只列当前真用到的枚举值;渲染策略只实现 `volume`/`curtain`/`plane2d` 三个。扩展点是「加新值/新策略」的预留口,不预先实现任何未用类型。 + +--- + +## 12. 验收 + +1. 左侧只有**一个无标题数据集栏**(无 tab);其中同时出现 3D 类型段(电阻率/视电阻率/瞬变/三维体)与 2D 类型段(轨迹)。 +2. 段**动态显隐**:无对应数据的段不显示;面板全空时显示居中占位提示。 +3. 段头操作为**图标工具条**:默认最多 3 个,超出/挤压时右侧收进「…」下拉。3D 反演段含「新增三维体/筛选」,2D 轨迹段含「z值/筛选/底图」。 +4. 「筛选」图标可**展开/收起**段内筛选行(默认折叠)。 +5. 勾选 2D 轨迹:同类型 ds 投影到**一块纯平平面**,平面 z = 首个勾选 ds 的 z(之后固定);「z值」滑块整体升降该平面;「底图」popup 可换矢量平面底图/无 + 调透明度(默认 50%)。该类型**全部取消勾选 → 平面与其底图一并消失**。 +6. 3D 与 2D 数据在**同一自由透视场景**同时可见,可自由旋转(无锁定俯视、无 tab 切换)。 +7. 渲染区工具栏 Gear 下方新增「地图」按钮,控制全局 3D 底图(天地图/无 + 透明度默认 50%)。 +8. 「导入雷达」入口出现在顶部「设备」菜单;三维体段头不再有该按钮。 +9. 「直接挂项目下的 ds」加载时默认勾选进 VTK 不被破坏。 +10. **可扩展性**:现存 5 类全部经 `categoryCatalog()` 描述符 + 渲染策略注册表驱动,控制器/段头无维度/ddCode 散判。新增一个「沿用已有渲染/操作/筛选」的类型,只需在 catalog 加一条描述符即可显示+渲染(以一个验证性 demo 描述符或单元测试佐证分类/路由走通)。 diff --git a/docs/superpowers/specs/2026-07-01-vtk-view-navigation-axes-design.md b/docs/superpowers/specs/2026-07-01-vtk-view-navigation-axes-design.md new file mode 100644 index 0000000..a23b89d --- /dev/null +++ b/docs/superpowers/specs/2026-07-01-vtk-view-navigation-axes-design.md @@ -0,0 +1,67 @@ +# VTK 视图导航与坐标轴改进 — Spec(2026-07-01) + +> 分支 `feat/vtk-merged-dataset-column`(合并数据集单栏重构之后的增量)。职责:渲染区坐标轴、方向标、相机导航、列表双击联动。全部决策已与用户逐条确认。 + +## 0. 背景与动机 + +合并单栏 + 单一自由场景后,2D 平面与 3D 体/帘面共存于一个世界坐标系。现状渲染坐标轴是**全场景合并包围盒**的一套 cube axes(`AxesActor`/`rebuildAxes`)。用户痛点:多个渲染物相距很远/尺度悬殊时,全场景轴只贴近某一个物体,**离轴远的物体读不出真实尺寸**。业界通行解法不是「每物体一套常驻轴」,而是「全场景一套参考 + 选中物体单独出其贴合轴 + 可点击方向标 + 按需测量」。本 spec 落地导航侧改进。 + +## 1. 已确认决策(逐条) + +1. **坐标轴(空间)全场景一套是对的**——坐标轴量的是空间位置(一个世界 CS),物理量差异由逐数据集色阶承担,尺度悬殊用统一 VE,不拆轴。 +2. **选中物体 → 隐全景轴、只显该物体贴合轴**(Q1)。 +3. **贴合轴按层级子树归一到一个包围盒**:选中三维体(或其下切片、切片下异常)→ 包围盒覆盖「该三维体 + 其切片 + 其异常」整棵子树,合成**一个**盒;选谁都归到该子树同一个盒。 +4. **角落三向标(gnomon)常驻 + 可点击**(Q2);点击某方向轴 → 相机**绕支点转到该轴、保留当前缩放距离**(ViewCube 手感,选项1)。 +5. **绕轴支点规则**:有选中 → 选中物体**子树包围盒中心**;无选中 → **全场景合并包围盒中心**。两者都保留当前缩放。 +6. **列表双击 DS →(a)视图适配到该 DS 的空间范围 +(b)联动打开中下方数据集详情页**;三维体等**详情页未做**的类型要**容错静默**(找不到对应详情页不报错、不弹窗,仅完成适配)。 + +## 2. 术语:子树包围盒 + +- 一个 ds 的「子树」= 该 ds 自身 + 其所有后代 ds(三维体 → 其切片 → 切片的异常),仅计入**当前已渲染**的成员。 +- 子树包围盒 = 子树内所有已渲染 ds 的 actor 包围盒并集(`dsProps_` 按 dsId 取 actor bounds)。 +- 后代关系来源:数据模型的父子(切片 parentId=体、异常 parentId=切片/体)——由控制器/仓储或面板树提供 dsId→后代集。渲染侧只需拿到「要并集的 dsId 列表」。 + +## 3. 设计 + +### 3.1 相机/包围盒基元(`VtkSceneView` + `CameraPreset`) +- `bool datasetBounds(const std::vector& dsIds, double outB[6]) const`:并集给定 dsIds 的已渲染 actor 包围盒;无有效则返回 false。 +- `void fitToBounds(const double b[6])`:把相机适配到指定包围盒(保持朝向,`ResetCamera(b)`)。用于双击适配、贴合。 +- `void orbitToAxis(ViewDir dir, const double pivot[3])`:相机**绕 pivot** 转到沿 dir 轴看向 pivot,**保留当前 focal-to-camera 距离**(即当前缩放)。区别于 `applyCameraView`(正视重置)与 6 视图按钮(重置+fit)。实现:取当前 |cam−focal| 距离 d;focal 设为 pivot;按 dir 设 cam=pivot+dir_offset*d、view-up 按预设;`ResetCameraClippingRange`。 + +### 3.2 贴合轴 + 隐全景轴(决策 2/3) +- `VtkSceneView` 维护两态:`rebuildAxes()`(全场景,现状)与新 `showFittedAxes(const double b[6])`/`showSceneAxes()`。 +- 选中某 ds(`datasetSelected` 到达)→ 上层算该 ds 子树的 dsIds → `datasetBounds` → `showFittedAxes(box)`(把 cube axes 的 bounds 设为子树盒)+ 隐全景轴。 +- 取消选中(空选中)→ `showSceneAxes()`(恢复全场景轴)。 +- 实现可复用 `AxesActor`:新增「按外部 bounds 显示」的模式(不再固定用全场景 bounds),选中/取消在两种 bounds 间切换。 + +### 3.3 可点击方向标 gnomon(决策 4/5) +- 新增角落三向标:`vtkOrientationMarkerWidget` + `vtkAxesActor`(XYZ 三向 + 轴标签),常驻右下(或右上,避开现有工具栏)。 +- **可点击**:`vtkOrientationMarkerWidget` 默认不透传点击到轴。需在其上做拾取——用一个 `vtkPropPicker`/自定义,把点击落到 +X/−X/+Y/−Y/+Z/−Z 六向之一 → 调 `orbitToAxis(dir, pivot)`。pivot 按决策 5(选中子树中心 / 全场景中心)。 +- 六向 → `ViewDir` 映射:+Z=Top、−Z=Bottom、+Y=Back(北望反)/−Y=Front… 与 `CameraPreset` 现有 ViewDir 语义对齐(复用,不新造方向定义)。 + +### 3.4 双击 DS → 适配 + 详情(决策 6) +- 列表双击现已发 `detailRequested(dsId, ddCode, name)`(`CategorySection::itemDoubleClicked`)。扩展双击行为:**并发**触发 + 1. **适配**:算该 dsId 子树 dsIds → `datasetBounds` → `fitToBounds`(相机适配到该 DS 空间范围,保持朝向)。 + 2. **详情联动**:沿用现有 `detailRequested → DatasetDetailController` 链打开中下方 `DatasetDetailPanel`。 +- **容错静默**:对详情页未实现的类型(三维体 `dd_voxel`/`dd_radar_3d` 等),详情链找不到对应页时**静默**——不弹错、不打断,适配照常完成。实现:详情控制器/面板在无匹配详情页时走「无操作」分支(或双击前按 ddCode 判定「有详情页才发 detailRequested」)。优先在联动入口按「该 ddCode 是否有详情页」gate,无则只做适配。 + +## 4. 影响文件(预估) +- `src/app/VtkSceneView.{hpp,cpp}`:`datasetBounds`/`fitToBounds`/`orbitToAxis`/`showFittedAxes`/`showSceneAxes` + gnomon widget。 +- `src/render/CameraPreset.{hpp,cpp}` 或 `src/render/actors/AxesActor.*`:orbit-to-axis 相机数学、按外部 bounds 的轴。 +- `src/controller/VtkSceneController.*` + `I3dSceneView`:转发新相机/轴/适配接口;子树 dsIds 解析(或由 main 用面板树/仓储父子算)。 +- `src/app/main.cpp`:选中→贴合轴、双击→适配+详情 gate、gnomon 点击接线。 +- `src/app/panels/columns/CategorySection.*`/`CategoryAnalysisTab.*`:双击透传(已有 detailRequested;可能加「fitRequested(dsId)」或复用 datasetSelected)。 + +## 5. 分期/任务 +- **T1(基元)**:`datasetBounds`/`fitToBounds`/`orbitToAxis`(相机+包围盒基元,纯渲染,可单测 orbit 数学)。 +- **T2(贴合轴)**:选中→子树盒→贴合轴+隐全景轴;取消→恢复全景轴。含「子树 dsIds 解析」。 +- **T3(gnomon)**:常驻可点击三向标 → orbitToAxis(pivot 规则)。 +- **T4(双击)**:双击→适配到子树盒 + 详情联动 gate(无详情页静默)。 +- 顺序:T1 先(T2/T3/T4 都依赖 T1 的基元);T2/T4 依赖「子树 dsIds 解析」(T2 引入,T4 复用)。 + +## 6. 验收 +1. 无选中:显示全场景总览轴 + 右下角三向标。 +2. 选中某体/切片/异常:全景轴隐去,出现覆盖「该体+其切片+异常」子树的一个贴合包围盒轴,能读该子树真实尺寸;取消选中恢复全景轴。 +3. 点击三向标某方向轴:相机绕支点(选中子树中心/无选中则全场景中心)转到该轴、**缩放不变**、数据仍居中。 +4. 双击列表某 DS:视图适配到该 DS(子树)空间范围;有详情页的类型联动打开中下方详情页;三维体等无详情页类型**静默**(只适配、不报错)。 +5. VE 对全部渲染物一致;坐标轴仍是空间位置度量、逐数据集色阶不变。 diff --git a/src/app/CMakeLists.txt b/src/app/CMakeLists.txt index c19e2e4..6a2c03f 100644 --- a/src/app/CMakeLists.txt +++ b/src/app/CMakeLists.txt @@ -5,8 +5,10 @@ find_package(VTK REQUIRED COMPONENTS GUISupportQt RenderingOpenGL2 RenderingVolumeOpenGL2 + RenderingAnnotation # vtkAxesActor(角落方向标 gnomon 三向箭头 + 轴标签) InteractionStyle InteractionWidgets + FiltersSources # vtkSphereSource(gnomon 6 向可点击方向球) FiltersGeometry FiltersModeling IOImage # vtkPNGWriter(切片导出图片) @@ -77,11 +79,11 @@ add_executable(geopro_desktop WIN32 panels/chart/ChartPickGeometry.cpp panels/chart/ScatterMarqueePicker.cpp panels/chart/ContourDrawTool.cpp - panels/columns/Column2DDataset.cpp panels/columns/CategorySection.cpp panels/columns/CategoryAnalysisTab.cpp panels/columns/DateRangeEdit.cpp panels/columns/ColumnDrawer.cpp + panels/columns/SectionIconBar.cpp panels/AnomalyTablePanel.cpp panels/LoadingOverlay.cpp panels/DatasetDetailPage.cpp @@ -107,7 +109,6 @@ add_executable(geopro_desktop WIN32 VolumeParamsDialog.cpp VolumePropertiesDialog.cpp Logging.cpp - DatasetDimension.cpp DatasetCategory.cpp VtkViewToolbar.cpp AxesSettingsDialog.cpp diff --git a/src/app/DatasetCategory.cpp b/src/app/DatasetCategory.cpp index bca33cc..db88af0 100644 --- a/src/app/DatasetCategory.cpp +++ b/src/app/DatasetCategory.cpp @@ -1,21 +1,15 @@ #include "DatasetCategory.hpp" +#include "repo/CategoryDescriptor.hpp" namespace geopro::app { CategoryBuckets splitByCategory(const std::vector& rows) { - const auto& cfg = categoryConfigs(); + const auto& cat = geopro::data::categoryCatalog(); CategoryBuckets b; - b.segments.resize(cfg.size()); - for (const auto& r : rows) { - int hit = -1; - // 先按 ddCode(三维体/切片)——它们无 dsTypeCode(来自 Api3dRepository mock 行)。 - for (std::size_t i = 0; i < cfg.size() && hit < 0; ++i) - if (!cfg[i].ddCode.empty() && r.ddCode == cfg[i].ddCode) hit = static_cast(i); - // 再按 dsTypeCode。 - for (std::size_t i = 0; i < cfg.size() && hit < 0; ++i) - if (!cfg[i].dsTypeCode.empty() && r.dsTypeCode == cfg[i].dsTypeCode) hit = static_cast(i); - if (hit >= 0) b.segments[static_cast(hit)].push_back(r); - } + b.segments.resize(cat.size()); + for (const auto& r : rows) + for (std::size_t i = 0; i < cat.size(); ++i) + if (cat[i].classify && cat[i].classify(r)) { b.segments[i].push_back(r); break; } return b; } diff --git a/src/app/DatasetCategory.hpp b/src/app/DatasetCategory.hpp index 519db3f..df68db3 100644 --- a/src/app/DatasetCategory.hpp +++ b/src/app/DatasetCategory.hpp @@ -1,15 +1,14 @@ #pragma once #include -#include "repo/CategoryConfig.hpp" #include "repo/RepoTypes.hpp" namespace geopro::app { struct CategoryBuckets { - std::vector> segments; // 与 categoryConfigs() 同序同长 + std::vector> segments; // 与 categoryCatalog() 同序同长 }; -// 按 CategoryConfig 把 ds 分入大类段:先判 ddCode 白名单(三维体/切片),否则按 dsTypeCode 匹配; +// 按 categoryCatalog() 把 ds 分入大类段:遍历目录,命中首个 classify(row)==true 的描述符即归入该段; // 不在表内的丢弃(接地电阻/原始数据/白化/坐标等)。保留原顺序。 CategoryBuckets splitByCategory(const std::vector& rows); diff --git a/src/app/DatasetDimension.cpp b/src/app/DatasetDimension.cpp deleted file mode 100644 index b4c1e2d..0000000 --- a/src/app/DatasetDimension.cpp +++ /dev/null @@ -1,31 +0,0 @@ -#include "DatasetDimension.hpp" - -namespace geopro::app { - -namespace { -// 与 LocalSample3dRepository::dimensionOf 同一映射(spec §6.1)。 -enum class Dim { D3, D2, Analysis, Other }; -Dim dimOf(const std::string& c) { - if (c == "dd_voxel" || c == "dd_Structual3D" || c == "dd_Property3D" || - c == "dd_section" || c == "dd_inversion_data" || c == "dd_radar_3d") - return Dim::D3; - if (c == "dd_slice") return Dim::Analysis; - if (c == "dd_trajectory_data") return Dim::D2; - return Dim::Other; -} -} // namespace - -DimBuckets splitByDimension(const std::vector& rows) { - DimBuckets b; - for (const auto& r : rows) { - switch (dimOf(r.ddCode)) { - case Dim::D3: b.dim3D.push_back(r); break; - case Dim::D2: b.dim2D.push_back(r); break; - case Dim::Analysis: b.analysis.push_back(r); break; - case Dim::Other: break; - } - } - return b; -} - -} // namespace geopro::app diff --git a/src/app/DatasetDimension.hpp b/src/app/DatasetDimension.hpp deleted file mode 100644 index 846ce57..0000000 --- a/src/app/DatasetDimension.hpp +++ /dev/null @@ -1,17 +0,0 @@ -#pragma once -#include -#include "repo/RepoTypes.hpp" - -namespace geopro::app { - -struct DimBuckets { - std::vector dim3D; - std::vector dim2D; - std::vector analysis; -}; - -// 按 ddCode 把 ds 分流到 三维数据集 / 二维数据集 / 三维分析 三栏。 -// Other 维度不入任何栏(保留原顺序)。 -DimBuckets splitByDimension(const std::vector& rows); - -} // namespace geopro::app diff --git a/src/app/TileBasemap.cpp b/src/app/TileBasemap.cpp index 943b83d..52c99db 100644 --- a/src/app/TileBasemap.cpp +++ b/src/app/TileBasemap.cpp @@ -52,11 +52,9 @@ constexpr double kRangeCeil = 30000.0; // 最多 30km(防远裁剪面失控) constexpr int kMaxConcurrent = 12; // 瓦片请求最大并发(天地图 8 子域+Mapbox,适度提高吞吐) constexpr int kMinZoom = 3; constexpr int kMaxZoom = 18; -constexpr double kGroundZ = 0.0; // 底图置于 z=0 地面参考(剖面深度向下为负,落其下) constexpr double kZEps = 0.02; // 每层级 Z 微偏移:高层级压上面,避免共面瓦片 z-fighting constexpr int kHardCap = 400; // 瓦片硬上限:超过则即便未落地也强制清理,兜底内存 constexpr double kPi = 3.14159265358979323846; -constexpr double kTerrainOpacity = 0.55; // 地形半透明:地下剖面可从任意角度透过地面看到(不再被遮挡) // 地面起伏:Mapbox terrain-RGB DEM 瓦片(原版 web 同款源,全球 CDN,比 AWS Terrarium 快)。 // 公式 elev(米) = -10000 + (R*65536 + G*256 + B)*0.1。数据到 z15,更高层级取祖先块。 @@ -121,8 +119,9 @@ long long TileBasemap::tileKey(int z, int x, int y) { } TileBasemap::TileBasemap(geopro::render::Scene& scene, vtkRenderWindow* rw, - std::shared_ptr frame, QObject* parent) - : QObject(parent), scene_(scene), rw_(rw), frame_(std::move(frame)) {} + std::shared_ptr frame, QObject* parent, + double groundZ) + : QObject(parent), scene_(scene), rw_(rw), frame_(std::move(frame)), groundZ_(groundZ) {} void TileBasemap::requestRender() { // 合并渲染:同一事件循环轮次内的多次请求只渲染一帧(标准做法,避免逐瓦片重复 Render 卡顿)。 @@ -140,6 +139,10 @@ void TileBasemap::requestRender() { } TileBasemap::~TileBasemap() { + // 移除本实例所有已贴瓦片:多实例(每 2D 平面一份)动态建销时,析构须撤回瓦片,否则渲染器仍持引用、 + // 底图不随平面消失。共享 3D 底图存活至退出故旧码无此清理也无碍,但 per-plane 实例必须清。 + if (auto* ren = scene_.renderer()) + for (auto& kv : placed_) ren->RemoveViewProp(kv.second); if (styleObs_ && observer_) styleObs_->RemoveObserver(observer_); } @@ -208,6 +211,23 @@ void TileBasemap::setVerticalExaggeration(double ve) { if (kind_ != Hidden) show(kind_); // 重建地形(高程×新VE),与剖面 VE 保持一致 } +void TileBasemap::setOpacity(double o) { + o = std::clamp(o, 0.0, 1.0); + if (o == opacity_) return; + opacity_ = o; + if (kind_ != Hidden) show(kind_); // 重建套用新透明度 +} + +void TileBasemap::setGroundZ(double z) { + // 直接平移平面高程:瓦片几何建于相对 z(仅含逐层级 z-fighting 偏移),平面高程 groundZ_ 经 actor position 施加。 + // 拖 z 值滑块时只改所有已贴瓦片的 SetPosition,无需重下载/重建;后续 refresh() 新瓦片经 placeActor 自动取新 groundZ_。 + if (z == groundZ_) return; + groundZ_ = z; + for (auto& kv : placed_) + if (kv.second) kv.second->SetPosition(0.0, 0.0, groundZ_); + requestRender(); +} + void TileBasemap::refineTile(int z, int x, int y, std::set& out, int& count) { if (count >= kMaxLeaves) { out.insert(tileKey(z, x, y)); return; } // 安全上限:停止细分 const int n = 1 << z; @@ -429,6 +449,7 @@ void TileBasemap::fetchTile(int z, int x, int y, long long key) { void TileBasemap::placeActor(long long key, vtkSmartPointer actor) { if (!actor) return; + actor->SetPosition(0.0, 0.0, groundZ_); // 平面高程经 position 施加:几何建于相对 z,此处抬到当前 groundZ_ scene_.addActor(actor); placed_[key] = actor; } @@ -439,7 +460,7 @@ vtkSmartPointer TileBasemap::buildFlat(int z, int x, int y, const auto sw = frame_->toLocal(b.south, b.west); const auto se = frame_->toLocal(b.south, b.east); const auto nw = frame_->toLocal(b.north, b.west); - const double gz = kGroundZ + (z - kMinZoom) * kZEps; // 高层级略抬高,压在旧层之上防共面闪烁 + const double gz = (z - kMinZoom) * kZEps; // 仅逐层级 z-fighting 偏移(相对 z);平面高程由 actor position 施加 // PlaneSource 自动 tcoord:origin=SW→u 西0东1、v 南0北1(与翻转后纹理对齐)。 vtkNew plane; @@ -452,7 +473,7 @@ vtkSmartPointer TileBasemap::buildFlat(int z, int x, int y, actor->SetMapper(mapper); actor->SetTexture(tex); actor->GetProperty()->LightingOff(); // 底图不受场景光照 - actor->GetProperty()->SetOpacity(kTerrainOpacity); // 半透明:不遮挡地下剖面 + actor->GetProperty()->SetOpacity(opacity_); // 半透明:不遮挡地下剖面 // 注意:UseBounds 保持默认 true → 参与相机裁剪面计算,否则底图会被裁剪面"蒙版"切掉。 // 坐标轴/取景不被底图撑大,由 VtkSceneView 改用"数据自身包围盒"解决(非靠 UseBounds=false)。 return actor; @@ -528,7 +549,7 @@ vtkSmartPointer TileBasemap::buildWarped(int sz, int sx, int sy, int d const auto sw = frame_->toLocal(sb.south, sb.west); const auto se = frame_->toLocal(sb.south, sb.east); const auto nw = frame_->toLocal(sb.north, sb.west); - const double base = kGroundZ + (sz - kMinZoom) * kZEps; + const double base = (sz - kMinZoom) * kZEps; // 仅逐层级 z-fighting 偏移(相对 z);平面高程由 actor position 施加 // PlaneSource(等距圆柱下平面插值即正确 x/y) + 自动 tcoord;再按各点真实经纬采 DEM 位移 Z。 vtkNew plane; @@ -566,7 +587,7 @@ vtkSmartPointer TileBasemap::buildWarped(int sz, int sx, int sy, int d actor->SetMapper(mapper); actor->SetTexture(tex); actor->GetProperty()->LightingOff(); - actor->GetProperty()->SetOpacity(kTerrainOpacity); // 半透明:不遮挡地下剖面 + actor->GetProperty()->SetOpacity(opacity_); // 半透明:不遮挡地下剖面 return actor; // UseBounds 默认 true:参与裁剪面,避免被"蒙版"切掉 } diff --git a/src/app/TileBasemap.hpp b/src/app/TileBasemap.hpp index da6e7af..a9119e4 100644 --- a/src/app/TileBasemap.hpp +++ b/src/app/TileBasemap.hpp @@ -33,13 +33,16 @@ class TileBasemap : public QObject { public: enum Kind { Street = 0, Satellite = 1, Hidden = 2 }; TileBasemap(geopro::render::Scene& scene, vtkRenderWindow* rw, - std::shared_ptr frame, QObject* parent = nullptr); + std::shared_ptr frame, QObject* parent = nullptr, + double groundZ = 0.0); ~TileBasemap() override; void show(Kind kind); // 显示某底图(Hidden 等同 hide);记住类型供 LOD 刷新复用 void hide(); // 移除全部瓦片 void refresh(); // 按当前相机重算层级+覆盖,增量更新瓦片(交互结束回调) void setVerticalExaggeration(double ve); // 地形垂向夸张(须与剖面 VE 一致才对齐) + void setOpacity(double o); // 底图半透明度[0,1],供渲染工具栏底图弹窗调节 + void setGroundZ(double z); // 直接平移底图平面 z(拖 z 值滑块):改所有已贴瓦片 actor 的 position,无重铺 // 数据半径提供者:刷新时查询当前所有勾选剖面的合并范围(半径,米),据此动态定底图最大范围。 void setDataRadiusProvider(std::function fn) { dataRadiusProvider_ = std::move(fn); } @@ -75,7 +78,9 @@ private: std::set inFlight_; // 在途瓦片(续到起伏/平面最终落地) std::map demCache_; // DEM 块缓存(key=DEMz/x/y),跨隐藏/重选复用 std::map> texCache_; // 影像纹理缓存,重选/缩放回看免重拉 + double groundZ_ = 0.0; // 底图地面参考 z(per-instance):3D 底图=0;2D 平面底图=各类型平面高程 double ve_ = 1.0; // 地形垂向夸张(与剖面 verticalExaggeration 一致才对齐) + double opacity_ = 0.5; // 底图半透明:地下剖面可从任意角度透过地面看到(不再被遮挡) double maxTileDist_ = 2000.0; // 底图最大距离(米),每次刷新按剖面范围动态算 std::function dataRadiusProvider_; // 返回当前勾选剖面合并范围的半径 // 卫星层「学习到的」最大可用层级:天地图卫星影像各区域覆盖深度不同(内地到z18, 台湾等仅到z16), diff --git a/src/app/TileBasemapPlaneAdapter.hpp b/src/app/TileBasemapPlaneAdapter.hpp new file mode 100644 index 0000000..16bd859 --- /dev/null +++ b/src/app/TileBasemapPlaneAdapter.hpp @@ -0,0 +1,37 @@ +#pragma once +#include +#include +#include + +#include "TileBasemap.hpp" +#include "controller/DatasetRenderStrategy.hpp" // geopro::controller::IPlaneBasemap + +namespace geopro::render { class Scene; } +namespace geopro::core { class GeoLocalFrame; } +class vtkRenderWindow; + +namespace geopro::app { + +// 2D 平面底图适配器:把 app 层 TileBasemap 适配到控制器层抽象 IPlaneBasemap, +// 使 geopro_controller(仅链 geopro_data+Qt::Core) 不反依赖 app 层与 VTK(仿 I3dSceneView/VtkSceneView)。 +// main.cpp 经底图工厂按平面 z 造之;持 TileBasemap 值成员,随适配器析构而析构(移除瓦片→底图随平面消失)。 +class TileBasemapPlaneAdapter : public geopro::controller::IPlaneBasemap { +public: + TileBasemapPlaneAdapter(geopro::render::Scene& scene, vtkRenderWindow* rw, + std::shared_ptr frame, double groundZ, + std::function radiusProvider) + : bm_(scene, rw, std::move(frame), nullptr, groundZ) { + bm_.setDataRadiusProvider(std::move(radiusProvider)); + } + void show(int kind) override { + bm_.show(kind == 0 ? TileBasemap::Street : TileBasemap::Hidden); + } + void hide() override { bm_.hide(); } + void setOpacity(double o) override { bm_.setOpacity(o); } + void setGroundZ(double z) override { bm_.setGroundZ(z); } + +private: + TileBasemap bm_; +}; + +} // namespace geopro::app diff --git a/src/app/TopBar.cpp b/src/app/TopBar.cpp index aba8d82..f9ec52c 100644 --- a/src/app/TopBar.cpp +++ b/src/app/TopBar.cpp @@ -129,14 +129,6 @@ QMenu* buildToolsMenu(QWidget* p) return m; } -QMenu* buildDeviceMenu(QWidget* p) -{ - auto* m = new QMenu(QStringLiteral("设备"), p); - m->addAction(QStringLiteral("连接设备")); - m->addAction(QStringLiteral("设备管理")); - return m; -} - } // namespace TopBar::TopBar(QWidget* parent) : QWidget(parent) { @@ -220,7 +212,7 @@ TopBar::TopBar(QWidget* parent) : QWidget(parent) { lay->addWidget(makeMenuButton(this, buildViewMenu())); lay->addWidget(makeMenuButton(this, buildProjectMenu())); lay->addWidget(makeMenuButton(this, buildToolsMenu(this))); - lay->addWidget(makeMenuButton(this, buildDeviceMenu(this))); + lay->addWidget(makeMenuButton(this, buildDeviceMenu())); lay->addStretch(); @@ -333,6 +325,21 @@ QMenu* TopBar::buildProjectMenu() { return m; } +// 设备菜单。连接设备/设备管理为占位;「导入雷达测线」是后端未就绪期的过渡测试入口,集中到设备菜单。 +// 原入口在三维体段头按钮(已移除);子项规范化/Impulse 分别 emit radarImportRequested(false/true), +// 由 main 接到既有导入流程。 +QMenu* TopBar::buildDeviceMenu() { + auto* m = new QMenu(QStringLiteral("设备"), this); + m->addAction(QStringLiteral("连接设备")); + m->addAction(QStringLiteral("设备管理")); + QMenu* radar = m->addMenu(QStringLiteral("导入雷达测线")); + radar->addAction(QStringLiteral("规范化测线目录(.head/.data)…"), this, + [this] { emit radarImportRequested(false); }); + radar->addAction(QStringLiteral("Impulse 测线目录(.iprb)…"), this, + [this] { emit radarImportRequested(true); }); + return m; +} + bool TopBar::eventFilter(QObject* obj, QEvent* event) { if (obj == userRow_ && event->type() == QEvent::MouseButtonRelease) { if (userMenu_) diff --git a/src/app/TopBar.hpp b/src/app/TopBar.hpp index e19478a..4c11c8a 100644 --- a/src/app/TopBar.hpp +++ b/src/app/TopBar.hpp @@ -32,10 +32,13 @@ signals: // 项目管理菜单中「直接嵌入」的 web 页被点击:title=窗口标题,target=嵌入页 target 路径。 void webPageRequested(const QString& title, const QString& target); void analysisViewRequested(); // 视图菜单「分析视图」→ 中央区切回默认工作台 + // 设备菜单「导入雷达测线」(后端未就绪的过渡测试入口):false=规范化(.head/.data),true=Impulse(.iprb)。 + void radarImportRequested(bool impulse); private: QMenu* buildViewMenu(); // 视图菜单(成员:「分析视图」需 emit 信号) QMenu* buildProjectMenu(); // 项目管理菜单(成员:webview 叶子项需 emit 信号) + QMenu* buildDeviceMenu(); // 设备菜单(成员:「导入雷达测线」子项需 emit 信号) QToolButton* wsBtn_ = nullptr; QToolButton* projBtn_ = nullptr; diff --git a/src/app/VtkSceneView.cpp b/src/app/VtkSceneView.cpp index 11526e9..90ed455 100644 --- a/src/app/VtkSceneView.cpp +++ b/src/app/VtkSceneView.cpp @@ -1,6 +1,7 @@ #include "VtkSceneView.hpp" #include +#include #include #include #include @@ -9,17 +10,26 @@ #include #include +#include +#include +#include +#include #include #include -#include #include +#include #include +#include #include +#include +#include #include #include #include #include +#include #include +#include #include #include @@ -77,6 +87,25 @@ VtkSceneView::VtkSceneView(geopro::render::Scene& scene, vtkRenderWindow* render // 近裁剪容差调小:场景含近处剖面 + 远处底图(几十km),默认容差会把近裁剪面随远面推出去、 // 切掉离相机近的剖面。调小后近面可贴近,剖面不被切(代价:远处深度精度略降,不可见层无所谓)。 scene_.renderer()->SetNearClippingPlaneTolerance(1e-5); + ensureGnomon(); // 交互器若已就绪即装配角落方向标(否则首帧 render 时补装) +} + +VtkSceneView::~VtkSceneView() { + // 摘除左键/相机观察者(clientData=this,本对象析构后若留存会悬垂)+ 移除叠加渲染器。 + // 渲染窗口/交互器可能已在 Qt 拆台中先行析构,全程判空。 + if (renderWindow_) { + if (auto* iren = renderWindow_->GetInteractor()) { + if (gnomonClickTag_ != 0) iren->RemoveObserver(gnomonClickTag_); + if (gnomonHoverTag_ != 0) iren->RemoveObserver(gnomonHoverTag_); + } + } + gnomonClickTag_ = 0; + gnomonHoverTag_ = 0; + // 主相机由 scene_ 渲染器持有、生命周期覆盖本对象(构造契约),析构时仍在 → 可安全摘观察者。 + if (gnomonObservedCam_ && gnomonCamTag_ != 0) gnomonObservedCam_->RemoveObserver(gnomonCamTag_); + gnomonCamTag_ = 0; + gnomonObservedCam_ = nullptr; + if (renderWindow_ && gnomonRenderer_) renderWindow_->RemoveRenderer(gnomonRenderer_); } void VtkSceneView::removeProps(std::vector>& props) { @@ -110,8 +139,7 @@ void VtkSceneView::clear() { // 只移除数据 prop(按 ds 跟踪)+ 杂项(地形/测线)+ 坐标轴;不动底图(TileBasemap 自管)→ 重建不丢图。 for (auto& kv : dsProps_) removeProps(kv.second); dsProps_.clear(); - mapLineDs_.clear(); // 2D 足迹维度记录随数据图元一并清(模式标志保留) - selectedMapLines_.clear(); // 选中态随图元清(actor 已销毁);Z 偏移 mapLineZOffset_ 保留→重建后复位高度 + mapLineDs_.clear(); // 2D 足迹归属记录随数据图元一并清 removeProps(miscProps_); clearAnomalies(); // 异常 actor 随清场一并移除 if (currentAxes_) { @@ -123,6 +151,7 @@ void VtkSceneView::clear() { volumeOwnerDs_.clear(); volumes_.clear(); // 多体并发:清场移除所有体 image frameAnchoredToData_ = false; // 新一轮选择重新按其首个真实剖面重锚原点 + useFittedAxes_ = false; // 清场:贴合轴复位为全场景轴(选中随数据一并失效,防残留旧盒) if (onVolumeChanged) onVolumeChanged(); } @@ -164,7 +193,6 @@ void VtkSceneView::addCurtain(const std::string& dsId, const geopro::core::Grid& auto curtain = geopro::render::buildCurtain(grid, cs, *frame_); if (curtain) { curtain->SetScale(1.0, 1.0, verticalExaggeration_); // 纵向夸张成墙 - curtain->SetVisibility(analysisMode2D_ ? 0 : 1); // 帘面=3D内容:二维分析下隐藏 scene_.addActor(curtain); dsProps_[dsId].push_back(curtain); } @@ -206,7 +234,6 @@ void VtkSceneView::addVolume(const std::string& dsId, const geopro::data::Volume // 体 actor 不参与拾取:切片选中靠点中切片平面(widget 交互/拾取)。否则点击落到体内部时 // picker 命中体、worldPoint 落体内 → nearestSlice 按平面距离选错切片(用户 ④ 串选)。 volume->PickableOff(); - volume->SetVisibility(analysisMode2D_ ? 0 : 1); // 体=3D内容:二维分析下隐藏 scene_.addViewProp(volume); dsProps_[dsId].push_back(volume); currentVolumeImage_ = image; @@ -228,7 +255,6 @@ void VtkSceneView::addVolume(const std::string& dsId, const geopro::data::Volume auto iso = geopro::render::buildIsosurface(image, cs, vol.vmin, vol.vmax, isoVal); if (iso) { iso->PickableOff(); // 不参与拾取(同体 actor,避免串选) - iso->SetVisibility(analysisMode2D_ ? 0 : 1); // 3D 内容:二维分析下隐藏 scene_.addActor(iso); dsProps_[dsId].push_back(iso); } @@ -258,17 +284,29 @@ void VtkSceneView::addMapLine(const std::string& dsId, const geopro::data::MapLi // worldZ 已是最终世界高程(含摆放语义),不再施加 VE(足迹是水平线,非随深度的竖直图元)。 // 足迹可能是首个(且唯一)带经纬的数据 → 与帘面同样重锚原点,否则按样本默认原点投到数百公里外不可见。 anchorFrameIfNeeded(line.lat, line.lon, static_cast(line.lat.size())); - auto actor = geopro::render::buildMapLine(line.lat, line.lon, worldZ, *frame_); + // 折线几何建于 Z=0,平面高程 worldZ 经 actor SetPosition 施加 → 后续拖 z 值滑块只改 position 即直接平移, + // 无需移除+异步重载几何(setMapLinesZ 走此)。首勾/后续勾选在当前平面 z 加入者立即摆到该 z。 + auto actor = geopro::render::buildMapLine(line.lat, line.lon, 0.0, *frame_); if (actor) { - actor->SetVisibility(analysisMode2D_ ? 1 : 0); // 足迹=2D内容:仅二维分析下显示 - auto off = mapLineZOffset_.find(dsId); // B 期:复用持久 Z 偏移(全量重建后仍在该高度) - if (off != mapLineZOffset_.end()) actor->AddPosition(0.0, 0.0, off->second); + actor->SetPosition(0.0, 0.0, worldZ); scene_.addActor(actor); dsProps_[dsId].push_back(actor); - mapLineDs_.insert(dsId); // 记录此 ds 为 2D 足迹(切 tab 按维度翻可见) + mapLineDs_.insert(dsId); // 记录此 ds 为 2D 足迹(供足迹归属识别) } } +void VtkSceneView::setMapLinesZ(const std::vector& dsIds, double z) { + // 直接平移足迹:仅对属于足迹的 dsId 改其 actor 的 SetPosition(0,0,z),即时渲染,无移除+重载。 + for (const auto& dsId : dsIds) { + if (!mapLineDs_.count(dsId)) continue; + auto it = dsProps_.find(dsId); + if (it == dsProps_.end()) continue; + for (auto& prop : it->second) + if (auto* a = vtkActor::SafeDownCast(prop)) a->SetPosition(0.0, 0.0, z); + } + if (renderWindow_) renderWindow_->Render(); +} + void VtkSceneView::addTerrain(const geopro::data::TerrainPaths& paths) { auto terrain = geopro::render::buildTerrain(paths.demPath, paths.imagePath, *frame_, zRefElev_, verticalExaggeration_); @@ -417,135 +455,315 @@ void VtkSceneView::fitView() { if (onCameraChanged) onCameraChanged(); // 取景后 → 底图按新视锥重算覆盖(治首帧部分瓦片不出) } -void VtkSceneView::setAnalysisMode2D(bool is2D) { - if (is2D == analysisMode2D_) return; // 幂等:同模式重复切不做事 - analysisMode2D_ = is2D; - if (!is2D) clearMapLineSelection(); // 离开二维分析:清足迹选中(三维下不可拖 Z);Z 偏移仍持久 - - // ① 按维度翻可见标志(不清空、不重建→切换瞬时):2D 足迹↔3D 帘面/体;异常属 3D。 - // 地形/测线(miscProps_)与底图(TileBasemap 自管)两边常驻、不动。 - for (auto& kv : dsProps_) { - const bool is2dContent = mapLineDs_.count(kv.first) > 0; - const bool vis = is2D ? is2dContent : !is2dContent; - for (auto& p : kv.second) - if (p) p->SetVisibility(vis ? 1 : 0); - } - for (auto& kv : anomalyProps_) - if (kv.second) kv.second->SetVisibility(is2D ? 0 : 1); // 异常=3D内容 - - // ② 取景 + 坐标轴 + 渲染统一走 render():朝向按 analysisMode2D_(已设)选近俯视/自由透视; - // ResetCamera 到"可见"数据包围盒(computeDataBounds 只计可见 prop);rebuildAxes 在二维下自移除; - // 末尾 Render + onCameraChanged(底图按新视锥重算)。不再用相机快照(陈旧易错),每次按可见内容取景。 - render(/*is2D ViewMode=*/false, /*resetCamera=*/true); -} - -// ── 二维分析改造 B 期:选中 2D 足迹沿高程 Z 拖动 ─────────────────────────────────── -void VtkSceneView::applyMapLineSelectionVisual() { - for (auto& kv : dsProps_) { - if (!mapLineDs_.count(kv.first)) continue; - const bool sel = selectedMapLines_.count(kv.first) > 0; - for (auto& p : kv.second) { - auto* a = vtkActor::SafeDownCast(p); - if (!a) continue; - if (sel) { // 选中:黄高亮 + 加粗 - a->GetProperty()->SetColor(1.0, 0.85, 0.2); - a->GetProperty()->SetLineWidth(6.0); - } else { // 未选:复原 buildMapLine 默认(橙 3.0) - a->GetProperty()->SetColor(0.95, 0.55, 0.10); - a->GetProperty()->SetLineWidth(3.0); - } - } - } -} - -void VtkSceneView::clearMapLineSelection() { - if (selectedMapLines_.empty()) return; - selectedMapLines_.clear(); - applyMapLineSelectionVisual(); - if (renderWindow_) renderWindow_->Render(); - if (onMapLineSelectionChanged) onMapLineSelectionChanged(); // VTK→列表:同步清空 -} - -std::vector VtkSceneView::selectedMapLines() const { - return std::vector(selectedMapLines_.begin(), selectedMapLines_.end()); -} - -void VtkSceneView::setSelectedMapLines(const std::vector& dsIds) { - // 列表→VTK:按 dsId 设选中(仅已渲染足迹),高亮+渲染;不回调 onMapLineSelectionChanged(防回环)。 - selectedMapLines_.clear(); - for (const auto& id : dsIds) - if (mapLineDs_.count(id)) selectedMapLines_.insert(id); - applyMapLineSelectionVisual(); - if (renderWindow_) renderWindow_->Render(); -} - -bool VtkSceneView::pickMapLineAt(int screenX, int screenY, bool additive) { - auto* ren = scene_.renderer(); - if (!ren) return false; - // 只在"可见足迹"中拾取(PickFromList):避免地形/底图/隐藏的 3D 体抢命中。 - vtkNew picker; - picker->SetTolerance(0.012); - picker->PickFromListOn(); - bool any = false; - for (auto& kv : dsProps_) { - if (!mapLineDs_.count(kv.first)) continue; - for (auto& p : kv.second) - if (p && p->GetVisibility()) { picker->AddPickList(p); any = true; } - } - if (!any) return false; // 无可见足迹 → 不拦截(交由平移) - if (!picker->Pick(screenX, screenY, 0.0, ren)) { - if (!additive) clearMapLineSelection(); // 点空白(非多选)→ 取消选中 - return false; - } - vtkProp* hit = picker->GetViewProp(); - std::string hitDs; - for (auto& kv : dsProps_) { - if (!mapLineDs_.count(kv.first)) continue; - for (auto& p : kv.second) - if (p.Get() == hit) { hitDs = kv.first; break; } - if (!hitDs.empty()) break; - } - if (hitDs.empty()) { - if (!additive) clearMapLineSelection(); - return false; - } - if (additive) { // Ctrl 多选:切换该足迹 - if (selectedMapLines_.count(hitDs)) selectedMapLines_.erase(hitDs); - else selectedMapLines_.insert(hitDs); - } else if (!selectedMapLines_.count(hitDs)) { // 单击未选中的线 → 替换为它 - selectedMapLines_.clear(); - selectedMapLines_.insert(hitDs); - } - // 单击已选中的线(可能为多选之一):保持当前选中集 → 起手即可整体拖动,不塌缩为单选。 - applyMapLineSelectionVisual(); - if (renderWindow_) renderWindow_->Render(); - if (onMapLineSelectionChanged) onMapLineSelectionChanged(); // VTK→列表:同步选中 - return !selectedMapLines_.empty(); // 有选中 → 交互样式进入 Z 拖动 -} - -void VtkSceneView::nudgeSelectedMapLinesZ(double worldDz) { - if (selectedMapLines_.empty() || worldDz == 0.0) return; - for (const auto& dsId : selectedMapLines_) { - mapLineZOffset_[dsId] += worldDz; // 持久累计(全量重建后 addMapLine 复用) - auto it = dsProps_.find(dsId); +bool VtkSceneView::datasetBounds(const std::vector& dsIds, double outB[6]) const { + // computeDataBounds 的按 dsId 版:只并集给定 dsIds 的已渲染 actor 包围盒(同样仅计可见 prop)。 + vtkBoundingBox bb; + for (const auto& id : dsIds) { + auto it = dsProps_.find(id); if (it == dsProps_.end()) continue; - for (auto& p : it->second) { - auto* a = vtkActor::SafeDownCast(p); - if (a) a->AddPosition(0.0, 0.0, worldDz); // 仅改 Z,锁 XY - } + for (const auto& p : it->second) + if (p && p->GetVisibility()) { if (double* b = p->GetBounds()) bb.AddBounds(b); } } - if (scene_.renderer()) scene_.renderer()->ResetCameraClippingRange(); // Z 抬升后防被裁剪面切 - if (renderWindow_) renderWindow_->Render(); + if (!bb.IsValid()) return false; + bb.GetBounds(outB); + return true; } -double VtkSceneView::selectedMapLineZ() const { - if (selectedMapLines_.empty()) return 0.0; - // 代表性 Z = 任一选中足迹 actor 的包围盒中心 Z(含 placement worldZ + 已累计偏移)。 - auto it = dsProps_.find(*selectedMapLines_.begin()); - if (it == dsProps_.end()) return 0.0; - for (const auto& p : it->second) - if (p) { if (double* b = p->GetBounds()) return 0.5 * (b[4] + b[5]); } - return 0.0; +void VtkSceneView::fitToBounds(const double b[6]) { + if (!scene_.renderer()) return; + scene_.renderer()->ResetCamera(b); // 保持朝向,仅重定位+缩放到该盒(区别于 fitView 的全场景) + scene_.renderer()->ResetCameraClippingRange(); // 裁剪面含底图 → 不被"蒙版"切掉 + if (renderWindow_) renderWindow_->Render(); + if (onCameraChanged) onCameraChanged(); // 相机变了 → 底图按新视锥重算覆盖 +} + +void VtkSceneView::orbitToAxis(geopro::controller::ViewDir dir, const double pivot[3]) { + auto* r = scene_.renderer(); + if (!r) return; + auto* c = r->GetActiveCamera(); + // 保留当前缩放:取现距离 d=|cam−focal|,绕 pivot 转到 dir 轴(focal=pivot、pos=pivot+off*d)。 + const double d = c->GetDistance(); + const auto pose = geopro::render::orbitPose(toRenderViewDir(dir), pivot, d); + c->SetFocalPoint(pose.focal[0], pose.focal[1], pose.focal[2]); + c->SetPosition(pose.pos[0], pose.pos[1], pose.pos[2]); + c->SetViewUp(pose.up[0], pose.up[1], pose.up[2]); + c->OrthogonalizeViewUp(); + r->ResetCameraClippingRange(); // 只转向不改距离 → 不 ResetCamera;仅扩裁剪面 + if (renderWindow_) renderWindow_->Render(); + if (onCameraChanged) onCameraChanged(); // 相机变了 → 底图按新视锥重算覆盖 +} + +void VtkSceneView::orbitToCurrentPivot(geopro::controller::ViewDir dir) { + // 支点 = 当前坐标轴盒中心(决策 5):有选中(贴合轴)→选中子树盒 fittedBounds_;否则全场景数据盒。 + double b[6]; + if (useFittedAxes_) { + for (int i = 0; i < 6; ++i) b[i] = fittedBounds_[i]; + } else if (!computeDataBounds(b)) { + return; // 无有效数据包围盒 → 无支点可绕,静默不动 + } + const double pivot[3] = {0.5 * (b[0] + b[1]), 0.5 * (b[2] + b[3]), 0.5 * (b[4] + b[5])}; + orbitToAxis(dir, pivot); // 复用 T1:绕 pivot 转到 dir 轴、保留当前缩放 +} + +void VtkSceneView::ensureGnomon() { + // 幂等装配:交互器就绪后建一次。用【专用叠加渲染器】(非 vtkOrientationMarkerWidget):图层1、固定 + // 右下角视口、InteractiveOff、透明背景 → 无 widget 外框、不可拖动/缩放;相机由 syncGnomonCamera + // 镜像主相机朝向 → gizmo 随场景旋转同步转。三轴线 + 6 方向球(仅球可拾取) + 正向 XYZ 标签。 + if (gnomonReady_ || !renderWindow_) return; + auto* iren = renderWindow_->GetInteractor(); + if (!iren) return; // QVTK 尚未提供交互器 → 下一帧 render 再补装 + + // 叠加渲染器:图层1 固定右下角(见下 SetViewport) —— 避开底部满宽沿线滑块条(仅雷达体时显示、 + // 约占底 46px)。透明背景只显 gizmo 图元;FXAA + MSAA 抗锯齿使边缘平滑;非交互不响应任何输入。 + renderWindow_->SetNumberOfLayers(2); + gnomonRenderer_ = vtkSmartPointer::New(); + gnomonRenderer_->SetLayer(1); + gnomonRenderer_->InteractiveOff(); + // 右下角、上抬避开底部满宽「沿线位置」滑块条(约占底 46px):y 从 0.10 抬到 0.15;靠右留 ~1% 边距。 + gnomonRenderer_->SetViewport(0.855, 0.15, 0.995, 0.35); + gnomonRenderer_->SetBackgroundAlpha(0.0); // 透明合成到主场景之上,无背景块 + gnomonRenderer_->SetUseFXAA(true); // FXAA + 窗口 MSAA 双重:轴线/球/字形边缘平滑 + renderWindow_->AddRenderer(gnomonRenderer_); + + // 抗锯齿(spec §7):整窗多重采样一次性开(仅当尚未开,不覆盖既有设置) → 平涂盘/白字边缘平滑。 + if (renderWindow_->GetMultiSamples() == 0) renderWindow_->SetMultiSamples(8); + + const double L = 1.0; // 球心到原点距离(= 轴线长度) + // 业界柔和轴色(非纯 RGB):X 红 / Y 绿 / Z 蓝(spec §2)。正向球=本色,负向球=本色×0.42(更暗)。 + const std::array kColX = {0.90, 0.30, 0.36}; + const std::array kColY = {0.55, 0.78, 0.33}; + const std::array kColZ = {0.28, 0.45, 0.90}; + + // 三根过原点的轴线:仅连到【正向】球,X=红 / Y=绿 / Z=蓝(平涂纯色、细、不可拾取;spec §5)。 + struct AxisLine { double to[3]; std::array col; }; + const AxisLine lines[3] = { + {{L, 0, 0}, kColX}, // X 红 + {{0, L, 0}, kColY}, // Y 绿 + {{0, 0, L}, kColZ}, // Z 蓝 + }; + for (const auto& ln : lines) { + vtkNew src; + src->SetPoint1(0.0, 0.0, 0.0); + src->SetPoint2(ln.to[0], ln.to[1], ln.to[2]); + vtkNew mapper; + mapper->SetInputConnection(src->GetOutputPort()); + vtkNew a; + a->SetMapper(mapper); + a->GetProperty()->SetColor(ln.col[0], ln.col[1], ln.col[2]); + a->GetProperty()->SetLineWidth(1.8f); // 细轴线 + a->GetProperty()->LightingOff(); // 平涂纯色,无高光 + a->SetPickable(0); // 轴线不参与拾取(仅方向球有方向语义) + gnomonRenderer_->AddViewProp(a); + } + + // 6 个方向球(平涂实心盘,非高光 3D 球):正向亮盘 + 白色 XYZ 字标、稍大;负向同色更暗、更小、无字标。 + // 方向 → ViewDir 与 CameraPreset 语义一致:+Z=Top、−Z=Bottom、+Y=Back、−Y=Front、+X=Right、−X=Left。 + struct DirSpec { + geopro::controller::ViewDir dir; + double pos[3]; + std::array base; // 该轴柔和本色 + bool positive; + const char* label; // 正向字标;负向 nullptr + }; + const DirSpec specs[6] = { + {geopro::controller::ViewDir::Right, {L, 0, 0}, kColX, true, "X"}, // +X + {geopro::controller::ViewDir::Left, {-L, 0, 0}, kColX, false, nullptr}, // −X + {geopro::controller::ViewDir::Back, {0, L, 0}, kColY, true, "Y"}, // +Y + {geopro::controller::ViewDir::Front, {0, -L, 0}, kColY, false, nullptr}, // −Y + {geopro::controller::ViewDir::Top, {0, 0, L}, kColZ, true, "Z"}, // +Z + {geopro::controller::ViewDir::Bottom, {0, 0, -L}, kColZ, false, nullptr}, // −Z + }; + gnomonDirs_.clear(); + gnomonBaseColor_.clear(); + gnomonLabels_.clear(); + for (const auto& s : specs) { + // 正向盘 r=0.32(稍大);负向盘 r=0.20(更小、更暗 → 呈"淡环/凹陷"观感,仍可拾取)。 + const double radius = s.positive ? 0.32 : 0.20; + const std::array col = + s.positive ? s.base + : std::array{s.base[0] * 0.42, s.base[1] * 0.42, s.base[2] * 0.42}; + vtkNew sphere; + sphere->SetRadius(radius); + sphere->SetThetaResolution(48); // 高分辨率 → 轮廓平滑(平涂下尤重要) + sphere->SetPhiResolution(48); + sphere->SetCenter(s.pos[0], s.pos[1], s.pos[2]); + vtkNew mapper; + mapper->SetInputConnection(sphere->GetOutputPort()); + auto actor = vtkSmartPointer::New(); + actor->SetMapper(mapper); + auto* prop = actor->GetProperty(); + prop->SetColor(col[0], col[1], col[2]); + prop->LightingOff(); // 关键(spec §1):平涂实心盘,无镜面/渐变 → 干净的 Blender 式 gizmo + actor->SetOrigin(s.pos[0], s.pos[1], s.pos[2]); // 缩放原点=球心 → hover 放大就地不位移 + actor->SetPickable(1); // 6 球均可拾取(负向点击仍 orbit 到对侧) + gnomonRenderer_->AddViewProp(actor); + gnomonDirs_[actor.Get()] = s.dir; // 渲染器持 actor 保活;此处仅记裸指针→方向 + gnomonBaseColor_[actor.Get()] = col; // 记本色 → hover 提亮后可复原 + + if (s.positive && s.label) { // 正向轴字标:始终朝相机的公告板文字,白色粗体、居中于球心 + auto lbl = vtkSmartPointer::New(); + lbl->SetInput(s.label); + lbl->SetPosition(s.pos[0], s.pos[1], s.pos[2]); + auto* tp = lbl->GetTextProperty(); + tp->SetFontSize(20); + tp->SetBold(true); + tp->SetColor(1.0, 1.0, 1.0); // 白字 + tp->SetJustificationToCentered(); + tp->SetVerticalJustificationToCentered(); + lbl->SetPickable(0); + gnomonRenderer_->AddViewProp(lbl); + gnomonLabels_.push_back({lbl.Get(), + {s.pos[0], s.pos[1], s.pos[2]}}); // syncGnomonCamera 推到球前避遮挡 + } + } + + gnomonPicker_ = vtkSmartPointer::New(); + + // 左键高优先级(1.0)观察者:先于交互样式(0.0),命中方向球 → orbit + abort 消费(阻止相机旋转/拾取)。 + gnomonClickCmd_ = vtkSmartPointer::New(); + gnomonClickCmd_->SetClientData(this); + gnomonClickCmd_->SetCallback([](vtkObject*, unsigned long, void* client, void*) { + static_cast(client)->handleGnomonClick(); + }); + gnomonClickTag_ = iren->AddObserver(vtkCommand::LeftButtonPressEvent, gnomonClickCmd_, 1.0); + + // 鼠标移动高优先级观察者:仅角落内拾取做 hover 高亮,永不 abort → 不阻塞场景旋转/平移/切片交互。 + gnomonHoverCmd_ = vtkSmartPointer::New(); + gnomonHoverCmd_->SetClientData(this); + gnomonHoverCmd_->SetCallback([](vtkObject*, unsigned long, void* client, void*) { + static_cast(client)->handleGnomonHover(); + }); + gnomonHoverTag_ = iren->AddObserver(vtkCommand::MouseMoveEvent, gnomonHoverCmd_, 1.0); + + // 相机同步:观察主相机 ModifiedEvent,每次朝向变化把 gizmo 相机镜像到同朝向 → gizmo 随场景转。 + gnomonCamCmd_ = vtkSmartPointer::New(); + gnomonCamCmd_->SetClientData(this); + gnomonCamCmd_->SetCallback([](vtkObject*, unsigned long, void* client, void*) { + static_cast(client)->syncGnomonCamera(); + }); + if (auto* mainCam = scene_.renderer() ? scene_.renderer()->GetActiveCamera() : nullptr) { + gnomonObservedCam_ = mainCam; + gnomonCamTag_ = mainCam->AddObserver(vtkCommand::ModifiedEvent, gnomonCamCmd_); + } + syncGnomonCamera(); // 初始一次:装配即与当前朝向对齐 + + gnomonReady_ = true; +} + +void VtkSceneView::syncGnomonCamera() { + if (!gnomonRenderer_ || !scene_.renderer()) return; + auto* mainCam = scene_.renderer()->GetActiveCamera(); + auto* gcam = gnomonRenderer_->GetActiveCamera(); + if (!mainCam || !gcam) return; + // 复制主相机投影方向 + view-up:gizmo 相机置于 -dir*dist、焦点在原点 → 与主相机同朝向看向 gizmo。 + double dir[3]; + mainCam->GetDirectionOfProjection(dir); // 已归一化(F−P),指向场景内(背离相机) + double up[3]; + mainCam->GetViewUp(up); + const double dist = 10.0; + gcam->SetParallelProjection(1); // 正交投影:gizmo 无透视畸变(业界标准) + // 按视口像素长宽比自适应取景半高:balls 到 ±(L+r)≈1.32 + 白字留边 → halfExtent=1.5。 + // parallelScale = 视口世界半高;水平可见半宽 = scale×aspect。取 scale=halfExtent/min(1,aspect) + // 保证长/宽两向都容得下所有球 → 任意窗口长宽比不裁切(视口归一化随窗拉伸也不失效)。 + const double halfExtent = 1.5; + double aspect = 1.0; + const int* wsz = renderWindow_->GetSize(); + const double* vp = gnomonRenderer_->GetViewport(); + if (wsz && wsz[0] > 0 && wsz[1] > 0) { + const double vpH = (vp[3] - vp[1]) * wsz[1]; + if (vpH > 0.0) aspect = (vp[2] - vp[0]) * wsz[0] / vpH; + } + gcam->SetParallelScale(halfExtent / std::min(1.0, aspect)); + gcam->SetFocalPoint(0.0, 0.0, 0.0); + gcam->SetPosition(-dir[0] * dist, -dir[1] * dist, -dir[2] * dist); + gcam->SetViewUp(up[0], up[1], up[2]); + gnomonRenderer_->ResetCameraClippingRange(); + + // 正向白字标签推到球【前】(朝相机 = −dir 方向、偏移略大于球半径):billboard 恒面相机,位于球前 + // → 不被球面前半遮挡,读数清晰。相机每次转动都随之更新前向偏移。 + const double front = 0.40; // > 正向球半径 0.32 → 字浮于球前表面之外 + for (const auto& lp : gnomonLabels_) { + if (!lp.first) continue; + lp.first->SetPosition(lp.second[0] - dir[0] * front, lp.second[1] - dir[1] * front, + lp.second[2] - dir[2] * front); + } +} + +void VtkSceneView::handleGnomonClick() { + if (!gnomonRenderer_ || !gnomonPicker_ || !renderWindow_) return; + auto* iren = renderWindow_->GetInteractor(); + if (!iren) return; + const int ex = iren->GetEventPosition()[0]; + const int ey = iren->GetEventPosition()[1]; + // 仅当点击落在 gnomon 角落视口矩形内才拾取(否则放行正常场景交互,且省去全场景每次左键的硬件拾取)。 + const double* vp = gnomonRenderer_->GetViewport(); // 归一化 [xmin,ymin,xmax,ymax] + const int* sz = renderWindow_->GetSize(); + if (sz[0] <= 0 || sz[1] <= 0) return; + const double fx = static_cast(ex) / sz[0]; + const double fy = static_cast(ey) / sz[1]; + if (fx < vp[0] || fx > vp[2] || fy < vp[1] || fy > vp[3]) return; // 不在角落 → 不 abort,放行 + // 角落内硬件拾取(仅方向球可拾):命中某方向球 → 取其 ViewDir → 绕当前轴盒中心转到该轴(保留缩放)。 + if (gnomonPicker_->PickProp(ex, ey, gnomonRenderer_)) { + vtkProp* leaf = gnomonPicker_->GetViewProp(); // 叠加渲染器内为裸 actor,直取即方向球 + auto it = gnomonDirs_.find(leaf); + if (it != gnomonDirs_.end()) { + orbitToCurrentPivot(it->second); + if (gnomonClickCmd_) gnomonClickCmd_->SetAbortFlag(1); // 命中才消费:不触发相机旋转/场景拾取 + } + } + // 未命中球 → 不 abort:左键继续走正常交互(旋转/平移/缩放/切片/拾取),保证非干扰。 +} + +void VtkSceneView::handleGnomonHover() { + // 非阻塞 hover 高亮:绝不 SetAbortFlag → 鼠标移动照常传给交互样式(旋转/平移/切片)。 + if (!gnomonRenderer_ || !gnomonPicker_ || !renderWindow_) return; + auto* iren = renderWindow_->GetInteractor(); + if (!iren) return; + + // 提亮本色(向白混 0.42);复原用记录的本色。二者共用,避免重复。 + auto applyColor = [](vtkProp* p, const std::array& c, bool highlight, double scale) { + auto* a = vtkActor::SafeDownCast(p); + if (!a) return; + if (highlight) { + a->GetProperty()->SetColor(c[0] * 0.58 + 0.42, c[1] * 0.58 + 0.42, c[2] * 0.58 + 0.42); + a->SetScale(scale); // 就地放大(origin=球心) + } else { + a->GetProperty()->SetColor(c[0], c[1], c[2]); + a->SetScale(1.0); + } + }; + auto restore = [&]() { + if (!gnomonHovered_) return; + auto it = gnomonBaseColor_.find(gnomonHovered_); + if (it != gnomonBaseColor_.end()) applyColor(gnomonHovered_, it->second, false, 1.0); + gnomonHovered_ = nullptr; + }; + + const int ex = iren->GetEventPosition()[0]; + const int ey = iren->GetEventPosition()[1]; + const double* vp = gnomonRenderer_->GetViewport(); // 归一化 [xmin,ymin,xmax,ymax] + const int* sz = renderWindow_->GetSize(); + if (sz[0] <= 0 || sz[1] <= 0) { restore(); return; } + const double fx = static_cast(ex) / sz[0]; + const double fy = static_cast(ey) / sz[1]; + if (fx < vp[0] || fx > vp[2] || fy < vp[1] || fy > vp[3]) { // 光标离开角落 → 复原并跳过拾取(廉价) + if (gnomonHovered_) { restore(); renderWindow_->Render(); } + return; + } + // 角落内才做硬件拾取:命中方向球 → 高亮该球、复原旧的;未命中 → 复原。 + vtkProp* hit = nullptr; + if (gnomonPicker_->PickProp(ex, ey, gnomonRenderer_)) { + vtkProp* leaf = gnomonPicker_->GetViewProp(); + if (gnomonBaseColor_.find(leaf) != gnomonBaseColor_.end()) hit = leaf; + } + if (hit == gnomonHovered_) return; // 无变化 → 不重绘 + restore(); + if (hit) { + auto it = gnomonBaseColor_.find(hit); + if (it != gnomonBaseColor_.end()) applyColor(hit, it->second, true, 1.18); + gnomonHovered_ = hit; + } + renderWindow_->Render(); // 高亮/复原变更 → 立即刷新 } void VtkSceneView::rebuildAxes() { @@ -555,13 +773,15 @@ void VtkSceneView::rebuildAxes() { scene_.renderer()->RemoveViewProp(currentAxes_); currentAxes_ = nullptr; } - // 二维分析无立体坐标轴:任何渲染路径(全量/增量/切模式)走到此都不重建坐标轴, - // 保证切到二维分析后坐标轴消失、且后续增量渲染不把它带回来。 - if (analysisMode2D_) return; // 坐标轴随数据包围盒重建:仅按数据图元算 bounds(不含底图,否则被~公里级底图撑大), // 再造 vtkCubeAxesActor 入场。None 模式或无数据 → buildAxes 返回 nullptr,场景无坐标轴。 + // 贴合态(useFittedAxes_):改用选中子树盒 fittedBounds_,只框该子树而非全场景(spec §3.2)。 double bounds[6]; - if (!computeDataBounds(bounds)) return; // 无数据 → 不建坐标轴 + if (useFittedAxes_) { + for (int i = 0; i < 6; ++i) bounds[i] = fittedBounds_[i]; + } else if (!computeDataBounds(bounds)) { + return; // 无数据 → 不建坐标轴 + } geopro::render::AxesOptions opts; opts.mode = toRenderMode(axesMode_); opts.unit = toRenderUnit(axesUnit_); @@ -580,7 +800,23 @@ void VtkSceneView::rebuildAxes() { } } +void VtkSceneView::showFittedAxes(const double b[6]) { + // 选中子树盒 → 冻结为贴合轴 bounds,隐去全场景轴(rebuildAxes 会先移除旧轴再按 fittedBounds_ 重建)。 + useFittedAxes_ = true; + for (int i = 0; i < 6; ++i) fittedBounds_[i] = b[i]; + rebuildAxes(); + if (renderWindow_) renderWindow_->Render(); +} + +void VtkSceneView::showSceneAxes() { + // 取消选中 → 复位为全场景总览轴(现状默认)。清掉贴合态后 rebuildAxes 走 computeDataBounds。 + useFittedAxes_ = false; + rebuildAxes(); + if (renderWindow_) renderWindow_->Render(); +} + void VtkSceneView::render(bool is2D, bool resetCamera) { + ensureGnomon(); // 构造时交互器未就绪则于此补装(幂等) // 视图区背景永远深色(规范 §0.5:不随明暗切换),让色阶数据更突出。 double bgR, bgG, bgB; geopro::app::vtkBackground(bgR, bgG, bgB); @@ -588,12 +824,9 @@ void VtkSceneView::render(bool is2D, bool resetCamera) { // 坐标轴仅三维视图显示(2D 俯视测线不需要立体坐标轴)。 if (!is2D) rebuildAxes(); // 相机预设(朝向)只在取景时应用——保留相机的重建(改放大系数)不重设朝向,否则也会跳视角。 - // 朝向优先看二维分析模式(本期 A):处二维分析→近俯视;否则按 ViewMode(旧 Map2D 正俯视/三维自由)。 - // 这样 VE 改/项目切等全量重建在二维分析下仍保持近俯视,不跳回三维自由视角。 + // 朝向按 is2D:俯视(Map2D)/三维自由透视。 if (resetCamera) { - if (analysisMode2D_) - geopro::render::applyNearTop2D(scene_.renderer()); - else if (is2D) + if (is2D) geopro::render::applyTop2D(scene_.renderer()); else geopro::render::applyFree3D(scene_.renderer()); @@ -609,6 +842,7 @@ void VtkSceneView::render(bool is2D, bool resetCamera) { } void VtkSceneView::renderIncremental() { + ensureGnomon(); // 幂等:交互器就绪后补装角落方向标 // 增量渲染:仅按新包围盒重建坐标轴并提交,不动相机(勾选/取消时视角不跳)。 rebuildAxes(); scene_.renderer()->ResetCameraClippingRange(); // 数据/底图变化后扩裁剪面,防被切 diff --git a/src/app/VtkSceneView.hpp b/src/app/VtkSceneView.hpp index abd63d1..748a53c 100644 --- a/src/app/VtkSceneView.hpp +++ b/src/app/VtkSceneView.hpp @@ -1,9 +1,11 @@ #pragma once +#include #include #include #include #include #include +#include #include #include @@ -20,6 +22,10 @@ class vtkRenderWindow; class vtkProp; class vtkActor; class vtkVolume; +class vtkPropPicker; +class vtkCallbackCommand; +class vtkCamera; +class vtkBillboardTextActor3D; namespace geopro::app { @@ -32,6 +38,7 @@ public: // 入参生命周期须覆盖本对象(由调用方保证)。zRefElev:地形 z 基准(测线地表高程)。 VtkSceneView(geopro::render::Scene& scene, vtkRenderWindow* renderWindow, std::shared_ptr frame, double zRefElev); + ~VtkSceneView() override; // 摘除 gnomon 左键/相机观察者(clientData=this),移除叠加渲染器 void clear() override; void setVerticalExaggeration(double ve) override; @@ -46,6 +53,7 @@ public: const geopro::core::ColorScale& cs) override; void addMapLine(const std::string& dsId, const geopro::data::MapLine& line, double worldZ) override; + void setMapLinesZ(const std::vector& dsIds, double z) override; void addTerrain(const geopro::data::TerrainPaths& paths) override; void removeDataset(const std::string& dsId) override; void addAnomaly(const geopro::core::Anomaly& a) override; @@ -61,6 +69,24 @@ public: void applyCameraView(geopro::controller::ViewDir dir) override; void zoom(double factor) override; void fitView() override; + + // ── 视图导航基元(spec §3.1;T1)────────────────────────────────────────────── + // 给定 dsIds 的已渲染 actor 世界包围盒并集;无有效返回 false,否则填 out=[xmin,xmax,…,zmax]。 + bool datasetBounds(const std::vector& dsIds, double outB[6]) const; + // 相机适配到指定包围盒,保持当前朝向(ResetCamera(b)),用于双击适配/贴合。 + void fitToBounds(const double b[6]); + // 绕 pivot 转到沿 dir 轴看向 pivot,保留当前 focal-to-camera 距离(缩放不变)。 + void orbitToAxis(geopro::controller::ViewDir dir, const double pivot[3]); + // ── 贴合轴 / 全景轴(spec §3.2;T2)─────────────────────────────────────────── + // 选中某 ds → 用其子树盒 b 显示贴合 cube axes、隐去全场景总览轴(立即提交渲染)。 + void showFittedAxes(const double b[6]); + // 取消选中 → 恢复全场景总览轴(现状默认行为,立即提交渲染)。 + void showSceneAxes(); + // ── 可点击方向标 gnomon(spec §3.3;T3)───────────────────────────────────────── + // 绕【当前坐标轴盒中心】转到 dir 轴、保留当前缩放:支点 = 有选中(useFittedAxes_)→选中子树盒 + // fittedBounds_ 中心,否则全场景数据盒 computeDataBounds 中心。无有效数据 → no-op。 + // 封装决策 5 的支点规则,调用方只需给方向(角落 gnomon 点击即调此)。 + void orbitToCurrentPivot(geopro::controller::ViewDir dir); void render(bool is2D, bool resetCamera = true) override; void renderIncremental() override; @@ -87,13 +113,6 @@ public: // 相机程序化变化(取景/预设/缩放)后回调,供底图按新视锥重算覆盖(否则首帧部分瓦片要手动微动才出)。 std::function onCameraChanged; - // ── 二维分析改造 A 期:一场景两相机 ────────────────────────────────────────── - // 切「二维分析」(is2D=true):相机锁近俯视、显 2D 足迹/隐 3D(体/帘面/异常);切回反之。 - // 只翻 actor 可见标志(不清空、不重建)→ 切换瞬时、零重插值。地形/底图常驻不动。 - // 切片显隐 + 交互锁由 InteractionManager::setMode2D 配合(上层在同一处调两者)。 - void setAnalysisMode2D(bool is2D); - bool isAnalysisMode2D() const { return analysisMode2D_; } - // ── B 方案#2:沿线位置巡航(雷达超长测线)────────────────────────────────────── // t∈[0,1] 沿数据【最长轴】定位;取景到该位置一段【窗口】(windowFrac=窗口占长轴比例), // 保持当前朝向(ResetCamera 只重定位+缩放、不转向)→ 像滚动读长 radargram。短轴满幅、长轴只取一段。 @@ -101,22 +120,6 @@ public: // 数据包围盒长短轴比(max/min 跨度)。用于判是否细长(雷达)→ 决定沿线滑块显隐。无数据返回 0。 double longAxisElongation() const; - // ── 二维分析改造 B 期:选中 2D 足迹沿高程 Z 拖动 ─────────────────────────────── - // 仅二维分析下用。pickMapLineAt:在屏幕(x,y)拾取足迹(只考虑可见足迹,不被地形/底图干扰);命中则 - // 选中(additive=Ctrl 多选切换,否则单选替换)并高亮,返回是否有选中(交互样式据此决定 Z 拖动/平移)。 - // nudgeSelectedMapLinesZ:选中足迹世界 Z += worldDz(锁 XY);偏移按 dsId 持久(切走再回/全量重建保留)。 - // selectedMapLineZ:代表性当前世界 Z(高程读数浮层用);无选中返回 0。 - bool pickMapLineAt(int screenX, int screenY, bool additive); - void clearMapLineSelection(); - bool hasMapLineSelection() const { return !selectedMapLines_.empty(); } - void nudgeSelectedMapLinesZ(double worldDz); - double selectedMapLineZ() const; - // 双向选择联动:列表↔VTK。selectedMapLines 取当前选中 dsId;setSelectedMapLines 由列表设置选中 - // (高亮,不回调,避免环)。VTK 内拾取改变选中时触发 onMapLineSelectionChanged → 上层同步列表。 - std::vector selectedMapLines() const; - void setSelectedMapLines(const std::vector& dsIds); - std::function onMapLineSelectionChanged; - private: // 首个带经纬数据(剖面/足迹)到达时把共享 frame 重锚到其 lat/lon 包围盒中心:使数据落在世界原点近旁 // (否则样本默认原点可能离真实数据数百公里→图元在视锥外、移动视角也找不到)。已锚或无经纬则跳过。 @@ -126,6 +129,20 @@ private: void removeProps(std::vector>& props); // 从 renderer 移除并清空 // 仅数据图元(剖面/体素/地形/测线)的包围盒,不含底图 → 坐标轴/取景不被~公里级底图撑大。 bool computeDataBounds(double out[6]) const; + // 角落可点击方向标 gnomon(T3):首次(交互器就绪)时装配【专用叠加渲染器】(图层1、固定右下角、 + // 非交互无边框) + 三轴线 + 6 向可拾取球 + 正向标签 + 左键/相机观察者。 + // 幂等:装配后置 gnomonReady_,重复调直接返回。render/renderIncremental/构造均可安全调用。 + void ensureGnomon(); + // 左键按下高优先级(先于交互样式)回调:点在 gnomon 角落视口且命中方向球 → orbitToCurrentPivot + abort + // (消费事件,阻止相机旋转/场景拾取);否则不 abort,放行正常交互(旋转/平移/缩放/切片/拾取)。 + void handleGnomonClick(); + // 鼠标移动(非 abort、不阻塞场景交互)回调:仅当光标落在 gnomon 角落视口内才拾取,命中方向球 → + // 高亮(提亮本色 + 放大 ~1.18×),复原其余;离开角落或未命中 → 复原全部。picking 只在角落内进行(廉价)。 + void handleGnomonHover(); + // 把主相机朝向(投影方向 + view-up)镜像到 gnomon 叠加渲染器相机(定距、焦点在 gizmo 原点), + // 使 gizmo 随场景旋转同步转。主相机 ModifiedEvent 观察者与初始装配各调一次。 + // 同时按视口像素长宽比自适应取景半高(球始终不裁切) + 把正向标签推到球前(朝相机)避免被球面遮挡。 + void syncGnomonCamera(); public: // 当前所有数据图元(剖面等)合并范围的水平半径(米);无数据返回 0。供底图动态定最大范围。 @@ -150,6 +167,10 @@ private: // 当前坐标轴 prop:render 可能多次调用 rebuildAxes(rebuild 末尾 + 异步回灌), // 持引用以便重建前移除旧 prop,避免叠加(评审 HIGH)。 vtkSmartPointer currentAxes_; + // 贴合轴态(T2):true=坐标轴按 fittedBounds_(选中子树盒)建,非全场景数据包围盒;选中时冻结该盒, + // 取消/清场复位为 false(走全场景 computeDataBounds)。 + bool useFittedAxes_ = false; + double fittedBounds_[6] = {0, 0, 0, 0, 0, 0}; // 当前体素 image + 色阶(P3 切片附着源);无体素时为空。「当前」=最后添加/活动的体(保存切片/ // 创建异常的默认归属)。多体并发下各体 image 另存 volumes_。 @@ -183,15 +204,32 @@ private: std::string volumeOwnerDs_; // 当前 currentVolumeImage_ 归属的 ds(其被移除时置空切片源) std::map> anomalyProps_; // 异常 id → 3D actor - // ── 二维分析改造 A 期 ── - // 哪些 dsProps_ 条目是 2D 足迹(addMapLine):切 tab 按此区分维度翻可见(其余 dsProps_=帘面/体=3D)。 + // 哪些 dsProps_ 条目是 2D 足迹(addMapLine):供足迹 actor 归属识别(Task E2/F2 用)。 std::set mapLineDs_; - bool analysisMode2D_ = false; // 当前是否处二维分析(默认三维:启动在「三维分析」tab) - // B 期:选中的足迹 dsId(Z 拖动目标) + 各足迹累计 Z 偏移(持久,全量重建后 addMapLine 复用)。 - std::set selectedMapLines_; - std::map mapLineZOffset_; - void applyMapLineSelectionVisual(); // 选中足迹加粗变亮、其余复原(橙 3.0) + // ── 可点击方向标 gnomon(T3)────────────────────────────────────────────────── + // 专用叠加渲染器:图层1、固定右下角视口、InteractiveOff、透明背景、无边框 —— 不是 widget, + // 故无外框、不可拖动/缩放;相机由 syncGnomonCamera 镜像主相机朝向 → gizmo 随场景转。 + // gnomonPicker_ 在此渲染器上做硬件拾取(仅方向球可拾取)。 + vtkSmartPointer gnomonRenderer_; + vtkSmartPointer gnomonPicker_; + vtkSmartPointer gnomonClickCmd_; // 左键观察者命令(可条件 SetAbortFlag 消费) + unsigned long gnomonClickTag_ = 0; // 左键观察者句柄(析构时摘除) + vtkSmartPointer gnomonCamCmd_; // 主相机 ModifiedEvent 观察者命令 + unsigned long gnomonCamTag_ = 0; // 相机观察者句柄(析构时摘除) + vtkCamera* gnomonObservedCam_ = nullptr; // 被观察的主相机(非拥有;析构摘观察者用) + bool gnomonReady_ = false; // 已装配(幂等 ensureGnomon) + // 6 个方向球 actor → ViewDir 映射(拾取命中球 → 该方向)。actor 由叠加渲染器持有保活。 + std::map gnomonDirs_; + + // ── hover 高亮(spec §6)───────────────────────────────────────────────────── + vtkSmartPointer gnomonHoverCmd_; // 鼠标移动观察者命令(不 abort,非阻塞) + unsigned long gnomonHoverTag_ = 0; // 移动观察者句柄(析构摘除) + vtkProp* gnomonHovered_ = nullptr; // 当前高亮的方向球(裸指针,renderer 保活) + std::map> gnomonBaseColor_; // 各球本色(hover 复原用) + // 正向标签(白字) + 其球心:每次 syncGnomonCamera 把标签推到球前(朝相机)→ 不被球面遮挡。 + // raw ptr 非拥有,由叠加渲染器持有保活(与 gnomonDirs_ 同生命周期约定)。 + std::vector>> gnomonLabels_; }; } // namespace geopro::app diff --git a/src/app/VtkViewToolbar.cpp b/src/app/VtkViewToolbar.cpp index 95c766b..99d2c8c 100644 --- a/src/app/VtkViewToolbar.cpp +++ b/src/app/VtkViewToolbar.cpp @@ -3,11 +3,13 @@ #include #include #include +#include #include #include #include #include #include +#include #include "Glyphs.hpp" #include "Theme.hpp" @@ -52,9 +54,28 @@ VtkViewToolbar::VtkViewToolbar(QWidget* parent) : QWidget(parent) { col->addWidget(line); }; - // ── 段1:设置(坐标轴)── + // ── 段1:设置(坐标轴)/ 底图 ── connect(iconBtn(Glyph::Gear, QStringLiteral("坐标轴设置")), &QToolButton::clicked, this, &VtkViewToolbar::axesSettingsRequested); + // 共享 3D 底图控件:天地图/无 + 透明度滑块(spec §7.5/§9.1,从数据集栏移至渲染区工具条,全局唯一)。 + { + auto* mapBtn = iconBtn(Glyph::Map, QStringLiteral("底图")); + mapBtn->setPopupMode(QToolButton::InstantPopup); + auto* menu = new QMenu(mapBtn); + menu->addAction(QStringLiteral("天地图"), this, [this] { emit basemapKindChanged(0); }); + menu->addAction(QStringLiteral("无"), this, [this] { emit basemapKindChanged(1); }); + menu->addSeparator(); + auto* wa = new QWidgetAction(menu); + auto* sld = new QSlider(Qt::Horizontal, menu); + sld->setRange(0, 100); + sld->setValue(50); + sld->setToolTip(QStringLiteral("底图透明度")); + connect(sld, &QSlider::valueChanged, this, + [this](int v) { emit basemapOpacityChanged(v / 100.0); }); + wa->setDefaultWidget(sld); + menu->addAction(wa); + mapBtn->setMenu(menu); + } sep(); // ── 段2:快捷视图(前/后/上/下/左/右)── struct V { @@ -67,7 +88,6 @@ VtkViewToolbar::VtkViewToolbar(QWidget* parent) : QWidget(parent) { const ViewDir d = v.d; auto* b = textBtn(QString::fromUtf8(v.t)); connect(b, &QToolButton::clicked, this, [this, d] { emit viewRequested(d); }); - viewDirButtons_.push_back(b); // 二维分析下禁用(会改朝向、破坏近俯视锁定) } sep(); // ── 段3:缩放 / 复位 ──(三维体不透明度已移交色阶「不透明度」,旧「透」滑块退役移除) @@ -89,12 +109,4 @@ VtkViewToolbar::VtkViewToolbar(QWidget* parent) : QWidget(parent) { adjustSize(); } -void VtkViewToolbar::setAnalysisMode2D(bool is2D) { - for (auto* b : viewDirButtons_) { - if (!b) continue; - b->setEnabled(!is2D); - b->setToolTip(is2D ? QStringLiteral("二维分析下不可用(已锁定近俯视)") : QString()); - } -} - } // namespace geopro::app diff --git a/src/app/VtkViewToolbar.hpp b/src/app/VtkViewToolbar.hpp index 875dc77..0eb00b9 100644 --- a/src/app/VtkViewToolbar.hpp +++ b/src/app/VtkViewToolbar.hpp @@ -1,6 +1,5 @@ #pragma once #include -#include #include "I3dSceneView.hpp" // geopro::controller::ViewDir class QToolButton; @@ -9,27 +8,21 @@ class QLabel; namespace geopro::app { -// VTK 画布竖排工具条(spec §9):全局视图控制——设置(坐标轴)/前后上下左右/放大缩小复位。 -// 仅发信号,不认 VTK;由 main 接到场景控制器。 +// VTK 画布竖排工具条(spec §9):全局视图控制——设置(坐标轴)/底图/前后上下左右/放大缩小复位。 +// 仅发信号,不认 VTK;由 main 接到场景控制器与共享 3D 底图。 class VtkViewToolbar : public QWidget { Q_OBJECT public: explicit VtkViewToolbar(QWidget* parent = nullptr); -public slots: - // 二维分析激活时禁用不适用的工具:6 向快捷视图会改相机朝向→破坏二维近俯视锁定,故二维下禁用; - // 缩放/适配/坐标轴设置(含 VE)仍可用。切回三维恢复。 - void setAnalysisMode2D(bool is2D); - signals: void axesSettingsRequested(); // 设置 → 弹 AxesSettingsDialog + void basemapKindChanged(int kind); // 底图类型:0 天地图 / 1 无 + void basemapOpacityChanged(double o); // 底图透明度:0..1 void viewRequested(geopro::controller::ViewDir dir); // 前/后/上/下/左/右 void zoomInRequested(); void zoomOutRequested(); void fitRequested(); // 复位=适配 - -private: - std::vector viewDirButtons_; // 6 向快捷视图按钮:二维分析下禁用 }; } // namespace geopro::app diff --git a/src/app/main.cpp b/src/app/main.cpp index 1a4b111..49194ff 100644 --- a/src/app/main.cpp +++ b/src/app/main.cpp @@ -98,8 +98,8 @@ #include "ApiClient.hpp" #include "AuthService.hpp" -#include "DatasetDimension.hpp" #include "DatasetCategory.hpp" +#include "repo/CategoryDescriptor.hpp" #include "Credential.hpp" #include "Glyphs.hpp" #include "Logging.hpp" @@ -151,6 +151,7 @@ #include "panels/DatasetAttrPanel.hpp" #include "panels/ObjectExceptionPanel.hpp" #include "TileBasemap.hpp" +#include "TileBasemapPlaneAdapter.hpp" #include "panels/columns/ColumnDrawer.hpp" #include "panels/columns/CategoryAnalysisTab.hpp" #include "panels/columns/CategorySection.hpp" @@ -158,7 +159,6 @@ #include "AxesSettingsDialog.hpp" #include "AxesSettingsPanel.hpp" #include "repo/DatasetFieldDictionary.hpp" -#include "panels/columns/Column2DDataset.hpp" #include "CameraPreset.hpp" #include "ColorLutBuilder.hpp" @@ -494,27 +494,6 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re // 子视图(表头+左树+画布)并 raise 到顶层;保存阶段显示,完成即撤。复用于其他需要整窗等待的场景。 auto* vtkLoading = new geopro::app::LoadingOverlay(centerWidget); - // ── 二维分析 B 期:高程 Z 拖动读数浮层(顶部居中)+ 足迹拾取/拖动回调注入交互样式 ────────── - // 拖动选中足迹时显示其当前世界 Z,松开隐藏;不挡画布鼠标。深底方角(同异常提示坑规避)。 - auto* elevHint = new QLabel(vtkWidget); - elevHint->setObjectName(QStringLiteral("elevHint")); - elevHint->setAttribute(Qt::WA_TransparentForMouseEvents); - geopro::app::applyTokenizedStyleSheet( - elevHint, QStringLiteral("QLabel#elevHint{background:#0E1A2D;color:#E6ECF5;" - "border:1px solid {{accent/primary}};padding:6px 12px;}")); - elevHint->hide(); - // 滚轮升降时读数浮层 1.2s 后自动隐藏(拖动则在松开时隐藏)。 - auto* zHideTimer = new QTimer(vtkWidget); - zHideTimer->setSingleShot(true); - QObject::connect(zHideTimer, &QTimer::timeout, elevHint, [elevHint]() { elevHint->hide(); }); - auto showZReadout = std::make_shared>([sceneView, elevHint, vtkWidget]() { - elevHint->setText( - QStringLiteral("高程 Z:%1 m").arg(sceneView->selectedMapLineZ(), 0, 'f', 1)); - elevHint->adjustSize(); - elevHint->move((vtkWidget->width() - elevHint->width()) / 2, 12); // 顶部居中 - elevHint->show(); - elevHint->raise(); - }); // ── B 方案#2:雷达沿线位置滑块(超长测线巡航)──────────────────────────────────── // 拖动 → 相机沿数据最长轴 dolly 到该位置的一段窗口(focusAlongLongAxis)。仅细长(雷达)体显示。 auto* alongLineBar = new QWidget(vtkWidget); @@ -540,48 +519,22 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re auto* alongLineOverlay = new BottomBarOverlay(alongLineBar, vtkWidget); QObject::connect(alongLineSlider, &QSlider::valueChanged, vtkWidget, [sceneView](int v) { sceneView->focusAlongLongAxis(v / 1000.0, 0.12); }); - // 显隐刷新:仅三维分析 + 细长(长短轴比≥4,即雷达)体时显示沿线滑块。 + // 显隐刷新:仅当场景中实际渲染了雷达三维体(StoredVolume 带 linePrefix)时显示沿线滑块。 + // 旧逻辑靠数据包围盒长短轴比≥4 判定,统一自由场景下细长的非雷达数据(2D 轨迹线/反演帘面/ + // 普通体)会撑大合并包围盒→误触发。改按「已渲染体中是否存在雷达体」门控(雷达体取消勾选/移除 + // 后 onVolumeChanged 会重评并隐藏)。 auto refreshAlongLineBar = std::make_shared>( - [sceneView, alongLineBar, alongLineOverlay]() { - const bool show = !sceneView->isAnalysisMode2D() && sceneView->longAxisElongation() >= 4.0; + [sceneView, scene3dRepo, alongLineBar, alongLineOverlay]() { + bool show = false; + for (const auto& kv : sceneView->volumes()) + if (scene3dRepo->isRadarVolume(kv.first)) { + show = true; + break; + } alongLineBar->setVisible(show); if (show) alongLineOverlay->reposition(); }); - - if (auto* style = interactionMgr->pickStyle()) { - // 命中可见足迹→选中(Ctrl 多选)并返回是否进入 Z 拖动;未命中(返回 false)→交互样式回退平移。 - style->onPick2D = [sceneView](int x, int y, bool additive) { - return sceneView->pickMapLineAt(x, y, additive); - }; - // 拖动中:施加世界 Z 增量(仅改 Z),并把选中足迹当前高程显示在顶部读数浮层。 - style->onDrag2D = [sceneView, showZReadout](double worldDz) { - sceneView->nudgeSelectedMapLinesZ(worldDz); - (*showZReadout)(); - }; - style->onDrag2DEnd = [elevHint]() { elevHint->hide(); }; - // 滚轮升降:有选中足迹则施加 Z 增量并显示读数(1.2s 后自动隐藏),返回 true 消费滚轮;否则缩放。 - style->onWheel2D = [sceneView, showZReadout, zHideTimer](double worldDz) { - if (!sceneView->hasMapLineSelection()) return false; - sceneView->nudgeSelectedMapLinesZ(worldDz); - (*showZReadout)(); - zHideTimer->start(1200); - return true; - }; - } - // 双向选择联动:列表行选中 ↔ VTK 足迹高亮。两向各自屏蔽回环(setSelectedMapLines 不回调、 - // setSelectedDsIds 屏蔽信号),故无需额外守卫。 - QObject::connect(drawer->col2D(), &geopro::app::Column2DDataset::selectedDatasetsChanged, &window, - [sceneView](const QStringList& ids) { - std::vector v; - for (const QString& s : ids) v.push_back(s.toStdString()); - sceneView->setSelectedMapLines(v); - }); - sceneView->onMapLineSelectionChanged = [sceneView, drawer]() { - QStringList ids; - for (const std::string& s : sceneView->selectedMapLines()) - ids << QString::fromStdString(s); - drawer->col2D()->setSelectedDsIds(ids); - }; + // 双向选择联动(B2:去 col2D 后由统一段 datasetSelected 承担 2D 选中,此处旧 col2D 链已移除)。 // 坐标轴设置抽屉面板:叠加 vtkWidget、工具条右侧滑出,默认隐藏(点设置 toggle)。 auto* axesPanel = new geopro::app::AxesSettingsPanel(vtkWidget); @@ -662,7 +615,7 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re // (设计 §2.1:三维分析栏按 对象/三维体模型/切片 三级树),归三维分析栏(非数据集栏)。 // 后端列表每次勾选测线全量覆盖,故客户端体单独维护、刷新时合并注入(不被冲掉)。 // 三维分析数据源 = 最近对象树勾选拉取的 ds + 客户端三维体(mock) + 已保存切片; - // splitByCategory 后注入 5 段(电阻率/视电阻率/瞬变/三维体/切片);二维(足迹)经 dim2D 仍走 col2D。 + // splitByCategory 后注入各段(电阻率/视电阻率/瞬变/三维体/轨迹);二维(足迹/轨迹)并入同一单列(B2)。 auto lastSourceRows = std::make_shared>(); auto lastStructNodes = std::make_shared>(); // 生成位置候选(项目内 GS/TM) auto refreshAnalysis = [drawer, scene3dRepo, lastSourceRows, lastStructNodes]() { @@ -679,7 +632,8 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re for (const auto& s : slices) voxelTree.push_back(s); for (const auto& a : anomalies) voxelTree.push_back(a); if (auto* sec = drawer->analysisTab()->section("voxel")) sec->setDatasets(voxelTree); - drawer->col2D()->setDatasets(geopro::app::splitByDimension(*lastSourceRows).dim2D); + // B2:二维(足迹/轨迹)不再走 col2D;splitByCategory 已含 trajectory 段,随 setBuckets 自动入单列。 + drawer->analysisTab()->refreshVisibility(); // voxel 段单独喂数据后刷新分段可见性(Task B1) }; // 渲染勾选聚合:三维数据集栏(剖面→帘面)+ 三维分析栏(三维体/切片→体素/切片)两套勾选并集 @@ -690,10 +644,31 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re // 让「三维分析栏勾选(体/切片)」这条渲染路径也能隐藏不透明引导层——否则它盖住已渲染的体 // (雷达体由分析栏勾选触发渲染,但旧逻辑只在对象树勾选时隐藏引导层 → 体被盖住看不到)。 auto setSceneEmptyVisible = std::make_shared>(); - auto pushChecked = [sceneCtrl, checkedProfiles, checkedAnalysis, setSceneEmptyVisible]() { + // 勾选并集 → (dsId, typeId=描述符 id) 列表 → 控制器统一入口(B2:经描述符路由渲染策略,无维度散判)。 + // typeId 解析:三维体(mock,不在 lastSourceRows) → "voxel";其余(剖面/轨迹)在 lastSourceRows + // 按 categoryCatalog().classify 命中描述符 → 其 id。控制器据 catalog[typeId].renderStrategyId 派策略。 + auto pushChecked = [sceneCtrl, checkedProfiles, checkedAnalysis, setSceneEmptyVisible, + scene3dRepo, lastSourceRows]() { QStringList all = *checkedProfiles; all += *checkedAnalysis; - sceneCtrl->setCheckedDatasets(all); + std::vector> idType; + const auto& cat = geopro::data::categoryCatalog(); + for (const QString& q : all) { + const std::string id = q.toStdString(); + std::string typeId; + if (scene3dRepo->isVolumeDataset(id)) { + typeId = "voxel"; // 客户端三维体(mock) 不在 lastSourceRows,按仓储谓词直判 + } else { + const auto& rows = *lastSourceRows; // 剖面/轨迹按描述符 classify 解析具体类型 + auto it = std::find_if(rows.begin(), rows.end(), + [&](const geopro::data::DsRow& r) { return r.id == id; }); + if (it != rows.end()) + for (const auto& d : cat) + if (d.classify && d.classify(*it)) { typeId = d.id; break; } + } + if (!typeId.empty()) idType.push_back({id, typeId}); + } + sceneCtrl->setCheckedDatasets(idType); if (*setSceneEmptyVisible) (*setSceneEmptyVisible)(all.isEmpty()); // 场景有内容→隐藏引导层 }; @@ -1064,66 +1039,9 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re analysisTab->scrollItemToTop(qid); // 新三维体行尽量滚到分析栏顶部 }); }); - // 本地导入三维雷达测线(后端未就绪的过渡入口):入口=三维体段头「+ 导入雷达测线」按钮(CategorySection) - // → analysisTab.radarImportRequested(impulse)。app 无原生菜单栏(menuBar 被 TopBar 经 setMenuWidget 占用), - // 故入口放可见的段头按钮。impulse=false 走规范化(.head/.data, 懒加载后台建体);true 走 Impulse(.iprb, eager)。 - QObject::connect( - analysisTab, &geopro::app::CategoryAnalysisTab::radarImportRequested, &window, - [&window, scene3dRepo, refreshAnalysis, analysisTab, vtkLoading](bool impulse) { - if (!impulse) { // 规范化 .head/.data → registerRadarDataset(dd_radar_3d, 懒加载后台建体) - const QString dir = QFileDialog::getExistingDirectory( - &window, QStringLiteral("选择规范化三维雷达测线目录(含 *.head/*.data)")); - if (dir.isEmpty()) return; - bool ok = false; - const QString prefix = QInputDialog::getText( - &window, QStringLiteral("测线前缀"), - QStringLiteral("输入测线前缀(如 南同大道_000):"), QLineEdit::Normal, QString(), &ok); - if (!ok || prefix.isEmpty()) return; - // structParentId 暂空(P0 挂三维体段根;P1 接 TM 归属)。 - // coarse=1 全分辨率(沿线不抽稀):验收期要肉眼判读反射/双曲线/通道连续性, - // 不能被沿线抽稀糊掉。单线峰值内存 ~0.7–1.5GB(spec §8.4);若 OOM 退回 2。 - const std::string newId = scene3dRepo->registerRadarDataset( - dir.toLocal8Bit().toStdString(), prefix.toLocal8Bit().toStdString(), - prefix.toStdString(), /*structParentId=*/std::string(), /*coarse=*/1); - { const QSignalBlocker block(analysisTab); refreshAnalysis(); } // DS 进三维体段(不触发渲染) - const QString qid = QString::fromStdString(newId); - analysisTab->setItemChecked(qid, true); // 勾选 → addDatasetAsync → loadVolume 后台建体渲染 - analysisTab->setItemBusy(qid, true); // spinner; 渲染完成由 datasetRendered 撤 - analysisTab->scrollItemToTop(qid); - return; - } - // 明星路 Impulse(.iprb):复用现成 createGprVolume(eager 同步建体,预填 cachedGrid)。双数据集互证下游几何无关。 - const QString dir = QFileDialog::getExistingDirectory( - &window, QStringLiteral("选择 Impulse 测线目录(含 *.iprb/*.ord)")); - if (dir.isEmpty()) return; - bool ok = false; - const QString prefix = QInputDialog::getText( - &window, QStringLiteral("测线前缀"), - QStringLiteral("输入测线前缀(如 明星路_010):"), QLineEdit::Normal, QString(), &ok); - if (!ok || prefix.isEmpty()) return; - vtkLoading->showOver(QStringLiteral("正在建Impulse体…")); - // 内层捕获 window 引用(非 [=] 值拷贝):QMainWindow 拷贝构造已删除,且 showToast 需非 const QWidget*。 - QTimer::singleShot(0, &window, [=, &window]() { - std::string newId; - try { - newId = scene3dRepo->createGprVolume(dir.toLocal8Bit().toStdString(), - prefix.toLocal8Bit().toStdString(), - prefix.toStdString(), /*coarse=*/8); - } catch (const std::exception& e) { - vtkLoading->hide(); - geopro::app::showToast(&window, - QStringLiteral("建体失败:%1").arg(QString::fromLocal8Bit(e.what()))); - return; - } - { const QSignalBlocker block(analysisTab); refreshAnalysis(); } - vtkLoading->hide(); - const QString qid = QString::fromStdString(newId); - // createGprVolume 预填 cachedGrid → setItemChecked 内 loadVolume 同步渲染、datasetRendered 自动撤 busy; - // 故此处【不要】再 setItemBusy(true)(否则 spinner 永久转圈)。 - analysisTab->setItemChecked(qid, true); - analysisTab->scrollItemToTop(qid); - }); - }); + // 本地导入三维雷达测线(后端未就绪的过渡测试入口):入口已迁至 TopBar「设备」菜单 + // →「导入雷达测线」二级项(emit TopBar::radarImportRequested)。接线在 topBar 创建后(见下方设备菜单接线), + // 因 topBar 此处尚未构造;导入流程目标不变。 // 任一数据集(剖面/体)异步加载开始 → 列表项复选框转等待 spinner;渲染完成 → 复原复选框。 // 覆盖非三维体:勾选剖面首次渲染较慢时也有等待反馈(用户反馈)。 QObject::connect(sceneCtrl, &geopro::controller::VtkSceneController::datasetLoading, analysisTab, @@ -1357,10 +1275,26 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re sceneView->setAnomalyVisible(id.toStdString(), vis); renderWindowPtr->Render(); }); + // 贴合坐标轴(T2):据非空选中 ds 解析其子树盒并显示贴合轴。返回 true=已应用贴合轴(有效盒)。 + // 树选中与 VTK 视口点选共用同一解析(子树 dsId → datasetBounds → showFittedAxes),保证两路对称。 + // 调用方据返回值决定退回策略(树路:非空但无盒 → 全景轴;视口路:无盒 → 保持现状不强推全景)。 + auto applyFittedAxes = [sceneView, analysisTab](const QString& dsId) -> bool { + if (dsId.isEmpty()) return false; + const QStringList sub = analysisTab->subtreeDsIds(dsId); + std::vector ids; + ids.reserve(static_cast(sub.size())); + for (const QString& s : sub) ids.push_back(s.toStdString()); + double box[6]; + if (!ids.empty() && sceneView->datasetBounds(ids, box)) { + sceneView->showFittedAxes(box); + return true; + } + return false; + }; // 树选中切片/异常 → VTK 高亮联动(正向 list→VTK;反向 VTK→list 需拾取回调,见 OPT-002)。 QObject::connect(analysisTab, &geopro::app::CategoryAnalysisTab::datasetSelected, vtkWidget, - [sceneView, interactionMgr, renderWindowPtr](const QString& dsId, - const QString& ddCode) { + [sceneView, interactionMgr, renderWindowPtr, applyFittedAxes]( + const QString& dsId, const QString& ddCode) { const std::string id = dsId.toStdString(); // 选中项决定高亮:异常↔切片互斥,选其它对象两者都清(否则切到别的对象后切片/异常仍高亮)。 if (ddCode == QStringLiteral("dd_anomaly")) { @@ -1373,17 +1307,52 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re sceneView->setSelectedAnomaly(std::string{}); interactionMgr->deselectSlice(); } + // 贴合坐标轴(T2):选中 → 该 ds 子树盒贴合轴+隐全景;空选中(取消) → 恢复全景轴。 + // 子树未渲染/无盒 → 退回全景轴,避免留下无据的贴合框。 + if (!applyFittedAxes(dsId)) sceneView->showSceneAxes(); renderWindowPtr->Render(); }); + // 双击 DS(决策6/T4):(a) 相机适配到该 ds 子树空间范围(复用 T2 subtreeDsIds + T1 + // datasetBounds/fitToBounds,与选中贴合轴同一子树盒);(b) 有详情页的类型联动打开中下方详情面板, + // 三维体等无详情页类型静默——gate 在联动入口(detailCtrl.supports),不走 openDataset→loadFailed + // 的状态栏提示。属性弹窗改由右键「详情」(detailRequested) 触发,双击不再弹属性框。 + QObject::connect(analysisTab, &geopro::app::CategoryAnalysisTab::datasetActivated, &window, + [sceneView, renderWindowPtr, analysisTab, &detailCtrl]( + const QString& dsId, const QString& ddCode, const QString& name) { + const QStringList sub = analysisTab->subtreeDsIds(dsId); + std::vector ids; + ids.reserve(static_cast(sub.size())); + for (const QString& s : sub) ids.push_back(s.toStdString()); + double box[6]; + if (!ids.empty() && sceneView->datasetBounds(ids, box)) { // 未渲染/无盒 → 跳过适配(静默) + sceneView->fitToBounds(box); + renderWindowPtr->Render(); + } + if (detailCtrl.supports(ddCode)) // 无详情页策略(三维体等)→ 只适配、不开面板(静默) + detailCtrl.openDataset(dsId, ddCode, name, QString()); + }); + // 2D 段「z 值」滑块 → 整体升降该 2D 类型平面(Plane2DRenderStrategy 重摆其全部足迹)。 + QObject::connect(analysisTab, &geopro::app::CategoryAnalysisTab::planeZChanged, sceneCtrl, + &geopro::controller::VtkSceneController::setPlaneZ); + // 2D 段「底图」弹窗 → 切该类型平面底图 矢量平面/无 + 透明度(Plane2DRenderStrategy 多实例底图)。 + QObject::connect(analysisTab, &geopro::app::CategoryAnalysisTab::basemapKindChanged, sceneCtrl, + &geopro::controller::VtkSceneController::setBasemapKind); + QObject::connect(analysisTab, &geopro::app::CategoryAnalysisTab::basemapOpacityChanged, sceneCtrl, + &geopro::controller::VtkSceneController::setBasemapOpacity); // 反向 VTK→list:在 VTK 里点中/选中一张切片 → 在三维体段树里同步选中该切片行(②反向)。 // 点空白/清选(dsId 空) → 一并清 VTK 异常高亮(否则取消选中后异常图形仍高亮,用户反馈)。 - interactionMgr->onSliceSelectionChanged = [drawer, sceneView, renderWindowPtr]( + interactionMgr->onSliceSelectionChanged = [drawer, sceneView, renderWindowPtr, applyFittedAxes]( const std::string& dsId) { if (auto* sec = drawer->analysisTab()->section("voxel")) sec->selectItem(QString::fromStdString(dsId)); if (dsId.empty()) { sceneView->setSelectedAnomaly(std::string{}); + sceneView->showSceneAxes(); // VTK 里点空白清选 → 一并恢复全景轴(selectItem 空被 blocker 拦,不走 datasetSelected) renderWindowPtr->Render(); + } else { + // 视口内点选切片/异常/体 → 与树选一致显示子树贴合轴。selectItem 在 QSignalBlocker 下不发 + // datasetSelected(防选择环),故这里直接走共用解析补上贴合轴。无盒则保持现状(不强推全景)。 + if (applyFittedAxes(QString::fromStdString(dsId))) renderWindowPtr->Render(); } }; // 已保存切片经 VTK 右键「关闭」→ 取消数据列表对应切片项的勾选(否则列表仍勾选,用户反馈)。 @@ -1397,48 +1366,11 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re // 当前底图选择(默认 天地图=Satellite,对齐 Column2DDataset 默认项);数据重锚后据此在数据位置加载。 auto basemapKind = std::make_shared(geopro::app::TileBasemap::Satellite); - QObject::connect(drawer->col2D(), &geopro::app::Column2DDataset::basemapChanged, basemap, - [basemap, basemapKind](int idx) { - // 地图下拉:0 天地图(卫星影像) / 1 Google(暂未实现→隐藏) / 2 隐藏。 - *basemapKind = (idx == 0) ? geopro::app::TileBasemap::Satellite - : geopro::app::TileBasemap::Hidden; - basemap->show(*basemapKind); - }); - // ── 二维数据集栏:勾选足迹(测线/轨迹) → 平铺进 View3D 地图;2D视图下拉控摆放高度 ── - // 足迹经控制器 loadMapLine(Api3dRepository 走 dd/ert/trajectory/line 端点) → addMapLine 至 - // 当前摆放 Z,与帘面/底图共享 GeoLocalFrame 配准。与 3D 勾选集独立、按 dsId 增量。 - QObject::connect(drawer->col2D(), &geopro::app::Column2DDataset::checkedDatasetsChanged, - sceneCtrl, &geopro::controller::VtkSceneController::setChecked2DDatasets); - // 2D视图下拉(0关闭/1 Z=0/2顶部/3底部/4自定义) + 自定义 Z → 控制器重摆已勾选足迹。 - auto custom2dZ = std::make_shared(0.0); - // 默认 1(Z=0):与「2D视图」下拉可见默认项(setCurrentIndex(1))及控制器默认摆放一致—— - // 组合框初始 view2DModeChanged 因 connect 前发射而丢失,此处不可依赖其同步初值。 - auto view2dMode = std::make_shared(1); - QObject::connect(drawer->col2D(), &geopro::app::Column2DDataset::view2DModeChanged, sceneCtrl, - [sceneCtrl, custom2dZ, view2dMode](int mode) { - *view2dMode = mode; - sceneCtrl->set2DPlacement(mode, *custom2dZ); - }); - // 自定义 Z 变化:记录;若当前正处自定义模式则即时重摆(控制器内 changed 判定避免无谓重画)。 - QObject::connect(drawer->col2D(), &geopro::app::Column2DDataset::customZChanged, sceneCtrl, - [sceneCtrl, custom2dZ, view2dMode](double z) { - *custom2dZ = z; - if (*view2dMode == 4) sceneCtrl->set2DPlacement(4, z); - }); - - // ── 二维分析改造 A 期:切「三维分析/二维分析」tab → 一场景两相机 ────────────────── - // 三处协作:①切片隐藏+交互锁(仅平移+缩放) [InteractionManager];②按目标维度重置取景基线 - // [VtkSceneController]——使切换后该维度首条数据自动取景;③维度显隐+近俯视/自由相机+取景+坐标轴+ - // 渲染 [VtkSceneView]。顺序:先 ①②(都不渲染),最后 ③ 收尾统一渲染。只翻可见标志、不清空/重建 → - // 切换瞬时;地形+底图常驻。 - QObject::connect(drawer, &geopro::app::ColumnDrawer::analysisModeChanged, &window, - [interactionMgr, sceneCtrl, sceneView, viewToolbar, refreshAlongLineBar](bool is2D) { - interactionMgr->setMode2D(is2D); - sceneCtrl->onAnalysisModeChanged(is2D); - sceneView->setAnalysisMode2D(is2D); - viewToolbar->setAnalysisMode2D(is2D); // 二维下禁用 6 向快捷视图 - (*refreshAlongLineBar)(); // 二维隐藏沿线滑块、三维细长体显示(B#2) - }); + // B2:col2D 已删 —— 旧 2D 面板信号(basemapChanged / checkedDatasetsChanged / view2DModeChanged / + // customZChanged)接线随之移除。其替代(工具条 3D 底图、平面 z 滑块、2D 底图弹层)由 Phase D3/E3/F2 + // 重接。轨迹(足迹)勾选现经统一段 checkedDatasetsChanged → pushChecked → "plane2d" 策略渲染。 + // 底图默认仍为天地图(basemapKind=Satellite),首个数据重锚后由 onFrameReanchored 显示。 + // 抽屉去 tab 后无「三维/二维分析」切换,视图固定三维分析;旧 analysisModeChanged 接线移除(C2/D3 重接)。 // 首个真实剖面到达 → frame 重锚到数据 lat/lon 后,把选中的底图加载到数据所在位置 // (默认天地图即在此刻出现,避免启动时在样本原点拉无关瓦片)。 @@ -1447,8 +1379,27 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re }; // 相机程序化变化(取景/预设/缩放)后,底图按新视锥重算覆盖(治首帧部分瓦片需手动微动才出)。 sceneView->onCameraChanged = [basemap]() { basemap->refresh(); }; + // D3:渲染区工具条「底图」控件 → 共享 3D 底图(天地图/无 + 透明度),替代旧 col2D basemapChanged 接线。 + QObject::connect(viewToolbar, &geopro::app::VtkViewToolbar::basemapKindChanged, basemap, + [basemap, basemapKind](int kind) { + *basemapKind = (kind == 0) ? geopro::app::TileBasemap::Satellite + : geopro::app::TileBasemap::Hidden; + basemap->show(*basemapKind); + }); + QObject::connect(viewToolbar, &geopro::app::VtkViewToolbar::basemapOpacityChanged, basemap, + [basemap](double o) { basemap->setOpacity(o); }); // 底图最大范围按当前勾选剖面合并范围动态定(随增删自动伸缩);刷新时实时查询。 basemap->setDataRadiusProvider([sceneView]() { return sceneView->dataHorizontalRadius(); }); + // F2:2D 平面底图工厂下发 → Plane2DRenderStrategy 据此为每个 2D 类型平面按需建/销平面矢量底图 + // (与共享 3D 底图同源 scene/渲染窗/frame/数据半径规则;随平面 z 升降重建、全消随平面消失)。 + // 工厂造 TileBasemapPlaneAdapter(app 层适配 IPlaneBasemap),使 geopro_controller 不反依赖 app/VTK。 + sceneCtrl->setPlaneBasemapFactory( + [scene, renderWindowPtr, frame, sceneView](double groundZ) + -> std::unique_ptr { + return std::make_unique( + *scene, renderWindowPtr, frame, groundZ, + [sceneView]() { return sceneView->dataHorizontalRadius(); }); + }); // 垂直夸张:地形须与剖面用同一 VE 才对齐(都按真实高程×VE)。单一来源 kVerticalExaggeration 下发 // 控制器(上方)/底图;运行时 VE 改由坐标轴设置抽屉「应用」下发(旧三维数据集栏滑块已退役)。 basemap->setVerticalExaggeration(kVerticalExaggeration); @@ -1529,7 +1480,7 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re auto* emptyCentering = new CenterOverlay(emptyState, vtkWidget); // 引导层定位后,把工具条与提示浮层 raise 到其上 → 工具条永在最上层(修:缩小渲染区时引导层挡工具条)。 - emptyCentering->setRaiseAfter({viewToolbar, anomalyHint, elevHint, alongLineBar}); + emptyCentering->setRaiseAfter({viewToolbar, anomalyHint, alongLineBar}); emptyCentering->reposition(); // 引导层隐藏器就位(见 pushChecked 处声明):场景(剖面∪三维分析)有勾选 → 隐藏不透明引导层、露出渲染。 *setSceneEmptyVisible = [emptyState](bool empty) { emptyState->setVisible(empty); }; @@ -1719,7 +1670,7 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re emptyState->setVisible(sources.isEmpty() && checkedAnalysis->isEmpty()); if (sources.isEmpty()) { *lastSourceRows = {}; - refreshAnalysis(); // 清空 5 段(客户端三维体仍驻留) + col2D + refreshAnalysis(); // 清空各段(客户端三维体仍驻留) return; } // 多源异步汇总:每个源(TM / GS·项目根直挂)按 confType 取整棵 ds 子树,全部回来后 splitByCategory 分 5 段。 @@ -1728,7 +1679,7 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re auto finish = [acc, generation, myGen, lastSourceRows, refreshAnalysis]() { if (*generation != myGen) return; // 已被更新的勾选批次取代→丢弃陈旧结果 *lastSourceRows = *acc; // 全部对象树 ds 作分析数据源 - refreshAnalysis(); // splitByCategory→5段 + 合并三维体/切片 + dim2D→col2D + refreshAnalysis(); // splitByCategory→各段 + 合并三维体/切片(单列) }; for (const geopro::data::DataSource& src : sources) { // 第3参 confType:1=GS/项目根(直挂 ds),2=TM(测线下 ds)——透传给 loadRowsAsync(spec §6)。 @@ -1820,23 +1771,82 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re centralStack->setCurrentWidget(dockManager); }); + // 设备菜单「导入雷达测线」(后端未就绪的过渡测试入口):原入口在三维体段头按钮(已移除),集中到设备菜单。 + // impulse=false 走规范化(.head/.data, 懒加载后台建体);true 走 Impulse(.iprb, eager)。导入流程目标不变。 + QObject::connect( + topBar, &geopro::app::TopBar::radarImportRequested, &window, + [&window, scene3dRepo, refreshAnalysis, analysisTab, vtkLoading](bool impulse) { + if (!impulse) { // 规范化 .head/.data → registerRadarDataset(dd_radar_3d, 懒加载后台建体) + const QString dir = QFileDialog::getExistingDirectory( + &window, QStringLiteral("选择规范化三维雷达测线目录(含 *.head/*.data)")); + if (dir.isEmpty()) return; + bool ok = false; + const QString prefix = QInputDialog::getText( + &window, QStringLiteral("测线前缀"), + QStringLiteral("输入测线前缀(如 南同大道_000):"), QLineEdit::Normal, QString(), &ok); + if (!ok || prefix.isEmpty()) return; + // structParentId 暂空(P0 挂三维体段根;P1 接 TM 归属)。 + // coarse=1 全分辨率(沿线不抽稀):验收期要肉眼判读反射/双曲线/通道连续性, + // 不能被沿线抽稀糊掉。单线峰值内存 ~0.7–1.5GB(spec §8.4);若 OOM 退回 2。 + const std::string newId = scene3dRepo->registerRadarDataset( + dir.toLocal8Bit().toStdString(), prefix.toLocal8Bit().toStdString(), + prefix.toStdString(), /*structParentId=*/std::string(), /*coarse=*/1); + { const QSignalBlocker block(analysisTab); refreshAnalysis(); } // DS 进三维体段(不触发渲染) + const QString qid = QString::fromStdString(newId); + analysisTab->setItemChecked(qid, true); // 勾选 → addDatasetAsync → loadVolume 后台建体渲染 + analysisTab->setItemBusy(qid, true); // spinner; 渲染完成由 datasetRendered 撤 + analysisTab->scrollItemToTop(qid); + return; + } + // 明星路 Impulse(.iprb):复用现成 createGprVolume(eager 同步建体,预填 cachedGrid)。双数据集互证下游几何无关。 + const QString dir = QFileDialog::getExistingDirectory( + &window, QStringLiteral("选择 Impulse 测线目录(含 *.iprb/*.ord)")); + if (dir.isEmpty()) return; + bool ok = false; + const QString prefix = QInputDialog::getText( + &window, QStringLiteral("测线前缀"), + QStringLiteral("输入测线前缀(如 明星路_010):"), QLineEdit::Normal, QString(), &ok); + if (!ok || prefix.isEmpty()) return; + vtkLoading->showOver(QStringLiteral("正在建Impulse体…")); + // 内层捕获 window 引用(非 [=] 值拷贝):QMainWindow 拷贝构造已删除,且 showToast 需非 const QWidget*。 + QTimer::singleShot(0, &window, [=, &window]() { + std::string newId; + try { + newId = scene3dRepo->createGprVolume(dir.toLocal8Bit().toStdString(), + prefix.toLocal8Bit().toStdString(), + prefix.toStdString(), /*coarse=*/8); + } catch (const std::exception& e) { + vtkLoading->hide(); + geopro::app::showToast(&window, + QStringLiteral("建体失败:%1").arg(QString::fromLocal8Bit(e.what()))); + return; + } + { const QSignalBlocker block(analysisTab); refreshAnalysis(); } + vtkLoading->hide(); + const QString qid = QString::fromStdString(newId); + // createGprVolume 预填 cachedGrid → setItemChecked 内 loadVolume 同步渲染、datasetRendered 自动撤 busy; + // 故此处【不要】再 setItemBusy(true)(否则 spinner 永久转圈)。 + analysisTab->setItemChecked(qid, true); + analysisTab->scrollItemToTop(qid); + }); + }); + // 切换到「不同项目」时先清空中央区,避免新项目残留旧项目的三栏数据与 VTK 渲染。 // 仅真正换项目用(delete-refresh 等 switchProject(currentProjectId) 不走此处,避免误清)。 - auto clearCentral = [sceneCtrl, emptyState, checkedProfiles, checkedAnalysis, + auto clearCentral = [emptyState, checkedProfiles, checkedAnalysis, pushChecked, lastSourceRows, refreshAnalysis, checkedSliceIds, syncSlices, basemap, sceneView, scene3dRepo]() { // 切项目:先清内存态三维体/切片/异常(否则上个项目的产物残留进新项目列表),再 refreshAnalysis。 scene3dRepo->clearMockData(); - // 数据源清空 → 5 段 + col2D 清空(refreshAnalysis 内 setBuckets/dim2D)。 + // 数据源清空 → 各段清空(refreshAnalysis 内 setBuckets,含 trajectory 段)。 *lastSourceRows = {}; refreshAnalysis(); - // 勾选集清空并下发空到 VTK(帘面/体素/切片/2D 足迹全部撤场)。 + // 勾选集清空并下发空到 VTK(帘面/体素/切片/2D 足迹全部撤场——统一入口一并清)。 checkedProfiles->clear(); checkedAnalysis->clear(); checkedSliceIds->clear(); - pushChecked(); // setCheckedDatasets({}) → 帘面/体素清空 - syncSlices(); // 切片随空勾选调和 - sceneCtrl->setChecked2DDatasets({}); // 2D 足迹显式撤场(与 col2D 空勾选双保险) + pushChecked(); // setCheckedDatasets({}) → 帘面/体素/足迹清空(统一勾选集 diff 全移除) + syncSlices(); // 切片随空勾选调和 // 复位重锚标志:增量勾选不走 clear(),不复位则旧标志残留 → 新项目数据不重锚 → // onFrameReanchored 不触发 → 下面 hide() 的底图永不再显。复位后新项目首个数据重锚→重显。 sceneView->resetFrameAnchor(); diff --git a/src/app/panels/columns/CategoryAnalysisTab.cpp b/src/app/panels/columns/CategoryAnalysisTab.cpp index 2680571..e86fec0 100644 --- a/src/app/panels/columns/CategoryAnalysisTab.cpp +++ b/src/app/panels/columns/CategoryAnalysisTab.cpp @@ -1,6 +1,7 @@ #include "panels/columns/CategoryAnalysisTab.hpp" #include +#include #include #include #include @@ -10,6 +11,7 @@ #include "Theme.hpp" #include "panels/columns/CategorySection.hpp" +#include "repo/CategoryDescriptor.hpp" // categoryCatalog(含 trajectory 段) namespace geopro::app { @@ -33,23 +35,25 @@ CategoryAnalysisTab::CategoryAnalysisTab(geopro::data::DatasetFieldDictionary* d col->setContentsMargins(0, space::kSm, 0, space::kSm); // 顶部留白:首段段头不贴顶 col->setSpacing(space::kSm); - for (const CategorySpec& spec : categoryConfigs()) { - auto* sec = new CategorySection(spec, dict, content); - sections_[spec.id] = sec; + for (const auto& desc : geopro::data::categoryCatalog()) { + auto* sec = new CategorySection(desc, dict, content); + sections_[desc.id] = sec; ordered_.push_back(sec); connect(sec, &CategorySection::collapsedChanged, this, &CategoryAnalysisTab::relayoutSections); // 折叠/展开 → 重排 stretch(向上收) - const std::string segId = spec.id; + const std::string segId = desc.id; connect(sec, &CategorySection::checkedDatasetsChanged, this, [this, segId](const QStringList& ids) { checkedBySeg_[segId] = ids; recomputeCheckedUnion(); + updatePlaceholderAndVisibility(); // 勾选/段内容变化后段「有无行」可能变 → 刷新显隐 }); connect(sec, &CategorySection::generateVolumeRequested, this, &CategoryAnalysisTab::generateVolumeRequested); - connect(sec, &CategorySection::radarImportRequested, this, - &CategoryAnalysisTab::radarImportRequested); + // 注:「导入雷达测线」入口已迁至 TopBar「设备」菜单(Task D1);CategorySection 段头按钮与 + // CategoryAnalysisTab::radarImportRequested 信号均已移除。 connect(sec, &CategorySection::detailRequested, this, &CategoryAnalysisTab::detailRequested); + connect(sec, &CategorySection::datasetActivated, this, &CategoryAnalysisTab::datasetActivated); connect(sec, &CategorySection::deleteDatasetRequested, this, &CategoryAnalysisTab::deleteDatasetRequested); connect(sec, &CategorySection::sliceRequested, this, &CategoryAnalysisTab::sliceRequested); @@ -64,7 +68,24 @@ CategoryAnalysisTab::CategoryAnalysisTab(geopro::data::DatasetFieldDictionary* d &CategoryAnalysisTab::sliceExportDatRequested); connect(sec, &CategorySection::anomalyVisibilityChanged, this, &CategoryAnalysisTab::anomalyVisibilityChanged); - connect(sec, &CategorySection::datasetSelected, this, &CategoryAnalysisTab::datasetSelected); + // 全列互斥选中:某段选中非空数据行 → 清其余各段选中,保证整列至多一个 ds 选中, + // 避免「电阻率段与三维体段各选一行」的二义(贴合轴该听谁)。clearSelection 内部 QSignalBlocker + // 阻断,被清段不回发 datasetSelected(空),无环路;inSelectionSync_ 为兜底防重入。 + connect(sec, &CategorySection::datasetSelected, this, + [this, sec](const QString& dsId, const QString& ddCode) { + if (!dsId.isEmpty() && !inSelectionSync_) { + inSelectionSync_ = true; + for (auto* other : ordered_) + if (other != sec) other->clearSelection(); + inSelectionSync_ = false; + } + emit datasetSelected(dsId, ddCode); + }); + connect(sec, &CategorySection::planeZChanged, this, &CategoryAnalysisTab::planeZChanged); + connect(sec, &CategorySection::basemapKindChanged, this, + &CategoryAnalysisTab::basemapKindChanged); + connect(sec, &CategorySection::basemapOpacityChanged, this, + &CategoryAnalysisTab::basemapOpacityChanged); // #7:各段等分 stretch → 内容都少时四段平分高度填满面板(初始与 VTK 区等高、不出滚动条); // 某段内容增多时其最小高度(=内容总高)撑大,超出视口则由外层 QScrollArea 统一出纵向滚动条。 col->addWidget(sec, 1); @@ -72,6 +93,18 @@ CategoryAnalysisTab::CategoryAnalysisTab(geopro::data::DatasetFieldDictionary* d // 尾部弹簧(末项):默认 0;全部段折叠时由 relayoutSections 置 1,吸收余量把段头顶到顶部。 col->addStretch(0); scroll->setWidget(content); + + // 全空占位:所有段都无可渲染数据行时显示,与 scroll 同级、互斥显隐(由 updatePlaceholderAndVisibility 切换)。 + auto* placeholder = new QLabel(QStringLiteral("请在左侧对象树勾选测线 / 数据集"), this); + placeholder->setAlignment(Qt::AlignCenter); + placeholder->setWordWrap(true); + applyTokenizedStyleSheet(placeholder, + QStringLiteral("QLabel{color:{{text/tertiary}};padding:24px;}")); + outer->addWidget(placeholder, 0); + placeholder->hide(); + placeholder_ = placeholder; + + updatePlaceholderAndVisibility(); // 初始无数据 → 直接进入占位态 } void CategoryAnalysisTab::relayoutSections() { @@ -86,14 +119,29 @@ void CategoryAnalysisTab::relayoutSections() { } void CategoryAnalysisTab::setBuckets(const CategoryBuckets& b) { - const auto& cfg = categoryConfigs(); - for (std::size_t i = 0; i < cfg.size() && i < b.segments.size(); ++i) { + // splitByCategory 现按 categoryCatalog() 分桶(含 trajectory 桶,自然分发到轨迹段)。 + // 轨迹段已随 catalog 在构造时建出(Task C2),section() 命中存在的段;下方 guard 仍保平衡兜底。 + const auto& cat = geopro::data::categoryCatalog(); + for (std::size_t i = 0; i < cat.size() && i < b.segments.size(); ++i) { // voxel(三维体) 段数据来自 mock voxelTree(体/切片/异常),由调用方单独 section("voxel")->setDatasets // 注入;splitByCategory 对它永远是空桶,若在此用空桶覆盖会先清掉其勾选(随后重填但勾选已丢) → // 表现为「创建切片/异常后体/切片选择被清空」(用户 issue 1)。故跳过 voxel,勿覆盖。 - if (cfg[i].id == "voxel") continue; - if (auto* sec = section(cfg[i].id)) sec->setDatasets(b.segments[i]); + if (cat[i].id == "voxel") continue; + if (auto* sec = section(cat[i].id)) sec->setDatasets(b.segments[i]); } + updatePlaceholderAndVisibility(); // 分发后据各段有无数据刷新显隐 + 占位 +} + +void CategoryAnalysisTab::updatePlaceholderAndVisibility() { + bool anyVisible = false; + for (auto* sec : ordered_) { + const bool has = sec->hasRenderableRows(); + sec->setVisible(has); + if (has) anyVisible = true; + } + if (scroll_) scroll_->setVisible(anyVisible); + if (placeholder_) placeholder_->setVisible(!anyVisible); + relayoutSections(); } void CategoryAnalysisTab::setStructure(const std::vector& nodes) { @@ -109,6 +157,14 @@ CategorySection* CategoryAnalysisTab::section(const std::string& id) const { return it != sections_.end() ? it->second : nullptr; } +QStringList CategoryAnalysisTab::subtreeDsIds(const QString& dsId) const { + for (auto* sec : ordered_) { + const QStringList ids = sec->subtreeDsIds(dsId); + if (!ids.isEmpty()) return ids; // ds 归属唯一段 → 首个命中段即答案 + } + return {}; +} + // ds 归属唯一段未知 → 广播到各段,仅含该 ds 的段会命中生效(其余 no-op)。 void CategoryAnalysisTab::setItemChecked(const QString& dsId, bool on) { for (auto* sec : ordered_) sec->setChecked(dsId, on); diff --git a/src/app/panels/columns/CategoryAnalysisTab.hpp b/src/app/panels/columns/CategoryAnalysisTab.hpp index eb00c5a..17ba9a3 100644 --- a/src/app/panels/columns/CategoryAnalysisTab.hpp +++ b/src/app/panels/columns/CategoryAnalysisTab.hpp @@ -26,21 +26,24 @@ class CategoryAnalysisTab : public QWidget { public: explicit CategoryAnalysisTab(geopro::data::DatasetFieldDictionary* dict, QWidget* parent = nullptr); - void setBuckets(const CategoryBuckets& b); // 分发到 5 段(与 categoryConfigs 同序) + void setBuckets(const CategoryBuckets& b); // 分发到各大类段(与 categoryCatalog() 同序) void setStructure(const std::vector& nodes); // 转发各段 void refreshArrayFilters(); // 装置枚举异步加载后,重填各段装置筛选下拉 - CategorySection* section(const std::string& id) const; // 按 CategorySpec.id 取段 + CategorySection* section(const std::string& id) const; // 按 CategoryDescriptor.id 取段 + // 该 ds 所在段的层级子树 dsId 集(贴合坐标轴子树盒):遍历各段,返首个命中段的 subtreeDsIds。空=无段含该 ds。 + QStringList subtreeDsIds(const QString& dsId) const; // ── 三维体生成后:自动勾选触发渲染 + 标题前等待动画(spinner)反馈(转发到含该 ds 的段)── void setItemChecked(const QString& dsId, bool on); // 自动勾选(触发渲染) void setItemBusy(const QString& dsId, bool busy); // 复选框↔等待 spinner 切换 void clearAllBusy(); // 撤回所有 spinner(失败兜底) void scrollItemToTop(const QString& dsId); // 把某行尽量滚到面板顶部(新建三维体后定位) + void refreshVisibility() { updatePlaceholderAndVisibility(); } // 外部注入(如 voxel setDatasets)后刷新段显隐/占位 signals: void checkedDatasetsChanged(const QStringList& dsIds); // 5 段勾选并集 void generateVolumeRequested(const QString& dsTypeCode, const QStringList& sourceDsIds); - void radarImportRequested(bool impulse); // 三维体段头「+导入雷达测线」(false=规范化, true=Impulse) - void detailRequested(const QString& dsId, const QString& ddCode, const QString& name); + void detailRequested(const QString& dsId, const QString& ddCode, const QString& name); // 右键「详情」=属性弹窗 + void datasetActivated(const QString& dsId, const QString& ddCode, const QString& name); // 双击=适配+图表联动(T4) void deleteDatasetRequested(const QString& dsId, const QString& ddCode); // 右键删除切片/异常 // ── 三维体段操作转发(迁自旧 Column3DAnalysis,全接)── void sliceRequested(geopro::render::interact::SliceAxis axis, const QString& volumeDsId); @@ -52,6 +55,9 @@ signals: void sliceExportDatRequested(const QString& dsId); void anomalyVisibilityChanged(const QString& dsId, bool vis); void datasetSelected(const QString& dsId, const QString& ddCode); // 树选中→VTK 高亮联动 + void planeZChanged(const QString& typeId, double z); // 2D 段 z 值滑块:整体升降该类型平面 + void basemapKindChanged(const QString& typeId, int kind); // 2D 段底图弹窗:矢量平面(0)/无(1) + void basemapOpacityChanged(const QString& typeId, double o); // 2D 段底图弹窗:透明度[0,1] private: void recomputeCheckedUnion(); @@ -61,13 +67,17 @@ private: // 据各段折叠态重排 stretch:折叠段=0(仅段头高)、展开段=1(吸收余量);全折叠时尾部弹簧吸收→段头顶到顶部。 // 仅在面板不产生滚动条(内容short于视口)时有可见效果——正是用户反馈的"折叠后停在原位中间"场景。 void relayoutSections(); + // 据各段是否有可渲染数据行 显隐段;全空时显示占位提示、隐藏滚动区。数据变化(分发/勾选/注入)后调用。 + void updatePlaceholderAndVisibility(); std::map sections_; - std::vector ordered_; // 按 categoryConfigs 顺序(relayout 遍历用) + std::vector ordered_; // 按 categoryCatalog() 顺序(relayout 遍历用) QScrollArea* scroll_ = nullptr; // 外层滚动区(scrollItemToTop 定位用) QWidget* content_ = nullptr; // 滚动内容容器(坐标映射用) QVBoxLayout* col_ = nullptr; // 段堆叠布局(末项=尾部弹簧) + QWidget* placeholder_ = nullptr; // 全空时显示的占位提示(与 scroll_ 同级,互斥显隐) std::map checkedBySeg_; // 各段当前勾选(合并成并集) + bool inSelectionSync_ = false; // 跨段互斥清选进行中标记(防重入信号环,clearSelection 已阻断信号,此为兜底) }; } // namespace geopro::app diff --git a/src/app/panels/columns/CategorySection.cpp b/src/app/panels/columns/CategorySection.cpp index ef66c92..b52fe03 100644 --- a/src/app/panels/columns/CategorySection.cpp +++ b/src/app/panels/columns/CategorySection.cpp @@ -8,18 +8,23 @@ #include #include #include +#include #include #include #include #include +#include +#include #include #include #include +#include #include #include #include "Theme.hpp" #include "panels/DatasetListPanel.hpp" +#include "panels/columns/SectionIconBar.hpp" #include "repo/DatasetFieldDictionary.hpp" namespace geopro::app { @@ -27,14 +32,20 @@ namespace geopro::app { using geopro::data::DsRow; using geopro::data::DsTypeFields; -CategorySection::CategorySection(const CategorySpec& spec, geopro::data::DatasetFieldDictionary* dict, - QWidget* parent) - : QWidget(parent), spec_(spec), dict_(dict) { +namespace { +// 段头图标条默认上限(spec §6):超过则末位收进「…」下拉。钉死为常量而非随操作数浮动, +// 否则上限恒=操作数、计数溢出折叠分支永不触发(当前各段操作 ≤3,对用户不可见,但保证分支正确)。 +constexpr int kDefaultMaxIcons = 3; +} // namespace + +CategorySection::CategorySection(const geopro::data::CategoryDescriptor& desc, + geopro::data::DatasetFieldDictionary* dict, QWidget* parent) + : QWidget(parent), desc_(desc), dict_(dict) { auto* root = new QVBoxLayout(this); root->setContentsMargins(0, 0, 0, 0); root->setSpacing(0); - // 数据类型段头(可折叠,规范§4.3/§6):chevron + 标题(title 字号·半粗) |「+ 新增三维体」(右,仅反演类)。 + // 数据类型段头(可折叠,规范§4.3/§6):chevron + 标题(title 字号·半粗) | 右侧响应式图标条(SectionIconBar)。 // 段头加浅底 + 底分隔线作视觉分段;去原生小三角(难看)→chevron 文本前缀,随主题/hover 变色。 auto* headerRow = new QWidget(this); headerRow->setObjectName(QStringLiteral("secHeader")); @@ -58,57 +69,44 @@ CategorySection::CategorySection(const CategorySpec& spec, geopro::data::Dataset .arg(type::kWeightSemibold)); auto syncHeader = [this] { header_->setText((header_->isChecked() ? QStringLiteral("▾ ") : QStringLiteral("▸ ")) - + QString::fromStdString(spec_.title)); + + QString::fromStdString(desc_.title)); }; syncHeader(); + // 标题先让位:水平 Preferred + 最小宽 0,列变窄时标题先收(必要时裁字),图标条守住自身 + // sizeHint(全图标宽)直到真正没空间才折叠右侧图标进「…」。否则标题不肯缩→图标条被迫先折(过早)。 + header_->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Fixed); + header_->setMinimumWidth(0); hl->addWidget(header_); hl->addStretch(1); - if (spec_.canGenerateVolume) { - auto* gen = new QToolButton(headerRow); - gen->setText(QStringLiteral("+ 新增三维体")); - gen->setCursor(Qt::PointingHandCursor); - // 次级强调按钮(规范§6.7):描边 accent + accent 文字,hover 浅强调底;非裸文字。 - applyTokenizedStyleSheet( - gen, QStringLiteral( - "QToolButton{border:1px solid {{accent/primary}};border-radius:%1px;" - "color:{{accent/primary}};background:transparent;padding:%2px %3px;font-size:%4px;}" - "QToolButton:hover{background:{{bg/selected}};}" - "QToolButton:pressed{background:{{bg/hover}};}") - .arg(radius::kSm) - .arg(scaledPx(space::kXxs)) - .arg(scaledPx(space::kMd)) - .arg(scaledPx(type::kCaption))); - connect(gen, &QToolButton::clicked, this, [this] { - emit generateVolumeRequested(QString::fromStdString(spec_.dsTypeCode), checkedDsIds()); - }); - hl->addWidget(gen); - } - // 三维体段头「+ 导入雷达测线」(后端未就绪的本地过渡入口):弹出菜单选 规范化/Impulse。 - // 次级强调按钮样式同「+新增三维体」;点击发 radarImportRequested(impulse) → 上层走导入流程。 - if (spec_.id == "voxel") { - auto* imp = new QToolButton(headerRow); - imp->setText(QStringLiteral("+ 导入雷达测线")); - imp->setCursor(Qt::PointingHandCursor); - imp->setPopupMode(QToolButton::InstantPopup); - applyTokenizedStyleSheet( - imp, QStringLiteral( - "QToolButton{border:1px solid {{accent/primary}};border-radius:%1px;" - "color:{{accent/primary}};background:transparent;padding:%2px %3px;font-size:%4px;}" - "QToolButton::menu-indicator{image:none;width:0;}" - "QToolButton:hover{background:{{bg/selected}};}" - "QToolButton:pressed{background:{{bg/hover}};}") - .arg(radius::kSm) - .arg(scaledPx(space::kXxs)) - .arg(scaledPx(space::kMd)) - .arg(scaledPx(type::kCaption))); - auto* menu = new QMenu(imp); - menu->addAction(QStringLiteral("规范化测线目录(.head/.data)…"), this, - [this] { emit radarImportRequested(false); }); - menu->addAction(QStringLiteral("Impulse 测线目录(.iprb)…"), this, - [this] { emit radarImportRequested(true); }); - imp->setMenu(menu); - hl->addWidget(imp); + // 段头图标条:遍历 desc_.operations,经一处 OpKind→IconAction 映射装配(spec §6)。 + // glyph 键须命中 SectionIconBar::glyphFromKey 已识别集;z 值无专用键,复用 collapse(竖向双箭头)。 + iconBar_ = new SectionIconBar(headerRow); + std::vector acts; + for (geopro::data::OpKind op : desc_.operations) { + switch (op) { + case geopro::data::OpKind::GenerateVolume: + // dsTypeCode 不再由段配置带(描述符无此字段)→ 发空串,接收方按 sourceIds 解析类型。 + acts.push_back({QStringLiteral("plus"), QStringLiteral("新增三维体"), + [this] { emit generateVolumeRequested(QString(), checkedDsIds()); }, {}}); + break; + case geopro::data::OpKind::Filter: + acts.push_back({QStringLiteral("filter"), QStringLiteral("筛选"), + [this] { if (filterRow_) filterRow_->setVisible(!filterRow_->isVisible()); }, + {}}); + break; + case geopro::data::OpKind::PlaneZ: + acts.push_back({QStringLiteral("collapse"), QStringLiteral("z 值"), {}, + [this](QToolButton* host) { showPlaneZPopup(host); }}); + break; + case geopro::data::OpKind::Basemap: + acts.push_back({QStringLiteral("map"), QStringLiteral("底图"), {}, + [this](QToolButton* host) { showBasemapPopup(host); }}); + break; + } } + iconBar_->setMaxIcons(kDefaultMaxIcons); // spec §6 默认上限 3(够宽全显,超数/不够宽收进「…」) + iconBar_->setActions(acts); + hl->addWidget(iconBar_); root->addWidget(headerRow); body_ = new QWidget(this); @@ -116,20 +114,27 @@ CategorySection::CategorySection(const CategorySpec& spec, geopro::data::Dataset body->setContentsMargins(space::kMd, space::kSm, space::kMd, space::kMd); body->setSpacing(space::kSm); - // 筛选行:采集时间范围(在前)+ 装置类型(在后,仅 ERT 类)。 - auto* filterRow = new QHBoxLayout(); - filterRow->setSpacing(space::kSm); - dateRange_ = new DateRangeEdit(body_); - connect(dateRange_, &DateRangeEdit::rangeChanged, this, [this] { rebuildList(); }); - filterRow->addWidget(dateRange_, 1); - if (spec_.hasArrayTypeFilter) { - arrayCombo_ = new QComboBox(body_); - arrayCombo_->addItem(QStringLiteral("全部装置"), QString()); - connect(arrayCombo_, qOverload(&QComboBox::currentIndexChanged), this, - [this](int) { rebuildList(); }); - filterRow->addWidget(arrayCombo_); + // 筛选行(默认折叠,由段头 Filter 图标 toggle):按 desc_.filters 装配 —— DateRange→采集时间范围(在前); + // ArrayType→装置类型下拉(在后)。未列出的维度不建控件,passesFilters/rebuildList 视该控件缺席=不筛该维。 + filterRow_ = new QWidget(body_); + auto* filterLay = new QHBoxLayout(filterRow_); + filterLay->setContentsMargins(0, 0, 0, 0); + filterLay->setSpacing(space::kSm); + for (geopro::data::FilterKind fk : desc_.filters) { + if (fk == geopro::data::FilterKind::DateRange) { + dateRange_ = new DateRangeEdit(filterRow_); + connect(dateRange_, &DateRangeEdit::rangeChanged, this, [this] { rebuildList(); }); + filterLay->addWidget(dateRange_, 1); + } else if (fk == geopro::data::FilterKind::ArrayType) { + arrayCombo_ = new QComboBox(filterRow_); + arrayCombo_->addItem(QStringLiteral("全部装置"), QString()); + connect(arrayCombo_, qOverload(&QComboBox::currentIndexChanged), this, + [this](int) { rebuildList(); }); + filterLay->addWidget(arrayCombo_); + } } - body->addLayout(filterRow); + filterRow_->hide(); // 默认折叠,点段头筛选图标展开 + body->addWidget(filterRow_); // 段体:可勾选数据树(勾选=渲染)。复用数据列表卡片委托与 populateDatasetList。 list_ = new QTreeWidget(body_); @@ -140,30 +145,52 @@ CategorySection::CategorySection(const CategorySpec& spec, geopro::data::Dataset list_->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff); list_->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); applyDatasetCardDelegate(list_); + list_->viewport()->installEventFilter(this); // 捕获按下瞬间选中态(默认改选前),供单击已选行 toggle 取消 connect(list_, &QTreeWidget::itemChanged, this, [this](QTreeWidgetItem* it, int) { + // 用户点勾选框会在按下→释放间先发 itemChanged:标记本次按下改动了勾选框,itemClicked 据此不取消选中 + // (点勾选框只切渲染,不应连带取消该行选中/丢贴合轴)。程序化改勾选均走 SignalBlocker,不到此处。 + if (it && it == pressedItem_) checkToggledThisPress_ = true; // 异常行复选框 = 该异常显隐(异常不进渲染勾选集,单独走 anomalyVisibilityChanged → setAnomalyVisible)。 if (it && it->data(0, kDsDdCodeRole).toString() == QStringLiteral("dd_anomaly")) emit anomalyVisibilityChanged(it->data(0, kDsIdRole).toString(), it->checkState(0) == Qt::Checked); emitChecked(); }); + // 单击已选中的数据行 → 取消选中(toggle-off):itemClicked 于释放时触发,此时选中态已落定; + // 仅当「按下瞬间该行已选中」且「本次未点勾选框」才取消。取消走 list_->clearSelection()(非成员 + // clearSelection,后者阻断信号)→ 直发 itemSelectionChanged(空) → datasetSelected("") → 恢复全景轴、丢贴合轴。 + // 双击仍可激活:首次释放虽可能 toggle 掉选中,随后 DblClick 会重新选中该行并发 datasetActivated(fit+详情)。 + connect(list_, &QTreeWidget::itemClicked, this, [this](QTreeWidgetItem* it, int) { + if (!it || it != pressedItem_ || !pressedSelected_ || checkToggledThisPress_) return; + if (it->data(0, kDsDdCodeRole).toString() == QStringLiteral("container")) return; // 容器行不参与选中 + pressedSelected_ = false; // 防重入 + list_->clearSelection(); // 发空 itemSelectionChanged → datasetSelected("") → 上层恢复全景轴 + }); connect(list_, &QTreeWidget::itemDoubleClicked, this, [this](QTreeWidgetItem* it, int) { const QString id = it->data(0, kDsIdRole).toString(); if (id.isEmpty()) return; - emit detailRequested(id, it->data(0, kDsDdCodeRole).toString(), it->data(0, kDsNameRole).toString()); + // 双击=适配相机到该 ds 子树盒 + 联动中下方图表详情页(无图表页类型静默,见 main.cpp T4 接线)。 + // 属性弹窗改由右键「详情」触发(detailRequested),双击不再弹属性框(决策6:三维体只适配、静默)。 + // 双击首击的 itemClicked 可能已把「本已选中」的该行 toggle 取消(见下方 toggle 逻辑);此处补回选中, + // 使双击终态 = 选中+贴合轴+详情一致(!isSelected 仅在被 toggle 掉时成立,正常双击未选行首击已选中→跳过)。 + if (!it->isSelected()) list_->setCurrentItem(it); + emit datasetActivated(id, it->data(0, kDsDdCodeRole).toString(), it->data(0, kDsNameRole).toString()); }); - if (spec_.id == "voxel") { // 仅三维体段提供右键操作菜单(体/切片/异常) + // 树选中任意数据行 → datasetSelected(各类型段通用):驱动该 ds 子树贴合坐标轴(spec §3.2), + // 并由上层 CategoryAnalysisTab 做全列互斥。voxel 段切片/异常的 VTK 高亮由上层据 ddCode 处理, + // 故此信号对所有段发射即可,任意类型选中都能显子树贴合轴(非仅三维体)。 + // 空选中(点树空白/清选)→ 发空 datasetSelected:上层据此恢复全景轴、清高亮(贴合轴取消)。 + connect(list_, &QTreeWidget::itemSelectionChanged, this, [this] { + const auto items = list_->selectedItems(); + if (items.isEmpty()) { emit datasetSelected(QString(), QString()); return; } + QTreeWidgetItem* it = items.first(); + const QString id = it->data(0, kDsIdRole).toString(); + const QString dd = it->data(0, kDsDdCodeRole).toString(); + if (!id.isEmpty() && dd != QStringLiteral("container")) emit datasetSelected(id, dd); + }); + if (desc_.id == "voxel") { // 仅三维体段提供右键操作菜单(体/切片/异常) list_->setContextMenuPolicy(Qt::CustomContextMenu); connect(list_, &QTreeWidget::customContextMenuRequested, this, &CategorySection::showContextMenu); - // 树选中切片/异常 → VTK 高亮联动(正向 list→VTK)。 - connect(list_, &QTreeWidget::itemSelectionChanged, this, [this] { - const auto items = list_->selectedItems(); - if (items.isEmpty()) return; - QTreeWidgetItem* it = items.first(); - const QString id = it->data(0, kDsIdRole).toString(); - const QString dd = it->data(0, kDsDdCodeRole).toString(); - if (!id.isEmpty() && dd != QStringLiteral("container")) emit datasetSelected(id, dd); - }); } body->addWidget(list_, 1); @@ -176,6 +203,20 @@ CategorySection::CategorySection(const CategorySpec& spec, geopro::data::Dataset }); } +bool CategorySection::eventFilter(QObject* obj, QEvent* ev) { + // 段体树 viewport 左键按下:在 QTreeWidget 默认处理改选之前,记录被按行及其「按下瞬间是否已选中」, + // 并清本次「勾选框改动」标记。itemClicked(释放)据此判定:已选中且未点勾选框 → toggle 取消选中。 + if (list_ && obj == list_->viewport() && ev->type() == QEvent::MouseButtonPress) { + auto* me = static_cast(ev); + if (me->button() == Qt::LeftButton) { + pressedItem_ = list_->itemAt(me->position().toPoint()); + pressedSelected_ = pressedItem_ && pressedItem_->isSelected(); + checkToggledThisPress_ = false; + } + } + return QWidget::eventFilter(obj, ev); +} + bool CategorySection::isExpanded() const { return header_ && header_->isChecked(); } void CategorySection::ensureExpanded() { @@ -189,6 +230,37 @@ QTreeWidgetItem* CategorySection::itemFor(const QString& dsId) const { return nullptr; } +QStringList CategorySection::subtreeDsIds(const QString& dsId) const { + QTreeWidgetItem* item = itemFor(dsId); + if (!item) return {}; + // 归一到子树根:向上找到最高的非容器祖先(三维体行;其父为结构容器 TM)。选中体/切片/异常都收敛到该体。 + QTreeWidgetItem* root = item; + for (QTreeWidgetItem* p = root->parent(); p; p = p->parent()) { + if (p->data(0, kDsDdCodeRole).toString() == QStringLiteral("container")) break; + root = p; + } + // 自根向下遍历整棵子树,收集非空 dsId(跳过容器骨架节点)。 + QStringList ids; + std::vector stack{root}; + while (!stack.empty()) { + QTreeWidgetItem* it = stack.back(); + stack.pop_back(); + const QString id = it->data(0, kDsIdRole).toString(); + if (!id.isEmpty()) ids << id; + for (int i = 0; i < it->childCount(); ++i) stack.push_back(it->child(i)); + } + return ids; +} + +bool CategorySection::hasRenderableRows() const { + if (!list_) return false; + // 数据行 = 非 container 的可勾选行;只有容器节点(分组)不算「有数据」。 + for (QTreeWidgetItemIterator it(list_); *it; ++it) + if ((*it)->data(0, kDsDdCodeRole).toString() != QStringLiteral("container")) + return true; + return false; +} + void CategorySection::setStructure(const std::vector& nodes) { structure_ = nodes; // 容器分层(项目根/GS/TM→ds)在 Task 12 接入真实结构后据此构建。 } @@ -209,6 +281,13 @@ void CategorySection::selectItem(const QString& dsId) { list_->setCurrentItem(nullptr); // 空 dsId / 未找到 → 清选中 } +void CategorySection::clearSelection() { + if (!list_) return; + const QSignalBlocker block(list_); // 跨段互斥清选:被清段不回发 datasetSelected(空),避免环路/误恢复全景轴 + list_->setCurrentItem(nullptr); + list_->clearSelection(); +} + void CategorySection::setChecked(const QString& dsId, bool on) { for (QTreeWidgetItemIterator it(list_); *it; ++it) if ((*it)->data(0, kDsIdRole).toString() == dsId && @@ -264,7 +343,7 @@ void CategorySection::clearAllBusy() { } void CategorySection::refreshArrayCombo() { - if (!spec_.hasArrayTypeFilter || !arrayCombo_) return; + if (!arrayCombo_) return; // 该段无装置筛选控件(desc_.filters 未含 ArrayType)→ 不刷 const QString prev = arrayCombo_->currentData().toString(); const QSignalBlocker block(arrayCombo_); arrayCombo_->clear(); @@ -294,8 +373,8 @@ void CategorySection::refreshArrayCombo() { } bool CategorySection::passesFilters(const DsRow& row) const { - // 类型筛选("全部"=空不筛):按 ds 自身类型值(typeName,回退 dsTypeCode)命中选中项。 - if (spec_.hasArrayTypeFilter && arrayCombo_) { + // 类型筛选("全部"=空不筛;无 arrayCombo_=该段不筛装置维度):按 ds 自身类型值(typeName,回退 dsTypeCode)命中选中项。 + if (arrayCombo_) { const QString sel = arrayCombo_->currentData().toString(); if (!sel.isEmpty()) { const QString t = !row.typeName.empty() ? QString::fromStdString(row.typeName) @@ -307,7 +386,8 @@ bool CategorySection::passesFilters(const DsRow& row) const { const QDate from = dateRange_ ? dateRange_->from() : QDate(); const QDate to = dateRange_ ? dateRange_->to() : QDate(); if (from.isValid() || to.isValid()) { - const DsTypeFields* f = dict_ ? dict_->fields(spec_.dsTypeCode) : nullptr; + // 采集时间字段定义按 ds 自身类型取(描述符无段级 dsTypeCode;逐行查更准,缺定义则回退 createTime)。 + const DsTypeFields* f = dict_ ? dict_->fields(row.dsTypeCode) : nullptr; std::string ts = f ? collectTimeOf(row, *f) : std::string(); if (ts.empty()) ts = row.createTime; const QDate d = QDate::fromString(QString::fromStdString(ts).left(10), QStringLiteral("yyyy-MM-dd")); @@ -452,4 +532,86 @@ void CategorySection::showContextMenu(const QPoint& pos) { menu.exec(list_->viewport()->mapToGlobal(pos)); } +void CategorySection::showPlaneZPopup(QToolButton* host) { + // z 值滑块 popup(仿工具条底图滑块):拖动整体升降本类型平面(含其上全部足迹)。 + // 范围 ±500 米:覆盖常见场景高程量级;滑块值即平面绝对高程(米),首开回显 lastPlaneZ_(无则 0)。 + constexpr int kPlaneZRangeM = 500; + QMenu menu(this); + auto* wa = new QWidgetAction(&menu); + auto* box = new QWidget(&menu); + auto* lay = new QVBoxLayout(box); + lay->setContentsMargins(space::kMd, space::kSm, space::kMd, space::kSm); + lay->setSpacing(space::kXs); + auto* lab = new QLabel(box); + auto* sld = new QSlider(Qt::Horizontal, box); + sld->setRange(-kPlaneZRangeM, kPlaneZRangeM); + sld->setValue(static_cast(lastPlaneZ_)); + sld->setMinimumWidth(160); + sld->setToolTip(QStringLiteral("平面高程 z(米)")); + auto syncLabel = [lab](int v) { lab->setText(QStringLiteral("平面 z:%1 米").arg(v)); }; + syncLabel(sld->value()); + // 直接平移平面/足迹/底图(改 actor position,即时同步)→ 无需防抖:valueChanged 每步直发 planeZChanged, + // 拖动即实时跟随、无移除+异步重载。lastPlaneZ_ 记住终值供重开 popup 回显。 + connect(sld, &QSlider::valueChanged, this, [this, syncLabel](int v) { + lastPlaneZ_ = v; + syncLabel(v); + emit planeZChanged(QString::fromStdString(desc_.id), static_cast(v)); + }); + lay->addWidget(lab); + lay->addWidget(sld); + wa->setDefaultWidget(box); + menu.addAction(wa); + menu.exec(host->mapToGlobal(QPoint(0, host->height()))); +} + +void CategorySection::showBasemapPopup(QToolButton* host) { + // 底图 popup(仿工具条底图控件):本类型平面底图 矢量平面(默认)/无 + 透明度滑块(0–100,默认 50)。 + // 类型切换为离散单次事件直发;透明度拖动逐步触发→单发 QTimer(150ms)防抖,停手后一次发射,免抖动重铺瓦片。 + QMenu menu(this); + auto* wa = new QWidgetAction(&menu); + auto* box = new QWidget(&menu); + auto* lay = new QVBoxLayout(box); + lay->setContentsMargins(space::kMd, space::kSm, space::kMd, space::kSm); + lay->setSpacing(space::kXs); + + auto* kindCombo = new QComboBox(box); + kindCombo->addItem(QStringLiteral("矢量平面")); // index 0 + kindCombo->addItem(QStringLiteral("无")); // index 1 + kindCombo->setCurrentIndex(lastBasemapKind_); + connect(kindCombo, &QComboBox::currentIndexChanged, this, [this](int idx) { + lastBasemapKind_ = idx; + emit basemapKindChanged(QString::fromStdString(desc_.id), idx); + }); + + auto* lab = new QLabel(box); + auto* sld = new QSlider(Qt::Horizontal, box); + sld->setRange(0, 100); + sld->setValue(lastBasemapOpacity_); + sld->setMinimumWidth(160); + sld->setToolTip(QStringLiteral("底图透明度")); + auto syncLabel = [lab](int v) { lab->setText(QStringLiteral("透明度:%1%").arg(v)); }; + syncLabel(sld->value()); + if (!basemapOpacityTimer_) { // 定时器 parent=this,存活于 modal popup 之外,停手后安全发射终值 + basemapOpacityTimer_ = new QTimer(this); + basemapOpacityTimer_->setSingleShot(true); + basemapOpacityTimer_->setInterval(150); + connect(basemapOpacityTimer_, &QTimer::timeout, this, [this]() { + emit basemapOpacityChanged(QString::fromStdString(desc_.id), pendingBasemapOpacity_); + }); + } + connect(sld, &QSlider::valueChanged, this, [this, syncLabel](int v) { + lastBasemapOpacity_ = v; + syncLabel(v); + pendingBasemapOpacity_ = v / 100.0; + basemapOpacityTimer_->start(); // 重启防抖窗口:覆盖拖动、键盘、点轨——停手后一次性发射 + }); + + lay->addWidget(kindCombo); + lay->addWidget(lab); + lay->addWidget(sld); + wa->setDefaultWidget(box); + menu.addAction(wa); + menu.exec(host->mapToGlobal(QPoint(0, host->height()))); +} + } // namespace geopro::app diff --git a/src/app/panels/columns/CategorySection.hpp b/src/app/panels/columns/CategorySection.hpp index 1ce1714..af03d36 100644 --- a/src/app/panels/columns/CategorySection.hpp +++ b/src/app/panels/columns/CategorySection.hpp @@ -3,7 +3,7 @@ #include #include #include "interact/SlicePlaneMath.hpp" // geopro::render::interact::SliceAxis -#include "repo/CategoryConfig.hpp" +#include "repo/CategoryDescriptor.hpp" // geopro::data::CategoryDescriptor / OpKind / FilterKind #include "repo/RepoTypes.hpp" class QTreeWidget; @@ -22,14 +22,16 @@ class DatasetFieldDictionary; namespace geopro::app { class DateRangeEdit; +class SectionIconBar; -// 单个数据类型大类段(spec §7):段头(标题/折叠 + 装置类型/日期筛选 + 「+新增三维体」)+ 段体(可勾选数据树)。 -// 勾选数据行 = 渲染(帘面/体素/切片);段头生成按钮据当前勾选源发 generateVolumeRequested。 +// 单个数据类型大类段(spec §7):段头(标题/折叠 + 段头图标条)+ 段体(折叠筛选行 + 可勾选数据树)。 +// 段头图标条由 descriptor.operations(OpKind) 驱动;筛选行由 descriptor.filters(FilterKind) 驱动、默认折叠。 +// 勾选数据行 = 渲染(帘面/体素/切片/二维面);GenerateVolume 图标据当前勾选源发 generateVolumeRequested。 class CategorySection : public QWidget { Q_OBJECT public: - CategorySection(const CategorySpec& spec, geopro::data::DatasetFieldDictionary* dict, - QWidget* parent = nullptr); + CategorySection(const geopro::data::CategoryDescriptor& desc, + geopro::data::DatasetFieldDictionary* dict, QWidget* parent = nullptr); // 对象树同源的扁平 GS/TM 节点(段体容器分层用;Task 12 接入真实结构,当前仅存储)。 void setStructure(const std::vector& nodes); @@ -39,20 +41,34 @@ public: void setBusy(const QString& dsId, bool busy); void clearAllBusy(); // 撤回本段所有 spinner(失败兜底) void selectItem(const QString& dsId); // 程序化选中某行(VTK→list 反向联动);空/未找到=清选中 + void clearSelection(); // 清本段树选中(信号阻断,不回发 datasetSelected);供跨段全列互斥用 QStringList checkedIds() const { return checkedDsIds(); } // 当前勾选 ds(异常显隐同步用) void refreshArrayFilter() { refreshArrayCombo(); } // 装置枚举异步加载后重填下拉 - const CategorySpec& spec() const { return spec_; } bool isExpanded() const; // 段头展开态(供外层按折叠状态重分配 stretch,实现"折叠向上收") void ensureExpanded(); // 展开本段(折叠则展开):滚动定位前确保目标行可见 QTreeWidget* listWidget() const { return list_; } // 段体树(外层滚动定位用) QTreeWidgetItem* itemFor(const QString& dsId) const; // 按 dsId 取行(无则 nullptr) + // 该 ds 所在层级子树的全部 dsId(贴合坐标轴子树盒,spec §2/§3.2 决策 3):先归一到子树根 + // (向上找最高非容器祖先=三维体行),再自根向下收集整棵子树(体+切片+异常)的非空 dsId。 + // 故选中体/切片/异常都归到同一个「该体子树」盒。dsId 不在本段则返回空。 + QStringList subtreeDsIds(const QString& dsId) const; + bool hasRenderableRows() const; // 段体是否含可渲染数据行(非 container 容器节点),供单列动态显隐 + +protected: + // 段体树 viewport 事件过滤:在默认处理改选之前记录「按下瞬间该行是否已选中」,供单击已选行的 toggle 取消判定。 + bool eventFilter(QObject* obj, QEvent* ev) override; signals: void checkedDatasetsChanged(const QStringList& dsIds); // 数据行勾选=渲染 void collapsedChanged(); // 折叠/展开切换 → 外层 CategoryAnalysisTab 重排各段 stretch - void generateVolumeRequested(const QString& dsTypeCode, const QStringList& sourceDsIds); // 段头「+新增三维体」 - void radarImportRequested(bool impulse); // 三维体段头「+导入雷达测线」(false=规范化 .head/.data, true=Impulse .iprb) - void detailRequested(const QString& dsId, const QString& ddCode, const QString& name); // 双击/右键=详情 + void generateVolumeRequested(const QString& dsTypeCode, const QStringList& sourceDsIds); // 段头「+新增三维体」(接收方按 sourceIds 解析类型) + void planeZChanged(const QString& typeId, double z); // PlaneZ 滑块:整体升降该 2D 类型平面(z=绝对高程,米) + void basemapKindChanged(const QString& typeId, int kind); // 底图弹窗:矢量平面(0)/无(1) + void basemapOpacityChanged(const QString& typeId, double o); // 底图弹窗透明度滑块[0,1](防抖发射) + void detailRequested(const QString& dsId, const QString& ddCode, const QString& name); // 右键「详情」=属性弹窗 + // 双击某行(决策6/T4):适配相机到该 ds 子树空间范围 + 联动中下方详情面板(无详情页类型静默)。 + // 与 detailRequested(右键属性弹窗)分开,使双击「只适配 + 图表联动」,三维体等无图表页时静默。 + void datasetActivated(const QString& dsId, const QString& ddCode, const QString& name); void deleteDatasetRequested(const QString& dsId, const QString& ddCode); // 右键删除(切片/异常) // ── 三维体段右键操作(迁自旧 Column3DAnalysis,全接)── void sliceRequested(geopro::render::interact::SliceAxis axis, const QString& volumeDsId); // 体→生成切片(轴+目标体) @@ -67,24 +83,37 @@ signals: private: void showContextMenu(const QPoint& pos); // 段体树右键菜单(详情 + 删除) + void showPlaneZPopup(QToolButton* host); // PlaneZ 图标:弹 z 值滑块 popup → planeZChanged + void showBasemapPopup(QToolButton* host); // Basemap 图标:弹 矢量平面/无 + 透明度滑块 popup void rebuildList(); // 据 rows_(经装置/日期筛选)重建段体树并复原勾选 void refreshArrayCombo(); // 据当前 rows_ 重填装置类型下拉项(经字典 value→中文) void emitChecked(); // 收集勾选 → checkedDatasetsChanged QStringList checkedDsIds() const; bool passesFilters(const geopro::data::DsRow& row) const; // 装置类型 + 采集时间范围 - CategorySpec spec_; + geopro::data::CategoryDescriptor desc_; geopro::data::DatasetFieldDictionary* dict_ = nullptr; std::vector rows_; std::vector structure_; QToolButton* header_ = nullptr; // 折叠头(标题 + 箭头) + SectionIconBar* iconBar_ = nullptr; // 段头响应式图标条(由 desc_.operations 装配) QWidget* body_ = nullptr; // 段体容器(折叠时隐藏) - QComboBox* arrayCombo_ = nullptr; // 装置类型筛选(仅 hasArrayTypeFilter) + QWidget* filterRow_ = nullptr; // 筛选行容器(默认折叠,由 Filter 图标 toggle) + QComboBox* arrayCombo_ = nullptr; // 装置类型筛选(仅 desc_.filters 含 ArrayType) DateRangeEdit* dateRange_ = nullptr; // 采集时间范围筛选(起止双日历,可清空) QTreeWidget* list_ = nullptr; QTimer* spinTimer_ = nullptr; // 驱动 busy 行 spinner 旋转(有 busy 行时运行) int spinAngle_ = 0; // 当前 spinner 角度(度) + double lastPlaneZ_ = 0.0; // 上次 z 值滑块设定的平面高程(重开 popup 时回显,无则 0;直接平移故无防抖) + int lastBasemapKind_ = 0; // 上次底图选择(0=矢量平面/1=无),重开 popup 时回显 + int lastBasemapOpacity_ = 50; // 上次底图透明度(0–100,重开 popup 回显;默认 50) + QTimer* basemapOpacityTimer_ = nullptr; // 底图透明度滑块发射防抖(透明度改动会触发瓦片重铺,仍需防抖) + double pendingBasemapOpacity_ = 0.5; // 防抖待发的底图透明度[0,1](定时器到点发射 basemapOpacityChanged) + // 单击已选行取消选中(toggle-off):eventFilter 于按下瞬间(默认改选前)记录被按行及其选中态;itemClicked 据此取消。 + QTreeWidgetItem* pressedItem_ = nullptr; // 本次左键按下的行(itemAt(press),与 itemClicked 的行比对) + bool pressedSelected_ = false; // 按下瞬间该行是否已选中(true 且非勾选框点击时,单击取消选中) + bool checkToggledThisPress_ = false; // 本次按下是否切换了勾选框(点勾选框只改渲染,不取消选中) }; } // namespace geopro::app diff --git a/src/app/panels/columns/Column2DDataset.cpp b/src/app/panels/columns/Column2DDataset.cpp deleted file mode 100644 index a8da596..0000000 --- a/src/app/panels/columns/Column2DDataset.cpp +++ /dev/null @@ -1,129 +0,0 @@ -#include "panels/columns/Column2DDataset.hpp" - -#include -#include - -#include - -#include "EmptyAwareComboBox.hpp" -#include -#include -#include -#include -#include -#include -#include -#include - -#include "Theme.hpp" -#include "panels/DatasetListPanel.hpp" - -namespace geopro::app { - -Column2DDataset::Column2DDataset(QWidget* parent) : QWidget(parent) { - auto* root = new QVBoxLayout(this); - root->setContentsMargins(space::kMd, space::kMd, space::kMd, space::kMd); - root->setSpacing(space::kMd); - - // 地图 - { - auto* form = new QFormLayout(); - auto* basemap = new EmptyAwareComboBox(); - basemap->addItem(QStringLiteral("天地图")); - basemap->addItem(QStringLiteral("Google Map")); - basemap->addItem(QStringLiteral("隐藏")); - basemap->setCurrentIndex(0); // 默认天地图:数据重锚后由 onFrameReanchored 在数据位置加载 - connect(basemap, qOverload(&QComboBox::currentIndexChanged), this, - [this](int index) { emit basemapChanged(index); }); - form->addRow(QStringLiteral("底图源"), basemap); - root->addWidget(new QLabel(QStringLiteral("地图"))); - root->addLayout(form); - } - - // 2D视图 - { - auto* form = new QFormLayout(); - auto* view2d = new EmptyAwareComboBox(); - view2d->addItem(QStringLiteral("关闭")); - view2d->addItem(QStringLiteral("Z=0")); - view2d->addItem(QStringLiteral("顶部")); - view2d->addItem(QStringLiteral("底部")); - view2d->addItem(QStringLiteral("自定义")); - view2d->setCurrentIndex(1); - auto* zSpin = new QDoubleSpinBox(); - zSpin->setRange(-1000000, 1000000); - zSpin->setSuffix(QStringLiteral(" m")); - zSpin->setValue(0); - connect(view2d, qOverload(&QComboBox::currentIndexChanged), this, - [this, form, zSpin](int idx) { - form->setRowVisible(zSpin, idx == 4); // 整行隐藏(含"Z 值"标签),非自定义时不留孤标签 - emit view2DModeChanged(idx); - }); - connect(zSpin, qOverload(&QDoubleSpinBox::valueChanged), this, - [this](double z) { emit customZChanged(z); }); - form->addRow(QStringLiteral("位置"), view2d); - form->addRow(QStringLiteral("Z 值"), zSpin); - form->setRowVisible(zSpin, false); // 默认非自定义→隐藏整行 - root->addWidget(new QLabel(QStringLiteral("2D视图"))); - root->addLayout(form); - } - - // 数据集列表(可勾选) - list_ = new QTreeWidget(); - list_->setHeaderHidden(true); - list_->setRootIsDecorated(true); - list_->setSelectionMode(QAbstractItemView::ExtendedSelection); // 多选行(与 VTK 多选拖动联动) - applyDatasetCardDelegate(list_); - connect(list_, &QTreeWidget::itemChanged, this, [this](QTreeWidgetItem*, int) { - QStringList ids; - for (QTreeWidgetItemIterator it(list_); *it; ++it) { - if ((*it)->checkState(0) == Qt::Checked) - ids << (*it)->data(0, kDsIdRole).toString(); - } - emit checkedDatasetsChanged(ids); - }); - // 行选中变化 → 上抛选中 dsId(高亮联动 VTK;与勾选/渲染独立)。 - connect(list_, &QTreeWidget::itemSelectionChanged, this, [this]() { - QStringList ids; - for (QTreeWidgetItem* it : list_->selectedItems()) - ids << it->data(0, kDsIdRole).toString(); - emit selectedDatasetsChanged(ids); - }); - root->addWidget(list_, 1); -} - -void Column2DDataset::setDatasets(const std::vector& rows) { - // 增量保留:记住当前已勾选的足迹 ds,重建后复原(仍存在的项保持勾选)。否则对象树每次增删勾选都触发 - // 本刷新 → 清空全部勾选 + 上抛空集 → 已渲染足迹被移除、列表选中丢失(用户反馈:必须增量更新, - // 与三维分析段 CategorySection::rebuildList 同一处理)。 - std::set wasChecked; - for (QTreeWidgetItemIterator it(list_); *it; ++it) - if ((*it)->checkState(0) == Qt::Checked) - wasChecked.insert((*it)->data(0, kDsIdRole).toString().toStdString()); - - { - QSignalBlocker blocker(list_); - populateDatasetList(list_, rows, /*append=*/false); - for (QTreeWidgetItemIterator it(list_); *it; ++it) { - (*it)->setFlags((*it)->flags() | Qt::ItemIsUserCheckable); - const std::string id = (*it)->data(0, kDsIdRole).toString().toStdString(); - // 复原勾选:仍存在的曾勾选项保持勾选;新项默认不勾。 - (*it)->setCheckState(0, wasChecked.count(id) ? Qt::Checked : Qt::Unchecked); - } - } // blocker released here - // 上抛复原后的勾选集(保持渲染,不再清空 → 控制器据 diff 增量保留已渲染足迹,集合不变则不增删)。 - QStringList ids; - for (QTreeWidgetItemIterator it(list_); *it; ++it) - if ((*it)->checkState(0) == Qt::Checked) - ids << (*it)->data(0, kDsIdRole).toString(); - emit checkedDatasetsChanged(ids); -} - -void Column2DDataset::setSelectedDsIds(const QStringList& dsIds) { - QSignalBlocker blocker(list_); // 防回环:VTK→列表 设置选中不再上抛 selectedDatasetsChanged - list_->clearSelection(); - for (QTreeWidgetItemIterator it(list_); *it; ++it) - if (dsIds.contains((*it)->data(0, kDsIdRole).toString())) (*it)->setSelected(true); -} - -} // namespace geopro::app diff --git a/src/app/panels/columns/Column2DDataset.hpp b/src/app/panels/columns/Column2DDataset.hpp deleted file mode 100644 index 5003510..0000000 --- a/src/app/panels/columns/Column2DDataset.hpp +++ /dev/null @@ -1,31 +0,0 @@ -#pragma once -#include -#include -#include -#include "repo/RepoTypes.hpp" - -class QTreeWidget; - -namespace geopro::app { - -// 二维数据集栏:地图 + 2D视图(含自定义 Z) + 2D 数据集列表。 -class Column2DDataset : public QWidget { - Q_OBJECT -public: - explicit Column2DDataset(QWidget* parent = nullptr); - void setDatasets(const std::vector& rows); - // VTK→列表 选择联动:按 dsId 选中对应行(高亮),内部屏蔽信号避免回环。 - void setSelectedDsIds(const QStringList& dsIds); - -signals: - void basemapChanged(int index); // 0 天地图 / 1 Google / 2 隐藏 - void view2DModeChanged(int index); // 0 关闭 /1 Z=0 /2 顶部 /3 底部 /4 自定义 - void customZChanged(double z); // 世界绝对高程(米),向上为正 - void checkedDatasetsChanged(const QStringList& dsIds); // 勾选(渲染开关)变化 - void selectedDatasetsChanged(const QStringList& dsIds); // 行选中(高亮联动)变化,非勾选 - -private: - QTreeWidget* list_ = nullptr; -}; - -} // namespace geopro::app diff --git a/src/app/panels/columns/ColumnDrawer.cpp b/src/app/panels/columns/ColumnDrawer.cpp index 7d74414..2f3703c 100644 --- a/src/app/panels/columns/ColumnDrawer.cpp +++ b/src/app/panels/columns/ColumnDrawer.cpp @@ -1,34 +1,19 @@ #include "panels/columns/ColumnDrawer.hpp" -#include "panels/columns/Column2DDataset.hpp" #include "panels/columns/CategoryAnalysisTab.hpp" -#include #include "Glyphs.hpp" #include "Theme.hpp" #include #include -#include -#include -#include namespace geopro::app { ColumnDrawer::ColumnDrawer(QWidget* parent, geopro::data::DatasetFieldDictionary* dict) : QWidget(parent) { - col2D_ = new Column2DDataset(this); + // 单列承载:去 QTabWidget,body_ 直接为 CategoryAnalysisTab(含 trajectory 段,二维并入同列)。 analysisTab_ = new CategoryAnalysisTab(dict, this); - - // Tab 容器(body_):两 tab(三维分析[分段] / 二维分析)。 - auto* tabs = new QTabWidget(this); - body_ = tabs; - tabs->addTab(analysisTab_, QStringLiteral("三维分析")); - tabs->addTab(col2D_, QStringLiteral("二维分析")); - tabs->tabBar()->setUsesScrollButtons(false); // 永不出左右滚动箭头(两 tab 必能平铺) - // 切 tab → 发 analysisModeChanged(is2D):以"当前 widget 是否 col2D"判定,不写死索引。 - connect(tabs, &QTabWidget::currentChanged, this, [this, tabs](int idx) { - emit analysisModeChanged(tabs->widget(idx) == col2D_); - }); + body_ = analysisTab_; // 折叠按钮:固定宽 18px,垂直拉伸。 // 用 SVG 图标(makeGlyph)而非 ◀/▶ 文字——三角符(U+25C0/25B6)不在 YaHei,作按钮文字会触发 @@ -58,22 +43,6 @@ ColumnDrawer::ColumnDrawer(QWidget* parent, geopro::data::DatasetFieldDictionary setMaximumWidth(560); } -void ColumnDrawer::resizeEvent(QResizeEvent* e) -{ - QWidget::resizeEvent(e); - // 两 tab 平分抽屉宽度填满(带样式表的 tab 不响应 setExpanding,须按 barWidth/n 显式给宽)。 - // 消除旧 3 栏布局遗留的右侧空白——重构成 2 栏后不再三分、留空第三位。 - if (auto* tabs = qobject_cast(body_)) { - const int n = tabs->count(); - if (n > 0 && tabs->width() > 0) { - // 每 tab 内容宽 = 总宽/n - 每 tab 非内容开销(全局 QSS padding 8+16+16=… 约 32 + margin 4)。 - // 稍欠一点宽避免溢出(溢出会触发滚动箭头);setUsesScrollButtons(false) 再兜底。 - const int w = std::max(40, tabs->width() / n - 42); - tabs->tabBar()->setStyleSheet(QStringLiteral("QTabBar::tab{width:%1px;}").arg(w)); - } - } -} - void ColumnDrawer::toggleCollapsed() { collapsed_ = !collapsed_; diff --git a/src/app/panels/columns/ColumnDrawer.hpp b/src/app/panels/columns/ColumnDrawer.hpp index 05175c1..65e31cc 100644 --- a/src/app/panels/columns/ColumnDrawer.hpp +++ b/src/app/panels/columns/ColumnDrawer.hpp @@ -2,7 +2,6 @@ #include class QPushButton; -class QResizeEvent; namespace geopro::data { class DatasetFieldDictionary; @@ -10,36 +9,29 @@ class DatasetFieldDictionary; namespace geopro::app { -class Column2DDataset; class CategoryAnalysisTab; -// VTK视图左侧内嵌抽屉:两 tab(三维分析[按数据类型分段]/二维分析) + 折叠开关。 +// VTK视图左侧内嵌抽屉:单列承载 CategoryAnalysisTab(按数据类型分段,含 trajectory 段) + 折叠开关。 +// B2 去 tab:原「三维分析 / 二维分析」双 tab 合一,二维(足迹/轨迹)经描述符分段并入同一列。 class ColumnDrawer : public QWidget { Q_OBJECT public: explicit ColumnDrawer(QWidget* parent = nullptr, geopro::data::DatasetFieldDictionary* dict = nullptr); - Column2DDataset* col2D() const { return col2D_; } CategoryAnalysisTab* analysisTab() const { return analysisTab_; } signals: - // 切换「三维分析 / 二维分析」tab:is2D=true 进入二维分析。上层据此切相机+翻维度可见标志。 - void analysisModeChanged(bool is2D); // 折叠/展开切换:上层据此调整 QSplitter 尺寸,回收/恢复抽屉栏宽(否则收起残留空白区)。 void collapsedChanged(bool collapsed); public slots: void toggleCollapsed(); - void expand(); // 强制展开(进入全屏时确保三栏可见) - -protected: - void resizeEvent(QResizeEvent* e) override; // 两 tab 按抽屉宽平分(消除右侧空白"第三栏位") + void expand(); // 强制展开(进入全屏时确保单列可见) private: - Column2DDataset* col2D_ = nullptr; CategoryAnalysisTab* analysisTab_ = nullptr; - QWidget* body_ = nullptr; // QTabWidget,折叠时隐藏 + QWidget* body_ = nullptr; // = analysisTab_(折叠时隐藏) QPushButton* toggleBtn_ = nullptr; bool collapsed_ = false; }; diff --git a/src/app/panels/columns/SectionIconBar.cpp b/src/app/panels/columns/SectionIconBar.cpp new file mode 100644 index 0000000..55cae40 --- /dev/null +++ b/src/app/panels/columns/SectionIconBar.cpp @@ -0,0 +1,159 @@ +#include "panels/columns/SectionIconBar.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "Glyphs.hpp" +#include "Theme.hpp" + +namespace geopro::app { + +int visibleIconCount(int totalIcons, int availablePx, int iconPx, int overflowPx, int maxIcons) { + if (totalIcons <= 0 || iconPx <= 0) return 0; + const int ideal = std::min(totalIcons, std::max(0, maxIcons)); + const bool overflowFromCap = totalIcons > ideal; // 超 max → 必有溢出位 + // 先看理想数能否放下(若已因 cap 溢出,理想数也要含溢出位) + auto fits = [&](int n, bool withOverflow) { + return n * iconPx + (withOverflow ? overflowPx : 0) <= availablePx; + }; + int n = ideal; + bool overflow = overflowFromCap; + while (n > 0 && !fits(n, overflow || (totalIcons > n))) { + --n; + overflow = true; // 一旦减少必有「…」 + } + if (n < 0) n = 0; + return n; +} + +namespace { +constexpr int kGlyphPx = 18; // glyph 绘制像素;按钮本体宽用 iconPx_,与溢出计算口径一致 + +// glyphKey 字符串 → Glyph 枚举(C2 按段头操作填键;未知键回退中性图标)。 +Glyph glyphFromKey(const QString& key) { + const QString k = key.toLower(); + if (k == QStringLiteral("plus")) return Glyph::Plus; + if (k == QStringLiteral("filter")) return Glyph::Filter; + if (k == QStringLiteral("upload")) return Glyph::Upload; + if (k == QStringLiteral("download")) return Glyph::Download; + if (k == QStringLiteral("collapse")) return Glyph::Collapse; + if (k == QStringLiteral("fullscreen")) return Glyph::Fullscreen; + if (k == QStringLiteral("map")) return Glyph::Map; + if (k == QStringLiteral("detail")) return Glyph::Detail; + if (k == QStringLiteral("property")) return Glyph::Property; + if (k == QStringLiteral("gear")) return Glyph::Gear; + return Glyph::Property; +} +} // namespace + +SectionIconBar::SectionIconBar(QWidget* parent) : QWidget(parent) { + auto* lay = new QHBoxLayout(this); + lay->setContentsMargins(0, 0, 0, 0); + lay->setSpacing(0); + // 水平 Preferred:优先取 sizeHint(全图标宽),列变窄受压时可缩向 minimumSizeHint(仅「…」); + // 垂直 Fixed:高度恒为图标按钮高。不可用 Fixed-Fixed(永不折叠)或 Ignored(丢 sizeHint)。 + setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Fixed); +} + +QSize SectionIconBar::sizeHint() const { + // 默认上限内的图标全显所需宽度:min(操作数, maxIcons_) 个按钮,各占 iconPx_。 + const int count = std::min(static_cast(actions_.size()), std::max(0, maxIcons_)); + return QSize(count * iconPx_, iconPx_); +} + +QSize SectionIconBar::minimumSizeHint() const { + // 最窄也要放下「…」溢出按钮,使本条可缩到仅剩「…」。 + return QSize(overflowPx_, iconPx_); +} + +void SectionIconBar::setActions(const std::vector& a) { + // 清旧按钮与溢出按钮 + for (auto* b : btns_) { + if (b) b->deleteLater(); + } + btns_.clear(); + if (overflowBtn_) { + overflowBtn_->deleteLater(); + overflowBtn_ = nullptr; + } + + actions_ = a; + auto* lay = qobject_cast(layout()); + const QColor ic = tokenColor("text/secondary"); + + for (const IconAction& act : actions_) { + auto* b = new QToolButton(this); + b->setIcon(makeGlyph(glyphFromKey(act.glyphKey), ic, kGlyphPx)); + b->setIconSize(QSize(kGlyphPx, kGlyphPx)); + b->setAutoRaise(true); + b->setToolTip(act.tooltip); + b->setFixedSize(iconPx_, iconPx_); + if (act.popupBuilder) { + const auto builder = act.popupBuilder; + QToolButton* host = b; + connect(b, &QToolButton::clicked, this, [builder, host] { builder(host); }); + } else if (act.onClick) { + const auto cb = act.onClick; + connect(b, &QToolButton::clicked, this, [cb] { cb(); }); + } + if (lay) lay->addWidget(b); + btns_.push_back(b); + } + + // 末尾「…」溢出按钮:即点即弹菜单 + overflowBtn_ = new QToolButton(this); + overflowBtn_->setText(QStringLiteral("…")); + overflowBtn_->setAutoRaise(true); + overflowBtn_->setToolTip(QStringLiteral("更多")); + overflowBtn_->setFixedSize(overflowPx_, iconPx_); + overflowBtn_->setPopupMode(QToolButton::InstantPopup); + overflowBtn_->setMenu(new QMenu(overflowBtn_)); + if (lay) lay->addWidget(overflowBtn_); + + updateGeometry(); // 操作数变化 → 通知父布局重新按新 sizeHint 分配宽度 + relayout(); +} + +void SectionIconBar::resizeEvent(QResizeEvent* e) { + QWidget::resizeEvent(e); + relayout(); +} + +void SectionIconBar::relayout() { + const int total = static_cast(actions_.size()); + const int vis = visibleIconCount(total, width(), iconPx_, overflowPx_, maxIcons_); + + QMenu* menu = overflowBtn_ ? overflowBtn_->menu() : nullptr; + if (menu) menu->clear(); + + for (int i = 0; i < total; ++i) { + QToolButton* b = btns_[static_cast(i)]; + if (!b) continue; + if (i < vis) { + b->setVisible(true); + continue; + } + b->setVisible(false); + if (!menu) continue; + const IconAction& act = actions_[static_cast(i)]; + QAction* ma = menu->addAction(act.tooltip); + if (act.popupBuilder) { + const auto builder = act.popupBuilder; + QToolButton* host = overflowBtn_; + connect(ma, &QAction::triggered, this, [builder, host] { builder(host); }); + } else if (act.onClick) { + const auto cb = act.onClick; + connect(ma, &QAction::triggered, this, [cb] { cb(); }); + } + } + + if (overflowBtn_) overflowBtn_->setVisible(vis < total); +} + +} // namespace geopro::app diff --git a/src/app/panels/columns/SectionIconBar.hpp b/src/app/panels/columns/SectionIconBar.hpp new file mode 100644 index 0000000..4033ff8 --- /dev/null +++ b/src/app/panels/columns/SectionIconBar.hpp @@ -0,0 +1,47 @@ +#pragma once +#include +#include +#include +#include +#include + +class QToolButton; +class QResizeEvent; + +namespace geopro::app { + +// 纯逻辑:给定约束返回可见图标数(其余收进「…」)。见 spec §6。 +int visibleIconCount(int totalIcons, int availablePx, int iconPx, int overflowPx, int maxIcons); + +// 段头响应式图标工具条:≤maxIcons 且宽度够则全显;否则右侧依次收进末尾「…」下拉。 +struct IconAction { + QString glyphKey; // 图标键(映射到 Glyph) + QString tooltip; + std::function onClick; // 直接动作;为空则用 popupBuilder + std::function popupBuilder; // 弹 popup(z值/底图/筛选用) +}; + +class SectionIconBar : public QWidget { + Q_OBJECT +public: + explicit SectionIconBar(QWidget* parent = nullptr); + void setActions(const std::vector& actions); // 重建按钮 + void setMaxIcons(int n) { maxIcons_ = n; updateGeometry(); relayout(); } + // sizeHint:声明放下「默认上限内全部图标」所需宽度,使段头 HBox 分给本条真实宽度 + // (否则 relayout 在 width()=0 时折叠全部图标→内层布局尺寸塌缩成只剩「…」→恒折叠,见 spec §6)。 + QSize sizeHint() const override; + // minimumSizeHint:至少容下「…」溢出按钮,列足够窄时本条可缩到仅剩「…」。 + QSize minimumSizeHint() const override; +protected: + void resizeEvent(QResizeEvent* e) override; +private: + void relayout(); // 按当前宽度算可见数,多余进「…」菜单 + std::vector actions_; + std::vector btns_; + QToolButton* overflowBtn_ = nullptr; + int maxIcons_ = 3; + int iconPx_ = 30; + int overflowPx_ = 30; +}; + +} // namespace geopro::app diff --git a/src/controller/CMakeLists.txt b/src/controller/CMakeLists.txt index 2f57347..b8ce020 100644 --- a/src/controller/CMakeLists.txt +++ b/src/controller/CMakeLists.txt @@ -3,6 +3,7 @@ add_library(geopro_controller STATIC WorkbenchNavController.cpp DatasetDetailController.cpp DatasetViewState.cpp + DatasetRenderStrategy.cpp VtkSceneController.cpp) target_include_directories(geopro_controller PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) target_link_libraries(geopro_controller PUBLIC geopro_data Qt6::Core) diff --git a/src/controller/DatasetDetailController.hpp b/src/controller/DatasetDetailController.hpp index e7eca17..2926799 100644 --- a/src/controller/DatasetDetailController.hpp +++ b/src/controller/DatasetDetailController.hpp @@ -31,6 +31,10 @@ public slots: void loadTabPaged(const QString& dsId, const QString& ddCode, int tabIndex, int pageNo, int pageSize); void focusDataset(const QString& dsId); +public: + // 该 ddCode 是否有已注册的详情页策略(=有中下方图表详情页)。供联动入口 gate: + // 无策略的类型(如 dd_voxel/dd_radar_3d 三维体)不走 openDataset,避免 loadFailed 的状态栏提示,静默。 + bool supports(const QString& ddCode) const { return registry_.supports(ddCode.toStdString()); } signals: void datasetOpened(const QString& dsId, const QString& ddCode, const QString& dsName, const QString& tmObjectId, const std::vector& tabs); diff --git a/src/controller/DatasetRenderStrategy.cpp b/src/controller/DatasetRenderStrategy.cpp new file mode 100644 index 0000000..88ce6c8 --- /dev/null +++ b/src/controller/DatasetRenderStrategy.cpp @@ -0,0 +1,98 @@ +#include "controller/DatasetRenderStrategy.hpp" + +#include +#include + +#include "controller/VtkSceneController.hpp" // 完整类型 + 含 I3dSceneView(view_.removeDataset) + +namespace geopro::controller { + +namespace { +constexpr double kDefaultBasemapOpacity = 0.5; // 平面底图默认半透明(地下足迹可透过看) +} + +// 各策略委托回控制器既有渲染路径(友元访问其私有 addDatasetAsync/add2DDatasetAsync/view_)。 +// 增量 gen 沿用控制器当前 rebuildGeneration_(不自增,与并发增量互不作废)。 +void VolumeRenderStrategy::add(const std::string& /*typeId*/, const std::string& dsId) { + ctrl_.addDatasetAsync(dsId, ctrl_.rebuildGeneration_); +} +void VolumeRenderStrategy::remove(const std::string& dsId) { ctrl_.view_.removeDataset(dsId); } + +void CurtainRenderStrategy::add(const std::string& /*typeId*/, const std::string& dsId) { + ctrl_.addDatasetAsync(dsId, ctrl_.rebuildGeneration_); +} +void CurtainRenderStrategy::remove(const std::string& dsId) { ctrl_.view_.removeDataset(dsId); } + +void Plane2DRenderStrategy::add(const std::string& typeId, const std::string& dsId) { + // 该类型平面 z:首勾以 dsZ(场景地表基准 zRefElev)定平面,后续勾选返回既有平面 z(投影)。 + const double dsZ = ctrl_.view_.zRefElev(); + const double z = planeReg_.onChecked(typeId, dsId, dsZ); + dsToType_[dsId] = typeId; // remove 仅得 dsId,记下归属类型以回收平面成员 + ctrl_.add2DDatasetAsync(dsId, ctrl_.rebuildGeneration_, z); +} +void Plane2DRenderStrategy::remove(const std::string& dsId) { + ctrl_.view_.removeDataset(dsId); + auto it = dsToType_.find(dsId); + if (it == dsToType_.end()) return; + planeReg_.onUnchecked(it->second, dsId); // 该类型成员集空时平面自动消失 + dsToType_.erase(it); +} +// 该类型首勾(控制器活跃计数 0→1,先于 add 触发):建该类型平面矢量底图。 +// 注意 onTypeActivated 在 add 之前触发 → 此刻 planeReg_ 尚无该平面,createBasemap 以 zRefElev() 兜底 +// (即首勾 add 即将写入的平面 z,二者一致)。kind/opacity 复位为默认(矢量平面/0.5)。 +void Plane2DRenderStrategy::onTypeActivated(const std::string& typeId) { + bmKind_[typeId] = 0; + bmOpacity_[typeId] = kDefaultBasemapOpacity; + createBasemap(typeId); +} + +// 该类型全消(控制器活跃计数 1→0):销毁该类型底图(析构移除瓦片→底图随平面一并消失),遗忘其 kind/opacity。 +void Plane2DRenderStrategy::onTypeDeactivated(const std::string& typeId) { + bms_.erase(typeId); + bmKind_.erase(typeId); + bmOpacity_.erase(typeId); +} + +void Plane2DRenderStrategy::setPlaneZ(const std::string& typeId, double z) { + planeReg_.setPlaneZ(typeId, z); // 平面 z 真源更新(类型不存在则无操作) + // 直接平移该类型全部已勾选足迹:只改足迹 actor 的 position(无移除+异步重载)→ 拖滑块即时跟随、无闪烁。 + std::vector dsIds; + for (const auto& [dsId, t] : dsToType_) + if (t == typeId) dsIds.push_back(dsId); + if (!dsIds.empty()) ctrl_.view_.setMapLinesZ(dsIds, z); + // 底图同步平移:直接改瓦片 position(无销毁+重建、无重下载)→ 底图与足迹一同实时跟随滑块。 + auto it = bms_.find(typeId); + if (it != bms_.end()) it->second->setGroundZ(z); + ctrl_.view_.renderIncremental(); +} + +void Plane2DRenderStrategy::setBasemapKind(const std::string& typeId, int kind) { + bmKind_[typeId] = kind; + auto it = bms_.find(typeId); + if (it == bms_.end()) return; + if (kind == 0) it->second->show(0); // 矢量平面 + else it->second->hide(); // 无(保留实例,仅隐瓦片) +} + +void Plane2DRenderStrategy::setBasemapOpacity(const std::string& typeId, double o) { + bmOpacity_[typeId] = o; + auto it = bms_.find(typeId); + if (it != bms_.end()) it->second->setOpacity(o); +} + +void Plane2DRenderStrategy::createBasemap(const std::string& typeId) { + if (!basemapFactory_) return; // 工厂未注入(如纯逻辑单测,无 VTK) → 不建底图 + // 平面 z:已建平面取 planeReg_(setPlaneZ 重建走此);onTypeActivated 时平面尚未建 → 以 zRefElev() 兜底 + // (= add 即将写入的首勾平面 z,二者一致)。 + const double gz = + planeReg_.hasPlane(typeId) ? planeReg_.planeZ(typeId) : ctrl_.view_.zRefElev(); + auto bm = basemapFactory_(gz); + if (!bm) return; + const int kind = bmKind_.count(typeId) ? bmKind_[typeId] : 0; + const double op = bmOpacity_.count(typeId) ? bmOpacity_[typeId] : kDefaultBasemapOpacity; + bm->setOpacity(op); // 先定透明度,再 show 套用到初次铺瓦 + if (kind == 0) bm->show(0); // 默认矢量平面;kind=1(无)则仅留实例 + bms_[typeId] = std::move(bm); // 替换旧实例 → 旧底图适配器析构移除其瓦片 +} + +} // namespace geopro::controller diff --git a/src/controller/DatasetRenderStrategy.hpp b/src/controller/DatasetRenderStrategy.hpp new file mode 100644 index 0000000..c2a2735 --- /dev/null +++ b/src/controller/DatasetRenderStrategy.hpp @@ -0,0 +1,100 @@ +#pragma once +#include +#include +#include +#include + +#include "controller/PlaneZRegistry.hpp" + +namespace geopro::controller { + +class VtkSceneController; // 策略委托回控制器既有渲染路径(add→addDatasetAsync/add2DDatasetAsync;remove→view.removeDataset) + +// 平面底图抽象:控制器层不依赖 VTK/app(geopro_controller 仅链 geopro_data+Qt::Core)。 +// app 层(main.cpp)经工厂注入具体 TileBasemap 适配器,仿既有 I3dSceneView/VtkSceneView 边界, +// 避免 geopro_controller 反向依赖 app 层与 VTK。 +class IPlaneBasemap { +public: + virtual ~IPlaneBasemap() = default; + virtual void show(int kind) = 0; // 0=矢量平面(Street)/其它=无(hide) + virtual void hide() = 0; + virtual void setOpacity(double o) = 0; // 半透明度[0,1] + virtual void setGroundZ(double z) = 0; // 直接平移平面高程 z(拖 z 值滑块):改瓦片 position,无重铺 +}; +// 工厂:按平面 z 造一份平面底图(底图所需 scene/渲染窗/frame/数据半径规则由 app 闭包捕获)。 +// 未注入(空)则不建底图——便于无 VTK 的纯逻辑单测。 +using PlaneBasemapFactory = std::function(double groundZ)>; + +class IDatasetRenderStrategy { +public: + virtual ~IDatasetRenderStrategy() = default; + virtual void add(const std::string& typeId, const std::string& dsId) = 0; + virtual void remove(const std::string& dsId) = 0; + virtual void onTypeActivated(const std::string& /*typeId*/) {} + virtual void onTypeDeactivated(const std::string& /*typeId*/) {} +}; + +class RenderStrategyRegistry { +public: + void registerStrategy(std::string id, std::unique_ptr s) { + strategies_[std::move(id)] = std::move(s); + } + IDatasetRenderStrategy* get(const std::string& id) const { + auto it = strategies_.find(id); + return it == strategies_.end() ? nullptr : it->second.get(); + } +private: + std::map> strategies_; +}; + +// 3 策略:各持 VtkSceneController& 引用,把 add/remove 委托回控制器既有渲染路径。 +// Volume/Curtain::add 均转调 addDatasetAsync(其内部按 isVolumeDataset 自分体/帘面分支); +// Plane2D::add 暂转调 add2DDatasetAsync(Phase E/F 改平面 z + 底图)。remove 统一 view.removeDataset。 +class VolumeRenderStrategy : public IDatasetRenderStrategy { +public: + explicit VolumeRenderStrategy(VtkSceneController& ctrl) : ctrl_(ctrl) {} + void add(const std::string& typeId, const std::string& dsId) override; + void remove(const std::string& dsId) override; +private: + VtkSceneController& ctrl_; +}; + +class CurtainRenderStrategy : public IDatasetRenderStrategy { +public: + explicit CurtainRenderStrategy(VtkSceneController& ctrl) : ctrl_(ctrl) {} + void add(const std::string& typeId, const std::string& dsId) override; + void remove(const std::string& dsId) override; +private: + VtkSceneController& ctrl_; +}; + +// 每个 2D 类型(段)的勾选足迹落到该类型「同一平面」上:首勾的 ds 定平面 z,后续投影到该 z; +// 全消则平面消失(z 遗忘)。平面 z 生命周期由 PlaneZRegistry 维护;足迹摆放经 add2DDatasetAsync(z)。 +class Plane2DRenderStrategy : public IDatasetRenderStrategy { +public: + explicit Plane2DRenderStrategy(VtkSceneController& ctrl) : ctrl_(ctrl) {} + void add(const std::string& typeId, const std::string& dsId) override; + void remove(const std::string& dsId) override; + void onTypeActivated(const std::string& typeId) override; // 首勾:建该类型平面矢量底图 + void onTypeDeactivated(const std::string& typeId) override; // 全消:销毁该类型底图(瓦片随之消失) + // 滑块整体升降该类型平面 z:更新 planeReg_ 后,对该类型已勾选足迹移除并按新 z 重摆 + 底图重建于新 z。 + void setPlaneZ(const std::string& typeId, double z); + // 底图工厂注入(main.cpp 构造后一次性下发;未注入则底图建造静默跳过,便于纯逻辑单测)。 + void setBasemapFactory(PlaneBasemapFactory f) { basemapFactory_ = std::move(f); } + void setBasemapKind(const std::string& typeId, int kind); // 0=矢量平面(show)/1=无(hide) + void setBasemapOpacity(const std::string& typeId, double o); // 该类型底图半透明度[0,1] +private: + void createBasemap(const std::string& typeId); // 按当前 z/kind/opacity 建(或重建)该类型底图 + + VtkSceneController& ctrl_; + PlaneZRegistry planeReg_; // 按类型的平面 z 生命周期 + std::map dsToType_; // dsId→typeId(remove 只得 dsId,需自存反查) + + // 每 2D 类型一份平面矢量底图(贴该类型平面 z);随平面建/销/升降。键=typeId。 + std::map> bms_; + std::map bmKind_; // 该类型底图选择(0=矢量平面/1=无),重建时复用 + std::map bmOpacity_; // 该类型底图透明度,重建时复用 + PlaneBasemapFactory basemapFactory_; // app 注入的底图工厂(空=不建底图) +}; + +} // namespace geopro::controller diff --git a/src/controller/I3dSceneView.hpp b/src/controller/I3dSceneView.hpp index 06e3118..9b2c458 100644 --- a/src/controller/I3dSceneView.hpp +++ b/src/controller/I3dSceneView.hpp @@ -1,5 +1,6 @@ #pragma once #include +#include #include "model/ColorScale.hpp" #include "model/Field.hpp" @@ -51,6 +52,9 @@ public: // 2D 足迹:把测线/轨迹经纬折线平铺进 3D 地图(worldZ=摆放高程);按 dsId 跟踪以支持增量移除。 virtual void addMapLine(const std::string& dsId, const geopro::data::MapLine& line, double worldZ) = 0; + // 直接平移一组 2D 足迹到新平面 z(拖 z 值滑块用):改足迹 actor 的 SetPosition,无移除+异步重载。 + // 仅对属于足迹的 dsId 生效;即时渲染。默认空实现,测试 mock 无需覆盖。 + virtual void setMapLinesZ(const std::vector& /*dsIds*/, double /*z*/) {} // 3D:DEM 地形 + 影像纹理。 virtual void addTerrain(const geopro::data::TerrainPaths& paths) = 0; // 增量移除某数据集的全部图元(取消勾选时调,不影响其余 ds 与底图)。 diff --git a/src/controller/PlaneZRegistry.hpp b/src/controller/PlaneZRegistry.hpp new file mode 100644 index 0000000..a8ed503 --- /dev/null +++ b/src/controller/PlaneZRegistry.hpp @@ -0,0 +1,45 @@ +#pragma once +#include +#include +#include + +namespace geopro::controller { + +// 纯逻辑:按 2D 类型管理「平面 z + 成员集」。首勾定 z(之后固定); 全消则平面消失。见 spec §8.2。 +// 无 Qt/VTK 依赖,便于纯逻辑单测。被 Plane2DRenderStrategy 持有以摆放足迹。 +class PlaneZRegistry { +public: + // 某类型某 ds 勾选:首勾(成员集空)记录平面 z=dsZ;返回该类型当前平面 z(后续勾选投影到此)。 + double onChecked(const std::string& typeId, const std::string& dsId, double dsZ) { + auto& p = planes_[typeId]; + if (p.members.empty()) p.z = dsZ; // 首勾定 z + p.members.insert(dsId); + return p.z; + } + // 取消勾选:移出成员;该类型成员集空时清除条目(平面消失,z 遗忘)。 + void onUnchecked(const std::string& typeId, const std::string& dsId) { + auto it = planes_.find(typeId); + if (it == planes_.end()) return; + it->second.members.erase(dsId); + if (it->second.members.empty()) planes_.erase(it); // 全消 → 平面消失 + } + bool hasPlane(const std::string& typeId) const { return planes_.count(typeId) > 0; } + double planeZ(const std::string& typeId) const { + auto it = planes_.find(typeId); + return it == planes_.end() ? 0.0 : it->second.z; + } + // 滑块整体调:移动该类型既有平面 z(类型不存在则无操作)。 + void setPlaneZ(const std::string& typeId, double z) { + auto it = planes_.find(typeId); + if (it != planes_.end()) it->second.z = z; + } + +private: + struct Plane { + double z = 0.0; + std::set members; + }; + std::map planes_; +}; + +} // namespace geopro::controller diff --git a/src/controller/VtkSceneController.cpp b/src/controller/VtkSceneController.cpp index a2ec377..3ec168a 100644 --- a/src/controller/VtkSceneController.cpp +++ b/src/controller/VtkSceneController.cpp @@ -9,21 +9,46 @@ #include "DatasetViewState.hpp" #include "I3dSceneView.hpp" +#include "controller/DatasetRenderStrategy.hpp" +#include "repo/CategoryDescriptor.hpp" #include "repo/IDatasetRepository.hpp" namespace geopro::controller { -namespace { -// 二维足迹「顶部/底部」摆放相对参考高程(Z=0)的偏移(米):控制器无地形/参考高程源 -// (地形异步、帘面经纬未必到场),故退化为 Z=0 上/下固定偏移,使足迹不与帘面顶/底面重叠遮挡。 -constexpr double kTopOffsetZ = 50.0; // 顶部:参考面上方 -constexpr double kBottomOffsetZ = -50.0; // 底部:参考面下方 -} // namespace - VtkSceneController::VtkSceneController(data::IDatasetRepository& dsRepo, data::I3dSceneRepository& sceneRepo, I3dSceneView& view, QObject* parent) - : QObject(parent), dsRepo_(dsRepo), sceneRepo_(sceneRepo), view_(view) {} + : QObject(parent), dsRepo_(dsRepo), sceneRepo_(sceneRepo), view_(view) { + // 注册 3 渲染策略(键与 CategoryDescriptor.renderStrategyId 对应)。各持本控制器引用, + // add/remove 委托回既有渲染路径(addDatasetAsync/add2DDatasetAsync/view_.removeDataset)。 + registry_.registerStrategy("volume", std::make_unique(*this)); + registry_.registerStrategy("curtain", std::make_unique(*this)); + auto plane2d = std::make_unique(*this); + plane2d_ = plane2d.get(); // 留裸指针供 setPlaneZ 直呼(registry_ 持所有权) + registry_.registerStrategy("plane2d", std::move(plane2d)); +} + +void VtkSceneController::setPlaneZ(const QString& typeId, double z) { + if (plane2d_) plane2d_->setPlaneZ(typeId.toStdString(), z); +} + +void VtkSceneController::setPlaneBasemapFactory(PlaneBasemapFactory factory) { + if (plane2d_) plane2d_->setBasemapFactory(std::move(factory)); +} + +void VtkSceneController::setBasemapKind(const QString& typeId, int kind) { + if (plane2d_) plane2d_->setBasemapKind(typeId.toStdString(), kind); +} + +void VtkSceneController::setBasemapOpacity(const QString& typeId, double opacity) { + if (plane2d_) plane2d_->setBasemapOpacity(typeId.toStdString(), opacity); +} + +IDatasetRenderStrategy* VtkSceneController::strategyForType(const std::string& typeId) const { + for (const auto& d : geopro::data::categoryCatalog()) + if (d.id == typeId) return registry_.get(d.renderStrategyId); + return nullptr; +} void VtkSceneController::setViewState(DatasetViewState* state) { state_ = state; @@ -58,111 +83,57 @@ void VtkSceneController::recolorDataset(const QString& qid) { if (changed) view_.renderIncremental(); } -void VtkSceneController::setCheckedDatasets(const QStringList& dsIds) { - std::vector newDs; - newDs.reserve(static_cast(dsIds.size())); - for (const QString& id : dsIds) newDs.push_back(id.toStdString()); +void VtkSceneController::setCheckedDatasets( + const std::vector>& idType) { + std::map next; // dsId→typeId + for (const auto& p : idType) next[p.first] = p.second; - // 2D 俯视测线:保持全量重建(测线非按 ds 跟踪移除)。 - if (mode_ == ViewMode::Map2D) { - checkedDs_ = std::move(newDs); - rebuildInternal(); - return; - } + const std::map prev = checked_; // diff 快照(区分新增/移除) - // 3D:增量 diff —— 只处理新增/移除,不全量重建(底图、其余 ds、相机均不动)。 - const std::set oldSet(checkedDs_.begin(), checkedDs_.end()); - const std::set newSet(newDs.begin(), newDs.end()); + // 移除:旧有新无 → 派该 ds 类型策略 remove;活跃计数归零则 onTypeDeactivated。 + for (const auto& [id, typeId] : prev) + if (!next.count(id)) { + if (auto* s = strategyForType(typeId)) s->remove(id); + if (--typeActive_[typeId] == 0) + if (auto* s = strategyForType(typeId)) s->onTypeDeactivated(typeId); + } - for (const auto& id : checkedDs_) - if (!newSet.count(id)) view_.removeDataset(id); // 移除:旧有新无 → 仅删该 ds 图元 - - checkedDs_ = std::move(newDs); - // 取景意图按「场景是否已有数据到场过」判定,而非 checkedDs_ 是否空——否则连续快速勾选第二个 - // ds 时 checkedDs_ 已非空但首批尚未到场,会被误清取景意图,相机不对准数据 → 看似不渲染。 + // 取景意图按「场景是否已有数据到场过」判定(连续快速勾选时 checked_ 已非空但首批未到场, + // 不可据 checked_ 空否清取景意图,否则相机不对准数据 → 看似不渲染)。全消时复位基线。 fitOnArrival_ = !hadArrivedData_; - // 仅当 3D 与 2D 都空才复位取景基线:否则取消全部 3D 但仍有 2D 足迹时,下个 3D 勾选会误跳取景。 - if (checkedDs_.empty() && checked2dDs_.empty()) hadArrivedData_ = false; + if (next.empty()) hadArrivedData_ = false; - const unsigned long long gen = rebuildGeneration_; // 不自增:并发增量互不作废 - for (const auto& id : checkedDs_) - if (!oldSet.count(id)) addDatasetAsync(id, gen); // 新增:新有旧无 → 异步取数增量入场 + // 先提交勾选集,再派 add:异步取数回调以 isChecked() 守「仍勾选?」,同步仓储(测试)会即时回灌, + // 若 add 时 checked_ 未更新则 isChecked 假、回调丢弃 → 不渲染。故必须先 commit 再 add。 + checked_ = next; + + // 新增:新有旧无 → 活跃计数 0→1 时 onTypeActivated;再派策略 add(委托回既有渲染路径)。 + for (const auto& [id, typeId] : checked_) + if (!prev.count(id)) { + if (typeActive_[typeId]++ == 0) + if (auto* s = strategyForType(typeId)) s->onTypeActivated(typeId); + if (auto* s = strategyForType(typeId)) s->add(typeId, id); + } view_.renderIncremental(); // 立即反映移除 / 触发坐标轴重算 } -void VtkSceneController::setChecked2DDatasets(const QStringList& dsIds) { - std::vector newDs; - newDs.reserve(static_cast(dsIds.size())); - for (const QString& id : dsIds) newDs.push_back(id.toStdString()); - - // 二维足迹始终画进 View3D 场景,且按 dsId 跟踪 → 一律增量 diff(不全量重建,不打断 3D 帘面/体)。 - const std::set oldSet(checked2dDs_.begin(), checked2dDs_.end()); - const std::set newSet(newDs.begin(), newDs.end()); - - for (const auto& id : checked2dDs_) - if (!newSet.count(id)) view_.removeDataset(id); // 取消勾选 → 移除该足迹图元 - - checked2dDs_ = std::move(newDs); - // 取景基线与 3D 路径统一用 hadArrivedData_(而非"两栏皆空"):否则二维分析下若已有隐藏的 3D 数据, - // 勾选首条足迹会因 wasEmpty=false 而不取景 → 足迹落在视野外。切 tab 时 onAnalysisModeChanged 已按 - // 目标维度是否有数据重置该基线,故此处首条可见维度数据能正确取景。 - fitOnArrival_ = !hadArrivedData_; - if (checkedDs_.empty() && checked2dDs_.empty()) hadArrivedData_ = false; - - // 足迹画进 View3D 场景;mode=0 关闭 → 仅记录勾选不渲染(见 set2DPlacement 切回时补画)。 - if (placement2dMode_ != 0 && mode_ == ViewMode::View3D) { - const unsigned long long gen = rebuildGeneration_; // 不自增:与 3D 增量互不作废 - for (const auto& id : checked2dDs_) - if (!oldSet.count(id)) add2DDatasetAsync(id, gen); // 新增 → 异步取足迹增量入场 - } - - view_.renderIncremental(); // 立即反映移除 -} - -void VtkSceneController::set2DPlacement(int mode, double customZ) { - const bool changed = (mode != placement2dMode_) || (mode == 4 && customZ != customZ2d_); - placement2dMode_ = mode; - customZ2d_ = customZ; - if (!changed || checked2dDs_.empty()) return; - - // 摆放变化 → 对已勾选足迹重摆:先全部移除,再按新 Z 重加(mode=0 关闭则只移除不重加)。 - for (const auto& id : checked2dDs_) view_.removeDataset(id); - if (placement2dMode_ != 0 && mode_ == ViewMode::View3D) { - const unsigned long long gen = rebuildGeneration_; - fitOnArrival_ = false; // 重摆:保持相机 - for (const auto& id : checked2dDs_) add2DDatasetAsync(id, gen); - } - view_.renderIncremental(); -} - -double VtkSceneController::placementZ() const { - const double surf = view_.zRefElev(); // 真实地表高程基准(测线地表高程) - switch (placement2dMode_) { - case 1: return 0.0; // Z=0(世界原点) - case 2: return surf + kTopOffsetZ; // 顶部:贴真实地表上方 - case 3: return surf + kBottomOffsetZ; // 底部:真实地表下方 - case 4: return customZ2d_; // 自定义 - default: return 0.0; // 关闭(0) 不应走到此(调用方拦截) - } -} - -void VtkSceneController::add2DDatasetAsync(const std::string& dsId, unsigned long long gen) { +void VtkSceneController::add2DDatasetAsync(const std::string& dsId, unsigned long long gen, + double z) { if (loadingDs_.count(dsId)) return; // 已在加载(重复勾选竞态)→ 不重复请求 loadingDs_.insert(dsId); QPointer self(this); sceneRepo_.loadMapLine( dsId, - [self, gen, dsId](data::MapLine line) { + [self, gen, dsId, z](data::MapLine line) { if (!self) return; self->loadingDs_.erase(dsId); - // gen 作废 / 已取消勾选 / 摆放已关闭 → 丢弃迟到回调。 - if (gen != self->rebuildGeneration_ || !self->is2DChecked(dsId) || - self->placement2dMode_ == 0) { + // gen 作废 / 已取消勾选 → 丢弃迟到回调。 + if (gen != self->rebuildGeneration_ || !self->is2DChecked(dsId)) { return; } - // 落地时按当前摆放 Z(非请求时快照)→ 加载期间摆放变化也取最新高程。 - self->view_.addMapLine(dsId, line, self->placementZ()); + // 足迹摆到所属 2D 类型的平面 z(首勾定、后续投影;由 Plane2DRenderStrategy 决定)。 + self->view_.addMapLine(dsId, line, z); self->onDatasetArrived(); }, [self, gen, dsId](const std::string& m) { @@ -260,11 +231,11 @@ void VtkSceneController::onDatasetArrived() { } bool VtkSceneController::isChecked(const std::string& dsId) const { - return std::find(checkedDs_.begin(), checkedDs_.end(), dsId) != checkedDs_.end(); + return checked_.count(dsId) > 0; // 统一勾选集(异步回调「仍勾选?」守护) } bool VtkSceneController::is2DChecked(const std::string& dsId) const { - return std::find(checked2dDs_.begin(), checked2dDs_.end(), dsId) != checked2dDs_.end(); + return checked_.count(dsId) > 0; // 同上:2D 足迹与 3D 同处统一勾选集 } void VtkSceneController::setViewMode(ViewMode mode) { @@ -272,14 +243,6 @@ void VtkSceneController::setViewMode(ViewMode mode) { rebuildInternal(); } -void VtkSceneController::onAnalysisModeChanged(bool is2D) { - // 切「三维分析/二维分析」tab:按目标维度是否已有数据重置取景基线。 - // 目标维度空 → hadArrivedData_=false:切换后该维度第一条数据自动取景(治"3D 数据不知生成到哪")。 - // 目标维度非空 → hadArrivedData_=true:视图切换时已 fit 到该维度,后续勾选不再跳(与三维一致)。 - // 显隐/相机/坐标轴由 VtkSceneView::setAnalysisMode2D 处理(上层在同一处调用);此处只管取景基线。 - hadArrivedData_ = is2D ? !checked2dDs_.empty() : !checkedDs_.empty(); -} - void VtkSceneController::setLayer(SceneLayer layer, bool on) { switch (layer) { case SceneLayer::Curtain: showCurtain_ = on; break; @@ -365,12 +328,6 @@ void VtkSceneController::zoomIn() { view_.zoom(1.2); } void VtkSceneController::zoomOut() { view_.zoom(1.0 / 1.2); } void VtkSceneController::fit() { view_.fitView(); } -const geopro::core::Grid& VtkSceneController::grid(const std::string& dsId) { - auto it = gridCache_.find(dsId); - if (it == gridCache_.end()) it = gridCache_.emplace(dsId, dsRepo_.loadGrid(dsId)).first; - return it->second; -} - const geopro::core::ColorScale& VtkSceneController::colorScale(const std::string& dsId) { auto it = colorScaleCache_.find(dsId); if (it == colorScaleCache_.end()) @@ -380,7 +337,6 @@ const geopro::core::ColorScale& VtkSceneController::colorScale(const std::string void VtkSceneController::rebuildInternal() { const unsigned long long gen = ++rebuildGeneration_; // 自增:作废此前所有在途增量回调 - const bool is2D = (mode_ == ViewMode::Map2D); view_.clear(); // 移除全部数据图元(保留底图);frame 重锚标志复位 loadingDs_.clear(); // 旧在途加载随之作废(回调按 gen 丢弃) @@ -392,34 +348,30 @@ void VtkSceneController::rebuildInternal() { // 坏 dsId(loadGrid/loadColorScale 抛异常)= best-effort 跳过:emit loadFailed 但不中断。 try { - if (is2D) { - for (const auto& dsId : checkedDs_) view_.addSurveyLine(grid(dsId)); - } else { - // 回调用 QPointer 守对象存活 + gen 守数据新鲜:迟到回调若已析构/作废则丢弃。 - QPointer self(this); - if (showTerrain_) { - sceneRepo_.loadTerrainPaths( - [self, gen](data::TerrainPaths p) { - if (!self || gen != self->rebuildGeneration_) return; // 已析构/迟到:丢弃 - self->view_.addTerrain(std::move(p)); - self->onDatasetArrived(); - }, - [self, gen](const std::string& m) { - if (!self || gen != self->rebuildGeneration_) return; - emit self->loadFailed(QString::fromStdString(m)); - }); - } - for (const auto& dsId : checkedDs_) addDatasetAsync(dsId, gen); - // 二维足迹随全量重建一并重画(clear 已移除其图元);mode=0 关闭则跳过。 - if (placement2dMode_ != 0) - for (const auto& dsId : checked2dDs_) add2DDatasetAsync(dsId, gen); + // 回调用 QPointer 守对象存活 + gen 守数据新鲜:迟到回调若已析构/作废则丢弃。 + QPointer self(this); + if (showTerrain_) { + sceneRepo_.loadTerrainPaths( + [self, gen](data::TerrainPaths p) { + if (!self || gen != self->rebuildGeneration_) return; // 已析构/迟到:丢弃 + self->view_.addTerrain(std::move(p)); + self->onDatasetArrived(); + }, + [self, gen](const std::string& m) { + if (!self || gen != self->rebuildGeneration_) return; + emit self->loadFailed(QString::fromStdString(m)); + }); } + // 全量重建:clear 已移除全部图元,据统一勾选集经各 ds 类型策略重放 add(不动活跃计数: + // 已在 setCheckedDatasets 计入;策略 add 内部转调 addDatasetAsync/add2DDatasetAsync)。 + for (const auto& [dsId, typeId] : checked_) + if (auto* s = strategyForType(typeId)) s->add(typeId, dsId); } catch (const std::exception& e) { emit loadFailed(QString::fromStdString(e.what())); } - // 保留相机重建(改VE):不 ResetCamera,原地按新夸张重绘。 - view_.render(is2D, /*resetCamera=*/!preserveCameraOnRebuild_); + // 保留相机重建(改VE):不 ResetCamera,原地按新夸张重绘。视图恒三维(is2D=false)。 + view_.render(/*is2D=*/false, /*resetCamera=*/!preserveCameraOnRebuild_); } } // namespace geopro::controller diff --git a/src/controller/VtkSceneController.hpp b/src/controller/VtkSceneController.hpp index 2521c0d..fac8ffe 100644 --- a/src/controller/VtkSceneController.hpp +++ b/src/controller/VtkSceneController.hpp @@ -7,8 +7,11 @@ #include #include #include +#include +#include #include "I3dSceneView.hpp" +#include "controller/DatasetRenderStrategy.hpp" #include "model/ColorScale.hpp" #include "model/Field.hpp" #include "repo/I3dSceneRepository.hpp" @@ -21,8 +24,8 @@ namespace geopro::controller { class DatasetViewState; // 跨视图共享色阶真源(统一同步机制) -// 中央视图模式:二维地图(俯视测线)/ 三维视图(帘面/体素/地形)。 -enum class ViewMode { Map2D, View3D }; +// 中央视图模式:固定三维视图(帘面/体素/地形)。旧二维俯视测线(Map2D)路径已退役(main 恒 View3D)。 +enum class ViewMode { View3D }; // 三维图层("视图详情"浮层勾选)。 enum class SceneLayer { Curtain, Voxel, Terrain }; @@ -34,6 +37,10 @@ enum class SceneLayer { Curtain, Voxel, Terrain }; // 不持有 widget;不认 vtkActor/vtkVolume(全交给 I3dSceneView)。 class VtkSceneController : public QObject { Q_OBJECT + // 渲染策略委托回控制器既有路径(addDatasetAsync/add2DDatasetAsync/view_);友元免widen公有面。 + friend class VolumeRenderStrategy; + friend class CurtainRenderStrategy; + friend class Plane2DRenderStrategy; public: VtkSceneController(data::IDatasetRepository& dsRepo, data::I3dSceneRepository& sceneRepo, I3dSceneView& view, QObject* parent = nullptr); @@ -42,16 +49,18 @@ public: // 构造后由 main.cpp 注入一次。 void setViewState(DatasetViewState* state); + // 注入 2D 平面底图工厂(app 层闭包捕获 scene/渲染窗/frame/数据半径规则,造 TileBasemap 适配器): + // 转交 Plane2DRenderStrategy,供各类型平面按需建底图。main.cpp 构造后一次性下发。 + void setPlaneBasemapFactory(PlaneBasemapFactory factory); + +public: + // 勾选并集统一入口(取代旧 setCheckedDatasets(QStringList)/setChecked2DDatasets): + // 每项 = (dsId, typeId=描述符 id)。diff vs 上次后按 catalog[typeId].renderStrategyId 派给策略 + // add/remove,并维护「每 typeId 活跃数」在首勾/全消时调 onTypeActivated/Deactivated。 + void setCheckedDatasets(const std::vector>& idType); + public slots: - void setCheckedDatasets(const QStringList& dsIds); - // 二维数据集栏勾选(足迹型测线/轨迹)→ 平铺进 View3D 地图。与 3D 勾选集独立,按 dsId 增量。 - void setChecked2DDatasets(const QStringList& dsIds); - // 二维足迹摆放高度(mode:0关闭 /1 Z=0 /2 顶部 /3 底部 /4 自定义;customZ 仅 mode=4 用)。 - void set2DPlacement(int mode, double customZ); void setViewMode(ViewMode mode); - // 切「三维分析/二维分析」tab(A 期):按目标维度是否已有数据重置取景基线,使切换后该维度第一条 - // 数据自动取景。显隐/相机/坐标轴由 VtkSceneView::setAnalysisMode2D 负责(上层在同一处一并调用)。 - void onAnalysisModeChanged(bool is2D); void setLayer(SceneLayer layer, bool on); void setVerticalExaggeration(double ve); // 三维体透明度调节(工具条滑块):运行时更新已渲染体的不透明度,并作为后续新体默认(0~1)。 @@ -71,6 +80,12 @@ public slots: // 坐标轴设置面板「应用」:一次性下发 显示方式 + 单位 + per-axis 可见性/范围(单次重建)。 void setAxesConfig(AxesMode mode, AxesUnit unit, const AxisRangeCfg& x, const AxisRangeCfg& y, const AxisRangeCfg& z); + // 2D 段「z 值」滑块:整体升降某 2D 类型平面(含其上全部已勾选足迹)。转交 Plane2DRenderStrategy。 + void setPlaneZ(const QString& typeId, double z); + // 2D 段「底图」弹窗:切该类型平面底图 矢量平面(0)/无(1) + 透明度[0,1]。转交 Plane2DRenderStrategy。 + void setBasemapKind(const QString& typeId, int kind); + void setBasemapOpacity(const QString& typeId, double opacity); + void applyView(ViewDir dir); // 6 向快捷视图 void zoomIn(); // Zoom In (×1.2) void zoomOut(); // Zoom Out (×1/1.2) @@ -91,28 +106,27 @@ private: void recolorDataset(const QString& dsId); // 增量加入单个 ds(帘面/体素,按图层开关);回调按 gen + 仍勾选 守护,落地后增量渲染。 void addDatasetAsync(const std::string& dsId, unsigned long long gen); - // 增量加入单个 2D 足迹(异步 loadMapLine → addMapLine at 当前摆放 Z);回调按 gen + 仍勾选 守护。 - void add2DDatasetAsync(const std::string& dsId, unsigned long long gen); + // 增量加入单个 2D 足迹(异步 loadMapLine → addMapLine at 摆放 z);回调按 gen + 仍勾选 守护。 + // z 为该 ds 所属 2D 类型的平面高程(由 Plane2DRenderStrategy 经 PlaneZRegistry 决定,§E2)。 + void add2DDatasetAsync(const std::string& dsId, unsigned long long gen, double z); void onDatasetArrived(); // 单个 ds 落地后:增量渲染 + 首批数据自动取景 bool isChecked(const std::string& dsId) const; bool is2DChecked(const std::string& dsId) const; - // 当前摆放模式下足迹的世界 Z(mode 0=关闭由调用方拦截;此处算 1/2/3/4 的 Z)。 - double placementZ() const; + // 按 typeId 查其渲染策略(catalog[typeId].renderStrategyId → registry_)。未知 typeId 返回 nullptr。 + IDatasetRenderStrategy* strategyForType(const std::string& typeId) const; data::IDatasetRepository& dsRepo_; data::I3dSceneRepository& sceneRepo_; I3dSceneView& view_; - std::vector checkedDs_; - // 二维足迹勾选集(与 checkedDs_ 独立;都画进 View3D 场景)。按 dsId 增量加/删。 - std::vector checked2dDs_; - // 二维足迹摆放:mode 0关闭/1 Z=0/2顶部/3底部/4自定义;customZ2d_ 仅 mode=4 用。 - // 默认 Z=0(1) 与 Column2DDataset「2D视图」下拉可见默认项一致——避免「下拉显示 Z=0 但 - // 控制器实为关闭」的初始信号丢失desync(组合框 setCurrentIndex 在 connect 前发射、且 - // 组件早于 main.cpp 接线构造,初始 view2DModeChanged 永不送达),致勾选足迹静默不渲染。 - int placement2dMode_ = 1; - double customZ2d_ = 0.0; - ViewMode mode_ = ViewMode::Map2D; + // 统一勾选集(2D+3D 合一):dsId→typeId(描述符 id)。增量 diff 的真源;rebuildInternal 据此重放。 + std::map checked_; + // 每 typeId 活跃计数:首勾(0→1)调 onTypeActivated、全消(1→0)调 onTypeDeactivated。 + std::map typeActive_; + // 渲染策略注册表(构造时注册 volume/curtain/plane2d 三策略,各持本控制器引用)。 + RenderStrategyRegistry registry_; + Plane2DRenderStrategy* plane2d_ = nullptr; // registry_ 中 plane2d 策略的裸指针(setPlaneZ 免下转型) + ViewMode mode_ = ViewMode::View3D; bool showCurtain_ = true; bool showVoxel_ = false; bool showTerrain_ = false; @@ -128,9 +142,8 @@ private: QPointer state_; // 跨视图色阶真源(注入;编辑/加载/重着色都经它;QPointer 防悬挂) // 缓存(按 dsId):避免重复读盘/插值。 - std::map gridCache_; std::map colorScaleCache_; - // 帘面源网格缓存:帘面重着色需 grid 重建 addCurtain(loadSection 的 s.grid 不在 gridCache_)。 + // 帘面源网格缓存:帘面重着色需 grid 重建 addCurtain(loadSection 的 s.grid 缓存于此)。 std::map sectionGridCache_; std::map volumeCache_; // 三维体色阶缓存:mock 体在 dsRepo_ 无条目,色阶随 loadVolume 一起交付并缓存于此。 @@ -147,7 +160,6 @@ private: // 正在加载的 ds:防重复勾选竞态重复请求;全量重建时清空。 std::set loadingDs_; - const geopro::core::Grid& grid(const std::string& dsId); const geopro::core::ColorScale& colorScale(const std::string& dsId); }; diff --git a/src/core/algo/GeoVolumeBuilder.cpp b/src/core/algo/GeoVolumeBuilder.cpp index 201ce71..037380b 100644 --- a/src/core/algo/GeoVolumeBuilder.cpp +++ b/src/core/algo/GeoVolumeBuilder.cpp @@ -22,6 +22,7 @@ #include "io/gpr/GpsTrack.hpp" #include "io/gpr/IprHeader.hpp" #include "io/gpr/IprbReader.hpp" +#include "io/gpr/LocalPath.hpp" namespace fs = std::filesystem; @@ -33,7 +34,7 @@ constexpr double kPi = 3.14159265358979323846; // 读 .iprh 文本 → 解析头(与 .iprb 同名)。 geopro::io::gpr::IprHeader readHeaderFor(const std::string& iprbPath) { - fs::path h = fs::path(iprbPath).replace_extension(".iprh"); + fs::path h = geopro::io::gpr::localPath(iprbPath).replace_extension(".iprh"); std::ifstream f(h); if (!f) throw std::runtime_error("GeoVolumeBuilder: 打不开 iprh " + h.string()); std::string text((std::istreambuf_iterator(f)), @@ -55,7 +56,7 @@ std::int64_t totalTracesOf(const std::vector& iprb, int samples) { const std::int64_t per = static_cast(samples) * 2; if (per <= 0) throw std::runtime_error("samples<=0"); for (const auto& p : iprb) { - const std::int64_t bytes = static_cast(fs::file_size(p)); + const std::int64_t bytes = static_cast(fs::file_size(geopro::io::gpr::localPath(p))); minTr = std::min(minTr, bytes / per); } return minTr; @@ -137,7 +138,7 @@ GeoBuildResult buildGeoVolume(const std::vector& lines, for (const auto& p : tracks[i].pts) sc.trackM.push_back(lonLatToLocalM(p.lat, p.lon, minLat, minLon)); - std::ifstream ordF(lines[i].ord); + std::ifstream ordF(geopro::io::gpr::localPath(lines[i].ord)); if (!ordF) throw std::runtime_error("buildGeoVolume: 打不开 ord " + lines[i].ord); std::string ordText((std::istreambuf_iterator(ordF)), std::istreambuf_iterator()); diff --git a/src/data/CMakeLists.txt b/src/data/CMakeLists.txt index 51a24fe..c4b4468 100644 --- a/src/data/CMakeLists.txt +++ b/src/data/CMakeLists.txt @@ -5,6 +5,7 @@ add_library(geopro_data STATIC repo/LocalSampleRepository.cpp repo/LocalSample3dRepository.cpp repo/DatasetFieldDictionary.cpp + repo/CategoryDescriptor.cpp dto/NavDto.cpp dto/Vtk3dRequests.cpp dto/DatasetChartDto.cpp diff --git a/src/data/StreamingVolumeBuilder.cpp b/src/data/StreamingVolumeBuilder.cpp index 20c6a6d..473f5cc 100644 --- a/src/data/StreamingVolumeBuilder.cpp +++ b/src/data/StreamingVolumeBuilder.cpp @@ -16,6 +16,7 @@ #include "data/store/ChunkedVolumeStore.hpp" #include "io/gpr/GprSurveyAssembler.hpp" #include "io/gpr/IprHeader.hpp" +#include "io/gpr/LocalPath.hpp" namespace geopro::data { @@ -49,7 +50,7 @@ std::string toHeaderPath(const std::string& iprbPath) { } std::string readFileText(const std::string& path) { - std::ifstream f(path, std::ios::binary); + std::ifstream f(geopro::io::gpr::localPath(path), std::ios::binary); if (!f) throw std::runtime_error("StreamingVolumeBuilder: 无法打开 " + path); std::ostringstream ss; ss << f.rdbuf(); @@ -68,7 +69,7 @@ std::int64_t totalTraces(const std::vector& iprb, double& surveyDx) throw std::runtime_error("StreamingVolumeBuilder: samples<=0"); if (c == 0) surveyDx = h.distanceInterval; const std::int64_t bytes = - static_cast(fs::file_size(fs::path(iprb[c]))); + static_cast(fs::file_size(geopro::io::gpr::localPath(iprb[c]))); const std::int64_t per = static_cast(h.samples) * 2; if (per <= 0 || bytes % per != 0) throw std::runtime_error("StreamingVolumeBuilder: .iprb 字节非整道"); diff --git a/src/data/api/Api3dRepository.cpp b/src/data/api/Api3dRepository.cpp index 5c4649c..e097709 100644 --- a/src/data/api/Api3dRepository.cpp +++ b/src/data/api/Api3dRepository.cpp @@ -256,6 +256,11 @@ bool Api3dRepository::volumeInfo(const std::string& dsId, VolumeInfo& out) const return true; } +bool Api3dRepository::isRadarVolume(const std::string& dsId) const { + auto it = volumes_.find(dsId); + return it != volumes_.end() && !it->second.linePrefix.empty(); +} + void Api3dRepository::appendGridPoints(const core::Grid& g, core::PointSet& pts) const { const int nx = g.nx(), ny = g.ny(); if (nx < 1 || ny < 1 || g.y.size() < static_cast(ny)) return; diff --git a/src/data/api/Api3dRepository.hpp b/src/data/api/Api3dRepository.hpp index e5df097..94a2f3a 100644 --- a/src/data/api/Api3dRepository.hpp +++ b/src/data/api/Api3dRepository.hpp @@ -78,6 +78,9 @@ public: }; // 取回三维体详情;dsId 非三维体返回 false(不弹空对话框)。 bool volumeInfo(const std::string& dsId, VolumeInfo& out) const; + // 该 dsId 是否为雷达三维体(StoredVolume 存在且 linePrefix 非空 → 走 loadVolume 雷达懒建分支)。 + // 用于「沿线位置」滑块门控:仅实际渲染了雷达体时显示,不再靠数据包围盒细长度误判。 + bool isRadarVolume(const std::string& dsId) const; // 已保存切片的列表行(ddCode="dd_slice",parentId=所属体 dsId → 树中挂父体下),供三维分析栏合并。 std::vector sliceRows() const; // 异常列表行(ddCode="dd_anomaly",parentId=remarkSourceId=归属实体[体/切片] dsId → 三级树自动挂载), diff --git a/src/data/repo/CategoryConfig.hpp b/src/data/repo/CategoryConfig.hpp deleted file mode 100644 index f6c4b0f..0000000 --- a/src/data/repo/CategoryConfig.hpp +++ /dev/null @@ -1,29 +0,0 @@ -#pragma once -#include -#include - -namespace geopro::app { - -// 一个数据类型大类段的配置(spec §5)。识别键二选一:dsTypeCode 优先;ddCode 用于三维体/切片。 -struct CategorySpec { - std::string id; // 段稳定 id - std::string title; // 段标题(UI 显示) - std::string dsTypeCode; // 主识别键(空=不按 dsTypeCode) - std::string ddCode; // 次识别键(dd_voxel/dd_slice;空=不按 ddCode) - bool canGenerateVolume; // 段内是否提供「生成三维体」入口(仅反演类) - bool hasArrayTypeFilter; // 段头是否显示装置类型筛选(仅 ERT 类) -}; - -// 5 段固定有序(spec §5 表)。 -inline const std::vector& categoryConfigs() { - static const std::vector kCfg = { - {"resistivity", "电阻率数据", "ERT platform inversion data", "", true, true}, - {"apparent", "视电阻率数据", "visual resistivity data", "", true, true}, - {"transient", "瞬变电磁数据", "DD TRANSIENT ELECTROMAGNETIC INVERSION", "", true, false}, - {"voxel", "三维体", "", "dd_voxel", false, false}, - // 切片不单列段——挂在三维体段「体→切片/异常」三级树下(spec §8 修订)。 - }; - return kCfg; -} - -} // namespace geopro::app diff --git a/src/data/repo/CategoryDescriptor.cpp b/src/data/repo/CategoryDescriptor.cpp new file mode 100644 index 0000000..d179f80 --- /dev/null +++ b/src/data/repo/CategoryDescriptor.cpp @@ -0,0 +1,47 @@ +#include "repo/CategoryDescriptor.hpp" +#include + +namespace geopro::data { + +std::function byDdCode(std::initializer_list codes) { + std::vector cs(codes); + return [cs](const DsRow& r) { + for (const auto& c : cs) if (r.ddCode == c) return true; + return false; + }; +} +std::function byDsTypeCode(std::initializer_list codes) { + std::vector cs(codes); + return [cs](const DsRow& r) { + for (const auto& c : cs) if (r.dsTypeCode == c) return true; + return false; + }; +} + +const std::vector& categoryCatalog() { + static const std::vector kCat = { + {"resistivity", "电阻率数据", SceneKind::Curtain3D, + byDsTypeCode({"ERT platform inversion data"}), + {FilterKind::DateRange, FilterKind::ArrayType}, + {OpKind::GenerateVolume, OpKind::Filter}, "curtain"}, + {"apparent", "视电阻率数据", SceneKind::Curtain3D, + byDsTypeCode({"visual resistivity data"}), + {FilterKind::DateRange, FilterKind::ArrayType}, + {OpKind::GenerateVolume, OpKind::Filter}, "curtain"}, + {"transient", "瞬变电磁数据", SceneKind::Curtain3D, + byDsTypeCode({"DD TRANSIENT ELECTROMAGNETIC INVERSION"}), + {FilterKind::DateRange}, + {OpKind::GenerateVolume, OpKind::Filter}, "curtain"}, + {"voxel", "三维体", SceneKind::Volume3D, + byDdCode({"dd_voxel"}), + {FilterKind::DateRange}, + {OpKind::Filter}, "volume"}, + {"trajectory", "轨迹数据", SceneKind::Plane2D, + byDdCode({"dd_trajectory_data"}), + {FilterKind::DateRange}, + {OpKind::PlaneZ, OpKind::Filter, OpKind::Basemap}, "plane2d"}, + }; + return kCat; +} + +} // namespace geopro::data diff --git a/src/data/repo/CategoryDescriptor.hpp b/src/data/repo/CategoryDescriptor.hpp new file mode 100644 index 0000000..8513578 --- /dev/null +++ b/src/data/repo/CategoryDescriptor.hpp @@ -0,0 +1,30 @@ +#pragma once +#include +#include +#include +#include +#include "repo/RepoTypes.hpp" // DsRow + +namespace geopro::data { + +enum class SceneKind { Volume3D, Curtain3D, Plane2D }; // 渲染语义/共存规则 +enum class FilterKind { DateRange, ArrayType }; // 筛选器契约(可扩展) +enum class OpKind { GenerateVolume, Filter, PlaneZ, Basemap }; // 段操作契约(可扩展) + +struct CategoryDescriptor { + std::string id; + std::string title; + SceneKind sceneKind; + std::function classify; // 轴1 数据来源/分类 + std::vector filters; // 轴2 筛选器 + std::vector operations; // 轴3 段头图标操作 + std::string renderStrategyId; // 轴4 渲染策略键 +}; + +// classify 便捷构造器(常见按 ddCode / dsTypeCode 接入) +std::function byDdCode(std::initializer_list codes); +std::function byDsTypeCode(std::initializer_list codes); + +const std::vector& categoryCatalog(); + +} // namespace geopro::data diff --git a/src/io/gpr/CMakeLists.txt b/src/io/gpr/CMakeLists.txt index 0470637..4a811d4 100644 --- a/src/io/gpr/CMakeLists.txt +++ b/src/io/gpr/CMakeLists.txt @@ -1,6 +1,6 @@ add_library(geopro_io_gpr STATIC IprHeader.cpp IprbReader.cpp GprGeometry.cpp GprSurveyAssembler.cpp - GpsTrack.cpp NormalizedRadarReader.cpp + GpsTrack.cpp NormalizedRadarReader.cpp LocalPath.cpp RadarVolumeAssembler.cpp NormalizedRadarVolumeBridge.cpp) target_include_directories(geopro_io_gpr PUBLIC ${CMAKE_SOURCE_DIR}/src) target_compile_features(geopro_io_gpr PUBLIC cxx_std_17) diff --git a/src/io/gpr/GprSurveyAssembler.cpp b/src/io/gpr/GprSurveyAssembler.cpp index e9e3c2e..4371f3d 100644 --- a/src/io/gpr/GprSurveyAssembler.cpp +++ b/src/io/gpr/GprSurveyAssembler.cpp @@ -10,6 +10,7 @@ #include "io/gpr/GprGeometry.hpp" #include "io/gpr/IprHeader.hpp" #include "io/gpr/IprbReader.hpp" +#include "io/gpr/LocalPath.hpp" namespace geopro::io::gpr { namespace { @@ -27,7 +28,7 @@ std::string toHeaderPath(const std::string& iprbPath) { } std::string readFileText(const std::string& path) { - std::ifstream f(path, std::ios::binary); + std::ifstream f(localPath(path), std::ios::binary); if (!f) { throw std::runtime_error("无法打开文件: " + path); } diff --git a/src/io/gpr/GpsTrack.cpp b/src/io/gpr/GpsTrack.cpp index 5004b9a..04e8e84 100644 --- a/src/io/gpr/GpsTrack.cpp +++ b/src/io/gpr/GpsTrack.cpp @@ -7,6 +7,8 @@ #include #include +#include "io/gpr/LocalPath.hpp" + namespace geopro::io::gpr { namespace { @@ -26,7 +28,7 @@ bool parseDouble(const std::string& s, double& out) { } // namespace GpsTrack parseGps(const std::string& path) { - std::ifstream f(path); + std::ifstream f(localPath(path)); if (!f) throw std::runtime_error("parseGps: 打不开 " + path); GpsTrack track; diff --git a/src/io/gpr/IprbReader.cpp b/src/io/gpr/IprbReader.cpp index 6137e74..488e498 100644 --- a/src/io/gpr/IprbReader.cpp +++ b/src/io/gpr/IprbReader.cpp @@ -4,10 +4,12 @@ #include #include +#include "io/gpr/LocalPath.hpp" + namespace geopro::io::gpr { BScan readIprb(const std::string& path, const IprHeader& h) { - std::ifstream f(path, std::ios::binary | std::ios::ate); + std::ifstream f(localPath(path), std::ios::binary | std::ios::ate); if (!f) { throw std::runtime_error("readIprb: 无法打开文件: " + path); } @@ -43,7 +45,7 @@ BScan readIprb(const std::string& path, const IprHeader& h) { BScan readIprbRange(const std::string& path, const IprHeader& h, std::int64_t t0, std::int64_t t1) { - std::ifstream f(path, std::ios::binary | std::ios::ate); + std::ifstream f(localPath(path), std::ios::binary | std::ios::ate); if (!f) { throw std::runtime_error("readIprbRange: 无法打开文件: " + path); } diff --git a/src/io/gpr/LocalPath.cpp b/src/io/gpr/LocalPath.cpp new file mode 100644 index 0000000..03f67de --- /dev/null +++ b/src/io/gpr/LocalPath.cpp @@ -0,0 +1,24 @@ +#include "io/gpr/LocalPath.hpp" + +#ifdef _WIN32 +#include +#endif + +namespace geopro::io::gpr { + +std::filesystem::path localPath(const std::string& p) { +#ifdef _WIN32 + if (p.empty()) return std::filesystem::path{}; + const int wlen = ::MultiByteToWideChar( + CP_ACP, 0, p.data(), static_cast(p.size()), nullptr, 0); + if (wlen <= 0) return std::filesystem::path(p); // 退化:原样(ASCII 安全) + std::wstring w(static_cast(wlen), L'\0'); + ::MultiByteToWideChar(CP_ACP, 0, p.data(), static_cast(p.size()), + w.data(), wlen); + return std::filesystem::path(w); +#else + return std::filesystem::path(p); // POSIX:窄字节即 UTF-8 原生路径 +#endif +} + +} // namespace geopro::io::gpr diff --git a/src/io/gpr/LocalPath.hpp b/src/io/gpr/LocalPath.hpp new file mode 100644 index 0000000..4adf024 --- /dev/null +++ b/src/io/gpr/LocalPath.hpp @@ -0,0 +1,16 @@ +#pragma once + +#include +#include + +namespace geopro::io::gpr { + +// 把"本地 8 位编码"的窄字节路径转成 std::filesystem::path。 +// Windows:源串按当前 ANSI 代码页(简中=GBK/936,即 QString::toLocal8Bit 产物) +// 解码为宽字符 path,使 std::ifstream/ofstream 走宽字符打开 —— 否则 +// 窄字符 ifstream 在默认 "C" locale 下无法解析含中文的路径,open 失败。 +// 其它平台:窄字节即原生 UTF-8 路径,直接构造。 +// 退化保护:转换失败时原样返回(ASCII 路径不受影响)。 +std::filesystem::path localPath(const std::string& p); + +} // namespace geopro::io::gpr diff --git a/src/io/gpr/NormalizedRadarReader.cpp b/src/io/gpr/NormalizedRadarReader.cpp index a3d360f..2f28b1d 100644 --- a/src/io/gpr/NormalizedRadarReader.cpp +++ b/src/io/gpr/NormalizedRadarReader.cpp @@ -1,4 +1,5 @@ #include "io/gpr/NormalizedRadarReader.hpp" +#include "io/gpr/LocalPath.hpp" #include #include #include @@ -76,11 +77,11 @@ std::vector readRadarDataCube(const std::string& dataPath, const std::size_t n = static_cast(h.lastTrace) * h.samples; const std::uintmax_t expect = static_cast(n) * 2; std::error_code ec; - const auto fsize = std::filesystem::file_size(dataPath, ec); + const auto fsize = std::filesystem::file_size(localPath(dataPath), ec); if (ec || fsize != expect) throw std::runtime_error("规范化 .data 大小不符: " + dataPath); std::vector cube(n); - std::ifstream f(dataPath, std::ios::binary); + std::ifstream f(localPath(dataPath), std::ios::binary); if (!f) throw std::runtime_error("打开 .data 失败: " + dataPath); f.read(reinterpret_cast(cube.data()), static_cast(expect)); if (!f) throw std::runtime_error("读 .data 失败: " + dataPath); diff --git a/src/io/gpr/NormalizedRadarVolumeBridge.cpp b/src/io/gpr/NormalizedRadarVolumeBridge.cpp index ca021d6..9a21015 100644 --- a/src/io/gpr/NormalizedRadarVolumeBridge.cpp +++ b/src/io/gpr/NormalizedRadarVolumeBridge.cpp @@ -7,6 +7,7 @@ #include #include +#include "io/gpr/LocalPath.hpp" #include "io/gpr/NormalizedRadarReader.hpp" #include "io/gpr/RadarVolumeAssembler.hpp" @@ -20,7 +21,7 @@ geopro::core::BuiltI16 buildLineVolumeFromNormalized(const std::string& lineDir, std::string headText; { - std::ifstream f(head); + std::ifstream f(localPath(head)); if (!f) throw std::runtime_error("打开 .head 失败: " + head); std::stringstream ss; ss << f.rdbuf(); diff --git a/src/render/CameraPreset.cpp b/src/render/CameraPreset.cpp index 3101e24..c772c64 100644 --- a/src/render/CameraPreset.cpp +++ b/src/render/CameraPreset.cpp @@ -90,6 +90,29 @@ void applyView(vtkRenderer* r, ViewDir dir) r->ResetCamera(); } +CameraPose orbitPose(ViewDir dir, const double pivot[3], double distance) +{ + // 方向偏移(pos = pivot + offset*distance)与 up 约定须与 applyView 完全一致: + // Top +Z/up+Y、Bottom -Z/up+Y、Front -Y/up+Z、Back +Y/up+Z、Left -X/up+Z、Right +X/up+Z。 + double off[3] = {0, 0, 0}; + double up[3] = {0, 0, 1}; + switch (dir) { + case ViewDir::Top: off[2] = 1; up[0] = 0; up[1] = 1; up[2] = 0; break; + case ViewDir::Bottom: off[2] = -1; up[0] = 0; up[1] = 1; up[2] = 0; break; + case ViewDir::Front: off[1] = -1; break; // 从 -Y 看 +Y,up=+Z + case ViewDir::Back: off[1] = 1; break; + case ViewDir::Left: off[0] = -1; break; // 从 -X 看 +X,up=+Z + case ViewDir::Right: off[0] = 1; break; + } + CameraPose pose; + for (int i = 0; i < 3; ++i) { + pose.focal[i] = pivot[i]; + pose.pos[i] = pivot[i] + off[i] * distance; + pose.up[i] = up[i]; + } + return pose; +} + void zoomBy(vtkRenderer* r, double factor) { if (!r || factor <= 0.0) return; diff --git a/src/render/CameraPreset.hpp b/src/render/CameraPreset.hpp index 5061074..0c33bb3 100644 --- a/src/render/CameraPreset.hpp +++ b/src/render/CameraPreset.hpp @@ -22,6 +22,16 @@ enum class ViewDir { Front, Back, Left, Right, Top, Bottom }; // 应用 6 向正交快捷视图:设 position/focalPoint/viewUp 后 ResetCamera。 void applyView(vtkRenderer* r, ViewDir dir); +// 绕支点转到某轴的相机位姿(纯数学,可单测):focal=pivot,pos=pivot+dir_offset*distance, +// up 按 dir 预设。方向偏移/up 约定与 applyView 完全一致(Top=+Z 看下、+Y 朝上;Front 从 -Y +// 看 +Y、+Z 朝上;…)。用于 orbitToAxis:保留当前缩放距离、只改朝向绕 pivot 转。 +struct CameraPose { + double pos[3]; + double focal[3]; + double up[3]; +}; +CameraPose orbitPose(ViewDir dir, const double pivot[3], double distance); + // 相机缩放:factor>1 拉近(放大),factor<1 推远(缩小)。透视下改距离、正交下改 parallelScale。 void zoomBy(vtkRenderer* r, double factor); diff --git a/src/render/interact/InteractionManager.cpp b/src/render/interact/InteractionManager.cpp index 35b11d8..0c8e628 100644 --- a/src/render/interact/InteractionManager.cpp +++ b/src/render/interact/InteractionManager.cpp @@ -293,27 +293,6 @@ void InteractionManager::closeAll() { safeRender(); } -PickInteractorStyle* InteractionManager::pickStyle() const { return style_; } - -void InteractionManager::setMode2D(bool is2D) { - // 进入二维分析:主动取消「三维前视图」的所有选中。否则残留的选中切片会让 onWheel 持续消费滚轮 - // (二维下无法缩放),且切回三维仍残留高亮。清 selected_ + 切片高亮;再经 onSliceSelectionChanged("") - // 联动清三维分析列表选中行与异常高亮(app 层接线)。与 VtkSceneView::setAnalysisMode2D 离开二维时 - // clearMapLineSelection 清足迹选中相对称。 - if (is2D) { - if (selected_ >= 0) { - selected_ = -1; - updateSelectionVisual(); // 清切片高亮(切回三维不残留选中) - } - if (onSliceSelectionChanged) onSliceSelectionChanged(std::string{}); - } - // 切片属三维内容:二维分析隐藏(不销毁→切回零重建)、三维分析显示。 - for (auto& s : slices_) - if (s) s->setVisible(!is2D); - if (style_) style_->setLock2D(is2D); // 二维=禁旋转、左键平移(仅平移+缩放) - // 不在此渲染:相机/地形/底图/维度显隐及统一 Render 由 VtkSceneView::setAnalysisMode2D 收尾。 -} - void InteractionManager::flipView() { if (!renderer_) return; auto* cam = renderer_->GetActiveCamera(); diff --git a/src/render/interact/InteractionManager.hpp b/src/render/interact/InteractionManager.hpp index d32d0d6..6c0ba37 100644 --- a/src/render/interact/InteractionManager.hpp +++ b/src/render/interact/InteractionManager.hpp @@ -68,9 +68,6 @@ public: // 关闭并释放所有切片(切到二维 / 清场 / 体素重建前调)。 void closeAll(); - // 切二维分析(is2D=true)/三维分析(false):翻所有切片显隐(不销毁,切回零重建) + 锁/解锁交互样式 - // (二维=仅平移+缩放、禁旋转)。地形/底图/相机由 VtkSceneView::setAnalysisMode2D 处理,此处不渲染。 - void setMode2D(bool is2D); // 关闭并释放某体下的所有切片(该体移除/重建时;不动其它体的切片)。 void closeSlicesOfVolume(const std::string& volumeDsId); @@ -126,10 +123,6 @@ public: void installStyle(); void uninstallStyle(); - // 暴露交互样式:供 app 层注入二维分析 B 期的足迹拾取/Z 拖动回调(onPick2D/onDrag2D/onDrag2DEnd)。 - // 定义在 .cpp(此处 PickInteractorStyle 仅前置声明,vtkSmartPointer→裸指针下转需完整类型)。 - PickInteractorStyle* pickStyle() const; - private: // 拾取回调实现(PickInteractorStyle 注入)。 void onPicked(const Vec3& worldPoint); // 选中所在切片 + 焦点 diff --git a/src/render/interact/PickInteractorStyle.cpp b/src/render/interact/PickInteractorStyle.cpp index 7194ec6..00dc8cd 100644 --- a/src/render/interact/PickInteractorStyle.cpp +++ b/src/render/interact/PickInteractorStyle.cpp @@ -1,7 +1,6 @@ #include "interact/PickInteractorStyle.hpp" #include -#include #include #include @@ -50,22 +49,6 @@ bool PickInteractorStyle::pickWorld(Vec3& out) { void PickInteractorStyle::OnLeftButtonDown() { auto* iren = this->GetInteractor(); - // 二维分析:左键命中足迹→进入高程 Z 拖动(B 期);否则=平移(等同中键),禁旋转。抬键由 OnLeftButtonUp 收尾。 - if (lock2D_) { - const int* p = iren ? iren->GetEventPosition() : nullptr; - if (p) this->FindPokedRenderer(p[0], p[1]); - if (!this->CurrentRenderer) return; - const bool additive = iren && iren->GetControlKey(); // Ctrl=多选 - if (onPick2D && p && onPick2D(p[0], p[1], additive)) { // 命中足迹 → Z 拖动 - dragging2D_ = true; - lastDragY_ = p[1]; - this->GrabFocus(this->EventCallbackCommand); - return; - } - this->GrabFocus(this->EventCallbackCommand); // 未命中 → 平移 - this->StartPan(); - return; - } Vec3 world; const bool hit = pickWorld(world); // 仍用于取选中所需世界点(onPick) // 命中切片【精确判定】:光标射线穿过某切片真实矩形内才算(不靠带容差的 picker 点)。 @@ -107,7 +90,6 @@ void PickInteractorStyle::OnLeftButtonDown() { } void PickInteractorStyle::Rotate() { - if (lock2D_) return; // 二维分析禁旋转(仅平移+缩放) if (!this->CurrentRenderer || !hasRotatePivot_) { Superclass::Rotate(); // 无支点 → 默认绕焦点旋转 return; @@ -154,49 +136,14 @@ void PickInteractorStyle::Rotate() { rwi->Render(); } -double PickInteractorStyle::worldPerPixelZ() const { - if (!this->CurrentRenderer) return 1.0; - auto* cam = this->CurrentRenderer->GetActiveCamera(); - auto* rw = this->CurrentRenderer->GetRenderWindow(); - if (!cam || !rw) return 1.0; - const int* sz = rw->GetSize(); - const double h = (sz && sz[1] > 0) ? static_cast(sz[1]) : 800.0; - if (cam->GetParallelProjection()) - return 2.0 * cam->GetParallelScale() / h; // 平行投影:可见世界高度=2*parallelScale - // 透视:可见世界高度 = 2*d*tan(viewAngle/2),d=相机到焦点距离。 - double pos[3], fp[3]; - cam->GetPosition(pos); - cam->GetFocalPoint(fp); - const double dx = pos[0] - fp[0], dy = pos[1] - fp[1], dz = pos[2] - fp[2]; - const double d = std::sqrt(dx * dx + dy * dy + dz * dz); - const double va = vtkMath::RadiansFromDegrees(cam->GetViewAngle()); - return 2.0 * d * std::tan(va * 0.5) / h; -} - void PickInteractorStyle::OnMouseMove() { - if (dragging2D_) { // B 期:竖向拖动 → 选中足迹 Z 增量(仅改 Z)。鼠标上移(y 增)→ 抬高。 - auto* rwi = this->Interactor; - if (rwi) { - const int y = rwi->GetEventPosition()[1]; - const int dyPix = y - lastDragY_; - lastDragY_ = y; - if (dyPix != 0 && onDrag2D) onDrag2D(worldPerPixelZ() * dyPix); - } - return; // 不走基类(不平移/不旋转) - } Superclass::OnMouseMove(); } void PickInteractorStyle::OnLeftButtonUp() { - if (dragging2D_) { // 结束 Z 拖动 - dragging2D_ = false; - if (this->Interactor) this->ReleaseFocus(); - if (onDrag2DEnd) onDrag2DEnd(); - return; - } // 单击(抬键位移<阈值=非拖动)且按下未命中切片 → 取消选中(点空/点体;体 PickableOff 故点体也 hit=false)。 // 拖空白旋转:抬键位移大 → 不取消,保留"绕选中切片旋转"。Esc 仍是完全拉近时的兜底。 - if (!lock2D_ && !downHitSlice_ && onDeselect) { + if (!downHitSlice_ && onDeselect) { auto* iren = this->GetInteractor(); const int* up = iren ? iren->GetEventPosition() : nullptr; if (up) { @@ -208,19 +155,13 @@ void PickInteractorStyle::OnLeftButtonUp() { Superclass::OnLeftButtonUp(); // 平移/旋转/缩放等由基类按 State 收尾 } -namespace { -constexpr double kWheelStepPx = 24.0; // 滚轮一格升降 ≈ 拖动 24 像素的世界 Z 量(与拖动手感一致) -} - void PickInteractorStyle::OnMouseWheelForward() { - // 二维分析有选中足迹 → 滚轮抬升其高程(消费滚轮);否则按切片推进 / 默认缩放。 - if (lock2D_ && onWheel2D && onWheel2D(worldPerPixelZ() * kWheelStepPx)) return; + // 有选中切片 → 沿法向推进(消费滚轮);否则默认缩放。 if (onWheelStep && onWheelStep(+1)) return; // 有选中切片 → 推进,消费滚轮 Superclass::OnMouseWheelForward(); // 否则默认缩放 } void PickInteractorStyle::OnMouseWheelBackward() { - if (lock2D_ && onWheel2D && onWheel2D(-worldPerPixelZ() * kWheelStepPx)) return; if (onWheelStep && onWheelStep(-1)) return; Superclass::OnMouseWheelBackward(); } diff --git a/src/render/interact/PickInteractorStyle.hpp b/src/render/interact/PickInteractorStyle.hpp index 81089be..71623b2 100644 --- a/src/render/interact/PickInteractorStyle.hpp +++ b/src/render/interact/PickInteractorStyle.hpp @@ -37,20 +37,6 @@ public: // 点帘面/其它非切片物/边界外 → 返回 false → 单击即取消选中。 std::function hitTestSlice; - // 二维分析锁:开 → 左键拖动改为平移、禁旋转(仅平移+缩放);关 → 恢复三维拾取/旋转交互。 - void setLock2D(bool on) { lock2D_ = on; } - bool isLock2D() const { return lock2D_; } - - // ── 二维分析 B 期:选中足迹沿高程 Z 拖动 ──(仅 lock2D 下生效;回调由 app 层注入) - // onPick2D:左键按下时在(x,y)拾取足迹(additive=Ctrl 多选),返回是否有选中→有则进入 Z 拖动、否则平移。 - // onDrag2D:拖动中把竖向像素换算成的世界 Z 增量(本类按相机算)交给 app 施加到选中足迹(仅改 Z)。 - // onDrag2DEnd:松开结束拖动(供 app 收起高程读数浮层)。 - std::function onPick2D; - std::function onDrag2D; - std::function onDrag2DEnd; - // 滚轮升降:有选中足迹时滚轮改其高程 Z(本类按相机算 worldDz);app 施加并返回是否消费(无选中→false→默认缩放)。 - std::function onWheel2D; - void OnMouseMove() override; void OnLeftButtonUp() override; @@ -68,8 +54,6 @@ protected: private: // 在当前鼠标位置拾取世界点;命中返回 true 并填 out。 bool pickWorld(Vec3& out); - // 当前相机下:竖向一屏幕像素对应的世界 Z(米/像素),用于把拖动像素换算成 Z 增量。 - double worldPerPixelZ() const; // 手动双击判定:QVTK+Windows 下 vtkRenderWindowInteractor::GetRepeatCount() 不可靠(评审 M5)。 // 记上次左键按下时刻+屏幕位置,两次按下间隔 < kDoubleClickMs 且位置相近视为双击。 @@ -84,13 +68,6 @@ private: // 选中切片=其中心;否则=光标射线穿过的体中段点。无则 hasRotatePivot_=false→默认绕焦点。 Vec3 rotatePivot_{}; bool hasRotatePivot_ = false; - - // 二维分析模式:左键=平移、禁旋转(仅平移+缩放)。由 InteractionManager 在切 tab 时设。 - bool lock2D_ = false; - - // B 期足迹 Z 拖动状态:左键命中足迹时进入,记上次鼠标 y 以算增量。 - bool dragging2D_ = false; - int lastDragY_ = 0; }; } // namespace geopro::render::interact diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 08499e2..efa835e 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -59,6 +59,8 @@ target_sources(geopro_tests PRIVATE data/test_nav_request.cpp) # GprVolumeRepository:逐线 GPR int16 量化体(BuiltI16)→ app 渲染链 float 体(VolumeGrid)。 # 纯适配器逐值反量化 + 全链(合成多通道 .iprb 走真 P1/P2)产出有效 VolumeGrid。 target_sources(geopro_tests PRIVATE data/test_gpr_volume_repository.cpp) +# CategoryDescriptor:类目描述符目录 categoryCatalog(classify谓词+扩展契约) + splitByCategory 遍历路由。 +target_sources(geopro_tests PRIVATE data/test_category_descriptor.cpp) target_link_libraries(geopro_tests PRIVATE geopro_data) # store 层:ChunkedVolumeStore(GPR 三维体分块压缩落盘 round-trip + 边缘块 + 压缩生效)。 @@ -173,11 +175,6 @@ target_sources(geopro_tests PRIVATE app/test_color_scale_io.cpp ${CMAKE_SOURCE_DIR}/src/app/ColorScaleIO.cpp ) -# 维度过滤纯函数(splitByDimension: ddCode -> 三维/二维/分析三栏,无 Qt/VTK 依赖)。 -target_sources(geopro_tests PRIVATE - app/test_dataset_dimension.cpp - ${CMAKE_SOURCE_DIR}/src/app/DatasetDimension.cpp -) # 大类分类纯函数(splitByCategory: dsTypeCode/ddCode -> 5 个数据类型大类段,无 Qt/VTK 依赖)。 target_sources(geopro_tests PRIVATE app/test_dataset_category.cpp @@ -185,6 +182,16 @@ target_sources(geopro_tests PRIVATE ) # 对象树勾选纯逻辑(GS 三态聚合 aggregateGsState + 勾选源去重 dedupeSources,header-only)。 target_sources(geopro_tests PRIVATE app/test_object_tree_selection.cpp) +# 段头响应式图标工具条溢出计算纯逻辑(visibleIconCount,见 spec §6)。SectionIconBar.cpp 还含 +# QWidget 组件,其按钮构造依赖 makeGlyph(Glyphs.cpp) 与主题(Theme.cpp),故一并加入并链 Qt6::Widgets/Svg。 +find_package(Qt6 COMPONENTS Widgets Svg REQUIRED) +target_sources(geopro_tests PRIVATE + app/test_section_icon_bar.cpp + ${CMAKE_SOURCE_DIR}/src/app/panels/columns/SectionIconBar.cpp + ${CMAKE_SOURCE_DIR}/src/app/Glyphs.cpp + ${CMAKE_SOURCE_DIR}/src/app/Theme.cpp +) +target_link_libraries(geopro_tests PRIVATE Qt6::Widgets Qt6::Svg) # measurement 散点纯逻辑(值类型变换 / 显隐 id 收集 / 过滤体 / 另存体,Qt6::Core JSON + core model)。 target_sources(geopro_tests PRIVATE app/test_scatter_data_ops.cpp @@ -219,6 +226,10 @@ target_sources(geopro_tests PRIVATE controller/test_dataset_detail_controller.cp target_sources(geopro_tests PRIVATE controller/test_workbench_nav_controller.cpp) # VtkSceneController 编排:注入 fake repo + fake view,断言 视图模式×图层 组合下 add 的图元类型/数量;取消勾选清空。 target_sources(geopro_tests PRIVATE controller/test_vtk_scene_controller.cpp) +# RenderStrategyRegistry:字符串键策略注册表解析(register/get,纯逻辑,FakeStrategy)。 +target_sources(geopro_tests PRIVATE controller/test_render_strategy_registry.cpp) +# PlaneZRegistry:按 2D 类型管理平面 z 生命周期(首勾定 z / 全消消失 / setPlaneZ 整体调,纯逻辑头)。 +target_sources(geopro_tests PRIVATE controller/test_plane_z_registry.cpp) target_link_libraries(geopro_tests PRIVATE geopro_controller Qt6::Test) # io/gpr 层:.iprh 头解析 + .iprb B-scan 读取(纯 C++17,零 Qt/VTK)。 diff --git a/tests/app/test_chart_strategy_registry.cpp b/tests/app/test_chart_strategy_registry.cpp index 9a508b6..71d8f0c 100644 --- a/tests/app/test_chart_strategy_registry.cpp +++ b/tests/app/test_chart_strategy_registry.cpp @@ -1,6 +1,7 @@ #include #include "DatasetDetailTab.hpp" #include "IDatasetChartStrategy.hpp" // geopro::controller(控制器层) +#include "panels/chart/ErtInversionStrategy.hpp" #include "panels/chart/MeasurementStrategy.hpp" #include "panels/chart/GrMeasurementStrategy.hpp" #include "panels/chart/TrajectoryStrategy.hpp" @@ -39,6 +40,29 @@ TEST(ChartStrategyRegistry, ExposesTabSpecsFromStrategy) { EXPECT_EQ(tabs[1].kind, ViewKind::FilledContour); } +// T4 双击详情联动 gate 契约(决策6):DatasetDetailController::supports() 直接委托本注册表的 supports()。 +// 三维体(dd_voxel/dd_radar_3d)等无详情页策略 → supports()=false → 双击只适配、静默不开面板; +// 5 种已注册类型 supports()=true → 双击联动打开中下方详情页。此测锁定这条 gate 的真实注册集行为。 +TEST(ChartStrategyRegistry, T4GateSilentForVolumeTypesSupportsRegistered) { + ChartStrategyRegistry reg; + reg.add(std::make_unique()); + reg.add(std::make_unique()); + reg.add(std::make_unique()); + reg.add(std::make_unique()); + reg.add(std::make_unique()); + // 有详情页 → 双击联动打开中下方详情面板。 + EXPECT_TRUE(reg.supports("dd_inversion_data")); + EXPECT_TRUE(reg.supports("dd_ert_measurement_data")); + EXPECT_TRUE(reg.supports("dd_ert_measurement_gr_data")); + EXPECT_TRUE(reg.supports("dd_trajectory_data")); + EXPECT_TRUE(reg.supports("dd_grid")); + // 无详情页(三维体/切片/异常)→ 静默:双击只适配、不开面板、不弹状态栏。 + EXPECT_FALSE(reg.supports("dd_voxel")); + EXPECT_FALSE(reg.supports("dd_radar_3d")); + EXPECT_FALSE(reg.supports("dd_slice")); + EXPECT_FALSE(reg.supports("dd_anomaly")); +} + TEST(MeasurementStrategy, DrivesTwoTabsScatterAndTable) { geopro::app::MeasurementStrategy s; EXPECT_EQ(s.ddCode(), "dd_ert_measurement_data"); diff --git a/tests/app/test_dataset_category.cpp b/tests/app/test_dataset_category.cpp index 82876f2..b5c7737 100644 --- a/tests/app/test_dataset_category.cpp +++ b/tests/app/test_dataset_category.cpp @@ -1,5 +1,6 @@ #include #include "DatasetCategory.hpp" +#include "repo/CategoryDescriptor.hpp" using geopro::data::DsRow; using namespace geopro::app; @@ -23,7 +24,8 @@ TEST(SplitByCategory, RoutesByDsTypeCodeAndDdCode) { row("x", "dd_ert_measurement_gr_data", "earth resistance"), // 接地电阻 → 丢弃 }; const CategoryBuckets b = splitByCategory(rows); - ASSERT_EQ(b.segments.size(), categoryConfigs().size()); + // splitByCategory 现走 categoryCatalog()(5 段,含 trajectory);旧 categoryConfigs 暂保留供 UI。 + ASSERT_EQ(b.segments.size(), geopro::data::categoryCatalog().size()); EXPECT_EQ(b.segments[0].size(), 1u); EXPECT_EQ(b.segments[0][0].id, "a"); EXPECT_EQ(b.segments[1].size(), 1u); EXPECT_EQ(b.segments[1][0].id, "b"); EXPECT_EQ(b.segments[2].size(), 1u); EXPECT_EQ(b.segments[2][0].id, "c"); diff --git a/tests/app/test_dataset_dimension.cpp b/tests/app/test_dataset_dimension.cpp deleted file mode 100644 index 6d5852d..0000000 --- a/tests/app/test_dataset_dimension.cpp +++ /dev/null @@ -1,38 +0,0 @@ -#include -#include "DatasetDimension.hpp" -#include "repo/RepoTypes.hpp" - -using geopro::data::DsRow; -using geopro::app::splitByDimension; -using geopro::app::DimBuckets; - -static DsRow row(const char* id, const char* ddCode) { - DsRow r; r.id = id; r.ddCode = ddCode; return r; -} - -TEST(DatasetDimension, SplitsByDdCode) { - std::vector in{ - row("a", "dd_section"), // 3D - row("b", "dd_voxel"), // 3D - row("f", "dd_radar_3d"), // 3D(三维雷达体,spec §6.1) - row("c", "dd_trajectory_data"), // 2D - row("d", "dd_slice"), // Analysis - row("e", "dd_unknownxyz"), // Other -> not in any bucket - }; - DimBuckets b = splitByDimension(in); - ASSERT_EQ(b.dim3D.size(), 3u); - EXPECT_EQ(b.dim3D[0].id, "a"); - EXPECT_EQ(b.dim3D[1].id, "b"); - EXPECT_EQ(b.dim3D[2].id, "f"); - ASSERT_EQ(b.dim2D.size(), 1u); - EXPECT_EQ(b.dim2D[0].id, "c"); - ASSERT_EQ(b.analysis.size(), 1u); - EXPECT_EQ(b.analysis[0].id, "d"); -} - -TEST(DatasetDimension, EmptyInput) { - DimBuckets b = splitByDimension({}); - EXPECT_TRUE(b.dim3D.empty()); - EXPECT_TRUE(b.dim2D.empty()); - EXPECT_TRUE(b.analysis.empty()); -} diff --git a/tests/app/test_section_icon_bar.cpp b/tests/app/test_section_icon_bar.cpp new file mode 100644 index 0000000..980bcc1 --- /dev/null +++ b/tests/app/test_section_icon_bar.cpp @@ -0,0 +1,24 @@ +#include +#include "panels/columns/SectionIconBar.hpp" + +using geopro::app::visibleIconCount; + +TEST(SectionIconBar, ShowsAllWhenWideEnoughAndUnderMax) { + // 3 图标, 宽 1000, 每个 30, 溢出 30, max 3 → 全显 + EXPECT_EQ(visibleIconCount(3, 1000, 30, 30, 3), 3); +} +TEST(SectionIconBar, CapsAtMaxIcons) { + // 5 图标但 max 3, 宽足够 → 显 3, 其余 2 进溢出(此时需留溢出位) + EXPECT_EQ(visibleIconCount(5, 1000, 30, 30, 3), 3); +} +TEST(SectionIconBar, FoldsRightWhenNarrow) { + // 3 图标, max 3, 但宽只够 2 个 + 溢出: 75px, 30 each, overflow 30 → 2*30+30=90>75 → 1*30+30=60<=75 → 1 + EXPECT_EQ(visibleIconCount(3, 75, 30, 30, 3), 1); +} +TEST(SectionIconBar, NoOverflowReserveWhenAllFit) { + // 2 图标全显且 <=max, 不需溢出位: 宽 60 恰好 2*30 + EXPECT_EQ(visibleIconCount(2, 60, 30, 30, 3), 2); +} +TEST(SectionIconBar, ZeroWhenTooNarrow) { + EXPECT_EQ(visibleIconCount(3, 20, 30, 30, 3), 0); +} diff --git a/tests/controller/test_plane_z_registry.cpp b/tests/controller/test_plane_z_registry.cpp new file mode 100644 index 0000000..ab3fa41 --- /dev/null +++ b/tests/controller/test_plane_z_registry.cpp @@ -0,0 +1,38 @@ +#include + +#include "controller/PlaneZRegistry.hpp" + +using geopro::controller::PlaneZRegistry; + +TEST(PlaneZRegistry, FirstCheckSetsPlaneZ) { + PlaneZRegistry r; + EXPECT_DOUBLE_EQ(r.onChecked("trajectory", "a", 12.0), 12.0); + EXPECT_TRUE(r.hasPlane("trajectory")); + EXPECT_DOUBLE_EQ(r.planeZ("trajectory"), 12.0); +} +TEST(PlaneZRegistry, SecondCheckKeepsFirstZ) { + PlaneZRegistry r; + r.onChecked("trajectory", "a", 12.0); + EXPECT_DOUBLE_EQ(r.onChecked("trajectory", "b", 99.0), 12.0); // 投影到首个 ds 的平面 +} +TEST(PlaneZRegistry, PlaneDisappearsWhenAllUnchecked) { + PlaneZRegistry r; + r.onChecked("trajectory", "a", 12.0); + r.onChecked("trajectory", "b", 99.0); + r.onUnchecked("trajectory", "a"); + EXPECT_TRUE(r.hasPlane("trajectory")); // 还有 b + r.onUnchecked("trajectory", "b"); + EXPECT_FALSE(r.hasPlane("trajectory")); // 全消 → 平面消失 +} +TEST(PlaneZRegistry, RecheckAfterEmptyResetsZ) { + PlaneZRegistry r; + r.onChecked("trajectory", "a", 12.0); + r.onUnchecked("trajectory", "a"); + EXPECT_DOUBLE_EQ(r.onChecked("trajectory", "c", 7.0), 7.0); // 重新首勾 → 新 z +} +TEST(PlaneZRegistry, SetPlaneZMovesPlane) { + PlaneZRegistry r; + r.onChecked("trajectory", "a", 12.0); + r.setPlaneZ("trajectory", 30.0); + EXPECT_DOUBLE_EQ(r.planeZ("trajectory"), 30.0); +} diff --git a/tests/controller/test_render_strategy_registry.cpp b/tests/controller/test_render_strategy_registry.cpp new file mode 100644 index 0000000..9bb4043 --- /dev/null +++ b/tests/controller/test_render_strategy_registry.cpp @@ -0,0 +1,22 @@ +#include +#include "controller/DatasetRenderStrategy.hpp" + +using namespace geopro::controller; + +namespace { +struct FakeStrategy : IDatasetRenderStrategy { + int added = 0; + void add(const std::string&, const std::string&) override { ++added; } + void remove(const std::string&) override {} +}; +} + +TEST(RenderStrategyRegistry, ResolvesById) { + RenderStrategyRegistry reg; + reg.registerStrategy("fake", std::make_unique()); + auto* s = reg.get("fake"); + ASSERT_NE(s, nullptr); + s->add("trajectory", "d1"); + EXPECT_EQ(static_cast(s)->added, 1); + EXPECT_EQ(reg.get("missing"), nullptr); +} diff --git a/tests/controller/test_vtk_scene_controller.cpp b/tests/controller/test_vtk_scene_controller.cpp index 9704ae0..1ede691 100644 --- a/tests/controller/test_vtk_scene_controller.cpp +++ b/tests/controller/test_vtk_scene_controller.cpp @@ -203,26 +203,27 @@ struct FakeSceneRepo : data::I3dSceneRepository { } // namespace -// 2D 模式 + 勾选 1 ds → 1 个测线 actor,无帘面/体素/地形。 -TEST(VtkSceneController, Map2DWithOneDatasetAddsSurveyLine) { - FakeDsRepo ds; FakeSceneRepo sc; FakeView view; - VtkSceneController c(ds, sc, view); - c.setViewMode(ViewMode::Map2D); - c.setCheckedDatasets({"ds1"}); - - EXPECT_EQ(view.surveyLines, 1); - EXPECT_EQ(view.curtains, 0); - EXPECT_EQ(view.volumes, 0); - EXPECT_GE(view.renders, 1); - EXPECT_TRUE(view.lastIs2D); +// B2 后勾选统一入口 = (dsId, typeId=描述符 id) 列表。便捷构造:电阻率(curtain)/三维体(volume)/轨迹(plane2d)。 +namespace { +using IdType = std::vector>; +IdType curtainIds(std::initializer_list ids) { + IdType v; + for (const auto& id : ids) v.push_back({id, "resistivity"}); // resistivity → renderStrategyId "curtain" + return v; } +IdType voxelIds(std::initializer_list ids) { + IdType v; + for (const auto& id : ids) v.push_back({id, "voxel"}); // voxel → "volume" + return v; +} +} // namespace -// 3D 模式 + 帘面图层 → 1 帘面 actor。 +// 3D 帘面:勾选电阻率(curtain 策略) → 1 帘面 actor。 TEST(VtkSceneController, View3DCurtainAddsCurtain) { FakeDsRepo ds; FakeSceneRepo sc; FakeView view; VtkSceneController c(ds, sc, view); c.setViewMode(ViewMode::View3D); - c.setCheckedDatasets({"ds1"}); + c.setCheckedDatasets(curtainIds({"ds1"})); EXPECT_EQ(view.curtains, 1); EXPECT_EQ(view.surveyLines, 0); @@ -235,7 +236,7 @@ TEST(VtkSceneController, View3DVolumeDatasetAddsVolume) { sc.volumeIds = {"ds1"}; // ds1 = 三维体类型 → 体素渲染路径 VtkSceneController c(ds, sc, view); c.setViewMode(ViewMode::View3D); - c.setCheckedDatasets({"ds1"}); + c.setCheckedDatasets(voxelIds({"ds1"})); EXPECT_EQ(view.volumes, 1); EXPECT_EQ(view.curtains, 0); // 体素数据集不再同时出帘面 @@ -247,18 +248,18 @@ TEST(VtkSceneController, View3DWithTerrainAddsTerrain) { VtkSceneController c(ds, sc, view); c.setViewMode(ViewMode::View3D); c.setLayer(SceneLayer::Terrain, true); - c.setCheckedDatasets({"ds1"}); + c.setCheckedDatasets(curtainIds({"ds1"})); EXPECT_EQ(view.terrains, 1); EXPECT_EQ(view.curtains, 1); } -// 取消勾选 → 增量移除该 ds 图元(不整场 clear,3D 增量路径)。 +// 取消勾选 → 增量移除该 ds 图元(不整场 clear,增量路径)。 TEST(VtkSceneController, UncheckRemovesDatasetIncrementally) { FakeDsRepo ds; FakeSceneRepo sc; FakeView view; VtkSceneController c(ds, sc, view); c.setViewMode(ViewMode::View3D); - c.setCheckedDatasets({"ds1"}); + c.setCheckedDatasets(curtainIds({"ds1"})); ASSERT_EQ(view.curtains, 1); const int clearsAfterCheck = view.clears; @@ -273,11 +274,11 @@ TEST(VtkSceneController, IncrementalAddKeepsExisting) { FakeDsRepo ds; FakeSceneRepo sc; FakeView view; VtkSceneController c(ds, sc, view); c.setViewMode(ViewMode::View3D); - c.setCheckedDatasets({"ds1"}); + c.setCheckedDatasets(curtainIds({"ds1"})); const int clearsAfterFirst = view.clears; ASSERT_EQ(view.curtains, 1); - c.setCheckedDatasets({"ds1", "ds2"}); // 增量加 ds2 + c.setCheckedDatasets(curtainIds({"ds1", "ds2"})); // 增量加 ds2 EXPECT_EQ(view.curtains, 2); EXPECT_EQ(view.clears, clearsAfterFirst); // 不重建 → 无新 clear } @@ -288,7 +289,7 @@ TEST(VtkSceneController, VerticalExaggerationForwarded) { VtkSceneController c(ds, sc, view); c.setViewMode(ViewMode::View3D); c.setVerticalExaggeration(3.5); - c.setCheckedDatasets({"ds1"}); + c.setCheckedDatasets(curtainIds({"ds1"})); EXPECT_DOUBLE_EQ(view.ve, 3.5); } @@ -297,7 +298,7 @@ TEST(VtkSceneController, MultipleDatasetsAddMultipleCurtains) { FakeDsRepo ds; FakeSceneRepo sc; FakeView view; VtkSceneController c(ds, sc, view); c.setViewMode(ViewMode::View3D); - c.setCheckedDatasets({"ds1", "ds2", "ds3"}); + c.setCheckedDatasets(curtainIds({"ds1", "ds2", "ds3"})); EXPECT_EQ(view.curtains, 3); } @@ -309,7 +310,7 @@ TEST(VtkSceneController, SetVolumeColorScaleRebuildsCheckedVolume) { sc.volumeIds = {"ds1"}; VtkSceneController c(ds, sc, view); c.setViewMode(ViewMode::View3D); - c.setCheckedDatasets({"ds1"}); + c.setCheckedDatasets(voxelIds({"ds1"})); ASSERT_EQ(view.volumes, 1); const int removesBefore = view.removeCalls; @@ -330,7 +331,7 @@ TEST(VtkSceneController, SetVolumeColorScalePersistsAcrossRecheck) { sc.volumeIds = {"ds1"}; VtkSceneController c(ds, sc, view); c.setViewMode(ViewMode::View3D); - c.setCheckedDatasets({"ds1"}); // 加载体(填充 volumeCache_) + c.setCheckedDatasets(voxelIds({"ds1"})); // 加载体(填充 volumeCache_) ASSERT_EQ(view.lastVolumeScale.stopCount(), 2u); // 初始两段 core::ColorScale edited; // 编辑成三段 @@ -340,8 +341,8 @@ TEST(VtkSceneController, SetVolumeColorScalePersistsAcrossRecheck) { c.setVolumeColorScale("ds1", edited); ASSERT_EQ(view.lastVolumeScale.stopCount(), 3u); - c.setCheckedDatasets({}); // 取消勾选 - c.setCheckedDatasets({"ds1"}); // 再勾选 → 命中缓存(含编辑后色阶) + c.setCheckedDatasets({}); // 取消勾选 + c.setCheckedDatasets(voxelIds({"ds1"})); // 再勾选 → 命中缓存(含编辑后色阶) EXPECT_EQ(view.lastVolumeScale.stopCount(), 3u); } @@ -409,146 +410,56 @@ TEST(VtkSceneController, ZoomAndFitForwarded) { EXPECT_EQ(view.fitCalls, 1); } -// ── 二维数据集视图:足迹平铺进 View3D ── - -// 显式关闭摆放(0) → 勾选 2D 足迹不渲染(仅记录勾选)。 -TEST(VtkSceneController, TwoDPlacementOffDoesNotRender) { - FakeDsRepo ds; FakeSceneRepo sc; FakeView view; - VtkSceneController c(ds, sc, view); - c.setViewMode(ViewMode::View3D); - c.set2DPlacement(0, 0.0); // 显式关闭 - c.setChecked2DDatasets({"traj1"}); - EXPECT_EQ(view.mapLines, 0); +// ── 二维数据集(轨迹/足迹)经 plane2d 策略平铺进场景 ── +// B2:去 col2D + setChecked2DDatasets/set2DPlacement 公有入口,2D 与 3D 合一经统一入口 +// setCheckedDatasets((dsId, typeId))。trajectory 描述符 → "plane2d" 策略 → add2DDatasetAsync。 +// 摆放暂固定默认(Z=0);置/底/自定义 + analysisMode 取景基线相关用例随旧入口移除(Phase E/F 重接后补)。 +namespace { +IdType trajIds(std::initializer_list ids) { + IdType v; + for (const auto& id : ids) v.push_back({id, "trajectory"}); // trajectory → renderStrategyId "plane2d" + return v; } +} // namespace -// 回归(足迹默认不渲染 bug):默认摆放=Z=0(1),与 2D视图下拉可见默认项一致 → -// 仅勾选 2D 足迹(不手动调 set2DPlacement)即应在 View3D 渲染,worldZ=0。 -TEST(VtkSceneController, TwoDDefaultPlacementRendersAtZeroOnCheck) { +// 勾选轨迹(plane2d 策略) → 1 条 mapLine,默认摆放 worldZ=0;不影响帘面/体素计数。 +TEST(VtkSceneController, TrajectoryRendersAsMapLineAtDefaultZero) { FakeDsRepo ds; FakeSceneRepo sc; FakeView view; VtkSceneController c(ds, sc, view); c.setViewMode(ViewMode::View3D); - c.setChecked2DDatasets({"traj1"}); // 不调 set2DPlacement,依赖默认摆放 - EXPECT_EQ(view.mapLines, 1); - EXPECT_DOUBLE_EQ(view.lastMapLineZ, 0.0); -} - -// 摆放 Z=0(1) + 勾选足迹 → 1 条 mapLine,worldZ=0;不影响帘面/体素计数。 -TEST(VtkSceneController, TwoDPlacementZeroAddsMapLine) { - FakeDsRepo ds; FakeSceneRepo sc; FakeView view; - VtkSceneController c(ds, sc, view); - c.setViewMode(ViewMode::View3D); - c.set2DPlacement(1, 0.0); // Z=0 - c.setChecked2DDatasets({"traj1"}); + c.setCheckedDatasets(trajIds({"traj1"})); EXPECT_EQ(view.mapLines, 1); EXPECT_DOUBLE_EQ(view.lastMapLineZ, 0.0); EXPECT_EQ(view.curtains, 0); EXPECT_EQ(view.volumes, 0); } -// 顶部/底部摆放锚定真实地表高程:worldZ = zRefElev ± 偏移(而非世界 0 ± 偏移)。 -TEST(VtkSceneController, TwoDPlacementTopBottomAnchorToSurfaceElev) { - FakeDsRepo ds; FakeSceneRepo sc; FakeView view; - view.refElev = 1200.0; // 地表高程基准 - VtkSceneController c(ds, sc, view); - c.setViewMode(ViewMode::View3D); - c.set2DPlacement(2, 0.0); // 顶部 - c.setChecked2DDatasets({"traj1"}); - EXPECT_DOUBLE_EQ(view.lastMapLineZ, 1200.0 + 50.0); // 贴地表上方 - c.set2DPlacement(3, 0.0); // 底部 - EXPECT_DOUBLE_EQ(view.lastMapLineZ, 1200.0 - 50.0); // 地表下方 -} - -// 取消勾选 2D 足迹 → 增量移除该足迹图元(不整场 clear)。 -TEST(VtkSceneController, TwoDUncheckRemovesMapLine) { +// 取消勾选轨迹 → 增量移除该足迹图元(不整场 clear)。 +TEST(VtkSceneController, TrajectoryUncheckRemovesMapLine) { FakeDsRepo ds; FakeSceneRepo sc; FakeView view; VtkSceneController c(ds, sc, view); c.setViewMode(ViewMode::View3D); - c.set2DPlacement(1, 0.0); - c.setChecked2DDatasets({"traj1"}); + c.setCheckedDatasets(trajIds({"traj1"})); ASSERT_EQ(view.mapLines, 1); const int clearsBefore = view.clears; - c.setChecked2DDatasets({}); // 取消勾选 + c.setCheckedDatasets({}); // 取消勾选 EXPECT_EQ(view.mapLines, 0); EXPECT_EQ(view.clears, clearsBefore); // 增量移除,不整场 clear } -// 2D 足迹与 3D 帘面共存且独立:勾选剖面 + 足迹,各出各的图元,互不影响。 -TEST(VtkSceneController, TwoDFootprintCoexistsWith3DCurtain) { +// 轨迹足迹与 3D 帘面经同一入口共存且独立:各出各的图元,取消足迹不影响帘面。 +TEST(VtkSceneController, TrajectoryCoexistsWith3DCurtain) { FakeDsRepo ds; FakeSceneRepo sc; FakeView view; VtkSceneController c(ds, sc, view); c.setViewMode(ViewMode::View3D); - c.setCheckedDatasets({"prof1"}); // 3D 帘面 - c.set2DPlacement(1, 0.0); - c.setChecked2DDatasets({"traj1"}); // 2D 足迹 + IdType both = curtainIds({"prof1"}); + both.push_back({"traj1", "trajectory"}); + c.setCheckedDatasets(both); // 帘面 + 足迹(统一入口并集) EXPECT_EQ(view.curtains, 1); EXPECT_EQ(view.mapLines, 1); - c.setChecked2DDatasets({}); // 取消足迹 → 帘面不受影响 + c.setCheckedDatasets(curtainIds({"prof1"})); // 仅留帘面 → 足迹移除 EXPECT_EQ(view.mapLines, 0); EXPECT_EQ(view.curtains, 1); } - -// 回归(BUG3:二维分析切回三维分析后,三维数据"不知生成到哪",要手动适配才定位): -// 二维勾选足迹自动取景后 hadArrivedData_=true;切回三维前 onAnalysisModeChanged(false) 按"三维栏空" -// 复位取景基线 → 勾选三维数据应自动取景(fitView),而非停在旧相机。 -TEST(VtkSceneController, ThreeDDataFitsAfterSwitchingBackFrom2D) { - FakeDsRepo ds; FakeSceneRepo sc; FakeView view; - VtkSceneController c(ds, sc, view); - c.setViewMode(ViewMode::View3D); - - c.onAnalysisModeChanged(true); // 切到二维(2D 栏空 → 基线允许取景) - c.setChecked2DDatasets({"traj1"}); - ASSERT_EQ(view.mapLines, 1); - const int fitsAfter2D = view.fitCalls; - EXPECT_GE(fitsAfter2D, 1); // 足迹首次到场已取景 - - c.onAnalysisModeChanged(false); // 切回三维(3D 栏空 → 基线允许取景) - c.setCheckedDatasets({"prof1"}); - EXPECT_EQ(view.curtains, 1); - EXPECT_GT(view.fitCalls, fitsAfter2D); // 三维数据到场自动取景(修复前不取景) -} - -// 回归(二维分析下已有隐藏 3D 数据时,勾选首条足迹也应取景;旧 wasEmpty 逻辑因 3D 非空而漏取景): -TEST(VtkSceneController, TwoDFootprintFitsEvenWhenHidden3DExists) { - FakeDsRepo ds; FakeSceneRepo sc; FakeView view; - VtkSceneController c(ds, sc, view); - c.setViewMode(ViewMode::View3D); - c.setCheckedDatasets({"prof1"}); // 三维数据(取景一次) - const int fitsAfter3D = view.fitCalls; - - c.onAnalysisModeChanged(true); // 切到二维(2D 栏空 → 基线允许取景) - c.setChecked2DDatasets({"traj1"}); - EXPECT_EQ(view.mapLines, 1); - EXPECT_GT(view.fitCalls, fitsAfter3D); // 首条足迹取景(旧逻辑因有隐藏 3D 而漏) -} - -// 自定义摆放(4) → worldZ=customZ;改摆放重摆已勾选足迹(移除旧 + 按新 Z 重加)。 -TEST(VtkSceneController, TwoDPlacementCustomZAndReplace) { - FakeDsRepo ds; FakeSceneRepo sc; FakeView view; - VtkSceneController c(ds, sc, view); - c.setViewMode(ViewMode::View3D); - c.set2DPlacement(4, 123.5); // 自定义 Z - c.setChecked2DDatasets({"traj1"}); - ASSERT_EQ(view.mapLines, 1); - EXPECT_DOUBLE_EQ(view.lastMapLineZ, 123.5); - const int removesBefore = view.removeCalls; - - c.set2DPlacement(4, 200.0); // 改自定义 Z → 重摆 - EXPECT_EQ(view.mapLines, 1); // 移除 1 + 新增 1 → 净计数不变 - EXPECT_EQ(view.removeCalls, removesBefore + 1); // 旧足迹被移除 - EXPECT_DOUBLE_EQ(view.lastMapLineZ, 200.0); // 新 Z 已下发 -} - -// 摆放从关闭(0)切到 Z=0(1) → 已勾选但未渲染的足迹补画。 -TEST(VtkSceneController, TwoDPlacementOffToOnDrawsCheckedFootprint) { - FakeDsRepo ds; FakeSceneRepo sc; FakeView view; - VtkSceneController c(ds, sc, view); - c.setViewMode(ViewMode::View3D); - c.set2DPlacement(0, 0.0); // 显式关闭(默认已是 Z=0) - c.setChecked2DDatasets({"traj1"}); // 关闭态:仅记录 - ASSERT_EQ(view.mapLines, 0); - - c.set2DPlacement(1, 0.0); // 切到 Z=0 → 补画 - EXPECT_EQ(view.mapLines, 1); -} diff --git a/tests/data/test_3d_repo.cpp b/tests/data/test_3d_repo.cpp index 3367e49..a40e671 100644 --- a/tests/data/test_3d_repo.cpp +++ b/tests/data/test_3d_repo.cpp @@ -259,6 +259,31 @@ TEST(Api3dRepo, RegisterRadarDatasetRoutesAsDdRadar3d) { std::filesystem::remove_all(dir, ec); } +// isRadarVolume:仅带 linePrefix 的体(registerRadarDataset 登记)为真;普通 IDW 体、未知 id 为假。 +// 支撑「沿线位置」滑块门控(仅雷达体在场才显示)。 +TEST(Api3dRepo, IsRadarVolumeOnlyTrueForRadarLine) { + const auto dir = std::filesystem::temp_directory_path() / "api3d_radar_isradar"; + std::error_code ec; + std::filesystem::remove_all(dir, ec); + writeSyntheticRadarLine(dir); + + StubAsyncRepo dsRepo; + auto frame = std::make_shared(22.0, 114.0); + Api3dRepository repo(dsRepo, frame); + + const std::string radarId = + repo.registerRadarDataset(dir.string(), "L", "测线L", "tm-1", /*coarse=*/1); + EXPECT_TRUE(repo.isRadarVolume(radarId)); + + VolumeBuildParams p; + p.sourceDatasetIds = {"src-a"}; + const std::string plainId = repo.createVolume(p, "普通体"); + EXPECT_FALSE(repo.isRadarVolume(plainId)); // 普通 IDW 体无 linePrefix + EXPECT_FALSE(repo.isRadarVolume("no-such")); // 未知 id + + std::filesystem::remove_all(dir, ec); +} + // loadVolume:首次勾选时懒建雷达体(无 QCoreApplication → 同步交付;全量测试中若其它用例已建 // QCoreApplication 单例则走异步,processEvents 排空队列交付)。回调收到有效 VolumeGrid。 TEST(Api3dRepo, LoadVolumeBuildsRadarLazily) { diff --git a/tests/data/test_category_descriptor.cpp b/tests/data/test_category_descriptor.cpp new file mode 100644 index 0000000..ef4708a --- /dev/null +++ b/tests/data/test_category_descriptor.cpp @@ -0,0 +1,35 @@ +#include +#include "repo/CategoryDescriptor.hpp" +#include "DatasetCategory.hpp" + +using namespace geopro::data; + +TEST(CategoryCatalog, HasFiveSegmentsInOrder) { + const auto& cat = categoryCatalog(); + ASSERT_EQ(cat.size(), 5u); + EXPECT_EQ(cat[0].id, "resistivity"); + EXPECT_EQ(cat[3].id, "voxel"); + EXPECT_EQ(cat[4].id, "trajectory"); + EXPECT_EQ(cat[4].sceneKind, SceneKind::Plane2D); + EXPECT_EQ(cat[4].renderStrategyId, "plane2d"); +} + +TEST(CategoryCatalog, TrajectoryClassifiesByDdCode) { + const auto& cat = categoryCatalog(); + DsRow traj; traj.id = "t1"; traj.ddCode = "dd_trajectory_data"; + EXPECT_TRUE(cat[4].classify(traj)); + DsRow vox; vox.ddCode = "dd_voxel"; + EXPECT_FALSE(cat[4].classify(vox)); +} + +TEST(SplitByCategory, RoutesRowToFirstMatchingDescriptor) { + DsRow traj; traj.id = "t1"; traj.ddCode = "dd_trajectory_data"; + DsRow ert; ert.id = "e1"; ert.dsTypeCode = "ERT platform inversion data"; + auto b = geopro::app::splitByCategory({traj, ert}); + const auto& cat = categoryCatalog(); + ASSERT_EQ(b.segments.size(), cat.size()); + EXPECT_EQ(b.segments[0].size(), 1u); // resistivity ← ert + EXPECT_EQ(b.segments[0][0].id, "e1"); + EXPECT_EQ(b.segments[4].size(), 1u); // trajectory ← traj + EXPECT_EQ(b.segments[4][0].id, "t1"); +} diff --git a/tests/io/gpr/test_normalized_radar_bridge.cpp b/tests/io/gpr/test_normalized_radar_bridge.cpp index 8e06d1f..1adc0de 100644 --- a/tests/io/gpr/test_normalized_radar_bridge.cpp +++ b/tests/io/gpr/test_normalized_radar_bridge.cpp @@ -1,11 +1,29 @@ #include +#include #include #include #include +#include #include "core/algo/GprVolumeBuilder.hpp" #include "io/gpr/NormalizedRadarVolumeBridge.hpp" +#ifdef _WIN32 +#include +#endif namespace fs = std::filesystem; +namespace { +// 在指定目录写出一组最小可解析的规范化 .head/.data(K=4 道 M=2 通道 N=3 采样)。 +void writeMinimalLine(const fs::path& head, const fs::path& data) { + { std::ofstream f(head); + f << "SAMPLES:3\nNUMBER_OF_CH:2\nLAST_TRACE:8\nBITS:16\nENDIAN_TYPE:1\n" + "DISTANCE_INTERVAL:0.1\nTIMEWINDOW:30\nDIELECTRIC:9\n"; } + { std::ofstream f(data, std::ios::binary); + for (int t = 0; t < 4; ++t) for (int c = 0; c < 2; ++c) for (int s = 0; s < 3; ++s) { + std::int16_t v = static_cast(t * 10 + c * 100 + s); + f.write(reinterpret_cast(&v), 2); } } +} +} // namespace + TEST(NormalizedRadarBridge, BuildsVolumeWithExpectedAxes) { // K=4 道, M=2 通道, N=3 采样, 无通道偏移(不插值), coarse=1。 fs::path dir = fs::temp_directory_path() / "radar_bridge_test"; @@ -26,3 +44,55 @@ TEST(NormalizedRadarBridge, BuildsVolumeWithExpectedAxes) { EXPECT_GT(b.spacing[2], 0.0); // dz 由 timewindow/dielectric 求得 >0 EXPECT_NEAR(b.quant.toPhys(b.vol.at(3, 1, 2)), 132.0, b.quant.scale); // t3c1s2=30+100+2 } + +// 回归:中文目录路径必须能打开渲染。app 传入的是 QString::toLocal8Bit(),即当前 +// ANSI 代码页(简中=GBK)窄字节。关键复现条件——GUI app 链接 QWebEngine(Chromium)/VTK, +// 它们在启动时 setlocale(LC_ALL,"") 把 LC_CTYPE 提升为系统 UTF-8 locale;此后窄字符 +// ifstream 会把 GBK 路径字节当 UTF-8 解析 → open 失败(即"打开 .head 失败")。 +// 故本测试显式置 UTF-8 locale 复现该失败面,走宽字符打开(见 io/gpr/LocalPath)守护回归。 +// (纯 "C" locale 下 UCRT 用 CP_ACP=GBK 解窄路径,反而不失败,无法复现——须置 UTF-8。) +TEST(NormalizedRadarBridge, OpensCjkDirectoryPathUnderUtf8Locale) { +#ifdef _WIN32 + const std::wstring wname = L"radar_cjk_南同大道"; + const fs::path dir = fs::temp_directory_path() / wname; + fs::remove_all(dir); + fs::create_directories(dir); + writeMinimalLine(dir / L"南同大道_000.head", dir / L"南同大道_000.data"); + + // 模拟 app:宽字符 → 当前 ANSI 代码页(GBK)窄字节,等价 QString::toLocal8Bit()。 + auto toAcp = [](const std::wstring& w) { + const int n = ::WideCharToMultiByte(CP_ACP, 0, w.data(), + static_cast(w.size()), nullptr, 0, + nullptr, nullptr); + std::string s(static_cast(n), '\0'); + ::WideCharToMultiByte(CP_ACP, 0, w.data(), static_cast(w.size()), + s.data(), n, nullptr, nullptr); + return s; + }; + const std::string dirAcp = toAcp(dir.wstring()); + const std::string prefixAcp = toAcp(L"南同大道_000"); + + // 复现 app 运行期的 UTF-8 C locale(QWebEngine/VTK 所置)——不修复则 narrow open 失败。 + const char* prevC = std::setlocale(LC_ALL, nullptr); + const std::string savedC = prevC ? prevC : "C"; + std::setlocale(LC_ALL, ".UTF-8"); + + geopro::core::BuiltI16 b; + try { + b = geopro::io::gpr::buildLineVolumeFromNormalized( + dirAcp, prefixAcp, /*coarse=*/1, /*targetDy=*/0.0); + } catch (...) { + std::setlocale(LC_ALL, savedC.c_str()); + fs::remove_all(dir); + throw; // 未修复时会在此抛"打开 .head 失败"→ 测试红,正是回归守护 + } + std::setlocale(LC_ALL, savedC.c_str()); + + EXPECT_EQ(b.vol.nx(), 4); + EXPECT_EQ(b.vol.ny(), 2); + EXPECT_EQ(b.vol.nz(), 3); + fs::remove_all(dir); +#else + GTEST_SKIP() << "中文窄路径打开问题仅 Windows 相关"; +#endif +} diff --git a/tests/render/test_camera_preset.cpp b/tests/render/test_camera_preset.cpp index c563ee8..a123e30 100644 --- a/tests/render/test_camera_preset.cpp +++ b/tests/render/test_camera_preset.cpp @@ -139,6 +139,73 @@ TEST(CameraPreset, ZoomInOrthoReducesParallelScale) { EXPECT_LT(c->GetParallelScale(), before); } +// ── orbitPose(纯数学,供 orbitToAxis 用)────────────────────────────────────── +// 各方向:focal==pivot、|pos-pivot|==distance、pos 沿正确轴偏移、up 与 applyView 约定一致。 +namespace { +constexpr double kPivot[3] = {10.0, -20.0, 5.0}; +constexpr double kDist = 7.0; + +double dist3(const double a[3], const double b[3]) { + const double dx = a[0] - b[0], dy = a[1] - b[1], dz = a[2] - b[2]; + return std::sqrt(dx * dx + dy * dy + dz * dz); +} +} // namespace + +// Top:pos 在 pivot 的 +Z、距离保持,up=+Y(对齐 applyView Top)。 +TEST(OrbitPose, TopOffsetsPlusZUpY) { + auto pose = orbitPose(ViewDir::Top, kPivot, kDist); + EXPECT_NEAR(pose.focal[0], kPivot[0], 1e-9); + EXPECT_NEAR(pose.focal[1], kPivot[1], 1e-9); + EXPECT_NEAR(pose.focal[2], kPivot[2], 1e-9); + EXPECT_NEAR(dist3(pose.pos, kPivot), kDist, 1e-9); + EXPECT_NEAR(pose.pos[0], kPivot[0], 1e-9); + EXPECT_NEAR(pose.pos[1], kPivot[1], 1e-9); + EXPECT_NEAR(pose.pos[2], kPivot[2] + kDist, 1e-9); // +Z + EXPECT_NEAR(pose.up[0], 0.0, 1e-9); + EXPECT_NEAR(pose.up[1], 1.0, 1e-9); + EXPECT_NEAR(pose.up[2], 0.0, 1e-9); +} + +// Bottom:pos 在 -Z,up=+Y。 +TEST(OrbitPose, BottomOffsetsMinusZUpY) { + auto pose = orbitPose(ViewDir::Bottom, kPivot, kDist); + EXPECT_NEAR(dist3(pose.pos, kPivot), kDist, 1e-9); + EXPECT_NEAR(pose.pos[2], kPivot[2] - kDist, 1e-9); // -Z + EXPECT_NEAR(pose.up[1], 1.0, 1e-9); +} + +// Front:从 -Y 看 +Y → pos 在 -Y,up=+Z。 +TEST(OrbitPose, FrontOffsetsMinusYUpZ) { + auto pose = orbitPose(ViewDir::Front, kPivot, kDist); + EXPECT_NEAR(dist3(pose.pos, kPivot), kDist, 1e-9); + EXPECT_NEAR(pose.pos[1], kPivot[1] - kDist, 1e-9); // -Y + EXPECT_NEAR(pose.up[2], 1.0, 1e-9); +} + +// Back:pos 在 +Y,up=+Z。 +TEST(OrbitPose, BackOffsetsPlusYUpZ) { + auto pose = orbitPose(ViewDir::Back, kPivot, kDist); + EXPECT_NEAR(dist3(pose.pos, kPivot), kDist, 1e-9); + EXPECT_NEAR(pose.pos[1], kPivot[1] + kDist, 1e-9); // +Y + EXPECT_NEAR(pose.up[2], 1.0, 1e-9); +} + +// Left:从 -X 看 +X → pos 在 -X,up=+Z。 +TEST(OrbitPose, LeftOffsetsMinusXUpZ) { + auto pose = orbitPose(ViewDir::Left, kPivot, kDist); + EXPECT_NEAR(dist3(pose.pos, kPivot), kDist, 1e-9); + EXPECT_NEAR(pose.pos[0], kPivot[0] - kDist, 1e-9); // -X + EXPECT_NEAR(pose.up[2], 1.0, 1e-9); +} + +// Right:pos 在 +X,up=+Z。 +TEST(OrbitPose, RightOffsetsPlusXUpZ) { + auto pose = orbitPose(ViewDir::Right, kPivot, kDist); + EXPECT_NEAR(dist3(pose.pos, kPivot), kDist, 1e-9); + EXPECT_NEAR(pose.pos[0], kPivot[0] + kDist, 1e-9); // +X + EXPECT_NEAR(pose.up[2], 1.0, 1e-9); +} + // 空指针/非法 factor 安全。 TEST(CameraPreset, NullAndInvalidAreSafe) { applyView(nullptr, ViewDir::Top);