feat/vtk-merged-dataset-column #10

Merged
gaozheng merged 40 commits from feat/vtk-merged-dataset-column into main 2026-07-01 14:48:38 +08:00
74 changed files with 3881 additions and 1420 deletions

View File

@ -13,6 +13,8 @@ set(CMAKE_AUTORCC ON)
set(CMAKE_AUTOUIC ON) set(CMAKE_AUTOUIC ON)
if(MSVC) if(MSVC)
# MSVC Release=/MDDebug=/MDdABI ENV_SETUP §9.2
set(CMAKE_MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>DLL")
add_compile_options(/utf-8 /MP /W4 /permissive-) add_compile_options(/utf-8 /MP /W4 /permissive-)
# PDB使 Release 使 minidump / # PDB使 Release 使 minidump /
# /Zi /DEBUG PDB/OPT:REF,ICF /DEBUG # /Zi /DEBUG PDB/OPT:REF,ICF /DEBUG
@ -31,16 +33,61 @@ endif()
# - Qt GDAL/PROJ/OpenSSL/Eigen/... vcpkg # - 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) 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 friendlyfind_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 9 COMPONENTS VTK_LIBRARIES VTK
# VTK_DIRexternal/vtk-installVolume/Filters # VTK_DIRexternal/vtk-installVolume/Filters
find_package(VTK REQUIRED COMPONENTS find_package(VTK REQUIRED COMPONENTS
GUISupportQt GUISupportQt
RenderingOpenGL2 RenderingOpenGL2
RenderingFreeType # gizmo 轴标签(vtkBillboardTextActor3D) FreeType
InteractionStyle InteractionStyle
FiltersSources FiltersSources
) )
# VTK major.minor 9.6VTK_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 # Qt vcpkg
# find_package(GDAL CONFIG REQUIRED) # find_package(GDAL CONFIG REQUIRED)
# find_package(PROJ CONFIG REQUIRED) # find_package(PROJ CONFIG REQUIRED)

View File

@ -12,7 +12,7 @@
"CMAKE_TOOLCHAIN_FILE": "$env{VCPKG_ROOT}/scripts/buildsystems/vcpkg.cmake", "CMAKE_TOOLCHAIN_FILE": "$env{VCPKG_ROOT}/scripts/buildsystems/vcpkg.cmake",
"VCPKG_TARGET_TRIPLET": "x64-windows", "VCPKG_TARGET_TRIPLET": "x64-windows",
"CMAKE_EXPORT_COMPILE_COMMANDS": "ON", "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" "VTK_DIR": "${sourceDir}/external/vtk-install/lib/cmake/vtk-9.6"
} }
}, },

View File

@ -42,6 +42,16 @@ if not exist "%CMAKE%" ( echo [build] cmake not found: "%CMAKE%" & exit /b 1 )
REM --- activate MSVC environment (cl / link / include / lib) --- REM --- activate MSVC environment (cl / link / include / lib) ---
call "%VCVARS%" >nul call "%VCVARS%" >nul
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" set "CMD=%~1"
if "%CMD%"=="" set "CMD=app" if "%CMD%"=="" set "CMD=app"

View File

@ -1,106 +1,87 @@
# Geopro 3.0 桌面客户端 — Windows 开发环境从零搭建指引 # Geopro 3.0 桌面客户端 — Windows 开发环境从零搭建指引
适用Windows 10 22H2 / Windows 1164 位,MSVC 2022 工具链 适用Windows 10 22H2 / Windows 1164 位,**MSVC 2022v143工具链**
目标:从一台干净的机器,搭到 `cmake --build` 出可运行的 Qt6 + VTK9 桌面程序。 目标:从一台干净的机器,搭到 `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 曾残留「全 vcpkgQt/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 的组件都不走 vcpkg**vcpkg 的 Qt 端口会再编一份 qtbase = 双份 Qt 冲突):
>
> **方案②-修订要点**:单一 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 安全。
四块:① 编译器VS18 / MSVC 14.51)② Git ③ vcpkg仅非 Qt 依赖)④ 官方 MSVC Qt + 源码 VTK。 | 组件 | 来源 | 备注 |
|---|---|---|
| Qt 6.11.1Core/Gui/Widgets/Network/Sql/Concurrent/OpenGL + tools | **官方 MSVC 2022 64-bit kit**§4 | 路径由预设 `CMAKE_PREFIX_PATH` 指定 |
| VTK 9.6.xGUISupportQt/RenderingOpenGL2/RenderingFreeType/InteractionStyle/FiltersSources… | **源码 Release 编 → `external/vtk-install`**§5 | 必须对齐同一份 Qt + 同一工具集 + Release/`/MD` |
| ADSQt-Advanced-Docking-System 4.3.1 | **FetchContent 自动**(配置时拉,对接官方 Qt | 无需手动 |
| QtKeychain v0.14.0 | **FetchContent 自动**`BUILD_WITH_QT6` | 无需手动 |
| Qwt 6.2(二维科学图表) | **手工克隆到 `external/qwt-src`**§5.3gitignored | 缺失则详情页图表功能不编入 |
| vendored 3DGPRViewergeopro_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 2022MSVC + CMake + Ninja ## 1. Visual StudioMSVC v143 + CMake + Ninja
1. 安装 **Visual Studio 2022 Community**(或更高)。 1. 安装 **Visual Studio 2022 Community**(或更高)。`build.bat` 也兼容 VS2026 preview但**其工具集必须与预编译 Qt 的 msvc2022(v143) ABI 兼容**——若用 VS2026 的更新工具集,稳妥做法是**用同一工具集重编 VTK**§5别让 Qt(v143 预编译) 与 VTK/app(更新工具集) 混。
2. 勾选工作负载 **「使用 C++ 的桌面开发」(Desktop development with C++)**,确保包含: 2. 勾选工作负载 **「使用 C++ 的桌面开发」**,确保含:
- MSVC v143 - VS 2022 C++ x64/x86 生成工具 - MSVC v143 - VS 2022 C++ x64/x86 生成工具
- Windows 11 SDK或 Windows 10 SDK - Windows 11 SDK或 Windows 10 SDK
- C++ CMake tools for Windows自带 CMake + Ninja - C++ CMake tools for Windows自带 CMake ≥3.21 + Ninja
- C++ AddressSanitizer用于 Debug Sanitizer规约 §10.2 - C++ AddressSanitizerDebug Sanitizer 用)
3. 验证开「x64 Native Tools Command Prompt for VS 2022」运行 3. 验证开「x64 Native Tools Command Prompt for VS」`cl` / `cmake --version`≥3.21/ `ninja --version`
```
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 ## 2. Git
1. 安装 [Git for Windows](https://git-scm.com/download/win)。 1. 安装 [Git for Windows](https://git-scm.com/download/win)`git --version` 验证。
2. 验证:`git --version`。 2. 本项目**已是 git 仓库**(无需 `git init`);正常 `git clone` 即可。
3. 本项目当前**尚未初始化 git 仓库**——首次提交前需 `git init`(见 §7
--- ---
## 3. vcpkg依赖管理 ## 3. vcpkg仅非 Qt 依赖)
```powershell ```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 C:\dev\vcpkg\bootstrap-vcpkg.bat
setx VCPKG_ROOT "C:\dev\vcpkg" # 永久;新开终端生效
``` ```
- **manifest 模式**:根 `vcpkg.json` 声明依赖 + `builtin-baseline` 锁版本CMake 配置时自动拉取,**无需手动 `vcpkg install`**。
设环境变量(系统环境变量或当前会话): - 实际依赖(勿加 Qt/VTK 进来):`eigen3, gdal, gtest, nlohmann-json, openssl, proj`。
```powershell - **不要随意 `vcpkg x-update-baseline`**——baseline 已锁,改动会漂移依赖版本、可能引入 ABI 不一致。
$env:VCPKG_ROOT = "C:\dev\vcpkg" - 首次会编 GDAL/PROJ 等,较久;可选配 `VCPKG_BINARY_SOURCES` 二进制缓存加速。
setx VCPKG_ROOT "C:\dev\vcpkg" # 永久(新开终端生效)
```
> 本项目用 **vcpkg manifest 模式**`vcpkg.json`),不需要手动 `vcpkg install`CMake 配置时按清单自动拉取。
--- ---
## 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` 1. 用官方在线安装器(或已装则开 `D:\Qt\MaintenanceTool.exe`)→ Add or remove components → 登录 Qt 账号。
2. 打开 `D:\Qt\MaintenanceTool.exe` → Add or remove components → 登录 Qt 账号。 2. 展开 Qt → **Qt 6.11.1**,勾选 **MSVC 2022 64-bit**,安装。
3. 展开 Qt → Qt 6.11.1,勾选 **MSVC 2022 64-bit**,安装。 3. 完成后应存在 `D:\Qt\6.11.1\msvc2022_64\lib\cmake\Qt6`
4. 完成后存在 `D:\Qt\6.11.1\msvc2022_64\lib\cmake\Qt6`(供 `find_package(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. 源码依赖(手工准备
| 类别 | 组件 | 来源 | ### 5.1 VTK 9.6.x 源码编到 `external/vtk-install`(用官方 QtRelease
|---|---|---|
| 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),要点:
```bat ```bat
call "<VS>\VC\Auxiliary\Build\vcvars64.bat" call "<VS>\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 -D CMAKE_INSTALL_PREFIX=D:/Git/lanbingtech/geopro/external/vtk-install
cmake --build D:\dev\vtk-build --target install cmake --build D:\dev\vtk-build --target install
``` ```
完成后 `external/vtk-install/lib/cmake/vtk-9.6``find_package(VTK)`(已在 `CMakePresets.json``VTK_DIR`)。 - 完成后 `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。 - **必须 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 / QtKeychainFetchContent自动
无需手动——根 `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 ```json
{ {
"version": 3,
"configurePresets": [ "configurePresets": [
{ {
"name": "msvc-debug", "name": "msvc-debug",
@ -134,112 +129,94 @@ cmake --build D:\dev\vtk-build --target install
"cacheVariables": { "cacheVariables": {
"CMAKE_BUILD_TYPE": "Debug", "CMAKE_BUILD_TYPE": "Debug",
"CMAKE_TOOLCHAIN_FILE": "$env{VCPKG_ROOT}/scripts/buildsystems/vcpkg.cmake", "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", "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 - `find_package(Qt6 REQUIRED COMPONENTS Core Gui Widgets Network Sql Concurrent)`
cmake_minimum_required(VERSION 3.21) - `find_package(VTK REQUIRED COMPONENTS GUISupportQt RenderingOpenGL2 RenderingFreeType InteractionStyle FiltersSources)`**必须列组件**)。
project(geopro_desktop LANGUAGES CXX) - MSVC flags`/utf-8 /MP /W4 /permissive-`;非 Debug 配置产 PDB`/Zi` + `/DEBUG` + `/OPT:REF,ICF`)。
set(CMAKE_CXX_STANDARD 17) - ADS/QtKeychain 经 FetchContentQwt 经 `cmake/qwt.cmake`(存在才编)。
set(CMAKE_CXX_STANDARD_REQUIRED ON) - 视图层用 **`QVTKOpenGLStereoWidget`**QOpenGLWidget 系ADS reparent 友好)。
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) # 含 GUISupportQtvtk[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
# ADSvcpkg 端口验证通过后 find_package否则用下方 FetchContent
add_subdirectory(src) ## 7. 首次配置、编译、运行、部署
enable_testing()
add_subdirectory(tests) **日常直接用 `build.bat`(推荐)**
``` ```
build.bat app # 配置(首次)+ 增量编 geopro_desktopRelease
> VTK 链接用 `vtk_module_autoinit`;视图层用 **`QVTKOpenGLStereoWidget`**QOpenGLWidget 系ADS reparent 友好,见设计 §K-9不用 native 版。 build.bat test # 编 + ctest 跑单测
build.bat run # 编 + 启动
> **ADS 备选引入**(若 vcpkg 端口不可用spike 阶段确定): build.bat rebuild # 强制 --clean-first 全量重编(增量疑似漏编时用)
> ```cmake build.bat configure # CMakeLists 改动后强制重跑 configure
> include(FetchContent)
> FetchContent_Declare(ads GIT_REPOSITORY https://github.com/githubuser0xFFFF/Qt-Advanced-Docking-System.git GIT_TAG <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` 固定 `--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**
- 全部 dll含 Qt来自 vcpkg`build/vcpkg_installed/x64-windows/(debug/)bin`。 - Qt6*.dll + plugins用**官方 Qt 的 `windeployqt`**`D:\Qt\6.11.1\msvc2022_64\bin\windeployqt.exe`)对齐**同一份官方 Qt** 到 exe 目录。
- 用 CMake `TARGET_RUNTIME_DLLS` + `add_custom_command(POST_BUILD)` 自动拷贝到 exe 目录(实现阶段加)。 - VTK*.dll`external/vtk-install/bin` 拷到 exe 目录(或加进 PATH
- **不混用官方 Qt 的 `windeployqt`**——本方案 Qt 来自 vcpkg混用会拷错版本造成双 Qt 冲突。 - **只保证 exe 目录一份 Qt6*.dll无双 Qt**;勿混入别处/vcpkg 的 Qt。
- Qt pluginsplatforms/imageformats/sqldrivers 等)由 vcpkg 部署脚本处理;如缺再用 vcpkg 安装树里的 `windeployqt` 对齐同一份 Qt。
--- ---
## 8. AI 编码上下文基础设施(规约 §10.1,强烈建议先建 ## 8. AI/IDE 上下文clangd
项目根放: - `.clangd``CompileFlags.CompilationDatabase: build/release`(对齐 `build.bat` 的主构建目录;若你主要用 Debug 则指 `build/debug`)。`CMAKE_EXPORT_COMPILE_COMMANDS=ON` 会在该目录产 `compile_commands.json`
- VS Code 用 **clangd** 扩展(禁用微软 C++ IntelliSense 避免冲突)。
- `.clang-format`:统一风格(基于 LLVM/Google + 团队微调)。
- `.clangd`
```yaml
CompileFlags:
CompilationDatabase: build/debug
```
使 clangd 读取 `compile_commands.json`,给 AI/IDE 精确类型上下文。
- VS Code 装 **clangd** 扩展(禁用微软 C++ IntelliSense 避免冲突),或 CLion 直接用 CMake。
--- ---
## 9. 验证清单 ## 9. 构建环境兼容性检查清单(交付/接手前逐项核对)
- [ ] `cl` / `cmake` / `ninja` / `git` 命令可用 > 目的:把「配置错→运行时随机崩溃」变成「一眼可查」。**任何一项不满足都可能导致 §10 的 `std::map`/`std::string` ABI 崩溃。**
- [ ] `VCPKG_ROOT` 已设
- [ ] `vcpkg.json` 含 qtbase + vtk[qt](共用一份 Qtpreset **未**指向官方 Qt ### 9.1 前置就位
- [ ] `cmake --preset msvc-debug` 成功Qt+VTK 已拉取编译,首次较久) - [ ] `cl` / `cmake`(≥3.21) / `ninja` / `git` 可用;`VCPKG_ROOT` 已设。
- [ ] `cmake --build` 出 exe - [ ] `QT_ROOT` 已 setx 指向 **6.11.1 `msvc2022_64`** kitMSVC非 MinGW`$QT_ROOT/lib/cmake/Qt6` 在(预设 `CMAKE_PREFIX_PATH=$env{QT_ROOT}` 读它;未设则配置期 FATAL
- [ ] exe 能起一个空 Qt 窗 + 一个 `QVTKOpenGLStereoWidget` 渲染窗(冒烟测试) - [ ] `external/vtk-install/lib/cmake/vtk-9.6` 存在VTK 已源码编 install
- [ ] 部署后 exe 目录只有一份 Qt6*.dll无双 Qt - [ ] `external/qwt-src/src` 存在(如需详情页图表)。
- [ ] `compile_commands.json` 生成clangd 正常索引 - [ ] `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=2STL 布局不同)。
- [ ] **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 流程逐条解决。*

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,256 @@
# VTK 视图重构:合并数据集单栏 + 动态段 + 图标工具条 + 2D 平面底图 — Spec2026-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<bool(const DsRow&)> classify; // 轴1 数据来源/分类("无论按什么规则接入"
std::vector<FilterKind> filters; // 轴2 本段筛选器(顺序=显示顺序)
std::vector<OpKind> operations; // 轴3 段头图标操作(顺序=显示顺序)
std::string renderStrategyId; // 轴4 渲染策略键(解析到注册表,见 §5.4
};
// classify 便捷构造器(覆盖现有按 ddCode / dsTypeCode 接入的常见情形;任意复杂规则可直接写 lambda
std::function<bool(const DsRow&)> byDdCode(std::initializer_list<std::string> codes);
std::function<bool(const DsRow&)> byDsTypeCode(std::initializer_list<std::string> codes);
const std::vector<CategoryDescriptor>& 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`3DSatellite/带高程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 描述符或单元测试佐证分类/路由走通)。

View File

@ -0,0 +1,67 @@
# VTK 视图导航与坐标轴改进 — Spec2026-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<std::string>& 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。实现取当前 |camfocal| 距离 dfocal 设为 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 解析」。
- **T3gnomon**:常驻可点击三向标 → orbitToAxis(pivot 规则)。
- **T4双击**:双击→适配到子树盒 + 详情联动 gate无详情页静默
- 顺序T1 先T2/T3/T4 都依赖 T1 的基元T2/T4 依赖「子树 dsIds 解析」T2 引入T4 复用)。
## 6. 验收
1. 无选中:显示全场景总览轴 + 右下角三向标。
2. 选中某体/切片/异常:全景轴隐去,出现覆盖「该体+其切片+异常」子树的一个贴合包围盒轴,能读该子树真实尺寸;取消选中恢复全景轴。
3. 点击三向标某方向轴:相机绕支点(选中子树中心/无选中则全场景中心)转到该轴、**缩放不变**、数据仍居中。
4. 双击列表某 DS视图适配到该 DS子树空间范围有详情页的类型联动打开中下方详情页三维体等无详情页类型**静默**(只适配、不报错)。
5. VE 对全部渲染物一致;坐标轴仍是空间位置度量、逐数据集色阶不变。

View File

@ -5,8 +5,10 @@ find_package(VTK REQUIRED COMPONENTS
GUISupportQt GUISupportQt
RenderingOpenGL2 RenderingOpenGL2
RenderingVolumeOpenGL2 RenderingVolumeOpenGL2
RenderingAnnotation # vtkAxesActor gnomon +
InteractionStyle InteractionStyle
InteractionWidgets InteractionWidgets
FiltersSources # vtkSphereSourcegnomon 6
FiltersGeometry FiltersGeometry
FiltersModeling FiltersModeling
IOImage # vtkPNGWriter IOImage # vtkPNGWriter
@ -80,11 +82,11 @@ add_executable(geopro_desktop WIN32
panels/chart/ChartPickGeometry.cpp panels/chart/ChartPickGeometry.cpp
panels/chart/ScatterMarqueePicker.cpp panels/chart/ScatterMarqueePicker.cpp
panels/chart/ContourDrawTool.cpp panels/chart/ContourDrawTool.cpp
panels/columns/Column2DDataset.cpp
panels/columns/CategorySection.cpp panels/columns/CategorySection.cpp
panels/columns/CategoryAnalysisTab.cpp panels/columns/CategoryAnalysisTab.cpp
panels/columns/DateRangeEdit.cpp panels/columns/DateRangeEdit.cpp
panels/columns/ColumnDrawer.cpp panels/columns/ColumnDrawer.cpp
panels/columns/SectionIconBar.cpp
panels/AnomalyTablePanel.cpp panels/AnomalyTablePanel.cpp
panels/LoadingOverlay.cpp panels/LoadingOverlay.cpp
panels/DatasetDetailPage.cpp panels/DatasetDetailPage.cpp
@ -109,7 +111,6 @@ add_executable(geopro_desktop WIN32
VolumeParamsDialog.cpp VolumeParamsDialog.cpp
VolumePropertiesDialog.cpp VolumePropertiesDialog.cpp
Logging.cpp Logging.cpp
DatasetDimension.cpp
DatasetCategory.cpp DatasetCategory.cpp
VtkViewToolbar.cpp VtkViewToolbar.cpp
AxesSettingsDialog.cpp AxesSettingsDialog.cpp

View File

@ -1,21 +1,15 @@
#include "DatasetCategory.hpp" #include "DatasetCategory.hpp"
#include "repo/CategoryDescriptor.hpp"
namespace geopro::app { namespace geopro::app {
CategoryBuckets splitByCategory(const std::vector<geopro::data::DsRow>& rows) { CategoryBuckets splitByCategory(const std::vector<geopro::data::DsRow>& rows) {
const auto& cfg = categoryConfigs(); const auto& cat = geopro::data::categoryCatalog();
CategoryBuckets b; CategoryBuckets b;
b.segments.resize(cfg.size()); b.segments.resize(cat.size());
for (const auto& r : rows) { for (const auto& r : rows)
int hit = -1; for (std::size_t i = 0; i < cat.size(); ++i)
// 先按 ddCode三维体/切片)——它们无 dsTypeCode来自 Api3dRepository mock 行)。 if (cat[i].classify && cat[i].classify(r)) { b.segments[i].push_back(r); break; }
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<int>(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<int>(i);
if (hit >= 0) b.segments[static_cast<std::size_t>(hit)].push_back(r);
}
return b; return b;
} }

View File

@ -1,15 +1,14 @@
#pragma once #pragma once
#include <vector> #include <vector>
#include "repo/CategoryConfig.hpp"
#include "repo/RepoTypes.hpp" #include "repo/RepoTypes.hpp"
namespace geopro::app { namespace geopro::app {
struct CategoryBuckets { struct CategoryBuckets {
std::vector<std::vector<geopro::data::DsRow>> segments; // 与 categoryConfigs() 同序同长 std::vector<std::vector<geopro::data::DsRow>> segments; // 与 categoryCatalog() 同序同长
}; };
// 按 CategoryConfig 把 ds 分入大类段:先判 ddCode 白名单(三维体/切片),否则按 dsTypeCode 匹配 // 按 categoryCatalog() 把 ds 分入大类段:遍历目录,命中首个 classify(row)==true 的描述符即归入该段
// 不在表内的丢弃(接地电阻/原始数据/白化/坐标等)。保留原顺序。 // 不在表内的丢弃(接地电阻/原始数据/白化/坐标等)。保留原顺序。
CategoryBuckets splitByCategory(const std::vector<geopro::data::DsRow>& rows); CategoryBuckets splitByCategory(const std::vector<geopro::data::DsRow>& rows);

View File

@ -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<geopro::data::DsRow>& 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

View File

@ -1,17 +0,0 @@
#pragma once
#include <vector>
#include "repo/RepoTypes.hpp"
namespace geopro::app {
struct DimBuckets {
std::vector<geopro::data::DsRow> dim3D;
std::vector<geopro::data::DsRow> dim2D;
std::vector<geopro::data::DsRow> analysis;
};
// 按 ddCode 把 ds 分流到 三维数据集 / 二维数据集 / 三维分析 三栏。
// Other 维度不入任何栏(保留原顺序)。
DimBuckets splitByDimension(const std::vector<geopro::data::DsRow>& rows);
} // namespace geopro::app

View File

@ -52,11 +52,9 @@ constexpr double kRangeCeil = 30000.0; // 最多 30km(防远裁剪面失控)
constexpr int kMaxConcurrent = 12; // 瓦片请求最大并发(天地图 8 子域+Mapbox适度提高吞吐) constexpr int kMaxConcurrent = 12; // 瓦片请求最大并发(天地图 8 子域+Mapbox适度提高吞吐)
constexpr int kMinZoom = 3; constexpr int kMinZoom = 3;
constexpr int kMaxZoom = 18; constexpr int kMaxZoom = 18;
constexpr double kGroundZ = 0.0; // 底图置于 z=0 地面参考(剖面深度向下为负,落其下)
constexpr double kZEps = 0.02; // 每层级 Z 微偏移:高层级压上面,避免共面瓦片 z-fighting constexpr double kZEps = 0.02; // 每层级 Z 微偏移:高层级压上面,避免共面瓦片 z-fighting
constexpr int kHardCap = 400; // 瓦片硬上限:超过则即便未落地也强制清理,兜底内存 constexpr int kHardCap = 400; // 瓦片硬上限:超过则即便未落地也强制清理,兜底内存
constexpr double kPi = 3.14159265358979323846; constexpr double kPi = 3.14159265358979323846;
constexpr double kTerrainOpacity = 0.55; // 地形半透明:地下剖面可从任意角度透过地面看到(不再被遮挡)
// 地面起伏Mapbox terrain-RGB DEM 瓦片(原版 web 同款源,全球 CDN比 AWS Terrarium 快)。 // 地面起伏Mapbox terrain-RGB DEM 瓦片(原版 web 同款源,全球 CDN比 AWS Terrarium 快)。
// 公式 elev(米) = -10000 + (R*65536 + G*256 + B)*0.1。数据到 z15更高层级取祖先块。 // 公式 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, TileBasemap::TileBasemap(geopro::render::Scene& scene, vtkRenderWindow* rw,
std::shared_ptr<geopro::core::GeoLocalFrame> frame, QObject* parent) std::shared_ptr<geopro::core::GeoLocalFrame> frame, QObject* parent,
: QObject(parent), scene_(scene), rw_(rw), frame_(std::move(frame)) {} double groundZ)
: QObject(parent), scene_(scene), rw_(rw), frame_(std::move(frame)), groundZ_(groundZ) {}
void TileBasemap::requestRender() { void TileBasemap::requestRender() {
// 合并渲染:同一事件循环轮次内的多次请求只渲染一帧(标准做法,避免逐瓦片重复 Render 卡顿)。 // 合并渲染:同一事件循环轮次内的多次请求只渲染一帧(标准做法,避免逐瓦片重复 Render 卡顿)。
@ -140,6 +139,10 @@ void TileBasemap::requestRender() {
} }
TileBasemap::~TileBasemap() { 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_); if (styleObs_ && observer_) styleObs_->RemoveObserver(observer_);
} }
@ -208,6 +211,23 @@ void TileBasemap::setVerticalExaggeration(double ve) {
if (kind_ != Hidden) show(kind_); // 重建地形(高程×新VE),与剖面 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<long long>& out, int& count) { void TileBasemap::refineTile(int z, int x, int y, std::set<long long>& out, int& count) {
if (count >= kMaxLeaves) { out.insert(tileKey(z, x, y)); return; } // 安全上限:停止细分 if (count >= kMaxLeaves) { out.insert(tileKey(z, x, y)); return; } // 安全上限:停止细分
const int n = 1 << z; 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<vtkActor> actor) { void TileBasemap::placeActor(long long key, vtkSmartPointer<vtkActor> actor) {
if (!actor) return; if (!actor) return;
actor->SetPosition(0.0, 0.0, groundZ_); // 平面高程经 position 施加:几何建于相对 z此处抬到当前 groundZ_
scene_.addActor(actor); scene_.addActor(actor);
placed_[key] = actor; placed_[key] = actor;
} }
@ -439,7 +460,7 @@ vtkSmartPointer<vtkActor> TileBasemap::buildFlat(int z, int x, int y,
const auto sw = frame_->toLocal(b.south, b.west); const auto sw = frame_->toLocal(b.south, b.west);
const auto se = frame_->toLocal(b.south, b.east); const auto se = frame_->toLocal(b.south, b.east);
const auto nw = frame_->toLocal(b.north, b.west); 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 自动 tcoordorigin=SW→u 西0东1、v 南0北1与翻转后纹理对齐 // PlaneSource 自动 tcoordorigin=SW→u 西0东1、v 南0北1与翻转后纹理对齐
vtkNew<vtkPlaneSource> plane; vtkNew<vtkPlaneSource> plane;
@ -452,7 +473,7 @@ vtkSmartPointer<vtkActor> TileBasemap::buildFlat(int z, int x, int y,
actor->SetMapper(mapper); actor->SetMapper(mapper);
actor->SetTexture(tex); actor->SetTexture(tex);
actor->GetProperty()->LightingOff(); // 底图不受场景光照 actor->GetProperty()->LightingOff(); // 底图不受场景光照
actor->GetProperty()->SetOpacity(kTerrainOpacity); // 半透明:不遮挡地下剖面 actor->GetProperty()->SetOpacity(opacity_); // 半透明:不遮挡地下剖面
// 注意UseBounds 保持默认 true → 参与相机裁剪面计算,否则底图会被裁剪面"蒙版"切掉。 // 注意UseBounds 保持默认 true → 参与相机裁剪面计算,否则底图会被裁剪面"蒙版"切掉。
// 坐标轴/取景不被底图撑大,由 VtkSceneView 改用"数据自身包围盒"解决(非靠 UseBounds=false // 坐标轴/取景不被底图撑大,由 VtkSceneView 改用"数据自身包围盒"解决(非靠 UseBounds=false
return actor; return actor;
@ -528,7 +549,7 @@ vtkSmartPointer<vtkActor> TileBasemap::buildWarped(int sz, int sx, int sy, int d
const auto sw = frame_->toLocal(sb.south, sb.west); const auto sw = frame_->toLocal(sb.south, sb.west);
const auto se = frame_->toLocal(sb.south, sb.east); const auto se = frame_->toLocal(sb.south, sb.east);
const auto nw = frame_->toLocal(sb.north, sb.west); 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。 // PlaneSource(等距圆柱下平面插值即正确 x/y) + 自动 tcoord再按各点真实经纬采 DEM 位移 Z。
vtkNew<vtkPlaneSource> plane; vtkNew<vtkPlaneSource> plane;
@ -566,7 +587,7 @@ vtkSmartPointer<vtkActor> TileBasemap::buildWarped(int sz, int sx, int sy, int d
actor->SetMapper(mapper); actor->SetMapper(mapper);
actor->SetTexture(tex); actor->SetTexture(tex);
actor->GetProperty()->LightingOff(); actor->GetProperty()->LightingOff();
actor->GetProperty()->SetOpacity(kTerrainOpacity); // 半透明:不遮挡地下剖面 actor->GetProperty()->SetOpacity(opacity_); // 半透明:不遮挡地下剖面
return actor; // UseBounds 默认 true参与裁剪面避免被"蒙版"切掉 return actor; // UseBounds 默认 true参与裁剪面避免被"蒙版"切掉
} }

View File

@ -33,13 +33,16 @@ class TileBasemap : public QObject {
public: public:
enum Kind { Street = 0, Satellite = 1, Hidden = 2 }; enum Kind { Street = 0, Satellite = 1, Hidden = 2 };
TileBasemap(geopro::render::Scene& scene, vtkRenderWindow* rw, TileBasemap(geopro::render::Scene& scene, vtkRenderWindow* rw,
std::shared_ptr<geopro::core::GeoLocalFrame> frame, QObject* parent = nullptr); std::shared_ptr<geopro::core::GeoLocalFrame> frame, QObject* parent = nullptr,
double groundZ = 0.0);
~TileBasemap() override; ~TileBasemap() override;
void show(Kind kind); // 显示某底图Hidden 等同 hide记住类型供 LOD 刷新复用 void show(Kind kind); // 显示某底图Hidden 等同 hide记住类型供 LOD 刷新复用
void hide(); // 移除全部瓦片 void hide(); // 移除全部瓦片
void refresh(); // 按当前相机重算层级+覆盖,增量更新瓦片(交互结束回调) void refresh(); // 按当前相机重算层级+覆盖,增量更新瓦片(交互结束回调)
void setVerticalExaggeration(double ve); // 地形垂向夸张(须与剖面 VE 一致才对齐) void setVerticalExaggeration(double ve); // 地形垂向夸张(须与剖面 VE 一致才对齐)
void setOpacity(double o); // 底图半透明度[0,1],供渲染工具栏底图弹窗调节
void setGroundZ(double z); // 直接平移底图平面 z(拖 z 值滑块):改所有已贴瓦片 actor 的 position无重铺
// 数据半径提供者:刷新时查询当前所有勾选剖面的合并范围(半径,米),据此动态定底图最大范围。 // 数据半径提供者:刷新时查询当前所有勾选剖面的合并范围(半径,米),据此动态定底图最大范围。
void setDataRadiusProvider(std::function<double()> fn) { dataRadiusProvider_ = std::move(fn); } void setDataRadiusProvider(std::function<double()> fn) { dataRadiusProvider_ = std::move(fn); }
@ -75,7 +78,9 @@ private:
std::set<long long> inFlight_; // 在途瓦片(续到起伏/平面最终落地) std::set<long long> inFlight_; // 在途瓦片(续到起伏/平面最终落地)
std::map<long long, QImage> demCache_; // DEM 块缓存(key=DEMz/x/y),跨隐藏/重选复用 std::map<long long, QImage> demCache_; // DEM 块缓存(key=DEMz/x/y),跨隐藏/重选复用
std::map<long long, vtkSmartPointer<vtkTexture>> texCache_; // 影像纹理缓存,重选/缩放回看免重拉 std::map<long long, vtkSmartPointer<vtkTexture>> texCache_; // 影像纹理缓存,重选/缩放回看免重拉
double groundZ_ = 0.0; // 底图地面参考 z(per-instance)3D 底图=02D 平面底图=各类型平面高程
double ve_ = 1.0; // 地形垂向夸张(与剖面 verticalExaggeration 一致才对齐) double ve_ = 1.0; // 地形垂向夸张(与剖面 verticalExaggeration 一致才对齐)
double opacity_ = 0.5; // 底图半透明:地下剖面可从任意角度透过地面看到(不再被遮挡)
double maxTileDist_ = 2000.0; // 底图最大距离(米),每次刷新按剖面范围动态算 double maxTileDist_ = 2000.0; // 底图最大距离(米),每次刷新按剖面范围动态算
std::function<double()> dataRadiusProvider_; // 返回当前勾选剖面合并范围的半径 std::function<double()> dataRadiusProvider_; // 返回当前勾选剖面合并范围的半径
// 卫星层「学习到的」最大可用层级:天地图卫星影像各区域覆盖深度不同(内地到z18, 台湾等仅到z16), // 卫星层「学习到的」最大可用层级:天地图卫星影像各区域覆盖深度不同(内地到z18, 台湾等仅到z16),

View File

@ -0,0 +1,37 @@
#pragma once
#include <functional>
#include <memory>
#include <utility>
#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<geopro::core::GeoLocalFrame> frame, double groundZ,
std::function<double()> 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

View File

@ -129,14 +129,6 @@ QMenu* buildToolsMenu(QWidget* p)
return m; return m;
} }
QMenu* buildDeviceMenu(QWidget* p)
{
auto* m = new QMenu(QStringLiteral("设备"), p);
m->addAction(QStringLiteral("连接设备"));
m->addAction(QStringLiteral("设备管理"));
return m;
}
} // namespace } // namespace
TopBar::TopBar(QWidget* parent) : QWidget(parent) { 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, buildViewMenu()));
lay->addWidget(makeMenuButton(this, buildProjectMenu())); lay->addWidget(makeMenuButton(this, buildProjectMenu()));
lay->addWidget(makeMenuButton(this, buildToolsMenu(this))); lay->addWidget(makeMenuButton(this, buildToolsMenu(this)));
lay->addWidget(makeMenuButton(this, buildDeviceMenu(this))); lay->addWidget(makeMenuButton(this, buildDeviceMenu()));
lay->addStretch(); lay->addStretch();
@ -333,6 +325,21 @@ QMenu* TopBar::buildProjectMenu() {
return m; 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) { bool TopBar::eventFilter(QObject* obj, QEvent* event) {
if (obj == userRow_ && event->type() == QEvent::MouseButtonRelease) { if (obj == userRow_ && event->type() == QEvent::MouseButtonRelease) {
if (userMenu_) if (userMenu_)

View File

@ -32,10 +32,13 @@ signals:
// 项目管理菜单中「直接嵌入」的 web 页被点击title=窗口标题target=嵌入页 target 路径。 // 项目管理菜单中「直接嵌入」的 web 页被点击title=窗口标题target=嵌入页 target 路径。
void webPageRequested(const QString& title, const QString& target); void webPageRequested(const QString& title, const QString& target);
void analysisViewRequested(); // 视图菜单「分析视图」→ 中央区切回默认工作台 void analysisViewRequested(); // 视图菜单「分析视图」→ 中央区切回默认工作台
// 设备菜单「导入雷达测线」(后端未就绪的过渡测试入口)false=规范化(.head/.data)true=Impulse(.iprb)。
void radarImportRequested(bool impulse);
private: private:
QMenu* buildViewMenu(); // 视图菜单(成员:「分析视图」需 emit 信号) QMenu* buildViewMenu(); // 视图菜单(成员:「分析视图」需 emit 信号)
QMenu* buildProjectMenu(); // 项目管理菜单成员webview 叶子项需 emit 信号) QMenu* buildProjectMenu(); // 项目管理菜单成员webview 叶子项需 emit 信号)
QMenu* buildDeviceMenu(); // 设备菜单(成员:「导入雷达测线」子项需 emit 信号)
QToolButton* wsBtn_ = nullptr; QToolButton* wsBtn_ = nullptr;
QToolButton* projBtn_ = nullptr; QToolButton* projBtn_ = nullptr;

View File

@ -1,6 +1,7 @@
#include "VtkSceneView.hpp" #include "VtkSceneView.hpp"
#include <algorithm> #include <algorithm>
#include <array>
#include <cmath> #include <cmath>
#include <memory> #include <memory>
#include <utility> #include <utility>
@ -9,17 +10,26 @@
#include <QString> #include <QString>
#include <vtkActor.h> #include <vtkActor.h>
#include <vtkBillboardTextActor3D.h>
#include <vtkCallbackCommand.h>
#include <vtkCamera.h>
#include <vtkCommand.h>
#include <vtkProperty.h> #include <vtkProperty.h>
#include <vtkBoundingBox.h> #include <vtkBoundingBox.h>
#include <vtkCellPicker.h>
#include <vtkCubeAxesActor.h> #include <vtkCubeAxesActor.h>
#include <vtkLineSource.h>
#include <vtkNew.h> #include <vtkNew.h>
#include <vtkPolyDataMapper.h>
#include <vtkProp.h> #include <vtkProp.h>
#include <vtkPropPicker.h>
#include <vtkTextProperty.h>
#include <vtkPiecewiseFunction.h> #include <vtkPiecewiseFunction.h>
#include <vtkColorTransferFunction.h> #include <vtkColorTransferFunction.h>
#include <vtkGPUVolumeRayCastMapper.h> #include <vtkGPUVolumeRayCastMapper.h>
#include <vtkRenderWindow.h> #include <vtkRenderWindow.h>
#include <vtkRenderWindowInteractor.h>
#include <vtkRenderer.h> #include <vtkRenderer.h>
#include <vtkSphereSource.h>
#include <vtkVolume.h> #include <vtkVolume.h>
#include <vtkVolumeProperty.h> #include <vtkVolumeProperty.h>
@ -77,6 +87,25 @@ VtkSceneView::VtkSceneView(geopro::render::Scene& scene, vtkRenderWindow* render
// 近裁剪容差调小:场景含近处剖面 + 远处底图(几十km),默认容差会把近裁剪面随远面推出去、 // 近裁剪容差调小:场景含近处剖面 + 远处底图(几十km),默认容差会把近裁剪面随远面推出去、
// 切掉离相机近的剖面。调小后近面可贴近,剖面不被切(代价:远处深度精度略降,不可见层无所谓)。 // 切掉离相机近的剖面。调小后近面可贴近,剖面不被切(代价:远处深度精度略降,不可见层无所谓)。
scene_.renderer()->SetNearClippingPlaneTolerance(1e-5); 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<vtkSmartPointer<vtkProp>>& props) { void VtkSceneView::removeProps(std::vector<vtkSmartPointer<vtkProp>>& props) {
@ -110,8 +139,7 @@ void VtkSceneView::clear() {
// 只移除数据 prop按 ds 跟踪)+ 杂项(地形/测线)+ 坐标轴;不动底图(TileBasemap 自管)→ 重建不丢图。 // 只移除数据 prop按 ds 跟踪)+ 杂项(地形/测线)+ 坐标轴;不动底图(TileBasemap 自管)→ 重建不丢图。
for (auto& kv : dsProps_) removeProps(kv.second); for (auto& kv : dsProps_) removeProps(kv.second);
dsProps_.clear(); dsProps_.clear();
mapLineDs_.clear(); // 2D 足迹维度记录随数据图元一并清(模式标志保留) mapLineDs_.clear(); // 2D 足迹归属记录随数据图元一并清
selectedMapLines_.clear(); // 选中态随图元清(actor 已销毁)Z 偏移 mapLineZOffset_ 保留→重建后复位高度
removeProps(miscProps_); removeProps(miscProps_);
clearAnomalies(); // 异常 actor 随清场一并移除 clearAnomalies(); // 异常 actor 随清场一并移除
if (currentAxes_) { if (currentAxes_) {
@ -123,6 +151,7 @@ void VtkSceneView::clear() {
volumeOwnerDs_.clear(); volumeOwnerDs_.clear();
volumes_.clear(); // 多体并发:清场移除所有体 image volumes_.clear(); // 多体并发:清场移除所有体 image
frameAnchoredToData_ = false; // 新一轮选择重新按其首个真实剖面重锚原点 frameAnchoredToData_ = false; // 新一轮选择重新按其首个真实剖面重锚原点
useFittedAxes_ = false; // 清场:贴合轴复位为全场景轴(选中随数据一并失效,防残留旧盒)
if (onVolumeChanged) onVolumeChanged(); 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_); auto curtain = geopro::render::buildCurtain(grid, cs, *frame_);
if (curtain) { if (curtain) {
curtain->SetScale(1.0, 1.0, verticalExaggeration_); // 纵向夸张成墙 curtain->SetScale(1.0, 1.0, verticalExaggeration_); // 纵向夸张成墙
curtain->SetVisibility(analysisMode2D_ ? 0 : 1); // 帘面=3D内容二维分析下隐藏
scene_.addActor(curtain); scene_.addActor(curtain);
dsProps_[dsId].push_back(curtain); dsProps_[dsId].push_back(curtain);
} }
@ -206,7 +234,6 @@ void VtkSceneView::addVolume(const std::string& dsId, const geopro::data::Volume
// 体 actor 不参与拾取切片选中靠点中切片平面widget 交互/拾取)。否则点击落到体内部时 // 体 actor 不参与拾取切片选中靠点中切片平面widget 交互/拾取)。否则点击落到体内部时
// picker 命中体、worldPoint 落体内 → nearestSlice 按平面距离选错切片(用户 ④ 串选)。 // picker 命中体、worldPoint 落体内 → nearestSlice 按平面距离选错切片(用户 ④ 串选)。
volume->PickableOff(); volume->PickableOff();
volume->SetVisibility(analysisMode2D_ ? 0 : 1); // 体=3D内容二维分析下隐藏
scene_.addViewProp(volume); scene_.addViewProp(volume);
dsProps_[dsId].push_back(volume); dsProps_[dsId].push_back(volume);
currentVolumeImage_ = image; 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); auto iso = geopro::render::buildIsosurface(image, cs, vol.vmin, vol.vmax, isoVal);
if (iso) { if (iso) {
iso->PickableOff(); // 不参与拾取(同体 actor避免串选 iso->PickableOff(); // 不参与拾取(同体 actor避免串选
iso->SetVisibility(analysisMode2D_ ? 0 : 1); // 3D 内容:二维分析下隐藏
scene_.addActor(iso); scene_.addActor(iso);
dsProps_[dsId].push_back(iso); dsProps_[dsId].push_back(iso);
} }
@ -258,17 +284,29 @@ void VtkSceneView::addMapLine(const std::string& dsId, const geopro::data::MapLi
// worldZ 已是最终世界高程(含摆放语义),不再施加 VE足迹是水平线非随深度的竖直图元 // worldZ 已是最终世界高程(含摆放语义),不再施加 VE足迹是水平线非随深度的竖直图元
// 足迹可能是首个(且唯一)带经纬的数据 → 与帘面同样重锚原点,否则按样本默认原点投到数百公里外不可见。 // 足迹可能是首个(且唯一)带经纬的数据 → 与帘面同样重锚原点,否则按样本默认原点投到数百公里外不可见。
anchorFrameIfNeeded(line.lat, line.lon, static_cast<int>(line.lat.size())); anchorFrameIfNeeded(line.lat, line.lon, static_cast<int>(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) { if (actor) {
actor->SetVisibility(analysisMode2D_ ? 1 : 0); // 足迹=2D内容仅二维分析下显示 actor->SetPosition(0.0, 0.0, worldZ);
auto off = mapLineZOffset_.find(dsId); // B 期:复用持久 Z 偏移(全量重建后仍在该高度)
if (off != mapLineZOffset_.end()) actor->AddPosition(0.0, 0.0, off->second);
scene_.addActor(actor); scene_.addActor(actor);
dsProps_[dsId].push_back(actor); dsProps_[dsId].push_back(actor);
mapLineDs_.insert(dsId); // 记录此 ds 为 2D 足迹(切 tab 按维度翻可见) mapLineDs_.insert(dsId); // 记录此 ds 为 2D 足迹(供足迹归属识别)
} }
} }
void VtkSceneView::setMapLinesZ(const std::vector<std::string>& 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) { void VtkSceneView::addTerrain(const geopro::data::TerrainPaths& paths) {
auto terrain = geopro::render::buildTerrain(paths.demPath, paths.imagePath, *frame_, zRefElev_, auto terrain = geopro::render::buildTerrain(paths.demPath, paths.imagePath, *frame_, zRefElev_,
verticalExaggeration_); verticalExaggeration_);
@ -417,135 +455,315 @@ void VtkSceneView::fitView() {
if (onCameraChanged) onCameraChanged(); // 取景后 → 底图按新视锥重算覆盖(治首帧部分瓦片不出) if (onCameraChanged) onCameraChanged(); // 取景后 → 底图按新视锥重算覆盖(治首帧部分瓦片不出)
} }
void VtkSceneView::setAnalysisMode2D(bool is2D) { bool VtkSceneView::datasetBounds(const std::vector<std::string>& dsIds, double outB[6]) const {
if (is2D == analysisMode2D_) return; // 幂等:同模式重复切不做事 // computeDataBounds 的按 dsId 版:只并集给定 dsIds 的已渲染 actor 包围盒(同样仅计可见 prop
analysisMode2D_ = is2D; vtkBoundingBox bb;
if (!is2D) clearMapLineSelection(); // 离开二维分析:清足迹选中(三维下不可拖 Z)Z 偏移仍持久 for (const auto& id : dsIds) {
auto it = dsProps_.find(id);
// ① 按维度翻可见标志(不清空、不重建→切换瞬时)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<std::string> VtkSceneView::selectedMapLines() const {
return std::vector<std::string>(selectedMapLines_.begin(), selectedMapLines_.end());
}
void VtkSceneView::setSelectedMapLines(const std::vector<std::string>& 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<vtkCellPicker> 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);
if (it == dsProps_.end()) continue; if (it == dsProps_.end()) continue;
for (auto& p : it->second) { for (const auto& p : it->second)
auto* a = vtkActor::SafeDownCast(p); if (p && p->GetVisibility()) { if (double* b = p->GetBounds()) bb.AddBounds(b); }
if (a) a->AddPosition(0.0, 0.0, worldDz); // 仅改 Z锁 XY
} }
} if (!bb.IsValid()) return false;
if (scene_.renderer()) scene_.renderer()->ResetCameraClippingRange(); // Z 抬升后防被裁剪面切 bb.GetBounds(outB);
if (renderWindow_) renderWindow_->Render(); return true;
} }
double VtkSceneView::selectedMapLineZ() const { void VtkSceneView::fitToBounds(const double b[6]) {
if (selectedMapLines_.empty()) return 0.0; if (!scene_.renderer()) return;
// 代表性 Z = 任一选中足迹 actor 的包围盒中心 Z(含 placement worldZ + 已累计偏移)。 scene_.renderer()->ResetCamera(b); // 保持朝向,仅重定位+缩放到该盒(区别于 fitView 的全场景)
auto it = dsProps_.find(*selectedMapLines_.begin()); scene_.renderer()->ResetCameraClippingRange(); // 裁剪面含底图 → 不被"蒙版"切掉
if (it == dsProps_.end()) return 0.0; if (renderWindow_) renderWindow_->Render();
for (const auto& p : it->second) if (onCameraChanged) onCameraChanged(); // 相机变了 → 底图按新视锥重算覆盖
if (p) { if (double* b = p->GetBounds()) return 0.5 * (b[4] + b[5]); } }
return 0.0;
void VtkSceneView::orbitToAxis(geopro::controller::ViewDir dir, const double pivot[3]) {
auto* r = scene_.renderer();
if (!r) return;
auto* c = r->GetActiveCamera();
// 保留当前缩放:取现距离 d=|camfocal|,绕 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<vtkRenderer>::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<double, 3> kColX = {0.90, 0.30, 0.36};
const std::array<double, 3> kColY = {0.55, 0.78, 0.33};
const std::array<double, 3> kColZ = {0.28, 0.45, 0.90};
// 三根过原点的轴线仅连到【正向】球X=红 / Y=绿 / Z=蓝平涂纯色、细、不可拾取spec §5
struct AxisLine { double to[3]; std::array<double, 3> 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<vtkLineSource> src;
src->SetPoint1(0.0, 0.0, 0.0);
src->SetPoint2(ln.to[0], ln.to[1], ln.to[2]);
vtkNew<vtkPolyDataMapper> mapper;
mapper->SetInputConnection(src->GetOutputPort());
vtkNew<vtkActor> 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<double, 3> 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<double, 3> col =
s.positive ? s.base
: std::array<double, 3>{s.base[0] * 0.42, s.base[1] * 0.42, s.base[2] * 0.42};
vtkNew<vtkSphereSource> sphere;
sphere->SetRadius(radius);
sphere->SetThetaResolution(48); // 高分辨率 → 轮廓平滑(平涂下尤重要)
sphere->SetPhiResolution(48);
sphere->SetCenter(s.pos[0], s.pos[1], s.pos[2]);
vtkNew<vtkPolyDataMapper> mapper;
mapper->SetInputConnection(sphere->GetOutputPort());
auto actor = vtkSmartPointer<vtkActor>::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<vtkBillboardTextActor3D>::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<vtkPropPicker>::New();
// 左键高优先级(1.0)观察者:先于交互样式(0.0),命中方向球 → orbit + abort 消费(阻止相机旋转/拾取)。
gnomonClickCmd_ = vtkSmartPointer<vtkCallbackCommand>::New();
gnomonClickCmd_->SetClientData(this);
gnomonClickCmd_->SetCallback([](vtkObject*, unsigned long, void* client, void*) {
static_cast<VtkSceneView*>(client)->handleGnomonClick();
});
gnomonClickTag_ = iren->AddObserver(vtkCommand::LeftButtonPressEvent, gnomonClickCmd_, 1.0);
// 鼠标移动高优先级观察者:仅角落内拾取做 hover 高亮,永不 abort → 不阻塞场景旋转/平移/切片交互。
gnomonHoverCmd_ = vtkSmartPointer<vtkCallbackCommand>::New();
gnomonHoverCmd_->SetClientData(this);
gnomonHoverCmd_->SetCallback([](vtkObject*, unsigned long, void* client, void*) {
static_cast<VtkSceneView*>(client)->handleGnomonHover();
});
gnomonHoverTag_ = iren->AddObserver(vtkCommand::MouseMoveEvent, gnomonHoverCmd_, 1.0);
// 相机同步:观察主相机 ModifiedEvent每次朝向变化把 gizmo 相机镜像到同朝向 → gizmo 随场景转。
gnomonCamCmd_ = vtkSmartPointer<vtkCallbackCommand>::New();
gnomonCamCmd_->SetClientData(this);
gnomonCamCmd_->SetCallback([](vtkObject*, unsigned long, void* client, void*) {
static_cast<VtkSceneView*>(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-upgizmo 相机置于 -dir*dist、焦点在原点 → 与主相机同朝向看向 gizmo。
double dir[3];
mainCam->GetDirectionOfProjection(dir); // 已归一化(FP),指向场景内(背离相机)
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<double>(ex) / sz[0];
const double fy = static_cast<double>(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<double, 3>& 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<double>(ex) / sz[0];
const double fy = static_cast<double>(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() { void VtkSceneView::rebuildAxes() {
@ -555,13 +773,15 @@ void VtkSceneView::rebuildAxes() {
scene_.renderer()->RemoveViewProp(currentAxes_); scene_.renderer()->RemoveViewProp(currentAxes_);
currentAxes_ = nullptr; currentAxes_ = nullptr;
} }
// 二维分析无立体坐标轴:任何渲染路径(全量/增量/切模式)走到此都不重建坐标轴,
// 保证切到二维分析后坐标轴消失、且后续增量渲染不把它带回来。
if (analysisMode2D_) return;
// 坐标轴随数据包围盒重建:仅按数据图元算 bounds(不含底图,否则被~公里级底图撑大) // 坐标轴随数据包围盒重建:仅按数据图元算 bounds(不含底图,否则被~公里级底图撑大)
// 再造 vtkCubeAxesActor 入场。None 模式或无数据 → buildAxes 返回 nullptr场景无坐标轴。 // 再造 vtkCubeAxesActor 入场。None 模式或无数据 → buildAxes 返回 nullptr场景无坐标轴。
// 贴合态(useFittedAxes_):改用选中子树盒 fittedBounds_只框该子树而非全场景spec §3.2)。
double bounds[6]; 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; geopro::render::AxesOptions opts;
opts.mode = toRenderMode(axesMode_); opts.mode = toRenderMode(axesMode_);
opts.unit = toRenderUnit(axesUnit_); 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) { void VtkSceneView::render(bool is2D, bool resetCamera) {
ensureGnomon(); // 构造时交互器未就绪则于此补装(幂等)
// 视图区背景永远深色(规范 §0.5:不随明暗切换),让色阶数据更突出。 // 视图区背景永远深色(规范 §0.5:不随明暗切换),让色阶数据更突出。
double bgR, bgG, bgB; double bgR, bgG, bgB;
geopro::app::vtkBackground(bgR, bgG, bgB); geopro::app::vtkBackground(bgR, bgG, bgB);
@ -588,12 +824,9 @@ void VtkSceneView::render(bool is2D, bool resetCamera) {
// 坐标轴仅三维视图显示2D 俯视测线不需要立体坐标轴)。 // 坐标轴仅三维视图显示2D 俯视测线不需要立体坐标轴)。
if (!is2D) rebuildAxes(); if (!is2D) rebuildAxes();
// 相机预设(朝向)只在取景时应用——保留相机的重建(改放大系数)不重设朝向,否则也会跳视角。 // 相机预设(朝向)只在取景时应用——保留相机的重建(改放大系数)不重设朝向,否则也会跳视角。
// 朝向优先看二维分析模式(本期 A):处二维分析→近俯视;否则按 ViewMode(旧 Map2D 正俯视/三维自由)。 // 朝向按 is2D俯视(Map2D)/三维自由透视。
// 这样 VE 改/项目切等全量重建在二维分析下仍保持近俯视,不跳回三维自由视角。
if (resetCamera) { if (resetCamera) {
if (analysisMode2D_) if (is2D)
geopro::render::applyNearTop2D(scene_.renderer());
else if (is2D)
geopro::render::applyTop2D(scene_.renderer()); geopro::render::applyTop2D(scene_.renderer());
else else
geopro::render::applyFree3D(scene_.renderer()); geopro::render::applyFree3D(scene_.renderer());
@ -609,6 +842,7 @@ void VtkSceneView::render(bool is2D, bool resetCamera) {
} }
void VtkSceneView::renderIncremental() { void VtkSceneView::renderIncremental() {
ensureGnomon(); // 幂等:交互器就绪后补装角落方向标
// 增量渲染:仅按新包围盒重建坐标轴并提交,不动相机(勾选/取消时视角不跳)。 // 增量渲染:仅按新包围盒重建坐标轴并提交,不动相机(勾选/取消时视角不跳)。
rebuildAxes(); rebuildAxes();
scene_.renderer()->ResetCameraClippingRange(); // 数据/底图变化后扩裁剪面,防被切 scene_.renderer()->ResetCameraClippingRange(); // 数据/底图变化后扩裁剪面,防被切

View File

@ -1,9 +1,11 @@
#pragma once #pragma once
#include <array>
#include <functional> #include <functional>
#include <map> #include <map>
#include <memory> #include <memory>
#include <set> #include <set>
#include <string> #include <string>
#include <utility>
#include <vector> #include <vector>
#include <vtkCubeAxesActor.h> #include <vtkCubeAxesActor.h>
@ -20,6 +22,10 @@ class vtkRenderWindow;
class vtkProp; class vtkProp;
class vtkActor; class vtkActor;
class vtkVolume; class vtkVolume;
class vtkPropPicker;
class vtkCallbackCommand;
class vtkCamera;
class vtkBillboardTextActor3D;
namespace geopro::app { namespace geopro::app {
@ -32,6 +38,7 @@ public:
// 入参生命周期须覆盖本对象由调用方保证。zRefElev地形 z 基准(测线地表高程)。 // 入参生命周期须覆盖本对象由调用方保证。zRefElev地形 z 基准(测线地表高程)。
VtkSceneView(geopro::render::Scene& scene, vtkRenderWindow* renderWindow, VtkSceneView(geopro::render::Scene& scene, vtkRenderWindow* renderWindow,
std::shared_ptr<geopro::core::GeoLocalFrame> frame, double zRefElev); std::shared_ptr<geopro::core::GeoLocalFrame> frame, double zRefElev);
~VtkSceneView() override; // 摘除 gnomon 左键/相机观察者(clientData=this),移除叠加渲染器
void clear() override; void clear() override;
void setVerticalExaggeration(double ve) override; void setVerticalExaggeration(double ve) override;
@ -46,6 +53,7 @@ public:
const geopro::core::ColorScale& cs) override; const geopro::core::ColorScale& cs) override;
void addMapLine(const std::string& dsId, const geopro::data::MapLine& line, void addMapLine(const std::string& dsId, const geopro::data::MapLine& line,
double worldZ) override; double worldZ) override;
void setMapLinesZ(const std::vector<std::string>& dsIds, double z) override;
void addTerrain(const geopro::data::TerrainPaths& paths) override; void addTerrain(const geopro::data::TerrainPaths& paths) override;
void removeDataset(const std::string& dsId) override; void removeDataset(const std::string& dsId) override;
void addAnomaly(const geopro::core::Anomaly& a) override; void addAnomaly(const geopro::core::Anomaly& a) override;
@ -61,6 +69,24 @@ public:
void applyCameraView(geopro::controller::ViewDir dir) override; void applyCameraView(geopro::controller::ViewDir dir) override;
void zoom(double factor) override; void zoom(double factor) override;
void fitView() override; void fitView() override;
// ── 视图导航基元spec §3.1T1──────────────────────────────────────────────
// 给定 dsIds 的已渲染 actor 世界包围盒并集;无有效返回 false否则填 out=[xmin,xmax,…,zmax]。
bool datasetBounds(const std::vector<std::string>& 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.2T2───────────────────────────────────────────
// 选中某 ds → 用其子树盒 b 显示贴合 cube axes、隐去全场景总览轴立即提交渲染
void showFittedAxes(const double b[6]);
// 取消选中 → 恢复全场景总览轴(现状默认行为,立即提交渲染)。
void showSceneAxes();
// ── 可点击方向标 gnomonspec §3.3T3─────────────────────────────────────────
// 绕【当前坐标轴盒中心】转到 dir 轴、保留当前缩放:支点 = 有选中(useFittedAxes_)→选中子树盒
// fittedBounds_ 中心,否则全场景数据盒 computeDataBounds 中心。无有效数据 → no-op。
// 封装决策 5 的支点规则,调用方只需给方向(角落 gnomon 点击即调此)。
void orbitToCurrentPivot(geopro::controller::ViewDir dir);
void render(bool is2D, bool resetCamera = true) override; void render(bool is2D, bool resetCamera = true) override;
void renderIncremental() override; void renderIncremental() override;
@ -87,13 +113,6 @@ public:
// 相机程序化变化(取景/预设/缩放)后回调,供底图按新视锥重算覆盖(否则首帧部分瓦片要手动微动才出)。 // 相机程序化变化(取景/预设/缩放)后回调,供底图按新视锥重算覆盖(否则首帧部分瓦片要手动微动才出)。
std::function<void()> onCameraChanged; std::function<void()> onCameraChanged;
// ── 二维分析改造 A 期:一场景两相机 ──────────────────────────────────────────
// 切「二维分析」(is2D=true):相机锁近俯视、显 2D 足迹/隐 3D(体/帘面/异常);切回反之。
// 只翻 actor 可见标志(不清空、不重建)→ 切换瞬时、零重插值。地形/底图常驻不动。
// 切片显隐 + 交互锁由 InteractionManager::setMode2D 配合(上层在同一处调两者)。
void setAnalysisMode2D(bool is2D);
bool isAnalysisMode2D() const { return analysisMode2D_; }
// ── B 方案#2沿线位置巡航雷达超长测线────────────────────────────────────── // ── B 方案#2沿线位置巡航雷达超长测线──────────────────────────────────────
// t∈[0,1] 沿数据【最长轴】定位;取景到该位置一段【窗口】(windowFrac=窗口占长轴比例) // t∈[0,1] 沿数据【最长轴】定位;取景到该位置一段【窗口】(windowFrac=窗口占长轴比例)
// 保持当前朝向(ResetCamera 只重定位+缩放、不转向)→ 像滚动读长 radargram。短轴满幅、长轴只取一段。 // 保持当前朝向(ResetCamera 只重定位+缩放、不转向)→ 像滚动读长 radargram。短轴满幅、长轴只取一段。
@ -101,22 +120,6 @@ public:
// 数据包围盒长短轴比(max/min 跨度)。用于判是否细长(雷达)→ 决定沿线滑块显隐。无数据返回 0。 // 数据包围盒长短轴比(max/min 跨度)。用于判是否细长(雷达)→ 决定沿线滑块显隐。无数据返回 0。
double longAxisElongation() const; 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 取当前选中 dsIdsetSelectedMapLines 由列表设置选中
// (高亮,不回调,避免环)。VTK 内拾取改变选中时触发 onMapLineSelectionChanged → 上层同步列表。
std::vector<std::string> selectedMapLines() const;
void setSelectedMapLines(const std::vector<std::string>& dsIds);
std::function<void()> onMapLineSelectionChanged;
private: private:
// 首个带经纬数据(剖面/足迹)到达时把共享 frame 重锚到其 lat/lon 包围盒中心:使数据落在世界原点近旁 // 首个带经纬数据(剖面/足迹)到达时把共享 frame 重锚到其 lat/lon 包围盒中心:使数据落在世界原点近旁
// (否则样本默认原点可能离真实数据数百公里→图元在视锥外、移动视角也找不到)。已锚或无经纬则跳过。 // (否则样本默认原点可能离真实数据数百公里→图元在视锥外、移动视角也找不到)。已锚或无经纬则跳过。
@ -126,6 +129,20 @@ private:
void removeProps(std::vector<vtkSmartPointer<vtkProp>>& props); // 从 renderer 移除并清空 void removeProps(std::vector<vtkSmartPointer<vtkProp>>& props); // 从 renderer 移除并清空
// 仅数据图元(剖面/体素/地形/测线)的包围盒,不含底图 → 坐标轴/取景不被~公里级底图撑大。 // 仅数据图元(剖面/体素/地形/测线)的包围盒,不含底图 → 坐标轴/取景不被~公里级底图撑大。
bool computeDataBounds(double out[6]) const; bool computeDataBounds(double out[6]) const;
// 角落可点击方向标 gnomonT3首次(交互器就绪)时装配【专用叠加渲染器】(图层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: public:
// 当前所有数据图元(剖面等)合并范围的水平半径(米);无数据返回 0。供底图动态定最大范围。 // 当前所有数据图元(剖面等)合并范围的水平半径(米);无数据返回 0。供底图动态定最大范围。
@ -150,6 +167,10 @@ private:
// 当前坐标轴 proprender 可能多次调用 rebuildAxesrebuild 末尾 + 异步回灌), // 当前坐标轴 proprender 可能多次调用 rebuildAxesrebuild 末尾 + 异步回灌),
// 持引用以便重建前移除旧 prop避免叠加评审 HIGH // 持引用以便重建前移除旧 prop避免叠加评审 HIGH
vtkSmartPointer<vtkCubeAxesActor> currentAxes_; vtkSmartPointer<vtkCubeAxesActor> currentAxes_;
// 贴合轴态T2true=坐标轴按 fittedBounds_选中子树盒非全场景数据包围盒选中时冻结该盒
// 取消/清场复位为 false走全场景 computeDataBounds
bool useFittedAxes_ = false;
double fittedBounds_[6] = {0, 0, 0, 0, 0, 0};
// 当前体素 image + 色阶P3 切片附着源);无体素时为空。「当前」=最后添加/活动的体(保存切片/ // 当前体素 image + 色阶P3 切片附着源);无体素时为空。「当前」=最后添加/活动的体(保存切片/
// 创建异常的默认归属)。多体并发下各体 image 另存 volumes_。 // 创建异常的默认归属)。多体并发下各体 image 另存 volumes_。
@ -183,15 +204,32 @@ private:
std::string volumeOwnerDs_; // 当前 currentVolumeImage_ 归属的 ds其被移除时置空切片源 std::string volumeOwnerDs_; // 当前 currentVolumeImage_ 归属的 ds其被移除时置空切片源
std::map<std::string, vtkSmartPointer<vtkActor>> anomalyProps_; // 异常 id → 3D actor std::map<std::string, vtkSmartPointer<vtkActor>> anomalyProps_; // 异常 id → 3D actor
// ── 二维分析改造 A 期 ── // 哪些 dsProps_ 条目是 2D 足迹(addMapLine):供足迹 actor 归属识别(Task E2/F2 用)。
// 哪些 dsProps_ 条目是 2D 足迹(addMapLine):切 tab 按此区分维度翻可见(其余 dsProps_=帘面/体=3D)。
std::set<std::string> mapLineDs_; std::set<std::string> mapLineDs_;
bool analysisMode2D_ = false; // 当前是否处二维分析(默认三维启动在「三维分析」tab)
// B 期:选中的足迹 dsId(Z 拖动目标) + 各足迹累计 Z 偏移(持久,全量重建后 addMapLine 复用)。 // ── 可点击方向标 gnomonT3──────────────────────────────────────────────────
std::set<std::string> selectedMapLines_; // 专用叠加渲染器图层1、固定右下角视口、InteractiveOff、透明背景、无边框 —— 不是 widget
std::map<std::string, double> mapLineZOffset_; // 故无外框、不可拖动/缩放;相机由 syncGnomonCamera 镜像主相机朝向 → gizmo 随场景转。
void applyMapLineSelectionVisual(); // 选中足迹加粗变亮、其余复原(橙 3.0) // gnomonPicker_ 在此渲染器上做硬件拾取(仅方向球可拾取)。
vtkSmartPointer<vtkRenderer> gnomonRenderer_;
vtkSmartPointer<vtkPropPicker> gnomonPicker_;
vtkSmartPointer<vtkCallbackCommand> gnomonClickCmd_; // 左键观察者命令(可条件 SetAbortFlag 消费)
unsigned long gnomonClickTag_ = 0; // 左键观察者句柄(析构时摘除)
vtkSmartPointer<vtkCallbackCommand> gnomonCamCmd_; // 主相机 ModifiedEvent 观察者命令
unsigned long gnomonCamTag_ = 0; // 相机观察者句柄(析构时摘除)
vtkCamera* gnomonObservedCam_ = nullptr; // 被观察的主相机(非拥有;析构摘观察者用)
bool gnomonReady_ = false; // 已装配(幂等 ensureGnomon
// 6 个方向球 actor → ViewDir 映射(拾取命中球 → 该方向。actor 由叠加渲染器持有保活。
std::map<vtkProp*, geopro::controller::ViewDir> gnomonDirs_;
// ── hover 高亮spec §6─────────────────────────────────────────────────────
vtkSmartPointer<vtkCallbackCommand> gnomonHoverCmd_; // 鼠标移动观察者命令(不 abort非阻塞
unsigned long gnomonHoverTag_ = 0; // 移动观察者句柄(析构摘除)
vtkProp* gnomonHovered_ = nullptr; // 当前高亮的方向球裸指针renderer 保活)
std::map<vtkProp*, std::array<double, 3>> gnomonBaseColor_; // 各球本色hover 复原用)
// 正向标签(白字) + 其球心:每次 syncGnomonCamera 把标签推到球前(朝相机)→ 不被球面遮挡。
// raw ptr 非拥有,由叠加渲染器持有保活(与 gnomonDirs_ 同生命周期约定)。
std::vector<std::pair<vtkBillboardTextActor3D*, std::array<double, 3>>> gnomonLabels_;
}; };
} // namespace geopro::app } // namespace geopro::app

View File

@ -3,11 +3,13 @@
#include <QFrame> #include <QFrame>
#include <QHBoxLayout> #include <QHBoxLayout>
#include <QLabel> #include <QLabel>
#include <QMenu>
#include <QPoint> #include <QPoint>
#include <QSize> #include <QSize>
#include <QSlider> #include <QSlider>
#include <QToolButton> #include <QToolButton>
#include <QVBoxLayout> #include <QVBoxLayout>
#include <QWidgetAction>
#include "Glyphs.hpp" #include "Glyphs.hpp"
#include "Theme.hpp" #include "Theme.hpp"
@ -52,9 +54,28 @@ VtkViewToolbar::VtkViewToolbar(QWidget* parent) : QWidget(parent) {
col->addWidget(line); col->addWidget(line);
}; };
// ── 段1设置坐标轴── // ── 段1设置坐标轴/ 底图 ──
connect(iconBtn(Glyph::Gear, QStringLiteral("坐标轴设置")), &QToolButton::clicked, this, connect(iconBtn(Glyph::Gear, QStringLiteral("坐标轴设置")), &QToolButton::clicked, this,
&VtkViewToolbar::axesSettingsRequested); &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(); sep();
// ── 段2快捷视图前/后/上/下/左/右)── // ── 段2快捷视图前/后/上/下/左/右)──
struct V { struct V {
@ -67,7 +88,6 @@ VtkViewToolbar::VtkViewToolbar(QWidget* parent) : QWidget(parent) {
const ViewDir d = v.d; const ViewDir d = v.d;
auto* b = textBtn(QString::fromUtf8(v.t)); auto* b = textBtn(QString::fromUtf8(v.t));
connect(b, &QToolButton::clicked, this, [this, d] { emit viewRequested(d); }); connect(b, &QToolButton::clicked, this, [this, d] { emit viewRequested(d); });
viewDirButtons_.push_back(b); // 二维分析下禁用(会改朝向、破坏近俯视锁定)
} }
sep(); sep();
// ── 段3缩放 / 复位 ──(三维体不透明度已移交色阶「不透明度」,旧「透」滑块退役移除) // ── 段3缩放 / 复位 ──(三维体不透明度已移交色阶「不透明度」,旧「透」滑块退役移除)
@ -89,12 +109,4 @@ VtkViewToolbar::VtkViewToolbar(QWidget* parent) : QWidget(parent) {
adjustSize(); adjustSize();
} }
void VtkViewToolbar::setAnalysisMode2D(bool is2D) {
for (auto* b : viewDirButtons_) {
if (!b) continue;
b->setEnabled(!is2D);
b->setToolTip(is2D ? QStringLiteral("二维分析下不可用(已锁定近俯视)") : QString());
}
}
} // namespace geopro::app } // namespace geopro::app

View File

@ -1,6 +1,5 @@
#pragma once #pragma once
#include <QWidget> #include <QWidget>
#include <vector>
#include "I3dSceneView.hpp" // geopro::controller::ViewDir #include "I3dSceneView.hpp" // geopro::controller::ViewDir
class QToolButton; class QToolButton;
@ -9,27 +8,21 @@ class QLabel;
namespace geopro::app { namespace geopro::app {
// VTK 画布竖排工具条spec §9全局视图控制——设置(坐标轴)/前后上下左右/放大缩小复位。 // VTK 画布竖排工具条spec §9全局视图控制——设置(坐标轴)/底图/前后上下左右/放大缩小复位。
// 仅发信号,不认 VTK由 main 接到场景控制器 // 仅发信号,不认 VTK由 main 接到场景控制器与共享 3D 底图
class VtkViewToolbar : public QWidget { class VtkViewToolbar : public QWidget {
Q_OBJECT Q_OBJECT
public: public:
explicit VtkViewToolbar(QWidget* parent = nullptr); explicit VtkViewToolbar(QWidget* parent = nullptr);
public slots:
// 二维分析激活时禁用不适用的工具6 向快捷视图会改相机朝向→破坏二维近俯视锁定,故二维下禁用;
// 缩放/适配/坐标轴设置(含 VE)仍可用。切回三维恢复。
void setAnalysisMode2D(bool is2D);
signals: signals:
void axesSettingsRequested(); // 设置 → 弹 AxesSettingsDialog void axesSettingsRequested(); // 设置 → 弹 AxesSettingsDialog
void basemapKindChanged(int kind); // 底图类型0 天地图 / 1 无
void basemapOpacityChanged(double o); // 底图透明度0..1
void viewRequested(geopro::controller::ViewDir dir); // 前/后/上/下/左/右 void viewRequested(geopro::controller::ViewDir dir); // 前/后/上/下/左/右
void zoomInRequested(); void zoomInRequested();
void zoomOutRequested(); void zoomOutRequested();
void fitRequested(); // 复位=适配 void fitRequested(); // 复位=适配
private:
std::vector<QToolButton*> viewDirButtons_; // 6 向快捷视图按钮:二维分析下禁用
}; };
} // namespace geopro::app } // namespace geopro::app

View File

@ -93,8 +93,8 @@
#include "ApiClient.hpp" #include "ApiClient.hpp"
#include "AuthService.hpp" #include "AuthService.hpp"
#include "DatasetDimension.hpp"
#include "DatasetCategory.hpp" #include "DatasetCategory.hpp"
#include "repo/CategoryDescriptor.hpp"
#include "Credential.hpp" #include "Credential.hpp"
#include "Glyphs.hpp" #include "Glyphs.hpp"
#include "Logging.hpp" #include "Logging.hpp"
@ -142,6 +142,7 @@
#include "panels/DatasetAttrPanel.hpp" #include "panels/DatasetAttrPanel.hpp"
#include "panels/ObjectExceptionPanel.hpp" #include "panels/ObjectExceptionPanel.hpp"
#include "TileBasemap.hpp" #include "TileBasemap.hpp"
#include "TileBasemapPlaneAdapter.hpp"
#include "panels/columns/ColumnDrawer.hpp" #include "panels/columns/ColumnDrawer.hpp"
#include "panels/columns/CategoryAnalysisTab.hpp" #include "panels/columns/CategoryAnalysisTab.hpp"
#include "panels/columns/CategorySection.hpp" #include "panels/columns/CategorySection.hpp"
@ -149,7 +150,6 @@
#include "AxesSettingsDialog.hpp" #include "AxesSettingsDialog.hpp"
#include "AxesSettingsPanel.hpp" #include "AxesSettingsPanel.hpp"
#include "repo/DatasetFieldDictionary.hpp" #include "repo/DatasetFieldDictionary.hpp"
#include "panels/columns/Column2DDataset.hpp"
#include "CameraPreset.hpp" #include "CameraPreset.hpp"
#include "ColorLutBuilder.hpp" #include "ColorLutBuilder.hpp"
@ -482,27 +482,6 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
// 子视图(表头+左树+画布)并 raise 到顶层;保存阶段显示,完成即撤。复用于其他需要整窗等待的场景。 // 子视图(表头+左树+画布)并 raise 到顶层;保存阶段显示,完成即撤。复用于其他需要整窗等待的场景。
auto* vtkLoading = new geopro::app::LoadingOverlay(centerWidget); 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<std::function<void()>>([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雷达沿线位置滑块超长测线巡航──────────────────────────────────── // ── B 方案#2雷达沿线位置滑块超长测线巡航────────────────────────────────────
// 拖动 → 相机沿数据最长轴 dolly 到该位置的一段窗口(focusAlongLongAxis)。仅细长(雷达)体显示。 // 拖动 → 相机沿数据最长轴 dolly 到该位置的一段窗口(focusAlongLongAxis)。仅细长(雷达)体显示。
auto* alongLineBar = new QWidget(vtkWidget); auto* alongLineBar = new QWidget(vtkWidget);
@ -528,48 +507,22 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
auto* alongLineOverlay = new BottomBarOverlay(alongLineBar, vtkWidget); auto* alongLineOverlay = new BottomBarOverlay(alongLineBar, vtkWidget);
QObject::connect(alongLineSlider, &QSlider::valueChanged, vtkWidget, QObject::connect(alongLineSlider, &QSlider::valueChanged, vtkWidget,
[sceneView](int v) { sceneView->focusAlongLongAxis(v / 1000.0, 0.12); }); [sceneView](int v) { sceneView->focusAlongLongAxis(v / 1000.0, 0.12); });
// 显隐刷新:仅三维分析 + 细长(长短轴比≥4即雷达)体时显示沿线滑块。 // 显隐刷新仅当场景中实际渲染了雷达三维体StoredVolume 带 linePrefix时显示沿线滑块。
// 旧逻辑靠数据包围盒长短轴比≥4 判定统一自由场景下细长的非雷达数据2D 轨迹线/反演帘面/
// 普通体)会撑大合并包围盒→误触发。改按「已渲染体中是否存在雷达体」门控(雷达体取消勾选/移除
// 后 onVolumeChanged 会重评并隐藏)。
auto refreshAlongLineBar = std::make_shared<std::function<void()>>( auto refreshAlongLineBar = std::make_shared<std::function<void()>>(
[sceneView, alongLineBar, alongLineOverlay]() { [sceneView, scene3dRepo, alongLineBar, alongLineOverlay]() {
const bool show = !sceneView->isAnalysisMode2D() && sceneView->longAxisElongation() >= 4.0; bool show = false;
for (const auto& kv : sceneView->volumes())
if (scene3dRepo->isRadarVolume(kv.first)) {
show = true;
break;
}
alongLineBar->setVisible(show); alongLineBar->setVisible(show);
if (show) alongLineOverlay->reposition(); if (show) alongLineOverlay->reposition();
}); });
// 双向选择联动B2去 col2D 后由统一段 datasetSelected 承担 2D 选中,此处旧 col2D 链已移除)。
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<std::string> 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);
};
// 坐标轴设置抽屉面板:叠加 vtkWidget、工具条右侧滑出默认隐藏点设置 toggle // 坐标轴设置抽屉面板:叠加 vtkWidget、工具条右侧滑出默认隐藏点设置 toggle
auto* axesPanel = new geopro::app::AxesSettingsPanel(vtkWidget); auto* axesPanel = new geopro::app::AxesSettingsPanel(vtkWidget);
@ -650,7 +603,7 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
// (设计 §2.1:三维分析栏按 对象/三维体模型/切片 三级树),归三维分析栏(非数据集栏)。 // (设计 §2.1:三维分析栏按 对象/三维体模型/切片 三级树),归三维分析栏(非数据集栏)。
// 后端列表每次勾选测线全量覆盖,故客户端体单独维护、刷新时合并注入(不被冲掉)。 // 后端列表每次勾选测线全量覆盖,故客户端体单独维护、刷新时合并注入(不被冲掉)。
// 三维分析数据源 = 最近对象树勾选拉取的 ds + 客户端三维体(mock) + 已保存切片; // 三维分析数据源 = 最近对象树勾选拉取的 ds + 客户端三维体(mock) + 已保存切片;
// splitByCategory 后注入 5 段(电阻率/视电阻率/瞬变/三维体/切片);二维(足迹)经 dim2D 仍走 col2D // splitByCategory 后注入各段(电阻率/视电阻率/瞬变/三维体/轨迹);二维(足迹/轨迹)并入同一单列(B2)
auto lastSourceRows = std::make_shared<std::vector<geopro::data::DsRow>>(); auto lastSourceRows = std::make_shared<std::vector<geopro::data::DsRow>>();
auto lastStructNodes = std::make_shared<std::vector<geopro::data::StructNode>>(); // 生成位置候选(项目内 GS/TM) auto lastStructNodes = std::make_shared<std::vector<geopro::data::StructNode>>(); // 生成位置候选(项目内 GS/TM)
auto refreshAnalysis = [drawer, scene3dRepo, lastSourceRows, lastStructNodes]() { auto refreshAnalysis = [drawer, scene3dRepo, lastSourceRows, lastStructNodes]() {
@ -667,7 +620,8 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
for (const auto& s : slices) voxelTree.push_back(s); for (const auto& s : slices) voxelTree.push_back(s);
for (const auto& a : anomalies) voxelTree.push_back(a); for (const auto& a : anomalies) voxelTree.push_back(a);
if (auto* sec = drawer->analysisTab()->section("voxel")) sec->setDatasets(voxelTree); if (auto* sec = drawer->analysisTab()->section("voxel")) sec->setDatasets(voxelTree);
drawer->col2D()->setDatasets(geopro::app::splitByDimension(*lastSourceRows).dim2D); // B2二维(足迹/轨迹)不再走 col2DsplitByCategory 已含 trajectory 段,随 setBuckets 自动入单列。
drawer->analysisTab()->refreshVisibility(); // voxel 段单独喂数据后刷新分段可见性Task B1
}; };
// 渲染勾选聚合:三维数据集栏(剖面→帘面)+ 三维分析栏(三维体/切片→体素/切片)两套勾选并集 // 渲染勾选聚合:三维数据集栏(剖面→帘面)+ 三维分析栏(三维体/切片→体素/切片)两套勾选并集
@ -678,10 +632,31 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
// 让「三维分析栏勾选(体/切片)」这条渲染路径也能隐藏不透明引导层——否则它盖住已渲染的体 // 让「三维分析栏勾选(体/切片)」这条渲染路径也能隐藏不透明引导层——否则它盖住已渲染的体
// (雷达体由分析栏勾选触发渲染,但旧逻辑只在对象树勾选时隐藏引导层 → 体被盖住看不到)。 // (雷达体由分析栏勾选触发渲染,但旧逻辑只在对象树勾选时隐藏引导层 → 体被盖住看不到)。
auto setSceneEmptyVisible = std::make_shared<std::function<void(bool)>>(); auto setSceneEmptyVisible = std::make_shared<std::function<void(bool)>>();
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; QStringList all = *checkedProfiles;
all += *checkedAnalysis; all += *checkedAnalysis;
sceneCtrl->setCheckedDatasets(all); std::vector<std::pair<std::string, std::string>> 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()); // 场景有内容→隐藏引导层 if (*setSceneEmptyVisible) (*setSceneEmptyVisible)(all.isEmpty()); // 场景有内容→隐藏引导层
}; };
@ -1052,66 +1027,9 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
analysisTab->scrollItemToTop(qid); // 新三维体行尽量滚到分析栏顶部 analysisTab->scrollItemToTop(qid); // 新三维体行尽量滚到分析栏顶部
}); });
}); });
// 本地导入三维雷达测线(后端未就绪的过渡入口):入口=三维体段头「+ 导入雷达测线」按钮(CategorySection) // 本地导入三维雷达测线(后端未就绪的过渡测试入口):入口已迁至 TopBar「设备」菜单
// → analysisTab.radarImportRequested(impulse)。app 无原生菜单栏(menuBar 被 TopBar 经 setMenuWidget 占用), // →「导入雷达测线」二级项(emit TopBar::radarImportRequested)。接线在 topBar 创建后(见下方设备菜单接线)
// 故入口放可见的段头按钮。impulse=false 走规范化(.head/.data, 懒加载后台建体)true 走 Impulse(.iprb, eager)。 // 因 topBar 此处尚未构造;导入流程目标不变。
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.71.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);
});
});
// 任一数据集(剖面/体)异步加载开始 → 列表项复选框转等待 spinner渲染完成 → 复原复选框。 // 任一数据集(剖面/体)异步加载开始 → 列表项复选框转等待 spinner渲染完成 → 复原复选框。
// 覆盖非三维体:勾选剖面首次渲染较慢时也有等待反馈(用户反馈)。 // 覆盖非三维体:勾选剖面首次渲染较慢时也有等待反馈(用户反馈)。
QObject::connect(sceneCtrl, &geopro::controller::VtkSceneController::datasetLoading, analysisTab, QObject::connect(sceneCtrl, &geopro::controller::VtkSceneController::datasetLoading, analysisTab,
@ -1345,10 +1263,26 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
sceneView->setAnomalyVisible(id.toStdString(), vis); sceneView->setAnomalyVisible(id.toStdString(), vis);
renderWindowPtr->Render(); 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<std::string> ids;
ids.reserve(static_cast<size_t>(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 // 树选中切片/异常 → VTK 高亮联动(正向 list→VTK反向 VTK→list 需拾取回调,见 OPT-002
QObject::connect(analysisTab, &geopro::app::CategoryAnalysisTab::datasetSelected, vtkWidget, QObject::connect(analysisTab, &geopro::app::CategoryAnalysisTab::datasetSelected, vtkWidget,
[sceneView, interactionMgr, renderWindowPtr](const QString& dsId, [sceneView, interactionMgr, renderWindowPtr, applyFittedAxes](
const QString& ddCode) { const QString& dsId, const QString& ddCode) {
const std::string id = dsId.toStdString(); const std::string id = dsId.toStdString();
// 选中项决定高亮:异常↔切片互斥,选其它对象两者都清(否则切到别的对象后切片/异常仍高亮)。 // 选中项决定高亮:异常↔切片互斥,选其它对象两者都清(否则切到别的对象后切片/异常仍高亮)。
if (ddCode == QStringLiteral("dd_anomaly")) { if (ddCode == QStringLiteral("dd_anomaly")) {
@ -1361,17 +1295,52 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
sceneView->setSelectedAnomaly(std::string{}); sceneView->setSelectedAnomaly(std::string{});
interactionMgr->deselectSlice(); interactionMgr->deselectSlice();
} }
// 贴合坐标轴T2选中 → 该 ds 子树盒贴合轴+隐全景;空选中(取消) → 恢复全景轴。
// 子树未渲染/无盒 → 退回全景轴,避免留下无据的贴合框。
if (!applyFittedAxes(dsId)) sceneView->showSceneAxes();
renderWindowPtr->Render(); 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<std::string> ids;
ids.reserve(static_cast<size_t>(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 里点中/选中一张切片 → 在三维体段树里同步选中该切片行(②反向)。 // 反向 VTK→list在 VTK 里点中/选中一张切片 → 在三维体段树里同步选中该切片行(②反向)。
// 点空白/清选(dsId 空) → 一并清 VTK 异常高亮(否则取消选中后异常图形仍高亮,用户反馈)。 // 点空白/清选(dsId 空) → 一并清 VTK 异常高亮(否则取消选中后异常图形仍高亮,用户反馈)。
interactionMgr->onSliceSelectionChanged = [drawer, sceneView, renderWindowPtr]( interactionMgr->onSliceSelectionChanged = [drawer, sceneView, renderWindowPtr, applyFittedAxes](
const std::string& dsId) { const std::string& dsId) {
if (auto* sec = drawer->analysisTab()->section("voxel")) if (auto* sec = drawer->analysisTab()->section("voxel"))
sec->selectItem(QString::fromStdString(dsId)); sec->selectItem(QString::fromStdString(dsId));
if (dsId.empty()) { if (dsId.empty()) {
sceneView->setSelectedAnomaly(std::string{}); sceneView->setSelectedAnomaly(std::string{});
sceneView->showSceneAxes(); // VTK 里点空白清选 → 一并恢复全景轴selectItem 空被 blocker 拦,不走 datasetSelected
renderWindowPtr->Render(); renderWindowPtr->Render();
} else {
// 视口内点选切片/异常/体 → 与树选一致显示子树贴合轴。selectItem 在 QSignalBlocker 下不发
// datasetSelected防选择环故这里直接走共用解析补上贴合轴。无盒则保持现状不强推全景
if (applyFittedAxes(QString::fromStdString(dsId))) renderWindowPtr->Render();
} }
}; };
// 已保存切片经 VTK 右键「关闭」→ 取消数据列表对应切片项的勾选(否则列表仍勾选,用户反馈)。 // 已保存切片经 VTK 右键「关闭」→ 取消数据列表对应切片项的勾选(否则列表仍勾选,用户反馈)。
@ -1385,48 +1354,11 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
// 当前底图选择(默认 天地图=Satellite对齐 Column2DDataset 默认项);数据重锚后据此在数据位置加载。 // 当前底图选择(默认 天地图=Satellite对齐 Column2DDataset 默认项);数据重锚后据此在数据位置加载。
auto basemapKind = auto basemapKind =
std::make_shared<geopro::app::TileBasemap::Kind>(geopro::app::TileBasemap::Satellite); std::make_shared<geopro::app::TileBasemap::Kind>(geopro::app::TileBasemap::Satellite);
QObject::connect(drawer->col2D(), &geopro::app::Column2DDataset::basemapChanged, basemap, // B2col2D 已删 —— 旧 2D 面板信号basemapChanged / checkedDatasetsChanged / view2DModeChanged /
[basemap, basemapKind](int idx) { // customZChanged接线随之移除。其替代工具条 3D 底图、平面 z 滑块、2D 底图弹层)由 Phase D3/E3/F2
// 地图下拉0 天地图(卫星影像) / 1 Google(暂未实现→隐藏) / 2 隐藏。 // 重接。轨迹(足迹)勾选现经统一段 checkedDatasetsChanged → pushChecked → "plane2d" 策略渲染。
*basemapKind = (idx == 0) ? geopro::app::TileBasemap::Satellite // 底图默认仍为天地图(basemapKind=Satellite),首个数据重锚后由 onFrameReanchored 显示。
: geopro::app::TileBasemap::Hidden; // 抽屉去 tab 后无「三维/二维分析」切换,视图固定三维分析;旧 analysisModeChanged 接线移除C2/D3 重接)。
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<double>(0.0);
// 默认 1(Z=0)与「2D视图」下拉可见默认项(setCurrentIndex(1))及控制器默认摆放一致——
// 组合框初始 view2DModeChanged 因 connect 前发射而丢失,此处不可依赖其同步初值。
auto view2dMode = std::make_shared<int>(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)
});
// 首个真实剖面到达 → frame 重锚到数据 lat/lon 后,把选中的底图加载到数据所在位置 // 首个真实剖面到达 → frame 重锚到数据 lat/lon 后,把选中的底图加载到数据所在位置
// (默认天地图即在此刻出现,避免启动时在样本原点拉无关瓦片)。 // (默认天地图即在此刻出现,避免启动时在样本原点拉无关瓦片)。
@ -1435,8 +1367,27 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
}; };
// 相机程序化变化(取景/预设/缩放)后,底图按新视锥重算覆盖(治首帧部分瓦片需手动微动才出)。 // 相机程序化变化(取景/预设/缩放)后,底图按新视锥重算覆盖(治首帧部分瓦片需手动微动才出)。
sceneView->onCameraChanged = [basemap]() { basemap->refresh(); }; 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(); }); basemap->setDataRadiusProvider([sceneView]() { return sceneView->dataHorizontalRadius(); });
// F22D 平面底图工厂下发 → Plane2DRenderStrategy 据此为每个 2D 类型平面按需建/销平面矢量底图
// (与共享 3D 底图同源 scene/渲染窗/frame/数据半径规则;随平面 z 升降重建、全消随平面消失)。
// 工厂造 TileBasemapPlaneAdapter(app 层适配 IPlaneBasemap),使 geopro_controller 不反依赖 app/VTK。
sceneCtrl->setPlaneBasemapFactory(
[scene, renderWindowPtr, frame, sceneView](double groundZ)
-> std::unique_ptr<geopro::controller::IPlaneBasemap> {
return std::make_unique<geopro::app::TileBasemapPlaneAdapter>(
*scene, renderWindowPtr, frame, groundZ,
[sceneView]() { return sceneView->dataHorizontalRadius(); });
});
// 垂直夸张:地形须与剖面用同一 VE 才对齐(都按真实高程×VE)。单一来源 kVerticalExaggeration 下发 // 垂直夸张:地形须与剖面用同一 VE 才对齐(都按真实高程×VE)。单一来源 kVerticalExaggeration 下发
// 控制器(上方)/底图;运行时 VE 改由坐标轴设置抽屉「应用」下发(旧三维数据集栏滑块已退役)。 // 控制器(上方)/底图;运行时 VE 改由坐标轴设置抽屉「应用」下发(旧三维数据集栏滑块已退役)。
basemap->setVerticalExaggeration(kVerticalExaggeration); basemap->setVerticalExaggeration(kVerticalExaggeration);
@ -1517,7 +1468,7 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
auto* emptyCentering = new CenterOverlay(emptyState, vtkWidget); auto* emptyCentering = new CenterOverlay(emptyState, vtkWidget);
// 引导层定位后,把工具条与提示浮层 raise 到其上 → 工具条永在最上层(修:缩小渲染区时引导层挡工具条)。 // 引导层定位后,把工具条与提示浮层 raise 到其上 → 工具条永在最上层(修:缩小渲染区时引导层挡工具条)。
emptyCentering->setRaiseAfter({viewToolbar, anomalyHint, elevHint, alongLineBar}); emptyCentering->setRaiseAfter({viewToolbar, anomalyHint, alongLineBar});
emptyCentering->reposition(); emptyCentering->reposition();
// 引导层隐藏器就位(见 pushChecked 处声明):场景(剖面∪三维分析)有勾选 → 隐藏不透明引导层、露出渲染。 // 引导层隐藏器就位(见 pushChecked 处声明):场景(剖面∪三维分析)有勾选 → 隐藏不透明引导层、露出渲染。
*setSceneEmptyVisible = [emptyState](bool empty) { emptyState->setVisible(empty); }; *setSceneEmptyVisible = [emptyState](bool empty) { emptyState->setVisible(empty); };
@ -1701,7 +1652,7 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
emptyState->setVisible(sources.isEmpty() && checkedAnalysis->isEmpty()); emptyState->setVisible(sources.isEmpty() && checkedAnalysis->isEmpty());
if (sources.isEmpty()) { if (sources.isEmpty()) {
*lastSourceRows = {}; *lastSourceRows = {};
refreshAnalysis(); // 清空 5 段(客户端三维体仍驻留) + col2D refreshAnalysis(); // 清空各段(客户端三维体仍驻留)
return; return;
} }
// 多源异步汇总:每个源(TM / GS·项目根直挂)按 confType 取整棵 ds 子树,全部回来后 splitByCategory 分 5 段。 // 多源异步汇总:每个源(TM / GS·项目根直挂)按 confType 取整棵 ds 子树,全部回来后 splitByCategory 分 5 段。
@ -1710,7 +1661,7 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
auto finish = [acc, generation, myGen, lastSourceRows, refreshAnalysis]() { auto finish = [acc, generation, myGen, lastSourceRows, refreshAnalysis]() {
if (*generation != myGen) return; // 已被更新的勾选批次取代→丢弃陈旧结果 if (*generation != myGen) return; // 已被更新的勾选批次取代→丢弃陈旧结果
*lastSourceRows = *acc; // 全部对象树 ds 作分析数据源 *lastSourceRows = *acc; // 全部对象树 ds 作分析数据源
refreshAnalysis(); // splitByCategory→5段 + 合并三维体/切片 + dim2D→col2D refreshAnalysis(); // splitByCategory→各段 + 合并三维体/切片(单列)
}; };
for (const geopro::data::DataSource& src : sources) { for (const geopro::data::DataSource& src : sources) {
// 第3参 confType1=GS/项目根(直挂 ds)2=TM(测线下 ds)——透传给 loadRowsAsync(spec §6)。 // 第3参 confType1=GS/项目根(直挂 ds)2=TM(测线下 ds)——透传给 loadRowsAsync(spec §6)。
@ -1800,23 +1751,82 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
centralStack->setCurrentWidget(dockManager); 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.71.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 渲染。 // 切换到「不同项目」时先清空中央区,避免新项目残留旧项目的三栏数据与 VTK 渲染。
// 仅真正换项目用delete-refresh 等 switchProject(currentProjectId) 不走此处,避免误清)。 // 仅真正换项目用delete-refresh 等 switchProject(currentProjectId) 不走此处,避免误清)。
auto clearCentral = [sceneCtrl, emptyState, checkedProfiles, checkedAnalysis, auto clearCentral = [emptyState, checkedProfiles, checkedAnalysis,
pushChecked, lastSourceRows, refreshAnalysis, checkedSliceIds, pushChecked, lastSourceRows, refreshAnalysis, checkedSliceIds,
syncSlices, basemap, sceneView, scene3dRepo]() { syncSlices, basemap, sceneView, scene3dRepo]() {
// 切项目:先清内存态三维体/切片/异常(否则上个项目的产物残留进新项目列表),再 refreshAnalysis。 // 切项目:先清内存态三维体/切片/异常(否则上个项目的产物残留进新项目列表),再 refreshAnalysis。
scene3dRepo->clearMockData(); scene3dRepo->clearMockData();
// 数据源清空 → 5 段 + col2D 清空refreshAnalysis 内 setBuckets/dim2D // 数据源清空 → 各段清空refreshAnalysis 内 setBuckets含 trajectory 段)。
*lastSourceRows = {}; *lastSourceRows = {};
refreshAnalysis(); refreshAnalysis();
// 勾选集清空并下发空到 VTK帘面/体素/切片/2D 足迹全部撤场)。 // 勾选集清空并下发空到 VTK帘面/体素/切片/2D 足迹全部撤场——统一入口一并清)。
checkedProfiles->clear(); checkedProfiles->clear();
checkedAnalysis->clear(); checkedAnalysis->clear();
checkedSliceIds->clear(); checkedSliceIds->clear();
pushChecked(); // setCheckedDatasets({}) → 帘面/体素清空 pushChecked(); // setCheckedDatasets({}) → 帘面/体素/足迹清空(统一勾选集 diff 全移除)
syncSlices(); // 切片随空勾选调和 syncSlices(); // 切片随空勾选调和
sceneCtrl->setChecked2DDatasets({}); // 2D 足迹显式撤场(与 col2D 空勾选双保险)
// 复位重锚标志:增量勾选不走 clear(),不复位则旧标志残留 → 新项目数据不重锚 → // 复位重锚标志:增量勾选不走 clear(),不复位则旧标志残留 → 新项目数据不重锚 →
// onFrameReanchored 不触发 → 下面 hide() 的底图永不再显。复位后新项目首个数据重锚→重显。 // onFrameReanchored 不触发 → 下面 hide() 的底图永不再显。复位后新项目首个数据重锚→重显。
sceneView->resetFrameAnchor(); sceneView->resetFrameAnchor();

View File

@ -1,6 +1,7 @@
#include "panels/columns/CategoryAnalysisTab.hpp" #include "panels/columns/CategoryAnalysisTab.hpp"
#include <QAbstractItemView> #include <QAbstractItemView>
#include <QLabel>
#include <QPointer> #include <QPointer>
#include <QScrollArea> #include <QScrollArea>
#include <QScrollBar> #include <QScrollBar>
@ -10,6 +11,7 @@
#include "Theme.hpp" #include "Theme.hpp"
#include "panels/columns/CategorySection.hpp" #include "panels/columns/CategorySection.hpp"
#include "repo/CategoryDescriptor.hpp" // categoryCatalog含 trajectory 段)
namespace geopro::app { namespace geopro::app {
@ -33,23 +35,25 @@ CategoryAnalysisTab::CategoryAnalysisTab(geopro::data::DatasetFieldDictionary* d
col->setContentsMargins(0, space::kSm, 0, space::kSm); // 顶部留白:首段段头不贴顶 col->setContentsMargins(0, space::kSm, 0, space::kSm); // 顶部留白:首段段头不贴顶
col->setSpacing(space::kSm); col->setSpacing(space::kSm);
for (const CategorySpec& spec : categoryConfigs()) { for (const auto& desc : geopro::data::categoryCatalog()) {
auto* sec = new CategorySection(spec, dict, content); auto* sec = new CategorySection(desc, dict, content);
sections_[spec.id] = sec; sections_[desc.id] = sec;
ordered_.push_back(sec); ordered_.push_back(sec);
connect(sec, &CategorySection::collapsedChanged, this, connect(sec, &CategorySection::collapsedChanged, this,
&CategoryAnalysisTab::relayoutSections); // 折叠/展开 → 重排 stretch向上收 &CategoryAnalysisTab::relayoutSections); // 折叠/展开 → 重排 stretch向上收
const std::string segId = spec.id; const std::string segId = desc.id;
connect(sec, &CategorySection::checkedDatasetsChanged, this, connect(sec, &CategorySection::checkedDatasetsChanged, this,
[this, segId](const QStringList& ids) { [this, segId](const QStringList& ids) {
checkedBySeg_[segId] = ids; checkedBySeg_[segId] = ids;
recomputeCheckedUnion(); recomputeCheckedUnion();
updatePlaceholderAndVisibility(); // 勾选/段内容变化后段「有无行」可能变 → 刷新显隐
}); });
connect(sec, &CategorySection::generateVolumeRequested, this, connect(sec, &CategorySection::generateVolumeRequested, this,
&CategoryAnalysisTab::generateVolumeRequested); &CategoryAnalysisTab::generateVolumeRequested);
connect(sec, &CategorySection::radarImportRequested, this, // 注:「导入雷达测线」入口已迁至 TopBar「设备」菜单Task D1CategorySection 段头按钮与
&CategoryAnalysisTab::radarImportRequested); // CategoryAnalysisTab::radarImportRequested 信号均已移除。
connect(sec, &CategorySection::detailRequested, this, &CategoryAnalysisTab::detailRequested); connect(sec, &CategorySection::detailRequested, this, &CategoryAnalysisTab::detailRequested);
connect(sec, &CategorySection::datasetActivated, this, &CategoryAnalysisTab::datasetActivated);
connect(sec, &CategorySection::deleteDatasetRequested, this, connect(sec, &CategorySection::deleteDatasetRequested, this,
&CategoryAnalysisTab::deleteDatasetRequested); &CategoryAnalysisTab::deleteDatasetRequested);
connect(sec, &CategorySection::sliceRequested, this, &CategoryAnalysisTab::sliceRequested); connect(sec, &CategorySection::sliceRequested, this, &CategoryAnalysisTab::sliceRequested);
@ -64,7 +68,24 @@ CategoryAnalysisTab::CategoryAnalysisTab(geopro::data::DatasetFieldDictionary* d
&CategoryAnalysisTab::sliceExportDatRequested); &CategoryAnalysisTab::sliceExportDatRequested);
connect(sec, &CategorySection::anomalyVisibilityChanged, this, connect(sec, &CategorySection::anomalyVisibilityChanged, this,
&CategoryAnalysisTab::anomalyVisibilityChanged); &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 区等高、不出滚动条) // #7各段等分 stretch → 内容都少时四段平分高度填满面板(初始与 VTK 区等高、不出滚动条)
// 某段内容增多时其最小高度(=内容总高)撑大,超出视口则由外层 QScrollArea 统一出纵向滚动条。 // 某段内容增多时其最小高度(=内容总高)撑大,超出视口则由外层 QScrollArea 统一出纵向滚动条。
col->addWidget(sec, 1); col->addWidget(sec, 1);
@ -72,6 +93,18 @@ CategoryAnalysisTab::CategoryAnalysisTab(geopro::data::DatasetFieldDictionary* d
// 尾部弹簧(末项):默认 0全部段折叠时由 relayoutSections 置 1吸收余量把段头顶到顶部。 // 尾部弹簧(末项):默认 0全部段折叠时由 relayoutSections 置 1吸收余量把段头顶到顶部。
col->addStretch(0); col->addStretch(0);
scroll->setWidget(content); 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() { void CategoryAnalysisTab::relayoutSections() {
@ -86,14 +119,29 @@ void CategoryAnalysisTab::relayoutSections() {
} }
void CategoryAnalysisTab::setBuckets(const CategoryBuckets& b) { void CategoryAnalysisTab::setBuckets(const CategoryBuckets& b) {
const auto& cfg = categoryConfigs(); // splitByCategory 现按 categoryCatalog() 分桶(含 trajectory 桶,自然分发到轨迹段)。
for (std::size_t i = 0; i < cfg.size() && i < b.segments.size(); ++i) { // 轨迹段已随 catalog 在构造时建出Task C2section() 命中存在的段;下方 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 // voxel(三维体) 段数据来自 mock voxelTree(体/切片/异常),由调用方单独 section("voxel")->setDatasets
// 注入splitByCategory 对它永远是空桶,若在此用空桶覆盖会先清掉其勾选(随后重填但勾选已丢) → // 注入splitByCategory 对它永远是空桶,若在此用空桶覆盖会先清掉其勾选(随后重填但勾选已丢) →
// 表现为「创建切片/异常后体/切片选择被清空」(用户 issue 1)。故跳过 voxel勿覆盖。 // 表现为「创建切片/异常后体/切片选择被清空」(用户 issue 1)。故跳过 voxel勿覆盖。
if (cfg[i].id == "voxel") continue; if (cat[i].id == "voxel") continue;
if (auto* sec = section(cfg[i].id)) sec->setDatasets(b.segments[i]); 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<geopro::data::StructNode>& nodes) { void CategoryAnalysisTab::setStructure(const std::vector<geopro::data::StructNode>& nodes) {
@ -109,6 +157,14 @@ CategorySection* CategoryAnalysisTab::section(const std::string& id) const {
return it != sections_.end() ? it->second : nullptr; 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 // ds 归属唯一段未知 → 广播到各段,仅含该 ds 的段会命中生效(其余 no-op
void CategoryAnalysisTab::setItemChecked(const QString& dsId, bool on) { void CategoryAnalysisTab::setItemChecked(const QString& dsId, bool on) {
for (auto* sec : ordered_) sec->setChecked(dsId, on); for (auto* sec : ordered_) sec->setChecked(dsId, on);

View File

@ -26,21 +26,24 @@ class CategoryAnalysisTab : public QWidget {
public: public:
explicit CategoryAnalysisTab(geopro::data::DatasetFieldDictionary* dict, QWidget* parent = nullptr); 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<geopro::data::StructNode>& nodes); // 转发各段 void setStructure(const std::vector<geopro::data::StructNode>& nodes); // 转发各段
void refreshArrayFilters(); // 装置枚举异步加载后,重填各段装置筛选下拉 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 的段)── // ── 三维体生成后:自动勾选触发渲染 + 标题前等待动画(spinner)反馈(转发到含该 ds 的段)──
void setItemChecked(const QString& dsId, bool on); // 自动勾选(触发渲染) void setItemChecked(const QString& dsId, bool on); // 自动勾选(触发渲染)
void setItemBusy(const QString& dsId, bool busy); // 复选框↔等待 spinner 切换 void setItemBusy(const QString& dsId, bool busy); // 复选框↔等待 spinner 切换
void clearAllBusy(); // 撤回所有 spinner失败兜底 void clearAllBusy(); // 撤回所有 spinner失败兜底
void scrollItemToTop(const QString& dsId); // 把某行尽量滚到面板顶部(新建三维体后定位) void scrollItemToTop(const QString& dsId); // 把某行尽量滚到面板顶部(新建三维体后定位)
void refreshVisibility() { updatePlaceholderAndVisibility(); } // 外部注入(如 voxel setDatasets)后刷新段显隐/占位
signals: signals:
void checkedDatasetsChanged(const QStringList& dsIds); // 5 段勾选并集 void checkedDatasetsChanged(const QStringList& dsIds); // 5 段勾选并集
void generateVolumeRequested(const QString& dsTypeCode, const QStringList& sourceDsIds); 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); // 右键删除切片/异常 void deleteDatasetRequested(const QString& dsId, const QString& ddCode); // 右键删除切片/异常
// ── 三维体段操作转发(迁自旧 Column3DAnalysis全接── // ── 三维体段操作转发(迁自旧 Column3DAnalysis全接──
void sliceRequested(geopro::render::interact::SliceAxis axis, const QString& volumeDsId); void sliceRequested(geopro::render::interact::SliceAxis axis, const QString& volumeDsId);
@ -52,6 +55,9 @@ signals:
void sliceExportDatRequested(const QString& dsId); void sliceExportDatRequested(const QString& dsId);
void anomalyVisibilityChanged(const QString& dsId, bool vis); void anomalyVisibilityChanged(const QString& dsId, bool vis);
void datasetSelected(const QString& dsId, const QString& ddCode); // 树选中→VTK 高亮联动 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: private:
void recomputeCheckedUnion(); void recomputeCheckedUnion();
@ -61,13 +67,17 @@ private:
// 据各段折叠态重排 stretch折叠段=0(仅段头高)、展开段=1(吸收余量);全折叠时尾部弹簧吸收→段头顶到顶部。 // 据各段折叠态重排 stretch折叠段=0(仅段头高)、展开段=1(吸收余量);全折叠时尾部弹簧吸收→段头顶到顶部。
// 仅在面板不产生滚动条(内容short于视口)时有可见效果——正是用户反馈的"折叠后停在原位中间"场景。 // 仅在面板不产生滚动条(内容short于视口)时有可见效果——正是用户反馈的"折叠后停在原位中间"场景。
void relayoutSections(); void relayoutSections();
// 据各段是否有可渲染数据行 显隐段;全空时显示占位提示、隐藏滚动区。数据变化(分发/勾选/注入)后调用。
void updatePlaceholderAndVisibility();
std::map<std::string, CategorySection*> sections_; std::map<std::string, CategorySection*> sections_;
std::vector<CategorySection*> ordered_; // 按 categoryConfigs 顺序relayout 遍历用) std::vector<CategorySection*> ordered_; // 按 categoryCatalog() 顺序relayout 遍历用)
QScrollArea* scroll_ = nullptr; // 外层滚动区scrollItemToTop 定位用) QScrollArea* scroll_ = nullptr; // 外层滚动区scrollItemToTop 定位用)
QWidget* content_ = nullptr; // 滚动内容容器(坐标映射用) QWidget* content_ = nullptr; // 滚动内容容器(坐标映射用)
QVBoxLayout* col_ = nullptr; // 段堆叠布局(末项=尾部弹簧) QVBoxLayout* col_ = nullptr; // 段堆叠布局(末项=尾部弹簧)
QWidget* placeholder_ = nullptr; // 全空时显示的占位提示(与 scroll_ 同级,互斥显隐)
std::map<std::string, QStringList> checkedBySeg_; // 各段当前勾选(合并成并集) std::map<std::string, QStringList> checkedBySeg_; // 各段当前勾选(合并成并集)
bool inSelectionSync_ = false; // 跨段互斥清选进行中标记防重入信号环clearSelection 已阻断信号,此为兜底)
}; };
} // namespace geopro::app } // namespace geopro::app

View File

@ -8,18 +8,23 @@
#include <QHBoxLayout> #include <QHBoxLayout>
#include <QLabel> #include <QLabel>
#include <QMenu> #include <QMenu>
#include <QMouseEvent>
#include <QPoint> #include <QPoint>
#include <QPushButton> #include <QPushButton>
#include <QSet> #include <QSet>
#include <QSignalBlocker> #include <QSignalBlocker>
#include <QSizePolicy>
#include <QSlider>
#include <QTimer> #include <QTimer>
#include <QToolButton> #include <QToolButton>
#include <QTreeWidget> #include <QTreeWidget>
#include <QWidgetAction>
#include <QTreeWidgetItemIterator> #include <QTreeWidgetItemIterator>
#include <QVBoxLayout> #include <QVBoxLayout>
#include "Theme.hpp" #include "Theme.hpp"
#include "panels/DatasetListPanel.hpp" #include "panels/DatasetListPanel.hpp"
#include "panels/columns/SectionIconBar.hpp"
#include "repo/DatasetFieldDictionary.hpp" #include "repo/DatasetFieldDictionary.hpp"
namespace geopro::app { namespace geopro::app {
@ -27,14 +32,20 @@ namespace geopro::app {
using geopro::data::DsRow; using geopro::data::DsRow;
using geopro::data::DsTypeFields; using geopro::data::DsTypeFields;
CategorySection::CategorySection(const CategorySpec& spec, geopro::data::DatasetFieldDictionary* dict, namespace {
QWidget* parent) // 段头图标条默认上限spec §6超过则末位收进「…」下拉。钉死为常量而非随操作数浮动
: QWidget(parent), spec_(spec), dict_(dict) { // 否则上限恒=操作数、计数溢出折叠分支永不触发(当前各段操作 ≤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); auto* root = new QVBoxLayout(this);
root->setContentsMargins(0, 0, 0, 0); root->setContentsMargins(0, 0, 0, 0);
root->setSpacing(0); root->setSpacing(0);
// 数据类型段头可折叠规范§4.3/§6chevron + 标题(title 字号·半粗) |「+ 新增三维体」(右,仅反演类)。 // 数据类型段头可折叠规范§4.3/§6chevron + 标题(title 字号·半粗) 右侧响应式图标条(SectionIconBar)。
// 段头加浅底 + 底分隔线作视觉分段;去原生小三角(难看)→chevron 文本前缀,随主题/hover 变色。 // 段头加浅底 + 底分隔线作视觉分段;去原生小三角(难看)→chevron 文本前缀,随主题/hover 变色。
auto* headerRow = new QWidget(this); auto* headerRow = new QWidget(this);
headerRow->setObjectName(QStringLiteral("secHeader")); headerRow->setObjectName(QStringLiteral("secHeader"));
@ -58,57 +69,44 @@ CategorySection::CategorySection(const CategorySpec& spec, geopro::data::Dataset
.arg(type::kWeightSemibold)); .arg(type::kWeightSemibold));
auto syncHeader = [this] { auto syncHeader = [this] {
header_->setText((header_->isChecked() ? QStringLiteral("") : QStringLiteral("")) header_->setText((header_->isChecked() ? QStringLiteral("") : QStringLiteral(""))
+ QString::fromStdString(spec_.title)); + QString::fromStdString(desc_.title));
}; };
syncHeader(); syncHeader();
// 标题先让位:水平 Preferred + 最小宽 0列变窄时标题先收必要时裁字图标条守住自身
// sizeHint(全图标宽)直到真正没空间才折叠右侧图标进「…」。否则标题不肯缩→图标条被迫先折(过早)。
header_->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Fixed);
header_->setMinimumWidth(0);
hl->addWidget(header_); hl->addWidget(header_);
hl->addStretch(1); hl->addStretch(1);
if (spec_.canGenerateVolume) { // 段头图标条:遍历 desc_.operations经一处 OpKind→IconAction 映射装配spec §6
auto* gen = new QToolButton(headerRow); // glyph 键须命中 SectionIconBar::glyphFromKey 已识别集z 值无专用键,复用 collapse(竖向双箭头)。
gen->setText(QStringLiteral("+ 新增三维体")); iconBar_ = new SectionIconBar(headerRow);
gen->setCursor(Qt::PointingHandCursor); std::vector<IconAction> acts;
// 次级强调按钮(规范§6.7):描边 accent + accent 文字hover 浅强调底;非裸文字。 for (geopro::data::OpKind op : desc_.operations) {
applyTokenizedStyleSheet( switch (op) {
gen, QStringLiteral( case geopro::data::OpKind::GenerateVolume:
"QToolButton{border:1px solid {{accent/primary}};border-radius:%1px;" // dsTypeCode 不再由段配置带(描述符无此字段)→ 发空串,接收方按 sourceIds 解析类型。
"color:{{accent/primary}};background:transparent;padding:%2px %3px;font-size:%4px;}" acts.push_back({QStringLiteral("plus"), QStringLiteral("新增三维体"),
"QToolButton:hover{background:{{bg/selected}};}" [this] { emit generateVolumeRequested(QString(), checkedDsIds()); }, {}});
"QToolButton:pressed{background:{{bg/hover}};}") break;
.arg(radius::kSm) case geopro::data::OpKind::Filter:
.arg(scaledPx(space::kXxs)) acts.push_back({QStringLiteral("filter"), QStringLiteral("筛选"),
.arg(scaledPx(space::kMd)) [this] { if (filterRow_) filterRow_->setVisible(!filterRow_->isVisible()); },
.arg(scaledPx(type::kCaption))); {}});
connect(gen, &QToolButton::clicked, this, [this] { break;
emit generateVolumeRequested(QString::fromStdString(spec_.dsTypeCode), checkedDsIds()); case geopro::data::OpKind::PlaneZ:
}); acts.push_back({QStringLiteral("collapse"), QStringLiteral("z 值"), {},
hl->addWidget(gen); [this](QToolButton* host) { showPlaneZPopup(host); }});
break;
case geopro::data::OpKind::Basemap:
acts.push_back({QStringLiteral("map"), QStringLiteral("底图"), {},
[this](QToolButton* host) { showBasemapPopup(host); }});
break;
} }
// 三维体段头「+ 导入雷达测线」(后端未就绪的本地过渡入口):弹出菜单选 规范化/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);
} }
iconBar_->setMaxIcons(kDefaultMaxIcons); // spec §6 默认上限 3够宽全显超数/不够宽收进「…」)
iconBar_->setActions(acts);
hl->addWidget(iconBar_);
root->addWidget(headerRow); root->addWidget(headerRow);
body_ = new QWidget(this); 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->setContentsMargins(space::kMd, space::kSm, space::kMd, space::kMd);
body->setSpacing(space::kSm); body->setSpacing(space::kSm);
// 筛选行:采集时间范围(在前)+ 装置类型(在后,仅 ERT 类)。 // 筛选行(默认折叠,由段头 Filter 图标 toggle按 desc_.filters 装配 —— DateRange→采集时间范围(在前)
auto* filterRow = new QHBoxLayout(); // ArrayType→装置类型下拉(在后)。未列出的维度不建控件passesFilters/rebuildList 视该控件缺席=不筛该维。
filterRow->setSpacing(space::kSm); filterRow_ = new QWidget(body_);
dateRange_ = new DateRangeEdit(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(); }); connect(dateRange_, &DateRangeEdit::rangeChanged, this, [this] { rebuildList(); });
filterRow->addWidget(dateRange_, 1); filterLay->addWidget(dateRange_, 1);
if (spec_.hasArrayTypeFilter) { } else if (fk == geopro::data::FilterKind::ArrayType) {
arrayCombo_ = new QComboBox(body_); arrayCombo_ = new QComboBox(filterRow_);
arrayCombo_->addItem(QStringLiteral("全部装置"), QString()); arrayCombo_->addItem(QStringLiteral("全部装置"), QString());
connect(arrayCombo_, qOverload<int>(&QComboBox::currentIndexChanged), this, connect(arrayCombo_, qOverload<int>(&QComboBox::currentIndexChanged), this,
[this](int) { rebuildList(); }); [this](int) { rebuildList(); });
filterRow->addWidget(arrayCombo_); filterLay->addWidget(arrayCombo_);
} }
body->addLayout(filterRow); }
filterRow_->hide(); // 默认折叠,点段头筛选图标展开
body->addWidget(filterRow_);
// 段体:可勾选数据树(勾选=渲染)。复用数据列表卡片委托与 populateDatasetList。 // 段体:可勾选数据树(勾选=渲染)。复用数据列表卡片委托与 populateDatasetList。
list_ = new QTreeWidget(body_); list_ = new QTreeWidget(body_);
@ -140,30 +145,52 @@ CategorySection::CategorySection(const CategorySpec& spec, geopro::data::Dataset
list_->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff); list_->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
list_->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); list_->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
applyDatasetCardDelegate(list_); applyDatasetCardDelegate(list_);
list_->viewport()->installEventFilter(this); // 捕获按下瞬间选中态(默认改选前),供单击已选行 toggle 取消
connect(list_, &QTreeWidget::itemChanged, this, [this](QTreeWidgetItem* it, int) { connect(list_, &QTreeWidget::itemChanged, this, [this](QTreeWidgetItem* it, int) {
// 用户点勾选框会在按下→释放间先发 itemChanged标记本次按下改动了勾选框itemClicked 据此不取消选中
// (点勾选框只切渲染,不应连带取消该行选中/丢贴合轴)。程序化改勾选均走 SignalBlocker不到此处。
if (it && it == pressedItem_) checkToggledThisPress_ = true;
// 异常行复选框 = 该异常显隐(异常不进渲染勾选集,单独走 anomalyVisibilityChanged → setAnomalyVisible // 异常行复选框 = 该异常显隐(异常不进渲染勾选集,单独走 anomalyVisibilityChanged → setAnomalyVisible
if (it && it->data(0, kDsDdCodeRole).toString() == QStringLiteral("dd_anomaly")) if (it && it->data(0, kDsDdCodeRole).toString() == QStringLiteral("dd_anomaly"))
emit anomalyVisibilityChanged(it->data(0, kDsIdRole).toString(), emit anomalyVisibilityChanged(it->data(0, kDsIdRole).toString(),
it->checkState(0) == Qt::Checked); it->checkState(0) == Qt::Checked);
emitChecked(); emitChecked();
}); });
// 单击已选中的数据行 → 取消选中toggle-offitemClicked 于释放时触发,此时选中态已落定;
// 仅当「按下瞬间该行已选中」且「本次未点勾选框」才取消。取消走 list_->clearSelection()(非成员
// clearSelection后者阻断信号→ 直发 itemSelectionChanged(空) → datasetSelected("") → 恢复全景轴、丢贴合轴。
// 双击仍可激活:首次释放虽可能 toggle 掉选中,随后 DblClick 会重新选中该行并发 datasetActivatedfit+详情)。
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) { connect(list_, &QTreeWidget::itemDoubleClicked, this, [this](QTreeWidgetItem* it, int) {
const QString id = it->data(0, kDsIdRole).toString(); const QString id = it->data(0, kDsIdRole).toString();
if (id.isEmpty()) return; 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
list_->setContextMenuPolicy(Qt::CustomContextMenu); // 并由上层 CategoryAnalysisTab 做全列互斥。voxel 段切片/异常的 VTK 高亮由上层据 ddCode 处理,
connect(list_, &QTreeWidget::customContextMenuRequested, this, &CategorySection::showContextMenu); // 故此信号对所有段发射即可,任意类型选中都能显子树贴合轴(非仅三维体)。
// 树选中切片/异常 → VTK 高亮联动(正向 list→VTK // 空选中(点树空白/清选)→ 发空 datasetSelected上层据此恢复全景轴、清高亮贴合轴取消)。
connect(list_, &QTreeWidget::itemSelectionChanged, this, [this] { connect(list_, &QTreeWidget::itemSelectionChanged, this, [this] {
const auto items = list_->selectedItems(); const auto items = list_->selectedItems();
if (items.isEmpty()) return; if (items.isEmpty()) { emit datasetSelected(QString(), QString()); return; }
QTreeWidgetItem* it = items.first(); QTreeWidgetItem* it = items.first();
const QString id = it->data(0, kDsIdRole).toString(); const QString id = it->data(0, kDsIdRole).toString();
const QString dd = it->data(0, kDsDdCodeRole).toString(); const QString dd = it->data(0, kDsDdCodeRole).toString();
if (!id.isEmpty() && dd != QStringLiteral("container")) emit datasetSelected(id, dd); 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);
} }
body->addWidget(list_, 1); 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<QMouseEvent*>(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(); } bool CategorySection::isExpanded() const { return header_ && header_->isChecked(); }
void CategorySection::ensureExpanded() { void CategorySection::ensureExpanded() {
@ -189,6 +230,37 @@ QTreeWidgetItem* CategorySection::itemFor(const QString& dsId) const {
return nullptr; 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<QTreeWidgetItem*> 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<geopro::data::StructNode>& nodes) { void CategorySection::setStructure(const std::vector<geopro::data::StructNode>& nodes) {
structure_ = nodes; // 容器分层(项目根/GS/TM→ds在 Task 12 接入真实结构后据此构建。 structure_ = nodes; // 容器分层(项目根/GS/TM→ds在 Task 12 接入真实结构后据此构建。
} }
@ -209,6 +281,13 @@ void CategorySection::selectItem(const QString& dsId) {
list_->setCurrentItem(nullptr); // 空 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) { void CategorySection::setChecked(const QString& dsId, bool on) {
for (QTreeWidgetItemIterator it(list_); *it; ++it) for (QTreeWidgetItemIterator it(list_); *it; ++it)
if ((*it)->data(0, kDsIdRole).toString() == dsId && if ((*it)->data(0, kDsIdRole).toString() == dsId &&
@ -264,7 +343,7 @@ void CategorySection::clearAllBusy() {
} }
void CategorySection::refreshArrayCombo() { void CategorySection::refreshArrayCombo() {
if (!spec_.hasArrayTypeFilter || !arrayCombo_) return; if (!arrayCombo_) return; // 该段无装置筛选控件desc_.filters 未含 ArrayType→ 不刷
const QString prev = arrayCombo_->currentData().toString(); const QString prev = arrayCombo_->currentData().toString();
const QSignalBlocker block(arrayCombo_); const QSignalBlocker block(arrayCombo_);
arrayCombo_->clear(); arrayCombo_->clear();
@ -294,8 +373,8 @@ void CategorySection::refreshArrayCombo() {
} }
bool CategorySection::passesFilters(const DsRow& row) const { bool CategorySection::passesFilters(const DsRow& row) const {
// 类型筛选("全部"=空不筛):按 ds 自身类型值typeName回退 dsTypeCode命中选中项。 // 类型筛选("全部"=空不筛;无 arrayCombo_=该段不筛装置维度):按 ds 自身类型值typeName回退 dsTypeCode命中选中项。
if (spec_.hasArrayTypeFilter && arrayCombo_) { if (arrayCombo_) {
const QString sel = arrayCombo_->currentData().toString(); const QString sel = arrayCombo_->currentData().toString();
if (!sel.isEmpty()) { if (!sel.isEmpty()) {
const QString t = !row.typeName.empty() ? QString::fromStdString(row.typeName) 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 from = dateRange_ ? dateRange_->from() : QDate();
const QDate to = dateRange_ ? dateRange_->to() : QDate(); const QDate to = dateRange_ ? dateRange_->to() : QDate();
if (from.isValid() || to.isValid()) { 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(); std::string ts = f ? collectTimeOf(row, *f) : std::string();
if (ts.empty()) ts = row.createTime; if (ts.empty()) ts = row.createTime;
const QDate d = QDate::fromString(QString::fromStdString(ts).left(10), QStringLiteral("yyyy-MM-dd")); 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)); 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<int>(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<double>(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仿工具条底图控件本类型平面底图 矢量平面(默认)/无 + 透明度滑块(0100默认 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 } // namespace geopro::app

View File

@ -3,7 +3,7 @@
#include <QWidget> #include <QWidget>
#include <vector> #include <vector>
#include "interact/SlicePlaneMath.hpp" // geopro::render::interact::SliceAxis #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" #include "repo/RepoTypes.hpp"
class QTreeWidget; class QTreeWidget;
@ -22,14 +22,16 @@ class DatasetFieldDictionary;
namespace geopro::app { namespace geopro::app {
class DateRangeEdit; class DateRangeEdit;
class SectionIconBar;
// 单个数据类型大类段spec §7段头标题/折叠 + 装置类型/日期筛选 + 「+新增三维体」)+ 段体(可勾选数据树)。 // 单个数据类型大类段spec §7段头标题/折叠 + 段头图标条)+ 段体(折叠筛选行 + 可勾选数据树)。
// 勾选数据行 = 渲染(帘面/体素/切片);段头生成按钮据当前勾选源发 generateVolumeRequested。 // 段头图标条由 descriptor.operations(OpKind) 驱动;筛选行由 descriptor.filters(FilterKind) 驱动、默认折叠。
// 勾选数据行 = 渲染(帘面/体素/切片/二维面GenerateVolume 图标据当前勾选源发 generateVolumeRequested。
class CategorySection : public QWidget { class CategorySection : public QWidget {
Q_OBJECT Q_OBJECT
public: public:
CategorySection(const CategorySpec& spec, geopro::data::DatasetFieldDictionary* dict, CategorySection(const geopro::data::CategoryDescriptor& desc,
QWidget* parent = nullptr); geopro::data::DatasetFieldDictionary* dict, QWidget* parent = nullptr);
// 对象树同源的扁平 GS/TM 节点段体容器分层用Task 12 接入真实结构,当前仅存储)。 // 对象树同源的扁平 GS/TM 节点段体容器分层用Task 12 接入真实结构,当前仅存储)。
void setStructure(const std::vector<geopro::data::StructNode>& nodes); void setStructure(const std::vector<geopro::data::StructNode>& nodes);
@ -39,20 +41,34 @@ public:
void setBusy(const QString& dsId, bool busy); void setBusy(const QString& dsId, bool busy);
void clearAllBusy(); // 撤回本段所有 spinner失败兜底 void clearAllBusy(); // 撤回本段所有 spinner失败兜底
void selectItem(const QString& dsId); // 程序化选中某行(VTK→list 反向联动);空/未找到=清选中 void selectItem(const QString& dsId); // 程序化选中某行(VTK→list 反向联动);空/未找到=清选中
void clearSelection(); // 清本段树选中(信号阻断,不回发 datasetSelected);供跨段全列互斥用
QStringList checkedIds() const { return checkedDsIds(); } // 当前勾选 ds异常显隐同步用 QStringList checkedIds() const { return checkedDsIds(); } // 当前勾选 ds异常显隐同步用
void refreshArrayFilter() { refreshArrayCombo(); } // 装置枚举异步加载后重填下拉 void refreshArrayFilter() { refreshArrayCombo(); } // 装置枚举异步加载后重填下拉
const CategorySpec& spec() const { return spec_; }
bool isExpanded() const; // 段头展开态(供外层按折叠状态重分配 stretch实现"折叠向上收" bool isExpanded() const; // 段头展开态(供外层按折叠状态重分配 stretch实现"折叠向上收"
void ensureExpanded(); // 展开本段(折叠则展开):滚动定位前确保目标行可见 void ensureExpanded(); // 展开本段(折叠则展开):滚动定位前确保目标行可见
QTreeWidget* listWidget() const { return list_; } // 段体树(外层滚动定位用) QTreeWidget* listWidget() const { return list_; } // 段体树(外层滚动定位用)
QTreeWidgetItem* itemFor(const QString& dsId) const; // 按 dsId 取行(无则 nullptr 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: signals:
void checkedDatasetsChanged(const QStringList& dsIds); // 数据行勾选=渲染 void checkedDatasetsChanged(const QStringList& dsIds); // 数据行勾选=渲染
void collapsedChanged(); // 折叠/展开切换 → 外层 CategoryAnalysisTab 重排各段 stretch void collapsedChanged(); // 折叠/展开切换 → 外层 CategoryAnalysisTab 重排各段 stretch
void generateVolumeRequested(const QString& dsTypeCode, const QStringList& sourceDsIds); // 段头「+新增三维体」 void generateVolumeRequested(const QString& dsTypeCode, const QStringList& sourceDsIds); // 段头「+新增三维体」(接收方按 sourceIds 解析类型)
void radarImportRequested(bool impulse); // 三维体段头「+导入雷达测线」(false=规范化 .head/.data, true=Impulse .iprb) void planeZChanged(const QString& typeId, double z); // PlaneZ 滑块:整体升降该 2D 类型平面z=绝对高程,米)
void detailRequested(const QString& dsId, const QString& ddCode, const QString& name); // 双击/右键=详情 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); // 右键删除(切片/异常) void deleteDatasetRequested(const QString& dsId, const QString& ddCode); // 右键删除(切片/异常)
// ── 三维体段右键操作(迁自旧 Column3DAnalysis全接── // ── 三维体段右键操作(迁自旧 Column3DAnalysis全接──
void sliceRequested(geopro::render::interact::SliceAxis axis, const QString& volumeDsId); // 体→生成切片(轴+目标体) void sliceRequested(geopro::render::interact::SliceAxis axis, const QString& volumeDsId); // 体→生成切片(轴+目标体)
@ -67,24 +83,37 @@ signals:
private: private:
void showContextMenu(const QPoint& pos); // 段体树右键菜单(详情 + 删除) void showContextMenu(const QPoint& pos); // 段体树右键菜单(详情 + 删除)
void showPlaneZPopup(QToolButton* host); // PlaneZ 图标:弹 z 值滑块 popup → planeZChanged
void showBasemapPopup(QToolButton* host); // Basemap 图标:弹 矢量平面/无 + 透明度滑块 popup
void rebuildList(); // 据 rows_经装置/日期筛选)重建段体树并复原勾选 void rebuildList(); // 据 rows_经装置/日期筛选)重建段体树并复原勾选
void refreshArrayCombo(); // 据当前 rows_ 重填装置类型下拉项(经字典 value→中文 void refreshArrayCombo(); // 据当前 rows_ 重填装置类型下拉项(经字典 value→中文
void emitChecked(); // 收集勾选 → checkedDatasetsChanged void emitChecked(); // 收集勾选 → checkedDatasetsChanged
QStringList checkedDsIds() const; QStringList checkedDsIds() const;
bool passesFilters(const geopro::data::DsRow& row) const; // 装置类型 + 采集时间范围 bool passesFilters(const geopro::data::DsRow& row) const; // 装置类型 + 采集时间范围
CategorySpec spec_; geopro::data::CategoryDescriptor desc_;
geopro::data::DatasetFieldDictionary* dict_ = nullptr; geopro::data::DatasetFieldDictionary* dict_ = nullptr;
std::vector<geopro::data::DsRow> rows_; std::vector<geopro::data::DsRow> rows_;
std::vector<geopro::data::StructNode> structure_; std::vector<geopro::data::StructNode> structure_;
QToolButton* header_ = nullptr; // 折叠头(标题 + 箭头) QToolButton* header_ = nullptr; // 折叠头(标题 + 箭头)
SectionIconBar* iconBar_ = nullptr; // 段头响应式图标条(由 desc_.operations 装配)
QWidget* body_ = nullptr; // 段体容器(折叠时隐藏) QWidget* body_ = nullptr; // 段体容器(折叠时隐藏)
QComboBox* arrayCombo_ = nullptr; // 装置类型筛选(仅 hasArrayTypeFilter QWidget* filterRow_ = nullptr; // 筛选行容器(默认折叠,由 Filter 图标 toggle
QComboBox* arrayCombo_ = nullptr; // 装置类型筛选(仅 desc_.filters 含 ArrayType
DateRangeEdit* dateRange_ = nullptr; // 采集时间范围筛选(起止双日历,可清空) DateRangeEdit* dateRange_ = nullptr; // 采集时间范围筛选(起止双日历,可清空)
QTreeWidget* list_ = nullptr; QTreeWidget* list_ = nullptr;
QTimer* spinTimer_ = nullptr; // 驱动 busy 行 spinner 旋转(有 busy 行时运行) QTimer* spinTimer_ = nullptr; // 驱动 busy 行 spinner 旋转(有 busy 行时运行)
int spinAngle_ = 0; // 当前 spinner 角度(度) int spinAngle_ = 0; // 当前 spinner 角度(度)
double lastPlaneZ_ = 0.0; // 上次 z 值滑块设定的平面高程(重开 popup 时回显,无则 0直接平移故无防抖
int lastBasemapKind_ = 0; // 上次底图选择0=矢量平面/1=无),重开 popup 时回显
int lastBasemapOpacity_ = 50; // 上次底图透明度0100重开 popup 回显;默认 50
QTimer* basemapOpacityTimer_ = nullptr; // 底图透明度滑块发射防抖(透明度改动会触发瓦片重铺,仍需防抖)
double pendingBasemapOpacity_ = 0.5; // 防抖待发的底图透明度[0,1](定时器到点发射 basemapOpacityChanged
// 单击已选行取消选中toggle-offeventFilter 于按下瞬间(默认改选前)记录被按行及其选中态itemClicked 据此取消。
QTreeWidgetItem* pressedItem_ = nullptr; // 本次左键按下的行itemAt(press),与 itemClicked 的行比对)
bool pressedSelected_ = false; // 按下瞬间该行是否已选中true 且非勾选框点击时,单击取消选中)
bool checkToggledThisPress_ = false; // 本次按下是否切换了勾选框(点勾选框只改渲染,不取消选中)
}; };
} // namespace geopro::app } // namespace geopro::app

View File

@ -1,129 +0,0 @@
#include "panels/columns/Column2DDataset.hpp"
#include <set>
#include <string>
#include <QComboBox>
#include "EmptyAwareComboBox.hpp"
#include <QDoubleSpinBox>
#include <QFormLayout>
#include <QHBoxLayout>
#include <QLabel>
#include <QSignalBlocker>
#include <QTreeWidget>
#include <QTreeWidgetItemIterator>
#include <QVBoxLayout>
#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<int>(&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<int>(&QComboBox::currentIndexChanged), this,
[this, form, zSpin](int idx) {
form->setRowVisible(zSpin, idx == 4); // 整行隐藏(含"Z 值"标签),非自定义时不留孤标签
emit view2DModeChanged(idx);
});
connect(zSpin, qOverload<double>(&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<geopro::data::DsRow>& rows) {
// 增量保留:记住当前已勾选的足迹 ds重建后复原仍存在的项保持勾选。否则对象树每次增删勾选都触发
// 本刷新 → 清空全部勾选 + 上抛空集 → 已渲染足迹被移除、列表选中丢失(用户反馈:必须增量更新,
// 与三维分析段 CategorySection::rebuildList 同一处理)。
std::set<std::string> 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

View File

@ -1,31 +0,0 @@
#pragma once
#include <QWidget>
#include <QStringList>
#include <vector>
#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<geopro::data::DsRow>& 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

View File

@ -1,34 +1,19 @@
#include "panels/columns/ColumnDrawer.hpp" #include "panels/columns/ColumnDrawer.hpp"
#include "panels/columns/Column2DDataset.hpp"
#include "panels/columns/CategoryAnalysisTab.hpp" #include "panels/columns/CategoryAnalysisTab.hpp"
#include <algorithm>
#include "Glyphs.hpp" #include "Glyphs.hpp"
#include "Theme.hpp" #include "Theme.hpp"
#include <QHBoxLayout> #include <QHBoxLayout>
#include <QPushButton> #include <QPushButton>
#include <QResizeEvent>
#include <QTabBar>
#include <QTabWidget>
namespace geopro::app { namespace geopro::app {
ColumnDrawer::ColumnDrawer(QWidget* parent, geopro::data::DatasetFieldDictionary* dict) ColumnDrawer::ColumnDrawer(QWidget* parent, geopro::data::DatasetFieldDictionary* dict)
: QWidget(parent) : QWidget(parent)
{ {
col2D_ = new Column2DDataset(this); // 单列承载:去 QTabWidgetbody_ 直接为 CategoryAnalysisTab含 trajectory 段,二维并入同列)。
analysisTab_ = new CategoryAnalysisTab(dict, this); analysisTab_ = new CategoryAnalysisTab(dict, this);
body_ = analysisTab_;
// 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_);
});
// 折叠按钮:固定宽 18px垂直拉伸。 // 折叠按钮:固定宽 18px垂直拉伸。
// 用 SVG 图标(makeGlyph)而非 ◀/▶ 文字——三角符(U+25C0/25B6)不在 YaHei作按钮文字会触发 // 用 SVG 图标(makeGlyph)而非 ◀/▶ 文字——三角符(U+25C0/25B6)不在 YaHei作按钮文字会触发
@ -58,22 +43,6 @@ ColumnDrawer::ColumnDrawer(QWidget* parent, geopro::data::DatasetFieldDictionary
setMaximumWidth(560); setMaximumWidth(560);
} }
void ColumnDrawer::resizeEvent(QResizeEvent* e)
{
QWidget::resizeEvent(e);
// 两 tab 平分抽屉宽度填满(带样式表的 tab 不响应 setExpanding须按 barWidth/n 显式给宽)。
// 消除旧 3 栏布局遗留的右侧空白——重构成 2 栏后不再三分、留空第三位。
if (auto* tabs = qobject_cast<QTabWidget*>(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() void ColumnDrawer::toggleCollapsed()
{ {
collapsed_ = !collapsed_; collapsed_ = !collapsed_;

View File

@ -2,7 +2,6 @@
#include <QWidget> #include <QWidget>
class QPushButton; class QPushButton;
class QResizeEvent;
namespace geopro::data { namespace geopro::data {
class DatasetFieldDictionary; class DatasetFieldDictionary;
@ -10,36 +9,29 @@ class DatasetFieldDictionary;
namespace geopro::app { namespace geopro::app {
class Column2DDataset;
class CategoryAnalysisTab; class CategoryAnalysisTab;
// VTK视图左侧内嵌抽屉两 tab(三维分析[按数据类型分段]/二维分析) + 折叠开关。 // VTK视图左侧内嵌抽屉单列承载 CategoryAnalysisTab(按数据类型分段,含 trajectory 段) + 折叠开关。
// B2 去 tab原「三维分析 / 二维分析」双 tab 合一,二维(足迹/轨迹)经描述符分段并入同一列。
class ColumnDrawer : public QWidget { class ColumnDrawer : public QWidget {
Q_OBJECT Q_OBJECT
public: public:
explicit ColumnDrawer(QWidget* parent = nullptr, explicit ColumnDrawer(QWidget* parent = nullptr,
geopro::data::DatasetFieldDictionary* dict = nullptr); geopro::data::DatasetFieldDictionary* dict = nullptr);
Column2DDataset* col2D() const { return col2D_; }
CategoryAnalysisTab* analysisTab() const { return analysisTab_; } CategoryAnalysisTab* analysisTab() const { return analysisTab_; }
signals: signals:
// 切换「三维分析 / 二维分析」tabis2D=true 进入二维分析。上层据此切相机+翻维度可见标志。
void analysisModeChanged(bool is2D);
// 折叠/展开切换:上层据此调整 QSplitter 尺寸,回收/恢复抽屉栏宽(否则收起残留空白区)。 // 折叠/展开切换:上层据此调整 QSplitter 尺寸,回收/恢复抽屉栏宽(否则收起残留空白区)。
void collapsedChanged(bool collapsed); void collapsedChanged(bool collapsed);
public slots: public slots:
void toggleCollapsed(); void toggleCollapsed();
void expand(); // 强制展开(进入全屏时确保三栏可见) void expand(); // 强制展开(进入全屏时确保单列可见)
protected:
void resizeEvent(QResizeEvent* e) override; // 两 tab 按抽屉宽平分(消除右侧空白"第三栏位"
private: private:
Column2DDataset* col2D_ = nullptr;
CategoryAnalysisTab* analysisTab_ = nullptr; CategoryAnalysisTab* analysisTab_ = nullptr;
QWidget* body_ = nullptr; // QTabWidget折叠时隐藏 QWidget* body_ = nullptr; // = analysisTab_折叠时隐藏
QPushButton* toggleBtn_ = nullptr; QPushButton* toggleBtn_ = nullptr;
bool collapsed_ = false; bool collapsed_ = false;
}; };

View File

@ -0,0 +1,159 @@
#include "panels/columns/SectionIconBar.hpp"
#include <QAction>
#include <QHBoxLayout>
#include <QMenu>
#include <QResizeEvent>
#include <QSize>
#include <QSizePolicy>
#include <QToolButton>
#include <algorithm>
#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<int>(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<IconAction>& a) {
// 清旧按钮与溢出按钮
for (auto* b : btns_) {
if (b) b->deleteLater();
}
btns_.clear();
if (overflowBtn_) {
overflowBtn_->deleteLater();
overflowBtn_ = nullptr;
}
actions_ = a;
auto* lay = qobject_cast<QHBoxLayout*>(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<int>(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<size_t>(i)];
if (!b) continue;
if (i < vis) {
b->setVisible(true);
continue;
}
b->setVisible(false);
if (!menu) continue;
const IconAction& act = actions_[static_cast<size_t>(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

View File

@ -0,0 +1,47 @@
#pragma once
#include <QWidget>
#include <QSize>
#include <QString>
#include <functional>
#include <vector>
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<void()> onClick; // 直接动作;为空则用 popupBuilder
std::function<void(QToolButton*)> popupBuilder; // 弹 popupz值/底图/筛选用)
};
class SectionIconBar : public QWidget {
Q_OBJECT
public:
explicit SectionIconBar(QWidget* parent = nullptr);
void setActions(const std::vector<IconAction>& 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<IconAction> actions_;
std::vector<QToolButton*> btns_;
QToolButton* overflowBtn_ = nullptr;
int maxIcons_ = 3;
int iconPx_ = 30;
int overflowPx_ = 30;
};
} // namespace geopro::app

View File

@ -3,6 +3,7 @@ add_library(geopro_controller STATIC
WorkbenchNavController.cpp WorkbenchNavController.cpp
DatasetDetailController.cpp DatasetDetailController.cpp
DatasetViewState.cpp DatasetViewState.cpp
DatasetRenderStrategy.cpp
VtkSceneController.cpp) VtkSceneController.cpp)
target_include_directories(geopro_controller PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) target_include_directories(geopro_controller PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
target_link_libraries(geopro_controller PUBLIC geopro_data Qt6::Core) target_link_libraries(geopro_controller PUBLIC geopro_data Qt6::Core)

View File

@ -31,6 +31,10 @@ public slots:
void loadTabPaged(const QString& dsId, const QString& ddCode, int tabIndex, int pageNo, void loadTabPaged(const QString& dsId, const QString& ddCode, int tabIndex, int pageNo,
int pageSize); int pageSize);
void focusDataset(const QString& dsId); 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: signals:
void datasetOpened(const QString& dsId, const QString& ddCode, const QString& dsName, void datasetOpened(const QString& dsId, const QString& ddCode, const QString& dsName,
const QString& tmObjectId, const std::vector<controller::TabSpec>& tabs); const QString& tmObjectId, const std::vector<controller::TabSpec>& tabs);

View File

@ -0,0 +1,98 @@
#include "controller/DatasetRenderStrategy.hpp"
#include <string>
#include <vector>
#include "controller/VtkSceneController.hpp" // 完整类型 + 含 I3dSceneViewview_.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<std::string> 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

View File

@ -0,0 +1,100 @@
#pragma once
#include <functional>
#include <map>
#include <memory>
#include <string>
#include "controller/PlaneZRegistry.hpp"
namespace geopro::controller {
class VtkSceneController; // 策略委托回控制器既有渲染路径add→addDatasetAsync/add2DDatasetAsyncremove→view.removeDataset
// 平面底图抽象:控制器层不依赖 VTK/appgeopro_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<std::unique_ptr<IPlaneBasemap>(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<IDatasetRenderStrategy> 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<std::string, std::unique_ptr<IDatasetRenderStrategy>> strategies_;
};
// 3 策略:各持 VtkSceneController& 引用,把 add/remove 委托回控制器既有渲染路径。
// Volume/Curtain::add 均转调 addDatasetAsync其内部按 isVolumeDataset 自分体/帘面分支);
// Plane2D::add 暂转调 add2DDatasetAsyncPhase 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<std::string, std::string> dsToType_; // dsId→typeId(remove 只得 dsId需自存反查)
// 每 2D 类型一份平面矢量底图(贴该类型平面 z);随平面建/销/升降。键=typeId。
std::map<std::string, std::unique_ptr<IPlaneBasemap>> bms_;
std::map<std::string, int> bmKind_; // 该类型底图选择(0=矢量平面/1=无),重建时复用
std::map<std::string, double> bmOpacity_; // 该类型底图透明度,重建时复用
PlaneBasemapFactory basemapFactory_; // app 注入的底图工厂(空=不建底图)
};
} // namespace geopro::controller

View File

@ -1,5 +1,6 @@
#pragma once #pragma once
#include <string> #include <string>
#include <vector>
#include "model/ColorScale.hpp" #include "model/ColorScale.hpp"
#include "model/Field.hpp" #include "model/Field.hpp"
@ -51,6 +52,9 @@ public:
// 2D 足迹:把测线/轨迹经纬折线平铺进 3D 地图worldZ=摆放高程);按 dsId 跟踪以支持增量移除。 // 2D 足迹:把测线/轨迹经纬折线平铺进 3D 地图worldZ=摆放高程);按 dsId 跟踪以支持增量移除。
virtual void addMapLine(const std::string& dsId, const geopro::data::MapLine& line, virtual void addMapLine(const std::string& dsId, const geopro::data::MapLine& line,
double worldZ) = 0; double worldZ) = 0;
// 直接平移一组 2D 足迹到新平面 z拖 z 值滑块用):改足迹 actor 的 SetPosition无移除+异步重载。
// 仅对属于足迹的 dsId 生效;即时渲染。默认空实现,测试 mock 无需覆盖。
virtual void setMapLinesZ(const std::vector<std::string>& /*dsIds*/, double /*z*/) {}
// 3DDEM 地形 + 影像纹理。 // 3DDEM 地形 + 影像纹理。
virtual void addTerrain(const geopro::data::TerrainPaths& paths) = 0; virtual void addTerrain(const geopro::data::TerrainPaths& paths) = 0;
// 增量移除某数据集的全部图元(取消勾选时调,不影响其余 ds 与底图)。 // 增量移除某数据集的全部图元(取消勾选时调,不影响其余 ds 与底图)。

View File

@ -0,0 +1,45 @@
#pragma once
#include <map>
#include <set>
#include <string>
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<std::string> members;
};
std::map<std::string, Plane> planes_;
};
} // namespace geopro::controller

View File

@ -9,21 +9,46 @@
#include "DatasetViewState.hpp" #include "DatasetViewState.hpp"
#include "I3dSceneView.hpp" #include "I3dSceneView.hpp"
#include "controller/DatasetRenderStrategy.hpp"
#include "repo/CategoryDescriptor.hpp"
#include "repo/IDatasetRepository.hpp" #include "repo/IDatasetRepository.hpp"
namespace geopro::controller { namespace geopro::controller {
namespace {
// 二维足迹「顶部/底部」摆放相对参考高程(Z=0)的偏移(米):控制器无地形/参考高程源
// (地形异步、帘面经纬未必到场),故退化为 Z=0 上/下固定偏移,使足迹不与帘面顶/底面重叠遮挡。
constexpr double kTopOffsetZ = 50.0; // 顶部:参考面上方
constexpr double kBottomOffsetZ = -50.0; // 底部:参考面下方
} // namespace
VtkSceneController::VtkSceneController(data::IDatasetRepository& dsRepo, VtkSceneController::VtkSceneController(data::IDatasetRepository& dsRepo,
data::I3dSceneRepository& sceneRepo, I3dSceneView& view, data::I3dSceneRepository& sceneRepo, I3dSceneView& view,
QObject* parent) 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<VolumeRenderStrategy>(*this));
registry_.registerStrategy("curtain", std::make_unique<CurtainRenderStrategy>(*this));
auto plane2d = std::make_unique<Plane2DRenderStrategy>(*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) { void VtkSceneController::setViewState(DatasetViewState* state) {
state_ = state; state_ = state;
@ -58,111 +83,57 @@ void VtkSceneController::recolorDataset(const QString& qid) {
if (changed) view_.renderIncremental(); if (changed) view_.renderIncremental();
} }
void VtkSceneController::setCheckedDatasets(const QStringList& dsIds) { void VtkSceneController::setCheckedDatasets(
std::vector<std::string> newDs; const std::vector<std::pair<std::string, std::string>>& idType) {
newDs.reserve(static_cast<std::size_t>(dsIds.size())); std::map<std::string, std::string> next; // dsId→typeId
for (const QString& id : dsIds) newDs.push_back(id.toStdString()); for (const auto& p : idType) next[p.first] = p.second;
// 2D 俯视测线:保持全量重建(测线非按 ds 跟踪移除)。 const std::map<std::string, std::string> prev = checked_; // diff 快照(区分新增/移除)
if (mode_ == ViewMode::Map2D) {
checkedDs_ = std::move(newDs); // 移除:旧有新无 → 派该 ds 类型策略 remove活跃计数归零则 onTypeDeactivated。
rebuildInternal(); for (const auto& [id, typeId] : prev)
return; if (!next.count(id)) {
if (auto* s = strategyForType(typeId)) s->remove(id);
if (--typeActive_[typeId] == 0)
if (auto* s = strategyForType(typeId)) s->onTypeDeactivated(typeId);
} }
// 3D增量 diff —— 只处理新增/移除,不全量重建(底图、其余 ds、相机均不动 // 取景意图按「场景是否已有数据到场过」判定(连续快速勾选时 checked_ 已非空但首批未到场,
const std::set<std::string> oldSet(checkedDs_.begin(), checkedDs_.end()); // 不可据 checked_ 空否清取景意图,否则相机不对准数据 → 看似不渲染)。全消时复位基线。
const std::set<std::string> newSet(newDs.begin(), newDs.end());
for (const auto& id : checkedDs_)
if (!newSet.count(id)) view_.removeDataset(id); // 移除:旧有新无 → 仅删该 ds 图元
checkedDs_ = std::move(newDs);
// 取景意图按「场景是否已有数据到场过」判定,而非 checkedDs_ 是否空——否则连续快速勾选第二个
// ds 时 checkedDs_ 已非空但首批尚未到场,会被误清取景意图,相机不对准数据 → 看似不渲染。
fitOnArrival_ = !hadArrivedData_; fitOnArrival_ = !hadArrivedData_;
// 仅当 3D 与 2D 都空才复位取景基线:否则取消全部 3D 但仍有 2D 足迹时,下个 3D 勾选会误跳取景。 if (next.empty()) hadArrivedData_ = false;
if (checkedDs_.empty() && checked2dDs_.empty()) hadArrivedData_ = false;
const unsigned long long gen = rebuildGeneration_; // 不自增:并发增量互不作废 // 先提交勾选集,再派 add异步取数回调以 isChecked() 守「仍勾选?」,同步仓储(测试)会即时回灌,
for (const auto& id : checkedDs_) // 若 add 时 checked_ 未更新则 isChecked 假、回调丢弃 → 不渲染。故必须先 commit 再 add。
if (!oldSet.count(id)) addDatasetAsync(id, gen); // 新增:新有旧无 → 异步取数增量入场 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(); // 立即反映移除 / 触发坐标轴重算 view_.renderIncremental(); // 立即反映移除 / 触发坐标轴重算
} }
void VtkSceneController::setChecked2DDatasets(const QStringList& dsIds) { void VtkSceneController::add2DDatasetAsync(const std::string& dsId, unsigned long long gen,
std::vector<std::string> newDs; double z) {
newDs.reserve(static_cast<std::size_t>(dsIds.size()));
for (const QString& id : dsIds) newDs.push_back(id.toStdString());
// 二维足迹始终画进 View3D 场景,且按 dsId 跟踪 → 一律增量 diff不全量重建不打断 3D 帘面/体)。
const std::set<std::string> oldSet(checked2dDs_.begin(), checked2dDs_.end());
const std::set<std::string> 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) {
if (loadingDs_.count(dsId)) return; // 已在加载(重复勾选竞态)→ 不重复请求 if (loadingDs_.count(dsId)) return; // 已在加载(重复勾选竞态)→ 不重复请求
loadingDs_.insert(dsId); loadingDs_.insert(dsId);
QPointer<VtkSceneController> self(this); QPointer<VtkSceneController> self(this);
sceneRepo_.loadMapLine( sceneRepo_.loadMapLine(
dsId, dsId,
[self, gen, dsId](data::MapLine line) { [self, gen, dsId, z](data::MapLine line) {
if (!self) return; if (!self) return;
self->loadingDs_.erase(dsId); self->loadingDs_.erase(dsId);
// gen 作废 / 已取消勾选 / 摆放已关闭 → 丢弃迟到回调。 // gen 作废 / 已取消勾选 → 丢弃迟到回调。
if (gen != self->rebuildGeneration_ || !self->is2DChecked(dsId) || if (gen != self->rebuildGeneration_ || !self->is2DChecked(dsId)) {
self->placement2dMode_ == 0) {
return; return;
} }
// 落地时按当前摆放 Z非请求时快照→ 加载期间摆放变化也取最新高程 // 足迹摆到所属 2D 类型的平面 z首勾定、后续投影由 Plane2DRenderStrategy 决定)。
self->view_.addMapLine(dsId, line, self->placementZ()); self->view_.addMapLine(dsId, line, z);
self->onDatasetArrived(); self->onDatasetArrived();
}, },
[self, gen, dsId](const std::string& m) { [self, gen, dsId](const std::string& m) {
@ -260,11 +231,11 @@ void VtkSceneController::onDatasetArrived() {
} }
bool VtkSceneController::isChecked(const std::string& dsId) const { 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 { 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) { void VtkSceneController::setViewMode(ViewMode mode) {
@ -272,14 +243,6 @@ void VtkSceneController::setViewMode(ViewMode mode) {
rebuildInternal(); 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) { void VtkSceneController::setLayer(SceneLayer layer, bool on) {
switch (layer) { switch (layer) {
case SceneLayer::Curtain: showCurtain_ = on; break; 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::zoomOut() { view_.zoom(1.0 / 1.2); }
void VtkSceneController::fit() { view_.fitView(); } 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) { const geopro::core::ColorScale& VtkSceneController::colorScale(const std::string& dsId) {
auto it = colorScaleCache_.find(dsId); auto it = colorScaleCache_.find(dsId);
if (it == colorScaleCache_.end()) if (it == colorScaleCache_.end())
@ -380,7 +337,6 @@ const geopro::core::ColorScale& VtkSceneController::colorScale(const std::string
void VtkSceneController::rebuildInternal() { void VtkSceneController::rebuildInternal() {
const unsigned long long gen = ++rebuildGeneration_; // 自增:作废此前所有在途增量回调 const unsigned long long gen = ++rebuildGeneration_; // 自增:作废此前所有在途增量回调
const bool is2D = (mode_ == ViewMode::Map2D);
view_.clear(); // 移除全部数据图元(保留底图)frame 重锚标志复位 view_.clear(); // 移除全部数据图元(保留底图)frame 重锚标志复位
loadingDs_.clear(); // 旧在途加载随之作废(回调按 gen 丢弃) loadingDs_.clear(); // 旧在途加载随之作废(回调按 gen 丢弃)
@ -392,9 +348,6 @@ void VtkSceneController::rebuildInternal() {
// 坏 dsIdloadGrid/loadColorScale 抛异常)= best-effort 跳过emit loadFailed 但不中断。 // 坏 dsIdloadGrid/loadColorScale 抛异常)= best-effort 跳过emit loadFailed 但不中断。
try { try {
if (is2D) {
for (const auto& dsId : checkedDs_) view_.addSurveyLine(grid(dsId));
} else {
// 回调用 QPointer<self> 守对象存活 + gen 守数据新鲜:迟到回调若已析构/作废则丢弃。 // 回调用 QPointer<self> 守对象存活 + gen 守数据新鲜:迟到回调若已析构/作废则丢弃。
QPointer<VtkSceneController> self(this); QPointer<VtkSceneController> self(this);
if (showTerrain_) { if (showTerrain_) {
@ -409,17 +362,16 @@ void VtkSceneController::rebuildInternal() {
emit self->loadFailed(QString::fromStdString(m)); emit self->loadFailed(QString::fromStdString(m));
}); });
} }
for (const auto& dsId : checkedDs_) addDatasetAsync(dsId, gen); // 全量重建clear 已移除全部图元,据统一勾选集经各 ds 类型策略重放 add不动活跃计数
// 二维足迹随全量重建一并重画clear 已移除其图元mode=0 关闭则跳过。 // 已在 setCheckedDatasets 计入;策略 add 内部转调 addDatasetAsync/add2DDatasetAsync
if (placement2dMode_ != 0) for (const auto& [dsId, typeId] : checked_)
for (const auto& dsId : checked2dDs_) add2DDatasetAsync(dsId, gen); if (auto* s = strategyForType(typeId)) s->add(typeId, dsId);
}
} catch (const std::exception& e) { } catch (const std::exception& e) {
emit loadFailed(QString::fromStdString(e.what())); emit loadFailed(QString::fromStdString(e.what()));
} }
// 保留相机重建(改VE):不 ResetCamera原地按新夸张重绘。 // 保留相机重建(改VE):不 ResetCamera原地按新夸张重绘。视图恒三维(is2D=false)。
view_.render(is2D, /*resetCamera=*/!preserveCameraOnRebuild_); view_.render(/*is2D=*/false, /*resetCamera=*/!preserveCameraOnRebuild_);
} }
} // namespace geopro::controller } // namespace geopro::controller

View File

@ -7,8 +7,11 @@
#include <optional> #include <optional>
#include <set> #include <set>
#include <string> #include <string>
#include <utility>
#include <vector>
#include "I3dSceneView.hpp" #include "I3dSceneView.hpp"
#include "controller/DatasetRenderStrategy.hpp"
#include "model/ColorScale.hpp" #include "model/ColorScale.hpp"
#include "model/Field.hpp" #include "model/Field.hpp"
#include "repo/I3dSceneRepository.hpp" #include "repo/I3dSceneRepository.hpp"
@ -21,8 +24,8 @@ namespace geopro::controller {
class DatasetViewState; // 跨视图共享色阶真源(统一同步机制) class DatasetViewState; // 跨视图共享色阶真源(统一同步机制)
// 中央视图模式:二维地图(俯视测线)/ 三维视图(帘面/体素/地形)。 // 中央视图模式:固定三维视图(帘面/体素/地形)。旧二维俯视测线(Map2D)路径已退役main 恒 View3D)。
enum class ViewMode { Map2D, View3D }; enum class ViewMode { View3D };
// 三维图层("视图详情"浮层勾选)。 // 三维图层("视图详情"浮层勾选)。
enum class SceneLayer { Curtain, Voxel, Terrain }; enum class SceneLayer { Curtain, Voxel, Terrain };
@ -34,6 +37,10 @@ enum class SceneLayer { Curtain, Voxel, Terrain };
// 不持有 widget不认 vtkActor/vtkVolume全交给 I3dSceneView // 不持有 widget不认 vtkActor/vtkVolume全交给 I3dSceneView
class VtkSceneController : public QObject { class VtkSceneController : public QObject {
Q_OBJECT Q_OBJECT
// 渲染策略委托回控制器既有路径addDatasetAsync/add2DDatasetAsync/view_友元免widen公有面。
friend class VolumeRenderStrategy;
friend class CurtainRenderStrategy;
friend class Plane2DRenderStrategy;
public: public:
VtkSceneController(data::IDatasetRepository& dsRepo, data::I3dSceneRepository& sceneRepo, VtkSceneController(data::IDatasetRepository& dsRepo, data::I3dSceneRepository& sceneRepo,
I3dSceneView& view, QObject* parent = nullptr); I3dSceneView& view, QObject* parent = nullptr);
@ -42,16 +49,18 @@ public:
// 构造后由 main.cpp 注入一次。 // 构造后由 main.cpp 注入一次。
void setViewState(DatasetViewState* state); 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<std::pair<std::string, std::string>>& idType);
public slots: public slots:
void setCheckedDatasets(const QStringList& dsIds);
// 二维数据集栏勾选(足迹型测线/轨迹)→ 平铺进 View3D 地图。与 3D 勾选集独立,按 dsId 增量。
void setChecked2DDatasets(const QStringList& dsIds);
// 二维足迹摆放高度mode0关闭 /1 Z=0 /2 顶部 /3 底部 /4 自定义customZ 仅 mode=4 用)。
void set2DPlacement(int mode, double customZ);
void setViewMode(ViewMode mode); void setViewMode(ViewMode mode);
// 切「三维分析/二维分析」tabA 期):按目标维度是否已有数据重置取景基线,使切换后该维度第一条
// 数据自动取景。显隐/相机/坐标轴由 VtkSceneView::setAnalysisMode2D 负责(上层在同一处一并调用)。
void onAnalysisModeChanged(bool is2D);
void setLayer(SceneLayer layer, bool on); void setLayer(SceneLayer layer, bool on);
void setVerticalExaggeration(double ve); void setVerticalExaggeration(double ve);
// 三维体透明度调节工具条滑块运行时更新已渲染体的不透明度并作为后续新体默认0~1 // 三维体透明度调节工具条滑块运行时更新已渲染体的不透明度并作为后续新体默认0~1
@ -71,6 +80,12 @@ public slots:
// 坐标轴设置面板「应用」:一次性下发 显示方式 + 单位 + per-axis 可见性/范围(单次重建)。 // 坐标轴设置面板「应用」:一次性下发 显示方式 + 单位 + per-axis 可见性/范围(单次重建)。
void setAxesConfig(AxesMode mode, AxesUnit unit, const AxisRangeCfg& x, const AxisRangeCfg& y, void setAxesConfig(AxesMode mode, AxesUnit unit, const AxisRangeCfg& x, const AxisRangeCfg& y,
const AxisRangeCfg& z); 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 applyView(ViewDir dir); // 6 向快捷视图
void zoomIn(); // Zoom In (×1.2) void zoomIn(); // Zoom In (×1.2)
void zoomOut(); // Zoom Out (×1/1.2) void zoomOut(); // Zoom Out (×1/1.2)
@ -91,28 +106,27 @@ private:
void recolorDataset(const QString& dsId); void recolorDataset(const QString& dsId);
// 增量加入单个 ds帘面/体素,按图层开关);回调按 gen + 仍勾选 守护,落地后增量渲染。 // 增量加入单个 ds帘面/体素,按图层开关);回调按 gen + 仍勾选 守护,落地后增量渲染。
void addDatasetAsync(const std::string& dsId, unsigned long long gen); void addDatasetAsync(const std::string& dsId, unsigned long long gen);
// 增量加入单个 2D 足迹(异步 loadMapLine → addMapLine at 当前摆放 Z回调按 gen + 仍勾选 守护。 // 增量加入单个 2D 足迹(异步 loadMapLine → addMapLine at 摆放 z回调按 gen + 仍勾选 守护。
void add2DDatasetAsync(const std::string& dsId, unsigned long long gen); // z 为该 ds 所属 2D 类型的平面高程(由 Plane2DRenderStrategy 经 PlaneZRegistry 决定§E2
void add2DDatasetAsync(const std::string& dsId, unsigned long long gen, double z);
void onDatasetArrived(); // 单个 ds 落地后:增量渲染 + 首批数据自动取景 void onDatasetArrived(); // 单个 ds 落地后:增量渲染 + 首批数据自动取景
bool isChecked(const std::string& dsId) const; bool isChecked(const std::string& dsId) const;
bool is2DChecked(const std::string& dsId) const; bool is2DChecked(const std::string& dsId) const;
// 当前摆放模式下足迹的世界 Zmode 0=关闭由调用方拦截;此处算 1/2/3/4 的 Z // 按 typeId 查其渲染策略catalog[typeId].renderStrategyId → registry_。未知 typeId 返回 nullptr
double placementZ() const; IDatasetRenderStrategy* strategyForType(const std::string& typeId) const;
data::IDatasetRepository& dsRepo_; data::IDatasetRepository& dsRepo_;
data::I3dSceneRepository& sceneRepo_; data::I3dSceneRepository& sceneRepo_;
I3dSceneView& view_; I3dSceneView& view_;
std::vector<std::string> checkedDs_; // 统一勾选集2D+3D 合一dsId→typeId(描述符 id)。增量 diff 的真源rebuildInternal 据此重放。
// 二维足迹勾选集(与 checkedDs_ 独立;都画进 View3D 场景)。按 dsId 增量加/删。 std::map<std::string, std::string> checked_;
std::vector<std::string> checked2dDs_; // 每 typeId 活跃计数:首勾(0→1)调 onTypeActivated、全消(1→0)调 onTypeDeactivated。
// 二维足迹摆放mode 0关闭/1 Z=0/2顶部/3底部/4自定义customZ2d_ 仅 mode=4 用。 std::map<std::string, int> typeActive_;
// 默认 Z=0(1) 与 Column2DDataset「2D视图」下拉可见默认项一致——避免「下拉显示 Z=0 但 // 渲染策略注册表(构造时注册 volume/curtain/plane2d 三策略,各持本控制器引用)。
// 控制器实为关闭」的初始信号丢失desync(组合框 setCurrentIndex 在 connect 前发射、且 RenderStrategyRegistry registry_;
// 组件早于 main.cpp 接线构造,初始 view2DModeChanged 永不送达),致勾选足迹静默不渲染。 Plane2DRenderStrategy* plane2d_ = nullptr; // registry_ 中 plane2d 策略的裸指针setPlaneZ 免下转型)
int placement2dMode_ = 1; ViewMode mode_ = ViewMode::View3D;
double customZ2d_ = 0.0;
ViewMode mode_ = ViewMode::Map2D;
bool showCurtain_ = true; bool showCurtain_ = true;
bool showVoxel_ = false; bool showVoxel_ = false;
bool showTerrain_ = false; bool showTerrain_ = false;
@ -128,9 +142,8 @@ private:
QPointer<DatasetViewState> state_; // 跨视图色阶真源(注入;编辑/加载/重着色都经它QPointer 防悬挂) QPointer<DatasetViewState> state_; // 跨视图色阶真源(注入;编辑/加载/重着色都经它QPointer 防悬挂)
// 缓存(按 dsId避免重复读盘/插值。 // 缓存(按 dsId避免重复读盘/插值。
std::map<std::string, geopro::core::Grid> gridCache_;
std::map<std::string, geopro::core::ColorScale> colorScaleCache_; std::map<std::string, geopro::core::ColorScale> colorScaleCache_;
// 帘面源网格缓存:帘面重着色需 grid 重建 addCurtainloadSection 的 s.grid 不在 gridCache_)。 // 帘面源网格缓存:帘面重着色需 grid 重建 addCurtainloadSection 的 s.grid 缓存于此)。
std::map<std::string, geopro::core::Grid> sectionGridCache_; std::map<std::string, geopro::core::Grid> sectionGridCache_;
std::map<std::string, data::VolumeGrid> volumeCache_; std::map<std::string, data::VolumeGrid> volumeCache_;
// 三维体色阶缓存mock 体在 dsRepo_ 无条目,色阶随 loadVolume 一起交付并缓存于此。 // 三维体色阶缓存mock 体在 dsRepo_ 无条目,色阶随 loadVolume 一起交付并缓存于此。
@ -147,7 +160,6 @@ private:
// 正在加载的 ds防重复勾选竞态重复请求全量重建时清空。 // 正在加载的 ds防重复勾选竞态重复请求全量重建时清空。
std::set<std::string> loadingDs_; std::set<std::string> loadingDs_;
const geopro::core::Grid& grid(const std::string& dsId);
const geopro::core::ColorScale& colorScale(const std::string& dsId); const geopro::core::ColorScale& colorScale(const std::string& dsId);
}; };

View File

@ -22,6 +22,7 @@
#include "io/gpr/GpsTrack.hpp" #include "io/gpr/GpsTrack.hpp"
#include "io/gpr/IprHeader.hpp" #include "io/gpr/IprHeader.hpp"
#include "io/gpr/IprbReader.hpp" #include "io/gpr/IprbReader.hpp"
#include "io/gpr/LocalPath.hpp"
namespace fs = std::filesystem; namespace fs = std::filesystem;
@ -33,7 +34,7 @@ constexpr double kPi = 3.14159265358979323846;
// 读 .iprh 文本 → 解析头(与 .iprb 同名)。 // 读 .iprh 文本 → 解析头(与 .iprb 同名)。
geopro::io::gpr::IprHeader readHeaderFor(const std::string& iprbPath) { 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); std::ifstream f(h);
if (!f) throw std::runtime_error("GeoVolumeBuilder: 打不开 iprh " + h.string()); if (!f) throw std::runtime_error("GeoVolumeBuilder: 打不开 iprh " + h.string());
std::string text((std::istreambuf_iterator<char>(f)), std::string text((std::istreambuf_iterator<char>(f)),
@ -55,7 +56,7 @@ std::int64_t totalTracesOf(const std::vector<std::string>& iprb, int samples) {
const std::int64_t per = static_cast<std::int64_t>(samples) * 2; const std::int64_t per = static_cast<std::int64_t>(samples) * 2;
if (per <= 0) throw std::runtime_error("samples<=0"); if (per <= 0) throw std::runtime_error("samples<=0");
for (const auto& p : iprb) { for (const auto& p : iprb) {
const std::int64_t bytes = static_cast<std::int64_t>(fs::file_size(p)); const std::int64_t bytes = static_cast<std::int64_t>(fs::file_size(geopro::io::gpr::localPath(p)));
minTr = std::min(minTr, bytes / per); minTr = std::min(minTr, bytes / per);
} }
return minTr; return minTr;
@ -137,7 +138,7 @@ GeoBuildResult buildGeoVolume(const std::vector<GeoLineInput>& lines,
for (const auto& p : tracks[i].pts) for (const auto& p : tracks[i].pts)
sc.trackM.push_back(lonLatToLocalM(p.lat, p.lon, minLat, minLon)); 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); if (!ordF) throw std::runtime_error("buildGeoVolume: 打不开 ord " + lines[i].ord);
std::string ordText((std::istreambuf_iterator<char>(ordF)), std::string ordText((std::istreambuf_iterator<char>(ordF)),
std::istreambuf_iterator<char>()); std::istreambuf_iterator<char>());

View File

@ -5,6 +5,7 @@ add_library(geopro_data STATIC
repo/LocalSampleRepository.cpp repo/LocalSampleRepository.cpp
repo/LocalSample3dRepository.cpp repo/LocalSample3dRepository.cpp
repo/DatasetFieldDictionary.cpp repo/DatasetFieldDictionary.cpp
repo/CategoryDescriptor.cpp
dto/NavDto.cpp dto/NavDto.cpp
dto/Vtk3dRequests.cpp dto/Vtk3dRequests.cpp
dto/DatasetChartDto.cpp dto/DatasetChartDto.cpp

View File

@ -16,6 +16,7 @@
#include "data/store/ChunkedVolumeStore.hpp" #include "data/store/ChunkedVolumeStore.hpp"
#include "io/gpr/GprSurveyAssembler.hpp" #include "io/gpr/GprSurveyAssembler.hpp"
#include "io/gpr/IprHeader.hpp" #include "io/gpr/IprHeader.hpp"
#include "io/gpr/LocalPath.hpp"
namespace geopro::data { namespace geopro::data {
@ -49,7 +50,7 @@ std::string toHeaderPath(const std::string& iprbPath) {
} }
std::string readFileText(const std::string& path) { 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); if (!f) throw std::runtime_error("StreamingVolumeBuilder: 无法打开 " + path);
std::ostringstream ss; std::ostringstream ss;
ss << f.rdbuf(); ss << f.rdbuf();
@ -68,7 +69,7 @@ std::int64_t totalTraces(const std::vector<std::string>& iprb, double& surveyDx)
throw std::runtime_error("StreamingVolumeBuilder: samples<=0"); throw std::runtime_error("StreamingVolumeBuilder: samples<=0");
if (c == 0) surveyDx = h.distanceInterval; if (c == 0) surveyDx = h.distanceInterval;
const std::int64_t bytes = const std::int64_t bytes =
static_cast<std::int64_t>(fs::file_size(fs::path(iprb[c]))); static_cast<std::int64_t>(fs::file_size(geopro::io::gpr::localPath(iprb[c])));
const std::int64_t per = static_cast<std::int64_t>(h.samples) * 2; const std::int64_t per = static_cast<std::int64_t>(h.samples) * 2;
if (per <= 0 || bytes % per != 0) if (per <= 0 || bytes % per != 0)
throw std::runtime_error("StreamingVolumeBuilder: .iprb 字节非整道"); throw std::runtime_error("StreamingVolumeBuilder: .iprb 字节非整道");

View File

@ -256,6 +256,11 @@ bool Api3dRepository::volumeInfo(const std::string& dsId, VolumeInfo& out) const
return true; 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 { void Api3dRepository::appendGridPoints(const core::Grid& g, core::PointSet& pts) const {
const int nx = g.nx(), ny = g.ny(); const int nx = g.nx(), ny = g.ny();
if (nx < 1 || ny < 1 || g.y.size() < static_cast<std::size_t>(ny)) return; if (nx < 1 || ny < 1 || g.y.size() < static_cast<std::size_t>(ny)) return;

View File

@ -78,6 +78,9 @@ public:
}; };
// 取回三维体详情dsId 非三维体返回 false不弹空对话框 // 取回三维体详情dsId 非三维体返回 false不弹空对话框
bool volumeInfo(const std::string& dsId, VolumeInfo& out) const; 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 → 树中挂父体下),供三维分析栏合并。 // 已保存切片的列表行ddCode="dd_slice"parentId=所属体 dsId → 树中挂父体下),供三维分析栏合并。
std::vector<DsRow> sliceRows() const; std::vector<DsRow> sliceRows() const;
// 异常列表行ddCode="dd_anomaly"parentId=remarkSourceId=归属实体[体/切片] dsId → 三级树自动挂载), // 异常列表行ddCode="dd_anomaly"parentId=remarkSourceId=归属实体[体/切片] dsId → 三级树自动挂载),

View File

@ -1,29 +0,0 @@
#pragma once
#include <string>
#include <vector>
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<CategorySpec>& categoryConfigs() {
static const std::vector<CategorySpec> 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

View File

@ -0,0 +1,47 @@
#include "repo/CategoryDescriptor.hpp"
#include <vector>
namespace geopro::data {
std::function<bool(const DsRow&)> byDdCode(std::initializer_list<std::string> codes) {
std::vector<std::string> cs(codes);
return [cs](const DsRow& r) {
for (const auto& c : cs) if (r.ddCode == c) return true;
return false;
};
}
std::function<bool(const DsRow&)> byDsTypeCode(std::initializer_list<std::string> codes) {
std::vector<std::string> cs(codes);
return [cs](const DsRow& r) {
for (const auto& c : cs) if (r.dsTypeCode == c) return true;
return false;
};
}
const std::vector<CategoryDescriptor>& categoryCatalog() {
static const std::vector<CategoryDescriptor> 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

View File

@ -0,0 +1,30 @@
#pragma once
#include <functional>
#include <initializer_list>
#include <string>
#include <vector>
#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<bool(const DsRow&)> classify; // 轴1 数据来源/分类
std::vector<FilterKind> filters; // 轴2 筛选器
std::vector<OpKind> operations; // 轴3 段头图标操作
std::string renderStrategyId; // 轴4 渲染策略键
};
// classify 便捷构造器(常见按 ddCode / dsTypeCode 接入)
std::function<bool(const DsRow&)> byDdCode(std::initializer_list<std::string> codes);
std::function<bool(const DsRow&)> byDsTypeCode(std::initializer_list<std::string> codes);
const std::vector<CategoryDescriptor>& categoryCatalog();
} // namespace geopro::data

View File

@ -1,6 +1,6 @@
add_library(geopro_io_gpr STATIC add_library(geopro_io_gpr STATIC
IprHeader.cpp IprbReader.cpp GprGeometry.cpp GprSurveyAssembler.cpp IprHeader.cpp IprbReader.cpp GprGeometry.cpp GprSurveyAssembler.cpp
GpsTrack.cpp NormalizedRadarReader.cpp GpsTrack.cpp NormalizedRadarReader.cpp LocalPath.cpp
RadarVolumeAssembler.cpp NormalizedRadarVolumeBridge.cpp) RadarVolumeAssembler.cpp NormalizedRadarVolumeBridge.cpp)
target_include_directories(geopro_io_gpr PUBLIC ${CMAKE_SOURCE_DIR}/src) target_include_directories(geopro_io_gpr PUBLIC ${CMAKE_SOURCE_DIR}/src)
target_compile_features(geopro_io_gpr PUBLIC cxx_std_17) target_compile_features(geopro_io_gpr PUBLIC cxx_std_17)

View File

@ -10,6 +10,7 @@
#include "io/gpr/GprGeometry.hpp" #include "io/gpr/GprGeometry.hpp"
#include "io/gpr/IprHeader.hpp" #include "io/gpr/IprHeader.hpp"
#include "io/gpr/IprbReader.hpp" #include "io/gpr/IprbReader.hpp"
#include "io/gpr/LocalPath.hpp"
namespace geopro::io::gpr { namespace geopro::io::gpr {
namespace { namespace {
@ -27,7 +28,7 @@ std::string toHeaderPath(const std::string& iprbPath) {
} }
std::string readFileText(const std::string& path) { std::string readFileText(const std::string& path) {
std::ifstream f(path, std::ios::binary); std::ifstream f(localPath(path), std::ios::binary);
if (!f) { if (!f) {
throw std::runtime_error("无法打开文件: " + path); throw std::runtime_error("无法打开文件: " + path);
} }

View File

@ -7,6 +7,8 @@
#include <sstream> #include <sstream>
#include <stdexcept> #include <stdexcept>
#include "io/gpr/LocalPath.hpp"
namespace geopro::io::gpr { namespace geopro::io::gpr {
namespace { namespace {
@ -26,7 +28,7 @@ bool parseDouble(const std::string& s, double& out) {
} // namespace } // namespace
GpsTrack parseGps(const std::string& path) { GpsTrack parseGps(const std::string& path) {
std::ifstream f(path); std::ifstream f(localPath(path));
if (!f) throw std::runtime_error("parseGps: 打不开 " + path); if (!f) throw std::runtime_error("parseGps: 打不开 " + path);
GpsTrack track; GpsTrack track;

View File

@ -4,10 +4,12 @@
#include <fstream> #include <fstream>
#include <stdexcept> #include <stdexcept>
#include "io/gpr/LocalPath.hpp"
namespace geopro::io::gpr { namespace geopro::io::gpr {
BScan readIprb(const std::string& path, const IprHeader& h) { 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) { if (!f) {
throw std::runtime_error("readIprb: 无法打开文件: " + path); 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, BScan readIprbRange(const std::string& path, const IprHeader& h,
std::int64_t t0, std::int64_t t1) { 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) { if (!f) {
throw std::runtime_error("readIprbRange: 无法打开文件: " + path); throw std::runtime_error("readIprbRange: 无法打开文件: " + path);
} }

24
src/io/gpr/LocalPath.cpp Normal file
View File

@ -0,0 +1,24 @@
#include "io/gpr/LocalPath.hpp"
#ifdef _WIN32
#include <windows.h>
#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<int>(p.size()), nullptr, 0);
if (wlen <= 0) return std::filesystem::path(p); // 退化:原样(ASCII 安全)
std::wstring w(static_cast<std::size_t>(wlen), L'\0');
::MultiByteToWideChar(CP_ACP, 0, p.data(), static_cast<int>(p.size()),
w.data(), wlen);
return std::filesystem::path(w);
#else
return std::filesystem::path(p); // POSIX窄字节即 UTF-8 原生路径
#endif
}
} // namespace geopro::io::gpr

16
src/io/gpr/LocalPath.hpp Normal file
View File

@ -0,0 +1,16 @@
#pragma once
#include <filesystem>
#include <string>
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

View File

@ -1,4 +1,5 @@
#include "io/gpr/NormalizedRadarReader.hpp" #include "io/gpr/NormalizedRadarReader.hpp"
#include "io/gpr/LocalPath.hpp"
#include <cmath> #include <cmath>
#include <cstdint> #include <cstdint>
#include <filesystem> #include <filesystem>
@ -76,11 +77,11 @@ std::vector<std::int16_t> readRadarDataCube(const std::string& dataPath,
const std::size_t n = static_cast<std::size_t>(h.lastTrace) * h.samples; const std::size_t n = static_cast<std::size_t>(h.lastTrace) * h.samples;
const std::uintmax_t expect = static_cast<std::uintmax_t>(n) * 2; const std::uintmax_t expect = static_cast<std::uintmax_t>(n) * 2;
std::error_code ec; 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) if (ec || fsize != expect)
throw std::runtime_error("规范化 .data 大小不符: " + dataPath); throw std::runtime_error("规范化 .data 大小不符: " + dataPath);
std::vector<std::int16_t> cube(n); std::vector<std::int16_t> 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); if (!f) throw std::runtime_error("打开 .data 失败: " + dataPath);
f.read(reinterpret_cast<char*>(cube.data()), static_cast<std::streamsize>(expect)); f.read(reinterpret_cast<char*>(cube.data()), static_cast<std::streamsize>(expect));
if (!f) throw std::runtime_error("读 .data 失败: " + dataPath); if (!f) throw std::runtime_error("读 .data 失败: " + dataPath);

View File

@ -7,6 +7,7 @@
#include <stdexcept> #include <stdexcept>
#include <vector> #include <vector>
#include "io/gpr/LocalPath.hpp"
#include "io/gpr/NormalizedRadarReader.hpp" #include "io/gpr/NormalizedRadarReader.hpp"
#include "io/gpr/RadarVolumeAssembler.hpp" #include "io/gpr/RadarVolumeAssembler.hpp"
@ -20,7 +21,7 @@ geopro::core::BuiltI16 buildLineVolumeFromNormalized(const std::string& lineDir,
std::string headText; std::string headText;
{ {
std::ifstream f(head); std::ifstream f(localPath(head));
if (!f) throw std::runtime_error("打开 .head 失败: " + head); if (!f) throw std::runtime_error("打开 .head 失败: " + head);
std::stringstream ss; std::stringstream ss;
ss << f.rdbuf(); ss << f.rdbuf();

View File

@ -90,6 +90,29 @@ void applyView(vtkRenderer* r, ViewDir dir)
r->ResetCamera(); 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 看 +Yup=+Z
case ViewDir::Back: off[1] = 1; break;
case ViewDir::Left: off[0] = -1; break; // 从 -X 看 +Xup=+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) void zoomBy(vtkRenderer* r, double factor)
{ {
if (!r || factor <= 0.0) return; if (!r || factor <= 0.0) return;

View File

@ -22,6 +22,16 @@ enum class ViewDir { Front, Back, Left, Right, Top, Bottom };
// 应用 6 向正交快捷视图:设 position/focalPoint/viewUp 后 ResetCamera。 // 应用 6 向正交快捷视图:设 position/focalPoint/viewUp 后 ResetCamera。
void applyView(vtkRenderer* r, ViewDir dir); void applyView(vtkRenderer* r, ViewDir dir);
// 绕支点转到某轴的相机位姿纯数学可单测focal=pivotpos=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。 // 相机缩放factor>1 拉近(放大)factor<1 推远(缩小)。透视下改距离、正交下改 parallelScale。
void zoomBy(vtkRenderer* r, double factor); void zoomBy(vtkRenderer* r, double factor);

View File

@ -293,27 +293,6 @@ void InteractionManager::closeAll() {
safeRender(); 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() { void InteractionManager::flipView() {
if (!renderer_) return; if (!renderer_) return;
auto* cam = renderer_->GetActiveCamera(); auto* cam = renderer_->GetActiveCamera();

View File

@ -68,9 +68,6 @@ public:
// 关闭并释放所有切片(切到二维 / 清场 / 体素重建前调)。 // 关闭并释放所有切片(切到二维 / 清场 / 体素重建前调)。
void closeAll(); void closeAll();
// 切二维分析(is2D=true)/三维分析(false):翻所有切片显隐(不销毁,切回零重建) + 锁/解锁交互样式
// (二维=仅平移+缩放、禁旋转)。地形/底图/相机由 VtkSceneView::setAnalysisMode2D 处理,此处不渲染。
void setMode2D(bool is2D);
// 关闭并释放某体下的所有切片(该体移除/重建时;不动其它体的切片)。 // 关闭并释放某体下的所有切片(该体移除/重建时;不动其它体的切片)。
void closeSlicesOfVolume(const std::string& volumeDsId); void closeSlicesOfVolume(const std::string& volumeDsId);
@ -126,10 +123,6 @@ public:
void installStyle(); void installStyle();
void uninstallStyle(); void uninstallStyle();
// 暴露交互样式:供 app 层注入二维分析 B 期的足迹拾取/Z 拖动回调onPick2D/onDrag2D/onDrag2DEnd
// 定义在 .cpp此处 PickInteractorStyle 仅前置声明vtkSmartPointer→裸指针下转需完整类型
PickInteractorStyle* pickStyle() const;
private: private:
// 拾取回调实现PickInteractorStyle 注入)。 // 拾取回调实现PickInteractorStyle 注入)。
void onPicked(const Vec3& worldPoint); // 选中所在切片 + 焦点 void onPicked(const Vec3& worldPoint); // 选中所在切片 + 焦点

View File

@ -1,7 +1,6 @@
#include "interact/PickInteractorStyle.hpp" #include "interact/PickInteractorStyle.hpp"
#include <chrono> #include <chrono>
#include <cmath>
#include <cstring> #include <cstring>
#include <vtkCallbackCommand.h> #include <vtkCallbackCommand.h>
@ -50,22 +49,6 @@ bool PickInteractorStyle::pickWorld(Vec3& out) {
void PickInteractorStyle::OnLeftButtonDown() { void PickInteractorStyle::OnLeftButtonDown() {
auto* iren = this->GetInteractor(); 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; Vec3 world;
const bool hit = pickWorld(world); // 仍用于取选中所需世界点(onPick) const bool hit = pickWorld(world); // 仍用于取选中所需世界点(onPick)
// 命中切片【精确判定】:光标射线穿过某切片真实矩形内才算(不靠带容差的 picker 点)。 // 命中切片【精确判定】:光标射线穿过某切片真实矩形内才算(不靠带容差的 picker 点)。
@ -107,7 +90,6 @@ void PickInteractorStyle::OnLeftButtonDown() {
} }
void PickInteractorStyle::Rotate() { void PickInteractorStyle::Rotate() {
if (lock2D_) return; // 二维分析禁旋转(仅平移+缩放)
if (!this->CurrentRenderer || !hasRotatePivot_) { if (!this->CurrentRenderer || !hasRotatePivot_) {
Superclass::Rotate(); // 无支点 → 默认绕焦点旋转 Superclass::Rotate(); // 无支点 → 默认绕焦点旋转
return; return;
@ -154,49 +136,14 @@ void PickInteractorStyle::Rotate() {
rwi->Render(); 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<double>(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() { 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(); Superclass::OnMouseMove();
} }
void PickInteractorStyle::OnLeftButtonUp() { void PickInteractorStyle::OnLeftButtonUp() {
if (dragging2D_) { // 结束 Z 拖动
dragging2D_ = false;
if (this->Interactor) this->ReleaseFocus();
if (onDrag2DEnd) onDrag2DEnd();
return;
}
// 单击(抬键位移<阈值=非拖动)且按下未命中切片 → 取消选中(点空/点体;体 PickableOff 故点体也 hit=false // 单击(抬键位移<阈值=非拖动)且按下未命中切片 → 取消选中(点空/点体;体 PickableOff 故点体也 hit=false
// 拖空白旋转:抬键位移大 → 不取消,保留"绕选中切片旋转"。Esc 仍是完全拉近时的兜底。 // 拖空白旋转:抬键位移大 → 不取消,保留"绕选中切片旋转"。Esc 仍是完全拉近时的兜底。
if (!lock2D_ && !downHitSlice_ && onDeselect) { if (!downHitSlice_ && onDeselect) {
auto* iren = this->GetInteractor(); auto* iren = this->GetInteractor();
const int* up = iren ? iren->GetEventPosition() : nullptr; const int* up = iren ? iren->GetEventPosition() : nullptr;
if (up) { if (up) {
@ -208,19 +155,13 @@ void PickInteractorStyle::OnLeftButtonUp() {
Superclass::OnLeftButtonUp(); // 平移/旋转/缩放等由基类按 State 收尾 Superclass::OnLeftButtonUp(); // 平移/旋转/缩放等由基类按 State 收尾
} }
namespace {
constexpr double kWheelStepPx = 24.0; // 滚轮一格升降 ≈ 拖动 24 像素的世界 Z 量(与拖动手感一致)
}
void PickInteractorStyle::OnMouseWheelForward() { void PickInteractorStyle::OnMouseWheelForward() {
// 二维分析有选中足迹 → 滚轮抬升其高程(消费滚轮);否则按切片推进 / 默认缩放。 // 有选中切片 → 沿法向推进(消费滚轮);否则默认缩放。
if (lock2D_ && onWheel2D && onWheel2D(worldPerPixelZ() * kWheelStepPx)) return;
if (onWheelStep && onWheelStep(+1)) return; // 有选中切片 → 推进,消费滚轮 if (onWheelStep && onWheelStep(+1)) return; // 有选中切片 → 推进,消费滚轮
Superclass::OnMouseWheelForward(); // 否则默认缩放 Superclass::OnMouseWheelForward(); // 否则默认缩放
} }
void PickInteractorStyle::OnMouseWheelBackward() { void PickInteractorStyle::OnMouseWheelBackward() {
if (lock2D_ && onWheel2D && onWheel2D(-worldPerPixelZ() * kWheelStepPx)) return;
if (onWheelStep && onWheelStep(-1)) return; if (onWheelStep && onWheelStep(-1)) return;
Superclass::OnMouseWheelBackward(); Superclass::OnMouseWheelBackward();
} }

View File

@ -37,20 +37,6 @@ public:
// 点帘面/其它非切片物/边界外 → 返回 false → 单击即取消选中。 // 点帘面/其它非切片物/边界外 → 返回 false → 单击即取消选中。
std::function<bool()> hitTestSlice; std::function<bool()> 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<bool(int x, int y, bool additive)> onPick2D;
std::function<void(double worldDz)> onDrag2D;
std::function<void()> onDrag2DEnd;
// 滚轮升降:有选中足迹时滚轮改其高程 Z(本类按相机算 worldDz)app 施加并返回是否消费(无选中→false→默认缩放)。
std::function<bool(double worldDz)> onWheel2D;
void OnMouseMove() override; void OnMouseMove() override;
void OnLeftButtonUp() override; void OnLeftButtonUp() override;
@ -68,8 +54,6 @@ protected:
private: private:
// 在当前鼠标位置拾取世界点;命中返回 true 并填 out。 // 在当前鼠标位置拾取世界点;命中返回 true 并填 out。
bool pickWorld(Vec3& out); bool pickWorld(Vec3& out);
// 当前相机下:竖向一屏幕像素对应的世界 Z米/像素),用于把拖动像素换算成 Z 增量。
double worldPerPixelZ() const;
// 手动双击判定QVTK+Windows 下 vtkRenderWindowInteractor::GetRepeatCount() 不可靠(评审 M5 // 手动双击判定QVTK+Windows 下 vtkRenderWindowInteractor::GetRepeatCount() 不可靠(评审 M5
// 记上次左键按下时刻+屏幕位置,两次按下间隔 < kDoubleClickMs 且位置相近视为双击。 // 记上次左键按下时刻+屏幕位置,两次按下间隔 < kDoubleClickMs 且位置相近视为双击。
@ -84,13 +68,6 @@ private:
// 选中切片=其中心;否则=光标射线穿过的体中段点。无则 hasRotatePivot_=false→默认绕焦点。 // 选中切片=其中心;否则=光标射线穿过的体中段点。无则 hasRotatePivot_=false→默认绕焦点。
Vec3 rotatePivot_{}; Vec3 rotatePivot_{};
bool hasRotatePivot_ = false; bool hasRotatePivot_ = false;
// 二维分析模式:左键=平移、禁旋转(仅平移+缩放)。由 InteractionManager 在切 tab 时设。
bool lock2D_ = false;
// B 期足迹 Z 拖动状态:左键命中足迹时进入,记上次鼠标 y 以算增量。
bool dragging2D_ = false;
int lastDragY_ = 0;
}; };
} // namespace geopro::render::interact } // namespace geopro::render::interact

View File

@ -59,6 +59,8 @@ target_sources(geopro_tests PRIVATE data/test_nav_request.cpp)
# GprVolumeRepository线 GPR int16 量化体(BuiltI16) app float (VolumeGrid) # GprVolumeRepository线 GPR int16 量化体(BuiltI16) app float (VolumeGrid)
# + 全链(合成多通道 .iprb 走真 P1/P2) VolumeGrid # + 全链(合成多通道 .iprb 走真 P1/P2) VolumeGrid
target_sources(geopro_tests PRIVATE data/test_gpr_volume_repository.cpp) 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) target_link_libraries(geopro_tests PRIVATE geopro_data)
# store ChunkedVolumeStoreGPR round-trip + + # store ChunkedVolumeStoreGPR round-trip + +
@ -170,11 +172,6 @@ target_sources(geopro_tests PRIVATE
app/test_color_scale_io.cpp app/test_color_scale_io.cpp
${CMAKE_SOURCE_DIR}/src/app/ColorScaleIO.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 # splitByCategory: dsTypeCode/ddCode -> 5 Qt/VTK
target_sources(geopro_tests PRIVATE target_sources(geopro_tests PRIVATE
app/test_dataset_category.cpp app/test_dataset_category.cpp
@ -182,6 +179,16 @@ target_sources(geopro_tests PRIVATE
) )
# GS aggregateGsState + dedupeSourcesheader-only # GS aggregateGsState + dedupeSourcesheader-only
target_sources(geopro_tests PRIVATE app/test_object_tree_selection.cpp) target_sources(geopro_tests PRIVATE app/test_object_tree_selection.cpp)
# visibleIconCount spec §6SectionIconBar.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 # measurement / id / / Qt6::Core JSON + core model
target_sources(geopro_tests PRIVATE target_sources(geopro_tests PRIVATE
app/test_scatter_data_ops.cpp app/test_scatter_data_ops.cpp
@ -216,6 +223,10 @@ target_sources(geopro_tests PRIVATE controller/test_dataset_detail_controller.cp
target_sources(geopro_tests PRIVATE controller/test_workbench_nav_controller.cpp) target_sources(geopro_tests PRIVATE controller/test_workbench_nav_controller.cpp)
# VtkSceneController fake repo + fake view × add / # VtkSceneController fake repo + fake view × add /
target_sources(geopro_tests PRIVATE controller/test_vtk_scene_controller.cpp) target_sources(geopro_tests PRIVATE controller/test_vtk_scene_controller.cpp)
# RenderStrategyRegistryregister/getFakeStrategy
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) target_link_libraries(geopro_tests PRIVATE geopro_controller Qt6::Test)
# io/gpr .iprh + .iprb B-scan C++17 Qt/VTK # io/gpr .iprh + .iprb B-scan C++17 Qt/VTK

View File

@ -1,6 +1,7 @@
#include <gtest/gtest.h> #include <gtest/gtest.h>
#include "DatasetDetailTab.hpp" #include "DatasetDetailTab.hpp"
#include "IDatasetChartStrategy.hpp" // geopro::controller控制器层 #include "IDatasetChartStrategy.hpp" // geopro::controller控制器层
#include "panels/chart/ErtInversionStrategy.hpp"
#include "panels/chart/MeasurementStrategy.hpp" #include "panels/chart/MeasurementStrategy.hpp"
#include "panels/chart/GrMeasurementStrategy.hpp" #include "panels/chart/GrMeasurementStrategy.hpp"
#include "panels/chart/TrajectoryStrategy.hpp" #include "panels/chart/TrajectoryStrategy.hpp"
@ -39,6 +40,29 @@ TEST(ChartStrategyRegistry, ExposesTabSpecsFromStrategy) {
EXPECT_EQ(tabs[1].kind, ViewKind::FilledContour); EXPECT_EQ(tabs[1].kind, ViewKind::FilledContour);
} }
// T4 双击详情联动 gate 契约决策6DatasetDetailController::supports() 直接委托本注册表的 supports()。
// 三维体dd_voxel/dd_radar_3d等无详情页策略 → supports()=false → 双击只适配、静默不开面板;
// 5 种已注册类型 supports()=true → 双击联动打开中下方详情页。此测锁定这条 gate 的真实注册集行为。
TEST(ChartStrategyRegistry, T4GateSilentForVolumeTypesSupportsRegistered) {
ChartStrategyRegistry reg;
reg.add(std::make_unique<geopro::app::ErtInversionStrategy>());
reg.add(std::make_unique<geopro::app::MeasurementStrategy>());
reg.add(std::make_unique<geopro::app::GrMeasurementStrategy>());
reg.add(std::make_unique<geopro::app::TrajectoryStrategy>());
reg.add(std::make_unique<geopro::app::GridStrategy>());
// 有详情页 → 双击联动打开中下方详情面板。
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) { TEST(MeasurementStrategy, DrivesTwoTabsScatterAndTable) {
geopro::app::MeasurementStrategy s; geopro::app::MeasurementStrategy s;
EXPECT_EQ(s.ddCode(), "dd_ert_measurement_data"); EXPECT_EQ(s.ddCode(), "dd_ert_measurement_data");

View File

@ -1,5 +1,6 @@
#include <gtest/gtest.h> #include <gtest/gtest.h>
#include "DatasetCategory.hpp" #include "DatasetCategory.hpp"
#include "repo/CategoryDescriptor.hpp"
using geopro::data::DsRow; using geopro::data::DsRow;
using namespace geopro::app; using namespace geopro::app;
@ -23,7 +24,8 @@ TEST(SplitByCategory, RoutesByDsTypeCodeAndDdCode) {
row("x", "dd_ert_measurement_gr_data", "earth resistance"), // 接地电阻 → 丢弃 row("x", "dd_ert_measurement_gr_data", "earth resistance"), // 接地电阻 → 丢弃
}; };
const CategoryBuckets b = splitByCategory(rows); 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[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[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"); EXPECT_EQ(b.segments[2].size(), 1u); EXPECT_EQ(b.segments[2][0].id, "c");

View File

@ -1,38 +0,0 @@
#include <gtest/gtest.h>
#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<DsRow> 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());
}

View File

@ -0,0 +1,24 @@
#include <gtest/gtest.h>
#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);
}

View File

@ -0,0 +1,38 @@
#include <gtest/gtest.h>
#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);
}

View File

@ -0,0 +1,22 @@
#include <gtest/gtest.h>
#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<FakeStrategy>());
auto* s = reg.get("fake");
ASSERT_NE(s, nullptr);
s->add("trajectory", "d1");
EXPECT_EQ(static_cast<FakeStrategy*>(s)->added, 1);
EXPECT_EQ(reg.get("missing"), nullptr);
}

View File

@ -203,26 +203,27 @@ struct FakeSceneRepo : data::I3dSceneRepository {
} // namespace } // namespace
// 2D 模式 + 勾选 1 ds → 1 个测线 actor无帘面/体素/地形。 // B2 后勾选统一入口 = (dsId, typeId=描述符 id) 列表。便捷构造:电阻率(curtain)/三维体(volume)/轨迹(plane2d)。
TEST(VtkSceneController, Map2DWithOneDatasetAddsSurveyLine) { namespace {
FakeDsRepo ds; FakeSceneRepo sc; FakeView view; using IdType = std::vector<std::pair<std::string, std::string>>;
VtkSceneController c(ds, sc, view); IdType curtainIds(std::initializer_list<std::string> ids) {
c.setViewMode(ViewMode::Map2D); IdType v;
c.setCheckedDatasets({"ds1"}); for (const auto& id : ids) v.push_back({id, "resistivity"}); // resistivity → renderStrategyId "curtain"
return v;
EXPECT_EQ(view.surveyLines, 1);
EXPECT_EQ(view.curtains, 0);
EXPECT_EQ(view.volumes, 0);
EXPECT_GE(view.renders, 1);
EXPECT_TRUE(view.lastIs2D);
} }
IdType voxelIds(std::initializer_list<std::string> 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) { TEST(VtkSceneController, View3DCurtainAddsCurtain) {
FakeDsRepo ds; FakeSceneRepo sc; FakeView view; FakeDsRepo ds; FakeSceneRepo sc; FakeView view;
VtkSceneController c(ds, sc, view); VtkSceneController c(ds, sc, view);
c.setViewMode(ViewMode::View3D); c.setViewMode(ViewMode::View3D);
c.setCheckedDatasets({"ds1"}); c.setCheckedDatasets(curtainIds({"ds1"}));
EXPECT_EQ(view.curtains, 1); EXPECT_EQ(view.curtains, 1);
EXPECT_EQ(view.surveyLines, 0); EXPECT_EQ(view.surveyLines, 0);
@ -235,7 +236,7 @@ TEST(VtkSceneController, View3DVolumeDatasetAddsVolume) {
sc.volumeIds = {"ds1"}; // ds1 = 三维体类型 → 体素渲染路径 sc.volumeIds = {"ds1"}; // ds1 = 三维体类型 → 体素渲染路径
VtkSceneController c(ds, sc, view); VtkSceneController c(ds, sc, view);
c.setViewMode(ViewMode::View3D); c.setViewMode(ViewMode::View3D);
c.setCheckedDatasets({"ds1"}); c.setCheckedDatasets(voxelIds({"ds1"}));
EXPECT_EQ(view.volumes, 1); EXPECT_EQ(view.volumes, 1);
EXPECT_EQ(view.curtains, 0); // 体素数据集不再同时出帘面 EXPECT_EQ(view.curtains, 0); // 体素数据集不再同时出帘面
@ -247,18 +248,18 @@ TEST(VtkSceneController, View3DWithTerrainAddsTerrain) {
VtkSceneController c(ds, sc, view); VtkSceneController c(ds, sc, view);
c.setViewMode(ViewMode::View3D); c.setViewMode(ViewMode::View3D);
c.setLayer(SceneLayer::Terrain, true); c.setLayer(SceneLayer::Terrain, true);
c.setCheckedDatasets({"ds1"}); c.setCheckedDatasets(curtainIds({"ds1"}));
EXPECT_EQ(view.terrains, 1); EXPECT_EQ(view.terrains, 1);
EXPECT_EQ(view.curtains, 1); EXPECT_EQ(view.curtains, 1);
} }
// 取消勾选 → 增量移除该 ds 图元(不整场 clear3D 增量路径)。 // 取消勾选 → 增量移除该 ds 图元(不整场 clear增量路径)。
TEST(VtkSceneController, UncheckRemovesDatasetIncrementally) { TEST(VtkSceneController, UncheckRemovesDatasetIncrementally) {
FakeDsRepo ds; FakeSceneRepo sc; FakeView view; FakeDsRepo ds; FakeSceneRepo sc; FakeView view;
VtkSceneController c(ds, sc, view); VtkSceneController c(ds, sc, view);
c.setViewMode(ViewMode::View3D); c.setViewMode(ViewMode::View3D);
c.setCheckedDatasets({"ds1"}); c.setCheckedDatasets(curtainIds({"ds1"}));
ASSERT_EQ(view.curtains, 1); ASSERT_EQ(view.curtains, 1);
const int clearsAfterCheck = view.clears; const int clearsAfterCheck = view.clears;
@ -273,11 +274,11 @@ TEST(VtkSceneController, IncrementalAddKeepsExisting) {
FakeDsRepo ds; FakeSceneRepo sc; FakeView view; FakeDsRepo ds; FakeSceneRepo sc; FakeView view;
VtkSceneController c(ds, sc, view); VtkSceneController c(ds, sc, view);
c.setViewMode(ViewMode::View3D); c.setViewMode(ViewMode::View3D);
c.setCheckedDatasets({"ds1"}); c.setCheckedDatasets(curtainIds({"ds1"}));
const int clearsAfterFirst = view.clears; const int clearsAfterFirst = view.clears;
ASSERT_EQ(view.curtains, 1); ASSERT_EQ(view.curtains, 1);
c.setCheckedDatasets({"ds1", "ds2"}); // 增量加 ds2 c.setCheckedDatasets(curtainIds({"ds1", "ds2"})); // 增量加 ds2
EXPECT_EQ(view.curtains, 2); EXPECT_EQ(view.curtains, 2);
EXPECT_EQ(view.clears, clearsAfterFirst); // 不重建 → 无新 clear EXPECT_EQ(view.clears, clearsAfterFirst); // 不重建 → 无新 clear
} }
@ -288,7 +289,7 @@ TEST(VtkSceneController, VerticalExaggerationForwarded) {
VtkSceneController c(ds, sc, view); VtkSceneController c(ds, sc, view);
c.setViewMode(ViewMode::View3D); c.setViewMode(ViewMode::View3D);
c.setVerticalExaggeration(3.5); c.setVerticalExaggeration(3.5);
c.setCheckedDatasets({"ds1"}); c.setCheckedDatasets(curtainIds({"ds1"}));
EXPECT_DOUBLE_EQ(view.ve, 3.5); EXPECT_DOUBLE_EQ(view.ve, 3.5);
} }
@ -297,7 +298,7 @@ TEST(VtkSceneController, MultipleDatasetsAddMultipleCurtains) {
FakeDsRepo ds; FakeSceneRepo sc; FakeView view; FakeDsRepo ds; FakeSceneRepo sc; FakeView view;
VtkSceneController c(ds, sc, view); VtkSceneController c(ds, sc, view);
c.setViewMode(ViewMode::View3D); c.setViewMode(ViewMode::View3D);
c.setCheckedDatasets({"ds1", "ds2", "ds3"}); c.setCheckedDatasets(curtainIds({"ds1", "ds2", "ds3"}));
EXPECT_EQ(view.curtains, 3); EXPECT_EQ(view.curtains, 3);
} }
@ -309,7 +310,7 @@ TEST(VtkSceneController, SetVolumeColorScaleRebuildsCheckedVolume) {
sc.volumeIds = {"ds1"}; sc.volumeIds = {"ds1"};
VtkSceneController c(ds, sc, view); VtkSceneController c(ds, sc, view);
c.setViewMode(ViewMode::View3D); c.setViewMode(ViewMode::View3D);
c.setCheckedDatasets({"ds1"}); c.setCheckedDatasets(voxelIds({"ds1"}));
ASSERT_EQ(view.volumes, 1); ASSERT_EQ(view.volumes, 1);
const int removesBefore = view.removeCalls; const int removesBefore = view.removeCalls;
@ -330,7 +331,7 @@ TEST(VtkSceneController, SetVolumeColorScalePersistsAcrossRecheck) {
sc.volumeIds = {"ds1"}; sc.volumeIds = {"ds1"};
VtkSceneController c(ds, sc, view); VtkSceneController c(ds, sc, view);
c.setViewMode(ViewMode::View3D); c.setViewMode(ViewMode::View3D);
c.setCheckedDatasets({"ds1"}); // 加载体(填充 volumeCache_ c.setCheckedDatasets(voxelIds({"ds1"})); // 加载体(填充 volumeCache_
ASSERT_EQ(view.lastVolumeScale.stopCount(), 2u); // 初始两段 ASSERT_EQ(view.lastVolumeScale.stopCount(), 2u); // 初始两段
core::ColorScale edited; // 编辑成三段 core::ColorScale edited; // 编辑成三段
@ -341,7 +342,7 @@ TEST(VtkSceneController, SetVolumeColorScalePersistsAcrossRecheck) {
ASSERT_EQ(view.lastVolumeScale.stopCount(), 3u); ASSERT_EQ(view.lastVolumeScale.stopCount(), 3u);
c.setCheckedDatasets({}); // 取消勾选 c.setCheckedDatasets({}); // 取消勾选
c.setCheckedDatasets({"ds1"}); // 再勾选 → 命中缓存(含编辑后色阶) c.setCheckedDatasets(voxelIds({"ds1"})); // 再勾选 → 命中缓存(含编辑后色阶)
EXPECT_EQ(view.lastVolumeScale.stopCount(), 3u); EXPECT_EQ(view.lastVolumeScale.stopCount(), 3u);
} }
@ -409,146 +410,56 @@ TEST(VtkSceneController, ZoomAndFitForwarded) {
EXPECT_EQ(view.fitCalls, 1); EXPECT_EQ(view.fitCalls, 1);
} }
// ── 二维数据集视图:足迹平铺进 View3D ── // ── 二维数据集(轨迹/足迹)经 plane2d 策略平铺进场景 ──
// B2去 col2D + setChecked2DDatasets/set2DPlacement 公有入口2D 与 3D 合一经统一入口
// 显式关闭摆放(0) → 勾选 2D 足迹不渲染(仅记录勾选)。 // setCheckedDatasets((dsId, typeId))。trajectory 描述符 → "plane2d" 策略 → add2DDatasetAsync。
TEST(VtkSceneController, TwoDPlacementOffDoesNotRender) { // 摆放暂固定默认(Z=0);置/底/自定义 + analysisMode 取景基线相关用例随旧入口移除Phase E/F 重接后补)。
FakeDsRepo ds; FakeSceneRepo sc; FakeView view; namespace {
VtkSceneController c(ds, sc, view); IdType trajIds(std::initializer_list<std::string> ids) {
c.setViewMode(ViewMode::View3D); IdType v;
c.set2DPlacement(0, 0.0); // 显式关闭 for (const auto& id : ids) v.push_back({id, "trajectory"}); // trajectory → renderStrategyId "plane2d"
c.setChecked2DDatasets({"traj1"}); return v;
EXPECT_EQ(view.mapLines, 0);
} }
} // namespace
// 回归(足迹默认不渲染 bug):默认摆放=Z=0(1),与 2D视图下拉可见默认项一致 → // 勾选轨迹(plane2d 策略) → 1 条 mapLine默认摆放 worldZ=0不影响帘面/体素计数。
// 仅勾选 2D 足迹(不手动调 set2DPlacement即应在 View3D 渲染worldZ=0。 TEST(VtkSceneController, TrajectoryRendersAsMapLineAtDefaultZero) {
TEST(VtkSceneController, TwoDDefaultPlacementRendersAtZeroOnCheck) {
FakeDsRepo ds; FakeSceneRepo sc; FakeView view; FakeDsRepo ds; FakeSceneRepo sc; FakeView view;
VtkSceneController c(ds, sc, view); VtkSceneController c(ds, sc, view);
c.setViewMode(ViewMode::View3D); c.setViewMode(ViewMode::View3D);
c.setChecked2DDatasets({"traj1"}); // 不调 set2DPlacement依赖默认摆放 c.setCheckedDatasets(trajIds({"traj1"}));
EXPECT_EQ(view.mapLines, 1);
EXPECT_DOUBLE_EQ(view.lastMapLineZ, 0.0);
}
// 摆放 Z=0(1) + 勾选足迹 → 1 条 mapLineworldZ=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"});
EXPECT_EQ(view.mapLines, 1); EXPECT_EQ(view.mapLines, 1);
EXPECT_DOUBLE_EQ(view.lastMapLineZ, 0.0); EXPECT_DOUBLE_EQ(view.lastMapLineZ, 0.0);
EXPECT_EQ(view.curtains, 0); EXPECT_EQ(view.curtains, 0);
EXPECT_EQ(view.volumes, 0); EXPECT_EQ(view.volumes, 0);
} }
// 顶部/底部摆放锚定真实地表高程worldZ = zRefElev ± 偏移(而非世界 0 ± 偏移)。 // 取消勾选轨迹 → 增量移除该足迹图元(不整场 clear
TEST(VtkSceneController, TwoDPlacementTopBottomAnchorToSurfaceElev) { TEST(VtkSceneController, TrajectoryUncheckRemovesMapLine) {
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) {
FakeDsRepo ds; FakeSceneRepo sc; FakeView view; FakeDsRepo ds; FakeSceneRepo sc; FakeView view;
VtkSceneController c(ds, sc, view); VtkSceneController c(ds, sc, view);
c.setViewMode(ViewMode::View3D); c.setViewMode(ViewMode::View3D);
c.set2DPlacement(1, 0.0); c.setCheckedDatasets(trajIds({"traj1"}));
c.setChecked2DDatasets({"traj1"});
ASSERT_EQ(view.mapLines, 1); ASSERT_EQ(view.mapLines, 1);
const int clearsBefore = view.clears; const int clearsBefore = view.clears;
c.setChecked2DDatasets({}); // 取消勾选 c.setCheckedDatasets({}); // 取消勾选
EXPECT_EQ(view.mapLines, 0); EXPECT_EQ(view.mapLines, 0);
EXPECT_EQ(view.clears, clearsBefore); // 增量移除,不整场 clear EXPECT_EQ(view.clears, clearsBefore); // 增量移除,不整场 clear
} }
// 2D 足迹与 3D 帘面共存且独立:勾选剖面 + 足迹,各出各的图元,互不影响 // 轨迹足迹与 3D 帘面经同一入口共存且独立:各出各的图元,取消足迹不影响帘面。
TEST(VtkSceneController, TwoDFootprintCoexistsWith3DCurtain) { TEST(VtkSceneController, TrajectoryCoexistsWith3DCurtain) {
FakeDsRepo ds; FakeSceneRepo sc; FakeView view; FakeDsRepo ds; FakeSceneRepo sc; FakeView view;
VtkSceneController c(ds, sc, view); VtkSceneController c(ds, sc, view);
c.setViewMode(ViewMode::View3D); c.setViewMode(ViewMode::View3D);
c.setCheckedDatasets({"prof1"}); // 3D 帘面 IdType both = curtainIds({"prof1"});
c.set2DPlacement(1, 0.0); both.push_back({"traj1", "trajectory"});
c.setChecked2DDatasets({"traj1"}); // 2D 足迹 c.setCheckedDatasets(both); // 帘面 + 足迹(统一入口并集)
EXPECT_EQ(view.curtains, 1); EXPECT_EQ(view.curtains, 1);
EXPECT_EQ(view.mapLines, 1); EXPECT_EQ(view.mapLines, 1);
c.setChecked2DDatasets({}); // 取消足迹 → 帘面不受影响 c.setCheckedDatasets(curtainIds({"prof1"})); // 仅留帘面 → 足迹移除
EXPECT_EQ(view.mapLines, 0); EXPECT_EQ(view.mapLines, 0);
EXPECT_EQ(view.curtains, 1); 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);
}

View File

@ -258,6 +258,31 @@ TEST(Api3dRepo, RegisterRadarDatasetRoutesAsDdRadar3d) {
std::filesystem::remove_all(dir, ec); 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<geopro::core::GeoLocalFrame>(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 → 同步交付;全量测试中若其它用例已建 // loadVolume首次勾选时懒建雷达体无 QCoreApplication → 同步交付;全量测试中若其它用例已建
// QCoreApplication 单例则走异步processEvents 排空队列交付)。回调收到有效 VolumeGrid。 // QCoreApplication 单例则走异步processEvents 排空队列交付)。回调收到有效 VolumeGrid。
TEST(Api3dRepo, LoadVolumeBuildsRadarLazily) { TEST(Api3dRepo, LoadVolumeBuildsRadarLazily) {

View File

@ -0,0 +1,35 @@
#include <gtest/gtest.h>
#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");
}

View File

@ -1,11 +1,29 @@
#include <gtest/gtest.h> #include <gtest/gtest.h>
#include <clocale>
#include <cstdint> #include <cstdint>
#include <filesystem> #include <filesystem>
#include <fstream> #include <fstream>
#include <string>
#include "core/algo/GprVolumeBuilder.hpp" #include "core/algo/GprVolumeBuilder.hpp"
#include "io/gpr/NormalizedRadarVolumeBridge.hpp" #include "io/gpr/NormalizedRadarVolumeBridge.hpp"
#ifdef _WIN32
#include <windows.h>
#endif
namespace fs = std::filesystem; namespace fs = std::filesystem;
namespace {
// 在指定目录写出一组最小可解析的规范化 .head/.dataK=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<std::int16_t>(t * 10 + c * 100 + s);
f.write(reinterpret_cast<const char*>(&v), 2); } }
}
} // namespace
TEST(NormalizedRadarBridge, BuildsVolumeWithExpectedAxes) { TEST(NormalizedRadarBridge, BuildsVolumeWithExpectedAxes) {
// K=4 道, M=2 通道, N=3 采样, 无通道偏移(不插值), coarse=1。 // K=4 道, M=2 通道, N=3 采样, 无通道偏移(不插值), coarse=1。
fs::path dir = fs::temp_directory_path() / "radar_bridge_test"; 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_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 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<int>(w.size()), nullptr, 0,
nullptr, nullptr);
std::string s(static_cast<std::size_t>(n), '\0');
::WideCharToMultiByte(CP_ACP, 0, w.data(), static_cast<int>(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
}

View File

@ -139,6 +139,73 @@ TEST(CameraPreset, ZoomInOrthoReducesParallelScale) {
EXPECT_LT(c->GetParallelScale(), before); 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
// Toppos 在 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);
}
// Bottompos 在 -Zup=+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 在 -Yup=+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);
}
// Backpos 在 +Yup=+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 在 -Xup=+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);
}
// Rightpos 在 +Xup=+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 安全。 // 空指针/非法 factor 安全。
TEST(CameraPreset, NullAndInvalidAreSafe) { TEST(CameraPreset, NullAndInvalidAreSafe) {
applyView(nullptr, ViewDir::Top); applyView(nullptr, ViewDir::Top);